From 74e4631c0298e78df53cc3c85c79648699ca587e Mon Sep 17 00:00:00 2001 From: cooper Date: Tue, 19 May 2026 12:41:21 +0800 Subject: [PATCH] fix(windows): define capsule native input contract --- .gitignore | 1 + .../scripts/windows-capsule-contract.test.mjs | 104 ++++ .../app/scripts/windows-capsule-hitl.ps1 | 537 ++++++++++++++++++ openless-all/app/src-tauri/src/coordinator.rs | 10 + .../src-tauri/src/coordinator/dictation.rs | 5 + openless-all/app/src-tauri/src/lib.rs | 239 +++++++- openless-all/app/src/components/Capsule.tsx | 6 +- .../app/src/lib/capsuleLayout.test.ts | 10 + openless-all/app/src/lib/capsuleLayout.ts | 37 +- 9 files changed, 937 insertions(+), 12 deletions(-) create mode 100644 openless-all/app/scripts/windows-capsule-contract.test.mjs create mode 100644 openless-all/app/scripts/windows-capsule-hitl.ps1 diff --git a/.gitignore b/.gitignore index de335f6c..8b34ae3d 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ promo-openless/ promo-openless-v2/ docs/old-promo/ .worktrees/ +.artifacts/ # 宣传录屏 / 素材根目录(GB 级二进制,绝不入版本库) video-materials/ diff --git a/openless-all/app/scripts/windows-capsule-contract.test.mjs b/openless-all/app/scripts/windows-capsule-contract.test.mjs new file mode 100644 index 00000000..17d8968f --- /dev/null +++ b/openless-all/app/scripts/windows-capsule-contract.test.mjs @@ -0,0 +1,104 @@ +import { readFile } from 'node:fs/promises'; + +function assertMatch(source, pattern, name) { + if (!pattern.test(source)) { + throw new Error(`${name}: pattern ${pattern} not found`); + } +} + +function assertNotMatch(source, pattern, name) { + if (pattern.test(source)) { + throw new Error(`${name}: forbidden pattern ${pattern} found`); + } +} + +const libRs = await readFile(new URL('../src-tauri/src/lib.rs', import.meta.url), 'utf-8'); +const coordinatorRs = await readFile(new URL('../src-tauri/src/coordinator.rs', import.meta.url), 'utf-8'); +const dictationRs = await readFile(new URL('../src-tauri/src/coordinator/dictation.rs', import.meta.url), 'utf-8'); +const capsuleTsx = await readFile(new URL('../src/components/Capsule.tsx', import.meta.url), 'utf-8'); +const capsuleLayoutTs = await readFile(new URL('../src/lib/capsuleLayout.ts', import.meta.url), 'utf-8'); +const hitlRunner = await readFile(new URL('./windows-capsule-hitl.ps1', import.meta.url), 'utf-8'); + +assertNotMatch( + libRs, + /apply_acrylic\(&capsule|DwmEnableBlurBehindWindow|DWMWA_SYSTEMBACKDROP_TYPE/, + 'windows capsule native host must not use Acrylic/Mica/DWM material', +); + +assertNotMatch( + libRs, + /WS_EX_TRANSPARENT|SetWindowLongPtrW|EnumChildWindows/, + 'windows capsule must not rely on dynamic WebView child HWND click-through styles', +); + +assertMatch( + libRs, + /fn configure_windows_capsule_container_region[\s\S]*?GetWindowRect[\s\S]*?windows_capsule_create_region[\s\S]*?SetWindowRgn/, + 'windows capsule should apply a native container/input envelope', +); + +assertMatch( + libRs, + /fn windows_capsule_region_rects[\s\S]*?windows_capsule_translation_badge_region_rect[\s\S]*?windows_capsule_pill_region_rect/, + 'windows capsule native envelope should be derived from rendered capsule surfaces', +); + +assertMatch( + libRs, + /CreateRoundRectRgn[\s\S]*?CombineRgn[\s\S]*?RGN_OR/, + 'windows capsule should use rounded native regions for pill and translation badge envelopes', +); + +assertMatch( + libRs, + /fn windows_capsule_pill_region_rect[\s\S]*?const PILL_WIDTH: f64 = 196\.0;[\s\S]*?const PILL_HEIGHT: f64 = 52\.0;[\s\S]*?const BOTTOM_INSET: f64 = 12\.0;/, + 'windows native envelope should cover the full rendered capsule, not a strict inner CSS box', +); + +assertMatch( + coordinatorRs, + /show_capsule_window_for_recording\(&app_for_main,\s*&window\);[\s\S]*?refresh_windows_capsule_container_region\(&window,\s*translation\)/, + 'windows capsule should refresh the native envelope after no-activate show', +); + +assertMatch( + coordinatorRs, + /fn capsule_hitl_translation_fixture_enabled\(\)[\s\S]*?OPENLESS_CAPSULE_HITL_TRANSLATION/, + 'translation badge HITL fixture should be gated behind an explicit debug environment variable', +); + +assertMatch( + dictationRs, + /if hotkey_injection_dry_run_enabled\(\)[\s\S]*?capsule_hitl_translation_fixture_enabled\(\)[\s\S]*?translation_modifier_seen[\s\S]*?store\(true,[\s\S]*?emit_capsule\(inner,\s*CapsuleState::Recording/, + 'translation badge HITL fixture should only affect hotkey-injection dry-run sessions', +); + +assertMatch( + capsuleLayoutTs, + /export interface CapsuleInputEnvelopeMetrics[\s\S]*?export function getCapsuleInputEnvelopeMetrics/, + 'frontend layout contract should name native input envelope metrics separately from host and pill metrics', +); + +assertMatch( + capsuleLayoutTs, + /getCapsuleInputEnvelopeMetrics\(os: OS\)[\s\S]*?width: 196,[\s\S]*?height: 52,[\s\S]*?radius: 26,[\s\S]*?bottomInset: 12,[\s\S]*?badgeWidth: 132,[\s\S]*?badgeHeight: 24,[\s\S]*?badgeGap: 8/, + 'frontend Windows input envelope metrics should match the native region contract', +); + +assertMatch( + capsuleTsx, + /const useBackdrop = os !== 'win';[\s\S]*?background: os === 'win' \? 'rgba\(255, 255, 255, 0\.96\)' : 'rgba\(255, 255, 255, 0\.85\)'/, + 'windows capsule DOM pill should own its surface without relying on desktop backdrop blur', +); + +assertMatch( + capsuleTsx, + /return\s*\(\s* bool { std::env::var_os("OPENLESS_HOTKEY_INJECTION_DRY_RUN").is_some() } +#[cfg(any(debug_assertions, test))] +fn capsule_hitl_translation_fixture_enabled() -> bool { + std::env::var("OPENLESS_CAPSULE_HITL_TRANSLATION") + .ok() + .as_deref() + == Some("1") +} + #[cfg(any(debug_assertions, test))] fn debug_transcript_override_text() -> Option { let path = std::env::var_os("OPENLESS_DEBUG_TRANSCRIPT_FILE")?; @@ -4412,6 +4420,8 @@ fn emit_capsule( ); } show_capsule_window_for_recording(&app_for_main, &window); + #[cfg(target_os = "windows")] + crate::refresh_windows_capsule_container_region(&window, translation); // macOS/Windows 优先走 no-activate show,避免录音胶囊抢走当前工作 app 焦点。 // 若 fallback 到 show(),OpenLess 已是前台 app 时再把 key window 还给 main。 #[cfg(target_os = "macos")] diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 56e661b1..8c784a77 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -486,6 +486,11 @@ pub(super) async fn begin_session(inner: &Arc) -> Result<(), String> { #[cfg(any(debug_assertions, test))] if hotkey_injection_dry_run_enabled() { + if capsule_hitl_translation_fixture_enabled() { + inner + .translation_modifier_seen + .store(true, Ordering::SeqCst); + } emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None); inner.state.lock().phase = SessionPhase::Listening; log::info!("[coord] session started (hotkey-injection dry-run)"); diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 4cee867a..771cee6c 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -133,15 +133,12 @@ pub fn run() { // backdrop-filter 只能模糊 DOM 内部,模糊不到 OS 桌面 → 胶囊背景 // 看起来就是「透到桌面」。这里给 Win10/Win11 都支持的 Acrylic 做兜底, // 让 OS 提供毛玻璃材质,胶囊 rgba(255,255,255,0.85) 上面再叠 DOM 模糊。 - // 失败不阻塞(老 Win10 / Win7 上 Acrylic 不可用),仅 warn。 + // Windows capsule contract: the native HWND is only a transparent + // carrier/input envelope. The DOM pill owns the visible surface; + // Acrylic/Mica/DWM materials on this host paint the transparent + // carrier as a gray rectangle. #[cfg(target_os = "windows")] - { - use window_vibrancy::apply_acrylic; - // 中性偏冷的浅灰半透,与胶囊白底叠合后保持可读性。 - if let Err(e) = apply_acrylic(&capsule, Some((30, 32, 38, 140))) { - log::warn!("[capsule] acrylic failed: {e}"); - } - } + configure_windows_capsule_container_region(&capsule, false); let _ = capsule.hide(); } @@ -726,6 +723,205 @@ fn apply_windows_caption_color(window: &tauri::WebviewWindow) { } } +#[cfg(target_os = "windows")] +#[derive(Clone, Copy, Debug)] +struct WindowsCapsuleRegionRect { + x: f64, + y: f64, + width: f64, + height: f64, + radius: f64, +} + +#[cfg(target_os = "windows")] +fn windows_capsule_pill_region_rect(bounds: CapsuleWindowBounds) -> WindowsCapsuleRegionRect { + const PILL_WIDTH: f64 = 196.0; + const PILL_HEIGHT: f64 = 52.0; + const BOTTOM_INSET: f64 = 12.0; + + WindowsCapsuleRegionRect { + x: (bounds.width - PILL_WIDTH) / 2.0, + y: bounds.height - BOTTOM_INSET - PILL_HEIGHT, + width: PILL_WIDTH, + height: PILL_HEIGHT, + radius: PILL_HEIGHT / 2.0, + } +} + +#[cfg(target_os = "windows")] +fn windows_capsule_translation_badge_region_rect( + bounds: CapsuleWindowBounds, +) -> WindowsCapsuleRegionRect { + const BADGE_WIDTH: f64 = 132.0; + const BADGE_HEIGHT: f64 = 24.0; + const BADGE_GAP: f64 = 8.0; + + let pill = windows_capsule_pill_region_rect(bounds); + WindowsCapsuleRegionRect { + x: (bounds.width - BADGE_WIDTH) / 2.0, + y: (pill.y - BADGE_GAP - BADGE_HEIGHT).max(0.0), + width: BADGE_WIDTH, + height: BADGE_HEIGHT, + radius: BADGE_HEIGHT / 2.0, + } +} + +#[cfg(target_os = "windows")] +fn windows_capsule_region_rects( + bounds: CapsuleWindowBounds, + translation_active: bool, +) -> Vec { + let mut rects = Vec::with_capacity(if translation_active { 2 } else { 1 }); + if translation_active { + rects.push(windows_capsule_translation_badge_region_rect(bounds)); + } + rects.push(windows_capsule_pill_region_rect(bounds)); + rects +} + +#[cfg(all(target_os = "windows", test))] +fn windows_capsule_rounded_rect_contains_point( + rect: WindowsCapsuleRegionRect, + x: f64, + y: f64, +) -> bool { + if x < rect.x || x > rect.x + rect.width || y < rect.y || y > rect.y + rect.height { + return false; + } + + let radius = rect.radius.min(rect.width / 2.0).min(rect.height / 2.0); + let nearest_x = if x < rect.x + radius { + rect.x + radius + } else if x > rect.x + rect.width - radius { + rect.x + rect.width - radius + } else { + x + }; + let nearest_y = if y < rect.y + radius { + rect.y + radius + } else if y > rect.y + rect.height - radius { + rect.y + rect.height - radius + } else { + y + }; + let dx = x - nearest_x; + let dy = y - nearest_y; + dx * dx + dy * dy <= radius * radius +} + +#[cfg(all(target_os = "windows", test))] +fn windows_capsule_region_contains_point( + bounds: CapsuleWindowBounds, + translation_active: bool, + x: f64, + y: f64, +) -> bool { + windows_capsule_region_rects(bounds, translation_active) + .into_iter() + .any(|rect| windows_capsule_rounded_rect_contains_point(rect, x, y)) +} + +#[cfg(target_os = "windows")] +fn windows_capsule_region_px(value: f64, scale: f64, outward: bool) -> i32 { + let scaled = value * scale; + if outward { + scaled.ceil() as i32 + } else { + scaled.floor() as i32 + } +} + +#[cfg(target_os = "windows")] +fn windows_capsule_create_region( + bounds: CapsuleWindowBounds, + translation_active: bool, + scale_x: f64, + scale_y: f64, +) -> windows::Win32::Graphics::Gdi::HRGN { + use windows::Win32::Graphics::Gdi::{ + CombineRgn, CreateRoundRectRgn, DeleteObject, HRGN, RGN_ERROR, RGN_OR, + }; + + let mut combined = HRGN::default(); + for rect in windows_capsule_region_rects(bounds, translation_active) { + let x1 = windows_capsule_region_px(rect.x, scale_x, false); + let y1 = windows_capsule_region_px(rect.y, scale_y, false); + let x2 = windows_capsule_region_px(rect.x + rect.width, scale_x, true); + let y2 = windows_capsule_region_px(rect.y + rect.height, scale_y, true); + let round_w = ((rect.radius * 2.0 * scale_x).round() as i32).max(1); + let round_h = ((rect.radius * 2.0 * scale_y).round() as i32).max(1); + let region = unsafe { CreateRoundRectRgn(x1, y1, x2, y2, round_w, round_h) }; + if region.is_invalid() { + log::warn!("[capsule] create rounded container region failed"); + continue; + } + + if combined.is_invalid() { + combined = region; + } else { + let result = unsafe { CombineRgn(combined, combined, region, RGN_OR) }; + let _ = unsafe { DeleteObject(region) }; + if result == RGN_ERROR { + log::warn!("[capsule] combine container regions failed"); + let _ = unsafe { DeleteObject(combined) }; + return HRGN::default(); + } + } + } + combined +} + +#[cfg(target_os = "windows")] +fn configure_windows_capsule_container_region( + window: &tauri::WebviewWindow, + translation_active: bool, +) { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use windows::Win32::Foundation::{BOOL, HWND, RECT}; + use windows::Win32::Graphics::Gdi::{DeleteObject, SetWindowRgn}; + use windows::Win32::UI::WindowsAndMessaging::GetWindowRect; + + let handle = match window.window_handle().map(|h| h.as_raw()) { + Ok(RawWindowHandle::Win32(handle)) => handle, + Ok(other) => { + log::warn!("[capsule] unexpected raw window handle for container region: {other:?}"); + return; + } + Err(e) => { + log::warn!("[capsule] read raw window handle for container region failed: {e}"); + return; + } + }; + let hwnd = HWND(handle.hwnd.get() as *mut core::ffi::c_void); + let bounds = capsule_window_bounds(translation_active); + let mut rect = RECT::default(); + let (scale_x, scale_y) = if unsafe { GetWindowRect(hwnd, &mut rect) }.is_ok() { + let width = (rect.right - rect.left).max(1) as f64; + let height = (rect.bottom - rect.top).max(1) as f64; + (width / bounds.width.max(1.0), height / bounds.height.max(1.0)) + } else { + (1.0, 1.0) + }; + + let region = windows_capsule_create_region(bounds, translation_active, scale_x, scale_y); + if region.is_invalid() { + return; + } + + if unsafe { SetWindowRgn(hwnd, region, BOOL(1)) } == 0 { + let _ = unsafe { DeleteObject(region) }; + log::warn!("[capsule] apply container region failed"); + } +} + +#[cfg(target_os = "windows")] +pub(crate) fn refresh_windows_capsule_container_region( + window: &tauri::WebviewWindow, + translation_active: bool, +) { + configure_windows_capsule_container_region(window, translation_active); +} + #[tauri::command] fn restart_app(app: AppHandle) { // macOS:自动更新会让新装的 .app 带 com.apple.quarantine(无论 Tauri updater @@ -1201,6 +1397,8 @@ pub(crate) fn position_capsule_bottom_center( }; let bounds = capsule_window_bounds(translation_active); window.set_size(LogicalSize::new(bounds.width, bounds.height))?; + #[cfg(target_os = "windows")] + configure_windows_capsule_container_region(window, translation_active); let scale = monitor.scale_factor(); let size = monitor.size(); @@ -1363,6 +1561,31 @@ mod tests { assert_eq!(capsule_visual_height(true), 96.0); } + #[test] + #[cfg(target_os = "windows")] + fn capsule_container_region_consumes_only_rendered_pill_envelope() { + let bounds = capsule_window_bounds(false); + + assert!(!super::windows_capsule_region_contains_point(bounds, false, 4.0, 4.0)); + assert!(!super::windows_capsule_region_contains_point(bounds, false, 4.0, 50.0)); + assert!(super::windows_capsule_region_contains_point(bounds, false, 110.0, 50.0)); + assert!(super::windows_capsule_region_contains_point(bounds, false, 20.0, 50.0)); + assert!(super::windows_capsule_region_contains_point(bounds, false, 200.0, 50.0)); + assert!(!super::windows_capsule_region_contains_point(bounds, false, 10.0, 50.0)); + assert!(!super::windows_capsule_region_contains_point(bounds, false, 110.0, 14.0)); + } + + #[test] + #[cfg(target_os = "windows")] + fn capsule_container_region_tracks_translation_badge_and_pill() { + let bounds = capsule_window_bounds(true); + + assert!(super::windows_capsule_region_contains_point(bounds, true, 110.0, 46.0)); + assert!(super::windows_capsule_region_contains_point(bounds, true, 110.0, 84.0)); + assert!(!super::windows_capsule_region_contains_point(bounds, true, 16.0, 84.0)); + assert!(!super::windows_capsule_region_contains_point(bounds, true, 110.0, 50.0)); + } + #[test] fn qa_anchor_uses_normal_capsule_height_source() { #[cfg(target_os = "windows")] diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index 283ce712..1cb4aed6 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -244,7 +244,7 @@ function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }: const ambient = state === 'recording' ? Math.min(1, Math.max(0, level)) : 0; const scale = os === 'win' ? 1 : 1 + ambient * 0.018; const shadowAlpha = 0.20 + ambient * 0.10; - const useBackdrop = true; + const useBackdrop = os !== 'win'; return (