@livx.cc/appwrap 0.46.1 → 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/shim.ts +12 -197
- 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 +13 -0
- package/runtime-desktop/src-tauri/Cargo.toml +3 -0
- package/runtime-desktop/src-tauri/src/bridge_mac.rs +83 -441
- package/runtime-desktop/src-tauri/src/browser_tab.rs +193 -0
- package/runtime-desktop/src-tauri/src/main.rs +8 -1
- package/runtime-desktop/src-tauri/src/popup_mac.rs +5 -519
- package/runtime-desktop/src-tauri/tauri.conf.json +1 -0
- package/src/cli.ts +16 -0
- package/src/config.ts +10 -1
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
//! Phase-2 host harness — proves a live WKWebView tears off (ChildOf pane -> TopLevel window) and
|
|
2
|
+
//! docks back (-> ChildOf pane) with page + WKWebsiteDataStore session (localStorage marker) +
|
|
3
|
+
//! WKScriptMessageHandler bridge all intact, driven THROUGH the embedded in-process API
|
|
4
|
+
//! (`webview_control::embedded::Controller`) — driving-adapter (a), no socket.
|
|
5
|
+
//!
|
|
6
|
+
//! Run: `cargo run --example host_harness` (macOS; add `-- --hold` to keep windows up for a look).
|
|
7
|
+
//!
|
|
8
|
+
//! It is a minimal bare-AppKit host: it owns a pane NSView inside a plain NSWindow (mirroring what a
|
|
9
|
+
//! Tauri pane / unclaw-app pane provides), serves a loopback page from an in-process HTTP server
|
|
10
|
+
//! (opaque about:/data: origins can't use localStorage), then runs the assertion sequence and exits
|
|
11
|
+
//! non-zero on any failure. The AppKit main runloop is pumped manually so each async step
|
|
12
|
+
//! (callAsyncJavaScript / page load / bridge post) completes before the next.
|
|
13
|
+
|
|
14
|
+
use std::cell::RefCell;
|
|
15
|
+
use std::ffi::c_void;
|
|
16
|
+
use std::io::{Read, Write};
|
|
17
|
+
use std::net::TcpListener;
|
|
18
|
+
use std::rc::Rc;
|
|
19
|
+
use std::sync::mpsc::{channel, Receiver};
|
|
20
|
+
use std::time::{Duration, Instant};
|
|
21
|
+
|
|
22
|
+
use objc2::rc::Retained;
|
|
23
|
+
use objc2::MainThreadMarker;
|
|
24
|
+
use objc2_app_kit::{
|
|
25
|
+
NSApplication, NSApplicationActivationPolicy, NSBackingStoreType, NSView, NSWindow,
|
|
26
|
+
NSWindowStyleMask,
|
|
27
|
+
};
|
|
28
|
+
use objc2_foundation::{
|
|
29
|
+
NSDate, NSDefaultRunLoopMode, NSPoint, NSRect, NSRunLoop, NSSize, NSString,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
use webview_control::embedded::Controller;
|
|
33
|
+
use webview_control::{CreateSpec, Mount, ViewHandle};
|
|
34
|
+
|
|
35
|
+
/// Pump the AppKit main runloop for up to `secs`, returning early once `done()` is true.
|
|
36
|
+
fn pump_until(secs: f64, mut done: impl FnMut() -> bool) {
|
|
37
|
+
let rl = NSRunLoop::currentRunLoop();
|
|
38
|
+
let deadline = Instant::now() + Duration::from_secs_f64(secs);
|
|
39
|
+
while Instant::now() < deadline {
|
|
40
|
+
if done() {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
let until = NSDate::dateWithTimeIntervalSinceNow(0.05);
|
|
44
|
+
unsafe { rl.runMode_beforeDate(NSDefaultRunLoopMode, &until) };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
fn pump(secs: f64) {
|
|
49
|
+
pump_until(secs, || false);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Synchronous eval through the embedded Controller: fire callAsyncJavaScript and pump until the
|
|
53
|
+
/// completion block lands (or timeout). Returns the RAW return string.
|
|
54
|
+
fn eval(ctrl: &Controller, mtm: MainThreadMarker, id: &str, js: &str) -> Result<String, String> {
|
|
55
|
+
let slot: Rc<RefCell<Option<Result<String, String>>>> = Rc::new(RefCell::new(None));
|
|
56
|
+
let s2 = slot.clone();
|
|
57
|
+
ctrl.eval_async(mtm, id, js.to_string(), move |r| *s2.borrow_mut() = Some(r));
|
|
58
|
+
pump_until(8.0, || slot.borrow().is_some());
|
|
59
|
+
let out = slot.borrow_mut().take();
|
|
60
|
+
out.unwrap_or(Err("eval timed out".into()))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// Start a throwaway loopback HTTP server; returns its `http://127.0.0.1:PORT/` URL. The page just
|
|
64
|
+
/// carries a stable title — the session marker is written via localStorage by the harness.
|
|
65
|
+
fn start_loopback() -> String {
|
|
66
|
+
let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback");
|
|
67
|
+
let addr = listener.local_addr().unwrap();
|
|
68
|
+
std::thread::spawn(move || {
|
|
69
|
+
let body = "<!doctype html><html><head><title>HOST-HARNESS-PAGE</title></head>\
|
|
70
|
+
<body style=\"margin:0;background:#0b5;color:#fff;font:700 40px system-ui;\
|
|
71
|
+
display:flex;align-items:center;justify-content:center;height:100vh\">\
|
|
72
|
+
<div id=\"tag\">HOST-HARNESS-LIVE</div></body></html>";
|
|
73
|
+
for stream in listener.incoming() {
|
|
74
|
+
let Ok(mut s) = stream else { continue };
|
|
75
|
+
let mut buf = [0u8; 1024];
|
|
76
|
+
let _ = s.read(&mut buf);
|
|
77
|
+
let resp = format!(
|
|
78
|
+
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
79
|
+
body.len(),
|
|
80
|
+
body
|
|
81
|
+
);
|
|
82
|
+
let _ = s.write_all(resp.as_bytes());
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
format!("http://{addr}/")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
fn main() {
|
|
89
|
+
let mtm = MainThreadMarker::new().expect("host_harness must run on the main thread");
|
|
90
|
+
|
|
91
|
+
// Scratch profile dir so we don't touch the real appwrap-desktop store.
|
|
92
|
+
let scratch = std::env::temp_dir().join(format!("webview-control-harness-{}", std::process::id()));
|
|
93
|
+
std::env::set_var("APPWRAP_PROFILE_DIR", &scratch);
|
|
94
|
+
|
|
95
|
+
// AppKit host app (accessory: no Dock icon; we pump the runloop manually rather than app.run()).
|
|
96
|
+
let app = NSApplication::sharedApplication(mtm);
|
|
97
|
+
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
|
|
98
|
+
let _ = app.finishLaunching();
|
|
99
|
+
|
|
100
|
+
// The HOST-owned pane: a plain NSWindow whose content view is the container the tab docks into.
|
|
101
|
+
let pane_rect = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(900.0, 640.0));
|
|
102
|
+
let style = NSWindowStyleMask::Titled | NSWindowStyleMask::Closable | NSWindowStyleMask::Resizable;
|
|
103
|
+
let pane_window = unsafe {
|
|
104
|
+
NSWindow::initWithContentRect_styleMask_backing_defer(
|
|
105
|
+
mtm.alloc::<NSWindow>(),
|
|
106
|
+
pane_rect,
|
|
107
|
+
style,
|
|
108
|
+
NSBackingStoreType::Buffered,
|
|
109
|
+
false,
|
|
110
|
+
)
|
|
111
|
+
};
|
|
112
|
+
unsafe {
|
|
113
|
+
pane_window.setReleasedWhenClosed(false);
|
|
114
|
+
pane_window.setTitle(&NSString::from_str("[host pane]"));
|
|
115
|
+
}
|
|
116
|
+
let pane = NSView::initWithFrame(mtm.alloc::<NSView>(), pane_rect);
|
|
117
|
+
pane_window.setContentView(Some(&pane));
|
|
118
|
+
pane_window.center();
|
|
119
|
+
pane_window.makeKeyAndOrderFront(None);
|
|
120
|
+
let pane_handle = unsafe { ViewHandle::from_ptr(Retained::as_ptr(&pane) as *mut c_void) };
|
|
121
|
+
|
|
122
|
+
let page_url = start_loopback();
|
|
123
|
+
let id = "harness-tab";
|
|
124
|
+
let ctrl = Controller::new();
|
|
125
|
+
|
|
126
|
+
// Bridge sink: the crate hands us RAW page->host payloads; capture them to prove the
|
|
127
|
+
// WKScriptMessageHandler survives reparenting.
|
|
128
|
+
let (tx, rx): (_, Receiver<String>) = channel();
|
|
129
|
+
let sink = Box::new(move |raw: String| {
|
|
130
|
+
let _ = tx.send(raw);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// (1) Create the tab as a CHILD of the host pane — an embedded in-app browser tab.
|
|
134
|
+
println!("== create_webview ChildOf(pane) id={id} url={page_url}");
|
|
135
|
+
let created = ctrl.create_webview(
|
|
136
|
+
mtm,
|
|
137
|
+
CreateSpec {
|
|
138
|
+
label: id.to_string(),
|
|
139
|
+
url: page_url.clone(),
|
|
140
|
+
profile_id: "harness".to_string(),
|
|
141
|
+
init_js: String::new(),
|
|
142
|
+
mount: Mount::ChildOf(pane_handle),
|
|
143
|
+
},
|
|
144
|
+
sink,
|
|
145
|
+
);
|
|
146
|
+
match &created {
|
|
147
|
+
Ok(c) => println!(" created: persistent={} store={}", c.persistent, c.store_uuid),
|
|
148
|
+
Err(e) => {
|
|
149
|
+
eprintln!("FAIL: create_webview: {e}");
|
|
150
|
+
std::process::exit(1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
pump(3.0); // let the page load
|
|
154
|
+
|
|
155
|
+
// (2) Write the session marker + capture page identity while CHILD.
|
|
156
|
+
let set = eval(
|
|
157
|
+
&ctrl,
|
|
158
|
+
mtm,
|
|
159
|
+
id,
|
|
160
|
+
"localStorage.setItem('marker','HOST-HARNESS-SESSION-77'); \
|
|
161
|
+
return JSON.stringify({href:location.href,title:document.title,marker:localStorage.getItem('marker')});",
|
|
162
|
+
);
|
|
163
|
+
println!("== child, marker set: {set:?}");
|
|
164
|
+
|
|
165
|
+
// (3) Bridge ping while CHILD.
|
|
166
|
+
let _ = eval(&ctrl, mtm, id, "webkit.messageHandlers.bridgeShim.postMessage('PING-CHILD'); return 'posted';");
|
|
167
|
+
pump(0.5);
|
|
168
|
+
|
|
169
|
+
// (4) TEAR OFF -> TopLevel window.
|
|
170
|
+
println!("== set_mount TopLevel (tear off)");
|
|
171
|
+
if !ctrl.set_mount(mtm, id, Mount::TopLevel) {
|
|
172
|
+
eprintln!("FAIL: set_mount TopLevel returned false");
|
|
173
|
+
std::process::exit(1);
|
|
174
|
+
}
|
|
175
|
+
pump(1.5);
|
|
176
|
+
|
|
177
|
+
// (5) DOCK BACK -> ChildOf(pane).
|
|
178
|
+
println!("== set_mount ChildOf(pane) (dock back)");
|
|
179
|
+
if !ctrl.set_mount(mtm, id, Mount::ChildOf(pane_handle)) {
|
|
180
|
+
eprintln!("FAIL: set_mount ChildOf returned false");
|
|
181
|
+
std::process::exit(1);
|
|
182
|
+
}
|
|
183
|
+
pump(1.5);
|
|
184
|
+
|
|
185
|
+
// (6) After child->toplevel->child: read page identity + marker back, and ping the bridge again.
|
|
186
|
+
let after = eval(
|
|
187
|
+
&ctrl,
|
|
188
|
+
mtm,
|
|
189
|
+
id,
|
|
190
|
+
"return JSON.stringify({href:location.href,title:document.title,marker:localStorage.getItem('marker')});",
|
|
191
|
+
);
|
|
192
|
+
println!("== after reparent cycle, read-back: {after:?}");
|
|
193
|
+
let _ = eval(&ctrl, mtm, id, "webkit.messageHandlers.bridgeShim.postMessage('PING-AFTER'); return 'posted';");
|
|
194
|
+
pump(0.5);
|
|
195
|
+
|
|
196
|
+
// ---- assertions ----
|
|
197
|
+
let after = match after {
|
|
198
|
+
Ok(s) => s,
|
|
199
|
+
Err(e) => {
|
|
200
|
+
eprintln!("FAIL: read-back eval error after reparent: {e}");
|
|
201
|
+
std::process::exit(1);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
let page_ok = after.contains("HOST-HARNESS-PAGE"); // document.title preserved (page not reloaded/lost)
|
|
205
|
+
let session_ok = after.contains("HOST-HARNESS-SESSION-77"); // localStorage marker survived
|
|
206
|
+
let pings: Vec<String> = rx.try_iter().collect();
|
|
207
|
+
let bridge_ok = pings.iter().any(|p| p == "PING-CHILD") && pings.iter().any(|p| p == "PING-AFTER");
|
|
208
|
+
|
|
209
|
+
println!("\n---- Phase-2 embedded-API reparent assertions ----");
|
|
210
|
+
println!("page survived (title HOST-HARNESS-PAGE): {page_ok}");
|
|
211
|
+
println!("session survived (marker 77): {session_ok}");
|
|
212
|
+
println!("bridge survived (pings {:?}): {bridge_ok}", pings);
|
|
213
|
+
|
|
214
|
+
if std::env::args().any(|a| a == "--hold") {
|
|
215
|
+
println!("(--hold) leaving windows up 20s for inspection…");
|
|
216
|
+
pump(20.0);
|
|
217
|
+
}
|
|
218
|
+
// ---- zombie-close gate ----
|
|
219
|
+
// The tab is currently docked ChildOf(pane) (step 5/6). BEFORE close it is a subview of the host
|
|
220
|
+
// pane; AFTER close() the pane MUST have ZERO subviews — i.e. the WKWebView is actually gone from
|
|
221
|
+
// the host view tree, not left alive+visible as a zombie. This machine-gates the Phase-2 HIGH bug
|
|
222
|
+
// where close() only called window.close() and never removeFromSuperview() on the embedded webview.
|
|
223
|
+
let before_close = pane.subviews().count();
|
|
224
|
+
println!("\n== host pane subview count BEFORE close: {before_close} (expect 1: the docked tab)");
|
|
225
|
+
let _ = ctrl.close(mtm, id);
|
|
226
|
+
pump(0.5); // let AppKit settle the view-tree mutation
|
|
227
|
+
let after_close = pane.subviews().count();
|
|
228
|
+
println!("== host pane subview count AFTER close: {after_close} (expect 0: tab detached)");
|
|
229
|
+
let pane_empty_ok = after_close == 0;
|
|
230
|
+
|
|
231
|
+
println!("pane empty after close (no zombie webview): {pane_empty_ok}");
|
|
232
|
+
|
|
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.");
|
|
235
|
+
} else {
|
|
236
|
+
if !pane_empty_ok {
|
|
237
|
+
eprintln!(
|
|
238
|
+
"\nFAIL: ZOMBIE WEBVIEW — host pane still has {after_close} subview(s) after close(); \
|
|
239
|
+
the embedded WKWebView was not removed from the host view tree."
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
eprintln!("\nFAIL: one or more invariants did not survive reparent.");
|
|
243
|
+
std::process::exit(1);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
//! Driving adapter (a): the in-process Rust API for EMBEDDED hosts (para-chat / unclaw-app).
|
|
2
|
+
//!
|
|
3
|
+
//! Same command surface as the control-channel adapter (bridge_mac.rs, driving adapter (b)) but
|
|
4
|
+
//! with NO socket, NO JSON framing — a plain Rust object over the one `WebviewControl` contract.
|
|
5
|
+
//! An embedded host that links this crate creates an in-app browser tab as `ChildOf(paneView)` and
|
|
6
|
+
//! tears it out / docks it back with `set_mount` — the same live WKWebView, session + bridge intact.
|
|
7
|
+
//!
|
|
8
|
+
//! Threading: every method takes a `MainThreadMarker` and runs synchronously on the AppKit main
|
|
9
|
+
//! thread. The facade deliberately has NO built-in `run_on_main_thread` — the host owns marshalling
|
|
10
|
+
//! (Tauri's `run_on_main_thread`, or being called from the main runloop). WKWebView/NSView are not
|
|
11
|
+
//! thread-safe, and the crate must not assume a Tauri `AppHandle`.
|
|
12
|
+
|
|
13
|
+
use objc2::rc::Retained;
|
|
14
|
+
use objc2::MainThreadMarker;
|
|
15
|
+
use objc2_web_kit::WKWebView;
|
|
16
|
+
|
|
17
|
+
use crate::wkwebview_backend as backend;
|
|
18
|
+
use crate::{CreateResult, CreateSpec, MessageSink, Mount, WebviewInfo};
|
|
19
|
+
|
|
20
|
+
/// In-process facade over the webview primitive. Stateless — the webview registry is a crate-level
|
|
21
|
+
/// static — so it is a zero-cost handle the host can construct freely.
|
|
22
|
+
#[derive(Clone, Copy, Default)]
|
|
23
|
+
pub struct Controller;
|
|
24
|
+
|
|
25
|
+
impl Controller {
|
|
26
|
+
pub fn new() -> Self {
|
|
27
|
+
Controller
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// Create a webview mounted per `spec.mount` (`ChildOf(pane)` for an embedded tab). `on_message`
|
|
31
|
+
/// receives RAW page->host payloads (`webkit.messageHandlers.bridgeShim.postMessage`).
|
|
32
|
+
pub fn create_webview(
|
|
33
|
+
&self,
|
|
34
|
+
mtm: MainThreadMarker,
|
|
35
|
+
spec: CreateSpec,
|
|
36
|
+
on_message: MessageSink,
|
|
37
|
+
) -> Result<CreateResult, String> {
|
|
38
|
+
backend::create_webview(mtm, spec, on_message)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Tear-off / dock: move the live webview between `TopLevel` and `ChildOf(pane)`. False if unknown.
|
|
42
|
+
pub fn set_mount(&self, mtm: MainThreadMarker, id: &str, mount: Mount) -> bool {
|
|
43
|
+
backend::set_mount(mtm, id, mount)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Navigate the webview `id` to `url`. Err if `id` is unknown/closed.
|
|
47
|
+
pub fn navigate(&self, _mtm: MainThreadMarker, id: &str, url: &str) -> Result<(), String> {
|
|
48
|
+
let wk = self.webview(id)?;
|
|
49
|
+
unsafe { backend::navigate(Retained::as_ptr(&wk) as *mut WKWebView, url) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Fire-and-forget JS injection into webview `id`. Err if `id` is unknown/closed.
|
|
53
|
+
pub fn inject_script(&self, _mtm: MainThreadMarker, id: &str, js: &str) -> Result<(), String> {
|
|
54
|
+
let wk = self.webview(id)?;
|
|
55
|
+
unsafe { backend::inject_script(Retained::as_ptr(&wk) as *mut WKWebView, js) };
|
|
56
|
+
Ok(())
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// CSP-immune eval (callAsyncJavaScript, `js` is a function body). Delivers the RAW return
|
|
60
|
+
/// string via `on_done` on the main thread. Err (via `on_done`) if `id` is unknown/closed.
|
|
61
|
+
pub fn eval_async(
|
|
62
|
+
&self,
|
|
63
|
+
_mtm: MainThreadMarker,
|
|
64
|
+
id: &str,
|
|
65
|
+
js: String,
|
|
66
|
+
on_done: impl FnOnce(Result<String, String>) + 'static,
|
|
67
|
+
) {
|
|
68
|
+
match self.webview(id) {
|
|
69
|
+
Ok(wk) => unsafe { backend::eval_async(Retained::as_ptr(&wk) as *mut WKWebView, js, on_done) },
|
|
70
|
+
Err(e) => on_done(Err(e)),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Snapshot webview `id` to PNG bytes via `on_done` on the main thread.
|
|
75
|
+
pub fn snapshot(
|
|
76
|
+
&self,
|
|
77
|
+
_mtm: MainThreadMarker,
|
|
78
|
+
id: &str,
|
|
79
|
+
on_done: impl FnOnce(Result<Vec<u8>, String>) + 'static,
|
|
80
|
+
) {
|
|
81
|
+
match self.webview(id) {
|
|
82
|
+
Ok(wk) => unsafe { backend::snapshot(Retained::as_ptr(&wk) as *mut WKWebView, on_done) },
|
|
83
|
+
Err(e) => on_done(Err(e)),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// Close + drop the webview `id` (releases its window + view). False if unknown.
|
|
88
|
+
pub fn close(&self, _mtm: MainThreadMarker, id: &str) -> bool {
|
|
89
|
+
backend::close(id)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// Live (non-closed) webviews.
|
|
93
|
+
pub fn list_webviews(&self) -> Vec<WebviewInfo> {
|
|
94
|
+
backend::list_webviews()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fn webview(&self, id: &str) -> Result<Retained<WKWebView>, String> {
|
|
98
|
+
backend::profile_webview(id).ok_or_else(|| format!("no webview {id}"))
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
//! webview-control — appwrap's generalized, automation-agnostic native webview primitive.
|
|
2
|
+
//!
|
|
3
|
+
//! Carved out of `src-tauri/src/bridge_mac.rs` (Phase 1). It owns the parts that MUST be native +
|
|
4
|
+
//! in-process + appwrap-owned because `WKWebsiteDataStore` is immutable post-webview-creation:
|
|
5
|
+
//! per-profile persistent+isolated stores, manual NSWindow+WKWebView creation, CSP-immune eval,
|
|
6
|
+
//! snapshot, the `bridgeShim` WKScriptMessageHandler, and the SSO-popup delegate.
|
|
7
|
+
//!
|
|
8
|
+
//! Seam purity: the primitive emits RAW page payloads (`MessageSink`) and RAW eval/snapshot results
|
|
9
|
+
//! (per-call `on_done` callbacks). It holds NO bod-browser protocol, transport `Sender`, or
|
|
10
|
+
//! `{type:"pageMsg"}` / `{id,result}` framing — those live in the control-channel adapter
|
|
11
|
+
//! (`bridge_mac.rs`). The `WkWebViewBackend` is macOS-only; a `WebKitGtkBackend` is a future slot.
|
|
12
|
+
//!
|
|
13
|
+
//! Two driving adapters over ONE contract:
|
|
14
|
+
//! - (a) [`embedded::Controller`] — in-process Rust API for EMBEDDED hosts (para-chat / unclaw-app);
|
|
15
|
+
//! creates tabs as [`Mount::ChildOf`] a host pane and tears off / docks with [`set_mount`].
|
|
16
|
+
//! - (b) the control-channel socket (`bridge_mac.rs`) — standalone / MCP; only ever [`Mount::TopLevel`].
|
|
17
|
+
//!
|
|
18
|
+
//! Cross-repo consumption (R1, resolved Phase 2): external hosts pin this crate as a **git dependency
|
|
19
|
+
//! on a tagged appwrap rev** — `webview-control = { git = ".../appwrap", tag = "webview-control-vX" }`
|
|
20
|
+
//! (no crates.io publish). appwrap-generated apps (unclaw-app) already get it via the whole-tree
|
|
21
|
+
//! `native-desktop` copy; para-chat's hand-maintained `src-tauri` adds the git dep directly. objc2*
|
|
22
|
+
//! versions come pinned by the tagged rev so the `*mut WKWebView`/`*mut NSView` pointers that cross
|
|
23
|
+
//! this boundary never hit an ABI mismatch.
|
|
24
|
+
|
|
25
|
+
#[cfg(target_os = "macos")]
|
|
26
|
+
pub mod embedded;
|
|
27
|
+
#[cfg(target_os = "macos")]
|
|
28
|
+
pub mod popup;
|
|
29
|
+
#[cfg(target_os = "macos")]
|
|
30
|
+
mod profiles;
|
|
31
|
+
#[cfg(target_os = "macos")]
|
|
32
|
+
mod wkwebview_backend;
|
|
33
|
+
|
|
34
|
+
use std::ffi::c_void;
|
|
35
|
+
|
|
36
|
+
/// Opaque handle to a host-owned `NSView` that a webview can be mounted inside (Phase 2).
|
|
37
|
+
///
|
|
38
|
+
/// # Safety contract
|
|
39
|
+
/// The wrapped pointer MUST be a valid `NSView*` (or subclass) for the ENTIRE time a webview is
|
|
40
|
+
/// mounted in it. The caller guarantees, and the crate relies on, all of:
|
|
41
|
+
/// - **Validity:** a live `NSView` created and retained by the *host* (e.g. a Tauri pane's
|
|
42
|
+
/// `ns_view()` / an AppKit container). Never a dangling, freed, or non-`NSView` pointer.
|
|
43
|
+
/// - **Main-thread only:** the handle is created, passed, and dereferenced ONLY on the AppKit main
|
|
44
|
+
/// thread. `ViewHandle` is intentionally `!Send`/`!Sync` — it must not cross threads.
|
|
45
|
+
/// - **Host-owned lifetime:** the host owns the pane and MUST NOT drop/free it while a webview is
|
|
46
|
+
/// mounted in it. Reparent the webview out (`set_mount(TopLevel)`) or `close` it BEFORE tearing
|
|
47
|
+
/// down the pane. The crate holds only a borrow-strength AppKit subview link, not ownership of
|
|
48
|
+
/// the host pane.
|
|
49
|
+
/// - **Laid-out bounds (Phase-3 landmine a):** fetch the `NSView*` and mount `ChildOf` it only
|
|
50
|
+
/// AFTER the pane has been laid out (non-zero `bounds`). The webview is framed to the pane's
|
|
51
|
+
/// bounds at mount; a zero-bounds pane (handle taken before layout) renders BLANK until a resize.
|
|
52
|
+
/// Take the handle post-layout, on the main thread.
|
|
53
|
+
/// - **objc2 ABI match (Phase-3 landmine b):** a host with its OWN `wry`/`objc2` tree (e.g.
|
|
54
|
+
/// para-chat's `src-tauri`) MUST resolve `objc2*` to the SAME `0.6.x` this crate is built against
|
|
55
|
+
/// (pinned by the tagged rev). A divergent objc2 makes the `*mut NSView` pointer cast UB in the
|
|
56
|
+
/// host's tree — pin/dedupe objc2 across the workspace, don't just add the git dep.
|
|
57
|
+
#[derive(Clone, Copy)]
|
|
58
|
+
pub struct ViewHandle(*mut c_void);
|
|
59
|
+
|
|
60
|
+
impl ViewHandle {
|
|
61
|
+
/// Wrap a raw host `NSView*`. See the type-level safety contract — the caller upholds validity,
|
|
62
|
+
/// main-thread affinity, and host-owned lifetime.
|
|
63
|
+
///
|
|
64
|
+
/// # Safety
|
|
65
|
+
/// `ptr` must be a valid `NSView*` per the [`ViewHandle`] contract.
|
|
66
|
+
pub unsafe fn from_ptr(ptr: *mut c_void) -> Self {
|
|
67
|
+
ViewHandle(ptr)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// The raw `NSView*`. Main-thread only.
|
|
71
|
+
pub fn as_ptr(self) -> *mut c_void {
|
|
72
|
+
self.0
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// Where a webview lives in the AppKit view tree (Phase 2 tear-off / dock).
|
|
77
|
+
/// - `TopLevel` — the webview owns/fills its own `NSWindow` content view (the standalone/MCP shape;
|
|
78
|
+
/// the only mount the control-channel adapter ever requests, so its behavior is unchanged).
|
|
79
|
+
/// - `ChildOf(handle)` — the webview is a subview of a host-owned pane `NSView`; the same live
|
|
80
|
+
/// `WKWebView` (page + `WKWebsiteDataStore` session + bridge) survives moving between the two.
|
|
81
|
+
#[derive(Clone, Copy)]
|
|
82
|
+
pub enum Mount {
|
|
83
|
+
TopLevel,
|
|
84
|
+
ChildOf(ViewHandle),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The Phase-1 primitive surface — a flat, transport-free command set the control-channel adapter
|
|
88
|
+
// drives — plus the Phase-2 reparent op (`set_mount`) and the embedded facade (`embedded::Controller`).
|
|
89
|
+
// See ARCHITECTURE.md for the full contract.
|
|
90
|
+
#[cfg(target_os = "macos")]
|
|
91
|
+
pub use wkwebview_backend::{
|
|
92
|
+
close, create_profile_window, create_webview, eval_async, inject_script,
|
|
93
|
+
install_message_handler, list_webviews, navigate, profile_webview, set_mount, snapshot,
|
|
94
|
+
user_close, CreateResult, CreateSpec, MessageSink, WebviewInfo,
|
|
95
|
+
};
|