@sero-ai/app-runtime 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/src/context.ts +9 -9
- package/src/index.ts +5 -0
- package/src/sero-bridge.ts +42 -39
- package/src/use-app-state.ts +53 -30
- package/src/use-app-tools.ts +31 -0
- package/src/use-widget-registration.ts +83 -0
- package/src/widget-registry.ts +124 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sero-ai/app-runtime",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Hooks for Sero federated app modules — useAppState, useAppInfo, useAgentPrompt",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Hooks for Sero federated app modules — useAppState, useAppInfo, useAgentPrompt, useAppTools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
7
7
|
"types": "./src/index.ts",
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"./package.json": "./package.json"
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
|
-
"src"
|
|
13
|
+
"src",
|
|
14
|
+
"README.md"
|
|
14
15
|
],
|
|
15
16
|
"scripts": {
|
|
16
17
|
"typecheck": "tsc --noEmit"
|
|
@@ -22,6 +23,7 @@
|
|
|
22
23
|
"react": ">=19.0.0"
|
|
23
24
|
},
|
|
24
25
|
"devDependencies": {
|
|
26
|
+
"@sero/common": "^0.1.0",
|
|
25
27
|
"@types/react": "^19.2.13",
|
|
26
28
|
"typescript": "^5.9.3"
|
|
27
29
|
}
|
package/src/context.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* multiple times (which happens in Vite dev mode with MF).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { createContext } from 'react';
|
|
9
|
+
import { createContext, type Context } from 'react';
|
|
10
10
|
|
|
11
11
|
export interface AppContextValue {
|
|
12
12
|
/** App identifier (e.g. "todo"). */
|
|
@@ -29,15 +29,15 @@ export interface AppContextValue {
|
|
|
29
29
|
themePresetId?: string;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
// Ensure a single context instance across all module copies
|
|
35
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
36
|
-
const g = globalThis as any;
|
|
37
|
-
if (!g[CONTEXT_KEY]) {
|
|
38
|
-
g[CONTEXT_KEY] = createContext<AppContextValue | null>(null);
|
|
32
|
+
declare global {
|
|
33
|
+
var __sero_app_context__: Context<AppContextValue | null> | undefined;
|
|
39
34
|
}
|
|
40
35
|
|
|
41
|
-
|
|
36
|
+
const appContext = globalThis.__sero_app_context__
|
|
37
|
+
?? createContext<AppContextValue | null>(null);
|
|
38
|
+
|
|
39
|
+
globalThis.__sero_app_context__ = appContext;
|
|
40
|
+
|
|
41
|
+
export const AppContext: Context<AppContextValue | null> = appContext;
|
|
42
42
|
|
|
43
43
|
export const AppProvider = AppContext.Provider;
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,12 @@ export { useAppState } from './use-app-state';
|
|
|
10
10
|
export { useAppInfo } from './use-app-info';
|
|
11
11
|
export { useAgentPrompt } from './use-agent-prompt';
|
|
12
12
|
export { useAI, type AppAI } from './use-ai';
|
|
13
|
+
export { useAppTools, type AppTools } from './use-app-tools';
|
|
13
14
|
export { useAvailableModels, type UseAvailableModelsResult } from './use-available-models';
|
|
14
15
|
export { useTheme, type UseThemeResult } from './use-theme';
|
|
15
16
|
export { getSeroApi } from './sero-bridge';
|
|
16
17
|
export type { AppModelInfo, AppModelGroup } from './sero-bridge';
|
|
18
|
+
export type { AppToolContentBlock, AppToolImageContent, AppToolResult, AppToolTextContent } from '@sero/common';
|
|
19
|
+
export { registerWidget, getRuntimeWidgets, onWidgetRegistryChange } from './widget-registry';
|
|
20
|
+
export type { RuntimeWidget } from './widget-registry';
|
|
21
|
+
export { useWidgetRegistration } from './use-widget-registration';
|
package/src/sero-bridge.ts
CHANGED
|
@@ -5,17 +5,24 @@
|
|
|
5
5
|
* module declares only the subset app-runtime hooks need, keeping the
|
|
6
6
|
* package decoupled from the desktop app's types while providing type
|
|
7
7
|
* safety for all IPC calls.
|
|
8
|
-
*
|
|
9
|
-
* The single `(window as ...)` cast lives here — every other module
|
|
10
|
-
* imports the typed getter.
|
|
11
8
|
*/
|
|
12
9
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
import type {
|
|
11
|
+
AppToolResult,
|
|
12
|
+
GitActionResult,
|
|
13
|
+
GitManagerRequest,
|
|
14
|
+
SharedAvailableModelGroup,
|
|
15
|
+
SharedModelInfo,
|
|
16
|
+
WebAppActionResult,
|
|
17
|
+
WebAppRequest,
|
|
18
|
+
} from '@sero/common';
|
|
19
|
+
|
|
20
|
+
export interface SeroWindowAppStateBridge {
|
|
21
|
+
read<TData = unknown>(filePath: string): Promise<TData>;
|
|
22
|
+
write<TData = unknown>(filePath: string, data: TData): Promise<void>;
|
|
23
|
+
watch<TData = unknown>(filePath: string): Promise<TData>;
|
|
17
24
|
unwatch(filePath: string): Promise<void>;
|
|
18
|
-
onChange(cb: (filePath: string, data:
|
|
25
|
+
onChange<TData = unknown>(cb: (filePath: string, data: TData) => void): () => void;
|
|
19
26
|
}
|
|
20
27
|
|
|
21
28
|
export interface SeroAppAgentBridge {
|
|
@@ -26,26 +33,20 @@ export interface SeroAppAgentBridge {
|
|
|
26
33
|
text: string,
|
|
27
34
|
onDelta: (delta: string) => void,
|
|
28
35
|
): Promise<string>;
|
|
36
|
+
invokeTool?(
|
|
37
|
+
appId: string,
|
|
38
|
+
workspaceId: string,
|
|
39
|
+
toolName: string,
|
|
40
|
+
params: Record<string, unknown>,
|
|
41
|
+
): Promise<AppToolResult>;
|
|
29
42
|
}
|
|
30
43
|
|
|
31
|
-
export interface
|
|
32
|
-
|
|
33
|
-
file?: string;
|
|
34
|
-
message?: string;
|
|
35
|
-
branch?: string;
|
|
36
|
-
hash?: string;
|
|
37
|
-
staged?: boolean;
|
|
38
|
-
all?: boolean;
|
|
39
|
-
stashIndex?: number;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface SeroGitAppActionResult {
|
|
43
|
-
ok: boolean;
|
|
44
|
-
message: string;
|
|
44
|
+
export interface SeroGitAppBridge {
|
|
45
|
+
run(workspaceId: string, params: GitManagerRequest): Promise<GitActionResult>;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
export interface
|
|
48
|
-
run(workspaceId: string, params:
|
|
48
|
+
export interface SeroWebAppBridge {
|
|
49
|
+
run(workspaceId: string, params: WebAppRequest): Promise<WebAppActionResult>;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
export interface SeroEditorExecResult {
|
|
@@ -61,39 +62,41 @@ export interface SeroEditorBridge {
|
|
|
61
62
|
// ── Model types (subset of desktop's ipc types) ──────────────
|
|
62
63
|
|
|
63
64
|
/** Serialisable model info for app modules. */
|
|
64
|
-
export
|
|
65
|
-
provider: string;
|
|
66
|
-
modelId: string;
|
|
67
|
-
name: string;
|
|
68
|
-
reasoning: boolean;
|
|
69
|
-
}
|
|
65
|
+
export type AppModelInfo = SharedModelInfo;
|
|
70
66
|
|
|
71
67
|
/** A group of models under a single provider. */
|
|
72
|
-
export
|
|
73
|
-
provider: string;
|
|
74
|
-
displayName: string;
|
|
75
|
-
logo: string;
|
|
76
|
-
models: AppModelInfo[];
|
|
77
|
-
}
|
|
68
|
+
export type AppModelGroup = SharedAvailableModelGroup<AppModelInfo>;
|
|
78
69
|
|
|
79
70
|
export interface SeroModelsBridge {
|
|
80
71
|
list(): Promise<AppModelGroup[]>;
|
|
81
72
|
}
|
|
82
73
|
|
|
83
74
|
export interface SeroBridge {
|
|
84
|
-
appState:
|
|
75
|
+
appState: SeroWindowAppStateBridge;
|
|
85
76
|
appAgent: SeroAppAgentBridge;
|
|
86
77
|
gitApp?: SeroGitAppBridge;
|
|
78
|
+
webApp?: SeroWebAppBridge;
|
|
87
79
|
editor?: SeroEditorBridge;
|
|
88
80
|
models?: SeroModelsBridge;
|
|
89
81
|
}
|
|
90
82
|
|
|
83
|
+
function readWindowSero(value: Window): unknown {
|
|
84
|
+
return Reflect.get(value, 'sero');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isSeroBridge(value: unknown): value is SeroBridge {
|
|
88
|
+
return typeof value === 'object'
|
|
89
|
+
&& value !== null
|
|
90
|
+
&& 'appState' in value
|
|
91
|
+
&& 'appAgent' in value;
|
|
92
|
+
}
|
|
93
|
+
|
|
91
94
|
/**
|
|
92
95
|
* Get the Sero preload bridge. Throws if not running inside the Sero shell.
|
|
93
96
|
*/
|
|
94
97
|
export function getSeroApi(): SeroBridge {
|
|
95
|
-
const sero = (window
|
|
96
|
-
if (!sero) {
|
|
98
|
+
const sero = readWindowSero(window);
|
|
99
|
+
if (!isSeroBridge(sero)) {
|
|
97
100
|
throw new Error('[app-runtime] window.sero not available — must run inside Sero shell');
|
|
98
101
|
}
|
|
99
102
|
return sero;
|
package/src/use-app-state.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { useState, useEffect, useCallback, useContext, useRef } from 'react';
|
|
10
10
|
import { AppContext } from './context';
|
|
11
|
-
import { getSeroApi } from './sero-bridge';
|
|
11
|
+
import { getSeroApi, type SeroWindowAppStateBridge } from './sero-bridge';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* File-backed reactive state hook.
|
|
@@ -24,55 +24,78 @@ export function useAppState<T>(defaultState: T): [T, (updater: (prev: T) => T) =
|
|
|
24
24
|
|
|
25
25
|
const { stateFilePath } = ctx;
|
|
26
26
|
const [state, setState] = useState<T>(defaultState);
|
|
27
|
+
const defaultStateRef = useRef<T>(defaultState);
|
|
27
28
|
const stateRef = useRef<T>(defaultState);
|
|
29
|
+
const latestWriteIdRef = useRef(0);
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
defaultStateRef.current = defaultState;
|
|
30
32
|
stateRef.current = state;
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
const applyState = useCallback((nextState: T) => {
|
|
35
|
+
stateRef.current = nextState;
|
|
36
|
+
setState(nextState);
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
const recoverFromWriteFailure = useCallback(
|
|
40
|
+
async (api: SeroWindowAppStateBridge, writeId: number, fallbackState: T) => {
|
|
41
|
+
if (writeId !== latestWriteIdRef.current) return;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const current = await api.read<T | null>(stateFilePath);
|
|
45
|
+
if (writeId !== latestWriteIdRef.current) return;
|
|
46
|
+
applyState(current ?? fallbackState);
|
|
47
|
+
} catch {
|
|
48
|
+
if (writeId !== latestWriteIdRef.current) return;
|
|
49
|
+
applyState(fallbackState);
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
[applyState, stateFilePath],
|
|
53
|
+
);
|
|
54
|
+
|
|
33
55
|
useEffect(() => {
|
|
34
56
|
const api = getSeroApi();
|
|
35
|
-
let
|
|
57
|
+
let isActive = true;
|
|
36
58
|
|
|
37
|
-
|
|
38
|
-
|
|
59
|
+
const applyIfActive = (nextState: T) => {
|
|
60
|
+
if (!isActive) return;
|
|
61
|
+
applyState(nextState);
|
|
62
|
+
};
|
|
39
63
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
setState(parsed);
|
|
46
|
-
}
|
|
64
|
+
applyState(defaultStateRef.current);
|
|
65
|
+
|
|
66
|
+
const unsubscribe = api.appState.onChange<T | null>((filePath, data) => {
|
|
67
|
+
if (filePath !== stateFilePath || data == null) return;
|
|
68
|
+
applyIfActive(data);
|
|
47
69
|
});
|
|
48
70
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const parsed = current as T;
|
|
53
|
-
stateRef.current = parsed;
|
|
54
|
-
setState(parsed);
|
|
55
|
-
}
|
|
71
|
+
void api.appState.watch<T | null>(stateFilePath).then((current) => {
|
|
72
|
+
if (current == null) return;
|
|
73
|
+
applyIfActive(current);
|
|
56
74
|
});
|
|
57
75
|
|
|
58
76
|
return () => {
|
|
59
|
-
|
|
60
|
-
|
|
77
|
+
isActive = false;
|
|
78
|
+
unsubscribe();
|
|
79
|
+
void api.appState.unwatch(stateFilePath);
|
|
61
80
|
};
|
|
62
|
-
}, [stateFilePath]);
|
|
81
|
+
}, [applyState, stateFilePath]);
|
|
63
82
|
|
|
64
|
-
// Write updater
|
|
65
83
|
const updateState = useCallback(
|
|
66
84
|
(updater: (prev: T) => T) => {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
85
|
+
const previous = stateRef.current;
|
|
86
|
+
const next = updater(previous);
|
|
87
|
+
const writeId = latestWriteIdRef.current + 1;
|
|
88
|
+
latestWriteIdRef.current = writeId;
|
|
89
|
+
applyState(next);
|
|
70
90
|
|
|
71
|
-
// Persist to disk (async, fire-and-forget — watcher will confirm)
|
|
72
91
|
const api = getSeroApi();
|
|
73
|
-
api.appState.write(stateFilePath, next)
|
|
92
|
+
void api.appState.write(stateFilePath, next).catch((error: unknown) => {
|
|
93
|
+
if (writeId !== latestWriteIdRef.current) return;
|
|
94
|
+
console.warn(`[app-runtime] Failed to persist app state for ${stateFilePath}`, error);
|
|
95
|
+
void recoverFromWriteFailure(api.appState, writeId, previous);
|
|
96
|
+
});
|
|
74
97
|
},
|
|
75
|
-
[stateFilePath],
|
|
98
|
+
[applyState, recoverFromWriteFailure, stateFilePath],
|
|
76
99
|
);
|
|
77
100
|
|
|
78
101
|
return [state, updateState];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AppToolResult } from '@sero/common';
|
|
2
|
+
import { useCallback, useContext, useMemo } from 'react';
|
|
3
|
+
|
|
4
|
+
import { AppContext } from './context';
|
|
5
|
+
import { getSeroApi } from './sero-bridge';
|
|
6
|
+
|
|
7
|
+
export interface AppTools {
|
|
8
|
+
run(toolName: string, params?: Record<string, unknown>): Promise<AppToolResult>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function useAppTools(): AppTools {
|
|
12
|
+
const ctx = useContext(AppContext);
|
|
13
|
+
|
|
14
|
+
const run = useCallback<AppTools['run']>(
|
|
15
|
+
async (toolName, params): Promise<AppToolResult> => {
|
|
16
|
+
if (!ctx?.appId || !ctx?.workspaceId) {
|
|
17
|
+
throw new Error('[useAppTools] No app context — must be used inside a Sero app');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { appAgent } = getSeroApi();
|
|
21
|
+
if (!appAgent.invokeTool) {
|
|
22
|
+
throw new Error('[useAppTools] App tool bridge unavailable');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return appAgent.invokeTool(ctx.appId, ctx.workspaceId, toolName, params ?? {});
|
|
26
|
+
},
|
|
27
|
+
[ctx],
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
return useMemo(() => ({ run }), [run]);
|
|
31
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useWidgetRegistration — hook for apps to register widgets at runtime.
|
|
3
|
+
*
|
|
4
|
+
* Registers a widget component for the current renderer session.
|
|
5
|
+
* This complements static manifest declarations in package.json.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```tsx
|
|
9
|
+
* import { useWidgetRegistration } from '@sero-ai/app-runtime';
|
|
10
|
+
* import { MyWidget } from './widgets/MyWidget';
|
|
11
|
+
*
|
|
12
|
+
* export function MyApp() {
|
|
13
|
+
* useWidgetRegistration({
|
|
14
|
+
* widgetId: 'summary',
|
|
15
|
+
* name: 'Summary',
|
|
16
|
+
* component: MyWidget,
|
|
17
|
+
* defaultSize: { w: 2, h: 2 },
|
|
18
|
+
* });
|
|
19
|
+
* // ...
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { useContext, useEffect } from 'react';
|
|
25
|
+
import type { ComponentType } from 'react';
|
|
26
|
+
import { AppContext } from './context';
|
|
27
|
+
import { registerWidget } from './widget-registry';
|
|
28
|
+
|
|
29
|
+
interface WidgetRegistrationOptions {
|
|
30
|
+
/** Unique widget identifier within the app. */
|
|
31
|
+
widgetId: string;
|
|
32
|
+
/** Display name. */
|
|
33
|
+
name: string;
|
|
34
|
+
/** The React component to render. */
|
|
35
|
+
component: ComponentType;
|
|
36
|
+
/** Default grid size. */
|
|
37
|
+
defaultSize: { w: number; h: number };
|
|
38
|
+
/** Minimum grid size. */
|
|
39
|
+
minSize?: { w: number; h: number };
|
|
40
|
+
/** Maximum grid size. */
|
|
41
|
+
maxSize?: { w: number; h: number };
|
|
42
|
+
/** Optional description. */
|
|
43
|
+
description?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Register a widget component for the current app.
|
|
48
|
+
*
|
|
49
|
+
* Registration is intentionally sticky for the renderer session so a
|
|
50
|
+
* dashboard widget can keep rendering after the full app view unmounts.
|
|
51
|
+
*/
|
|
52
|
+
export function useWidgetRegistration(options: WidgetRegistrationOptions): void {
|
|
53
|
+
const ctx = useContext(AppContext);
|
|
54
|
+
const appId = ctx?.appId;
|
|
55
|
+
|
|
56
|
+
// Acceptable useEffect — registration into a shared external registry
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!appId) return;
|
|
59
|
+
|
|
60
|
+
registerWidget({
|
|
61
|
+
appId,
|
|
62
|
+
widgetId: options.widgetId,
|
|
63
|
+
name: options.name,
|
|
64
|
+
component: options.component,
|
|
65
|
+
defaultSize: options.defaultSize,
|
|
66
|
+
minSize: options.minSize,
|
|
67
|
+
maxSize: options.maxSize,
|
|
68
|
+
description: options.description,
|
|
69
|
+
});
|
|
70
|
+
}, [
|
|
71
|
+
appId,
|
|
72
|
+
options.widgetId,
|
|
73
|
+
options.name,
|
|
74
|
+
options.component,
|
|
75
|
+
options.defaultSize.w,
|
|
76
|
+
options.defaultSize.h,
|
|
77
|
+
options.minSize?.w,
|
|
78
|
+
options.minSize?.h,
|
|
79
|
+
options.maxSize?.w,
|
|
80
|
+
options.maxSize?.h,
|
|
81
|
+
options.description,
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget registry — runtime registration of widgets from app modules.
|
|
3
|
+
*
|
|
4
|
+
* Apps can register widget components dynamically (in addition to static
|
|
5
|
+
* manifest declarations). The host dashboard subscribes to changes and
|
|
6
|
+
* renders registered widgets.
|
|
7
|
+
*
|
|
8
|
+
* Uses a globalThis singleton to ensure a single registry across all
|
|
9
|
+
* module federation copies, matching the AppContext pattern.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ComponentType } from 'react';
|
|
13
|
+
|
|
14
|
+
/** A runtime-registered widget definition. */
|
|
15
|
+
export interface RuntimeWidget {
|
|
16
|
+
/** App ID that owns this widget. */
|
|
17
|
+
appId: string;
|
|
18
|
+
/** Unique widget identifier within the app. */
|
|
19
|
+
widgetId: string;
|
|
20
|
+
/** Display name. */
|
|
21
|
+
name: string;
|
|
22
|
+
/** The React component to render. */
|
|
23
|
+
component: ComponentType;
|
|
24
|
+
/** Default grid size (react-grid-layout units). */
|
|
25
|
+
defaultSize: { w: number; h: number };
|
|
26
|
+
/** Minimum grid size. */
|
|
27
|
+
minSize?: { w: number; h: number };
|
|
28
|
+
/** Maximum grid size. */
|
|
29
|
+
maxSize?: { w: number; h: number };
|
|
30
|
+
/** Optional description. */
|
|
31
|
+
description?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type WidgetChangeListener = () => void;
|
|
35
|
+
|
|
36
|
+
interface WidgetRegistryState {
|
|
37
|
+
widgets: Map<string, RuntimeWidget>;
|
|
38
|
+
snapshot: RuntimeWidget[];
|
|
39
|
+
listeners: Set<WidgetChangeListener>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
declare global {
|
|
43
|
+
var __sero_widget_registry__: WidgetRegistryState | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createRegistryState(): WidgetRegistryState {
|
|
47
|
+
return {
|
|
48
|
+
widgets: new Map<string, RuntimeWidget>(),
|
|
49
|
+
snapshot: [],
|
|
50
|
+
listeners: new Set<WidgetChangeListener>(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getRegistry(): WidgetRegistryState {
|
|
55
|
+
if (!globalThis.__sero_widget_registry__) {
|
|
56
|
+
globalThis.__sero_widget_registry__ = createRegistryState();
|
|
57
|
+
}
|
|
58
|
+
return globalThis.__sero_widget_registry__;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function makeKey(appId: string, widgetId: string): string {
|
|
62
|
+
return `${appId}:${widgetId}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function publishSnapshot(registry: WidgetRegistryState): void {
|
|
66
|
+
registry.snapshot = Array.from(registry.widgets.values());
|
|
67
|
+
registry.listeners.forEach((fn) => fn());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sameGridSize(
|
|
71
|
+
left: RuntimeWidget['defaultSize'] | RuntimeWidget['minSize'] | RuntimeWidget['maxSize'],
|
|
72
|
+
right: RuntimeWidget['defaultSize'] | RuntimeWidget['minSize'] | RuntimeWidget['maxSize'],
|
|
73
|
+
): boolean {
|
|
74
|
+
if (!left && !right) return true;
|
|
75
|
+
if (!left || !right) return false;
|
|
76
|
+
return left.w === right.w && left.h === right.h;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sameWidget(existing: RuntimeWidget | undefined, next: RuntimeWidget): boolean {
|
|
80
|
+
if (!existing) return false;
|
|
81
|
+
return existing.appId === next.appId
|
|
82
|
+
&& existing.widgetId === next.widgetId
|
|
83
|
+
&& existing.name === next.name
|
|
84
|
+
&& existing.component === next.component
|
|
85
|
+
&& sameGridSize(existing.defaultSize, next.defaultSize)
|
|
86
|
+
&& sameGridSize(existing.minSize, next.minSize)
|
|
87
|
+
&& sameGridSize(existing.maxSize, next.maxSize)
|
|
88
|
+
&& existing.description === next.description;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Register a widget component at runtime. Call from your app's root
|
|
93
|
+
* component or module initialisation.
|
|
94
|
+
*
|
|
95
|
+
* @returns An unregister function.
|
|
96
|
+
*/
|
|
97
|
+
export function registerWidget(widget: RuntimeWidget): () => void {
|
|
98
|
+
const registry = getRegistry();
|
|
99
|
+
const key = makeKey(widget.appId, widget.widgetId);
|
|
100
|
+
const existing = registry.widgets.get(key);
|
|
101
|
+
if (!sameWidget(existing, widget)) {
|
|
102
|
+
registry.widgets.set(key, widget);
|
|
103
|
+
publishSnapshot(registry);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return () => {
|
|
107
|
+
registry.widgets.delete(key);
|
|
108
|
+
publishSnapshot(registry);
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Get all runtime-registered widgets. */
|
|
113
|
+
export function getRuntimeWidgets(): RuntimeWidget[] {
|
|
114
|
+
return getRegistry().snapshot;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Subscribe to widget registration changes. Returns an unsubscribe function. */
|
|
118
|
+
export function onWidgetRegistryChange(listener: WidgetChangeListener): () => void {
|
|
119
|
+
const registry = getRegistry();
|
|
120
|
+
registry.listeners.add(listener);
|
|
121
|
+
return () => {
|
|
122
|
+
registry.listeners.delete(listener);
|
|
123
|
+
};
|
|
124
|
+
}
|