@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 +1 -1
- package/runtime-desktop/crates/webview-control/examples/host_harness.rs +25 -1
- package/runtime-desktop/crates/webview-control/src/embedded.rs +17 -0
- package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +5 -0
- package/src/cli.ts +7 -6
- package/src/desktop.ts +1 -1
- package/src/plugin/host.ts +13 -2
- package/src/plugin/types.ts +5 -2
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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:[{
|
|
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. `
|
|
2089
|
-
*
|
|
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
|
|
2131
|
-
const bundlePath = join(outPlugins, `${
|
|
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({
|
|
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: {
|
|
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. */
|
package/src/plugin/host.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/plugin/types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
101
|
-
|
|
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. */
|