@ateam-ai-mobile/plugin-runtime 0.1.5 → 0.1.7
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/chunk-PKDQLNBL.mjs +2873 -0
- package/dist/chunk-PKDQLNBL.mjs.map +1 -0
- package/dist/index.d.mts +1759 -8
- package/dist/index.d.ts +1759 -8
- package/dist/index.js +2467 -1321
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +335 -2139
- package/dist/index.mjs.map +1 -1
- package/dist/loader-5LUVXUY2.mjs +9 -0
- package/dist/loader-5LUVXUY2.mjs.map +1 -0
- package/package.json +2 -2
- package/src/bundles/bundleManager.ts +368 -27
- package/src/bundles/integrity.ts +29 -0
- package/src/bundles/storage.ts +40 -0
- package/src/deviceStateHost.ts +197 -0
- package/src/hooks/usePlugin.ts +1 -1
- package/src/host.ts +80 -8
- package/src/index.ts +90 -1
- package/src/pluginRuntime.ts +97 -0
- package/src/sdk/CameraPreview.tsx +94 -0
- package/src/sdk/device-state.ts +1225 -0
- package/src/sdk/index.ts +35 -3
- package/src/sdk/registration.ts +39 -0
- package/src/sdk/types.ts +345 -8
- package/src/sdk/voice.ts +148 -0
- package/src/tools/loader.ts +167 -0
- package/src/tools/platformTools.ts +41 -0
- package/src/tools/registry.ts +183 -0
- package/src/tools/serverCalls.ts +78 -0
- package/src/tools/sessionSync.ts +107 -0
- package/src/voiceHost.ts +126 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host DI for the SEMANTIC layer — the `props.deviceState` counterpart of
|
|
3
|
+
* `setPluginNativeProvider`.
|
|
4
|
+
*
|
|
5
|
+
* Kept in its own file rather than added to host.ts, for the same reason the
|
|
6
|
+
* two layers have separate namespaces: the moment they share a module, the
|
|
7
|
+
* next person adds a domain to `PluginNative` or a capability to
|
|
8
|
+
* `stateDomains` and the boundary starts to blur.
|
|
9
|
+
*
|
|
10
|
+
* THE GATE IS NOT THE MECHANICAL ONE. `gatePluginNative` replaces an
|
|
11
|
+
* undeclared capability with a proxy whose methods warn and return `undefined`
|
|
12
|
+
* — so `await native.location.getCurrent()` resolves to `undefined`, and
|
|
13
|
+
* `pos?.latitude` is `undefined`, which flows into a model answer as a
|
|
14
|
+
* confident nothing. A silent undefined is a fabrication vector.
|
|
15
|
+
*
|
|
16
|
+
* Here, an undeclared domain returns a real snapshot in which everything is
|
|
17
|
+
* explicitly `unknown` / `null`, with `freshness.state = 'unknown'`. The
|
|
18
|
+
* plugin gets a well-formed answer that says "I know nothing", which no
|
|
19
|
+
* reasonable code path can mistake for data.
|
|
20
|
+
*/
|
|
21
|
+
import type { DeviceBridgeDomain, DeviceState, StateQuality } from './sdk/device-state';
|
|
22
|
+
|
|
23
|
+
// The `unknown` freshness/confidence shape is the canonical `neverObserved()` +
|
|
24
|
+
// `{ level: 'unknown' }` (see sdk/device-state.ts). It is inlined here rather
|
|
25
|
+
// than imported because this module is imported directly by node --test and the
|
|
26
|
+
// harness resolves only the explicit entry, not a nested extensionless value
|
|
27
|
+
// import. The golden snapshot test pins this shape === neverObserved()'s.
|
|
28
|
+
const UNKNOWN_QUALITY: StateQuality = {
|
|
29
|
+
freshness: { state: 'unknown', observedAt: null, ageMs: null },
|
|
30
|
+
confidence: { level: 'unknown' },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The empty snapshot for a domain the plugin did not declare.
|
|
35
|
+
*
|
|
36
|
+
* Deliberately built per domain rather than one shared `{}`: each snapshot has
|
|
37
|
+
* required fields, and a caller reading `snapshot.position` must get `null`,
|
|
38
|
+
* not a TypeError. Domains not listed fall through to `{ quality }`, which is
|
|
39
|
+
* the one field every snapshot in the design shares.
|
|
40
|
+
*/
|
|
41
|
+
function deniedSnapshot(domain: DeviceBridgeDomain): any {
|
|
42
|
+
const base = { quality: UNKNOWN_QUALITY };
|
|
43
|
+
switch (domain) {
|
|
44
|
+
case 'spatial':
|
|
45
|
+
return {
|
|
46
|
+
...base,
|
|
47
|
+
position: null,
|
|
48
|
+
movement: { speedMps: null, bearingDeg: null, distanceRecentM: null, state: 'unknown' },
|
|
49
|
+
orientation: { headingDeg: null },
|
|
50
|
+
recentPath: [],
|
|
51
|
+
};
|
|
52
|
+
case 'visual':
|
|
53
|
+
return {
|
|
54
|
+
...base,
|
|
55
|
+
camera: { available: false, active: false, facing: 'unknown' },
|
|
56
|
+
latest: null,
|
|
57
|
+
recentTimeline: { windowMs: 0, frames: [] },
|
|
58
|
+
};
|
|
59
|
+
case 'deviceContext':
|
|
60
|
+
return {
|
|
61
|
+
...base,
|
|
62
|
+
power: { batteryLevel: null, charging: null, lowPowerMode: null },
|
|
63
|
+
thermal: { state: 'unknown' },
|
|
64
|
+
resources: { memoryPressure: 'unknown', storagePressure: 'unknown' },
|
|
65
|
+
};
|
|
66
|
+
case 'connectivity':
|
|
67
|
+
return {
|
|
68
|
+
...base,
|
|
69
|
+
reachability: 'unknown',
|
|
70
|
+
transport: 'unknown',
|
|
71
|
+
qualityClass: 'unknown',
|
|
72
|
+
stability: 'unknown',
|
|
73
|
+
latencyMs: null,
|
|
74
|
+
};
|
|
75
|
+
case 'interaction':
|
|
76
|
+
return {
|
|
77
|
+
...base,
|
|
78
|
+
lifecycle: { appState: 'unknown', screenOn: null },
|
|
79
|
+
audio: { captureActive: null, playbackActive: null, route: 'unknown', interruption: 'unknown' },
|
|
80
|
+
};
|
|
81
|
+
case 'activity':
|
|
82
|
+
return {
|
|
83
|
+
...base,
|
|
84
|
+
// `confidence` is REQUIRED on both blocks in the freeze, precisely
|
|
85
|
+
// because an inferred activity must never be handed over without
|
|
86
|
+
// saying how sure the inference is. An empty snapshot is the least
|
|
87
|
+
// sure of all.
|
|
88
|
+
human: { state: 'unknown', since: null, confidence: { level: 'unknown' } },
|
|
89
|
+
devicePose: { placement: 'unknown', orientation: 'unknown', confidence: { level: 'unknown' } },
|
|
90
|
+
recentTransitions: [],
|
|
91
|
+
};
|
|
92
|
+
case 'environment':
|
|
93
|
+
return {
|
|
94
|
+
...base,
|
|
95
|
+
lighting: { level: 'unknown', lux: null },
|
|
96
|
+
acoustics: { level: 'unknown', approximateDb: null },
|
|
97
|
+
// 'unknown' rather than 'uncertain': uncertain means we looked and
|
|
98
|
+
// could not decide, and nothing looked at all here.
|
|
99
|
+
enclosure: { state: 'unknown' },
|
|
100
|
+
proximity: { distanceClass: 'unknown' },
|
|
101
|
+
nearbySignals: { bleAvailable: false, beaconCount: null, uwbAvailable: false },
|
|
102
|
+
};
|
|
103
|
+
case 'capability': {
|
|
104
|
+
// Every axis unknown — and note this is NOT the same as `denied`.
|
|
105
|
+
// "I could not check" and "the user said no" lead to different things
|
|
106
|
+
// being said to the user, which is why the freeze keeps them apart.
|
|
107
|
+
const unknownStatus = { support: 'unknown', permission: 'unknown', availability: 'unknown' };
|
|
108
|
+
return {
|
|
109
|
+
...base,
|
|
110
|
+
camera: unknownStatus,
|
|
111
|
+
microphone: unknownStatus,
|
|
112
|
+
location: { ...unknownStatus, precision: 'unknown', backgroundPermission: 'unknown' },
|
|
113
|
+
motion: unknownStatus,
|
|
114
|
+
bluetooth: unknownStatus,
|
|
115
|
+
uwb: unknownStatus,
|
|
116
|
+
nfc: unknownStatus,
|
|
117
|
+
notifications: unknownStatus,
|
|
118
|
+
health: unknownStatus,
|
|
119
|
+
backgroundExecution: unknownStatus,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
default:
|
|
123
|
+
return base;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A domain the plugin may not use, or that this build does not implement.
|
|
129
|
+
*
|
|
130
|
+
* Reads answer with explicit unknowns. Anything that ACTS — start, stop,
|
|
131
|
+
* subscribe — throws, because there is no honest "unknown" for an action: a
|
|
132
|
+
* silent no-op start would leave the caller believing a session is open, and a
|
|
133
|
+
* subscription that never fires reads as "nothing has changed".
|
|
134
|
+
*/
|
|
135
|
+
function deniedDomain(domain: DeviceBridgeDomain, why: string): any {
|
|
136
|
+
return {
|
|
137
|
+
async getSnapshot() { return deniedSnapshot(domain); },
|
|
138
|
+
async start() { throw denial(domain, why); },
|
|
139
|
+
async stop() { throw denial(domain, why); },
|
|
140
|
+
subscribe() { throw denial(domain, why); },
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function denial(domain: DeviceBridgeDomain, why: string): Error {
|
|
145
|
+
const err: any = new Error(`deviceState.${domain}: ${why}`);
|
|
146
|
+
// Frozen vocabulary. A manifest gate IS a policy, so a manifest denial is
|
|
147
|
+
// policyRestricted — no new error vocabulary needed.
|
|
148
|
+
err.code = 'RESTRICTED';
|
|
149
|
+
err.domain = domain;
|
|
150
|
+
return err;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const ALL_DOMAINS: DeviceBridgeDomain[] = [
|
|
154
|
+
'spatial', 'visual', 'activity', 'deviceContext',
|
|
155
|
+
'connectivity', 'capability', 'environment', 'interaction',
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
let _provider: Partial<DeviceState> = {};
|
|
159
|
+
|
|
160
|
+
/** Host DI: the app supplies the engines it has. Called once at startup. */
|
|
161
|
+
export function setDeviceStateProvider(state: Partial<DeviceState>): void {
|
|
162
|
+
_provider = state || {};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The `deviceState` a plugin receives, gated by what it declared.
|
|
167
|
+
*
|
|
168
|
+
* Every domain is always present — a plugin can call any of the eight without
|
|
169
|
+
* an existence check. What differs is whether it answers with data, or with
|
|
170
|
+
* an explicit "I know nothing" and a reason.
|
|
171
|
+
*/
|
|
172
|
+
export function gateDeviceState(declared?: readonly DeviceBridgeDomain[]): DeviceState {
|
|
173
|
+
const allowed = new Set(declared || []);
|
|
174
|
+
const out: any = {};
|
|
175
|
+
|
|
176
|
+
for (const domain of ALL_DOMAINS) {
|
|
177
|
+
if (!allowed.has(domain)) {
|
|
178
|
+
out[domain] = deniedDomain(
|
|
179
|
+
domain,
|
|
180
|
+
`not declared in this plugin's stateDomains. Add '${domain}' to use it.`,
|
|
181
|
+
);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const engine = (_provider as any)[domain];
|
|
185
|
+
out[domain] = engine ?? deniedDomain(
|
|
186
|
+
domain,
|
|
187
|
+
'no engine in this build — the native side does not exist yet. See docs/DEVICE_API_STATUS.md.',
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return out as DeviceState;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Which domains this build can actually answer — for diagnostics. */
|
|
195
|
+
export function implementedStateDomains(): DeviceBridgeDomain[] {
|
|
196
|
+
return ALL_DOMAINS.filter(d => !!(_provider as any)[d]);
|
|
197
|
+
}
|
package/src/hooks/usePlugin.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* const { plugin, loading, error } = usePlugin(pluginId, manifest);
|
|
9
9
|
* if (loading) return <Spinner />;
|
|
10
10
|
* if (error) return <Error message={error} />;
|
|
11
|
-
* return <plugin.Component bridge={bridge}
|
|
11
|
+
* return <plugin.Component bridge={bridge} device={device} theme={theme} context={context} />;
|
|
12
12
|
*/
|
|
13
13
|
import { useState, useEffect } from 'react';
|
|
14
14
|
import { bundleManager } from '../bundles/bundleManager';
|
package/src/host.ts
CHANGED
|
@@ -41,19 +41,22 @@ const noopNative: PluginNative = {
|
|
|
41
41
|
biometrics: unavailable('biometrics'),
|
|
42
42
|
biometricsV2: unavailable('biometricsV2'),
|
|
43
43
|
notifications: unavailable('notifications'),
|
|
44
|
-
haptics: { impact: () => {}, notification: () => {}, selection: () => {} },
|
|
44
|
+
haptics: { impact: () => {}, notification: () => {}, selection: () => {}, error: () => {} },
|
|
45
45
|
connectivity: { isOnline: () => true, onChange: () => () => {} },
|
|
46
46
|
fileSystem: unavailable('fileSystem'),
|
|
47
47
|
cookies: unavailable('cookies'),
|
|
48
48
|
sharing: unavailable('sharing'),
|
|
49
49
|
scanner: unavailable('scanner'),
|
|
50
50
|
nfc: unavailable('nfc'),
|
|
51
|
+
bluetooth: unavailable('bluetooth'),
|
|
52
|
+
vision: unavailable('vision'),
|
|
51
53
|
};
|
|
52
54
|
|
|
53
55
|
let _pluginNative: PluginNative = noopNative;
|
|
54
56
|
|
|
55
57
|
/** Install the device-capabilities implementation plugins receive as
|
|
56
|
-
* `props.native
|
|
58
|
+
* `props.device` (the L1 native modules keep the `native` name; the plugin-
|
|
59
|
+
* facing prop is `device`). The host owns the native modules. */
|
|
57
60
|
export function setPluginNativeProvider(native: PluginNative): void {
|
|
58
61
|
_pluginNative = native;
|
|
59
62
|
}
|
|
@@ -64,12 +67,12 @@ export function getPluginNative(): PluginNative {
|
|
|
64
67
|
|
|
65
68
|
/** Blocked-capability proxy: any call warns and is ignored (no throw) —
|
|
66
69
|
* matches the reference app's behaviour for undeclared capabilities. */
|
|
67
|
-
function blocked(api: string): any {
|
|
70
|
+
function blocked(api: string, reason?: string): any {
|
|
68
71
|
return new Proxy({}, {
|
|
69
72
|
get: (_t, method) => {
|
|
70
73
|
if (typeof method !== 'string') return undefined;
|
|
71
74
|
return () => {
|
|
72
|
-
console.warn(`[adas] native.${api}.${method} blocked — capability '${api}' not declared in the plugin manifest`);
|
|
75
|
+
console.warn(`[adas] native.${api}.${method} blocked — ${reason ?? `capability '${api}' not declared in the plugin manifest`}`);
|
|
73
76
|
};
|
|
74
77
|
},
|
|
75
78
|
});
|
|
@@ -87,23 +90,92 @@ function blocked(api: string): any {
|
|
|
87
90
|
export function gatePluginNative(
|
|
88
91
|
native: PluginNative,
|
|
89
92
|
declared?: NativeCapabilities,
|
|
93
|
+
pluginId?: string,
|
|
90
94
|
): PluginNative {
|
|
91
95
|
const caps = declared || {};
|
|
92
|
-
|
|
96
|
+
// Per-plugin filesystem + storage isolation, FAIL CLOSED. A declared plugin
|
|
97
|
+
// gets a sandbox/namespace scoped to its OWN id (plugin-files/<pluginId>/,
|
|
98
|
+
// @plugin:<id>: KV, plugin-storage-<id>.db) via the provider's
|
|
99
|
+
// fileSystemFor/storageFor. There is deliberately NO shared fallback: if the
|
|
100
|
+
// id is missing or the provider can't scope, the plugin is BLOCKED, not handed
|
|
101
|
+
// a bucket some other plugin also holds. A silent shared fallback is exactly
|
|
102
|
+
// the P1 that reopened when the UI renderer omitted the id — the isolation must
|
|
103
|
+
// fail closed, never degrade to "shared" without anyone noticing.
|
|
104
|
+
const scopedFileSystem =
|
|
105
|
+
native.fileSystemFor && pluginId ? native.fileSystemFor(pluginId) : null;
|
|
106
|
+
const scopedStorage =
|
|
107
|
+
native.storageFor && pluginId ? native.storageFor(pluginId) : null;
|
|
108
|
+
const gated = {
|
|
93
109
|
camera: caps.camera ? native.camera : blocked('camera'),
|
|
94
110
|
location: caps.location ? native.location : blocked('location'),
|
|
95
|
-
storage: caps.offline
|
|
111
|
+
storage: caps.offline
|
|
112
|
+
? (scopedStorage ?? blocked('storage', "no trusted pluginId to scope storage — refusing shared access (fail closed)"))
|
|
113
|
+
: blocked('storage'),
|
|
96
114
|
biometrics: caps.biometrics ? native.biometrics : blocked('biometrics'),
|
|
97
115
|
biometricsV2: caps.biometricsV2 ? native.biometricsV2 : blocked('biometricsV2'),
|
|
98
116
|
notifications: caps.notifications ? native.notifications : blocked('notifications'),
|
|
99
117
|
haptics: caps.haptics ? native.haptics : blocked('haptics'),
|
|
100
118
|
connectivity: native.connectivity,
|
|
101
|
-
fileSystem: caps.fileSystem
|
|
119
|
+
fileSystem: caps.fileSystem
|
|
120
|
+
? (scopedFileSystem ?? blocked('fileSystem', "no trusted pluginId to scope the filesystem — refusing shared access (fail closed)"))
|
|
121
|
+
: blocked('fileSystem'),
|
|
102
122
|
cookies: caps.cookies ? native.cookies : blocked('cookies'),
|
|
103
123
|
sharing: caps.sharing ? native.sharing : blocked('sharing'),
|
|
104
124
|
scanner: caps.scanner ? native.scanner : blocked('scanner'),
|
|
105
125
|
nfc: caps.nfc ? native.nfc : blocked('nfc'),
|
|
106
|
-
|
|
126
|
+
// Gated like the rest even though nothing implements it yet. An undeclared
|
|
127
|
+
// plugin gets the usual blocked proxy; a DECLARED one reaches an API whose
|
|
128
|
+
// every method refuses by name. Both are better than the `undefined` this
|
|
129
|
+
// used to be, which crashed with "cannot read properties of undefined".
|
|
130
|
+
bluetooth: caps.bluetooth ? native.bluetooth : blocked('bluetooth'),
|
|
131
|
+
vision: caps.vision ? native.vision : blocked('vision'),
|
|
132
|
+
// What this plugin DECLARED, carried on the object it already receives.
|
|
133
|
+
//
|
|
134
|
+
// It is how a COMPONENT can be capability-gated: <CameraPreview> is handed
|
|
135
|
+
// `native` and reads this, so showing the camera obeys the same flag as
|
|
136
|
+
// calling it. Without this the preview would be a second door to the camera
|
|
137
|
+
// with only one lock. Non-enumerable so it cannot be mistaken for an API.
|
|
138
|
+
} as any;
|
|
139
|
+
|
|
140
|
+
Object.defineProperty(gated, '__declaredCapabilities', {
|
|
141
|
+
value: Object.freeze({ ...caps }),
|
|
142
|
+
enumerable: false,
|
|
143
|
+
writable: false,
|
|
144
|
+
});
|
|
145
|
+
return gated;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Camera preview (the plugin-facing way to SHOW the camera) ─────────────
|
|
149
|
+
//
|
|
150
|
+
// A plugin composes <CameraPreview native={native} /> from the SDK; the HOST
|
|
151
|
+
// supplies what actually renders, because only the host has the native module.
|
|
152
|
+
//
|
|
153
|
+
// This exists so we do NOT register raw `expo-camera` for bundles. That would
|
|
154
|
+
// be a third door to the device with no capability gate and no honest failure
|
|
155
|
+
// mode — a plugin could mount a camera without declaring it. Here the pixels
|
|
156
|
+
// stay on the host's side: a plugin can SHOW the camera and still cannot touch
|
|
157
|
+
// a frame.
|
|
158
|
+
|
|
159
|
+
/** Props the host's preview implementation receives. */
|
|
160
|
+
export interface CameraPreviewImplProps {
|
|
161
|
+
facing?: 'front' | 'back';
|
|
162
|
+
style?: any;
|
|
163
|
+
renderUnavailable?: (reason: string) => any;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let _cameraPreview: ((props: CameraPreviewImplProps) => any) | null = null;
|
|
167
|
+
|
|
168
|
+
/** Install the host's camera preview (e.g. an expo-camera CameraView). */
|
|
169
|
+
export function setCameraPreviewProvider(
|
|
170
|
+
Component: ((props: CameraPreviewImplProps) => any) | null,
|
|
171
|
+
): void {
|
|
172
|
+
_cameraPreview = Component;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Null when the host installed none — the SDK then renders a stated reason,
|
|
176
|
+
* never a black rectangle that looks like a dark room. */
|
|
177
|
+
export function getCameraPreviewProvider(): ((props: CameraPreviewImplProps) => any) | null {
|
|
178
|
+
return _cameraPreview;
|
|
107
179
|
}
|
|
108
180
|
|
|
109
181
|
// ── Native (host-bundled) plugin registry ─────────────────────────────────
|
package/src/index.ts
CHANGED
|
@@ -13,14 +13,71 @@
|
|
|
13
13
|
* No Expo imports — @ateam-ai-mobile/expo supplies the Expo-backed providers.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
// Plugin SDK (plugin-author surface)
|
|
16
|
+
// Plugin SDK (plugin-author surface) — the MECHANICAL layer.
|
|
17
|
+
// `props.device.*`: verbs, bare values, throws when it cannot.
|
|
17
18
|
export * from './sdk';
|
|
18
19
|
|
|
20
|
+
// Device Bridge v2 — the SEMANTIC layer. `props.deviceState.*`: state names,
|
|
21
|
+
// snapshots carrying freshness and confidence, and reads that say `unknown`
|
|
22
|
+
// rather than throwing.
|
|
23
|
+
//
|
|
24
|
+
// Exported under its own path rather than star-exported into this namespace,
|
|
25
|
+
// deliberately: the whole point of the layer is that a reader can tell at a
|
|
26
|
+
// glance which one they are in, and merging both into one import undoes that.
|
|
27
|
+
export * as deviceStateV2 from './sdk/device-state';
|
|
28
|
+
export type {
|
|
29
|
+
DeviceState,
|
|
30
|
+
DeviceBridgeDomain,
|
|
31
|
+
StateQuality,
|
|
32
|
+
Freshness,
|
|
33
|
+
Confidence,
|
|
34
|
+
VisualSnapshot,
|
|
35
|
+
SpatialSnapshot,
|
|
36
|
+
SpatialPoint,
|
|
37
|
+
SpatialIntent,
|
|
38
|
+
ActivitySnapshot,
|
|
39
|
+
EnvironmentSnapshot,
|
|
40
|
+
InteractionSnapshot,
|
|
41
|
+
CapabilitySnapshot,
|
|
42
|
+
CapabilityStatus,
|
|
43
|
+
DeviceContextSnapshot,
|
|
44
|
+
ConnectivitySnapshot,
|
|
45
|
+
} from './sdk/device-state';
|
|
46
|
+
// Device Bridge v2 runtime helpers (VALUES). `neverObserved` is the canonical
|
|
47
|
+
// `unknown` freshness the engine's quality module derives from; the others were
|
|
48
|
+
// already in the built types but missing from the source root export.
|
|
49
|
+
export {
|
|
50
|
+
neverObserved,
|
|
51
|
+
isActionable,
|
|
52
|
+
explainCapability,
|
|
53
|
+
} from './sdk/device-state';
|
|
54
|
+
|
|
55
|
+
// Device Bridge v2 host DI — the semantic counterpart of
|
|
56
|
+
// setPluginNativeProvider. Separate module on purpose: the moment the two
|
|
57
|
+
// layers share one, the boundary starts to blur.
|
|
58
|
+
export {
|
|
59
|
+
setDeviceStateProvider,
|
|
60
|
+
gateDeviceState,
|
|
61
|
+
implementedStateDomains,
|
|
62
|
+
} from './deviceStateHost';
|
|
63
|
+
|
|
64
|
+
// Voice session host DI — the app hands its one live session here, and the gate
|
|
65
|
+
// (called only via resolvePluginRuntimeProps) decides per-plugin exposure. Its
|
|
66
|
+
// own file for the same reason deviceState is: a session is neither a mechanical
|
|
67
|
+
// verb nor a device domain, and merging it in would blur that.
|
|
68
|
+
export {
|
|
69
|
+
setVoiceProvider,
|
|
70
|
+
gateVoice,
|
|
71
|
+
implementedVoice,
|
|
72
|
+
} from './voiceHost';
|
|
73
|
+
|
|
19
74
|
// Host integration
|
|
20
75
|
export {
|
|
21
76
|
setHapticsProvider,
|
|
22
77
|
getHaptics,
|
|
23
78
|
setPluginNativeProvider,
|
|
79
|
+
setCameraPreviewProvider,
|
|
80
|
+
getCameraPreviewProvider,
|
|
24
81
|
getPluginNative,
|
|
25
82
|
gatePluginNative,
|
|
26
83
|
registerNativePlugin,
|
|
@@ -29,8 +86,40 @@ export {
|
|
|
29
86
|
registerBundleModules,
|
|
30
87
|
} from './host';
|
|
31
88
|
|
|
89
|
+
// The ONE runtime contract every renderer uses to gate a plugin's device surface
|
|
90
|
+
// (device/native + deviceState) — so a plugin gets identical access on every
|
|
91
|
+
// surface. See pluginRuntime.ts for why this is centralized.
|
|
92
|
+
export { resolvePluginRuntimeProps } from './pluginRuntime';
|
|
93
|
+
export type { PluginRuntimeProps, PluginRuntimeSource } from './pluginRuntime';
|
|
94
|
+
|
|
95
|
+
// Device tools (plugin `tools/` sections — registered at bundle load)
|
|
96
|
+
export {
|
|
97
|
+
registerDeviceTools,
|
|
98
|
+
unregisterDeviceTools,
|
|
99
|
+
setDeviceToolContextProvider,
|
|
100
|
+
callDeviceTool,
|
|
101
|
+
listDeviceTools,
|
|
102
|
+
hasDeviceTool,
|
|
103
|
+
clearDeviceTools,
|
|
104
|
+
} from './tools/registry';
|
|
105
|
+
export type { DeviceToolSummary, DeviceToolOutcome } from './tools/registry';
|
|
106
|
+
export { ensureDeviceToolsLoaded, resetDeviceToolLoader } from './tools/loader';
|
|
107
|
+
export { registerPlatformDeviceTools } from './tools/platformTools';
|
|
108
|
+
export {
|
|
109
|
+
syncDeviceToolsForSession,
|
|
110
|
+
deviceToolsActionForSession,
|
|
111
|
+
__resetSessionSyncForTest,
|
|
112
|
+
} from './tools/sessionSync';
|
|
113
|
+
export type { SessionSnapshot, DeviceToolsAction, DeviceToolsSessionDeps } from './tools/sessionSync';
|
|
114
|
+
export {
|
|
115
|
+
handleServerDeviceToolCall,
|
|
116
|
+
isDeviceToolCommand,
|
|
117
|
+
setDeviceToolResultPoster,
|
|
118
|
+
} from './tools/serverCalls';
|
|
119
|
+
|
|
32
120
|
// Bundles
|
|
33
121
|
export { bundleManager } from './bundles/bundleManager';
|
|
122
|
+
export type { LoadedBundleInfo, LoadedBundleSource } from './bundles/bundleManager';
|
|
34
123
|
export { setBundleStorage, MemoryBundleStorage } from './bundles/storage';
|
|
35
124
|
export type { BundleStorage, BundleCacheEntry } from './bundles/storage';
|
|
36
125
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE runtime contract that turns a plugin's declarations into its gated
|
|
3
|
+
* device surface — `props.device` / `props.native` (L2) and `props.deviceState`
|
|
4
|
+
* (L3) — for EVERY renderer, on EVERY surface.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS. Each renderer used to resolve this itself: the embedded route
|
|
7
|
+
* gated both `native` and `deviceState`; the drawer/fullscreen renderer gated
|
|
8
|
+
* only `native` and read `manifest.native` instead of `manifest.capabilities`;
|
|
9
|
+
* the host-built-in surfaces gated `native` off the registry entry and passed no
|
|
10
|
+
* `deviceState` at all. The SAME plugin therefore got full device access on one
|
|
11
|
+
* surface and `undefined` on another — invisible to the author, whose manifest
|
|
12
|
+
* was correct. A seam owned by three renderers is tested by none. This makes it
|
|
13
|
+
* owned by one: every renderer calls resolvePluginRuntimeProps, so they cannot
|
|
14
|
+
* disagree, and a parity test on this function covers all of them at once.
|
|
15
|
+
*/
|
|
16
|
+
import { getPluginNative, gatePluginNative } from './host';
|
|
17
|
+
import { gateDeviceState } from './deviceStateHost';
|
|
18
|
+
import { gateVoice } from './voiceHost';
|
|
19
|
+
import type { PluginNative, NativeCapabilities } from './sdk/types';
|
|
20
|
+
import type { DeviceState, DeviceBridgeDomain } from './sdk/device-state';
|
|
21
|
+
import type { VoiceAPI } from './sdk/voice';
|
|
22
|
+
|
|
23
|
+
/** The gated surface a plugin Component receives. `device` and `native` are the
|
|
24
|
+
* SAME object (native is the deprecated alias). */
|
|
25
|
+
export interface PluginRuntimeProps {
|
|
26
|
+
device: PluginNative;
|
|
27
|
+
native: PluginNative;
|
|
28
|
+
deviceState: DeviceState;
|
|
29
|
+
voice: VoiceAPI;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Where a plugin's declarations come from. TRUSTED source resolution lives here,
|
|
34
|
+
* not in each caller: the server `manifest` wins, the loaded bundle's own default
|
|
35
|
+
* export is the fallback.
|
|
36
|
+
*/
|
|
37
|
+
/** One declaration source — either the server manifest or the registry entry. */
|
|
38
|
+
interface PluginDeclaration {
|
|
39
|
+
capabilities?: NativeCapabilities | Record<string, unknown>;
|
|
40
|
+
native?: NativeCapabilities;
|
|
41
|
+
stateDomains?: readonly DeviceBridgeDomain[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PluginRuntimeSource {
|
|
45
|
+
pluginId: string;
|
|
46
|
+
/**
|
|
47
|
+
* The server manifest (Core-passed) — the TRUSTED authority for a REMOTE
|
|
48
|
+
* plugin. `capabilities` is the current field Core emits; `native` is the
|
|
49
|
+
* legacy one. A grant absent here is NOT GRANTED (fail closed).
|
|
50
|
+
*/
|
|
51
|
+
manifest?: PluginDeclaration | null;
|
|
52
|
+
/**
|
|
53
|
+
* The registry entry — the trusted authority for a HOST-OWNED plugin
|
|
54
|
+
* (registered locally, no server manifest). For a REMOTE plugin this is the
|
|
55
|
+
* downloaded bundle's own claim and is UNTRUSTED for gating; it is used ONLY
|
|
56
|
+
* when there is no manifest.
|
|
57
|
+
*/
|
|
58
|
+
plugin?: PluginDeclaration | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Union the recognized native-capability flags from a source's `native`
|
|
62
|
+
* (legacy) and `capabilities` (current) fields. A grant in EITHER is honored;
|
|
63
|
+
* neither shadows the other. `capabilities` may also carry non-capability
|
|
64
|
+
* metadata (e.g. `device_tools`) — gatePluginNative reads only the flags it
|
|
65
|
+
* knows and ignores the rest. */
|
|
66
|
+
function mergeCaps(decl: PluginDeclaration | null | undefined): NativeCapabilities | undefined {
|
|
67
|
+
if (!decl) return undefined;
|
|
68
|
+
return { ...(decl.native as any), ...(decl.capabilities as any) } as NativeCapabilities;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Turn a plugin's declarations into the gated { device, native, deviceState } it
|
|
73
|
+
* receives — the SINGLE place this happens, so every surface is identical.
|
|
74
|
+
*
|
|
75
|
+
* TRUST BOUNDARY. Exactly ONE source is authoritative: the server `manifest` for
|
|
76
|
+
* a remote plugin, the registry `plugin` for a host-owned one. They are NEVER
|
|
77
|
+
* mixed, and a remote plugin's own downloaded bundle claim NEVER expands its
|
|
78
|
+
* capabilities or L3 domains past what the trusted source granted. If the
|
|
79
|
+
* trusted source omits a grant, it is not granted — fail closed. (If Core does
|
|
80
|
+
* not carry `stateDomains` for a remote plugin, the fix is Core passing it, not
|
|
81
|
+
* trusting the bundle: Core already holds the solution's declaration.)
|
|
82
|
+
*/
|
|
83
|
+
export function resolvePluginRuntimeProps(src: PluginRuntimeSource): PluginRuntimeProps {
|
|
84
|
+
const trusted = src.manifest ?? src.plugin ?? null;
|
|
85
|
+
const capabilities = mergeCaps(trusted);
|
|
86
|
+
const stateDomains = trusted?.stateDomains;
|
|
87
|
+
const native = gatePluginNative(getPluginNative(), capabilities, src.pluginId);
|
|
88
|
+
const deviceState = gateDeviceState(stateDomains);
|
|
89
|
+
// Voice is a capability flag, gated the same way — the TRUSTED declaration
|
|
90
|
+
// decides, a downloaded bundle cannot grant itself the session (or the
|
|
91
|
+
// transcript it can read). The facade is bound to the TRUSTED pluginId, so the
|
|
92
|
+
// session is isolated per solution: declaring voice runs YOUR conversation, it
|
|
93
|
+
// does not attach to another plugin's live one.
|
|
94
|
+
const voice = gateVoice(!!(capabilities as any)?.voice, src.pluginId);
|
|
95
|
+
// device and native are intentionally the SAME gated object (R4 rename).
|
|
96
|
+
return { device: native, native, deviceState, voice };
|
|
97
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CameraPreview — the plugin-facing way to SHOW the user what the camera sees.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS RATHER THAN HANDING PLUGINS `expo-camera`.
|
|
5
|
+
*
|
|
6
|
+
* The design has exactly two doors to the device: `props.native.*` (mechanical
|
|
7
|
+
* verbs, capability-gated, throws) and `props.deviceState.*` (semantic state,
|
|
8
|
+
* gated, says unknown). Registering a raw native module for bundles would open
|
|
9
|
+
* a THIRD door with no grammar, no gate and no honest failure mode — a plugin
|
|
10
|
+
* could mount a camera without declaring it, and nothing in the manifest would
|
|
11
|
+
* say so. That is the shape of hole this platform keeps closing, not opening.
|
|
12
|
+
*
|
|
13
|
+
* So the preview is a COMPONENT the plugin composes, and the pixels stay on the
|
|
14
|
+
* host's side of the boundary. A plugin can show the camera; it still cannot
|
|
15
|
+
* touch a frame. Reading frames remains `deviceState.visual` (semantic, with
|
|
16
|
+
* freshness) and analysing them remains `native.vision` (which takes an id and
|
|
17
|
+
* resolves it inside the trusted layer).
|
|
18
|
+
*
|
|
19
|
+
* IT IS GATED BY THE SAME FLAG AS `native.camera`. One door, one lock: a plugin
|
|
20
|
+
* that has not declared `capabilities: { camera: true }` gets a refusal panel
|
|
21
|
+
* saying exactly that, not a black rectangle it might mistake for a dark room.
|
|
22
|
+
*
|
|
23
|
+
* IT NEVER RENDERS NOTHING. Every failure — capability not declared, host has
|
|
24
|
+
* not installed a provider, OS permission refused — draws a panel stating which
|
|
25
|
+
* one. A preview that silently renders empty is indistinguishable from a camera
|
|
26
|
+
* pointed at a wall, and a guide built on it would describe a scene nobody saw.
|
|
27
|
+
*/
|
|
28
|
+
import React from 'react';
|
|
29
|
+
import { View, Text } from './primitives';
|
|
30
|
+
import type { PluginNative } from './types';
|
|
31
|
+
import { getCameraPreviewProvider } from '../host';
|
|
32
|
+
|
|
33
|
+
export interface CameraPreviewProps {
|
|
34
|
+
/**
|
|
35
|
+
* The plugin's own `props.native`. REQUIRED, and it is the gate: the runtime
|
|
36
|
+
* stamps the declared capabilities onto this object, so passing it is how the
|
|
37
|
+
* component knows whether this plugin may show a camera at all.
|
|
38
|
+
*/
|
|
39
|
+
native: PluginNative;
|
|
40
|
+
facing?: 'front' | 'back';
|
|
41
|
+
style?: any;
|
|
42
|
+
/** Rendered instead of the built-in panel when the preview is unavailable. */
|
|
43
|
+
renderUnavailable?: (reason: string) => React.ReactElement | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Declared capabilities, stamped onto the gated object by gatePluginNative. */
|
|
47
|
+
function declaredCapabilities(native: any): Record<string, boolean> | null {
|
|
48
|
+
return (native && native.__declaredCapabilities) || null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function CameraPreview({ native, facing = 'back', style, renderUnavailable }: CameraPreviewProps) {
|
|
52
|
+
const caps = declaredCapabilities(native);
|
|
53
|
+
|
|
54
|
+
// The gate. Deliberately checked BEFORE asking the host for a provider, so a
|
|
55
|
+
// plugin cannot learn whether a camera exists without declaring that it wants
|
|
56
|
+
// one — an undeclared plugin gets the same answer on every device.
|
|
57
|
+
if (caps && !caps.camera) {
|
|
58
|
+
return unavailable(
|
|
59
|
+
"this plugin did not declare `capabilities: { camera: true }`, so it may not show the camera",
|
|
60
|
+
renderUnavailable,
|
|
61
|
+
style,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const Provider = getCameraPreviewProvider();
|
|
66
|
+
if (!Provider) {
|
|
67
|
+
// The host has not installed a preview implementation. Say so rather than
|
|
68
|
+
// rendering black: "no provider" and "a dark room" must never look alike.
|
|
69
|
+
return unavailable(
|
|
70
|
+
'this app does not provide a camera preview (the host has not called setCameraPreviewProvider)',
|
|
71
|
+
renderUnavailable,
|
|
72
|
+
style,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return <Provider facing={facing} style={style} renderUnavailable={renderUnavailable} />;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function unavailable(
|
|
80
|
+
reason: string,
|
|
81
|
+
render: CameraPreviewProps['renderUnavailable'],
|
|
82
|
+
style: any,
|
|
83
|
+
): React.ReactElement | null {
|
|
84
|
+
if (render) return render(reason);
|
|
85
|
+
return (
|
|
86
|
+
<View style={[{ flex: 1, backgroundColor: '#000', alignItems: 'center', justifyContent: 'center', padding: 20 }, style]}>
|
|
87
|
+
<Text style={{ color: '#39FF66', fontSize: 12, textAlign: 'center' }}>
|
|
88
|
+
{`NO CAMERA PREVIEW\n${reason}`}
|
|
89
|
+
</Text>
|
|
90
|
+
</View>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export default CameraPreview;
|