From 9f8829e40e2e153a8119ffec2734ac7f57186fc3 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Thu, 13 Aug 2026 00:15:18 +0800 Subject: [PATCH] fix(windows): allow synthetic hotkeys to trigger OpenLess --- .../windows-capsule-lifecycle-smoke.ps1 | 123 ++++++++++++-- .../scripts/windows-hotkey-os-hook-smoke.ps1 | 157 +++++++++++++++--- .../windows-microphone-privacy-smoke.ps1 | 2 - .../windows-real-asr-insertion-smoke.ps1 | 2 - openless-all/app/src-tauri/src/coordinator.rs | 30 +++- .../src-tauri/src/coordinator/dictation.rs | 4 + openless-all/app/src-tauri/src/hotkey.rs | 47 ++++-- .../src/remote_server/pin_persistence.rs | 12 +- 8 files changed, 324 insertions(+), 53 deletions(-) diff --git a/openless-all/app/scripts/windows-capsule-lifecycle-smoke.ps1 b/openless-all/app/scripts/windows-capsule-lifecycle-smoke.ps1 index a5c72b994..96ca35126 100644 --- a/openless-all/app/scripts/windows-capsule-lifecycle-smoke.ps1 +++ b/openless-all/app/scripts/windows-capsule-lifecycle-smoke.ps1 @@ -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 Release-AllModifiers() { + foreach ($vk in @(0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0x5B, 0x5C)) { + Send-KeyEdge $vk $true + } } -function Get-CapsuleWindowState() { - $hwnd = [OpenLessCapsuleProbe]::FindWindowW($null, "OpenLess Capsule") +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 + } } diff --git a/openless-all/app/scripts/windows-hotkey-os-hook-smoke.ps1 b/openless-all/app/scripts/windows-hotkey-os-hook-smoke.ps1 index ae9a8fc25..e37a18554 100644 --- a/openless-all/app/scripts/windows-hotkey-os-hook-smoke.ps1 +++ b/openless-all/app/scripts/windows-hotkey-os-hook-smoke.ps1 @@ -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 $observedPress) { - throw "Windows hook did not observe synthetic vk=$VirtualKey press." + 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 (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 } diff --git a/openless-all/app/scripts/windows-microphone-privacy-smoke.ps1 b/openless-all/app/scripts/windows-microphone-privacy-smoke.ps1 index d5d0f0eb8..1c9a5b271 100644 --- a/openless-all/app/scripts/windows-microphone-privacy-smoke.ps1 +++ b/openless-all/app/scripts/windows-microphone-privacy-smoke.ps1 @@ -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 diff --git a/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 b/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 index 5f76f9349..d058e24a9 100644 --- a/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 +++ b/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 @@ -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 } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 55e8f5129..dbc4abf87 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -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), diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 45ab08417..2228876ce 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -3307,6 +3307,10 @@ pub(super) async fn end_session(inner: &Arc) -> 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(()); } diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 8a4901515..8121dc1cc 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -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 = 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); diff --git a/openless-all/app/src-tauri/src/remote_server/pin_persistence.rs b/openless-all/app/src-tauri/src/remote_server/pin_persistence.rs index 6141c0e75..2ae528cdf 100644 --- a/openless-all/app/src-tauri/src/remote_server/pin_persistence.rs +++ b/openless-all/app/src-tauri/src/remote_server/pin_persistence.rs @@ -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");