@livx.cc/appwrap 0.46.2 → 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.
- package/package.json +1 -1
- package/runtime/app/shell/custom-webview.android.ts +19 -2
- package/runtime-desktop/chrome/appwrap-browser-chrome.html +120 -0
- package/runtime-desktop/crates/webview-control/examples/host_harness.rs +97 -4
- package/runtime-desktop/crates/webview-control/src/embedded.rs +21 -0
- package/runtime-desktop/crates/webview-control/src/lib.rs +7 -3
- package/runtime-desktop/crates/webview-control/src/popup.rs +3 -0
- package/runtime-desktop/crates/webview-control/src/profiles.rs +12 -0
- package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +43 -0
- package/runtime-desktop/src-tauri/Cargo.toml +3 -1
- package/runtime-desktop/src-tauri/capabilities/default.json +1 -1
- package/runtime-desktop/src-tauri/src/bridge_mac.rs +53 -0
- package/runtime-desktop/src-tauri/src/browser_tab.rs +254 -2
- package/runtime-desktop/src-tauri/src/main.rs +82 -1
- package/src/cli.ts +10 -1
package/package.json
CHANGED
|
@@ -93,6 +93,15 @@ export class CustomWebView extends WebView {
|
|
|
93
93
|
/** Set by the bridge before load; receives raw envelope JSON. */
|
|
94
94
|
onAppwrapMessage: ((json: string) => void) | null = null;
|
|
95
95
|
|
|
96
|
+
// STRONG JS references to the native clients. Chromium holds the Java WebViewClient /
|
|
97
|
+
// WebChromeClient, but NativeScript's mark-and-sweep does NOT treat that native hold as a
|
|
98
|
+
// GC root — so without a JS-side reference the JS peer gets collected under memory pressure
|
|
99
|
+
// (e.g. heavy scrolling), and the next background-thread shouldInterceptRequest crashes with
|
|
100
|
+
// "Cannot find runtime for instance". Retaining here (mirrors @nativescript/core's
|
|
101
|
+
// `nativeView.client = client`) keeps the peer alive for the WebView's lifetime.
|
|
102
|
+
private webViewClient: android.webkit.WebViewClient | null = null;
|
|
103
|
+
private webChromeClient: android.webkit.WebChromeClient | null = null;
|
|
104
|
+
|
|
96
105
|
// Native-view → owning CustomWebView, keyed by the Java identity hash (stable per native
|
|
97
106
|
// object, robust to NS re-wrapping the arg passed into the ChromeClient callback). WeakRef
|
|
98
107
|
// so a destroyed view can be GC'd; entry pruned on dispose.
|
|
@@ -131,15 +140,23 @@ export class CustomWebView extends WebView {
|
|
|
131
140
|
wv.setOverScrollMode(android.view.View.OVER_SCROLL_NEVER);
|
|
132
141
|
|
|
133
142
|
this.installDocumentStartShim(wv);
|
|
134
|
-
|
|
143
|
+
// Retain both clients in long-lived JS fields (see field docs) so their JS peers survive GC
|
|
144
|
+
// for the WebView's lifetime — otherwise a background-thread shouldInterceptRequest can fire
|
|
145
|
+
// after the peer is collected and crash with "Cannot find runtime for instance".
|
|
146
|
+
this.webViewClient = createAssetServingClient();
|
|
147
|
+
wv.setWebViewClient(this.webViewClient);
|
|
135
148
|
// Bind THIS native view to THIS instance before wiring the (shared, extend-once) client.
|
|
136
149
|
CustomWebView.registry.set(wv.hashCode(), new WeakRef<CustomWebView>(this));
|
|
137
|
-
|
|
150
|
+
this.webChromeClient = new (getChromeClientClass())();
|
|
151
|
+
wv.setWebChromeClient(this.webChromeClient);
|
|
138
152
|
}
|
|
139
153
|
|
|
140
154
|
disposeNativeView(): void {
|
|
141
155
|
const wv = this.android as android.webkit.WebView | undefined;
|
|
142
156
|
if (wv) CustomWebView.registry.delete(wv.hashCode());
|
|
157
|
+
// Drop the retained clients now that the native view is going away, so their peers can be GC'd.
|
|
158
|
+
this.webViewClient = null;
|
|
159
|
+
this.webChromeClient = null;
|
|
143
160
|
super.disposeNativeView();
|
|
144
161
|
}
|
|
145
162
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Browser</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bar-h: 44px;
|
|
10
|
+
--bg: #f2f2f4; --fg: #1c1c1e; --border: #d0d0d5;
|
|
11
|
+
--field-bg: #ffffff; --field-border: #c4c4cc; --btn-hover: #e2e2e6;
|
|
12
|
+
}
|
|
13
|
+
@media (prefers-color-scheme: dark) {
|
|
14
|
+
:root {
|
|
15
|
+
--bg: #2a2a2e; --fg: #f2f2f4; --border: #444;
|
|
16
|
+
--field-bg: #1c1c1e; --field-border: #555; --btn-hover: #3a3a40;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
* { box-sizing: border-box; }
|
|
20
|
+
html, body { margin: 0; height: 100%; overflow: hidden;
|
|
21
|
+
font: 13px -apple-system, system-ui, sans-serif; color: var(--fg); }
|
|
22
|
+
/* The controlled WKWebView is a native subview covering everything below the bar; the body below
|
|
23
|
+
the bar is never seen — only this top strip renders. */
|
|
24
|
+
#bar {
|
|
25
|
+
display: flex; align-items: center; gap: 6px;
|
|
26
|
+
height: var(--bar-h); padding: 0 8px;
|
|
27
|
+
background: var(--bg); border-bottom: 1px solid var(--border);
|
|
28
|
+
-webkit-user-select: none; user-select: none;
|
|
29
|
+
}
|
|
30
|
+
button.nav {
|
|
31
|
+
width: 30px; height: 28px; flex: none;
|
|
32
|
+
border: none; border-radius: 6px; background: transparent; color: var(--fg);
|
|
33
|
+
font-size: 16px; line-height: 1; cursor: default;
|
|
34
|
+
}
|
|
35
|
+
button.nav:hover { background: var(--btn-hover); }
|
|
36
|
+
button.nav:active { background: var(--field-border); }
|
|
37
|
+
#addr {
|
|
38
|
+
flex: 1 1 auto; height: 28px; padding: 0 10px;
|
|
39
|
+
border: 1px solid var(--field-border); border-radius: 7px;
|
|
40
|
+
background: var(--field-bg); color: var(--fg); font-size: 13px; outline: none;
|
|
41
|
+
}
|
|
42
|
+
#addr:focus { border-color: #4c8bf5; }
|
|
43
|
+
#profile {
|
|
44
|
+
flex: none; height: 28px; max-width: 160px;
|
|
45
|
+
border: 1px solid var(--field-border); border-radius: 7px;
|
|
46
|
+
background: var(--field-bg); color: var(--fg); font-size: 12px; padding: 0 4px;
|
|
47
|
+
}
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<div id="bar">
|
|
52
|
+
<button class="nav" id="back" title="Back">‹</button>
|
|
53
|
+
<button class="nav" id="fwd" title="Forward">›</button>
|
|
54
|
+
<button class="nav" id="reload" title="Reload">↻</button>
|
|
55
|
+
<input id="addr" type="text" spellcheck="false" autocomplete="off"
|
|
56
|
+
placeholder="Enter address (e.g. 127.0.0.1:3000)" />
|
|
57
|
+
<select id="profile" title="Profile"></select>
|
|
58
|
+
</div>
|
|
59
|
+
<script>
|
|
60
|
+
(function () {
|
|
61
|
+
var id = new URLSearchParams(location.search).get('id') || '';
|
|
62
|
+
function invoke(cmd, args) {
|
|
63
|
+
var core = window.__TAURI__ && window.__TAURI__.core;
|
|
64
|
+
if (!core) return Promise.reject(new Error('Tauri core unavailable'));
|
|
65
|
+
return core.invoke(cmd, args || {});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
var addr = document.getElementById('addr');
|
|
69
|
+
var profileSel = document.getElementById('profile');
|
|
70
|
+
|
|
71
|
+
function normalizeUrl(raw) {
|
|
72
|
+
var s = (raw || '').trim();
|
|
73
|
+
if (!s) return '';
|
|
74
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s) || /^about:/i.test(s)) return s;
|
|
75
|
+
return 'https://' + s;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
document.getElementById('back').addEventListener('click', function () { invoke('browser_tab_back', { id: id }); });
|
|
79
|
+
document.getElementById('fwd').addEventListener('click', function () { invoke('browser_tab_forward', { id: id }); });
|
|
80
|
+
document.getElementById('reload').addEventListener('click', function () { invoke('browser_tab_reload', { id: id }); });
|
|
81
|
+
|
|
82
|
+
addr.addEventListener('keydown', function (e) {
|
|
83
|
+
if (e.key !== 'Enter') return;
|
|
84
|
+
var url = normalizeUrl(addr.value);
|
|
85
|
+
if (url) invoke('browser_tab_navigate', { id: id, url: url });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
profileSel.addEventListener('change', function () {
|
|
89
|
+
invoke('browser_subwindow_switch_profile', { id: id, profileId: profileSel.value });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Populate the profile dropdown.
|
|
93
|
+
invoke('browser_list_profiles').then(function (profiles) {
|
|
94
|
+
(profiles || ['default']).forEach(function (p) {
|
|
95
|
+
var opt = document.createElement('option');
|
|
96
|
+
opt.value = p; opt.textContent = p;
|
|
97
|
+
profileSel.appendChild(opt);
|
|
98
|
+
});
|
|
99
|
+
}).catch(function (e) { console.error('list profiles failed', e); });
|
|
100
|
+
|
|
101
|
+
// Keep the address bar in sync with the controlled webview (skip while the user is editing it).
|
|
102
|
+
var focused = false;
|
|
103
|
+
addr.addEventListener('focus', function () { focused = true; });
|
|
104
|
+
addr.addEventListener('blur', function () { focused = false; });
|
|
105
|
+
function poll() {
|
|
106
|
+
invoke('browser_tab_current_url', { id: id }).then(function (json) {
|
|
107
|
+
if (focused) return;
|
|
108
|
+
try {
|
|
109
|
+
var o = JSON.parse(json);
|
|
110
|
+
var href = o && o.href ? o.href : '';
|
|
111
|
+
if (href && href !== 'about:blank') addr.value = href;
|
|
112
|
+
} catch (_) {}
|
|
113
|
+
}).catch(function () {});
|
|
114
|
+
}
|
|
115
|
+
setInterval(poll, 1000);
|
|
116
|
+
poll();
|
|
117
|
+
})();
|
|
118
|
+
</script>
|
|
119
|
+
</body>
|
|
120
|
+
</html>
|
|
@@ -63,13 +63,21 @@ fn eval(ctrl: &Controller, mtm: MainThreadMarker, id: &str, js: &str) -> Result<
|
|
|
63
63
|
/// Start a throwaway loopback HTTP server; returns its `http://127.0.0.1:PORT/` URL. The page just
|
|
64
64
|
/// carries a stable title — the session marker is written via localStorage by the harness.
|
|
65
65
|
fn start_loopback() -> String {
|
|
66
|
+
start_loopback_titled("HOST-HARNESS-PAGE")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Start a throwaway loopback HTTP server carrying `title` as its `<title>`; returns its URL.
|
|
70
|
+
fn start_loopback_titled(title: &str) -> String {
|
|
66
71
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback");
|
|
67
72
|
let addr = listener.local_addr().unwrap();
|
|
73
|
+
let title = title.to_string();
|
|
68
74
|
std::thread::spawn(move || {
|
|
69
|
-
let body =
|
|
75
|
+
let body = format!(
|
|
76
|
+
"<!doctype html><html><head><title>{title}</title></head>\
|
|
70
77
|
<body style=\"margin:0;background:#0b5;color:#fff;font:700 40px system-ui;\
|
|
71
78
|
display:flex;align-items:center;justify-content:center;height:100vh\">\
|
|
72
|
-
<div id=\"tag\">HOST-HARNESS-LIVE</div></body></html>"
|
|
79
|
+
<div id=\"tag\">HOST-HARNESS-LIVE</div></body></html>"
|
|
80
|
+
);
|
|
73
81
|
for stream in listener.incoming() {
|
|
74
82
|
let Ok(mut s) = stream else { continue };
|
|
75
83
|
let mut buf = [0u8; 1024];
|
|
@@ -85,6 +93,35 @@ fn start_loopback() -> String {
|
|
|
85
93
|
format!("http://{addr}/")
|
|
86
94
|
}
|
|
87
95
|
|
|
96
|
+
/// Read `location.href` once (pumping until the eval completes).
|
|
97
|
+
fn href(ctrl: &Controller, mtm: MainThreadMarker, id: &str) -> String {
|
|
98
|
+
eval(ctrl, mtm, id, "return location.href;").unwrap_or_default()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// Issue a nav op (idempotent — navigate/go_back/go_forward/reload can all be safely re-issued) and
|
|
102
|
+
/// poll `location.href` until it contains `needle` or the deadline elapses. Re-issuing each iteration
|
|
103
|
+
/// makes it robust to a WKWebView dropping a single async load request. Returns the last href.
|
|
104
|
+
fn nav_settle(
|
|
105
|
+
ctrl: &Controller,
|
|
106
|
+
mtm: MainThreadMarker,
|
|
107
|
+
id: &str,
|
|
108
|
+
needle: &str,
|
|
109
|
+
secs: f64,
|
|
110
|
+
mut issue: impl FnMut(),
|
|
111
|
+
) -> String {
|
|
112
|
+
let deadline = Instant::now() + Duration::from_secs_f64(secs);
|
|
113
|
+
let mut last = String::new();
|
|
114
|
+
while Instant::now() < deadline {
|
|
115
|
+
issue();
|
|
116
|
+
pump(0.4);
|
|
117
|
+
last = href(ctrl, mtm, id);
|
|
118
|
+
if last.contains(needle) {
|
|
119
|
+
return last;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
last
|
|
123
|
+
}
|
|
124
|
+
|
|
88
125
|
fn main() {
|
|
89
126
|
let mtm = MainThreadMarker::new().expect("host_harness must run on the main thread");
|
|
90
127
|
|
|
@@ -211,6 +248,60 @@ fn main() {
|
|
|
211
248
|
println!("session survived (marker 77): {session_ok}");
|
|
212
249
|
println!("bridge survived (pings {:?}): {bridge_ok}", pings);
|
|
213
250
|
|
|
251
|
+
// ---- NAV ops (go_back / go_forward / reload via Controller) ----
|
|
252
|
+
// The tab is docked ChildOf(pane) on page 1. Navigate to a distinct page 2, then walk history.
|
|
253
|
+
let page1_href = href(&ctrl, mtm, id);
|
|
254
|
+
let page2_url = start_loopback_titled("HOST-HARNESS-PAGE-2");
|
|
255
|
+
println!("\n== NAV: navigate -> page2 {page2_url}");
|
|
256
|
+
let at2 = nav_settle(&ctrl, mtm, id, &page2_url, 10.0, || { let _ = ctrl.navigate(mtm, id, &page2_url); });
|
|
257
|
+
let nav_fwd_ok = at2.contains(&page2_url);
|
|
258
|
+
println!(" at page2: {at2:?} (ok={nav_fwd_ok})");
|
|
259
|
+
|
|
260
|
+
println!("== NAV: go_back -> page1");
|
|
261
|
+
let back1 = nav_settle(&ctrl, mtm, id, &page1_href, 10.0, || { let _ = ctrl.go_back(mtm, id); });
|
|
262
|
+
let nav_back_ok = back1 == page1_href && !page1_href.is_empty();
|
|
263
|
+
println!(" back at page1: {back1:?} (ok={nav_back_ok})");
|
|
264
|
+
|
|
265
|
+
println!("== NAV: go_forward -> page2");
|
|
266
|
+
let fwd2 = nav_settle(&ctrl, mtm, id, &page2_url, 10.0, || { let _ = ctrl.go_forward(mtm, id); });
|
|
267
|
+
let nav_fwd2_ok = fwd2.contains(&page2_url);
|
|
268
|
+
println!(" forward at page2: {fwd2:?} (ok={nav_fwd2_ok})");
|
|
269
|
+
|
|
270
|
+
println!("== NAV: reload (still page2, title present)");
|
|
271
|
+
let reloaded = nav_settle(&ctrl, mtm, id, &page2_url, 10.0, || { let _ = ctrl.reload(mtm, id); });
|
|
272
|
+
let title2 = eval(&ctrl, mtm, id, "return document.title;").unwrap_or_default();
|
|
273
|
+
let reload_ok = reloaded.contains(&page2_url) && title2.contains("HOST-HARNESS-PAGE-2");
|
|
274
|
+
println!(" after reload: href={reloaded:?} title={title2:?} (ok={reload_ok})");
|
|
275
|
+
let nav_ok = nav_fwd_ok && nav_back_ok && nav_fwd2_ok && reload_ok;
|
|
276
|
+
|
|
277
|
+
// ---- PROFILE SWAP: a second webview on a DIFFERENT profile → different store uuid ----
|
|
278
|
+
let first_store = created.as_ref().map(|c| c.store_uuid.clone()).unwrap_or_default();
|
|
279
|
+
let id_b = "harness-tab-b";
|
|
280
|
+
println!("\n== PROFILE SWAP: create id={id_b} profile=harnessB ChildOf(same pane)");
|
|
281
|
+
let created_b = ctrl.create_webview(
|
|
282
|
+
mtm,
|
|
283
|
+
CreateSpec {
|
|
284
|
+
label: id_b.to_string(),
|
|
285
|
+
url: page_url.clone(),
|
|
286
|
+
profile_id: "harnessB".to_string(),
|
|
287
|
+
init_js: String::new(),
|
|
288
|
+
mount: Mount::ChildOf(pane_handle),
|
|
289
|
+
},
|
|
290
|
+
Box::new(|_raw| {}),
|
|
291
|
+
);
|
|
292
|
+
let store_b = match &created_b {
|
|
293
|
+
Ok(c) => { println!(" harnessB store={}", c.store_uuid); c.store_uuid.clone() }
|
|
294
|
+
Err(e) => { eprintln!("FAIL: create harnessB: {e}"); std::process::exit(1); }
|
|
295
|
+
};
|
|
296
|
+
let profile_swap_ok = !first_store.is_empty() && store_b != first_store;
|
|
297
|
+
println!(" store isolation: harness={first_store} harnessB={store_b} (differ={profile_swap_ok})");
|
|
298
|
+
let _ = ctrl.close(mtm, id_b);
|
|
299
|
+
pump(0.3);
|
|
300
|
+
|
|
301
|
+
println!("\n---- nav + profile-swap assertions ----");
|
|
302
|
+
println!("nav ops (fwd/back/fwd/reload): {nav_ok}");
|
|
303
|
+
println!("profile swap (distinct store uuid): {profile_swap_ok}");
|
|
304
|
+
|
|
214
305
|
if std::env::args().any(|a| a == "--hold") {
|
|
215
306
|
println!("(--hold) leaving windows up 20s for inspection…");
|
|
216
307
|
pump(20.0);
|
|
@@ -230,9 +321,11 @@ fn main() {
|
|
|
230
321
|
|
|
231
322
|
println!("pane empty after close (no zombie webview): {pane_empty_ok}");
|
|
232
323
|
|
|
233
|
-
if page_ok && session_ok && bridge_ok && pane_empty_ok {
|
|
234
|
-
println!("\nPASS: live webview reparented child->toplevel->child via EMBEDDED API; page+session+bridge intact; host pane empty after close.");
|
|
324
|
+
if page_ok && session_ok && bridge_ok && pane_empty_ok && nav_ok && profile_swap_ok {
|
|
325
|
+
println!("\nPASS: live webview reparented child->toplevel->child via EMBEDDED API; page+session+bridge intact; nav ops (back/forward/reload) + profile-swap (distinct store) verified; host pane empty after close.");
|
|
235
326
|
} else {
|
|
327
|
+
if !nav_ok { eprintln!("\nFAIL: nav ops (go_back/go_forward/reload) did not land as expected."); }
|
|
328
|
+
if !profile_swap_ok { eprintln!("\nFAIL: profile swap did not yield a distinct store uuid."); }
|
|
236
329
|
if !pane_empty_ok {
|
|
237
330
|
eprintln!(
|
|
238
331
|
"\nFAIL: ZOMBIE WEBVIEW — host pane still has {after_close} subview(s) after close(); \
|
|
@@ -49,6 +49,27 @@ impl Controller {
|
|
|
49
49
|
unsafe { backend::navigate(Retained::as_ptr(&wk) as *mut WKWebView, url) }
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/// Navigate back in webview `id`'s history. Err if `id` is unknown/closed.
|
|
53
|
+
pub fn go_back(&self, _mtm: MainThreadMarker, id: &str) -> Result<(), String> {
|
|
54
|
+
let wk = self.webview(id)?;
|
|
55
|
+
unsafe { backend::go_back(Retained::as_ptr(&wk) as *mut WKWebView) };
|
|
56
|
+
Ok(())
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Navigate forward in webview `id`'s history. Err if `id` is unknown/closed.
|
|
60
|
+
pub fn go_forward(&self, _mtm: MainThreadMarker, id: &str) -> Result<(), String> {
|
|
61
|
+
let wk = self.webview(id)?;
|
|
62
|
+
unsafe { backend::go_forward(Retained::as_ptr(&wk) as *mut WKWebView) };
|
|
63
|
+
Ok(())
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Reload webview `id`. Err if `id` is unknown/closed.
|
|
67
|
+
pub fn reload(&self, _mtm: MainThreadMarker, id: &str) -> Result<(), String> {
|
|
68
|
+
let wk = self.webview(id)?;
|
|
69
|
+
unsafe { backend::reload(Retained::as_ptr(&wk) as *mut WKWebView) };
|
|
70
|
+
Ok(())
|
|
71
|
+
}
|
|
72
|
+
|
|
52
73
|
/// Fire-and-forget JS injection into webview `id`. Err if `id` is unknown/closed.
|
|
53
74
|
pub fn inject_script(&self, _mtm: MainThreadMarker, id: &str, js: &str) -> Result<(), String> {
|
|
54
75
|
let wk = self.webview(id)?;
|
|
@@ -89,7 +89,11 @@ pub enum Mount {
|
|
|
89
89
|
// See ARCHITECTURE.md for the full contract.
|
|
90
90
|
#[cfg(target_os = "macos")]
|
|
91
91
|
pub use wkwebview_backend::{
|
|
92
|
-
close, create_profile_window, create_webview, eval_async,
|
|
93
|
-
install_message_handler, list_webviews, navigate, profile_webview,
|
|
94
|
-
user_close, CreateResult, CreateSpec, MessageSink,
|
|
92
|
+
close, create_profile_window, create_webview, devtools_enabled, eval_async, go_back, go_forward,
|
|
93
|
+
inject_script, install_message_handler, list_webviews, navigate, profile_webview, reload,
|
|
94
|
+
set_inspectable, set_mount, snapshot, user_close, CreateResult, CreateSpec, MessageSink,
|
|
95
|
+
WebviewInfo,
|
|
95
96
|
};
|
|
97
|
+
|
|
98
|
+
#[cfg(target_os = "macos")]
|
|
99
|
+
pub use profiles::list as list_profiles;
|
|
@@ -278,6 +278,9 @@ define_class!(
|
|
|
278
278
|
};
|
|
279
279
|
unsafe {
|
|
280
280
|
child.setCustomUserAgent(Some(&NSString::from_str(SAFARI_UA)));
|
|
281
|
+
// Safari Web Inspector on window.open() child popups too (SSO flows etc.) —
|
|
282
|
+
// gated identically (debug always; release only under APPWRAP_DEVTOOLS).
|
|
283
|
+
crate::wkwebview_backend::set_inspectable(Retained::as_ptr(&child) as *mut WKWebView);
|
|
281
284
|
// Route the child's own UI-delegate messages (window.close(), nested
|
|
282
285
|
// window.open) back through us.
|
|
283
286
|
child.setUIDelegate(Some(ProtocolObject::from_ref(self)));
|
|
@@ -27,6 +27,18 @@ fn load() -> HashMap<String, String> {
|
|
|
27
27
|
.unwrap_or_default()
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/// Sorted list of known profile ids from the on-disk map (the dropdown source). Always includes
|
|
31
|
+
/// "default" so the chrome always has at least one selectable profile, even on a fresh install.
|
|
32
|
+
pub fn list() -> Vec<String> {
|
|
33
|
+
let mut ids: Vec<String> = load().into_keys().collect();
|
|
34
|
+
if !ids.iter().any(|k| k == "default") {
|
|
35
|
+
ids.push("default".to_string());
|
|
36
|
+
}
|
|
37
|
+
ids.sort();
|
|
38
|
+
ids.dedup();
|
|
39
|
+
ids
|
|
40
|
+
}
|
|
41
|
+
|
|
30
42
|
/// Stable UUID string for a profile id — generated once and persisted so the
|
|
31
43
|
/// same on-disk data store is reattached on every subsequent launch.
|
|
32
44
|
pub fn uuid_for(profile_id: &str) -> String {
|
|
@@ -28,6 +28,31 @@ use crate::popup;
|
|
|
28
28
|
use crate::profiles;
|
|
29
29
|
use crate::Mount;
|
|
30
30
|
|
|
31
|
+
/// True when webviews should be Safari-inspectable (Develop ▸ <app> ▸ Web Inspector). Debug builds
|
|
32
|
+
/// are ALWAYS inspectable; a release build is inspectable ONLY when `APPWRAP_DEVTOOLS` is set to
|
|
33
|
+
/// `1`/`true` — so production apps aren't inspectable by default. Self-contained in this crate (no
|
|
34
|
+
/// shell-config dependency) so every webview creation site can gate uniformly.
|
|
35
|
+
pub fn devtools_enabled() -> bool {
|
|
36
|
+
cfg!(debug_assertions)
|
|
37
|
+
|| matches!(std::env::var("APPWRAP_DEVTOOLS").ok().as_deref(), Some("1") | Some("true"))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Flip a WKWebView's `inspectable` property ON when [`devtools_enabled`], making it attachable from
|
|
41
|
+
/// Safari → Develop. The property lands in macOS 13.3+, so it's guarded by `respondsToSelector:` and
|
|
42
|
+
/// no-ops on older systems. Set right after webview creation, on the main thread. `wk` must be a live
|
|
43
|
+
/// `*mut WKWebView`.
|
|
44
|
+
pub unsafe fn set_inspectable(wk: *mut WKWebView) {
|
|
45
|
+
if wk.is_null() || !devtools_enabled() {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
let wk = &*wk;
|
|
49
|
+
let sel = objc2::sel!(setInspectable:);
|
|
50
|
+
let responds: bool = msg_send![wk, respondsToSelector: sel];
|
|
51
|
+
if responds {
|
|
52
|
+
let _: () = msg_send![wk, setInspectable: true];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
31
56
|
/// Autoresizing mask that pins a mounted webview to fill its superview on resize — the same
|
|
32
57
|
/// width+height-sizable mask popup windows use, applied on every (re)mount.
|
|
33
58
|
const FILL_MASK: NSAutoresizingMaskOptions =
|
|
@@ -316,6 +341,9 @@ pub fn create_webview(
|
|
|
316
341
|
unsafe {
|
|
317
342
|
webview.setCustomUserAgent(Some(&NSString::from_str(popup::SAFARI_UA)));
|
|
318
343
|
window.setTitle(&NSString::from_str(label));
|
|
344
|
+
// Safari Web Inspector (debug always; release only under APPWRAP_DEVTOOLS) for every
|
|
345
|
+
// bridge/profile/sub-window webview created through this primitive.
|
|
346
|
+
set_inspectable(Retained::as_ptr(&webview) as *mut WKWebView);
|
|
319
347
|
}
|
|
320
348
|
|
|
321
349
|
// Per-window SSO-popup delegate + window-close delegate. Both are weak refs on AppKit/WebKit →
|
|
@@ -424,6 +452,21 @@ pub unsafe fn navigate(wk: *mut WKWebView, url: &str) -> Result<(), String> {
|
|
|
424
452
|
}
|
|
425
453
|
}
|
|
426
454
|
|
|
455
|
+
/// Navigate back in the WKWebView's session history (no-op if none). Main-thread only.
|
|
456
|
+
pub unsafe fn go_back(wk: *mut WKWebView) {
|
|
457
|
+
let _ = (&*wk).goBack();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/// Navigate forward in the WKWebView's session history (no-op if none). Main-thread only.
|
|
461
|
+
pub unsafe fn go_forward(wk: *mut WKWebView) {
|
|
462
|
+
let _ = (&*wk).goForward();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/// Reload the current page. Main-thread only.
|
|
466
|
+
pub unsafe fn reload(wk: *mut WKWebView) {
|
|
467
|
+
let _ = (&*wk).reload();
|
|
468
|
+
}
|
|
469
|
+
|
|
427
470
|
/// Fire-and-forget JS injection. Main-thread only.
|
|
428
471
|
pub unsafe fn inject_script(wk: *mut WKWebView, js: &str) {
|
|
429
472
|
let wk = &*wk;
|
|
@@ -7,7 +7,9 @@ edition = "2021"
|
|
|
7
7
|
tauri-build = { version = "2", features = [] }
|
|
8
8
|
|
|
9
9
|
[dependencies]
|
|
10
|
-
|
|
10
|
+
# `devtools` feature so `open_devtools()`/`close_devtools()` compile in RELEASE too (in debug they're
|
|
11
|
+
# always available); the ⌥⌘I toggle is still gated at runtime by webview_control::devtools_enabled().
|
|
12
|
+
tauri = { version = "2", features = ["devtools"] }
|
|
11
13
|
tauri-plugin-deep-link = "2"
|
|
12
14
|
tauri-plugin-window-state = "2"
|
|
13
15
|
tauri-plugin-notification = "2"
|
|
@@ -14,6 +14,7 @@ use serde_json::{json, Value};
|
|
|
14
14
|
use std::io::{Read, Write};
|
|
15
15
|
use std::net::TcpStream;
|
|
16
16
|
use std::sync::mpsc::{channel, Sender};
|
|
17
|
+
use std::sync::Mutex;
|
|
17
18
|
use std::time::Duration;
|
|
18
19
|
use tauri::{AppHandle, Manager};
|
|
19
20
|
|
|
@@ -37,6 +38,50 @@ fn page_sink(tx: Sender<String>) -> wv::MessageSink {
|
|
|
37
38
|
fn page_port() -> u16 {
|
|
38
39
|
std::env::var("APPWRAP_BRIDGE_PAGE_PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(9236)
|
|
39
40
|
}
|
|
41
|
+
|
|
42
|
+
/// The live control-channel page->shim sender, set for the duration of a connected `run()`. Lets a
|
|
43
|
+
/// webview created OUTSIDE the createWindow op (e.g. a browser sub-window in browser_tab.rs) route
|
|
44
|
+
/// its page beacons to the SAME shim, so it registers as a bod-browser tab. None when disconnected.
|
|
45
|
+
static PAGE_TX: Mutex<Option<Sender<String>>> = Mutex::new(None);
|
|
46
|
+
|
|
47
|
+
/// Clears PAGE_TX on drop, so every `run()` return path (normal or error) leaves it None.
|
|
48
|
+
struct PageTxGuard;
|
|
49
|
+
impl Drop for PageTxGuard {
|
|
50
|
+
fn drop(&mut self) {
|
|
51
|
+
*PAGE_TX.lock().unwrap() = None;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Page->shim sink for a webview created outside `createWindow` (browser sub-windows). Unlike a
|
|
56
|
+
/// snapshot of the CURRENT PAGE_TX, this sink reads the LIVE control connection at send-time: a
|
|
57
|
+
/// sub-window opened while the shim is disconnected (PAGE_TX == None) installs this ONE sink and its
|
|
58
|
+
/// beacons are dropped for now, but the moment a later `run()` publishes a PAGE_TX the SAME installed
|
|
59
|
+
/// sink starts delivering — so the window SELF-HEALS into agent-exposure on shim (re)connect, with no
|
|
60
|
+
/// re-injection of the message handler. Framing stays identical to `page_sink`.
|
|
61
|
+
pub fn bridge_sink() -> wv::MessageSink {
|
|
62
|
+
Box::new(move |raw: String| {
|
|
63
|
+
if let Some(tx) = PAGE_TX.lock().unwrap().as_ref() {
|
|
64
|
+
let _ = tx.send(json!({ "type": "pageMsg", "payload": raw }).to_string());
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Build the SAME framed init-JS `createWindow` injects, for a webview created elsewhere. Reads the
|
|
70
|
+
/// content bundle from `APPWRAP_BRIDGE_JS`; None when the feature is off (unset/empty) or the read
|
|
71
|
+
/// fails (logged). `label` becomes `window.__BRIDGE_LABEL` so the shim registers this webview by id.
|
|
72
|
+
pub fn bridge_init_js(label: &str) -> Option<String> {
|
|
73
|
+
let path = match std::env::var("APPWRAP_BRIDGE_JS") {
|
|
74
|
+
Ok(p) if !p.is_empty() => p,
|
|
75
|
+
_ => return None,
|
|
76
|
+
};
|
|
77
|
+
match std::fs::read_to_string(&path) {
|
|
78
|
+
Ok(bundle) => Some(format!(
|
|
79
|
+
"window.__BRIDGE_LABEL={};window.__BRIDGE_PORT={};\n{}",
|
|
80
|
+
serde_json::to_string(&label).unwrap(), page_port(), bundle
|
|
81
|
+
)),
|
|
82
|
+
Err(e) => { eprintln!("[bridge-shim] bridge_init_js read {path}: {e}"); None }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
40
85
|
fn rust_port() -> u16 {
|
|
41
86
|
std::env::var("APPWRAP_BRIDGE_RUST_PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(9237)
|
|
42
87
|
}
|
|
@@ -62,6 +107,14 @@ pub fn init(app: AppHandle) {
|
|
|
62
107
|
fn run(app: &AppHandle, mut stream: TcpStream, js_path: &str) {
|
|
63
108
|
let _ = stream.set_read_timeout(Some(Duration::from_millis(50)));
|
|
64
109
|
let (tx, rx) = channel::<String>();
|
|
110
|
+
// Publish this connection's sender so sub-window webviews (created outside createWindow) can route
|
|
111
|
+
// their page beacons here; the guard clears it on ANY return below (disconnect/error).
|
|
112
|
+
*PAGE_TX.lock().unwrap() = Some(tx.clone());
|
|
113
|
+
let _tx_guard = PageTxGuard;
|
|
114
|
+
// Self-heal sub-windows opened BEFORE this connect: their dynamic sink can now deliver, but a
|
|
115
|
+
// document already loaded won't re-fire its `register` beacon on its own. Nudge every open
|
|
116
|
+
// sub-window webview to re-post `register` so it (re)appears as a bod-browser tab on this shim.
|
|
117
|
+
crate::browser_tab::reregister_subwindows(app);
|
|
65
118
|
let mut acc = String::new();
|
|
66
119
|
let mut buf = [0u8; 65536];
|
|
67
120
|
loop {
|
|
@@ -13,19 +13,39 @@
|
|
|
13
13
|
mod imp {
|
|
14
14
|
use std::collections::HashMap;
|
|
15
15
|
use std::ffi::c_void;
|
|
16
|
+
use std::sync::mpsc::channel;
|
|
16
17
|
use std::sync::{Mutex, OnceLock};
|
|
18
|
+
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
17
19
|
|
|
18
20
|
use objc2::rc::Retained;
|
|
19
21
|
use objc2::{MainThreadMarker, MainThreadOnly};
|
|
20
|
-
use objc2_app_kit::{NSView, NSWindow};
|
|
22
|
+
use objc2_app_kit::{NSAutoresizingMaskOptions, NSView, NSWindow};
|
|
21
23
|
use objc2_foundation::{NSPoint, NSRect, NSSize};
|
|
22
24
|
use objc2_web_kit::WKWebView;
|
|
23
25
|
use serde::Deserialize;
|
|
24
|
-
use tauri::Manager;
|
|
26
|
+
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
|
|
25
27
|
|
|
26
28
|
use webview_control::embedded::Controller;
|
|
27
29
|
use webview_control::{CreateSpec, Mount, ViewHandle};
|
|
28
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
|
+
|
|
29
49
|
/// Web-DOM pane rect the front-end reserves (top-left origin, CSS px == points here since the
|
|
30
50
|
/// shell runs at the window's backing scale). We flip Y to AppKit's bottom-left origin at mount.
|
|
31
51
|
#[derive(Deserialize, Clone, Copy)]
|
|
@@ -160,6 +180,189 @@ mod imp {
|
|
|
160
180
|
.map_err(|e| e.to_string())?;
|
|
161
181
|
rx.recv().map_err(|e| e.to_string())?
|
|
162
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
|
+
}
|
|
163
366
|
}
|
|
164
367
|
|
|
165
368
|
#[cfg(target_os = "macos")]
|
|
@@ -191,3 +394,52 @@ pub fn browser_tab_navigate(app: tauri::AppHandle, id: String, url: String) -> R
|
|
|
191
394
|
pub fn browser_tab_close(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
|
192
395
|
imp::close(app, id)
|
|
193
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
|
+
}
|
|
@@ -695,6 +695,13 @@ fn main() {
|
|
|
695
695
|
browser_tab::browser_tab_set_mount,
|
|
696
696
|
browser_tab::browser_tab_navigate,
|
|
697
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,
|
|
698
705
|
])
|
|
699
706
|
// Popups are plain NSWindows tauri doesn't know about; with the main (only tauri) window
|
|
700
707
|
// closed, tauri exits the app — but tear popups down explicitly so no dangling windows or
|
|
@@ -714,6 +721,75 @@ fn main() {
|
|
|
714
721
|
.setup(|app| {
|
|
715
722
|
// Stash the app handle so off-main-thread handlers can reach plugin state (notifications).
|
|
716
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
|
+
}
|
|
717
793
|
// SPIKE: browser-bridge window-ops backend — inert unless APPWRAP_BRIDGE_JS is set.
|
|
718
794
|
#[cfg(target_os = "macos")]
|
|
719
795
|
bridge_mac::init(app.handle().clone());
|
|
@@ -755,7 +831,12 @@ fn main() {
|
|
|
755
831
|
#[cfg(target_os = "macos")]
|
|
756
832
|
if let Some(window) = app.get_webview_window("main") {
|
|
757
833
|
let picker = shell().google_account_picker;
|
|
758
|
-
window.with_webview(move |w|
|
|
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
|
+
})?;
|
|
759
840
|
}
|
|
760
841
|
// Reveal after the state plugin's on_window_ready restore has applied (built hidden above).
|
|
761
842
|
if restore {
|
package/src/cli.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { execFileSync, spawn } from 'child_process';
|
|
12
12
|
import { createHash } from 'crypto';
|
|
13
|
-
import { cpSync, existsSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync, writeSync } from 'fs';
|
|
13
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync, writeSync } from 'fs';
|
|
14
14
|
import { networkInterfaces, tmpdir } from 'os';
|
|
15
15
|
import { delimiter as pathDelimiter, dirname, join, resolve } from 'path';
|
|
16
16
|
import { pathToFileURL } from 'url';
|
|
@@ -2052,6 +2052,15 @@ function regenerateDesktop(cwd: string, cfg: AppwrapConfig, outDir: string, push
|
|
|
2052
2052
|
cpSync(dist, stagedDist, { recursive: true, force: true });
|
|
2053
2053
|
}
|
|
2054
2054
|
|
|
2055
|
+
// Stage the app-owned browser sub-window chrome (‹ › ⟳ + address bar + profile▾) INTO the dist so
|
|
2056
|
+
// `WebviewUrl::App("appwrap-browser-chrome.html")` resolves it (browser_tab::browser_subwindow_open).
|
|
2057
|
+
const chromeSrc = join(DESKTOP_TEMPLATE_DIR, 'chrome', 'appwrap-browser-chrome.html');
|
|
2058
|
+
if (existsSync(chromeSrc)) {
|
|
2059
|
+
copyFileSync(chromeSrc, join(stagedDist, 'appwrap-browser-chrome.html'));
|
|
2060
|
+
} else {
|
|
2061
|
+
console.warn(`⚠ browser chrome not found at ${chromeSrc} — sub-window chrome will 404.`);
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2055
2064
|
// Local-server mode: resolve `command` (bare name → PATH lookup) and `cwd` to ABSOLUTE, and stamp
|
|
2056
2065
|
// the build-time PATH — a GUI-launched .app inherits the minimal launchd PATH (no ~/.bun/bin), so
|
|
2057
2066
|
// a bare-name spawn (or a child that itself shells out to `bun`/`node`) would fail there.
|