diff --git a/.github/workflows/build-package.yml b/.github/workflows/build-package.yml index d794d617e..877f30f49 100644 --- a/.github/workflows/build-package.yml +++ b/.github/workflows/build-package.yml @@ -164,6 +164,19 @@ jobs: $platform = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "arm64" } else { "x64" } .\scripts\test-e2e-winui-ui.ps1 -WinAppPath "artifacts/cli/win-$platform/winapp.exe" + # 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 + run: .\scripts\test-ui-coordination.ps1 + + - 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/cli-schema.json b/docs/cli-schema.json index 257aaf596..3fe51c74c 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 606c88b48..d8f5c3689 100644 --- a/docs/npm-usage.md +++ b/docs/npm-usage.md @@ -44,6 +44,8 @@ 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`. | +| `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` @@ -57,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`). +These functions wrap native `winapp` CLI commands. All accept [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`). ### `azSign()` @@ -78,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -104,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -124,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -144,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -165,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -231,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -249,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -279,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -299,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -399,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -418,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: base-directory) | -*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -458,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -479,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -497,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -515,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -538,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -563,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -584,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -604,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -626,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -647,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -669,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -695,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -716,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -736,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -764,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -788,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -812,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -833,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -871,7 +873,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 | @@ -880,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -902,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -922,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -951,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -977,7 +979,25 @@ 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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* + +--- + +### `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`).* --- @@ -997,7 +1017,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -1015,7 +1035,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`).* +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`, `signal`, `workflowId`).* --- @@ -1263,6 +1283,8 @@ 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`. | +| `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` @@ -1275,6 +1297,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. | +| `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` @@ -1367,6 +1391,8 @@ 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`. | +| `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` @@ -1384,6 +1410,8 @@ 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`. | +| `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` @@ -1395,6 +1423,8 @@ 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`. | +| `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` @@ -1406,6 +1436,8 @@ 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`. | +| `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` @@ -1418,6 +1450,8 @@ 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`. | +| `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` @@ -1432,6 +1466,8 @@ 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`. | +| `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` @@ -1442,6 +1478,8 @@ 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`. | +| `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` @@ -1457,6 +1495,8 @@ 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`. | +| `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` @@ -1466,6 +1506,8 @@ 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`. | +| `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` @@ -1487,6 +1529,8 @@ 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`. | +| `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` @@ -1498,6 +1542,8 @@ 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`. | +| `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` @@ -1515,6 +1561,8 @@ 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`. | +| `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` @@ -1526,6 +1574,8 @@ 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`. | +| `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` @@ -1542,6 +1592,8 @@ 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`. | +| `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` @@ -1562,6 +1614,8 @@ 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`. | +| `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` @@ -1572,6 +1626,8 @@ 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`. | +| `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` @@ -1603,6 +1659,8 @@ 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`. | +| `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` @@ -1615,6 +1673,8 @@ 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`. | +| `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` @@ -1624,6 +1684,8 @@ 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`. | +| `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` @@ -1633,6 +1695,8 @@ 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`. | +| `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` @@ -1647,6 +1711,8 @@ 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`. | +| `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` @@ -1663,6 +1729,8 @@ 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`. | +| `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` @@ -1675,6 +1743,8 @@ 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`. | +| `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` @@ -1686,6 +1756,8 @@ 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`. | +| `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` @@ -1699,6 +1771,8 @@ 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`. | +| `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` @@ -1711,6 +1785,8 @@ 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`. | +| `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` @@ -1724,6 +1800,8 @@ 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`. | +| `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` @@ -1741,6 +1819,8 @@ 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`. | +| `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` @@ -1753,6 +1833,8 @@ 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`. | +| `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` @@ -1764,6 +1846,8 @@ 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`. | +| `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` @@ -1783,6 +1867,8 @@ 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`. | +| `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` @@ -1798,6 +1884,8 @@ 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`. | +| `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` @@ -1813,6 +1901,8 @@ 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`. | +| `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` @@ -1825,6 +1915,8 @@ 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`. | +| `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` @@ -1838,12 +1930,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`. | +| `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` | 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 | @@ -1854,6 +1948,8 @@ 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`. | +| `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` @@ -1867,6 +1963,8 @@ 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`. | +| `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` @@ -1878,6 +1976,8 @@ 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`. | +| `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` @@ -1898,6 +1998,8 @@ 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`. | +| `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` @@ -1915,6 +2017,19 @@ 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`. | +| `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` @@ -1926,6 +2041,8 @@ 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`. | +| `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` @@ -1935,4 +2052,6 @@ 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`. | +| `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/docs/telemetry.md b/docs/telemetry.md index 40049dea8..6e812d1d0 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -88,6 +88,7 @@ The telemetry feature collects the following data: | Caller | The value of the `WINAPP_CLI_CALLER` environment variable, if set. This allows wrapper tools (like the npm package) to identify themselves. | | Project context | For `init`, `run`, `restore`, `update`, and `package`, a separate correlated event records bounded categories describing the project family (`dotnet`, `node`, `cpp`, `rust`, `dart`, `hybrid`, `mixed`, or `unknown`), recognized app framework (such as `winui`, `wpf`, `winforms`, `maui`, `electron`, `tauri`, `flutter`, `react-native-windows`, `avalonia`, `uwp`, `windows-app-sdk`, or `other-dotnet`), target kind, detection source, confidence, packaging model, and whether `run` used project or folder execution. It doesn't include project names, paths, repositories, versions, dependency lists, or source. | | `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 (`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 @@ -98,6 +99,7 @@ The winapp CLI takes several measures to protect your privacy: - **Parsing errors** are logged as `[error]` without including the actual erroneous input. - **Project classification** emits only fixed, allow-listed category values. Unknown or unrecognized metadata is reported as `unknown` or a generic family category rather than transmitted as text. - 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_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 a6134eb14..a78266cd7 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 @@ -31,6 +31,146 @@ 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. + +**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_WORKFLOW_ID = [guid]::NewGuid().ToString() +``` + +What you need to know: + +- **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 — 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. 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 + 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 + 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. 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` | +| 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 +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. + +> **`--capture-screen` needs exactly one window.** Live-screen capture records whatever is actually +> 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: + +- 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_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 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), `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 +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 ### By process name @@ -241,6 +381,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. @@ -541,6 +683,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 | @@ -596,6 +747,7 @@ for example `MSTest.Windows.UIAutomation`, whose `WindowTest.MainWindow` is a UI | "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/docs/usage.md b/docs/usage.md index 69b76e495..7c8877487 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1469,6 +1469,27 @@ To make this permanent: [System.Environment]::SetEnvironmentVariable('WINAPP_CLI_UPDATE_CHECK', '0', 'User') ``` +### UI workflow identity + +`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_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. 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 Inspect and interact with running Windows app UIs using UI Automation (UIA). diff --git a/plugins/winapp/com.github.copilot/agents/winapp.agent.md b/plugins/winapp/com.github.copilot/agents/winapp.agent.md index eb474e6c2..dd083cd34 100644 --- a/plugins/winapp/com.github.copilot/agents/winapp.agent.md +++ b/plugins/winapp/com.github.copilot/agents/winapp.agent.md @@ -75,6 +75,25 @@ 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? +├─ 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. 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 + 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 @@ -291,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. `-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. @@ -306,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 dcac034e1..d40fdc127 100644 --- a/plugins/winapp/skills/winapp-ui-automation/SKILL.md +++ b/plugins/winapp/skills/winapp-ui-automation/SKILL.md @@ -11,7 +11,66 @@ 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. +- 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**. `-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 + +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. 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_WORKFLOW_ID = [guid]::NewGuid().ToString() +``` + +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. 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; 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. +- **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`. +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 @@ -121,7 +180,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. @@ -321,6 +380,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/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..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 @@ -85,3 +85,72 @@ 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_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 +abort landed after the command acquired the desktop. + +`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. Workflow 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..9f60145ca 100644 --- a/samples/winui-app/README.md +++ b/samples/winui-app/README.md @@ -50,6 +50,11 @@ winapp run .\bin\x64\Debug\net10.0-windows10.0.26100.0\win-x64 --detach --json ## Testing with winapp ui ```powershell +# 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/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/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/DesktopTargetValidationTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/DesktopTargetValidationTests.cs new file mode 100644 index 000000000..3f272ca9e --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/DesktopTargetValidationTests.cs @@ -0,0 +1,195 @@ +// 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. 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; + + 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)); + } + + // -------------------------------------------------- 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/FakeDesktopForegroundService.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeDesktopForegroundService.cs new file mode 100644 index 000000000..4ac1af493 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeDesktopForegroundService.cs @@ -0,0 +1,53 @@ +// 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, so a test can drive the restore path. + public HashSet MinimizedWindows { get; } = []; + + /// + /// 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; } + + 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 new file mode 100644 index 000000000..deddab673 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeInteractiveDesktopLock.cs @@ -0,0 +1,129 @@ +// 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; } + + /// 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; } + + /// + /// 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, + Func> body, + CancellationToken cancellationToken) + { + Runs.Add((mode, operation)); + + if (ThrowOnRun is { } failure) + { + throw failure; + } + + 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 + { + 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)); + } + + } + + 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/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/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.PushQueue.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.PushQueue.cs new file mode 100644 index 000000000..ea7164a0e --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.PushQueue.cs @@ -0,0 +1,292 @@ +// 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"); + } + + [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. + /// + 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.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/InteractiveDesktopLockTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs new file mode 100644 index 000000000..980a4656b --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopLockTests.cs @@ -0,0 +1,660 @@ +// 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_WORKFLOW_ID are process-wide. +public partial 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!; + private FakeParticipantSignals _signals = 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.WorkflowIdVariable); + + Environment.SetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable, _lockDirectory); + // A stable explicit owner keeps these tests independent of the test host's parent process. + 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( + _paths, _participants, new TickCountClock(), NullLogger.Instance); + _coordinator = new InteractiveDesktopLock( + _store, + _paths, + _participants, + new UiOwnerResolver(), + inspector, + new TickCountClock(), + new FakePollDelay(), + _signals, + new TestConsole(), + NullLogger.Instance); + } + + [TestCleanup] + public void Cleanup() + { + Environment.SetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable, _previousLockOverride); + Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, _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.Workflow, 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+|\+)?$")); + } + + [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.Workflow, 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 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(0, ReadOwnerDeadline(), + "a command that produced nothing must not renew the owner's idle grace"); + } + + [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(0, ReadOwnerDeadline(), + "a command that never ran must not renew the owner's idle grace"); + } + + // --------------------------------------------------------------------------- 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 InvalidExplicitWorkflowIdFailsBeforeAnyUiSideEffect() + { + Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, " "); + + var ran = false; + var ex = await Assert.ThrowsExactlyAsync(() => + RunAsync(UiTurnMode.DesktopExclusive, "ui click", (_, _) => + { + ran = true; + return Task.FromResult(0); + })); + + 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"); + } + + // -------------------------------------------------------- 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.Workflow, 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); + + // ------------------------------------------ cancellation must not swallow coordination faults + + [TestMethod] + public async Task AnActiveRecordingThatFinalizesOnCancellationStillRenewsTheGrace() + { + // `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(); + + await Task.Delay(50); + + using var cts = new CancellationTokenSource(); + var bodyObservedCancellation = false; + 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; + return 0; + }, cts.Token); + + Assert.AreEqual(0, exitCode, "a finalized recording reports success"); + Assert.IsTrue(bodyObservedCancellation, + "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"); + } + + 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. + /// +} \ No newline at end of file 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 new file mode 100644 index 000000000..588b7f052 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopMultiprocessTests.cs @@ -0,0 +1,476 @@ +// 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 partial 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 = WinappTestBinary.Resolve(); + + _lockDirectory = Path.Combine(Path.GetTempPath(), $"winapp-mp-{Guid.NewGuid():N}"); + _previousLockOverride = Environment.GetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable); + _previousOwnerId = Environment.GetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable); + + Environment.SetEnvironmentVariable( + InteractiveDesktopPaths.LockDirectoryOverrideVariable, _lockDirectory); + Environment.SetEnvironmentVariable(UiOwnerResolver.WorkflowIdVariable, "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, + 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); + } + + [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.WorkflowIdVariable, _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. + } + } + + /// + /// 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.WorkflowIdVariable] = 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.WorkflowIdVariable] = "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 AnInvalidWorkflowIdIsRejectedByTheRealBinaryBeforeAnyWork() + { + 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.WorkflowIdVariable] = " "; + 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.InvalidWorkflowId); + 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/InteractiveDesktopPathsHardeningTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopPathsHardeningTests.cs new file mode 100644 index 000000000..a0d50dfdc --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopPathsHardeningTests.cs @@ -0,0 +1,332 @@ +// 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 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() + { + // 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.Tests/InteractiveDesktopRealAppTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs new file mode 100644 index 000000000..3049862fe --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopRealAppTests.cs @@ -0,0 +1,483 @@ +// 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_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. +/// +/// +/// 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 = WinappTestBinary.Resolve(); + + _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.WorkflowIdVariable); + 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.WorkflowIdVariable, _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. + } + } + } + + // ------------------------------------------------------------------ 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.WorkflowIdVariable] = 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.ComputeWorkflowKey(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 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() + { + 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. + /// + /// + /// 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() + { + 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 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 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) + + /// + /// §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 new file mode 100644 index 000000000..b63d072ec --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSchedulerTests.cs @@ -0,0 +1,989 @@ +// 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.Workflow, "aaaa"); + private static readonly UiOwnerIdentity OwnerB = new(UiOwnerKind.Workflow, "bbbb"); + + [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, OwnerA, 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, OwnerA, 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, 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, OwnerA, 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 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() + { + 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 - 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, OwnerA, 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.Workflow, "cccc"); + 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, OwnerA, 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, OwnerA, 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, OwnerA, 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, OwnerA, 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"); + 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, 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"); + } + + // ------------------------------------------------------------------------------ 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.Workflow, "cccc"); + _scheduler.BeginParticipating(state, _probe, ownerC, Participant(300), UiTurnMode.DesktopExclusive); + + _probe.Alive.Remove((actor.ProcessId, actor.StartTicksUtc)); + _scheduler.CompleteCommand(state, _probe, actor, OwnerA, 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.Workflow, $"owner{i}"), + 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.Workflow, $"owner{i}"), + 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, OwnerA, 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.Workflow, "cccc"); + 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, OwnerA, 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.Workflow, "cccc"); + 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, OwnerA, 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, OwnerA, renewGrace: false); + Assert.AreEqual("cccc", state.Owner!.Key); + } + + // -------------------------------------------------------------------------- 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()); + } + + // ---------------------------------------------------------------- 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.Workflow, 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.Workflow, 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"); + 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"); + } + + + [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; + + 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 bool IsParticipantLive(int processId, long startTicksUtc) + => Alive.Contains((processId, startTicksUtc)); + } +} 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..065274d40 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopSignalSchedulingTests.cs @@ -0,0 +1,265 @@ +// 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 ARegisteringObservationIsReportedAsNewlyRunnable() + { + // 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); + + 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 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.Tests/InteractiveDesktopStoreTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs new file mode 100644 index 000000000..e014a1a5d --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/InteractiveDesktopStoreTests.cs @@ -0,0 +1,808 @@ +// 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; + +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_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() + { + _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"); + } + + [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] + 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":"workflow","key":"a"}, + "ownerCommands":[{"ticket":5,"pid":10,"processStartTicksUtc":1,"operation":"ui click","mode":"DesktopExclusive","status":"running"}], + "waiters":[{"ticket":5,"ownerKey":"b","ownerKind":"workflow","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":"workflow","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":"workflow","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":"workflow","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() + { + // 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,"turnStartedTick64":100,"nextTicket":9,"idleExpiresTick64":0, + "owner":{"kind":"workflow","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(); + + // 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); + + using var stateLock = _store.AcquireStateLock(CancellationToken.None); + var state = _store.Read().State!; + state.TurnId = 11; + _store.Publish(state); + + Assert.IsFalse(_store.IsActiveLockFree()); + } + + // ------------------------------------------------------- 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() + { + 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 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/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 babc60966..47f53366e 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/RealRecordingTests.cs @@ -6,6 +6,8 @@ using Windows.Win32.Foundation; using WinApp.Cli.Services; +using WinApp.Cli.Services.InteractiveDesktop; + namespace WinApp.Cli.Tests; /// @@ -189,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; 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..d7b6b238a --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.CaptureForeground.cs @@ -0,0 +1,80 @@ +// 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 ForegroundLostException 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() + { + _fakeRecording.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"); + } + + [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.Tests/UiCommandTests.Coordination.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs new file mode 100644 index 000000000..35e8f50da --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Coordination.cs @@ -0,0 +1,631 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// 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; + +/// +/// 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_TakesASharedTurnBecauseItChangesWhatTheAppShows() + { + // 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.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] + 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 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.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] + 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.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); + } + + // ------------------------------------------------- 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 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. + _fakeTargetResolver.TargetResult = new UiTarget + { + 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() + { + _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"); + } + + // --------------------------------------------- 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. + _fakeRecording.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. + _fakeRecording.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}"); + } + + // ------------------------------------------- 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() + { + // 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(); + + _fakeRecording.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. + _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; + 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; + _fakeRecording.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.DenyReason = ForegroundCheck.ForegroundNotTarget; + _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.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.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.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 380568bb2..98d6269c3 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!); + 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.Ambiguity.cs b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.Ambiguity.cs new file mode 100644 index 000000000..ee5d49151 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/UiCommandTests.Screenshot.Ambiguity.cs @@ -0,0 +1,177 @@ +// 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_CaptureScreenForOneExplicitWindowThatOwnsADialog_StillCaptures() + { + // 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.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 a4261d7bc..d0e1bfcbc 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; @@ -44,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(); @@ -54,6 +59,44 @@ 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] + 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] @@ -62,6 +105,8 @@ 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(); @@ -89,6 +134,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() { @@ -197,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/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.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.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.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/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/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/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 5abd5819e..dc0a57319 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( IUiTargetResolver targetResolver, IUiAutomation 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 uiTarget = await targetResolver.ResolveAsync(app, window, cancellationToken); @@ -80,7 +96,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/UiGetPropertyCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiGetPropertyCommand.cs index f6945e512..aa16c3a30 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( IUiAutomation uiAutomation, IUiSelectorParser selectorParser, 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 @@ -105,7 +122,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/UiGetValueCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiGetValueCommand.cs index 4e3952500..af6a20392 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( IUiAutomation uiAutomation, IUiSelectorParser selectorParser, 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 uiTarget = await targetResolver.ResolveAsync(app, window, cancellationToken); @@ -98,7 +116,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/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 ae03fdb59..a8066f5db 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( IUiTargetResolver targetResolver, IUiAutomation 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); @@ -260,7 +276,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/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 546c191e4..c8dbd8cea 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( IUiAutomation 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); @@ -171,7 +181,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio 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..c9071f3d5 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/UiRecordCommand.cs @@ -44,8 +44,11 @@ public UiRecordCommand() public class Handler( IUiTargetResolver targetResolver, IUiRecordingService recordingService, + IWindowCapture windowCapture, + ISystemUiQuery systemQuery, 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 +57,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 +121,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,60 +129,165 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return 1; } - // Set _stdinMonitorStopped before disposing this source. - var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - _stdinMonitorStopped = false; + // 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 { - // Resolve output path inside error handling so path errors produce structured output. - string filePath; - string? 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))) { - // 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; + 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; + } - 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; - } - } + fullPath = Path.GetFullPath(candidate); + framesDirectory = frames ? GetFramesDirectory(fullPath) : null; - var dir = Path.GetDirectoryName(filePath); - if (dir is not null) - { - Directory.CreateDirectory(dir); - } + 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; } - catch (Exception pathEx) + + if (framesDirectory is not null && Path.Exists(framesDirectory)) { - UiJsonError.Emit(json, UiJsonError.CodeInvalidArguments, $"Invalid output path: {pathEx.Message}"); - logger.LogError("{Symbol} Invalid output path: {Message}", UiSymbols.Error, pathEx.Message); + 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); + 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; + try + { + // 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; + if (ValidateRecordingOutput( + output ?? $"recording-{DateTime.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}.mp4", + frames, + json, + parseResult.InvocationConfiguration.Error, + out filePath, + out framesDirectory) is { } outputFailure) + { + return outputFailure; + } + var uiTarget = await targetResolver.ResolveAsync(app, window, cancellationToken); var isStdinRedirected = s_isInputRedirectedOverride?.Invoke() ?? Console.IsInputRedirected; @@ -231,7 +343,21 @@ 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, 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. + var allWarnings = coordinationWarning is null + ? result.Warnings + : [.. result.Warnings ?? [], coordinationWarning]; if (json) { @@ -251,7 +377,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 +386,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 +457,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 +491,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 +503,158 @@ 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, + 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. + 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)) + { + if (!TargetStillValid()) + { + return (null, null); + } + + 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) + { + // 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); + 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 + { + 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 + // 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..5aae841ac 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,37 @@ 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. + /// + /// + /// 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; + + 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 +79,125 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio UiErrors.MissingApp(logger, json); return 1; } - var output = parseResult.GetValue(SharedUiOptions.OutputOption); - var captureScreen = parseResult.GetValue(SharedUiOptions.CaptureScreenOption); - var focus = parseResult.GetValue(SharedUiOptions.FocusOption); + // 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 { - // Screenshot handles multi-window discovery itself (avoids duplicate warning from session resolution) - if (selector is null) + if (candidate.Length > 0 + && (candidate.EndsWith(Path.DirectorySeparatorChar) || candidate.EndsWith(Path.AltDirectorySeparatorChar))) { - 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); - } + return InvalidOutput($"'{candidate}' names a directory, not a file."); } - // Single window capture (or element crop) - var singleSession = await targetResolver.ResolveAsync(app, window, cancellationToken); + var fullPath = Path.GetFullPath(candidate); - // Even for single-window session, check for owned dialogs - if (selector is null) + if (Directory.Exists(fullPath)) { - 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 InvalidOutput($"'{fullPath}' is an existing directory."); } - 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)); + var dir = Path.GetDirectoryName(fullPath); if (dir is not null) { Directory.CreateDirectory(dir); } - await File.WriteAllBytesAsync(filePath, pngBytes, cancellationToken); - var absolutePath = Path.GetFullPath(filePath); - if (json) + 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) + { + 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 + { + 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)) + { + pass = await CaptureUnderSectionAsync( + 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 + // 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 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; + return earlyExit; } - 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,23 +205,161 @@ 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( - List<(nint Hwnd, int Pid, string Title)> windows, + /// 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); + + /// + /// 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, + 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, + 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 (expandsToSeveralWindows) + { + 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, errorOut, ct).ConfigureAwait(false); + } + } + + var singleTarget = await targetResolver.ResolveAsync(app, window, ct).ConfigureAwait(false); + + // Even for a single-window session, check for owned dialogs. + if (expandsToSeveralWindows) + { + var targetWindowHwnd = (nint)singleTarget.WindowHandle; + 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 = ToCandidates(appWindows, ownedWindows); + return await CaptureWindowsAsync( + allWindows, singleTarget, json, captureScreen, focus, errorOut, 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 windows, UiTarget uiTarget, - string? output, bool json, bool captureScreen, bool focus, + TextWriter errorOut, CancellationToken ct) { - var filePath = output ?? "screenshot.png"; + 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 => @@ -163,23 +373,56 @@ 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) { 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. 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 + ? "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 windowSession = new UiTarget + 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 + 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 +440,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 +469,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 ?? DefaultOutputFileName; + 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 +512,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; @@ -316,7 +592,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; @@ -363,9 +639,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/Commands/UiScrollCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiScrollCommand.cs index f302f42e2..9d43c4e3b 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,29 @@ 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"; + + /// + /// 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.TurnShared; + + protected override int? Preflight(ParseResult parseResult) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); @@ -107,6 +124,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 +154,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 +161,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); - } + int centerX; + int centerY; - // 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")) + 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 - // 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; - } + 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; - // 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; + if (!DesktopTargetValidation.TryConfirmTargetWindow( + systemQuery, targetHwnd, uiTarget.ProcessId, logger, json, "scroll --wheel", parseResult.InvocationConfiguration.Error)) + { + return 1; + } - // Final foreground gate after the awaited confirm read (focus could shift during it). - if (!foregroundGuard.TryEnsureForeground(targetHwnd, logger, json, "scroll --wheel")) - { - 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); - // --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); + 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); + } } else { @@ -222,7 +247,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 9ab2cb489..9ab2648bd 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,20 @@ public class Handler( IUiAutomation uiAutomation, IUiSelectorParser selectorParser, 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, 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) { var json = parseResult.GetValue(WinAppRootCommand.JsonOption); var selectorStr = parseResult.GetValue(SharedUiOptions.SelectorArgument); @@ -53,6 +65,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); @@ -84,7 +107,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/UiSearchCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiSearchCommand.cs index 9bcb36dbd..fb9762e90 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( IUiAutomation uiAutomation, IUiSelectorParser selectorParser, 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 uiTarget = await targetResolver.ResolveAsync(app, window, cancellationToken); @@ -119,7 +137,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/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 fca36d1a6..378a4bd71 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,40 @@ public class Handler( IUiAutomation uiAutomation, IUiSelectorParser selectorParser, 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"; + + /// + /// 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.TurnShared; + + 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 +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 value = parseResult.GetValue(SharedUiOptions.ValueArgument)!; + try { var uiTarget = await targetResolver.ResolveAsync(app, window, cancellationToken); @@ -96,7 +122,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/UiStatusCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/UiStatusCommand.cs index 18d339d18..ad6a5c365 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( IUiTargetResolver targetResolver, 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 uiTarget = await targetResolver.ResolveAsync(app, window, cancellationToken); @@ -76,7 +92,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio } 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 cc6ec90d7..8a7873705 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( IUiSelectorParser selectorParser, 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 { @@ -109,8 +124,11 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio { 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; } @@ -223,19 +241,13 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio } 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); 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/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/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/DesktopTargetValidation.cs b/src/winapp-CLI/WinApp.Cli/Helpers/DesktopTargetValidation.cs new file mode 100644 index 000000000..c8aa409bc --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/DesktopTargetValidation.cs @@ -0,0 +1,181 @@ +// 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) + { + 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 TargetWindowState.Valid; + } + + var actualProcessId = systemQuery.GetProcessIdForWindow(hwnd); + if (actualProcessId == 0) + { + return TargetWindowState.Gone; + } + + if (expectedProcessId > 0 + && actualProcessId != (uint)expectedProcessId + && !IsOwnedByExpectedProcess(systemQuery, hwnd, expectedProcessId)) + { + return TargetWindowState.Recycled; + } + + return TargetWindowState.Valid; + } + + /// + /// 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. + /// + /// + /// 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. + /// + /// + 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/HostBuilderExtensions.cs b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs index b802eecb5..703e1e4fc 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; @@ -69,6 +70,15 @@ 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() .AddSingleton(); } @@ -123,6 +133,7 @@ public static IServiceCollection ConfigureCommands(this IServiceCollection servi .UseCommandHandler() .UseCommandHandler() .UseCommandHandler() + .UseCommandHandler() .ConfigureCommand(); } 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..f89546bb4 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/IDesktopForegroundService.cs @@ -0,0 +1,87 @@ +// 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 (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); + + /// 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 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)); + + 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 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 new file mode 100644 index 000000000..e49526ec4 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Helpers/UiCoordinatedAction.cs @@ -0,0 +1,100 @@ +// 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); + + /// + /// Whether belongs to coordination and must escape a handler's catch-all. + /// + /// + /// 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 + /// 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) + { + 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 7f5043401..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))] @@ -46,6 +47,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 +222,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 @@ -263,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/Helpers/UiJsonError.cs b/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonError.cs index ba8e78c01..d82296a1b 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonError.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/UiJsonError.cs @@ -36,11 +36,16 @@ internal static class UiJsonError /// 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 +59,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 0d06cbabc..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 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/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/IInteractiveDesktopLock.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs new file mode 100644 index 000000000..48b5037d6 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/IInteractiveDesktopLock.cs @@ -0,0 +1,116 @@ +// 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. + /// + /// + /// + /// 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); +} + +/// The workflow turn a coordinated command is executing under. +internal interface IUiTurn : IDesktopSection +{ + /// 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; } +} + +/// 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). +/// +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); + + /// + /// 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/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..0d98f3f94 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopLock.cs @@ -0,0 +1,961 @@ +// 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; + + /// + /// 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; + + // 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 IParticipantSignals _signals; + private readonly IAnsiConsole _console; + private readonly ILogger _logger; + private readonly IMonotonicClock _clock; + private readonly InteractiveDesktopScheduler _scheduler; + + public InteractiveDesktopLock( + IInteractiveDesktopStateStore store, + IInteractiveDesktopPaths paths, + IParticipantRegistry participants, + IUiOwnerResolver ownerResolver, + IProcessInspector processInspector, + IMonotonicClock clock, + IPollDelay pollDelay, + IParticipantSignals signals, + IAnsiConsole console, + ILogger logger) + { + _store = store; + _paths = paths; + _participants = participants; + _ownerResolver = ownerResolver; + _processInspector = processInspector; + _pollDelay = pollDelay; + _signals = signals; + _console = console; + _logger = logger; + _clock = clock; + _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); + + /// + /// 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. + // `||` 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)) + { + 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). + /// + 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 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( + 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); + } + } + + /// 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); + } + + /// + /// 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, + UiOwnerIdentity owner, + UiParticipantIdentity participant, + UiTurnMode mode, + UiCoordinationOutputMode outputMode, + ParseResult parseResult) : IUiTurn, IDisposable + { + 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. + /// + /// + /// 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; + + /// + /// 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; } + + /// + /// Publishes a mutated state and wakes every participant the mutation made runnable, skipping + /// this command itself. + /// + private void PublishAndSignal( + InteractiveDesktopState state, + HashSet<(int Pid, long StartTicksUtc)> runnableBefore) + => coordinator.PublishAndSignal( + state, runnableBefore, (participant.ProcessId, participant.StartTicksUtc)); + + 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); + + // "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 + { + await ReleaseAllSectionsAsync().ConfigureAwait(false); + } + } + 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; + EmitCancellation(cancelledWhileQueued: _waitWatch.IsRunning); + 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!; + + // 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, runnableBefore); + return; + } + + RegisterParticipating(state, runnableBefore); + } + + /// + /// 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, HashSet<(int Pid, long StartTicksUtc)> runnableBefore) + { + 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) + { + PublishAndSignal(state, runnableBefore); + } + + _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); + + if (admission.Admission == UiAdmission.Detached) + { + // BeginObserve re-normalizes, so ownership can lapse between the check above and here — + // 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(); + _lease = null; + _detached = true; + _turnAction = UiTurnAction.Detached; + + if (changed || _recoveredFromCorruption) + { + PublishAndSignal(state, runnableBefore); + } + + return; + } + + _turnAction = admission.TurnAction; + _turnStartedTick64 = TurnStartTick(state); + PublishAndSignal(state, runnableBefore); + } + + private void RegisterParticipating(InteractiveDesktopState state, HashSet<(int Pid, long StartTicksUtc)> runnableBefore) + { + _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; + _turnStartedTick64 = admission.Admission == UiAdmission.GlobalWaiter + ? null + : TurnStartTick(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) + { + _waitWatch.Start(); + } + } + + /// + /// 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) + { + return; + } + + var reporter = new UiCoordinationWaitReporter( + coordinator._console, outputMode, participant.Operation); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + WaitPlan plan; + 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!; + var runnableBefore = InteractiveDesktopScheduler.RunnableParticipants(state); + if (coordinator._scheduler.Normalize(state, _probe) || read.RecoveredFromCorruption) + { + // 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); + } + + // 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 }) + { + _waitWatch.Stop(); + 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; + } + + plan = BuildWaitPlan(state, entry, reporter.IsReportDue(_waitWatch.ElapsedMilliseconds)); + } + + if (plan.Diagnostics is { } diagnostics) + { + reporter.ReportIfDue(_waitWatch.ElapsedMilliseconds, diagnostics); + } + + // 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) + { + // 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. 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); + + 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); + } + 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); + } + + 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) + { + // 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); + PublishAndSignal(state, runnableBefore); + } + } + 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; + } + } + + /// + /// 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; + + 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); + } + + 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, + message, + errorOut: parseResult.InvocationConfiguration.Error, + coordination: new UiCoordinationInfo + { + WaitedMs = waitedMs, + QueuePosition = queuePosition, + }); + + if (!outputMode.Json && !outputMode.Quiet) + { + 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); + } + } + } + + 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, + 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; + } + + /// + /// 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; + + // 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(); + } + + 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..892ae9c0e --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopPaths.cs @@ -0,0 +1,564 @@ +// 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. + /// + /// + /// 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-"; + 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); + 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 (IsFileReachableOnlyByThisUser(file.GetAccessControl(), currentUser)) + { + 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); + 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 the same fully qualified local directory for every winapp process on this desktop."); + } + + 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 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 + // 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. + /// + /// + /// + /// 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; + 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()); + + // 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%."); + } + + // 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) + { + // 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%."); + } + } + + /// + /// 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 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. + /// + /// This is the strict directory rule. Files use + /// , which differs for reasons documented there. + /// + /// + internal static bool IsCurrentUserOnly(FileSystemSecurity 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 + // 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..8da6a8adf --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopScheduler.cs @@ -0,0 +1,587 @@ +// 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); +} + +/// 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, 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 |= ExpireIdleTurn(state); + changed |= PromoteOldestWaiter(state); + changed |= AbsorbSameOwnerWaiters(state); + changed |= ApplyOwnerLocalEligibility(state); + 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. + /// + 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) + { + // 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); + + // 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) + { + // 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, + handoff ? UiTurnAction.HandoffAfterIdle : 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, + Operation = participant.Operation, + Mode = mode, + }); + + return new UiAdmissionResult( + UiAdmission.GlobalWaiter, + ticket, + UiTurnAction.Queued, + 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; + /// 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, + UiOwnerIdentity owner, + bool renewGrace) + { + RemoveParticipantEntries(state, participant); + + if (state.Owner is not null && OwnerMatches(state.Owner, owner)) + { + // 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); + } + + /// + /// 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. + /// + /// + /// 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; + 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; + } + + /// + /// 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 + { + 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, + }; + + 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; + } + + /// + /// Whether the idle turn expired. + /// + 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; + state.TurnStartedTick64 = 0; + return true; + } + + private bool PromoteOldestWaiter(InteractiveDesktopState state) + { + 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. + // + // 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) + { + return false; + } + + ClaimTurn(state, new OwnerRecord + { + Kind = oldest.OwnerKind, + Key = oldest.OwnerKey, + }); + 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; + } + + /// + /// 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..a48d5f4e5 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopState.cs @@ -0,0 +1,201 @@ +// 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. +/// +/// 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 +{ + /// The schema version this binary reads and writes. + 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; } + + /// + /// 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. + /// + [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. + 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. + /// + [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. + [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, + TurnStartedTick64 = 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 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. + [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. A command's mode is fixed + /// before it registers, so a ticket is never assigned after the fact. + /// + 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. + [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. + [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. + [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..d1baa7736 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/InteractiveDesktopStateStore.cs @@ -0,0 +1,493 @@ +// 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 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( + 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) + { + // 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); + } + + 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(); + } + + // 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 + { + 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) + { + // 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); + } + + 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); + } + + /// + /// 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. + /// + /// + /// 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 (HasLiveTurnEvidence()) + { + throw CannotRebuildState("unreadable"); + } + + 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); + } + + /// + /// 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 = TryFormatIdleExpiry(state); + + 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/ParticipantRegistry.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ParticipantRegistry.cs new file mode 100644 index 000000000..90a61bff1 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ParticipantRegistry.cs @@ -0,0 +1,198 @@ +// 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 (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) + { + // 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/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/ProcessInspector.cs b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ProcessInspector.cs new file mode 100644 index 000000000..b91d8d17c --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/ProcessInspector.cs @@ -0,0 +1,98 @@ +// 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: 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 liveness answers change under a test while it runs. 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; } + + /// + /// 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 . +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; + + 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..c8678e3c6 --- /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 (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..4e8f52f71 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationTypes.cs @@ -0,0 +1,136 @@ +// 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, +/// 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 +{ + /// 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 + /// 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"; + + /// + /// 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"; +} + +/// +/// 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 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 behind another owner before acquiring the turn. + Queued, + + /// + /// 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. + 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 workflow 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: 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) + { + 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..efc939b9a --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiCoordinationWaitReporter.cs @@ -0,0 +1,95 @@ +// 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) +{ + /// 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; + + /// + /// 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. + /// + 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"; + + return "[grey]Waiting for the desktop for " + seconds + "s — " + + Markup.Escape(operation) + "; " + active + "; " + + $"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 new file mode 100644 index 000000000..50bdfe0c7 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiOwnerResolver.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +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_WORKFLOW_ID never leaves this process. +/// +/// How the owner was resolved. +/// Lowercase hex SHA-256 of the domain-separated owner payload. +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. +internal interface IUiOwnerResolver +{ + /// + /// Resolves the owner from WINAPP_UI_WORKFLOW_ID, or mints a unique anonymous one-command + /// owner when the variable is absent. + /// + /// + /// WINAPP_UI_WORKFLOW_ID is present but invalid. Thrown before any UI side effect. + /// + UiOwnerIdentity Resolve(); +} + +/// +internal sealed class UiOwnerResolver : IUiOwnerResolver +{ + /// + /// 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 MaxWorkflowIdLength = 256; + + 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(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 ResolveWorkflow(string raw) + { + // An explicitly-set-but-blank value is a scripting mistake (an unset variable expanded to ""), + // 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.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 > MaxWorkflowIdLength) + { + throw new UiCoordinationException( + UiCoordinationErrorCodes.InvalidWorkflowId, + $"{WorkflowIdVariable} is longer than {MaxWorkflowIdLength} characters.", + $"Set {WorkflowIdVariable} to a short opaque value such as a GUID."); + } + + 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."); + } + } + + /// + /// 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. + /// + /// + /// 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(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 + /// the same shell, which is exactly what makes each one a self-contained one-shot. + /// + 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..9c374442f --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/InteractiveDesktop/UiTurnMode.cs @@ -0,0 +1,83 @@ +// 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 +{ + /// + /// 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 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, + + /// + /// 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. +/// +/// 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_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("workflow")] + Workflow, + + /// + /// 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, +} + +/// 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/Telemetry/Events/CommandCompletedEvent.cs b/src/winapp-CLI/WinApp.Cli/Telemetry/Events/CommandCompletedEvent.cs index 7cebec793..87d971a67 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 owner was resolved: Workflow (from WINAPP_UI_WORKFLOW_ID) 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-CLI/WinApp.UIAutomation.Recording/PACKAGE.md b/src/winapp-CLI/WinApp.UIAutomation.Recording/PACKAGE.md index 0267f6e11..c3e887aa2 100644 --- a/src/winapp-CLI/WinApp.UIAutomation.Recording/PACKAGE.md +++ b/src/winapp-CLI/WinApp.UIAutomation.Recording/PACKAGE.md @@ -3,6 +3,14 @@ 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 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 dotnet add package Microsoft.Extensions.DependencyInjection diff --git a/src/winapp-CLI/WinApp.UIAutomation.Recording/UiRecordingService.cs b/src/winapp-CLI/WinApp.UIAutomation.Recording/UiRecordingService.cs index 1863e1512..9acce7b08 100644 --- a/src/winapp-CLI/WinApp.UIAutomation.Recording/UiRecordingService.cs +++ b/src/winapp-CLI/WinApp.UIAutomation.Recording/UiRecordingService.cs @@ -96,6 +96,23 @@ 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. + // + // 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 " + + "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); @@ -273,6 +290,23 @@ 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. + // + // 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 " + + "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-CLI/WinApp.UIAutomation.TestSupport/FakeUiServices.cs b/src/winapp-CLI/WinApp.UIAutomation.TestSupport/FakeUiServices.cs index f438119ce..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); } @@ -206,9 +218,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 +234,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 +478,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 +516,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.TestSupport/UiaTestFixture.cs b/src/winapp-CLI/WinApp.UIAutomation.TestSupport/UiaTestFixture.cs index 862edaf5a..f97853baf 100644 --- a/src/winapp-CLI/WinApp.UIAutomation.TestSupport/UiaTestFixture.cs +++ b/src/winapp-CLI/WinApp.UIAutomation.TestSupport/UiaTestFixture.cs @@ -62,6 +62,13 @@ public 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.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 new file mode 100644 index 000000000..e35a64c7d --- /dev/null +++ b/src/winapp-CLI/WinApp.UIAutomation.Tests/CaptureForegroundSafetyTests.cs @@ -0,0 +1,206 @@ +// 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)); + } + + // ------------------------------------------- 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) + .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 @@ + + 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/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/PACKAGE.md b/src/winapp-CLI/WinApp.UIAutomation/PACKAGE.md index daadbb45d..8aed94657 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 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 dotnet add package Microsoft.Extensions.DependencyInjection @@ -88,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 47ec0cb50..4bf4f3e0b 100644 --- a/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.Screenshot.cs +++ b/src/winapp-CLI/WinApp.UIAutomation/Services/UiAutomationService.Screenshot.cs @@ -92,6 +92,24 @@ 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. + // + // 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 " + + "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); } diff --git a/src/winapp-npm/README.md b/src/winapp-npm/README.md index 09209a34a..2bf09b64c 100644 --- a/src/winapp-npm/README.md +++ b/src/winapp-npm/README.md @@ -85,6 +85,55 @@ 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 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. + ## 🔧 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..51a436927 100644 --- a/src/winapp-npm/scripts/generate-commands.mjs +++ b/src/winapp-npm/scripts/generate-commands.mjs @@ -249,6 +249,29 @@ 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(' * 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. */'); @@ -280,7 +303,11 @@ 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(' if (opts.workflowId !== undefined) result.workflowId = opts.workflowId;'); + L(' return result;'); L('}'); L(); L('async function execCommand(args: string[], opts: CommonOptions): Promise {'); diff --git a/src/winapp-npm/scripts/generate-docs.mjs b/src/winapp-npm/scripts/generate-docs.mjs index f429428c4..789fef8b2 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'); @@ -31,7 +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']); +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`, `workflowId`)'; // --------------------------------------------------------------------------- // Create TypeScript program from tsconfig.json @@ -181,12 +186,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 +210,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 +273,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 +384,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/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/src/ui-record-guard.ts b/src/winapp-npm/src/ui-record-guard.ts index 77cc2f672..cb1f76825 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,10 @@ 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; + if (options.workflowId !== undefined) captureOpts.workflowId = options.workflowId; const result = await callWinappCliCapture(args, captureOpts); return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; } @@ -147,7 +154,10 @@ 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; + 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/src/winapp-cli-utils.ts b/src/winapp-npm/src/winapp-cli-utils.ts index d7607a333..295cbf996 100644 --- a/src/winapp-npm/src/winapp-cli-utils.ts +++ b/src/winapp-npm/src/winapp-cli-utils.ts @@ -5,8 +5,82 @@ 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'; + +/** + * 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])|(? { - const { exitOnError = false } = options; + const { exitOnError = false, signal, workflowId } = options; const winappCliPath = getWinappCliPath(); return new Promise((resolve, reject) => { @@ -57,32 +141,55 @@ export async function callWinappCli(args: string[], options: CallWinappCliOption stdio: 'inherit', cwd: process.cwd(), shell: false, - env: { - ...process.env, - WINAPP_CLI_CALLER: WINAPP_CLI_CALLER_VALUE, - }, + signal, + env: childEnv(workflowId), }); + // Node emits BOTH events for one aborted spawn: 'error' carrying the AbortError, then 'close' + // with a non-zero code. Rejecting from the first and falling into the second is not harmless, + // because the close path may call process.exit — so an aborted call would hand the caller a + // rejection to handle and then terminate the whole host process out from under it. Every way + // this call can finish therefore goes through one gate that runs at most once. + let settled = false; + const settle = (finish: () => 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) => { - 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}`)); - } + 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}`)); + } + }); }); }); } @@ -95,7 +202,7 @@ export async function callWinappCliCapture( args: string[], options: CallWinappCliCaptureOptions = {} ): Promise { - const { cwd = process.cwd() } = options; + const { cwd = process.cwd(), signal, workflowId } = options; const winappCliPath = getWinappCliPath(); return new Promise((resolve, reject) => { @@ -106,37 +213,58 @@ export async function callWinappCliCapture( stdio: ['pipe', 'pipe', 'pipe'], cwd, shell: false, - env: { - ...process.env, - WINAPP_CLI_CALLER: WINAPP_CLI_CALLER_VALUE, - }, + signal, + env: childEnv(workflowId), }); 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) => { - reject(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}`)); + }); }); }); } + +/** 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 9f09bc69e..982bd1498 100644 --- a/src/winapp-npm/src/winapp-commands.ts +++ b/src/winapp-npm/src/winapp-commands.ts @@ -35,6 +35,29 @@ 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; + /** + * 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. */ @@ -64,7 +87,11 @@ 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; + if (opts.workflowId !== undefined) result.workflowId = opts.workflowId; + return result; } async function execCommand(args: string[], opts: CommonOptions): Promise { @@ -1449,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/abort-signal.test.ts b/src/winapp-npm/test/abort-signal.test.ts new file mode 100644 index 000000000..f70189a41 --- /dev/null +++ b/src/winapp-npm/test/abort-signal.test.ts @@ -0,0 +1,223 @@ +// 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/ + ); +}); + +/** + * 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/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.'); +}); 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}`); + } + } +}); diff --git a/src/winapp-npm/test/ui-record-guard.test.ts b/src/winapp-npm/test/ui-record-guard.test.ts index 52e86b947..8caf9c07a 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,75 @@ 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: 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) { 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..4625a8eb9 --- /dev/null +++ b/src/winapp-npm/test/workflow-id.test.ts @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +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, 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 +// 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; + } + } +}); + +// 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(); +}); + +// `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(); +});