@livx.cc/appwrap 0.46.4 → 0.47.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.46.4",
3
+ "version": "0.47.1",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -31,6 +31,7 @@
31
31
  ".": "./src/config.ts",
32
32
  "./config": "./src/config.ts",
33
33
  "./handlers": "./src/handlers.ts",
34
+ "./plugin": "./src/plugin/index.ts",
34
35
  "./cli": "./src/cli.ts"
35
36
  },
36
37
  "files": [
@@ -248,6 +248,25 @@ fn main() {
248
248
  println!("session survived (marker 77): {session_ok}");
249
249
  println!("bridge survived (pings {:?}): {bridge_ok}", pings);
250
250
 
251
+ // ---- IDEMPOTENT DOUBLE-INSTALL (bridgeShim) ----
252
+ // Simulate a SECOND plugin attaching onMessage to the SAME window. Before the fix, a second
253
+ // addScriptMessageHandler(name:"bridgeShim") on the same UCC threw NSInvalidArgumentException =
254
+ // hard crash. install_message_handler now does remove-then-add, so this must NOT crash and the
255
+ // bridge must still route to the newly-installed sink. Runs AFTER the original-sink assertions
256
+ // above (re-install replaces the sink, which is fine from here on).
257
+ println!("\n== IDEMPOTENCY: re-install bridgeShim on the live webview (second-plugin attach)");
258
+ let (tx2, rx2): (_, Receiver<String>) = channel();
259
+ let sink2 = Box::new(move |raw: String| { let _ = tx2.send(raw); });
260
+ if let Err(e) = ctrl.reinstall_message_handler(mtm, id, sink2) {
261
+ eprintln!("FAIL: reinstall_message_handler errored: {e}");
262
+ std::process::exit(1);
263
+ }
264
+ let _ = eval(&ctrl, mtm, id, "webkit.messageHandlers.bridgeShim.postMessage('PING-REINSTALL'); return 'posted';");
265
+ pump(0.5);
266
+ let reinstall_pings: Vec<String> = rx2.try_iter().collect();
267
+ let idempotent_ok = reinstall_pings.iter().any(|p| p == "PING-REINSTALL");
268
+ println!(" no crash on double-install; post-reinstall pings {reinstall_pings:?} (routed={idempotent_ok})");
269
+
251
270
  // ---- NAV ops (go_back / go_forward / reload via Controller) ----
252
271
  // The tab is docked ChildOf(pane) on page 1. Navigate to a distinct page 2, then walk history.
253
272
  let page1_href = href(&ctrl, mtm, id);
@@ -321,7 +340,12 @@ fn main() {
321
340
 
322
341
  println!("pane empty after close (no zombie webview): {pane_empty_ok}");
323
342
 
324
- if page_ok && session_ok && bridge_ok && pane_empty_ok && nav_ok && profile_swap_ok {
343
+ println!("bridge idempotent re-install (no crash, routes): {idempotent_ok}");
344
+ if !idempotent_ok {
345
+ eprintln!("\nFAIL: bridge did not route after idempotent double-install of bridgeShim.");
346
+ }
347
+
348
+ if page_ok && session_ok && bridge_ok && pane_empty_ok && nav_ok && profile_swap_ok && idempotent_ok {
325
349
  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.");
326
350
  } else {
327
351
  if !nav_ok { eprintln!("\nFAIL: nav ops (go_back/go_forward/reload) did not land as expected."); }
@@ -115,6 +115,23 @@ impl Controller {
115
115
  backend::list_webviews()
116
116
  }
117
117
 
118
+ /// Re-install the bridgeShim message handler on an existing webview's UCC. Mirrors a second
119
+ /// plugin attaching `onMessage` to the same window. Idempotent (remove-then-add) — proves the
120
+ /// double-install crash (NSInvalidArgumentException) is gone. Test/harness surface only.
121
+ #[doc(hidden)]
122
+ pub fn reinstall_message_handler(
123
+ &self,
124
+ mtm: MainThreadMarker,
125
+ id: &str,
126
+ on_message: crate::MessageSink,
127
+ ) -> Result<(), String> {
128
+ let wk = self.webview(id)?;
129
+ unsafe {
130
+ backend::install_message_handler(Retained::as_ptr(&wk) as *mut WKWebView, mtm, on_message)
131
+ };
132
+ Ok(())
133
+ }
134
+
118
135
  fn webview(&self, id: &str) -> Result<Retained<WKWebView>, String> {
119
136
  backend::profile_webview(id).ok_or_else(|| format!("no webview {id}"))
120
137
  }
@@ -127,6 +127,11 @@ pub unsafe fn install_message_handler(wk: *mut WKWebView, mtm: MainThreadMarker,
127
127
  let handler = MsgHandler::alloc(mtm).set_ivars(MsgHandlerIvars { on_message });
128
128
  let handler: Retained<MsgHandler> = msg_send![super(handler), init];
129
129
  let ucc = wk.configuration().userContentController();
130
+ // Idempotent per UCC: WKUserContentController throws NSInvalidArgumentException if a handler is
131
+ // already registered for a name. A window can already carry `bridgeShim` (a second plugin
132
+ // attaching onMessage, a browser tab, or the env-gated bridge). removeScriptMessageHandlerForName
133
+ // is safe when none is present, so remove-then-add makes re-install a no-op instead of a crash.
134
+ ucc.removeScriptMessageHandlerForName(&NSString::from_str("bridgeShim"));
130
135
  ucc.addScriptMessageHandler_name(
131
136
  ProtocolObject::from_ref(&*handler),
132
137
  &NSString::from_str("bridgeShim"),
@@ -23,6 +23,8 @@ mod notifications_mac;
23
23
  #[cfg(target_os = "macos")]
24
24
  mod oauth_mac;
25
25
  #[cfg(target_os = "macos")]
26
+ mod plugin_host;
27
+ #[cfg(target_os = "macos")]
26
28
  mod popup_mac;
27
29
  #[cfg(target_os = "macos")]
28
30
  mod push_mac;
@@ -107,6 +109,14 @@ struct ShellConfig {
107
109
  /// bare `bun` lookup would fail there; sidecar.rs falls back to `bun` when this is empty/gone.
108
110
  #[serde(default)]
109
111
  handlers_runtime: String,
112
+ /// Absolute path to the bun-built multiplexed plugin-host bundle (src/plugin/host.ts). Empty →
113
+ /// no plugins configured; the shell skips spawning the host. Stamped by `regeneratePlugins`.
114
+ #[serde(default)]
115
+ plugin_host: String,
116
+ /// Resolved + bun-built plugins the host loads at boot (`[{namespace, attachTo, bundlePath}]`),
117
+ /// passed through to the host process verbatim. Empty when none.
118
+ #[serde(default)]
119
+ plugins: Vec<serde_json::Value>,
110
120
  /// True when the CLI resolved a real signing identity + macOS provisioning profile for this
111
121
  /// build — i.e. the .app carries the aps-environment entitlement, so APNs registration can
112
122
  /// actually succeed. Gates push.register and the handshake's push capability (adhoc → 'none').
@@ -595,6 +605,20 @@ fn appwrap_invoke(window: tauri::WebviewWindow, envelope: String) {
595
605
  deliver(&window, &resp);
596
606
  return;
597
607
  }
608
+ // Plugin handler methods (cfg.plugins): the sidecar-compatible RPC path, routed AFTER the
609
+ // sidecar so a `desktop.handlers` claim always wins for the same name. Same envelope shape.
610
+ #[cfg(target_os = "macos")]
611
+ if plugin_host::claims(&method) {
612
+ let resp = match plugin_host::request(&method, &params) {
613
+ Ok(result) => json!({ "v": 1, "id": id, "kind": "response", "result": result }),
614
+ Err((code, message)) => json!({
615
+ "v": 1, "id": id, "kind": "response",
616
+ "error": { "code": code, "message": message }
617
+ }),
618
+ };
619
+ deliver(&window, &resp);
620
+ return;
621
+ }
598
622
  let resp = match handle(&method, &params) {
599
623
  Ok(result) => json!({ "v": 1, "id": id, "kind": "response", "result": result }),
600
624
  Err((code, message)) => json!({
@@ -714,6 +738,9 @@ fn main() {
714
738
  popup_mac::close_all();
715
739
  // Kill the handlers sidecar so the Bun process never outlives the shell.
716
740
  sidecar::shutdown();
741
+ // Tell plugins the main window is gone, then kill the plugin host (never outlives the shell).
742
+ plugin_host::on_window_closed("main");
743
+ plugin_host::shutdown();
717
744
  // NB: server teardown runs on RunEvent::Exit (below), NOT here — a blocking kill in
718
745
  // this close handler would starve tauri-plugin-window-state's own save-on-close.
719
746
  }
@@ -801,6 +828,20 @@ fn main() {
801
828
  if !shell().handlers.is_empty() {
802
829
  sidecar::init(&shell().handlers, &shell().handlers_runtime);
803
830
  }
831
+ // Multiplexed plugin host (cfg.plugins): spawn the Bun host over a Unix socket and block
832
+ // (≤5s) on its `ready` before the window's first invoke can race a plugin handler. Additive
833
+ // to the sidecar — inert when no plugins are configured. Reuses handlersRuntime (the same
834
+ // stamped bun binary). See plugin_host.rs.
835
+ #[cfg(target_os = "macos")]
836
+ if !shell().plugin_host.is_empty() && !shell().plugins.is_empty() {
837
+ let plugins_json = serde_json::to_string(&shell().plugins).unwrap_or_else(|_| "[]".into());
838
+ plugin_host::init(
839
+ app.handle().clone(),
840
+ &shell().plugin_host,
841
+ &shell().handlers_runtime,
842
+ &plugins_json,
843
+ );
844
+ }
804
845
  // Launch-on-login: reconcile the login item to the config every launch, so turning
805
846
  // `autostart` off (or on) takes effect on the next run. Best-effort — a failure to
806
847
  // enable/disable the LaunchAgent shouldn't abort startup.
@@ -844,6 +885,10 @@ fn main() {
844
885
  let _ = window.show();
845
886
  }
846
887
  }
888
+ // Per-window plugin attach: fan out `window-created` to the plugin host so plugins whose
889
+ // `attachTo` matches "main" run their `onWindow(ctx)`. No-op when no host is running.
890
+ #[cfg(target_os = "macos")]
891
+ plugin_host::on_window_created("main", "index.html");
847
892
  // Local-server mode: boot the server child now (the window shows the bundled splash), then a
848
893
  // background thread polls its port and navigates the window to it — or, on timeout/spawn
849
894
  // failure, writes the reason into the splash. Absent `server` → the bundled dist just loads.
@@ -0,0 +1,522 @@
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, &params);
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(&params["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
+ }
package/src/cli.ts CHANGED
@@ -685,6 +685,51 @@ function selectSigningProfile(
685
685
  return newest.name;
686
686
  }
687
687
 
688
+ /** True if the persisted iOS project carries Manual / App-Store (match/fastlane) signing residue —
689
+ * `CODE_SIGN_STYLE = Manual` or a `PROVISIONING_PROFILE_SPECIFIER`. `appwrap release/publish`
690
+ * (fastlane `update_code_signing_settings`) writes these DIRECTLY into project.pbxproj, which NS
691
+ * PRESERVES across prepares and which OVERRIDES build.xcconfig — so a later device `deploy`/`dev`
692
+ * (auto lane) would inherit App-Store distribution signing and fail to install on a device. */
693
+ export function pbxHasManualSigningResidue(pbxSrc: string): boolean {
694
+ return /^\s*CODE_SIGN_STYLE\s*=\s*Manual\s*;/m.test(pbxSrc)
695
+ || /^\s*PROVISIONING_PROFILE_SPECIFIER\s*=/m.test(pbxSrc);
696
+ }
697
+
698
+ /** Rewrite App-Store/manual signing residue in a project.pbxproj back to Xcode AUTOMATIC signing:
699
+ * Manual→Automatic, drop match/App-Store profile pins (PROVISIONING_PROFILE_SPECIFIER + UUID form),
700
+ * and Apple Distribution→Apple Development (plain + `[sdk=…]` variants). DEVELOPMENT_TEAM is left as
701
+ * is (correct for the current app). Pure + idempotent — a clean project passes through unchanged. */
702
+ export function resetPbxToAutomaticSigning(pbxSrc: string): string {
703
+ let s = pbxSrc;
704
+ s = s.replace(/(^\s*CODE_SIGN_STYLE\s*=\s*)Manual(\s*;)/mg, '$1Automatic$2');
705
+ s = s.replace(/^[ \t]*PROVISIONING_PROFILE_SPECIFIER\s*=\s*[^;\n]*;[ \t]*\n?/mg, '');
706
+ s = s.replace(/^[ \t]*PROVISIONING_PROFILE\s*=\s*[^;\n]*;[ \t]*\n?/mg, '');
707
+ s = s.replace(/("?CODE_SIGN_IDENTITY(?:\[[^\]]*\])?"?\s*=\s*)"?Apple Distribution"?(\s*;)/mg, '$1"Apple Development"$2');
708
+ return s;
709
+ }
710
+
711
+ /** Self-heal for the auto lane (`deploy`/`dev`, signing≠manual): if a prior `release`/`publish` left
712
+ * Manual/App-Store signing in platforms/ios/project.pbxproj (fastlane `update_code_signing_settings`,
713
+ * which NS preserves across prepares + which OVERRIDES build.xcconfig), rewrite it back to automatic
714
+ * development signing IN PLACE. Surgical (no rebuild, deterministic — unlike wiping the platform dir,
715
+ * which fights the build-cache skip). Keeps device installs working across lane/app switches; a normal
716
+ * deploy with no residue is a no-op. */
717
+ function resetStaleSigningForAutoLane(outDir: string, cfg: AppwrapConfig): void {
718
+ const mode = process.env.APPWRAP_SIGNING?.trim() || cfg.signing;
719
+ if (mode === 'manual') return;
720
+ const iosDir = join(outDir, 'platforms', 'ios');
721
+ if (!existsSync(iosDir)) return;
722
+ let proj: string | undefined;
723
+ try { proj = readdirSync(iosDir).find((d) => d.endsWith('.xcodeproj') && d !== 'Pods.xcodeproj'); } catch { return; }
724
+ if (!proj) return;
725
+ const pbx = join(iosDir, proj, 'project.pbxproj');
726
+ if (!existsSync(pbx)) return;
727
+ const src = readFileSync(pbx, 'utf8');
728
+ if (!pbxHasManualSigningResidue(src)) return;
729
+ writeFileSync(pbx, resetPbxToAutomaticSigning(src));
730
+ console.log(' ⚠ cleared stale manual/App-Store signing (from a prior `release`/`publish`) → automatic dev signing for this device build.');
731
+ }
732
+
688
733
  /** Manual code-signing for device builds (`signing: 'manual'`). Resolves the app + each extension
689
734
  * target to an installed provisioning profile (by `<teamId>.<bundleId>`) and stamps Manual signing
690
735
  * into build.xcconfig (main app) + each extension's extension.json targetBuildConfigurationProperties.
@@ -2033,6 +2078,71 @@ flex-direction:column;gap:20px;background:#0b0b0f;color:#e5e5e5;font:14px -apple
2033
2078
  /** Copy the desktop template into `native-desktop/` and stamp it from the config: the Rust-read
2034
2079
  * `shell_config.json` (identity + window), `tauri.conf.json` (productName/version/identifier +
2035
2080
  * frontendDist), and the staged web dist (`native-desktop/dist`, referenced as `../dist`). */
2081
+ /**
2082
+ * Make `cfg.plugins` LIVE (A.1 loader): resolve each entry (path OR npm package), bun-build its
2083
+ * entrypoint to a single embedded file under `native-desktop/plugins/`, bun-build the multiplexed
2084
+ * plugin-host once, and stamp `{ pluginHost, plugins:[{bundleId, attachTo, bundlePath}] }` into the
2085
+ * shell config. The Rust shell (plugin_host.rs) spawns the host with the stamped plugins at boot.
2086
+ *
2087
+ * A.1 scope: manifest/perms/native-deps merge is STUBBED (noted) — full `capabilities.manifest.ts`
2088
+ * reuse (perms/entitlements union) lands later. `bundleId` here is the file/package basename (a
2089
+ * pre-load build id for the bundle filename only); the authoritative routing/diagnostic identifier is
2090
+ * the plugin def's `name`, which the host reads from the bundle after `import()`.
2091
+ */
2092
+ export function regeneratePlugins(cwd: string, cfg: AppwrapConfig, outDir: string, shell: DesktopShellConfig): void {
2093
+ const entries = cfg.plugins ?? [];
2094
+ if (entries.length === 0) return;
2095
+ const outPlugins = join(outDir, 'plugins');
2096
+ mkdirSync(outPlugins, { recursive: true });
2097
+ const bun = process.execPath; // the CLI runs under bun → the exact runtime to build/spawn with
2098
+
2099
+ const bunBuild = (entrypoint: string, outfile: string): boolean => {
2100
+ try {
2101
+ execFileSync(bun, ['build', entrypoint, '--target=bun', '--outfile', outfile], { stdio: 'pipe' });
2102
+ return true;
2103
+ } catch (e: unknown) {
2104
+ console.warn(`⚠ plugin bun-build failed (${entrypoint}): ${e instanceof Error ? e.message : String(e)}`);
2105
+ return false;
2106
+ }
2107
+ };
2108
+
2109
+ // Build the host bundle once (from THIS package's src/plugin/host.ts).
2110
+ const hostSrc = resolve(import.meta.dir, 'plugin', 'host.ts');
2111
+ const hostOut = join(outPlugins, 'host.js');
2112
+ if (!bunBuild(hostSrc, hostOut)) {
2113
+ console.warn('⚠ plugin host failed to build — plugins disabled for this build.');
2114
+ return;
2115
+ }
2116
+
2117
+ const stamped: DesktopShellConfig['plugins'] = [];
2118
+ for (const raw of entries) {
2119
+ const name = typeof raw === 'string' ? raw : raw.name;
2120
+ const attachTo = typeof raw === 'string' ? undefined : raw.attachTo;
2121
+ // Resolve: an existing path (relative to the app root) wins; else treat as an npm package name.
2122
+ let entrypoint = resolve(cwd, name);
2123
+ if (!existsSync(entrypoint)) {
2124
+ try {
2125
+ entrypoint = (Bun as unknown as { resolveSync(id: string, parent: string): string }).resolveSync(name, cwd);
2126
+ } catch {
2127
+ console.warn(`⚠ plugin "${name}" not found (no such path, not resolvable as an npm package) — skipping.`);
2128
+ continue;
2129
+ }
2130
+ }
2131
+ const bundleId = name.replace(/[^a-zA-Z0-9_-]/g, '_');
2132
+ const bundlePath = join(outPlugins, `${bundleId}.js`);
2133
+ if (!bunBuild(entrypoint, bundlePath)) continue;
2134
+ stamped.push({ bundleId, attachTo, bundlePath });
2135
+ console.log(` plugin ← ${name} (attachTo: ${attachTo ?? 'def-declared'})`);
2136
+ }
2137
+
2138
+ if (stamped.length > 0) {
2139
+ shell.pluginHost = hostOut;
2140
+ shell.plugins = stamped;
2141
+ // TODO(A.x): read each plugin's `manifest` and merge perms/entitlements/native-deps via
2142
+ // generateModuleArtifacts + capabilities.manifest.ts (plugins are manifest-compatible with modules).
2143
+ }
2144
+ }
2145
+
2036
2146
  function regenerateDesktop(cwd: string, cfg: AppwrapConfig, outDir: string, pushSigned = false): void {
2037
2147
  if (!existsSync(DESKTOP_TEMPLATE_DIR)) {
2038
2148
  console.error(`✖ Desktop template not found at ${DESKTOP_TEMPLATE_DIR}`);
@@ -2104,6 +2214,10 @@ function regenerateDesktop(cwd: string, cfg: AppwrapConfig, outDir: string, push
2104
2214
  // the launchd PATH (no ~/.bun/bin), so the Rust shell can't rely on a bare `bun` lookup.
2105
2215
  shell.handlersRuntime = process.execPath;
2106
2216
  }
2217
+ // Resolve + bun-build the configured plugins (make `cfg.plugins` LIVE). Runs BEFORE writing
2218
+ // shell_config (and before applyOverrides, which happens after regenerateDesktop returns) so the
2219
+ // stamped plugin bundles + host are embedded and the .app is self-contained.
2220
+ regeneratePlugins(cwd, cfg, outDir, shell);
2107
2221
  writeFileSync(join(srcTauri, 'shell_config.json'), JSON.stringify(shell, null, 2) + '\n');
2108
2222
 
2109
2223
  const tauriConfPath = join(srcTauri, 'tauri.conf.json');
@@ -3204,6 +3318,9 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
3204
3318
  await sync(cwd, flags, cfgOverride); // re-stamp config + copy latest PWA dist (+ vendor backend assets)
3205
3319
  // Dev deploy → debug mode: keep-awake + WebView inspector for continuous troubleshooting.
3206
3320
  stampShellConfig(outDir, { ...cfg, debug: true });
3321
+ // Self-heal: drop platforms/ios if a prior `release`/`publish` left App-Store/manual signing there
3322
+ // (NS preserves it across prepares + it overrides build.xcconfig) → clean automatic dev signing.
3323
+ resetStaleSigningForAutoLane(outDir, cfg);
3207
3324
 
3208
3325
  const ipaDir = join(outDir, 'platforms/ios/build/Debug-iphoneos');
3209
3326
 
package/src/config.ts CHANGED
@@ -257,9 +257,13 @@ export interface AppwrapConfig {
257
257
  * OVER the generated wrapper after stamping — for legacy/custom native code the declarative config
258
258
  * can't express. Default `'appwrap.overrides'`; applied only if it exists. */
259
259
  overrides?: string;
260
- /** Reserved — appwrap plugins (npm packages contributing a kit module + native handlers + config).
261
- * Parsed today; full native composition lands with the plugin contract (see framework-extensibility). */
262
- plugins?: string[];
260
+ /** appwrap TS plugins (`@livx.cc/appwrap/plugin`). Each entry is an npm package name or a path to a
261
+ * plugin entrypoint (that `export default definePlugin(...)`); the object form adds a config-level
262
+ * `attachTo` override (which windows it attaches to). `appwrap dev|build desktop` resolves + bun-builds
263
+ * each into a single embedded bundle and stamps it into the desktop shell (see `regeneratePlugins`),
264
+ * where the multiplexed plugin host loads them. A.1 skeleton: desktop-only; manifest/perms merge is
265
+ * stubbed (full module-manifest reuse lands later). */
266
+ plugins?: (string | { name: string; attachTo?: 'main' | 'all' | string; options?: unknown })[];
263
267
  /** Opt-in capability allow-list (built-in modules — see capabilities.manifest.ts). When PRESENT,
264
268
  * only the listed capabilities (plus always-on core) are advertised, permissioned, and — for
265
269
  * modules that own their handler file (e.g. health) — compiled into the shell. Their permissions,
package/src/desktop.ts CHANGED
@@ -44,6 +44,11 @@ export interface DesktopShellConfig {
44
44
  * ~/.bun/bin, so a bare `bun` spawn fails. The Rust shell falls back to `bun` when this is
45
45
  * missing/nonexistent. */
46
46
  handlersRuntime: string;
47
+ /** Absolute path to the bun-built multiplexed plugin-host bundle (`src/plugin/host.ts`); '' when no
48
+ * plugins are configured (the Rust shell skips spawning the host). Stamped by `regeneratePlugins`. */
49
+ pluginHost: string;
50
+ /** Resolved + bun-built plugins the host loads at boot. Empty when none. Stamped by `regeneratePlugins`. */
51
+ plugins: { bundleId: string; attachTo?: string; bundlePath: string }[];
47
52
  /** True when `modules` includes 'media' → the .app carries an NSMicrophoneUsageDescription so
48
53
  * `getUserMedia` (voice mode) can pass macOS TCC. The WebKit-layer grant (popup_mac) is
49
54
  * unconditional; this only adds the OS usage string an app that actually uses the mic needs. */
@@ -90,6 +95,8 @@ export function deriveDesktopConfig(cfg: AppwrapConfig): DesktopShellConfig {
90
95
  handlers: d.handlers ?? '',
91
96
  pushSigned: false, // stamped by the CLI when a signing identity + macOS profile resolve — see buildDesktop
92
97
  handlersRuntime: '', // stamped by the CLI (process.execPath) when handlers is set — see regenerateDesktop
98
+ pluginHost: '', // stamped by regeneratePlugins (bun-built host bundle) when plugins are configured
99
+ plugins: [], // stamped by regeneratePlugins (resolved + bun-built plugin bundles)
93
100
  microphone: (cfg.modules ?? []).includes('media'),
94
101
  // `command`/`cwd`/`path` are resolved to absolute by the CLI (regenerateDesktop); carried as authored here.
95
102
  server: d.server
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Multiplexed plugin host — ONE Bun process hosting all configured plugins (Elya decision: multiplexed
3
+ * for first-party). Generalizes `sidecar.rs`'s one-way NDJSON RPC to a BIDIRECTIONAL, per-window model.
4
+ *
5
+ * Boot: `bun host.ts --socket <path> --plugins <json>` — spawned by the Rust shell (plugin_host.rs).
6
+ * It connects to the shell's Unix domain socket, `import()`s each plugin bundle, and:
7
+ * - announces `ready` (per-plugin handler methods, for the sidecar-compatible RPC path),
8
+ * - on a `window-created` event, fans out to each plugin whose `attachTo` matches → `onWindow(ctx)`,
9
+ * - marshals every `WindowCtx` call into an `op` envelope and awaits its `result`,
10
+ * - routes page `message` events to that window's `onMessage` subscribers,
11
+ * - answers `call` (handler RPC) exactly like the sidecar.
12
+ *
13
+ * The socket + framing live only in `main()`; `PluginHost` is transport-decoupled (a `send` sink) so
14
+ * the routing/attach logic is unit-testable with in-memory sinks (mirrors `runHandlers`).
15
+ */
16
+ import type { Dispose, Envelope, PluginDef, StampedPlugin, WindowCtx, WindowIdentity } from './types';
17
+
18
+ /** Reject an outstanding op after this long if the shell never sends its `result` (a native callback
19
+ * that never fires would otherwise leak the plugin's pending promise forever). Mirrors the Rust
20
+ * shell's CALL_TIMEOUT (plugin_host.rs, 30s) for the reverse op direction. */
21
+ const OP_TIMEOUT_MS = 30_000;
22
+
23
+ /** Resolve `attachTo` against a window identity. Config-string override wins over the def's own scope. */
24
+ export function matchScope(def: PluginDef, override: string | undefined, win: WindowIdentity): boolean {
25
+ const scope = override ?? def.attachTo;
26
+ if (scope == null) return false; // handlers-only plugin — no per-window attach
27
+ if (typeof scope === 'function') return scope(win);
28
+ if (scope === 'all') return true;
29
+ if (scope === 'main') return win.id === 'main';
30
+ return scope === win.id;
31
+ }
32
+
33
+ interface Loaded {
34
+ entry: StampedPlugin;
35
+ def: PluginDef;
36
+ }
37
+
38
+ /** Per-(plugin,window) attach state: message subscribers + the onWindow disposer. */
39
+ interface Attach {
40
+ subs: Set<(msg: string) => void>;
41
+ dispose?: Dispose;
42
+ }
43
+
44
+ export class PluginHost {
45
+ private pluginsRegistry: { pluginId: string; methods: string[] }[];
46
+ private ops = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
47
+ private attaches = new Map<string, Attach>(); // key: `${pluginId}\0${windowId}`
48
+ private opSeq = 0;
49
+
50
+ constructor(
51
+ private loaded: Loaded[],
52
+ private send: (env: Envelope) => void,
53
+ ) {
54
+ this.pluginsRegistry = loaded.map((l) => ({
55
+ pluginId: l.def.name,
56
+ methods: Object.keys(l.def.handlers ?? {}),
57
+ }));
58
+ }
59
+
60
+ /** Announce handler surfaces so the shell can route `call`s (the sidecar-compatible path). */
61
+ announceReady(): void {
62
+ this.send({ kind: 'ready', plugins: this.pluginsRegistry });
63
+ }
64
+
65
+ private key(pluginId: string, windowId: string): string {
66
+ return `${pluginId}\0${windowId}`;
67
+ }
68
+
69
+ /** Build the curated facade for one (plugin, window). Every method → an `op` awaiting a `result`. */
70
+ private makeCtx(pluginId: string, win: WindowIdentity): WindowCtx {
71
+ const call = <T>(method: string, params?: unknown): Promise<T> =>
72
+ new Promise<T>((resolve, reject) => {
73
+ const id = `op-${pluginId}-${++this.opSeq}`;
74
+ const timer = setTimeout(() => {
75
+ if (this.ops.delete(id)) reject(new Error(`op ${method} (${id}) timed out after ${OP_TIMEOUT_MS}ms`));
76
+ }, OP_TIMEOUT_MS);
77
+ // Don't keep the host process alive just for a pending op timer.
78
+ (timer as { unref?: () => void }).unref?.();
79
+ this.ops.set(id, { resolve: resolve as (v: unknown) => void, reject, timer });
80
+ this.send({ kind: 'op', id, pluginId, windowId: win.id, method, params });
81
+ });
82
+ const k = this.key(pluginId, win.id);
83
+ return {
84
+ id: win.id,
85
+ profileId: win.profileId,
86
+ url: () => call<string>('url'),
87
+ injectScript: (js) => call<void>('injectScript', { js }),
88
+ eval: <T = unknown>(js: string) => call<T>('eval', { js }),
89
+ navigate: (url) => call<void>('navigate', { url }),
90
+ back: () => call<void>('back'),
91
+ forward: () => call<void>('forward'),
92
+ reload: () => call<void>('reload'),
93
+ snapshot: async () => new Uint8Array(await call<number[]>('snapshot')),
94
+ close: () => call<void>('close'),
95
+ setInspectable: (on) => call<void>('setInspectable', { on }),
96
+ postMessage: (msg) => call<void>('postMessage', { msg }),
97
+ onMessage: (cb) => {
98
+ const a = this.attaches.get(k);
99
+ a?.subs.add(cb);
100
+ // First subscriber installs the native message handler on this window.
101
+ if (a && a.subs.size === 1) void call<void>('subscribeMessages');
102
+ return () => {
103
+ this.attaches.get(k)?.subs.delete(cb);
104
+ };
105
+ },
106
+ };
107
+ }
108
+
109
+ /** Dispatch one inbound envelope from the shell. */
110
+ async handle(env: Envelope): Promise<void> {
111
+ switch (env.kind) {
112
+ case 'result': {
113
+ const p = this.ops.get(env.id);
114
+ if (!p) return;
115
+ this.ops.delete(env.id);
116
+ clearTimeout(p.timer);
117
+ if (env.ok) p.resolve(env.data);
118
+ else p.reject(new Error(env.error?.message ?? 'op failed'));
119
+ return;
120
+ }
121
+ case 'call': {
122
+ // Handler RPC — sidecar-compatible. Method is namespaced; find the owning plugin.
123
+ const owner = this.loaded.find((l) => l.def.handlers && env.method in l.def.handlers);
124
+ const fn = owner?.def.handlers?.[env.method];
125
+ if (!fn) {
126
+ this.send({ kind: 'result', id: env.id, ok: false, error: { code: 'UNSUPPORTED', message: `${env.method} not implemented` } });
127
+ return;
128
+ }
129
+ try {
130
+ const data = await fn(env.params);
131
+ this.send({ kind: 'result', id: env.id, ok: true, data: data ?? null });
132
+ } catch (e) {
133
+ this.send({ kind: 'result', id: env.id, ok: false, error: { code: 'NATIVE_ERROR', message: e instanceof Error ? e.message : String(e) } });
134
+ }
135
+ return;
136
+ }
137
+ case 'event': {
138
+ const win: WindowIdentity = { id: env.windowId, profileId: env.params?.profileId };
139
+ if (env.method === 'window-created') {
140
+ for (const l of this.loaded) {
141
+ if (!matchScope(l.def, l.entry.attachTo, win)) continue;
142
+ const k = this.key(l.def.name, win.id);
143
+ if (this.attaches.has(k)) continue; // already attached
144
+ const attach: Attach = { subs: new Set() };
145
+ this.attaches.set(k, attach);
146
+ try {
147
+ const d = await l.def.onWindow?.(this.makeCtx(l.def.name, win));
148
+ if (typeof d === 'function') attach.dispose = d;
149
+ } catch (e) {
150
+ console.error(`[plugin-host] ${l.def.name}.onWindow(${win.id}) threw:`, e);
151
+ }
152
+ }
153
+ } else if (env.method === 'message') {
154
+ const raw = String(env.params?.raw ?? '');
155
+ for (const l of this.loaded) {
156
+ const a = this.attaches.get(this.key(l.def.name, win.id));
157
+ a?.subs.forEach((cb) => {
158
+ try { cb(raw); } catch (e) { console.error(`[plugin-host] ${l.def.name} onMessage threw:`, e); }
159
+ });
160
+ }
161
+ } else if (env.method === 'window-closed') {
162
+ for (const l of this.loaded) {
163
+ const k = this.key(l.def.name, win.id);
164
+ const a = this.attaches.get(k);
165
+ if (!a) continue;
166
+ try { a.dispose?.(); l.def.onClose?.(win); } catch (e) { console.error(`[plugin-host] ${l.def.name} onClose threw:`, e); }
167
+ this.attaches.delete(k);
168
+ }
169
+ }
170
+ return;
171
+ }
172
+ default:
173
+ return;
174
+ }
175
+ }
176
+ }
177
+
178
+ /** Load each stamped plugin bundle → its default-exported def. */
179
+ async function loadPlugins(entries: StampedPlugin[]): Promise<Loaded[]> {
180
+ const out: Loaded[] = [];
181
+ for (const entry of entries) {
182
+ try {
183
+ const mod = await import(entry.bundlePath);
184
+ const def: PluginDef | undefined = mod.default ?? mod.plugin;
185
+ if (!def?.name) {
186
+ console.error(`[plugin-host] ${entry.bundlePath} has no default-exported plugin def — skipping`);
187
+ continue;
188
+ }
189
+ out.push({ entry, def });
190
+ } catch (e) {
191
+ console.error(`[plugin-host] failed to load ${entry.bundlePath}:`, e);
192
+ }
193
+ }
194
+ return out;
195
+ }
196
+
197
+ /** CLI entrypoint: wire the Unix-socket transport around a `PluginHost`. */
198
+ async function main(): Promise<void> {
199
+ const args = process.argv.slice(2);
200
+ const socketPath = args[args.indexOf('--socket') + 1];
201
+ const pluginsArg = args[args.indexOf('--plugins') + 1];
202
+ if (!socketPath || !pluginsArg) {
203
+ console.error('[plugin-host] missing --socket or --plugins');
204
+ process.exit(1);
205
+ }
206
+ const entries: StampedPlugin[] = JSON.parse(pluginsArg);
207
+ const loaded = await loadPlugins(entries);
208
+
209
+ const net = await import('node:net');
210
+ const sock = net.connect(socketPath);
211
+ let host: PluginHost;
212
+ const sendLine = (env: Envelope) => sock.write(JSON.stringify(env) + '\n');
213
+
214
+ sock.on('connect', () => {
215
+ host = new PluginHost(loaded, sendLine);
216
+ host.announceReady();
217
+ });
218
+ let buf = '';
219
+ sock.setEncoding('utf8');
220
+ sock.on('data', (chunk: string) => {
221
+ buf += chunk;
222
+ let nl: number;
223
+ while ((nl = buf.indexOf('\n')) >= 0) {
224
+ const line = buf.slice(0, nl).trim();
225
+ buf = buf.slice(nl + 1);
226
+ if (line) {
227
+ try { void host.handle(JSON.parse(line)); }
228
+ catch (e) { console.error('[plugin-host] bad line:', e); }
229
+ }
230
+ }
231
+ });
232
+ sock.on('error', (e: unknown) => console.error('[plugin-host] socket error:', e));
233
+ sock.on('close', () => process.exit(0));
234
+ }
235
+
236
+ // Run only as an entrypoint (not when imported by tests).
237
+ if (import.meta.main) void main();
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `definePlugin` — author an appwrap TS plugin (`@livx.cc/appwrap/plugin`).
3
+ *
4
+ * // my-plugin.ts (referenced by `plugins` in appwrap.config.ts)
5
+ * import { definePlugin } from '@livx.cc/appwrap/plugin';
6
+ * export default definePlugin({
7
+ * name: 'my-plugin',
8
+ * attachTo: 'main',
9
+ * onWindow(win) {
10
+ * win.onMessage((m) => console.log('page said', m));
11
+ * win.injectScript(`webkit.messageHandlers.bridgeShim.postMessage('hi')`);
12
+ * },
13
+ * handlers: { 'my-plugin.echo': (p) => ({ echoed: p }) }, // back-compat: == defineHandlers
14
+ * });
15
+ *
16
+ * Types-first: `definePlugin` is an identity helper that validates + returns the def, which the plugin
17
+ * entrypoint `export default`s. The multiplexed host (`host.ts`) `import()`s the built bundle and reads
18
+ * `.default`. Nothing runs at import time (unlike `defineHandlers`, which starts a loop) — the host
19
+ * drives the lifecycle.
20
+ */
21
+ import type { PluginDef } from './types';
22
+
23
+ export function definePlugin(def: PluginDef): PluginDef {
24
+ if (!def || typeof def.name !== 'string' || !def.name) {
25
+ throw new Error('definePlugin: `name` is required');
26
+ }
27
+ return def;
28
+ }
29
+
30
+ export type {
31
+ PluginDef,
32
+ WindowCtx,
33
+ WindowScope,
34
+ WindowIdentity,
35
+ Dispose,
36
+ Envelope,
37
+ StampedPlugin,
38
+ } from './types';
39
+ export { WINDOW_CTX_VERSION } from './types';
@@ -0,0 +1,109 @@
1
+ /**
2
+ * appwrap plugin contract — the PORT (`@livx.cc/appwrap/plugin`).
3
+ *
4
+ * A TS plugin consumes a CURATED, VERSIONED facade over the native `webview-control` primitives
5
+ * ({@link WindowCtx}) — it never touches objc2 FFI. The plugin runs OUT-OF-WEBVIEW in the trusted
6
+ * multiplexed Bun host (see `host.ts`); the host speaks a bidirectional line-JSON protocol over a
7
+ * Unix domain socket to the Rust shell, which owns the primitives.
8
+ *
9
+ * IoC / Hollywood: the CORE calls the plugin — on window-created it fans out to each plugin whose
10
+ * `attachTo` matches, invoking `onWindow(ctx)`. Plugins never reach into core internals.
11
+ *
12
+ * A plugin with ONLY `handlers` behaves exactly like today's `defineHandlers` sidecar (back-compat):
13
+ * `desktop.handlers` is sugar for a local handlers-only plugin.
14
+ */
15
+
16
+ /** The facade version this contract targets. Bump on a breaking WindowCtx change. */
17
+ export const WINDOW_CTX_VERSION = 1 as const;
18
+
19
+ /** Which windows a plugin attaches to (the OPT-IN gate — nothing is controllable unless configured).
20
+ * String forms are also expressible in `appwrap.config.ts` (stamped into shell_config); the predicate
21
+ * form is code-only (it lives in the host process). */
22
+ export type WindowScope =
23
+ | 'main' // the app's main window
24
+ | 'all' // every window (incl. sub-windows)
25
+ | string // an explicit window label / id
26
+ | ((win: WindowIdentity) => boolean);
27
+
28
+ /** The minimal identity the host matches `attachTo` against. */
29
+ export interface WindowIdentity {
30
+ id: string;
31
+ /** Per-profile isolated store id, when the window was created for a profile; else undefined. */
32
+ profileId?: string;
33
+ }
34
+
35
+ /** Disposer returned by subscriptions / `onWindow` for teardown on detach/close. */
36
+ export type Dispose = () => void;
37
+
38
+ /**
39
+ * Curated, versioned facade over the native `webview-control` primitives, scoped to ONE window.
40
+ * NOT raw FFI — a deliberately small subset (design risk #4: avoid a god-interface). Every method
41
+ * marshals over the host↔shell socket as an `op` envelope and awaits a `result`.
42
+ */
43
+ export interface WindowCtx extends WindowIdentity {
44
+ /** Current top-level URL of the window. */
45
+ url(): Promise<string>;
46
+ /** Inject JS that runs immediately in the page (fire-and-forget; no return value). */
47
+ injectScript(js: string): Promise<void>;
48
+ /** CSP-immune eval with an awaited return value (callAsyncJavaScript). `js` is a FUNCTION BODY
49
+ * (use `return`). Result is JSON-parsed. */
50
+ eval<T = unknown>(js: string): Promise<T>;
51
+ navigate(url: string): Promise<void>;
52
+ back(): Promise<void>;
53
+ forward(): Promise<void>;
54
+ reload(): Promise<void>;
55
+ /** PNG bytes of the current page. */
56
+ snapshot(): Promise<Uint8Array>;
57
+ close(): Promise<void>;
58
+ /** Toggle the Safari Web Inspector for this window (release builds gate on APPWRAP_DEVTOOLS). */
59
+ setInspectable(on: boolean): Promise<void>;
60
+ /** Subscribe to page→plugin beacons: the page calls
61
+ * `webkit.messageHandlers.bridgeShim.postMessage(json)`; `cb` receives the RAW string. */
62
+ onMessage(cb: (msg: string) => void): Dispose;
63
+ /** plugin→page: delivered as a `window` `appwrap:plugin` CustomEvent whose `detail` is `msg`. */
64
+ postMessage(msg: unknown): Promise<void>;
65
+ }
66
+
67
+ /** The plugin definition — authored with {@link definePlugin} and `export default`ed. */
68
+ export interface PluginDef {
69
+ /** Namespace — guards against shadowing built-in method prefixes (like the sidecar's filter). */
70
+ name: string;
71
+ /** BACK-COMPAT: PWA→plugin RPC, identical semantics to `defineHandlers`. */
72
+ handlers?: Record<string, (params: any) => any | Promise<any>>;
73
+ /** OPT-IN: which windows this plugin attaches to. Omit → no per-window attach (handlers-only). */
74
+ attachTo?: WindowScope;
75
+ /** Per-window attach hook (the bidirectional part). Return a Dispose for detach cleanup. */
76
+ onWindow?(win: WindowCtx): void | Dispose | Promise<void | Dispose>;
77
+ /** Window closed. */
78
+ onClose?(win: WindowIdentity): void;
79
+ }
80
+
81
+ // ── Wire protocol (host ↔ shell, one JSON object per line over a Unix domain socket) ──────────────
82
+ // Envelope: { pluginId?, windowId?, kind, method?, params?, id?, ... }. Route by pluginId × windowId.
83
+ // host → shell: { kind:'ready', plugins:[{ pluginId, methods:[…] }] } (once, at boot)
84
+ // host → shell: { kind:'op', id, pluginId, windowId, method, params } (a WindowCtx call)
85
+ // shell → host: { kind:'result', id, ok, data|error } (op result)
86
+ // shell → host: { kind:'event', method, windowId, params } (window/page lifecycle)
87
+ // shell → host: { kind:'call', id, method, params } (handler RPC, ==sidecar)
88
+ // host → shell: { kind:'result', id, ok, data|error } (handler result)
89
+ // `result` is bidirectional — each side keys its own pending map by the unique `id` it minted.
90
+
91
+ export type Envelope =
92
+ | { kind: 'ready'; plugins: { pluginId: string; methods: string[] }[] }
93
+ | { kind: 'op'; id: string; pluginId: string; windowId: string; method: string; params?: unknown }
94
+ | { kind: 'result'; id: string; ok: boolean; data?: unknown; error?: { code: string; message: string } }
95
+ | { kind: 'event'; method: 'window-created' | 'window-closed' | 'message'; windowId: string; params?: any }
96
+ | { kind: 'call'; id: string; method: string; params?: unknown };
97
+
98
+ /** One resolved plugin the host loads at boot (stamped into shell_config by `regeneratePlugins`). */
99
+ export interface StampedPlugin {
100
+ /** Bundle/build id derived from the config source (sanitized filename or package name). This is a
101
+ * pre-bundle-load stamp — the plugin's real def `name` isn't known until the host `import()`s the
102
+ * bundle. It is NOT the routing/diagnostic identifier: routing (matchScope, registry) and all
103
+ * diagnostics key off the loaded def `name` (see host.ts). Used only for the bundle filename. */
104
+ bundleId: string;
105
+ /** Config-level attachTo OVERRIDE (string forms only). Falls back to the def's own `attachTo`. */
106
+ attachTo?: 'main' | 'all' | string;
107
+ /** Absolute path to the bun-built single-file plugin bundle. */
108
+ bundlePath: string;
109
+ }