@livx.cc/appwrap 0.43.0 → 0.46.2
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 +1 -0
- package/runtime-desktop/bridge-shim/build.sh +9 -0
- package/runtime-desktop/bridge-shim/content-entry.ts +87 -0
- package/runtime-desktop/bridge-shim/shim.ts +132 -0
- package/runtime-desktop/crates/webview-control/Cargo.lock +330 -0
- package/runtime-desktop/crates/webview-control/Cargo.toml +28 -0
- package/runtime-desktop/crates/webview-control/examples/host_harness.rs +245 -0
- package/runtime-desktop/crates/webview-control/src/embedded.rs +100 -0
- package/runtime-desktop/crates/webview-control/src/lib.rs +95 -0
- package/runtime-desktop/crates/webview-control/src/popup.rs +519 -0
- package/runtime-desktop/crates/webview-control/src/profiles.rs +45 -0
- package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +497 -0
- package/runtime-desktop/src-tauri/Cargo.lock +15 -0
- package/runtime-desktop/src-tauri/Cargo.toml +5 -0
- package/runtime-desktop/src-tauri/src/bridge_mac.rs +306 -0
- package/runtime-desktop/src-tauri/src/browser_tab.rs +193 -0
- package/runtime-desktop/src-tauri/src/main.rs +206 -3
- package/runtime-desktop/src-tauri/src/popup_mac.rs +5 -466
- package/runtime-desktop/src-tauri/src/server.rs +156 -0
- package/runtime-desktop/src-tauri/tauri.conf.json +1 -0
- package/src/cli.ts +86 -13
- package/src/config.ts +46 -1
- package/src/desktop.ts +48 -1
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// Control-channel ADAPTER for the browser-bridge shim (bridge-shim/shim.ts).
|
|
2
|
+
//
|
|
3
|
+
// Phase 1 extraction: the WKWebView primitive (per-profile persistent+isolated stores, manual
|
|
4
|
+
// window creation, CSP-immune eval, snapshot, the bridgeShim handler, SSO popups) now lives in the
|
|
5
|
+
// `webview-control` crate. This file is the thin transport adapter that OWNS the bod-browser wire
|
|
6
|
+
// format + the TCP control channel:
|
|
7
|
+
// - dials the shim's TCP control port (line-JSON) and serves window ops,
|
|
8
|
+
// - frames the crate's RAW page payloads into {type:"pageMsg", payload} lines,
|
|
9
|
+
// - frames RAW eval/snapshot results into {id,result}/{id,error} responses,
|
|
10
|
+
// - keeps the legacy tauri-window path (no profileId) for back-compat.
|
|
11
|
+
// Env-gated: fully inert unless APPWRAP_BRIDGE_JS points at the built content bundle.
|
|
12
|
+
use base64::Engine;
|
|
13
|
+
use serde_json::{json, Value};
|
|
14
|
+
use std::io::{Read, Write};
|
|
15
|
+
use std::net::TcpStream;
|
|
16
|
+
use std::sync::mpsc::{channel, Sender};
|
|
17
|
+
use std::time::Duration;
|
|
18
|
+
use tauri::{AppHandle, Manager};
|
|
19
|
+
|
|
20
|
+
use objc2::rc::Retained;
|
|
21
|
+
use objc2::MainThreadMarker;
|
|
22
|
+
use objc2_web_kit::WKWebView;
|
|
23
|
+
|
|
24
|
+
// The transport-free primitive. This adapter is the ONLY place that knows the bod-browser wire
|
|
25
|
+
// format; the crate stays automation-agnostic.
|
|
26
|
+
use webview_control as wv;
|
|
27
|
+
|
|
28
|
+
/// Build the page->shim message sink for one webview: wrap the crate's RAW payload into the
|
|
29
|
+
/// {type:"pageMsg", payload} envelope the shim expects. This is the seam — the framing lives here,
|
|
30
|
+
/// never in the crate.
|
|
31
|
+
fn page_sink(tx: Sender<String>) -> wv::MessageSink {
|
|
32
|
+
Box::new(move |raw: String| {
|
|
33
|
+
let _ = tx.send(json!({ "type": "pageMsg", "payload": raw }).to_string());
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fn page_port() -> u16 {
|
|
38
|
+
std::env::var("APPWRAP_BRIDGE_PAGE_PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(9236)
|
|
39
|
+
}
|
|
40
|
+
fn rust_port() -> u16 {
|
|
41
|
+
std::env::var("APPWRAP_BRIDGE_RUST_PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(9237)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
pub fn init(app: AppHandle) {
|
|
45
|
+
let js_path = match std::env::var("APPWRAP_BRIDGE_JS") {
|
|
46
|
+
Ok(p) if !p.is_empty() => p,
|
|
47
|
+
_ => return, // feature off
|
|
48
|
+
};
|
|
49
|
+
std::thread::spawn(move || loop {
|
|
50
|
+
match TcpStream::connect(("127.0.0.1", rust_port())) {
|
|
51
|
+
Ok(stream) => {
|
|
52
|
+
eprintln!("[bridge-shim] control connected");
|
|
53
|
+
run(&app, stream, &js_path);
|
|
54
|
+
eprintln!("[bridge-shim] control disconnected");
|
|
55
|
+
}
|
|
56
|
+
Err(_) => {}
|
|
57
|
+
}
|
|
58
|
+
std::thread::sleep(Duration::from_secs(1));
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
fn run(app: &AppHandle, mut stream: TcpStream, js_path: &str) {
|
|
63
|
+
let _ = stream.set_read_timeout(Some(Duration::from_millis(50)));
|
|
64
|
+
let (tx, rx) = channel::<String>();
|
|
65
|
+
let mut acc = String::new();
|
|
66
|
+
let mut buf = [0u8; 65536];
|
|
67
|
+
loop {
|
|
68
|
+
while let Ok(line) = rx.try_recv() {
|
|
69
|
+
if stream.write_all(line.as_bytes()).and_then(|_| stream.write_all(b"\n")).is_err() {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
match stream.read(&mut buf) {
|
|
74
|
+
Ok(0) => return,
|
|
75
|
+
Ok(n) => {
|
|
76
|
+
acc.push_str(&String::from_utf8_lossy(&buf[..n]));
|
|
77
|
+
while let Some(idx) = acc.find('\n') {
|
|
78
|
+
let line = acc[..idx].trim().to_string();
|
|
79
|
+
acc.drain(..=idx);
|
|
80
|
+
if line.is_empty() { continue; }
|
|
81
|
+
match serde_json::from_str::<Value>(&line) {
|
|
82
|
+
Ok(v) => handle_op(app, &tx, v, js_path),
|
|
83
|
+
Err(e) => eprintln!("[bridge-shim] bad line: {e}"),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {}
|
|
88
|
+
Err(_) => return,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
fn respond(tx: &Sender<String>, id: &str, result: Result<Value, String>) {
|
|
94
|
+
let msg = match result {
|
|
95
|
+
Ok(r) => json!({ "id": id, "result": r }),
|
|
96
|
+
Err(e) => json!({ "id": id, "error": e }),
|
|
97
|
+
};
|
|
98
|
+
let _ = tx.send(msg.to_string());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
fn handle_op(app: &AppHandle, tx: &Sender<String>, v: Value, js_path: &str) {
|
|
102
|
+
let id = v["id"].as_str().unwrap_or_default().to_string();
|
|
103
|
+
let op = v["op"].as_str().unwrap_or_default().to_string();
|
|
104
|
+
eprintln!("[bridge-shim] op={op} id={id}");
|
|
105
|
+
let app2 = app.clone();
|
|
106
|
+
let tx2 = tx.clone();
|
|
107
|
+
match op.as_str() {
|
|
108
|
+
"createWindow" => {
|
|
109
|
+
let label = v["label"].as_str().unwrap_or_default().to_string();
|
|
110
|
+
let url = v["url"].as_str().unwrap_or("about:blank").to_string();
|
|
111
|
+
let profile_id = v["profileId"].as_str().unwrap_or_default().to_string();
|
|
112
|
+
// Re-read the bundle each time — a rebuilt content.js applies to the next window (dev loop).
|
|
113
|
+
let init_js = match std::fs::read_to_string(js_path) {
|
|
114
|
+
Ok(bundle) => format!(
|
|
115
|
+
"window.__BRIDGE_LABEL={};window.__BRIDGE_PORT={};\n{}",
|
|
116
|
+
serde_json::to_string(&label).unwrap(), page_port(), bundle
|
|
117
|
+
),
|
|
118
|
+
Err(e) => { respond(tx, &id, Err(format!("read bundle {js_path}: {e}"))); return; }
|
|
119
|
+
};
|
|
120
|
+
// Profile windows take the crate's MANUAL creation path (per-profile persistent store).
|
|
121
|
+
// Windows without a profileId keep the original tauri path (back-compat).
|
|
122
|
+
if !profile_id.is_empty() {
|
|
123
|
+
let idc = id.clone();
|
|
124
|
+
let init_js2 = init_js.clone();
|
|
125
|
+
let r = app.run_on_main_thread(move || {
|
|
126
|
+
let mtm = MainThreadMarker::new().expect("run_on_main_thread is main");
|
|
127
|
+
let res = wv::create_profile_window(mtm, &label, &url, &profile_id, &init_js2, page_sink(tx2.clone()))
|
|
128
|
+
.map(|c| json!({ "ok": true, "label": label, "profileId": profile_id, "storeUuid": c.store_uuid, "persistent": c.persistent }));
|
|
129
|
+
respond(&tx2, &idc, res);
|
|
130
|
+
});
|
|
131
|
+
if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
let idc = id.clone();
|
|
135
|
+
let r = app.run_on_main_thread(move || {
|
|
136
|
+
let parsed = match tauri::Url::parse(&url) {
|
|
137
|
+
Ok(u) => u,
|
|
138
|
+
Err(e) => { respond(&tx2, &idc, Err(format!("bad url: {e}"))); return; }
|
|
139
|
+
};
|
|
140
|
+
let r = tauri::WebviewWindowBuilder::new(&app2, &label, tauri::WebviewUrl::External(parsed))
|
|
141
|
+
.title(&label)
|
|
142
|
+
.inner_size(1100.0, 800.0)
|
|
143
|
+
// Safari-normalized UA at BUILD time (same constraint as the main window).
|
|
144
|
+
.user_agent(crate::popup_mac::SAFARI_UA)
|
|
145
|
+
.initialization_script(&init_js)
|
|
146
|
+
.build();
|
|
147
|
+
// Install the page->shim message handler on the fresh webview (main thread), plus the
|
|
148
|
+
// SAME popup machinery the main window gets (SSO window.open child windows).
|
|
149
|
+
if let Ok(w) = &r {
|
|
150
|
+
let tx4 = tx2.clone();
|
|
151
|
+
let _ = w.with_webview(move |pw| unsafe {
|
|
152
|
+
crate::popup_mac::install(pw.inner() as _, false);
|
|
153
|
+
let mtm = MainThreadMarker::new().expect("with_webview runs on main");
|
|
154
|
+
wv::install_message_handler(pw.inner() as *mut WKWebView, mtm, page_sink(tx4));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
respond(&tx2, &idc, r.map(|_| json!({ "ok": true, "label": label })).map_err(|e| e.to_string()));
|
|
158
|
+
});
|
|
159
|
+
if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
|
|
160
|
+
}
|
|
161
|
+
"closeWindow" => {
|
|
162
|
+
let label = v["label"].as_str().unwrap_or_default().to_string();
|
|
163
|
+
if wv::profile_webview(&label).is_some() {
|
|
164
|
+
let idc = id.clone();
|
|
165
|
+
let r = app.run_on_main_thread(move || {
|
|
166
|
+
let ok = wv::close(&label);
|
|
167
|
+
respond(&tx2, &idc, if ok { Ok(json!({ "ok": true })) } else { Err(format!("no window {label}")) });
|
|
168
|
+
});
|
|
169
|
+
if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
match app.get_webview_window(&label) {
|
|
173
|
+
Some(w) => respond(tx, &id, w.close().map(|_| json!({ "ok": true })).map_err(|e| e.to_string())),
|
|
174
|
+
None => respond(tx, &id, Err(format!("no window {label}"))),
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
"navigate" => {
|
|
178
|
+
let label = v["label"].as_str().unwrap_or_default().to_string();
|
|
179
|
+
let url = v["url"].as_str().unwrap_or_default().to_string();
|
|
180
|
+
let idc = id.clone();
|
|
181
|
+
let r = app.run_on_main_thread(move || {
|
|
182
|
+
if let Some(wk) = wv::profile_webview(&label) {
|
|
183
|
+
let res = unsafe { wv::navigate(Retained::as_ptr(&wk) as *mut WKWebView, &url) };
|
|
184
|
+
respond(&tx2, &idc, res.map(|_| json!({ "ok": true })));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
let parsed = match tauri::Url::parse(&url) {
|
|
188
|
+
Ok(u) => u,
|
|
189
|
+
Err(e) => { respond(&tx2, &idc, Err(format!("bad url: {e}"))); return; }
|
|
190
|
+
};
|
|
191
|
+
match app2.get_webview_window(&label) {
|
|
192
|
+
Some(w) => respond(&tx2, &idc, w.navigate(parsed).map(|_| json!({ "ok": true })).map_err(|e| e.to_string())),
|
|
193
|
+
None => respond(&tx2, &idc, Err(format!("no window {label}"))),
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
|
|
197
|
+
}
|
|
198
|
+
"screenshot" => {
|
|
199
|
+
let label = v["label"].as_str().unwrap_or_default().to_string();
|
|
200
|
+
let idc = id.clone();
|
|
201
|
+
let r = app.run_on_main_thread(move || {
|
|
202
|
+
if let Some(wk) = wv::profile_webview(&label) {
|
|
203
|
+
let tx3 = tx2.clone();
|
|
204
|
+
let id2 = idc.clone();
|
|
205
|
+
unsafe { wv::snapshot(Retained::as_ptr(&wk) as *mut WKWebView, move |r| respond_png(&tx3, &id2, r)) };
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
let Some(w) = app2.get_webview_window(&label) else {
|
|
209
|
+
respond(&tx2, &idc, Err(format!("no window {label}")));
|
|
210
|
+
return;
|
|
211
|
+
};
|
|
212
|
+
let tx3 = tx2.clone();
|
|
213
|
+
let id2 = idc.clone();
|
|
214
|
+
let r = w.with_webview(move |pw| unsafe {
|
|
215
|
+
wv::snapshot(pw.inner() as *mut WKWebView, move |r| respond_png(&tx3, &id2, r))
|
|
216
|
+
});
|
|
217
|
+
if let Err(e) = r { respond(&tx2, &idc, Err(e.to_string())); }
|
|
218
|
+
});
|
|
219
|
+
if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
|
|
220
|
+
}
|
|
221
|
+
"evalJs" => {
|
|
222
|
+
// Fire-and-forget JS injection into a window (debug aid + transport fallback probe).
|
|
223
|
+
let label = v["label"].as_str().unwrap_or_default().to_string();
|
|
224
|
+
let js = v["js"].as_str().unwrap_or_default().to_string();
|
|
225
|
+
if wv::profile_webview(&label).is_some() {
|
|
226
|
+
let idc = id.clone();
|
|
227
|
+
let r = app.run_on_main_thread(move || {
|
|
228
|
+
if let Some(wk) = wv::profile_webview(&label) {
|
|
229
|
+
unsafe { wv::inject_script(Retained::as_ptr(&wk) as *mut WKWebView, &js) };
|
|
230
|
+
respond(&tx2, &idc, Ok(json!({ "ok": true })));
|
|
231
|
+
} else {
|
|
232
|
+
respond(&tx2, &idc, Err(format!("no window {label}")));
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
match app.get_webview_window(&label) {
|
|
239
|
+
Some(w) => respond(tx, &id, w.eval(&js).map(|_| json!({ "ok": true })).map_err(|e| e.to_string())),
|
|
240
|
+
None => respond(tx, &id, Err(format!("no window {label}"))),
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
"evalAsync" => {
|
|
244
|
+
// CSP-immune eval with a real (awaited) return value — callAsyncJavaScript. `js` is a
|
|
245
|
+
// FUNCTION BODY (use `return`). The crate delivers the RAW return string; we frame {json}.
|
|
246
|
+
let label = v["label"].as_str().unwrap_or_default().to_string();
|
|
247
|
+
let js = v["js"].as_str().unwrap_or_default().to_string();
|
|
248
|
+
let idc = id.clone();
|
|
249
|
+
let r = app.run_on_main_thread(move || {
|
|
250
|
+
if let Some(wk) = wv::profile_webview(&label) {
|
|
251
|
+
let tx3 = tx2.clone();
|
|
252
|
+
let id2 = idc.clone();
|
|
253
|
+
unsafe { wv::eval_async(Retained::as_ptr(&wk) as *mut WKWebView, js.clone(), move |r| respond_eval(&tx3, &id2, r)) };
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
let Some(w) = app2.get_webview_window(&label) else {
|
|
257
|
+
respond(&tx2, &idc, Err(format!("no window {label}")));
|
|
258
|
+
return;
|
|
259
|
+
};
|
|
260
|
+
let tx3 = tx2.clone();
|
|
261
|
+
let id2 = idc.clone();
|
|
262
|
+
let js2 = js.clone();
|
|
263
|
+
let r = w.with_webview(move |pw| unsafe {
|
|
264
|
+
wv::eval_async(pw.inner() as *mut WKWebView, js2, move |r| respond_eval(&tx3, &id2, r));
|
|
265
|
+
});
|
|
266
|
+
if let Err(e) = r { respond(&tx2, &idc, Err(e.to_string())); }
|
|
267
|
+
});
|
|
268
|
+
if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
|
|
269
|
+
}
|
|
270
|
+
"userClose" => {
|
|
271
|
+
// Verification aid: drive the red-button path WITHOUT touching the registry, so the
|
|
272
|
+
// windowWillClose delegate is the sole remover — proves user-close hygiene headlessly.
|
|
273
|
+
let label = v["label"].as_str().unwrap_or_default().to_string();
|
|
274
|
+
let idc = id.clone();
|
|
275
|
+
let r = app.run_on_main_thread(move || {
|
|
276
|
+
let ok = wv::user_close(&label);
|
|
277
|
+
respond(&tx2, &idc, if ok { Ok(json!({ "ok": true })) } else { Err(format!("no window {label}")) });
|
|
278
|
+
});
|
|
279
|
+
if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
|
|
280
|
+
}
|
|
281
|
+
"listWindows" => {
|
|
282
|
+
// Closed windows are filtered by the crate; actual reaping (which drops objc objects) is
|
|
283
|
+
// deferred to createWindow, which runs on the main thread.
|
|
284
|
+
let mut labels: Vec<String> = app.webview_windows().keys().cloned().collect();
|
|
285
|
+
let profiles: Vec<Value> = wv::list_webviews()
|
|
286
|
+
.into_iter()
|
|
287
|
+
.map(|info| {
|
|
288
|
+
labels.push(info.label.clone());
|
|
289
|
+
json!({ "label": info.label, "profileId": info.profile_id })
|
|
290
|
+
})
|
|
291
|
+
.collect();
|
|
292
|
+
respond(tx, &id, Ok(json!({ "labels": labels, "profiles": profiles })));
|
|
293
|
+
}
|
|
294
|
+
_ => respond(tx, &id, Err(format!("unknown op: {op}"))),
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/// Frame a RAW eval return string into the {json} envelope the shim reads (`r.json`).
|
|
299
|
+
fn respond_eval(tx: &Sender<String>, id: &str, r: Result<String, String>) {
|
|
300
|
+
respond(tx, id, r.map(|out| json!({ "json": out })));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/// Base64-encode the crate's RAW PNG bytes and frame into the {png} envelope.
|
|
304
|
+
fn respond_png(tx: &Sender<String>, id: &str, r: Result<Vec<u8>, String>) {
|
|
305
|
+
respond(tx, id, r.map(|bytes| json!({ "png": base64::engine::general_purpose::STANDARD.encode(bytes) })));
|
|
306
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// browser_tab — Tauri commands for an EMBEDDED, tear-off/dock-able browser tab (project "browser-tab"
|
|
2
|
+
// Phase 3). Thin front-end-facing wrapper over `webview_control::embedded::Controller`: the same live
|
|
3
|
+
// WKWebView (page + WKWebsiteDataStore session + bridgeShim) is mounted `ChildOf` a host pane NSView
|
|
4
|
+
// and torn off / docked with `set_mount`.
|
|
5
|
+
//
|
|
6
|
+
// R2 (agent-drive, DRY): the tab is created in the crate-level registry keyed by `id` (its label), so
|
|
7
|
+
// the control-channel adapter (`bridge_mac.rs`) — and thus the mcp-desktop-browser MCP — can drive
|
|
8
|
+
// this SAME embedded tab (navigate/eval/screenshot/close) by that id, with NO new TS<->Rust binding.
|
|
9
|
+
//
|
|
10
|
+
// Landmine (a): the pane NSView* is fetched + the webview mounted on the MAIN THREAD, AFTER the pane
|
|
11
|
+
// container has non-zero bounds (we create the container at the caller-supplied paneRect), else blank.
|
|
12
|
+
#[cfg(target_os = "macos")]
|
|
13
|
+
mod imp {
|
|
14
|
+
use std::collections::HashMap;
|
|
15
|
+
use std::ffi::c_void;
|
|
16
|
+
use std::sync::{Mutex, OnceLock};
|
|
17
|
+
|
|
18
|
+
use objc2::rc::Retained;
|
|
19
|
+
use objc2::{MainThreadMarker, MainThreadOnly};
|
|
20
|
+
use objc2_app_kit::{NSView, NSWindow};
|
|
21
|
+
use objc2_foundation::{NSPoint, NSRect, NSSize};
|
|
22
|
+
use objc2_web_kit::WKWebView;
|
|
23
|
+
use serde::Deserialize;
|
|
24
|
+
use tauri::Manager;
|
|
25
|
+
|
|
26
|
+
use webview_control::embedded::Controller;
|
|
27
|
+
use webview_control::{CreateSpec, Mount, ViewHandle};
|
|
28
|
+
|
|
29
|
+
/// Web-DOM pane rect the front-end reserves (top-left origin, CSS px == points here since the
|
|
30
|
+
/// shell runs at the window's backing scale). We flip Y to AppKit's bottom-left origin at mount.
|
|
31
|
+
#[derive(Deserialize, Clone, Copy)]
|
|
32
|
+
pub struct PaneRect {
|
|
33
|
+
pub x: f64,
|
|
34
|
+
pub y: f64,
|
|
35
|
+
pub width: f64,
|
|
36
|
+
pub height: f64,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// id -> host pane container NSView* (as usize). The container is retained by being a subview of
|
|
40
|
+
// the window contentView; we hold only its pointer to reposition / reparent / remove it. Access
|
|
41
|
+
// is main-thread only (all commands marshal via run_on_main_thread), the Mutex just satisfies the
|
|
42
|
+
// `static` Send/Sync bound for the raw pointer.
|
|
43
|
+
fn containers() -> &'static Mutex<HashMap<String, usize>> {
|
|
44
|
+
static CONTAINERS: OnceLock<Mutex<HashMap<String, usize>>> = OnceLock::new();
|
|
45
|
+
CONTAINERS.get_or_init(|| Mutex::new(HashMap::new()))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
unsafe fn appkit_rect(content: &NSView, r: PaneRect) -> NSRect {
|
|
49
|
+
let ch = content.bounds().size.height;
|
|
50
|
+
NSRect::new(
|
|
51
|
+
NSPoint::new(r.x, ch - r.y - r.height),
|
|
52
|
+
NSSize::new(r.width, r.height),
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// The main window's contentView (parent of the shell WKWebView). Main-thread only.
|
|
57
|
+
unsafe fn content_view(win: &NSWindow) -> Retained<NSView> {
|
|
58
|
+
win.contentView().expect("main window has a contentView")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
pub fn create(
|
|
62
|
+
app: tauri::AppHandle,
|
|
63
|
+
id: String,
|
|
64
|
+
url: String,
|
|
65
|
+
profile_id: String,
|
|
66
|
+
rect: PaneRect,
|
|
67
|
+
) -> Result<(), String> {
|
|
68
|
+
let win = app.get_webview_window("main").ok_or("no main window")?;
|
|
69
|
+
// with_webview runs its closure on the AppKit main thread. Build the pane container at
|
|
70
|
+
// non-zero bounds FIRST (landmine a), then mount the webview ChildOf it — all on main.
|
|
71
|
+
let (tx, rx) = std::sync::mpsc::channel();
|
|
72
|
+
win.with_webview(move |w| {
|
|
73
|
+
let res = (|| -> Result<(), String> {
|
|
74
|
+
let mtm = MainThreadMarker::new().ok_or("not main thread")?;
|
|
75
|
+
unsafe {
|
|
76
|
+
let wk = &*(w.inner() as *mut WKWebView);
|
|
77
|
+
let ns_win = wk.window().ok_or("shell webview has no window")?;
|
|
78
|
+
let content = content_view(&ns_win);
|
|
79
|
+
let frame = appkit_rect(&content, rect);
|
|
80
|
+
let container = NSView::initWithFrame(NSView::alloc(mtm), frame);
|
|
81
|
+
content.addSubview(&container);
|
|
82
|
+
let cptr = Retained::as_ptr(&container) as *mut c_void;
|
|
83
|
+
let handle = ViewHandle::from_ptr(cptr);
|
|
84
|
+
let spec = CreateSpec {
|
|
85
|
+
label: id.clone(),
|
|
86
|
+
url: url.clone(),
|
|
87
|
+
profile_id: profile_id.clone(),
|
|
88
|
+
init_js: String::new(),
|
|
89
|
+
mount: Mount::ChildOf(handle),
|
|
90
|
+
};
|
|
91
|
+
Controller::new().create_webview(mtm, spec, Box::new(|_raw| {}))?;
|
|
92
|
+
// contentView holds a strong ref via addSubview; store only the pointer.
|
|
93
|
+
containers().lock().unwrap().insert(id.clone(), cptr as usize);
|
|
94
|
+
}
|
|
95
|
+
Ok(())
|
|
96
|
+
})();
|
|
97
|
+
let _ = tx.send(res);
|
|
98
|
+
})
|
|
99
|
+
.map_err(|e| e.to_string())?;
|
|
100
|
+
rx.recv().map_err(|e| e.to_string())?
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
pub fn set_mount(app: tauri::AppHandle, id: String, mode: String) -> Result<(), String> {
|
|
104
|
+
let (tx, rx) = std::sync::mpsc::channel();
|
|
105
|
+
let win = app.get_webview_window("main").ok_or("no main window")?;
|
|
106
|
+
win.run_on_main_thread(move || {
|
|
107
|
+
let res = (|| -> Result<(), String> {
|
|
108
|
+
let mtm = MainThreadMarker::new().ok_or("not main thread")?;
|
|
109
|
+
let mount = match mode.as_str() {
|
|
110
|
+
"toplevel" => Mount::TopLevel,
|
|
111
|
+
"child" => {
|
|
112
|
+
let ptr = *containers().lock().unwrap().get(&id).ok_or("no pane for id")?;
|
|
113
|
+
unsafe { Mount::ChildOf(ViewHandle::from_ptr(ptr as *mut c_void)) }
|
|
114
|
+
}
|
|
115
|
+
other => return Err(format!("bad mount mode: {other}")),
|
|
116
|
+
};
|
|
117
|
+
if Controller::new().set_mount(mtm, &id, mount) {
|
|
118
|
+
Ok(())
|
|
119
|
+
} else {
|
|
120
|
+
Err(format!("no webview {id}"))
|
|
121
|
+
}
|
|
122
|
+
})();
|
|
123
|
+
let _ = tx.send(res);
|
|
124
|
+
})
|
|
125
|
+
.map_err(|e| e.to_string())?;
|
|
126
|
+
rx.recv().map_err(|e| e.to_string())?
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
pub fn navigate(app: tauri::AppHandle, id: String, url: String) -> Result<(), String> {
|
|
130
|
+
let (tx, rx) = std::sync::mpsc::channel();
|
|
131
|
+
let win = app.get_webview_window("main").ok_or("no main window")?;
|
|
132
|
+
win.run_on_main_thread(move || {
|
|
133
|
+
let res = (|| -> Result<(), String> {
|
|
134
|
+
let mtm = MainThreadMarker::new().ok_or("not main thread")?;
|
|
135
|
+
Controller::new().navigate(mtm, &id, &url)
|
|
136
|
+
})();
|
|
137
|
+
let _ = tx.send(res);
|
|
138
|
+
})
|
|
139
|
+
.map_err(|e| e.to_string())?;
|
|
140
|
+
rx.recv().map_err(|e| e.to_string())?
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
pub fn close(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
|
144
|
+
let (tx, rx) = std::sync::mpsc::channel();
|
|
145
|
+
let win = app.get_webview_window("main").ok_or("no main window")?;
|
|
146
|
+
win.run_on_main_thread(move || {
|
|
147
|
+
let res = (|| -> Result<(), String> {
|
|
148
|
+
let mtm = MainThreadMarker::new().ok_or("not main thread")?;
|
|
149
|
+
Controller::new().close(mtm, &id);
|
|
150
|
+
if let Some(ptr) = containers().lock().unwrap().remove(&id) {
|
|
151
|
+
unsafe {
|
|
152
|
+
let container = &*(ptr as *mut NSView);
|
|
153
|
+
container.removeFromSuperview();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
Ok(())
|
|
157
|
+
})();
|
|
158
|
+
let _ = tx.send(res);
|
|
159
|
+
})
|
|
160
|
+
.map_err(|e| e.to_string())?;
|
|
161
|
+
rx.recv().map_err(|e| e.to_string())?
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#[cfg(target_os = "macos")]
|
|
166
|
+
#[tauri::command]
|
|
167
|
+
pub fn browser_tab_create(
|
|
168
|
+
app: tauri::AppHandle,
|
|
169
|
+
id: String,
|
|
170
|
+
url: String,
|
|
171
|
+
profile_id: String,
|
|
172
|
+
rect: imp::PaneRect,
|
|
173
|
+
) -> Result<String, String> {
|
|
174
|
+
imp::create(app, id.clone(), url, profile_id, rect).map(|_| id)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#[cfg(target_os = "macos")]
|
|
178
|
+
#[tauri::command]
|
|
179
|
+
pub fn browser_tab_set_mount(app: tauri::AppHandle, id: String, mode: String) -> Result<(), String> {
|
|
180
|
+
imp::set_mount(app, id, mode)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#[cfg(target_os = "macos")]
|
|
184
|
+
#[tauri::command]
|
|
185
|
+
pub fn browser_tab_navigate(app: tauri::AppHandle, id: String, url: String) -> Result<(), String> {
|
|
186
|
+
imp::navigate(app, id, url)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#[cfg(target_os = "macos")]
|
|
190
|
+
#[tauri::command]
|
|
191
|
+
pub fn browser_tab_close(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
|
192
|
+
imp::close(app, id)
|
|
193
|
+
}
|