diff --git a/Tools/windows/Invoke-MatchedActionSupplement.ps1 b/Tools/windows/Invoke-MatchedActionSupplement.ps1 new file mode 100644 index 00000000..2140cdbc --- /dev/null +++ b/Tools/windows/Invoke-MatchedActionSupplement.ps1 @@ -0,0 +1,189 @@ +param( + [Parameter(Mandatory)][string]$Repository, + [Parameter(Mandatory)][string]$OwnedRoot, + [Parameter(Mandatory)][string]$ActionPlanPath, + [Parameter(Mandatory)][ValidatePattern('^[A-Fa-f0-9]{64}$')][string]$ActionPlanSha256 +) +$ErrorActionPreference='Stop' +$root=[IO.Path]::GetFullPath($OwnedRoot).TrimEnd('\') +$repositoryRoot=[IO.Path]::GetFullPath($Repository).TrimEnd('\') +$planPath=[IO.Path]::GetFullPath($ActionPlanPath) +if (-not $planPath.StartsWith($root + '\',[StringComparison]::OrdinalIgnoreCase) -or + (Get-FileHash $planPath -Algorithm SHA256).Hash -ne $ActionPlanSha256.ToUpperInvariant()) { + throw 'Action supplement plan path or hash mismatch.' +} +$plan=Get-Content $planPath -Raw | ConvertFrom-Json -DateKind String +if ($plan.status -cne 'PREPARED_NO_AUTHORITY' -or $plan.pngArtifacts -ne 0 -or + $plan.processLaunches -ne 0 -or @(Get-ChildItem $root -Recurse -File -Filter '*.png').Count -ne 0) { + throw 'Action supplement plan no-authority boundary differs.' +} +$head=(& git -C $repositoryRoot rev-parse HEAD).Trim() +$tree=(& git -C $repositoryRoot rev-parse 'HEAD^{tree}').Trim() +if ($head -cne $plan.sourceHead -or $tree -cne $plan.sourceTree -or + @(& git -C $repositoryRoot status --porcelain --untracked-files=all).Count -ne 0) { + throw 'Action supplement requires unchanged clean planned source.' +} +$modulePath=Join-Path $repositoryRoot 'Tools\windows\UiCaptureDriver.psm1' +$workerPath=Join-Path $repositoryRoot 'Tools\windows\Invoke-UiCaptureWorker.ps1' +$stubPath=Join-Path $repositoryRoot 'Tools\windows\Stub-Daemon.ps1' +foreach ($binding in @( + @($modulePath,$plan.moduleSha256), + @($workerPath,$plan.workerSha256), + @($stubPath,$plan.stubDaemonSha256), + @($plan.jobPath,$plan.jobSha256), + @($plan.preflightReceiptPath,$plan.preflightReceiptSha256), + @($plan.existingSceneManifestPath,$plan.existingSceneManifestSha256) +)) { + if (-not (Test-Path $binding[0] -PathType Leaf) -or + (Get-FileHash $binding[0] -Algorithm SHA256).Hash -ne [string]$binding[1]) { + throw "Action supplement bound input changed: $($binding[0])" + } +} +$granted=[DateTimeOffset]::UtcNow.AddSeconds(-1) +$authorization=[ordered]@{ + schemaVersion=1L + authorizationId=[guid]::NewGuid().ToString() + status='AUTHORIZED' + authorizedAction='matched-action-supplement' + group=$plan.group + sourceHead=$head + sourceTree=$tree + actionPlanSha256=$ActionPlanSha256.ToUpperInvariant() + moduleSha256=$plan.moduleSha256 + workerSha256=$plan.workerSha256 + jobSha256=$plan.jobSha256 + preflightReceiptSha256=$plan.preflightReceiptSha256 + pngAuthorized=$false + grantedUtc=$granted.ToString('o') + expiresUtc=$granted.AddMinutes( + [long]$plan.authorizationBounds.requestedMaxMinutes).ToString('o') + maxMinutes=[long]$plan.authorizationBounds.requestedMaxMinutes +} +$authorizationPath=Join-Path $root 'action-authorization.json' +if (Test-Path $authorizationPath) {throw 'Action supplement authorization already consumed.'} +[IO.File]::WriteAllText($authorizationPath,($authorization|ConvertTo-Json -Depth 20), + [Text.UTF8Encoding]::new($false)) +$output=Join-Path $root 'action-output' +if (Test-Path $output) {throw 'Action supplement output already exists.'} +$null=New-Item -ItemType Directory -Path $output + +Import-Module $modulePath -Force -WarningAction SilentlyContinue +. $workerPath -OwnedRoot $root -DefinitionsOnly +$job=Get-Content $plan.jobPath -Raw | ConvertFrom-Json -DateKind String +$matchedCapturePlan=Get-CapMatchedCapturePlan -Job $job +$fixturePath=Join-Path $root 'fixtures\fixture.json' +$peerScript=Join-Path $root 'inputs\offline-peer-source.ps1' +$runDirectory=Join-Path $root ('action-runs\' + [guid]::NewGuid().ToString('N')) +$null=New-Item -ItemType Directory -Path $runDirectory +$outputDirectory=$output +$workerLog=Join-Path $output 'action-worker.log' +[IO.File]::WriteAllText($workerLog,'',[Text.UTF8Encoding]::new($false)) +Set-CapTracePath -Path (Join-Path $output 'action-calls.jsonl') +$leaseWindow=Get-CapLeaseWindow -GrantedUtc $authorization.grantedUtc ` + -ExpiresUtc $authorization.expiresUtc -MaximumMinutes ([double]$authorization.maxMinutes) ` + -NowUtc ([DateTime]::UtcNow) +$leaseDeadlineUtc=$leaseWindow.WorkerDeadlineUtc +$focusedCapture=$true +$rehearsal=$false +$transitionRehearsal=$false +$fixtureHash=(Get-FileHash $fixturePath -Algorithm SHA256).Hash +$fixtureSourceHash=$plan.fixture.canonicalSourceSha256 +$fixtureMappingHash=$plan.fixture.mappingSha256 +$startedUtc=[DateTime]::UtcNow +$primaryFailure=$null +$independent=@() +try { + $matchedFixture=Get-Content $fixturePath -Raw | ConvertFrom-Json -DateKind String + Initialize-CapMatchedWorktrees -Fixture $matchedFixture + if ($plan.group -ceq 'TerminalActions') { + $bindings=@{} + foreach ($property in $plan.imageBindings.PSObject.Properties) { + $bindings[$property.Name]=[string]$property.Value + } + Invoke-CapMatchedScenes -Phase 'after' -SceneNames @($plan.expectedScenes) ` + -SkipRestart -ActionSupplement -ImageBindings $bindings + if ($actionSupplementReceipts.Count -ne [long]$plan.expectedReceiptCount -or + $zmxSeedReceipts.Count -ne [long]$plan.expectedReceiptCount) { + throw 'Terminal action supplement receipt or provider count differs.' + } + } else { + $receipt=Invoke-CapWireCompletionSupplement -StubDaemonPath $stubPath + $receipt | Add-Member -NotePropertyName imageBindingSha256 ` + -NotePropertyValue ([string]$plan.imageBindings.'s-goal-unwired-unselected') + $actionSupplementReceipts.Add($receipt) + } +} catch { + $primaryFailure=$_ +} finally { + foreach ($appRun in @($applicationRuns | Sort-Object {$_.Run.StartedUtc} -Descending)) { + if (-not $appRun.CleanupComplete) { + try {Close-CapGraphCodeApp -AppRun $appRun} + catch {$cleanupFailures.Add("Final app cleanup failed: $($_.Exception.Message)")} + } + } + foreach ($seedRun in @($seedRuns)) { + if (-not $seedRun.CleanupComplete) { + try {Close-CapOfflineSeed -SeedRun $seedRun} + catch {$cleanupFailures.Add("Final seed cleanup failed: $($_.Exception.Message)")} + } + } + $independent=@(Get-CimInstance Win32_Process | Where-Object { + $_.ExecutablePath -and $_.ExecutablePath.StartsWith($root + '\', + [StringComparison]::OrdinalIgnoreCase) + } | Select-Object ProcessId,Name,ExecutablePath) +} +$manifest=[ordered]@{ + schemaVersion=1L + status=$(if (-not $primaryFailure -and $cleanupFailures.Count -eq 0 -and + $independent.Count -eq 0) {'COMPLETE'} else {'FAILED'}) + group=$plan.group + sourceHead=$head + sourceTree=$tree + actionPlanSha256=$ActionPlanSha256.ToUpperInvariant() + authorizationSha256=(Get-FileHash $authorizationPath -Algorithm SHA256).Hash + startedUtc=$startedUtc.ToString('o') + finishedUtc=[DateTime]::UtcNow.ToString('o') + imageBindings=$plan.imageBindings + receipts=@($actionSupplementReceipts) + receiptCount=[long]$actionSupplementReceipts.Count + providerReceipts=@($zmxSeedReceipts) + cleanup=@($cleanupReceipts) + cleanupFailures=@($cleanupFailures) + independentSurvivors=@($independent) + failures=$(if ($primaryFailure) {@($primaryFailure.Exception.Message)} else {@()}) + pngCount=[long]@(Get-ChildItem $root -Recurse -File -Filter '*.png').Count +} +$manifestPath=Join-Path $output 'action-manifest.json' +[IO.File]::WriteAllText($manifestPath,($manifest|ConvertTo-Json -Depth 50), + [Text.UTF8Encoding]::new($false)) +$release=[ordered]@{ + schemaVersion=1L + status=$(if ($manifest.status -ceq 'COMPLETE') {'RELEASED_ZERO_SURVIVORS'} + elseif ($independent.Count -eq 0) {'FAILED_ZERO_SURVIVORS'} else {'CLEANUP_UNPROVEN'}) + cleanupState=$(if ($independent.Count -eq 0 -and $cleanupFailures.Count -eq 0) { + 'HELD_EXIT_AND_EOF_PROVEN' + } else {'UNPROVEN'}) + group=$plan.group + manifestSha256=(Get-FileHash $manifestPath -Algorithm SHA256).Hash + receiptCount=[long]$actionSupplementReceipts.Count + cleanupFailures=[long]$cleanupFailures.Count + independentSurvivors=[long]$independent.Count + releasedUtc=[DateTime]::UtcNow.ToString('o') +} +$releasePath=Join-Path $output 'action-release.json' +[IO.File]::WriteAllText($releasePath,($release|ConvertTo-Json -Depth 20), + [Text.UTF8Encoding]::new($false)) +if ($manifest.status -cne 'COMPLETE' -or $release.status -cne 'RELEASED_ZERO_SURVIVORS') { + throw "Action supplement failed: $($manifest.failures -join '; ') cleanup=$($cleanupFailures -join '; ') survivors=$($independent.Count)" +} +[ordered]@{ + group=$plan.group + manifestPath=$manifestPath + manifestSha256=$release.manifestSha256 + releasePath=$releasePath + releaseSha256=(Get-FileHash $releasePath -Algorithm SHA256).Hash + receiptCount=$manifest.receiptCount + providerReceiptCount=[long]$manifest.providerReceipts.Count + cleanupState=$release.cleanupState + pngCount=$manifest.pngCount +} | ConvertTo-Json -Depth 10 diff --git a/Tools/windows/Invoke-UiCaptureWorker.ps1 b/Tools/windows/Invoke-UiCaptureWorker.ps1 index 80d656be..51eb6c6a 100644 --- a/Tools/windows/Invoke-UiCaptureWorker.ps1 +++ b/Tools/windows/Invoke-UiCaptureWorker.ps1 @@ -40,6 +40,7 @@ $runs = [Collections.Generic.List[object]]::new() $seedRuns = [Collections.Generic.List[object]]::new() $applicationRuns = [Collections.Generic.List[object]]::new() $realRestarts = [Collections.Generic.List[object]]::new() +$actionSupplementReceipts = [Collections.Generic.List[object]]::new() $descendantIdentities = [Collections.Generic.List[object]]::new() $journalEntries = [Collections.Generic.List[object]]::new() $manifestPath = $null @@ -1948,8 +1949,8 @@ function Invoke-CapMatchedTabSelection { } } -function Read-CapMatchedPanes { - param($AppRun,[int]$ExpectedCount,[int]$FocusedIndex=-1) +function Get-CapMatchedPaneElements { + param($AppRun,[int]$ExpectedCount) $null=Assert-CapRootProcess -RootHandle $AppRun.Window -ProcessIdentity $AppRun.Run.Identity $rootElement=[Windows.Automation.AutomationElement]::FromHandle($AppRun.Window) $condition=[Windows.Automation.PropertyCondition]::new( @@ -1964,6 +1965,12 @@ function Read-CapMatchedPanes { Assert-CapWorkerDeadline 'matched terminal provider publication';Start-Sleep -Milliseconds 100 } while ([DateTime]::UtcNow -lt $deadline) if ($panes.Count -ne $ExpectedCount) {throw "Matched panes expected $ExpectedCount bounded terminals; found=$($panes.Count)."} + return @($panes) +} + +function Read-CapMatchedPanes { + param($AppRun,[int]$ExpectedCount,[int]$FocusedIndex=-1) + $panes=@(Get-CapMatchedPaneElements -AppRun $AppRun -ExpectedCount $ExpectedCount) if ($FocusedIndex -ge 0) { if ($FocusedIndex -ge $panes.Count) {throw 'Matched pane focus index is outside the owned topology.'} $panes[$FocusedIndex].SetFocus() @@ -1981,6 +1988,332 @@ function Read-CapMatchedPanes { }) } +function Get-CapMatchedProviderTopology { + param($AppRun,$SeedRun) + $tree=@(Get-CapTreeForApp -AppRun $AppRun) + $zmxPath=ConvertTo-CapWorkerImagePath (Join-Path $root 'runtime\zmx.exe') + $providers=@(foreach ($candidate in $tree) { + if ($candidate.ExpectedImagePath -ine $zmxPath) {continue} + $verified=Assert-CapProcessIdentity -Identity $candidate + if ($verified.State -ceq 'Exited') {continue} + $arguments=@([CapNative]::CommandArguments($verified.Metadata.CommandLine)) + [pscustomobject]@{ + pid=[long]$candidate.Pid + creationFileTimeUtc=[long]$candidate.CreationFileTimeUtc + role=$candidate.Role + command=$(if ($arguments.Count -gt 1) {$arguments[1]} else {$null}) + session=$(if ($arguments.Count -gt 2) {$arguments[2]} else {$null}) + held=$true + } + }) + return [pscustomobject]@{ + seedSession=$SeedRun.SessionName + seedJobId=$SeedRun.JobId + mountedProviders=$providers + providerCount=[long]$providers.Count + } +} + +function Invoke-CapMatchedTerminalInputChallenge { + param($AppRun,[int]$ExpectedPaneCount,[int]$FocusedIndex=0) + $panes=@(Get-CapMatchedPaneElements -AppRun $AppRun -ExpectedCount $ExpectedPaneCount) + if ($FocusedIndex -lt 0 -or $FocusedIndex -ge $panes.Count) { + throw 'Terminal input challenge focus index is outside the owned pane topology.' + } + $pane=$panes[$FocusedIndex] + $pane.SetFocus() + Start-Sleep -Milliseconds 100 + if (-not [GraphCodeUiaGateState]::IsForegroundWindow($AppRun.Window) -or + -not $pane.Current.HasKeyboardFocus) { + throw 'Terminal input challenge requires the owned shell and pane focus.' + } + $pattern=[Windows.Automation.TextPattern]$pane.GetCurrentPattern( + [Windows.Automation.TextPattern]::Pattern) + $before=[string]$pattern.DocumentRange.GetText(-1) + $marker='GC_SUPPLEMENT_INPUT_OK' + $command="echo $marker" + $characters=[GraphCodeUiaGateState]::SendUnicodeText($command) + $enter=[GraphCodeUiaGateState]::SendKeyInput(0x0D,1) + if ($characters -ne $command.Length -or $enter -ne 1) { + throw 'Terminal input challenge native SendInput count differs.' + } + $after=$null;$occurrences=0 + $deadline=[DateTime]::UtcNow.AddSeconds(5) + if ($deadline -gt $leaseDeadlineUtc.AddSeconds(-90)) {$deadline=$leaseDeadlineUtc.AddSeconds(-90)} + do { + $after=[string]$pattern.DocumentRange.GetText(-1) + $occurrences=[regex]::Matches($after,[regex]::Escape($marker)).Count + if ($occurrences -ge 2) {break} + Assert-CapWorkerDeadline 'terminal native input output';Start-Sleep -Milliseconds 100 + } while ([DateTime]::UtcNow -lt $deadline) + if ($occurrences -lt 2) { + throw 'Terminal input challenge did not observe both the shell command and shell-produced output.' + } + return [pscustomobject]@{ + marker=$marker + paneAutomationId=$pane.Current.AutomationId + paneNativeWindowHandle=[long]$pane.Current.NativeWindowHandle + focused=$true + foreground=$true + unicodeCharacters=[long]$characters + enterEvents=1L + markerOccurrences=[long]$occurrences + beforeTextLength=[long]$before.Length + afterTextLength=[long]$after.Length + evidence='NATIVE_SENDINPUT_TO_FOCUSED_LOCAL_CONSOLE_WITH_SHELL_OUTPUT' + } +} + +function Invoke-CapMatchedTerminalSceneSupplement { + param($AppRun,[string]$Scene,$SeedRun,[int]$TotalTabs,[int]$SelectedTab,[string]$AgentTitle) + $receipt=[ordered]@{ + scene=$Scene + imageBindingSha256=$null + actions=[Collections.Generic.List[object]]::new() + topologyBefore=$null + topologyAfter=$null + panes=$null + provider=$null + nativeInput=$null + actualFinalTabCloseResult=$null + } + $observeTabs={ + param([int]$Total,[int]$Selected) + $rows=@(Read-CapMatchedRows -AppRun $AppRun) + return Get-CapMatchedTabObservation -Rows $rows -ExpectedProcessId $AppRun.Run.Identity.Pid ` + -TotalTabs $Total -SelectedTab $Selected -AgentTitle $AgentTitle ` + -SourceCommit $job.binaries.after.source ` + -ExpectedDpi ([long]$job.captureContract.expectedWindowDpi) ` + -DpiBase ([long]$job.captureContract.dpiBase) + } + $receipt.topologyBefore=& $observeTabs $TotalTabs $SelectedTab + switch ($Scene) { + 's-tabs-backend-idle' { + $receipt.nativeInput=Invoke-CapMatchedTerminalInputChallenge -AppRun $AppRun -ExpectedPaneCount 1 + $null=Invoke-CapSceneAction -AppRun $AppRun -Action 'workspace-new-tab' + $TotalTabs=2;$SelectedTab=1 + $receipt.actions.Add([pscustomobject]@{action='New Tab';result='MOUNTED_SELECTED_SHELL'}) + } + 's-tabs-goal-needs-you' { + $null=Invoke-CapSceneAction -AppRun $AppRun -Action 'workspace-split-down' + $receipt.panes=@(Read-CapMatchedPanes -AppRun $AppRun -ExpectedCount 2 -FocusedIndex 1) + $receipt.actions.Add([pscustomobject]@{action='Split Down';paneCount=2;focusedPane=1}) + } + 's-tabs-two-agent-selected' { + $null=Invoke-CapSceneAction -AppRun $AppRun -Action 'workspace-select-tab' -SubjectId '1' -Name 'Shell' + $alternate=& $observeTabs 2 1 + $null=Invoke-CapSceneAction -AppRun $AppRun -Action 'workspace-select-tab' -SubjectId '0' -Name $AgentTitle + $restored=& $observeTabs 2 0 + $receipt.actions.Add([pscustomobject]@{action='Alternate tab selection';alternate=$alternate;restored=$restored}) + } + 's-tabs-two-shell-selected' { + $null=Invoke-CapSceneAction -AppRun $AppRun -Action 'workspace-select-tab' -SubjectId '0' -Name 'Shell' + $alternate=& $observeTabs 2 0 + $null=Invoke-CapSceneAction -AppRun $AppRun -Action 'workspace-select-tab' -SubjectId '1' -Name 'Shell' + $restored=& $observeTabs 2 1 + $receipt.actions.Add([pscustomobject]@{action='Alternate tab selection';alternate=$alternate;restored=$restored}) + } + 's-tabs-split-right-focused' { + $before=@(Read-CapMatchedPanes -AppRun $AppRun -ExpectedCount 2 -FocusedIndex 1) + $null=Invoke-CapSceneAction -AppRun $AppRun -Action 'workspace-split-down' + $after=@(Read-CapMatchedPanes -AppRun $AppRun -ExpectedCount 3 -FocusedIndex 2) + $receipt.panes=$after + $receipt.actions.Add([pscustomobject]@{action='Split Right then Split Down';before=$before;after=$after}) + } + 's-tabs-many-first' { + $roundTrip=Invoke-CapMatchedTabSelection -AppRun $AppRun -TotalTabs 12 ` + -SelectedTab 0 -TargetIndex 11 -AgentTitle $AgentTitle ` + -SourceCommit $job.binaries.after.source ` + -ExpectedDpi ([long]$job.captureContract.expectedWindowDpi) ` + -DpiBase ([long]$job.captureContract.dpiBase) + $receipt.actions.Add([pscustomobject]@{action='Many-first overflow selection';receipt=$roundTrip}) + $SelectedTab=11 + } + 's-tabs-overflow-last' { + $toFirst=Invoke-CapMatchedTabSelection -AppRun $AppRun -TotalTabs 12 ` + -SelectedTab 11 -TargetIndex 0 -AgentTitle $AgentTitle ` + -SourceCommit $job.binaries.after.source ` + -ExpectedDpi ([long]$job.captureContract.expectedWindowDpi) ` + -DpiBase ([long]$job.captureContract.dpiBase) + $back=Invoke-CapMatchedTabSelection -AppRun $AppRun -TotalTabs 12 ` + -SelectedTab 0 -TargetIndex 11 -AgentTitle $AgentTitle ` + -SourceCommit $job.binaries.after.source ` + -ExpectedDpi ([long]$job.captureContract.expectedWindowDpi) ` + -DpiBase ([long]$job.captureContract.dpiBase) + $receipt.actions.Add([pscustomobject]@{action='Overflow-last round trip';toFirst=$toFirst;back=$back}) + } + 's-tabs-hover-close' { + $null=Invoke-CapSceneAction -AppRun $AppRun -Action 'workspace-close-tab' -SubjectId '1' + $TotalTabs=1;$SelectedTab=0 + $receipt.actions.Add([pscustomobject]@{action='Per-tab hover close';closedIndex=1;remaining=1}) + } + 's-tabs-last-guard' { + $before=& $observeTabs 1 0 + $null=Invoke-CapSceneAction -AppRun $AppRun -Action 'workspace-close-tab' -SubjectId '0' + $after=& $observeTabs 1 0 + $receipt.actualFinalTabCloseResult=[pscustomobject]@{ + attempted=$true + result='REFUSED_TAB_RETAINED' + before=$before + after=$after + } + } + } + $receipt.topologyAfter=& $observeTabs $TotalTabs $SelectedTab + $receipt.provider=Get-CapMatchedProviderTopology -AppRun $AppRun -SeedRun $SeedRun + return [pscustomobject]$receipt +} + +function Invoke-CapWireCompletionSupplement { + param([Parameter(Mandatory)][string]$StubDaemonPath) + $phase='wire-completion' + $roots=Initialize-CapPhaseRoot -PhaseRoot (Join-Path $runDirectory $phase) + $projectRoot=Join-Path $root 'fixtures\wire-project' + $null=New-Item -ItemType Directory -Path $projectRoot -Force + $sourceId='60000000-0000-4000-8000-000000000001' + $targetId='60000000-0000-4000-8000-000000000002' + $pipe="gc-wire-$([guid]::NewGuid().ToString('N'))" + $resultPath=Join-Path $roots.Peer 'stub-result.json' + $stub=$null;$app=$null;$stubCleanup=$null + try { + $stub=Start-CapManagedProcess -FilePath (Join-Path $PSHOME 'pwsh.exe') -ArgumentList @( + '-NoProfile','-File',$StubDaemonPath, + '-PipeName',$pipe,'-ResultPath',$resultPath, + '-NodeAId',$sourceId,'-NodeBId',$targetId, + '-NodeATitle','Wire source','-NodeBTitle','Wire target', + '-ApplyGraphCommands','-StubProjectPath',$projectRoot + ) -WorkingDirectory $roots.Peer -Role 'wire-supplement-stub' + $runs.Add($stub) + $app=Start-CapGraphCodeApp -Roots $roots -Phase $phase -PipeName $pipe -SessionPrefix 'wire-supplement' + $app | Add-Member -NotePropertyMembers @{ + SActions=[Collections.Generic.List[object]]::new() + SObservation=$null + } + $null=Invoke-CapSceneAction -AppRun $app -Action 'open-project' -Name 'Stub project' + $null=Invoke-CapSceneAction -AppRun $app -Action 'actual-size' + $graph=Find-CapAutomationElement -RootHandle $app.Window -ProcessIdentity $app.Run.Identity ` + -ExpectedProcessId $app.Run.Identity.Pid -Name 'Graph' -AutomationId 'graph' + $children=@(Get-CapDirectChildren -Parent $graph) + $sourceCardId=Get-CapMatchedAutomationId "project-card:$projectRoot`:$sourceId" + $targetCardId=Get-CapMatchedAutomationId "project-card:$projectRoot`:$targetId" + $sourceCard=@($children | Where-Object { + $_.Current.AutomationId -ceq $sourceCardId -and $_.Current.Name -ceq 'Wire source'}) + $targetCard=@($children | Where-Object { + $_.Current.AutomationId -ceq $targetCardId -and $_.Current.Name -ceq 'Wire target'}) + if ($sourceCard.Count -ne 1 -or $targetCard.Count -ne 1) { + throw 'Wire supplement did not expose its exact source and target cards.' + } + $wireIdentity="wire-node:$projectRoot`:$sourceId" + $wireId=Get-CapMatchedAutomationId $wireIdentity + $wire=@($children | Where-Object { + $_.Current.AutomationId -ceq $wireId -and $_.Current.Name -ceq 'Wire it up'}) + if ($wire.Count -ne 1) {throw 'Wire supplement source omitted its exact Wire it up control.'} + $null=Invoke-CapSceneAction -AppRun $app -Action 'wire-node' -SubjectId $wireIdentity + $wireStatus=Wait-CapMatchedWireMode -AppRun $app ` + -DeadlineUtc (Get-CapActionDeadline -WorkerDeadlineUtc $leaseDeadlineUtc) + $targetBounds=$targetCard[0].Current.BoundingRectangle + $targetX=[int](($targetBounds.Left+$targetBounds.Right)/2) + $targetY=[int](($targetBounds.Top+$targetBounds.Bottom)/2) + if (-not [GraphCodeUiaGateState]::PostPointerClickScreen($app.Window,$targetX,$targetY)) { + throw 'Wire supplement target click was rejected.' + } + $form=[IntPtr]::Zero + $deadline=[DateTime]::UtcNow.AddSeconds(5) + do { + $form=[GraphCodeUiaGateState]::FindTopLevel('GraphCodeNativeForm',[uint32]$app.Run.Identity.Pid) + if ($form -ne [IntPtr]::Zero) {break} + Assert-CapWorkerDeadline 'Wire completion native form';Start-Sleep -Milliseconds 100 + } while ([DateTime]::UtcNow -lt $deadline) + if ($form -eq [IntPtr]::Zero) {throw 'Wire supplement target did not open the native edge form.'} + $formElement=[Windows.Automation.AutomationElement]::FromHandle($form) + $buttonCondition=[Windows.Automation.AndCondition]::new( + [Windows.Automation.PropertyCondition]::new( + [Windows.Automation.AutomationElement]::ControlTypeProperty, + [Windows.Automation.ControlType]::Button), + [Windows.Automation.PropertyCondition]::new( + [Windows.Automation.AutomationElement]::NameProperty,'Create')) + $create=$formElement.FindFirst([Windows.Automation.TreeScope]::Descendants,$buttonCondition) + if ($null -eq $create -or -not $create.Current.IsEnabled) { + throw 'Wire supplement native edge form omitted its enabled Create action.' + } + $create.GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern).Invoke() + $stubResult=$null + $deadline=[DateTime]::UtcNow.AddSeconds(5) + do { + if (Test-Path -LiteralPath $resultPath) { + try {$stubResult=Get-Content $resultPath -Raw | ConvertFrom-Json -DateKind String} + catch [Management.Automation.RuntimeException] {$stubResult=$null} + } + if ($stubResult -and $stubResult.appliedEdgeCreates.Count -eq 1 -and + $stubResult.edges.Count -eq 1) {break} + Assert-CapWorkerDeadline 'Wire completion graph readback';Start-Sleep -Milliseconds 100 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $stubResult -or $stubResult.appliedEdgeCreates.Count -ne 1 -or + $stubResult.edges.Count -ne 1) { + throw 'Wire supplement did not receive one applied edge and graph readback.' + } + $edge=$stubResult.edges[0] + if ($edge.from -cne $sourceId -or $edge.to -cne $targetId -or + $edge.kind -cne 'handoff' -or $edge.condition -cne 'always') { + throw 'Wire supplement created edge/spec differs from the native default form.' + } + $graph=Find-CapAutomationElement -RootHandle $app.Window -ProcessIdentity $app.Run.Identity ` + -ExpectedProcessId $app.Run.Identity.Pid -Name 'Graph' -AutomationId 'graph' + $after=@(Get-CapDirectChildren -Parent $graph) + $sourceAfter=@($after | Where-Object {$_.Current.AutomationId -ceq $sourceCardId}) + $targetAfter=@($after | Where-Object {$_.Current.AutomationId -ceq $targetCardId}) + $wireAfter=@($after | Where-Object {$_.Current.AutomationId -ceq $wireId}) + if ($sourceAfter.Count -ne 1 -or $targetAfter.Count -ne 1 -or $wireAfter.Count -ne 0) { + throw 'Wire supplement graph readback did not preserve cards and remove the source unwired control.' + } + $rootElement=[Windows.Automation.AutomationElement]::FromHandle($app.Window) + $status=$rootElement.FindFirst([Windows.Automation.TreeScope]::Descendants, + [Windows.Automation.PropertyCondition]::new( + [Windows.Automation.AutomationElement]::AutomationIdProperty,'status')) + if ($status -and $status.Current.ProcessId -ne $app.Run.Identity.Pid) { + throw 'Wire supplement status belongs to a foreign process.' + } + $receipt=[pscustomobject]@{ + group='WireCompletion' + projectPath=$projectRoot + sourceNodeId=$sourceId + targetNodeId=$targetId + sourceCardAutomationId=$sourceCardId + targetCardAutomationId=$targetCardId + wireAutomationId=$wireId + armedStatus=$wireStatus.Current.Name + targetScreenPoint=@($targetX,$targetY) + edgeId=$edge.id + edgeSpec=[pscustomobject]@{ + kind=$edge.kind;condition=$edge.condition + payloadTransform=$edge.payloadTransform + cycleGuard=$edge.cycleGuard + spawnTargetProjectPath=$edge.spawnTargetProjectPath + } + graphReadback=[pscustomobject]@{ + edgeCount=[long]$stubResult.edges.Count + sourceCardPreserved=$sourceAfter[0].Current.AutomationId + targetCardPreserved=$targetAfter[0].Current.AutomationId + unwiredControlRemoved=$true + status=$(if ($status) {$status.Current.Name} else {$null}) + } + requestIds=@($stubResult.appliedEdgeCreateRequests) + correlated=$stubResult.unansweredRequests.Count -eq 0 + } + return $receipt + } finally { + if ($app) {Close-CapGraphCodeApp -AppRun $app} + if ($stub) { + $stubCleanup=Stop-CapManagedProcess -Identity $stub.Identity -TimeoutMilliseconds 5000 + if (-not $stub.StdoutTask.Wait(2000) -or -not $stub.StderrTask.Wait(2000)) { + throw 'Wire supplement stub streams did not reach EOF.' + } + $stub.Identity.Process.Dispose() + } + if ($stubCleanup) {$cleanupReceipts.Add($stubCleanup)} + } +} + function Get-CapMatchedAttach { param($AppRun,[string]$NodeId) $tree=@(Get-CapTreeForApp -AppRun $AppRun) @@ -2191,7 +2524,9 @@ function Invoke-CapMatchedScenes { param( [Parameter(Mandatory)][string]$Phase, [Parameter()][string[]]$SceneNames = @((Get-CapMatchedSceneNames)[0..16]), - [switch]$SkipRestart + [switch]$SkipRestart, + [switch]$ActionSupplement, + [hashtable]$ImageBindings = @{} ) $allowed=@((Get-CapMatchedSceneNames)[0..16]) if ($SceneNames.Count -lt 1 -or @($SceneNames | Sort-Object -Unique).Count -ne $SceneNames.Count -or @@ -2210,7 +2545,7 @@ function Invoke-CapMatchedScenes { $roots=Initialize-CapPhaseRoot -PhaseRoot (Join-Path $runDirectory "phases\$Phase\$scene") $pipe="gc-s-$([guid]::NewGuid().ToString('N'))" $peer=Start-CapOfflinePeer -Roots $roots -PipeName $pipe -Phase "$Phase-$scene" - $app=$null;$seed=$null + $app=$null;$seed=$null;$supplementReceipt=$null try { $state=@($fixture.states | Where-Object scene -CEQ $scene) $workspace=$state.Count -eq 0 @@ -2447,16 +2782,41 @@ function Invoke-CapMatchedScenes { chromeControls=$chromeControls tabSelection=$tabSelection hover=if ($scene -ceq 's-tabs-hover-close') {'POSTED_WM_MOUSEMOVE_NOT_PHYSICAL_POINTER'} else {'NOT_REQUESTED'}} - Save-CapSceneImage -AppRun $app -SceneName $scene -ZoomEvidence $null -Phase $Phase + if ($ActionSupplement) { + if (-not $ImageBindings.ContainsKey($scene) -or + [string]$ImageBindings[$scene] -cnotmatch '^[A-Fa-f0-9]{64}$') { + throw "Terminal action supplement lacks an existing image binding for $scene." + } + $supplementReceipt=Invoke-CapMatchedTerminalSceneSupplement -AppRun $app ` + -Scene $scene -SeedRun $seed -TotalTabs $total -SelectedTab $selected -AgentTitle $title + $supplementReceipt.imageBindingSha256=[string]$ImageBindings[$scene] + $app.SObservation | Add-Member -NotePropertyName actionSupplement -NotePropertyValue $supplementReceipt + $actionSupplementReceipts.Add($supplementReceipt) + } else { + Save-CapSceneImage -AppRun $app -SceneName $scene -ZoomEvidence $null -Phase $Phase + } } Close-CapGraphCodeApp -AppRun $app -NormalExit + if ($supplementReceipt) { + $layoutFiles=@(Get-ChildItem -LiteralPath $roots.Local -Recurse -File -Filter '*.json' -ErrorAction SilentlyContinue | + Sort-Object FullName | ForEach-Object { + [pscustomobject]@{ + relativePath=[IO.Path]::GetRelativePath($roots.Local,$_.FullName) + sha256=(Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash + length=[long]$_.Length + } + }) + $supplementReceipt | Add-Member -NotePropertyName persistedLayoutFiles -NotePropertyValue $layoutFiles + } } finally { if ($app) {Close-CapGraphCodeApp -AppRun $app} if ($seed) {Close-CapOfflineSeed -SeedRun $seed} Close-CapPeer -PeerRun $peer -Label $pipe } } - if (-not $SkipRestart) {Invoke-CapMatchedRestartCapture -Phase $Phase -Fixture $fixture} + if (-not $SkipRestart -and -not $ActionSupplement) { + Invoke-CapMatchedRestartCapture -Phase $Phase -Fixture $fixture + } } function Set-CapMatchedTabHover { diff --git a/Tools/windows/Prepare-MatchedActionSupplement.ps1 b/Tools/windows/Prepare-MatchedActionSupplement.ps1 new file mode 100644 index 00000000..03706630 --- /dev/null +++ b/Tools/windows/Prepare-MatchedActionSupplement.ps1 @@ -0,0 +1,118 @@ +param( + [Parameter(Mandatory)][string]$Repository, + [Parameter(Mandatory)][string]$OwnedRoot, + [Parameter(Mandatory)][string]$FocusedPlanPath, + [Parameter(Mandatory)][ValidatePattern('^[A-Fa-f0-9]{64}$')][string]$FocusedPlanSha256, + [Parameter(Mandatory)][ValidateSet('WireCompletion','TerminalActions')][string]$Group, + [Parameter(Mandatory)][string]$ExistingSceneManifestPath, + [Parameter(Mandatory)][ValidatePattern('^[A-Fa-f0-9]{64}$')][string]$ExistingSceneManifestSha256, + [ValidateRange(3,30)][long]$AuthorizationMinutes = 20 +) +$ErrorActionPreference='Stop' +$root=[IO.Path]::GetFullPath($OwnedRoot).TrimEnd('\') +$repositoryRoot=[IO.Path]::GetFullPath($Repository).TrimEnd('\') +$focusedPath=[IO.Path]::GetFullPath($FocusedPlanPath) +$sceneManifestPath=[IO.Path]::GetFullPath($ExistingSceneManifestPath) +if (-not $focusedPath.StartsWith($root + '\',[StringComparison]::OrdinalIgnoreCase) -or + (Get-FileHash $focusedPath -Algorithm SHA256).Hash -ne $FocusedPlanSha256.ToUpperInvariant()) { + throw 'Action supplement requires the exact focused no-authority plan below its owned root.' +} +if ((Get-FileHash $sceneManifestPath -Algorithm SHA256).Hash -ne + $ExistingSceneManifestSha256.ToUpperInvariant()) { + throw 'Action supplement existing scene manifest hash mismatch.' +} +$focused=Get-Content $focusedPath -Raw | ConvertFrom-Json -DateKind String +$scenes=Get-Content $sceneManifestPath -Raw | ConvertFrom-Json -DateKind String +$expectedProfile=if ($Group -ceq 'WireCompletion') {'PostFixCards'} else {'TerminalChrome'} +if ($focused.status -cne 'PREPARED_NO_AUTHORITY' -or $focused.profile -cne $expectedProfile -or + $focused.pngArtifacts -ne 0 -or $focused.processLaunches -ne 0 -or + @(Get-ChildItem $root -Recurse -File -Filter '*.png').Count -ne 0) { + throw 'Action supplement focused plan profile or no-launch/no-PNG boundary differs.' +} +$head=(& git -C $repositoryRoot rev-parse HEAD).Trim() +$tree=(& git -C $repositoryRoot rev-parse 'HEAD^{tree}').Trim() +if ($head -cne $focused.sourceHead -or $tree -cne $focused.sourceTree -or + @(& git -C $repositoryRoot status --porcelain --untracked-files=all).Count -ne 0) { + throw 'Action supplement requires unchanged clean focused-plan source.' +} +$expectedScenes=if ($Group -ceq 'WireCompletion') { + @('s-goal-unwired-unselected') +} else { + @( + 's-tabs-agent-unknown','s-tabs-backend-idle','s-tabs-goal-needs-you','s-tabs-loop-running', + 's-tabs-two-agent-selected','s-tabs-two-shell-selected','s-tabs-split-right-focused', + 's-tabs-many-first','s-tabs-overflow-last','s-tabs-hover-close','s-tabs-last-guard' + ) +} +$bindings=[ordered]@{} +foreach ($scene in $expectedScenes) { + $rows=@($scenes.scenes | Where-Object canonicalScene -CEQ $scene) + if ($rows.Count -ne 1 -or $rows[0].sha256 -cnotmatch '^[A-Fa-f0-9]{64}$') { + throw "Action supplement existing image binding missing or duplicated: $scene" + } + $bindings[$scene]=[string]$rows[0].sha256 +} +$stubPath=Join-Path $repositoryRoot 'Tools\windows\Stub-Daemon.ps1' +$plan=[ordered]@{ + schemaVersion=1L + status='PREPARED_NO_AUTHORITY' + preparedUtc=[DateTimeOffset]::UtcNow.ToString('o') + group=$Group + ownedRoot=$root + repository=$repositoryRoot + sourceHead=$head + sourceTree=$tree + focusedPlanPath=$focusedPath + focusedPlanSha256=$FocusedPlanSha256.ToUpperInvariant() + focusedProfile=$focused.profile + jobPath=$focused.jobPath + jobSha256=$focused.jobSha256 + moduleSha256=$focused.moduleSha256 + workerSha256=$focused.workerSha256 + supervisorSha256=$focused.supervisorSha256 + preflightReceiptPath=$focused.preflightReceiptPath + preflightReceiptSha256=$focused.preflightReceiptSha256 + fixture=$focused.fixture + binaries=$focused.binaries + stubDaemonPath=$stubPath + stubDaemonSha256=(Get-FileHash $stubPath -Algorithm SHA256).Hash + existingSceneManifestPath=$sceneManifestPath + existingSceneManifestSha256=$ExistingSceneManifestSha256.ToUpperInvariant() + expectedScenes=$expectedScenes + imageBindings=$bindings + expectedReceiptCount=[long]$expectedScenes.Count + expectedActions=$(if ($Group -ceq 'WireCompletion') { + @('Wire it up','target selection','Create edge','graph readback') + } else { + @('New Tab','Split Right','Split Down','alternate selection/focus','per-tab close', + 'final-tab close','many-first','overflow-last','native input/output') + }) + authorizationBounds=[ordered]@{ + status='NOT_GRANTED' + requestedMaxMinutes=$AuthorizationMinutes + cleanupReserveSeconds=90L + } + cleanupExpectations=[ordered]@{ + heldExitAndEofRequired=$true + cleanupFailuresExpected=0L + pendingLaunchesExpected=0L + survivorsExpected=0L + } + pngArtifacts=0L + processLaunches=0L +} +$planPath=Join-Path $root 'action-plan.json' +if (Test-Path $planPath) {throw 'Action supplement plan already exists.'} +[IO.File]::WriteAllText($planPath,($plan|ConvertTo-Json -Depth 40),[Text.UTF8Encoding]::new($false)) +[ordered]@{ + status=$plan.status + group=$Group + actionPlanPath=$planPath + actionPlanSha256=(Get-FileHash $planPath -Algorithm SHA256).Hash + sourceHead=$head + sourceTree=$tree + expectedScenes=$expectedScenes + expectedReceiptCount=$plan.expectedReceiptCount + pngArtifacts=0L + processLaunches=0L +} | ConvertTo-Json -Depth 10 diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index bad61bd9..5005934d 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -714,6 +714,8 @@ function Assert-CapRejects { Invoke-CapTest 'journal native bindings match the exact job-ownership declaration contract' { $expected = @( 'GraphCodeUiaGateState|PostMessage|user32.dll|PostMessageA', + 'GraphCodeUiaGateState|ScreenToClient|user32.dll|ScreenToClient', + 'GraphCodeUiaGateState|SendKeyInputs|user32.dll|SendInput', 'GraphCodeUiaGateState|EnumWindows|user32.dll|EnumWindows', 'GraphCodeUiaGateState|GetWindowThreadProcessId|user32.dll|GetWindowThreadProcessId', 'GraphCodeUiaGateState|GetForegroundWindow|user32.dll|GetForegroundWindow', @@ -752,7 +754,7 @@ function Assert-CapRejects { 'CapProcessJob|SetHandleInformation|kernel32.dll|SetHandleInformation' ) $actual = @(Test-CapNativeBindings | ForEach-Object { "$($_.Type)|$($_.Method)|$($_.Library)|$($_.EntryPoint)" }) - Assert-CapTest ($actual.Count -eq 37 -and @(Compare-Object $expected $actual).Count -eq 0) 'Exact static37 export contract differs.' + Assert-CapTest ($actual.Count -eq 39 -and @(Compare-Object $expected $actual).Count -eq 0) 'Exact static39 export contract differs.' return @{ subcases = 36; evidence = 'API export resolution only, no native window or GUI action.' } } Invoke-CapTest 'legacy mutable seed journal proof is refused and exact numbered terminal proof is accepted' { @@ -2997,6 +2999,9 @@ function Invoke-CapMatchedContracts { $workerSource=[IO.File]::ReadAllText($workerPath) $prepareSource=[IO.File]::ReadAllText((Join-Path $driverDirectory 'Prepare-FocusedUiCapture.ps1')) $authorizeSource=[IO.File]::ReadAllText((Join-Path $driverDirectory 'Authorize-FocusedUiCapture.ps1')) + $actionPrepareSource=[IO.File]::ReadAllText((Join-Path $driverDirectory 'Prepare-MatchedActionSupplement.ps1')) + $actionInvokeSource=[IO.File]::ReadAllText((Join-Path $driverDirectory 'Invoke-MatchedActionSupplement.ps1')) + $splitDown=Get-CapActionSpec -Action 'workspace-split-down' Assert-CapTest ($supervisorSource.Contains("'FOCUSED_LIVE_CAPTURE'") -and $supervisorSource.Contains('FocusedAuthorizationPath') -and $supervisorSource.Contains('Assert-CapFocusedPrelaunchPaths') -and @@ -3006,6 +3011,15 @@ function Invoke-CapMatchedContracts { $workerSource.Contains('$images.Count -eq (Get-CapWorkerExpectedImageCount)') -and $workerSource.Contains('function Wait-CapMatchedWireMode') -and $workerSource.Contains('[GraphCodeUiaGateState]::PostKeyboard') -and + $workerSource.Contains('function Invoke-CapMatchedTerminalSceneSupplement') -and + $workerSource.Contains('function Invoke-CapWireCompletionSupplement') -and + $workerSource.Contains('NATIVE_SENDINPUT_TO_FOCUSED_LOCAL_CONSOLE_WITH_SHELL_OUTPUT') -and + $actionPrepareSource.Contains("status='PREPARED_NO_AUTHORITY'") -and + $actionPrepareSource.Contains("pngArtifacts=0L") -and + $actionInvokeSource.Contains('-DefinitionsOnly') -and + $actionInvokeSource.Contains("'RELEASED_ZERO_SURVIVORS'") -and + $splitDown.Name -ceq 'Split Down' -and + $splitDown.AutomationId -ceq (Get-CapMatchedAutomationId 'workspace-split-down:control') -and $workerSource.Contains("-Action 'attention-action'") -and $workerSource.Contains("fixtureIsolation='ONE_SCENE_ONE_DISPOSABLE_PHASE_ROOT'") -and $prepareSource.Contains("[switch]`$PlanOnly") -and diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index f20c4a3c..3592f7e5 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -20,8 +20,26 @@ using System; using System.Runtime.InteropServices; using System.Text; public static class GraphCodeUiaGateState { + [StructLayout(LayoutKind.Sequential)] + private struct Point { public int X, Y; } + [StructLayout(LayoutKind.Sequential)] + private struct KeybdInput { + public ushort VirtualKey, ScanCode; + public uint Flags, Time; + public UIntPtr ExtraInfo; + } + [StructLayout(LayoutKind.Sequential)] + private struct KeyInputRecord { + public uint Type; + public KeybdInput Key; + public uint Pad0, Pad1; + } [DllImport("user32.dll", SetLastError = true)] private static extern bool PostMessage(IntPtr window, uint message, UIntPtr wParam, IntPtr lParam); + [DllImport("user32.dll", SetLastError = true)] + private static extern bool ScreenToClient(IntPtr window, ref Point point); + [DllImport("user32.dll", SetLastError = true, EntryPoint = "SendInput")] + private static extern uint SendKeyInputs(uint count, KeyInputRecord[] inputs, int size); private delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); [DllImport("user32.dll")] private static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); @@ -55,6 +73,45 @@ public static class GraphCodeUiaGateState { public static bool IsForegroundWindow(IntPtr window) { return window != IntPtr.Zero && GetForegroundWindow() == window; } + public static bool PostPointerClickScreen(IntPtr window, int screenX, int screenY) { + var point = new Point { X = screenX, Y = screenY }; + if (window == IntPtr.Zero || !ScreenToClient(window, ref point)) return false; + var packed = (IntPtr)((point.Y << 16) | (point.X & 0xffff)); + return PostMessage(window, 0x0200, UIntPtr.Zero, packed) && + PostMessage(window, 0x0201, (UIntPtr)1, packed) && + PostMessage(window, 0x0202, UIntPtr.Zero, packed); + } + public static int SendUnicodeText(string text) { + if (String.IsNullOrEmpty(text)) return 0; + var inputs = new KeyInputRecord[text.Length * 2]; + int offset = 0; + foreach (char character in text) { + inputs[offset++] = new KeyInputRecord { + Type = 1, Key = new KeybdInput { ScanCode = character, Flags = 4 } + }; + inputs[offset++] = new KeyInputRecord { + Type = 1, Key = new KeybdInput { ScanCode = character, Flags = 6 } + }; + } + uint sent = SendKeyInputs((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(KeyInputRecord))); + if (sent != inputs.Length) return 0; + return text.Length; + } + public static int SendKeyInput(ushort virtualKey, int count) { + if (count <= 0) return 0; + var inputs = new KeyInputRecord[count * 2]; + for (int index = 0; index < count; index++) { + inputs[index * 2] = new KeyInputRecord { + Type = 1, Key = new KeybdInput { VirtualKey = virtualKey } + }; + inputs[index * 2 + 1] = new KeyInputRecord { + Type = 1, Key = new KeybdInput { VirtualKey = virtualKey, Flags = 2 } + }; + } + uint sent = SendKeyInputs((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(KeyInputRecord))); + if (sent != inputs.Length) return 0; + return count; + } public static bool PostHover(IntPtr window, int x, int y) { return PostMessage(window, 0x0200, UIntPtr.Zero, (IntPtr)((y << 16) | (x & 0xffff))); } @@ -1030,7 +1087,8 @@ function Get-CapActionSpec { param( [Parameter(Mandatory)][ValidateSet('actual-size', 'open-project', 'overview-destination', 'open-loop', 'file-exit', 'quick-chats-destination','open-quick-chat','workspace-show-graph','workspace-toggle-panel', - 'workspace-restart-session','workspace-new-tab','workspace-split-right','workspace-select-tab','workspace-close-tab', + 'workspace-restart-session','workspace-new-tab','workspace-split-right','workspace-split-down', + 'workspace-select-tab','workspace-close-tab', 'wire-node','mark-entry','attention-action')][string]$Action, [string]$Name, [string]$SubjectId @@ -1064,6 +1122,8 @@ function Get-CapActionSpec { AutomationIdPrefix=$null;Name='New Tab';CommandId=$null}} 'workspace-split-right' {return [pscustomobject]@{AutomationId=(Get-CapMatchedAutomationId 'workspace-split-right:control') AutomationIdPrefix=$null;Name='Split Right';CommandId=$null}} + 'workspace-split-down' {return [pscustomobject]@{AutomationId=(Get-CapMatchedAutomationId 'workspace-split-down:control') + AutomationIdPrefix=$null;Name='Split Down';CommandId=$null}} 'workspace-select-tab' { if ($SubjectId -cnotmatch '^(?:[0-9]|1[01])$' -or [string]::IsNullOrWhiteSpace($Name)) {throw 'Matched tab selection requires its bounded index and actual name.'} return [pscustomobject]@{AutomationId=(Get-CapMatchedAutomationId "workspace-tab:$SubjectId") @@ -1789,7 +1849,8 @@ function Invoke-CapAction { if ($Action -in @('reply', 'reclaim', 'keep')) { throw "Mutating graph action '$Action' is prohibited by the capture contract." } if ($Action -notin @('actual-size', 'open-project', 'overview-destination', 'open-loop', 'file-exit', 'quick-chats-destination','open-quick-chat','workspace-show-graph','workspace-toggle-panel', - 'workspace-restart-session','workspace-new-tab','workspace-split-right','workspace-select-tab','workspace-close-tab', + 'workspace-restart-session','workspace-new-tab','workspace-split-right','workspace-split-down', + 'workspace-select-tab','workspace-close-tab', 'wire-node','mark-entry','attention-action')) { throw "Unsupported capture action '$Action'." } @@ -1830,7 +1891,8 @@ function Invoke-CapAction { $null = Assert-CapRootProcess -RootHandle $RootHandle -ProcessIdentity $ProcessIdentity -WindowProcessResolver $WindowProcessResolver $null = Assert-CapUiaProcessIds -ExpectedProcessId $ProcessIdentity.Pid -ObservedProcessIds @([int]$current.ProcessId) if ($Action -in @('quick-chats-destination','open-quick-chat','workspace-show-graph','workspace-toggle-panel', - 'workspace-restart-session','workspace-new-tab','workspace-split-right','workspace-select-tab','workspace-close-tab', + 'workspace-restart-session','workspace-new-tab','workspace-split-right','workspace-split-down', + 'workspace-select-tab','workspace-close-tab', 'wire-node','mark-entry','attention-action')) { if (-not $current.IsEnabled) { throw 'D action target is disabled.' } if ($current.ControlType -ne [System.Windows.Automation.ControlType]::Button -and