@livx.cc/appwrap 0.46.1 → 0.46.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,445 @@
1
+ // browser_tab — Tauri commands for an EMBEDDED, tear-off/dock-able browser tab (project "browser-tab"
2
+ // Phase 3). Thin front-end-facing wrapper over `webview_control::embedded::Controller`: the same live
3
+ // WKWebView (page + WKWebsiteDataStore session + bridgeShim) is mounted `ChildOf` a host pane NSView
4
+ // and torn off / docked with `set_mount`.
5
+ //
6
+ // R2 (agent-drive, DRY): the tab is created in the crate-level registry keyed by `id` (its label), so
7
+ // the control-channel adapter (`bridge_mac.rs`) — and thus the mcp-desktop-browser MCP — can drive
8
+ // this SAME embedded tab (navigate/eval/screenshot/close) by that id, with NO new TS<->Rust binding.
9
+ //
10
+ // Landmine (a): the pane NSView* is fetched + the webview mounted on the MAIN THREAD, AFTER the pane
11
+ // container has non-zero bounds (we create the container at the caller-supplied paneRect), else blank.
12
+ #[cfg(target_os = "macos")]
13
+ mod imp {
14
+ use std::collections::HashMap;
15
+ use std::ffi::c_void;
16
+ use std::sync::mpsc::channel;
17
+ use std::sync::{Mutex, OnceLock};
18
+ use std::time::{Duration, SystemTime, UNIX_EPOCH};
19
+
20
+ use objc2::rc::Retained;
21
+ use objc2::{MainThreadMarker, MainThreadOnly};
22
+ use objc2_app_kit::{NSAutoresizingMaskOptions, NSView, NSWindow};
23
+ use objc2_foundation::{NSPoint, NSRect, NSSize};
24
+ use objc2_web_kit::WKWebView;
25
+ use serde::Deserialize;
26
+ use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
27
+
28
+ use webview_control::embedded::Controller;
29
+ use webview_control::{CreateSpec, Mount, ViewHandle};
30
+
31
+ /// Fixed height (points) of the sub-window chrome strip (‹ › ⟳ + address bar + profile▾). The
32
+ /// controlled WKWebView pane is pinned BELOW this within the sub-window's contentView.
33
+ pub const CHROME_H: f64 = 44.0;
34
+
35
+ /// Autoresizing mask keeping the controlled-webview container pinned below the chrome bar while it
36
+ /// tracks window resize: both margins fixed (44pt top gap, 0 bottom), width+height flexible.
37
+ fn container_mask() -> NSAutoresizingMaskOptions {
38
+ NSAutoresizingMaskOptions(
39
+ NSAutoresizingMaskOptions::ViewWidthSizable.0 | NSAutoresizingMaskOptions::ViewHeightSizable.0,
40
+ )
41
+ }
42
+
43
+ /// A unique sub-window id / bridge tab label.
44
+ fn new_subwin_id() -> String {
45
+ let ms = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
46
+ format!("subwin-{ms}")
47
+ }
48
+
49
+ /// Web-DOM pane rect the front-end reserves (top-left origin, CSS px == points here since the
50
+ /// shell runs at the window's backing scale). We flip Y to AppKit's bottom-left origin at mount.
51
+ #[derive(Deserialize, Clone, Copy)]
52
+ pub struct PaneRect {
53
+ pub x: f64,
54
+ pub y: f64,
55
+ pub width: f64,
56
+ pub height: f64,
57
+ }
58
+
59
+ // id -> host pane container NSView* (as usize). The container is retained by being a subview of
60
+ // the window contentView; we hold only its pointer to reposition / reparent / remove it. Access
61
+ // is main-thread only (all commands marshal via run_on_main_thread), the Mutex just satisfies the
62
+ // `static` Send/Sync bound for the raw pointer.
63
+ fn containers() -> &'static Mutex<HashMap<String, usize>> {
64
+ static CONTAINERS: OnceLock<Mutex<HashMap<String, usize>>> = OnceLock::new();
65
+ CONTAINERS.get_or_init(|| Mutex::new(HashMap::new()))
66
+ }
67
+
68
+ unsafe fn appkit_rect(content: &NSView, r: PaneRect) -> NSRect {
69
+ let ch = content.bounds().size.height;
70
+ NSRect::new(
71
+ NSPoint::new(r.x, ch - r.y - r.height),
72
+ NSSize::new(r.width, r.height),
73
+ )
74
+ }
75
+
76
+ /// The main window's contentView (parent of the shell WKWebView). Main-thread only.
77
+ unsafe fn content_view(win: &NSWindow) -> Retained<NSView> {
78
+ win.contentView().expect("main window has a contentView")
79
+ }
80
+
81
+ pub fn create(
82
+ app: tauri::AppHandle,
83
+ id: String,
84
+ url: String,
85
+ profile_id: String,
86
+ rect: PaneRect,
87
+ ) -> Result<(), String> {
88
+ let win = app.get_webview_window("main").ok_or("no main window")?;
89
+ // with_webview runs its closure on the AppKit main thread. Build the pane container at
90
+ // non-zero bounds FIRST (landmine a), then mount the webview ChildOf it — all on main.
91
+ let (tx, rx) = std::sync::mpsc::channel();
92
+ win.with_webview(move |w| {
93
+ let res = (|| -> Result<(), String> {
94
+ let mtm = MainThreadMarker::new().ok_or("not main thread")?;
95
+ unsafe {
96
+ let wk = &*(w.inner() as *mut WKWebView);
97
+ let ns_win = wk.window().ok_or("shell webview has no window")?;
98
+ let content = content_view(&ns_win);
99
+ let frame = appkit_rect(&content, rect);
100
+ let container = NSView::initWithFrame(NSView::alloc(mtm), frame);
101
+ content.addSubview(&container);
102
+ let cptr = Retained::as_ptr(&container) as *mut c_void;
103
+ let handle = ViewHandle::from_ptr(cptr);
104
+ let spec = CreateSpec {
105
+ label: id.clone(),
106
+ url: url.clone(),
107
+ profile_id: profile_id.clone(),
108
+ init_js: String::new(),
109
+ mount: Mount::ChildOf(handle),
110
+ };
111
+ Controller::new().create_webview(mtm, spec, Box::new(|_raw| {}))?;
112
+ // contentView holds a strong ref via addSubview; store only the pointer.
113
+ containers().lock().unwrap().insert(id.clone(), cptr as usize);
114
+ }
115
+ Ok(())
116
+ })();
117
+ let _ = tx.send(res);
118
+ })
119
+ .map_err(|e| e.to_string())?;
120
+ rx.recv().map_err(|e| e.to_string())?
121
+ }
122
+
123
+ pub fn set_mount(app: tauri::AppHandle, id: String, mode: String) -> Result<(), String> {
124
+ let (tx, rx) = std::sync::mpsc::channel();
125
+ let win = app.get_webview_window("main").ok_or("no main window")?;
126
+ win.run_on_main_thread(move || {
127
+ let res = (|| -> Result<(), String> {
128
+ let mtm = MainThreadMarker::new().ok_or("not main thread")?;
129
+ let mount = match mode.as_str() {
130
+ "toplevel" => Mount::TopLevel,
131
+ "child" => {
132
+ let ptr = *containers().lock().unwrap().get(&id).ok_or("no pane for id")?;
133
+ unsafe { Mount::ChildOf(ViewHandle::from_ptr(ptr as *mut c_void)) }
134
+ }
135
+ other => return Err(format!("bad mount mode: {other}")),
136
+ };
137
+ if Controller::new().set_mount(mtm, &id, mount) {
138
+ Ok(())
139
+ } else {
140
+ Err(format!("no webview {id}"))
141
+ }
142
+ })();
143
+ let _ = tx.send(res);
144
+ })
145
+ .map_err(|e| e.to_string())?;
146
+ rx.recv().map_err(|e| e.to_string())?
147
+ }
148
+
149
+ pub fn navigate(app: tauri::AppHandle, id: String, url: String) -> Result<(), String> {
150
+ let (tx, rx) = std::sync::mpsc::channel();
151
+ let win = app.get_webview_window("main").ok_or("no main window")?;
152
+ win.run_on_main_thread(move || {
153
+ let res = (|| -> Result<(), String> {
154
+ let mtm = MainThreadMarker::new().ok_or("not main thread")?;
155
+ Controller::new().navigate(mtm, &id, &url)
156
+ })();
157
+ let _ = tx.send(res);
158
+ })
159
+ .map_err(|e| e.to_string())?;
160
+ rx.recv().map_err(|e| e.to_string())?
161
+ }
162
+
163
+ pub fn close(app: tauri::AppHandle, id: String) -> Result<(), String> {
164
+ let (tx, rx) = std::sync::mpsc::channel();
165
+ let win = app.get_webview_window("main").ok_or("no main window")?;
166
+ win.run_on_main_thread(move || {
167
+ let res = (|| -> Result<(), String> {
168
+ let mtm = MainThreadMarker::new().ok_or("not main thread")?;
169
+ Controller::new().close(mtm, &id);
170
+ if let Some(ptr) = containers().lock().unwrap().remove(&id) {
171
+ unsafe {
172
+ let container = &*(ptr as *mut NSView);
173
+ container.removeFromSuperview();
174
+ }
175
+ }
176
+ Ok(())
177
+ })();
178
+ let _ = tx.send(res);
179
+ })
180
+ .map_err(|e| e.to_string())?;
181
+ rx.recv().map_err(|e| e.to_string())?
182
+ }
183
+
184
+ // ---- Sub-window: a standalone Tauri chrome window + a controlled WKWebView pane below the bar ----
185
+
186
+ /// Bridge init-JS + page sink for a sub-window webview, so it registers as a bod-browser tab when
187
+ /// the shim is running, and is inert-but-functional otherwise (empty init, no-op sink).
188
+ fn bridge_wiring(id: &str) -> (String, webview_control::MessageSink) {
189
+ let init_js = crate::bridge_mac::bridge_init_js(id).unwrap_or_default();
190
+ // Live self-healing sink: delivers to whichever shim is connected at send-time (None-safe),
191
+ // so a sub-window created while the shim is down becomes agent-exposed on a later connect.
192
+ let sink = crate::bridge_mac::bridge_sink();
193
+ (init_js, sink)
194
+ }
195
+
196
+ /// Mount a controlled WKWebView `ChildOf` a pane pinned below the chrome bar in `win`'s content
197
+ /// view, wiring the bridge for agent-exposure. Runs on the main thread (via `with_webview`).
198
+ fn mount_pane(win: &tauri::WebviewWindow, id: &str, profile_id: &str, url: &str) -> Result<(), String> {
199
+ let (tx, rx) = channel();
200
+ let (id, profile_id, url) = (id.to_string(), profile_id.to_string(), url.to_string());
201
+ win.with_webview(move |w| {
202
+ let res = (|| -> Result<(), String> {
203
+ let mtm = MainThreadMarker::new().ok_or("not main thread")?;
204
+ unsafe {
205
+ let wk = &*(w.inner() as *mut WKWebView);
206
+ let ns_win = wk.window().ok_or("chrome webview has no window")?;
207
+ let content = content_view(&ns_win);
208
+ let b = content.bounds().size;
209
+ let rect = PaneRect { x: 0.0, y: CHROME_H, width: b.width, height: (b.height - CHROME_H).max(0.0) };
210
+ let frame = appkit_rect(&content, rect);
211
+ let container = NSView::initWithFrame(NSView::alloc(mtm), frame);
212
+ container.setAutoresizingMask(container_mask());
213
+ content.addSubview(&container);
214
+ let cptr = Retained::as_ptr(&container) as *mut c_void;
215
+ let handle = ViewHandle::from_ptr(cptr);
216
+ let (init_js, sink) = bridge_wiring(&id);
217
+ let spec = CreateSpec {
218
+ label: id.clone(),
219
+ url: url.clone(),
220
+ profile_id: profile_id.clone(),
221
+ init_js,
222
+ mount: Mount::ChildOf(handle),
223
+ };
224
+ Controller::new().create_webview(mtm, spec, sink)?;
225
+ containers().lock().unwrap().insert(id.clone(), cptr as usize);
226
+ }
227
+ Ok(())
228
+ })();
229
+ let _ = tx.send(res);
230
+ })
231
+ .map_err(|e| e.to_string())?;
232
+ rx.recv().map_err(|e| e.to_string())?
233
+ }
234
+
235
+ /// Clean up a sub-window's controlled webview + container. Main-thread only (called from the
236
+ /// window's Destroyed event, which fires on the main thread).
237
+ fn teardown(id: &str, mtm: MainThreadMarker) {
238
+ Controller::new().close(mtm, id);
239
+ if let Some(ptr) = containers().lock().unwrap().remove(id) {
240
+ unsafe { (*(ptr as *mut NSView)).removeFromSuperview() };
241
+ }
242
+ }
243
+
244
+ /// Open a sub-window: build the Tauri chrome window (loads the bundled chrome HTML asset, `?id=`
245
+ /// stamped so it binds to this webview), then mount the controlled webview below the chrome bar.
246
+ pub fn open(app: tauri::AppHandle, profile_id: String) -> Result<String, String> {
247
+ // Two DISTINCT labels: the Tauri chrome window (`win_id`) and the controlled WKWebView it
248
+ // hosts (`view_id = <win_id>-view`). Sharing one label double-listed it in `listWindows`
249
+ // (once from `app.webview_windows()`, once from the crate registry). The chrome HTML, its
250
+ // invokes, the container/registry key, the bridge `__BRIDGE_LABEL`, and what agents drive all
251
+ // key on `view_id`; only the OS window keeps `win_id`.
252
+ let win_id = new_subwin_id();
253
+ let view_id = format!("{win_id}-view");
254
+ let chrome_url = format!("appwrap-browser-chrome.html?id={view_id}");
255
+ let win = WebviewWindowBuilder::new(&app, &win_id, WebviewUrl::App(chrome_url.into()))
256
+ .title("Browser")
257
+ .inner_size(1100.0, 800.0)
258
+ .build()
259
+ .map_err(|e| e.to_string())?;
260
+ // Tear the controlled webview down when the sub-window closes, so it doesn't linger as a
261
+ // registry zombie / stray bod-browser tab.
262
+ let idc = view_id.clone();
263
+ win.on_window_event(move |ev| {
264
+ if matches!(ev, tauri::WindowEvent::Destroyed) {
265
+ if let Some(mtm) = MainThreadMarker::new() {
266
+ teardown(&idc, mtm);
267
+ }
268
+ }
269
+ });
270
+ mount_pane(&win, &view_id, &profile_id, "about:blank")?;
271
+ Ok(view_id)
272
+ }
273
+
274
+ /// Re-fire the `register` beacon on every open sub-window controlled webview. Called when the
275
+ /// shim (re)connects: their live sink can now deliver, but an already-loaded document won't
276
+ /// re-run its init-JS `register()`, so nudge each via the bridgeShim handler to re-post it.
277
+ /// Idempotent + inert when the bridge feature is off (no bridgeShim handler → the try/catch eats
278
+ /// it) and when there are no sub-windows.
279
+ pub fn reregister_all(app: &tauri::AppHandle) {
280
+ let ids: Vec<String> = containers().lock().unwrap().keys().cloned().collect();
281
+ if ids.is_empty() {
282
+ return;
283
+ }
284
+ let _ = app.run_on_main_thread(move || {
285
+ if let Some(mtm) = MainThreadMarker::new() {
286
+ let c = Controller::new();
287
+ for id in &ids {
288
+ let js = "try{if(window.__BRIDGE_LABEL){webkit.messageHandlers.bridgeShim.postMessage(JSON.stringify({label:window.__BRIDGE_LABEL,type:'register',url:location.href,title:document.title}))}}catch(e){}".to_string();
289
+ let _ = c.inject_script(mtm, id, &js);
290
+ }
291
+ }
292
+ });
293
+ }
294
+
295
+ /// Swap the sub-window's controlled webview to another profile: profile is immutable per-webview,
296
+ /// so close the current one (keeps the pane container) and recreate `ChildOf` the SAME container
297
+ /// with the new profile. The Tauri chrome window is untouched.
298
+ pub fn switch_profile(app: tauri::AppHandle, id: String, profile_id: String) -> Result<(), String> {
299
+ let win = app.get_webview_window("main").ok_or("no main window")?;
300
+ let (tx, rx) = channel();
301
+ win.run_on_main_thread(move || {
302
+ let res = (|| -> Result<(), String> {
303
+ let mtm = MainThreadMarker::new().ok_or("not main thread")?;
304
+ let ptr = *containers().lock().unwrap().get(&id).ok_or("no pane for id")?;
305
+ Controller::new().close(mtm, &id);
306
+ unsafe {
307
+ let handle = ViewHandle::from_ptr(ptr as *mut c_void);
308
+ let (init_js, sink) = bridge_wiring(&id);
309
+ let spec = CreateSpec {
310
+ label: id.clone(),
311
+ url: "about:blank".to_string(),
312
+ profile_id: profile_id.clone(),
313
+ init_js,
314
+ mount: Mount::ChildOf(handle),
315
+ };
316
+ Controller::new().create_webview(mtm, spec, sink)?;
317
+ }
318
+ Ok(())
319
+ })();
320
+ let _ = tx.send(res);
321
+ })
322
+ .map_err(|e| e.to_string())?;
323
+ rx.recv().map_err(|e| e.to_string())?
324
+ }
325
+
326
+ /// Route a nav op (`back`/`forward`/`reload`) to the controlled webview `id` on the main thread.
327
+ pub fn nav_op(app: tauri::AppHandle, id: String, op: &'static str) -> Result<(), String> {
328
+ let win = app.get_webview_window("main").ok_or("no main window")?;
329
+ let (tx, rx) = channel();
330
+ win.run_on_main_thread(move || {
331
+ let res = (|| -> Result<(), String> {
332
+ let mtm = MainThreadMarker::new().ok_or("not main thread")?;
333
+ let c = Controller::new();
334
+ match op {
335
+ "back" => c.go_back(mtm, &id),
336
+ "forward" => c.go_forward(mtm, &id),
337
+ "reload" => c.reload(mtm, &id),
338
+ other => Err(format!("bad nav op: {other}")),
339
+ }
340
+ })();
341
+ let _ = tx.send(res);
342
+ })
343
+ .map_err(|e| e.to_string())?;
344
+ rx.recv().map_err(|e| e.to_string())?
345
+ }
346
+
347
+ /// Read the controlled webview `id`'s current `{href,title}` as a JSON string (for the address bar).
348
+ pub fn current_url(app: tauri::AppHandle, id: String) -> Result<String, String> {
349
+ let win = app.get_webview_window("main").ok_or("no main window")?;
350
+ let (tx, rx) = channel();
351
+ win.run_on_main_thread(move || {
352
+ let mtm = match MainThreadMarker::new() {
353
+ Some(m) => m,
354
+ None => { let _ = tx.send(Err("not main thread".to_string())); return; }
355
+ };
356
+ Controller::new().eval_async(
357
+ mtm,
358
+ &id,
359
+ "return JSON.stringify({href:location.href,title:document.title});".to_string(),
360
+ move |r| { let _ = tx.send(r); },
361
+ );
362
+ })
363
+ .map_err(|e| e.to_string())?;
364
+ rx.recv_timeout(Duration::from_secs(5)).map_err(|e| e.to_string())?
365
+ }
366
+ }
367
+
368
+ #[cfg(target_os = "macos")]
369
+ #[tauri::command]
370
+ pub fn browser_tab_create(
371
+ app: tauri::AppHandle,
372
+ id: String,
373
+ url: String,
374
+ profile_id: String,
375
+ rect: imp::PaneRect,
376
+ ) -> Result<String, String> {
377
+ imp::create(app, id.clone(), url, profile_id, rect).map(|_| id)
378
+ }
379
+
380
+ #[cfg(target_os = "macos")]
381
+ #[tauri::command]
382
+ pub fn browser_tab_set_mount(app: tauri::AppHandle, id: String, mode: String) -> Result<(), String> {
383
+ imp::set_mount(app, id, mode)
384
+ }
385
+
386
+ #[cfg(target_os = "macos")]
387
+ #[tauri::command]
388
+ pub fn browser_tab_navigate(app: tauri::AppHandle, id: String, url: String) -> Result<(), String> {
389
+ imp::navigate(app, id, url)
390
+ }
391
+
392
+ #[cfg(target_os = "macos")]
393
+ #[tauri::command]
394
+ pub fn browser_tab_close(app: tauri::AppHandle, id: String) -> Result<(), String> {
395
+ imp::close(app, id)
396
+ }
397
+
398
+ #[cfg(target_os = "macos")]
399
+ #[tauri::command]
400
+ pub fn browser_subwindow_open(app: tauri::AppHandle, profile_id: Option<String>) -> Result<String, String> {
401
+ imp::open(app, profile_id.unwrap_or_else(|| "default".to_string()))
402
+ }
403
+
404
+ #[cfg(target_os = "macos")]
405
+ #[tauri::command]
406
+ pub fn browser_subwindow_switch_profile(app: tauri::AppHandle, id: String, profile_id: String) -> Result<(), String> {
407
+ imp::switch_profile(app, id, profile_id)
408
+ }
409
+
410
+ #[cfg(target_os = "macos")]
411
+ #[tauri::command]
412
+ pub fn browser_tab_back(app: tauri::AppHandle, id: String) -> Result<(), String> {
413
+ imp::nav_op(app, id, "back")
414
+ }
415
+
416
+ #[cfg(target_os = "macos")]
417
+ #[tauri::command]
418
+ pub fn browser_tab_forward(app: tauri::AppHandle, id: String) -> Result<(), String> {
419
+ imp::nav_op(app, id, "forward")
420
+ }
421
+
422
+ #[cfg(target_os = "macos")]
423
+ #[tauri::command]
424
+ pub fn browser_tab_reload(app: tauri::AppHandle, id: String) -> Result<(), String> {
425
+ imp::nav_op(app, id, "reload")
426
+ }
427
+
428
+ #[cfg(target_os = "macos")]
429
+ #[tauri::command]
430
+ pub fn browser_tab_current_url(app: tauri::AppHandle, id: String) -> Result<String, String> {
431
+ imp::current_url(app, id)
432
+ }
433
+
434
+ #[cfg(target_os = "macos")]
435
+ #[tauri::command]
436
+ pub fn browser_list_profiles() -> Result<Vec<String>, String> {
437
+ Ok(webview_control::list_profiles())
438
+ }
439
+
440
+ /// Nudge every open browser sub-window to re-post its `register` beacon (called by the bridge control
441
+ /// channel when a shim (re)connects, so pre-existing sub-windows self-heal into agent-exposure).
442
+ #[cfg(target_os = "macos")]
443
+ pub fn reregister_subwindows(app: &tauri::AppHandle) {
444
+ imp::reregister_all(app);
445
+ }
@@ -17,6 +17,7 @@ use tauri::Manager;
17
17
  mod biometrics_mac;
18
18
  #[cfg(target_os = "macos")]
19
19
  mod bridge_mac;
20
+ mod browser_tab;
20
21
  #[cfg(target_os = "macos")]
21
22
  mod notifications_mac;
22
23
  #[cfg(target_os = "macos")]
@@ -688,7 +689,20 @@ fn main() {
688
689
  builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
689
690
  }
690
691
  builder
691
- .invoke_handler(tauri::generate_handler![appwrap_invoke])
692
+ .invoke_handler(tauri::generate_handler![
693
+ appwrap_invoke,
694
+ browser_tab::browser_tab_create,
695
+ browser_tab::browser_tab_set_mount,
696
+ browser_tab::browser_tab_navigate,
697
+ browser_tab::browser_tab_close,
698
+ browser_tab::browser_subwindow_open,
699
+ browser_tab::browser_subwindow_switch_profile,
700
+ browser_tab::browser_tab_back,
701
+ browser_tab::browser_tab_forward,
702
+ browser_tab::browser_tab_reload,
703
+ browser_tab::browser_tab_current_url,
704
+ browser_tab::browser_list_profiles,
705
+ ])
692
706
  // Popups are plain NSWindows tauri doesn't know about; with the main (only tauri) window
693
707
  // closed, tauri exits the app — but tear popups down explicitly so no dangling windows or
694
708
  // live child webviews outlive the shell during teardown (or if the exit is ever prevented).
@@ -707,6 +721,75 @@ fn main() {
707
721
  .setup(|app| {
708
722
  // Stash the app handle so off-main-thread handlers can reach plugin state (notifications).
709
723
  let _ = APP.set(app.handle().clone());
724
+ // Native "Browser ▸ New Browser Window" menu — every appwrap desktop app gets the
725
+ // app-owned browser sub-window affordance. We APPEND to the DEFAULT macOS menu (built via
726
+ // Menu::default) so Edit's Cut/Copy/Paste/Select-All stay intact; suppressing them would
727
+ // break Cmd+C/V in inputs. The menu event opens a sub-window for the "default" profile.
728
+ {
729
+ use tauri::menu::{Menu, MenuItem, Submenu};
730
+ let handle = app.handle();
731
+ match Menu::default(handle).and_then(|menu| {
732
+ let new_win = MenuItem::with_id(
733
+ handle, "browser_new_window", "New Browser Window", true,
734
+ Some("CmdOrCtrl+Shift+N"),
735
+ )?;
736
+ let browser = Submenu::with_id_and_items(
737
+ handle, "browser_menu", "Browser", true, &[&new_win],
738
+ )?;
739
+ menu.append(&browser)?;
740
+ // View ▸ Toggle DevTools (⌥⌘I). Opens/closes Tauri's devtools on the focused
741
+ // window; the handler no-ops in release unless APPWRAP_DEVTOOLS is set
742
+ // (devtools_enabled), matching the Safari-inspectable gate.
743
+ let toggle_devtools = MenuItem::with_id(
744
+ handle, "toggle_devtools", "Toggle DevTools", true,
745
+ Some("Alt+CmdOrCtrl+I"),
746
+ )?;
747
+ let view = Submenu::with_id_and_items(
748
+ handle, "view_menu", "View", true, &[&toggle_devtools],
749
+ )?;
750
+ menu.append(&view)?;
751
+ Ok(menu)
752
+ }) {
753
+ Ok(menu) => {
754
+ if let Err(e) = app.set_menu(menu) {
755
+ eprintln!("[browser] set_menu failed: {e}");
756
+ }
757
+ }
758
+ Err(e) => eprintln!("[browser] build Browser menu failed: {e}"),
759
+ }
760
+ app.on_menu_event(|app, event| {
761
+ match event.id().as_ref() {
762
+ "browser_new_window" => {
763
+ if let Err(e) = browser_tab::browser_subwindow_open(app.clone(), Some("default".to_string())) {
764
+ eprintln!("[browser] open sub-window (menu) failed: {e}");
765
+ }
766
+ }
767
+ "toggle_devtools" => {
768
+ // Gate at runtime: inert in a release build unless APPWRAP_DEVTOOLS is set
769
+ // (same policy as Safari-inspectable); always on in debug.
770
+ #[cfg(target_os = "macos")]
771
+ if !webview_control::devtools_enabled() {
772
+ eprintln!("[devtools] toggle ignored (set APPWRAP_DEVTOOLS=1 to enable in release)");
773
+ return;
774
+ }
775
+ // Toggle on the focused webview window (sub-windows are their own
776
+ // WebviewWindows), falling back to main.
777
+ let win = app.webview_windows()
778
+ .into_values()
779
+ .find(|w| w.is_focused().unwrap_or(false))
780
+ .or_else(|| app.get_webview_window("main"));
781
+ if let Some(win) = win {
782
+ if win.is_devtools_open() {
783
+ win.close_devtools();
784
+ } else {
785
+ win.open_devtools();
786
+ }
787
+ }
788
+ }
789
+ _ => {}
790
+ }
791
+ });
792
+ }
710
793
  // SPIKE: browser-bridge window-ops backend — inert unless APPWRAP_BRIDGE_JS is set.
711
794
  #[cfg(target_os = "macos")]
712
795
  bridge_mac::init(app.handle().clone());
@@ -748,7 +831,12 @@ fn main() {
748
831
  #[cfg(target_os = "macos")]
749
832
  if let Some(window) = app.get_webview_window("main") {
750
833
  let picker = shell().google_account_picker;
751
- window.with_webview(move |w| popup_mac::install(w.inner() as _, picker))?;
834
+ window.with_webview(move |w| {
835
+ popup_mac::install(w.inner() as _, picker);
836
+ // Safari Web Inspector on the main Tauri window's WKWebView (debug always;
837
+ // release only under APPWRAP_DEVTOOLS). Runs on the main thread (with_webview).
838
+ unsafe { webview_control::set_inspectable(w.inner() as *mut _) };
839
+ })?;
752
840
  }
753
841
  // Reveal after the state plugin's on_window_ready restore has applied (built hidden above).
754
842
  if restore {