Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions openless-all/app/scripts/windows-hotkey-os-hook-smoke.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
param(
[string]$ExePath = "",
[int]$TimeoutSeconds = 20,
[int]$VirtualKey = 0xA3
)

$ErrorActionPreference = "Stop"

if ([string]::IsNullOrWhiteSpace($ExePath)) {
$appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$ExePath = Join-Path $appRoot "src-tauri\target\x86_64-pc-windows-gnu\release\openless.exe"
}

if (-not $env:SystemDrive) {
$env:SystemDrive = "C:"
}
if (-not $env:ProgramData) {
$env:ProgramData = Join-Path $env:SystemDrive "ProgramData"
}

if (-not (Test-Path $ExePath)) {
throw "OpenLess executable not found: $ExePath"
}

Add-Type @"
using System;
using System.Runtime.InteropServices;

public static class OpenLessInput {
[DllImport("user32.dll")]
public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);

[DllImport("user32.dll")]
public static extern bool SetForegroundWindow(IntPtr hWnd);

[DllImport("user32.dll")]
public static extern void keybd_event(byte bVk, byte bScan, int dwFlags, UIntPtr dwExtraInfo);

public const int KEYEVENTF_EXTENDEDKEY = 0x0001;
public const int KEYEVENTF_KEYUP = 0x0002;
}
"@

function Wait-LogPattern($Path, $Pattern, $TimeoutSeconds) {
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
if (Test-Path $Path) {
$text = Get-Content -Raw $Path
if ($text -match $Pattern) {
return $true
}
}
Start-Sleep -Milliseconds 250
}
return $false
}

function Send-KeyEdge($Vk, $KeyUp) {
$flags = [OpenLessInput]::KEYEVENTF_EXTENDEDKEY
if ($KeyUp) {
$flags = $flags -bor [OpenLessInput]::KEYEVENTF_KEYUP
}
[OpenLessInput]::keybd_event([byte]$Vk, 0x1D, $flags, [UIntPtr]::Zero)
Comment on lines +58 to +63
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Send-KeyEdge uses a fixed scan code that may not match the provided virtual key.

The helper always uses scan code 0x1D (Control) when calling keybd_event, which only matches the default $Vk = 0xA3. If callers pass a different virtual key, the scan code/virtual key pair becomes inconsistent. Consider deriving the scan code from the virtual key (e.g., via MapVirtualKey) or exposing the scan code as a parameter so they always match.

}

function Focus-Window($Process) {
if ($null -eq $Process -or $Process.MainWindowHandle -eq 0) {
return $false
}
[OpenLessInput]::ShowWindow($Process.MainWindowHandle, 9) | Out-Null
[OpenLessInput]::SetForegroundWindow($Process.MainWindowHandle) | Out-Null
Start-Sleep -Milliseconds 500
return $true
}

function Wait-ProcessWindow($ProcessName, $After, $TimeoutSeconds) {
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
$candidates = Get-Process $ProcessName -ErrorAction SilentlyContinue |
Where-Object { $_.StartTime -ge $After -and $_.MainWindowHandle -ne 0 } |
Sort-Object StartTime -Descending
$windowProcess = @($candidates) | Select-Object -First 1
if ($null -ne $windowProcess) {
return $windowProcess
}
Start-Sleep -Milliseconds 300
}
return $null
}

$logPath = Join-Path $env:LOCALAPPDATA "OpenLess\Logs\openless.log"
Remove-Item -LiteralPath $logPath -Force -ErrorAction SilentlyContinue
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
try {
if (-not (Wait-LogPattern $logPath "hotkey listener installed|Windows low-level keyboard hook" $TimeoutSeconds)) {
throw "Windows low-level keyboard hook was not installed within $TimeoutSeconds seconds."
}

$notepadStart = Get-Date
Start-Process notepad.exe | Out-Null
$notepad = Wait-ProcessWindow "notepad" $notepadStart 15
if (-not (Focus-Window $notepad)) {
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
}
}

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."
} finally {
if ($null -ne $notepad) {
Stop-Process -Id $notepad.Id -Force -ErrorAction SilentlyContinue
}
Get-Process openless -ErrorAction SilentlyContinue | Stop-Process -Force
}

Write-Host "Windows OS hotkey hook smoke passed."
9 changes: 8 additions & 1 deletion openless-all/app/src-tauri/src/hotkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ mod platform {
const VK_RMENU: u32 = 0xA5;
const VK_RWIN: u32 = 0x5C;
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());

Expand Down Expand Up @@ -531,7 +532,7 @@ 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 {
if keyboard.flags.0 & LLKHF_INJECTED == 0 || accept_injected_events() {
dispatch_keyboard_event(ctx, keyboard.vkCode, wparam.0);
}
}
Comment on lines +535 to 538
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (performance): Consider caching the environment flag instead of re-reading it for every keyboard event.

This hook runs on every keyboard event, so repeatedly calling accept_injected_events() (and thus std::env::var) adds avoidable overhead to a hot path. Consider resolving the env var once (e.g., into a static OnceLock<bool> / LazyLock<bool> or an AtomicBool) and reading a cached bool here instead.

Suggested implementation:

    const VK_RWIN: u32 = 0x5C;
    const LLKHF_INJECTED: u32 = 0x0000_0010;
    const ACCEPT_INJECTED_ENV: &str = "OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS";

    /// Cached value of whether we should accept synthetic/injected keyboard events.
    ///
    /// This is initialized once from the `OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS`
    /// environment variable the first time `accept_injected_events()` is called,
    /// and then read cheaply on every subsequent keyboard hook invocation.
    static ACCEPT_INJECTED_EVENTS: std::sync::OnceLock<bool> = std::sync::OnceLock::new();

    static HOOK_CONTEXT: AtomicPtr<CallbackContext> = AtomicPtr::new(std::ptr::null_mut());
fn accept_injected_events() -> bool {
    // Initialize once from the environment and reuse the cached value.
    *ACCEPT_INJECTED_EVENTS.get_or_init(|| {
        std::env::var(ACCEPT_INJECTED_ENV)
            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
            .unwrap_or(false)
    })
}
  1. Ensure there is an existing fn accept_injected_events() -> bool definition matching the SEARCH block; if the current implementation differs, adjust the SEARCH pattern accordingly when applying the replacement.
  2. If you already import OnceLock elsewhere (e.g., use std::sync::OnceLock;), you can simplify the static type from std::sync::OnceLock<bool> to just OnceLock<bool> and rely on the existing import. If you do not, you may add use std::sync::OnceLock; at the top and update the static accordingly.

Expand Down Expand Up @@ -564,12 +565,14 @@ mod platform {
WM_KEYDOWN | WM_SYSKEYDOWN => {
let was_held = ctx.shared.trigger_held.swap(true, Ordering::SeqCst);
if !was_held {
log::info!("[hotkey] Windows trigger pressed vk={vk_code}");
send_or_log(&ctx.tx, HotkeyEvent::Pressed);
}
}
WM_KEYUP | WM_SYSKEYUP => {
let was_held = ctx.shared.trigger_held.swap(false, Ordering::SeqCst);
if was_held {
log::info!("[hotkey] Windows trigger released vk={vk_code}");
send_or_log(&ctx.tx, HotkeyEvent::Released);
}
}
Expand All @@ -593,6 +596,10 @@ mod platform {
HotkeyTrigger::Fn => VK_RCONTROL,
}
}

fn accept_injected_events() -> bool {
std::env::var(ACCEPT_INJECTED_ENV).ok().as_deref() == Some("1")
}
}

// ─────────────────────────── Linux / other implementation ───────────────────────────
Expand Down
Loading