fix(windows): allow synthetic hotkeys to trigger OpenLess (#948)

This commit is contained in:
Chris233 authored and GitHub committed 2026-08-14 22:04:47 +08:00
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
}
+29 -1
View File
@@ -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(());
}
+34 -13
View File
@@ -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");