@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.
@@ -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
+ }
@@ -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} native={native} theme={theme} context={context} />;
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`. The host owns the native modules. */
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
- return {
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 ? native.storage : blocked('storage'),
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 ? native.fileSystem : blocked('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;