From 0db94a7d4136292b1878f734134a9c0104aadb89 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 7 Oct 2026 04:06:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(diagnostic):=20=E5=AE=8C=E6=88=90=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=200B=20=E5=8E=9F=E7=94=9F=E9=94=AE=E7=9B=98=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E6=B6=88=E8=B4=B9=E6=8E=A2=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/analyze-input-probe.ps1 | 214 ++++- scripts/run-input-consume-probe-manual.ps1 | 41 + scripts/run-input-consume-probe.ps1 | 576 ++++++++++++ src/StellarX/SxInputProbe.cpp | 873 +++++++++++++++++-- src/StellarX/Window.cpp | 20 +- src/StellarX/internal/SxInputProbeInternal.h | 6 + tests/UiFixture.cpp | 44 +- 7 files changed, 1701 insertions(+), 73 deletions(-) create mode 100644 scripts/run-input-consume-probe-manual.ps1 create mode 100644 scripts/run-input-consume-probe.ps1 diff --git a/scripts/analyze-input-probe.ps1 b/scripts/analyze-input-probe.ps1 index 44af6cd..5db0c2d 100644 --- a/scripts/analyze-input-probe.ps1 +++ b/scripts/analyze-input-probe.ps1 @@ -1,4 +1,4 @@ -param( +param( [Parameter(Mandatory = $true)] [string]$Path ) @@ -39,7 +39,183 @@ $textLeaks = @($records | Where-Object { $_.messageCategory -eq 'text' -and $_.wParamHex -ne $null }) $lost = @($records | Where-Object recordType -eq 'records_lost') +$lostTotal = 0 +foreach ($lostRecord in $lost) { + if ($null -ne $lostRecord.lostCount) { + $lostTotal += [int]$lostRecord.lostCount + } + else { + $lostTotal++ + } +} $attach = $records | Where-Object recordType -eq 'window_attach' | Select-Object -First 1 +$closeObserved = @($records | Where-Object messageName -eq 'WM_CLOSE').Count -gt 0 +$sessionEndObserved = @($records | Where-Object recordType -eq 'session_end').Count -gt 0 +$experimentConfigs = @($records | Where-Object recordType -eq 'experiment_config') +if ($experimentConfigs.Count -gt 1) { + throw "More than one experiment_config record was found: $($experimentConfigs.Count)" +} +$experimentConfig = $experimentConfigs | Select-Object -First 1 +$experimentTargetEnters = @() +$experimentTargetLeaves = @() +$experimentEnters = @() +$experimentLeaves = @() +$matchingEasyX = @() +$easyxVisibility = $null +$experimentValidity = 'not_configured' +$imePositionAttempts = @() +$imePositionSuccesses = @() +$imePositionResultRecords = @() +$imePositionOrphans = @() +$runtimeFailOpenEnters = @() +$runtimeFailOpenLeaves = @() +$runtimeFailOpenReason = $null +if ($experimentConfig) { + $schemaVersions = @($records | Select-Object -ExpandProperty schemaVersion -Unique) + $experimentTargetEnters = @($records | Where-Object { + $_.recordType -eq 'wndproc_enter' -and + $_.experimentId -eq $experimentConfig.experimentId -and + $_.ruleId -eq $experimentConfig.ruleId + }) + $targetCallIds = @($experimentTargetEnters | Select-Object -ExpandProperty wndProcCallId) + $experimentTargetLeaves = @($records | Where-Object { + $_.recordType -eq 'wndproc_leave' -and + $targetCallIds -contains $_.wndProcCallId + }) + $runtimeFailOpenEnters = @($experimentTargetEnters | Where-Object failOpenApplied -eq $true) + $runtimeFailOpenCallIds = @($runtimeFailOpenEnters | Select-Object -ExpandProperty wndProcCallId) + $runtimeFailOpenLeaves = @($experimentTargetLeaves | Where-Object { + $runtimeFailOpenCallIds -contains $_.wndProcCallId -and $_.failOpenApplied -eq $true + }) + $runtimeFailOpenRecords = @($runtimeFailOpenEnters) + @($runtimeFailOpenLeaves) + $runtimeFailOpenReasons = @($runtimeFailOpenRecords | + Select-Object -ExpandProperty failOpenReason -Unique) + if ($runtimeFailOpenReasons.Count -eq 1) { + $runtimeFailOpenReason = [string]$runtimeFailOpenReasons[0] + } + $experimentEnters = @($experimentTargetEnters | Where-Object ruleMatched -eq $true) + $matchedCallIds = @($experimentEnters | Select-Object -ExpandProperty wndProcCallId) + $experimentLeaves = @($records | Where-Object { + $_.recordType -eq 'wndproc_leave' -and + $matchedCallIds -contains $_.wndProcCallId + }) + $easyXMessageType = if ([string]$experimentConfig.targetMessage -match 'UP$') { + 'WM_KEYUP' + } + else { + 'WM_KEYDOWN' + } + $targetScanSpecified = $null -ne $experimentConfig.targetScanCode + $matchingEasyX = @($records | Where-Object { + $_.recordType -eq 'easyx_key' -and + $_.easyxMessageType -eq $easyXMessageType -and + [int]$_.easyxVkCode -eq [int]$experimentConfig.targetVirtualKey -and + (-not $targetScanSpecified -or + [int]$_.easyxScanCode -eq [int]$experimentConfig.targetScanCode) + }) + $imePositionResultRecords = @($records | Where-Object recordType -eq 'ime_position') + # 兼容本修复前把定位结果直接挂在 wndproc_enter 上的 schema v2 日志。 + $legacyImePositionAttempts = @($records | Where-Object { + $_.recordType -eq 'wndproc_enter' -and $_.imePositionAttempted -eq $true + }) + $imePositionAttempts = @($imePositionResultRecords) + @($legacyImePositionAttempts) + $imePositionSuccesses = @($imePositionAttempts | Where-Object { + $_.imePositionContextAvailable -eq $true -and + $_.compositionPositionSucceeded -eq $true -and + $_.candidatePositionSucceeded -eq $true + }) + $sequenceLookup = @{} + foreach ($entry in $records) { $sequenceLookup[[string]$entry.sequence] = $true } + $imePositionOrphans = @($imePositionResultRecords | Where-Object { + $null -eq $_.parentSequence -or + -not $sequenceLookup.ContainsKey([string]$_.parentSequence) -or + [uint64]$_.parentSequence -ge [uint64]$_.sequence + }) + $easyxVisibility = if ($matchingEasyX.Count -eq 0) { + 'absent' + } + elseif ($matchingEasyX.Count -eq 1) { + 'present_once' + } + else { + 'present_multiple' + } + + if ($schemaVersions.Count -ne 1 -or [int]$schemaVersions[0] -ne 2) { + $experimentValidity = 'invalid_schema' + } + elseif (-not $sequenceStrict) { + $experimentValidity = 'inconclusive_sequence' + } + elseif ($textLeaks.Count -gt 0) { + $experimentValidity = 'inconclusive_text_leak' + } + elseif ($lostTotal -gt 0) { + $experimentValidity = 'inconclusive_capture_loss' + } + elseif ($imePositionOrphans.Count -gt 0) { + $experimentValidity = 'inconclusive_ime_position_link' + } + elseif (-not $closeObserved) { + $experimentValidity = 'inconclusive_close_missing' + } + elseif (-not $sessionEndObserved) { + $experimentValidity = 'inconclusive_session_end_missing' + } + elseif (-not $experimentConfig.configurationValid) { + $experimentValidity = 'invalid_configuration' + } + elseif (-not $experimentConfig.ruleEligible) { + $experimentValidity = 'ineligible_fail_open' + } + elseif ([string]$experimentConfig.inputSource -eq 'Manual' -and + (-not $experimentConfig.imePositionConfigured -or $imePositionSuccesses.Count -eq 0)) { + $experimentValidity = 'inconclusive_ime_position' + } + elseif ($experimentTargetEnters.Count -ne [int]$experimentConfig.maxMatches) { + $experimentValidity = 'unexpected_target_count' + } + elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndConsume' -and + $experimentEnters.Count -eq 0 -and + $runtimeFailOpenEnters.Count -eq [int]$experimentConfig.maxMatches -and + $runtimeFailOpenLeaves.Count -eq $runtimeFailOpenEnters.Count -and + $runtimeFailOpenReasons.Count -eq 1 -and + -not [string]::IsNullOrWhiteSpace($runtimeFailOpenReason) -and + @($runtimeFailOpenLeaves | Where-Object { + $_.actualAction -ne 'ObserveAndForward' -or $_.forwarded -ne $true + }).Count -eq 0) { + $experimentValidity = 'runtime_fail_open' + } + elseif ($experimentEnters.Count -ne [int]$experimentConfig.maxMatches) { + $experimentValidity = 'unexpected_match_count' + } + elseif ($experimentLeaves.Count -ne $experimentEnters.Count) { + $experimentValidity = 'missing_leave_record' + } + elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndConsume' -and + @($experimentLeaves | Where-Object actualAction -ne 'ObserveAndConsume').Count -gt 0) { + $experimentValidity = 'consume_action_mismatch' + } + elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndConsume' -and + @($experimentLeaves | Where-Object forwarded -eq $true).Count -gt 0) { + $experimentValidity = 'consume_was_forwarded' + } + elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndConsume' -and + @($experimentLeaves | Where-Object forwardResultHex -ne '0x0000000000000000').Count -gt 0) { + $experimentValidity = 'consume_result_nonzero' + } + elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndForward' -and + @($experimentLeaves | Where-Object actualAction -ne 'ObserveAndForward').Count -gt 0) { + $experimentValidity = 'forward_action_mismatch' + } + elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndForward' -and + @($experimentLeaves | Where-Object forwarded -ne $true).Count -gt 0) { + $experimentValidity = 'forward_was_not_forwarded' + } + else { + $experimentValidity = 'valid' + } +} $correlationCounts = [ordered]@{} foreach ($entry in $echoSummaries) { @@ -51,8 +227,10 @@ foreach ($entry in $echoSummaries) { [pscustomobject]@{ file = $file.FullName recordCount = $records.Count + schemaVersions = @($records | Select-Object -ExpandProperty schemaVersion -Unique) sequenceStrict = $sequenceStrict - recordsLost = $lost.Count + recordsLost = $lostTotal + lossRecordCount = $lost.Count textRawWParamLeaks = $textLeaks.Count isWindowUnicode = $attach.isWindowUnicode ansiCodePage = $attach.ansiCodePage @@ -72,6 +250,34 @@ foreach ($entry in $echoSummaries) { successfulScalarCounts = @($successfulResults | Select-Object -ExpandProperty scalarCount) digestPresentCount = @($successfulResults | Where-Object sessionDigest -ne $null).Count correlationCounts = [pscustomobject]$correlationCounts - closeObserved = @($records | Where-Object messageName -eq 'WM_CLOSE').Count -gt 0 - sessionEndObserved = @($records | Where-Object recordType -eq 'session_end').Count -gt 0 + closeObserved = $closeObserved + sessionEndObserved = $sessionEndObserved + experimentConfigured = $null -ne $experimentConfig + experimentId = if ($experimentConfig) { $experimentConfig.experimentId } else { $null } + ruleId = if ($experimentConfig) { $experimentConfig.ruleId } else { $null } + ruleEligible = if ($experimentConfig) { [bool]$experimentConfig.ruleEligible } else { $null } + experimentFailOpenReason = if ($experimentConfig) { $experimentConfig.failOpenReason } else { $null } + runtimeFailOpenReason = $runtimeFailOpenReason + requestedAction = if ($experimentConfig) { $experimentConfig.requestedAction } else { $null } + targetMessage = if ($experimentConfig) { $experimentConfig.targetMessage } else { $null } + targetVirtualKey = if ($experimentConfig) { $experimentConfig.targetVirtualKey } else { $null } + targetScanCode = if ($experimentConfig) { $experimentConfig.targetScanCode } else { $null } + baselineId = if ($experimentConfig) { $experimentConfig.baselineId } else { $null } + targetObservationCount = $experimentTargetEnters.Count + targetLeaveCount = $experimentTargetLeaves.Count + failOpenObservationCount = @($experimentTargetEnters | Where-Object failOpenApplied -eq $true).Count + failOpenForwardedCount = @($experimentTargetLeaves | Where-Object { + $_.failOpenApplied -eq $true -and $_.forwarded -eq $true + }).Count + ruleMatchCount = $experimentEnters.Count + consumedMatchCount = @($experimentEnters | Where-Object actualAction -eq 'ObserveAndConsume').Count + forwardedMatchCount = @($experimentLeaves | Where-Object forwarded -eq $true).Count + matchingEasyXKeyCount = $matchingEasyX.Count + easyxVisibility = $easyxVisibility + imePositionAttemptCount = $imePositionAttempts.Count + imePositionSuccessCount = $imePositionSuccesses.Count + imePositionReady = $imePositionSuccesses.Count -gt 0 + imePositionResultRecordCount = $imePositionResultRecords.Count + imePositionOrphanCount = $imePositionOrphans.Count + experimentValidity = $experimentValidity } diff --git a/scripts/run-input-consume-probe-manual.ps1 b/scripts/run-input-consume-probe-manual.ps1 new file mode 100644 index 0000000..bf61087 --- /dev/null +++ b/scripts/run-input-consume-probe-manual.ps1 @@ -0,0 +1,41 @@ +param( + [Parameter(Mandatory = $true)] + [ValidateSet('MicrosoftPinyin', 'Sogou')] + [string]$ImeName, + [Parameter(Mandatory = $true)] + [ValidateSet('ObserveAndForward', 'ObserveAndConsume')] + [string]$Action, + [ValidateSet('WM_KEYDOWN', 'WM_KEYUP', 'WM_SYSKEYDOWN', 'WM_SYSKEYUP')] + [string]$Message = 'WM_KEYDOWN', + [ValidateRange(1, 255)] + [int]$VirtualKey = 229, + [ValidateRange(-1, 255)] + [int]$ScanCode = 1, + [string]$OperatorKeyLabel = 'Escape', + [ValidateSet('IdleOnly', 'ComposingOnly')] + [string]$ImeCondition = 'ComposingOnly', + [ValidateSet('none', 'ime_condition_not_met')] + [string]$ExpectedFailOpenReason = 'none', + [string]$BaselineSet, + [switch]$ConfirmImeIdentity +) + +$ErrorActionPreference = 'Stop' +$runner = Join-Path $PSScriptRoot 'run-input-consume-probe.ps1' +$arguments = @{ + Action = $Action + Message = $Message + VirtualKey = $VirtualKey + ScanCode = $ScanCode + OperatorKeyLabel = $OperatorKeyLabel + InputSource = 'Manual' + ImeCondition = $ImeCondition + ImeName = $ImeName + ConfirmImeIdentity = [bool]$ConfirmImeIdentity + ExpectedFailOpenReason = $ExpectedFailOpenReason +} +if ($BaselineSet) { + $arguments.BaselineSet = $BaselineSet +} + +& $runner @arguments diff --git a/scripts/run-input-consume-probe.ps1 b/scripts/run-input-consume-probe.ps1 new file mode 100644 index 0000000..30a1e65 --- /dev/null +++ b/scripts/run-input-consume-probe.ps1 @@ -0,0 +1,576 @@ +param( + [ValidateSet('Both', 'ObserveAndForward', 'ObserveAndConsume')] + [string]$Action = 'Both', + [ValidateSet('WM_KEYDOWN', 'WM_KEYUP', 'WM_SYSKEYDOWN', 'WM_SYSKEYUP')] + [string]$Message = 'WM_KEYDOWN', + [ValidateRange(1, 255)] + [int]$VirtualKey = 0x87, + [ValidateRange(-1, 255)] + [int]$ScanCode = -1, + [string]$OperatorKeyLabel, + [string]$BaselineSet, + [ValidateRange(1, 65535)] + [int]$ExpectedAnsiCodePage = 936, + [string]$RuleBaselineId, + [ValidateSet('none', 'baseline_id_mismatch', 'ime_condition_not_met')] + [string]$ExpectedFailOpenReason = 'none', + [ValidateSet('SendInput', 'Manual')] + [string]$InputSource = 'SendInput', + [ValidateSet('IdleOnly', 'ComposingOnly')] + [string]$ImeCondition = 'IdleOnly', + [ValidateSet('unspecified', 'MicrosoftPinyin', 'Sogou')] + [string]$ImeName = 'unspecified', + [switch]$ConfirmImeIdentity +) + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$project = Join-Path $root 'tests\StellarX.UiFixture.vcxproj' +$fixture = Join-Path $root 'build\x64\Debug\StellarX.UiFixture.exe' +$msbuild = if ($env:STELLARX_MSBUILD) { $env:STELLARX_MSBUILD } else { 'D:\vs2022\MSBuild\Current\Bin\MSBuild.exe' } +$expectedBaselineId = 'sha256:5C544D9FEC9C1A2324A7AF3F593A1A34110100644BAF5071E777302387E3C6C5' +if (-not (Test-Path -LiteralPath $msbuild)) { throw "MSBuild not found: $msbuild" } +if ($ExpectedFailOpenReason -ne 'none' -and $Action -ne 'ObserveAndConsume') { + throw 'A fail-open negative run must request only ObserveAndConsume' +} +if ($ExpectedFailOpenReason -eq 'baseline_id_mismatch' -and -not $RuleBaselineId) { + throw 'A baseline mismatch run must pass -RuleBaselineId with an intentionally different value' +} +if ($InputSource -eq 'SendInput' -and $ImeCondition -ne 'IdleOnly') { + throw 'Automatic SendInput runs are limited to IdleOnly; Composition requires real manual IME input' +} +if ($InputSource -eq 'Manual' -and $Action -eq 'Both') { + throw 'Manual runs must select one action so each process contains exactly one operator exercise' +} +if ($InputSource -eq 'Manual' -and ($ImeName -eq 'unspecified' -or -not $ConfirmImeIdentity)) { + throw 'Manual runs require -ImeName and the explicit -ConfirmImeIdentity switch' +} +if ($ExpectedFailOpenReason -eq 'baseline_id_mismatch' -and $InputSource -ne 'SendInput') { + throw 'The baseline mismatch negative check uses the controlled SendInput path' +} +if ($ExpectedFailOpenReason -eq 'ime_condition_not_met' -and + ($InputSource -ne 'Manual' -or $ImeCondition -ne 'ComposingOnly')) { + throw 'The IME condition runtime fail-open check requires Manual + ComposingOnly' +} +$effectiveScanCode = if ($ScanCode -lt 0) { 256 } else { $ScanCode } +$operatorKeyDisplay = if ([string]::IsNullOrWhiteSpace($OperatorKeyLabel)) { + "VK=$VirtualKey" +} +else { + $OperatorKeyLabel.Trim() +} + +function Get-Sha256Text([string]$text) { + $sha = [Security.Cryptography.SHA256]::Create() + try { + $bytes = [Text.Encoding]::UTF8.GetBytes($text) + return ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-', '') + } + finally { + $sha.Dispose() + } +} + +function Resolve-BaselineSet([string]$requestedPath) { + if ($requestedPath) { + return (Resolve-Path -LiteralPath $requestedPath).Path + } + $candidates = @(Get-ChildItem -LiteralPath (Join-Path $root 'TestResults\VisualProbe') ` + -Filter 'baseline-set-v1.json' -File -Recurse -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending) + foreach ($candidate in $candidates) { + try { + $candidateValue = Get-Content -Raw -LiteralPath $candidate.FullName | ConvertFrom-Json + if ([string]$candidateValue.combinedBaselineId -eq $expectedBaselineId) { + return $candidate.FullName + } + } + catch { + # 无效历史产物不参与当前基线选择,继续寻找下一个可解析候选。 + continue + } + } + throw 'No matching 0C baseline-set-v1.json was found; pass -BaselineSet explicitly' +} + +function Read-VerifiedBaseline([string]$path) { + $resolvedPath = (Resolve-Path -LiteralPath $path).Path + $sidecarPath = $resolvedPath + '.sha256' + if (-not (Test-Path -LiteralPath $sidecarPath)) { throw "Baseline sidecar missing: $sidecarPath" } + $actualBaselineHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedPath).Hash + $sidecarHash = ((Get-Content -Raw -LiteralPath $sidecarPath).Trim() -split '\s+')[0].ToUpperInvariant() + if ($actualBaselineHash -ne $sidecarHash) { throw 'Baseline-set SHA-256 does not match its sidecar' } + + $baseline = Get-Content -Raw -LiteralPath $resolvedPath | ConvertFrom-Json + if ([int]$baseline.visualProbeBaselineSchemaVersion -ne 1) { throw 'Unsupported visual baseline schema' } + if ([string]$baseline.automatedStatus -ne 'pass') { throw '0C baseline automatedStatus is not pass' } + $directory = Split-Path -Parent $resolvedPath + $drawingPath = Join-Path $directory ([string]$baseline.drawingBaselineManifest) + $coordinatePath = Join-Path $directory ([string]$baseline.coordinateBaselineManifest) + if (-not (Test-Path -LiteralPath $drawingPath)) { throw "Drawing baseline missing: $drawingPath" } + if (-not (Test-Path -LiteralPath $coordinatePath)) { throw "Coordinate baseline missing: $coordinatePath" } + $drawingHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $drawingPath).Hash + $coordinateHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $coordinatePath).Hash + if ($drawingHash -ne [string]$baseline.drawingBaselineSha256) { throw 'Drawing baseline SHA-256 mismatch' } + if ($coordinateHash -ne [string]$baseline.coordinateBaselineSha256) { throw 'Coordinate baseline SHA-256 mismatch' } + $combined = 'sha256:' + (Get-Sha256Text "$drawingHash`:$coordinateHash") + if ($combined -ne [string]$baseline.combinedBaselineId) { throw 'Combined baseline ID mismatch' } + if ($combined -ne $expectedBaselineId) { throw "Unsupported 0C baseline ID: $combined" } + + $coordinate = Get-Content -Raw -LiteralPath $coordinatePath | ConvertFrom-Json + $awarenessMap = @{ unaware = 0; system = 1; 'per-monitor' = 2; 'per-monitor-v2' = 2 } + $awarenessName = [string]$baseline.dpiAwareness + if (-not $awarenessMap.ContainsKey($awarenessName)) { + throw "Unsupported DPI awareness in baseline: $awarenessName" + } + [pscustomobject]@{ + path = $resolvedPath + sha256 = $actualBaselineHash + combinedBaselineId = $combined + dpiForWindow = [int]$baseline.dpiForWindow + dpiAwareness = [int]$awarenessMap[$awarenessName] + windowUnicode = [bool]$coordinate.environment.windowUnicode + drawingSha256 = $drawingHash + coordinateSha256 = $coordinateHash + } +} + +# Windows PowerShell 5.1 能精确收口宿主注入的 PATH/Path 重复键;只影响当前 runner。 +$savedPath = $env:Path +[Environment]::SetEnvironmentVariable('PATH', $null, [EnvironmentVariableTarget]::Process) +[Environment]::SetEnvironmentVariable('Path', $savedPath, [EnvironmentVariableTarget]::Process) + +$baselineInfo = Read-VerifiedBaseline (Resolve-BaselineSet $BaselineSet) +& $msbuild $project /m /t:Build /p:Configuration=Debug /p:Platform=x64 ` + /p:StellarXInputProbe=true /v:minimal +if ($LASTEXITCODE -ne 0) { throw 'Input consume probe fixture build failed' } +if (-not (Test-Path -LiteralPath $fixture)) { throw "Fixture not found: $fixture" } + +Add-Type @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +public static class StellarXInputConsumeNative { + [StructLayout(LayoutKind.Sequential)] private struct POINT { public int X; public int Y; } + [StructLayout(LayoutKind.Sequential)] private struct RECT { public int Left; public int Top; public int Right; public int Bottom; } + [StructLayout(LayoutKind.Sequential)] private struct MOUSEINPUT { + public int Dx; public int Dy; public uint MouseData; public uint Flags; public uint Time; public UIntPtr ExtraInfo; + } + [StructLayout(LayoutKind.Sequential)] private struct KEYBDINPUT { + public ushort Vk; public ushort Scan; public uint Flags; public uint Time; public UIntPtr ExtraInfo; + } + [StructLayout(LayoutKind.Explicit)] private struct INPUTUNION { + [FieldOffset(0)] public MOUSEINPUT Mouse; + [FieldOffset(0)] public KEYBDINPUT Keyboard; + } + [StructLayout(LayoutKind.Sequential)] private struct INPUT { + public uint Type; public INPUTUNION Data; + } + [DllImport("user32.dll", CharSet=CharSet.Unicode)] private static extern IntPtr FindWindowW(string className, string title); + [DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr h); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] private static extern bool BringWindowToTop(IntPtr h); + [DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr h, int command); + [DllImport("user32.dll", SetLastError=true)] private static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags); + [DllImport("user32.dll", SetLastError=true)] private static extern bool GetWindowRect(IntPtr h, out RECT rect); + [DllImport("user32.dll")] private static extern bool GetCursorPos(out POINT point); + [DllImport("user32.dll")] private static extern bool SetCursorPos(int x, int y); + [DllImport("user32.dll")] private static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extraInfo); + [DllImport("user32.dll", SetLastError=true)] private static extern bool AttachThreadInput(uint attach, uint attachTo, bool value); + [DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId(); + [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr h, uint message, IntPtr w, IntPtr l); + [DllImport("user32.dll", SetLastError=true)] private static extern uint GetWindowThreadProcessId(IntPtr h, out uint processId); + [DllImport("user32.dll", SetLastError=true)] private static extern uint SendInput(uint count, INPUT[] inputs, int size); + public static IntPtr FindWindowByTitle(string title) { return FindWindowW(null, title); } + public static uint GetWindowProcessId(IntPtr h) { + uint processId; + if (GetWindowThreadProcessId(h, out processId) == 0 || processId == 0) throw new Win32Exception(); + return processId; + } + public static bool ActivateWindow(IntPtr h) { + uint ignored; + uint currentThread = GetCurrentThreadId(); + uint targetThread = GetWindowThreadProcessId(h, out ignored); + IntPtr foreground = GetForegroundWindow(); + uint foregroundThread = foreground == IntPtr.Zero ? 0 : GetWindowThreadProcessId(foreground, out ignored); + bool attachedForeground = foregroundThread != 0 && foregroundThread != currentThread && + AttachThreadInput(currentThread, foregroundThread, true); + bool attachedTarget = targetThread != 0 && targetThread != currentThread && targetThread != foregroundThread && + AttachThreadInput(currentThread, targetThread, true); + try { + ShowWindow(h, 9); + BringWindowToTop(h); + SetForegroundWindow(h); + return GetForegroundWindow() == h; + } + finally { + if (attachedTarget) AttachThreadInput(currentThread, targetThread, false); + if (attachedForeground) AttachThreadInput(currentThread, foregroundThread, false); + } + } + public static long ClickWindowForActivation(IntPtr h) { + RECT rect; + POINT original; + if (!GetWindowRect(h, out rect) || !GetCursorPos(out original)) throw new Win32Exception(); + const uint noMoveNoSize = 0x0001 | 0x0002; + SetWindowPos(h, new IntPtr(-1), 0, 0, 0, 0, noMoveNoSize); + int x = rect.Left + Math.Max(16, (rect.Right - rect.Left) / 2); + int y = rect.Top + Math.Max(40, (rect.Bottom - rect.Top) / 2); + try { + if (!SetCursorPos(x, y)) throw new Win32Exception(); + mouse_event(0x0002, 0, 0, 0, UIntPtr.Zero); + mouse_event(0x0004, 0, 0, 0, UIntPtr.Zero); + int outsideX = rect.Left > 48 ? rect.Left - 32 : rect.Right + 32; + int outsideY = rect.Top > 48 ? rect.Top - 32 : rect.Bottom + 32; + SetCursorPos(outsideX, outsideY); + } + finally { + SetWindowPos(h, new IntPtr(-2), 0, 0, 0, 0, noMoveNoSize); + } + return ((long)original.X << 32) | (uint)original.Y; + } + public static void RestoreCursorPosition(long packed) { + SetCursorPos((int)(packed >> 32), (int)(packed & 0xffffffffL)); + } + private static void SendKey(ushort virtualKey, bool up) { + var inputs = new INPUT[1]; + inputs[0].Type = 1; inputs[0].Data.Keyboard.Vk = virtualKey; + if (up) inputs[0].Data.Keyboard.Flags = 0x0002; + if (SendInput(1, inputs, Marshal.SizeOf(typeof(INPUT))) != 1) throw new Win32Exception(); + } + public static void SendKeyDown(ushort virtualKey) { SendKey(virtualKey, false); } + public static void SendKeyUp(ushort virtualKey) { SendKey(virtualKey, true); } +} +'@ + +function Wait-Window([string]$title, [int]$timeoutMs = 15000) { + $watch = [Diagnostics.Stopwatch]::StartNew() + while ($watch.ElapsedMilliseconds -lt $timeoutMs) { + $window = [StellarXInputConsumeNative]::FindWindowByTitle($title) + if ($window -ne [IntPtr]::Zero) { + $fixturePid = [StellarXInputConsumeNative]::GetWindowProcessId($window) + try { + $fixtureProcess = [Diagnostics.Process]::GetProcessById([int]$fixturePid) + return [pscustomobject]@{ + window = $window + process = $fixtureProcess + } + } + catch { + # 窗口可能正处于销毁边界;继续等待同标题的有效新实例。 + $fixtureProcess = $null + } + } + Start-Sleep -Milliseconds 50 + } + throw "Window not found: $title" +} + +function Invoke-ProbeRun([string]$requestedAction, [string]$sessionRoot, [string]$experimentId) { + $actionSlug = if ($requestedAction -eq 'ObserveAndForward') { 'forward' } else { 'consume' } + $runDirectory = Join-Path $sessionRoot $actionSlug + $fixtureDirectory = Join-Path $runDirectory 'fixture' + New-Item -ItemType Directory -Force -Path $fixtureDirectory | Out-Null + $scanSlug = if ($ScanCode -lt 0) { 'scan-any' } else { "scan-$ScanCode" } + $ruleId = "$($Message.ToLowerInvariant())-vk$VirtualKey-$scanSlug-$($ImeCondition.ToLowerInvariant())-$actionSlug" + $windowUnicode = if ($baselineInfo.windowUnicode) { '1' } else { '0' } + $effectiveRuleBaselineId = if ($RuleBaselineId) { $RuleBaselineId } else { $baselineInfo.combinedBaselineId } + $launchParts = @( + 'set "STELLARX_INPUT_PROBE=1"', + 'set "STELLARX_INPUT_PROBE_0B=1"', + "set `"STELLARX_INPUT_PROBE_DIR=$runDirectory`"", + "set `"STELLARX_INPUT_PROBE_0B_EXPERIMENT_ID=$experimentId`"", + "set `"STELLARX_INPUT_PROBE_0B_RULE_ID=$ruleId`"", + "set `"STELLARX_INPUT_PROBE_0B_ACTION=$requestedAction`"", + "set `"STELLARX_INPUT_PROBE_0B_MESSAGE=$Message`"", + "set `"STELLARX_INPUT_PROBE_0B_VIRTUAL_KEY=$VirtualKey`"", + "set `"STELLARX_INPUT_PROBE_0B_SCAN_CODE=$effectiveScanCode`"", + "set `"STELLARX_INPUT_PROBE_0B_IME_CONDITION=$ImeCondition`"", + 'set "STELLARX_INPUT_PROBE_0B_MAX_MATCHES=1"', + "set `"STELLARX_INPUT_PROBE_0B_INPUT_SOURCE=$InputSource`"", + "set `"STELLARX_INPUT_PROBE_0B_BASELINE_ID=$effectiveRuleBaselineId`"", + "set `"STELLARX_INPUT_PROBE_0B_EXPECT_WINDOW_UNICODE=$windowUnicode`"", + "set `"STELLARX_INPUT_PROBE_0B_EXPECT_ANSI_CODE_PAGE=$ExpectedAnsiCodePage`"", + "set `"STELLARX_INPUT_PROBE_0B_EXPECT_DPI_FOR_WINDOW=$($baselineInfo.dpiForWindow)`"", + "set `"STELLARX_INPUT_PROBE_0B_EXPECT_DPI_AWARENESS=$($baselineInfo.dpiAwareness)`"" + ) + if ($InputSource -eq 'Manual') { + $launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_IDENTITY_CONFIRMED=1"' + $launchParts += "set `"STELLARX_INPUT_PROBE_0B_IME_NAME=$ImeName`"" + $launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_CARET_X=225"' + $launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_CARET_Y=282"' + $launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_LINE_LEFT=200"' + $launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_LINE_TOP=255"' + $launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_LINE_RIGHT=680"' + $launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_LINE_BOTTOM=284"' + } + $launchParts += ('"{0}" input-consume-probe "{1}"' -f $fixture, $fixtureDirectory) + $launchCommand = $launchParts -join ' & ' + + $title = 'StellarX.UI.input-consume-probe' + if ([StellarXInputConsumeNative]::FindWindowByTitle($title) -ne [IntPtr]::Zero) { + throw "A stale fixture window is already open: $title" + } + $launcherProcess = Start-Process -FilePath $env:ComSpec -ArgumentList @('/d', '/c', $launchCommand) -PassThru + $window = [IntPtr]::Zero + $fixtureProcess = $null + $savedCursorPosition = $null + $keyDownSent = $false + $keyUpSent = $false + try { + $windowState = Wait-Window $title + $window = $windowState.window + $fixtureProcess = $windowState.process + if ($InputSource -eq 'Manual') { + Write-Host "Manual 0B window ready: $ImeName / $ImeCondition / $requestedAction" + Write-Host 'Complete the printed checklist in the EasyX window, then close it.' + [void]$fixtureProcess.WaitForExit() + } + else { + Start-Sleep -Milliseconds 400 + $windowShell = New-Object -ComObject WScript.Shell + [void]$windowShell.AppActivate($fixtureProcess.Id) + Start-Sleep -Milliseconds 400 + if ([StellarXInputConsumeNative]::GetForegroundWindow() -ne $window) { + [void][StellarXInputConsumeNative]::ActivateWindow($window) + Start-Sleep -Milliseconds 200 + } + if ([StellarXInputConsumeNative]::GetForegroundWindow() -ne $window) { + $savedCursorPosition = [StellarXInputConsumeNative]::ClickWindowForActivation($window) + Start-Sleep -Milliseconds 300 + } + if ([StellarXInputConsumeNative]::GetForegroundWindow() -ne $window) { + throw 'Fixture did not become the foreground window' + } + [StellarXInputConsumeNative]::SendKeyDown([uint16]$VirtualKey) + $keyDownSent = $true + Start-Sleep -Milliseconds 250 + [StellarXInputConsumeNative]::SendKeyUp([uint16]$VirtualKey) + $keyUpSent = $true + Start-Sleep -Milliseconds 800 + if (-not [StellarXInputConsumeNative]::PostMessage($window, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero)) { + throw 'Failed to post WM_CLOSE to the input consume fixture' + } + if (-not $fixtureProcess.WaitForExit(10000)) { throw 'Fixture did not exit after WM_CLOSE' } + } + } + finally { + if ($keyDownSent -and -not $keyUpSent) { + try { [StellarXInputConsumeNative]::SendKeyUp([uint16]$VirtualKey) } + catch { Write-Verbose 'Failed to release synthetic key during cleanup' } + } + if ($fixtureProcess) { $fixtureProcess.Refresh() } + if ($fixtureProcess -and -not $fixtureProcess.HasExited -and $window -ne [IntPtr]::Zero) { + [void][StellarXInputConsumeNative]::PostMessage( + $window, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero) + [void]$fixtureProcess.WaitForExit(3000) + $fixtureProcess.Refresh() + } + if ($fixtureProcess -and -not $fixtureProcess.HasExited) { + Stop-Process -Id $fixtureProcess.Id -Force -ErrorAction SilentlyContinue + } + $launcherProcess.Refresh() + if (-not $launcherProcess.HasExited) { + [void]$launcherProcess.WaitForExit(3000) + $launcherProcess.Refresh() + } + if (-not $launcherProcess.HasExited) { + Stop-Process -Id $launcherProcess.Id -Force -ErrorAction SilentlyContinue + } + if ($null -ne $savedCursorPosition) { + [StellarXInputConsumeNative]::RestoreCursorPosition([long]$savedCursorPosition) + } + } + + $summary = & (Join-Path $PSScriptRoot 'analyze-input-probe.ps1') -Path $runDirectory + if ($ExpectedFailOpenReason -eq 'none') { + if ($summary.experimentValidity -ne 'valid') { + throw "Invalid $requestedAction experiment: $($summary.experimentValidity)" + } + } + elseif ($ExpectedFailOpenReason -eq 'baseline_id_mismatch') { + $failOpenValid = + $summary.experimentValidity -eq 'ineligible_fail_open' -and + $summary.experimentFailOpenReason -eq $ExpectedFailOpenReason -and + $summary.targetObservationCount -eq 1 -and + $summary.targetLeaveCount -eq 1 -and + $summary.failOpenObservationCount -eq 1 -and + $summary.failOpenForwardedCount -eq 1 -and + $summary.ruleMatchCount -eq 0 + if (-not $failOpenValid) { + throw "Invalid fail-open experiment: validity=$($summary.experimentValidity), reason=$($summary.experimentFailOpenReason)" + } + } + else { + $failOpenValid = + $summary.experimentValidity -eq 'runtime_fail_open' -and + $summary.runtimeFailOpenReason -eq $ExpectedFailOpenReason -and + $summary.targetObservationCount -eq 1 -and + $summary.targetLeaveCount -eq 1 -and + $summary.failOpenObservationCount -eq 1 -and + $summary.failOpenForwardedCount -eq 1 -and + $summary.ruleMatchCount -eq 0 -and + $summary.consumedMatchCount -eq 0 -and + $summary.matchingEasyXKeyCount -eq 1 + if (-not $failOpenValid) { + throw "Invalid runtime fail-open experiment: validity=$($summary.experimentValidity), reason=$($summary.runtimeFailOpenReason)" + } + } + $actualFailOpenReason = if ($summary.experimentFailOpenReason -and + $summary.experimentFailOpenReason -ne 'none') { + $summary.experimentFailOpenReason + } + else { + $summary.runtimeFailOpenReason + } + [pscustomobject][ordered]@{ + action = $requestedAction + runDirectory = $runDirectory + file = $summary.file + fixtureProcessId = if ($fixtureProcess) { $fixtureProcess.Id } else { $null } + sequenceStrict = $summary.sequenceStrict + recordsLost = $summary.recordsLost + ruleMatchCount = $summary.ruleMatchCount + consumedMatchCount = $summary.consumedMatchCount + forwardedMatchCount = $summary.forwardedMatchCount + matchingEasyXKeyCount = $summary.matchingEasyXKeyCount + easyxVisibility = $summary.easyxVisibility + imePositionAttemptCount = $summary.imePositionAttemptCount + imePositionSuccessCount = $summary.imePositionSuccessCount + imePositionReady = $summary.imePositionReady + imePositionResultRecordCount = $summary.imePositionResultRecordCount + imePositionOrphanCount = $summary.imePositionOrphanCount + closeObserved = $summary.closeObserved + sessionEndObserved = $summary.sessionEndObserved + expectedFailOpenReason = $ExpectedFailOpenReason + actualFailOpenReason = $actualFailOpenReason + experimentFailOpenReason = $summary.experimentFailOpenReason + runtimeFailOpenReason = $summary.runtimeFailOpenReason + targetObservationCount = $summary.targetObservationCount + targetLeaveCount = $summary.targetLeaveCount + failOpenObservationCount = $summary.failOpenObservationCount + failOpenForwardedCount = $summary.failOpenForwardedCount + experimentValidity = $summary.experimentValidity + } +} + +$stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff' +$comparisonId = [Guid]::NewGuid().ToString() +$sessionRoot = Join-Path $root "TestResults\InputProbe\0B\$stamp-$comparisonId" +New-Item -ItemType Directory -Force -Path $sessionRoot | Out-Null +$actions = if ($Action -eq 'Both') { + @('ObserveAndForward', 'ObserveAndConsume') +} +else { + @($Action) +} +$results = @() +if ($InputSource -eq 'Manual') { + Write-Host "Manual IME consume probe: $ImeName" + $scanDescription = if ($ScanCode -lt 0) { 'any scan code' } else { "scanCode=$ScanCode" } + Write-Host " Rule: $Message VK=$VirtualKey, $scanDescription, $ImeCondition, $Action" + Write-Host ' 1. Confirm the selected input method really matches -ImeName, then click the EasyX window.' + if ($ImeCondition -eq 'ComposingOnly') { + Write-Host ' 2. Type only ceshi until the composition/candidate UI is visible.' + Write-Host " 3. Press $operatorKeyDisplay exactly once while composition is active; release it normally." + } + else { + Write-Host " 2. Before starting composition, press $operatorKeyDisplay exactly once; release it normally." + Write-Host ' 3. Then type only ceshi to confirm ordinary candidate behavior still works.' + } + Write-Host ' 4. Check candidate, commit/cancel behavior and position against the accepted 0C baseline.' + Write-Host ' 5. Close the EasyX window. The runner will validate privacy-safe structural evidence.' +} +foreach ($requestedAction in $actions) { + Write-Host "Running 0B $requestedAction $Message VK=$VirtualKey scan=$effectiveScanCode" + $results += Invoke-ProbeRun $requestedAction $sessionRoot $comparisonId +} + +$forward = $results | Where-Object action -eq 'ObserveAndForward' | Select-Object -First 1 +$consume = $results | Where-Object action -eq 'ObserveAndConsume' | Select-Object -First 1 +$allRunsComplete = @($results | Where-Object { + -not $_.sequenceStrict -or + $_.recordsLost -gt 0 -or + -not $_.closeObserved -or + -not $_.sessionEndObserved +}).Count -eq 0 +$comparisonStatus = if (-not $allRunsComplete) { + 'inconclusive' +} +elseif ($ExpectedFailOpenReason -ne 'none' -and @($results | Where-Object { + $_.actualFailOpenReason -ne $ExpectedFailOpenReason -or + $_.failOpenObservationCount -ne 1 -or + $_.failOpenForwardedCount -ne 1 +}).Count -gt 0) { + 'inconclusive' +} +elseif ($Action -eq 'Both' -and (-not $forward -or -not $consume)) { + 'inconclusive' +} +elseif ($Action -eq 'Both' -and $forward.matchingEasyXKeyCount -lt 1) { + 'inconclusive' +} +else { + 'pass' +} +$observedRelation = if (-not $consume) { + 'not_measured' +} +elseif ($consume.experimentValidity -eq 'runtime_fail_open') { + 'easyx_visible_after_fail_open_forward' +} +elseif ($consume.easyxVisibility -eq 'absent') { + 'easyx_suppressed_by_wndproc_consume' +} +elseif ($consume.easyxVisibility -eq 'present_once' -or $consume.easyxVisibility -eq 'present_multiple') { + 'easyx_still_visible_after_wndproc_consume' +} +else { + 'inconclusive' +} + +$comparison = [pscustomobject][ordered]@{ + inputConsumeProbeSchemaVersion = 1 + comparisonId = $comparisonId + createdAtUtc = [DateTime]::UtcNow.ToString('o') + targetMessage = $Message + targetVirtualKey = $VirtualKey + targetScanCode = if ($ScanCode -lt 0) { $null } else { $ScanCode } + inputSource = $InputSource + imeCondition = $ImeCondition + imeName = if ($InputSource -eq 'Manual') { $ImeName } else { $null } + imeIdentityConfirmed = $InputSource -eq 'Manual' -and $ConfirmImeIdentity + expectedFailOpenReason = $ExpectedFailOpenReason + baselineSet = $baselineInfo.path + baselineSetSha256 = $baselineInfo.sha256 + combinedBaselineId = $baselineInfo.combinedBaselineId + drawingBaselineSha256 = $baselineInfo.drawingSha256 + coordinateBaselineSha256 = $baselineInfo.coordinateSha256 + automatedStatus = $comparisonStatus + observedRelation = $observedRelation + finalVerdict = if ($ExpectedFailOpenReason -eq 'ime_condition_not_met') { + 'MustForward' + } + else { + 'Inconclusive' + } + finalVerdictReason = if ($ExpectedFailOpenReason -eq 'ime_condition_not_met') { + 'The candidate UI was visible, but the HWND exposed no reliable composing state; the consume request fail-opened to Forward.' + } + elseif ($InputSource -eq 'Manual') { + 'Structural capture only; operator review of IME behavior and 0C position evidence is still required.' + } + else { + 'Core EX_KEY relation only; the required message and IME matrix is not complete.' + } + runs = @($results) +} +$comparisonPath = Join-Path $sessionRoot 'comparison-v1.json' +$utf8Bom = New-Object System.Text.UTF8Encoding($true) +[IO.File]::WriteAllText($comparisonPath, + ($comparison | ConvertTo-Json -Depth 20) + [Environment]::NewLine, $utf8Bom) +$comparisonHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $comparisonPath).Hash +$comparison | Format-List +Write-Host "Comparison: $comparisonPath" +Write-Host "Comparison SHA-256: $comparisonHash" +if ($comparisonStatus -ne 'pass') { throw '0B automatic comparison is inconclusive' } diff --git a/src/StellarX/SxInputProbe.cpp b/src/StellarX/SxInputProbe.cpp index 4040598..47620a6 100644 --- a/src/StellarX/SxInputProbe.cpp +++ b/src/StellarX/SxInputProbe.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #pragma comment(lib, "bcrypt.lib") @@ -31,15 +32,20 @@ namespace StellarX::Internal constexpr std::size_t MaxImeResultBytes = 64 * 1024; constexpr std::size_t MaxEchoUtf16Units = 1024; constexpr std::size_t MaxEchoAnsiBytes = 2048; + constexpr std::uint32_t MaxConsumeRuleMatches = 16; + constexpr const char* Current0CBaselineId = + "sha256:5C544D9FEC9C1A2324A7AF3F593A1A34110100644BAF5071E777302387E3C6C5"; enum class RecordType : std::uint8_t { SessionStart, SessionEnd, WindowAttach, + ExperimentConfig, WindowDetach, WndProcEnter, WndProcLeave, + ImePosition, EasyXKey, ImmQuery, EchoSummary, @@ -115,6 +121,78 @@ namespace StellarX::Internal WmChar }; + enum class ProbeAction : std::uint8_t + { + ObserveAndForward, + ObserveAndConsume + }; + + enum class ImeStateCondition : std::uint8_t + { + Any, + IdleOnly, + ComposingOnly + }; + + enum class ProbeInputSource : std::uint8_t + { + Unknown, + SendInput, + Manual + }; + + enum class FailOpenReason : std::uint8_t + { + None, + InvalidConfiguration, + BaselineIdMismatch, + WindowUnicodeMismatch, + AnsiCodePageMismatch, + DpiMismatch, + DpiAwarenessMismatch, + ImeIdentityUnconfirmed, + ImeConditionNotMet, + ImePositionUnavailable, + CaptureLossObserved, + MatchLimitReached + }; + + struct ConsumeRuleState + { + bool requested = false; + bool configurationValid = false; + bool baselineMatched = false; + bool eligible = false; + std::string experimentId; + std::string ruleId; + std::string baselineId; + std::string imeName; + UINT message = 0; + UINT virtualKey = 0; + bool scanCodeSpecified = false; + std::uint8_t scanCode = 0; + ImeStateCondition imeCondition = ImeStateCondition::Any; + ProbeAction requestedAction = ProbeAction::ObserveAndForward; + ProbeInputSource inputSource = ProbeInputSource::Unknown; + std::uint32_t maxMatches = 0; + std::uint32_t matchCount = 0; + bool expectedWindowUnicode = false; + std::uint32_t expectedAnsiCodePage = 0; + std::uint32_t expectedDpiForWindow = 0; + int expectedDpiAwareness = -1; + bool actualWindowUnicode = false; + std::uint32_t actualAnsiCodePage = 0; + std::uint32_t actualDpiForWindow = 0; + int actualDpiAwareness = -1; + bool imePositionConfigured = false; + bool imePositionReady = false; + POINT imeCaretPoint{}; + RECT imeLineRect{}; + std::uint32_t imePositionAttemptCount = 0; + std::uint32_t imePositionSuccessCount = 0; + FailOpenReason failOpenReason = FailOpenReason::None; + }; + struct ProbeRecord { RecordType type = RecordType::SessionStart; @@ -139,6 +217,16 @@ namespace StellarX::Internal std::uint64_t lParamValue = 0; std::uint64_t forwardResult = 0; bool forwarded = false; + bool experimentTagged = false; + bool configurationValid = false; + bool baselineMatched = false; + bool ruleEligible = false; + bool ruleMatched = false; + bool failOpenApplied = false; + std::uint32_t matchOrdinal = 0; + ProbeAction requestedAction = ProbeAction::ObserveAndForward; + ProbeAction actualAction = ProbeAction::ObserveAndForward; + FailOpenReason failOpenReason = FailOpenReason::None; std::uint16_t repeatCount = 0; std::uint8_t scanCode = 0; @@ -156,6 +244,12 @@ namespace StellarX::Internal bool capsLock = false; bool numLock = false; bool scrollLock = false; + bool imePositionAttempted = false; + bool imePositionContextAvailable = false; + bool compositionPositionSucceeded = false; + bool candidatePositionSucceeded = false; + POINT imeCaretPoint{}; + RECT imeLineRect{}; std::uint32_t imeFlags = 0; bool imeStateBefore = false; @@ -206,6 +300,26 @@ namespace StellarX::Internal bool durableFlush = false; }; + struct ImePositionRequest + { + bool active = false; + HWND hwnd = nullptr; + std::uint64_t hwndInstanceId = 0; + std::uint64_t parentSequence = 0; + std::uint64_t wndProcCallId = 0; + std::uint64_t compositionId = 0; + UINT triggerMessage = 0; + POINT caretPoint{}; + RECT lineRect{}; + }; + + struct ImePositionResult + { + bool contextAvailable = false; + bool compositionSucceeded = false; + bool candidateSucceeded = false; + }; + struct EchoCorrelationState { bool active = false; @@ -227,7 +341,11 @@ namespace StellarX::Internal struct FlushSnapshot { - std::vector records; + std::size_t bufferIndex = 0; + std::size_t head = 0; + std::size_t count = 0; + std::size_t hardLimit = 0; + std::vector trailingRecords; HWND hwnd = nullptr; std::uint64_t hwndInstanceId = 0; bool durable = false; @@ -237,6 +355,7 @@ namespace StellarX::Internal { std::atomic enabled{ false }; SRWLOCK lock = SRWLOCK_INIT; + std::uint32_t schemaVersion = 1; bool recordNonInputMessages = false; bool durableRequested = false; HANDLE file = INVALID_HANDLE_VALUE; @@ -261,8 +380,10 @@ namespace StellarX::Internal std::uint32_t modalDepth = 0; bool imeComposing = false; EchoCorrelationState echo{}; + ConsumeRuleState consumeRule{}; - std::array records{}; + std::array, 2> recordBuffers{}; + std::size_t activeRecordBuffer = 0; std::size_t head = 0; std::size_t count = 0; std::size_t softLimit = DefaultSoftLimit; @@ -275,6 +396,10 @@ namespace StellarX::Internal }; thread_local std::uint32_t wndProcDepth = 0; + thread_local bool imePositionApiCallActive = false; + // All persistence paths acquire this lock before ProbeState::lock so snapshots + // from the host and WndProc threads reach disk in global sequence order. + SRWLOCK probeFileWriteLock = SRWLOCK_INIT; ProbeState& probeState() noexcept { @@ -300,27 +425,6 @@ namespace StellarX::Internal SRWLOCK* lock_; }; - class TryExclusiveStateLock - { - public: - explicit TryExclusiveStateLock(SRWLOCK& lock) noexcept - : lock_(&lock), acquired_(TryAcquireSRWLockExclusive(lock_) != FALSE) - { - } - ~TryExclusiveStateLock() noexcept - { - if (acquired_) - ReleaseSRWLockExclusive(lock_); - } - bool acquired() const noexcept { return acquired_; } - TryExclusiveStateLock(const TryExclusiveStateLock&) = delete; - TryExclusiveStateLock& operator=(const TryExclusiveStateLock&) = delete; - - private: - SRWLOCK* lock_; - bool acquired_; - }; - std::uint64_t qpcNow() noexcept { LARGE_INTEGER value{}; @@ -351,6 +455,113 @@ namespace StellarX::Internal return static_cast(parsed); } + bool environmentText(const wchar_t* name, std::wstring& output) noexcept + { + try + { + const DWORD required = GetEnvironmentVariableW(name, nullptr, 0); + if (required <= 1 || required > 512) + return false; + std::wstring value(required, L'\0'); + const DWORD copied = GetEnvironmentVariableW(name, value.data(), required); + if (copied == 0 || copied >= required) + return false; + value.resize(copied); + output = std::move(value); + return true; + } + catch (...) + { + return false; + } + } + + bool toSafeAscii(const std::wstring& value, std::string& output, + bool identifierOnly) noexcept + { + try + { + if (value.empty() || value.size() > 128) + return false; + std::string converted; + converted.reserve(value.size()); + for (const wchar_t unit : value) + { + if (unit < 0x20 || unit > 0x7E) + return false; + const char character = static_cast(unit); + if (identifierOnly && !((character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || character == '.' || + character == '_' || character == '-')) + { + return false; + } + converted.push_back(character); + } + output = std::move(converted); + return true; + } + catch (...) + { + return false; + } + } + + bool environmentAscii(const wchar_t* name, std::string& output, + bool identifierOnly = false) noexcept + { + std::wstring value; + return environmentText(name, value) && toSafeAscii(value, output, identifierOnly); + } + + bool environmentUnsigned(const wchar_t* name, std::uint32_t& output) noexcept + { + std::wstring value; + if (!environmentText(name, value)) + return false; + wchar_t* end = nullptr; + const unsigned long parsed = std::wcstoul(value.c_str(), &end, 0); + if (end == value.c_str() || *end != L'\0') + return false; + output = static_cast(parsed); + return true; + } + + bool environmentSigned(const wchar_t* name, int& output) noexcept + { + std::wstring value; + if (!environmentText(name, value)) + return false; + wchar_t* end = nullptr; + const long parsed = std::wcstol(value.c_str(), &end, 10); + if (end == value.c_str() || *end != L'\0' || + parsed < (std::numeric_limits::min)() || parsed > (std::numeric_limits::max)()) + { + return false; + } + output = static_cast(parsed); + return true; + } + + bool environmentBoolean(const wchar_t* name, bool& output) noexcept + { + std::wstring value; + if (!environmentText(name, value)) + return false; + if (value == L"1" || _wcsicmp(value.c_str(), L"true") == 0) + { + output = true; + return true; + } + if (value == L"0" || _wcsicmp(value.c_str(), L"false") == 0) + { + output = false; + return true; + } + return false; + } + std::filesystem::path outputDirectory() noexcept { try @@ -416,9 +627,11 @@ namespace StellarX::Internal case RecordType::SessionStart: return "session_start"; case RecordType::SessionEnd: return "session_end"; case RecordType::WindowAttach: return "window_attach"; + case RecordType::ExperimentConfig: return "experiment_config"; case RecordType::WindowDetach: return "window_detach"; case RecordType::WndProcEnter: return "wndproc_enter"; case RecordType::WndProcLeave: return "wndproc_leave"; + case RecordType::ImePosition: return "ime_position"; case RecordType::EasyXKey: return "easyx_key"; case RecordType::ImmQuery: return "imm_query"; case RecordType::EchoSummary: return "echo_correlation"; @@ -437,6 +650,8 @@ namespace StellarX::Internal case RecordType::WndProcEnter: case RecordType::WndProcLeave: return "Win32WndProc"; + case RecordType::ImePosition: + return "Imm32Position"; case RecordType::EasyXKey: return "EasyXExKey"; case RecordType::ImmQuery: @@ -455,9 +670,11 @@ namespace StellarX::Internal case RecordType::SessionStart: return "probe_start"; case RecordType::SessionEnd: return "probe_stop"; case RecordType::WindowAttach: return "window_attach"; + case RecordType::ExperimentConfig: return "experiment_config"; case RecordType::WindowDetach: return "window_detach"; case RecordType::WndProcEnter: return "enter"; case RecordType::WndProcLeave: return "after_dispatch"; + case RecordType::ImePosition: return "ime_position_result"; case RecordType::EasyXKey: return "existing_peekmessage_result"; case RecordType::ImmQuery: return "gcs_result"; case RecordType::EchoSummary: return "echo_summary"; @@ -546,6 +763,51 @@ namespace StellarX::Internal } } + const char* probeActionName(ProbeAction action) noexcept + { + return action == ProbeAction::ObserveAndConsume + ? "ObserveAndConsume" : "ObserveAndForward"; + } + + const char* imeConditionName(ImeStateCondition condition) noexcept + { + switch (condition) + { + case ImeStateCondition::IdleOnly: return "idle_only"; + case ImeStateCondition::ComposingOnly: return "composing_only"; + default: return "any"; + } + } + + const char* inputSourceName(ProbeInputSource source) noexcept + { + switch (source) + { + case ProbeInputSource::SendInput: return "SendInput"; + case ProbeInputSource::Manual: return "Manual"; + default: return "unknown"; + } + } + + const char* failOpenReasonName(FailOpenReason reason) noexcept + { + switch (reason) + { + case FailOpenReason::InvalidConfiguration: return "invalid_configuration"; + case FailOpenReason::BaselineIdMismatch: return "baseline_id_mismatch"; + case FailOpenReason::WindowUnicodeMismatch: return "window_unicode_mismatch"; + case FailOpenReason::AnsiCodePageMismatch: return "ansi_code_page_mismatch"; + case FailOpenReason::DpiMismatch: return "dpi_mismatch"; + case FailOpenReason::DpiAwarenessMismatch: return "dpi_awareness_mismatch"; + case FailOpenReason::ImeIdentityUnconfirmed: return "ime_identity_unconfirmed"; + case FailOpenReason::ImeConditionNotMet: return "ime_condition_not_met"; + case FailOpenReason::ImePositionUnavailable: return "ime_position_unavailable"; + case FailOpenReason::CaptureLossObserved: return "capture_loss_observed"; + case FailOpenReason::MatchLimitReached: return "match_limit_reached"; + default: return "none"; + } + } + const char* messageName(UINT message) noexcept { switch (message) @@ -696,6 +958,185 @@ namespace StellarX::Internal return GetACP(); } + bool parseProbeAction(const std::string& value, ProbeAction& action) noexcept + { + if (value == "ObserveAndForward") + { + action = ProbeAction::ObserveAndForward; + return true; + } + if (value == "ObserveAndConsume") + { + action = ProbeAction::ObserveAndConsume; + return true; + } + return false; + } + + bool parseImeCondition(const std::string& value, ImeStateCondition& condition) noexcept + { + if (value == "Any") condition = ImeStateCondition::Any; + else if (value == "IdleOnly") condition = ImeStateCondition::IdleOnly; + else if (value == "ComposingOnly") condition = ImeStateCondition::ComposingOnly; + else return false; + return true; + } + + bool parseInputSource(const std::string& value, ProbeInputSource& source) noexcept + { + if (value == "SendInput") source = ProbeInputSource::SendInput; + else if (value == "Manual") source = ProbeInputSource::Manual; + else return false; + return true; + } + + bool parseRuleMessage(const std::string& value, UINT& message) noexcept + { + if (value == "WM_KEYDOWN") message = WM_KEYDOWN; + else if (value == "WM_KEYUP") message = WM_KEYUP; + else if (value == "WM_SYSKEYDOWN") message = WM_SYSKEYDOWN; + else if (value == "WM_SYSKEYUP") message = WM_SYSKEYUP; + else return false; + return true; + } + + bool isSha256BaselineId(const std::string& value) noexcept + { + constexpr char Prefix[] = "sha256:"; + if (value.size() != (std::size(Prefix) - 1u) + 64u || + value.compare(0, std::size(Prefix) - 1u, Prefix) != 0) + { + return false; + } + return std::all_of(value.begin() + (std::size(Prefix) - 1u), value.end(), + [](char character) noexcept { + return (character >= '0' && character <= '9') || + (character >= 'a' && character <= 'f') || + (character >= 'A' && character <= 'F'); + }); + } + + ConsumeRuleState loadConsumeRule(HWND hwnd, std::uint32_t ansiCodePage) noexcept + { + ConsumeRuleState rule{}; + rule.requested = environmentFlagEnabled(L"STELLARX_INPUT_PROBE_0B"); + if (!rule.requested) + return rule; + + rule.actualWindowUnicode = IsWindowUnicode(hwnd) != FALSE; + rule.actualAnsiCodePage = ansiCodePage; + rule.actualDpiForWindow = dpiForWindow(hwnd); + rule.actualDpiAwareness = awarenessForWindow(hwnd); + + std::string action; + std::string message; + std::string condition; + std::string inputSource; + std::string baselineId; + std::uint32_t virtualKey = 0; + std::uint32_t scanCode = 0; + std::uint32_t maxMatches = 0; + bool configurationValid = + environmentAscii(L"STELLARX_INPUT_PROBE_0B_EXPERIMENT_ID", rule.experimentId, true) && + environmentAscii(L"STELLARX_INPUT_PROBE_0B_RULE_ID", rule.ruleId, true) && + environmentAscii(L"STELLARX_INPUT_PROBE_0B_ACTION", action) && + environmentAscii(L"STELLARX_INPUT_PROBE_0B_MESSAGE", message) && + environmentAscii(L"STELLARX_INPUT_PROBE_0B_IME_CONDITION", condition) && + environmentAscii(L"STELLARX_INPUT_PROBE_0B_INPUT_SOURCE", inputSource) && + environmentAscii(L"STELLARX_INPUT_PROBE_0B_BASELINE_ID", baselineId) && + environmentUnsigned(L"STELLARX_INPUT_PROBE_0B_VIRTUAL_KEY", virtualKey) && + environmentUnsigned(L"STELLARX_INPUT_PROBE_0B_SCAN_CODE", scanCode) && + environmentUnsigned(L"STELLARX_INPUT_PROBE_0B_MAX_MATCHES", maxMatches) && + environmentBoolean(L"STELLARX_INPUT_PROBE_0B_EXPECT_WINDOW_UNICODE", + rule.expectedWindowUnicode) && + environmentUnsigned(L"STELLARX_INPUT_PROBE_0B_EXPECT_ANSI_CODE_PAGE", + rule.expectedAnsiCodePage) && + environmentUnsigned(L"STELLARX_INPUT_PROBE_0B_EXPECT_DPI_FOR_WINDOW", + rule.expectedDpiForWindow) && + environmentSigned(L"STELLARX_INPUT_PROBE_0B_EXPECT_DPI_AWARENESS", + rule.expectedDpiAwareness); + + const bool baselineIdValid = isSha256BaselineId(baselineId); + if (baselineIdValid) + rule.baselineId = std::move(baselineId); + configurationValid = configurationValid && baselineIdValid && + parseProbeAction(action, rule.requestedAction) && + parseRuleMessage(message, rule.message) && + parseImeCondition(condition, rule.imeCondition) && + parseInputSource(inputSource, rule.inputSource) && + virtualKey > 0 && virtualKey <= 0xFFu && scanCode <= 0x100u && maxMatches > 0 && + maxMatches <= MaxConsumeRuleMatches && rule.expectedAnsiCodePage > 0 && + rule.expectedDpiForWindow > 0; + rule.virtualKey = virtualKey; + rule.scanCodeSpecified = scanCode <= 0xFFu; + rule.scanCode = rule.scanCodeSpecified ? static_cast(scanCode) : 0; + rule.maxMatches = maxMatches; + rule.configurationValid = configurationValid; + if (!configurationValid) + { + rule.failOpenReason = FailOpenReason::InvalidConfiguration; + return rule; + } + + if (rule.baselineId != Current0CBaselineId) + rule.failOpenReason = FailOpenReason::BaselineIdMismatch; + else if (rule.actualWindowUnicode != rule.expectedWindowUnicode) + rule.failOpenReason = FailOpenReason::WindowUnicodeMismatch; + else if (rule.actualAnsiCodePage != rule.expectedAnsiCodePage) + rule.failOpenReason = FailOpenReason::AnsiCodePageMismatch; + else if (rule.actualDpiForWindow != rule.expectedDpiForWindow) + rule.failOpenReason = FailOpenReason::DpiMismatch; + else if (rule.actualDpiAwareness != rule.expectedDpiAwareness) + rule.failOpenReason = FailOpenReason::DpiAwarenessMismatch; + else + rule.baselineMatched = true; + + if (!rule.baselineMatched) + return rule; + + if (rule.imeCondition == ImeStateCondition::ComposingOnly) + { + const bool identityConfirmed = + rule.inputSource == ProbeInputSource::Manual && + environmentFlagEnabled(L"STELLARX_INPUT_PROBE_0B_IME_IDENTITY_CONFIRMED") && + environmentAscii(L"STELLARX_INPUT_PROBE_0B_IME_NAME", rule.imeName, true); + if (!identityConfirmed) + { + rule.failOpenReason = FailOpenReason::ImeIdentityUnconfirmed; + return rule; + } + + int caretX = 0; + int caretY = 0; + int lineLeft = 0; + int lineTop = 0; + int lineRight = 0; + int lineBottom = 0; + rule.imePositionConfigured = + environmentSigned(L"STELLARX_INPUT_PROBE_0B_IME_CARET_X", caretX) && + environmentSigned(L"STELLARX_INPUT_PROBE_0B_IME_CARET_Y", caretY) && + environmentSigned(L"STELLARX_INPUT_PROBE_0B_IME_LINE_LEFT", lineLeft) && + environmentSigned(L"STELLARX_INPUT_PROBE_0B_IME_LINE_TOP", lineTop) && + environmentSigned(L"STELLARX_INPUT_PROBE_0B_IME_LINE_RIGHT", lineRight) && + environmentSigned(L"STELLARX_INPUT_PROBE_0B_IME_LINE_BOTTOM", lineBottom) && + caretX >= 0 && caretY >= 0 && lineLeft >= 0 && lineTop >= 0 && + lineRight > lineLeft && lineBottom > lineTop && + caretX >= lineLeft && caretX <= lineRight && + caretY >= lineTop && caretY <= lineBottom; + if (!rule.imePositionConfigured) + { + rule.failOpenReason = FailOpenReason::ImePositionUnavailable; + return rule; + } + rule.imeCaretPoint = POINT{ caretX, caretY }; + rule.imeLineRect = RECT{ lineLeft, lineTop, lineRight, lineBottom }; + } + + rule.eligible = true; + rule.failOpenReason = FailOpenReason::None; + return rule; + } + bool analyzeUtf16(const wchar_t* text, std::size_t length, std::uint32_t& scalarCount) noexcept { scalarCount = 0; @@ -799,13 +1240,6 @@ namespace StellarX::Internal state.totalLostCount.fetch_add(1, std::memory_order_relaxed); } - void noteLostObservation() noexcept - { - auto& state = probeState(); - state.contentionLostCount.fetch_add(1, std::memory_order_relaxed); - state.totalLostCount.fetch_add(1, std::memory_order_relaxed); - } - void pushRecord(const ProbeRecord& record) noexcept { auto& state = probeState(); @@ -815,14 +1249,15 @@ namespace StellarX::Internal if (state.count < state.hardLimit) { const std::size_t index = (state.head + state.count) % state.hardLimit; - state.records[index] = record; + state.recordBuffers[state.activeRecordBuffer][index] = record; ++state.count; return; } - const std::uint64_t lostSequence = state.records[state.head].sequence; + const std::uint64_t lostSequence = + state.recordBuffers[state.activeRecordBuffer][state.head].sequence; noteOverflowLostSequence(lostSequence); - state.records[state.head] = record; + state.recordBuffers[state.activeRecordBuffer][state.head] = record; state.head = (state.head + 1) % state.hardLimit; } @@ -1109,10 +1544,30 @@ namespace StellarX::Internal output << (value ? "true" : "false"); } + void appendExperimentTag(std::ostringstream& output, const ProbeRecord& record) + { + if (!record.experimentTagged) + return; + const auto& rule = probeState().consumeRule; + output << ",\"experimentId\":\"" << rule.experimentId + << "\",\"ruleId\":\"" << rule.ruleId << '"'; + if (record.type == RecordType::EasyXKey || record.type == RecordType::ImePosition) + return; + output << ",\"ruleMatched\":"; + appendBoolean(output, record.ruleMatched); + output << ",\"matchOrdinal\":" << record.matchOrdinal + << ",\"requestedAction\":\"" << probeActionName(record.requestedAction) + << "\",\"actualAction\":\"" << probeActionName(record.actualAction) + << "\",\"failOpenApplied\":"; + appendBoolean(output, record.failOpenApplied); + output << ",\"failOpenReason\":\"" << failOpenReasonName(record.failOpenReason) << '"'; + } + void appendCommon(std::ostringstream& output, const ProbeRecord& record) { const auto& state = probeState(); - output << "{\"schemaVersion\":1,\"recordType\":\"" << recordTypeName(record.type) + output << "{\"schemaVersion\":" << state.schemaVersion + << ",\"recordType\":\"" << recordTypeName(record.type) << "\",\"sessionId\":\"" << state.sessionId.data() << "\",\"sequence\":" << record.sequence << ",\"sourceBackend\":\"" << sourceBackendName(record.type) @@ -1168,6 +1623,54 @@ namespace StellarX::Internal << ",\"dpiAwareness\":" << record.dpiAwareness << ",\"ansiCodePage\":" << record.ansiCodePage; break; + case RecordType::ExperimentConfig: + { + const auto& rule = state.consumeRule; + const char* targetName = messageName(rule.message); + output << ",\"experimentId\":\"" << rule.experimentId + << "\",\"ruleId\":\"" << rule.ruleId + << "\",\"requestedAction\":\"" << probeActionName(rule.requestedAction) + << "\",\"targetMessageId\":" << rule.message + << ",\"targetMessage\":\"" << (targetName ? targetName : "unknown") + << "\",\"targetVirtualKey\":" << rule.virtualKey + << ",\"targetScanCode\":"; + if (rule.scanCodeSpecified) output << static_cast(rule.scanCode); + else output << "null"; + output + << ",\"imeCondition\":\"" << imeConditionName(rule.imeCondition) + << "\",\"maxMatches\":" << rule.maxMatches + << ",\"inputSource\":\"" << inputSourceName(rule.inputSource) + << "\",\"baselineId\":\"" << rule.baselineId + << "\",\"configurationValid\":"; + appendBoolean(output, rule.configurationValid); + output << ",\"baselineMatched\":"; + appendBoolean(output, rule.baselineMatched); + output << ",\"ruleEligible\":"; + appendBoolean(output, rule.eligible); + output << ",\"failOpenReason\":\"" << failOpenReasonName(rule.failOpenReason) + << "\",\"expectedWindowUnicode\":"; + appendBoolean(output, rule.expectedWindowUnicode); + output << ",\"actualWindowUnicode\":"; + appendBoolean(output, rule.actualWindowUnicode); + output << ",\"expectedAnsiCodePage\":" << rule.expectedAnsiCodePage + << ",\"actualAnsiCodePage\":" << rule.actualAnsiCodePage + << ",\"expectedDpiForWindow\":" << rule.expectedDpiForWindow + << ",\"actualDpiForWindow\":" << rule.actualDpiForWindow + << ",\"expectedDpiAwareness\":" << rule.expectedDpiAwareness + << ",\"actualDpiAwareness\":" << rule.actualDpiAwareness + << ",\"imePositionConfigured\":"; + appendBoolean(output, rule.imePositionConfigured); + output << ",\"imeCaretPoint\":{\"x\":" << rule.imeCaretPoint.x + << ",\"y\":" << rule.imeCaretPoint.y + << "},\"imeLineRect\":{\"left\":" << rule.imeLineRect.left + << ",\"top\":" << rule.imeLineRect.top + << ",\"right\":" << rule.imeLineRect.right + << ",\"bottom\":" << rule.imeLineRect.bottom << '}' + << ",\"imeName\":"; + if (rule.imeName.empty()) output << "null"; + else output << '"' << rule.imeName << '"'; + break; + } case RecordType::WndProcEnter: { const char* knownName = messageName(record.messageId); @@ -1270,6 +1773,22 @@ namespace StellarX::Internal << ",\"correlationTruncated\":"; appendBoolean(output, record.correlationTruncated); } + if (record.imePositionAttempted) + { + output << ",\"imePositionAttempted\":true,\"imePositionContextAvailable\":"; + appendBoolean(output, record.imePositionContextAvailable); + output << ",\"compositionPositionSucceeded\":"; + appendBoolean(output, record.compositionPositionSucceeded); + output << ",\"candidatePositionSucceeded\":"; + appendBoolean(output, record.candidatePositionSucceeded); + output << ",\"imeCaretPoint\":{\"x\":" << record.imeCaretPoint.x + << ",\"y\":" << record.imeCaretPoint.y + << "},\"imeLineRect\":{\"left\":" << record.imeLineRect.left + << ",\"top\":" << record.imeLineRect.top + << ",\"right\":" << record.imeLineRect.right + << ",\"bottom\":" << record.imeLineRect.bottom << '}'; + } + appendExperimentTag(output, record); break; } case RecordType::WndProcLeave: @@ -1280,7 +1799,30 @@ namespace StellarX::Internal appendBoolean(output, record.forwarded); output << ",\"forwardResultHex\":"; appendHex(output, record.forwardResult); + appendExperimentTag(output, record); break; + case RecordType::ImePosition: + { + const char* triggerName = messageName(record.messageId); + output << ",\"parentSequence\":" << record.parentSequence + << ",\"wndProcCallId\":" << record.wndProcCallId + << ",\"triggerMessageId\":" << record.messageId + << ",\"triggerMessage\":\"" << (triggerName ? triggerName : "unknown") + << "\",\"imePositionAttempted\":true,\"imePositionContextAvailable\":"; + appendBoolean(output, record.imePositionContextAvailable); + output << ",\"compositionPositionSucceeded\":"; + appendBoolean(output, record.compositionPositionSucceeded); + output << ",\"candidatePositionSucceeded\":"; + appendBoolean(output, record.candidatePositionSucceeded); + output << ",\"imeCaretPoint\":{\"x\":" << record.imeCaretPoint.x + << ",\"y\":" << record.imeCaretPoint.y + << "},\"imeLineRect\":{\"left\":" << record.imeLineRect.left + << ",\"top\":" << record.imeLineRect.top + << ",\"right\":" << record.imeLineRect.right + << ",\"bottom\":" << record.imeLineRect.bottom << '}'; + appendExperimentTag(output, record); + break; + } case RecordType::EasyXKey: output << ",\"batchId\":" << record.batchId << ",\"easyxMessageType\":\"" @@ -1293,6 +1835,7 @@ namespace StellarX::Internal output << ",\"easyxCtrl\":null,\"easyxShift\":null,\"easyxAlt\":null" << ",\"modifierSource\":\"not_available_in_ExMessage_key_variant\"" << ",\"easyxMessageTime\":null,\"existingDispatchDecision\":\"dispatch_existing\""; + appendExperimentTag(output, record); break; case RecordType::ImmQuery: output << ",\"parentSequence\":" << record.parentSequence @@ -1382,10 +1925,11 @@ namespace StellarX::Internal snapshot.hwnd = state.attachedHwnd; snapshot.hwndInstanceId = state.hwndInstanceId; snapshot.durable = durable || state.durableRequested; - for (std::size_t index = 0; index < state.count; ++index) - { - snapshot.records.push_back(state.records[(state.head + index) % state.hardLimit]); - } + snapshot.bufferIndex = state.activeRecordBuffer; + snapshot.head = state.head; + snapshot.count = state.count; + snapshot.hardLimit = state.hardLimit; + state.activeRecordBuffer ^= 1u; state.head = 0; state.count = 0; @@ -1395,7 +1939,7 @@ namespace StellarX::Internal loss.lostFirstSequence = state.overflowLostFirstSequence; loss.lostLastSequence = state.overflowLostLastSequence; loss.lostCountValue = state.overflowLostCount; - snapshot.records.push_back(loss); + snapshot.trailingRecords.push_back(loss); } state.overflowLostFirstSequence = 0; state.overflowLostLastSequence = 0; @@ -1408,7 +1952,7 @@ namespace StellarX::Internal ProbeRecord loss = makeRecord(RecordType::RecordsLost, state.attachedHwnd); loss.lossFromContention = true; loss.lostCountValue = contentionLoss; - snapshot.records.push_back(loss); + snapshot.trailingRecords.push_back(loss); } state.durableRequested = false; } @@ -1420,7 +1964,14 @@ namespace StellarX::Internal return false; try { - for (const ProbeRecord& record : snapshot.records) + for (std::size_t index = 0; index < snapshot.count; ++index) + { + const ProbeRecord& record = state.recordBuffers[snapshot.bufferIndex] + [(snapshot.head + index) % snapshot.hardLimit]; + if (!writeAll(state.file, serializeRecord(record))) + return false; + } + for (const ProbeRecord& record : snapshot.trailingRecords) { if (!writeAll(state.file, serializeRecord(record))) return false; @@ -1442,6 +1993,7 @@ namespace StellarX::Internal state.enabled.store(false, std::memory_order_release); const HANDLE file = state.file; state.file = INVALID_HANDLE_VALUE; + state.activeRecordBuffer = 0; state.head = 0; state.count = 0; ReleaseSRWLockExclusive(&state.lock); @@ -1449,6 +2001,187 @@ namespace StellarX::Internal CloseHandle(file); } + bool isImePositionTrigger(UINT message) noexcept + { + return message == WM_SETFOCUS || message == WM_IME_SETCONTEXT || + message == WM_IME_STARTCOMPOSITION || message == WM_IME_COMPOSITION; + } + + ImePositionRequest prepareImePositionRequestLocked( + ProbeState& state, HWND hwnd, const ProbeRecord& record) noexcept + { + ImePositionRequest request{}; + auto& rule = state.consumeRule; + if (!rule.requested || rule.inputSource != ProbeInputSource::Manual || + !rule.imePositionConfigured || !isImePositionTrigger(record.messageId) || + imePositionApiCallActive) + { + return request; + } + + request.active = true; + request.hwnd = hwnd; + request.hwndInstanceId = state.hwndInstanceId; + request.parentSequence = record.sequence; + request.wndProcCallId = record.wndProcCallId; + request.compositionId = record.compositionId; + request.triggerMessage = record.messageId; + request.caretPoint = rule.imeCaretPoint; + request.lineRect = rule.imeLineRect; + ++rule.imePositionAttemptCount; + return request; + } + + ImePositionResult executeImePositionRequest(const ImePositionRequest& request) noexcept + { + ImePositionResult result{}; + if (!request.active) + return result; + + // IMM32 setters may synchronously re-enter the window procedure. Keep both + // the probe state lock and recursive positioning outside this call chain. + imePositionApiCallActive = true; + const HIMC context = ImmGetContext(request.hwnd); + result.contextAvailable = context != nullptr; + if (!context) + { + imePositionApiCallActive = false; + return result; + } + + COMPOSITIONFORM composition{}; + composition.dwStyle = CFS_POINT; + composition.ptCurrentPos = request.caretPoint; + result.compositionSucceeded = + ImmSetCompositionWindow(context, &composition) != FALSE; + + CANDIDATEFORM candidate{}; + candidate.dwIndex = 0; + candidate.dwStyle = CFS_EXCLUDE; + candidate.ptCurrentPos = request.caretPoint; + candidate.rcArea = request.lineRect; + result.candidateSucceeded = ImmSetCandidateWindow(context, &candidate) != FALSE; + ImmReleaseContext(request.hwnd, context); + imePositionApiCallActive = false; + return result; + } + + void recordImePositionResult( + ProbeState& state, const ImePositionRequest& request, + const ImePositionResult& result) noexcept + { + if (!request.active) + return; + ExclusiveStateLock lock(state.lock); + if (!state.enabled.load(std::memory_order_relaxed) || + state.attachedHwnd != request.hwnd || + state.hwndInstanceId != request.hwndInstanceId) + { + return; + } + + auto& rule = state.consumeRule; + ProbeRecord record = makeRecord(RecordType::ImePosition, request.hwnd); + record.parentSequence = request.parentSequence; + record.wndProcCallId = request.wndProcCallId; + record.compositionId = request.compositionId; + record.messageId = request.triggerMessage; + record.imePositionAttempted = true; + record.imePositionContextAvailable = result.contextAvailable; + record.compositionPositionSucceeded = result.compositionSucceeded; + record.candidatePositionSucceeded = result.candidateSucceeded; + record.imeCaretPoint = request.caretPoint; + record.imeLineRect = request.lineRect; + record.experimentTagged = rule.requested; + pushRecord(record); + + if (result.compositionSucceeded && result.candidateSucceeded) + { + rule.imePositionReady = true; + ++rule.imePositionSuccessCount; + } + } + + void evaluateConsumeRuleLocked(ProbeState& state, UINT message, WPARAM wParam, + ProbeRecord& record, InputProbeWndProcToken& token) noexcept + { + auto& rule = state.consumeRule; + if (!rule.requested || message != rule.message || + static_cast(wParam) != rule.virtualKey || + (rule.scanCodeSpecified && record.scanCode != rule.scanCode)) + { + return; + } + + record.experimentTagged = true; + record.configurationValid = rule.configurationValid; + record.baselineMatched = rule.baselineMatched; + record.ruleEligible = rule.eligible; + record.requestedAction = rule.requestedAction; + record.actualAction = ProbeAction::ObserveAndForward; + record.failOpenReason = rule.failOpenReason; + token.experimentTagged = true; + auto copyFailOpenToToken = [&]() noexcept { + token.failOpenApplied = record.failOpenApplied; + token.failOpenReason = static_cast(record.failOpenReason); + }; + + if (!rule.eligible) + { + record.failOpenApplied = rule.requestedAction == ProbeAction::ObserveAndConsume; + copyFailOpenToToken(); + return; + } + + const bool imeConditionMet = rule.imeCondition == ImeStateCondition::Any || + (rule.imeCondition == ImeStateCondition::IdleOnly && !state.imeComposing) || + (rule.imeCondition == ImeStateCondition::ComposingOnly && state.imeComposing); + if (!imeConditionMet) + { + record.failOpenReason = FailOpenReason::ImeConditionNotMet; + record.failOpenApplied = rule.requestedAction == ProbeAction::ObserveAndConsume; + copyFailOpenToToken(); + return; + } + + if (rule.requestedAction == ProbeAction::ObserveAndConsume && + rule.imeCondition == ImeStateCondition::ComposingOnly && !rule.imePositionReady) + { + record.failOpenReason = FailOpenReason::ImePositionUnavailable; + record.failOpenApplied = true; + copyFailOpenToToken(); + return; + } + + if (state.totalLostCount.load(std::memory_order_relaxed) > 0 || + state.overflowLostCount > 0 || + state.contentionLostCount.load(std::memory_order_relaxed) > 0) + { + record.failOpenReason = FailOpenReason::CaptureLossObserved; + record.failOpenApplied = rule.requestedAction == ProbeAction::ObserveAndConsume; + copyFailOpenToToken(); + return; + } + + if (rule.matchCount >= rule.maxMatches) + { + record.failOpenReason = FailOpenReason::MatchLimitReached; + record.failOpenApplied = rule.requestedAction == ProbeAction::ObserveAndConsume; + copyFailOpenToToken(); + return; + } + + ++rule.matchCount; + record.ruleMatched = true; + record.matchOrdinal = rule.matchCount; + record.failOpenReason = FailOpenReason::None; + record.actualAction = rule.requestedAction; + token.ruleMatched = true; + token.matchOrdinal = rule.matchCount; + token.observeAndConsume = rule.requestedAction == ProbeAction::ObserveAndConsume; + copyFailOpenToToken(); + } + void materializeBatchBegin(HWND hwnd, InputProbeBatchToken& batch) noexcept { if (!batch.active || batch.materialized) @@ -1470,10 +2203,11 @@ namespace StellarX::Internal if (!hwnd || state.enabled.load(std::memory_order_acquire) || !environmentFlagEnabled(L"STELLARX_INPUT_PROBE")) return; + ExclusiveStateLock fileLock(probeFileWriteLock); FlushSnapshot snapshot; try { - snapshot.records.reserve(MaxBufferedRecords + 4); + snapshot.trailingRecords.reserve(4); ExclusiveStateLock lock(state.lock); if (state.enabled.load(std::memory_order_relaxed)) return; @@ -1482,6 +2216,8 @@ namespace StellarX::Internal state.hostThreadId = GetCurrentThreadId(); state.uiThreadId = GetWindowThreadProcessId(hwnd, nullptr); state.ansiCodePage = ansiCodePageForLayout(GetKeyboardLayout(state.uiThreadId)); + state.consumeRule = loadConsumeRule(hwnd, state.ansiCodePage); + state.schemaVersion = state.consumeRule.requested ? 2u : 1u; state.attachedHwnd = hwnd; state.hwndInstanceId = 1; state.nextSequence.store(0, std::memory_order_relaxed); @@ -1493,6 +2229,7 @@ namespace StellarX::Internal state.modalDepth = 0; state.imeComposing = false; state.echo = {}; + state.activeRecordBuffer = 0; state.head = 0; state.count = 0; state.overflowLostFirstSequence = 0; @@ -1543,6 +2280,8 @@ namespace StellarX::Internal attach.dpiAwareness = awarenessForWindow(hwnd); attach.ansiCodePage = state.ansiCodePage; pushRecord(attach); + if (state.consumeRule.requested) + pushRecord(makeRecord(RecordType::ExperimentConfig, hwnd)); captureSnapshotLocked(snapshot, false); } catch (...) @@ -1559,19 +2298,20 @@ namespace StellarX::Internal auto& state = probeState(); if (!state.enabled.load(std::memory_order_acquire)) return; + ExclusiveStateLock fileLock(probeFileWriteLock); FlushSnapshot snapshot; try { - snapshot.records.reserve(MaxBufferedRecords + 4); + snapshot.trailingRecords.reserve(4); ExclusiveStateLock lock(state.lock); if (!state.enabled.load(std::memory_order_relaxed) || hwnd != state.attachedHwnd) return; finishEchoCorrelationLocked(hwnd, CorrelationStatus::Expired); captureSnapshotLocked(snapshot, true); - snapshot.records.push_back(makeRecord(RecordType::WindowDetach, hwnd)); + snapshot.trailingRecords.push_back(makeRecord(RecordType::WindowDetach, hwnd)); ProbeRecord sessionEnd = makeRecord(RecordType::SessionEnd, nullptr); sessionEnd.lostCountValue = state.totalLostCount.load(std::memory_order_relaxed); - snapshot.records.push_back(sessionEnd); + snapshot.trailingRecords.push_back(sessionEnd); state.enabled.store(false, std::memory_order_release); state.attachedHwnd = nullptr; state.echo = {}; @@ -1607,15 +2347,12 @@ namespace StellarX::Internal token.message = message; if (!shouldObserveMessage(message, state.recordNonInputMessages)) return token; - TryExclusiveStateLock lock(state.lock); - if (!lock.acquired()) + ImePositionRequest positionRequest{}; { - noteLostObservation(); - return token; - } - if (!state.enabled.load(std::memory_order_relaxed) || hwnd != state.attachedHwnd) - return token; - token.callId = ++state.nextWndProcCallId; + ExclusiveStateLock lock(state.lock); + if (!state.enabled.load(std::memory_order_relaxed) || hwnd != state.attachedHwnd) + return token; + token.callId = ++state.nextWndProcCallId; ProbeRecord record = makeRecord(RecordType::WndProcEnter, hwnd); record.wndProcCallId = token.callId; @@ -1716,8 +2453,10 @@ namespace StellarX::Internal } if (message == WM_IME_ENDCOMPOSITION) record.imeStateAfter = false; + positionRequest = prepareImePositionRequestLocked(state, hwnd, record); if (isTextMessage(message)) correlateTextMessageLocked(record, wParam); + evaluateConsumeRuleLocked(state, message, wParam, record, token); token.compositionId = record.compositionId; token.enterSequence = record.sequence; @@ -1745,6 +2484,13 @@ namespace StellarX::Internal finishEchoCorrelationLocked(hwnd, CorrelationStatus::Expired); state.ansiCodePage = record.ansiCodePage; } + } + + // The enter record is visible before any re-entrant IMM32 work. Nested + // messages therefore receive later sequence numbers, and the result record + // is appended afterwards with parentSequence linking it to this enter. + const ImePositionResult positionResult = executeImePositionRequest(positionRequest); + recordImePositionResult(state, positionRequest, positionResult); return token; } @@ -1757,10 +2503,8 @@ namespace StellarX::Internal return result; if (token.recorded && state.enabled.load(std::memory_order_acquire)) { - TryExclusiveStateLock lock(state.lock); - if (!lock.acquired()) - noteLostObservation(); - else if (state.enabled.load(std::memory_order_relaxed)) + ExclusiveStateLock lock(state.lock); + if (state.enabled.load(std::memory_order_relaxed)) { ProbeRecord record = makeRecord(RecordType::WndProcLeave, state.attachedHwnd); record.wndProcCallId = token.callId; @@ -1769,6 +2513,14 @@ namespace StellarX::Internal record.compositionId = token.compositionId; record.forwarded = forwardedToOldProc; record.forwardResult = static_cast(static_cast(result)); + record.experimentTagged = token.experimentTagged; + record.ruleMatched = token.ruleMatched; + record.matchOrdinal = token.matchOrdinal; + record.requestedAction = state.consumeRule.requestedAction; + record.actualAction = token.observeAndConsume + ? ProbeAction::ObserveAndConsume : ProbeAction::ObserveAndForward; + record.failOpenApplied = token.failOpenApplied; + record.failOpenReason = static_cast(token.failOpenReason); pushRecord(record); if (token.message == WM_IME_ENDCOMPOSITION) finishEchoCorrelationLocked(state.attachedHwnd, CorrelationStatus::Expired); @@ -1821,6 +2573,10 @@ namespace StellarX::Internal record.easyxDown = message.message == WM_KEYDOWN; record.easyxExtended = message.extended; record.easyxPreviousDown = message.prevdown; + if (state.consumeRule.requested) + { + record.experimentTagged = true; + } pushRecord(record); } @@ -1856,10 +2612,11 @@ namespace StellarX::Internal auto& state = probeState(); if (!state.enabled.load(std::memory_order_acquire)) return; + ExclusiveStateLock fileLock(probeFileWriteLock); FlushSnapshot snapshot; try { - snapshot.records.reserve(MaxBufferedRecords + 4); + snapshot.trailingRecords.reserve(4); { ExclusiveStateLock lock(state.lock); if (!state.enabled.load(std::memory_order_relaxed) || hwnd != state.attachedHwnd || diff --git a/src/StellarX/Window.cpp b/src/StellarX/Window.cpp index 9778f0f..74cc58d 100644 --- a/src/StellarX/Window.cpp +++ b/src/StellarX/Window.cpp @@ -710,6 +710,19 @@ LRESULT CALLBACK Window::WndProcThunk(HWND h, UINT m, WPARAM w, LPARAM l) return DefWindowProc(h, m, w, l); } const auto probeCall = StellarX::Internal::inputProbeWndProcEnter(h, m, w, l); + if (probeCall.observeAndConsume) + { + // 阶段 0B 的唯一消费出口:仅诊断构建、有效单规则和匹配基线下可达。 + // 默认构建的 token 恒为 false,不改变生产窗口过程行为。 + return StellarX::Internal::inputProbeWndProcLeave(probeCall, 0, false); + } + if (m == WM_CLOSE) + { + // EasyX 的旧窗口过程可能在 WM_CLOSE 内直接终止进程,不保证返回到 leave、 + // WM_NCDESTROY 或宿主循环。这里是诊断会话最后一个可证明到达的耐久边界。 + // 默认构建中 inputProbeDetach 为空操作,不改变生产关闭路径。 + StellarX::Internal::inputProbeDetach(h); + } // 关键点①:禁止系统擦背景,避免和我们自己的清屏/双缓冲打架造成闪烁 if (m == WM_ERASEBKGND) @@ -922,7 +935,8 @@ int Window::runEventLoop() { if (!hasLiveNativeWindow()) { - StellarX::Internal::inputProbeFlushAtSafePoint(hWnd); + // 宿主循环已经确定退出;在 Window/EasyX 进一步改写句柄状态前结束探测会话。 + StellarX::Internal::inputProbeDetach(hWnd); return 0; } @@ -1047,7 +1061,9 @@ int Window::runEventLoop() StellarX::Internal::inputProbeHostBatchEnd(hWnd, probeBatch, batch.processed, batch.stopped); if (batch.stopped || !hasLiveNativeWindow()) { - StellarX::Internal::inputProbeFlushAtSafePoint(hWnd); + // batch.stopped(典型为 WM_CLOSE)是宿主线程最后一个可靠安全点。 + // detach 会落下 window_detach/session_end;默认构建中该调用为空操作。 + StellarX::Internal::inputProbeDetach(hWnd); return 0; } diff --git a/src/StellarX/internal/SxInputProbeInternal.h b/src/StellarX/internal/SxInputProbeInternal.h index 951f453..8c96123 100644 --- a/src/StellarX/internal/SxInputProbeInternal.h +++ b/src/StellarX/internal/SxInputProbeInternal.h @@ -17,6 +17,12 @@ namespace StellarX::Internal UINT message = 0; bool depthCounted = false; bool recorded = false; + bool experimentTagged = false; + bool ruleMatched = false; + bool observeAndConsume = false; + bool failOpenApplied = false; + std::uint8_t failOpenReason = 0; + std::uint32_t matchOrdinal = 0; }; struct InputProbeBatchToken diff --git a/tests/UiFixture.cpp b/tests/UiFixture.cpp index d46ad9b..e20bc06 100644 --- a/tests/UiFixture.cpp +++ b/tests/UiFixture.cpp @@ -35,7 +35,10 @@ int main(int argc, char** argv) // fixture 每次只运行一个确定性场景,并用唯一标题让驱动脚本精确绑定 HWND。 const std::string scenario = argc > 1 ? argv[1] : "geometry"; const std::filesystem::path resultDir = argc > 2 ? argv[2] : "TestResults"; - Window window(480, 320, 1, Background, "StellarX.UI." + scenario); + const bool consumeProbeScenario = scenario == "input-consume-probe"; + const int windowWidth = consumeProbeScenario ? 720 : 480; + const int windowHeight = consumeProbeScenario ? 420 : 320; + Window window(windowWidth, windowHeight, 1, Background, "StellarX.UI." + scenario); if (scenario == "ownership") { @@ -89,16 +92,25 @@ int main(int argc, char** argv) button->setTooltipText("stationary tip"); window.addControl(std::move(button)); } - else if (scenario == "input-probe") + else if (scenario == "input-probe" || scenario == "input-consume-probe") { // 该场景只提供一个可聚焦的 EasyX 顶层窗口,不让控件消费或改写键盘/IME 消息。 - const std::array instructions{ - "StellarX input probe - manual IME run", - "1. Click this window, then switch to the requested IME.", - "2. Type only the agreed non-sensitive test strings.", - "3. Cover commit, candidate select, cancel and language switch.", - "4. Close the window after the checklist is complete." - }; + const bool consumeProbe = scenario == "input-consume-probe"; + const std::array instructions = consumeProbe + ? std::array{ + "StellarX stage 0B isolated consume probe", + "This window is driven by the bounded diagnostic runner.", + "Only one message, key and action rule is active.", + "No control receives business keyboard routing.", + "The runner will close this window after capture." + } + : std::array{ + "StellarX input probe - manual IME run", + "1. Click this window, then switch to the requested IME.", + "2. Type only the agreed non-sensitive test strings.", + "3. Cover commit, candidate select, cancel and language switch.", + "4. Close the window after the checklist is complete." + }; int y = 30; for (const std::string& line : instructions) { @@ -107,6 +119,20 @@ int main(int argc, char** argv) window.addControl(std::move(label)); y += 42; } + if (consumeProbe) + { + // 与 runner 传给 IMM32 的固定客户区坐标完全一致:行区 (200,255)-(680,284), + // caret bottom 为 (225,282)。仅用于人工确认候选框没有落回桌面边缘。 + auto anchorLabel = std::make_unique