@livx.cc/appwrap 0.54.0 → 0.56.0

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.
Files changed (70) hide show
  1. package/package.json +5 -4
  2. package/runtime/app/main-page.ts +0 -2
  3. package/runtime/app/shell/capabilities.manifest.ts +27 -92
  4. package/runtime/app/shell/config.ts +4 -0
  5. package/runtime/app/shell/env.ts +8 -1
  6. package/runtime/app/shell/handlers.ts +2 -2
  7. package/runtime/tests/bridge-response-delivery.test.ts +10 -1
  8. package/runtime/tests/env-scheme.test.ts +50 -0
  9. package/scripts/stage-assets.mjs +1 -1
  10. package/src/cli.ts +424 -621
  11. package/src/config.ts +8 -1
  12. package/src/handlers.ts +1 -1
  13. package/src/packs.ts +174 -0
  14. package/src/testing.ts +173 -0
  15. package/templates/module-pack/README.md +44 -0
  16. package/templates/module-pack/handler.ts +9 -0
  17. package/templates/module-pack/manifest.ts +27 -0
  18. package/templates/module-pack/native-src/__MODULE_NAME__/.gitkeep +0 -0
  19. package/runtime/app/shell/billing-offer.ts +0 -54
  20. package/runtime/app/shell/handlers-billing.ts +0 -662
  21. package/runtime/app/shell/handlers-health.ts +0 -178
  22. package/runtime/app/shell/handlers-widget.ts +0 -97
  23. package/runtime/modules-native/billing/App_Resources/iOS/src/AppwrapManageSubscriptions.swift +0 -44
  24. package/runtime/modules-native/health/App_Resources/Android/src/main/java/cc/livx/appwrap/HealthConnectBridge.kt +0 -74
  25. package/runtime/modules-native/widget/App_Resources/Android/src/main/java/cc/livx/appwrap/AppwrapWidgetProvider.kt +0 -118
  26. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/drawable/appwrap_badge_bg.xml +0 -9
  27. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml +0 -123
  28. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_colors.xml +0 -10
  29. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_styles.xml +0 -67
  30. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values-night/appwrap_widget_colors.xml +0 -9
  31. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml +0 -17
  32. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.entitlements +0 -10
  33. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.swift +0 -275
  34. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/Info.plist +0 -25
  35. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/extension.json +0 -9
  36. package/runtime/modules-native/widget/App_Resources/iOS/src/AppwrapWidgetReload.swift +0 -18
  37. package/runtime/tests/billing-offer.test.ts +0 -61
  38. package/runtime-desktop/autotest/index.html +0 -77
  39. package/runtime-desktop/bridge-shim/build.sh +0 -9
  40. package/runtime-desktop/bridge-shim/content-entry.ts +0 -87
  41. package/runtime-desktop/bridge-shim/shim.ts +0 -132
  42. package/runtime-desktop/chrome/appwrap-browser-chrome.html +0 -185
  43. package/runtime-desktop/crates/webview-control/Cargo.lock +0 -330
  44. package/runtime-desktop/crates/webview-control/Cargo.toml +0 -28
  45. package/runtime-desktop/crates/webview-control/examples/host_harness.rs +0 -362
  46. package/runtime-desktop/crates/webview-control/src/embedded.rs +0 -138
  47. package/runtime-desktop/crates/webview-control/src/lib.rs +0 -100
  48. package/runtime-desktop/crates/webview-control/src/popup.rs +0 -522
  49. package/runtime-desktop/crates/webview-control/src/profiles.rs +0 -57
  50. package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +0 -558
  51. package/runtime-desktop/src-tauri/Cargo.lock +0 -5474
  52. package/runtime-desktop/src-tauri/Cargo.toml +0 -45
  53. package/runtime-desktop/src-tauri/build.rs +0 -3
  54. package/runtime-desktop/src-tauri/capabilities/default.json +0 -6
  55. package/runtime-desktop/src-tauri/icons/icon.png +0 -0
  56. package/runtime-desktop/src-tauri/shell_config.json +0 -10
  57. package/runtime-desktop/src-tauri/src/biometrics_mac.rs +0 -138
  58. package/runtime-desktop/src-tauri/src/bridge_mac.rs +0 -424
  59. package/runtime-desktop/src-tauri/src/browser_tab.rs +0 -804
  60. package/runtime-desktop/src-tauri/src/main.rs +0 -1228
  61. package/runtime-desktop/src-tauri/src/notifications_mac.rs +0 -202
  62. package/runtime-desktop/src-tauri/src/oauth_mac.rs +0 -139
  63. package/runtime-desktop/src-tauri/src/plugin_host.rs +0 -522
  64. package/runtime-desktop/src-tauri/src/popup_mac.rs +0 -5
  65. package/runtime-desktop/src-tauri/src/push_mac.rs +0 -193
  66. package/runtime-desktop/src-tauri/src/server.rs +0 -156
  67. package/runtime-desktop/src-tauri/src/sidecar.rs +0 -398
  68. package/runtime-desktop/src-tauri/tauri.conf.json +0 -14
  69. package/src/desktop.ts +0 -300
  70. package/src/plugin/host.ts +0 -242
@@ -1,804 +0,0 @@
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::path::PathBuf;
17
- use std::sync::atomic::{AtomicBool, Ordering};
18
- use std::sync::mpsc::channel;
19
- use std::sync::{Mutex, OnceLock};
20
- use std::time::{Duration, SystemTime, UNIX_EPOCH};
21
-
22
- use objc2::rc::Retained;
23
- use objc2::{MainThreadMarker, MainThreadOnly};
24
- use objc2_app_kit::{NSAutoresizingMaskOptions, NSView, NSWindow, NSWindowStyleMask};
25
- use objc2_foundation::{NSPoint, NSRect, NSSize};
26
- use objc2_web_kit::WKWebView;
27
- use serde::Deserialize;
28
- use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
29
-
30
- use webview_control::embedded::Controller;
31
- use webview_control::{CreateSpec, Mount, ViewHandle};
32
-
33
- /// Fixed height (points) of the sub-window chrome strip (‹ › ⟳ + address bar + profile▾). The
34
- /// controlled WKWebView pane is pinned BELOW this within the sub-window's contentView.
35
- pub const CHROME_H: f64 = 44.0;
36
-
37
- /// Autoresizing mask keeping the controlled-webview container pinned below the chrome bar while it
38
- /// tracks window resize: both margins fixed (44pt top gap, 0 bottom), width+height flexible.
39
- fn container_mask() -> NSAutoresizingMaskOptions {
40
- NSAutoresizingMaskOptions(
41
- NSAutoresizingMaskOptions::ViewWidthSizable.0 | NSAutoresizingMaskOptions::ViewHeightSizable.0,
42
- )
43
- }
44
-
45
- /// Percent-encode a string for safe use as a URL hash-fragment param value (RFC 3986 unreserved
46
- /// stay literal; everything else → %XX). URLSearchParams on the chrome side decodes it back.
47
- fn hash_encode(s: &str) -> String {
48
- let mut out = String::new();
49
- for b in s.bytes() {
50
- match b {
51
- b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
52
- _ => out.push_str(&format!("%{b:02X}")),
53
- }
54
- }
55
- out
56
- }
57
-
58
- /// A unique sub-window id / bridge tab label.
59
- fn new_subwin_id() -> String {
60
- let ms = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
61
- format!("subwin-{ms}")
62
- }
63
-
64
- /// Web-DOM pane rect the front-end reserves (top-left origin, CSS px == points here since the
65
- /// shell runs at the window's backing scale). We flip Y to AppKit's bottom-left origin at mount.
66
- #[derive(Deserialize, Clone, Copy)]
67
- pub struct PaneRect {
68
- pub x: f64,
69
- pub y: f64,
70
- pub width: f64,
71
- pub height: f64,
72
- }
73
-
74
- // id -> host pane container NSView* (as usize). The container is retained by being a subview of
75
- // the window contentView; we hold only its pointer to reposition / reparent / remove it. Access
76
- // is main-thread only (all commands marshal via run_on_main_thread), the Mutex just satisfies the
77
- // `static` Send/Sync bound for the raw pointer.
78
- fn containers() -> &'static Mutex<HashMap<String, usize>> {
79
- static CONTAINERS: OnceLock<Mutex<HashMap<String, usize>>> = OnceLock::new();
80
- CONTAINERS.get_or_init(|| Mutex::new(HashMap::new()))
81
- }
82
-
83
- // ---- Session persistence: restore open browser sub-windows across app restarts ----
84
- //
85
- // We mirror the live set of open sub-windows (profile + last-navigated url, keyed by view id) to a
86
- // JSON file. On the next launch `restore()` re-opens them. A user-closed window is dropped from the
87
- // snapshot immediately; on app QUIT the snapshot is left intact (guarded by `APP_QUITTING`), so
88
- // whatever was open comes back. Restore-fidelity is "last explicit navigation" (back/fwd history
89
- // is not persisted) — deliberately lean.
90
-
91
- /// Set on app exit so the per-window teardown (which fires on BOTH user-close and quit) leaves the
92
- /// snapshot intact on quit but prunes it on a genuine user-close.
93
- pub static APP_QUITTING: AtomicBool = AtomicBool::new(false);
94
-
95
- fn sessions() -> &'static Mutex<HashMap<String, (String, String)>> {
96
- static S: OnceLock<Mutex<HashMap<String, (String, String)>>> = OnceLock::new();
97
- S.get_or_init(|| Mutex::new(HashMap::new()))
98
- }
99
-
100
- fn state_path() -> PathBuf {
101
- PathBuf::from(std::env::var("HOME").unwrap_or_default())
102
- .join("Library/Application Support/appwrap-desktop/browser-subwindows.json")
103
- }
104
-
105
- /// Write the current live session set (profile,url pairs) to disk. Best-effort (logs on failure).
106
- fn persist() {
107
- let list: Vec<(String, String)> = sessions().lock().unwrap().values().cloned().collect();
108
- let path = state_path();
109
- if let Some(dir) = path.parent() {
110
- let _ = std::fs::create_dir_all(dir);
111
- }
112
- if let Err(e) = std::fs::write(&path, serde_json::to_string_pretty(&list).unwrap_or_default()) {
113
- eprintln!("[browser] persist sub-window sessions failed: {e}");
114
- }
115
- }
116
-
117
- fn record_open(id: &str, profile: &str, url: &str) {
118
- sessions().lock().unwrap().insert(id.to_string(), (profile.to_string(), url.to_string()));
119
- persist();
120
- }
121
- fn record_url(id: &str, url: &str) {
122
- if let Some(e) = sessions().lock().unwrap().get_mut(id) {
123
- e.1 = url.to_string();
124
- } else {
125
- return;
126
- }
127
- persist();
128
- }
129
- fn record_profile(id: &str, profile: &str) {
130
- if let Some(e) = sessions().lock().unwrap().get_mut(id) {
131
- e.0 = profile.to_string();
132
- e.1 = "about:blank".to_string();
133
- } else {
134
- return;
135
- }
136
- persist();
137
- }
138
- /// Drop a session on genuine user-close; on app quit keep it so `restore()` re-opens it next launch.
139
- fn record_close(id: &str) {
140
- if APP_QUITTING.load(Ordering::SeqCst) {
141
- return;
142
- }
143
- sessions().lock().unwrap().remove(id);
144
- persist();
145
- }
146
-
147
- unsafe fn appkit_rect(content: &NSView, r: PaneRect) -> NSRect {
148
- let ch = content.bounds().size.height;
149
- NSRect::new(
150
- NSPoint::new(r.x, ch - r.y - r.height),
151
- NSSize::new(r.width, r.height),
152
- )
153
- }
154
-
155
- /// Map a DOM rect `r` (SHELL WEBVIEW viewport coords, top-left origin) to an AppKit frame in the
156
- /// container's superview (contentView) coordinate space. Two corrections vs a naive `content.bounds`
157
- /// flip: (1) `fe_in_content` — the shell webview's own rect in contentView coords (handles the
158
- /// webview being offset/nested); (2) `sa` — the webview's SAFE-AREA insets. On a full-size-content
159
- /// macOS window WKWebView insets its web VISUAL VIEWPORT under the titlebar (e.g. innerHeight 897 in
160
- /// a 929pt webview), so DOM y=0 sits `sa.top` px BELOW the webview's frame top. Without adding
161
- /// `sa.top`/`sa.left` the pane rides exactly that inset too high, covering the FE chrome. `cv_h` is
162
- /// the contentView height (for the single bottom-left-origin flip). Callers pass
163
- /// `wk.convertRect_toView(wk.bounds(), Some(content))` and `wk.safeAreaInsets()`.
164
- fn appkit_rect_in(cv_h: f64, fe_in_content: NSRect, sa_top: f64, sa_left: f64, r: PaneRect) -> NSRect {
165
- // Shell webview's frame top, measured from the contentView TOP (top-left origin space).
166
- let fe_top_from_cv_top = cv_h - (fe_in_content.origin.y + fe_in_content.size.height);
167
- // Pane top-left from contentView top: webview offset + safe-area inset + DOM offset.
168
- let x = fe_in_content.origin.x + sa_left + r.x;
169
- let top_from_cv_top = fe_top_from_cv_top + sa_top + r.y;
170
- // Single flip to AppKit bottom-left origin.
171
- NSRect::new(
172
- NSPoint::new(x, cv_h - top_from_cv_top - r.height),
173
- NSSize::new(r.width, r.height),
174
- )
175
- }
176
-
177
- /// The main window's contentView (parent of the shell WKWebView). Main-thread only.
178
- unsafe fn content_view(win: &NSWindow) -> Retained<NSView> {
179
- win.contentView().expect("main window has a contentView")
180
- }
181
-
182
- pub fn create(
183
- app: tauri::AppHandle,
184
- id: String,
185
- url: String,
186
- profile_id: String,
187
- rect: PaneRect,
188
- ) -> Result<(), String> {
189
- let win = app.get_webview_window("main").ok_or("no main window")?;
190
- // with_webview runs its closure on the AppKit main thread. Build the pane container at
191
- // non-zero bounds FIRST (landmine a), then mount the webview ChildOf it — all on main.
192
- let (tx, rx) = std::sync::mpsc::channel();
193
- win.with_webview(move |w| {
194
- let res = (|| -> Result<(), String> {
195
- let mtm = MainThreadMarker::new().ok_or("not main thread")?;
196
- unsafe {
197
- // Idempotent: a fast unmount→remount (flexlayout re-render / StrictMode) can call
198
- // create for an id whose webview+container still exist. Tear the old one down first
199
- // so we never STACK duplicate WKWebViews for one tab (each is heavy — the prime
200
- // suspect for the runaway memory growth when panes churn).
201
- Controller::new().close(mtm, &id);
202
- if let Some(ptr) = containers().lock().unwrap().remove(&id) {
203
- (*(ptr as *mut NSView)).removeFromSuperview();
204
- }
205
- let wk = &*(w.inner() as *mut WKWebView);
206
- let ns_win = wk.window().ok_or("shell webview has no window")?;
207
- let content = content_view(&ns_win);
208
- let fe_in_content = wk.convertRect_toView(wk.bounds(), Some(&content));
209
- let sa = wk.safeAreaInsets();
210
- let cb = content.bounds().size;
211
- let frame = appkit_rect_in(cb.height, fe_in_content, sa.top, sa.left, rect);
212
- let container = NSView::initWithFrame(NSView::alloc(mtm), frame);
213
- content.addSubview(&container);
214
- let cptr = Retained::as_ptr(&container) as *mut c_void;
215
- let handle = ViewHandle::from_ptr(cptr);
216
- // R2 (agent-drive, DRY): wire the SAME bridge init-JS + live self-healing page sink
217
- // the sub-window path uses, so an embedded tab registers as a bod-browser tab
218
- // addressable by `id` over the control channel — thus drivable by mcp-desktop-browser.
219
- // Inert when the bridge feature is off (empty init-JS, None-safe sink); doesn't
220
- // affect mount/rect (init_js/sink only touch page registration, not layout).
221
- let (init_js, sink) = bridge_wiring(&id);
222
- let spec = CreateSpec {
223
- label: id.clone(),
224
- url: url.clone(),
225
- profile_id: profile_id.clone(),
226
- init_js,
227
- mount: Mount::ChildOf(handle),
228
- };
229
- Controller::new().create_webview(mtm, spec, sink)?;
230
- // contentView holds a strong ref via addSubview; store only the pointer.
231
- containers().lock().unwrap().insert(id.clone(), cptr as usize);
232
- }
233
- Ok(())
234
- })();
235
- let _ = tx.send(res);
236
- })
237
- .map_err(|e| e.to_string())?;
238
- rx.recv().map_err(|e| e.to_string())?
239
- }
240
-
241
- /// Reposition an embedded tab's host pane container to `rect` (front-end DOM coords). The webview
242
- /// is a fill-masked subview of the container, so moving/resizing the container carries the live
243
- /// webview with it — no webview re-create, session intact. Recomputes `appkit_rect` (Y-flip vs the
244
- /// current content-view height) EXACTLY as `create()` does, so a layout move/resize/scroll on the
245
- /// FE side keeps the native pane glued to the reserved DOM rect. Runs on the main thread.
246
- pub fn set_rect(app: tauri::AppHandle, id: String, rect: PaneRect) -> Result<(), String> {
247
- let win = app.get_webview_window("main").ok_or("no main window")?;
248
- let (tx, rx) = std::sync::mpsc::channel();
249
- win.with_webview(move |w| {
250
- let res = (|| -> Result<(), String> {
251
- let _mtm = MainThreadMarker::new().ok_or("not main thread")?;
252
- unsafe {
253
- let wk = &*(w.inner() as *mut WKWebView);
254
- let ns_win = wk.window().ok_or("shell webview has no window")?;
255
- let content = content_view(&ns_win);
256
- let fe_in_content = wk.convertRect_toView(wk.bounds(), Some(&content));
257
- let sa = wk.safeAreaInsets();
258
- let cb = content.bounds().size;
259
- let frame = appkit_rect_in(cb.height, fe_in_content, sa.top, sa.left, rect);
260
- let ptr = *containers().lock().unwrap().get(&id).ok_or("no pane for id")?;
261
- let container = &*(ptr as *mut NSView);
262
- container.setFrame(frame);
263
- }
264
- Ok(())
265
- })();
266
- let _ = tx.send(res);
267
- })
268
- .map_err(|e| e.to_string())?;
269
- rx.recv().map_err(|e| e.to_string())?
270
- }
271
-
272
- pub fn set_mount(app: tauri::AppHandle, id: String, mode: String) -> Result<(), String> {
273
- let (tx, rx) = std::sync::mpsc::channel();
274
- let win = app.get_webview_window("main").ok_or("no main window")?;
275
- win.run_on_main_thread(move || {
276
- let res = (|| -> Result<(), String> {
277
- let mtm = MainThreadMarker::new().ok_or("not main thread")?;
278
- let mount = match mode.as_str() {
279
- "toplevel" => Mount::TopLevel,
280
- "child" => {
281
- let ptr = *containers().lock().unwrap().get(&id).ok_or("no pane for id")?;
282
- unsafe { Mount::ChildOf(ViewHandle::from_ptr(ptr as *mut c_void)) }
283
- }
284
- other => return Err(format!("bad mount mode: {other}")),
285
- };
286
- if Controller::new().set_mount(mtm, &id, mount) {
287
- Ok(())
288
- } else {
289
- Err(format!("no webview {id}"))
290
- }
291
- })();
292
- let _ = tx.send(res);
293
- })
294
- .map_err(|e| e.to_string())?;
295
- rx.recv().map_err(|e| e.to_string())?
296
- }
297
-
298
- pub fn navigate(app: tauri::AppHandle, id: String, url: String) -> Result<(), String> {
299
- let (tx, rx) = std::sync::mpsc::channel();
300
- let win = app.get_webview_window("main").ok_or("no main window")?;
301
- let (id_c, url_c) = (id.clone(), url.clone());
302
- win.run_on_main_thread(move || {
303
- let res = (|| -> Result<(), String> {
304
- let mtm = MainThreadMarker::new().ok_or("not main thread")?;
305
- Controller::new().navigate(mtm, &id, &url)
306
- })();
307
- let _ = tx.send(res);
308
- })
309
- .map_err(|e| e.to_string())?;
310
- rx.recv().map_err(|e| e.to_string())??;
311
- // Persist the last-navigated url for sub-window session restore (no-op for embedded tabs).
312
- record_url(&id_c, &url_c);
313
- Ok(())
314
- }
315
-
316
- pub fn close(app: tauri::AppHandle, id: String) -> Result<(), String> {
317
- let (tx, rx) = std::sync::mpsc::channel();
318
- let win = app.get_webview_window("main").ok_or("no main window")?;
319
- win.run_on_main_thread(move || {
320
- let res = (|| -> Result<(), String> {
321
- let mtm = MainThreadMarker::new().ok_or("not main thread")?;
322
- Controller::new().close(mtm, &id);
323
- if let Some(ptr) = containers().lock().unwrap().remove(&id) {
324
- unsafe {
325
- let container = &*(ptr as *mut NSView);
326
- container.removeFromSuperview();
327
- }
328
- }
329
- Ok(())
330
- })();
331
- let _ = tx.send(res);
332
- })
333
- .map_err(|e| e.to_string())?;
334
- rx.recv().map_err(|e| e.to_string())?
335
- }
336
-
337
- /// Tear down EVERY embedded pane (main-window children). Called on ⌘R app reload: `reload()`
338
- /// reloads the FE but never runs React's unmount cleanup, so `close` is never called and the panes
339
- /// orphan — leaked WKWebViews + visual mixing during the reload gap. The reloaded FE re-creates its
340
- /// tabs by the SAME id (`create` is idempotent), so this is a safe pre-reload wipe. Sub-window panes
341
- /// (`*-view`) live in their own OS windows, untouched by a main-window reload, so they're skipped.
342
- /// Main-thread only: the ⌘R menu handler already runs on main, so we run inline with its
343
- /// `MainThreadMarker` — NOT via run_on_main_thread+recv, which would deadlock main against itself.
344
- pub fn close_all_embedded(mtm: MainThreadMarker) {
345
- let ids: Vec<String> = containers()
346
- .lock()
347
- .unwrap()
348
- .keys()
349
- .filter(|id| !id.ends_with("-view"))
350
- .cloned()
351
- .collect();
352
- let c = Controller::new();
353
- for id in &ids {
354
- c.close(mtm, id);
355
- if let Some(ptr) = containers().lock().unwrap().remove(id) {
356
- unsafe { (*(ptr as *mut NSView)).removeFromSuperview() };
357
- }
358
- }
359
- }
360
-
361
- // ---- Sub-window: a standalone Tauri chrome window + a controlled WKWebView pane below the bar ----
362
-
363
- /// Bridge init-JS + page sink for a sub-window webview, so it registers as a bod-browser tab when
364
- /// the shim is running, and is inert-but-functional otherwise (empty init, no-op sink).
365
- fn bridge_wiring(id: &str) -> (String, webview_control::MessageSink) {
366
- let init_js = crate::bridge_mac::bridge_init_js(id).unwrap_or_default();
367
- // Live self-healing sink: delivers to whichever shim is connected at send-time (None-safe),
368
- // so a sub-window created while the shim is down becomes agent-exposed on a later connect.
369
- let sink = crate::bridge_mac::bridge_sink();
370
- (init_js, sink)
371
- }
372
-
373
- /// Mount a controlled WKWebView `ChildOf` a pane pinned below the chrome bar in `win`'s content
374
- /// view, wiring the bridge for agent-exposure. Runs on the main thread (via `with_webview`).
375
- fn mount_pane(win: &tauri::WebviewWindow, id: &str, profile_id: &str, url: &str) -> Result<(), String> {
376
- let (tx, rx) = channel();
377
- let (id, profile_id, url) = (id.to_string(), profile_id.to_string(), url.to_string());
378
- win.with_webview(move |w| {
379
- let res = (|| -> Result<(), String> {
380
- let mtm = MainThreadMarker::new().ok_or("not main thread")?;
381
- unsafe {
382
- let wk = &*(w.inner() as *mut WKWebView);
383
- let ns_win = wk.window().ok_or("chrome webview has no window")?;
384
- // wry gives the sub-window a FULL-SIZE content view: contentView spans the whole
385
- // frame and the traffic-light titlebar overlays its top, hiding all but a sliver of
386
- // the HTML chrome bar. Drop the full-size-content style so contentView sits BELOW
387
- // the titlebar — the bar + pane then lay out in the visible region with no offset.
388
- ns_win.setStyleMask(NSWindowStyleMask(
389
- ns_win.styleMask().0 & !NSWindowStyleMask::FullSizeContentView.0,
390
- ));
391
- ns_win.setTitlebarAppearsTransparent(false);
392
- let content = content_view(&ns_win);
393
- let b = content.bounds().size;
394
- let rect = PaneRect { x: 0.0, y: CHROME_H, width: b.width, height: (b.height - CHROME_H).max(0.0) };
395
- let frame = appkit_rect(&content, rect);
396
- let container = NSView::initWithFrame(NSView::alloc(mtm), frame);
397
- container.setAutoresizingMask(container_mask());
398
- content.addSubview(&container);
399
- let cptr = Retained::as_ptr(&container) as *mut c_void;
400
- let handle = ViewHandle::from_ptr(cptr);
401
- let (init_js, sink) = bridge_wiring(&id);
402
- let spec = CreateSpec {
403
- label: id.clone(),
404
- url: url.clone(),
405
- profile_id: profile_id.clone(),
406
- init_js,
407
- mount: Mount::ChildOf(handle),
408
- };
409
- Controller::new().create_webview(mtm, spec, sink)?;
410
- containers().lock().unwrap().insert(id.clone(), cptr as usize);
411
- }
412
- Ok(())
413
- })();
414
- let _ = tx.send(res);
415
- })
416
- .map_err(|e| e.to_string())?;
417
- rx.recv().map_err(|e| e.to_string())?
418
- }
419
-
420
- /// Clean up a sub-window's controlled webview + container. Main-thread only (called from the
421
- /// window's Destroyed event, which fires on the main thread).
422
- fn teardown(id: &str, mtm: MainThreadMarker) {
423
- Controller::new().close(mtm, id);
424
- if let Some(ptr) = containers().lock().unwrap().remove(id) {
425
- unsafe { (*(ptr as *mut NSView)).removeFromSuperview() };
426
- }
427
- // Prune from the restore snapshot on a genuine user-close; kept intact on app quit.
428
- record_close(id);
429
- }
430
-
431
- /// Open a sub-window: build the Tauri chrome window (loads the bundled chrome HTML asset, `?id=`
432
- /// stamped so it binds to this webview), then mount the controlled webview below the chrome bar.
433
- pub fn open(app: tauri::AppHandle, profile_id: String) -> Result<String, String> {
434
- // Two DISTINCT labels: the Tauri chrome window (`win_id`) and the controlled WKWebView it
435
- // hosts (`view_id = <win_id>-view`). Sharing one label double-listed it in `listWindows`
436
- // (once from `app.webview_windows()`, once from the crate registry). The chrome HTML, its
437
- // invokes, the container/registry key, the bridge `__BRIDGE_LABEL`, and what agents drive all
438
- // key on `view_id`; only the OS window keeps `win_id`.
439
- let win_id = new_subwin_id();
440
- let view_id = format!("{win_id}-view");
441
- // Pass the view id via a hash FRAGMENT, not a query — Tauri's `WebviewUrl::App` treats the
442
- // string as a path and the `?…` breaks asset resolution (looks up a literal `…html?id=…` file
443
- // → 404 → blank chrome). Fragments are never used for lookup and survive in `location.hash`.
444
- // Also pass the profile so the chrome dropdown reflects the ACTUAL mounted profile (a restored
445
- // window may be on a non-default profile). Percent-encode it — profile names are user-entered.
446
- let chrome_url = format!(
447
- "appwrap-browser-chrome.html#id={view_id}&profile={}",
448
- hash_encode(&profile_id)
449
- );
450
- // Open at the main window's size (logical points) so the sub-window matches the app it spawned
451
- // from, falling back to a sane default if the main window or its metrics are unavailable.
452
- let (w, h) = app
453
- .get_webview_window("main")
454
- .and_then(|m| {
455
- let sf = m.scale_factor().ok()?;
456
- let sz = m.inner_size().ok()?;
457
- Some((sz.width as f64 / sf, sz.height as f64 / sf))
458
- })
459
- .unwrap_or((1100.0, 800.0));
460
- let win = WebviewWindowBuilder::new(&app, &win_id, WebviewUrl::App(chrome_url.into()))
461
- .title("Browser")
462
- .inner_size(w, h)
463
- .build()
464
- .map_err(|e| e.to_string())?;
465
- // Tear the controlled webview down when the sub-window closes, so it doesn't linger as a
466
- // registry zombie / stray bod-browser tab.
467
- let idc = view_id.clone();
468
- win.on_window_event(move |ev| {
469
- if matches!(ev, tauri::WindowEvent::Destroyed) {
470
- if let Some(mtm) = MainThreadMarker::new() {
471
- teardown(&idc, mtm);
472
- }
473
- }
474
- });
475
- mount_pane(&win, &view_id, &profile_id, "about:blank")?;
476
- record_open(&view_id, &profile_id, "about:blank");
477
- Ok(view_id)
478
- }
479
-
480
- /// Re-open the sub-windows that were open at last quit (profile + last-navigated url). Best-effort:
481
- /// a failed re-open is logged and skipped. Called once on app startup.
482
- pub fn restore(app: tauri::AppHandle) {
483
- let saved: Vec<(String, String)> = std::fs::read_to_string(state_path())
484
- .ok()
485
- .and_then(|s| serde_json::from_str(&s).ok())
486
- .unwrap_or_default();
487
- for (profile, url) in saved {
488
- match open(app.clone(), profile) {
489
- Ok(id) => {
490
- if !url.is_empty() && url != "about:blank" {
491
- if let Err(e) = navigate(app.clone(), id, url) {
492
- eprintln!("[browser] restore navigate failed: {e}");
493
- }
494
- }
495
- }
496
- Err(e) => eprintln!("[browser] restore open failed: {e}"),
497
- }
498
- }
499
- }
500
-
501
- /// Re-fire the `register` beacon on every open sub-window controlled webview. Called when the
502
- /// shim (re)connects: their live sink can now deliver, but an already-loaded document won't
503
- /// re-run its init-JS `register()`, so nudge each via the bridgeShim handler to re-post it.
504
- /// Idempotent + inert when the bridge feature is off (no bridgeShim handler → the try/catch eats
505
- /// it) and when there are no sub-windows.
506
- pub fn reregister_all(app: &tauri::AppHandle) {
507
- let ids: Vec<String> = containers().lock().unwrap().keys().cloned().collect();
508
- if ids.is_empty() {
509
- return;
510
- }
511
- let _ = app.run_on_main_thread(move || {
512
- if let Some(mtm) = MainThreadMarker::new() {
513
- let c = Controller::new();
514
- for id in &ids {
515
- 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();
516
- let _ = c.inject_script(mtm, id, &js);
517
- }
518
- }
519
- });
520
- }
521
-
522
- /// Swap the sub-window's controlled webview to another profile: profile is immutable per-webview,
523
- /// so close the current one (keeps the pane container) and recreate `ChildOf` the SAME container
524
- /// with the new profile. The Tauri chrome window is untouched.
525
- pub fn switch_profile(app: tauri::AppHandle, id: String, profile_id: String) -> Result<(), String> {
526
- let win = app.get_webview_window("main").ok_or("no main window")?;
527
- let (id_c, profile_c) = (id.clone(), profile_id.clone());
528
- let (tx, rx) = channel();
529
- win.run_on_main_thread(move || {
530
- let res = (|| -> Result<(), String> {
531
- let mtm = MainThreadMarker::new().ok_or("not main thread")?;
532
- let ptr = *containers().lock().unwrap().get(&id).ok_or("no pane for id")?;
533
- Controller::new().close(mtm, &id);
534
- unsafe {
535
- let handle = ViewHandle::from_ptr(ptr as *mut c_void);
536
- let (init_js, sink) = bridge_wiring(&id);
537
- let spec = CreateSpec {
538
- label: id.clone(),
539
- url: "about:blank".to_string(),
540
- profile_id: profile_id.clone(),
541
- init_js,
542
- mount: Mount::ChildOf(handle),
543
- };
544
- Controller::new().create_webview(mtm, spec, sink)?;
545
- }
546
- Ok(())
547
- })();
548
- let _ = tx.send(res);
549
- })
550
- .map_err(|e| e.to_string())?;
551
- rx.recv().map_err(|e| e.to_string())??;
552
- // Persist the new profile (resets the restored url to about:blank) for session restore.
553
- record_profile(&id_c, &profile_c);
554
- Ok(())
555
- }
556
-
557
- /// Route a nav op (`back`/`forward`/`reload`) to the controlled webview `id` on the main thread.
558
- pub fn nav_op(app: tauri::AppHandle, id: String, op: &'static str) -> Result<(), String> {
559
- let win = app.get_webview_window("main").ok_or("no main window")?;
560
- let (tx, rx) = channel();
561
- win.run_on_main_thread(move || {
562
- let res = (|| -> Result<(), String> {
563
- let mtm = MainThreadMarker::new().ok_or("not main thread")?;
564
- let c = Controller::new();
565
- match op {
566
- "back" => c.go_back(mtm, &id),
567
- "forward" => c.go_forward(mtm, &id),
568
- "reload" => c.reload(mtm, &id),
569
- other => Err(format!("bad nav op: {other}")),
570
- }
571
- })();
572
- let _ = tx.send(res);
573
- })
574
- .map_err(|e| e.to_string())?;
575
- rx.recv().map_err(|e| e.to_string())?
576
- }
577
-
578
- /// Read the controlled webview `id`'s current `{href,title}` as a JSON string (for the address bar).
579
- pub fn current_url(app: tauri::AppHandle, id: String) -> Result<String, String> {
580
- let win = app.get_webview_window("main").ok_or("no main window")?;
581
- let (tx, rx) = channel();
582
- win.run_on_main_thread(move || {
583
- let mtm = match MainThreadMarker::new() {
584
- Some(m) => m,
585
- None => { let _ = tx.send(Err("not main thread".to_string())); return; }
586
- };
587
- Controller::new().eval_async(
588
- mtm,
589
- &id,
590
- "return JSON.stringify({href:location.href,title:document.title});".to_string(),
591
- move |r| { let _ = tx.send(r); },
592
- );
593
- })
594
- .map_err(|e| e.to_string())?;
595
- // Short timeout: this is a 1s address-bar poll; if the webview is mid-teardown the eval never
596
- // resolves, so cap the wait low rather than blocking on a dying window.
597
- rx.recv_timeout(Duration::from_millis(1500)).map_err(|e| e.to_string())?
598
- }
599
-
600
- /// CSP-immune eval of `js` (a function body that `return`s a value) into the controlled webview
601
- /// `id`. Returns the raw JS return value as a string ("null" for non-string/undefined returns —
602
- /// callers that need structured data should `return JSON.stringify(...)`). Powers the agent's
603
- /// read-page / click / type / scroll (all injected JS) over the SAME embedded WKWebView.
604
- pub fn eval(app: tauri::AppHandle, id: String, js: String) -> Result<String, String> {
605
- let win = app.get_webview_window("main").ok_or("no main window")?;
606
- let (tx, rx) = channel();
607
- win.run_on_main_thread(move || {
608
- let mtm = match MainThreadMarker::new() {
609
- Some(m) => m,
610
- None => { let _ = tx.send(Err("not main thread".to_string())); return; }
611
- };
612
- Controller::new().eval_async(mtm, &id, js, move |r| { let _ = tx.send(r); });
613
- })
614
- .map_err(|e| e.to_string())?;
615
- // Longer than the address-bar poll: page reads/clicks can await layout/network.
616
- rx.recv_timeout(Duration::from_secs(10)).map_err(|e| e.to_string())?
617
- }
618
- }
619
-
620
- #[cfg(target_os = "macos")]
621
- #[tauri::command]
622
- pub async fn browser_tab_create(
623
- app: tauri::AppHandle,
624
- id: String,
625
- url: String,
626
- profile_id: String,
627
- rect: imp::PaneRect,
628
- ) -> Result<String, String> {
629
- imp::create(app, id.clone(), url, profile_id, rect).map(|_| id)
630
- }
631
-
632
- #[cfg(target_os = "macos")]
633
- #[tauri::command]
634
- pub async fn browser_tab_set_rect(app: tauri::AppHandle, id: String, rect: imp::PaneRect) -> Result<(), String> {
635
- imp::set_rect(app, id, rect)
636
- }
637
-
638
- #[cfg(target_os = "macos")]
639
- #[tauri::command]
640
- pub async fn browser_tab_set_mount(app: tauri::AppHandle, id: String, mode: String) -> Result<(), String> {
641
- imp::set_mount(app, id, mode)
642
- }
643
-
644
- #[cfg(target_os = "macos")]
645
- #[tauri::command]
646
- pub async fn browser_tab_navigate(app: tauri::AppHandle, id: String, url: String) -> Result<(), String> {
647
- imp::navigate(app, id, url)
648
- }
649
-
650
- #[cfg(target_os = "macos")]
651
- #[tauri::command]
652
- pub async fn browser_tab_close(app: tauri::AppHandle, id: String) -> Result<(), String> {
653
- imp::close(app, id)
654
- }
655
-
656
- #[cfg(target_os = "macos")]
657
- #[tauri::command]
658
- pub async fn browser_subwindow_open(app: tauri::AppHandle, profile_id: Option<String>) -> Result<String, String> {
659
- imp::open(app, profile_id.unwrap_or_else(|| "default".to_string()))
660
- }
661
-
662
- #[cfg(target_os = "macos")]
663
- #[tauri::command]
664
- pub async fn browser_subwindow_switch_profile(app: tauri::AppHandle, id: String, profile_id: String) -> Result<(), String> {
665
- imp::switch_profile(app, id, profile_id)
666
- }
667
-
668
- #[cfg(target_os = "macos")]
669
- #[tauri::command]
670
- pub async fn browser_tab_back(app: tauri::AppHandle, id: String) -> Result<(), String> {
671
- imp::nav_op(app, id, "back")
672
- }
673
-
674
- #[cfg(target_os = "macos")]
675
- #[tauri::command]
676
- pub async fn browser_tab_forward(app: tauri::AppHandle, id: String) -> Result<(), String> {
677
- imp::nav_op(app, id, "forward")
678
- }
679
-
680
- #[cfg(target_os = "macos")]
681
- #[tauri::command]
682
- pub async fn browser_tab_reload(app: tauri::AppHandle, id: String) -> Result<(), String> {
683
- imp::nav_op(app, id, "reload")
684
- }
685
-
686
- #[cfg(target_os = "macos")]
687
- #[tauri::command]
688
- pub async fn browser_tab_current_url(app: tauri::AppHandle, id: String) -> Result<String, String> {
689
- imp::current_url(app, id)
690
- }
691
-
692
- #[cfg(target_os = "macos")]
693
- #[tauri::command]
694
- pub async fn browser_list_profiles() -> Result<Vec<String>, String> {
695
- Ok(webview_control::list_profiles())
696
- }
697
-
698
- /// Nudge every open browser sub-window to re-post its `register` beacon (called by the bridge control
699
- /// channel when a shim (re)connects, so pre-existing sub-windows self-heal into agent-exposure).
700
- #[cfg(target_os = "macos")]
701
- pub fn reregister_subwindows(app: &tauri::AppHandle) {
702
- imp::reregister_all(app);
703
- }
704
-
705
- // ---- Bridge entry points (served-PWA path via `browser.window.*`, distinct from the Tauri commands
706
- // which only reach app://-served content). Sync, callable from the appwrap bridge worker thread. ----
707
-
708
- /// Open an app-owned browser sub-window; returns its view id. Used by the `browser.window.open` bridge
709
- /// method so a server-loaded PWA (no injected `__TAURI__`) can spawn one.
710
- #[cfg(target_os = "macos")]
711
- pub fn open_subwindow(app: tauri::AppHandle, profile_id: String) -> Result<String, String> {
712
- imp::open(app, profile_id)
713
- }
714
-
715
- /// Switch a sub-window to another profile (creating+persisting the profile on first use).
716
- #[cfg(target_os = "macos")]
717
- pub fn switch_subwindow_profile(app: tauri::AppHandle, id: String, profile_id: String) -> Result<(), String> {
718
- imp::switch_profile(app, id, profile_id)
719
- }
720
-
721
- // ---- Bridge entry points for EMBEDDED tabs (served-PWA path via `browser.tab.*`). Sync, callable
722
- // from the appwrap bridge worker thread; each marshals to the main thread itself. `rect` arrives as
723
- // JSON from the wire (kept as `Value` here so the `PaneRect` shape stays private to `imp`). ----
724
-
725
- /// Create an embedded browser tab `ChildOf` a front-end-reserved pane; returns its id.
726
- #[cfg(target_os = "macos")]
727
- pub fn tab_create(
728
- app: tauri::AppHandle,
729
- id: String,
730
- url: String,
731
- profile_id: String,
732
- rect: serde_json::Value,
733
- ) -> Result<String, String> {
734
- let rect: imp::PaneRect = serde_json::from_value(rect).map_err(|e| format!("bad rect: {e}"))?;
735
- imp::create(app, id.clone(), url, profile_id, rect).map(|_| id)
736
- }
737
-
738
- /// Reposition an embedded tab's pane container to a new front-end DOM rect.
739
- #[cfg(target_os = "macos")]
740
- pub fn tab_set_rect(app: tauri::AppHandle, id: String, rect: serde_json::Value) -> Result<(), String> {
741
- let rect: imp::PaneRect = serde_json::from_value(rect).map_err(|e| format!("bad rect: {e}"))?;
742
- imp::set_rect(app, id, rect)
743
- }
744
-
745
- /// Navigate an embedded tab to `url`.
746
- #[cfg(target_os = "macos")]
747
- pub fn tab_navigate(app: tauri::AppHandle, id: String, url: String) -> Result<(), String> {
748
- imp::navigate(app, id, url)
749
- }
750
-
751
- /// Tear-off (`toplevel`) / dock (`child`) an embedded tab — same live webview, session intact.
752
- #[cfg(target_os = "macos")]
753
- pub fn tab_set_mount(app: tauri::AppHandle, id: String, mode: String) -> Result<(), String> {
754
- imp::set_mount(app, id, mode)
755
- }
756
-
757
- /// Close + drop an embedded tab and remove its pane container.
758
- #[cfg(target_os = "macos")]
759
- pub fn tab_close(app: tauri::AppHandle, id: String) -> Result<(), String> {
760
- imp::close(app, id)
761
- }
762
-
763
- /// Tear down every embedded browser pane before a ⌘R app reload. Main-thread only — the ⌘R menu
764
- /// handler runs on main, so we grab the `MainThreadMarker` inline (no marshalling → no deadlock).
765
- /// No-op off the main thread. See `imp::close_all_embedded`.
766
- #[cfg(target_os = "macos")]
767
- pub fn close_all_embedded() {
768
- if let Some(mtm) = objc2::MainThreadMarker::new() {
769
- imp::close_all_embedded(mtm);
770
- }
771
- }
772
-
773
- /// Route a history/reload op (`back`/`forward`/`reload`) to an embedded tab's controlled webview.
774
- #[cfg(target_os = "macos")]
775
- pub fn tab_nav(app: tauri::AppHandle, id: String, op: &'static str) -> Result<(), String> {
776
- imp::nav_op(app, id, op)
777
- }
778
-
779
- /// Read an embedded tab's current `{href,title}` (JSON string) — for polling the address bar so it
780
- /// tracks in-page navigation (link clicks / redirects), not just user-typed URLs.
781
- #[cfg(target_os = "macos")]
782
- pub fn tab_current_url(app: tauri::AppHandle, id: String) -> Result<String, String> {
783
- imp::current_url(app, id)
784
- }
785
-
786
- /// Eval `js` (a function body) into an embedded tab's controlled webview; returns the raw string
787
- /// result. Backs the `browser.tab.eval` bridge method so the side-panel agent can read page content
788
- /// (innerText) and drive clicks/typing/scroll over the SAME embedded WKWebView.
789
- #[cfg(target_os = "macos")]
790
- pub fn tab_eval(app: tauri::AppHandle, id: String, js: String) -> Result<String, String> {
791
- imp::eval(app, id, js)
792
- }
793
-
794
- /// Re-open the sub-windows that were open at last quit. Called once on app startup.
795
- #[cfg(target_os = "macos")]
796
- pub fn restore_subwindows(app: tauri::AppHandle) {
797
- imp::restore(app);
798
- }
799
-
800
- /// Mark the app as quitting so per-window teardown keeps (not prunes) the restore snapshot.
801
- #[cfg(target_os = "macos")]
802
- pub fn mark_quitting() {
803
- imp::APP_QUITTING.store(true, std::sync::atomic::Ordering::SeqCst);
804
- }