@hosty-sdk/app 0.23.0 → 0.25.12

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,104 @@
1
+ "use client";
2
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
4
+ import { UiHostContext } from "./ui-context.js";
5
+ import { appendThemeLaunchParams, parseActiveFrameAskAssistant, parseActiveFrameAttention, parseActiveFrameAuthRequired, parseActiveFramePermissionReview } from "./embedder.js";
6
+ import { createShellThemeMessage } from "./theme.js";
7
+ import { permissionReviewUrl } from "./permissions.js";
8
+ export function parseFrameMcpReview(event, frame, src, appId) {
9
+ if (!frame || event.source !== frame || !event.data || typeof event.data !== "object")
10
+ return null;
11
+ try {
12
+ if (event.origin !== new URL(src).origin)
13
+ return null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ const data = event.data;
19
+ return data.type === "hosty:request-mcp-review" && typeof data.targetAppId === "string" && /^[a-zA-Z0-9._:-]{1,128}$/.test(data.targetAppId)
20
+ ? { appId, targetAppId: data.targetAppId } : null;
21
+ }
22
+ export function frameSandbox(policy) {
23
+ return "allow-scripts allow-same-origin allow-forms allow-popups allow-downloads" + (policy.popupEscape ? " allow-popups-to-escape-sandbox" : "");
24
+ }
25
+ /** Shared frame coordinator. No delegated-token responder and no authority inferred from messages. */
26
+ export function HostyAppFrame(props) {
27
+ const { surface, title, className, standaloneHref, theme, themePreference } = props;
28
+ const host = useContext(UiHostContext);
29
+ const ref = useRef(null);
30
+ const callbacks = useRef(props);
31
+ callbacks.current = props;
32
+ const [review, setReview] = useState(null);
33
+ const [loaded, setLoaded] = useState(false);
34
+ const [connection, setConnection] = useState({ ready: false, generation: 0 });
35
+ const [appearance, setAppearance] = useState({ theme: "light", preference: "system" });
36
+ useEffect(() => {
37
+ const read = () => {
38
+ const root = document.documentElement;
39
+ setAppearance({
40
+ theme: root.classList.contains("dark") || root.dataset.hostyTheme === "dark" ? "dark" : "light",
41
+ preference: root.dataset.hostyThemePreference === "dark" ? "dark" : root.dataset.hostyThemePreference === "light" ? "light" : "system"
42
+ });
43
+ };
44
+ read();
45
+ const observer = new MutationObserver(read);
46
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-hosty-theme", "data-hosty-theme-preference"] });
47
+ return () => observer.disconnect();
48
+ }, []);
49
+ const resolvedTheme = theme ?? appearance.theme, preference = themePreference ?? appearance.preference;
50
+ const source = props.src ?? new URL(surface.route, surface.origin).href;
51
+ // Theme changes are messages; they must not replace a document with an unsent draft.
52
+ const [launch] = useState(() => {
53
+ const url = new URL(source);
54
+ url.searchParams.set("hosty_launch", "embedded");
55
+ return appendThemeLaunchParams(url.href, resolvedTheme, preference);
56
+ });
57
+ const frameId = JSON.stringify([surface, source]);
58
+ useLayoutEffect(() => {
59
+ const frame = ref.current;
60
+ if (!frame || !host)
61
+ return;
62
+ return host.registerFrame(frame, surface, ready => setConnection(current => ({ ready, generation: current.generation + (ready ? 1 : 0) })));
63
+ }, [host, frameId]); // Caller keys the frame by this immutable surface.
64
+ useEffect(() => {
65
+ if (loaded)
66
+ ref.current?.contentWindow?.postMessage(createShellThemeMessage(resolvedTheme, preference), surface.origin);
67
+ }, [loaded, resolvedTheme, preference, surface.origin]);
68
+ useEffect(() => {
69
+ const handle = (event) => {
70
+ const frame = ref.current?.contentWindow ?? null;
71
+ if (!frame || event.source !== frame || event.origin !== surface.origin)
72
+ return;
73
+ const current = callbacks.current;
74
+ if (parseActiveFrameAuthRequired(event, frame, source, surface.appId))
75
+ current.onAuthRequired?.(surface.appId);
76
+ const ask = parseActiveFrameAskAssistant(event, frame, source);
77
+ if (ask)
78
+ current.onAskAssistant?.(ask.text, surface.appId);
79
+ const attention = parseActiveFrameAttention(event, frame, source);
80
+ if (attention !== null)
81
+ current.onAttention?.(attention);
82
+ const permission = parseActiveFramePermissionReview(event, frame, source, surface.appId);
83
+ const mcp = parseFrameMcpReview(event, frame, source, surface.appId);
84
+ const core = host?.getSnapshot().bootstrap?.coreOrigin;
85
+ if (core && (permission || mcp)) {
86
+ const url = mcp ? new URL(`/install/agents/${encodeURIComponent(mcp.appId)}/${encodeURIComponent(mcp.targetAppId)}`, core).href : permissionReviewUrl(core, surface.appId);
87
+ setReview(url);
88
+ window.open(url, "_blank", "noopener,noreferrer");
89
+ }
90
+ current.onMessage?.(event, frame);
91
+ };
92
+ window.addEventListener("message", handle);
93
+ return () => window.removeEventListener("message", handle);
94
+ }, [host, frameId, source, surface]);
95
+ const outboundReady = host ? connection.ready : loaded;
96
+ useEffect(() => {
97
+ if (outboundReady && props.outbound)
98
+ ref.current?.contentWindow?.postMessage(props.outbound.message, surface.origin);
99
+ }, [outboundReady, connection.generation, props.outbound?.nonce, surface.origin]);
100
+ const insecure = typeof window !== "undefined" && window.location.protocol === "https:" && new URL(surface.origin).protocol === "http:";
101
+ if (insecure)
102
+ return _jsxs("div", { role: "status", className: "hosty-panel-notice", children: [_jsxs("strong", { children: [title, " needs HTTPS to appear here"] }), _jsx("p", { children: "This app publishes an HTTP address. Configure its public HTTPS origin in Hosty." }), /^(localhost|127\.|\[::1\])/.test(new URL(surface.origin).hostname) && _jsx("p", { children: "This local address is reachable only from the Hosty computer." }), standaloneHref && _jsx("a", { href: standaloneHref, target: "_blank", rel: "noopener noreferrer", children: "Open separately" })] });
103
+ return _jsxs(_Fragment, { children: [review && _jsx("a", { className: "hosty-panel-review", href: review, target: "_blank", rel: "noopener noreferrer", children: "Review access in Core" }), _jsx("iframe", { ref: ref, className: className, title: title, src: launch, sandbox: frameSandbox(surface.framePolicy), allow: surface.framePolicy.microphone ? "clipboard-write; microphone 'src'" : "clipboard-write", style: { border: 0, height: "100%", width: "100%", colorScheme: resolvedTheme }, onLoad: () => setLoaded(true) })] });
104
+ }
@@ -0,0 +1,80 @@
1
+ import { type UiClient } from "./ui.js";
2
+ import { type PanelRef, type PanelTarget, type PresentationOptions, type PresentationResult, type UiBootstrap, type UiEnvironment, type UiEvent, type UiFrameSurface, type UiPanel, type UiResolvedPanel, type SurfaceReadiness } from "./ui-types.js";
3
+ export type UiTool = {
4
+ key: string;
5
+ target: PanelTarget;
6
+ resolved: UiResolvedPanel;
7
+ readiness: SurfaceReadiness;
8
+ attention: number;
9
+ };
10
+ export type UiHostSnapshot = {
11
+ bootstrap: UiBootstrap | null;
12
+ catalog: UiPanel[];
13
+ tools: UiTool[];
14
+ selected: PanelRef | null;
15
+ expanded: boolean;
16
+ width: number;
17
+ error: {
18
+ code: string;
19
+ message: string;
20
+ } | null;
21
+ };
22
+ /** One top-level composition owner. All Core traffic goes through this app's authenticated adapter. */
23
+ export declare function createUiHost(options?: {
24
+ uiPath?: string;
25
+ active?: boolean;
26
+ }): UiHost;
27
+ export declare class UiHost implements UiClient {
28
+ private readonly path;
29
+ private active;
30
+ private snapshot;
31
+ private environment;
32
+ private readonly windowId;
33
+ private readonly listeners;
34
+ private readonly events;
35
+ private readonly frames;
36
+ private readonly ledger;
37
+ private readonly toolQueues;
38
+ private selectionGeneration;
39
+ private activityGeneration;
40
+ private activityReady;
41
+ private disposed;
42
+ private workspace;
43
+ private refreshing;
44
+ private readonly onMessage;
45
+ private readonly onFocus;
46
+ constructor(path: string, active?: boolean);
47
+ getEnvironment: () => UiEnvironment;
48
+ setActive(active: boolean): void;
49
+ private checkActivity;
50
+ claimWorkspace: (owner: object) => () => void;
51
+ getSnapshot: () => UiHostSnapshot;
52
+ subscribeSnapshot: (listener: () => void) => () => void;
53
+ subscribe: (listener: (event: UiEvent) => void) => () => void;
54
+ private emit;
55
+ private update;
56
+ private setEnvironment;
57
+ private bootstrap;
58
+ refresh: () => Promise<void>;
59
+ openSurface: (target: PanelTarget, options: PresentationOptions) => Promise<PresentationResult>;
60
+ private open;
61
+ getPresentation: (id: string, options?: {
62
+ signal?: AbortSignal;
63
+ }) => Promise<PresentationResult>;
64
+ private status;
65
+ selectPanel: (ref: PanelRef) => Promise<PresentationResult>;
66
+ setExpanded: (expanded: boolean) => void;
67
+ setWidth: (width: number) => void;
68
+ setAttention: (panel: PanelRef, attention: number) => void;
69
+ reportSurfaceState: (_state: {
70
+ route: string;
71
+ state: "ready" | "blocked";
72
+ code?: string;
73
+ }) => void;
74
+ private forgetFrameRequests;
75
+ registerFrame: (element: HTMLIFrameElement, surface: UiFrameSurface, onConnection?: (connected: boolean) => void) => (() => void);
76
+ private post;
77
+ private reportFrame;
78
+ private receive;
79
+ dispose: () => void;
80
+ }
@@ -0,0 +1,439 @@
1
+ import { createPresentationRequestId } from "./ui.js";
2
+ import { UiError, panelKey, routeWithin } from "./ui-types.js";
3
+ import { canonicalSurfaceRoute, DISCOVERY_TIMEOUT, failure, readUiMessage, uiPath, uiRequest, validId, validTarget, waitForPresentation } from "./ui-transport.js";
4
+ let documentHost = null;
5
+ /** One top-level composition owner. All Core traffic goes through this app's authenticated adapter. */
6
+ export function createUiHost(options = {}) {
7
+ if (typeof window === "undefined" || window.parent !== window)
8
+ throw new Error("A panel host belongs to a top-level browser document.");
9
+ if (documentHost)
10
+ throw new Error("A Hosty panel host already owns this document.");
11
+ const host = new UiHost(uiPath(options.uiPath), options.active);
12
+ documentHost = host;
13
+ return host;
14
+ }
15
+ export class UiHost {
16
+ path;
17
+ active;
18
+ snapshot = { bootstrap: null, catalog: [], tools: [], selected: null, expanded: false, width: 360, error: null };
19
+ environment = { mode: "standalone", connection: "connecting" };
20
+ windowId = createPresentationRequestId();
21
+ listeners = new Set();
22
+ events = new Set();
23
+ frames = new Set();
24
+ ledger = new Map();
25
+ toolQueues = new Map();
26
+ selectionGeneration = 0;
27
+ activityGeneration = 0;
28
+ activityReady;
29
+ disposed = false;
30
+ workspace = null;
31
+ refreshing = null;
32
+ onMessage = (event) => { void this.receive(event); };
33
+ onFocus = () => { void this.refresh(); };
34
+ constructor(path, active = true) {
35
+ this.path = path;
36
+ this.active = active;
37
+ this.activityReady = active;
38
+ window.addEventListener("message", this.onMessage);
39
+ window.addEventListener("focus", this.onFocus);
40
+ if (active)
41
+ void this.refresh();
42
+ else
43
+ this.setEnvironment("blocked", "activity_required");
44
+ }
45
+ getEnvironment = () => this.environment;
46
+ setActive(active) {
47
+ if (this.disposed || this.active === active)
48
+ return;
49
+ this.active = active;
50
+ this.activityReady = false;
51
+ this.activityGeneration++;
52
+ this.selectionGeneration++;
53
+ this.refreshing = null;
54
+ this.toolQueues.clear();
55
+ for (const [key, entry] of this.ledger)
56
+ if (entry.pending || entry.frame)
57
+ this.ledger.delete(key);
58
+ for (const frame of this.frames) {
59
+ frame.connected = false;
60
+ frame.actor = null;
61
+ frame.nonce = null;
62
+ frame.onConnection?.(false);
63
+ frame.generation = createPresentationRequestId();
64
+ }
65
+ this.setEnvironment(active ? "connecting" : "blocked", active ? undefined : "activity_required");
66
+ if (active)
67
+ void this.refresh();
68
+ }
69
+ checkActivity(generation, renewing = false) {
70
+ if (this.disposed || !this.active || generation !== this.activityGeneration || !renewing && !this.activityReady)
71
+ throw new UiError("activity_required", "Renew this app's activity before opening panels.");
72
+ }
73
+ claimWorkspace = (owner) => {
74
+ if (this.workspace && this.workspace !== owner)
75
+ throw new Error("Exactly one panel workspace may occupy this host.");
76
+ this.workspace = owner;
77
+ return () => { if (this.workspace === owner)
78
+ this.workspace = null; };
79
+ };
80
+ getSnapshot = () => this.snapshot;
81
+ subscribeSnapshot = (listener) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
82
+ subscribe = (listener) => {
83
+ this.events.add(listener);
84
+ listener({ type: "environment", value: this.environment });
85
+ return () => { this.events.delete(listener); };
86
+ };
87
+ emit(event) { if (!this.disposed)
88
+ for (const listener of this.events)
89
+ listener(event); }
90
+ update(patch) {
91
+ if (this.disposed)
92
+ return;
93
+ this.snapshot = { ...this.snapshot, ...patch };
94
+ for (const listener of this.listeners)
95
+ listener();
96
+ }
97
+ setEnvironment(connection, code) {
98
+ this.environment = { mode: "standalone", connection, ...(code ? { code } : {}) };
99
+ this.emit({ type: "environment", value: this.environment });
100
+ }
101
+ async bootstrap(generation = this.activityGeneration, renewing = false) {
102
+ this.checkActivity(generation, renewing);
103
+ const value = await uiRequest(this.path, "bootstrap");
104
+ this.checkActivity(generation, renewing);
105
+ if (value.version !== 1 || !value.app?.installationId || !value.userId)
106
+ throw new UiError("unsupported_app", "Unsupported panel metadata.");
107
+ const previous = this.snapshot.bootstrap;
108
+ if (previous && (previous.userId !== value.userId || previous.coreOrigin !== value.coreOrigin || previous.app.installationId !== value.app.installationId)) {
109
+ this.ledger.clear();
110
+ this.toolQueues.clear();
111
+ for (const frame of this.frames) {
112
+ frame.connected = false;
113
+ frame.actor = null;
114
+ frame.nonce = null;
115
+ frame.onConnection?.(false);
116
+ frame.generation = createPresentationRequestId();
117
+ this.post(frame, { type: "hosty:ui:probe-request" });
118
+ }
119
+ this.update({ tools: [], selected: null, expanded: false });
120
+ }
121
+ this.update({ bootstrap: value });
122
+ return value;
123
+ }
124
+ refresh = () => {
125
+ if (!this.active || this.disposed)
126
+ return Promise.resolve();
127
+ if (this.refreshing)
128
+ return this.refreshing;
129
+ const generation = this.activityGeneration;
130
+ const operation = (async () => {
131
+ try {
132
+ const own = await this.bootstrap(generation, true);
133
+ this.checkActivity(generation, true);
134
+ if (!own.canHost) {
135
+ this.setEnvironment("unsupported", "app_permission_required");
136
+ this.update({ catalog: [], tools: [], selected: null, expanded: false });
137
+ return;
138
+ }
139
+ const catalog = await uiRequest(this.path, "panels");
140
+ this.checkActivity(generation, true);
141
+ if (!Array.isArray(catalog.panels))
142
+ throw new UiError("ui_response_invalid", "Invalid panel catalog.");
143
+ this.activityReady = true;
144
+ this.update({ catalog: catalog.panels, error: null });
145
+ this.setEnvironment("connected");
146
+ // Catalog disappearance/replacement is access loss, while temporary readiness loss retains drafts.
147
+ const allowed = new Map(catalog.panels.map(p => [panelKey(p.panel), p]));
148
+ this.update({ tools: this.snapshot.tools.map(tool => {
149
+ const entry = allowed.get(tool.key);
150
+ const invalid = !entry || !["running", "degraded", "unavailable", "starting", "stopping"].includes(entry.state);
151
+ const surface = tool.resolved.surface;
152
+ const changed = entry && surface && JSON.stringify(entry.framePolicy) !== JSON.stringify(surface.framePolicy);
153
+ const moved = entry && surface && (entry.origin !== surface.origin || entry.endpoint !== surface.endpoint
154
+ || !routeWithin(surface.route, entry.route));
155
+ return invalid || changed || moved ? { ...tool, resolved: { ...tool.resolved, available: false, surface: null,
156
+ state: !entry ? "access_unavailable" : changed ? "policy_changed" : moved ? "destination_changed" : entry.state } }
157
+ : entry && surface ? { ...tool, resolved: { ...tool.resolved, available: entry.available, state: entry.state } } : tool;
158
+ }) });
159
+ for (const frame of this.frames)
160
+ this.post(frame, { type: "hosty:ui:probe-request" });
161
+ }
162
+ catch (error) {
163
+ if (!this.active || this.disposed || generation !== this.activityGeneration)
164
+ return;
165
+ const code = error instanceof UiError ? error.code : "ui_unavailable";
166
+ this.update({ error: { code, message: error instanceof Error ? error.message : "Panel metadata is unavailable." } });
167
+ this.setEnvironment(code.startsWith("unsupported") ? "unsupported" : "blocked", code);
168
+ }
169
+ })().finally(() => { if (this.refreshing === operation)
170
+ this.refreshing = null; });
171
+ this.refreshing = operation;
172
+ return operation;
173
+ };
174
+ openSurface = (target, options) => waitForPresentation(this.open(target, options, "local"), options.requestId, options.signal);
175
+ open(target, options, source, frame) {
176
+ const id = options.requestId;
177
+ if (!this.active || !this.activityReady || this.disposed)
178
+ return Promise.resolve({ status: "blocked", requestId: id, code: "activity_required" });
179
+ const generation = this.activityGeneration;
180
+ if (!validId(id) || !validTarget(target) || options.placement !== "side")
181
+ return Promise.resolve({ status: "rejected", requestId: id, code: "invalid_presentation" });
182
+ const key = `${source}:${id}`, input = JSON.stringify([target.panel.appId, target.panel.installationId, target.panel.panelId, target.route ?? null]);
183
+ const existing = this.ledger.get(key);
184
+ if (existing && existing.input !== input)
185
+ return Promise.resolve({ status: "rejected", requestId: id, code: "request_conflict" });
186
+ if (existing?.pending)
187
+ return existing.pending;
188
+ if ([...this.ledger.entries()].filter(([k, v]) => k.startsWith(source + ":") && v.pending).length >= 4)
189
+ return Promise.resolve({ status: "blocked", requestId: id, code: "presentation_busy" });
190
+ const entry = { input, frame };
191
+ const selectionGeneration = ++this.selectionGeneration;
192
+ const currentSource = () => !frame || source === `${frame.surface.appId}:${frame.generation}:${frame.nonce}`;
193
+ const toolKey = panelKey(target.panel), previous = this.toolQueues.get(toolKey);
194
+ const operation = (async () => {
195
+ if (previous)
196
+ await previous;
197
+ try {
198
+ const own = await this.bootstrap(generation);
199
+ this.checkActivity(generation);
200
+ if (!own.canHost)
201
+ throw new UiError("app_permission_required", "Allow this app to host panels.");
202
+ if (frame && (!frame.live || !frame.connected || frame.actor !== own.userId || !currentSource()))
203
+ throw new UiError("actor_mismatch", "Reconnect using the same account.");
204
+ const resolved = await uiRequest(this.path, "panels/resolve", target);
205
+ this.checkActivity(generation);
206
+ if (this.disposed || this.snapshot.bootstrap?.userId !== own.userId || this.snapshot.bootstrap?.coreOrigin !== own.coreOrigin || this.snapshot.bootstrap?.app.installationId !== own.app.installationId
207
+ || frame && (!frame.live || !frame.connected || frame.actor !== own.userId || !currentSource()))
208
+ throw new UiError("actor_mismatch", "The signed-in account changed.");
209
+ if (!resolved.available || !resolved.surface)
210
+ throw new UiError(resolved.state || "destination_unavailable", "The selected panel is unavailable.");
211
+ const insecure = window.location.protocol === "https:" && new URL(resolved.surface.origin).protocol === "http:";
212
+ if (selectionGeneration !== this.selectionGeneration)
213
+ throw new UiError("presentation_superseded", "A newer panel action replaced this opening. Retry to open it again.");
214
+ const old = this.snapshot.tools.find(t => t.key === toolKey);
215
+ const same = old?.resolved.surface && JSON.stringify(old.resolved.surface) === JSON.stringify(resolved.surface);
216
+ const tool = { key: toolKey, target: resolved.target, resolved,
217
+ readiness: insecure ? "unreported" : same ? old.readiness : "loading", attention: old?.attention ?? 0 };
218
+ this.update({ tools: old ? this.snapshot.tools.map(t => t.key === toolKey ? tool : t) : [...this.snapshot.tools, tool], selected: target.panel, expanded: true });
219
+ return { status: "opened", requestId: id, target: resolved.target, readiness: tool.readiness };
220
+ }
221
+ catch (error) {
222
+ return failure(id, error);
223
+ }
224
+ })();
225
+ entry.pending = operation;
226
+ this.ledger.set(key, entry);
227
+ this.toolQueues.set(toolKey, operation);
228
+ void operation.then(result => {
229
+ entry.pending = undefined;
230
+ entry.result = result;
231
+ if (this.toolQueues.get(toolKey) === operation)
232
+ this.toolQueues.delete(toolKey);
233
+ if (!frame && this.active && generation === this.activityGeneration)
234
+ this.emit({ type: "presentation", value: result });
235
+ const keys = [...this.ledger.keys()].filter(k => k.startsWith(source + ":"));
236
+ for (const oldKey of keys.slice(0, Math.max(0, keys.length - 256)))
237
+ if (!this.ledger.get(oldKey)?.pending)
238
+ this.ledger.delete(oldKey);
239
+ });
240
+ return operation;
241
+ }
242
+ getPresentation = (id, options) => waitForPresentation(this.status(id, "local"), id, options?.signal);
243
+ async status(id, source, frame) {
244
+ const generation = this.activityGeneration;
245
+ try {
246
+ const own = await this.bootstrap(generation);
247
+ const check = () => {
248
+ this.checkActivity(generation);
249
+ if (!own.canHost)
250
+ throw new UiError("app_permission_required", "Allow this app to host panels.");
251
+ if (this.snapshot.bootstrap?.userId !== own.userId || this.snapshot.bootstrap?.coreOrigin !== own.coreOrigin
252
+ || this.snapshot.bootstrap?.app.installationId !== own.app.installationId || frame && (!frame.live || !frame.connected || frame.actor !== own.userId
253
+ || source !== `${frame.surface.appId}:${frame.generation}:${frame.nonce}`))
254
+ throw new UiError("actor_mismatch", "Reconnect using the same account.");
255
+ };
256
+ check();
257
+ const entry = this.ledger.get(`${source}:${id}`);
258
+ const result = entry?.pending ? await entry.pending : entry?.result;
259
+ check();
260
+ if (result?.status === "opened") {
261
+ const current = await uiRequest(this.path, "panels/resolve", result.target);
262
+ check();
263
+ if (!current.available)
264
+ return { status: "blocked", requestId: id, code: current.state };
265
+ const tool = this.snapshot.tools.find(t => t.key === panelKey(result.target.panel));
266
+ if (!tool || tool.target.route !== result.target.route || JSON.stringify(tool.resolved.surface) !== JSON.stringify(current.surface))
267
+ return { status: "blocked", requestId: id, code: "destination_changed" };
268
+ return { ...result, readiness: tool.readiness };
269
+ }
270
+ return result ?? { status: "unknown", requestId: id, code: "presentation_unknown" };
271
+ }
272
+ catch (error) {
273
+ return failure(id, error);
274
+ }
275
+ }
276
+ selectPanel = async (ref) => {
277
+ const opened = this.snapshot.tools.find(t => t.key === panelKey(ref));
278
+ return this.openSurface(opened?.resolved.surface ? opened.target : { panel: ref }, { placement: "side", requestId: createPresentationRequestId() });
279
+ };
280
+ setExpanded = (expanded) => {
281
+ if (expanded && (!this.active || !this.activityReady))
282
+ return;
283
+ if (!expanded)
284
+ this.selectionGeneration++;
285
+ this.update({ expanded });
286
+ };
287
+ setWidth = (width) => { if (Number.isFinite(width))
288
+ this.update({ width: Math.min(800, Math.max(280, width)) }); };
289
+ setAttention = (panel, attention) => this.update({ tools: this.snapshot.tools.map(t => t.key === panelKey(panel) ? { ...t, attention: Math.max(0, Math.floor(attention)) } : t) });
290
+ reportSurfaceState = (_state) => { };
291
+ forgetFrameRequests(frame) {
292
+ for (const [key, entry] of this.ledger)
293
+ if (entry.frame === frame)
294
+ this.ledger.delete(key);
295
+ }
296
+ registerFrame = (element, surface, onConnection) => {
297
+ const frame = { element, surface, onConnection, generation: createPresentationRequestId(), nonce: null, connected: false, actor: null, live: true, cleanup: () => { }, waitForReadiness: () => { } };
298
+ let timer;
299
+ const forget = () => this.forgetFrameRequests(frame);
300
+ frame.waitForReadiness = () => {
301
+ clearTimeout(timer);
302
+ timer = setTimeout(() => {
303
+ const tool = this.snapshot.tools.find(t => t.target.panel.panelId === frame.surface.panelId && t.target.panel.appId === frame.surface.appId);
304
+ if (tool?.readiness === "loading")
305
+ this.reportFrame(frame, "unreported");
306
+ }, DISCOVERY_TIMEOUT);
307
+ };
308
+ const loaded = () => {
309
+ forget();
310
+ frame.generation = createPresentationRequestId();
311
+ frame.nonce = null;
312
+ frame.connected = false;
313
+ frame.actor = null;
314
+ frame.onConnection?.(false);
315
+ this.reportFrame(frame, "loading");
316
+ this.post(frame, { type: "hosty:ui:probe-request" });
317
+ frame.waitForReadiness();
318
+ };
319
+ frame.cleanup = () => { frame.live = false; forget(); clearTimeout(timer); element.removeEventListener("load", loaded); this.frames.delete(frame); };
320
+ this.frames.add(frame);
321
+ element.addEventListener("load", loaded);
322
+ loaded();
323
+ return frame.cleanup;
324
+ };
325
+ post(frame, value) {
326
+ if (!frame.live || this.disposed || !this.active || !this.activityReady)
327
+ return;
328
+ frame.element.contentWindow?.postMessage({ version: 1, hostWindowId: this.windowId, generation: frame.generation,
329
+ childNonce: frame.nonce, ...value }, frame.surface.origin);
330
+ }
331
+ reportFrame(frame, readiness) {
332
+ if (!frame.surface.panelId || !this.active || !this.activityReady)
333
+ return;
334
+ const tools = this.snapshot.tools.map(t => t.resolved.surface?.appId === frame.surface.appId
335
+ && t.target.panel.panelId === frame.surface.panelId
336
+ && t.resolved.surface.installationId === frame.surface.installationId && t.resolved.surface.endpoint === frame.surface.endpoint
337
+ && t.target.route === frame.surface.route ? { ...t, readiness } : t);
338
+ this.update({ tools });
339
+ for (const [key, entry] of this.ledger) {
340
+ const result = entry.result;
341
+ if (result?.status !== "opened")
342
+ continue;
343
+ const tool = tools.find(t => t.key === panelKey(result.target.panel) && t.target.route === result.target.route);
344
+ if (!tool || tool.readiness === result.readiness)
345
+ continue;
346
+ entry.result = { ...result, readiness: tool.readiness };
347
+ if (entry.frame?.live)
348
+ this.post(entry.frame, { type: "hosty:ui:result", requestId: result.requestId, result: entry.result });
349
+ else if (key.startsWith("local:"))
350
+ this.emit({ type: "presentation", value: entry.result });
351
+ }
352
+ }
353
+ async receive(event) {
354
+ if (!this.active || !this.activityReady || this.disposed)
355
+ return;
356
+ const msg = readUiMessage(event.data);
357
+ if (!msg)
358
+ return;
359
+ const frame = [...this.frames].find(f => f.live && f.element.contentWindow === event.source && f.surface.origin === event.origin);
360
+ const own = this.snapshot.bootstrap;
361
+ if (!frame || !own?.canHost)
362
+ return;
363
+ if (msg.type === "hosty:ui:probe" && validId(msg.childNonce) && Array.isArray(msg.versions) && msg.versions.includes(1)) {
364
+ if (frame.nonce !== msg.childNonce) {
365
+ this.forgetFrameRequests(frame);
366
+ frame.connected = false;
367
+ frame.actor = null;
368
+ frame.onConnection?.(false);
369
+ }
370
+ frame.nonce = msg.childNonce;
371
+ this.post(frame, { type: "hosty:ui:offer" });
372
+ return;
373
+ }
374
+ if (msg.hostWindowId !== this.windowId || msg.generation !== frame.generation || msg.childNonce !== frame.nonce || !frame.nonce)
375
+ return;
376
+ // This is advisory state, accepted only from the mounted origin/document. It grants no authority.
377
+ if (msg.type === "hosty:ui:auth-required") {
378
+ frame.connected = false;
379
+ frame.actor = null;
380
+ frame.onConnection?.(false);
381
+ this.reportFrame(frame, "auth-required");
382
+ return;
383
+ }
384
+ if (msg.type === "hosty:ui:accept") {
385
+ const wasConnected = frame.connected;
386
+ frame.connected = msg.coreOrigin === own.coreOrigin && msg.userId === own.userId
387
+ && msg.appId === frame.surface.appId && msg.installationId === frame.surface.installationId;
388
+ frame.actor = frame.connected ? own.userId : null;
389
+ if (frame.connected) {
390
+ this.reportFrame(frame, "loading");
391
+ frame.waitForReadiness();
392
+ }
393
+ this.post(frame, { type: "hosty:ui:connected", coreOrigin: own.coreOrigin, userId: own.userId,
394
+ appId: own.app.appId, installationId: own.app.installationId, code: frame.connected ? null : "actor_mismatch", route: frame.surface.route });
395
+ if (frame.connected !== wasConnected)
396
+ frame.onConnection?.(frame.connected);
397
+ if (!frame.connected)
398
+ this.reportFrame(frame, "auth-required");
399
+ return;
400
+ }
401
+ if (!frame.connected)
402
+ return;
403
+ const source = `${frame.surface.appId}:${frame.generation}:${frame.nonce}`;
404
+ if (msg.type === "hosty:ui:open" && validId(msg.requestId)) {
405
+ const result = await this.open(msg.target, { placement: msg.placement, requestId: msg.requestId }, source, frame);
406
+ if (source === `${frame.surface.appId}:${frame.generation}:${frame.nonce}`)
407
+ this.post(frame, { type: "hosty:ui:result", requestId: msg.requestId, result });
408
+ }
409
+ else if (msg.type === "hosty:ui:status" && validId(msg.requestId)) {
410
+ const result = await this.status(msg.requestId, source, frame);
411
+ if (source === `${frame.surface.appId}:${frame.generation}:${frame.nonce}`)
412
+ this.post(frame, { type: "hosty:ui:result", requestId: msg.requestId, result });
413
+ }
414
+ else if (msg.type === "hosty:ui:surface-state" && canonicalSurfaceRoute(msg.route) !== null
415
+ && canonicalSurfaceRoute(msg.route) === canonicalSurfaceRoute(frame.surface.route)) {
416
+ if (msg.state === "ready")
417
+ this.reportFrame(frame, "ready");
418
+ else if (msg.state === "auth-required" || msg.state === "blocked")
419
+ this.reportFrame(frame, "auth-required");
420
+ }
421
+ else if (msg.type === "hosty:ui:dismiss") {
422
+ const selected = this.snapshot.tools.find(t => this.snapshot.selected && t.key === panelKey(this.snapshot.selected));
423
+ if (selected?.resolved.surface?.appId === frame.surface.appId && selected.target.panel.panelId === frame.surface.panelId && selected.target.route === frame.surface.route)
424
+ window.dispatchEvent(new CustomEvent("hosty:ui:drawer-dismiss"));
425
+ }
426
+ }
427
+ dispose = () => {
428
+ this.disposed = true;
429
+ for (const frame of this.frames)
430
+ frame.cleanup();
431
+ window.removeEventListener("message", this.onMessage);
432
+ window.removeEventListener("focus", this.onFocus);
433
+ this.listeners.clear();
434
+ this.events.clear();
435
+ this.ledger.clear();
436
+ if (documentHost === this)
437
+ documentHost = null;
438
+ };
439
+ }
@@ -0,0 +1,35 @@
1
+ import { type ReactNode } from "react";
2
+ import { type UiClient } from "./ui.js";
3
+ import { type UiHost } from "./ui-host.js";
4
+ import { type HostyAppFrameProps } from "./ui-frame.js";
5
+ import { type PanelTarget, type UiPanel } from "./ui-types.js";
6
+ export { HostyAppFrame, type HostyAppFrameProps } from "./ui-frame.js";
7
+ export declare function useHostyUi(): UiClient;
8
+ export declare function useHostyUiHost(): UiHost | null;
9
+ /** Internal composition root, owned by HostyOverlay rather than mounted again by domain apps. */
10
+ export declare function HostyUiRoot({ children, uiPath, panelLayout, active }: {
11
+ children: ReactNode;
12
+ uiPath?: string;
13
+ panelLayout?: "auto" | "slot" | "none";
14
+ active?: boolean;
15
+ }): import("react").JSX.Element;
16
+ export type HostyPanelWorkspaceProps = {
17
+ children: ReactNode;
18
+ railMode?: "opened" | "catalog";
19
+ className?: string;
20
+ initialWidth?: number;
21
+ initialExpanded?: boolean;
22
+ onWidthChange?: (width: number) => void;
23
+ onExpandedChange?: (expanded: boolean) => void;
24
+ onStartApp?: (appId: string) => void | Promise<void>;
25
+ onAskAssistant?: HostyAppFrameProps["onAskAssistant"];
26
+ onAuthRequired?: HostyAppFrameProps["onAuthRequired"];
27
+ outbound?: {
28
+ target: PanelTarget;
29
+ message: unknown;
30
+ nonce: number;
31
+ } | null;
32
+ renderIcon?: (panel: UiPanel) => ReactNode;
33
+ };
34
+ /** A single retained frame tree for dock and drawer; only CSS placement changes at the breakpoint. */
35
+ export declare function HostyPanelWorkspace({ children, railMode, className, initialWidth, initialExpanded, onWidthChange, onExpandedChange, onStartApp, onAskAssistant, onAuthRequired, outbound, renderIcon }: HostyPanelWorkspaceProps): import("react").JSX.Element;