@brass-build/client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +255 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +836 -0
- package/dist/api-fetch.d.ts +16 -0
- package/dist/api-fetch.d.ts.map +1 -0
- package/dist/api-fetch.js +97 -0
- package/dist/api-fetch.js.map +1 -0
- package/dist/api-headers.d.ts +3 -0
- package/dist/api-headers.d.ts.map +1 -0
- package/dist/api-headers.js +25 -0
- package/dist/api-headers.js.map +1 -0
- package/dist/auth-flow.d.ts +23 -0
- package/dist/auth-flow.d.ts.map +1 -0
- package/dist/auth-flow.js +76 -0
- package/dist/auth-flow.js.map +1 -0
- package/dist/client.d.ts +106 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1278 -0
- package/dist/client.js.map +1 -0
- package/dist/doc-url-sync.d.ts +19 -0
- package/dist/doc-url-sync.d.ts.map +1 -0
- package/dist/doc-url-sync.js +181 -0
- package/dist/doc-url-sync.js.map +1 -0
- package/dist/document-branches.d.ts +32 -0
- package/dist/document-branches.d.ts.map +1 -0
- package/dist/document-branches.js +89 -0
- package/dist/document-branches.js.map +1 -0
- package/dist/document-checkpoints.d.ts +24 -0
- package/dist/document-checkpoints.d.ts.map +1 -0
- package/dist/document-checkpoints.js +115 -0
- package/dist/document-checkpoints.js.map +1 -0
- package/dist/document-commands.d.ts +93 -0
- package/dist/document-commands.d.ts.map +1 -0
- package/dist/document-commands.js +423 -0
- package/dist/document-commands.js.map +1 -0
- package/dist/document-signals.d.ts +53 -0
- package/dist/document-signals.d.ts.map +1 -0
- package/dist/document-signals.js +114 -0
- package/dist/document-signals.js.map +1 -0
- package/dist/document-streams.d.ts +22 -0
- package/dist/document-streams.d.ts.map +1 -0
- package/dist/document-streams.js +98 -0
- package/dist/document-streams.js.map +1 -0
- package/dist/document-thumbnail-capture.d.ts +27 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -0
- package/dist/document-thumbnail-capture.js +160 -0
- package/dist/document-thumbnail-capture.js.map +1 -0
- package/dist/documents-namespace.d.ts +134 -0
- package/dist/documents-namespace.d.ts.map +1 -0
- package/dist/documents-namespace.js +190 -0
- package/dist/documents-namespace.js.map +1 -0
- package/dist/documents.d.ts +146 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +2045 -0
- package/dist/documents.js.map +1 -0
- package/dist/emitter.d.ts +15 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +90 -0
- package/dist/emitter.js.map +1 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +22 -0
- package/dist/errors.js.map +1 -0
- package/dist/export-guest.d.ts +9 -0
- package/dist/export-guest.d.ts.map +1 -0
- package/dist/export-guest.js +225 -0
- package/dist/export-guest.js.map +1 -0
- package/dist/fetch-retry.d.ts +16 -0
- package/dist/fetch-retry.d.ts.map +1 -0
- package/dist/fetch-retry.js +90 -0
- package/dist/fetch-retry.js.map +1 -0
- package/dist/guest-link.d.ts +3 -0
- package/dist/guest-link.d.ts.map +1 -0
- package/dist/guest-link.js +13 -0
- package/dist/guest-link.js.map +1 -0
- package/dist/handles.d.ts +57 -0
- package/dist/handles.d.ts.map +1 -0
- package/dist/handles.js +1375 -0
- package/dist/handles.js.map +1 -0
- package/dist/hosted-runtime.d.ts +33 -0
- package/dist/hosted-runtime.d.ts.map +1 -0
- package/dist/hosted-runtime.js +99 -0
- package/dist/hosted-runtime.js.map +1 -0
- package/dist/hosted-ui.d.ts +84 -0
- package/dist/hosted-ui.d.ts.map +1 -0
- package/dist/hosted-ui.js +673 -0
- package/dist/hosted-ui.js.map +1 -0
- package/dist/importer.d.ts +22 -0
- package/dist/importer.d.ts.map +1 -0
- package/dist/importer.js +54 -0
- package/dist/importer.js.map +1 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +132 -0
- package/dist/index.js.map +1 -0
- package/dist/init-keys.d.ts +3 -0
- package/dist/init-keys.d.ts.map +1 -0
- package/dist/init-keys.js +13 -0
- package/dist/init-keys.js.map +1 -0
- package/dist/internal.d.ts +6 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +17 -0
- package/dist/internal.js.map +1 -0
- package/dist/jwt.d.ts +3 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +22 -0
- package/dist/jwt.js.map +1 -0
- package/dist/launch-context.d.ts +4 -0
- package/dist/launch-context.d.ts.map +1 -0
- package/dist/launch-context.js +44 -0
- package/dist/launch-context.js.map +1 -0
- package/dist/login-hint.d.ts +4 -0
- package/dist/login-hint.d.ts.map +1 -0
- package/dist/login-hint.js +63 -0
- package/dist/login-hint.js.map +1 -0
- package/dist/mutate-batching.d.ts +14 -0
- package/dist/mutate-batching.d.ts.map +1 -0
- package/dist/mutate-batching.js +65 -0
- package/dist/mutate-batching.js.map +1 -0
- package/dist/open-document.d.ts +127 -0
- package/dist/open-document.d.ts.map +1 -0
- package/dist/open-document.js +2121 -0
- package/dist/open-document.js.map +1 -0
- package/dist/page-title.d.ts +3 -0
- package/dist/page-title.d.ts.map +1 -0
- package/dist/page-title.js +32 -0
- package/dist/page-title.js.map +1 -0
- package/dist/perf.d.ts +9 -0
- package/dist/perf.d.ts.map +1 -0
- package/dist/perf.js +39 -0
- package/dist/perf.js.map +1 -0
- package/dist/presence.d.ts +62 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +331 -0
- package/dist/presence.js.map +1 -0
- package/dist/record-paging.d.ts +99 -0
- package/dist/record-paging.d.ts.map +1 -0
- package/dist/record-paging.js +220 -0
- package/dist/record-paging.js.map +1 -0
- package/dist/schema-conflict-dialog.d.ts +10 -0
- package/dist/schema-conflict-dialog.d.ts.map +1 -0
- package/dist/schema-conflict-dialog.js +164 -0
- package/dist/schema-conflict-dialog.js.map +1 -0
- package/dist/selection-persistence.d.ts +5 -0
- package/dist/selection-persistence.d.ts.map +1 -0
- package/dist/selection-persistence.js +90 -0
- package/dist/selection-persistence.js.map +1 -0
- package/dist/serializer.d.ts +3 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/serializer.js +2 -0
- package/dist/serializer.js.map +1 -0
- package/dist/session-persistence.d.ts +4 -0
- package/dist/session-persistence.d.ts.map +1 -0
- package/dist/session-persistence.js +68 -0
- package/dist/session-persistence.js.map +1 -0
- package/dist/session-toolbar.d.ts +62 -0
- package/dist/session-toolbar.d.ts.map +1 -0
- package/dist/session-toolbar.js +1205 -0
- package/dist/session-toolbar.js.map +1 -0
- package/dist/session.d.ts +69 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +926 -0
- package/dist/session.js.map +1 -0
- package/dist/tail-ops.d.ts +28 -0
- package/dist/tail-ops.d.ts.map +1 -0
- package/dist/tail-ops.js +46 -0
- package/dist/tail-ops.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +41 -0
- package/dist/topic-lifecycle.d.ts.map +1 -0
- package/dist/topic-lifecycle.js +169 -0
- package/dist/topic-lifecycle.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/undo.d.ts +18 -0
- package/dist/undo.d.ts.map +1 -0
- package/dist/undo.js +110 -0
- package/dist/undo.js.map +1 -0
- package/dist/version-bar.d.ts +22 -0
- package/dist/version-bar.d.ts.map +1 -0
- package/dist/version-bar.js +169 -0
- package/dist/version-bar.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +36 -0
- package/dist/vite.js.map +1 -0
- package/dist/ws-client.d.ts +53 -0
- package/dist/ws-client.d.ts.map +1 -0
- package/dist/ws-client.js +604 -0
- package/dist/ws-client.js.map +1 -0
- package/dist/ws-frame-limit.d.ts +21 -0
- package/dist/ws-frame-limit.d.ts.map +1 -0
- package/dist/ws-frame-limit.js +40 -0
- package/dist/ws-frame-limit.js.map +1 -0
- package/docs/app-manifest.md +65 -0
- package/docs/custom-chrome.md +147 -0
- package/docs/hosted-ui-advanced.md +77 -0
- package/docs/imported-documents.md +261 -0
- package/docs/presence.md +129 -0
- package/docs/schemas.md +135 -0
- package/docs/streams.md +248 -0
- package/package.json +60 -0
- package/scripts/prepare.mjs +50 -0
- package/src/api-fetch.ts +127 -0
- package/src/api-headers.ts +28 -0
- package/src/auth-flow.ts +125 -0
- package/src/client.ts +1457 -0
- package/src/doc-url-sync.ts +183 -0
- package/src/document-branches.ts +124 -0
- package/src/document-checkpoints.ts +135 -0
- package/src/document-commands.ts +651 -0
- package/src/document-signals.ts +234 -0
- package/src/document-streams.ts +132 -0
- package/src/document-thumbnail-capture.ts +191 -0
- package/src/documents-namespace.ts +516 -0
- package/src/documents.ts +2426 -0
- package/src/emitter.ts +129 -0
- package/src/errors.ts +45 -0
- package/src/export-guest.ts +282 -0
- package/src/fetch-retry.ts +119 -0
- package/src/guest-link.ts +14 -0
- package/src/handles.ts +1731 -0
- package/src/hosted-runtime.ts +109 -0
- package/src/hosted-ui.ts +1020 -0
- package/src/importer.ts +89 -0
- package/src/index.ts +267 -0
- package/src/init-keys.ts +15 -0
- package/src/internal.ts +23 -0
- package/src/jwt.ts +18 -0
- package/src/launch-context.ts +43 -0
- package/src/login-hint.ts +60 -0
- package/src/mutate-batching.ts +63 -0
- package/src/open-document.ts +2869 -0
- package/src/page-title.ts +33 -0
- package/src/perf.ts +46 -0
- package/src/presence.ts +471 -0
- package/src/record-paging.ts +270 -0
- package/src/schema-conflict-dialog.ts +176 -0
- package/src/selection-persistence.ts +98 -0
- package/src/serializer.ts +15 -0
- package/src/session-persistence.ts +69 -0
- package/src/session-toolbar.ts +1457 -0
- package/src/session.ts +1262 -0
- package/src/tail-ops.ts +63 -0
- package/src/topic-lifecycle.ts +217 -0
- package/src/types.ts +75 -0
- package/src/undo.ts +125 -0
- package/src/version-bar.ts +221 -0
- package/src/version.ts +8 -0
- package/src/vite.ts +49 -0
- package/src/ws-client.ts +678 -0
- package/src/ws-frame-limit.ts +52 -0
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
// The hosted-UI launcher core.
|
|
2
|
+
//
|
|
3
|
+
// "Hosted UI" surfaces are Brass-owned pages (document picker, share dialog) a
|
|
4
|
+
// third-party app opens without building the UI. The app calls e.g.
|
|
5
|
+
// `Brass.selectDocument()`; this loads `${dashboardBaseUrl}/embed/<surface>`
|
|
6
|
+
// in a modal iframe, runs a postMessage handshake with the embed page's
|
|
7
|
+
// bridge, and resolves with whatever the user did.
|
|
8
|
+
//
|
|
9
|
+
// Iframe-first. When the iframe can't complete the handshake (CSP
|
|
10
|
+
// `frame-ancestors`, a partitioned third-party auth cookie, ...) the launcher
|
|
11
|
+
// falls back per surface:
|
|
12
|
+
// - `reject`: throw `BrassError` `code: 'iframe_unavailable'`. Used by
|
|
13
|
+
// value-returning launchers with nowhere else to send the user.
|
|
14
|
+
// - `redirect`: navigate the top window to the standalone
|
|
15
|
+
// `/embed/<surface>?...&return_to=<here>` page. Used by fire-and-forget
|
|
16
|
+
// launchers; the returned promise never settles (the page is gone).
|
|
17
|
+
// - `redirect-with-value`: the same navigation for a surface that still
|
|
18
|
+
// owes the caller a value, which comes home on one-shot URL params (the
|
|
19
|
+
// document picker).
|
|
20
|
+
//
|
|
21
|
+
// Bridge protocol (envelope `__brass_embed: 1`):
|
|
22
|
+
// iframe -> host: ready | resize{height} | result{payload} | cancel |
|
|
23
|
+
// error{code,message}
|
|
24
|
+
// host -> iframe: init{surface,params}
|
|
25
|
+
// The host validates `event.origin === dashboardOrigin` and
|
|
26
|
+
// `event.source === iframe.contentWindow` on every inbound message; the embed
|
|
27
|
+
// page learns the host origin from `init` and targets outbound at it (never
|
|
28
|
+
// `*`).
|
|
29
|
+
// Both sides ignore any `message` whose `data[ENVELOPE]` is not this exact
|
|
30
|
+
// value, so unrelated postMessage traffic never reaches the handshake.
|
|
31
|
+
import { BrassError } from './errors.js';
|
|
32
|
+
const ENVELOPE = '__brass_embed';
|
|
33
|
+
const PROTOCOL_VERSION = 1;
|
|
34
|
+
// No `ready` within this window means the iframe is unusable (CSP, origin
|
|
35
|
+
// unreachable, bridge threw). What the window is worth is set by what the
|
|
36
|
+
// fallback costs the user, not by which fallback runs.
|
|
37
|
+
//
|
|
38
|
+
// Only a plain `redirect` can afford the short window: the surface returns
|
|
39
|
+
// nothing, the standalone page finishes the same job, and the user keeps
|
|
40
|
+
// their place, so eating ten seconds of blank modal is the bigger cost.
|
|
41
|
+
//
|
|
42
|
+
// `reject` and `redirect-with-value` both end with the caller worse off (a
|
|
43
|
+
// thrown error, or the user navigated out of the host app with the value
|
|
44
|
+
// routed home through a URL round trip), so they wait long enough that only
|
|
45
|
+
// a genuinely unusable iframe times out. The embed page is a full
|
|
46
|
+
// application boot behind a cross-origin request, so a merely slow link
|
|
47
|
+
// clears the short window while the iframe is perfectly capable.
|
|
48
|
+
const READY_TIMEOUT_LONG_MS = 10_000;
|
|
49
|
+
const READY_TIMEOUT_SHORT_MS = 4_000;
|
|
50
|
+
// The embed page posts `resize` with its measured height; the host clamps to
|
|
51
|
+
// this range so a malformed or hostile height can't collapse the dialog or
|
|
52
|
+
// paint the whole viewport.
|
|
53
|
+
const INITIAL_IFRAME_HEIGHT = 480;
|
|
54
|
+
const MIN_IFRAME_HEIGHT = 160;
|
|
55
|
+
const MAX_IFRAME_HEIGHT = 760;
|
|
56
|
+
// Full-page fallback when the iframe can't be hosted: navigate the top
|
|
57
|
+
// window to the standalone embed page, threading the surface params plus a
|
|
58
|
+
// `return_to` so the standalone page can send the user home when done.
|
|
59
|
+
function redirectToStandalone(baseUrl, surface, params, theme) {
|
|
60
|
+
const url = new URL(`${baseUrl}/embed/${surface}`);
|
|
61
|
+
for (const [key, value] of Object.entries(params)) {
|
|
62
|
+
// `sessionToken` is an in-iframe-only credential hand-off (posted over the
|
|
63
|
+
// bridge to the pinned dashboard origin); never let it reach a URL. On the
|
|
64
|
+
// redirect fallback the standalone page is top-level and first-party, so
|
|
65
|
+
// its own cookie carries the session and no hand-off is needed.
|
|
66
|
+
if (key === 'sessionToken')
|
|
67
|
+
continue;
|
|
68
|
+
if (value !== undefined && value !== null) {
|
|
69
|
+
url.searchParams.set(key, String(value));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
url.searchParams.set('return_to', window.location.href);
|
|
73
|
+
// Thread the theme only when it overrides the `'light'` default, keeping the
|
|
74
|
+
// URL stable for the no-config path (mirrors the iframe-src construction).
|
|
75
|
+
if (theme !== 'light') {
|
|
76
|
+
url.searchParams.set('theme', theme);
|
|
77
|
+
}
|
|
78
|
+
window.location.assign(url.toString());
|
|
79
|
+
}
|
|
80
|
+
// Resolved light/dark for the modal chrome. `'auto'` reads the host's
|
|
81
|
+
// `prefers-color-scheme` once on open; the embed page still receives the
|
|
82
|
+
// unresolved `'auto'` so its own rule keeps tracking OS preference.
|
|
83
|
+
function resolveChromeTheme(theme) {
|
|
84
|
+
if (theme === 'light' || theme === 'dark')
|
|
85
|
+
return theme;
|
|
86
|
+
const mql = window.matchMedia?.('(prefers-color-scheme: dark)');
|
|
87
|
+
return mql?.matches ? 'dark' : 'light';
|
|
88
|
+
}
|
|
89
|
+
function chromeTokensFor(resolved) {
|
|
90
|
+
if (resolved === 'dark') {
|
|
91
|
+
return {
|
|
92
|
+
backdrop: 'rgba(0, 0, 0, 0.65)',
|
|
93
|
+
cardBackground: '#11141b',
|
|
94
|
+
cardShadow: '0 20px 60px rgba(0, 0, 0, 0.55)',
|
|
95
|
+
closeButtonColor: '#e7eaf0',
|
|
96
|
+
spinnerTrack: 'rgba(231, 234, 240, 0.18)',
|
|
97
|
+
spinnerAccent: '#e7eaf0',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
backdrop: 'rgba(15, 23, 42, 0.55)',
|
|
102
|
+
cardBackground: '#fff',
|
|
103
|
+
cardShadow: '0 20px 60px rgba(15, 23, 42, 0.35)',
|
|
104
|
+
closeButtonColor: '#0f172a',
|
|
105
|
+
spinnerTrack: 'rgba(15, 23, 42, 0.12)',
|
|
106
|
+
spinnerAccent: '#0f172a',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const CHROME_STYLE_ID = 'brass-hosted-ui-chrome';
|
|
110
|
+
// The chrome dims the page with the dialog element's own themed background, so
|
|
111
|
+
// the UA's `::backdrop` would stack a second, unthemed dim beneath it. A
|
|
112
|
+
// pseudo-element cannot be styled inline, which is why the chrome injects a
|
|
113
|
+
// stylesheet at all; everything else it paints is an inline style.
|
|
114
|
+
const CHROME_CSS = `dialog[data-brass-hosted-ui]::backdrop { background: transparent; }`;
|
|
115
|
+
function injectChromeStyles(doc) {
|
|
116
|
+
if (doc.getElementById(CHROME_STYLE_ID))
|
|
117
|
+
return;
|
|
118
|
+
const style = doc.createElement('style');
|
|
119
|
+
style.id = CHROME_STYLE_ID;
|
|
120
|
+
style.textContent = CHROME_CSS;
|
|
121
|
+
doc.head.appendChild(style);
|
|
122
|
+
}
|
|
123
|
+
// The control the reader last acted on, which is who a dismissed surface hands
|
|
124
|
+
// focus back to. `document.activeElement` when the launcher is called looks
|
|
125
|
+
// like the answer and is often `<body>` by then: a host that disables its
|
|
126
|
+
// trigger for the life of the dialog does so before calling, and disabling a
|
|
127
|
+
// focused control blurs it. Tracking the activation instead survives that.
|
|
128
|
+
let lastActivated = null;
|
|
129
|
+
let trackingActivations = false;
|
|
130
|
+
const ACTIVATABLE = 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
131
|
+
function trackActivation(event) {
|
|
132
|
+
// Enter and Space are the keys that press a control; every other keystroke
|
|
133
|
+
// is someone typing, and typing is not what a dialog returns focus to.
|
|
134
|
+
if (event instanceof KeyboardEvent && event.key !== 'Enter' && event.key !== ' ') {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const target = event.target;
|
|
138
|
+
if (!(target instanceof HTMLElement))
|
|
139
|
+
return;
|
|
140
|
+
lastActivated = target.closest(ACTIVATABLE) ?? target;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Start recording which control the reader last pressed, so a dismissed
|
|
144
|
+
* surface can hand the keyboard back to it. Idempotent.
|
|
145
|
+
*
|
|
146
|
+
* @internal
|
|
147
|
+
*/
|
|
148
|
+
export function trackHostedUiActivations() {
|
|
149
|
+
if (trackingActivations || typeof document === 'undefined')
|
|
150
|
+
return;
|
|
151
|
+
trackingActivations = true;
|
|
152
|
+
document.addEventListener('pointerdown', trackActivation, true);
|
|
153
|
+
document.addEventListener('keydown', trackActivation, true);
|
|
154
|
+
}
|
|
155
|
+
// Who a dismissal should put the keyboard back on: whatever holds focus when
|
|
156
|
+
// the surface opens, or the last control the reader pressed when nothing does.
|
|
157
|
+
function resolveOpener() {
|
|
158
|
+
const active = document.activeElement;
|
|
159
|
+
if (active instanceof HTMLElement && active !== document.body)
|
|
160
|
+
return active;
|
|
161
|
+
return lastActivated;
|
|
162
|
+
}
|
|
163
|
+
// Opens `${baseUrl}/embed/<surface>` in a modal iframe and drives the bridge
|
|
164
|
+
// handshake. Resolves with the `result` payload or `null` on cancel; rejects
|
|
165
|
+
// on not-in-browser / embed error. On the ready timeout it rejects
|
|
166
|
+
// (`unavailable: 'reject'`) or redirects and never settles (the two
|
|
167
|
+
// `redirect` modes).
|
|
168
|
+
function openEmbed(baseUrl, dashboardOrigin, surface, params, unavailable, theme,
|
|
169
|
+
// Handles an `export-request`: serialise the surface's `docId` and post the
|
|
170
|
+
// bytes back. Only the document surfaces (with a Download button) pass it.
|
|
171
|
+
onExportRequest,
|
|
172
|
+
// Notified when the delete surface reports a local delete (or its
|
|
173
|
+
// retraction) for a doc id, so the session can classify the matching
|
|
174
|
+
// `doc_gone`. Only the document-settings / share surface emits it.
|
|
175
|
+
onLocalDelete,
|
|
176
|
+
// Notified when the user takes the refusal surface's sign-in action.
|
|
177
|
+
onSignInRequested) {
|
|
178
|
+
return new Promise((resolve, reject) => {
|
|
179
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
180
|
+
reject(new BrassError('Hosted UI requires a browser environment.', {
|
|
181
|
+
code: 'not_in_browser',
|
|
182
|
+
}));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const resolvedChrome = resolveChromeTheme(theme);
|
|
186
|
+
const tokens = chromeTokensFor(resolvedChrome);
|
|
187
|
+
injectChromeStyles(document);
|
|
188
|
+
// A `<dialog>` opened with `showModal()` (below), which is what makes the
|
|
189
|
+
// host page inert while the surface is up: the reader tabs around the
|
|
190
|
+
// dialog instead of through the app behind it, and the browser enforces
|
|
191
|
+
// that rather than a key handler here. The UA box styles for a dialog (a
|
|
192
|
+
// content-sized, centered, bordered, padded box with a max-width) are all
|
|
193
|
+
// wrong for a full-viewport backdrop, so each is zeroed.
|
|
194
|
+
const overlay = document.createElement('dialog');
|
|
195
|
+
overlay.setAttribute('data-brass-hosted-ui', surface);
|
|
196
|
+
overlay.setAttribute('data-brass-theme', theme);
|
|
197
|
+
Object.assign(overlay.style, {
|
|
198
|
+
position: 'fixed',
|
|
199
|
+
inset: '0',
|
|
200
|
+
margin: '0',
|
|
201
|
+
padding: '0',
|
|
202
|
+
border: '0',
|
|
203
|
+
width: 'auto',
|
|
204
|
+
height: 'auto',
|
|
205
|
+
maxWidth: 'none',
|
|
206
|
+
maxHeight: 'none',
|
|
207
|
+
// Above effectively everything; this is a foreground modal. The top
|
|
208
|
+
// layer already outranks any z-index, so this is what keeps the chrome
|
|
209
|
+
// in front on the fallback path where `showModal` is unavailable.
|
|
210
|
+
zIndex: '2147483647',
|
|
211
|
+
display: 'flex',
|
|
212
|
+
alignItems: 'center',
|
|
213
|
+
justifyContent: 'center',
|
|
214
|
+
background: tokens.backdrop,
|
|
215
|
+
});
|
|
216
|
+
const card = document.createElement('div');
|
|
217
|
+
// Expose the frame as a labelled modal dialog: the overlay behind it is
|
|
218
|
+
// the backdrop, and the embed page inside the iframe carries its own
|
|
219
|
+
// heading. The close button / Escape dismiss it (below); a backdrop
|
|
220
|
+
// click does not.
|
|
221
|
+
card.setAttribute('role', 'dialog');
|
|
222
|
+
card.setAttribute('aria-modal', 'true');
|
|
223
|
+
card.setAttribute('aria-label', 'Brass');
|
|
224
|
+
Object.assign(card.style, {
|
|
225
|
+
position: 'relative',
|
|
226
|
+
width: 'min(420px, calc(100vw - 32px))',
|
|
227
|
+
background: tokens.cardBackground,
|
|
228
|
+
borderRadius: '12px',
|
|
229
|
+
overflow: 'hidden',
|
|
230
|
+
boxShadow: tokens.cardShadow,
|
|
231
|
+
});
|
|
232
|
+
const iframe = document.createElement('iframe');
|
|
233
|
+
// Thread the theme on the iframe URL (not the bridge `init` payload) so
|
|
234
|
+
// the embed page pins its `color-scheme` before it paints, avoiding a
|
|
235
|
+
// wrong-theme flash while the handshake is in flight. Omit the `'light'`
|
|
236
|
+
// default to keep the URL stable for the no-config path.
|
|
237
|
+
if (theme === 'light') {
|
|
238
|
+
iframe.src = `${baseUrl}/embed/${surface}`;
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
const iframeUrl = new URL(`${baseUrl}/embed/${surface}`);
|
|
242
|
+
iframeUrl.searchParams.set('theme', theme);
|
|
243
|
+
iframe.src = iframeUrl.toString();
|
|
244
|
+
}
|
|
245
|
+
iframe.title = 'Brass';
|
|
246
|
+
// The dialogs offer copy-to-clipboard actions (the share surface's
|
|
247
|
+
// document link); a cross-origin iframe only gets the Clipboard API
|
|
248
|
+
// when its embedder delegates it, so writeText throws NotAllowedError
|
|
249
|
+
// without this.
|
|
250
|
+
iframe.allow = 'clipboard-write';
|
|
251
|
+
// `colorScheme` + `background` on the iframe ELEMENT defend against a
|
|
252
|
+
// flash through the OS color while the embed HTML + JS loads: until the
|
|
253
|
+
// embed page's `applyEmbedTheme()` pins `color-scheme` on its own
|
|
254
|
+
// `<html>`, the browser paints the iframe's default background from the OS
|
|
255
|
+
// `prefers-color-scheme` (a black flash on a dark-OS phone opening a
|
|
256
|
+
// light modal). Pinning here paints the resolved color before any request
|
|
257
|
+
// leaves the wire. `'auto'` follows OS preference, matching the embed page
|
|
258
|
+
// once loaded.
|
|
259
|
+
Object.assign(iframe.style, {
|
|
260
|
+
display: 'block',
|
|
261
|
+
border: '0',
|
|
262
|
+
width: '100%',
|
|
263
|
+
height: `${INITIAL_IFRAME_HEIGHT}px`,
|
|
264
|
+
colorScheme: theme === 'auto' ? 'light dark' : resolvedChrome,
|
|
265
|
+
background: tokens.cardBackground,
|
|
266
|
+
});
|
|
267
|
+
const closeButton = document.createElement('button');
|
|
268
|
+
closeButton.type = 'button';
|
|
269
|
+
closeButton.setAttribute('aria-label', 'Close');
|
|
270
|
+
closeButton.textContent = '×';
|
|
271
|
+
Object.assign(closeButton.style, {
|
|
272
|
+
position: 'absolute',
|
|
273
|
+
top: '6px',
|
|
274
|
+
right: '8px',
|
|
275
|
+
width: '28px',
|
|
276
|
+
height: '28px',
|
|
277
|
+
border: '0',
|
|
278
|
+
borderRadius: '6px',
|
|
279
|
+
background: 'transparent',
|
|
280
|
+
color: tokens.closeButtonColor,
|
|
281
|
+
fontSize: '20px',
|
|
282
|
+
lineHeight: '1',
|
|
283
|
+
cursor: 'pointer',
|
|
284
|
+
});
|
|
285
|
+
card.appendChild(closeButton);
|
|
286
|
+
card.appendChild(iframe);
|
|
287
|
+
// Loading spinner over the iframe until the `ready` handshake. Without it
|
|
288
|
+
// the user sees a blank panel for ~300-1000ms while the iframe boots,
|
|
289
|
+
// reading as "the click did nothing". It can't sit BEHIND the iframe
|
|
290
|
+
// because the iframe paints its own background synchronously (the
|
|
291
|
+
// OS-color-flash defense above), so it goes on top and hides on `ready`.
|
|
292
|
+
const loader = document.createElement('div');
|
|
293
|
+
Object.assign(loader.style, {
|
|
294
|
+
position: 'absolute',
|
|
295
|
+
inset: '0',
|
|
296
|
+
display: 'flex',
|
|
297
|
+
alignItems: 'center',
|
|
298
|
+
justifyContent: 'center',
|
|
299
|
+
background: tokens.cardBackground,
|
|
300
|
+
zIndex: '1',
|
|
301
|
+
// Brief fade so the spinner-to-content transition isn't abrupt.
|
|
302
|
+
transition: 'opacity 120ms ease-out',
|
|
303
|
+
});
|
|
304
|
+
const spinner = document.createElement('div');
|
|
305
|
+
Object.assign(spinner.style, {
|
|
306
|
+
width: '32px',
|
|
307
|
+
height: '32px',
|
|
308
|
+
borderRadius: '50%',
|
|
309
|
+
border: `3px solid ${tokens.spinnerTrack}`,
|
|
310
|
+
borderTopColor: tokens.spinnerAccent,
|
|
311
|
+
boxSizing: 'border-box',
|
|
312
|
+
});
|
|
313
|
+
// Web Animations API rather than a `<style>` @keyframes, so no CSS is
|
|
314
|
+
// injected into the host page (which would need scoping to avoid leaking).
|
|
315
|
+
if (typeof spinner.animate === 'function') {
|
|
316
|
+
spinner.animate([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], { duration: 800, iterations: Infinity, easing: 'linear' });
|
|
317
|
+
}
|
|
318
|
+
loader.appendChild(spinner);
|
|
319
|
+
card.appendChild(loader);
|
|
320
|
+
overlay.appendChild(card);
|
|
321
|
+
let settled = false;
|
|
322
|
+
let readyTimer;
|
|
323
|
+
// Where to put the reader back. Dismissal detaches the dialog rather than
|
|
324
|
+
// closing it in place, so this is the launcher's job; a reader left on
|
|
325
|
+
// `<body>` resumes tabbing from the top of the page instead of from the
|
|
326
|
+
// control that opened the surface.
|
|
327
|
+
const opener = resolveOpener();
|
|
328
|
+
function restoreFocus() {
|
|
329
|
+
if (!opener?.isConnected)
|
|
330
|
+
return;
|
|
331
|
+
opener.focus();
|
|
332
|
+
if (document.activeElement === opener)
|
|
333
|
+
return;
|
|
334
|
+
// A host that disabled its trigger for the life of the dialog re-enables
|
|
335
|
+
// it when the launcher settles, which happens after this runs, so the
|
|
336
|
+
// press above landed on a disabled control and did nothing. Try again
|
|
337
|
+
// past the caller's own reaction, and only while nothing has taken focus
|
|
338
|
+
// since, so a host that moves focus itself on the result keeps it.
|
|
339
|
+
setTimeout(() => {
|
|
340
|
+
const active = document.activeElement;
|
|
341
|
+
if (active !== null && active !== document.body)
|
|
342
|
+
return;
|
|
343
|
+
if (opener.isConnected)
|
|
344
|
+
opener.focus();
|
|
345
|
+
}, 0);
|
|
346
|
+
}
|
|
347
|
+
function teardown() {
|
|
348
|
+
window.removeEventListener('message', onMessage);
|
|
349
|
+
window.removeEventListener('keydown', onKeydown, true);
|
|
350
|
+
overlay.removeEventListener('cancel', onCancel);
|
|
351
|
+
if (readyTimer !== undefined)
|
|
352
|
+
clearTimeout(readyTimer);
|
|
353
|
+
if (overlay.open)
|
|
354
|
+
overlay.close();
|
|
355
|
+
overlay.remove();
|
|
356
|
+
restoreFocus();
|
|
357
|
+
}
|
|
358
|
+
function settleResolve(value) {
|
|
359
|
+
if (settled)
|
|
360
|
+
return;
|
|
361
|
+
settled = true;
|
|
362
|
+
teardown();
|
|
363
|
+
resolve(value);
|
|
364
|
+
}
|
|
365
|
+
function settleReject(error) {
|
|
366
|
+
if (settled)
|
|
367
|
+
return;
|
|
368
|
+
settled = true;
|
|
369
|
+
teardown();
|
|
370
|
+
reject(error);
|
|
371
|
+
}
|
|
372
|
+
function onMessage(event) {
|
|
373
|
+
// Origin + source pinning: only the embed page we opened, in the
|
|
374
|
+
// dashboard origin, can drive this handshake.
|
|
375
|
+
if (event.origin !== dashboardOrigin)
|
|
376
|
+
return;
|
|
377
|
+
if (event.source !== iframe.contentWindow)
|
|
378
|
+
return;
|
|
379
|
+
const data = event.data;
|
|
380
|
+
if (!data || data[ENVELOPE] !== PROTOCOL_VERSION)
|
|
381
|
+
return;
|
|
382
|
+
switch (data['type']) {
|
|
383
|
+
case 'ready': {
|
|
384
|
+
if (readyTimer !== undefined)
|
|
385
|
+
clearTimeout(readyTimer);
|
|
386
|
+
iframe.contentWindow?.postMessage({ [ENVELOPE]: PROTOCOL_VERSION, type: 'init', surface, params }, dashboardOrigin);
|
|
387
|
+
// Embed page mounted; fade the loader out (delayed removal so the
|
|
388
|
+
// last 120ms is a fade, not a snap).
|
|
389
|
+
loader.style.opacity = '0';
|
|
390
|
+
window.setTimeout(() => {
|
|
391
|
+
loader.style.display = 'none';
|
|
392
|
+
}, 150);
|
|
393
|
+
// Hand the keyboard to the embed now that there is something inside
|
|
394
|
+
// to receive it: opening the dialog leaves focus on the close
|
|
395
|
+
// button, which is the right place to wait while the frame boots and
|
|
396
|
+
// the wrong place to stay once it has. Only when the reader has not
|
|
397
|
+
// moved in the meantime, since taking focus back off a control they
|
|
398
|
+
// tabbed to during the load is worse than leaving it where it is.
|
|
399
|
+
if (document.activeElement === closeButton)
|
|
400
|
+
iframe.focus();
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
case 'resize': {
|
|
404
|
+
const height = data['height'];
|
|
405
|
+
if (typeof height === 'number' && Number.isFinite(height)) {
|
|
406
|
+
const clamped = Math.min(MAX_IFRAME_HEIGHT, Math.max(MIN_IFRAME_HEIGHT, Math.round(height)));
|
|
407
|
+
iframe.style.height = `${clamped}px`;
|
|
408
|
+
}
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
case 'export-request': {
|
|
412
|
+
// The Download button asks this host to serialise its open doc. The
|
|
413
|
+
// docId is the surface's own param, not the child's to choose, so
|
|
414
|
+
// the app never serialises a doc the page wasn't opened for. The
|
|
415
|
+
// dialog stays open; do not settle the promise.
|
|
416
|
+
const requestId = data['requestId'];
|
|
417
|
+
const docId = typeof params['docId'] === 'string' ? params['docId'] : '';
|
|
418
|
+
const reply = (outcome) => {
|
|
419
|
+
iframe.contentWindow?.postMessage({ [ENVELOPE]: PROTOCOL_VERSION, type: 'export-result', requestId, ...outcome }, dashboardOrigin);
|
|
420
|
+
};
|
|
421
|
+
if (!onExportRequest) {
|
|
422
|
+
reply({ ok: false, code: 'no_serializer', message: 'Export is not supported.' });
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
void onExportRequest(docId).then(reply, (err) => {
|
|
426
|
+
reply({
|
|
427
|
+
ok: false,
|
|
428
|
+
code: 'serializer_failed',
|
|
429
|
+
message: err instanceof Error ? err.message : 'Serializer failed.',
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
case 'request-sign-in': {
|
|
435
|
+
// The user clicked the refusal surface's sign-in action. Settle the
|
|
436
|
+
// launcher first (this dialog is over; the host is about to
|
|
437
|
+
// navigate) so the caller's promise does not dangle on a page that
|
|
438
|
+
// is going away, then run the host's sign-in.
|
|
439
|
+
settleResolve(null);
|
|
440
|
+
onSignInRequested?.();
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
case 'document-deleted':
|
|
444
|
+
case 'document-delete-failed': {
|
|
445
|
+
// The delete surface reports that the local user is deleting a doc
|
|
446
|
+
// (or that the attempt failed). Fire-and-forget: the dialog settles
|
|
447
|
+
// separately through its own `result` / `cancel`, so do not settle
|
|
448
|
+
// the promise here.
|
|
449
|
+
const docId = data['docId'];
|
|
450
|
+
if (onLocalDelete && typeof docId === 'string' && docId) {
|
|
451
|
+
onLocalDelete(docId, data['type'] === 'document-deleted');
|
|
452
|
+
}
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
case 'result': {
|
|
456
|
+
settleResolve({ payload: data['payload'] });
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
case 'cancel': {
|
|
460
|
+
settleResolve(null);
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
case 'error': {
|
|
464
|
+
const message = typeof data['message'] === 'string' && data['message']
|
|
465
|
+
? data['message']
|
|
466
|
+
: 'The hosted UI could not be opened.';
|
|
467
|
+
settleReject(new BrassError(message, { code: 'embed_error' }));
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
default:
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// The close button dismisses. A backdrop click does NOT: these surfaces
|
|
475
|
+
// hold in-progress work (connecting a drive, sharing, picking a
|
|
476
|
+
// destination), so a stray click outside the card must not throw it away.
|
|
477
|
+
// Dismissal is the explicit close button or Escape only.
|
|
478
|
+
closeButton.addEventListener('click', () => settleResolve(null));
|
|
479
|
+
// Escape dismisses too, matching the close button. This host-side
|
|
480
|
+
// listener fires for an Escape landing on the modal chrome (the close
|
|
481
|
+
// button) or the host page behind it; an Escape pressed while focus is
|
|
482
|
+
// inside the cross-origin iframe never reaches the host, so the embed
|
|
483
|
+
// page relays it as a `cancel` over the bridge. Capture phase so the
|
|
484
|
+
// foreground modal wins over any Escape handler the host app registered.
|
|
485
|
+
function onKeydown(event) {
|
|
486
|
+
if (event.key === 'Escape' && !settled) {
|
|
487
|
+
event.stopPropagation();
|
|
488
|
+
settleResolve(null);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
window.addEventListener('keydown', onKeydown, true);
|
|
492
|
+
// An open dialog answers Escape itself, and closing it that way would
|
|
493
|
+
// leave the launcher's promise unsettled. Route it into the one dismissal
|
|
494
|
+
// path instead; `onKeydown` above normally gets there first, so this is
|
|
495
|
+
// the backstop for an Escape it does not see.
|
|
496
|
+
function onCancel(event) {
|
|
497
|
+
event.preventDefault();
|
|
498
|
+
settleResolve(null);
|
|
499
|
+
}
|
|
500
|
+
overlay.addEventListener('cancel', onCancel);
|
|
501
|
+
window.addEventListener('message', onMessage);
|
|
502
|
+
const readyTimeoutMs = unavailable === 'redirect'
|
|
503
|
+
? READY_TIMEOUT_SHORT_MS
|
|
504
|
+
: READY_TIMEOUT_LONG_MS;
|
|
505
|
+
readyTimer = setTimeout(() => {
|
|
506
|
+
if (unavailable !== 'reject') {
|
|
507
|
+
// Graceful fallback: tear down the never-loaded modal and navigate to
|
|
508
|
+
// the standalone page. The promise never settles (this page is about
|
|
509
|
+
// to be replaced).
|
|
510
|
+
if (settled)
|
|
511
|
+
return;
|
|
512
|
+
settled = true;
|
|
513
|
+
teardown();
|
|
514
|
+
redirectToStandalone(baseUrl, surface, params, theme);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
settleReject(new BrassError('The Brass hosted UI did not load. This browser or app context ' +
|
|
518
|
+
'may block third-party iframes.', { code: 'iframe_unavailable' }));
|
|
519
|
+
}, readyTimeoutMs);
|
|
520
|
+
document.body.appendChild(overlay);
|
|
521
|
+
// The call that makes the dialog modal: without it the chrome paints
|
|
522
|
+
// identically and Tab keeps walking the host app behind it. Guarded so a
|
|
523
|
+
// browser without the top layer still gets the chrome, non-modal, rather
|
|
524
|
+
// than no dialog at all.
|
|
525
|
+
if (typeof overlay.showModal === 'function')
|
|
526
|
+
overlay.showModal();
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
export function createHostedUiLauncher(opts) {
|
|
530
|
+
const baseUrl = opts.dashboardBaseUrl.replace(/\/$/, '');
|
|
531
|
+
// Parsed once for the postMessage origin checks; a malformed
|
|
532
|
+
// `dashboardBaseUrl` throws here, at construction, not mid-handshake.
|
|
533
|
+
const dashboardOrigin = new URL(baseUrl).origin;
|
|
534
|
+
// Watch for activations from here on, so a surface opened later knows which
|
|
535
|
+
// control the reader pressed to get there.
|
|
536
|
+
trackHostedUiActivations();
|
|
537
|
+
// Default `'light'`: most hosts are light-only, and an `'auto'` default
|
|
538
|
+
// would put a dark embed inside a light host on a dark-OS user.
|
|
539
|
+
const theme = opts.theme ?? 'light';
|
|
540
|
+
// Merge the SDK's fallback session pointer into a surface's bridge params
|
|
541
|
+
// when this browser blocked the cross-site cookie (so the embed page can
|
|
542
|
+
// refresh the dashboard's tokens without its own framed cookie). Read at
|
|
543
|
+
// open time, not launcher-construction time, since the sid is populated by
|
|
544
|
+
// the first fallback refresh, which may land after the launcher is built.
|
|
545
|
+
// Every embed surface authenticates the same way, so this is cross-cutting
|
|
546
|
+
// rather than per-surface like `appId`.
|
|
547
|
+
const withSession = (params) => {
|
|
548
|
+
const sid = opts.getSessionToken?.();
|
|
549
|
+
const actingSub = opts.getActingSub?.();
|
|
550
|
+
return {
|
|
551
|
+
...params,
|
|
552
|
+
...(sid ? { sessionToken: sid } : {}),
|
|
553
|
+
...(actingSub ? { actingSub } : {}),
|
|
554
|
+
};
|
|
555
|
+
};
|
|
556
|
+
// Open a hosted surface and unwrap its bridge outcome: `null` is a user
|
|
557
|
+
// dismissal, otherwise the payload. `exportDoc` threads the export
|
|
558
|
+
// serializer for the document surfaces whose page offers a Download
|
|
559
|
+
// action.
|
|
560
|
+
const launch = async (surface, params, unavailable, exportDoc) => {
|
|
561
|
+
const outcome = await openEmbed(baseUrl, dashboardOrigin, surface, withSession(params), unavailable, theme, exportDoc ? opts.serializeForExport : undefined,
|
|
562
|
+
// Only the document-settings / share surface deletes; harmless to pass
|
|
563
|
+
// for all, since the message arrives only from that surface.
|
|
564
|
+
opts.onLocalDelete, opts.onSignInRequested);
|
|
565
|
+
if (!outcome)
|
|
566
|
+
return null;
|
|
567
|
+
return outcome.payload;
|
|
568
|
+
};
|
|
569
|
+
// Every Brass app shares one sign-in client, so a request's JWT
|
|
570
|
+
// does not say which app made it. Surfaces that must name the caller
|
|
571
|
+
// (its scopes on the install screen, its documents in the picker, the app
|
|
572
|
+
// a share link should open in) read this off the bridge instead.
|
|
573
|
+
const callerAppParam = () => opts.callingAppId !== undefined ? { appId: opts.callingAppId } : {};
|
|
574
|
+
return {
|
|
575
|
+
async selectDocument() {
|
|
576
|
+
// Thread the calling app id over the bridge (every Brass app shares one
|
|
577
|
+
// sign-in client, so the origin can't otherwise tell which
|
|
578
|
+
// app opened the picker). The embed page uses it for a "only documents
|
|
579
|
+
// this app can open" filter.
|
|
580
|
+
const params = {};
|
|
581
|
+
if (opts.callingAppId !== undefined)
|
|
582
|
+
params['appId'] = opts.callingAppId;
|
|
583
|
+
// Where the iframe can't be hosted (cookie-partitioning browsers), the
|
|
584
|
+
// picker falls back to a full-page redirect: the standalone page returns
|
|
585
|
+
// the pick on these one-shot params, which the SDK consumes on boot to
|
|
586
|
+
// open the document, first-party and top-level, the context the iframe
|
|
587
|
+
// fails in. A returned value can't survive that navigation, so the raw
|
|
588
|
+
// promise resolves only on the in-iframe path; the pick otherwise
|
|
589
|
+
// arrives through the session.
|
|
590
|
+
//
|
|
591
|
+
// That fallback ejects the user from the host app, so the picker takes
|
|
592
|
+
// the long handshake window (`redirect-with-value`): degrading early on
|
|
593
|
+
// a slow-but-working iframe costs the user their place for nothing.
|
|
594
|
+
const pickReturn = opts.pickReturn;
|
|
595
|
+
if (pickReturn) {
|
|
596
|
+
params['docParam'] = pickReturn.docParam;
|
|
597
|
+
params['branchParam'] = pickReturn.branchParam;
|
|
598
|
+
}
|
|
599
|
+
return launch('select-document', params, pickReturn ? 'redirect-with-value' : 'reject');
|
|
600
|
+
},
|
|
601
|
+
async selectCreateDestination(options) {
|
|
602
|
+
// The `select-document` surface opened on its New panel via
|
|
603
|
+
// `intent: 'create'`: the embed page resolves a destination and sends
|
|
604
|
+
// it back instead of creating the doc, so the create stays on the SDK's
|
|
605
|
+
// credential path. `name` pre-fills the dialog.
|
|
606
|
+
const params = { intent: 'create' };
|
|
607
|
+
if (opts.callingAppId !== undefined)
|
|
608
|
+
params['appId'] = opts.callingAppId;
|
|
609
|
+
if (options?.name !== undefined)
|
|
610
|
+
params['name'] = options.name;
|
|
611
|
+
return launch('select-document', params, 'reject');
|
|
612
|
+
},
|
|
613
|
+
async acquireOrganization() {
|
|
614
|
+
const params = {};
|
|
615
|
+
if (opts.callingAppId !== undefined)
|
|
616
|
+
params['appId'] = opts.callingAppId;
|
|
617
|
+
return launch('acquire-organization', params, 'reject');
|
|
618
|
+
},
|
|
619
|
+
async shareDocument(docId) {
|
|
620
|
+
// The share-focused dialog, on the same surface `documentSettings`
|
|
621
|
+
// reaches with `focus: 'share'`. It discards the outcome, so it passes
|
|
622
|
+
// no `canOpenCopy`: that flag says the caller will route a duplicate's
|
|
623
|
+
// `newDocId` into `openSiblingDoc`, and this one cannot.
|
|
624
|
+
await launch('share-document', { ...callerAppParam(), docId, focus: 'share' }, 'redirect', true);
|
|
625
|
+
},
|
|
626
|
+
async documentSettings(docId, options) {
|
|
627
|
+
// The embed page reads `focus` from `init.params`. Defaults to
|
|
628
|
+
// `'share'` so passing only docId matches the `shareDocument(docId)`
|
|
629
|
+
// surface.
|
|
630
|
+
const focus = options?.focus ?? 'share';
|
|
631
|
+
return launch(
|
|
632
|
+
// The surface names one dialog with two entry points, and it is what
|
|
633
|
+
// the overlay carries as `data-brass-hosted-ui`, so it follows what
|
|
634
|
+
// is on screen rather than which method was called. A host that
|
|
635
|
+
// selects the share dialog matches it whether the app opened it or
|
|
636
|
+
// the toolbar's Share did.
|
|
637
|
+
focus === 'share' ? 'share-document' : 'document-settings',
|
|
638
|
+
// `canOpenCopy` tells the surface this caller routes a duplicate's
|
|
639
|
+
// `newDocId` into `openSiblingDoc`. The hosted page is served
|
|
640
|
+
// fresh against pinned SDKs, so it offers the Open only to a host
|
|
641
|
+
// that says it will honour it.
|
|
642
|
+
{ ...callerAppParam(), docId, focus, canOpenCopy: true }, 'redirect', true);
|
|
643
|
+
},
|
|
644
|
+
async documentHistory(docId, options) {
|
|
645
|
+
const params = { docId };
|
|
646
|
+
if (options?.branch !== undefined)
|
|
647
|
+
params['branch'] = options.branch;
|
|
648
|
+
return launch('document-history', params, 'reject');
|
|
649
|
+
},
|
|
650
|
+
async selectBranch(docId, options) {
|
|
651
|
+
const params = { docId };
|
|
652
|
+
if (options?.branch !== undefined)
|
|
653
|
+
params['branch'] = options.branch;
|
|
654
|
+
return launch('select-branch', params, 'reject');
|
|
655
|
+
},
|
|
656
|
+
async installApp(organizationId) {
|
|
657
|
+
// Thread the caller's `appId` over the bridge so the embed page renders
|
|
658
|
+
// the right app's name + scopes (every Brass app shares one sign-in
|
|
659
|
+
// user-pool client, so the JWT can't disambiguate). On confirm the
|
|
660
|
+
// embed page POSTs the install server-side and forwards the response.
|
|
661
|
+
const params = {};
|
|
662
|
+
if (opts.callingAppId !== undefined)
|
|
663
|
+
params['appId'] = opts.callingAppId;
|
|
664
|
+
if (organizationId !== undefined)
|
|
665
|
+
params['organizationId'] = organizationId;
|
|
666
|
+
return launch('install-app', params, 'reject');
|
|
667
|
+
},
|
|
668
|
+
async driveResolve(docId) {
|
|
669
|
+
return launch('drive-resolve', { docId }, 'reject');
|
|
670
|
+
},
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
//# sourceMappingURL=hosted-ui.js.map
|