@livx.cc/appwrap 0.47.2 → 0.48.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.
- package/package.json +1 -1
- package/runtime/App_Resources/Android/src/main/AndroidManifest.xml +3 -0
- package/runtime/app/shell/capabilities.manifest.ts +65 -3
- package/runtime/app/shell/custom-webview.android.ts +22 -2
- package/runtime/app/shell/handlers-android.ts +54 -9
- package/runtime/app/shell/handlers-extended.ts +82 -4
- package/runtime/app/shell/handlers-system.ts +48 -1
- package/runtime/app/shell/handlers-widget.ts +97 -0
- package/runtime/app/shell/notif-identity.ts +45 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/java/cc/livx/appwrap/AppwrapWidgetProvider.kt +79 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/drawable/appwrap_badge_bg.xml +7 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml +70 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_styles.xml +40 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml +13 -0
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.entitlements +10 -0
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.swift +275 -0
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/Info.plist +25 -0
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/extension.json +9 -0
- package/runtime/modules-native/widget/App_Resources/iOS/src/AppwrapWidgetReload.swift +18 -0
- package/runtime/references.d.ts +1 -0
- package/runtime/tests/notif-identity.test.ts +43 -0
- package/runtime-desktop/chrome/appwrap-browser-chrome.html +70 -5
- package/runtime-desktop/crates/webview-control/src/lib.rs +2 -1
- package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +22 -9
- package/runtime-desktop/src-tauri/src/browser_tab.rs +379 -20
- package/runtime-desktop/src-tauri/src/main.rs +191 -11
- package/src/cli.ts +74 -0
- package/src/config.ts +8 -1
- package/src/plugin/host.ts +5 -0
- package/src/plugin/types.ts +1 -0
|
@@ -123,19 +123,28 @@ define_class!(
|
|
|
123
123
|
/// the legacy tauri-window path — the profile-window path installs it on the config's UCC before
|
|
124
124
|
/// creation). The UCC retains the handler, so no external retention is needed here. Main-thread only.
|
|
125
125
|
pub unsafe fn install_message_handler(wk: *mut WKWebView, mtm: MainThreadMarker, on_message: MessageSink) {
|
|
126
|
+
install_named_message_handler(wk, mtm, "bridgeShim", on_message);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// Install a WKScriptMessageHandler under `name` on a WKWebView, so page JS reaches native via
|
|
130
|
+
/// `webkit.messageHandlers.<name>.postMessage(str)`. Origin-agnostic and NOT command-ACL-gated —
|
|
131
|
+
/// the ONLY reliable page→native path for a REMOTE-served page (Tauri command IPC is ACL-blocked
|
|
132
|
+
/// for remote origins; ws/http/custom-scheme are mixed-content-blocked from https). Main-thread only.
|
|
133
|
+
pub unsafe fn install_named_message_handler(
|
|
134
|
+
wk: *mut WKWebView,
|
|
135
|
+
mtm: MainThreadMarker,
|
|
136
|
+
name: &str,
|
|
137
|
+
on_message: MessageSink,
|
|
138
|
+
) {
|
|
126
139
|
let wk = &*wk;
|
|
127
140
|
let handler = MsgHandler::alloc(mtm).set_ivars(MsgHandlerIvars { on_message });
|
|
128
141
|
let handler: Retained<MsgHandler> = msg_send![super(handler), init];
|
|
129
142
|
let ucc = wk.configuration().userContentController();
|
|
130
|
-
// Idempotent per UCC:
|
|
131
|
-
// already registered
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
ucc.
|
|
135
|
-
ucc.addScriptMessageHandler_name(
|
|
136
|
-
ProtocolObject::from_ref(&*handler),
|
|
137
|
-
&NSString::from_str("bridgeShim"),
|
|
138
|
-
);
|
|
143
|
+
// Idempotent per UCC: addScriptMessageHandler throws NSInvalidArgumentException if `name` is
|
|
144
|
+
// already registered; remove-then-add makes re-install a no-op instead of a crash.
|
|
145
|
+
let ns_name = NSString::from_str(name);
|
|
146
|
+
ucc.removeScriptMessageHandlerForName(&ns_name);
|
|
147
|
+
ucc.addScriptMessageHandler_name(ProtocolObject::from_ref(&*handler), &ns_name);
|
|
139
148
|
}
|
|
140
149
|
|
|
141
150
|
/// A manually-created profile window. Strong refs kept alive until close (or windowWillClose).
|
|
@@ -305,6 +314,10 @@ pub fn create_webview(
|
|
|
305
314
|
|
|
306
315
|
let cfg = unsafe { WKWebViewConfiguration::new(mtm) };
|
|
307
316
|
unsafe { cfg.setWebsiteDataStore(&store) };
|
|
317
|
+
// Allow programmatic window.open (the createWebViewWithConfiguration UI-delegate below hosts the
|
|
318
|
+
// child popup). Without this WKWebView blocks any window.open NOT fired synchronously in a click —
|
|
319
|
+
// which silently kills OAuth "Sign in with Google/…" popups that open AFTER an async round-trip.
|
|
320
|
+
unsafe { cfg.preferences().setJavaScriptCanOpenWindowsAutomatically(true) };
|
|
308
321
|
|
|
309
322
|
// bridgeShim message handler + content bundle (documentStart) on this cfg's UCC.
|
|
310
323
|
let ucc = unsafe { cfg.userContentController() };
|
|
@@ -13,13 +13,15 @@
|
|
|
13
13
|
mod imp {
|
|
14
14
|
use std::collections::HashMap;
|
|
15
15
|
use std::ffi::c_void;
|
|
16
|
+
use std::path::PathBuf;
|
|
17
|
+
use std::sync::atomic::{AtomicBool, Ordering};
|
|
16
18
|
use std::sync::mpsc::channel;
|
|
17
19
|
use std::sync::{Mutex, OnceLock};
|
|
18
20
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
19
21
|
|
|
20
22
|
use objc2::rc::Retained;
|
|
21
23
|
use objc2::{MainThreadMarker, MainThreadOnly};
|
|
22
|
-
use objc2_app_kit::{NSAutoresizingMaskOptions, NSView, NSWindow};
|
|
24
|
+
use objc2_app_kit::{NSAutoresizingMaskOptions, NSView, NSWindow, NSWindowStyleMask};
|
|
23
25
|
use objc2_foundation::{NSPoint, NSRect, NSSize};
|
|
24
26
|
use objc2_web_kit::WKWebView;
|
|
25
27
|
use serde::Deserialize;
|
|
@@ -40,6 +42,19 @@ mod imp {
|
|
|
40
42
|
)
|
|
41
43
|
}
|
|
42
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
|
+
|
|
43
58
|
/// A unique sub-window id / bridge tab label.
|
|
44
59
|
fn new_subwin_id() -> String {
|
|
45
60
|
let ms = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
|
|
@@ -65,6 +80,70 @@ mod imp {
|
|
|
65
80
|
CONTAINERS.get_or_init(|| Mutex::new(HashMap::new()))
|
|
66
81
|
}
|
|
67
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
|
+
|
|
68
147
|
unsafe fn appkit_rect(content: &NSView, r: PaneRect) -> NSRect {
|
|
69
148
|
let ch = content.bounds().size.height;
|
|
70
149
|
NSRect::new(
|
|
@@ -73,6 +152,28 @@ mod imp {
|
|
|
73
152
|
)
|
|
74
153
|
}
|
|
75
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
|
+
|
|
76
177
|
/// The main window's contentView (parent of the shell WKWebView). Main-thread only.
|
|
77
178
|
unsafe fn content_view(win: &NSWindow) -> Retained<NSView> {
|
|
78
179
|
win.contentView().expect("main window has a contentView")
|
|
@@ -93,22 +194,39 @@ mod imp {
|
|
|
93
194
|
let res = (|| -> Result<(), String> {
|
|
94
195
|
let mtm = MainThreadMarker::new().ok_or("not main thread")?;
|
|
95
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
|
+
}
|
|
96
205
|
let wk = &*(w.inner() as *mut WKWebView);
|
|
97
206
|
let ns_win = wk.window().ok_or("shell webview has no window")?;
|
|
98
207
|
let content = content_view(&ns_win);
|
|
99
|
-
let
|
|
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);
|
|
100
212
|
let container = NSView::initWithFrame(NSView::alloc(mtm), frame);
|
|
101
213
|
content.addSubview(&container);
|
|
102
214
|
let cptr = Retained::as_ptr(&container) as *mut c_void;
|
|
103
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);
|
|
104
222
|
let spec = CreateSpec {
|
|
105
223
|
label: id.clone(),
|
|
106
224
|
url: url.clone(),
|
|
107
225
|
profile_id: profile_id.clone(),
|
|
108
|
-
init_js
|
|
226
|
+
init_js,
|
|
109
227
|
mount: Mount::ChildOf(handle),
|
|
110
228
|
};
|
|
111
|
-
Controller::new().create_webview(mtm, spec,
|
|
229
|
+
Controller::new().create_webview(mtm, spec, sink)?;
|
|
112
230
|
// contentView holds a strong ref via addSubview; store only the pointer.
|
|
113
231
|
containers().lock().unwrap().insert(id.clone(), cptr as usize);
|
|
114
232
|
}
|
|
@@ -120,6 +238,37 @@ mod imp {
|
|
|
120
238
|
rx.recv().map_err(|e| e.to_string())?
|
|
121
239
|
}
|
|
122
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
|
+
|
|
123
272
|
pub fn set_mount(app: tauri::AppHandle, id: String, mode: String) -> Result<(), String> {
|
|
124
273
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
125
274
|
let win = app.get_webview_window("main").ok_or("no main window")?;
|
|
@@ -149,6 +298,7 @@ mod imp {
|
|
|
149
298
|
pub fn navigate(app: tauri::AppHandle, id: String, url: String) -> Result<(), String> {
|
|
150
299
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
151
300
|
let win = app.get_webview_window("main").ok_or("no main window")?;
|
|
301
|
+
let (id_c, url_c) = (id.clone(), url.clone());
|
|
152
302
|
win.run_on_main_thread(move || {
|
|
153
303
|
let res = (|| -> Result<(), String> {
|
|
154
304
|
let mtm = MainThreadMarker::new().ok_or("not main thread")?;
|
|
@@ -157,7 +307,10 @@ mod imp {
|
|
|
157
307
|
let _ = tx.send(res);
|
|
158
308
|
})
|
|
159
309
|
.map_err(|e| e.to_string())?;
|
|
160
|
-
rx.recv().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(())
|
|
161
314
|
}
|
|
162
315
|
|
|
163
316
|
pub fn close(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
|
@@ -181,6 +334,30 @@ mod imp {
|
|
|
181
334
|
rx.recv().map_err(|e| e.to_string())?
|
|
182
335
|
}
|
|
183
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
|
+
|
|
184
361
|
// ---- Sub-window: a standalone Tauri chrome window + a controlled WKWebView pane below the bar ----
|
|
185
362
|
|
|
186
363
|
/// Bridge init-JS + page sink for a sub-window webview, so it registers as a bod-browser tab when
|
|
@@ -204,6 +381,14 @@ mod imp {
|
|
|
204
381
|
unsafe {
|
|
205
382
|
let wk = &*(w.inner() as *mut WKWebView);
|
|
206
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);
|
|
207
392
|
let content = content_view(&ns_win);
|
|
208
393
|
let b = content.bounds().size;
|
|
209
394
|
let rect = PaneRect { x: 0.0, y: CHROME_H, width: b.width, height: (b.height - CHROME_H).max(0.0) };
|
|
@@ -239,6 +424,8 @@ mod imp {
|
|
|
239
424
|
if let Some(ptr) = containers().lock().unwrap().remove(id) {
|
|
240
425
|
unsafe { (*(ptr as *mut NSView)).removeFromSuperview() };
|
|
241
426
|
}
|
|
427
|
+
// Prune from the restore snapshot on a genuine user-close; kept intact on app quit.
|
|
428
|
+
record_close(id);
|
|
242
429
|
}
|
|
243
430
|
|
|
244
431
|
/// Open a sub-window: build the Tauri chrome window (loads the bundled chrome HTML asset, `?id=`
|
|
@@ -251,10 +438,28 @@ mod imp {
|
|
|
251
438
|
// key on `view_id`; only the OS window keeps `win_id`.
|
|
252
439
|
let win_id = new_subwin_id();
|
|
253
440
|
let view_id = format!("{win_id}-view");
|
|
254
|
-
|
|
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));
|
|
255
460
|
let win = WebviewWindowBuilder::new(&app, &win_id, WebviewUrl::App(chrome_url.into()))
|
|
256
461
|
.title("Browser")
|
|
257
|
-
.inner_size(
|
|
462
|
+
.inner_size(w, h)
|
|
258
463
|
.build()
|
|
259
464
|
.map_err(|e| e.to_string())?;
|
|
260
465
|
// Tear the controlled webview down when the sub-window closes, so it doesn't linger as a
|
|
@@ -268,9 +473,31 @@ mod imp {
|
|
|
268
473
|
}
|
|
269
474
|
});
|
|
270
475
|
mount_pane(&win, &view_id, &profile_id, "about:blank")?;
|
|
476
|
+
record_open(&view_id, &profile_id, "about:blank");
|
|
271
477
|
Ok(view_id)
|
|
272
478
|
}
|
|
273
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
|
+
|
|
274
501
|
/// Re-fire the `register` beacon on every open sub-window controlled webview. Called when the
|
|
275
502
|
/// shim (re)connects: their live sink can now deliver, but an already-loaded document won't
|
|
276
503
|
/// re-run its init-JS `register()`, so nudge each via the bridgeShim handler to re-post it.
|
|
@@ -297,6 +524,7 @@ mod imp {
|
|
|
297
524
|
/// with the new profile. The Tauri chrome window is untouched.
|
|
298
525
|
pub fn switch_profile(app: tauri::AppHandle, id: String, profile_id: String) -> Result<(), String> {
|
|
299
526
|
let win = app.get_webview_window("main").ok_or("no main window")?;
|
|
527
|
+
let (id_c, profile_c) = (id.clone(), profile_id.clone());
|
|
300
528
|
let (tx, rx) = channel();
|
|
301
529
|
win.run_on_main_thread(move || {
|
|
302
530
|
let res = (|| -> Result<(), String> {
|
|
@@ -320,7 +548,10 @@ mod imp {
|
|
|
320
548
|
let _ = tx.send(res);
|
|
321
549
|
})
|
|
322
550
|
.map_err(|e| e.to_string())?;
|
|
323
|
-
rx.recv().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(())
|
|
324
555
|
}
|
|
325
556
|
|
|
326
557
|
/// Route a nav op (`back`/`forward`/`reload`) to the controlled webview `id` on the main thread.
|
|
@@ -361,13 +592,34 @@ mod imp {
|
|
|
361
592
|
);
|
|
362
593
|
})
|
|
363
594
|
.map_err(|e| e.to_string())?;
|
|
364
|
-
|
|
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())?
|
|
365
617
|
}
|
|
366
618
|
}
|
|
367
619
|
|
|
368
620
|
#[cfg(target_os = "macos")]
|
|
369
621
|
#[tauri::command]
|
|
370
|
-
pub fn browser_tab_create(
|
|
622
|
+
pub async fn browser_tab_create(
|
|
371
623
|
app: tauri::AppHandle,
|
|
372
624
|
id: String,
|
|
373
625
|
url: String,
|
|
@@ -379,61 +631,67 @@ pub fn browser_tab_create(
|
|
|
379
631
|
|
|
380
632
|
#[cfg(target_os = "macos")]
|
|
381
633
|
#[tauri::command]
|
|
382
|
-
pub fn
|
|
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> {
|
|
383
641
|
imp::set_mount(app, id, mode)
|
|
384
642
|
}
|
|
385
643
|
|
|
386
644
|
#[cfg(target_os = "macos")]
|
|
387
645
|
#[tauri::command]
|
|
388
|
-
pub fn browser_tab_navigate(app: tauri::AppHandle, id: String, url: String) -> Result<(), String> {
|
|
646
|
+
pub async fn browser_tab_navigate(app: tauri::AppHandle, id: String, url: String) -> Result<(), String> {
|
|
389
647
|
imp::navigate(app, id, url)
|
|
390
648
|
}
|
|
391
649
|
|
|
392
650
|
#[cfg(target_os = "macos")]
|
|
393
651
|
#[tauri::command]
|
|
394
|
-
pub fn browser_tab_close(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
|
652
|
+
pub async fn browser_tab_close(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
|
395
653
|
imp::close(app, id)
|
|
396
654
|
}
|
|
397
655
|
|
|
398
656
|
#[cfg(target_os = "macos")]
|
|
399
657
|
#[tauri::command]
|
|
400
|
-
pub fn browser_subwindow_open(app: tauri::AppHandle, profile_id: Option<String>) -> Result<String, String> {
|
|
658
|
+
pub async fn browser_subwindow_open(app: tauri::AppHandle, profile_id: Option<String>) -> Result<String, String> {
|
|
401
659
|
imp::open(app, profile_id.unwrap_or_else(|| "default".to_string()))
|
|
402
660
|
}
|
|
403
661
|
|
|
404
662
|
#[cfg(target_os = "macos")]
|
|
405
663
|
#[tauri::command]
|
|
406
|
-
pub fn browser_subwindow_switch_profile(app: tauri::AppHandle, id: String, profile_id: String) -> Result<(), String> {
|
|
664
|
+
pub async fn browser_subwindow_switch_profile(app: tauri::AppHandle, id: String, profile_id: String) -> Result<(), String> {
|
|
407
665
|
imp::switch_profile(app, id, profile_id)
|
|
408
666
|
}
|
|
409
667
|
|
|
410
668
|
#[cfg(target_os = "macos")]
|
|
411
669
|
#[tauri::command]
|
|
412
|
-
pub fn browser_tab_back(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
|
670
|
+
pub async fn browser_tab_back(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
|
413
671
|
imp::nav_op(app, id, "back")
|
|
414
672
|
}
|
|
415
673
|
|
|
416
674
|
#[cfg(target_os = "macos")]
|
|
417
675
|
#[tauri::command]
|
|
418
|
-
pub fn browser_tab_forward(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
|
676
|
+
pub async fn browser_tab_forward(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
|
419
677
|
imp::nav_op(app, id, "forward")
|
|
420
678
|
}
|
|
421
679
|
|
|
422
680
|
#[cfg(target_os = "macos")]
|
|
423
681
|
#[tauri::command]
|
|
424
|
-
pub fn browser_tab_reload(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
|
682
|
+
pub async fn browser_tab_reload(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
|
425
683
|
imp::nav_op(app, id, "reload")
|
|
426
684
|
}
|
|
427
685
|
|
|
428
686
|
#[cfg(target_os = "macos")]
|
|
429
687
|
#[tauri::command]
|
|
430
|
-
pub fn browser_tab_current_url(app: tauri::AppHandle, id: String) -> Result<String, String> {
|
|
688
|
+
pub async fn browser_tab_current_url(app: tauri::AppHandle, id: String) -> Result<String, String> {
|
|
431
689
|
imp::current_url(app, id)
|
|
432
690
|
}
|
|
433
691
|
|
|
434
692
|
#[cfg(target_os = "macos")]
|
|
435
693
|
#[tauri::command]
|
|
436
|
-
pub fn browser_list_profiles() -> Result<Vec<String>, String> {
|
|
694
|
+
pub async fn browser_list_profiles() -> Result<Vec<String>, String> {
|
|
437
695
|
Ok(webview_control::list_profiles())
|
|
438
696
|
}
|
|
439
697
|
|
|
@@ -443,3 +701,104 @@ pub fn browser_list_profiles() -> Result<Vec<String>, String> {
|
|
|
443
701
|
pub fn reregister_subwindows(app: &tauri::AppHandle) {
|
|
444
702
|
imp::reregister_all(app);
|
|
445
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
|
+
}
|