diff --git a/docs/github-tracking/issue-153-qa-helper-native-spec.md b/docs/github-tracking/issue-153-qa-helper-native-spec.md new file mode 100644 index 00000000..2044e6f3 --- /dev/null +++ b/docs/github-tracking/issue-153-qa-helper-native-spec.md @@ -0,0 +1,100 @@ +## Issue #153 Native Spec + +Scope: Windows-native helper-window implementation for QA panel + +Status: + +- This document is the implementation-spec layer for `#153` +- It replaces "keep trying shared drag carriers" with a platform-layered plan +- Product goal stays shared with macOS; implementation becomes Windows-native + +### Shared Product Contract + +The QA panel must satisfy the same product goal across platforms: + +1. Non-disruptive to the source app context +2. Clickable controls: + - close + - pin +3. Draggable from toolbar/background drag affordance +4. Safe multi-turn follow-up usage +5. Dismiss means non-participating, not merely visually hidden + +### Windows Native Contract + +Windows should not reuse macOS's implementation shape as the primary carrier. + +Instead, the Windows-specific helper-window must explicitly define: + +1. Window creation attributes + - topmost helper window + - transparent window is allowed only if it does not break hit-test semantics + - no taskbar entry + - activation behavior must be explicitly chosen, not inherited accidentally + +2. Native drag semantics + - drag region must be recognized at the native window/message layer + - do not depend on shared async drag helpers as the primary mechanism + - toolbar drag and control-hit regions must be natively separated + +3. Native click semantics + - close and pin controls must remain clickable + - drag region must not swallow control clicks + - hit-test mapping must distinguish: + - drag region + - control buttons + - normal client area + +4. Source-app context relation + - normal show path should avoid stealing the user's upstream context unnecessarily + - if Windows drag requires temporary focus/activation semantics, this must be treated as an explicit transition, not an accident + - after drag/dismiss, helper-window semantics must be restored + +### Current Baseline Findings + +Upstream-based repro branch confirms: + +- QA feature exists +- hotkey path can be made healthy +- close path can be healthy +- drag is the remaining isolated interaction gap + +This means the implementation target is now narrow: + +```text +Fix Windows-native draggable semantics for QA helper-window +without reopening unrelated QA / UI / hotkey scope. +``` + +### Implementation Boundaries + +In scope: + +- Windows QA helper-window creation/runtime behavior +- native hit-test / message routing for drag region +- preserving clickable controls while enabling drag + +Out of scope: + +- main window frame/radius/shadow issues +- Capsule geometry / lifecycle work from other families +- unrelated provider / insertion / polish changes + +### Acceptance Criteria + +- [ ] `Ctrl+Shift+;` still opens and closes QA panel +- [ ] close button remains clickable +- [ ] pin remains clickable +- [ ] toolbar drag works on Windows +- [ ] no regression in follow-up QA flow +- [ ] implementation remains scoped to Windows-native helper-window semantics only + +### Notes + +The key design rule is: + +```text +Same product goal, different OS carriers. +Windows must own its native helper-window carrier instead of inheriting +macOS-shaped interaction assumptions. +``` diff --git a/docs/github-tracking/pr-184-qa-helper-native.md b/docs/github-tracking/pr-184-qa-helper-native.md new file mode 100644 index 00000000..d7215f9b --- /dev/null +++ b/docs/github-tracking/pr-184-qa-helper-native.md @@ -0,0 +1,65 @@ +## Summary + +Closes #153 + +This code PR is the upstream-based delivery branch for the narrowed Windows repair: + +```text +Windows native drag semantics for QA helper-window +``` + +This PR follows a layered strategy: + +- keep the same product goal as macOS +- stop assuming the same implementation carrier works on Windows +- move Windows behavior toward a native helper-window contract + +## Scope + +In scope: + +- Windows-native QA helper-window interaction semantics +- drag-region / click-region separation +- preserving non-disruptive QA workflow + +Out of scope: + +- main window appearance +- Capsule family work +- unrelated QA renderer redesign + +## Shared Product Goal + +The PR keeps the same product target: + +- non-disruptive helper window +- clickable close / pin +- draggable toolbar region +- follow-up QA flow stays healthy +- dismiss returns to non-participating state + +## Windows-native Direction + +This PR is not meant to keep stacking shared drag workarounds. + +It exists to drive the implementation toward: + +- Windows-specific helper-window carrier +- native hit-test / message ownership where needed +- explicit separation of drag surface and control surface + +## Validation Target + +- [x] upstream-based branch exists and builds +- [x] narrowed baseline established: hotkey + close can be healthy while drag remains broken +- [ ] Windows drag becomes healthy on this branch +- [ ] reviewer-side Windows regression confirms: + - open + - close + - drag + - follow-up QA + +## Related Anchors + +- #156 remains the tracking / design-convergence PR +- #158 remains the governance anchor for helper-window / native-window contract thinking diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 2baeddd3..ff004a64 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -26,6 +26,8 @@ mod types; #[cfg(target_os = "macos")] use std::sync::mpsc; use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(target_os = "windows")] +use std::sync::atomic::AtomicIsize; use std::sync::Arc; #[cfg(target_os = "macos")] use std::time::Duration; @@ -33,6 +35,10 @@ use std::time::Duration; /// 第一次 show 时把 QA 浮窗摆到屏幕底部居中;之后的 show 不再 reposition, /// 让用户拖动后的位置在 hide → show 之间得以保持。详见 issue #118 v2。 static QA_WINDOW_POSITIONED: AtomicBool = AtomicBool::new(false); +#[cfg(target_os = "windows")] +static QA_WNDPROC_INSTALLED: AtomicBool = AtomicBool::new(false); +#[cfg(target_os = "windows")] +static QA_ORIGINAL_WNDPROC: AtomicIsize = AtomicIsize::new(0); use tauri::menu::{MenuBuilder, MenuItemBuilder}; use tauri::tray::{MouseButton, TrayIconBuilder, TrayIconEvent}; use tauri::{AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, RunEvent, Runtime}; @@ -77,6 +83,8 @@ pub fn run() { } #[cfg(target_os = "macos")] make_qa_window_draggable_macos(&qa); + #[cfg(target_os = "windows")] + install_qa_drag_hit_test(&qa); let _ = qa.hide(); } else { log::info!("[qa] qa 窗口未在 tauri.conf.json 中声明,前端 agent 会补上"); @@ -516,6 +524,9 @@ pub(crate) fn show_qa_window(app: &AppHandle, content_kind } #[cfg(target_os = "windows")] { + if let Err(e) = window.set_ignore_cursor_events(false) { + log::warn!("[qa] show: set_ignore_cursor_events(false) failed: {e}"); + } if !show_qa_window_no_activate(&window) { log::warn!("[qa] show_no_activate failed; falling back to window.show()"); if let Err(e) = window.show() { @@ -565,6 +576,15 @@ fn make_qa_window_draggable_macos(window: &tauri::WebviewWind /// 隐藏 QA 窗口。供 commands::qa_window_dismiss / coordinator session 收尾共用。 pub(crate) fn hide_qa_window(app: &AppHandle) { if let Some(window) = app.get_webview_window("qa") { + #[cfg(target_os = "windows")] + { + if let Err(e) = window.set_ignore_cursor_events(true) { + log::warn!("[qa] hide: set_ignore_cursor_events(true) failed: {e}"); + } + if hide_qa_window_non_participating() { + return; + } + } let _ = window.hide(); } } @@ -604,6 +624,120 @@ fn show_qa_window_no_activate(window: &tauri::WebviewWindow(window: &tauri::WebviewWindow) { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use windows::Win32::Foundation::{HWND, LRESULT}; + use windows::Win32::UI::WindowsAndMessaging::{ + CallWindowProcW, GetClientRect, GetWindowRect, SetWindowLongPtrW, GWLP_WNDPROC, + HTCAPTION, HTCLIENT, WM_NCHITTEST, WNDPROC, + }; + + if QA_WNDPROC_INSTALLED.load(Ordering::SeqCst) { + return; + } + + let Ok(handle) = window.window_handle() else { + log::warn!("[qa] window_handle unavailable; drag hit-test install skipped"); + return; + }; + let RawWindowHandle::Win32(raw) = handle.as_raw() else { + log::warn!("[qa] raw window handle is not Win32; drag hit-test install skipped"); + return; + }; + let hwnd = HWND(raw.hwnd.get() as *mut _); + if hwnd.0.is_null() { + log::warn!("[qa] hwnd null; drag hit-test install skipped"); + return; + } + + unsafe extern "system" fn qa_wndproc( + hwnd: HWND, + msg: u32, + wparam: windows::Win32::Foundation::WPARAM, + lparam: windows::Win32::Foundation::LPARAM, + ) -> LRESULT { + if msg == WM_NCHITTEST { + let mut rect = windows::Win32::Foundation::RECT::default(); + let _ = GetClientRect(hwnd, &mut rect); + let mut window_rect = windows::Win32::Foundation::RECT::default(); + let _ = GetWindowRect(hwnd, &mut window_rect); + + let point_x = (lparam.0 & 0xffff) as i16 as i32 - window_rect.left; + let point_y = ((lparam.0 >> 16) & 0xffff) as i16 as i32 - window_rect.top; + let toolbar_height = 32; + let right_controls_width = 76; + let draggable_right = (rect.right - right_controls_width).max(0); + + if point_y >= 0 + && point_y < toolbar_height + && point_x >= 0 + && point_x < draggable_right + { + return LRESULT(HTCAPTION as isize); + } + return LRESULT(HTCLIENT as isize); + } + + let original = QA_ORIGINAL_WNDPROC.load(Ordering::SeqCst); + if original == 0 { + return LRESULT(0); + } + let proc: WNDPROC = Some(std::mem::transmute(original)); + unsafe { CallWindowProcW(proc, hwnd, msg, wparam, lparam) } + } + + let previous = unsafe { + SetWindowLongPtrW(hwnd, GWLP_WNDPROC, qa_wndproc as *const () as usize as isize) + }; + if previous != 0 { + QA_ORIGINAL_WNDPROC.store(previous, Ordering::SeqCst); + QA_WNDPROC_INSTALLED.store(true, Ordering::SeqCst); + log::info!("[qa] installed native drag hit-test on QA window"); + } else { + log::warn!("[qa] SetWindowLongPtrW returned 0; drag hit-test install may have failed"); + } +} + +#[cfg(target_os = "windows")] +fn hide_qa_window_non_participating() -> bool { + use std::iter::once; + use windows::Win32::Foundation::HWND; + use windows::Win32::UI::WindowsAndMessaging::{ + FindWindowW, SetWindowPos, ShowWindow, HWND_NOTOPMOST, SWP_HIDEWINDOW, SWP_NOACTIVATE, + SWP_NOMOVE, SWP_NOSIZE, SW_HIDE, + }; + use windows::core::PCWSTR; + + let title: Vec = "OpenLess QA".encode_utf16().chain(once(0)).collect(); + let hwnd = match unsafe { FindWindowW(PCWSTR::null(), PCWSTR(title.as_ptr())) } { + Ok(hwnd) => hwnd, + Err(_) => return false, + }; + if hwnd.0.is_null() { + return false; + } + + let _ = unsafe { ShowWindow(hwnd, SW_HIDE) }; + let _ = unsafe { + SetWindowPos( + hwnd, + HWND_NOTOPMOST, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_HIDEWINDOW, + ) + }; + true +} + +#[cfg(not(target_os = "windows"))] +fn hide_qa_window_non_participating() -> bool { + false +} + /// 把 capsule 窗口移到屏幕底部居中,与 Swift `CapsuleWindowController.repositionToBottomCenter` 同效。 /// 留 80pt 给 macOS Dock;Windows 任务栏一般在底部 48pt 以内,整体也合适。 pub(crate) fn position_capsule_bottom_center( diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index b1baac39..c2e4fc80 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -7,7 +7,6 @@ import { checkAccessibilityPermission, checkMicrophonePermission, getHotkeyStatus, - handleWindowHotkeyEvent, isTauri, } from './lib/ipc'; import { QaPanel } from './pages/QaPanel'; @@ -109,21 +108,9 @@ export function App({ isCapsule, isQa }: AppProps) { useEffect(() => { if (!isTauri || os !== 'win') return; - const forwardKey = (event: KeyboardEvent) => { - if (!isWindowHotkeyCandidate(event)) return; - void handleWindowHotkeyEvent( - event.type as 'keydown' | 'keyup', - event.key, - event.code, - event.repeat, - ).catch(error => console.warn('[window-hotkey] forward failed', error)); - }; - window.addEventListener('keydown', forwardKey, true); - window.addEventListener('keyup', forwardKey, true); - return () => { - window.removeEventListener('keydown', forwardKey, true); - window.removeEventListener('keyup', forwardKey, true); - }; + // Windows 听写 / QA lifecycle 由 backend low-level keyboard hook 单独拥有。 + // 不再让前台 main window 额外转发 keydown/keyup,避免双事件源共同驱动同一状态机。 + return; }, [os]); if (gate === 'checking') { @@ -136,16 +123,6 @@ export function App({ isCapsule, isQa }: AppProps) { ); } -function isWindowHotkeyCandidate(event: KeyboardEvent): boolean { - return ( - event.key === 'Escape' || - event.code === 'ControlRight' || - event.code === 'ControlLeft' || - event.code === 'AltRight' || - event.code === 'MetaRight' - ); -} - function StartupShell() { return (
-
+
+