From 223e17688394c5f102e28082c8a54b035f5ed711 Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:31:00 -0700 Subject: [PATCH 01/29] Add cooperative UI turns for concurrent winapp ui agents Concurrent winapp.exe processes share one Windows desktop, so a command could steal focus or dismiss another workflow's transient UI even though the existing foreground guards stopped wrong-window injection. Add an owner-aware coordination service so desktop-driving commands take turns. Coordinator (Services/InteractiveDesktop): - Owner identity: WINAPP_UI_OWNER_ID, else the immediate parent process, else a one-command anonymous owner. Only a domain-separated SHA-256 key is persisted; the raw value never reaches disk, logs, or telemetry. - state.lock, atomically published state.json with schema versioning and unknown-field preservation, active.lock, and DeleteOnClose participant leases scoped per user and Windows session. - A pure, clock-injected scheduler implementing the forward barrier, FIFO promotion, four-second idle grace, and handoff. - Liveness is proven only by a held lease plus PID/start match: there are no heartbeats, so a suspended process keeps its place in the queue. Command integration: - New two-phase UiCoordinatedAction: all local validation runs in Preflight, so a malformed command never opens a lease, takes a ticket, or joins the queue. - All 21 ui commands declare a mode. active.lock is held only across the desktop-sensitive section, never across output formatting, PNG encoding or file publication. - Every DesktopExclusive command resolves and validates the HWND, PID and element it acts on inside that section, so nothing acts on state captured before an unbounded queue wait. - IDesktopForegroundService is now the only path to SetForegroundWindow and window restore. - send-keys no longer focuses its --target before foreground validation. - record is TurnShared so same-owner input interleaves; screenshot starts observational and escalates the whole invocation, discarding buffered captures and recapturing from the beginning. Also: privacy-minimized bucketed coordination telemetry, npm AbortSignal threaded through both spawn helpers, and documentation across the UI guide, usage, telemetry, JSON envelope reference, shipped skill, agent guidance, sample, and npm README. Refs #764 --- docs/npm-usage.md | 692 +++++++++++++++++ docs/telemetry.md | 2 + docs/ui-automation.md | 57 ++ docs/usage.md | 17 + plugins/winapp/agents/winapp.agent.md | 9 + .../skills/winapp-ui-automation/SKILL.md | 37 + .../references/ui-json-envelope.md | 58 ++ samples/winui-app/README.md | 4 + .../FakeDesktopForegroundService.cs | 29 + .../FakeInteractiveDesktopLock.cs | 92 +++ .../WinApp.Cli.Tests/FakeUiServices.cs | 31 +- .../WinApp.Cli.Tests/GestureTargetingTests.cs | 6 +- .../InteractiveDesktopLockTests.cs | 311 ++++++++ .../InteractiveDesktopMultiprocessTests.cs | 495 ++++++++++++ .../InteractiveDesktopSchedulerTests.cs | 703 ++++++++++++++++++ .../InteractiveDesktopStoreTests.cs | 412 ++++++++++ .../RealUiAutomationTests.Capture.cs | 18 +- .../RealUiAutomationTests.Coverage.cs | 16 +- .../RealUiAutomationTests.Patterns.cs | 4 +- .../RealUiAutomationTests.Record.cs | 26 +- .../WinApp.Cli.Tests/RealUiAutomationTests.cs | 4 +- .../UiAutomationServicePureTests.cs | 57 +- .../UiCommandTests.Coordination.cs | 336 +++++++++ .../UiCommandTests.Record.Stdin.cs | 2 +- .../WinApp.Cli.Tests/UiCommandTests.cs | 8 + .../WinApp.Cli/Commands/UiClickCommand.cs | 159 ++-- .../WinApp.Cli/Commands/UiDragCommand.cs | 184 +++-- .../WinApp.Cli/Commands/UiFocusCommand.cs | 51 +- .../Commands/UiGetFocusedCommand.cs | 20 +- .../Commands/UiGetPropertyCommand.cs | 21 +- .../WinApp.Cli/Commands/UiGetValueCommand.cs | 22 +- .../WinApp.Cli/Commands/UiHoverCommand.cs | 108 ++- .../WinApp.Cli/Commands/UiInspectCommand.cs | 24 +- .../WinApp.Cli/Commands/UiInvokeCommand.cs | 74 +- .../Commands/UiListWindowsCommand.cs | 14 +- .../WinApp.Cli/Commands/UiPenCommand.cs | 150 ++-- .../WinApp.Cli/Commands/UiRecordCommand.cs | 88 ++- .../Commands/UiScreenshotCommand.cs | 247 ++++-- .../WinApp.Cli/Commands/UiScrollCommand.cs | 160 ++-- .../Commands/UiScrollIntoViewCommand.cs | 22 +- .../WinApp.Cli/Commands/UiSearchCommand.cs | 24 +- .../WinApp.Cli/Commands/UiSendKeysCommand.cs | 394 ++++++---- .../WinApp.Cli/Commands/UiSetValueCommand.cs | 30 +- .../WinApp.Cli/Commands/UiStatusCommand.cs | 20 +- .../WinApp.Cli/Commands/UiTouchCommand.cs | 151 +++- .../WinApp.Cli/Commands/UiWaitForCommand.cs | 39 +- .../Helpers/DesktopTargetValidation.cs | 74 ++ .../Helpers/HostBuilderExtensions.cs | 10 + .../Helpers/IDesktopForegroundService.cs | 72 ++ .../Helpers/PointerCommandSupport.cs | 8 +- .../WinApp.Cli/Helpers/UiCoordinatedAction.cs | 85 +++ .../WinApp.Cli/Helpers/UiJsonContext.cs | 23 + .../WinApp.Cli/Helpers/UiJsonError.cs | 20 +- src/winapp-CLI/WinApp.Cli/NativeMethods.txt | 5 + src/winapp-CLI/WinApp.Cli/Program.cs | 5 + .../Services/IUiAutomationService.cs | 23 +- .../IInteractiveDesktopLock.cs | 72 ++ .../InteractiveDesktop/IMonotonicClock.cs | 33 + .../InteractiveDesktopJsonContext.cs | 28 + .../InteractiveDesktopLock.cs | 645 ++++++++++++++++ .../InteractiveDesktopPaths.cs | 302 ++++++++ .../InteractiveDesktopScheduler.cs | 534 +++++++++++++ .../InteractiveDesktopState.cs | 179 +++++ .../InteractiveDesktopStateStore.cs | 401 ++++++++++ .../InteractiveDesktop/NullDesktopSection.cs | 34 + .../InteractiveDesktop/ParticipantRegistry.cs | 188 +++++ .../InteractiveDesktop/ProcessInspector.cs | 180 +++++ .../UiCoordinationOutputMode.cs | 54 ++ .../UiCoordinationTelemetryScope.cs | 52 ++ .../InteractiveDesktop/UiCoordinationTypes.cs | 121 +++ .../UiCoordinationWaitReporter.cs | 82 ++ .../InteractiveDesktop/UiOwnerResolver.cs | 124 +++ .../Services/InteractiveDesktop/UiTurnMode.cs | 75 ++ .../Services/UiAutomationService.Record.cs | 37 +- .../UiAutomationService.Screenshot.cs | 115 ++- .../Services/UiAutomationService.cs | 13 +- .../Telemetry/Events/CommandCompletedEvent.cs | 40 + src/winapp-npm/README.md | 29 + src/winapp-npm/scripts/generate-commands.mjs | 16 +- src/winapp-npm/src/ui-record-guard.ts | 16 +- src/winapp-npm/src/winapp-cli-utils.ts | 40 +- src/winapp-npm/src/winapp-commands.ts | 18 +- src/winapp-npm/test/abort-signal.test.ts | 161 ++++ 83 files changed, 8635 insertions(+), 707 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/FakeDesktopForegroundService.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Helpers/DesktopTargetValidation.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Helpers/IDesktopForegroundService.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Helpers/UiCoordinatedAction.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IMonotonicClock.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopJsonContext.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopState.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopStateStore.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/NullDesktopSection.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ParticipantRegistry.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ProcessInspector.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationOutputMode.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTelemetryScope.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationWaitReporter.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiOwnerResolver.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiTurnMode.cs create mode 100644 src/winapp-npm/test/abort-signal.test.ts diff --git a/docs/npm-usage.md b/docs/npm-usage.md index 2a4bf7dd9..743c40395 100644 --- a/docs/npm-usage.md +++ b/docs/npm-usage.md @@ -44,6 +44,14 @@ Base options shared by most commands. | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `WinappResult` @@ -77,6 +85,14 @@ function azSign(options: AzSignOptions): Promise | `profile` | `string \| undefined` | No | Certificate profile name. Must be used with --account | | `resourceGroup` | `string \| undefined` | No | Resource group to narrow down signing accounts | | `subscription` | `string \| undefined` | No | Azure subscription ID to use. If not provided and multiple subscriptions exist, you will be prompted. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -103,6 +119,14 @@ function certGenerate(options?: CertGenerateOptions): Promise | `password` | `string \| undefined` | No | Password for the generated PFX file | | `publisher` | `string \| undefined` | No | Publisher distinguished name (DN) for the generated certificate (e.g., CN=MyCompany or OU=Team, O=Corp, C=US). If not specified, will be inferred from manifest. Bare names are auto-wrapped as CN=. | | `validDays` | `number \| undefined` | No | Number of days the certificate is valid | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -123,6 +147,14 @@ function certInfo(options: CertInfoOptions): Promise | `certPath` | `string` | Yes | Path to the certificate file (PFX) | | `json` | `boolean \| undefined` | No | Format output as JSON | | `password` | `string \| undefined` | No | Password for the PFX file | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -143,6 +175,14 @@ function certInstall(options: CertInstallOptions): Promise | `certPath` | `string` | Yes | Path to the certificate file (PFX or CER) | | `force` | `boolean \| undefined` | No | Force installation even if the certificate already exists | | `password` | `string \| undefined` | No | Password for the PFX file | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -164,6 +204,14 @@ function createDebugIdentity(options?: CreateDebugIdentityOptions): Promise |----------|------|----------|-------------| | `target` | `string` | Yes | Path to the .exe (embeds identity into its side-by-side manifest via mt.exe) or an .xml/.manifest side-by-side manifest file (inserts/replaces the element; created if it doesn't exist). | | `manifest` | `string \| undefined` | No | Path to the sparse appxmanifest.xml to read identity from. When omitted, searched in a 'sparse/' folder (where 'winapp init --exe --sparse' writes it by default) beside the target first, then in the current directory, then beside the target and in the current directory. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -230,6 +294,14 @@ function findUi(options?: FindUiOptions): Promise | `max` | `number \| undefined` | No | Maximum number of matched controls to return. Applies to search only; ignored with --list and --id. | | `refresh` | `boolean \| undefined` | No | Bypass the local cache and re-fetch the WinUI corpus from GitHub. | | `source` | `string \| undefined` | No | Restrict results to a single source: gallery (WinUI 3 Gallery), toolkit (Windows Community Toolkit), reactor (microsoft-ui-reactor, C#-only declarative WinUI), or core (curated patterns). Reactor is opt-in — it is excluded from a normal search, so pass --source reactor to search it (only do this for a Reactor/MVU project; its C#-only samples don't paste into a standard XAML app). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -248,6 +320,14 @@ function getWinappPath(options?: GetWinappPathOptions): Promise | Property | Type | Required | Description | |----------|------|----------|-------------| | `global` | `boolean \| undefined` | No | Get the global .winapp directory instead of local | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -278,6 +358,14 @@ function init(options?: InitOptions): Promise | `setupSdks` | `SdkInstallMode \| undefined` | No | SDK installation mode: 'stable' (default), 'preview', 'experimental', or 'none' (skip SDK installation) | | `sparse` | `boolean \| undefined` | No | Generate a sparse identity manifest (appxmanifest.xml) for an existing desktop exe instead of a full package manifest. Use with --exe. Skips SDK/package installation. | | `useDefaults` | `boolean \| undefined` | No | Skip interactive prompts and use default answers. Normal init targets the positional project directory if given, otherwise the current directory (e.g., winapp init . --use-defaults). Sparse init (--exe --sparse) ignores the positional directory and writes to --output-dir instead. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -298,6 +386,14 @@ function manifestAddAlias(options?: ManifestAddAliasOptions): Promise). Accepts any valid X.500 DN; bare names are auto-wrapped as CN=. | | `template` | `ManifestTemplates \| undefined` | No | Manifest template type: 'packaged' (full MSIX app, default) or 'sparse' (desktop app with package identity for Windows APIs) | | `version` | `string \| undefined` | No | App version in Major.Minor.Build.Revision format (e.g., 1.0.0.0). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -344,6 +448,14 @@ function manifestUpdateAssets(options: ManifestUpdateAssetsOptions): Promise | `template` | `string \| undefined` | No | Template short name (e.g. winui, winui-navview, winui-mvvm, winui-lib, winui-unittest). Run 'winapp new --list' to see all. | | `templateVersion` | `string \| undefined` | No | WinUI template pack version: 'latest' (install newest), 'installed' (keep what's installed), or an explicit version. Default: install latest if none, else prompt to update a stale pack. | | `useDefaults` | `boolean \| undefined` | No | Do not prompt; use defaults (blank template, name from --output/--name, keep installed templates). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -398,6 +518,14 @@ function packageApp(options: PackageOptions): Promise | `publisher` | `string \| undefined` | No | Publisher distinguished name (DN) for certificate generation (e.g., CN=MyCompany). Bare names are auto-wrapped as CN=. | | `selfContained` | `boolean \| undefined` | No | Bundle Windows App SDK runtime for self-contained deployment | | `skipPri` | `boolean \| undefined` | No | Skip PRI file generation | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -417,6 +545,14 @@ function restore(options?: RestoreOptions): Promise |----------|------|----------|-------------| | `baseDirectory` | `string \| undefined` | No | Base/root directory for the winapp workspace | | `configDir` | `string \| undefined` | No | Directory to read configuration from (default: current directory) | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -457,6 +593,14 @@ function run(options?: RunOptions): Promise | `unregisterOnExit` | `boolean \| undefined` | No | Unregister the development package after the application exits. Only removes packages registered in development mode. | | `withAlias` | `boolean \| undefined` | No | Launch the app using its execution alias instead of AUMID activation. The app runs in the current terminal with inherited stdin/stdout/stderr. Requires a uap5:ExecutionAlias in the manifest. Use "winapp manifest add-alias" to add an execution alias to the manifest. | | `appArgs` | `string \| string[] \| undefined` | No | Arguments to pass to the launched application (forwarded after --). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -478,6 +622,14 @@ function sign(options: SignOptions): Promise | `certPath` | `string` | Yes | Path to the certificate file (PFX format) | | `password` | `string \| undefined` | No | Certificate password | | `timestamp` | `string \| undefined` | No | Timestamp server URL | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -496,6 +648,14 @@ function store(options?: StoreOptions): Promise | Property | Type | Required | Description | |----------|------|----------|-------------| | `storeArgs` | `string \| string[] \| undefined` | No | Arguments to pass through to the Microsoft Store Developer CLI. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -514,6 +674,14 @@ function tool(options?: ToolOptions): Promise | Property | Type | Required | Description | |----------|------|----------|-------------| | `toolArgs` | `string \| string[] \| undefined` | No | Arguments to pass to the SDK tool, e.g. ['makeappx', 'pack', '/d', './folder', '/p', './out.msix']. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -537,6 +705,14 @@ function uiClick(options?: UiClickOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `right` | `boolean \| undefined` | No | Perform a right-click instead of a left click | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -562,6 +738,14 @@ function uiDrag(options?: UiDragOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `right` | `boolean \| undefined` | No | Drag with the right mouse button instead of the left button | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -583,6 +767,14 @@ function uiFocus(options?: UiFocusOptions): Promise | `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -603,6 +795,14 @@ function uiGetFocused(options?: UiGetFocusedOptions): Promise | `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -625,6 +825,14 @@ function uiGetProperty(options?: UiGetPropertyOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `property` | `string \| undefined` | No | Property name to read or filter on | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -646,6 +854,14 @@ function uiGetValue(options?: UiGetValueOptions): Promise | `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -668,6 +884,14 @@ function uiHover(options?: UiHoverOptions): Promise | `dwellTime` | `number \| undefined` | No | Time in milliseconds to wait after hovering for hover effects to appear (default: 800) | | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -694,6 +918,14 @@ function uiInspect(options?: UiInspectOptions): Promise | `interactive` | `boolean \| undefined` | No | Show only interactive/invokable elements (buttons, links, inputs, list items). Increases default depth to 8. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -715,6 +947,14 @@ function uiInvoke(options?: UiInvokeOptions): Promise | `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -735,6 +975,14 @@ function uiListWindows(options?: UiListWindowsOptions): Promise | `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `showHidden` | `boolean \| undefined` | No | Include untitled zero-size windows that are hidden by default | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -763,6 +1011,14 @@ function uiPen(options?: UiPenOptions): Promise | `tiltX` | `number \| undefined` | No | Pen tilt along the x-axis in degrees (-90 to 90, default: 0). | | `tiltY` | `number \| undefined` | No | Pen tilt along the y-axis in degrees (-90 to 90, default: 0). | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -787,6 +1043,14 @@ function uiScreenshot(options?: UiScreenshotOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `output` | `string \| undefined` | No | Save output to this file path. | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -811,6 +1075,14 @@ function uiScroll(options?: UiScrollOptions): Promise | `to` | `string \| undefined` | No | Scroll to position: top, bottom | | `wheel` | `number \| undefined` | No | Rotate the mouse wheel over the element by this many notches (1 = one notch up, -1 = one notch down). Synthesizes real wheel input instead of using ScrollPattern. | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -832,6 +1104,14 @@ function uiScrollIntoView(options?: UiScrollIntoViewOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `max` | `number \| undefined` | No | Maximum search results | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -879,6 +1167,14 @@ function uiSendKeys(options?: UiSendKeysOptions): Promise | `verbatim` | `boolean \| undefined` | No | Type the entire keys argument as literal text — no named-key, combo, or vk= interpretation, and exact whitespace preserved. The whole-argument form of the per-token text= escape: --verbatim "down down enter" types the words instead of pressing Down, Down, Enter. | | `via` | `string \| undefined` | No | Transport: post-message (default, HWND-targeted, bypasses UIPI; typed text raises TextChanged but not a per-character KeyDown) or send-input (OS-wide; typed text raises a real per-character KeyDown + TextChanged). Named keys and combos raise KeyDown on both, but keyboard accelerators/shortcuts (KeyboardAccelerator, e.g. ctrl+t) only fire via send-input. post-message targets the focused child control and works for classic Win32/WinForms controls, but WinUI 3 / UWP / XAML controls are windowless and ignore posted messages — use send-input for those (a warning is emitted when the target looks like a XAML app). | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -901,6 +1197,14 @@ function uiSetValue(options?: UiSetValueOptions): Promise | `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -921,6 +1225,14 @@ function uiStatus(options?: UiStatusOptions): Promise | `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -950,6 +1262,14 @@ function uiTouch(options?: UiTouchOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `toPoint` | `string \| undefined` | No | End point x,y for a swipe (screen coordinates). Takes precedence over --direction. | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -976,6 +1296,14 @@ function uiWaitFor(options?: UiWaitForOptions): Promise | `timeout` | `number \| undefined` | No | Timeout in milliseconds | | `value` | `string \| undefined` | No | Wait for element value to equal this string. Uses smart fallback (TextPattern -> ValuePattern -> Name). Combine with --property to check a specific property instead. | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -996,6 +1324,14 @@ function unregister(options?: UnregisterOptions): Promise | `force` | `boolean \| undefined` | No | Skip the install-location directory check and unregister even if the package was registered from a different project tree | | `json` | `boolean \| undefined` | No | Format output as JSON | | `manifest` | `string \| undefined` | No | Path to the Package.appxmanifest (default: auto-detect from current directory) | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -1014,6 +1350,14 @@ function update(options?: UpdateOptions): Promise | Property | Type | Required | Description | |----------|------|----------|-------------| | `setupSdks` | `SdkInstallMode \| undefined` | No | SDK installation mode: 'stable' (default), 'preview', 'experimental', or 'none' (skip SDK installation) | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -1263,6 +1607,16 @@ Re-exported from Node.js for convenience. See [Node.js docs](https://nodejs.org/ | Property | Type | Required | Description | |----------|------|----------|-------------| | `exitOnError` | `boolean \| undefined` | No | | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +On Windows, Node force-terminates the child, so the CLI's own cleanup may not run. That is safe: +Windows closes the process's coordination file handles and deletes its `DeleteOnClose` participant +lease, and other `winapp ui` processes prune the entry through lease and PID/start validation. +If the abort lands after the command acquired the desktop, UI side effects may already have +happened, and aborting an active recording can leave partial or invalid output — this wrapper does +not promise graceful MP4 finalization. + +Rejects with an `AbortError`. | ### `CallWinappCliResult` @@ -1275,6 +1629,8 @@ Re-exported from Node.js for convenience. See [Node.js docs](https://nodejs.org/ | Property | Type | Required | Description | |----------|------|----------|-------------| | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()) | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation. See {@link CallWinappCliOptions.signal} for the exact +contract, including what is and is not guaranteed after an abort. | ### `CallWinappCliCaptureResult` @@ -1367,6 +1723,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `CertGenerateOptions` @@ -1384,6 +1748,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `CertInfoOptions` @@ -1395,6 +1767,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `CertInstallOptions` @@ -1406,6 +1786,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `CreateDebugIdentityOptions` @@ -1418,6 +1806,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `CreateExternalCatalogOptions` @@ -1432,6 +1828,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `EmbedIdentityOptions` @@ -1442,6 +1846,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `FindUiOptions` @@ -1457,6 +1869,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `GetWinappPathOptions` @@ -1466,6 +1886,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `InitOptions` @@ -1487,6 +1915,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `ManifestAddAliasOptions` @@ -1498,6 +1934,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `ManifestGenerateOptions` @@ -1515,6 +1959,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `ManifestUpdateAssetsOptions` @@ -1526,6 +1978,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `NewOptions` @@ -1542,6 +2002,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `PackageOptions` @@ -1562,6 +2030,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `RestoreOptions` @@ -1572,6 +2048,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `RunOptions` @@ -1603,6 +2087,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `SignOptions` @@ -1615,6 +2107,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `StoreOptions` @@ -1624,6 +2124,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `ToolOptions` @@ -1633,6 +2141,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UiClickOptions` @@ -1647,6 +2163,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UiDragOptions` @@ -1663,6 +2187,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UiFocusOptions` @@ -1675,6 +2207,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UiGetFocusedOptions` @@ -1686,6 +2226,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UiGetPropertyOptions` @@ -1699,6 +2247,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UiGetValueOptions` @@ -1711,6 +2267,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UiHoverOptions` @@ -1724,6 +2288,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UiInspectOptions` @@ -1741,6 +2313,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UiInvokeOptions` @@ -1753,6 +2333,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UiListWindowsOptions` @@ -1764,6 +2352,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UiPenOptions` @@ -1783,6 +2379,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UiScreenshotOptions` @@ -1798,6 +2402,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UiScrollOptions` @@ -1813,6 +2425,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UiScrollIntoViewOptions` @@ -1825,6 +2445,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UiSearchOptions` @@ -1838,6 +2466,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UiSendKeysOptions` @@ -1854,6 +2490,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UiSetValueOptions` @@ -1867,6 +2511,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UiStatusOptions` @@ -1878,6 +2530,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UiTouchOptions` @@ -1898,6 +2558,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UiWaitForOptions` @@ -1915,6 +2583,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UnregisterOptions` @@ -1926,6 +2602,14 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | ### `UpdateOptions` @@ -1935,4 +2619,12 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. + +`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another +workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may +not run, but Windows releases its coordination handles and deletes its participant lease, and +other processes reclaim the queue entry. If the abort lands after the command acquired the +desktop, UI side effects may already have happened, and aborting an active recording can leave +partial output. Rejects with an `AbortError`. | diff --git a/docs/telemetry.md b/docs/telemetry.md index 76ee4f7de..20743f8a3 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -85,6 +85,7 @@ The telemetry feature collects the following data: | CI environment | A boolean flag indicating whether the CLI is running in a Continuous Integration environment. | | Caller | The value of the `WINAPP_CLI_CALLER` environment variable, if set. This allows wrapper tools (like the npm package) to identify themselves. | | `find-ui` usage | For the `winapp find-ui` command only, an additional usage event with non-personal, bounded values: the mode (`search`, `fetch`, or `list`); the selected `--source` (a fixed value — `gallery`, `toolkit`, `reactor`, or `core`); the catalog scenario IDs fetched (e.g. `gallery-tabview-1`), which identify built-in WinUI sample controls, never your code; whether `--json` was used; and result/ID counts. The free-form search query is **never** collected, and any requested IDs that don't match a real catalog entry are counted but **not** collected as text. | +| `winapp ui` desktop coordination | For `winapp ui` commands only, a privacy-minimized summary of how the command shared the desktop with other UI workflows: how the workflow identity was resolved (`Explicit`, `Parent`, or `Anonymous` — never the identity itself), the coordination mode (`Observe`, `TurnShared`, or `DesktopExclusive`), how the turn was obtained (new, continuation, queued, handoff-after-idle, or detached), the outcome (completed, cancelled, coordination failure, or corruption recovery), and **coarse buckets** for wait time, queue depth, and turn age. | ### Sanitization of sensitive data @@ -94,6 +95,7 @@ The winapp CLI takes several measures to protect your privacy: - **Implicit values** (default values that weren't explicitly provided) are not collected. - **Parsing errors** are logged as `[error]` without including the actual erroneous input. - All string values in telemetry events undergo **sensitive string replacement** before transmission, which replaces any registered sensitive strings with anonymized tokens. +- **Desktop coordination** never collects the `WINAPP_UI_OWNER_ID` value or its hash, process IDs, process or application names, window titles, selectors, element text, queue contents, or any part of the coordination state files. Durations and counts are reported only as fixed buckets (for example `1000-4999`), never as exact values. ## Crash exception telemetry diff --git a/docs/ui-automation.md b/docs/ui-automation.md index 32c684ece..3cda6a655 100644 --- a/docs/ui-automation.md +++ b/docs/ui-automation.md @@ -31,6 +31,63 @@ winapp ui invoke Close -a notepad winapp ui screenshot -a notepad ``` +## Coordinating concurrent UI workflows + +Windows has only one foreground window, one keyboard focus, one cursor, and one input stream. When +two `winapp ui` workflows run on the same signed-in desktop at once, they can steal focus from each +other, dismiss a menu the other just opened, or move a target out from under a pending click. + +`winapp ui` coordinates automatically: commands that need the physical desktop take **cooperative +turns**, and read-only commands keep running concurrently. There is nothing to enable — but set one +environment variable per logical workflow so the CLI can tell your commands apart from someone +else's. + +```powershell +# Set once per logical UI workflow +$env:WINAPP_UI_OWNER_ID = [guid]::NewGuid().ToString() +``` + +What you need to know: + +- **Explicit identity overrides parent identity.** `WINAPP_UI_OWNER_ID` names one logical workflow — + not necessarily a whole agent, and not necessarily one app. Use the *same* value for cooperating + processes (a recording plus the clicks it should capture); use *different* values for independent + workflows, even when one agent launches both. +- **Direct scripts work automatically.** With no variable set, commands are grouped by the shell or + script that launched them, so a normal `.ps1` or `.cmd` needs no setup. +- **Fresh-shell and adaptive hosts must inject the same value.** If each command runs in a new shell + — which is how most agent tool calls work — parent identity cannot group them. Pass the same + explicit `WINAPP_UI_OWNER_ID` into every cooperating call. +- **The four-second grace protects tight bursts, not model reasoning.** A workflow keeps its turn as + long as the next command starts within four seconds. That covers back-to-back commands in one + script; it intentionally expires while a model is thinking. +- **Adaptive workflows must reacquire, revalidate, and replay.** After a reasoning gap another + workflow may have used the desktop, so reopen the menu, re-resolve the element, and then act. + Send known end-to-end sequences as one tight script rather than holding the desktop while you think. +- **There is no hard cap.** A live workflow can hold the desktop indefinitely, which means a long + script, an unbounded recording, or a failure loop can block other mutating workflows. +- **Cancellation or process termination is the recovery** for a stuck live workflow. Waiting commands + print a status after one second and can be stopped with `Ctrl+C`, which exits `130`. +- **Only compatible updated binaries cooperate.** Older `winapp` builds predate this feature and are + not coordinated. + +Which commands wait for a turn: + +| Behavior | Commands | +|---|---| +| Runs concurrently (never waits) | `status`, `list-windows`, `inspect`, `search`, `get-property`, `get-value`, `get-focused`, `wait-for`, `set-value`, `scroll-into-view`, `scroll --direction`/`--to`, plain `screenshot` | +| Claims the turn, shares it with the same workflow | `record` | +| Claims the turn and takes the desktop exclusively | `invoke`, `click`, `drag`, `hover`, `scroll --wheel`, `touch`, `pen`, `focus`, `send-keys`, `screenshot --focus`/`--capture-screen` | + +A plain `screenshot` starts as a concurrent capture and escalates to an exclusive turn only if a +target turns out to be minimized or renders blank, in which case it re-captures everything from the +beginning so the saved image is never a mix of before and after. + +Errors you may see: `invalid_ui_owner_id` (the variable is set but empty or over 256 characters), +`desktop_coordination_unavailable` (coordination state is unreadable and cannot be safely rebuilt, or +was written by a newer `winapp`), `queue_capacity_exceeded` (64 commands are already waiting), and +`cancelled` (Ctrl+C while waiting, exit code `130`). + ## Targeting Apps ### By process name diff --git a/docs/usage.md b/docs/usage.md index fde5e16a7..0e7a6de89 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1432,6 +1432,23 @@ To make this permanent: [System.Environment]::SetEnvironmentVariable('WINAPP_CLI_UPDATE_CHECK', '0', 'User') ``` +### UI workflow identity + +`winapp ui` commands that drive the physical desktop take cooperative turns, so two workflows running +at once cannot steal each other's focus or dismiss each other's menus. To tell the CLI which commands +belong to the same logical workflow, set `WINAPP_UI_OWNER_ID` once per workflow: + +```pwsh +$env:WINAPP_UI_OWNER_ID = [guid]::NewGuid().ToString() +``` + +Use the *same* value for cooperating processes (for example a recording and the clicks it should +capture) and *different* values for independent workflows. Commands issued directly from one shell or +script are grouped automatically and need no variable; hosts that start a fresh shell per command +must inject the same explicit value into each one. The value is opaque, is never treated as a +credential, and is only ever persisted as a SHA-256 hash. See +[UI Automation → Coordinating concurrent UI workflows](ui-automation.md#coordinating-concurrent-ui-workflows). + ### ui Inspect and interact with running Windows app UIs using UI Automation (UIA). diff --git a/plugins/winapp/agents/winapp.agent.md b/plugins/winapp/agents/winapp.agent.md index 3f3c36771..f5218e55b 100644 --- a/plugins/winapp/agents/winapp.agent.md +++ b/plugins/winapp/agents/winapp.agent.md @@ -75,6 +75,15 @@ Want to inspect or interact with a running app's UI? ├─ Inject pen/stylus ink stroke or tap → winapp ui pen -a --path "10,10 200,200" └─ List app windows → winapp ui list-windows -a [--show-hidden] +Driving a UI while other workflows may be running? +├─ Set $env:WINAPP_UI_OWNER_ID once per logical workflow, and inject the SAME value into every +│ cooperating call — each tool call usually gets a fresh shell, which otherwise looks like a +│ different workflow +├─ A workflow keeps the desktop for 4s after its last command; that covers a tight script but +│ intentionally expires while you reason +└─ After a reasoning gap, reopen/re-navigate and re-resolve before acting — another workflow may + have used the desktop, so transient UI (menus, flyouts) is gone + Building a WinUI 3 UI and need to find the right control or a working sample? └─ winapp find-ui "" (search WinUI 3 Gallery + Community Toolkit; Reactor is opt-in via --source reactor) ├─ Then fetch full code for a match → winapp find-ui --id diff --git a/plugins/winapp/skills/winapp-ui-automation/SKILL.md b/plugins/winapp/skills/winapp-ui-automation/SKILL.md index dcac034e1..2051e516a 100644 --- a/plugins/winapp/skills/winapp-ui-automation/SKILL.md +++ b/plugins/winapp/skills/winapp-ui-automation/SKILL.md @@ -12,6 +12,43 @@ description: Inspect and interact with running Windows app UIs from the command ## Prerequisites - For UIA mode (any app): No setup needed — works with any running Windows app - For input-injecting verbs (`click`, `hover`, `drag`, `touch`, `pen`, `scroll --wheel`, `send-keys --via send-input`): an **unlocked, interactive desktop** with the target window foregroundable. On a locked/secure desktop they fail fast with `no_interactive_desktop`. The UIA-pattern verbs (`inspect`, `search`, `get-*`, `wait-for`, `set-value`, `invoke`, `scroll --direction/--to`, `screenshot`) are headless/locked-session friendly — prefer them in CI. +- **If other UI workflows may run at the same time**, set one owner id per logical workflow (see below). Nothing breaks without it, but your commands will not be recognized as belonging together. + +## Coordinating with other UI workflows + +Windows has one foreground window, one keyboard focus, one cursor, and one input stream. `winapp ui` +therefore makes desktop-driving commands take **cooperative turns** so concurrent workflows cannot +steal each other's focus or dismiss each other's menus. Read-only commands never wait. + +```powershell +# Set once per logical UI workflow — same value for cooperating calls, different values for +# independent workflows (even from the same agent). +$env:WINAPP_UI_OWNER_ID = [guid]::NewGuid().ToString() +``` + +Rules that matter when driving this from an agent: + +- **Each tool call usually gets a fresh shell**, so parent-process grouping will NOT hold your + commands together. Inject the *same* `WINAPP_UI_OWNER_ID` into every cooperating call. +- **A workflow keeps its turn for four seconds** after its last command. That covers back-to-back + commands in one script; it deliberately expires while you are reasoning. +- **After a reasoning gap, replay your setup.** Another workflow may have used the desktop, so + reopen the menu / re-navigate, re-resolve the element, then act. Do not assume transient UI + survived. +- **Prefer one tight script over many round trips** for a known sequence: `winapp ui invoke View -w + $hwnd; winapp ui search "Status bar" -w $hwnd; winapp ui click "Status bar" -w $hwnd`. +- **`record` shares the turn with its own workflow**, so same-owner clicks and typing are captured + while it runs. +- **Waiting is indefinite and cancellable.** A status line appears after one second; Ctrl+C exits + `130` with error code `cancelled` and the command never ran. +- **There is no hard cap** — a long script, unbounded recording, or failure loop can block other + mutating workflows until it finishes or is stopped. + +Commands that never wait: `status`, `list-windows`, `inspect`, `search`, `get-*`, `wait-for`, +`set-value`, `scroll-into-view`, `scroll --direction`/`--to`, and a plain `screenshot`. +Commands that take a turn: `record` (shared) and `invoke`, `click`, `drag`, `hover`, +`scroll --wheel`, `touch`, `pen`, `focus`, `send-keys`, `screenshot --focus`/`--capture-screen` +(exclusive). ## Common patterns diff --git a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md index a8a0cf866..af3aa154b 100644 --- a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md +++ b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md @@ -85,3 +85,61 @@ like a label inside a button). The internal `id`, `parentSelector`, and `windowHandle` fields are **scrubbed** from results — both at the top level and inside any nested `invokableAncestor`. Don't depend on them; use `selector` as the handle. + +## Error envelope + +Every `winapp ui` command writes errors to **stderr** as: + +```json +{ + "error": { + "code": "element_not_found", + "message": "…", + "selector": "btn-save-c3d4", + "details": "…", + "recoveryHint": "…" + } +} +``` + +Only `code` and `message` are always present; the rest are omitted when +they do not apply. + +### Desktop coordination + +Concurrent `winapp ui` workflows take cooperative turns on the shared +desktop (see the skill's coordination section). Four additional codes can +appear: + +| `code` | Meaning | +|---|---| +| `invalid_ui_owner_id` | `WINAPP_UI_OWNER_ID` is set but empty/whitespace or longer than 256 characters. Fails before any UI side effect. | +| `desktop_coordination_unavailable` | Coordination state could not be read, published, or safely rebuilt — including state written by a newer `winapp`. Mutating commands fail closed rather than acting uncoordinated. | +| `queue_capacity_exceeded` | 64 commands are already waiting for the desktop. | +| `cancelled` | Ctrl+C (or an npm `AbortSignal`) while the command was still waiting for its turn. The command never ran, so it has no UI side effects. Exit code **130**. | + +`cancelled` — and optionally the other coordination errors — carries an +additive `coordination` object: + +```json +{ + "error": { + "code": "cancelled", + "message": "UI turn wait was cancelled.", + "coordination": { + "waitedMs": 1234, + "queuePosition": 2 + } + } +} +``` + +`waitedMs` is always present for a cancellation while queued. +`queuePosition` is one-based among live waiters and is **omitted** when it +cannot be computed reliably — including while a command waits behind its +own workflow's earlier command. Owner identities are never exposed, in +raw or hashed form. + +Cancelling *after* the command acquired its turn keeps that command's +existing behavior; for example Ctrl+C during `ui record` still finalizes +the recording and returns its normal successful result. diff --git a/samples/winui-app/README.md b/samples/winui-app/README.md index 6a7ab4e08..6b32158aa 100644 --- a/samples/winui-app/README.md +++ b/samples/winui-app/README.md @@ -50,6 +50,10 @@ winapp run .\bin\x64\Debug\net10.0-windows10.0.26100.0\win-x64 --detach --json ## Testing with winapp ui ```powershell +# Set once per logical UI workflow, so these commands are recognized as belonging together and +# don't interleave with another workflow driving the same desktop. +$env:WINAPP_UI_OWNER_ID = [guid]::NewGuid().ToString() + # Inspect the UI tree winapp ui inspect -a winui-app diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeDesktopForegroundService.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeDesktopForegroundService.cs new file mode 100644 index 000000000..9b44b5d31 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeDesktopForegroundService.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Helpers; + +namespace WinApp.Cli.Tests; + +/// +/// Records foreground and restore requests instead of performing them, so tests can assert that a +/// command asks for the foreground only inside a desktop-sensitive section, and never before its +/// foreground validation has run (spec §13). +/// +internal sealed class FakeDesktopForegroundService : IDesktopForegroundService +{ + /// Window handles passed to , in order. + public List ForegroundRequests { get; } = []; + + /// Window handles passed to , in order. + public List RestoreRequests { get; } = []; + + /// Handles this fake reports as minimized, to drive the screenshot escalation path. + public HashSet MinimizedWindows { get; } = []; + + public void RequestForeground(long hwnd) => ForegroundRequests.Add(hwnd); + + public bool IsMinimized(long hwnd) => MinimizedWindows.Contains(hwnd); + + public void Restore(long hwnd) => RestoreRequests.Add(hwnd); +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs new file mode 100644 index 000000000..162942a83 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.CommandLine; +using WinApp.Cli.Services.InteractiveDesktop; + +namespace WinApp.Cli.Tests; + +/// +/// Pass-through for command handler tests. +/// +/// +/// Command tests build the real DI graph, so without this fake every ui test would open lock +/// files under the developer's real %LOCALAPPDATA% and queue against their live desktop +/// workflows. It also records what each command asked for, which is how the tests assert coordination +/// mode, desktop-section placement, and — critically — that a command rejected in preflight never +/// touched coordination at all. +/// +internal sealed class FakeInteractiveDesktopLock : IInteractiveDesktopLock +{ + /// Every coordinated run, in order. Empty means preflight rejected before coordination. + public List<(UiTurnMode Mode, string Operation)> Runs { get; } = []; + + /// How many times a body entered a desktop-sensitive section. + public int DesktopSectionEnters { get; private set; } + + /// How many desktop sections are open right now; tests assert this around observable calls. + public int OpenDesktopSections { get; private set; } + + /// How many times a body escalated an observation to DesktopExclusive. + public int Escalations { get; private set; } + + /// Set to throw from , to cover coordination failures. + public UiCoordinationException? ThrowOnRun { get; set; } + + /// Milliseconds reported as queue wait, so output/telemetry paths can be exercised. + public long WaitedMs { get; set; } + + public Task RunCoordinatedAsync( + UiTurnMode mode, + string operation, + ParseResult parseResult, + Func> body, + CancellationToken cancellationToken) + { + Runs.Add((mode, operation)); + + if (ThrowOnRun is { } failure) + { + throw failure; + } + + return body(new FakeTurn(this, mode), cancellationToken); + } + + private sealed class FakeTurn(FakeInteractiveDesktopLock owner, UiTurnMode mode) : IUiTurn + { + public UiTurnMode Mode { get; private set; } = mode; + + public long WaitedMs => owner.WaitedMs; + + public Task EnterAsync(CancellationToken cancellationToken) + { + owner.DesktopSectionEnters++; + owner.OpenDesktopSections++; + return Task.FromResult(new FakeSection(owner)); + } + + public Task EscalateToDesktopExclusiveAsync(CancellationToken cancellationToken) + { + owner.Escalations++; + Mode = UiTurnMode.DesktopExclusive; + return Task.CompletedTask; + } + } + + private sealed class FakeSection(FakeInteractiveDesktopLock owner) : IAsyncDisposable + { + private bool _disposed; + + public ValueTask DisposeAsync() + { + if (!_disposed) + { + _disposed = true; + owner.OpenDesktopSections--; + } + + return ValueTask.CompletedTask; + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeUiServices.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeUiServices.cs index c8d02091a..f4852d7ef 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeUiServices.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeUiServices.cs @@ -4,6 +4,8 @@ using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; + namespace WinApp.Cli.Tests; /// @@ -176,7 +178,7 @@ public Task SearchAsync(UiSessionInfo session, SelectorExpression s return Task.FromResult(PropertiesResult); } - public Task<(byte[] Pixels, int Width, int Height)> ScreenshotAsync(UiSessionInfo session, string? elementId, bool captureScreen, bool focus, CancellationToken ct) + public Task<(byte[] Pixels, int Width, int Height)> ScreenshotAsync(UiSessionInfo session, string? elementId, bool captureScreen, bool focus, IDesktopSection desktopSection, bool observeOnly, CancellationToken ct) { if (ScreenshotThrow is not null) { throw ScreenshotThrow; } return Task.FromResult(ScreenshotResult); @@ -194,7 +196,7 @@ public Task SearchAsync(UiSessionInfo session, SelectorExpression s public bool? RecordingStartedFrameArtifactsActiveOverride { get; set; } - public async Task RecordAsync(UiSessionInfo session, string? elementId, RecordOptions options, CancellationToken ct, Action? onRecordingStarted = null) + public async Task RecordAsync(UiSessionInfo session, string? elementId, RecordOptions options, IDesktopSection desktopSection, CancellationToken ct, Action? onRecordingStarted = null) { LastRecordOptions = options; if (RecordException is not null) @@ -260,6 +262,13 @@ await File.WriteAllTextAsync( }; } + /// + /// The element passed to the most recent call, or + /// when nothing was invoked. Lets a test assert that a queued command acted on the element it + /// re-resolved inside its desktop section rather than the advisory one read before waiting (#764). + /// + public UiElement? LastInvokedElement { get; private set; } + public Task InvokeAsync(UiSessionInfo session, UiElement element, CancellationToken ct) { if (InvokeThrow is not null) { throw InvokeThrow; } @@ -267,6 +276,7 @@ public Task InvokeAsync(UiSessionInfo session, UiElement element, Cancel { throw new InvalidOperationException("Element does not support an actionable pattern (test)."); } + LastInvokedElement = element; return Task.FromResult(InvokeResult); } @@ -276,9 +286,17 @@ public Task SetValueAsync(UiSessionInfo session, UiElement element, string text, return Task.CompletedTask; } + /// + /// The element passed to the most recent call, or + /// when focus was never applied. Used to assert send-keys never focuses a target before the + /// foreground has been verified (spec §13). + /// + public UiElement? LastFocusedElement { get; private set; } + public Task FocusAsync(UiSessionInfo session, UiElement element, CancellationToken ct) { if (FocusThrow is not null) { throw FocusThrow; } + LastFocusedElement = element; return Task.CompletedTask; } @@ -519,8 +537,13 @@ internal sealed class FakeSystemUiQuery : ISystemUiQuery /// Handle returned by . Default 0 = "no foreground". public nint ForegroundWindowResult { get; set; } - /// PID returned by . Default 0 = "window not found". - public uint ProcessIdForWindowResult { get; set; } + /// + /// PID returned by . Defaults to the fake session's PID (1234) + /// so a window looks alive and owned by the session under test — the post-wait target check treats + /// 0 as "this window no longer exists" (issue #764). Set it to 0 to model a closed window, or to a + /// different PID to model a recycled handle. + /// + public uint ProcessIdForWindowResult { get; set; } = 1234; /// Title returned by . Default null = "no/empty title". public string? WindowTextResult { get; set; } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/GestureTargetingTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/GestureTargetingTests.cs index f4a4f3289..ab4636f76 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/GestureTargetingTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/GestureTargetingTests.cs @@ -5,6 +5,8 @@ using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; + namespace WinApp.Cli.Tests; [TestClass] @@ -162,8 +164,8 @@ private sealed class QueueUiAutomation : IUiAutomationService public Task InspectAncestorsAsync(UiSessionInfo session, string elementId, CancellationToken ct) => throw new NotImplementedException(); public Task SearchAsync(UiSessionInfo session, SelectorExpression selector, int maxResults, CancellationToken ct) => throw new NotImplementedException(); public Task> GetPropertiesAsync(UiSessionInfo session, UiElement element, string? propertyName, CancellationToken ct) => throw new NotImplementedException(); - public Task<(byte[] Pixels, int Width, int Height)> ScreenshotAsync(UiSessionInfo session, string? elementId, bool captureScreen, bool focus, CancellationToken ct) => throw new NotImplementedException(); - public Task RecordAsync(UiSessionInfo session, string? elementId, RecordOptions options, CancellationToken ct, Action? onRecordingStarted = null) => throw new NotImplementedException(); + public Task<(byte[] Pixels, int Width, int Height)> ScreenshotAsync(UiSessionInfo session, string? elementId, bool captureScreen, bool focus, IDesktopSection desktopSection, bool observeOnly, CancellationToken ct) => throw new NotImplementedException(); + public Task RecordAsync(UiSessionInfo session, string? elementId, RecordOptions options, IDesktopSection desktopSection, CancellationToken ct, Action? onRecordingStarted = null) => throw new NotImplementedException(); public Task InvokeAsync(UiSessionInfo session, UiElement element, CancellationToken ct) => throw new NotImplementedException(); public Task SetValueAsync(UiSessionInfo session, UiElement element, string text, CancellationToken ct) => throw new NotImplementedException(); public Task FocusAsync(UiSessionInfo session, UiElement element, CancellationToken ct) => throw new NotImplementedException(); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs new file mode 100644 index 000000000..247a4d37e --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs @@ -0,0 +1,311 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.CommandLine; +using Microsoft.Extensions.Logging.Abstractions; +using Spectre.Console; +using Spectre.Console.Testing; +using WinApp.Cli.Commands; +using WinApp.Cli.Helpers; +using WinApp.Cli.Services.InteractiveDesktop; + +namespace WinApp.Cli.Tests; + +/// +/// End-to-end coverage of over the real store, leases and file +/// locks (issue #764): admission, the forward barrier, and the desktop-section contract. +/// +[TestClass] +[DoNotParallelize] // WINAPP_UI_LOCK_DIRECTORY and WINAPP_UI_OWNER_ID are process-wide. +public class InteractiveDesktopLockTests +{ + private string _lockDirectory = null!; + private string? _previousLockOverride; + private string? _previousOwnerId; + private InteractiveDesktopPaths _paths = null!; + private ParticipantRegistry _participants = null!; + private InteractiveDesktopStateStore _store = null!; + private InteractiveDesktopLock _coordinator = null!; + + [TestInitialize] + public void Setup() + { + _lockDirectory = Path.Combine(Path.GetTempPath(), $"winapp-lock-svc-{Guid.NewGuid():N}"); + _previousLockOverride = Environment.GetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable); + _previousOwnerId = Environment.GetEnvironmentVariable(UiOwnerResolver.OwnerIdVariable); + + Environment.SetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable, _lockDirectory); + // A stable explicit owner keeps these tests independent of the test host's parent process. + Environment.SetEnvironmentVariable(UiOwnerResolver.OwnerIdVariable, "interactive-desktop-lock-tests"); + + var inspector = new ProcessInspector(); + _paths = new InteractiveDesktopPaths(inspector); + _participants = new ParticipantRegistry(_paths, inspector, NullLogger.Instance); + _store = new InteractiveDesktopStateStore( + _paths, _participants, new TickCountClock(), NullLogger.Instance); + _coordinator = new InteractiveDesktopLock( + _store, + _paths, + _participants, + new UiOwnerResolver(inspector), + inspector, + new TickCountClock(), + new FakePollDelay(), + new TestConsole(), + NullLogger.Instance); + } + + [TestCleanup] + public void Cleanup() + { + Environment.SetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable, _previousLockOverride); + Environment.SetEnvironmentVariable(UiOwnerResolver.OwnerIdVariable, _previousOwnerId); + UiCoordinationTelemetryScope.Clear(); + + try + { + if (Directory.Exists(_lockDirectory)) + { + Directory.Delete(_lockDirectory, recursive: true); + } + } + catch (IOException) + { + // A leaked temp directory must never fail a test. + } + } + + private static ParseResult Parse() + { + var command = new Command("probe"); + command.Options.Add(WinAppRootCommand.JsonOption); + command.Options.Add(WinAppRootCommand.QuietOption); + command.Options.Add(WinAppRootCommand.VerboseOption); + return command.Parse(["--quiet"]); + } + + private Task RunAsync(UiTurnMode mode, string operation, Func> body) + => _coordinator.RunCoordinatedAsync(mode, operation, Parse(), body, CancellationToken.None); + + // ------------------------------------------------------------------------------- admission + + [TestMethod] + public async Task ObserveOnAFreeDesktop_RunsDetachedAndLeavesNoState() + { + var ran = false; + var exit = await RunAsync(UiTurnMode.Observe, "ui inspect", (turn, _) => + { + ran = true; + Assert.AreEqual(UiTurnMode.Observe, turn.Mode); + return Task.FromResult(0); + }); + + Assert.AreEqual(0, exit); + Assert.IsTrue(ran); + Assert.IsFalse(_participants.AnyLiveParticipant(), "a detached observation opens no lease"); + } + + [TestMethod] + public async Task DesktopExclusive_ClaimsTheTurnAndReleasesItOnCompletion() + { + await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => + { + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var state = _store.Read().State!; + Assert.IsNotNull(state.Owner, "the command must own the turn while it runs"); + Assert.AreEqual(1, state.OwnerCommands.Count); + Assert.AreEqual(UiCommandStatus.Running, state.OwnerCommands[0].Status); + return Task.FromResult(0); + }); + + using var afterLock = _store.AcquireStateLock(CancellationToken.None); + var after = _store.Read().State!; + Assert.AreEqual(0, after.OwnerCommands.Count, "the entry is removed on completion"); + Assert.IsFalse(_participants.AnyLiveParticipant(), "the lease closes after the entry is removed"); + } + + [TestMethod] + public async Task NonZeroExitStillCountsAsACompletedCommand() + { + // Spec §10.6: a command that ran and returned a failing code still renews the grace, because the + // workflow is alive and its next command is probably a retry. + var exit = await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(3)); + + Assert.AreEqual(3, exit, "the command's own exit code must reach the caller unchanged"); + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + Assert.IsNotNull(_store.Read().State!.Owner, "the turn stays reserved for the idle grace"); + } + + [TestMethod] + public async Task CoordinationSummaryIsPublishedForTelemetry() + { + // Program opens this scope before invoking a command; do the same so the summary the coordinator + // writes deep inside the invocation is visible here. + UiCoordinationTelemetryScope.Begin(); + + await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0)); + + var summary = UiCoordinationTelemetryScope.Current; + Assert.IsNotNull(summary); + Assert.AreEqual(UiOwnerKind.Explicit, summary!.IdentitySource); + Assert.AreEqual(UiTurnMode.DesktopExclusive, summary.Mode); + Assert.AreEqual(UiCoordinationOutcome.Completed, summary.Outcome); + + // Buckets only — an exact wait duration could correlate a user's workflow timing across events. + StringAssert.Matches(summary.WaitBucket, new System.Text.RegularExpressions.Regex(@"^\d+(-\d+|\+)?$")); + } + + // --------------------------------------------------------------------------- desktop sections + + [TestMethod] + public async Task DesktopSection_TakesAndReleasesTheActiveLock() + { + await RunAsync(UiTurnMode.DesktopExclusive, "ui click", async (turn, ct) => + { + Assert.IsTrue(_store.IsActiveLockFree(), "active.lock is not held before the section opens"); + + await using (await turn.EnterAsync(ct)) + { + Assert.IsFalse(_store.IsActiveLockFree(), "the section must hold active.lock"); + } + + Assert.IsTrue(_store.IsActiveLockFree(), "the section must release active.lock on dispose"); + return 0; + }); + } + + [TestMethod] + public async Task DesktopSection_IsNotHeldAcrossTheWholeCommandBody() + { + // The turn wraps execution, but active.lock must not: output formatting, encoding and file + // publication would otherwise block every other workflow for the whole command. + await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => + { + Assert.IsTrue(_store.IsActiveLockFree()); + return Task.FromResult(0); + }); + } + + [TestMethod] + public async Task DesktopSection_SequentialEntersEachTakeTheLockAfresh() + { + // Screenshot and record open one section per restore/foreground/live-screen moment. + await RunAsync(UiTurnMode.TurnShared, "ui screenshot", async (turn, ct) => + { + for (var i = 0; i < 3; i++) + { + await using (await turn.EnterAsync(ct)) + { + Assert.IsFalse(_store.IsActiveLockFree()); + } + + Assert.IsTrue(_store.IsActiveLockFree()); + } + + return 0; + }); + } + + [TestMethod] + public async Task DesktopSection_ConcurrentEntersInOneCommandSerializeInsteadOfOverlapping() + { + // Regression guard: an earlier refcount design let a second concurrent task see depth > 0 and + // skip active.lock entirely, running its desktop work with no cross-process protection. + var concurrent = 0; + var maxConcurrent = 0; + var gate = new object(); + + await RunAsync(UiTurnMode.DesktopExclusive, "ui click", async (turn, ct) => + { + var tasks = Enumerable.Range(0, 8).Select(async _ => + { + await using (await turn.EnterAsync(ct)) + { + lock (gate) + { + concurrent++; + maxConcurrent = Math.Max(maxConcurrent, concurrent); + } + + Assert.IsFalse(_store.IsActiveLockFree(), + "every section must actually hold active.lock, not just believe it does"); + await Task.Delay(5, ct); + + lock (gate) + { + concurrent--; + } + } + }).ToArray(); + + await Task.WhenAll(tasks); + return 0; + }); + + Assert.AreEqual(1, maxConcurrent, "unrelated concurrent enters in one command must serialize"); + Assert.IsTrue(_store.IsActiveLockFree()); + } + + [TestMethod] + public async Task DesktopSection_IsReleasedWhenTheBodyThrows() + { + await Assert.ThrowsExactlyAsync(() => + RunAsync(UiTurnMode.DesktopExclusive, "ui click", async (turn, ct) => + { + await turn.EnterAsync(ct); + throw new InvalidOperationException("boom (test)"); + })); + + Assert.IsTrue(_store.IsActiveLockFree(), + "a leaked section must never leave active.lock held for the rest of the process"); + } + + // ------------------------------------------------------------------------ coordination failure + + [TestMethod] + public async Task UnknownNewerSchemaFailsParticipatingCommandsAndAllowsDetachedObservations() + { + _paths.EnsureDirectories(); + File.WriteAllText( + _paths.StatePath, + """{"version":99,"turnId":1,"nextTicket":2,"ownerCommands":[],"waiters":[]}"""); + + var ex = await Assert.ThrowsExactlyAsync(() => + RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0))); + Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code); + + // Observations may continue: they never claim a turn and never write state. + var observed = false; + var exit = await RunAsync(UiTurnMode.Observe, "ui inspect", (_, _) => + { + observed = true; + return Task.FromResult(0); + }); + + Assert.AreEqual(0, exit); + Assert.IsTrue(observed); + } + + [TestMethod] + public async Task InvalidExplicitOwnerIdFailsBeforeAnyUiSideEffect() + { + Environment.SetEnvironmentVariable(UiOwnerResolver.OwnerIdVariable, " "); + + var ran = false; + var ex = await Assert.ThrowsExactlyAsync(() => + RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => + { + ran = true; + return Task.FromResult(0); + })); + + Assert.AreEqual(UiCoordinationErrorCodes.InvalidOwnerId, ex.Code); + Assert.IsFalse(ran, "the command body must never run with an unusable owner identity"); + Assert.IsFalse(_participants.AnyLiveParticipant(), "no lease may be left behind"); + } + +} \ No newline at end of file diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.cs new file mode 100644 index 000000000..d6b1d6487 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.cs @@ -0,0 +1,495 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Spectre.Console.Testing; +using WinApp.Cli.Services.InteractiveDesktop; + +namespace WinApp.Cli.Tests; + +/// +/// True multiprocess coverage of cooperative desktop turns (issue #764): a real second +/// winapp.exe queues behind a turn this test process holds, and the file protocol — leases, +/// state.lock, active.lock — is exercised across an OS process boundary. +/// +/// +/// +/// These cannot be simulated in one process: participant identity is (pid, processStartTicks), +/// so two "owners" inside a single process would share one identity and one lease file. Only separate +/// processes exercise the real protocol. +/// +/// +/// Gated on WINAPP_UI_MULTIPROCESS_TESTS=1 and a published winapp.exe, so the canonical +/// build does not depend on build artifacts being present. +/// +/// +[TestClass] +[DoNotParallelize] // WINAPP_UI_LOCK_DIRECTORY is process-wide and the child inherits it. +[TestCategory("Interactive")] +[TestCategory("UiCoordination")] +public class InteractiveDesktopMultiprocessTests +{ + private const string GateVariable = "WINAPP_UI_MULTIPROCESS_TESTS"; + + private string _lockDirectory = null!; + private string? _previousLockOverride; + private string? _previousOwnerId; + private string _winappPath = null!; + private InteractiveDesktopPaths _paths = null!; + private ParticipantRegistry _participants = null!; + private InteractiveDesktopStateStore _store = null!; + private InteractiveDesktopLock _coordinator = null!; + private readonly List _children = []; + + [TestInitialize] + public void Setup() + { + if (!string.Equals(Environment.GetEnvironmentVariable(GateVariable), "1", StringComparison.Ordinal)) + { + Assert.Inconclusive( + $"Set {GateVariable}=1 (and build the CLI) to run multiprocess UI coordination coverage."); + } + + _winappPath = FindWinappExe() + ?? throw new AssertInconclusiveException( + "winapp.exe was not found. Run scripts\\build-cli.ps1 first so artifacts\\cli\\\\winapp.exe exists."); + + _lockDirectory = Path.Combine(Path.GetTempPath(), $"winapp-mp-{Guid.NewGuid():N}"); + _previousLockOverride = Environment.GetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable); + _previousOwnerId = Environment.GetEnvironmentVariable(UiOwnerResolver.OwnerIdVariable); + + Environment.SetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable, _lockDirectory); + Environment.SetEnvironmentVariable(UiOwnerResolver.OwnerIdVariable, "multiprocess-test-holder"); + + var inspector = new ProcessInspector(); + _paths = new InteractiveDesktopPaths(inspector); + _participants = new ParticipantRegistry(_paths, inspector, NullLogger.Instance); + _store = new InteractiveDesktopStateStore( + _paths, _participants, new TickCountClock(), NullLogger.Instance); + _coordinator = new InteractiveDesktopLock( + _store, _paths, _participants, new UiOwnerResolver(inspector), inspector, + new TickCountClock(), new FakePollDelay(), new TestConsole(), + NullLogger.Instance); + } + + [TestCleanup] + public void Cleanup() + { + foreach (var child in _children) + { + try + { + if (!child.HasExited) + { + child.Kill(entireProcessTree: true); + } + } + catch (InvalidOperationException) + { + // Already gone. + } + + child.Dispose(); + } + + Environment.SetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable, _previousLockOverride); + Environment.SetEnvironmentVariable(UiOwnerResolver.OwnerIdVariable, _previousOwnerId); + + try + { + if (_lockDirectory is not null && Directory.Exists(_lockDirectory)) + { + Directory.Delete(_lockDirectory, recursive: true); + } + } + catch (IOException) + { + // A leaked temp directory must never fail a test. + } + } + + private static string? FindWinappExe() + { + var root = AppContext.BaseDirectory; + for (var i = 0; i < 8 && root is not null; i++) + { + foreach (var rid in new[] { "win-arm64", "win-x64" }) + { + var candidate = Path.Combine(root, "artifacts", "cli", rid, "winapp.exe"); + if (File.Exists(candidate)) + { + return candidate; + } + } + + root = Path.GetDirectoryName(root.TrimEnd(Path.DirectorySeparatorChar)); + } + + var sideBySide = Path.Combine(AppContext.BaseDirectory, "winapp.exe"); + return File.Exists(sideBySide) ? sideBySide : null; + } + + /// + /// Starts a real winapp ui click against a process that does not exist. Preflight passes + /// (an app and a selector were supplied), so the command genuinely enters coordination, waits its + /// turn, and only then fails to resolve the app — which is exactly the coordination behavior under + /// test, without needing a live target window. + /// + private Process StartQueuedClick(string ownerId) + { + var startInfo = new ProcessStartInfo(_winappPath) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + ArgumentList = { "ui", "click", "some-selector", "-a", "winapp-no-such-app-zzz", "--json" }, + }; + startInfo.Environment[InteractiveDesktopPaths.LockDirectoryOverrideVariable] = _lockDirectory; + startInfo.Environment[UiOwnerResolver.OwnerIdVariable] = ownerId; + startInfo.Environment["WINAPP_CLI_UPDATE_CHECK"] = "0"; + + var child = Process.Start(startInfo)!; + _children.Add(child); + return child; + } + + private InteractiveDesktopState ReadState() + { + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + return _store.Read().State!; + } + + /// Waits until holds over the shared state, or times out. + private async Task WaitForStateAsync(Func predicate, int timeoutMs = 15_000) + { + var deadline = Stopwatch.StartNew(); + while (deadline.ElapsedMilliseconds < timeoutMs) + { + if (predicate(ReadState())) + { + return true; + } + + await Task.Delay(50); + } + + return false; + } + + [TestMethod] + public async Task ASecondProcessQueuesBehindTheTurnAndProceedsWhenItIsReleased() + { + var holderStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseHolder = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var holder = _coordinator.RunCoordinatedAsync( + UiTurnMode.DesktopExclusive, "ui click", UiCoordinationTestParse.Quiet(), + async (_, _) => + { + holderStarted.SetResult(); + await releaseHolder.Task; + return 0; + }, + CancellationToken.None); + + await holderStarted.Task; + + var child = StartQueuedClick("multiprocess-test-other"); + + Assert.IsTrue( + await WaitForStateAsync(s => s.Waiters.Count == 1), + "the second process must register as a global waiter while another owner holds the turn"); + + var waiterPid = ReadState().Waiters[0].Pid; + Assert.AreEqual(child.Id, waiterPid, "the waiter must be the real child process"); + Assert.IsFalse(child.HasExited, "the child must still be waiting, not running"); + + releaseHolder.SetResult(); + await holder; + + Assert.IsTrue(child.WaitForExit(30_000), "the child must proceed once the turn is released"); + + // The app does not exist, so the command fails after acquiring its turn — the point is that it + // got that far only after the holder finished. + Assert.AreEqual(1, child.ExitCode); + Assert.IsTrue( + await WaitForStateAsync(s => s.Waiters.Count == 0 && s.OwnerCommands.Count == 0), + "the child must remove its own entry on completion"); + } + + [TestMethod] + public async Task KillingAQueuedProcessReleasesItsLeaseAndReclaimsItsTicket() + { + var holderStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseHolder = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var holder = _coordinator.RunCoordinatedAsync( + UiTurnMode.DesktopExclusive, "ui click", UiCoordinationTestParse.Quiet(), + async (_, _) => + { + holderStarted.SetResult(); + await releaseHolder.Task; + return 0; + }, + CancellationToken.None); + + await holderStarted.Task; + + var child = StartQueuedClick("multiprocess-test-other"); + Assert.IsTrue(await WaitForStateAsync(s => s.Waiters.Count == 1)); + + var leasePath = _paths.LeasePath(child.Id, child.StartTime.ToUniversalTime().Ticks); + Assert.IsTrue(File.Exists(leasePath), "a queued participant must hold a lease file"); + + // Forced termination is the documented recovery for a stuck process. Windows closes the handle, + // which deletes the DeleteOnClose lease — that is what makes heartbeats unnecessary. + child.Kill(entireProcessTree: true); + Assert.IsTrue(child.WaitForExit(30_000)); + + Assert.IsTrue( + await WaitForStateAsync(_ => !File.Exists(leasePath)), + "Windows must delete the participant lease when the holder is killed"); + + releaseHolder.SetResult(); + await holder; + + // The next coordination pass prunes the dead waiter rather than queueing behind it forever. + Assert.IsTrue( + await WaitForStateAsync(s => s.Waiters.Count == 0), + "a killed waiter's ticket must be reclaimed by the next coordinator"); + } + + [TestMethod] + public async Task TwoProcessesNeverHoldTheDesktopSectionAtTheSameTime() + { + // Hold active.lock from this process for a beat, and prove the child cannot take it meanwhile. + var sectionEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseSection = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var holder = _coordinator.RunCoordinatedAsync( + UiTurnMode.DesktopExclusive, "ui click", UiCoordinationTestParse.Quiet(), + async (turn, ct) => + { + await using (await turn.EnterAsync(ct)) + { + sectionEntered.SetResult(); + await releaseSection.Task; + } + + return 0; + }, + CancellationToken.None); + + await sectionEntered.Task; + Assert.IsFalse(_store.IsActiveLockFree(), "this process holds the desktop section"); + + var child = StartQueuedClick("multiprocess-test-other"); + Assert.IsTrue(await WaitForStateAsync(s => s.Waiters.Count == 1)); + Assert.IsFalse(child.HasExited); + Assert.IsFalse(_store.IsActiveLockFree(), "the child must not have taken active.lock"); + + releaseSection.SetResult(); + await holder; + Assert.IsTrue(child.WaitForExit(30_000)); + } + + [TestMethod] + public async Task ASameOwnerProcessJoinsTheTurnInsteadOfQueueingGlobally() + { + var holderStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseHolder = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var holder = _coordinator.RunCoordinatedAsync( + UiTurnMode.DesktopExclusive, "ui click", UiCoordinationTestParse.Quiet(), + async (_, _) => + { + holderStarted.SetResult(); + await releaseHolder.Task; + return 0; + }, + CancellationToken.None); + + await holderStarted.Task; + + // Same explicit workflow id as this test process, so the child is the *same* logical workflow. + var child = StartQueuedClick("multiprocess-test-holder"); + + Assert.IsTrue( + await WaitForStateAsync(s => s.OwnerCommands.Count == 2), + "a same-owner command joins the owner's command list rather than the global queue"); + Assert.AreEqual(0, ReadState().Waiters.Count); + + releaseHolder.SetResult(); + await holder; + Assert.IsTrue(child.WaitForExit(30_000)); + } + + [TestMethod] + public async Task AnObservationFromAnotherOwnerRunsConcurrentlyWithATurn() + { + var holderStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseHolder = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var holder = _coordinator.RunCoordinatedAsync( + UiTurnMode.DesktopExclusive, "ui click", UiCoordinationTestParse.Quiet(), + async (_, _) => + { + holderStarted.SetResult(); + await releaseHolder.Task; + return 0; + }, + CancellationToken.None); + + await holderStarted.Task; + + // list-windows is an Observe command: it must not wait for anyone's turn. + var startInfo = new ProcessStartInfo(_winappPath) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + ArgumentList = { "ui", "list-windows", "--json" }, + }; + startInfo.Environment[InteractiveDesktopPaths.LockDirectoryOverrideVariable] = _lockDirectory; + startInfo.Environment[UiOwnerResolver.OwnerIdVariable] = "multiprocess-test-observer"; + startInfo.Environment["WINAPP_CLI_UPDATE_CHECK"] = "0"; + + using var observer = Process.Start(startInfo)!; + + // list-windows emits a large JSON payload. Drain both pipes concurrently — a child that fills + // the pipe buffer while nobody reads it blocks on write, which would look like a coordination + // hang rather than the test harness deadlock it actually is. + var stdout = observer.StandardOutput.ReadToEndAsync(); + var stderr = observer.StandardError.ReadToEndAsync(); + + Assert.IsTrue(observer.WaitForExit(30_000), + "a non-owner observation must run immediately, not wait for the desktop"); + await Task.WhenAll(stdout, stderr); + + Assert.AreEqual(0, observer.ExitCode); + Assert.AreEqual(0, ReadState().Waiters.Count, "an observation must never enter the queue"); + + releaseHolder.SetResult(); + await holder; + } + + [TestMethod] + public async Task CorruptStateIsNotResetWhileAnotherProcessIsLive() + { + var holderStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseHolder = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var holder = _coordinator.RunCoordinatedAsync( + UiTurnMode.DesktopExclusive, "ui click", UiCoordinationTestParse.Quiet(), + async (_, _) => + { + holderStarted.SetResult(); + await releaseHolder.Task; + return 0; + }, + CancellationToken.None); + + await holderStarted.Task; + + // Corrupt the shared document while this process is provably mid-workflow. + using (var stateLock = _store.AcquireStateLock(CancellationToken.None)) + { + File.WriteAllText(_paths.StatePath, "{ not json at all"); + } + + var child = StartQueuedClick("multiprocess-test-other"); + Assert.IsTrue(child.WaitForExit(30_000)); + + var stderr = child.StandardError.ReadToEnd() + child.StandardOutput.ReadToEnd(); + Assert.AreEqual(1, child.ExitCode); + StringAssert.Contains(stderr, UiCoordinationErrorCodes.Unavailable, + "a mutating command must fail closed rather than rebuild state over a live participant"); + + releaseHolder.SetResult(); + await holder; + } + + [TestMethod] + public async Task AnInvalidOwnerIdIsRejectedByTheRealBinaryBeforeAnyWork() + { + var startInfo = new ProcessStartInfo(_winappPath) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + ArgumentList = { "ui", "click", "some-selector", "-a", "winapp-no-such-app-zzz", "--json" }, + }; + startInfo.Environment[InteractiveDesktopPaths.LockDirectoryOverrideVariable] = _lockDirectory; + startInfo.Environment[UiOwnerResolver.OwnerIdVariable] = " "; + startInfo.Environment["WINAPP_CLI_UPDATE_CHECK"] = "0"; + + using var child = Process.Start(startInfo)!; + Assert.IsTrue(child.WaitForExit(30_000)); + + var output = child.StandardError.ReadToEnd() + child.StandardOutput.ReadToEnd(); + Assert.AreEqual(1, child.ExitCode); + StringAssert.Contains(output, UiCoordinationErrorCodes.InvalidOwnerId); + Assert.IsFalse(_participants.AnyLiveParticipant(), "a rejected command must leave no lease behind"); + + await Task.CompletedTask; + } + + [TestMethod] + public async Task StateStaysReadableWhileAnotherProcessHoldsTheDesktopSection() + { + var sectionEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseSection = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var holder = _coordinator.RunCoordinatedAsync( + UiTurnMode.DesktopExclusive, "ui click", UiCoordinationTestParse.Quiet(), + async (turn, ct) => + { + await using (await turn.EnterAsync(ct)) + { + sectionEntered.SetResult(); + await releaseSection.Task; + } + + return 0; + }, + CancellationToken.None); + + await sectionEntered.Task; + + // A queued command must still be able to read and update metadata while the desktop is busy, + // otherwise nothing could ever join the queue. + var child = StartQueuedClick("multiprocess-test-other"); + Assert.IsTrue( + await WaitForStateAsync(s => s.Waiters.Count == 1), + "state.lock must be independent of active.lock"); + + var raw = File.ReadAllText(_paths.StatePath); + using var document = JsonDocument.Parse(raw); + Assert.AreEqual(1, document.RootElement.GetProperty("version").GetInt32()); + + releaseSection.SetResult(); + await holder; + Assert.IsTrue(child.WaitForExit(30_000)); + } +} + +/// Minimal parse results for coordination tests that do not exercise a real command. +internal static class UiCoordinationTestParse +{ + public static System.CommandLine.ParseResult Quiet() + { + var command = new System.CommandLine.Command("probe"); + command.Options.Add(WinApp.Cli.Commands.WinAppRootCommand.JsonOption); + command.Options.Add(WinApp.Cli.Commands.WinAppRootCommand.QuietOption); + command.Options.Add(WinApp.Cli.Commands.WinAppRootCommand.VerboseOption); + return command.Parse(["--quiet"]); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs new file mode 100644 index 000000000..66429dfbe --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs @@ -0,0 +1,703 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Services.InteractiveDesktop; + +namespace WinApp.Cli.Tests; + +/// +/// Deterministic, file-free coverage of the cooperative-turn state machine +/// (, issue #764). Every rule from spec sections 10.1–10.7 is +/// asserted here against in-memory state and a fake clock, so scheduling bugs surface without needing +/// a desktop, real processes, or timing luck. +/// +[TestClass] +public class InteractiveDesktopSchedulerTests +{ + private FakeClock _clock = null!; + private FakeLivenessProbe _probe = null!; + private InteractiveDesktopScheduler _scheduler = null!; + + private static readonly UiOwnerIdentity OwnerA = new(UiOwnerKind.Explicit, "aaaa", null, null); + private static readonly UiOwnerIdentity OwnerB = new(UiOwnerKind.Explicit, "bbbb", null, null); + + [TestInitialize] + public void Setup() + { + _clock = new FakeClock(); + _probe = new FakeLivenessProbe(); + _scheduler = new InteractiveDesktopScheduler(_clock); + } + + private UiParticipantIdentity Participant(int pid, string operation = "ui click") + { + _probe.Alive.Add((pid, pid)); + return new UiParticipantIdentity(pid, pid, operation); + } + + // ---------------------------------------------------------------- identity and turn acquisition + + [TestMethod] + public void BeginParticipating_OnFreeDesktop_StartsNewTurnAndRunsImmediately() + { + var state = InteractiveDesktopState.CreateFresh(); + + var result = _scheduler.BeginParticipating( + state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive); + + Assert.AreEqual(UiAdmission.OwnerCommandRunning, result.Admission); + Assert.AreEqual(UiTurnAction.New, result.TurnAction); + Assert.AreEqual(OwnerA.Key, state.Owner!.Key); + Assert.AreEqual(1, state.TurnId); + Assert.AreEqual(1, state.OwnerCommands.Count); + Assert.AreEqual(UiCommandStatus.Running, state.OwnerCommands[0].Status); + } + + [TestMethod] + public void BeginParticipating_SameOwner_JoinsExistingTurnAsContinuation() + { + var state = InteractiveDesktopState.CreateFresh(); + _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100, "ui record"), UiTurnMode.TurnShared); + + var result = _scheduler.BeginParticipating( + state, _probe, OwnerA, Participant(101), UiTurnMode.DesktopExclusive); + + Assert.AreEqual(UiTurnAction.Continuation, result.TurnAction); + Assert.AreEqual(1, state.TurnId, "joining an owned turn must not start a new one"); + Assert.AreEqual(2, state.OwnerCommands.Count); + } + + [TestMethod] + public void BeginParticipating_OtherOwner_QueuesGloballyWithTicket() + { + var state = InteractiveDesktopState.CreateFresh(); + _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive); + + var result = _scheduler.BeginParticipating( + state, _probe, OwnerB, Participant(200), UiTurnMode.DesktopExclusive); + + Assert.AreEqual(UiAdmission.GlobalWaiter, result.Admission); + Assert.AreEqual(UiTurnAction.Queued, result.TurnAction); + Assert.AreEqual(1, result.QueuePosition); + Assert.AreEqual(1, state.Waiters.Count); + Assert.AreEqual(UiTurnMode.DesktopExclusive, state.Waiters[0].Mode, + "the requested mode must be persisted so any process can promote the waiter"); + } + + // ---------------------------------------------------------------------------- forward barrier + + [TestMethod] + public void Barrier_RunningDesktopExclusive_BlocksLaterTurnSharedAndExclusive() + { + var state = InteractiveDesktopState.CreateFresh(); + _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive); + + var laterShared = _scheduler.BeginParticipating( + state, _probe, OwnerA, Participant(101, "ui record"), UiTurnMode.TurnShared); + var laterExclusive = _scheduler.BeginParticipating( + state, _probe, OwnerA, Participant(102), UiTurnMode.DesktopExclusive); + + Assert.AreEqual(UiAdmission.OwnerCommandWaiting, laterShared.Admission); + Assert.AreEqual(UiAdmission.OwnerCommandWaiting, laterExclusive.Admission); + } + + [TestMethod] + public void Barrier_WaitingDesktopExclusive_AlsoBlocksLaterCommands() + { + var state = InteractiveDesktopState.CreateFresh(); + // Ticket 1 exclusive runs; ticket 2 exclusive waits behind it; ticket 3 must wait behind ticket 2 + // even though ticket 2 has not started — a pending barrier blocks just like a running one. + _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive); + _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(101), UiTurnMode.DesktopExclusive); + + var third = _scheduler.BeginParticipating( + state, _probe, OwnerA, Participant(102, "ui record"), UiTurnMode.TurnShared); + + Assert.AreEqual(UiAdmission.OwnerCommandWaiting, third.Admission); + Assert.AreEqual(UiCommandStatus.Waiting, state.OwnerCommands[1].Status); + } + + [TestMethod] + public void Barrier_EarlierRunningTurnShared_ContinuesAcrossALaterExclusive() + { + var state = InteractiveDesktopState.CreateFresh(); + var recorder = Participant(100, "ui record"); + _scheduler.BeginParticipating(state, _probe, OwnerA, recorder, UiTurnMode.TurnShared); + + _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(101), UiTurnMode.DesktopExclusive); + + var recording = InteractiveDesktopScheduler.FindOwnerCommand(state, recorder)!; + Assert.AreEqual(UiCommandStatus.Running, recording.Status, + "a recording that already started keeps running while same-owner input takes the desktop"); + } + + [TestMethod] + public void Barrier_MultipleTurnShared_OverlapWhenNoExclusiveIsPending() + { + var state = InteractiveDesktopState.CreateFresh(); + var first = _scheduler.BeginParticipating( + state, _probe, OwnerA, Participant(100, "ui record"), UiTurnMode.TurnShared); + var second = _scheduler.BeginParticipating( + state, _probe, OwnerA, Participant(101, "ui record"), UiTurnMode.TurnShared); + + Assert.AreEqual(UiAdmission.OwnerCommandRunning, first.Admission); + Assert.AreEqual(UiAdmission.OwnerCommandRunning, second.Admission); + } + + [TestMethod] + public void Barrier_ReleasesNextCommandInTicketOrderWhenTheBarrierCompletes() + { + var state = InteractiveDesktopState.CreateFresh(); + var first = Participant(100); + var second = Participant(101); + var third = Participant(102); + _scheduler.BeginParticipating(state, _probe, OwnerA, first, UiTurnMode.DesktopExclusive); + _scheduler.BeginParticipating(state, _probe, OwnerA, third, UiTurnMode.DesktopExclusive); + _scheduler.BeginParticipating(state, _probe, OwnerA, second, UiTurnMode.DesktopExclusive); + + _probe.Alive.Remove((first.ProcessId, first.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, first, UiOwnerKind.Explicit, renewGrace: true); + + // 'third' was admitted before 'second', so it owns the smaller ticket and runs first. + Assert.AreEqual(UiCommandStatus.Running, + InteractiveDesktopScheduler.FindOwnerCommand(state, third)!.Status); + Assert.AreEqual(UiCommandStatus.Waiting, + InteractiveDesktopScheduler.FindOwnerCommand(state, second)!.Status); + } + + // -------------------------------------------------------------------------------- observations + + [TestMethod] + public void BeginObserve_NonOwner_RunsDetachedWithoutTouchingTheQueue() + { + var state = InteractiveDesktopState.CreateFresh(); + _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive); + + var result = _scheduler.BeginObserve(state, _probe, OwnerB, Participant(200, "ui inspect")); + + Assert.AreEqual(UiAdmission.Detached, result.Admission); + Assert.AreEqual(UiTurnAction.Detached, result.TurnAction); + Assert.AreEqual(1, state.OwnerCommands.Count, "a detached observation must not register"); + Assert.AreEqual(0, state.Waiters.Count); + } + + [TestMethod] + public void BeginObserve_CurrentOwner_PinsTheTurnAndRunsImmediately() + { + var state = InteractiveDesktopState.CreateFresh(); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: true); + + var observation = Participant(101, "ui inspect"); + var result = _scheduler.BeginObserve(state, _probe, OwnerA, observation); + + Assert.AreEqual(UiAdmission.OwnerCommandRunning, result.Admission); + Assert.IsNull(InteractiveDesktopScheduler.FindOwnerCommand(state, observation)!.Ticket, + "observations carry no ticket, so they never act as a barrier"); + + // The pin must survive past the original grace: another owner cannot take the desktop while the + // observation is still reading transient UI. + _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs + 1_000); + _scheduler.Normalize(state, _probe); + Assert.AreEqual(OwnerA.Key, state.Owner!.Key); + } + + [TestMethod] + public void CompletingAnObservation_StartsAFreshGrace() + { + var state = InteractiveDesktopState.CreateFresh(); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: true); + + _clock.Advance(3_000); + var observation = Participant(101, "ui inspect"); + _scheduler.BeginObserve(state, _probe, OwnerA, observation); + _clock.Advance(5_000); + _probe.Alive.Remove((observation.ProcessId, observation.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, observation, UiOwnerKind.Explicit, renewGrace: true); + + _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs - 100); + _scheduler.Normalize(state, _probe); + Assert.AreEqual(OwnerA.Key, state.Owner!.Key, "the observation's completion renewed the grace"); + } + + // ------------------------------------------------------------------------- expiry and handoff + + [TestMethod] + public void IdleTurn_ExpiresAfterExactlyFourSeconds() + { + var state = InteractiveDesktopState.CreateFresh(); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: true); + + _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs - 1); + _scheduler.Normalize(state, _probe); + Assert.IsNotNull(state.Owner, "the turn is still reserved inside the grace"); + + _clock.Advance(1); + _scheduler.Normalize(state, _probe); + Assert.IsNull(state.Owner, "the turn is released once the grace elapses"); + } + + [TestMethod] + public void ActiveOwner_HasNoHardDeadline() + { + var state = InteractiveDesktopState.CreateFresh(); + _scheduler.BeginParticipating( + state, _probe, OwnerA, Participant(100, "ui record"), UiTurnMode.TurnShared); + _scheduler.BeginParticipating(state, _probe, OwnerB, Participant(200), UiTurnMode.DesktopExclusive); + + _clock.Advance(10 * 60 * 1_000); + _scheduler.Normalize(state, _probe); + + Assert.AreEqual(OwnerA.Key, state.Owner!.Key, "a live owner command is never preempted"); + Assert.AreEqual(1, state.Waiters.Count); + } + + [TestMethod] + public void WaitingOwnerCommand_CountsAsActivityAndBlocksHandoff() + { + var state = InteractiveDesktopState.CreateFresh(); + _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive); + _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(101), UiTurnMode.DesktopExclusive); + _scheduler.BeginParticipating(state, _probe, OwnerB, Participant(200), UiTurnMode.DesktopExclusive); + + _clock.Advance(60_000); + _scheduler.Normalize(state, _probe); + + Assert.AreEqual(OwnerA.Key, state.Owner!.Key); + } + + [TestMethod] + public void Handoff_PromotesOldestLiveWaiterAndIncrementsTurnId() + { + var state = InteractiveDesktopState.CreateFresh(); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); + var waiterB = Participant(200); + _scheduler.BeginParticipating(state, _probe, OwnerB, waiterB, UiTurnMode.DesktopExclusive); + + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: true); + _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs); + _scheduler.Normalize(state, _probe); + + Assert.AreEqual(OwnerB.Key, state.Owner!.Key); + Assert.AreEqual(2, state.TurnId); + Assert.AreEqual(0, state.Waiters.Count); + Assert.AreEqual(UiCommandStatus.Running, + InteractiveDesktopScheduler.FindOwnerCommand(state, waiterB)!.Status); + } + + [TestMethod] + public void Handoff_SkipsDeadWaitersAndPicksTheOldestLiveTicket() + { + var state = InteractiveDesktopState.CreateFresh(); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); + + var deadWaiter = Participant(200); + _scheduler.BeginParticipating(state, _probe, OwnerB, deadWaiter, UiTurnMode.DesktopExclusive); + var ownerC = new UiOwnerIdentity(UiOwnerKind.Explicit, "cccc", null, null); + var liveWaiter = Participant(300); + _scheduler.BeginParticipating(state, _probe, ownerC, liveWaiter, UiTurnMode.DesktopExclusive); + + _probe.Alive.Remove((deadWaiter.ProcessId, deadWaiter.StartTicksUtc)); + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: true); + _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs); + _scheduler.Normalize(state, _probe); + + Assert.AreEqual("cccc", state.Owner!.Key); + } + + [TestMethod] + public void ExpiredOwner_ReRegisteringGoesToTheBackOfTheQueue() + { + var state = InteractiveDesktopState.CreateFresh(); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); + _scheduler.BeginParticipating(state, _probe, OwnerB, Participant(200), UiTurnMode.DesktopExclusive); + + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: true); + _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs); + + var result = _scheduler.BeginParticipating( + state, _probe, OwnerA, Participant(101), UiTurnMode.DesktopExclusive); + + Assert.AreEqual(UiAdmission.GlobalWaiter, result.Admission, + "the previous owner must not race ahead of a waiter that was already queued"); + Assert.AreEqual(OwnerB.Key, state.Owner!.Key); + } + + // ------------------------------------------------------------------------------- grace rules + + [TestMethod] + public void NonCancelledFailure_RenewsTheGrace() + { + var state = InteractiveDesktopState.CreateFresh(); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + + // A command that ran and returned a non-zero exit code still renews: the workflow is alive and + // its next command is likely a retry. + _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: true); + + _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs - 1); + _scheduler.Normalize(state, _probe); + Assert.IsNotNull(state.Owner); + } + + [TestMethod] + public void Cancellation_DoesNotRenewTheGrace() + { + var state = InteractiveDesktopState.CreateFresh(); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + + _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: false); + + Assert.IsNull(state.Owner, "a cancelled command leaves no reservation behind"); + } + + [TestMethod] + public void AnonymousOwner_ReceivesNoGraceAndHandsOffImmediately() + { + var state = InteractiveDesktopState.CreateFresh(); + var anonymous = new UiOwnerIdentity(UiOwnerKind.Anonymous, "anon", null, null); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, anonymous, actor, UiTurnMode.DesktopExclusive); + var waiter = Participant(200); + _scheduler.BeginParticipating(state, _probe, OwnerB, waiter, UiTurnMode.DesktopExclusive); + + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Anonymous, renewGrace: true); + + Assert.AreEqual(OwnerB.Key, state.Owner!.Key, + "a one-command owner has no shell that could issue a follow-up, so it hands off at once"); + } + + [TestMethod] + public void ParentDerivedOwner_ReleasesImmediatelyWhenItsShellIsGone() + { + var state = InteractiveDesktopState.CreateFresh(); + var parentOwner = new UiOwnerIdentity(UiOwnerKind.Parent, "parent", 900, 900); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, parentOwner, actor, UiTurnMode.DesktopExclusive); + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Parent, renewGrace: true); + + _probe.DeadParents.Add((900, 900)); + _scheduler.Normalize(state, _probe); + + Assert.IsNull(state.Owner, "no further command can arrive from a shell that has exited"); + } + + [TestMethod] + public void ParentDerivedOwner_KeepsGraceWhenParentLivenessIsUnknown() + { + var state = InteractiveDesktopState.CreateFresh(); + var parentOwner = new UiOwnerIdentity(UiOwnerKind.Parent, "parent", 900, 900); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, parentOwner, actor, UiTurnMode.DesktopExclusive); + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Parent, renewGrace: true); + + _probe.UnknownParents.Add((900, 900)); + _scheduler.Normalize(state, _probe); + + Assert.IsNotNull(state.Owner, "an unreadable parent must never be treated as a dead one"); + } + + // ------------------------------------------------------------------------------ pruning rules + + [TestMethod] + public void DeadParticipants_ArePrunedAndReleaseTheTurn() + { + var state = InteractiveDesktopState.CreateFresh(); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); + + // The process was killed: Windows deleted its DeleteOnClose lease, so it is provably gone. + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.Normalize(state, _probe); + + Assert.AreEqual(0, state.OwnerCommands.Count); + Assert.IsNull(state.Owner, "a crash does not renew the grace, so the turn is released at once"); + } + + [TestMethod] + public void SuspendedLiveWaiter_IsNeverPrunedAndKeepsTheHeadOfTheQueue() + { + var state = InteractiveDesktopState.CreateFresh(); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); + + // A suspended process still holds its lease. There are no heartbeats, so nothing can mistake it + // for dead and nothing can overtake it (spec §19). + var suspended = Participant(200); + _scheduler.BeginParticipating(state, _probe, OwnerB, suspended, UiTurnMode.DesktopExclusive); + var ownerC = new UiOwnerIdentity(UiOwnerKind.Explicit, "cccc", null, null); + _scheduler.BeginParticipating(state, _probe, ownerC, Participant(300), UiTurnMode.DesktopExclusive); + + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: false); + _clock.Advance(60_000); + _scheduler.Normalize(state, _probe); + + Assert.AreEqual(OwnerB.Key, state.Owner!.Key, + "the suspended waiter is alive, so it is promoted rather than skipped"); + Assert.AreEqual(1, state.Waiters.Count, "the later waiter stays queued behind it"); + } + + // -------------------------------------------------------------------------------- queue limits + + [TestMethod] + public void QueueCap_AppliesOnlyAfterDeadWaitersArePruned() + { + var state = InteractiveDesktopState.CreateFresh(); + _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive); + + var queued = new List(); + for (var i = 0; i < InteractiveDesktopScheduler.MaxGlobalWaiters; i++) + { + var waiter = Participant(1_000 + i); + queued.Add(waiter); + _scheduler.BeginParticipating( + state, _probe, new UiOwnerIdentity(UiOwnerKind.Explicit, $"owner{i}", null, null), + waiter, UiTurnMode.DesktopExclusive); + } + + Assert.ThrowsExactly(() => _scheduler.BeginParticipating( + state, _probe, OwnerB, Participant(9_999), UiTurnMode.DesktopExclusive)); + + // Once one waiter dies the cap frees up again, because it counts live waiters only. + _probe.Alive.Remove((queued[0].ProcessId, queued[0].StartTicksUtc)); + var accepted = _scheduler.BeginParticipating( + state, _probe, OwnerB, Participant(9_998), UiTurnMode.DesktopExclusive); + Assert.AreEqual(UiAdmission.GlobalWaiter, accepted.Admission); + } + + [TestMethod] + public void QueueCapFailure_LeavesNoStateEntryBehind() + { + var state = InteractiveDesktopState.CreateFresh(); + _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive); + for (var i = 0; i < InteractiveDesktopScheduler.MaxGlobalWaiters; i++) + { + _scheduler.BeginParticipating( + state, _probe, new UiOwnerIdentity(UiOwnerKind.Explicit, $"owner{i}", null, null), + Participant(1_000 + i), UiTurnMode.DesktopExclusive); + } + + var rejected = Participant(9_999); + try + { + _scheduler.BeginParticipating(state, _probe, OwnerB, rejected, UiTurnMode.DesktopExclusive); + } + catch (UiCoordinationException ex) + { + Assert.AreEqual(UiCoordinationErrorCodes.QueueCapacityExceeded, ex.Code); + } + + Assert.IsNull(InteractiveDesktopScheduler.FindWaiter(state, rejected)); + Assert.IsNull(InteractiveDesktopScheduler.FindOwnerCommand(state, rejected)); + } + + // --------------------------------------------------------------- same-owner queue absorption + + [TestMethod] + public void PromotedOwner_AbsorbsItsOtherQueuedCommandsInTicketOrder() + { + var state = InteractiveDesktopState.CreateFresh(); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); + + var firstB = Participant(200); + var secondB = Participant(201); + _scheduler.BeginParticipating(state, _probe, OwnerB, firstB, UiTurnMode.DesktopExclusive); + _scheduler.BeginParticipating(state, _probe, OwnerB, secondB, UiTurnMode.DesktopExclusive); + + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: false); + + Assert.AreEqual(OwnerB.Key, state.Owner!.Key); + Assert.AreEqual(0, state.Waiters.Count, + "with no other owner queued, the whole prefix belongs to B and is absorbed"); + Assert.AreEqual(UiCommandStatus.Running, + InteractiveDesktopScheduler.FindOwnerCommand(state, firstB)!.Status); + Assert.AreEqual(UiCommandStatus.Waiting, + InteractiveDesktopScheduler.FindOwnerCommand(state, secondB)!.Status, + "the second command still queues behind its own owner's barrier"); + } + + // --------------------------------------------------------------- same-owner queue absorption + + [TestMethod] + public void PromotedOwner_AbsorbsOnlyItsContiguousPrefixAtTheQueueHead() + { + var state = InteractiveDesktopState.CreateFresh(); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); + + // Queue order B, B, C: nothing separates the two B commands, so both may be absorbed. + var firstB = Participant(200); + var secondB = Participant(201); + var ownerC = new UiOwnerIdentity(UiOwnerKind.Explicit, "cccc", null, null); + var firstC = Participant(300); + _scheduler.BeginParticipating(state, _probe, OwnerB, firstB, UiTurnMode.DesktopExclusive); + _scheduler.BeginParticipating(state, _probe, OwnerB, secondB, UiTurnMode.DesktopExclusive); + _scheduler.BeginParticipating(state, _probe, ownerC, firstC, UiTurnMode.DesktopExclusive); + + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: false); + + Assert.AreEqual(OwnerB.Key, state.Owner!.Key); + Assert.IsNotNull(InteractiveDesktopScheduler.FindOwnerCommand(state, firstB)); + Assert.IsNotNull(InteractiveDesktopScheduler.FindOwnerCommand(state, secondB)); + Assert.IsNotNull(InteractiveDesktopScheduler.FindWaiter(state, firstC), + "the other owner's command must stay in the global queue"); + } + + [TestMethod] + public void PromotedOwner_StopsAbsorbingAtTheFirstDifferentOwner() + { + var state = InteractiveDesktopState.CreateFresh(); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); + + // Queue order B, C, B. Absorbing the trailing B would let it run before C even though C has the + // older ticket, which would break strict global FIFO. + var firstB = Participant(200); + var ownerC = new UiOwnerIdentity(UiOwnerKind.Explicit, "cccc", null, null); + var firstC = Participant(300); + var secondB = Participant(201); + _scheduler.BeginParticipating(state, _probe, OwnerB, firstB, UiTurnMode.DesktopExclusive); + _scheduler.BeginParticipating(state, _probe, ownerC, firstC, UiTurnMode.DesktopExclusive); + _scheduler.BeginParticipating(state, _probe, OwnerB, secondB, UiTurnMode.DesktopExclusive); + + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: false); + + Assert.AreEqual(OwnerB.Key, state.Owner!.Key); + Assert.IsNotNull(InteractiveDesktopScheduler.FindOwnerCommand(state, firstB), + "the head of the queue belongs to the promoted owner and is absorbed"); + Assert.IsNull(InteractiveDesktopScheduler.FindOwnerCommand(state, secondB), + "absorption must stop at the first different owner so C keeps its earlier place"); + Assert.IsNotNull(InteractiveDesktopScheduler.FindWaiter(state, secondB)); + Assert.IsNotNull(InteractiveDesktopScheduler.FindWaiter(state, firstC)); + + // And C really does get the turn next, ahead of B's second command. + _probe.Alive.Remove((firstB.ProcessId, firstB.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, firstB, UiOwnerKind.Explicit, renewGrace: false); + Assert.AreEqual("cccc", state.Owner!.Key); + } + + // ---------------------------------------------------------------------------- escalation + + [TestMethod] + public void Escalation_ConvertsTheObservationInPlaceWithANewTicket() + { + var state = InteractiveDesktopState.CreateFresh(); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: true); + + var screenshot = Participant(101, "ui screenshot"); + _scheduler.BeginObserve(state, _probe, OwnerA, screenshot); + var beforeTicket = state.NextTicket; + + Assert.IsTrue(_scheduler.EscalateObserveToExclusive(state, _probe, screenshot)); + + var entry = InteractiveDesktopScheduler.FindOwnerCommand(state, screenshot)!; + Assert.AreEqual(UiTurnMode.DesktopExclusive, entry.Mode); + Assert.AreEqual(beforeTicket, entry.Ticket, + "priority starts at escalation time, not when the observational pass began"); + Assert.AreEqual(1, state.OwnerCommands.Count, + "the same entry is reused, so no intermediate state lacks this command"); + } + + [TestMethod] + public void Escalation_QueuesBehindAnEarlierExclusiveCommand() + { + var state = InteractiveDesktopState.CreateFresh(); + _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive); + var screenshot = Participant(101, "ui screenshot"); + _scheduler.BeginObserve(state, _probe, OwnerA, screenshot); + + _scheduler.EscalateObserveToExclusive(state, _probe, screenshot); + + Assert.AreEqual(UiCommandStatus.Waiting, + InteractiveDesktopScheduler.FindOwnerCommand(state, screenshot)!.Status); + } + + // -------------------------------------------------------------------------- ticket monotonicity + + [TestMethod] + public void Tickets_AreGloballyMonotonicAcrossOwnersAndModes() + { + var state = InteractiveDesktopState.CreateFresh(); + var seen = new List(); + + seen.Add(_scheduler.BeginParticipating( + state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive).Ticket!.Value); + seen.Add(_scheduler.BeginParticipating( + state, _probe, OwnerA, Participant(101, "ui record"), UiTurnMode.TurnShared).Ticket!.Value); + seen.Add(_scheduler.BeginParticipating( + state, _probe, OwnerB, Participant(200), UiTurnMode.DesktopExclusive).Ticket!.Value); + + CollectionAssert.AreEqual(seen.OrderBy(t => t).ToList(), seen); + Assert.AreEqual(seen.Count, seen.Distinct().Count()); + } + + private sealed class FakeClock : IMonotonicClock + { + private long _ticks = 1_000_000; + + public long NowTicks64 => _ticks; + + public DateTimeOffset UtcNow { get; private set; } = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public void Advance(long milliseconds) + { + _ticks += milliseconds; + UtcNow = UtcNow.AddMilliseconds(milliseconds); + } + } + + /// + /// Lease-backed liveness, faked. Membership in stands in for "holds its + /// DeleteOnClose lease"; there is deliberately no timestamp or heartbeat concept to fake. + /// + private sealed class FakeLivenessProbe : ICoordinationLivenessProbe + { + public HashSet<(int Pid, long Start)> Alive { get; } = []; + + public HashSet<(int Pid, long Start)> DeadParents { get; } = []; + + public HashSet<(int Pid, long Start)> UnknownParents { get; } = []; + + public bool IsParticipantLive(int processId, long startTicksUtc) + => Alive.Contains((processId, startTicksUtc)); + + public bool? IsParentAlive(int processId, long startTicksUtc) + { + if (UnknownParents.Contains((processId, startTicksUtc))) + { + return null; + } + + return !DeadParents.Contains((processId, startTicksUtc)); + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs new file mode 100644 index 000000000..91c2a88d1 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs @@ -0,0 +1,412 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using WinApp.Cli.Services.InteractiveDesktop; + +namespace WinApp.Cli.Tests; + +/// +/// File-level coverage of the coordination store, participant leases, and lock-directory setup +/// (issue #764). Everything here runs against a throwaway directory supplied through +/// WINAPP_UI_LOCK_DIRECTORY, so a test never touches the developer's live coordination state. +/// +[TestClass] +[DoNotParallelize] // WINAPP_UI_LOCK_DIRECTORY is process-wide. +public class InteractiveDesktopStoreTests +{ + private string _lockDirectory = null!; + private string? _previousOverride; + private InteractiveDesktopPaths _paths = null!; + private ParticipantRegistry _participants = null!; + private InteractiveDesktopStateStore _store = null!; + private FakeProcessInspector _inspector = null!; + + [TestInitialize] + public void Setup() + { + _lockDirectory = Path.Combine(Path.GetTempPath(), $"winapp-locks-{Guid.NewGuid():N}"); + _previousOverride = Environment.GetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable); + Environment.SetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable, _lockDirectory); + + _inspector = new FakeProcessInspector(); + _paths = new InteractiveDesktopPaths(_inspector); + _participants = new ParticipantRegistry(_paths, _inspector, NullLogger.Instance); + _store = new InteractiveDesktopStateStore( + _paths, _participants, new FixedClock(), NullLogger.Instance); + } + + [TestCleanup] + public void Cleanup() + { + Environment.SetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable, _previousOverride); + try + { + if (Directory.Exists(_lockDirectory)) + { + Directory.Delete(_lockDirectory, recursive: true); + } + } + catch (IOException) + { + // A leaked temp directory must never fail a test. + } + } + + // ------------------------------------------------------------------------------- fresh vs corrupt + + [TestMethod] + public void Read_MissingFile_StartsFresh() + { + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var result = _store.Read(); + + Assert.IsFalse(result.RecoveredFromCorruption, "a missing file is an ordinary first run"); + Assert.IsNotNull(result.State); + Assert.IsNull(result.State!.Owner); + } + + [TestMethod] + public void Read_EmptyFile_IsTreatedAsCorruptionNotFreshState() + { + // Atomic publication never produces an empty file, so one means a torn write or truncation. + _paths.EnsureDirectories(); + File.WriteAllText(_paths.StatePath, string.Empty); + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var result = _store.Read(); + + Assert.IsTrue(result.RecoveredFromCorruption, + "an existing empty state file must take the guarded recovery path"); + Assert.IsTrue( + Directory.EnumerateFiles(_paths.LockDirectory, "state.corrupt-*.json").Any(), + "the unreadable file must be quarantined rather than silently discarded"); + } + + [TestMethod] + public void Read_WhitespaceFile_IsTreatedAsCorruption() + { + _paths.EnsureDirectories(); + File.WriteAllText(_paths.StatePath, " \r\n "); + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + Assert.IsTrue(_store.Read().RecoveredFromCorruption); + } + + [TestMethod] + public void Read_CorruptStateWithALiveParticipant_FailsClosed() + { + _paths.EnsureDirectories(); + File.WriteAllText(_paths.StatePath, "{ this is not json"); + + // A live lease means some other winapp process is mid-workflow; rebuilding state under it would + // strand its ownership and let two processes drive the desktop. + using var lease = _participants.OpenLease(_inspector.CurrentProcessId, _inspector.CurrentProcessStartTicksUtc); + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var ex = Assert.ThrowsExactly(() => _store.Read()); + Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code); + } + + [TestMethod] + public void Read_EmptyStateWithALiveParticipant_FailsClosed() + { + _paths.EnsureDirectories(); + File.WriteAllText(_paths.StatePath, string.Empty); + + using var lease = _participants.OpenLease(_inspector.CurrentProcessId, _inspector.CurrentProcessStartTicksUtc); + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var ex = Assert.ThrowsExactly(() => _store.Read()); + Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code); + } + + [TestMethod] + public void Read_UnknownNewerVersion_IsNeverResetOrDowngraded() + { + _paths.EnsureDirectories(); + const string future = """{"version":99,"turnId":7,"nextTicket":3,"ownerCommands":[],"waiters":[]}"""; + File.WriteAllText(_paths.StatePath, future); + + using (var stateLock = _store.AcquireStateLock(CancellationToken.None)) + { + var result = _store.Read(); + Assert.IsTrue(result.UnknownNewerVersion); + Assert.IsNull(result.State); + Assert.IsFalse(result.RecoveredFromCorruption, "a newer schema is not corruption"); + } + + Assert.AreEqual(future, File.ReadAllText(_paths.StatePath), "the newer document must be left alone"); + } + + // ------------------------------------------------------------------- structural validation + + [TestMethod] + public void Read_DuplicateTicketAcrossOwnerCommandsAndWaiters_IsCorrupt() + { + // Ticket 5 appearing in both lists would make two commands share one barrier position. + WriteRawState(""" + {"version":1,"turnId":1,"nextTicket":9,"owner":{"kind":"explicit","key":"a"}, + "ownerCommands":[{"ticket":5,"pid":10,"processStartTicksUtc":1,"operation":"ui click","mode":"DesktopExclusive","status":"running"}], + "waiters":[{"ticket":5,"ownerKey":"b","ownerKind":"explicit","pid":11,"processStartTicksUtc":2,"operation":"ui click","mode":"DesktopExclusive"}]} + """); + + AssertRecovered(); + } + + [TestMethod] + public void Read_NextTicketNotAheadOfEveryPersistedTicket_IsCorrupt() + { + // nextTicket 5 would re-issue ticket 5 and collide with the running command. + WriteRawState(""" + {"version":1,"turnId":1,"nextTicket":5,"owner":{"kind":"explicit","key":"a"}, + "ownerCommands":[{"ticket":5,"pid":10,"processStartTicksUtc":1,"operation":"ui click","mode":"DesktopExclusive","status":"running"}], + "waiters":[]} + """); + + AssertRecovered(); + } + + [TestMethod] + public void Read_OwnerCommandsWithoutAnOwner_IsCorrupt() + { + WriteRawState(""" + {"version":1,"turnId":1,"nextTicket":9, + "ownerCommands":[{"ticket":5,"pid":10,"processStartTicksUtc":1,"operation":"ui click","mode":"DesktopExclusive","status":"running"}], + "waiters":[]} + """); + + AssertRecovered(); + } + + [TestMethod] + public void Read_ObserveEntryCarryingATicket_IsCorrupt() + { + // Observations never serialize as barriers, so a ticket on one is meaningless and would be + // compared against real barrier tickets. + WriteRawState(""" + {"version":1,"turnId":1,"nextTicket":9,"owner":{"kind":"explicit","key":"a"}, + "ownerCommands":[{"ticket":5,"pid":10,"processStartTicksUtc":1,"operation":"ui inspect","mode":"Observe","status":"running"}], + "waiters":[]} + """); + + AssertRecovered(); + } + + [TestMethod] + public void Read_OutOfRangeEnumValue_IsCorrupt() + { + WriteRawState(""" + {"version":1,"turnId":1,"nextTicket":9,"owner":{"kind":"explicit","key":"a"}, + "ownerCommands":[{"ticket":5,"pid":10,"processStartTicksUtc":1,"operation":"ui click","mode":42,"status":"running"}], + "waiters":[]} + """); + + AssertRecovered(); + } + + // ------------------------------------------------------------------- publication round-trip + + [TestMethod] + public void Publish_RoundTripsStateAndPreservesUnknownFieldsFromANewerWriter() + { + _paths.EnsureDirectories(); + WriteRawState(""" + {"version":1,"turnId":3,"nextTicket":9,"owner":{"kind":"explicit","key":"a","futureOwnerField":"keep-me"}, + "ownerCommands":[],"waiters":[],"futureRootField":{"nested":true}} + """); + + using (var stateLock = _store.AcquireStateLock(CancellationToken.None)) + { + var state = _store.Read().State!; + state.TurnId = 4; + _store.Publish(state); + } + + using var document = JsonDocument.Parse(File.ReadAllText(_paths.StatePath)); + Assert.AreEqual(4, document.RootElement.GetProperty("turnId").GetInt32()); + Assert.IsTrue(document.RootElement.TryGetProperty("futureRootField", out _), + "unknown root fields from a newer writer must survive a rewrite"); + Assert.IsTrue(document.RootElement.GetProperty("owner").TryGetProperty("futureOwnerField", out _), + "unknown owner fields from a newer writer must survive a rewrite"); + } + + [TestMethod] + public void Publish_LeavesNoTemporaryFilesBehind() + { + using (var stateLock = _store.AcquireStateLock(CancellationToken.None)) + { + _store.Publish(InteractiveDesktopState.CreateFresh()); + } + + Assert.AreEqual(0, Directory.EnumerateFiles(_paths.LockDirectory, "*.tmp").Count()); + } + + [TestMethod] + public void StateRemainsReadableWhileTheActiveLockIsHeld() + { + // active.lock guards the desktop, not the metadata: a queued command must still be able to read + // and update state while another process is mid-gesture. + _paths.EnsureDirectories(); + using var activeLock = new FileStream( + _paths.ActiveLockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var state = _store.Read().State!; + state.TurnId = 11; + _store.Publish(state); + + Assert.IsFalse(_store.IsActiveLockFree()); + } + + // --------------------------------------------------------------------------- participant leases + + [TestMethod] + public void Lease_IsHeldWhileOpenAndDeletedOnClose() + { + var leasePath = _paths.LeasePath(_inspector.CurrentProcessId, _inspector.CurrentProcessStartTicksUtc); + + var lease = _participants.OpenLease(_inspector.CurrentProcessId, _inspector.CurrentProcessStartTicksUtc); + Assert.IsTrue(File.Exists(leasePath)); + Assert.IsTrue(_participants.IsParticipantLive(_inspector.CurrentProcessId, _inspector.CurrentProcessStartTicksUtc)); + Assert.IsTrue(_participants.AnyLiveParticipant()); + + lease.Dispose(); + + // DeleteOnClose means the OS removes the file, which is exactly what makes heartbeats unnecessary. + Assert.IsFalse(File.Exists(leasePath)); + Assert.IsFalse(_participants.AnyLiveParticipant()); + } + + [TestMethod] + public void Lease_OrphanedFileFromAPowerLossIsNotLiveAndIsCleanedUp() + { + _paths.EnsureDirectories(); + var orphan = _paths.LeasePath(999_999, 12_345); + File.WriteAllText(orphan, string.Empty); + + Assert.IsFalse(_participants.IsParticipantLive(999_999, 12_345), + "an openable lease proves its holder is gone"); + Assert.IsFalse(File.Exists(orphan), "the stale lease file must be removed while probing"); + } + + [TestMethod] + public void Lease_FileNameRoundTripsThroughTheProcessIdentity() + { + var path = _paths.LeasePath(4242, 987_654_321); + Assert.IsTrue(_paths.TryParseLeaseFileName(Path.GetFileName(path), out var pid, out var startTicks)); + Assert.AreEqual(4242, pid); + Assert.AreEqual(987_654_321, startTicks); + } + + // ------------------------------------------------------------------------- lock directory setup + + [TestMethod] + public void EnsureDirectories_RepairsAnExistingDirectoryWithInheritedPermissions() + { + // A WINAPP_UI_LOCK_DIRECTORY pointed at a shared location can already exist with inherited + // rules that let another user tamper with coordination state or hold a lease. + Directory.CreateDirectory(_lockDirectory); + var before = new DirectoryInfo(_lockDirectory).GetAccessControl(); + Assert.IsFalse(before.AreAccessRulesProtected, "precondition: the directory starts with inherited rules"); + + _paths.EnsureDirectories(); + + var after = new DirectoryInfo(_lockDirectory).GetAccessControl(); + Assert.IsTrue(after.AreAccessRulesProtected, + "an existing coordination directory must not be left with inherited permissions"); + } + + [TestMethod] + public void EnsureDirectories_IsSafeToCallRepeatedlyAndConcurrently() + { + // Every state-lock acquisition and lease open calls this, and several winapp processes can race. + Parallel.For(0, 16, _ => _paths.EnsureDirectories()); + + Assert.IsTrue(Directory.Exists(_paths.LockDirectory)); + Assert.IsTrue(Directory.Exists(_paths.ParticipantsDirectory)); + } + + [TestMethod] + public void Paths_RejectARelativeOverrideDirectory() + { + Environment.SetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable, "relative\\locks"); + + // A relative path resolves against the caller's working directory, so two winapp processes + // started in different folders would silently coordinate against different files. + var ex = Assert.ThrowsExactly(() => new InteractiveDesktopPaths(_inspector)); + Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code); + } + + [TestMethod] + public void Paths_RejectANetworkOverrideDirectory() + { + Environment.SetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable, @"\\server\share\locks"); + + // SMB byte-range locking is advisory, so exclusive-share semantics would silently not exclude. + var ex = Assert.ThrowsExactly(() => new InteractiveDesktopPaths(_inspector)); + Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code); + } + + [TestMethod] + public void Paths_ScopeEveryArtifactToTheWindowsSession() + { + // Two signed-in sessions have independent foreground/focus/input, so they must not queue + // behind each other. + var otherSession = new InteractiveDesktopPaths(new FakeProcessInspector { CurrentSessionId = 7 }); + + Assert.AreNotEqual(_paths.StatePath, otherSession.StatePath); + Assert.AreNotEqual(_paths.ActiveLockPath, otherSession.ActiveLockPath); + Assert.AreNotEqual(_paths.StateLockPath, otherSession.StateLockPath); + } + + private void WriteRawState(string json) + { + _paths.EnsureDirectories(); + File.WriteAllText(_paths.StatePath, json); + } + + private void AssertRecovered() + { + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var result = _store.Read(); + Assert.IsTrue(result.RecoveredFromCorruption, + "structurally invalid scheduling state must be quarantined, not used"); + Assert.IsNotNull(result.State); + Assert.IsNull(result.State!.Owner); + } + + private sealed class FixedClock : IMonotonicClock + { + public long NowTicks64 => 1_000_000; + + public DateTimeOffset UtcNow => new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + } + + /// + /// Reports this test process's real identity — the lease protocol needs a genuinely live process — + /// while letting a test vary the Windows session id. + /// + private sealed class FakeProcessInspector : IProcessInspector + { + private readonly ProcessInspector _real = new(); + + public int CurrentSessionId { get; init; } = 1; + + public int CurrentProcessId => _real.CurrentProcessId; + + public long CurrentProcessStartTicksUtc => _real.CurrentProcessStartTicksUtc; + + public int? TryGetParentProcessId() => _real.TryGetParentProcessId(); + + public long? TryGetProcessStartTicksUtc(int processId) => _real.TryGetProcessStartTicksUtc(processId); + + public bool? IsProcessAlive(int processId, long startTicksUtc) => _real.IsProcessAlive(processId, startTicksUtc); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/RealUiAutomationTests.Capture.cs b/src/winapp-CLI/WinApp.Cli.Tests/RealUiAutomationTests.Capture.cs index 5eae933e8..8cd07d5f1 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/RealUiAutomationTests.Capture.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/RealUiAutomationTests.Capture.cs @@ -6,6 +6,8 @@ using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; + namespace WinApp.Cli.Tests; public partial class RealUiAutomationTests @@ -24,7 +26,7 @@ public async Task ScreenshotAsync_Window_ProducesNonBlankImage() await ResolveAsync(svc, session, "btnInvoke"); var (pixels, width, height) = await CaptureNonBlankAsync(fx, - () => svc.ScreenshotAsync(session, null, captureScreen: false, focus: true, CancellationToken.None)); + () => svc.ScreenshotAsync(session, null, captureScreen: false, focus: true, NullDesktopSection.Instance, observeOnly: false, CancellationToken.None)); AssertNonBlankRenderOrInconclusive(pixels); Assert.IsTrue(width > 100 && height > 100, $"unexpected capture size {width}x{height}"); @@ -41,7 +43,7 @@ public async Task ScreenshotAsync_CaptureScreen_ProducesNonBlankImage() await ResolveAsync(svc, session, "btnInvoke"); var (pixels, width, height) = await CaptureNonBlankAsync(fx, - () => svc.ScreenshotAsync(session, null, captureScreen: true, focus: false, CancellationToken.None)); + () => svc.ScreenshotAsync(session, null, captureScreen: true, focus: false, NullDesktopSection.Instance, observeOnly: false, CancellationToken.None)); AssertNonBlankRenderOrInconclusive(pixels); Assert.IsTrue(width > 100 && height > 100, $"unexpected capture size {width}x{height}"); @@ -57,7 +59,7 @@ public async Task ScreenshotAsync_ElementCrop_ReturnsElementSizedImage() Foreground(fx); var button = await ResolveAsync(svc, session, "btnInvoke"); - var (pixels, width, height) = await svc.ScreenshotAsync(session, button.Selector ?? "btnInvoke", captureScreen: false, focus: true, CancellationToken.None); + var (pixels, width, height) = await svc.ScreenshotAsync(session, button.Selector ?? "btnInvoke", captureScreen: false, focus: true, NullDesktopSection.Instance, observeOnly: false, CancellationToken.None); // The button is ~120x30; the crop should be far smaller than the whole window. Assert.IsTrue(width > 10 && width < 300, $"unexpected crop width {width}"); @@ -72,7 +74,7 @@ public async Task ScreenshotAsync_NoWindow_Throws() var session = new UiSessionInfo { ProcessId = 0x7FFFFFFE, WindowHandle = 0, IsExplicitWindow = true }; await Assert.ThrowsExactlyAsync( - () => svc.ScreenshotAsync(session, null, captureScreen: false, focus: false, CancellationToken.None)); + () => svc.ScreenshotAsync(session, null, captureScreen: false, focus: false, NullDesktopSection.Instance, observeOnly: false, CancellationToken.None)); } [TestMethod] @@ -90,7 +92,7 @@ public async Task ScreenshotAsync_MinimizedWindow_RestoresThenCaptures() fx.OnUiThread(() => fx.Form.WindowState = FormWindowState.Minimized); await Task.Delay(200); - var (pixels, width, height) = await svc.ScreenshotAsync(session, null, captureScreen: false, focus: true, CancellationToken.None); + var (pixels, width, height) = await svc.ScreenshotAsync(session, null, captureScreen: false, focus: true, NullDesktopSection.Instance, observeOnly: false, CancellationToken.None); Assert.IsTrue(width > 100 && height > 100, $"unexpected capture size {width}x{height}"); Assert.AreEqual(width * height * 4, pixels.Length); @@ -109,7 +111,7 @@ public async Task ScreenshotAsync_LegacySelectorCrop_ReturnsElementSizedImage() // A bare automation id (not a slug) drives CropToElement's legacy-selector branch: // _selectorService.Parse -> BuildCondition -> root.FindFirst, then crop to the element rect. - var (pixels, width, height) = await svc.ScreenshotAsync(session, "btnInvoke", captureScreen: false, focus: true, CancellationToken.None); + var (pixels, width, height) = await svc.ScreenshotAsync(session, "btnInvoke", captureScreen: false, focus: true, NullDesktopSection.Instance, observeOnly: false, CancellationToken.None); Assert.IsTrue(width > 10 && width < 300, $"unexpected crop width {width}"); Assert.IsTrue(height > 5 && height < 200, $"unexpected crop height {height}"); @@ -128,8 +130,8 @@ public async Task ScreenshotAsync_MissingElement_ReturnsFullFrame() // A selector that matches nothing makes CropToElement return null, so ScreenshotAsync falls // back to returning the full window frame rather than a crop. var full = await CaptureNonBlankAsync(fx, - () => svc.ScreenshotAsync(session, null, captureScreen: false, focus: true, CancellationToken.None)); - var (pixels, width, height) = await svc.ScreenshotAsync(session, "no-such-control-zzz", captureScreen: false, focus: true, CancellationToken.None); + () => svc.ScreenshotAsync(session, null, captureScreen: false, focus: true, NullDesktopSection.Instance, observeOnly: false, CancellationToken.None)); + var (pixels, width, height) = await svc.ScreenshotAsync(session, "no-such-control-zzz", captureScreen: false, focus: true, NullDesktopSection.Instance, observeOnly: false, CancellationToken.None); Assert.AreEqual(full.Width, width, "missing-element crop should yield the full-frame width"); Assert.AreEqual(full.Height, height, "missing-element crop should yield the full-frame height"); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/RealUiAutomationTests.Coverage.cs b/src/winapp-CLI/WinApp.Cli.Tests/RealUiAutomationTests.Coverage.cs index c8f4297b4..1e84d9f3b 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/RealUiAutomationTests.Coverage.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/RealUiAutomationTests.Coverage.cs @@ -8,6 +8,8 @@ using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Helpers; + namespace WinApp.Cli.Tests; public partial class RealUiAutomationTests @@ -256,7 +258,7 @@ public async Task InspectAsync_NonExplicitSessionSkipsElementsAlreadyInMainTree( { using var fx = new UiaTestFixture(); var logger = new CapturingLogger(); - var svc = new UiAutomationService(logger, new SelectorService()); + var svc = new UiAutomationService(logger, new SelectorService(), new DesktopForegroundService()); var session = NonExplicitSession(fx); var childHwnd = fx.OnUiThread(() => (nint)fx.InvokeButton.Handle); UiAutomationService.s_getAllAppWindows = (_, _) => [(fx.Hwnd, fx.ProcessId, fx.Title), (childHwnd, fx.ProcessId, "child")]; @@ -368,7 +370,7 @@ public async Task MalformedSlugSelector_ReturnsStaleElementError() public async Task InvalidStoredHwndFallsBackAndReturnsEmpty() { var logger = new CapturingLogger(); - var svc = new UiAutomationService(logger, new SelectorService()); + var svc = new UiAutomationService(logger, new SelectorService(), new DesktopForegroundService()); var session = new UiSessionInfo { ProcessId = int.MaxValue, @@ -440,7 +442,7 @@ public async Task OtherWindowSearch_ComFailureIsLoggedAndIgnored() { using var fx = new UiaTestFixture(); var logger = new CapturingLogger(); - var svc = new UiAutomationService(logger, new SelectorService()); + var svc = new UiAutomationService(logger, new SelectorService(), new DesktopForegroundService()); var session = NonExplicitSession(fx); var otherHwnd = fx.Hwnd + 1000; UiAutomationService.s_getAllAppWindows = (_, _) => [(fx.Hwnd, fx.ProcessId, fx.Title), (otherHwnd, fx.ProcessId, "faulty")]; @@ -459,7 +461,7 @@ public async Task OtherWindowSearch_ComFailureIsLoggedAndIgnored() public async Task FaultInjectedComProxies_CoverPatternBranches() { var logger = new CapturingLogger(); - var svc = new UiAutomationService(logger, new SelectorService()); + var svc = new UiAutomationService(logger, new SelectorService(), new DesktopForegroundService()); var session = new UiSessionInfo { ProcessId = Environment.ProcessId, ProcessName = "fake", WindowHandle = 0 }; var model = new UiElement { Id = "fake", Type = "Custom", AutomationId = "fakeAid", Selector = null }; var rect = new RECT { left = 1, top = 2, right = 11, bottom = 12 }; @@ -583,7 +585,7 @@ public async Task FaultInjectedComProxies_CoverExpandCollapsePropertyStates() public async Task FaultInjectedComProxies_CoverPromoteFailureAndPatternCatches() { var logger = new CapturingLogger(); - var svc = new UiAutomationService(logger, new SelectorService()); + var svc = new UiAutomationService(logger, new SelectorService(), new DesktopForegroundService()); var session = new UiSessionInfo { ProcessId = Environment.ProcessId, ProcessName = "fake", WindowHandle = 111 }; var target = ComProxy((method, _) => method.Name switch { @@ -671,7 +673,7 @@ public async Task FaultInjectedComProxies_CoverPromoteInnerElementFailure() public async Task NativeSeams_CoverRootElementFallbacks() { var logger = new CapturingLogger(); - var svc = new UiAutomationService(logger, new SelectorService()); + var svc = new UiAutomationService(logger, new SelectorService(), new DesktopForegroundService()); var session = new UiSessionInfo { ProcessId = Environment.ProcessId, ProcessName = "fake", WindowHandle = 0 }; var target = ComProxy((method, _) => method.Name switch { @@ -938,7 +940,7 @@ public async Task SetValueAsync_RangeValuePatternSucceedsWhenValuePatternUnavail public async Task SetValueAsync_LegacyIAccessibleComFailureIsLoggedAndThrows() { var logger = new CapturingLogger(); - var svc = new UiAutomationService(logger, new SelectorService()); + var svc = new UiAutomationService(logger, new SelectorService(), new DesktopForegroundService()); var session = new UiSessionInfo { ProcessId = Environment.ProcessId, ProcessName = "fake" }; var model = new UiElement { Id = "legacy", Type = "Edit", AutomationId = "legacyAid" }; var legacyPattern = ComProxy((_, _) => ThrowCom()); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/RealUiAutomationTests.Patterns.cs b/src/winapp-CLI/WinApp.Cli.Tests/RealUiAutomationTests.Patterns.cs index 18603745b..ee8ffa817 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/RealUiAutomationTests.Patterns.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/RealUiAutomationTests.Patterns.cs @@ -6,6 +6,8 @@ using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Helpers; + namespace WinApp.Cli.Tests; public partial class RealUiAutomationTests @@ -250,7 +252,7 @@ public async Task SetValueAsync_NumericUpDown_FallsThroughToLegacyIAccessible() { using var fx = new UiaTestFixture(); var logger = new CapturingLogger(); - var svc = new UiAutomationService(logger, new SelectorService()); + var svc = new UiAutomationService(logger, new SelectorService(), new DesktopForegroundService()); var session = SessionFor(fx); var spinner = await ResolveAsync(svc, session, "numSpin"); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/RealUiAutomationTests.Record.cs b/src/winapp-CLI/WinApp.Cli.Tests/RealUiAutomationTests.Record.cs index 31f2c5b2a..0fe310a49 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/RealUiAutomationTests.Record.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/RealUiAutomationTests.Record.cs @@ -6,6 +6,8 @@ using Windows.Win32.Foundation; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; + namespace WinApp.Cli.Tests; public partial class RealUiAutomationTests @@ -45,7 +47,7 @@ public async Task RecordAsync_WgcSeams_EncodesTimedFramesAndReportsResult() Fps = 2, MaxEdge = 64, CaptureScreen = false, - }, CancellationToken.None, _ => started++); + }, NullDesktopSection.Instance, CancellationToken.None, _ => started++); Assert.AreEqual("wgc", result.Mode); Assert.AreEqual(2, result.Frames); @@ -85,7 +87,7 @@ public async Task RecordAsync_WgcClosedDrainsLatestFrameBeforeStopping() Fps = 5, MaxEdge = 0, CaptureScreen = false, - }, CancellationToken.None); + }, NullDesktopSection.Instance, CancellationToken.None); Assert.AreEqual(1, result.Frames, "closed WGC items should drain the cached frame once before finalizing"); Assert.AreEqual("wgc", result.Mode); @@ -117,7 +119,7 @@ public async Task RecordAsync_CaptureScreen_UsesConsentedScreenPath() Fps = 1, MaxEdge = 64, CaptureScreen = true, - }, CancellationToken.None); + }, NullDesktopSection.Instance, CancellationToken.None); Assert.AreEqual("screen", result.Mode); Assert.AreEqual(1, result.Frames); @@ -150,7 +152,7 @@ public async Task RecordAsync_PrintWindowFallback_UsesBlankRetryCapture() Fps = 1, MaxEdge = 64, CaptureScreen = false, - }, CancellationToken.None); + }, NullDesktopSection.Instance, CancellationToken.None); Assert.AreEqual("printwindow", result.Mode); Assert.AreEqual(1, result.Frames); @@ -184,7 +186,7 @@ public async Task RecordAsync_FrameArtifacts_UseTheProcessedMp4FrameStream() Fps = 2, MaxEdge = 64, CaptureScreen = false, - }, CancellationToken.None); + }, NullDesktopSection.Instance, CancellationToken.None); Assert.AreEqual(2, result.Frames); Assert.IsNotNull(result.FrameArtifacts); @@ -224,7 +226,7 @@ public async Task RecordAsync_FirstMp4WriteFailure_PreservesAcceptedFrameArtifac DurationSec = 1, Fps = 1, MaxEdge = 64, - }, CancellationToken.None)); + }, NullDesktopSection.Instance, CancellationToken.None)); Assert.IsNotNull(exception.FramesDirectory); StringAssert.StartsWith(exception.FramesDirectory, framesDirectory + ".partial-"); @@ -270,7 +272,7 @@ public async Task RecordAsync_Mp4PublicationRaceDoesNotPublishMismatchedFrames() DurationSec = 1, Fps = 1, MaxEdge = 64, - }, CancellationToken.None)); + }, NullDesktopSection.Instance, CancellationToken.None)); Assert.AreEqual("winning recording", await File.ReadAllTextAsync(output)); Assert.IsFalse(Directory.Exists(framesDirectory)); @@ -310,7 +312,7 @@ public async Task RecordAsync_FrameAndMp4FailurePreservesNeitherArtifact() DurationSec = 1, Fps = 1, MaxEdge = 64, - }, CancellationToken.None)); + }, NullDesktopSection.Instance, CancellationToken.None)); StringAssert.Contains(exception.InnerException!.Message, "frame failure"); Assert.IsFalse(Directory.EnumerateFileSystemEntries(root).Any()); @@ -351,7 +353,7 @@ public async Task RecordAsync_CancellationAfterProcessedFrameCommitsSampleBefore DurationSec = 10, Fps = 1, MaxEdge = 64, - }, cts.Token); + }, NullDesktopSection.Instance, cts.Token); Assert.AreEqual(1, result.Frames); Assert.AreEqual(1, frameSink.SampleCount); @@ -389,7 +391,7 @@ public async Task RecordAsync_JpegWorkerFailurePreservesMp4AndRemovesFrameStagin DurationSec = 1, Fps = 1, MaxEdge = 64, - }, CancellationToken.None)); + }, NullDesktopSection.Instance, CancellationToken.None)); Assert.AreEqual(output, exception.VideoPath); Assert.IsTrue(File.Exists(output)); @@ -437,7 +439,7 @@ public async Task RecordAsync_NonIoFrameFailurePreservesMp4(bool failOnComplete) DurationSec = 1, Fps = 1, MaxEdge = 64, - }, CancellationToken.None)); + }, NullDesktopSection.Instance, CancellationToken.None)); Assert.AreEqual(output, exception.VideoPath); Assert.IsTrue(File.Exists(output)); @@ -472,7 +474,7 @@ public async Task RecordAsync_TruncatedFrameBundleReturnsActionableWarning() DurationSec = 1, Fps = 1, MaxEdge = 64, - }, CancellationToken.None); + }, NullDesktopSection.Instance, CancellationToken.None); Assert.IsNotNull(result.FrameArtifacts); Assert.IsTrue(result.FrameArtifacts.Truncated); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/RealUiAutomationTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/RealUiAutomationTests.cs index a82196eab..7fc05d46c 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/RealUiAutomationTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/RealUiAutomationTests.cs @@ -6,6 +6,8 @@ using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Helpers; + namespace WinApp.Cli.Tests; /// @@ -61,7 +63,7 @@ public void ResetNativeSeams() } private static UiAutomationService NewService() - => new(NullLogger.Instance, new SelectorService()); + => new(NullLogger.Instance, new SelectorService(), new DesktopForegroundService()); private static UiSessionInfo SessionFor(UiaTestFixture fx, bool explicitWindow = true) => new() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiAutomationServicePureTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiAutomationServicePureTests.cs index be74865e0..876e76ddf 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiAutomationServicePureTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiAutomationServicePureTests.cs @@ -6,6 +6,8 @@ using Microsoft.Extensions.Logging.Abstractions; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; + namespace WinApp.Cli.Tests; /// @@ -189,7 +191,7 @@ public void IsBlankCapture_AllZeroWithRemainderTail_ReturnsTrue() } [TestMethod] - public void CaptureFromWindowWithBlankRetry_RetriesBlankFrame() + public async Task CaptureFromWindowWithBlankRetry_RetriesBlankFrame() { var calls = 0; var foregrounded = false; @@ -198,16 +200,38 @@ public void CaptureFromWindowWithBlankRetry_RetriesBlankFrame() UiAutomationService.s_foregroundWindowForBlankRetry = _ => foregrounded = true; UiAutomationService.s_sleepForBlankRetry = ms => Assert.AreEqual(200, ms); - var service = new UiAutomationService(NullLogger.Instance, new SelectorService()); - var pixels = service.CaptureFromWindowWithBlankRetry(new HWND(123), 1, 2); + var service = new UiAutomationService( + NullLogger.Instance, new SelectorService(), new FakeDesktopForegroundService()); + var section = new CountingDesktopSection(); + var pixels = await service.CaptureFromWindowWithBlankRetryAsync( + new HWND(123), 1, 2, section, observeOnly: false, CancellationToken.None); Assert.AreEqual(2, calls, "blank first capture must trigger one retry"); Assert.IsTrue(foregrounded, "blank retry must foreground the target window"); + Assert.AreEqual(1, section.Enters, "the blank-retry foreground must happen inside a desktop section"); CollectionAssert.AreEqual(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }, pixels); } [TestMethod] - public void CaptureFromWindowWithBlankRetry_NonBlankDoesNotRetry() + public async Task CaptureFromWindowWithBlankRetry_ObserveOnlyRequestsEscalationInsteadOfForegrounding() + { + UiAutomationService.s_captureFromWindow = (_, _, _) => new byte[8]; + UiAutomationService.s_foregroundWindowForBlankRetry = + _ => Assert.Fail("an observational pass must never take the foreground"); + + var service = new UiAutomationService( + NullLogger.Instance, new SelectorService(), new FakeDesktopForegroundService()); + var section = new CountingDesktopSection(); + + await Assert.ThrowsExactlyAsync( + () => service.CaptureFromWindowWithBlankRetryAsync( + new HWND(123), 1, 2, section, observeOnly: true, CancellationToken.None)); + + Assert.AreEqual(0, section.Enters, "an observational pass must not take active.lock"); + } + + [TestMethod] + public async Task CaptureFromWindowWithBlankRetry_NonBlankDoesNotRetry() { var calls = 0; UiAutomationService.s_captureFromWindow = (_, _, _) => @@ -217,13 +241,34 @@ public void CaptureFromWindowWithBlankRetry_NonBlankDoesNotRetry() }; UiAutomationService.s_foregroundWindowForBlankRetry = _ => Assert.Fail("non-blank capture must not foreground/retry"); - var service = new UiAutomationService(NullLogger.Instance, new SelectorService()); - var pixels = service.CaptureFromWindowWithBlankRetry(new HWND(456), 1, 1); + var service = new UiAutomationService( + NullLogger.Instance, new SelectorService(), new FakeDesktopForegroundService()); + var section = new CountingDesktopSection(); + var pixels = await service.CaptureFromWindowWithBlankRetryAsync( + new HWND(456), 1, 1, section, observeOnly: false, CancellationToken.None); Assert.AreEqual(1, calls); + Assert.AreEqual(0, section.Enters, "a clean capture must never take active.lock"); Assert.AreEqual(1, pixels[3]); } + /// Counts how many desktop sections a capture path opened. + private sealed class CountingDesktopSection : IDesktopSection + { + public int Enters { get; private set; } + + public Task EnterAsync(CancellationToken cancellationToken) + { + Enters++; + return Task.FromResult(new Scope()); + } + + private sealed class Scope : IAsyncDisposable + { + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + } + [TestMethod] public void CaptureScreenFrame_LetterboxesScaledContent() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs new file mode 100644 index 000000000..27119efae --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs @@ -0,0 +1,336 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Commands; +using WinApp.Cli.Models; +using WinApp.Cli.Services.InteractiveDesktop; + +namespace WinApp.Cli.Tests; + +/// +/// Command-level coverage of cooperative desktop turns (issue #764): coordination is entered only +/// after local validation, each command declares the right mode, desktop-sensitive work happens inside +/// a section, and no command acts on a target it resolved before waiting. +/// +public partial class UiCommandTests +{ + // ---------------------------------------------- validation must precede coordination (spec §10) + + [TestMethod] + public async Task Click_MissingApp_NeverEntersCoordination() + { + // A malformed command must not open a participant lease, take an arrival ticket, or join an + // indefinite queue behind another workflow. + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["Button", "--json"]); + + Assert.AreEqual(1, exitCode); + Assert.AreEqual(0, _fakeDesktopLock.Runs.Count, "preflight must reject before coordination"); + } + + [TestMethod] + public async Task Click_MissingSelector_NeverEntersCoordination() + { + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--json"]); + + Assert.AreEqual(1, exitCode); + Assert.AreEqual(0, _fakeDesktopLock.Runs.Count); + } + + [TestMethod] + public async Task SendKeys_SystemReservedCombo_IsRefusedBeforeCoordination() + { + // win+l can never be driven from automation, so it must never wait for the desktop first. + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["win+l", "-a", "TestApp", "--via", "send-input", "--json"]); + + Assert.AreEqual(1, exitCode); + Assert.AreEqual(0, _fakeDesktopLock.Runs.Count); + } + + [TestMethod] + public async Task Record_InvalidDuration_NeverEntersCoordination() + { + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-a", "TestApp", "--duration-sec", "-1", "--json"]); + + Assert.AreEqual(1, exitCode); + Assert.AreEqual(0, _fakeDesktopLock.Runs.Count); + } + + [TestMethod] + public async Task Touch_InvalidGesture_NeverEntersCoordination() + { + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["Button", "-a", "TestApp", "--gesture", "wiggle", "--json"]); + + Assert.AreEqual(1, exitCode); + Assert.AreEqual(0, _fakeDesktopLock.Runs.Count); + } + + // ------------------------------------------------------------------- mode classification (§6.1) + + [TestMethod] + public async Task Inspect_IsAnObservation() + { + var command = GetRequiredService(); + await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--json"]); + + Assert.AreEqual(1, _fakeDesktopLock.Runs.Count); + Assert.AreEqual(UiTurnMode.Observe, _fakeDesktopLock.Runs[0].Mode); + Assert.AreEqual("ui inspect", _fakeDesktopLock.Runs[0].Operation); + } + + [TestMethod] + public async Task SetValue_StaysAnObservationBecauseItIsBackgroundSafe() + { + // Spec §6.1: background-safe UIA mutations stay concurrent. This feature prevents desktop + // interference, not transactional app-state isolation. + _fakeUia.FindSingleResult = new UiElement { Id = "box", Selector = "box", Name = "Box" }; + var command = GetRequiredService(); + await ParseAndInvokeWithCaptureAsync(command, ["box", "hello", "-a", "TestApp", "--json"]); + + Assert.AreEqual(UiTurnMode.Observe, _fakeDesktopLock.Runs[0].Mode); + } + + [TestMethod] + public async Task Record_IsTurnSharedSoSameOwnerInputCanInterleave() + { + var command = GetRequiredService(); + await ParseAndInvokeWithCaptureAsync( + command, ["-a", "TestApp", "--duration-sec", "1", "--output", Path.Combine(_tempDirectory.FullName, "r.mp4"), "--json"]); + + Assert.AreEqual(UiTurnMode.TurnShared, _fakeDesktopLock.Runs[0].Mode); + } + + [TestMethod] + public async Task Invoke_IsDesktopExclusive() + { + _fakeUia.FindSingleResult = new UiElement { Id = "btn", Selector = "btn", Name = "Button" }; + var command = GetRequiredService(); + await ParseAndInvokeWithCaptureAsync(command, ["btn", "-a", "TestApp", "--json"]); + + Assert.AreEqual(UiTurnMode.DesktopExclusive, _fakeDesktopLock.Runs[0].Mode); + } + + [TestMethod] + public async Task Scroll_ClassifiesByTransport() + { + _fakeUia.FindSingleResult = new UiElement + { + Id = "list", Selector = "list", Name = "List", X = 10, Y = 10, Width = 100, Height = 100, + }; + _fakeSystemQuery.ProcessIdForWindowResult = 1234; + + // --direction uses the UIA ScrollPattern, which works in the background. + var command = GetRequiredService(); + await ParseAndInvokeWithCaptureAsync(command, ["list", "-a", "TestApp", "--direction", "down", "--json"]); + Assert.AreEqual(UiTurnMode.Observe, _fakeDesktopLock.Runs[0].Mode); + + // --wheel injects OS-wide mouse input at the cursor. + _fakeDesktopLock.Runs.Clear(); + await ParseAndInvokeWithCaptureAsync(command, ["list", "-a", "TestApp", "--wheel", "3", "--json"]); + Assert.AreEqual(UiTurnMode.DesktopExclusive, _fakeDesktopLock.Runs[0].Mode); + } + + [TestMethod] + public async Task Screenshot_ClassifiesByWhetherItNeedsTheForeground() + { + _fakeUia.ScreenshotResult = (new byte[4 * 4 * 4], 4, 4); + var output = Path.Combine(_tempDirectory.FullName, "shot.png"); + var command = GetRequiredService(); + + await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--output", output, "--json"]); + Assert.AreEqual(UiTurnMode.Observe, _fakeDesktopLock.Runs[0].Mode); + + _fakeDesktopLock.Runs.Clear(); + await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--focus", "--output", output, "--json"]); + Assert.AreEqual(UiTurnMode.DesktopExclusive, _fakeDesktopLock.Runs[0].Mode); + } + + // ------------------------------------------------- desktop-section placement and revalidation + + [TestMethod] + public async Task Click_ForegroundsAndInjectsInsideADesktopSection() + { + _fakeUia.FindSingleResult = new UiElement + { + Id = "btn", Selector = "btn", Name = "Button", + X = 10, Y = 20, Width = 40, Height = 30, WindowHandle = 4242, + }; + _fakeSystemQuery.ProcessIdForWindowResult = 1234; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["btn", "-a", "TestApp", "--json"]); + + Assert.AreEqual(0, exitCode); + Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters, + "the foreground request and the click must run inside one desktop section"); + Assert.AreEqual(0, _fakeDesktopLock.OpenDesktopSections, + "the section must be released before the result is formatted"); + CollectionAssert.Contains(_fakeDesktopForeground.ForegroundRequests, 4242L); + } + + [TestMethod] + public async Task Click_RefreshesTheTargetWindowFromTheReResolvedElement() + { + // The window a queued command acts on must come from the read taken inside the section, not + // from the advisory read taken before the wait (spec §10.5). + var stale = new UiElement + { + Id = "btn", Selector = "btn", Name = "Button", + X = 10, Y = 20, Width = 40, Height = 30, WindowHandle = 1111, + }; + var current = new UiElement + { + Id = "btn", Selector = "btn", Name = "Button", + X = 10, Y = 20, Width = 40, Height = 30, WindowHandle = 2222, + }; + // A per-selector read sequence: the advisory read taken before the section sees the old window, + // every read inside the section sees the current one. + _fakeUia.MovingResults["btn"] = new Queue([stale, current, current, current, current]); + _fakeSystemQuery.ProcessIdForWindowResult = 1234; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["btn", "-a", "TestApp", "--json"]); + + Assert.AreEqual(0, exitCode); + CollectionAssert.Contains(_fakeDesktopForeground.ForegroundRequests, 2222L); + CollectionAssert.DoesNotContain(_fakeDesktopForeground.ForegroundRequests, 1111L, + "the pre-wait window handle must never be foregrounded"); + } + + [TestMethod] + public async Task Click_RefusesWhenTheTargetWindowClosedWhileQueued() + { + _fakeUia.FindSingleResult = new UiElement + { + Id = "btn", Selector = "btn", Name = "Button", + X = 10, Y = 20, Width = 40, Height = 30, WindowHandle = 4242, + }; + // 0 means "no such window": the target closed while this command waited for the desktop. + _fakeSystemQuery.ProcessIdForWindowResult = 0; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["btn", "-a", "TestApp", "--json"]); + + Assert.AreEqual(1, exitCode); + AssertJsonErrorCode("stale_element"); + Assert.AreEqual(0, _fakeMouse.ClickCalls.Count, "no input may be injected at a dead target"); + } + + [TestMethod] + public async Task Click_RefusesWhenTheWindowHandleWasRecycledByAnotherProcess() + { + _fakeUia.FindSingleResult = new UiElement + { + Id = "btn", Selector = "btn", Name = "Button", + X = 10, Y = 20, Width = 40, Height = 30, WindowHandle = 4242, + }; + // The session resolves PID 1234; a different PID means Windows reused the handle. + _fakeSystemQuery.ProcessIdForWindowResult = 9999; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["btn", "-a", "TestApp", "--json"]); + + Assert.AreEqual(1, exitCode); + AssertJsonErrorCode("stale_element"); + Assert.AreEqual(0, _fakeMouse.ClickCalls.Count); + } + + [TestMethod] + public async Task Invoke_ReResolvesTheElementInsideTheDesktopSection() + { + var stale = new UiElement { Id = "btn", Selector = "btn", Name = "Stale", WindowHandle = 4242 }; + var current = new UiElement { Id = "btn", Selector = "btn", Name = "Current", WindowHandle = 4242 }; + _fakeUia.MovingResults["btn"] = new Queue([stale, current]); + _fakeSystemQuery.ProcessIdForWindowResult = 1234; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["btn", "-a", "TestApp", "--json"]); + + Assert.AreEqual(0, exitCode); + Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters); + Assert.AreSame(current, _fakeUia.LastInvokedElement, + "invoke must act on the element resolved after the queue wait, not before it"); + } + + [TestMethod] + public async Task Invoke_RefusesWhenTheTargetWindowClosedWhileQueued() + { + _fakeUia.FindSingleResult = new UiElement { Id = "btn", Selector = "btn", Name = "Button", WindowHandle = 4242 }; + _fakeSystemQuery.ProcessIdForWindowResult = 0; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["btn", "-a", "TestApp", "--json"]); + + Assert.AreEqual(1, exitCode); + AssertJsonErrorCode("stale_element"); + Assert.IsNull(_fakeUia.LastInvokedElement, "no pattern may be invoked on a dead target"); + } + + [TestMethod] + public async Task Focus_ReResolvesTheElementInsideTheDesktopSection() + { + var stale = new UiElement { Id = "box", Selector = "box", Name = "Stale", WindowHandle = 4242 }; + var current = new UiElement { Id = "box", Selector = "box", Name = "Current", WindowHandle = 4242 }; + _fakeUia.MovingResults["box"] = new Queue([stale, current]); + _fakeSystemQuery.ProcessIdForWindowResult = 1234; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["box", "-a", "TestApp", "--json"]); + + Assert.AreEqual(0, exitCode); + Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters); + Assert.AreSame(current, _fakeUia.LastFocusedElement); + } + + // --------------------------------------------------------------- send-keys ordering fix (§13) + + [TestMethod] + public async Task SendKeys_DoesNotFocusTheTargetWhenForegroundValidationFails() + { + // Spec §13: a command that fails foreground validation must not first apply avoidable focus to + // its target — that alone would dismiss another workflow's transient UI. + _fakeUia.FindSingleResult = new UiElement + { + Id = "box", Selector = "box", Name = "Box", WindowHandle = 4242, + }; + _fakeSystemQuery.ProcessIdForWindowResult = 1234; + _fakeForeground.Allow = false; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["hello", "-a", "TestApp", "--target", "box", "--via", "send-input", "--json"]); + + Assert.AreEqual(1, exitCode); + Assert.IsNull(_fakeUia.LastFocusedElement, + "focus must be applied only after the foreground has been verified"); + Assert.AreEqual(0, _fakeKeyboard.SendCalls.Count); + } + + [TestMethod] + public async Task SendKeys_FocusesTheTargetOnlyAfterForegroundIsVerified() + { + _fakeUia.FindSingleResult = new UiElement + { + Id = "box", Selector = "box", Name = "Box", WindowHandle = 4242, + }; + _fakeSystemQuery.ProcessIdForWindowResult = 1234; + _fakeForeground.Allow = true; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["hello", "-a", "TestApp", "--target", "box", "--via", "send-input", "--json"]); + + Assert.AreEqual(0, exitCode); + Assert.IsNotNull(_fakeUia.LastFocusedElement); + Assert.AreEqual(1, _fakeKeyboard.SendCalls.Count); + Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters, + "revalidate, foreground, focus and send belong to one desktop section"); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Stdin.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Stdin.cs index df6ab9783..46fb0835f 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Stdin.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Stdin.cs @@ -353,7 +353,7 @@ public async Task StdinMonitor_DisposedCts_NeverThrowsUnhandledException() Interlocked.Exchange(ref unhandled, null); var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); - var handler = new UiRecordCommand.Handler(null!, null!, null!, null!); + var handler = new UiRecordCommand.Handler(null!, null!, null!, null!, null!); cts.Dispose(); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.cs index 9325000ef..4b350f0ce 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.cs @@ -21,6 +21,8 @@ public partial class UiCommandTests : BaseCommandTests private FakeOwnedWindowFinder _fakeWindowFinder = null!; private FakeSystemUiQuery _fakeSystemQuery = null!; private FakePollDelay _fakePollDelay = null!; + private FakeInteractiveDesktopLock _fakeDesktopLock = null!; + private FakeDesktopForegroundService _fakeDesktopForeground = null!; private void AssertJsonErrorCode(string expectedCode) => AssertJsonErrorCodeIn(ConsoleStdErr.ToString(), expectedCode); @@ -45,6 +47,8 @@ protected override IServiceCollection ConfigureServices(IServiceCollection servi _fakeWindowFinder = new FakeOwnedWindowFinder(); _fakeSystemQuery = new FakeSystemUiQuery(); _fakePollDelay = new FakePollDelay(); + _fakeDesktopLock = new FakeInteractiveDesktopLock(); + _fakeDesktopForeground = new FakeDesktopForegroundService(); return services .AddSingleton(_fakeUia) .AddSingleton(_fakeSession) @@ -54,6 +58,10 @@ protected override IServiceCollection ConfigureServices(IServiceCollection servi .AddSingleton(_fakePointer) .AddSingleton(_fakeWindowFinder) .AddSingleton(_fakeSystemQuery) + // Coordination is faked so command tests never queue against the developer's live desktop + // and can assert what each command asked the coordinator for (issue #764). + .AddSingleton(_fakeDesktopLock) + .AddSingleton(_fakeDesktopForeground) .AddSingleton(_fakePollDelay); } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiClickCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiClickCommand.cs index 6353dcc24..3dbe7c61d 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiClickCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiClickCommand.cs @@ -10,6 +10,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -46,12 +47,21 @@ public class Handler( ISelectorService selectorService, IMouseInput mouseInput, IForegroundGuard foregroundGuard, + IDesktopForegroundService desktopForeground, + ISystemUiQuery systemQuery, + IInteractiveDesktopLock desktopLock, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { /// Cursor-settle pause (ms) before the final confirm read and button-down. private const int CursorSettleMs = 50; - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + + protected override string Operation => "ui click"; + + /// A click drives the shared cursor and OS-wide SendInput stream. + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.DesktopExclusive; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); @@ -70,6 +80,16 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + // Preflight rejected a missing selector, so this is non-null by construction. + var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument)!; + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); var doubleClick = parseResult.GetValue(DoubleClickOption); var rightClick = parseResult.GetValue(RightClickOption); @@ -87,10 +107,6 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio var clickType = doubleClick ? "double-click" : rightClick ? "right-click" : "click"; - // Get element center from bounding rect - int centerX = (int)(element.X + element.Width / 2.0); - int centerY = (int)(element.Y + element.Height / 2.0); - if (element.Width == 0 || element.Height == 0) { logger.LogError("{Symbol} Element has zero size — cannot click.", UiSymbols.Error); @@ -98,70 +114,87 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } - // Use the element's own window handle if available, otherwise fall back to session + // Use the element's own window handle if available, otherwise fall back to session. + // Advisory only — refreshed from the re-resolved element inside the section below. var targetHwnd = element.WindowHandle ?? session.WindowHandle; - // Bring target window to foreground - if (targetHwnd != 0) - { - Windows.Win32.PInvoke.SetForegroundWindow( - new Windows.Win32.Foundation.HWND((nint)targetHwnd)); - await Task.Delay(100, cancellationToken); // let window activate - } + int centerX; + int centerY; - // Re-resolve the element just before clicking (N5): foregrounding can restore/animate the - // window, so the rect captured above may be stale. Refuse rather than click empty space if - // the target is still moving. - var stable = await GestureTargeting.ResolveStableAsync( - uiAutomation, session, selector, element, - GestureTargeting.DefaultMaxReads, GestureTargeting.DefaultReadDelayMs, null, cancellationToken); - if (!GestureTargeting.TryReport(stable, logger, json, selectorStr, clickType)) + // Everything that touches the shared desktop — foreground, cursor, SendInput — happens + // inside one section, and so does the resolution whose result is acted upon (spec §10.5). + // The element found above is advisory: foregrounding can restore or animate the window, + // and another workflow may have moved, closed or replaced the target while this command + // was queued. + await using (await turn.EnterAsync(cancellationToken).ConfigureAwait(false)) { - return 1; - } - centerX = stable.CenterX; - centerY = stable.CenterY; - - // Verify the target STILL holds the foreground as the first gate before the OS-wide click - // (F1) — matches drag / scroll --wheel. The re-resolve above awaits UIA reads during which - // focus could shift, so we check here, after the awaits. Also yields a clean - // no_interactive_desktop error on a locked session instead of a misleading SendInput failure. - // (A second, final gate runs below, after the cursor-settle confirm read.) - if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, clickType)) - { - return 1; - } + // Re-resolve before anything else so the HWND we foreground and validate is the + // current one, not the one captured before the wait. + var stable = await GestureTargeting.ResolveStableAsync( + uiAutomation, session, selector, element, + GestureTargeting.DefaultMaxReads, GestureTargeting.DefaultReadDelayMs, null, cancellationToken); + if (!GestureTargeting.TryReport(stable, logger, json, selectorStr, clickType)) + { + return 1; + } - // Close the residual re-resolve→button-down race (F3/N5): position the cursor, let it - // settle, then re-confirm the target hasn't drifted during that settle window before - // pressing. ResolveStableAsync can read a continuously-animating target as "settled" by - // chance and the element then moves during the ~50 ms cursor settle, landing the click on - // empty space yet reporting success. By doing the settle here and a fresh confirm read - // immediately before the button-down (which itself uses settleMs: 0), a reported ✅ means - // the target was still in place when the button went down. - mouseInput.MoveCursor(centerX, centerY); - await Task.Delay(CursorSettleMs, cancellationToken); - - var confirmed = await GestureTargeting.ConfirmStillAsync( - uiAutomation, session, selector, stable.Element, cancellationToken); - if (!GestureTargeting.TryReport(confirmed, logger, json, selectorStr, clickType)) - { - return 1; - } - centerX = confirmed.CenterX; - centerY = confirmed.CenterY; + // TryReport returned true, so a settled element was resolved and Element is populated. + targetHwnd = stable.Element.WindowHandle ?? session.WindowHandle; - // Final foreground gate after the awaited confirm read — the true last check before the - // OS-wide button-down (M3). Focus could have shifted during the cursor-settle + confirm - // read above, which the first gate (before those awaits) couldn't see. - if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, clickType)) - { - return 1; - } + if (!DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, targetHwnd, session.ProcessId, logger, json, clickType, parseResult.InvocationConfiguration.Error)) + { + return 1; + } - // Perform the click via SendInput — no extra settle, the cursor is already positioned and - // the target just confirmed in place. - mouseInput.Click(centerX, centerY, doubleClick, rightClick, settleMs: 0); + // Bring target window to foreground + if (targetHwnd != 0) + { + desktopForeground.RequestForeground(targetHwnd); + await Task.Delay(100, cancellationToken); // let window activate + } + + // Verify the target STILL holds the foreground as the first gate before the OS-wide click + // (F1) — matches drag / scroll --wheel. The re-resolve above awaits UIA reads during which + // focus could shift, so we check here, after the awaits. Also yields a clean + // no_interactive_desktop error on a locked session instead of a misleading SendInput failure. + // (A second, final gate runs below, after the cursor-settle confirm read.) + if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, clickType)) + { + return 1; + } + + // Close the residual re-resolve→button-down race (F3/N5): position the cursor, let it + // settle, then re-confirm the target hasn't drifted during that settle window before + // pressing. ResolveStableAsync can read a continuously-animating target as "settled" by + // chance and the element then moves during the ~50 ms cursor settle, landing the click on + // empty space yet reporting success. By doing the settle here and a fresh confirm read + // immediately before the button-down (which itself uses settleMs: 0), a reported ✅ means + // the target was still in place when the button went down. + mouseInput.MoveCursor(stable.CenterX, stable.CenterY); + await Task.Delay(CursorSettleMs, cancellationToken); + + var confirmed = await GestureTargeting.ConfirmStillAsync( + uiAutomation, session, selector, stable.Element, cancellationToken); + if (!GestureTargeting.TryReport(confirmed, logger, json, selectorStr, clickType)) + { + return 1; + } + centerX = confirmed.CenterX; + centerY = confirmed.CenterY; + + // Final foreground gate after the awaited confirm read — the true last check before the + // OS-wide button-down (M3). Focus could have shifted during the cursor-settle + confirm + // read above, which the first gate (before those awaits) couldn't see. + if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, clickType)) + { + return 1; + } + + // Perform the click via SendInput — no extra settle, the cursor is already positioned and + // the target just confirmed in place. + mouseInput.Click(centerX, centerY, doubleClick, rightClick, settleMs: 0); + } var elementId = (element.Selector ?? element.Id ?? ""); diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiDragCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiDragCommand.cs index 7bb00e9c0..349a99a05 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiDragCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiDragCommand.cs @@ -10,6 +10,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -75,20 +76,27 @@ public class Handler( ISelectorService selectorService, IMouseInput mouseInput, IForegroundGuard foregroundGuard, + IDesktopForegroundService desktopForeground, + ISystemUiQuery systemQuery, + IInteractiveDesktopLock desktopLock, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { // Cursor-settle pause (ms) after positioning on the from-point, before the confirm read + press. private const int CursorSettleMs = 50; - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui drag"; + + /// A drag holds the shared cursor and mouse button across the whole gesture. + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.DesktopExclusive; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var app = parseResult.GetValue(SharedUiOptions.AppOption); var window = parseResult.GetValue(SharedUiOptions.WindowOption); var arg0 = parseResult.GetValue(FromArgument); var arg1 = parseResult.GetValue(ToArgument); - var rightButton = parseResult.GetValue(RightButtonOption); var holdMs = parseResult.GetValue(HoldOption); var dwellMs = parseResult.GetValue(DwellOption); @@ -98,6 +106,14 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + if (string.IsNullOrWhiteSpace(arg0) || string.IsNullOrWhiteSpace(arg1)) + { + logger.LogError("{Symbol} Specify both and — each is an element selector or x,y coordinates.", UiSymbols.Error); + UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, + "Specify both and — each is an element selector or x,y coordinates."); + return 1; + } + if (holdMs < 0 || dwellMs < 0) { logger.LogError("{Symbol} --hold-ms and --dwell-ms must be zero or positive.", UiSymbols.Error); @@ -106,18 +122,25 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + // Preflight rejected empty endpoints, so both are non-null by construction. + var arg0 = parseResult.GetValue(FromArgument)!; + var arg1 = parseResult.GetValue(ToArgument)!; + var rightButton = parseResult.GetValue(RightButtonOption); + var holdMs = parseResult.GetValue(HoldOption); + var dwellMs = parseResult.GetValue(DwellOption); + try { var session = await sessionService.ResolveSessionAsync(app, window, cancellationToken); - if (string.IsNullOrWhiteSpace(arg0) || string.IsNullOrWhiteSpace(arg1)) - { - logger.LogError("{Symbol} Specify both and — each is an element selector or x,y coordinates.", UiSymbols.Error); - UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, - "Specify both and — each is an element selector or x,y coordinates."); - return 1; - } - var from = await ResolveEndpointAsync(arg0, "from", session, json, cancellationToken); if (!from.Ok) { @@ -130,84 +153,101 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } - int fromX = from.X; - int fromY = from.Y; - int toX = to.X; - int toY = to.Y; - // Prefer the HWND of whichever endpoint resolved from a real element; fall back to the - // session window when both endpoints are bare coordinates. + int fromX; + int fromY; + int toX; + int toY; + // Advisory only — refreshed from the re-resolved endpoints inside the section below. long targetHwnd = from.Hwnd != 0 ? from.Hwnd : (to.Hwnd != 0 ? to.Hwnd : session.WindowHandle); - if (targetHwnd != 0) + // Foreground, endpoint re-resolution and the button-down…up sequence all share the desktop + // and run in one section; the result formatting below does not. + await using (await turn.EnterAsync(cancellationToken).ConfigureAwait(false)) { - Windows.Win32.PInvoke.SetForegroundWindow( - new Windows.Win32.Foundation.HWND((nint)targetHwnd)); - await Task.Delay(100, cancellationToken); - } - - // Foregrounding can shift/animate the window (restore, snap, layout settle); re-resolve any - // element endpoint so we drag where it is *now*, and refuse rather than hit empty space if - // it's still moving. Raw-coordinate endpoints can't be verified, so they pass through. - var fromStable = await StabilizeAsync(from, session, "from", json, cancellationToken); - if (!fromStable.Ok) - { - return 1; - } + // Foregrounding can shift/animate the window (restore, snap, layout settle), and another + // workflow may have moved or closed an endpoint while this command was queued. Re-resolve + // any element endpoint FIRST so the HWND we foreground and validate is the current one + // (spec §10.5), and refuse rather than hit empty space if it's still moving. + // Raw-coordinate endpoints can't be verified, so they pass through. + var fromStable = await StabilizeAsync(from, session, "from", json, cancellationToken); + if (!fromStable.Ok) + { + return 1; + } - var toStable = await StabilizeAsync(to, session, "to", json, cancellationToken); - if (!toStable.Ok) - { - return 1; - } + var toStable = await StabilizeAsync(to, session, "to", json, cancellationToken); + if (!toStable.Ok) + { + return 1; + } - fromX = fromStable.X; - fromY = fromStable.Y; - toX = toStable.X; - toY = toStable.Y; - - // Verify the target STILL holds the foreground as the first gate before the OS-wide drag. - // The stabilize re-resolve above performs awaited UIA reads (with delays); another window - // could steal focus during that gap, so we check here — after the awaits, not before them. - // Also distinguishes a locked/secure desktop from a wrong-window foreground. (For an element - // from-point a second, final gate runs below, after the cursor-settle confirm read.) - if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "drag")) - { - return 1; - } + fromX = fromStable.X; + fromY = fromStable.Y; + toX = toStable.X; + toY = toStable.Y; - // Close the residual re-resolve→button-down race for the from-point (mirrors click's F3 - // fix): the button-down happens at , and MouseInput.Drag's own pre-press settle is an - // unguarded window in which a still-animating from-element could drift, so the press grabs - // empty space yet the drag reports success. When is an element, position the cursor - // on it, let it settle, confirm it hasn't moved, re-check the foreground, then press with - // settleMs: 0 — so a reported ✅ means the button went down on the element. A raw-coordinate - // from-point has nothing to confirm and keeps MouseInput.Drag's internal settle. - int dragSettleMs = 50; - if (from.Selector is not null && fromStable.StableElement is not null) - { - mouseInput.MoveCursor(fromX, fromY); - await Task.Delay(CursorSettleMs, cancellationToken); + // Prefer the HWND of whichever endpoint re-resolved to a real element; fall back to the + // session window when both endpoints are bare coordinates. + targetHwnd = fromStable.StableElement?.WindowHandle + ?? toStable.StableElement?.WindowHandle + ?? (from.Hwnd != 0 ? from.Hwnd : (to.Hwnd != 0 ? to.Hwnd : session.WindowHandle)); - var confirmed = await GestureTargeting.ConfirmStillAsync( - uiAutomation, session, from.Selector, fromStable.StableElement, cancellationToken); - if (!GestureTargeting.TryReport(confirmed, logger, json, from.Token ?? "from", "drag")) + if (!DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, targetHwnd, session.ProcessId, logger, json, "drag", parseResult.InvocationConfiguration.Error)) { return 1; } - fromX = confirmed.CenterX; - fromY = confirmed.CenterY; - // Final foreground gate after the awaited confirm read (focus could shift during it). + if (targetHwnd != 0) + { + desktopForeground.RequestForeground(targetHwnd); + await Task.Delay(100, cancellationToken); + } + + // Verify the target STILL holds the foreground as the first gate before the OS-wide drag. + // The stabilize re-resolve above performs awaited UIA reads (with delays); another window + // could steal focus during that gap, so we check here — after the awaits, not before them. + // Also distinguishes a locked/secure desktop from a wrong-window foreground. (For an element + // from-point a second, final gate runs below, after the cursor-settle confirm read.) if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "drag")) { return 1; } - // Cursor already positioned on the just-confirmed from-point; press without re-settling. - dragSettleMs = 0; - } + // Close the residual re-resolve→button-down race for the from-point (mirrors click's F3 + // fix): the button-down happens at , and MouseInput.Drag's own pre-press settle is an + // unguarded window in which a still-animating from-element could drift, so the press grabs + // empty space yet the drag reports success. When is an element, position the cursor + // on it, let it settle, confirm it hasn't moved, re-check the foreground, then press with + // settleMs: 0 — so a reported ✅ means the button went down on the element. A raw-coordinate + // from-point has nothing to confirm and keeps MouseInput.Drag's internal settle. + int dragSettleMs = 50; + if (from.Selector is not null && fromStable.StableElement is not null) + { + mouseInput.MoveCursor(fromX, fromY); + await Task.Delay(CursorSettleMs, cancellationToken); + + var confirmed = await GestureTargeting.ConfirmStillAsync( + uiAutomation, session, from.Selector, fromStable.StableElement, cancellationToken); + if (!GestureTargeting.TryReport(confirmed, logger, json, from.Token ?? "from", "drag")) + { + return 1; + } + fromX = confirmed.CenterX; + fromY = confirmed.CenterY; + + // Final foreground gate after the awaited confirm read (focus could shift during it). + if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "drag")) + { + return 1; + } + + // Cursor already positioned on the just-confirmed from-point; press without re-settling. + dragSettleMs = 0; + } - mouseInput.Drag(fromX, fromY, toX, toY, rightButton, holdMs, dwellMs, settleMs: dragSettleMs); + mouseInput.Drag(fromX, fromY, toX, toY, rightButton, holdMs, dwellMs, settleMs: dragSettleMs); + } var button = rightButton ? "right" : "left"; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiFocusCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiFocusCommand.cs index 1b0f6ed5f..8c9584b51 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiFocusCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiFocusCommand.cs @@ -10,6 +10,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -31,10 +32,20 @@ public class Handler( IUiSessionService sessionService, IUiAutomationService uiAutomation, ISelectorService selectorService, + ISystemUiQuery systemQuery, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui focus"; + + /// + /// UIA SetFocus moves the shared keyboard focus, which is exactly the resource that lets + /// concurrent workflows dismiss each other's transient UI. + /// + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.DesktopExclusive; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); @@ -53,19 +64,49 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + // Preflight rejected a missing selector, so this is non-null by construction. + var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument)!; + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + try { + // Advisory resolution: gives a clean element_not_found without holding the desktop. var session = await sessionService.ResolveSessionAsync(app, window, cancellationToken); var selector = selectorService.Parse(selectorStr); - var element = await uiAutomation.FindSingleElementAsync(session, selector, cancellationToken); + var advisory = await uiAutomation.FindSingleElementAsync(session, selector, cancellationToken); - if (element is null) + if (advisory is null) { UiErrors.ElementNotFound(logger, selectorStr, json); return 1; } - await uiAutomation.FocusAsync(session, element, cancellationToken); + UiElement element; + + // Only the focus change itself needs the desktop; the result formatting below does not. + await using (await turn.EnterAsync(cancellationToken).ConfigureAwait(false)) + { + // Spec §10.5: focus the element as it exists now, not as it existed before the wait. + element = await uiAutomation.FindSingleElementAsync(session, selector, cancellationToken) + ?? throw new UiElementNotFoundException(selectorStr); + + if (!DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, element.WindowHandle ?? session.WindowHandle, session.ProcessId, + logger, json, "focus", parseResult.InvocationConfiguration.Error)) + { + return 1; + } + + await uiAutomation.FocusAsync(session, element, cancellationToken); + } + if (json) { var result = new UiFocusResult { ElementId = (element.Selector ?? element.Id ?? ""), Hwnd = session.WindowHandle }; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiGetFocusedCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiGetFocusedCommand.cs index ea242c3a4..0b7f781bf 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiGetFocusedCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiGetFocusedCommand.cs @@ -9,6 +9,7 @@ using Spectre.Console; using WinApp.Cli.Helpers; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -28,9 +29,15 @@ public class Handler( IUiSessionService sessionService, IUiAutomationService uiAutomation, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui get-focused"; + + /// Reading which element has focus never changes it, so no turn is claimed. + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.Observe; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var app = parseResult.GetValue(SharedUiOptions.AppOption); @@ -42,6 +49,15 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + try { var session = await sessionService.ResolveSessionAsync(app, window, cancellationToken); diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiGetPropertyCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiGetPropertyCommand.cs index ef0ca9d69..3f4fa6969 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiGetPropertyCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiGetPropertyCommand.cs @@ -10,6 +10,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -33,9 +34,15 @@ public class Handler( IUiAutomationService uiAutomation, ISelectorService selectorService, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui get-property"; + + /// Reading properties is a background-safe UIA read that never touches the desktop. + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.Observe; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); @@ -54,6 +61,16 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + // Preflight rejected a missing selector, so this is non-null by construction. + var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument)!; + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); var propertyName = parseResult.GetValue(SharedUiOptions.PropertyOption); try diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiGetValueCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiGetValueCommand.cs index ccb418ab0..2b175253b 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiGetValueCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiGetValueCommand.cs @@ -9,6 +9,7 @@ using Spectre.Console; using WinApp.Cli.Helpers; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -33,9 +34,15 @@ public class Handler( IUiAutomationService uiAutomation, ISelectorService selectorService, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui get-value"; + + /// Reading a value is a background-safe UIA read that never touches the desktop. + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.Observe; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); @@ -54,6 +61,17 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + // Preflight rejected a missing selector, so this is non-null by construction. + var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument)!; + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + try { var session = await sessionService.ResolveSessionAsync(app, window, cancellationToken); diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiHoverCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiHoverCommand.cs index 6d0457d50..d2282fde5 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiHoverCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiHoverCommand.cs @@ -10,6 +10,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -40,10 +41,18 @@ public class Handler( ISelectorService selectorService, IMouseInput mouseInput, IForegroundGuard foregroundGuard, + IDesktopForegroundService desktopForeground, + ISystemUiQuery systemQuery, + IInteractiveDesktopLock desktopLock, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui hover"; + + /// Hovering moves the shared cursor and holds it there for the dwell. + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.DesktopExclusive; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); @@ -70,6 +79,18 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + // Preflight rejected a missing selector, so this is non-null by construction. + var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument)!; + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + var dwellTime = parseResult.GetValue(DwellTimeOption); + try { var session = await sessionService.ResolveSessionAsync(app, window, cancellationToken); @@ -82,9 +103,6 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } - int centerX = (int)(element.X + element.Width / 2.0); - int centerY = (int)(element.Y + element.Height / 2.0); - if (element.Width == 0 || element.Height == 0) { logger.LogError("{Symbol} Element has zero size — cannot hover.", UiSymbols.Error); @@ -92,44 +110,60 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } - // Use the element's own window handle if available, otherwise fall back to session + // Use the element's own window handle if available, otherwise fall back to session. + // Advisory only — refreshed from the re-resolved element inside the section below. var targetHwnd = element.WindowHandle ?? session.WindowHandle; - // Bring target window to foreground - if (targetHwnd != 0) - { - Windows.Win32.PInvoke.SetForegroundWindow( - new Windows.Win32.Foundation.HWND((nint)targetHwnd)); - await Task.Delay(100, cancellationToken); - } + int centerX; + int centerY; - // Re-resolve just before hovering (N5): foregrounding can restore/animate the window, so - // the captured rect may be stale. Refuse rather than hover empty space if it's still moving. - var stable = await GestureTargeting.ResolveStableAsync( - uiAutomation, session, selector, element, - GestureTargeting.DefaultMaxReads, GestureTargeting.DefaultReadDelayMs, null, cancellationToken); - if (!GestureTargeting.TryReport(stable, logger, json, selectorStr, "hover")) - { - return 1; - } - centerX = stable.CenterX; - centerY = stable.CenterY; - - // Verify the target STILL holds the foreground as the final gate before the OS-wide hover - // (F1) — matches click / drag / scroll --wheel. Checked here, after the awaited re-resolve, - // to close the focus-steal race; also yields a clean no_interactive_desktop error on a - // locked session instead of a misleading SendInput failure, and refuses to move the pointer - // over whatever window grabbed the foreground. - if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "hover")) + // Foreground, re-resolve, cursor move and the dwell all share the desktop, so they run in + // one section; the JSON/log output below deliberately does not. + await using (await turn.EnterAsync(cancellationToken).ConfigureAwait(false)) { - return 1; - } + // Re-resolve first (spec §10.5): the target may have moved, closed, or been replaced + // while this command waited, so the HWND we foreground must come from the fresh read. + var stable = await GestureTargeting.ResolveStableAsync( + uiAutomation, session, selector, element, + GestureTargeting.DefaultMaxReads, GestureTargeting.DefaultReadDelayMs, null, cancellationToken); + if (!GestureTargeting.TryReport(stable, logger, json, selectorStr, "hover")) + { + return 1; + } + centerX = stable.CenterX; + centerY = stable.CenterY; + // TryReport returned true, so a settled element was resolved and Element is populated. + targetHwnd = stable.Element.WindowHandle ?? session.WindowHandle; + + if (!DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, targetHwnd, session.ProcessId, logger, json, "hover", parseResult.InvocationConfiguration.Error)) + { + return 1; + } + + // Bring target window to foreground + if (targetHwnd != 0) + { + desktopForeground.RequestForeground(targetHwnd); + await Task.Delay(100, cancellationToken); + } + + // Verify the target STILL holds the foreground as the final gate before the OS-wide hover + // (F1) — matches click / drag / scroll --wheel. Checked here, after the awaited re-resolve, + // to close the focus-steal race; also yields a clean no_interactive_desktop error on a + // locked session instead of a misleading SendInput failure, and refuses to move the pointer + // over whatever window grabbed the foreground. + if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "hover")) + { + return 1; + } - // Move mouse to element center with a small wiggle to trigger hover detection - mouseInput.Hover(centerX, centerY); + // Move mouse to element center with a small wiggle to trigger hover detection + mouseInput.Hover(centerX, centerY); - // Wait for dwell time to allow hover effects to appear - await Task.Delay(dwellTime, cancellationToken); + // Wait for dwell time to allow hover effects to appear + await Task.Delay(dwellTime, cancellationToken); + } var elementId = element.Selector ?? element.Id ?? ""; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiInspectCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiInspectCommand.cs index 4e79d753d..3ba7125de 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiInspectCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiInspectCommand.cs @@ -10,6 +10,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -46,13 +47,17 @@ public partial class Handler( IUiSessionService sessionService, IUiAutomationService uiAutomation, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui inspect"; + + /// Walking the UIA tree is a background-safe read. + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.Observe; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); - - var selector = parseResult.GetValue(SharedUiOptions.SelectorArgument); var app = parseResult.GetValue(SharedUiOptions.AppOption); var window = parseResult.GetValue(SharedUiOptions.WindowOption); @@ -61,6 +66,17 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio UiErrors.MissingApp(logger, json); return 1; } + + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + + var selector = parseResult.GetValue(SharedUiOptions.SelectorArgument); + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); var depth = parseResult.GetRequiredValue(SharedUiOptions.DepthOption); var depthExplicit = parseResult.GetResult(SharedUiOptions.DepthOption)?.Implicit == false; var ancestors = parseResult.GetValue(AncestorsOption); diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiInvokeCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiInvokeCommand.cs index 26cf94131..951363ab3 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiInvokeCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiInvokeCommand.cs @@ -10,6 +10,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -32,10 +33,21 @@ public class Handler( IUiSessionService sessionService, IUiAutomationService uiAutomation, ISelectorService selectorService, + ISystemUiQuery systemQuery, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui invoke"; + + /// + /// Spec §6.4: the call itself is a UIA pattern, but an invoked control may synchronously + /// activate, focus, toggle, select, expand, or open transient UI, so it is treated as + /// desktop-exclusive and holds active.lock across the pattern call. + /// + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.DesktopExclusive; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); @@ -54,37 +66,75 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + // Preflight rejected a missing selector, so this is non-null by construction. + var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument)!; + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + try { + // Resolution before the section is advisory only: it produces a clear element_not_found + // without holding the desktop. The element actually invoked is re-resolved inside. var session = await sessionService.ResolveSessionAsync(app, window, cancellationToken); var selector = selectorService.Parse(selectorStr); - var element = await uiAutomation.FindSingleElementAsync(session, selector, cancellationToken); + var advisory = await uiAutomation.FindSingleElementAsync(session, selector, cancellationToken); - if (element is null) + if (advisory is null) { UiErrors.ElementNotFound(logger, selectorStr, json); return 1; } string pattern; - try + UiElement element; + UiElement invoked; + + // The pattern call is the desktop-sensitive moment; output formatting below is not. + await using (await turn.EnterAsync(cancellationToken).ConfigureAwait(false)) { - pattern = await uiAutomation.InvokeAsync(session, element, cancellationToken); + // Spec §10.5: never invoke an element resolved before the queue wait. Another + // workflow may have navigated, closed, or rebuilt the tree in the meantime. + element = await uiAutomation.FindSingleElementAsync(session, selector, cancellationToken) + ?? throw new UiElementNotFoundException(selectorStr); + + if (!DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, element.WindowHandle ?? session.WindowHandle, session.ProcessId, + logger, json, "invoke", parseResult.InvocationConfiguration.Error)) + { + return 1; + } + + invoked = element; + try + { + pattern = await uiAutomation.InvokeAsync(session, element, cancellationToken); + } + catch (InvalidOperationException) when (element.InvokableAncestor is { } ancestor) + { + // Element isn't invokable but has an invokable ancestor — invoke that instead + pattern = await uiAutomation.InvokeAsync(session, ancestor, cancellationToken); + invoked = ancestor; + } } - catch (InvalidOperationException) when (element.InvokableAncestor is { } ancestor) + + if (!ReferenceEquals(invoked, element)) { - // Element isn't invokable but has an invokable ancestor — invoke that instead - pattern = await uiAutomation.InvokeAsync(session, ancestor, cancellationToken); if (json) { - var result = new UiInvokeResult { ElementId = ancestor.Selector ?? ancestor.Id ?? "", Pattern = pattern, Hwnd = session.WindowHandle }; + var ancestorResult = new UiInvokeResult { ElementId = invoked.Selector ?? invoked.Id ?? "", Pattern = pattern, Hwnd = session.WindowHandle }; ansiConsole.Profile.Out.Writer.WriteLine( - JsonSerializer.Serialize(result, UiJsonContext.Default.UiInvokeResult)); + JsonSerializer.Serialize(ancestorResult, UiJsonContext.Default.UiInvokeResult)); } else { logger.LogInformation("Invoked ancestor {Selector} \"{Name}\" via {Pattern} (matched text element was not invokable)", - ancestor.Selector ?? ancestor.Id, ancestor.Name, pattern); + invoked.Selector ?? invoked.Id, invoked.Name, pattern); } return 0; } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiListWindowsCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiListWindowsCommand.cs index a0c344372..b34743284 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiListWindowsCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiListWindowsCommand.cs @@ -10,6 +10,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -53,9 +54,18 @@ internal static bool ShouldIncludeWindow(string? title, int width, int height, b public class Handler( IUiAutomationService uiAutomation, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui list-windows"; + + /// Enumerating windows is a read-only Win32/UIA query. + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.Observe; + + /// Nothing to validate locally: --app is optional for this command. + protected override int? Preflight(ParseResult parseResult) => null; + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var app = parseResult.GetValue(SharedUiOptions.AppOption); diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiPenCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiPenCommand.cs index a3fbb9a51..2d13376e6 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiPenCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiPenCommand.cs @@ -10,6 +10,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -85,10 +86,46 @@ public class Handler( IPointerInput pointerInput, IForegroundGuard foregroundGuard, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IDesktopForegroundService desktopForeground, + ISystemUiQuery systemQuery, + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + /// + /// The fully validated argument set, produced once by so + /// and share one parse. + /// + private readonly record struct PenArgs( + bool Json, + string? SelectorStr, + string? App, + long? Window, + string? AtStr, + string? PathStr, + PointerPoint? At, + List? Path, + float Pressure, + int TiltX, + int TiltY, + bool Eraser, + int DurationMs); + + protected override string Operation => "ui pen"; + + /// Synthetic pen injection is OS-wide and lands wherever the desktop points. + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.DesktopExclusive; + + protected override int? Preflight(ParseResult parseResult) => Validate(parseResult, out _); + + /// + /// Semantic validation. Runs before the missing-app check so a malformed value produces + /// invalid_arguments rather than missing_app (M5 root-cause fix), and before any + /// coordination so a malformed command never joins the desktop queue. + /// + private int? Validate(ParseResult parseResult, out PenArgs args) { + args = default; + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); var app = parseResult.GetValue(SharedUiOptions.AppOption); @@ -104,8 +141,14 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio bool pathWasSupplied = (parseResult.GetResult(PathOption)?.Tokens.Count ?? 0) > 0; bool durationWasSupplied = (parseResult.GetResult(DurationOption)?.Tokens.Count ?? 0) > 0; - // Semantic validation runs BEFORE the missing-app check so a malformed value - // produces invalid_arguments rather than missing_app (M5 root-cause fix). + int RejectInvalidArguments(string message) + { + logger.LogError("{Symbol} {Message}", UiSymbols.Error, message); + UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, message, + errorOut: parseResult.InvocationConfiguration.Error); + return 1; + } + if (!float.IsFinite(pressure) || pressure < 0f || pressure > 1f) { logger.LogError("{Symbol} --pressure must be a finite number between 0.0 and 1.0. Got '{Pressure}'.", UiSymbols.Error, pressure); @@ -184,6 +227,18 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + args = new PenArgs(json, selectorStr, app, window, atStr, pathStr, at, path, + pressure, tiltX, tiltY, eraser, durationMs); + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + // Preflight already ran this and rejected every invalid combination, so it cannot fail here. + Validate(parseResult, out var args); + var (json, selectorStr, app, window, atStr, pathStr, at, path, + pressure, tiltX, tiltY, eraser, durationMs) = args; + // Track whether --path was provided (before the inner block mutates path). // Used by M7: the selector branch calls SetForeground during stable-resolve so we skip // the post-resolution SetForeground for that path only. @@ -198,44 +253,59 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio // was given, or selectorStr when the selector resolved the contact point. var targetLabel = pathStr ?? (at is not null ? atStr : selectorStr); - // Build the ink path: explicit --path wins; else --at; else the selector's center. - if (path is null) + PointerCommandSupport.InjectionPreparation prep; + + // Foreground and synthetic pointer injection share the desktop; the selector re-resolve + // inside ResolvePointAsync foregrounds the target, so it belongs in the section too. The + // warning composition and result formatting below do not. + await using (await turn.EnterAsync(cancellationToken).ConfigureAwait(false)) { - var target = await PointerCommandSupport.ResolvePointAsync( - uiAutomation, selectorService, session, selectorStr, at, atStr, - "pen", "pen point", logger, json, cancellationToken); - if (!target.Ok) + // Build the ink path: explicit --path wins; else --at; else the selector's center. + if (path is null) { - return 1; + var target = await PointerCommandSupport.ResolvePointAsync( + uiAutomation, selectorService, desktopForeground, session, selectorStr, at, atStr, + "pen", "pen point", logger, json, cancellationToken); + if (!target.Ok) + { + return 1; + } + + targetHwnd = target.TargetHwnd; + path = [target.Point]; } - targetHwnd = target.TargetHwnd; - path = [target.Point]; - } - - // M7: SetForeground only when the selector branch did not already do it. - // The selector branch (no --path and no --at) calls SetForeground during stable-resolve; - // the --at and --path branches do not, so they need it here before injection. - if (pathFromOption || at is not null) - { - await PointerCommandSupport.SetForegroundAsync(targetHwnd, cancellationToken); - } + if (!DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, targetHwnd, session.ProcessId, logger, json, "pen", + parseResult.InvocationConfiguration.Error)) + { + return 1; + } + + // M7: SetForeground only when the selector branch did not already do it. + // The selector branch (no --path and no --at) calls SetForeground during stable-resolve; + // the --at and --path branches do not, so they need it here before injection. + if (pathFromOption || at is not null) + { + await PointerCommandSupport.SetForegroundAsync(desktopForeground, targetHwnd, cancellationToken); + } - var prep = PointerCommandSupport.TryPrepareInjection( - uiAutomation, foregroundGuard, targetHwnd, path, "pen", "pen input", logger, json); - if (!prep.Ok) - { - return 1; - } + prep = PointerCommandSupport.TryPrepareInjection( + uiAutomation, foregroundGuard, targetHwnd, path, "pen", "pen input", logger, json); + if (!prep.Ok) + { + return 1; + } - // M6: narrow the injection_unsupported catch to only the actual injection call so that - // pre-injection failures (element not found, etc.) are NOT mis-classified as - // injection_unsupported. Session resolution failures surface as missing_app (outer catch). - if (!PointerCommandSupport.TryInject( - () => pointerInput.Pen(path, pressure, tiltX, tiltY, eraser, durationMs), - logger, json, parseResult.InvocationConfiguration.Error)) - { - return 1; + // M6: narrow the injection_unsupported catch to only the actual injection call so that + // pre-injection failures (element not found, etc.) are NOT mis-classified as + // injection_unsupported. Session resolution failures surface as missing_app (outer catch). + if (!PointerCommandSupport.TryInject( + () => pointerInput.Pen(path, pressure, tiltX, tiltY, eraser, durationMs), + logger, json, parseResult.InvocationConfiguration.Error)) + { + return 1; + } } var action = eraser ? "erase" : (path.Count > 1 ? "draw" : "tap"); @@ -320,14 +390,6 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio UiErrors.GenericError(logger, ex, json, parseResult.InvocationConfiguration.Error); return 1; } - - int RejectInvalidArguments(string message) - { - logger.LogError("{Symbol} {Message}", UiSymbols.Error, message); - UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, message, - errorOut: parseResult.InvocationConfiguration.Error); - return 1; - } } } } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs index 3f5dbecfd..8bac7b885 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs @@ -8,6 +8,7 @@ using Spectre.Console; using WinApp.Cli.Helpers; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -45,7 +46,8 @@ public class Handler( IUiSessionService sessionService, IUiAutomationService uiAutomation, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { // Test seams: override Console.IsInputRedirected and Console.In without process-level side effects. internal static Func? s_isInputRedirectedOverride; @@ -54,18 +56,25 @@ public class Handler( // Prevents the stdin monitor from racing disposal of its cancellation source. private volatile bool _stdinMonitorStopped; - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui record"; + + /// + /// Spec §6.3: recording claims the workflow turn so no other owner can take the desktop mid-capture, + /// but it is rather than exclusive so the same owner's clicks and + /// typing can be recorded while it runs. Recording duration starts after turn acquisition, because the + /// coordinator returns only once this command is runnable. + /// + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.TurnShared; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); - var quiet = parseResult.GetValue(WinAppRootCommand.QuietOption); - var selector = parseResult.GetValue(SharedUiOptions.SelectorArgument); var app = parseResult.GetValue(SharedUiOptions.AppOption); var window = parseResult.GetValue(SharedUiOptions.WindowOption); var durationSec = parseResult.GetValue(SharedUiOptions.DurationSecOption); var fps = parseResult.GetValue(SharedUiOptions.FpsOption); var maxEdge = parseResult.GetValue(SharedUiOptions.MaxEdgeOption); var maxEdgeExplicit = parseResult.GetResult(SharedUiOptions.MaxEdgeOption)?.Implicit == false; - var captureScreen = parseResult.GetValue(SharedUiOptions.CaptureScreenOption); var output = parseResult.GetValue(SharedUiOptions.OutputOption); var frames = parseResult.GetValue(FramesOption); @@ -109,11 +118,6 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio logger.LogError("{Symbol} {Message}", UiSymbols.Error, message); return 1; } - - if (!maxEdgeExplicit) - { - maxEdge = DefaultFrameArtifactMaxEdge; - } } if (string.IsNullOrWhiteSpace(app) && window is null) @@ -122,6 +126,68 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + // The output path is caller-supplied text, so its shape and collision checks are local too. + // Refusing here means a bad path never waits behind another workflow's turn. + try + { + var probePath = Path.GetFullPath(output ?? "recording-probe.mp4"); + if (frames && output is not null) + { + var probeFramesDirectory = GetFramesDirectory(probePath); + if (Path.Exists(probePath)) + { + UiJsonError.Emit( + json, + UiJsonError.CodeOutputExists, + $"MP4 output already exists: {probePath}", + errorOut: parseResult.InvocationConfiguration.Error, + recoveryHint: "Choose a new --output path; recording never replaces existing artifacts."); + logger.LogError("{Symbol} MP4 output already exists: {Path}", UiSymbols.Error, probePath); + return 1; + } + if (Path.Exists(probeFramesDirectory)) + { + UiJsonError.Emit( + json, + UiJsonError.CodeOutputExists, + $"Frame artifact output already exists: {probeFramesDirectory}", + errorOut: parseResult.InvocationConfiguration.Error, + recoveryHint: "Choose a new --output path; the derived frame directory already exists and is never replaced."); + logger.LogError("{Symbol} Frame artifact output already exists: {Path}", UiSymbols.Error, probeFramesDirectory); + return 1; + } + } + } + catch (Exception pathEx) when (pathEx is ArgumentException or NotSupportedException or PathTooLongException or IOException) + { + UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, $"Invalid output path: {pathEx.Message}"); + logger.LogError("{Symbol} Invalid output path: {Message}", UiSymbols.Error, pathEx.Message); + return 1; + } + + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + var quiet = parseResult.GetValue(WinAppRootCommand.QuietOption); + var selector = parseResult.GetValue(SharedUiOptions.SelectorArgument); + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + var durationSec = parseResult.GetValue(SharedUiOptions.DurationSecOption); + var fps = parseResult.GetValue(SharedUiOptions.FpsOption); + var maxEdge = parseResult.GetValue(SharedUiOptions.MaxEdgeOption); + var maxEdgeExplicit = parseResult.GetResult(SharedUiOptions.MaxEdgeOption)?.Implicit == false; + var captureScreen = parseResult.GetValue(SharedUiOptions.CaptureScreenOption); + var output = parseResult.GetValue(SharedUiOptions.OutputOption); + var frames = parseResult.GetValue(FramesOption); + + if (frames && !maxEdgeExplicit) + { + maxEdge = DefaultFrameArtifactMaxEdge; + } + // Set _stdinMonitorStopped before disposing this source. var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); _stdinMonitorStopped = false; @@ -231,7 +297,7 @@ void OnRecordingStarted(bool frameArtifactsActive) FramesDirectory = framesDirectory, }; - var result = await uiAutomation.RecordAsync(session, selector, options, linkedCts.Token, OnRecordingStarted); + var result = await uiAutomation.RecordAsync(session, selector, options, turn, linkedCts.Token, OnRecordingStarted); if (json) { diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs index 8430b4b93..681bf512f 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs @@ -11,6 +11,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -39,114 +40,190 @@ public class Handler( IOwnedWindowFinder ownedWindowFinder, ISystemUiQuery systemQuery, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui screenshot"; + + /// + /// Spec §6.1/§6.5: a plain screenshot is a background capture and stays an observation. + /// --focus and --capture-screen need the foreground, so they are desktop-exclusive + /// from the start. An observation that turns out to need restore or foreground escalates the + /// whole invocation at run time. + /// + protected override UiTurnMode ResolveMode(ParseResult parseResult) + => parseResult.GetValue(SharedUiOptions.FocusOption) || parseResult.GetValue(SharedUiOptions.CaptureScreenOption) + ? UiTurnMode.DesktopExclusive + : UiTurnMode.Observe; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); - var selector = parseResult.GetValue(SharedUiOptions.SelectorArgument); var app = parseResult.GetValue(SharedUiOptions.AppOption); var window = parseResult.GetValue(SharedUiOptions.WindowOption); + var output = parseResult.GetValue(SharedUiOptions.OutputOption); if (string.IsNullOrWhiteSpace(app) && window is null) { UiErrors.MissingApp(logger, json); return 1; } + + if (output is not null) + { + try + { + _ = Path.GetFullPath(output); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, $"Invalid output path: {ex.Message}"); + logger.LogError("{Symbol} Invalid output path: {Message}", UiSymbols.Error, ex.Message); + return 1; + } + } + + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + var selector = parseResult.GetValue(SharedUiOptions.SelectorArgument); + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); var output = parseResult.GetValue(SharedUiOptions.OutputOption); var captureScreen = parseResult.GetValue(SharedUiOptions.CaptureScreenOption); var focus = parseResult.GetValue(SharedUiOptions.FocusOption); try { - // Screenshot handles multi-window discovery itself (avoids duplicate warning from session resolution) - if (selector is null) + // Spec §6.5: the invocation runs its capture pass observationally first. If ANY target + // turns out to need restore or foreground, every buffered capture is discarded, the whole + // invocation escalates to DesktopExclusive, and the pass runs again from the beginning — + // rediscovering windows and revalidating each one — so the published image never mixes + // pre- and post-escalation pixels. + var observeOnly = turn.Mode == UiTurnMode.Observe; + while (true) { - var allWindows = DiscoverAllWindows(app, window); - if (allWindows is not null && allWindows.Count > 1) + try { - // Resolve session using the largest window's HWND (suppresses session multi-window warning) - var main = allWindows.OrderByDescending(w => - { - var info = UiSessionService.GetWindowInfo(w.Hwnd); - return (long)info.Width * info.Height; - }).First(); - var session = await sessionService.ResolveSessionAsync(null, main.Hwnd, cancellationToken); - return await CaptureMultipleWindows(allWindows, session, output, json, captureScreen, focus, cancellationToken); + return await CapturePassAsync( + parseResult, turn, selector, app, window, output, json, captureScreen, focus, + observeOnly, cancellationToken).ConfigureAwait(false); } - } - - // Single window capture (or element crop) - var singleSession = await sessionService.ResolveSessionAsync(app, window, cancellationToken); - - // Even for single-window session, check for owned dialogs - if (selector is null) - { - var sessionHwnd = (nint)singleSession.WindowHandle; - var ownedWindows = ownedWindowFinder.FindOwnedWindows([(sessionHwnd, singleSession.ProcessId, singleSession.WindowTitle ?? "")]); - if (ownedWindows.Count > 0) + catch (DesktopEscalationRequiredException escalation) when (observeOnly) { - var allWindows = new List<(nint Hwnd, int Pid, string Title)> - { - (sessionHwnd, singleSession.ProcessId, singleSession.WindowTitle ?? "") - }; - allWindows.AddRange(ownedWindows); - return await CaptureMultipleWindows(allWindows, singleSession, output, json, captureScreen, focus, cancellationToken); + logger.LogDebug( + "Screenshot escalating to an exclusive desktop turn: {Reason}", escalation.Reason); + await turn.EscalateToDesktopExclusiveAsync(cancellationToken).ConfigureAwait(false); + observeOnly = false; } } + } + catch (System.Runtime.InteropServices.COMException comEx) + { + logger.LogDebug("COM error: {HResult} {StackTrace}", comEx.HResult, comEx.StackTrace); + UiErrors.StaleElement(logger, json); + return 1; + } + catch (Exception ex) + { + UiErrors.GenericError(logger, ex, json); + return 1; + } + } - var (pixels, w, h) = await uiAutomation.ScreenshotAsync(singleSession, selector, captureScreen, focus, cancellationToken); - var pngBytes = EncodePng(pixels, w, h); - - var filePath = output ?? "screenshot.png"; - var dir = Path.GetDirectoryName(Path.GetFullPath(filePath)); - if (dir is not null) + /// + /// One complete capture pass. Buffers pixels and human progress and writes nothing until every + /// target succeeded, so an escalation partway through discards a consistent set. + /// + private async Task CapturePassAsync( + ParseResult parseResult, IUiTurn turn, string? selector, string? app, long? window, + string? output, bool json, bool captureScreen, bool focus, bool observeOnly, + CancellationToken cancellationToken) + { + // Screenshot handles multi-window discovery itself (avoids duplicate warning from session resolution) + if (selector is null) + { + var allWindows = DiscoverAllWindows(app, window); + if (allWindows is not null && allWindows.Count > 1) { - Directory.CreateDirectory(dir); + // Resolve session using the largest window's HWND (suppresses session multi-window warning) + var main = allWindows.OrderByDescending(w => + { + var info = UiSessionService.GetWindowInfo(w.Hwnd); + return (long)info.Width * info.Height; + }).First(); + var multiSession = await sessionService.ResolveSessionAsync(null, main.Hwnd, cancellationToken); + return await CaptureMultipleWindows( + allWindows, multiSession, turn, output, json, captureScreen, focus, observeOnly, cancellationToken); } - await File.WriteAllBytesAsync(filePath, pngBytes, cancellationToken); - var absolutePath = Path.GetFullPath(filePath); + } - if (json) + // Single window capture (or element crop) + var singleSession = await sessionService.ResolveSessionAsync(app, window, cancellationToken); + + // Even for single-window session, check for owned dialogs + if (selector is null) + { + var sessionHwnd = (nint)singleSession.WindowHandle; + var ownedWindows = ownedWindowFinder.FindOwnedWindows([(sessionHwnd, singleSession.ProcessId, singleSession.WindowTitle ?? "")]); + if (ownedWindows.Count > 0) { - var result = new UiScreenshotResult + var allWindows = new List<(nint Hwnd, int Pid, string Title)> { - ElementId = selector, - FilePath = absolutePath, - Width = w, - Height = h, - ProcessId = singleSession.ProcessId, - WindowTitle = singleSession.WindowTitle, - Hwnd = singleSession.WindowHandle + (sessionHwnd, singleSession.ProcessId, singleSession.WindowTitle ?? "") }; - ansiConsole.Profile.Out.Writer.WriteLine( - JsonSerializer.Serialize(result, UiJsonContext.Default.UiScreenshotResult)); - return 0; + allWindows.AddRange(ownedWindows); + return await CaptureMultipleWindows( + allWindows, singleSession, turn, output, json, captureScreen, focus, observeOnly, cancellationToken); } - - logger.LogInformation("Screenshot of \"{WindowTitle}\" (PID {ProcessId}) saved to {Path} ({Width}x{Height}, {Size}KB)", singleSession.WindowTitle, singleSession.ProcessId, absolutePath, w, h, pngBytes.Length / 1024); - return 0; } - catch (System.Runtime.InteropServices.COMException comEx) + + var (pixels, w, h) = await uiAutomation.ScreenshotAsync( + singleSession, selector, captureScreen, focus, turn, observeOnly, cancellationToken); + var pngBytes = EncodePng(pixels, w, h); + + var filePath = output ?? "screenshot.png"; + var dir = Path.GetDirectoryName(Path.GetFullPath(filePath)); + if (dir is not null) { - logger.LogDebug("COM error: {HResult} {StackTrace}", comEx.HResult, comEx.StackTrace); - UiErrors.StaleElement(logger, json); - return 1; + Directory.CreateDirectory(dir); } - catch (Exception ex) + await File.WriteAllBytesAsync(filePath, pngBytes, cancellationToken); + var absolutePath = Path.GetFullPath(filePath); + + if (json) { - UiErrors.GenericError(logger, ex, json); - return 1; + var result = new UiScreenshotResult + { + ElementId = selector, + FilePath = absolutePath, + Width = w, + Height = h, + ProcessId = singleSession.ProcessId, + WindowTitle = singleSession.WindowTitle, + Hwnd = singleSession.WindowHandle + }; + ansiConsole.Profile.Out.Writer.WriteLine( + JsonSerializer.Serialize(result, UiJsonContext.Default.UiScreenshotResult)); + return 0; } + + logger.LogInformation("Screenshot of \"{WindowTitle}\" (PID {ProcessId}) saved to {Path} ({Width}x{Height}, {Size}KB)", singleSession.WindowTitle, singleSession.ProcessId, absolutePath, w, h, pngBytes.Length / 1024); + return 0; } private async Task CaptureMultipleWindows( List<(nint Hwnd, int Pid, string Title)> windows, UiSessionInfo session, + IUiTurn turn, string? output, bool json, bool captureScreen, bool focus, + bool observeOnly, CancellationToken ct) { var filePath = output ?? "screenshot.png"; @@ -158,10 +235,12 @@ private async Task CaptureMultipleWindows( return (long)info.Width * info.Height; }).ToList(); - if (!json) + // Buffered so an escalation partway through this loop discards everything rather than + // publishing a composite of pre- and post-escalation captures (spec §6.5). + var progress = new List { - ansiConsole.MarkupLine($"[yellow]⚠ {windows.Count} windows detected. Compositing into single image.[/]"); - } + $"[yellow]⚠ {windows.Count} windows detected. Compositing into single image.[/]" + }; // Capture each window var captures = new List<(byte[] Pixels, int Width, int Height, nint Hwnd, string Title, string Label)>(); @@ -179,7 +258,8 @@ private async Task CaptureMultipleWindows( WindowTitle = title, WindowHandle = w.Hwnd }; - var (pixels, width, height) = await uiAutomation.ScreenshotAsync(windowSession, null, captureScreen, focus, ct); + var (pixels, width, height) = await uiAutomation.ScreenshotAsync( + windowSession, null, captureScreen, focus, turn, observeOnly, ct); captures.Add((pixels, width, height, w.Hwnd, title, info.Label)); windowDetails.Add(new UiScreenshotWindowInfo { @@ -191,11 +271,14 @@ private async Task CaptureMultipleWindows( Captured = true, }); - if (!json) - { - var owner = info.OwnerHwnd != 0 ? $", owner: HWND {info.OwnerHwnd}" : ""; - ansiConsole.MarkupLine($" [green]✓[/] HWND [cyan]{w.Hwnd}[/]: \"{Markup.Escape(title)}\" [grey]({info.Label}, {width}x{height}{owner})[/]"); - } + var owner = info.OwnerHwnd != 0 ? $", owner: HWND {info.OwnerHwnd}" : ""; + progress.Add($" [green]✓[/] HWND [cyan]{w.Hwnd}[/]: \"{Markup.Escape(title)}\" [grey]({info.Label}, {width}x{height}{owner})[/]"); + } + catch (DesktopEscalationRequiredException) + { + // Propagate so the whole invocation escalates and recaptures from the beginning; + // recording a per-window failure here would publish a partially observational image. + throw; } catch (Exception ex) { @@ -208,15 +291,22 @@ private async Task CaptureMultipleWindows( Captured = false, Error = ex.Message, }); - if (!json) - { - ansiConsole.MarkupLine($" [red]✗[/] HWND {w.Hwnd}: \"{Markup.Escape(title)}\" — {Markup.Escape(ex.Message)}"); - } + progress.Add($" [red]✗[/] HWND {w.Hwnd}: \"{Markup.Escape(title)}\" — {Markup.Escape(ex.Message)}"); } } if (captures.Count == 0) { + // Every window failed for a non-escalation reason, so there is no image to publish — but + // the buffered per-window diagnostics are exactly what explains the failure. + if (!json) + { + foreach (var line in progress) + { + ansiConsole.MarkupLine(line); + } + } + logger.LogError("No windows could be captured."); UiJsonError.Emit(json, UiJsonError.CodeInternalError, "No windows could be captured."); return 1; @@ -238,6 +328,11 @@ private async Task CaptureMultipleWindows( if (!json) { + // Only now that the image is published does the buffered progress reach the user. + foreach (var line in progress) + { + ansiConsole.MarkupLine(line); + } ansiConsole.MarkupLine($" [green]✓[/] Saved composite: {absolutePath}"); } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiScrollCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiScrollCommand.cs index 1c4a93b1d..24a15ee49 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiScrollCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiScrollCommand.cs @@ -9,6 +9,7 @@ using Spectre.Console; using WinApp.Cli.Helpers; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -61,18 +62,36 @@ public class Handler( ISelectorService selectorService, IMouseInput mouseInput, IForegroundGuard foregroundGuard, + IDesktopForegroundService desktopForeground, + ISystemUiQuery systemQuery, + IInteractiveDesktopLock desktopLock, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { // Cursor-settle pause (ms) after positioning over the target, before the confirm read + wheel. private const int CursorSettleMs = 30; - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui scroll"; + + /// + /// Spec §6.1: --wheel injects OS-wide mouse input at the cursor, so it is desktop-exclusive. + /// --direction / --to use the UIA ScrollPattern, which works in the background + /// and stays an observation. + /// + protected override UiTurnMode ResolveMode(ParseResult parseResult) + => parseResult.GetValue(WheelOption) is not null + ? UiTurnMode.DesktopExclusive + : UiTurnMode.Observe; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); var app = parseResult.GetValue(SharedUiOptions.AppOption); var window = parseResult.GetValue(SharedUiOptions.WindowOption); + var direction = parseResult.GetValue(DirectionOption); + var to = parseResult.GetValue(ToOption); + var wheel = parseResult.GetValue(WheelOption); if (string.IsNullOrWhiteSpace(app) && window is null) { @@ -80,10 +99,6 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } - var direction = parseResult.GetValue(DirectionOption); - var to = parseResult.GetValue(ToOption); - var wheel = parseResult.GetValue(WheelOption); - if (string.IsNullOrWhiteSpace(selectorStr)) { UiErrors.MissingSelector(logger, "scroll", json); @@ -107,6 +122,20 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + // Preflight rejected a missing selector, so this is non-null by construction. + var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument)!; + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + var direction = parseResult.GetValue(DirectionOption); + var to = parseResult.GetValue(ToOption); + var wheel = parseResult.GetValue(WheelOption); + try { var session = await sessionService.ResolveSessionAsync(app, window, cancellationToken); @@ -123,9 +152,6 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio if (wheel is int notches) { - int centerX = (int)(element.X + element.Width / 2.0); - int centerY = (int)(element.Y + element.Height / 2.0); - if (element.Width == 0 || element.Height == 0) { logger.LogError("{Symbol} Element has zero size — cannot scroll-wheel over it.", UiSymbols.Error); @@ -133,66 +159,78 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } - if (targetHwnd != 0) + // Foreground, re-resolve, cursor positioning and the wheel injection all share the + // desktop and run in one section; the result formatting below does not. + await using (await turn.EnterAsync(cancellationToken).ConfigureAwait(false)) { - Windows.Win32.PInvoke.SetForegroundWindow( - new Windows.Win32.Foundation.HWND((nint)targetHwnd)); - await Task.Delay(100, cancellationToken); - } + // Re-resolve first (spec §10.5): the container may have moved, closed, or been + // replaced while this command waited, so the HWND we foreground must be fresh. + var stable = await GestureTargeting.ResolveStableAsync( + uiAutomation, session, selector, element, + GestureTargeting.DefaultMaxReads, GestureTargeting.DefaultReadDelayMs, null, cancellationToken); + if (!GestureTargeting.TryReport(stable, logger, json, selectorStr, "scroll --wheel")) + { + return 1; + } + var centerX = stable.CenterX; + var centerY = stable.CenterY; + // TryReport returned true, so a settled element was resolved and Element is populated. + targetHwnd = stable.Element.WindowHandle ?? session.WindowHandle; - // Re-resolve just before scrolling (N5): foregrounding can restore/animate the window, - // so the captured rect may be stale. Refuse rather than scroll empty space if it's - // still moving. - var stable = await GestureTargeting.ResolveStableAsync( - uiAutomation, session, selector, element, - GestureTargeting.DefaultMaxReads, GestureTargeting.DefaultReadDelayMs, null, cancellationToken); - if (!GestureTargeting.TryReport(stable, logger, json, selectorStr, "scroll --wheel")) - { - return 1; - } - centerX = stable.CenterX; - centerY = stable.CenterY; - - // Verify the target STILL holds the foreground as the first gate before the OS-wide - // wheel injection. The re-resolve above awaits UIA reads (with delays) during which - // another window could steal focus, so we check here — after the awaits, not before - // them. Also distinguishes a locked/secure desktop from a wrong-window foreground. - if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "scroll --wheel")) - { - return 1; - } + if (!DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, targetHwnd, session.ProcessId, logger, json, "scroll --wheel", parseResult.InvocationConfiguration.Error)) + { + return 1; + } - // Close the residual re-resolve→wheel race (mirrors click/drag): ScrollWheel positions - // the cursor and settles before injecting, which is an unguarded window in which a - // still-animating target could drift, routing the wheel to whatever is now under the - // pointer. Position the cursor, let it settle, confirm the target hasn't moved, re-check - // the foreground, then inject with settleMs: 0 — so a reported ✅ means the wheel went to - // the element. - mouseInput.MoveCursor(centerX, centerY); - await Task.Delay(CursorSettleMs, cancellationToken); - - var confirmed = await GestureTargeting.ConfirmStillAsync( - uiAutomation, session, selector, stable.Element, cancellationToken); - if (!GestureTargeting.TryReport(confirmed, logger, json, selectorStr, "scroll --wheel")) - { - return 1; - } - centerX = confirmed.CenterX; - centerY = confirmed.CenterY; + if (targetHwnd != 0) + { + desktopForeground.RequestForeground(targetHwnd); + await Task.Delay(100, cancellationToken); + } - // Final foreground gate after the awaited confirm read (focus could shift during it). - if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "scroll --wheel")) - { - return 1; - } + // Verify the target STILL holds the foreground as the first gate before the OS-wide + // wheel injection. The re-resolve above awaits UIA reads (with delays) during which + // another window could steal focus, so we check here — after the awaits, not before + // them. Also distinguishes a locked/secure desktop from a wrong-window foreground. + if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "scroll --wheel")) + { + return 1; + } - // --wheel is expressed in notches for ergonomics; SendInput's mouse wheel works in - // WHEEL_DELTA units (120 per detent), so scale up to the raw delta the OS expects. The - // cursor is already positioned and the target just confirmed, so skip the inner settle. - mouseInput.ScrollWheel(centerX, centerY, notches * WheelDelta, settleMs: 0); + // Close the residual re-resolve→wheel race (mirrors click/drag): ScrollWheel positions + // the cursor and settles before injecting, which is an unguarded window in which a + // still-animating target could drift, routing the wheel to whatever is now under the + // pointer. Position the cursor, let it settle, confirm the target hasn't moved, re-check + // the foreground, then inject with settleMs: 0 — so a reported ✅ means the wheel went to + // the element. + mouseInput.MoveCursor(centerX, centerY); + await Task.Delay(CursorSettleMs, cancellationToken); + + var confirmed = await GestureTargeting.ConfirmStillAsync( + uiAutomation, session, selector, stable.Element, cancellationToken); + if (!GestureTargeting.TryReport(confirmed, logger, json, selectorStr, "scroll --wheel")) + { + return 1; + } + centerX = confirmed.CenterX; + centerY = confirmed.CenterY; + + // Final foreground gate after the awaited confirm read (focus could shift during it). + if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "scroll --wheel")) + { + return 1; + } + + // --wheel is expressed in notches for ergonomics; SendInput's mouse wheel works in + // WHEEL_DELTA units (120 per detent), so scale up to the raw delta the OS expects. The + // cursor is already positioned and the target just confirmed, so skip the inner settle. + mouseInput.ScrollWheel(centerX, centerY, notches * WheelDelta, settleMs: 0); + } } else { + // UIA scrolling is background-safe: no foreground, no cursor, no desktop section. await uiAutomation.ScrollContainerAsync(session, element, direction, to, cancellationToken); } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiScrollIntoViewCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiScrollIntoViewCommand.cs index b245fb970..c5e722b71 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiScrollIntoViewCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiScrollIntoViewCommand.cs @@ -10,6 +10,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -32,9 +33,15 @@ public class Handler( IUiAutomationService uiAutomation, ISelectorService selectorService, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui scroll-into-view"; + + /// UIA ScrollItemPattern works in the background and never takes the foreground. + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.Observe; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); @@ -53,6 +60,17 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + // Preflight rejected a missing selector, so this is non-null by construction. + var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument)!; + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + try { var session = await sessionService.ResolveSessionAsync(app, window, cancellationToken); diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiSearchCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiSearchCommand.cs index df1fcda08..a86695258 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiSearchCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiSearchCommand.cs @@ -10,6 +10,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -33,9 +34,15 @@ public class Handler( IUiAutomationService uiAutomation, ISelectorService selectorService, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui search"; + + /// Searching the element tree is a background-safe UIA read. + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.Observe; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); @@ -47,7 +54,6 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio UiErrors.MissingApp(logger, json); return 1; } - var maxResults = parseResult.GetRequiredValue(SharedUiOptions.MaxResultsOption); if (string.IsNullOrWhiteSpace(selectorStr)) { @@ -55,6 +61,18 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + // Preflight rejected a missing selector, so this is non-null by construction. + var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument)!; + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + var maxResults = parseResult.GetRequiredValue(SharedUiOptions.MaxResultsOption); + try { var session = await sessionService.ResolveSessionAsync(app, window, cancellationToken); diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiSendKeysCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiSendKeysCommand.cs index 56b4f7dbf..9ff74d5da 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiSendKeysCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiSendKeysCommand.cs @@ -8,7 +8,9 @@ using Microsoft.Extensions.Logging; using Spectre.Console; using WinApp.Cli.Helpers; +using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -87,20 +89,29 @@ public class Handler( ISelectorService selectorService, IKeyboardInput keyboardInput, IForegroundGuard foregroundGuard, + IDesktopForegroundService desktopForeground, + IInteractiveDesktopLock desktopLock, ISystemUiQuery systemQuery, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui send-keys"; + + /// + /// Spec §6.1: both transports are desktop-exclusive. send-input is OS-wide by definition, + /// and the current post-message path still foregrounds the target and focuses a child + /// control to route keys, so it disturbs the shared desktop just as much. + /// + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.DesktopExclusive; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var keysStr = parseResult.GetValue(KeysArgument); var app = parseResult.GetValue(SharedUiOptions.AppOption); var window = parseResult.GetValue(SharedUiOptions.WindowOption); - var target = parseResult.GetValue(TargetOption); var viaStr = parseResult.GetValue(ViaOption) ?? "post-message"; var verbatim = parseResult.GetValue(VerbatimOption); - var allowSystemKeys = parseResult.GetValue(AllowSystemKeysOption); if (string.IsNullOrWhiteSpace(app) && window is null) { @@ -110,8 +121,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio // For --verbatim, whitespace is legitimate content to type (the help promises exact // whitespace preservation), so only reject a genuinely empty argument. Without --verbatim, - // a whitespace-only argument has no tokens to interpret, so it stays an error. (The IsNullOrEmpty - // operand is also what lets the compiler treat keysStr as non-null on the fall-through path.) + // a whitespace-only argument has no tokens to interpret, so it stays an error. if (string.IsNullOrEmpty(keysStr) || (!verbatim && string.IsNullOrWhiteSpace(keysStr))) { logger.LogError("{Symbol} Keys are required. Usage: winapp ui send-keys -a ", UiSymbols.Error); @@ -120,7 +130,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } - if (!TryParseTransport(viaStr, out var transport)) + if (!TryParseTransport(viaStr, out _)) { logger.LogError("{Symbol} Invalid --via value '{Via}'. Use post-message or send-input.", UiSymbols.Error, viaStr); UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, @@ -128,6 +138,85 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + IReadOnlyList preflightActions; + try + { + // Parsing the key grammar contacts nothing, so a malformed sequence must be rejected + // before this command ever queues for the desktop. + preflightActions = verbatim ? KeyStringParser.ParseVerbatim(keysStr) : KeyStringParser.Parse(keysStr); + } + catch (FormatException ex) + { + logger.LogError("{Symbol} {Message}", UiSymbols.Error, ex.Message); + UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, ex.Message); + return 1; + } + + // send-input is OS-wide, so a system-reserved combo (win+l, alt+f4, ctrl+shift+esc, …) + // would act on the OS/shell rather than just the target app (lock the session, close the + // window, open Task Manager). These are decided purely from the parsed keys, so they are + // refused here — before the command can take a lease or wait for the desktop. + _ = TryParseTransport(viaStr, out var transport); + if (transport == KeyTransport.SendInput) + { + var allowSystemKeys = parseResult.GetValue(AllowSystemKeysOption); + + // win+l (LockWorkStation) and ctrl+alt+del (SAS) are unconditionally blocked even + // with --allow-system-keys: win+l locks the interactive session with no recovery + // path from automation, and ctrl+alt+del is a Secure Attention Sequence that Windows + // drops from injected input regardless of the flag — reporting success for it would + // be misleading. Each carries its own reason so the message explains why. + var neverBypassable = SystemKeyGuard.FindNeverBypassableCombos(preflightActions); + if (neverBypassable.Count > 0) + { + var names = string.Join(", ", neverBypassable.Select(c => c.Name)); + var reasons = string.Join(" ", neverBypassable.Select(c => $"{c.Name} {c.Reason}.")); + logger.LogError( + "{Symbol} Refusing to synthesize {Combos} via --via send-input. {Reasons} " + + "This stays blocked even with --allow-system-keys, which is for app-registered " + + "global hotkeys (e.g. win+r, win+shift+v), not combos that can't be driven from automation.", + UiSymbols.Error, names, reasons); + UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, + $"Refusing to synthesize {names} via --via send-input. {reasons} " + + "This stays blocked even with --allow-system-keys, which is for app-registered " + + "global hotkeys (e.g. win+r, win+shift+v), not combos that can't be driven from automation.", + errorOut: parseResult.InvocationConfiguration.Error); + return 1; + } + + var systemCombos = SystemKeyGuard.FindSystemCombos(preflightActions); + if (systemCombos.Count > 0 && !allowSystemKeys) + { + logger.LogError( + "{Symbol} Refusing to synthesize system-reserved key(s) via --via send-input: {Combos}. " + + "These act on the OS/shell (e.g. win+l locks the session, alt+f4 closes the window, ctrl+alt+del is intercepted by Windows), not just the target app. " + + "Pass --allow-system-keys to opt in (e.g. to drive a global hotkey).", + UiSymbols.Error, string.Join(", ", systemCombos)); + UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, + $"Refusing to synthesize system-reserved key(s) via --via send-input: {string.Join(", ", systemCombos)}. " + + "These act on the OS/shell rather than just the target app. Pass --allow-system-keys to opt in."); + return 1; + } + } + + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + // Preflight rejected an empty keys argument, so this is non-null by construction. + var keysStr = parseResult.GetValue(KeysArgument)!; + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + var target = parseResult.GetValue(TargetOption); + var viaStr = parseResult.GetValue(ViaOption) ?? "post-message"; + var verbatim = parseResult.GetValue(VerbatimOption); + var allowSystemKeys = parseResult.GetValue(AllowSystemKeysOption); + + // Preflight validated the transport, so this cannot fail here. + _ = TryParseTransport(viaStr, out var transport); + // SEC-02: --allow-system-keys only applies to send-input; with post-message the transport is // already window-scoped so system combos are never blocked and the flag has no effect. var warnings = new List(); @@ -142,124 +231,153 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio "--via post-message (post-message is already window-scoped and never blocks system combos)."); } - IReadOnlyList actions; - try - { - // --verbatim types the whole argument literally (no key/combo/vk=/text= parsing, no - // whitespace collapsing); otherwise interpret the friendly key grammar token by token. - // keysStr is non-null here: the guard above rejected null/empty. - actions = verbatim ? KeyStringParser.ParseVerbatim(keysStr) : KeyStringParser.Parse(keysStr); - } - catch (FormatException ex) - { - logger.LogError("{Symbol} {Message}", UiSymbols.Error, ex.Message); - UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, ex.Message); - return 1; - } + // --verbatim types the whole argument literally (no key/combo/vk=/text= parsing, no + // whitespace collapsing); otherwise interpret the friendly key grammar token by token. + // Preflight already proved this parses. + IReadOnlyList actions = + verbatim ? KeyStringParser.ParseVerbatim(keysStr) : KeyStringParser.Parse(keysStr); try { var session = await sessionService.ResolveSessionAsync(app, window, cancellationToken); var targetHwnd = session.WindowHandle; + // Spec §13: resolve the --target element WITHOUT focusing it. The old order focused the + // control first and only then checked the foreground, so a command that was going to be + // refused had already moved another workflow's keyboard focus. Focus now happens after + // the foreground request has been verified, inside the desktop section. + UiElement? targetElement = null; + SelectorExpression? targetSelector = null; if (!string.IsNullOrWhiteSpace(target)) { - var selector = selectorService.Parse(target); - var element = await uiAutomation.FindSingleElementAsync(session, selector, cancellationToken); + targetSelector = selectorService.Parse(target); + targetElement = await uiAutomation.FindSingleElementAsync(session, targetSelector, cancellationToken); - if (element is null) + if (targetElement is null) { UiErrors.ElementNotFound(logger, target, json); return 1; } - await uiAutomation.FocusAsync(session, element, cancellationToken); - targetHwnd = element.WindowHandle ?? session.WindowHandle; + targetHwnd = targetElement.WindowHandle ?? session.WindowHandle; } - // Bring the target window to the foreground so input is routed to it. - if (targetHwnd != 0) - { - Windows.Win32.PInvoke.SetForegroundWindow( - new Windows.Win32.Foundation.HWND((nint)targetHwnd)); - await Task.Delay(100, cancellationToken); - } - - // PostMessage posts to a specific HWND's message queue; a top-level window does NOT - // forward keyboard messages to its focused child control, so posting there silently - // drops the input for classic Win32 child controls (e.g. an edit box) — the resolved - // target is usually the top-level window, not the control. Retarget to the thread's - // actually-focused window (populated now that the target is foreground) so the keys - // reach the control the user sees focused. Falls back to the passed HWND when focus - // can't be resolved. send-input is OS-wide and unaffected, so leave it alone. var effectiveHwnd = targetHwnd; - if (transport == KeyTransport.PostMessage && targetHwnd != 0) + bool targetLooksXaml; + + // Foreground, focus and key injection all share the desktop and run in one section; the + // warning composition and result formatting below do not. + await using (await turn.EnterAsync(cancellationToken).ConfigureAwait(false)) { - var focused = systemQuery.GetFocusedWindow(targetHwnd); - if (focused != 0 && focused != targetHwnd) + // Revalidate the target after any queue wait: another workflow may have closed or moved + // the control while this command was queued (spec §10.5). + if (targetSelector is not null) { - // GetGUIThreadInfo reports focus for the entire GUI thread, and one thread can - // own several top-level windows. If SetForegroundWindow was denied (focus-stealing - // prevention, a UAC prompt, etc.), the focused HWND may belong to a *different* - // window on that thread — posting there would deliver the keys to the wrong window - // despite an explicit target. Only retarget when the focused HWND shares the - // target's top-level root; otherwise keep the passed target. - var targetRoot = systemQuery.GetRootWindow(targetHwnd); - if (targetRoot != 0 && systemQuery.GetRootWindow(focused) == targetRoot) + targetElement = await uiAutomation.FindSingleElementAsync(session, targetSelector, cancellationToken); + if (targetElement is null) { - logger.LogDebug( - "post-message: retargeting from HWND {Target} to focused child HWND {Focused}", - targetHwnd, focused); - effectiveHwnd = focused; + UiErrors.ElementNotFound(logger, target!, json); + return 1; } - else + + targetHwnd = targetElement.WindowHandle ?? session.WindowHandle; + effectiveHwnd = targetHwnd; + } + + // Request foreground on the top-level window that owns the target, so activation is + // asked for once at the right level rather than on a child control HWND. + if (targetHwnd != 0) + { + var topLevel = systemQuery.GetRootWindow(targetHwnd); + desktopForeground.RequestForeground(topLevel != 0 ? topLevel : targetHwnd); + await Task.Delay(100, cancellationToken); + } + + // send-input is OS-wide: it lands on whatever window is actually in the foreground. If + // SetForegroundWindow didn't take (focus-stealing prevention, a UAC prompt, another app + // grabbing focus, or a locked/secure desktop), injecting now would type into the wrong + // window. Verify the foreground belongs to the target BEFORE focusing anything, so a + // refused command leaves the desktop exactly as it found it (spec §13). (post-message + // posts straight to the target HWND's queue, so it isn't affected.) + if (transport == KeyTransport.SendInput) + { + // Unlike a coordinate gesture (which targets a screen point), keystrokes have no + // location — without a resolvable target window there is nothing to verify the + // foreground against, so OS-wide injection would type blindly into whatever has + // focus. Refuse rather than send to an unknown window. + if (targetHwnd == 0) + { + logger.LogError( + "{Symbol} --via send-input needs a resolvable target window, but none was found. Pass --window , ensure -a/--app resolves a window, or use --target to focus an element first.", + UiSymbols.Error); + UiJsonError.Emit(json, UiJsonError.CodeForegroundNotTarget, + "send-input needs a resolvable target window, but none was found — refusing OS-wide keyboard injection without a known target. Pass --window/--app or --target."); + return 1; + } + + if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "--via send-input")) { - logger.LogDebug( - "post-message: focused HWND {Focused} is not within target {Target}'s top-level window; keeping target", - focused, targetHwnd); + return 1; } } - } - // send-input is OS-wide: it lands on whatever window is actually in the foreground. If - // SetForegroundWindow didn't take (focus-stealing prevention, a UAC prompt, another app - // grabbing focus, or a locked/secure desktop), injecting now would type into the wrong - // window. Verify the foreground belongs to the target before sending. (post-message posts - // straight to the target HWND's queue, so it isn't affected.) - if (transport == KeyTransport.SendInput) - { - // Unlike a coordinate gesture (which targets a screen point), keystrokes have no - // location — without a resolvable target window there is nothing to verify the - // foreground against, so OS-wide injection would type blindly into whatever has - // focus. Refuse rather than send to an unknown window. - if (targetHwnd == 0) + // Only now, with the foreground confirmed, move focus to the requested child control. + if (targetElement is not null) { - logger.LogError( - "{Symbol} --via send-input needs a resolvable target window, but none was found. Pass --window , ensure -a/--app resolves a window, or use --target to focus an element first.", - UiSymbols.Error); - UiJsonError.Emit(json, UiJsonError.CodeForegroundNotTarget, - "send-input needs a resolvable target window, but none was found — refusing OS-wide keyboard injection without a known target. Pass --window/--app or --target."); - return 1; + await uiAutomation.FocusAsync(session, targetElement, cancellationToken); } - if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "--via send-input")) + // PostMessage posts to a specific HWND's message queue; a top-level window does NOT + // forward keyboard messages to its focused child control, so posting there silently + // drops the input for classic Win32 child controls (e.g. an edit box) — the resolved + // target is usually the top-level window, not the control. Retarget to the thread's + // actually-focused window (populated now that the target is foreground) so the keys + // reach the control the user sees focused. Falls back to the passed HWND when focus + // can't be resolved. send-input is OS-wide and unaffected, so leave it alone. + if (transport == KeyTransport.PostMessage && targetHwnd != 0) { - return 1; + var focused = systemQuery.GetFocusedWindow(targetHwnd); + if (focused != 0 && focused != targetHwnd) + { + // GetGUIThreadInfo reports focus for the entire GUI thread, and one thread can + // own several top-level windows. If SetForegroundWindow was denied (focus-stealing + // prevention, a UAC prompt, etc.), the focused HWND may belong to a *different* + // window on that thread — posting there would deliver the keys to the wrong window + // despite an explicit target. Only retarget when the focused HWND shares the + // target's top-level root; otherwise keep the passed target. + var targetRoot = systemQuery.GetRootWindow(targetHwnd); + if (targetRoot != 0 && systemQuery.GetRootWindow(focused) == targetRoot) + { + logger.LogDebug( + "post-message: retargeting from HWND {Target} to focused child HWND {Focused}", + targetHwnd, focused); + effectiveHwnd = focused; + } + else + { + logger.LogDebug( + "post-message: focused HWND {Focused} is not within target {Target}'s top-level window; keeping target", + focused, targetHwnd); + } + } } + + // WM_CHAR / WM_KEYDOWN posted to a WinUI 3 / UWP / XAML window is not routed to the + // windowless focused control by the XAML input pipeline, so posted keys — typed literal + // text AND named keys/combos (Enter, digits, …) — silently no-op there even though + // PostMessage reports success. Check both the top-level target and the resolved focused + // child (either looking XAML is enough). Class names are read through ISystemUiQuery so + // this branch is exercisable with a fake. + targetLooksXaml = + (targetHwnd != 0 && FrameworkHint.IsXamlClassName(systemQuery.GetWindowClassName(targetHwnd))) + || (effectiveHwnd != 0 && effectiveHwnd != targetHwnd + && FrameworkHint.IsXamlClassName(systemQuery.GetWindowClassName(effectiveHwnd))); + + keyboardInput.Send(effectiveHwnd, actions, transport); } - // WM_CHAR / WM_KEYDOWN posted to a WinUI 3 / UWP / XAML window is not routed to the - // windowless focused control by the XAML input pipeline, so posted keys — typed literal - // text AND named keys/combos (Enter, digits, …) — silently no-op there even though - // PostMessage reports success. Warn, but only when the target actually looks like a XAML - // host, rather than false-alarming on Win32/WPF/Electron apps that do consume posted - // messages. Check both the top-level target and the resolved focused child (either - // looking XAML is enough). Class names are read through ISystemUiQuery so this branch is - // exercisable with a fake. - var targetLooksXaml = - (targetHwnd != 0 && FrameworkHint.IsXamlClassName(systemQuery.GetWindowClassName(targetHwnd))) - || (effectiveHwnd != 0 && effectiveHwnd != targetHwnd - && FrameworkHint.IsXamlClassName(systemQuery.GetWindowClassName(effectiveHwnd))); + // Warn only when the target actually looks like a XAML host, rather than false-alarming on + // Win32/WPF/Electron apps that do consume posted messages. if (ShouldWarnPostMessageMayNotDeliver(transport == KeyTransport.PostMessage, targetLooksXaml)) { const string postMessageXamlWarning = @@ -270,55 +388,14 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio warnings.Add(postMessageXamlWarning); } - // send-input is OS-wide, so a system-reserved combo (win+l, alt+f4, ctrl+shift+esc, …) - // would act on the OS/shell rather than just the target app (lock the session, close the - // window, open Task Manager). Refuse to synthesize them via send-input — the blast radius - // beyond the target window makes silently sending them too dangerous for an automation run. if (transport == KeyTransport.SendInput) { - // win+l (LockWorkStation) and ctrl+alt+del (SAS) are unconditionally blocked even - // with --allow-system-keys: win+l locks the interactive session with no recovery - // path from automation, and ctrl+alt+del is a Secure Attention Sequence that Windows - // drops from injected input regardless of the flag — reporting success for it would - // be misleading. Each carries its own reason so the message explains why. Return - // early so they don't fall through into the soft-combo / allow path below. - var neverBypassable = SystemKeyGuard.FindNeverBypassableCombos(actions); - if (neverBypassable.Count > 0) - { - var names = string.Join(", ", neverBypassable.Select(c => c.Name)); - var reasons = string.Join(" ", neverBypassable.Select(c => $"{c.Name} {c.Reason}.")); - logger.LogError( - "{Symbol} Refusing to synthesize {Combos} via --via send-input. {Reasons} " + - "This stays blocked even with --allow-system-keys, which is for app-registered " + - "global hotkeys (e.g. win+r, win+shift+v), not combos that can't be driven from automation.", - UiSymbols.Error, names, reasons); - UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, - $"Refusing to synthesize {names} via --via send-input. {reasons} " + - "This stays blocked even with --allow-system-keys, which is for app-registered " + - "global hotkeys (e.g. win+r, win+shift+v), not combos that can't be driven from automation.", - errorOut: parseResult.InvocationConfiguration.Error); - return 1; - } - + // Caller explicitly opted in with --allow-system-keys (e.g. to fire a global hotkey such as + // PowerToys' win+shift+v). Record the bypass so it's auditable in persisted logs. + // (win+l and ctrl+alt+del never reach here — preflight hard-blocks them.) var systemCombos = SystemKeyGuard.FindSystemCombos(actions); if (systemCombos.Count > 0) { - if (!allowSystemKeys) - { - logger.LogError( - "{Symbol} Refusing to synthesize system-reserved key(s) via --via send-input: {Combos}. " + - "These act on the OS/shell (e.g. win+l locks the session, alt+f4 closes the window, ctrl+alt+del is intercepted by Windows), not just the target app. " + - "Pass --allow-system-keys to opt in (e.g. to drive a global hotkey).", - UiSymbols.Error, string.Join(", ", systemCombos)); - UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, - $"Refusing to synthesize system-reserved key(s) via --via send-input: {string.Join(", ", systemCombos)}. " + - "These act on the OS/shell rather than just the target app. Pass --allow-system-keys to opt in."); - return 1; - } - - // Caller explicitly opted in with --allow-system-keys (e.g. to fire a global hotkey such as - // PowerToys' win+shift+v). Record the bypass so it's auditable in persisted logs, then fall - // through and inject. (win+l and ctrl+alt+del never reach here — they're hard-blocked above.) var systemCombosStr = string.Join(", ", systemCombos); logger.LogWarning( "{Symbol} Injecting system-reserved key(s) via --via send-input because --allow-system-keys was set: {Combos}. " + @@ -328,29 +405,24 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio $"Injecting system-reserved key(s) via --via send-input because --allow-system-keys was set: {systemCombosStr}. " + "These act on the OS/shell beyond the target app."); } - } - - // Long literal text via send-input is auto-throttled into paced chunks (issue #657) so the - // target's input queue never overruns and no characters are silently dropped. That pacing - // adds a little wall-clock time for big payloads, so let the caller know the throttling is - // intentional — and that 'ui set-value' lands bulk text in one shot — when the payload is - // large enough to be chunked (more than one chunk's worth of characters). - if (transport == KeyTransport.SendInput) - { - int textChars = actions.OfType().Sum(t => t.Text.Length); - if (textChars > KeyboardInput.DefaultTextChunkChars) - { - logger.LogWarning( - "{Symbol} {Count} characters via --via send-input are auto-throttled into paced chunks for reliable delivery, so this may take a moment. For bulk text, 'ui set-value' is faster and more reliable.", - UiSymbols.Warning, textChars); - warnings.Add( - $"{textChars} characters via send-input are auto-throttled into paced chunks for reliable delivery, so this may take a moment. For bulk text, 'ui set-value' is faster and more reliable."); - } - } - - keyboardInput.Send(effectiveHwnd, actions, transport); - - if (json) + + // Long literal text via send-input is auto-throttled into paced chunks (issue #657) so the + // target's input queue never overruns and no characters are silently dropped. That pacing + // adds a little wall-clock time for big payloads, so let the caller know the throttling is + // intentional — and that 'ui set-value' lands bulk text in one shot — when the payload is + // large enough to be chunked (more than one chunk's worth of characters). + int textChars = actions.OfType().Sum(t => t.Text.Length); + if (textChars > KeyboardInput.DefaultTextChunkChars) + { + logger.LogWarning( + "{Symbol} {Count} characters via --via send-input are auto-throttled into paced chunks for reliable delivery, so this may take a moment. For bulk text, 'ui set-value' is faster and more reliable.", + UiSymbols.Warning, textChars); + warnings.Add( + $"{textChars} characters via send-input are auto-throttled into paced chunks for reliable delivery, so this may take a moment. For bulk text, 'ui set-value' is faster and more reliable."); + } + } + + if (json) { var result = new UiSendKeysResult { @@ -436,4 +508,4 @@ private static bool TryParseTransport(string via, out KeyTransport transport) } } } -} +} \ No newline at end of file diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiSetValueCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiSetValueCommand.cs index b48f524a9..258c1699b 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiSetValueCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiSetValueCommand.cs @@ -9,6 +9,7 @@ using Spectre.Console; using WinApp.Cli.Helpers; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -36,27 +37,38 @@ public class Handler( IUiAutomationService uiAutomation, ISelectorService selectorService, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui set-value"; + + /// + /// Spec §6.1: background-safe UIA mutations stay concurrent even against the same target. This + /// feature prevents desktop interference; it deliberately does not provide transactional + /// app-state isolation. + /// + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.Observe; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); var app = parseResult.GetValue(SharedUiOptions.AppOption); var window = parseResult.GetValue(SharedUiOptions.WindowOption); + var value = parseResult.GetValue(SharedUiOptions.ValueArgument); if (string.IsNullOrWhiteSpace(app) && window is null) { UiErrors.MissingApp(logger, json); return 1; } - var value = parseResult.GetValue(SharedUiOptions.ValueArgument); if (string.IsNullOrWhiteSpace(selectorStr)) { UiErrors.MissingSelector(logger, "set-value", json); return 1; } + if (value is null) { logger.LogError("{Symbol} A value is required. Usage: winapp ui set-value -a ", UiSymbols.Error); @@ -65,6 +77,18 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + // Preflight rejected a missing selector, so this is non-null by construction. + var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument)!; + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + var value = parseResult.GetValue(SharedUiOptions.ValueArgument)!; + try { var session = await sessionService.ResolveSessionAsync(app, window, cancellationToken); diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiStatusCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiStatusCommand.cs index 1581fd1c4..9988d3ec1 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiStatusCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiStatusCommand.cs @@ -10,6 +10,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -29,9 +30,15 @@ public UiStatusCommand() public class Handler( IUiSessionService sessionService, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui status"; + + /// Reading connection info never touches the desktop, so it never claims a free turn. + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.Observe; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var app = parseResult.GetValue(SharedUiOptions.AppOption); @@ -43,6 +50,15 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + try { var session = await sessionService.ResolveSessionAsync(app, window, cancellationToken); diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiTouchCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiTouchCommand.cs index 7a165ecf2..74a7f61b2 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiTouchCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiTouchCommand.cs @@ -10,6 +10,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -103,11 +104,49 @@ public class Handler( ISelectorService selectorService, IPointerInput pointerInput, IForegroundGuard foregroundGuard, + IDesktopForegroundService desktopForeground, + ISystemUiQuery systemQuery, + IInteractiveDesktopLock desktopLock, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + /// + /// The fully validated argument set. Produced once by so + /// and share one parse instead of duplicating + /// the gesture/point/range rules. + /// + private readonly record struct TouchArgs( + bool Json, + string? SelectorStr, + string? App, + long? Window, + string GestureStr, + TouchGesture Gesture, + PointerPoint? At, + string? AtStr, + PointerPoint? To, + int Distance, + string? Direction, + int HoldMs, + int DurationMs, + int Fingers); + + protected override string Operation => "ui touch"; + + /// Synthetic touch injection is OS-wide and lands wherever the desktop points. + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.DesktopExclusive; + + protected override int? Preflight(ParseResult parseResult) => Validate(parseResult, out _); + + /// + /// All app-independent semantic validation. Runs before the missing-app check so that malformed + /// argument values return invalid_arguments, not missing_app (M4 root-cause fix), + /// and before any coordination so a malformed command never joins the desktop queue. + /// + private int? Validate(ParseResult parseResult, out TouchArgs args) { + args = default; + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); var app = parseResult.GetValue(SharedUiOptions.AppOption); @@ -126,8 +165,13 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio bool durationWasSupplied = (parseResult.GetResult(DurationOption)?.Tokens.Count ?? 0) > 0; bool fingersWasSupplied = (parseResult.GetResult(FingersOption)?.Tokens.Count ?? 0) > 0; - // All app-independent semantic validation runs BEFORE the missing-app check so that - // malformed argument values return invalid_arguments, not missing_app (M4 root-cause fix). + int RejectInvalidArguments(string message) + { + logger.LogError("{Symbol} {Message}", UiSymbols.Error, message); + UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, message, + errorOut: parseResult.InvocationConfiguration.Error); + return 1; + } if (!Gestures.TryGetValue(gestureStr, out var gesture)) { @@ -277,45 +321,78 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + args = new TouchArgs(json, selectorStr, app, window, gestureStr, gesture, at, atStr, to, + distance, direction, holdMs, durationMs, fingers); + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + // Preflight already ran this and rejected every invalid combination, so it cannot fail here. + Validate(parseResult, out var args); + var (json, selectorStr, app, window, gestureStr, gesture, at, atStr, to, + distance, direction, holdMs, durationMs, fingers) = args; + try { var session = await sessionService.ResolveSessionAsync(app, window, cancellationToken); - var target = await PointerCommandSupport.ResolvePointAsync( - uiAutomation, selectorService, session, selectorStr, at, atStr, - "touch", "touch point", logger, json, cancellationToken); - if (!target.Ok) + PointerCommandSupport.InjectionPreparation prep; + long targetHwnd; + PointerPoint start; + string? targetLabel; + IReadOnlyList points; + int effectiveFingers; + + // Point resolution, foreground and synthetic pointer injection all share the desktop and + // run in one section: ResolvePointAsync foregrounds the target during its stable read, and + // spec §10.5 requires the coordinates actually injected to be resolved after the queue wait. + // The warning composition and result formatting below stay outside. + await using (await turn.EnterAsync(cancellationToken).ConfigureAwait(false)) { - return 1; - } + var target = await PointerCommandSupport.ResolvePointAsync( + uiAutomation, selectorService, desktopForeground, session, selectorStr, at, atStr, + "touch", "touch point", logger, json, cancellationToken); + if (!target.Ok) + { + return 1; + } - var targetHwnd = target.TargetHwnd; - var start = target.Point; - var targetLabel = target.TargetLabel; + targetHwnd = target.TargetHwnd; + start = target.Point; + targetLabel = target.TargetLabel; - if (at is not null) - { - await PointerCommandSupport.SetForegroundAsync(targetHwnd, cancellationToken); - } + if (!DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, targetHwnd, session.ProcessId, logger, json, "touch", + parseResult.InvocationConfiguration.Error)) + { + return 1; + } - var (contactPaths, points, effectiveFingers) = - PointerGesturePlanner.PlanTouch(gesture, start, to, distance, fingers, direction); + (var contactPaths, points, effectiveFingers) = + PointerGesturePlanner.PlanTouch(gesture, start, to, distance, fingers, direction); - var prep = PointerCommandSupport.TryPrepareInjection( - uiAutomation, foregroundGuard, targetHwnd, points, "touch", "touch", logger, json); - if (!prep.Ok) - { - return 1; - } + if (at is not null) + { + await PointerCommandSupport.SetForegroundAsync(desktopForeground, targetHwnd, cancellationToken); + } - // M8: narrow the injection_unsupported catch to only the actual injection call so that - // pre-injection failures (element not found, etc.) are NOT mis-classified as - // injection_unsupported. Session resolution failures surface as missing_app (outer catch). - if (!PointerCommandSupport.TryInject( - () => pointerInput.Touch(gesture, contactPaths, holdMs, durationMs), - logger, json, parseResult.InvocationConfiguration.Error)) - { - return 1; + prep = PointerCommandSupport.TryPrepareInjection( + uiAutomation, foregroundGuard, targetHwnd, points, "touch", "touch", logger, json); + if (!prep.Ok) + { + return 1; + } + + // M8: narrow the injection_unsupported catch to only the actual injection call so that + // pre-injection failures (element not found, etc.) are NOT mis-classified as + // injection_unsupported. Session resolution failures surface as missing_app (outer catch). + if (!PointerCommandSupport.TryInject( + () => pointerInput.Touch(gesture, contactPaths, holdMs, durationMs), + logger, json, parseResult.InvocationConfiguration.Error)) + { + return 1; + } } // id27/id28: synthetic touch injection can report success without actually reaching the @@ -395,14 +472,6 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio UiErrors.GenericError(logger, ex, json, parseResult.InvocationConfiguration.Error); return 1; } - - int RejectInvalidArguments(string message) - { - logger.LogError("{Symbol} {Message}", UiSymbols.Error, message); - UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, message, - errorOut: parseResult.InvocationConfiguration.Error); - return 1; - } } } } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiWaitForCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiWaitForCommand.cs index a857a492c..6df9b05ed 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiWaitForCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiWaitForCommand.cs @@ -11,6 +11,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -62,9 +63,18 @@ public class Handler( ISelectorService selectorService, IPollDelay pollDelay, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui wait-for"; + + /// + /// Spec §14: wait-for polls UIA in the background and keeps its existing timeout scope, + /// which starts immediately rather than after any coordination wait. + /// + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.Observe; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); @@ -76,11 +86,6 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio UiErrors.MissingApp(logger, json); return 1; } - var timeout = parseResult.GetRequiredValue(SharedUiOptions.TimeoutOption); - var gone = parseResult.GetValue(GoneOption); - var property = parseResult.GetValue(SharedUiOptions.PropertyOption); - var value = parseResult.GetValue(ValueOption); - var contains = parseResult.GetValue(ContainsOption); if (string.IsNullOrWhiteSpace(selectorStr)) { @@ -88,11 +93,21 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } - if (value != null && string.IsNullOrWhiteSpace(property)) - { - // --value without --property: use smart get-value fallback (TextPattern → ValuePattern → Name) - // No error — this is the recommended usage - } + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + // Preflight rejected a missing selector, so this is non-null by construction. + var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument)!; + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + var timeout = parseResult.GetRequiredValue(SharedUiOptions.TimeoutOption); + var gone = parseResult.GetValue(GoneOption); + var property = parseResult.GetValue(SharedUiOptions.PropertyOption); + var value = parseResult.GetValue(ValueOption); + var contains = parseResult.GetValue(ContainsOption); try { diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/DesktopTargetValidation.cs b/src/winapp-CLI/WinApp.Cli/Helpers/DesktopTargetValidation.cs new file mode 100644 index 000000000..fddee3bf0 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/DesktopTargetValidation.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Helpers; + +/// +/// Post-wait target validation for cooperative desktop turns (issue #764). +/// +/// +/// A DesktopExclusive command may sit in the queue for an unbounded time while another workflow +/// drives the desktop. In that gap the target window can close, move, or exit and have its HWND reused +/// by an unrelated process. Spec §10.5 therefore requires the HWND, PID, element and bounds actually +/// acted upon to be resolved or revalidated inside the desktop section — never carried across +/// the wait. These helpers are the shared check that the window a command is about to act on is still +/// the one it resolved. +/// +internal static class DesktopTargetValidation +{ + /// + /// Confirms the window a command is about to act on still exists and still belongs to the process + /// the command resolved. Emits the standard stale-target error and returns + /// when it does not. + /// + /// + /// The PID resolved for this target. A mismatch means the original window closed and Windows reused + /// its handle for a different process — acting on it would drive the wrong application. + /// + /// Verb used in the message, e.g. "click", "invoke". + public static bool TryConfirmTargetWindow( + ISystemUiQuery systemQuery, + long hwnd, + int expectedProcessId, + ILogger logger, + bool json, + string action, + TextWriter? errorOut = null) + { + if (hwnd == 0) + { + // A bare-coordinate target has no window to confirm; the foreground guard is the gate there. + return true; + } + + var actualProcessId = systemQuery.GetProcessIdForWindow(hwnd); + if (actualProcessId == 0) + { + logger.LogError( + "{Symbol} The target window closed while this command was waiting for the desktop — refusing to {Action}.", + UiSymbols.Error, action); + UiJsonError.Emit(json, UiJsonError.CodeStaleElement, + $"The target window no longer exists — refusing to {action}. Re-resolve the target and retry.", + errorOut: errorOut, + recoveryHint: "Another workflow may have closed the window while this command waited for the desktop. Re-run the discovery step (ui list-windows / ui search) and retry."); + return false; + } + + if (expectedProcessId > 0 && actualProcessId != (uint)expectedProcessId) + { + logger.LogError( + "{Symbol} The target window handle now belongs to a different process — refusing to {Action}.", + UiSymbols.Error, action); + UiJsonError.Emit(json, UiJsonError.CodeStaleElement, + $"The target window handle now belongs to a different process — refusing to {action}. Re-resolve the target and retry.", + errorOut: errorOut, + recoveryHint: "The original window exited while this command waited for the desktop and Windows reused its handle. Re-run the discovery step and retry."); + return false; + } + + return true; + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs index 1fa89420e..eda6fa783 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs @@ -9,6 +9,7 @@ using WinApp.Cli.Commands; using WinApp.Cli.Services; using WinApp.Cli.Services.Controls; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Helpers; @@ -72,6 +73,15 @@ public static IServiceCollection ConfigureServices(this IServiceCollection servi .AddSingleton() .AddSingleton() .AddSingleton() + // Cooperative desktop turn coordination (issue #764) + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton(); } diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/IDesktopForegroundService.cs b/src/winapp-CLI/WinApp.Cli/Helpers/IDesktopForegroundService.cs new file mode 100644 index 000000000..5ef372980 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/IDesktopForegroundService.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Helpers; + +/// +/// The single place any winapp ui code path may change the shared desktop's foreground window or +/// restore a minimized window. +/// +/// +/// +/// These two Win32 calls are what make concurrent winapp ui processes interfere: they steal +/// focus, dismiss another workflow's transient menu, and invalidate a target another process has +/// already resolved. Routing every call site through one service means "is this coordinated?" is a +/// question about one file rather than about a dozen scattered PInvoke.SetForegroundWindow +/// calls, and a fake can assert ordering in unit tests without a live desktop. +/// +/// +/// Callers must already be inside a desktop section (holding active.lock). Cursor movement, +/// SendInput and synthetic pointer injection are reached only from +/// command bodies, which enter a +/// section before touching them. +/// +/// +internal interface IDesktopForegroundService +{ + /// + /// Requests that becomes the foreground window. Windows may refuse + /// (focus-stealing prevention, a UAC prompt, a locked session); callers must still verify with + /// before injecting OS-wide input. + /// + void RequestForeground(long hwnd); + + /// Whether is currently minimized. + bool IsMinimized(long hwnd); + + /// Restores a minimized window so it can be captured or interacted with. + void Restore(long hwnd); +} + +/// Production over the Win32 window APIs. +/// +/// Coverage ceiling (issue #630): every member is a direct Win32 call against the shared desktop. +/// Callers are covered through the interface with a fake. +/// +internal sealed class DesktopForegroundService : IDesktopForegroundService +{ + public void RequestForeground(long hwnd) + { + if (hwnd == 0) + { + return; + } + + Windows.Win32.PInvoke.SetForegroundWindow(new Windows.Win32.Foundation.HWND((nint)hwnd)); + } + + public bool IsMinimized(long hwnd) + => hwnd != 0 && Windows.Win32.PInvoke.IsIconic(new Windows.Win32.Foundation.HWND((nint)hwnd)); + + public void Restore(long hwnd) + { + if (hwnd == 0) + { + return; + } + + Windows.Win32.PInvoke.ShowWindow( + new Windows.Win32.Foundation.HWND((nint)hwnd), + Windows.Win32.UI.WindowsAndMessaging.SHOW_WINDOW_CMD.SW_RESTORE); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/PointerCommandSupport.cs b/src/winapp-CLI/WinApp.Cli/Helpers/PointerCommandSupport.cs index 131a68879..f5bb5de30 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/PointerCommandSupport.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/PointerCommandSupport.cs @@ -14,6 +14,7 @@ internal static class PointerCommandSupport public static async Task ResolvePointAsync( IUiAutomationService uiAutomation, ISelectorService selectorService, + IDesktopForegroundService desktopForeground, UiSessionInfo session, string? selectorStr, PointerPoint? explicitPoint, @@ -50,7 +51,7 @@ public static async Task ResolvePointAsync( if (targetHwnd != 0) { - Windows.Win32.PInvoke.SetForegroundWindow(new Windows.Win32.Foundation.HWND((nint)targetHwnd)); + desktopForeground.RequestForeground(targetHwnd); await Task.Delay(100, cancellationToken); } @@ -65,11 +66,12 @@ public static async Task ResolvePointAsync( return new ResolvedPoint(true, new PointerPoint(stable.CenterX, stable.CenterY), targetHwnd, selectorStr); } - public static async Task SetForegroundAsync(long targetHwnd, CancellationToken cancellationToken) + public static async Task SetForegroundAsync( + IDesktopForegroundService desktopForeground, long targetHwnd, CancellationToken cancellationToken) { if (targetHwnd != 0) { - Windows.Win32.PInvoke.SetForegroundWindow(new Windows.Win32.Foundation.HWND((nint)targetHwnd)); + desktopForeground.RequestForeground(targetHwnd); await Task.Delay(100, cancellationToken); } } diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/UiCoordinatedAction.cs b/src/winapp-CLI/WinApp.Cli/Helpers/UiCoordinatedAction.cs new file mode 100644 index 000000000..0bd76b4de --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/UiCoordinatedAction.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Invocation; +using System.CommandLine.Parsing; +using Microsoft.Extensions.Logging; +using WinApp.Cli.Services.InteractiveDesktop; + +namespace WinApp.Cli.Helpers; + +/// +/// Base for every winapp ui command handler, splitting invocation into two phases so cooperative +/// desktop coordination can never be entered by a command that was going to fail anyway. +/// +/// +/// +/// runs first and performs only local validation — syntax, types, ranges, +/// required options, path shape. It must not resolve a session, touch UI Automation, or contact the +/// target app. A malformed command therefore never opens a participant lease, takes an arrival ticket, +/// or joins an indefinite queue (spec §10). +/// +/// +/// then runs under the workflow turn. The turn and the forward barrier wrap +/// the whole body, but active.lock does not: the body takes it only around the moment it touches +/// the shared desktop, via . That keeps output formatting, PNG +/// encoding, file publication and logging outside the exclusive section. +/// +/// +internal abstract class UiCoordinatedAction(IInteractiveDesktopLock coordinator, ILogger logger) + : AsynchronousCommandLineAction +{ + /// Command name used for local diagnostics, e.g. ui click. Never includes arguments. + protected abstract string Operation { get; } + + /// + /// Local-only validation. Return to continue, or an exit code after emitting + /// the appropriate human and --json error. Must not contact the target app. + /// + protected abstract int? Preflight(ParseResult parseResult); + + /// + /// The command's coordination mode, resolved after so it may read + /// already-validated options such as --wheel or --capture-screen. + /// + protected abstract UiTurnMode ResolveMode(ParseResult parseResult); + + /// The command's work. Runs under the workflow turn. + protected abstract Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken); + + public sealed override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + { + if (Preflight(parseResult) is { } preflightExitCode) + { + return preflightExitCode; + } + + try + { + return await coordinator.RunCoordinatedAsync( + ResolveMode(parseResult), + Operation, + parseResult, + (turn, token) => ExecuteAsync(parseResult, turn, token), + cancellationToken).ConfigureAwait(false); + } + catch (UiCoordinationException ex) + { + var json = UiCoordinationOutputMode.FromParseResult(parseResult).Json; + logger.LogError("{Symbol} {Message}", UiSymbols.Error, ex.Message); + if (ex.RecoveryHint is { } hint) + { + logger.LogError("{Symbol} {Hint}", UiSymbols.Error, hint); + } + + UiJsonError.Emit( + json, + ex.Code, + ex.Message, + errorOut: parseResult.InvocationConfiguration.Error, + recoveryHint: ex.RecoveryHint); + return 1; + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonContext.cs b/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonContext.cs index 6e242d7db..2c7f91179 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonContext.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonContext.cs @@ -46,6 +46,7 @@ namespace WinApp.Cli.Helpers; [JsonSerializable(typeof(UiPointResult[]))] [JsonSerializable(typeof(UiErrorResult))] [JsonSerializable(typeof(UiErrorInfo))] +[JsonSerializable(typeof(UiCoordinationInfo))] [JsonSerializable(typeof(UiFocusedResult))] [JsonSerializable(typeof(UiScreenshotWindowInfo))] [JsonSerializable(typeof(WindowInfo))] @@ -220,6 +221,28 @@ internal sealed class UiErrorInfo public string? Details { get; set; } public string? RecoveryHint { get; set; } public UiPartialOutputInfo? PartialOutput { get; set; } + + /// + /// Desktop turn coordination detail. Present on cancelled and other coordination errors; + /// omitted entirely for every pre-existing error, so the envelope stays additive. + /// + public UiCoordinationInfo? Coordination { get; set; } +} + +/// +/// Why a command was waiting for the shared desktop when it failed or was cancelled. Contains only +/// local, non-identifying counters — never an owner id, hash, app name or selector. +/// +internal sealed class UiCoordinationInfo +{ + /// Milliseconds spent waiting for the desktop before the command stopped waiting. + public long WaitedMs { get; set; } + + /// + /// One-based position among live global waiters, counting this command. Omitted when it cannot be + /// computed reliably, including while waiting behind the command's own owner-local barrier. + /// + public int? QueuePosition { get; set; } } internal sealed class UiPartialOutputInfo diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonError.cs b/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonError.cs index ba8e78c01..7f8a404f0 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonError.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonError.cs @@ -30,17 +30,34 @@ internal static class UiJsonError public const string CodeFrameOutputFailed = "frame_output_failed"; public const string CodePartialOutput = "partial_output"; + /// WINAPP_UI_OWNER_ID was set but empty/whitespace or over 256 characters. + public const string CodeInvalidUiOwnerId = "invalid_ui_owner_id"; + + /// Desktop turn coordination could not be read, published, or safely recovered. + public const string CodeDesktopCoordinationUnavailable = "desktop_coordination_unavailable"; + + /// Too many commands are already waiting for the desktop. + public const string CodeQueueCapacityExceeded = "queue_capacity_exceeded"; + + /// The command was cancelled while waiting for the desktop and never ran. + public const string CodeCancelled = "cancelled"; + /// Write a JSON error envelope to stderr. No-op when is false. /// /// Optional error writer; defaults to . Pass /// parseResult.InvocationConfiguration.Error from a command handler so test harnesses /// that set InvocationConfiguration.Error to a capturing writer can inspect the output. /// + /// + /// Optional desktop-coordination detail (wait duration, queue position) attached to cancellation and + /// other coordination errors. + /// public static void Emit(bool json, string code, string message, string? selector = null, string? details = null, TextWriter? errorOut = null, string? recoveryHint = null, - UiPartialOutputInfo? partialOutput = null) + UiPartialOutputInfo? partialOutput = null, + UiCoordinationInfo? coordination = null) { if (!json) { return; } @@ -54,6 +71,7 @@ public static void Emit(bool json, string code, string message, Details = details, RecoveryHint = recoveryHint, PartialOutput = partialOutput, + Coordination = coordination, }, }; var payload = JsonSerializer.Serialize(result, UiJsonLineContext.Default.UiErrorResult); diff --git a/src/winapp-CLI/WinApp.Cli/NativeMethods.txt b/src/winapp-CLI/WinApp.Cli/NativeMethods.txt index 8fdb144b0..d536086bd 100644 --- a/src/winapp-CLI/WinApp.Cli/NativeMethods.txt +++ b/src/winapp-CLI/WinApp.Cli/NativeMethods.txt @@ -168,3 +168,8 @@ GetHandleInformation SetHandleInformation STD_HANDLE HANDLE_FLAGS +CreateToolhelp32Snapshot +Process32First +Process32Next +PROCESSENTRY32 +CREATE_TOOLHELP_SNAPSHOT_FLAGS diff --git a/src/winapp-CLI/WinApp.Cli/Program.cs b/src/winapp-CLI/WinApp.Cli/Program.cs index 9a59491b6..5d99012d5 100644 --- a/src/winapp-CLI/WinApp.Cli/Program.cs +++ b/src/winapp-CLI/WinApp.Cli/Program.cs @@ -265,6 +265,11 @@ internal static async Task RunWithTelemetryAsync( // relatedActivityId for this invocation. TelemetryCorrelation.Begin(); + // Open the coordination scope in the same async flow that reads it below. A `winapp ui` + // command sets its cooperative-turn summary from deep inside invoke(), and + // CommandCompletedEvent picks it up here (issue #764). + Services.InteractiveDesktop.UiCoordinationTelemetryScope.Begin(); + if (!isCompleteMode) { logCommandInvoked(parsedArgs.CommandResult); diff --git a/src/winapp-CLI/WinApp.Cli/Services/IUiAutomationService.cs b/src/winapp-CLI/WinApp.Cli/Services/IUiAutomationService.cs index 96a9f6b60..1457c8e0f 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/IUiAutomationService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/IUiAutomationService.cs @@ -3,6 +3,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Services; @@ -37,11 +38,27 @@ internal interface IUiAutomationService Task SearchAsync(UiSessionInfo session, SelectorExpression selector, int maxResults, CancellationToken ct); Task FindSingleElementAsync(UiSessionInfo session, SelectorExpression selector, CancellationToken ct); Task> GetPropertiesAsync(UiSessionInfo session, UiElement element, string? propertyName, CancellationToken ct); - Task<(byte[] Pixels, int Width, int Height)> ScreenshotAsync(UiSessionInfo session, string? elementId, bool captureScreen, bool focus, CancellationToken ct); + /// Captures a window or element region as raw BGRA pixels. + /// + /// Scope entered only around restore, foreground, and live-screen moments — never around the + /// readback, crop or encode (spec §6.5). + /// + /// + /// When the capture refuses to restore or foreground anything and instead + /// throws , so an Observe screenshot can + /// escalate the whole invocation and recapture from the beginning. + /// + Task<(byte[] Pixels, int Width, int Height)> ScreenshotAsync( + UiSessionInfo session, string? elementId, bool captureScreen, bool focus, + IDesktopSection desktopSection, bool observeOnly, CancellationToken ct); /// Records a window or element region to H.264 MP4. + /// + /// Scope used only for the restore/foreground moment before capture starts. The capture loop itself + /// stays outside it so same-owner input can interleave with an in-flight recording (spec §6.3). + /// /// Invoked after the first frame; reports whether frame output is active. - Task RecordAsync(UiSessionInfo session, string? elementId, RecordOptions options, CancellationToken ct, Action? onRecordingStarted = null); + Task RecordAsync(UiSessionInfo session, string? elementId, RecordOptions options, IDesktopSection desktopSection, CancellationToken ct, Action? onRecordingStarted = null); Task InvokeAsync(UiSessionInfo session, UiElement element, CancellationToken ct); Task SetValueAsync(UiSessionInfo session, UiElement element, string text, CancellationToken ct); Task FocusAsync(UiSessionInfo session, UiElement element, CancellationToken ct); @@ -49,4 +66,4 @@ internal interface IUiAutomationService Task ScrollContainerAsync(UiSessionInfo session, UiElement element, string? direction, string? to, CancellationToken ct); Task GetFocusedElementAsync(UiSessionInfo session, CancellationToken ct); Task GetTextAsync(UiSessionInfo session, UiElement element, CancellationToken ct); -} +} \ No newline at end of file diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs new file mode 100644 index 000000000..cd1218c42 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Parsing; + +namespace WinApp.Cli.Services.InteractiveDesktop; + +/// +/// Scope around a desktop-sensitive section — foreground, focus, cursor, SendInput, synthetic +/// pointer input, window restore, or live-screen capture. Entering takes active.lock; disposing +/// releases it. +/// +/// +/// Passed to services (screenshot, record) as an explicit parameter rather than read from ambient +/// state, matching the repo's existing injectable-seam style and letting unit tests substitute a no-op. +/// +internal interface IDesktopSection +{ + /// + /// Acquires active.lock for the duration of the returned scope. Reentrant within a process: + /// a nested enter increments a refcount and releases the lock only when the outermost scope closes. + /// + Task EnterAsync(CancellationToken cancellationToken); +} + +/// The workflow turn a coordinated command is executing under. +internal interface IUiTurn : IDesktopSection +{ + /// The mode this command was admitted with, after any escalation. + UiTurnMode Mode { get; } + + /// Milliseconds spent queued before execution began. Zero when the turn was free. + long WaitedMs { get; } + + /// + /// Converts an in-flight command into a + /// one and waits for the barrier (spec §6.5). Used by + /// ui screenshot when a target turns out to need restore or foreground: the invocation + /// discards its buffered captures, escalates as a whole, and recaptures from the beginning. + /// + Task EscalateToDesktopExclusiveAsync(CancellationToken cancellationToken); +} + +/// +/// Cooperative desktop turn coordination across concurrent winapp.exe processes (issue #764). +/// +internal interface IInteractiveDesktopLock +{ + /// + /// Runs under the workflow turn implied by . + /// + /// + /// Callers must have completed every local validation before calling this: a malformed command must + /// never open a lease, take a ticket, or join an indefinite queue (spec §10). The forward barrier + /// wraps ; active.lock is not held across it — the body takes + /// it only for its desktop-sensitive section via . + /// + /// The command's coordination mode. + /// Command name for diagnostics, e.g. ui click. Never arguments. + /// Used only to resolve --json / --verbose / --quiet. + /// The command's work, which contacts the target app. + /// + /// The body's exit code, or 130 when the command was cancelled while queued and never ran. + /// + Task RunCoordinatedAsync( + UiTurnMode mode, + string operation, + ParseResult parseResult, + Func> body, + CancellationToken cancellationToken); +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IMonotonicClock.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IMonotonicClock.cs new file mode 100644 index 000000000..78e61809a --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IMonotonicClock.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Services.InteractiveDesktop; + +/// +/// The monotonic time source behind every coordination deadline. Backed by +/// in production (spec §10): UTC timestamps in +/// state.json are diagnostic only, because wall-clock time can jump backwards across DST, +/// NTP corrections and manual clock changes, which would either strand a turn or hand it off early. +/// +/// +/// counts milliseconds since the current boot, so a state file +/// written before a reboot carries deadlines from a different epoch. That is safe here because +/// prior-boot state is only ever acted upon when no participant lease is live, and such state is +/// treated as stale (spec §12.3). +/// +internal interface IMonotonicClock +{ + /// Milliseconds since boot. Strictly non-decreasing within one boot. + long NowTicks64 { get; } + + /// Wall-clock UTC, used only for the diagnostic fields in state.json. + DateTimeOffset UtcNow { get; } +} + +/// Production over . +internal sealed class TickCountClock : IMonotonicClock +{ + public long NowTicks64 => Environment.TickCount64; + + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopJsonContext.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopJsonContext.cs new file mode 100644 index 000000000..7a2e25ffd --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopJsonContext.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace WinApp.Cli.Services.InteractiveDesktop; + +/// +/// Source-generated serializer context for interactive-desktop-{session}.state.json. The CLI +/// publishes with NativeAOT and TrimMode=full, so reflection-based serialization is not +/// available — every coordination type must be reachable from here. +/// +/// +/// is intentionally off: the state +/// file is machine-only and is rewritten on every registration and completion, so the smaller payload +/// keeps the atomic temp-write cheap. Nulls are omitted so an absent owner or an +/// entry's missing ticket round-trip as absent rather than as +/// null. +/// +[JsonSerializable(typeof(InteractiveDesktopState))] +[JsonSerializable(typeof(OwnerRecord))] +[JsonSerializable(typeof(OwnerCommandEntry))] +[JsonSerializable(typeof(WaiterEntry))] +[JsonSourceGenerationOptions( + WriteIndented = false, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +internal partial class InteractiveDesktopJsonContext : JsonSerializerContext; diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs new file mode 100644 index 000000000..19a825516 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs @@ -0,0 +1,645 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using WinApp.Cli.Helpers; + +namespace WinApp.Cli.Services.InteractiveDesktop; + +/// +/// Cooperative desktop turn coordination for concurrent winapp ui processes (issue #764). +/// +/// +/// +/// Lock ordering is mandatory and enforced by the shape of this class (spec §9): state.lock is +/// only ever held inside a using that contains no awaits on UI work, a participant lease is +/// always opened before its state entry is published and closed after the entry is removed, and +/// active.lock is never acquired while state.lock is held. +/// +/// +/// The forward barrier wraps command execution, but active.lock deliberately does not: a +/// command takes it only around the moment it touches the shared desktop, so output formatting, PNG +/// encoding, file publication and logging never block another workflow. +/// +/// +internal sealed class InteractiveDesktopLock : IInteractiveDesktopLock +{ + /// Exit code for a command cancelled before it ever ran (128 + SIGINT). + internal const int CancelledExitCode = 130; + + private const int PollMinMs = 50; + private const int PollMaxMs = 75; + private const int ActiveLockRetryMinMs = 10; + private const int ActiveLockRetryMaxMs = 25; + + // Explicit fields rather than primary-constructor parameters: the nested CoordinatedExecution needs + // access to them, and captured primary-constructor parameters are not visible to nested types. + private readonly IInteractiveDesktopStateStore _store; + private readonly IInteractiveDesktopPaths _paths; + private readonly IParticipantRegistry _participants; + private readonly IUiOwnerResolver _ownerResolver; + private readonly IProcessInspector _processInspector; + private readonly IPollDelay _pollDelay; + private readonly IAnsiConsole _console; + private readonly ILogger _logger; + private readonly InteractiveDesktopScheduler _scheduler; + + public InteractiveDesktopLock( + IInteractiveDesktopStateStore store, + IInteractiveDesktopPaths paths, + IParticipantRegistry participants, + IUiOwnerResolver ownerResolver, + IProcessInspector processInspector, + IMonotonicClock clock, + IPollDelay pollDelay, + IAnsiConsole console, + ILogger logger) + { + _store = store; + _paths = paths; + _participants = participants; + _ownerResolver = ownerResolver; + _processInspector = processInspector; + _pollDelay = pollDelay; + _console = console; + _logger = logger; + _scheduler = new InteractiveDesktopScheduler(clock); + } + + public async Task RunCoordinatedAsync( + UiTurnMode mode, + string operation, + ParseResult parseResult, + Func> body, + CancellationToken cancellationToken) + { + var outputMode = UiCoordinationOutputMode.FromParseResult(parseResult); + var owner = _ownerResolver.Resolve(); + var participant = new UiParticipantIdentity( + _processInspector.CurrentProcessId, + _processInspector.CurrentProcessStartTicksUtc, + operation); + + var execution = new CoordinatedExecution(this, owner, participant, mode, outputMode, parseResult); + using (execution) + { + return await execution.RunAsync(body, cancellationToken).ConfigureAwait(false); + } + } + + private LivenessProbe CreateProbe() => new LivenessProbe(_participants, _processInspector); + + /// + /// Waits for active.lock. Never steals it from a live process — a hung owner is recovered by + /// cancelling or terminating it, not by another process forcing its way onto the desktop (spec §7.3). + /// + private async Task AcquireActiveLockAsync(string activeLockPath, CancellationToken cancellationToken) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + return new FileStream( + activeLockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.None); + } + catch (IOException) + { + // Held by a process inside its desktop-sensitive section. + } + catch (UnauthorizedAccessException ex) + { + throw new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, + $"The UI desktop lock could not be opened: {ex.Message}", + "Check that the current user can write to the coordination directory."); + } + + await _pollDelay + .DelayAsync(Random.Shared.Next(ActiveLockRetryMinMs, ActiveLockRetryMaxMs + 1), cancellationToken) + .ConfigureAwait(false); + } + } + + /// Composes lease-backed participant liveness with process liveness for parent owners. + private sealed class LivenessProbe(IParticipantRegistry participants, IProcessInspector processInspector) + : ICoordinationLivenessProbe + { + public bool IsParticipantLive(int processId, long startTicksUtc) + => participants.IsParticipantLive(processId, startTicksUtc); + + public bool? IsParentAlive(int processId, long startTicksUtc) + => processInspector.IsProcessAlive(processId, startTicksUtc); + } + + /// + /// One command's participation: registration, queue waiting, desktop sections, escalation and + /// teardown. Held as a separate object so itself stays a + /// stateless singleton. + /// + private sealed class CoordinatedExecution( + InteractiveDesktopLock coordinator, + UiOwnerIdentity owner, + UiParticipantIdentity participant, + UiTurnMode mode, + UiCoordinationOutputMode outputMode, + ParseResult parseResult) : IUiTurn, IDisposable + { + private readonly LivenessProbe _probe = coordinator.CreateProbe(); + private readonly Stopwatch _waitWatch = new(); + + /// + /// Serializes desktop sections opened by this one command. + /// + /// + /// active.lock is FileShare.None, so a second handle blocks even from the same + /// process. Without this gate, two concurrent tasks in one command would race on the file lock; + /// with an earlier refcount design one of them would have skipped the lock entirely and run its + /// desktop work unprotected. The gate makes unrelated concurrent enters queue up instead. + /// + /// Desktop sections are deliberately NOT reentrant. Every call site is sequential: the screenshot + /// pass enters once per restore/foreground/live-screen moment and closes each scope before the + /// next, and recording enters once before capture plus once per rare blank-frame retry. Code that + /// genuinely needs the desktop inside an open section must receive the existing + /// scope rather than opening a nested one, which would self-deadlock + /// on the file lock. + /// + /// + private readonly SemaphoreSlim _sectionGate = new(1, 1); + + private IParticipantLease? _lease; + private FileStream? _activeLock; private bool _detached; + private bool _recoveredFromCorruption; + private long? _ticket; + private UiTurnAction _turnAction = UiTurnAction.New; + private int _observedQueueDepth; + + public UiTurnMode Mode { get; private set; } = mode; + + public long WaitedMs { get; private set; } + + public async Task RunAsync( + Func> body, + CancellationToken cancellationToken) + { + var bodyCompletedNormally = false; + var outcome = UiCoordinationOutcome.Completed; + + try + { + Register(cancellationToken); + + if (!_detached) + { + await WaitUntilRunnableAsync(cancellationToken).ConfigureAwait(false); + } + + try + { + var exitCode = await body(this, cancellationToken).ConfigureAwait(false); + bodyCompletedNormally = true; + return exitCode; + } + finally + { + await ReleaseAllSectionsAsync().ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (!bodyCompletedNormally && _waitWatch.IsRunning) + { + // Cancelled while queued: the command never reached execution, so it has no partial UI + // side effects and no result to preserve (spec §11.1). + outcome = UiCoordinationOutcome.Cancelled; + EmitQueuedCancellation(); + return CancelledExitCode; + } + catch (UiCoordinationException) + { + outcome = UiCoordinationOutcome.CoordinationFailure; + throw; + } + finally + { + Complete(bodyCompletedNormally); + PublishTelemetry(bodyCompletedNormally, outcome); + } + } + + private void Register(CancellationToken cancellationToken) + { + using var stateLock = coordinator._store.AcquireStateLock(cancellationToken); + var read = coordinator._store.Read(); + _recoveredFromCorruption = read.RecoveredFromCorruption; + + if (read.UnknownNewerVersion) + { + RegisterAgainstUnknownVersion(); + return; + } + + var state = read.State!; + + if (Mode == UiTurnMode.Observe) + { + RegisterObserve(state); + return; + } + + RegisterParticipating(state); + } + + /// + /// A newer binary owns the state file. Observations continue detached without touching it; + /// anything that would claim or mutate a turn fails closed rather than driving the desktop + /// outside coordination (spec §12.4). + /// + private void RegisterAgainstUnknownVersion() + { + if (Mode != UiTurnMode.Observe) + { + throw new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, + "UI turn coordination state was written by a newer version of winapp, so this build cannot coordinate safely.", + "Update winapp so every process on this desktop uses a compatible version, then retry."); + } + + coordinator._logger.LogDebug( + "UI coordination state has a newer schema version; running {Operation} detached.", participant.Operation); + _detached = true; + _turnAction = UiTurnAction.Detached; + } + + private void RegisterObserve(InteractiveDesktopState state) + { + var changed = coordinator._scheduler.Normalize(state, _probe); + + if (!InteractiveDesktopScheduler.IsCurrentOwner(state, owner)) + { + // Spec §6.2: a non-owner observation never claims a free turn, so it runs with no lease + // and no state entry and cannot block anyone. + if (changed || _recoveredFromCorruption) + { + coordinator._store.Publish(state); + } + + _detached = true; + _turnAction = UiTurnAction.Detached; + return; + } + + // The lease is opened before the entry is published so no published command ever lacks + // liveness proof (spec §9 rule 5). + _lease = coordinator._participants.OpenLease(participant.ProcessId, participant.StartTicksUtc); + var admission = coordinator._scheduler.BeginObserve(state, _probe, owner, participant); + _turnAction = admission.TurnAction; + coordinator._store.Publish(state); + } + + private void RegisterParticipating(InteractiveDesktopState state) + { + _lease = coordinator._participants.OpenLease(participant.ProcessId, participant.StartTicksUtc); + + UiAdmissionResult admission; + try + { + admission = coordinator._scheduler.BeginParticipating(state, _probe, owner, participant, Mode); + } + catch + { + // Nothing was published, so close the lease immediately rather than leaving an orphan for + // another coordinator to prune (spec §10.3). + _lease.Dispose(); + _lease = null; + throw; + } + + _ticket = admission.Ticket; + _turnAction = admission.TurnAction; + _observedQueueDepth = InteractiveDesktopScheduler.CountLiveWaiters(state, _probe); + coordinator._store.Publish(state); + + if (admission.Admission is UiAdmission.OwnerCommandWaiting or UiAdmission.GlobalWaiter) + { + _waitWatch.Start(); + } + } + + /// + /// Polls until this command's entry is — covering both the + /// global FIFO wait and the owner-local forward barrier. Cancellable and indefinite: there is no + /// coordination timeout in v1 (spec §10.3, §10.4). + /// + private async Task WaitUntilRunnableAsync(CancellationToken cancellationToken) + { + if (!_waitWatch.IsRunning) + { + return; + } + + var reporter = new UiCoordinationWaitReporter( + coordinator._console, outputMode, participant.Operation, owner.ParentPid); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + UiWaitDiagnostics diagnostics; + using (var stateLock = coordinator._store.AcquireStateLock(cancellationToken)) + { + var read = coordinator._store.Read(); + if (read.UnknownNewerVersion) + { + throw new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, + "UI turn coordination state was replaced by a newer version of winapp while this command was waiting.", + "Update winapp so every process on this desktop uses a compatible version, then retry."); + } + + var state = read.State!; + if (coordinator._scheduler.Normalize(state, _probe) || read.RecoveredFromCorruption) + { + coordinator._store.Publish(state); + } + + var entry = InteractiveDesktopScheduler.FindOwnerCommand(state, participant); + if (entry is { Status: UiCommandStatus.Running }) + { + _waitWatch.Stop(); + WaitedMs = _waitWatch.ElapsedMilliseconds; + Mode = entry.Mode; + _ticket = entry.Ticket; + return; + } + + diagnostics = BuildDiagnostics(state, entry); + } + + reporter.ReportIfDue(_waitWatch.ElapsedMilliseconds, diagnostics); + + // Jittered so a burst of waiters does not resynchronize into a lock-step convoy on + // state.lock. There are no heartbeat writes — a poll that finds nothing changed + // publishes nothing. + await coordinator._pollDelay + .DelayAsync(Random.Shared.Next(PollMinMs, PollMaxMs + 1), cancellationToken) + .ConfigureAwait(false); + } + } + + private UiWaitDiagnostics BuildDiagnostics(InteractiveDesktopState state, OwnerCommandEntry? ownEntry) + { + var queueDepth = InteractiveDesktopScheduler.CountLiveWaiters(state, _probe); + _observedQueueDepth = Math.Max(_observedQueueDepth, queueDepth); + + var active = state.OwnerCommands + .Where(c => c.Status == UiCommandStatus.Running && c.Pid != participant.ProcessId) + .OrderBy(c => c.Ticket ?? long.MaxValue) + .FirstOrDefault(); + + int commandsAhead; + if (ownEntry is { Ticket: { } ownTicket }) + { + // Owner-local: everything ahead of us in our own owner's barrier order. + commandsAhead = state.OwnerCommands.Count(c => (c.Ticket ?? long.MaxValue) < ownTicket); + } + else if (_ticket is { } queuedTicket) + { + commandsAhead = state.OwnerCommands.Count + + state.Waiters.Count(w => w.Ticket < queuedTicket + && _probe.IsParticipantLive(w.Pid, w.ProcessStartTicksUtc)); + } + else + { + commandsAhead = state.OwnerCommands.Count; + } + + return new UiWaitDiagnostics( + queueDepth, + commandsAhead, + active?.Pid, + active?.Operation); + } + + public async Task EnterAsync(CancellationToken cancellationToken) + { + // Serialize within this command first, then take the cross-process lock. Both are required: + // the gate stops two concurrent tasks in this command from racing, and active.lock stops + // other winapp processes from acting on the desktop at the same time. + await _sectionGate.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + _activeLock = await coordinator + .AcquireActiveLockAsync(coordinator._paths.ActiveLockPath, cancellationToken) + .ConfigureAwait(false); + } + catch + { + _sectionGate.Release(); + throw; + } + + return new SectionScope(this); + } + + public async Task EscalateToDesktopExclusiveAsync(CancellationToken cancellationToken) + { + if (Mode == UiTurnMode.DesktopExclusive) + { + return; + } + + using (var stateLock = coordinator._store.AcquireStateLock(cancellationToken)) + { + var read = coordinator._store.Read(); + if (read.UnknownNewerVersion) + { + throw new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, + "UI turn coordination state was written by a newer version of winapp, so this screenshot cannot escalate safely.", + "Update winapp so every process on this desktop uses a compatible version, then retry."); + } + + var state = read.State!; + + if (_lease is not null + && coordinator._scheduler.EscalateObserveToExclusive(state, _probe, participant)) + { + // Spec §6.5: the same lease and the same entry are reused, so no intermediate state + // is ever published in which this process has no command. + _ticket = InteractiveDesktopScheduler.FindOwnerCommand(state, participant)?.Ticket; + } + else + { + // A detached non-owner observation registers a brand-new DesktopExclusive command. + _lease ??= coordinator._participants.OpenLease( + participant.ProcessId, participant.StartTicksUtc); + var admission = coordinator._scheduler.BeginParticipating( + state, _probe, owner, participant, UiTurnMode.DesktopExclusive); + _ticket = admission.Ticket; + _turnAction = admission.TurnAction; + _detached = false; + } + + coordinator._store.Publish(state); + } + + Mode = UiTurnMode.DesktopExclusive; + _waitWatch.Restart(); + await WaitUntilRunnableAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task ReleaseAllSectionsAsync() + { + // Safety net for a body that returned or threw without disposing its scope. Releasing the + // file lock here (rather than the gate) is deliberate: a leaked gate would only stall this + // already-finishing command, whereas a leaked active.lock would block the whole desktop + // until the process exits. + if (_activeLock is not null) + { + await _activeLock.DisposeAsync().ConfigureAwait(false); + _activeLock = null; + } + } + + /// + /// Removes this command's entry and applies the idle-grace rule, then closes the lease — in that + /// order, so no entry is ever left without liveness proof (spec §9 rule 6, §10.6). + /// + private void Complete(bool renewGrace) + { + if (_lease is null) + { + return; + } + + try + { + using var stateLock = coordinator._store.AcquireStateLock(CancellationToken.None); + var read = coordinator._store.Read(); + if (read.State is { } state) + { + coordinator._scheduler.CompleteCommand(state, _probe, participant, owner.Kind, renewGrace); + coordinator._store.Publish(state); + } + } + catch (Exception ex) when (ex is UiCoordinationException or IOException) + { + // Teardown must never mask the command's own result. Windows deletes the lease below, so + // the next coordinator prunes this entry anyway. + coordinator._logger.LogDebug("UI coordination teardown could not update state: {Message}", ex.Message); + } + finally + { + _lease.Dispose(); + _lease = null; + } + } + + private void EmitQueuedCancellation() + { + var waitedMs = _waitWatch.ElapsedMilliseconds; + int? queuePosition = null; + + try + { + using var stateLock = coordinator._store.AcquireStateLock(CancellationToken.None); + var read = coordinator._store.Read(); + if (read.State is { } state && _ticket is { } ticket + && InteractiveDesktopScheduler.FindWaiter(state, participant) is not null) + { + queuePosition = InteractiveDesktopScheduler.QueuePositionOf(state, _probe, ticket); + } + } + catch (Exception ex) when (ex is UiCoordinationException or IOException) + { + coordinator._logger.LogDebug("Queue position could not be read while cancelling: {Message}", ex.Message); + } + + UiJsonError.Emit( + outputMode.Json, + UiCoordinationErrorCodes.Cancelled, + "UI turn wait was cancelled.", + errorOut: parseResult.InvocationConfiguration.Error, + coordination: new UiCoordinationInfo + { + WaitedMs = waitedMs, + QueuePosition = queuePosition, + }); + + if (!outputMode.Json && !outputMode.Quiet) + { + coordinator._logger.LogWarning( + "{Symbol} Cancelled while waiting {WaitedMs} ms for the desktop.", + UiSymbols.Warning, + waitedMs); + } + } + + private void PublishTelemetry(bool completedNormally, UiCoordinationOutcome outcome) + { + var effectiveOutcome = _recoveredFromCorruption && completedNormally + ? UiCoordinationOutcome.CorruptionRecovery + : outcome; + + UiCoordinationTelemetryScope.Set(new UiCoordinationSummary( + owner.Kind, + Mode, + _turnAction, + effectiveOutcome, + WaitedMs, + _observedQueueDepth, + _waitWatch.ElapsedMilliseconds)); + } + + /// + /// Releases the in-process section gate once the command has finished. + /// + /// + /// The participant lease is normally closed by , which must remove this + /// command's state entry before the lease closes (spec §9 rule 6). This runs strictly + /// after that, so the null-conditional call here is only a safety net for a lease that somehow + /// outlived completion — it never inverts the ordering. + /// + public void Dispose() + { + _lease?.Dispose(); + _lease = null; + _sectionGate.Dispose(); + } + + private sealed class SectionScope(CoordinatedExecution execution) : IAsyncDisposable + { + private bool _disposed; + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + + // Release the cross-process lock before the in-process gate, so the next waiter in this + // command never finds the gate open while the file lock is still held. + if (execution._activeLock is not null) + { + await execution._activeLock.DisposeAsync().ConfigureAwait(false); + execution._activeLock = null; + } + + execution._sectionGate.Release(); + } + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs new file mode 100644 index 000000000..80c3143d8 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs @@ -0,0 +1,302 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Globalization; +using System.Security.AccessControl; +using System.Security.Principal; +using WinApp.Cli.Helpers; + +namespace WinApp.Cli.Services.InteractiveDesktop; + +/// +/// Resolves the coordination file set for the current user and Windows session (spec §7): +/// +/// %LOCALAPPDATA%\Microsoft\WinAppCli\locks\ +/// interactive-desktop-{session}.state.lock +/// interactive-desktop-{session}.state.json +/// interactive-desktop-{session}.active.lock +/// participants\interactive-desktop-{session}-{pid}-{startTicks}.lease +/// +/// +/// +/// The session scope matters because Windows gives every signed-in session its own foreground window, +/// focus and input stream. Two sessions on one machine (fast user switching, several RDP sessions) do +/// not interfere, so they must not queue behind each other. +/// +internal interface IInteractiveDesktopPaths +{ + /// Directory holding the lock, state and participants for this user + session. + string LockDirectory { get; } + + /// Directory holding one lease file per queued or active participant. + string ParticipantsDirectory { get; } + + /// Short lock taken around every state read or update. + string StateLockPath { get; } + + /// The coordination state document. + string StatePath { get; } + + /// Long lock held only across a desktop-sensitive section. + string ActiveLockPath { get; } + + /// Lease path for one participant process. + string LeasePath(int processId, long startTicksUtc); + + /// Glob matching every lease belonging to this user + session. + string LeaseSearchPattern { get; } + + /// Parses a lease file name back into the owning process identity. + bool TryParseLeaseFileName(string fileName, out int processId, out long startTicksUtc); + + /// Creates the lock and participants directories, restricted to the current user. + void EnsureDirectories(); +} + +/// +internal sealed class InteractiveDesktopPaths : IInteractiveDesktopPaths +{ + /// + /// Redirects the whole coordination file set. Exists so multiprocess and file-level tests never + /// touch the developer's live desktop coordination state. It relocates coordination; it never + /// disables it, so a test still exercises the real locking protocol. + /// + internal const string LockDirectoryOverrideVariable = "WINAPP_UI_LOCK_DIRECTORY"; + + private const string FilePrefix = "interactive-desktop-"; + private const string LeaseExtension = ".lease"; + + private readonly string _sessionToken; + private bool _directoriesVerified; + + public InteractiveDesktopPaths(IProcessInspector processInspector) + { + _sessionToken = processInspector.CurrentSessionId.ToString(CultureInfo.InvariantCulture); + LockDirectory = ResolveLockDirectory(); + ParticipantsDirectory = Path.Combine(LockDirectory, "participants"); + StateLockPath = Path.Combine(LockDirectory, $"{FilePrefix}{_sessionToken}.state.lock"); + StatePath = Path.Combine(LockDirectory, $"{FilePrefix}{_sessionToken}.state.json"); + ActiveLockPath = Path.Combine(LockDirectory, $"{FilePrefix}{_sessionToken}.active.lock"); + LeaseSearchPattern = $"{FilePrefix}{_sessionToken}-*{LeaseExtension}"; + } + + public string LockDirectory { get; } + + public string ParticipantsDirectory { get; } + + public string StateLockPath { get; } + + public string StatePath { get; } + + public string ActiveLockPath { get; } + + public string LeaseSearchPattern { get; } + + public string LeasePath(int processId, long startTicksUtc) + => Path.Combine( + ParticipantsDirectory, + $"{FilePrefix}{_sessionToken}-{processId.ToString(CultureInfo.InvariantCulture)}-" + + $"{ProcessInspector.FormatStartTicks(startTicksUtc)}{LeaseExtension}"); + + public bool TryParseLeaseFileName(string fileName, out int processId, out long startTicksUtc) + { + processId = 0; + startTicksUtc = 0; + + var expectedPrefix = $"{FilePrefix}{_sessionToken}-"; + if (!fileName.StartsWith(expectedPrefix, StringComparison.Ordinal) + || !fileName.EndsWith(LeaseExtension, StringComparison.Ordinal)) + { + return false; + } + + var body = fileName[expectedPrefix.Length..^LeaseExtension.Length]; + var separator = body.LastIndexOf('-'); + if (separator <= 0 || separator == body.Length - 1) + { + return false; + } + + return int.TryParse(body[..separator], NumberStyles.None, CultureInfo.InvariantCulture, out processId) + && long.TryParse(body[(separator + 1)..], NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out startTicksUtc); + } + + public void EnsureDirectories() + { + // Verified once per process: the check is a DACL read per directory, and every state-lock + // acquisition and lease open calls this. + if (_directoriesVerified) + { + return; + } + + EnsureRestrictedDirectory(LockDirectory); + EnsureRestrictedDirectory(ParticipantsDirectory); + _directoriesVerified = true; + } + + private static string ResolveLockDirectory() + { + var overridePath = Environment.GetEnvironmentVariable(LockDirectoryOverrideVariable); + if (!string.IsNullOrWhiteSpace(overridePath)) + { + return ValidateLockDirectory(overridePath.Trim(), LockDirectoryOverrideVariable); + } + + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrWhiteSpace(localAppData)) + { + throw new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, + "The local application data folder could not be resolved, so UI turn coordination has nowhere to store its state.", + "Ensure LOCALAPPDATA is set for this user, or set WINAPP_UI_LOCK_DIRECTORY to a fully qualified local directory."); + } + + return ValidateLockDirectory( + Path.Combine(localAppData, "Microsoft", "WinAppCli", "locks"), + "LOCALAPPDATA"); + } + + private static string ValidateLockDirectory(string path, string source) + { + // A relative path would resolve against the caller's working directory, so two processes in + // different directories would coordinate against different files and silently not cooperate. + if (!Path.IsPathFullyQualified(path)) + { + throw new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, + $"The UI coordination directory resolved from {source} is not a fully qualified path.", + "Set WINAPP_UI_LOCK_DIRECTORY to a fully qualified local directory such as C:\\Temp\\winapp-locks."); + } + + // Byte-range locking over SMB is advisory and unreliable for the exclusive-share protocol this + // coordinator depends on, so a network path would produce silent overlap instead of exclusion. + if (PathSafety.IsNetworkPath(path)) + { + throw new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, + $"The UI coordination directory resolved from {source} is a network path, which cannot provide reliable exclusive file locks.", + "Set WINAPP_UI_LOCK_DIRECTORY to a fully qualified path on a local drive."); + } + + return Path.GetFullPath(path); + } + + /// + /// The parent (%LOCALAPPDATA%) is already restricted to the current user, but that is not + /// enough on its own: WINAPP_UI_LOCK_DIRECTORY can point at a shared location such as + /// C:\Temp, and a directory created by an earlier run may have inherited permissive rules. + /// Coordination state is not a secret, but a foreign writer could corrupt it or hold a lease and + /// stall this user's UI workflows indefinitely, so an existing directory is inspected and repaired + /// rather than trusted. + /// + private static void EnsureRestrictedDirectory(string path) + { + var directoryInfo = new DirectoryInfo(path); + + if (!directoryInfo.Exists) + { + try + { + directoryInfo.Create(BuildCurrentUserOnlySecurity()); + return; + } + catch (UnauthorizedAccessException ex) + { + throw Unavailable(path, ex); + } + catch (IOException ex) + { + // Another winapp process can win the create race; that is success, not failure. Fall + // through so the existing directory still gets its DACL verified below. + directoryInfo.Refresh(); + if (!directoryInfo.Exists) + { + throw Unavailable(path, ex); + } + } + } + + RepairAccessRulesIfNeeded(directoryInfo); + } + + /// + /// Re-applies the current-user-only DACL when the existing one is inherited or grants any other + /// identity. A no-op in the overwhelmingly common case, so the per-process check stays cheap. + /// + private static void RepairAccessRulesIfNeeded(DirectoryInfo directoryInfo) + { + var currentUser = WindowsIdentity.GetCurrent().User; + if (currentUser is null) + { + // Without an identity there is nothing to scope the DACL to; inherited parent permissions + // are the best available protection. + return; + } + + try + { + var existing = directoryInfo.GetAccessControl(); + if (IsCurrentUserOnly(existing, currentUser)) + { + return; + } + + directoryInfo.SetAccessControl(BuildCurrentUserOnlySecurity()); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or PrivilegeNotHeldException or InvalidOperationException) + { + // The directory is reachable but cannot be secured — for example it belongs to another user. + // Coordinating through storage a third party can tamper with is worse than not running. + throw new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, + $"The UI coordination directory '{directoryInfo.FullName}' could not be restricted to the current user: {ex.Message}", + "Point WINAPP_UI_LOCK_DIRECTORY at a directory this user owns, or remove the override to use the default location under %LOCALAPPDATA%."); + } + } + + private static bool IsCurrentUserOnly(DirectorySecurity security, SecurityIdentifier currentUser) + { + if (!security.AreAccessRulesProtected) + { + // Inherited rules can grant anyone the parent grants, which for a shared override directory + // includes other users. + return false; + } + + foreach (FileSystemAccessRule rule in security.GetAccessRules(true, true, typeof(SecurityIdentifier))) + { + if (rule.IdentityReference is not SecurityIdentifier sid || sid != currentUser) + { + return false; + } + } + + return true; + } + + private static DirectorySecurity BuildCurrentUserOnlySecurity() + { + var security = new DirectorySecurity(); + var currentUser = WindowsIdentity.GetCurrent().User; + if (currentUser is not null) + { + security.SetOwner(currentUser); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new FileSystemAccessRule( + currentUser, + FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, + AccessControlType.Allow)); + } + + return security; + } + + private static UiCoordinationException Unavailable(string path, Exception ex) + => new( + UiCoordinationErrorCodes.Unavailable, + $"The UI coordination directory '{path}' could not be created: {ex.Message}", + "Check that the current user can write to the directory, or set WINAPP_UI_LOCK_DIRECTORY to a writable local directory."); +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs new file mode 100644 index 000000000..cfed58d42 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs @@ -0,0 +1,534 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Services.InteractiveDesktop; + +/// +/// Answers the two liveness questions the scheduler needs, kept behind an interface so every state +/// transition is testable without real processes or lease files. +/// +internal interface ICoordinationLivenessProbe +{ + /// + /// Whether a recorded participant still holds its lease. This is the only basis for + /// pruning: there are no heartbeats and no timestamps, so a merely suspended process is correctly + /// reported alive and keeps its queue position. + /// + bool IsParticipantLive(int processId, long startTicksUtc); + + /// + /// Whether a parent-derived owner's shell is still running. means liveness + /// could not be determined, which must never be treated as death. + /// + bool? IsParentAlive(int processId, long startTicksUtc); +} + +/// Identity of the command this process is registering. +/// This process's id. +/// This process's start ticks, pairing with the PID to defeat PID reuse. +/// Command name for diagnostics, e.g. ui click. Never arguments. +internal readonly record struct UiParticipantIdentity(int ProcessId, long StartTicksUtc, string Operation); + +/// Where a newly admitted command landed. +internal enum UiAdmission +{ + /// Registered in and already eligible. + OwnerCommandRunning, + + /// Registered in but behind an earlier barrier. + OwnerCommandWaiting, + + /// Queued in the global FIFO behind another owner's turn. + GlobalWaiter, + + /// A non-owner observation that runs detached, with no lease and no state entry. + Detached, +} + +/// Result of admitting a command. +/// Where the command landed. +/// Arrival ticket, or for detached observations. +/// How the turn was obtained, for telemetry and verbose output. +/// One-based position among live global waiters, when queued. +internal readonly record struct UiAdmissionResult( + UiAdmission Admission, + long? Ticket, + UiTurnAction TurnAction, + int? QueuePosition); + +/// +/// The cooperative-turn state machine (spec §10.1–§10.7). Deliberately free of file, clock-reading and +/// process side effects: it mutates an instance that the caller +/// read and will publish under state.lock. That keeps every scheduling rule — expiry, the +/// forward barrier, FIFO promotion, handoff — exhaustively testable without touching the desktop. +/// +internal sealed class InteractiveDesktopScheduler(IMonotonicClock clock) +{ + /// + /// Idle grace after the most recent non-cancelled owner command completes (spec §4). Four seconds + /// comfortably covers the gap between commands in one shell script while intentionally expiring + /// during model reasoning, so an adaptive agent must reacquire and replay rather than hold the + /// desktop hostage. + /// + internal const int IdleGraceMs = 4_000; + + /// Maximum live global waiters, applied after pruning dead entries (spec §8). + internal const int MaxGlobalWaiters = 64; + + /// + /// Applies section 10.1 normalization: prune dead participants, release a parent-derived + /// reservation whose shell died, expire an idle turn, promote the oldest live waiter, and + /// re-evaluate owner-local eligibility. + /// + /// when anything changed and the state must be published. + public bool Normalize(InteractiveDesktopState state, ICoordinationLivenessProbe probe) + { + var changed = PruneDeadParticipants(state, probe); + changed |= ReleaseDeadParentReservation(state, probe); + changed |= ExpireIdleTurn(state); + changed |= PromoteOldestWaiter(state, probe); + changed |= AbsorbSameOwnerWaiters(state); + changed |= ApplyOwnerLocalEligibility(state); + return changed; + } + + /// + /// Section 10.2: registers a current-owner observation so it pins and renews the turn, or reports + /// that a non-owner observation should run detached. + /// + public UiAdmissionResult BeginObserve( + InteractiveDesktopState state, + ICoordinationLivenessProbe probe, + UiOwnerIdentity owner, + UiParticipantIdentity participant) + { + Normalize(state, probe); + + if (state.Owner is null || !OwnerMatches(state.Owner, owner)) + { + // Another owner holds the turn, or nobody does. Observations never claim a free turn, so + // this runs concurrently without a lease or a state entry. + return new UiAdmissionResult(UiAdmission.Detached, null, UiTurnAction.Detached, null); + } + + state.OwnerCommands.Add(new OwnerCommandEntry + { + Ticket = null, + Pid = participant.ProcessId, + ProcessStartTicksUtc = participant.StartTicksUtc, + Operation = participant.Operation, + Mode = UiTurnMode.Observe, + Status = UiCommandStatus.Running, + }); + + return new UiAdmissionResult(UiAdmission.OwnerCommandRunning, null, UiTurnAction.Continuation, null); + } + + /// + /// Section 10.3: admits a or + /// command — starting a new turn, joining the owner's + /// existing turn, or queueing globally behind another owner. + /// + /// The global queue is full after pruning. + public UiAdmissionResult BeginParticipating( + InteractiveDesktopState state, + ICoordinationLivenessProbe probe, + UiOwnerIdentity owner, + UiParticipantIdentity participant, + UiTurnMode mode) + { + Normalize(state, probe); + + var liveWaiters = CountLiveWaiters(state, probe); + + if (state.Owner is null && liveWaiters == 0) + { + state.Owner = ToOwnerRecord(owner); + state.TurnId++; + AddOwnerCommand(state, participant, mode); + ApplyOwnerLocalEligibility(state); + return Describe(state, participant, UiTurnAction.New); + } + + if (state.Owner is not null && OwnerMatches(state.Owner, owner)) + { + AddOwnerCommand(state, participant, mode); + ApplyOwnerLocalEligibility(state); + return Describe(state, participant, UiTurnAction.Continuation); + } + + if (liveWaiters >= MaxGlobalWaiters) + { + // Refuse before publishing anything, so the caller can close its lease and exit without + // leaving an entry that other coordinators would have to prune. + throw new UiCoordinationException( + UiCoordinationErrorCodes.QueueCapacityExceeded, + $"{MaxGlobalWaiters} winapp ui commands are already waiting for the desktop.", + "Wait for the queued commands to finish, or stop some of the waiting winapp ui processes, then retry."); + } + + var ticket = state.AllocateTicket(); + state.Waiters.Add(new WaiterEntry + { + Ticket = ticket, + OwnerKey = owner.Key, + OwnerKind = owner.Kind, + Pid = participant.ProcessId, + ProcessStartTicksUtc = participant.StartTicksUtc, + DiagnosticParentPid = owner.ParentPid, + ParentStartTicksUtc = owner.ParentStartTicksUtc, + Operation = participant.Operation, + Mode = mode, + }); + + return new UiAdmissionResult( + UiAdmission.GlobalWaiter, + ticket, + UiTurnAction.Queued, + QueuePositionOf(state, probe, ticket)); + } + + /// + /// Section 6.5: converts this process's existing entry into a + /// command in place — same lease, new arrival ticket, + /// status — so a screenshot that discovers it must restore or + /// foreground a target never publishes an intermediate state with no entry for itself. + /// + /// when an entry was converted. + public bool EscalateObserveToExclusive( + InteractiveDesktopState state, + ICoordinationLivenessProbe probe, + UiParticipantIdentity participant) + { + Normalize(state, probe); + + var entry = FindOwnerCommand(state, participant); + if (entry is null || entry.Mode != UiTurnMode.Observe) + { + return false; + } + + // Priority starts at escalation time — the observational pass earns no head start. + entry.Ticket = state.AllocateTicket(); + entry.Mode = UiTurnMode.DesktopExclusive; + entry.Status = UiCommandStatus.Waiting; + ApplyOwnerLocalEligibility(state); + return true; + } + + /// + /// Section 10.6: removes this process's command and sets the idle deadline. A non-cancelled + /// completion renews the grace; an anonymous owner gets none and hands off immediately; + /// cancellation never renews. + /// + public void CompleteCommand( + InteractiveDesktopState state, + ICoordinationLivenessProbe probe, + UiParticipantIdentity participant, + UiOwnerKind ownerKind, + bool renewGrace) + { + RemoveParticipantEntries(state, participant); + + if (renewGrace && ownerKind != UiOwnerKind.Anonymous) + { + // Stored unconditionally but only consulted once OwnerCommands is empty, so a long-running + // sibling command is unaffected. + state.IdleExpiresTick64 = clock.NowTicks64 + IdleGraceMs; + } + else if (ownerKind == UiOwnerKind.Anonymous) + { + // A one-command owner has no shell that could issue a follow-up, so holding the desktop for + // another four seconds would only delay everyone else. + state.IdleExpiresTick64 = clock.NowTicks64; + } + + Normalize(state, probe); + } + + /// + /// Removes this process's command or waiter entry without touching the idle deadline. Used when a + /// queued command is cancelled before it ever ran (spec §11.1). + /// + public void RemoveParticipant( + InteractiveDesktopState state, + ICoordinationLivenessProbe probe, + UiParticipantIdentity participant) + { + RemoveParticipantEntries(state, participant); + Normalize(state, probe); + } + + /// + /// Whether currently holds the turn. Callers use this before opening a + /// participant lease, so a detached observation never creates one. + /// + public static bool IsCurrentOwner(InteractiveDesktopState state, UiOwnerIdentity owner) + => state.Owner is { } record && OwnerMatches(record, owner); + + /// + /// This process's current owner-command entry, or when it is still queued + /// globally or has been pruned. + /// + public static OwnerCommandEntry? FindOwnerCommand(InteractiveDesktopState state, UiParticipantIdentity participant) + => state.OwnerCommands.FirstOrDefault( + c => c.Pid == participant.ProcessId && c.ProcessStartTicksUtc == participant.StartTicksUtc); + + /// This process's global waiter entry, or once promoted or pruned. + public static WaiterEntry? FindWaiter(InteractiveDesktopState state, UiParticipantIdentity participant) + => state.Waiters.FirstOrDefault( + w => w.Pid == participant.ProcessId && w.ProcessStartTicksUtc == participant.StartTicksUtc); + + /// One-based position of a ticket among live global waiters, for cancellation diagnostics. + public static int? QueuePositionOf(InteractiveDesktopState state, ICoordinationLivenessProbe probe, long ticket) + { + var ahead = 0; + var found = false; + foreach (var waiter in state.Waiters.OrderBy(w => w.Ticket)) + { + if (waiter.Ticket == ticket) + { + found = true; + break; + } + + if (probe.IsParticipantLive(waiter.Pid, waiter.ProcessStartTicksUtc)) + { + ahead++; + } + } + + return found ? ahead + 1 : null; + } + + /// Live global waiter count, used for verbose output and the queue cap. + public static int CountLiveWaiters(InteractiveDesktopState state, ICoordinationLivenessProbe probe) + => state.Waiters.Count(w => probe.IsParticipantLive(w.Pid, w.ProcessStartTicksUtc)); + + private static void AddOwnerCommand(InteractiveDesktopState state, UiParticipantIdentity participant, UiTurnMode mode) + => state.OwnerCommands.Add(new OwnerCommandEntry + { + Ticket = state.AllocateTicket(), + Pid = participant.ProcessId, + ProcessStartTicksUtc = participant.StartTicksUtc, + Operation = participant.Operation, + Mode = mode, + Status = UiCommandStatus.Waiting, + }); + + private static UiAdmissionResult Describe( + InteractiveDesktopState state, UiParticipantIdentity participant, UiTurnAction turnAction) + { + var entry = FindOwnerCommand(state, participant); + var admission = entry?.Status == UiCommandStatus.Running + ? UiAdmission.OwnerCommandRunning + : UiAdmission.OwnerCommandWaiting; + return new UiAdmissionResult(admission, entry?.Ticket, turnAction, null); + } + + private static OwnerRecord ToOwnerRecord(UiOwnerIdentity owner) => new() + { + Kind = owner.Kind, + Key = owner.Key, + DiagnosticParentPid = owner.ParentPid, + ParentStartTicksUtc = owner.ParentStartTicksUtc, + }; + + private static bool OwnerMatches(OwnerRecord record, UiOwnerIdentity owner) + => string.Equals(record.Key, owner.Key, StringComparison.Ordinal); + + private static void RemoveParticipantEntries(InteractiveDesktopState state, UiParticipantIdentity participant) + { + state.OwnerCommands.RemoveAll( + c => c.Pid == participant.ProcessId && c.ProcessStartTicksUtc == participant.StartTicksUtc); + state.Waiters.RemoveAll( + w => w.Pid == participant.ProcessId && w.ProcessStartTicksUtc == participant.StartTicksUtc); + } + + private static bool PruneDeadParticipants(InteractiveDesktopState state, ICoordinationLivenessProbe probe) + { + var removed = state.OwnerCommands.RemoveAll( + c => !probe.IsParticipantLive(c.Pid, c.ProcessStartTicksUtc)); + removed += state.Waiters.RemoveAll( + w => !probe.IsParticipantLive(w.Pid, w.ProcessStartTicksUtc)); + return removed > 0; + } + + /// + /// A parent-derived owner exists only to group one shell's commands. Once that shell is gone no + /// further command can arrive, so the reservation is released immediately instead of idling for the + /// full grace. An unreadable parent keeps the normal deadline (spec §5.2). + /// + private bool ReleaseDeadParentReservation(InteractiveDesktopState state, ICoordinationLivenessProbe probe) + { + if (state.Owner is not { Kind: UiOwnerKind.Parent } owner + || state.OwnerCommands.Count > 0 + || owner.DiagnosticParentPid is not { } parentPid + || owner.ParentStartTicksUtc is not { } parentStart) + { + return false; + } + + if (probe.IsParentAlive(parentPid, parentStart) is not false) + { + return false; + } + + var now = clock.NowTicks64; + if (state.IdleExpiresTick64 <= now) + { + return false; + } + + state.IdleExpiresTick64 = now; + return true; + } + + private bool ExpireIdleTurn(InteractiveDesktopState state) + { + // Any live entry — waiting or running — counts as owner activity, so the turn is never taken + // from an owner that still has work queued behind its own barrier. + if (state.Owner is null + || state.OwnerCommands.Count > 0 + || state.IdleExpiresTick64 > clock.NowTicks64) + { + return false; + } + + state.Owner = null; + state.IdleExpiresTick64 = 0; + return true; + } + + private static bool PromoteOldestWaiter(InteractiveDesktopState state, ICoordinationLivenessProbe probe) + { + if (state.Owner is not null) + { + return false; + } + + // Strict FIFO by persisted ticket, never by file-lock acquisition order. A suspended live waiter + // therefore keeps the head of the queue until it resumes or is terminated. + var oldest = state.Waiters + .OrderBy(w => w.Ticket) + .FirstOrDefault(w => probe.IsParticipantLive(w.Pid, w.ProcessStartTicksUtc)); + + if (oldest is null) + { + return false; + } + + state.Owner = new OwnerRecord + { + Kind = oldest.OwnerKind, + Key = oldest.OwnerKey, + DiagnosticParentPid = oldest.DiagnosticParentPid, + ParentStartTicksUtc = oldest.ParentStartTicksUtc, + }; + state.TurnId++; + state.IdleExpiresTick64 = 0; + return true; + } + + /// + /// Moves the contiguous run of global waiters at the head of the queue that belong to the current + /// owner into that owner's command list, preserving each waiter's arrival ticket. + /// + /// + /// + /// Absorption stops at the first waiter belonging to a different owner. That is what keeps global + /// FIFO strict: with tickets B10, C11, B12 only B10 is absorbed, so C11 still + /// runs before B12. With B10, B11, C12 both B10 and B11 are absorbed, + /// because no other owner is waiting between them. + /// + /// + /// Absorbing the head prefix at all matches how section 10.3 admits a new same-owner command + /// directly into ownerCommands. Without it, an owner that queued two commands behind another + /// owner would run the first and then stall a full four seconds before its own second command, even + /// though the turn is already theirs and nobody else is ahead of it. + /// + /// + private static bool AbsorbSameOwnerWaiters(InteractiveDesktopState state) + { + if (state.Owner is not { } owner) + { + return false; + } + + var absorbed = new List(); + foreach (var waiter in state.Waiters.OrderBy(w => w.Ticket)) + { + // Dead waiters were already pruned, so the ordered list is the live queue. The first + // foreign owner ends the prefix — everything behind it keeps its place in global FIFO. + if (!string.Equals(waiter.OwnerKey, owner.Key, StringComparison.Ordinal)) + { + break; + } + + absorbed.Add(waiter); + } + + if (absorbed.Count == 0) + { + return false; + } + + foreach (var waiter in absorbed) + { + state.Waiters.Remove(waiter); + state.OwnerCommands.Add(new OwnerCommandEntry + { + Ticket = waiter.Ticket, + Pid = waiter.Pid, + ProcessStartTicksUtc = waiter.ProcessStartTicksUtc, + Operation = waiter.Operation, + Mode = waiter.Mode, + Status = UiCommandStatus.Waiting, + }); + } + + return true; + } + + /// + /// Section 10.4: a command with ticket T is a forward + /// barrier — every later or + /// command waits behind it whether it is waiting or + /// running, while earlier commands (including already-running TurnShared work such as a + /// recording) continue. + /// + private static bool ApplyOwnerLocalEligibility(InteractiveDesktopState state) + { + long? earliestBarrier = null; + foreach (var command in state.OwnerCommands) + { + if (command.Mode == UiTurnMode.DesktopExclusive && command.Ticket is { } ticket + && (earliestBarrier is null || ticket < earliestBarrier)) + { + earliestBarrier = ticket; + } + } + + var changed = false; + foreach (var command in state.OwnerCommands) + { + if (command.Status != UiCommandStatus.Waiting) + { + continue; + } + + // Observations never queue; they only pin the turn. + var eligible = command.Mode == UiTurnMode.Observe + || earliestBarrier is null + || (command.Ticket is { } ticket && ticket <= earliestBarrier); + + if (eligible) + { + command.Status = UiCommandStatus.Running; + changed = true; + } + } + + return changed; + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopState.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopState.cs new file mode 100644 index 000000000..4337d984b --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopState.cs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace WinApp.Cli.Services.InteractiveDesktop; + +/// +/// The persisted coordination state shared by every winapp.exe in one Windows session +/// (interactive-desktop-{session}.state.json, spec §8). Every read and write happens while +/// holding state.lock; publication is atomic, so a crash leaves either the old complete state +/// or the new complete state. +/// +/// +/// Unknown properties are round-tripped through so a newer binary's +/// additive fields survive a rewrite by an older compatible binary (spec §8, "Preserve unknown fields +/// when rewriting a known version"). A greater than +/// is never reset or downgraded. +/// +internal sealed class InteractiveDesktopState +{ + /// The schema version this binary reads and writes. + internal const int CurrentVersion = 1; + + /// Schema version. Values above are failed closed, never rewritten. + public int Version { get; set; } = CurrentVersion; + + /// Incremented every time the turn is claimed by an owner. Diagnostic and test observability. + public long TurnId { get; set; } + + /// Next globally monotonic arrival ticket. Tickets order the barrier and the global FIFO. + public long NextTicket { get; set; } = 1; + + /// The owner currently holding the turn, or when the desktop is free. + public OwnerRecord? Owner { get; set; } + + /// + /// Monotonic deadline after which an idle turn may be handed off. Only consulted when + /// is empty — a waiting or running owner command keeps the turn + /// regardless of this value. + /// + public long IdleExpiresTick64 { get; set; } + + /// Human-readable mirror of . Diagnostic only; never compared. + public string? DiagnosticIdleExpiresUtc { get; set; } + + /// Commands belonging to the current owner, in arrival order. + public List OwnerCommands { get; set; } = []; + + /// Other owners' commands waiting for the turn, oldest ticket first. + public List Waiters { get; set; } = []; + + /// Unknown properties from a newer writer, preserved verbatim on rewrite. + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } + + /// The state a coordinator starts from when no file exists or a corrupt file was quarantined. + public static InteractiveDesktopState CreateFresh() => new() + { + Version = CurrentVersion, + TurnId = 0, + NextTicket = 1, + Owner = null, + IdleExpiresTick64 = 0, + OwnerCommands = [], + Waiters = [], + }; + + /// Allocates the next arrival ticket and advances the counter. + public long AllocateTicket() + { + var ticket = NextTicket; + NextTicket = ticket + 1; + return ticket; + } +} + +/// The owner currently holding the turn. +internal sealed class OwnerRecord +{ + /// How this owner was resolved. Drives the idle-grace and parent-liveness rules. + public UiOwnerKind Kind { get; set; } + + /// + /// Lowercase hex SHA-256 of the domain-separated owner payload. Never the raw + /// WINAPP_UI_OWNER_ID, and never emitted in output, logs or telemetry. + /// + public string Key { get; set; } = ""; + + /// + /// Parent PID for owners. Used to release an idle reservation + /// immediately when the parent shell is confirmed dead, and shown by --verbose waiting + /// output. Local diagnostics only — never telemetry. + /// + public int? DiagnosticParentPid { get; set; } + + /// + /// The parent's Process.StartTime.ToUniversalTime().Ticks, so a recycled PID is not mistaken + /// for the original parent. + /// + public long? ParentStartTicksUtc { get; set; } + + /// Unknown properties from a newer writer, preserved verbatim on rewrite. + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } +} + +/// One command belonging to the current owner (spec §8). +internal sealed class OwnerCommandEntry +{ + /// + /// Globally monotonic arrival ticket. Present for every and + /// command; for + /// , which never serializes as a barrier. An observation that + /// escalates is assigned a ticket at escalation time. + /// + public long? Ticket { get; set; } + + /// Owning winapp.exe process id. + public int Pid { get; set; } + + /// + /// The owning process's Process.StartTime.ToUniversalTime().Ticks. Combined with + /// this identifies the participant lease and detects PID reuse. + /// + public long ProcessStartTicksUtc { get; set; } + + /// Command name for diagnostics, e.g. ui click. Never includes arguments. + public string Operation { get; set; } = ""; + + /// The command's coordination mode. + public UiTurnMode Mode { get; set; } + + /// Whether the command is blocked behind an earlier barrier or executing. + public UiCommandStatus Status { get; set; } + + /// Unknown properties from a newer writer, preserved verbatim on rewrite. + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } +} + +/// A command from another owner waiting for the current turn (spec §8). +internal sealed class WaiterEntry +{ + /// Globally monotonic arrival ticket. Defines strict FIFO order among waiters. + public long Ticket { get; set; } + + /// The waiting command's owner key (SHA-256 hex). Becomes the current owner on promotion. + public string OwnerKey { get; set; } = ""; + + /// Owning winapp.exe process id. + public int Pid { get; set; } + + /// The owning process's Process.StartTime.ToUniversalTime().Ticks. + public long ProcessStartTicksUtc { get; set; } + + /// Parent PID of the waiting process, for --verbose waiting output. Never telemetry. + public int? DiagnosticParentPid { get; set; } + + /// The parent's start ticks, carried so a promoted parent-derived owner keeps its liveness check. + public long? ParentStartTicksUtc { get; set; } + + /// The owner kind to install when this waiter is promoted. + public UiOwnerKind OwnerKind { get; set; } + + /// Command name for diagnostics, e.g. ui click. Never includes arguments. + public string Operation { get; set; } = ""; + + /// + /// The mode this waiter requested, stored so any process can promote it without inferring behavior + /// from the operation name (spec §8). + /// + public UiTurnMode Mode { get; set; } + + /// Unknown properties from a newer writer, preserved verbatim on rewrite. + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopStateStore.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopStateStore.cs new file mode 100644 index 000000000..745df3fbb --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopStateStore.cs @@ -0,0 +1,401 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Globalization; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace WinApp.Cli.Services.InteractiveDesktop; + +/// Outcome of reading coordination state under state.lock. +/// +/// The parsed state, or when is set. +/// +/// +/// A newer binary wrote a schema this build cannot interpret. Turn-participating and mutating commands +/// must fail closed; detached observations may continue without touching state (spec §12.4). +/// +/// +/// Unreadable state was safely quarantined and replaced with a fresh document, which callers surface as +/// a warning and report in telemetry. +/// +internal readonly record struct StateReadResult( + InteractiveDesktopState? State, + bool UnknownNewerVersion, + bool RecoveredFromCorruption); + +/// +/// Reads and publishes interactive-desktop-{session}.state.json under state.lock +/// (spec §7.1–§7.2). +/// +internal interface IInteractiveDesktopStateStore +{ + /// + /// Takes state.lock. Callers must hold it for every read and update and must release it + /// before waiting for active.lock or running UI code (spec §9). + /// + IDisposable AcquireStateLock(CancellationToken cancellationToken); + + /// Reads state. Must be called while holding state.lock. + StateReadResult Read(); + + /// Atomically publishes state. Must be called while holding state.lock. + void Publish(InteractiveDesktopState state); + + /// Whether active.lock is currently free, tested without waiting. + bool IsActiveLockFree(); +} + +/// +internal sealed class InteractiveDesktopStateStore( + IInteractiveDesktopPaths paths, + IParticipantRegistry participants, + IMonotonicClock clock, + ILogger logger) : IInteractiveDesktopStateStore +{ + /// + /// How long a publish keeps retrying transient sharing/access failures before giving up (spec §7.2). + /// Antivirus and search indexers routinely hold a just-written file open for a few milliseconds. + /// + private const int PublishRetryBudgetMs = 1_000; + + /// + /// Spin attempts before yielding the thread while waiting for state.lock. The critical + /// section is a small read plus an optional small write, so contention almost always clears within + /// the spin and never pays a timer-resolution sleep. + /// + private const int StateLockSpinAttempts = 64; + + public IDisposable AcquireStateLock(CancellationToken cancellationToken) + { + paths.EnsureDirectories(); + + var attempt = 0; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + return new FileStream( + paths.StateLockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.None); + } + catch (IOException) + { + // Held by another coordinator mid-transition. Spin briefly, then yield. + } + catch (UnauthorizedAccessException ex) + { + throw new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, + $"The UI coordination state lock '{paths.StateLockPath}' could not be opened: {ex.Message}", + "Check that the current user can write to the coordination directory."); + } + + attempt++; + if (attempt <= StateLockSpinAttempts) + { + Thread.SpinWait(20 * attempt); + } + else + { + Thread.Sleep(1); + } + } + } + + public StateReadResult Read() + { + string? raw; + var fileExists = File.Exists(paths.StatePath); + try + { + raw = fileExists ? File.ReadAllText(paths.StatePath) : null; + } + catch (IOException ex) + { + throw new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, + $"The UI coordination state could not be read: {ex.Message}", + "Retry the command. If it keeps failing, close other winapp ui processes and retry."); + } + + if (!fileExists) + { + // No file at all is the ordinary first command on this desktop: nothing to recover. + return new StateReadResult(InteractiveDesktopState.CreateFresh(), false, false); + } + + if (string.IsNullOrWhiteSpace(raw)) + { + // A file that exists but holds nothing is NOT a fresh start — atomic publication never + // produces one, so it means a torn write or external truncation while other processes may + // still be relying on the state it replaced. Take the guarded recovery path. + logger.LogDebug("UI coordination state file exists but is empty; treating as corrupt."); + return RecoverCorruptState(); + } + + InteractiveDesktopState? parsed; + try + { + parsed = JsonSerializer.Deserialize(raw, InteractiveDesktopJsonContext.Default.InteractiveDesktopState); + } + catch (JsonException ex) + { + logger.LogDebug("UI coordination state is not valid JSON: {Message}", ex.Message); + return RecoverCorruptState(); + } + + if (parsed is null) + { + return RecoverCorruptState(); + } + + if (parsed.Version > InteractiveDesktopState.CurrentVersion) + { + // Not corruption — a newer binary owns this file. Never reset or downgrade it; version 1 + // owner fields cannot be assumed to mean the same thing in a newer schema (spec §12.4). + return new StateReadResult(null, UnknownNewerVersion: true, RecoveredFromCorruption: false); + } + + if (!IsStructurallyValid(parsed)) + { + logger.LogDebug("UI coordination state failed version {Version} structural validation.", parsed.Version); + return RecoverCorruptState(); + } + + parsed.OwnerCommands ??= []; + parsed.Waiters ??= []; + return new StateReadResult(parsed, false, false); + } + + /// + /// Rejects documents that parse as JSON but describe scheduling state that cannot be reasoned about. + /// + /// + /// The checks here are the ones whose violation would corrupt scheduling rather than merely look + /// odd: duplicate tickets would make the forward barrier and FIFO order ambiguous, a + /// nextTicket at or below a live ticket would hand a second command the same barrier + /// position, owner commands without an owner would let a turn run unattributed, and an out-of-range + /// enum would silently behave as Observe or Waiting. + /// + private static bool IsStructurallyValid(InteractiveDesktopState state) + { + if (state.Version < 1 || state.NextTicket < 1 || state.TurnId < 0) + { + return false; + } + + if (state.Owner is { } owner + && (string.IsNullOrWhiteSpace(owner.Key) || !Enum.IsDefined(owner.Kind))) + { + return false; + } + + var commands = state.OwnerCommands ?? []; + var waiters = state.Waiters ?? []; + + // Commands can only belong to an owner. An orphaned set means the owner record was lost. + if (state.Owner is null && commands.Count > 0) + { + return false; + } + + // Tickets order the owner-local forward barrier AND the global queue, so they must be unique + // across both lists, not just within one. + var tickets = new HashSet(); + var highestTicket = 0L; + + foreach (var command in commands) + { + if (command.Pid <= 0 + || !Enum.IsDefined(command.Mode) + || !Enum.IsDefined(command.Status)) + { + return false; + } + + if (command.Mode == UiTurnMode.Observe) + { + // Observations never serialize as barriers, so they carry no ticket. + if (command.Ticket is not null) + { + return false; + } + + continue; + } + + if (command.Ticket is not { } commandTicket || commandTicket < 1 || !tickets.Add(commandTicket)) + { + return false; + } + + highestTicket = Math.Max(highestTicket, commandTicket); + } + + foreach (var waiter in waiters) + { + if (waiter.Pid <= 0 + || waiter.Ticket < 1 + || string.IsNullOrWhiteSpace(waiter.OwnerKey) + || !Enum.IsDefined(waiter.Mode) + || !Enum.IsDefined(waiter.OwnerKind) + || waiter.Mode == UiTurnMode.Observe + || !tickets.Add(waiter.Ticket)) + { + return false; + } + + highestTicket = Math.Max(highestTicket, waiter.Ticket); + } + + // The next allocation must not collide with a ticket already in use. + return state.NextTicket > highestTicket; + } + + /// + /// Quarantines unreadable state and starts fresh, but only when it is provably safe: no + /// active.lock holder and no live participant lease. Otherwise a live workflow would silently + /// lose its turn and two processes could drive the desktop at once (spec §12.3). + /// + private StateReadResult RecoverCorruptState() + { + if (!IsActiveLockFree() || participants.AnyLiveParticipant()) + { + throw new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, + "UI coordination state is unreadable and another winapp ui process is still active, so it cannot be safely rebuilt.", + "Wait for the other winapp ui commands to finish (or stop them) and retry."); + } + + var quarantinePath = System.IO.Path.Combine( + paths.LockDirectory, + $"state.corrupt-{clock.UtcNow.ToString("yyyyMMdd'T'HHmmss'.'fff'Z'", CultureInfo.InvariantCulture)}.json"); + + try + { + File.Move(paths.StatePath, quarantinePath, overwrite: true); + logger.LogWarning( + "{Symbol} UI coordination state was unreadable and has been rebuilt. The previous file was kept at {Path}.", + Helpers.UiSymbols.Warning, + quarantinePath); + } + catch (IOException ex) + { + // Quarantining is best effort: the important part is that no live participant exists, so + // overwriting with a fresh document below is already safe. + logger.LogDebug("Corrupt UI coordination state could not be quarantined: {Message}", ex.Message); + } + + return new StateReadResult(InteractiveDesktopState.CreateFresh(), false, RecoveredFromCorruption: true); + } + + public void Publish(InteractiveDesktopState state) + { + state.DiagnosticIdleExpiresUtc = state.IdleExpiresTick64 > 0 + ? clock.UtcNow.AddMilliseconds(state.IdleExpiresTick64 - clock.NowTicks64) + .ToString("O", CultureInfo.InvariantCulture) + : null; + + var payload = JsonSerializer.SerializeToUtf8Bytes( + state, InteractiveDesktopJsonContext.Default.InteractiveDesktopState); + + var tempPath = paths.StatePath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + var stopwatch = Stopwatch.StartNew(); + Exception? lastFailure = null; + + while (stopwatch.ElapsedMilliseconds <= PublishRetryBudgetMs) + { + try + { + // Same directory so the replace below is a rename on one volume, and flushed to disk so a + // crash between write and rename cannot publish a truncated document. + using (var stream = new FileStream( + tempPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 4096, FileOptions.WriteThrough)) + { + stream.Write(payload); + stream.Flush(flushToDisk: true); + } + + File.Move(tempPath, paths.StatePath, overwrite: true); + SweepStaleTempFiles(); + return; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + lastFailure = ex; + Thread.Sleep(10); + } + } + + TryDeleteTemp(tempPath); + throw new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, + $"UI coordination state could not be published: {lastFailure?.Message ?? "unknown error"}", + "Retry the command. If it keeps failing, check that the coordination directory is on a local writable drive and not being scanned by another tool."); + } + + public bool IsActiveLockFree() + { + try + { + using var probe = new FileStream( + paths.ActiveLockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.None); + return true; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + // Unable to prove it is free, so report "held" — the caller only ever uses a free answer to + // authorize a destructive rebuild. + return false; + } + } + + /// + /// Removes publish temp files orphaned by a crash between write and rename. Best effort and only + /// under state.lock, so a live publisher's temp file is never removed underneath it. + /// + private void SweepStaleTempFiles() + { + try + { + var pattern = System.IO.Path.GetFileName(paths.StatePath) + ".*.tmp"; + foreach (var stale in Directory.EnumerateFiles(paths.LockDirectory, pattern)) + { + TryDeleteTemp(stale); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or DirectoryNotFoundException) + { + logger.LogDebug("Stale UI coordination temp files could not be swept: {Message}", ex.Message); + } + } + + private static void TryDeleteTemp(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // A leftover .tmp is harmless: it is uniquely named and swept on a later publish. + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/NullDesktopSection.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/NullDesktopSection.cs new file mode 100644 index 000000000..3be0a9a8e --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/NullDesktopSection.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Services.InteractiveDesktop; + +/// +/// An that grants the section immediately without taking +/// active.lock. +/// +/// +/// Only for callers that provably have no turn to coordinate under: unit tests exercising capture +/// mechanics directly against , and gated real-UIA tests that drive +/// the service outside the command pipeline. Command handlers always pass their real +/// — using this there would silently opt a command out of coordination. +/// +internal sealed class NullDesktopSection : IDesktopSection +{ + /// The shared instance. Stateless, so one is enough. + public static NullDesktopSection Instance { get; } = new(); + + private NullDesktopSection() + { + } + + public Task EnterAsync(CancellationToken cancellationToken) + => Task.FromResult(NoOpScope.Instance); + + private sealed class NoOpScope : IAsyncDisposable + { + public static NoOpScope Instance { get; } = new(); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ParticipantRegistry.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ParticipantRegistry.cs new file mode 100644 index 000000000..4d4a62692 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ParticipantRegistry.cs @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; + +namespace WinApp.Cli.Services.InteractiveDesktop; + +/// +/// A process-held proof of liveness for one queued or active participant (spec §7.4). Opened +/// FileShare.None + FileOptions.DeleteOnClose and held for the command's entire +/// participation. +/// +/// +/// This replaces heartbeat writes entirely. Windows closes the handle and deletes the file on normal +/// exit and on forced termination, so liveness needs no timestamps and no periodic I/O — and +/// a merely suspended process still holds its lease, so it is correctly treated as alive and +/// keeps its place in the queue. +/// +internal interface IParticipantLease : IDisposable +{ + /// Full path of the lease file, for diagnostics. + string Path { get; } +} + +/// +/// Opens participant leases and answers liveness questions about recorded participants, which is the +/// only mechanism by which coordination state entries are pruned. +/// +internal interface IParticipantRegistry +{ + /// + /// Opens this process's lease. Must be called while holding state.lock and before publishing + /// any participation, so no published entry ever lacks liveness proof (spec §9). + /// + IParticipantLease OpenLease(int processId, long startTicksUtc); + + /// + /// Whether the given participant is still alive. A lease that can be opened proves the holder is + /// gone (and the stale file is removed); a lease that cannot be opened proves it is alive. + /// + bool IsParticipantLive(int processId, long startTicksUtc); + + /// + /// Whether any lease in the participants directory is currently held. Used by corruption recovery, + /// which must never reset state that a live participant is relying on (spec §12.3). + /// + bool AnyLiveParticipant(); +} + +/// +internal sealed class ParticipantRegistry( + IInteractiveDesktopPaths paths, + IProcessInspector processInspector, + ILogger logger) : IParticipantRegistry +{ + public IParticipantLease OpenLease(int processId, long startTicksUtc) + { + paths.EnsureDirectories(); + var path = paths.LeasePath(processId, startTicksUtc); + + try + { + // FileMode.Create rather than CreateNew: a same-identity file can only be a stale leftover + // from a power loss (a live holder's file is deleted when its handle closes), and a leftover + // is openable precisely because nobody holds it. + var stream = new FileStream( + path, + FileMode.Create, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.DeleteOnClose); + return new ParticipantLease(stream, path); + } + catch (IOException ex) + { + throw new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, + $"The UI coordination participant lease '{path}' could not be opened: {ex.Message}", + "Retry the command. If it keeps failing, check that the coordination directory is on a local writable drive."); + } + catch (UnauthorizedAccessException ex) + { + throw new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, + $"The UI coordination participant lease '{path}' could not be opened: {ex.Message}", + "Check that the current user can write to the coordination directory."); + } + } + + public bool IsParticipantLive(int processId, long startTicksUtc) + { + var path = paths.LeasePath(processId, startTicksUtc); + if (!File.Exists(path)) + { + // The holder's handle closed (normal exit or kill), so Windows already removed the file. + return false; + } + + if (IsLeaseFileHeld(path)) + { + return true; + } + + // The lease is openable, so the holder is gone. Cross-check the PID/start pair as well: a + // recycled PID must not resurrect a dead participant, and a lease left by a power loss belongs + // to a process that no longer exists. + var alive = processInspector.IsProcessAlive(processId, startTicksUtc); + if (alive is true) + { + // The process exists with a matching start time but is not holding its lease — it either has + // not opened it yet or has already torn it down. Neither is a participant we may prune, + // because the registration protocol opens the lease before publishing and removes the entry + // before closing it. Treat as live and let the owning process finish its own teardown. + return true; + } + + return false; + } + + public bool AnyLiveParticipant() + { + if (!Directory.Exists(paths.ParticipantsDirectory)) + { + return false; + } + + IEnumerable leaseFiles; + try + { + leaseFiles = Directory.EnumerateFiles(paths.ParticipantsDirectory, paths.LeaseSearchPattern); + } + catch (DirectoryNotFoundException) + { + return false; + } + + foreach (var leaseFile in leaseFiles) + { + if (IsLeaseFileHeld(leaseFile)) + { + return true; + } + } + + return false; + } + + /// + /// Probes one lease file. A sharing violation means a live holder; a successful open means the file + /// is an orphan, which this method removes via DeleteOnClose so the participants directory + /// does not accumulate leftovers after a power loss. + /// + private bool IsLeaseFileHeld(string leaseFilePath) + { + try + { + using var probe = new FileStream( + leaseFilePath, + FileMode.Open, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.DeleteOnClose); + return false; + } + catch (IOException) + { + // Sharing violation: another process holds this lease FileShare.None. That is the liveness + // proof — including for a suspended process, which still owns its handle. + return true; + } + catch (UnauthorizedAccessException ex) + { + // A lease we cannot probe cannot be proven dead, and pruning a live participant would strand + // its ownership. Fail safe by treating it as held. + logger.LogDebug("Participant lease '{Path}' could not be probed: {Message}", leaseFilePath, ex.Message); + return true; + } + } + + private sealed class ParticipantLease(FileStream stream, string path) : IParticipantLease + { + public string Path { get; } = path; + + public void Dispose() => stream.Dispose(); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ProcessInspector.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ProcessInspector.cs new file mode 100644 index 000000000..40e869c15 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ProcessInspector.cs @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Globalization; + +namespace WinApp.Cli.Services.InteractiveDesktop; + +/// +/// Process facts the coordinator needs: the immediate parent of this process (for parent-derived owner +/// identity, spec §5.2) and whether a recorded participant is still alive (for pruning, spec §10.1). +/// +/// +/// Extracted behind an interface because every fact here is unavailable or unstable in a unit test: +/// the parent of the test host is the test runner, and liveness answers change under the test. The +/// production implementation is . +/// +internal interface IProcessInspector +{ + /// This process's id. + int CurrentProcessId { get; } + + /// + /// This process's Process.StartTime.ToUniversalTime().Ticks, which pairs with the PID to form + /// a reuse-proof participant identity. + /// + long CurrentProcessStartTicksUtc { get; } + + /// The Windows session this process runs in. Coordination is scoped per session. + int CurrentSessionId { get; } + + /// + /// The immediate parent process id, or when it cannot be read. Never walks + /// farther up the tree — a higher ancestor may be shared by unrelated workflows (spec §5). + /// + int? TryGetParentProcessId(); + + /// + /// A process's start ticks, or when the process is gone or its start time + /// cannot be read (for example a protected or higher-integrity process). + /// + long? TryGetProcessStartTicksUtc(int processId); + + /// + /// Whether is running and started at + /// . Returns when liveness cannot be + /// determined, which callers must treat as "assume alive" rather than as death. + /// + bool? IsProcessAlive(int processId, long startTicksUtc); +} + +/// +/// Production . Parent discovery uses a Toolhelp process snapshot, +/// which needs no special privileges and, unlike NtQueryInformationProcess, is a documented +/// stable API. +/// +internal sealed class ProcessInspector : IProcessInspector +{ + private readonly int _currentProcessId; + private readonly long _currentStartTicks; + private readonly int _sessionId; + + public ProcessInspector() + { + using var current = Process.GetCurrentProcess(); + _currentProcessId = current.Id; + _currentStartTicks = current.StartTime.ToUniversalTime().Ticks; + _sessionId = current.SessionId; + } + + public int CurrentProcessId => _currentProcessId; + + public long CurrentProcessStartTicksUtc => _currentStartTicks; + + public int CurrentSessionId => _sessionId; + + /// + /// Coverage ceiling (issue #630): the Toolhelp snapshot walk is a native enumeration of live + /// processes. Tests drive callers through instead. + /// + public int? TryGetParentProcessId() + { + try + { + return TryGetParentProcessIdCore(_currentProcessId); + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException) + { + // Snapshot creation can fail under low resources or a restricted token. Spec §5.3: fall back + // to an anonymous one-command owner rather than guessing at an ancestor. + return null; + } + } + + private static unsafe int? TryGetParentProcessIdCore(int processId) + { + using var snapshot = Windows.Win32.PInvoke.CreateToolhelp32Snapshot_SafeHandle( + Windows.Win32.System.Diagnostics.ToolHelp.CREATE_TOOLHELP_SNAPSHOT_FLAGS.TH32CS_SNAPPROCESS, 0); + if (snapshot.IsInvalid) + { + return null; + } + + var entry = new Windows.Win32.System.Diagnostics.ToolHelp.PROCESSENTRY32 + { + dwSize = (uint)sizeof(Windows.Win32.System.Diagnostics.ToolHelp.PROCESSENTRY32), + }; + + if (!Windows.Win32.PInvoke.Process32First(snapshot, ref entry)) + { + return null; + } + + do + { + if (entry.th32ProcessID == (uint)processId) + { + var parent = (int)entry.th32ParentProcessID; + return parent > 0 ? parent : null; + } + } + while (Windows.Win32.PInvoke.Process32Next(snapshot, ref entry)); + + return null; + } + + public long? TryGetProcessStartTicksUtc(int processId) + { + try + { + using var process = Process.GetProcessById(processId); + return process.StartTime.ToUniversalTime().Ticks; + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception) + { + // ArgumentException: no such process. InvalidOperationException: exited between calls. + // Win32Exception: start time unreadable (protected / higher integrity). All mean "unknown". + return null; + } + } + + public bool? IsProcessAlive(int processId, long startTicksUtc) + { + if (processId <= 0) + { + return false; + } + + try + { + using var process = Process.GetProcessById(processId); + // A matching start time proves this is the same process, not a recycled PID. + return process.StartTime.ToUniversalTime().Ticks == startTicksUtc; + } + catch (ArgumentException) + { + // No process with that id is running — definitively dead. + return false; + } + catch (InvalidOperationException) + { + // The process exited between lookup and property read — definitively dead. + return false; + } + catch (System.ComponentModel.Win32Exception) + { + // The process exists but its start time is unreadable. Spec §5.2/§10.1: an unreadable + // liveness answer must not be treated as death, or a live owner could be evicted. + return null; + } + } + + /// + /// Formats process start ticks the way lease filenames and every start-time comparison require: + /// invariant-culture signed 64-bit decimal with no sign for positives, no grouping and no + /// locale-specific digits (spec §8). + /// + public static string FormatStartTicks(long startTicksUtc) + => startTicksUtc.ToString(CultureInfo.InvariantCulture); +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationOutputMode.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationOutputMode.cs new file mode 100644 index 000000000..d4acde8d2 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationOutputMode.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Parsing; +using WinApp.Cli.Commands; + +namespace WinApp.Cli.Services.InteractiveDesktop; + +/// +/// The output mode a coordinated command is running in, resolved once from the parse result. +/// +/// +/// --json is in effect: stay silent until the final result or a structured error, so a consumer +/// parsing stdout never has to skip progress lines. +/// +/// +/// --verbose is in effect: include local diagnostics (parent PID, the active winapp PID, +/// its operation, queue depth, commands ahead, elapsed wait). +/// +/// --quiet is in effect: emit nothing while waiting. +internal readonly record struct UiCoordinationOutputMode(bool Json, bool Verbose, bool Quiet) +{ + /// + /// Reads the global output options from the selected command, guarding against commands that do not + /// declare them. Options.Contains matters because ParseResult.GetValue for an option + /// the selected command does not own is not meaningful. + /// + public static UiCoordinationOutputMode FromParseResult(ParseResult parseResult) => new( + Json: TryGetFlag(parseResult, WinAppRootCommand.JsonOption), + Verbose: TryGetFlag(parseResult, WinAppRootCommand.VerboseOption), + Quiet: TryGetFlag(parseResult, WinAppRootCommand.QuietOption)); + + private static bool TryGetFlag(ParseResult parseResult, Option option) + { + if (!parseResult.CommandResult.Command.Options.Contains(option)) + { + return false; + } + + try + { + return parseResult.GetValue(option); + } + catch (InvalidOperationException) + { + // A value that cannot be read must never break coordination; fall back to "not set". + return false; + } + } + + /// Whether any human-facing waiting status may be written. + public bool AllowsWaitingStatus => !Json && !Quiet; +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTelemetryScope.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTelemetryScope.cs new file mode 100644 index 000000000..c050ab4f8 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTelemetryScope.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Runtime.CompilerServices; + +namespace WinApp.Cli.Services.InteractiveDesktop; + +/// +/// Carries the coordination summary for the current command out to command-completion telemetry, +/// without threading a parameter through every handler. +/// +/// +/// +/// Mirrors the existing TelemetryCorrelation pattern, but stores a mutable box rather than the +/// value itself. That indirection is required: an assignment made +/// inside an async method is not visible to its caller once the method returns, and the +/// coordinator sets the summary deep inside the invocation while Program reads it afterwards. +/// Publishing the box up front and mutating its contents keeps the value visible to the reader. +/// +/// +/// rather than a static field because the test host runs many commands in +/// one process, and a leaked summary would mislabel an unrelated command. +/// +/// +internal static class UiCoordinationTelemetryScope +{ + private static readonly AsyncLocal?> s_current = new(); + + /// + /// Opens a scope for one command invocation. Must be called before the command runs, from the same + /// async flow that will later read . + /// + public static void Begin() => s_current.Value = new StrongBox(null); + + /// The summary for the command in the current scope, or . + public static UiCoordinationSummary? Current => s_current.Value?.Value; + + /// + /// Records the summary for the current scope. A no-op when no scope was opened — for example a unit + /// test invoking a handler directly — so coordination never depends on telemetry being wired up. + /// + public static void Set(UiCoordinationSummary summary) + { + if (s_current.Value is { } box) + { + box.Value = summary; + } + } + + /// Closes the scope. Called by tests and between invocations. + public static void Clear() => s_current.Value = null; +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs new file mode 100644 index 000000000..441549421 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Services.InteractiveDesktop; + +/// +/// Stable error codes for coordination failures. These appear in the --json error envelope and +/// are mirrored in UiJsonError so UI commands emit one consistent shape. +/// +internal static class UiCoordinationErrorCodes +{ + /// WINAPP_UI_OWNER_ID was set but empty/whitespace or longer than 256 UTF-16 units. + public const string InvalidOwnerId = "invalid_ui_owner_id"; + + /// + /// Coordination state could not be read, published, or safely recovered — for example an unknown + /// newer schema version, or corrupt state while a live participant may exist. Turn-participating + /// and mutating commands fail closed rather than acting uncoordinated. + /// + public const string Unavailable = "desktop_coordination_unavailable"; + + /// 64 live global waiters already queued after pruning dead entries (spec §8). + public const string QueueCapacityExceeded = "queue_capacity_exceeded"; + + /// The command was cancelled while queued and never reached execution. + public const string Cancelled = "cancelled"; +} + +/// +/// Raised when coordination cannot proceed safely. Carries the stable error code so command handlers +/// emit the right envelope without string matching. +/// +internal sealed class UiCoordinationException(string code, string message, string? recoveryHint = null) + : Exception(message) +{ + /// One of . + public string Code { get; } = code; + + /// Optional actionable next step surfaced alongside the error. + public string? RecoveryHint { get; } = recoveryHint; +} + +/// +/// What happened to a command's turn, attached to command-completion telemetry in privacy-minimized +/// bucketed form (spec §16) and used by --verbose waiting output. +/// +internal enum UiTurnAction +{ + /// The command started a new turn because the desktop was free. + New, + + /// The command joined a turn its owner already held. + Continuation, + + /// The command waited in the global queue before acquiring the turn. + Queued, + + /// The command acquired the turn after another owner's idle grace expired. + HandoffAfterIdle, + + /// A non-owner observation that ran concurrently without claiming the turn. + Detached, +} + +/// How a coordinated command finished, for telemetry (spec §16). +internal enum UiCoordinationOutcome +{ + /// The command reached execution and returned, including with a non-zero exit code. + Completed, + + /// The command was cancelled while queued and never executed. + Cancelled, + + /// Coordination failed closed (unavailable, queue capacity, invalid owner id). + CoordinationFailure, + + /// Corrupt state was safely quarantined and rebuilt before the command proceeded. + CorruptionRecovery, +} + +/// +/// Privacy-minimized summary of one command's coordination, attached to existing command-completion +/// telemetry. Contains no owner ids or hashes, no PIDs, no process/app/window/selector text, no queue +/// entries, no command arguments and no state-file contents (spec §16). +/// +internal sealed record UiCoordinationSummary( + UiOwnerKind IdentitySource, + UiTurnMode Mode, + UiTurnAction TurnAction, + UiCoordinationOutcome Outcome, + long WaitedMs, + int QueueDepth, + long TurnAgeMs) +{ + /// + /// Coarse wait bucket. Exact durations could correlate a user's workflow timing across events, so + /// only the bucket is reported. + /// + public string WaitBucket => Bucket(WaitedMs, [0, 100, 1_000, 5_000, 30_000, 120_000]); + + /// Coarse queue-depth bucket. + public string QueueDepthBucket => Bucket(QueueDepth, [0, 1, 2, 4, 8, 16]); + + /// Coarse turn-age bucket. + public string TurnAgeBucket => Bucket(TurnAgeMs, [0, 1_000, 5_000, 30_000, 120_000, 600_000]); + + private static string Bucket(long value, long[] edges) + { + for (var i = edges.Length - 1; i >= 0; i--) + { + if (value >= edges[i]) + { + return i == edges.Length - 1 + ? $"{edges[i]}+" + : $"{edges[i]}-{edges[i + 1] - 1}"; + } + } + + return "0"; + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationWaitReporter.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationWaitReporter.cs new file mode 100644 index 000000000..4993c5b21 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationWaitReporter.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Globalization; +using Spectre.Console; + +namespace WinApp.Cli.Services.InteractiveDesktop; + +/// +/// A snapshot of why this command is waiting, read under state.lock and rendered outside it. +/// +/// Live global waiters, including this command when it is queued globally. +/// How many commands must finish before this one becomes eligible. +/// PID of a winapp process currently holding the turn, when any. +/// That process's command name, e.g. ui record. +internal readonly record struct UiWaitDiagnostics( + int QueueDepth, + int CommandsAhead, + int? ActiveProcessId, + string? ActiveOperation); + +/// +/// Renders the "still waiting for the desktop" status (spec §14). Nothing is written for the first +/// second, because the overwhelmingly common case — a tight script burst where the previous command +/// has just finished — clears well inside that window and a flash of status would be noise. +/// +internal sealed class UiCoordinationWaitReporter( + IAnsiConsole console, + UiCoordinationOutputMode outputMode, + string operation, + int? parentProcessId) +{ + /// Delay before the first status line. + internal const int FirstReportAfterMs = 1_000; + + /// Minimum gap between subsequent status lines, so a long wait does not spam the console. + internal const int RepeatIntervalMs = 5_000; + + private long _lastReportedAtMs = -1; + + /// + /// Writes a waiting status when one is due. Silent under --json and --quiet, and + /// silent for the first milliseconds in every mode. + /// + public void ReportIfDue(long elapsedMs, UiWaitDiagnostics diagnostics) + { + if (!outputMode.AllowsWaitingStatus || elapsedMs < FirstReportAfterMs) + { + return; + } + + if (_lastReportedAtMs >= 0 && elapsedMs - _lastReportedAtMs < RepeatIntervalMs) + { + return; + } + + _lastReportedAtMs = elapsedMs; + console.MarkupLine(outputMode.Verbose + ? BuildVerboseLine(elapsedMs, diagnostics) + : "[grey]Waiting for the desktop — another winapp ui workflow is using it. Press Ctrl+C to cancel.[/]"); + } + + private string BuildVerboseLine(long elapsedMs, UiWaitDiagnostics diagnostics) + { + var seconds = (elapsedMs / 1000.0).ToString("F1", CultureInfo.InvariantCulture); + var active = diagnostics.ActiveProcessId is { } activePid + ? $"held by winapp PID {activePid}" + + (string.IsNullOrEmpty(diagnostics.ActiveOperation) + ? "" + : $" running {Markup.Escape(diagnostics.ActiveOperation)}") + : "no active winapp command"; + + var parent = parentProcessId is { } pid + ? $"parent PID {pid.ToString(CultureInfo.InvariantCulture)}" + : "parent PID unknown"; + + return "[grey]Waiting for the desktop for " + seconds + "s — " + + Markup.Escape(operation) + "; " + active + "; " + + $"queue depth {diagnostics.QueueDepth}, {diagnostics.CommandsAhead} ahead; " + + parent + ". Press Ctrl+C to cancel.[/]"; + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiOwnerResolver.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiOwnerResolver.cs new file mode 100644 index 000000000..7f80e7b81 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiOwnerResolver.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace WinApp.Cli.Services.InteractiveDesktop; + +/// +/// One resolved logical UI workflow owner. Only is ever persisted; the raw +/// WINAPP_UI_OWNER_ID never leaves this process. +/// +/// How the owner was resolved. +/// Lowercase hex SHA-256 of the domain-separated owner payload. +/// Immediate parent PID, when known. Local diagnostics only. +/// The parent's start ticks, when known. +internal sealed record UiOwnerIdentity( + UiOwnerKind Kind, + string Key, + int? ParentPid, + long? ParentStartTicksUtc); + +/// Resolves the logical workflow owner for the current command (spec §5). +internal interface IUiOwnerResolver +{ + /// + /// Resolves the owner, preferring WINAPP_UI_OWNER_ID, then the immediate parent process, + /// then a unique anonymous one-command owner. + /// + /// + /// WINAPP_UI_OWNER_ID is present but invalid. Thrown before any UI side effect. + /// + UiOwnerIdentity Resolve(); +} + +/// +internal sealed class UiOwnerResolver(IProcessInspector processInspector) : IUiOwnerResolver +{ + /// Environment variable naming one logical UI workflow — not an agent and not an app. + internal const string OwnerIdVariable = "WINAPP_UI_OWNER_ID"; + + /// + /// Maximum accepted length in UTF-16 code units (string.Length). The value is an opaque + /// grouping token, so a bound keeps a pathological value from bloating every state write. + /// + internal const int MaxOwnerIdLength = 256; + + private const string ExplicitDomain = "winapp-ui-owner-v1\0"; + private const string ParentDomain = "winapp-ui-parent-v1\0"; + private const string AnonymousDomain = "winapp-ui-anonymous-v1\0"; + + public UiOwnerIdentity Resolve() + { + var raw = Environment.GetEnvironmentVariable(OwnerIdVariable); + if (raw is not null) + { + return ResolveExplicit(raw); + } + + var parentPid = processInspector.TryGetParentProcessId(); + if (parentPid is { } pid) + { + var parentStart = processInspector.TryGetProcessStartTicksUtc(pid); + if (parentStart is { } startTicks) + { + return new UiOwnerIdentity(UiOwnerKind.Parent, ComputeParentKey(pid, startTicks), pid, startTicks); + } + } + + // Spec §5.3: parent inspection failed, so this command gets a unique owner of its own. It queues + // normally but receives no idle grace, because there is no shell to issue a follow-up command. + return new UiOwnerIdentity(UiOwnerKind.Anonymous, ComputeAnonymousKey(), null, null); + } + + private static UiOwnerIdentity ResolveExplicit(string raw) + { + // An explicitly-set-but-blank value is a scripting mistake (an unset variable expanded to ""), + // not a request for an empty owner. Failing here is far cheaper than silently merging every + // workflow that made the same mistake into one shared owner. + if (string.IsNullOrWhiteSpace(raw)) + { + throw new UiCoordinationException( + UiCoordinationErrorCodes.InvalidOwnerId, + $"{OwnerIdVariable} is set but empty or whitespace.", + $"Set {OwnerIdVariable} to a non-empty value that identifies one logical UI workflow, for example a GUID, or unset it to use the parent process identity."); + } + + if (raw.Length > MaxOwnerIdLength) + { + throw new UiCoordinationException( + UiCoordinationErrorCodes.InvalidOwnerId, + $"{OwnerIdVariable} is longer than {MaxOwnerIdLength} characters.", + $"Set {OwnerIdVariable} to a short opaque value such as a GUID."); + } + + return new UiOwnerIdentity(UiOwnerKind.Explicit, ComputeExplicitKey(raw), null, null); + } + + /// + /// SHA-256("winapp-ui-owner-v1\0" + raw value). Hashing means a workflow id that happens to + /// contain a path, ticket number or user name never reaches disk, and the domain prefix keeps an + /// explicit id from ever colliding with a parent-derived one. + /// + internal static string ComputeExplicitKey(string rawOwnerId) + => Hash(Encoding.UTF8.GetBytes(ExplicitDomain + rawOwnerId)); + + /// + /// SHA-256("winapp-ui-parent-v1\0" + pid + "\0" + parentStartUtcTicks) (spec §5.2). Including + /// the start time means a recycled PID does not inherit the previous shell's turn. + /// + internal static string ComputeParentKey(int parentPid, long parentStartTicksUtc) + => Hash(Encoding.UTF8.GetBytes( + ParentDomain + + parentPid.ToString(CultureInfo.InvariantCulture) + + "\0" + + parentStartTicksUtc.ToString(CultureInfo.InvariantCulture))); + + private static string ComputeAnonymousKey() + => Hash(Encoding.UTF8.GetBytes(AnonymousDomain + Guid.NewGuid().ToString("N"))); + + private static string Hash(byte[] payload) + => Convert.ToHexStringLower(SHA256.HashData(payload)); +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiTurnMode.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiTurnMode.cs new file mode 100644 index 000000000..96824dc64 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiTurnMode.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Services.InteractiveDesktop; + +/// +/// How a winapp ui command participates in cooperative desktop turns (issue #764). +/// +/// +/// Windows exposes a single foreground window, keyboard focus, cursor and SendInput stream, so +/// concurrent winapp.exe processes can dismiss each other's transient UI even when they target +/// different apps. Every UI command declares one of these modes; the coordinator uses it to decide +/// whether the command claims the workflow turn, waits behind a forward barrier, or runs concurrently. +/// +[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] +internal enum UiTurnMode +{ + /// + /// Does not claim a free turn. A non-owner runs immediately and detached (no lease, no queue entry); + /// the current owner registers so the observation pins and renews that owner's turn while it reads + /// transient UI. + /// + Observe, + + /// + /// Claims or waits for the workflow turn. Several same-owner TurnShared commands may overlap + /// unless an earlier forward barrier is waiting or running. Used by + /// ui record, which pins the owner for the whole capture while same-owner input continues. + /// + TurnShared, + + /// + /// Claims or waits for the workflow turn, creates a forward barrier in the owner's command stream, + /// and takes active.lock for its desktop-sensitive section (foreground, focus, cursor, + /// SendInput, synthetic pointer input, restore, live-screen capture). + /// + DesktopExclusive, +} + +/// How the logical workflow owner behind a command was resolved (spec §5). +[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] +internal enum UiOwnerKind +{ + /// Resolved from WINAPP_UI_OWNER_ID. Groups cooperating processes explicitly. + [System.Text.Json.Serialization.JsonStringEnumMemberName("explicit")] + Explicit, + + /// + /// Derived from the immediate parent PID plus that parent's start time, which groups the commands of + /// one long-lived shell or script. Never walks farther up the tree — a higher ancestor may be shared + /// by unrelated workflows. + /// + [System.Text.Json.Serialization.JsonStringEnumMemberName("parent")] + Parent, + + /// + /// A unique one-command owner used when parent inspection fails. It queues normally but receives no + /// post-command idle grace. + /// + [System.Text.Json.Serialization.JsonStringEnumMemberName("anonymous")] + Anonymous, +} + +/// Whether a registered owner command is waiting behind the barrier or currently executing. +[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] +internal enum UiCommandStatus +{ + /// Admitted with an arrival ticket but blocked by an earlier command. + [System.Text.Json.Serialization.JsonStringEnumMemberName("waiting")] + Waiting, + + /// Eligible and executing. Counts as owner activity, so it blocks handoff to another owner. + [System.Text.Json.Serialization.JsonStringEnumMemberName("running")] + Running, +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Record.cs b/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Record.cs index 4e20a3745..2c364effd 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Record.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Record.cs @@ -9,6 +9,8 @@ using Windows.Win32.UI.Accessibility; using WinApp.Cli.Models; +using WinApp.Cli.Services.InteractiveDesktop; + namespace WinApp.Cli.Services; /// @@ -24,7 +26,7 @@ internal sealed partial class UiAutomationService /// fault arms, and cancellation timing races that require mutating real desktop windows or native /// WGC failures and are not safe to trigger on the shared coverage host. /// - public async Task RecordAsync(UiSessionInfo session, string? elementId, RecordOptions options, CancellationToken ct, Action? onRecordingStarted = null) + public async Task RecordAsync(UiSessionInfo session, string? elementId, RecordOptions options, IDesktopSection desktopSection, CancellationToken ct, Action? onRecordingStarted = null) { _logger.LogDebug("Recording process {Pid} (duration={Dur}s, fps={Fps}, maxEdge={MaxEdge}, captureScreen={Screen})", session.ProcessId, options.DurationSec, options.Fps, options.MaxEdge, options.CaptureScreen); @@ -51,17 +53,26 @@ public async Task RecordAsync(UiSessionInfo session, string throw new InvalidOperationException($"No native window handle for {session.ProcessName}. Is the window visible?"); } - if (Windows.Win32.PInvoke.IsIconic(hwnd)) + // Spec §6.3: recording is TurnShared, so it pins the owner for the whole capture but takes + // active.lock only for these desktop-sensitive moments. The capture loop below stays outside the + // section so same-owner clicks and typing can interleave with an in-flight recording. + var handle = (long)(nint)hwnd; + if (_desktopForeground.IsMinimized(handle) || options.CaptureScreen) { - Windows.Win32.PInvoke.ShowWindow(hwnd, Windows.Win32.UI.WindowsAndMessaging.SHOW_WINDOW_CMD.SW_RESTORE); - await Task.Delay(300, ct).ConfigureAwait(false); - } + await using var section = await desktopSection.EnterAsync(ct).ConfigureAwait(false); - // Bring to foreground for screen-DC capture. - if (options.CaptureScreen) - { - Windows.Win32.PInvoke.SetForegroundWindow(hwnd); - await Task.Delay(150, ct).ConfigureAwait(false); + if (_desktopForeground.IsMinimized(handle)) + { + _desktopForeground.Restore(handle); + await Task.Delay(300, ct).ConfigureAwait(false); + } + + // Bring to foreground for screen-DC capture. + if (options.CaptureScreen) + { + _desktopForeground.RequestForeground(handle); + await Task.Delay(150, ct).ConfigureAwait(false); + } } Windows.Win32.PInvoke.GetWindowRect(hwnd, out var rect); @@ -363,7 +374,11 @@ async ValueTask CommitFrameAsync(byte[] processedFrame) } else { - var source = CaptureFromWindowWithBlankRetry(hwnd, srcWidth, srcHeight); + // The blank-frame retry foregrounds the target, so it is a desktop-sensitive + // moment even mid-recording and enters the section. Blank frames are rare, so + // this does not take active.lock on every frame. + var source = await CaptureFromWindowWithBlankRetryAsync( + hwnd, srcWidth, srcHeight, desktopSection, observeOnly: false, ct).ConfigureAwait(false); frame = ProcessFrame( source, srcWidth, srcHeight, cropX, cropY, cropW, cropH, diff --git a/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Screenshot.cs b/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Screenshot.cs index d337c2cb9..728a6c4aa 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Screenshot.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Screenshot.cs @@ -3,10 +3,29 @@ using Microsoft.Extensions.Logging; using Windows.Win32.UI.Accessibility; +using WinApp.Cli.Helpers; using WinApp.Cli.Models; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Services; +/// +/// Raised by an observational screenshot pass when the target can only be captured by restoring or +/// foregrounding it — a desktop-sensitive act that an invocation is +/// not entitled to perform. +/// +/// +/// The caller responds by discarding every buffered capture, escalating the whole invocation +/// to , and recapturing from the beginning (spec §6.5). +/// Escalating per-window instead would publish an image mixing pre- and post-escalation pixels. +/// +internal sealed class DesktopEscalationRequiredException(string reason) + : Exception($"This screenshot needs the desktop: {reason}") +{ + /// Why the desktop is needed, for verbose diagnostics. + public string Reason { get; } = reason; +} + /// /// Screenshot capture methods: window/screen capture, pixel extraction, and element cropping. /// @@ -20,6 +39,12 @@ internal sealed partial class UiAutomationService /// /// Coverage ceiling (issue #630): this is a direct Win32 foreground request used only after a /// native PrintWindow blank frame. Tests cover callers through the injectable seam. + /// + /// Coordination (issue #764): this bypasses only because it + /// is an established test seam with an HWND signature. Its single caller + /// () invokes it inside a desktop section, so the + /// foreground change is still serialized against every other coordinated process. + /// /// private static void ForegroundWindowForBlankRetry(Windows.Win32.Foundation.HWND hwnd) => Windows.Win32.PInvoke.SetForegroundWindow(hwnd); @@ -30,9 +55,16 @@ private static void ForegroundWindowForBlankRetry(Windows.Win32.Foundation.HWND /// foreground policy transitions, WGC cancellation timing, or UIA elements without native handles /// that cannot be forced safely on the shared desktop. /// - public async Task<(byte[] Pixels, int Width, int Height)> ScreenshotAsync(UiSessionInfo session, string? elementId, bool captureScreen, bool focus, CancellationToken ct) + public async Task<(byte[] Pixels, int Width, int Height)> ScreenshotAsync( + UiSessionInfo session, + string? elementId, + bool captureScreen, + bool focus, + IDesktopSection desktopSection, + bool observeOnly, + CancellationToken ct) { - _logger.LogDebug("Taking screenshot of process {Pid} (captureScreen={CaptureScreen}, focus={Focus})", session.ProcessId, captureScreen, focus); + _logger.LogDebug("Taking screenshot of process {Pid} (captureScreen={CaptureScreen}, focus={Focus}, observeOnly={ObserveOnly})", session.ProcessId, captureScreen, focus, observeOnly); var root = GetRootElement(session); if (root is null) @@ -60,10 +92,20 @@ private static void ForegroundWindowForBlankRetry(Windows.Win32.Foundation.HWND throw new InvalidOperationException($"No native window handle for {session.ProcessName}. Is the window visible?"); } - // Check if window is minimized - if (Windows.Win32.PInvoke.IsIconic(hwnd)) + var handle = (long)(nint)hwnd; + + // Restoring a minimized window is desktop-sensitive: it changes what the user sees and can take + // the foreground. An observational pass reports the need and lets the caller escalate rather + // than quietly disturbing another workflow's desktop (spec §6.5). + if (_desktopForeground.IsMinimized(handle)) { - Windows.Win32.PInvoke.ShowWindow(hwnd, Windows.Win32.UI.WindowsAndMessaging.SHOW_WINDOW_CMD.SW_RESTORE); + if (observeOnly) + { + throw new DesktopEscalationRequiredException("the target window is minimized and must be restored"); + } + + await using var restoreSection = await desktopSection.EnterAsync(ct).ConfigureAwait(false); + _desktopForeground.Restore(handle); Thread.Sleep(300); } @@ -81,20 +123,31 @@ private static void ForegroundWindowForBlankRetry(Windows.Win32.Foundation.HWND var cropOriginLeft = rect.left; var cropOriginTop = rect.top; - // Bring window to foreground when explicitly requested or implied by --capture-screen. - // Done exactly once here, regardless of capture path. + // --focus and --capture-screen both require the foreground, so they are classified + // DesktopExclusive up front and never reach the observational path. if (focus || captureScreen) { - Windows.Win32.PInvoke.SetForegroundWindow(hwnd); + if (observeOnly) + { + throw new DesktopEscalationRequiredException("the requested capture mode needs the target in the foreground"); + } + + // Bring window to foreground when explicitly requested or implied by --capture-screen. + // Done exactly once here, regardless of capture path. The screen-DC BitBlt below reads the + // live screen, so it stays inside the same section. + await using var foregroundSection = await desktopSection.EnterAsync(ct).ConfigureAwait(false); + _desktopForeground.RequestForeground(handle); await Task.Delay(focus ? 150 : 100, ct).ConfigureAwait(false); - } - if (captureScreen) - { - // Screen capture mode: BitBlt from screen DC — captures popups and overlays. - pixelData = CaptureFromScreen(rect.left, rect.top, width, height); + if (captureScreen) + { + // Screen capture mode: BitBlt from screen DC — captures popups and overlays. + pixelData = CaptureFromScreen(rect.left, rect.top, width, height); + return CropIfRequested(pixelData, width, height, elementId, session, root, cropOriginLeft, cropOriginTop); + } } - else if (WgcCapture.IsSupported()) + + if (WgcCapture.IsSupported()) { try { @@ -113,14 +166,21 @@ private static void ForegroundWindowForBlankRetry(Windows.Win32.Foundation.HWND catch (Exception ex) { _logger.LogDebug(ex, "WGC capture failed; falling back to PrintWindow"); - pixelData = CaptureFromWindowWithBlankRetry(hwnd, width, height); + pixelData = await CaptureFromWindowWithBlankRetryAsync(hwnd, width, height, desktopSection, observeOnly, ct).ConfigureAwait(false); } } else { - pixelData = CaptureFromWindowWithBlankRetry(hwnd, width, height); + pixelData = await CaptureFromWindowWithBlankRetryAsync(hwnd, width, height, desktopSection, observeOnly, ct).ConfigureAwait(false); } + return CropIfRequested(pixelData, width, height, elementId, session, root, cropOriginLeft, cropOriginTop); + } + + private (byte[] Pixels, int Width, int Height) CropIfRequested( + byte[] pixelData, int width, int height, string? elementId, + UiSessionInfo session, IUIAutomationElement root, int cropOriginLeft, int cropOriginTop) + { // If a selector was provided, crop to the element's bounding rectangle if (!string.IsNullOrEmpty(elementId)) { @@ -149,16 +209,33 @@ private static unsafe Windows.Win32.Foundation.RECT GetVisibleWindowRect( return hr.Succeeded ? visibleRect : fallbackRect; } - internal byte[] CaptureFromWindowWithBlankRetry(Windows.Win32.Foundation.HWND hwnd, int width, int height) + internal async Task CaptureFromWindowWithBlankRetryAsync( + Windows.Win32.Foundation.HWND hwnd, int width, int height, + IDesktopSection desktopSection, bool observeOnly, CancellationToken ct) { var pixels = s_captureFromWindow(hwnd, width, height); - if (IsBlankCapture(pixels)) + if (!IsBlankCapture(pixels)) + { + return pixels; + } + + // A blank PrintWindow frame can only be recovered by foregrounding the window, which is + // desktop-sensitive. An observational pass reports it so the caller can escalate the whole + // invocation rather than publishing a black image or stealing focus (spec §6.5). + if (observeOnly) + { + throw new DesktopEscalationRequiredException( + "the target rendered a blank frame and must be foregrounded to capture it"); + } + + _logger.LogDebug("PrintWindow returned blank frame; foregrounding and retrying"); + await using (await desktopSection.EnterAsync(ct).ConfigureAwait(false)) { - _logger.LogDebug("PrintWindow returned blank frame; foregrounding and retrying"); s_foregroundWindowForBlankRetry(hwnd); s_sleepForBlankRetry(200); pixels = s_captureFromWindow(hwnd, width, height); } + return pixels; } diff --git a/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.cs b/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.cs index 9b5d27098..cc2c87760 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.cs @@ -27,6 +27,13 @@ internal sealed partial class UiAutomationService : IUiAutomationService private readonly IUIAutomation _automation; private readonly ISelectorService _selectorService; + /// + /// The single sanctioned path for changing the foreground window or restoring a minimized one. + /// Recording and screenshot capture both need those, and both must only do them inside a desktop + /// section (issue #764). + /// + private readonly IDesktopForegroundService _desktopForeground; + internal static Func s_getRootElement = (service, session) => service.GetRootElementCore(session); internal static Func s_getRootElementForHwnd = (service, hwnd) => service.GetRootElementForHwndCore(hwnd); internal static Func> s_getAllAppWindows = (service, session) => service.GetAllAppWindowsCore(session); @@ -58,10 +65,14 @@ internal static void ResetNativeSeams() s_sleepForBlankRetry = Thread.Sleep; } - public UiAutomationService(ILogger logger, ISelectorService selectorService) + public UiAutomationService( + ILogger logger, + ISelectorService selectorService, + IDesktopForegroundService desktopForeground) { _logger = logger; _selectorService = selectorService; + _desktopForeground = desktopForeground; _automation = CUIAutomation8.CreateInstance(); } diff --git a/src/winapp-CLI/WinApp.Cli/Telemetry/Events/CommandCompletedEvent.cs b/src/winapp-CLI/WinApp.Cli/Telemetry/Events/CommandCompletedEvent.cs index 7cebec793..33416b30a 100644 --- a/src/winapp-CLI/WinApp.Cli/Telemetry/Events/CommandCompletedEvent.cs +++ b/src/winapp-CLI/WinApp.Cli/Telemetry/Events/CommandCompletedEvent.cs @@ -5,6 +5,7 @@ using Microsoft.Diagnostics.Telemetry.Internal; using System.CommandLine.Parsing; using System.Diagnostics.Tracing; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Telemetry.Events; @@ -16,6 +17,21 @@ internal CommandCompletedEvent(CommandResult commandResult, DateTime finishedTim CommandName = commandResult.Command.GetType().FullName!; FinishedTime = finishedTime; ExitCode = exitCode; + + // Cooperative desktop turns (issue #764). Populated only for `winapp ui` commands, which are + // the only ones that coordinate. Everything here is a coarse bucket or a fixed enum name — + // never an owner id or hash, a PID, a process/app/window/selector string, a queue entry, a + // command argument, or any part of the state file (spec §16). + if (UiCoordinationTelemetryScope.Current is { } coordination) + { + UiIdentitySource = coordination.IdentitySource.ToString(); + UiTurnMode = coordination.Mode.ToString(); + UiTurnAction = coordination.TurnAction.ToString(); + UiCoordinationOutcome = coordination.Outcome.ToString(); + UiWaitBucket = coordination.WaitBucket; + UiQueueDepthBucket = coordination.QueueDepthBucket; + UiTurnAgeBucket = coordination.TurnAgeBucket; + } } public string CommandName { get; private set; } @@ -24,11 +40,35 @@ internal CommandCompletedEvent(CommandResult commandResult, DateTime finishedTim public int ExitCode { get; } + /// How the workflow owner was resolved: Explicit, Parent, or Anonymous. + public string? UiIdentitySource { get; } + + /// Coordination mode: Observe, TurnShared, or DesktopExclusive. + public string? UiTurnMode { get; } + + /// How the turn was obtained: new, continuation, queued, handoff-after-idle, or detached. + public string? UiTurnAction { get; } + + /// Completed, cancelled, coordination failure, or corruption recovery. + public string? UiCoordinationOutcome { get; } + + /// Coarse bucket for time spent waiting for the desktop, never an exact duration. + public string? UiWaitBucket { get; } + + /// Coarse bucket for how many commands were queued, never the queue contents. + public string? UiQueueDepthBucket { get; } + + /// Coarse bucket for how long the turn had been held. + public string? UiTurnAgeBucket { get; } + public override PartA_PrivTags PartA_PrivTags => PrivTags.ProductAndServiceUsage; public override void ReplaceSensitiveStrings(Func replaceSensitiveStrings) { CommandName = replaceSensitiveStrings(CommandName); + + // The coordination fields are fixed enum names and bucket labels produced by this build, so + // they cannot contain user paths or identifiers and need no scrubbing. } public static void Log(CommandResult commandResult, int exitCode) diff --git a/src/winapp-npm/README.md b/src/winapp-npm/README.md index 09209a34a..2dacf86f2 100644 --- a/src/winapp-npm/README.md +++ b/src/winapp-npm/README.md @@ -85,6 +85,35 @@ Full programmatic API reference: [NPM API Documentation](https://github.com/micr > **Note — the programmatic API runs the CLI non-interactively.** The wrapper functions capture output and give the native process piped stdin, so commands that would normally prompt cannot do so. For `azSign` in particular this means you must pass either a `metadataFile` or a fully specified identity (`subscription`, `resourceGroup`, `account`, and `profile`), and a non-interactive Azure credential must already be available (for example `AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/`AZURE_CLIENT_SECRET`, OIDC, a managed identity, or an existing `az login` session). Calls that would otherwise require a selection prompt or an interactive `az login` fail instead of prompting. +#### Cancelling a call + +Every command option object accepts a `signal`. It cancels the whole native invocation and rejects +with an `AbortError`: + +```typescript +const controller = new AbortController(); +setTimeout(() => controller.abort(), 30_000); + +await uiClick({ app: 'notepad', selector: 'btn-save-c3d4', signal: controller.signal }); +``` + +On Windows the child is force-terminated, so the CLI's own cleanup may not run. That is safe — +Windows releases the process's coordination handles and other `winapp ui` processes reclaim its queue +entry — but if the abort lands after the command already had the desktop, UI side effects may already +have happened, and aborting an active recording can leave partial output with no graceful MP4 +finalization. + +#### Driving UI from several workflows + +`winapp ui` commands that need the physical desktop take cooperative turns. Separate npm calls may +run under different Node parents, so the CLI cannot infer that they belong together. Set +`process.env.WINAPP_UI_OWNER_ID` to the same value for every cooperating call (it is forwarded to the +child automatically); the wrapper never generates one for you. See +[UI Automation → Coordinating concurrent UI workflows](https://github.com/microsoft/WinAppCli/blob/main/docs/ui-automation.md#coordinating-concurrent-ui-workflows). + +`uiRecord` still requires a finite positive `durationSec`: `signal` can only stop a recording by +killing it, which does not produce a valid MP4. + ## 🔧 Feedback - [File an issue, feature request or bug](https://github.com/microsoft/WinAppCli/issues): please ensure that you are not filing a duplicate issue diff --git a/src/winapp-npm/scripts/generate-commands.mjs b/src/winapp-npm/scripts/generate-commands.mjs index 14b1ca71a..1577fce09 100644 --- a/src/winapp-npm/scripts/generate-commands.mjs +++ b/src/winapp-npm/scripts/generate-commands.mjs @@ -249,6 +249,17 @@ function generate(schema) { L(' verbose?: boolean;'); L(' /** Working directory for the CLI process (defaults to process.cwd()). */'); L(' cwd?: string;'); + L(' /**'); + L(' * Cancels the whole native invocation, not just a wait for the shared desktop.'); + L(' *'); + L(' * `winapp ui` commands take cooperative turns on the desktop, so a command may wait for another'); + L(' * workflow to finish. Aborting force-terminates the child on Windows; the CLI\'s own cleanup may'); + L(' * not run, but Windows releases its coordination handles and deletes its participant lease, and'); + L(' * other processes reclaim the queue entry. If the abort lands after the command acquired the'); + L(' * desktop, UI side effects may already have happened, and aborting an active recording can leave'); + L(' * partial output. Rejects with an `AbortError`.'); + L(' */'); + L(' signal?: AbortSignal;'); L('}'); L(); L('/** Result returned by every command wrapper. */'); @@ -280,7 +291,10 @@ function generate(schema) { L('}'); L(); L('function captureOpts(opts: CommonOptions): CallWinappCliCaptureOptions {'); - L(' return opts.cwd ? { cwd: opts.cwd } : {};'); + L(' const result: CallWinappCliCaptureOptions = {};'); + L(' if (opts.cwd) result.cwd = opts.cwd;'); + L(' if (opts.signal) result.signal = opts.signal;'); + L(' return result;'); L('}'); L(); L('async function execCommand(args: string[], opts: CommonOptions): Promise {'); diff --git a/src/winapp-npm/src/ui-record-guard.ts b/src/winapp-npm/src/ui-record-guard.ts index 77cc2f672..0e245ce66 100644 --- a/src/winapp-npm/src/ui-record-guard.ts +++ b/src/winapp-npm/src/ui-record-guard.ts @@ -10,8 +10,12 @@ * * The guard validates that `durationSec` is provided and positive before calling the * CLI, because unbounded recording (durationSec == 0) is only supportable via the CLI - * with Ctrl+C or piped stdin — the npm wrapper has no mechanism to stop an unbounded - * spawn (no AbortSignal, no stdin pass-through). + * with Ctrl+C or piped stdin, which the npm wrapper has no way to drive. + * + * `CommonOptions.signal` (issue #764) does NOT relax this. An `AbortSignal` force-terminates the + * child, so it can stop an unbounded recording only by killing it — leaving partial or invalid MP4 + * output with no graceful finalization. A finite `durationSec` remains required so the normal path + * always produces a valid recording. * * This file must NOT be edited by the code generator; it is hand-maintained. */ @@ -112,7 +116,9 @@ export async function uiRecord(options: UiRecordOptions): Promise } const args = buildUiRecordArgs(options); - const captureOpts: CallWinappCliCaptureOptions = options.cwd ? { cwd: options.cwd } : {}; + const captureOpts: CallWinappCliCaptureOptions = {}; + if (options.cwd) captureOpts.cwd = options.cwd; + if (options.signal) captureOpts.signal = options.signal; const result = await callWinappCliCapture(args, captureOpts); return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; } @@ -147,7 +153,9 @@ export async function _uiRecordWithCapture( ); } const args = buildUiRecordArgs(options); - const captureOpts: CallWinappCliCaptureOptions = options.cwd ? { cwd: options.cwd } : {}; + const captureOpts: CallWinappCliCaptureOptions = {}; + if (options.cwd) captureOpts.cwd = options.cwd; + if (options.signal) captureOpts.signal = options.signal; const result = await capture(args, captureOpts); return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; } diff --git a/src/winapp-npm/src/winapp-cli-utils.ts b/src/winapp-npm/src/winapp-cli-utils.ts index d7607a333..3413e2ecf 100644 --- a/src/winapp-npm/src/winapp-cli-utils.ts +++ b/src/winapp-npm/src/winapp-cli-utils.ts @@ -7,6 +7,19 @@ export const WINAPP_CLI_CALLER_VALUE = 'nodejs-package'; export interface CallWinappCliOptions { exitOnError?: boolean; + /** + * Cancels the whole native invocation, not just a wait for the shared desktop. + * + * On Windows, Node force-terminates the child, so the CLI's own cleanup may not run. That is safe: + * Windows closes the process's coordination file handles and deletes its `DeleteOnClose` participant + * lease, and other `winapp ui` processes prune the entry through lease and PID/start validation. + * If the abort lands after the command acquired the desktop, UI side effects may already have + * happened, and aborting an active recording can leave partial or invalid output — this wrapper does + * not promise graceful MP4 finalization. + * + * Rejects with an `AbortError`. + */ + signal?: AbortSignal; } export interface CallWinappCliResult { @@ -16,6 +29,11 @@ export interface CallWinappCliResult { export interface CallWinappCliCaptureOptions { /** Working directory for the CLI process (defaults to process.cwd()) */ cwd?: string; + /** + * Cancels the whole native invocation. See {@link CallWinappCliOptions.signal} for the exact + * contract, including what is and is not guaranteed after an abort. + */ + signal?: AbortSignal; } export interface CallWinappCliCaptureResult { @@ -49,7 +67,7 @@ export function getWinappCliPath(): string { * Always captures output and returns it along with the exit code */ export async function callWinappCli(args: string[], options: CallWinappCliOptions = {}): Promise { - const { exitOnError = false } = options; + const { exitOnError = false, signal } = options; const winappCliPath = getWinappCliPath(); return new Promise((resolve, reject) => { @@ -57,6 +75,7 @@ export async function callWinappCli(args: string[], options: CallWinappCliOption stdio: 'inherit', cwd: process.cwd(), shell: false, + signal, env: { ...process.env, WINAPP_CLI_CALLER: WINAPP_CLI_CALLER_VALUE, @@ -76,6 +95,14 @@ export async function callWinappCli(args: string[], options: CallWinappCliOption }); child.on('error', (error) => { + // An aborted spawn surfaces here as an AbortError. Propagate it unchanged so callers can + // distinguish "I cancelled this" from "the CLI could not be launched", and never call + // process.exit for it — cancellation is the caller's decision, not a fatal tool failure. + if (isAbortError(error)) { + reject(error); + return; + } + if (exitOnError) { console.error(`Failed to execute winapp-cli: ${error.message}`); console.error(`Tried to run: ${winappCliPath}`); @@ -95,7 +122,7 @@ export async function callWinappCliCapture( args: string[], options: CallWinappCliCaptureOptions = {} ): Promise { - const { cwd = process.cwd() } = options; + const { cwd = process.cwd(), signal } = options; const winappCliPath = getWinappCliPath(); return new Promise((resolve, reject) => { @@ -106,6 +133,7 @@ export async function callWinappCliCapture( stdio: ['pipe', 'pipe', 'pipe'], cwd, shell: false, + signal, env: { ...process.env, WINAPP_CLI_CALLER: WINAPP_CLI_CALLER_VALUE, @@ -136,7 +164,13 @@ export async function callWinappCliCapture( }); child.on('error', (error) => { - reject(new Error(`Failed to execute winapp-cli: ${error.message}`)); + // Propagate an AbortError unchanged so callers can tell cancellation apart from a launch failure. + reject(isAbortError(error) ? error : new Error(`Failed to execute winapp-cli: ${error.message}`)); }); }); } + +/** Whether an error came from an aborted {@link AbortSignal} rather than a real spawn failure. */ +function isAbortError(error: Error): boolean { + return (error as NodeJS.ErrnoException).name === 'AbortError'; +} diff --git a/src/winapp-npm/src/winapp-commands.ts b/src/winapp-npm/src/winapp-commands.ts index c4814b5ce..19447fa40 100644 --- a/src/winapp-npm/src/winapp-commands.ts +++ b/src/winapp-npm/src/winapp-commands.ts @@ -2,7 +2,7 @@ * AUTO-GENERATED — DO NOT EDIT * * Regenerate with: npm run generate-commands - * Source schema version: 0.5.1 + * Source schema version: 0.6.1 * * Programmatic wrappers for all winapp CLI commands. * Each function builds the CLI arguments, invokes the native CLI, @@ -35,6 +35,17 @@ export interface CommonOptions { verbose?: boolean; /** Working directory for the CLI process (defaults to process.cwd()). */ cwd?: string; + /** + * Cancels the whole native invocation, not just a wait for the shared desktop. + * + * `winapp ui` commands take cooperative turns on the desktop, so a command may wait for another + * workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may + * not run, but Windows releases its coordination handles and deletes its participant lease, and + * other processes reclaim the queue entry. If the abort lands after the command acquired the + * desktop, UI side effects may already have happened, and aborting an active recording can leave + * partial output. Rejects with an `AbortError`. + */ + signal?: AbortSignal; } /** Result returned by every command wrapper. */ @@ -64,7 +75,10 @@ function pushCommon(args: string[], opts: CommonOptions): void { } function captureOpts(opts: CommonOptions): CallWinappCliCaptureOptions { - return opts.cwd ? { cwd: opts.cwd } : {}; + const result: CallWinappCliCaptureOptions = {}; + if (opts.cwd) result.cwd = opts.cwd; + if (opts.signal) result.signal = opts.signal; + return result; } async function execCommand(args: string[], opts: CommonOptions): Promise { diff --git a/src/winapp-npm/test/abort-signal.test.ts b/src/winapp-npm/test/abort-signal.test.ts new file mode 100644 index 000000000..ab35055bf --- /dev/null +++ b/src/winapp-npm/test/abort-signal.test.ts @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +/** + * Coverage for `CommonOptions.signal` (issue #764). + * + * `winapp ui` commands take cooperative turns on the shared desktop, so a call can wait an unbounded + * time for another workflow to finish. `signal` is the only way a programmatic caller can stop + * waiting, so these tests pin down that it actually reaches `child_process.spawn` on every path and + * that an abort surfaces as an `AbortError` rather than a generic spawn failure. + */ + +import { test, mock, afterEach } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +// Use import-equals so childProcess is the REAL cached module object (not an __importStar copy). +// winapp-cli-utils calls `require('child_process').spawn`, so the mock must be installed on the same +// shared exports object to be observed. +import childProcess = require('child_process'); + +import { callWinappCli, callWinappCliCapture } from '../src/winapp-cli-utils'; +import { uiInspect } from '../src/winapp-commands'; +import { uiRecord } from '../src/ui-record-guard'; + +type SpawnOptions = { signal?: AbortSignal }; + +/** Records the options object handed to spawn and returns a child that closes successfully. */ +function captureSpawnOptions(): { calls: SpawnOptions[] } { + const state = { calls: [] as SpawnOptions[] }; + mock.method(childProcess, 'spawn', ((_cmd: string, _args: string[], options: SpawnOptions) => { + state.calls.push(options); + const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + process.nextTick(() => child.emit('close', 0)); + return child; + }) as unknown as typeof childProcess.spawn); + return state; +} + +/** Emits the AbortError Node raises when a spawn is cancelled through its signal. */ +function abortingSpawn(): void { + mock.method(childProcess, 'spawn', ((_cmd: string, _args: string[], _options: SpawnOptions) => { + const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + process.nextTick(() => { + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + child.emit('error', error); + }); + return child; + }) as unknown as typeof childProcess.spawn); +} + +afterEach(() => { + mock.restoreAll(); +}); + +test('callWinappCli forwards the signal to spawn', async () => { + const spawned = captureSpawnOptions(); + const controller = new AbortController(); + + await callWinappCli(['ui', 'status'], { signal: controller.signal }); + + assert.equal(spawned.calls.length, 1); + assert.equal(spawned.calls[0].signal, controller.signal); +}); + +test('callWinappCliCapture forwards the signal to spawn', async () => { + const spawned = captureSpawnOptions(); + const controller = new AbortController(); + + await callWinappCliCapture(['ui', 'status'], { signal: controller.signal }); + + assert.equal(spawned.calls.length, 1); + assert.equal(spawned.calls[0].signal, controller.signal); +}); + +test('omitting the signal leaves spawn uncancellable rather than passing undefined semantics', async () => { + const spawned = captureSpawnOptions(); + + await callWinappCliCapture(['ui', 'status']); + + assert.equal(spawned.calls.length, 1); + assert.equal(spawned.calls[0].signal, undefined); +}); + +test('generated command wrappers thread the signal through captureOpts', async () => { + // The generator emits captureOpts() for every command, so proving it for one wrapper proves the + // shape for all of them. + const spawned = captureSpawnOptions(); + const controller = new AbortController(); + + await uiInspect({ app: 'notepad', signal: controller.signal }); + + assert.equal(spawned.calls.length, 1); + assert.equal(spawned.calls[0].signal, controller.signal); +}); + +test('the hand-written uiRecord guard threads the signal through', async () => { + const spawned = captureSpawnOptions(); + const controller = new AbortController(); + + await uiRecord({ app: 'notepad', durationSec: 1, signal: controller.signal }); + + assert.equal(spawned.calls.length, 1); + assert.equal(spawned.calls[0].signal, controller.signal); +}); + +test('uiRecord still requires a finite positive duration even with a signal', async () => { + // An AbortSignal can only stop a recording by killing the child, which does not finalize the MP4, + // so it must not be treated as a way to opt into unbounded recording. + const controller = new AbortController(); + + await assert.rejects( + () => uiRecord({ app: 'notepad', durationSec: 0, signal: controller.signal } as never), + /durationSec must be a finite integer/ + ); +}); + +test('an aborted call rejects with AbortError, not a generic spawn failure', async () => { + abortingSpawn(); + const controller = new AbortController(); + + await assert.rejects( + () => callWinappCliCapture(['ui', 'click'], { signal: controller.signal }), + (error: Error) => { + assert.equal(error.name, 'AbortError', 'callers must be able to tell cancellation from a launch failure'); + return true; + } + ); +}); + +test('an aborted inherit-stdio call also rejects with AbortError', async () => { + abortingSpawn(); + const controller = new AbortController(); + + await assert.rejects( + () => callWinappCli(['ui', 'click'], { signal: controller.signal }), + (error: Error) => { + assert.equal(error.name, 'AbortError'); + return true; + } + ); +}); + +test('a real spawn failure is still wrapped with the winapp-cli context', async () => { + mock.method(childProcess, 'spawn', ((_cmd: string, _args: string[], _options: SpawnOptions) => { + const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + process.nextTick(() => child.emit('error', new Error('ENOENT'))); + return child; + }) as unknown as typeof childProcess.spawn); + + await assert.rejects( + () => callWinappCliCapture(['ui', 'status']), + /Failed to execute winapp-cli/ + ); +}); From 36c3255362175f7fddf50e21243a008ab0c21542 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:18:50 -0700 Subject: [PATCH 02/29] =?UTF-8?q?Fix=20six=20coordination=20correctness=20?= =?UTF-8?q?bugs=20and=20add=20=C2=A718.3=20real-app=20acceptance=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2 findings, each with a regression test proven to fail without the fix: 1. Prior-boot idle deadline stranded the turn. Environment.TickCount64 resets on reboot, so a persisted deadline from a long-uptime session could hold the desktop for days. Normalize now clamps a deadline beyond now + IdleGraceMs, and the UTC diagnostic is overflow-safe. 2. Screenshot escalation swallowed OperationCanceledException and UiCoordinationException in its catch-all, reporting internal_error while the coordinator saw a normal completion and renewed the grace. All ten UI handlers now filter their catch-all with UiCoordinatedAction.IsCoordinationFault, and the coordinator no longer treats a cancelled token as a normal completion. 3. CompleteCommand rewrote the idle deadline without checking that the completing owner is the current owner, letting a foreign completion revoke or extend a stranger's grace. It now takes the full UiOwnerIdentity and mutates the deadline only on a match. 4. RegisterObserve ignored a Detached admission, leaving the lease open and completing against a foreign owner when ownership lapsed mid-registration. 5. IDesktopSection.EnterAsync documented reentrancy the implementation deliberately does not provide. 6. Missing state.json was treated as fresh unconditionally, so an external deletion while a participant was live could mint a second owner. It is fresh only with no live participant and a free active.lock. Also adds spec 18.3 real-app acceptance coverage: a tight burst protecting transient menu UI, a >4s reasoning gap forcing handover and replay, and a recording pinning its owner while same-owner input continues and another owner waits - driven by real separate winapp.exe processes against a real window. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../InteractiveDesktopLockTests.cs | 379 ++++++++++++++ .../InteractiveDesktopRealAppTests.cs | 487 ++++++++++++++++++ .../InteractiveDesktopSchedulerTests.cs | 158 +++++- .../InteractiveDesktopStoreTests.cs | 76 ++- .../WinApp.Cli.Tests/UiaTestFixture.cs | 78 ++- .../WinApp.Cli/Commands/UiClickCommand.cs | 2 +- .../WinApp.Cli/Commands/UiDragCommand.cs | 2 +- .../WinApp.Cli/Commands/UiFocusCommand.cs | 2 +- .../WinApp.Cli/Commands/UiHoverCommand.cs | 2 +- .../WinApp.Cli/Commands/UiInvokeCommand.cs | 2 +- .../WinApp.Cli/Commands/UiPenCommand.cs | 2 +- .../Commands/UiScreenshotCommand.cs | 4 +- .../WinApp.Cli/Commands/UiScrollCommand.cs | 2 +- .../WinApp.Cli/Commands/UiSendKeysCommand.cs | 2 +- .../WinApp.Cli/Commands/UiTouchCommand.cs | 2 +- .../WinApp.Cli/Helpers/UiCoordinatedAction.cs | 15 + .../IInteractiveDesktopLock.cs | 17 +- .../InteractiveDesktopLock.cs | 29 +- .../InteractiveDesktopScheduler.cs | 57 +- .../InteractiveDesktopStateStore.cs | 66 ++- 20 files changed, 1324 insertions(+), 60 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs index 247a4d37e..de957a499 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs @@ -308,4 +308,383 @@ public async Task InvalidExplicitOwnerIdFailsBeforeAnyUiSideEffect() Assert.IsFalse(_participants.AnyLiveParticipant(), "no lease may be left behind"); } + // -------------------------------------------------------- queueing behind another live owner + + /// + /// Publishes state in which a different owner holds the turn, and holds that owner's + /// participant lease so it reads as live. + /// + /// + /// Participant identity is (pid, processStartTicks), so a single process cannot legitimately + /// act as two owners. Holding the foreign lease file FileShare.None reproduces exactly what + /// the liveness probe observes for a real second process — a locked lease — without needing one, + /// which keeps the cancellation contract deterministically testable. (Genuine cross-process + /// behavior is covered separately by the multiprocess lane.) + /// + private FileStream OccupyTurnWithAnotherOwner(int foreignPid = 424242, long foreignStart = 987654321) + { + _paths.EnsureDirectories(); + var leaseStream = new FileStream( + _paths.LeasePath(foreignPid, foreignStart), + FileMode.Create, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.DeleteOnClose); + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var state = InteractiveDesktopState.CreateFresh(); + state.TurnId = 1; + state.NextTicket = 2; + state.Owner = new OwnerRecord { Kind = UiOwnerKind.Explicit, Key = "some-other-workflow" }; + state.OwnerCommands.Add(new OwnerCommandEntry + { + Ticket = 1, + Pid = foreignPid, + ProcessStartTicksUtc = foreignStart, + Operation = "ui click", + Mode = UiTurnMode.DesktopExclusive, + Status = UiCommandStatus.Running, + }); + _store.Publish(state); + + return leaseStream; + } + + [TestMethod] + public async Task ACommandQueuesWhileAnotherOwnerHoldsTheTurn() + { + using var foreignLease = OccupyTurnWithAnotherOwner(); + + using var cts = new CancellationTokenSource(); + var ran = false; + var queued = RunAsyncWithToken(UiTurnMode.DesktopExclusive, "ui click", (_, _) => + { + ran = true; + return Task.FromResult(0); + }, cts.Token); + + await Task.Delay(250); + Assert.IsFalse(ran, "the command must wait while another owner holds the turn"); + + using (var stateLock = _store.AcquireStateLock(CancellationToken.None)) + { + Assert.AreEqual(1, _store.Read().State!.Waiters.Count, + "the command must be recorded as a global waiter"); + } + + await cts.CancelAsync(); + await queued; + } + + [TestMethod] + public async Task CancellingWhileQueuedExitsOneThirtyAndRemovesTheTicket() + { + using var foreignLease = OccupyTurnWithAnotherOwner(); + + using var cts = new CancellationTokenSource(); + var ran = false; + var queued = RunAsyncWithToken(UiTurnMode.DesktopExclusive, "ui click", (_, _) => + { + ran = true; + return Task.FromResult(0); + }, cts.Token); + + await Task.Delay(250); + await cts.CancelAsync(); + + Assert.AreEqual(InteractiveDesktopLock.CancelledExitCode, await queued, + "a command cancelled while queued exits 130"); + Assert.IsFalse(ran, "it never reached execution, so it has no UI side effects"); + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var state = _store.Read().State!; + Assert.AreEqual(0, state.Waiters.Count, "cancellation must remove the waiter's ticket"); + Assert.AreEqual("some-other-workflow", state.Owner!.Key, + "the cancelled command must not disturb the current owner"); + } + + [TestMethod] + public async Task CancellingWhileQueuedEmitsTheStructuredCancelledError() + { + using var foreignLease = OccupyTurnWithAnotherOwner(); + + var errorWriter = new StringWriter(); + var command = new Command("probe"); + command.Options.Add(WinAppRootCommand.JsonOption); + command.Options.Add(WinAppRootCommand.QuietOption); + command.Options.Add(WinAppRootCommand.VerboseOption); + var parseResult = command.Parse(["--json"]); + parseResult.InvocationConfiguration.Error = errorWriter; + + using var cts = new CancellationTokenSource(); + var queued = _coordinator.RunCoordinatedAsync( + UiTurnMode.DesktopExclusive, "ui click", parseResult, + (_, _) => Task.FromResult(0), cts.Token); + + await Task.Delay(250); + await cts.CancelAsync(); + Assert.AreEqual(InteractiveDesktopLock.CancelledExitCode, await queued); + + var payload = errorWriter.ToString(); + StringAssert.Contains(payload, "\"code\":\"cancelled\""); + StringAssert.Contains(payload, "\"waitedMs\""); + // Owner identity must never surface, in raw or hashed form. + Assert.IsFalse(payload.Contains("some-other-workflow", StringComparison.Ordinal)); + Assert.IsFalse(payload.Contains("interactive-desktop-lock-tests", StringComparison.Ordinal)); + } + + [TestMethod] + public async Task AQueuedCommandProceedsOnceTheOtherOwnerIsGone() + { + var foreignLease = OccupyTurnWithAnotherOwner(); + + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var queued = RunAsyncWithToken(UiTurnMode.DesktopExclusive, "ui click", (_, _) => + { + started.SetResult(); + return Task.FromResult(0); + }, CancellationToken.None); + + await Task.Delay(200); + Assert.IsFalse(started.Task.IsCompleted); + + // Closing the lease is what Windows does when that process exits or is killed. A crash does not + // renew the grace, so the turn is released immediately and this command is promoted. + foreignLease.Dispose(); + + Assert.AreEqual(0, await queued); + Assert.IsTrue(started.Task.IsCompleted); + } + + private Task RunAsyncWithToken( + UiTurnMode mode, string operation, Func> body, CancellationToken token) + => _coordinator.RunCoordinatedAsync(mode, operation, Parse(), body, token); + + // ------------------------------------------------------ escalation must not swallow coordination + + [TestMethod] + public async Task EscalationCancelledWhileQueuedExitsOneThirtyAndLeavesNoTrace() + { + // Exactly what `ui screenshot` does: an observational pass discovers it needs the foreground and + // escalates. Here the escalation queues behind another owner and is cancelled. + using var foreignLease = OccupyTurnWithAnotherOwner(); + var deadlineBefore = ReadOwnerDeadline(); + + var errorWriter = new StringWriter(); + var parseResult = ParseWithWriter(errorWriter); + + using var cts = new CancellationTokenSource(); + var escalated = false; + var queued = _coordinator.RunCoordinatedAsync( + UiTurnMode.Observe, "ui screenshot", parseResult, + async (turn, token) => + { + await turn.EscalateToDesktopExclusiveAsync(token); + escalated = true; + return 0; + }, + cts.Token); + + await Task.Delay(250); + await cts.CancelAsync(); + + Assert.AreEqual(InteractiveDesktopLock.CancelledExitCode, await queued, + "a cancelled escalation is a cancellation, not an internal error"); + Assert.IsFalse(escalated, "the body never resumed, so it produced no image"); + StringAssert.Contains(errorWriter.ToString(), "\"code\":\"cancelled\""); + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var state = _store.Read().State!; + Assert.AreEqual(0, state.Waiters.Count, "the escalation's ticket must be removed"); + Assert.AreEqual(deadlineBefore, state.IdleExpiresTick64, + "a cancelled stranger must not disturb the current owner's idle deadline"); + } + + [TestMethod] + public async Task ABodyThatSwallowsCancellationStillDoesNotRenewTheGrace() + { + // Defence in depth for the handler catch-all: even if a body swallows its own cancellation, the + // coordinator must not treat that as a normal completion and extend the owner's turn. + Assert.AreEqual(0, await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0))); + var deadlineAfterNormalCommand = ReadOwnerDeadline(); + Assert.AreNotEqual(0, deadlineAfterNormalCommand, "an ordinary completion establishes the grace"); + + // Any renewal would land strictly later than the deadline captured above. + await Task.Delay(50); + + using var cts = new CancellationTokenSource(); + var bodyObservedCancellation = false; + var exitCode = await RunAsyncWithToken(UiTurnMode.DesktopExclusive, "ui click", async (_, token) => + { + await cts.CancelAsync(); + bodyObservedCancellation = token.IsCancellationRequested; + try + { + token.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException) + { + // Deliberately swallowed, imitating an over-broad handler catch. + } + + return 1; + }, cts.Token); + + Assert.AreEqual(1, exitCode, "the body's own result is preserved"); + Assert.IsTrue(bodyObservedCancellation, + "the body must actually observe cancellation, or this test proves nothing"); + Assert.AreEqual(deadlineAfterNormalCommand, ReadOwnerDeadline(), + "a cancelled command must not renew the owner's idle grace, however its body handled the cancellation"); + } + + [TestMethod] + public async Task EscalationAgainstUnknownNewerStateReportsCoordinationUnavailable() + { + _paths.EnsureDirectories(); + using (var stateLock = _store.AcquireStateLock(CancellationToken.None)) + { + var state = InteractiveDesktopState.CreateFresh(); + state.Version = int.MaxValue; + _store.Publish(state); + } + + var ex = await Assert.ThrowsExactlyAsync(() => + RunAsync(UiTurnMode.Observe, "ui screenshot", async (turn, token) => + { + await turn.EscalateToDesktopExclusiveAsync(token); + return 0; + })); + + Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code, + "escalating against state written by a newer build must fail closed, not run uncoordinated"); + } + + private long ReadOwnerDeadline() + { + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + return _store.Read().State!.IdleExpiresTick64; + } + + private static ParseResult ParseWithWriter(TextWriter errorWriter) + { + var command = new Command("probe"); + command.Options.Add(WinAppRootCommand.JsonOption); + command.Options.Add(WinAppRootCommand.QuietOption); + command.Options.Add(WinAppRootCommand.VerboseOption); + var parseResult = command.Parse(["--json"]); + parseResult.InvocationConfiguration.Error = errorWriter; + return parseResult; + } + + // ------------------------------------------------- ownership lapsing mid-registration (observe) + + /// + /// The turn is lost between the owner check and the admission that follows it. + /// + /// + /// normalizes again, so an owner that was + /// current a moment earlier can be gone by the time admission runs — here because the shell the + /// reservation was derived from exits in between. The observation must then run fully detached: + /// keeping the lease open would publish liveness for a participant with no state entry, and + /// completing later would adjust a different owner's turn. + /// + [TestMethod] + public async Task ObserveWhoseOwnerLapsesDuringAdmissionRunsFullyDetached() + { + const int parentPid = 4242; + const long parentStart = 777_777; + + // Parent-derived ownership, so the fake inspector controls exactly when the turn lapses. + Environment.SetEnvironmentVariable(UiOwnerResolver.OwnerIdVariable, null); + var inspector = new ParentDiesAfterFirstProbeInspector(parentPid, parentStart); + var paths = new InteractiveDesktopPaths(inspector); + var participants = new ParticipantRegistry(paths, inspector, NullLogger.Instance); + var store = new InteractiveDesktopStateStore( + paths, participants, new TickCountClock(), NullLogger.Instance); + var coordinator = new InteractiveDesktopLock( + store, paths, participants, new UiOwnerResolver(inspector), inspector, + new TickCountClock(), new FakePollDelay(), new TestConsole(), + NullLogger.Instance); + + paths.EnsureDirectories(); + using (var stateLock = store.AcquireStateLock(CancellationToken.None)) + { + var state = InteractiveDesktopState.CreateFresh(); + state.TurnId = 3; + state.Owner = new OwnerRecord + { + Kind = UiOwnerKind.Parent, + Key = UiOwnerResolver.ComputeParentKey(parentPid, parentStart), + DiagnosticParentPid = parentPid, + ParentStartTicksUtc = parentStart, + }; + state.IdleExpiresTick64 = new TickCountClock().NowTicks64 + InteractiveDesktopScheduler.IdleGraceMs; + store.Publish(state); + } + + var leaseLiveDuringBody = true; + var exitCode = await coordinator.RunCoordinatedAsync( + UiTurnMode.Observe, "ui inspect", Parse(), + (_, _) => + { + // The decisive check. By teardown the lease is closed either way, so the only moment the + // bug is observable is while the detached body runs: a detached observation must hold no + // lease at all, or other processes see liveness for a participant that has no entry. + leaseLiveDuringBody = participants.AnyLiveParticipant(); + return Task.FromResult(0); + }, + CancellationToken.None); + + Assert.AreEqual(0, exitCode, "a detached observation still runs; it simply pins nothing"); + Assert.IsTrue( + inspector.ParentProbes >= 2, + "the test is only meaningful if the owner check and the admission both probed the parent"); + Assert.IsFalse( + leaseLiveDuringBody, + "the lease opened before admission must be closed as soon as the admission came back detached"); + + using (var stateLock = store.AcquireStateLock(CancellationToken.None)) + { + var state = store.Read().State!; + Assert.AreEqual(0, state.OwnerCommands.Count, + "a detached observation must publish no owner-command entry"); + } + + Assert.IsFalse(participants.AnyLiveParticipant(), + "the lease must also be gone once the command has finished"); + } + + /// + /// Reports the owning shell as alive for the first probe and gone afterwards, so a turn lapses at a + /// precisely known point instead of depending on wall-clock timing. + /// + private sealed class ParentDiesAfterFirstProbeInspector(int parentPid, long parentStartTicks) : IProcessInspector + { + private readonly ProcessInspector _real = new(); + + public int ParentProbes { get; private set; } + + public int CurrentProcessId => _real.CurrentProcessId; + + public long CurrentProcessStartTicksUtc => _real.CurrentProcessStartTicksUtc; + + public int CurrentSessionId => _real.CurrentSessionId; + + public int? TryGetParentProcessId() => parentPid; + + public long? TryGetProcessStartTicksUtc(int processId) + => processId == parentPid ? parentStartTicks : _real.TryGetProcessStartTicksUtc(processId); + + public bool? IsProcessAlive(int processId, long startTicksUtc) + { + if (processId != parentPid || startTicksUtc != parentStartTicks) + { + return _real.IsProcessAlive(processId, startTicksUtc); + } + + ParentProbes++; + return ParentProbes <= 1; + } + } } \ No newline at end of file diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs new file mode 100644 index 000000000..92a0db424 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs @@ -0,0 +1,487 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Diagnostics; +using Microsoft.Extensions.Logging.Abstractions; +using WinApp.Cli.Services.InteractiveDesktop; + +namespace WinApp.Cli.Tests; + +/// +/// Real-app acceptance coverage for cooperative desktop turns (issue #764 §18.3). +/// +/// +/// +/// Unlike the scheduler and store suites, nothing here is faked. A real window (, +/// hosted on this test process's UI thread, with a genuine MenuStrip whose drop-down Windows +/// dismisses on foreground loss) plays the target app, and every agent is a separate real +/// winapp.exe process carrying its own WINAPP_UI_OWNER_ID. The properties under test — +/// a transient menu surviving another agent's attempt to act, a reasoning gap handing the turn away, +/// and a recording pinning its owner — are only meaningful end to end, so they are asserted against +/// observable desktop state rather than scheduler internals. +/// +/// +/// Gated on WINAPP_UI_MULTIPROCESS_TESTS=1 plus a published winapp.exe: these need an +/// interactive desktop and build artifacts, so the canonical build skips them. +/// +/// +[TestClass] +[DoNotParallelize] // Drives the real foreground window and a process-wide lock-directory override. +[TestCategory("Interactive")] +[TestCategory("UiCoordination")] +public class InteractiveDesktopRealAppTests : IDisposable +{ + private const string GateVariable = "WINAPP_UI_MULTIPROCESS_TESTS"; + private const string OwnerA = "realapp-agent-a"; + private const string OwnerB = "realapp-agent-b"; + + private string _lockDirectory = null!; + private string? _previousLockOverride; + private string? _previousOwnerId; + private string _winappPath = null!; + private string _scratchDirectory = null!; + private UiaTestFixture _fixture = null!; + private InteractiveDesktopStateStore _store = null!; + private Stopwatch? _graceWatch; + private readonly List _children = []; + + [TestInitialize] + public void Setup() + { + if (!string.Equals(Environment.GetEnvironmentVariable(GateVariable), "1", StringComparison.Ordinal)) + { + Assert.Inconclusive( + $"Set {GateVariable}=1 on an interactive desktop (and build the CLI) to run real-app UI coordination coverage."); + } + + _winappPath = FindWinappExe() + ?? throw new AssertInconclusiveException( + "winapp.exe was not found. Run scripts\\build-cli.ps1 first so artifacts\\cli\\\\winapp.exe exists."); + + _lockDirectory = Path.Combine(Path.GetTempPath(), $"winapp-realapp-{Guid.NewGuid():N}"); + _scratchDirectory = Path.Combine(Path.GetTempPath(), $"winapp-realapp-out-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_scratchDirectory); + + _previousLockOverride = Environment.GetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable); + _previousOwnerId = Environment.GetEnvironmentVariable(UiOwnerResolver.OwnerIdVariable); + Environment.SetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable, _lockDirectory); + + var inspector = new ProcessInspector(); + var paths = new InteractiveDesktopPaths(inspector); + var participants = new ParticipantRegistry(paths, inspector, NullLogger.Instance); + _store = new InteractiveDesktopStateStore( + paths, participants, new TickCountClock(), NullLogger.Instance); + + _fixture = new UiaTestFixture(); + } + + [TestCleanup] + public void Cleanup() + { + foreach (var child in _children) + { + try + { + if (!child.HasExited) + { + child.Kill(entireProcessTree: true); + } + } + catch (InvalidOperationException) + { + // Already gone. + } + + child.Dispose(); + } + + // An open drop-down holds a desktop-wide keyboard capture. Leaving one behind would silently + // swallow input in the next test, so menu mode is exited before the window goes away. + try + { + _fixture?.CloseFileMenu(); + } + catch (InvalidOperationException) + { + // The UI thread is already gone; nothing to release. + } + + _fixture?.Dispose(); + _fixture = null!; + + Environment.SetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable, _previousLockOverride); + Environment.SetEnvironmentVariable(UiOwnerResolver.OwnerIdVariable, _previousOwnerId); + + foreach (var directory in new[] { _lockDirectory, _scratchDirectory }) + { + try + { + if (directory is not null && Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + catch (IOException) + { + // A leaked temp directory must never fail a test. + } + } + } + + private static string? FindWinappExe() + { + var root = AppContext.BaseDirectory; + for (var i = 0; i < 8 && root is not null; i++) + { + foreach (var rid in new[] { "win-arm64", "win-x64" }) + { + var candidate = Path.Combine(root, "artifacts", "cli", rid, "winapp.exe"); + if (File.Exists(candidate)) + { + return candidate; + } + } + + root = Path.GetDirectoryName(root.TrimEnd(Path.DirectorySeparatorChar)); + } + + var sideBySide = Path.Combine(AppContext.BaseDirectory, "winapp.exe"); + return File.Exists(sideBySide) ? sideBySide : null; + } + + // ------------------------------------------------------------------ real winapp.exe agents + + private sealed record AgentRun(Process Process, Task Completion, Task Output); + + /// + /// Launches a real winapp.exe as against the fixture window. + /// + /// + /// Both pipes are drained concurrently: ui commands emit payloads large enough to fill the + /// pipe buffer, and waiting for exit without reading would deadlock. + /// + private AgentRun StartAgent(string ownerId, params string[] args) + { + var startInfo = new ProcessStartInfo(_winappPath) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + foreach (var arg in args) + { + startInfo.ArgumentList.Add(arg); + } + + startInfo.Environment[InteractiveDesktopPaths.LockDirectoryOverrideVariable] = _lockDirectory; + startInfo.Environment[UiOwnerResolver.OwnerIdVariable] = ownerId; + startInfo.Environment["WINAPP_CLI_UPDATE_CHECK"] = "0"; + + var process = Process.Start(startInfo)!; + _children.Add(process); + + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + var completion = Task.Run(async () => + { + await process.WaitForExitAsync(); + return process.ExitCode; + }); + var output = Task.Run(async () => await stdout + await stderr); + + return new AgentRun(process, completion, output); + } + + private async Task<(int ExitCode, string Output)> RunAgentAsync(string ownerId, params string[] args) + { + var run = StartAgent(ownerId, args); + return (await run.Completion, await run.Output); + } + + /// Arguments that target the fixture window precisely (HWND beats process-name matching). + private string[] TargetArgs => ["-w", _fixture.Hwnd.ToString(System.Globalization.CultureInfo.InvariantCulture)]; + + private string[] WithTarget(params string[] args) => [.. args, .. TargetArgs, "--json"]; + + private InteractiveDesktopState ReadState() + { + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + return _store.Read().State!; + } + + /// + /// The persisted owner key for an explicit owner id. Raw ids never reach disk — state.json + /// stores only the domain-separated SHA-256 — so tests must compare against the hash. + /// + private static string KeyOf(string ownerId) => UiOwnerResolver.ComputeExplicitKey(ownerId); + + private async Task WaitForStateAsync(Func predicate, int timeoutMs = 20_000) + { + var deadline = Stopwatch.StartNew(); + while (deadline.ElapsedMilliseconds < timeoutMs) + { + if (predicate(ReadState())) + { + return true; + } + + await Task.Delay(50); + } + + return false; + } + + /// + /// Waits for , failing with the agent's own output if it exits first. + /// + /// + /// A child that dies during startup — a mistyped option, an unresolvable target — would otherwise + /// surface only as an opaque "state never reached" timeout that says nothing about the real cause. + /// + private async Task WaitForAgentStateAsync( + AgentRun agent, Func predicate, string because, int timeoutMs = 20_000) + { + var deadline = Stopwatch.StartNew(); + while (deadline.ElapsedMilliseconds < timeoutMs) + { + if (predicate(ReadState())) + { + return; + } + + if (agent.Process.HasExited) + { + Assert.Fail( + $"{because}, but the agent exited early with code {agent.Process.ExitCode}. Output: {await agent.Output}"); + } + + await Task.Delay(50); + } + + Assert.Fail($"{because}, but the state was never reached within {timeoutMs} ms."); + } + + /// + /// Has open the File drop-down through a real exclusive command, so the + /// turn and the transient UI are established by the same agent action. + /// + /// + /// Doing both in one command is not just convenient — it removes any window between "A owns the + /// turn" and "A has transient UI on screen" in which the 4 s idle grace could lapse and quietly + /// invalidate the test's premise. It is also what a real agent does. + /// + private async Task OpenMenuAsOwnerAsync(string ownerId) + { + var (exitCode, output) = await RunAgentAsync(ownerId, WithTarget("ui", "invoke", "File Menu")); + Assert.AreEqual(0, exitCode, $"the agent's menu-opening command should succeed. Output: {output}"); + _graceWatch = Stopwatch.StartNew(); + + // UIA Invoke posts the click; the drop-down appears a moment later. + var deadline = Stopwatch.StartNew(); + while (deadline.ElapsedMilliseconds < 3_000 && !_fixture.IsFileMenuOpen) + { + await Task.Delay(50); + } + + Assert.IsTrue( + _fixture.IsFileMenuOpen, + "the agent's own command must have opened real transient UI for the test to be meaningful"); + Assert.AreEqual( + KeyOf(ownerId), ReadState().Owner?.Key, + "the agent must hold the turn immediately after its exclusive command completes"); + } + + // ------------------------------------------------------------------------------ §18.3 (a) + + /// + /// §18.3(a): a tight burst by one owner keeps its transient UI intact while a different owner's + /// mutation is held back. + /// + /// + /// The menu is opened directly on the fixture rather than through UIA so the test asserts the + /// coordination property (the drop-down survives) rather than re-testing menu invocation, which + /// RealUiAutomationTests already covers. Agent B is a genuine separate process, so the + /// foreground steal it would perform is real. + /// + [TestMethod] + public async Task ATightBurstKeepsTransientMenuOpenWhileAnotherOwnerWaits() + { + await OpenMenuAsOwnerAsync(OwnerA); + + // A different agent tries to act on the same desktop. Its click would take the foreground and + // dismiss the drop-down, so coordination must hold it until A's burst is finished. + var agentB = StartAgent(OwnerB, WithTarget("ui", "click", "btnInvoke")); + + await WaitForAgentStateAsync( + agentB, + s => s.Waiters.Count == 1 && s.Waiters[0].Pid == agentB.Process.Id, + "agent B must queue behind agent A's turn instead of acting immediately"); + + // A's burst: observations never yield the turn, and each one refreshes nothing that would let B in. + for (var i = 0; i < 3; i++) + { + var (exitCode, output) = await RunAgentAsync(OwnerA, WithTarget("ui", "inspect")); + Assert.AreEqual(0, exitCode, $"burst step {i} should succeed. Output: {output}"); + Assert.IsTrue( + _fixture.IsFileMenuOpen, + $"the transient menu must still be open after burst step {i}: another owner was allowed to interfere"); + Assert.IsFalse(agentB.Process.HasExited, "agent B must still be waiting during the burst"); + } + + Assert.IsTrue(_fixture.IsFileMenuOpen, "the burst must complete with the transient UI intact"); + + // Releasing the turn lets B through, and its foreground steal dismisses the menu. Observing that + // proves the earlier assertions were real protection rather than B simply being slow. + Assert.AreEqual(0, await agentB.Completion, $"agent B should succeed once it gets the turn. Output: {await agentB.Output}"); + + var deadline = Stopwatch.StartNew(); + while (deadline.ElapsedMilliseconds < 5_000 && _fixture.IsFileMenuOpen) + { + await Task.Delay(100); + } + + Assert.IsFalse( + _fixture.IsFileMenuOpen, + "agent B's click should have dismissed the menu once it ran, confirming it was genuinely blocked before"); + } + + // ------------------------------------------------------------------------------ §18.3 (b) + + /// + /// §18.3(b): a reasoning gap longer than the idle grace hands the turn to a waiting owner, and the + /// transient UI the first owner left behind does not survive — so its next step must replay. + /// + [TestMethod] + public async Task AReasoningGapHandsOverTheTurnAndForcesReplay() + { + await OpenMenuAsOwnerAsync(OwnerA); + + var agentB = StartAgent(OwnerB, WithTarget("ui", "click", "btnInvoke")); + await WaitForAgentStateAsync( + agentB, + s => s.Waiters.Count == 1, + "agent B must queue while agent A still holds the turn"); + + // The reasoning gap: agent A issues nothing while its model thinks. The grace is not renewed, + // so ownership legitimately transfers. The grace started when A's command completed, not when B + // queued, so it is measured from there. + Assert.AreEqual(0, await agentB.Completion, $"agent B must acquire the turn after the gap. Output: {await agentB.Output}"); + Assert.IsTrue( + _graceWatch!.ElapsedMilliseconds >= InteractiveDesktopScheduler.IdleGraceMs - 500, + $"handover must wait out the {InteractiveDesktopScheduler.IdleGraceMs} ms idle grace, " + + $"but took {_graceWatch.ElapsedMilliseconds} ms from agent A's last command"); + + Assert.IsTrue( + await WaitForStateAsync(s => s.Owner?.Key == KeyOf(OwnerB), timeoutMs: 5_000), + "the turn must transfer to agent B after agent A's grace expires"); + + var menuGone = Stopwatch.StartNew(); + while (menuGone.ElapsedMilliseconds < 5_000 && _fixture.IsFileMenuOpen) + { + await Task.Delay(100); + } + + Assert.IsFalse( + _fixture.IsFileMenuOpen, + "the transient UI must NOT survive the handover: this is exactly why an agent has to replay after a gap"); + + // Agent A resumes and finds a different world. Its recovery step succeeds only because it + // reopens rather than assuming the menu it left behind is still there. + _fixture.OpenFileMenu(); + var (replayExit, replayOutput) = await RunAgentAsync(OwnerA, WithTarget("ui", "inspect")); + Assert.AreEqual(0, replayExit, $"agent A must be able to replay after the handover. Output: {replayOutput}"); + Assert.IsTrue(_fixture.IsFileMenuOpen, "agent A's replay must restore its transient UI"); + } + + // ------------------------------------------------------------------------------ §18.3 (c) + + /// + /// §18.3(c): a recording pins the turn to its owner. Same-owner input continues during the + /// recording (a later exclusive command is not blocked by an earlier running shared one), while a + /// different owner's mutation waits until the recording finishes. + /// + [TestMethod] + public async Task RecordingPinsTheOwnerWhileSameOwnerInputContinuesAndAnotherOwnerWaits() + { + const int recordSeconds = 8; + var outputPath = Path.Combine(_scratchDirectory, "pinned.mp4"); + + // Explicit precondition: this test injects real keystrokes, so it must not inherit menu mode or + // a stray foreground window from whatever ran before it. + _fixture.CloseFileMenu(); + Assert.IsFalse(_fixture.IsFileMenuOpen, "no drop-down may be capturing keyboard input"); + + var recorder = StartAgent(OwnerA, WithTarget( + "ui", "record", "--duration-sec", recordSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture), + "-o", outputPath)); + + await WaitForAgentStateAsync( + recorder, + s => s.Owner?.Key == KeyOf(OwnerA) && s.OwnerCommands.Any( + c => c.Mode == UiTurnMode.TurnShared && c.Status == UiCommandStatus.Running), + "the recording must register as a running shared command owned by agent A"); + + // A different owner's mutation must not interleave with the recording. + var agentB = StartAgent(OwnerB, WithTarget("ui", "click", "btnInvoke")); + await WaitForAgentStateAsync( + agentB, + s => s.Waiters.Any(w => w.Pid == agentB.Process.Id), + "agent B must queue behind the recording owner"); + + // Same-owner input proceeds while the recording runs: an earlier running TurnShared command does + // not block a later DesktopExclusive one from the same owner (§10.3). + // + // A UIA Invoke is used rather than synthetic keystrokes because its effect is observable + // deterministically (the button handler sets the result box) and does not depend on desktop-wide + // keyboard focus, which other tests in this class legitimately disturb. Keystroke ordering itself + // is covered by the send-keys coverage in RealUiAutomationTests. + var inputTimer = Stopwatch.StartNew(); + var (actionExit, actionOutput) = await RunAgentAsync(OwnerA, WithTarget("ui", "invoke", "Click Me")); + inputTimer.Stop(); + + Assert.AreEqual(0, actionExit, $"same-owner input must proceed during the recording. Output: {actionOutput}"); + Assert.IsFalse(recorder.Process.HasExited, "the recording must still be running when same-owner input completes"); + Assert.IsFalse(agentB.Process.HasExited, "agent B must still be waiting while the recording owner is active"); + + Assert.IsTrue( + inputTimer.ElapsedMilliseconds < recordSeconds * 1000, + $"same-owner input waited {inputTimer.ElapsedMilliseconds} ms, which suggests it was blocked by the recording"); + + // The control updates on the app's UI thread after the invoke is dispatched, so poll briefly + // rather than sampling once and racing the message pump. + var effectWatch = Stopwatch.StartNew(); + var result = _fixture.OnUiThread(() => _fixture.ResultBox.Text); + while (effectWatch.ElapsedMilliseconds < 5_000 && result != "clicked") + { + await Task.Delay(100); + result = _fixture.OnUiThread(() => _fixture.ResultBox.Text); + } + + Assert.AreEqual("clicked", result, + "the same-owner command must have really acted on the app while the recording was running"); + + Assert.AreEqual(0, await recorder.Completion, $"the recording should succeed. Output: {await recorder.Output}"); + Assert.IsTrue(File.Exists(outputPath), "the recording must have produced its output file"); + + Assert.AreEqual(0, await agentB.Completion, $"agent B should run once the recording releases the turn. Output: {await agentB.Output}"); + Assert.IsTrue( + agentB.Process.ExitTime >= recorder.Process.ExitTime.AddMilliseconds(-250), + "agent B must not have completed before the recording released the turn"); + } + + /// + /// Backstop for the fixture window: already disposes it after every test, so + /// this only matters if the framework tears the class down without running cleanup. + /// + public void Dispose() + { + _fixture?.Dispose(); + _fixture = null!; + GC.SuppressFinalize(this); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs index 66429dfbe..15114a4c3 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs @@ -156,7 +156,7 @@ public void Barrier_ReleasesNextCommandInTicketOrderWhenTheBarrierCompletes() _scheduler.BeginParticipating(state, _probe, OwnerA, second, UiTurnMode.DesktopExclusive); _probe.Alive.Remove((first.ProcessId, first.StartTicksUtc)); - _scheduler.CompleteCommand(state, _probe, first, UiOwnerKind.Explicit, renewGrace: true); + _scheduler.CompleteCommand(state, _probe, first, OwnerA, renewGrace: true); // 'third' was admitted before 'second', so it owns the smaller ticket and runs first. Assert.AreEqual(UiCommandStatus.Running, @@ -188,7 +188,7 @@ public void BeginObserve_CurrentOwner_PinsTheTurnAndRunsImmediately() var actor = Participant(100); _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); - _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: true); + _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true); var observation = Participant(101, "ui inspect"); var result = _scheduler.BeginObserve(state, _probe, OwnerA, observation); @@ -211,14 +211,14 @@ public void CompletingAnObservation_StartsAFreshGrace() var actor = Participant(100); _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); - _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: true); + _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true); _clock.Advance(3_000); var observation = Participant(101, "ui inspect"); _scheduler.BeginObserve(state, _probe, OwnerA, observation); _clock.Advance(5_000); _probe.Alive.Remove((observation.ProcessId, observation.StartTicksUtc)); - _scheduler.CompleteCommand(state, _probe, observation, UiOwnerKind.Explicit, renewGrace: true); + _scheduler.CompleteCommand(state, _probe, observation, OwnerA, renewGrace: true); _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs - 100); _scheduler.Normalize(state, _probe); @@ -234,7 +234,7 @@ public void IdleTurn_ExpiresAfterExactlyFourSeconds() var actor = Participant(100); _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); - _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: true); + _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true); _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs - 1); _scheduler.Normalize(state, _probe); @@ -284,7 +284,7 @@ public void Handoff_PromotesOldestLiveWaiterAndIncrementsTurnId() _scheduler.BeginParticipating(state, _probe, OwnerB, waiterB, UiTurnMode.DesktopExclusive); _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); - _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: true); + _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true); _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs); _scheduler.Normalize(state, _probe); @@ -310,7 +310,7 @@ public void Handoff_SkipsDeadWaitersAndPicksTheOldestLiveTicket() _probe.Alive.Remove((deadWaiter.ProcessId, deadWaiter.StartTicksUtc)); _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); - _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: true); + _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true); _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs); _scheduler.Normalize(state, _probe); @@ -326,7 +326,7 @@ public void ExpiredOwner_ReRegisteringGoesToTheBackOfTheQueue() _scheduler.BeginParticipating(state, _probe, OwnerB, Participant(200), UiTurnMode.DesktopExclusive); _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); - _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: true); + _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true); _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs); var result = _scheduler.BeginParticipating( @@ -349,7 +349,7 @@ public void NonCancelledFailure_RenewsTheGrace() // A command that ran and returned a non-zero exit code still renews: the workflow is alive and // its next command is likely a retry. - _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: true); + _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true); _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs - 1); _scheduler.Normalize(state, _probe); @@ -364,7 +364,7 @@ public void Cancellation_DoesNotRenewTheGrace() _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); - _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: false); + _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: false); Assert.IsNull(state.Owner, "a cancelled command leaves no reservation behind"); } @@ -380,7 +380,7 @@ public void AnonymousOwner_ReceivesNoGraceAndHandsOffImmediately() _scheduler.BeginParticipating(state, _probe, OwnerB, waiter, UiTurnMode.DesktopExclusive); _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); - _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Anonymous, renewGrace: true); + _scheduler.CompleteCommand(state, _probe, actor, anonymous, renewGrace: true); Assert.AreEqual(OwnerB.Key, state.Owner!.Key, "a one-command owner has no shell that could issue a follow-up, so it hands off at once"); @@ -394,7 +394,7 @@ public void ParentDerivedOwner_ReleasesImmediatelyWhenItsShellIsGone() var actor = Participant(100); _scheduler.BeginParticipating(state, _probe, parentOwner, actor, UiTurnMode.DesktopExclusive); _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); - _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Parent, renewGrace: true); + _scheduler.CompleteCommand(state, _probe, actor, parentOwner, renewGrace: true); _probe.DeadParents.Add((900, 900)); _scheduler.Normalize(state, _probe); @@ -410,7 +410,7 @@ public void ParentDerivedOwner_KeepsGraceWhenParentLivenessIsUnknown() var actor = Participant(100); _scheduler.BeginParticipating(state, _probe, parentOwner, actor, UiTurnMode.DesktopExclusive); _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); - _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Parent, renewGrace: true); + _scheduler.CompleteCommand(state, _probe, actor, parentOwner, renewGrace: true); _probe.UnknownParents.Add((900, 900)); _scheduler.Normalize(state, _probe); @@ -450,7 +450,7 @@ public void SuspendedLiveWaiter_IsNeverPrunedAndKeepsTheHeadOfTheQueue() _scheduler.BeginParticipating(state, _probe, ownerC, Participant(300), UiTurnMode.DesktopExclusive); _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); - _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: false); + _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: false); _clock.Advance(60_000); _scheduler.Normalize(state, _probe); @@ -528,7 +528,7 @@ public void PromotedOwner_AbsorbsItsOtherQueuedCommandsInTicketOrder() _scheduler.BeginParticipating(state, _probe, OwnerB, secondB, UiTurnMode.DesktopExclusive); _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); - _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: false); + _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: false); Assert.AreEqual(OwnerB.Key, state.Owner!.Key); Assert.AreEqual(0, state.Waiters.Count, @@ -559,7 +559,7 @@ public void PromotedOwner_AbsorbsOnlyItsContiguousPrefixAtTheQueueHead() _scheduler.BeginParticipating(state, _probe, ownerC, firstC, UiTurnMode.DesktopExclusive); _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); - _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: false); + _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: false); Assert.AreEqual(OwnerB.Key, state.Owner!.Key); Assert.IsNotNull(InteractiveDesktopScheduler.FindOwnerCommand(state, firstB)); @@ -586,7 +586,7 @@ public void PromotedOwner_StopsAbsorbingAtTheFirstDifferentOwner() _scheduler.BeginParticipating(state, _probe, OwnerB, secondB, UiTurnMode.DesktopExclusive); _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); - _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: false); + _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: false); Assert.AreEqual(OwnerB.Key, state.Owner!.Key); Assert.IsNotNull(InteractiveDesktopScheduler.FindOwnerCommand(state, firstB), @@ -598,7 +598,7 @@ public void PromotedOwner_StopsAbsorbingAtTheFirstDifferentOwner() // And C really does get the turn next, ahead of B's second command. _probe.Alive.Remove((firstB.ProcessId, firstB.StartTicksUtc)); - _scheduler.CompleteCommand(state, _probe, firstB, UiOwnerKind.Explicit, renewGrace: false); + _scheduler.CompleteCommand(state, _probe, firstB, OwnerA, renewGrace: false); Assert.AreEqual("cccc", state.Owner!.Key); } @@ -611,7 +611,7 @@ public void Escalation_ConvertsTheObservationInPlaceWithANewTicket() var actor = Participant(100); _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); - _scheduler.CompleteCommand(state, _probe, actor, UiOwnerKind.Explicit, renewGrace: true); + _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true); var screenshot = Participant(101, "ui screenshot"); _scheduler.BeginObserve(state, _probe, OwnerA, screenshot); @@ -660,6 +660,126 @@ public void Tickets_AreGloballyMonotonicAcrossOwnersAndModes() Assert.AreEqual(seen.Count, seen.Distinct().Count()); } + // ---------------------------------------------------------------- prior-boot deadline recovery + + [TestMethod] + public void PriorBootDeadline_ExpiresImmediatelyInsteadOfStrandingTheTurn() + { + // Environment.TickCount64 restarts at reboot. A state file written after days of uptime carries + // a deadline far beyond the new uptime; without clamping, the owner that died with the previous + // boot would hold the desktop until the machine had been up just as long again. + var state = InteractiveDesktopState.CreateFresh(); + state.Owner = new OwnerRecord { Kind = UiOwnerKind.Explicit, Key = OwnerA.Key }; + state.TurnId = 7; + state.IdleExpiresTick64 = _clock.NowTicks64 + (long)TimeSpan.FromDays(5).TotalMilliseconds; + + var changed = _scheduler.Normalize(state, _probe); + + Assert.IsTrue(changed, "clamping a prior-boot deadline is a state change that must be published"); + Assert.IsNull(state.Owner, "the stranded turn must be released on the very next normalization"); + } + + [TestMethod] + public void PriorBootDeadline_PromotesAWaitingOwnerImmediately() + { + var state = InteractiveDesktopState.CreateFresh(); + state.Owner = new OwnerRecord { Kind = UiOwnerKind.Explicit, Key = OwnerA.Key }; + state.TurnId = 7; + state.NextTicket = 1; + state.IdleExpiresTick64 = _clock.NowTicks64 + (long)TimeSpan.FromDays(5).TotalMilliseconds; + + var waiter = Participant(300); + _scheduler.BeginParticipating(state, _probe, OwnerB, waiter, UiTurnMode.DesktopExclusive); + + Assert.AreEqual(OwnerB.Key, state.Owner!.Key, "the waiting owner takes the abandoned turn at once"); + Assert.AreEqual(UiCommandStatus.Running, + InteractiveDesktopScheduler.FindOwnerCommand(state, waiter)!.Status); + } + + [TestMethod] + public void ADeadlineWithinTheGraceIsNotTreatedAsPriorBoot() + { + var state = InteractiveDesktopState.CreateFresh(); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true); + + // Exactly the deadline a normal completion writes: now + IdleGraceMs. It must survive. + _scheduler.Normalize(state, _probe); + + Assert.IsNotNull(state.Owner, "an ordinary in-grace deadline must never be mistaken for a stale one"); + } + + // -------------------------------------------------------- the idle deadline belongs to the owner + + [TestMethod] + public void AForeignCompletionCannotRevokeTheCurrentOwnersGrace() + { + // An anonymous global waiter that gets cancelled or fails completes under its own identity. + // Setting the deadline from that path would hand owner B's turn away instantly. + var state = InteractiveDesktopState.CreateFresh(); + var ownerBActor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerB, ownerBActor, UiTurnMode.DesktopExclusive); + _probe.Alive.Remove((ownerBActor.ProcessId, ownerBActor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, ownerBActor, OwnerB, renewGrace: true); + + var graceBefore = state.IdleExpiresTick64; + Assert.IsNotNull(state.Owner); + + var anonymous = new UiOwnerIdentity(UiOwnerKind.Anonymous, "anon", null, null); + var stranger = Participant(200); + _scheduler.BeginParticipating(state, _probe, anonymous, stranger, UiTurnMode.DesktopExclusive); + _probe.Alive.Remove((stranger.ProcessId, stranger.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, stranger, anonymous, renewGrace: false); + + Assert.AreEqual(OwnerB.Key, state.Owner!.Key, "owner B must still hold the turn"); + Assert.AreEqual(graceBefore, state.IdleExpiresTick64, + "a non-owner completion must not touch the current owner's idle deadline"); + } + + [TestMethod] + public void AForeignCompletionCannotExtendTheCurrentOwnersGrace() + { + var state = InteractiveDesktopState.CreateFresh(); + var ownerBActor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerB, ownerBActor, UiTurnMode.DesktopExclusive); + _probe.Alive.Remove((ownerBActor.ProcessId, ownerBActor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, ownerBActor, OwnerB, renewGrace: true); + var graceBefore = state.IdleExpiresTick64; + + _clock.Advance(1_000); + + // A queued command belonging to a *different* owner finishes normally. + var stranger = Participant(200); + _scheduler.BeginParticipating(state, _probe, OwnerA, stranger, UiTurnMode.DesktopExclusive); + _probe.Alive.Remove((stranger.ProcessId, stranger.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, stranger, OwnerA, renewGrace: true); + + Assert.AreEqual(graceBefore, state.IdleExpiresTick64, + "only the current owner's own commands may extend its grace"); + } + + [TestMethod] + public void TheCurrentOwnersOwnCompletionStillRenewsItsGrace() + { + var state = InteractiveDesktopState.CreateFresh(); + var first = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, first, UiTurnMode.DesktopExclusive); + _probe.Alive.Remove((first.ProcessId, first.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, first, OwnerA, renewGrace: true); + var firstDeadline = state.IdleExpiresTick64; + + _clock.Advance(1_000); + var second = Participant(101); + _scheduler.BeginParticipating(state, _probe, OwnerA, second, UiTurnMode.DesktopExclusive); + _probe.Alive.Remove((second.ProcessId, second.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, second, OwnerA, renewGrace: true); + + Assert.IsTrue(state.IdleExpiresTick64 > firstDeadline, + "a burst from the owner keeps renewing its own grace"); + } + private sealed class FakeClock : IMonotonicClock { private long _ticks = 1_000_000; diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs index 91c2a88d1..e7632b512 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs @@ -252,6 +252,15 @@ public void StateRemainsReadableWhileTheActiveLockIsHeld() // active.lock guards the desktop, not the metadata: a queued command must still be able to read // and update state while another process is mid-gesture. _paths.EnsureDirectories(); + + // A process holding active.lock has necessarily registered first, so state already exists. + // (Missing state while active.lock is held is a different case entirely — an external deletion — + // and is deliberately fail-closed; see MissingStateWhileTheActiveLockIsHeldFailsClosed.) + using (var seedLock = _store.AcquireStateLock(CancellationToken.None)) + { + _store.Publish(InteractiveDesktopState.CreateFresh()); + } + using var activeLock = new FileStream( _paths.ActiveLockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); @@ -263,8 +272,73 @@ public void StateRemainsReadableWhileTheActiveLockIsHeld() Assert.IsFalse(_store.IsActiveLockFree()); } - // --------------------------------------------------------------------------- participant leases + // ------------------------------------------------------- missing state must not mint a new owner + + [TestMethod] + public void MissingStateWithNoLivenessEvidenceIsATrueFirstUse() + { + _paths.EnsureDirectories(); + Assert.IsFalse(File.Exists(_paths.StatePath), "the test starts with no state file at all"); + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var read = _store.Read(); + + Assert.IsNotNull(read.State, "the ordinary first command on a desktop starts from a fresh document"); + Assert.IsNull(read.State!.Owner); + Assert.IsFalse(read.RecoveredFromCorruption, "a first use is not a recovery"); + } + + [TestMethod] + public void MissingStateWhileAParticipantIsLiveFailsClosed() + { + // An external deletion — AV, manual cleanup, a stray rmdir — while a recording or queued waiter + // is still live. Rebuilding here would mint a second owner for the same desktop. + _paths.EnsureDirectories(); + using var lease = _participants.OpenLease( + _inspector.CurrentProcessId, _inspector.CurrentProcessStartTicksUtc); + + Assert.IsFalse(File.Exists(_paths.StatePath)); + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var ex = Assert.ThrowsExactly(() => _store.Read()); + Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code); + } + [TestMethod] + public void MissingStateWhileTheActiveLockIsHeldFailsClosed() + { + _paths.EnsureDirectories(); + using var activeLock = new FileStream( + _paths.ActiveLockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + + Assert.IsFalse(File.Exists(_paths.StatePath)); + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var ex = Assert.ThrowsExactly(() => _store.Read()); + Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code); + } + + // ------------------------------------------------------------------ prior-boot deadline handling + + [TestMethod] + public void PublishSurvivesAnUnrepresentableIdleDeadline() + { + // Environment.TickCount64 resets on reboot, so a state file written after long uptime can carry + // a deadline far beyond the current uptime. Converting that delta to a UTC diagnostic overflows + // DateTime; a diagnostic string must never be able to fail a publish. + _paths.EnsureDirectories(); + var state = InteractiveDesktopState.CreateFresh(); + state.IdleExpiresTick64 = long.MaxValue; + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + _store.Publish(state); + + Assert.IsNull(state.DiagnosticIdleExpiresUtc, + "an unrepresentable deadline is omitted from diagnostics rather than throwing"); + Assert.IsTrue(File.Exists(_paths.StatePath), "the publish itself must still succeed"); + } + + // --------------------------------------------------------------------------- participant leases [TestMethod] public void Lease_IsHeldWhileOpenAndDeletedOnClose() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiaTestFixture.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiaTestFixture.cs index 621ffc048..2cdf7730f 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiaTestFixture.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiaTestFixture.cs @@ -62,6 +62,13 @@ internal sealed class UiaTestFixture : IDisposable public TrackBar Slider { get; private set; } = null!; public TabControl Tabs { get; private set; } = null!; public MenuStrip Menu { get; private set; } = null!; + + /// + /// The "File" menu. Its drop-down is genuine transient UI: Windows dismisses it when another + /// window takes the foreground, which is what makes it a faithful subject for the cooperative-turn + /// acceptance tests (issue #764 §18.3). + /// + public ToolStripMenuItem FileMenu { get; private set; } = null!; public TreeView Tree { get; private set; } = null!; public Panel HScrollPanel { get; private set; } = null!; public NumericUpDown Spinner { get; private set; } = null!; @@ -505,9 +512,10 @@ private void BuildExtraControls(Form form) }; Menu = new MenuStrip { Name = "menuMain", AccessibleName = "Main Menu" }; - var fileMenu = new ToolStripMenuItem("File") { Name = "menuFile" }; - fileMenu.DropDownItems.Add(new ToolStripMenuItem("Open") { Name = "menuOpen" }); - Menu.Items.Add(fileMenu); + // AccessibleName (not Name) is what UIA exposes, so an external winapp.exe can select this item. + FileMenu = new ToolStripMenuItem("File") { Name = "menuFile", AccessibleName = "File Menu" }; + FileMenu.DropDownItems.Add(new ToolStripMenuItem("Open") { Name = "menuOpen", AccessibleName = "Open Item" }); + Menu.Items.Add(FileMenu); DupButton = new Button { @@ -646,6 +654,70 @@ public T OnUiThread(Func func) /// Native window handle of a hosted control (read on the UI thread). public nint HandleOf(Control control) => OnUiThread(() => control.Handle); + /// + /// Opens the File drop-down, producing real transient UI on the desktop, and waits for it to be + /// shown. Used by the cooperative-turn acceptance tests (issue #764 §18.3). + /// + /// + /// A drop-down only appears on an active window, and under load the activation that + /// ShowDropDown relies on can be dropped. Forcing the foreground and retrying keeps this + /// setup step deterministic, so a coordination test never fails for an unrelated reason. Throws + /// rather than returning quietly: a test that proceeds without its transient UI would assert + /// nothing meaningful. + /// + public void OpenFileMenu() + { + var deadline = Environment.TickCount64 + 8000; + while (Environment.TickCount64 < deadline) + { + DesktopTestHelpers.ForceForeground(Hwnd); + OnUiThread(() => + { + Form.Activate(); + Form.BringToFront(); + FileMenu.ShowDropDown(); + }); + + // ShowDropDown posts the popup; give the menu window a moment to actually appear so a test + // never asserts against a drop-down that has been requested but not yet realized. + var attemptDeadline = Environment.TickCount64 + 1000; + while (Environment.TickCount64 < attemptDeadline) + { + if (IsFileMenuOpen) + { + return; + } + + Thread.Sleep(25); + } + } + + throw new TimeoutException( + "The File drop-down did not open within 8s, so the fixture never presented the transient UI under test."); + } + + /// Whether the File drop-down is currently displayed. + public bool IsFileMenuOpen => OnUiThread(() => FileMenu.DropDown.Visible); + + /// + /// Closes the File drop-down and leaves menu mode. + /// + /// + /// An open drop-down puts the thread into Windows menu mode, which captures keyboard input for the + /// whole desktop. Disposing the form without closing it can leave that capture behind and silently + /// swallow input in whatever runs next, so tests that open the menu must close it. + /// + public void CloseFileMenu() + { + OnUiThread(() => + { + FileMenu.DropDown.Close(); + // Explicitly leave menu mode; closing only the drop-down can leave the strip focused. + Menu.Items.OfType().ToList().ForEach(item => item.DropDown.Close()); + Form.Focus(); + }); + } + /// Screen-space center point of a hosted control (read on the UI thread). public (int X, int Y) ScreenCenterOf(Control control) => OnUiThread(() => { diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiClickCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiClickCommand.cs index 3dbe7c61d..1fbd629be 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiClickCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiClickCommand.cs @@ -225,7 +225,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiDragCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiDragCommand.cs index 349a99a05..4815e2ba0 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiDragCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiDragCommand.cs @@ -283,7 +283,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiFocusCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiFocusCommand.cs index 8c9584b51..79d7f7321 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiFocusCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiFocusCommand.cs @@ -125,7 +125,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiHoverCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiHoverCommand.cs index d2282fde5..f814a0e27 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiHoverCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiHoverCommand.cs @@ -194,7 +194,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiInvokeCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiInvokeCommand.cs index 951363ab3..6bac95578 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiInvokeCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiInvokeCommand.cs @@ -158,7 +158,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiPenCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiPenCommand.cs index 2d13376e6..4589cb453 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiPenCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiPenCommand.cs @@ -385,7 +385,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn errorOut: parseResult.InvocationConfiguration.Error); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json, parseResult.InvocationConfiguration.Error); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs index 681bf512f..7548ff780 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs @@ -127,7 +127,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; @@ -280,7 +280,7 @@ private async Task CaptureMultipleWindows( // recording a per-window failure here would publish a partially observational image. throw; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { logger.LogDebug("Failed to capture HWND {Hwnd}: {Error}", w.Hwnd, ex.Message); windowDetails.Add(new UiScreenshotWindowInfo diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiScrollCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiScrollCommand.cs index 24a15ee49..d563a903a 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiScrollCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiScrollCommand.cs @@ -260,7 +260,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiSendKeysCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiSendKeysCommand.cs index 9ff74d5da..ac69767f8 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiSendKeysCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiSendKeysCommand.cs @@ -472,7 +472,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiTouchCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiTouchCommand.cs index 74a7f61b2..0b74d06b3 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiTouchCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiTouchCommand.cs @@ -467,7 +467,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn errorOut: parseResult.InvocationConfiguration.Error); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json, parseResult.InvocationConfiguration.Error); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/UiCoordinatedAction.cs b/src/winapp-CLI/WinApp.Cli/Helpers/UiCoordinatedAction.cs index 0bd76b4de..c709a6ee3 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/UiCoordinatedAction.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/UiCoordinatedAction.cs @@ -48,6 +48,21 @@ internal abstract class UiCoordinatedAction(IInteractiveDesktopLock coordinator, /// The command's work. Runs under the workflow turn. protected abstract Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken); + /// + /// Whether belongs to coordination and must escape a handler's catch-all. + /// + /// + /// Handler bodies call into coordination — and + /// — from inside their broad + /// catch (Exception). Letting that catch win would be doubly wrong: the user would see + /// internal_error instead of cancelled or the real coordination code, and the + /// coordinator would see a normal body completion and renew the owner's idle grace on a command + /// that never actually ran. Handlers therefore filter their catch-all with + /// when (!UiCoordinatedAction.IsCoordinationFault(ex)). + /// + internal static bool IsCoordinationFault(Exception ex) + => ex is OperationCanceledException or UiCoordinationException; + public sealed override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) { if (Preflight(parseResult) is { } preflightExitCode) diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs index cd1218c42..28a23aee8 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs @@ -18,9 +18,22 @@ namespace WinApp.Cli.Services.InteractiveDesktop; internal interface IDesktopSection { /// - /// Acquires active.lock for the duration of the returned scope. Reentrant within a process: - /// a nested enter increments a refcount and releases the lock only when the outermost scope closes. + /// Acquires active.lock for the duration of the returned scope. /// + /// + /// + /// Not reentrant. Every enter takes active.lock, and concurrent enters + /// within one execution are serialized. A nested enter inside an already-held scope therefore + /// deadlocks against itself; code that needs the desktop from within a section must reuse the + /// enclosing scope or be refactored so the enters are sequential. + /// + /// + /// This is deliberate. A process- or execution-wide refcount would let two unrelated concurrent + /// tasks share one acquisition: the second would see a non-zero count and proceed without + /// holding the lock, driving the desktop outside the exclusive section. Cheap reentrancy is not + /// worth silently losing the guarantee the section exists to provide. + /// + /// Task EnterAsync(CancellationToken cancellationToken); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs index 19a825516..caaca6a62 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs @@ -207,7 +207,12 @@ public async Task RunAsync( try { var exitCode = await body(this, cancellationToken).ConfigureAwait(false); - bodyCompletedNormally = true; + + // A body that swallowed its own cancellation must not be treated as a normal + // completion: renewing the idle grace for a command that never really ran would hold + // the desktop against every other owner. The handler catch-all is filtered to let + // cancellation escape, and this is the backstop if one ever is not. + bodyCompletedNormally = !cancellationToken.IsCancellationRequested; return exitCode; } finally @@ -301,6 +306,26 @@ private void RegisterObserve(InteractiveDesktopState state) // liveness proof (spec §9 rule 5). _lease = coordinator._participants.OpenLease(participant.ProcessId, participant.StartTicksUtc); var admission = coordinator._scheduler.BeginObserve(state, _probe, owner, participant); + + if (admission.Admission == UiAdmission.Detached) + { + // BeginObserve re-normalizes, so ownership can lapse between the check above and here — + // an expiring grace, or a dead parent reservation being released. Nothing was added to + // the state, so the lease must go too: keeping it open would publish liveness for a + // participant with no entry, and Complete would later adjust a foreign owner's turn. + _lease.Dispose(); + _lease = null; + _detached = true; + _turnAction = UiTurnAction.Detached; + + if (changed || _recoveredFromCorruption) + { + coordinator._store.Publish(state); + } + + return; + } + _turnAction = admission.TurnAction; coordinator._store.Publish(state); } @@ -528,7 +553,7 @@ private void Complete(bool renewGrace) var read = coordinator._store.Read(); if (read.State is { } state) { - coordinator._scheduler.CompleteCommand(state, _probe, participant, owner.Kind, renewGrace); + coordinator._scheduler.CompleteCommand(state, _probe, participant, owner, renewGrace); coordinator._store.Publish(state); } } diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs index cfed58d42..8d5dcd8a9 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs @@ -83,7 +83,8 @@ internal sealed class InteractiveDesktopScheduler(IMonotonicClock clock) /// when anything changed and the state must be published. public bool Normalize(InteractiveDesktopState state, ICoordinationLivenessProbe probe) { - var changed = PruneDeadParticipants(state, probe); + var changed = ClampStaleDeadline(state); + changed |= PruneDeadParticipants(state, probe); changed |= ReleaseDeadParentReservation(state, probe); changed |= ExpireIdleTurn(state); changed |= PromoteOldestWaiter(state, probe); @@ -92,6 +93,28 @@ public bool Normalize(InteractiveDesktopState state, ICoordinationLivenessProbe return changed; } + /// + /// Treats an idle deadline further out than the grace itself as already expired. + /// + /// + /// The only writer sets now + , so a larger value cannot have been + /// produced during this boot. Windows resets on restart, so a + /// state file written after days of uptime carries a deadline far beyond the new uptime and would + /// otherwise pin the turn to an owner that died with the previous boot — for days. Clamping to + /// now lets release it on the very next normalization. + /// + private bool ClampStaleDeadline(InteractiveDesktopState state) + { + var now = clock.NowTicks64; + if (state.IdleExpiresTick64 <= now + IdleGraceMs) + { + return false; + } + + state.IdleExpiresTick64 = now; + return true; + } + /// /// Section 10.2: registers a current-owner observation so it pins and renews the turn, or reports /// that a non-owner observation should run detached. @@ -221,26 +244,36 @@ public bool EscalateObserveToExclusive( /// completion renews the grace; an anonymous owner gets none and hands off immediately; /// cancellation never renews. /// + /// + /// The deadline belongs to whoever currently holds the turn, so it is only touched when + /// is that owner. Without this check a process finishing under a + /// different identity — a global waiter that was cancelled, or a command whose owner already lost + /// the turn — would rewrite a stranger's grace: an anonymous completion would revoke it outright + /// (deadline = now) and a normal one would silently extend it. + /// public void CompleteCommand( InteractiveDesktopState state, ICoordinationLivenessProbe probe, UiParticipantIdentity participant, - UiOwnerKind ownerKind, + UiOwnerIdentity owner, bool renewGrace) { RemoveParticipantEntries(state, participant); - if (renewGrace && ownerKind != UiOwnerKind.Anonymous) - { - // Stored unconditionally but only consulted once OwnerCommands is empty, so a long-running - // sibling command is unaffected. - state.IdleExpiresTick64 = clock.NowTicks64 + IdleGraceMs; - } - else if (ownerKind == UiOwnerKind.Anonymous) + if (state.Owner is not null && OwnerMatches(state.Owner, owner)) { - // A one-command owner has no shell that could issue a follow-up, so holding the desktop for - // another four seconds would only delay everyone else. - state.IdleExpiresTick64 = clock.NowTicks64; + if (renewGrace && owner.Kind != UiOwnerKind.Anonymous) + { + // Stored unconditionally but only consulted once OwnerCommands is empty, so a long-running + // sibling command is unaffected. + state.IdleExpiresTick64 = clock.NowTicks64 + IdleGraceMs; + } + else if (owner.Kind == UiOwnerKind.Anonymous) + { + // A one-command owner has no shell that could issue a follow-up, so holding the desktop for + // another four seconds would only delay everyone else. + state.IdleExpiresTick64 = clock.NowTicks64; + } } Normalize(state, probe); diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopStateStore.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopStateStore.cs index 745df3fbb..b262970b7 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopStateStore.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopStateStore.cs @@ -128,7 +128,16 @@ public StateReadResult Read() if (!fileExists) { - // No file at all is the ordinary first command on this desktop: nothing to recover. + // Missing state is the ordinary first command on this desktop — but only when nothing on + // disk still proves a turn is in progress. An external deletion (AV, manual cleanup, a + // stray rmdir) while a recording or a queued waiter is live would otherwise mint a second + // owner for the same desktop and let two agents drive it at once. + if (HasLiveTurnEvidence()) + { + logger.LogDebug("UI coordination state is missing while a participant is still live; failing closed."); + throw CannotRebuildState("missing"); + } + return new StateReadResult(InteractiveDesktopState.CreateFresh(), false, false); } @@ -267,12 +276,9 @@ private static bool IsStructurallyValid(InteractiveDesktopState state) /// private StateReadResult RecoverCorruptState() { - if (!IsActiveLockFree() || participants.AnyLiveParticipant()) + if (HasLiveTurnEvidence()) { - throw new UiCoordinationException( - UiCoordinationErrorCodes.Unavailable, - "UI coordination state is unreadable and another winapp ui process is still active, so it cannot be safely rebuilt.", - "Wait for the other winapp ui commands to finish (or stop them) and retry."); + throw CannotRebuildState("unreadable"); } var quarantinePath = System.IO.Path.Combine( @@ -297,12 +303,52 @@ private StateReadResult RecoverCorruptState() return new StateReadResult(InteractiveDesktopState.CreateFresh(), false, RecoveredFromCorruption: true); } + /// + /// Whether anything on disk still proves a turn is in progress: a held active.lock, or any + /// live participant lease. Checked before rebuilding state that is missing or unreadable, so a + /// rebuild can never invent a second owner alongside a running one. + /// + private bool HasLiveTurnEvidence() + => !IsActiveLockFree() || participants.AnyLiveParticipant(); + + private static UiCoordinationException CannotRebuildState(string condition) + => new( + UiCoordinationErrorCodes.Unavailable, + $"UI coordination state is {condition} and another winapp ui process is still active, so it cannot be safely rebuilt.", + "Wait for the other winapp ui commands to finish (or stop them) and retry."); + + /// + /// Human-readable idle deadline, written for diagnostics only and never read back. + /// + /// + /// The delta between the stored deadline and the current uptime is unbounded in principle — a + /// state file written before a reboot carries a deadline from the previous boot — and + /// throws once the result leaves the representable + /// range. A diagnostic string must never be able to fail a publish, so an unrepresentable value + /// is simply omitted. + /// + private string? TryFormatIdleExpiry(InteractiveDesktopState state) + { + if (state.IdleExpiresTick64 <= 0) + { + return null; + } + + try + { + return clock.UtcNow + .AddMilliseconds(state.IdleExpiresTick64 - clock.NowTicks64) + .ToString("O", CultureInfo.InvariantCulture); + } + catch (ArgumentOutOfRangeException) + { + return null; + } + } + public void Publish(InteractiveDesktopState state) { - state.DiagnosticIdleExpiresUtc = state.IdleExpiresTick64 > 0 - ? clock.UtcNow.AddMilliseconds(state.IdleExpiresTick64 - clock.NowTicks64) - .ToString("O", CultureInfo.InvariantCulture) - : null; + state.DiagnosticIdleExpiresUtc = TryFormatIdleExpiry(state); var payload = JsonSerializer.SerializeToUtf8Bytes( state, InteractiveDesktopJsonContext.Default.InteractiveDesktopState); From 18aaa9a89e770869e6d18ba9db76b4bb8893dec1 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Tue, 18 Aug 2026 01:03:25 -0700 Subject: [PATCH 03/29] Correct recording cancellation semantics and harden coordination boundaries Recording/cancellation correction (reviewer): - bodyCompletedNormally now means "the body returned rather than threw", not "the token is unset". `ui record` observes Ctrl+C deliberately, finalizes the MP4 and returns success; per spec that is a completed command and must renew the owner's grace. The earlier generic token check wrongly denied renewal. Commands that must not renew let cancellation propagate instead, which is what the handler catch-all filters are for. - Added coverage on both sides: a queued screenshot escalation that is cancelled removes its ticket and renews nothing, while an active recording that finalizes on cancellation does renew. - Added handler-level coverage that UiScreenshotCommand lets a cancelled or refused escalation escape instead of flattening it to internal_error. Additional hardening: 1. InteractiveDesktopPaths.IsCurrentUserOnly now also requires the directory owner to be the current user: an owner implicitly holds WRITE_DAC and can rewrite even a protected, current-user-only DACL. The repair path re-reads and fails closed if ownership could not actually be taken. 2. UiSendKeysCommand now runs the same in-section HWND/PID validation as click and invoke. Without --target it could act on a recycled session handle; with --target the re-resolved element is verified to still belong to the session process before foreground, focus, post or send. 3. Lock acquisition no longer retries every IOException. Only ERROR_SHARING_VIOLATION (32) and ERROR_LOCK_VIOLATION (33) are contention; anything else fails desktop_coordination_unavailable instead of spinning forever on a failure that will never clear. ParticipantRegistry keeps its fail-safe-as-live probe (documented why it differs) but no longer reports a vanished lease as held. 4. Screenshot blank-retry foreground now goes through IDesktopForegroundService rather than a direct PInvoke.SetForegroundWindow seam, so every foreground change has one choke point. Added a guard test that scans production sources for direct SetForegroundWindow/ShowWindow calls outside that service, plus a self-check that the guard's pattern still matches. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../DesktopPrimitiveGuardTests.cs | 92 ++++++++++++ .../FakeDesktopForegroundService.cs | 8 +- .../FakeInteractiveDesktopLock.cs | 13 ++ .../InteractiveDesktopLockTests.cs | 39 ++--- .../InteractiveDesktopStoreTests.cs | 141 ++++++++++++++++++ .../UiAutomationServicePureTests.cs | 22 +-- .../UiCommandTests.Coordination.cs | 107 +++++++++++++ .../WinApp.Cli/Commands/UiSendKeysCommand.cs | 12 ++ .../InteractiveDesktop/CoordinationLockIo.cs | 37 +++++ .../InteractiveDesktopLock.cs | 18 ++- .../InteractiveDesktopPaths.cs | 31 +++- .../InteractiveDesktopStateStore.cs | 7 +- .../InteractiveDesktop/ParticipantRegistry.cs | 10 ++ .../UiAutomationService.Screenshot.cs | 18 +-- .../Services/UiAutomationService.cs | 1 - 15 files changed, 499 insertions(+), 57 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/DesktopPrimitiveGuardTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/CoordinationLockIo.cs diff --git a/src/winapp-CLI/WinApp.Cli.Tests/DesktopPrimitiveGuardTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/DesktopPrimitiveGuardTests.cs new file mode 100644 index 000000000..1fcf3f4ef --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/DesktopPrimitiveGuardTests.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Text.RegularExpressions; + +namespace WinApp.Cli.Tests; + +/// +/// Guards the coordination invariant that every desktop-wide foreground or window-state change goes +/// through IDesktopForegroundService (issue #764). +/// +/// +/// The value of coordination is that a reviewer can answer "what can move the foreground?" by reading +/// one file. A single direct PInvoke.SetForegroundWindow added later — even one that happens to +/// sit inside a desktop section today — silently reopens that question, and the next refactor may move +/// it outside the section. Asserting over the source keeps the invariant true by construction rather +/// than by convention. +/// +[TestClass] +public class DesktopPrimitiveGuardTests +{ + /// The one file allowed to call the primitives directly. + private const string ForegroundServiceFile = "IDesktopForegroundService.cs"; + + private static readonly Regex s_directPrimitiveCall = new( + @"PInvoke\s*\.\s*(SetForegroundWindow|ShowWindow)\s*\(", + RegexOptions.Compiled); + + [TestMethod] + public void NoProductionCodeCallsForegroundPrimitivesOutsideTheForegroundService() + { + var productionRoot = FindProductionRoot(); + var offenders = new List(); + + foreach (var file in Directory.EnumerateFiles(productionRoot, "*.cs", SearchOption.AllDirectories)) + { + // Skip build output: CsWin32 emits the P/Invoke declarations themselves under obj\. + var relative = Path.GetRelativePath(productionRoot, file); + if (relative.Contains($"obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) + || relative.Contains($"bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) + || string.Equals(Path.GetFileName(file), ForegroundServiceFile, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var text = File.ReadAllText(file); + foreach (Match match in s_directPrimitiveCall.Matches(text)) + { + var line = text.Take(match.Index).Count(c => c == '\n') + 1; + offenders.Add($"{relative}({line}): {match.Value.Trim()}"); + } + } + + Assert.AreEqual( + 0, + offenders.Count, + "Desktop foreground and window-state changes must go through IDesktopForegroundService so " + + "they are coordinated and reviewable in one place. Offending call sites:\n" + + string.Join("\n", offenders)); + } + + [TestMethod] + public void TheGuardActuallyMatchesADirectPrimitiveCall() + { + // A source-scanning assertion is worthless if its pattern silently stops matching, so pin the + // pattern against the exact shapes it is meant to catch. + Assert.IsTrue(s_directPrimitiveCall.IsMatch("Windows.Win32.PInvoke.SetForegroundWindow(hwnd);")); + Assert.IsTrue(s_directPrimitiveCall.IsMatch("PInvoke.ShowWindow(hwnd, SHOW_WINDOW_CMD.SW_RESTORE);")); + Assert.IsTrue(s_directPrimitiveCall.IsMatch("PInvoke . SetForegroundWindow (")); + Assert.IsFalse(s_directPrimitiveCall.IsMatch("_desktopForeground.RequestForeground(hwnd);")); + Assert.IsFalse(s_directPrimitiveCall.IsMatch("// mentions SetForegroundWindow in prose")); + } + + /// Locates src\winapp-CLI\WinApp.Cli by walking up from the test binaries. + private static string FindProductionRoot() + { + var directory = AppContext.BaseDirectory; + for (var i = 0; i < 8 && directory is not null; i++) + { + var candidate = Path.Combine(directory, "src", "winapp-CLI", "WinApp.Cli"); + if (Directory.Exists(candidate)) + { + return candidate; + } + + directory = Path.GetDirectoryName(directory.TrimEnd(Path.DirectorySeparatorChar)); + } + + throw new AssertInconclusiveException( + "The WinApp.Cli production sources were not found, so the desktop-primitive guard could not run."); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeDesktopForegroundService.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeDesktopForegroundService.cs index 9b44b5d31..1a44fbac5 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeDesktopForegroundService.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeDesktopForegroundService.cs @@ -21,9 +21,15 @@ internal sealed class FakeDesktopForegroundService : IDesktopForegroundService /// Handles this fake reports as minimized, to drive the screenshot escalation path. public HashSet MinimizedWindows { get; } = []; + /// + /// Reports every window as minimized, so a test can force the screenshot escalation path without + /// having to know which handle the fake session happens to resolve to. + /// + public bool AllWindowsMinimized { get; set; } + public void RequestForeground(long hwnd) => ForegroundRequests.Add(hwnd); - public bool IsMinimized(long hwnd) => MinimizedWindows.Contains(hwnd); + public bool IsMinimized(long hwnd) => AllWindowsMinimized || MinimizedWindows.Contains(hwnd); public void Restore(long hwnd) => RestoreRequests.Add(hwnd); } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs index 162942a83..c90720b30 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs @@ -33,6 +33,13 @@ internal sealed class FakeInteractiveDesktopLock : IInteractiveDesktopLock /// Set to throw from , to cover coordination failures. public UiCoordinationException? ThrowOnRun { get; set; } + /// + /// Set to throw from EscalateToDesktopExclusiveAsync, covering an escalation that is + /// cancelled while queued or refused because coordination is unavailable. Handlers must let these + /// escape rather than flattening them into internal_error. + /// + public Exception? ThrowOnEscalation { get; set; } + /// Milliseconds reported as queue wait, so output/telemetry paths can be exercised. public long WaitedMs { get; set; } @@ -69,6 +76,12 @@ public Task EnterAsync(CancellationToken cancellationToken) public Task EscalateToDesktopExclusiveAsync(CancellationToken cancellationToken) { owner.Escalations++; + + if (owner.ThrowOnEscalation is { } failure) + { + return Task.FromException(failure); + } + Mode = UiTurnMode.DesktopExclusive; return Task.CompletedTask; } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs index de957a499..f25a35150 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs @@ -498,44 +498,37 @@ public async Task EscalationCancelledWhileQueuedExitsOneThirtyAndLeavesNoTrace() var state = _store.Read().State!; Assert.AreEqual(0, state.Waiters.Count, "the escalation's ticket must be removed"); Assert.AreEqual(deadlineBefore, state.IdleExpiresTick64, - "a cancelled stranger must not disturb the current owner's idle deadline"); + "a command cancelled while queued never ran, so it must renew no grace — neither its own " + + "(it never held the turn) nor the current owner's"); } [TestMethod] - public async Task ABodyThatSwallowsCancellationStillDoesNotRenewTheGrace() + public async Task AnActiveRecordingThatFinalizesOnCancellationStillRenewsTheGrace() { - // Defence in depth for the handler catch-all: even if a body swallows its own cancellation, the - // coordinator must not treat that as a normal completion and extend the owner's turn. - Assert.AreEqual(0, await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0))); - var deadlineAfterNormalCommand = ReadOwnerDeadline(); - Assert.AreNotEqual(0, deadlineAfterNormalCommand, "an ordinary completion establishes the grace"); + // `ui record` observes Ctrl+C deliberately: it stops capturing, finalizes the MP4, and returns + // success. That is a completed command, so the owner keeps its turn and its grace — the agent is + // expected to issue a follow-up command next. "Completed normally" therefore means the body + // returned, not that the token is unset. + Assert.AreEqual(0, await RunAsync(UiTurnMode.TurnShared, "ui record", (_, _) => Task.FromResult(0))); + var deadlineBeforeRecording = ReadOwnerDeadline(); - // Any renewal would land strictly later than the deadline captured above. await Task.Delay(50); using var cts = new CancellationTokenSource(); var bodyObservedCancellation = false; - var exitCode = await RunAsyncWithToken(UiTurnMode.DesktopExclusive, "ui click", async (_, token) => + var exitCode = await RunAsyncWithToken(UiTurnMode.TurnShared, "ui record", async (_, token) => { + // Stands in for a capture loop that is interrupted and finalizes its output. await cts.CancelAsync(); bodyObservedCancellation = token.IsCancellationRequested; - try - { - token.ThrowIfCancellationRequested(); - } - catch (OperationCanceledException) - { - // Deliberately swallowed, imitating an over-broad handler catch. - } - - return 1; + return 0; }, cts.Token); - Assert.AreEqual(1, exitCode, "the body's own result is preserved"); + Assert.AreEqual(0, exitCode, "a finalized recording reports success"); Assert.IsTrue(bodyObservedCancellation, - "the body must actually observe cancellation, or this test proves nothing"); - Assert.AreEqual(deadlineAfterNormalCommand, ReadOwnerDeadline(), - "a cancelled command must not renew the owner's idle grace, however its body handled the cancellation"); + "the recording must actually observe cancellation, or this test proves nothing"); + Assert.IsTrue(ReadOwnerDeadline() > deadlineBeforeRecording, + "a recording that finalized and returned successfully must renew its owner's idle grace"); } [TestMethod] diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs index e7632b512..76fcbe6be 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation and Contributors. All rights reserved. // Licensed under the MIT License. +using System.Security.AccessControl; +using System.Security.Principal; using System.Text.Json; using Microsoft.Extensions.Logging.Abstractions; using WinApp.Cli.Services.InteractiveDesktop; @@ -483,4 +485,143 @@ private sealed class FakeProcessInspector : IProcessInspector public bool? IsProcessAlive(int processId, long startTicksUtc) => _real.IsProcessAlive(processId, startTicksUtc); } + + // ------------------------------------------------------------------------ lock retryability + + [TestMethod] + public void OnlySharingAndLockViolationsAreTreatedAsContention() + { + // Win32 codes arrive in the low word of HResult as 0x8007xxxx. + Assert.IsTrue(CoordinationLockIo.IsContention(new IOException("busy", unchecked((int)0x80070020))), + "ERROR_SHARING_VIOLATION means another process holds the file"); + Assert.IsTrue(CoordinationLockIo.IsContention(new IOException("locked", unchecked((int)0x80070021))), + "ERROR_LOCK_VIOLATION means a byte-range lock is held"); + } + + [TestMethod] + public void OtherIoFailuresAreNotContentionAndMustNotBeRetried() + { + // Retrying these forever would be indistinguishable from waiting on a real lock, so the command + // would hang instead of reporting that coordination is unavailable. + Assert.IsFalse(CoordinationLockIo.IsContention(new IOException("gone", unchecked((int)0x80070003))), + "ERROR_PATH_NOT_FOUND will never clear by waiting"); + Assert.IsFalse(CoordinationLockIo.IsContention(new IOException("device", unchecked((int)0x8007001F))), + "ERROR_GEN_FAILURE is a real device failure"); + Assert.IsFalse(CoordinationLockIo.IsContention(new IOException("handles", unchecked((int)0x80070004))), + "ERROR_TOO_MANY_OPEN_FILES is a process-level failure"); + Assert.IsFalse(CoordinationLockIo.IsContention(new FileNotFoundException()), + "a missing file is not contention"); + } + + [TestMethod] + public void ANonContentionStateLockFailureReportsCoordinationUnavailable() + { + // A directory sitting where the lock file belongs makes FileStream fail with a non-sharing + // error, which must fail closed rather than spin forever. + _paths.EnsureDirectories(); + var occupied = Path.Combine(_paths.LockDirectory, "occupied.lock"); + Directory.CreateDirectory(occupied); + + var store = new InteractiveDesktopStateStore( + new RedirectedStateLockPaths(_paths, occupied), _participants, new TickCountClock(), + NullLogger.Instance); + + var ex = Assert.ThrowsExactly( + () => store.AcquireStateLock(CancellationToken.None).Dispose()); + Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code, + "a real I/O failure must fail closed instead of retrying forever"); + } + + /// Redirects only state.lock, so a test can point it at an unusable path. + private sealed class RedirectedStateLockPaths(IInteractiveDesktopPaths inner, string stateLockPath) + : IInteractiveDesktopPaths + { + public string LockDirectory => inner.LockDirectory; + + public string ParticipantsDirectory => inner.ParticipantsDirectory; + + public string StatePath => inner.StatePath; + + public string StateLockPath => stateLockPath; + + public string ActiveLockPath => inner.ActiveLockPath; + + public string LeaseSearchPattern => inner.LeaseSearchPattern; + + public string LeasePath(int processId, long startTicksUtc) => inner.LeasePath(processId, startTicksUtc); + + public bool TryParseLeaseFileName(string fileName, out int processId, out long startTicksUtc) + => inner.TryParseLeaseFileName(fileName, out processId, out startTicksUtc); + + public void EnsureDirectories() => inner.EnsureDirectories(); + } + + // ------------------------------------------------------------------------ directory ownership + + [TestMethod] + public void ADirectoryOwnedByAnotherUserIsRejectedEvenWithACurrentUserOnlyDacl() + { + // The owner of an object implicitly holds WRITE_DAC, so a foreign owner can rewrite even a + // protected, current-user-only DACL at any moment. Checking the DACL alone is not enough. + var currentUser = WindowsIdentity.GetCurrent().User!; + var stranger = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + + var security = new DirectorySecurity(); + security.SetOwner(stranger); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new FileSystemAccessRule( + currentUser, FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, AccessControlType.Allow)); + + Assert.IsFalse(InteractiveDesktopPaths.IsCurrentUserOnly(security, currentUser), + "a foreign owner retains WRITE_DAC and can re-permission the directory behind our back"); + } + + [TestMethod] + public void ADirectoryOwnedByTheCurrentUserWithAProtectedSelfOnlyDaclIsAccepted() + { + var currentUser = WindowsIdentity.GetCurrent().User!; + + var security = new DirectorySecurity(); + security.SetOwner(currentUser); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new FileSystemAccessRule( + currentUser, FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, AccessControlType.Allow)); + + Assert.IsTrue(InteractiveDesktopPaths.IsCurrentUserOnly(security, currentUser)); + } + + [TestMethod] + public void ADirectoryGrantingAnotherIdentityIsRejectedEvenWhenOwnedByTheCurrentUser() + { + var currentUser = WindowsIdentity.GetCurrent().User!; + var everyone = new SecurityIdentifier(WellKnownSidType.WorldSid, null); + + var security = new DirectorySecurity(); + security.SetOwner(currentUser); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new FileSystemAccessRule( + everyone, FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, AccessControlType.Allow)); + + Assert.IsFalse(InteractiveDesktopPaths.IsCurrentUserOnly(security, currentUser), + "a world-writable coordination directory must never be accepted"); + } + + [TestMethod] + public void ADirectoryWithInheritedRulesIsRejected() + { + var currentUser = WindowsIdentity.GetCurrent().User!; + + var security = new DirectorySecurity(); + security.SetOwner(currentUser); + security.SetAccessRuleProtection(isProtected: false, preserveInheritance: true); + + Assert.IsFalse(InteractiveDesktopPaths.IsCurrentUserOnly(security, currentUser), + "inherited rules can grant whatever the parent grants, including other users"); + } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiAutomationServicePureTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiAutomationServicePureTests.cs index 876e76ddf..579c2cab9 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiAutomationServicePureTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiAutomationServicePureTests.cs @@ -194,20 +194,22 @@ public void IsBlankCapture_AllZeroWithRemainderTail_ReturnsTrue() public async Task CaptureFromWindowWithBlankRetry_RetriesBlankFrame() { var calls = 0; - var foregrounded = false; UiAutomationService.s_captureFromWindow = (_, _, _) => ++calls == 1 ? new byte[8] : new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }; - UiAutomationService.s_foregroundWindowForBlankRetry = _ => foregrounded = true; UiAutomationService.s_sleepForBlankRetry = ms => Assert.AreEqual(200, ms); + // Foreground now goes through the one injected service rather than a private static seam, so + // the test observes the same choke point coordination relies on. + var foreground = new FakeDesktopForegroundService(); var service = new UiAutomationService( - NullLogger.Instance, new SelectorService(), new FakeDesktopForegroundService()); + NullLogger.Instance, new SelectorService(), foreground); var section = new CountingDesktopSection(); var pixels = await service.CaptureFromWindowWithBlankRetryAsync( new HWND(123), 1, 2, section, observeOnly: false, CancellationToken.None); Assert.AreEqual(2, calls, "blank first capture must trigger one retry"); - Assert.IsTrue(foregrounded, "blank retry must foreground the target window"); + CollectionAssert.AreEqual(new long[] { 123 }, foreground.ForegroundRequests, + "blank retry must foreground the target window through IDesktopForegroundService"); Assert.AreEqual(1, section.Enters, "the blank-retry foreground must happen inside a desktop section"); CollectionAssert.AreEqual(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }, pixels); } @@ -216,17 +218,18 @@ public async Task CaptureFromWindowWithBlankRetry_RetriesBlankFrame() public async Task CaptureFromWindowWithBlankRetry_ObserveOnlyRequestsEscalationInsteadOfForegrounding() { UiAutomationService.s_captureFromWindow = (_, _, _) => new byte[8]; - UiAutomationService.s_foregroundWindowForBlankRetry = - _ => Assert.Fail("an observational pass must never take the foreground"); + var foreground = new FakeDesktopForegroundService(); var service = new UiAutomationService( - NullLogger.Instance, new SelectorService(), new FakeDesktopForegroundService()); + NullLogger.Instance, new SelectorService(), foreground); var section = new CountingDesktopSection(); await Assert.ThrowsExactlyAsync( () => service.CaptureFromWindowWithBlankRetryAsync( new HWND(123), 1, 2, section, observeOnly: true, CancellationToken.None)); + Assert.AreEqual(0, foreground.ForegroundRequests.Count, + "an observational pass must never take the foreground"); Assert.AreEqual(0, section.Enters, "an observational pass must not take active.lock"); } @@ -239,15 +242,16 @@ public async Task CaptureFromWindowWithBlankRetry_NonBlankDoesNotRetry() calls++; return new byte[] { 0, 0, 0, 1 }; }; - UiAutomationService.s_foregroundWindowForBlankRetry = _ => Assert.Fail("non-blank capture must not foreground/retry"); + var foreground = new FakeDesktopForegroundService(); var service = new UiAutomationService( - NullLogger.Instance, new SelectorService(), new FakeDesktopForegroundService()); + NullLogger.Instance, new SelectorService(), foreground); var section = new CountingDesktopSection(); var pixels = await service.CaptureFromWindowWithBlankRetryAsync( new HWND(456), 1, 1, section, observeOnly: false, CancellationToken.None); Assert.AreEqual(1, calls); + Assert.AreEqual(0, foreground.ForegroundRequests.Count, "non-blank capture must not foreground/retry"); Assert.AreEqual(0, section.Enters, "a clean capture must never take active.lock"); Assert.AreEqual(1, pixels[3]); } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs index 27119efae..3c442f02d 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs @@ -152,6 +152,50 @@ public async Task Screenshot_ClassifiesByWhetherItNeedsTheForeground() Assert.AreEqual(UiTurnMode.DesktopExclusive, _fakeDesktopLock.Runs[0].Mode); } + [TestMethod] + public async Task Screenshot_LetsACancelledEscalationEscapeInsteadOfReportingAnInternalError() + { + // The handler's catch-all must not swallow cancellation raised by escalation: the user would see + // internal_error instead of a cancellation, and the coordinator would treat the body as having + // completed and renew the owner's grace for a command that never captured anything. + _fakeUia.ScreenshotResult = (new byte[4 * 4 * 4], 4, 4); + _fakeUia.ScreenshotThrow = new WinApp.Cli.Services.DesktopEscalationRequiredException("the target window is minimized"); + _fakeDesktopLock.ThrowOnEscalation = new OperationCanceledException(); + + var command = GetRequiredService(); + var output = Path.Combine(_tempDirectory.FullName, "cancelled.png"); + _ = await ParseAndInvokeWithCaptureAsync( + command, ["-a", "TestApp", "--output", output, "--json"]); + + var emitted = $"{ConsoleStdOut}{ConsoleStdErr}"; + Assert.AreEqual(1, _fakeDesktopLock.Escalations, "the observational pass must have forced an escalation"); + Assert.IsTrue( + string.IsNullOrWhiteSpace(emitted), + "the handler must report nothing for a cancelled escalation and let it propagate, so the " + + $"coordinator can emit the structured cancellation and exit 130. It emitted: {emitted}"); + Assert.IsFalse(File.Exists(output), "a cancelled capture must publish no image"); + } + + [TestMethod] + public async Task Screenshot_ReportsCoordinationUnavailableRatherThanInternalErrorWhenEscalationFails() + { + _fakeUia.ScreenshotResult = (new byte[4 * 4 * 4], 4, 4); + _fakeUia.ScreenshotThrow = new WinApp.Cli.Services.DesktopEscalationRequiredException("the target window is minimized"); + _fakeDesktopLock.ThrowOnEscalation = new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, "newer state", "update winapp"); + + var command = GetRequiredService(); + var output = Path.Combine(_tempDirectory.FullName, "unavailable.png"); + + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-a", "TestApp", "--output", output, "--json"]); + + Assert.AreEqual(1, exitCode); + StringAssert.Contains($"{ConsoleStdOut}{ConsoleStdErr}", UiCoordinationErrorCodes.Unavailable, + "a coordination failure must keep its own error code and remediation"); + Assert.IsFalse(File.Exists(output), "no image may be published when escalation was refused"); + } + // ------------------------------------------------- desktop-section placement and revalidation [TestMethod] @@ -204,6 +248,69 @@ public async Task Click_RefreshesTheTargetWindowFromTheReResolvedElement() "the pre-wait window handle must never be foregrounded"); } + [TestMethod] + public async Task SendKeys_RefusesWhenTheTargetWindowClosedWhileQueued() + { + // Keystrokes are irreversible, so send-keys must revalidate its window after the queue wait just + // as click and invoke do — otherwise it types into whatever now owns a recycled handle. + _fakeUia.FindSingleResult = new UiElement + { + Id = "txt", Selector = "txt", Name = "Value", + X = 10, Y = 20, Width = 40, Height = 30, WindowHandle = 4242, + }; + _fakeSystemQuery.ProcessIdForWindowResult = 0; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["hello", "-a", "TestApp", "--target", "txt", "--json"]); + + Assert.AreEqual(1, exitCode); + Assert.AreEqual(0, _fakeDesktopForeground.ForegroundRequests.Count, + "a closed target must be refused before anything touches the desktop"); + } + + [TestMethod] + public async Task SendKeys_RefusesWhenTheWindowHandleWasRecycledByAnotherProcess() + { + _fakeUia.FindSingleResult = new UiElement + { + Id = "txt", Selector = "txt", Name = "Value", + X = 10, Y = 20, Width = 40, Height = 30, WindowHandle = 4242, + }; + // The handle now belongs to a different process: Windows reused it while this command queued. + _fakeSystemQuery.ProcessIdForWindowResult = 9999; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["hello", "-a", "TestApp", "--target", "txt", "--json"]); + + Assert.AreEqual(1, exitCode); + Assert.AreEqual(0, _fakeDesktopForeground.ForegroundRequests.Count, + "a recycled handle must be refused before anything touches the desktop"); + } + + [TestMethod] + public async Task SendKeys_WithoutATargetStillValidatesTheSessionWindow() + { + // With no --target the session HWND was captured before the queue wait, so it needs the same + // check: nothing re-resolves it on the way in. + _fakeSession.SessionResult = new UiSessionInfo + { + ProcessId = 1234, + ProcessName = "TestApp", + WindowTitle = "Test Window", + WindowHandle = 4242, + }; + _fakeSystemQuery.ProcessIdForWindowResult = 0; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["hello", "-a", "TestApp", "--json"]); + + Assert.AreEqual(1, exitCode); + Assert.AreEqual(0, _fakeDesktopForeground.ForegroundRequests.Count, + "a stale session window must be refused before anything touches the desktop"); + } + [TestMethod] public async Task Click_RefusesWhenTheTargetWindowClosedWhileQueued() { diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiSendKeysCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiSendKeysCommand.cs index ac69767f8..f924900c6 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiSendKeysCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiSendKeysCommand.cs @@ -284,6 +284,18 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn effectiveHwnd = targetHwnd; } + // Confirm the window this command is about to drive still exists and still belongs to + // the resolved app. Without --target the session HWND was captured before the queue + // wait and may since have been closed and its handle recycled by another process; with + // --target the re-resolved element must still live under the same process. Keystrokes + // are irreversible, so this gate runs before foreground, focus, post or send. + if (!DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, targetHwnd, session.ProcessId, logger, json, "send-keys", + parseResult.InvocationConfiguration.Error)) + { + return 1; + } + // Request foreground on the top-level window that owns the target, so activation is // asked for once at the right level rather than on a child control HWND. if (targetHwnd != 0) diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/CoordinationLockIo.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/CoordinationLockIo.cs new file mode 100644 index 000000000..bf8da8bd5 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/CoordinationLockIo.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Services.InteractiveDesktop; + +/// +/// Classifies s raised while acquiring the coordination file locks. +/// +internal static class CoordinationLockIo +{ + /// ERROR_SHARING_VIOLATION — another process has the file open without sharing. + private const int ErrorSharingViolation = 32; + + /// ERROR_LOCK_VIOLATION — a byte-range lock is held on the file. + private const int ErrorLockViolation = 33; + + /// + /// Whether means "another process holds this file right now", which is + /// the only I/O condition a lock acquisition may retry. + /// + /// + /// Everything else — a bad or vanished path, a failing volume, exhausted handles, a disconnected + /// network share — is a real failure. Retrying it forever would be indistinguishable from waiting on + /// a genuinely held lock: the command would hang instead of reporting + /// desktop_coordination_unavailable. Win32 codes surface in the low word of + /// as 0x8007xxxx. + /// + internal static bool IsContention(IOException exception) + => (exception.HResult & 0xFFFF) is ErrorSharingViolation or ErrorLockViolation; + + /// The failure reported when a lock cannot be opened for a non-contention reason. + internal static UiCoordinationException CannotOpen(string path, IOException exception) + => new( + UiCoordinationErrorCodes.Unavailable, + $"The UI coordination lock '{path}' could not be opened: {exception.Message}", + "Check that the coordination directory is on a healthy, reachable volume, then retry."); +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs index caaca6a62..632bd3e60 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs @@ -113,10 +113,15 @@ private async Task AcquireActiveLockAsync(string activeLockPath, Can bufferSize: 1, FileOptions.None); } - catch (IOException) + catch (IOException ex) when (CoordinationLockIo.IsContention(ex)) { // Held by a process inside its desktop-sensitive section. } + catch (IOException ex) + { + // Not contention: retrying would wait forever on a failure that will never clear. + throw CoordinationLockIo.CannotOpen(activeLockPath, ex); + } catch (UnauthorizedAccessException ex) { throw new UiCoordinationException( @@ -208,11 +213,12 @@ public async Task RunAsync( { var exitCode = await body(this, cancellationToken).ConfigureAwait(false); - // A body that swallowed its own cancellation must not be treated as a normal - // completion: renewing the idle grace for a command that never really ran would hold - // the desktop against every other owner. The handler catch-all is filtered to let - // cancellation escape, and this is the backstop if one ever is not. - bodyCompletedNormally = !cancellationToken.IsCancellationRequested; + // "Completed normally" means the body RETURNED rather than threw — deliberately not + // "the token is unset". `ui record` observes Ctrl+C on purpose, finalizes the MP4 and + // returns success; that is a completed command and must renew the owner's grace. + // Commands that must not renew let the cancellation propagate instead, which is why + // handler catch-alls are filtered with UiCoordinatedAction.IsCoordinationFault. + bodyCompletedNormally = true; return exitCode; } finally diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs index 80c3143d8..4fcf1662a 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs @@ -243,6 +243,19 @@ private static void RepairAccessRulesIfNeeded(DirectoryInfo directoryInfo) } directoryInfo.SetAccessControl(BuildCurrentUserOnlySecurity()); + + // Verify rather than assume. Taking ownership can be refused without throwing on every + // Windows configuration, and a directory whose owner is still a stranger remains + // re-permissionable behind our back — so confirm the repair actually took effect and fail + // closed if it did not. + directoryInfo.Refresh(); + if (!IsCurrentUserOnly(directoryInfo.GetAccessControl(), currentUser)) + { + throw new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, + $"The UI coordination directory '{directoryInfo.FullName}' is still owned or reachable by another user after repair.", + "Point WINAPP_UI_LOCK_DIRECTORY at a directory this user owns, or remove the override to use the default location under %LOCALAPPDATA%."); + } } catch (Exception ex) when (ex is UnauthorizedAccessException or PrivilegeNotHeldException or InvalidOperationException) { @@ -255,8 +268,24 @@ private static void RepairAccessRulesIfNeeded(DirectoryInfo directoryInfo) } } - private static bool IsCurrentUserOnly(DirectorySecurity security, SecurityIdentifier currentUser) + /// + /// Whether describes a directory only the current user can reach or + /// re-permission. + /// + /// + /// Owner is checked as well as the DACL because the owner of an object implicitly holds + /// WRITE_DAC: a foreign owner can rewrite even a protected, current-user-only DACL and grant + /// itself access at any time. That matters most for a WINAPP_UI_LOCK_DIRECTORY override under + /// a shared path, where another user may have created the directory first. + /// + internal static bool IsCurrentUserOnly(DirectorySecurity security, SecurityIdentifier currentUser) { + if (security.GetOwner(typeof(SecurityIdentifier)) is not SecurityIdentifier owner + || owner != currentUser) + { + return false; + } + if (!security.AreAccessRulesProtected) { // Inherited rules can grant anyone the parent grants, which for a shared override directory diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopStateStore.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopStateStore.cs index b262970b7..550fdd753 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopStateStore.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopStateStore.cs @@ -86,10 +86,15 @@ public IDisposable AcquireStateLock(CancellationToken cancellationToken) bufferSize: 1, FileOptions.None); } - catch (IOException) + catch (IOException ex) when (CoordinationLockIo.IsContention(ex)) { // Held by another coordinator mid-transition. Spin briefly, then yield. } + catch (IOException ex) + { + // Not contention: retrying would hang forever on a failure that will never clear. + throw CoordinationLockIo.CannotOpen(paths.StateLockPath, ex); + } catch (UnauthorizedAccessException ex) { throw new UiCoordinationException( diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ParticipantRegistry.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ParticipantRegistry.cs index 4d4a62692..90a61bff1 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ParticipantRegistry.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ParticipantRegistry.cs @@ -164,10 +164,20 @@ private bool IsLeaseFileHeld(string leaseFilePath) FileOptions.DeleteOnClose); return false; } + catch (FileNotFoundException) + { + // The holder's handle closed between enumeration and this probe, so Windows removed the + // file. That is proof of death, not of life — unlike a sharing violation below. + return false; + } catch (IOException) { // Sharing violation: another process holds this lease FileShare.None. That is the liveness // proof — including for a suspended process, which still owns its handle. + // + // Any other I/O failure also lands here deliberately. Unlike lock acquisition (see + // CoordinationLockIo), a probe that cannot prove death must assume life: pruning a live + // participant would strand its turn, whereas an over-cautious "live" only delays reclaim. return true; } catch (UnauthorizedAccessException ex) diff --git a/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Screenshot.cs b/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Screenshot.cs index 728a6c4aa..7f2677b4f 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Screenshot.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Screenshot.cs @@ -33,22 +33,8 @@ internal sealed partial class UiAutomationService { internal static Func s_captureFromWindow = CaptureFromWindow; internal static Func s_captureFromScreenScaled = CaptureFromScreenScaled; - internal static Action s_foregroundWindowForBlankRetry = ForegroundWindowForBlankRetry; internal static Action s_sleepForBlankRetry = Thread.Sleep; - /// - /// Coverage ceiling (issue #630): this is a direct Win32 foreground request used only after a - /// native PrintWindow blank frame. Tests cover callers through the injectable seam. - /// - /// Coordination (issue #764): this bypasses only because it - /// is an established test seam with an HWND signature. Its single caller - /// () invokes it inside a desktop section, so the - /// foreground change is still serialized against every other coordinated process. - /// - /// - private static void ForegroundWindowForBlankRetry(Windows.Win32.Foundation.HWND hwnd) - => Windows.Win32.PInvoke.SetForegroundWindow(hwnd); - /// /// Coverage ceiling (issue #630): tests cover real WGC/screen/PrintWindow attempts and deterministic /// blank-retry/composition seams. Remaining lines require minimized/zero-size native HWND state, @@ -231,7 +217,9 @@ internal async Task CaptureFromWindowWithBlankRetryAsync( _logger.LogDebug("PrintWindow returned blank frame; foregrounding and retrying"); await using (await desktopSection.EnterAsync(ct).ConfigureAwait(false)) { - s_foregroundWindowForBlankRetry(hwnd); + // Every foreground request goes through the one service, so coordination has a single + // choke point and tests observe it through the injected fake. + _desktopForeground.RequestForeground((nint)hwnd); s_sleepForBlankRetry(200); pixels = s_captureFromWindow(hwnd, width, height); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.cs b/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.cs index cc2c87760..f3dbcd818 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.cs @@ -61,7 +61,6 @@ internal static void ResetNativeSeams() s_getMainWindowHandleForProcessId = pid => System.Diagnostics.Process.GetProcessById(pid).MainWindowHandle; s_captureFromWindow = CaptureFromWindow; s_captureFromScreenScaled = CaptureFromScreenScaled; - s_foregroundWindowForBlankRetry = ForegroundWindowForBlankRetry; s_sleepForBlankRetry = Thread.Sleep; } From 94a62329a65a6425508c2c27410256df0f67c3c5 Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:30:03 -0700 Subject: [PATCH 04/29] Address PR review: turn-age telemetry, handoff action, record faults, npm docs Fixes the five substantive review findings on the cooperative UI turns PR: - UiTurnAgeBucket was populated from the queue-wait stopwatch, so it duplicated the wait bucket and was always 0 for an immediate acquisition. The turn's claim tick is now persisted in state (TurnStartedTick64, written only by ClaimTurn alongside TurnId) so the bucket reports how long the owning workflow has held the desktop across all of its commands. - UiTurnAction.HandoffAfterIdle was unreachable: BeginParticipating normalized an expired owner away and then reported New. The previous owner key is now captured before normalization, so taking over a lapsed turn is distinguishable from finding a genuinely free desktop. - ui record's catch-all swallowed coordination faults, turning an active.lock failure into internal_error and letting the coordinator renew the owner's grace on a command that never ran. It is now filtered with IsCoordinationFault like the other eleven desktop-sensitive handlers. - The ui-json-envelope reference claimed an npm AbortSignal produces the native cancelled envelope and exit 130. Node force-terminates the child and rejects with AbortError; that row is now native Ctrl+C only. - CommonOptions.signal has a multi-paragraph JSDoc that was emitted verbatim into every generated Markdown table, terminating the row at its first newline. Table cells are now flattened, and signal is treated as a common option so it is documented once instead of in all 43 command tables. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/npm-usage.md | 823 ++---------------- docs/telemetry.md | 2 +- .../references/ui-json-envelope.md | 7 +- .../InteractiveDesktopLockTests.cs | 100 +++ .../InteractiveDesktopSchedulerTests.cs | 118 +++ .../UiCommandTests.Coordination.cs | 42 + .../WinApp.Cli/Commands/UiRecordCommand.cs | 2 +- .../InteractiveDesktopLock.cs | 49 +- .../InteractiveDesktopScheduler.cs | 37 +- .../InteractiveDesktopState.cs | 9 + .../InteractiveDesktop/UiCoordinationTypes.cs | 16 +- src/winapp-npm/scripts/generate-docs.mjs | 30 +- src/winapp-npm/test/npm-usage-doc.test.ts | 76 ++ 13 files changed, 555 insertions(+), 756 deletions(-) create mode 100644 src/winapp-npm/test/npm-usage-doc.test.ts diff --git a/docs/npm-usage.md b/docs/npm-usage.md index 743c40395..3d6b0475f 100644 --- a/docs/npm-usage.md +++ b/docs/npm-usage.md @@ -44,14 +44,7 @@ Base options shared by most commands. | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `WinappResult` @@ -65,7 +58,7 @@ Result returned by every command wrapper. ## CLI command wrappers -These functions wrap native `winapp` CLI commands. All accept [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`). +These functions wrap native `winapp` CLI commands. All accept [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`). ### `azSign()` @@ -85,16 +78,8 @@ function azSign(options: AzSignOptions): Promise | `profile` | `string \| undefined` | No | Certificate profile name. Must be used with --account | | `resourceGroup` | `string \| undefined` | No | Resource group to narrow down signing accounts | | `subscription` | `string \| undefined` | No | Azure subscription ID to use. If not provided and multiple subscriptions exist, you will be prompted. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -119,16 +104,8 @@ function certGenerate(options?: CertGenerateOptions): Promise | `password` | `string \| undefined` | No | Password for the generated PFX file | | `publisher` | `string \| undefined` | No | Publisher distinguished name (DN) for the generated certificate (e.g., CN=MyCompany or OU=Team, O=Corp, C=US). If not specified, will be inferred from manifest. Bare names are auto-wrapped as CN=. | | `validDays` | `number \| undefined` | No | Number of days the certificate is valid | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -147,16 +124,8 @@ function certInfo(options: CertInfoOptions): Promise | `certPath` | `string` | Yes | Path to the certificate file (PFX) | | `json` | `boolean \| undefined` | No | Format output as JSON | | `password` | `string \| undefined` | No | Password for the PFX file | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | - -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -175,16 +144,8 @@ function certInstall(options: CertInstallOptions): Promise | `certPath` | `string` | Yes | Path to the certificate file (PFX or CER) | | `force` | `boolean \| undefined` | No | Force installation even if the certificate already exists | | `password` | `string \| undefined` | No | Password for the PFX file | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -204,16 +165,8 @@ function createDebugIdentity(options?: CreateDebugIdentityOptions): Promise |----------|------|----------|-------------| | `target` | `string` | Yes | Path to the .exe (embeds identity into its side-by-side manifest via mt.exe) or an .xml/.manifest side-by-side manifest file (inserts/replaces the element; created if it doesn't exist). | | `manifest` | `string \| undefined` | No | Path to the sparse appxmanifest.xml to read identity from. When omitted, searched in a 'sparse/' folder (where 'winapp init --exe --sparse' writes it by default) beside the target first, then in the current directory, then beside the target and in the current directory. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -294,16 +231,8 @@ function findUi(options?: FindUiOptions): Promise | `max` | `number \| undefined` | No | Maximum number of matched controls to return. Applies to search only; ignored with --list and --id. | | `refresh` | `boolean \| undefined` | No | Bypass the local cache and re-fetch the WinUI corpus from GitHub. | | `source` | `string \| undefined` | No | Restrict results to a single source: gallery (WinUI 3 Gallery), toolkit (Windows Community Toolkit), reactor (microsoft-ui-reactor, C#-only declarative WinUI), or core (curated patterns). Reactor is opt-in — it is excluded from a normal search, so pass --source reactor to search it (only do this for a Reactor/MVU project; its C#-only samples don't paste into a standard XAML app). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -320,16 +249,8 @@ function getWinappPath(options?: GetWinappPathOptions): Promise | Property | Type | Required | Description | |----------|------|----------|-------------| | `global` | `boolean \| undefined` | No | Get the global .winapp directory instead of local | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | - -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -358,16 +279,8 @@ function init(options?: InitOptions): Promise | `setupSdks` | `SdkInstallMode \| undefined` | No | SDK installation mode: 'stable' (default), 'preview', 'experimental', or 'none' (skip SDK installation) | | `sparse` | `boolean \| undefined` | No | Generate a sparse identity manifest (appxmanifest.xml) for an existing desktop exe instead of a full package manifest. Use with --exe. Skips SDK/package installation. | | `useDefaults` | `boolean \| undefined` | No | Skip interactive prompts and use default answers. Normal init targets the positional project directory if given, otherwise the current directory (e.g., winapp init . --use-defaults). Sparse init (--exe --sparse) ignores the positional directory and writes to --output-dir instead. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -386,16 +299,8 @@ function manifestAddAlias(options?: ManifestAddAliasOptions): Promise). Accepts any valid X.500 DN; bare names are auto-wrapped as CN=. | | `template` | `ManifestTemplates \| undefined` | No | Manifest template type: 'packaged' (full MSIX app, default) or 'sparse' (desktop app with package identity for Windows APIs) | | `version` | `string \| undefined` | No | App version in Major.Minor.Build.Revision format (e.g., 1.0.0.0). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -448,16 +345,8 @@ function manifestUpdateAssets(options: ManifestUpdateAssetsOptions): Promise | `template` | `string \| undefined` | No | Template short name (e.g. winui, winui-navview, winui-mvvm, winui-lib, winui-unittest). Run 'winapp new --list' to see all. | | `templateVersion` | `string \| undefined` | No | WinUI template pack version: 'latest' (install newest), 'installed' (keep what's installed), or an explicit version. Default: install latest if none, else prompt to update a stale pack. | | `useDefaults` | `boolean \| undefined` | No | Do not prompt; use defaults (blank template, name from --output/--name, keep installed templates). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | - -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -518,16 +399,8 @@ function packageApp(options: PackageOptions): Promise | `publisher` | `string \| undefined` | No | Publisher distinguished name (DN) for certificate generation (e.g., CN=MyCompany). Bare names are auto-wrapped as CN=. | | `selfContained` | `boolean \| undefined` | No | Bundle Windows App SDK runtime for self-contained deployment | | `skipPri` | `boolean \| undefined` | No | Skip PRI file generation | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -545,16 +418,8 @@ function restore(options?: RestoreOptions): Promise |----------|------|----------|-------------| | `baseDirectory` | `string \| undefined` | No | Base/root directory for the winapp workspace | | `configDir` | `string \| undefined` | No | Directory to read configuration from (default: current directory) | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -593,16 +458,8 @@ function run(options?: RunOptions): Promise | `unregisterOnExit` | `boolean \| undefined` | No | Unregister the development package after the application exits. Only removes packages registered in development mode. | | `withAlias` | `boolean \| undefined` | No | Launch the app using its execution alias instead of AUMID activation. The app runs in the current terminal with inherited stdin/stdout/stderr. Requires a uap5:ExecutionAlias in the manifest. Use "winapp manifest add-alias" to add an execution alias to the manifest. | | `appArgs` | `string \| string[] \| undefined` | No | Arguments to pass to the launched application (forwarded after --). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | - -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -622,16 +479,8 @@ function sign(options: SignOptions): Promise | `certPath` | `string` | Yes | Path to the certificate file (PFX format) | | `password` | `string \| undefined` | No | Certificate password | | `timestamp` | `string \| undefined` | No | Timestamp server URL | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -648,16 +497,8 @@ function store(options?: StoreOptions): Promise | Property | Type | Required | Description | |----------|------|----------|-------------| | `storeArgs` | `string \| string[] \| undefined` | No | Arguments to pass through to the Microsoft Store Developer CLI. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -674,16 +515,8 @@ function tool(options?: ToolOptions): Promise | Property | Type | Required | Description | |----------|------|----------|-------------| | `toolArgs` | `string \| string[] \| undefined` | No | Arguments to pass to the SDK tool, e.g. ['makeappx', 'pack', '/d', './folder', '/p', './out.msix']. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | - -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -705,16 +538,8 @@ function uiClick(options?: UiClickOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `right` | `boolean \| undefined` | No | Perform a right-click instead of a left click | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -738,16 +563,8 @@ function uiDrag(options?: UiDragOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `right` | `boolean \| undefined` | No | Drag with the right mouse button instead of the left button | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | - -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -767,16 +584,8 @@ function uiFocus(options?: UiFocusOptions): Promise | `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -795,16 +604,8 @@ function uiGetFocused(options?: UiGetFocusedOptions): Promise | `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -825,16 +626,8 @@ function uiGetProperty(options?: UiGetPropertyOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `property` | `string \| undefined` | No | Property name to read or filter on | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | - -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -854,16 +647,8 @@ function uiGetValue(options?: UiGetValueOptions): Promise | `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -884,16 +669,8 @@ function uiHover(options?: UiHoverOptions): Promise | `dwellTime` | `number \| undefined` | No | Time in milliseconds to wait after hovering for hover effects to appear (default: 800) | | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -918,16 +695,8 @@ function uiInspect(options?: UiInspectOptions): Promise | `interactive` | `boolean \| undefined` | No | Show only interactive/invokable elements (buttons, links, inputs, list items). Increases default depth to 8. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | - -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -947,16 +716,8 @@ function uiInvoke(options?: UiInvokeOptions): Promise | `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -975,16 +736,8 @@ function uiListWindows(options?: UiListWindowsOptions): Promise | `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `showHidden` | `boolean \| undefined` | No | Include untitled zero-size windows that are hidden by default | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -1011,16 +764,8 @@ function uiPen(options?: UiPenOptions): Promise | `tiltX` | `number \| undefined` | No | Pen tilt along the x-axis in degrees (-90 to 90, default: 0). | | `tiltY` | `number \| undefined` | No | Pen tilt along the y-axis in degrees (-90 to 90, default: 0). | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | - -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -1043,16 +788,8 @@ function uiScreenshot(options?: UiScreenshotOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `output` | `string \| undefined` | No | Save output to this file path. | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -1075,16 +812,8 @@ function uiScroll(options?: UiScrollOptions): Promise | `to` | `string \| undefined` | No | Scroll to position: top, bottom | | `wheel` | `number \| undefined` | No | Rotate the mouse wheel over the element by this many notches (1 = one notch up, -1 = one notch down). Synthesizes real wheel input instead of using ScrollPattern. | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | - -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -1104,16 +833,8 @@ function uiScrollIntoView(options?: UiScrollIntoViewOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `max` | `number \| undefined` | No | Maximum search results | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -1167,16 +880,8 @@ function uiSendKeys(options?: UiSendKeysOptions): Promise | `verbatim` | `boolean \| undefined` | No | Type the entire keys argument as literal text — no named-key, combo, or vk= interpretation, and exact whitespace preserved. The whole-argument form of the per-token text= escape: --verbatim "down down enter" types the words instead of pressing Down, Down, Enter. | | `via` | `string \| undefined` | No | Transport: post-message (default, HWND-targeted, bypasses UIPI; typed text raises TextChanged but not a per-character KeyDown) or send-input (OS-wide; typed text raises a real per-character KeyDown + TextChanged). Named keys and combos raise KeyDown on both, but keyboard accelerators/shortcuts (KeyboardAccelerator, e.g. ctrl+t) only fire via send-input. post-message targets the focused child control and works for classic Win32/WinForms controls, but WinUI 3 / UWP / XAML controls are windowless and ignore posted messages — use send-input for those (a warning is emitted when the target looks like a XAML app). | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | - -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -1197,16 +902,8 @@ function uiSetValue(options?: UiSetValueOptions): Promise | `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -1225,16 +922,8 @@ function uiStatus(options?: UiStatusOptions): Promise | `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | - -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -1262,16 +951,8 @@ function uiTouch(options?: UiTouchOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `toPoint` | `string \| undefined` | No | End point x,y for a swipe (screen coordinates). Takes precedence over --direction. | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -1296,16 +977,8 @@ function uiWaitFor(options?: UiWaitForOptions): Promise | `timeout` | `number \| undefined` | No | Timeout in milliseconds | | `value` | `string \| undefined` | No | Wait for element value to equal this string. Uses smart fallback (TextPattern -> ValuePattern -> Name). Combine with --property to check a specific property instead. | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -1324,16 +997,8 @@ function unregister(options?: UnregisterOptions): Promise | `force` | `boolean \| undefined` | No | Skip the install-location directory check and unregister even if the package was registered from a different project tree | | `json` | `boolean \| undefined` | No | Format output as JSON | | `manifest` | `string \| undefined` | No | Path to the Package.appxmanifest (default: auto-detect from current directory) | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | - -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -1350,16 +1015,8 @@ function update(options?: UpdateOptions): Promise | Property | Type | Required | Description | |----------|------|----------|-------------| | `setupSdks` | `SdkInstallMode \| undefined` | No | SDK installation mode: 'stable' (default), 'preview', 'experimental', or 'none' (skip SDK installation) | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* --- @@ -1607,16 +1264,7 @@ Re-exported from Node.js for convenience. See [Node.js docs](https://nodejs.org/ | Property | Type | Required | Description | |----------|------|----------|-------------| | `exitOnError` | `boolean \| undefined` | No | | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -On Windows, Node force-terminates the child, so the CLI's own cleanup may not run. That is safe: -Windows closes the process's coordination file handles and deletes its `DeleteOnClose` participant -lease, and other `winapp ui` processes prune the entry through lease and PID/start validation. -If the abort lands after the command acquired the desktop, UI side effects may already have -happened, and aborting an active recording can leave partial or invalid output — this wrapper does -not promise graceful MP4 finalization. - -Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

On Windows, Node force-terminates the child, so the CLI's own cleanup may not run. That is safe: Windows closes the process's coordination file handles and deletes its `DeleteOnClose` participant lease, and other `winapp ui` processes prune the entry through lease and PID/start validation. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial or invalid output — this wrapper does not promise graceful MP4 finalization.

Rejects with an `AbortError`. | ### `CallWinappCliResult` @@ -1629,8 +1277,7 @@ Rejects with an `AbortError`. | | Property | Type | Required | Description | |----------|------|----------|-------------| | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()) | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation. See {@link CallWinappCliOptions.signal} for the exact -contract, including what is and is not guaranteed after an abort. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation. See {@link CallWinappCliOptions.signal} for the exact contract, including what is and is not guaranteed after an abort. | ### `CallWinappCliCaptureResult` @@ -1723,14 +1370,7 @@ type ManifestTemplates = "packaged" | "sparse" | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `CertGenerateOptions` @@ -1748,14 +1388,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `CertInfoOptions` @@ -1767,14 +1400,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `CertInstallOptions` @@ -1786,14 +1412,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `CreateDebugIdentityOptions` @@ -1806,14 +1425,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `CreateExternalCatalogOptions` @@ -1828,14 +1440,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `EmbedIdentityOptions` @@ -1846,14 +1451,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `FindUiOptions` @@ -1869,14 +1467,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `GetWinappPathOptions` @@ -1886,14 +1477,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `InitOptions` @@ -1915,14 +1499,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `ManifestAddAliasOptions` @@ -1934,14 +1511,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `ManifestGenerateOptions` @@ -1959,14 +1529,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `ManifestUpdateAssetsOptions` @@ -1978,14 +1541,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `NewOptions` @@ -2002,14 +1558,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `PackageOptions` @@ -2030,14 +1579,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `RestoreOptions` @@ -2048,14 +1590,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `RunOptions` @@ -2087,14 +1622,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `SignOptions` @@ -2107,14 +1635,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `StoreOptions` @@ -2124,14 +1645,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `ToolOptions` @@ -2141,14 +1655,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UiClickOptions` @@ -2163,14 +1670,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UiDragOptions` @@ -2187,14 +1687,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UiFocusOptions` @@ -2207,14 +1700,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UiGetFocusedOptions` @@ -2226,14 +1712,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UiGetPropertyOptions` @@ -2247,14 +1726,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UiGetValueOptions` @@ -2267,14 +1739,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UiHoverOptions` @@ -2288,14 +1753,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UiInspectOptions` @@ -2313,14 +1771,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UiInvokeOptions` @@ -2333,14 +1784,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UiListWindowsOptions` @@ -2352,14 +1796,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UiPenOptions` @@ -2379,14 +1816,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UiScreenshotOptions` @@ -2402,14 +1832,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UiScrollOptions` @@ -2425,14 +1848,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UiScrollIntoViewOptions` @@ -2445,14 +1861,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UiSearchOptions` @@ -2466,14 +1875,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UiSendKeysOptions` @@ -2490,14 +1892,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UiSetValueOptions` @@ -2511,14 +1906,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UiStatusOptions` @@ -2530,14 +1918,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UiTouchOptions` @@ -2558,14 +1939,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UiWaitForOptions` @@ -2583,14 +1957,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UnregisterOptions` @@ -2602,14 +1969,7 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | ### `UpdateOptions` @@ -2619,12 +1979,5 @@ partial output. Rejects with an `AbortError`. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | -| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop. - -`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another -workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may -not run, but Windows releases its coordination handles and deletes its participant lease, and -other processes reclaim the queue entry. If the abort lands after the command acquired the -desktop, UI side effects may already have happened, and aborting an active recording can leave -partial output. Rejects with an `AbortError`. | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | diff --git a/docs/telemetry.md b/docs/telemetry.md index 20743f8a3..5606d845b 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -85,7 +85,7 @@ The telemetry feature collects the following data: | CI environment | A boolean flag indicating whether the CLI is running in a Continuous Integration environment. | | Caller | The value of the `WINAPP_CLI_CALLER` environment variable, if set. This allows wrapper tools (like the npm package) to identify themselves. | | `find-ui` usage | For the `winapp find-ui` command only, an additional usage event with non-personal, bounded values: the mode (`search`, `fetch`, or `list`); the selected `--source` (a fixed value — `gallery`, `toolkit`, `reactor`, or `core`); the catalog scenario IDs fetched (e.g. `gallery-tabview-1`), which identify built-in WinUI sample controls, never your code; whether `--json` was used; and result/ID counts. The free-form search query is **never** collected, and any requested IDs that don't match a real catalog entry are counted but **not** collected as text. | -| `winapp ui` desktop coordination | For `winapp ui` commands only, a privacy-minimized summary of how the command shared the desktop with other UI workflows: how the workflow identity was resolved (`Explicit`, `Parent`, or `Anonymous` — never the identity itself), the coordination mode (`Observe`, `TurnShared`, or `DesktopExclusive`), how the turn was obtained (new, continuation, queued, handoff-after-idle, or detached), the outcome (completed, cancelled, coordination failure, or corruption recovery), and **coarse buckets** for wait time, queue depth, and turn age. | +| `winapp ui` desktop coordination | For `winapp ui` commands only, a privacy-minimized summary of how the command shared the desktop with other UI workflows: how the workflow identity was resolved (`Explicit`, `Parent`, or `Anonymous` — never the identity itself), the coordination mode (`Observe`, `TurnShared`, or `DesktopExclusive`), how the turn was obtained (new, continuation, queued, handoff-after-idle, or detached), the outcome (completed, cancelled, coordination failure, or corruption recovery), and **coarse buckets** for how long this command waited for the desktop, how many commands were queued, and how long the owning workflow had held its turn. | ### Sanitization of sensitive data diff --git a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md index af3aa154b..91d65c739 100644 --- a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md +++ b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md @@ -116,7 +116,12 @@ appear: | `invalid_ui_owner_id` | `WINAPP_UI_OWNER_ID` is set but empty/whitespace or longer than 256 characters. Fails before any UI side effect. | | `desktop_coordination_unavailable` | Coordination state could not be read, published, or safely rebuilt — including state written by a newer `winapp`. Mutating commands fail closed rather than acting uncoordinated. | | `queue_capacity_exceeded` | 64 commands are already waiting for the desktop. | -| `cancelled` | Ctrl+C (or an npm `AbortSignal`) while the command was still waiting for its turn. The command never ran, so it has no UI side effects. Exit code **130**. | +| `cancelled` | Native Ctrl+C while the command was still waiting for its turn. The command never ran, so it has no UI side effects. Exit code **130**. | + +An npm `AbortSignal` is a different contract: Node force-terminates the child, +so there is usually no envelope and no exit code 130 — the wrapper rejects with +an `AbortError` instead, and UI side effects may already have happened if the +abort landed after the command acquired the desktop. `cancelled` — and optionally the other coordination errors — carries an additive `coordination` object: diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs index f25a35150..727af66e5 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs @@ -159,6 +159,106 @@ public async Task CoordinationSummaryIsPublishedForTelemetry() StringAssert.Matches(summary.WaitBucket, new System.Text.RegularExpressions.Regex(@"^\d+(-\d+|\+)?$")); } + [TestMethod] + public async Task CoordinationSummary_TurnAgeMeasuresTheHeldTurnNotTheQueueWait() + { + // Regression: turn age used to be read from the queue-wait stopwatch, so it duplicated the wait + // bucket and was always zero for a command that acquired the desktop immediately. + Assert.AreEqual(0, await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0))); + + // Still inside the four-second grace, so the second command is a continuation of the same turn. + await Task.Delay(150); + + UiCoordinationTelemetryScope.Begin(); + await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0)); + + var summary = UiCoordinationTelemetryScope.Current; + Assert.IsNotNull(summary); + Assert.AreEqual(UiTurnAction.Continuation, summary!.TurnAction); + Assert.AreEqual(0, summary.WaitedMs, "nothing was queued, so this command never waited"); + Assert.IsTrue(summary.TurnAgeMs >= 100, + $"the turn had been held for over 150 ms; reported age was {summary.TurnAgeMs} ms"); + } + + [TestMethod] + public async Task CoordinationSummary_DetachedObservationReportsNoTurnAge() + { + using var foreignLease = OccupyTurnWithAnotherOwner(); + UiCoordinationTelemetryScope.Begin(); + + await RunAsync(UiTurnMode.Observe, "ui inspect", (_, _) => Task.FromResult(0)); + + var summary = UiCoordinationTelemetryScope.Current; + Assert.IsNotNull(summary); + Assert.AreEqual(UiTurnAction.Detached, summary!.TurnAction); + Assert.AreEqual(0, summary.TurnAgeMs, "a detached observation holds no turn, so it has no age"); + } + + [TestMethod] + public async Task CoordinationSummary_ReportsHandoffAfterIdleWhenTakingOverALapsedTurn() + { + // Spec §16 advertises `handoff-after-idle`; this proves the value is actually reachable. + using (var foreignLease = OccupyTurnWithAnotherOwner()) + { + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var state = _store.Read().State!; + state.OwnerCommands.Clear(); + state.IdleExpiresTick64 = 1; // already elapsed + _store.Publish(state); + } + + UiCoordinationTelemetryScope.Begin(); + await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0)); + + var summary = UiCoordinationTelemetryScope.Current; + Assert.IsNotNull(summary); + Assert.AreEqual(UiTurnAction.HandoffAfterIdle, summary!.TurnAction); + } + + [TestMethod] + public async Task CoordinationSummary_StateWithoutATurnStartTickReportsNoTurnAge() + { + // A state file written before the turn-start field existed deserializes it as 0, which is also + // the "no owner" sentinel. Measuring an age from it would report the machine's uptime. + _paths.EnsureDirectories(); + using (var stateLock = _store.AcquireStateLock(CancellationToken.None)) + { + var state = InteractiveDesktopState.CreateFresh(); + state.TurnId = 7; + state.Owner = new OwnerRecord { Kind = UiOwnerKind.Explicit, Key = "some-other-workflow" }; + state.TurnStartedTick64 = 0; + state.IdleExpiresTick64 = 1; // already elapsed, so this command takes the turn over + _store.Publish(state); + } + + UiCoordinationTelemetryScope.Begin(); + await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0)); + + var summary = UiCoordinationTelemetryScope.Current; + Assert.IsNotNull(summary); + Assert.IsTrue(summary!.TurnAgeMs < 60_000, + $"a freshly claimed turn cannot be minutes old; reported {summary.TurnAgeMs} ms"); + } + + [TestMethod] + public async Task ABodyThatFailsCoordinationDoesNotRenewTheOwnersGrace() + { + // A coordination fault raised inside the body (for example active.lock I/O failing while a + // recording opens its desktop section) must not look like a completed command. + Assert.AreEqual(0, await RunAsync(UiTurnMode.TurnShared, "ui record", (_, _) => Task.FromResult(0))); + var deadlineBefore = ReadOwnerDeadline(); + + await Task.Delay(50); + + var ex = await Assert.ThrowsExactlyAsync(() => + RunAsync(UiTurnMode.TurnShared, "ui record", (_, _) => throw new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, "active.lock could not be opened"))); + + Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code); + Assert.AreEqual(deadlineBefore, ReadOwnerDeadline(), + "a command that never ran must not renew the owner's idle grace"); + } + // --------------------------------------------------------------------------- desktop sections [TestMethod] diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs index 15114a4c3..b13ca997c 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs @@ -227,6 +227,124 @@ public void CompletingAnObservation_StartsAFreshGrace() // ------------------------------------------------------------------------- expiry and handoff + [TestMethod] + public void BeginParticipating_AfterAnotherOwnersGraceExpired_ReportsHandoffAfterIdle() + { + // Spec §16 advertises a `handoff-after-idle` turn action. Taking over a turn that normalization + // just released is not the same as finding a free desktop, and the two must stay distinguishable + // in telemetry. + var state = InteractiveDesktopState.CreateFresh(); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true); + + _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs); + + var result = _scheduler.BeginParticipating( + state, _probe, OwnerB, Participant(200), UiTurnMode.DesktopExclusive); + + Assert.AreEqual(UiAdmission.OwnerCommandRunning, result.Admission); + Assert.AreEqual(UiTurnAction.HandoffAfterIdle, result.TurnAction, + "the desktop was not free — another workflow's idle grace had just run out"); + Assert.AreEqual(OwnerB.Key, state.Owner!.Key); + } + + [TestMethod] + public void BeginParticipating_AfterItsOwnGraceExpired_ReportsNewRatherThanHandoff() + { + // The same workflow reclaiming its own lapsed turn took nothing from anybody, so it is a new + // turn. Reporting a handoff here would make the value meaningless. + var state = InteractiveDesktopState.CreateFresh(); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true); + + _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs); + + var result = _scheduler.BeginParticipating( + state, _probe, OwnerA, Participant(101), UiTurnMode.DesktopExclusive); + + Assert.AreEqual(UiTurnAction.New, result.TurnAction); + } + + [TestMethod] + public void BeginParticipating_OnAGenuinelyFreeDesktop_ReportsNew() + { + var state = InteractiveDesktopState.CreateFresh(); + + var result = _scheduler.BeginParticipating( + state, _probe, OwnerB, Participant(200), UiTurnMode.DesktopExclusive); + + Assert.AreEqual(UiTurnAction.New, result.TurnAction, + "no previous owner existed, so nothing was handed off"); + } + + // ------------------------------------------------------------------------------- turn stamping + + [TestMethod] + public void ClaimingATurn_StampsTheTurnStartTick() + { + // The turn-age telemetry bucket measures how long the workflow has held the desktop, so the + // start tick must be written by whoever claims the turn, not derived from a per-command wait. + _clock.Advance(5_000); + var state = InteractiveDesktopState.CreateFresh(); + + _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive); + + Assert.AreEqual(_clock.NowTicks64, state.TurnStartedTick64); + } + + [TestMethod] + public void JoiningAnExistingTurn_KeepsTheOriginalStartTick() + { + var state = InteractiveDesktopState.CreateFresh(); + _scheduler.BeginParticipating( + state, _probe, OwnerA, Participant(100, "ui record"), UiTurnMode.TurnShared); + var claimedAt = state.TurnStartedTick64; + + _clock.Advance(30_000); + _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(101), UiTurnMode.DesktopExclusive); + + Assert.AreEqual(claimedAt, state.TurnStartedTick64, + "a continuation joins the turn already in progress; its age keeps accumulating"); + } + + [TestMethod] + public void Handoff_RestampsTheTurnStartTickForThePromotedOwner() + { + var state = InteractiveDesktopState.CreateFresh(); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); + _scheduler.BeginParticipating(state, _probe, OwnerB, Participant(200), UiTurnMode.DesktopExclusive); + + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true); + _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs); + _scheduler.Normalize(state, _probe); + + Assert.AreEqual(OwnerB.Key, state.Owner!.Key); + Assert.AreEqual(_clock.NowTicks64, state.TurnStartedTick64, + "the promoted owner's turn starts now — it must not inherit the previous owner's age"); + } + + [TestMethod] + public void ExpiringAnIdleTurn_ClearsTheTurnStartTick() + { + var state = InteractiveDesktopState.CreateFresh(); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true); + + _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs); + _scheduler.Normalize(state, _probe); + + Assert.IsNull(state.Owner); + Assert.AreEqual(0, state.TurnStartedTick64, "an unowned desktop has no turn to age"); + } + [TestMethod] public void IdleTurn_ExpiresAfterExactlyFourSeconds() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs index 3c442f02d..537eb5066 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs @@ -440,4 +440,46 @@ public async Task SendKeys_FocusesTheTargetOnlyAfterForegroundIsVerified() Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters, "revalidate, foreground, focus and send belong to one desktop section"); } + + // --------------------------------------------- coordination faults must not become internal_error + + [TestMethod] + public async Task Record_CoordinationFailureInsideTheBody_SurfacesTheCoordinationError() + { + // `ui record` opens its desktop section from inside the handler's broad catch-all. Without the + // IsCoordinationFault filter, an active.lock failure was reported as `internal_error` and — worse + // — looked to the coordinator like a normal body return, renewing the owner's idle grace. + _fakeUia.RecordException = new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, "The UI desktop lock could not be opened."); + + var outputPath = Path.Combine(_tempDirectory.FullName, "coordination-fault.mp4"); + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath, "--json"]); + + Assert.AreEqual(1, exitCode); + AssertJsonErrorCode(UiCoordinationErrorCodes.Unavailable); + } + + [TestMethod] + public async Task Record_OrdinaryFailureInsideTheBody_StaysWithTheHandler() + { + // The filter must be narrow: only coordination faults escape. An ordinary capture failure keeps + // its existing handler-owned envelope and never turns into a coordination error. + _fakeUia.RecordException = new InvalidOperationException("encoder blew up"); + + var outputPath = Path.Combine(_tempDirectory.FullName, "ordinary-fault.mp4"); + var command = GetRequiredService(); + + // Escaping to UiCoordinatedAction would not be caught there either, so an unhandled throw here + // is itself the failure signal. + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath, "--json"]); + + Assert.AreEqual(1, exitCode); + var stderr = ConsoleStdErr.ToString(); + StringAssert.Contains(stderr, "encoder blew up"); + Assert.IsFalse(stderr.Contains(UiCoordinationErrorCodes.Unavailable, StringComparison.Ordinal), + $"an ordinary failure must not be reported as a coordination error; got: {stderr}"); + } } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs index 8bac7b885..a338b8802 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs @@ -409,7 +409,7 @@ void OnRecordingStarted(bool frameArtifactsActive) UiErrors.GenericError(logger, comEx, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs index 632bd3e60..bafee4005 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs @@ -46,6 +46,7 @@ internal sealed class InteractiveDesktopLock : IInteractiveDesktopLock private readonly IPollDelay _pollDelay; private readonly IAnsiConsole _console; private readonly ILogger _logger; + private readonly IMonotonicClock _clock; private readonly InteractiveDesktopScheduler _scheduler; public InteractiveDesktopLock( @@ -67,6 +68,7 @@ public InteractiveDesktopLock( _pollDelay = pollDelay; _console = console; _logger = logger; + _clock = clock; _scheduler = new InteractiveDesktopScheduler(clock); } @@ -189,6 +191,14 @@ private sealed class CoordinatedExecution( private UiTurnAction _turnAction = UiTurnAction.New; private int _observedQueueDepth; + /// + /// Monotonic tick at which the turn this command runs under was claimed, copied from the state + /// every time this command observes the turn it belongs to. Reported as the turn-age bucket, so + /// it measures how long the whole workflow has held the desktop rather than how long this one + /// command waited (spec §16). Null for detached observations, which hold no turn. + /// + private long? _turnStartedTick64; + public UiTurnMode Mode { get; private set; } = mode; public long WaitedMs { get; private set; } @@ -333,6 +343,7 @@ private void RegisterObserve(InteractiveDesktopState state) } _turnAction = admission.TurnAction; + _turnStartedTick64 = TurnStartTick(state); coordinator._store.Publish(state); } @@ -356,6 +367,9 @@ private void RegisterParticipating(InteractiveDesktopState state) _ticket = admission.Ticket; _turnAction = admission.TurnAction; + _turnStartedTick64 = admission.Admission == UiAdmission.GlobalWaiter + ? null + : TurnStartTick(state); _observedQueueDepth = InteractiveDesktopScheduler.CountLiveWaiters(state, _probe); coordinator._store.Publish(state); @@ -409,6 +423,10 @@ private async Task WaitUntilRunnableAsync(CancellationToken cancellationToken) WaitedMs = _waitWatch.ElapsedMilliseconds; Mode = entry.Mode; _ticket = entry.Ticket; + + // A global waiter only learns its turn's start once it has been promoted into + // ownerCommands, which may be many turns after it queued. + _turnStartedTick64 = TurnStartTick(state); return; } @@ -518,6 +536,9 @@ public async Task EscalateToDesktopExclusiveAsync(CancellationToken cancellation state, _probe, owner, participant, UiTurnMode.DesktopExclusive); _ticket = admission.Ticket; _turnAction = admission.TurnAction; + _turnStartedTick64 = admission.Admission == UiAdmission.GlobalWaiter + ? null + : TurnStartTick(state); _detached = false; } @@ -629,7 +650,33 @@ private void PublishTelemetry(bool completedNormally, UiCoordinationOutcome outc effectiveOutcome, WaitedMs, _observedQueueDepth, - _waitWatch.ElapsedMilliseconds)); + MeasureTurnAgeMs())); + } + + /// + /// The turn's claim tick, or when it is unknown. Zero is the "no turn" + /// sentinel written by CreateFresh and by idle expiry, and is also what an older state + /// file that predates the field deserializes to — measuring an age from it would report the + /// machine's uptime. + /// + private static long? TurnStartTick(InteractiveDesktopState state) + => state.TurnStartedTick64 == 0 ? null : state.TurnStartedTick64; + + /// + /// How long the turn this command ran under had been held when the command finished. Zero for a + /// detached observation and for a command that never acquired a turn, which have no turn to age. + /// + private long MeasureTurnAgeMs() + { + if (_turnStartedTick64 is not { } startedTick) + { + return 0; + } + + // Clamped rather than trusted: the tick was written by whichever process claimed the turn, + // and a state file that survived a reboot carries pre-restart ticks. + var age = coordinator._clock.NowTicks64 - startedTick; + return age > 0 ? age : 0; } /// diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs index 8d5dcd8a9..f7b7f1aca 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs @@ -160,17 +160,28 @@ public UiAdmissionResult BeginParticipating( UiParticipantIdentity participant, UiTurnMode mode) { + // Captured before normalization, which is what releases an expired owner: afterwards there is no + // way to tell "the desktop was free" from "another workflow's idle grace just ran out". + var previousOwnerKey = state.Owner?.Key; + Normalize(state, probe); var liveWaiters = CountLiveWaiters(state, probe); if (state.Owner is null && liveWaiters == 0) { - state.Owner = ToOwnerRecord(owner); - state.TurnId++; + // A previous owner released by the normalization above means this command did not simply + // find a free desktop — it took over a turn whose idle grace had run out (spec §10.7). + var handoff = previousOwnerKey is not null + && !string.Equals(previousOwnerKey, owner.Key, StringComparison.Ordinal); + + ClaimTurn(state, ToOwnerRecord(owner)); AddOwnerCommand(state, participant, mode); ApplyOwnerLocalEligibility(state); - return Describe(state, participant, UiTurnAction.New); + return Describe( + state, + participant, + handoff ? UiTurnAction.HandoffAfterIdle : UiTurnAction.New); } if (state.Owner is not null && OwnerMatches(state.Owner, owner)) @@ -430,10 +441,11 @@ private bool ExpireIdleTurn(InteractiveDesktopState state) state.Owner = null; state.IdleExpiresTick64 = 0; + state.TurnStartedTick64 = 0; return true; } - private static bool PromoteOldestWaiter(InteractiveDesktopState state, ICoordinationLivenessProbe probe) + private bool PromoteOldestWaiter(InteractiveDesktopState state, ICoordinationLivenessProbe probe) { if (state.Owner is not null) { @@ -451,16 +463,27 @@ private static bool PromoteOldestWaiter(InteractiveDesktopState state, ICoordina return false; } - state.Owner = new OwnerRecord + ClaimTurn(state, new OwnerRecord { Kind = oldest.OwnerKind, Key = oldest.OwnerKey, DiagnosticParentPid = oldest.DiagnosticParentPid, ParentStartTicksUtc = oldest.ParentStartTicksUtc, - }; + }); + return true; + } + + /// + /// Installs as the current owner and stamps the turn. The only writer + /// of and + /// , so the two can never disagree. + /// + private void ClaimTurn(InteractiveDesktopState state, OwnerRecord ownerRecord) + { + state.Owner = ownerRecord; state.TurnId++; + state.TurnStartedTick64 = clock.NowTicks64; state.IdleExpiresTick64 = 0; - return true; } /// diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopState.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopState.cs index 4337d984b..df022980a 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopState.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopState.cs @@ -29,6 +29,14 @@ internal sealed class InteractiveDesktopState /// Incremented every time the turn is claimed by an owner. Diagnostic and test observability. public long TurnId { get; set; } + /// + /// Monotonic tick at which the current turn was claimed, written alongside every + /// increment. Lets any participant report how long the workflow turn + /// has been held — across all of that owner's commands — rather than how long its own command + /// waited (spec §16 turn-age bucket). Zero when no owner holds the turn. + /// + public long TurnStartedTick64 { get; set; } + /// Next globally monotonic arrival ticket. Tickets order the barrier and the global FIFO. public long NextTicket { get; set; } = 1; @@ -60,6 +68,7 @@ internal sealed class InteractiveDesktopState { Version = CurrentVersion, TurnId = 0, + TurnStartedTick64 = 0, NextTicket = 1, Owner = null, IdleExpiresTick64 = 0, diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs index 441549421..2dfeb8f8b 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs @@ -46,16 +46,20 @@ internal sealed class UiCoordinationException(string code, string message, strin /// internal enum UiTurnAction { - /// The command started a new turn because the desktop was free. + /// The command started a new turn because no other workflow held or wanted the desktop. New, /// The command joined a turn its owner already held. Continuation, - /// The command waited in the global queue before acquiring the turn. + /// The command waited in the global queue behind another owner before acquiring the turn. Queued, - /// The command acquired the turn after another owner's idle grace expired. + /// + /// The command claimed the turn without queueing because another owner's idle grace had already + /// expired when it registered (spec §10.7). Distinct from , where the desktop was + /// genuinely unowned, and from , where the command had to wait its turn. + /// HandoffAfterIdle, /// A non-owner observation that ran concurrently without claiming the turn. @@ -101,7 +105,11 @@ internal sealed record UiCoordinationSummary( /// Coarse queue-depth bucket. public string QueueDepthBucket => Bucket(QueueDepth, [0, 1, 2, 4, 8, 16]); - /// Coarse turn-age bucket. + /// + /// Coarse turn-age bucket: how long the owning workflow had held the desktop when this command + /// finished, spanning every command in that turn. Distinct from , which + /// covers only this command's own queue wait. + /// public string TurnAgeBucket => Bucket(TurnAgeMs, [0, 1_000, 5_000, 30_000, 120_000, 600_000]); private static string Bucket(long value, long[] edges) diff --git a/src/winapp-npm/scripts/generate-docs.mjs b/src/winapp-npm/scripts/generate-docs.mjs index f429428c4..81c30d0cc 100644 --- a/src/winapp-npm/scripts/generate-docs.mjs +++ b/src/winapp-npm/scripts/generate-docs.mjs @@ -31,7 +31,10 @@ const OUTPUT = resolve(NPM_ROOT, '../../docs/npm-usage.md'); // --------------------------------------------------------------------------- // CommonOptions properties — documented once, skipped in per-function tables // --------------------------------------------------------------------------- -const COMMON_OPTION_NAMES = new Set(['quiet', 'verbose', 'cwd']); +const COMMON_OPTION_NAMES = new Set(['quiet', 'verbose', 'cwd', 'signal']); + +// Rendered wherever a section says which inherited options also apply. +const COMMON_OPTION_NOTE = '(`quiet`, `verbose`, `cwd`, `signal`)'; // --------------------------------------------------------------------------- // Create TypeScript program from tsconfig.json @@ -128,6 +131,21 @@ function typeStr(type) { return checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation); } +/** + * Make arbitrary JSDoc text safe for a single Markdown table cell. + * + * A multi-paragraph JSDoc comment (such as `CommonOptions.signal`) otherwise ends the table row at + * its first newline and dumps the remainder as body text, corrupting every table that renders it. + */ +function tableCell(text) { + if (!text) return ''; + return text + .replace(/\|/g, '\\|') + .replace(/\r?\n\s*\r?\n/g, '

') + .replace(/\r?\n\s*/g, ' ') + .trim(); +} + // --------------------------------------------------------------------------- // Emit a function section // --------------------------------------------------------------------------- @@ -181,12 +199,12 @@ function emitFunction(lines, name, symbol, isCLIWrapper) { const propDecl = prop.valueDeclaration || prop.declarations?.[0]; let pt = typeStr(getSymType(prop)); pt = pt.replace(/\\/g, '\\\\').replace(/\|/g, '\\|'); - const pdoc = getDoc(prop); + const pdoc = tableCell(getDoc(prop)); const opt = isOptionalDecl(propDecl); lines.push(`| \`${prop.getName()}\` | \`${pt}\` | ${opt ? 'No' : 'Yes'} | ${pdoc} |`); } lines.push(''); - lines.push('*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).*'); + lines.push(`*Also accepts [CommonOptions](#commonoptions) ${COMMON_OPTION_NOTE}.*`); lines.push(''); } } @@ -205,7 +223,7 @@ function emitFunction(lines, name, symbol, isCLIWrapper) { const text = ts.displayPartsToString(t.text || []); return text.startsWith(param.getName()); }); - const desc = pTag ? paramTagDesc(pTag) : ''; + const desc = pTag ? tableCell(paramTagDesc(pTag)) : ''; lines.push(`| \`${param.getName()}\` | \`${pType.replace(/\\/g, '\\\\').replace(/\|/g, '\\|')}\` | ${opt ? 'No' : 'Yes'} | ${desc} |`); } lines.push(''); @@ -268,7 +286,7 @@ function emitType(lines, name, symbol, external) { const propDecl = prop.valueDeclaration || prop.declarations?.[0]; let pt = typeStr(getSymType(prop)); pt = pt.replace(/\\/g, '\\\\').replace(/\|/g, '\\|'); - const pdoc = getDoc(prop); + const pdoc = tableCell(getDoc(prop)); const opt = isOptionalDecl(propDecl); lines.push(`| \`${prop.getName()}\` | \`${pt}\` | ${opt ? 'No' : 'Yes'} | ${pdoc} |`); } @@ -379,7 +397,7 @@ function generate() { // --- CLI command wrappers --- L('## CLI command wrappers'); L(); - L('These functions wrap native `winapp` CLI commands. All accept [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).'); + L(`These functions wrap native \`winapp\` CLI commands. All accept [CommonOptions](#commonoptions) ${COMMON_OPTION_NOTE}.`); L(); for (const { name, symbol } of cliCommandFns) { diff --git a/src/winapp-npm/test/npm-usage-doc.test.ts b/src/winapp-npm/test/npm-usage-doc.test.ts new file mode 100644 index 000000000..02e49550e --- /dev/null +++ b/src/winapp-npm/test/npm-usage-doc.test.ts @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import * as fs from 'fs'; +import * as path from 'path'; + +// docs/npm-usage.md is generated by scripts/generate-docs.mjs from the TypeScript API surface. A +// multi-paragraph JSDoc comment (CommonOptions.signal) used to be emitted verbatim, so its embedded +// newlines terminated the table row and dumped the rest as body text — in every command table. +// npm scripts run from src/winapp-npm, matching how ui-record-guard.test.ts resolves generated files. +const DOC_PATH = path.resolve(process.cwd(), '..', '..', 'docs', 'npm-usage.md'); + +function readDoc(): string[] { + return fs.readFileSync(DOC_PATH, 'utf8').split(/\r?\n/); +} + +test('every generated Markdown table row is a single well-formed line', () => { + const lines = readDoc(); + const broken: string[] = []; + let inFence = false; + + for (const line of lines) { + if (line.startsWith('```')) { + inFence = !inFence; + continue; + } + if (inFence || !line.startsWith('|')) continue; + if (!line.trimEnd().endsWith('|')) broken.push(line); + } + + assert.deepEqual(broken, [], 'table rows must open and close with a pipe on one line'); +}); + +test('CommonOptions properties are not repeated in per-command option tables', () => { + const lines = readDoc(); + const start = lines.indexOf('## CLI command wrappers'); + const end = lines.indexOf('## Utility functions'); + assert.ok(start >= 0 && end > start, 'expected a CLI command wrappers section'); + + // The trailing "Types reference" section deliberately expands every exported interface, inherited + // members included; only the per-command tables must stay free of CommonOptions noise. + const wrapperSection = lines.slice(start, end); + const leaked = wrapperSection.filter((line) => + ['quiet', 'verbose', 'cwd', 'signal'].some((prop) => line.startsWith(`| \`${prop}\``)) + ); + + assert.deepEqual(leaked, [], 'CommonOptions belong in the shared note, not in each command table'); +}); + +test('the single CommonOptions signal row carries the full cancellation contract', () => { + const lines = readDoc(); + const start = lines.indexOf('### `CommonOptions`'); + const end = lines.indexOf('### `WinappResult`'); + assert.ok(start >= 0 && end > start, 'expected a CommonOptions section'); + + const signalRows = lines.slice(start, end).filter((line) => line.startsWith('| `signal`')); + assert.equal(signalRows.length, 1); + assert.ok( + signalRows[0].includes('AbortError'), + 'the flattened signal row must keep its whole multi-paragraph contract' + ); +}); + +test('the shared-options note lists every CommonOptions property', () => { + const doc = fs.readFileSync(DOC_PATH, 'utf8'); + const notes = doc.match(/\[CommonOptions\]\(#commonoptions\) \(([^)]*)\)/g) ?? []; + + assert.ok(notes.length > 0, 'expected at least one shared-options note'); + for (const note of notes) { + for (const prop of ['quiet', 'verbose', 'cwd', 'signal']) { + assert.ok(note.includes(`\`${prop}\``), `${note} must mention ${prop}`); + } + } +}); From 6976ef37b166b43f4154203a7f74b4fdd4d04074 Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:04:41 -0700 Subject: [PATCH 05/29] Escape backslashes in generated Markdown tables Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8ff5ec0b-09ea-4a4e-baef-2e5d807feb1e --- docs/npm-usage.md | 4 +- src/winapp-npm/scripts/generate-docs.mjs | 17 +-- .../scripts/markdown-table-cell.mjs | 39 +++++++ .../test/markdown-table-cell.test.ts | 105 ++++++++++++++++++ 4 files changed, 148 insertions(+), 17 deletions(-) create mode 100644 src/winapp-npm/scripts/markdown-table-cell.mjs create mode 100644 src/winapp-npm/test/markdown-table-cell.test.ts diff --git a/docs/npm-usage.md b/docs/npm-usage.md index 3d6b0475f..abffd2720 100644 --- a/docs/npm-usage.md +++ b/docs/npm-usage.md @@ -872,7 +872,7 @@ function uiSendKeys(options?: UiSendKeysOptions): Promise | Property | Type | Required | Description | |----------|------|----------|-------------| -| `keys` | `string \| undefined` | No | Keys to send. Whitespace-separated tokens: named keys (down, enter, tab, esc, f5), modifier combos (ctrl+shift+t, alt+f4), raw virtual keys (vk=0x42), or literal text (hello). Use text= to type a single value verbatim when it would otherwise be read as a key name or combo (text=enter types "enter"; text=ctrl+a types "ctrl+a"); backslash escapes \s \t \n \r \\ are supported (text=a\s\sb types "a b"). To type the whole argument literally without escaping each token, pass --verbatim instead. Quote multi-token strings, e.g. "ctrl+a delete". | +| `keys` | `string \| undefined` | No | Keys to send. Whitespace-separated tokens: named keys (down, enter, tab, esc, f5), modifier combos (ctrl+shift+t, alt+f4), raw virtual keys (vk=0x42), or literal text (hello). Use text= to type a single value verbatim when it would otherwise be read as a key name or combo (text=enter types "enter"; text=ctrl+a types "ctrl+a"); backslash escapes \\s \\t \\n \\r \\\\ are supported (text=a\\s\\sb types "a b"). To type the whole argument literally without escaping each token, pass --verbatim instead. Quote multi-token strings, e.g. "ctrl+a delete". | | `allowSystemKeys` | `boolean \| undefined` | No | Allow synthesizing system-/shell-reserved combos (win+, alt+f4, alt+tab, ctrl+esc, …) via --via send-input, which are refused by default because they act on the OS/shell beyond the target app. Opt in to drive global hotkeys (e.g. PowerToys' win+shift+v, win+r). No effect on --via post-message (already window-scoped; a warning is emitted if set without send-input). Note: win+l and ctrl+alt+del stay blocked even with this flag — win+l locks the workstation (LockWorkStation() via the shell hook), which is unrecoverable from automation, and ctrl+alt+del is a Secure Attention Sequence (SAS) that Windows drops from injected input regardless of this flag, so it can never take effect. | | `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | | `json` | `boolean \| undefined` | No | Format output as JSON | @@ -1881,7 +1881,7 @@ type ManifestTemplates = "packaged" | "sparse" | Property | Type | Required | Description | |----------|------|----------|-------------| -| `keys` | `string \| undefined` | No | Keys to send. Whitespace-separated tokens: named keys (down, enter, tab, esc, f5), modifier combos (ctrl+shift+t, alt+f4), raw virtual keys (vk=0x42), or literal text (hello). Use text= to type a single value verbatim when it would otherwise be read as a key name or combo (text=enter types "enter"; text=ctrl+a types "ctrl+a"); backslash escapes \s \t \n \r \\ are supported (text=a\s\sb types "a b"). To type the whole argument literally without escaping each token, pass --verbatim instead. Quote multi-token strings, e.g. "ctrl+a delete". | +| `keys` | `string \| undefined` | No | Keys to send. Whitespace-separated tokens: named keys (down, enter, tab, esc, f5), modifier combos (ctrl+shift+t, alt+f4), raw virtual keys (vk=0x42), or literal text (hello). Use text= to type a single value verbatim when it would otherwise be read as a key name or combo (text=enter types "enter"; text=ctrl+a types "ctrl+a"); backslash escapes \\s \\t \\n \\r \\\\ are supported (text=a\\s\\sb types "a b"). To type the whole argument literally without escaping each token, pass --verbatim instead. Quote multi-token strings, e.g. "ctrl+a delete". | | `allowSystemKeys` | `boolean \| undefined` | No | Allow synthesizing system-/shell-reserved combos (win+, alt+f4, alt+tab, ctrl+esc, …) via --via send-input, which are refused by default because they act on the OS/shell beyond the target app. Opt in to drive global hotkeys (e.g. PowerToys' win+shift+v, win+r). No effect on --via post-message (already window-scoped; a warning is emitted if set without send-input). Note: win+l and ctrl+alt+del stay blocked even with this flag — win+l locks the workstation (LockWorkStation() via the shell hook), which is unrecoverable from automation, and ctrl+alt+del is a Secure Attention Sequence (SAS) that Windows drops from injected input regardless of this flag, so it can never take effect. | | `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | | `json` | `boolean \| undefined` | No | Format output as JSON | diff --git a/src/winapp-npm/scripts/generate-docs.mjs b/src/winapp-npm/scripts/generate-docs.mjs index 81c30d0cc..e46852086 100644 --- a/src/winapp-npm/scripts/generate-docs.mjs +++ b/src/winapp-npm/scripts/generate-docs.mjs @@ -15,6 +15,8 @@ import { createRequire } from 'node:module'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import { tableCell } from './markdown-table-cell.mjs'; + const require = createRequire(import.meta.url); const ts = require('typescript'); @@ -131,21 +133,6 @@ function typeStr(type) { return checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation); } -/** - * Make arbitrary JSDoc text safe for a single Markdown table cell. - * - * A multi-paragraph JSDoc comment (such as `CommonOptions.signal`) otherwise ends the table row at - * its first newline and dumps the remainder as body text, corrupting every table that renders it. - */ -function tableCell(text) { - if (!text) return ''; - return text - .replace(/\|/g, '\\|') - .replace(/\r?\n\s*\r?\n/g, '

') - .replace(/\r?\n\s*/g, ' ') - .trim(); -} - // --------------------------------------------------------------------------- // Emit a function section // --------------------------------------------------------------------------- diff --git a/src/winapp-npm/scripts/markdown-table-cell.mjs b/src/winapp-npm/scripts/markdown-table-cell.mjs new file mode 100644 index 000000000..c6d87c7c1 --- /dev/null +++ b/src/winapp-npm/scripts/markdown-table-cell.mjs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +/** + * Markdown table-cell escaping for the docs generator. + * + * Kept in its own module so it can be unit-tested directly: importing generate-docs.mjs would run + * the whole generator, which builds a TypeScript program and rewrites docs/npm-usage.md. + */ + +/** + * Make arbitrary JSDoc text safe for a single Markdown table cell. + * + * Two separate hazards, both of which have broken this file's tables before: + * + * - A multi-paragraph comment (such as `CommonOptions.signal`) ends the row at its first newline and + * dumps the remainder as body text. + * - A literal `|` splits the row into extra columns. + * + * Backslashes are escaped *first*. Escaping only the pipe is incomplete: text containing `\|` would + * become `\\|`, which Markdown renders as a literal backslash followed by an unescaped pipe — the + * exact breakage the pipe escaping exists to prevent. A trailing lone backslash would likewise + * escape the row's own closing delimiter. + * + * This is for plain Markdown text. Values rendered inside a code span need different treatment, + * because a code span does not process backslash escapes and would show the doubled backslashes. + * + * @param {string | undefined | null} text Raw documentation text. + * @returns {string} A single-line, table-safe cell value. + */ +export function tableCell(text) { + if (!text) return ''; + return text + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|') + .replace(/\r?\n\s*\r?\n/g, '

') + .replace(/\r?\n\s*/g, ' ') + .trim(); +} diff --git a/src/winapp-npm/test/markdown-table-cell.test.ts b/src/winapp-npm/test/markdown-table-cell.test.ts new file mode 100644 index 000000000..bab95190a --- /dev/null +++ b/src/winapp-npm/test/markdown-table-cell.test.ts @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import * as path from 'path'; +import { pathToFileURL } from 'url'; + +type TableCell = (text: string | undefined | null) => string; + +// The npm package compiles to CommonJS, so a plain `await import()` would be transpiled to require() +// and fail on an ESM .mjs module. This indirection keeps a real dynamic import at runtime. +const importEsm = new Function('specifier', 'return import(specifier)') as ( + specifier: string +) => Promise<{ tableCell: TableCell }>; + +// npm scripts run from src/winapp-npm, matching how ui-record-guard.test.ts resolves generated files. +const MODULE_URL = pathToFileURL(path.resolve(process.cwd(), 'scripts', 'markdown-table-cell.mjs')).href; + +function loadTableCell(): Promise { + return importEsm(MODULE_URL).then((mod) => mod.tableCell); +} + +// String.raw cannot express a trailing backslash, and stacked escapes are easy to misread, so the +// cases below build their strings from this constant and annotate the characters they contain. +const BS = '\\'; + +/** + * Counts pipes that would still split a Markdown table row. A backslash escapes the character that + * follows it, so `\\` is a literal backslash that protects nothing and `\|` is a safe pipe. + */ +function countUnescapedPipes(cell: string): number { + let count = 0; + for (let i = 0; i < cell.length; i++) { + if (cell[i] === BS) { + i++; // the next character is escaped, whatever it is + continue; + } + if (cell[i] === '|') count++; + } + return count; +} + +test('a backslash before a pipe cannot cancel the pipe escape', async () => { + const tableCell = await loadTableCell(); + + // Escaping only the pipe turns `\|` into `\\|`, which Markdown renders as a literal backslash + // followed by an *unescaped* pipe — so the row splits anyway. + const cell = tableCell(`a${BS}|b`); // a \ | b + + assert.equal(cell, `a${BS}${BS}${BS}|b`); // a \ \ \ | b + assert.equal(countUnescapedPipes(cell), 0); +}); + +test('a trailing backslash cannot escape the row delimiter', async () => { + const tableCell = await loadTableCell(); + + const cell = tableCell(`a path ending in C:${BS}`); // ...C:\ + + assert.equal(cell, `a path ending in C:${BS}${BS}`); // ...C:\\ + assert.ok(cell.endsWith(`${BS}${BS}`), 'the closing pipe this generator emits after the cell must survive'); +}); + +test('Windows paths keep their backslashes literal while pipes stay escaped', async () => { + const tableCell = await loadTableCell(); + + const cell = tableCell(`use C:${BS}Users${BS}me | or D:${BS}tmp`); + + assert.equal(cell, `use C:${BS}${BS}Users${BS}${BS}me ${BS}| or D:${BS}${BS}tmp`); + assert.equal(countUnescapedPipes(cell), 0); +}); + +test('multi-paragraph text collapses to a single line', async () => { + const tableCell = await loadTableCell(); + + const cell = tableCell('First paragraph\nwrapped here.\n\nSecond paragraph.'); + + assert.equal(cell, 'First paragraph wrapped here.

Second paragraph.'); + assert.ok(!/\r|\n/.test(cell), 'a table row must not contain a line break'); +}); + +test('backslash, pipe and newlines survive together', async () => { + const tableCell = await loadTableCell(); + + const cell = tableCell(`Pass a${BS}|b to the filter.\r\n\r\nSee C:${BS}logs\nfor output.`); + + assert.equal(countUnescapedPipes(cell), 0); + assert.ok(!/\r|\n/.test(cell)); + assert.ok(cell.includes('

'), 'the paragraph break must be preserved'); + assert.ok(cell.includes(`C:${BS}${BS}logs`), 'path backslashes must stay literal'); +}); + +test('empty input yields an empty cell', async () => { + const tableCell = await loadTableCell(); + + assert.equal(tableCell(''), ''); + assert.equal(tableCell(undefined), ''); + assert.equal(tableCell(null), ''); +}); + +test('ordinary prose is passed through untouched apart from trimming', async () => { + const tableCell = await loadTableCell(); + + assert.equal(tableCell(' Suppress progress messages. '), 'Suppress progress messages.'); +}); From 7cf27882d088fde97748158ca3c6a3efade9078b Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:06:24 -0700 Subject: [PATCH 06/29] Fix six high findings from the PR #767 re-review H1 CI gating. InteractiveDesktopMultiprocessTests and InteractiveDesktopRealAppTests are gated on WINAPP_UI_MULTIPROCESS_TESTS=1, but no workflow ever set it, so all 11 skipped everywhere and the coordination protocol was effectively unverified in CI. They now run in the existing interactive e2e-test-ui job, after the CLI artifacts are downloaded, with the gate set explicitly. The step asserts against the TRX counters rather than the exit code, because a run that skips everything still exits 0: zero matched tests, any skip, or any failure fails the job. Verified both ways locally - 11/11 pass with the gate set, and with it unset the guard trips on 11 skips. The existing WinUI E2E step is untouched. H2 recording pre-start cancellation. The catch at UiRecordCommand ~400 swallowed a native Ctrl+C that arrived before capture started and returned 1. The coordinator decides whether to renew the owner's idle grace from whether the body returned or threw, so a recording that produced nothing looked like a completed command and kept the desktop reserved. It now propagates when the caller's token is cancelled, and InteractiveDesktopLock handles post-acquisition cancellation with the documented contract - exit 130, the `cancelled` envelope, no grace renewal - instead of letting it escape as "an unexpected error occurred". The established behaviour is preserved and pinned by its own test: an ACTIVE recording that observes cancellation, finalizes its MP4 and returns success is a completed command and still renews. H3 send-input focus drift. The foreground was verified before an awaited FocusAsync and never rechecked before injection. Setting focus can itself change the foreground - a focus handler activating another window, or any app stealing focus during the await - and the published repro exited 0 while HELLO landed in a decoy window. send-input now re-verifies after focusing; nothing between that check and keyboardInput.Send awaits. post-message posts straight to the target HWND's queue and is deliberately unaffected. H4 legitimate cross-process owned windows. DesktopTargetValidation rejected any target HWND whose PID differed from the session's, but GetAllAppWindows intentionally discovers cross-process windows the app OWNS - common-item file pickers and system dialogs - and tags elements with those foreign HWNDs, so every such target became unreachable after a queue wait. Validation now accepts a live target whose GW_OWNER chain reaches a live window belonging to the expected PID, mirroring the exact association discovery uses. Recycled-handle protection is preserved: each owner link is checked for liveness AND for belonging to the expected process, so a dead or reused owner cannot launder an unrelated window in. The walk is depth-capped and cycle-guarded. H5 future state version. InteractiveDesktopStateStore deserialized strongly before checking the version, so a newer schema that changes field SHAPES rather than merely adding fields threw JsonException and was classified as corruption - quarantined and replaced with a v1 document. The published repro had `owner` as a string and `ui list-windows` silently downgraded the file. The root version is now read with JsonDocument first and an unknown newer version returns UnknownNewerVersion before any typed deserialization, leaving the bytes untouched. Malformed JSON and a non-numeric version still take the existing corruption-recovery path. H6 foreground capture verification. screenshot --capture-screen and record --capture-screen requested the foreground and then BitBlt'd the screen without confirming activation took. SetForegroundWindow is advisory, so a refusal produced a PNG/MP4 of whatever window was actually in front while the command exited 0 - the repro captured a magenta decoy for a green target. Both paths now verify after the activation delay and immediately before capture, through one shared helper, and report the existing foreground_not_target contract rather than internal_error. No artifact is written on refusal. Window-targeted capture (WGC/PrintWindow) is unaffected. Regression tests fail before their fix and pass after: 10 for the owner chain, 3 for the version probe, 3 for focus drift, 2 for pre-start cancellation plus the positive finalized-recording case, 1 for post-acquisition cancellation, and 2 service-level plus 2 command-level for capture verification. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-package.yml | 46 +++++ docs/ui-automation.md | 3 + .../skills/winapp-ui-automation/SKILL.md | 3 +- .../DesktopTargetValidationTests.cs | 164 ++++++++++++++++++ .../FakeDesktopForegroundService.cs | 18 ++ .../FakeInteractiveDesktopLock.cs | 30 +++- .../WinApp.Cli.Tests/FakeUiServices.cs | 19 +- .../InteractiveDesktopLockTests.cs | 33 ++++ .../InteractiveDesktopStoreTests.cs | 47 +++++ .../UiAutomationServicePureTests.cs | 35 +++- .../UiCommandTests.CaptureForeground.cs | 60 +++++++ .../UiCommandTests.Coordination.cs | 142 +++++++++++++++ .../WinApp.Cli/Commands/UiRecordCommand.cs | 30 +++- .../Commands/UiScreenshotCommand.cs | 9 + .../WinApp.Cli/Commands/UiSendKeysCommand.cs | 16 ++ .../CaptureForegroundNotTargetException.cs | 21 +++ .../Helpers/DesktopTargetValidation.cs | 66 ++++++- .../Helpers/IDesktopForegroundService.cs | 15 ++ .../InteractiveDesktopLock.cs | 46 +++-- .../InteractiveDesktopStateStore.cs | 45 ++++- .../Services/UiAutomationService.Record.cs | 5 + .../UiAutomationService.Screenshot.cs | 7 + .../Services/UiAutomationService.cs | 22 +++ 23 files changed, 860 insertions(+), 22 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/DesktopTargetValidationTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.CaptureForeground.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Helpers/CaptureForegroundNotTargetException.cs diff --git a/.github/workflows/build-package.yml b/.github/workflows/build-package.yml index d688be30a..f08e8b683 100644 --- a/.github/workflows/build-package.yml +++ b/.github/workflows/build-package.yml @@ -161,6 +161,52 @@ jobs: $platform = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "arm64" } else { "x64" } .\scripts\test-e2e-winui-ui.ps1 -WinAppPath "artifacts/cli/win-$platform/winapp.exe" + # Cooperative desktop-turn coverage (issue #764 §18.2/§18.3). These drive real winapp.exe child + # processes against a real foreground window, so they are gated off the canonical build and only + # run here, on the interactive lane, after the CLI binaries above are on disk. Without this step + # the gate was never set by any workflow and all of them silently skipped everywhere. + - name: Run UI coordination multiprocess and real-app tests + env: + WINAPP_UI_MULTIPROCESS_TESTS: "1" + run: | + $filter = "FullyQualifiedName~InteractiveDesktopMultiprocessTests|FullyQualifiedName~InteractiveDesktopRealAppTests" + $results = Join-Path $PWD "artifacts/TestResults/ui-coordination" + New-Item -ItemType Directory -Path $results -Force | Out-Null + + dotnet run --project src/winapp-CLI/WinApp.Cli.Tests/WinApp.Cli.Tests.csproj -c Debug ` + --results-directory $results --report-trx --report-trx-filename ui-coordination.trx ` + --filter $filter + $testExit = $LASTEXITCODE + + # A skip here is a silent failure: it means the gate or the published-binary lookup regressed and + # nothing was actually verified. Assert against the TRX rather than trusting the exit code, which + # is 0 for a run that skipped everything. + $trx = Get-ChildItem -Path $results -Filter *.trx -Recurse | Sort-Object LastWriteTime | Select-Object -Last 1 + if (-not $trx) { throw "No TRX produced: the UI coordination tests did not run." } + + [xml]$doc = Get-Content $trx.FullName + $counters = $doc.TestRun.ResultSummary.Counters + $total = [int]$counters.total + $passed = [int]$counters.passed + $failed = [int]$counters.failed + # MSTest reports Assert.Inconclusive (the gate's skip path) under notExecuted. + $skipped = [int]$counters.notExecuted + [int]$counters.inconclusive + Write-Host "UI coordination tests: total=$total passed=$passed failed=$failed skipped=$skipped" + + # Currently 8 multiprocess + 3 real-app = 11. Asserted dynamically rather than pinned to 11 so + # adding coverage does not fail the build, while a filter that stops matching still does. + if ($total -eq 0) { throw "The UI coordination filter matched no tests — it no longer selects the gated suites." } + if ($skipped -gt 0) { throw "$skipped UI coordination test(s) skipped; the gate must run them here, not skip them." } + if ($failed -gt 0 -or $testExit -ne 0) { throw "UI coordination tests failed (exit $testExit)." } + + - name: Upload UI coordination test results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ui-coordination-test-results + path: artifacts/TestResults/ui-coordination/ + if-no-files-found: warn + - name: Upload screenshots if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/docs/ui-automation.md b/docs/ui-automation.md index 3cda6a655..9abdc37ed 100644 --- a/docs/ui-automation.md +++ b/docs/ui-automation.md @@ -298,6 +298,8 @@ The default capture path uses **Windows.Graphics.Capture (WGC)**, reading the ac Use `--capture-screen` when you need to capture popup menus, dropdowns, flyouts, or tooltip overlays that aren't owned by the target window. `--capture-screen` reads from the screen DC and brings the window to the foreground first. Use `--focus` if you just want to foreground the window without switching capture modes (e.g., to ensure the screenshot matches what the user is currently looking at). +> Because the screen DC captures whatever is actually in front, `--capture-screen` **verifies the target reached the foreground immediately before capturing** and fails with **`foreground_not_target`** if it didn't (focus-stealing prevention, a UAC prompt, or another window activating itself). No image is written in that case — previously the command exited 0 and handed back a picture of the wrong window. `ui record --capture-screen` applies the same check before the first frame. + ### record Record a window or element region to an H.264 MP4. By default, recording continues until Ctrl+C or, for redirected stdin, a newline or EOF. @@ -624,6 +626,7 @@ winapp ui list-windows --show-hidden # include invisible | "No UIA window found" | UIA can't see the process | Use `list-windows` to find the HWND, then `-w` | | "Window has zero size" | Window is minimized | App will be auto-restored | | Popup/dropdown not in screenshot | Default capture is per-window and doesn't include unowned overlays | Use `--capture-screen` flag | +| `foreground_not_target` from `--capture-screen` | Windows refused the activation, so a screen capture would have recorded whatever window is actually in front | Click the target window or close the focus-stealing window and retry, or drop `--capture-screen` | | `element_not_found` during record | Selector given but no matching element | Re-run `inspect` or `search` to get a fresh selector | | WGC unavailable during record | WGC capture init failed; no silent fallback | Check GPU/driver; use `--capture-screen` to consent to screen-DC capture | diff --git a/plugins/winapp/skills/winapp-ui-automation/SKILL.md b/plugins/winapp/skills/winapp-ui-automation/SKILL.md index 2051e516a..ae352e0ed 100644 --- a/plugins/winapp/skills/winapp-ui-automation/SKILL.md +++ b/plugins/winapp/skills/winapp-ui-automation/SKILL.md @@ -158,7 +158,7 @@ winapp ui record -a myapp --capture-screen --duration-sec 5 --output with-popups - Default `--duration-sec 0` records until Ctrl+C, a newline, or EOF on redirected stdin. - `--frames` writes `.frames` with a manifest, NDJSON index, and changed JPEGs. It supports 1-30 fps and `--max-edge` 64-4096 (default 1280), with a 1 GiB cap. Use `elapsedMs` to bound transitions. - With `--frames`, existing MP4 and frame paths are not replaced. On partial failure, use the reported preserved path and `recoveryHint`. -- `--capture-screen` captures from the screen DC so overlays and popups are included; the window is brought to the foreground first. When WGC is unavailable and `--capture-screen` is not passed, the CLI returns an error — re-run with `--capture-screen` to consent to screen-DC capture. +- `--capture-screen` captures from the screen DC so overlays and popups are included; the window is brought to the foreground first. When WGC is unavailable and `--capture-screen` is not passed, the CLI returns an error — re-run with `--capture-screen` to consent to screen-DC capture. Because the screen DC captures whatever is genuinely in front, the target's foreground is **verified immediately before capture**; if activation was refused the command fails with `foreground_not_target` and writes nothing rather than returning an image of the wrong window. - Providing a selector that doesn't match any element fails immediately with `element_not_found` (rather than silently recording the whole window). - `--json` writes the final result to stdout and one JSON event per line to stderr. @@ -358,6 +358,7 @@ Full schemas with examples: `references/ui-json-envelope.md`. | "does not support any invoke pattern" | Element can't be invoked | The error shows the invokable ancestor slug if one exists — use that | | "No UIA window found" | UIA can't see the window | Use `list-windows` to find HWND, then `-w` | | Popup not in screenshot | Default capture path doesn't include unowned overlays | Use `--capture-screen` flag | +| `foreground_not_target` from `--capture-screen` | Windows refused the activation (focus-stealing prevention, UAC prompt, another window activating itself), so a screen capture would have recorded the wrong window | Click the target window, close the window that stole focus, then retry — or drop `--capture-screen` to capture the window directly | | `element_not_found` during record | Selector given but element not in tree | Re-run `inspect` or `search` to get a fresh selector | | `ambiguous_selector` during record | Plain-text selector matched multiple elements | Use a slug from the suggestions in the error message, or from `inspect` output | | WGC unavailable during record | WGC capture init failed; no silent fallback | Check GPU/driver; use `--capture-screen` to explicitly request screen DC capture | diff --git a/src/winapp-CLI/WinApp.Cli.Tests/DesktopTargetValidationTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/DesktopTargetValidationTests.cs new file mode 100644 index 000000000..b56e66183 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/DesktopTargetValidationTests.cs @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging.Abstractions; +using WinApp.Cli.Helpers; + +namespace WinApp.Cli.Tests; + +/// +/// Post-wait target validation (, issue #764 spec §10.5). +/// +/// +/// The check has to satisfy two things that pull in opposite directions: refuse a handle Windows has +/// recycled onto an unrelated process during a queue wait, while still allowing the cross-process +/// windows an app legitimately owns. UiAutomationService.GetAllAppWindows deliberately +/// discovers common-item file pickers and system dialogs — they run in another process yet are part of +/// the app's UI, and elements found on them carry that foreign HWND — so a plain PID equality check +/// made every one of those targets unreachable after a wait. +/// +[TestClass] +public class DesktopTargetValidationTests : IDisposable +{ + private const int SessionPid = 1234; + private const int ForeignPid = 5678; + + private FakeSystemUiQuery _systemQuery = null!; + private StringWriter _errorOut = null!; + + public void Dispose() + { + _errorOut?.Dispose(); + GC.SuppressFinalize(this); + } + + [TestInitialize] + public void Setup() + { + _systemQuery = new FakeSystemUiQuery(); + _errorOut = new StringWriter(); + } + + private bool Confirm(long hwnd, int expectedPid = SessionPid) + => DesktopTargetValidation.TryConfirmTargetWindow( + _systemQuery, hwnd, expectedPid, NullLogger.Instance, json: true, "click", _errorOut); + + private void AssertStaleElementEmitted() + => StringAssert.Contains(_errorOut.ToString(), "\"code\":\"stale_element\""); + + // ------------------------------------------------------------------ the straightforward cases + + [TestMethod] + public void DirectSamePidWindowIsAccepted() + { + _systemQuery.ProcessIdByHwnd[100] = SessionPid; + + Assert.IsTrue(Confirm(100)); + Assert.AreEqual(string.Empty, _errorOut.ToString(), "an accepted target emits no error"); + } + + [TestMethod] + public void BareCoordinateTargetIsAccepted() + { + // hwnd 0 means "no window to confirm" — the foreground guard is the gate for coordinates. + Assert.IsTrue(Confirm(0)); + } + + [TestMethod] + public void ClosedWindowIsRejected() + { + _systemQuery.ProcessIdByHwnd[100] = 0; + + Assert.IsFalse(Confirm(100)); + AssertStaleElementEmitted(); + } + + [TestMethod] + public void ForeignPidWithNoOwnerIsRejected() + { + // The recycled-handle case: the original window exited and Windows handed its handle to an + // unrelated top-level window. No owner chain, so nothing associates it with the session. + _systemQuery.ProcessIdByHwnd[100] = ForeignPid; + + Assert.IsFalse(Confirm(100)); + AssertStaleElementEmitted(); + } + + // -------------------------------------------------------- legitimate cross-process owned windows + + [TestMethod] + public void CrossProcessWindowOwnedByTheExpectedProcessIsAccepted() + { + // Exactly what a common-item file picker looks like: a window in another process whose + // GW_OWNER is one of the session's own windows. This is the association GetAllAppWindows uses + // to surface it in the first place, so validation must accept what discovery offered. + _systemQuery.ProcessIdByHwnd[100] = ForeignPid; + _systemQuery.WindowOwnerByHwnd[100] = 200; + _systemQuery.ProcessIdByHwnd[200] = SessionPid; + + Assert.IsTrue(Confirm(100)); + Assert.AreEqual(string.Empty, _errorOut.ToString()); + } + + [TestMethod] + public void CrossProcessWindowWhoseOwnerDiedIsRejected() + { + // The owning app exited during the queue wait; its window handle now resolves to no process. + // The picker may still be on screen, but it is no longer the session's UI. + _systemQuery.ProcessIdByHwnd[100] = ForeignPid; + _systemQuery.WindowOwnerByHwnd[100] = 200; + _systemQuery.ProcessIdByHwnd[200] = 0; + + Assert.IsFalse(Confirm(100)); + AssertStaleElementEmitted(); + } + + [TestMethod] + public void CrossProcessWindowWhoseOwnerHandleWasRecycledIsRejected() + { + // The owner handle is live but now belongs to a *different* process than the one resolved. + // Accepting on "the owner exists" alone would launder an unrelated window into the session. + _systemQuery.ProcessIdByHwnd[100] = ForeignPid; + _systemQuery.WindowOwnerByHwnd[100] = 200; + _systemQuery.ProcessIdByHwnd[200] = 9999; + + Assert.IsFalse(Confirm(100)); + AssertStaleElementEmitted(); + } + + [TestMethod] + public void OwnerChainIsFollowedThroughAnIntermediateWindow() + { + // A dialog owned by a dialog owned by the app window. + _systemQuery.ProcessIdByHwnd[100] = ForeignPid; + _systemQuery.WindowOwnerByHwnd[100] = 200; + _systemQuery.ProcessIdByHwnd[200] = ForeignPid; + _systemQuery.WindowOwnerByHwnd[200] = 300; + _systemQuery.ProcessIdByHwnd[300] = SessionPid; + + Assert.IsTrue(Confirm(100)); + } + + [TestMethod] + public void OwnerCycleIsRejectedRatherThanLoopingForever() + { + // A torn or hostile window tree must terminate the walk, not hang the command. + _systemQuery.ProcessIdByHwnd[100] = ForeignPid; + _systemQuery.WindowOwnerByHwnd[100] = 200; + _systemQuery.ProcessIdByHwnd[200] = ForeignPid; + _systemQuery.WindowOwnerByHwnd[200] = 100; + + Assert.IsFalse(Confirm(100)); + AssertStaleElementEmitted(); + } + + [TestMethod] + public void UnknownExpectedProcessSkipsTheOwnershipCheck() + { + // expectedProcessId <= 0 means the caller had no PID to compare against; the existing contract + // is to allow through rather than invent a rejection. + _systemQuery.ProcessIdByHwnd[100] = ForeignPid; + + Assert.IsTrue(Confirm(100, expectedPid: 0)); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeDesktopForegroundService.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeDesktopForegroundService.cs index 1a44fbac5..df9e8fd12 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeDesktopForegroundService.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeDesktopForegroundService.cs @@ -29,6 +29,24 @@ internal sealed class FakeDesktopForegroundService : IDesktopForegroundService public void RequestForeground(long hwnd) => ForegroundRequests.Add(hwnd); + /// + /// Whether reports success. Defaults to so the + /// ordinary "activation took" path is what tests get without opting in; set to + /// to simulate Windows refusing the activation (focus-stealing prevention, + /// a decoy window grabbing focus) and prove a screen capture refuses instead of recording the + /// wrong window. + /// + public bool ForegroundRequestSucceeds { get; set; } = true; + + /// Window handles passed to , in order. + public List ForegroundChecks { get; } = []; + + public bool IsForeground(long hwnd) + { + ForegroundChecks.Add(hwnd); + return ForegroundRequestSucceeds; + } + public bool IsMinimized(long hwnd) => AllWindowsMinimized || MinimizedWindows.Contains(hwnd); public void Restore(long hwnd) => RestoreRequests.Add(hwnd); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs index c90720b30..f28e8489b 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs @@ -43,7 +43,22 @@ internal sealed class FakeInteractiveDesktopLock : IInteractiveDesktopLock /// Milliseconds reported as queue wait, so output/telemetry paths can be exercised. public long WaitedMs { get; set; } - public Task RunCoordinatedAsync( + /// + /// Whether the most recent coordinated body threw instead of returning an exit code. + /// + /// + /// This is the exact signal the real coordinator uses to decide whether to renew the owner's idle + /// grace: a body that RETURNS is a completed command and renews, a body that THROWS did not produce + /// a result and must not. System.CommandLine flattens a propagating cancellation to exit code 1 — + /// the same code the old swallow path returned — so the exit code cannot distinguish the two and + /// tests have to observe the boundary itself. + /// + public bool LastBodyThrew { get; private set; } + + /// The exception the most recent coordinated body threw, if any. + public Exception? LastBodyException { get; private set; } + + public async Task RunCoordinatedAsync( UiTurnMode mode, string operation, ParseResult parseResult, @@ -57,7 +72,18 @@ public Task RunCoordinatedAsync( throw failure; } - return body(new FakeTurn(this, mode), cancellationToken); + LastBodyThrew = false; + LastBodyException = null; + try + { + return await body(new FakeTurn(this, mode), cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + LastBodyThrew = true; + LastBodyException = ex; + throw; + } } private sealed class FakeTurn(FakeInteractiveDesktopLock owner, UiTurnMode mode) : IUiTurn diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeUiServices.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeUiServices.cs index f4852d7ef..b4281124d 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeUiServices.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeUiServices.cs @@ -293,10 +293,18 @@ public Task SetValueAsync(UiSessionInfo session, UiElement element, string text, ///
public UiElement? LastFocusedElement { get; private set; } + /// + /// Runs inside , before it returns. Models the real hazard that setting + /// focus is an awaited round-trip into the target's UI thread during which the foreground can + /// change — a focus handler activating another window, or an unrelated app stealing focus. + /// + public Action? OnFocus { get; set; } + public Task FocusAsync(UiSessionInfo session, UiElement element, CancellationToken ct) { if (FocusThrow is not null) { throw FocusThrow; } LastFocusedElement = element; + OnFocus?.Invoke(); return Task.CompletedTask; } @@ -545,6 +553,14 @@ internal sealed class FakeSystemUiQuery : ISystemUiQuery ///
public uint ProcessIdForWindowResult { get; set; } = 1234; + /// + /// Per-HWND owning PIDs for , consulted before + /// . Needed to model an owner chain, where the target HWND + /// and the window that owns it deliberately belong to different processes (a cross-process file + /// picker owned by the app's own window). + /// + public Dictionary ProcessIdByHwnd { get; } = []; + /// Title returned by . Default null = "no/empty title". public string? WindowTextResult { get; set; } @@ -580,7 +596,8 @@ internal sealed class FakeSystemUiQuery : ISystemUiQuery public nint GetForegroundWindow() => ForegroundWindowResult; - public uint GetProcessIdForWindow(long hwnd) => ProcessIdForWindowResult; + public uint GetProcessIdForWindow(long hwnd) + => ProcessIdByHwnd.TryGetValue(hwnd, out var pid) ? pid : ProcessIdForWindowResult; public string? GetWindowText(long hwnd) => WindowTextResult; diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs index 727af66e5..7ad3f0c90 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs @@ -240,6 +240,39 @@ public async Task CoordinationSummary_StateWithoutATurnStartTickReportsNoTurnAge $"a freshly claimed turn cannot be minutes old; reported {summary.TurnAgeMs} ms"); } + [TestMethod] + public async Task CancellationAfterAcquisitionEmitsTheContractAndDoesNotRenewTheGrace() + { + // A command cancelled after it took the turn produced nothing, so it must not renew the owner's + // idle grace — and it must report the documented `cancelled` contract rather than escaping as an + // "unexpected error". This is the coordinator half of the `ui record` pre-start fix: the command + // propagates the cancellation instead of swallowing it, and this is what receives it. + Assert.AreEqual(0, await RunAsync(UiTurnMode.TurnShared, "ui record", (_, _) => Task.FromResult(0))); + var deadlineBefore = ReadOwnerDeadline(); + + await Task.Delay(50); + + var errorWriter = new StringWriter(); + var parseResult = ParseWithWriter(errorWriter); + + using var cts = new CancellationTokenSource(); + var exitCode = await _coordinator.RunCoordinatedAsync( + UiTurnMode.TurnShared, "ui record", parseResult, + async (_, token) => + { + await cts.CancelAsync(); + token.ThrowIfCancellationRequested(); + return 0; + }, + cts.Token); + + Assert.AreEqual(InteractiveDesktopLock.CancelledExitCode, exitCode, + "a cancelled command reports 130, not an internal error"); + StringAssert.Contains(errorWriter.ToString(), "\"code\":\"cancelled\""); + Assert.AreEqual(deadlineBefore, ReadOwnerDeadline(), + "a command that produced nothing must not renew the owner's idle grace"); + } + [TestMethod] public async Task ABodyThatFailsCoordinationDoesNotRenewTheOwnersGrace() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs index 76fcbe6be..1550616af 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs @@ -145,6 +145,53 @@ public void Read_UnknownNewerVersion_IsNeverResetOrDowngraded() Assert.AreEqual(future, File.ReadAllText(_paths.StatePath), "the newer document must be left alone"); } + [TestMethod] + public void Read_UnknownNewerVersionWithIncompatibleFieldShapes_IsNotTreatedAsCorruption() + { + // A newer schema may change field *shapes*, not merely add fields. Here `owner` is a string + // rather than an object, so strong deserialization throws — and the version check used to run + // only AFTER that, so a perfectly valid v99 document was classified as corruption, quarantined, + // and replaced with a v1 document. A published repro had `ui list-windows` silently downgrade + // the file. The version must therefore be read from the raw document first. + _paths.EnsureDirectories(); + const string future = """{"version":99,"owner":"a-newer-shape","turnId":7,"nextTicket":3}"""; + File.WriteAllText(_paths.StatePath, future); + + using (var stateLock = _store.AcquireStateLock(CancellationToken.None)) + { + var result = _store.Read(); + Assert.IsTrue(result.UnknownNewerVersion, "a newer schema must be reported as such, not as corruption"); + Assert.IsNull(result.State); + Assert.IsFalse(result.RecoveredFromCorruption); + } + + Assert.AreEqual(future, File.ReadAllText(_paths.StatePath), "the newer document must be left byte-for-byte"); + Assert.AreEqual( + 0, + Directory.GetFiles(_paths.LockDirectory, "state.corrupt-*.json").Length, + "a newer document must never be quarantined"); + } + + [TestMethod] + public void Read_MalformedJson_IsStillCorruptionNotAVersionDivert() + { + // The raw version probe must not swallow genuine corruption: a document that is not JSON at all + // has no readable version and has to keep taking the guarded recovery path. + WriteRawState("{this is not json"); + + AssertRecovered(); + } + + [TestMethod] + public void Read_VersionOfTheWrongJsonType_FallsThroughToCorruptionRecovery() + { + // `version` present but not a number is malformed rather than "newer", so it must not be + // mistaken for a future schema and left in place forever. + WriteRawState("""{"version":"ninety-nine","turnId":1,"nextTicket":2}"""); + + AssertRecovered(); + } + // ------------------------------------------------------------------- structural validation [TestMethod] diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiAutomationServicePureTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiAutomationServicePureTests.cs index 579c2cab9..d749dd3c4 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiAutomationServicePureTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiAutomationServicePureTests.cs @@ -4,6 +4,7 @@ using Windows.Win32.UI.Accessibility; using Windows.Win32.Foundation; using Microsoft.Extensions.Logging.Abstractions; +using WinApp.Cli.Helpers; using WinApp.Cli.Services; using WinApp.Cli.Services.InteractiveDesktop; @@ -256,10 +257,40 @@ public async Task CaptureFromWindowWithBlankRetry_NonBlankDoesNotRetry() Assert.AreEqual(1, pixels[3]); } + // --------------------------------------- screen-capture foreground verification (re-review H6) + + [TestMethod] + public void EnsureForegroundForScreenCapture_ForegroundHeld_Proceeds() + { + var foreground = new FakeDesktopForegroundService { ForegroundRequestSucceeds = true }; + var service = new UiAutomationService( + NullLogger.Instance, new SelectorService(), foreground); + + service.EnsureForegroundForScreenCapture(123, "screenshot --capture-screen"); + + CollectionAssert.AreEqual(new long[] { 123 }, foreground.ForegroundChecks, + "the capture path must verify the foreground, not merely request it"); + } + + [TestMethod] + public void EnsureForegroundForScreenCapture_ActivationRefused_ThrowsRatherThanCapturing() + { + // SetForegroundWindow is advisory. Without this check a screen-DC BitBlt returns a picture of + // whichever window is really in front while the command reports success. + var foreground = new FakeDesktopForegroundService { ForegroundRequestSucceeds = false }; + var service = new UiAutomationService( + NullLogger.Instance, new SelectorService(), foreground); + + var ex = Assert.ThrowsExactly( + () => service.EnsureForegroundForScreenCapture(123, "record --capture-screen")); + + StringAssert.Contains(ex.Message, "record --capture-screen"); + StringAssert.Contains(ex.Message, "foreground"); + } + /// Counts how many desktop sections a capture path opened. private sealed class CountingDesktopSection : IDesktopSection - { - public int Enters { get; private set; } + { public int Enters { get; private set; } public Task EnterAsync(CancellationToken cancellationToken) { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.CaptureForeground.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.CaptureForeground.cs new file mode 100644 index 000000000..9d02a83a6 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.CaptureForeground.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Commands; +using WinApp.Cli.Helpers; + +namespace WinApp.Cli.Tests; + +/// +/// Command-level mapping of a failed screen-capture foreground verification (issue #764 re-review H6). +/// +/// +/// --capture-screen BitBlts the live screen rather than a specific window, so it records +/// whatever is actually in front. SetForegroundWindow is only a request and Windows refuses it +/// under focus-stealing prevention, a UAC prompt, a locked session, or when another app activates +/// itself in the same instant. A published repro pointed at a green target while a magenta decoy held +/// the foreground: the command exited 0 and the PNG's centre pixel was magenta. Silently handing back +/// an image of the wrong app is worse than failing, because the caller cannot tell. +/// +/// The verification itself lives in UiAutomationService and is covered in +/// ; these tests pin the contract callers see — the existing +/// foreground_not_target code rather than internal_error, and no artifact written. +/// +/// +public partial class UiCommandTests +{ + private static CaptureForegroundNotTargetException CaptureRefusal() + => new("Target window is not in the foreground — refusing to capture the screen."); + + [TestMethod] + public async Task Screenshot_CaptureScreenForegroundRefused_ReportsForegroundNotTarget() + { + _fakeUia.ScreenshotThrow = CaptureRefusal(); + + var outputPath = Path.Combine(_tempDirectory.FullName, "decoy.png"); + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-a", "TestApp", "--capture-screen", "-o", outputPath, "--json"]); + + Assert.AreEqual(1, exitCode); + AssertJsonErrorCode(UiJsonError.CodeForegroundNotTarget); + Assert.IsFalse(File.Exists(outputPath), + "no image may be published when the capture would have recorded the wrong window"); + } + + [TestMethod] + public async Task Record_CaptureScreenForegroundRefused_ReportsForegroundNotTarget() + { + _fakeUia.RecordException = CaptureRefusal(); + + var outputPath = Path.Combine(_tempDirectory.FullName, "decoy.mp4"); + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-a", "TestApp", "--capture-screen", "--duration-sec", "1", "-o", outputPath, "--json"]); + + Assert.AreEqual(1, exitCode); + AssertJsonErrorCode(UiJsonError.CodeForegroundNotTarget); + Assert.IsFalse(File.Exists(outputPath), "no MP4 may be published for a refused capture"); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs index 537eb5066..e103d444f 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs @@ -2,7 +2,9 @@ // Licensed under the MIT License. using WinApp.Cli.Commands; +using WinApp.Cli.Helpers; using WinApp.Cli.Models; +using WinApp.Cli.Services; using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Tests; @@ -482,4 +484,144 @@ public async Task Record_OrdinaryFailureInsideTheBody_StaysWithTheHandler() Assert.IsFalse(stderr.Contains(UiCoordinationErrorCodes.Unavailable, StringComparison.Ordinal), $"an ordinary failure must not be reported as a coordination error; got: {stderr}"); } + + // ------------------------------------------- pre-start recording cancellation must not renew grace + + [TestMethod] + public async Task Record_CancelledBeforeCaptureStarted_DoesNotReportItselfAsACompletedCommand() + { + // The coordinator decides whether to renew the owner's idle grace from whether the body RETURNED + // or THREW. Swallowing a native cancellation here and returning 1 made a recording that produced + // nothing look like a completed command, so the workflow kept the desktop reserved for four more + // seconds on the strength of work it never did. + // + // System.CommandLine flattens a propagating OperationCanceledException to exit code 1 — the same + // code the old swallow path returned — so the exit code cannot distinguish them. The observable + // difference, and the one the coordinator actually acts on, is that the body THREW rather than + // returned. The coordinator half — 130, the `cancelled` envelope and no grace renewal — is + // asserted against the real coordinator in InteractiveDesktopLockTests. + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + _fakeUia.RecordException = new OperationCanceledException(cts.Token); + + var outputPath = Path.Combine(_tempDirectory.FullName, "cancelled-pre-start.mp4"); + var command = GetRequiredService(); + + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, + ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath, "--json"], + cts.Token); + + Assert.IsTrue(_fakeDesktopLock.LastBodyThrew, + "the body must propagate the cancellation; returning any exit code makes the coordinator treat " + + "a recording that produced nothing as a completed command and renew the owner's grace"); + Assert.IsInstanceOfType(_fakeDesktopLock.LastBodyException); + Assert.IsFalse(File.Exists(outputPath), "a recording cancelled before capture produces no artifact"); + Assert.IsFalse(ConsoleStdErr.ToString().Contains("internal_error", StringComparison.Ordinal), + $"a cancellation is not an internal error (exit {exitCode})"); + } + + [TestMethod] + public async Task Record_ThatFinalizesOnCancellationAndReturnsSuccess_StillCompletesNormally() + { + // The positive half of the same contract, kept explicit so the fix above cannot be "simplified" + // into propagating every cancellation: an ACTIVE recording observes Ctrl+C, finalizes its MP4 and + // returns success. That is a completed command and must keep renewing the owner's grace. + _fakeUia.RecordResult = new RecordCaptureResult { Frames = 3, Width = 64, Height = 64, Mode = "wgc" }; + _fakeUia.RecordShouldWaitForCancellation = true; + + using var stdin = new StringReader("stop"); + UiRecordCommand.Handler.s_isInputRedirectedOverride = () => true; + UiRecordCommand.Handler.s_stdinOverride = stdin; + try + { + var outputPath = Path.Combine(_tempDirectory.FullName, "finalized.mp4"); + var command = GetRequiredService(); + + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-a", "TestApp", "--duration-sec", "0", "-o", outputPath, "--json"]); + + Assert.AreEqual(0, exitCode, "a finalized recording reports success rather than cancellation"); + Assert.IsTrue(File.Exists(outputPath)); + } + finally + { + UiRecordCommand.Handler.s_isInputRedirectedOverride = null; + UiRecordCommand.Handler.s_stdinOverride = null; + _fakeUia.RecordShouldWaitForCancellation = false; + } + } + + // --------------------------------------------- send-input must re-verify foreground after focusing + + [TestMethod] + public async Task SendKeys_ForegroundLostDuringFocus_RefusesToSend() + { + // FocusAsync is an awaited round-trip into the target's UI thread, and setting focus can itself + // activate another window. A published repro exited 0 while HELLO landed in a decoy window that + // the target's own focus event had activated. The check before focusing is therefore not enough — + // the last check before injection is the one that protects the user. + _fakeUia.FindSingleResult = new UiElement + { + Id = "box", Selector = "box", Name = "Box", WindowHandle = 4242, + }; + _fakeSystemQuery.ProcessIdForWindowResult = 1234; + _fakeForeground.Allow = true; + + // First gate (before focus) passes; the second (after focus) denies, modelling the drift. + _fakeForeground.DenyOnCallNumber = 2; + _fakeForeground.DenyCode = UiJsonError.CodeForegroundNotTarget; + _fakeUia.OnFocus = () => { /* a focus handler activates a decoy window */ }; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["hello", "-a", "TestApp", "--target", "box", "--via", "send-input", "--json"]); + + Assert.AreEqual(1, exitCode); + Assert.AreEqual(0, _fakeKeyboard.SendCalls.Count, + "no keystrokes may be injected once the foreground drifted away from the target"); + Assert.AreEqual(2, _fakeForeground.Calls.Count, + "the foreground must be verified again after focus, not only before it"); + } + + [TestMethod] + public async Task SendKeys_ForegroundHeldThroughFocus_Sends() + { + // The recheck must not become a false refusal on the ordinary path. + _fakeUia.FindSingleResult = new UiElement + { + Id = "box", Selector = "box", Name = "Box", WindowHandle = 4242, + }; + _fakeSystemQuery.ProcessIdForWindowResult = 1234; + _fakeForeground.Allow = true; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["hello", "-a", "TestApp", "--target", "box", "--via", "send-input", "--json"]); + + Assert.AreEqual(0, exitCode); + Assert.AreEqual(1, _fakeKeyboard.SendCalls.Count); + } + + [TestMethod] + public async Task SendKeys_PostMessage_IsUnaffectedByTheForegroundRecheck() + { + // post-message posts straight to the target HWND's queue, so it is not foreground-sensitive and + // must not acquire a new refusal path. + _fakeUia.FindSingleResult = new UiElement + { + Id = "box", Selector = "box", Name = "Box", WindowHandle = 4242, + }; + _fakeSystemQuery.ProcessIdForWindowResult = 1234; + _fakeForeground.Allow = false; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["hello", "-a", "TestApp", "--target", "box", "--via", "post-message", "--json"]); + + Assert.AreEqual(0, exitCode); + Assert.AreEqual(1, _fakeKeyboard.SendCalls.Count); + Assert.AreEqual(0, _fakeForeground.Calls.Count, "post-message never consults the foreground guard"); + } } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs index a338b8802..1162b18da 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs @@ -380,6 +380,15 @@ void OnRecordingStarted(bool frameArtifactsActive) } return 1; } + catch (CaptureForegroundNotTargetException foregroundEx) + { + // Same contract as the pre-injection foreground guard: a precise foreground_not_target + // refusal, never internal_error, and no recording is produced. + logger.LogError("{Symbol} {Message}", UiSymbols.Error, foregroundEx.Message); + UiJsonError.Emit(json, UiJsonError.CodeForegroundNotTarget, foregroundEx.Message, + errorOut: parseResult.InvocationConfiguration.Error); + return 1; + } catch (UiAmbiguousSelectorException ambiguousEx) { UiErrors.AmbiguousSelector(logger, ambiguousEx.Message, json); @@ -397,10 +406,27 @@ void OnRecordingStarted(bool frameArtifactsActive) UiErrors.ElementNotFound(logger, notFoundEx.Selector, json); return 1; } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Native Ctrl+C / coordinator cancellation before capture ever started. There is no + // finalized MP4 to preserve, so this is NOT a completed command: swallowing it here made + // the coordinator see a normal body return and renew the owner's idle grace for a command + // that produced nothing. Propagating lets the coordinator emit the cancellation contract + // and leave the grace alone. + // + // The established finalize-on-Ctrl+C behaviour is untouched: an active recording that + // observes cancellation stops capturing, writes its MP4 and RETURNS success, so it never + // reaches this catch and still renews. + logger.LogDebug("Recording cancelled before capture started; propagating to coordination."); + throw; + } catch (OperationCanceledException) when (linkedCts.IsCancellationRequested) { - // In-loop cancellation returns a finalized recording instead. - logger.LogDebug("Recording cancelled before capture started."); + // Defensive: the stdin stop-monitor only arms after encoder readiness, so this is the + // narrow race where it fires between readiness and the first frame. The workflow asked + // its own recording to stop rather than abandoning the command, so it keeps its turn and + // this must not propagate. + logger.LogDebug("Recording stopped via stdin before capture started."); return 1; } catch (System.Runtime.InteropServices.COMException comEx) diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs index 7548ff780..e2658af41 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs @@ -121,6 +121,15 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn } } } + catch (CaptureForegroundNotTargetException foregroundEx) + { + // Same contract as the pre-injection foreground guard: a precise foreground_not_target + // refusal, never internal_error, and no image is written. + logger.LogError("{Symbol} {Message}", UiSymbols.Error, foregroundEx.Message); + UiJsonError.Emit(json, UiJsonError.CodeForegroundNotTarget, foregroundEx.Message, + errorOut: parseResult.InvocationConfiguration.Error); + return 1; + } catch (System.Runtime.InteropServices.COMException comEx) { logger.LogDebug("COM error: {HResult} {StackTrace}", comEx.HResult, comEx.StackTrace); diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiSendKeysCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiSendKeysCommand.cs index f924900c6..a301ba058 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiSendKeysCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiSendKeysCommand.cs @@ -334,9 +334,25 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn } // Only now, with the foreground confirmed, move focus to the requested child control. + var focusWasApplied = false; if (targetElement is not null) { await uiAutomation.FocusAsync(session, targetElement, cancellationToken); + focusWasApplied = true; + } + + // FocusAsync is an awaited round-trip into the target's UI thread, and setting focus can + // itself change the foreground: a focus/activation handler may open and activate another + // window, and any unrelated app can steal focus during the await. send-input is OS-wide, + // so the check that actually protects the user is the last one before injection, not the + // one taken before focusing — a real repro exited 0 while the keystrokes landed in a + // decoy window activated by the target's own focus event. Nothing between here and + // keyboardInput.Send awaits, so this is that last check. + if (focusWasApplied + && transport == KeyTransport.SendInput + && !foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "--via send-input")) + { + return 1; } // PostMessage posts to a specific HWND's message queue; a top-level window does NOT diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/CaptureForegroundNotTargetException.cs b/src/winapp-CLI/WinApp.Cli/Helpers/CaptureForegroundNotTargetException.cs new file mode 100644 index 000000000..9b0d69ed5 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/CaptureForegroundNotTargetException.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Helpers; + +/// +/// Thrown when a screen-DC capture is about to run but the target did not actually reach the +/// foreground. +/// +/// +/// --capture-screen BitBlts the live screen rather than a specific window, so it records +/// whatever is in front. SetForegroundWindow is only a request — Windows refuses it under +/// focus-stealing prevention, a UAC prompt, a locked session, or when another app activates itself in +/// the same instant. Without this check the command exits 0 and hands back a PNG/MP4 of an unrelated +/// window, which is worse than failing: the caller has no way to tell. +/// +/// Commands map this to the existing foreground_not_target contract — the same code the +/// pre-injection foreground guard emits — rather than reporting internal_error. +/// +/// +internal sealed class CaptureForegroundNotTargetException(string message) : InvalidOperationException(message); diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/DesktopTargetValidation.cs b/src/winapp-CLI/WinApp.Cli/Helpers/DesktopTargetValidation.cs index fddee3bf0..5f1d8aa68 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/DesktopTargetValidation.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/DesktopTargetValidation.cs @@ -57,7 +57,9 @@ public static bool TryConfirmTargetWindow( return false; } - if (expectedProcessId > 0 && actualProcessId != (uint)expectedProcessId) + if (expectedProcessId > 0 + && actualProcessId != (uint)expectedProcessId + && !IsOwnedByExpectedProcess(systemQuery, hwnd, expectedProcessId)) { logger.LogError( "{Symbol} The target window handle now belongs to a different process — refusing to {Action}.", @@ -71,4 +73,66 @@ public static bool TryConfirmTargetWindow( return true; } + + /// + /// Maximum owner links to follow. Owner chains are short in practice (dialog → owner window); the + /// cap bounds the walk against a cycle produced by a torn or hostile window tree. + /// + private const int MaxOwnerChainDepth = 8; + + /// + /// Whether is a live window owned by a window belonging to + /// , following the GW_OWNER chain. + /// + /// + /// + /// A plain PID equality check is too strict: UiAutomationService.GetAllAppWindows deliberately + /// discovers cross-process windows that the target app owns — common-item file pickers and + /// system dialogs run in another process yet are genuinely part of the app's UI, and elements found + /// on them are tagged with that foreign HWND. Rejecting those made every such target unreachable + /// after a queue wait. + /// + /// + /// This mirrors the exact association the discovery side uses (GW_OWNER reaching one of the + /// session's windows), so nothing is admitted here that discovery would not have surfaced. The + /// recycled-handle protection is preserved: a reused HWND belonging to an unrelated process has no + /// owner chain reaching the expected PID, and an owner link that leads to a dead or reused window + /// fails the liveness check on that link — + /// returns 0 for a destroyed window and the true current PID for a recycled one. + /// + /// + private static bool IsOwnedByExpectedProcess(ISystemUiQuery systemQuery, long hwnd, int expectedProcessId) + { + var current = hwnd; + var seen = new HashSet(); + + for (var depth = 0; depth < MaxOwnerChainDepth; depth++) + { + if (!seen.Add(current)) + { + // A cycle cannot reach the expected process by any further step. + return false; + } + + var owner = (long)systemQuery.GetWindowOwner(current); + if (owner == 0) + { + // Top of the chain: an unowned window that is not in the expected process is either a + // recycled handle or an unrelated window. Either way it must not be acted upon. + return false; + } + + // The owner must still be alive AND still belong to the expected process. Checking the PID + // of the owner (rather than merely that one exists) is what keeps a recycled owner handle + // from laundering an unrelated window into the expected session. + if (systemQuery.GetProcessIdForWindow(owner) == (uint)expectedProcessId) + { + return true; + } + + current = owner; + } + + return false; + } } diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/IDesktopForegroundService.cs b/src/winapp-CLI/WinApp.Cli/Helpers/IDesktopForegroundService.cs index 5ef372980..f89546bb4 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/IDesktopForegroundService.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/IDesktopForegroundService.cs @@ -31,6 +31,18 @@ internal interface IDesktopForegroundService ///
void RequestForeground(long hwnd); + /// + /// Whether (or its top-level root) currently holds the foreground. + /// + /// + /// is advisory — Windows silently refuses it under focus-stealing + /// prevention, a UAC prompt, a locked session, or when another app activates itself in the same + /// instant. Any code that reads the screen rather than a specific window (a screen-DC + /// BitBlt) or injects OS-wide input must confirm the request actually took, immediately before it + /// acts, or it will silently capture/type into whatever window really is in front. + /// + bool IsForeground(long hwnd); + /// Whether is currently minimized. bool IsMinimized(long hwnd); @@ -55,6 +67,9 @@ public void RequestForeground(long hwnd) Windows.Win32.PInvoke.SetForegroundWindow(new Windows.Win32.Foundation.HWND((nint)hwnd)); } + public bool IsForeground(long hwnd) + => hwnd != 0 && ForegroundGuard.ForegroundBelongsTo(hwnd); + public bool IsMinimized(long hwnd) => hwnd != 0 && Windows.Win32.PInvoke.IsIconic(new Windows.Win32.Foundation.HWND((nint)hwnd)); diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs index bafee4005..f4d8573c0 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs @@ -236,12 +236,17 @@ public async Task RunAsync( await ReleaseAllSectionsAsync().ConfigureAwait(false); } } - catch (OperationCanceledException) when (!bodyCompletedNormally && _waitWatch.IsRunning) - { - // Cancelled while queued: the command never reached execution, so it has no partial UI - // side effects and no result to preserve (spec §11.1). + catch (OperationCanceledException) when (!bodyCompletedNormally) + { + // The body threw rather than returned, so there is no result to preserve (spec §11.1 for + // a queued command; §11.2 for one cancelled after acquisition, whose command semantics are + // "it produced nothing"). Either way the command must not renew the owner's idle grace, + // which the `finally` below guarantees by passing bodyCompletedNormally: false. + // + // A command that DOES have something to preserve — an active `ui record` finalizing its + // MP4 on Ctrl+C — returns instead of throwing, never reaches here, and still renews. outcome = UiCoordinationOutcome.Cancelled; - EmitQueuedCancellation(); + EmitCancellation(cancelledWhileQueued: _waitWatch.IsRunning); return CancelledExitCode; } catch (UiCoordinationException) @@ -597,7 +602,15 @@ private void Complete(bool renewGrace) } } - private void EmitQueuedCancellation() + /// + /// Emits the structured cancelled envelope (spec §14). + /// + /// + /// when the command never reached execution, which is the case that also + /// carries a queue position. when it was cancelled after acquiring the + /// turn, where UI side effects may already have happened. + /// + private void EmitCancellation(bool cancelledWhileQueued) { var waitedMs = _waitWatch.ElapsedMilliseconds; int? queuePosition = null; @@ -617,10 +630,14 @@ private void EmitQueuedCancellation() coordinator._logger.LogDebug("Queue position could not be read while cancelling: {Message}", ex.Message); } + var message = cancelledWhileQueued + ? "UI turn wait was cancelled." + : "The command was cancelled after it acquired the desktop; any UI changes it had already made remain."; + UiJsonError.Emit( outputMode.Json, UiCoordinationErrorCodes.Cancelled, - "UI turn wait was cancelled.", + message, errorOut: parseResult.InvocationConfiguration.Error, coordination: new UiCoordinationInfo { @@ -630,10 +647,17 @@ private void EmitQueuedCancellation() if (!outputMode.Json && !outputMode.Quiet) { - coordinator._logger.LogWarning( - "{Symbol} Cancelled while waiting {WaitedMs} ms for the desktop.", - UiSymbols.Warning, - waitedMs); + if (cancelledWhileQueued) + { + coordinator._logger.LogWarning( + "{Symbol} Cancelled while waiting {WaitedMs} ms for the desktop.", + UiSymbols.Warning, + waitedMs); + } + else + { + coordinator._logger.LogWarning("{Symbol} {Message}", UiSymbols.Warning, message); + } } } diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopStateStore.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopStateStore.cs index 550fdd753..d1baa7736 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopStateStore.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopStateStore.cs @@ -155,6 +155,18 @@ public StateReadResult Read() return RecoverCorruptState(); } + // The schema version is read from the raw document BEFORE any typed deserialization. A newer + // binary may change field *shapes*, not just add fields — if `owner` became an object here and a + // string there, strong deserialization throws JsonException and the catch below would classify a + // perfectly valid newer state as corruption, quarantine it, and replace it with a version 1 + // document. That is exactly the downgrade spec §12.4 forbids. + if (TryReadDeclaredVersion(raw, out var declaredVersion) + && declaredVersion > InteractiveDesktopState.CurrentVersion) + { + // Not corruption — a newer binary owns this file. Leave the bytes untouched. + return new StateReadResult(null, UnknownNewerVersion: true, RecoveredFromCorruption: false); + } + InteractiveDesktopState? parsed; try { @@ -173,8 +185,8 @@ public StateReadResult Read() if (parsed.Version > InteractiveDesktopState.CurrentVersion) { - // Not corruption — a newer binary owns this file. Never reset or downgrade it; version 1 - // owner fields cannot be assumed to mean the same thing in a newer schema (spec §12.4). + // Reached when the version survived typed deserialization (an additive-only newer schema). + // TryReadDeclaredVersion above already caught the shape-incompatible case. return new StateReadResult(null, UnknownNewerVersion: true, RecoveredFromCorruption: false); } @@ -189,6 +201,35 @@ public StateReadResult Read() return new StateReadResult(parsed, false, false); } + /// + /// Reads only the root version number, without binding any other field to a type. + /// + /// + /// Deliberately tolerant: a document that is not valid JSON, has no object root, omits + /// version, or carries a non-numeric version returns so the + /// caller falls through to typed deserialization and the existing corruption-recovery semantics. + /// This method only ever *diverts* a document that positively declares a version newer than this + /// binary understands. + /// + private static bool TryReadDeclaredVersion(string raw, out int version) + { + version = 0; + + try + { + using var document = JsonDocument.Parse(raw); + return document.RootElement.ValueKind == JsonValueKind.Object + && document.RootElement.TryGetProperty("version", out var versionElement) + && versionElement.ValueKind == JsonValueKind.Number + && versionElement.TryGetInt32(out version); + } + catch (JsonException) + { + // Malformed JSON is genuine corruption; let the typed path report it. + return false; + } + } + /// /// Rejects documents that parse as JSON but describe scheduling state that cannot be reasoned about. /// diff --git a/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Record.cs b/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Record.cs index 2c364effd..92827afa9 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Record.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Record.cs @@ -72,6 +72,11 @@ public async Task RecordAsync(UiSessionInfo session, string { _desktopForeground.RequestForeground(handle); await Task.Delay(150, ct).ConfigureAwait(false); + + // Same hazard as the screenshot path: a refused activation would make every recorded + // frame a BitBlt of whichever window actually holds the foreground. Verify before any + // frame is captured rather than producing an MP4 of the wrong app. + EnsureForegroundForScreenCapture(handle, "record --capture-screen"); } } diff --git a/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Screenshot.cs b/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Screenshot.cs index 7f2677b4f..a188c5938 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Screenshot.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.Screenshot.cs @@ -127,6 +127,13 @@ internal sealed partial class UiAutomationService if (captureScreen) { + // SetForegroundWindow is advisory. If it was refused (focus-stealing prevention, a UAC + // prompt, another app activating itself in the same instant), a screen-DC BitBlt reads + // whatever window is really in front and happily returns a PNG of the wrong app — a real + // repro produced an all-magenta image of a decoy window while exiting 0. Verify after the + // activation delay and immediately before the capture begins. + EnsureForegroundForScreenCapture(handle, "screenshot --capture-screen"); + // Screen capture mode: BitBlt from screen DC — captures popups and overlays. pixelData = CaptureFromScreen(rect.left, rect.top, width, height); return CropIfRequested(pixelData, width, height, elementId, session, root, cropOriginLeft, cropOriginTop); diff --git a/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.cs b/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.cs index f3dbcd818..6adebfe3a 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/UiAutomationService.cs @@ -75,6 +75,28 @@ public UiAutomationService( _automation = CUIAutomation8.CreateInstance(); } + /// + /// Confirms actually reached the foreground before a screen-DC capture, + /// throwing when it did not. + /// + /// + /// Shared by the screenshot and record capture-screen paths so both refuse identically, and so the + /// activation-then-verify pattern lives in one place rather than being re-derived per call site. + /// + internal void EnsureForegroundForScreenCapture(long handle, string action) + { + if (_desktopForeground.IsForeground(handle)) + { + return; + } + + _logger.LogDebug("Foreground verification failed for {Action}; refusing to capture the screen.", action); + throw new CaptureForegroundNotTargetException( + $"Target window is not in the foreground — refusing to {action} because a screen capture would " + + "record whatever window is actually in front. Bring the window to the foreground and retry, or " + + "capture without --capture-screen."); + } + public List<(nint Hwnd, int Pid, string Title)> FindWindowsByTitle(string titleQuery) { return EnumerateWindows((pid, title) => From 90f364f0eb5b8555dd364848dc2964f1310f5b44 Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:38:41 -0700 Subject: [PATCH 07/29] Propagate capture-foreground refusal out of the multi-window screenshot loop Follow-up to H6, caught by self-review. CaptureForegroundNotTargetException derives from InvalidOperationException and is therefore not a coordination fault, so the per-window catch-all in CaptureMultipleWindows swallowed it, recorded it as a per-window failure, and ended with 'No windows could be captured' as internal_error - exactly the outcome H6 exists to replace. The path is reachable with plain --capture-screen whenever discovery finds more than one top-level window, or any owned dialog for a single-window session, which is the common case the flag is used for. The foreground is a property of the desktop rather than of one window, so a refused activation is not a per-window condition; it now propagates out of the loop the way DesktopEscalationRequiredException already does, and lands on the handler that reports foreground_not_target. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../UiCommandTests.CaptureForeground.cs | 20 +++++++++++++++++++ .../Commands/UiScreenshotCommand.cs | 8 ++++++++ 2 files changed, 28 insertions(+) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.CaptureForeground.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.CaptureForeground.cs index 9d02a83a6..7f819320f 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.CaptureForeground.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.CaptureForeground.cs @@ -57,4 +57,24 @@ public async Task Record_CaptureScreenForegroundRefused_ReportsForegroundNotTarg AssertJsonErrorCode(UiJsonError.CodeForegroundNotTarget); Assert.IsFalse(File.Exists(outputPath), "no MP4 may be published for a refused capture"); } + + [TestMethod] + public async Task Screenshot_MultiWindowCaptureScreenForegroundRefused_ReportsForegroundNotTarget() + { + // The multi-window path captures each window in a loop whose catch-all records per-window + // failures and then reports "No windows could be captured" as internal_error. A refused + // activation is a property of the desktop, not of one window, so it must escape that loop — + // otherwise --capture-screen on any app with an owned dialog still buries the real cause. + _fakeWindowFinder.OwnedWindowsResult = [((nint)99, 4321, "Owned Dialog")]; + _fakeUia.ScreenshotThrow = CaptureRefusal(); + + var outputPath = Path.Combine(_tempDirectory.FullName, "decoy-multi.png"); + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-a", "TestApp", "--capture-screen", "-o", outputPath, "--json"]); + + Assert.AreEqual(1, exitCode); + AssertJsonErrorCode(UiJsonError.CodeForegroundNotTarget); + Assert.IsFalse(File.Exists(outputPath)); + } } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs index e2658af41..66c2ccf8f 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs @@ -289,6 +289,14 @@ private async Task CaptureMultipleWindows( // recording a per-window failure here would publish a partially observational image. throw; } + catch (CaptureForegroundNotTargetException) + { + // The foreground is a property of the desktop, not of this one window, so a refused + // activation is not a per-window failure: recording it as one and continuing would + // end with "No windows could be captured" (internal_error) and bury the real, + // actionable cause. Propagate to the handler that reports foreground_not_target. + throw; + } catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { logger.LogDebug("Failed to capture HWND {Hwnd}: {Error}", w.Hwnd, ex.Message); From a16b646f63ac7eccabd0b246bf7e6d15c0863796 Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:30:29 -0700 Subject: [PATCH 08/29] Select the gated-test winapp.exe by host architecture The canonical build publishes both win-x64 and win-arm64, and the CI lane downloads the whole cli-binaries artifact, so BOTH runtime identifiers are on disk when the newly enabled gated step runs. Both InteractiveDesktopMultiprocessTests and InteractiveDesktopRealAppTests resolved the executable by probing a fixed { win-arm64, win-x64 } order and taking the first hit, which is a choice by luck rather than by architecture: on the x64 windows-latest runner they would deterministically pick the ARM64 binary and every child Process.Start would fail. The bug was invisible locally because this dev box is arm64, where the wrong-order probe happens to land on the right file - the same reason the gated suite passed 11/11 here while being broken for CI. Resolution now lives in one shared WinappTestBinary helper rather than being duplicated in both suites, and is driven by RuntimeInformation.OSArchitecture: an arm64 host runs arm64 natively (x64 only under emulation) and an x64 host cannot run arm64 at all, so the OS architecture is the one that is always executable. It is fail-closed - only the matching RID is ever returned, never a different one. A build that produced only the other architecture now fails with a message naming both the required and the found RIDs, instead of skipping and reporting the suite as merely "not run"; no build at all stays inconclusive, which is the ordinary local-dev state and which the CI step's skip guard already turns into a job failure. The RID choice is a pure function so both architectures are asserted from one machine, which is what makes this regression testable at all: WinappTestBinaryTests covers x64, arm64, that the two differ, that an unpublished architecture throws rather than guessing, and that resolution never hands back a binary built for the other architecture. Also corrects a comment on the H4 owner-chain walk. It claimed to mirror "the exact association" used by discovery, but GetAllAppWindows checks a single GW_OWNER hop while the validator follows up to eight. The walk is a deliberate superset - a picker can parent a nested dialog - and is safe because every hop enforces the same property: the link must resolve to a live window whose PID equals the expected process. Depth changes how far the chain is followed, never what qualifies as a match. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../DesktopTargetValidationTests.cs | 4 +- .../InteractiveDesktopMultiprocessTests.cs | 25 +--- .../InteractiveDesktopRealAppTests.cs | 25 +--- .../WinApp.Cli.Tests/WinappTestBinary.cs | 134 ++++++++++++++++++ .../WinApp.Cli.Tests/WinappTestBinaryTests.cs | 77 ++++++++++ .../Helpers/DesktopTargetValidation.cs | 17 ++- 6 files changed, 227 insertions(+), 55 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/WinappTestBinary.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/WinappTestBinaryTests.cs diff --git a/src/winapp-CLI/WinApp.Cli.Tests/DesktopTargetValidationTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/DesktopTargetValidationTests.cs index b56e66183..a8abff983 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/DesktopTargetValidationTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/DesktopTargetValidationTests.cs @@ -90,8 +90,8 @@ public void ForeignPidWithNoOwnerIsRejected() public void CrossProcessWindowOwnedByTheExpectedProcessIsAccepted() { // Exactly what a common-item file picker looks like: a window in another process whose - // GW_OWNER is one of the session's own windows. This is the association GetAllAppWindows uses - // to surface it in the first place, so validation must accept what discovery offered. + // GW_OWNER is one of the session's own windows. Discovery checks exactly this one hop; the + // validator additionally follows longer chains, which the nested-dialog case below covers. _systemQuery.ProcessIdByHwnd[100] = ForeignPid; _systemQuery.WindowOwnerByHwnd[100] = 200; _systemQuery.ProcessIdByHwnd[200] = SessionPid; diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.cs index d6b1d6487..28558f615 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.cs @@ -52,9 +52,7 @@ public void Setup() $"Set {GateVariable}=1 (and build the CLI) to run multiprocess UI coordination coverage."); } - _winappPath = FindWinappExe() - ?? throw new AssertInconclusiveException( - "winapp.exe was not found. Run scripts\\build-cli.ps1 first so artifacts\\cli\\\\winapp.exe exists."); + _winappPath = WinappTestBinary.Resolve(); _lockDirectory = Path.Combine(Path.GetTempPath(), $"winapp-mp-{Guid.NewGuid():N}"); _previousLockOverride = Environment.GetEnvironmentVariable( @@ -113,27 +111,6 @@ public void Cleanup() } } - private static string? FindWinappExe() - { - var root = AppContext.BaseDirectory; - for (var i = 0; i < 8 && root is not null; i++) - { - foreach (var rid in new[] { "win-arm64", "win-x64" }) - { - var candidate = Path.Combine(root, "artifacts", "cli", rid, "winapp.exe"); - if (File.Exists(candidate)) - { - return candidate; - } - } - - root = Path.GetDirectoryName(root.TrimEnd(Path.DirectorySeparatorChar)); - } - - var sideBySide = Path.Combine(AppContext.BaseDirectory, "winapp.exe"); - return File.Exists(sideBySide) ? sideBySide : null; - } - /// /// Starts a real winapp ui click against a process that does not exist. Preflight passes /// (an app and a selector were supplied), so the command genuinely enters coordination, waits its diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs index 92a0db424..ff4514e73 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs @@ -54,9 +54,7 @@ public void Setup() $"Set {GateVariable}=1 on an interactive desktop (and build the CLI) to run real-app UI coordination coverage."); } - _winappPath = FindWinappExe() - ?? throw new AssertInconclusiveException( - "winapp.exe was not found. Run scripts\\build-cli.ps1 first so artifacts\\cli\\\\winapp.exe exists."); + _winappPath = WinappTestBinary.Resolve(); _lockDirectory = Path.Combine(Path.GetTempPath(), $"winapp-realapp-{Guid.NewGuid():N}"); _scratchDirectory = Path.Combine(Path.GetTempPath(), $"winapp-realapp-out-{Guid.NewGuid():N}"); @@ -131,27 +129,6 @@ public void Cleanup() } } - private static string? FindWinappExe() - { - var root = AppContext.BaseDirectory; - for (var i = 0; i < 8 && root is not null; i++) - { - foreach (var rid in new[] { "win-arm64", "win-x64" }) - { - var candidate = Path.Combine(root, "artifacts", "cli", rid, "winapp.exe"); - if (File.Exists(candidate)) - { - return candidate; - } - } - - root = Path.GetDirectoryName(root.TrimEnd(Path.DirectorySeparatorChar)); - } - - var sideBySide = Path.Combine(AppContext.BaseDirectory, "winapp.exe"); - return File.Exists(sideBySide) ? sideBySide : null; - } - // ------------------------------------------------------------------ real winapp.exe agents private sealed record AgentRun(Process Process, Task Completion, Task Output); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WinappTestBinary.cs b/src/winapp-CLI/WinApp.Cli.Tests/WinappTestBinary.cs new file mode 100644 index 000000000..3da21000d --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/WinappTestBinary.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Runtime.InteropServices; + +namespace WinApp.Cli.Tests; + +/// +/// Locates the published winapp.exe that the gated multiprocess and real-app coordination +/// suites launch as real child processes. +/// +/// +/// +/// The canonical build publishes both artifacts/cli/win-x64 and artifacts/cli/win-arm64, +/// and CI downloads the whole cli-binaries artifact, so BOTH runtime identifiers are on disk at +/// once. Probing them in a fixed order therefore picks by luck rather than by architecture: an +/// arm64-first walk hands an ARM64 executable to an x64 runner and every +/// fails. That is +/// invisible on an arm64 dev box, where the wrong-order lookup happens to be right. +/// +/// +/// Resolution is therefore driven by the host architecture and is fail-closed: only the matching RID is +/// ever returned, never a different one. Shared by both suites so the rule cannot drift between them. +/// +/// +internal static class WinappTestBinary +{ + /// How far up from the test output directory to look for the artifacts folder. + private const int MaxParentLevels = 8; + + /// + /// The publish RID for . Pure, so the choice itself is unit-testable + /// without a published binary or a second machine. + /// + /// + /// The canonical build publishes only x64 and arm64, so any other architecture has no binary to run + /// and must fail loudly rather than silently falling back to one that cannot execute. + /// + internal static string RidFor(Architecture architecture) => architecture switch + { + Architecture.X64 => "win-x64", + Architecture.Arm64 => "win-arm64", + _ => throw new PlatformNotSupportedException( + $"No winapp.exe is published for {architecture}; the gated UI coordination suites need win-x64 or win-arm64."), + }; + + /// + /// The RID this machine can launch. Uses the OS architecture rather than the test process's own: + /// an arm64 Windows host runs arm64 natively (and x64 only under emulation), while an x64 host + /// cannot run arm64 at all, so the OS architecture is the one that is always executable. + /// + internal static string CurrentRid => RidFor(RuntimeInformation.OSArchitecture); + + /// + /// The published winapp.exe for this host, or when it is not present. + /// + /// + /// RIDs that were found but are not runnable here — the signature of an architecture mismatch rather + /// than a missing build. + /// + internal static string? TryFind(out IReadOnlyList foundOtherRids) + { + var requiredRid = CurrentRid; + var otherRids = new List(); + var root = AppContext.BaseDirectory; + + for (var i = 0; i < MaxParentLevels && root is not null; i++) + { + var cliRoot = Path.Combine(root, "artifacts", "cli"); + + var candidate = Path.Combine(cliRoot, requiredRid, "winapp.exe"); + if (File.Exists(candidate)) + { + foundOtherRids = []; + return candidate; + } + + // Only recorded for diagnostics — a non-matching RID is never returned. + if (Directory.Exists(cliRoot)) + { + foreach (var rid in new[] { "win-x64", "win-arm64" }) + { + if (rid != requiredRid && File.Exists(Path.Combine(cliRoot, rid, "winapp.exe"))) + { + otherRids.Add(rid); + } + } + } + + root = Path.GetDirectoryName(root.TrimEnd(Path.DirectorySeparatorChar)); + } + + // Last resort for a binary copied next to the test assembly, which is built for this host. + var sideBySide = Path.Combine(AppContext.BaseDirectory, "winapp.exe"); + if (File.Exists(sideBySide)) + { + foundOtherRids = []; + return sideBySide; + } + + foundOtherRids = otherRids; + return null; + } + + /// + /// The published winapp.exe for this host, or a precise failure explaining which one is + /// missing. + /// + /// + /// A build that produced the other architecture is a hard configuration error and fails the + /// test, because silently skipping would report the suite as "not run" when the real problem is that + /// it can never run here. No build at all stays inconclusive, which is the ordinary local-dev state; + /// the CI step additionally fails the job on any skip, so neither case can pass unnoticed. + /// + internal static string Resolve() + { + var path = TryFind(out var otherRids); + if (path is not null) + { + return path; + } + + if (otherRids.Count > 0) + { + Assert.Fail( + $"winapp.exe was published for {string.Join(", ", otherRids)} but not for {CurrentRid}, " + + $"which is the only architecture this {RuntimeInformation.OSArchitecture} host can launch. " + + "Publish the matching runtime identifier (scripts\\build-cli.ps1 builds both)."); + } + + throw new AssertInconclusiveException( + $"winapp.exe was not found. Run scripts\\build-cli.ps1 first so artifacts\\cli\\{CurrentRid}\\winapp.exe exists."); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/WinappTestBinaryTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/WinappTestBinaryTests.cs new file mode 100644 index 000000000..3a1aca7e3 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/WinappTestBinaryTests.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Runtime.InteropServices; + +namespace WinApp.Cli.Tests; + +/// +/// Architecture-aware resolution of the published winapp.exe the gated coordination suites +/// launch (). +/// +/// +/// The canonical build publishes both RIDs and CI downloads both, so a fixed-order probe picks by luck. +/// An arm64-first walk handed an ARM64 binary to the x64 CI runner while looking correct on an arm64 dev +/// box — the failure mode these tests exist to prevent. The RID choice is a pure function precisely so +/// both architectures can be asserted from one machine. +/// +[TestClass] +public class WinappTestBinaryTests +{ + private static readonly string[] SupportedRids = ["win-x64", "win-arm64"]; + + [TestMethod] + public void X64HostSelectsTheX64Binary() + { + Assert.AreEqual("win-x64", WinappTestBinary.RidFor(Architecture.X64)); + } + + [TestMethod] + public void Arm64HostSelectsTheArm64Binary() + { + Assert.AreEqual("win-arm64", WinappTestBinary.RidFor(Architecture.Arm64)); + } + + [TestMethod] + public void EachArchitectureSelectsADifferentRid() + { + // The whole bug was that both architectures resolved to the same (arm64) binary. + Assert.AreNotEqual( + WinappTestBinary.RidFor(Architecture.X64), + WinappTestBinary.RidFor(Architecture.Arm64)); + } + + [TestMethod] + public void AnUnpublishedArchitectureFailsRatherThanGuessing() + { + // Falling back to a binary that cannot execute here would surface as an opaque Process.Start + // failure deep inside a coordination test. + Assert.ThrowsExactly(() => WinappTestBinary.RidFor(Architecture.X86)); + } + + [TestMethod] + public void CurrentRidMatchesThisHostAndIsSupported() + { + var rid = WinappTestBinary.CurrentRid; + + Assert.AreEqual(WinappTestBinary.RidFor(RuntimeInformation.OSArchitecture), rid); + CollectionAssert.Contains(SupportedRids, rid); + } + + [TestMethod] + public void ResolutionNeverReturnsABinaryForAnotherArchitecture() + { + // Guards the fail-closed property directly: whatever is on disk, the path handed to + // Process.Start is either this host's RID or nothing at all. + var path = WinappTestBinary.TryFind(out _); + if (path is null) + { + return; + } + + var directory = Path.GetFileName(Path.GetDirectoryName(path)); + var otherRid = WinappTestBinary.CurrentRid == "win-x64" ? "win-arm64" : "win-x64"; + Assert.AreNotEqual(otherRid, directory, + $"resolution returned a {otherRid} binary on a {WinappTestBinary.CurrentRid} host"); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/DesktopTargetValidation.cs b/src/winapp-CLI/WinApp.Cli/Helpers/DesktopTargetValidation.cs index 5f1d8aa68..a7bea1365 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/DesktopTargetValidation.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/DesktopTargetValidation.cs @@ -93,11 +93,18 @@ public static bool TryConfirmTargetWindow( /// after a queue wait. /// /// - /// This mirrors the exact association the discovery side uses (GW_OWNER reaching one of the - /// session's windows), so nothing is admitted here that discovery would not have surfaced. The - /// recycled-handle protection is preserved: a reused HWND belonging to an unrelated process has no - /// owner chain reaching the expected PID, and an owner link that leads to a dead or reused window - /// fails the liveness check on that link — + /// The association is deliberately a superset of the discovery side's, not a mirror of it: + /// GetAllAppWindows checks a single GW_OWNER hop against the session's own windows, + /// whereas this walks up to hops, so it also admits a dialog owned + /// by a dialog. That is intentional — a picker can parent a nested dialog — and it stays safe + /// because the property enforced at every hop is the same one: the link must resolve to a + /// live window whose PID equals the expected process. Depth changes how far the chain is + /// followed, never what qualifies as a match. + /// + /// + /// The recycled-handle protection is therefore preserved at any depth: a reused HWND belonging to an + /// unrelated process has no owner chain reaching the expected PID, and an owner link that leads to a + /// dead or reused window fails the check on that link — /// returns 0 for a destroyed window and the true current PID for a recycled one. /// /// From 92e000599e58bcc07095b67ddbd26a40ddfe20dd Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:38:03 -0700 Subject: [PATCH 09/29] Replace parent-derived owners with opt-in WINAPP_UI_WORKFLOW_ID Two tiers replace three owner kinds. Collision arbitration stays unconditional for every desktop-sensitive mutation; continuity between commands is now opt-in. WINAPP_UI_OWNER_ID becomes WINAPP_UI_WORKFLOW_ID, because the value names one logical workflow rather than an agent, an app or a process. When it is absent the command gets a unique anonymous one-command owner: it queues and arbitrates like anyone else, but banks no idle grace and hands the desktop off the instant it finishes, so a one-shot can never strand the desktop waiting for a follow-up that is never coming. The parent-derived owner is deleted outright - UiOwnerKind.Parent, the parent PID/start fields on OwnerRecord and WaiterEntry, ReleaseDeadParentReservation, ICoordinationLivenessProbe.IsParentAlive, IProcessInspector.TryGetParentProcessId/TryGetProcessStartTicksUtc, the Toolhelp snapshot walk and its five NativeMethods entries, and the diagnostic parent PID in --verbose waiting output. Inferring a workflow from process ancestry silently grouped unrelated commands that merely shared a shell and just as silently split commands of one workflow that did not; continuity you cannot see is worse than none. The grace rule now reads off UiOwnerIdentity.HasContinuity, so the two-tier decision lives in one place instead of being re-derived per call site. Also restores IsIconic/ShowWindow to the CLI's NativeMethods after PR #799 moved the window P/Invokes into the package, and reads foreground state through the package's public ForegroundGuard.ForegroundBelongsTo. Both keep coordination CLI-side without adding anything to the package surface. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/winapp-CLI/WinApp.Cli/NativeMethods.txt | 8 +- .../InteractiveDesktopLock.cs | 13 +- .../InteractiveDesktopScheduler.cs | 65 ++-------- .../InteractiveDesktopState.cs | 23 +--- .../InteractiveDesktop/ProcessInspector.cs | 90 +------------- .../InteractiveDesktop/UiCoordinationTypes.cs | 4 +- .../UiCoordinationWaitReporter.cs | 11 +- .../InteractiveDesktop/UiOwnerResolver.cs | 116 ++++++++---------- .../Services/InteractiveDesktop/UiTurnMode.cs | 24 ++-- 9 files changed, 94 insertions(+), 260 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli/NativeMethods.txt b/src/winapp-CLI/WinApp.Cli/NativeMethods.txt index 85fab7a1c..4c76e6715 100644 --- a/src/winapp-CLI/WinApp.Cli/NativeMethods.txt +++ b/src/winapp-CLI/WinApp.Cli/NativeMethods.txt @@ -45,6 +45,9 @@ DBG_EXCEPTION_NOT_HANDLED GetShortPathName GetForegroundWindow SetForegroundWindow +IsIconic +ShowWindow +SHOW_WINDOW_CMD MiniDumpWriteDump MINIDUMP_TYPE MINIDUMP_EXCEPTION_INFORMATION @@ -58,8 +61,3 @@ GetHandleInformation SetHandleInformation STD_HANDLE HANDLE_FLAGS -CreateToolhelp32Snapshot -Process32First -Process32Next -PROCESSENTRY32 -CREATE_TOOLHELP_SNAPSHOT_FLAGS diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs index f4d8573c0..78688066f 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs @@ -93,7 +93,7 @@ public async Task RunCoordinatedAsync( } } - private LivenessProbe CreateProbe() => new LivenessProbe(_participants, _processInspector); + private LivenessProbe CreateProbe() => new LivenessProbe(_participants); /// /// Waits for active.lock. Never steals it from a live process — a hung owner is recovered by @@ -138,15 +138,12 @@ await _pollDelay } } - /// Composes lease-backed participant liveness with process liveness for parent owners. - private sealed class LivenessProbe(IParticipantRegistry participants, IProcessInspector processInspector) + /// Lease-backed participant liveness, the only basis for pruning. + private sealed class LivenessProbe(IParticipantRegistry participants) : ICoordinationLivenessProbe { public bool IsParticipantLive(int processId, long startTicksUtc) => participants.IsParticipantLive(processId, startTicksUtc); - - public bool? IsParentAlive(int processId, long startTicksUtc) - => processInspector.IsProcessAlive(processId, startTicksUtc); } /// @@ -331,7 +328,7 @@ private void RegisterObserve(InteractiveDesktopState state) if (admission.Admission == UiAdmission.Detached) { // BeginObserve re-normalizes, so ownership can lapse between the check above and here — - // an expiring grace, or a dead parent reservation being released. Nothing was added to + // an expiring grace released by normalization. Nothing was added to // the state, so the lease must go too: keeping it open would publish liveness for a // participant with no entry, and Complete would later adjust a foreign owner's turn. _lease.Dispose(); @@ -397,7 +394,7 @@ private async Task WaitUntilRunnableAsync(CancellationToken cancellationToken) } var reporter = new UiCoordinationWaitReporter( - coordinator._console, outputMode, participant.Operation, owner.ParentPid); + coordinator._console, outputMode, participant.Operation); while (true) { diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs index f7b7f1aca..c70318b59 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs @@ -15,12 +15,6 @@ internal interface ICoordinationLivenessProbe /// reported alive and keeps its queue position. /// bool IsParticipantLive(int processId, long startTicksUtc); - - /// - /// Whether a parent-derived owner's shell is still running. means liveness - /// could not be determined, which must never be treated as death. - /// - bool? IsParentAlive(int processId, long startTicksUtc); } /// Identity of the command this process is registering. @@ -76,16 +70,14 @@ internal sealed class InteractiveDesktopScheduler(IMonotonicClock clock) internal const int MaxGlobalWaiters = 64; /// - /// Applies section 10.1 normalization: prune dead participants, release a parent-derived - /// reservation whose shell died, expire an idle turn, promote the oldest live waiter, and - /// re-evaluate owner-local eligibility. + /// Applies section 10.1 normalization: prune dead participants, expire an idle turn, promote the + /// oldest live waiter, and re-evaluate owner-local eligibility. /// /// when anything changed and the state must be published. public bool Normalize(InteractiveDesktopState state, ICoordinationLivenessProbe probe) { var changed = ClampStaleDeadline(state); changed |= PruneDeadParticipants(state, probe); - changed |= ReleaseDeadParentReservation(state, probe); changed |= ExpireIdleTurn(state); changed |= PromoteOldestWaiter(state, probe); changed |= AbsorbSameOwnerWaiters(state); @@ -209,8 +201,6 @@ public UiAdmissionResult BeginParticipating( OwnerKind = owner.Kind, Pid = participant.ProcessId, ProcessStartTicksUtc = participant.StartTicksUtc, - DiagnosticParentPid = owner.ParentPid, - ParentStartTicksUtc = owner.ParentStartTicksUtc, Operation = participant.Operation, Mode = mode, }); @@ -273,18 +263,12 @@ public void CompleteCommand( if (state.Owner is not null && OwnerMatches(state.Owner, owner)) { - if (renewGrace && owner.Kind != UiOwnerKind.Anonymous) - { - // Stored unconditionally but only consulted once OwnerCommands is empty, so a long-running - // sibling command is unaffected. - state.IdleExpiresTick64 = clock.NowTicks64 + IdleGraceMs; - } - else if (owner.Kind == UiOwnerKind.Anonymous) - { - // A one-command owner has no shell that could issue a follow-up, so holding the desktop for - // another four seconds would only delay everyone else. - state.IdleExpiresTick64 = clock.NowTicks64; - } + // The two tiers differ only here. A named workflow banks a grace so its next command finds + // the desktop still reserved; an anonymous one-shot has nothing that could issue a follow-up, + // so holding the desktop any longer would only delay everyone else. + state.IdleExpiresTick64 = renewGrace && owner.HasContinuity + ? clock.NowTicks64 + IdleGraceMs + : clock.NowTicks64; } Normalize(state, probe); @@ -374,8 +358,6 @@ private static UiAdmissionResult Describe( { Kind = owner.Kind, Key = owner.Key, - DiagnosticParentPid = owner.ParentPid, - ParentStartTicksUtc = owner.ParentStartTicksUtc, }; private static bool OwnerMatches(OwnerRecord record, UiOwnerIdentity owner) @@ -399,35 +381,8 @@ private static bool PruneDeadParticipants(InteractiveDesktopState state, ICoordi } /// - /// A parent-derived owner exists only to group one shell's commands. Once that shell is gone no - /// further command can arrive, so the reservation is released immediately instead of idling for the - /// full grace. An unreadable parent keeps the normal deadline (spec §5.2). + /// Whether the idle turn expired. /// - private bool ReleaseDeadParentReservation(InteractiveDesktopState state, ICoordinationLivenessProbe probe) - { - if (state.Owner is not { Kind: UiOwnerKind.Parent } owner - || state.OwnerCommands.Count > 0 - || owner.DiagnosticParentPid is not { } parentPid - || owner.ParentStartTicksUtc is not { } parentStart) - { - return false; - } - - if (probe.IsParentAlive(parentPid, parentStart) is not false) - { - return false; - } - - var now = clock.NowTicks64; - if (state.IdleExpiresTick64 <= now) - { - return false; - } - - state.IdleExpiresTick64 = now; - return true; - } - private bool ExpireIdleTurn(InteractiveDesktopState state) { // Any live entry — waiting or running — counts as owner activity, so the turn is never taken @@ -467,8 +422,6 @@ private bool PromoteOldestWaiter(InteractiveDesktopState state, ICoordinationLiv { Kind = oldest.OwnerKind, Key = oldest.OwnerKey, - DiagnosticParentPid = oldest.DiagnosticParentPid, - ParentStartTicksUtc = oldest.ParentStartTicksUtc, }); return true; } diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopState.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopState.cs index df022980a..811ccfed3 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopState.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopState.cs @@ -88,28 +88,15 @@ public long AllocateTicket() /// The owner currently holding the turn. internal sealed class OwnerRecord { - /// How this owner was resolved. Drives the idle-grace and parent-liveness rules. + /// How this owner was resolved. Drives whether the turn earns a post-command idle grace. public UiOwnerKind Kind { get; set; } /// /// Lowercase hex SHA-256 of the domain-separated owner payload. Never the raw - /// WINAPP_UI_OWNER_ID, and never emitted in output, logs or telemetry. + /// WINAPP_UI_WORKFLOW_ID, and never emitted in output, logs or telemetry. /// public string Key { get; set; } = ""; - /// - /// Parent PID for owners. Used to release an idle reservation - /// immediately when the parent shell is confirmed dead, and shown by --verbose waiting - /// output. Local diagnostics only — never telemetry. - /// - public int? DiagnosticParentPid { get; set; } - - /// - /// The parent's Process.StartTime.ToUniversalTime().Ticks, so a recycled PID is not mistaken - /// for the original parent. - /// - public long? ParentStartTicksUtc { get; set; } - /// Unknown properties from a newer writer, preserved verbatim on rewrite. [JsonExtensionData] public Dictionary? ExtensionData { get; set; } @@ -164,12 +151,6 @@ internal sealed class WaiterEntry /// The owning process's Process.StartTime.ToUniversalTime().Ticks. public long ProcessStartTicksUtc { get; set; } - /// Parent PID of the waiting process, for --verbose waiting output. Never telemetry. - public int? DiagnosticParentPid { get; set; } - - /// The parent's start ticks, carried so a promoted parent-derived owner keeps its liveness check. - public long? ParentStartTicksUtc { get; set; } - /// The owner kind to install when this waiter is promoted. public UiOwnerKind OwnerKind { get; set; } diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ProcessInspector.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ProcessInspector.cs index 40e869c15..b91d8d17c 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ProcessInspector.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ProcessInspector.cs @@ -7,12 +7,11 @@ namespace WinApp.Cli.Services.InteractiveDesktop; /// -/// Process facts the coordinator needs: the immediate parent of this process (for parent-derived owner -/// identity, spec §5.2) and whether a recorded participant is still alive (for pruning, spec §10.1). +/// Process facts the coordinator needs: this process's reuse-proof identity, its Windows session, and +/// whether a recorded participant is still alive (for pruning, spec §10.1). /// /// -/// Extracted behind an interface because every fact here is unavailable or unstable in a unit test: -/// the parent of the test host is the test runner, and liveness answers change under the test. The +/// Extracted behind an interface because liveness answers change under a test while it runs. The /// production implementation is . /// internal interface IProcessInspector @@ -29,18 +28,6 @@ internal interface IProcessInspector /// The Windows session this process runs in. Coordination is scoped per session. int CurrentSessionId { get; } - /// - /// The immediate parent process id, or when it cannot be read. Never walks - /// farther up the tree — a higher ancestor may be shared by unrelated workflows (spec §5). - /// - int? TryGetParentProcessId(); - - /// - /// A process's start ticks, or when the process is gone or its start time - /// cannot be read (for example a protected or higher-integrity process). - /// - long? TryGetProcessStartTicksUtc(int processId); - /// /// Whether is running and started at /// . Returns when liveness cannot be @@ -49,11 +36,7 @@ internal interface IProcessInspector bool? IsProcessAlive(int processId, long startTicksUtc); } -/// -/// Production . Parent discovery uses a Toolhelp process snapshot, -/// which needs no special privileges and, unlike NtQueryInformationProcess, is a documented -/// stable API. -/// +/// Production . internal sealed class ProcessInspector : IProcessInspector { private readonly int _currentProcessId; @@ -74,71 +57,6 @@ public ProcessInspector() public int CurrentSessionId => _sessionId; - /// - /// Coverage ceiling (issue #630): the Toolhelp snapshot walk is a native enumeration of live - /// processes. Tests drive callers through instead. - /// - public int? TryGetParentProcessId() - { - try - { - return TryGetParentProcessIdCore(_currentProcessId); - } - catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException) - { - // Snapshot creation can fail under low resources or a restricted token. Spec §5.3: fall back - // to an anonymous one-command owner rather than guessing at an ancestor. - return null; - } - } - - private static unsafe int? TryGetParentProcessIdCore(int processId) - { - using var snapshot = Windows.Win32.PInvoke.CreateToolhelp32Snapshot_SafeHandle( - Windows.Win32.System.Diagnostics.ToolHelp.CREATE_TOOLHELP_SNAPSHOT_FLAGS.TH32CS_SNAPPROCESS, 0); - if (snapshot.IsInvalid) - { - return null; - } - - var entry = new Windows.Win32.System.Diagnostics.ToolHelp.PROCESSENTRY32 - { - dwSize = (uint)sizeof(Windows.Win32.System.Diagnostics.ToolHelp.PROCESSENTRY32), - }; - - if (!Windows.Win32.PInvoke.Process32First(snapshot, ref entry)) - { - return null; - } - - do - { - if (entry.th32ProcessID == (uint)processId) - { - var parent = (int)entry.th32ParentProcessID; - return parent > 0 ? parent : null; - } - } - while (Windows.Win32.PInvoke.Process32Next(snapshot, ref entry)); - - return null; - } - - public long? TryGetProcessStartTicksUtc(int processId) - { - try - { - using var process = Process.GetProcessById(processId); - return process.StartTime.ToUniversalTime().Ticks; - } - catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception) - { - // ArgumentException: no such process. InvalidOperationException: exited between calls. - // Win32Exception: start time unreadable (protected / higher integrity). All mean "unknown". - return null; - } - } - public bool? IsProcessAlive(int processId, long startTicksUtc) { if (processId <= 0) diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs index 2dfeb8f8b..e7f55653f 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs @@ -9,8 +9,8 @@ namespace WinApp.Cli.Services.InteractiveDesktop; /// internal static class UiCoordinationErrorCodes { - /// WINAPP_UI_OWNER_ID was set but empty/whitespace or longer than 256 UTF-16 units. - public const string InvalidOwnerId = "invalid_ui_owner_id"; + /// WINAPP_UI_WORKFLOW_ID was set but empty/whitespace or longer than 256 UTF-16 units. + public const string InvalidWorkflowId = "invalid_ui_workflow_id"; /// /// Coordination state could not be read, published, or safely recovered — for example an unknown diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationWaitReporter.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationWaitReporter.cs index 4993c5b21..526e01675 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationWaitReporter.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationWaitReporter.cs @@ -27,8 +27,7 @@ internal readonly record struct UiWaitDiagnostics( internal sealed class UiCoordinationWaitReporter( IAnsiConsole console, UiCoordinationOutputMode outputMode, - string operation, - int? parentProcessId) + string operation) { /// Delay before the first status line. internal const int FirstReportAfterMs = 1_000; @@ -70,13 +69,9 @@ private string BuildVerboseLine(long elapsedMs, UiWaitDiagnostics diagnostics) : $" running {Markup.Escape(diagnostics.ActiveOperation)}") : "no active winapp command"; - var parent = parentProcessId is { } pid - ? $"parent PID {pid.ToString(CultureInfo.InvariantCulture)}" - : "parent PID unknown"; - return "[grey]Waiting for the desktop for " + seconds + "s — " + Markup.Escape(operation) + "; " + active + "; " + - $"queue depth {diagnostics.QueueDepth}, {diagnostics.CommandsAhead} ahead; " + - parent + ". Press Ctrl+C to cancel.[/]"; + $"queue depth {diagnostics.QueueDepth}, {diagnostics.CommandsAhead} ahead" + + ". Press Ctrl+C to cancel.[/]"; } } diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiOwnerResolver.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiOwnerResolver.cs index 7f80e7b81..cad51acd9 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiOwnerResolver.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiOwnerResolver.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation and Contributors. All rights reserved. // Licensed under the MIT License. -using System.Globalization; using System.Security.Cryptography; using System.Text; @@ -9,113 +8,106 @@ namespace WinApp.Cli.Services.InteractiveDesktop; /// /// One resolved logical UI workflow owner. Only is ever persisted; the raw -/// WINAPP_UI_OWNER_ID never leaves this process. +/// WINAPP_UI_WORKFLOW_ID never leaves this process. /// /// How the owner was resolved. /// Lowercase hex SHA-256 of the domain-separated owner payload. -/// Immediate parent PID, when known. Local diagnostics only. -/// The parent's start ticks, when known. -internal sealed record UiOwnerIdentity( - UiOwnerKind Kind, - string Key, - int? ParentPid, - long? ParentStartTicksUtc); +internal sealed record UiOwnerIdentity(UiOwnerKind Kind, string Key) +{ + /// + /// Whether this owner may hold the desktop across separate winapp.exe invocations. + /// + /// + /// This is the whole two-tier rule in one place. Collision arbitration is unconditional — every + /// mutation takes a turn — but continuity between commands is opt-in: only a workflow that + /// named itself keeps a post-command idle grace. An anonymous command releases the desktop the + /// instant it finishes, so a one-shot can never strand the desktop waiting for a follow-up that is + /// never coming. + /// + public bool HasContinuity => Kind == UiOwnerKind.Workflow; +} -/// Resolves the logical workflow owner for the current command (spec §5). +/// Resolves the logical workflow owner for the current command. internal interface IUiOwnerResolver { /// - /// Resolves the owner, preferring WINAPP_UI_OWNER_ID, then the immediate parent process, - /// then a unique anonymous one-command owner. + /// Resolves the owner from WINAPP_UI_WORKFLOW_ID, or mints a unique anonymous one-command + /// owner when the variable is absent. /// /// - /// WINAPP_UI_OWNER_ID is present but invalid. Thrown before any UI side effect. + /// WINAPP_UI_WORKFLOW_ID is present but invalid. Thrown before any UI side effect. /// UiOwnerIdentity Resolve(); } /// -internal sealed class UiOwnerResolver(IProcessInspector processInspector) : IUiOwnerResolver +internal sealed class UiOwnerResolver : IUiOwnerResolver { - /// Environment variable naming one logical UI workflow — not an agent and not an app. - internal const string OwnerIdVariable = "WINAPP_UI_OWNER_ID"; + /// + /// Environment variable naming one logical UI workflow — not an agent, not an app, and not a + /// process. Setting the same value across several winapp.exe invocations is what makes them + /// one cooperating workflow. + /// + internal const string WorkflowIdVariable = "WINAPP_UI_WORKFLOW_ID"; /// /// Maximum accepted length in UTF-16 code units (string.Length). The value is an opaque /// grouping token, so a bound keeps a pathological value from bloating every state write. /// - internal const int MaxOwnerIdLength = 256; + internal const int MaxWorkflowIdLength = 256; - private const string ExplicitDomain = "winapp-ui-owner-v1\0"; - private const string ParentDomain = "winapp-ui-parent-v1\0"; + private const string WorkflowDomain = "winapp-ui-workflow-v1\0"; private const string AnonymousDomain = "winapp-ui-anonymous-v1\0"; public UiOwnerIdentity Resolve() { - var raw = Environment.GetEnvironmentVariable(OwnerIdVariable); - if (raw is not null) - { - return ResolveExplicit(raw); - } - - var parentPid = processInspector.TryGetParentProcessId(); - if (parentPid is { } pid) - { - var parentStart = processInspector.TryGetProcessStartTicksUtc(pid); - if (parentStart is { } startTicks) - { - return new UiOwnerIdentity(UiOwnerKind.Parent, ComputeParentKey(pid, startTicks), pid, startTicks); - } - } - - // Spec §5.3: parent inspection failed, so this command gets a unique owner of its own. It queues - // normally but receives no idle grace, because there is no shell to issue a follow-up command. - return new UiOwnerIdentity(UiOwnerKind.Anonymous, ComputeAnonymousKey(), null, null); + var raw = Environment.GetEnvironmentVariable(WorkflowIdVariable); + + // Deliberately no process-ancestry fallback. Deriving an owner from the parent process silently + // grouped unrelated commands that merely shared a shell, and just as silently split commands of + // one workflow that did not — continuity you cannot see is worse than none, so a workflow that + // wants it now asks for it by name. + return raw is null + ? new UiOwnerIdentity(UiOwnerKind.Anonymous, ComputeAnonymousKey()) + : ResolveWorkflow(raw); } - private static UiOwnerIdentity ResolveExplicit(string raw) + private static UiOwnerIdentity ResolveWorkflow(string raw) { // An explicitly-set-but-blank value is a scripting mistake (an unset variable expanded to ""), - // not a request for an empty owner. Failing here is far cheaper than silently merging every + // not a request for an empty workflow. Failing here is far cheaper than silently merging every // workflow that made the same mistake into one shared owner. if (string.IsNullOrWhiteSpace(raw)) { throw new UiCoordinationException( - UiCoordinationErrorCodes.InvalidOwnerId, - $"{OwnerIdVariable} is set but empty or whitespace.", - $"Set {OwnerIdVariable} to a non-empty value that identifies one logical UI workflow, for example a GUID, or unset it to use the parent process identity."); + UiCoordinationErrorCodes.InvalidWorkflowId, + $"{WorkflowIdVariable} is set but empty or whitespace.", + $"Set {WorkflowIdVariable} to a non-empty value identifying one logical UI workflow, for example a GUID, or unset it to run this command as a standalone one-shot."); } - if (raw.Length > MaxOwnerIdLength) + if (raw.Length > MaxWorkflowIdLength) { throw new UiCoordinationException( - UiCoordinationErrorCodes.InvalidOwnerId, - $"{OwnerIdVariable} is longer than {MaxOwnerIdLength} characters.", - $"Set {OwnerIdVariable} to a short opaque value such as a GUID."); + UiCoordinationErrorCodes.InvalidWorkflowId, + $"{WorkflowIdVariable} is longer than {MaxWorkflowIdLength} characters.", + $"Set {WorkflowIdVariable} to a short opaque value such as a GUID."); } - return new UiOwnerIdentity(UiOwnerKind.Explicit, ComputeExplicitKey(raw), null, null); + return new UiOwnerIdentity(UiOwnerKind.Workflow, ComputeWorkflowKey(raw)); } /// - /// SHA-256("winapp-ui-owner-v1\0" + raw value). Hashing means a workflow id that happens to - /// contain a path, ticket number or user name never reaches disk, and the domain prefix keeps an - /// explicit id from ever colliding with a parent-derived one. + /// SHA-256("winapp-ui-workflow-v1\0" + raw value). Hashing means a workflow id that happens + /// to contain a path, ticket number or user name never reaches disk, and the domain prefix keeps a + /// named workflow from ever colliding with an anonymous owner. /// - internal static string ComputeExplicitKey(string rawOwnerId) - => Hash(Encoding.UTF8.GetBytes(ExplicitDomain + rawOwnerId)); + internal static string ComputeWorkflowKey(string rawWorkflowId) + => Hash(Encoding.UTF8.GetBytes(WorkflowDomain + rawWorkflowId)); /// - /// SHA-256("winapp-ui-parent-v1\0" + pid + "\0" + parentStartUtcTicks) (spec §5.2). Including - /// the start time means a recycled PID does not inherit the previous shell's turn. + /// A fresh key per call. Two no-ID commands are therefore different owners even when they come from + /// the same shell, which is exactly what makes each one a self-contained one-shot. /// - internal static string ComputeParentKey(int parentPid, long parentStartTicksUtc) - => Hash(Encoding.UTF8.GetBytes( - ParentDomain - + parentPid.ToString(CultureInfo.InvariantCulture) - + "\0" - + parentStartTicksUtc.ToString(CultureInfo.InvariantCulture))); - private static string ComputeAnonymousKey() => Hash(Encoding.UTF8.GetBytes(AnonymousDomain + Guid.NewGuid().ToString("N"))); diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiTurnMode.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiTurnMode.cs index 96824dc64..34b0eba5d 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiTurnMode.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiTurnMode.cs @@ -37,25 +37,25 @@ internal enum UiTurnMode DesktopExclusive, } -/// How the logical workflow owner behind a command was resolved (spec §5). +/// How the logical workflow owner behind a command was resolved. +/// +/// The two values are the two tiers. Arbitration applies to both; only carries +/// continuity across invocations. +/// [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] internal enum UiOwnerKind { - /// Resolved from WINAPP_UI_OWNER_ID. Groups cooperating processes explicitly. - [System.Text.Json.Serialization.JsonStringEnumMemberName("explicit")] - Explicit, - /// - /// Derived from the immediate parent PID plus that parent's start time, which groups the commands of - /// one long-lived shell or script. Never walks farther up the tree — a higher ancestor may be shared - /// by unrelated workflows. + /// Resolved from WINAPP_UI_WORKFLOW_ID. Groups cooperating processes explicitly and earns a + /// post-command idle grace so a burst of commands reads as one workflow. /// - [System.Text.Json.Serialization.JsonStringEnumMemberName("parent")] - Parent, + [System.Text.Json.Serialization.JsonStringEnumMemberName("workflow")] + Workflow, /// - /// A unique one-command owner used when parent inspection fails. It queues normally but receives no - /// post-command idle grace. + /// A unique one-command owner minted when no workflow id is set. It queues and arbitrates like any + /// other owner, but receives no post-command idle grace and hands the desktop off the moment it + /// completes. /// [System.Text.Json.Serialization.JsonStringEnumMemberName("anonymous")] Anonymous, From aeeb6d8623ff7628492cbae77a2f5558c86cc582 Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:04:23 -0700 Subject: [PATCH 10/29] Port all 21 UI commands onto the package APIs under coordination Re-applies cooperative-turn integration on top of main's package-based handlers, which the merge had reset. Every handler regains the two-phase shape: Preflight does local-only validation so a malformed command never opens a lease or joins a queue, and ExecuteAsync runs under the workflow turn. Mutating handlers take one desktop section around the work that touches the shared desktop, re-resolve their target inside it, and validate the HWND with DesktopTargetValidation before acting - a queued command may have waited an unbounded time, so anything resolved before the wait is advisory. Screenshot is now unconditionally DesktopExclusive and the entire Observe-to-exclusive escalation mechanism is gone: IUiTurn.EscalateToDesktopExclusiveAsync, the scheduler's EscalateObserveToExclusive transition, the observeOnly plumbing, the discard-and-recapture loop and DesktopEscalationRequiredException. Every capture path restores or foregrounds a window, so a screenshot was desktop-sensitive whatever its arguments; escalation bought a non-blocking start for a command that virtually always ended up blocking anyway, at the cost of a whole second capture pass and a mode that could change mid-command. The capture pass now runs under ONE section spanning discovery, revalidation and every window's pixels - compositing several windows only means something if they were captured against the same desktop state, and a per-window section would let another workflow foreground something between two frames. Encoding, PNG compression and the disk write sit outside the section: they are the slowest part of the command and touch no shared state. Recording holds the desktop only for as long as its capture mode needs it. For WGC and screen-DC it acquires the section before RecordAsync and releases it once the first frame is committed, so a workflow can record itself typing. The started callback only completes a TaskCompletionSource created with RunContinuationsAsynchronously - it never disposes the section, because disposal is async and would otherwise run on the engine's capture thread inside its own callback. Task.WhenAny races the started signal against the recording task so a run that faults, cancels or produces no frame still releases, and release is idempotent. PrintWindow is different: any frame there can trigger the engine's blank-frame foreground retry, so the section is held for the whole recording and the caller gets an actionable text + JSON warning. The predicted mode is asserted against the reported one so future engine drift surfaces instead of silently releasing the desktop mid-recording. Package-internal capture safety (not coordination): both ScreenshotAsync and RecordAsync now verify the target actually reached the foreground before a screen-DC capture and throw the package's existing ForegroundLostException if not. SetForegroundWindow is advisory, so without this a refused activation returned a valid-looking image of the wrong window while reporting success. Commands map it to the established foreground_not_target contract and write no artifact. No coordination type crosses into either package. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../WinApp.Cli/Commands/UiClickCommand.cs | 141 ++++---- .../WinApp.Cli/Commands/UiDragCommand.cs | 195 ++++++----- .../WinApp.Cli/Commands/UiFocusCommand.cs | 45 ++- .../Commands/UiGetFocusedCommand.cs | 2 +- .../Commands/UiGetPropertyCommand.cs | 2 +- .../WinApp.Cli/Commands/UiGetValueCommand.cs | 2 +- .../WinApp.Cli/Commands/UiHoverCommand.cs | 97 ++++-- .../WinApp.Cli/Commands/UiInspectCommand.cs | 2 +- .../WinApp.Cli/Commands/UiInvokeCommand.cs | 83 +++-- .../Commands/UiListWindowsCommand.cs | 2 +- .../WinApp.Cli/Commands/UiPenCommand.cs | 136 +++++--- .../WinApp.Cli/Commands/UiRecordCommand.cs | 223 +++++++++++- .../Commands/UiScreenshotCommand.cs | 278 ++++++++++----- .../WinApp.Cli/Commands/UiScrollCommand.cs | 137 ++++---- .../Commands/UiScrollIntoViewCommand.cs | 2 +- .../WinApp.Cli/Commands/UiSearchCommand.cs | 2 +- .../WinApp.Cli/Commands/UiSendKeysCommand.cs | 318 ++++++++++-------- .../WinApp.Cli/Commands/UiSetValueCommand.cs | 2 +- .../WinApp.Cli/Commands/UiStatusCommand.cs | 2 +- .../WinApp.Cli/Commands/UiTouchCommand.cs | 157 ++++++--- .../WinApp.Cli/Commands/UiWaitForCommand.cs | 2 +- src/winapp-CLI/WinApp.Cli/GlobalUsings.cs | 2 +- .../Helpers/HostBuilderExtensions.cs | 9 + .../Helpers/PointerCommandSupport.cs | 295 ++++++++-------- .../WinApp.Cli/Helpers/UiCoordinatedAction.cs | 4 +- .../IInteractiveDesktopLock.cs | 10 +- .../InteractiveDesktopLock.cs | 50 --- .../InteractiveDesktopScheduler.cs | 28 -- .../UiRecordingService.cs | 12 + .../Input/ForegroundLostException.cs | 27 +- .../UiAutomationService.Screenshot.cs | 13 + 31 files changed, 1410 insertions(+), 870 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiClickCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiClickCommand.cs index c3e5561aa..83e9938c0 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiClickCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiClickCommand.cs @@ -10,6 +10,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -46,12 +47,21 @@ public class Handler( IUiSelectorParser selectorParser, IMouseInput mouseInput, IForegroundGuard foregroundGuard, + IDesktopForegroundService desktopForeground, + ISystemUiQuery systemQuery, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { /// Cursor-settle pause (ms) before the final confirm read and button-down. private const int CursorSettleMs = 50; - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + + protected override string Operation => "ui click"; + + /// A click drives the shared cursor and OS-wide SendInput stream. + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.DesktopExclusive; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); @@ -70,6 +80,16 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + // Preflight rejected a missing selector, so this is non-null by construction. + var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument)!; + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); var doubleClick = parseResult.GetValue(DoubleClickOption); var rightClick = parseResult.GetValue(RightClickOption); @@ -87,10 +107,6 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio var clickType = doubleClick ? "double-click" : rightClick ? "right-click" : "click"; - // Get element center from bounding rect - int centerX = (int)(element.X + element.Width / 2.0); - int centerY = (int)(element.Y + element.Height / 2.0); - if (element.Width == 0 || element.Height == 0) { logger.LogError("{Symbol} Element has zero size — cannot click.", UiSymbols.Error); @@ -98,70 +114,67 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } - // Use the element's own window handle if available, otherwise fall back to session + // Use the element's own window handle if available, otherwise fall back to session. + // Advisory only — refreshed from the re-resolved element inside the section below. var targetHwnd = element.WindowHandle ?? uiTarget.WindowHandle; + int centerX; + int centerY; - // Bring target window to foreground - if (targetHwnd != 0) + // Everything that touches the shared desktop — foreground, cursor, SendInput — happens + // inside one section, and so does the resolution whose result is acted upon. + await using (await turn.EnterAsync(cancellationToken).ConfigureAwait(false)) { - Windows.Win32.PInvoke.SetForegroundWindow( - new Windows.Win32.Foundation.HWND((nint)targetHwnd)); - await Task.Delay(100, cancellationToken); // let window activate - } + // Re-resolve before anything else so the HWND we foreground and validate is current. + var stable = await GestureTargeting.ResolveStableAsync( + uiAutomation, uiTarget, selector, element, + GestureTargeting.DefaultMaxReads, GestureTargeting.DefaultReadDelayMs, null, cancellationToken); + if (!UiInjectionReporting.TryReport(stable, logger, json, selectorStr, clickType)) + { + return 1; + } + targetHwnd = stable.Element.WindowHandle ?? uiTarget.WindowHandle; - // Re-resolve the element just before clicking (N5): foregrounding can restore/animate the - // window, so the rect captured above may be stale. Refuse rather than click empty space if - // the target is still moving. - var stable = await GestureTargeting.ResolveStableAsync( - uiAutomation, uiTarget, selector, element, - GestureTargeting.DefaultMaxReads, GestureTargeting.DefaultReadDelayMs, null, cancellationToken); - if (!UiInjectionReporting.TryReport(stable, logger, json, selectorStr, clickType)) - { - return 1; - } - centerX = stable.CenterX; - centerY = stable.CenterY; - - // Verify the target STILL holds the foreground as the first gate before the OS-wide click - // (F1) — matches drag / scroll --wheel. The re-resolve above awaits UIA reads during which - // focus could shift, so we check here, after the awaits. Also yields a clean - // no_interactive_desktop error on a locked session instead of a misleading SendInput failure. - // (A second, final gate runs below, after the cursor-settle confirm read.) - if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, clickType)) - { - return 1; - } + if (!DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, targetHwnd, uiTarget.ProcessId, logger, json, clickType, parseResult.InvocationConfiguration.Error)) + { + return 1; + } - // Close the residual re-resolve→button-down race (F3/N5): position the cursor, let it - // settle, then re-confirm the target hasn't drifted during that settle window before - // pressing. ResolveStableAsync can read a continuously-animating target as "settled" by - // chance and the element then moves during the ~50 ms cursor settle, landing the click on - // empty space yet reporting success. By doing the settle here and a fresh confirm read - // immediately before the button-down (which itself uses settleMs: 0), a reported ✅ means - // the target was still in place when the button went down. - mouseInput.MoveCursor(centerX, centerY); - await Task.Delay(CursorSettleMs, cancellationToken); - - var confirmed = await GestureTargeting.ConfirmStillAsync( - uiAutomation, uiTarget, selector, stable.Element, cancellationToken); - if (!UiInjectionReporting.TryReport(confirmed, logger, json, selectorStr, clickType)) - { - return 1; - } - centerX = confirmed.CenterX; - centerY = confirmed.CenterY; + // Bring target window to foreground + if (targetHwnd != 0) + { + desktopForeground.RequestForeground(targetHwnd); + await Task.Delay(100, cancellationToken); // let window activate + } - // Final foreground gate after the awaited confirm read — the true last check before the - // OS-wide button-down (M3). Focus could have shifted during the cursor-settle + confirm - // read above, which the first gate (before those awaits) couldn't see. - if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, clickType)) - { - return 1; - } + // Verify the target STILL holds the foreground as the first gate before the OS-wide click. + if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, clickType)) + { + return 1; + } + + // Close the residual re-resolve→button-down race. + mouseInput.MoveCursor(stable.CenterX, stable.CenterY); + await Task.Delay(CursorSettleMs, cancellationToken); + + var confirmed = await GestureTargeting.ConfirmStillAsync( + uiAutomation, uiTarget, selector, stable.Element, cancellationToken); + if (!UiInjectionReporting.TryReport(confirmed, logger, json, selectorStr, clickType)) + { + return 1; + } + centerX = confirmed.CenterX; + centerY = confirmed.CenterY; + + // Final foreground gate after the awaited confirm read. + if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, clickType)) + { + return 1; + } - // Perform the click via SendInput — no extra settle, the cursor is already positioned and - // the target just confirmed in place. - mouseInput.Click(centerX, centerY, doubleClick, rightClick, settleMs: 0); + // Perform the click via SendInput — no extra settle, the cursor is already positioned. + mouseInput.Click(centerX, centerY, doubleClick, rightClick, settleMs: 0); + } var elementId = (element.Selector ?? element.Id ?? ""); @@ -192,7 +205,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiDragCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiDragCommand.cs index 80efdb29c..7eea47c27 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiDragCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiDragCommand.cs @@ -10,6 +10,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -75,20 +76,27 @@ public class Handler( IUiSelectorParser selectorParser, IMouseInput mouseInput, IForegroundGuard foregroundGuard, + IDesktopForegroundService desktopForeground, + ISystemUiQuery systemQuery, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { // Cursor-settle pause (ms) after positioning on the from-point, before the confirm read + press. private const int CursorSettleMs = 50; - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui drag"; + + /// A drag holds the shared cursor and mouse button across the whole gesture. + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.DesktopExclusive; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var app = parseResult.GetValue(SharedUiOptions.AppOption); var window = parseResult.GetValue(SharedUiOptions.WindowOption); var arg0 = parseResult.GetValue(FromArgument); var arg1 = parseResult.GetValue(ToArgument); - var rightButton = parseResult.GetValue(RightButtonOption); var holdMs = parseResult.GetValue(HoldOption); var dwellMs = parseResult.GetValue(DwellOption); @@ -106,108 +114,127 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } - try + if (string.IsNullOrWhiteSpace(arg0) || string.IsNullOrWhiteSpace(arg1)) { - var uiTarget = await targetResolver.ResolveAsync(app, window, cancellationToken); + logger.LogError("{Symbol} Specify both and — each is an element selector or x,y coordinates.", UiSymbols.Error); + UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, + "Specify both and — each is an element selector or x,y coordinates."); + return 1; + } - if (string.IsNullOrWhiteSpace(arg0) || string.IsNullOrWhiteSpace(arg1)) - { - logger.LogError("{Symbol} Specify both and — each is an element selector or x,y coordinates.", UiSymbols.Error); - UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, - "Specify both and — each is an element selector or x,y coordinates."); - return 1; - } + return null; + } - var from = await ResolveEndpointAsync(arg0, "from", uiTarget, json, cancellationToken); - if (!from.Ok) - { - return 1; - } + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + // Preflight rejected empty endpoints, so both are non-null by construction. + var arg0 = parseResult.GetValue(FromArgument)!; + var arg1 = parseResult.GetValue(ToArgument)!; + var rightButton = parseResult.GetValue(RightButtonOption); + var holdMs = parseResult.GetValue(HoldOption); + var dwellMs = parseResult.GetValue(DwellOption); - var to = await ResolveEndpointAsync(arg1, "to", uiTarget, json, cancellationToken); - if (!to.Ok) - { - return 1; - } + try + { + var uiTarget = await targetResolver.ResolveAsync(app, window, cancellationToken); - int fromX = from.X; - int fromY = from.Y; - int toX = to.X; - int toY = to.Y; - // Prefer the HWND of whichever endpoint resolved from a real element; fall back to the - // session window when both endpoints are bare coordinates. - long targetHwnd = from.Hwnd != 0 ? from.Hwnd : (to.Hwnd != 0 ? to.Hwnd : uiTarget.WindowHandle); + int fromX; + int fromY; + int toX; + int toY; + long targetHwnd; - if (targetHwnd != 0) + await using (await turn.EnterAsync(cancellationToken).ConfigureAwait(false)) { - Windows.Win32.PInvoke.SetForegroundWindow( - new Windows.Win32.Foundation.HWND((nint)targetHwnd)); - await Task.Delay(100, cancellationToken); - } + var from = await ResolveEndpointAsync(arg0, "from", uiTarget, json, cancellationToken); + if (!from.Ok) + { + return 1; + } - // Foregrounding can shift/animate the window (restore, snap, layout settle); re-resolve any - // element endpoint so we drag where it is *now*, and refuse rather than hit empty space if - // it's still moving. Raw-coordinate endpoints can't be verified, so they pass through. - var fromStable = await StabilizeAsync(from, uiTarget, "from", json, cancellationToken); - if (!fromStable.Ok) - { - return 1; - } + var to = await ResolveEndpointAsync(arg1, "to", uiTarget, json, cancellationToken); + if (!to.Ok) + { + return 1; + } - var toStable = await StabilizeAsync(to, uiTarget, "to", json, cancellationToken); - if (!toStable.Ok) - { - return 1; - } + fromX = from.X; + fromY = from.Y; + toX = to.X; + toY = to.Y; + // Prefer the HWND of whichever endpoint resolved from a real element; fall back to the + // session window when both endpoints are bare coordinates. + targetHwnd = from.Hwnd != 0 ? from.Hwnd : (to.Hwnd != 0 ? to.Hwnd : uiTarget.WindowHandle); - fromX = fromStable.X; - fromY = fromStable.Y; - toX = toStable.X; - toY = toStable.Y; - - // Verify the target STILL holds the foreground as the first gate before the OS-wide drag. - // The stabilize re-resolve above performs awaited UIA reads (with delays); another window - // could steal focus during that gap, so we check here — after the awaits, not before them. - // Also distinguishes a locked/secure desktop from a wrong-window foreground. (For an element - // from-point a second, final gate runs below, after the cursor-settle confirm read.) - if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "drag")) - { - return 1; - } + if (!DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, targetHwnd, uiTarget.ProcessId, logger, json, "drag", parseResult.InvocationConfiguration.Error)) + { + return 1; + } - // Close the residual re-resolve→button-down race for the from-point (mirrors click's F3 - // fix): the button-down happens at , and MouseInput.Drag's own pre-press settle is an - // unguarded window in which a still-animating from-element could drift, so the press grabs - // empty space yet the drag reports success. When is an element, position the cursor - // on it, let it settle, confirm it hasn't moved, re-check the foreground, then press with - // settleMs: 0 — so a reported ✅ means the button went down on the element. A raw-coordinate - // from-point has nothing to confirm and keeps MouseInput.Drag's internal settle. - int dragSettleMs = 50; - if (from.Selector is not null && fromStable.StableElement is not null) - { - mouseInput.MoveCursor(fromX, fromY); - await Task.Delay(CursorSettleMs, cancellationToken); + if (targetHwnd != 0) + { + desktopForeground.RequestForeground(targetHwnd); + await Task.Delay(100, cancellationToken); + } - var confirmed = await GestureTargeting.ConfirmStillAsync( - uiAutomation, uiTarget, from.Selector, fromStable.StableElement, cancellationToken); - if (!UiInjectionReporting.TryReport(confirmed, logger, json, from.Token ?? "from", "drag")) + // Foregrounding can shift/animate the window (restore, snap, layout settle); re-resolve any + // element endpoint so we drag where it is *now*, and refuse rather than hit empty space if + // it's still moving. Raw-coordinate endpoints can't be verified, so they pass through. + var fromStable = await StabilizeAsync(from, uiTarget, "from", json, cancellationToken); + if (!fromStable.Ok) { return 1; } - fromX = confirmed.CenterX; - fromY = confirmed.CenterY; - // Final foreground gate after the awaited confirm read (focus could shift during it). + var toStable = await StabilizeAsync(to, uiTarget, "to", json, cancellationToken); + if (!toStable.Ok) + { + return 1; + } + + fromX = fromStable.X; + fromY = fromStable.Y; + toX = toStable.X; + toY = toStable.Y; + + // Verify the target STILL holds the foreground as the first gate before the OS-wide drag. if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "drag")) { return 1; } - // Cursor already positioned on the just-confirmed from-point; press without re-settling. - dragSettleMs = 0; - } + // Close the residual re-resolve→button-down race for the from-point. + int dragSettleMs = 50; + if (from.Selector is not null && fromStable.StableElement is not null) + { + mouseInput.MoveCursor(fromX, fromY); + await Task.Delay(CursorSettleMs, cancellationToken); + + var confirmed = await GestureTargeting.ConfirmStillAsync( + uiAutomation, uiTarget, from.Selector, fromStable.StableElement, cancellationToken); + if (!UiInjectionReporting.TryReport(confirmed, logger, json, from.Token ?? "from", "drag")) + { + return 1; + } + fromX = confirmed.CenterX; + fromY = confirmed.CenterY; + + // Final foreground gate after the awaited confirm read (focus could shift during it). + if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "drag")) + { + return 1; + } + + // Cursor already positioned on the just-confirmed from-point; press without re-settling. + dragSettleMs = 0; + } - mouseInput.Drag(fromX, fromY, toX, toY, rightButton, holdMs, dwellMs, settleMs: dragSettleMs); + mouseInput.Drag(fromX, fromY, toX, toY, rightButton, holdMs, dwellMs, settleMs: dragSettleMs); + } var button = rightButton ? "right" : "left"; @@ -243,7 +270,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiFocusCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiFocusCommand.cs index 346bbac9f..94d76dbaf 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiFocusCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiFocusCommand.cs @@ -10,6 +10,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -31,10 +32,17 @@ public class Handler( IUiTargetResolver targetResolver, IUiAutomation uiAutomation, IUiSelectorParser selectorParser, + ISystemUiQuery systemQuery, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui focus"; + + /// SetFocus changes the interactive desktop focus and must run exclusively. + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.DesktopExclusive; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); @@ -53,6 +61,17 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + // Preflight rejected a missing selector, so this is non-null by construction. + var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument)!; + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + try { var uiTarget = await targetResolver.ResolveAsync(app, window, cancellationToken); @@ -65,7 +84,25 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } - await uiAutomation.FocusAsync(uiTarget, element, cancellationToken); + await using (await turn.EnterAsync(cancellationToken).ConfigureAwait(false)) + { + element = await uiAutomation.FindSingleElementAsync(uiTarget, selector, cancellationToken); + if (element is null) + { + UiErrors.ElementNotFound(logger, selectorStr, json); + return 1; + } + + if (!DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, element.WindowHandle ?? uiTarget.WindowHandle, uiTarget.ProcessId, + logger, json, "focus", parseResult.InvocationConfiguration.Error)) + { + return 1; + } + + await uiAutomation.FocusAsync(uiTarget, element, cancellationToken); + } + if (json) { var result = new UiFocusResult { ElementId = (element.Selector ?? element.Id ?? ""), Hwnd = uiTarget.WindowHandle }; @@ -84,7 +121,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiGetFocusedCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiGetFocusedCommand.cs index 3ba7767f2..dc0a57319 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiGetFocusedCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiGetFocusedCommand.cs @@ -96,7 +96,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiGetPropertyCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiGetPropertyCommand.cs index 4cbb06777..aa16c3a30 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiGetPropertyCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiGetPropertyCommand.cs @@ -122,7 +122,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiGetValueCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiGetValueCommand.cs index da66e71f7..af6a20392 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiGetValueCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiGetValueCommand.cs @@ -116,7 +116,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiHoverCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiHoverCommand.cs index 676f30b03..fe4eb8cc1 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiHoverCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiHoverCommand.cs @@ -10,6 +10,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -40,10 +41,18 @@ public class Handler( IUiSelectorParser selectorParser, IMouseInput mouseInput, IForegroundGuard foregroundGuard, + IDesktopForegroundService desktopForeground, + ISystemUiQuery systemQuery, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui hover"; + + /// Hovering moves the shared cursor and holds it there for the dwell. + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.DesktopExclusive; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); @@ -70,6 +79,18 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + // Preflight rejected a missing selector, so this is non-null by construction. + var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument)!; + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + var dwellTime = parseResult.GetValue(DwellTimeOption); + try { var uiTarget = await targetResolver.ResolveAsync(app, window, cancellationToken); @@ -82,9 +103,6 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } - int centerX = (int)(element.X + element.Width / 2.0); - int centerY = (int)(element.Y + element.Height / 2.0); - if (element.Width == 0 || element.Height == 0) { logger.LogError("{Symbol} Element has zero size — cannot hover.", UiSymbols.Error); @@ -94,42 +112,47 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio // Use the element's own window handle if available, otherwise fall back to session var targetHwnd = element.WindowHandle ?? uiTarget.WindowHandle; + int centerX; + int centerY; - // Bring target window to foreground - if (targetHwnd != 0) + await using (await turn.EnterAsync(cancellationToken).ConfigureAwait(false)) { - Windows.Win32.PInvoke.SetForegroundWindow( - new Windows.Win32.Foundation.HWND((nint)targetHwnd)); - await Task.Delay(100, cancellationToken); - } + // Re-resolve just before hovering so the captured rect is current after any wait. + var stable = await GestureTargeting.ResolveStableAsync( + uiAutomation, uiTarget, selector, element, + GestureTargeting.DefaultMaxReads, GestureTargeting.DefaultReadDelayMs, null, cancellationToken); + if (!UiInjectionReporting.TryReport(stable, logger, json, selectorStr, "hover")) + { + return 1; + } + targetHwnd = stable.Element.WindowHandle ?? uiTarget.WindowHandle; + centerX = stable.CenterX; + centerY = stable.CenterY; + + if (!DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, targetHwnd, uiTarget.ProcessId, logger, json, "hover", parseResult.InvocationConfiguration.Error)) + { + return 1; + } - // Re-resolve just before hovering (N5): foregrounding can restore/animate the window, so - // the captured rect may be stale. Refuse rather than hover empty space if it's still moving. - var stable = await GestureTargeting.ResolveStableAsync( - uiAutomation, uiTarget, selector, element, - GestureTargeting.DefaultMaxReads, GestureTargeting.DefaultReadDelayMs, null, cancellationToken); - if (!UiInjectionReporting.TryReport(stable, logger, json, selectorStr, "hover")) - { - return 1; - } - centerX = stable.CenterX; - centerY = stable.CenterY; - - // Verify the target STILL holds the foreground as the final gate before the OS-wide hover - // (F1) — matches click / drag / scroll --wheel. Checked here, after the awaited re-resolve, - // to close the focus-steal race; also yields a clean no_interactive_desktop error on a - // locked session instead of a misleading SendInput failure, and refuses to move the pointer - // over whatever window grabbed the foreground. - if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "hover")) - { - return 1; - } + // Bring target window to foreground + if (targetHwnd != 0) + { + desktopForeground.RequestForeground(targetHwnd); + await Task.Delay(100, cancellationToken); + } - // Move mouse to element center with a small wiggle to trigger hover detection - mouseInput.Hover(centerX, centerY); + if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "hover")) + { + return 1; + } + + // Move mouse to element center with a small wiggle to trigger hover detection + mouseInput.Hover(centerX, centerY); - // Wait for dwell time to allow hover effects to appear - await Task.Delay(dwellTime, cancellationToken); + // Wait for dwell time to allow hover effects to appear + await Task.Delay(dwellTime, cancellationToken); + } var elementId = element.Selector ?? element.Id ?? ""; @@ -160,7 +183,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiInspectCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiInspectCommand.cs index 82a3e79d7..a8066f5db 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiInspectCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiInspectCommand.cs @@ -276,7 +276,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiInvokeCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiInvokeCommand.cs index 4c32ef1cf..d5ce25183 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiInvokeCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiInvokeCommand.cs @@ -10,6 +10,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -32,10 +33,17 @@ public class Handler( IUiTargetResolver targetResolver, IUiAutomation uiAutomation, IUiSelectorParser selectorParser, + ISystemUiQuery systemQuery, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui invoke"; + + /// InvokePattern and related actions can mutate UI and must run as a desktop turn. + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.DesktopExclusive; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); @@ -54,6 +62,17 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + // Preflight rejected a missing selector, so this is non-null by construction. + var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument)!; + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + try { var uiTarget = await targetResolver.ResolveAsync(app, window, cancellationToken); @@ -67,37 +86,61 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio } string pattern; - try - { - pattern = await uiAutomation.InvokeAsync(uiTarget, element, cancellationToken); - } - catch (InvalidOperationException) when (element.InvokableAncestor is { } ancestor) + UiElement invokedElement = element; + + await using (await turn.EnterAsync(cancellationToken).ConfigureAwait(false)) { - // Element isn't invokable but has an invokable ancestor — invoke that instead - pattern = await uiAutomation.InvokeAsync(uiTarget, ancestor, cancellationToken); - if (json) + element = await uiAutomation.FindSingleElementAsync(uiTarget, selector, cancellationToken); + if (element is null) { - var result = new UiInvokeResult { ElementId = ancestor.Selector ?? ancestor.Id ?? "", Pattern = pattern, Hwnd = uiTarget.WindowHandle }; - ansiConsole.Profile.Out.Writer.WriteLine( - JsonSerializer.Serialize(result, UiJsonContext.Default.UiInvokeResult)); + UiErrors.ElementNotFound(logger, selectorStr, json); + return 1; } - else + + if (!DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, element.WindowHandle ?? uiTarget.WindowHandle, uiTarget.ProcessId, + logger, json, "invoke", parseResult.InvocationConfiguration.Error)) { - logger.LogInformation("Invoked ancestor {Selector} \"{Name}\" via {Pattern} (matched text element was not invokable)", - ancestor.Selector ?? ancestor.Id, ancestor.Name, pattern); + return 1; + } + + try + { + pattern = await uiAutomation.InvokeAsync(uiTarget, element, cancellationToken); + invokedElement = element; + } + catch (InvalidOperationException) when (element.InvokableAncestor is { } ancestor) + { + // Element isn't invokable but has an invokable ancestor — invoke that instead + if (!DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, ancestor.WindowHandle ?? uiTarget.WindowHandle, uiTarget.ProcessId, + logger, json, "invoke", parseResult.InvocationConfiguration.Error)) + { + return 1; + } + + pattern = await uiAutomation.InvokeAsync(uiTarget, ancestor, cancellationToken); + invokedElement = ancestor; } - return 0; } if (json) { - var result = new UiInvokeResult { ElementId = (element.Selector ?? element.Id ?? ""), Pattern = pattern, Hwnd = uiTarget.WindowHandle }; + var result = new UiInvokeResult { ElementId = (invokedElement.Selector ?? invokedElement.Id ?? ""), Pattern = pattern, Hwnd = uiTarget.WindowHandle }; ansiConsole.Profile.Out.Writer.WriteLine( JsonSerializer.Serialize(result, UiJsonContext.Default.UiInvokeResult)); } else { - logger.LogInformation("Invoked {ElementId} via {Pattern}", (element.Selector ?? element.Id ?? ""), pattern); + if (ReferenceEquals(invokedElement, element)) + { + logger.LogInformation("Invoked {ElementId} via {Pattern}", (element.Selector ?? element.Id ?? ""), pattern); + } + else + { + logger.LogInformation("Invoked ancestor {Selector} \"{Name}\" via {Pattern} (matched text element was not invokable)", + invokedElement.Selector ?? invokedElement.Id, invokedElement.Name, pattern); + } } return 0; @@ -108,7 +151,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiListWindowsCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiListWindowsCommand.cs index d6a51a2b3..c8dbd8cea 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiListWindowsCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiListWindowsCommand.cs @@ -181,7 +181,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn logger.LogInformation("Found {Count} windows", displayedCount); return 0; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiPenCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiPenCommand.cs index fab766a08..de29801bd 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiPenCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiPenCommand.cs @@ -10,6 +10,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -84,10 +85,18 @@ public class Handler( IUiSelectorParser selectorParser, IPointerInput pointerInput, IForegroundGuard foregroundGuard, + IDesktopForegroundService desktopForeground, + ISystemUiQuery systemQuery, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui pen"; + + /// Synthetic pen injection is OS-wide and lands wherever the desktop points. + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.DesktopExclusive; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); @@ -124,7 +133,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio if (durationMs > MaxDelayMs) { - return RejectInvalidArguments($"--duration-ms must be {MaxDelayMs} ms or less (60 seconds). Got '{durationMs}'."); + return RejectInvalidArguments(parseResult, json, $"--duration-ms must be {MaxDelayMs} ms or less (60 seconds). Got '{durationMs}'."); } if (tiltX < -90 || tiltX > 90 || tiltY < -90 || tiltY > 90) @@ -149,12 +158,12 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio if (pathWasSupplied && atWasSupplied) { - return RejectInvalidArguments("--at is only valid for pen taps and cannot be combined with --path."); + return RejectInvalidArguments(parseResult, json, "--at is only valid for pen taps and cannot be combined with --path."); } if (durationWasSupplied && path is null) { - return RejectInvalidArguments("--duration-ms is only valid with --path (got a pen tap)."); + return RejectInvalidArguments(parseResult, json, "--duration-ms is only valid with --path (got a pen tap)."); } PointerPoint? at = null; @@ -184,10 +193,35 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } - // Track whether --path was provided (before the inner block mutates path). - // Used by M7: the selector branch calls SetForeground during stable-resolve so we skip - // the post-resolution SetForeground for that path only. - bool pathFromOption = path is not null; + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + var atStr = parseResult.GetValue(AtOption); + var pathStr = parseResult.GetValue(PathOption); + var pressure = parseResult.GetValue(PressureOption); + var tiltX = parseResult.GetValue(TiltXOption); + var tiltY = parseResult.GetValue(TiltYOption); + var eraser = parseResult.GetValue(EraserOption); + var durationMs = parseResult.GetValue(DurationOption); + + List? path = null; + if (!string.IsNullOrWhiteSpace(pathStr)) + { + _ = PointerGesturePlanner.TryParsePath(pathStr, out path); + } + + PointerPoint? at = null; + if (path is null && !string.IsNullOrWhiteSpace(atStr)) + { + _ = PointerGesturePlanner.TryParsePoint(atStr, out var atPoint); + at = atPoint; + } try { @@ -197,45 +231,50 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio // L1: report the effective target — pathStr when --path was given, atStr when --at // was given, or selectorStr when the selector resolved the contact point. var targetLabel = pathStr ?? (at is not null ? atStr : selectorStr); + PointerCommandSupport.InjectionPreparation prep; - // Build the ink path: explicit --path wins; else --at; else the selector's center. - if (path is null) + await using (await turn.EnterAsync(cancellationToken).ConfigureAwait(false)) { - var target = await PointerCommandSupport.ResolvePointAsync( - uiAutomation, selectorParser, uiTarget, selectorStr, at, atStr, - "pen", "pen point", logger, json, cancellationToken); - if (!target.Ok) + // Build the ink path: explicit --path wins; else --at; else the selector's center. + if (path is null) + { + var target = await PointerCommandSupport.ResolvePointAsync( + uiAutomation, selectorParser, uiTarget, selectorStr, at, atStr, + "pen", "pen point", logger, json, cancellationToken); + if (!target.Ok) + { + return 1; + } + + targetHwnd = target.TargetHwnd; + path = [target.Point]; + } + + if (!DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, targetHwnd, uiTarget.ProcessId, logger, json, "pen", + parseResult.InvocationConfiguration.Error)) { return 1; } - targetHwnd = target.TargetHwnd; - path = [target.Point]; - } + await PointerCommandSupport.SetForegroundAsync(desktopForeground, targetHwnd, cancellationToken); - // M7: SetForeground only when the selector branch did not already do it. - // The selector branch (no --path and no --at) calls SetForeground during stable-resolve; - // the --at and --path branches do not, so they need it here before injection. - if (pathFromOption || at is not null) - { - await PointerCommandSupport.SetForegroundAsync(targetHwnd, cancellationToken); - } - - var prep = PointerCommandSupport.TryPrepareInjection( - uiAutomation, foregroundGuard, targetHwnd, path, "pen", "pen input", logger, json); - if (!prep.Ok) - { - return 1; - } + prep = PointerCommandSupport.TryPrepareInjection( + uiAutomation, foregroundGuard, targetHwnd, path, "pen", "pen input", logger, json); + if (!prep.Ok) + { + return 1; + } - // M6: narrow the injection_unsupported catch to only the actual injection call so that - // pre-injection failures (element not found, etc.) are NOT mis-classified as - // injection_unsupported. Session resolution failures surface as missing_app (outer catch). - if (!PointerCommandSupport.TryInject( - () => pointerInput.Pen(path, pressure, tiltX, tiltY, eraser, durationMs), - logger, json, parseResult.InvocationConfiguration.Error)) - { - return 1; + // M6: narrow the injection_unsupported catch to only the actual injection call so that + // pre-injection failures (element not found, etc.) are NOT mis-classified as + // injection_unsupported. Session resolution failures surface as missing_app (outer catch). + if (!PointerCommandSupport.TryInject( + () => pointerInput.Pen(path, pressure, tiltX, tiltY, eraser, durationMs), + logger, json, parseResult.InvocationConfiguration.Error)) + { + return 1; + } } var action = eraser ? "erase" : (path.Count > 1 ? "draw" : "tap"); @@ -315,19 +354,20 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio errorOut: parseResult.InvocationConfiguration.Error); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json, parseResult.InvocationConfiguration.Error); return 1; } - int RejectInvalidArguments(string message) - { - logger.LogError("{Symbol} {Message}", UiSymbols.Error, message); - UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, message, - errorOut: parseResult.InvocationConfiguration.Error); - return 1; - } + } + + private int RejectInvalidArguments(ParseResult parseResult, bool json, string message) + { + logger.LogError("{Symbol} {Message}", UiSymbols.Error, message); + UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, message, + errorOut: parseResult.InvocationConfiguration.Error); + return 1; } } } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs index 9b65a1c69..1a82192e3 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs @@ -44,8 +44,10 @@ public UiRecordCommand() public class Handler( IUiTargetResolver targetResolver, IUiRecordingService recordingService, + IWindowCapture windowCapture, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { // Test seams: override Console.IsInputRedirected and Console.In without process-level side effects. internal static Func? s_isInputRedirectedOverride; @@ -54,19 +56,28 @@ public class Handler( // Prevents the stdin monitor from racing disposal of its cancellation source. private volatile bool _stdinMonitorStopped; - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui record"; + + /// + /// Recording shares the turn: it pins its owner for the whole capture, but same-workflow input + /// may interleave so a workflow can record itself driving the app. + /// + /// + /// With no WINAPP_UI_WORKFLOW_ID the recording is an anonymous one-command owner, so it + /// blocks every other owner for its full duration — to record and click concurrently, both + /// commands must name the same workflow. + /// + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.TurnShared; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); - var quiet = parseResult.GetValue(WinAppRootCommand.QuietOption); - var selector = parseResult.GetValue(SharedUiOptions.SelectorArgument); var app = parseResult.GetValue(SharedUiOptions.AppOption); var window = parseResult.GetValue(SharedUiOptions.WindowOption); var durationSec = parseResult.GetValue(SharedUiOptions.DurationSecOption); var fps = parseResult.GetValue(SharedUiOptions.FpsOption); var maxEdge = parseResult.GetValue(SharedUiOptions.MaxEdgeOption); var maxEdgeExplicit = parseResult.GetResult(SharedUiOptions.MaxEdgeOption)?.Implicit == false; - var captureScreen = parseResult.GetValue(SharedUiOptions.CaptureScreenOption); - var output = parseResult.GetValue(SharedUiOptions.OutputOption); var frames = parseResult.GetValue(FramesOption); // Validate options before resolving the target. @@ -109,11 +120,6 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio logger.LogError("{Symbol} {Message}", UiSymbols.Error, message); return 1; } - - if (!maxEdgeExplicit) - { - maxEdge = DefaultFrameArtifactMaxEdge; - } } if (string.IsNullOrWhiteSpace(app) && window is null) @@ -122,6 +128,30 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + var quiet = parseResult.GetValue(WinAppRootCommand.QuietOption); + var selector = parseResult.GetValue(SharedUiOptions.SelectorArgument); + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + var durationSec = parseResult.GetValue(SharedUiOptions.DurationSecOption); + var fps = parseResult.GetValue(SharedUiOptions.FpsOption); + var maxEdge = parseResult.GetValue(SharedUiOptions.MaxEdgeOption); + var maxEdgeExplicit = parseResult.GetResult(SharedUiOptions.MaxEdgeOption)?.Implicit == false; + var captureScreen = parseResult.GetValue(SharedUiOptions.CaptureScreenOption); + var output = parseResult.GetValue(SharedUiOptions.OutputOption); + var frames = parseResult.GetValue(FramesOption); + + // Preflight already validated every option above; only the default-derivation remains. + if (frames && !maxEdgeExplicit) + { + maxEdge = DefaultFrameArtifactMaxEdge; + } + // Set _stdinMonitorStopped before disposing this source. var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); _stdinMonitorStopped = false; @@ -231,7 +261,15 @@ void OnRecordingStarted(bool frameArtifactsActive) FramesDirectory = framesDirectory, }; - var result = await recordingService.RecordAsync(uiTarget, selector, options, linkedCts.Token, OnRecordingStarted); + var (result, coordinationWarning) = await RecordUnderTurnAsync( + turn, uiTarget, selector, options, captureScreen, json, quiet, + OnRecordingStarted, linkedCts.Token).ConfigureAwait(false); + + // Merged here rather than inside the helper because RecordCaptureResult is engine-owned + // and immutable; the coordination note is a CLI concern layered on top of it. + var allWarnings = coordinationWarning is null + ? result.Warnings + : [.. result.Warnings ?? [], coordinationWarning]; if (json) { @@ -251,7 +289,7 @@ void OnRecordingStarted(bool frameArtifactsActive) CadenceRatio = result.CadenceRatio, StopReason = result.StopReason, FrameArtifacts = result.FrameArtifacts, - Warnings = result.Warnings, + Warnings = allWarnings, }; ansiConsole.Profile.Out.Writer.WriteLine( JsonSerializer.Serialize(payload, UiJsonContext.Default.UiRecordResult)); @@ -260,7 +298,7 @@ void OnRecordingStarted(bool frameArtifactsActive) logger.LogInformation( "Recorded {Frames} frames ({Width}x{Height}, h264) to {Path} ({Size}KB)", result.Frames, result.Width, result.Height, filePath, result.FileSize / 1024); - foreach (var warning in result.Warnings ?? []) + foreach (var warning in allWarnings ?? []) { logger.LogWarning("{Symbol} {Warning}", UiSymbols.Warning, warning); } @@ -331,10 +369,32 @@ void OnRecordingStarted(bool frameArtifactsActive) UiErrors.ElementNotFound(logger, notFoundEx.Selector, json); return 1; } + catch (ForegroundLostException foregroundEx) + { + // The engine refused to record because the target never reached the foreground, so every + // screen frame would have been of the wrong window. Same precise contract as the + // pre-injection foreground guard, and no MP4 is produced. + logger.LogError("{Symbol} {Message}", UiSymbols.Error, foregroundEx.Message); + UiJsonError.Emit(json, UiJsonError.CodeForegroundNotTarget, foregroundEx.Message, + errorOut: parseResult.InvocationConfiguration.Error); + return 1; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Native Ctrl+C / coordinator cancellation before capture started. There is no finalized + // MP4 to preserve, so this is NOT a completed command: swallowing it would make the + // coordinator see a normal body return and renew the owner's idle grace for a command that + // produced nothing. An ACTIVE recording that observes cancellation instead finalizes its + // MP4 and RETURNS success, so it never reaches this catch and still renews. + logger.LogDebug("Recording cancelled before capture started; propagating to coordination."); + throw; + } catch (OperationCanceledException) when (linkedCts.IsCancellationRequested) { - // In-loop cancellation returns a finalized recording instead. - logger.LogDebug("Recording cancelled before capture started."); + // Defensive: the stdin stop-monitor only arms after encoder readiness, so this is the + // narrow race where it fires between readiness and the first frame. The workflow asked its + // own recording to stop rather than abandoning the command, so it keeps its turn. + logger.LogDebug("Recording stopped via stdin before capture started."); return 1; } catch (System.Runtime.InteropServices.COMException comEx) @@ -343,7 +403,7 @@ void OnRecordingStarted(bool frameArtifactsActive) UiErrors.GenericError(logger, comEx, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; @@ -355,6 +415,135 @@ void OnRecordingStarted(bool frameArtifactsActive) } } + /// + /// Runs the recording, holding active.lock only for as long as the capture mode actually + /// needs the desktop to itself. + /// + /// + /// + /// WGC and screen-DC recording touch the desktop only while starting up — restoring a minimized + /// window and taking the foreground — so the section is released as soon as the first frame is + /// committed. That is what lets a workflow record itself typing: the recording keeps the turn + /// () while same-workflow input takes the section between + /// frames. + /// + /// + /// PrintWindow is different. When the host has no frame-capture support, any frame can + /// hit the engine's blank-frame retry, which foregrounds the window to recover — mid-recording, + /// with no warning. Releasing the section there would let that retry fight another command for + /// the foreground, so the section is held for the whole recording and the caller is told plainly + /// that input cannot interleave on this host. + /// + /// + private async Task<(RecordCaptureResult Result, string? CoordinationWarning)> RecordUnderTurnAsync( + IUiTurn turn, + UiTarget uiTarget, + string? selector, + RecordOptions options, + bool captureScreen, + bool json, + bool quiet, + Action onRecordingStarted, + CancellationToken ct) + { + // Mirrors the engine's own selection in UiRecordingService: screen DC when asked for, else WGC + // when the host supports frame capture, else PrintWindow. Asserted against the result below so + // this prediction cannot silently drift away from the engine. + var predictedMode = captureScreen + ? "screen" + : windowCapture.IsFrameCaptureSupported ? "wgc" : "printwindow"; + var holdForWholeRecording = predictedMode == "printwindow"; + + if (holdForWholeRecording) + { + const string warning = + "This host has no frame-capture support, so recording falls back to PrintWindow, whose " + + "blank-frame recovery can foreground the window at any point. The desktop is therefore " + + "held for the whole recording and other winapp ui commands — including ones sharing this " + + "workflow id — will wait until it finishes."; + if (!json && !quiet) + { + logger.LogWarning("{Symbol} {Message}", UiSymbols.Warning, warning); + } + + RecordCaptureResult heldResult; + await using (await turn.EnterAsync(ct).ConfigureAwait(false)) + { + heldResult = await recordingService + .RecordAsync(uiTarget, selector, options, ct, onRecordingStarted).ConfigureAwait(false); + } + + AssertPredictedMode(predictedMode, heldResult); + return (heldResult, warning); + } + + // RunContinuationsAsynchronously is mandatory: without it, TrySetResult below would run this + // method's continuation ON the engine's capture thread, inside its first-frame callback, and + // the section disposal would happen there too. + var startedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void OnStarted(bool frameArtifactsActive) + { + // The callback only signals. It never disposes the section: disposal is async and belongs + // to this method, and doing it here would block the engine's capture loop on a file lock. + onRecordingStarted(frameArtifactsActive); + startedTcs.TrySetResult(); + } + + var section = await turn.EnterAsync(ct).ConfigureAwait(false); + var released = false; + + async Task ReleaseOnceAsync() + { + // Exactly once, whichever of the two outcomes below happens first — and again from the + // finally, in case neither did (a synchronous throw before any frame). + if (released) + { + return; + } + + released = true; + await section.DisposeAsync().ConfigureAwait(false); + } + + try + { + var recordTask = recordingService.RecordAsync(uiTarget, selector, options, ct, OnStarted); + + // Race the two ways the desktop stops being needed: the first frame landed, or the + // recording ended before producing one (fault, cancellation, or a zero-frame run). Waiting + // only on the started signal would hang forever on the second case. + await Task.WhenAny(startedTcs.Task, recordTask).ConfigureAwait(false); + await ReleaseOnceAsync().ConfigureAwait(false); + + var result = await recordTask.ConfigureAwait(false); + AssertPredictedMode(predictedMode, result); + return (result, null); + } + finally + { + await ReleaseOnceAsync().ConfigureAwait(false); + } + } + + /// + /// Fails loudly when the engine chose a different capture mode than this command predicted. + /// + /// + /// The section-holding decision above is derived from a prediction, so a future engine change that + /// altered mode selection would silently release the desktop mid-PrintWindow recording. Comparing + /// against the reported mode turns that into a visible failure instead. + /// + private void AssertPredictedMode(string predictedMode, RecordCaptureResult result) + { + if (result.Mode is { } actual && !string.Equals(actual, predictedMode, StringComparison.Ordinal)) + { + logger.LogWarning( + "{Symbol} Recording used capture mode '{Actual}' but coordination planned for '{Predicted}'. Desktop coordination for this recording may have been wider or narrower than needed.", + UiSymbols.Warning, actual, predictedMode); + } + } + internal void CancelFromStdinMonitor(CancellationTokenSource linkedCts) { if (!_stdinMonitorStopped) diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs index 420c5b281..316851904 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs @@ -11,6 +11,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -39,12 +40,27 @@ public class Handler( IOwnedWindowFinder ownedWindowFinder, ISystemUiQuery systemQuery, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui screenshot"; + + /// + /// Always exclusive. + /// + /// + /// Every capture path restores minimized windows and/or takes the foreground, and + /// --capture-screen reads the live screen, so a screenshot is desktop-sensitive whatever + /// its arguments. An earlier design started observationally and escalated on discovering it + /// needed the foreground, which cost an entire discard-and-recapture pass, a second scheduler + /// transition, and a mode that could change mid-command — all to avoid queueing for a command + /// that virtually always ended up queueing anyway. + /// + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.DesktopExclusive; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); - var selector = parseResult.GetValue(SharedUiOptions.SelectorArgument); var app = parseResult.GetValue(SharedUiOptions.AppOption); var window = parseResult.GetValue(SharedUiOptions.WindowOption); @@ -53,79 +69,57 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio UiErrors.MissingApp(logger, json); return 1; } + + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + var selector = parseResult.GetValue(SharedUiOptions.SelectorArgument); + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); var output = parseResult.GetValue(SharedUiOptions.OutputOption); var captureScreen = parseResult.GetValue(SharedUiOptions.CaptureScreenOption); var focus = parseResult.GetValue(SharedUiOptions.FocusOption); try { - // Screenshot handles multi-window discovery itself (avoids duplicate warning from session resolution) - if (selector is null) + CapturePass pass; + + // One section spans the WHOLE pixel-capture pass, not one per window. Compositing several + // windows only makes sense if they were all captured against the same desktop state; a + // per-window section would let another workflow foreground something between two frames + // and produce a composite that never existed on screen. Window discovery and target + // revalidation are inside for the same reason — a command may have queued for an + // unbounded time, so anything resolved before the wait is advisory. + await using (await turn.EnterAsync(cancellationToken).ConfigureAwait(false)) { - var allWindows = DiscoverAllWindows(app, window); - if (allWindows is not null && allWindows.Count > 1) - { - // Resolve session using the largest window's HWND (suppresses session multi-window warning) - var main = allWindows.OrderByDescending(w => - { - var info = UiTargetResolver.GetWindowInfo(w.Hwnd); - return (long)info.Width * info.Height; - }).First(); - var uiTarget = await targetResolver.ResolveAsync(null, main.Hwnd, cancellationToken); - return await CaptureMultipleWindows(allWindows, uiTarget, output, json, captureScreen, focus, cancellationToken); - } + pass = await CaptureUnderSectionAsync( + selector, app, window, json, captureScreen, focus, cancellationToken).ConfigureAwait(false); } - // Single window capture (or element crop) - var singleSession = await targetResolver.ResolveAsync(app, window, cancellationToken); - - // Even for single-window session, check for owned dialogs - if (selector is null) + // Deliberately outside the section: composing, PNG encoding and writing to disk are pure + // CPU and file I/O that touch no shared desktop state, and they are the slowest part of + // the command. Holding active.lock across them would block every other workflow for no + // safety benefit. + if (pass.ExitCode is { } earlyExit) { - var targetWindowHwnd = (nint)singleSession.WindowHandle; - var ownedWindows = ownedWindowFinder.FindOwnedWindows([(targetWindowHwnd, singleSession.ProcessId, singleSession.WindowTitle ?? "")]); - if (ownedWindows.Count > 0) - { - var allWindows = new List<(nint Hwnd, int Pid, string Title)> - { - (targetWindowHwnd, singleSession.ProcessId, singleSession.WindowTitle ?? "") - }; - allWindows.AddRange(ownedWindows); - return await CaptureMultipleWindows(allWindows, singleSession, output, json, captureScreen, focus, cancellationToken); - } + return earlyExit; } - var (pixels, w, h) = await uiAutomation.ScreenshotAsync(singleSession, selector, captureScreen, focus, cancellationToken); - var pngBytes = EncodePng(pixels, w, h); - - var filePath = output ?? "screenshot.png"; - var dir = Path.GetDirectoryName(Path.GetFullPath(filePath)); - if (dir is not null) - { - Directory.CreateDirectory(dir); - } - await File.WriteAllBytesAsync(filePath, pngBytes, cancellationToken); - var absolutePath = Path.GetFullPath(filePath); - - if (json) - { - var result = new UiScreenshotResult - { - ElementId = selector, - FilePath = absolutePath, - Width = w, - Height = h, - ProcessId = singleSession.ProcessId, - WindowTitle = singleSession.WindowTitle, - Hwnd = singleSession.WindowHandle - }; - ansiConsole.Profile.Out.Writer.WriteLine( - JsonSerializer.Serialize(result, UiJsonContext.Default.UiScreenshotResult)); - return 0; - } - - logger.LogInformation("Screenshot of \"{WindowTitle}\" (PID {ProcessId}) saved to {Path} ({Width}x{Height}, {Size}KB)", singleSession.WindowTitle, singleSession.ProcessId, absolutePath, w, h, pngBytes.Length / 1024); - return 0; + return await PublishAsync(pass, output, json, cancellationToken).ConfigureAwait(false); + } + catch (ForegroundLostException foregroundEx) + { + // The engine refused to capture because the target never reached the foreground. Report + // the same precise contract as the pre-injection foreground guard rather than a generic + // internal error, and write no artifact — a PNG of the wrong window is worse than none, + // because the caller cannot tell. + logger.LogError("{Symbol} {Message}", UiSymbols.Error, foregroundEx.Message); + UiJsonError.Emit(json, UiJsonError.CodeForegroundNotTarget, foregroundEx.Message, + errorOut: parseResult.InvocationConfiguration.Error); + return 1; } catch (System.Runtime.InteropServices.COMException comEx) { @@ -133,24 +127,99 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; } } - private async Task CaptureMultipleWindows( + /// Pixels captured by one pass, plus everything needed to publish them. + /// Set when the pass already reported a failure and produced no pixels. + private sealed record CapturePass( + int? ExitCode, + UiTarget Target, + string? Selector, + List<(byte[] Pixels, int Width, int Height, nint Hwnd, string Title, string Label)> Captures, + List WindowDetails, + bool IsComposite); + + /// + /// Everything that reads the shared desktop. Runs entirely inside the caller's active section. + /// + private async Task CaptureUnderSectionAsync( + string? selector, + string? app, + long? window, + bool json, + bool captureScreen, + bool focus, + CancellationToken ct) + { + // Screenshot handles multi-window discovery itself (avoids duplicate warning from session resolution) + if (selector is null) + { + var allWindows = DiscoverAllWindows(app, window); + if (allWindows is not null && allWindows.Count > 1) + { + // Resolve session using the largest window's HWND (suppresses session multi-window warning) + var main = allWindows.OrderByDescending(w => + { + var info = UiTargetResolver.GetWindowInfo(w.Hwnd); + return (long)info.Width * info.Height; + }).First(); + var multiTarget = await targetResolver.ResolveAsync(null, main.Hwnd, ct).ConfigureAwait(false); + return await CaptureWindowsAsync(allWindows, multiTarget, json, captureScreen, focus, ct).ConfigureAwait(false); + } + } + + var singleTarget = await targetResolver.ResolveAsync(app, window, ct).ConfigureAwait(false); + + // Even for a single-window session, check for owned dialogs. + if (selector is null) + { + var targetWindowHwnd = (nint)singleTarget.WindowHandle; + var ownedWindows = ownedWindowFinder.FindOwnedWindows( + [(targetWindowHwnd, singleTarget.ProcessId, singleTarget.WindowTitle ?? "")]); + if (ownedWindows.Count > 0) + { + var allWindows = new List<(nint Hwnd, int Pid, string Title)> + { + (targetWindowHwnd, singleTarget.ProcessId, singleTarget.WindowTitle ?? ""), + }; + allWindows.AddRange(ownedWindows); + return await CaptureWindowsAsync(allWindows, singleTarget, json, captureScreen, focus, ct).ConfigureAwait(false); + } + } + + // The window this command is about to capture must still be the one it resolved: while it + // waited, the original could have closed and Windows reused its handle for another process. + if (!DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, singleTarget.WindowHandle, singleTarget.ProcessId, logger, json, "screenshot")) + { + return new CapturePass(1, singleTarget, selector, [], [], IsComposite: false); + } + + var (pixels, w, h) = await uiAutomation + .ScreenshotAsync(singleTarget, selector, captureScreen, focus, ct).ConfigureAwait(false); + + return new CapturePass( + null, + singleTarget, + selector, + [(pixels, w, h, (nint)singleTarget.WindowHandle, singleTarget.WindowTitle ?? "", "")], + [], + IsComposite: false); + } + + private async Task CaptureWindowsAsync( List<(nint Hwnd, int Pid, string Title)> windows, UiTarget uiTarget, - string? output, bool json, bool captureScreen, bool focus, CancellationToken ct) { - var filePath = output ?? "screenshot.png"; - // Sort: main window first (largest), then others var sorted = windows.OrderByDescending(w => { @@ -163,7 +232,6 @@ private async Task CaptureMultipleWindows( ansiConsole.MarkupLine($"[yellow]⚠ {windows.Count} windows detected. Compositing into single image.[/]"); } - // Capture each window var captures = new List<(byte[] Pixels, int Width, int Height, nint Hwnd, string Title, string Label)>(); var windowDetails = new List(); foreach (var w in sorted) @@ -172,14 +240,15 @@ private async Task CaptureMultipleWindows( var title = string.IsNullOrEmpty(w.Title) ? "(no title)" : w.Title; try { - var windowSession = new UiTarget + var windowTarget = new UiTarget { ProcessId = w.Pid, ProcessName = uiTarget.ProcessName, WindowTitle = title, - WindowHandle = w.Hwnd + WindowHandle = w.Hwnd, }; - var (pixels, width, height) = await uiAutomation.ScreenshotAsync(windowSession, null, captureScreen, focus, ct); + var (pixels, width, height) = await uiAutomation + .ScreenshotAsync(windowTarget, null, captureScreen, focus, ct).ConfigureAwait(false); captures.Add((pixels, width, height, w.Hwnd, title, info.Label)); windowDetails.Add(new UiScreenshotWindowInfo { @@ -197,7 +266,14 @@ private async Task CaptureMultipleWindows( ansiConsole.MarkupLine($" [green]✓[/] HWND [cyan]{w.Hwnd}[/]: \"{Markup.Escape(title)}\" [grey]({info.Label}, {width}x{height}{owner})[/]"); } } - catch (Exception ex) + catch (ForegroundLostException) + { + // The foreground is a property of the desktop, not of this one window, so a refused + // activation is not a per-window failure. Recording it as one and continuing would end + // with "No windows could be captured" and bury the real, actionable cause. + throw; + } + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { logger.LogDebug("Failed to capture HWND {Hwnd}: {Error}", w.Hwnd, ex.Message); windowDetails.Add(new UiScreenshotWindowInfo @@ -219,24 +295,41 @@ private async Task CaptureMultipleWindows( { logger.LogError("No windows could be captured."); UiJsonError.Emit(json, UiJsonError.CodeInternalError, "No windows could be captured."); - return 1; + return new CapturePass(1, uiTarget, null, captures, windowDetails, IsComposite: true); } - // Compose all captures side-by-side into single image - var pngBytes = ComposeSideBySide(captures); + return new CapturePass(null, uiTarget, null, captures, windowDetails, IsComposite: true); + } + + /// + /// Encodes and writes what the pass captured. Runs after the active section has been released. + /// + private async Task PublishAsync(CapturePass pass, string? output, bool json, CancellationToken ct) + { + var filePath = output ?? "screenshot.png"; + var captures = pass.Captures; + + var pngBytes = pass.IsComposite + ? ComposeSideBySide(captures) + : EncodePng(captures[0].Pixels, captures[0].Width, captures[0].Height); + var dir = Path.GetDirectoryName(Path.GetFullPath(filePath)); if (dir is not null) { Directory.CreateDirectory(dir); } - await File.WriteAllBytesAsync(filePath, pngBytes, ct); + + await File.WriteAllBytesAsync(filePath, pngBytes, ct).ConfigureAwait(false); var absolutePath = Path.GetFullPath(filePath); - // Calculate composite dimensions for JSON output - var compositeWidth = captures.Sum(c => c.Width) + WindowGap * (captures.Count - 1); - var compositeHeight = captures.Max(c => c.Height) + LabelBarHeight; + var width = pass.IsComposite + ? captures.Sum(c => c.Width) + WindowGap * (captures.Count - 1) + : captures[0].Width; + var height = pass.IsComposite + ? captures.Max(c => c.Height) + LabelBarHeight + : captures[0].Height; - if (!json) + if (pass.IsComposite && !json) { ansiConsole.MarkupLine($" [green]✓[/] Saved composite: {absolutePath}"); } @@ -245,16 +338,25 @@ private async Task CaptureMultipleWindows( { var result = new UiScreenshotResult { + ElementId = pass.Selector, FilePath = absolutePath, - Width = compositeWidth, - Height = compositeHeight, - ProcessId = uiTarget.ProcessId, - WindowTitle = uiTarget.WindowTitle, - Hwnd = uiTarget.WindowHandle, - Windows = windowDetails.ToArray(), + Width = width, + Height = height, + ProcessId = pass.Target.ProcessId, + WindowTitle = pass.Target.WindowTitle, + Hwnd = pass.Target.WindowHandle, + Windows = pass.IsComposite ? pass.WindowDetails.ToArray() : null, }; ansiConsole.Profile.Out.Writer.WriteLine( JsonSerializer.Serialize(result, UiJsonContext.Default.UiScreenshotResult)); + return 0; + } + + if (!pass.IsComposite) + { + logger.LogInformation( + "Screenshot of \"{WindowTitle}\" (PID {ProcessId}) saved to {Path} ({Width}x{Height}, {Size}KB)", + pass.Target.WindowTitle, pass.Target.ProcessId, absolutePath, width, height, pngBytes.Length / 1024); } return 0; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiScrollCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiScrollCommand.cs index f302f42e2..bd6bbaed5 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiScrollCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiScrollCommand.cs @@ -9,6 +9,7 @@ using Spectre.Console; using WinApp.Cli.Helpers; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -61,13 +62,21 @@ public class Handler( IUiSelectorParser selectorParser, IMouseInput mouseInput, IForegroundGuard foregroundGuard, + IDesktopForegroundService desktopForeground, + ISystemUiQuery systemQuery, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { // Cursor-settle pause (ms) after positioning over the target, before the confirm read + wheel. private const int CursorSettleMs = 30; - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui scroll"; + + protected override UiTurnMode ResolveMode(ParseResult parseResult) + => parseResult.GetValue(WheelOption) is not null ? UiTurnMode.DesktopExclusive : UiTurnMode.Observe; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); @@ -107,6 +116,20 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + // Preflight rejected a missing selector, so this is non-null by construction. + var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument)!; + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + var direction = parseResult.GetValue(DirectionOption); + var to = parseResult.GetValue(ToOption); + var wheel = parseResult.GetValue(WheelOption); + try { var uiTarget = await targetResolver.ResolveAsync(app, window, cancellationToken); @@ -123,9 +146,6 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio if (wheel is int notches) { - int centerX = (int)(element.X + element.Width / 2.0); - int centerY = (int)(element.Y + element.Height / 2.0); - if (element.Width == 0 || element.Height == 0) { logger.LogError("{Symbol} Element has zero size — cannot scroll-wheel over it.", UiSymbols.Error); @@ -133,63 +153,60 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } - if (targetHwnd != 0) - { - Windows.Win32.PInvoke.SetForegroundWindow( - new Windows.Win32.Foundation.HWND((nint)targetHwnd)); - await Task.Delay(100, cancellationToken); - } - - // Re-resolve just before scrolling (N5): foregrounding can restore/animate the window, - // so the captured rect may be stale. Refuse rather than scroll empty space if it's - // still moving. - var stable = await GestureTargeting.ResolveStableAsync( - uiAutomation, uiTarget, selector, element, - GestureTargeting.DefaultMaxReads, GestureTargeting.DefaultReadDelayMs, null, cancellationToken); - if (!UiInjectionReporting.TryReport(stable, logger, json, selectorStr, "scroll --wheel")) - { - return 1; - } - centerX = stable.CenterX; - centerY = stable.CenterY; - - // Verify the target STILL holds the foreground as the first gate before the OS-wide - // wheel injection. The re-resolve above awaits UIA reads (with delays) during which - // another window could steal focus, so we check here — after the awaits, not before - // them. Also distinguishes a locked/secure desktop from a wrong-window foreground. - if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "scroll --wheel")) - { - return 1; - } - - // Close the residual re-resolve→wheel race (mirrors click/drag): ScrollWheel positions - // the cursor and settles before injecting, which is an unguarded window in which a - // still-animating target could drift, routing the wheel to whatever is now under the - // pointer. Position the cursor, let it settle, confirm the target hasn't moved, re-check - // the foreground, then inject with settleMs: 0 — so a reported ✅ means the wheel went to - // the element. - mouseInput.MoveCursor(centerX, centerY); - await Task.Delay(CursorSettleMs, cancellationToken); - - var confirmed = await GestureTargeting.ConfirmStillAsync( - uiAutomation, uiTarget, selector, stable.Element, cancellationToken); - if (!UiInjectionReporting.TryReport(confirmed, logger, json, selectorStr, "scroll --wheel")) - { - return 1; - } - centerX = confirmed.CenterX; - centerY = confirmed.CenterY; + int centerX; + int centerY; - // Final foreground gate after the awaited confirm read (focus could shift during it). - if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "scroll --wheel")) + await using (await turn.EnterAsync(cancellationToken).ConfigureAwait(false)) { - return 1; + var stable = await GestureTargeting.ResolveStableAsync( + uiAutomation, uiTarget, selector, element, + GestureTargeting.DefaultMaxReads, GestureTargeting.DefaultReadDelayMs, null, cancellationToken); + if (!UiInjectionReporting.TryReport(stable, logger, json, selectorStr, "scroll --wheel")) + { + return 1; + } + targetHwnd = stable.Element.WindowHandle ?? uiTarget.WindowHandle; + centerX = stable.CenterX; + centerY = stable.CenterY; + + if (!DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, targetHwnd, uiTarget.ProcessId, logger, json, "scroll --wheel", parseResult.InvocationConfiguration.Error)) + { + return 1; + } + + if (targetHwnd != 0) + { + desktopForeground.RequestForeground(targetHwnd); + await Task.Delay(100, cancellationToken); + } + + if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "scroll --wheel")) + { + return 1; + } + + mouseInput.MoveCursor(centerX, centerY); + await Task.Delay(CursorSettleMs, cancellationToken); + + var confirmed = await GestureTargeting.ConfirmStillAsync( + uiAutomation, uiTarget, selector, stable.Element, cancellationToken); + if (!UiInjectionReporting.TryReport(confirmed, logger, json, selectorStr, "scroll --wheel")) + { + return 1; + } + centerX = confirmed.CenterX; + centerY = confirmed.CenterY; + + if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "scroll --wheel")) + { + return 1; + } + + // --wheel is expressed in notches for ergonomics; SendInput's mouse wheel works in + // WHEEL_DELTA units (120 per detent), so scale up to the raw delta the OS expects. + mouseInput.ScrollWheel(centerX, centerY, notches * WheelDelta, settleMs: 0); } - - // --wheel is expressed in notches for ergonomics; SendInput's mouse wheel works in - // WHEEL_DELTA units (120 per detent), so scale up to the raw delta the OS expects. The - // cursor is already positioned and the target just confirmed, so skip the inner settle. - mouseInput.ScrollWheel(centerX, centerY, notches * WheelDelta, settleMs: 0); } else { @@ -222,7 +239,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiScrollIntoViewCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiScrollIntoViewCommand.cs index 549c18cd9..8d49b67b8 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiScrollIntoViewCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiScrollIntoViewCommand.cs @@ -102,7 +102,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiSearchCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiSearchCommand.cs index 4a757248c..fb9762e90 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiSearchCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiSearchCommand.cs @@ -137,7 +137,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiSendKeysCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiSendKeysCommand.cs index 68bb48b3a..98b9c9697 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiSendKeysCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiSendKeysCommand.cs @@ -9,6 +9,7 @@ using Spectre.Console; using WinApp.Cli.Helpers; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -87,17 +88,26 @@ public class Handler( IUiSelectorParser selectorParser, IKeyboardInput keyboardInput, IForegroundGuard foregroundGuard, + IDesktopForegroundService desktopForeground, ISystemUiQuery systemQuery, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui send-keys"; + + /// + /// Both transports are desktop-exclusive: send-input is OS-wide and post-message still foregrounds + /// and focuses the target to route keys. + /// + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.DesktopExclusive; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var keysStr = parseResult.GetValue(KeysArgument); var app = parseResult.GetValue(SharedUiOptions.AppOption); var window = parseResult.GetValue(SharedUiOptions.WindowOption); - var target = parseResult.GetValue(TargetOption); var viaStr = parseResult.GetValue(ViaOption) ?? "post-message"; var verbatim = parseResult.GetValue(VerbatimOption); var allowSystemKeys = parseResult.GetValue(AllowSystemKeysOption); @@ -128,20 +138,6 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } - // SEC-02: --allow-system-keys only applies to send-input; with post-message the transport is - // already window-scoped so system combos are never blocked and the flag has no effect. - var warnings = new List(); - if (allowSystemKeys && transport != KeyTransport.SendInput) - { - logger.LogWarning( - "{Symbol} --allow-system-keys only applies to --via send-input and has no effect with " + - "--via post-message (post-message is already window-scoped and never blocks system combos).", - UiSymbols.Warning); - warnings.Add( - "--allow-system-keys only applies to --via send-input and has no effect with " + - "--via post-message (post-message is already window-scoped and never blocks system combos)."); - } - IReadOnlyList actions; try { @@ -157,109 +153,193 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + if (transport == KeyTransport.SendInput) + { + var neverBypassable = SystemKeyGuard.FindNeverBypassableCombos(actions); + if (neverBypassable.Count > 0) + { + var names = string.Join(", ", neverBypassable.Select(c => c.Name)); + var reasons = string.Join(" ", neverBypassable.Select(c => $"{c.Name} {c.Reason}.")); + logger.LogError( + "{Symbol} Refusing to synthesize {Combos} via --via send-input. {Reasons} " + + "This stays blocked even with --allow-system-keys, which is for app-registered " + + "global hotkeys (e.g. win+r, win+shift+v), not combos that can't be driven from automation.", + UiSymbols.Error, names, reasons); + UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, + $"Refusing to synthesize {names} via --via send-input. {reasons} " + + "This stays blocked even with --allow-system-keys, which is for app-registered " + + "global hotkeys (e.g. win+r, win+shift+v), not combos that can't be driven from automation.", + errorOut: parseResult.InvocationConfiguration.Error); + return 1; + } + + var systemCombos = SystemKeyGuard.FindSystemCombos(actions); + if (systemCombos.Count > 0 && !allowSystemKeys) + { + logger.LogError( + "{Symbol} Refusing to synthesize system-reserved key(s) via --via send-input: {Combos}. " + + "These act on the OS/shell (e.g. win+l locks the session, alt+f4 closes the window, ctrl+alt+del is intercepted by Windows), not just the target app. " + + "Pass --allow-system-keys to opt in (e.g. to drive a global hotkey).", + UiSymbols.Error, string.Join(", ", systemCombos)); + UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, + $"Refusing to synthesize system-reserved key(s) via --via send-input: {string.Join(", ", systemCombos)}. " + + "These act on the OS/shell rather than just the target app. Pass --allow-system-keys to opt in."); + return 1; + } + } + + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + // Preflight rejected an empty keys argument, so this is non-null by construction. + var keysStr = parseResult.GetValue(KeysArgument)!; + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + var target = parseResult.GetValue(TargetOption); + var viaStr = parseResult.GetValue(ViaOption) ?? "post-message"; + var verbatim = parseResult.GetValue(VerbatimOption); + var allowSystemKeys = parseResult.GetValue(AllowSystemKeysOption); + + // Preflight validated the transport, so this cannot fail here. + _ = TryParseTransport(viaStr, out var transport); + + var warnings = new List(); + if (allowSystemKeys && transport != KeyTransport.SendInput) + { + logger.LogWarning( + "{Symbol} --allow-system-keys only applies to --via send-input and has no effect with " + + "--via post-message (post-message is already window-scoped and never blocks system combos).", + UiSymbols.Warning); + warnings.Add( + "--allow-system-keys only applies to --via send-input and has no effect with " + + "--via post-message (post-message is already window-scoped and never blocks system combos)."); + } + + // Preflight already proved this parses. + IReadOnlyList actions = + verbatim ? KeyStringParser.ParseVerbatim(keysStr) : KeyStringParser.Parse(keysStr); + try { var uiTarget = await targetResolver.ResolveAsync(app, window, cancellationToken); var targetHwnd = uiTarget.WindowHandle; + UiElement? targetElement = null; + UiSelector? targetSelector = null; if (!string.IsNullOrWhiteSpace(target)) { - var selector = selectorParser.Parse(target); - var element = await uiAutomation.FindSingleElementAsync(uiTarget, selector, cancellationToken); + targetSelector = selectorParser.Parse(target); + targetElement = await uiAutomation.FindSingleElementAsync(uiTarget, targetSelector, cancellationToken); - if (element is null) + if (targetElement is null) { UiErrors.ElementNotFound(logger, target, json); return 1; } - await uiAutomation.FocusAsync(uiTarget, element, cancellationToken); - targetHwnd = element.WindowHandle ?? uiTarget.WindowHandle; + targetHwnd = targetElement.WindowHandle ?? uiTarget.WindowHandle; } - // Bring the target window to the foreground so input is routed to it. - if (targetHwnd != 0) - { - Windows.Win32.PInvoke.SetForegroundWindow( - new Windows.Win32.Foundation.HWND((nint)targetHwnd)); - await Task.Delay(100, cancellationToken); - } - - // PostMessage posts to a specific HWND's message queue; a top-level window does NOT - // forward keyboard messages to its focused child control, so posting there silently - // drops the input for classic Win32 child controls (e.g. an edit box) — the resolved - // target is usually the top-level window, not the control. Retarget to the thread's - // actually-focused window (populated now that the target is foreground) so the keys - // reach the control the user sees focused. Falls back to the passed HWND when focus - // can't be resolved. send-input is OS-wide and unaffected, so leave it alone. var effectiveHwnd = targetHwnd; - if (transport == KeyTransport.PostMessage && targetHwnd != 0) + bool targetLooksXaml; + + await using (await turn.EnterAsync(cancellationToken).ConfigureAwait(false)) { - var focused = systemQuery.GetFocusedWindow(targetHwnd); - if (focused != 0 && focused != targetHwnd) + if (targetSelector is not null) + { + targetElement = await uiAutomation.FindSingleElementAsync(uiTarget, targetSelector, cancellationToken); + if (targetElement is null) + { + UiErrors.ElementNotFound(logger, target!, json); + return 1; + } + + targetHwnd = targetElement.WindowHandle ?? uiTarget.WindowHandle; + effectiveHwnd = targetHwnd; + } + + if (!DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, targetHwnd, uiTarget.ProcessId, logger, json, "send-keys", + parseResult.InvocationConfiguration.Error)) + { + return 1; + } + + if (targetHwnd != 0) + { + var topLevel = systemQuery.GetRootWindow(targetHwnd); + desktopForeground.RequestForeground(topLevel != 0 ? topLevel : targetHwnd); + await Task.Delay(100, cancellationToken); + } + + if (transport == KeyTransport.SendInput) { - // GetGUIThreadInfo reports focus for the entire GUI thread, and one thread can - // own several top-level windows. If SetForegroundWindow was denied (focus-stealing - // prevention, a UAC prompt, etc.), the focused HWND may belong to a *different* - // window on that thread — posting there would deliver the keys to the wrong window - // despite an explicit target. Only retarget when the focused HWND shares the - // target's top-level root; otherwise keep the passed target. - var targetRoot = systemQuery.GetRootWindow(targetHwnd); - if (targetRoot != 0 && systemQuery.GetRootWindow(focused) == targetRoot) + if (targetHwnd == 0) { - logger.LogDebug( - "post-message: retargeting from HWND {Target} to focused child HWND {Focused}", - targetHwnd, focused); - effectiveHwnd = focused; + logger.LogError( + "{Symbol} --via send-input needs a resolvable target window, but none was found. Pass --window , ensure -a/--app resolves a window, or use --target to focus an element first.", + UiSymbols.Error); + UiJsonError.Emit(json, UiJsonError.CodeForegroundNotTarget, + "send-input needs a resolvable target window, but none was found — refusing OS-wide keyboard injection without a known target. Pass --window/--app or --target."); + return 1; } - else + + if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "--via send-input")) { - logger.LogDebug( - "post-message: focused HWND {Focused} is not within target {Target}'s top-level window; keeping target", - focused, targetHwnd); + return 1; } } - } - // send-input is OS-wide: it lands on whatever window is actually in the foreground. If - // SetForegroundWindow didn't take (focus-stealing prevention, a UAC prompt, another app - // grabbing focus, or a locked/secure desktop), injecting now would type into the wrong - // window. Verify the foreground belongs to the target before sending. (post-message posts - // straight to the target HWND's queue, so it isn't affected.) - if (transport == KeyTransport.SendInput) - { - // Unlike a coordinate gesture (which targets a screen point), keystrokes have no - // location — without a resolvable target window there is nothing to verify the - // foreground against, so OS-wide injection would type blindly into whatever has - // focus. Refuse rather than send to an unknown window. - if (targetHwnd == 0) + var focusWasApplied = false; + if (targetElement is not null) { - logger.LogError( - "{Symbol} --via send-input needs a resolvable target window, but none was found. Pass --window , ensure -a/--app resolves a window, or use --target to focus an element first.", - UiSymbols.Error); - UiJsonError.Emit(json, UiJsonError.CodeForegroundNotTarget, - "send-input needs a resolvable target window, but none was found — refusing OS-wide keyboard injection without a known target. Pass --window/--app or --target."); - return 1; + await uiAutomation.FocusAsync(uiTarget, targetElement, cancellationToken); + focusWasApplied = true; } - if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "--via send-input")) + if (focusWasApplied + && transport == KeyTransport.SendInput + && !foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "--via send-input")) { return 1; } + + // PostMessage posts to a specific HWND's message queue; a top-level window does NOT + // forward keyboard messages to its focused child control, so posting there silently + // drops the input for classic Win32 child controls (e.g. an edit box). + if (transport == KeyTransport.PostMessage && targetHwnd != 0) + { + var focused = systemQuery.GetFocusedWindow(targetHwnd); + if (focused != 0 && focused != targetHwnd) + { + var targetRoot = systemQuery.GetRootWindow(targetHwnd); + if (targetRoot != 0 && systemQuery.GetRootWindow(focused) == targetRoot) + { + logger.LogDebug( + "post-message: retargeting from HWND {Target} to focused child HWND {Focused}", + targetHwnd, focused); + effectiveHwnd = focused; + } + else + { + logger.LogDebug( + "post-message: focused HWND {Focused} is not within target {Target}'s top-level window; keeping target", + focused, targetHwnd); + } + } + } + + targetLooksXaml = + (targetHwnd != 0 && FrameworkHint.IsXamlClassName(systemQuery.GetWindowClassName(targetHwnd))) + || (effectiveHwnd != 0 && effectiveHwnd != targetHwnd + && FrameworkHint.IsXamlClassName(systemQuery.GetWindowClassName(effectiveHwnd))); + + keyboardInput.Send(effectiveHwnd, actions, transport); } - // WM_CHAR / WM_KEYDOWN posted to a WinUI 3 / UWP / XAML window is not routed to the - // windowless focused control by the XAML input pipeline, so posted keys — typed literal - // text AND named keys/combos (Enter, digits, …) — silently no-op there even though - // PostMessage reports success. Warn, but only when the target actually looks like a XAML - // host, rather than false-alarming on Win32/WPF/Electron apps that do consume posted - // messages. Check both the top-level target and the resolved focused child (either - // looking XAML is enough). Class names are read through ISystemUiQuery so this branch is - // exercisable with a fake. - var targetLooksXaml = - (targetHwnd != 0 && FrameworkHint.IsXamlClassName(systemQuery.GetWindowClassName(targetHwnd))) - || (effectiveHwnd != 0 && effectiveHwnd != targetHwnd - && FrameworkHint.IsXamlClassName(systemQuery.GetWindowClassName(effectiveHwnd))); if (ShouldWarnPostMessageMayNotDeliver(transport == KeyTransport.PostMessage, targetLooksXaml)) { const string postMessageXamlWarning = @@ -270,55 +350,11 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio warnings.Add(postMessageXamlWarning); } - // send-input is OS-wide, so a system-reserved combo (win+l, alt+f4, ctrl+shift+esc, …) - // would act on the OS/shell rather than just the target app (lock the session, close the - // window, open Task Manager). Refuse to synthesize them via send-input — the blast radius - // beyond the target window makes silently sending them too dangerous for an automation run. if (transport == KeyTransport.SendInput) { - // win+l (LockWorkStation) and ctrl+alt+del (SAS) are unconditionally blocked even - // with --allow-system-keys: win+l locks the interactive session with no recovery - // path from automation, and ctrl+alt+del is a Secure Attention Sequence that Windows - // drops from injected input regardless of the flag — reporting success for it would - // be misleading. Each carries its own reason so the message explains why. Return - // early so they don't fall through into the soft-combo / allow path below. - var neverBypassable = SystemKeyGuard.FindNeverBypassableCombos(actions); - if (neverBypassable.Count > 0) - { - var names = string.Join(", ", neverBypassable.Select(c => c.Name)); - var reasons = string.Join(" ", neverBypassable.Select(c => $"{c.Name} {c.Reason}.")); - logger.LogError( - "{Symbol} Refusing to synthesize {Combos} via --via send-input. {Reasons} " + - "This stays blocked even with --allow-system-keys, which is for app-registered " + - "global hotkeys (e.g. win+r, win+shift+v), not combos that can't be driven from automation.", - UiSymbols.Error, names, reasons); - UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, - $"Refusing to synthesize {names} via --via send-input. {reasons} " + - "This stays blocked even with --allow-system-keys, which is for app-registered " + - "global hotkeys (e.g. win+r, win+shift+v), not combos that can't be driven from automation.", - errorOut: parseResult.InvocationConfiguration.Error); - return 1; - } - var systemCombos = SystemKeyGuard.FindSystemCombos(actions); if (systemCombos.Count > 0) { - if (!allowSystemKeys) - { - logger.LogError( - "{Symbol} Refusing to synthesize system-reserved key(s) via --via send-input: {Combos}. " + - "These act on the OS/shell (e.g. win+l locks the session, alt+f4 closes the window, ctrl+alt+del is intercepted by Windows), not just the target app. " + - "Pass --allow-system-keys to opt in (e.g. to drive a global hotkey).", - UiSymbols.Error, string.Join(", ", systemCombos)); - UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, - $"Refusing to synthesize system-reserved key(s) via --via send-input: {string.Join(", ", systemCombos)}. " + - "These act on the OS/shell rather than just the target app. Pass --allow-system-keys to opt in."); - return 1; - } - - // Caller explicitly opted in with --allow-system-keys (e.g. to fire a global hotkey such as - // PowerToys' win+shift+v). Record the bypass so it's auditable in persisted logs, then fall - // through and inject. (win+l and ctrl+alt+del never reach here — they're hard-blocked above.) var systemCombosStr = string.Join(", ", systemCombos); logger.LogWarning( "{Symbol} Injecting system-reserved key(s) via --via send-input because --allow-system-keys was set: {Combos}. " + @@ -327,16 +363,8 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio warnings.Add( $"Injecting system-reserved key(s) via --via send-input because --allow-system-keys was set: {systemCombosStr}. " + "These act on the OS/shell beyond the target app."); - } - } - - // Long literal text via send-input is auto-throttled into paced chunks (issue #657) so the - // target's input queue never overruns and no characters are silently dropped. That pacing - // adds a little wall-clock time for big payloads, so let the caller know the throttling is - // intentional — and that 'ui set-value' lands bulk text in one shot — when the payload is - // large enough to be chunked (more than one chunk's worth of characters). - if (transport == KeyTransport.SendInput) - { + } + int textChars = actions.OfType().Sum(t => t.Text.Length); if (textChars > KeyboardInput.DefaultTextChunkChars) { @@ -346,9 +374,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio warnings.Add( $"{textChars} characters via send-input are auto-throttled into paced chunks for reliable delivery, so this may take a moment. For bulk text, 'ui set-value' is faster and more reliable."); } - } - - keyboardInput.Send(effectiveHwnd, actions, transport); + } if (json) { @@ -400,7 +426,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiSetValueCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiSetValueCommand.cs index a38fa3dbc..9c55f9c9c 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiSetValueCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiSetValueCommand.cs @@ -120,7 +120,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiStatusCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiStatusCommand.cs index af923ecdc..ad6a5c365 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiStatusCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiStatusCommand.cs @@ -92,7 +92,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn } return 0; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiTouchCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiTouchCommand.cs index 16f9da26c..199d0a55f 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiTouchCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiTouchCommand.cs @@ -10,6 +10,7 @@ using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Commands; @@ -103,10 +104,18 @@ public class Handler( IUiSelectorParser selectorParser, IPointerInput pointerInput, IForegroundGuard foregroundGuard, + IDesktopForegroundService desktopForeground, + ISystemUiQuery systemQuery, IAnsiConsole ansiConsole, - ILogger logger) : AsynchronousCommandLineAction + IInteractiveDesktopLock desktopLock, + ILogger logger) : UiCoordinatedAction(desktopLock, logger) { - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + protected override string Operation => "ui touch"; + + /// Synthetic touch injection is OS-wide and lands wherever the desktop points. + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.DesktopExclusive; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); @@ -148,12 +157,12 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio if (holdMs > MaxDelayMs) { - return RejectInvalidArguments($"--hold-ms must be {MaxDelayMs} ms or less (60 seconds). Got '{holdMs}'."); + return RejectInvalidArguments(parseResult, json, $"--hold-ms must be {MaxDelayMs} ms or less (60 seconds). Got '{holdMs}'."); } if (durationMs > MaxDelayMs) { - return RejectInvalidArguments($"--duration-ms must be {MaxDelayMs} ms or less (60 seconds). Got '{durationMs}'."); + return RejectInvalidArguments(parseResult, json, $"--duration-ms must be {MaxDelayMs} ms or less (60 seconds). Got '{durationMs}'."); } // Validate --direction value up front. @@ -171,22 +180,22 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio if (toPointWasSupplied && !isSwipe) { - return RejectInvalidArguments($"--to-point is only valid with --gesture swipe (got {gestureStr})."); + return RejectInvalidArguments(parseResult, json, $"--to-point is only valid with --gesture swipe (got {gestureStr})."); } if (directionWasSupplied && !isSwipe) { - return RejectInvalidArguments($"--direction is only valid with --gesture swipe (got {gestureStr})."); + return RejectInvalidArguments(parseResult, json, $"--direction is only valid with --gesture swipe (got {gestureStr})."); } if (distanceWasSupplied && isStationaryGesture) { - return RejectInvalidArguments($"--distance is only valid with --gesture swipe, pinch, or stretch (got {gestureStr})."); + return RejectInvalidArguments(parseResult, json, $"--distance is only valid with --gesture swipe, pinch, or stretch (got {gestureStr})."); } if (durationWasSupplied && isStationaryGesture) { - return RejectInvalidArguments($"--duration-ms is only valid with moving gestures: swipe, pinch, or stretch (got {gestureStr})."); + return RejectInvalidArguments(parseResult, json, $"--duration-ms is only valid with moving gestures: swipe, pinch, or stretch (got {gestureStr})."); } // Long-press with no explicit --hold-ms defaults to 500 ms (a real long-press). @@ -220,7 +229,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio if (fingersWasSupplied && gesture is TouchGesture.Pinch or TouchGesture.Stretch && fingers != 2) { - return RejectInvalidArguments($"--fingers must be 2 with --gesture {gestureStr}; pinch/stretch always use 2 contacts."); + return RejectInvalidArguments(parseResult, json, $"--fingers must be 2 with --gesture {gestureStr}; pinch/stretch always use 2 contacts."); } // Parse an explicit start point up front (mutually independent of selector resolution). @@ -277,45 +286,100 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } + return null; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); + var app = parseResult.GetValue(SharedUiOptions.AppOption); + var window = parseResult.GetValue(SharedUiOptions.WindowOption); + var gestureStr = parseResult.GetValue(GestureOption) ?? "tap"; + var atStr = parseResult.GetValue(AtOption); + var toStr = parseResult.GetValue(ToPointOption); + var distance = parseResult.GetValue(DistanceOption); + var direction = parseResult.GetValue(DirectionOption); + var holdMs = parseResult.GetValue(HoldOption); + var durationMs = parseResult.GetValue(DurationOption); + var fingers = parseResult.GetValue(FingersOption); + + _ = Gestures.TryGetValue(gestureStr, out var gesture); + + if (gesture is TouchGesture.LongPress + && (parseResult.GetResult(HoldOption)?.Tokens.Count ?? 0) == 0) + { + holdMs = 500; + } + + PointerPoint? at = null; + if (!string.IsNullOrWhiteSpace(atStr)) + { + _ = PointerGesturePlanner.TryParsePoint(atStr, out var atPoint); + at = atPoint; + } + + PointerPoint? to = null; + if (!string.IsNullOrWhiteSpace(toStr)) + { + _ = PointerGesturePlanner.TryParsePoint(toStr, out var toPoint); + to = toPoint; + } + try { var uiTarget = await targetResolver.ResolveAsync(app, window, cancellationToken); - var target = await PointerCommandSupport.ResolvePointAsync( - uiAutomation, selectorParser, uiTarget, selectorStr, at, atStr, - "touch", "touch point", logger, json, cancellationToken); - if (!target.Ok) + long targetHwnd; + PointerPoint start; + string? targetLabel; + IReadOnlyList> contactPaths; + IReadOnlyList points; + int effectiveFingers; + PointerCommandSupport.InjectionPreparation prep; + + await using (await turn.EnterAsync(cancellationToken).ConfigureAwait(false)) { - return 1; - } + var target = await PointerCommandSupport.ResolvePointAsync( + uiAutomation, selectorParser, uiTarget, selectorStr, at, atStr, + "touch", "touch point", logger, json, cancellationToken); + if (!target.Ok) + { + return 1; + } - var targetHwnd = target.TargetHwnd; - var start = target.Point; - var targetLabel = target.TargetLabel; + targetHwnd = target.TargetHwnd; + start = target.Point; + targetLabel = target.TargetLabel; - if (at is not null) - { - await PointerCommandSupport.SetForegroundAsync(targetHwnd, cancellationToken); - } + if (!DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, targetHwnd, uiTarget.ProcessId, logger, json, "touch", + parseResult.InvocationConfiguration.Error)) + { + return 1; + } - var (contactPaths, points, effectiveFingers) = - PointerGesturePlanner.PlanTouch(gesture, start, to, distance, fingers, direction); + await PointerCommandSupport.SetForegroundAsync(desktopForeground, targetHwnd, cancellationToken); - var prep = PointerCommandSupport.TryPrepareInjection( - uiAutomation, foregroundGuard, targetHwnd, points, "touch", "touch", logger, json); - if (!prep.Ok) - { - return 1; - } + (contactPaths, points, effectiveFingers) = + PointerGesturePlanner.PlanTouch(gesture, start, to, distance, fingers, direction); - // M8: narrow the injection_unsupported catch to only the actual injection call so that - // pre-injection failures (element not found, etc.) are NOT mis-classified as - // injection_unsupported. Session resolution failures surface as missing_app (outer catch). - if (!PointerCommandSupport.TryInject( - () => pointerInput.Touch(gesture, contactPaths, holdMs, durationMs), - logger, json, parseResult.InvocationConfiguration.Error)) - { - return 1; + prep = PointerCommandSupport.TryPrepareInjection( + uiAutomation, foregroundGuard, targetHwnd, points, "touch", "touch", logger, json); + if (!prep.Ok) + { + return 1; + } + + // M8: narrow the injection_unsupported catch to only the actual injection call so that + // pre-injection failures (element not found, etc.) are NOT mis-classified as + // injection_unsupported. Session resolution failures surface as missing_app (outer catch). + if (!PointerCommandSupport.TryInject( + () => pointerInput.Touch(gesture, contactPaths, holdMs, durationMs), + logger, json, parseResult.InvocationConfiguration.Error)) + { + return 1; + } } // id27/id28: synthetic touch injection can report success without actually reaching the @@ -390,19 +454,20 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio errorOut: parseResult.InvocationConfiguration.Error); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json, parseResult.InvocationConfiguration.Error); return 1; } - int RejectInvalidArguments(string message) - { - logger.LogError("{Symbol} {Message}", UiSymbols.Error, message); - UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, message, - errorOut: parseResult.InvocationConfiguration.Error); - return 1; - } + } + + private int RejectInvalidArguments(ParseResult parseResult, bool json, string message) + { + logger.LogError("{Symbol} {Message}", UiSymbols.Error, message); + UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, message, + errorOut: parseResult.InvocationConfiguration.Error); + return 1; } } } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiWaitForCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiWaitForCommand.cs index b5c81dbf8..9bdb767d6 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiWaitForCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiWaitForCommand.cs @@ -250,7 +250,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn UiErrors.StaleElement(logger, json); return 1; } - catch (Exception ex) + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { UiErrors.GenericError(logger, ex, json); return 1; diff --git a/src/winapp-CLI/WinApp.Cli/GlobalUsings.cs b/src/winapp-CLI/WinApp.Cli/GlobalUsings.cs index dbc4c160e..fb5475427 100644 --- a/src/winapp-CLI/WinApp.Cli/GlobalUsings.cs +++ b/src/winapp-CLI/WinApp.Cli/GlobalUsings.cs @@ -6,4 +6,4 @@ // repeated in every command and helper file. global using Microsoft.Windows.SDK.BuildTools.WinApp.UIAutomation; global using Microsoft.Windows.SDK.BuildTools.WinApp.UIAutomation.Recording; - +global using WinApp.Cli.Services.InteractiveDesktop; diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs index 224416618..4aa046eb4 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs @@ -9,6 +9,7 @@ using WinApp.Cli.Commands; using WinApp.Cli.Services; using WinApp.Cli.Services.Controls; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Helpers; @@ -65,6 +66,14 @@ public static IServiceCollection ConfigureServices(this IServiceCollection servi // UI Automation services (from the Microsoft.Windows.SDK.BuildTools.WinApp.UIAutomation package) .AddWinAppUiAutomation() .AddWinAppUiRecording() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton(); } diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/PointerCommandSupport.cs b/src/winapp-CLI/WinApp.Cli/Helpers/PointerCommandSupport.cs index 356f3ffe7..7b8ed08e1 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/PointerCommandSupport.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/PointerCommandSupport.cs @@ -1,150 +1,145 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.Extensions.Logging; -using WinApp.Cli.Models; -using WinApp.Cli.Services; - -namespace WinApp.Cli.Helpers; - -internal static class PointerCommandSupport -{ - public readonly record struct ResolvedPoint(bool Ok, PointerPoint Point, long TargetHwnd, string? TargetLabel); - - public static async Task ResolvePointAsync( - IUiAutomation uiAutomation, - IUiSelectorParser selectorParser, - UiTarget uiTarget, - string? selectorStr, - PointerPoint? explicitPoint, - string? explicitLabel, - string action, - string pointKind, - ILogger logger, - bool json, - CancellationToken cancellationToken) - { - if (explicitPoint is not null) - { - return new ResolvedPoint(true, explicitPoint.Value, uiTarget.WindowHandle, explicitLabel); - } - - var selector = selectorParser.Parse(selectorStr!); - var element = await uiAutomation.FindSingleElementAsync(uiTarget, selector, cancellationToken); - if (element is null) - { - UiErrors.ElementNotFound(logger, selectorStr!, json); - return default; - } - - if (element.Width == 0 || element.Height == 0) - { - logger.LogError("{Symbol} Element has zero size — cannot use its center as a {PointKind}.", - UiSymbols.Error, pointKind); - UiJsonError.Emit(json, UiJsonError.CodeZeroSize, - $"Element has zero size — cannot use its center as a {pointKind}.", selectorStr); - return default; - } - - long targetHwnd = element.WindowHandle ?? uiTarget.WindowHandle; - - if (targetHwnd != 0) - { - Windows.Win32.PInvoke.SetForegroundWindow(new Windows.Win32.Foundation.HWND((nint)targetHwnd)); - await Task.Delay(100, cancellationToken); - } - - var stable = await GestureTargeting.ResolveStableAsync( - uiAutomation, uiTarget, selector, element, - GestureTargeting.DefaultMaxReads, GestureTargeting.DefaultReadDelayMs, null, cancellationToken); - if (!UiInjectionReporting.TryReport(stable, logger, json, selectorStr!, action)) - { - return default; - } - - return new ResolvedPoint(true, new PointerPoint(stable.CenterX, stable.CenterY), targetHwnd, selectorStr); - } - - public static async Task SetForegroundAsync(long targetHwnd, CancellationToken cancellationToken) - { - if (targetHwnd != 0) - { - Windows.Win32.PInvoke.SetForegroundWindow(new Windows.Win32.Foundation.HWND((nint)targetHwnd)); - await Task.Delay(100, cancellationToken); - } - } - - /// - /// Outcome of . is false when a hard - /// pre-injection gate failed (no target window, unreadable window rect, or foreground could not be - /// secured) and the caller should return non-zero. is a - /// non-fatal advisory (issue #661): injection proceeds, but the caller should surface this string to - /// the user (stdout for text output — routes non-error levels to - /// stdout — and warnings[] for --json). - /// - public readonly record struct InjectionPreparation(bool Ok, string? OutOfWindowWarning); - - public static InjectionPreparation TryPrepareInjection( - IUiAutomation uiAutomation, - IForegroundGuard foregroundGuard, - long targetHwnd, - IEnumerable points, - string action, - string inputNoun, - ILogger logger, - bool json) - { - if (targetHwnd == 0) - { - logger.LogError("{Symbol} No target window could be resolved — refusing to inject {InputNoun} (it could hit the wrong window).", - UiSymbols.Error, inputNoun); - UiJsonError.Emit(json, UiJsonError.CodeNoTarget, - $"No target window could be resolved — refusing to inject {inputNoun}. Target an app window (via --app/--window) whose element resolves to a window handle."); - return new InjectionPreparation(false, null); - } - - if (!uiAutomation.TryGetWindowRect(targetHwnd, out var windowRect)) - { - logger.LogError("{Symbol} Could not read the target window rectangle — refusing to inject {InputNoun}.", - UiSymbols.Error, inputNoun); - UiJsonError.Emit(json, UiJsonError.CodeNoTarget, - $"Could not read the target window rectangle — refusing to inject {inputNoun}."); - return new InjectionPreparation(false, null); - } - - // #661: a point outside the target window is a non-fatal advisory, not a hard failure. - // The mouse verbs (click/drag/hover/scroll) already inject at out-of-window coordinates, so - // touch/pen warn and inject too. Emit nothing here — the caller decides text-vs-json (mirrors - // the RemoteInjectionWarning discipline). - string? outOfWindowWarning = null; - var outOfBounds = PointerGesturePlanner.FirstOutOfBounds(windowRect, points); - if (outOfBounds is not null) - { - outOfWindowWarning = - $"Point ({outOfBounds.Value.X},{outOfBounds.Value.Y}) is outside the target window " + - $"({windowRect.Left},{windowRect.Top})-({windowRect.Right},{windowRect.Bottom}) — injecting anyway."; - } - - return foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, action) - ? new InjectionPreparation(true, outOfWindowWarning) - : new InjectionPreparation(false, null); - } - - public static bool TryInject(Action inject, ILogger logger, bool json, TextWriter? errorOut) - { - try - { - inject(); - return true; - } - catch (InvalidOperationException injectEx) - { - logger.LogError("{Symbol} {Message}", UiSymbols.Error, injectEx.Message); - UiJsonError.Emit(json, UiJsonError.CodeInjectionUnsupported, injectEx.Message, errorOut: errorOut); - return false; - } - } - - public static string? RemoteInjectionWarning(IForegroundGuard foregroundGuard, string inputKind) - => ForegroundGuard.RemoteInjectionWarning(foregroundGuard.IsRemoteSession(), inputKind); -} +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using WinApp.Cli.Models; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Helpers; + +internal static class PointerCommandSupport +{ + public readonly record struct ResolvedPoint(bool Ok, PointerPoint Point, long TargetHwnd, string? TargetLabel); + + public static async Task ResolvePointAsync( + IUiAutomation uiAutomation, + IUiSelectorParser selectorParser, + UiTarget uiTarget, + string? selectorStr, + PointerPoint? explicitPoint, + string? explicitLabel, + string action, + string pointKind, + ILogger logger, + bool json, + CancellationToken cancellationToken) + { + if (explicitPoint is not null) + { + return new ResolvedPoint(true, explicitPoint.Value, uiTarget.WindowHandle, explicitLabel); + } + + var selector = selectorParser.Parse(selectorStr!); + var element = await uiAutomation.FindSingleElementAsync(uiTarget, selector, cancellationToken); + if (element is null) + { + UiErrors.ElementNotFound(logger, selectorStr!, json); + return default; + } + + if (element.Width == 0 || element.Height == 0) + { + logger.LogError("{Symbol} Element has zero size — cannot use its center as a {PointKind}.", + UiSymbols.Error, pointKind); + UiJsonError.Emit(json, UiJsonError.CodeZeroSize, + $"Element has zero size — cannot use its center as a {pointKind}.", selectorStr); + return default; + } + + long targetHwnd = element.WindowHandle ?? uiTarget.WindowHandle; + + var stable = await GestureTargeting.ResolveStableAsync( + uiAutomation, uiTarget, selector, element, + GestureTargeting.DefaultMaxReads, GestureTargeting.DefaultReadDelayMs, null, cancellationToken); + if (!UiInjectionReporting.TryReport(stable, logger, json, selectorStr!, action)) + { + return default; + } + + return new ResolvedPoint(true, new PointerPoint(stable.CenterX, stable.CenterY), targetHwnd, selectorStr); + } + + public static async Task SetForegroundAsync( + IDesktopForegroundService desktopForeground, long targetHwnd, CancellationToken cancellationToken) + { + if (targetHwnd != 0) + { + desktopForeground.RequestForeground(targetHwnd); + await Task.Delay(100, cancellationToken); + } + } + + /// + /// Outcome of . is false when a hard + /// pre-injection gate failed (no target window, unreadable window rect, or foreground could not be + /// secured) and the caller should return non-zero. is a + /// non-fatal advisory (issue #661): injection proceeds, but the caller should surface this string to + /// the user (stdout for text output — routes non-error levels to + /// stdout — and warnings[] for --json). + /// + public readonly record struct InjectionPreparation(bool Ok, string? OutOfWindowWarning); + + public static InjectionPreparation TryPrepareInjection( + IUiAutomation uiAutomation, + IForegroundGuard foregroundGuard, + long targetHwnd, + IEnumerable points, + string action, + string inputNoun, + ILogger logger, + bool json) + { + if (targetHwnd == 0) + { + logger.LogError("{Symbol} No target window could be resolved — refusing to inject {InputNoun} (it could hit the wrong window).", + UiSymbols.Error, inputNoun); + UiJsonError.Emit(json, UiJsonError.CodeNoTarget, + $"No target window could be resolved — refusing to inject {inputNoun}. Target an app window (via --app/--window) whose element resolves to a window handle."); + return new InjectionPreparation(false, null); + } + + if (!uiAutomation.TryGetWindowRect(targetHwnd, out var windowRect)) + { + logger.LogError("{Symbol} Could not read the target window rectangle — refusing to inject {InputNoun}.", + UiSymbols.Error, inputNoun); + UiJsonError.Emit(json, UiJsonError.CodeNoTarget, + $"Could not read the target window rectangle — refusing to inject {inputNoun}."); + return new InjectionPreparation(false, null); + } + + // #661: a point outside the target window is a non-fatal advisory, not a hard failure. + // The mouse verbs (click/drag/hover/scroll) already inject at out-of-window coordinates, so + // touch/pen warn and inject too. Emit nothing here — the caller decides text-vs-json (mirrors + // the RemoteInjectionWarning discipline). + string? outOfWindowWarning = null; + var outOfBounds = PointerGesturePlanner.FirstOutOfBounds(windowRect, points); + if (outOfBounds is not null) + { + outOfWindowWarning = + $"Point ({outOfBounds.Value.X},{outOfBounds.Value.Y}) is outside the target window " + + $"({windowRect.Left},{windowRect.Top})-({windowRect.Right},{windowRect.Bottom}) — injecting anyway."; + } + + return foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, action) + ? new InjectionPreparation(true, outOfWindowWarning) + : new InjectionPreparation(false, null); + } + + public static bool TryInject(Action inject, ILogger logger, bool json, TextWriter? errorOut) + { + try + { + inject(); + return true; + } + catch (InvalidOperationException injectEx) + { + logger.LogError("{Symbol} {Message}", UiSymbols.Error, injectEx.Message); + UiJsonError.Emit(json, UiJsonError.CodeInjectionUnsupported, injectEx.Message, errorOut: errorOut); + return false; + } + } + + public static string? RemoteInjectionWarning(IForegroundGuard foregroundGuard, string inputKind) + => ForegroundGuard.RemoteInjectionWarning(foregroundGuard.IsRemoteSession(), inputKind); +} diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/UiCoordinatedAction.cs b/src/winapp-CLI/WinApp.Cli/Helpers/UiCoordinatedAction.cs index c709a6ee3..e49526ec4 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/UiCoordinatedAction.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/UiCoordinatedAction.cs @@ -52,8 +52,8 @@ internal abstract class UiCoordinatedAction(IInteractiveDesktopLock coordinator, /// Whether belongs to coordination and must escape a handler's catch-all. /// /// - /// Handler bodies call into coordination — and - /// — from inside their broad + /// Handler bodies call into coordination — — from inside + /// their broad /// catch (Exception). Letting that catch win would be doubly wrong: the user would see /// internal_error instead of cancelled or the real coordination code, and the /// coordinator would see a normal body completion and renew the owner's idle grace on a command diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs index 28a23aee8..3bf5b851d 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs @@ -40,19 +40,11 @@ internal interface IDesktopSection /// The workflow turn a coordinated command is executing under. internal interface IUiTurn : IDesktopSection { - /// The mode this command was admitted with, after any escalation. + /// The mode this command was admitted with. UiTurnMode Mode { get; } /// Milliseconds spent queued before execution began. Zero when the turn was free. long WaitedMs { get; } - - /// - /// Converts an in-flight command into a - /// one and waits for the barrier (spec §6.5). Used by - /// ui screenshot when a target turns out to need restore or foreground: the invocation - /// discards its buffered captures, escalates as a whole, and recaptures from the beginning. - /// - Task EscalateToDesktopExclusiveAsync(CancellationToken cancellationToken); } /// diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs index 78688066f..2f117e573 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs @@ -502,56 +502,6 @@ public async Task EnterAsync(CancellationToken cancellationTok return new SectionScope(this); } - public async Task EscalateToDesktopExclusiveAsync(CancellationToken cancellationToken) - { - if (Mode == UiTurnMode.DesktopExclusive) - { - return; - } - - using (var stateLock = coordinator._store.AcquireStateLock(cancellationToken)) - { - var read = coordinator._store.Read(); - if (read.UnknownNewerVersion) - { - throw new UiCoordinationException( - UiCoordinationErrorCodes.Unavailable, - "UI turn coordination state was written by a newer version of winapp, so this screenshot cannot escalate safely.", - "Update winapp so every process on this desktop uses a compatible version, then retry."); - } - - var state = read.State!; - - if (_lease is not null - && coordinator._scheduler.EscalateObserveToExclusive(state, _probe, participant)) - { - // Spec §6.5: the same lease and the same entry are reused, so no intermediate state - // is ever published in which this process has no command. - _ticket = InteractiveDesktopScheduler.FindOwnerCommand(state, participant)?.Ticket; - } - else - { - // A detached non-owner observation registers a brand-new DesktopExclusive command. - _lease ??= coordinator._participants.OpenLease( - participant.ProcessId, participant.StartTicksUtc); - var admission = coordinator._scheduler.BeginParticipating( - state, _probe, owner, participant, UiTurnMode.DesktopExclusive); - _ticket = admission.Ticket; - _turnAction = admission.TurnAction; - _turnStartedTick64 = admission.Admission == UiAdmission.GlobalWaiter - ? null - : TurnStartTick(state); - _detached = false; - } - - coordinator._store.Publish(state); - } - - Mode = UiTurnMode.DesktopExclusive; - _waitWatch.Restart(); - await WaitUntilRunnableAsync(cancellationToken).ConfigureAwait(false); - } - private async Task ReleaseAllSectionsAsync() { // Safety net for a body that returned or threw without disposing its scope. Releasing the diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs index c70318b59..f140dd0e6 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs @@ -212,34 +212,6 @@ public UiAdmissionResult BeginParticipating( QueuePositionOf(state, probe, ticket)); } - /// - /// Section 6.5: converts this process's existing entry into a - /// command in place — same lease, new arrival ticket, - /// status — so a screenshot that discovers it must restore or - /// foreground a target never publishes an intermediate state with no entry for itself. - /// - /// when an entry was converted. - public bool EscalateObserveToExclusive( - InteractiveDesktopState state, - ICoordinationLivenessProbe probe, - UiParticipantIdentity participant) - { - Normalize(state, probe); - - var entry = FindOwnerCommand(state, participant); - if (entry is null || entry.Mode != UiTurnMode.Observe) - { - return false; - } - - // Priority starts at escalation time — the observational pass earns no head start. - entry.Ticket = state.AllocateTicket(); - entry.Mode = UiTurnMode.DesktopExclusive; - entry.Status = UiCommandStatus.Waiting; - ApplyOwnerLocalEligibility(state); - return true; - } - /// /// Section 10.6: removes this process's command and sets the idle deadline. A non-cancelled /// completion renews the grace; an anonymous owner gets none and hands off immediately; diff --git a/src/winapp-CLI/WinApp.UIAutomation.Recording/UiRecordingService.cs b/src/winapp-CLI/WinApp.UIAutomation.Recording/UiRecordingService.cs index 1863e1512..25a800eda 100644 --- a/src/winapp-CLI/WinApp.UIAutomation.Recording/UiRecordingService.cs +++ b/src/winapp-CLI/WinApp.UIAutomation.Recording/UiRecordingService.cs @@ -96,6 +96,18 @@ public async Task RecordAsync(UiTarget uiTarget, string? el { global::Windows.Win32.PInvoke.SetForegroundWindow(hwnd); await Task.Delay(150, ct).ConfigureAwait(false); + + // SetForegroundWindow is advisory. If it was refused, every screen-DC frame would record + // whichever window is really in front and the caller would get a perfectly playable MP4 of + // the wrong app. Verify after the activation delay and before any frame is captured. Capture + // safety, not coordination: it says nothing about who else may be driving the desktop. + if (!ForegroundGuard.ForegroundBelongsTo((long)rootHwnd)) + { + throw new ForegroundLostException( + "The target window is not in the foreground, so a screen recording would capture " + + "whatever window is actually in front. Bring the window to the foreground and retry, " + + "or record the window directly instead of the screen."); + } } global::Windows.Win32.PInvoke.GetWindowRect(hwnd, out var rect); diff --git a/src/winapp-CLI/WinApp.UIAutomation/Input/ForegroundLostException.cs b/src/winapp-CLI/WinApp.UIAutomation/Input/ForegroundLostException.cs index b92b84ad1..8270f524c 100644 --- a/src/winapp-CLI/WinApp.UIAutomation/Input/ForegroundLostException.cs +++ b/src/winapp-CLI/WinApp.UIAutomation/Input/ForegroundLostException.cs @@ -4,13 +4,28 @@ namespace Microsoft.Windows.SDK.BuildTools.WinApp.UIAutomation; /// -/// Thrown by when the foreground window drifts away from the injection target -/// partway through a throttled SendInput sequence (issue #657 follow-up H1). A long payload is paced over -/// many SendInput calls spanning seconds; SendInput is OS-wide, so continuing after focus leaves the target -/// would type the remaining keystrokes into whatever window grabbed focus. The send-keys command maps this -/// to the foreground_not_target error — the same contract as the pre-send foreground check — rather -/// than a generic failure. +/// Thrown when the foreground window is not the intended target at a moment when acting anyway would +/// affect the wrong window. /// +/// +/// +/// Raised by when the foreground drifts away from the injection target +/// partway through a throttled SendInput sequence (issue #657 follow-up H1). A long payload is paced +/// over many SendInput calls spanning seconds; SendInput is OS-wide, so continuing after focus leaves +/// the target would type the remaining keystrokes into whatever window grabbed focus. +/// +/// +/// Also raised before a screen-DC capture. SetForegroundWindow is advisory — Windows refuses it +/// under focus-stealing prevention, a UAC prompt, a locked session, or when another app activates +/// itself in the same instant — and a screen capture reads whatever is genuinely in front. Without this +/// check the caller receives a perfectly valid-looking image of an unrelated window and has no way to +/// tell. +/// +/// +/// The send-keys and capture commands map this to the foreground_not_target error — the +/// same contract as the pre-send foreground check — rather than a generic failure. +/// +/// public sealed class ForegroundLostException : InvalidOperationException { /// Creates an exception with a message describing how foreground ownership was lost. diff --git a/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.Screenshot.cs b/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.Screenshot.cs index 47ec0cb50..1d3493d8a 100644 --- a/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.Screenshot.cs +++ b/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.Screenshot.cs @@ -92,6 +92,19 @@ private static void ForegroundWindowForBlankRetry(global::Windows.Win32.Foundati if (captureScreen) { + // SetForegroundWindow above is advisory. If it was refused, a screen-DC BitBlt reads whatever + // window is really in front and returns a picture of the wrong app while reporting success — + // worse than failing, because the caller cannot tell. Verify after the activation delay and + // immediately before the capture. This is capture safety, not coordination: it says nothing + // about who else may be driving the desktop, only that these pixels would be the wrong ones. + if (!ForegroundGuard.ForegroundBelongsTo((long)(nint)hwnd)) + { + throw new ForegroundLostException( + "The target window is not in the foreground, so a screen capture would record whatever " + + "window is actually in front. Bring the window to the foreground and retry, or capture " + + "the window directly instead of the screen."); + } + // Screen capture mode: BitBlt from screen DC — captures popups and overlays. pixelData = CaptureFromScreen(rect.left, rect.top, width, height); } From 553ad0a8c8490c0f033ac110f15ee518158079df Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:11:50 -0700 Subject: [PATCH 11/29] Add opt-in workflowId to the npm surface CommonOptions gains workflowId, applied to the spawned child's environment only. The wrapper never touches process.env: mutating it would silently enrol every later call in the process - including unrelated ones - into a workflow the caller meant for a single command, and would race across concurrent calls. Two tests pin that, one for an explicit id and one for an id inherited from the environment. Arbitration does not depend on this. Every desktop-sensitive winapp ui command takes a turn whether or not an id is set; what the id buys is continuity between invocations - a shared idle grace, permission to overlap (a recording and the clicks it records), and never being interleaved with another workflow's input. Documented that way in the README rather than as a switch that turns coordination on. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/npm-usage.md | 131 +++++++++++++------ src/winapp-npm/README.md | 30 ++++- src/winapp-npm/scripts/generate-commands.mjs | 13 ++ src/winapp-npm/scripts/generate-docs.mjs | 4 +- src/winapp-npm/src/winapp-cli-utils.ts | 55 ++++++-- src/winapp-npm/src/winapp-commands.ts | 15 ++- src/winapp-npm/test/workflow-id.test.ts | 47 +++++++ 7 files changed, 234 insertions(+), 61 deletions(-) create mode 100644 src/winapp-npm/test/workflow-id.test.ts diff --git a/docs/npm-usage.md b/docs/npm-usage.md index 84243eaea..f466b6f0f 100644 --- a/docs/npm-usage.md +++ b/docs/npm-usage.md @@ -45,6 +45,7 @@ Base options shared by most commands. | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `WinappResult` @@ -58,7 +59,7 @@ Result returned by every command wrapper. ## CLI command wrappers -These functions wrap native `winapp` CLI commands. All accept [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`). +These functions wrap native `winapp` CLI commands. All accept [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`). ### `azSign()` @@ -79,7 +80,7 @@ function azSign(options: AzSignOptions): Promise | `resourceGroup` | `string \| undefined` | No | Resource group to narrow down signing accounts | | `subscription` | `string \| undefined` | No | Azure subscription ID to use. If not provided and multiple subscriptions exist, you will be prompted. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -105,7 +106,7 @@ function certGenerate(options?: CertGenerateOptions): Promise | `publisher` | `string \| undefined` | No | Publisher distinguished name (DN) for the generated certificate (e.g., CN=MyCompany or OU=Team, O=Corp, C=US). If not specified, will be inferred from manifest. Bare names are auto-wrapped as CN=. | | `validDays` | `number \| undefined` | No | Number of days the certificate is valid | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -125,7 +126,7 @@ function certInfo(options: CertInfoOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `password` | `string \| undefined` | No | Password for the PFX file | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -145,7 +146,7 @@ function certInstall(options: CertInstallOptions): Promise | `force` | `boolean \| undefined` | No | Force installation even if the certificate already exists | | `password` | `string \| undefined` | No | Password for the PFX file | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -166,7 +167,7 @@ function createDebugIdentity(options?: CreateDebugIdentityOptions): Promise | `target` | `string` | Yes | Path to the .exe (embeds identity into its side-by-side manifest via mt.exe) or an .xml/.manifest side-by-side manifest file (inserts/replaces the element; created if it doesn't exist). | | `manifest` | `string \| undefined` | No | Path to the sparse appxmanifest.xml to read identity from. When omitted, searched in a 'sparse/' folder (where 'winapp init --exe --sparse' writes it by default) beside the target first, then in the current directory, then beside the target and in the current directory. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -232,7 +233,7 @@ function findUi(options?: FindUiOptions): Promise | `refresh` | `boolean \| undefined` | No | Bypass the local cache and re-fetch the WinUI corpus from GitHub. | | `source` | `string \| undefined` | No | Restrict results to a single source: gallery (WinUI 3 Gallery), toolkit (Windows Community Toolkit), reactor (microsoft-ui-reactor, C#-only declarative WinUI), or core (curated patterns). Reactor is opt-in — it is excluded from a normal search, so pass --source reactor to search it (only do this for a Reactor/MVU project; its C#-only samples don't paste into a standard XAML app). | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -250,7 +251,7 @@ function getWinappPath(options?: GetWinappPathOptions): Promise |----------|------|----------|-------------| | `global` | `boolean \| undefined` | No | Get the global .winapp directory instead of local | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -280,7 +281,7 @@ function init(options?: InitOptions): Promise | `sparse` | `boolean \| undefined` | No | Generate a sparse identity manifest (appxmanifest.xml) for an existing desktop exe instead of a full package manifest. Use with --exe. Skips SDK/package installation. | | `useDefaults` | `boolean \| undefined` | No | Skip interactive prompts and use default answers. Normal init targets the positional project directory if given, otherwise the current directory (e.g., winapp init . --use-defaults). Sparse init (--exe --sparse) ignores the positional directory and writes to --output-dir instead. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -300,7 +301,7 @@ function manifestAddAlias(options?: ManifestAddAliasOptions): Promise | `templateVersion` | `string \| undefined` | No | WinUI template pack version: 'latest' (install newest), 'installed' (keep what's installed), or an explicit version. Default: install latest if none, else prompt to update a stale pack. | | `useDefaults` | `boolean \| undefined` | No | Do not prompt; use defaults (blank template, name from --output/--name, keep installed templates). | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -400,7 +401,7 @@ function packageApp(options: PackageOptions): Promise | `selfContained` | `boolean \| undefined` | No | Bundle Windows App SDK runtime for self-contained deployment | | `skipPri` | `boolean \| undefined` | No | Skip PRI file generation | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -419,7 +420,7 @@ function restore(options?: RestoreOptions): Promise | `baseDirectory` | `string \| undefined` | No | Base/root directory for the winapp workspace | | `configDir` | `string \| undefined` | No | Directory to read configuration from (default: current directory) | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -459,7 +460,7 @@ function run(options?: RunOptions): Promise | `withAlias` | `boolean \| undefined` | No | Launch the app using its execution alias instead of AUMID activation. The app runs in the current terminal with inherited stdin/stdout/stderr. Requires a uap5:ExecutionAlias in the manifest. Use "winapp manifest add-alias" to add an execution alias to the manifest. | | `appArgs` | `string \| string[] \| undefined` | No | Arguments to pass to the launched application (forwarded after --). | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -480,7 +481,7 @@ function sign(options: SignOptions): Promise | `password` | `string \| undefined` | No | Certificate password | | `timestamp` | `string \| undefined` | No | Timestamp server URL | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -498,7 +499,7 @@ function store(options?: StoreOptions): Promise |----------|------|----------|-------------| | `storeArgs` | `string \| string[] \| undefined` | No | Arguments to pass through to the Microsoft Store Developer CLI. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -516,7 +517,7 @@ function tool(options?: ToolOptions): Promise |----------|------|----------|-------------| | `toolArgs` | `string \| string[] \| undefined` | No | Arguments to pass to the SDK tool, e.g. ['makeappx', 'pack', '/d', './folder', '/p', './out.msix']. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -539,7 +540,7 @@ function uiClick(options?: UiClickOptions): Promise | `right` | `boolean \| undefined` | No | Perform a right-click instead of a left click | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -564,7 +565,7 @@ function uiDrag(options?: UiDragOptions): Promise | `right` | `boolean \| undefined` | No | Drag with the right mouse button instead of the left button | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -585,7 +586,7 @@ function uiFocus(options?: UiFocusOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -605,7 +606,7 @@ function uiGetFocused(options?: UiGetFocusedOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -627,7 +628,7 @@ function uiGetProperty(options?: UiGetPropertyOptions): Promise | `property` | `string \| undefined` | No | Property name to read or filter on | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -648,7 +649,7 @@ function uiGetValue(options?: UiGetValueOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -670,7 +671,7 @@ function uiHover(options?: UiHoverOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -696,7 +697,7 @@ function uiInspect(options?: UiInspectOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -717,7 +718,7 @@ function uiInvoke(options?: UiInvokeOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -737,7 +738,7 @@ function uiListWindows(options?: UiListWindowsOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `showHidden` | `boolean \| undefined` | No | Include untitled zero-size windows that are hidden by default | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -765,7 +766,7 @@ function uiPen(options?: UiPenOptions): Promise | `tiltY` | `number \| undefined` | No | Pen tilt along the y-axis in degrees (-90 to 90, default: 0). | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -789,7 +790,7 @@ function uiScreenshot(options?: UiScreenshotOptions): Promise | `output` | `string \| undefined` | No | Save output to this file path. | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -813,7 +814,7 @@ function uiScroll(options?: UiScrollOptions): Promise | `wheel` | `number \| undefined` | No | Rotate the mouse wheel over the element by this many notches (1 = one notch up, -1 = one notch down). Synthesizes real wheel input instead of using ScrollPattern. | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -834,7 +835,7 @@ function uiScrollIntoView(options?: UiScrollIntoViewOptions): Promise | `max` | `number \| undefined` | No | Maximum search results | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -881,7 +882,7 @@ function uiSendKeys(options?: UiSendKeysOptions): Promise | `via` | `string \| undefined` | No | Transport: post-message (default, HWND-targeted, bypasses UIPI; typed text raises TextChanged but not a per-character KeyDown) or send-input (OS-wide; typed text raises a real per-character KeyDown + TextChanged). Named keys and combos raise KeyDown on both, but keyboard accelerators/shortcuts (KeyboardAccelerator, e.g. ctrl+t) only fire via send-input. post-message targets the focused child control and works for classic Win32/WinForms controls, but WinUI 3 / UWP / XAML controls are windowless and ignore posted messages — use send-input for those (a warning is emitted when the target looks like a XAML app). | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -903,7 +904,7 @@ function uiSetValue(options?: UiSetValueOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -923,7 +924,7 @@ function uiStatus(options?: UiStatusOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -952,7 +953,7 @@ function uiTouch(options?: UiTouchOptions): Promise | `toPoint` | `string \| undefined` | No | End point x,y for a swipe (screen coordinates). Takes precedence over --direction. | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -978,7 +979,7 @@ function uiWaitFor(options?: UiWaitForOptions): Promise | `value` | `string \| undefined` | No | Wait for element value to equal this string. Uses smart fallback (TextPattern -> ValuePattern -> Name). Combine with --property to check a specific property instead. | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -998,7 +999,7 @@ function unregister(options?: UnregisterOptions): Promise | `json` | `boolean \| undefined` | No | Format output as JSON | | `manifest` | `string \| undefined` | No | Path to the Package.appxmanifest (default: auto-detect from current directory) | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -1016,7 +1017,7 @@ function update(options?: UpdateOptions): Promise |----------|------|----------|-------------| | `setupSdks` | `SdkInstallMode \| undefined` | No | SDK installation mode: 'stable' (default), 'preview', 'experimental', or 'none' (skip SDK installation) | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -1265,6 +1266,7 @@ Re-exported from Node.js for convenience. See [Node.js docs](https://nodejs.org/ |----------|------|----------|-------------| | `exitOnError` | `boolean \| undefined` | No | | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

On Windows, Node force-terminates the child, so the CLI's own cleanup may not run. That is safe: Windows closes the process's coordination file handles and deletes its `DeleteOnClose` participant lease, and other `winapp ui` processes prune the entry through lease and PID/start validation. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial or invalid output — this wrapper does not promise graceful MP4 finalization.

Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls that pass the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not you set this. What a workflow id adds is *continuity*: commands sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input.

Without it each call is a self-contained one-shot that releases the desktop the moment it finishes.

Applied to the spawned child only; `process.env` is never modified. | ### `CallWinappCliResult` @@ -1278,6 +1280,7 @@ Re-exported from Node.js for convenience. See [Node.js docs](https://nodejs.org/ |----------|------|----------|-------------| | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()) | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation. See {@link CallWinappCliOptions.signal} for the exact contract, including what is and is not guaranteed after an abort. | +| `workflowId` | `string \| undefined` | No | Groups this call into one logical UI workflow. See {@link CallWinappCliOptions.workflowId} for what continuity buys and why arbitration does not depend on it. | ### `CallWinappCliCaptureResult` @@ -1371,6 +1374,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `CertGenerateOptions` @@ -1389,6 +1393,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `CertInfoOptions` @@ -1401,6 +1406,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `CertInstallOptions` @@ -1413,6 +1419,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `CreateDebugIdentityOptions` @@ -1426,6 +1433,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `CreateExternalCatalogOptions` @@ -1441,6 +1449,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `EmbedIdentityOptions` @@ -1452,6 +1461,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `FindUiOptions` @@ -1468,6 +1478,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `GetWinappPathOptions` @@ -1478,6 +1489,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `InitOptions` @@ -1500,6 +1512,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `ManifestAddAliasOptions` @@ -1512,6 +1525,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `ManifestGenerateOptions` @@ -1530,6 +1544,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `ManifestUpdateAssetsOptions` @@ -1542,6 +1557,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `NewOptions` @@ -1559,6 +1575,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `PackageOptions` @@ -1580,6 +1597,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `RestoreOptions` @@ -1591,6 +1609,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `RunOptions` @@ -1623,6 +1642,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `SignOptions` @@ -1636,6 +1656,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `StoreOptions` @@ -1646,6 +1667,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `ToolOptions` @@ -1656,6 +1678,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UiClickOptions` @@ -1671,6 +1694,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UiDragOptions` @@ -1688,6 +1712,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UiFocusOptions` @@ -1701,6 +1726,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UiGetFocusedOptions` @@ -1713,6 +1739,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UiGetPropertyOptions` @@ -1727,6 +1754,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UiGetValueOptions` @@ -1740,6 +1768,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UiHoverOptions` @@ -1754,6 +1783,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UiInspectOptions` @@ -1772,6 +1802,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UiInvokeOptions` @@ -1785,6 +1816,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UiListWindowsOptions` @@ -1797,6 +1829,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UiPenOptions` @@ -1817,6 +1850,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UiScreenshotOptions` @@ -1833,6 +1867,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UiScrollOptions` @@ -1849,6 +1884,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UiScrollIntoViewOptions` @@ -1862,6 +1898,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UiSearchOptions` @@ -1876,6 +1913,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UiSendKeysOptions` @@ -1893,6 +1931,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UiSetValueOptions` @@ -1907,6 +1946,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UiStatusOptions` @@ -1919,6 +1959,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UiTouchOptions` @@ -1940,6 +1981,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UiWaitForOptions` @@ -1958,6 +2000,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UnregisterOptions` @@ -1970,6 +2013,7 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | ### `UpdateOptions` @@ -1980,4 +2024,5 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | diff --git a/src/winapp-npm/README.md b/src/winapp-npm/README.md index 2dacf86f2..2bf09b64c 100644 --- a/src/winapp-npm/README.md +++ b/src/winapp-npm/README.md @@ -105,11 +105,31 @@ finalization. #### Driving UI from several workflows -`winapp ui` commands that need the physical desktop take cooperative turns. Separate npm calls may -run under different Node parents, so the CLI cannot infer that they belong together. Set -`process.env.WINAPP_UI_OWNER_ID` to the same value for every cooperating call (it is forwarded to the -child automatically); the wrapper never generates one for you. See -[UI Automation → Coordinating concurrent UI workflows](https://github.com/microsoft/WinAppCli/blob/main/docs/ui-automation.md#coordinating-concurrent-ui-workflows). +`winapp ui` commands that touch the physical desktop always arbitrate for it — that is on by default +and cannot be turned off, so two agents can never type into each other's windows. + +What is opt-in is *continuity*. Pass the same `workflowId` to every call that belongs to one logical +workflow and they keep the desktop reserved between invocations for a short idle grace, may overlap +with each other (a recording and the clicks it is recording), and are never interleaved with another +workflow's input: + +```typescript +const workflowId = crypto.randomUUID(); + +await uiClick({ app: 'notepad', selector: 'btn-file-a1b2', workflowId }); +await uiSendKeys({ app: 'notepad', keys: 'hello', workflowId }); +``` + +`workflowId` is applied to the spawned child only — the wrapper never mutates `process.env`, so it +cannot leak into unrelated concurrent calls. Setting `WINAPP_UI_WORKFLOW_ID` in the environment works +too and is inherited by every child. + +Without a `workflowId` each call is a self-contained one-shot: it still waits its turn, but releases +the desktop the moment it finishes. That also means a no-`workflowId` `uiRecord` blocks every other +workflow for its whole duration — to record and click at the same time, give both calls the same +`workflowId`. + +See [UI Automation → Coordinating concurrent UI workflows](https://github.com/microsoft/WinAppCli/blob/main/docs/ui-automation.md#coordinating-concurrent-ui-workflows). `uiRecord` still requires a finite positive `durationSec`: `signal` can only stop a recording by killing it, which does not produce a valid MP4. diff --git a/src/winapp-npm/scripts/generate-commands.mjs b/src/winapp-npm/scripts/generate-commands.mjs index 1577fce09..51a436927 100644 --- a/src/winapp-npm/scripts/generate-commands.mjs +++ b/src/winapp-npm/scripts/generate-commands.mjs @@ -260,6 +260,18 @@ function generate(schema) { L(' * partial output. Rejects with an `AbortError`.'); L(' */'); L(' signal?: AbortSignal;'); + L(' /**'); + L(' * Groups this call with other `winapp ui` calls passing the same value into one logical workflow.'); + L(' *'); + L(' * Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn'); + L(' * whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop'); + L(' * reserved between invocations for a short idle grace, may overlap with each other (a recording and'); + L(' * the clicks it is recording), and are never interleaved with another workflow\'s input. Without it,'); + L(' * each call is a self-contained one-shot that releases the desktop as soon as it finishes.'); + L(' *'); + L(' * Applied to the spawned child process only; `process.env` is never modified.'); + L(' */'); + L(' workflowId?: string;'); L('}'); L(); L('/** Result returned by every command wrapper. */'); @@ -294,6 +306,7 @@ function generate(schema) { L(' const result: CallWinappCliCaptureOptions = {};'); L(' if (opts.cwd) result.cwd = opts.cwd;'); L(' if (opts.signal) result.signal = opts.signal;'); + L(' if (opts.workflowId !== undefined) result.workflowId = opts.workflowId;'); L(' return result;'); L('}'); L(); diff --git a/src/winapp-npm/scripts/generate-docs.mjs b/src/winapp-npm/scripts/generate-docs.mjs index e46852086..789fef8b2 100644 --- a/src/winapp-npm/scripts/generate-docs.mjs +++ b/src/winapp-npm/scripts/generate-docs.mjs @@ -33,10 +33,10 @@ const OUTPUT = resolve(NPM_ROOT, '../../docs/npm-usage.md'); // --------------------------------------------------------------------------- // CommonOptions properties — documented once, skipped in per-function tables // --------------------------------------------------------------------------- -const COMMON_OPTION_NAMES = new Set(['quiet', 'verbose', 'cwd', 'signal']); +const COMMON_OPTION_NAMES = new Set(['quiet', 'verbose', 'cwd', 'signal', 'workflowId']); // Rendered wherever a section says which inherited options also apply. -const COMMON_OPTION_NOTE = '(`quiet`, `verbose`, `cwd`, `signal`)'; +const COMMON_OPTION_NOTE = '(`quiet`, `verbose`, `cwd`, `signal`, `workflowId`)'; // --------------------------------------------------------------------------- // Create TypeScript program from tsconfig.json diff --git a/src/winapp-npm/src/winapp-cli-utils.ts b/src/winapp-npm/src/winapp-cli-utils.ts index 3413e2ecf..58a8b55c2 100644 --- a/src/winapp-npm/src/winapp-cli-utils.ts +++ b/src/winapp-npm/src/winapp-cli-utils.ts @@ -5,6 +5,29 @@ import { spawn } from 'child_process'; export const WINAPP_CLI_CALLER_VALUE = 'nodejs-package'; +/** Environment variable naming one logical UI workflow for cooperative desktop turns. */ +export const WINAPP_UI_WORKFLOW_ID = 'WINAPP_UI_WORKFLOW_ID'; + +/** + * Builds the child environment, adding the workflow id when one was supplied. + * + * The value is applied ONLY to the spawned child's environment — `process.env` is never mutated. + * Mutating it would silently enrol every later call in this process, including unrelated ones, into + * a workflow the caller only meant for one command, and would race across concurrent calls. + */ +function childEnv(workflowId?: string): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...process.env, + WINAPP_CLI_CALLER: WINAPP_CLI_CALLER_VALUE, + }; + + if (workflowId !== undefined) { + env[WINAPP_UI_WORKFLOW_ID] = workflowId; + } + + return env; +} + export interface CallWinappCliOptions { exitOnError?: boolean; /** @@ -20,6 +43,19 @@ export interface CallWinappCliOptions { * Rejects with an `AbortError`. */ signal?: AbortSignal; + /** + * Groups this call with other `winapp ui` calls that pass the same value into one logical workflow. + * + * Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn + * whether or not you set this. What a workflow id adds is *continuity*: commands sharing one keep the + * desktop reserved between invocations for a short idle grace, may overlap with each other (a + * recording and the clicks it is recording), and are never interleaved with another workflow's input. + * + * Without it each call is a self-contained one-shot that releases the desktop the moment it finishes. + * + * Applied to the spawned child only; `process.env` is never modified. + */ + workflowId?: string; } export interface CallWinappCliResult { @@ -34,6 +70,11 @@ export interface CallWinappCliCaptureOptions { * contract, including what is and is not guaranteed after an abort. */ signal?: AbortSignal; + /** + * Groups this call into one logical UI workflow. See {@link CallWinappCliOptions.workflowId} for + * what continuity buys and why arbitration does not depend on it. + */ + workflowId?: string; } export interface CallWinappCliCaptureResult { @@ -67,7 +108,7 @@ export function getWinappCliPath(): string { * Always captures output and returns it along with the exit code */ export async function callWinappCli(args: string[], options: CallWinappCliOptions = {}): Promise { - const { exitOnError = false, signal } = options; + const { exitOnError = false, signal, workflowId } = options; const winappCliPath = getWinappCliPath(); return new Promise((resolve, reject) => { @@ -76,10 +117,7 @@ export async function callWinappCli(args: string[], options: CallWinappCliOption cwd: process.cwd(), shell: false, signal, - env: { - ...process.env, - WINAPP_CLI_CALLER: WINAPP_CLI_CALLER_VALUE, - }, + env: childEnv(workflowId), }); child.on('close', (code) => { @@ -122,7 +160,7 @@ export async function callWinappCliCapture( args: string[], options: CallWinappCliCaptureOptions = {} ): Promise { - const { cwd = process.cwd(), signal } = options; + const { cwd = process.cwd(), signal, workflowId } = options; const winappCliPath = getWinappCliPath(); return new Promise((resolve, reject) => { @@ -134,10 +172,7 @@ export async function callWinappCliCapture( cwd, shell: false, signal, - env: { - ...process.env, - WINAPP_CLI_CALLER: WINAPP_CLI_CALLER_VALUE, - }, + env: childEnv(workflowId), }); child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk)); diff --git a/src/winapp-npm/src/winapp-commands.ts b/src/winapp-npm/src/winapp-commands.ts index e14de8a4a..b34362e63 100644 --- a/src/winapp-npm/src/winapp-commands.ts +++ b/src/winapp-npm/src/winapp-commands.ts @@ -2,7 +2,7 @@ * AUTO-GENERATED — DO NOT EDIT * * Regenerate with: npm run generate-commands - * Source schema version: 0.6.1 + * Source schema version: 0.6.3 * * Programmatic wrappers for all winapp CLI commands. * Each function builds the CLI arguments, invokes the native CLI, @@ -46,6 +46,18 @@ export interface CommonOptions { * partial output. Rejects with an `AbortError`. */ signal?: AbortSignal; + /** + * Groups this call with other `winapp ui` calls passing the same value into one logical workflow. + * + * Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn + * whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop + * reserved between invocations for a short idle grace, may overlap with each other (a recording and + * the clicks it is recording), and are never interleaved with another workflow's input. Without it, + * each call is a self-contained one-shot that releases the desktop as soon as it finishes. + * + * Applied to the spawned child process only; `process.env` is never modified. + */ + workflowId?: string; } /** Result returned by every command wrapper. */ @@ -78,6 +90,7 @@ function captureOpts(opts: CommonOptions): CallWinappCliCaptureOptions { const result: CallWinappCliCaptureOptions = {}; if (opts.cwd) result.cwd = opts.cwd; if (opts.signal) result.signal = opts.signal; + if (opts.workflowId !== undefined) result.workflowId = opts.workflowId; return result; } diff --git a/src/winapp-npm/test/workflow-id.test.ts b/src/winapp-npm/test/workflow-id.test.ts new file mode 100644 index 000000000..bc2f726b7 --- /dev/null +++ b/src/winapp-npm/test/workflow-id.test.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { WINAPP_UI_WORKFLOW_ID } from '../src/winapp-cli-utils'; +import { uiListWindows } from '../src/winapp-commands'; + +// Cooperative desktop turns group commands by workflow id. The wrapper must pass that id to the +// spawned child ONLY: writing it into process.env would silently enrol every later call in this +// process — including unrelated ones — into a workflow the caller meant for one command, and would +// race across concurrent calls. + +test('workflowId is not written into the parent process environment', async () => { + const before = process.env[WINAPP_UI_WORKFLOW_ID]; + + // The CLI binary is not present in a unit-test checkout, so the call fails; the assertion is about + // what the wrapper did to this process before spawning, which happens either way. + await uiListWindows({ workflowId: 'unit-test-workflow' }).catch(() => undefined); + + assert.equal( + process.env[WINAPP_UI_WORKFLOW_ID], + before, + 'the wrapper must never mutate process.env — the id belongs to the child only' + ); +}); + +test('omitting workflowId leaves an inherited value untouched', async () => { + const before = process.env[WINAPP_UI_WORKFLOW_ID]; + process.env[WINAPP_UI_WORKFLOW_ID] = 'inherited-workflow'; + try { + await uiListWindows({}).catch(() => undefined); + + assert.equal( + process.env[WINAPP_UI_WORKFLOW_ID], + 'inherited-workflow', + 'an environment-provided workflow id must still reach the child unchanged' + ); + } finally { + if (before === undefined) { + delete process.env[WINAPP_UI_WORKFLOW_ID]; + } else { + process.env[WINAPP_UI_WORKFLOW_ID] = before; + } + } +}); From 5d22eede2019413522f80c09577c898cc8bfb0be Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:22:19 -0700 Subject: [PATCH 12/29] Rewrite the coordination docs around arbitration-always-on, continuity-opt-in The old docs led with 'set this variable so the CLI can tell your commands apart', which read like coordination was something you switch on. It never was: arbitration is unconditional and a workflow id only buys continuity between commands. Every surface now says that in the same order - arbitration first, then the opt-in. Also corrected across all surfaces: - WINAPP_UI_OWNER_ID -> WINAPP_UI_WORKFLOW_ID, invalid_ui_owner_id -> invalid_ui_workflow_id, telemetry identity values Explicit/Parent/Anonymous -> Workflow/Anonymous. - Dropped the claim that direct scripts are grouped automatically. There is no process-ancestry fallback any more, so without an id every command is its own one-shot - including several launched from one shell. - Ordering is described honestly as owner affinity first, then FIFO among the remaining workflows once the owner yields or its grace expires. It was previously implied to be strict FIFO, which it has never been. - screenshot is documented as always exclusive, with the escalation story removed, plus the fact that a multi-window composite is captured under one turn and encoded after the desktop is released. - record: a no-id recording blocks every other workflow for its duration, and PrintWindow hosts hold the desktop for the whole recording. - Both package READMEs state plainly that direct NuGet consumers are outside the CLI's coordination guarantee and must serialize themselves. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/telemetry.md | 4 +- docs/ui-automation.md | 70 ++++++++++++------- docs/usage.md | 20 +++--- .../com.github.copilot/agents/winapp.agent.md | 15 ++-- .../skills/winapp-ui-automation/SKILL.md | 27 ++++--- .../references/ui-json-envelope.md | 2 +- .../WinApp.UIAutomation.Recording/PACKAGE.md | 6 ++ src/winapp-CLI/WinApp.UIAutomation/PACKAGE.md | 7 ++ 8 files changed, 100 insertions(+), 51 deletions(-) diff --git a/docs/telemetry.md b/docs/telemetry.md index 5606d845b..9b73a0b6e 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -85,7 +85,7 @@ The telemetry feature collects the following data: | CI environment | A boolean flag indicating whether the CLI is running in a Continuous Integration environment. | | Caller | The value of the `WINAPP_CLI_CALLER` environment variable, if set. This allows wrapper tools (like the npm package) to identify themselves. | | `find-ui` usage | For the `winapp find-ui` command only, an additional usage event with non-personal, bounded values: the mode (`search`, `fetch`, or `list`); the selected `--source` (a fixed value — `gallery`, `toolkit`, `reactor`, or `core`); the catalog scenario IDs fetched (e.g. `gallery-tabview-1`), which identify built-in WinUI sample controls, never your code; whether `--json` was used; and result/ID counts. The free-form search query is **never** collected, and any requested IDs that don't match a real catalog entry are counted but **not** collected as text. | -| `winapp ui` desktop coordination | For `winapp ui` commands only, a privacy-minimized summary of how the command shared the desktop with other UI workflows: how the workflow identity was resolved (`Explicit`, `Parent`, or `Anonymous` — never the identity itself), the coordination mode (`Observe`, `TurnShared`, or `DesktopExclusive`), how the turn was obtained (new, continuation, queued, handoff-after-idle, or detached), the outcome (completed, cancelled, coordination failure, or corruption recovery), and **coarse buckets** for how long this command waited for the desktop, how many commands were queued, and how long the owning workflow had held its turn. | +| `winapp ui` desktop coordination | For `winapp ui` commands only, a privacy-minimized summary of how the command shared the desktop with other UI workflows: how the workflow identity was resolved (`Workflow` or `Anonymous` — never the identity itself), the coordination mode (`Observe`, `TurnShared`, or `DesktopExclusive`), how the turn was obtained (new, continuation, queued, handoff-after-idle, or detached), the outcome (completed, cancelled, coordination failure, or corruption recovery), and **coarse buckets** for how long this command waited for the desktop, how many commands were queued, and how long the owning workflow had held its turn. | ### Sanitization of sensitive data @@ -95,7 +95,7 @@ The winapp CLI takes several measures to protect your privacy: - **Implicit values** (default values that weren't explicitly provided) are not collected. - **Parsing errors** are logged as `[error]` without including the actual erroneous input. - All string values in telemetry events undergo **sensitive string replacement** before transmission, which replaces any registered sensitive strings with anonymized tokens. -- **Desktop coordination** never collects the `WINAPP_UI_OWNER_ID` value or its hash, process IDs, process or application names, window titles, selectors, element text, queue contents, or any part of the coordination state files. Durations and counts are reported only as fixed buckets (for example `1000-4999`), never as exact values. +- **Desktop coordination** never collects the `WINAPP_UI_WORKFLOW_ID` value or its hash, process IDs, process or application names, window titles, selectors, element text, queue contents, or any part of the coordination state files. Durations and counts are reported only as fixed buckets (for example `1000-4999`), never as exact values. ## Crash exception telemetry diff --git a/docs/ui-automation.md b/docs/ui-automation.md index 18742cc00..4d773bec2 100644 --- a/docs/ui-automation.md +++ b/docs/ui-automation.md @@ -37,53 +37,73 @@ Windows has only one foreground window, one keyboard focus, one cursor, and one two `winapp ui` workflows run on the same signed-in desktop at once, they can steal focus from each other, dismiss a menu the other just opened, or move a target out from under a pending click. -`winapp ui` coordinates automatically: commands that need the physical desktop take **cooperative -turns**, and read-only commands keep running concurrently. There is nothing to enable — but set one -environment variable per logical workflow so the CLI can tell your commands apart from someone -else's. +**Arbitration is always on.** Every `winapp ui` command that touches the physical desktop takes a +turn, with no setup and no way to switch it off, so two agents can never type into each other's +windows. Read-only commands keep running concurrently. + +**Continuity between commands is opt-in.** By default each command is a self-contained one-shot: it +waits its turn, does its work, and releases the desktop immediately. To keep the desktop across +several commands, give them all the same workflow id: ```powershell # Set once per logical UI workflow -$env:WINAPP_UI_OWNER_ID = [guid]::NewGuid().ToString() +$env:WINAPP_UI_WORKFLOW_ID = [guid]::NewGuid().ToString() ``` What you need to know: -- **Explicit identity overrides parent identity.** `WINAPP_UI_OWNER_ID` names one logical workflow — - not necessarily a whole agent, and not necessarily one app. Use the *same* value for cooperating - processes (a recording plus the clicks it should capture); use *different* values for independent - workflows, even when one agent launches both. -- **Direct scripts work automatically.** With no variable set, commands are grouped by the shell or - script that launched them, so a normal `.ps1` or `.cmd` needs no setup. +- **A workflow id names one logical workflow** — not necessarily a whole agent, and not necessarily + one app. Use the *same* value for cooperating commands (a recording plus the clicks it should + capture); use *different* values for independent workflows, even when one agent launches both. +- **With no id, every command is an independent one-shot.** It still arbitrates, but it banks no + grace and hands the desktop off the moment it finishes. Two no-id commands are separate workflows + even when launched from the same shell. - **Fresh-shell and adaptive hosts must inject the same value.** If each command runs in a new shell - — which is how most agent tool calls work — parent identity cannot group them. Pass the same - explicit `WINAPP_UI_OWNER_ID` into every cooperating call. -- **The four-second grace protects tight bursts, not model reasoning.** A workflow keeps its turn as - long as the next command starts within four seconds. That covers back-to-back commands in one - script; it intentionally expires while a model is thinking. + — which is how most agent tool calls work — the only thing that can group them is an explicit + `WINAPP_UI_WORKFLOW_ID` passed into every cooperating call. +- **The four-second grace protects tight bursts, not model reasoning.** A workflow with an id keeps + its turn as long as the next command starts within four seconds. That covers back-to-back commands + in one script; it intentionally expires while a model is thinking. - **Adaptive workflows must reacquire, revalidate, and replay.** After a reasoning gap another workflow may have used the desktop, so reopen the menu, re-resolve the element, and then act. Send known end-to-end sequences as one tight script rather than holding the desktop while you think. -- **There is no hard cap.** A live workflow can hold the desktop indefinitely, which means a long - script, an unbounded recording, or a failure loop can block other mutating workflows. +- **Ordering is owner affinity first, then FIFO among the others.** While a workflow is active or + inside its grace it may keep issuing commands, even if other workflows are already waiting. Once it + yields or its grace expires, waiting workflows are served in strict arrival order. Continuous + activity by one workflow can therefore delay others indefinitely. +- **There is no hard cap.** A long script, an unbounded recording, or a failure loop can block other + mutating workflows. - **Cancellation or process termination is the recovery** for a stuck live workflow. Waiting commands print a status after one second and can be stopped with `Ctrl+C`, which exits `130`. - **Only compatible updated binaries cooperate.** Older `winapp` builds predate this feature and are - not coordinated. + not coordinated. Code calling the UI Automation NuGet packages directly is outside this guarantee + entirely — coordination lives in the CLI, not in the packages. Which commands wait for a turn: | Behavior | Commands | |---|---| -| Runs concurrently (never waits) | `status`, `list-windows`, `inspect`, `search`, `get-property`, `get-value`, `get-focused`, `wait-for`, `set-value`, `scroll-into-view`, `scroll --direction`/`--to`, plain `screenshot` | +| Runs concurrently (never waits) | `status`, `list-windows`, `inspect`, `search`, `get-property`, `get-value`, `get-focused`, `wait-for`, `set-value`, `scroll-into-view`, `scroll --direction`/`--to` | | Claims the turn, shares it with the same workflow | `record` | -| Claims the turn and takes the desktop exclusively | `invoke`, `click`, `drag`, `hover`, `scroll --wheel`, `touch`, `pen`, `focus`, `send-keys`, `screenshot --focus`/`--capture-screen` | +| Claims the turn and takes the desktop exclusively | `invoke`, `click`, `drag`, `hover`, `scroll --wheel`, `touch`, `pen`, `focus`, `send-keys`, `screenshot` | + +`screenshot` always queues for an exclusive turn. Every capture path restores minimized windows or +takes the foreground, so it is desktop-sensitive whatever its arguments; when it composites several +windows it captures them all under one exclusive turn, so the saved image is a single consistent +moment rather than a mix of before and after. Encoding and writing the file happen after the desktop +is released. + +`record` shares its turn, so same-workflow input can interleave with the capture — that is how you +record a workflow driving an app. Two caveats: -A plain `screenshot` starts as a concurrent capture and escalates to an exclusive turn only if a -target turns out to be minimized or renders blank, in which case it re-captures everything from the -beginning so the saved image is never a mix of before and after. +- A `record` with **no** workflow id is a one-shot owner, so it blocks every other workflow for its + whole duration. To record and click at the same time, give both commands the same + `WINAPP_UI_WORKFLOW_ID`. +- On a host without frame-capture support, recording falls back to PrintWindow, whose blank-frame + recovery can foreground the window at any moment. There the desktop is held for the **entire** + recording and the command says so in its output; even same-workflow input will wait. -Errors you may see: `invalid_ui_owner_id` (the variable is set but empty or over 256 characters), +Errors you may see: `invalid_ui_workflow_id` (the variable is set but empty or over 256 characters), `desktop_coordination_unavailable` (coordination state is unreadable and cannot be safely rebuilt, or was written by a newer `winapp`), `queue_capacity_exceeded` (64 commands are already waiting), and `cancelled` (Ctrl+C while waiting, exit code `130`). diff --git a/docs/usage.md b/docs/usage.md index 8499e3ea4..6b986d9ab 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1438,19 +1438,23 @@ To make this permanent: ### UI workflow identity -`winapp ui` commands that drive the physical desktop take cooperative turns, so two workflows running -at once cannot steal each other's focus or dismiss each other's menus. To tell the CLI which commands -belong to the same logical workflow, set `WINAPP_UI_OWNER_ID` once per workflow: +`winapp ui` commands that drive the physical desktop always take cooperative turns, so two workflows +running at once cannot steal each other's focus or dismiss each other's menus. That arbitration needs +no setup and cannot be switched off. + +What is optional is *continuity*. By default each command is a self-contained one-shot that releases +the desktop as soon as it finishes. To keep the desktop across several commands, give them all the +same workflow id: ```pwsh -$env:WINAPP_UI_OWNER_ID = [guid]::NewGuid().ToString() +$env:WINAPP_UI_WORKFLOW_ID = [guid]::NewGuid().ToString() ``` Use the *same* value for cooperating processes (for example a recording and the clicks it should -capture) and *different* values for independent workflows. Commands issued directly from one shell or -script are grouped automatically and need no variable; hosts that start a fresh shell per command -must inject the same explicit value into each one. The value is opaque, is never treated as a -credential, and is only ever persisted as a SHA-256 hash. See +capture) and *different* values for independent workflows. Every command without an id is its own +one-shot workflow, even when several are launched from one shell, so hosts that start a fresh shell +per command must inject the same explicit value into each one. The value is opaque, is never treated +as a credential, and is only ever persisted as a SHA-256 hash. See [UI Automation → Coordinating concurrent UI workflows](ui-automation.md#coordinating-concurrent-ui-workflows). ### ui diff --git a/plugins/winapp/com.github.copilot/agents/winapp.agent.md b/plugins/winapp/com.github.copilot/agents/winapp.agent.md index 7068e83eb..bf0c67949 100644 --- a/plugins/winapp/com.github.copilot/agents/winapp.agent.md +++ b/plugins/winapp/com.github.copilot/agents/winapp.agent.md @@ -76,11 +76,16 @@ Want to inspect or interact with a running app's UI? └─ List app windows → winapp ui list-windows -a [--show-hidden] Driving a UI while other workflows may be running? -├─ Set $env:WINAPP_UI_OWNER_ID once per logical workflow, and inject the SAME value into every -│ cooperating call — each tool call usually gets a fresh shell, which otherwise looks like a -│ different workflow -├─ A workflow keeps the desktop for 4s after its last command; that covers a tight script but -│ intentionally expires while you reason +├─ Turn-taking is ALWAYS on — no setup needed, and two agents can never type into each other's +│ windows +├─ Set $env:WINAPP_UI_WORKFLOW_ID once per logical workflow and inject the SAME value into every +│ cooperating call — that is what keeps the desktop across commands. Without it each call is a +│ one-shot that releases the desktop immediately (each tool call usually gets a fresh shell, and +│ there is no process-ancestry fallback) +├─ A workflow with an id keeps the desktop for 4s after its last command; that covers a tight +│ script but intentionally expires while you reason +├─ `ui record` shares its turn only with the SAME workflow id — a no-id recording blocks everyone +│ else for its whole duration └─ After a reasoning gap, reopen/re-navigate and re-resolve before acting — another workflow may have used the desktop, so transient UI (menus, flyouts) is gone diff --git a/plugins/winapp/skills/winapp-ui-automation/SKILL.md b/plugins/winapp/skills/winapp-ui-automation/SKILL.md index ae352e0ed..3c8a73c72 100644 --- a/plugins/winapp/skills/winapp-ui-automation/SKILL.md +++ b/plugins/winapp/skills/winapp-ui-automation/SKILL.md @@ -18,36 +18,43 @@ description: Inspect and interact with running Windows app UIs from the command Windows has one foreground window, one keyboard focus, one cursor, and one input stream. `winapp ui` therefore makes desktop-driving commands take **cooperative turns** so concurrent workflows cannot -steal each other's focus or dismiss each other's menus. Read-only commands never wait. +steal each other's focus or dismiss each other's menus. That is always on. Read-only commands never +wait. + +Keeping the desktop *across* commands is opt-in — without an id, each command is a one-shot that +releases the desktop the moment it finishes: ```powershell # Set once per logical UI workflow — same value for cooperating calls, different values for # independent workflows (even from the same agent). -$env:WINAPP_UI_OWNER_ID = [guid]::NewGuid().ToString() +$env:WINAPP_UI_WORKFLOW_ID = [guid]::NewGuid().ToString() ``` Rules that matter when driving this from an agent: -- **Each tool call usually gets a fresh shell**, so parent-process grouping will NOT hold your - commands together. Inject the *same* `WINAPP_UI_OWNER_ID` into every cooperating call. -- **A workflow keeps its turn for four seconds** after its last command. That covers back-to-back - commands in one script; it deliberately expires while you are reasoning. +- **Each tool call usually gets a fresh shell**, and there is no process-ancestry fallback, so + commands are grouped ONLY by the id you inject. Without it every call is its own workflow. +- **A workflow with an id keeps its turn for four seconds** after its last command. That covers + back-to-back commands in one script; it deliberately expires while you are reasoning. - **After a reasoning gap, replay your setup.** Another workflow may have used the desktop, so reopen the menu / re-navigate, re-resolve the element, then act. Do not assume transient UI survived. - **Prefer one tight script over many round trips** for a known sequence: `winapp ui invoke View -w $hwnd; winapp ui search "Status bar" -w $hwnd; winapp ui click "Status bar" -w $hwnd`. -- **`record` shares the turn with its own workflow**, so same-owner clicks and typing are captured - while it runs. +- **`record` shares the turn with its own workflow**, so same-workflow clicks and typing are captured + while it runs — but only if both commands carry the same id. A `record` with no id blocks everyone + else for its whole duration. +- **Ordering is owner affinity, then FIFO.** An active workflow may keep issuing commands ahead of + others already waiting; once it yields or its grace expires, waiters are served in arrival order. - **Waiting is indefinite and cancellable.** A status line appears after one second; Ctrl+C exits `130` with error code `cancelled` and the command never ran. - **There is no hard cap** — a long script, unbounded recording, or failure loop can block other mutating workflows until it finishes or is stopped. Commands that never wait: `status`, `list-windows`, `inspect`, `search`, `get-*`, `wait-for`, -`set-value`, `scroll-into-view`, `scroll --direction`/`--to`, and a plain `screenshot`. +`set-value`, `scroll-into-view`, `scroll --direction`/`--to`. Commands that take a turn: `record` (shared) and `invoke`, `click`, `drag`, `hover`, -`scroll --wheel`, `touch`, `pen`, `focus`, `send-keys`, `screenshot --focus`/`--capture-screen` +`scroll --wheel`, `touch`, `pen`, `focus`, `send-keys`, `screenshot` (always exclusive) (exclusive). ## Common patterns diff --git a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md index 91d65c739..6340769d2 100644 --- a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md +++ b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md @@ -113,7 +113,7 @@ appear: | `code` | Meaning | |---|---| -| `invalid_ui_owner_id` | `WINAPP_UI_OWNER_ID` is set but empty/whitespace or longer than 256 characters. Fails before any UI side effect. | +| `invalid_ui_workflow_id` | `WINAPP_UI_WORKFLOW_ID` is set but empty/whitespace or longer than 256 characters. Fails before any UI side effect. | | `desktop_coordination_unavailable` | Coordination state could not be read, published, or safely rebuilt — including state written by a newer `winapp`. Mutating commands fail closed rather than acting uncoordinated. | | `queue_capacity_exceeded` | 64 commands are already waiting for the desktop. | | `cancelled` | Native Ctrl+C while the command was still waiting for its turn. The command never ran, so it has no UI side effects. Exit code **130**. | diff --git a/src/winapp-CLI/WinApp.UIAutomation.Recording/PACKAGE.md b/src/winapp-CLI/WinApp.UIAutomation.Recording/PACKAGE.md index 0267f6e11..4b331a9ab 100644 --- a/src/winapp-CLI/WinApp.UIAutomation.Recording/PACKAGE.md +++ b/src/winapp-CLI/WinApp.UIAutomation.Recording/PACKAGE.md @@ -3,6 +3,12 @@ Record a Windows app window — or one element's region — to an H.264 MP4, with optional timestamped JPEG frames for evidence. This is the recording engine behind `winapp ui record`. +> **This package does not coordinate with other automation on the desktop.** The `winapp` CLI layers +> cooperative desktop turns on top of this engine — holding the desktop while a recording starts, and +> for the whole recording when the host falls back to PrintWindow capture. That arbitration lives in +> the CLI, not here. Code calling these APIs directly is outside that guarantee and is responsible +> for serializing itself against any other automation running at the same time. + ```console dotnet add package Microsoft.Windows.SDK.BuildTools.WinApp.UIAutomation.Recording dotnet add package Microsoft.Extensions.DependencyInjection diff --git a/src/winapp-CLI/WinApp.UIAutomation/PACKAGE.md b/src/winapp-CLI/WinApp.UIAutomation/PACKAGE.md index daadbb45d..c0b4fabf7 100644 --- a/src/winapp-CLI/WinApp.UIAutomation/PACKAGE.md +++ b/src/winapp-CLI/WinApp.UIAutomation/PACKAGE.md @@ -3,6 +3,13 @@ Inspect and drive any running Windows desktop app from code — the UI Automation engine behind the `winapp ui` commands, packaged as a library. +> **This package does not coordinate with other automation on the desktop.** The `winapp` CLI layers +> cooperative desktop turns on top of this engine so concurrent `winapp ui` workflows cannot steal +> each other's focus or dismiss each other's menus. That arbitration lives in the CLI, not here. +> Code calling these APIs directly drives the desktop immediately and is outside that guarantee — if +> you run it alongside `winapp ui`, or alongside another copy of itself, you are responsible for +> serializing the two. + ```console dotnet add package Microsoft.Windows.SDK.BuildTools.WinApp.UIAutomation dotnet add package Microsoft.Extensions.DependencyInjection From 3e729a212b1d4bc97eb2d4b0d17946710e1e37a3 Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:44:46 -0700 Subject: [PATCH 13/29] Port the test suites to the redesigned coordination model Retargets every coordination test onto the two-tier owner model and the package APIs, and deletes the tests for concepts that no longer exist rather than adapting them: parent-derived owner identity and liveness, and the screenshot observe-to-exclusive escalation including its cancellation and unknown-version paths. New coverage for the semantics the redesign actually promises: a named workflow banks the four-second grace while an anonymous one banks none; two no-id resolutions produce different owners; an anonymous completion hands off on the next normalization; a no-id recording blocks a no-id click while the same workflow id lets a recording and a click overlap; owner affinity lets the current owner continue ahead of waiting foreign owners, with FIFO applying among those waiters only after it yields; and a persisted grace survives with no live participant lease. Screenshot tests assert it is always DesktopExclusive, that a multi-window composite is captured under exactly one desktop section, that the section is released before encoding and writing, and that a foreground refusal writes no artifact. Record tests cover the first-frame release, release on failure or cancellation before the callback, a late or duplicate callback, the PrintWindow full-duration hold and its warning, and predicted-versus-actual mode disagreement. Package foreground-loss safety is tested in WinApp.UIAutomation.Tests using no coordination type, and PublicApiSurfaceTests proves neither package exports one. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../FakeInteractiveDesktopLock.cs | 22 -- .../FakeUiRecordingService.cs | 24 +- .../InteractiveDesktopLockTests.cs | 183 +------------ .../InteractiveDesktopMultiprocessTests.cs | 18 +- .../InteractiveDesktopRealAppTests.cs | 10 +- .../InteractiveDesktopSchedulerTests.cs | 240 +++++++++++------- .../InteractiveDesktopStoreTests.cs | 16 +- .../WinApp.Cli.Tests/RealRecordingTests.cs | 24 +- .../UiCommandTests.CaptureForeground.cs | 4 +- .../UiCommandTests.Coordination.cs | 64 +---- .../UiCommandTests.Record.Command.cs | 94 +++++++ .../UiCommandTests.Record.Stdin.cs | 3 +- .../UiCommandTests.Screenshot.cs | 19 ++ .../WinApp.Cli.Tests/UiCommandTests.cs | 15 +- .../FakeUiServices.cs | 18 +- .../CaptureForegroundSafetyTests.cs | 98 +++++++ .../UiTargetResolverTests.cs | 2 +- .../WinApp.UIAutomation.Tests.csproj | 2 + 18 files changed, 468 insertions(+), 388 deletions(-) create mode 100644 src/winapp-CLI/WinApp.UIAutomation.Tests/CaptureForegroundSafetyTests.cs diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs index f28e8489b..7733144cc 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs @@ -27,19 +27,9 @@ internal sealed class FakeInteractiveDesktopLock : IInteractiveDesktopLock /// How many desktop sections are open right now; tests assert this around observable calls. public int OpenDesktopSections { get; private set; } - /// How many times a body escalated an observation to DesktopExclusive. - public int Escalations { get; private set; } - /// Set to throw from , to cover coordination failures. public UiCoordinationException? ThrowOnRun { get; set; } - /// - /// Set to throw from EscalateToDesktopExclusiveAsync, covering an escalation that is - /// cancelled while queued or refused because coordination is unavailable. Handlers must let these - /// escape rather than flattening them into internal_error. - /// - public Exception? ThrowOnEscalation { get; set; } - /// Milliseconds reported as queue wait, so output/telemetry paths can be exercised. public long WaitedMs { get; set; } @@ -99,18 +89,6 @@ public Task EnterAsync(CancellationToken cancellationToken) return Task.FromResult(new FakeSection(owner)); } - public Task EscalateToDesktopExclusiveAsync(CancellationToken cancellationToken) - { - owner.Escalations++; - - if (owner.ThrowOnEscalation is { } failure) - { - return Task.FromException(failure); - } - - Mode = UiTurnMode.DesktopExclusive; - return Task.CompletedTask; - } } private sealed class FakeSection(FakeInteractiveDesktopLock owner) : IAsyncDisposable diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeUiRecordingService.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeUiRecordingService.cs index 99b4f6350..5d02b43d1 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeUiRecordingService.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeUiRecordingService.cs @@ -23,6 +23,14 @@ internal sealed class FakeUiRecordingService : IUiRecordingService public bool? RecordingStartedFrameArtifactsActiveOverride { get; set; } + public Action? BeforeRecordingStarted { get; set; } + + public Action? AfterRecordingStarted { get; set; } + + public bool InvokeRecordingStartedTwice { get; set; } + + public Task? WaitAfterRecordingStarted { get; set; } + public async Task RecordAsync(UiTarget uiTarget, string? elementId, RecordOptions options, CancellationToken ct, Action? onRecordingStarted = null) { LastRecordOptions = options; @@ -54,9 +62,19 @@ await File.WriteAllTextAsync( TotalBytes = 64, }; } - onRecordingStarted?.Invoke( - RecordingStartedFrameArtifactsActiveOverride - ?? (options.FramesDirectory is not null)); + BeforeRecordingStarted?.Invoke(); + var frameArtifactsActive = RecordingStartedFrameArtifactsActiveOverride + ?? (options.FramesDirectory is not null); + onRecordingStarted?.Invoke(frameArtifactsActive); + if (InvokeRecordingStartedTwice) + { + onRecordingStarted?.Invoke(frameArtifactsActive); + } + AfterRecordingStarted?.Invoke(); + if (WaitAfterRecordingStarted is { } wait) + { + await wait.ConfigureAwait(false); + } if (RecordExceptionAfterStarted is not null) { throw RecordExceptionAfterStarted; diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs index 7ad3f0c90..3dd774ceb 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs @@ -16,7 +16,7 @@ namespace WinApp.Cli.Tests; /// locks (issue #764): admission, the forward barrier, and the desktop-section contract. ///
[TestClass] -[DoNotParallelize] // WINAPP_UI_LOCK_DIRECTORY and WINAPP_UI_OWNER_ID are process-wide. +[DoNotParallelize] // WINAPP_UI_LOCK_DIRECTORY and WINAPP_UI_WORKFLOW_ID are process-wide. public class InteractiveDesktopLockTests { private string _lockDirectory = null!; @@ -33,12 +33,12 @@ public void Setup() _lockDirectory = Path.Combine(Path.GetTempPath(), $"winapp-lock-svc-{Guid.NewGuid():N}"); _previousLockOverride = Environment.GetEnvironmentVariable( InteractiveDesktopPaths.LockDirectoryOverrideVariable); - _previousOwnerId = Environment.GetEnvironmentVariable(UiOwnerResolver.OwnerIdVariable); + _previousOwnerId = Environment.GetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable); Environment.SetEnvironmentVariable( InteractiveDesktopPaths.LockDirectoryOverrideVariable, _lockDirectory); // A stable explicit owner keeps these tests independent of the test host's parent process. - Environment.SetEnvironmentVariable(UiOwnerResolver.OwnerIdVariable, "interactive-desktop-lock-tests"); + Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, "interactive-desktop-lock-tests"); var inspector = new ProcessInspector(); _paths = new InteractiveDesktopPaths(inspector); @@ -49,7 +49,7 @@ public void Setup() _store, _paths, _participants, - new UiOwnerResolver(inspector), + new UiOwnerResolver(), inspector, new TickCountClock(), new FakePollDelay(), @@ -62,7 +62,7 @@ public void Cleanup() { Environment.SetEnvironmentVariable( InteractiveDesktopPaths.LockDirectoryOverrideVariable, _previousLockOverride); - Environment.SetEnvironmentVariable(UiOwnerResolver.OwnerIdVariable, _previousOwnerId); + Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, _previousOwnerId); UiCoordinationTelemetryScope.Clear(); try @@ -151,7 +151,7 @@ public async Task CoordinationSummaryIsPublishedForTelemetry() var summary = UiCoordinationTelemetryScope.Current; Assert.IsNotNull(summary); - Assert.AreEqual(UiOwnerKind.Explicit, summary!.IdentitySource); + Assert.AreEqual(UiOwnerKind.Workflow, summary!.IdentitySource); Assert.AreEqual(UiTurnMode.DesktopExclusive, summary.Mode); Assert.AreEqual(UiCoordinationOutcome.Completed, summary.Outcome); @@ -225,7 +225,7 @@ public async Task CoordinationSummary_StateWithoutATurnStartTickReportsNoTurnAge { var state = InteractiveDesktopState.CreateFresh(); state.TurnId = 7; - state.Owner = new OwnerRecord { Kind = UiOwnerKind.Explicit, Key = "some-other-workflow" }; + state.Owner = new OwnerRecord { Kind = UiOwnerKind.Workflow, Key = "some-other-workflow" }; state.TurnStartedTick64 = 0; state.IdleExpiresTick64 = 1; // already elapsed, so this command takes the turn over _store.Publish(state); @@ -269,7 +269,7 @@ public async Task CancellationAfterAcquisitionEmitsTheContractAndDoesNotRenewThe Assert.AreEqual(InteractiveDesktopLock.CancelledExitCode, exitCode, "a cancelled command reports 130, not an internal error"); StringAssert.Contains(errorWriter.ToString(), "\"code\":\"cancelled\""); - Assert.AreEqual(deadlineBefore, ReadOwnerDeadline(), + Assert.AreEqual(0, ReadOwnerDeadline(), "a command that produced nothing must not renew the owner's idle grace"); } @@ -288,7 +288,7 @@ public async Task ABodyThatFailsCoordinationDoesNotRenewTheOwnersGrace() UiCoordinationErrorCodes.Unavailable, "active.lock could not be opened"))); Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code); - Assert.AreEqual(deadlineBefore, ReadOwnerDeadline(), + Assert.AreEqual(0, ReadOwnerDeadline(), "a command that never ran must not renew the owner's idle grace"); } @@ -426,7 +426,7 @@ public async Task UnknownNewerSchemaFailsParticipatingCommandsAndAllowsDetachedO [TestMethod] public async Task InvalidExplicitOwnerIdFailsBeforeAnyUiSideEffect() { - Environment.SetEnvironmentVariable(UiOwnerResolver.OwnerIdVariable, " "); + Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, " "); var ran = false; var ex = await Assert.ThrowsExactlyAsync(() => @@ -436,7 +436,7 @@ public async Task InvalidExplicitOwnerIdFailsBeforeAnyUiSideEffect() return Task.FromResult(0); })); - Assert.AreEqual(UiCoordinationErrorCodes.InvalidOwnerId, ex.Code); + Assert.AreEqual(UiCoordinationErrorCodes.InvalidWorkflowId, ex.Code); Assert.IsFalse(ran, "the command body must never run with an unusable owner identity"); Assert.IsFalse(_participants.AnyLiveParticipant(), "no lease may be left behind"); } @@ -469,7 +469,7 @@ private FileStream OccupyTurnWithAnotherOwner(int foreignPid = 424242, long fore var state = InteractiveDesktopState.CreateFresh(); state.TurnId = 1; state.NextTicket = 2; - state.Owner = new OwnerRecord { Kind = UiOwnerKind.Explicit, Key = "some-other-workflow" }; + state.Owner = new OwnerRecord { Kind = UiOwnerKind.Workflow, Key = "some-other-workflow" }; state.OwnerCommands.Add(new OwnerCommandEntry { Ticket = 1, @@ -596,45 +596,6 @@ private Task RunAsyncWithToken( // ------------------------------------------------------ escalation must not swallow coordination - [TestMethod] - public async Task EscalationCancelledWhileQueuedExitsOneThirtyAndLeavesNoTrace() - { - // Exactly what `ui screenshot` does: an observational pass discovers it needs the foreground and - // escalates. Here the escalation queues behind another owner and is cancelled. - using var foreignLease = OccupyTurnWithAnotherOwner(); - var deadlineBefore = ReadOwnerDeadline(); - - var errorWriter = new StringWriter(); - var parseResult = ParseWithWriter(errorWriter); - - using var cts = new CancellationTokenSource(); - var escalated = false; - var queued = _coordinator.RunCoordinatedAsync( - UiTurnMode.Observe, "ui screenshot", parseResult, - async (turn, token) => - { - await turn.EscalateToDesktopExclusiveAsync(token); - escalated = true; - return 0; - }, - cts.Token); - - await Task.Delay(250); - await cts.CancelAsync(); - - Assert.AreEqual(InteractiveDesktopLock.CancelledExitCode, await queued, - "a cancelled escalation is a cancellation, not an internal error"); - Assert.IsFalse(escalated, "the body never resumed, so it produced no image"); - StringAssert.Contains(errorWriter.ToString(), "\"code\":\"cancelled\""); - - using var stateLock = _store.AcquireStateLock(CancellationToken.None); - var state = _store.Read().State!; - Assert.AreEqual(0, state.Waiters.Count, "the escalation's ticket must be removed"); - Assert.AreEqual(deadlineBefore, state.IdleExpiresTick64, - "a command cancelled while queued never ran, so it must renew no grace — neither its own " - + "(it never held the turn) nor the current owner's"); - } - [TestMethod] public async Task AnActiveRecordingThatFinalizesOnCancellationStillRenewsTheGrace() { @@ -664,28 +625,6 @@ public async Task AnActiveRecordingThatFinalizesOnCancellationStillRenewsTheGrac "a recording that finalized and returned successfully must renew its owner's idle grace"); } - [TestMethod] - public async Task EscalationAgainstUnknownNewerStateReportsCoordinationUnavailable() - { - _paths.EnsureDirectories(); - using (var stateLock = _store.AcquireStateLock(CancellationToken.None)) - { - var state = InteractiveDesktopState.CreateFresh(); - state.Version = int.MaxValue; - _store.Publish(state); - } - - var ex = await Assert.ThrowsExactlyAsync(() => - RunAsync(UiTurnMode.Observe, "ui screenshot", async (turn, token) => - { - await turn.EscalateToDesktopExclusiveAsync(token); - return 0; - })); - - Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code, - "escalating against state written by a newer build must fail closed, not run uncoordinated"); - } - private long ReadOwnerDeadline() { using var stateLock = _store.AcquireStateLock(CancellationToken.None); @@ -715,102 +654,4 @@ private static ParseResult ParseWithWriter(TextWriter errorWriter) /// keeping the lease open would publish liveness for a participant with no state entry, and /// completing later would adjust a different owner's turn. /// - [TestMethod] - public async Task ObserveWhoseOwnerLapsesDuringAdmissionRunsFullyDetached() - { - const int parentPid = 4242; - const long parentStart = 777_777; - - // Parent-derived ownership, so the fake inspector controls exactly when the turn lapses. - Environment.SetEnvironmentVariable(UiOwnerResolver.OwnerIdVariable, null); - var inspector = new ParentDiesAfterFirstProbeInspector(parentPid, parentStart); - var paths = new InteractiveDesktopPaths(inspector); - var participants = new ParticipantRegistry(paths, inspector, NullLogger.Instance); - var store = new InteractiveDesktopStateStore( - paths, participants, new TickCountClock(), NullLogger.Instance); - var coordinator = new InteractiveDesktopLock( - store, paths, participants, new UiOwnerResolver(inspector), inspector, - new TickCountClock(), new FakePollDelay(), new TestConsole(), - NullLogger.Instance); - - paths.EnsureDirectories(); - using (var stateLock = store.AcquireStateLock(CancellationToken.None)) - { - var state = InteractiveDesktopState.CreateFresh(); - state.TurnId = 3; - state.Owner = new OwnerRecord - { - Kind = UiOwnerKind.Parent, - Key = UiOwnerResolver.ComputeParentKey(parentPid, parentStart), - DiagnosticParentPid = parentPid, - ParentStartTicksUtc = parentStart, - }; - state.IdleExpiresTick64 = new TickCountClock().NowTicks64 + InteractiveDesktopScheduler.IdleGraceMs; - store.Publish(state); - } - - var leaseLiveDuringBody = true; - var exitCode = await coordinator.RunCoordinatedAsync( - UiTurnMode.Observe, "ui inspect", Parse(), - (_, _) => - { - // The decisive check. By teardown the lease is closed either way, so the only moment the - // bug is observable is while the detached body runs: a detached observation must hold no - // lease at all, or other processes see liveness for a participant that has no entry. - leaseLiveDuringBody = participants.AnyLiveParticipant(); - return Task.FromResult(0); - }, - CancellationToken.None); - - Assert.AreEqual(0, exitCode, "a detached observation still runs; it simply pins nothing"); - Assert.IsTrue( - inspector.ParentProbes >= 2, - "the test is only meaningful if the owner check and the admission both probed the parent"); - Assert.IsFalse( - leaseLiveDuringBody, - "the lease opened before admission must be closed as soon as the admission came back detached"); - - using (var stateLock = store.AcquireStateLock(CancellationToken.None)) - { - var state = store.Read().State!; - Assert.AreEqual(0, state.OwnerCommands.Count, - "a detached observation must publish no owner-command entry"); - } - - Assert.IsFalse(participants.AnyLiveParticipant(), - "the lease must also be gone once the command has finished"); - } - - /// - /// Reports the owning shell as alive for the first probe and gone afterwards, so a turn lapses at a - /// precisely known point instead of depending on wall-clock timing. - /// - private sealed class ParentDiesAfterFirstProbeInspector(int parentPid, long parentStartTicks) : IProcessInspector - { - private readonly ProcessInspector _real = new(); - - public int ParentProbes { get; private set; } - - public int CurrentProcessId => _real.CurrentProcessId; - - public long CurrentProcessStartTicksUtc => _real.CurrentProcessStartTicksUtc; - - public int CurrentSessionId => _real.CurrentSessionId; - - public int? TryGetParentProcessId() => parentPid; - - public long? TryGetProcessStartTicksUtc(int processId) - => processId == parentPid ? parentStartTicks : _real.TryGetProcessStartTicksUtc(processId); - - public bool? IsProcessAlive(int processId, long startTicksUtc) - { - if (processId != parentPid || startTicksUtc != parentStartTicks) - { - return _real.IsProcessAlive(processId, startTicksUtc); - } - - ParentProbes++; - return ParentProbes <= 1; - } - } } \ No newline at end of file diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.cs index 28558f615..35c2faaca 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.cs @@ -57,11 +57,11 @@ public void Setup() _lockDirectory = Path.Combine(Path.GetTempPath(), $"winapp-mp-{Guid.NewGuid():N}"); _previousLockOverride = Environment.GetEnvironmentVariable( InteractiveDesktopPaths.LockDirectoryOverrideVariable); - _previousOwnerId = Environment.GetEnvironmentVariable(UiOwnerResolver.OwnerIdVariable); + _previousOwnerId = Environment.GetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable); Environment.SetEnvironmentVariable( InteractiveDesktopPaths.LockDirectoryOverrideVariable, _lockDirectory); - Environment.SetEnvironmentVariable(UiOwnerResolver.OwnerIdVariable, "multiprocess-test-holder"); + Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, "multiprocess-test-holder"); var inspector = new ProcessInspector(); _paths = new InteractiveDesktopPaths(inspector); @@ -69,7 +69,7 @@ public void Setup() _store = new InteractiveDesktopStateStore( _paths, _participants, new TickCountClock(), NullLogger.Instance); _coordinator = new InteractiveDesktopLock( - _store, _paths, _participants, new UiOwnerResolver(inspector), inspector, + _store, _paths, _participants, new UiOwnerResolver(), inspector, new TickCountClock(), new FakePollDelay(), new TestConsole(), NullLogger.Instance); } @@ -96,7 +96,7 @@ public void Cleanup() Environment.SetEnvironmentVariable( InteractiveDesktopPaths.LockDirectoryOverrideVariable, _previousLockOverride); - Environment.SetEnvironmentVariable(UiOwnerResolver.OwnerIdVariable, _previousOwnerId); + Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, _previousOwnerId); try { @@ -128,7 +128,7 @@ private Process StartQueuedClick(string ownerId) ArgumentList = { "ui", "click", "some-selector", "-a", "winapp-no-such-app-zzz", "--json" }, }; startInfo.Environment[InteractiveDesktopPaths.LockDirectoryOverrideVariable] = _lockDirectory; - startInfo.Environment[UiOwnerResolver.OwnerIdVariable] = ownerId; + startInfo.Environment[UiOwnerResolver.WorkflowIdVariable] = ownerId; startInfo.Environment["WINAPP_CLI_UPDATE_CHECK"] = "0"; var child = Process.Start(startInfo)!; @@ -335,7 +335,7 @@ public async Task AnObservationFromAnotherOwnerRunsConcurrentlyWithATurn() ArgumentList = { "ui", "list-windows", "--json" }, }; startInfo.Environment[InteractiveDesktopPaths.LockDirectoryOverrideVariable] = _lockDirectory; - startInfo.Environment[UiOwnerResolver.OwnerIdVariable] = "multiprocess-test-observer"; + startInfo.Environment[UiOwnerResolver.WorkflowIdVariable] = "multiprocess-test-observer"; startInfo.Environment["WINAPP_CLI_UPDATE_CHECK"] = "0"; using var observer = Process.Start(startInfo)!; @@ -394,7 +394,7 @@ public async Task CorruptStateIsNotResetWhileAnotherProcessIsLive() } [TestMethod] - public async Task AnInvalidOwnerIdIsRejectedByTheRealBinaryBeforeAnyWork() + public async Task AnInvalidWorkflowIdIsRejectedByTheRealBinaryBeforeAnyWork() { var startInfo = new ProcessStartInfo(_winappPath) { @@ -405,7 +405,7 @@ public async Task AnInvalidOwnerIdIsRejectedByTheRealBinaryBeforeAnyWork() ArgumentList = { "ui", "click", "some-selector", "-a", "winapp-no-such-app-zzz", "--json" }, }; startInfo.Environment[InteractiveDesktopPaths.LockDirectoryOverrideVariable] = _lockDirectory; - startInfo.Environment[UiOwnerResolver.OwnerIdVariable] = " "; + startInfo.Environment[UiOwnerResolver.WorkflowIdVariable] = " "; startInfo.Environment["WINAPP_CLI_UPDATE_CHECK"] = "0"; using var child = Process.Start(startInfo)!; @@ -413,7 +413,7 @@ public async Task AnInvalidOwnerIdIsRejectedByTheRealBinaryBeforeAnyWork() var output = child.StandardError.ReadToEnd() + child.StandardOutput.ReadToEnd(); Assert.AreEqual(1, child.ExitCode); - StringAssert.Contains(output, UiCoordinationErrorCodes.InvalidOwnerId); + StringAssert.Contains(output, UiCoordinationErrorCodes.InvalidWorkflowId); Assert.IsFalse(_participants.AnyLiveParticipant(), "a rejected command must leave no lease behind"); await Task.CompletedTask; diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs index ff4514e73..2afb24113 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs @@ -15,7 +15,7 @@ namespace WinApp.Cli.Tests; /// Unlike the scheduler and store suites, nothing here is faked. A real window (, /// hosted on this test process's UI thread, with a genuine MenuStrip whose drop-down Windows /// dismisses on foreground loss) plays the target app, and every agent is a separate real -/// winapp.exe process carrying its own WINAPP_UI_OWNER_ID. The properties under test — +/// winapp.exe process carrying its own WINAPP_UI_WORKFLOW_ID. The properties under test — /// a transient menu surviving another agent's attempt to act, a reasoning gap handing the turn away, /// and a recording pinning its owner — are only meaningful end to end, so they are asserted against /// observable desktop state rather than scheduler internals. @@ -62,7 +62,7 @@ public void Setup() _previousLockOverride = Environment.GetEnvironmentVariable( InteractiveDesktopPaths.LockDirectoryOverrideVariable); - _previousOwnerId = Environment.GetEnvironmentVariable(UiOwnerResolver.OwnerIdVariable); + _previousOwnerId = Environment.GetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable); Environment.SetEnvironmentVariable( InteractiveDesktopPaths.LockDirectoryOverrideVariable, _lockDirectory); @@ -111,7 +111,7 @@ public void Cleanup() Environment.SetEnvironmentVariable( InteractiveDesktopPaths.LockDirectoryOverrideVariable, _previousLockOverride); - Environment.SetEnvironmentVariable(UiOwnerResolver.OwnerIdVariable, _previousOwnerId); + Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, _previousOwnerId); foreach (var directory in new[] { _lockDirectory, _scratchDirectory }) { @@ -156,7 +156,7 @@ private AgentRun StartAgent(string ownerId, params string[] args) } startInfo.Environment[InteractiveDesktopPaths.LockDirectoryOverrideVariable] = _lockDirectory; - startInfo.Environment[UiOwnerResolver.OwnerIdVariable] = ownerId; + startInfo.Environment[UiOwnerResolver.WorkflowIdVariable] = ownerId; startInfo.Environment["WINAPP_CLI_UPDATE_CHECK"] = "0"; var process = Process.Start(startInfo)!; @@ -195,7 +195,7 @@ private InteractiveDesktopState ReadState() /// The persisted owner key for an explicit owner id. Raw ids never reach disk — state.json /// stores only the domain-separated SHA-256 — so tests must compare against the hash. ///
- private static string KeyOf(string ownerId) => UiOwnerResolver.ComputeExplicitKey(ownerId); + private static string KeyOf(string ownerId) => UiOwnerResolver.ComputeWorkflowKey(ownerId); private async Task WaitForStateAsync(Func predicate, int timeoutMs = 20_000) { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs index b13ca997c..39dc48971 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs @@ -18,8 +18,8 @@ public class InteractiveDesktopSchedulerTests private FakeLivenessProbe _probe = null!; private InteractiveDesktopScheduler _scheduler = null!; - private static readonly UiOwnerIdentity OwnerA = new(UiOwnerKind.Explicit, "aaaa", null, null); - private static readonly UiOwnerIdentity OwnerB = new(UiOwnerKind.Explicit, "bbbb", null, null); + private static readonly UiOwnerIdentity OwnerA = new(UiOwnerKind.Workflow, "aaaa"); + private static readonly UiOwnerIdentity OwnerB = new(UiOwnerKind.Workflow, "bbbb"); [TestInitialize] public void Setup() @@ -422,7 +422,7 @@ public void Handoff_SkipsDeadWaitersAndPicksTheOldestLiveTicket() var deadWaiter = Participant(200); _scheduler.BeginParticipating(state, _probe, OwnerB, deadWaiter, UiTurnMode.DesktopExclusive); - var ownerC = new UiOwnerIdentity(UiOwnerKind.Explicit, "cccc", null, null); + var ownerC = new UiOwnerIdentity(UiOwnerKind.Workflow, "cccc"); var liveWaiter = Participant(300); _scheduler.BeginParticipating(state, _probe, ownerC, liveWaiter, UiTurnMode.DesktopExclusive); @@ -491,7 +491,7 @@ public void Cancellation_DoesNotRenewTheGrace() public void AnonymousOwner_ReceivesNoGraceAndHandsOffImmediately() { var state = InteractiveDesktopState.CreateFresh(); - var anonymous = new UiOwnerIdentity(UiOwnerKind.Anonymous, "anon", null, null); + var anonymous = new UiOwnerIdentity(UiOwnerKind.Anonymous, "anon"); var actor = Participant(100); _scheduler.BeginParticipating(state, _probe, anonymous, actor, UiTurnMode.DesktopExclusive); var waiter = Participant(200); @@ -504,38 +504,6 @@ public void AnonymousOwner_ReceivesNoGraceAndHandsOffImmediately() "a one-command owner has no shell that could issue a follow-up, so it hands off at once"); } - [TestMethod] - public void ParentDerivedOwner_ReleasesImmediatelyWhenItsShellIsGone() - { - var state = InteractiveDesktopState.CreateFresh(); - var parentOwner = new UiOwnerIdentity(UiOwnerKind.Parent, "parent", 900, 900); - var actor = Participant(100); - _scheduler.BeginParticipating(state, _probe, parentOwner, actor, UiTurnMode.DesktopExclusive); - _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); - _scheduler.CompleteCommand(state, _probe, actor, parentOwner, renewGrace: true); - - _probe.DeadParents.Add((900, 900)); - _scheduler.Normalize(state, _probe); - - Assert.IsNull(state.Owner, "no further command can arrive from a shell that has exited"); - } - - [TestMethod] - public void ParentDerivedOwner_KeepsGraceWhenParentLivenessIsUnknown() - { - var state = InteractiveDesktopState.CreateFresh(); - var parentOwner = new UiOwnerIdentity(UiOwnerKind.Parent, "parent", 900, 900); - var actor = Participant(100); - _scheduler.BeginParticipating(state, _probe, parentOwner, actor, UiTurnMode.DesktopExclusive); - _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); - _scheduler.CompleteCommand(state, _probe, actor, parentOwner, renewGrace: true); - - _probe.UnknownParents.Add((900, 900)); - _scheduler.Normalize(state, _probe); - - Assert.IsNotNull(state.Owner, "an unreadable parent must never be treated as a dead one"); - } - // ------------------------------------------------------------------------------ pruning rules [TestMethod] @@ -564,7 +532,7 @@ public void SuspendedLiveWaiter_IsNeverPrunedAndKeepsTheHeadOfTheQueue() // for dead and nothing can overtake it (spec §19). var suspended = Participant(200); _scheduler.BeginParticipating(state, _probe, OwnerB, suspended, UiTurnMode.DesktopExclusive); - var ownerC = new UiOwnerIdentity(UiOwnerKind.Explicit, "cccc", null, null); + var ownerC = new UiOwnerIdentity(UiOwnerKind.Workflow, "cccc"); _scheduler.BeginParticipating(state, _probe, ownerC, Participant(300), UiTurnMode.DesktopExclusive); _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); @@ -591,7 +559,7 @@ public void QueueCap_AppliesOnlyAfterDeadWaitersArePruned() var waiter = Participant(1_000 + i); queued.Add(waiter); _scheduler.BeginParticipating( - state, _probe, new UiOwnerIdentity(UiOwnerKind.Explicit, $"owner{i}", null, null), + state, _probe, new UiOwnerIdentity(UiOwnerKind.Workflow, $"owner{i}"), waiter, UiTurnMode.DesktopExclusive); } @@ -613,7 +581,7 @@ public void QueueCapFailure_LeavesNoStateEntryBehind() for (var i = 0; i < InteractiveDesktopScheduler.MaxGlobalWaiters; i++) { _scheduler.BeginParticipating( - state, _probe, new UiOwnerIdentity(UiOwnerKind.Explicit, $"owner{i}", null, null), + state, _probe, new UiOwnerIdentity(UiOwnerKind.Workflow, $"owner{i}"), Participant(1_000 + i), UiTurnMode.DesktopExclusive); } @@ -670,7 +638,7 @@ public void PromotedOwner_AbsorbsOnlyItsContiguousPrefixAtTheQueueHead() // Queue order B, B, C: nothing separates the two B commands, so both may be absorbed. var firstB = Participant(200); var secondB = Participant(201); - var ownerC = new UiOwnerIdentity(UiOwnerKind.Explicit, "cccc", null, null); + var ownerC = new UiOwnerIdentity(UiOwnerKind.Workflow, "cccc"); var firstC = Participant(300); _scheduler.BeginParticipating(state, _probe, OwnerB, firstB, UiTurnMode.DesktopExclusive); _scheduler.BeginParticipating(state, _probe, OwnerB, secondB, UiTurnMode.DesktopExclusive); @@ -696,7 +664,7 @@ public void PromotedOwner_StopsAbsorbingAtTheFirstDifferentOwner() // Queue order B, C, B. Absorbing the trailing B would let it run before C even though C has the // older ticket, which would break strict global FIFO. var firstB = Participant(200); - var ownerC = new UiOwnerIdentity(UiOwnerKind.Explicit, "cccc", null, null); + var ownerC = new UiOwnerIdentity(UiOwnerKind.Workflow, "cccc"); var firstC = Participant(300); var secondB = Participant(201); _scheduler.BeginParticipating(state, _probe, OwnerB, firstB, UiTurnMode.DesktopExclusive); @@ -722,43 +690,6 @@ public void PromotedOwner_StopsAbsorbingAtTheFirstDifferentOwner() // ---------------------------------------------------------------------------- escalation - [TestMethod] - public void Escalation_ConvertsTheObservationInPlaceWithANewTicket() - { - var state = InteractiveDesktopState.CreateFresh(); - var actor = Participant(100); - _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); - _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); - _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true); - - var screenshot = Participant(101, "ui screenshot"); - _scheduler.BeginObserve(state, _probe, OwnerA, screenshot); - var beforeTicket = state.NextTicket; - - Assert.IsTrue(_scheduler.EscalateObserveToExclusive(state, _probe, screenshot)); - - var entry = InteractiveDesktopScheduler.FindOwnerCommand(state, screenshot)!; - Assert.AreEqual(UiTurnMode.DesktopExclusive, entry.Mode); - Assert.AreEqual(beforeTicket, entry.Ticket, - "priority starts at escalation time, not when the observational pass began"); - Assert.AreEqual(1, state.OwnerCommands.Count, - "the same entry is reused, so no intermediate state lacks this command"); - } - - [TestMethod] - public void Escalation_QueuesBehindAnEarlierExclusiveCommand() - { - var state = InteractiveDesktopState.CreateFresh(); - _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive); - var screenshot = Participant(101, "ui screenshot"); - _scheduler.BeginObserve(state, _probe, OwnerA, screenshot); - - _scheduler.EscalateObserveToExclusive(state, _probe, screenshot); - - Assert.AreEqual(UiCommandStatus.Waiting, - InteractiveDesktopScheduler.FindOwnerCommand(state, screenshot)!.Status); - } - // -------------------------------------------------------------------------- ticket monotonicity [TestMethod] @@ -787,7 +718,7 @@ public void PriorBootDeadline_ExpiresImmediatelyInsteadOfStrandingTheTurn() // a deadline far beyond the new uptime; without clamping, the owner that died with the previous // boot would hold the desktop until the machine had been up just as long again. var state = InteractiveDesktopState.CreateFresh(); - state.Owner = new OwnerRecord { Kind = UiOwnerKind.Explicit, Key = OwnerA.Key }; + state.Owner = new OwnerRecord { Kind = UiOwnerKind.Workflow, Key = OwnerA.Key }; state.TurnId = 7; state.IdleExpiresTick64 = _clock.NowTicks64 + (long)TimeSpan.FromDays(5).TotalMilliseconds; @@ -801,7 +732,7 @@ public void PriorBootDeadline_ExpiresImmediatelyInsteadOfStrandingTheTurn() public void PriorBootDeadline_PromotesAWaitingOwnerImmediately() { var state = InteractiveDesktopState.CreateFresh(); - state.Owner = new OwnerRecord { Kind = UiOwnerKind.Explicit, Key = OwnerA.Key }; + state.Owner = new OwnerRecord { Kind = UiOwnerKind.Workflow, Key = OwnerA.Key }; state.TurnId = 7; state.NextTicket = 1; state.IdleExpiresTick64 = _clock.NowTicks64 + (long)TimeSpan.FromDays(5).TotalMilliseconds; @@ -845,7 +776,7 @@ public void AForeignCompletionCannotRevokeTheCurrentOwnersGrace() var graceBefore = state.IdleExpiresTick64; Assert.IsNotNull(state.Owner); - var anonymous = new UiOwnerIdentity(UiOwnerKind.Anonymous, "anon", null, null); + var anonymous = new UiOwnerIdentity(UiOwnerKind.Anonymous, "anon"); var stranger = Participant(200); _scheduler.BeginParticipating(state, _probe, anonymous, stranger, UiTurnMode.DesktopExclusive); _probe.Alive.Remove((stranger.ProcessId, stranger.StartTicksUtc)); @@ -898,6 +829,139 @@ public void TheCurrentOwnersOwnCompletionStillRenewsItsGrace() "a burst from the owner keeps renewing its own grace"); } + + [TestMethod] + public void WorkflowOwner_KeepsFourSecondGraceAfterCompletion() + { + var state = InteractiveDesktopState.CreateFresh(); + var actor = Participant(100); + + _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true); + + Assert.AreEqual(_clock.NowTicks64 + InteractiveDesktopScheduler.IdleGraceMs, state.IdleExpiresTick64); + Assert.AreEqual(OwnerA.Key, state.Owner!.Key); + } + + [TestMethod] + public void AnonymousOwner_SetsDeadlineToNowOnCompletion() + { + var state = InteractiveDesktopState.CreateFresh(); + var anonymous = new UiOwnerIdentity(UiOwnerKind.Anonymous, "anon-a"); + var actor = Participant(100); + + _scheduler.BeginParticipating(state, _probe, anonymous, actor, UiTurnMode.DesktopExclusive); + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, anonymous, renewGrace: true); + + Assert.IsNull(state.Owner, "normalization should expire the now-deadline immediately"); + Assert.AreEqual(0, state.IdleExpiresTick64); + } + + [TestMethod] + public void UiOwnerResolver_NoWorkflowId_MintsDistinctAnonymousOwners() + { + var previous = Environment.GetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable); + Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, null); + try + { + var resolver = new UiOwnerResolver(); + var first = resolver.Resolve(); + var second = resolver.Resolve(); + + Assert.AreEqual(UiOwnerKind.Anonymous, first.Kind); + Assert.AreEqual(UiOwnerKind.Anonymous, second.Kind); + Assert.AreNotEqual(first.Key, second.Key); + } + finally + { + Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, previous); + } + } + + [TestMethod] + public void AnonymousTurnShared_BlocksDifferentAnonymousDesktopExclusive() + { + var state = InteractiveDesktopState.CreateFresh(); + var recordingOwner = new UiOwnerIdentity(UiOwnerKind.Anonymous, "anon-record"); + var clickOwner = new UiOwnerIdentity(UiOwnerKind.Anonymous, "anon-click"); + + _scheduler.BeginParticipating(state, _probe, recordingOwner, Participant(100, "ui record"), UiTurnMode.TurnShared); + var click = _scheduler.BeginParticipating( + state, _probe, clickOwner, Participant(200, "ui click"), UiTurnMode.DesktopExclusive); + + Assert.AreEqual(UiAdmission.GlobalWaiter, click.Admission); + Assert.AreEqual(recordingOwner.Key, state.Owner!.Key); + } + + [TestMethod] + public void SameWorkflowTurnSharedAndDesktopExclusiveOverlap() + { + var state = InteractiveDesktopState.CreateFresh(); + + var record = _scheduler.BeginParticipating( + state, _probe, OwnerA, Participant(100, "ui record"), UiTurnMode.TurnShared); + var click = _scheduler.BeginParticipating( + state, _probe, OwnerA, Participant(101, "ui click"), UiTurnMode.DesktopExclusive); + + Assert.AreEqual(UiAdmission.OwnerCommandRunning, record.Admission); + Assert.AreEqual(UiAdmission.OwnerCommandRunning, click.Admission); + Assert.AreEqual(0, state.Waiters.Count); + } + + [TestMethod] + public void CurrentOwnerAffinityBeatsForeignWaiterUntilOwnerYields() + { + var state = InteractiveDesktopState.CreateFresh(); + var first = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, first, UiTurnMode.DesktopExclusive); + var foreignB = Participant(200); + _scheduler.BeginParticipating(state, _probe, OwnerB, foreignB, UiTurnMode.DesktopExclusive); + + _probe.Alive.Remove((first.ProcessId, first.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, first, OwnerA, renewGrace: true); + + var returning = _scheduler.BeginParticipating( + state, _probe, OwnerA, Participant(101), UiTurnMode.DesktopExclusive); + Assert.AreEqual(UiAdmission.OwnerCommandRunning, returning.Admission, + "same workflow continuation joins during grace instead of queueing behind a foreign waiter"); + + var ownerC = new UiOwnerIdentity(UiOwnerKind.Workflow, "cccc"); + var foreignC = Participant(300); + _scheduler.BeginParticipating(state, _probe, ownerC, foreignC, UiTurnMode.DesktopExclusive); + + foreach (var command in state.OwnerCommands.ToArray()) + { + _probe.Alive.Remove((command.Pid, command.ProcessStartTicksUtc)); + _scheduler.CompleteCommand( + state, + _probe, + new UiParticipantIdentity(command.Pid, command.ProcessStartTicksUtc, command.Operation), + OwnerA, + renewGrace: false); + } + + Assert.AreEqual(OwnerB.Key, state.Owner!.Key, "foreign waiters remain FIFO after the current owner yields"); + Assert.IsNotNull(InteractiveDesktopScheduler.FindWaiter(state, foreignC)); + } + + [TestMethod] + public void PersistedGraceSurvivesWithoutLiveLease() + { + var state = InteractiveDesktopState.CreateFresh(); + var actor = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, actor, UiTurnMode.DesktopExclusive); + + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, OwnerA, renewGrace: true); + _scheduler.Normalize(state, _probe); + + Assert.AreEqual(OwnerA.Key, state.Owner!.Key); + Assert.AreEqual(0, state.OwnerCommands.Count); + } + + private sealed class FakeClock : IMonotonicClock { private long _ticks = 1_000_000; @@ -921,21 +985,7 @@ private sealed class FakeLivenessProbe : ICoordinationLivenessProbe { public HashSet<(int Pid, long Start)> Alive { get; } = []; - public HashSet<(int Pid, long Start)> DeadParents { get; } = []; - - public HashSet<(int Pid, long Start)> UnknownParents { get; } = []; - public bool IsParticipantLive(int processId, long startTicksUtc) => Alive.Contains((processId, startTicksUtc)); - - public bool? IsParentAlive(int processId, long startTicksUtc) - { - if (UnknownParents.Contains((processId, startTicksUtc))) - { - return null; - } - - return !DeadParents.Contains((processId, startTicksUtc)); - } } } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs index 1550616af..9d11cf2c1 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs @@ -199,9 +199,9 @@ public void Read_DuplicateTicketAcrossOwnerCommandsAndWaiters_IsCorrupt() { // Ticket 5 appearing in both lists would make two commands share one barrier position. WriteRawState(""" - {"version":1,"turnId":1,"nextTicket":9,"owner":{"kind":"explicit","key":"a"}, + {"version":1,"turnId":1,"nextTicket":9,"owner":{"kind":"workflow","key":"a"}, "ownerCommands":[{"ticket":5,"pid":10,"processStartTicksUtc":1,"operation":"ui click","mode":"DesktopExclusive","status":"running"}], - "waiters":[{"ticket":5,"ownerKey":"b","ownerKind":"explicit","pid":11,"processStartTicksUtc":2,"operation":"ui click","mode":"DesktopExclusive"}]} + "waiters":[{"ticket":5,"ownerKey":"b","ownerKind":"workflow","pid":11,"processStartTicksUtc":2,"operation":"ui click","mode":"DesktopExclusive"}]} """); AssertRecovered(); @@ -212,7 +212,7 @@ public void Read_NextTicketNotAheadOfEveryPersistedTicket_IsCorrupt() { // nextTicket 5 would re-issue ticket 5 and collide with the running command. WriteRawState(""" - {"version":1,"turnId":1,"nextTicket":5,"owner":{"kind":"explicit","key":"a"}, + {"version":1,"turnId":1,"nextTicket":5,"owner":{"kind":"workflow","key":"a"}, "ownerCommands":[{"ticket":5,"pid":10,"processStartTicksUtc":1,"operation":"ui click","mode":"DesktopExclusive","status":"running"}], "waiters":[]} """); @@ -238,7 +238,7 @@ public void Read_ObserveEntryCarryingATicket_IsCorrupt() // Observations never serialize as barriers, so a ticket on one is meaningless and would be // compared against real barrier tickets. WriteRawState(""" - {"version":1,"turnId":1,"nextTicket":9,"owner":{"kind":"explicit","key":"a"}, + {"version":1,"turnId":1,"nextTicket":9,"owner":{"kind":"workflow","key":"a"}, "ownerCommands":[{"ticket":5,"pid":10,"processStartTicksUtc":1,"operation":"ui inspect","mode":"Observe","status":"running"}], "waiters":[]} """); @@ -250,7 +250,7 @@ public void Read_ObserveEntryCarryingATicket_IsCorrupt() public void Read_OutOfRangeEnumValue_IsCorrupt() { WriteRawState(""" - {"version":1,"turnId":1,"nextTicket":9,"owner":{"kind":"explicit","key":"a"}, + {"version":1,"turnId":1,"nextTicket":9,"owner":{"kind":"workflow","key":"a"}, "ownerCommands":[{"ticket":5,"pid":10,"processStartTicksUtc":1,"operation":"ui click","mode":42,"status":"running"}], "waiters":[]} """); @@ -265,7 +265,7 @@ public void Publish_RoundTripsStateAndPreservesUnknownFieldsFromANewerWriter() { _paths.EnsureDirectories(); WriteRawState(""" - {"version":1,"turnId":3,"nextTicket":9,"owner":{"kind":"explicit","key":"a","futureOwnerField":"keep-me"}, + {"version":1,"turnId":3,"nextTicket":9,"owner":{"kind":"workflow","key":"a","futureOwnerField":"keep-me"}, "ownerCommands":[],"waiters":[],"futureRootField":{"nested":true}} """); @@ -526,10 +526,6 @@ private sealed class FakeProcessInspector : IProcessInspector public long CurrentProcessStartTicksUtc => _real.CurrentProcessStartTicksUtc; - public int? TryGetParentProcessId() => _real.TryGetParentProcessId(); - - public long? TryGetProcessStartTicksUtc(int processId) => _real.TryGetProcessStartTicksUtc(processId); - public bool? IsProcessAlive(int processId, long startTicksUtc) => _real.IsProcessAlive(processId, startTicksUtc); } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.cs index ef6910696..0fe20950f 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.cs @@ -132,7 +132,7 @@ public async Task RecordAsync_WgcSeams_EncodesTimedFramesAndReportsResult() Fps = 2, MaxEdge = 64, CaptureScreen = false, - }, NullDesktopSection.Instance, CancellationToken.None, _ => started++); + }, CancellationToken.None, _ => started++); Assert.AreEqual("wgc", result.Mode); Assert.AreEqual(2, result.Frames); @@ -174,7 +174,7 @@ public async Task RecordAsync_WgcClosedDrainsLatestFrameBeforeStopping() Fps = 5, MaxEdge = 0, CaptureScreen = false, - }, NullDesktopSection.Instance, CancellationToken.None); + }, CancellationToken.None); Assert.AreEqual(1, result.Frames, "closed WGC items should drain the cached frame once before finalizing"); Assert.AreEqual("wgc", result.Mode); @@ -208,7 +208,7 @@ public async Task RecordAsync_CaptureScreen_UsesConsentedScreenPath() Fps = 1, MaxEdge = 64, CaptureScreen = true, - }, NullDesktopSection.Instance, CancellationToken.None); + }, CancellationToken.None); Assert.AreEqual("screen", result.Mode); Assert.AreEqual(1, result.Frames); @@ -243,7 +243,7 @@ public async Task RecordAsync_PrintWindowFallback_UsesBlankRetryCapture() Fps = 1, MaxEdge = 64, CaptureScreen = false, - }, NullDesktopSection.Instance, CancellationToken.None); + }, CancellationToken.None); Assert.AreEqual("printwindow", result.Mode); Assert.AreEqual(1, result.Frames); @@ -279,7 +279,7 @@ public async Task RecordAsync_FrameArtifacts_UseTheProcessedMp4FrameStream() Fps = 2, MaxEdge = 64, CaptureScreen = false, - }, NullDesktopSection.Instance, CancellationToken.None); + }, CancellationToken.None); Assert.AreEqual(2, result.Frames); Assert.IsNotNull(result.FrameArtifacts); @@ -321,7 +321,7 @@ public async Task RecordAsync_FirstMp4WriteFailure_PreservesAcceptedFrameArtifac DurationSec = 1, Fps = 1, MaxEdge = 64, - }, NullDesktopSection.Instance, CancellationToken.None)); + }, CancellationToken.None)); Assert.IsNotNull(exception.FramesDirectory); StringAssert.StartsWith(exception.FramesDirectory, framesDirectory + ".partial-"); @@ -369,7 +369,7 @@ public async Task RecordAsync_Mp4PublicationRaceDoesNotPublishMismatchedFrames() DurationSec = 1, Fps = 1, MaxEdge = 64, - }, NullDesktopSection.Instance, CancellationToken.None)); + }, CancellationToken.None)); Assert.AreEqual("winning recording", await File.ReadAllTextAsync(output)); Assert.IsFalse(Directory.Exists(framesDirectory)); @@ -411,7 +411,7 @@ public async Task RecordAsync_FrameAndMp4FailurePreservesNeitherArtifact() DurationSec = 1, Fps = 1, MaxEdge = 64, - }, NullDesktopSection.Instance, CancellationToken.None)); + }, CancellationToken.None)); StringAssert.Contains(exception.InnerException!.Message, "frame failure"); Assert.IsFalse(Directory.EnumerateFileSystemEntries(root).Any()); @@ -454,7 +454,7 @@ public async Task RecordAsync_CancellationAfterProcessedFrameCommitsSampleBefore DurationSec = 10, Fps = 1, MaxEdge = 64, - }, NullDesktopSection.Instance, cts.Token); + }, cts.Token); Assert.AreEqual(1, result.Frames); Assert.AreEqual(1, frameSink.SampleCount); @@ -494,7 +494,7 @@ public async Task RecordAsync_JpegWorkerFailurePreservesMp4AndRemovesFrameStagin DurationSec = 1, Fps = 1, MaxEdge = 64, - }, NullDesktopSection.Instance, CancellationToken.None)); + }, CancellationToken.None)); Assert.AreEqual(output, exception.VideoPath); Assert.IsTrue(File.Exists(output)); @@ -544,7 +544,7 @@ public async Task RecordAsync_NonIoFrameFailurePreservesMp4(bool failOnComplete) DurationSec = 1, Fps = 1, MaxEdge = 64, - }, NullDesktopSection.Instance, CancellationToken.None)); + }, CancellationToken.None)); Assert.AreEqual(output, exception.VideoPath); Assert.IsTrue(File.Exists(output)); @@ -581,7 +581,7 @@ public async Task RecordAsync_TruncatedFrameBundleReturnsActionableWarning() DurationSec = 1, Fps = 1, MaxEdge = 64, - }, NullDesktopSection.Instance, CancellationToken.None); + }, CancellationToken.None); Assert.IsNotNull(result.FrameArtifacts); Assert.IsTrue(result.FrameArtifacts.Truncated); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.CaptureForeground.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.CaptureForeground.cs index 7f819320f..d7b6b238a 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.CaptureForeground.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.CaptureForeground.cs @@ -24,7 +24,7 @@ namespace WinApp.Cli.Tests; ///
public partial class UiCommandTests { - private static CaptureForegroundNotTargetException CaptureRefusal() + private static ForegroundLostException CaptureRefusal() => new("Target window is not in the foreground — refusing to capture the screen."); [TestMethod] @@ -46,7 +46,7 @@ public async Task Screenshot_CaptureScreenForegroundRefused_ReportsForegroundNot [TestMethod] public async Task Record_CaptureScreenForegroundRefused_ReportsForegroundNotTarget() { - _fakeUia.RecordException = CaptureRefusal(); + _fakeRecording.RecordException = CaptureRefusal(); var outputPath = Path.Combine(_tempDirectory.FullName, "decoy.mp4"); var command = GetRequiredService(); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs index e103d444f..318268ae5 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs @@ -140,64 +140,20 @@ public async Task Scroll_ClassifiesByTransport() } [TestMethod] - public async Task Screenshot_ClassifiesByWhetherItNeedsTheForeground() + public async Task Screenshot_IsAlwaysDesktopExclusive() { _fakeUia.ScreenshotResult = (new byte[4 * 4 * 4], 4, 4); var output = Path.Combine(_tempDirectory.FullName, "shot.png"); var command = GetRequiredService(); await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--output", output, "--json"]); - Assert.AreEqual(UiTurnMode.Observe, _fakeDesktopLock.Runs[0].Mode); + Assert.AreEqual(UiTurnMode.DesktopExclusive, _fakeDesktopLock.Runs[0].Mode); _fakeDesktopLock.Runs.Clear(); await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--focus", "--output", output, "--json"]); Assert.AreEqual(UiTurnMode.DesktopExclusive, _fakeDesktopLock.Runs[0].Mode); } - [TestMethod] - public async Task Screenshot_LetsACancelledEscalationEscapeInsteadOfReportingAnInternalError() - { - // The handler's catch-all must not swallow cancellation raised by escalation: the user would see - // internal_error instead of a cancellation, and the coordinator would treat the body as having - // completed and renew the owner's grace for a command that never captured anything. - _fakeUia.ScreenshotResult = (new byte[4 * 4 * 4], 4, 4); - _fakeUia.ScreenshotThrow = new WinApp.Cli.Services.DesktopEscalationRequiredException("the target window is minimized"); - _fakeDesktopLock.ThrowOnEscalation = new OperationCanceledException(); - - var command = GetRequiredService(); - var output = Path.Combine(_tempDirectory.FullName, "cancelled.png"); - _ = await ParseAndInvokeWithCaptureAsync( - command, ["-a", "TestApp", "--output", output, "--json"]); - - var emitted = $"{ConsoleStdOut}{ConsoleStdErr}"; - Assert.AreEqual(1, _fakeDesktopLock.Escalations, "the observational pass must have forced an escalation"); - Assert.IsTrue( - string.IsNullOrWhiteSpace(emitted), - "the handler must report nothing for a cancelled escalation and let it propagate, so the " - + $"coordinator can emit the structured cancellation and exit 130. It emitted: {emitted}"); - Assert.IsFalse(File.Exists(output), "a cancelled capture must publish no image"); - } - - [TestMethod] - public async Task Screenshot_ReportsCoordinationUnavailableRatherThanInternalErrorWhenEscalationFails() - { - _fakeUia.ScreenshotResult = (new byte[4 * 4 * 4], 4, 4); - _fakeUia.ScreenshotThrow = new WinApp.Cli.Services.DesktopEscalationRequiredException("the target window is minimized"); - _fakeDesktopLock.ThrowOnEscalation = new UiCoordinationException( - UiCoordinationErrorCodes.Unavailable, "newer state", "update winapp"); - - var command = GetRequiredService(); - var output = Path.Combine(_tempDirectory.FullName, "unavailable.png"); - - var exitCode = await ParseAndInvokeWithCaptureAsync( - command, ["-a", "TestApp", "--output", output, "--json"]); - - Assert.AreEqual(1, exitCode); - StringAssert.Contains($"{ConsoleStdOut}{ConsoleStdErr}", UiCoordinationErrorCodes.Unavailable, - "a coordination failure must keep its own error code and remediation"); - Assert.IsFalse(File.Exists(output), "no image may be published when escalation was refused"); - } - // ------------------------------------------------- desktop-section placement and revalidation [TestMethod] @@ -296,7 +252,7 @@ public async Task SendKeys_WithoutATargetStillValidatesTheSessionWindow() { // With no --target the session HWND was captured before the queue wait, so it needs the same // check: nothing re-resolves it on the way in. - _fakeSession.SessionResult = new UiSessionInfo + _fakeTargetResolver.TargetResult = new UiTarget { ProcessId = 1234, ProcessName = "TestApp", @@ -451,7 +407,7 @@ public async Task Record_CoordinationFailureInsideTheBody_SurfacesTheCoordinatio // `ui record` opens its desktop section from inside the handler's broad catch-all. Without the // IsCoordinationFault filter, an active.lock failure was reported as `internal_error` and — worse // — looked to the coordinator like a normal body return, renewing the owner's idle grace. - _fakeUia.RecordException = new UiCoordinationException( + _fakeRecording.RecordException = new UiCoordinationException( UiCoordinationErrorCodes.Unavailable, "The UI desktop lock could not be opened."); var outputPath = Path.Combine(_tempDirectory.FullName, "coordination-fault.mp4"); @@ -468,7 +424,7 @@ public async Task Record_OrdinaryFailureInsideTheBody_StaysWithTheHandler() { // The filter must be narrow: only coordination faults escape. An ordinary capture failure keeps // its existing handler-owned envelope and never turns into a coordination error. - _fakeUia.RecordException = new InvalidOperationException("encoder blew up"); + _fakeRecording.RecordException = new InvalidOperationException("encoder blew up"); var outputPath = Path.Combine(_tempDirectory.FullName, "ordinary-fault.mp4"); var command = GetRequiredService(); @@ -503,7 +459,7 @@ public async Task Record_CancelledBeforeCaptureStarted_DoesNotReportItselfAsACom using var cts = new CancellationTokenSource(); await cts.CancelAsync(); - _fakeUia.RecordException = new OperationCanceledException(cts.Token); + _fakeRecording.RecordException = new OperationCanceledException(cts.Token); var outputPath = Path.Combine(_tempDirectory.FullName, "cancelled-pre-start.mp4"); var command = GetRequiredService(); @@ -528,8 +484,8 @@ public async Task Record_ThatFinalizesOnCancellationAndReturnsSuccess_StillCompl // The positive half of the same contract, kept explicit so the fix above cannot be "simplified" // into propagating every cancellation: an ACTIVE recording observes Ctrl+C, finalizes its MP4 and // returns success. That is a completed command and must keep renewing the owner's grace. - _fakeUia.RecordResult = new RecordCaptureResult { Frames = 3, Width = 64, Height = 64, Mode = "wgc" }; - _fakeUia.RecordShouldWaitForCancellation = true; + _fakeRecording.RecordResult = new RecordCaptureResult { Frames = 3, Width = 64, Height = 64, Mode = "wgc" }; + _fakeRecording.RecordShouldWaitForCancellation = true; using var stdin = new StringReader("stop"); UiRecordCommand.Handler.s_isInputRedirectedOverride = () => true; @@ -549,7 +505,7 @@ public async Task Record_ThatFinalizesOnCancellationAndReturnsSuccess_StillCompl { UiRecordCommand.Handler.s_isInputRedirectedOverride = null; UiRecordCommand.Handler.s_stdinOverride = null; - _fakeUia.RecordShouldWaitForCancellation = false; + _fakeRecording.RecordShouldWaitForCancellation = false; } } @@ -571,7 +527,7 @@ public async Task SendKeys_ForegroundLostDuringFocus_RefusesToSend() // First gate (before focus) passes; the second (after focus) denies, modelling the drift. _fakeForeground.DenyOnCallNumber = 2; - _fakeForeground.DenyCode = UiJsonError.CodeForegroundNotTarget; + _fakeForeground.DenyReason = ForegroundCheck.ForegroundNotTarget; _fakeUia.OnFocus = () => { /* a focus handler activates a decoy window */ }; var command = GetRequiredService(); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Command.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Command.cs index 03dc15274..04b436a47 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Command.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Command.cs @@ -128,6 +128,100 @@ public async Task Record_Success_EmitsRecordResultJson() Assert.IsTrue(File.Exists(outputPath), "record should have produced an output file"); } + + [TestMethod] + public async Task Record_ReleasesDesktopSectionAfterStartedCallback() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var unblock = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _fakeRecording.AfterRecordingStarted = () => started.SetResult(); + _fakeRecording.WaitAfterRecordingStarted = unblock.Task; + + var outputPath = Path.Combine(_tempDirectory.FullName, "started-release.mp4"); + var command = GetRequiredService(); + var running = ParseAndInvokeWithCaptureAsync( + command, ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath, "--json"]); + + await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + SpinWait.SpinUntil(() => _fakeDesktopLock.OpenDesktopSections == 0, TimeSpan.FromSeconds(5)); + Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters); + Assert.AreEqual(0, _fakeDesktopLock.OpenDesktopSections); + + unblock.SetResult(); + Assert.AreEqual(0, await running); + } + + [TestMethod] + public async Task Record_FailureBeforeStartedCallback_ReleasesDesktopSection() + { + _fakeRecording.RecordException = new InvalidOperationException("capture failed before first frame"); + + var outputPath = Path.Combine(_tempDirectory.FullName, "before-start.mp4"); + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath, "--json"]); + + Assert.AreEqual(1, exitCode); + Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters); + Assert.AreEqual(0, _fakeDesktopLock.OpenDesktopSections); + } + + [TestMethod] + public async Task Record_DuplicateStartedCallback_DoesNotReleaseTwiceOrThrow() + { + _fakeRecording.InvokeRecordingStartedTwice = true; + + var outputPath = Path.Combine(_tempDirectory.FullName, "duplicate-start.mp4"); + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath, "--json"]); + + Assert.AreEqual(0, exitCode); + Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters); + Assert.AreEqual(0, _fakeDesktopLock.OpenDesktopSections); + } + + [TestMethod] + public async Task Record_PrintWindowMode_HoldsSectionForWholeRecordingAndWarns() + { + _fakeWindowCapture.Supported = false; + _fakeRecording.RecordResult = new RecordCaptureResult { Frames = 5, Width = 100, Height = 100, Mode = "printwindow" }; + _fakeRecording.BeforeRecordingStarted = () => Assert.AreEqual(1, _fakeDesktopLock.OpenDesktopSections); + _fakeRecording.AfterRecordingStarted = () => Assert.AreEqual(1, _fakeDesktopLock.OpenDesktopSections); + + var outputPath = Path.Combine(_tempDirectory.FullName, "printwindow-warning.mp4"); + var command = GetRequiredService(); + var (exitCode, ambientOutput) = await InvokeWithAmbientConsoleCaptureAsync(command, ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath]); + + Assert.AreEqual(0, exitCode); + Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters); + Assert.AreEqual(0, _fakeDesktopLock.OpenDesktopSections); + StringAssert.Contains(ambientOutput, "PrintWindow"); + + _fakeDesktopLock.Runs.Clear(); + _fakeWindowCapture.Supported = false; + TestAnsiConsole.Clear(false); + outputPath = Path.Combine(_tempDirectory.FullName, "printwindow-warning-json.mp4"); + exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath, "--json"]); + + Assert.AreEqual(0, exitCode); + StringAssert.Contains(TestAnsiConsole.Output, "PrintWindow"); + } + + [TestMethod] + public async Task Record_PredictedModeDisagreement_IsSurfacedAsWarning() + { + _fakeWindowCapture.Supported = false; + _fakeRecording.RecordResult = new RecordCaptureResult { Frames = 5, Width = 100, Height = 100, Mode = "wgc" }; + + var outputPath = Path.Combine(_tempDirectory.FullName, "mode-disagreement.mp4"); + var command = GetRequiredService(); + var (exitCode, ambientOutput) = await InvokeWithAmbientConsoleCaptureAsync(command, ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath]); + + Assert.AreEqual(0, exitCode); + StringAssert.Contains(ambientOutput, "coordination planned for 'printwindow'"); + } + [TestMethod] public async Task Record_JsonMode_EmitsLivenessEventToStderr_NotToStdout() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Stdin.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Stdin.cs index cbfa78af0..af7c4b5a9 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Stdin.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Stdin.cs @@ -4,6 +4,7 @@ using WinApp.Cli.Commands; using WinApp.Cli.Helpers; using WinApp.Cli.Services; +using Microsoft.Extensions.Logging.Abstractions; using System.Security.AccessControl; using System.Security.Principal; @@ -353,7 +354,7 @@ public async Task StdinMonitor_DisposedCts_NeverThrowsUnhandledException() Interlocked.Exchange(ref unhandled, null); var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); - var handler = new UiRecordCommand.Handler(null!, null!, null!, null!, null!); + var handler = new UiRecordCommand.Handler(null!, null!, null!, null!, null!, NullLogger.Instance); cts.Dispose(); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.cs index a4261d7bc..45e179c04 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using WinApp.Cli.Commands; +using WinApp.Cli.Helpers; namespace WinApp.Cli.Tests; @@ -54,6 +55,10 @@ public async Task Screenshot_MultiWindowByPid_Json_EmitsWindowsArray() StringAssert.Contains(TestAnsiConsole.Output, "\"windows\":"); StringAssert.Contains(TestAnsiConsole.Output, "\"captured\": true"); Assert.IsTrue(File.Exists(path)); + Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters, + "the whole multi-window capture pass must run inside one desktop section"); + Assert.AreEqual(0, _fakeDesktopLock.OpenDesktopSections, + "the section must be closed before the command reports the written file"); } [TestMethod] @@ -89,6 +94,20 @@ public async Task Screenshot_MultiWindowAllCapturesFail_ReturnsError() StringAssert.Contains(TestAnsiConsole.Output, "✗"); } + [TestMethod] + public async Task Screenshot_ForegroundLost_WritesNoArtifact() + { + _fakeUia.ScreenshotThrow = new ForegroundLostException("target lost foreground"); + var path = ShotPath(); + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "--capture-screen", "--json", "-o", path]); + + Assert.AreEqual(1, exitCode); + AssertJsonErrorCodeIn(ConsoleStdErr.ToString(), UiJsonError.CodeForegroundNotTarget); + Assert.IsFalse(File.Exists(path)); + } + [TestMethod] public async Task Screenshot_SingleWindowWithOwnedDialog_Composites() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.cs index 24488bfa1..bce562652 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.cs @@ -3,8 +3,10 @@ using Microsoft.Extensions.DependencyInjection; using WinApp.Cli.Commands; +using WinApp.Cli.Helpers; using WinApp.Cli.Models; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; namespace WinApp.Cli.Tests; @@ -22,6 +24,9 @@ public partial class UiCommandTests : BaseCommandTests private FakeOwnedWindowFinder _fakeWindowFinder = null!; private FakeSystemUiQuery _fakeSystemQuery = null!; private FakePollDelay _fakePollDelay = null!; + private FakeInteractiveDesktopLock _fakeDesktopLock = null!; + private FakeDesktopForegroundService _fakeDesktopForeground = null!; + private FakeWindowCapture _fakeWindowCapture = null!; private void AssertJsonErrorCode(string expectedCode) => AssertJsonErrorCodeIn(ConsoleStdErr.ToString(), expectedCode); @@ -45,8 +50,11 @@ protected override IServiceCollection ConfigureServices(IServiceCollection servi _fakeForeground = new FakeForegroundGuard(); _fakePointer = new FakePointerInput(); _fakeWindowFinder = new FakeOwnedWindowFinder(); - _fakeSystemQuery = new FakeSystemUiQuery(); + _fakeSystemQuery = new FakeSystemUiQuery { ProcessIdForWindowResult = 1234 }; _fakePollDelay = new FakePollDelay(); + _fakeDesktopLock = new FakeInteractiveDesktopLock(); + _fakeDesktopForeground = new FakeDesktopForegroundService(); + _fakeWindowCapture = new FakeWindowCapture(); return services .AddSingleton(_fakeUia) .AddSingleton(_fakeRecording) @@ -57,7 +65,10 @@ protected override IServiceCollection ConfigureServices(IServiceCollection servi .AddSingleton(_fakePointer) .AddSingleton(_fakeWindowFinder) .AddSingleton(_fakeSystemQuery) - .AddSingleton(_fakePollDelay); + .AddSingleton(_fakePollDelay) + .AddSingleton(_fakeDesktopLock) + .AddSingleton(_fakeDesktopForeground) + .AddSingleton(_fakeWindowCapture); } [TestMethod] diff --git a/src/winapp-CLI/WinApp.UIAutomation.TestSupport/FakeUiServices.cs b/src/winapp-CLI/WinApp.UIAutomation.TestSupport/FakeUiServices.cs index f438119ce..bb4998c03 100644 --- a/src/winapp-CLI/WinApp.UIAutomation.TestSupport/FakeUiServices.cs +++ b/src/winapp-CLI/WinApp.UIAutomation.TestSupport/FakeUiServices.cs @@ -206,9 +206,13 @@ public Task InvokeAsync(UiTarget uiTarget, UiElement element, Cancellati { throw new InvalidOperationException("Element does not support an actionable pattern (test)."); } + LastInvokedElement = element; return Task.FromResult(InvokeResult); } + /// Last element passed to . + public UiElement? LastInvokedElement { get; private set; } + public Task SetValueAsync(UiTarget uiTarget, UiElement element, string text, CancellationToken ct) { if (SetValueThrow is not null) { throw SetValueThrow; } @@ -218,9 +222,17 @@ public Task SetValueAsync(UiTarget uiTarget, UiElement element, string text, Can public Task FocusAsync(UiTarget uiTarget, UiElement element, CancellationToken ct) { if (FocusThrow is not null) { throw FocusThrow; } + LastFocusedElement = element; + OnFocus?.Invoke(); return Task.CompletedTask; } + /// Last element passed to . + public UiElement? LastFocusedElement { get; private set; } + + /// Optional callback invoked during . + public Action? OnFocus { get; set; } + public Task ScrollIntoViewAsync(UiTarget uiTarget, UiElement element, CancellationToken ct) { if (ScrollIntoViewThrow is not null) { throw ScrollIntoViewThrow; } @@ -454,6 +466,9 @@ public sealed class FakeSystemUiQuery : ISystemUiQuery /// PID returned by . Default 0 = "window not found". public uint ProcessIdForWindowResult { get; set; } + /// Per-HWND PID lookup; unmapped handles fall back to . + public Dictionary ProcessIdByHwnd { get; } = []; + /// Title returned by . Default null = "no/empty title". public string? WindowTextResult { get; set; } @@ -489,7 +504,8 @@ public sealed class FakeSystemUiQuery : ISystemUiQuery public nint GetForegroundWindow() => ForegroundWindowResult; - public uint GetProcessIdForWindow(long hwnd) => ProcessIdForWindowResult; + public uint GetProcessIdForWindow(long hwnd) + => ProcessIdByHwnd.TryGetValue(hwnd, out var pid) ? pid : ProcessIdForWindowResult; public string? GetWindowText(long hwnd) => WindowTextResult; diff --git a/src/winapp-CLI/WinApp.UIAutomation.Tests/CaptureForegroundSafetyTests.cs b/src/winapp-CLI/WinApp.UIAutomation.Tests/CaptureForegroundSafetyTests.cs new file mode 100644 index 000000000..eb9848e60 --- /dev/null +++ b/src/winapp-CLI/WinApp.UIAutomation.Tests/CaptureForegroundSafetyTests.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Windows.SDK.BuildTools.WinApp.UIAutomation.Recording; +using Microsoft.Windows.SDK.BuildTools.WinApp.UIAutomation.TestSupport; +using Windows.Win32.Foundation; + +namespace Microsoft.Windows.SDK.BuildTools.WinApp.UIAutomation.Tests; + +[TestClass] +[DoNotParallelize] +public class CaptureForegroundSafetyTests +{ + [TestCleanup] + public void Cleanup() + { + ForegroundGuard.ResetNativeSeams(); + UiAutomationService.ResetNativeSeams(); + } + + [TestMethod] + public async Task ScreenshotAsync_CaptureScreen_ThrowsWhenTargetIsNotForeground() + { + using var fx = new UiaTestFixture(); + var service = NewAutomationService(); + var target = TargetFor(fx); + ForegroundGuard.s_getForegroundWindow = () => new HWND(0); + + await Assert.ThrowsExactlyAsync( + () => service.ScreenshotAsync(target, null, captureScreen: true, focus: false, CancellationToken.None)); + } + + [TestMethod] + public async Task RecordAsync_FirstScreenFrame_ThrowsWhenTargetIsNotForeground() + { + using var fx = new UiaTestFixture(); + var recorder = NewRecordingService(); + var target = TargetFor(fx); + ForegroundGuard.s_getForegroundWindow = () => new HWND(0); + + await Assert.ThrowsExactlyAsync( + () => recorder.RecordAsync(target, null, new RecordOptions + { + OutputPath = Path.Combine(AppContext.BaseDirectory, "coverage-scratch", Guid.NewGuid().ToString("N"), "foreground.mp4"), + CaptureScreen = true, + DurationSec = 1, + Fps = 1, + MaxEdge = 64, + }, CancellationToken.None)); + } + + private static IUiAutomation NewAutomationService() + => new ServiceCollection() + .AddSingleton(NullLoggerFactory.Instance) + .AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)) + .AddWinAppUiAutomation() + .BuildServiceProvider() + .GetRequiredService(); + + private static IUiRecordingService NewRecordingService() + => new ServiceCollection() + .AddSingleton(NullLoggerFactory.Instance) + .AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)) + .AddWinAppUiAutomation() + .AddSingleton() + .AddWinAppUiRecording() + .BuildServiceProvider() + .GetRequiredService(); + + private static UiTarget TargetFor(UiaTestFixture fx) => new() + { + ProcessId = fx.ProcessId, + ProcessName = "WinApp.UIAutomation.Tests", + WindowHandle = fx.Hwnd, + WindowTitle = fx.Title, + IsExplicitWindow = true, + }; + + private sealed class SafeFakeWindowCapture : IWindowCapture + { + public bool IsFrameCaptureSupported => false; + + public IFrameGrabber StartFrameGrabber(nint hwnd, int fps = 0) + => throw new InvalidOperationException("Foreground safety should fail before WGC starts."); + + public byte[] CaptureWindowPixels(nint hwnd, int width, int height) + => new byte[Math.Max(0, width * height * 4)]; + + public byte[] CaptureScreenPixels( + int x, int y, int cropWidth, int cropHeight, + int encoderWidth, int encoderHeight, + int displayWidth, int displayHeight) + => throw new InvalidOperationException("Foreground safety should fail before screen capture."); + } +} diff --git a/src/winapp-CLI/WinApp.UIAutomation.Tests/UiTargetResolverTests.cs b/src/winapp-CLI/WinApp.UIAutomation.Tests/UiTargetResolverTests.cs index 51a20f376..03a084f21 100644 --- a/src/winapp-CLI/WinApp.UIAutomation.Tests/UiTargetResolverTests.cs +++ b/src/winapp-CLI/WinApp.UIAutomation.Tests/UiTargetResolverTests.cs @@ -25,7 +25,7 @@ private static (UiTargetResolver Service, FakeUiAutomationService Uia, FakeSyste } [TestMethod] - public void UiSessionInfo_IsExplicitWindow_DefaultsToFalse() + public void UiTarget_IsExplicitWindow_DefaultsToFalse() { var info = new UiTarget(); Assert.IsFalse(info.IsExplicitWindow); diff --git a/src/winapp-CLI/WinApp.UIAutomation.Tests/WinApp.UIAutomation.Tests.csproj b/src/winapp-CLI/WinApp.UIAutomation.Tests/WinApp.UIAutomation.Tests.csproj index 322a4dba7..45aa3e470 100644 --- a/src/winapp-CLI/WinApp.UIAutomation.Tests/WinApp.UIAutomation.Tests.csproj +++ b/src/winapp-CLI/WinApp.UIAutomation.Tests/WinApp.UIAutomation.Tests.csproj @@ -21,7 +21,9 @@ + + From df176002580da3a0c2f5371a786136418d6e0611 Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:06:03 -0700 Subject: [PATCH 14/29] Satisfy the new screen-capture foreground precondition in the real recording test RecordAsync_CaptureScreen_UsesConsentedScreenPath drives the real recording service against a real fixture window, so the engine's new pre-capture foreground check applies to it. The test now brings the fixture to the foreground the way a user would, via the existing DesktopTestHelpers.ForceForeground, and reports inconclusive only if the session refuses activation outright - the refusal path itself is covered directly by CaptureForegroundSafetyTests. The foreground question is asked through the shipped ForegroundGuard rather than a private P/Invoke so the test asks exactly what the engine asks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../WinApp.Cli.Tests/RealRecordingTests.Helpers.cs | 12 ++++++++++++ .../WinApp.Cli.Tests/RealRecordingTests.cs | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.Helpers.cs b/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.Helpers.cs index 2446b30a5..65083ebe5 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.Helpers.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.Helpers.cs @@ -14,6 +14,18 @@ public partial class RealRecordingTests { private const int ReadyTimeoutMs = 10_000; + /// + /// Whether the fixture window (or its top-level root) currently owns the foreground. + /// + /// + /// Uses the shipped guard rather than a private P/Invoke so the test asks the exact question the + /// engine asks before it agrees to capture the screen. A session that refuses activation — a + /// locked desktop, or an agent host running the suite in the background — cannot exercise the + /// screen path at all, and the caller reports that rather than failing on it. + /// + private static bool ForegroundBelongsToFixture(UiaTestFixture fixture) + => ForegroundGuard.ForegroundBelongsTo(fixture.Hwnd); + /// Real UI Automation engine, resolved the same way the CLI resolves it. private static TestAutomation NewAutomation() => new(new ServiceCollection() diff --git a/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.cs index 0fe20950f..47f53366e 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.cs @@ -191,6 +191,18 @@ public async Task RecordAsync_CaptureScreen_UsesConsentedScreenPath() var uiTarget = SessionFor(fx); await ResolveAsync(svc, uiTarget, "btnInvoke"); + // A screen-DC recording captures whatever is genuinely in front, so the engine now refuses to + // start one unless the target actually reached the foreground. That is a real precondition of + // this capture mode, not test scaffolding — satisfy it the same way a user would. + DesktopTestHelpers.ForceForeground(fx.Hwnd); + if (!ForegroundBelongsToFixture(fx)) + { + Assert.Inconclusive( + "The fixture window could not be brought to the foreground on this session, so the " + + "consented screen path cannot be exercised. Foreground refusal itself is covered by " + + "CaptureForegroundSafetyTests, which drives the check directly."); + } + var output = Path.Combine(AppContext.BaseDirectory, "coverage-scratch", Guid.NewGuid().ToString("N"), "screen.mp4"); Directory.CreateDirectory(Path.GetDirectoryName(output)!); var screenCalls = 0; From 0060c56a6b9c851334798ed47f70eb811eccb2a9 Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Thu, 3 Sep 2026 04:54:21 -0700 Subject: [PATCH 15/29] Close six ways a coordinated UI command could act on the wrong thing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An independent review of the integrated feature found six defects that all share a shape: something is checked once, and then acted on later as if the check still held. Each is fixed at the point of use, and each fix has a test that fails without it. A blocked "recording started" write no longer holds the desktop. The engine raises its first-frame callback on the capture thread, and the CLI both signalled its own "the desktop is free" gate and wrote the liveness JSON from inside it. Writing first meant a caller slow to drain stderr — a full pipe, a paused consumer — pinned the capture thread in the callback, so the gate never fired and the section stayed open for every other winapp ui command on the machine. The signal now goes first, which is what makes the release independent of the reader. Recording revalidates its target inside the section. The target is resolved before the command queues; by the time the turn is granted the window may have closed and Windows may have reused its handle. Without the check the command recorded the new occupant and exited 0 — a video of the wrong application is indistinguishable from a correct one. The multi-window screenshot composite had the same hole and never validated any handle, because it branches to the composite path above the single-window check; each handle is now classified before capture and a recycled one is reported as not captured instead of being spliced into the image. ui wait-for propagates cancellation. Its dedicated catch returned an exit code, and the coordinator decides whether to renew the owner's idle grace from whether the body returned or threw — so an interrupted wait renewed grace for a workflow that had already stopped. Worse, the inner polling catch swallowed cancellation too, so --gone could report the element as gone: a success, invented from a Ctrl+C. Screen recording checks the foreground once more before any file exists. The existing check ran right after the activation delay, with selector resolution still to come; it now sits immediately above encoder creation, which is the last moment a refusal leaves no MP4 behind — the contract the foreground_not_target path states. npm uiRecord forwards workflowId. Both wrappers passed only cwd and signal, so a recording started through the SDK became an anonymous one-shot and blocked its own workflow's clicks for the whole recording, which is the exact scenario workflow ids exist for. Also removes the last two references to the deleted owner-id contract — a sample that still set WINAPP_UI_OWNER_ID and an unused error constant mirroring it — and states on WINAPP_UI_LOCK_DIRECTORY that cooperating processes must agree on it, since the recovery hints that mention it previously did not say so. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- samples/winui-app/README.md | 7 +- .../DesktopTargetValidationTests.cs | 31 ++++ .../InteractiveDesktopLockTests.cs | 2 +- .../UiCommandTests.Coordination.cs | 23 +++ .../UiCommandTests.Record.Coordination.cs | 170 ++++++++++++++++++ .../UiCommandTests.Record.Stdin.cs | 2 +- .../UiCommandTests.Screenshot.cs | 40 +++++ .../WinApp.Cli/Commands/UiRecordCommand.cs | 40 ++++- .../Commands/UiScreenshotCommand.cs | 27 +++ .../WinApp.Cli/Commands/UiWaitForCommand.cs | 11 +- .../Helpers/DesktopTargetValidation.cs | 72 ++++++-- .../WinApp.Cli/Helpers/UiJsonError.cs | 4 +- .../InteractiveDesktopPaths.cs | 11 +- .../UiRecordingService.cs | 14 ++ src/winapp-npm/src/ui-record-guard.ts | 2 + src/winapp-npm/test/ui-record-guard.test.ts | 27 ++- 16 files changed, 442 insertions(+), 41 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Coordination.cs diff --git a/samples/winui-app/README.md b/samples/winui-app/README.md index 6b32158aa..9f60145ca 100644 --- a/samples/winui-app/README.md +++ b/samples/winui-app/README.md @@ -50,9 +50,10 @@ winapp run .\bin\x64\Debug\net10.0-windows10.0.26100.0\win-x64 --detach --json ## Testing with winapp ui ```powershell -# Set once per logical UI workflow, so these commands are recognized as belonging together and -# don't interleave with another workflow driving the same desktop. -$env:WINAPP_UI_OWNER_ID = [guid]::NewGuid().ToString() +# Optional: set once per logical UI workflow so these commands are treated as one workflow and can +# overlap with each other. Collision arbitration against other workflows is always on either way; +# this only adds continuity across commands. Unset, each command runs as a standalone one-shot. +$env:WINAPP_UI_WORKFLOW_ID = [guid]::NewGuid().ToString() # Inspect the UI tree winapp ui inspect -a winui-app diff --git a/src/winapp-CLI/WinApp.Cli.Tests/DesktopTargetValidationTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/DesktopTargetValidationTests.cs index a8abff983..3f272ca9e 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/DesktopTargetValidationTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/DesktopTargetValidationTests.cs @@ -161,4 +161,35 @@ public void UnknownExpectedProcessSkipsTheOwnershipCheck() Assert.IsTrue(Confirm(100, expectedPid: 0)); } + + // -------------------------------------------------- the silent verdict used for batched handles + + /// + /// The multi-window screenshot composite validates every handle it is about to capture. Each one + /// needs a verdict without writing a top-level error envelope, because a single unreachable window + /// is recorded against that window and the remaining ones are still captured — so the classifier + /// has to be reusable separately from the reporting. + /// + [TestMethod] + public void ClassifierAgreesWithTheReportingCheckOnEveryOutcome() + { + _systemQuery.ProcessIdByHwnd[100] = SessionPid; // straightforward match + _systemQuery.ProcessIdByHwnd[300] = ForeignPid; // recycled onto another process + _systemQuery.ProcessIdByHwnd[400] = ForeignPid; // owned dialog + _systemQuery.WindowOwnerByHwnd[400] = 100; + // 200 is deliberately unmapped: GetProcessIdForWindow reports 0, i.e. destroyed. + + Assert.AreEqual(DesktopTargetValidation.TargetWindowState.Valid, Classify(100)); + Assert.AreEqual(DesktopTargetValidation.TargetWindowState.Gone, Classify(200)); + Assert.AreEqual(DesktopTargetValidation.TargetWindowState.Recycled, Classify(300)); + Assert.AreEqual(DesktopTargetValidation.TargetWindowState.Valid, Classify(400)); + + // A zero handle is a bare-coordinate target, which has no window to confirm. + Assert.AreEqual(DesktopTargetValidation.TargetWindowState.Valid, Classify(0)); + + Assert.AreEqual(string.Empty, _errorOut.ToString(), "classifying must never emit an error envelope"); + } + + private DesktopTargetValidation.TargetWindowState Classify(long hwnd, int expectedPid = SessionPid) + => DesktopTargetValidation.ClassifyTargetWindow(_systemQuery, hwnd, expectedPid); } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs index 3dd774ceb..ab449bfdc 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs @@ -424,7 +424,7 @@ public async Task UnknownNewerSchemaFailsParticipatingCommandsAndAllowsDetachedO } [TestMethod] - public async Task InvalidExplicitOwnerIdFailsBeforeAnyUiSideEffect() + public async Task InvalidExplicitWorkflowIdFailsBeforeAnyUiSideEffect() { Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, " "); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs index 318268ae5..da58732ca 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs @@ -443,6 +443,29 @@ public async Task Record_OrdinaryFailureInsideTheBody_StaysWithTheHandler() // ------------------------------------------- pre-start recording cancellation must not renew grace + [TestMethod] + public async Task WaitFor_Cancelled_DoesNotReportItselfAsACompletedCommand() + { + // wait-for is the one polling command, so it is the one most likely to be interrupted: an agent + // that gives up on a slow app presses Ctrl+C mid-wait. Catching the cancellation and returning + // an exit code told the coordinator the command had completed, which renewed the owner's idle + // grace and kept foreign waiters queued behind a workflow that had already stopped. Worse, with + // --gone the swallowed cancellation could surface as "the element is gone" — a success. + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["Button1", "-a", "TestApp", "--json"], cts.Token); + + Assert.IsTrue(_fakeDesktopLock.LastBodyThrew, + "the body must propagate the cancellation; returning any exit code makes the coordinator treat " + + "an interrupted wait as a completed command and renew the owner's grace"); + Assert.IsInstanceOfType(_fakeDesktopLock.LastBodyException); + Assert.IsFalse(ConsoleStdErr.ToString().Contains("internal_error", StringComparison.Ordinal), + $"a cancellation is not an internal error (exit {exitCode})"); + } + [TestMethod] public async Task Record_CancelledBeforeCaptureStarted_DoesNotReportItselfAsACompletedCommand() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Coordination.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Coordination.cs new file mode 100644 index 000000000..6757f4066 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Coordination.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Text; +using WinApp.Cli.Commands; + +namespace WinApp.Cli.Tests; + +/// +/// How ui record hands the desktop back. Recording is the only command that keeps running +/// after it stops needing exclusive use of the desktop, so the moment it releases the section — and +/// what can delay that moment — is its own coverage area. +/// +public partial class UiCommandTests +{ + /// + /// A whose first WriteLine blocks until the test releases it, + /// standing in for a caller that reads the command's stderr slowly (a full pipe buffer, a paused + /// consumer, a debugger-attached parent). + /// + private sealed class BlockingWriter : TextWriter + { + private readonly ManualResetEventSlim _release = new(false); + private readonly ManualResetEventSlim _entered = new(false); + private int _writes; + + public override Encoding Encoding => Encoding.UTF8; + + /// Waits until the command is actually blocked inside the write. + public bool WaitUntilBlocked(TimeSpan timeout) => _entered.Wait(timeout); + + public void Release() => _release.Set(); + + public override void WriteLine(string? value) + { + if (Interlocked.Increment(ref _writes) == 1) + { + _entered.Set(); + _release.Wait(TimeSpan.FromSeconds(30)); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _release.Set(); + _release.Dispose(); + _entered.Dispose(); + } + + base.Dispose(disposing); + } + } + + /// + /// A blocked "recording-started" write must not keep the desktop locked. + /// + /// + /// The engine raises its first-frame callback on the capture thread, and the CLI both signals its + /// own "the desktop is free now" gate and writes the liveness JSON from inside that callback. If + /// the write happens first, a caller that is slow to drain stderr pins the capture thread inside + /// the callback; the gate is never signalled, the recording task never completes, and the section + /// stays open for as long as the reader is slow — blocking every other winapp ui command on the + /// machine. Signalling first makes the release independent of the write, which is what this + /// asserts: the section is observed closed while the writer is still blocked. + /// + [TestMethod] + public async Task Record_BlockedStartedNotification_DoesNotHoldTheDesktopSection() + { + var outputPath = Path.Combine(_tempDirectory.FullName, "blocked-notify.mp4"); + + // WGC, not PrintWindow: PrintWindow deliberately holds the section for the whole recording, + // so only the frame-capture path exercises the first-frame handoff this test is about. + _fakeWindowCapture.Supported = true; + + // Keep the fake recording running until the test says otherwise, so the only thing that can + // close the section is the first-frame handoff — not the recording simply ending. + var finishRecording = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _fakeRecording.WaitAfterRecordingStarted = finishRecording.Task; + + using var blockingStderr = new BlockingWriter(); + var command = GetRequiredService(); + var parseResult = command.Parse( + ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath, "--json"]); + parseResult.InvocationConfiguration.Output = TestAnsiConsole.Profile.Out.Writer; + parseResult.InvocationConfiguration.Error = blockingStderr; + + var invocation = Task.Run( + () => parseResult.InvokeAsync(parseResult.InvocationConfiguration, CancellationToken.None), + CancellationToken.None); + + Assert.IsTrue( + blockingStderr.WaitUntilBlocked(TimeSpan.FromSeconds(20)), + "the command should have reached the recording-started write"); + + // The write is still blocked right now. The section must already be closed anyway. + var releasedWhileBlocked = await WaitForConditionAsync( + () => _fakeDesktopLock.OpenDesktopSections == 0, + TimeSpan.FromSeconds(10)); + + blockingStderr.Release(); + finishRecording.TrySetResult(); + var exitCode = await invocation; + + Assert.IsTrue( + releasedWhileBlocked, + "the desktop section must be released from the first-frame signal alone; a caller that is " + + "slow to read stderr must not be able to hold the desktop."); + Assert.AreEqual(0, exitCode); + Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters, "recording takes exactly one section"); + } + + /// + /// A target whose handle was recycled while the command queued must be refused from inside the + /// section, before the engine is handed the stale handle. + /// + /// + /// The target is resolved before the command queues for the desktop. By the time the turn is + /// granted, the original window may have closed and Windows may have reused its handle for an + /// unrelated process — and a recording of the wrong application looks exactly like a correct one. + /// + [TestMethod] + public async Task Record_TargetHandleRecycledWhileQueued_RefusesAndRecordsNothing() + { + var outputPath = Path.Combine(_tempDirectory.FullName, "recycled.mp4"); + _fakeWindowCapture.Supported = true; + + const long hwnd = 777; + _fakeTargetResolver.TargetResult = new UiTarget + { + ProcessId = 1234, + ProcessName = "TestApp", + WindowTitle = "Test Window", + WindowHandle = hwnd, + }; + + // The window the command resolved is gone; the handle now belongs to an unrelated process + // and has no owner chain leading back to the expected one. + _fakeSystemQuery.ProcessIdByHwnd[hwnd] = 9999; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-a", "TestApp", "--duration-sec", "1", "-o", outputPath, "--json"]); + + Assert.AreEqual(1, exitCode); + AssertJsonErrorCode("stale_element"); + Assert.IsFalse(File.Exists(outputPath), "a refused recording must not leave an MP4 behind"); + Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters, + "the check belongs inside the section — it is only meaningful once the turn is held"); + Assert.AreEqual(0, _fakeDesktopLock.OpenDesktopSections, "the section must still be released"); + } + + private static async Task WaitForConditionAsync(Func condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (condition()) + { + return true; + } + + await Task.Delay(25, CancellationToken.None); + } + + return condition(); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Stdin.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Stdin.cs index af7c4b5a9..98d6269c3 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Stdin.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Record.Stdin.cs @@ -354,7 +354,7 @@ public async Task StdinMonitor_DisposedCts_NeverThrowsUnhandledException() Interlocked.Exchange(ref unhandled, null); var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); - var handler = new UiRecordCommand.Handler(null!, null!, null!, null!, null!, NullLogger.Instance); + var handler = new UiRecordCommand.Handler(null!, null!, null!, null!, null!, null!, NullLogger.Instance); cts.Dispose(); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.cs index 45e179c04..fe66a38da 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.cs @@ -45,6 +45,10 @@ public async Task Screenshot_MultiWindowByPid_Json_EmitsWindowsArray() { // Integer --app → FindWindowsByPid; two windows → composite multi-window capture. _fakeUia.WindowsByPidResult = [((nint)11, 4321, "Main"), ((nint)12, 4321, "Dialog")]; + // Both handles really do belong to the discovered process, which is what the OS reports when + // the command revalidates them before capturing. + _fakeSystemQuery.ProcessIdByHwnd[11] = 4321; + _fakeSystemQuery.ProcessIdByHwnd[12] = 4321; _fakeUia.ScreenshotResult = (new byte[4], 1, 1); var path = ShotPath(); @@ -61,12 +65,48 @@ public async Task Screenshot_MultiWindowByPid_Json_EmitsWindowsArray() "the section must be closed before the command reports the written file"); } + [TestMethod] + public async Task Screenshot_MultiWindow_RecycledHandle_IsSkippedNotComposited() + { + // Every handle in the composite was discovered before the command queued for the desktop. If + // one of those windows closed while waiting and Windows reused its handle, capturing it would + // splice an unrelated application's pixels into an image reported as this target's — and the + // per-window "captured" flag would claim it succeeded. + _fakeUia.WindowsByPidResult = [((nint)31, 4321, "Main"), ((nint)32, 4321, "Doomed")]; + _fakeSystemQuery.ProcessIdByHwnd[31] = 4321; + _fakeSystemQuery.ProcessIdByHwnd[32] = 9999; // recycled onto an unrelated process + _fakeUia.ScreenshotResult = (new byte[4], 1, 1); + var path = ShotPath(); + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "4321", "--json", "-o", path]); + + Assert.AreEqual(0, exitCode, "the surviving window is still captured"); + + var output = System.Text.Json.JsonSerializer.Deserialize(TestAnsiConsole.Output); + var windows = output.GetProperty("windows").EnumerateArray().ToList(); + Assert.HasCount(2, windows, "both windows are reported, so the omission is visible"); + + var recycled = windows.Single(w => w.GetProperty("hwnd").GetInt64() == 32); + Assert.IsFalse(recycled.GetProperty("captured").GetBoolean(), + "a recycled handle must never be reported as captured"); + StringAssert.Contains(recycled.GetProperty("error").GetString()!, "different process"); + + var survivor = windows.Single(w => w.GetProperty("hwnd").GetInt64() == 31); + Assert.IsTrue(survivor.GetProperty("captured").GetBoolean()); + + Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters, + "the whole multi-window capture pass must run inside one desktop section"); + } + [TestMethod] public async Task Screenshot_MultiWindowByName_NonJson_Composites() { // Exact current-process-name match → real process enumeration hits FindWindowsByPid per process. var procName = Process.GetCurrentProcess().ProcessName; _fakeUia.WindowsByPidResult = [((nint)21, 100, "A"), ((nint)22, 100, "B")]; + _fakeSystemQuery.ProcessIdByHwnd[21] = 100; + _fakeSystemQuery.ProcessIdByHwnd[22] = 100; _fakeUia.ScreenshotResult = (new byte[4], 1, 1); var path = ShotPath(); diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs index 1a82192e3..31b830b0d 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs @@ -45,6 +45,7 @@ public class Handler( IUiTargetResolver targetResolver, IUiRecordingService recordingService, IWindowCapture windowCapture, + ISystemUiQuery systemQuery, IAnsiConsole ansiConsole, IInteractiveDesktopLock desktopLock, ILogger logger) : UiCoordinatedAction(desktopLock, logger) @@ -263,7 +264,13 @@ void OnRecordingStarted(bool frameArtifactsActive) var (result, coordinationWarning) = await RecordUnderTurnAsync( turn, uiTarget, selector, options, captureScreen, json, quiet, - OnRecordingStarted, linkedCts.Token).ConfigureAwait(false); + OnRecordingStarted, parseResult.InvocationConfiguration.Error, linkedCts.Token).ConfigureAwait(false); + + if (result is null) + { + // The target was refused from inside the section and the reason already reported. + return 1; + } // Merged here rather than inside the helper because RecordCaptureResult is engine-owned // and immutable; the coordination note is a CLI concern layered on top of it. @@ -435,7 +442,7 @@ void OnRecordingStarted(bool frameArtifactsActive) /// that input cannot interleave on this host. /// /// - private async Task<(RecordCaptureResult Result, string? CoordinationWarning)> RecordUnderTurnAsync( + private async Task<(RecordCaptureResult? Result, string? CoordinationWarning)> RecordUnderTurnAsync( IUiTurn turn, UiTarget uiTarget, string? selector, @@ -444,8 +451,15 @@ void OnRecordingStarted(bool frameArtifactsActive) bool json, bool quiet, Action onRecordingStarted, + TextWriter errorOut, CancellationToken ct) { + // The target was resolved before this command queued for the desktop, so re-confirm it from + // inside the section: the window could have closed while waiting and had its handle reused, + // and a recording of the wrong application is indistinguishable from a correct one. + bool TargetStillValid() => DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, uiTarget.WindowHandle, uiTarget.ProcessId, logger, json, "record", errorOut); + // Mirrors the engine's own selection in UiRecordingService: screen DC when asked for, else WGC // when the host supports frame capture, else PrintWindow. Asserted against the result below so // this prediction cannot silently drift away from the engine. @@ -469,6 +483,11 @@ void OnRecordingStarted(bool frameArtifactsActive) RecordCaptureResult heldResult; await using (await turn.EnterAsync(ct).ConfigureAwait(false)) { + if (!TargetStillValid()) + { + return (null, null); + } + heldResult = await recordingService .RecordAsync(uiTarget, selector, options, ct, onRecordingStarted).ConfigureAwait(false); } @@ -484,10 +503,15 @@ void OnRecordingStarted(bool frameArtifactsActive) void OnStarted(bool frameArtifactsActive) { - // The callback only signals. It never disposes the section: disposal is async and belongs - // to this method, and doing it here would block the engine's capture loop on a file lock. - onRecordingStarted(frameArtifactsActive); + // Signal FIRST. The outer notification writes the "recording started" JSON, and a caller + // reading stderr slowly can block that write for an unbounded time. Setting the result + // first means the awaiting continuation (scheduled asynchronously, see above) can release + // the desktop section even while this engine thread is still stuck in that write — + // otherwise one slow reader would hold the desktop lock for every other command. + // The callback never disposes the section itself: disposal is async and belongs to this + // method, and doing it here would block the engine's capture loop on a file lock. startedTcs.TrySetResult(); + onRecordingStarted(frameArtifactsActive); } var section = await turn.EnterAsync(ct).ConfigureAwait(false); @@ -508,6 +532,12 @@ async Task ReleaseOnceAsync() try { + if (!TargetStillValid()) + { + // The finally below releases the section. + return (null, null); + } + var recordTask = recordingService.RecordAsync(uiTarget, selector, options, ct, OnStarted); // Race the two ways the desktop stops being needed: the first frame landed, or the diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs index 316851904..f9f019c3b 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs @@ -238,6 +238,33 @@ private async Task CaptureWindowsAsync( { var info = UiTargetResolver.GetWindowInfo(w.Hwnd); var title = string.IsNullOrEmpty(w.Title) ? "(no title)" : w.Title; + + // Each handle was discovered before this command waited for the desktop, so any of them + // could have closed and had its handle reused since. Capturing an unvalidated handle + // would composite another application's pixels into an image reported as this target's. + var state = DesktopTargetValidation.ClassifyTargetWindow(systemQuery, w.Hwnd, w.Pid); + if (state != DesktopTargetValidation.TargetWindowState.Valid) + { + var reason = state == DesktopTargetValidation.TargetWindowState.Gone + ? "The window closed while this command was waiting for the desktop." + : "The window handle now belongs to a different process."; + logger.LogDebug("Skipping HWND {Hwnd}: {Reason}", w.Hwnd, reason); + windowDetails.Add(new UiScreenshotWindowInfo + { + Hwnd = w.Hwnd, + Title = string.IsNullOrEmpty(w.Title) ? null : w.Title, + Label = info.Label, + Captured = false, + Error = reason, + }); + if (!json) + { + ansiConsole.MarkupLine($" [red]✗[/] HWND {w.Hwnd}: \"{Markup.Escape(title)}\" — {Markup.Escape(reason)}"); + } + + continue; + } + try { var windowTarget = new UiTarget diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiWaitForCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiWaitForCommand.cs index 9bdb767d6..8a7873705 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiWaitForCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiWaitForCommand.cs @@ -124,8 +124,11 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn { element = await uiAutomation.FindSingleElementAsync(uiTarget, selector, cancellationToken); } - catch + catch (Exception ex) when (!UiCoordinatedAction.IsCoordinationFault(ex)) { + // "Not found yet" is the normal case while polling, so a lookup failure just means + // keep waiting. Cancellation is not a lookup failure: swallowing it here would let + // --gone report the element as gone the moment the user pressed Ctrl+C. element = null; } @@ -238,12 +241,6 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn } return 1; } - catch (OperationCanceledException) - { - logger.LogError("Wait cancelled"); - UiJsonError.Emit(json, UiJsonError.CodeInternalError, "Wait cancelled"); - return 1; - } catch (System.Runtime.InteropServices.COMException comEx) { logger.LogDebug("COM error: {HResult} {StackTrace}", comEx.HResult, comEx.StackTrace); diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/DesktopTargetValidation.cs b/src/winapp-CLI/WinApp.Cli/Helpers/DesktopTargetValidation.cs index a7bea1365..c8aa409bc 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/DesktopTargetValidation.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/DesktopTargetValidation.cs @@ -37,41 +37,77 @@ public static bool TryConfirmTargetWindow( bool json, string action, TextWriter? errorOut = null) + { + switch (ClassifyTargetWindow(systemQuery, hwnd, expectedProcessId)) + { + case TargetWindowState.Gone: + logger.LogError( + "{Symbol} The target window closed while this command was waiting for the desktop — refusing to {Action}.", + UiSymbols.Error, action); + UiJsonError.Emit(json, UiJsonError.CodeStaleElement, + $"The target window no longer exists — refusing to {action}. Re-resolve the target and retry.", + errorOut: errorOut, + recoveryHint: "Another workflow may have closed the window while this command waited for the desktop. Re-run the discovery step (ui list-windows / ui search) and retry."); + return false; + + case TargetWindowState.Recycled: + logger.LogError( + "{Symbol} The target window handle now belongs to a different process — refusing to {Action}.", + UiSymbols.Error, action); + UiJsonError.Emit(json, UiJsonError.CodeStaleElement, + $"The target window handle now belongs to a different process — refusing to {action}. Re-resolve the target and retry.", + errorOut: errorOut, + recoveryHint: "The original window exited while this command waited for the desktop and Windows reused its handle. Re-run the discovery step and retry."); + return false; + + default: + return true; + } + } + + /// Outcome of re-checking a resolved window handle just before acting on it. + internal enum TargetWindowState + { + /// Still the window the command resolved. + Valid, + + /// The window was destroyed while the command waited. + Gone, + + /// The handle now names a window belonging to an unrelated process. + Recycled, + } + + /// + /// The non-emitting core of . Callers that validate several + /// handles in one pass — the multi-window screenshot composite — need the verdict per handle + /// without each one writing a top-level error envelope. + /// + internal static TargetWindowState ClassifyTargetWindow( + ISystemUiQuery systemQuery, + long hwnd, + int expectedProcessId) { if (hwnd == 0) { // A bare-coordinate target has no window to confirm; the foreground guard is the gate there. - return true; + return TargetWindowState.Valid; } var actualProcessId = systemQuery.GetProcessIdForWindow(hwnd); if (actualProcessId == 0) { - logger.LogError( - "{Symbol} The target window closed while this command was waiting for the desktop — refusing to {Action}.", - UiSymbols.Error, action); - UiJsonError.Emit(json, UiJsonError.CodeStaleElement, - $"The target window no longer exists — refusing to {action}. Re-resolve the target and retry.", - errorOut: errorOut, - recoveryHint: "Another workflow may have closed the window while this command waited for the desktop. Re-run the discovery step (ui list-windows / ui search) and retry."); - return false; + return TargetWindowState.Gone; } if (expectedProcessId > 0 && actualProcessId != (uint)expectedProcessId && !IsOwnedByExpectedProcess(systemQuery, hwnd, expectedProcessId)) { - logger.LogError( - "{Symbol} The target window handle now belongs to a different process — refusing to {Action}.", - UiSymbols.Error, action); - UiJsonError.Emit(json, UiJsonError.CodeStaleElement, - $"The target window handle now belongs to a different process — refusing to {action}. Re-resolve the target and retry.", - errorOut: errorOut, - recoveryHint: "The original window exited while this command waited for the desktop and Windows reused its handle. Re-run the discovery step and retry."); - return false; + return TargetWindowState.Recycled; } - return true; + return TargetWindowState.Valid; } /// diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonError.cs b/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonError.cs index 7f8a404f0..5352dafaa 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonError.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonError.cs @@ -30,8 +30,8 @@ internal static class UiJsonError public const string CodeFrameOutputFailed = "frame_output_failed"; public const string CodePartialOutput = "partial_output"; - /// WINAPP_UI_OWNER_ID was set but empty/whitespace or over 256 characters. - public const string CodeInvalidUiOwnerId = "invalid_ui_owner_id"; + /// WINAPP_UI_WORKFLOW_ID was set but empty/whitespace or over 256 characters. + public const string CodeInvalidUiWorkflowId = "invalid_ui_workflow_id"; /// Desktop turn coordination could not be read, published, or safely recovered. public const string CodeDesktopCoordinationUnavailable = "desktop_coordination_unavailable"; diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs index 4fcf1662a..cd4101f7c 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs @@ -61,6 +61,13 @@ internal sealed class InteractiveDesktopPaths : IInteractiveDesktopPaths /// touch the developer's live desktop coordination state. It relocates coordination; it never /// disables it, so a test still exercises the real locking protocol. /// + /// + /// Coordination is scoped to whichever directory this resolves to, so every winapp process that + /// must cooperate on one desktop has to agree on it. Two processes given different values each + /// coordinate correctly within their own namespace and not at all with each other, which is why + /// this is an unadvertised test seam rather than a user-facing knob, and why the recovery hints + /// below always say "the same directory for every winapp process". + /// internal const string LockDirectoryOverrideVariable = "WINAPP_UI_LOCK_DIRECTORY"; private const string FilePrefix = "interactive-desktop-"; @@ -149,7 +156,7 @@ private static string ResolveLockDirectory() throw new UiCoordinationException( UiCoordinationErrorCodes.Unavailable, "The local application data folder could not be resolved, so UI turn coordination has nowhere to store its state.", - "Ensure LOCALAPPDATA is set for this user, or set WINAPP_UI_LOCK_DIRECTORY to a fully qualified local directory."); + "Ensure LOCALAPPDATA is set for this user, or set WINAPP_UI_LOCK_DIRECTORY to the same fully qualified local directory for every winapp process on this desktop."); } return ValidateLockDirectory( @@ -166,7 +173,7 @@ private static string ValidateLockDirectory(string path, string source) throw new UiCoordinationException( UiCoordinationErrorCodes.Unavailable, $"The UI coordination directory resolved from {source} is not a fully qualified path.", - "Set WINAPP_UI_LOCK_DIRECTORY to a fully qualified local directory such as C:\\Temp\\winapp-locks."); + "Set WINAPP_UI_LOCK_DIRECTORY to the same fully qualified local directory for every winapp process on this desktop, such as C:\\Temp\\winapp-locks."); } // Byte-range locking over SMB is advisory and unreliable for the exclusive-share protocol this diff --git a/src/winapp-CLI/WinApp.UIAutomation.Recording/UiRecordingService.cs b/src/winapp-CLI/WinApp.UIAutomation.Recording/UiRecordingService.cs index 25a800eda..4d44b4548 100644 --- a/src/winapp-CLI/WinApp.UIAutomation.Recording/UiRecordingService.cs +++ b/src/winapp-CLI/WinApp.UIAutomation.Recording/UiRecordingService.cs @@ -285,6 +285,20 @@ public async Task RecordAsync(UiTarget uiTarget, string? el var (encoderW, encoderH, displayW, displayH) = ComputeTargetSize(cropW, cropH, options.MaxEdge); var bitrate = (uint)Math.Clamp((long)encoderW * encoderH * options.Fps / 8, 1_000_000, 24_000_000); + // Last foreground check before any file exists. The earlier one ran right after the + // activation delay; selector resolution between the two can take long enough for another + // window to steal the foreground, and every screen-DC frame would then be of that window. + // It deliberately sits above the encoder rather than next to the first frame read: creating + // the encoder creates OutputPath, so refusing after that point would leave an empty MP4 and + // break the "no artifact on refusal" contract the CLI states for foreground_not_target. + if (options.CaptureScreen && !ForegroundGuard.ForegroundBelongsTo((long)rootHwnd)) + { + throw new ForegroundLostException( + "The target window lost the foreground while the recording was being prepared, so a " + + "screen recording would capture whatever window is actually in front. Bring the " + + "window to the foreground and retry, or record the window directly instead of the screen."); + } + // Never replace an existing recording. The CLI refuses up front ("recording never // replaces existing artifacts"), but that guard does not travel with the package, and // the previous video-only path silently overwrote OutputPath - running the readme diff --git a/src/winapp-npm/src/ui-record-guard.ts b/src/winapp-npm/src/ui-record-guard.ts index 0e245ce66..f7faea4bc 100644 --- a/src/winapp-npm/src/ui-record-guard.ts +++ b/src/winapp-npm/src/ui-record-guard.ts @@ -119,6 +119,7 @@ export async function uiRecord(options: UiRecordOptions): Promise const captureOpts: CallWinappCliCaptureOptions = {}; if (options.cwd) captureOpts.cwd = options.cwd; if (options.signal) captureOpts.signal = options.signal; + if (options.workflowId) captureOpts.workflowId = options.workflowId; const result = await callWinappCliCapture(args, captureOpts); return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; } @@ -156,6 +157,7 @@ export async function _uiRecordWithCapture( const captureOpts: CallWinappCliCaptureOptions = {}; if (options.cwd) captureOpts.cwd = options.cwd; if (options.signal) captureOpts.signal = options.signal; + if (options.workflowId) captureOpts.workflowId = options.workflowId; const result = await capture(args, captureOpts); return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; } diff --git a/src/winapp-npm/test/ui-record-guard.test.ts b/src/winapp-npm/test/ui-record-guard.test.ts index 52e86b947..b1221a2cb 100644 --- a/src/winapp-npm/test/ui-record-guard.test.ts +++ b/src/winapp-npm/test/ui-record-guard.test.ts @@ -168,6 +168,7 @@ test('_uiRecordWithCapture: calls capture with correct args and returns result', fps: 15, output: 'rec.mp4', cwd: 'C:\\work', + workflowId: 'wf-forwarded', }; const result = await _uiRecordWithCapture(opts, mockCapture as Parameters[1]); @@ -184,8 +185,8 @@ test('_uiRecordWithCapture: calls capture with correct args and returns result', assert.ok(dIdx >= 0, '--duration-sec must be in args'); assert.equal(capturedArgs[0][dIdx + 1], '5'); - // cwd is forwarded - assert.deepEqual(capturedOpts[0], { cwd: 'C:\\work' }); + // cwd and workflowId are forwarded + assert.deepEqual(capturedOpts[0], { cwd: 'C:\\work', workflowId: 'wf-forwarded' }); // Result is correctly mapped assert.equal(result.exitCode, 0); @@ -193,6 +194,28 @@ test('_uiRecordWithCapture: calls capture with correct args and returns result', assert.equal(result.stderr, ''); }); +test('_uiRecordWithCapture: workflowId reaches the CLI child environment', async () => { + // A recording is the one long-running ui command, so it is the one most likely to be running while + // its workflow also clicks. Dropping workflowId here made the recording an anonymous one-shot, which + // blocks every other command for its whole duration instead of overlapping with its own workflow. + const capturedOpts: unknown[] = []; + async function mockCapture(_args: string[], opts: unknown) { + capturedOpts.push(opts); + return { exitCode: 0, stdout: '', stderr: '' }; + } + + await _uiRecordWithCapture( + { durationSec: 3, workflowId: 'wf-only' }, + mockCapture as Parameters[1] + ); + + assert.deepEqual( + capturedOpts[0], + { workflowId: 'wf-only' }, + 'workflowId must be forwarded even when no other capture option is set' + ); +}); + test('_uiRecordWithCapture: no cwd → empty capture options', async () => { const capturedOpts: unknown[] = []; async function mockCapture(_args: string[], opts: unknown) { From 0a7b1c0ad74a73ea79f72b4de7bcd70f7afd755a Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Thu, 3 Sep 2026 05:25:31 -0700 Subject: [PATCH 16/29] Say plainly in both package readmes that direct callers get no arbitration Both readmes already told direct consumers they are outside the CLI's cooperative desktop turns. Two gaps remained against what a reader needs to act on. Neither said the words a reader is looking for - that package calls do not participate in the CLI's workflow arbitration - so the boundary read as a general caution rather than a specific one. And only the UIAutomation readme offered the alternative to serializing yourself: running somewhere nothing else is driving the desktop. That advice is most useful to the recording package, which is the one that can capture a whole screen, where another workflow's clicks and menus land in the video rather than merely racing it. The UIAutomation note points at its own 'Input injection drives the real mouse and keyboard' section instead of restating the desktop guidance, so the advice stays on one canonical surface. No API, behavior, or package contents change: readme text only. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/winapp-CLI/WinApp.UIAutomation.Recording/PACKAGE.md | 6 ++++-- src/winapp-CLI/WinApp.UIAutomation/PACKAGE.md | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/winapp-CLI/WinApp.UIAutomation.Recording/PACKAGE.md b/src/winapp-CLI/WinApp.UIAutomation.Recording/PACKAGE.md index 4b331a9ab..c3e887aa2 100644 --- a/src/winapp-CLI/WinApp.UIAutomation.Recording/PACKAGE.md +++ b/src/winapp-CLI/WinApp.UIAutomation.Recording/PACKAGE.md @@ -6,8 +6,10 @@ JPEG frames for evidence. This is the recording engine behind `winapp ui record` > **This package does not coordinate with other automation on the desktop.** The `winapp` CLI layers > cooperative desktop turns on top of this engine — holding the desktop while a recording starts, and > for the whole recording when the host falls back to PrintWindow capture. That arbitration lives in -> the CLI, not here. Code calling these APIs directly is outside that guarantee and is responsible -> for serializing itself against any other automation running at the same time. +> the CLI, not here. Code calling these APIs directly does not participate in it: it is outside that +> guarantee and is responsible for serializing itself against any other automation running at the +> same time, or for running on a desktop nothing else is driving. This matters most for +> `RecordOptions.CaptureScreen`, where anything another workflow does lands in the video. ```console dotnet add package Microsoft.Windows.SDK.BuildTools.WinApp.UIAutomation.Recording diff --git a/src/winapp-CLI/WinApp.UIAutomation/PACKAGE.md b/src/winapp-CLI/WinApp.UIAutomation/PACKAGE.md index c0b4fabf7..369c8ef09 100644 --- a/src/winapp-CLI/WinApp.UIAutomation/PACKAGE.md +++ b/src/winapp-CLI/WinApp.UIAutomation/PACKAGE.md @@ -6,9 +6,9 @@ Inspect and drive any running Windows desktop app from code — the UI Automatio > **This package does not coordinate with other automation on the desktop.** The `winapp` CLI layers > cooperative desktop turns on top of this engine so concurrent `winapp ui` workflows cannot steal > each other's focus or dismiss each other's menus. That arbitration lives in the CLI, not here. -> Code calling these APIs directly drives the desktop immediately and is outside that guarantee — if -> you run it alongside `winapp ui`, or alongside another copy of itself, you are responsible for -> serializing the two. +> Code calling these APIs directly does not participate in it: it drives the desktop immediately, so +> if you run it alongside `winapp ui`, or alongside another copy of itself, you are responsible for +> serializing the two — or for running on a dedicated interactive desktop, as described below. ```console dotnet add package Microsoft.Windows.SDK.BuildTools.WinApp.UIAutomation From d1636f02ec7a9c74a7bff3e3c202192d9d6efc43 Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Thu, 3 Sep 2026 06:01:03 -0700 Subject: [PATCH 17/29] Remove coordination leftovers and stop dropping an empty workflow id Subtractive pass over the redesign, plus one behavior asymmetry it exposed. Three pieces of code survived the redesign with no callers at all. The screenshot and recording paths now raise the package's own ForegroundLostException, so the CLI-side CaptureForegroundNotTargetException has nothing left to throw it. NullDesktopSection existed to let callers outside the command pipeline take a section that does nothing; every such caller went away with the escalation mechanism. RemoveParticipant was the cancellation cleanup entry point before that work moved behind the coordinator, and its private helper is still used by the remaining caller. UiJsonError also carried its own copies of three coordination codes that nothing read, because commands emit them through UiCoordinatedAction from UiCoordinationErrorCodes. That duplication was not harmless: one of the copies had already drifted and named a variable this branch deleted. The comment claiming the two sets mirror each other is corrected rather than left to invite a fourth copy. The telemetry doc for UiIdentitySource still listed Parent, and Explicit, neither of which is a value the enum can produce - it emits Workflow or Anonymous. Separately, the hand-written uiRecord guard forwarded workflowId only when truthy, while the generated wrappers forward whenever it is not undefined. An empty string is a scripting mistake - an unset variable that expanded to " - and the CLI rejects it as invalid_ui_workflow_id so the caller finds out. Testing truthiness swallowed that mistake and ran the recording as an anonymous one-shot, so identical input failed loudly through one entry point and quietly through the other. Both checks now match childEnv, which already forwarded on !== undefined. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CaptureForegroundNotTargetException.cs | 21 --------- .../WinApp.Cli/Helpers/UiJsonError.cs | 9 ---- .../InteractiveDesktopScheduler.cs | 13 ----- .../InteractiveDesktop/NullDesktopSection.cs | 34 -------------- .../InteractiveDesktop/UiCoordinationTypes.cs | 5 +- .../Telemetry/Events/CommandCompletedEvent.cs | 2 +- src/winapp-npm/src/ui-record-guard.ts | 4 +- src/winapp-npm/test/ui-record-guard.test.ts | 47 +++++++++++++++++++ 8 files changed, 53 insertions(+), 82 deletions(-) delete mode 100644 src/winapp-CLI/WinApp.Cli/Helpers/CaptureForegroundNotTargetException.cs delete mode 100644 src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/NullDesktopSection.cs diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/CaptureForegroundNotTargetException.cs b/src/winapp-CLI/WinApp.Cli/Helpers/CaptureForegroundNotTargetException.cs deleted file mode 100644 index 9b0d69ed5..000000000 --- a/src/winapp-CLI/WinApp.Cli/Helpers/CaptureForegroundNotTargetException.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -namespace WinApp.Cli.Helpers; - -/// -/// Thrown when a screen-DC capture is about to run but the target did not actually reach the -/// foreground. -/// -/// -/// --capture-screen BitBlts the live screen rather than a specific window, so it records -/// whatever is in front. SetForegroundWindow is only a request — Windows refuses it under -/// focus-stealing prevention, a UAC prompt, a locked session, or when another app activates itself in -/// the same instant. Without this check the command exits 0 and hands back a PNG/MP4 of an unrelated -/// window, which is worse than failing: the caller has no way to tell. -/// -/// Commands map this to the existing foreground_not_target contract — the same code the -/// pre-injection foreground guard emits — rather than reporting internal_error. -/// -/// -internal sealed class CaptureForegroundNotTargetException(string message) : InvalidOperationException(message); diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonError.cs b/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonError.cs index 5352dafaa..6d3dd63b7 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonError.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonError.cs @@ -30,15 +30,6 @@ internal static class UiJsonError public const string CodeFrameOutputFailed = "frame_output_failed"; public const string CodePartialOutput = "partial_output"; - /// WINAPP_UI_WORKFLOW_ID was set but empty/whitespace or over 256 characters. - public const string CodeInvalidUiWorkflowId = "invalid_ui_workflow_id"; - - /// Desktop turn coordination could not be read, published, or safely recovered. - public const string CodeDesktopCoordinationUnavailable = "desktop_coordination_unavailable"; - - /// Too many commands are already waiting for the desktop. - public const string CodeQueueCapacityExceeded = "queue_capacity_exceeded"; - /// The command was cancelled while waiting for the desktop and never ran. public const string CodeCancelled = "cancelled"; diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs index f140dd0e6..4bf403af4 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs @@ -246,19 +246,6 @@ public void CompleteCommand( Normalize(state, probe); } - /// - /// Removes this process's command or waiter entry without touching the idle deadline. Used when a - /// queued command is cancelled before it ever ran (spec §11.1). - /// - public void RemoveParticipant( - InteractiveDesktopState state, - ICoordinationLivenessProbe probe, - UiParticipantIdentity participant) - { - RemoveParticipantEntries(state, participant); - Normalize(state, probe); - } - /// /// Whether currently holds the turn. Callers use this before opening a /// participant lease, so a detached observation never creates one. diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/NullDesktopSection.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/NullDesktopSection.cs deleted file mode 100644 index 3be0a9a8e..000000000 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/NullDesktopSection.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. -// Licensed under the MIT License. - -namespace WinApp.Cli.Services.InteractiveDesktop; - -/// -/// An that grants the section immediately without taking -/// active.lock. -/// -/// -/// Only for callers that provably have no turn to coordinate under: unit tests exercising capture -/// mechanics directly against , and gated real-UIA tests that drive -/// the service outside the command pipeline. Command handlers always pass their real -/// — using this there would silently opt a command out of coordination. -/// -internal sealed class NullDesktopSection : IDesktopSection -{ - /// The shared instance. Stateless, so one is enough. - public static NullDesktopSection Instance { get; } = new(); - - private NullDesktopSection() - { - } - - public Task EnterAsync(CancellationToken cancellationToken) - => Task.FromResult(NoOpScope.Instance); - - private sealed class NoOpScope : IAsyncDisposable - { - public static NoOpScope Instance { get; } = new(); - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - } -} diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs index e7f55653f..3852fb25b 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs @@ -4,8 +4,9 @@ namespace WinApp.Cli.Services.InteractiveDesktop; /// -/// Stable error codes for coordination failures. These appear in the --json error envelope and -/// are mirrored in UiJsonError so UI commands emit one consistent shape. +/// Stable error codes for coordination failures. These appear in the --json error envelope, +/// alongside the command-level codes in UiJsonError, and are declared here only — a duplicate +/// set of constants drifted from these once already and shipped a code no code path emitted. /// internal static class UiCoordinationErrorCodes { diff --git a/src/winapp-CLI/WinApp.Cli/Telemetry/Events/CommandCompletedEvent.cs b/src/winapp-CLI/WinApp.Cli/Telemetry/Events/CommandCompletedEvent.cs index 33416b30a..87d971a67 100644 --- a/src/winapp-CLI/WinApp.Cli/Telemetry/Events/CommandCompletedEvent.cs +++ b/src/winapp-CLI/WinApp.Cli/Telemetry/Events/CommandCompletedEvent.cs @@ -40,7 +40,7 @@ internal CommandCompletedEvent(CommandResult commandResult, DateTime finishedTim public int ExitCode { get; } - /// How the workflow owner was resolved: Explicit, Parent, or Anonymous. + /// How the owner was resolved: Workflow (from WINAPP_UI_WORKFLOW_ID) or Anonymous. public string? UiIdentitySource { get; } /// Coordination mode: Observe, TurnShared, or DesktopExclusive. diff --git a/src/winapp-npm/src/ui-record-guard.ts b/src/winapp-npm/src/ui-record-guard.ts index f7faea4bc..cb1f76825 100644 --- a/src/winapp-npm/src/ui-record-guard.ts +++ b/src/winapp-npm/src/ui-record-guard.ts @@ -119,7 +119,7 @@ export async function uiRecord(options: UiRecordOptions): Promise const captureOpts: CallWinappCliCaptureOptions = {}; if (options.cwd) captureOpts.cwd = options.cwd; if (options.signal) captureOpts.signal = options.signal; - if (options.workflowId) captureOpts.workflowId = options.workflowId; + if (options.workflowId !== undefined) captureOpts.workflowId = options.workflowId; const result = await callWinappCliCapture(args, captureOpts); return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; } @@ -157,7 +157,7 @@ export async function _uiRecordWithCapture( const captureOpts: CallWinappCliCaptureOptions = {}; if (options.cwd) captureOpts.cwd = options.cwd; if (options.signal) captureOpts.signal = options.signal; - if (options.workflowId) captureOpts.workflowId = options.workflowId; + if (options.workflowId !== undefined) captureOpts.workflowId = options.workflowId; const result = await capture(args, captureOpts); return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; } diff --git a/src/winapp-npm/test/ui-record-guard.test.ts b/src/winapp-npm/test/ui-record-guard.test.ts index b1221a2cb..8caf9c07a 100644 --- a/src/winapp-npm/test/ui-record-guard.test.ts +++ b/src/winapp-npm/test/ui-record-guard.test.ts @@ -216,6 +216,53 @@ test('_uiRecordWithCapture: workflowId reaches the CLI child environment', async ); }); +test('_uiRecordWithCapture: an empty workflowId is forwarded, not silently dropped', async () => { + // An empty string is a scripting mistake — an unset variable that expanded to "" — and the CLI + // rejects it with invalid_ui_workflow_id so the caller finds out. Testing it for truthiness instead + // of for undefined swallowed that mistake here and ran the recording as an anonymous one-shot, so + // the same bad input failed loudly through the generated wrappers and passed quietly through this + // one. Forwarding keeps a single answer for a single input. + const capturedOpts: unknown[] = []; + async function mockCapture(_args: string[], opts: unknown) { + capturedOpts.push(opts); + return { exitCode: 0, stdout: '', stderr: '' }; + } + + await _uiRecordWithCapture( + { durationSec: 3, workflowId: '' }, + mockCapture as Parameters[1] + ); + + assert.deepEqual( + capturedOpts[0], + { workflowId: '' }, + 'an empty workflowId must reach the CLI so it can reject it' + ); +}); + +test('_uiRecordWithCapture: an omitted workflowId is still omitted', async () => { + // The other half of the contract: only `undefined` means "no opinion", and it must leave any + // inherited WINAPP_UI_WORKFLOW_ID in the environment alone. + const capturedOpts: unknown[] = []; + async function mockCapture(_args: string[], opts: unknown) { + capturedOpts.push(opts); + return { exitCode: 0, stdout: '', stderr: '' }; + } + + await _uiRecordWithCapture( + { durationSec: 3 }, + mockCapture as Parameters[1] + ); + + assert.deepEqual(capturedOpts[0], {}, 'no workflowId key at all when the caller omitted it'); +}); + +// The public uiRecord differs from _uiRecordWithCapture only in that it spawns the CLI instead of +// taking an injected capture function — the forwarding line above is the same one. It is not +// exercised end-to-end here because the compiled tests run from dist-test/, where the package's own +// binary resolution (dist/../bin) does not find the built CLI. _uiRecordWithCapture exists for +// exactly this reason, and the environment half of the contract is covered in workflow-id.test.ts. + test('_uiRecordWithCapture: no cwd → empty capture options', async () => { const capturedOpts: unknown[] = []; async function mockCapture(_args: string[], opts: unknown) { From 96372fdceb39a27389a882b028906b570397e74d Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Thu, 3 Sep 2026 06:04:51 -0700 Subject: [PATCH 18/29] Remove the last unread copy of the cancelled error code UiJsonError.CodeCancelled had no readers: the cancellation envelope is emitted from InteractiveDesktopLock through UiCoordinationErrorCodes.Cancelled. It was the fourth and last of the duplicated coordination codes, kept in the previous pass only because it had not been named explicitly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/winapp-CLI/WinApp.Cli/Helpers/UiJsonError.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonError.cs b/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonError.cs index 6d3dd63b7..d82296a1b 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonError.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonError.cs @@ -30,9 +30,6 @@ internal static class UiJsonError public const string CodeFrameOutputFailed = "frame_output_failed"; public const string CodePartialOutput = "partial_output"; - /// The command was cancelled while waiting for the desktop and never ran. - public const string CodeCancelled = "cancelled"; - /// Write a JSON error envelope to stderr. No-op when is false. /// /// Optional error writer; defaults to . Pass From 01a983c8eb7c724db100d26d03396686f7a01f3e Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Thu, 3 Sep 2026 06:42:44 -0700 Subject: [PATCH 19/29] Make the handover test replay the way a real agent has to AReasoningGapHandsOverTheTurnAndForcesReplay failed on the hosted lane at its last assertion: the menu was closed again by the time it looked. The timing was the symptom. The test had agent A recover by calling _fixture.OpenFileMenu() directly and then running ui inspect. Neither step participates in coordination. Opening the menu on the fixture bypasses arbitration entirely, and a non-owner's Observe is detached by design - no ticket, no lease, nothing holding the desktop - so agent A never reacquired anything. Agent B still held the turn and its idle grace throughout, which means the menu was surviving on luck rather than on the property the test claims to prove. On a slower hosted agent the luck ran out. Agent A now replays through the same coordinated mutation it used to open the menu the first time, which is the only move actually available to a returning agent: it queues behind agent B, waits out the grace, reacquires the turn, and reopens the menu. The following inspect then means something, because an owner's Observe pins where a stranger's does not, and a new assertion pins the ownership the old test only assumed. Restoring the out-of-band recovery makes the ownership assertion fail with agent B's key, which is the root cause stated directly rather than inferred from a flake. Also corrects the neighbouring remark on the burst test, which still described opening the menu directly on the fixture after that test had moved to the coordinated helper. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../InteractiveDesktopRealAppTests.cs | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs index 2afb24113..3049862fe 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs @@ -280,10 +280,10 @@ private async Task OpenMenuAsOwnerAsync(string ownerId) /// mutation is held back. /// /// - /// The menu is opened directly on the fixture rather than through UIA so the test asserts the - /// coordination property (the drop-down survives) rather than re-testing menu invocation, which - /// RealUiAutomationTests already covers. Agent B is a genuine separate process, so the - /// foreground steal it would perform is real. + /// The menu is opened by agent A's own coordinated command rather than directly on the fixture, so + /// the turn and the transient UI are established by the same action and there is no window in + /// which the premise could lapse. Agent B is a genuine separate process, so the foreground steal it + /// would perform is real. /// [TestMethod] public async Task ATightBurstKeepsTransientMenuOpenWhileAnotherOwnerWaits() @@ -333,6 +333,13 @@ await WaitForAgentStateAsync( /// §18.3(b): a reasoning gap longer than the idle grace hands the turn to a waiting owner, and the /// transient UI the first owner left behind does not survive — so its next step must replay. ///
+ /// + /// The replay is itself a coordinated mutation, because that is the only thing a returning agent + /// can actually do: by then the other owner holds the turn and its own grace, so the returning + /// agent has to queue, wait, and reacquire before it can put its UI back. An observation cannot + /// stand in for that step — a non-owner's Observe is detached by design, so it neither waits nor + /// pins, and would leave the assertion below racing the other owner's grace. + /// [TestMethod] public async Task AReasoningGapHandsOverTheTurnAndForcesReplay() { @@ -367,12 +374,24 @@ await WaitForStateAsync(s => s.Owner?.Key == KeyOf(OwnerB), timeoutMs: 5_000), _fixture.IsFileMenuOpen, "the transient UI must NOT survive the handover: this is exactly why an agent has to replay after a gap"); - // Agent A resumes and finds a different world. Its recovery step succeeds only because it - // reopens rather than assuming the menu it left behind is still there. - _fixture.OpenFileMenu(); + // Agent A resumes and finds a different world. Its recovery must go through coordination the + // same way its first step did: reopening the drop-down straight on the fixture would prove + // nothing here, because it bypasses the arbitration this test exists to exercise. The command + // below queues behind whatever agent B still holds, waits, reacquires the turn, and reopens the + // menu — and OpenMenuAsOwnerAsync asserts both halves of that (menu back on screen, turn owned + // by A again). + await OpenMenuAsOwnerAsync(OwnerA); + + // Now that A owns the turn again its Observe pins rather than detaches, so the UI it just + // restored is still standing afterwards. Running the same inspect while A was a non-owner + // would have been detached — no ticket, no lease, nothing holding the desktop — which is why + // the menu could not be expected to survive it before this point. var (replayExit, replayOutput) = await RunAgentAsync(OwnerA, WithTarget("ui", "inspect")); Assert.AreEqual(0, replayExit, $"agent A must be able to replay after the handover. Output: {replayOutput}"); - Assert.IsTrue(_fixture.IsFileMenuOpen, "agent A's replay must restore its transient UI"); + Assert.IsTrue(_fixture.IsFileMenuOpen, "agent A's restored transient UI must survive its own observation"); + Assert.AreEqual( + KeyOf(OwnerA), ReadState().Owner?.Key, + "agent A must still hold the turn it reacquired"); } // ------------------------------------------------------------------------------ §18.3 (c) From 58ef9b06c303418cdd545d3a974b33547bafdd67 Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:05:36 -0700 Subject: [PATCH 20/29] Correct documentation that outlived the coordination redesign A sweep of hand-written docs, plugin skills, package readmes, samples, npm sources and production comments for text describing behavior this branch changed. The biggest correction is about screenshot. Both the CLI remarks and the docs claimed every capture path restores or foregrounds the window, which is simply not true: an ordinary visible window captured through Windows Graphics Capture is untouched. The real reason screenshot is exclusive is narrower and worth stating accurately - the engine may restore a minimized target, and falls back to foregrounding when frame capture is unavailable or --capture-screen reads the live screen. Neither need is knowable until capture is under way, and the package deliberately offers no coordination hook to react to them, so the lean policy is to take the turn up front rather than guess. Overstating it made the rule look arbitrary; understating it would invite someone to make screenshot observational again. The skill still listed screenshot among the headless and locked-session friendly verbs, which is now wrong twice over: it queues for an exclusive turn, and its capture can need a usable interactive desktop. The same list in docs/ui-automation.md had the same problem. Both now name screenshot as the exception among the non-injecting verbs and say why. Smaller stale text: the skill asked for an owner id when the variable users set is a workflow id; its turn-taking list repeated the exclusive marker twice; the JSON envelope reference and a coordination outcome comment still said owner id where the external concept is the workflow id; the verbose wait line no longer prints a parent PID; and three comments still described the escalation path and the ticket it used to assign, which no longer exist. Deliberately left alone: owner as the internal scheduler term, the historical note in ResolveMode explaining why escalation was rejected, the note in UiOwnerResolver explaining why there is no ancestry fallback, and strict FIFO where it is scoped to ordering among waiters, which owner affinity does not weaken. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/ui-automation.md | 14 +++++++----- .../skills/winapp-ui-automation/SKILL.md | 8 +++---- .../references/ui-json-envelope.md | 2 +- .../FakeDesktopForegroundService.cs | 6 ++--- .../InteractiveDesktopLockTests.cs | 2 +- .../InteractiveDesktopSchedulerTests.cs | 2 -- .../Commands/UiScreenshotCommand.cs | 22 ++++++++++++++----- .../InteractiveDesktopLock.cs | 5 ++--- .../InteractiveDesktopState.cs | 4 ++-- .../UiCoordinationOutputMode.cs | 4 ++-- .../InteractiveDesktop/UiCoordinationTypes.cs | 2 +- 11 files changed, 40 insertions(+), 31 deletions(-) diff --git a/docs/ui-automation.md b/docs/ui-automation.md index 4d773bec2..b373c0e23 100644 --- a/docs/ui-automation.md +++ b/docs/ui-automation.md @@ -13,7 +13,7 @@ Uses Windows UI Automation (UIA). Works with any Windows app — WPF, WinForms, Most commands drive the app through UIA patterns (no input injection). The exceptions inject real input: `ui click`/`ui hover`/`ui drag` use mouse simulation, `ui touch`/`ui pen` synthesize touch and pen/stylus input, and `ui send-keys` synthesizes keyboard input — for controls and scenarios that UIA patterns can't drive. > [!IMPORTANT] -> **Interactive-desktop requirement (input-injecting verbs).** `click`, `hover`, `drag`, `touch`, `pen`, `scroll --wheel`, and `send-keys --via send-input` synthesize OS-level input, so they need an **unlocked, interactive desktop** with the target window in the foreground. On a **locked workstation or secure desktop** (LogonUI/UAC) they can't inject and fail fast with **`no_interactive_desktop`** (distinct from the elevation/`foreground_not_target` cases). `touch`/`pen` additionally refuse when no window resolves (**`no_target`**); a coordinate outside the target window is a **non-fatal warning** (a `warnings[]` entry under `--json`, or a warning line in text mode) and injection still proceeds — consistent with the mouse verbs. Everything else — `inspect`, `search`, `get-property`, `get-value`, `wait-for`, `set-value`, `invoke`, `scroll --direction/--to`, `screenshot` — drives the app through UIA patterns and is **headless/locked-session friendly**. Prefer the UIA-pattern verbs in CI; reserve the injection verbs for scenarios that genuinely need real input. Before injecting, the gesture verbs also **re-resolve the target element** and refuse with **`target_moved`** if it's still animating/relocating, rather than landing input on empty space. +> **Interactive-desktop requirement (input-injecting verbs).** `click`, `hover`, `drag`, `touch`, `pen`, `scroll --wheel`, and `send-keys --via send-input` synthesize OS-level input, so they need an **unlocked, interactive desktop** with the target window in the foreground. On a **locked workstation or secure desktop** (LogonUI/UAC) they can't inject and fail fast with **`no_interactive_desktop`** (distinct from the elevation/`foreground_not_target` cases). `touch`/`pen` additionally refuse when no window resolves (**`no_target`**); a coordinate outside the target window is a **non-fatal warning** (a `warnings[]` entry under `--json`, or a warning line in text mode) and injection still proceeds — consistent with the mouse verbs. Everything else — `inspect`, `search`, `get-property`, `get-value`, `wait-for`, `set-value`, `invoke`, `scroll --direction/--to` — drives the app through UIA patterns and is **headless/locked-session friendly**. `screenshot` is the exception among the non-injecting verbs: it takes an exclusive turn and its capture can need a usable interactive desktop, because the engine restores a minimized target and falls back to foregrounding it when frame capture is unavailable or `--capture-screen` is used. Prefer the UIA-pattern verbs in CI; reserve the injection verbs for scenarios that genuinely need real input. Before injecting, the gesture verbs also **re-resolve the target element** and refuse with **`target_moved`** if it's still animating/relocating, rather than landing input on empty space. ## Quick Start @@ -87,11 +87,13 @@ Which commands wait for a turn: | Claims the turn, shares it with the same workflow | `record` | | Claims the turn and takes the desktop exclusively | `invoke`, `click`, `drag`, `hover`, `scroll --wheel`, `touch`, `pen`, `focus`, `send-keys`, `screenshot` | -`screenshot` always queues for an exclusive turn. Every capture path restores minimized windows or -takes the foreground, so it is desktop-sensitive whatever its arguments; when it composites several -windows it captures them all under one exclusive turn, so the saved image is a single consistent -moment rather than a mix of before and after. Encoding and writing the file happen after the desktop -is released. +`screenshot` always queues for an exclusive turn. Not every capture disturbs the desktop — an +ordinary visible window captured through Windows Graphics Capture does not — but the engine restores +the target if it is minimized, and falls back to foregrounding it when frame capture is unavailable +or `--capture-screen` reads the live screen. Those needs only surface once capture is under way, so +the command takes the turn up front rather than guessing. When it composites several windows it +captures them all under one exclusive turn, so the saved image is a single consistent moment rather +than a mix of before and after. Encoding and writing the file happen after the desktop is released. `record` shares its turn, so same-workflow input can interleave with the capture — that is how you record a workflow driving an app. Two caveats: diff --git a/plugins/winapp/skills/winapp-ui-automation/SKILL.md b/plugins/winapp/skills/winapp-ui-automation/SKILL.md index 3c8a73c72..0ed718c9c 100644 --- a/plugins/winapp/skills/winapp-ui-automation/SKILL.md +++ b/plugins/winapp/skills/winapp-ui-automation/SKILL.md @@ -11,8 +11,9 @@ description: Inspect and interact with running Windows app UIs from the command ## Prerequisites - For UIA mode (any app): No setup needed — works with any running Windows app -- For input-injecting verbs (`click`, `hover`, `drag`, `touch`, `pen`, `scroll --wheel`, `send-keys --via send-input`): an **unlocked, interactive desktop** with the target window foregroundable. On a locked/secure desktop they fail fast with `no_interactive_desktop`. The UIA-pattern verbs (`inspect`, `search`, `get-*`, `wait-for`, `set-value`, `invoke`, `scroll --direction/--to`, `screenshot`) are headless/locked-session friendly — prefer them in CI. -- **If other UI workflows may run at the same time**, set one owner id per logical workflow (see below). Nothing breaks without it, but your commands will not be recognized as belonging together. +- For input-injecting verbs (`click`, `hover`, `drag`, `touch`, `pen`, `scroll --wheel`, `send-keys --via send-input`): an **unlocked, interactive desktop** with the target window foregroundable. On a locked/secure desktop they fail fast with `no_interactive_desktop`. The UIA-pattern verbs (`inspect`, `search`, `get-*`, `wait-for`, `set-value`, `invoke`, `scroll --direction/--to`) are headless/locked-session friendly — prefer them in CI. +- `screenshot` is **not** in that group: it always takes an exclusive turn, so it queues behind other UI workflows, and capture can need a usable interactive desktop — the engine restores the target if it is minimized, and falls back to foregrounding it when frame capture is unavailable or `--capture-screen` is used. +- **If other UI workflows may run at the same time**, set one workflow id per logical workflow (see below). Nothing breaks without it, but your commands will not be recognized as belonging together. ## Coordinating with other UI workflows @@ -54,8 +55,7 @@ Rules that matter when driving this from an agent: Commands that never wait: `status`, `list-windows`, `inspect`, `search`, `get-*`, `wait-for`, `set-value`, `scroll-into-view`, `scroll --direction`/`--to`. Commands that take a turn: `record` (shared) and `invoke`, `click`, `drag`, `hover`, -`scroll --wheel`, `touch`, `pen`, `focus`, `send-keys`, `screenshot` (always exclusive) -(exclusive). +`scroll --wheel`, `touch`, `pen`, `focus`, `send-keys`, `screenshot` (all exclusive). ## Common patterns diff --git a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md index 6340769d2..cfc130e8f 100644 --- a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md +++ b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md @@ -142,7 +142,7 @@ additive `coordination` object: `waitedMs` is always present for a cancellation while queued. `queuePosition` is one-based among live waiters and is **omitted** when it cannot be computed reliably — including while a command waits behind its -own workflow's earlier command. Owner identities are never exposed, in +own workflow's earlier command. Workflow identities are never exposed, in raw or hashed form. Cancelling *after* the command acquired its turn keeps that command's diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeDesktopForegroundService.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeDesktopForegroundService.cs index df9e8fd12..4ac1af493 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeDesktopForegroundService.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeDesktopForegroundService.cs @@ -18,12 +18,12 @@ internal sealed class FakeDesktopForegroundService : IDesktopForegroundService /// Window handles passed to , in order. public List RestoreRequests { get; } = []; - /// Handles this fake reports as minimized, to drive the screenshot escalation path. + /// Handles this fake reports as minimized, so a test can drive the restore path. public HashSet MinimizedWindows { get; } = []; /// - /// Reports every window as minimized, so a test can force the screenshot escalation path without - /// having to know which handle the fake session happens to resolve to. + /// Reports every window as minimized, so a test can force the restore path without having to know + /// which handle the fake session happens to resolve to. /// public bool AllWindowsMinimized { get; set; } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs index ab449bfdc..97557a649 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs @@ -594,7 +594,7 @@ private Task RunAsyncWithToken( UiTurnMode mode, string operation, Func> body, CancellationToken token) => _coordinator.RunCoordinatedAsync(mode, operation, Parse(), body, token); - // ------------------------------------------------------ escalation must not swallow coordination + // ------------------------------------------ cancellation must not swallow coordination faults [TestMethod] public async Task AnActiveRecordingThatFinalizesOnCancellationStillRenewsTheGrace() diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs index 39dc48971..b63d072ec 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs @@ -688,8 +688,6 @@ public void PromotedOwner_StopsAbsorbingAtTheFirstDifferentOwner() Assert.AreEqual("cccc", state.Owner!.Key); } - // ---------------------------------------------------------------------------- escalation - // -------------------------------------------------------------------------- ticket monotonicity [TestMethod] diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs index f9f019c3b..619ae16fe 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs @@ -49,12 +49,22 @@ public class Handler( /// Always exclusive. ///
/// - /// Every capture path restores minimized windows and/or takes the foreground, and - /// --capture-screen reads the live screen, so a screenshot is desktop-sensitive whatever - /// its arguments. An earlier design started observationally and escalated on discovering it - /// needed the foreground, which cost an entire discard-and-recapture pass, a second scheduler - /// transition, and a mode that could change mid-command — all to avoid queueing for a command - /// that virtually always ended up queueing anyway. + /// Not because every capture foregrounds something — an ordinary visible window captured through + /// Windows Graphics Capture does not. It is because the engine may restore a minimized target, and + /// falls back to foregrounding it when frame capture is unavailable or --capture-screen + /// reads the live screen. Those needs surface only once capture is under way, and the package + /// deliberately exposes no coordination hook for the CLI to react to them, so the lean policy is + /// to classify the whole command exclusive rather than to guess per invocation. + /// + /// An earlier design started observationally and escalated on discovering it needed the + /// foreground, which cost an entire discard-and-recapture pass, a second scheduler transition, and + /// a mode that could change mid-command — all to avoid queueing for a command that virtually + /// always ended up queueing anyway. + /// + /// + /// A multi-window capture runs every window inside the one section, so the composite is a single + /// consistent moment; encoding and writing the file happen after it is released. + /// /// protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.DesktopExclusive; diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs index 2f117e573..5b53e3261 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs @@ -147,9 +147,8 @@ public bool IsParticipantLive(int processId, long startTicksUtc) } /// - /// One command's participation: registration, queue waiting, desktop sections, escalation and - /// teardown. Held as a separate object so itself stays a - /// stateless singleton. + /// One command's participation: registration, queue waiting, desktop sections and teardown. Held as + /// a separate object so itself stays a stateless singleton. /// private sealed class CoordinatedExecution( InteractiveDesktopLock coordinator, diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopState.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopState.cs index 811ccfed3..73f3c89f9 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopState.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopState.cs @@ -108,8 +108,8 @@ internal sealed class OwnerCommandEntry /// /// Globally monotonic arrival ticket. Present for every and /// command; for - /// , which never serializes as a barrier. An observation that - /// escalates is assigned a ticket at escalation time. + /// , which never serializes as a barrier. A command's mode is fixed + /// before it registers, so a ticket is never assigned after the fact. /// public long? Ticket { get; set; } diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationOutputMode.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationOutputMode.cs index d4acde8d2..c8678e3c6 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationOutputMode.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationOutputMode.cs @@ -15,8 +15,8 @@ namespace WinApp.Cli.Services.InteractiveDesktop; /// parsing stdout never has to skip progress lines. /// /// -/// --verbose is in effect: include local diagnostics (parent PID, the active winapp PID, -/// its operation, queue depth, commands ahead, elapsed wait). +/// --verbose is in effect: include local diagnostics (the active winapp PID, its +/// operation, queue depth, commands ahead, elapsed wait). /// /// --quiet is in effect: emit nothing while waiting. internal readonly record struct UiCoordinationOutputMode(bool Json, bool Verbose, bool Quiet) diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs index 3852fb25b..c5d8a9f96 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs @@ -76,7 +76,7 @@ internal enum UiCoordinationOutcome /// The command was cancelled while queued and never executed. Cancelled, - /// Coordination failed closed (unavailable, queue capacity, invalid owner id). + /// Coordination failed closed (unavailable, queue capacity, invalid workflow id). CoordinationFailure, /// Corrupt state was safely quarantined and rebuilt before the command proceeded. From 40be6f479594acd4aa9a57de45efbf77c88b2c3d Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:36:38 -0700 Subject: [PATCH 21/29] Fix five ways a UI command trusted something it should have checked Five findings from the Copilot review, each with a test that fails without its fix. An aborted npm call could kill the host process. Node emits 'error' with the AbortError and then 'close' with a non-zero code for one aborted spawn, so both handlers ran. The promise ignores the second settle, but process.exit does not: a caller with exitOnError got a rejection to handle and then had the whole process torn down underneath it. Both entry points now route every outcome through one exactly-once gate. An ill-formed workflow id silently merged unrelated workflows. The owner key is SHA-256 over UTF-8, and the default encoder substitutes U+FFFD for anything it cannot encode - so "\uD800", "\uD801" and a literal "\uFFFD" all produced the same key, and three unrelated workflows shared one owner and one desktop. The encoding is now strict and an unpaired surrogate is refused as invalid_ui_workflow_id. It also has to be refused on the npm side, because Node performs that same substitution while building the child environment: by the time the value reaches the CLI the distinction is already gone. Screenshot and record took the desktop before checking where they would write. Screenshot claims the desktop exclusively, so a command whose output path was already impossible - a directory, a trailing separator, a parent that cannot be created - queued for a turn, foregrounded a window and captured pixels, then failed on the last step, having made every other workflow wait for nothing. Record checked for an existing output only under --frames, so an ordinary recording queued and was then refused by the engine's no-clobber check. Both now resolve and validate the path in Preflight. The later checks stay: the file system can change while a command waits, and the engine's no-clobber check is still the final word. Overwriting an existing screenshot remains deliberate, and record's generated default is unique by construction so it is not preflighted. A recycled dialog handle could be composited into another app's screenshot. Owned dialogs - file pickers, print dialogs - run in a shared system host, so validating one against its own current PID accepts any live window in that host, including a handle reused after the real dialog closed. Windows now carry the process they were discovered FOR alongside the process that owns them, so an owned dialog is validated against the originating application and its owner chain has to still reach it. A dialog whose owner cannot be matched back to the app is dropped rather than guessed at. The app's own windows are unaffected: they expect their own process. Two test fakes had to start modelling reality to keep passing - an owned dialog really does report its app window as GW_OWNER - which is the same reason the earlier multi-window tests needed per-HWND PIDs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../UiCommandTests.OutputPreflight.cs | 213 ++++++++++++++++++ .../UiCommandTests.Screenshot.Provenance.cs | 124 ++++++++++ .../UiCommandTests.Screenshot.cs | 3 + .../WinApp.Cli.Tests/UiOwnerResolverTests.cs | 124 ++++++++++ .../WinApp.Cli/Commands/UiRecordCommand.cs | 171 ++++++++++---- .../Commands/UiScreenshotCommand.cs | 170 ++++++++++++-- .../InteractiveDesktop/UiOwnerResolver.cs | 33 ++- src/winapp-npm/src/winapp-cli-utils.ts | 135 +++++++---- src/winapp-npm/test/abort-signal.test.ts | 62 +++++ src/winapp-npm/test/workflow-id.test.ts | 54 ++++- 10 files changed, 986 insertions(+), 103 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.OutputPreflight.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.Provenance.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/UiOwnerResolverTests.cs diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.OutputPreflight.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.OutputPreflight.cs new file mode 100644 index 000000000..71d4b169f --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.OutputPreflight.cs @@ -0,0 +1,213 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Commands; + +namespace WinApp.Cli.Tests; + +/// +/// Output paths that can never work must be rejected before the command queues for the desktop. +/// +/// +/// Both of these commands take a turn on a desktop shared with every other UI workflow on the machine +/// — screenshot exclusively. A command whose destination is already impossible still waited for that +/// turn, and made everyone else wait behind it, before failing on the last step. The fixture's +/// DesktopSectionEnters and Runs counters are the assertion: zero of either means the +/// command never reached coordination at all. +/// +public partial class UiCommandTests +{ + private string ExistingDirectory(string name) + { + var path = Path.Combine(_tempDirectory.FullName, name); + Directory.CreateDirectory(path); + return path; + } + + /// + /// Asserts the JSON error envelope on stderr carries . + /// + /// + /// Scoped to the envelope line rather than reusing AssertJsonErrorCode, which parses from the + /// first brace to the end of the stream: a preflight failure also logs a human-readable line after + /// the envelope, and that trailing text is not JSON. + /// + private void AssertPreflightErrorCode(string expectedCode) + { + var stderr = ConsoleStdErr.ToString(); + var envelope = stderr + .Split('\n') + .Select(line => line.Trim()) + .FirstOrDefault(line => line.StartsWith('{')); + + Assert.IsNotNull(envelope, $"stderr must contain a JSON error envelope; got: {stderr}"); + + var error = System.Text.Json.JsonSerializer.Deserialize(envelope); + Assert.AreEqual(expectedCode, error.GetProperty("error").GetProperty("code").GetString()); + } + + private void AssertNeverCoordinated(string because) + { + Assert.AreEqual(0, _fakeDesktopLock.Runs.Count, because); + Assert.AreEqual(0, _fakeDesktopLock.DesktopSectionEnters, because); + } + + /// + /// Makes any attempt to capture pixels fail loudly, so a test that expects to be rejected during + /// preflight cannot quietly pass by failing later for a different reason. + /// + private void ArmCaptureTripwire() + => _fakeUia.ScreenshotThrow = new InvalidOperationException( + "capture must not be attempted for a command whose output path was already impossible"); + + // ------------------------------------------------------------------------------------ screenshot + + [TestMethod] + public async Task Screenshot_OutputIsAnExistingDirectory_FailsBeforeTakingTheDesktop() + { + ArmCaptureTripwire(); + var target = ExistingDirectory("shot-dir"); + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "-o", target, "--json"]); + + Assert.AreEqual(1, exitCode); + AssertPreflightErrorCode("invalid_arguments"); + AssertNeverCoordinated("an unwritable destination must be refused before the desktop is taken"); + // The tripwire is armed in each test below: reaching capture at all would surface as this. + } + + [TestMethod] + public async Task Screenshot_OutputEndingInASeparator_FailsBeforeTakingTheDesktop() + { + ArmCaptureTripwire(); + var target = Path.Combine(_tempDirectory.FullName, "not-a-file") + Path.DirectorySeparatorChar; + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "-o", target, "--json"]); + + Assert.AreEqual(1, exitCode); + AssertPreflightErrorCode("invalid_arguments"); + AssertNeverCoordinated("a directory-shaped path is not a screenshot destination"); + } + + [TestMethod] + public async Task Screenshot_ParentDirectoryIsAFile_FailsBeforeTakingTheDesktop() + { + ArmCaptureTripwire(); + // The parent cannot be created because a file already occupies its name. + var blocker = Path.Combine(_tempDirectory.FullName, "blocker"); + await File.WriteAllTextAsync(blocker, "not a directory", TestContext.CancellationToken); + var target = Path.Combine(blocker, "shot.png"); + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "-o", target, "--json"]); + + Assert.AreEqual(1, exitCode); + AssertPreflightErrorCode("invalid_arguments"); + AssertNeverCoordinated("an uncreatable parent directory must be found before queueing"); + } + + [TestMethod] + public async Task Screenshot_OverwritingAnExistingFileIsStillAllowed() + { + // Deliberate: a screenshot is cheap to retake and callers write to a fixed name in a loop. + var target = Path.Combine(_tempDirectory.FullName, "existing.png"); + await File.WriteAllTextAsync(target, "old", TestContext.CancellationToken); + _fakeUia.ScreenshotResult = (new byte[4], 1, 1); + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["-a", "TestApp", "-o", target, "--json"]); + + Assert.AreEqual(0, exitCode); + Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters, "a valid destination still takes its turn"); + } + + // ---------------------------------------------------------------------------------------- record + + [TestMethod] + public async Task Record_ExplicitOutputAlreadyExists_FailsBeforeTakingTheDesktop() + { + // Previously only checked with --frames, so a plain recording queued for the desktop and then + // refused at the engine's no-clobber check, having made every other workflow wait first. + var target = Path.Combine(_tempDirectory.FullName, "taken.mp4"); + await File.WriteAllTextAsync(target, "first take", TestContext.CancellationToken); + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-a", "TestApp", "--duration-sec", "1", "-o", target, "--json"]); + + Assert.AreEqual(1, exitCode); + AssertPreflightErrorCode("output_exists"); + AssertNeverCoordinated("an existing recording must be found before the command queues"); + Assert.AreEqual("first take", await File.ReadAllTextAsync(target, TestContext.CancellationToken), + "the existing take must be untouched"); + } + + [TestMethod] + public async Task Record_DerivedFramesDirectoryAlreadyExists_FailsBeforeTakingTheDesktop() + { + var target = Path.Combine(_tempDirectory.FullName, "with-frames.mp4"); + Directory.CreateDirectory(Path.Combine(_tempDirectory.FullName, "with-frames.frames")); + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-a", "TestApp", "--duration-sec", "1", "-o", target, "--frames", "--json"]); + + Assert.AreEqual(1, exitCode); + AssertPreflightErrorCode("output_exists"); + AssertNeverCoordinated("a colliding frame directory must be found before the command queues"); + } + + [TestMethod] + public async Task Record_OutputIsAnExistingDirectory_FailsBeforeTakingTheDesktop() + { + var target = ExistingDirectory("rec-dir"); + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-a", "TestApp", "--duration-sec", "1", "-o", target, "--json"]); + + Assert.AreEqual(1, exitCode); + AssertPreflightErrorCode("invalid_arguments"); + AssertNeverCoordinated("a directory is not a recording destination"); + } + + [TestMethod] + public async Task Record_ParentDirectoryIsAFile_FailsBeforeTakingTheDesktop() + { + var blocker = Path.Combine(_tempDirectory.FullName, "rec-blocker"); + await File.WriteAllTextAsync(blocker, "not a directory", TestContext.CancellationToken); + var target = Path.Combine(blocker, "take.mp4"); + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-a", "TestApp", "--duration-sec", "1", "-o", target, "--json"]); + + Assert.AreEqual(1, exitCode); + AssertPreflightErrorCode("invalid_arguments"); + AssertNeverCoordinated("an uncreatable parent directory must be found before queueing"); + } + + [TestMethod] + public async Task Record_WithNoExplicitOutput_StillCoordinatesNormally() + { + // The generated default carries a timestamp and a GUID, so it cannot collide and must not be + // treated as a preflight failure. + var previous = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(_tempDirectory.FullName); + try + { + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-a", "TestApp", "--duration-sec", "1", "--json"]); + + Assert.AreEqual(0, exitCode); + Assert.AreEqual(1, _fakeDesktopLock.Runs.Count, "a default-path recording still takes its turn"); + } + finally + { + Directory.SetCurrentDirectory(previous); + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.Provenance.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.Provenance.cs new file mode 100644 index 000000000..12779f993 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.Provenance.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Commands; + +namespace WinApp.Cli.Tests; + +/// +/// Where a captured window came from, and why that is not the same question as who owns it now. +/// +/// +/// A screenshot deliberately includes the dialogs an app owns — common-item file pickers, print and +/// security dialogs — and those run in another process. So a captured window's current PID +/// is not evidence of anything: for an owned dialog it is a shared system host that also runs +/// dialogs for unrelated applications. Checking the handle against that PID accepts any live window +/// in the host, including one whose handle was recycled after the real dialog closed. The check has +/// to be against the application the window was discovered for, so the owner chain has to still +/// reach it. +/// +public partial class UiCommandTests +{ + private const int AppPid = 4321; + private const int SystemHostPid = 9100; + private const nint AppWindow = 0x100; + private const nint OwnedDialog = 0xD1A; + + /// Points the command at a single app window that owns one cross-process dialog. + private void ArrangeOwnedDialog(nint ownerOfDialog) + { + _fakeSystemQuery.ProcessIdForWindowResult = AppPid; + _fakeSystemQuery.WindowTextResult = "Main Window"; + _fakeSystemQuery.ProcessIdByHwnd[AppWindow] = AppPid; + + // The dialog belongs to a shared system host, not to the app. + _fakeSystemQuery.ProcessIdByHwnd[OwnedDialog] = SystemHostPid; + _fakeSystemQuery.WindowOwnerByHwnd[OwnedDialog] = ownerOfDialog; + + _fakeWindowFinder.OwnedWindowsResult = [(OwnedDialog, SystemHostPid, "Save As")]; + _fakeUia.ScreenshotResult = (new byte[4], 1, 1); + } + + private async Task CaptureOwnedDialogAsync() + { + var path = ShotPath(); + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-w", AppWindow.ToString(), "--json", "-o", path]); + + Assert.AreEqual(0, exitCode); + return System.Text.Json.JsonSerializer.Deserialize(TestAnsiConsole.Output); + } + + [TestMethod] + public async Task Screenshot_LiveCrossProcessOwnedDialog_IsCaptured() + { + // The dialog runs in another process and is still owned by the app window, which is exactly the + // case the validation must not break: a file picker is part of the app's UI. + ArrangeOwnedDialog(ownerOfDialog: AppWindow); + + var output = await CaptureOwnedDialogAsync(); + var windows = output.GetProperty("windows").EnumerateArray().ToList(); + + var dialog = windows.Single(w => w.GetProperty("hwnd").GetInt64() == OwnedDialog); + Assert.IsTrue(dialog.GetProperty("captured").GetBoolean(), + "an owned dialog in another process is legitimately part of this app's UI"); + } + + [TestMethod] + public async Task Screenshot_OwnedDialogHandleReusedInsideTheSameHost_IsNotCaptured() + { + // The regression. The real dialog closed and its handle was reused by an unrelated window in + // the SAME system host, so the handle still resolves to the host's PID and a check against + // that PID passes — while the owner link back to the app is gone. Under the old check this + // window was composited into an image labelled as this application's. + ArrangeOwnedDialog(ownerOfDialog: 0); // no owner: the relationship to the app no longer exists + + var output = await CaptureOwnedDialogAsync(); + var windows = output.GetProperty("windows").EnumerateArray().ToList(); + + Assert.IsFalse( + windows.Any(w => w.GetProperty("hwnd").GetInt64() == OwnedDialog), + "a handle that can no longer be traced back to the app must not be captured as its window"); + } + + [TestMethod] + public async Task Screenshot_OwnedDialogNowOwnedByAnUnrelatedWindow_IsNotCaptured() + { + // Same shape, but the recycled handle has acquired a different owner rather than none. It still + // cannot be attributed to this application. + ArrangeOwnedDialog(ownerOfDialog: 0x999); + _fakeSystemQuery.ProcessIdByHwnd[0x999] = SystemHostPid; + + var output = await CaptureOwnedDialogAsync(); + var windows = output.GetProperty("windows").EnumerateArray().ToList(); + + Assert.IsFalse( + windows.Any(w => w.GetProperty("hwnd").GetInt64() == OwnedDialog), + "an owner outside the app's own windows proves nothing about provenance"); + } + + [TestMethod] + public async Task Screenshot_DirectSamePidWindows_AreStillCaptured() + { + // The app's own windows expect their own process, so the stricter owned-window rule must not + // catch them. + _fakeUia.WindowsByPidResult = [((nint)41, AppPid, "Main"), ((nint)42, AppPid, "Tool")]; + _fakeSystemQuery.ProcessIdByHwnd[41] = AppPid; + _fakeSystemQuery.ProcessIdByHwnd[42] = AppPid; + _fakeUia.ScreenshotResult = (new byte[4], 1, 1); + + var path = ShotPath(); + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-a", AppPid.ToString(), "--json", "-o", path]); + + Assert.AreEqual(0, exitCode); + var output = System.Text.Json.JsonSerializer.Deserialize(TestAnsiConsole.Output); + var windows = output.GetProperty("windows").EnumerateArray().ToList(); + + Assert.HasCount(2, windows); + Assert.IsTrue(windows.All(w => w.GetProperty("captured").GetBoolean()), + "an app's own windows are validated against their own process and must still be captured"); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.cs index fe66a38da..d0e1bfcbc 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.cs @@ -256,6 +256,9 @@ public async Task Screenshot_WindowHandleValid_WithOwnedDialog_CompositesThrough _fakeSystemQuery.ProcessIdForWindowResult = 4321; _fakeSystemQuery.WindowTextResult = "Main Window"; _fakeWindowFinder.OwnedWindowsResult = [((nint)0xD1A, 4321, "Owned Dialog")]; + // A real owned dialog reports the app window as its GW_OWNER; that link is what attributes it + // to this application rather than to whatever process happens to host it. + _fakeSystemQuery.WindowOwnerByHwnd[0xD1A] = (nint)2748; _fakeUia.ScreenshotResult = (new byte[4], 1, 1); var path = ShotPath(); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiOwnerResolverTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiOwnerResolverTests.cs new file mode 100644 index 000000000..4c7b3f376 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiOwnerResolverTests.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Services.InteractiveDesktop; + +namespace WinApp.Cli.Tests; + +/// +/// How a workflow id becomes an owner key (). +/// +/// +/// The key is the only thing that decides whether two commands share the desktop, so two different +/// ids producing one key is a correctness failure, not a hashing nicety: the workflows would take +/// each other's turns and interleave on the same desktop while each believed it was alone. +/// +[TestClass] +[DoNotParallelize] // WINAPP_UI_WORKFLOW_ID is process-wide, so these must not race other classes. +public class UiOwnerResolverTests : IDisposable +{ + private string? _previousWorkflowId; + + [TestInitialize] + public void Setup() + => _previousWorkflowId = Environment.GetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable); + + [TestCleanup] + public void Cleanup() + => Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, _previousWorkflowId); + + public void Dispose() => GC.SuppressFinalize(this); + + private static UiOwnerIdentity ResolveWith(string? workflowId) + { + Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, workflowId); + return new UiOwnerResolver().Resolve(); + } + + // ------------------------------------------------------------------ ill-formed UTF-16 is not text + + /// + /// Built from char values in code rather than passed as [DataRow] constants: attribute + /// arguments are stored as UTF-8 in assembly metadata, so a lone surrogate written there is + /// substituted before the test ever runs and the case silently tests nothing. + /// + private static IEnumerable<(string Value, string Description)> IllFormedWorkflowIds() + { + const char high = '\ud800'; + const char low = '\udc00'; + yield return (high.ToString(), "lone high surrogate"); + yield return (low.ToString(), "lone low surrogate"); + yield return ("wf-" + '\ud801' + "-tail", "lone high surrogate inside a longer id"); + yield return ("lead" + '\udfff', "lone low surrogate at the end"); + } + + [TestMethod] + public void AnUnpairedSurrogateIsRefusedInsteadOfBeingSubstituted() + { + // Encoding these the ordinary way substitutes U+FFFD, which is why they must be refused: the + // substitution is lossy in exactly the direction that matters, mapping distinct ids onto one + // owner rather than onto distinct owners. + foreach (var (value, description) in IllFormedWorkflowIds()) + { + var ex = Assert.ThrowsExactly( + () => ResolveWith(value), $"{description} must be refused"); + + Assert.AreEqual(UiCoordinationErrorCodes.InvalidWorkflowId, ex.Code, description); + StringAssert.Contains(ex.Message, "surrogate", description); + } + } + + [TestMethod] + public void DistinctIllFormedIdsWouldOtherwiseCollideOnOneKey() + { + // The specific failure the refusal prevents. Under replacement encoding all three of these + // become the same bytes, so all three become the same owner — two unrelated workflows and one + // caller who legitimately used U+FFFD, sharing one turn on one desktop. + Assert.ThrowsExactly(() => ResolveWith('\ud800'.ToString())); + Assert.ThrowsExactly(() => ResolveWith('\ud801'.ToString())); + + // U+FFFD itself is an ordinary character and stays valid, so the two above cannot reach it. + var replacementChar = ResolveWith("\ufffd"); + Assert.AreEqual(UiOwnerKind.Workflow, replacementChar.Kind); + } + + // ------------------------------------------------------------------------- well-formed stays valid + + [TestMethod] + public void WellFormedIdsIncludingSurrogatePairsResolveToDistinctWorkflowOwners() + { + string[] ids = + [ + "plain-workflow", + "550e8400-e29b-41d4-a716-446655440000", + "\ud83d\ude80", // one astral character: a PAIR, not a lone surrogate + "\ufffd", + ]; + + var keys = new HashSet(StringComparer.Ordinal); + foreach (var id in ids) + { + var owner = ResolveWith(id); + Assert.AreEqual(UiOwnerKind.Workflow, owner.Kind, $"'{id}' must resolve to a workflow owner"); + Assert.IsTrue(keys.Add(owner.Key), $"'{id}' must not collide with another id"); + } + } + + [TestMethod] + public void TheKeyForAnOrdinaryIdIsUnchanged() + { + // Golden values: the strict encoding must not alter the key for text that was always valid, or + // every running workflow would be re-homed on upgrade. These are SHA-256 of + // "winapp-ui-workflow-v1\0" + the id, computed independently of this code. + // + // Computed directly rather than through the environment variable, which is process-wide and + // therefore the one input a parallel test could change underneath this assertion. + Assert.AreEqual( + "af0babd78c14cae807477f0a3085bfd1ad91a9b37d89733766dbf32af0dcc328", + UiOwnerResolver.ComputeWorkflowKey("550e8400-e29b-41d4-a716-446655440000")); + + Assert.AreEqual( + "99c43e974bc265169b8fac2a24c905b019dfa8e1aea94eeb47dafb3e9d24d929", + UiOwnerResolver.ComputeWorkflowKey("plain-workflow")); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs index 31b830b0d..c9071f3d5 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs @@ -129,9 +129,123 @@ public class Handler( return 1; } + // The output path is knowable now, so a recording that can never be written must not first + // queue for — and then occupy — a desktop turn. Only an explicit --output is checked here: + // the generated default carries a timestamp and a GUID, so it cannot collide, and resolving + // it now would produce a different name than the one Execute goes on to use. + if (parseResult.GetValue(SharedUiOptions.OutputOption) is { } explicitOutput) + { + return ValidateRecordingOutput( + explicitOutput, frames, json, parseResult.InvocationConfiguration.Error, out _, out _); + } + return null; } + /// + /// Resolves and validates where a recording will be written: a usable path, no collision with an + /// existing artifact, and a parent directory that exists and can be written to. + /// + /// + /// Shared by Preflight and ExecuteAsync rather than moved wholesale, because the two + /// answer different questions. Preflight rejects a doomed command before it takes a turn; the + /// re-check under the turn still matters because the file system can change while the command + /// queues, and the engine's own no-clobber check remains the final word. + /// + /// when the path is usable, otherwise the exit code to return. + private int? ValidateRecordingOutput( + string candidate, + bool frames, + bool json, + TextWriter errorOut, + out string fullPath, + out string? framesDirectory) + { + fullPath = ""; + framesDirectory = null; + + try + { + // A path ending in a separator names a directory, and a recording is a file. Left to + // GetFullPath it resolves to a directory path that only fails much later, mid-capture. + if (candidate.Length > 0 + && (candidate.EndsWith(Path.DirectorySeparatorChar) || candidate.EndsWith(Path.AltDirectorySeparatorChar))) + { + UiJsonError.Emit( + json, + UiJsonError.CodeInvalidArguments, + $"Invalid output path: '{candidate}' names a directory, not a file.", + errorOut: errorOut, + recoveryHint: "Pass --output a file path ending in .mp4."); + logger.LogError("{Symbol} Invalid output path: '{Path}' names a directory, not a file.", UiSymbols.Error, candidate); + return 1; + } + + fullPath = Path.GetFullPath(candidate); + framesDirectory = frames ? GetFramesDirectory(fullPath) : null; + + if (Directory.Exists(fullPath)) + { + UiJsonError.Emit( + json, + UiJsonError.CodeInvalidArguments, + $"Invalid output path: '{fullPath}' is an existing directory.", + errorOut: errorOut, + recoveryHint: "Pass --output a file path ending in .mp4."); + logger.LogError("{Symbol} Invalid output path: '{Path}' is an existing directory.", UiSymbols.Error, fullPath); + return 1; + } + + // Applies to every recording mode, not only --frames: replacing a take that already + // exists loses it, and the loss is silent because the command still reports success. + if (Path.Exists(fullPath)) + { + UiJsonError.Emit( + json, + UiJsonError.CodeOutputExists, + $"MP4 output already exists: {fullPath}", + errorOut: errorOut, + recoveryHint: "Choose a new --output path; recording never replaces existing artifacts."); + logger.LogError("{Symbol} MP4 output already exists: {Path}", UiSymbols.Error, fullPath); + return 1; + } + + if (framesDirectory is not null && Path.Exists(framesDirectory)) + { + UiJsonError.Emit( + json, + UiJsonError.CodeOutputExists, + $"Frame artifact output already exists: {framesDirectory}", + errorOut: errorOut, + recoveryHint: "Choose a new --output path; the derived frame directory already exists and is never replaced."); + logger.LogError("{Symbol} Frame artifact output already exists: {Path}", UiSymbols.Error, framesDirectory); + return 1; + } + + var dir = Path.GetDirectoryName(fullPath); + if (dir is not null) + { + Directory.CreateDirectory(dir); + } + + return null; + } + catch (Exception pathEx) when (pathEx is ArgumentException + or NotSupportedException + or PathTooLongException + or IOException + or UnauthorizedAccessException) + { + UiJsonError.Emit( + json, + UiJsonError.CodeInvalidArguments, + $"Invalid output path: {pathEx.Message}", + errorOut: errorOut); + logger.LogError("{Symbol} Invalid output path: {Message}", UiSymbols.Error, pathEx.Message); + return 1; + } + } + protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); @@ -158,53 +272,20 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn _stdinMonitorStopped = false; try { - // Resolve output path inside error handling so path errors produce structured output. + // Re-resolved under the turn. Preflight already rejected a doomed explicit path before + // this command queued; this pass covers the generated default and anything that changed + // on disk while waiting, and the engine's no-clobber check is still the final word. string filePath; - string? framesDirectory = null; - try + string? framesDirectory; + if (ValidateRecordingOutput( + output ?? $"recording-{DateTime.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}.mp4", + frames, + json, + parseResult.InvocationConfiguration.Error, + out filePath, + out framesDirectory) is { } outputFailure) { - // Avoid collisions between concurrent recordings using the default path. - filePath = Path.GetFullPath( - output ?? $"recording-{DateTime.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}.mp4"); - framesDirectory = frames ? GetFramesDirectory(filePath) : null; - - if (framesDirectory is not null) - { - if (Path.Exists(filePath)) - { - UiJsonError.Emit( - json, - UiJsonError.CodeOutputExists, - $"MP4 output already exists: {filePath}", - errorOut: parseResult.InvocationConfiguration.Error, - recoveryHint: "Choose a new --output path; recording never replaces existing artifacts."); - logger.LogError("{Symbol} MP4 output already exists: {Path}", UiSymbols.Error, filePath); - return 1; - } - if (Path.Exists(framesDirectory)) - { - UiJsonError.Emit( - json, - UiJsonError.CodeOutputExists, - $"Frame artifact output already exists: {framesDirectory}", - errorOut: parseResult.InvocationConfiguration.Error, - recoveryHint: "Choose a new --output path; the derived frame directory already exists and is never replaced."); - logger.LogError("{Symbol} Frame artifact output already exists: {Path}", UiSymbols.Error, framesDirectory); - return 1; - } - } - - var dir = Path.GetDirectoryName(filePath); - if (dir is not null) - { - Directory.CreateDirectory(dir); - } - } - catch (Exception pathEx) - { - UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, $"Invalid output path: {pathEx.Message}"); - logger.LogError("{Symbol} Invalid output path: {Message}", UiSymbols.Error, pathEx.Message); - return 1; + return outputFailure; } var uiTarget = await targetResolver.ResolveAsync(app, window, cancellationToken); diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs index 619ae16fe..3df0e5694 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs @@ -80,7 +80,74 @@ public class Handler( return 1; } - return null; + // Where the PNG goes is knowable now. Screenshot takes the desktop exclusively, so a command + // that cannot possibly write its file must not first queue for a turn, foreground a window + // and capture pixels only to fail at the last step — the user waited and every other + // workflow waited too, for nothing. + return ValidateScreenshotOutput( + parseResult.GetValue(SharedUiOptions.OutputOption) ?? DefaultOutputFileName, + json, + parseResult.InvocationConfiguration.Error); + } + + private const string DefaultOutputFileName = "screenshot.png"; + + /// + /// Checks that the screenshot can actually be written: a file-shaped path, not an existing + /// directory, and a parent directory that exists or can be created. + /// + /// + /// Overwriting an existing file stays deliberate — unlike a recording, a screenshot is + /// cheap to retake and callers rely on writing to a fixed name repeatedly. The write in + /// PublishAsync keeps its own directory creation and error handling, because the file + /// system can change while the command waits for its turn. + /// + /// when the path is usable, otherwise the exit code to return. + private int? ValidateScreenshotOutput(string candidate, bool json, TextWriter errorOut) + { + try + { + if (candidate.Length > 0 + && (candidate.EndsWith(Path.DirectorySeparatorChar) || candidate.EndsWith(Path.AltDirectorySeparatorChar))) + { + return InvalidOutput($"'{candidate}' names a directory, not a file."); + } + + var fullPath = Path.GetFullPath(candidate); + + if (Directory.Exists(fullPath)) + { + return InvalidOutput($"'{fullPath}' is an existing directory."); + } + + var dir = Path.GetDirectoryName(fullPath); + if (dir is not null) + { + Directory.CreateDirectory(dir); + } + + return null; + } + catch (Exception pathEx) when (pathEx is ArgumentException + or NotSupportedException + or PathTooLongException + or IOException + or UnauthorizedAccessException) + { + return InvalidOutput(pathEx.Message); + } + + int InvalidOutput(string detail) + { + UiJsonError.Emit( + json, + UiJsonError.CodeInvalidArguments, + $"Invalid output path: {detail}", + errorOut: errorOut, + recoveryHint: "Pass --output a writable file path ending in .png."); + logger.LogError("{Symbol} Invalid output path: {Detail}", UiSymbols.Error, detail); + return 1; + } } protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn turn, CancellationToken cancellationToken) @@ -145,6 +212,27 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn } /// Pixels captured by one pass, plus everything needed to publish them. + /// + /// One window the pass intends to capture, together with where it came from. + /// + /// + /// The process that currently owns the HWND. This is what the engine is handed, because it is + /// what the window really belongs to. + /// + /// + /// The process of the application this window was discovered *for*. For an app's own window that + /// is the same process; for a cross-process owned dialog — a common-item file picker, a system + /// print dialog — it is the app that owns the dialog, not the host that runs it. + /// + /// + /// The distinction is the whole point. Validating an owned dialog against its own current PID is + /// close to vacuous: any live window passes, including a recycled handle that now belongs to a + /// different dialog in the same system host and has no relationship to this app at all. + /// Validating against makes the owner chain prove the window + /// still belongs to the application being captured. + /// + private readonly record struct CaptureCandidate(nint Hwnd, int ActualPid, int ExpectedAppPid, string Title); + /// Set when the pass already reported a failure and produced no pixels. private sealed record CapturePass( int? ExitCode, @@ -189,15 +277,14 @@ private async Task CaptureUnderSectionAsync( if (selector is null) { var targetWindowHwnd = (nint)singleTarget.WindowHandle; - var ownedWindows = ownedWindowFinder.FindOwnedWindows( - [(targetWindowHwnd, singleTarget.ProcessId, singleTarget.WindowTitle ?? "")]); + var appWindows = new List<(nint Hwnd, int Pid, string Title)> + { + (targetWindowHwnd, singleTarget.ProcessId, singleTarget.WindowTitle ?? ""), + }; + var ownedWindows = ownedWindowFinder.FindOwnedWindows(appWindows); if (ownedWindows.Count > 0) { - var allWindows = new List<(nint Hwnd, int Pid, string Title)> - { - (targetWindowHwnd, singleTarget.ProcessId, singleTarget.WindowTitle ?? ""), - }; - allWindows.AddRange(ownedWindows); + var allWindows = ToCandidates(appWindows, ownedWindows); return await CaptureWindowsAsync(allWindows, singleTarget, json, captureScreen, focus, ct).ConfigureAwait(false); } } @@ -223,7 +310,7 @@ private async Task CaptureUnderSectionAsync( } private async Task CaptureWindowsAsync( - List<(nint Hwnd, int Pid, string Title)> windows, + List windows, UiTarget uiTarget, bool json, bool captureScreen, @@ -250,9 +337,13 @@ private async Task CaptureWindowsAsync( var title = string.IsNullOrEmpty(w.Title) ? "(no title)" : w.Title; // Each handle was discovered before this command waited for the desktop, so any of them - // could have closed and had its handle reused since. Capturing an unvalidated handle - // would composite another application's pixels into an image reported as this target's. - var state = DesktopTargetValidation.ClassifyTargetWindow(systemQuery, w.Hwnd, w.Pid); + // could have closed and had its handle reused since. The check is against the process the + // window was discovered FOR, not the one that currently owns it: for a cross-process + // owned dialog those differ, and comparing it with its own PID would accept any live + // window in the same system host — including a recycled handle with no connection to + // this app. Validating against the originating process makes the owner chain prove the + // dialog still belongs to it. + var state = DesktopTargetValidation.ClassifyTargetWindow(systemQuery, w.Hwnd, w.ExpectedAppPid); if (state != DesktopTargetValidation.TargetWindowState.Valid) { var reason = state == DesktopTargetValidation.TargetWindowState.Gone @@ -279,7 +370,9 @@ private async Task CaptureWindowsAsync( { var windowTarget = new UiTarget { - ProcessId = w.Pid, + // The engine is handed the process that really owns the window, which for an + // owned dialog is its host rather than the app it was discovered for. + ProcessId = w.ActualPid, ProcessName = uiTarget.ProcessName, WindowTitle = title, WindowHandle = w.Hwnd, @@ -343,7 +436,7 @@ private async Task CaptureWindowsAsync( ///
private async Task PublishAsync(CapturePass pass, string? output, bool json, CancellationToken ct) { - var filePath = output ?? "screenshot.png"; + var filePath = output ?? DefaultOutputFileName; var captures = pass.Captures; var pngBytes = pass.IsComposite @@ -455,7 +548,7 @@ private static byte[] ComposeSideBySide(List<(byte[] Pixels, int Width, int Heig /// Discover all windows for the target app, including cross-process owned windows. /// Returns null if we can't determine the app's windows (e.g., no --app provided). /// - private List<(nint Hwnd, int Pid, string Title)>? DiscoverAllWindows(string? app, long? window) + private List? DiscoverAllWindows(string? app, long? window) { List<(nint Hwnd, int Pid, string Title)> appWindows; @@ -502,9 +595,52 @@ private static byte[] ComposeSideBySide(List<(byte[] Pixels, int Width, int Heig // Also find cross-process owned windows var ownedWindows = ownedWindowFinder.FindOwnedWindows(appWindows); - appWindows.AddRange(ownedWindows); - return appWindows.Count > 1 ? appWindows : null; + return ToCandidates(appWindows, ownedWindows) is { Count: > 1 } candidates ? candidates : null; + } + + /// + /// Combines an app's own windows with the dialogs they own, recording for each the application + /// process it was discovered for. + /// + /// + /// The finder reports only direct owners — it admits a window when a single + /// GW_OWNER hop lands inside the app window set — so the owner is re-read here and matched + /// back to that set. A dialog whose owner cannot be matched is dropped rather than guessed at: + /// without provenance there is nothing to validate it against later, and capturing it would put + /// an unattributed window into an image labelled as this application's. + /// + private List ToCandidates( + List<(nint Hwnd, int Pid, string Title)> appWindows, + List<(nint Hwnd, int Pid, string Title)> ownedWindows) + { + var appPidByHwnd = new Dictionary(); + foreach (var w in appWindows) + { + appPidByHwnd[w.Hwnd] = w.Pid; + } + + // An app window stands for itself, so its expected process is its own. + var candidates = appWindows + .Select(w => new CaptureCandidate(w.Hwnd, w.Pid, w.Pid, w.Title)) + .ToList(); + + foreach (var owned in ownedWindows) + { + var ownerHwnd = systemQuery.GetWindowOwner(owned.Hwnd); + if (ownerHwnd != 0 && appPidByHwnd.TryGetValue(ownerHwnd, out var expectedAppPid)) + { + candidates.Add(new CaptureCandidate(owned.Hwnd, owned.Pid, expectedAppPid, owned.Title)); + } + else + { + logger.LogDebug( + "Skipping owned window {Hwnd}: its owner is no longer one of the application's windows.", + owned.Hwnd); + } + } + + return candidates; } private static byte[] EncodePng(byte[] bgraPixels, int width, int height) diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiOwnerResolver.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiOwnerResolver.cs index cad51acd9..50bdfe0c7 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiOwnerResolver.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiOwnerResolver.cs @@ -93,7 +93,25 @@ private static UiOwnerIdentity ResolveWorkflow(string raw) $"Set {WorkflowIdVariable} to a short opaque value such as a GUID."); } - return new UiOwnerIdentity(UiOwnerKind.Workflow, ComputeWorkflowKey(raw)); + return ResolveWorkflowKey(raw); + } + + private static UiOwnerIdentity ResolveWorkflowKey(string raw) + { + try + { + return new UiOwnerIdentity(UiOwnerKind.Workflow, ComputeWorkflowKey(raw)); + } + catch (EncoderFallbackException) + { + // An unpaired surrogate is not text. Encoding it with the usual replacement behaviour turns + // every such value into U+FFFD, so "\uD800", "\uD801" and a literal "\uFFFD" would all hash + // to one key and three unrelated workflows would silently share an owner — and the desktop. + throw new UiCoordinationException( + UiCoordinationErrorCodes.InvalidWorkflowId, + $"{WorkflowIdVariable} is not valid text: it contains an unpaired UTF-16 surrogate.", + $"Set {WorkflowIdVariable} to a plain text value identifying one logical UI workflow, for example a GUID."); + } } /// @@ -101,8 +119,19 @@ private static UiOwnerIdentity ResolveWorkflow(string raw) /// to contain a path, ticket number or user name never reaches disk, and the domain prefix keeps a /// named workflow from ever colliding with an anonymous owner. /// + /// + /// The encoding is strict on purpose. substitutes U+FFFD for anything + /// it cannot encode, which would map distinct ill-formed ids onto one key; throwing instead makes + /// that collision structurally impossible rather than merely unlikely. + /// + /// + /// is not well-formed UTF-16. + /// internal static string ComputeWorkflowKey(string rawWorkflowId) - => Hash(Encoding.UTF8.GetBytes(WorkflowDomain + rawWorkflowId)); + => Hash(s_strictUtf8.GetBytes(WorkflowDomain + rawWorkflowId)); + + /// UTF-8 that refuses to encode ill-formed UTF-16 rather than substituting U+FFFD. + private static readonly UTF8Encoding s_strictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); /// /// A fresh key per call. Two no-ID commands are therefore different owners even when they come from diff --git a/src/winapp-npm/src/winapp-cli-utils.ts b/src/winapp-npm/src/winapp-cli-utils.ts index 58a8b55c2..295cbf996 100644 --- a/src/winapp-npm/src/winapp-cli-utils.ts +++ b/src/winapp-npm/src/winapp-cli-utils.ts @@ -8,6 +8,30 @@ export const WINAPP_CLI_CALLER_VALUE = 'nodejs-package'; /** Environment variable naming one logical UI workflow for cooperative desktop turns. */ export const WINAPP_UI_WORKFLOW_ID = 'WINAPP_UI_WORKFLOW_ID'; +/** + * Matches a UTF-16 code unit that is half of a surrogate pair with nothing to pair with — a high + * surrogate not followed by a low one, or a low surrogate not preceded by a high one. + */ +const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? void): void => { + if (settled) { + return; + } + + settled = true; + finish(); + }; + child.on('close', (code) => { - if (code === 0) { - resolve({ exitCode: code }); - } else { - if (exitOnError) { + settle(() => { + if (code === 0) { + resolve({ exitCode: code }); + } else if (exitOnError) { process.exit(code ?? 1); } else { reject(new Error(`winapp-cli exited with code ${code}`)); } - } + }); }); child.on('error', (error) => { - // An aborted spawn surfaces here as an AbortError. Propagate it unchanged so callers can - // distinguish "I cancelled this" from "the CLI could not be launched", and never call - // process.exit for it — cancellation is the caller's decision, not a fatal tool failure. - if (isAbortError(error)) { - reject(error); - return; - } + settle(() => { + // An aborted spawn surfaces here as an AbortError. Propagate it unchanged so callers can + // distinguish "I cancelled this" from "the CLI could not be launched", and never call + // process.exit for it — cancellation is the caller's decision, not a fatal tool failure. + if (isAbortError(error)) { + reject(error); + return; + } - if (exitOnError) { - console.error(`Failed to execute winapp-cli: ${error.message}`); - console.error(`Tried to run: ${winappCliPath}`); - process.exit(1); - } else { - reject(new Error(`Failed to execute winapp-cli: ${error.message}`)); - } + if (exitOnError) { + console.error(`Failed to execute winapp-cli: ${error.message}`); + console.error(`Tried to run: ${winappCliPath}`); + process.exit(1); + } else { + reject(new Error(`Failed to execute winapp-cli: ${error.message}`)); + } + }); }); }); } @@ -178,29 +220,46 @@ export async function callWinappCliCapture( child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk)); child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); - child.on('close', (code) => { - const stdout = Buffer.concat(stdoutChunks).toString('utf8'); - const stderr = Buffer.concat(stderrChunks).toString('utf8'); - const exitCode = code ?? 1; - - if (exitCode === 0) { - resolve({ exitCode, stdout, stderr }); - } else { - const error = new Error(`winapp-cli exited with code ${exitCode}: ${stderr || stdout}`) as Error & { - exitCode: number; - stdout: string; - stderr: string; - }; - error.exitCode = exitCode; - error.stdout = stdout; - error.stderr = stderr; - reject(error); + // Same exactly-once gate as callWinappCli: an aborted spawn raises 'error' and then 'close', and + // the first outcome is the one the caller asked about. Guarding here as well keeps the two entry + // points behaving identically and stops a late event from ever reaching a side effect. + let settled = false; + const settle = (finish: () => void): void => { + if (settled) { + return; } + + settled = true; + finish(); + }; + + child.on('close', (code) => { + settle(() => { + const stdout = Buffer.concat(stdoutChunks).toString('utf8'); + const stderr = Buffer.concat(stderrChunks).toString('utf8'); + const exitCode = code ?? 1; + + if (exitCode === 0) { + resolve({ exitCode, stdout, stderr }); + } else { + const error = new Error(`winapp-cli exited with code ${exitCode}: ${stderr || stdout}`) as Error & { + exitCode: number; + stdout: string; + stderr: string; + }; + error.exitCode = exitCode; + error.stdout = stdout; + error.stderr = stderr; + reject(error); + } + }); }); child.on('error', (error) => { - // Propagate an AbortError unchanged so callers can tell cancellation apart from a launch failure. - reject(isAbortError(error) ? error : new Error(`Failed to execute winapp-cli: ${error.message}`)); + settle(() => { + // Propagate an AbortError unchanged so callers can tell cancellation apart from a launch failure. + reject(isAbortError(error) ? error : new Error(`Failed to execute winapp-cli: ${error.message}`)); + }); }); }); } diff --git a/src/winapp-npm/test/abort-signal.test.ts b/src/winapp-npm/test/abort-signal.test.ts index ab35055bf..f70189a41 100644 --- a/src/winapp-npm/test/abort-signal.test.ts +++ b/src/winapp-npm/test/abort-signal.test.ts @@ -159,3 +159,65 @@ test('a real spawn failure is still wrapped with the winapp-cli context', async /Failed to execute winapp-cli/ ); }); + +/** + * Emits what Node really emits for an aborted spawn: the AbortError on 'error', and then 'close' + * with a non-zero code, because the child was killed. + */ +function abortingSpawnThatAlsoCloses(): void { + mock.method(childProcess, 'spawn', ((_cmd: string, _args: string[], _options: SpawnOptions) => { + const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + process.nextTick(() => { + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + child.emit('error', error); + child.emit('close', 1); + }); + return child; + }) as unknown as typeof childProcess.spawn); +} + +test('aborting an exitOnError call rejects without terminating the host process', async () => { + // Both events arrive for a single aborted spawn. The promise ignores the second settle on its own, + // but process.exit does not: without a gate the caller is handed an AbortError to handle and the + // whole process is then torn down underneath it, which for a library is the worst of both. + abortingSpawnThatAlsoCloses(); + const controller = new AbortController(); + + const exitCalls: (number | undefined)[] = []; + mock.method(process, 'exit', ((code?: number) => { + exitCalls.push(code); + // Deliberately does NOT terminate, so the test can observe the bug instead of dying with it. + return undefined as never; + }) as typeof process.exit); + + await assert.rejects( + () => callWinappCli(['ui', 'click'], { signal: controller.signal, exitOnError: true }), + (error: Error) => { + assert.equal(error.name, 'AbortError'); + return true; + } + ); + + // Give the trailing 'close' every chance to be processed before asserting. + await new Promise((r) => setImmediate(r)); + + assert.deepEqual(exitCalls, [], 'a cancelled call must never call process.exit'); +}); + +test('a late close cannot re-settle an aborted capture call', async () => { + abortingSpawnThatAlsoCloses(); + const controller = new AbortController(); + + await assert.rejects( + () => callWinappCliCapture(['ui', 'click'], { signal: controller.signal }), + (error: Error) => { + assert.equal(error.name, 'AbortError', 'the first outcome is the one the caller asked about'); + return true; + } + ); + + await new Promise((r) => setImmediate(r)); +}); diff --git a/src/winapp-npm/test/workflow-id.test.ts b/src/winapp-npm/test/workflow-id.test.ts index bc2f726b7..39e59035f 100644 --- a/src/winapp-npm/test/workflow-id.test.ts +++ b/src/winapp-npm/test/workflow-id.test.ts @@ -1,8 +1,10 @@ // Copyright (c) Microsoft Corporation and Contributors. All rights reserved. // Licensed under the MIT License. -import { test } from 'node:test'; +import { test, mock } from 'node:test'; import * as assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import childProcess = require('child_process'); import { WINAPP_UI_WORKFLOW_ID } from '../src/winapp-cli-utils'; import { uiListWindows } from '../src/winapp-commands'; @@ -45,3 +47,53 @@ test('omitting workflowId leaves an inherited value untouched', async () => { } } }); + +// An unpaired surrogate is not text. Node replaces every one of them with U+FFFD while building the +// child environment, so "\uD800", "\uD801" and a literal "\uFFFD" all arrive at the CLI as the same +// valid string. The CLI cannot tell them apart — it would see well-formed text and group three +// unrelated workflows into one owner sharing one desktop turn — so the check has to happen here, +// before the value crosses the process boundary and the distinction is lost. + +test('an ill-formed workflowId is rejected before the CLI is spawned', async () => { + const spawnCalls: unknown[] = []; + mock.method(childProcess, 'spawn', ((..._args: unknown[]) => { + spawnCalls.push(_args); + const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + process.nextTick(() => child.emit('close', 0)); + return child; + }) as unknown as typeof childProcess.spawn); + + for (const ill of ['\uD800', '\uDC00', 'wf-\uD801-tail', 'lead\uDFFF']) { + await assert.rejects( + () => uiListWindows({ workflowId: ill }), + /unpaired UTF-16 surrogate/, + `expected ${JSON.stringify(ill)} to be refused` + ); + } + + assert.equal(spawnCalls.length, 0, 'an ill-formed workflow id must never reach a child process'); + mock.restoreAll(); +}); + +test('well-formed workflow ids — including a real U+FFFD — are still accepted', async () => { + const spawnCalls: unknown[] = []; + mock.method(childProcess, 'spawn', ((..._args: unknown[]) => { + spawnCalls.push(_args); + const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + process.nextTick(() => child.emit('close', 0)); + return child; + }) as unknown as typeof childProcess.spawn); + + // A surrogate PAIR is one valid astral character, and U+FFFD is an ordinary character a caller is + // entitled to use; neither may be caught by the ill-formed check. + for (const ok of ['plain-workflow', '550e8400-e29b-41d4-a716-446655440000', '\uD83D\uDE80', '\uFFFD']) { + await uiListWindows({ workflowId: ok }).catch(() => undefined); + } + + assert.equal(spawnCalls.length, 4, 'every well-formed workflow id must still spawn the CLI'); + mock.restoreAll(); +}); From a30c00b220215c73f832423a84fbf54ccae2667b Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:57:46 -0700 Subject: [PATCH 22/29] Wake queued UI commands instead of making them poll for their turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A queued `winapp ui` command used to re-read coordination state every 50-75 ms until its turn came. That is fine for two or three commands and quadratic-feeling by sixty: every waiter woke twenty times a second, took state.lock, and asked the OS whether each of the other waiters was still alive. Seventy concurrent commands spent 299 seconds of CPU to do 16 seconds of work, and took two and a half minutes to drain. Waiting is now push-based. Each process opens a named auto-reset event before it publishes any state entry naming itself, and whoever changes the state wakes exactly the participants that change made runnable. The set is computed by comparing what the state said was runnable before a transaction with what it says after, so it is a property of the state rather than of any one transition — promotion, same-owner absorption, barrier release, cancellation cleanup and crash pruning all reach it, and a transition added later cannot forget to wake anyone. The scheduler stays the only authority. A signal is a hint that something may have changed; every waiter still re-reads state under state.lock and re-checks its own status before doing anything, so a duplicate, stale or misdirected wake costs one lock acquisition and nothing else. Auto-reset semantics close the other half: a wake-up delivered while a waiter is between its state read and its next wait stays latched and is consumed immediately, so there is no window in which a promotion can be missed. Pure push would strand people, because the interesting failures are the ones where nobody is left to send anything. A killed owner publishes no completion; a killed queue head blocks everyone behind it; a promoter can die between publishing and signalling. So waiters keep a deadline. Only the head of the queue — and a command at the front of its own owner's barrier — recheck briskly, because they are the ones whose progress can depend on a process that died silently; everyone else cannot run before the head does anyway and keeps a much longer backstop. The head also wakes exactly at an idle grace expiry, which is a deadline nobody announces. Removing the polling exposed the second half of the cost. Inside one state.lock transaction the coordinator normalized — which prunes every dead participant — and then asked the OS about those same participants twice more, to count live waiters for the cap and to compute a queue position. A normalized list is already only live entries, so those answers could not differ; at a full queue it was 192 process handles per admitted command instead of 64. Admission, promotion, diagnostics and observed depth now read the pruned lists. The probing queue-position overload stays for cancellation teardown, which is the one caller with no preceding normalization. Verbose diagnostics are also built only when a status line is actually due, which at the old poll rate was invisible and is now most of what a waiter would do. Measured on this machine against the same commands, an isolated coordination directory and the same harness, with the pre-change binary as the baseline: 8 anonymous 1.88s -> 1.89s CPU 1.41s -> 1.62s 32 anonymous 7.56s -> 6.24s CPU 14.28s -> 7.08s 32 same workflow 5.13s -> 6.89s CPU 12.48s -> 7.00s 70 anonymous 152.88s -> 16.52s CPU 299.72s -> 16.95s Nine times faster to drain and eighteen times less CPU at seventy, no change worth claiming at eight, and no stranded processes anywhere. Peak observed queue depth at seventy fell from 60 to 50 — not because fewer commands queued, but because the queue now drains faster than a shell can launch into it. MaxGlobalWaiters stays 64. The docs now say what it has always counted: live waiters belonging to other workflows, not processes started and not a workflow's own commands queued behind each other. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/npm-usage.md | 6 +- docs/ui-automation.md | 13 +- .../references/ui-json-envelope.md | 2 +- .../FakeParticipantSignals.cs | 107 +++++++ .../InteractiveDesktopLockTests.PushQueue.cs | 246 ++++++++++++++++ .../InteractiveDesktopLockTests.cs | 5 +- ...activeDesktopMultiprocessTests.Recovery.cs | 118 ++++++++ .../InteractiveDesktopMultiprocessTests.cs | 8 +- ...InteractiveDesktopSignalSchedulingTests.cs | 263 ++++++++++++++++++ .../Helpers/HostBuilderExtensions.cs | 1 + .../InteractiveDesktopLock.cs | 239 ++++++++++++++-- .../InteractiveDesktopScheduler.cs | 83 +++++- .../InteractiveDesktop/ParticipantSignals.cs | 176 ++++++++++++ .../UiCoordinationWaitReporter.cs | 18 ++ 14 files changed, 1242 insertions(+), 43 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/FakeParticipantSignals.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.PushQueue.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.Recovery.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSignalSchedulingTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ParticipantSignals.cs diff --git a/docs/npm-usage.md b/docs/npm-usage.md index 40eebba2e..511fbb838 100644 --- a/docs/npm-usage.md +++ b/docs/npm-usage.md @@ -407,7 +407,7 @@ function packageApp(options: PackageOptions): Promise ### `restore()` -Use after cloning a repo or when .winapp/ folder is missing. Reinstalls SDK packages from existing winapp.yaml without changing versions. Requires winapp.yaml (created by 'init'). To check for newer SDK versions, use 'update' instead. +Use after cloning a repo or when .winapp/ folder is missing. Reinstalls SDK packages without changing versions, reading them from winapp.yaml or, for a .NET project initialized by 'init', from the .csproj via 'dotnet restore'. Requires a project already initialized by 'init'. To check for newer SDK versions, use 'update' instead. ```typescript function restore(options?: RestoreOptions): Promise @@ -418,7 +418,7 @@ function restore(options?: RestoreOptions): Promise | Property | Type | Required | Description | |----------|------|----------|-------------| | `baseDirectory` | `string \| undefined` | No | Base/root directory for the winapp workspace | -| `configDir` | `string \| undefined` | No | Directory to read configuration from (default: current directory) | +| `configDir` | `string \| undefined` | No | Directory to read configuration from (default: base-directory) | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* @@ -1604,7 +1604,7 @@ type ManifestTemplates = "packaged" | "sparse" | Property | Type | Required | Description | |----------|------|----------|-------------| | `baseDirectory` | `string \| undefined` | No | Base/root directory for the winapp workspace | -| `configDir` | `string \| undefined` | No | Directory to read configuration from (default: current directory) | +| `configDir` | `string \| undefined` | No | Directory to read configuration from (default: base-directory) | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | diff --git a/docs/ui-automation.md b/docs/ui-automation.md index b373c0e23..32bf2cb86 100644 --- a/docs/ui-automation.md +++ b/docs/ui-automation.md @@ -107,8 +107,17 @@ record a workflow driving an app. Two caveats: Errors you may see: `invalid_ui_workflow_id` (the variable is set but empty or over 256 characters), `desktop_coordination_unavailable` (coordination state is unreadable and cannot be safely rebuilt, or -was written by a newer `winapp`), `queue_capacity_exceeded` (64 commands are already waiting), and -`cancelled` (Ctrl+C while waiting, exit code `130`). +was written by a newer `winapp`), `queue_capacity_exceeded` (64 commands from **other** workflows are +already waiting — the limit counts live foreign waiters, not processes you have started, so entries +belonging to commands that have exited or been killed do not occupy a slot, and your own workflow's +commands queue behind each other rather than against this limit), and `cancelled` (Ctrl+C while +waiting, exit code `130`). + +A waiting command is woken by whoever releases the desktop rather than by polling for it, so a queue +costs almost nothing while it waits and handoff is immediate. Each waiter also rechecks on its own +occasionally, which is what recovers the desktop when a process is killed and never publishes +anything: the command at the head of the queue looks every half second, and commands behind it — +which cannot run before the head does anyway — every few seconds. ## Targeting Apps diff --git a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md index cfc130e8f..abf560400 100644 --- a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md +++ b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md @@ -115,7 +115,7 @@ appear: |---|---| | `invalid_ui_workflow_id` | `WINAPP_UI_WORKFLOW_ID` is set but empty/whitespace or longer than 256 characters. Fails before any UI side effect. | | `desktop_coordination_unavailable` | Coordination state could not be read, published, or safely rebuilt — including state written by a newer `winapp`. Mutating commands fail closed rather than acting uncoordinated. | -| `queue_capacity_exceeded` | 64 commands are already waiting for the desktop. | +| `queue_capacity_exceeded` | 64 commands from other workflows are already waiting for the desktop. Counts live foreign waiters, so entries left by commands that exited or were killed do not occupy a slot. | | `cancelled` | Native Ctrl+C while the command was still waiting for its turn. The command never ran, so it has no UI side effects. Exit code **130**. | An npm `AbortSignal` is a different contract: Node force-terminates the child, diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeParticipantSignals.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeParticipantSignals.cs new file mode 100644 index 000000000..89fbd4644 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeParticipantSignals.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Services.InteractiveDesktop; + +namespace WinApp.Cli.Tests; + +/// +/// In-memory stand-in for the named-event wake-up channels, so queue behavior can be tested without +/// real cross-process events. +/// +/// +/// Models the two properties the real primitive is chosen for: a signal delivered before anyone waits +/// stays latched and is consumed by the next wait, and one signal releases exactly one wait. It also +/// records every timeout a waiter asked for, which is how the recovery-deadline schedule is asserted +/// without sleeping through it. +/// +internal sealed class FakeParticipantSignals : IParticipantSignals +{ + private readonly Dictionary<(int Pid, long Start), FakeSignal> _channels = []; + private readonly Lock _sync = new(); + + /// Every participant that was woken, in order, including repeats. + public List<(int Pid, long StartTicksUtc)> Signalled { get; } = []; + + /// Timeouts requested by waits, in order, so a test can assert the recovery schedule. + public List RequestedTimeouts { get; } = []; + + /// Signals delivered to participants that never opened a channel. + public List<(int Pid, long StartTicksUtc)> SignalledWithoutChannel { get; } = []; + + public IParticipantSignal Create(int processId, long startTicksUtc) + { + lock (_sync) + { + var signal = new FakeSignal(this); + _channels[(processId, startTicksUtc)] = signal; + return signal; + } + } + + public void Signal(int processId, long startTicksUtc) + { + FakeSignal? channel; + lock (_sync) + { + Signalled.Add((processId, startTicksUtc)); + if (!_channels.TryGetValue((processId, startTicksUtc), out channel)) + { + SignalledWithoutChannel.Add((processId, startTicksUtc)); + return; + } + } + + channel.Set(); + } + + /// How many times was woken. + public int SignalCountFor(UiParticipantIdentity participant) + { + lock (_sync) + { + return Signalled.Count(s => s.Pid == participant.ProcessId && s.StartTicksUtc == participant.StartTicksUtc); + } + } + + /// Wakes a participant directly, standing in for another process's promotion. + public void SignalDirect(UiParticipantIdentity participant) + => Signal(participant.ProcessId, participant.StartTicksUtc); + + internal sealed class FakeSignal(FakeParticipantSignals owner) : IParticipantSignal + { + // Capacity one: the latch either holds a pending wake-up or it does not, exactly like an + // auto-reset event. Releasing twice cannot bank two wake-ups. + private readonly SemaphoreSlim _latch = new(0, 1); + + public bool Disposed { get; private set; } + + public void Set() + { + try + { + _latch.Release(); + } + catch (SemaphoreFullException) + { + // Already latched; a second signal before anyone waits is not two wake-ups. + } + } + + public async Task WaitAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + lock (owner._sync) + { + owner.RequestedTimeouts.Add(timeout); + } + + return await _latch.WaitAsync(timeout, cancellationToken).ConfigureAwait(false); + } + + public void Dispose() + { + Disposed = true; + _latch.Dispose(); + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.PushQueue.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.PushQueue.cs new file mode 100644 index 000000000..d10cb5df4 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.PushQueue.cs @@ -0,0 +1,246 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Services.InteractiveDesktop; + +namespace WinApp.Cli.Tests; + +/// +/// How a queued command sleeps and what wakes it, over the real store, leases and file locks. +/// +/// +/// Waiting used to be a 50-75 ms poll, so every one of these questions had the same boring answer: +/// the command would notice within a poll. Now a waiter can sleep for seconds, so what wakes it — and +/// what happens when nothing does — is the behavior worth pinning down. +/// +public partial class InteractiveDesktopLockTests +{ + /// Waits for a condition without pinning the schedule to wall-clock luck. + private static async Task EventuallyAsync(Func condition, int timeoutMs = 15_000) + { + var deadline = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < deadline) + { + if (condition()) + { + return true; + } + + await Task.Delay(20); + } + + return condition(); + } + + // ----------------------------------------------------------------------------- what wakes a waiter + + [TestMethod] + public async Task CompletingACommandWakesTheWaiterItPromoted() + { + // The ordinary handoff. Without this the waiter would sit until a recovery deadline, which is + // the difference between a script that flows and one that stutters for half a second per step. + using var foreignLease = OccupyTurnWithAnotherOwner(); + + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var queued = RunAsyncWithToken(UiTurnMode.DesktopExclusive, "ui click", (_, _) => + { + started.SetResult(); + return Task.FromResult(0); + }, CancellationToken.None); + + Assert.IsTrue( + await EventuallyAsync(() => _signals.RequestedTimeouts.Count > 0), + "the command should be waiting on its signal rather than polling"); + Assert.IsFalse(started.Task.IsCompleted); + + // Releasing the foreign lease is what a crash looks like; the waiter recovers it itself. + foreignLease.Dispose(); + + Assert.AreEqual(0, await queued); + Assert.IsTrue(started.Task.IsCompleted); + } + + [TestMethod] + public async Task AWakeUpDeliveredBeforeTheWaitIsNotLost() + { + // The race the auto-reset event exists to close: a promoter can publish and signal in the window + // between a waiter reading state and actually waiting. A latch that only released waiters + // already parked would strand that command until its backstop fired. + var signal = _signals.Create(4242, 99); + + _signals.Signal(4242, 99); + + var woken = await signal.WaitAsync(TimeSpan.FromMilliseconds(50), CancellationToken.None); + Assert.IsTrue(woken, "a signal delivered before the wait must still release it"); + + // ...and it is consumed, not sticky: one signal is one wake-up. + var again = await signal.WaitAsync(TimeSpan.FromMilliseconds(50), CancellationToken.None); + Assert.IsFalse(again, "the latch must auto-reset so a stale signal cannot wake the next wait too"); + } + + [TestMethod] + public async Task AStaleWakeUpCannotStartACommandThatIsStillQueued() + { + // Signals are hints, not authority. A duplicate or misdirected wake must cost one state read + // and nothing else — never a command running out of turn on somebody else's desktop. + using var foreignLease = OccupyTurnWithAnotherOwner(); + + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var queued = RunAsyncWithToken(UiTurnMode.DesktopExclusive, "ui click", (_, _) => + { + started.SetResult(); + return Task.FromResult(0); + }, CancellationToken.None); + + Assert.IsTrue(await EventuallyAsync(() => _signals.RequestedTimeouts.Count > 0)); + + var self = new UiParticipantIdentity(Environment.ProcessId, ProcessStartTicks(), "ui click"); + for (var i = 0; i < 5; i++) + { + _signals.SignalDirect(self); + await Task.Delay(30); + } + + Assert.IsFalse( + started.Task.IsCompleted, + "the turn is still held, so no number of wake-ups may let this command run"); + + foreignLease.Dispose(); + Assert.AreEqual(0, await queued); + } + + // --------------------------------------------------------------------------- the recovery schedule + + [TestMethod] + public async Task TheQueueHeadRechecksBrisklyBecauseNobodyMayBeAliveToWakeIt() + { + using var foreignLease = OccupyTurnWithAnotherOwner(); + + using var cts = new CancellationTokenSource(); + var queued = RunAsyncWithToken( + UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0), cts.Token); + + Assert.IsTrue(await EventuallyAsync(() => _signals.RequestedTimeouts.Count > 0)); + + var firstTimeout = _signals.RequestedTimeouts[0]; + Assert.AreEqual( + InteractiveDesktopLock.HeadRecoveryMs, + firstTimeout.TotalMilliseconds, + $"the only waiter is the head and must take the short interval; it asked for {firstTimeout}"); + + await cts.CancelAsync(); + Assert.AreEqual(InteractiveDesktopLock.CancelledExitCode, await queued); + } + + [TestMethod] + public async Task AWaiterBehindTheHeadSleepsOnTheLongBackstop() + { + // Someone else is the head, so this command's turn cannot come before theirs — and they will be + // woken and will wake this one in turn. Its own deadline only covers a promoter that died + // between publishing and signalling, which is why it is ten times longer. + using var foreignLease = OccupyTurnWithAnotherOwner(); + using var headLease = QueueForeignWaiterAhead(foreignPid: 515151, foreignStart: 123123); + + using var cts = new CancellationTokenSource(); + var queued = RunAsyncWithToken( + UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0), cts.Token); + + Assert.IsTrue(await EventuallyAsync(() => _signals.RequestedTimeouts.Count > 0)); + + var firstTimeout = _signals.RequestedTimeouts[0]; + Assert.AreEqual( + InteractiveDesktopLock.DeepRecoveryMs, + firstTimeout.TotalMilliseconds, + $"a waiter behind the head must not recheck at head cadence; it asked for {firstTimeout}"); + + await cts.CancelAsync(); + Assert.AreEqual(InteractiveDesktopLock.CancelledExitCode, await queued); + } + + [TestMethod] + public async Task AQuietWaiterDoesNotWakeUpRepeatedlyToSayNothing() + { + // The measurable half of the change: with no status line to print and no signal to consume, a + // deep waiter should be asleep, not spinning. Under the old poll this window held ~30 wake-ups. + using var foreignLease = OccupyTurnWithAnotherOwner(); + using var headLease = QueueForeignWaiterAhead(foreignPid: 626262, foreignStart: 456456); + + using var cts = new CancellationTokenSource(); + var queued = RunAsyncWithToken( + UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0), cts.Token); + + Assert.IsTrue(await EventuallyAsync(() => _signals.RequestedTimeouts.Count > 0)); + await Task.Delay(1_500); + + Assert.IsLessThanOrEqualTo( + 2, + _signals.RequestedTimeouts.Count, + $"a quiet deep waiter must sleep rather than poll; it woke {_signals.RequestedTimeouts.Count} times in 1.5s"); + + await cts.CancelAsync(); + Assert.AreEqual(InteractiveDesktopLock.CancelledExitCode, await queued); + } + + [TestMethod] + public async Task CancellingAQueuedCommandWakesTheParticipantsItLeavesBehind() + { + // Cancellation removes an entry, which can be exactly what lets someone else through, so the + // teardown path has to wake people just like an ordinary completion does. + using var foreignLease = OccupyTurnWithAnotherOwner(); + + using var cts = new CancellationTokenSource(); + var queued = RunAsyncWithToken( + UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0), cts.Token); + + Assert.IsTrue(await EventuallyAsync(() => _signals.RequestedTimeouts.Count > 0)); + await cts.CancelAsync(); + Assert.AreEqual(InteractiveDesktopLock.CancelledExitCode, await queued); + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var state = _store.Read().State!; + Assert.IsFalse( + state.Waiters.Any(w => w.Pid == Environment.ProcessId), + "a cancelled command must leave no queue entry behind for others to prune"); + } + + /// + /// Adds a live foreign waiter ahead of this process, so the test process is deliberately not the + /// head of the queue. + /// + private FileStream QueueForeignWaiterAhead(int foreignPid, long foreignStart) + { + _paths.EnsureDirectories(); + var leaseStream = new FileStream( + _paths.LeasePath(foreignPid, foreignStart), + FileMode.Create, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.DeleteOnClose); + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var state = _store.Read().State!; + + // Tickets must be unique and at least 1, and nextTicket must stay above every ticket in use — + // the store rejects state that breaks either, because ambiguous tickets would make both the + // forward barrier and global FIFO order undefined. Allocating properly is also what puts this + // waiter genuinely ahead of the command the test starts next. + var ticket = state.NextTicket; + state.NextTicket = ticket + 1; + state.Waiters.Add(new WaiterEntry + { + Ticket = ticket, + OwnerKey = "yet-another-workflow", + OwnerKind = UiOwnerKind.Workflow, + Pid = foreignPid, + ProcessStartTicksUtc = foreignStart, + Operation = "ui click", + Mode = UiTurnMode.DesktopExclusive, + }); + _store.Publish(state); + + return leaseStream; + } + + private static long ProcessStartTicks() => new ProcessInspector().CurrentProcessStartTicksUtc; +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs index 97557a649..980a4656b 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs @@ -17,7 +17,7 @@ namespace WinApp.Cli.Tests; /// [TestClass] [DoNotParallelize] // WINAPP_UI_LOCK_DIRECTORY and WINAPP_UI_WORKFLOW_ID are process-wide. -public class InteractiveDesktopLockTests +public partial class InteractiveDesktopLockTests { private string _lockDirectory = null!; private string? _previousLockOverride; @@ -26,6 +26,7 @@ public class InteractiveDesktopLockTests private ParticipantRegistry _participants = null!; private InteractiveDesktopStateStore _store = null!; private InteractiveDesktopLock _coordinator = null!; + private FakeParticipantSignals _signals = null!; [TestInitialize] public void Setup() @@ -41,6 +42,7 @@ public void Setup() Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, "interactive-desktop-lock-tests"); var inspector = new ProcessInspector(); + _signals = new FakeParticipantSignals(); _paths = new InteractiveDesktopPaths(inspector); _participants = new ParticipantRegistry(_paths, inspector, NullLogger.Instance); _store = new InteractiveDesktopStateStore( @@ -53,6 +55,7 @@ public void Setup() inspector, new TickCountClock(), new FakePollDelay(), + _signals, new TestConsole(), NullLogger.Instance); } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.Recovery.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.Recovery.cs new file mode 100644 index 000000000..870700a20 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.Recovery.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Tests; + +using WinApp.Cli.Services.InteractiveDesktop; + +/// +/// Recovery across real processes, where the wake-up nobody sends is the interesting case. +/// +/// +/// +/// Waiting is push-based, so an ordinary handoff is somebody publishing and then signalling. None of +/// that happens when a process is killed: there is no completion to publish and nobody left to send a +/// signal. These tests kill real winapp.exe processes inside the queue and require the +/// survivors to get the desktop anyway, which is what the recovery deadlines exist for. +/// +/// +/// The turn holder is this process rather than a child, because a child that can be killed also has +/// to be a child that stays in coordination, and a command that reaches its turn without a live target +/// window finishes immediately. The killed-owner case is covered where it can be made +/// deterministic instead: InteractiveDesktopLockTests closes an owner's lease, which is exactly +/// what Windows does when that process dies. +/// +/// +/// Timings are generous upper bounds. The claim under test is that recovery happens without anyone +/// being told, not that it happens on a particular schedule. +/// +/// +public partial class InteractiveDesktopMultiprocessTests +{ + [TestMethod] + public async Task KillingTheQueueHeadLetsTheNextWaiterThrough() + { + // The head is what everyone behind it is effectively waiting on, and the one position where a + // corpse blocks the whole queue until somebody prunes it. + var holderStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseHolder = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var holder = _coordinator.RunCoordinatedAsync( + UiTurnMode.DesktopExclusive, "ui click", UiCoordinationTestParse.Quiet(), + async (_, _) => + { + holderStarted.SetResult(); + await releaseHolder.Task; + return 0; + }, + CancellationToken.None); + + await holderStarted.Task; + + var head = StartQueuedClick("multiprocess-killed-head-first"); + Assert.IsTrue( + await WaitForStateAsync(s => s.Waiters.Any(w => w.Pid == head.Id)), + "the first child must be queued before the second arrives"); + + var behind = StartQueuedClick("multiprocess-killed-head-second"); + Assert.IsTrue( + await WaitForStateAsync(s => s.Waiters.Count == 2), + "both children must be queued before the head is killed"); + + head.Kill(entireProcessTree: true); + releaseHolder.SetResult(); + await holder; + + Assert.IsTrue( + behind.WaitForExit(45_000), + "the surviving waiter must prune the dead head and take the turn without being told"); + + // The app does not exist, so the command fails after acquiring its turn — reaching that failure + // is the proof that it got the desktop. + Assert.AreEqual(1, behind.ExitCode); + Assert.IsTrue( + await WaitForStateAsync(s => s.Waiters.Count == 0 && s.OwnerCommands.Count == 0), + "neither the corpse nor the survivor may be left in the queue"); + } + + [TestMethod] + public async Task ADeadWaiterDoesNotHoldQueueCapacityAgainstNewCommands() + { + // What the cap counts, demonstrated rather than read off the constant: live foreign waiters. A + // process that started and died is not one, however recently its entry was written. + var holderStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseHolder = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var holder = _coordinator.RunCoordinatedAsync( + UiTurnMode.DesktopExclusive, "ui click", UiCoordinationTestParse.Quiet(), + async (_, _) => + { + holderStarted.SetResult(); + await releaseHolder.Task; + return 0; + }, + CancellationToken.None); + + await holderStarted.Task; + + var doomed = StartQueuedClick("multiprocess-cap-doomed"); + Assert.IsTrue(await WaitForStateAsync(s => s.Waiters.Any(w => w.Pid == doomed.Id))); + + doomed.Kill(entireProcessTree: true); + + var arriving = StartQueuedClick("multiprocess-cap-arriving"); + Assert.IsTrue( + await WaitForStateAsync(s => s.Waiters.Any(w => w.Pid == arriving.Id)), + "a new command must still be admitted while a dead entry is nominally in the queue"); + + Assert.IsTrue( + await WaitForStateAsync(s => s.Waiters.All(w => w.Pid != doomed.Id)), + "the dead waiter's entry must be pruned rather than left occupying a slot"); + + releaseHolder.SetResult(); + await holder; + + Assert.IsTrue(arriving.WaitForExit(45_000), "the live waiter must still get its turn"); + Assert.AreEqual(1, arriving.ExitCode); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.cs index 35c2faaca..588b7f052 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.cs @@ -29,7 +29,7 @@ namespace WinApp.Cli.Tests; [DoNotParallelize] // WINAPP_UI_LOCK_DIRECTORY is process-wide and the child inherits it. [TestCategory("Interactive")] [TestCategory("UiCoordination")] -public class InteractiveDesktopMultiprocessTests +public partial class InteractiveDesktopMultiprocessTests { private const string GateVariable = "WINAPP_UI_MULTIPROCESS_TESTS"; @@ -70,7 +70,11 @@ public void Setup() _paths, _participants, new TickCountClock(), NullLogger.Instance); _coordinator = new InteractiveDesktopLock( _store, _paths, _participants, new UiOwnerResolver(), inspector, - new TickCountClock(), new FakePollDelay(), new TestConsole(), + new TickCountClock(), new FakePollDelay(), + // Real named events: the whole point of these tests is that a wake-up crosses a process + // boundary to a genuine winapp.exe child. + new ParticipantSignals(inspector, NullLogger.Instance), + new TestConsole(), NullLogger.Instance); } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSignalSchedulingTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSignalSchedulingTests.cs new file mode 100644 index 000000000..b1b0b6806 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSignalSchedulingTests.cs @@ -0,0 +1,263 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Services.InteractiveDesktop; + +namespace WinApp.Cli.Tests; + +/// +/// The scheduler half of push-based waiting: which participants a transition makes runnable, and how +/// many times it asks the OS whether a process is still alive. +/// +/// +/// Waking the right processes is what replaces polling, and the set is derived from the state rather +/// than from each transition, so these tests pin the derivation rather than any one code path. The +/// probe counts matter for the same reason the polling did: at a deep queue the coordinator was +/// opening a process handle per waiter per transaction, which is work that scales with exactly the +/// thing that is already under pressure. +/// +[TestClass] +public class InteractiveDesktopSignalSchedulingTests +{ + private readonly CountingLivenessProbe _probe = new(); + private InteractiveDesktopScheduler _scheduler = null!; + private TestClock _clock = null!; + + private static readonly UiOwnerIdentity OwnerA = new(UiOwnerKind.Workflow, "aaaa"); + private static readonly UiOwnerIdentity OwnerB = new(UiOwnerKind.Workflow, "bbbb"); + + [TestInitialize] + public void Setup() + { + _clock = new TestClock(); + _scheduler = new InteractiveDesktopScheduler(_clock); + } + + private UiParticipantIdentity Participant(int pid, string operation = "ui click") + { + _probe.Alive.Add((pid, pid)); + return new UiParticipantIdentity(pid, pid, operation); + } + + // --------------------------------------------------------------- who a transition makes runnable + + [TestMethod] + public void CompletingTheOwnersLastCommandMakesTheNextQueuedOwnerRunnable() + { + var state = InteractiveDesktopState.CreateFresh(); + var a = Participant(100); + var b = Participant(200); + + _scheduler.BeginParticipating(state, _probe, OwnerA, a, UiTurnMode.DesktopExclusive); + _scheduler.BeginParticipating(state, _probe, OwnerB, b, UiTurnMode.DesktopExclusive); + + var before = InteractiveDesktopScheduler.RunnableParticipants(state); + Assert.IsTrue(before.Contains((100, 100)), "the first owner runs immediately"); + Assert.IsFalse(before.Contains((200, 200)), "the second owner is queued behind it"); + + _scheduler.CompleteCommand(state, _probe, a, OwnerA, renewGrace: false); + + var after = InteractiveDesktopScheduler.RunnableParticipants(state); + var newlyRunnable = after.Except(before).ToList(); + + Assert.HasCount(1, newlyRunnable, "exactly the promoted waiter becomes runnable"); + Assert.AreEqual((200, 200), newlyRunnable[0]); + } + + [TestMethod] + public void ReleasingABarrierMakesEveryBlockedCommandOfThatOwnerRunnable() + { + // The case a per-transition signal list would be most likely to get wrong: one completion can + // unblock several commands at once, and waking only the first would leave the rest asleep until + // their backstop fired. + var state = InteractiveDesktopState.CreateFresh(); + var barrier = Participant(100); + var shared1 = Participant(201, "ui record"); + var shared2 = Participant(202, "ui record"); + + _scheduler.BeginParticipating(state, _probe, OwnerA, barrier, UiTurnMode.DesktopExclusive); + _scheduler.BeginParticipating(state, _probe, OwnerA, shared1, UiTurnMode.TurnShared); + _scheduler.BeginParticipating(state, _probe, OwnerA, shared2, UiTurnMode.TurnShared); + + var before = InteractiveDesktopScheduler.RunnableParticipants(state); + Assert.IsFalse(before.Contains((201, 201)), "both shared commands wait behind the barrier"); + Assert.IsFalse(before.Contains((202, 202))); + + _scheduler.CompleteCommand(state, _probe, barrier, OwnerA, renewGrace: true); + + var newlyRunnable = InteractiveDesktopScheduler.RunnableParticipants(state).Except(before).ToList(); + CollectionAssert.AreEquivalent( + new[] { (201, 201L), (202, 202L) }, + newlyRunnable, + "every command the barrier released must be woken, not just the first"); + } + + [TestMethod] + public void PruningADeadOwnerMakesTheQueuedWaiterRunnable() + { + // Nobody publishes anything when a process is killed, so the promotion happens inside whichever + // participant normalizes next — and that participant must wake the winner. + var state = InteractiveDesktopState.CreateFresh(); + var dead = Participant(100); + var waiting = Participant(200); + + _scheduler.BeginParticipating(state, _probe, OwnerA, dead, UiTurnMode.DesktopExclusive); + _scheduler.BeginParticipating(state, _probe, OwnerB, waiting, UiTurnMode.DesktopExclusive); + + var before = InteractiveDesktopScheduler.RunnableParticipants(state); + _probe.Alive.Remove((100, 100)); + + Assert.IsTrue(_scheduler.Normalize(state, _probe), "the dead owner must be pruned"); + + var newlyRunnable = InteractiveDesktopScheduler.RunnableParticipants(state).Except(before).ToList(); + Assert.HasCount(1, newlyRunnable); + Assert.AreEqual((200, 200), newlyRunnable[0]); + } + + [TestMethod] + public void AnIdleGraceExpiringMakesTheWaiterRunnable() + { + var state = InteractiveDesktopState.CreateFresh(); + var first = Participant(100); + var waiting = Participant(200); + + _scheduler.BeginParticipating(state, _probe, OwnerA, first, UiTurnMode.DesktopExclusive); + _scheduler.BeginParticipating(state, _probe, OwnerB, waiting, UiTurnMode.DesktopExclusive); + _scheduler.CompleteCommand(state, _probe, first, OwnerA, renewGrace: true); + + // Grace still running: the waiter stays queued because owner affinity is deliberate. + Assert.IsFalse(InteractiveDesktopScheduler.RunnableParticipants(state).Contains((200, 200))); + + var before = InteractiveDesktopScheduler.RunnableParticipants(state); + _clock.Advance(InteractiveDesktopScheduler.IdleGraceMs + 1); + _scheduler.Normalize(state, _probe); + + Assert.IsTrue( + InteractiveDesktopScheduler.RunnableParticipants(state).Except(before).Contains((200, 200)), + "an expired grace is a deadline nobody announces, so the waiter that wakes at it must find itself runnable"); + } + + [TestMethod] + public void ObservationsAreNotReportedAsNewlyRunnable() + { + // Observations are already running when they register; they never wait, so waking one would be + // a wasted wake-up rather than a correctness problem — but it is still noise worth not sending. + var state = InteractiveDesktopState.CreateFresh(); + var owner = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, owner, UiTurnMode.DesktopExclusive); + + var before = InteractiveDesktopScheduler.RunnableParticipants(state); + var observer = Participant(300, "ui inspect"); + _scheduler.BeginObserve(state, _probe, OwnerA, observer); + + var newlyRunnable = InteractiveDesktopScheduler.RunnableParticipants(state).Except(before).ToList(); + Assert.HasCount(1, newlyRunnable, "the observation does appear, and it is the only change"); + Assert.AreEqual((300, 300), newlyRunnable[0]); + } + + // ------------------------------------------------------------------------ liveness probe economy + + [TestMethod] + public void AdmissionProbesEachParticipantOnceRatherThanOncePerCheck() + { + // Admission used to prune, then re-count live waiters, then compute a queue position — asking + // the OS about the same processes three times inside one transaction. With 64 waiters that is + // 192 process handles per admitted command. + var state = InteractiveDesktopState.CreateFresh(); + _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive); + for (var i = 0; i < 20; i++) + { + _scheduler.BeginParticipating( + state, _probe, new UiOwnerIdentity(UiOwnerKind.Workflow, $"owner{i}"), + Participant(1_000 + i), UiTurnMode.DesktopExclusive); + } + + _probe.Reset(); + var afterSetup = _probe.Calls; + _scheduler.Normalize(state, _probe); + var afterNormalize = _probe.Calls; + _scheduler.BeginParticipating(state, _probe, OwnerB, Participant(9_999), UiTurnMode.DesktopExclusive); + var afterAdmission = _probe.Calls; + + // 21 existing participants: one command entry plus 20 waiters, each asked about exactly once by + // the prune inside Normalize. Everything after that reads the pruned lists. + Assert.AreEqual(21, afterNormalize - afterSetup, + $"one normalization must probe each participant once; it probed {afterNormalize - afterSetup}"); + Assert.AreEqual(21, afterAdmission - afterNormalize, + $"admission must probe each participant once; it probed {afterAdmission - afterNormalize}"); + } + + [TestMethod] + public void PromotionReusesThePrunedQueueRatherThanReprobingIt() + { + // Promotion is the path that re-walked the queue asking the OS about each waiter again. It runs + // inside the completion that frees the turn, so that is the transaction to measure. + var state = InteractiveDesktopState.CreateFresh(); + var owner = Participant(100); + _scheduler.BeginParticipating(state, _probe, OwnerA, owner, UiTurnMode.DesktopExclusive); + for (var i = 0; i < 10; i++) + { + _scheduler.BeginParticipating( + state, _probe, new UiOwnerIdentity(UiOwnerKind.Workflow, $"owner{i}"), + Participant(2_000 + i), UiTurnMode.DesktopExclusive); + } + + _probe.Reset(); + _scheduler.CompleteCommand(state, _probe, owner, OwnerA, renewGrace: false); + + Assert.IsNotNull(state.Owner, "completing the only owner command must promote a waiting owner"); + Assert.AreEqual(10, _probe.Calls, + $"promotion must reuse the list the prune just produced; it probed {_probe.Calls} times"); + } + + [TestMethod] + public void QueuePositionStillProbesWhenNothingHasNormalized() + { + // Cancellation teardown reads a position without normalizing first, so there the probe is the + // only thing that can tell a live queue from a stale one. + var state = InteractiveDesktopState.CreateFresh(); + _scheduler.BeginParticipating(state, _probe, OwnerA, Participant(100), UiTurnMode.DesktopExclusive); + var first = Participant(200); + var second = Participant(300); + _scheduler.BeginParticipating(state, _probe, OwnerB, first, UiTurnMode.DesktopExclusive); + var admission = _scheduler.BeginParticipating( + state, _probe, new UiOwnerIdentity(UiOwnerKind.Workflow, "cccc"), second, UiTurnMode.DesktopExclusive); + + var ticket = admission.Ticket!.Value; + Assert.AreEqual(2, InteractiveDesktopScheduler.QueuePositionOf(state, _probe, ticket)); + + // The waiter ahead dies without anyone normalizing: the probing overload must notice. + _probe.Alive.Remove((200, 200)); + Assert.AreEqual(1, InteractiveDesktopScheduler.QueuePositionOf(state, _probe, ticket), + "a stale queue must not inflate the reported position"); + + // The non-probing overload is for callers that already normalized, and counts entries as-is. + Assert.AreEqual(2, InteractiveDesktopScheduler.QueuePositionOf(state, ticket)); + } + + private sealed class CountingLivenessProbe : ICoordinationLivenessProbe + { + public HashSet<(int Pid, long Start)> Alive { get; } = []; + + public int Calls { get; private set; } + + public void Reset() => Calls = 0; + + public bool IsParticipantLive(int processId, long startTicksUtc) + { + Calls++; + return Alive.Contains((processId, startTicksUtc)); + } + } + + private sealed class TestClock : IMonotonicClock + { + private long _now = 1_000_000; + + public long NowTicks64 => _now; + + public DateTimeOffset UtcNow => DateTimeOffset.UnixEpoch.AddMilliseconds(_now); + + public void Advance(long ms) => _now += ms; + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs index dc15ebf61..3179318d7 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs @@ -74,6 +74,7 @@ public static IServiceCollection ConfigureServices(this IServiceCollection servi .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs index 5b53e3261..b3e3efe01 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs @@ -31,8 +31,28 @@ internal sealed class InteractiveDesktopLock : IInteractiveDesktopLock /// Exit code for a command cancelled before it ever ran (128 + SIGINT). internal const int CancelledExitCode = 130; - private const int PollMinMs = 50; - private const int PollMaxMs = 75; + /// + /// How often the true global head — and a command blocked at the front of its own owner's barrier — + /// rechecks state even without a wake-up. + /// + /// + /// The head is the one waiter whose progress nobody may be alive to signal: if the owner is killed + /// there is no orderly completion to publish and no promoter to wake anyone, so somebody has to + /// notice. Keeping that duty at the head means exactly one process per desktop recovers, however + /// deep the queue. + /// + internal const int HeadRecoveryMs = 500; + + /// + /// Lost-signal backstop for waiters that are not the head. + /// + /// + /// These are woken by the promoter in every ordinary case, so this only covers a promoter that + /// crashed between publishing and signalling. Rare enough to be worth almost nothing, which is why + /// it is ten times the head interval rather than the old 50-75 ms poll. + /// + internal const int DeepRecoveryMs = 5_000; + private const int ActiveLockRetryMinMs = 10; private const int ActiveLockRetryMaxMs = 25; @@ -44,6 +64,7 @@ internal sealed class InteractiveDesktopLock : IInteractiveDesktopLock private readonly IUiOwnerResolver _ownerResolver; private readonly IProcessInspector _processInspector; private readonly IPollDelay _pollDelay; + private readonly IParticipantSignals _signals; private readonly IAnsiConsole _console; private readonly ILogger _logger; private readonly IMonotonicClock _clock; @@ -57,6 +78,7 @@ public InteractiveDesktopLock( IProcessInspector processInspector, IMonotonicClock clock, IPollDelay pollDelay, + IParticipantSignals signals, IAnsiConsole console, ILogger logger) { @@ -66,6 +88,7 @@ public InteractiveDesktopLock( _ownerResolver = ownerResolver; _processInspector = processInspector; _pollDelay = pollDelay; + _signals = signals; _console = console; _logger = logger; _clock = clock; @@ -161,6 +184,13 @@ private sealed class CoordinatedExecution( private readonly LivenessProbe _probe = coordinator.CreateProbe(); private readonly Stopwatch _waitWatch = new(); + /// + /// This process's wake-up channel, opened before any entry naming it is published so a promoter + /// can never find it missing. + /// + private readonly IParticipantSignal _signal = + coordinator._signals.Create(participant.ProcessId, participant.StartTicksUtc); + /// /// Serializes desktop sections opened by this one command. /// @@ -199,6 +229,46 @@ private sealed class CoordinatedExecution( public long WaitedMs { get; private set; } + /// + /// Publishes a mutated state and wakes every participant the mutation made runnable. + /// + /// + /// + /// The single place state reaches disk during a transaction that can change who may run, so no + /// transition path — promotion, absorption, barrier release, cancellation cleanup, crash + /// pruning — has to remember to wake anyone. The set is computed by comparing what the state + /// said was runnable before the mutation with what it says afterwards, which is a property of + /// the state rather than of the code path that produced it. + /// + /// + /// Signalling happens strictly after the publish. A wake-up that arrived first would send its + /// target to read state that has not changed yet, and the target would go back to sleep having + /// consumed the only notification it was going to get. + /// + /// + private void PublishAndSignal( + InteractiveDesktopState state, + HashSet<(int Pid, long StartTicksUtc)> runnableBefore) + { + coordinator._store.Publish(state); + + foreach (var target in InteractiveDesktopScheduler.RunnableParticipants(state)) + { + if (target.Pid == participant.ProcessId && target.StartTicksUtc == participant.StartTicksUtc) + { + // Waking ourselves would only cost us a spurious loop after we already know. + continue; + } + + if (runnableBefore.Contains(target)) + { + continue; + } + + coordinator._signals.Signal(target.Pid, target.StartTicksUtc); + } + } + public async Task RunAsync( Func> body, CancellationToken cancellationToken) @@ -271,13 +341,17 @@ private void Register(CancellationToken cancellationToken) var state = read.State!; + // Captured before any mutation: everything published from this transaction compares against + // it to find who became runnable. + var runnableBefore = InteractiveDesktopScheduler.RunnableParticipants(state); + if (Mode == UiTurnMode.Observe) { - RegisterObserve(state); + RegisterObserve(state, runnableBefore); return; } - RegisterParticipating(state); + RegisterParticipating(state, runnableBefore); } /// @@ -301,7 +375,7 @@ private void RegisterAgainstUnknownVersion() _turnAction = UiTurnAction.Detached; } - private void RegisterObserve(InteractiveDesktopState state) + private void RegisterObserve(InteractiveDesktopState state, HashSet<(int Pid, long StartTicksUtc)> runnableBefore) { var changed = coordinator._scheduler.Normalize(state, _probe); @@ -311,7 +385,7 @@ private void RegisterObserve(InteractiveDesktopState state) // and no state entry and cannot block anyone. if (changed || _recoveredFromCorruption) { - coordinator._store.Publish(state); + PublishAndSignal(state, runnableBefore); } _detached = true; @@ -337,7 +411,7 @@ private void RegisterObserve(InteractiveDesktopState state) if (changed || _recoveredFromCorruption) { - coordinator._store.Publish(state); + PublishAndSignal(state, runnableBefore); } return; @@ -345,10 +419,10 @@ private void RegisterObserve(InteractiveDesktopState state) _turnAction = admission.TurnAction; _turnStartedTick64 = TurnStartTick(state); - coordinator._store.Publish(state); + PublishAndSignal(state, runnableBefore); } - private void RegisterParticipating(InteractiveDesktopState state) + private void RegisterParticipating(InteractiveDesktopState state, HashSet<(int Pid, long StartTicksUtc)> runnableBefore) { _lease = coordinator._participants.OpenLease(participant.ProcessId, participant.StartTicksUtc); @@ -371,8 +445,9 @@ private void RegisterParticipating(InteractiveDesktopState state) _turnStartedTick64 = admission.Admission == UiAdmission.GlobalWaiter ? null : TurnStartTick(state); - _observedQueueDepth = InteractiveDesktopScheduler.CountLiveWaiters(state, _probe); - coordinator._store.Publish(state); + // Normalized inside BeginParticipating, so the list is already only live waiters. + _observedQueueDepth = InteractiveDesktopScheduler.CountWaiters(state); + PublishAndSignal(state, runnableBefore); if (admission.Admission is UiAdmission.OwnerCommandWaiting or UiAdmission.GlobalWaiter) { @@ -381,10 +456,24 @@ private void RegisterParticipating(InteractiveDesktopState state) } /// - /// Polls until this command's entry is — covering both the + /// Waits until this command's entry is — covering both the /// global FIFO wait and the owner-local forward barrier. Cancellable and indefinite: there is no /// coordination timeout in v1 (spec §10.3, §10.4). /// + /// + /// + /// Waiting is push-based: whoever makes this command runnable wakes it, so the common case costs + /// one state read rather than one every 50-75 ms for the whole wait. The signal is only a hint, + /// though — the status is always re-read under state.lock before returning, so a stale, + /// duplicated or spurious wake cannot start a command that is not actually eligible. + /// + /// + /// A wake-up that never arrives must not strand anyone, which is what the recovery deadlines are + /// for. Only the head of the queue can be waiting on a process that died without publishing + /// anything, so only the head rechecks briskly; everyone behind it is covered by a much longer + /// backstop, because their turn cannot come before the head's does. + /// + /// private async Task WaitUntilRunnableAsync(CancellationToken cancellationToken) { if (!_waitWatch.IsRunning) @@ -399,7 +488,7 @@ private async Task WaitUntilRunnableAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - UiWaitDiagnostics diagnostics; + WaitPlan plan; using (var stateLock = coordinator._store.AcquireStateLock(cancellationToken)) { var read = coordinator._store.Read(); @@ -412,9 +501,12 @@ private async Task WaitUntilRunnableAsync(CancellationToken cancellationToken) } var state = read.State!; + var runnableBefore = InteractiveDesktopScheduler.RunnableParticipants(state); if (coordinator._scheduler.Normalize(state, _probe) || read.RecoveredFromCorruption) { - coordinator._store.Publish(state); + // This waiter may itself be the one that recovers a crashed owner, in which case + // it has just promoted somebody — possibly not itself. + PublishAndSignal(state, runnableBefore); } var entry = InteractiveDesktopScheduler.FindOwnerCommand(state, participant); @@ -431,23 +523,111 @@ private async Task WaitUntilRunnableAsync(CancellationToken cancellationToken) return; } - diagnostics = BuildDiagnostics(state, entry); + plan = BuildWaitPlan(state, entry, reporter.IsReportDue(_waitWatch.ElapsedMilliseconds)); } - reporter.ReportIfDue(_waitWatch.ElapsedMilliseconds, diagnostics); + if (plan.Diagnostics is { } diagnostics) + { + reporter.ReportIfDue(_waitWatch.ElapsedMilliseconds, diagnostics); + } - // Jittered so a burst of waiters does not resynchronize into a lock-step convoy on - // state.lock. There are no heartbeat writes — a poll that finds nothing changed - // publishes nothing. - await coordinator._pollDelay - .DelayAsync(Random.Shared.Next(PollMinMs, PollMaxMs + 1), cancellationToken) - .ConfigureAwait(false); + // A signal that arrived before this call is still latched on the auto-reset event, so a + // promoter that published and woke us while we were between iterations cannot be missed. + await _signal.WaitAsync(plan.Timeout, cancellationToken).ConfigureAwait(false); + } + } + + /// How long to sleep next, and the diagnostics to render first when one is due. + private readonly record struct WaitPlan(TimeSpan Timeout, UiWaitDiagnostics? Diagnostics); + + /// + /// Decides how long this command may sleep before it must look again on its own. + /// + /// + /// + /// The head of the global queue, and a command sitting at the front of its own owner's barrier, + /// take the short interval: they are the ones whose unblocking may depend on a process that + /// died without publishing anything, and a dead process sends no signals. Everyone else keeps + /// the long backstop, which only exists for a promoter that crashed between publishing and + /// signalling. + /// + /// + /// An explicit idle grace is a deadline nobody will announce — the turn simply becomes stale at + /// a known time — so the head also wakes exactly then rather than up to an interval late. + /// + /// + private WaitPlan BuildWaitPlan(InteractiveDesktopState state, OwnerCommandEntry? ownEntry, bool reportDue) + { + var isHead = IsRecoveryResponsible(state, ownEntry); + var timeoutMs = isHead ? HeadRecoveryMs : DeepRecoveryMs; + + if (isHead && state.Owner is not null && state.OwnerCommands.Count == 0) + { + // The turn is idle and will lapse at a known tick; waking then turns a grace expiry into + // an immediate handoff instead of one that waits for the next interval. + var untilGrace = state.IdleExpiresTick64 - coordinator._clock.NowTicks64; + if (untilGrace > 0 && untilGrace < timeoutMs) + { + timeoutMs = (int)untilGrace; + } + } + + if (outputMode.AllowsWaitingStatus) + { + // Human output has its own cadence to keep, so never sleep past the next status line. + var untilReport = NextReportInMs(_waitWatch.ElapsedMilliseconds); + if (untilReport < timeoutMs) + { + timeoutMs = untilReport; + } } + + return new WaitPlan( + TimeSpan.FromMilliseconds(Math.Max(1, timeoutMs)), + reportDue ? BuildDiagnostics(state, ownEntry) : null); + } + + /// + /// Whether this command is the one that must notice a failure nobody will report. + /// + private bool IsRecoveryResponsible(InteractiveDesktopState state, OwnerCommandEntry? ownEntry) + { + if (ownEntry is not null) + { + // Blocked behind its own owner's barrier: responsible when nothing of this owner's is + // ahead of it, because then the only thing it waits on is a command that may have died. + var ownTicket = ownEntry.Ticket ?? long.MaxValue; + return !state.OwnerCommands.Any(c => + (c.Pid != participant.ProcessId || c.ProcessStartTicksUtc != participant.StartTicksUtc) + && (c.Ticket ?? long.MaxValue) < ownTicket); + } + + // Global queue: the lowest live ticket. Normalization has already pruned the dead, so the + // minimum is the true head. + var head = state.Waiters.MinBy(w => w.Ticket); + return head is not null + && head.Pid == participant.ProcessId + && head.ProcessStartTicksUtc == participant.StartTicksUtc; + } + + /// Milliseconds until the wait reporter would next print, for the sleep clamp. + private static int NextReportInMs(long elapsedMs) + { + if (elapsedMs < UiCoordinationWaitReporter.FirstReportAfterMs) + { + return (int)(UiCoordinationWaitReporter.FirstReportAfterMs - elapsedMs); + } + + var sinceCycle = (elapsedMs - UiCoordinationWaitReporter.FirstReportAfterMs) + % UiCoordinationWaitReporter.RepeatIntervalMs; + return (int)(UiCoordinationWaitReporter.RepeatIntervalMs - sinceCycle); } private UiWaitDiagnostics BuildDiagnostics(InteractiveDesktopState state, OwnerCommandEntry? ownEntry) { - var queueDepth = InteractiveDesktopScheduler.CountLiveWaiters(state, _probe); + // Called only from inside a transaction that has just normalized, so the lists hold live + // participants only and no entry here needs a process handle to confirm it. + var queueDepth = InteractiveDesktopScheduler.CountWaiters(state); _observedQueueDepth = Math.Max(_observedQueueDepth, queueDepth); var active = state.OwnerCommands @@ -464,8 +644,7 @@ private UiWaitDiagnostics BuildDiagnostics(InteractiveDesktopState state, OwnerC else if (_ticket is { } queuedTicket) { commandsAhead = state.OwnerCommands.Count - + state.Waiters.Count(w => w.Ticket < queuedTicket - && _probe.IsParticipantLive(w.Pid, w.ProcessStartTicksUtc)); + + state.Waiters.Count(w => w.Ticket < queuedTicket); } else { @@ -531,8 +710,11 @@ private void Complete(bool renewGrace) var read = coordinator._store.Read(); if (read.State is { } state) { + // The most important wake-up of all: finishing is what frees the desktop, so the + // participants this completion promotes must be told before this process exits. + var runnableBefore = InteractiveDesktopScheduler.RunnableParticipants(state); coordinator._scheduler.CompleteCommand(state, _probe, participant, owner, renewGrace); - coordinator._store.Publish(state); + PublishAndSignal(state, runnableBefore); } } catch (Exception ex) when (ex is UiCoordinationException or IOException) @@ -662,6 +844,11 @@ public void Dispose() { _lease?.Dispose(); _lease = null; + + // Closed last, after the lease. While this handle is open another process can still name and + // signal us, which is harmless; closing it before the entry is gone would only lose wake-ups + // we might still legitimately receive. + _signal.Dispose(); _sectionGate.Dispose(); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs index 4bf403af4..24b708be5 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs @@ -79,7 +79,7 @@ public bool Normalize(InteractiveDesktopState state, ICoordinationLivenessProbe var changed = ClampStaleDeadline(state); changed |= PruneDeadParticipants(state, probe); changed |= ExpireIdleTurn(state); - changed |= PromoteOldestWaiter(state, probe); + changed |= PromoteOldestWaiter(state); changed |= AbsorbSameOwnerWaiters(state); changed |= ApplyOwnerLocalEligibility(state); return changed; @@ -158,7 +158,10 @@ public UiAdmissionResult BeginParticipating( Normalize(state, probe); - var liveWaiters = CountLiveWaiters(state, probe); + // Normalize just pruned every dead participant, so the in-memory list IS the live queue. The + // cap therefore counts entries rather than re-probing each one: at a full queue that was 64 + // process handles opened per admission, which is most of what made a deep queue expensive. + var liveWaiters = CountWaiters(state); if (state.Owner is null && liveWaiters == 0) { @@ -209,7 +212,7 @@ public UiAdmissionResult BeginParticipating( UiAdmission.GlobalWaiter, ticket, UiTurnAction.Queued, - QueuePositionOf(state, probe, ticket)); + QueuePositionOf(state, ticket)); } /// @@ -266,7 +269,14 @@ public static bool IsCurrentOwner(InteractiveDesktopState state, UiOwnerIdentity => state.Waiters.FirstOrDefault( w => w.Pid == participant.ProcessId && w.ProcessStartTicksUtc == participant.StartTicksUtc); - /// One-based position of a ticket among live global waiters, for cancellation diagnostics. + /// + /// One-based position of a ticket among live global waiters, for cancellation diagnostics. + /// + /// + /// Probes each waiter ahead, because this overload is reached from cancellation teardown where no + /// normalization is guaranteed to have run and the list may still name processes that have exited. + /// Inside a transaction that has just normalized, use . + /// public static int? QueuePositionOf(InteractiveDesktopState state, ICoordinationLivenessProbe probe, long ticket) { var ahead = 0; @@ -288,10 +298,65 @@ public static bool IsCurrentOwner(InteractiveDesktopState state, UiOwnerIdentity return found ? ahead + 1 : null; } + /// + /// One-based position of a ticket in an already-normalized queue, where every remaining waiter is + /// live by construction. + /// + public static int? QueuePositionOf(InteractiveDesktopState state, long ticket) + { + var ahead = 0; + foreach (var waiter in state.Waiters.OrderBy(w => w.Ticket)) + { + if (waiter.Ticket == ticket) + { + return ahead + 1; + } + + ahead++; + } + + return null; + } + /// Live global waiter count, used for verbose output and the queue cap. public static int CountLiveWaiters(InteractiveDesktopState state, ICoordinationLivenessProbe probe) => state.Waiters.Count(w => probe.IsParticipantLive(w.Pid, w.ProcessStartTicksUtc)); + /// + /// Live global waiter count taken straight from a normalized state, without re-probing. + /// + /// + /// has already removed every dead participant from the lists it returns, so + /// asking the OS again inside the same state.lock transaction can only produce the same + /// answer at the cost of one process handle per waiter — the cost that showed up as CPU burn when + /// the queue was deep. Callers that have not just normalized must keep using the probing overload. + /// + public static int CountWaiters(InteractiveDesktopState state) => state.Waiters.Count; + + /// + /// The participants a published state says may run right now, keyed by the identity the state file + /// carries. + /// + /// + /// Comparing this set before and after a transaction is how newly-runnable commands are found. That + /// is deliberately a property of the state rather than of any one transition: promotion, same-owner + /// absorption, barrier release, cancellation cleanup and crash pruning all reach the same place, so + /// a future transition cannot forget to wake anyone. + /// + public static HashSet<(int Pid, long StartTicksUtc)> RunnableParticipants(InteractiveDesktopState state) + { + var runnable = new HashSet<(int, long)>(); + foreach (var command in state.OwnerCommands) + { + if (command.Status == UiCommandStatus.Running) + { + runnable.Add((command.Pid, command.ProcessStartTicksUtc)); + } + } + + return runnable; + } + private static void AddOwnerCommand(InteractiveDesktopState state, UiParticipantIdentity participant, UiTurnMode mode) => state.OwnerCommands.Add(new OwnerCommandEntry { @@ -359,7 +424,7 @@ private bool ExpireIdleTurn(InteractiveDesktopState state) return true; } - private bool PromoteOldestWaiter(InteractiveDesktopState state, ICoordinationLivenessProbe probe) + private bool PromoteOldestWaiter(InteractiveDesktopState state) { if (state.Owner is not null) { @@ -368,9 +433,11 @@ private bool PromoteOldestWaiter(InteractiveDesktopState state, ICoordinationLiv // Strict FIFO by persisted ticket, never by file-lock acquisition order. A suspended live waiter // therefore keeps the head of the queue until it resumes or is terminated. - var oldest = state.Waiters - .OrderBy(w => w.Ticket) - .FirstOrDefault(w => probe.IsParticipantLive(w.Pid, w.ProcessStartTicksUtc)); + // + // No liveness re-probe: PruneDeadParticipants ran first in this same normalization, so this list + // is already only live waiters, and asking the OS again would cost one process handle per waiter + // to learn nothing. + var oldest = state.Waiters.MinBy(w => w.Ticket); if (oldest is null) { diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ParticipantSignals.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ParticipantSignals.cs new file mode 100644 index 000000000..42305859d --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ParticipantSignals.cs @@ -0,0 +1,176 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Globalization; +using System.Runtime.Versioning; +using Microsoft.Extensions.Logging; + +namespace WinApp.Cli.Services.InteractiveDesktop; + +/// +/// One process's wake-up channel while it waits for the desktop. +/// +/// +/// Auto-reset semantics matter: a signal delivered before this process starts waiting stays latched +/// and is consumed by the next wait, so a promoter that publishes and signals faster than the waiter +/// reaches its wait cannot lose the wake-up. +/// +internal interface IParticipantSignal : IDisposable +{ + /// + /// Waits for a wake-up, , or cancellation. + /// + /// when signalled, on timeout. + Task WaitAsync(TimeSpan timeout, CancellationToken cancellationToken); +} + +/// +/// Creates this process's wake-up channel and pokes other processes' channels. +/// +/// +/// +/// The scheduler stays the sole authority. A signal is only a hint that the state may have +/// changed; every waiter re-reads state under state.lock and re-checks its own status before +/// doing anything, so a duplicate, stale or entirely spurious wake costs one lock acquisition and +/// nothing else. A missing wake costs a recovery deadline, never correctness. +/// +/// +/// Nothing about this is persisted. The channel name is derived from identity the state file already +/// carries — session, PID and process start time — so there is no schema change and no way for the +/// name to disagree with the entry it belongs to. +/// +/// +internal interface IParticipantSignals +{ + /// + /// Opens this process's own channel. Must be called before any state entry naming this process is + /// published, so a promoter can never look for a channel that does not exist yet. + /// + IParticipantSignal Create(int processId, long startTicksUtc); + + /// + /// Best-effort wake-up for another participant. Never throws: a participant that has exited has no + /// channel to open, and that is the normal case rather than an error. + /// + void Signal(int processId, long startTicksUtc); +} + +/// +/// Named-event implementation. Events live in the Local\ namespace, so they are scoped to the +/// Windows session exactly like the coordination state they mirror. +/// +[SupportedOSPlatform("windows")] +internal sealed class ParticipantSignals(IProcessInspector processInspector, ILogger logger) + : IParticipantSignals +{ + /// + /// Deterministic channel name for one participant. + /// + /// + /// PID alone is not identity — Windows reuses PIDs — so the process start time is included for the + /// same reason the state entries carry it. The workflow id is deliberately absent: it is a secret + /// the coordinator hashes before it ever touches disk, and a name is visible to any process in the + /// session. + /// + internal static string NameFor(int sessionId, int processId, long startTicksUtc) + => string.Create( + CultureInfo.InvariantCulture, + $@"Local\winapp-ui-turn-{sessionId}-{processId}-{startTicksUtc}"); + + private string NameFor(int processId, long startTicksUtc) + => NameFor(processInspector.CurrentSessionId, processId, startTicksUtc); + + public IParticipantSignal Create(int processId, long startTicksUtc) + { + try + { + return new NamedEventSignal(new EventWaitHandle( + initialState: false, EventResetMode.AutoReset, NameFor(processId, startTicksUtc))); + } + catch (Exception ex) when (ex is WaitHandleCannotBeOpenedException + or UnauthorizedAccessException + or IOException) + { + // Wake-ups are an optimization over state that is published either way, so failing to open + // a channel must not fail the command. Without one this process simply falls back to its + // recovery deadline — slower, never wrong — and nobody else is affected. + logger.LogDebug( + "Could not open a UI coordination wake-up channel: {Message}. This command will rely on its recovery interval.", + ex.Message); + return new UnsignallableParticipant(); + } + } + + public void Signal(int processId, long startTicksUtc) + { + try + { + if (EventWaitHandle.TryOpenExisting(NameFor(processId, startTicksUtc), out var handle)) + { + using (handle) + { + handle.Set(); + } + } + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or WaitHandleCannotBeOpenedException) + { + // A wake-up is an optimization layered on top of published state, so failing to deliver one + // must never fail the transaction that published it. The target falls back to its recovery + // deadline and finds the same state a moment later. + logger.LogDebug( + "Could not signal winapp participant {Pid}: {Message}. It will pick the change up on its next recheck.", + processId, ex.Message); + } + } + + private sealed class NamedEventSignal(EventWaitHandle handle) : IParticipantSignal + { + public async Task WaitAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // RegisterWaitForSingleObject parks the handle on a shared wait thread rather than blocking + // one thread per waiter, so a machine full of queued commands does not cost a thread each. + var registration = ThreadPool.RegisterWaitForSingleObject( + handle, + (_, timedOut) => completion.TrySetResult(!timedOut), + state: null, + timeout, + executeOnlyOnce: true); + + await using var cancellation = cancellationToken.Register( + static s => ((TaskCompletionSource)s!).TrySetCanceled(), completion); + + try + { + return await completion.Task.ConfigureAwait(false); + } + finally + { + registration.Unregister(waitObject: null); + } + } + + public void Dispose() => handle.Dispose(); + } + + /// + /// Degraded channel for a process that could not open a real one: it simply sleeps out whatever + /// recovery interval the caller asked for. + /// + private sealed class UnsignallableParticipant : IParticipantSignal + { + public async Task WaitAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + await Task.Delay(timeout, cancellationToken).ConfigureAwait(false); + return false; + } + + public void Dispose() + { + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationWaitReporter.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationWaitReporter.cs index 526e01675..efc939b9a 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationWaitReporter.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationWaitReporter.cs @@ -37,6 +37,24 @@ internal sealed class UiCoordinationWaitReporter( private long _lastReportedAtMs = -1; + /// + /// Whether would print at . + /// + /// + /// Lets a caller skip building diagnostics — which walks the whole queue — on the iterations where + /// nothing would be shown. At the old poll rate that waste was invisible; woken only on demand, it + /// would be most of the work a waiter does. + /// + public bool IsReportDue(long elapsedMs) + { + if (!outputMode.AllowsWaitingStatus || elapsedMs < FirstReportAfterMs) + { + return false; + } + + return _lastReportedAtMs < 0 || elapsedMs - _lastReportedAtMs >= RepeatIntervalMs; + } + /// /// Writes a waiting status when one is due. Silent under --json and --quiet, and /// silent for the first milliseconds in every mode. From fc9ef956bfd7bb1fcd023df119618f13b3fe6f76 Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:25:24 -0700 Subject: [PATCH 23/29] Sample observed queue depth on every look, not next to the status line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coordination telemetry summary reports the deepest queue a command ever saw while waiting. Moving diagnostics behind an is-a-status-line-due check took that sampling with it, and under --json or --quiet no status line is ever due — so the depth froze at whatever it was when the command registered and every command that piled up behind it went unrecorded. Those are precisely the runs where queue depth is worth knowing: a script or agent driving the CLI is the thing that produces a deep queue, and it is also the thing that passes --json. The depth is now taken on every state read instead. Counting an already-normalized list is a list length, so doing it unconditionally costs nothing, and the telemetry no longer depends on whether anyone happened to be watching. BuildDiagnostics keeps computing the depth it renders and no longer records it. Also corrects a test whose name claimed the opposite of what it asserts: a registering observation IS in the newly-runnable difference, which is right, and it is not woken because PublishAndSignal skips the participant doing the publishing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../InteractiveDesktopLockTests.PushQueue.cs | 46 +++++++++++++++++++ ...InteractiveDesktopSignalSchedulingTests.cs | 10 ++-- .../InteractiveDesktopLock.cs | 13 +++++- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.PushQueue.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.PushQueue.cs index d10cb5df4..ea7164a0e 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.PushQueue.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.PushQueue.cs @@ -203,6 +203,52 @@ public async Task CancellingAQueuedCommandWakesTheParticipantsItLeavesBehind() "a cancelled command must leave no queue entry behind for others to prune"); } + [TestMethod] + public async Task AQuietWaiterStillRecordsQueueGrowthThatHappensAfterItRegisters() + { + // The telemetry summary reports the deepest queue a command ever saw. Sampling that next to the + // status line made it depend on whether anyone was watching: under --json and --quiet no status + // line is ever due, so the depth froze at whatever it was when the command registered and every + // command that piled up behind it went unrecorded — exactly the runs where queue depth is worth + // knowing. + using var foreignLease = OccupyTurnWithAnotherOwner(); + + UiCoordinationTelemetryScope.Begin(); + + using var cts = new CancellationTokenSource(); + var queued = _coordinator.RunCoordinatedAsync( + UiTurnMode.DesktopExclusive, "ui click", UiCoordinationTestParse.Quiet(), + (_, _) => Task.FromResult(0), cts.Token); + + Assert.IsTrue( + await EventuallyAsync(() => _signals.RequestedTimeouts.Count > 0), + "the command must be waiting before the queue grows behind it"); + + // Three more waiters arrive after this command registered, so a depth captured only at + // registration would report one rather than four. + using var second = QueueForeignWaiterAhead(foreignPid: 717171, foreignStart: 717); + using var third = QueueForeignWaiterAhead(foreignPid: 727272, foreignStart: 727); + using var fourth = QueueForeignWaiterAhead(foreignPid: 737373, foreignStart: 737); + + // Wake it so it takes another look; the wake is a hint, and the look is what samples the depth. + _signals.SignalDirect(new UiParticipantIdentity( + Environment.ProcessId, new ProcessInspector().CurrentProcessStartTicksUtc, "ui click")); + + Assert.IsTrue( + await EventuallyAsync(() => _signals.RequestedTimeouts.Count > 1), + "the waiter must have looked again after the queue grew"); + + await cts.CancelAsync(); + Assert.AreEqual(InteractiveDesktopLock.CancelledExitCode, await queued); + + var summary = UiCoordinationTelemetryScope.Current; + Assert.IsNotNull(summary); + Assert.IsGreaterThanOrEqualTo( + 4, + summary!.QueueDepth, + $"a quiet waiter must still observe the queue growing behind it; it recorded {summary.QueueDepth}"); + } + /// /// Adds a live foreign waiter ahead of this process, so the test process is deliberately not the /// head of the queue. diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSignalSchedulingTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSignalSchedulingTests.cs index b1b0b6806..065274d40 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSignalSchedulingTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSignalSchedulingTests.cs @@ -138,10 +138,12 @@ public void AnIdleGraceExpiringMakesTheWaiterRunnable() } [TestMethod] - public void ObservationsAreNotReportedAsNewlyRunnable() + public void ARegisteringObservationIsReportedAsNewlyRunnable() { - // Observations are already running when they register; they never wait, so waking one would be - // a wasted wake-up rather than a correctness problem — but it is still noise worth not sending. + // An observation is Running the moment it registers, so it does appear in the difference. That + // is correct rather than wasteful: the entry is a real change to who may act on the desktop. + // Nobody is woken for it, because PublishAndSignal skips the participant doing the publishing — + // which for a registration is always the observation itself. var state = InteractiveDesktopState.CreateFresh(); var owner = Participant(100); _scheduler.BeginParticipating(state, _probe, OwnerA, owner, UiTurnMode.DesktopExclusive); @@ -151,7 +153,7 @@ public void ObservationsAreNotReportedAsNewlyRunnable() _scheduler.BeginObserve(state, _probe, OwnerA, observer); var newlyRunnable = InteractiveDesktopScheduler.RunnableParticipants(state).Except(before).ToList(); - Assert.HasCount(1, newlyRunnable, "the observation does appear, and it is the only change"); + Assert.HasCount(1, newlyRunnable, "the observation is the only change"); Assert.AreEqual((300, 300), newlyRunnable[0]); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs index b3e3efe01..7cda8cf9d 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs @@ -509,6 +509,14 @@ private async Task WaitUntilRunnableAsync(CancellationToken cancellationToken) PublishAndSignal(state, runnableBefore); } + // Telemetry reports the deepest queue this command ever saw, so it is sampled on + // every look rather than alongside the status line: under --json and --quiet no + // status line is ever due, and sampling there would have reported only the depth at + // registration and missed everything that queued up behind it afterwards. Counting a + // normalized list is a list length, so it is cheap enough to do unconditionally. + _observedQueueDepth = Math.Max( + _observedQueueDepth, InteractiveDesktopScheduler.CountWaiters(state)); + var entry = InteractiveDesktopScheduler.FindOwnerCommand(state, participant); if (entry is { Status: UiCommandStatus.Running }) { @@ -626,9 +634,10 @@ private static int NextReportInMs(long elapsedMs) private UiWaitDiagnostics BuildDiagnostics(InteractiveDesktopState state, OwnerCommandEntry? ownEntry) { // Called only from inside a transaction that has just normalized, so the lists hold live - // participants only and no entry here needs a process handle to confirm it. + // participants only and no entry here needs a process handle to confirm it. The observed + // depth is not tracked here: this runs only when a status line is due, and the telemetry + // has to be the same whether or not anyone was watching. var queueDepth = InteractiveDesktopScheduler.CountWaiters(state); - _observedQueueDepth = Math.Max(_observedQueueDepth, queueDepth); var active = state.OwnerCommands .Where(c => c.Status == UiCommandStatus.Running && c.Pid != participant.ProcessId) From 41143f3917e8781d9f4dd4f223ecf29455923efa Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:11:01 -0700 Subject: [PATCH 24/29] Treat a state document missing required fields as corruption, not a free desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every property on the persisted state carried a defaulting initializer, so `{}` deserialized into version 1, ticket 1, no owner, no commands, no waiters — which is byte-for-byte what CreateFresh() produces. Structural validation then found nothing wrong with it, because there genuinely is nothing wrong with an empty desktop. So a file truncated to `{}` by a torn write, a full disk or a stray editor did not look damaged; it looked like nobody was using the desktop. The consequence is the part that matters. Reading it never reached RecoverCorruptState, so HasLiveTurnEvidence never got the chance to fail closed, and the next command minted itself a fresh turn while other processes still held their leases — two workflows driving one desktop, which is the single thing this feature exists to prevent. Every property the v1 writer always emits is now [JsonRequired], on the root and on the nested records, so a partial document raises JsonException and takes the path it should always have taken. The nested records matter for the same reason as the root, one level down: an entry missing ownerKind or status does not fail, it defaults — and those fields decide whether a promoted waiter earns an idle grace and whether a command counts as running. Genuinely optional fields stay optional: owner and diagnosticIdleExpiresUtc are omitted when null, and an Observe entry carries no ticket by design. Verified against a real published file rather than assumed, because required-field sets are easy to get wrong in the strict direction: version, turnId, turnStartedTick64, nextTicket, idleExpiresTick64, ownerCommands and waiters are all present in what the writer produces. One existing round-trip fixture was hand-built and omitted two of them; it now carries the full set, which is what a newer writer's document would also contain. The raw version probe still runs before typed deserialization, so a v99 document whose field shapes this binary cannot bind is still diverted as a newer schema rather than being quarantined and downgraded. Demonstrated end to end on the NativeAOT build, same truncated file and a held lease. Before: exit 1 from the app-not-found path, meaning the command had already taken a turn, and the state file rewritten with a brand-new owner. After: desktop_coordination_unavailable, and the `{}` bytes left exactly as they were. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../InteractiveDesktopStoreTests.cs | 140 +++++++++++++++++- .../InteractiveDesktopState.cs | 32 ++++ 2 files changed, 171 insertions(+), 1 deletion(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs index 9d11cf2c1..e014a1a5d 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs @@ -127,6 +127,139 @@ public void Read_EmptyStateWithALiveParticipant_FailsClosed() Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code); } + [TestMethod] + public void Read_IncompleteObject_IsTreatedAsCorruption() + { + // An empty object is not a fresh state, it is a truncated one. Every property carried a + // defaulting initializer, so `{}` bound to exactly what CreateFresh() produces — version 1, + // ticket 1, no owner, no commands — and sailed through structural validation as if a real + // coordinator had written it. A file truncated to `{}` by a crashed writer, a disk-full + // rewrite or a stray editor therefore looked like "nobody is using the desktop". + _paths.EnsureDirectories(); + File.WriteAllText(_paths.StatePath, "{}"); + + using (var stateLock = _store.AcquireStateLock(CancellationToken.None)) + { + var result = _store.Read(); + Assert.IsTrue( + result.RecoveredFromCorruption, + "a document missing every field the writer always emits must be corruption, not a fresh state"); + Assert.IsNotNull(result.State); + } + + Assert.IsTrue( + Directory.EnumerateFiles(_paths.LockDirectory, "state.corrupt-*.json").Any(), + "the unusable document must be quarantined rather than silently believed"); + } + + [TestMethod] + public void Read_IncompleteObjectWithALiveParticipant_FailsClosed() + { + // The consequence that makes this critical rather than untidy. Believing `{}` mints a fresh + // owner while another process still holds its lease, so two workflows drive one desktop at + // once. With a live lease the read must refuse instead. + _paths.EnsureDirectories(); + File.WriteAllText(_paths.StatePath, "{}"); + + using var lease = _participants.OpenLease(_inspector.CurrentProcessId, _inspector.CurrentProcessStartTicksUtc); + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var ex = Assert.ThrowsExactly(() => _store.Read()); + Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code); + + Assert.AreEqual("{}", File.ReadAllText(_paths.StatePath), + "failing closed must leave the bytes alone rather than replacing them with a fresh state"); + } + + [TestMethod] + public void Read_WaiterMissingARequiredField_IsTreatedAsCorruption() + { + // A partially written entry is worse than a missing one: an absent ownerKind would default to + // whichever enum value is zero, so a truncated waiter would be promoted later with an owner + // kind nobody ever wrote — deciding, among other things, whether it earns an idle grace. + _paths.EnsureDirectories(); + const string truncatedWaiter = """ + {"version":1,"turnId":0,"turnStartedTick64":0,"nextTicket":2,"idleExpiresTick64":0, + "ownerCommands":[], + "waiters":[{"ticket":1,"ownerKey":"abc","pid":4242,"processStartTicksUtc":99,"operation":"ui click","mode":"desktop-exclusive"}]} + """; + File.WriteAllText(_paths.StatePath, truncatedWaiter); + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var result = _store.Read(); + + Assert.IsTrue( + result.RecoveredFromCorruption, + "a waiter missing ownerKind must not default into valid scheduling semantics"); + } + + [TestMethod] + public void Read_OwnerCommandMissingStatus_IsTreatedAsCorruption() + { + // Status decides whether a command is running or still blocked behind a barrier. Defaulting it + // silently would let a truncated entry claim either. + _paths.EnsureDirectories(); + const string truncatedCommand = """ + {"version":1,"turnId":1,"turnStartedTick64":5,"nextTicket":2,"idleExpiresTick64":0, + "owner":{"kind":"workflow","key":"abc"}, + "ownerCommands":[{"ticket":1,"pid":4242,"processStartTicksUtc":99,"operation":"ui click","mode":"desktop-exclusive"}], + "waiters":[]} + """; + File.WriteAllText(_paths.StatePath, truncatedCommand); + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var result = _store.Read(); + + Assert.IsTrue( + result.RecoveredFromCorruption, + "an owner command missing status must not default into a runnable entry"); + } + + [TestMethod] + public void Read_AFreshlyPublishedState_StillRoundTrips() + { + // The other half of the contract: requiring fields must not reject what this binary writes. + _paths.EnsureDirectories(); + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var published = InteractiveDesktopState.CreateFresh(); + published.Owner = new OwnerRecord { Kind = UiOwnerKind.Workflow, Key = "abc" }; + published.TurnId = 3; + published.TurnStartedTick64 = 1_234; + published.OwnerCommands.Add(new OwnerCommandEntry + { + Ticket = published.AllocateTicket(), + Pid = 4242, + ProcessStartTicksUtc = 99, + Operation = "ui click", + Mode = UiTurnMode.DesktopExclusive, + Status = UiCommandStatus.Running, + }); + published.Waiters.Add(new WaiterEntry + { + Ticket = published.AllocateTicket(), + OwnerKey = "def", + OwnerKind = UiOwnerKind.Anonymous, + Pid = 5252, + ProcessStartTicksUtc = 100, + Operation = "ui screenshot", + Mode = UiTurnMode.DesktopExclusive, + }); + _store.Publish(published); + + var result = _store.Read(); + + Assert.IsFalse(result.RecoveredFromCorruption, "this binary's own output must read back cleanly"); + Assert.IsFalse(result.UnknownNewerVersion); + Assert.IsNotNull(result.State); + Assert.AreEqual(3, result.State!.TurnId); + Assert.AreEqual("abc", result.State.Owner!.Key); + Assert.HasCount(1, result.State.OwnerCommands); + Assert.HasCount(1, result.State.Waiters); + Assert.AreEqual(UiOwnerKind.Anonymous, result.State.Waiters[0].OwnerKind); + Assert.AreEqual(UiCommandStatus.Running, result.State.OwnerCommands[0].Status); + } + [TestMethod] public void Read_UnknownNewerVersion_IsNeverResetOrDowngraded() { @@ -263,9 +396,14 @@ public void Read_OutOfRangeEnumValue_IsCorrupt() [TestMethod] public void Publish_RoundTripsStateAndPreservesUnknownFieldsFromANewerWriter() { + // The fixture carries every field the v1 writer emits, because a document missing one of those + // is now corruption rather than a partially populated state. Confirmed against a real published + // file: version, turnId, turnStartedTick64, nextTicket, idleExpiresTick64, ownerCommands and + // waiters are always written. _paths.EnsureDirectories(); WriteRawState(""" - {"version":1,"turnId":3,"nextTicket":9,"owner":{"kind":"workflow","key":"a","futureOwnerField":"keep-me"}, + {"version":1,"turnId":3,"turnStartedTick64":100,"nextTicket":9,"idleExpiresTick64":0, + "owner":{"kind":"workflow","key":"a","futureOwnerField":"keep-me"}, "ownerCommands":[],"waiters":[],"futureRootField":{"nested":true}} """); diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopState.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopState.cs index 73f3c89f9..a48d5f4e5 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopState.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopState.cs @@ -17,6 +17,17 @@ namespace WinApp.Cli.Services.InteractiveDesktop; /// additive fields survive a rewrite by an older compatible binary (spec §8, "Preserve unknown fields /// when rewriting a known version"). A greater than /// is never reset or downgraded. +/// +/// Every property the v1 writer always emits is required. The +/// initializers below exist for and for code that builds a state in memory; +/// without the attribute they also silently absorbed a truncated document, because {} bound to +/// precisely the values produces and then passed structural validation as a +/// genuinely empty desktop. Requiring them turns a partial document into a +/// , which is corruption, which fails closed while any lease is live. +/// Optional properties stay optional: and +/// are omitted when null, and an +/// entry legitimately carries no ticket. +/// /// internal sealed class InteractiveDesktopState { @@ -24,9 +35,11 @@ internal sealed class InteractiveDesktopState internal const int CurrentVersion = 1; /// Schema version. Values above are failed closed, never rewritten. + [JsonRequired] public int Version { get; set; } = CurrentVersion; /// Incremented every time the turn is claimed by an owner. Diagnostic and test observability. + [JsonRequired] public long TurnId { get; set; } /// @@ -35,9 +48,11 @@ internal sealed class InteractiveDesktopState /// has been held — across all of that owner's commands — rather than how long its own command /// waited (spec §16 turn-age bucket). Zero when no owner holds the turn. /// + [JsonRequired] public long TurnStartedTick64 { get; set; } /// Next globally monotonic arrival ticket. Tickets order the barrier and the global FIFO. + [JsonRequired] public long NextTicket { get; set; } = 1; /// The owner currently holding the turn, or when the desktop is free. @@ -48,15 +63,18 @@ internal sealed class InteractiveDesktopState /// is empty — a waiting or running owner command keeps the turn /// regardless of this value. /// + [JsonRequired] public long IdleExpiresTick64 { get; set; } /// Human-readable mirror of . Diagnostic only; never compared. public string? DiagnosticIdleExpiresUtc { get; set; } /// Commands belonging to the current owner, in arrival order. + [JsonRequired] public List OwnerCommands { get; set; } = []; /// Other owners' commands waiting for the turn, oldest ticket first. + [JsonRequired] public List Waiters { get; set; } = []; /// Unknown properties from a newer writer, preserved verbatim on rewrite. @@ -89,12 +107,14 @@ public long AllocateTicket() internal sealed class OwnerRecord { /// How this owner was resolved. Drives whether the turn earns a post-command idle grace. + [JsonRequired] public UiOwnerKind Kind { get; set; } /// /// Lowercase hex SHA-256 of the domain-separated owner payload. Never the raw /// WINAPP_UI_WORKFLOW_ID, and never emitted in output, logs or telemetry. /// + [JsonRequired] public string Key { get; set; } = ""; /// Unknown properties from a newer writer, preserved verbatim on rewrite. @@ -114,21 +134,26 @@ internal sealed class OwnerCommandEntry public long? Ticket { get; set; } /// Owning winapp.exe process id. + [JsonRequired] public int Pid { get; set; } /// /// The owning process's Process.StartTime.ToUniversalTime().Ticks. Combined with /// this identifies the participant lease and detects PID reuse. /// + [JsonRequired] public long ProcessStartTicksUtc { get; set; } /// Command name for diagnostics, e.g. ui click. Never includes arguments. + [JsonRequired] public string Operation { get; set; } = ""; /// The command's coordination mode. + [JsonRequired] public UiTurnMode Mode { get; set; } /// Whether the command is blocked behind an earlier barrier or executing. + [JsonRequired] public UiCommandStatus Status { get; set; } /// Unknown properties from a newer writer, preserved verbatim on rewrite. @@ -140,27 +165,34 @@ internal sealed class OwnerCommandEntry internal sealed class WaiterEntry { /// Globally monotonic arrival ticket. Defines strict FIFO order among waiters. + [JsonRequired] public long Ticket { get; set; } /// The waiting command's owner key (SHA-256 hex). Becomes the current owner on promotion. + [JsonRequired] public string OwnerKey { get; set; } = ""; /// Owning winapp.exe process id. + [JsonRequired] public int Pid { get; set; } /// The owning process's Process.StartTime.ToUniversalTime().Ticks. + [JsonRequired] public long ProcessStartTicksUtc { get; set; } /// The owner kind to install when this waiter is promoted. + [JsonRequired] public UiOwnerKind OwnerKind { get; set; } /// Command name for diagnostics, e.g. ui click. Never includes arguments. + [JsonRequired] public string Operation { get; set; } = ""; /// /// The mode this waiter requested, stored so any process can promote it without inferring behavior /// from the operation name (spec §8). /// + [JsonRequired] public UiTurnMode Mode { get; set; } /// Unknown properties from a newer writer, preserved verbatim on rewrite. From 2f91a2ca2a4dbcf9c610cb9f387863e69a93d85a Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:22:54 -0700 Subject: [PATCH 25/29] Reclassify background-safe mutations, add `ui yield`, name screenshot ambiguity Four changes from team review, plus the docs and CI cleanups they imply. Background-safe mutations now take a shared turn ------------------------------------------------ `set-value`, `scroll-into-view` and `scroll --direction`/`--to` were classified Observe, which meant "never waits". That was right about the desktop and wrong about the app: they drive UIA patterns rather than the foreground, but they do change what the app shows, so an Observe classification let one workflow edit a field or scroll a list out from under another workflow's click. They are TurnShared now. They still never take `active.lock`, so they remain usable on a locked or headless session, but they wait behind a foreign workflow's turn. Inside one workflow they overlap with other shared work -- that is how `record` captures the `set-value` calls it is recording -- and they still wait behind an earlier same-workflow `DesktopExclusive` barrier, so a click and the mutation after it run in the order they were written. `scroll --wheel` synthesizes real mouse input and stays DesktopExclusive. New command: `winapp ui yield` ------------------------------ The four-second idle grace is a fallback for when a workflow cannot say it is finished. When it can, it now says so: `ui yield` ends the grace immediately and hands the desktop to whoever was waiting, instead of making them sit out four seconds nobody needs. It is deliberately not a `UiCoordinatedAction`. Every other `ui` command asks coordination for a turn; this one gives one back, so registering it as a participant would make it the very live command that stops a turn being idle -- it would always find itself busy. It takes `state.lock` only: no lease, no owner command, no `active.lock`, no grace-renewing teardown. - Requires `WINAPP_UI_WORKFLOW_ID`. Absent is `invalid_arguments`, decided before state is read; present-but-malformed keeps reporting `invalid_ui_workflow_id`, because those are different mistakes. - Idempotent: no owner, a foreign owner, or an already-released turn all return `{"released": false}` and exit 0. - Never releases another workflow's turn. - Fails with the new `ui_turn_busy` when this workflow still has a command running or queued. A valid request at an unsafe moment is neither malformed arguments nor broken coordination, so it gets its own code and a recovery hint rather than being folded into either. The publish is strictly before the return, so `released: true` is only ever reported for a release that reached disk. The `false` branches publish too: normalization inside the same transaction may have pruned a dead participant, expired a grace or promoted a waiter, and whoever performed that recovery has to publish it. `--capture-screen` names its ambiguity -------------------------------------- `ui screenshot -a --capture-screen` against an app with several top-level or owned windows died with a bare `foreground_not_target` that said nothing about what to do next. Live-screen capture records whatever is actually in front, and only one window can be, so compositing several of them is unsatisfiable -- at best moments stitched together where each window covered the others. It now fails before foregrounding or capturing anything, with the fix in the message: list the windows, retry with `-w `, or drop `--capture-screen`. Window-content capture is unaffected and still composites. CI script extraction -------------------- The gated coordination runner and its TRX assertions move out of `build-package.yml` into `scripts/test-ui-coordination.ps1`, where the explanation belongs in `.SYNOPSIS` rather than in a thirty-line YAML comment. Fail-on-zero, fail-on-skip and TRX output are byte-for-byte the same behaviour; the workflow step is now two lines. Also here --------- `PublishAndSignal` moves from `CoordinatedExecution` to the outer class so commands and yield share one implementation instead of two. Docs, the shipped skill, the Copilot agent and the JSON envelope reference are updated for all of the above; `cli-schema.json`, `npm-usage.md` and `winapp-commands.ts` are regenerated, so `uiYield` carries per-call `workflowId` without touching `process.env`. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1f5699f4-3328-49e1-a043-31b2173a5bdf --- .github/workflows/build-package.yml | 39 +- docs/cli-schema.json | 51 +++ docs/npm-usage.md | 29 ++ docs/ui-automation.md | 74 +++- .../com.github.copilot/agents/winapp.agent.md | 10 +- .../skills/winapp-ui-automation/SKILL.md | 27 +- .../references/ui-json-envelope.md | 6 + scripts/test-ui-coordination.ps1 | 78 ++++ .../FakeInteractiveDesktopLock.cs | 20 + .../InteractiveDesktopLockTests.Yield.cs | 370 ++++++++++++++++++ .../UiCommandTests.Coordination.cs | 37 +- .../UiCommandTests.Screenshot.Ambiguity.cs | 124 ++++++ .../WinApp.Cli.Tests/UiCommandTests.Yield.cs | 114 ++++++ .../WinApp.Cli/Commands/UiCommand.cs | 4 +- .../Commands/UiScreenshotCommand.cs | 42 +- .../WinApp.Cli/Commands/UiScrollCommand.cs | 10 +- .../Commands/UiScrollIntoViewCommand.cs | 9 +- .../WinApp.Cli/Commands/UiSetValueCommand.cs | 10 +- .../WinApp.Cli/Commands/UiYieldCommand.cs | 125 ++++++ .../Helpers/HostBuilderExtensions.cs | 1 + .../WinApp.Cli/Helpers/UiJsonContext.cs | 14 + .../IInteractiveDesktopLock.cs | 39 ++ .../InteractiveDesktopLock.cs | 138 +++++-- .../InteractiveDesktopScheduler.cs | 18 + .../InteractiveDesktop/UiCoordinationTypes.cs | 6 + .../Services/InteractiveDesktop/UiTurnMode.cs | 20 +- .../FakeUiServices.cs | 12 + src/winapp-npm/src/winapp-commands.ts | 18 + src/winapp-npm/test/workflow-id.test.ts | 52 ++- 29 files changed, 1387 insertions(+), 110 deletions(-) create mode 100644 scripts/test-ui-coordination.ps1 create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.Yield.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.Ambiguity.cs create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Yield.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Commands/UiYieldCommand.cs diff --git a/.github/workflows/build-package.yml b/.github/workflows/build-package.yml index 45da8f8c8..877f30f49 100644 --- a/.github/workflows/build-package.yml +++ b/.github/workflows/build-package.yml @@ -164,43 +164,10 @@ jobs: $platform = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "arm64" } else { "x64" } .\scripts\test-e2e-winui-ui.ps1 -WinAppPath "artifacts/cli/win-$platform/winapp.exe" - # Cooperative desktop-turn coverage (issue #764 §18.2/§18.3). These drive real winapp.exe child - # processes against a real foreground window, so they are gated off the canonical build and only - # run here, on the interactive lane, after the CLI binaries above are on disk. Without this step - # the gate was never set by any workflow and all of them silently skipped everywhere. + # Gated cooperative desktop-turn coverage (issue #764). Runs here, on the interactive lane, because + # it drives real winapp.exe processes against a real foreground window. - name: Run UI coordination multiprocess and real-app tests - env: - WINAPP_UI_MULTIPROCESS_TESTS: "1" - run: | - $filter = "FullyQualifiedName~InteractiveDesktopMultiprocessTests|FullyQualifiedName~InteractiveDesktopRealAppTests" - $results = Join-Path $PWD "artifacts/TestResults/ui-coordination" - New-Item -ItemType Directory -Path $results -Force | Out-Null - - dotnet run --project src/winapp-CLI/WinApp.Cli.Tests/WinApp.Cli.Tests.csproj -c Debug ` - --results-directory $results --report-trx --report-trx-filename ui-coordination.trx ` - --filter $filter - $testExit = $LASTEXITCODE - - # A skip here is a silent failure: it means the gate or the published-binary lookup regressed and - # nothing was actually verified. Assert against the TRX rather than trusting the exit code, which - # is 0 for a run that skipped everything. - $trx = Get-ChildItem -Path $results -Filter *.trx -Recurse | Sort-Object LastWriteTime | Select-Object -Last 1 - if (-not $trx) { throw "No TRX produced: the UI coordination tests did not run." } - - [xml]$doc = Get-Content $trx.FullName - $counters = $doc.TestRun.ResultSummary.Counters - $total = [int]$counters.total - $passed = [int]$counters.passed - $failed = [int]$counters.failed - # MSTest reports Assert.Inconclusive (the gate's skip path) under notExecuted. - $skipped = [int]$counters.notExecuted + [int]$counters.inconclusive - Write-Host "UI coordination tests: total=$total passed=$passed failed=$failed skipped=$skipped" - - # Currently 8 multiprocess + 3 real-app = 11. Asserted dynamically rather than pinned to 11 so - # adding coverage does not fail the build, while a filter that stops matching still does. - if ($total -eq 0) { throw "The UI coordination filter matched no tests — it no longer selects the gated suites." } - if ($skipped -gt 0) { throw "$skipped UI coordination test(s) skipped; the gate must run them here, not skip them." } - if ($failed -gt 0 -or $testExit -ne 0) { throw "UI coordination tests failed (exit $testExit)." } + run: .\scripts\test-ui-coordination.ps1 - name: Upload UI coordination test results if: always() diff --git a/docs/cli-schema.json b/docs/cli-schema.json index 91b05ede8..5f07a3d00 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -4927,6 +4927,57 @@ "recursive": false } } + }, + "yield": { + "description": "Release the current workflow's idle UI turn early. A workflow with WINAPP_UI_WORKFLOW_ID keeps the desktop for a few seconds after each command so a burst of commands reads as one workflow; run this after the final command of a workflow to hand the desktop to waiting workflows straight away. Requires WINAPP_UI_WORKFLOW_ID; targets no app and takes no selector.", + "hidden": false, + "options": { + "--json": { + "description": "Format output as JSON", + "hidden": false, + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--quiet": { + "description": "Suppress progress messages", + "hidden": false, + "aliases": [ + "-q" + ], + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--verbose": { + "description": "Enable verbose output", + "hidden": false, + "aliases": [ + "-v" + ], + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + } + } } } }, diff --git a/docs/npm-usage.md b/docs/npm-usage.md index 511fbb838..68e279821 100644 --- a/docs/npm-usage.md +++ b/docs/npm-usage.md @@ -983,6 +983,24 @@ function uiWaitFor(options?: UiWaitForOptions): Promise --- +### `uiYield()` + +Release the current workflow's idle UI turn early. A workflow with WINAPP_UI_WORKFLOW_ID keeps the desktop for a few seconds after each command so a burst of commands reads as one workflow; run this after the final command of a workflow to hand the desktop to waiting workflows straight away. Requires WINAPP_UI_WORKFLOW_ID; targets no app and takes no selector. + +```typescript +function uiYield(options?: UiYieldOptions): Promise +``` + +**Options:** + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `json` | `boolean \| undefined` | No | Format output as JSON | + +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* + +--- + ### `unregister()` Unregisters a sideloaded development package. Only removes packages registered in development mode (e.g., via 'winapp run' or 'create-debug-identity'). @@ -2002,6 +2020,17 @@ type ManifestTemplates = "packaged" | "sparse" | `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | | `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | +### `UiYieldOptions` + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `json` | `boolean \| undefined` | No | Format output as JSON | +| `quiet` | `boolean \| undefined` | No | Suppress progress messages. | +| `verbose` | `boolean \| undefined` | No | Enable verbose output. | +| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +| `signal` | `AbortSignal \| undefined` | No | Cancels the whole native invocation, not just a wait for the shared desktop.

`winapp ui` commands take cooperative turns on the desktop, so a command may wait for another workflow to finish. Aborting force-terminates the child on Windows; the CLI's own cleanup may not run, but Windows releases its coordination handles and deletes its participant lease, and other processes reclaim the queue entry. If the abort lands after the command acquired the desktop, UI side effects may already have happened, and aborting an active recording can leave partial output. Rejects with an `AbortError`. | +| `workflowId` | `string \| undefined` | No | Groups this call with other `winapp ui` calls passing the same value into one logical workflow.

Collision arbitration is always on — every desktop-sensitive `winapp ui` command takes a turn whether or not this is set. A workflow id adds *continuity*: calls sharing one keep the desktop reserved between invocations for a short idle grace, may overlap with each other (a recording and the clicks it is recording), and are never interleaved with another workflow's input. Without it, each call is a self-contained one-shot that releases the desktop as soon as it finishes.

Applied to the spawned child process only; `process.env` is never modified. | + ### `UnregisterOptions` | Property | Type | Required | Description | diff --git a/docs/ui-automation.md b/docs/ui-automation.md index 32bf2cb86..f6eca0b10 100644 --- a/docs/ui-automation.md +++ b/docs/ui-automation.md @@ -63,14 +63,15 @@ What you need to know: `WINAPP_UI_WORKFLOW_ID` passed into every cooperating call. - **The four-second grace protects tight bursts, not model reasoning.** A workflow with an id keeps its turn as long as the next command starts within four seconds. That covers back-to-back commands - in one script; it intentionally expires while a model is thinking. + in one script; it intentionally expires while a model is thinking. It is a fallback for when you + cannot say you are finished — when you can, run `winapp ui yield` instead of waiting it out. - **Adaptive workflows must reacquire, revalidate, and replay.** After a reasoning gap another workflow may have used the desktop, so reopen the menu, re-resolve the element, and then act. Send known end-to-end sequences as one tight script rather than holding the desktop while you think. - **Ordering is owner affinity first, then FIFO among the others.** While a workflow is active or inside its grace it may keep issuing commands, even if other workflows are already waiting. Once it - yields or its grace expires, waiting workflows are served in strict arrival order. Continuous - activity by one workflow can therefore delay others indefinitely. + runs `winapp ui yield` or its grace expires, waiting workflows are served in strict arrival order. + Continuous activity by one workflow can therefore delay others indefinitely. - **There is no hard cap.** A long script, an unbounded recording, or a failure loop can block other mutating workflows. - **Cancellation or process termination is the recovery** for a stuck live workflow. Waiting commands @@ -83,9 +84,21 @@ Which commands wait for a turn: | Behavior | Commands | |---|---| -| Runs concurrently (never waits) | `status`, `list-windows`, `inspect`, `search`, `get-property`, `get-value`, `get-focused`, `wait-for`, `set-value`, `scroll-into-view`, `scroll --direction`/`--to` | -| Claims the turn, shares it with the same workflow | `record` | -| Claims the turn and takes the desktop exclusively | `invoke`, `click`, `drag`, `hover`, `scroll --wheel`, `touch`, `pen`, `focus`, `send-keys`, `screenshot` | +| Runs concurrently (never waits) | `status`, `list-windows`, `inspect`, `search`, `get-property`, `get-value`, `get-focused`, `wait-for` | +| Waits for the turn but never takes the desktop | `set-value`, `scroll-into-view`, `scroll --direction`/`--to`, `record` | +| Waits for the turn and takes the desktop exclusively | `invoke`, `click`, `drag`, `hover`, `scroll --wheel`, `touch`, `pen`, `focus`, `send-keys`, `screenshot` | + +The middle row is the one worth understanding. `set-value`, `scroll-into-view` and +`scroll --direction`/`--to` drive UIA patterns rather than the foreground, so they stay +**headless/locked-session friendly** and never block anyone from using the desktop. But they *do* +change what the app shows, so they wait behind another workflow's turn rather than editing a field or +scrolling a list out from under somebody else's click. + +Within one workflow they overlap with other *shared* work — that is how a `record` captures the +`set-value` calls it is recording. They do **not** ignore their own workflow's forward barrier: an +earlier `DesktopExclusive` command of the same workflow (a `click`, a `screenshot`) still blocks +them, exactly as it blocks every later command, so a click and the mutation that follows it stay in +the order you wrote them. `screenshot` always queues for an exclusive turn. Not every capture disturbs the desktop — an ordinary visible window captured through Windows Graphics Capture does not — but the engine restores @@ -95,6 +108,12 @@ the command takes the turn up front rather than guessing. When it composites sev captures them all under one exclusive turn, so the saved image is a single consistent moment rather than a mix of before and after. Encoding and writing the file happen after the desktop is released. +> **`--capture-screen` needs exactly one window.** Live-screen capture records whatever is actually +> in front, and only one window can be. If `-a` matches several top-level or owned windows, the +> command fails with **`invalid_arguments`** before capturing anything rather than fighting the +> foreground. Run `winapp ui list-windows -a ` and retry with `-w `, or drop +> `--capture-screen` to composite every window from its own contents. + `record` shares its turn, so same-workflow input can interleave with the capture — that is how you record a workflow driving an app. Two caveats: @@ -110,8 +129,38 @@ Errors you may see: `invalid_ui_workflow_id` (the variable is set but empty or o was written by a newer `winapp`), `queue_capacity_exceeded` (64 commands from **other** workflows are already waiting — the limit counts live foreign waiters, not processes you have started, so entries belonging to commands that have exited or been killed do not occupy a slot, and your own workflow's -commands queue behind each other rather than against this limit), and `cancelled` (Ctrl+C while -waiting, exit code `130`). +commands queue behind each other rather than against this limit), `ui_turn_busy` (`yield` while your +own workflow still has a command running), and `cancelled` (Ctrl+C while waiting, exit code `130`). + +### Releasing the turn early: `winapp ui yield` + +The four-second grace is a **fallback**: it keeps the desktop reserved when you cannot say for +certain that you are finished. When you *can* say so, say so — `yield` hands the desktop over +immediately instead of making everyone else wait out a grace nobody needs. + +```powershell +$env:WINAPP_UI_WORKFLOW_ID = [guid]::NewGuid().ToString() + +winapp ui invoke File -a notepad +winapp ui click "Save As..." -a notepad +winapp ui set-value txt-filename-a1b2 "notes.txt" -a notepad +winapp ui yield # done — a waiting workflow starts now, not in four seconds +``` + +- **One-shot commands should not set a workflow id at all.** Without one, each command already + releases the desktop the moment it finishes, and there is nothing to yield. +- **Multi-step workflows should yield when they finish**, especially when other workflows may be + waiting. It costs one fast command and removes a four-second stall from everyone else. +- It is **idempotent**. Yielding twice, or after the grace has already lapsed, succeeds and reports + `{ "released": false }` — that is the normal end of a script, not a failure. +- It **never releases another workflow's turn**. If somebody else holds the desktop, or nobody does, + it is a no-op. +- It **fails with `ui_turn_busy`** if your own workflow still has a command running or queued — + a recording, say. Releasing underneath that would hand the desktop away mid-command, so nothing is + released and the running command is unaffected. Wait for it or stop it, then yield again. +- It requires `WINAPP_UI_WORKFLOW_ID`. Without one it fails with `invalid_arguments`. +- It takes no app and no selector: it gives back a reservation, not a window, so it still works after + the app has closed. A waiting command is woken by whoever releases the desktop rather than by polling for it, so a queue costs almost nothing while it waits and handoff is immediate. Each waiter also rechecks on its own @@ -631,6 +680,15 @@ winapp ui list-windows # all windows (no fi winapp ui list-windows --show-hidden # include invisible zero-size windows ``` +### yield +Release this workflow's UI turn early instead of waiting out the four-second idle grace. Requires +`WINAPP_UI_WORKFLOW_ID`; takes no app and no selector. See +[Releasing the turn early](#releasing-the-turn-early-winapp-ui-yield). +```bash +winapp ui yield +winapp ui yield --json # {"released": true} — or false when nothing was held +``` + ## Framework Support | Framework | inspect | search | invoke | set-value | screenshot | diff --git a/plugins/winapp/com.github.copilot/agents/winapp.agent.md b/plugins/winapp/com.github.copilot/agents/winapp.agent.md index ae966782d..5e887e4c8 100644 --- a/plugins/winapp/com.github.copilot/agents/winapp.agent.md +++ b/plugins/winapp/com.github.copilot/agents/winapp.agent.md @@ -83,7 +83,12 @@ Driving a UI while other workflows may be running? │ one-shot that releases the desktop immediately (each tool call usually gets a fresh shell, and │ there is no process-ancestry fallback) ├─ A workflow with an id keeps the desktop for 4s after its last command; that covers a tight -│ script but intentionally expires while you reason +│ script but intentionally expires while you reason. It is a fallback, not the way to finish +├─ Finished a known sequence? → winapp ui yield (hands the desktop over immediately instead of +│ making a waiting workflow sit out the 4s grace; needs WINAPP_UI_WORKFLOW_ID; safe to repeat) +├─ `set-value`, `scroll-into-view` and `scroll --direction`/`--to` never take the desktop, so they +│ stay headless/locked-session friendly — but they DO wait behind another workflow, because they +│ change what the app shows, and behind an earlier exclusive command of your OWN workflow ├─ `ui record` shares its turn only with the SAME workflow id — a no-id recording blocks everyone │ else for its whole duration └─ After a reasoning gap, reopen/re-navigate and re-resolve before acting — another workflow may @@ -305,7 +310,7 @@ Building a WinUI 3 UI and need to find the right control or a working sample? - `ui inspect -a [--depth N] [--interactive] [--hide-disabled] [--hide-offscreen]` — view element tree with semantic slugs and 2-space indentation. `--interactive` filters to invokable elements only (auto-depth 8) — ideal for discovering clickable elements - `ui search -a [--max N]` — find elements; output shows semantic slugs. Surfaces invokable ancestor for all non-invokable results - `ui get-property -a [-p ]` — read UIA properties (including ToggleState, Value, IsSelected, ExpandCollapseState) -- `ui screenshot -a [--output file.png] [--json] [--focus] [--capture-screen]` — capture window as PNG. Default uses Windows.Graphics.Capture (composited surface — preserves rounded corners and works while occluded), with PrintWindow as fallback. Use `--focus` to bring the window to the foreground first; use `--capture-screen` for popup overlays not owned by the target window. +- `ui screenshot -a [--output file.png] [--json] [--focus] [--capture-screen]` — capture window as PNG. Default uses Windows.Graphics.Capture (composited surface — preserves rounded corners and works while occluded), with PrintWindow as fallback. Use `--focus` to bring the window to the foreground first; use `--capture-screen` for popup overlays not owned by the target window. **`--capture-screen` needs exactly one window** — it reads whatever is in front, and only one window can be. If `-a` matches several top-level or owned windows it fails with `invalid_arguments` before capturing; run `winapp ui list-windows -a ` and retry with `-w `. If a capture reports `foreground_not_target`, the window could not be brought to the front — do the same thing: list the windows and target one with `-w `. - `ui record -a [--output file.mp4] [--duration-sec ] [--fps ] [--max-edge ] [--frames] [--capture-screen] [--json]` — record window or element region to an H.264 MP4 using Windows Graphics Capture + Media Foundation. Default is 0 — records until stopped (Ctrl+C interactively, or a newline/EOF on stdin for programmatic callers); use `--duration-sec N` for a timed run. Add `--frames` to retain timestamped JPEGs, `frames.ndjson`, and `manifest.json` under `.frames`. JSON results include `elapsedMs`, `achievedFps`, `cadenceRatio`, `stopReason`, optional `frameArtifacts`, and the capture `mode` (`"wgc"`, `"screen"`, or `"printwindow"`). - `ui invoke -a ` — activate element by slug or text search. Auto-walks to invokable ancestor for non-invokable elements. - `ui hover -a [--dwell-time ]` — move mouse to element center to trigger tooltips, flyouts, and hover states. Use with `ui screenshot --capture-screen` to capture the result. @@ -320,6 +325,7 @@ Building a WinUI 3 UI and need to find the right control or a working sample? - `ui wait-for -a --timeout [--gone] [--value Y] [--property X --value Y]` — wait for element value or property match - `ui list-windows -a [--show-hidden]` — list windows, popups, and dialogs with HWNDs (untitled zero-size windows hidden by default) - `ui get-focused -a ` — show the element with keyboard focus +- `ui yield` — release this workflow's UI turn early instead of waiting out the 4s idle grace. Requires `WINAPP_UI_WORKFLOW_ID`; takes no app or selector. Idempotent, never releases another workflow's turn, and fails with `ui_turn_busy` if your own workflow still has a command running. ## Framework-specific guidance diff --git a/plugins/winapp/skills/winapp-ui-automation/SKILL.md b/plugins/winapp/skills/winapp-ui-automation/SKILL.md index 0ed718c9c..1cc085a24 100644 --- a/plugins/winapp/skills/winapp-ui-automation/SKILL.md +++ b/plugins/winapp/skills/winapp-ui-automation/SKILL.md @@ -13,6 +13,7 @@ description: Inspect and interact with running Windows app UIs from the command - For UIA mode (any app): No setup needed — works with any running Windows app - For input-injecting verbs (`click`, `hover`, `drag`, `touch`, `pen`, `scroll --wheel`, `send-keys --via send-input`): an **unlocked, interactive desktop** with the target window foregroundable. On a locked/secure desktop they fail fast with `no_interactive_desktop`. The UIA-pattern verbs (`inspect`, `search`, `get-*`, `wait-for`, `set-value`, `invoke`, `scroll --direction/--to`) are headless/locked-session friendly — prefer them in CI. - `screenshot` is **not** in that group: it always takes an exclusive turn, so it queues behind other UI workflows, and capture can need a usable interactive desktop — the engine restores the target if it is minimized, and falls back to foregrounding it when frame capture is unavailable or `--capture-screen` is used. +- `--capture-screen` needs **exactly one window**. If `-a` matches several top-level or owned windows the command fails with `invalid_arguments` before capturing; run `winapp ui list-windows -a ` and retry with `-w `. - **If other UI workflows may run at the same time**, set one workflow id per logical workflow (see below). Nothing breaks without it, but your commands will not be recognized as belonging together. ## Coordinating with other UI workflows @@ -36,12 +37,16 @@ Rules that matter when driving this from an agent: - **Each tool call usually gets a fresh shell**, and there is no process-ancestry fallback, so commands are grouped ONLY by the id you inject. Without it every call is its own workflow. - **A workflow with an id keeps its turn for four seconds** after its last command. That covers - back-to-back commands in one script; it deliberately expires while you are reasoning. + back-to-back commands in one script; it deliberately expires while you are reasoning. Treat it as + a fallback for when you cannot say you are done — not as the way to finish. +- **Run `winapp ui yield` when you finish a known sequence.** It hands the desktop over immediately + instead of making a waiting workflow sit out a four-second grace nobody needs. Yielding twice, or + after the grace lapsed, is a harmless success. - **After a reasoning gap, replay your setup.** Another workflow may have used the desktop, so reopen the menu / re-navigate, re-resolve the element, then act. Do not assume transient UI survived. - **Prefer one tight script over many round trips** for a known sequence: `winapp ui invoke View -w - $hwnd; winapp ui search "Status bar" -w $hwnd; winapp ui click "Status bar" -w $hwnd`. + $hwnd; winapp ui search "Status bar" -w $hwnd; winapp ui click "Status bar" -w $hwnd; winapp ui yield`. - **`record` shares the turn with its own workflow**, so same-workflow clicks and typing are captured while it runs — but only if both commands carry the same id. A `record` with no id blocks everyone else for its whole duration. @@ -52,10 +57,20 @@ Rules that matter when driving this from an agent: - **There is no hard cap** — a long script, unbounded recording, or failure loop can block other mutating workflows until it finishes or is stopped. -Commands that never wait: `status`, `list-windows`, `inspect`, `search`, `get-*`, `wait-for`, -`set-value`, `scroll-into-view`, `scroll --direction`/`--to`. -Commands that take a turn: `record` (shared) and `invoke`, `click`, `drag`, `hover`, -`scroll --wheel`, `touch`, `pen`, `focus`, `send-keys`, `screenshot` (all exclusive). +Commands that never wait: `status`, `list-windows`, `inspect`, `search`, `get-*`, `wait-for`. +Commands that wait for the turn but never take the desktop (headless/locked-session friendly): +`set-value`, `scroll-into-view`, `scroll --direction`/`--to`, `record`. They mutate the app, so they +queue behind another workflow. Inside your own workflow they overlap with other shared work — that +is how `record` captures the `set-value` calls it is recording — but they still wait behind an +earlier `DesktopExclusive` command of your own workflow, so a `click` followed by a `set-value` runs +in the order you wrote it. +Commands that take the desktop exclusively: `invoke`, `click`, `drag`, `hover`, `scroll --wheel`, +`touch`, `pen`, `focus`, `send-keys`, `screenshot`. + +```powershell +# Finish a workflow deliberately rather than leaving the desktop reserved for four more seconds. +winapp ui yield +``` ## Common patterns diff --git a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md index abf560400..6a0fa1988 100644 --- a/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md +++ b/plugins/winapp/skills/winapp-ui-automation/references/ui-json-envelope.md @@ -116,8 +116,14 @@ appear: | `invalid_ui_workflow_id` | `WINAPP_UI_WORKFLOW_ID` is set but empty/whitespace or longer than 256 characters. Fails before any UI side effect. | | `desktop_coordination_unavailable` | Coordination state could not be read, published, or safely rebuilt — including state written by a newer `winapp`. Mutating commands fail closed rather than acting uncoordinated. | | `queue_capacity_exceeded` | 64 commands from other workflows are already waiting for the desktop. Counts live foreign waiters, so entries left by commands that exited or were killed do not occupy a slot. | +| `ui_turn_busy` | `ui yield` was run while this same workflow still has a command running or queued, so its turn is not idle. Nothing was released, and the running command is unaffected. Distinct from `invalid_arguments` (the request was well formed) and from `desktop_coordination_unavailable` (coordination is working — this is a valid request at an unsafe moment). Carries a `recoveryHint`: wait for or stop this workflow's other `winapp ui` commands — typically a `record` started with the same `WINAPP_UI_WORKFLOW_ID` — then retry `yield`. | | `cancelled` | Native Ctrl+C while the command was still waiting for its turn. The command never ran, so it has no UI side effects. Exit code **130**. | +`ui yield` also emits the command-level `invalid_arguments` when `WINAPP_UI_WORKFLOW_ID` is not set +at all — deliberately not `invalid_ui_workflow_id`, which means the variable is present but +malformed. On success it writes `{ "released": true }`, or `{ "released": false }` when this workflow +held nothing to release (both exit **0**). + An npm `AbortSignal` is a different contract: Node force-terminates the child, so there is usually no envelope and no exit code 130 — the wrapper rejects with an `AbortError` instead, and UI side effects may already have happened if the diff --git a/scripts/test-ui-coordination.ps1 b/scripts/test-ui-coordination.ps1 new file mode 100644 index 000000000..3468d14d9 --- /dev/null +++ b/scripts/test-ui-coordination.ps1 @@ -0,0 +1,78 @@ +<# +.SYNOPSIS +Runs the gated cooperative desktop-turn tests and proves they actually ran. + +.DESCRIPTION +The UI coordination suites (issue #764 §18.2/§18.3) drive real winapp.exe child processes against a +real foreground window, so they are gated behind WINAPP_UI_MULTIPROCESS_TESTS and stay off the +canonical build. This script sets that gate and runs them on an interactive machine or CI lane where +the published CLI binaries already exist. + +The gate itself is the reason this is a script rather than a plain `dotnet run`. When the gate is +unset, or the published-binary lookup regresses, every test reports Inconclusive — and the run still +exits 0. Trusting the exit code would mean a green build that verified nothing, which is exactly what +happened before these suites were wired into CI at all. So the result is asserted against the TRX: +zero tests is a broken filter, any skip is a broken gate, and either fails the run. + +.PARAMETER ResultsDirectory +Where to write the TRX. Default: artifacts/TestResults/ui-coordination under the repo root. + +.PARAMETER Configuration +Build configuration for the test project. Default: Debug. + +.PARAMETER Filter +Test filter. Default: the multiprocess and real-app coordination suites. + +.EXAMPLE +.\test-ui-coordination.ps1 +Run every gated coordination test and fail on zero-matched, skipped, or failing tests. + +.EXAMPLE +.\test-ui-coordination.ps1 -Filter "FullyQualifiedName~InteractiveDesktopRealAppTests" +Run only the real-app suite. +#> + +param( + [string]$ResultsDirectory, + [string]$Configuration = 'Debug', + [string]$Filter = 'FullyQualifiedName~InteractiveDesktopMultiprocessTests|FullyQualifiedName~InteractiveDesktopRealAppTests' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path "$PSScriptRoot\..").Path +$testProject = Join-Path $repoRoot 'src\winapp-CLI\WinApp.Cli.Tests\WinApp.Cli.Tests.csproj' + +if (-not $ResultsDirectory) { + $ResultsDirectory = Join-Path $repoRoot 'artifacts\TestResults\ui-coordination' +} + +New-Item -ItemType Directory -Path $ResultsDirectory -Force | Out-Null + +# The suites resolve the published winapp.exe for the current architecture themselves; this only has +# to turn them on. Scoped to the child process so an interactive shell is not left gated. +$env:WINAPP_UI_MULTIPROCESS_TESTS = '1' + +dotnet run --project $testProject -c $Configuration ` + --results-directory $ResultsDirectory --report-trx --report-trx-filename ui-coordination.trx ` + --filter $Filter +$testExit = $LASTEXITCODE + +$trx = Get-ChildItem -Path $ResultsDirectory -Filter *.trx -Recurse | Sort-Object LastWriteTime | Select-Object -Last 1 +if (-not $trx) { throw "No TRX produced: the UI coordination tests did not run." } + +[xml]$doc = Get-Content $trx.FullName +$counters = $doc.TestRun.ResultSummary.Counters +$total = [int]$counters.total +$passed = [int]$counters.passed +$failed = [int]$counters.failed +# MSTest reports Assert.Inconclusive (the gate's skip path) under notExecuted. +$skipped = [int]$counters.notExecuted + [int]$counters.inconclusive +Write-Host "UI coordination tests: total=$total passed=$passed failed=$failed skipped=$skipped" + +# Asserted dynamically rather than pinned to today's count, so adding coverage does not fail the +# build while a filter that stops matching still does. +if ($total -eq 0) { throw "The UI coordination filter matched no tests — it no longer selects the gated suites." } +if ($skipped -gt 0) { throw "$skipped UI coordination test(s) skipped; the gate must run them here, not skip them." } +if ($failed -gt 0 -or $testExit -ne 0) { throw "UI coordination tests failed (exit $testExit)." } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs index 7733144cc..deddab673 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs @@ -30,6 +30,26 @@ internal sealed class FakeInteractiveDesktopLock : IInteractiveDesktopLock /// Set to throw from , to cover coordination failures. public UiCoordinationException? ThrowOnRun { get; set; } + /// Every ui yield that reached coordination, in order. + public List YieldCalls { get; } = []; + + /// What reports. Defaults to a successful release. + public UiYieldResult YieldResult { get; set; } = UiYieldResult.Released; + + /// Set to throw from , to cover coordination failures. + public UiCoordinationException? ThrowOnYield { get; set; } + + public UiYieldResult ReleaseIdleTurn(CancellationToken cancellationToken) + { + if (ThrowOnYield is { } failure) + { + throw failure; + } + + YieldCalls.Add(YieldResult); + return YieldResult; + } + /// Milliseconds reported as queue wait, so output/telemetry paths can be exercised. public long WaitedMs { get; set; } diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.Yield.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.Yield.cs new file mode 100644 index 000000000..d7377bdc0 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.Yield.cs @@ -0,0 +1,370 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Services.InteractiveDesktop; + +namespace WinApp.Cli.Tests; + +/// +/// Explicit early release (winapp ui yield) and the turn-sharing behaviour of background-safe +/// mutations, over the real store, leases and file locks. +/// +public partial class InteractiveDesktopLockTests +{ + // ------------------------------------------------- background-safe mutations take a shared turn + + [TestMethod] + public async Task ABackgroundSafeMutationWaitsWhileAnotherWorkflowOwnsTheDesktop() + { + // set-value and scroll --direction never take active.lock, but they do change what the app + // shows, so they must not run underneath another workflow's click. + using var foreignLease = OccupyTurnWithAnotherOwner(); + + using var cts = new CancellationTokenSource(); + var ran = false; + var queued = RunAsyncWithToken(UiTurnMode.TurnShared, "ui set-value", (_, _) => + { + ran = true; + return Task.FromResult(0); + }, cts.Token); + + await Task.Delay(250); + Assert.IsFalse(ran, "a background-safe mutation must wait behind a foreign workflow's turn"); + + using (var stateLock = _store.AcquireStateLock(CancellationToken.None)) + { + Assert.AreEqual(1, _store.Read().State!.Waiters.Count, + "it must be recorded as a global waiter, not run detached"); + } + + await cts.CancelAsync(); + await queued; + } + + [TestMethod] + public async Task ABackgroundSafeMutationOverlapsWithItsOwnWorkflowsRecording() + { + // The point of TurnShared rather than DesktopExclusive: a recording and the set-value it is + // recording belong to one workflow and must run at the same time. The recording is seeded as a + // live foreign process under this same workflow, because participant identity is + // (pid, processStartTicks) and one test process cannot hold two leases. + using var recordingLease = OccupyTurnWithThisWorkflow(UiTurnMode.TurnShared, "ui record"); + + var mutationRan = false; + var mutation = RunAsync(UiTurnMode.TurnShared, "ui set-value", (_, _) => + { + mutationRan = true; + return Task.FromResult(0); + }); + + Assert.AreEqual(0, await mutation.WaitAsync(TimeSpan.FromSeconds(5)), + "the same workflow's mutation must not wait for its own recording to finish"); + Assert.IsTrue(mutationRan); + } + + [TestMethod] + public async Task ABackgroundSafeMutationStillWaitsBehindItsOwnWorkflowsExclusiveBarrier() + { + // Same workflow, but an exclusive command is already holding the barrier. Sharing the turn does + // not mean ignoring the barrier: the mutation must queue behind it like any later command. + using var clickLease = OccupyTurnWithThisWorkflow(UiTurnMode.DesktopExclusive, "ui click"); + + using var cts = new CancellationTokenSource(); + var ran = false; + var queued = RunAsyncWithToken(UiTurnMode.TurnShared, "ui set-value", (_, _) => + { + ran = true; + return Task.FromResult(0); + }, cts.Token); + + await Task.Delay(250); + Assert.IsFalse(ran, "an exclusive command of the same workflow is still a forward barrier"); + + await cts.CancelAsync(); + await queued; + } + + // ------------------------------------------------------------------------ ui yield: rejection + + [TestMethod] + public void Yield_WithNoWorkflowId_IsRejectedWithoutReadingState() + { + Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, null); + + Assert.AreEqual(UiYieldResult.NotAWorkflow, _coordinator.ReleaseIdleTurn(CancellationToken.None)); + Assert.IsFalse(File.Exists(_paths.StatePath), + "an anonymous caller holds no turn by construction, so state must not be touched to prove it"); + } + + [TestMethod] + public void Yield_WithABlankWorkflowId_StillReportsTheMalformedValue() + { + // Absence and malformation are different mistakes. An unset variable means "I am a one-shot" + // and is rejected locally as invalid_arguments; a variable that expanded to "" is a scripting + // bug, and must keep reporting invalid_ui_workflow_id through the ordinary resolver path. + Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, " "); + + var ex = Assert.ThrowsExactly( + () => _coordinator.ReleaseIdleTurn(CancellationToken.None)); + Assert.AreEqual(UiCoordinationErrorCodes.InvalidWorkflowId, ex.Code); + } + + // ------------------------------------------------------------------------- ui yield: releasing + + [TestMethod] + public async Task Yield_ReleasesThisWorkflowsIdleTurn() + { + // A completed command leaves the turn owned and idle for the grace; yield ends that early. + await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0)); + + using (var beforeLock = _store.AcquireStateLock(CancellationToken.None)) + { + var before = _store.Read().State!; + Assert.IsNotNull(before.Owner, "the workflow keeps the turn through its idle grace"); + Assert.AreEqual(0, before.OwnerCommands.Count); + } + + Assert.AreEqual(UiYieldResult.Released, _coordinator.ReleaseIdleTurn(CancellationToken.None)); + + using var afterLock = _store.AcquireStateLock(CancellationToken.None); + var after = _store.Read().State!; + Assert.IsNull(after.Owner, "the turn must be free immediately, not at the end of the grace"); + Assert.AreEqual(0, after.IdleExpiresTick64); + } + + [TestMethod] + public async Task Yield_PromotesAndWakesTheWaitingWorkflow() + { + await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0)); + + // A foreign workflow queued behind this one's idle grace. + const int WaitingPid = 515151; + const long WaitingStart = 424242424; + using var waitingLease = OpenForeignLease(WaitingPid, WaitingStart); + using (var stateLock = _store.AcquireStateLock(CancellationToken.None)) + { + var state = _store.Read().State!; + state.Waiters.Add(new WaiterEntry + { + Ticket = state.AllocateTicket(), + OwnerKey = "a-waiting-workflow", + OwnerKind = UiOwnerKind.Workflow, + Pid = WaitingPid, + ProcessStartTicksUtc = WaitingStart, + Operation = "ui click", + Mode = UiTurnMode.DesktopExclusive, + }); + _store.Publish(state); + } + + _signals.Signalled.Clear(); + Assert.AreEqual(UiYieldResult.Released, _coordinator.ReleaseIdleTurn(CancellationToken.None)); + + using var afterLock = _store.AcquireStateLock(CancellationToken.None); + var after = _store.Read().State!; + Assert.AreEqual("a-waiting-workflow", after.Owner!.Key, "the waiter must take the turn"); + Assert.AreEqual(1, after.OwnerCommands.Count); + Assert.AreEqual(UiCommandStatus.Running, after.OwnerCommands[0].Status); + CollectionAssert.Contains( + _signals.Signalled, (WaitingPid, WaitingStart), + "the promoted waiter must be woken rather than left to time out"); + } + + [TestMethod] + public async Task Yield_Twice_IsIdempotent() + { + await RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => Task.FromResult(0)); + + Assert.AreEqual(UiYieldResult.Released, _coordinator.ReleaseIdleTurn(CancellationToken.None)); + Assert.AreEqual(UiYieldResult.NothingHeld, _coordinator.ReleaseIdleTurn(CancellationToken.None), + "yielding an already-released turn is the normal end of a script, not a failure"); + } + + // ---------------------------------------------------------------------------- ui yield: no-ops + + [TestMethod] + public void Yield_WhenNobodyOwnsTheTurn_IsANoOp() + => Assert.AreEqual(UiYieldResult.NothingHeld, _coordinator.ReleaseIdleTurn(CancellationToken.None)); + + [TestMethod] + public void Yield_NeverReleasesAnotherWorkflowsTurn() + { + using var foreignLease = OccupyTurnWithAnotherOwner(); + + Assert.AreEqual(UiYieldResult.NothingHeld, _coordinator.ReleaseIdleTurn(CancellationToken.None)); + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var state = _store.Read().State!; + Assert.AreEqual("some-other-workflow", state.Owner!.Key, "the other workflow must keep its turn"); + Assert.AreEqual(1, state.OwnerCommands.Count, "and keep its running command"); + } + + [TestMethod] + public void Yield_PublishesPruningEvenWhenItReleasesNothing() + { + // A released:false branch that still changed state. The dead waiter's lease was never opened, + // so normalization prunes it; if yield returned without publishing, that pruning would be + // silently discarded and every later reader would keep paying for the phantom entry. + using (var stateLock = _store.AcquireStateLock(CancellationToken.None)) + { + var state = InteractiveDesktopState.CreateFresh(); + state.NextTicket = 2; + state.Waiters.Add(new WaiterEntry + { + Ticket = 1, + OwnerKey = "a-dead-workflow", + OwnerKind = UiOwnerKind.Workflow, + Pid = 818181, + ProcessStartTicksUtc = 777888999, + Operation = "ui click", + Mode = UiTurnMode.DesktopExclusive, + }); + _store.Publish(state); + } + + Assert.AreEqual(UiYieldResult.NothingHeld, _coordinator.ReleaseIdleTurn(CancellationToken.None)); + + using var afterLock = _store.AcquireStateLock(CancellationToken.None); + var after = _store.Read().State!; + Assert.AreEqual(0, after.Waiters.Count, "the pruning yield performed must be published, not dropped"); + Assert.IsNull(after.Owner, "a dead waiter must not be promoted into ownership"); + } + + // ------------------------------------------------------------------------------ ui yield: busy + + [TestMethod] + public async Task Yield_WhileThisWorkflowStillHasALiveCommand_RefusesAsBusy() + { + var commandStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseCommand = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var running = RunAsync(UiTurnMode.TurnShared, "ui record", async (_, _) => + { + commandStarted.SetResult(); + await releaseCommand.Task; + return 0; + }); + + await commandStarted.Task; + + Assert.AreEqual(UiYieldResult.Busy, _coordinator.ReleaseIdleTurn(CancellationToken.None), + "the turn is not idle while this workflow is still driving it"); + + using (var stateLock = _store.AcquireStateLock(CancellationToken.None)) + { + var state = _store.Read().State!; + Assert.IsNotNull(state.Owner, "a refused yield must leave the turn exactly as it was"); + Assert.AreEqual(1, state.OwnerCommands.Count); + } + + releaseCommand.SetResult(); + Assert.AreEqual(0, await running); + } + + // --------------------------------------------------------------------- ui yield: normalization + + [TestMethod] + public void Yield_NormalizesACrashedOwnerAndPromotesTheNextWorkflow() + { + // The dead owner's lease is never opened, so it reads as crashed. Yield's own transaction has + // to publish that normalization even though this workflow had nothing of its own to release. + const int DeadPid = 606060; + const long DeadStart = 111222333; + const int WaitingPid = 707070; + const long WaitingStart = 444555666; + + using var waitingLease = OpenForeignLease(WaitingPid, WaitingStart); + using (var stateLock = _store.AcquireStateLock(CancellationToken.None)) + { + var state = InteractiveDesktopState.CreateFresh(); + state.TurnId = 1; + state.NextTicket = 3; + state.Owner = new OwnerRecord { Kind = UiOwnerKind.Workflow, Key = "a-crashed-workflow" }; + state.OwnerCommands.Add(new OwnerCommandEntry + { + Ticket = 1, + Pid = DeadPid, + ProcessStartTicksUtc = DeadStart, + Operation = "ui click", + Mode = UiTurnMode.DesktopExclusive, + Status = UiCommandStatus.Running, + }); + state.Waiters.Add(new WaiterEntry + { + Ticket = 2, + OwnerKey = "a-waiting-workflow", + OwnerKind = UiOwnerKind.Workflow, + Pid = WaitingPid, + ProcessStartTicksUtc = WaitingStart, + Operation = "ui click", + Mode = UiTurnMode.DesktopExclusive, + }); + _store.Publish(state); + } + + _signals.Signalled.Clear(); + Assert.AreEqual(UiYieldResult.NothingHeld, _coordinator.ReleaseIdleTurn(CancellationToken.None), + "the yielding workflow held nothing — the crashed owner did"); + + using var afterLock = _store.AcquireStateLock(CancellationToken.None); + var after = _store.Read().State!; + Assert.AreEqual("a-waiting-workflow", after.Owner!.Key, + "yield's normalization must publish the recovery it just performed"); + CollectionAssert.Contains(_signals.Signalled, (WaitingPid, WaitingStart)); + } + + /// + /// Holds a foreign participant's lease FileShare.None, which is exactly what the liveness + /// probe sees for a real second process. + /// + private FileStream OpenForeignLease(int pid, long startTicksUtc) + { + _paths.EnsureDirectories(); + return new FileStream( + _paths.LeasePath(pid, startTicksUtc), + FileMode.Create, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.DeleteOnClose); + } + + /// + /// Publishes state in which this workflow already holds the turn through a live command + /// running in another process, and holds that command's lease so it reads as live. + /// + /// + /// Participant identity is (pid, processStartTicks), so one test process cannot hold two + /// leases and cannot run two coordinated commands at once. Seeding the first command is how the + /// same-workflow overlap rules — which are about two processes sharing a workflow id — + /// stay testable in process. + /// + private FileStream OccupyTurnWithThisWorkflow( + UiTurnMode mode, string operation, int foreignPid = 313131, long foreignStart = 191919191) + { + var leaseStream = OpenForeignLease(foreignPid, foreignStart); + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var state = InteractiveDesktopState.CreateFresh(); + state.TurnId = 1; + state.NextTicket = 2; + state.Owner = new OwnerRecord + { + Kind = UiOwnerKind.Workflow, + // The same key this test process resolves, so the seeded command really is "us". + Key = new UiOwnerResolver().Resolve().Key, + }; + state.OwnerCommands.Add(new OwnerCommandEntry + { + Ticket = 1, + Pid = foreignPid, + ProcessStartTicksUtc = foreignStart, + Operation = operation, + Mode = mode, + Status = UiCommandStatus.Running, + }); + _store.Publish(state); + + return leaseStream; + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs index da58732ca..35e8f50da 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs @@ -88,15 +88,29 @@ public async Task Inspect_IsAnObservation() } [TestMethod] - public async Task SetValue_StaysAnObservationBecauseItIsBackgroundSafe() + public async Task SetValue_TakesASharedTurnBecauseItChangesWhatTheAppShows() { - // Spec §6.1: background-safe UIA mutations stay concurrent. This feature prevents desktop - // interference, not transactional app-state isolation. + // Background-safe: it drives ValuePattern and never takes active.lock, so it stays usable on a + // locked or headless session. But it does mutate the app, so it must wait behind a foreign + // workflow rather than editing a control underneath somebody else's click. _fakeUia.FindSingleResult = new UiElement { Id = "box", Selector = "box", Name = "Box" }; var command = GetRequiredService(); await ParseAndInvokeWithCaptureAsync(command, ["box", "hello", "-a", "TestApp", "--json"]); - Assert.AreEqual(UiTurnMode.Observe, _fakeDesktopLock.Runs[0].Mode); + Assert.AreEqual(UiTurnMode.TurnShared, _fakeDesktopLock.Runs[0].Mode); + Assert.AreEqual(0, _fakeDesktopLock.DesktopSectionEnters, + "a background-safe mutation must never take active.lock"); + } + + [TestMethod] + public async Task ScrollIntoView_TakesASharedTurnWithoutTakingTheDesktop() + { + _fakeUia.FindSingleResult = new UiElement { Id = "item", Selector = "item", Name = "Item" }; + var command = GetRequiredService(); + await ParseAndInvokeWithCaptureAsync(command, ["item", "-a", "TestApp", "--json"]); + + Assert.AreEqual(UiTurnMode.TurnShared, _fakeDesktopLock.Runs[0].Mode); + Assert.AreEqual(0, _fakeDesktopLock.DesktopSectionEnters); } [TestMethod] @@ -128,15 +142,26 @@ public async Task Scroll_ClassifiesByTransport() }; _fakeSystemQuery.ProcessIdForWindowResult = 1234; - // --direction uses the UIA ScrollPattern, which works in the background. + // --direction uses the UIA ScrollPattern, which moves the container without touching the + // desktop: a shared turn, and no active.lock. var command = GetRequiredService(); await ParseAndInvokeWithCaptureAsync(command, ["list", "-a", "TestApp", "--direction", "down", "--json"]); - Assert.AreEqual(UiTurnMode.Observe, _fakeDesktopLock.Runs[0].Mode); + Assert.AreEqual(UiTurnMode.TurnShared, _fakeDesktopLock.Runs[0].Mode); + Assert.AreEqual(0, _fakeDesktopLock.DesktopSectionEnters, + "ScrollPattern must not take active.lock"); + + // --to is the same transport, so it classifies the same way. + _fakeDesktopLock.Runs.Clear(); + await ParseAndInvokeWithCaptureAsync(command, ["list", "-a", "TestApp", "--to", "bottom", "--json"]); + Assert.AreEqual(UiTurnMode.TurnShared, _fakeDesktopLock.Runs[0].Mode); + Assert.AreEqual(0, _fakeDesktopLock.DesktopSectionEnters); // --wheel injects OS-wide mouse input at the cursor. _fakeDesktopLock.Runs.Clear(); await ParseAndInvokeWithCaptureAsync(command, ["list", "-a", "TestApp", "--wheel", "3", "--json"]); Assert.AreEqual(UiTurnMode.DesktopExclusive, _fakeDesktopLock.Runs[0].Mode); + Assert.AreEqual(1, _fakeDesktopLock.DesktopSectionEnters, + "wheel input must run inside a desktop section"); } [TestMethod] diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.Ambiguity.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.Ambiguity.cs new file mode 100644 index 000000000..6960d4456 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.Ambiguity.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Commands; + +namespace WinApp.Cli.Tests; + +/// +/// --capture-screen against an app with more than one window. +/// +/// +/// Live-screen capture reads pixels off the screen, so it records whatever is actually in front. +/// Only one window can be in front, which makes "capture the screen for each of these five windows +/// and composite them" unsatisfiable: at best it stitches together moments where each window covered +/// the others, and in practice Windows refuses the second activation and the command dies with a bare +/// foreground_not_target that says nothing about the real problem. The command has to name the +/// ambiguity instead, before it foregrounds or captures anything. +/// +public partial class UiCommandTests +{ + private const int MultiWindowPid = 7788; + private const nint FirstWindow = 0x200; + private const nint SecondWindow = 0x201; + + /// + /// Points the command at an app whose process has two top-level windows. + /// + /// + /// Targeted by PID rather than by name: name lookup goes through the real process table, while + /// -a <pid> routes through FindWindowsByPid, which the fake drives. + /// + private void ArrangeTwoTopLevelWindows() + { + _fakeSystemQuery.ProcessIdForWindowResult = MultiWindowPid; + _fakeSystemQuery.ProcessIdByHwnd[FirstWindow] = MultiWindowPid; + _fakeSystemQuery.ProcessIdByHwnd[SecondWindow] = MultiWindowPid; + _fakeSystemQuery.WindowTextResult = "Main Window"; + _fakeUia.WindowsByPidResult = + [ + (FirstWindow, MultiWindowPid, "Main Window"), + (SecondWindow, MultiWindowPid, "Second Window"), + ]; + _fakeUia.ScreenshotResult = (new byte[4], 1, 1); + } + + private static string App => MultiWindowPid.ToString(); + + [TestMethod] + public async Task Screenshot_CaptureScreenWithSeveralWindows_IsRejectedBeforeAnyCapture() + { + ArrangeTwoTopLevelWindows(); + var command = GetRequiredService(); + + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-a", App, "--capture-screen", "--json", "-o", ShotPath()]); + + Assert.AreEqual(1, exitCode); + AssertJsonErrorCode("invalid_arguments"); + Assert.AreEqual(0, _fakeUia.ScreenshotCalls.Count, + "the failure must land before any window is foregrounded or captured"); + } + + [TestMethod] + public async Task Screenshot_CaptureScreenAmbiguity_PointsAtListWindowsAndTheWindowFlag() + { + // The reviewer's report was that the generic foreground error left no way to work out what to + // do next. The recovery hint has to name both halves of the fix. + ArrangeTwoTopLevelWindows(); + var command = GetRequiredService(); + + await ParseAndInvokeWithCaptureAsync( + command, ["-a", App, "--capture-screen", "--json", "-o", ShotPath()]); + + var stderr = ConsoleStdErr.ToString(); + StringAssert.Contains(stderr, "list-windows"); + StringAssert.Contains(stderr, "-w "); + } + + [TestMethod] + public async Task Screenshot_CaptureScreenForOneExplicitWindow_StillWorks() + { + // -w is the documented fix, so it must not be caught by the same guard. + ArrangeTwoTopLevelWindows(); + var command = GetRequiredService(); + + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-w", SecondWindow.ToString(), "--capture-screen", "--json", "-o", ShotPath()]); + + Assert.AreEqual(0, exitCode); + Assert.AreEqual(1, _fakeUia.ScreenshotCalls.Count); + Assert.IsTrue(_fakeUia.ScreenshotCalls[0].CaptureScreen); + } + + [TestMethod] + public async Task Screenshot_SeveralWindowsWithoutCaptureScreen_StillComposites() + { + // Window-content capture does not read the screen, so several windows compose fine. Rejecting + // this too would have broken the feature the multi-window path exists for. + ArrangeTwoTopLevelWindows(); + var command = GetRequiredService(); + + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-a", App, "--json", "-o", ShotPath()]); + + Assert.AreEqual(0, exitCode); + Assert.AreEqual(2, _fakeUia.ScreenshotCalls.Count, "both windows must still be captured"); + } + + [TestMethod] + public async Task Screenshot_CaptureScreenWithAnOwnedDialog_IsAlsoRejected() + { + // The other way a capture becomes multi-window: one app window plus a dialog it owns. Same + // ambiguity, same guard — the app window and its file picker cannot both be in front. + ArrangeOwnedDialog(ownerOfDialog: AppWindow); + var command = GetRequiredService(); + + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-w", AppWindow.ToString(), "--capture-screen", "--json", "-o", ShotPath()]); + + Assert.AreEqual(1, exitCode); + AssertJsonErrorCode("invalid_arguments"); + Assert.AreEqual(0, _fakeUia.ScreenshotCalls.Count); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Yield.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Yield.cs new file mode 100644 index 000000000..b16359439 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Yield.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.Commands; +using WinApp.Cli.Services.InteractiveDesktop; + +namespace WinApp.Cli.Tests; + +/// +/// Command-level coverage of winapp ui yield: the outcomes coordination reports must reach the +/// caller as the right exit code, error code and JSON shape, and yield must never register itself as a +/// participant of the turn it is releasing. +/// +public partial class UiCommandTests +{ + [TestMethod] + public async Task Yield_ReleasesWithoutTakingATurnOrTheDesktop() + { + // Yield gives a turn back. Taking one to do it would make it the very live command that stops + // the turn being idle, so it must never appear as a coordinated run or open a desktop section. + _fakeDesktopLock.YieldResult = UiYieldResult.Released; + var command = GetRequiredService(); + + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["--json"]); + + Assert.AreEqual(0, exitCode); + StringAssert.Contains(TestAnsiConsole.Output, "\"released\": true"); + Assert.AreEqual(1, _fakeDesktopLock.YieldCalls.Count); + Assert.AreEqual(0, _fakeDesktopLock.Runs.Count, "yield must not take a turn of its own"); + Assert.AreEqual(0, _fakeDesktopLock.DesktopSectionEnters, "yield must never take active.lock"); + } + + [TestMethod] + public async Task Yield_WithNothingHeld_SucceedsAndReportsItReleasedNothing() + { + // Yielding after the grace already lapsed, or twice, is the normal end of a script. + _fakeDesktopLock.YieldResult = UiYieldResult.NothingHeld; + var command = GetRequiredService(); + + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["--json"]); + + Assert.AreEqual(0, exitCode); + StringAssert.Contains(TestAnsiConsole.Output, "\"released\": false"); + } + + [TestMethod] + public async Task Yield_WithoutAWorkflowId_FailsWithInvalidArguments() + { + // Deliberately not invalid_ui_workflow_id: that code means the variable is present but + // malformed, and reporting it here would send the caller hunting for a value they never set. + _fakeDesktopLock.YieldResult = UiYieldResult.NotAWorkflow; + var command = GetRequiredService(); + + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["--json"]); + + Assert.AreEqual(1, exitCode); + AssertJsonErrorCode("invalid_arguments"); + StringAssert.Contains(ConsoleStdErr.ToString(), UiOwnerResolver.WorkflowIdVariable); + } + + [TestMethod] + public async Task Yield_WhileTheWorkflowIsStillBusy_FailsWithTurnBusy() + { + _fakeDesktopLock.YieldResult = UiYieldResult.Busy; + var command = GetRequiredService(); + + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["--json"]); + + Assert.AreEqual(1, exitCode); + AssertJsonErrorCode(UiCoordinationErrorCodes.TurnBusy); + } + + [TestMethod] + public async Task Yield_WhenCoordinationIsUnavailable_SurfacesTheCoordinationError() + { + _fakeDesktopLock.ThrowOnYield = new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, "state is unreadable", "update winapp"); + var command = GetRequiredService(); + + var exitCode = await ParseAndInvokeWithCaptureAsync(command, ["--json"]); + + Assert.AreEqual(1, exitCode); + AssertJsonErrorCode(UiCoordinationErrorCodes.Unavailable); + } + + [TestMethod] + public async Task Yield_TextOutput_NeverRevealsTheWorkflowId() + { + _fakeDesktopLock.YieldResult = UiYieldResult.Released; + var command = GetRequiredService(); + + var exitCode = await ParseAndInvokeWithCaptureAsync(command, []); + + Assert.AreEqual(0, exitCode); + var output = TestAnsiConsole.Output + ConsoleStdErr; + Assert.IsFalse(output.Contains('{'), "text mode must not emit the JSON envelope"); + Assert.IsFalse( + output.Contains(UiOwnerResolver.WorkflowIdVariable + "=", StringComparison.Ordinal), + "the workflow id itself is never echoed back"); + } + + [TestMethod] + public void Yield_TakesNoAppOrSelector() + { + // It releases a reservation, not a window: requiring -a would make the last step of a workflow + // depend on an app that may already have closed. + var command = GetRequiredService(); + + Assert.AreEqual(0, command.Arguments.Count); + Assert.IsFalse( + command.Options.Any(o => o.Name is "--app" or "--window"), + "yield targets no app"); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiCommand.cs index ba92272c2..f39287161 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiCommand.cs @@ -30,7 +30,8 @@ public UiCommand( UiScrollCommand scrollCommand, UiWaitForCommand waitForCommand, UiListWindowsCommand listWindowsCommand, - UiGetFocusedCommand getFocusedCommand) + UiGetFocusedCommand getFocusedCommand, + UiYieldCommand yieldCommand) : base("ui", "Inspect and interact with any running Windows app using UI Automation (UIA). " + "Works with WPF, WinForms, Win32, Electron, and WinUI 3 apps.") { @@ -55,5 +56,6 @@ public UiCommand( Subcommands.Add(waitForCommand); Subcommands.Add(listWindowsCommand); Subcommands.Add(getFocusedCommand); + Subcommands.Add(yieldCommand); } } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs index 3df0e5694..cb1b61b87 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs @@ -173,7 +173,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn await using (await turn.EnterAsync(cancellationToken).ConfigureAwait(false)) { pass = await CaptureUnderSectionAsync( - selector, app, window, json, captureScreen, focus, cancellationToken).ConfigureAwait(false); + selector, app, window, json, captureScreen, focus, + parseResult.InvocationConfiguration.Error, cancellationToken).ConfigureAwait(false); } // Deliberately outside the section: composing, PNG encoding and writing to disk are pure @@ -233,6 +234,15 @@ protected override async Task ExecuteAsync(ParseResult parseResult, IUiTurn /// private readonly record struct CaptureCandidate(nint Hwnd, int ActualPid, int ExpectedAppPid, string Title); + /// + /// The -a fragment to put in a recovery hint, so the suggested command is one the caller + /// can paste. Falls back to the PID when there is no process name to name. + /// + private static string DescribeApp(UiTarget target) + => string.IsNullOrWhiteSpace(target.ProcessName) + ? $" -a {target.ProcessId}" + : $" -a {target.ProcessName}"; + /// Set when the pass already reported a failure and produced no pixels. private sealed record CapturePass( int? ExitCode, @@ -252,6 +262,7 @@ private async Task CaptureUnderSectionAsync( bool json, bool captureScreen, bool focus, + TextWriter errorOut, CancellationToken ct) { // Screenshot handles multi-window discovery itself (avoids duplicate warning from session resolution) @@ -267,7 +278,8 @@ private async Task CaptureUnderSectionAsync( return (long)info.Width * info.Height; }).First(); var multiTarget = await targetResolver.ResolveAsync(null, main.Hwnd, ct).ConfigureAwait(false); - return await CaptureWindowsAsync(allWindows, multiTarget, json, captureScreen, focus, ct).ConfigureAwait(false); + return await CaptureWindowsAsync( + allWindows, multiTarget, json, captureScreen, focus, errorOut, ct).ConfigureAwait(false); } } @@ -285,7 +297,8 @@ private async Task CaptureUnderSectionAsync( if (ownedWindows.Count > 0) { var allWindows = ToCandidates(appWindows, ownedWindows); - return await CaptureWindowsAsync(allWindows, singleTarget, json, captureScreen, focus, ct).ConfigureAwait(false); + return await CaptureWindowsAsync( + allWindows, singleTarget, json, captureScreen, focus, errorOut, ct).ConfigureAwait(false); } } @@ -315,8 +328,31 @@ private async Task CaptureWindowsAsync( bool json, bool captureScreen, bool focus, + TextWriter errorOut, CancellationToken ct) { + if (captureScreen && windows.Count > 1) + { + // Live-screen capture reads pixels off the screen, so it can only ever record the one + // window that is actually in front. Compositing several of them would mean activating + // each in turn: at best a picture stitched from moments where each window covered the + // others, and in practice a foreground_not_target failure the moment Windows refuses the + // second activation. Refuse here, before any window is foregrounded or captured, so the + // caller gets the fix instead of a generic activation error. + var message = + $"--capture-screen needs exactly one window, but {windows.Count} windows matched. " + + "Live-screen capture reads whatever is in front, so several windows cannot be captured together."; + var hint = + $"Run 'winapp ui list-windows{DescribeApp(uiTarget)}' and retry with '-w ' for the window you want, " + + "or drop --capture-screen to composite all of them from their own window contents."; + + logger.LogError("{Symbol} {Message}", UiSymbols.Error, message); + logger.LogError("{Symbol} {Hint}", UiSymbols.Error, hint); + UiJsonError.Emit( + json, UiJsonError.CodeInvalidArguments, message, errorOut: errorOut, recoveryHint: hint); + return new CapturePass(1, uiTarget, null, [], [], IsComposite: true); + } + // Sort: main window first (largest), then others var sorted = windows.OrderByDescending(w => { diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiScrollCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiScrollCommand.cs index bd6bbaed5..9d43c4e3b 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiScrollCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiScrollCommand.cs @@ -73,8 +73,16 @@ public class Handler( protected override string Operation => "ui scroll"; + /// + /// Two different commands share one name. --wheel synthesizes real mouse input at screen + /// coordinates, so it foregrounds the target and needs the desktop exclusively. --direction + /// and --to drive UIA ScrollPattern in the background: they never take + /// active.lock and stay usable on a locked or headless session, but they do move the + /// container, so they wait behind a foreign workflow rather than scrolling content out from under + /// somebody else's click. + /// protected override UiTurnMode ResolveMode(ParseResult parseResult) - => parseResult.GetValue(WheelOption) is not null ? UiTurnMode.DesktopExclusive : UiTurnMode.Observe; + => parseResult.GetValue(WheelOption) is not null ? UiTurnMode.DesktopExclusive : UiTurnMode.TurnShared; protected override int? Preflight(ParseResult parseResult) { diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiScrollIntoViewCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiScrollIntoViewCommand.cs index 8d49b67b8..9ab2648bd 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiScrollIntoViewCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiScrollIntoViewCommand.cs @@ -38,8 +38,13 @@ public class Handler( { protected override string Operation => "ui scroll-into-view"; - /// UIA ScrollItemPattern works in the background and never takes the foreground. - protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.Observe; + /// + /// UIA ScrollItemPattern works in the background and never takes the foreground, so this + /// never takes active.lock and stays usable on a locked or headless session. It does move + /// the container, though, so it waits behind a foreign workflow's turn rather than scrolling + /// content out from under somebody else's click. + /// + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.TurnShared; protected override int? Preflight(ParseResult parseResult) { diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiSetValueCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiSetValueCommand.cs index 9c55f9c9c..378a4bd71 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiSetValueCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiSetValueCommand.cs @@ -43,11 +43,13 @@ public class Handler( protected override string Operation => "ui set-value"; /// - /// Spec §6.1: background-safe UIA mutations stay concurrent even against the same target. This - /// feature prevents desktop interference; it deliberately does not provide transactional - /// app-state isolation. + /// A background-safe mutation: it drives ValuePattern rather than the foreground, so it must + /// stay usable on a locked or headless session and never takes active.lock. But it does + /// change what the app shows, so it waits behind a foreign workflow's turn instead of editing a + /// control underneath somebody else's click. Same-workflow commands still overlap under the + /// ordinary barrier rules. /// - protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.Observe; + protected override UiTurnMode ResolveMode(ParseResult parseResult) => UiTurnMode.TurnShared; protected override int? Preflight(ParseResult parseResult) { diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiYieldCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiYieldCommand.cs new file mode 100644 index 000000000..f25db7156 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiYieldCommand.cs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Invocation; +using System.CommandLine.Parsing; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using WinApp.Cli.Helpers; +using WinApp.Cli.Services.InteractiveDesktop; + +namespace WinApp.Cli.Commands; + +internal class UiYieldCommand : Command, IShortDescription +{ + public string ShortDescription => "Release this workflow's UI turn immediately instead of waiting out the idle grace"; + + public UiYieldCommand() + : base("yield", "Release the current workflow's idle UI turn early. " + + "A workflow with WINAPP_UI_WORKFLOW_ID keeps the desktop for a few seconds after each command so a burst " + + "of commands reads as one workflow; run this after the final command of a workflow to hand the desktop to " + + "waiting workflows straight away. Requires WINAPP_UI_WORKFLOW_ID; targets no app and takes no selector.") + { + Options.Add(WinAppRootCommand.JsonOption); + } + + /// + /// Handler for ui yield. + /// + /// + /// Deliberately not a . Every other ui command asks + /// coordination for a turn; this one gives one back, so registering it as a participant would make + /// it the very live command that stops a turn being idle — it would always find itself busy. + /// + public class Handler( + IAnsiConsole ansiConsole, + IInteractiveDesktopLock desktopLock, + ILogger logger) : AsynchronousCommandLineAction + { + public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + { + var json = parseResult.GetValue(WinAppRootCommand.JsonOption); + + try + { + return Task.FromResult(Report(desktopLock.ReleaseIdleTurn(cancellationToken), json, parseResult)); + } + catch (UiCoordinationException ex) + { + logger.LogError("{Symbol} {Message}", UiSymbols.Error, ex.Message); + if (ex.RecoveryHint is { } hint) + { + logger.LogError("{Symbol} {Hint}", UiSymbols.Error, hint); + } + + UiJsonError.Emit( + json, + ex.Code, + ex.Message, + errorOut: parseResult.InvocationConfiguration.Error, + recoveryHint: ex.RecoveryHint); + return Task.FromResult(1); + } + } + + private int Report(UiYieldResult result, bool json, ParseResult parseResult) + { + switch (result) + { + case UiYieldResult.NotAWorkflow: + // Not invalid_ui_workflow_id: that code means the variable is present but malformed, + // and conflating the two would send someone hunting for a bad value they never set. + logger.LogError( + "{Symbol} Set {Variable} before running 'winapp ui yield' — without it each command is its own one-shot workflow that already releases the desktop when it finishes, so there is no turn to yield.", + UiSymbols.Error, + UiOwnerResolver.WorkflowIdVariable); + UiJsonError.Emit( + json, + UiJsonError.CodeInvalidArguments, + $"'winapp ui yield' requires {UiOwnerResolver.WorkflowIdVariable}. Without it each command is its own one-shot workflow and releases the desktop as soon as it finishes.", + errorOut: parseResult.InvocationConfiguration.Error); + return 1; + + case UiYieldResult.Busy: + throw new UiCoordinationException( + UiCoordinationErrorCodes.TurnBusy, + "This workflow still has a winapp ui command running or waiting, so its turn is not idle and was not released.", + "Wait for this workflow's other winapp ui commands to finish — or stop them, for example a recording started with the same WINAPP_UI_WORKFLOW_ID — then run 'winapp ui yield' again."); + + case UiYieldResult.Released: + EmitReleased(json, released: true); + if (!json) + { + logger.LogInformation("Released the UI turn."); + } + + return 0; + + default: + // Idempotent by design: yielding twice, or after the grace already lapsed, is the + // normal end of a script and must not look like a failure. + EmitReleased(json, released: false); + if (!json) + { + logger.LogInformation("Nothing to release — this workflow does not hold the UI turn."); + } + + return 0; + } + } + + private void EmitReleased(bool json, bool released) + { + if (!json) + { + return; + } + + ansiConsole.Profile.Out.Writer.WriteLine( + JsonSerializer.Serialize( + new UiYieldResultJson { Released = released }, UiJsonContext.Default.UiYieldResultJson)); + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs index 3179318d7..703e1e4fc 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs @@ -133,6 +133,7 @@ public static IServiceCollection ConfigureCommands(this IServiceCollection servi .UseCommandHandler() .UseCommandHandler() .UseCommandHandler() + .UseCommandHandler() .ConfigureCommand(); } diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonContext.cs b/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonContext.cs index c26371ef5..dfb702d14 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonContext.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonContext.cs @@ -34,6 +34,7 @@ namespace WinApp.Cli.Helpers; [JsonSerializable(typeof(UiWaitForResult))] [JsonSerializable(typeof(UiScrollResult))] [JsonSerializable(typeof(UiSetValueResult))] +[JsonSerializable(typeof(UiYieldResultJson))] [JsonSerializable(typeof(UiFocusResult))] [JsonSerializable(typeof(UiScrollIntoViewResult))] [JsonSerializable(typeof(UiHoverResult))] @@ -286,6 +287,19 @@ internal sealed class UiSetValueResult public long Hwnd { get; set; } } +/// +/// Result of ui yield. Named to avoid colliding with UiYieldResult, the coordination +/// outcome it is produced from. +/// +internal sealed class UiYieldResultJson +{ + /// + /// Whether an idle turn was actually ended. is a success too: it means the + /// workflow held nothing, which is the normal result of yielding twice or after the grace lapsed. + /// + public bool Released { get; set; } +} + internal sealed class UiFocusResult { public string ElementId { get; set; } = ""; diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs index 3bf5b851d..48b5037d6 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs @@ -47,6 +47,28 @@ internal interface IUiTurn : IDesktopSection long WaitedMs { get; } } +/// What an explicit early release did. +internal enum UiYieldResult +{ + /// + /// The caller has no explicit WINAPP_UI_WORKFLOW_ID, so it has no turn that outlives a + /// command and nothing it could release. Reported before coordination state is read. + /// + NotAWorkflow, + + /// This workflow's idle turn was ended and any waiting workflow promoted. + Released, + + /// Nothing to release: the desktop was unowned, or another workflow held it. + NothingHeld, + + /// + /// This workflow holds the turn but still has a live command running or queued under it, so the + /// turn is not idle and releasing it would pull the desktop out from under that command. + /// + Busy, +} + /// /// Cooperative desktop turn coordination across concurrent winapp.exe processes (issue #764). /// @@ -74,4 +96,21 @@ Task RunCoordinatedAsync( ParseResult parseResult, Func> body, CancellationToken cancellationToken); + + /// + /// Ends this workflow's post-command idle grace immediately instead of waiting it out. + /// + /// + /// + /// A control operation over a reservation this workflow already holds, not a command that runs on + /// the desktop. It therefore never takes active.lock, opens no participant lease and adds no + /// entry to the state: it takes state.lock, ends the grace, promotes whoever was waiting and + /// wakes them, all in one transaction. + /// + /// + /// It only ever releases the caller's own idle turn. A turn held by another workflow, or one this + /// workflow still has a live command under, is left exactly as it was. + /// + /// + UiYieldResult ReleaseIdleTurn(CancellationToken cancellationToken); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs index 7cda8cf9d..952da6473 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs @@ -118,6 +118,106 @@ public async Task RunCoordinatedAsync( private LivenessProbe CreateProbe() => new LivenessProbe(_participants); + /// + /// Publishes a mutated state and wakes every participant the mutation made runnable. + /// + /// + /// + /// The single place state reaches disk during a transaction that can change who may run, so no + /// transition path — promotion, absorption, barrier release, cancellation cleanup, crash pruning, + /// an explicit yield — has to remember to wake anyone. The set is computed by comparing what the + /// state said was runnable before the mutation with what it says afterwards, which is a property of + /// the state rather than of the code path that produced it. + /// + /// + /// Signalling happens strictly after the publish. A wake-up that arrived first would send its + /// target to read state that has not changed yet, and the target would go back to sleep having + /// consumed the only notification it was going to get. + /// + /// + /// + /// The caller's own participant identity, skipped because waking ourselves would only cost a + /// spurious loop after we already know. Null when the caller is not a participant at all, which is + /// the case for . + /// + private void PublishAndSignal( + InteractiveDesktopState state, + HashSet<(int Pid, long StartTicksUtc)> runnableBefore, + (int Pid, long StartTicksUtc)? self) + { + _store.Publish(state); + + foreach (var target in InteractiveDesktopScheduler.RunnableParticipants(state)) + { + if (target == self || runnableBefore.Contains(target)) + { + continue; + } + + _signals.Signal(target.Pid, target.StartTicksUtc); + } + } + + public UiYieldResult ReleaseIdleTurn(CancellationToken cancellationToken) + { + var owner = _ownerResolver.Resolve(); + if (!owner.HasContinuity) + { + // An anonymous owner releases the desktop the instant its one command ends, so it can never + // be holding an idle turn. Answered before state.lock: reading state to prove a structural + // impossibility would only add contention. + return UiYieldResult.NotAWorkflow; + } + + using var stateLock = _store.AcquireStateLock(cancellationToken); + var read = _store.Read(); + if (read.UnknownNewerVersion) + { + throw new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, + "UI turn coordination state was written by a newer version of winapp, so this build cannot release the turn safely.", + "Update winapp so every process on this desktop uses a compatible version, then retry."); + } + + var state = read.State!; + var runnableBefore = InteractiveDesktopScheduler.RunnableParticipants(state); + + // Normalization alone can already have released this turn — the grace may have lapsed while the + // caller was getting here — and can promote a waiter. Either way the result must be published. + var changed = _scheduler.Normalize(state, CreateProbe()) | read.RecoveredFromCorruption; + + var result = UiYieldResult.NothingHeld; + if (InteractiveDesktopScheduler.IsCurrentOwner(state, owner)) + { + if (state.OwnerCommands.Count > 0) + { + // Normalization just pruned every dead participant, so anything left here is a live + // command of this workflow's own, running or queued behind its barrier. The turn is not + // idle, and ending it would hand the desktop to somebody else mid-command. + result = UiYieldResult.Busy; + } + else + { + _scheduler.ReleaseIdleTurn(state, CreateProbe()); + changed = true; + result = UiYieldResult.Released; + } + } + + if (changed) + { + // Strictly before the return, so `Released` is only ever reported for a release that + // actually reached disk: a failed publish throws out of here rather than telling the + // caller the desktop was handed over when it was not. The `NothingHeld` and `Busy` + // branches reach this too — normalization may have pruned a dead participant, expired + // somebody's grace or promoted a waiter, and that recovery has to be published by + // whoever performed it. + PublishAndSignal(state, runnableBefore, self: null); + } + + return result; + } + /// /// Waits for active.lock. Never steals it from a live process — a hung owner is recovered by /// cancelling or terminating it, not by another process forcing its way onto the desktop (spec §7.3). @@ -230,44 +330,14 @@ private sealed class CoordinatedExecution( public long WaitedMs { get; private set; } /// - /// Publishes a mutated state and wakes every participant the mutation made runnable. + /// Publishes a mutated state and wakes every participant the mutation made runnable, skipping + /// this command itself. /// - /// - /// - /// The single place state reaches disk during a transaction that can change who may run, so no - /// transition path — promotion, absorption, barrier release, cancellation cleanup, crash - /// pruning — has to remember to wake anyone. The set is computed by comparing what the state - /// said was runnable before the mutation with what it says afterwards, which is a property of - /// the state rather than of the code path that produced it. - /// - /// - /// Signalling happens strictly after the publish. A wake-up that arrived first would send its - /// target to read state that has not changed yet, and the target would go back to sleep having - /// consumed the only notification it was going to get. - /// - /// private void PublishAndSignal( InteractiveDesktopState state, HashSet<(int Pid, long StartTicksUtc)> runnableBefore) - { - coordinator._store.Publish(state); - - foreach (var target in InteractiveDesktopScheduler.RunnableParticipants(state)) - { - if (target.Pid == participant.ProcessId && target.StartTicksUtc == participant.StartTicksUtc) - { - // Waking ourselves would only cost us a spurious loop after we already know. - continue; - } - - if (runnableBefore.Contains(target)) - { - continue; - } - - coordinator._signals.Signal(target.Pid, target.StartTicksUtc); - } - } + => coordinator.PublishAndSignal( + state, runnableBefore, (participant.ProcessId, participant.StartTicksUtc)); public async Task RunAsync( Func> body, diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs index 24b708be5..8da6a8adf 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs @@ -215,6 +215,24 @@ public UiAdmissionResult BeginParticipating( QueuePositionOf(state, ticket)); } + /// + /// Ends the current owner's idle grace now, then re-normalizes so the release, the promotion of the + /// next waiter and that waiter's eligibility all land in the same transaction. + /// + /// + /// The caller must have already established that the turn belongs to the yielding workflow and that + /// it has no live commands under it — this deliberately does not re-check, because it is the same + /// primitive applies when the grace runs out on its own, only at a time + /// the owner chose. Expressing it as "the deadline is now" rather than as a second way to clear + /// means an explicit yield and a lapsed grace cannot + /// diverge. + /// + public void ReleaseIdleTurn(InteractiveDesktopState state, ICoordinationLivenessProbe probe) + { + state.IdleExpiresTick64 = clock.NowTicks64; + Normalize(state, probe); + } + /// /// Section 10.6: removes this process's command and sets the idle deadline. A non-cancelled /// completion renews the grace; an anonymous owner gets none and hands off immediately; diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs index c5d8a9f96..4e8f52f71 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs @@ -25,6 +25,12 @@ internal static class UiCoordinationErrorCodes /// The command was cancelled while queued and never reached execution. public const string Cancelled = "cancelled"; + + /// + /// ui yield found the workflow's turn still busy: a command of this same workflow is running + /// or queued under it, so the turn is not idle and there is nothing safe to release. + /// + public const string TurnBusy = "ui_turn_busy"; } /// diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiTurnMode.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiTurnMode.cs index 34b0eba5d..9c374442f 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiTurnMode.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiTurnMode.cs @@ -16,17 +16,25 @@ namespace WinApp.Cli.Services.InteractiveDesktop; internal enum UiTurnMode { /// - /// Does not claim a free turn. A non-owner runs immediately and detached (no lease, no queue entry); - /// the current owner registers so the observation pins and renews that owner's turn while it reads - /// transient UI. + /// Reads the UI without changing it. Does not claim a free turn: a non-owner runs immediately and + /// detached (no lease, no queue entry); the current owner registers so the observation pins and + /// renews that owner's turn while it reads transient UI. /// Observe, /// - /// Claims or waits for the workflow turn. Several same-owner TurnShared commands may overlap - /// unless an earlier forward barrier is waiting or running. Used by - /// ui record, which pins the owner for the whole capture while same-owner input continues. + /// Claims or waits for the workflow turn without taking active.lock. Several same-owner + /// TurnShared commands may overlap unless an earlier forward + /// barrier is waiting or running. /// + /// + /// This is the mode for work that changes the app but not the desktop: ui record, which pins + /// the owner for the whole capture while same-owner input continues, and the background-safe UIA + /// mutations set-value, scroll-into-view and scroll --direction/--to. + /// Because they never take active.lock they remain usable on a locked or headless session, + /// but they still wait behind a foreign workflow's turn rather than editing or scrolling content out + /// from under somebody else's click. + /// TurnShared, /// diff --git a/src/winapp-CLI/WinApp.UIAutomation.TestSupport/FakeUiServices.cs b/src/winapp-CLI/WinApp.UIAutomation.TestSupport/FakeUiServices.cs index bb4998c03..81af2f3a5 100644 --- a/src/winapp-CLI/WinApp.UIAutomation.TestSupport/FakeUiServices.cs +++ b/src/winapp-CLI/WinApp.UIAutomation.TestSupport/FakeUiServices.cs @@ -35,6 +35,17 @@ public class FakeUiAutomationService : IUiAutomation public Dictionary PropertiesResult { get; set; } = []; public string InvokeResult { get; set; } = "InvokePattern"; public (byte[] Pixels, int Width, int Height) ScreenshotResult { get; set; } = (new byte[4], 1, 1); + + /// + /// Every window was asked to capture, in order. + /// + /// + /// Lets a test assert that a command failed before touching the desktop, which an exit + /// code alone cannot show — a command that foregrounded three windows and then gave up also + /// returns 1. + /// + public List<(long Hwnd, bool CaptureScreen, bool Focus)> ScreenshotCalls { get; } = []; + public List<(nint Hwnd, int Pid, string Title)> WindowsByTitleResult { get; set; } = []; public List<(nint Hwnd, int Pid, string Title)> WindowsByPidResult { get; set; } = []; @@ -176,6 +187,7 @@ public Task SearchAsync(UiTarget uiTarget, UiSelector selector, int public Task<(byte[] Pixels, int Width, int Height)> ScreenshotAsync(UiTarget uiTarget, string? elementId, bool captureScreen, bool focus, CancellationToken ct) { + ScreenshotCalls.Add((uiTarget.WindowHandle, captureScreen, focus)); if (ScreenshotThrow is not null) { throw ScreenshotThrow; } return Task.FromResult(ScreenshotResult); } diff --git a/src/winapp-npm/src/winapp-commands.ts b/src/winapp-npm/src/winapp-commands.ts index 2a5d25e91..7ca56aee8 100644 --- a/src/winapp-npm/src/winapp-commands.ts +++ b/src/winapp-npm/src/winapp-commands.ts @@ -1476,6 +1476,24 @@ export async function uiWaitFor(options: UiWaitForOptions = {}): Promise { + const args: string[] = ['ui', 'yield']; + if (options.json) args.push('--json'); + return execCommand(args, options); +} + // --------------------------------------------------------------------------- // unregister // --------------------------------------------------------------------------- diff --git a/src/winapp-npm/test/workflow-id.test.ts b/src/winapp-npm/test/workflow-id.test.ts index 39e59035f..4625a8eb9 100644 --- a/src/winapp-npm/test/workflow-id.test.ts +++ b/src/winapp-npm/test/workflow-id.test.ts @@ -7,7 +7,7 @@ import { EventEmitter } from 'node:events'; import childProcess = require('child_process'); import { WINAPP_UI_WORKFLOW_ID } from '../src/winapp-cli-utils'; -import { uiListWindows } from '../src/winapp-commands'; +import { uiListWindows, uiYield } from '../src/winapp-commands'; // Cooperative desktop turns group commands by workflow id. The wrapper must pass that id to the // spawned child ONLY: writing it into process.env would silently enrol every later call in this @@ -97,3 +97,53 @@ test('well-formed workflow ids — including a real U+FFFD — are still accepte assert.equal(spawnCalls.length, 4, 'every well-formed workflow id must still spawn the CLI'); mock.restoreAll(); }); + +// `ui yield` releases the turn belonging to one specific workflow, so the id it carries decides +// which reservation is ended. A generated wrapper that dropped it — or that reached for the ambient +// process.env instead — would either yield nothing or yield somebody else's turn. + +test('uiYield sends its per-call workflowId to the child and nowhere else', async () => { + const before = process.env[WINAPP_UI_WORKFLOW_ID]; + const childEnvs: (NodeJS.ProcessEnv | undefined)[] = []; + + mock.method(childProcess, 'spawn', ((..._args: unknown[]) => { + const options = _args[2] as { env?: NodeJS.ProcessEnv } | undefined; + childEnvs.push(options?.env); + const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + process.nextTick(() => child.emit('close', 0)); + return child; + }) as unknown as typeof childProcess.spawn); + + await uiYield({ workflowId: 'yield-unit-test-workflow' }).catch(() => undefined); + + assert.equal(childEnvs.length, 1, 'ui yield must spawn the CLI'); + assert.equal( + childEnvs[0]?.[WINAPP_UI_WORKFLOW_ID], + 'yield-unit-test-workflow', + 'the id decides whose turn is released, so it has to reach the child' + ); + assert.equal( + process.env[WINAPP_UI_WORKFLOW_ID], + before, + 'and it must not leak into this process' + ); + mock.restoreAll(); +}); + +test('uiYield refuses an ill-formed workflowId before spawning', async () => { + const spawnCalls: unknown[] = []; + mock.method(childProcess, 'spawn', ((..._args: unknown[]) => { + spawnCalls.push(_args); + const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + process.nextTick(() => child.emit('close', 0)); + return child; + }) as unknown as typeof childProcess.spawn); + + await assert.rejects(() => uiYield({ workflowId: '\uD800' }), /unpaired UTF-16 surrogate/); + assert.equal(spawnCalls.length, 0); + mock.restoreAll(); +}); From 147b885e2a5dadd46fbcb36c8a476cd91b809d0b Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:44:29 -0700 Subject: [PATCH 26/29] Use short-circuit || for the yield transaction's changed flag Normalize is the left operand, so it still always runs; the right side is a plain flag read from state already in hand. The result is identical either way, and the short-circuit form is the one a reader does not have to stop and check. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1f5699f4-3328-49e1-a043-31b2173a5bdf --- .../Services/InteractiveDesktop/InteractiveDesktopLock.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs index 952da6473..0d98f3f94 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs @@ -184,7 +184,9 @@ public UiYieldResult ReleaseIdleTurn(CancellationToken cancellationToken) // Normalization alone can already have released this turn — the grace may have lapsed while the // caller was getting here — and can promote a waiter. Either way the result must be published. - var changed = _scheduler.Normalize(state, CreateProbe()) | read.RecoveredFromCorruption; + // `||` is safe despite Normalize's side effects: it is the LEFT operand, so it always runs. + // The right side is a plain flag read from the state we already have. + var changed = _scheduler.Normalize(state, CreateProbe()) || read.RecoveredFromCorruption; var result = UiYieldResult.NothingHeld; if (InteractiveDesktopScheduler.IsCurrentOwner(state, owner)) From 74cdab86f0c2723601f6942f8245888acf1d15f5 Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:13:24 -0700 Subject: [PATCH 27/29] Do not expand owned windows for an explicit --capture-screen -w target The ambiguity guard added in 2f91a2ca told callers to disambiguate with '-w ', then rejected that very command whenever the window they picked owned a dialog: '-w' still ran owned-window expansion, produced two candidates, and hit the same guard. The documented recovery repeated the error it was supposed to escape. An explicit '-w' with --capture-screen is not ambiguous. It names exactly one live-screen region -- the pixels inside that window's bounds -- and a dialog or overlay sitting on top of it is already in those pixels, which is the reason to read the screen rather than the window. So both expansion paths are skipped for that combination and the command falls through to ordinary single-target validation and one capture. Everything else is unchanged: '-a' discovery that yields several top-level or owned candidates still fails before capture with the list-windows guidance, window-content capture still composites owned dialogs (there they are NOT already in the pixels), and selector capture is untouched. The test that asserted the old behaviour is replaced rather than adjusted -- it had encoded the bug as the contract. Alongside it now: '-a' plus an owned dialog still rejects, and explicit '-w' without --capture-screen still composites. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1f5699f4-3328-49e1-a043-31b2173a5bdf --- docs/ui-automation.md | 11 ++-- .../com.github.copilot/agents/winapp.agent.md | 2 +- .../skills/winapp-ui-automation/SKILL.md | 2 +- .../UiCommandTests.Screenshot.Ambiguity.cs | 59 ++++++++++++++++++- .../Commands/UiScreenshotCommand.cs | 12 +++- 5 files changed, 75 insertions(+), 11 deletions(-) diff --git a/docs/ui-automation.md b/docs/ui-automation.md index f6eca0b10..a78266cd7 100644 --- a/docs/ui-automation.md +++ b/docs/ui-automation.md @@ -109,10 +109,13 @@ captures them all under one exclusive turn, so the saved image is a single consi than a mix of before and after. Encoding and writing the file happen after the desktop is released. > **`--capture-screen` needs exactly one window.** Live-screen capture records whatever is actually -> in front, and only one window can be. If `-a` matches several top-level or owned windows, the -> command fails with **`invalid_arguments`** before capturing anything rather than fighting the -> foreground. Run `winapp ui list-windows -a ` and retry with `-w `, or drop -> `--capture-screen` to composite every window from its own contents. +> in front, and only one window can be. Selecting a window explicitly with `-w ` gives it +> exactly one region — the pixels inside that window's bounds, including any dialog or overlay +> visibly on top of it, which is the reason to read the screen in the first place. When `-a` matches +> several top-level or owned windows there is no such selection, so the command fails with +> **`invalid_arguments`** before capturing anything rather than fighting the foreground. Run +> `winapp ui list-windows -a ` and retry with `-w `, or drop `--capture-screen` to +> composite every window from its own contents. `record` shares its turn, so same-workflow input can interleave with the capture — that is how you record a workflow driving an app. Two caveats: diff --git a/plugins/winapp/com.github.copilot/agents/winapp.agent.md b/plugins/winapp/com.github.copilot/agents/winapp.agent.md index 5e887e4c8..e1a30f843 100644 --- a/plugins/winapp/com.github.copilot/agents/winapp.agent.md +++ b/plugins/winapp/com.github.copilot/agents/winapp.agent.md @@ -310,7 +310,7 @@ Building a WinUI 3 UI and need to find the right control or a working sample? - `ui inspect -a [--depth N] [--interactive] [--hide-disabled] [--hide-offscreen]` — view element tree with semantic slugs and 2-space indentation. `--interactive` filters to invokable elements only (auto-depth 8) — ideal for discovering clickable elements - `ui search -a [--max N]` — find elements; output shows semantic slugs. Surfaces invokable ancestor for all non-invokable results - `ui get-property -a [-p ]` — read UIA properties (including ToggleState, Value, IsSelected, ExpandCollapseState) -- `ui screenshot -a [--output file.png] [--json] [--focus] [--capture-screen]` — capture window as PNG. Default uses Windows.Graphics.Capture (composited surface — preserves rounded corners and works while occluded), with PrintWindow as fallback. Use `--focus` to bring the window to the foreground first; use `--capture-screen` for popup overlays not owned by the target window. **`--capture-screen` needs exactly one window** — it reads whatever is in front, and only one window can be. If `-a` matches several top-level or owned windows it fails with `invalid_arguments` before capturing; run `winapp ui list-windows -a ` and retry with `-w `. If a capture reports `foreground_not_target`, the window could not be brought to the front — do the same thing: list the windows and target one with `-w `. +- `ui screenshot -a [--output file.png] [--json] [--focus] [--capture-screen]` — capture window as PNG. Default uses Windows.Graphics.Capture (composited surface — preserves rounded corners and works while occluded), with PrintWindow as fallback. Use `--focus` to bring the window to the foreground first; use `--capture-screen` for popup overlays not owned by the target window. **`--capture-screen` needs exactly one window** — it reads whatever is in front, and only one window can be. `-w ` selects one: that window's screen region, including any dialog or overlay visibly on top of it. If `-a` matches several top-level or owned windows there is no such selection and it fails with `invalid_arguments` before capturing; run `winapp ui list-windows -a ` and retry with `-w `. If a capture reports `foreground_not_target`, the window could not be brought to the front — do the same thing: list the windows and target one with `-w `. - `ui record -a [--output file.mp4] [--duration-sec ] [--fps ] [--max-edge ] [--frames] [--capture-screen] [--json]` — record window or element region to an H.264 MP4 using Windows Graphics Capture + Media Foundation. Default is 0 — records until stopped (Ctrl+C interactively, or a newline/EOF on stdin for programmatic callers); use `--duration-sec N` for a timed run. Add `--frames` to retain timestamped JPEGs, `frames.ndjson`, and `manifest.json` under `.frames`. JSON results include `elapsedMs`, `achievedFps`, `cadenceRatio`, `stopReason`, optional `frameArtifacts`, and the capture `mode` (`"wgc"`, `"screen"`, or `"printwindow"`). - `ui invoke -a ` — activate element by slug or text search. Auto-walks to invokable ancestor for non-invokable elements. - `ui hover -a [--dwell-time ]` — move mouse to element center to trigger tooltips, flyouts, and hover states. Use with `ui screenshot --capture-screen` to capture the result. diff --git a/plugins/winapp/skills/winapp-ui-automation/SKILL.md b/plugins/winapp/skills/winapp-ui-automation/SKILL.md index 1cc085a24..d40fdc127 100644 --- a/plugins/winapp/skills/winapp-ui-automation/SKILL.md +++ b/plugins/winapp/skills/winapp-ui-automation/SKILL.md @@ -13,7 +13,7 @@ description: Inspect and interact with running Windows app UIs from the command - For UIA mode (any app): No setup needed — works with any running Windows app - For input-injecting verbs (`click`, `hover`, `drag`, `touch`, `pen`, `scroll --wheel`, `send-keys --via send-input`): an **unlocked, interactive desktop** with the target window foregroundable. On a locked/secure desktop they fail fast with `no_interactive_desktop`. The UIA-pattern verbs (`inspect`, `search`, `get-*`, `wait-for`, `set-value`, `invoke`, `scroll --direction/--to`) are headless/locked-session friendly — prefer them in CI. - `screenshot` is **not** in that group: it always takes an exclusive turn, so it queues behind other UI workflows, and capture can need a usable interactive desktop — the engine restores the target if it is minimized, and falls back to foregrounding it when frame capture is unavailable or `--capture-screen` is used. -- `--capture-screen` needs **exactly one window**. If `-a` matches several top-level or owned windows the command fails with `invalid_arguments` before capturing; run `winapp ui list-windows -a ` and retry with `-w `. +- `--capture-screen` needs **exactly one window**. `-w ` gives it one: that window's screen region, including anything visibly on top of it. If `-a` matches several top-level or owned windows the command fails with `invalid_arguments` before capturing; run `winapp ui list-windows -a ` and retry with `-w `. - **If other UI workflows may run at the same time**, set one workflow id per logical workflow (see below). Nothing breaks without it, but your commands will not be recognized as belonging together. ## Coordinating with other UI workflows diff --git a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.Ambiguity.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.Ambiguity.cs index 6960d4456..ee5d49151 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.Ambiguity.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.Ambiguity.cs @@ -107,18 +107,71 @@ public async Task Screenshot_SeveralWindowsWithoutCaptureScreen_StillComposites( } [TestMethod] - public async Task Screenshot_CaptureScreenWithAnOwnedDialog_IsAlsoRejected() + public async Task Screenshot_CaptureScreenForOneExplicitWindowThatOwnsADialog_StillCaptures() { - // The other way a capture becomes multi-window: one app window plus a dialog it owns. Same - // ambiguity, same guard — the app window and its file picker cannot both be in front. + // The regression this guard nearly shipped. `-w ` is what the ambiguity error tells you + // to do, so it must not land back in the same error when the window you picked happens to own + // a dialog. Live-screen capture of a chosen window is one region, and a dialog sitting on top + // of it is already in those pixels — that is the reason to use --capture-screen at all. ArrangeOwnedDialog(ownerOfDialog: AppWindow); + // The real resolver returns the window it was asked for; the fake needs telling, and this + // test is specifically about which HWND the single capture targets. + _fakeTargetResolver.TargetResult = new UiTarget + { + ProcessId = AppPid, + ProcessName = "TestApp", + WindowTitle = "Main Window", + WindowHandle = AppWindow, + }; var command = GetRequiredService(); var exitCode = await ParseAndInvokeWithCaptureAsync( command, ["-w", AppWindow.ToString(), "--capture-screen", "--json", "-o", ShotPath()]); + Assert.AreEqual(0, exitCode); + Assert.AreEqual(1, _fakeUia.ScreenshotCalls.Count, "exactly one region is captured"); + Assert.AreEqual((long)AppWindow, _fakeUia.ScreenshotCalls[0].Hwnd, + "and it is the window the caller explicitly selected, not the dialog"); + Assert.IsTrue(_fakeUia.ScreenshotCalls[0].CaptureScreen); + } + + [TestMethod] + public async Task Screenshot_CaptureScreenWithAnAppOwnedDialog_IsStillRejected() + { + // Discovery from `-a` can produce an app window plus a dialog it owns, and there the caller + // has not chosen between them — same ambiguity as several top-level windows, same guard. + _fakeSystemQuery.ProcessIdForWindowResult = MultiWindowPid; + _fakeSystemQuery.ProcessIdByHwnd[FirstWindow] = MultiWindowPid; + _fakeSystemQuery.ProcessIdByHwnd[OwnedDialog] = SystemHostPid; + _fakeSystemQuery.WindowOwnerByHwnd[OwnedDialog] = FirstWindow; + _fakeSystemQuery.WindowTextResult = "Main Window"; + _fakeUia.WindowsByPidResult = [(FirstWindow, MultiWindowPid, "Main Window")]; + _fakeWindowFinder.OwnedWindowsResult = [(OwnedDialog, SystemHostPid, "Save As")]; + _fakeUia.ScreenshotResult = (new byte[4], 1, 1); + + var command = GetRequiredService(); + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-a", App, "--capture-screen", "--json", "-o", ShotPath()]); + Assert.AreEqual(1, exitCode); AssertJsonErrorCode("invalid_arguments"); Assert.AreEqual(0, _fakeUia.ScreenshotCalls.Count); + StringAssert.Contains(ConsoleStdErr.ToString(), "-w "); + } + + [TestMethod] + public async Task Screenshot_ExplicitWindowThatOwnsADialog_StillCompositesWithoutCaptureScreen() + { + // Window-content capture reads each window's own pixels, so a dialog is not already included + // and compositing it is the point. Only the live-screen path narrows to one region. + ArrangeOwnedDialog(ownerOfDialog: AppWindow); + var command = GetRequiredService(); + + var exitCode = await ParseAndInvokeWithCaptureAsync( + command, ["-w", AppWindow.ToString(), "--json", "-o", ShotPath()]); + + Assert.AreEqual(0, exitCode); + Assert.AreEqual(2, _fakeUia.ScreenshotCalls.Count, + "the owned dialog is still composited when not capturing the screen"); } } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs index cb1b61b87..5aae841ac 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiScreenshotCommand.cs @@ -265,8 +265,16 @@ private async Task CaptureUnderSectionAsync( TextWriter errorOut, CancellationToken ct) { + // A live-screen capture of an EXPLICITLY chosen window is exactly one region: the pixels + // inside that window's bounds. Anything visibly on top of it — including a dialog it owns — + // is already in those pixels, which is the whole reason to reach for --capture-screen. So + // the multi-window expansion below is skipped: running it would send `-w ` into the + // ambiguity guard, and `-w ` is the documented way OUT of that ambiguity. Without a + // window the app may still resolve to several candidates, and that stays an error. + var expandsToSeveralWindows = selector is null && !(captureScreen && window is not null and > 0); + // Screenshot handles multi-window discovery itself (avoids duplicate warning from session resolution) - if (selector is null) + if (expandsToSeveralWindows) { var allWindows = DiscoverAllWindows(app, window); if (allWindows is not null && allWindows.Count > 1) @@ -286,7 +294,7 @@ private async Task CaptureUnderSectionAsync( var singleTarget = await targetResolver.ResolveAsync(app, window, ct).ConfigureAwait(false); // Even for a single-window session, check for owned dialogs. - if (selector is null) + if (expandsToSeveralWindows) { var targetWindowHwnd = (nint)singleTarget.WindowHandle; var appWindows = new List<(nint Hwnd, int Pid, string Title)> From 45c9dd1cefc63261ad34d02adc029c140eb33f58 Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:20:23 -0700 Subject: [PATCH 28/29] Separate capture foreground safety from injection, and secure pre-existing state Three findings from review, all reproduced before being fixed. A capture-only foreground predicate ----------------------------------- `ForegroundBelongsTo` accepts the target window or its `GA_ROOT`. A top-level modal dialog is its own root, so a dialog the target owns could never satisfy it. That is correct for input -- keystrokes would land in the dialog -- but wrong for live-screen capture, where the dialog is part of the app's UI and is sitting on the very pixels being read. It is the reason to capture the screen rather than the window. The effect was that `--capture-screen -w ` failed with `foreground_not_target` for the most ordinary reason a window is not foreground, which is precisely the recovery the previous commit tells callers to use. `ForegroundIsCapturableFor` additionally accepts a foreground window whose `GW_OWNER` chain reaches the target, within a bound of eight hops so a corrupt or cyclic chain cannot spin. It remains a real check: an unrelated window has no owner path to the target, so a capture can still never return somebody else's window labelled as yours. Injection keeps the strict predicate; every remaining `ForegroundBelongsTo` caller is an injection path. Screen recording had the same defect, in two places rather than one. The reported site was the check after the activation delay; a test found the second, the pre-encoder re-verification after selector resolution, which would have rejected the same dialog a moment later. Both use the capture predicate now, and a refusal still writes no artifact. This adds a public member. `InternalsVisibleTo` was tried first and does not work here -- both assemblies run the CsWin32 generator, so sharing internals makes `Windows.Win32.PInvoke` ambiguous (CS0436, fatal under Release warnings-as-errors), a constraint `PublicApiSurfaceTests` already documents. The alternative was a second copy of a safety predicate in the Recording package, which is how these two paths came to disagree in the first place. It is a sibling on an already-public guard class, adds no type, and is documented in `WinApp.UIAutomation/PACKAGE.md`. No coordination type or concept is exposed. Coordination state that predates its directory being secured ------------------------------------------------------------ Repairing a directory's DACL does not repair what is already inside it: Windows keeps an explicit ACE on a child file when a protected DACL is written to its parent, because inheritance changes propagate only inherited ACEs. A `state.json` or `active.lock` seeded with an `Everyone` grant therefore stayed writable by whoever seeded it, and coordination went on trusting it -- defeating the current-user isolation this hardening exists to provide. Two changes, covering the two ways it can arise: - When a directory has to be repaired, its coordination artifacts are discarded, because they were written under somebody else's permissions. Anything that will not go fails the command closed: a process holding a handle in a namespace just proven untrusted is not liveness evidence worth acting on. Scoped to this feature's own file-name shapes, so an override directory holding a user's unrelated files is left alone. - Independently, the three files whose content or lock state is trusted -- the state document, the transaction lock, the desktop lock -- are checked on every path, including when the directory was already secure. That covers a directory secured by an earlier build that did not clear it. Deliberately three files and not a sweep: three ACL reads once per process, against roughly five milliseconds measured for a full pass over a deep participants directory. Leases are left to the repair-time purge and to the participants directory's own DACL, because a lease is inert on its own -- liveness is only consulted for participants named in the state document, so a seeded lease for a process appearing nowhere in state is never opened, and its one reachable effect is to make corruption recovery refuse to reset, which fails closed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1f5699f4-3328-49e1-a043-31b2173a5bdf --- .../InteractiveDesktopPathsHardeningTests.cs | 277 ++++++++++++++++++ .../InteractiveDesktopPaths.cs | 194 +++++++++++- .../UiRecordingService.cs | 12 +- .../CaptureForegroundOwnerChainTests.cs | 175 +++++++++++ .../CaptureForegroundSafetyTests.cs | 108 +++++++ .../Input/ForegroundGuard.cs | 113 ++++++- src/winapp-CLI/WinApp.UIAutomation/PACKAGE.md | 9 + .../UiAutomationService.Screenshot.cs | 7 +- 8 files changed, 884 insertions(+), 11 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopPathsHardeningTests.cs create mode 100644 src/winapp-CLI/WinApp.UIAutomation.Tests/CaptureForegroundOwnerChainTests.cs diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopPathsHardeningTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopPathsHardeningTests.cs new file mode 100644 index 000000000..3ce6eda09 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopPathsHardeningTests.cs @@ -0,0 +1,277 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Security.AccessControl; +using System.Security.Principal; +using WinApp.Cli.Services.InteractiveDesktop; + +namespace WinApp.Cli.Tests; + +/// +/// What happens to coordination artifacts that were already sitting in a directory when its +/// permissions had to be repaired. +/// +/// +/// Repairing the directory does not repair its contents. Windows keeps an explicit ACE on a child +/// file when a protected DACL is written to its parent — inheritance changes propagate only inherited +/// ACEs — so a file placed there beforehand stays writable by whoever placed it. Coordination would +/// then go on reading and trusting state another user can still rewrite, which is exactly the +/// isolation this hardening exists to provide. +/// +[TestClass] +[DoNotParallelize] // WINAPP_UI_LOCK_DIRECTORY is process-wide. +public class InteractiveDesktopPathsHardeningTests +{ + private string _root = null!; + private string? _previousOverride; + + [TestInitialize] + public void Setup() + { + _root = Path.Combine(Path.GetTempPath(), $"winapp-acl-{Guid.NewGuid():N}"); + _previousOverride = Environment.GetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable); + Environment.SetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable, _root); + } + + [TestCleanup] + public void Cleanup() + { + Environment.SetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable, _previousOverride); + + try + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // A leaked temp directory must never fail a test. + } + } + + /// Writes a file and grants Everyone full control on it, as a pre-seeder would. + private static string SeedWorldWritable(string directory, string fileName) + { + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, fileName); + File.WriteAllText(path, "{\"version\":1,\"owner\":\"attacker\"}"); + + var file = new FileInfo(path); + var acl = file.GetAccessControl(); + acl.AddAccessRule(new FileSystemAccessRule( + new SecurityIdentifier(WellKnownSidType.WorldSid, null), + FileSystemRights.FullControl, + AccessControlType.Allow)); + file.SetAccessControl(acl); + + Assert.IsTrue(HasForeignGrant(path), "arrange failed: the Everyone ACE was not applied"); + return path; + } + + private static bool HasForeignGrant(string path) + { + var currentUser = WindowsIdentity.GetCurrent().User!; + var rules = new FileInfo(path).GetAccessControl() + .GetAccessRules(includeExplicit: true, includeInherited: false, typeof(SecurityIdentifier)); + return rules.Cast() + .Any(r => r.IdentityReference is SecurityIdentifier sid && sid != currentUser); + } + + [TestMethod] + public void APreSeededStateFileWithAnEveryoneGrantIsDiscarded() + { + // The reported repro. The directory's DACL is inherited, so it will be repaired — and the + // explicit Everyone ACE on this file would survive that repair untouched. + var seeded = SeedWorldWritable(_root, "interactive-desktop-1.state.json"); + + new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories(); + + Assert.IsFalse(File.Exists(seeded), + "a world-writable state file that predates the repair cannot be trusted or kept"); + } + + [TestMethod] + public void APreSeededActiveLockWithAnEveryoneGrantIsDiscarded() + { + // The other half of the repro: holding active.lock would let another user block this desktop. + var seeded = SeedWorldWritable(_root, "interactive-desktop-1.active.lock"); + + new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories(); + + Assert.IsFalse(File.Exists(seeded)); + } + + [TestMethod] + public void APreSeededLeaseIsDiscarded() + { + // A lease is liveness evidence. One written by somebody else is a claim about a participant + // that never existed, and it would keep a phantom queue entry unprunable. + var participants = Path.Combine(_root, "participants"); + var seeded = SeedWorldWritable(participants, "interactive-desktop-1-4242-638000000000000000.lease"); + + new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories(); + + Assert.IsFalse(File.Exists(seeded)); + } + + [TestMethod] + public void AQuarantinedCorruptStateCopyIsAlsoDiscarded() + { + var seeded = SeedWorldWritable(_root, "state.corrupt-20260101T000000.000Z.json"); + + new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories(); + + Assert.IsFalse(File.Exists(seeded)); + } + + [TestMethod] + public void AnArtifactThatSurvivesTheRepairFailsClosed() + { + // Held open, so it cannot be deleted. A process keeping a handle in a namespace that was just + // proven untrusted is not liveness evidence worth acting on, so this must refuse to run rather + // than coordinate through storage a third party can still write. + Directory.CreateDirectory(_root); + var held = Path.Combine(_root, "interactive-desktop-1.state.json"); + using var handle = new FileStream(held, FileMode.Create, FileAccess.ReadWrite, FileShare.None); + + var paths = new InteractiveDesktopPaths(new ProcessInspector()); + var ex = Assert.ThrowsExactly(() => paths.EnsureDirectories()); + + Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code); + StringAssert.Contains(ex.Message, "could not be removed"); + Assert.IsNotNull(ex.RecoveryHint, "a fail-closed error has to say what to do next"); + } + + [TestMethod] + public void UnrelatedFilesInAnOverrideDirectoryAreLeftAlone() + { + // WINAPP_UI_LOCK_DIRECTORY can point at a path holding somebody's own files. Those are never + // read by coordination, so they are not a trust question — and deleting them would be + // destroying data that was not ours to touch. + Directory.CreateDirectory(_root); + var unrelated = Path.Combine(_root, "my-notes.txt"); + File.WriteAllText(unrelated, "keep me"); + + new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories(); + + Assert.IsTrue(File.Exists(unrelated), "only this feature's own artifacts may be discarded"); + Assert.AreEqual("keep me", File.ReadAllText(unrelated)); + } + + [TestMethod] + public void AnInsecureStateFileUnderAnAlreadySecuredDirectoryIsAlsoDiscarded() + { + // The case that is NOT covered by the directory-repair path. Here the directory is already + // current-user-only, so the repair fast path returns early — but a state file inside it still + // carries a foreign grant, exactly as it would if an earlier build had secured the directory + // without clearing its contents. + var paths = new InteractiveDesktopPaths(new ProcessInspector()); + paths.EnsureDirectories(); + Assert.IsTrue( + InteractiveDesktopPaths.IsCurrentUserOnly( + new DirectoryInfo(paths.LockDirectory).GetAccessControl(), WindowsIdentity.GetCurrent().User!), + "arrange failed: the directory should already be secured"); + + File.WriteAllText(paths.StatePath, "{\"version\":1,\"owner\":\"attacker\"}"); + var file = new FileInfo(paths.StatePath); + var acl = file.GetAccessControl(); + acl.AddAccessRule(new FileSystemAccessRule( + new SecurityIdentifier(WellKnownSidType.WorldSid, null), + FileSystemRights.FullControl, + AccessControlType.Allow)); + file.SetAccessControl(acl); + + // A fresh instance repeats the whole check; the directory needs no repair this time. + new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories(); + + Assert.IsFalse(File.Exists(paths.StatePath), + "a world-writable state file must be discarded even when its directory is already secure"); + } + + [TestMethod] + public void AnInsecureActiveLockUnderAnAlreadySecuredDirectoryIsAlsoDiscarded() + { + var paths = new InteractiveDesktopPaths(new ProcessInspector()); + paths.EnsureDirectories(); + + File.WriteAllText(paths.ActiveLockPath, "x"); + var file = new FileInfo(paths.ActiveLockPath); + var acl = file.GetAccessControl(); + acl.AddAccessRule(new FileSystemAccessRule( + new SecurityIdentifier(WellKnownSidType.WorldSid, null), + FileSystemRights.FullControl, + AccessControlType.Allow)); + file.SetAccessControl(acl); + + new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories(); + + Assert.IsFalse(File.Exists(paths.ActiveLockPath), + "another user able to hold active.lock would be able to block this desktop"); + } + + [TestMethod] + public void AnInsecureStateFileThatCannotBeReplacedFailsClosed() + { + var paths = new InteractiveDesktopPaths(new ProcessInspector()); + paths.EnsureDirectories(); + + File.WriteAllText(paths.StatePath, "{}"); + var file = new FileInfo(paths.StatePath); + var acl = file.GetAccessControl(); + acl.AddAccessRule(new FileSystemAccessRule( + new SecurityIdentifier(WellKnownSidType.WorldSid, null), + FileSystemRights.FullControl, + AccessControlType.Allow)); + file.SetAccessControl(acl); + + using var held = new FileStream(paths.StatePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + var ex = Assert.ThrowsExactly( + () => new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories()); + + Assert.AreEqual(UiCoordinationErrorCodes.Unavailable, ex.Code); + StringAssert.Contains(ex.Message, "reachable by another user"); + } + + [TestMethod] + public void AnAlreadySecuredDirectoryKeepsItsContents() + { + // The steady state, and the case that must not become destructive: a second winapp process + // joining a directory this user already secured must not discard the first process's state. + // This is also the every-command path, so it must stay free of per-file work beyond the three + // trusted files. + var paths = new InteractiveDesktopPaths(new ProcessInspector()); + paths.EnsureDirectories(); + + File.WriteAllText(paths.StatePath, "{\"version\":1}"); + + new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories(); + + Assert.IsTrue(File.Exists(paths.StatePath), + "a state file this user owns, inheriting from a secured directory, is trustworthy"); + Assert.AreEqual("{\"version\":1}", File.ReadAllText(paths.StatePath)); + } + + [TestMethod] + public void RepairLeavesBothDirectoriesReachableOnlyByThisUser() + { + Directory.CreateDirectory(_root); + + var paths = new InteractiveDesktopPaths(new ProcessInspector()); + paths.EnsureDirectories(); + + var currentUser = WindowsIdentity.GetCurrent().User!; + Assert.IsTrue( + InteractiveDesktopPaths.IsCurrentUserOnly( + new DirectoryInfo(paths.LockDirectory).GetAccessControl(), currentUser)); + Assert.IsTrue( + InteractiveDesktopPaths.IsCurrentUserOnly( + new DirectoryInfo(paths.ParticipantsDirectory).GetAccessControl(), currentUser), + "the participants directory holds leases and needs the same protection"); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs index cd4101f7c..e03235150 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs @@ -139,9 +139,84 @@ public void EnsureDirectories() EnsureRestrictedDirectory(LockDirectory); EnsureRestrictedDirectory(ParticipantsDirectory); + EnsureTrustedStateFiles(); _directoriesVerified = true; } + /// + /// Discards any of this session's state or lock files that another identity can still reach. + /// + /// + /// + /// Runs on both paths — a directory that had to be repaired and one that was already + /// current-user-only — because securing a directory does not secure what is already inside it. An + /// explicit ACE on a child survives a protected DACL being written to its parent, so a file + /// seeded before the directory was locked down stays writable by whoever seeded it. That is true + /// however the directory came to be secure, including by a build of this feature that predates + /// this check. + /// + /// + /// Scoped to these three files, which is what keeps it off the latency budget: three ACL reads + /// once per process, against roughly five milliseconds for a full sweep of a deep participants + /// directory. They are the ones whose content or lock state is trusted — the scheduler + /// document, the transaction lock, and the desktop lock — so a foreign grant on any of them lets + /// another user mint an owner, block every transaction, or hold the desktop. + /// + /// + /// Leases are deliberately not swept here. A lease is inert on its own: liveness is only consulted + /// for participants named in the state document, so a seeded lease for a process that appears + /// nowhere in state is never opened. Its one reachable effect is to make corruption recovery + /// refuse to reset state, which fails closed rather than open. Leases seeded before a repair are + /// still discarded by , and the participants directory's own + /// DACL stops new ones appearing. + /// + /// + private void EnsureTrustedStateFiles() + { + var currentUser = WindowsIdentity.GetCurrent().User; + if (currentUser is null) + { + return; + } + + foreach (var path in new[] { StatePath, StateLockPath, ActiveLockPath }) + { + var file = new FileInfo(path); + if (!file.Exists) + { + continue; + } + + try + { + if (IsCurrentUserOnly(file.GetAccessControl(), currentUser, requireProtected: false)) + { + continue; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Its permissions cannot even be read, which is not a file to trust either. + throw UntrustedArtifact(path, ex.Message); + } + + try + { + file.Delete(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw UntrustedArtifact(path, ex.Message); + } + } + } + + private static UiCoordinationException UntrustedArtifact(string path, string reason) + => new( + UiCoordinationErrorCodes.Unavailable, + $"The UI coordination file '{path}' is reachable by another user and could not be replaced: {reason}", + "Close any winapp process using this directory and delete its contents, or point WINAPP_UI_LOCK_DIRECTORY at a directory this user owns."); + private static string ResolveLockDirectory() { var overridePath = Environment.GetEnvironmentVariable(LockDirectoryOverrideVariable); @@ -231,6 +306,28 @@ private static void EnsureRestrictedDirectory(string path) /// Re-applies the current-user-only DACL when the existing one is inherited or grants any other /// identity. A no-op in the overwhelmingly common case, so the per-process check stays cheap. /// + /// + /// + /// Together with this establishes the invariant the rest + /// of coordination relies on: after this returns, no coordination artifact in the directory is + /// reachable by another user. + /// + /// + /// The steady-state case is covered by that invariant rather than by re-checking every file on + /// every command. A foreign-owned or foreign-granted child cannot appear in a directory that is + /// already current-user-only: creating a file there needs write access to the directory, which + /// only this user has, and re-permissioning an existing file needs WRITE_DAC on it, which + /// only its owner — this user — has. So the only way such a child exists is that it predates the + /// directory being secured, and that is exactly the moment this method hands to + /// , which removes it or fails closed. By induction every + /// directory that reaches the "already secure" fast path was made secure by a pass that had + /// already cleared it, or was created empty by us. + /// + /// + /// That is what keeps the normal path free: one DACL read per directory per process, and no + /// per-file work at all. + /// + /// private static void RepairAccessRulesIfNeeded(DirectoryInfo directoryInfo) { var currentUser = WindowsIdentity.GetCurrent().User; @@ -263,6 +360,15 @@ private static void RepairAccessRulesIfNeeded(DirectoryInfo directoryInfo) $"The UI coordination directory '{directoryInfo.FullName}' is still owned or reachable by another user after repair.", "Point WINAPP_UI_LOCK_DIRECTORY at a directory this user owns, or remove the override to use the default location under %LOCALAPPDATA%."); } + + // Repairing the directory does NOT repair what was already inside it. An explicit ACE on a + // child file survives a protected DACL being written to its parent — inheritance changes + // only propagate inherited ACEs — so a file placed here before the repair stays writable by + // whoever placed it, and coordination would keep reading and trusting it. + // + // Only reached when the directory actually needed repair, which for the default location + // under %LOCALAPPDATA% is the first run and never again. + DiscardUntrustedArtifacts(directoryInfo); } catch (Exception ex) when (ex is UnauthorizedAccessException or PrivilegeNotHeldException or InvalidOperationException) { @@ -276,16 +382,98 @@ private static void RepairAccessRulesIfNeeded(DirectoryInfo directoryInfo) } /// - /// Whether describes a directory only the current user can reach or + /// Deletes the coordination artifacts already inside a directory whose permissions were just + /// repaired, and refuses to continue if any of them survive. + /// + /// + /// + /// Called only after a repair, because only then can the directory have held files written under + /// somebody else's permissions. A file keeps its own explicit ACEs when its parent's DACL is + /// replaced — inheritance changes propagate only inherited ACEs — so "the directory is now + /// current-user-only" says nothing about what is already in it. An Everyone grant placed on + /// a pre-seeded state.json or active.lock survives, and coordination would go on + /// reading and trusting a file another user can still rewrite. + /// + /// + /// Everything here is reconstructible — state is rebuilt fresh, locks are just handles, leases are + /// DeleteOnClose — so discarding is safe. A file that will not go is a different + /// matter: it is held open by a process in a namespace that was just proven untrusted, which is + /// not liveness evidence worth acting on. That fails closed rather than being accepted, because + /// coordinating through storage a third party can tamper with is worse than not running. + /// + /// + /// Scoped to this feature's own file-name shapes and to the top level of the directory. A + /// WINAPP_UI_LOCK_DIRECTORY override may point at a path holding unrelated files; those are + /// never read, so they are not a trust question, and deleting them would be destroying data that + /// was not ours to touch. + /// + /// + private static void DiscardUntrustedArtifacts(DirectoryInfo directoryInfo) + { + foreach (var pattern in s_coordinationArtifactPatterns) + { + FileInfo[] artifacts; + try + { + artifacts = directoryInfo.GetFiles(pattern, SearchOption.TopDirectoryOnly); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, + $"The UI coordination directory '{directoryInfo.FullName}' could not be inspected after its permissions were repaired: {ex.Message}", + "Point WINAPP_UI_LOCK_DIRECTORY at a directory this user owns, or remove the override to use the default location under %LOCALAPPDATA%."); + } + + foreach (var artifact in artifacts) + { + try + { + artifact.Delete(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new UiCoordinationException( + UiCoordinationErrorCodes.Unavailable, + $"The UI coordination file '{artifact.FullName}' predates this directory being secured and could not be removed: {ex.Message}", + "Close any winapp process using this directory and delete its contents, or point WINAPP_UI_LOCK_DIRECTORY at a directory this user owns."); + } + } + } + } + + /// + /// File-name shapes this feature writes: the per-session state and lock files, their publish + /// temporaries and quarantined copies, and participant leases. Everything else in the directory + /// belongs to somebody else and is left alone. + /// + private static readonly string[] s_coordinationArtifactPatterns = + [ + $"{FilePrefix}*", + "state.corrupt-*", + ]; + + /// + /// Whether describes an object only the current user can reach or /// re-permission. /// /// + /// /// Owner is checked as well as the DACL because the owner of an object implicitly holds /// WRITE_DAC: a foreign owner can rewrite even a protected, current-user-only DACL and grant /// itself access at any time. That matters most for a WINAPP_UI_LOCK_DIRECTORY override under /// a shared path, where another user may have created the directory first. + /// + /// + /// is what differs between a directory and a file inside it. A + /// directory has to reject inherited rules outright, because it may hang under a shared parent that + /// grants other users. A file inside an already-verified directory legitimately inherits from it, + /// so for files the question is only whether any rule — inherited or explicit — names somebody + /// else, which the loop below answers either way. + /// /// - internal static bool IsCurrentUserOnly(DirectorySecurity security, SecurityIdentifier currentUser) + internal static bool IsCurrentUserOnly( + FileSystemSecurity security, SecurityIdentifier currentUser, bool requireProtected = true) { if (security.GetOwner(typeof(SecurityIdentifier)) is not SecurityIdentifier owner || owner != currentUser) @@ -293,7 +481,7 @@ internal static bool IsCurrentUserOnly(DirectorySecurity security, SecurityIdent return false; } - if (!security.AreAccessRulesProtected) + if (requireProtected && !security.AreAccessRulesProtected) { // Inherited rules can grant anyone the parent grants, which for a shared override directory // includes other users. diff --git a/src/winapp-CLI/WinApp.UIAutomation.Recording/UiRecordingService.cs b/src/winapp-CLI/WinApp.UIAutomation.Recording/UiRecordingService.cs index 4d44b4548..9acce7b08 100644 --- a/src/winapp-CLI/WinApp.UIAutomation.Recording/UiRecordingService.cs +++ b/src/winapp-CLI/WinApp.UIAutomation.Recording/UiRecordingService.cs @@ -101,7 +101,12 @@ public async Task RecordAsync(UiTarget uiTarget, string? el // whichever window is really in front and the caller would get a perfectly playable MP4 of // the wrong app. Verify after the activation delay and before any frame is captured. Capture // safety, not coordination: it says nothing about who else may be driving the desktop. - if (!ForegroundGuard.ForegroundBelongsTo((long)rootHwnd)) + // + // The capture predicate, not the injection one: a modal dialog the target owns is part of + // its UI and is sitting on the pixels being recorded, which is the reason to record the + // screen rather than the window. An unrelated foreground window is still refused, and a + // refusal still produces no artifact. + if (!ForegroundGuard.ForegroundIsCapturableFor((long)rootHwnd)) { throw new ForegroundLostException( "The target window is not in the foreground, so a screen recording would capture " + @@ -291,7 +296,10 @@ public async Task RecordAsync(UiTarget uiTarget, string? el // It deliberately sits above the encoder rather than next to the first frame read: creating // the encoder creates OutputPath, so refusing after that point would leave an empty MP4 and // break the "no artifact on refusal" contract the CLI states for foreground_not_target. - if (options.CaptureScreen && !ForegroundGuard.ForegroundBelongsTo((long)rootHwnd)) + // + // Same capture predicate as the first check — the two must agree, or a modal dialog the + // target owns would pass one and fail the other. + if (options.CaptureScreen && !ForegroundGuard.ForegroundIsCapturableFor((long)rootHwnd)) { throw new ForegroundLostException( "The target window lost the foreground while the recording was being prepared, so a " + diff --git a/src/winapp-CLI/WinApp.UIAutomation.Tests/CaptureForegroundOwnerChainTests.cs b/src/winapp-CLI/WinApp.UIAutomation.Tests/CaptureForegroundOwnerChainTests.cs new file mode 100644 index 000000000..086a12d6b --- /dev/null +++ b/src/winapp-CLI/WinApp.UIAutomation.Tests/CaptureForegroundOwnerChainTests.cs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Windows.Win32.Foundation; + +namespace Microsoft.Windows.SDK.BuildTools.WinApp.UIAutomation.Tests; + +/// +/// The looser foreground predicate live-screen capture uses. +/// +/// +/// Injection and capture want different answers to "is the foreground close enough to my target". +/// For injection a dialog in front is fatal — the keystrokes would land in the dialog. For capture it +/// is the normal case and the whole point: a modal dialog the target owns is sitting on the pixels +/// being read, which is why --capture-screen exists. An owned dialog is its own +/// GA_ROOT, so the strict predicate can never see the relationship; only the owner chain can. +/// +[TestClass] +[DoNotParallelize] // ForegroundGuard's native seams are static. +public class CaptureForegroundOwnerChainTests +{ + private static readonly HWND s_mainWindow = new(0x1000); + private static readonly HWND s_ownedDialog = new(0x2000); + private static readonly HWND s_nestedDialog = new(0x3000); + private static readonly HWND s_strangerWindow = new(0x9000); + + [TestCleanup] + public void Cleanup() => ForegroundGuard.ResetNativeSeams(); + + /// Every window is its own root, which is how real top-level windows behave. + private static void ArrangeTopLevelRoots() => ForegroundGuard.s_getRootAncestor = h => h; + + private static void ArrangeOwners(Dictionary owners) + => ForegroundGuard.s_getOwner = h => owners.TryGetValue((nint)h, out var owner) ? owner : new HWND(0); + + [TestMethod] + public void AnOwnedDialogInFrontIsCapturableForItsOwner() + { + // The reported regression: `ui screenshot -w
--capture-screen` while the app's own + // modal dialog holds the foreground. The strict predicate says no, because the dialog is its + // own root; capture must say yes, because the dialog is the overlay being captured. + ArrangeTopLevelRoots(); + ForegroundGuard.s_getForegroundWindow = () => s_ownedDialog; + ArrangeOwners(new() { [(nint)s_ownedDialog] = s_mainWindow }); + + Assert.IsFalse(ForegroundGuard.ForegroundBelongsTo((nint)s_mainWindow), + "the strict predicate cannot see an owner relationship — that is why capture needs its own"); + Assert.IsTrue(ForegroundGuard.ForegroundIsCapturableFor((nint)s_mainWindow)); + } + + [TestMethod] + public void AnOwnerChainSeveralHopsDeepStillResolves() + { + // A file picker owned by a document window owned by the main frame. + ArrangeTopLevelRoots(); + ForegroundGuard.s_getForegroundWindow = () => s_nestedDialog; + ArrangeOwners(new() + { + [(nint)s_nestedDialog] = s_ownedDialog, + [(nint)s_ownedDialog] = s_mainWindow, + }); + + Assert.IsTrue(ForegroundGuard.ForegroundIsCapturableFor((nint)s_mainWindow)); + } + + [TestMethod] + public void AnUnrelatedForegroundWindowIsStillRefused() + { + // The case the guard exists for. Capturing another app's pixels and labelling them as the + // target's is worse than failing, because the caller cannot tell. + ArrangeTopLevelRoots(); + ForegroundGuard.s_getForegroundWindow = () => s_strangerWindow; + ArrangeOwners([]); + + Assert.IsFalse(ForegroundGuard.ForegroundIsCapturableFor((nint)s_mainWindow)); + } + + [TestMethod] + public void ADialogOwnedByAnUnrelatedWindowIsStillRefused() + { + // An owner chain that leads somewhere else is not evidence of anything. + ArrangeTopLevelRoots(); + ForegroundGuard.s_getForegroundWindow = () => s_ownedDialog; + ArrangeOwners(new() { [(nint)s_ownedDialog] = s_strangerWindow }); + + Assert.IsFalse(ForegroundGuard.ForegroundIsCapturableFor((nint)s_mainWindow)); + } + + [TestMethod] + public void ACyclicOwnerChainTerminates() + { + // A corrupted or hostile chain must not spin. The hop bound is what makes the walk total. + ArrangeTopLevelRoots(); + ForegroundGuard.s_getForegroundWindow = () => s_ownedDialog; + ArrangeOwners(new() + { + [(nint)s_ownedDialog] = s_strangerWindow, + [(nint)s_strangerWindow] = s_ownedDialog, + }); + + Assert.IsFalse(ForegroundGuard.ForegroundIsCapturableFor((nint)s_mainWindow)); + } + + [TestMethod] + public void NoForegroundWindowIsStillRefused() + { + // A locked or secure desktop. Nothing to capture, and nothing to prove ownership against. + ArrangeTopLevelRoots(); + ForegroundGuard.s_getForegroundWindow = () => new HWND(0); + ArrangeOwners([]); + + Assert.IsFalse(ForegroundGuard.ForegroundIsCapturableFor((nint)s_mainWindow)); + } + + [TestMethod] + public void TheTargetItselfInFrontIsStillCapturable() + { + // The ordinary case must not regress: no owner walk needed. + ArrangeTopLevelRoots(); + ForegroundGuard.s_getForegroundWindow = () => s_mainWindow; + ArrangeOwners([]); + + Assert.IsTrue(ForegroundGuard.ForegroundIsCapturableFor((nint)s_mainWindow)); + } + + [TestMethod] + public void AChildTargetWhoseRootOwnsTheDialogIsCapturable() + { + // The target HWND resolved from an element is often a child/host window. The dialog is owned + // by that child's top-level root, not by the child itself, so the walk has to accept the root. + var childTarget = new HWND(0x1100); + ForegroundGuard.s_getRootAncestor = h => h == childTarget ? s_mainWindow : h; + ForegroundGuard.s_getForegroundWindow = () => s_ownedDialog; + ArrangeOwners(new() { [(nint)s_ownedDialog] = s_mainWindow }); + + Assert.IsTrue(ForegroundGuard.ForegroundIsCapturableFor((nint)childTarget)); + } + + [TestMethod] + public void AZeroTargetIsRefused() + { + // No resolvable window means there is nothing to prove a relationship against, so "can't + // verify" has to read as no — same as the strict predicate. + ArrangeTopLevelRoots(); + ForegroundGuard.s_getForegroundWindow = () => s_ownedDialog; + ArrangeOwners(new() { [(nint)s_ownedDialog] = s_mainWindow }); + + Assert.IsFalse(ForegroundGuard.ForegroundIsCapturableFor(0)); + } + + [TestMethod] + public void AForegroundWindowWithNoOwnerAtAllIsRefused() + { + // GW_OWNER returns null for an unowned top-level window; the walk must stop rather than treat + // "no owner" as a match. + ArrangeTopLevelRoots(); + ForegroundGuard.s_getForegroundWindow = () => s_strangerWindow; + ForegroundGuard.s_getOwner = _ => new HWND(0); + + Assert.IsFalse(ForegroundGuard.ForegroundIsCapturableFor((nint)s_mainWindow)); + } + + [TestMethod] + public void AChainLongerThanTheBoundIsRefused() + { + // The bound is what makes the walk total. A chain that only reaches the target beyond it is + // refused rather than followed forever. + ArrangeTopLevelRoots(); + ForegroundGuard.s_getForegroundWindow = () => new HWND(1); + // 1 -> 2 -> 3 -> ... -> 20, with the target parked at the far end. + ForegroundGuard.s_getOwner = h => (nint)h < 20 ? new HWND((nint)h + 1) : s_mainWindow; + + Assert.IsFalse(ForegroundGuard.ForegroundIsCapturableFor((nint)s_mainWindow)); + } +} diff --git a/src/winapp-CLI/WinApp.UIAutomation.Tests/CaptureForegroundSafetyTests.cs b/src/winapp-CLI/WinApp.UIAutomation.Tests/CaptureForegroundSafetyTests.cs index eb9848e60..e35a64c7d 100644 --- a/src/winapp-CLI/WinApp.UIAutomation.Tests/CaptureForegroundSafetyTests.cs +++ b/src/winapp-CLI/WinApp.UIAutomation.Tests/CaptureForegroundSafetyTests.cs @@ -52,6 +52,114 @@ await Assert.ThrowsExactlyAsync( }, CancellationToken.None)); } + // ------------------------------------------- an owned dialog in front is the capturable case + + [TestMethod] + public async Task ScreenshotAsync_CaptureScreen_AcceptsAModalDialogTheTargetOwns() + { + // The reported regression, at the service boundary. `-w
--capture-screen` while the + // app's own modal dialog holds the foreground must capture, not throw: the dialog is the + // overlay --capture-screen exists to record, and it is sitting on the pixels being read. + using var fx = new UiaTestFixture(); + var service = NewAutomationService(); + var target = TargetFor(fx); + + var dialog = new HWND(0x7F7F); + ForegroundGuard.s_getForegroundWindow = () => dialog; + ForegroundGuard.s_getRootAncestor = h => h; // both are top-level, as real windows are + ForegroundGuard.s_getOwner = h => h == dialog ? new HWND((nint)fx.Hwnd) : new HWND(0); + + // Reaching the capture at all is the assertion: the old predicate threw before this point. + var (pixels, width, height) = await service.ScreenshotAsync( + target, null, captureScreen: true, focus: false, CancellationToken.None); + + Assert.IsTrue(pixels.Length > 0); + Assert.IsTrue(width > 0 && height > 0); + } + + [TestMethod] + public async Task ScreenshotAsync_CaptureScreen_StillRefusesAnUnrelatedForegroundWindow() + { + // The guard has to keep doing its job: an unrelated window in front means the pixels would be + // somebody else's, and a PNG of the wrong app is worse than no PNG. + using var fx = new UiaTestFixture(); + var service = NewAutomationService(); + var target = TargetFor(fx); + + ForegroundGuard.s_getForegroundWindow = () => new HWND(0x6060); + ForegroundGuard.s_getRootAncestor = h => h; + ForegroundGuard.s_getOwner = _ => new HWND(0); + + await Assert.ThrowsExactlyAsync( + () => service.ScreenshotAsync(target, null, captureScreen: true, focus: false, CancellationToken.None)); + } + + [TestMethod] + public async Task RecordAsync_FirstScreenFrame_AcceptsAModalDialogTheTargetOwns() + { + // Recording had the same defect and the same fix: `record -w
--capture-screen` must + // accept its own modal foreground. + // + // Success is measured by what it does NOT throw. SafeFakeWindowCapture refuses to produce + // screen pixels precisely so these tests can prove the foreground gate ran first, so reaching + // it is the assertion: the gate accepted the owned dialog and let the recording proceed. + using var fx = new UiaTestFixture(); + var recorder = NewRecordingService(); + var target = TargetFor(fx); + var output = Path.Combine( + AppContext.BaseDirectory, "coverage-scratch", Guid.NewGuid().ToString("N"), "owned.mp4"); + Directory.CreateDirectory(Path.GetDirectoryName(output)!); + + var dialog = new HWND(0x7E7E); + ForegroundGuard.s_getForegroundWindow = () => dialog; + ForegroundGuard.s_getRootAncestor = h => h; + ForegroundGuard.s_getOwner = h => h == dialog ? new HWND((nint)fx.Hwnd) : new HWND(0); + + var thrown = await Assert.ThrowsExactlyAsync( + () => recorder.RecordAsync(target, null, new RecordOptions + { + OutputPath = output, + CaptureScreen = true, + DurationSec = 1, + Fps = 1, + MaxEdge = 64, + }, CancellationToken.None)); + + StringAssert.Contains(thrown.Message, "should fail before screen capture", + "the recording reached pixel capture, which means the owned-dialog foreground was accepted"); + Assert.IsNotInstanceOfType(thrown, + "an owned modal dialog must not be treated as a lost foreground"); + } + + [TestMethod] + public async Task RecordAsync_RefusingAnUnrelatedForeground_WritesNoArtifact() + { + // A refusal must leave nothing behind: a truncated MP4 is worse than none, because the caller + // cannot tell it apart from a real recording of the wrong window. + using var fx = new UiaTestFixture(); + var recorder = NewRecordingService(); + var target = TargetFor(fx); + var output = Path.Combine( + AppContext.BaseDirectory, "coverage-scratch", Guid.NewGuid().ToString("N"), "refused.mp4"); + Directory.CreateDirectory(Path.GetDirectoryName(output)!); + + ForegroundGuard.s_getForegroundWindow = () => new HWND(0x5050); + ForegroundGuard.s_getRootAncestor = h => h; + ForegroundGuard.s_getOwner = _ => new HWND(0); + + await Assert.ThrowsExactlyAsync( + () => recorder.RecordAsync(target, null, new RecordOptions + { + OutputPath = output, + CaptureScreen = true, + DurationSec = 1, + Fps = 1, + MaxEdge = 64, + }, CancellationToken.None)); + + Assert.IsFalse(File.Exists(output), "a refused recording must produce no file"); + } + private static IUiAutomation NewAutomationService() => new ServiceCollection() .AddSingleton(NullLoggerFactory.Instance) diff --git a/src/winapp-CLI/WinApp.UIAutomation/Input/ForegroundGuard.cs b/src/winapp-CLI/WinApp.UIAutomation/Input/ForegroundGuard.cs index fc2c22cf0..f5b11acfc 100644 --- a/src/winapp-CLI/WinApp.UIAutomation/Input/ForegroundGuard.cs +++ b/src/winapp-CLI/WinApp.UIAutomation/Input/ForegroundGuard.cs @@ -6,12 +6,31 @@ namespace Microsoft.Windows.SDK.BuildTools.WinApp.UIAutomation; /// -/// Helpers for verifying that the window we're about to inject OS-wide input into is actually the -/// one the user targeted. SendInput-based gestures (send-keys via send-input, drag, scroll -/// --wheel, click, hover) land on whatever window is in the foreground / under the cursor — if -/// SetForegroundWindow silently failed (focus-stealing prevention, a UAC prompt, another app -/// grabbing focus, or the session being locked) the input would hit the wrong window or be dropped. +/// Helpers for verifying that the window we're about to act on is actually the one the user targeted. /// +/// +/// +/// There are two questions here, and they have different right answers. +/// +/// +/// Injection — , . +/// SendInput-based gestures (send-keys via send-input, drag, scroll --wheel, click, hover) land +/// on whatever window is in the foreground / under the cursor. If SetForegroundWindow silently +/// failed (focus-stealing prevention, a UAC prompt, another app grabbing focus, or the session being +/// locked) the input would hit the wrong window or be dropped. This check is strict on purpose: it +/// accepts only the target itself or its top-level root, because a dialog sitting in front would +/// swallow the keystrokes meant for the window behind it. +/// +/// +/// Live-screen capture — . Reading pixels from +/// the screen wants the opposite treatment for that same dialog: a modal dialog the target owns is +/// part of that app's UI and is sitting on the very pixels being captured, which is the reason to read +/// the screen rather than the window. So the capture predicate additionally accepts a foreground +/// window whose GW_OWNER chain reaches the target, within a bound. It is still a real check — +/// an unrelated window from another app has no owner path to the target and is refused, so a capture +/// can never quietly return somebody else's window labelled as yours. +/// +/// public static class ForegroundGuard { /// @@ -32,6 +51,24 @@ public static class ForegroundGuard private static global::Windows.Win32.Foundation.HWND DefaultGetRootAncestor(global::Windows.Win32.Foundation.HWND hwnd) => global::Windows.Win32.PInvoke.GetAncestor(hwnd, global::Windows.Win32.UI.WindowsAndMessaging.GET_ANCESTOR_FLAGS.GA_ROOT); + /// + /// Native adapter seam: the default body takes one GW_OWNER hop. Tests inject deterministic + /// owner links so an owned-dialog foreground can be reproduced without real windows. + /// + internal static Func s_getOwner = + DefaultGetOwner; + + private static global::Windows.Win32.Foundation.HWND DefaultGetOwner(global::Windows.Win32.Foundation.HWND hwnd) => + global::Windows.Win32.PInvoke.GetWindow(hwnd, global::Windows.Win32.UI.WindowsAndMessaging.GET_WINDOW_CMD.GW_OWNER); + + /// How many GW_OWNER hops are followed before giving up. + /// + /// Owner links are a chain, not a tree — a file picker owned by a document window owned by the + /// main frame is three deep. The bound stops a corrupted or cyclic chain from spinning; anything + /// deeper than this is not a dialog relationship worth trusting. + /// + private const int MaxOwnerHops = 8; + /// /// Restores every native seam to its production delegate. Test cleanup calls this so a faked /// seam never leaks into a later test that reads the live foreground window (issue #630). @@ -40,6 +77,7 @@ internal static void ResetNativeSeams() { s_getForegroundWindow = global::Windows.Win32.PInvoke.GetForegroundWindow; s_getRootAncestor = DefaultGetRootAncestor; + s_getOwner = DefaultGetOwner; } /// @@ -76,6 +114,71 @@ public static bool ForegroundBelongsTo(long targetHwnd) return !targetRoot.IsNull && targetRoot == foreground; } + /// + /// Whether the current foreground window is close enough to that a + /// live-screen capture of the target's rectangle would show the target's own UI. + /// + /// + /// + /// Deliberately looser than , and only for capture. Injection has + /// to be strict: if a dialog is in front, keystrokes land on the dialog, so accepting it would type + /// into the wrong window. Capture is the opposite case — a modal dialog the target owns is part of + /// that app's UI and is sitting on the pixels being read, which is precisely what + /// --capture-screen exists to record. Rejecting it would fail the documented + /// -w <hwnd> recovery for the most ordinary reason a window is not foreground. + /// + /// + /// It stays a real check. An owned dialog is its own GA_ROOT, so root ancestry alone can + /// never see it; the owner chain is what proves the relationship. An unrelated window from another + /// app has no owner path to the target, so the case this guard exists for — capturing somebody + /// else's window and labelling it as the target's — is still refused. + /// + /// + /// Public rather than internal, deliberately. Sharing it with the sibling Recording package via + /// InternalsVisibleTo was tried first and does not work: both assemblies generate their own + /// internal CsWin32 PInvoke type, so making one's internals visible to the other makes that + /// type ambiguous (CS0436, fatal under the Release warnings-as-errors settings). The alternative + /// was a second copy of a safety check, which is worse than one more method on a guard class that + /// already exposes and . + /// + /// + public static bool ForegroundIsCapturableFor(long targetHwnd) + { + if (ForegroundBelongsTo(targetHwnd)) + { + return true; + } + + if (targetHwnd == 0) + { + return false; + } + + var foreground = s_getForegroundWindow(); + if (foreground.IsNull) + { + return false; + } + + var target = new global::Windows.Win32.Foundation.HWND((nint)targetHwnd); + var targetRoot = s_getRootAncestor(target); + + // Walk from the foreground window outwards: the dialog names its owner, not the other way + // round, so this is the only direction the relationship can be read in. + var owner = s_getOwner(foreground); + for (var hop = 0; hop < MaxOwnerHops && !owner.IsNull; hop++) + { + if (owner == target || (!targetRoot.IsNull && owner == targetRoot)) + { + return true; + } + + owner = s_getOwner(owner); + } + + return false; + } + /// /// Returns when there is no foreground window at all — the signature of a /// locked workstation or a secure desktop (LogonUI / UAC), where a user-session process cannot diff --git a/src/winapp-CLI/WinApp.UIAutomation/PACKAGE.md b/src/winapp-CLI/WinApp.UIAutomation/PACKAGE.md index 369c8ef09..8aed94657 100644 --- a/src/winapp-CLI/WinApp.UIAutomation/PACKAGE.md +++ b/src/winapp-CLI/WinApp.UIAutomation/PACKAGE.md @@ -95,6 +95,15 @@ Run this on a dedicated interactive desktop rather than the one you are working injection does nothing useful over a disconnected RDP session, where there is no live desktop to receive it. +`ForegroundGuard` exposes the two checks these paths need, and they are deliberately different: + +- `ForegroundBelongsTo(hwnd)` — the strict one, for input. It accepts only the target window or the + top-level root that owns it, because a dialog in front would swallow your keystrokes. +- `ForegroundIsCapturableFor(hwnd)` — the capture one, for screen capture and screen recording. It + also accepts a foreground window whose owner chain reaches the target, because a modal dialog the + target owns is part of that app's UI and is sitting on the pixels you asked for. An unrelated + window is still refused, so you never get a picture of somebody else's app labelled as yours. + ## Requirements Windows 10 version 1809 or later for synthetic pen and touch injection; other features work on diff --git a/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.Screenshot.cs b/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.Screenshot.cs index 1d3493d8a..4bf4f3e0b 100644 --- a/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.Screenshot.cs +++ b/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.Screenshot.cs @@ -97,7 +97,12 @@ private static void ForegroundWindowForBlankRetry(global::Windows.Win32.Foundati // worse than failing, because the caller cannot tell. Verify after the activation delay and // immediately before the capture. This is capture safety, not coordination: it says nothing // about who else may be driving the desktop, only that these pixels would be the wrong ones. - if (!ForegroundGuard.ForegroundBelongsTo((long)(nint)hwnd)) + // + // The capture predicate also accepts a foreground window whose owner chain reaches the + // target — a modal dialog the target owns is part of its UI and is sitting on the pixels + // being read, which is the reason to capture the screen rather than the window. Injection + // keeps the strict predicate, because keystrokes would land on the dialog. + if (!ForegroundGuard.ForegroundIsCapturableFor((long)(nint)hwnd)) { throw new ForegroundLostException( "The target window is not in the foreground, so a screen capture would record whatever " + From c33f7f2c70bc137923d62af453f8be306186575b Mon Sep 17 00:00:00 2001 From: nmetulev <711864+nmetulev@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:24:01 -0700 Subject: [PATCH 29/29] Do not treat Administrators or SYSTEM as a foreign identity on state files CI caught this: the file-level trust check deleted a perfectly good state document on every command under an elevated process. Windows assigns BUILTIN\Administrators as the default owner of newly created files there, so requiring the owner to be exactly the running user never held -- and the directory rule, which does hold because we set that owner ourselves, is simply the wrong rule for a file. Files now use their own predicate. SYSTEM and Administrators are excluded from it because they are not a boundary this can defend: either can already take ownership of any file and grant itself whatever it likes, so treating their presence as hostile buys no security and costs a false positive that destroys live state. The identity that actually matters is another standard user, and any ACE naming one still fails. This was a worse bug than the hole it was closing, and it was my own test that found it -- AnAlreadySecuredDirectoryKeepsItsContents, added in the same change precisely to check the sweep could not become destructive. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1f5699f4-3328-49e1-a043-31b2173a5bdf --- .../InteractiveDesktopPathsHardeningTests.cs | 55 +++++++++++++++++ .../InteractiveDesktopPaths.cs | 60 +++++++++++++++---- 2 files changed, 104 insertions(+), 11 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopPathsHardeningTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopPathsHardeningTests.cs index 3ce6eda09..a0d50dfdc 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopPathsHardeningTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopPathsHardeningTests.cs @@ -238,6 +238,61 @@ public void AnInsecureStateFileThatCannotBeReplacedFailsClosed() StringAssert.Contains(ex.Message, "reachable by another user"); } + [TestMethod] + public void AStateFileWithAdministratorsAndSystemAcesIsKept() + { + // Half of the CI regression this check caused, in the half reproducible without elevation. + // Administrators and SYSTEM can already take ownership of anything, so their presence is not a + // boundary this can defend and must not read as hostile. (The other half is a file *owned* by + // BUILTIN\Administrators, which Windows assigns by default under an elevated process; setting + // that owner needs SeRestorePrivilege, so it is covered by the same carve-out and exercised by + // the elevated CI lane rather than arranged here.) + var paths = new InteractiveDesktopPaths(new ProcessInspector()); + paths.EnsureDirectories(); + + File.WriteAllText(paths.StatePath, "{\"version\":1}"); + var file = new FileInfo(paths.StatePath); + var acl = file.GetAccessControl(); + acl.AddAccessRule(new FileSystemAccessRule( + new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null), + FileSystemRights.FullControl, + AccessControlType.Allow)); + acl.AddAccessRule(new FileSystemAccessRule( + new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), + FileSystemRights.FullControl, + AccessControlType.Allow)); + file.SetAccessControl(acl); + + new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories(); + + Assert.IsTrue(File.Exists(paths.StatePath), + "an Administrators/SYSTEM ACE is not another standard user and must not discard live state"); + } + + [TestMethod] + public void AStateFileGrantedToAnotherStandardUserIsStillDiscarded() + { + // The boundary that does matter, and the one the Administrators carve-out must not widen: + // an ACE naming an ordinary account that is not this user. + var paths = new InteractiveDesktopPaths(new ProcessInspector()); + paths.EnsureDirectories(); + + File.WriteAllText(paths.StatePath, "{\"version\":1}"); + var file = new FileInfo(paths.StatePath); + var acl = file.GetAccessControl(); + // Guests is a well-known group, but not a privileged one — it stands in for any other account. + acl.AddAccessRule(new FileSystemAccessRule( + new SecurityIdentifier(WellKnownSidType.BuiltinGuestsSid, null), + FileSystemRights.FullControl, + AccessControlType.Allow)); + file.SetAccessControl(acl); + + new InteractiveDesktopPaths(new ProcessInspector()).EnsureDirectories(); + + Assert.IsFalse(File.Exists(paths.StatePath), + "a grant to any non-privileged identity other than this user is still untrusted"); + } + [TestMethod] public void AnAlreadySecuredDirectoryKeepsItsContents() { diff --git a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs index e03235150..892ae9c0e 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs @@ -189,7 +189,7 @@ private void EnsureTrustedStateFiles() try { - if (IsCurrentUserOnly(file.GetAccessControl(), currentUser, requireProtected: false)) + if (IsFileReachableOnlyByThisUser(file.GetAccessControl(), currentUser)) { continue; } @@ -453,27 +453,65 @@ private static void DiscardUntrustedArtifacts(DirectoryInfo directoryInfo) "state.corrupt-*", ]; + /// + /// Whether a coordination file can only be written by this user or by an identity that + /// already outranks the protection entirely. + /// + /// + /// + /// Deliberately not the directory rule. We create directories and set their owner explicitly, so + /// requiring the owner to be exactly this user is both achievable and meaningful there. Files are + /// created by ordinary I/O and inherit the system's default owner, which on an elevated process is + /// BUILTIN\Administrators rather than the running user. Applying the directory rule to files + /// deleted a perfectly good state document on every command in exactly that configuration. + /// + /// + /// SYSTEM and Administrators are excluded from the check because they are not a boundary this can + /// defend: either can already take ownership of any file and grant themselves whatever they like. + /// Treating their presence as hostile buys no security and costs a false positive that destroys + /// live state. The identity that matters is another standard user, and any ACE naming one + /// still fails this. + /// + /// + private static bool IsFileReachableOnlyByThisUser(FileSecurity security, SecurityIdentifier currentUser) + { + if (security.GetOwner(typeof(SecurityIdentifier)) is not SecurityIdentifier owner + || !IsSelfOrPrivileged(owner, currentUser)) + { + return false; + } + + foreach (FileSystemAccessRule rule in security.GetAccessRules(true, true, typeof(SecurityIdentifier))) + { + if (rule.IdentityReference is not SecurityIdentifier sid || !IsSelfOrPrivileged(sid, currentUser)) + { + return false; + } + } + + return true; + } + + private static bool IsSelfOrPrivileged(SecurityIdentifier sid, SecurityIdentifier currentUser) + => sid == currentUser + || sid.IsWellKnown(WellKnownSidType.LocalSystemSid) + || sid.IsWellKnown(WellKnownSidType.BuiltinAdministratorsSid); + /// /// Whether describes an object only the current user can reach or /// re-permission. /// /// - /// /// Owner is checked as well as the DACL because the owner of an object implicitly holds /// WRITE_DAC: a foreign owner can rewrite even a protected, current-user-only DACL and grant /// itself access at any time. That matters most for a WINAPP_UI_LOCK_DIRECTORY override under /// a shared path, where another user may have created the directory first. - /// /// - /// is what differs between a directory and a file inside it. A - /// directory has to reject inherited rules outright, because it may hang under a shared parent that - /// grants other users. A file inside an already-verified directory legitimately inherits from it, - /// so for files the question is only whether any rule — inherited or explicit — names somebody - /// else, which the loop below answers either way. + /// This is the strict directory rule. Files use + /// , which differs for reasons documented there. /// /// - internal static bool IsCurrentUserOnly( - FileSystemSecurity security, SecurityIdentifier currentUser, bool requireProtected = true) + internal static bool IsCurrentUserOnly(FileSystemSecurity security, SecurityIdentifier currentUser) { if (security.GetOwner(typeof(SecurityIdentifier)) is not SecurityIdentifier owner || owner != currentUser) @@ -481,7 +519,7 @@ internal static bool IsCurrentUserOnly( return false; } - if (requireProtected && !security.AreAccessRulesProtected) + if (!security.AreAccessRulesProtected) { // Inherited rules can grant anyone the parent grants, which for a shared override directory // includes other users.