Skip to content

Commit 5d08090

Browse files
committed
feat(ui): 全局禁用 WebView2 浏览器原生右键菜单 + 文本输入应用内右键小菜单(REQ-244,App 挂载随 AI 批)
1 parent 533e7e9 commit 5d08090

8 files changed

Lines changed: 343 additions & 4 deletions

File tree

‎app/src-tauri/Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎app/src-tauri/Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,4 +140,7 @@ windows = { version = "0.61", features = [
140140
"Graphics_DirectX",
141141
"Graphics_DirectX_Direct3D11",
142142
] }
143+
# v0.16.1:浏览器痕迹去除——with_webview 取 ICoreWebView2Settings 禁用原生右键菜单;
144+
# 版本与 tauri 依赖树对齐(lock 内已有 0.38.2,零新增下载)
145+
webview2-com = "0.38"
143146

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
//! browser_chrome — WebView2 浏览器痕迹去除(v0.16.1 用户决定①)。
2+
//!
3+
//! @ai-context: 桌面应用右键默认弹出 WebView2 原生菜单(复制/粘贴/检查元素),
4+
//! 用户观感是"这是浏览器"且暴露调试入口——v0.16.1 起全局禁用:
5+
//! 经 wry Windows 扩展 with_webview → ICoreWebView2Controller →
6+
//! CoreWebView2() → Settings() 的 AreDefaultContextMenusEnabled(false)
7+
//! (W3C 标准的 contextmenu preventDefault 在 WebView2 下不保证抑制
8+
//! 原生菜单,host 侧设置才是可靠通道;前端另有 preventDefault 兜底)。
9+
//! 文本输入(textarea/input)的右键粘贴/复制由前端自绘小菜单补齐
10+
//! (BrowserChrome 组件),本模块只管"原生菜单消失"。
11+
//! 失败策略:仅日志、不阻断(降级=回到原生菜单,前端叠加自绘菜单)。
12+
13+
/// 禁用单个窗口内 WebView2 的原生右键菜单(失败仅日志——降级不阻断启动)。
14+
///
15+
/// 调用点:主窗(lib.rs setup)与浮窗/框选窗(建窗后)。关闭窗口前后幂等。
16+
#[cfg(target_os = "windows")]
17+
pub fn disable_default_context_menu(win: &tauri::WebviewWindow) -> tauri::Result<()> {
18+
win.with_webview(|wv| {
19+
let controller = wv.controller();
20+
// SAFETY: controller 由 wry 持有、与当前 webview 生命周期一致——仅在闭包内使用。
21+
unsafe {
22+
// Settings 在 ICoreWebView2 上(controller.CoreWebView2() 取 webview)
23+
if let Ok(webview) = controller.CoreWebView2() {
24+
if let Ok(settings) = webview.Settings() {
25+
if let Err(e) = settings.SetAreDefaultContextMenusEnabled(false) {
26+
eprintln!("[browser-chrome] SetAreDefaultContextMenusEnabled(false) 失败: {e}");
27+
}
28+
} else {
29+
eprintln!("[browser-chrome] 获取 WebView2 settings 失败");
30+
}
31+
} else {
32+
eprintln!("[browser-chrome] 获取 ICoreWebView2 失败");
33+
}
34+
}
35+
})
36+
}
37+
38+
/// 非 Windows 平台无 WebView2 原生菜单概念——空实现(保调用点代码统一)。
39+
#[cfg(not(target_os = "windows"))]
40+
pub fn disable_default_context_menu(_win: &tauri::WebviewWindow) -> tauri::Result<()> {
41+
Ok(())
42+
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ pub async fn open_capture_overlay(state: State<'_, AppState>, app: tauri::AppHan
6565
.map_err(|e| format!("覆盖层截图路径锁中毒: {}", e))? = Some(path);
6666
// 3) 创建覆盖层窗口(已存在则复用——幂等)
6767
if app.get_webview_window(CAPTURE_OVERLAY_LABEL).is_none() {
68-
WebviewWindowBuilder::new(&app, CAPTURE_OVERLAY_LABEL, WebviewUrl::App("index.html?overlay=1".into()))
68+
let overlay = WebviewWindowBuilder::new(&app, CAPTURE_OVERLAY_LABEL, WebviewUrl::App("index.html?overlay=1".into()))
6969
.title("熵减截图")
7070
.decorations(false)
7171
.transparent(true)
@@ -74,6 +74,10 @@ pub async fn open_capture_overlay(state: State<'_, AppState>, app: tauri::AppHan
7474
.always_on_top(true)
7575
.build()
7676
.map_err(|e| format!("创建覆盖层窗口失败: {}", e))?;
77+
// v0.16.1:框选窗同样禁用 WebView2 原生右键菜单(浏览器痕迹去除——失败仅日志)
78+
if let Err(e) = crate::browser_chrome::disable_default_context_menu(&overlay) {
79+
eprintln!("[browser-chrome] 框选窗禁用默认右键菜单失败: {e}");
80+
}
7781
}
7882
Ok(())
7983
}

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ pub fn precreate_float(app: &tauri::AppHandle) {
107107

108108
/// 构建浮窗窗口(precreated=true → 隐藏未聚焦——预创建路径;false → 可见带焦点——懒创建打开路径)。
109109
fn build_float_window(app: &tauri::AppHandle, precreated: bool) -> Result<WebviewWindow, String> {
110-
WebviewWindowBuilder::new(app, CAPTURE_FLOAT_LABEL, WebviewUrl::App("index.html?float=1".into()))
110+
let win = WebviewWindowBuilder::new(app, CAPTURE_FLOAT_LABEL, WebviewUrl::App("index.html?float=1".into()))
111111
.title("采集浮窗")
112112
.inner_size(360.0, 240.0)
113113
.decorations(false)
@@ -118,7 +118,12 @@ fn build_float_window(app: &tauri::AppHandle, precreated: bool) -> Result<Webvie
118118
.visible(!precreated)
119119
.focused(!precreated)
120120
.build()
121-
.map_err(|e| format!("创建采集浮窗失败: {}", e))
121+
.map_err(|e| format!("创建采集浮窗失败: {}", e))?;
122+
// v0.16.1:浮窗同样禁用 WebView2 原生右键菜单(浏览器痕迹去除——失败仅日志不阻断)
123+
if let Err(e) = crate::browser_chrome::disable_default_context_menu(&win) {
124+
eprintln!("[browser-chrome] 浮窗禁用默认右键菜单失败: {e}");
125+
}
126+
Ok(win)
122127
}
123128

124129
/// 显示 + 还原 + 聚焦主窗(回主窗/收起浮窗后的兜底——绝不把用户留在无可见窗口)。

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ mod ai_judge;
99
mod ai_mock;
1010
mod ai_protocol;
1111
mod ai_text_filter;
12+
// v0.16.1:WebView2 浏览器痕迹去除(原生右键菜单全局禁用——Windows host 侧设置)
13+
mod browser_chrome;
1214
// v0.8.0 M1(REQ-138/139/140):AI 使能层——全局设置(授权红线默认关)/
1315
// 密钥凭据存储(DPAPI)/余额查询/共享 AI client(ai_text_filter 与 M2/M3
1416
// ai_note_refine/ai_enrich 共用)
@@ -420,7 +422,15 @@ pub fn run() {
420422
.setup(|app| {
421423
// AppState 装配(数据目录/DB/引擎池/可校准配置——拆至 app_setup.rs,
422424
// line-limit-exemptions 登记计划:lib.rs >600 硬拆落地)
423-
crate::app_setup::setup_app_state(app).map_err(Into::into)
425+
crate::app_setup::setup_app_state(app).map_err(|e| Box::<dyn std::error::Error>::from(e))?;
426+
// v0.16.1:浏览器原生右键菜单全局禁用——失败仅日志(降级=原生菜单,
427+
// 前端 contextmenu preventDefault 兜底;文本输入右键粘贴由前端自绘小菜单补齐)
428+
if let Some(main) = app.get_webview_window(crate::commands_window::MAIN_WINDOW_LABEL) {
429+
if let Err(e) = crate::browser_chrome::disable_default_context_menu(&main) {
430+
eprintln!("[browser-chrome] 主窗禁用默认右键菜单失败: {e}");
431+
}
432+
}
433+
Ok(())
424434
})
425435
// ADR-007:采集进行时拦截窗口关闭——prevent_close + 通知前端弹确认框;
426436
// 用户确认后前端先 stop_live_session 再 close(届时无活动会话,放行)
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* BrowserChrome.test.tsx — 浏览器痕迹去除前端层(v0.16.1)。AAA 模式。
4+
*
5+
* @ai-context: 覆盖三契约——① 任意位置右键都 preventDefault(原生菜单不出现在
6+
* 应用内);② 文本控件右键弹应用内小菜单(剪切/复制/粘贴/全选);
7+
* ③ 粘贴 = 剪贴板文本插入光标处(异步 readText)。原生事件监听器
8+
* 触发的 setState 需 act 包裹(非 React 事件路径)。
9+
*/
10+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11+
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
12+
import BrowserChrome from "./BrowserChrome";
13+
14+
const readTextMock = vi.fn();
15+
16+
/** 追加到 body 的原生元素(脱离 React 树——单独清理避免跨测试残留) */
17+
function append<T extends HTMLElement>(el: T): T {
18+
document.body.appendChild(el);
19+
return el;
20+
}
21+
22+
beforeEach(() => {
23+
readTextMock.mockReset();
24+
readTextMock.mockResolvedValue("剪贴板文本");
25+
Object.defineProperty(globalThis.navigator, "clipboard", {
26+
value: { readText: readTextMock },
27+
configurable: true,
28+
});
29+
// jsdom 无 execCommand——模拟浏览器环境提供(复制/剪切走它)
30+
document.execCommand = vi.fn(() => true) as unknown as typeof document.execCommand;
31+
});
32+
afterEach(() => {
33+
cleanup();
34+
document.body.innerHTML = "";
35+
});
36+
37+
describe("BrowserChrome 浏览器痕迹去除", () => {
38+
it("非文本区域右键:不弹菜单(原生菜单已被 preventDefault 抑制)", () => {
39+
render(<BrowserChrome />);
40+
const box = append(document.createElement("div"));
41+
const ev = new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 10, clientY: 10 });
42+
box.dispatchEvent(ev);
43+
expect(ev.defaultPrevented).toBe(true);
44+
expect(screen.queryByTestId("browser-chrome-menu")).toBeNull();
45+
});
46+
47+
it("textarea 右键:弹出应用内菜单(剪切/复制/粘贴/全选)", () => {
48+
render(<BrowserChrome />);
49+
const ta = append(document.createElement("textarea"));
50+
act(() => {
51+
ta.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 20, clientY: 20 }));
52+
});
53+
expect(screen.getByTestId("browser-chrome-menu")).toBeTruthy();
54+
for (const label of ["剪切", "复制", "粘贴", "全选"]) {
55+
expect(screen.getByTestId(`browser-chrome-${label}`)).toBeTruthy();
56+
}
57+
});
58+
59+
it("粘贴:读取剪贴板并插入光标处(setRangeText + input 事件)", async () => {
60+
render(<BrowserChrome />);
61+
const ta = append(document.createElement("textarea"));
62+
ta.value = "ab";
63+
ta.setSelectionRange(1, 1); // 光标在 a|b
64+
act(() => {
65+
ta.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 20, clientY: 20 }));
66+
});
67+
fireEvent.click(screen.getByTestId("browser-chrome-粘贴"));
68+
await vi.waitFor(() => {
69+
expect(ta.value).toBe("a剪贴板文本b");
70+
});
71+
expect(readTextMock).toHaveBeenCalled();
72+
// 菜单已收起
73+
expect(screen.queryByTestId("browser-chrome-menu")).toBeNull();
74+
});
75+
76+
it("无选区:剪切/复制禁用;全选可点", () => {
77+
render(<BrowserChrome />);
78+
const inp = append(document.createElement("input"));
79+
inp.value = "hello";
80+
inp.setSelectionRange(0, 0);
81+
act(() => {
82+
inp.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 20, clientY: 20 }));
83+
});
84+
expect((screen.getByTestId("browser-chrome-剪切") as HTMLButtonElement).disabled).toBe(true);
85+
expect((screen.getByTestId("browser-chrome-复制") as HTMLButtonElement).disabled).toBe(true);
86+
fireEvent.click(screen.getByTestId("browser-chrome-全选"));
87+
expect(inp.selectionStart).toBe(0);
88+
expect(inp.selectionEnd).toBe(5);
89+
});
90+
91+
it("ESC 关闭菜单", () => {
92+
render(<BrowserChrome />);
93+
const inp = append(document.createElement("input"));
94+
act(() => {
95+
inp.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 20, clientY: 20 }));
96+
});
97+
expect(screen.getByTestId("browser-chrome-menu")).toBeTruthy();
98+
fireEvent.keyDown(window, { key: "Escape" });
99+
expect(screen.queryByTestId("browser-chrome-menu")).toBeNull();
100+
});
101+
});

0 commit comments

Comments
 (0)