@livx.cc/appwrap 0.47.0 → 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.47.0",
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",
@@ -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"),
package/src/cli.ts CHANGED
@@ -2081,12 +2081,13 @@ flex-direction:column;gap:20px;background:#0b0b0f;color:#e5e5e5;font:14px -apple
2081
2081
  /**
2082
2082
  * Make `cfg.plugins` LIVE (A.1 loader): resolve each entry (path OR npm package), bun-build its
2083
2083
  * entrypoint to a single embedded file under `native-desktop/plugins/`, bun-build the multiplexed
2084
- * plugin-host once, and stamp `{ pluginHost, plugins:[{namespace, attachTo, bundlePath}] }` into the
2084
+ * plugin-host once, and stamp `{ pluginHost, plugins:[{bundleId, attachTo, bundlePath}] }` into the
2085
2085
  * shell config. The Rust shell (plugin_host.rs) spawns the host with the stamped plugins at boot.
2086
2086
  *
2087
2087
  * A.1 scope: manifest/perms/native-deps merge is STUBBED (noted) — full `capabilities.manifest.ts`
2088
- * reuse (perms/entitlements union) lands later. `namespace` here is the file/package basename; the
2089
- * authoritative namespace is the plugin def's `name`, which the host reads from the bundle.
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()`.
2090
2091
  */
2091
2092
  export function regeneratePlugins(cwd: string, cfg: AppwrapConfig, outDir: string, shell: DesktopShellConfig): void {
2092
2093
  const entries = cfg.plugins ?? [];
@@ -2127,10 +2128,10 @@ export function regeneratePlugins(cwd: string, cfg: AppwrapConfig, outDir: strin
2127
2128
  continue;
2128
2129
  }
2129
2130
  }
2130
- const namespace = name.replace(/[^a-zA-Z0-9_-]/g, '_');
2131
- const bundlePath = join(outPlugins, `${namespace}.js`);
2131
+ const bundleId = name.replace(/[^a-zA-Z0-9_-]/g, '_');
2132
+ const bundlePath = join(outPlugins, `${bundleId}.js`);
2132
2133
  if (!bunBuild(entrypoint, bundlePath)) continue;
2133
- stamped.push({ namespace, attachTo, bundlePath });
2134
+ stamped.push({ bundleId, attachTo, bundlePath });
2134
2135
  console.log(` plugin ← ${name} (attachTo: ${attachTo ?? 'def-declared'})`);
2135
2136
  }
2136
2137
 
package/src/desktop.ts CHANGED
@@ -48,7 +48,7 @@ export interface DesktopShellConfig {
48
48
  * plugins are configured (the Rust shell skips spawning the host). Stamped by `regeneratePlugins`. */
49
49
  pluginHost: string;
50
50
  /** Resolved + bun-built plugins the host loads at boot. Empty when none. Stamped by `regeneratePlugins`. */
51
- plugins: { namespace: string; attachTo?: string; bundlePath: string }[];
51
+ plugins: { bundleId: string; attachTo?: string; bundlePath: string }[];
52
52
  /** True when `modules` includes 'media' → the .app carries an NSMicrophoneUsageDescription so
53
53
  * `getUserMedia` (voice mode) can pass macOS TCC. The WebKit-layer grant (popup_mac) is
54
54
  * unconditional; this only adds the OS usage string an app that actually uses the mic needs. */
@@ -15,6 +15,11 @@
15
15
  */
16
16
  import type { Dispose, Envelope, PluginDef, StampedPlugin, WindowCtx, WindowIdentity } from './types';
17
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
+
18
23
  /** Resolve `attachTo` against a window identity. Config-string override wins over the def's own scope. */
19
24
  export function matchScope(def: PluginDef, override: string | undefined, win: WindowIdentity): boolean {
20
25
  const scope = override ?? def.attachTo;
@@ -38,7 +43,7 @@ interface Attach {
38
43
 
39
44
  export class PluginHost {
40
45
  private pluginsRegistry: { pluginId: string; methods: string[] }[];
41
- private ops = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
46
+ private ops = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
42
47
  private attaches = new Map<string, Attach>(); // key: `${pluginId}\0${windowId}`
43
48
  private opSeq = 0;
44
49
 
@@ -66,7 +71,12 @@ export class PluginHost {
66
71
  const call = <T>(method: string, params?: unknown): Promise<T> =>
67
72
  new Promise<T>((resolve, reject) => {
68
73
  const id = `op-${pluginId}-${++this.opSeq}`;
69
- this.ops.set(id, { resolve: resolve as (v: unknown) => void, reject });
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 });
70
80
  this.send({ kind: 'op', id, pluginId, windowId: win.id, method, params });
71
81
  });
72
82
  const k = this.key(pluginId, win.id);
@@ -103,6 +113,7 @@ export class PluginHost {
103
113
  const p = this.ops.get(env.id);
104
114
  if (!p) return;
105
115
  this.ops.delete(env.id);
116
+ clearTimeout(p.timer);
106
117
  if (env.ok) p.resolve(env.data);
107
118
  else p.reject(new Error(env.error?.message ?? 'op failed'));
108
119
  return;
@@ -97,8 +97,11 @@ export type Envelope =
97
97
 
98
98
  /** One resolved plugin the host loads at boot (stamped into shell_config by `regeneratePlugins`). */
99
99
  export interface StampedPlugin {
100
- /** Plugin namespace (== PluginDef.name). */
101
- namespace: string;
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;
102
105
  /** Config-level attachTo OVERRIDE (string forms only). Falls back to the def's own `attachTo`. */
103
106
  attachTo?: 'main' | 'all' | string;
104
107
  /** Absolute path to the bun-built single-file plugin bundle. */