@livx.cc/appwrap 0.54.0 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -4
- package/runtime/app/main-page.ts +0 -2
- package/runtime/app/shell/capabilities.manifest.ts +27 -92
- package/runtime/app/shell/config.ts +4 -0
- package/runtime/app/shell/env.ts +8 -1
- package/runtime/app/shell/handlers.ts +2 -2
- package/runtime/tests/bridge-response-delivery.test.ts +10 -1
- package/runtime/tests/env-scheme.test.ts +50 -0
- package/scripts/stage-assets.mjs +1 -1
- package/src/cli.ts +424 -621
- package/src/config.ts +8 -1
- package/src/handlers.ts +1 -1
- package/src/packs.ts +174 -0
- package/src/testing.ts +173 -0
- package/templates/module-pack/README.md +44 -0
- package/templates/module-pack/handler.ts +9 -0
- package/templates/module-pack/manifest.ts +27 -0
- package/templates/module-pack/native-src/__MODULE_NAME__/.gitkeep +0 -0
- package/runtime/app/shell/billing-offer.ts +0 -54
- package/runtime/app/shell/handlers-billing.ts +0 -662
- package/runtime/app/shell/handlers-health.ts +0 -178
- package/runtime/app/shell/handlers-widget.ts +0 -97
- package/runtime/modules-native/billing/App_Resources/iOS/src/AppwrapManageSubscriptions.swift +0 -44
- package/runtime/modules-native/health/App_Resources/Android/src/main/java/cc/livx/appwrap/HealthConnectBridge.kt +0 -74
- package/runtime/modules-native/widget/App_Resources/Android/src/main/java/cc/livx/appwrap/AppwrapWidgetProvider.kt +0 -118
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/drawable/appwrap_badge_bg.xml +0 -9
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml +0 -123
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_colors.xml +0 -10
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_styles.xml +0 -67
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values-night/appwrap_widget_colors.xml +0 -9
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml +0 -17
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.entitlements +0 -10
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.swift +0 -275
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/Info.plist +0 -25
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/extension.json +0 -9
- package/runtime/modules-native/widget/App_Resources/iOS/src/AppwrapWidgetReload.swift +0 -18
- package/runtime/tests/billing-offer.test.ts +0 -61
- package/runtime-desktop/autotest/index.html +0 -77
- package/runtime-desktop/bridge-shim/build.sh +0 -9
- package/runtime-desktop/bridge-shim/content-entry.ts +0 -87
- package/runtime-desktop/bridge-shim/shim.ts +0 -132
- package/runtime-desktop/chrome/appwrap-browser-chrome.html +0 -185
- package/runtime-desktop/crates/webview-control/Cargo.lock +0 -330
- package/runtime-desktop/crates/webview-control/Cargo.toml +0 -28
- package/runtime-desktop/crates/webview-control/examples/host_harness.rs +0 -362
- package/runtime-desktop/crates/webview-control/src/embedded.rs +0 -138
- package/runtime-desktop/crates/webview-control/src/lib.rs +0 -100
- package/runtime-desktop/crates/webview-control/src/popup.rs +0 -522
- package/runtime-desktop/crates/webview-control/src/profiles.rs +0 -57
- package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +0 -558
- package/runtime-desktop/src-tauri/Cargo.lock +0 -5474
- package/runtime-desktop/src-tauri/Cargo.toml +0 -45
- package/runtime-desktop/src-tauri/build.rs +0 -3
- package/runtime-desktop/src-tauri/capabilities/default.json +0 -6
- package/runtime-desktop/src-tauri/icons/icon.png +0 -0
- package/runtime-desktop/src-tauri/shell_config.json +0 -10
- package/runtime-desktop/src-tauri/src/biometrics_mac.rs +0 -138
- package/runtime-desktop/src-tauri/src/bridge_mac.rs +0 -424
- package/runtime-desktop/src-tauri/src/browser_tab.rs +0 -804
- package/runtime-desktop/src-tauri/src/main.rs +0 -1228
- package/runtime-desktop/src-tauri/src/notifications_mac.rs +0 -202
- package/runtime-desktop/src-tauri/src/oauth_mac.rs +0 -139
- package/runtime-desktop/src-tauri/src/plugin_host.rs +0 -522
- package/runtime-desktop/src-tauri/src/popup_mac.rs +0 -5
- package/runtime-desktop/src-tauri/src/push_mac.rs +0 -193
- package/runtime-desktop/src-tauri/src/server.rs +0 -156
- package/runtime-desktop/src-tauri/src/sidecar.rs +0 -398
- package/runtime-desktop/src-tauri/tauri.conf.json +0 -14
- package/src/desktop.ts +0 -300
- package/src/plugin/host.ts +0 -242
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
-
}
|
package/src/plugin/host.ts
DELETED
|
@@ -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();
|