Skip to content

Commit 17c15a9

Browse files
committed
feat(live): 采集浮窗死锁修复与交互/架构升级
P0 死锁(wry#583):同步 command 在 WebView2 IPC 回调(主线程)内执行 WebviewWindowBuilder::build()——CreateCoreWebView2ControllerWithOptions 完成 回调需主线程派发而主线程阻塞在回调内等待(循环等待)。症状:点浮窗化 → 空白窗 + 全应用不可点击(ASR 引擎线程独立)。浮窗/覆盖层四个窗口命令 全部 async(Tauri 官方模式)。 P1 交互:面板⇄字幕条双形态(Esc 切换)+ 拖拽/位置记忆/边缘吸附(纯函数 可单测)+ 点击穿透锁定(主窗三态按钮解锁)+ 置顶开关 + 透明度滑杆 + 回主窗改为显示聚焦主窗且浮窗保留。 P2 架构:setup 预创建常驻(失败回落懒创建)+ 窗口状态收敛 (AppState.float_ui 单一来源 + float:state 事件)+ capabilities 拆分 (float/overlay 最小权限无 dialog)。 验证:cargo check --all-targets 通过;tsc 零错误;vitest 81 通过 (新增 floatWindow 14 项)。v0.12.3。
1 parent 904f53a commit 17c15a9

13 files changed

Lines changed: 757 additions & 49 deletions

File tree

‎app/src-tauri/capabilities/default.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "../gen/schemas/desktop-schema.json",
33
"identifier": "default",
44
"description": "Capability for the main window",
5-
"windows": ["main", "capture-float", "capture-overlay"],
5+
"windows": ["main"],
66
"permissions": [
77
"core:default",
88
"dialog:default"
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"$schema": "../gen/schemas/desktop-schema.json",
3+
"identifier": "capture-float",
4+
"description": "Capability for the capture float window (v0.12.3: 最小权限——无 dialog,浮窗仅消费 live:* 事件与窗口自身操作)",
5+
"windows": ["capture-float"],
6+
"permissions": [
7+
"core:default"
8+
]
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"$schema": "../gen/schemas/desktop-schema.json",
3+
"identifier": "capture-overlay",
4+
"description": "Capability for the capture overlay window (v0.12.3: 最小权限——覆盖层仅框选交互与窗口自身操作,无 dialog)",
5+
"windows": ["capture-overlay"],
6+
"permissions": [
7+
"core:default"
8+
]
9+
}

‎app/src-tauri/src/app_setup.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,12 @@ pub fn setup_app_state(app: &mut tauri::App) -> Result<(), String> {
280280
photo_store: std::sync::Arc::new(std::sync::Mutex::new(None)),
281281
// v0.12.0 M3:系统级覆盖层截图临时文件路径(open_capture_overlay 写入)
282282
overlay_image_path: std::sync::Arc::new(std::sync::Mutex::new(None)),
283+
// v0.12.3:浮窗 UI 状态(locked/topmost——Rust 侧单一来源,见 commands_window.rs)
284+
float_ui: std::sync::Arc::new(std::sync::Mutex::new(crate::commands_window::FloatUi::default())),
283285
});
286+
// v0.12.3(P2-10):浮窗预创建常驻(隐藏)——打开秒显、点击期零建窗风险;
287+
// 失败幂等回落为打开时懒创建(open_capture_float 内部兜底)。
288+
crate::commands_window::precreate_float(app.handle());
284289
Ok(())
285290
}
286291

‎app/src-tauri/src/commands.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,9 @@ pub struct AppState {
173173
/// v0.12.0 M3:系统级覆盖层截图临时文件路径(open_capture_overlay 写入;
174174
/// 覆盖层窗口与其后主窗口裁剪共用——窗口不持久,截完即销毁)
175175
pub overlay_image_path: std::sync::Arc<std::sync::Mutex<Option<std::path::PathBuf>>>,
176+
/// v0.12.3:浮窗 UI 状态(locked/topmost——set_ignore_cursor_events 无 getter,
177+
/// 锁定态必须自存;单一来源即 commands_window.rs,变更 emit float:state 事件)
178+
pub float_ui: std::sync::Arc<std::sync::Mutex<crate::commands_window::FloatUi>>,
176179
}
177180

178181
/// 枚举可捕获的窗口/进程(课堂助手目标窗口选择,含推荐评分)。

‎app/src-tauri/src/commands_overlay.rs‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! 系统级覆盖层截图命令(v0.12.0 M3,ADR-022 关联——交互债)。
1+
//! 系统级覆盖层截图命令(v0.12.0 M3,ADR-022 关联——交互债;v0.12.3 死锁修复)。
22
//!
33
//! @ai-context: 替代应用内 letterbox 框选(4K 屏在 1200px 窗口内缩到 73%,
44
//! 文字细节丢失 + 三步操作):Tauri 独立全屏透明窗口 1:1 原始像素
@@ -10,6 +10,9 @@
1010
//! @ai-context: 截图临时文件(data_dir/overlay-tmp/snapshot.jpg)经 asset 协议
1111
//! 供覆盖层窗口显示;主窗口裁剪与 OCR 均不依赖前端 canvas(前端
1212
//! 无 taint 问题——crop 收敛到后端 image crate)。
13+
//! @ai-context: **建窗/关窗命令必须 async**(wry#583,与 capture-float 同因)——
14+
//! Windows 上同步 command 在主线程 WebView2 IPC 回调内执行
15+
//! WebviewWindowBuilder::build() 会死锁(完成回调依赖主线程派发)。
1316
1417
use base64::Engine;
1518
use tauri::{Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
@@ -37,8 +40,11 @@ pub struct OverlayCapturedPayload {
3740
pub const CAPTURE_OVERLAY_LABEL: &str = "capture-overlay";
3841

3942
/// 打开覆盖层截图:截屏 → 存临时文件 → 创建全屏透明覆盖层窗口(幂等)。
43+
///
44+
/// @ai-context: async(wry#583)——建窗不能在主线程/WebView2 IPC 回调内同步执行
45+
/// (死锁,与 capture-float 同因);截屏重活也移到线程池,不再卡主线程。
4046
#[tauri::command]
41-
pub fn open_capture_overlay(state: State<'_, AppState>, app: tauri::AppHandle) -> Result<(), String> {
47+
pub async fn open_capture_overlay(state: State<'_, AppState>, app: tauri::AppHandle) -> Result<(), String> {
4248
let now_ms = std::time::SystemTime::now()
4349
.duration_since(std::time::UNIX_EPOCH)
4450
.map(|d| d.as_millis() as u64)
@@ -86,8 +92,10 @@ pub fn overlay_get_image(state: State<'_, AppState>) -> Result<Option<String>, S
8692
///
8793
/// @ai-context: 裁剪在 Rust 侧完成(前端 canvas 可能被 asset 协议 taint——
8894
/// 裁剪收敛到 image crate,回传 base64 与旧 letterbox 路径同语义)。
95+
/// @ai-context: async——由覆盖层窗口自身的 WebView2 IPC 回调发起,裁剪重活和
96+
/// 关窗均避免在主线程回调内同步执行(wry#583 同类风险)。
8997
#[tauri::command]
90-
pub fn overlay_submit_capture(
98+
pub async fn overlay_submit_capture(
9199
state: State<'_, AppState>,
92100
app: tauri::AppHandle,
93101
rect: OverlayRect,
@@ -116,14 +124,19 @@ pub fn overlay_submit_capture(
116124
}
117125

118126
/// 覆盖层取消(Esc)→ overlay:cancelled 通知主窗口 → 关窗,无副作用。
127+
///
128+
/// @ai-context: async——由覆盖层窗口自身 IPC 回调发起,关窗不在主线程回调内同步执行。
119129
#[tauri::command]
120-
pub fn overlay_cancel(app: tauri::AppHandle) -> Result<(), String> {
130+
pub async fn overlay_cancel(app: tauri::AppHandle) -> Result<(), String> {
121131
let _ = app.emit("overlay:cancelled", ());
122132
close_overlay(&app);
123133
Ok(())
124134
}
125135

126136
/// 关闭覆盖层窗口(不存在视为已关闭——幂等)。
137+
///
138+
/// @ai-context: 可能从 overlay 自身 IPC 回调链调用(submit/cancel);
139+
/// 调用方已 async 化,此处仅销毁不阻塞(close 经 dispatcher 消息)。
127140
fn close_overlay(app: &tauri::AppHandle) {
128141
if let Some(window) = app.get_webview_window(CAPTURE_OVERLAY_LABEL) {
129142
let _ = window.close();
Lines changed: 184 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,213 @@
1-
//! 采集浮窗窗口命令(v0.12.0 M6,采集体验债)。
1+
//! 采集浮窗窗口命令(v0.12.0 M6,采集体验债;v0.12.3 死锁修复 + 交互/架构升级)。
22
//!
33
//! @ai-context: 采集中全屏看视频时,主导航/会话面板被遮挡——悬浮小窗
44
//! (alwaysOnTop)常显状态/转写/控制,主面板与浮窗共用
55
//! useLiveSessionEvents hook。浮窗为独立 Tauri 子窗口:
66
//! 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+
//! (浮窗化 ⇄ 收起 ⇄ 解锁点击穿透)。
1121
12-
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
22+
use tauri::{Emitter, Manager, State, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
1323

14-
/// 浮窗标签(唯一标识;capabilities/default.json windows 列表需包含)。
24+
use crate::commands::AppState;
25+
26+
/// 浮窗标签(唯一标识;capabilities 中 capture-float 窗口需包含)。
1527
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+
}
1660

17-
/// 打开采集浮窗(幂等——已存在直接返回,不重复创建)。
61+
/// setup 预创建隐藏浮窗(P2-10:常驻秒开;失败回落为打开时懒创建——不阻断启动)。
1862
///
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) {
2467
if app.get_webview_window(CAPTURE_FLOAT_LABEL).is_some() {
25-
return Ok(());
68+
return;
2669
}
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()))
2878
.title("采集浮窗")
2979
.inner_size(360.0, 240.0)
3080
.decorations(false)
3181
.transparent(true)
3282
.always_on_top(true)
3383
.skip_taskbar(true)
3484
.resizable(false)
85+
.visible(!precreated)
86+
.focused(!precreated)
3587
.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);
37139
Ok(())
38140
}
39141

40-
/// 关闭采集浮窗(窗口不存在视为已关闭——幂等)。
142+
/// 设置浮窗点击穿透(锁定后浮窗只读显示、不拦截鼠标——看视频零遮挡)。
41143
///
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+
/// 设置浮窗置顶开关(不遮挡画面时可关掉,切换为普通窗口层级)。
44172
#[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> {
46178
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))?;
48182
}
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))?;
49212
Ok(())
50213
}

‎app/src-tauri/src/lib.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,11 @@ pub fn run() {
506506
// v0.12.0 M6(采集体验债):采集浮窗打开/关闭
507507
commands_window::open_capture_float,
508508
commands_window::close_capture_float,
509+
// v0.12.3(交互/架构升级):浮窗点击穿透/置顶/状态查询 + 回主窗
510+
commands_window::float_set_locked,
511+
commands_window::float_set_topmost,
512+
commands_window::float_state,
513+
commands_window::show_main_window,
509514
// v0.12.0 M3(系统级覆盖层截图):打开/取图/确认裁剪/取消
510515
commands_overlay::open_capture_overlay,
511516
commands_overlay::overlay_get_image,

0 commit comments

Comments
 (0)