@livx.cc/appwrap 0.55.0 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -3
- package/runtime/app/shell/capabilities.manifest.ts +3 -3
- package/runtime/tests/bridge-response-delivery.test.ts +10 -1
- package/runtime/tests/env-scheme.test.ts +6 -0
- package/scripts/stage-assets.mjs +1 -1
- package/src/cli.ts +40 -570
- package/src/config.ts +1 -1
- package/src/handlers.ts +1 -1
- package/src/packs.ts +1 -1
- package/templates/ci/fastlane/Fastfile +47 -6
- package/runtime-desktop/autotest/index.html +0 -77
- package/runtime-desktop/bridge-shim/build.sh +0 -9
- package/runtime-desktop/bridge-shim/content-entry.ts +0 -87
- package/runtime-desktop/bridge-shim/shim.ts +0 -132
- package/runtime-desktop/chrome/appwrap-browser-chrome.html +0 -185
- package/runtime-desktop/crates/webview-control/Cargo.lock +0 -330
- package/runtime-desktop/crates/webview-control/Cargo.toml +0 -28
- package/runtime-desktop/crates/webview-control/examples/host_harness.rs +0 -362
- package/runtime-desktop/crates/webview-control/src/embedded.rs +0 -138
- package/runtime-desktop/crates/webview-control/src/lib.rs +0 -100
- package/runtime-desktop/crates/webview-control/src/popup.rs +0 -522
- package/runtime-desktop/crates/webview-control/src/profiles.rs +0 -57
- package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +0 -558
- package/runtime-desktop/src-tauri/Cargo.lock +0 -5474
- package/runtime-desktop/src-tauri/Cargo.toml +0 -45
- package/runtime-desktop/src-tauri/build.rs +0 -3
- package/runtime-desktop/src-tauri/capabilities/default.json +0 -6
- package/runtime-desktop/src-tauri/icons/icon.png +0 -0
- package/runtime-desktop/src-tauri/shell_config.json +0 -10
- package/runtime-desktop/src-tauri/src/biometrics_mac.rs +0 -138
- package/runtime-desktop/src-tauri/src/bridge_mac.rs +0 -424
- package/runtime-desktop/src-tauri/src/browser_tab.rs +0 -804
- package/runtime-desktop/src-tauri/src/main.rs +0 -1228
- package/runtime-desktop/src-tauri/src/notifications_mac.rs +0 -202
- package/runtime-desktop/src-tauri/src/oauth_mac.rs +0 -139
- package/runtime-desktop/src-tauri/src/plugin_host.rs +0 -522
- package/runtime-desktop/src-tauri/src/popup_mac.rs +0 -5
- package/runtime-desktop/src-tauri/src/push_mac.rs +0 -193
- package/runtime-desktop/src-tauri/src/server.rs +0 -156
- package/runtime-desktop/src-tauri/src/sidecar.rs +0 -398
- package/runtime-desktop/src-tauri/tauri.conf.json +0 -14
- package/src/desktop.ts +0 -300
- package/src/plugin/host.ts +0 -242
|
@@ -1,522 +0,0 @@
|
|
|
1
|
-
//! Multiplexed PLUGIN HOST — the bidirectional generalization of `sidecar.rs`.
|
|
2
|
-
//!
|
|
3
|
-
//! Where the sidecar is a ONE-WAY method-RPC to a single Bun process (shell→sidecar request/response,
|
|
4
|
-
//! no window reference), the plugin host is a BIDIRECTIONAL, per-window channel to a single Bun host
|
|
5
|
-
//! process that loads ALL configured plugins (Elya decision: one multiplexed host):
|
|
6
|
-
//! - the shell EMITS window/page lifecycle EVENTS to the host (window-created, message, closed),
|
|
7
|
-
//! - the host CALLS native `webview-control` OPS back (a plugin's `WindowCtx` methods) — routed by
|
|
8
|
-
//! `pluginId × windowId`, dispatched on the main thread, answered with a `result`,
|
|
9
|
-
//! - handler RPC (`call`/`result`) preserves the sidecar's back-compat path: a plugin's `handlers`
|
|
10
|
-
//! route exactly like `defineHandlers`.
|
|
11
|
-
//!
|
|
12
|
-
//! Transport = a Unix domain socket (design decision: no port, filesystem-perm'd). The shell BINDS +
|
|
13
|
-
//! listens, spawns `bun <host> --socket <path> --plugins <json>`, and the host connects. Wire = the
|
|
14
|
-
//! same proven line-JSON model as `bridge_mac.rs`, generalized to the plugin envelope. This module is
|
|
15
|
-
//! ADDITIVE — the `desktop.handlers` sidecar (sidecar.rs) is untouched, so `defineHandlers` is unbroken.
|
|
16
|
-
|
|
17
|
-
use serde_json::{json, Value};
|
|
18
|
-
use std::collections::{HashMap, HashSet};
|
|
19
|
-
use std::io::{BufRead, BufReader, Write};
|
|
20
|
-
use std::os::unix::net::{UnixListener, UnixStream};
|
|
21
|
-
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
22
|
-
use std::sync::mpsc::{self, Receiver, Sender, SyncSender};
|
|
23
|
-
use std::sync::{Arc, Mutex, OnceLock};
|
|
24
|
-
use std::time::Duration;
|
|
25
|
-
|
|
26
|
-
use objc2::rc::Retained;
|
|
27
|
-
use objc2::MainThreadMarker;
|
|
28
|
-
use objc2_web_kit::WKWebView;
|
|
29
|
-
use tauri::{AppHandle, Manager};
|
|
30
|
-
|
|
31
|
-
use webview_control as wv;
|
|
32
|
-
|
|
33
|
-
const READY_TIMEOUT: Duration = Duration::from_secs(5);
|
|
34
|
-
const CALL_TIMEOUT: Duration = Duration::from_secs(30);
|
|
35
|
-
|
|
36
|
-
pub type PluginResult = Result<Value, (String, String)>;
|
|
37
|
-
|
|
38
|
-
struct PluginHost {
|
|
39
|
-
/// Handler methods the host advertised (sidecar-compatible RPC surface), builtin-filtered.
|
|
40
|
-
methods: HashSet<String>,
|
|
41
|
-
alive: Arc<AtomicBool>,
|
|
42
|
-
/// Outbound line sink (drained by the writer thread → the socket).
|
|
43
|
-
out: Sender<String>,
|
|
44
|
-
/// Pending handler `call`s awaiting a `result`, keyed by our minted id.
|
|
45
|
-
pending: Arc<Mutex<HashMap<String, Sender<Value>>>>,
|
|
46
|
-
next_id: AtomicU64,
|
|
47
|
-
child: Mutex<std::process::Child>,
|
|
48
|
-
socket_path: String,
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
static HOST: OnceLock<PluginHost> = OnceLock::new();
|
|
52
|
-
/// Live outbound sink, published for the duration of a connection so `on_window_created` / message
|
|
53
|
-
/// sinks (which fire outside a request/response) can reach the host. None when disconnected.
|
|
54
|
-
static OUT: Mutex<Option<Sender<String>>> = Mutex::new(None);
|
|
55
|
-
|
|
56
|
-
/// Parse one host line into a routable outcome. Pure — unit-tested.
|
|
57
|
-
enum Line {
|
|
58
|
-
Ready(Vec<String>),
|
|
59
|
-
Op { env: Value },
|
|
60
|
-
Result { id: String, value: Value },
|
|
61
|
-
Other,
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
fn parse_line(line: &str) -> Line {
|
|
65
|
-
let v: Value = match serde_json::from_str(line) {
|
|
66
|
-
Ok(v) => v,
|
|
67
|
-
Err(_) => return Line::Other,
|
|
68
|
-
};
|
|
69
|
-
match v["kind"].as_str() {
|
|
70
|
-
Some("ready") => {
|
|
71
|
-
let methods = v["plugins"]
|
|
72
|
-
.as_array()
|
|
73
|
-
.map(|plugins| {
|
|
74
|
-
plugins
|
|
75
|
-
.iter()
|
|
76
|
-
.flat_map(|p| p["methods"].as_array().cloned().unwrap_or_default())
|
|
77
|
-
.filter_map(|m| m.as_str().map(str::to_string))
|
|
78
|
-
.collect()
|
|
79
|
-
})
|
|
80
|
-
.unwrap_or_default();
|
|
81
|
-
Line::Ready(methods)
|
|
82
|
-
}
|
|
83
|
-
Some("op") => Line::Op { env: v },
|
|
84
|
-
Some("result") => match v["id"].as_str() {
|
|
85
|
-
Some(id) => Line::Result { id: id.to_string(), value: v },
|
|
86
|
-
None => Line::Other,
|
|
87
|
-
},
|
|
88
|
-
_ => Line::Other,
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/// Drop any handler claim that shadows a built-in namespace (same guard as the sidecar). Pure.
|
|
93
|
-
fn filter_claims(methods: Vec<String>) -> Vec<String> {
|
|
94
|
-
let (rejected, kept): (Vec<String>, Vec<String>) = methods
|
|
95
|
-
.into_iter()
|
|
96
|
-
.partition(|m| crate::BUILTIN_METHOD_PREFIXES.iter().any(|p| m.starts_with(p)));
|
|
97
|
-
if !rejected.is_empty() {
|
|
98
|
-
eprintln!("[plugin-host] rejecting handler method(s) that shadow built-ins: {rejected:?}");
|
|
99
|
-
}
|
|
100
|
-
kept
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/// Resolve which Bun binary to spawn (same policy as the sidecar — stamped path wins, else `bun`).
|
|
104
|
-
fn resolve_runtime(stamped: &str) -> String {
|
|
105
|
-
if !stamped.is_empty() && std::path::Path::new(stamped).exists() {
|
|
106
|
-
return stamped.to_string();
|
|
107
|
-
}
|
|
108
|
-
eprintln!("[plugin-host] stamped runtime missing/empty ({stamped}) — falling back to `bun` (PATH lookup)");
|
|
109
|
-
"bun".to_string()
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/// Spawn the multiplexed plugin host over a fresh Unix socket and block (≤5s) on its `ready`.
|
|
113
|
-
/// `plugins_json` is the stamped `[{namespace, attachTo, bundlePath}]` array. Degrades to inert on
|
|
114
|
-
/// any failure (the shell runs without plugins). Idempotent (OnceLock).
|
|
115
|
-
pub fn init(app: AppHandle, host_path: &str, runtime: &str, plugins_json: &str) {
|
|
116
|
-
if HOST.get().is_some() {
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
let socket_path = std::env::temp_dir()
|
|
120
|
-
.join(format!("appwrap-plugins-{}.sock", std::process::id()))
|
|
121
|
-
.to_string_lossy()
|
|
122
|
-
.to_string();
|
|
123
|
-
let _ = std::fs::remove_file(&socket_path);
|
|
124
|
-
let listener = match UnixListener::bind(&socket_path) {
|
|
125
|
-
Ok(l) => l,
|
|
126
|
-
Err(e) => {
|
|
127
|
-
eprintln!("[plugin-host] bind {socket_path} failed: {e} — running without plugins");
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
let bun = resolve_runtime(runtime);
|
|
133
|
-
let mut child = match std::process::Command::new(&bun)
|
|
134
|
-
.args([host_path, "--socket", &socket_path, "--plugins", plugins_json])
|
|
135
|
-
.stdin(std::process::Stdio::null())
|
|
136
|
-
.stdout(std::process::Stdio::piped())
|
|
137
|
-
.stderr(std::process::Stdio::piped())
|
|
138
|
-
.spawn()
|
|
139
|
-
{
|
|
140
|
-
Ok(c) => c,
|
|
141
|
-
Err(e) => {
|
|
142
|
-
eprintln!("[plugin-host] spawn `{bun} {host_path}` failed: {e} — running without plugins");
|
|
143
|
-
let _ = std::fs::remove_file(&socket_path);
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
};
|
|
147
|
-
// host stdout/stderr → shell stderr (prefixed) for diagnostics.
|
|
148
|
-
if let Some(p) = child.stdout.take() {
|
|
149
|
-
std::thread::spawn(move || {
|
|
150
|
-
for line in BufReader::new(p).lines().map_while(Result::ok) {
|
|
151
|
-
eprintln!("[plugin-host:out] {line}");
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
if let Some(p) = child.stderr.take() {
|
|
156
|
-
std::thread::spawn(move || {
|
|
157
|
-
for line in BufReader::new(p).lines().map_while(Result::ok) {
|
|
158
|
-
eprintln!("[plugin-host:err] {line}");
|
|
159
|
-
}
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Accept the host's connection (bounded — the host connects immediately after spawn).
|
|
164
|
-
listener.set_nonblocking(false).ok();
|
|
165
|
-
let stream = match accept_with_timeout(&listener, READY_TIMEOUT) {
|
|
166
|
-
Some(s) => s,
|
|
167
|
-
None => {
|
|
168
|
-
eprintln!("[plugin-host] host did not connect within {READY_TIMEOUT:?} — killing, no plugins");
|
|
169
|
-
let _ = child.kill();
|
|
170
|
-
let _ = std::fs::remove_file(&socket_path);
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
};
|
|
174
|
-
let writer = stream.try_clone().expect("clone unix stream");
|
|
175
|
-
let reader = stream;
|
|
176
|
-
|
|
177
|
-
// Writer thread: drain the outbound channel → the socket.
|
|
178
|
-
let (out_tx, out_rx): (Sender<String>, Receiver<String>) = mpsc::channel();
|
|
179
|
-
{
|
|
180
|
-
let mut w = writer;
|
|
181
|
-
std::thread::spawn(move || {
|
|
182
|
-
for line in out_rx {
|
|
183
|
-
if w.write_all(line.as_bytes()).and_then(|_| w.write_all(b"\n")).is_err() {
|
|
184
|
-
break;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
*OUT.lock().unwrap() = Some(out_tx.clone());
|
|
190
|
-
|
|
191
|
-
let alive = Arc::new(AtomicBool::new(true));
|
|
192
|
-
let pending: Arc<Mutex<HashMap<String, Sender<Value>>>> = Arc::new(Mutex::new(HashMap::new()));
|
|
193
|
-
|
|
194
|
-
// Reader thread: first `ready` unblocks init; then route ops (→ main thread) + resolve results.
|
|
195
|
-
let (ready_tx, ready_rx): (SyncSender<Option<Vec<String>>>, Receiver<Option<Vec<String>>>) =
|
|
196
|
-
mpsc::sync_channel(1);
|
|
197
|
-
let r_alive = alive.clone();
|
|
198
|
-
let r_pending = pending.clone();
|
|
199
|
-
let r_out = out_tx.clone();
|
|
200
|
-
let r_app = app.clone();
|
|
201
|
-
std::thread::spawn(move || {
|
|
202
|
-
let mut reader = BufReader::new(reader);
|
|
203
|
-
let mut got_ready = false;
|
|
204
|
-
let mut line = String::new();
|
|
205
|
-
loop {
|
|
206
|
-
line.clear();
|
|
207
|
-
match reader.read_line(&mut line) {
|
|
208
|
-
Ok(0) | Err(_) => break,
|
|
209
|
-
Ok(_) => {}
|
|
210
|
-
}
|
|
211
|
-
let trimmed = line.trim();
|
|
212
|
-
if trimmed.is_empty() {
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
match parse_line(trimmed) {
|
|
216
|
-
Line::Ready(methods) => {
|
|
217
|
-
if !got_ready {
|
|
218
|
-
got_ready = true;
|
|
219
|
-
let _ = ready_tx.send(Some(methods));
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
Line::Op { env } => dispatch_op(&r_app, &r_out, env),
|
|
223
|
-
Line::Result { id, value } => {
|
|
224
|
-
if let Some(tx) = r_pending.lock().unwrap().remove(&id) {
|
|
225
|
-
let _ = tx.send(value);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
Line::Other => eprintln!("[plugin-host] ignoring line: {trimmed}"),
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
r_alive.store(false, Ordering::SeqCst);
|
|
232
|
-
r_pending.lock().unwrap().clear();
|
|
233
|
-
*OUT.lock().unwrap() = None;
|
|
234
|
-
if !got_ready {
|
|
235
|
-
let _ = ready_tx.send(None);
|
|
236
|
-
}
|
|
237
|
-
eprintln!("[plugin-host] reader exited — host marked dead");
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
let methods = match ready_rx.recv_timeout(READY_TIMEOUT) {
|
|
241
|
-
Ok(Some(m)) => filter_claims(m),
|
|
242
|
-
_ => {
|
|
243
|
-
eprintln!("[plugin-host] no `ready` within {READY_TIMEOUT:?} — killing, no plugins");
|
|
244
|
-
let _ = child.kill();
|
|
245
|
-
let _ = std::fs::remove_file(&socket_path);
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
};
|
|
249
|
-
eprintln!("[plugin-host] ready — {} handler method(s): {:?}", methods.len(), methods);
|
|
250
|
-
let _ = HOST.set(PluginHost {
|
|
251
|
-
methods: methods.into_iter().collect(),
|
|
252
|
-
alive,
|
|
253
|
-
out: out_tx,
|
|
254
|
-
pending,
|
|
255
|
-
next_id: AtomicU64::new(1),
|
|
256
|
-
child: Mutex::new(child),
|
|
257
|
-
socket_path,
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/// Accept one connection, polling in non-blocking mode until `timeout`.
|
|
262
|
-
fn accept_with_timeout(listener: &UnixListener, timeout: Duration) -> Option<UnixStream> {
|
|
263
|
-
listener.set_nonblocking(true).ok()?;
|
|
264
|
-
let deadline = std::time::Instant::now() + timeout;
|
|
265
|
-
loop {
|
|
266
|
-
match listener.accept() {
|
|
267
|
-
Ok((s, _)) => {
|
|
268
|
-
s.set_nonblocking(false).ok();
|
|
269
|
-
return Some(s);
|
|
270
|
-
}
|
|
271
|
-
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
|
272
|
-
if std::time::Instant::now() >= deadline {
|
|
273
|
-
return None;
|
|
274
|
-
}
|
|
275
|
-
std::thread::sleep(Duration::from_millis(10));
|
|
276
|
-
}
|
|
277
|
-
Err(_) => return None,
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// ── Events: shell → host (window/page lifecycle). Fire-and-forget via the live OUT sink. ──────────
|
|
283
|
-
|
|
284
|
-
/// Emit a `window-created` event so the host can fan out to plugins whose `attachTo` matches. Called
|
|
285
|
-
/// from main.rs once a window's webview exists.
|
|
286
|
-
pub fn on_window_created(label: &str, url: &str) {
|
|
287
|
-
emit_event("window-created", label, json!({ "url": url }));
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/// Emit a `window-closed` event (plugin `onClose` + attach teardown).
|
|
291
|
-
pub fn on_window_closed(label: &str) {
|
|
292
|
-
emit_event("window-closed", label, json!({}));
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
fn emit_event(method: &str, window_id: &str, params: Value) {
|
|
296
|
-
if let Some(tx) = OUT.lock().unwrap().as_ref() {
|
|
297
|
-
let _ = tx.send(json!({ "kind": "event", "method": method, "windowId": window_id, "params": params }).to_string());
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// ── Ops: host → shell (a plugin's WindowCtx call). Dispatched on the main thread, answered `result`. ──
|
|
302
|
-
|
|
303
|
-
fn dispatch_op(app: &AppHandle, out: &Sender<String>, env: Value) {
|
|
304
|
-
let id = env["id"].as_str().unwrap_or_default().to_string();
|
|
305
|
-
let window_id = env["windowId"].as_str().unwrap_or_default().to_string();
|
|
306
|
-
let method = env["method"].as_str().unwrap_or_default().to_string();
|
|
307
|
-
let params = env["params"].clone();
|
|
308
|
-
let app = app.clone();
|
|
309
|
-
let out = out.clone();
|
|
310
|
-
let out_err = out.clone();
|
|
311
|
-
let id_err = id.clone();
|
|
312
|
-
let r = app.clone().run_on_main_thread(move || {
|
|
313
|
-
run_op_on_main(&app, &out, &id, &window_id, &method, ¶ms);
|
|
314
|
-
});
|
|
315
|
-
if let Err(e) = r {
|
|
316
|
-
respond(&out_err, &id_err, Err(format!("dispatch failed: {e}")));
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/// Resolve the target window's `*mut WKWebView` (profile window OR the main tauri window) and hand it
|
|
321
|
-
/// to `f` on the main thread. Returns Err if the window is unknown. Mirrors bridge_mac's dual path.
|
|
322
|
-
fn on_webview<F>(app: &AppHandle, label: &str, f: F) -> Result<(), String>
|
|
323
|
-
where
|
|
324
|
-
F: FnOnce(*mut WKWebView) + Send + 'static,
|
|
325
|
-
{
|
|
326
|
-
if let Some(wk) = wv::profile_webview(label) {
|
|
327
|
-
f(Retained::as_ptr(&wk) as *mut WKWebView);
|
|
328
|
-
return Ok(());
|
|
329
|
-
}
|
|
330
|
-
match app.get_webview_window(label) {
|
|
331
|
-
Some(w) => w
|
|
332
|
-
.with_webview(move |pw| f(pw.inner() as *mut WKWebView))
|
|
333
|
-
.map_err(|e| e.to_string()),
|
|
334
|
-
None => Err(format!("no window {label}")),
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
fn run_op_on_main(app: &AppHandle, out: &Sender<String>, id: &str, label: &str, method: &str, params: &Value) {
|
|
339
|
-
let js = params["js"].as_str().unwrap_or_default().to_string();
|
|
340
|
-
let url = params["url"].as_str().unwrap_or_default().to_string();
|
|
341
|
-
match method {
|
|
342
|
-
"injectScript" => {
|
|
343
|
-
let res = on_webview(app, label, move |wk| unsafe { wv::inject_script(wk, &js) });
|
|
344
|
-
respond(out, id, res.map(|_| json!({ "ok": true })));
|
|
345
|
-
}
|
|
346
|
-
"postMessage" => {
|
|
347
|
-
// plugin→page: deliver as a `window` `appwrap:plugin` CustomEvent whose detail is the msg.
|
|
348
|
-
let payload = serde_json::to_string(¶ms["msg"]).unwrap_or_else(|_| "null".into());
|
|
349
|
-
let script = format!(
|
|
350
|
-
"window.dispatchEvent(new CustomEvent('appwrap:plugin',{{detail:{payload}}}));"
|
|
351
|
-
);
|
|
352
|
-
let res = on_webview(app, label, move |wk| unsafe { wv::inject_script(wk, &script) });
|
|
353
|
-
respond(out, id, res.map(|_| json!({ "ok": true })));
|
|
354
|
-
}
|
|
355
|
-
"navigate" => {
|
|
356
|
-
let res = on_webview(app, label, move |wk| unsafe { let _ = wv::navigate(wk, &url); });
|
|
357
|
-
respond(out, id, res.map(|_| json!({ "ok": true })));
|
|
358
|
-
}
|
|
359
|
-
"back" => respond(out, id, on_webview(app, label, |wk| unsafe { wv::go_back(wk) }).map(|_| json!({ "ok": true }))),
|
|
360
|
-
"forward" => respond(out, id, on_webview(app, label, |wk| unsafe { wv::go_forward(wk) }).map(|_| json!({ "ok": true }))),
|
|
361
|
-
"reload" => respond(out, id, on_webview(app, label, |wk| unsafe { wv::reload(wk) }).map(|_| json!({ "ok": true }))),
|
|
362
|
-
"setInspectable" => respond(out, id, on_webview(app, label, |wk| unsafe { wv::set_inspectable(wk) }).map(|_| json!({ "ok": true }))),
|
|
363
|
-
"url" => {
|
|
364
|
-
let out2 = out.clone();
|
|
365
|
-
let id2 = id.to_string();
|
|
366
|
-
let res = on_webview(app, label, move |wk| unsafe {
|
|
367
|
-
wv::eval_async(wk, "return location.href".into(), move |r| {
|
|
368
|
-
respond(&out2, &id2, r.map(Value::String).map_err(|e| e));
|
|
369
|
-
});
|
|
370
|
-
});
|
|
371
|
-
if let Err(e) = res { respond(out, id, Err(e)); }
|
|
372
|
-
}
|
|
373
|
-
"eval" => {
|
|
374
|
-
let out2 = out.clone();
|
|
375
|
-
let id2 = id.to_string();
|
|
376
|
-
let js2 = js.clone();
|
|
377
|
-
let res = on_webview(app, label, move |wk| unsafe {
|
|
378
|
-
wv::eval_async(wk, js2, move |r| {
|
|
379
|
-
// The crate delivers a RAW JSON string; parse it so the plugin gets a real value.
|
|
380
|
-
respond(&out2, &id2, r.map(|s| serde_json::from_str(&s).unwrap_or(Value::String(s))));
|
|
381
|
-
});
|
|
382
|
-
});
|
|
383
|
-
if let Err(e) = res { respond(out, id, Err(e)); }
|
|
384
|
-
}
|
|
385
|
-
"snapshot" => {
|
|
386
|
-
let out2 = out.clone();
|
|
387
|
-
let id2 = id.to_string();
|
|
388
|
-
let res = on_webview(app, label, move |wk| unsafe {
|
|
389
|
-
wv::snapshot(wk, move |r| {
|
|
390
|
-
respond(&out2, &id2, r.map(|bytes| json!(bytes)));
|
|
391
|
-
});
|
|
392
|
-
});
|
|
393
|
-
if let Err(e) = res { respond(out, id, Err(e)); }
|
|
394
|
-
}
|
|
395
|
-
"close" => {
|
|
396
|
-
let ok = wv::close(label);
|
|
397
|
-
if ok {
|
|
398
|
-
respond(out, id, Ok(json!({ "ok": true })));
|
|
399
|
-
} else if let Some(w) = app.get_webview_window(label) {
|
|
400
|
-
respond(out, id, w.close().map(|_| json!({ "ok": true })).map_err(|e| e.to_string()));
|
|
401
|
-
} else {
|
|
402
|
-
respond(out, id, Err(format!("no window {label}")));
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
"subscribeMessages" => {
|
|
406
|
-
// Install the bridgeShim WKScriptMessageHandler → page beacons become `message` events.
|
|
407
|
-
let out2 = out.clone();
|
|
408
|
-
let label2 = label.to_string();
|
|
409
|
-
let res = on_webview(app, label, move |wk| {
|
|
410
|
-
let mtm = MainThreadMarker::new().expect("run_on_main_thread is main");
|
|
411
|
-
let sink_out = out2.clone();
|
|
412
|
-
let sink_label = label2.clone();
|
|
413
|
-
let sink: wv::MessageSink = Box::new(move |raw: String| {
|
|
414
|
-
let _ = sink_out.send(
|
|
415
|
-
json!({ "kind": "event", "method": "message", "windowId": sink_label, "params": { "raw": raw } }).to_string(),
|
|
416
|
-
);
|
|
417
|
-
});
|
|
418
|
-
unsafe { wv::install_message_handler(wk, mtm, sink) };
|
|
419
|
-
});
|
|
420
|
-
respond(out, id, res.map(|_| json!({ "ok": true })));
|
|
421
|
-
}
|
|
422
|
-
_ => respond(out, id, Err(format!("unknown op: {method}"))),
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
fn respond(out: &Sender<String>, id: &str, result: Result<Value, String>) {
|
|
427
|
-
let msg = match result {
|
|
428
|
-
Ok(data) => json!({ "kind": "result", "id": id, "ok": true, "data": data }),
|
|
429
|
-
Err(e) => json!({ "kind": "result", "id": id, "ok": false, "error": { "code": "NATIVE_ERROR", "message": e } }),
|
|
430
|
-
};
|
|
431
|
-
let _ = out.send(msg.to_string());
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// ── Handler RPC: shell → host (sidecar-compatible `call`/`result`). ───────────────────────────────
|
|
435
|
-
|
|
436
|
-
/// True if the host advertised this handler method (routed AFTER the sidecar in appwrap_invoke).
|
|
437
|
-
pub fn claims(method: &str) -> bool {
|
|
438
|
-
HOST.get().is_some_and(|h| h.methods.contains(method))
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/// Forward a claimed handler method to the host and block on its response (≤30s).
|
|
442
|
-
pub fn request(method: &str, params: &Value) -> PluginResult {
|
|
443
|
-
let h = HOST.get().ok_or_else(|| ("NATIVE_ERROR".to_string(), "no plugin host".to_string()))?;
|
|
444
|
-
if !h.alive.load(Ordering::SeqCst) {
|
|
445
|
-
return Err(("NATIVE_ERROR".into(), format!("plugin host is not running ({method})")));
|
|
446
|
-
}
|
|
447
|
-
let id = format!("ph-{}", h.next_id.fetch_add(1, Ordering::SeqCst));
|
|
448
|
-
let (tx, rx) = mpsc::channel::<Value>();
|
|
449
|
-
h.pending.lock().unwrap().insert(id.clone(), tx);
|
|
450
|
-
let env = json!({ "kind": "call", "id": id, "method": method, "params": params });
|
|
451
|
-
if h.out.send(env.to_string()).is_err() {
|
|
452
|
-
h.pending.lock().unwrap().remove(&id);
|
|
453
|
-
return Err(("NATIVE_ERROR".into(), "plugin host write failed".into()));
|
|
454
|
-
}
|
|
455
|
-
match rx.recv_timeout(CALL_TIMEOUT) {
|
|
456
|
-
Ok(resp) => {
|
|
457
|
-
if resp["ok"].as_bool() == Some(true) {
|
|
458
|
-
Ok(resp.get("data").cloned().unwrap_or(Value::Null))
|
|
459
|
-
} else {
|
|
460
|
-
let code = resp["error"]["code"].as_str().unwrap_or("NATIVE_ERROR").to_string();
|
|
461
|
-
let message = resp["error"]["message"].as_str().unwrap_or("handler error").to_string();
|
|
462
|
-
Err((code, message))
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
Err(mpsc::RecvTimeoutError::Timeout) => {
|
|
466
|
-
h.pending.lock().unwrap().remove(&id);
|
|
467
|
-
Err(("NATIVE_ERROR".into(), format!("plugin host timeout ({method})")))
|
|
468
|
-
}
|
|
469
|
-
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
|
470
|
-
Err(("NATIVE_ERROR".into(), format!("plugin host died during call ({method})")))
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
/// Kill the host child + remove the socket file (idempotent). Called on main-window teardown.
|
|
476
|
-
pub fn shutdown() {
|
|
477
|
-
if let Some(h) = HOST.get() {
|
|
478
|
-
let _ = h.child.lock().unwrap().kill();
|
|
479
|
-
let _ = std::fs::remove_file(&h.socket_path);
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
#[cfg(test)]
|
|
484
|
-
mod tests {
|
|
485
|
-
use super::*;
|
|
486
|
-
|
|
487
|
-
#[test]
|
|
488
|
-
fn parse_ready_flattens_all_plugin_methods() {
|
|
489
|
-
match parse_line(r#"{"kind":"ready","plugins":[{"pluginId":"a","methods":["a.x"]},{"pluginId":"b","methods":["b.y","b.z"]}]}"#) {
|
|
490
|
-
Line::Ready(m) => assert_eq!(m, vec!["a.x".to_string(), "b.y".into(), "b.z".into()]),
|
|
491
|
-
_ => panic!("expected Ready"),
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
#[test]
|
|
496
|
-
fn parse_op_and_result() {
|
|
497
|
-
assert!(matches!(parse_line(r#"{"kind":"op","id":"op-1","method":"url"}"#), Line::Op { .. }));
|
|
498
|
-
match parse_line(r#"{"kind":"result","id":"ph-1","ok":true,"data":5}"#) {
|
|
499
|
-
Line::Result { id, value } => { assert_eq!(id, "ph-1"); assert_eq!(value["data"], 5); }
|
|
500
|
-
_ => panic!("expected Result"),
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
#[test]
|
|
505
|
-
fn parse_malformed_is_other() {
|
|
506
|
-
assert!(matches!(parse_line("not json"), Line::Other));
|
|
507
|
-
assert!(matches!(parse_line(r#"{"kind":"result","ok":true}"#), Line::Other)); // no id
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
#[test]
|
|
511
|
-
fn filter_claims_rejects_builtin_shadowing() {
|
|
512
|
-
let kept = filter_claims(vec!["storage.get".into(), "myplugin.doThing".into()]);
|
|
513
|
-
assert_eq!(kept, vec!["myplugin.doThing".to_string()]);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
#[test]
|
|
517
|
-
fn resolve_runtime_prefers_existing_stamped_path() {
|
|
518
|
-
assert_eq!(resolve_runtime("/bin/ls"), "/bin/ls");
|
|
519
|
-
assert_eq!(resolve_runtime("/nonexistent/bun-xyz"), "bun");
|
|
520
|
-
assert_eq!(resolve_runtime(""), "bun");
|
|
521
|
-
}
|
|
522
|
-
}
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
// popup_mac moved into the `webview-control` crate (Phase 1 extraction) — this is a re-export shim
|
|
2
|
-
// so existing call sites (`crate::popup_mac::install` / `install_for_profile` / `close_all` /
|
|
3
|
-
// `SAFARI_UA` in main.rs + bridge_mac.rs) resolve unchanged. Edit the source in
|
|
4
|
-
// crates/webview-control/src/popup.rs, not here.
|
|
5
|
-
pub use webview_control::popup::*;
|