@ateam-ai-mobile/plugin-runtime 0.1.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/dist/index.d.mts +1129 -0
- package/dist/index.d.ts +1129 -0
- package/dist/index.js +2388 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2369 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +31 -0
- package/src/api/plugins.ts +257 -0
- package/src/bridge/native.ts +266 -0
- package/src/bridge/types.ts +268 -0
- package/src/bundles/bundleManager.ts +396 -0
- package/src/bundles/storage.ts +68 -0
- package/src/hooks/usePlugin.ts +185 -0
- package/src/hooks/useRemotePlugins.ts +97 -0
- package/src/host.ts +117 -0
- package/src/index.ts +79 -0
- package/src/registry/SurfaceDispatcher.tsx +66 -0
- package/src/registry/store.ts +131 -0
- package/src/registry/types.ts +60 -0
- package/src/sdk/components.ts +370 -0
- package/src/sdk/index.ts +76 -0
- package/src/sdk/primitives.ts +22 -0
- package/src/sdk/registration.ts +52 -0
- package/src/sdk/types.ts +315 -0
- package/src/sdk/useApi.ts +78 -0
- package/src/sdk/usePluginTheme.ts +86 -0
- package/src/surfaces/AdaOrb.tsx +318 -0
- package/src/surfaces/SurfaceCard.tsx +137 -0
- package/src/surfaces/SurfaceDrawer.tsx +216 -0
- package/src/surfaces/SurfaceFullscreen.tsx +207 -0
- package/src/utils/sha256.ts +85 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native Bridge SDK Types
|
|
3
|
+
*
|
|
4
|
+
* TypeScript interfaces for the bridge API exposed to dynamic connectors.
|
|
5
|
+
* Connectors use these types to interact with device capabilities.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
9
|
+
// CALENDAR
|
|
10
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
11
|
+
|
|
12
|
+
export interface CalendarEvent {
|
|
13
|
+
id: string;
|
|
14
|
+
title: string;
|
|
15
|
+
date: string; // YYYY-MM-DD
|
|
16
|
+
start: string; // HH:MM
|
|
17
|
+
end: string; // HH:MM
|
|
18
|
+
location: string;
|
|
19
|
+
attendees: string[];
|
|
20
|
+
notes: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CalendarTodayResult {
|
|
24
|
+
date: string; // YYYY-MM-DD
|
|
25
|
+
day_of_week: string; // "Monday"
|
|
26
|
+
events: CalendarEvent[];
|
|
27
|
+
count: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CalendarUpcomingResult {
|
|
31
|
+
from: string;
|
|
32
|
+
days_ahead: number;
|
|
33
|
+
events: CalendarEvent[];
|
|
34
|
+
count: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CalendarCreateParams {
|
|
38
|
+
title: string;
|
|
39
|
+
date: string; // YYYY-MM-DD
|
|
40
|
+
start_time: string; // HH:MM
|
|
41
|
+
end_time?: string; // HH:MM
|
|
42
|
+
location?: string;
|
|
43
|
+
attendees?: string[];
|
|
44
|
+
notes?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CalendarCreateResult {
|
|
48
|
+
event_id: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface CalendarRescheduleParams {
|
|
52
|
+
event_id: string;
|
|
53
|
+
new_date?: string;
|
|
54
|
+
new_start_time?: string;
|
|
55
|
+
new_end_time?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
59
|
+
// CONTACTS
|
|
60
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
61
|
+
|
|
62
|
+
export interface Contact {
|
|
63
|
+
id: string;
|
|
64
|
+
name: string;
|
|
65
|
+
phone: string | null;
|
|
66
|
+
email: string | null;
|
|
67
|
+
relationship: string;
|
|
68
|
+
birthday: string | null; // YYYY-MM-DD
|
|
69
|
+
notes: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ContactSearchResult {
|
|
73
|
+
contacts: Contact[];
|
|
74
|
+
count: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface Birthday {
|
|
78
|
+
name: string;
|
|
79
|
+
relationship: string;
|
|
80
|
+
birthday: string;
|
|
81
|
+
days_until: number;
|
|
82
|
+
is_today: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface BirthdayResult {
|
|
86
|
+
birthdays: Birthday[];
|
|
87
|
+
count: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
91
|
+
// LOCATION
|
|
92
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
93
|
+
|
|
94
|
+
export interface LocationResult {
|
|
95
|
+
lat: number;
|
|
96
|
+
lng: number;
|
|
97
|
+
place: string;
|
|
98
|
+
address: string;
|
|
99
|
+
type: string; // home, work, school, fitness, medical, other
|
|
100
|
+
timestamp: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
104
|
+
// BATTERY
|
|
105
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
106
|
+
|
|
107
|
+
export interface BatteryStatus {
|
|
108
|
+
ok: true;
|
|
109
|
+
timestamp: string;
|
|
110
|
+
battery: {
|
|
111
|
+
level: number; // 0-100
|
|
112
|
+
state: 'charging' | 'unplugged' | 'full';
|
|
113
|
+
low_power_mode: boolean;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
118
|
+
// NETWORK
|
|
119
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
120
|
+
|
|
121
|
+
export interface NetworkStatus {
|
|
122
|
+
type: 'wifi' | 'cellular' | 'none';
|
|
123
|
+
is_connected: boolean;
|
|
124
|
+
wifi_name?: string;
|
|
125
|
+
timestamp: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
129
|
+
// NOTIFICATIONS
|
|
130
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
131
|
+
|
|
132
|
+
export interface Notification {
|
|
133
|
+
id: string;
|
|
134
|
+
app: string;
|
|
135
|
+
title: string;
|
|
136
|
+
body: string;
|
|
137
|
+
timestamp: string;
|
|
138
|
+
sender: string | null;
|
|
139
|
+
priority: 'high' | 'medium' | 'low';
|
|
140
|
+
read: boolean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface NotificationListResult {
|
|
144
|
+
notifications: Notification[];
|
|
145
|
+
total: number;
|
|
146
|
+
unread: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
150
|
+
// SMS
|
|
151
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
152
|
+
|
|
153
|
+
export interface SMSSendResult {
|
|
154
|
+
ok: boolean;
|
|
155
|
+
status: 'sent' | 'unknown' | 'cancelled';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
159
|
+
// MAPS
|
|
160
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
161
|
+
|
|
162
|
+
export interface MapsNavigateResult {
|
|
163
|
+
ok: boolean;
|
|
164
|
+
status: 'navigating' | 'error';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
168
|
+
// HTTP
|
|
169
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
170
|
+
|
|
171
|
+
export interface HttpResponse {
|
|
172
|
+
ok: boolean;
|
|
173
|
+
status: number;
|
|
174
|
+
data: any;
|
|
175
|
+
headers: Record<string, string>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
179
|
+
// PERMISSIONS
|
|
180
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
181
|
+
|
|
182
|
+
export type PermissionState = 'granted' | 'denied' | 'not_requested';
|
|
183
|
+
|
|
184
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
185
|
+
// DEVICE
|
|
186
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
187
|
+
|
|
188
|
+
export interface DeviceInfo {
|
|
189
|
+
os: 'ios' | 'android';
|
|
190
|
+
version: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
194
|
+
// BRIDGE API
|
|
195
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
196
|
+
|
|
197
|
+
export interface Bridge {
|
|
198
|
+
calendar: {
|
|
199
|
+
getToday(): Promise<CalendarTodayResult>;
|
|
200
|
+
getUpcoming(days?: number): Promise<CalendarUpcomingResult>;
|
|
201
|
+
create(event: CalendarCreateParams): Promise<CalendarCreateResult>;
|
|
202
|
+
reschedule(params: CalendarRescheduleParams): Promise<{ ok: boolean }>;
|
|
203
|
+
cancel(event_id: string): Promise<{ ok: boolean }>;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
contacts: {
|
|
207
|
+
search(query: string): Promise<ContactSearchResult>;
|
|
208
|
+
get(idOrName: string): Promise<Contact | null>;
|
|
209
|
+
getAll(): Promise<ContactSearchResult>;
|
|
210
|
+
birthdays(days?: number): Promise<BirthdayResult>;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
location: {
|
|
214
|
+
getCurrent(): Promise<LocationResult>;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
battery: {
|
|
218
|
+
getStatus(): Promise<BatteryStatus>;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
network: {
|
|
222
|
+
getStatus(): Promise<NetworkStatus>;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
notifications: {
|
|
226
|
+
list(): Promise<NotificationListResult>;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
sms: {
|
|
230
|
+
isAvailable(): Promise<boolean>;
|
|
231
|
+
send(phone: string, message: string): Promise<SMSSendResult>;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
maps: {
|
|
235
|
+
navigate(destination: string, mode?: 'driving' | 'transit' | 'walking'): Promise<MapsNavigateResult>;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
http: {
|
|
239
|
+
get(url: string, headers?: Record<string, string>): Promise<HttpResponse>;
|
|
240
|
+
post(url: string, body: any, headers?: Record<string, string>): Promise<HttpResponse>;
|
|
241
|
+
put(url: string, body: any, headers?: Record<string, string>): Promise<HttpResponse>;
|
|
242
|
+
delete(url: string, body?: any, headers?: Record<string, string>): Promise<HttpResponse>;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
storage: {
|
|
246
|
+
get(key: string): Promise<string | null>;
|
|
247
|
+
set(key: string, value: string): Promise<void>;
|
|
248
|
+
delete(key: string): Promise<void>;
|
|
249
|
+
keys(): Promise<string[]>;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
permissions: {
|
|
253
|
+
check(capability: string): Promise<PermissionState>;
|
|
254
|
+
request(capability: string): Promise<boolean>;
|
|
255
|
+
getAll(): Promise<Record<string, PermissionState>>;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
device: {
|
|
259
|
+
getDeviceId(): string;
|
|
260
|
+
getPlatform(): DeviceInfo;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
log: {
|
|
264
|
+
info(message: string, data?: any): void;
|
|
265
|
+
warn(message: string, data?: any): void;
|
|
266
|
+
error(message: string, data?: any): void;
|
|
267
|
+
};
|
|
268
|
+
}
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global Plugin Bundle Manager — singleton.
|
|
3
|
+
*
|
|
4
|
+
* ONE download per bundleUrl (deduplicates multi-plugin bundles); all plugin
|
|
5
|
+
* renderer instances share the same loaded modules. Persistence goes through
|
|
6
|
+
* the BundleStorage adapter (in-memory by default; @ateam-ai-mobile/expo provides disk).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const component = await bundleManager.load(pluginId, bundleUrl, version, hash);
|
|
10
|
+
* // component = { id, type, Component, ... } (RegisteredUIPlugin)
|
|
11
|
+
*/
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import {
|
|
14
|
+
useState, useEffect, useCallback, useMemo, useRef,
|
|
15
|
+
useContext, useReducer, useLayoutEffect, useImperativeHandle, useDebugValue,
|
|
16
|
+
} from 'react';
|
|
17
|
+
import * as ReactNative from 'react-native';
|
|
18
|
+
import { apiFetchRaw } from '@ateam-ai-mobile/core';
|
|
19
|
+
import * as PluginSDKModule from '../sdk';
|
|
20
|
+
import { resolveBundleModule } from '../host';
|
|
21
|
+
import { sha256Hex } from '../utils/sha256';
|
|
22
|
+
import { getBundleStorage, type BundleCacheEntry } from './storage';
|
|
23
|
+
import type { RegisteredUIPlugin } from '../sdk/types';
|
|
24
|
+
|
|
25
|
+
// ── Require shim ─────────────────────────────────────────────
|
|
26
|
+
// Bundles are built with external: ['react', 'react-native', '@adas/plugin-sdk']
|
|
27
|
+
// so they emit require() calls that this shim resolves at runtime. Anything
|
|
28
|
+
// beyond the built-ins (react-native-webview, expo-linking, …) must be
|
|
29
|
+
// registered by the host via registerBundleModules() — the host is the one
|
|
30
|
+
// that actually has those modules installed.
|
|
31
|
+
|
|
32
|
+
const ReactShim: any = {
|
|
33
|
+
__esModule: true,
|
|
34
|
+
default: React,
|
|
35
|
+
...React,
|
|
36
|
+
// Explicitly include hooks (React 19 may not have them as own enumerable props)
|
|
37
|
+
useState, useEffect, useCallback, useMemo, useRef, useContext,
|
|
38
|
+
useReducer, useLayoutEffect, useImperativeHandle, useDebugValue,
|
|
39
|
+
createElement: React.createElement,
|
|
40
|
+
Fragment: React.Fragment,
|
|
41
|
+
Component: React.Component,
|
|
42
|
+
PureComponent: React.PureComponent,
|
|
43
|
+
createContext: React.createContext,
|
|
44
|
+
forwardRef: React.forwardRef,
|
|
45
|
+
memo: React.memo,
|
|
46
|
+
Children: React.Children,
|
|
47
|
+
cloneElement: React.cloneElement,
|
|
48
|
+
isValidElement: React.isValidElement,
|
|
49
|
+
createRef: React.createRef,
|
|
50
|
+
lazy: React.lazy,
|
|
51
|
+
Suspense: React.Suspense,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Freeze PluginSDK shim to prevent bundle monkey-patching of register()
|
|
55
|
+
const PluginSDKShim = Object.freeze({
|
|
56
|
+
...PluginSDKModule,
|
|
57
|
+
PluginSDK: Object.freeze({
|
|
58
|
+
...PluginSDKModule.PluginSDK,
|
|
59
|
+
register: PluginSDKModule.PluginSDK.register,
|
|
60
|
+
}),
|
|
61
|
+
register: PluginSDKModule.register,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const requireShim = (name: string) => {
|
|
65
|
+
if (name === 'react') return ReactShim;
|
|
66
|
+
if (name === 'react-native') return ReactNative;
|
|
67
|
+
if (name === '@adas/plugin-sdk') return PluginSDKShim;
|
|
68
|
+
const hostModule = resolveBundleModule(name);
|
|
69
|
+
if (hostModule !== undefined) return hostModule;
|
|
70
|
+
console.warn(`[BundleManager] Unknown module: ${name} — register it via registerBundleModules()`);
|
|
71
|
+
return {};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ── Cache constants ──────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
77
|
+
|
|
78
|
+
// ── BundleManager singleton ──────────────────────────────────
|
|
79
|
+
|
|
80
|
+
class BundleManager {
|
|
81
|
+
/** In-memory module cache: bundleUrl → evaluated module.exports */
|
|
82
|
+
private modules = new Map<string, any>();
|
|
83
|
+
|
|
84
|
+
/** In-flight download deduplication: bundleUrl → Promise */
|
|
85
|
+
private inflight = new Map<string, Promise<any>>();
|
|
86
|
+
|
|
87
|
+
// ── Public API ──────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Load a plugin component. Returns the RegisteredUIPlugin object.
|
|
91
|
+
* Handles: in-memory cache → disk cache → download → evaluate → extract.
|
|
92
|
+
*
|
|
93
|
+
* `expectedHash` (SHA-256 hex from cp.getContextPlugin manifest) MUST be
|
|
94
|
+
* provided for any bundle the user has not previously verified — without
|
|
95
|
+
* it, a tampered bundle is `eval`'d as JS with full RN runtime + every
|
|
96
|
+
* native capability the manifest declares.
|
|
97
|
+
*/
|
|
98
|
+
async load(
|
|
99
|
+
pluginId: string,
|
|
100
|
+
bundleUrl: string,
|
|
101
|
+
version: string,
|
|
102
|
+
expectedHash?: string,
|
|
103
|
+
): Promise<RegisteredUIPlugin> {
|
|
104
|
+
// 1. In-memory cache — instant
|
|
105
|
+
const cached = this.getComponentFromMemory(pluginId, bundleUrl);
|
|
106
|
+
if (cached) return cached;
|
|
107
|
+
|
|
108
|
+
// 2. Deduplicate in-flight downloads for same bundleUrl
|
|
109
|
+
let modulePromise = this.inflight.get(bundleUrl);
|
|
110
|
+
if (!modulePromise) {
|
|
111
|
+
modulePromise = this.loadModule(bundleUrl, version, expectedHash);
|
|
112
|
+
this.inflight.set(bundleUrl, modulePromise);
|
|
113
|
+
// Swallow rejection on the cleanup chain — the original `await` below
|
|
114
|
+
// still surfaces the error to the caller.
|
|
115
|
+
modulePromise.catch(() => {}).finally(() => this.inflight.delete(bundleUrl));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await modulePromise;
|
|
119
|
+
|
|
120
|
+
// 3. Extract component for this specific pluginId
|
|
121
|
+
const component = this.getComponentFromMemory(pluginId, bundleUrl);
|
|
122
|
+
if (!component) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Bundle loaded from ${bundleUrl} but no component found for "${pluginId}". ` +
|
|
125
|
+
`Available exports: ${this.describeModule(this.modules.get(bundleUrl))}`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return component;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Prewarm: load every plugin bundle in the background after app boot, so a
|
|
134
|
+
* later open is instant (in-memory hit) instead of round-tripping the
|
|
135
|
+
* network. Errors are swallowed per-plugin — one bad bundle shouldn't
|
|
136
|
+
* block others. Concurrency capped at 3.
|
|
137
|
+
*/
|
|
138
|
+
async prewarmAll(items: Array<{
|
|
139
|
+
pluginId: string;
|
|
140
|
+
bundleUrl: string;
|
|
141
|
+
version: string;
|
|
142
|
+
bundleHash?: string;
|
|
143
|
+
}>): Promise<void> {
|
|
144
|
+
const queue = [...items];
|
|
145
|
+
const concurrency = Math.min(3, queue.length);
|
|
146
|
+
const start = Date.now();
|
|
147
|
+
let okCount = 0;
|
|
148
|
+
let failCount = 0;
|
|
149
|
+
|
|
150
|
+
const worker = async () => {
|
|
151
|
+
while (queue.length > 0) {
|
|
152
|
+
const it = queue.shift();
|
|
153
|
+
if (!it) return;
|
|
154
|
+
try {
|
|
155
|
+
await this.load(it.pluginId, it.bundleUrl, it.version, it.bundleHash);
|
|
156
|
+
okCount++;
|
|
157
|
+
} catch (err) {
|
|
158
|
+
failCount++;
|
|
159
|
+
// Silent — preload is best-effort. Log for diagnostics only.
|
|
160
|
+
console.log(`[prewarm] ${it.pluginId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
await Promise.all(Array.from({ length: concurrency }, worker));
|
|
166
|
+
console.log(`[prewarm] ${okCount} loaded, ${failCount} failed in ${Date.now() - start}ms`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Clear all caches (in-memory + storage).
|
|
171
|
+
*/
|
|
172
|
+
async clearAll(): Promise<void> {
|
|
173
|
+
this.modules.clear();
|
|
174
|
+
try {
|
|
175
|
+
await getBundleStorage().clearAll();
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.warn('[BundleManager] clearAll error:', err);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Clear cache for a specific plugin/bundle.
|
|
183
|
+
*/
|
|
184
|
+
async clearPlugin(pluginId: string): Promise<void> {
|
|
185
|
+
const storage = getBundleStorage();
|
|
186
|
+
const index = await storage.loadIndex();
|
|
187
|
+
const entry = index[pluginId];
|
|
188
|
+
if (entry) {
|
|
189
|
+
this.modules.delete(entry.bundleUrl);
|
|
190
|
+
delete index[pluginId];
|
|
191
|
+
await storage.saveIndex(index);
|
|
192
|
+
try {
|
|
193
|
+
await storage.deleteBundle(entry.localPath);
|
|
194
|
+
} catch { /* ignore */ }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Private: module loading pipeline ────────────────────────
|
|
199
|
+
|
|
200
|
+
private async loadModule(bundleUrl: string, version: string, expectedHash?: string): Promise<void> {
|
|
201
|
+
// Already in memory?
|
|
202
|
+
if (this.modules.has(bundleUrl)) return;
|
|
203
|
+
|
|
204
|
+
const storage = getBundleStorage();
|
|
205
|
+
const index = await storage.loadIndex();
|
|
206
|
+
const expectedLc = expectedHash ? expectedHash.toLowerCase() : null;
|
|
207
|
+
|
|
208
|
+
// Check disk cache. A cache hit is only valid when the cached SHA-256
|
|
209
|
+
// matches the manifest hash exactly — never trust a cached bundle
|
|
210
|
+
// without proof.
|
|
211
|
+
let content: string | null = null;
|
|
212
|
+
for (const entry of Object.values(index)) {
|
|
213
|
+
if (entry.bundleUrl === bundleUrl && entry.version === version) {
|
|
214
|
+
const age = Date.now() - entry.timestamp;
|
|
215
|
+
const hashOk = expectedLc && entry.bundleHash && entry.bundleHash.toLowerCase() === expectedLc;
|
|
216
|
+
if (age < CACHE_TTL && hashOk) {
|
|
217
|
+
content = await storage.readBundle(entry.localPath);
|
|
218
|
+
if (content) break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Download if not cached
|
|
224
|
+
if (!content) {
|
|
225
|
+
const downloaded = await this.download(bundleUrl, expectedLc);
|
|
226
|
+
content = downloaded.content;
|
|
227
|
+
// Save with verified hash
|
|
228
|
+
const safeName = bundleUrl.replace(/[^a-zA-Z0-9]/g, '_').slice(-80);
|
|
229
|
+
try {
|
|
230
|
+
const localPath = await storage.writeBundle(`${safeName}@${version}.js`, content);
|
|
231
|
+
index[bundleUrl] = {
|
|
232
|
+
localPath,
|
|
233
|
+
version,
|
|
234
|
+
timestamp: Date.now(),
|
|
235
|
+
bundleUrl,
|
|
236
|
+
bundleHash: downloaded.sha256,
|
|
237
|
+
} satisfies BundleCacheEntry;
|
|
238
|
+
await storage.saveIndex(index);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
console.warn('[BundleManager] Cache write error:', err);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Evaluate
|
|
245
|
+
const mod = this.evaluate(content, bundleUrl);
|
|
246
|
+
this.modules.set(bundleUrl, mod);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Download + integrity-verify a bundle. Refuses to return when:
|
|
251
|
+
* - manifest hash and computed hash disagree
|
|
252
|
+
* - server's `X-Bundle-SHA256` disagrees with manifest hash
|
|
253
|
+
* - no expected hash AND no server hash header (cannot prove the bundle
|
|
254
|
+
* wasn't tampered with — fail closed rather than trust the wire).
|
|
255
|
+
*/
|
|
256
|
+
private async download(bundleUrl: string, expectedHash: string | null): Promise<{ content: string; sha256: string }> {
|
|
257
|
+
const cacheBust = `${bundleUrl}${bundleUrl.includes('?') ? '&' : '?'}t=${Date.now()}`;
|
|
258
|
+
const response = await apiFetchRaw(cacheBust);
|
|
259
|
+
if (!response.ok) {
|
|
260
|
+
const body = await response.text().catch(() => '');
|
|
261
|
+
throw new Error(`Download failed: ${response.status} ${response.statusText} — ${body.substring(0, 200)}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const content = await response.text();
|
|
265
|
+
if (content.length < 50 || content.trimStart().startsWith('<!')) {
|
|
266
|
+
throw new Error('Bundle content is empty or HTML — server may have returned an error page');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const headerHash = (response.headers.get('x-bundle-sha256') || '').toLowerCase();
|
|
270
|
+
const computed = sha256Hex(content);
|
|
271
|
+
|
|
272
|
+
if (expectedHash) {
|
|
273
|
+
if (computed !== expectedHash) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`Bundle integrity FAIL: expected sha256=${expectedHash.slice(0, 12)}... got ${computed.slice(0, 12)}.... Refusing to evaluate.`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (headerHash && headerHash !== expectedHash) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`Bundle hash mismatch: manifest=${expectedHash.slice(0, 12)}... server=${headerHash.slice(0, 12)}.... Refusing to evaluate.`
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
} else if (headerHash) {
|
|
284
|
+
if (computed !== headerHash) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`Bundle body sha256 (${computed.slice(0, 12)}...) does not match server's X-Bundle-SHA256 (${headerHash.slice(0, 12)}...).`
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
console.warn(`[BundleManager] No manifest bundleHash — verified via X-Bundle-SHA256 header only`);
|
|
290
|
+
} else {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`No bundleHash in manifest and no X-Bundle-SHA256 header from server. Refusing to evaluate untrusted bundle.`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { content, sha256: computed };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private evaluate(content: string, bundleUrl: string): any {
|
|
300
|
+
// Strip __toESM wrapping — our shim already provides correct ESM objects.
|
|
301
|
+
// esbuild's __toESM + isNodeMode=1 double-wraps and breaks React 19 hooks.
|
|
302
|
+
let cleaned = content.replace(
|
|
303
|
+
/__toESM\(require\(("[^"]+")\)(?:,\s*\d+)?\)/g,
|
|
304
|
+
'require($1)',
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
// Strip __toCommonJS wrapper — it creates a getter-based Proxy that
|
|
308
|
+
// collapses all named exports to the same object in Hermes/JSC.
|
|
309
|
+
cleaned = cleaned.replace(
|
|
310
|
+
/module\.exports\s*=\s*__toCommonJS\((\w+)\)/g,
|
|
311
|
+
'module.exports = $1',
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
const mod: { exports: any } = { exports: {} };
|
|
315
|
+
try {
|
|
316
|
+
const factory = new Function(
|
|
317
|
+
'module', 'exports', 'require', '__filename', '__dirname',
|
|
318
|
+
cleaned,
|
|
319
|
+
);
|
|
320
|
+
factory(mod, mod.exports, requireShim, bundleUrl, 'adas-plugin-bundles');
|
|
321
|
+
} catch (err) {
|
|
322
|
+
throw new Error(`Bundle evaluation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Materialize getter-based exports into plain values.
|
|
326
|
+
// esbuild's __export uses Object.defineProperty getters which can
|
|
327
|
+
// misbehave in Hermes/JSC — force-resolve them now.
|
|
328
|
+
const raw = mod.exports;
|
|
329
|
+
const materialized: Record<string, any> = {};
|
|
330
|
+
for (const key of Object.getOwnPropertyNames(raw)) {
|
|
331
|
+
try {
|
|
332
|
+
materialized[key] = raw[key];
|
|
333
|
+
} catch { /* skip broken getters */ }
|
|
334
|
+
}
|
|
335
|
+
return materialized;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── Private: component extraction ───────────────────────────
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Find a plugin component in a loaded module.
|
|
342
|
+
* Handles: default export, direct key match, multi-plugin .id match.
|
|
343
|
+
*/
|
|
344
|
+
private getComponentFromMemory(pluginId: string, bundleUrl: string): RegisteredUIPlugin | null {
|
|
345
|
+
const mod = this.modules.get(bundleUrl);
|
|
346
|
+
if (!mod) return null;
|
|
347
|
+
|
|
348
|
+
const shortName = pluginId.includes(':') ? pluginId.split(':').pop()! : pluginId;
|
|
349
|
+
|
|
350
|
+
// 1. Direct key match (full pluginId as key)
|
|
351
|
+
if (mod[pluginId] && typeof mod[pluginId].Component === 'function') {
|
|
352
|
+
return mod[pluginId] as RegisteredUIPlugin;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// 2. PascalCase match from shortName (most reliable for combined bundles)
|
|
356
|
+
// e.g., "memories-panel" → "MemoriesPanel"
|
|
357
|
+
const pascal = shortName.replace(/(^|-)([a-z])/g, (_: string, __: string, c: string) => c.toUpperCase());
|
|
358
|
+
if (mod[pascal] && typeof mod[pascal] === 'object' && typeof mod[pascal].Component === 'function') {
|
|
359
|
+
return mod[pascal] as RegisteredUIPlugin;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// 3. Default export (single-plugin bundles)
|
|
363
|
+
if (mod.default && typeof mod.default === 'object' && typeof mod.default.Component === 'function') {
|
|
364
|
+
return mod.default as RegisteredUIPlugin;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// 4. Match by .id field (last resort — can be unreliable in combined bundles)
|
|
368
|
+
for (const key of Object.keys(mod)) {
|
|
369
|
+
const val = mod[key];
|
|
370
|
+
if (
|
|
371
|
+
val &&
|
|
372
|
+
typeof val === 'object' &&
|
|
373
|
+
val.id === shortName &&
|
|
374
|
+
typeof val.Component === 'function'
|
|
375
|
+
) {
|
|
376
|
+
return val as RegisteredUIPlugin;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private describeModule(mod: any): string {
|
|
384
|
+
if (!mod) return '(empty)';
|
|
385
|
+
return Object.keys(mod)
|
|
386
|
+
.map(k => {
|
|
387
|
+
const v = mod[k];
|
|
388
|
+
if (v && typeof v === 'object' && v.id) return `${k}(id="${v.id}")`;
|
|
389
|
+
return k;
|
|
390
|
+
})
|
|
391
|
+
.join(', ');
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Singleton — shared across all plugin renderers */
|
|
396
|
+
export const bundleManager = new BundleManager();
|