@hosty-sdk/app 0.26.0 → 0.27.22
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 +147 -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 +13 -3
- package/dist/ui-react.js +127 -50
- 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
package/dist/ui-host.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
|
+
import { UiCompositionModel, validChromeOverrides, withChromeHints } from "./ui-composition.js";
|
|
2
|
+
import { DEFAULT_WORKSPACE, ROOT_VIEW, workspace } from "./ui-workspaces.js";
|
|
3
|
+
import { UI_COMPOSITION_CAPABILITY } from "./ui-types.js";
|
|
1
4
|
import { createPresentationRequestId } from "./ui.js";
|
|
2
5
|
import { UiError, panelKey, routeWithin } from "./ui-types.js";
|
|
3
6
|
import { canonicalSurfaceRoute, DISCOVERY_TIMEOUT, failure, readUiMessage, uiPath, uiRequest, validId, validTarget, waitForPresentation } from "./ui-transport.js";
|
|
7
|
+
function immutable(value) {
|
|
8
|
+
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
|
9
|
+
for (const child of Object.values(value))
|
|
10
|
+
immutable(child);
|
|
11
|
+
Object.freeze(value);
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
4
15
|
let documentHost = null;
|
|
5
16
|
/** One top-level composition owner. All Core traffic goes through this app's authenticated adapter. */
|
|
6
17
|
export function createUiHost(options = {}) {
|
|
@@ -8,7 +19,7 @@ export function createUiHost(options = {}) {
|
|
|
8
19
|
throw new Error("A panel host belongs to a top-level browser document.");
|
|
9
20
|
if (documentHost)
|
|
10
21
|
throw new Error("A Hosty panel host already owns this document.");
|
|
11
|
-
const host = new UiHost(uiPath(options.uiPath), options.active);
|
|
22
|
+
const host = new UiHost(uiPath(options.uiPath), options.active, options.chrome);
|
|
12
23
|
documentHost = host;
|
|
13
24
|
return host;
|
|
14
25
|
}
|
|
@@ -21,19 +32,35 @@ export class UiHost {
|
|
|
21
32
|
listeners = new Set();
|
|
22
33
|
events = new Set();
|
|
23
34
|
frames = new Set();
|
|
35
|
+
viewChrome = new Map();
|
|
24
36
|
ledger = new Map();
|
|
25
37
|
toolQueues = new Map();
|
|
26
|
-
|
|
38
|
+
moves = new Map();
|
|
39
|
+
rendererWork = new Map();
|
|
40
|
+
composition;
|
|
41
|
+
workspaces = new Map([[DEFAULT_WORKSPACE, workspace(DEFAULT_WORKSPACE)]]);
|
|
42
|
+
placement;
|
|
43
|
+
actions = new Map();
|
|
44
|
+
focusSequence = 0;
|
|
27
45
|
activityGeneration = 0;
|
|
28
46
|
activityReady;
|
|
29
47
|
disposed = false;
|
|
30
|
-
workspace = null;
|
|
31
48
|
refreshing = null;
|
|
32
49
|
onMessage = (event) => { void this.receive(event); };
|
|
33
50
|
onFocus = () => { void this.refresh(); };
|
|
34
|
-
constructor(path, active = true) {
|
|
51
|
+
constructor(path, active = true, chrome = {}) {
|
|
35
52
|
this.path = path;
|
|
36
53
|
this.active = active;
|
|
54
|
+
this.composition = new UiCompositionModel(path, "standalone", chrome);
|
|
55
|
+
this.composition.attachRouter();
|
|
56
|
+
this.composition.subscribeChrome(() => {
|
|
57
|
+
if (!this.getChrome().tools.enabled)
|
|
58
|
+
for (const state of this.workspaces.values())
|
|
59
|
+
this.collapseWorkspace(state.workspaceId);
|
|
60
|
+
for (const frame of this.frames)
|
|
61
|
+
if (frame.connected && frame.composition)
|
|
62
|
+
this.publishPolicy(frame);
|
|
63
|
+
});
|
|
37
64
|
this.activityReady = active;
|
|
38
65
|
window.addEventListener("message", this.onMessage);
|
|
39
66
|
window.addEventListener("focus", this.onFocus);
|
|
@@ -43,13 +70,30 @@ export class UiHost {
|
|
|
43
70
|
this.setEnvironment("blocked", "activity_required");
|
|
44
71
|
}
|
|
45
72
|
getEnvironment = () => this.environment;
|
|
73
|
+
getChrome = () => this.composition.getChrome();
|
|
74
|
+
subscribeChrome = (listener) => this.composition.subscribeChrome(listener);
|
|
75
|
+
getNavigation = () => this.composition.getNavigation();
|
|
76
|
+
subscribeNavigation = (listener) => this.composition.subscribeNavigation(listener);
|
|
46
77
|
setActive(active) {
|
|
47
78
|
if (this.disposed || this.active === active)
|
|
48
79
|
return;
|
|
80
|
+
for (const move of this.moves.values())
|
|
81
|
+
move.abort();
|
|
49
82
|
this.active = active;
|
|
50
83
|
this.activityReady = false;
|
|
51
84
|
this.activityGeneration++;
|
|
52
|
-
this.
|
|
85
|
+
for (const state of this.workspaces.values())
|
|
86
|
+
state.generation++;
|
|
87
|
+
this.invalidateRendererWork();
|
|
88
|
+
if (!active) {
|
|
89
|
+
for (const state of this.workspaces.values()) {
|
|
90
|
+
state.expanded = false;
|
|
91
|
+
state.renderer?.collapse();
|
|
92
|
+
}
|
|
93
|
+
this.publishWorkspaces();
|
|
94
|
+
}
|
|
95
|
+
this.composition.resetNavigation();
|
|
96
|
+
this.actions.clear();
|
|
53
97
|
this.refreshing = null;
|
|
54
98
|
this.toolQueues.clear();
|
|
55
99
|
for (const [key, entry] of this.ledger)
|
|
@@ -71,11 +115,205 @@ export class UiHost {
|
|
|
71
115
|
throw new UiError("activity_required", "Renew this app's activity before opening panels.");
|
|
72
116
|
}
|
|
73
117
|
claimWorkspace = (owner) => {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
118
|
+
const state = this.workspaces.get(DEFAULT_WORKSPACE);
|
|
119
|
+
if (state.renderer || state.owner && state.owner !== owner)
|
|
120
|
+
throw new Error("Exactly one renderer may occupy a workspace.");
|
|
121
|
+
state.owner = owner;
|
|
122
|
+
return () => { if (state.owner === owner)
|
|
123
|
+
state.owner = undefined; };
|
|
124
|
+
};
|
|
125
|
+
registerWorkspace = ({ workspaceId, renderer }) => {
|
|
126
|
+
if (!workspaceId || workspaceId.length > 256)
|
|
127
|
+
throw new Error("Invalid workspace ID.");
|
|
128
|
+
const state = this.workspaces.get(workspaceId) ?? workspace(workspaceId);
|
|
129
|
+
if (state.renderer && state.renderer !== renderer)
|
|
130
|
+
throw new Error("A workspace already has a renderer.");
|
|
131
|
+
state.renderer = renderer;
|
|
132
|
+
this.workspaces.set(workspaceId, state);
|
|
133
|
+
this.publishWorkspaces();
|
|
134
|
+
return () => {
|
|
135
|
+
if (!this.disposed && this.snapshot.tools.some(t => t.workspaceId === workspaceId))
|
|
136
|
+
throw new Error("Close or move presentations before disposing their workspace.");
|
|
137
|
+
if (state.renderer === renderer) {
|
|
138
|
+
state.generation++;
|
|
139
|
+
this.invalidateRendererWork();
|
|
140
|
+
state.renderer = undefined;
|
|
141
|
+
this.publishWorkspaces();
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
publishWorkspaces() {
|
|
146
|
+
const state = this.workspaces.get(DEFAULT_WORKSPACE);
|
|
147
|
+
const selected = this.snapshot.tools.find(t => t.presentationId === state.selectedId);
|
|
148
|
+
this.update({ selected: selected?.target.panel ?? null, selectedPresentationId: state.selectedId,
|
|
149
|
+
expanded: state.expanded, width: state.width,
|
|
150
|
+
workspaces: [...this.workspaces.values()].map(({ workspaceId, selectedId, expanded, width }) => ({ workspaceId, selectedId, expanded, width })) });
|
|
151
|
+
}
|
|
152
|
+
setPlacementResolver = (resolver) => { this.placement = resolver; };
|
|
153
|
+
getViewChrome = (viewId) => {
|
|
154
|
+
const chrome = this.viewChrome.get(viewId) ?? [...this.frames].find(frame => frame.view.viewId === viewId)?.chrome;
|
|
155
|
+
return chrome ? structuredClone(chrome) : undefined;
|
|
156
|
+
};
|
|
157
|
+
setViewChrome = (viewId, overrides) => {
|
|
158
|
+
if (!validChromeOverrides(overrides))
|
|
159
|
+
throw new Error("Invalid chrome policy.");
|
|
160
|
+
if (viewId === ROOT_VIEW) {
|
|
161
|
+
this.composition.setHostPolicy("root", this.getChrome().hostRevision + 1, overrides, false);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const frame = [...this.frames].find(f => f.view.viewId === viewId);
|
|
165
|
+
if (!frame)
|
|
166
|
+
throw new Error("Unknown view.");
|
|
167
|
+
frame.chrome = structuredClone(overrides);
|
|
168
|
+
this.viewChrome.set(viewId, frame.chrome);
|
|
169
|
+
this.publishPolicy(frame);
|
|
170
|
+
};
|
|
171
|
+
publishPolicy(frame) {
|
|
172
|
+
if (!frame.composition || !frame.connected)
|
|
173
|
+
return;
|
|
174
|
+
this.post(frame, { type: "hosty:ui:chrome", revision: ++frame.policyRevision, chrome: frame.chrome,
|
|
175
|
+
rootDisabled: !this.getChrome().tools.enabled, endpoint: frame.surface.endpoint });
|
|
176
|
+
}
|
|
177
|
+
requireTools(frame) {
|
|
178
|
+
if (!this.getChrome().tools.enabled || frame?.chrome.tools?.enabled === false)
|
|
179
|
+
throw new UiError("tools_disabled", "Tools are disabled in this view.");
|
|
180
|
+
}
|
|
181
|
+
focusPresentation = (id) => {
|
|
182
|
+
const tool = this.snapshot.tools.find(t => t.presentationId === id);
|
|
183
|
+
if (!tool)
|
|
184
|
+
return false;
|
|
185
|
+
this.requireTools();
|
|
186
|
+
const state = this.workspaces.get(tool.workspaceId);
|
|
187
|
+
state.generation++;
|
|
188
|
+
state.selectedId = id;
|
|
189
|
+
state.expanded = true;
|
|
190
|
+
this.update({ tools: this.snapshot.tools.map(t => t === tool ? { ...t, focusedAt: ++this.focusSequence } : t) });
|
|
191
|
+
this.publishWorkspaces();
|
|
192
|
+
state.renderer?.focus(id);
|
|
193
|
+
return true;
|
|
194
|
+
};
|
|
195
|
+
collapseWorkspace = (id) => {
|
|
196
|
+
const state = this.workspaces.get(id);
|
|
197
|
+
if (!state)
|
|
198
|
+
return;
|
|
199
|
+
state.generation++;
|
|
200
|
+
state.expanded = false;
|
|
201
|
+
state.renderer?.collapse();
|
|
202
|
+
this.publishWorkspaces();
|
|
203
|
+
};
|
|
204
|
+
closePresentation = (id) => { this.releasePresentation(id); };
|
|
205
|
+
releasePresentation(id, supersede = true) {
|
|
206
|
+
const tool = this.snapshot.tools.find(t => t.presentationId === id);
|
|
207
|
+
if (!tool)
|
|
208
|
+
return;
|
|
209
|
+
this.moves.get(id)?.abort();
|
|
210
|
+
const state = this.workspaces.get(tool.workspaceId);
|
|
211
|
+
for (const frame of this.frames)
|
|
212
|
+
if (frame.view.presentationId === id)
|
|
213
|
+
frame.cleanup();
|
|
214
|
+
if (supersede)
|
|
215
|
+
state.generation++;
|
|
216
|
+
state.renderer?.release(id);
|
|
217
|
+
this.update({ tools: this.snapshot.tools.filter(t => t !== tool) });
|
|
218
|
+
if (state.selectedId === id) {
|
|
219
|
+
state.selectedId = null;
|
|
220
|
+
state.expanded = false;
|
|
221
|
+
}
|
|
222
|
+
this.publishWorkspaces();
|
|
223
|
+
}
|
|
224
|
+
resetPresentations() {
|
|
225
|
+
for (const tool of this.snapshot.tools)
|
|
226
|
+
this.closePresentation(tool.presentationId);
|
|
227
|
+
for (const state of this.workspaces.values()) {
|
|
228
|
+
state.selectedId = null;
|
|
229
|
+
state.expanded = false;
|
|
230
|
+
state.generation++;
|
|
231
|
+
state.renderer?.collapse();
|
|
232
|
+
}
|
|
233
|
+
this.publishWorkspaces();
|
|
234
|
+
}
|
|
235
|
+
movePresentation = async (id, workspaceId) => {
|
|
236
|
+
this.requireTools();
|
|
237
|
+
const tool = this.snapshot.tools.find(t => t.presentationId === id), next = this.workspaces.get(workspaceId);
|
|
238
|
+
if (!tool || !next?.renderer || this.moves.has(id))
|
|
239
|
+
return false;
|
|
240
|
+
const previous = this.workspaces.get(tool.workspaceId);
|
|
241
|
+
if (previous === next)
|
|
242
|
+
return true;
|
|
243
|
+
if (!previous.renderer?.frameLayer || previous.renderer.frameLayer !== next.renderer.frameLayer)
|
|
244
|
+
return false;
|
|
245
|
+
const activity = this.activityGeneration, generation = ++next.generation, renderer = next.renderer;
|
|
246
|
+
this.invalidateRendererWork();
|
|
247
|
+
const moved = { ...tool, workspaceId }, controller = new AbortController();
|
|
248
|
+
const retainedSource = () => this.snapshot.tools.find(t => t.presentationId === id && t.workspaceId === tool.workspaceId && t.resolved.surface);
|
|
249
|
+
this.moves.set(id, controller);
|
|
250
|
+
this.rendererWork.set(controller, { check: () => {
|
|
251
|
+
if (!retainedSource() || generation !== next.generation || renderer !== next.renderer)
|
|
252
|
+
throw new Error("Movement was superseded.");
|
|
253
|
+
} });
|
|
254
|
+
const stop = () => { this.rendererWork.delete(controller); };
|
|
255
|
+
try {
|
|
256
|
+
if (await renderer.present(moved, controller.signal) !== "accepted")
|
|
257
|
+
throw new Error("Movement was refused.");
|
|
258
|
+
this.checkActivity(activity);
|
|
259
|
+
this.requireTools();
|
|
260
|
+
if (controller.signal.aborted || !retainedSource() || generation !== next.generation || renderer !== next.renderer)
|
|
261
|
+
throw new Error("Movement was superseded.");
|
|
262
|
+
stop();
|
|
263
|
+
previous.generation++;
|
|
264
|
+
this.update({ tools: this.snapshot.tools.map(t => t.presentationId === id ? { ...t, workspaceId } : t) });
|
|
265
|
+
if (previous.selectedId === id) {
|
|
266
|
+
previous.selectedId = null;
|
|
267
|
+
previous.expanded = false;
|
|
268
|
+
previous.renderer?.collapse();
|
|
269
|
+
}
|
|
270
|
+
for (const frame of this.frames)
|
|
271
|
+
if (frame.view.presentationId === id)
|
|
272
|
+
frame.view = { ...frame.view, workspaceId };
|
|
273
|
+
this.focusPresentation(id);
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
stop();
|
|
278
|
+
controller.abort();
|
|
279
|
+
const retained = this.snapshot.tools.find(t => t.presentationId === id);
|
|
280
|
+
if (!retained) {
|
|
281
|
+
renderer.release(id);
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
const state = this.workspaces.get(retained.workspaceId), restore = state.renderer;
|
|
285
|
+
const restorationGeneration = state.generation;
|
|
286
|
+
const rollback = new AbortController();
|
|
287
|
+
this.moves.set(id, rollback);
|
|
288
|
+
this.rendererWork.set(rollback, { check: () => {
|
|
289
|
+
if (rollback.signal.aborted || restorationGeneration !== state.generation || restore !== state.renderer
|
|
290
|
+
|| !this.snapshot.tools.some(t => t.presentationId === id && t.workspaceId === retained.workspaceId))
|
|
291
|
+
throw new Error("Restoration was superseded.");
|
|
292
|
+
} });
|
|
293
|
+
try {
|
|
294
|
+
if (!restore || await restore.present(retained, rollback.signal) !== "accepted")
|
|
295
|
+
throw new Error("Restoration was refused.");
|
|
296
|
+
if (!this.snapshot.tools.some(t => t.presentationId === id)) {
|
|
297
|
+
restore.release(id);
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
if (this.active && this.getChrome().tools.enabled && state.expanded && state.selectedId)
|
|
301
|
+
restore.focus(state.selectedId);
|
|
302
|
+
else
|
|
303
|
+
restore.collapse();
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
this.releasePresentation(id, restorationGeneration === state.generation);
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
this.rendererWork.delete(rollback);
|
|
310
|
+
}
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
stop();
|
|
315
|
+
this.moves.delete(id);
|
|
316
|
+
}
|
|
79
317
|
};
|
|
80
318
|
getSnapshot = () => this.snapshot;
|
|
81
319
|
subscribeSnapshot = (listener) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
|
|
@@ -90,12 +328,42 @@ export class UiHost {
|
|
|
90
328
|
update(patch) {
|
|
91
329
|
if (this.disposed)
|
|
92
330
|
return;
|
|
93
|
-
this.snapshot = { ...this.snapshot, ...patch };
|
|
331
|
+
this.snapshot = immutable({ ...this.snapshot, ...patch });
|
|
332
|
+
this.invalidateRendererWork();
|
|
94
333
|
for (const listener of this.listeners)
|
|
95
334
|
listener();
|
|
96
335
|
}
|
|
336
|
+
invalidateRendererWork() {
|
|
337
|
+
for (const [controller, work] of this.rendererWork)
|
|
338
|
+
if (!controller.signal.aborted) {
|
|
339
|
+
try {
|
|
340
|
+
work.check();
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
controller.abort();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async runRenderer(check, perform, frame) {
|
|
348
|
+
check();
|
|
349
|
+
const controller = new AbortController();
|
|
350
|
+
this.rendererWork.set(controller, { check, frame });
|
|
351
|
+
try {
|
|
352
|
+
const result = await perform(controller.signal);
|
|
353
|
+
check();
|
|
354
|
+
return result;
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
check();
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
this.rendererWork.delete(controller);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
97
364
|
setEnvironment(connection, code) {
|
|
98
365
|
this.environment = { mode: "standalone", connection, ...(code ? { code } : {}) };
|
|
366
|
+
this.composition.setEnvironment(this.environment);
|
|
99
367
|
this.emit({ type: "environment", value: this.environment });
|
|
100
368
|
}
|
|
101
369
|
async bootstrap(generation = this.activityGeneration, renewing = false) {
|
|
@@ -107,7 +375,10 @@ export class UiHost {
|
|
|
107
375
|
const previous = this.snapshot.bootstrap;
|
|
108
376
|
if (previous && (previous.userId !== value.userId || previous.coreOrigin !== value.coreOrigin || previous.app.installationId !== value.app.installationId)) {
|
|
109
377
|
this.ledger.clear();
|
|
378
|
+
this.actions.clear();
|
|
110
379
|
this.toolQueues.clear();
|
|
380
|
+
this.composition.resetNavigation();
|
|
381
|
+
this.resetPresentations();
|
|
111
382
|
for (const frame of this.frames) {
|
|
112
383
|
frame.connected = false;
|
|
113
384
|
frame.actor = null;
|
|
@@ -131,7 +402,10 @@ export class UiHost {
|
|
|
131
402
|
try {
|
|
132
403
|
const own = await this.bootstrap(generation, true);
|
|
133
404
|
this.checkActivity(generation, true);
|
|
405
|
+
void this.composition.refreshNavigation();
|
|
134
406
|
if (!own.canHost) {
|
|
407
|
+
this.resetPresentations();
|
|
408
|
+
this.activityReady = true;
|
|
135
409
|
this.setEnvironment("unsupported", "app_permission_required");
|
|
136
410
|
this.update({ catalog: [], tools: [], selected: null, expanded: false });
|
|
137
411
|
return;
|
|
@@ -146,9 +420,9 @@ export class UiHost {
|
|
|
146
420
|
// Catalog disappearance/replacement is access loss, while temporary readiness loss retains drafts.
|
|
147
421
|
const allowed = new Map(catalog.panels.map(p => [panelKey(p.panel), p]));
|
|
148
422
|
this.update({ tools: this.snapshot.tools.map(tool => {
|
|
149
|
-
const entry = allowed.get(tool.
|
|
423
|
+
const entry = allowed.get(panelKey(tool.target.panel));
|
|
150
424
|
const invalid = !entry || !["running", "degraded", "unavailable", "starting", "stopping"].includes(entry.state);
|
|
151
|
-
const surface = tool.resolved.surface;
|
|
425
|
+
const surface = tool.resolved.surface && tool.panelSurface;
|
|
152
426
|
const changed = entry && surface && JSON.stringify(entry.framePolicy) !== JSON.stringify(surface.framePolicy);
|
|
153
427
|
const moved = entry && surface && (entry.origin !== surface.origin || entry.endpoint !== surface.endpoint
|
|
154
428
|
|| !routeWithin(surface.route, entry.route));
|
|
@@ -172,51 +446,119 @@ export class UiHost {
|
|
|
172
446
|
return operation;
|
|
173
447
|
};
|
|
174
448
|
openSurface = (target, options) => waitForPresentation(this.open(target, options, "local"), options.requestId, options.signal);
|
|
175
|
-
open(target, options, source, frame) {
|
|
449
|
+
open(target, options, source, frame, sourceWorkspace) {
|
|
176
450
|
const id = options.requestId;
|
|
177
451
|
if (!this.active || !this.activityReady || this.disposed)
|
|
178
452
|
return Promise.resolve({ status: "blocked", requestId: id, code: "activity_required" });
|
|
179
|
-
|
|
180
|
-
if (!validId(id) || !validTarget(target) || options.placement !== "side")
|
|
453
|
+
if (!validId(id) || !validTarget(target) || options.placement !== "side" || options.reuse !== undefined && !["exact", "new"].includes(options.reuse))
|
|
181
454
|
return Promise.resolve({ status: "rejected", requestId: id, code: "invalid_presentation" });
|
|
182
|
-
|
|
455
|
+
target = { panel: { appId: target.panel.appId, installationId: target.panel.installationId, panelId: target.panel.panelId },
|
|
456
|
+
...(target.route === undefined ? {} : { route: canonicalSurfaceRoute(target.route) }) };
|
|
457
|
+
const generation = this.activityGeneration, key = `${source}:${id}`;
|
|
458
|
+
const input = JSON.stringify(["open", target.panel, target.route ?? null, options.reuse ?? "exact"]);
|
|
183
459
|
const existing = this.ledger.get(key);
|
|
184
|
-
if (existing && existing.input !== input)
|
|
460
|
+
if (this.actions.has(key) || existing && existing.input !== input)
|
|
185
461
|
return Promise.resolve({ status: "rejected", requestId: id, code: "request_conflict" });
|
|
186
462
|
if (existing?.pending)
|
|
187
463
|
return existing.pending;
|
|
188
|
-
if (
|
|
464
|
+
if (existing?.result?.status === "opened")
|
|
465
|
+
return this.status(id, source, frame).then(result => {
|
|
466
|
+
if (result.status === "opened" && result.presentationId && this.snapshot.tools.some(t => t.presentationId === result.presentationId)) {
|
|
467
|
+
try {
|
|
468
|
+
this.requireTools(frame);
|
|
469
|
+
this.focusPresentation(result.presentationId);
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
return failure(id, error);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return result;
|
|
476
|
+
});
|
|
477
|
+
if (this.pendingCount(source) >= 4)
|
|
189
478
|
return Promise.resolve({ status: "blocked", requestId: id, code: "presentation_busy" });
|
|
479
|
+
let destination;
|
|
480
|
+
try {
|
|
481
|
+
this.requireTools(frame);
|
|
482
|
+
destination = this.placement?.({ source: frame?.view ?? { viewId: ROOT_VIEW, workspaceId: sourceWorkspace ?? DEFAULT_WORKSPACE },
|
|
483
|
+
target: structuredClone(target), presentations: structuredClone(this.snapshot.tools) }) ?? { workspaceId: frame?.view.workspaceId ?? sourceWorkspace ?? DEFAULT_WORKSPACE };
|
|
484
|
+
if (!this.workspaces.get(destination.workspaceId)?.renderer)
|
|
485
|
+
throw new UiError("presentation_unavailable", "No renderer is available for this workspace.");
|
|
486
|
+
if (options.reuse === "new" && destination.presentationId)
|
|
487
|
+
throw new UiError("invalid_presentation", "A new presentation cannot reuse an instance.");
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
return Promise.resolve(failure(id, error));
|
|
491
|
+
}
|
|
492
|
+
const state = this.workspaces.get(destination.workspaceId), selection = ++state.generation;
|
|
493
|
+
this.invalidateRendererWork();
|
|
190
494
|
const entry = { input, frame };
|
|
191
|
-
const
|
|
192
|
-
const
|
|
193
|
-
const toolKey = panelKey(target.panel), previous = this.toolQueues.get(toolKey);
|
|
495
|
+
const currentSource = () => !frame || frame.live && frame.connected && source === `${frame.surface.appId}:${frame.generation}:${frame.nonce}`;
|
|
496
|
+
const queueKey = `${destination.workspaceId}:${panelKey(target.panel)}`, previous = this.toolQueues.get(queueKey);
|
|
194
497
|
const operation = (async () => {
|
|
195
498
|
if (previous)
|
|
196
499
|
await previous;
|
|
197
500
|
try {
|
|
198
501
|
const own = await this.bootstrap(generation);
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
502
|
+
const check = () => {
|
|
503
|
+
this.checkActivity(generation);
|
|
504
|
+
this.requireTools(frame);
|
|
505
|
+
if (!own.canHost)
|
|
506
|
+
throw new UiError("app_permission_required", "Allow this app to host panels.");
|
|
507
|
+
if (!currentSource() || frame && frame.actor !== own.userId || this.snapshot.bootstrap?.userId !== own.userId
|
|
508
|
+
|| this.snapshot.bootstrap?.coreOrigin !== own.coreOrigin || this.snapshot.bootstrap?.app.installationId !== own.app.installationId)
|
|
509
|
+
throw new UiError("actor_mismatch", "Reconnect using the same account.");
|
|
510
|
+
if (selection !== state.generation)
|
|
511
|
+
throw new UiError("presentation_superseded", "A newer action replaced this opening.");
|
|
512
|
+
};
|
|
513
|
+
check();
|
|
204
514
|
const resolved = await uiRequest(this.path, "panels/resolve", target);
|
|
205
|
-
|
|
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.");
|
|
515
|
+
check();
|
|
209
516
|
if (!resolved.available || !resolved.surface)
|
|
210
517
|
throw new UiError(resolved.state || "destination_unavailable", "The selected panel is unavailable.");
|
|
518
|
+
const matches = (tool) => !this.moves.has(tool.presentationId) && tool.workspaceId === state.workspaceId && panelKey(tool.target.panel) === panelKey(resolved.target.panel)
|
|
519
|
+
&& tool.resolved.surface?.origin === resolved.surface.origin && tool.resolved.surface?.endpoint === resolved.surface.endpoint
|
|
520
|
+
&& canonicalSurfaceRoute(tool.currentRoute) === canonicalSurfaceRoute(resolved.target.route)
|
|
521
|
+
&& JSON.stringify(tool.resolved.surface?.framePolicy) === JSON.stringify(resolved.surface.framePolicy);
|
|
522
|
+
const preferred = destination.presentationId ? this.snapshot.tools.find(t => t.presentationId === destination.presentationId) : undefined;
|
|
523
|
+
if (destination.presentationId && (!preferred || !matches(preferred)))
|
|
524
|
+
throw new UiError("destination_changed", "The selected instance does not match this target.");
|
|
525
|
+
const old = options.reuse === "new" ? undefined : preferred ?? this.snapshot.tools.filter(matches).sort((a, b) => b.focusedAt - a.focusedAt)[0];
|
|
211
526
|
const insecure = window.location.protocol === "https:" && new URL(resolved.surface.origin).protocol === "http:";
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
527
|
+
const tool = old ?? { key: panelKey(target.panel), presentationId: createPresentationRequestId(), workspaceId: state.workspaceId,
|
|
528
|
+
target: resolved.target, panelSurface: resolved.surface, resolved, readiness: insecure ? "unreported" : "loading", attention: 0, currentRoute: resolved.target.route ?? null, focusedAt: 0 };
|
|
529
|
+
if (!old)
|
|
530
|
+
this.update({ tools: [...this.snapshot.tools, tool] });
|
|
531
|
+
const renderer = state.renderer;
|
|
532
|
+
try {
|
|
533
|
+
const checkRenderer = () => {
|
|
534
|
+
check();
|
|
535
|
+
if (state.renderer !== renderer)
|
|
536
|
+
throw new UiError("presentation_superseded", "The workspace renderer changed.");
|
|
537
|
+
const current = this.snapshot.tools.find(t => t.presentationId === tool.presentationId);
|
|
538
|
+
if (!current?.resolved.surface || JSON.stringify(current.resolved.surface) !== JSON.stringify(tool.resolved.surface))
|
|
539
|
+
throw new UiError("destination_changed", "The presentation destination changed.");
|
|
540
|
+
if (!current.resolved.available)
|
|
541
|
+
throw new UiError(current.resolved.state, "The presentation is unavailable.");
|
|
542
|
+
};
|
|
543
|
+
if (await this.runRenderer(checkRenderer, signal => renderer.present(tool, signal), frame) !== "accepted")
|
|
544
|
+
throw new UiError("presentation_unavailable", "The workspace could not present this tool.");
|
|
545
|
+
check();
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
if (!old) {
|
|
549
|
+
renderer.release(tool.presentationId);
|
|
550
|
+
this.update({ tools: this.snapshot.tools.filter(t => t.presentationId !== tool.presentationId) });
|
|
551
|
+
}
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
554
|
+
state.selectedId = tool.presentationId;
|
|
555
|
+
state.expanded = true;
|
|
556
|
+
this.update({ tools: this.snapshot.tools.map(t => t.presentationId === tool.presentationId ? { ...t, focusedAt: ++this.focusSequence } : t) });
|
|
557
|
+
this.publishWorkspaces();
|
|
558
|
+
state.renderer?.focus(tool.presentationId);
|
|
559
|
+
entry.openingSurface = resolved.surface;
|
|
560
|
+
const readiness = this.snapshot.tools.find(t => t.presentationId === tool.presentationId)?.readiness ?? tool.readiness;
|
|
561
|
+
return { status: "opened", requestId: id, target: resolved.target, readiness, presentationId: tool.presentationId };
|
|
220
562
|
}
|
|
221
563
|
catch (error) {
|
|
222
564
|
return failure(id, error);
|
|
@@ -224,21 +566,138 @@ export class UiHost {
|
|
|
224
566
|
})();
|
|
225
567
|
entry.pending = operation;
|
|
226
568
|
this.ledger.set(key, entry);
|
|
227
|
-
this.toolQueues.set(
|
|
569
|
+
this.toolQueues.set(queueKey, operation);
|
|
228
570
|
void operation.then(result => {
|
|
229
571
|
entry.pending = undefined;
|
|
230
572
|
entry.result = result;
|
|
231
|
-
if (this.toolQueues.get(
|
|
232
|
-
this.toolQueues.delete(
|
|
573
|
+
if (this.toolQueues.get(queueKey) === operation)
|
|
574
|
+
this.toolQueues.delete(queueKey);
|
|
233
575
|
if (!frame && this.active && generation === this.activityGeneration)
|
|
234
576
|
this.emit({ type: "presentation", value: result });
|
|
235
|
-
|
|
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);
|
|
577
|
+
this.trimLedger(source);
|
|
239
578
|
});
|
|
240
579
|
return operation;
|
|
241
580
|
}
|
|
581
|
+
pendingCount(source) {
|
|
582
|
+
return [...this.ledger, ...this.actions].filter(([key, value]) => key.startsWith(source + ":") && value.pending).length;
|
|
583
|
+
}
|
|
584
|
+
trimLedger(source) {
|
|
585
|
+
const entries = [...this.ledger, ...this.actions].filter(([key]) => key.startsWith(source + ":"));
|
|
586
|
+
for (const [key, value] of entries.slice(0, Math.max(0, entries.length - 256)))
|
|
587
|
+
if (!value.pending) {
|
|
588
|
+
this.ledger.delete(key);
|
|
589
|
+
this.actions.delete(key);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
peerResult(frame, result) {
|
|
593
|
+
if (frame.composition || result.status !== "opened")
|
|
594
|
+
return result;
|
|
595
|
+
const { presentationId: _id, ...legacy } = result;
|
|
596
|
+
return legacy;
|
|
597
|
+
}
|
|
598
|
+
action(kind, input, options, perform, frame) {
|
|
599
|
+
const id = options.requestId, source = frame ? `${frame.surface.appId}:${frame.generation}:${frame.nonce}` : "local", key = `${source}:${id}`;
|
|
600
|
+
if (!validId(id))
|
|
601
|
+
return Promise.resolve({ status: "rejected", requestId: id, code: "invalid_presentation" });
|
|
602
|
+
const payload = JSON.stringify([kind, input]), existing = this.actions.get(key);
|
|
603
|
+
if (this.ledger.has(key) || existing && existing.input !== payload)
|
|
604
|
+
return Promise.resolve({ status: "rejected", requestId: id, code: "request_conflict" });
|
|
605
|
+
if (existing)
|
|
606
|
+
return waitForPresentation(existing.pending ?? Promise.resolve(existing.result), id, options.signal);
|
|
607
|
+
if (this.pendingCount(source) >= 4)
|
|
608
|
+
return Promise.resolve({ status: "blocked", requestId: id, code: "presentation_busy" });
|
|
609
|
+
const generation = this.activityGeneration;
|
|
610
|
+
const check = () => {
|
|
611
|
+
this.checkActivity(generation);
|
|
612
|
+
if (frame && (!frame.live || !frame.connected || frame.actor !== this.snapshot.bootstrap?.userId || source !== `${frame.surface.appId}:${frame.generation}:${frame.nonce}`))
|
|
613
|
+
throw new UiError("host_unavailable", "The originating view is no longer connected.");
|
|
614
|
+
};
|
|
615
|
+
const entry = { input: payload, frame };
|
|
616
|
+
const operation = Promise.resolve().then(async () => {
|
|
617
|
+
try {
|
|
618
|
+
check();
|
|
619
|
+
await perform(check);
|
|
620
|
+
return { status: "accepted", requestId: id };
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
return failure(id, error);
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
entry.pending = operation;
|
|
627
|
+
this.actions.set(key, entry);
|
|
628
|
+
void operation.then(result => { entry.pending = undefined; entry.result = result; this.trimLedger(source); });
|
|
629
|
+
return waitForPresentation(operation, id, options.signal);
|
|
630
|
+
}
|
|
631
|
+
navigate = (entryKey, options) => this.action("navigate", entryKey, options, async (check) => {
|
|
632
|
+
const catalog = this.getNavigation().catalog, entry = catalog?.entries.find(e => e.key === entryKey);
|
|
633
|
+
if (!entry || !entry.route)
|
|
634
|
+
throw new UiError("destination_changed", "Refresh this app's navigation.");
|
|
635
|
+
if (await this.composition.navigateLocal(entry)) {
|
|
636
|
+
check();
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const resolved = await uiRequest(this.path, "navigation/resolve", { appId: catalog.appId, installationId: catalog.installationId, entryKey });
|
|
640
|
+
check();
|
|
641
|
+
if (!resolved.available || !resolved.surface)
|
|
642
|
+
throw new UiError(resolved.state, "This page is unavailable.");
|
|
643
|
+
window.location.assign(withChromeHints(new URL(resolved.surface.route, resolved.surface.origin).href, this.getChrome()));
|
|
644
|
+
});
|
|
645
|
+
openToolPicker = (options) => this.pickTools(options);
|
|
646
|
+
pickTools(options, frame) {
|
|
647
|
+
return this.action("picker", null, options, async (check) => {
|
|
648
|
+
this.requireTools(frame);
|
|
649
|
+
const state = this.workspaces.get(frame?.view.workspaceId ?? DEFAULT_WORKSPACE), renderer = state?.renderer;
|
|
650
|
+
if (!state || !renderer)
|
|
651
|
+
throw new UiError("presentation_unavailable", "A tool chooser is not available here.");
|
|
652
|
+
const selection = ++state.generation;
|
|
653
|
+
this.invalidateRendererWork();
|
|
654
|
+
const checkRenderer = () => {
|
|
655
|
+
check();
|
|
656
|
+
this.requireTools(frame);
|
|
657
|
+
if (selection !== state.generation || renderer !== state.renderer)
|
|
658
|
+
throw new UiError("presentation_superseded", "A newer action replaced this chooser.");
|
|
659
|
+
};
|
|
660
|
+
const own = await this.bootstrap();
|
|
661
|
+
checkRenderer();
|
|
662
|
+
if (!own.canHost)
|
|
663
|
+
throw new UiError("app_permission_required", "Allow this app to discover tools.");
|
|
664
|
+
if (await this.runRenderer(checkRenderer, signal => renderer.showToolPicker(signal), frame) !== "accepted")
|
|
665
|
+
throw new UiError("presentation_unavailable", "A tool chooser is not available here.");
|
|
666
|
+
checkRenderer();
|
|
667
|
+
}, frame);
|
|
668
|
+
}
|
|
669
|
+
navigateFrame(entryKey, options, frame) {
|
|
670
|
+
return this.action("navigate", entryKey, options, async (check) => {
|
|
671
|
+
await this.bootstrap();
|
|
672
|
+
check();
|
|
673
|
+
const target = { appId: frame.surface.appId, installationId: frame.surface.installationId, entryKey };
|
|
674
|
+
const resolved = await uiRequest(this.path, "navigation/resolve", target);
|
|
675
|
+
check();
|
|
676
|
+
if (!resolved.available || !resolved.surface)
|
|
677
|
+
throw new UiError(resolved.state, "This page is unavailable.");
|
|
678
|
+
if (frame.view.navigate) {
|
|
679
|
+
const navigation = frame.view.navigate(resolved.surface);
|
|
680
|
+
if (navigation)
|
|
681
|
+
await navigation;
|
|
682
|
+
check();
|
|
683
|
+
frame.surface = { ...resolved.surface, panelId: frame.surface.panelId };
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
this.forgetFrameRequests(frame);
|
|
687
|
+
frame.onConnection?.(false);
|
|
688
|
+
frame.surface = { ...resolved.surface, panelId: frame.surface.panelId };
|
|
689
|
+
frame.connected = false;
|
|
690
|
+
frame.actor = null;
|
|
691
|
+
frame.nonce = null;
|
|
692
|
+
frame.generation = createPresentationRequestId();
|
|
693
|
+
frame.element.src = withChromeHints(new URL(resolved.surface.route, resolved.surface.origin).href, frame.chrome);
|
|
694
|
+
}
|
|
695
|
+
if (frame.view.presentationId)
|
|
696
|
+
this.update({ tools: this.snapshot.tools.map(tool => tool.presentationId === frame.view.presentationId
|
|
697
|
+
? { ...tool, resolved: { ...tool.resolved, surface: resolved.surface }, navigationRoot: resolved.surface.route,
|
|
698
|
+
currentRoute: resolved.surface.route, readiness: "loading" } : tool) });
|
|
699
|
+
}, frame);
|
|
700
|
+
}
|
|
242
701
|
getPresentation = (id, options) => waitForPresentation(this.status(id, "local"), id, options?.signal);
|
|
243
702
|
async status(id, source, frame) {
|
|
244
703
|
const generation = this.activityGeneration;
|
|
@@ -262,10 +721,11 @@ export class UiHost {
|
|
|
262
721
|
check();
|
|
263
722
|
if (!current.available)
|
|
264
723
|
return { status: "blocked", requestId: id, code: current.state };
|
|
265
|
-
const tool = this.snapshot.tools.find(t => t.
|
|
266
|
-
|
|
724
|
+
const tool = this.snapshot.tools.find(t => t.presentationId === result.presentationId);
|
|
725
|
+
// Navigation changes the live document, not the destination accepted by this request.
|
|
726
|
+
if ((tool && !tool.resolved.surface) || JSON.stringify(entry?.openingSurface) !== JSON.stringify(current.surface))
|
|
267
727
|
return { status: "blocked", requestId: id, code: "destination_changed" };
|
|
268
|
-
return { ...result, readiness: tool.readiness };
|
|
728
|
+
return { ...result, readiness: tool && tool.currentRoute === result.target.route ? tool.readiness : result.readiness };
|
|
269
729
|
}
|
|
270
730
|
return result ?? { status: "unknown", requestId: id, code: "presentation_unknown" };
|
|
271
731
|
}
|
|
@@ -273,34 +733,95 @@ export class UiHost {
|
|
|
273
733
|
return failure(id, error);
|
|
274
734
|
}
|
|
275
735
|
}
|
|
276
|
-
selectPanel = async (ref) => {
|
|
277
|
-
const opened = this.snapshot.tools.
|
|
278
|
-
|
|
736
|
+
selectPanel = async (ref, workspaceId = DEFAULT_WORKSPACE) => {
|
|
737
|
+
const opened = this.snapshot.tools.filter(t => t.workspaceId === workspaceId && t.key === panelKey(ref)).sort((a, b) => b.focusedAt - a.focusedAt)[0];
|
|
738
|
+
if (opened?.resolved.surface) {
|
|
739
|
+
const requestId = createPresentationRequestId();
|
|
740
|
+
try {
|
|
741
|
+
this.requireTools();
|
|
742
|
+
const state = this.workspaces.get(workspaceId);
|
|
743
|
+
const selection = ++state.generation;
|
|
744
|
+
this.invalidateRendererWork();
|
|
745
|
+
const generation = this.activityGeneration, own = await this.bootstrap();
|
|
746
|
+
if (!own.canHost)
|
|
747
|
+
throw new UiError("app_permission_required", "Allow this app to host tools.");
|
|
748
|
+
const resolved = await uiRequest(this.path, "panels/resolve", opened.target);
|
|
749
|
+
this.checkActivity(generation);
|
|
750
|
+
this.requireTools();
|
|
751
|
+
if (selection !== state.generation)
|
|
752
|
+
throw new UiError("presentation_superseded", "A newer action replaced this opening.");
|
|
753
|
+
if (!resolved.available || !resolved.surface)
|
|
754
|
+
throw new UiError(resolved.state, "The tool is unavailable.");
|
|
755
|
+
const surface = opened.panelSurface;
|
|
756
|
+
if (surface.appId !== resolved.surface.appId || surface.installationId !== resolved.surface.installationId
|
|
757
|
+
|| surface.origin !== resolved.surface.origin || surface.endpoint !== resolved.surface.endpoint
|
|
758
|
+
|| JSON.stringify(surface.framePolicy) !== JSON.stringify(resolved.surface.framePolicy)) {
|
|
759
|
+
this.update({ tools: this.snapshot.tools.map(t => t.presentationId === opened.presentationId
|
|
760
|
+
? { ...t, resolved: { ...t.resolved, available: false, surface: null, state: "destination_changed" } } : t) });
|
|
761
|
+
throw new UiError("destination_changed", "The tool destination changed. Open its current version again.");
|
|
762
|
+
}
|
|
763
|
+
if (this.focusPresentation(opened.presentationId)) {
|
|
764
|
+
const readiness = this.snapshot.tools.find(t => t.presentationId === opened.presentationId)?.readiness ?? opened.readiness;
|
|
765
|
+
return { status: "opened", requestId, target: resolved.target, readiness, presentationId: opened.presentationId };
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
catch (error) {
|
|
769
|
+
return failure(requestId, error);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
else if (opened)
|
|
773
|
+
this.closePresentation(opened.presentationId);
|
|
774
|
+
const requestId = createPresentationRequestId();
|
|
775
|
+
return waitForPresentation(this.open({ panel: ref }, { placement: "side", requestId }, "local", undefined, workspaceId), requestId);
|
|
279
776
|
};
|
|
280
|
-
setExpanded = (expanded) => {
|
|
281
|
-
if (expanded && (!this.active || !this.activityReady))
|
|
282
|
-
return;
|
|
777
|
+
setExpanded = (expanded, workspaceId = DEFAULT_WORKSPACE) => {
|
|
283
778
|
if (!expanded)
|
|
284
|
-
this.
|
|
285
|
-
this.
|
|
779
|
+
return this.collapseWorkspace(workspaceId);
|
|
780
|
+
if (!this.active || !this.activityReady || !this.getChrome().tools.enabled)
|
|
781
|
+
return;
|
|
782
|
+
const state = this.workspaces.get(workspaceId);
|
|
783
|
+
if (state) {
|
|
784
|
+
state.expanded = true;
|
|
785
|
+
this.publishWorkspaces();
|
|
786
|
+
if (state.expanded && state.selectedId)
|
|
787
|
+
state.renderer?.focus(state.selectedId);
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
setWidth = (width, workspaceId = DEFAULT_WORKSPACE) => {
|
|
791
|
+
const state = this.workspaces.get(workspaceId);
|
|
792
|
+
if (state && Number.isFinite(width)) {
|
|
793
|
+
state.width = Math.min(800, Math.max(280, width));
|
|
794
|
+
this.publishWorkspaces();
|
|
795
|
+
}
|
|
286
796
|
};
|
|
287
|
-
|
|
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) });
|
|
797
|
+
setAttention = (panel, attention) => this.update({ tools: this.snapshot.tools.map(t => (typeof panel === "string" ? t.presentationId === panel : t.key === panelKey(panel)) ? { ...t, attention: Math.max(0, Math.floor(attention)) } : t) });
|
|
290
798
|
reportSurfaceState = (_state) => { };
|
|
291
799
|
forgetFrameRequests(frame) {
|
|
800
|
+
frame.routeReport = undefined;
|
|
801
|
+
for (const [controller, work] of this.rendererWork)
|
|
802
|
+
if (work.frame === frame)
|
|
803
|
+
controller.abort();
|
|
292
804
|
for (const [key, entry] of this.ledger)
|
|
293
805
|
if (entry.frame === frame)
|
|
294
806
|
this.ledger.delete(key);
|
|
807
|
+
for (const [key, entry] of this.actions)
|
|
808
|
+
if (entry.frame === frame)
|
|
809
|
+
this.actions.delete(key);
|
|
295
810
|
}
|
|
296
|
-
registerFrame = (element, surface, onConnection) => {
|
|
297
|
-
const
|
|
811
|
+
registerFrame = (element, surface, onConnection, view) => {
|
|
812
|
+
const descriptor = view ?? { viewId: createPresentationRequestId(), workspaceId: DEFAULT_WORKSPACE, chrome: { navigation: "hide", tools: { launcher: "hide" } } };
|
|
813
|
+
if (descriptor.viewId === ROOT_VIEW || [...this.frames].some(f => f.view.viewId === descriptor.viewId))
|
|
814
|
+
throw new Error("A view ID must be unique.");
|
|
815
|
+
if (!this.workspaces.has(descriptor.workspaceId))
|
|
816
|
+
throw new Error("Register the workspace before its views.");
|
|
817
|
+
const frame = { element, surface, view: descriptor, chrome: structuredClone(this.viewChrome.get(descriptor.viewId) ?? descriptor.chrome ?? {}), policyRevision: 0, composition: false, onConnection, generation: createPresentationRequestId(), nonce: null, connected: false, actor: null, live: true, cleanup: () => { }, waitForReadiness: () => { } };
|
|
298
818
|
let timer;
|
|
299
819
|
const forget = () => this.forgetFrameRequests(frame);
|
|
300
820
|
frame.waitForReadiness = () => {
|
|
301
821
|
clearTimeout(timer);
|
|
302
822
|
timer = setTimeout(() => {
|
|
303
|
-
const tool = this.snapshot.tools.find(t =>
|
|
823
|
+
const tool = this.snapshot.tools.find(t => frame.view.presentationId ? t.presentationId === frame.view.presentationId
|
|
824
|
+
: t.target.panel.panelId === frame.surface.panelId && t.resolved.surface?.appId === frame.surface.appId && t.target.route === frame.surface.route);
|
|
304
825
|
if (tool?.readiness === "loading")
|
|
305
826
|
this.reportFrame(frame, "unreported");
|
|
306
827
|
}, DISCOVERY_TIMEOUT);
|
|
@@ -331,16 +852,16 @@ export class UiHost {
|
|
|
331
852
|
reportFrame(frame, readiness) {
|
|
332
853
|
if (!frame.surface.panelId || !this.active || !this.activityReady)
|
|
333
854
|
return;
|
|
334
|
-
const tools = this.snapshot.tools.map(t => t.resolved.surface?.appId === frame.surface.appId
|
|
855
|
+
const tools = this.snapshot.tools.map(t => (frame.view.presentationId ? t.presentationId === frame.view.presentationId : t.resolved.surface?.appId === frame.surface.appId)
|
|
335
856
|
&& t.target.panel.panelId === frame.surface.panelId
|
|
336
|
-
&& t.resolved.surface
|
|
337
|
-
&& t.target.route === frame.surface.route ? { ...t, readiness } : t);
|
|
857
|
+
&& t.resolved.surface?.installationId === frame.surface.installationId && t.resolved.surface?.endpoint === frame.surface.endpoint
|
|
858
|
+
&& (frame.view.presentationId !== undefined || t.target.route === frame.surface.route) ? { ...t, readiness } : t);
|
|
338
859
|
this.update({ tools });
|
|
339
860
|
for (const [key, entry] of this.ledger) {
|
|
340
861
|
const result = entry.result;
|
|
341
862
|
if (result?.status !== "opened")
|
|
342
863
|
continue;
|
|
343
|
-
const tool = tools.find(t => t.
|
|
864
|
+
const tool = tools.find(t => t.presentationId === result.presentationId && t.currentRoute === result.target.route);
|
|
344
865
|
if (!tool || tool.readiness === result.readiness)
|
|
345
866
|
continue;
|
|
346
867
|
entry.result = { ...result, readiness: tool.readiness };
|
|
@@ -350,6 +871,43 @@ export class UiHost {
|
|
|
350
871
|
this.emit({ type: "presentation", value: entry.result });
|
|
351
872
|
}
|
|
352
873
|
}
|
|
874
|
+
reportRoute(frame, value, entryKey) {
|
|
875
|
+
if (!frame.view.presentationId)
|
|
876
|
+
return;
|
|
877
|
+
const route = canonicalSurfaceRoute(value);
|
|
878
|
+
if (frame.routeReport?.route === route && frame.routeReport.pending)
|
|
879
|
+
return;
|
|
880
|
+
const report = { route };
|
|
881
|
+
frame.routeReport = report;
|
|
882
|
+
const tool = this.snapshot.tools.find(t => t.presentationId === frame.view.presentationId);
|
|
883
|
+
const declared = this.snapshot.catalog.find(p => p.panel.appId === frame.surface.appId && p.panel.panelId === frame.surface.panelId);
|
|
884
|
+
const roots = [tool?.navigationRoot, declared?.origin === frame.surface.origin && declared.endpoint === frame.surface.endpoint ? declared.route : undefined];
|
|
885
|
+
const currentRoute = route && roots.some(root => root && routeWithin(route, root)) ? route : null;
|
|
886
|
+
this.update({ tools: this.snapshot.tools.map(t => t.presentationId === frame.view.presentationId && t.currentRoute !== currentRoute
|
|
887
|
+
? { ...t, currentRoute, readiness: "unreported" } : t) });
|
|
888
|
+
if (currentRoute || !route || typeof entryKey !== "string" || !entryKey || entryKey.length > 256)
|
|
889
|
+
return;
|
|
890
|
+
const activity = this.activityGeneration, generation = frame.generation, nonce = frame.nonce, surface = frame.surface;
|
|
891
|
+
report.pending = (async () => {
|
|
892
|
+
try {
|
|
893
|
+
const resolved = await uiRequest(this.path, "navigation/resolve", {
|
|
894
|
+
appId: surface.appId, installationId: surface.installationId, entryKey,
|
|
895
|
+
});
|
|
896
|
+
this.checkActivity(activity);
|
|
897
|
+
if (!frame.live || !frame.connected || frame.generation !== generation || frame.nonce !== nonce || frame.routeReport !== report
|
|
898
|
+
|| frame.actor !== this.snapshot.bootstrap?.userId)
|
|
899
|
+
return;
|
|
900
|
+
const next = resolved.surface;
|
|
901
|
+
if (!resolved.available || !next || next.appId !== surface.appId || next.installationId !== surface.installationId
|
|
902
|
+
|| next.origin !== surface.origin || next.endpoint !== surface.endpoint || !routeWithin(route, next.route)
|
|
903
|
+
|| JSON.stringify(next.framePolicy) !== JSON.stringify(surface.framePolicy))
|
|
904
|
+
return;
|
|
905
|
+
this.update({ tools: this.snapshot.tools.map(t => t.presentationId === frame.view.presentationId
|
|
906
|
+
? { ...t, navigationRoot: next.route, currentRoute: route, readiness: "unreported" } : t) });
|
|
907
|
+
}
|
|
908
|
+
catch { /* Local navigation remains usable when its host cannot verify new metadata. */ }
|
|
909
|
+
})().finally(() => { report.pending = undefined; });
|
|
910
|
+
}
|
|
353
911
|
async receive(event) {
|
|
354
912
|
if (!this.active || !this.activityReady || this.disposed)
|
|
355
913
|
return;
|
|
@@ -368,7 +926,8 @@ export class UiHost {
|
|
|
368
926
|
frame.onConnection?.(false);
|
|
369
927
|
}
|
|
370
928
|
frame.nonce = msg.childNonce;
|
|
371
|
-
|
|
929
|
+
frame.composition = Array.isArray(msg.capabilities) && msg.capabilities.includes(UI_COMPOSITION_CAPABILITY);
|
|
930
|
+
this.post(frame, { type: "hosty:ui:offer", capabilities: [UI_COMPOSITION_CAPABILITY] });
|
|
372
931
|
return;
|
|
373
932
|
}
|
|
374
933
|
if (msg.hostWindowId !== this.windowId || msg.generation !== frame.generation || msg.childNonce !== frame.nonce || !frame.nonce)
|
|
@@ -391,7 +950,9 @@ export class UiHost {
|
|
|
391
950
|
frame.waitForReadiness();
|
|
392
951
|
}
|
|
393
952
|
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
|
|
953
|
+
appId: own.app.appId, installationId: own.app.installationId, code: frame.connected ? null : "actor_mismatch", route: frame.surface.route,
|
|
954
|
+
...(frame.composition ? { capabilities: [UI_COMPOSITION_CAPABILITY], chrome: frame.chrome, revision: ++frame.policyRevision,
|
|
955
|
+
rootDisabled: !this.getChrome().tools.enabled, endpoint: frame.surface.endpoint } : {}) });
|
|
395
956
|
if (frame.connected !== wasConnected)
|
|
396
957
|
frame.onConnection?.(frame.connected);
|
|
397
958
|
if (!frame.connected)
|
|
@@ -401,31 +962,59 @@ export class UiHost {
|
|
|
401
962
|
if (!frame.connected)
|
|
402
963
|
return;
|
|
403
964
|
const source = `${frame.surface.appId}:${frame.generation}:${frame.nonce}`;
|
|
965
|
+
if (frame.composition && (msg.type === "hosty:ui:picker" || msg.type === "hosty:ui:navigate") && validId(msg.requestId)) {
|
|
966
|
+
const result = msg.type === "hosty:ui:picker" ? await this.pickTools({ requestId: msg.requestId }, frame)
|
|
967
|
+
: typeof msg.entryKey === "string" ? await this.navigateFrame(msg.entryKey, { requestId: msg.requestId }, frame)
|
|
968
|
+
: { status: "rejected", requestId: msg.requestId, code: "destination_invalid" };
|
|
969
|
+
if (source === `${frame.surface.appId}:${frame.generation}:${frame.nonce}`)
|
|
970
|
+
this.post(frame, { type: "hosty:ui:action-result", requestId: msg.requestId, result });
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (frame.composition && msg.type === "hosty:ui:route") {
|
|
974
|
+
this.reportRoute(frame, msg.route, msg.entryKey);
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
if (msg.type === "hosty:ui:surface-state" && frame.routeReport?.pending) {
|
|
978
|
+
const report = frame.routeReport;
|
|
979
|
+
if (canonicalSurfaceRoute(msg.route) !== report.route)
|
|
980
|
+
return;
|
|
981
|
+
await report.pending;
|
|
982
|
+
if (!this.active || this.disposed || !frame.live || !frame.connected || frame.routeReport !== report
|
|
983
|
+
|| source !== `${frame.surface.appId}:${frame.generation}:${frame.nonce}`)
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
404
986
|
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);
|
|
987
|
+
const result = await this.open(msg.target, { placement: msg.placement, requestId: msg.requestId, reuse: frame.composition ? msg.reuse : undefined }, source, frame);
|
|
406
988
|
if (source === `${frame.surface.appId}:${frame.generation}:${frame.nonce}`)
|
|
407
|
-
this.post(frame, { type: "hosty:ui:result", requestId: msg.requestId, result });
|
|
989
|
+
this.post(frame, { type: "hosty:ui:result", requestId: msg.requestId, result: this.peerResult(frame, result) });
|
|
408
990
|
}
|
|
409
991
|
else if (msg.type === "hosty:ui:status" && validId(msg.requestId)) {
|
|
410
992
|
const result = await this.status(msg.requestId, source, frame);
|
|
411
993
|
if (source === `${frame.surface.appId}:${frame.generation}:${frame.nonce}`)
|
|
412
|
-
this.post(frame, { type: "hosty:ui:result", requestId: msg.requestId, result });
|
|
994
|
+
this.post(frame, { type: "hosty:ui:result", requestId: msg.requestId, result: this.peerResult(frame, result) });
|
|
413
995
|
}
|
|
414
996
|
else if (msg.type === "hosty:ui:surface-state" && canonicalSurfaceRoute(msg.route) !== null
|
|
415
|
-
&& canonicalSurfaceRoute(msg.route) === canonicalSurfaceRoute(frame.
|
|
997
|
+
&& canonicalSurfaceRoute(msg.route) === canonicalSurfaceRoute(frame.view.presentationId
|
|
998
|
+
? this.snapshot.tools.find(t => t.presentationId === frame.view.presentationId)?.currentRoute : frame.surface.route)) {
|
|
416
999
|
if (msg.state === "ready")
|
|
417
1000
|
this.reportFrame(frame, "ready");
|
|
418
1001
|
else if (msg.state === "auth-required" || msg.state === "blocked")
|
|
419
1002
|
this.reportFrame(frame, "auth-required");
|
|
420
1003
|
}
|
|
421
1004
|
else if (msg.type === "hosty:ui:dismiss") {
|
|
422
|
-
const selected = this.snapshot.tools.find(t =>
|
|
423
|
-
if (selected
|
|
424
|
-
|
|
1005
|
+
const selected = this.snapshot.tools.find(t => t.presentationId === this.workspaces.get(frame.view.workspaceId)?.selectedId);
|
|
1006
|
+
if (selected && (frame.view.presentationId ? selected.presentationId === frame.view.presentationId
|
|
1007
|
+
: selected.resolved.surface?.appId === frame.surface.appId && selected.target.panel.panelId === frame.surface.panelId && selected.target.route === frame.surface.route))
|
|
1008
|
+
window.dispatchEvent(new CustomEvent("hosty:ui:drawer-dismiss", { detail: frame.view.workspaceId }));
|
|
425
1009
|
}
|
|
426
1010
|
}
|
|
427
1011
|
dispose = () => {
|
|
1012
|
+
if (this.disposed)
|
|
1013
|
+
return;
|
|
1014
|
+
this.resetPresentations();
|
|
428
1015
|
this.disposed = true;
|
|
1016
|
+
this.composition.dispose();
|
|
1017
|
+
this.actions.clear();
|
|
429
1018
|
for (const frame of this.frames)
|
|
430
1019
|
frame.cleanup();
|
|
431
1020
|
window.removeEventListener("message", this.onMessage);
|
|
@@ -433,6 +1022,7 @@ export class UiHost {
|
|
|
433
1022
|
this.listeners.clear();
|
|
434
1023
|
this.events.clear();
|
|
435
1024
|
this.ledger.clear();
|
|
1025
|
+
this.viewChrome.clear();
|
|
436
1026
|
if (documentHost === this)
|
|
437
1027
|
documentHost = null;
|
|
438
1028
|
};
|