@vincentt-xr/harness 1.3.0 → 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.
- package/dist/client/HarnessProvider.js +13 -1
- package/dist/client/channel.d.ts +47 -0
- package/dist/client/channel.js +135 -0
- package/dist/client/consoleOrigin.d.ts +16 -0
- package/dist/client/consoleOrigin.js +39 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.js +3 -0
- package/dist/client/share.d.ts +14 -3
- package/dist/client/share.js +13 -5
- package/dist/shared/channel.d.ts +60 -0
- package/dist/shared/channel.js +45 -0
- package/package.json +1 -1
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// but both are overridable for the production review-service path later.
|
|
10
10
|
import { useEffect } from "react";
|
|
11
11
|
import { CLIENT_SESSION_ID_RE } from "../shared/events.js";
|
|
12
|
+
import { isFramed } from "./previewOrigin.js";
|
|
12
13
|
function defaultRelayUrl() {
|
|
13
14
|
if (typeof window === "undefined")
|
|
14
15
|
return "ws://localhost:7331";
|
|
@@ -108,7 +109,8 @@ export function HarnessProvider(props) {
|
|
|
108
109
|
//
|
|
109
110
|
// Share defaults to `enabled`. Feedback defaults to `enabled` too (opt OUT
|
|
110
111
|
// with `feedback={false}`); the chip gates itself on the origin at mount, so
|
|
111
|
-
// the flag
|
|
112
|
+
// the flag decides whether the module is fetched at all — as does the
|
|
113
|
+
// framing check hoisted into `mountFeedback` below.
|
|
112
114
|
if (props.share !== false) {
|
|
113
115
|
void import("./share.js").then(({ mountShareButton }) => {
|
|
114
116
|
if (cancelled)
|
|
@@ -126,6 +128,16 @@ export function HarnessProvider(props) {
|
|
|
126
128
|
function mountFeedback() {
|
|
127
129
|
if (props.feedback === false)
|
|
128
130
|
return;
|
|
131
|
+
// THE FRAMING CHECK RUNS BEFORE THE IMPORT, not only inside the mount.
|
|
132
|
+
// `mountFeedbackButton` gates itself too (defence in depth), but that gate
|
|
133
|
+
// runs after the module has already been fetched and evaluated — so the
|
|
134
|
+
// annotation module, which carries an unauthenticated same-origin write
|
|
135
|
+
// path, was being loaded inside the framed document that now holds a
|
|
136
|
+
// camera delegation. Nothing was appended and no chip rendered, so there
|
|
137
|
+
// was no redress target; the code was simply present. Hoisting the check
|
|
138
|
+
// here closes that outright rather than mitigating it.
|
|
139
|
+
if (isFramed())
|
|
140
|
+
return;
|
|
129
141
|
void import("./annotate.js").then(({ mountFeedbackButton }) => {
|
|
130
142
|
if (cancelled)
|
|
131
143
|
return;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type AnnouncedPreset, type ConsoleSetMediaSourceMessage, type HarnessAnnounceMessage } from "../shared/channel.js";
|
|
2
|
+
import { type FramingView } from "./previewOrigin.js";
|
|
3
|
+
/** The minimum of `window` this needs, so the whole module is testable without a DOM. */
|
|
4
|
+
export interface ChannelWindow {
|
|
5
|
+
parent: {
|
|
6
|
+
postMessage(message: unknown, targetOrigin: string): void;
|
|
7
|
+
} | null;
|
|
8
|
+
addEventListener(type: "message", listener: (event: MessageEvent) => void): void;
|
|
9
|
+
removeEventListener(type: "message", listener: (event: MessageEvent) => void): void;
|
|
10
|
+
}
|
|
11
|
+
export interface ChannelOptions {
|
|
12
|
+
/** The presets this app can play. Announced verbatim after validation. */
|
|
13
|
+
presets: AnnouncedPreset[];
|
|
14
|
+
/** Applied when the console names a preset. The app owns the swap; the console never does. */
|
|
15
|
+
onSetMediaSource: (presetId: string) => void;
|
|
16
|
+
/** The console origins commands may come from. Injected; defaults to the production set. */
|
|
17
|
+
allowedOrigins?: readonly string[];
|
|
18
|
+
/** Injectable for tests. Defaults to `window`. */
|
|
19
|
+
view?: ChannelWindow;
|
|
20
|
+
/** Injectable for tests. Defaults to `window`'s own handles. */
|
|
21
|
+
framing?: FramingView;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The announce, built from a preset list.
|
|
25
|
+
*
|
|
26
|
+
* Exported so a test can assert the payload's exact shape without a window, and
|
|
27
|
+
* so `url` never being on the wire is checkable at the boundary that produces it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildAnnounce(presets: AnnouncedPreset[]): HarnessAnnounceMessage;
|
|
30
|
+
/**
|
|
31
|
+
* Is this a command this app acts on?
|
|
32
|
+
*
|
|
33
|
+
* Shape check only — the origin is checked separately and BOTH must pass. Any
|
|
34
|
+
* other `type` on a well-formed envelope is ignored, which is what keeps `ack`,
|
|
35
|
+
* `error`, `log`, `count`, `re-announce` and `request` from becoming reachable
|
|
36
|
+
* by a console that decides to send one.
|
|
37
|
+
*/
|
|
38
|
+
export declare function isSetMediaSourceCommand(data: unknown): data is ConsoleSetMediaSourceMessage;
|
|
39
|
+
/**
|
|
40
|
+
* Open the app's side of the channel. Returns a teardown.
|
|
41
|
+
*
|
|
42
|
+
* UNFRAMED IS A NO-OP. With no console around the app there is nothing to
|
|
43
|
+
* announce to and no chrome to command it, and the app keeps its own in-page
|
|
44
|
+
* controls instead. Nothing is posted and no listener is installed — the same
|
|
45
|
+
* absent-rather-than-inert rule the cluster controls follow.
|
|
46
|
+
*/
|
|
47
|
+
export declare function openConsoleChannel(opts: ChannelOptions): () => void;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// The app side of the console↔app channel.
|
|
2
|
+
//
|
|
3
|
+
// It does two things and refuses everything else: it announces its own presets
|
|
4
|
+
// ONCE on mount, and it listens for `set-media-source`. It never acknowledges,
|
|
5
|
+
// never reports an outcome, never re-announces, never counts, and never requests
|
|
6
|
+
// (B-F13-3). The transport is bidirectional — postMessage has no one-way mode —
|
|
7
|
+
// so the vocabulary in `shared/channel.ts` and the single-send guard below are
|
|
8
|
+
// the only cap there is.
|
|
9
|
+
import { ANNOUNCE_MAX_PRESETS, CHANNEL_PROTOCOL_VERSION, CONSOLE_MESSAGE_SOURCE, HARNESS_MESSAGE_SOURCE, PRESET_ID_RE, PRESET_KINDS, PRESET_MAX_ID_LENGTH, PRESET_MAX_LABEL_LENGTH, } from "../shared/channel.js";
|
|
10
|
+
import { isAllowedConsoleOrigin, PRODUCTION_CONSOLE_ORIGINS } from "./consoleOrigin.js";
|
|
11
|
+
import { isFramed } from "./previewOrigin.js";
|
|
12
|
+
/**
|
|
13
|
+
* Is this a well-formed preset the console can render?
|
|
14
|
+
*
|
|
15
|
+
* Applied to the app's OWN list before it goes out, which is not paranoia: the
|
|
16
|
+
* presets come from the SDK or from creator code, and an oversized or malformed
|
|
17
|
+
* announce is dropped whole by the console rather than partially rendered. A
|
|
18
|
+
* validated send means a drop on the far side is a version mismatch and never a
|
|
19
|
+
* payload we could have caught here.
|
|
20
|
+
*/
|
|
21
|
+
function isValidPreset(preset) {
|
|
22
|
+
if (typeof preset !== "object" || preset === null)
|
|
23
|
+
return false;
|
|
24
|
+
const p = preset;
|
|
25
|
+
if (typeof p.id !== "string" || p.id.length > PRESET_MAX_ID_LENGTH)
|
|
26
|
+
return false;
|
|
27
|
+
if (!PRESET_ID_RE.test(p.id))
|
|
28
|
+
return false;
|
|
29
|
+
if (typeof p.label !== "string" || p.label.length === 0)
|
|
30
|
+
return false;
|
|
31
|
+
if (p.label.length > PRESET_MAX_LABEL_LENGTH)
|
|
32
|
+
return false;
|
|
33
|
+
if (typeof p.kind !== "string")
|
|
34
|
+
return false;
|
|
35
|
+
if (!PRESET_KINDS.includes(p.kind))
|
|
36
|
+
return false;
|
|
37
|
+
if (typeof p.mirrored !== "boolean")
|
|
38
|
+
return false;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The announce, built from a preset list.
|
|
43
|
+
*
|
|
44
|
+
* Exported so a test can assert the payload's exact shape without a window, and
|
|
45
|
+
* so `url` never being on the wire is checkable at the boundary that produces it.
|
|
46
|
+
*/
|
|
47
|
+
export function buildAnnounce(presets) {
|
|
48
|
+
const valid = presets.filter(isValidPreset).slice(0, ANNOUNCE_MAX_PRESETS);
|
|
49
|
+
return {
|
|
50
|
+
source: HARNESS_MESSAGE_SOURCE,
|
|
51
|
+
v: CHANNEL_PROTOCOL_VERSION,
|
|
52
|
+
type: "announce",
|
|
53
|
+
presets: valid.map((p) => ({
|
|
54
|
+
id: p.id,
|
|
55
|
+
label: p.label,
|
|
56
|
+
kind: p.kind,
|
|
57
|
+
mirrored: p.mirrored,
|
|
58
|
+
})),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Is this a command this app acts on?
|
|
63
|
+
*
|
|
64
|
+
* Shape check only — the origin is checked separately and BOTH must pass. Any
|
|
65
|
+
* other `type` on a well-formed envelope is ignored, which is what keeps `ack`,
|
|
66
|
+
* `error`, `log`, `count`, `re-announce` and `request` from becoming reachable
|
|
67
|
+
* by a console that decides to send one.
|
|
68
|
+
*/
|
|
69
|
+
export function isSetMediaSourceCommand(data) {
|
|
70
|
+
if (typeof data !== "object" || data === null)
|
|
71
|
+
return false;
|
|
72
|
+
const m = data;
|
|
73
|
+
if (m.source !== CONSOLE_MESSAGE_SOURCE)
|
|
74
|
+
return false;
|
|
75
|
+
if (m.v !== CHANNEL_PROTOCOL_VERSION)
|
|
76
|
+
return false;
|
|
77
|
+
if (m.type !== "set-media-source")
|
|
78
|
+
return false;
|
|
79
|
+
if (typeof m.presetId !== "string" || m.presetId.length > PRESET_MAX_ID_LENGTH)
|
|
80
|
+
return false;
|
|
81
|
+
return PRESET_ID_RE.test(m.presetId);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Open the app's side of the channel. Returns a teardown.
|
|
85
|
+
*
|
|
86
|
+
* UNFRAMED IS A NO-OP. With no console around the app there is nothing to
|
|
87
|
+
* announce to and no chrome to command it, and the app keeps its own in-page
|
|
88
|
+
* controls instead. Nothing is posted and no listener is installed — the same
|
|
89
|
+
* absent-rather-than-inert rule the cluster controls follow.
|
|
90
|
+
*/
|
|
91
|
+
export function openConsoleChannel(opts) {
|
|
92
|
+
const view = opts.view ??
|
|
93
|
+
(typeof window === "undefined" ? undefined : window);
|
|
94
|
+
if (!view)
|
|
95
|
+
return () => undefined;
|
|
96
|
+
if (!isFramed(opts.framing))
|
|
97
|
+
return () => undefined;
|
|
98
|
+
const allowed = opts.allowedOrigins ?? PRODUCTION_CONSOLE_ORIGINS;
|
|
99
|
+
const onMessage = (event) => {
|
|
100
|
+
// BOTH checks, and neither is sufficient. Iteration 1 shipped this channel
|
|
101
|
+
// with source-equality alone (archived SEV-3), which made any embedding page
|
|
102
|
+
// the de-facto parent and let it drive the runtime.
|
|
103
|
+
if (!isAllowedConsoleOrigin(event.origin, allowed))
|
|
104
|
+
return;
|
|
105
|
+
// Fail CLOSED on a missing source, matching the console side. Tolerating null
|
|
106
|
+
// here left one of the pair non-binding — the SEV-3 shape this comment warns
|
|
107
|
+
// about — and no test could see it, because none delivered a null source.
|
|
108
|
+
if (event.source !== view.parent)
|
|
109
|
+
return;
|
|
110
|
+
if (!isSetMediaSourceCommand(event.data))
|
|
111
|
+
return;
|
|
112
|
+
opts.onSetMediaSource(event.data.presetId);
|
|
113
|
+
};
|
|
114
|
+
view.addEventListener("message", onMessage);
|
|
115
|
+
// ONE MESSAGE, ONCE. Posted to each allowed console origin explicitly — never
|
|
116
|
+
// "*", which is the outgoing half of the same archived SEV-3. Only the real
|
|
117
|
+
// parent's origin matches, so the others are delivered nowhere.
|
|
118
|
+
//
|
|
119
|
+
// There is deliberately no re-announce, no retry and no interval: a console
|
|
120
|
+
// that mounts after the app misses it and stays in its waiting state, which is
|
|
121
|
+
// the honest failure the design chose over a channel that keeps talking.
|
|
122
|
+
const announce = buildAnnounce(opts.presets);
|
|
123
|
+
for (const origin of allowed) {
|
|
124
|
+
try {
|
|
125
|
+
view.parent?.postMessage(announce, origin);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// A refused target origin is not an error the app reports anywhere — there
|
|
129
|
+
// is no channel to report it on, by construction.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return () => {
|
|
133
|
+
view.removeEventListener("message", onMessage);
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The production console origins. A closed, exact set.
|
|
3
|
+
*
|
|
4
|
+
* Both are real deployed consoles (`project_console_workspace_vs_org_naming`:
|
|
5
|
+
* the console is `console.vincentt.studio`). Adding an entry here is a visible,
|
|
6
|
+
* reviewable act — which is what makes the artifact assertion meaningful.
|
|
7
|
+
*/
|
|
8
|
+
export declare const PRODUCTION_CONSOLE_ORIGINS: readonly ["https://console.vincentt.studio", "https://console.staging.vincentt.studio"];
|
|
9
|
+
/**
|
|
10
|
+
* Is this origin one the console may command us from?
|
|
11
|
+
*
|
|
12
|
+
* `origins` is injected so the predicate is pure and the dev set can be supplied
|
|
13
|
+
* by a caller that has it, rather than reached by an import this module would
|
|
14
|
+
* then carry into every published bundle.
|
|
15
|
+
*/
|
|
16
|
+
export declare function isAllowedConsoleOrigin(origin: unknown, origins?: readonly string[]): boolean;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The compiled-in console-origin allowlist — the app side's inbound check.
|
|
2
|
+
//
|
|
3
|
+
// THIS MODULE SHIPS IN CREATORS' PRODUCTION BUNDLES. @vincentt-xr/harness is a
|
|
4
|
+
// published npm package installed into creators' own apps, so whatever is written
|
|
5
|
+
// here to make local development work is present in every published bundle,
|
|
6
|
+
// where anything holding a local port — a second dev server, a local tool's web
|
|
7
|
+
// UI, a malicious postinstall binding a port — could post a command into a live
|
|
8
|
+
// preview. That is why the dev origins are not in this file at all; see
|
|
9
|
+
// `consoleOriginDev.dev.ts` and the `*.dev.ts` exclusion in `tsconfig.build.json`.
|
|
10
|
+
//
|
|
11
|
+
// EXACT-STRING EQUALITY, never a prefix, a suffix, a `startsWith` or a regex.
|
|
12
|
+
// `event.origin` is already a browser-normalised origin string, so set membership
|
|
13
|
+
// is the whole check. A prefix test admits `https://console.vincentt.studio.evil.com`,
|
|
14
|
+
// which is the standard way this check is broken; a suffix test on a hostname is
|
|
15
|
+
// what `isPreviewOrigin` does deliberately for a WILDCARD apex, and a fixed set
|
|
16
|
+
// of two origins is not that case.
|
|
17
|
+
/**
|
|
18
|
+
* The production console origins. A closed, exact set.
|
|
19
|
+
*
|
|
20
|
+
* Both are real deployed consoles (`project_console_workspace_vs_org_naming`:
|
|
21
|
+
* the console is `console.vincentt.studio`). Adding an entry here is a visible,
|
|
22
|
+
* reviewable act — which is what makes the artifact assertion meaningful.
|
|
23
|
+
*/
|
|
24
|
+
export const PRODUCTION_CONSOLE_ORIGINS = [
|
|
25
|
+
"https://console.vincentt.studio",
|
|
26
|
+
"https://console.staging.vincentt.studio",
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* Is this origin one the console may command us from?
|
|
30
|
+
*
|
|
31
|
+
* `origins` is injected so the predicate is pure and the dev set can be supplied
|
|
32
|
+
* by a caller that has it, rather than reached by an import this module would
|
|
33
|
+
* then carry into every published bundle.
|
|
34
|
+
*/
|
|
35
|
+
export function isAllowedConsoleOrigin(origin, origins = PRODUCTION_CONSOLE_ORIGINS) {
|
|
36
|
+
if (typeof origin !== "string" || origin === "")
|
|
37
|
+
return false;
|
|
38
|
+
return origins.includes(origin);
|
|
39
|
+
}
|
package/dist/client/index.d.ts
CHANGED
|
@@ -3,6 +3,9 @@ export type { HarnessProviderProps } from "./HarnessProvider.js";
|
|
|
3
3
|
export { sendAnnotation, captureScreenshot, mountFeedbackButton, shouldShowFeedback, type SendAnnotationOptions, type FeedbackButtonOptions, } from "./annotate.js";
|
|
4
4
|
export { mountShareButton, deriveShareUrl, isPhone, isPreviewOrigin, renderQr, PREVIEW_APEXES, type ShareButtonOptions, } from "./share.js";
|
|
5
5
|
export { isFramed, type FramingView } from "./previewOrigin.js";
|
|
6
|
+
export { openConsoleChannel, buildAnnounce, isSetMediaSourceCommand, type ChannelOptions, type ChannelWindow, } from "./channel.js";
|
|
7
|
+
export { isAllowedConsoleOrigin, PRODUCTION_CONSOLE_ORIGINS } from "./consoleOrigin.js";
|
|
8
|
+
export { CHANNEL_MESSAGE_TYPES, HARNESS_OUTBOUND_MESSAGE_TYPES, CONSOLE_INBOUND_MESSAGE_TYPES, HARNESS_MESSAGE_SOURCE, CONSOLE_MESSAGE_SOURCE, CHANNEL_PROTOCOL_VERSION, PRESET_KINDS, ANNOUNCE_MAX_PRESETS, type AnnouncedPreset, type PresetKind, type HarnessAnnounceMessage, type ConsoleSetMediaSourceMessage, } from "../shared/channel.js";
|
|
6
9
|
export { getClusterContainer, releaseClusterContainer } from "./cluster.js";
|
|
7
10
|
export type { DiagEvent, LogEvent, NetworkEvent, TraceEvent } from "../shared/events.js";
|
|
8
11
|
export type { Annotation, AnnotationInput, AnnotationSpec, AnnotationStroke, AnnotationPin, } from "../shared/events.js";
|
package/dist/client/index.js
CHANGED
|
@@ -6,4 +6,7 @@ export { HarnessProvider } from "./HarnessProvider.js";
|
|
|
6
6
|
export { sendAnnotation, captureScreenshot, mountFeedbackButton, shouldShowFeedback, } from "./annotate.js";
|
|
7
7
|
export { mountShareButton, deriveShareUrl, isPhone, isPreviewOrigin, renderQr, PREVIEW_APEXES, } from "./share.js";
|
|
8
8
|
export { isFramed } from "./previewOrigin.js";
|
|
9
|
+
export { openConsoleChannel, buildAnnounce, isSetMediaSourceCommand, } from "./channel.js";
|
|
10
|
+
export { isAllowedConsoleOrigin, PRODUCTION_CONSOLE_ORIGINS } from "./consoleOrigin.js";
|
|
11
|
+
export { CHANNEL_MESSAGE_TYPES, HARNESS_OUTBOUND_MESSAGE_TYPES, CONSOLE_INBOUND_MESSAGE_TYPES, HARNESS_MESSAGE_SOURCE, CONSOLE_MESSAGE_SOURCE, CHANNEL_PROTOCOL_VERSION, PRESET_KINDS, ANNOUNCE_MAX_PRESETS, } from "../shared/channel.js";
|
|
9
12
|
export { getClusterContainer, releaseClusterContainer } from "./cluster.js";
|
package/dist/client/share.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type FramingView } from "./previewOrigin.js";
|
|
1
2
|
export { isPhone, isPreviewOrigin, PREVIEW_APEXES } from "./previewOrigin.js";
|
|
2
3
|
/**
|
|
3
4
|
* The canonical preview URL to encode — `origin + "/"`, stable across in-app SPA
|
|
@@ -16,13 +17,23 @@ export interface ShareButtonOptions {
|
|
|
16
17
|
matchMedia?: typeof window.matchMedia;
|
|
17
18
|
/** Injectable for tests. Defaults to `window.location`. */
|
|
18
19
|
location?: Pick<Location, "origin" | "protocol" | "hostname">;
|
|
20
|
+
/** Injectable for tests. Defaults to `window`'s own handles. */
|
|
21
|
+
view?: FramingView;
|
|
19
22
|
}
|
|
20
23
|
/**
|
|
21
24
|
* Mount the icon-only Share chip into the shared cluster and wire its popover.
|
|
22
25
|
*
|
|
23
|
-
* The show-gate is evaluated ONCE, here at mount: render iff the
|
|
24
|
-
* phone AND the origin is a real preview origin.
|
|
25
|
-
* appended to the DOM (no disabled state) and the
|
|
26
|
+
* The show-gate is evaluated ONCE, here at mount: render iff the document is not
|
|
27
|
+
* framed AND the viewer is not a phone AND the origin is a real preview origin.
|
|
28
|
+
* If the gate fails, nothing is appended to the DOM (no disabled state) and the
|
|
29
|
+
* returned unmount is a no-op.
|
|
30
|
+
*
|
|
31
|
+
* THE FRAMED VETO. The console renders its own Share control beside the frame,
|
|
32
|
+
* so an in-frame chip would give the creator two Share buttons in one workspace
|
|
33
|
+
* — and this one is inside a document that now holds a camera delegation, which
|
|
34
|
+
* makes an overlay near the frame boundary read as console UI. GATED, NOT
|
|
35
|
+
* DELETED: an unframed local preview (`vincentt preview` opened directly) has no
|
|
36
|
+
* console chrome around it to take over, and keeps its Share.
|
|
26
37
|
*
|
|
27
38
|
* Returns an unmount function that removes the chip + popover, tears down listeners,
|
|
28
39
|
* and releases the shared cluster.
|
package/dist/client/share.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// whole module — and `qrcode`, reached only from here — tree-shakes out of prod.
|
|
12
12
|
import QRCode from "qrcode";
|
|
13
13
|
import { getClusterContainer, releaseClusterContainer } from "./cluster.js";
|
|
14
|
-
import { isPhone, isPreviewOrigin } from "./previewOrigin.js";
|
|
14
|
+
import { isFramed, isPhone, isPreviewOrigin } from "./previewOrigin.js";
|
|
15
15
|
// The viewer/origin predicates moved to the dependency-free `previewOrigin.ts` so
|
|
16
16
|
// the feedback chip can gate on them without importing this module (and with it
|
|
17
17
|
// `qrcode`). Re-exported here because this is the path every existing caller and
|
|
@@ -46,9 +46,17 @@ const QR_GLYPH_SVG = `<svg width="18" height="18" viewBox="0 0 24 24" fill="curr
|
|
|
46
46
|
/**
|
|
47
47
|
* Mount the icon-only Share chip into the shared cluster and wire its popover.
|
|
48
48
|
*
|
|
49
|
-
* The show-gate is evaluated ONCE, here at mount: render iff the
|
|
50
|
-
* phone AND the origin is a real preview origin.
|
|
51
|
-
* appended to the DOM (no disabled state) and the
|
|
49
|
+
* The show-gate is evaluated ONCE, here at mount: render iff the document is not
|
|
50
|
+
* framed AND the viewer is not a phone AND the origin is a real preview origin.
|
|
51
|
+
* If the gate fails, nothing is appended to the DOM (no disabled state) and the
|
|
52
|
+
* returned unmount is a no-op.
|
|
53
|
+
*
|
|
54
|
+
* THE FRAMED VETO. The console renders its own Share control beside the frame,
|
|
55
|
+
* so an in-frame chip would give the creator two Share buttons in one workspace
|
|
56
|
+
* — and this one is inside a document that now holds a camera delegation, which
|
|
57
|
+
* makes an overlay near the frame boundary read as console UI. GATED, NOT
|
|
58
|
+
* DELETED: an unframed local preview (`vincentt preview` opened directly) has no
|
|
59
|
+
* console chrome around it to take over, and keeps its Share.
|
|
52
60
|
*
|
|
53
61
|
* Returns an unmount function that removes the chip + popover, tears down listeners,
|
|
54
62
|
* and releases the shared cluster.
|
|
@@ -60,7 +68,7 @@ export function mountShareButton(opts = {}) {
|
|
|
60
68
|
const location = opts.location ?? (typeof window !== "undefined" ? window.location : undefined);
|
|
61
69
|
if (!location)
|
|
62
70
|
return () => undefined;
|
|
63
|
-
const show = !isPhone(matchMedia) && isPreviewOrigin(location);
|
|
71
|
+
const show = !isFramed(opts.view) && !isPhone(matchMedia) && isPreviewOrigin(location);
|
|
64
72
|
if (!show)
|
|
65
73
|
return () => undefined;
|
|
66
74
|
const url = deriveShareUrl(location);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** The app→console message names. Exactly one, and adding a second is the failure. */
|
|
2
|
+
export declare const HARNESS_OUTBOUND_MESSAGE_TYPES: readonly ["announce"];
|
|
3
|
+
/** The console→app message names. Exactly one. */
|
|
4
|
+
export declare const CONSOLE_INBOUND_MESSAGE_TYPES: readonly ["set-media-source"];
|
|
5
|
+
/** The whole channel vocabulary, both directions. */
|
|
6
|
+
export declare const CHANNEL_MESSAGE_TYPES: readonly ["announce", "set-media-source"];
|
|
7
|
+
export type HarnessOutboundMessageType = (typeof HARNESS_OUTBOUND_MESSAGE_TYPES)[number];
|
|
8
|
+
export type ConsoleInboundMessageType = (typeof CONSOLE_INBOUND_MESSAGE_TYPES)[number];
|
|
9
|
+
/** Envelope discriminator for app→console traffic. */
|
|
10
|
+
export declare const HARNESS_MESSAGE_SOURCE = "vincentt-harness";
|
|
11
|
+
/** Envelope discriminator for console→app traffic. */
|
|
12
|
+
export declare const CONSOLE_MESSAGE_SOURCE = "vincentt-console";
|
|
13
|
+
/** The channel's protocol version. Bumped only with both repos in one release. */
|
|
14
|
+
export declare const CHANNEL_PROTOCOL_VERSION = 1;
|
|
15
|
+
/** The media kinds a preset may declare. Closed — an unknown kind drops the announce. */
|
|
16
|
+
export declare const PRESET_KINDS: readonly ["video", "image", "camera"];
|
|
17
|
+
export type PresetKind = (typeof PRESET_KINDS)[number];
|
|
18
|
+
/**
|
|
19
|
+
* One announced preset — CAPABILITY, not observation.
|
|
20
|
+
*
|
|
21
|
+
* `url` is deliberately NOT on the wire (security SHOULD-FIX 5.1). The console
|
|
22
|
+
* names an `id` and the app resolves it against the SDK list it already holds,
|
|
23
|
+
* so a creator-controlled URL never reaches console chrome to be rendered as an
|
|
24
|
+
* `href`, a `src`, or a thumbnail. If the console does not render it, it must
|
|
25
|
+
* not receive it.
|
|
26
|
+
*/
|
|
27
|
+
export interface AnnouncedPreset {
|
|
28
|
+
id: string;
|
|
29
|
+
label: string;
|
|
30
|
+
kind: PresetKind;
|
|
31
|
+
/** Whether the SDK mirrors this source. The console renders the difference it now causes. */
|
|
32
|
+
mirrored: boolean;
|
|
33
|
+
}
|
|
34
|
+
/** app → console, posted ONCE on mount to the console's exact origin. */
|
|
35
|
+
export interface HarnessAnnounceMessage {
|
|
36
|
+
source: typeof HARNESS_MESSAGE_SOURCE;
|
|
37
|
+
v: typeof CHANNEL_PROTOCOL_VERSION;
|
|
38
|
+
type: "announce";
|
|
39
|
+
presets: AnnouncedPreset[];
|
|
40
|
+
}
|
|
41
|
+
/** console → app. The only command name that exists. */
|
|
42
|
+
export interface ConsoleSetMediaSourceMessage {
|
|
43
|
+
source: typeof CONSOLE_MESSAGE_SOURCE;
|
|
44
|
+
v: typeof CHANNEL_PROTOCOL_VERSION;
|
|
45
|
+
type: "set-media-source";
|
|
46
|
+
presetId: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Caps on an inbound announce, mirrored by the console's own validator.
|
|
50
|
+
*
|
|
51
|
+
* They live in the shared contract rather than in the parser so both repos
|
|
52
|
+
* refuse the same payload: a cap enforced on one side only means the sender can
|
|
53
|
+
* emit something the receiver silently drops, which is the drift this file exists
|
|
54
|
+
* to prevent.
|
|
55
|
+
*/
|
|
56
|
+
export declare const ANNOUNCE_MAX_PRESETS = 32;
|
|
57
|
+
export declare const PRESET_MAX_ID_LENGTH = 64;
|
|
58
|
+
export declare const PRESET_MAX_LABEL_LENGTH = 120;
|
|
59
|
+
/** Conservative id grammar. Ids are ours; a label is creator-facing text, an id is not. */
|
|
60
|
+
export declare const PRESET_ID_RE: RegExp;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The console↔app channel's wire contract. Pure data: no DOM, no React, no node.
|
|
2
|
+
//
|
|
3
|
+
// The console mirrors these two names in its own `contract.ts`. They ship on
|
|
4
|
+
// DIFFERENT release cadences — @vincentt-xr/harness is caret-pinned and reaches
|
|
5
|
+
// old projects on a plain reinstall, the console deploys independently — so a
|
|
6
|
+
// rename in one lands in creators' apps weeks apart from the other, and the
|
|
7
|
+
// channel then fails into a permanent waiting state indistinguishable from an
|
|
8
|
+
// old project. That is why the vocabulary is a pinned constant in both repos
|
|
9
|
+
// rather than a string literal at each call site.
|
|
10
|
+
//
|
|
11
|
+
// B-F13-3, and the reason this file is a CLOSED set rather than a union that
|
|
12
|
+
// grows: the app declares its own capability, once, and never reports an
|
|
13
|
+
// observation. postMessage has no one-way mode — both directions of the
|
|
14
|
+
// transport are open at all times — so the vocabulary is the only cap there is,
|
|
15
|
+
// and a vocabulary is a convention until a test asserts it.
|
|
16
|
+
/** The app→console message names. Exactly one, and adding a second is the failure. */
|
|
17
|
+
export const HARNESS_OUTBOUND_MESSAGE_TYPES = ["announce"];
|
|
18
|
+
/** The console→app message names. Exactly one. */
|
|
19
|
+
export const CONSOLE_INBOUND_MESSAGE_TYPES = ["set-media-source"];
|
|
20
|
+
/** The whole channel vocabulary, both directions. */
|
|
21
|
+
export const CHANNEL_MESSAGE_TYPES = [
|
|
22
|
+
...HARNESS_OUTBOUND_MESSAGE_TYPES,
|
|
23
|
+
...CONSOLE_INBOUND_MESSAGE_TYPES,
|
|
24
|
+
];
|
|
25
|
+
/** Envelope discriminator for app→console traffic. */
|
|
26
|
+
export const HARNESS_MESSAGE_SOURCE = "vincentt-harness";
|
|
27
|
+
/** Envelope discriminator for console→app traffic. */
|
|
28
|
+
export const CONSOLE_MESSAGE_SOURCE = "vincentt-console";
|
|
29
|
+
/** The channel's protocol version. Bumped only with both repos in one release. */
|
|
30
|
+
export const CHANNEL_PROTOCOL_VERSION = 1;
|
|
31
|
+
/** The media kinds a preset may declare. Closed — an unknown kind drops the announce. */
|
|
32
|
+
export const PRESET_KINDS = ["video", "image", "camera"];
|
|
33
|
+
/**
|
|
34
|
+
* Caps on an inbound announce, mirrored by the console's own validator.
|
|
35
|
+
*
|
|
36
|
+
* They live in the shared contract rather than in the parser so both repos
|
|
37
|
+
* refuse the same payload: a cap enforced on one side only means the sender can
|
|
38
|
+
* emit something the receiver silently drops, which is the drift this file exists
|
|
39
|
+
* to prevent.
|
|
40
|
+
*/
|
|
41
|
+
export const ANNOUNCE_MAX_PRESETS = 32;
|
|
42
|
+
export const PRESET_MAX_ID_LENGTH = 64;
|
|
43
|
+
export const PRESET_MAX_LABEL_LENGTH = 120;
|
|
44
|
+
/** Conservative id grammar. Ids are ours; a label is creator-facing text, an id is not. */
|
|
45
|
+
export const PRESET_ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|