Skip to content

fix(hotkey): 补齐 Windows 主窗口前台热键#90

Merged
appergb merged 2 commits into
Open-Less:mainfrom
H-Chris233:main
Apr 30, 2026
Merged

fix(hotkey): 补齐 Windows 主窗口前台热键#90
appergb merged 2 commits into
Open-Less:mainfrom
H-Chris233:main

Conversation

@H-Chris233
Copy link
Copy Markdown
Collaborator

@H-Chris233 H-Chris233 commented Apr 30, 2026

摘要

Fixes #85

本 PR 修复 Windows 上 OpenLess 主窗口处于前台时,默认右 Ctrl 热键不触发的问题。

此前 Windows 全局热键在外部窗口前台时可以正常进入 WH_KEYBOARD_LL 路径,但当焦点位于 OpenLess 主窗口自身时,UI/DOM 能收到 ControlRight,后端全局 hook 路径却没有对应热键事件,导致默认 rightControl 无法触发按住说话。

本次改动在 Windows 主窗口前台时增加一条窗口键盘事件兜底路径:前端只转发热键相关按键,后端根据当前 hotkey.trigger 匹配 event.key / event.code,命中后复用既有 handle_pressed / handle_released / cancel_session 语义。全局 hook 路径保持不变。

修复 / 新增 / 改进

  • Windows 主窗口前台时监听 keydown / keyup

  • 前端仅转发热键相关按键,避免把普通输入键盘事件发送到后端:

    • Left Ctrl / Right Ctrl
    • Left Alt / Right Alt
    • Right Meta
    • Fn
    • Esc
  • 新增前端 IPC 方法:

    • handleWindowHotkeyEvent
  • 新增后端 Tauri command,用于接收窗口前台热键事件。

  • 后端按当前 hotkey.trigger 匹配窗口事件中的 event.key / event.code

  • 命中当前热键后复用既有 coordinator 路径:

    • handle_pressed
    • handle_released
    • cancel_session
  • 增加 RightCtrl 等侧键匹配测试,覆盖侧键专用 code 匹配行为。

  • 注册新的 Tauri command。

兼容

  • 不包含:

    • 不改变 Windows 全局 WH_KEYBOARD_LL hook 路径。
    • 不移除或替换现有热键适配器。
    • 不改变 macOS CGEventTap 实现。
    • 不把普通键盘输入统一上报到后端。
    • 不新增新的默认热键。
    • 不引入新依赖。
  • 对现有用户 / 本地环境 / 构建流程的影响:

    • Windows 用户在 OpenLess 主窗口前台时,也可以使用默认右 Ctrl 热键。
    • 外部窗口前台时,仍走原有 Windows 全局 hook 路径。
    • 侧键匹配按 event.code 保留左右键差异,例如 ControlRight
    • macOS 和非 Windows 平台不受该窗口前台兜底逻辑影响。
    • 构建流程无变化。

测试计划

  • 命令:cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml

  • 结果:通过,仅有既有 warnings

  • 证据路径:本地检查输出

  • 命令:cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml window_key_matcher_uses_side_specific_modifier_codes

  • 结果:通过

  • 证据路径:本地测试输出

  • 命令:npm run build

  • 结果:通过

  • 证据路径:本地构建输出

  • 命令:git diff --check

  • 结果:通过

  • 证据路径:本地命令输出

主要改动文件

  • openless-all/app/src/App.tsx
  • openless-all/app/src/lib/ipc.ts
  • openless-all/app/src-tauri/src/commands.rs
  • openless-all/app/src-tauri/src/coordinator.rs
  • openless-all/app/src-tauri/src/lib.rs

备注

本 PR 只补齐 OpenLess 主窗口前台时的 Windows 热键兜底路径。外部窗口前台时仍依赖现有全局 hook,不改变原有系统级热键行为。

Summary by Sourcery

Add a Windows foreground window hotkey path so the main OpenLess window can react to the configured trigger without relying solely on the global keyboard hook.

Bug Fixes:

  • Ensure the default Right Ctrl (and other side-specific modifier) hotkey works when the Windows main window is focused by forwarding relevant key events from the UI to the backend.

Enhancements:

  • Deduplicate hotkey press/release handling with an edge detector to avoid repeated trigger handling from overlapping event sources.
  • Restrict forwarded window key events to a small set of hotkey-related keys to avoid sending normal typing input to the backend.

Tests:

  • Add unit tests to verify foreground window key matching for side-specific hotkey triggers mirrors the Windows trigger aliases.

Windows can deliver modifier-only key events to the focused WebView without the low-level hook seeing the same edge, so the main window now forwards only hotkey-relevant DOM key events to the coordinator. Both native hook and window-forwarded events share the same coordinator edge gate so a working hook does not double-trigger toggle sessions.

Constraint: Issue Open-Less#85 requires OpenLess foreground Right Control to reuse existing pressed/released/cancel semantics without changing global hook behavior

Rejected: Start dictation directly in the frontend | would duplicate coordinator state-machine semantics

Rejected: Keep a separate window-held flag | review found it can double-trigger toggle mode when both DOM and low-level hook report the same physical press

Confidence: medium

Scope-risk: moderate

Tested: cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml

Tested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml window_key_matcher_mirrors_windows_trigger_aliases

Tested: npm run build

Tested: git diff --check

Not-tested: Physical Windows foreground Right Control on this Linux host
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Apr 30, 2026

Reviewer's Guide

Adds a Windows-foreground hotkey fallback path by forwarding specific key events from the Tauri frontend to the Rust coordinator, which matches them against the configured hotkey trigger and reuses the existing press/release/cancel logic while deduplicating edges and preserving the legacy global hook path.

Sequence diagram for Windows foreground hotkey handling

sequenceDiagram
    actor User
    participant Window as TauriWindow
    participant App as App_tsx
    participant IPC as ipc_ts
    participant Tauri as TauriCommands
    participant Coord as Coordinator
    participant Inner as InnerState

    User->>Window: Focus OpenLess main window
    User->>Window: Press RightControl
    Window->>App: keydown event (KeyboardEvent)
    App->>App: isWindowHotkeyCandidate(event)
    alt hotkey candidate
        App->>IPC: handleWindowHotkeyEvent("keydown", key, code, repeat)
        IPC->>Tauri: invoke handle_window_hotkey_event
        Tauri->>Coord: handle_window_hotkey_event(event_type, key, code, repeat)
        Coord->>Inner: handle_window_hotkey_event(inner, event_type, key, code, repeat)
        alt event_type == keydown and key == Escape
            Inner->>Inner: cancel_session
        else Windows target
            Inner->>Inner: window_key_matches_trigger(trigger, key, code)
            alt matches trigger and repeat == false
                Inner->>Inner: handle_pressed_edge
                Inner->>Inner: hotkey_trigger_held.swap(true)
                alt was_held == false
                    Inner->>Inner: handle_pressed
                end
            end
        end
    else non hotkey candidate
        App-->>Window: Ignore event
    end

    User->>Window: Release RightControl
    Window->>App: keyup event (KeyboardEvent)
    App->>App: isWindowHotkeyCandidate(event)
    alt hotkey candidate
        App->>IPC: handleWindowHotkeyEvent("keyup", key, code, repeat)
        IPC->>Tauri: invoke handle_window_hotkey_event
        Tauri->>Coord: handle_window_hotkey_event(event_type, key, code, repeat)
        Coord->>Inner: handle_window_hotkey_event(inner, event_type, key, code, repeat)
        alt event_type == keyup and matches trigger
            Inner->>Inner: handle_released_edge
            Inner->>Inner: hotkey_trigger_held.swap(false)
            alt was_held == true
                Inner->>Inner: handle_released
            end
        end
    else non hotkey candidate
        App-->>Window: Ignore event
    end
Loading

Updated class diagram for Coordinator hotkey handling

classDiagram
    class Inner {
        +Mutex<Option<Recorder>> recorder
        +Mutex<Option<HotkeyMonitor>> hotkey
        +Mutex<HotkeyStatus> hotkey_status
        +AtomicBool hotkey_trigger_held
    }

    class Coordinator {
        +inner: Arc<Inner>
        +new() Coordinator
        +cancel_dictation() void
        +handle_window_hotkey_event(event_type: String, key: String, code: String, repeat: bool) Result~(), String~
    }

    class CommandsRs {
        +handle_window_hotkey_event(coord: CoordinatorState, event_type: String, key: String, code: String, repeat: bool) Result~(), String~
    }

    class App_tsx {
        +useEffect_osGate()
        +useEffect_windowHotkeyForwarder()
        +isWindowHotkeyCandidate(event: KeyboardEvent) bool
    }

    class Ipc_ts {
        +handleWindowHotkeyEvent(eventType: string, key: string, code: string, repeat: boolean) Promise~void~
    }

    class HotkeyTrigger {
        <<enum>>
        RightControl
        LeftControl
        RightOption
        RightAlt
        LeftOption
        RightCommand
        Fn
    }

    class WindowKeyMatcher {
        +window_key_matches_trigger(trigger: HotkeyTrigger, key: &str, code: &str) bool
    }

    class HotkeyEdges {
        +handle_pressed_edge(inner: &Arc<Inner>) void
        +handle_released_edge(inner: &Arc<Inner>) void
        +handle_pressed(inner: &Arc<Inner>) void
        +handle_released(inner: &Arc<Inner>) void
        +handle_window_hotkey_event(inner: &Arc<Inner>, event_type: String, key: String, code: String, repeat: bool) Result~(), String~
    }

    Coordinator --> Inner
    CommandsRs --> Coordinator
    Ipc_ts --> CommandsRs
    App_tsx --> Ipc_ts
    HotkeyEdges --> Inner
    WindowKeyMatcher --> HotkeyTrigger
    HotkeyEdges --> WindowKeyMatcher
    HotkeyEdges --> HotkeyTrigger
Loading

File-Level Changes

Change Details Files
Add a foreground-window hotkey IPC flow from frontend to backend and wire it into the coordinator
  • Expose a new handleWindowHotkeyEvent IPC wrapper in the frontend that invokes the Tauri command with event type, key, code, and repeat
  • Declare a new async Tauri command handle_window_hotkey_event that delegates to the coordinator
  • Register the new command in the Tauri invoke_handler so it is callable from the UI
openless-all/app/src/lib/ipc.ts
openless-all/app/src-tauri/src/commands.rs
openless-all/app/src-tauri/src/lib.rs
Listen for hotkey-related key events when the Windows main window is focused and forward only candidate keys
  • In the React root component, install keydown/keyup listeners only on Windows Tauri, using capture phase to see events even when focused elements consume them
  • Filter events with isWindowHotkeyCandidate so only Esc and modifier-like keys (left/right Ctrl, left/right Alt, right Meta, Fn) are forwarded to the backend
  • Log forwarded events at debug level and handle IPC errors with a warn log, and cleanly remove listeners on unmount or OS change
openless-all/app/src/App.tsx
Extend the coordinator to handle window-originated hotkey events and deduplicate edge transitions across global and window paths
  • Add an AtomicBool hotkey_trigger_held to track whether the trigger is currently considered pressed
  • Introduce handle_pressed_edge and handle_released_edge helpers that gate calls to handle_pressed/handle_released based on the held flag, and switch the global hotkey bridge loop to use these edge-aware helpers
  • Implement handle_window_hotkey_event that cancels on Escape, no-ops on non-Windows, and on Windows matches the event’s key/code against the configured trigger, ignores repeats, logs, and dispatches to the edge handlers
openless-all/app/src-tauri/src/coordinator.rs
Implement Windows-specific key/code matching for configured hotkey triggers with tests covering side-specific behavior
  • Add window_key_matches_trigger that maps HotkeyTrigger variants to expected DOM key/code combinations, preserving side-specific distinctions (e.g., ControlRight vs ControlLeft, AltRight for both RightOption and LeftOption according to existing aliases, Fn mapping)
  • Add unit tests that verify positive matches for each trigger/candidate pair and negative matches for mismatched sides or Fn key/code that should not match
  • Use the matcher in the window hotkey handler to decide when to trigger the press/release logic
openless-all/app/src-tauri/src/coordinator.rs

Assessment against linked issues

Issue Objective Addressed Explanation
#85 Add a Windows main-window foreground keyboard fallback so that when OpenLess is focused, hotkey events (especially default Right Control) are detected and routed through the existing coordinator pressed/released/cancel semantics.
#85 Ensure the existing global WH_KEYBOARD_LL hotkey path and non-Windows platforms remain unchanged, with the new behavior only acting as a foreground-window fallback on Windows.

Possibly linked issues


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 left some high level feedback:

  • The handleWindowHotkeyEvent IPC uses eventType as the key on the frontend but the Tauri command expects an event_type argument, so the payload field name should be aligned on one side or the other to ensure Tauri correctly binds the parameter.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `handleWindowHotkeyEvent` IPC uses `eventType` as the key on the frontend but the Tauri command expects an `event_type` argument, so the payload field name should be aligned on one side or the other to ensure Tauri correctly binds the parameter.

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.

@appergb appergb merged commit 42dc4fe into Open-Less:main Apr 30, 2026
2 checks passed
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.

[windows] OpenLess 前台时默认右 Ctrl 热键不触发

2 participants