@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.
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Bundle disk-cache adapter. The bundle manager persists downloaded plugin
3
+ * bundles through this interface so the package stays free of Expo/RN
4
+ * storage imports. @ateam-ai-mobile/expo ships a file-system implementation; the
5
+ * default is in-memory (bundles re-download per app session — correct,
6
+ * just slower on cold start).
7
+ */
8
+
9
+ export interface BundleCacheEntry {
10
+ localPath: string;
11
+ version: string;
12
+ timestamp: number;
13
+ bundleUrl: string;
14
+ /** SHA-256 (hex, lowercase) of the cached bundle bytes — integrity proof */
15
+ bundleHash?: string;
16
+ }
17
+
18
+ export interface BundleStorage {
19
+ loadIndex(): Promise<Record<string, BundleCacheEntry>>;
20
+ saveIndex(index: Record<string, BundleCacheEntry>): Promise<void>;
21
+ /** Read a cached bundle. Returns null when missing/unreadable. */
22
+ readBundle(localPath: string): Promise<string | null>;
23
+ /** Persist a bundle; returns the localPath to record in the index.
24
+ * `key` is a filesystem-safe name derived from the bundle URL+version. */
25
+ writeBundle(key: string, content: string): Promise<string>;
26
+ deleteBundle(localPath: string): Promise<void>;
27
+ /** Wipe everything (index + bundles). */
28
+ clearAll(): Promise<void>;
29
+ }
30
+
31
+ /** Default storage: keeps bundles for the lifetime of the JS context only. */
32
+ export class MemoryBundleStorage implements BundleStorage {
33
+ private index: Record<string, BundleCacheEntry> = {};
34
+ private files = new Map<string, string>();
35
+
36
+ async loadIndex() {
37
+ return this.index;
38
+ }
39
+ async saveIndex(index: Record<string, BundleCacheEntry>) {
40
+ this.index = index;
41
+ }
42
+ async readBundle(localPath: string) {
43
+ return this.files.get(localPath) ?? null;
44
+ }
45
+ async writeBundle(key: string, content: string) {
46
+ const localPath = `memory://${key}`;
47
+ this.files.set(localPath, content);
48
+ return localPath;
49
+ }
50
+ async deleteBundle(localPath: string) {
51
+ this.files.delete(localPath);
52
+ }
53
+ async clearAll() {
54
+ this.index = {};
55
+ this.files.clear();
56
+ }
57
+ }
58
+
59
+ let _storage: BundleStorage = new MemoryBundleStorage();
60
+
61
+ /** Install a persistent storage implementation (call before first plugin load). */
62
+ export function setBundleStorage(storage: BundleStorage): void {
63
+ _storage = storage;
64
+ }
65
+
66
+ export function getBundleStorage(): BundleStorage {
67
+ return _storage;
68
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * usePlugin — single hook for loading any plugin.
3
+ *
4
+ * Simple async load → done pattern over the global BundleManager singleton
5
+ * (deduplication + caching) and the host's native plugin registry.
6
+ *
7
+ * Usage:
8
+ * const { plugin, loading, error } = usePlugin(pluginId, manifest);
9
+ * if (loading) return <Spinner />;
10
+ * if (error) return <Error message={error} />;
11
+ * return <plugin.Component bridge={bridge} native={native} theme={theme} context={context} />;
12
+ */
13
+ import { useState, useEffect } from 'react';
14
+ import { bundleManager } from '../bundles/bundleManager';
15
+ import { nativePluginRegistry } from '../host';
16
+ import type { RegisteredUIPlugin } from '../sdk/types';
17
+
18
+ interface PluginManifestLike {
19
+ version?: string;
20
+ name?: string;
21
+ render?: any;
22
+ _connectorId?: string;
23
+ }
24
+
25
+ interface UsePluginResult {
26
+ plugin: RegisteredUIPlugin | null;
27
+ loading: boolean;
28
+ error: string | null;
29
+ bundleMissing: boolean;
30
+ }
31
+
32
+ /**
33
+ * Compute the bundle URL.
34
+ *
35
+ * **Policy:** bundle URLs are ALWAYS derived from the pluginId via the
36
+ * canonical platform route /api/ui-plugins/{pluginId}/bundle.js.
37
+ * Connector-supplied bundleUrl fields are IGNORED — the Builder strips them
38
+ * at introspection time, but we treat any survivors as bugs and ignore them
39
+ * here too (defense in depth vs "hand-coded URL with bad encoding causes
40
+ * silent iframe fallback" bugs).
41
+ */
42
+ type BundleUrlSource = 'derived-default' | 'ignored-explicit' | 'none';
43
+ function extractBundleUrl(pluginId: string, manifest: PluginManifestLike): { url: string | null; source: BundleUrlSource; ignored?: string } {
44
+ const render = manifest.render;
45
+ if (!render || typeof render !== 'object') return { url: null, source: 'none' };
46
+
47
+ // Detect (and refuse) any explicit bundle URL the connector tried to inject.
48
+ const injected =
49
+ (manifest as any).bundleUrl ||
50
+ (render as any).bundleUrl ||
51
+ render.reactNative?.bundleUrl ||
52
+ null;
53
+
54
+ // Mobile decides native vs web INDEPENDENT of render.mode. The server's
55
+ // cp.getContextPlugin flattens mode to "iframe" whenever an iframeUrl is
56
+ // available (for web-frontend compatibility) — even when a reactNative
57
+ // component is also declared. So we derive the bundle URL whenever a
58
+ // reactNative component is present, regardless of what `mode` says.
59
+ const hasNativeComponent = !!(render.reactNative?.component || render.component);
60
+ if (hasNativeComponent) {
61
+ return {
62
+ url: `/api/ui-plugins/${encodeURIComponent(pluginId)}/bundle.js`,
63
+ source: injected ? 'ignored-explicit' : 'derived-default',
64
+ ...(injected && { ignored: injected }),
65
+ };
66
+ }
67
+
68
+ return { url: null, source: 'none' };
69
+ }
70
+
71
+ /**
72
+ * Extract the SHA-256 integrity hash from the manifest (server-provided).
73
+ * BundleManager refuses to evaluate a downloaded bundle whose bytes don't
74
+ * hash to this value — defense vs MITM/CDN tampering.
75
+ */
76
+ function extractBundleHash(manifest: PluginManifestLike): string | undefined {
77
+ if ((manifest as any).bundleHash) return (manifest as any).bundleHash;
78
+ const render = manifest.render;
79
+ if (!render || typeof render !== 'object') return undefined;
80
+ if (render.bundleHash) return render.bundleHash;
81
+ if (render.reactNative?.bundleHash) return render.reactNative.bundleHash;
82
+ return undefined;
83
+ }
84
+
85
+ /**
86
+ * Extract component name from manifest render field.
87
+ */
88
+ function extractComponentId(manifest: PluginManifestLike): string | null {
89
+ const render = manifest.render;
90
+ if (!render || typeof render !== 'object') return null;
91
+ return render.component || render.reactNative?.component || null;
92
+ }
93
+
94
+ export function usePlugin(pluginId: string, manifest: PluginManifestLike): UsePluginResult {
95
+ const [plugin, setPlugin] = useState<RegisteredUIPlugin | null>(null);
96
+ const [loading, setLoading] = useState(true);
97
+ const [error, setError] = useState<string | null>(null);
98
+ const [bundleMissing, setBundleMissing] = useState(false);
99
+
100
+ const version = manifest.version || '1.0.0';
101
+ const { url: bundleUrl, ignored: ignoredUrl } = extractBundleUrl(pluginId, manifest);
102
+ const bundleHash = extractBundleHash(manifest);
103
+ const componentId = extractComponentId(manifest);
104
+ const lookupId = componentId || pluginId;
105
+
106
+ if (ignoredUrl) {
107
+ console.warn(`[usePlugin] ${pluginId}: IGNORED connector-injected bundleUrl="${ignoredUrl}". The host derives bundle URLs from pluginId via the canonical route.`);
108
+ }
109
+
110
+ useEffect(() => {
111
+ let cancelled = false;
112
+
113
+ (async () => {
114
+ try {
115
+ // Path 1: Local registry (host-bundled / dev plugins)
116
+ if (nativePluginRegistry.has(lookupId)) {
117
+ const registered = await nativePluginRegistry.get(lookupId);
118
+ if (cancelled) return;
119
+
120
+ if (!registered) {
121
+ throw new Error(`Local plugin not found: ${lookupId}`);
122
+ }
123
+ if (registered.type === 'service') {
124
+ throw new Error(`Plugin ${lookupId} is a service plugin and cannot be rendered`);
125
+ }
126
+
127
+ setPlugin(registered as RegisteredUIPlugin);
128
+ setLoading(false);
129
+ return;
130
+ }
131
+
132
+ // Path 2: Remote bundle (via global BundleManager)
133
+ if (bundleUrl) {
134
+ try {
135
+ const component = await bundleManager.load(pluginId, bundleUrl, version, bundleHash);
136
+ if (cancelled) return;
137
+ setPlugin(component);
138
+ setLoading(false);
139
+ return;
140
+ } catch (bundleErr) {
141
+ const msg = bundleErr instanceof Error ? bundleErr.message : String(bundleErr);
142
+ // warn (not error) — this is a handled path: we fall back to WebView.
143
+ console.warn(`[usePlugin] ${pluginId}: Bundle load failed, falling back to WebView:`, msg);
144
+ // RN bundle failed — connector declared a component but didn't
145
+ // ship a usable bundle (404, eval error, integrity fail, etc).
146
+ // Mark "bundle missing" so the parent renderer can fall back to
147
+ // the WebView/iframe when the manifest also has an iframeUrl.
148
+ if (!cancelled) {
149
+ setBundleMissing(true);
150
+ setLoading(false);
151
+ }
152
+ return;
153
+ }
154
+ }
155
+
156
+ // No native source available. If the manifest also has an iframeUrl,
157
+ // mark "bundle missing" so the parent falls back to the WebView.
158
+ // Only throw when there's truly nothing renderable.
159
+ const iframeUrl = manifest?.render?.iframeUrl
160
+ || (manifest?.render?.mode === 'adaptive' && manifest?.render?.iframe?.iframeUrl);
161
+ if (iframeUrl) {
162
+ if (!cancelled) {
163
+ setBundleMissing(true);
164
+ setLoading(false);
165
+ }
166
+ return;
167
+ }
168
+ throw new Error(
169
+ `Plugin "${manifest.name || pluginId}" has no bundle URL and is not in the local registry. ` +
170
+ `Check that the solution definition includes a valid render.reactNative.bundleUrl.`
171
+ );
172
+ } catch (err) {
173
+ if (cancelled) return;
174
+ const msg = err instanceof Error ? err.message : String(err);
175
+ console.error(`[usePlugin] Failed to load ${pluginId}:`, msg);
176
+ setError(msg);
177
+ setLoading(false);
178
+ }
179
+ })();
180
+
181
+ return () => { cancelled = true; };
182
+ }, [pluginId, lookupId, bundleUrl, bundleHash, version]);
183
+
184
+ return { plugin, loading, error, bundleMissing };
185
+ }
@@ -0,0 +1,97 @@
1
+ import { useEffect, useState, useCallback } from 'react';
2
+ import { apiFetch } from '@ateam-ai-mobile/core';
3
+ import type { PluginManifest } from '../sdk/types';
4
+
5
+ export interface RemotePlugin {
6
+ id: string;
7
+ name: string;
8
+ version: string;
9
+ manifest: PluginManifest;
10
+ source: 'remote' | 'local'; // Where this plugin came from
11
+ }
12
+
13
+ interface UseRemotePluginsResult {
14
+ plugins: RemotePlugin[];
15
+ loading: boolean;
16
+ error: string | null;
17
+ refreshing: boolean;
18
+ refresh: () => Promise<void>;
19
+ }
20
+
21
+ /**
22
+ * Fetches available UI plugins from A-Team Core for the current solution.
23
+ * Uses apiFetch which auto-injects JWT + tenant headers.
24
+ */
25
+ export function useRemotePlugins(solutionId?: string): UseRemotePluginsResult {
26
+ const [plugins, setPlugins] = useState<RemotePlugin[]>([]);
27
+ const [loading, setLoading] = useState(true);
28
+ const [refreshing, setRefreshing] = useState(false);
29
+ const [error, setError] = useState<string | null>(null);
30
+
31
+ const fetchRemotePlugins = useCallback(async () => {
32
+ try {
33
+ setError(null);
34
+
35
+ const targetSolutionId = solutionId || 'current';
36
+
37
+ let data: any;
38
+ try {
39
+ data = await apiFetch<any>(`/api/solutions/${targetSolutionId}/ui-plugins`);
40
+ } catch (err: any) {
41
+ if (err?.message?.includes('404')) {
42
+ console.warn(`[useRemotePlugins] Solution ${targetSolutionId} not found or has no plugins`);
43
+ setPlugins([]);
44
+ return;
45
+ }
46
+ throw err;
47
+ }
48
+ const uiPlugins: RemotePlugin[] = (data.plugins || []).map((plugin: any) => ({
49
+ id: plugin.id,
50
+ name: plugin.name,
51
+ version: plugin.version || '1.0.0',
52
+ source: 'remote' as const,
53
+ manifest: {
54
+ id: plugin.id,
55
+ name: plugin.name,
56
+ version: plugin.version || '1.0.0',
57
+ type: 'ui',
58
+ description: plugin.description || '',
59
+ capabilities: plugin.capabilities || {},
60
+ channels: plugin.channels || ['default'],
61
+ render: (plugin.render && typeof plugin.render === 'object') ? plugin.render : { mode: 'react-native' as const, component: plugin.name || plugin.id },
62
+ entry_point: plugin.entry_point || plugin.name,
63
+ sandbox: plugin.sandbox !== false,
64
+ requires_auth: plugin.requires_auth !== false,
65
+ } as PluginManifest,
66
+ }));
67
+
68
+ setPlugins(uiPlugins);
69
+ } catch (err) {
70
+ const message = err instanceof Error ? err.message : String(err);
71
+ console.error('[useRemotePlugins] Failed to fetch plugins:', message);
72
+ setError(message);
73
+ setPlugins([]); // Clear plugins on error
74
+ } finally {
75
+ setLoading(false);
76
+ setRefreshing(false);
77
+ }
78
+ }, [solutionId]);
79
+
80
+ const refresh = useCallback(async () => {
81
+ setRefreshing(true);
82
+ await fetchRemotePlugins();
83
+ }, [fetchRemotePlugins]);
84
+
85
+ useEffect(() => {
86
+ setLoading(true);
87
+ fetchRemotePlugins();
88
+ }, [fetchRemotePlugins]);
89
+
90
+ return {
91
+ plugins,
92
+ loading,
93
+ error,
94
+ refreshing,
95
+ refresh,
96
+ };
97
+ }
package/src/host.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Host integration points — the DI seams that keep this package free of
3
+ * Expo imports. The host app (or @ateam-ai-mobile/expo) plugs in concrete providers
4
+ * once at startup; everything here has a safe no-op/in-memory default so
5
+ * a bare host works with zero configuration.
6
+ */
7
+
8
+ import type { HapticsAPI, PluginNative } from './sdk/types';
9
+ import type { RegisteredPlugin } from './sdk/types';
10
+
11
+ // ── Haptics ────────────────────────────────────────────────────────────────
12
+
13
+ let _haptics: HapticsAPI | null = null;
14
+
15
+ /** Register a haptics implementation (e.g. expo-haptics via @ateam-ai-mobile/expo). */
16
+ export function setHapticsProvider(haptics: HapticsAPI | null): void {
17
+ _haptics = haptics;
18
+ }
19
+
20
+ /** No-op safe accessor — surfaces call this for tap feedback. */
21
+ export function getHaptics(): HapticsAPI | null {
22
+ return _haptics;
23
+ }
24
+
25
+ // ── PluginNative (device capabilities handed to plugins) ──────────────────
26
+ // The full implementation lives in the HOST (it owns the native modules —
27
+ // e.g. ateam-mobile's device-bridge). Without a provider, plugins get
28
+ // clearly-erroring stubs instead of crashes.
29
+
30
+ function unavailable(api: string): any {
31
+ return new Proxy({}, {
32
+ get: (_t, method) => () =>
33
+ Promise.reject(new Error(`[adas] native.${api}.${String(method)} unavailable — host did not call setPluginNativeProvider()`)),
34
+ });
35
+ }
36
+
37
+ const noopNative: PluginNative = {
38
+ camera: unavailable('camera'),
39
+ location: unavailable('location'),
40
+ storage: unavailable('storage'),
41
+ biometrics: unavailable('biometrics'),
42
+ biometricsV2: unavailable('biometricsV2'),
43
+ notifications: unavailable('notifications'),
44
+ haptics: { impact: () => {}, notification: () => {}, selection: () => {} },
45
+ connectivity: { isOnline: () => true, onChange: () => () => {} },
46
+ fileSystem: unavailable('fileSystem'),
47
+ cookies: unavailable('cookies'),
48
+ sharing: unavailable('sharing'),
49
+ scanner: unavailable('scanner'),
50
+ nfc: unavailable('nfc'),
51
+ };
52
+
53
+ let _pluginNative: PluginNative = noopNative;
54
+
55
+ /** Install the device-capabilities implementation plugins receive as
56
+ * `props.native`. The host owns the native modules. */
57
+ export function setPluginNativeProvider(native: PluginNative): void {
58
+ _pluginNative = native;
59
+ }
60
+
61
+ export function getPluginNative(): PluginNative {
62
+ return _pluginNative;
63
+ }
64
+
65
+ // ── Native (host-bundled) plugin registry ─────────────────────────────────
66
+ // Plugins compiled into the host binary (dev plugins, first-party panels).
67
+ // usePlugin checks this registry before hitting the remote bundle pipeline.
68
+ // Values may be lazy loaders so heavy plugins don't cost app-start time.
69
+
70
+ type PluginResolver = RegisteredPlugin | (() => Promise<RegisteredPlugin | null>);
71
+
72
+ const _nativeRegistry = new Map<string, PluginResolver>();
73
+
74
+ export function registerNativePlugin(id: string, plugin: PluginResolver): void {
75
+ _nativeRegistry.set(id, plugin);
76
+ }
77
+
78
+ export function unregisterNativePlugin(id: string): void {
79
+ _nativeRegistry.delete(id);
80
+ }
81
+
82
+ export const nativePluginRegistry = {
83
+ has(id: string): boolean {
84
+ return _nativeRegistry.has(id);
85
+ },
86
+ async get(id: string): Promise<RegisteredPlugin | null> {
87
+ const entry = _nativeRegistry.get(id);
88
+ if (!entry) return null;
89
+ if (typeof entry === 'function') {
90
+ try {
91
+ return await entry();
92
+ } catch (err) {
93
+ console.warn(`[adas-plugins] lazy native plugin '${id}' failed to load:`, err);
94
+ return null;
95
+ }
96
+ }
97
+ return entry;
98
+ },
99
+ };
100
+
101
+ // ── Extra bundle modules ───────────────────────────────────────────────────
102
+ // Remote bundles are built with externals resolved by the require shim in
103
+ // bundleManager. react / react-native / @adas/plugin-sdk are always provided;
104
+ // anything else (react-native-webview, expo-linking, …) must be registered
105
+ // by the host, which actually has those modules installed.
106
+
107
+ const _bundleModules = new Map<string, any>();
108
+
109
+ export function registerBundleModules(modules: Record<string, any>): void {
110
+ for (const [name, mod] of Object.entries(modules)) {
111
+ _bundleModules.set(name, mod);
112
+ }
113
+ }
114
+
115
+ export function resolveBundleModule(name: string): any | undefined {
116
+ return _bundleModules.get(name);
117
+ }
package/src/index.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @ateam-ai-mobile/plugin-runtime — the ADAS plugin system for React Native hosts.
3
+ *
4
+ * Layers:
5
+ * - Plugin SDK (./sdk): the contract PLUGIN authors code against. Remote
6
+ * bundles resolve `require('@adas/plugin-sdk')` to this namespace.
7
+ * - Bundle pipeline: download, SHA-256 integrity verification, caching
8
+ * (BundleStorage adapter), evaluation with a frozen require shim.
9
+ * - Bridge: host↔plugin contract (mcpCall, commands, chat handoff).
10
+ * - Surfaces: drawer/card/fullscreen chrome + registry + dispatcher.
11
+ * - Host DI (./host): haptics, native plugin registry, extra bundle modules.
12
+ *
13
+ * No Expo imports — @ateam-ai-mobile/expo supplies the Expo-backed providers.
14
+ */
15
+
16
+ // Plugin SDK (plugin-author surface)
17
+ export * from './sdk';
18
+
19
+ // Host integration
20
+ export {
21
+ setHapticsProvider,
22
+ getHaptics,
23
+ setPluginNativeProvider,
24
+ getPluginNative,
25
+ registerNativePlugin,
26
+ unregisterNativePlugin,
27
+ nativePluginRegistry,
28
+ registerBundleModules,
29
+ } from './host';
30
+
31
+ // Bundles
32
+ export { bundleManager } from './bundles/bundleManager';
33
+ export { setBundleStorage, MemoryBundleStorage } from './bundles/storage';
34
+ export type { BundleStorage, BundleCacheEntry } from './bundles/storage';
35
+
36
+ // Bridge
37
+ export { useNativeBridge, createNativeBridge } from './bridge/native';
38
+ export type { Bridge } from './bridge/types';
39
+
40
+ // Plugin loading
41
+ export { usePlugin } from './hooks/usePlugin';
42
+ export { useRemotePlugins } from './hooks/useRemotePlugins';
43
+ export type { RemotePlugin } from './hooks/useRemotePlugins';
44
+
45
+ // Platform plugin API (cp.listContextPlugins / cp.getContextPlugin)
46
+ export {
47
+ listPlugins,
48
+ getPlugin,
49
+ resolvePluginUrl,
50
+ supportsNative,
51
+ supportsWebView,
52
+ getNativeComponentId,
53
+ getNativeBundleUrl,
54
+ getNativeBundleHash,
55
+ } from './api/plugins';
56
+ export type { PluginSummary, PluginSurfaceMeta } from './api/plugins';
57
+ export type { PluginManifest as PlatformPluginManifest } from './api/plugins';
58
+
59
+ // Surfaces
60
+ export { default as SurfaceDrawer } from './surfaces/SurfaceDrawer';
61
+ export type { SurfaceDrawerProps } from './surfaces/SurfaceDrawer';
62
+ export { default as SurfaceCard } from './surfaces/SurfaceCard';
63
+ export type { SurfaceCardProps } from './surfaces/SurfaceCard';
64
+ export { default as SurfaceFullscreen } from './surfaces/SurfaceFullscreen';
65
+ export type { SurfaceFullscreenProps } from './surfaces/SurfaceFullscreen';
66
+ export { default as AdaOrb } from './surfaces/AdaOrb';
67
+ export type { AdaOrbState } from './surfaces/AdaOrb';
68
+
69
+ // Surface registry + dispatcher
70
+ export { useSurfaceStore, isMountSurface, isInlineSurface } from './registry/store';
71
+ export { default as SurfaceDispatcher } from './registry/SurfaceDispatcher';
72
+ export type {
73
+ SurfaceManifest,
74
+ SurfaceType,
75
+ SurfaceVisibility,
76
+ SurfacePlacement,
77
+ MountSurfaceManifest,
78
+ InlineSurfaceManifest,
79
+ } from './registry/types';
@@ -0,0 +1,66 @@
1
+ /**
2
+ * SurfaceDispatcher — mounted once at the app root.
3
+ *
4
+ * Watches the surface store and:
5
+ * - Mounts the active drawer surface (max one at a time)
6
+ * - Mounts the active fullscreen surface (max one at a time)
7
+ *
8
+ * The mounted Component receives `dismiss` to close itself; the dispatcher
9
+ * clears active-state after dismiss.
10
+ *
11
+ * Inline surfaces (card / header / ambient / nudge) are NOT rendered by the
12
+ * dispatcher — they're queried via `useSurfaceStore().list({ type: 'card' })`
13
+ * and rendered inline by the host screen that owns that placement.
14
+ */
15
+ import { useEffect } from 'react';
16
+ import { useSurfaceStore } from './store';
17
+ import { pluginCommandEmitter } from '@ateam-ai-mobile/core';
18
+
19
+ export default function SurfaceDispatcher() {
20
+ const manifests = useSurfaceStore(s => s.manifests);
21
+ const activeDrawer = useSurfaceStore(s => s.activeDrawer);
22
+ const activeFullscreen = useSurfaceStore(s => s.activeFullscreen);
23
+ const activeProps = useSurfaceStore(s => s.activeProps);
24
+ const mountKey = useSurfaceStore(s => s.mountKey);
25
+ const hide = useSurfaceStore(s => s.hide);
26
+
27
+ // bridge.close() inside a plugin emits a global `plugin-close` event.
28
+ // The legacy plugins overlay listens for this; we mirror that here so
29
+ // surface-mounted plugins (auth-webview, etc.) actually dismiss themselves.
30
+ useEffect(() => {
31
+ const unsub = pluginCommandEmitter.on('plugin-close', ({ pluginId }: { pluginId?: string } = {}) => {
32
+ if (!pluginId) return;
33
+ const s = useSurfaceStore.getState();
34
+ if (s.activeDrawer === pluginId || s.activeFullscreen === pluginId) {
35
+ s.hide(pluginId);
36
+ }
37
+ });
38
+ return unsub;
39
+ }, []);
40
+
41
+ const drawer = activeDrawer ? manifests[activeDrawer] : null;
42
+ const fullscreen = activeFullscreen ? manifests[activeFullscreen] : null;
43
+ const drawerProps = activeDrawer ? activeProps[activeDrawer] : undefined;
44
+ const fullscreenProps = activeFullscreen ? activeProps[activeFullscreen] : undefined;
45
+
46
+ // mountKey changes on every show() so React remounts the Component → the
47
+ // plugin's data-fetching useEffects re-run → user sees fresh data each open.
48
+ return (
49
+ <>
50
+ {drawer && drawer.type === 'drawer' && drawer.Component ? (
51
+ <drawer.Component
52
+ key={`drawer-${drawer.id}-${mountKey}`}
53
+ dismiss={() => hide(drawer.id)}
54
+ props={drawerProps}
55
+ />
56
+ ) : null}
57
+ {fullscreen && fullscreen.type === 'fullscreen' && fullscreen.Component ? (
58
+ <fullscreen.Component
59
+ key={`fullscreen-${fullscreen.id}-${mountKey}`}
60
+ dismiss={() => hide(fullscreen.id)}
61
+ props={fullscreenProps}
62
+ />
63
+ ) : null}
64
+ </>
65
+ );
66
+ }