Skip to content

Commit f8db9e2

Browse files
committed
fix(live): 浮窗审查三处即修——ACL 权限/开态语义/监听清理
新增代码七维审查(v0.12.3 二轮)发现并修复: 1. HIGH:float.json 仅 core:default——core:window:default 只含只读查询, startDragging/setPosition/setSize 走 plugin:window|* 且无条件 ACL 校验, 浮窗全部新交互(拖拽/位置记忆/双形态)将被静默拒绝;补 allow-set-size/allow-set-position/allow-start-dragging 最小集。关联修复: 主窗 App.tsx 关闭确认的 getCurrentWindow().close() 同为此缺口 (pre-existing),default.json 补 allow-close。 2. HIGH:FloatUiView.open 以 get_webview_window 存在性判定——常驻预创建后 窗口对象恒存在,open 恒 true,主窗按钮永远"收起"且无法重新打开; 改为 FloatUi.open 显式状态(open/close 命令置位)+ 单测锚定。 3. MED:useFloatWindow 的 float:state 监听未在组件卸载时释放(listen 异步 resolve 时序缺陷)——HMR/StrictMode 泄漏;改为 ref 持有 + 卸载释放。 另:loadFloatPrefs 惰性一次(原 useRef 参数每渲染求值)、ClassroomPage 浮窗状态类型统一共享 FloatSnapshot。验证:cargo check 通过 + clippy 本批 零新增告警 + tsc 零错误 + vitest 83 通过。
1 parent 2d1b507 commit f8db9e2

5 files changed

Lines changed: 71 additions & 23 deletions

File tree

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
{
22
"$schema": "../gen/schemas/desktop-schema.json",
33
"identifier": "default",
4-
"description": "Capability for the main window",
4+
"description": "Capability for the main window (allow-close: App.tsx 关闭确认流程 getCurrentWindow().close()——审查发现 core:window:default 仅只读、该调用此前被 ACL 静默拒绝)",
55
"windows": ["main"],
66
"permissions": [
77
"core:default",
8-
"dialog:default"
8+
"dialog:default",
9+
"core:window:allow-close"
910
]
1011
}
Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
{
22
"$schema": "../gen/schemas/desktop-schema.json",
33
"identifier": "capture-float",
4-
"description": "Capability for the capture float window (v0.12.3: 最小权限——无 dialog,浮窗仅消费 live:* 事件与窗口自身操作)",
4+
"description": "Capability for the capture float window (v0.12.3: 最小权限——无 dialog;core:window:default 仅含只读查询,浮窗交互所需的 3 个变更权限显式列出:拖拽/位置恢复吸附/双形态 resize)",
55
"windows": ["capture-float"],
66
"permissions": [
7-
"core:default"
7+
"core:default",
8+
"core:window:allow-set-size",
9+
"core:window:allow-set-position",
10+
"core:window:allow-start-dragging"
811
]
912
}

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

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,17 @@ pub const FLOAT_STATE_EVENT: &str = "float:state";
3131
/// 浮窗 UI 状态(Rust 侧单一来源;set_ignore_cursor_events 无 getter 必须自存)。
3232
#[derive(Debug, Clone, Copy, Default)]
3333
pub struct FloatUi {
34+
/// 是否**显示**(open 语义)。预创建常驻后窗口对象恒存在,
35+
/// 不能用 get_webview_window 存在性判定 open——否则按钮语义恒为
36+
/// "收起"且永远无法重新打开(审查 P0,v0.12.3 审查即修)。
37+
pub open: bool,
3438
/// 点击穿透(锁定后浮窗不可点,主窗按钮/快捷键解锁)
3539
pub locked: bool,
3640
/// 是否置顶
3741
pub topmost: bool,
3842
}
3943

40-
/// 对外状态视图(camelCase 契约——前端按钮语义数据源)。
44+
/// 对外状态视图(camelCase 契约——前端按钮语义数据源;纯函数构造可单测)。
4145
#[derive(Debug, Clone, Copy, serde::Serialize)]
4246
#[serde(rename_all = "camelCase")]
4347
pub struct FloatUiView {
@@ -46,16 +50,16 @@ pub struct FloatUiView {
4650
pub topmost: bool,
4751
}
4852

49-
fn view(app: &tauri::AppHandle, ui: &FloatUi) -> FloatUiView {
53+
fn view(ui: &FloatUi) -> FloatUiView {
5054
FloatUiView {
51-
open: app.get_webview_window(CAPTURE_FLOAT_LABEL).is_some(),
55+
open: ui.open,
5256
locked: ui.locked,
5357
topmost: ui.topmost,
5458
}
5559
}
5660

5761
fn emit_float_state(app: &tauri::AppHandle, ui: &FloatUi) {
58-
let _ = app.emit(FLOAT_STATE_EVENT, view(app, ui));
62+
let _ = app.emit(FLOAT_STATE_EVENT, view(ui));
5963
}
6064

6165
/// setup 预创建隐藏浮窗(P2-10:常驻秒开;失败回落为打开时懒创建——不阻断启动)。
@@ -100,20 +104,28 @@ pub async fn open_capture_float(
100104
app: tauri::AppHandle,
101105
state: State<'_, AppState>,
102106
) -> Result<(), String> {
103-
let ui = *state
107+
let topmost = state
104108
.float_ui
105109
.lock()
106-
.map_err(|e| format!("浮窗状态锁中毒: {}", e))?;
110+
.map_err(|e| format!("浮窗状态锁中毒: {}", e))?
111+
.topmost;
107112
let window = match app.get_webview_window(CAPTURE_FLOAT_LABEL) {
108113
Some(w) => w,
109114
None => build_float_window(&app, false)?,
110115
};
111116
window.show().map_err(|e| format!("显示采集浮窗失败: {}", e))?;
112117
window
113-
.set_always_on_top(ui.topmost)
118+
.set_always_on_top(topmost)
114119
.map_err(|e| format!("设置浮窗置顶失败: {}", e))?;
115120
let _ = window.set_focus();
116-
emit_float_state(&app, &ui);
121+
{
122+
let mut ui = state
123+
.float_ui
124+
.lock()
125+
.map_err(|e| format!("浮窗状态锁中毒: {}", e))?;
126+
ui.open = true;
127+
emit_float_state(&app, &ui);
128+
}
117129
Ok(())
118130
}
119131

@@ -131,10 +143,11 @@ pub async fn close_capture_float(
131143
if let Some(window) = app.get_webview_window(CAPTURE_FLOAT_LABEL) {
132144
window.hide().map_err(|e| format!("隐藏采集浮窗失败: {}", e))?;
133145
}
134-
let ui = *state
146+
let mut ui = state
135147
.float_ui
136148
.lock()
137149
.map_err(|e| format!("浮窗状态锁中毒: {}", e))?;
150+
ui.open = false;
138151
emit_float_state(&app, &ui);
139152
Ok(())
140153
}
@@ -163,7 +176,7 @@ pub async fn float_set_locked(
163176
.lock()
164177
.map_err(|e| format!("浮窗状态锁中毒: {}", e))?;
165178
ui.locked = locked;
166-
let v = view(&app, &ui);
179+
let v = view(&ui);
167180
emit_float_state(&app, &ui);
168181
Ok(v)
169182
}
@@ -185,19 +198,39 @@ pub async fn float_set_topmost(
185198
.lock()
186199
.map_err(|e| format!("浮窗状态锁中毒: {}", e))?;
187200
ui.topmost = topmost;
188-
let v = view(&app, &ui);
201+
let v = view(&ui);
189202
emit_float_state(&app, &ui);
190203
Ok(v)
191204
}
192205

193206
/// 浮窗当前状态(主窗挂载/快捷键语义同步;轻量读,无需 async)。
194207
#[tauri::command]
195-
pub fn float_state(app: tauri::AppHandle, state: State<'_, AppState>) -> Result<FloatUiView, String> {
208+
pub fn float_state(state: State<'_, AppState>) -> Result<FloatUiView, String> {
196209
let ui = *state
197210
.float_ui
198211
.lock()
199212
.map_err(|e| format!("浮窗状态锁中毒: {}", e))?;
200-
Ok(view(&app, &ui))
213+
Ok(view(&ui))
214+
}
215+
216+
#[cfg(test)]
217+
mod tests {
218+
use super::*;
219+
220+
/// 审查即修(v0.12.3):open 必须是显式状态——常驻预创建后窗口对象恒存在,
221+
/// 不能用 get_webview_window 存在性判定(否则 open 恒 true,主窗按钮
222+
/// 永远显示"收起"且无法再次打开)。
223+
#[test]
224+
fn float_ui_view_open_is_explicit_state() {
225+
let ui = FloatUi { open: false, locked: true, topmost: false };
226+
let v = view(&ui);
227+
let json = serde_json::to_value(v).expect("序列化失败");
228+
assert_eq!(json["open"], false, "open 必须为显式状态(预创建存在≠显示)");
229+
assert_eq!(json["locked"], true);
230+
assert_eq!(json["topmost"], false);
231+
// camelCase 契约锚定(前端直接消费该 JSON 键)
232+
assert!(json.get("open").is_some() && json.get("locked").is_some() && json.get("topmost").is_some());
233+
}
201234
}
202235

203236
/// 回主窗:显示 + 还原 + 聚焦主窗口(浮窗保留——回主窗不损失悬浮态)。

‎app/src/hooks/useFloatWindow.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ export interface FloatSnapshot {
3333
const MOVE_FINALIZE_DEBOUNCE_MS = 250;
3434

3535
export function useFloatWindow() {
36-
const prefsRef = useRef(loadFloatPrefs());
36+
// 惰性一次读取(审查 MED-1:useRef(loadFloatPrefs()) 每渲染求值——
37+
// 浮窗 1s tick 重渲染时等价于每秒 localStorage 读 + JSON.parse)
38+
const [initialPrefs] = useState(loadFloatPrefs);
39+
const prefsRef = useRef(initialPrefs);
3740
const [snapshot, setSnapshot] = useState<FloatSnapshot>({
3841
open: true,
3942
locked: false,
@@ -46,6 +49,7 @@ export function useFloatWindow() {
4649
// 挂载兜底:拉取一次状态 + 订阅 float:state 事件(Rust 单一来源)
4750
useEffect(() => {
4851
let disposed = false;
52+
let unlisten: (() => void) | undefined;
4953
void invoke<FloatSnapshot>("float_state")
5054
.then((s) => {
5155
if (disposed) return;
@@ -56,11 +60,15 @@ export function useFloatWindow() {
5660
void listen<FloatSnapshot>("float:state", (e) => {
5761
setSnapshot(e.payload);
5862
prefsRef.current = { ...prefsRef.current, topmost: e.payload.topmost };
59-
}).then((un) => {
60-
if (disposed) void un();
63+
}).then((fn) => {
64+
// 审查修复:listen 异步 resolve——若卸载已发生立即释放,
65+
// 否则存入 ref 供卸载时调用(原实现仅 disposed 分支释放,热重载/StrictMode 会泄漏)
66+
if (disposed) void fn();
67+
else unlisten = fn;
6168
});
6269
return () => {
6370
disposed = true;
71+
unlisten?.();
6472
};
6573
}, []);
6674

‎app/src/pages/ClassroomPage.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ import MaterialInputPanel from "../components/MaterialInputPanel";
2626
// v0.11.7:图文采集(第三动线:截屏导入图文内容 → 图文会话)
2727
import PhotoCapturePanel from "../components/PhotoCapturePanel";
2828
import type { Note, WindowInfo, StreamingModelStatus, LiveSessionStatus, DownloadProgress, DownloadStatus, ProfileKind } from "../types";
29+
// v0.12.3:浮窗状态快照类型(与 Rust FloatUiView camelCase 契约同源;
30+
// 审查 LOW-3:统一共享类型替代内联重复声明)
31+
import type { FloatSnapshot } from "../hooks/useFloatWindow";
2932
// Low 清扫:标题截断长度单一定义源(与 MaterialInputPanel 共享)
3033
import { NOTE_TITLE_MAX_LEN } from "../utils/constants";
3134

@@ -64,7 +67,7 @@ export default function ClassroomPage({ onOpenSessions }: { onOpenSessions?: (se
6467
// P3:引擎预热状态(选窗口阶段后台加载;与 Rust PrepareStatus 契约一致)
6568
const [prepareState, setPrepareState] = useState<PrepareState>("idle");
6669
// v0.12.3:浮窗状态(按钮语义:浮窗化 ⇄ 收起 ⇄ 解锁穿透;Rust 单一来源)
67-
const [floatSnap, setFloatSnap] = useState<{ open: boolean; locked: boolean }>({ open: false, locked: false });
70+
const [floatSnap, setFloatSnap] = useState<FloatSnapshot>({ open: false, locked: false, topmost: true });
6871

6972
// ── 素材与结果(文件流水线,v0.1.0)──
7073
// 素材路径/处理中状态已下沉 MaterialInputPanel(审查硬拆);父级仅保留产物与提示
@@ -219,13 +222,13 @@ export default function ClassroomPage({ onOpenSessions }: { onOpenSessions?: (se
219222
useEffect(() => {
220223
let disposed = false;
221224
const unlisteners: Promise<() => void>[] = [];
222-
void invoke<{ open: boolean; locked: boolean }>("float_state")
225+
void invoke<FloatSnapshot>("float_state")
223226
.then((s) => {
224227
if (!disposed) setFloatSnap(s);
225228
})
226229
.catch(() => undefined);
227230
unlisteners.push(
228-
listen<{ open: boolean; locked: boolean }>("float:state", (e) => {
231+
listen<FloatSnapshot>("float:state", (e) => {
229232
if (!disposed) setFloatSnap(e.payload);
230233
}),
231234
);

0 commit comments

Comments
 (0)