@displayxr/inline3d 1.2.1 → 1.4.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.
@@ -45,6 +45,12 @@
45
45
  // in front), and render `eye.camera` directly. No per-frame world scaling — that is the whole
46
46
  // point of using the rig instead of re-deriving it in the app, and it mirrors the native
47
47
  // reference apps (cube_handle), which supply one scale number and consume render-ready views.
48
+ //
49
+ // VIEW RIGS. virtualDisplayHeight is one number out of a whole descriptor. cameraRigFromCamera()
50
+ // and displayRig() below build the full thing — a posed portal, or an app CAMERA whose frustum
51
+ // the runtime perturbs with the viewer's eyes — for handle.setViewRig(). They fill in a
52
+ // descriptor and nothing else: no Kooima, no off-axis math, no scale, here or anywhere in this
53
+ // SDK. That stays in the runtime, which is the point of the extension.
48
54
 
49
55
  /**
50
56
  * A reusable three.js camera driven directly by an XRView's matrices. Construct once with
@@ -89,6 +95,188 @@ export class EyeCamera {
89
95
  cam.matrixWorldInverse.copy(cam.matrixWorld).invert();
90
96
  return cam;
91
97
  }
98
+
99
+ /** Set the camera's projection + LOCAL pose from an XRView — the attach pattern below. */
100
+ setLocalFromView(view) {
101
+ return this.setLocalFromMatrices(view.projectionMatrix, view.transform.matrix);
102
+ }
103
+
104
+ /**
105
+ * Like setFromMatrices, but the view's transform is written as the camera's LOCAL matrix and
106
+ * three composes `matrixWorld` from the parent — so the eye can hang off another object.
107
+ *
108
+ * WHY THIS EXISTS: the browser locates views BEFORE the page's rAF, so a view rig set during
109
+ * frame N drives the views delivered in frame N+1. Send a camera rig with an IDENTITY pose
110
+ * instead, parent both eye cameras under your app camera object, and the runtime's job shrinks
111
+ * to what it is uniquely good at (the eye offsets and the tracking-perturbed frustum, in rig
112
+ * space) while the app's own scene graph supplies the world pose — this frame's, not last
113
+ * frame's. A camera whipping around under the pointer then has zero rig lag.
114
+ *
115
+ * That is a SCENE-GRAPH parent and nothing more. No projection math moves into the page: the
116
+ * projectionMatrix is still the runtime's, untouched, and the local transform is still the eye
117
+ * pose the runtime reported — it is simply interpreted in rig space rather than world space,
118
+ * which is exactly what an identity-posed rig means.
119
+ *
120
+ * appCamera.add(eyeL.camera); appCamera.add(eyeR.camera); // once
121
+ * handle.setViewRig(cameraRigFromCamera(THREE, appCamera, { attach: true, convergence }));
122
+ * eyeL.setLocalFromView(views[0]); // per frame
123
+ *
124
+ * `matrixAutoUpdate` is false (the matrix is ours, not three's) but that does NOT opt out of
125
+ * world composition: `updateMatrixWorld` still multiplies parent × local. So the eye cameras
126
+ * must be reached by a normal traversal — `renderer.render(scene, eye.camera)` only
127
+ * auto-updates a camera whose `parent` is null, so make sure the app camera is IN the scene
128
+ * (or call `scene.updateMatrixWorld()` yourself) or the eyes will render at a stale pose.
129
+ *
130
+ * @param {ArrayLike<number>} projectionMatrix 16 floats, column-major (view.projectionMatrix).
131
+ * @param {ArrayLike<number>} transformMatrix 16 floats, column-major (view.transform.matrix),
132
+ * read as a pose in the RIG's space.
133
+ */
134
+ setLocalFromMatrices(projectionMatrix, transformMatrix) {
135
+ const cam = this.camera;
136
+ cam.projectionMatrix.fromArray(projectionMatrix);
137
+ cam.projectionMatrixInverse.copy(cam.projectionMatrix).invert();
138
+ cam.matrix.fromArray(transformMatrix);
139
+ // Hand the world matrices back to three. Marking the flag is the whole handshake: with
140
+ // matrixAutoUpdate off, nothing else tells updateMatrixWorld that the local matrix moved,
141
+ // and a parent that happens not to move that frame would leave the eye at its old world
142
+ // pose (Camera.updateMatrixWorld re-derives matrixWorldInverse from matrixWorld, so both
143
+ // stay consistent once it runs).
144
+ cam.matrixWorldNeedsUpdate = true;
145
+ return cam;
146
+ }
147
+ }
148
+
149
+ // Scratch for cameraRigFromCamera's decompose. Module-scoped and lazily built from the caller's
150
+ // THREE, so a per-frame rig costs no allocation — the values are copied straight out into the
151
+ // descriptor before anything else can observe them, so sharing is safe.
152
+ let _scratch = null;
153
+ function scratch(THREE) {
154
+ if (!_scratch) {
155
+ _scratch = { p: new THREE.Vector3(), q: new THREE.Quaternion(), s: new THREE.Vector3() };
156
+ }
157
+ return _scratch;
158
+ }
159
+
160
+ /**
161
+ * Build a CAMERA-rig descriptor from a three.js PerspectiveCamera.
162
+ *
163
+ * A camera rig says "here is an app camera; perturb its frustum with the viewer's eyes" — the
164
+ * runtime keeps your vertical FOV, offsets the eyes, and skews each frustum so the convergence
165
+ * distance lands on the zero-disparity plane. Contrast the DISPLAY rig ({@link displayRig}),
166
+ * which says "the canvas is a portal onto a virtual display this tall". Neither computes
167
+ * anything here: this function only fills in a descriptor, and every off-axis projection stays
168
+ * in the runtime, where it is the same code the native apps use.
169
+ *
170
+ * CONVERGENCE IS THE ONE KNOB TO GET RIGHT. It is the distance at which content sits ON the
171
+ * glass; everything nearer pops out, everything further recedes. Point it at whatever the viewer
172
+ * is meant to be looking at (an orbit target, a hit-tested surface) — for an orbiting camera
173
+ * that is usually just the orbit radius. Left at 0 it means infinity, which puts the entire
174
+ * scene in front of the display and is comfortable for almost nothing.
175
+ *
176
+ * COMFORT. The runtime's rule is `ipdFactor × metersToVirtual × convergenceDiopters × N <= 1`
177
+ * (N = nominal viewing distance, ~0.5 m): at 1 the viewer's eyes are parallel on infinitely far
178
+ * content, and past it they diverge, which no one can fuse. With the defaults (factors 1,
179
+ * metersToVirtual 1) that is `convergence >= ~0.5` world units. Nothing here enforces it — the
180
+ * runtime clamps out-of-range values itself, once, with a warning — but a scene authored in
181
+ * centimetres with a 0.1-unit convergence is the shape of the mistake.
182
+ *
183
+ * @param {object} THREE your imported three.js module namespace.
184
+ * @param {object} camera a THREE.PerspectiveCamera (`.fov` in degrees, `.matrixWorld` current).
185
+ * @param {object} [opts]
186
+ * @param {number} [opts.convergence=0] zero-disparity distance in WORLD units (0 = infinity).
187
+ * @param {boolean} [opts.attach=false] emit an IDENTITY pose, for the attach pattern above —
188
+ * you parent the eye cameras under this camera and three supplies the world pose.
189
+ * @param {number} [opts.ipdFactor=1] eye separation, ABSOLUTE on a camera rig (world units per
190
+ * metre of real IPD); 0 collapses to mono.
191
+ * @param {number} [opts.parallaxFactor=1] how far the rig tracks head motion, absolute likewise.
192
+ * @param {number} [opts.metersToVirtual=1] metres → world units on the eye.
193
+ * @param {object} [opts.out] a descriptor object to overwrite instead of allocating one.
194
+ * @returns {object} an XRViewRigInit-shaped plain object.
195
+ */
196
+ export function cameraRigFromCamera(THREE, camera, opts = {}) {
197
+ const {
198
+ convergence = 0,
199
+ attach = false,
200
+ ipdFactor = 1,
201
+ parallaxFactor = 1,
202
+ metersToVirtual = 1,
203
+ out = {},
204
+ } = opts;
205
+ out.type = 'camera';
206
+ if (attach) {
207
+ // Identity pose: the rig IS the camera, so the runtime reports eyes in camera space and the
208
+ // scene graph does the rest. Deliberately not "the camera's pose from a frame ago".
209
+ out.position = { x: 0, y: 0, z: 0 };
210
+ out.orientation = { x: 0, y: 0, z: 0, w: 1 };
211
+ } else {
212
+ // World pose, decomposed from the matrix rather than read off .position/.quaternion: those
213
+ // are LOCAL, and an app camera parented under a rig/dolly (the usual way to build an orbit)
214
+ // would then send the runtime a pose in the wrong space.
215
+ camera.updateMatrixWorld();
216
+ const { p, q, s } = scratch(THREE);
217
+ camera.matrixWorld.decompose(p, q, s);
218
+ out.position = { x: p.x, y: p.y, z: p.z };
219
+ out.orientation = { x: q.x, y: q.y, z: q.z, w: q.w };
220
+ }
221
+ out.ipdFactor = ipdFactor;
222
+ out.parallaxFactor = parallaxFactor;
223
+ // Diopters, not distance: the wire unit is 1/distance so that "infinity" is representable as
224
+ // a finite 0 instead of a sentinel.
225
+ out.convergenceDiopters = convergence > 0 ? 1 / convergence : 0;
226
+ out.verticalFov = THREE.MathUtils.degToRad(camera.fov); // three's fov is the FULL angle, in degrees
227
+ out.metersToVirtual = metersToVirtual;
228
+ return out;
229
+ }
230
+
231
+ /**
232
+ * Build a DISPLAY-rig descriptor — the default rig, made explicit and posable.
233
+ *
234
+ * The display rig treats the canvas as a PORTAL: the element's plane is world z = 0 and the
235
+ * viewer looks through it at a virtual display `virtualDisplayHeight` metres tall (the m2v knob
236
+ * `addScene`'s scalar option sets). This adds what the scalar cannot say — a pose, so the portal
237
+ * can be tilted or offset, and the three factors, so eye separation, head-tracking response and
238
+ * perspective strength can be dialled independently.
239
+ *
240
+ * The factors are RELATIVE here (unlike a camera rig, where ipd/parallax are absolute):
241
+ * `ipdFactor` and `parallaxFactor` are [0,1] multipliers on what the display would naturally do
242
+ * — 1 is correct-by-construction, 0 is flat/frozen, and the values between are a comfort dial,
243
+ * not a correctness one. `perspectiveFactor` is [0.1,10] and exaggerates or flattens the
244
+ * off-axis skew; it is the one knob with no physical justification, so treat it as an effect.
245
+ * The runtime clamps anything out of range (once, with a warning) rather than refusing the rig.
246
+ *
247
+ * @param {object} [opts]
248
+ * @param {number} [opts.virtualDisplayHeight=0.24] metres of virtual display (the zoom knob).
249
+ * @param {{x?:number,y?:number,z?:number}} [opts.position] rig pose, app world units.
250
+ * @param {{x?:number,y?:number,z?:number,w?:number}} [opts.orientation] rig orientation quat.
251
+ * @param {number} [opts.ipdFactor=1] [opts.parallaxFactor=1] [opts.perspectiveFactor=1]
252
+ * @param {object} [opts.out] a descriptor object to overwrite instead of allocating one.
253
+ * @returns {object} an XRViewRigInit-shaped plain object.
254
+ */
255
+ export function displayRig(opts = {}) {
256
+ const {
257
+ virtualDisplayHeight = 0.24,
258
+ position = { x: 0, y: 0, z: 0 },
259
+ orientation = { x: 0, y: 0, z: 0, w: 1 },
260
+ ipdFactor = 1,
261
+ parallaxFactor = 1,
262
+ perspectiveFactor = 1,
263
+ out = {},
264
+ } = opts;
265
+ out.type = 'display';
266
+ // Copied field by field, not aliased: a caller reusing `out` every frame must not end up
267
+ // holding a live reference to a THREE.Vector3 it is also mutating.
268
+ out.position = { x: position.x || 0, y: position.y || 0, z: position.z || 0 };
269
+ out.orientation = {
270
+ x: orientation.x || 0,
271
+ y: orientation.y || 0,
272
+ z: orientation.z || 0,
273
+ w: orientation.w === undefined ? 1 : orientation.w,
274
+ };
275
+ out.virtualDisplayHeight = virtualDisplayHeight;
276
+ out.ipdFactor = ipdFactor;
277
+ out.parallaxFactor = parallaxFactor;
278
+ out.perspectiveFactor = perspectiveFactor;
279
+ return out;
92
280
  }
93
281
 
94
282
  /**
@@ -0,0 +1,251 @@
1
+ // inline3d-undock.js — lift a window's 3D asset OUT of the page into a floating, transparent,
2
+ // click-through native viewer over the desktop. Dependency-free, and usable on its own.
3
+ //
4
+ // TWO PATHS, ONE CONTRACT. Where the browser exposes `XRDisplayLayer.undock()` the request goes
5
+ // straight through it: the layer already knows its element's rect, and the viewer opens with no
6
+ // prompt. Everywhere else the page spawns the same viewer through the `displayxr-view:` OS
7
+ // protocol — the spawn primitive every browser hands a page (Chrome asks once, "Open DisplayXR
8
+ // …?", with an "Always allow" tick). The URL grammar below is the contract both paths share, and
9
+ // it is the one parsed by displayxr-common's `launch_args.h`.
10
+ //
11
+ // WHY A NATIVE PROCESS AND NOT A WINDOW. The browser's inline-3D weave is bound to ONE window per
12
+ // process and hands back opaque pixels into the page's own compositing, so no browser window can
13
+ // be the transparent floating one. The floating window is a native process; the page's only job
14
+ // is to spawn it with the asset URL and the tile's screen rect.
15
+ //
16
+ // NOTHING HERE TOUCHES THE TILE. Undock READS an element's rect and never writes to it, so it
17
+ // cannot disturb a woven window — the button that calls it lives in the page's own chrome.
18
+
19
+ /** @typedef {'model'|'splat'} UndockType */
20
+
21
+ /**
22
+ * How the API-first path finds the live `XRDisplayLayer` behind an element. `inline3d.js`
23
+ * registers one when it is imported (only it knows the canvas -> layer map); with no resolver —
24
+ * this module used standalone — every call takes the protocol fallback, which is the correct
25
+ * degradation rather than a failure.
26
+ *
27
+ * @param {(el: Element) => object|null} fn
28
+ */
29
+ let layerResolver = null;
30
+ export function setUndockLayerResolver(fn) {
31
+ layerResolver = typeof fn === 'function' ? fn : null;
32
+ }
33
+
34
+ /**
35
+ * True where a native DisplayXR viewer can exist at all. The viewers are Windows-only today, so
36
+ * this is a PLATFORM probe, not a capability one — a page uses it to decide whether to show an
37
+ * undock affordance. It says nothing about whether the viewer is installed: that is only knowable
38
+ * when the launch is attempted (a `not-installed` Error on the API path; silently nothing on the
39
+ * protocol path, which is exactly why the API path is worth having).
40
+ */
41
+ export function undockAvailable() {
42
+ if (typeof navigator === 'undefined') return false;
43
+ const uad = navigator.userAgentData;
44
+ if (uad && uad.platform) return uad.platform === 'Windows';
45
+ return /Windows/i.test(navigator.userAgent);
46
+ }
47
+
48
+ /**
49
+ * The element's rect in PHYSICAL SCREEN pixels — the space the viewer places its window in.
50
+ *
51
+ * `screenX/Y` and `outerWidth/Height` are CSS px in the browser's own DIP space; `devicePixelRatio`
52
+ * folds the OS scale AND the page zoom together. At 100 % zoom the arithmetic is exact; with page
53
+ * zoom it drifts by the zoom factor, and the viewer clamps the rect into the panel anyway. `dpr`
54
+ * travels along so a calibration session can read both numbers from the viewer's log instead of
55
+ * reverse-engineering the DIP space.
56
+ *
57
+ * @param {Element} el
58
+ * @returns {{x:number, y:number, w:number, h:number, dpr:number}}
59
+ */
60
+ export function tileScreenRect(el) {
61
+ const r = el.getBoundingClientRect();
62
+ const dpr = window.devicePixelRatio || 1;
63
+ const chromeX = Math.max(0, (window.outerWidth - window.innerWidth) / 2);
64
+ const chromeY = Math.max(0, window.outerHeight - window.innerHeight);
65
+ return {
66
+ x: Math.round((window.screenX + chromeX + r.left) * dpr),
67
+ y: Math.round((window.screenY + chromeY + r.top) * dpr),
68
+ w: Math.max(64, Math.round(r.width * dpr)),
69
+ h: Math.max(64, Math.round(r.height * dpr)),
70
+ dpr,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * The `displayxr-view:` URL for undocking `opts` at `el`'s screen rect — the fallback path's
76
+ * whole payload, exported so a page can log or test it without launching anything.
77
+ *
78
+ * displayxr-view://open?src=<pct>&type=model|splat&rect=X,Y,W,H&dpr=<f>&title=<pct>
79
+ * &env=room&pose=<yaw>,<pitch>&margin=<f>&transparent=1&v=1
80
+ *
81
+ * `src` is resolved to an ABSOLUTE url here and must be https (or http on loopback): the viewer
82
+ * refuses file:/UNC/local paths from a protocol launch by design.
83
+ *
84
+ * NO `vh`. The grammar has one, and sending it is wrong: it is a raw pin that DISABLES the
85
+ * viewer's auto-fit, so an asset authored at 0.2 m arrives at native scale in a small window (far
86
+ * too zoomed in). Left unpinned the viewer auto-fits to ~80 % of the window height — the same
87
+ * rule a page's own fit applies to the same pixel box — so the apparent size matches. Apparent
88
+ * size is a property of the window rect, not of vH.
89
+ *
90
+ * @param {Element} el
91
+ * @param {object} opts {src, type, env?, pose?, margin?, title?}
92
+ * @returns {string}
93
+ */
94
+ export function undockUrl(el, opts) {
95
+ const src = new URL(opts.src, window.location.href).href;
96
+ const rect = tileScreenRect(el);
97
+ const pairs = [];
98
+ const put = (k, v) => pairs.push(`${k}=${encodeURIComponent(v)}`);
99
+ put('src', src);
100
+ put('type', opts.type);
101
+ put('rect', `${rect.x},${rect.y},${rect.w},${rect.h}`);
102
+ put('dpr', rect.dpr.toFixed(3));
103
+ if (opts.title) put('title', String(opts.title).slice(0, 64));
104
+ if (opts.env) put('env', opts.env);
105
+ // The page's opening angle: a model seen at yaw -40 has a very different silhouette from the
106
+ // same model face-on, and "it looks bigger undocked" is usually exactly that (the fit rules
107
+ // agree; the pose did not). Same convention as the SDK's setPose({yaw, pitch, zoom}).
108
+ if (opts.pose) {
109
+ const z = opts.pose.zoom !== undefined && opts.pose.zoom !== 1 ? `,${opts.pose.zoom}` : '';
110
+ put('pose', `${opts.pose.yaw},${opts.pose.pitch ?? 0}${z}`);
111
+ }
112
+ if (opts.margin !== undefined) put('margin', String(opts.margin));
113
+ // Transparent is the protocol's default; stated explicitly so the intent is visible in the URL.
114
+ put('transparent', '1');
115
+ put('v', '1');
116
+ // Built by hand rather than with URLSearchParams, which encodes a space as '+' — a form the
117
+ // viewer deliberately does NOT decode. encodeURIComponent is the spec form and never emits '+'.
118
+ return `displayxr-view://open?${pairs.join('&')}`;
119
+ }
120
+
121
+ /**
122
+ * Fire the protocol from a user gesture. A hidden iframe rather than `location.href`: the page
123
+ * never unloads mid-demo, and a "no handler installed" outcome is contained in the frame — which
124
+ * is also why this path can never REPORT that outcome. Chrome only shows the external-protocol
125
+ * dialog under a transient user activation, so it has to run synchronously in the click.
126
+ */
127
+ function launchProtocol(url) {
128
+ const frame = document.createElement('iframe');
129
+ frame.setAttribute('aria-hidden', 'true');
130
+ frame.style.display = 'none';
131
+ frame.src = url;
132
+ document.body.appendChild(frame);
133
+ window.setTimeout(() => frame.remove(), 1500);
134
+ }
135
+
136
+ const UNDOCK_ERRORS = ['not-installed', 'src-not-allowed', 'no-activation', 'busy'];
137
+
138
+ /** Give a rejection one of the four contract names, keeping the browser's own where it has one. */
139
+ function undockError(e, fallbackName, message) {
140
+ const name = e && UNDOCK_ERRORS.includes(e.name) ? e.name : fallbackName;
141
+ const err = new Error(message || (e && e.message) || `[inline3d] undock failed (${name}).`);
142
+ err.name = name;
143
+ if (e) err.cause = e;
144
+ return err;
145
+ }
146
+
147
+ // ONE LIVE UNDOCK AT A TIME. The viewer is a single floating window and the browser refuses a
148
+ // second request while one is in flight ('busy'); the fallback path has no such guard, so the
149
+ // module keeps its own — two protocol launches from one click would spawn two viewers.
150
+ let inFlight = false;
151
+
152
+ /**
153
+ * Undock `target`'s asset into the floating native viewer.
154
+ *
155
+ * CALL IT SYNCHRONOUSLY INSIDE THE CLICK. Both paths need the transient user activation — the
156
+ * API path to be allowed at all (`no-activation`), the fallback to get Chrome's protocol dialog —
157
+ * and an `await` before this call spends it. Nothing here awaits before the launch, so the
158
+ * activation is intact when it matters.
159
+ *
160
+ * @param {Element} target the element whose SCREEN RECT the viewer opens over (the tile).
161
+ * @param {object} opts
162
+ * @param {string} opts.src absolute https URL (or http on loopback) of the asset.
163
+ * @param {UndockType} opts.type
164
+ * @param {'room'|'studio'|'sky'|'none'} [opts.env] lighting the page rendered with.
165
+ * @param {{yaw:number, pitch?:number, zoom?:number}} [opts.pose] the angle the page opened at.
166
+ * @param {number} [opts.margin] the page's fit margin, when it overrides the default.
167
+ * @param {string} [opts.title]
168
+ * @returns {Promise<{ended:Promise<void>, viewer:UndockType, detached?:boolean}>}
169
+ * `ended` resolves when the viewer exits (fallback path: immediately, with
170
+ * `detached === true` — a protocol launch is fire-and-forget and the page never hears back).
171
+ * Rejects with an Error named `not-installed` | `src-not-allowed` | `no-activation` | `busy`.
172
+ */
173
+ export function undock(target, opts) {
174
+ if (!target || typeof target.getBoundingClientRect !== 'function') {
175
+ return Promise.reject(new TypeError('[inline3d] undock() takes an Element and options.'));
176
+ }
177
+ if (!opts || typeof opts.src !== 'string' || !opts.src) {
178
+ return Promise.reject(new TypeError('[inline3d] undock() needs opts.src (an absolute URL).'));
179
+ }
180
+ if (opts.type !== 'model' && opts.type !== 'splat') {
181
+ return Promise.reject(
182
+ new TypeError(`[inline3d] undock() opts.type is 'model' or 'splat', got ${JSON.stringify(opts.type)}.`)
183
+ );
184
+ }
185
+ if (inFlight) return Promise.reject(undockError(null, 'busy', '[inline3d] an undock is already in flight.'));
186
+
187
+ const layer = layerResolver ? layerResolver(target) : null;
188
+ const viewer = opts.type;
189
+
190
+ // ── fallback: the OS protocol ──────────────────────────────────────────────────────────
191
+ if (!layer || typeof layer.undock !== 'function') {
192
+ let url;
193
+ try {
194
+ url = undockUrl(target, opts);
195
+ } catch (e) {
196
+ return Promise.reject(undockError(e, 'src-not-allowed'));
197
+ }
198
+ launchProtocol(url);
199
+ // Fire-and-forget by construction: the iframe swallows "no handler installed" and nothing
200
+ // comes back from a spawned process, so `ended` is honest only about THIS page's part being
201
+ // over. `detached` is how a caller tells the two paths apart.
202
+ return Promise.resolve({ ended: Promise.resolve(), viewer, detached: true, url });
203
+ }
204
+
205
+ // ── API path ───────────────────────────────────────────────────────────────────────────
206
+ // Synchronous, first thing, activation intact. The layer knows its own element rect, so only
207
+ // the content half of the contract travels.
208
+ const init = { src: opts.src, type: opts.type };
209
+ if (opts.env) init.env = opts.env;
210
+ if (opts.pose) init.pose = opts.pose;
211
+ if (opts.margin !== undefined) init.margin = opts.margin;
212
+ if (opts.title) init.title = opts.title;
213
+
214
+ let call;
215
+ try {
216
+ call = Promise.resolve(layer.undock(init));
217
+ } catch (e) {
218
+ // A synchronous throw is the same failure as a rejection; one .catch() should cover both.
219
+ return Promise.reject(undockError(e, 'src-not-allowed'));
220
+ }
221
+ inFlight = true;
222
+ const settled = call.finally(() => {
223
+ inFlight = false;
224
+ });
225
+
226
+ // TWO PROMISE SHAPES, ONE ANSWER. `undock()` either rejects PROMPTLY — every refusal
227
+ // (not-installed / src-not-allowed / no-activation / busy) is decided before any window
228
+ // exists — or it stays pending until the viewer exits. A short race tells them apart without
229
+ // inventing an event: whatever has not rejected by then launched. The launch has already
230
+ // happened synchronously above, so this wait costs the user nothing.
231
+ const LAUNCH_MS = 150;
232
+ const launchProbe = settled.then(
233
+ () => 'launched',
234
+ (e) => {
235
+ throw undockError(e, 'not-installed');
236
+ }
237
+ );
238
+ // The probe LOSES the race whenever the viewer stays open, and a rejection arriving after that
239
+ // would otherwise be an unhandled one. Marked handled here; the same rejection still reaches
240
+ // the caller through `ended`, which is where a late failure belongs.
241
+ launchProbe.catch(() => {});
242
+ return Promise.race([
243
+ launchProbe,
244
+ new Promise((resolve) => window.setTimeout(() => resolve('launched'), LAUNCH_MS)),
245
+ ]).then(() => ({
246
+ // A rejection AFTER the launch window is the viewer failing later, and it belongs on `ended`.
247
+ ended: settled.then(() => undefined),
248
+ viewer,
249
+ detached: false,
250
+ }));
251
+ }