fix(windows): allow synthetic hotkeys to trigger OpenLess (#948)
This commit is contained in:
1 parent
94db63f248
commit
f008cc6f05
8 files changed
+324
-53
No files matched your search
@@ -15,21 +15,59 @@ if (-not (Test-Path $ExePath)) {
|
||||
}
|
||||
|
||||
$logPath = Join-Path $env:LOCALAPPDATA "OpenLess\Logs\openless.log"
|
||||
$existingOpenLess = @(Get-Process openless -ErrorAction SilentlyContinue)
|
||||
foreach ($existingProcess in $existingOpenLess) {
|
||||
Stop-Process -Id $existingProcess.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($existingOpenLess.Count -gt 0) {
|
||||
Start-Sleep -Milliseconds 300
|
||||
}
|
||||
Remove-Item -LiteralPath $logPath -Force -ErrorAction SilentlyContinue
|
||||
Get-Process openless -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
public static class OpenLessCapsuleProbe {
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
public static extern IntPtr FindWindowW(string lpClassName, string lpWindowName);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool IsWindowVisible(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool EnumWindows(EnumWindowsProc callback, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int maxCount);
|
||||
|
||||
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
||||
|
||||
public static IntPtr FindVisibleCapsuleWindowForProcess(int processId) {
|
||||
var result = IntPtr.Zero;
|
||||
EnumWindows((hWnd, _) => {
|
||||
if (!IsWindowVisible(hWnd)) {
|
||||
return true;
|
||||
}
|
||||
uint ownerPid;
|
||||
GetWindowThreadProcessId(hWnd, out ownerPid);
|
||||
if (ownerPid != (uint)processId) {
|
||||
return true;
|
||||
}
|
||||
var title = new StringBuilder(256);
|
||||
GetWindowText(hWnd, title, title.Capacity);
|
||||
if (title.ToString() == "OpenLess Capsule") {
|
||||
result = hWnd;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, IntPtr.Zero);
|
||||
return result;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern void keybd_event(byte bVk, byte bScan, int dwFlags, UIntPtr dwExtraInfo);
|
||||
|
||||
@@ -49,16 +87,57 @@ function Wait-LogPattern($Pattern, $TimeoutSeconds) {
|
||||
return $false
|
||||
}
|
||||
|
||||
function Get-LogCount($Pattern) {
|
||||
if (-not (Test-Path $logPath)) {
|
||||
return 0
|
||||
}
|
||||
return ([regex]::Matches((Get-Content -Raw $logPath), $Pattern)).Count
|
||||
}
|
||||
|
||||
function Get-KeyScanCode($Vk) {
|
||||
switch ([int]$Vk) {
|
||||
0xA0 { return 0x2A }
|
||||
0xA1 { return 0x36 }
|
||||
0xA2 { return 0x1D }
|
||||
0xA3 { return 0x1D }
|
||||
0xA4 { return 0x38 }
|
||||
0xA5 { return 0x38 }
|
||||
0x5B { return 0x5B }
|
||||
0x5C { return 0x5C }
|
||||
default { return 0 }
|
||||
}
|
||||
}
|
||||
|
||||
function Test-KeyExtended($Vk) {
|
||||
return @(
|
||||
0xA3, 0xA5, 0x5B, 0x5C
|
||||
) -contains [int]$Vk
|
||||
}
|
||||
|
||||
function Send-KeyEdge([byte]$Vk, [bool]$KeyUp) {
|
||||
$flags = [OpenLessCapsuleProbe]::KEYEVENTF_EXTENDEDKEY
|
||||
$flags = 0
|
||||
if (Test-KeyExtended $Vk) {
|
||||
$flags = $flags -bor [OpenLessCapsuleProbe]::KEYEVENTF_EXTENDEDKEY
|
||||
}
|
||||
if ($KeyUp) {
|
||||
$flags = $flags -bor [OpenLessCapsuleProbe]::KEYEVENTF_KEYUP
|
||||
}
|
||||
[OpenLessCapsuleProbe]::keybd_event($Vk, 0x1D, $flags, [UIntPtr]::Zero)
|
||||
[OpenLessCapsuleProbe]::keybd_event(
|
||||
$Vk,
|
||||
[byte](Get-KeyScanCode $Vk),
|
||||
$flags,
|
||||
[UIntPtr]::Zero
|
||||
)
|
||||
}
|
||||
|
||||
function Get-CapsuleWindowState() {
|
||||
$hwnd = [OpenLessCapsuleProbe]::FindWindowW($null, "OpenLess Capsule")
|
||||
function Release-AllModifiers() {
|
||||
foreach ($vk in @(0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0x5B, 0x5C)) {
|
||||
Send-KeyEdge $vk $true
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CapsuleWindowState($ProcessId) {
|
||||
$hwnd = [OpenLessCapsuleProbe]::FindVisibleCapsuleWindowForProcess($ProcessId)
|
||||
if ($hwnd -eq [IntPtr]::Zero) {
|
||||
return [pscustomobject]@{
|
||||
Exists = $false
|
||||
@@ -75,7 +154,6 @@ function Get-CapsuleWindowState() {
|
||||
}
|
||||
|
||||
Write-Host "== Windows capsule lifecycle smoke =="
|
||||
$env:OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS = "1"
|
||||
$env:OPENLESS_HOTKEY_INJECTION_DRY_RUN = "1"
|
||||
$process = Start-Process -FilePath $ExePath -WorkingDirectory (Split-Path $ExePath -Parent) -PassThru
|
||||
try {
|
||||
@@ -84,21 +162,34 @@ try {
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds 500
|
||||
$before = Get-CapsuleWindowState
|
||||
$before = Get-CapsuleWindowState $process.Id
|
||||
|
||||
Send-KeyEdge 0xA3 $false
|
||||
Start-Sleep -Milliseconds 120
|
||||
Send-KeyEdge 0xA3 $true
|
||||
|
||||
$startedDryRun = Wait-LogPattern "session started \(hotkey-injection dry-run\)" 5
|
||||
$startedDryRun = Wait-LogPattern "session started \(hotkey-injection dry-run\)" $TimeoutSeconds
|
||||
Start-Sleep -Milliseconds 400
|
||||
$afterStart = Get-CapsuleWindowState
|
||||
$afterStart = Get-CapsuleWindowState $process.Id
|
||||
|
||||
Send-KeyEdge 0xA3 $false
|
||||
Start-Sleep -Milliseconds 120
|
||||
Send-KeyEdge 0xA3 $true
|
||||
Start-Sleep -Seconds 3
|
||||
$afterStop = Get-CapsuleWindowState
|
||||
$afterStop = Get-CapsuleWindowState $process.Id
|
||||
|
||||
# Auto/hold semantics depend on the user's persisted mode. If the first short
|
||||
# cycle was interpreted as a long hold, it already stopped the first session;
|
||||
# the second cycle may therefore have started a new one. Close only that
|
||||
# observed extra dry-run session, while still failing on a single-session hide
|
||||
# regression instead of masking it with another key press.
|
||||
if ($afterStop.Visible -and (Get-LogCount "session started \(hotkey-injection dry-run\)") -gt 1) {
|
||||
Send-KeyEdge 0xA3 $false
|
||||
Start-Sleep -Milliseconds 120
|
||||
Send-KeyEdge 0xA3 $true
|
||||
Start-Sleep -Seconds 3
|
||||
$afterStop = Get-CapsuleWindowState $process.Id
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
StartedDryRun = $startedDryRun
|
||||
@@ -122,7 +213,9 @@ try {
|
||||
Write-Host "[ok] Capsule window is not visible after synthetic stop."
|
||||
}
|
||||
finally {
|
||||
Remove-Item Env:OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS -ErrorAction SilentlyContinue
|
||||
Release-AllModifiers
|
||||
Remove-Item Env:OPENLESS_HOTKEY_INJECTION_DRY_RUN -ErrorAction SilentlyContinue
|
||||
Get-Process openless -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||
if ($null -ne $process) {
|
||||
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
param(
|
||||
[string]$ExePath = "",
|
||||
[int]$TimeoutSeconds = 20,
|
||||
[int]$VirtualKey = 0xA3
|
||||
[int]$VirtualKey = 0xA3,
|
||||
[int]$Iterations = 20
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -36,6 +37,57 @@ public static class OpenLessInput {
|
||||
[DllImport("user32.dll")]
|
||||
public static extern void keybd_event(byte bVk, byte bScan, int dwFlags, UIntPtr dwExtraInfo);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MOUSEINPUT {
|
||||
public int dx;
|
||||
public int dy;
|
||||
public uint mouseData;
|
||||
public uint dwFlags;
|
||||
public uint time;
|
||||
public UIntPtr dwExtraInfo;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct KEYBDINPUT {
|
||||
public ushort wVk;
|
||||
public ushort wScan;
|
||||
public uint dwFlags;
|
||||
public uint time;
|
||||
public UIntPtr dwExtraInfo;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Explicit)]
|
||||
private struct INPUT_UNION {
|
||||
[FieldOffset(0)] public MOUSEINPUT mi;
|
||||
[FieldOffset(0)] public KEYBDINPUT ki;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct INPUT {
|
||||
public uint type;
|
||||
public INPUT_UNION U;
|
||||
}
|
||||
|
||||
public static uint SendInputKey(byte bVk, bool keyUp) {
|
||||
var extended = bVk == 0xA3 || bVk == 0xA5 || bVk == 0x5B || bVk == 0x5C;
|
||||
var input = new INPUT {
|
||||
type = 1,
|
||||
U = new INPUT_UNION {
|
||||
ki = new KEYBDINPUT {
|
||||
wVk = bVk,
|
||||
wScan = 0,
|
||||
dwFlags = (uint)((keyUp ? KEYEVENTF_KEYUP : 0) | (extended ? KEYEVENTF_EXTENDEDKEY : 0)),
|
||||
time = 0,
|
||||
dwExtraInfo = UIntPtr.Zero
|
||||
}
|
||||
}
|
||||
};
|
||||
return SendInput(1, new[] { input }, Marshal.SizeOf(typeof(INPUT)));
|
||||
}
|
||||
|
||||
public const int KEYEVENTF_EXTENDEDKEY = 0x0001;
|
||||
public const int KEYEVENTF_KEYUP = 0x0002;
|
||||
}
|
||||
@@ -55,12 +107,73 @@ function Wait-LogPattern($Path, $Pattern, $TimeoutSeconds) {
|
||||
return $false
|
||||
}
|
||||
|
||||
function Send-KeyEdge($Vk, $KeyUp) {
|
||||
$flags = [OpenLessInput]::KEYEVENTF_EXTENDEDKEY
|
||||
function Get-KeyScanCode($Vk) {
|
||||
switch ([int]$Vk) {
|
||||
0xA0 { return 0x2A }
|
||||
0xA1 { return 0x36 }
|
||||
0xA2 { return 0x1D }
|
||||
0xA3 { return 0x1D }
|
||||
0xA4 { return 0x38 }
|
||||
0xA5 { return 0x38 }
|
||||
0x5B { return 0x5B }
|
||||
0x5C { return 0x5C }
|
||||
default { return 0 }
|
||||
}
|
||||
}
|
||||
|
||||
function Test-KeyExtended($Vk) {
|
||||
return @(
|
||||
0xA3, 0xA5, 0x5B, 0x5C
|
||||
) -contains [int]$Vk
|
||||
}
|
||||
|
||||
function Send-KeyEdge($Vk, $KeyUp, [ValidateSet("keybd_event", "SendInput")] [string]$Method) {
|
||||
if ($Method -eq "SendInput") {
|
||||
if ([OpenLessInput]::SendInputKey([byte]$Vk, [bool]$KeyUp) -ne 1) {
|
||||
throw "SendInput failed for vk=$Vk keyUp=$KeyUp (Win32=$([Runtime.InteropServices.Marshal]::GetLastWin32Error()))."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
$flags = 0
|
||||
if (Test-KeyExtended $Vk) {
|
||||
$flags = $flags -bor [OpenLessInput]::KEYEVENTF_EXTENDEDKEY
|
||||
}
|
||||
if ($KeyUp) {
|
||||
$flags = $flags -bor [OpenLessInput]::KEYEVENTF_KEYUP
|
||||
}
|
||||
[OpenLessInput]::keybd_event([byte]$Vk, 0x1D, $flags, [UIntPtr]::Zero)
|
||||
[OpenLessInput]::keybd_event(
|
||||
[byte]$Vk,
|
||||
[byte](Get-KeyScanCode $Vk),
|
||||
$flags,
|
||||
[UIntPtr]::Zero
|
||||
)
|
||||
}
|
||||
|
||||
function Release-AllModifiers() {
|
||||
foreach ($vk in @(0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0x5B, 0x5C)) {
|
||||
# keybd_event 的 key-up 可清理由两种注入 API 设置的系统修饰键状态;
|
||||
# 重复 key-up 是幂等的,适合在失败路径兜底。
|
||||
Send-KeyEdge $vk $true "keybd_event"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-LogCount($Path, $Pattern) {
|
||||
if (-not (Test-Path $Path)) {
|
||||
return 0
|
||||
}
|
||||
return ([regex]::Matches((Get-Content -Raw $Path), $Pattern)).Count
|
||||
}
|
||||
|
||||
function Wait-LogCount($Path, $Pattern, $Minimum, $TimeoutSeconds) {
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if ((Get-LogCount $Path $Pattern) -ge $Minimum) {
|
||||
return $true
|
||||
}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Focus-Window($Process) {
|
||||
@@ -94,12 +207,10 @@ Get-Process openless -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||
|
||||
Write-Host "== Windows OS hotkey hook smoke =="
|
||||
$env:OPENLESS_SHOW_MAIN_ON_START = "1"
|
||||
$env:OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS = "1"
|
||||
try {
|
||||
Start-Process -FilePath $ExePath -WorkingDirectory (Split-Path $ExePath -Parent) | Out-Null
|
||||
} finally {
|
||||
Remove-Item Env:OPENLESS_SHOW_MAIN_ON_START -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$notepad = $null
|
||||
@@ -115,26 +226,34 @@ try {
|
||||
throw "Notepad window could not be focused."
|
||||
}
|
||||
|
||||
$observedPress = $false
|
||||
for ($attempt = 1; $attempt -le 3 -and -not $observedPress; $attempt++) {
|
||||
Send-KeyEdge $VirtualKey $false
|
||||
$observedPress = Wait-LogPattern $logPath "\[hotkey\] Windows trigger pressed" 4
|
||||
Start-Sleep -Milliseconds 400
|
||||
Send-KeyEdge $VirtualKey $true
|
||||
if (-not $observedPress) {
|
||||
Start-Sleep -Milliseconds 500
|
||||
Focus-Window $notepad | Out-Null
|
||||
$methods = @("keybd_event", "SendInput")
|
||||
foreach ($method in $methods) {
|
||||
$pressedBefore = Get-LogCount $logPath "\[hotkey\] Windows trigger pressed"
|
||||
$releasedBefore = Get-LogCount $logPath "\[hotkey\] Windows trigger released"
|
||||
Write-Host "Testing $method with $Iterations complete down/up cycles..."
|
||||
|
||||
for ($iteration = 1; $iteration -le $Iterations; $iteration++) {
|
||||
Send-KeyEdge $VirtualKey $false $method
|
||||
Start-Sleep -Milliseconds 35
|
||||
Send-KeyEdge $VirtualKey $true $method
|
||||
Start-Sleep -Milliseconds 35
|
||||
}
|
||||
|
||||
if (-not (Wait-LogCount $logPath "\[hotkey\] Windows trigger pressed" ($pressedBefore + $Iterations) $TimeoutSeconds)) {
|
||||
throw "$method did not produce $Iterations Windows trigger pressed events."
|
||||
}
|
||||
if (-not (Wait-LogCount $logPath "\[hotkey\] Windows trigger released" ($releasedBefore + $Iterations) $TimeoutSeconds)) {
|
||||
throw "$method did not produce $Iterations Windows trigger released events."
|
||||
}
|
||||
Write-Host "[ok] $method produced $Iterations complete hotkey cycles."
|
||||
}
|
||||
|
||||
if (-not $observedPress) {
|
||||
throw "Windows hook did not observe synthetic vk=$VirtualKey press."
|
||||
}
|
||||
if (-not (Wait-LogPattern $logPath "\[coord\] hotkey pressed" $TimeoutSeconds)) {
|
||||
throw "Coordinator did not observe OS hook hotkey press."
|
||||
}
|
||||
Write-Host "[ok] Windows low-level hook observed vk=$VirtualKey and reached Coordinator."
|
||||
Write-Host "[ok] Windows low-level hook accepted keybd_event and SendInput for vk=$VirtualKey."
|
||||
} finally {
|
||||
Release-AllModifiers
|
||||
if ($null -ne $notepad) {
|
||||
Stop-Process -Id $notepad.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
@@ -194,12 +194,10 @@ function Invoke-HotkeyAttempt($ExpectedPattern, $UnexpectedPattern, $Label) {
|
||||
Remove-Item -LiteralPath $logPath -Force -ErrorAction SilentlyContinue
|
||||
|
||||
$env:OPENLESS_SHOW_MAIN_ON_START = "1"
|
||||
$env:OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS = "1"
|
||||
try {
|
||||
Start-Process -FilePath $ExePath -WorkingDirectory (Split-Path $ExePath -Parent) | Out-Null
|
||||
} finally {
|
||||
Remove-Item Env:OPENLESS_SHOW_MAIN_ON_START -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$notepad = $null
|
||||
|
||||
@@ -944,7 +944,6 @@ try {
|
||||
|
||||
Write-Host "== Real ASR + direct insertion smoke ($Target, ASR=$AsrProvider) =="
|
||||
$env:OPENLESS_SHOW_MAIN_ON_START = "1"
|
||||
$env:OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS = "1"
|
||||
if ($DebugHotkeyEvents) {
|
||||
$env:OPENLESS_DEBUG_HOTKEY_EVENTS = "1"
|
||||
}
|
||||
@@ -955,7 +954,6 @@ try {
|
||||
$openless = Start-Process -FilePath $ExePath -WorkingDirectory (Split-Path $ExePath -Parent) -PassThru
|
||||
} finally {
|
||||
Remove-Item Env:OPENLESS_SHOW_MAIN_ON_START -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:OPENLESS_DEBUG_HOTKEY_EVENTS -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:OPENLESS_DEBUG_TRANSCRIPT_FILE -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
@@ -4452,7 +4452,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_dictation_from_listening_without_asr_returns_idle() {
|
||||
async fn stop_dictation_from_listening_without_asr_returns_idle_and_hides_capsule() {
|
||||
let coordinator = Coordinator::new();
|
||||
{
|
||||
let mut state = coordinator.inner.state.lock();
|
||||
@@ -4463,6 +4463,20 @@ mod tests {
|
||||
coordinator.stop_dictation().await.unwrap();
|
||||
|
||||
assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(
|
||||
CAPSULE_AUTO_HIDE_DELAY_MS + 100,
|
||||
))
|
||||
.await;
|
||||
assert_eq!(
|
||||
coordinator
|
||||
.inner
|
||||
.last_capsule_state
|
||||
.lock()
|
||||
.as_ref()
|
||||
.copied(),
|
||||
Some(CapsuleState::Idle),
|
||||
"无 ASR 句柄的停止路径也必须调度胶囊隐藏"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -4598,6 +4612,20 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn toggle_press_within_cooldown_is_dropped() {
|
||||
let coordinator = Coordinator::new();
|
||||
// Coordinator::new() 读取真实持久化偏好;测试必须固定自己的模式,不能让本机
|
||||
// 当前设置(例如 Hold/Auto)改变该用例验证的 Toggle 冷却语义。
|
||||
coordinator
|
||||
.inner
|
||||
.prefs
|
||||
.set(crate::types::UserPreferences {
|
||||
hotkey: crate::types::HotkeyBinding {
|
||||
trigger: HotkeyTrigger::RightControl,
|
||||
mode: HotkeyMode::Toggle,
|
||||
keys: None,
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
// Idle + 冷却未过期:模拟「识别中按下 → 会话收尾 → bridge 取出该 Pressed」的时刻。
|
||||
*coordinator.inner.session_cooldown_until.lock() = Some(
|
||||
std::time::Instant::now() + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS),
|
||||
|
||||
@@ -3307,6 +3307,10 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
if !finish_cancelled_processing(inner, current_session_id) {
|
||||
set_phase_idle_if_session_matches(inner, current_session_id);
|
||||
// Dry-run、启动竞态或 ASR 初始化失败都可能让收尾时没有可用的
|
||||
// ASR 句柄。phase 已经回到 Idle 后仍必须安排胶囊收起,否则
|
||||
// 无 ASR 的测试/异常路径会把 Transcribing 胶囊永久留在屏幕上。
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ mod tests {
|
||||
Shared {
|
||||
binding: RwLock::new(HotkeyBinding::default()),
|
||||
trigger_held: AtomicBool::new(true),
|
||||
trigger_press_id: AtomicU64::new(0),
|
||||
trigger_companion_seen: AtomicU64::new(0),
|
||||
trigger_press_id: AtomicU64::new(42),
|
||||
trigger_companion_seen: AtomicU64::new(42),
|
||||
qa_trigger: RwLock::new(None),
|
||||
qa_trigger_held: AtomicBool::new(true),
|
||||
selection_polish_trigger: RwLock::new(None),
|
||||
@@ -67,6 +67,8 @@ mod tests {
|
||||
reset_shared_held_state(&shared);
|
||||
|
||||
assert!(!shared.trigger_held.load(Ordering::SeqCst));
|
||||
assert_eq!(shared.trigger_press_id.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(shared.trigger_companion_seen.load(Ordering::SeqCst), 0);
|
||||
assert!(!shared.qa_trigger_held.load(Ordering::SeqCst));
|
||||
assert!(!shared.selection_polish_trigger_held.load(Ordering::SeqCst));
|
||||
assert!(!shared.translation_trigger_held.load(Ordering::SeqCst));
|
||||
@@ -86,6 +88,8 @@ mod tests {
|
||||
|
||||
assert_eq!(*shared.binding.read(), next);
|
||||
assert!(!shared.trigger_held.load(Ordering::SeqCst));
|
||||
assert_eq!(shared.trigger_press_id.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(shared.trigger_companion_seen.load(Ordering::SeqCst), 0);
|
||||
assert!(shared.qa_trigger_held.load(Ordering::SeqCst));
|
||||
assert!(shared.selection_polish_trigger_held.load(Ordering::SeqCst));
|
||||
assert!(shared.translation_trigger_held.load(Ordering::SeqCst));
|
||||
@@ -339,6 +343,12 @@ fn update_shared_binding(shared: &Shared, binding: HotkeyBinding) {
|
||||
shared
|
||||
.trigger_held
|
||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
shared
|
||||
.trigger_press_id
|
||||
.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
shared
|
||||
.trigger_companion_seen
|
||||
.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn update_shared_modifier_shortcuts(
|
||||
@@ -368,6 +378,9 @@ fn reset_shared_held_state(shared: &Shared) {
|
||||
shared
|
||||
.trigger_companion_seen
|
||||
.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
shared
|
||||
.trigger_press_id
|
||||
.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
shared
|
||||
.qa_trigger_held
|
||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
@@ -1059,9 +1072,6 @@ mod platform {
|
||||
const VK_RWIN: u32 = 0x5C;
|
||||
const VK_LWIN: u32 = 0x5B;
|
||||
const VK_MEDIA_PLAY_PAUSE: u32 = 0xB3;
|
||||
const LLKHF_INJECTED: u32 = 0x0000_0010;
|
||||
const ACCEPT_INJECTED_ENV: &str = "OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS";
|
||||
|
||||
static HOOK_CONTEXT: AtomicPtr<CallbackContext> = AtomicPtr::new(std::ptr::null_mut());
|
||||
|
||||
pub fn start_adapter(
|
||||
@@ -1201,6 +1211,10 @@ mod platform {
|
||||
if let Some(hook) = (*context).hook.lock().unwrap().take() {
|
||||
let _ = UnhookWindowsHookEx(hook);
|
||||
}
|
||||
// 监听线程可能在触发键仍处于按下状态时退出(配置重载、应用关闭或
|
||||
// hook 消息循环异常结束)。先清理内部锁存,避免下一次监听器复用
|
||||
// 共享状态时把旧的按下状态带过去。
|
||||
super::reset_shared_held_state(&(*context).shared);
|
||||
HOOK_CONTEXT.store(std::ptr::null_mut(), AtomicOrdering::SeqCst);
|
||||
let _ = Box::from_raw(context);
|
||||
}
|
||||
@@ -1214,10 +1228,11 @@ mod platform {
|
||||
if code == HC_ACTION as i32 && lparam.0 != 0 {
|
||||
if let Some(ctx) = callback_context() {
|
||||
let keyboard = *(lparam.0 as *const KBDLLHOOKSTRUCT);
|
||||
if keyboard.flags.0 & LLKHF_INJECTED == 0 || accept_injected_events() {
|
||||
if dispatch_keyboard_event(ctx, keyboard.vkCode, wparam.0) {
|
||||
return LRESULT(1);
|
||||
}
|
||||
// 合成输入(SendInput/keybd_event)与真实键盘统一走同一条分发路径。
|
||||
// 只要事件的虚拟键值匹配当前配置,现有的边沿去重和组合键撤销逻辑
|
||||
// 仍然负责决定是否触发 OpenLess;这里不再按合成输入来源过滤。
|
||||
if dispatch_keyboard_event(ctx, keyboard.vkCode, wparam.0) {
|
||||
return LRESULT(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1426,10 +1441,6 @@ mod platform {
|
||||
}
|
||||
}
|
||||
|
||||
fn accept_injected_events() -> bool {
|
||||
std::env::var(ACCEPT_INJECTED_ENV).ok().as_deref() == Some("1")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1521,6 +1532,16 @@ mod platform {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_unrelated_key_does_not_trigger_configured_modifier() {
|
||||
let shared = shared(HotkeyTrigger::RightControl);
|
||||
let (ctx, rx) = callback_context(shared);
|
||||
|
||||
assert!(!dispatch_keyboard_event(&ctx, 0x41, WM_KEYDOWN));
|
||||
assert!(!dispatch_keyboard_event(&ctx, 0x41, WM_KEYUP));
|
||||
assert!(drain(&rx).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_modifier_edges_ignore_unrelated_keys_and_reemit_after_release() {
|
||||
let shared = shared(HotkeyTrigger::RightControl);
|
||||
|
||||
@@ -563,7 +563,17 @@ mod tests {
|
||||
let path = root.pin_path();
|
||||
let target = root.0.join("outside-target.txt");
|
||||
std::fs::write(&target, "123456").unwrap();
|
||||
symlink_file(&target, &path).unwrap();
|
||||
match symlink_file(&target, &path) {
|
||||
Ok(()) => {}
|
||||
// Creating Windows symlinks requires SeCreateSymbolicLinkPrivilege unless
|
||||
// Developer Mode is enabled. Keep the security assertion, but do not turn
|
||||
// a missing test-environment privilege into a product-test failure.
|
||||
Err(error) if error.raw_os_error() == Some(1314) => {
|
||||
eprintln!("skipping Windows symlink test: symbolic-link privilege is unavailable");
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("failed to create test symlink: {error}"),
|
||||
}
|
||||
|
||||
assert!(load_or_create_test_pin(&path).is_err());
|
||||
assert_eq!(std::fs::read_to_string(&target).unwrap(), "123456");
|
||||
|
||||
Reference in new issue
Block a user