@hosty-sdk/app 0.26.0 → 0.27.23
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/README.md +158 -8
- package/dist/index.d.ts +9 -6
- package/dist/index.js +10 -1
- package/dist/next.d.ts +4 -0
- package/dist/next.js +29 -0
- package/dist/overlay-react.d.ts +7 -1
- package/dist/overlay-react.js +2 -2
- package/dist/react.d.ts +1 -0
- package/dist/react.js +3 -0
- package/dist/ui-composition.d.ts +71 -0
- package/dist/ui-composition.js +232 -0
- package/dist/ui-context.d.ts +2 -0
- package/dist/ui-context.js +1 -0
- package/dist/ui-embedded.d.ts +18 -1
- package/dist/ui-embedded.js +123 -17
- package/dist/ui-frame-layer.d.ts +36 -0
- package/dist/ui-frame-layer.js +88 -0
- package/dist/ui-frame.d.ts +2 -0
- package/dist/ui-frame.js +43 -7
- package/dist/ui-host.d.ts +62 -15
- package/dist/ui-host.js +666 -76
- package/dist/ui-navigation-react.d.ts +17 -0
- package/dist/ui-navigation-react.js +89 -0
- package/dist/ui-react.d.ts +15 -3
- package/dist/ui-react.js +131 -54
- package/dist/ui-server.d.ts +3 -1
- package/dist/ui-server.js +14 -2
- package/dist/ui-transport.d.ts +2 -2
- package/dist/ui-transport.js +1 -1
- package/dist/ui-types.d.ts +88 -0
- package/dist/ui-types.js +1 -0
- package/dist/ui-workspaces.d.ts +54 -0
- package/dist/ui-workspaces.js +3 -0
- package/dist/ui.d.ts +7 -1
- package/dist/ui.js +22 -0
- package/package.json +5 -1
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { UI_COMPOSITION_CAPABILITY, UiError, isSurfaceRoute } from "./ui-types.js";
|
|
2
|
+
import { canonicalSurfaceRoute, uiRequest } from "./ui-transport.js";
|
|
3
|
+
export const CHROME_HINT_ATTRIBUTE = "data-hosty-chrome-hints";
|
|
4
|
+
const navigationAttribute = "data-hosty-navigation";
|
|
5
|
+
const fields = ["hosty_navigation", "hosty_tools", "hosty_tool_launcher", "hosty_launch"];
|
|
6
|
+
export function parseChromeHints(search) {
|
|
7
|
+
const params = new URLSearchParams(search);
|
|
8
|
+
const one = (key) => params.getAll(key).length === 1 ? params.get(key) : null;
|
|
9
|
+
const navigation = one(fields[0]), enabled = one(fields[1]), launcher = one(fields[2]), launch = one(fields[3]);
|
|
10
|
+
return { ...(navigation === "show" || navigation === "hide" ? { navigation } : {}),
|
|
11
|
+
tools: { ...(enabled === "enabled" || enabled === "disabled" ? { enabled: enabled === "enabled" } : {}),
|
|
12
|
+
...(launcher === "show" || launcher === "hide" ? { launcher } : {}) },
|
|
13
|
+
...(launch === "embedded" || launch === "native" ? { legacy: "hide" } : launch === "standalone" ? { legacy: "show" } : {}) };
|
|
14
|
+
}
|
|
15
|
+
export function validChromeOverrides(value) {
|
|
16
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
17
|
+
return false;
|
|
18
|
+
const v = value;
|
|
19
|
+
if (Object.keys(v).some(k => !["navigation", "tools"].includes(k)))
|
|
20
|
+
return false;
|
|
21
|
+
if (v.navigation !== undefined && v.navigation !== "show" && v.navigation !== "hide")
|
|
22
|
+
return false;
|
|
23
|
+
if (v.tools === undefined)
|
|
24
|
+
return true;
|
|
25
|
+
return !!v.tools && typeof v.tools === "object" && !Array.isArray(v.tools)
|
|
26
|
+
&& Object.keys(v.tools).every(k => k === "enabled" || k === "launcher")
|
|
27
|
+
&& (v.tools.enabled === undefined || typeof v.tools.enabled === "boolean")
|
|
28
|
+
&& (v.tools.launcher === undefined || ["show", "hide"].includes(v.tools.launcher));
|
|
29
|
+
}
|
|
30
|
+
export function resolveChrome(defaults, hints, host = {}) {
|
|
31
|
+
function leaf(values, fallback) {
|
|
32
|
+
return values.find(([value]) => value !== undefined) ?? [fallback, "default"];
|
|
33
|
+
}
|
|
34
|
+
const [navigation, n] = leaf([[host.navigation, "host"], [hints.navigation, "url"], [defaults.navigation, "app"], [hints.legacy, "legacy"]], "show");
|
|
35
|
+
const [enabled, e] = leaf([[host.tools?.enabled, "host"], [hints.tools?.enabled, "url"], [defaults.tools?.enabled, "app"]], true);
|
|
36
|
+
const [launcher, l] = leaf([[host.tools?.launcher, "host"], [hints.tools?.launcher, "url"], [defaults.tools?.launcher, "app"]], "show");
|
|
37
|
+
return { navigation, enabled, launcher, sources: { navigation: n, enabled: e, launcher: l } };
|
|
38
|
+
}
|
|
39
|
+
export const INITIAL_CHROME = Object.freeze({ navigation: "show", tools: Object.freeze({ enabled: true, launcher: "show", owner: "unavailable" }),
|
|
40
|
+
revision: 0, hostRevision: -1, rootDisabled: false, stale: false, connection: "connecting", capabilities: Object.freeze([]),
|
|
41
|
+
sources: Object.freeze({ navigation: "default", enabled: "default", launcher: "default" }) });
|
|
42
|
+
export const INITIAL_NAVIGATION = Object.freeze({ status: "loading", catalog: null, entries: Object.freeze([]), activeKey: null });
|
|
43
|
+
/** Presentation hints are captured only for this document, never from inherited session storage. */
|
|
44
|
+
export function captureChromeHints() {
|
|
45
|
+
if (typeof document === "undefined")
|
|
46
|
+
return "";
|
|
47
|
+
const root = document.documentElement;
|
|
48
|
+
const captured = root.getAttribute(CHROME_HINT_ATTRIBUTE);
|
|
49
|
+
if (captured !== null)
|
|
50
|
+
return captured;
|
|
51
|
+
const params = new URLSearchParams(window.location.search), hints = new URLSearchParams();
|
|
52
|
+
for (const key of fields)
|
|
53
|
+
for (const value of params.getAll(key))
|
|
54
|
+
hints.append(key, value);
|
|
55
|
+
root.setAttribute(CHROME_HINT_ATTRIBUTE, hints.toString());
|
|
56
|
+
return hints.toString();
|
|
57
|
+
}
|
|
58
|
+
export function withChromeHints(href, chrome) {
|
|
59
|
+
const url = new URL(href, typeof location === "undefined" ? "https://surface.invalid" : location.href);
|
|
60
|
+
for (const key of fields.slice(0, 3))
|
|
61
|
+
url.searchParams.delete(key);
|
|
62
|
+
if (chrome.navigation !== undefined)
|
|
63
|
+
url.searchParams.set(fields[0], chrome.navigation);
|
|
64
|
+
if (chrome.tools?.enabled !== undefined)
|
|
65
|
+
url.searchParams.set(fields[1], chrome.tools.enabled ? "enabled" : "disabled");
|
|
66
|
+
if (chrome.tools?.launcher !== undefined)
|
|
67
|
+
url.searchParams.set(fields[2], chrome.tools.launcher);
|
|
68
|
+
return url.href;
|
|
69
|
+
}
|
|
70
|
+
export { chromeBootstrapScript } from "./index.js";
|
|
71
|
+
export function activeNavigationKey(entries, endpoint, route) {
|
|
72
|
+
const canonical = canonicalSurfaceRoute(route);
|
|
73
|
+
if (!canonical || !endpoint)
|
|
74
|
+
return null;
|
|
75
|
+
const current = new URL(canonical, "https://surface.invalid");
|
|
76
|
+
const candidates = entries.flatMap((entry, index) => {
|
|
77
|
+
if (entry.endpoint !== endpoint || !entry.route)
|
|
78
|
+
return [];
|
|
79
|
+
const normalized = canonicalSurfaceRoute(entry.route);
|
|
80
|
+
if (!normalized)
|
|
81
|
+
return [];
|
|
82
|
+
const declared = new URL(normalized, current.origin), path = declared.pathname.replace(/\/$/, "") || "/";
|
|
83
|
+
if (declared.search && declared.search !== current.search || declared.hash && declared.hash !== current.hash)
|
|
84
|
+
return [];
|
|
85
|
+
if (current.pathname !== declared.pathname && (path === "/" || !current.pathname.startsWith(path + "/")))
|
|
86
|
+
return [];
|
|
87
|
+
return [{ key: entry.key, rank: normalized === canonical ? Infinity : path.length, index }];
|
|
88
|
+
});
|
|
89
|
+
return candidates.sort((a, b) => b.rank - a.rank || a.index - b.index)[0]?.key ?? null;
|
|
90
|
+
}
|
|
91
|
+
/** Shared observable state for local and embedded clients; it owns no frame or identity authority. */
|
|
92
|
+
export class UiCompositionModel {
|
|
93
|
+
path;
|
|
94
|
+
defaults;
|
|
95
|
+
chrome = INITIAL_CHROME;
|
|
96
|
+
navigation = INITIAL_NAVIGATION;
|
|
97
|
+
chromeListeners = new Set();
|
|
98
|
+
navigationListeners = new Set();
|
|
99
|
+
hints = parseChromeHints(captureChromeHints());
|
|
100
|
+
overrides = {};
|
|
101
|
+
hostRevision = -1;
|
|
102
|
+
hostGeneration = "";
|
|
103
|
+
rootDisabled = false;
|
|
104
|
+
capabilities = [];
|
|
105
|
+
environment;
|
|
106
|
+
router;
|
|
107
|
+
stopRouter;
|
|
108
|
+
endpoint;
|
|
109
|
+
epoch = 0;
|
|
110
|
+
disposed = false;
|
|
111
|
+
refreshing = null;
|
|
112
|
+
constructor(path, mode, defaults = {}) {
|
|
113
|
+
this.path = path;
|
|
114
|
+
this.defaults = defaults;
|
|
115
|
+
this.environment = { mode, connection: "connecting" };
|
|
116
|
+
if (mode === "standalone")
|
|
117
|
+
this.capabilities = [UI_COMPOSITION_CAPABILITY];
|
|
118
|
+
this.publishChrome();
|
|
119
|
+
}
|
|
120
|
+
getChrome = () => this.chrome;
|
|
121
|
+
subscribeChrome = (listener) => { this.chromeListeners.add(listener); return () => { this.chromeListeners.delete(listener); }; };
|
|
122
|
+
getNavigation = () => this.navigation;
|
|
123
|
+
subscribeNavigation = (listener) => { this.navigationListeners.add(listener); return () => { this.navigationListeners.delete(listener); }; };
|
|
124
|
+
configure(defaults) {
|
|
125
|
+
if (!validChromeOverrides(defaults))
|
|
126
|
+
throw new Error("Invalid chrome defaults.");
|
|
127
|
+
this.defaults = structuredClone(defaults);
|
|
128
|
+
this.publishChrome();
|
|
129
|
+
}
|
|
130
|
+
setEnvironment(environment) { this.environment = environment; this.publishChrome(); }
|
|
131
|
+
setHostPolicy(generation, revision, policy, rootDisabled, endpoint) {
|
|
132
|
+
if (!validChromeOverrides(policy) || !Number.isSafeInteger(revision) || revision < 0)
|
|
133
|
+
return;
|
|
134
|
+
if (generation === this.hostGeneration && revision <= this.hostRevision)
|
|
135
|
+
return;
|
|
136
|
+
this.hostGeneration = generation;
|
|
137
|
+
this.hostRevision = revision;
|
|
138
|
+
this.overrides = structuredClone(policy);
|
|
139
|
+
this.rootDisabled = rootDisabled;
|
|
140
|
+
this.endpoint = endpoint;
|
|
141
|
+
this.capabilities = [UI_COMPOSITION_CAPABILITY];
|
|
142
|
+
this.publishChrome();
|
|
143
|
+
this.updateRoute();
|
|
144
|
+
}
|
|
145
|
+
setLegacyHost() {
|
|
146
|
+
this.capabilities = [];
|
|
147
|
+
this.overrides = {};
|
|
148
|
+
this.hostRevision = -1;
|
|
149
|
+
this.hostGeneration = "";
|
|
150
|
+
this.rootDisabled = false;
|
|
151
|
+
this.publishChrome();
|
|
152
|
+
}
|
|
153
|
+
publishChrome() {
|
|
154
|
+
const { navigation, enabled, launcher, sources } = resolveChrome(this.defaults, this.hints, this.overrides);
|
|
155
|
+
const connected = this.environment.connection === "connected";
|
|
156
|
+
this.chrome = Object.freeze({ navigation, tools: Object.freeze({ enabled: enabled && !this.rootDisabled, launcher,
|
|
157
|
+
owner: this.environment.mode === "standalone" ? "local" : connected ? "host" : "unavailable" }),
|
|
158
|
+
revision: this.chrome.revision + 1, hostRevision: this.hostRevision, rootDisabled: this.rootDisabled,
|
|
159
|
+
stale: this.hostRevision >= 0 && !connected, connection: this.environment.connection,
|
|
160
|
+
capabilities: Object.freeze([...this.capabilities]), sources: Object.freeze({ ...sources, ...(this.rootDisabled ? { enabled: "host" } : {}) }) });
|
|
161
|
+
if (typeof document !== "undefined")
|
|
162
|
+
document.documentElement.setAttribute(navigationAttribute, navigation);
|
|
163
|
+
for (const listener of this.chromeListeners)
|
|
164
|
+
listener();
|
|
165
|
+
}
|
|
166
|
+
attachRouter(router) {
|
|
167
|
+
this.stopRouter?.();
|
|
168
|
+
this.router = router;
|
|
169
|
+
if (router)
|
|
170
|
+
this.stopRouter = router.subscribe(() => this.updateRoute());
|
|
171
|
+
else if (typeof window !== "undefined") {
|
|
172
|
+
const changed = () => this.updateRoute();
|
|
173
|
+
window.addEventListener("popstate", changed);
|
|
174
|
+
window.addEventListener("hashchange", changed);
|
|
175
|
+
this.stopRouter = () => { window.removeEventListener("popstate", changed); window.removeEventListener("hashchange", changed); };
|
|
176
|
+
}
|
|
177
|
+
this.updateRoute();
|
|
178
|
+
}
|
|
179
|
+
updateNavigation(patch) {
|
|
180
|
+
this.navigation = Object.freeze({ ...this.navigation, ...patch });
|
|
181
|
+
for (const listener of this.navigationListeners)
|
|
182
|
+
listener();
|
|
183
|
+
}
|
|
184
|
+
currentRoute = () => this.router?.getRoute() ?? `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
185
|
+
currentEndpoint = () => {
|
|
186
|
+
const endpoints = new Set(this.navigation.entries.filter(e => e.origin === window.location.origin && e.endpoint).map(e => e.endpoint));
|
|
187
|
+
return this.router?.endpoint ?? this.endpoint ?? (endpoints.size === 1 ? [...endpoints][0] : undefined);
|
|
188
|
+
};
|
|
189
|
+
isLocalEntry(entry) { return entry.origin === window.location.origin && entry.endpoint === this.currentEndpoint(); }
|
|
190
|
+
updateRoute = () => {
|
|
191
|
+
const origins = new Set(this.navigation.entries.filter(e => e.origin === window.location.origin && e.endpoint).map(e => e.endpoint));
|
|
192
|
+
const endpoint = this.router?.endpoint ?? this.endpoint ?? (origins.size === 1 ? [...origins][0] : undefined);
|
|
193
|
+
const route = this.router?.getRoute() ?? `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
194
|
+
this.updateNavigation({ activeKey: activeNavigationKey(this.navigation.entries, endpoint, route) });
|
|
195
|
+
};
|
|
196
|
+
refreshNavigation = () => {
|
|
197
|
+
if (this.refreshing)
|
|
198
|
+
return this.refreshing;
|
|
199
|
+
const epoch = this.epoch;
|
|
200
|
+
const operation = (async () => {
|
|
201
|
+
try {
|
|
202
|
+
const catalog = await uiRequest(this.path, "navigation");
|
|
203
|
+
if (this.disposed || epoch !== this.epoch)
|
|
204
|
+
return;
|
|
205
|
+
if (!catalog.installationId || !Array.isArray(catalog.entries) || catalog.entries.some(e => e.route !== null && !isSurfaceRoute(e.route)))
|
|
206
|
+
throw new UiError("ui_response_invalid", "Invalid navigation metadata.");
|
|
207
|
+
const entries = Object.freeze(catalog.entries.map(e => Object.freeze({ ...e })));
|
|
208
|
+
this.updateNavigation({ status: "ready", catalog: Object.freeze({ ...catalog, entries }), entries, code: undefined });
|
|
209
|
+
this.updateRoute();
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
if (!this.disposed && epoch === this.epoch)
|
|
213
|
+
this.updateNavigation({ status: "unavailable", code: error instanceof UiError ? error.code : "ui_unavailable" });
|
|
214
|
+
}
|
|
215
|
+
})().finally(() => { if (this.refreshing === operation)
|
|
216
|
+
this.refreshing = null; });
|
|
217
|
+
this.refreshing = operation;
|
|
218
|
+
return operation;
|
|
219
|
+
};
|
|
220
|
+
resetNavigation() { this.epoch++; this.refreshing = null; this.updateNavigation({ ...INITIAL_NAVIGATION }); }
|
|
221
|
+
async navigateLocal(entry) {
|
|
222
|
+
if (!entry.route || !isSurfaceRoute(entry.route) || !this.isLocalEntry(entry))
|
|
223
|
+
return false;
|
|
224
|
+
if (this.router)
|
|
225
|
+
await this.router.navigate(entry.route);
|
|
226
|
+
else
|
|
227
|
+
window.location.assign(withChromeHints(new URL(entry.route, window.location.origin).href, this.chrome));
|
|
228
|
+
this.updateRoute();
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
dispose() { this.disposed = true; this.epoch++; this.stopRouter?.(); this.chromeListeners.clear(); this.navigationListeners.clear(); }
|
|
232
|
+
}
|
package/dist/ui-context.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
import type { UiHost } from "./ui-host.js";
|
|
2
2
|
export declare const UiHostContext: import("react").Context<UiHost | null>;
|
|
3
|
+
import type { UiCompositionModel } from "./ui-composition.js";
|
|
4
|
+
export declare const UiCompositionContext: import("react").Context<UiCompositionModel | null>;
|
package/dist/ui-context.js
CHANGED
package/dist/ui-embedded.d.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { UiCompositionModel } from "./ui-composition.js";
|
|
2
|
+
import { type UiChromeOverrides, type UiActionOptions, type UiActionResult } from "./ui-types.js";
|
|
1
3
|
import { type UiClient } from "./ui.js";
|
|
2
4
|
import { type PanelTarget, type PresentationOptions, type PresentationResult, type UiEnvironment, type UiEvent } from "./ui-types.js";
|
|
3
5
|
/** Direct-child transport. Discovery carries no identity and never creates a fallback host. */
|
|
4
6
|
export declare class EmbeddedUiClient implements UiClient {
|
|
5
7
|
private readonly path;
|
|
8
|
+
readonly composition: UiCompositionModel;
|
|
9
|
+
private readonly localActions;
|
|
6
10
|
private environment;
|
|
7
11
|
private nonce;
|
|
8
12
|
private readonly listeners;
|
|
@@ -18,8 +22,18 @@ export declare class EmbeddedUiClient implements UiClient {
|
|
|
18
22
|
private readonly onMessage;
|
|
19
23
|
private readonly onFocus;
|
|
20
24
|
private readonly onKey;
|
|
21
|
-
constructor(path: string);
|
|
25
|
+
constructor(path: string, chrome?: UiChromeOverrides);
|
|
22
26
|
getEnvironment: () => UiEnvironment;
|
|
27
|
+
getChrome: () => import("./ui-types").UiChromeSnapshot;
|
|
28
|
+
subscribeChrome: (listener: () => void) => () => void;
|
|
29
|
+
getNavigation: () => Readonly<{
|
|
30
|
+
status: "loading" | "ready" | "unavailable";
|
|
31
|
+
catalog: import("./ui-types").UiNavigationCatalog | null;
|
|
32
|
+
entries: readonly import("./ui-types").UiNavigationEntry[];
|
|
33
|
+
activeKey: string | null;
|
|
34
|
+
code?: string;
|
|
35
|
+
}>;
|
|
36
|
+
subscribeNavigation: (listener: () => void) => () => void;
|
|
23
37
|
setActive(active: boolean): void;
|
|
24
38
|
subscribe: (listener: (event: UiEvent) => void) => () => void;
|
|
25
39
|
private emit;
|
|
@@ -36,6 +50,9 @@ export declare class EmbeddedUiClient implements UiClient {
|
|
|
36
50
|
getPresentation: (requestId: string, options?: {
|
|
37
51
|
signal?: AbortSignal;
|
|
38
52
|
}) => Promise<PresentationResult>;
|
|
53
|
+
openToolPicker: (options: UiActionOptions) => Promise<UiActionResult>;
|
|
54
|
+
navigate: (entryKey: string, options: UiActionOptions) => Promise<UiActionResult>;
|
|
55
|
+
private reportRoute;
|
|
39
56
|
reportSurfaceState: (state: {
|
|
40
57
|
route: string;
|
|
41
58
|
state: "ready" | "blocked";
|
package/dist/ui-embedded.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { activeNavigationKey, UiCompositionModel } from "./ui-composition.js";
|
|
2
|
+
import { UI_COMPOSITION_CAPABILITY } from "./ui-types.js";
|
|
1
3
|
import { createPresentationRequestId } from "./ui.js";
|
|
2
4
|
import { UiError } from "./ui-types.js";
|
|
3
|
-
import { DISCOVERY_TIMEOUT, failure, readUiMessage, uiRequest, validId, validTarget, waitForPresentation } from "./ui-transport.js";
|
|
5
|
+
import { canonicalSurfaceRoute, DISCOVERY_TIMEOUT, failure, readUiMessage, uiRequest, validId, validTarget, waitForPresentation } from "./ui-transport.js";
|
|
4
6
|
/** Direct-child transport. Discovery carries no identity and never creates a fallback host. */
|
|
5
7
|
export class EmbeddedUiClient {
|
|
6
8
|
path;
|
|
9
|
+
composition;
|
|
10
|
+
localActions = new Map();
|
|
7
11
|
environment = { mode: "embedded", connection: "connecting" };
|
|
8
12
|
nonce = createPresentationRequestId();
|
|
9
13
|
listeners = new Set();
|
|
@@ -20,8 +24,19 @@ export class EmbeddedUiClient {
|
|
|
20
24
|
onFocus = () => { void this.recheck(); };
|
|
21
25
|
onKey = (event) => { if (this.active && !event.defaultPrevented && event.key === "Escape")
|
|
22
26
|
this.post({ type: "hosty:ui:dismiss" }); };
|
|
23
|
-
constructor(path) {
|
|
27
|
+
constructor(path, chrome = {}) {
|
|
24
28
|
this.path = path;
|
|
29
|
+
this.composition = new UiCompositionModel(path, "embedded", chrome);
|
|
30
|
+
this.composition.attachRouter();
|
|
31
|
+
this.composition.subscribeNavigation(() => {
|
|
32
|
+
if (this.getChrome().capabilities.includes(UI_COMPOSITION_CAPABILITY) && this.environment.connection === "connected") {
|
|
33
|
+
const route = this.composition.currentRoute();
|
|
34
|
+
if (this.state && canonicalSurfaceRoute(this.state.route) === canonicalSurfaceRoute(route))
|
|
35
|
+
this.reportSurfaceState(this.state);
|
|
36
|
+
else
|
|
37
|
+
this.reportRoute(route);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
25
40
|
window.addEventListener("message", this.onMessage);
|
|
26
41
|
window.addEventListener("focus", this.onFocus);
|
|
27
42
|
window.addEventListener("keydown", this.onKey);
|
|
@@ -30,12 +45,18 @@ export class EmbeddedUiClient {
|
|
|
30
45
|
this.setEnvironment("unsupported", "unsupported_nesting");
|
|
31
46
|
}
|
|
32
47
|
getEnvironment = () => this.environment;
|
|
48
|
+
getChrome = () => this.composition.getChrome();
|
|
49
|
+
subscribeChrome = (listener) => this.composition.subscribeChrome(listener);
|
|
50
|
+
getNavigation = () => this.composition.getNavigation();
|
|
51
|
+
subscribeNavigation = (listener) => this.composition.subscribeNavigation(listener);
|
|
33
52
|
setActive(active) {
|
|
34
53
|
if (this.active === active)
|
|
35
54
|
return;
|
|
36
55
|
if (!active)
|
|
37
56
|
this.post({ type: "hosty:ui:auth-required" });
|
|
38
57
|
this.active = active;
|
|
58
|
+
this.composition.resetNavigation();
|
|
59
|
+
this.localActions.clear();
|
|
39
60
|
this.channel = null;
|
|
40
61
|
this.verifying = null;
|
|
41
62
|
this.nonce = createPresentationRequestId();
|
|
@@ -72,11 +93,12 @@ export class EmbeddedUiClient {
|
|
|
72
93
|
if (this.disposed)
|
|
73
94
|
return;
|
|
74
95
|
this.environment = { mode: "embedded", connection, ...(code ? { code } : {}) };
|
|
96
|
+
this.composition.setEnvironment(this.environment);
|
|
75
97
|
this.emit({ type: "environment", value: this.environment });
|
|
76
98
|
}
|
|
77
99
|
probe() {
|
|
78
100
|
if (this.active && !this.disposed && window.parent === window.top && !this.channel)
|
|
79
|
-
window.parent.postMessage({ type: "hosty:ui:probe", version: 1, versions: [1], childNonce: this.nonce }, "*");
|
|
101
|
+
window.parent.postMessage({ type: "hosty:ui:probe", version: 1, versions: [1], capabilities: [UI_COMPOSITION_CAPABILITY], childNonce: this.nonce }, "*");
|
|
80
102
|
}
|
|
81
103
|
discover() {
|
|
82
104
|
clearTimeout(this.deadline);
|
|
@@ -154,7 +176,7 @@ export class EmbeddedUiClient {
|
|
|
154
176
|
if (previous && (previous.origin !== event.origin || previous.hostWindowId !== msg.hostWindowId || previous.generation !== msg.generation))
|
|
155
177
|
this.retirePending();
|
|
156
178
|
this.channel = { origin: event.origin, hostWindowId: msg.hostWindowId, generation: msg.generation, own };
|
|
157
|
-
this.post({ type: "hosty:ui:accept", coreOrigin: own.coreOrigin, userId: own.userId, appId: own.app.appId, installationId: own.app.installationId });
|
|
179
|
+
this.post({ type: "hosty:ui:accept", capabilities: [UI_COMPOSITION_CAPABILITY], coreOrigin: own.coreOrigin, userId: own.userId, appId: own.app.appId, installationId: own.app.installationId });
|
|
158
180
|
}
|
|
159
181
|
catch (error) {
|
|
160
182
|
if (this.disposed || !this.active || this.verifying !== key)
|
|
@@ -179,16 +201,29 @@ export class EmbeddedUiClient {
|
|
|
179
201
|
}
|
|
180
202
|
clearTimeout(this.deadline);
|
|
181
203
|
this.setEnvironment("connected");
|
|
204
|
+
if (Array.isArray(msg.capabilities) && msg.capabilities.includes(UI_COMPOSITION_CAPABILITY)) {
|
|
205
|
+
this.composition.setHostPolicy(channel.generation, msg.revision, msg.chrome, msg.rootDisabled === true, msg.endpoint);
|
|
206
|
+
}
|
|
207
|
+
else
|
|
208
|
+
this.composition.setLegacyHost();
|
|
209
|
+
void this.composition.refreshNavigation();
|
|
182
210
|
if (this.state)
|
|
183
211
|
this.reportSurfaceState(this.state);
|
|
184
212
|
return;
|
|
185
213
|
}
|
|
186
214
|
if (this.environment.connection !== "connected")
|
|
187
215
|
return;
|
|
188
|
-
if (msg.type === "hosty:ui:
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
216
|
+
if (msg.type === "hosty:ui:chrome" && this.getChrome().capabilities.includes(UI_COMPOSITION_CAPABILITY)) {
|
|
217
|
+
this.composition.setHostPolicy(channel.generation, msg.revision, msg.chrome, msg.rootDisabled === true, msg.endpoint);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if ((msg.type === "hosty:ui:result" || msg.type === "hosty:ui:action-result") && validId(msg.requestId) && validResult(msg.result, msg.requestId)) {
|
|
221
|
+
const result = msg.result, pending = this.pending.get(msg.requestId);
|
|
222
|
+
if (result.status === "accepted" && pending?.kind !== "action" || result.status === "opened" && pending?.kind === "action")
|
|
223
|
+
return;
|
|
224
|
+
pending?.resolve(result);
|
|
225
|
+
if (result.status !== "accepted")
|
|
226
|
+
this.emit({ type: "presentation", value: result });
|
|
192
227
|
}
|
|
193
228
|
}
|
|
194
229
|
async request(requestId, message, signal) {
|
|
@@ -199,7 +234,9 @@ export class EmbeddedUiClient {
|
|
|
199
234
|
if (signal?.aborted)
|
|
200
235
|
return { status: "uncertain", requestId, code: "presentation_unknown" };
|
|
201
236
|
const target = message.target;
|
|
202
|
-
const input =
|
|
237
|
+
const input = message.type === "hosty:ui:status" ? null : JSON.stringify([message.type, target?.panel ?? null, target?.route ?? null, message.reuse ?? "exact", message.entryKey ?? null]);
|
|
238
|
+
if (this.localActions.has(requestId))
|
|
239
|
+
return { status: "rejected", requestId, code: "request_conflict" };
|
|
203
240
|
const existing = this.pending.get(requestId);
|
|
204
241
|
if (existing) {
|
|
205
242
|
if (input !== null && existing.input !== null && input !== existing.input)
|
|
@@ -214,11 +251,11 @@ export class EmbeddedUiClient {
|
|
|
214
251
|
}
|
|
215
252
|
return this.wait(requestId, existing, signal);
|
|
216
253
|
}
|
|
217
|
-
if (this.pending.size >= 4)
|
|
254
|
+
if (this.pending.size + [...this.localActions.values()].filter(value => value.pending).length >= 4)
|
|
218
255
|
return { status: "blocked", requestId, code: "presentation_busy" };
|
|
219
256
|
let settle;
|
|
220
257
|
const operation = new Promise(resolve => { settle = resolve; });
|
|
221
|
-
const entry = { input, operation, waiters: 0, sent: false, resolve: result => {
|
|
258
|
+
const entry = { kind: message.type === "hosty:ui:picker" || message.type === "hosty:ui:navigate" ? "action" : "presentation", input, operation, waiters: 0, sent: false, resolve: result => {
|
|
222
259
|
if (this.pending.get(requestId) === entry)
|
|
223
260
|
this.pending.delete(requestId);
|
|
224
261
|
settle(result);
|
|
@@ -247,17 +284,86 @@ export class EmbeddedUiClient {
|
|
|
247
284
|
this.pending.delete(requestId);
|
|
248
285
|
});
|
|
249
286
|
}
|
|
250
|
-
openSurface = (target, options) =>
|
|
251
|
-
|
|
252
|
-
|
|
287
|
+
openSurface = (target, options) => {
|
|
288
|
+
if (!this.getChrome().tools.enabled)
|
|
289
|
+
return Promise.resolve({ status: "blocked", requestId: options.requestId, code: "tools_disabled" });
|
|
290
|
+
if (options.reuse !== undefined && !this.getChrome().capabilities.includes(UI_COMPOSITION_CAPABILITY))
|
|
291
|
+
return Promise.resolve({ status: "unsupported", requestId: options.requestId, code: "unsupported_composition" });
|
|
292
|
+
return validTarget(target) && options.placement === "side"
|
|
293
|
+
? this.request(options.requestId, { type: "hosty:ui:open", target, placement: "side", ...(options.reuse ? { reuse: options.reuse } : {}) }, options.signal)
|
|
294
|
+
: Promise.resolve({ status: "rejected", requestId: options.requestId, code: "invalid_presentation" });
|
|
295
|
+
};
|
|
253
296
|
getPresentation = (requestId, options) => this.request(requestId, { type: "hosty:ui:status" }, options?.signal);
|
|
297
|
+
openToolPicker = (options) => {
|
|
298
|
+
if (!this.getChrome().tools.enabled)
|
|
299
|
+
return Promise.resolve({ status: "blocked", requestId: options.requestId, code: "tools_disabled" });
|
|
300
|
+
if (!this.getChrome().capabilities.includes(UI_COMPOSITION_CAPABILITY))
|
|
301
|
+
return Promise.resolve({ status: "unsupported", requestId: options.requestId, code: "unsupported_composition" });
|
|
302
|
+
return this.request(options.requestId, { type: "hosty:ui:picker" }, options.signal);
|
|
303
|
+
};
|
|
304
|
+
navigate = (entryKey, options) => {
|
|
305
|
+
const id = options.requestId, existing = this.localActions.get(id);
|
|
306
|
+
if (!validId(id))
|
|
307
|
+
return Promise.resolve({ status: "rejected", requestId: id, code: "invalid_presentation" });
|
|
308
|
+
if (existing)
|
|
309
|
+
return existing.key === entryKey ? waitForPresentation(existing.result, id, options.signal) : Promise.resolve({ status: "rejected", requestId: id, code: "request_conflict" });
|
|
310
|
+
const entry = this.getNavigation().entries.find(e => e.key === entryKey);
|
|
311
|
+
if (!entry?.route)
|
|
312
|
+
return Promise.resolve({ status: "blocked", requestId: id, code: "destination_changed" });
|
|
313
|
+
if (!this.composition.isLocalEntry(entry)) {
|
|
314
|
+
if (!this.getChrome().capabilities.includes(UI_COMPOSITION_CAPABILITY))
|
|
315
|
+
return Promise.resolve({ status: "unsupported", requestId: id, code: "unsupported_composition" });
|
|
316
|
+
return this.request(id, { type: "hosty:ui:navigate", entryKey }, options.signal);
|
|
317
|
+
}
|
|
318
|
+
if (this.pending.has(id))
|
|
319
|
+
return Promise.resolve({ status: "rejected", requestId: id, code: "request_conflict" });
|
|
320
|
+
if (this.pending.size + [...this.localActions.values()].filter(value => value.pending).length >= 4)
|
|
321
|
+
return Promise.resolve({ status: "blocked", requestId: id, code: "presentation_busy" });
|
|
322
|
+
const nonce = this.nonce;
|
|
323
|
+
const operation = Promise.resolve().then(async () => {
|
|
324
|
+
try {
|
|
325
|
+
if (!this.active || this.disposed || nonce !== this.nonce)
|
|
326
|
+
throw new UiError("activity_required", "Sign in to navigate.");
|
|
327
|
+
if (options.signal?.aborted)
|
|
328
|
+
return { status: "uncertain", requestId: id, code: "presentation_unknown" };
|
|
329
|
+
await this.composition.navigateLocal(entry);
|
|
330
|
+
if (!this.active || this.disposed || nonce !== this.nonce)
|
|
331
|
+
throw new UiError("activity_required", "Sign in to navigate.");
|
|
332
|
+
return { status: "accepted", requestId: id };
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
return failure(id, error);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
const action = { key: entryKey, result: operation, pending: true };
|
|
339
|
+
this.localActions.set(id, action);
|
|
340
|
+
void operation.finally(() => {
|
|
341
|
+
action.pending = false;
|
|
342
|
+
for (const [key, value] of this.localActions) {
|
|
343
|
+
if (this.localActions.size <= 256)
|
|
344
|
+
break;
|
|
345
|
+
if (!value.pending)
|
|
346
|
+
this.localActions.delete(key);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
return waitForPresentation(operation, id, options.signal);
|
|
350
|
+
};
|
|
351
|
+
reportRoute(route) {
|
|
352
|
+
const entryKey = activeNavigationKey(this.getNavigation().entries, this.composition.currentEndpoint(), route);
|
|
353
|
+
this.post({ type: "hosty:ui:route", route, ...(entryKey ? { entryKey } : {}) });
|
|
354
|
+
}
|
|
254
355
|
reportSurfaceState = (state) => {
|
|
255
356
|
this.state = state;
|
|
256
|
-
if (this.environment.connection === "connected")
|
|
357
|
+
if (this.environment.connection === "connected") {
|
|
358
|
+
if (this.getChrome().capabilities.includes(UI_COMPOSITION_CAPABILITY))
|
|
359
|
+
this.reportRoute(state.route);
|
|
257
360
|
this.post({ type: "hosty:ui:surface-state", ...state });
|
|
361
|
+
}
|
|
258
362
|
};
|
|
259
363
|
dispose = () => {
|
|
260
364
|
this.disposed = true;
|
|
365
|
+
this.composition.dispose();
|
|
366
|
+
this.localActions.clear();
|
|
261
367
|
clearTimeout(this.deadline);
|
|
262
368
|
this.retries.forEach(clearTimeout);
|
|
263
369
|
window.removeEventListener("message", this.onMessage);
|
|
@@ -273,6 +379,6 @@ function validResult(value, id) {
|
|
|
273
379
|
if (!value || typeof value !== "object")
|
|
274
380
|
return false;
|
|
275
381
|
const v = value;
|
|
276
|
-
return v.requestId === id && (v.status === "opened" ? validTarget(v.target) && ["loading", "ready", "unreported", "auth-required"].includes(v.readiness)
|
|
277
|
-
: ["blocked", "unsupported", "rejected", "uncertain", "unknown"].includes(v.status) && "code" in v && typeof v.code === "string");
|
|
382
|
+
return v.requestId === id && (v.status === "accepted" || (v.status === "opened" ? validTarget(v.target) && ["loading", "ready", "unreported", "auth-required"].includes(v.readiness)
|
|
383
|
+
: ["blocked", "unsupported", "rejected", "uncertain", "unknown"].includes(v.status) && "code" in v && typeof v.code === "string"));
|
|
278
384
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type HostyAppFrameProps } from "./ui-frame.js";
|
|
2
|
+
import type { UiHost } from "./ui-host.js";
|
|
3
|
+
import type { PanelTarget } from "./ui-types.js";
|
|
4
|
+
export type UiViewport = {
|
|
5
|
+
element: HTMLElement;
|
|
6
|
+
drawer: boolean;
|
|
7
|
+
onAskAssistant?: HostyAppFrameProps["onAskAssistant"];
|
|
8
|
+
onAuthRequired?: HostyAppFrameProps["onAuthRequired"];
|
|
9
|
+
outbound?: {
|
|
10
|
+
target: PanelTarget;
|
|
11
|
+
presentationId?: string;
|
|
12
|
+
message: unknown;
|
|
13
|
+
nonce: number;
|
|
14
|
+
} | null;
|
|
15
|
+
};
|
|
16
|
+
/** One stable React frame tree for every dock/window rectangle in this document. */
|
|
17
|
+
export declare class UiFrameLayer {
|
|
18
|
+
root: HTMLDivElement | null;
|
|
19
|
+
readonly containers: Map<string, HTMLDivElement>;
|
|
20
|
+
ensure(id: string): boolean;
|
|
21
|
+
release(id: string): void;
|
|
22
|
+
readonly viewports: Map<string, UiViewport>;
|
|
23
|
+
private revision;
|
|
24
|
+
private readonly listeners;
|
|
25
|
+
getSnapshot: () => number;
|
|
26
|
+
subscribe: (listener: () => void) => () => void;
|
|
27
|
+
update: () => void;
|
|
28
|
+
register(id: string, viewport: UiViewport): () => void;
|
|
29
|
+
}
|
|
30
|
+
export declare const UiFrameLayerContext: import("react").Context<UiFrameLayer | null>;
|
|
31
|
+
export declare function presentationElement(id: string): HTMLElement | null;
|
|
32
|
+
export declare function HostyPresentationLayer({ layer, host, active }: {
|
|
33
|
+
layer: UiFrameLayer;
|
|
34
|
+
host: UiHost;
|
|
35
|
+
active: boolean;
|
|
36
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, useEffect, useLayoutEffect, useSyncExternalStore } from "react";
|
|
4
|
+
import { HostyAppFrame } from "./ui-frame.js";
|
|
5
|
+
import { createPortal } from "react-dom";
|
|
6
|
+
import { panelKey } from "./ui-types.js";
|
|
7
|
+
/** One stable React frame tree for every dock/window rectangle in this document. */
|
|
8
|
+
export class UiFrameLayer {
|
|
9
|
+
root = null;
|
|
10
|
+
containers = new Map();
|
|
11
|
+
ensure(id) {
|
|
12
|
+
if (!this.root?.isConnected)
|
|
13
|
+
return false;
|
|
14
|
+
if (!this.containers.has(id)) {
|
|
15
|
+
const element = document.createElement("div");
|
|
16
|
+
element.dataset.hostyPresentation = id;
|
|
17
|
+
element.id = `hosty-presentation-${id}`;
|
|
18
|
+
element.className = "hosty-panel-frame";
|
|
19
|
+
element.hidden = true;
|
|
20
|
+
element.inert = true;
|
|
21
|
+
element.style.display = "none";
|
|
22
|
+
this.root.append(element);
|
|
23
|
+
this.containers.set(id, element);
|
|
24
|
+
this.update();
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
release(id) { this.containers.get(id)?.remove(); this.containers.delete(id); this.update(); }
|
|
29
|
+
viewports = new Map();
|
|
30
|
+
revision = 0;
|
|
31
|
+
listeners = new Set();
|
|
32
|
+
getSnapshot = () => this.revision;
|
|
33
|
+
subscribe = (listener) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
|
|
34
|
+
update = () => { this.revision++; for (const listener of this.listeners)
|
|
35
|
+
listener(); };
|
|
36
|
+
register(id, viewport) {
|
|
37
|
+
this.viewports.set(id, viewport);
|
|
38
|
+
this.update();
|
|
39
|
+
const observer = new ResizeObserver(this.update);
|
|
40
|
+
observer.observe(viewport.element);
|
|
41
|
+
return () => { observer.disconnect(); if (this.viewports.get(id) === viewport)
|
|
42
|
+
this.viewports.delete(id); this.update(); };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export const UiFrameLayerContext = createContext(null);
|
|
46
|
+
export function presentationElement(id) {
|
|
47
|
+
return document.querySelector(`[data-hosty-presentation="${id}"]`);
|
|
48
|
+
}
|
|
49
|
+
export function HostyPresentationLayer({ layer, host, active }) {
|
|
50
|
+
const state = useSyncExternalStore(host.subscribeSnapshot, host.getSnapshot, host.getSnapshot);
|
|
51
|
+
useSyncExternalStore(layer.subscribe, layer.getSnapshot, layer.getSnapshot);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
window.addEventListener("resize", layer.update);
|
|
54
|
+
window.addEventListener("scroll", layer.update, true);
|
|
55
|
+
return () => { window.removeEventListener("resize", layer.update); window.removeEventListener("scroll", layer.update, true); };
|
|
56
|
+
}, [layer]);
|
|
57
|
+
useLayoutEffect(() => {
|
|
58
|
+
for (const [id, element] of layer.containers) {
|
|
59
|
+
const tool = state.tools.find(t => t.presentationId === id);
|
|
60
|
+
if (!tool) {
|
|
61
|
+
layer.release(id);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const viewport = layer.viewports.get(tool.workspaceId), workspace = state.workspaces?.find(w => w.workspaceId === tool.workspaceId);
|
|
65
|
+
const visible = !!(active && viewport && workspace?.expanded && workspace.selectedId === id);
|
|
66
|
+
const rect = viewport?.element.getBoundingClientRect();
|
|
67
|
+
element.hidden = !visible;
|
|
68
|
+
element.inert = !visible;
|
|
69
|
+
Object.assign(element.style, { position: "fixed", left: `${rect?.left ?? 0}px`, top: `${rect?.top ?? 0}px`,
|
|
70
|
+
width: `${rect?.width ?? 0}px`, height: `${rect?.height ?? 0}px`, zIndex: viewport?.drawer ? "103" : "10", display: visible ? "block" : "none" });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
return _jsxs(_Fragment, { children: [_jsx("div", { ref: element => { layer.root = element; }, className: "hosty-presentation-layer" }), state.tools.map(tool => {
|
|
74
|
+
const viewport = layer.viewports.get(tool.workspaceId), workspace = state.workspaces?.find(w => w.workspaceId === tool.workspaceId);
|
|
75
|
+
const surface = tool.resolved.surface, container = layer.containers.get(tool.presentationId);
|
|
76
|
+
if (!container)
|
|
77
|
+
return null;
|
|
78
|
+
const focusControls = (last) => {
|
|
79
|
+
const controls = Array.from(viewport?.element.closest("aside")?.querySelectorAll('button,a[href],select') ?? [])
|
|
80
|
+
.filter(element => !element.disabled);
|
|
81
|
+
(last ? controls.at(-1) : controls[0])?.focus();
|
|
82
|
+
};
|
|
83
|
+
return createPortal(_jsxs(_Fragment, { children: [viewport?.drawer && _jsx("span", { tabIndex: 0, "aria-hidden": "true", className: "hosty-panel-focus-guard", onFocus: () => focusControls(true) }), surface ? _jsx(HostyAppFrame, { surface: { ...surface, panelId: tool.target.panel.panelId }, view: { viewId: `tool-${tool.presentationId}`, workspaceId: tool.workspaceId, presentationId: tool.presentationId, chrome: { navigation: "hide", tools: { launcher: "hide" } } }, title: state.catalog.find(p => panelKey(p.panel) === tool.key)?.label ?? "Tool", standaloneHref: tool.resolved.standaloneHref, onAskAssistant: viewport?.onAskAssistant, onAuthRequired: viewport?.onAuthRequired, onAttention: count => host.setAttention(tool.presentationId, count), outbound: viewport?.outbound && panelKey(viewport.outbound.target.panel) === tool.key
|
|
84
|
+
&& (viewport.outbound.presentationId ? viewport.outbound.presentationId === tool.presentationId
|
|
85
|
+
: workspace?.selectedId === tool.presentationId && viewport.outbound.target.route === tool.currentRoute) ? viewport.outbound : null })
|
|
86
|
+
: _jsxs("div", { className: "hosty-panel-notice", role: "status", children: ["This tool is unavailable (", tool.resolved.state, ")."] }), viewport?.drawer && _jsx("span", { tabIndex: 0, "aria-hidden": "true", className: "hosty-panel-focus-guard", onFocus: () => focusControls(false) })] }), container, tool.presentationId);
|
|
87
|
+
})] });
|
|
88
|
+
}
|