@vincentt-xr/harness 1.3.0 → 1.5.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.
@@ -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 only decides whether the module is fetched at all.
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,77 @@
1
+ import { type AnnouncedPreset, type ConsoleSetMediaSourceMessage, type ConsoleSetRenderHoldMessage, 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
+ /**
17
+ * Called when the console asks the app to freeze or resume its own render.
18
+ *
19
+ * SUPPLYING THIS IS WHAT DECLARES THE CAPABILITY — see `buildAnnounce`. The
20
+ * harness holds no opinion about how the app holds; it hands over `held` and
21
+ * the app decides what stops. Omit it and the app declares nothing and the
22
+ * console renders no control, which is the correct state for an app that
23
+ * cannot hold.
24
+ */
25
+ onSetRenderHold?: (held: boolean) => void;
26
+ /** The console origins commands may come from. Injected; defaults to the production set. */
27
+ allowedOrigins?: readonly string[];
28
+ /** Injectable for tests. Defaults to `window`. */
29
+ view?: ChannelWindow;
30
+ /** Injectable for tests. Defaults to `window`'s own handles. */
31
+ framing?: FramingView;
32
+ }
33
+ /**
34
+ * The announce, built from a preset list and the app's WIRED handlers.
35
+ *
36
+ * Exported so a test can assert the payload's exact shape without a window, and
37
+ * so `url` never being on the wire is checkable at the boundary that produces it.
38
+ *
39
+ * THE CAPABILITY IS DERIVED, NEVER DECLARED. The second parameter is the handler
40
+ * itself, not a capability list, so there is no way to spell a declaration the
41
+ * app cannot honour: `render-hold` appears exactly when `onSetRenderHold` was
42
+ * supplied. An app that declared it without implementing it would render a live
43
+ * button that does nothing, and the console cannot detect that — there is no ack,
44
+ * by design — so this boundary is the only place it is preventable.
45
+ *
46
+ * The key is OMITTED rather than set to `[]` when nothing is wired: an old
47
+ * harness omits it, and emitting an empty array would hand the console a second
48
+ * shape meaning the same thing.
49
+ */
50
+ export declare function buildAnnounce(presets: AnnouncedPreset[], handlers?: Pick<ChannelOptions, "onSetRenderHold">): HarnessAnnounceMessage;
51
+ /**
52
+ * Is this a command this app acts on?
53
+ *
54
+ * Shape check only — the origin is checked separately and BOTH must pass. Any
55
+ * other `type` on a well-formed envelope is ignored, which is what keeps `ack`,
56
+ * `error`, `log`, `count`, `re-announce` and `request` from becoming reachable
57
+ * by a console that decides to send one.
58
+ */
59
+ export declare function isSetMediaSourceCommand(data: unknown): data is ConsoleSetMediaSourceMessage;
60
+ /**
61
+ * Is this a hold command this app acts on?
62
+ *
63
+ * Shape check only, mirroring `isSetMediaSourceCommand` — the origin and the
64
+ * source are checked separately and ALL must pass. `held` is required to be a
65
+ * real boolean rather than coerced: a truthy string arriving as `held` would
66
+ * freeze an app that could never be told to resume by the same mistake.
67
+ */
68
+ export declare function isSetRenderHoldCommand(data: unknown): data is ConsoleSetRenderHoldMessage;
69
+ /**
70
+ * Open the app's side of the channel. Returns a teardown.
71
+ *
72
+ * UNFRAMED IS A NO-OP. With no console around the app there is nothing to
73
+ * announce to and no chrome to command it, and the app keeps its own in-page
74
+ * controls instead. Nothing is posted and no listener is installed — the same
75
+ * absent-rather-than-inert rule the cluster controls follow.
76
+ */
77
+ export declare function openConsoleChannel(opts: ChannelOptions): () => void;
@@ -0,0 +1,186 @@
1
+ // The app side of the console↔app channel.
2
+ //
3
+ // It does two things and refuses everything else: it announces ONCE on mount,
4
+ // and it listens for the console's commands. It never acknowledges, never
5
+ // 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
+ //
10
+ // It carries the hold command and does not perform the hold. The harness has
11
+ // ZERO coupling to r3f/three, and that absence is load-bearing rather than
12
+ // incidental: it is what lets the harness be transport with no opinion about the
13
+ // app. The app freezes itself in its own render tree.
14
+ 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";
15
+ import { isAllowedConsoleOrigin, PRODUCTION_CONSOLE_ORIGINS } from "./consoleOrigin.js";
16
+ import { isFramed } from "./previewOrigin.js";
17
+ /**
18
+ * Is this a well-formed preset the console can render?
19
+ *
20
+ * Applied to the app's OWN list before it goes out, which is not paranoia: the
21
+ * presets come from the SDK or from creator code, and an oversized or malformed
22
+ * announce is dropped whole by the console rather than partially rendered. A
23
+ * validated send means a drop on the far side is a version mismatch and never a
24
+ * payload we could have caught here.
25
+ */
26
+ function isValidPreset(preset) {
27
+ if (typeof preset !== "object" || preset === null)
28
+ return false;
29
+ const p = preset;
30
+ if (typeof p.id !== "string" || p.id.length > PRESET_MAX_ID_LENGTH)
31
+ return false;
32
+ if (!PRESET_ID_RE.test(p.id))
33
+ return false;
34
+ if (typeof p.label !== "string" || p.label.length === 0)
35
+ return false;
36
+ if (p.label.length > PRESET_MAX_LABEL_LENGTH)
37
+ return false;
38
+ if (typeof p.kind !== "string")
39
+ return false;
40
+ if (!PRESET_KINDS.includes(p.kind))
41
+ return false;
42
+ if (typeof p.mirrored !== "boolean")
43
+ return false;
44
+ return true;
45
+ }
46
+ /**
47
+ * The announce, built from a preset list and the app's WIRED handlers.
48
+ *
49
+ * Exported so a test can assert the payload's exact shape without a window, and
50
+ * so `url` never being on the wire is checkable at the boundary that produces it.
51
+ *
52
+ * THE CAPABILITY IS DERIVED, NEVER DECLARED. The second parameter is the handler
53
+ * itself, not a capability list, so there is no way to spell a declaration the
54
+ * app cannot honour: `render-hold` appears exactly when `onSetRenderHold` was
55
+ * supplied. An app that declared it without implementing it would render a live
56
+ * button that does nothing, and the console cannot detect that — there is no ack,
57
+ * by design — so this boundary is the only place it is preventable.
58
+ *
59
+ * The key is OMITTED rather than set to `[]` when nothing is wired: an old
60
+ * harness omits it, and emitting an empty array would hand the console a second
61
+ * shape meaning the same thing.
62
+ */
63
+ export function buildAnnounce(presets, handlers = {}) {
64
+ const valid = presets.filter(isValidPreset).slice(0, ANNOUNCE_MAX_PRESETS);
65
+ const announce = {
66
+ source: HARNESS_MESSAGE_SOURCE,
67
+ v: CHANNEL_PROTOCOL_VERSION,
68
+ type: "announce",
69
+ presets: valid.map((p) => ({
70
+ id: p.id,
71
+ label: p.label,
72
+ kind: p.kind,
73
+ mirrored: p.mirrored,
74
+ })),
75
+ };
76
+ if (handlers.onSetRenderHold)
77
+ announce.capabilities = ["render-hold"];
78
+ return announce;
79
+ }
80
+ /**
81
+ * Is this a command this app acts on?
82
+ *
83
+ * Shape check only — the origin is checked separately and BOTH must pass. Any
84
+ * other `type` on a well-formed envelope is ignored, which is what keeps `ack`,
85
+ * `error`, `log`, `count`, `re-announce` and `request` from becoming reachable
86
+ * by a console that decides to send one.
87
+ */
88
+ export function isSetMediaSourceCommand(data) {
89
+ if (typeof data !== "object" || data === null)
90
+ return false;
91
+ const m = data;
92
+ if (m.source !== CONSOLE_MESSAGE_SOURCE)
93
+ return false;
94
+ if (m.v !== CHANNEL_PROTOCOL_VERSION)
95
+ return false;
96
+ if (m.type !== "set-media-source")
97
+ return false;
98
+ if (typeof m.presetId !== "string" || m.presetId.length > PRESET_MAX_ID_LENGTH)
99
+ return false;
100
+ return PRESET_ID_RE.test(m.presetId);
101
+ }
102
+ /**
103
+ * Is this a hold command this app acts on?
104
+ *
105
+ * Shape check only, mirroring `isSetMediaSourceCommand` — the origin and the
106
+ * source are checked separately and ALL must pass. `held` is required to be a
107
+ * real boolean rather than coerced: a truthy string arriving as `held` would
108
+ * freeze an app that could never be told to resume by the same mistake.
109
+ */
110
+ export function isSetRenderHoldCommand(data) {
111
+ if (typeof data !== "object" || data === null)
112
+ return false;
113
+ const m = data;
114
+ if (m.source !== CONSOLE_MESSAGE_SOURCE)
115
+ return false;
116
+ if (m.v !== CHANNEL_PROTOCOL_VERSION)
117
+ return false;
118
+ if (m.type !== "set-render-hold")
119
+ return false;
120
+ return typeof m.held === "boolean";
121
+ }
122
+ /**
123
+ * Open the app's side of the channel. Returns a teardown.
124
+ *
125
+ * UNFRAMED IS A NO-OP. With no console around the app there is nothing to
126
+ * announce to and no chrome to command it, and the app keeps its own in-page
127
+ * controls instead. Nothing is posted and no listener is installed — the same
128
+ * absent-rather-than-inert rule the cluster controls follow.
129
+ */
130
+ export function openConsoleChannel(opts) {
131
+ const view = opts.view ??
132
+ (typeof window === "undefined" ? undefined : window);
133
+ if (!view)
134
+ return () => undefined;
135
+ if (!isFramed(opts.framing))
136
+ return () => undefined;
137
+ const allowed = opts.allowedOrigins ?? PRODUCTION_CONSOLE_ORIGINS;
138
+ const onMessage = (event) => {
139
+ // BOTH checks, and neither is sufficient. Iteration 1 shipped this channel
140
+ // with source-equality alone (archived SEV-3), which made any embedding page
141
+ // the de-facto parent and let it drive the runtime.
142
+ if (!isAllowedConsoleOrigin(event.origin, allowed))
143
+ return;
144
+ // Fail CLOSED on a missing source, matching the console side. Tolerating null
145
+ // here left one of the pair non-binding — the SEV-3 shape this comment warns
146
+ // about — and no test could see it, because none delivered a null source.
147
+ if (event.source !== view.parent)
148
+ return;
149
+ // Both commands clear the IDENTICAL two checks above before any dispatch.
150
+ // Neither name is privileged and neither has its own gate — a second command
151
+ // is one more branch inside a parser that already validates by exact match
152
+ // against a closed set, which is the whole reason growing the vocabulary is
153
+ // cheap and growing the CHECKS would not be.
154
+ if (isSetMediaSourceCommand(event.data)) {
155
+ opts.onSetMediaSource(event.data.presetId);
156
+ return;
157
+ }
158
+ if (isSetRenderHoldCommand(event.data)) {
159
+ // An app with no handler ignores it in silence. It never declared the
160
+ // capability, so a console sending this is speaking to the wrong app —
161
+ // and there is no channel to say so on.
162
+ opts.onSetRenderHold?.(event.data.held);
163
+ }
164
+ };
165
+ view.addEventListener("message", onMessage);
166
+ // ONE MESSAGE, ONCE. Posted to each allowed console origin explicitly — never
167
+ // "*", which is the outgoing half of the same archived SEV-3. Only the real
168
+ // parent's origin matches, so the others are delivered nowhere.
169
+ //
170
+ // There is deliberately no re-announce, no retry and no interval: a console
171
+ // that mounts after the app misses it and stays in its waiting state, which is
172
+ // the honest failure the design chose over a channel that keeps talking.
173
+ const announce = buildAnnounce(opts.presets, opts);
174
+ for (const origin of allowed) {
175
+ try {
176
+ view.parent?.postMessage(announce, origin);
177
+ }
178
+ catch {
179
+ // A refused target origin is not an error the app reports anywhere — there
180
+ // is no channel to report it on, by construction.
181
+ }
182
+ }
183
+ return () => {
184
+ view.removeEventListener("message", onMessage);
185
+ };
186
+ }
@@ -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
+ }
@@ -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, isSetRenderHoldCommand, 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, APP_CAPABILITIES, ANNOUNCE_MAX_PRESETS, ANNOUNCE_MAX_CAPABILITIES, type AnnouncedPreset, type PresetKind, type AppCapability, type HarnessAnnounceMessage, type ConsoleSetMediaSourceMessage, type ConsoleSetRenderHoldMessage, } 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";
@@ -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, isSetRenderHoldCommand, } 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, APP_CAPABILITIES, ANNOUNCE_MAX_PRESETS, ANNOUNCE_MAX_CAPABILITIES, } from "../shared/channel.js";
9
12
  export { getClusterContainer, releaseClusterContainer } from "./cluster.js";
@@ -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 viewer is not a
24
- * phone AND the origin is a real preview origin. If the gate fails, nothing is
25
- * appended to the DOM (no disabled state) and the returned unmount is a no-op.
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.
@@ -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 viewer is not a
50
- * phone AND the origin is a real preview origin. If the gate fails, nothing is
51
- * appended to the DOM (no disabled state) and the returned unmount is a no-op.
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,99 @@
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 two. */
4
+ export declare const CONSOLE_INBOUND_MESSAGE_TYPES: readonly ["set-media-source", "set-render-hold"];
5
+ /** The whole channel vocabulary, both directions. */
6
+ export declare const CHANNEL_MESSAGE_TYPES: readonly ["announce", "set-media-source", "set-render-hold"];
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
+ * What an app may declare it CAN DO. Closed and validated exactly as
20
+ * `PRESET_KINDS` is — an unknown entry is dropped rather than passed through.
21
+ *
22
+ * A capability is a property of the app's own code, never a report of its
23
+ * current condition: `render-hold` says *this app carries a hold handler*, not
24
+ * *this app is held*. The console renders a control on the strength of it and
25
+ * can never learn whether the app acted, so the declaration has to be derived
26
+ * from the wiring rather than written by hand — see `buildAnnounce`.
27
+ */
28
+ export declare const APP_CAPABILITIES: readonly ["render-hold"];
29
+ export type AppCapability = (typeof APP_CAPABILITIES)[number];
30
+ /**
31
+ * One announced preset — CAPABILITY, not observation.
32
+ *
33
+ * `url` is deliberately NOT on the wire (security SHOULD-FIX 5.1). The console
34
+ * names an `id` and the app resolves it against the SDK list it already holds,
35
+ * so a creator-controlled URL never reaches console chrome to be rendered as an
36
+ * `href`, a `src`, or a thumbnail. If the console does not render it, it must
37
+ * not receive it.
38
+ */
39
+ export interface AnnouncedPreset {
40
+ id: string;
41
+ label: string;
42
+ kind: PresetKind;
43
+ /** Whether the SDK mirrors this source. The console renders the difference it now causes. */
44
+ mirrored: boolean;
45
+ }
46
+ /** app → console, posted ONCE on mount to the console's exact origin. */
47
+ export interface HarnessAnnounceMessage {
48
+ source: typeof HARNESS_MESSAGE_SOURCE;
49
+ v: typeof CHANNEL_PROTOCOL_VERSION;
50
+ type: "announce";
51
+ presets: AnnouncedPreset[];
52
+ /**
53
+ * OPTIONAL, and the key is OMITTED when there is nothing to declare — never
54
+ * `[]`. An old harness omits it, so a console reading `undefined` cannot tell
55
+ * an old app from a new one that declares nothing, and must not be given two
56
+ * shapes to mean the same thing.
57
+ */
58
+ capabilities?: readonly AppCapability[];
59
+ }
60
+ /** console → app. Applies a named media preset. */
61
+ export interface ConsoleSetMediaSourceMessage {
62
+ source: typeof CONSOLE_MESSAGE_SOURCE;
63
+ v: typeof CHANNEL_PROTOCOL_VERSION;
64
+ type: "set-media-source";
65
+ presetId: string;
66
+ }
67
+ /**
68
+ * console → app. Freezes or resumes the app's own render loop.
69
+ *
70
+ * `held` is absolute, never a toggle: the console tracks only what it last sent
71
+ * and cannot read the app, so a toggle would drift the moment one message was
72
+ * dropped and neither side could tell.
73
+ */
74
+ export interface ConsoleSetRenderHoldMessage {
75
+ source: typeof CONSOLE_MESSAGE_SOURCE;
76
+ v: typeof CHANNEL_PROTOCOL_VERSION;
77
+ type: "set-render-hold";
78
+ held: boolean;
79
+ }
80
+ /**
81
+ * Caps on an inbound announce, mirrored by the console's own validator.
82
+ *
83
+ * They live in the shared contract rather than in the parser so both repos
84
+ * refuse the same payload: a cap enforced on one side only means the sender can
85
+ * emit something the receiver silently drops, which is the drift this file exists
86
+ * to prevent.
87
+ */
88
+ export declare const ANNOUNCE_MAX_PRESETS = 32;
89
+ export declare const PRESET_MAX_ID_LENGTH = 64;
90
+ export declare const PRESET_MAX_LABEL_LENGTH = 120;
91
+ /**
92
+ * The same class of cap as `ANNOUNCE_MAX_PRESETS`, and it exists for the same
93
+ * reason: the receiver must be able to refuse an oversized list BEFORE walking
94
+ * it, and both repos must refuse the identical payload. It is comfortably above
95
+ * `APP_CAPABILITIES.length` so growing the closed set does not require moving it.
96
+ */
97
+ export declare const ANNOUNCE_MAX_CAPABILITIES = 8;
98
+ /** Conservative id grammar. Ids are ours; a label is creator-facing text, an id is not. */
99
+ export declare const PRESET_ID_RE: RegExp;
@@ -0,0 +1,70 @@
1
+ // The console↔app channel's wire contract. Pure data: no DOM, no React, no node.
2
+ //
3
+ // The console mirrors these three 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
+ //
17
+ // THE ONE-WAY LINE IS UNCHANGED BY THE SECOND COMMAND. Console→app grew to two
18
+ // names; app→console is still exactly one, still posted once on mount. The
19
+ // announce's `capabilities` field is the app declaring WHAT IT CAN DO — the same
20
+ // class of fact as its preset list, which B-F13-3 permits in terms — and never
21
+ // what it is doing. Capability crosses; observation does not. A second app→console
22
+ // name, or an ack for `set-render-hold`, would be the breach.
23
+ /** The app→console message names. Exactly one, and adding a second is the failure. */
24
+ export const HARNESS_OUTBOUND_MESSAGE_TYPES = ["announce"];
25
+ /** The console→app message names. Exactly two. */
26
+ export const CONSOLE_INBOUND_MESSAGE_TYPES = ["set-media-source", "set-render-hold"];
27
+ /** The whole channel vocabulary, both directions. */
28
+ export const CHANNEL_MESSAGE_TYPES = [
29
+ ...HARNESS_OUTBOUND_MESSAGE_TYPES,
30
+ ...CONSOLE_INBOUND_MESSAGE_TYPES,
31
+ ];
32
+ /** Envelope discriminator for app→console traffic. */
33
+ export const HARNESS_MESSAGE_SOURCE = "vincentt-harness";
34
+ /** Envelope discriminator for console→app traffic. */
35
+ export const CONSOLE_MESSAGE_SOURCE = "vincentt-console";
36
+ /** The channel's protocol version. Bumped only with both repos in one release. */
37
+ export const CHANNEL_PROTOCOL_VERSION = 1;
38
+ /** The media kinds a preset may declare. Closed — an unknown kind drops the announce. */
39
+ export const PRESET_KINDS = ["video", "image", "camera"];
40
+ /**
41
+ * What an app may declare it CAN DO. Closed and validated exactly as
42
+ * `PRESET_KINDS` is — an unknown entry is dropped rather than passed through.
43
+ *
44
+ * A capability is a property of the app's own code, never a report of its
45
+ * current condition: `render-hold` says *this app carries a hold handler*, not
46
+ * *this app is held*. The console renders a control on the strength of it and
47
+ * can never learn whether the app acted, so the declaration has to be derived
48
+ * from the wiring rather than written by hand — see `buildAnnounce`.
49
+ */
50
+ export const APP_CAPABILITIES = ["render-hold"];
51
+ /**
52
+ * Caps on an inbound announce, mirrored by the console's own validator.
53
+ *
54
+ * They live in the shared contract rather than in the parser so both repos
55
+ * refuse the same payload: a cap enforced on one side only means the sender can
56
+ * emit something the receiver silently drops, which is the drift this file exists
57
+ * to prevent.
58
+ */
59
+ export const ANNOUNCE_MAX_PRESETS = 32;
60
+ export const PRESET_MAX_ID_LENGTH = 64;
61
+ export const PRESET_MAX_LABEL_LENGTH = 120;
62
+ /**
63
+ * The same class of cap as `ANNOUNCE_MAX_PRESETS`, and it exists for the same
64
+ * reason: the receiver must be able to refuse an oversized list BEFORE walking
65
+ * it, and both repos must refuse the identical payload. It is comfortably above
66
+ * `APP_CAPABILITIES.length` so growing the closed set does not require moving it.
67
+ */
68
+ export const ANNOUNCE_MAX_CAPABILITIES = 8;
69
+ /** Conservative id grammar. Ids are ours; a label is creator-facing text, an id is not. */
70
+ export const PRESET_ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/harness",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Vincentt AR dev-loop harness - in-app diagnostics provider + wire contract",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",