@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.
Files changed (43) hide show
  1. package/package.json +2 -3
  2. package/runtime/app/shell/capabilities.manifest.ts +3 -3
  3. package/runtime/tests/bridge-response-delivery.test.ts +10 -1
  4. package/runtime/tests/env-scheme.test.ts +6 -0
  5. package/scripts/stage-assets.mjs +1 -1
  6. package/src/cli.ts +40 -570
  7. package/src/config.ts +1 -1
  8. package/src/handlers.ts +1 -1
  9. package/src/packs.ts +1 -1
  10. package/templates/ci/fastlane/Fastfile +47 -6
  11. package/runtime-desktop/autotest/index.html +0 -77
  12. package/runtime-desktop/bridge-shim/build.sh +0 -9
  13. package/runtime-desktop/bridge-shim/content-entry.ts +0 -87
  14. package/runtime-desktop/bridge-shim/shim.ts +0 -132
  15. package/runtime-desktop/chrome/appwrap-browser-chrome.html +0 -185
  16. package/runtime-desktop/crates/webview-control/Cargo.lock +0 -330
  17. package/runtime-desktop/crates/webview-control/Cargo.toml +0 -28
  18. package/runtime-desktop/crates/webview-control/examples/host_harness.rs +0 -362
  19. package/runtime-desktop/crates/webview-control/src/embedded.rs +0 -138
  20. package/runtime-desktop/crates/webview-control/src/lib.rs +0 -100
  21. package/runtime-desktop/crates/webview-control/src/popup.rs +0 -522
  22. package/runtime-desktop/crates/webview-control/src/profiles.rs +0 -57
  23. package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +0 -558
  24. package/runtime-desktop/src-tauri/Cargo.lock +0 -5474
  25. package/runtime-desktop/src-tauri/Cargo.toml +0 -45
  26. package/runtime-desktop/src-tauri/build.rs +0 -3
  27. package/runtime-desktop/src-tauri/capabilities/default.json +0 -6
  28. package/runtime-desktop/src-tauri/icons/icon.png +0 -0
  29. package/runtime-desktop/src-tauri/shell_config.json +0 -10
  30. package/runtime-desktop/src-tauri/src/biometrics_mac.rs +0 -138
  31. package/runtime-desktop/src-tauri/src/bridge_mac.rs +0 -424
  32. package/runtime-desktop/src-tauri/src/browser_tab.rs +0 -804
  33. package/runtime-desktop/src-tauri/src/main.rs +0 -1228
  34. package/runtime-desktop/src-tauri/src/notifications_mac.rs +0 -202
  35. package/runtime-desktop/src-tauri/src/oauth_mac.rs +0 -139
  36. package/runtime-desktop/src-tauri/src/plugin_host.rs +0 -522
  37. package/runtime-desktop/src-tauri/src/popup_mac.rs +0 -5
  38. package/runtime-desktop/src-tauri/src/push_mac.rs +0 -193
  39. package/runtime-desktop/src-tauri/src/server.rs +0 -156
  40. package/runtime-desktop/src-tauri/src/sidecar.rs +0 -398
  41. package/runtime-desktop/src-tauri/tauri.conf.json +0 -14
  42. package/src/desktop.ts +0 -300
  43. package/src/plugin/host.ts +0 -242
package/src/desktop.ts DELETED
@@ -1,300 +0,0 @@
1
- /**
2
- * Pure desktop (Tauri-chassis) config derivation + stamp transforms — side-effect-free (no fs, no
3
- * config I/O) so they unit-test against fixtures, same standard as derive.ts. The CLI wires these into
4
- * the `dev|build desktop` lane: `deriveDesktopConfig` → the JSON the Rust shell reads (`shell_config.json`,
5
- * embedded at compile time), `stampTauriConf` → the generated `tauri.conf.json`.
6
- */
7
- import type { AppwrapConfig } from './config';
8
-
9
- /** The resolved shell config the Rust desktop chassis reads (mirrors the `ShellConfig` struct in
10
- * runtime-desktop/src-tauri/src/main.rs — keys are camelCase; Rust deserializes with rename_all). */
11
- export interface DesktopShellConfig {
12
- appId: string;
13
- name: string;
14
- version: string;
15
- title: string;
16
- width: number;
17
- height: number;
18
- /** App's own deep-link scheme (e.g. "hellowrap"); '' when none — the Rust shell skips the
19
- * `<scheme>://` loopback so an empty scheme can't spuriously match every url. */
20
- urlScheme: string;
21
- identifier: string;
22
- /** Persist + restore window size/position across launches (default true). When false the Rust
23
- * shell skips registering tauri-plugin-window-state, so the configured width/height always apply. */
24
- restoreWindowState: boolean;
25
- /** Append `prompt=select_account` to Google `signInWithPopup` OAuth URLs (default false) so a
26
- * remembered Google session doesn't auto-sign — the user picks an account per instance. */
27
- googleAccountPicker: boolean;
28
- /** Launch on login via tauri-plugin-autostart (macOS LaunchAgent). Default false. The Rust shell
29
- * enables the login item when true and explicitly disables it when false (so turning this off
30
- * removes the item on the next launch). Works for the adhoc-signed .app (no Developer ID needed). */
31
- autostart: boolean;
32
- /** Path to the app's custom-handlers TS file for the Bun sidecar; '' when none (the Rust shell
33
- * skips spawning the sidecar entirely). `deriveDesktopConfig` carries it through AS AUTHORED
34
- * (relative to the app root); the CLI resolves it to an ABSOLUTE path before stamping
35
- * `shell_config.json` (see `regenerateDesktop`), since the shell binary runs from elsewhere. */
36
- handlers: string;
37
- /** True when the CLI resolved a real signing identity + a macOS provisioning profile for this
38
- * build (the .app will carry the aps-environment entitlement). Stamped by `build desktop` —
39
- * never authored; gates the Rust shell's push capability + push.register. */
40
- pushSigned: boolean;
41
- /** Absolute path to the Bun binary the sidecar should be spawned with; '' until the CLI stamps it
42
- * (from `process.execPath` — the exact runtime the app was built with). Needed because a
43
- * GUI-launched .app inherits the launchd PATH (/usr/bin:/bin:...) which does NOT contain
44
- * ~/.bun/bin, so a bare `bun` spawn fails. The Rust shell falls back to `bun` when this is
45
- * missing/nonexistent. */
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 }[];
52
- /** True when `modules` includes 'media' → the .app carries an NSMicrophoneUsageDescription so
53
- * `getUserMedia` (voice mode) can pass macOS TCC. The WebKit-layer grant (popup_mac) is
54
- * unconditional; this only adds the OS usage string an app that actually uses the mic needs. */
55
- microphone: boolean;
56
- /** Local-server mode. When present the Rust shell boots `command` (+`args`) in a background thread,
57
- * polls `http://localhost:<port><healthPath>` until it answers, then navigates the window there
58
- * (instead of the bundled dist); it kills the child on window-close. Absent → bundled-PWA load.
59
- * `command`/`cwd` are resolved to ABSOLUTE by the CLI (see `regenerateDesktop`) because a
60
- * GUI-launched .app has the minimal launchd PATH (no ~/.bun/bin). Empty when the app isn't a
61
- * local-server app. */
62
- server?: {
63
- command: string;
64
- args: string[];
65
- cwd: string;
66
- /** Stdout marker → capture the URL the server prints (dynamic port). '' when using fixed `port`. */
67
- urlMarker: string;
68
- /** Fixed port (used only when `urlMarker` is ''). 0 when unused. */
69
- port: number;
70
- https: boolean;
71
- healthPath: string;
72
- readyTimeoutMs: number;
73
- /** PATH the child is spawned with — the CLI stamps its own (build-time) PATH so a GUI launch can
74
- * still resolve neighbouring tools (bun, node) the launchd PATH omits. */
75
- path: string;
76
- };
77
- }
78
-
79
- /** Resolve the desktop shell config from an appwrap config: identity + `urlScheme` come from the
80
- * top-level fields, the window specifics from the `desktop` sub-shape (with lean defaults). */
81
- export function deriveDesktopConfig(cfg: AppwrapConfig): DesktopShellConfig {
82
- const d = cfg.desktop ?? {};
83
- return {
84
- appId: cfg.id,
85
- name: cfg.name,
86
- version: cfg.version,
87
- title: d.title ?? cfg.name,
88
- width: d.width ?? 430,
89
- height: d.height ?? 800,
90
- urlScheme: cfg.urlScheme ?? '',
91
- identifier: d.identifier ?? `${cfg.id}.desktop`,
92
- restoreWindowState: d.restoreWindowState ?? true,
93
- googleAccountPicker: d.googleAccountPicker ?? false,
94
- autostart: d.autostart ?? false,
95
- handlers: d.handlers ?? '',
96
- pushSigned: false, // stamped by the CLI when a signing identity + macOS profile resolve — see buildDesktop
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)
100
- microphone: (cfg.modules ?? []).includes('media'),
101
- // `command`/`cwd`/`path` are resolved to absolute by the CLI (regenerateDesktop); carried as authored here.
102
- server: d.server
103
- ? {
104
- command: d.server.command,
105
- args: d.server.args ?? [],
106
- cwd: d.server.cwd ?? '',
107
- urlMarker: d.server.urlMarker ?? '',
108
- port: d.server.port ?? 0,
109
- https: d.server.https ?? false,
110
- healthPath: d.server.healthPath ?? '/',
111
- readyTimeoutMs: d.server.readyTimeoutMs ?? 120_000,
112
- path: '',
113
- }
114
- : undefined,
115
- };
116
- }
117
-
118
- /** Stamp a `tauri.conf.json` template object from the resolved shell config + the (src-tauri-relative)
119
- * frontendDist. Pure: takes the parsed template, returns the stamped object (the CLI reads/writes). */
120
- export function stampTauriConf(
121
- template: Record<string, unknown>,
122
- shell: DesktopShellConfig,
123
- frontendDist: string,
124
- ): Record<string, unknown> {
125
- const out: Record<string, unknown> = {
126
- ...template,
127
- productName: shell.name,
128
- version: shell.version,
129
- identifier: shell.identifier,
130
- build: { ...(template.build as Record<string, unknown> | undefined), frontendDist },
131
- };
132
- // tauri-plugin-deep-link needs its schemes declared for the desktop lane (used by the plugin's
133
- // registration + the tauri bundler). We assemble the .app ourselves, but keep this in sync so the
134
- // plugin config is honest. Omit entirely when the app declares no scheme.
135
- if (shell.urlScheme) {
136
- const plugins = { ...(template.plugins as Record<string, unknown> | undefined) };
137
- plugins['deep-link'] = { desktop: { schemes: [shell.urlScheme] } };
138
- out.plugins = plugins;
139
- }
140
- return out;
141
- }
142
-
143
- const xmlEscape = (s: string): string =>
144
- s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
145
-
146
- /**
147
- * Generate the macOS `Contents/Info.plist` for the assembled `.app`. This is what registers the app's
148
- * `urlScheme` with LaunchServices (CFBundleURLTypes) so a `<scheme>://…` open reaches the running shell
149
- * via tauri-plugin-deep-link. `executable` = the copied Mach-O name (CFBundleExecutable must match the
150
- * file in `Contents/MacOS/`); `iconFile` = the `.icns` basename when an icon was generated (else omit);
151
- * `iconName` = the asset-catalog icon name (e.g. `AppIcon`) when an `Assets.car` was compiled — macOS 26
152
- * (Tahoe) resolves the Finder/Dock icon via `CFBundleIconName`+`Assets.car`, not the legacy `.icns`.
153
- */
154
- export function buildInfoPlist(
155
- shell: DesktopShellConfig,
156
- executable: string,
157
- iconFile?: string,
158
- iconName?: string,
159
- ): string {
160
- const urlTypes = shell.urlScheme
161
- ? `
162
- <key>CFBundleURLTypes</key>
163
- <array>
164
- <dict>
165
- <key>CFBundleURLName</key>
166
- <string>${xmlEscape(shell.identifier)}</string>
167
- <key>CFBundleURLSchemes</key>
168
- <array>
169
- <string>${xmlEscape(shell.urlScheme)}</string>
170
- </array>
171
- </dict>
172
- </array>`
173
- : '';
174
- const icon = iconFile
175
- ? `
176
- <key>CFBundleIconFile</key>
177
- <string>${xmlEscape(iconFile)}</string>`
178
- : '';
179
- const iconAsset = iconName
180
- ? `
181
- <key>CFBundleIconName</key>
182
- <string>${xmlEscape(iconName)}</string>`
183
- : '';
184
- // NSMicrophoneUsageDescription: required for getUserMedia (voice mode) to pass macOS TCC — without
185
- // it the OS silently denies mic access (no prompt). Added only when the app declares the `media` module.
186
- const mic = shell.microphone
187
- ? `
188
- <key>NSMicrophoneUsageDescription</key>
189
- <string>${xmlEscape(shell.name)} uses the microphone for voice input.</string>`
190
- : '';
191
- return `<?xml version="1.0" encoding="UTF-8"?>
192
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
193
- <plist version="1.0">
194
- <dict>
195
- <key>CFBundleName</key>
196
- <string>${xmlEscape(shell.name)}</string>
197
- <key>CFBundleDisplayName</key>
198
- <string>${xmlEscape(shell.name)}</string>
199
- <key>CFBundleIdentifier</key>
200
- <string>${xmlEscape(shell.identifier)}</string>
201
- <key>CFBundleVersion</key>
202
- <string>${xmlEscape(shell.version)}</string>
203
- <key>CFBundleShortVersionString</key>
204
- <string>${xmlEscape(shell.version)}</string>
205
- <key>CFBundleExecutable</key>
206
- <string>${xmlEscape(executable)}</string>
207
- <key>CFBundlePackageType</key>
208
- <string>APPL</string>
209
- <key>NSHighResolutionCapable</key>
210
- <true/>${icon}${iconAsset}${mic}${urlTypes}
211
- </dict>
212
- </plist>
213
- `;
214
- }
215
-
216
- /**
217
- * Generate the macOS code-signing entitlements plist for the signed desktop lane. macOS uses the
218
- * PREFIXED keys (`com.apple.application-identifier`, `com.apple.developer.aps-environment`) — the
219
- * bare iOS spellings (`application-identifier`, `aps-environment`) are NOT valid on macOS. The
220
- * restricted entitlements here (app identifier + aps) only launch when backed by an embedded
221
- * provisioning profile, so the caller must only include `apsEnvironment` when it embeds one.
222
- * `apsEnvironment`: 'development' for Apple Development signing, 'production' for Developer ID.
223
- */
224
- /** A macOS provisioning profile's decoded payload (the CMS-decoded `security cms -D` plist). */
225
- export interface MacProfileInfo {
226
- teamId?: string;
227
- appId?: string;
228
- expiration?: string;
229
- /** The profile's OWN `com.apple.developer.aps-environment` entitlement (source of truth for the
230
- * app entitlement — a cert-derived guess can claim `production` the profile doesn't back → launch kill). */
231
- apsEnvironment?: string;
232
- /** Base64 DER of each embedded DeveloperCertificate (whitespace-stripped). */
233
- certificates: string[];
234
- }
235
-
236
- /** Parse a CMS-decoded `.provisionprofile` plist (XML). Pure — unit-tested. */
237
- export function parseMacProfile(plist: string): MacProfileInfo {
238
- const teamId = plist.match(/<key>TeamIdentifier<\/key>\s*<array>\s*<string>([^<]+)<\/string>/)?.[1];
239
- const appId = plist.match(/<key>com\.apple\.application-identifier<\/key>\s*<string>([^<]+)<\/string>/)?.[1];
240
- const expiration = plist.match(/<key>ExpirationDate<\/key>\s*<date>([^<]+)<\/date>/)?.[1];
241
- const apsEnvironment = plist.match(/<key>com\.apple\.developer\.aps-environment<\/key>\s*<string>([^<]+)<\/string>/)?.[1];
242
- const certBlock = plist.match(/<key>DeveloperCertificates<\/key>\s*<array>([\s\S]*?)<\/array>/)?.[1] ?? '';
243
- const certificates = [...certBlock.matchAll(/<data>([\s\S]*?)<\/data>/g)].map((m) => m[1].replace(/\s+/g, ''));
244
- return { teamId, appId, expiration, apsEnvironment, certificates };
245
- }
246
-
247
- export function buildMacEntitlements(opts: {
248
- teamId: string;
249
- bundleId: string;
250
- apsEnvironment?: 'development' | 'production';
251
- }): string {
252
- const aps = opts.apsEnvironment
253
- ? `
254
- <key>com.apple.developer.aps-environment</key>
255
- <string>${xmlEscape(opts.apsEnvironment)}</string>`
256
- : '';
257
- return `<?xml version="1.0" encoding="UTF-8"?>
258
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
259
- <plist version="1.0">
260
- <dict>
261
- <key>com.apple.application-identifier</key>
262
- <string>${xmlEscape(`${opts.teamId}.${opts.bundleId}`)}</string>
263
- <key>com.apple.developer.team-identifier</key>
264
- <string>${xmlEscape(opts.teamId)}</string>${aps}
265
- </dict>
266
- </plist>
267
- `;
268
- }
269
-
270
- /** Result of locating a spawnable `cargo` for the desktop lane. `bin` is what to invoke (a bare
271
- * `cargo` when it's already on PATH, else the absolute `<home>/.cargo/bin/cargo`); `path` is the
272
- * (possibly augmented) PATH to hand the child so rustc/rustup neighbours resolve too. */
273
- export interface CargoResolution {
274
- found: boolean;
275
- augmented: boolean;
276
- bin: string;
277
- path: string;
278
- }
279
-
280
- /** Pure PATH resolution for `cargo` (side-effect-free — fs access is injected via `hasCargo`), so it
281
- * unit-tests against fixtures. If `cargo` isn't on any current PATH entry but `<home>/.cargo/bin/cargo`
282
- * exists (rustup's default location, commonly missing from a GUI/login shell's PATH), append that dir
283
- * and invoke the binary by absolute path. Returns `found:false` when it's genuinely nowhere — the
284
- * caller turns that into an actionable "install rust via rustup / source $HOME/.cargo/env" message
285
- * instead of a raw spawn ENOENT. */
286
- export function resolveCargoPath(opts: {
287
- path: string;
288
- home: string;
289
- delimiter: string;
290
- hasCargo: (dir: string) => boolean;
291
- }): CargoResolution {
292
- const { path, home, delimiter, hasCargo } = opts;
293
- const dirs = path.split(delimiter).filter(Boolean);
294
- if (dirs.some(hasCargo)) return { found: true, augmented: false, bin: 'cargo', path };
295
- const cargoBin = home ? `${home}/.cargo/bin` : '';
296
- if (cargoBin && hasCargo(cargoBin)) {
297
- return { found: true, augmented: true, bin: `${cargoBin}/cargo`, path: [...dirs, cargoBin].join(delimiter) };
298
- }
299
- return { found: false, augmented: false, bin: 'cargo', path };
300
- }
@@ -1,242 +0,0 @@
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
- // 'browser' is a CATEGORY, not a literal id: the browser sub-windows (browser_tab.rs) whose
31
- // controlled webview is labeled `<win_id>-view`. This is the safe default for remote-control.
32
- // (Phase A does NOT yet emit window-created for sub-windows — browser_tab keeps its core bridging —
33
- // so this matches once that emission lands in Phase B, or any window explicitly labeled `*-view`.)
34
- if (scope === 'browser') return win.id.endsWith('-view');
35
- return scope === win.id;
36
- }
37
-
38
- interface Loaded {
39
- entry: StampedPlugin;
40
- def: PluginDef;
41
- }
42
-
43
- /** Per-(plugin,window) attach state: message subscribers + the onWindow disposer. */
44
- interface Attach {
45
- subs: Set<(msg: string) => void>;
46
- dispose?: Dispose;
47
- }
48
-
49
- export class PluginHost {
50
- private pluginsRegistry: { pluginId: string; methods: string[] }[];
51
- private ops = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
52
- private attaches = new Map<string, Attach>(); // key: `${pluginId}\0${windowId}`
53
- private opSeq = 0;
54
-
55
- constructor(
56
- private loaded: Loaded[],
57
- private send: (env: Envelope) => void,
58
- ) {
59
- this.pluginsRegistry = loaded.map((l) => ({
60
- pluginId: l.def.name,
61
- methods: Object.keys(l.def.handlers ?? {}),
62
- }));
63
- }
64
-
65
- /** Announce handler surfaces so the shell can route `call`s (the sidecar-compatible path). */
66
- announceReady(): void {
67
- this.send({ kind: 'ready', plugins: this.pluginsRegistry });
68
- }
69
-
70
- private key(pluginId: string, windowId: string): string {
71
- return `${pluginId}\0${windowId}`;
72
- }
73
-
74
- /** Build the curated facade for one (plugin, window). Every method → an `op` awaiting a `result`. */
75
- private makeCtx(pluginId: string, win: WindowIdentity): WindowCtx {
76
- const call = <T>(method: string, params?: unknown): Promise<T> =>
77
- new Promise<T>((resolve, reject) => {
78
- const id = `op-${pluginId}-${++this.opSeq}`;
79
- const timer = setTimeout(() => {
80
- if (this.ops.delete(id)) reject(new Error(`op ${method} (${id}) timed out after ${OP_TIMEOUT_MS}ms`));
81
- }, OP_TIMEOUT_MS);
82
- // Don't keep the host process alive just for a pending op timer.
83
- (timer as { unref?: () => void }).unref?.();
84
- this.ops.set(id, { resolve: resolve as (v: unknown) => void, reject, timer });
85
- this.send({ kind: 'op', id, pluginId, windowId: win.id, method, params });
86
- });
87
- const k = this.key(pluginId, win.id);
88
- return {
89
- id: win.id,
90
- profileId: win.profileId,
91
- url: () => call<string>('url'),
92
- injectScript: (js) => call<void>('injectScript', { js }),
93
- eval: <T = unknown>(js: string) => call<T>('eval', { js }),
94
- navigate: (url) => call<void>('navigate', { url }),
95
- back: () => call<void>('back'),
96
- forward: () => call<void>('forward'),
97
- reload: () => call<void>('reload'),
98
- snapshot: async () => new Uint8Array(await call<number[]>('snapshot')),
99
- close: () => call<void>('close'),
100
- setInspectable: (on) => call<void>('setInspectable', { on }),
101
- postMessage: (msg) => call<void>('postMessage', { msg }),
102
- onMessage: (cb) => {
103
- const a = this.attaches.get(k);
104
- a?.subs.add(cb);
105
- // First subscriber installs the native message handler on this window.
106
- if (a && a.subs.size === 1) void call<void>('subscribeMessages');
107
- return () => {
108
- this.attaches.get(k)?.subs.delete(cb);
109
- };
110
- },
111
- };
112
- }
113
-
114
- /** Dispatch one inbound envelope from the shell. */
115
- async handle(env: Envelope): Promise<void> {
116
- switch (env.kind) {
117
- case 'result': {
118
- const p = this.ops.get(env.id);
119
- if (!p) return;
120
- this.ops.delete(env.id);
121
- clearTimeout(p.timer);
122
- if (env.ok) p.resolve(env.data);
123
- else p.reject(new Error(env.error?.message ?? 'op failed'));
124
- return;
125
- }
126
- case 'call': {
127
- // Handler RPC — sidecar-compatible. Method is namespaced; find the owning plugin.
128
- const owner = this.loaded.find((l) => l.def.handlers && env.method in l.def.handlers);
129
- const fn = owner?.def.handlers?.[env.method];
130
- if (!fn) {
131
- this.send({ kind: 'result', id: env.id, ok: false, error: { code: 'UNSUPPORTED', message: `${env.method} not implemented` } });
132
- return;
133
- }
134
- try {
135
- const data = await fn(env.params);
136
- this.send({ kind: 'result', id: env.id, ok: true, data: data ?? null });
137
- } catch (e) {
138
- this.send({ kind: 'result', id: env.id, ok: false, error: { code: 'NATIVE_ERROR', message: e instanceof Error ? e.message : String(e) } });
139
- }
140
- return;
141
- }
142
- case 'event': {
143
- const win: WindowIdentity = { id: env.windowId, profileId: env.params?.profileId };
144
- if (env.method === 'window-created') {
145
- for (const l of this.loaded) {
146
- if (!matchScope(l.def, l.entry.attachTo, win)) continue;
147
- const k = this.key(l.def.name, win.id);
148
- if (this.attaches.has(k)) continue; // already attached
149
- const attach: Attach = { subs: new Set() };
150
- this.attaches.set(k, attach);
151
- try {
152
- const d = await l.def.onWindow?.(this.makeCtx(l.def.name, win));
153
- if (typeof d === 'function') attach.dispose = d;
154
- } catch (e) {
155
- console.error(`[plugin-host] ${l.def.name}.onWindow(${win.id}) threw:`, e);
156
- }
157
- }
158
- } else if (env.method === 'message') {
159
- const raw = String(env.params?.raw ?? '');
160
- for (const l of this.loaded) {
161
- const a = this.attaches.get(this.key(l.def.name, win.id));
162
- a?.subs.forEach((cb) => {
163
- try { cb(raw); } catch (e) { console.error(`[plugin-host] ${l.def.name} onMessage threw:`, e); }
164
- });
165
- }
166
- } else if (env.method === 'window-closed') {
167
- for (const l of this.loaded) {
168
- const k = this.key(l.def.name, win.id);
169
- const a = this.attaches.get(k);
170
- if (!a) continue;
171
- try { a.dispose?.(); l.def.onClose?.(win); } catch (e) { console.error(`[plugin-host] ${l.def.name} onClose threw:`, e); }
172
- this.attaches.delete(k);
173
- }
174
- }
175
- return;
176
- }
177
- default:
178
- return;
179
- }
180
- }
181
- }
182
-
183
- /** Load each stamped plugin bundle → its default-exported def. */
184
- async function loadPlugins(entries: StampedPlugin[]): Promise<Loaded[]> {
185
- const out: Loaded[] = [];
186
- for (const entry of entries) {
187
- try {
188
- const mod = await import(entry.bundlePath);
189
- const def: PluginDef | undefined = mod.default ?? mod.plugin;
190
- if (!def?.name) {
191
- console.error(`[plugin-host] ${entry.bundlePath} has no default-exported plugin def — skipping`);
192
- continue;
193
- }
194
- out.push({ entry, def });
195
- } catch (e) {
196
- console.error(`[plugin-host] failed to load ${entry.bundlePath}:`, e);
197
- }
198
- }
199
- return out;
200
- }
201
-
202
- /** CLI entrypoint: wire the Unix-socket transport around a `PluginHost`. */
203
- async function main(): Promise<void> {
204
- const args = process.argv.slice(2);
205
- const socketPath = args[args.indexOf('--socket') + 1];
206
- const pluginsArg = args[args.indexOf('--plugins') + 1];
207
- if (!socketPath || !pluginsArg) {
208
- console.error('[plugin-host] missing --socket or --plugins');
209
- process.exit(1);
210
- }
211
- const entries: StampedPlugin[] = JSON.parse(pluginsArg);
212
- const loaded = await loadPlugins(entries);
213
-
214
- const net = await import('node:net');
215
- const sock = net.connect(socketPath);
216
- let host: PluginHost;
217
- const sendLine = (env: Envelope) => sock.write(JSON.stringify(env) + '\n');
218
-
219
- sock.on('connect', () => {
220
- host = new PluginHost(loaded, sendLine);
221
- host.announceReady();
222
- });
223
- let buf = '';
224
- sock.setEncoding('utf8');
225
- sock.on('data', (chunk: string) => {
226
- buf += chunk;
227
- let nl: number;
228
- while ((nl = buf.indexOf('\n')) >= 0) {
229
- const line = buf.slice(0, nl).trim();
230
- buf = buf.slice(nl + 1);
231
- if (line) {
232
- try { void host.handle(JSON.parse(line)); }
233
- catch (e) { console.error('[plugin-host] bad line:', e); }
234
- }
235
- }
236
- });
237
- sock.on('error', (e: unknown) => console.error('[plugin-host] socket error:', e));
238
- sock.on('close', () => process.exit(0));
239
- }
240
-
241
- // Run only as an entrypoint (not when imported by tests).
242
- if (import.meta.main) void main();