|
1 | | -//! 采集浮窗窗口命令(v0.12.0 M6,采集体验债)。 |
| 1 | +//! 采集浮窗窗口命令(v0.12.0 M6,采集体验债;v0.12.3 死锁修复 + 交互/架构升级)。 |
2 | 2 | //! |
3 | 3 | //! @ai-context: 采集中全屏看视频时,主导航/会话面板被遮挡——悬浮小窗 |
4 | 4 | //! (alwaysOnTop)常显状态/转写/控制,主面板与浮窗共用 |
5 | 5 | //! useLiveSessionEvents hook。浮窗为独立 Tauri 子窗口: |
6 | 6 | //! alwaysOnTop/decorations:false/transparent/skipTaskbar/360×240, |
7 | | -//! 加载 index.html?float=1(App.tsx 按 URL query 渲染 CaptureFloatPanel, |
8 | | -//! 不渲染主导航壳)。 |
9 | | -//! @ai-context: 窗口不持久——停止采集后由 ClassroomPage 调 close_capture_float 销毁; |
10 | | -//! 幂等:重复打开时若已存在则直接返回(不叠窗)。 |
| 7 | +//! 加载 index.html?float=1(App.tsx 按 URL query 渲染 CaptureFloatPanel)。 |
| 8 | +//! @ai-context: **必须 async 命令**(wry#583):Windows 上同步 command 在 WebView2 |
| 9 | +//! IPC 回调(主线程)内执行 WebviewWindowBuilder::build() 会死锁—— |
| 10 | +//! CreateCoreWebView2ControllerWithOptions 完成回调需要主线程派发, |
| 11 | +//! 而主线程正阻塞在回调内等待它(循环等待)。症状:新窗空白 + |
| 12 | +//! 全应用不可点击(ASR 引擎线程独立不受影响)。async 命令在 tokio |
| 13 | +//! 线程池执行,建窗经事件循环代理投递到主线程正常事件处理(与启动 |
| 14 | +//! 建窗同环境)。参见 tauri 2.11.5 webview_window.rs 的官方警告。 |
| 15 | +//! @ai-context: v0.12.3 架构升级:浮窗**常驻**(setup 预创建隐藏窗口—— |
| 16 | +//! P2-10 秒开 + 零点击期建窗风险),打开/关闭改为 show/hide; |
| 17 | +//! 预创建失败幂等回落为打开时懒创建。UI 状态(locked/topmost) |
| 18 | +//! Rust 侧单一来源(set_ignore_cursor_events 无 getter,必须自存), |
| 19 | +//! 变更时 emit float:state 事件同步主窗按钮语义 |
| 20 | +//! (浮窗化 ⇄ 收起 ⇄ 解锁点击穿透)。 |
11 | 21 |
|
12 | | -use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; |
| 22 | +use tauri::{Emitter, Manager, State, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; |
13 | 23 |
|
14 | | -/// 浮窗标签(唯一标识;capabilities/default.json windows 列表需包含)。 |
| 24 | +use crate::commands::AppState; |
| 25 | + |
| 26 | +/// 浮窗标签(唯一标识;capabilities 中 capture-float 窗口需包含)。 |
15 | 27 | pub const CAPTURE_FLOAT_LABEL: &str = "capture-float"; |
| 28 | +/// 浮窗状态事件名(主窗监听:按钮语义随 open/locked 切换)。 |
| 29 | +pub const FLOAT_STATE_EVENT: &str = "float:state"; |
| 30 | + |
| 31 | +/// 浮窗 UI 状态(Rust 侧单一来源;set_ignore_cursor_events 无 getter 必须自存)。 |
| 32 | +#[derive(Debug, Clone, Copy, Default)] |
| 33 | +pub struct FloatUi { |
| 34 | + /// 点击穿透(锁定后浮窗不可点,主窗按钮/快捷键解锁) |
| 35 | + pub locked: bool, |
| 36 | + /// 是否置顶 |
| 37 | + pub topmost: bool, |
| 38 | +} |
| 39 | + |
| 40 | +/// 对外状态视图(camelCase 契约——前端按钮语义数据源)。 |
| 41 | +#[derive(Debug, Clone, Copy, serde::Serialize)] |
| 42 | +#[serde(rename_all = "camelCase")] |
| 43 | +pub struct FloatUiView { |
| 44 | + pub open: bool, |
| 45 | + pub locked: bool, |
| 46 | + pub topmost: bool, |
| 47 | +} |
| 48 | + |
| 49 | +fn view(app: &tauri::AppHandle, ui: &FloatUi) -> FloatUiView { |
| 50 | + FloatUiView { |
| 51 | + open: app.get_webview_window(CAPTURE_FLOAT_LABEL).is_some(), |
| 52 | + locked: ui.locked, |
| 53 | + topmost: ui.topmost, |
| 54 | + } |
| 55 | +} |
| 56 | + |
| 57 | +fn emit_float_state(app: &tauri::AppHandle, ui: &FloatUi) { |
| 58 | + let _ = app.emit(FLOAT_STATE_EVENT, view(app, ui)); |
| 59 | +} |
16 | 60 |
|
17 | | -/// 打开采集浮窗(幂等——已存在直接返回,不重复创建)。 |
| 61 | +/// setup 预创建隐藏浮窗(P2-10:常驻秒开;失败回落为打开时懒创建——不阻断启动)。 |
18 | 62 | /// |
19 | | -/// @ai-context: ?float=1 让 App.tsx 渲染 CaptureFloatPanel;窗口不加载数据库/ |
20 | | -/// 采集引擎(纯前端消费 live:* 事件),创建失败返回错误由调用方 |
21 | | -/// 引导(浮窗是增强,失败不阻断采集主链路)。 |
22 | | -#[tauri::command] |
23 | | -pub fn open_capture_float(app: tauri::AppHandle) -> Result<(), String> { |
| 63 | +/// @ai-context: setup 阶段主线程无 WebView2 回调嵌套,建窗安全(同主窗启动路径); |
| 64 | +/// 隐藏窗口聚焦 false 不抢焦点;默认置顶(与用户偏好 topmost 合并 |
| 65 | +/// 由 open_capture_float 应用)。 |
| 66 | +pub fn precreate_float(app: &tauri::AppHandle) { |
24 | 67 | if app.get_webview_window(CAPTURE_FLOAT_LABEL).is_some() { |
25 | | - return Ok(()); |
| 68 | + return; |
26 | 69 | } |
27 | | - WebviewWindowBuilder::new(&app, CAPTURE_FLOAT_LABEL, WebviewUrl::App("index.html?float=1".into())) |
| 70 | + if let Err(e) = build_float_window(app, false) { |
| 71 | + eprintln!("[capture-float] 预创建失败(回落为打开时懒创建): {}", e); |
| 72 | + } |
| 73 | +} |
| 74 | + |
| 75 | +/// 构建浮窗窗口(precreated=false 时可见并带焦点——懒创建打开路径)。 |
| 76 | +fn build_float_window(app: &tauri::AppHandle, precreated: bool) -> Result<WebviewWindow, String> { |
| 77 | + WebviewWindowBuilder::new(app, CAPTURE_FLOAT_LABEL, WebviewUrl::App("index.html?float=1".into())) |
28 | 78 | .title("采集浮窗") |
29 | 79 | .inner_size(360.0, 240.0) |
30 | 80 | .decorations(false) |
31 | 81 | .transparent(true) |
32 | 82 | .always_on_top(true) |
33 | 83 | .skip_taskbar(true) |
34 | 84 | .resizable(false) |
| 85 | + .visible(!precreated) |
| 86 | + .focused(!precreated) |
35 | 87 | .build() |
36 | | - .map_err(|e| format!("创建采集浮窗失败: {}", e))?; |
| 88 | + .map_err(|e| format!("创建采集浮窗失败: {}", e)) |
| 89 | +} |
| 90 | + |
| 91 | +/// 打开采集浮窗(幂等——已存在则前置显示并聚焦)。 |
| 92 | +/// |
| 93 | +/// @ai-context: ?float=1 让 App.tsx 渲染 CaptureFloatPanel;窗口不加载数据库/ |
| 94 | +/// 采集引擎(纯前端消费 live:* 事件),创建失败返回错误由调用方 |
| 95 | +/// 引导(浮窗是增强,失败不阻断采集主链路)。已存在时同步应用 |
| 96 | +/// stored topmost(precreated 窗口初态可能被用户关过置顶)。 |
| 97 | +/// @ai-context: async——见模块头注释(wry#583 主线程建窗死锁)。 |
| 98 | +#[tauri::command] |
| 99 | +pub async fn open_capture_float( |
| 100 | + app: tauri::AppHandle, |
| 101 | + state: State<'_, AppState>, |
| 102 | +) -> Result<(), String> { |
| 103 | + let ui = *state |
| 104 | + .float_ui |
| 105 | + .lock() |
| 106 | + .map_err(|e| format!("浮窗状态锁中毒: {}", e))?; |
| 107 | + let window = match app.get_webview_window(CAPTURE_FLOAT_LABEL) { |
| 108 | + Some(w) => w, |
| 109 | + None => build_float_window(&app, false)?, |
| 110 | + }; |
| 111 | + window.show().map_err(|e| format!("显示采集浮窗失败: {}", e))?; |
| 112 | + window |
| 113 | + .set_always_on_top(ui.topmost) |
| 114 | + .map_err(|e| format!("设置浮窗置顶失败: {}", e))?; |
| 115 | + let _ = window.set_focus(); |
| 116 | + emit_float_state(&app, &ui); |
| 117 | + Ok(()) |
| 118 | +} |
| 119 | + |
| 120 | +/// 关闭采集浮窗(**隐藏**而非销毁——v0.12.3 常驻语义;重复调用幂等)。 |
| 121 | +/// |
| 122 | +/// @ai-context: 停止采集由 ClassroomPage / CaptureFloatPanel 调用;隐藏保留 |
| 123 | +/// webview 与事件订阅,重新打开秒显。窗口不存在视为已关闭。 |
| 124 | +/// @ai-context: async——浮窗自身按钮从浮窗的 WebView2 IPC 回调发起, |
| 125 | +/// 销毁/隐藏调用同样避免在主线程回调内同步执行(wry#583 风险类)。 |
| 126 | +#[tauri::command] |
| 127 | +pub async fn close_capture_float( |
| 128 | + app: tauri::AppHandle, |
| 129 | + state: State<'_, AppState>, |
| 130 | +) -> Result<(), String> { |
| 131 | + if let Some(window) = app.get_webview_window(CAPTURE_FLOAT_LABEL) { |
| 132 | + window.hide().map_err(|e| format!("隐藏采集浮窗失败: {}", e))?; |
| 133 | + } |
| 134 | + let ui = *state |
| 135 | + .float_ui |
| 136 | + .lock() |
| 137 | + .map_err(|e| format!("浮窗状态锁中毒: {}", e))?; |
| 138 | + emit_float_state(&app, &ui); |
37 | 139 | Ok(()) |
38 | 140 | } |
39 | 141 |
|
40 | | -/// 关闭采集浮窗(窗口不存在视为已关闭——幂等)。 |
| 142 | +/// 设置浮窗点击穿透(锁定后浮窗只读显示、不拦截鼠标——看视频零遮挡)。 |
41 | 143 | /// |
42 | | -/// @ai-context: 停止采集后由 ClassroomPage / CaptureFloatPanel 回主窗调用销毁; |
43 | | -/// 幂等:不存在则无副作用(不报错)。 |
| 144 | +/// @ai-context: 解锁路径必须由主窗提供(锁定态浮窗自身不可点):主窗按钮 |
| 145 | +/// Ctrl+Shift+F / 「解锁浮窗」调本命令 locked=false。返回最新状态 |
| 146 | +/// 视图(前端按钮语义即时更新,事件通道兜底)。 |
| 147 | +#[tauri::command] |
| 148 | +pub async fn float_set_locked( |
| 149 | + app: tauri::AppHandle, |
| 150 | + state: State<'_, AppState>, |
| 151 | + locked: bool, |
| 152 | +) -> Result<FloatUiView, String> { |
| 153 | + if let Some(window) = app.get_webview_window(CAPTURE_FLOAT_LABEL) { |
| 154 | + window |
| 155 | + .set_ignore_cursor_events(locked) |
| 156 | + .map_err(|e| format!("设置点击穿透失败: {}", e))?; |
| 157 | + if !locked { |
| 158 | + let _ = window.set_focus(); |
| 159 | + } |
| 160 | + } |
| 161 | + let mut ui = state |
| 162 | + .float_ui |
| 163 | + .lock() |
| 164 | + .map_err(|e| format!("浮窗状态锁中毒: {}", e))?; |
| 165 | + ui.locked = locked; |
| 166 | + let v = view(&app, &ui); |
| 167 | + emit_float_state(&app, &ui); |
| 168 | + Ok(v) |
| 169 | +} |
| 170 | + |
| 171 | +/// 设置浮窗置顶开关(不遮挡画面时可关掉,切换为普通窗口层级)。 |
44 | 172 | #[tauri::command] |
45 | | -pub fn close_capture_float(app: tauri::AppHandle) -> Result<(), String> { |
| 173 | +pub async fn float_set_topmost( |
| 174 | + app: tauri::AppHandle, |
| 175 | + state: State<'_, AppState>, |
| 176 | + topmost: bool, |
| 177 | +) -> Result<FloatUiView, String> { |
46 | 178 | if let Some(window) = app.get_webview_window(CAPTURE_FLOAT_LABEL) { |
47 | | - window.close().map_err(|e| format!("关闭采集浮窗失败: {}", e))?; |
| 179 | + window |
| 180 | + .set_always_on_top(topmost) |
| 181 | + .map_err(|e| format!("设置置顶失败: {}", e))?; |
48 | 182 | } |
| 183 | + let mut ui = state |
| 184 | + .float_ui |
| 185 | + .lock() |
| 186 | + .map_err(|e| format!("浮窗状态锁中毒: {}", e))?; |
| 187 | + ui.topmost = topmost; |
| 188 | + let v = view(&app, &ui); |
| 189 | + emit_float_state(&app, &ui); |
| 190 | + Ok(v) |
| 191 | +} |
| 192 | + |
| 193 | +/// 浮窗当前状态(主窗挂载/快捷键语义同步;轻量读,无需 async)。 |
| 194 | +#[tauri::command] |
| 195 | +pub fn float_state(app: tauri::AppHandle, state: State<'_, AppState>) -> Result<FloatUiView, String> { |
| 196 | + let ui = *state |
| 197 | + .float_ui |
| 198 | + .lock() |
| 199 | + .map_err(|e| format!("浮窗状态锁中毒: {}", e))?; |
| 200 | + Ok(view(&app, &ui)) |
| 201 | +} |
| 202 | + |
| 203 | +/// 回主窗:显示 + 还原 + 聚焦主窗口(浮窗保留——回主窗不损失悬浮态)。 |
| 204 | +#[tauri::command] |
| 205 | +pub async fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { |
| 206 | + let main = app |
| 207 | + .get_webview_window("main") |
| 208 | + .ok_or_else(|| "主窗口不存在".to_string())?; |
| 209 | + main.show().map_err(|e| format!("显示主窗口失败: {}", e))?; |
| 210 | + let _ = main.unminimize(); |
| 211 | + main.set_focus().map_err(|e| format!("聚焦主窗口失败: {}", e))?; |
49 | 212 | Ok(()) |
50 | 213 | } |
0 commit comments