Skip to content

test(windows): 增加低层热键钩子回归#44

Merged
appergb merged 1 commit into
Open-Less:mainfrom
Cooper-X-Oak:codex/windows-hotkey-hook-pr
Apr 30, 2026
Merged

test(windows): 增加低层热键钩子回归#44
appergb merged 1 commit into
Open-Less:mainfrom
Cooper-X-Oak:codex/windows-hotkey-hook-pr

Conversation

@Cooper-X-Oak
Copy link
Copy Markdown
Contributor

@Cooper-X-Oak Cooper-X-Oak commented Apr 30, 2026

摘要

关联 issue:无。本 PR 是 Windows 热键核心回归门禁,不混入 ASR / 插入 / 权限修复。

本 PR 只解决一个目标:给 Windows 原生低层键盘 hook 增加可自动回归的 OS 触发门禁,避免 modifier-only 全局快捷键退化时只能靠人工按键发现。

fork/dev 先行验证

修复 / 新增 / 改进

  • 新增 windows-hotkey-os-hook-smoke.ps1,启动 release exe 后用右 Control 合成事件验证 Windows low-level hook 能到达 Coordinator。
  • Windows hook 默认仍过滤 injected keyboard event;仅当 OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS=1 时接受合成事件,供 smoke 脚本使用。
  • 增加 Windows trigger pressed/released 日志,方便真机回归定位 hook 是否收到事件。

兼容

  • 不包含:ASR、插入 fallback、麦克风权限、设置页文案改动。
  • 对现有用户 / 本地环境 / 构建流程的影响:正常运行默认继续过滤 injected 事件;只在显式设置测试环境变量时改变行为。

测试计划

  • fork PR CI:test(windows): 收敛 fork dev 真机回归批次 Cooper-X-Oak/openless#12 Windows Tauri checks / Sourcery review 均通过。
  • 命令:openless-all/app/scripts/windows-build-gnu.ps1
  • 结果:Windows GNU release/msi/nsis 构建通过。
  • 命令:openless-all/app/scripts/windows-hotkey-os-hook-smoke.ps1
  • 结果:Windows low-level hook observed vk=163 and reached Coordinator.
  • 证据路径:%LOCALAPPDATA%\OpenLess\Logs\openless.log

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Apr 30, 2026

Reviewer's Guide

Adds an automated Windows smoke test for the native low-level keyboard hook and introduces a guarded code path to accept synthetic hotkey events plus additional logging for trigger edges.

Sequence diagram for the Windows low-level hotkey smoke test reaching Coordinator

sequenceDiagram
    actor Tester
    participant SmokeScript as WindowsHotkeyOSHookSmokePs1
    participant OpenLessExe as OpenLessExe
    participant WindowsOS
    participant LowLevelHook as WindowsLowLevelHook
    participant HotkeyModule as HotkeyHandler
    participant Coordinator
    participant LogFile as OpenLessLog

    Tester->>SmokeScript: Run windows-hotkey-os-hook-smoke.ps1
    SmokeScript->>SmokeScript: Set env OPENLESS_SHOW_MAIN_ON_START=1
    SmokeScript->>SmokeScript: Set env OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS=1
    SmokeScript->>OpenLessExe: Start-Process openless.exe

    OpenLessExe->>WindowsOS: Register low-level keyboard hook
    WindowsOS->>LowLevelHook: Install hook callback
    LowLevelHook->>LogFile: Write "hotkey listener installed" / "Windows low-level keyboard hook"

    SmokeScript->>WindowsOS: Start notepad.exe
    WindowsOS->>SmokeScript: Return notepad process with window handle
    SmokeScript->>WindowsOS: Focus notepad window

    loop Up to 3 attempts
        SmokeScript->>WindowsOS: keybd_event(RControl, down)
        WindowsOS->>LowLevelHook: Deliver KBDLLHOOKSTRUCT (vk=RControl, injected)
        LowLevelHook->>HotkeyModule: accept_injected_events() (reads env)
        HotkeyModule-->>LowLevelHook: true when env OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS=1
        LowLevelHook->>HotkeyModule: dispatch_keyboard_event(vk=RControl, WM_KEYDOWN)
        HotkeyModule->>LogFile: Write "[hotkey] Windows trigger pressed vk=163"
        HotkeyModule->>Coordinator: Send HotkeyEvent.Pressed
        Coordinator->>LogFile: Write "[coord] hotkey pressed"
        SmokeScript->>LogFile: Poll for "[hotkey] Windows trigger pressed"
        SmokeScript->>WindowsOS: keybd_event(RControl, up)
        WindowsOS->>LowLevelHook: Deliver KBDLLHOOKSTRUCT (vk=RControl, injected)
        LowLevelHook->>HotkeyModule: dispatch_keyboard_event(vk=RControl, WM_KEYUP)
        HotkeyModule->>LogFile: Write "[hotkey] Windows trigger released vk=163"
    end

    SmokeScript->>LogFile: Poll for "[coord] hotkey pressed"
    SmokeScript-->>Tester: Report "Windows low-level hook observed vk=163 and reached Coordinator"
Loading

File-Level Changes

Change Details Files
Gate acceptance of injected keyboard events in the Windows low-level hook behind an environment variable and add trigger edge logging.
  • Introduce a constant for the OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS env var name.
  • Change the low-level keyboard hook callback to dispatch events if they are not injected or if injected-event acceptance is enabled.
  • Add log lines when the Windows hotkey trigger transitions to pressed and released states to aid debugging of hook behavior.
  • Add a helper function that checks the environment variable and returns whether to accept injected events.
openless-all/app/src-tauri/src/hotkey.rs
Add a PowerShell smoke test script that synthesizes a right-Control keypress to verify the Windows OS low-level hotkey hook reaches the Coordinator.
  • Define parameters for target executable path, timeout, and virtual-key code with defaults aimed at the Windows GNU release build and VK_RCONTROL.
  • Embed a small C# helper type via Add-Type to call user32 APIs for window focus and keybd_event-based synthetic key injection.
  • Implement utility functions to wait for log patterns with a timeout, send key down/up edges, focus a process window, and wait for a process with a main window.
  • Orchestrate the smoke test: clean up prior processes/logs, start OpenLess with environment variables to show main window and accept synthetic events, ensure the hook is installed, start Notepad and focus it, send synthetic trigger presses up to a few attempts, then assert that both the hook press log and the Coordinator hotkey press log appear, failing with descriptive errors otherwise, and finally tear down Notepad/OpenLess.
  • Print human-readable success messages when the hook and coordinator observation both succeed.
openless-all/app/scripts/windows-hotkey-os-hook-smoke.ps1

Possibly linked issues

  • #[windows] 热键链路缺少非人工自动注入门禁: PR 增加合成热键事件与日志,并用脚本验证事件经 OS hook 抵达 coordinator。

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • The accept_injected_events() check is evaluated on every hook callback; consider caching the env var in a static/once-initialized flag so the low-level keyboard hook hot path avoids repeated environment lookups.
  • Logging trigger press/release at info level on every key edge may be noisy in normal usage; consider switching these to debug or gating them behind a dedicated debug env flag.
  • In windows-hotkey-os-hook-smoke.ps1, Wait-LogPattern repeatedly reads the entire log with Get-Content -Raw; for large logs this can be slow, so consider limiting reads (e.g., -Tail or incremental reads) to reduce overhead while polling.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `accept_injected_events()` check is evaluated on every hook callback; consider caching the env var in a static/once-initialized flag so the low-level keyboard hook hot path avoids repeated environment lookups.
- Logging trigger press/release at `info` level on every key edge may be noisy in normal usage; consider switching these to `debug` or gating them behind a dedicated debug env flag.
- In `windows-hotkey-os-hook-smoke.ps1`, `Wait-LogPattern` repeatedly reads the entire log with `Get-Content -Raw`; for large logs this can be slow, so consider limiting reads (e.g., `-Tail` or incremental reads) to reduce overhead while polling.

## Individual Comments

### Comment 1
<location path="openless-all/app/src-tauri/src/hotkey.rs" line_range="535-538" />
<code_context>
             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);
                 }
</code_context>
<issue_to_address>
**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:

```rust
    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());

```

```rust
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.
</issue_to_address>

### Comment 2
<location path="openless-all/app/scripts/windows-hotkey-os-hook-smoke.ps1" line_range="58-63" />
<code_context>
+  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)
+}
+
</code_context>
<issue_to_address>
**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.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +535 to 538
if keyboard.flags.0 & LLKHF_INJECTED == 0 || accept_injected_events() {
dispatch_keyboard_event(ctx, keyboard.vkCode, wparam.0);
}
}
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.

Comment on lines +58 to +63
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)
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.

@Cooper-X-Oak Cooper-X-Oak force-pushed the codex/windows-hotkey-hook-pr branch from 6298bb2 to 3a13e56 Compare April 30, 2026 04:03
@Cooper-X-Oak
Copy link
Copy Markdown
Contributor Author

自审结论:通过,作为 fork/dev 已验证批次拆出的最小 upstream 维护项。

核对结果:

  • fork 先行 PR:test(windows): 收敛 fork dev 真机回归批次 Cooper-X-Oak/openless#12,已 merge 到 fork/dev,merge commit b9ee8b8
  • fork CI:Windows Tauri checks / Sourcery review 均 SUCCESS。
  • upstream PR 当前范围只包含 Windows hotkey core + smoke,没有混入 ASR、插入 fallback、权限、设置页文案等其他 Windows 真机问题。
  • 生产默认仍过滤 injected keyboard event;只有 smoke 显式设置 OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS=1 才放行合成事件。
  • upstream CI:Windows Tauri checks / Sourcery review 均 SUCCESS。

@appergb appergb merged commit 3d68708 into Open-Less:main Apr 30, 2026
2 checks passed
appergb pushed a commit that referenced this pull request Apr 30, 2026
包含本轮所有合并:
- Codex 终审两条 HIGH (cancel race) 修复 (PR #79)
- 6 个 Cooper-X-Oak/Codex bot PRs 自动合并 (#44 #49 #53 #68 #72 #73)
- 2 个有冲突 PR 本地 rebase 后合并 (#66 cancel + 空转写并存 / #67 Windows docs)
- README 破图修复 (PR #80)
- workflow-scope 受限的 #48 + #75 由用户在 GitHub UI 直接合并

3 处版本字段同步:package.json + tauri.conf.json + Cargo.toml
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants