@vincentt-xr/harness 1.2.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.
@@ -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
+ }
@@ -1,7 +1,11 @@
1
1
  export { HarnessProvider } from "./HarnessProvider.js";
2
2
  export type { HarnessProviderProps } from "./HarnessProvider.js";
3
- export { sendAnnotation, captureScreenshot, mountFeedbackButton, type SendAnnotationOptions, type FeedbackButtonOptions, } from "./annotate.js";
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
+ 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";
5
9
  export { getClusterContainer, releaseClusterContainer } from "./cluster.js";
6
10
  export type { DiagEvent, LogEvent, NetworkEvent, TraceEvent } from "../shared/events.js";
7
11
  export type { Annotation, AnnotationInput, AnnotationSpec, AnnotationStroke, AnnotationPin, } from "../shared/events.js";
@@ -3,6 +3,10 @@
3
3
  // tree-shakes it). The relay and MCP server are NOT exported here; they are
4
4
  // run-from-bin, not imported.
5
5
  export { HarnessProvider } from "./HarnessProvider.js";
6
- export { sendAnnotation, captureScreenshot, mountFeedbackButton, } from "./annotate.js";
6
+ export { sendAnnotation, captureScreenshot, mountFeedbackButton, shouldShowFeedback, } from "./annotate.js";
7
7
  export { mountShareButton, deriveShareUrl, isPhone, isPreviewOrigin, renderQr, PREVIEW_APEXES, } from "./share.js";
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";
8
12
  export { getClusterContainer, releaseClusterContainer } from "./cluster.js";
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The compiled-in preview-apex allowlist. Previews sit on their own registrable
3
+ * domain, `vincentt.dev` (f3's `D-Preview-is-its-own-registrable-domain`); staging
4
+ * and dev are subdomains of it (`*.staging.vincentt.dev`, `*.local.vincentt.dev`),
5
+ * so a single `.vincentt.dev` suffix matches every environment. The leading dot is a
6
+ * real subdomain-label boundary, so `<label>.vincentt.dev` matches but a bare
7
+ * `vincentt.dev` or a look-alike (`evilvincentt.dev`) does not. This is a public DNS
8
+ * name, not a secret — the same host the viewer's address bar already shows.
9
+ */
10
+ export declare const PREVIEW_APEXES: readonly [".vincentt.dev"];
11
+ /**
12
+ * Is the viewer a phone? True iff BOTH `(pointer: coarse)` and `(max-width: 820px)`
13
+ * match — a coarse-pointer narrow viewer is the device you would hand off to, so the
14
+ * Share button is pointless there. A missing or THROWING `matchMedia`, or a viewer we
15
+ * cannot classify, folds to `false` ⇒ the button shows (the "unknown shows" rule: a
16
+ * missing handoff on a desktop is worse than a stray, send-nothing button on an odd
17
+ * device). `matchMedia` is injected so the predicate is pure and testable without a DOM.
18
+ */
19
+ export declare function isPhone(matchMedia?: typeof window.matchMedia): boolean;
20
+ /**
21
+ * Is this a real, phone-reachable preview origin? `https:` AND the hostname ends with
22
+ * a `PREVIEW_APEXES` suffix (`.vincentt.dev`). On the creator's plain local loop
23
+ * (`http://localhost`) or a self-signed LAN origin (`https://192.168.1.20`, `foo.local`)
24
+ * there is no phone-reachable preview link to encode and no relay a phone can POST to,
25
+ * so neither cluster control appears.
26
+ */
27
+ export declare function isPreviewOrigin(loc: Pick<Location, "protocol" | "hostname">): boolean;
28
+ /** The two window handles the framed check compares. Injected so it is testable. */
29
+ export interface FramingView {
30
+ self: unknown;
31
+ top: unknown;
32
+ }
33
+ /**
34
+ * Is this document running inside a frame? `window.self !== window.top`.
35
+ *
36
+ * A fact the app reads about ITSELF — no sender, no parameter, no message, no
37
+ * channel from the embedder. It is also true inside ANY frame, which is correct:
38
+ * the reason a control is withheld holds for any embedder, not just ours.
39
+ *
40
+ * Cross-origin does not break it. Reading `window.top` as an opaque handle and
41
+ * comparing references is same-origin-policy-safe; only reaching THROUGH it
42
+ * (`top.location`, `top.document`) throws. Nothing here dereferences it.
43
+ *
44
+ * A missing or throwing `window` folds to `false` ⇒ NOT framed. That direction is
45
+ * deliberate and is the only one that is safe here: it means an unknown context
46
+ * behaves like today's shipped top-level case rather than silently withholding a
47
+ * control on the creator's own phone. The framed case this exists for is a real
48
+ * browser with a real `window`, where the comparison is exact.
49
+ */
50
+ export declare function isFramed(view?: FramingView): boolean;
@@ -0,0 +1,76 @@
1
+ // The viewer/origin predicates the bare-DOM cluster controls gate on. A LEAF
2
+ // module: no imports, no dependencies.
3
+ //
4
+ // It exists as its own file so a control can ask "is this a real preview origin?"
5
+ // without importing `share.ts`, which statically pulls in `qrcode`. Both overlays
6
+ // sit behind guarded dynamic imports, so a prod fold drops either one — but with
7
+ // the predicates living in share.ts, mounting only the FEEDBACK chip would still
8
+ // drag the QR encoder into its chunk for a 40-line string check.
9
+ /**
10
+ * The compiled-in preview-apex allowlist. Previews sit on their own registrable
11
+ * domain, `vincentt.dev` (f3's `D-Preview-is-its-own-registrable-domain`); staging
12
+ * and dev are subdomains of it (`*.staging.vincentt.dev`, `*.local.vincentt.dev`),
13
+ * so a single `.vincentt.dev` suffix matches every environment. The leading dot is a
14
+ * real subdomain-label boundary, so `<label>.vincentt.dev` matches but a bare
15
+ * `vincentt.dev` or a look-alike (`evilvincentt.dev`) does not. This is a public DNS
16
+ * name, not a secret — the same host the viewer's address bar already shows.
17
+ */
18
+ export const PREVIEW_APEXES = [".vincentt.dev"];
19
+ /**
20
+ * Is the viewer a phone? True iff BOTH `(pointer: coarse)` and `(max-width: 820px)`
21
+ * match — a coarse-pointer narrow viewer is the device you would hand off to, so the
22
+ * Share button is pointless there. A missing or THROWING `matchMedia`, or a viewer we
23
+ * cannot classify, folds to `false` ⇒ the button shows (the "unknown shows" rule: a
24
+ * missing handoff on a desktop is worse than a stray, send-nothing button on an odd
25
+ * device). `matchMedia` is injected so the predicate is pure and testable without a DOM.
26
+ */
27
+ export function isPhone(matchMedia) {
28
+ if (typeof matchMedia !== "function")
29
+ return false;
30
+ try {
31
+ return (matchMedia("(pointer: coarse)").matches && matchMedia("(max-width: 820px)").matches);
32
+ }
33
+ catch {
34
+ // A throwing matchMedia is "unknown", never an error to propagate ⇒ show.
35
+ return false;
36
+ }
37
+ }
38
+ /**
39
+ * Is this a real, phone-reachable preview origin? `https:` AND the hostname ends with
40
+ * a `PREVIEW_APEXES` suffix (`.vincentt.dev`). On the creator's plain local loop
41
+ * (`http://localhost`) or a self-signed LAN origin (`https://192.168.1.20`, `foo.local`)
42
+ * there is no phone-reachable preview link to encode and no relay a phone can POST to,
43
+ * so neither cluster control appears.
44
+ */
45
+ export function isPreviewOrigin(loc) {
46
+ return (loc.protocol === "https:" &&
47
+ PREVIEW_APEXES.some((apex) => loc.hostname.endsWith(apex)));
48
+ }
49
+ /**
50
+ * Is this document running inside a frame? `window.self !== window.top`.
51
+ *
52
+ * A fact the app reads about ITSELF — no sender, no parameter, no message, no
53
+ * channel from the embedder. It is also true inside ANY frame, which is correct:
54
+ * the reason a control is withheld holds for any embedder, not just ours.
55
+ *
56
+ * Cross-origin does not break it. Reading `window.top` as an opaque handle and
57
+ * comparing references is same-origin-policy-safe; only reaching THROUGH it
58
+ * (`top.location`, `top.document`) throws. Nothing here dereferences it.
59
+ *
60
+ * A missing or throwing `window` folds to `false` ⇒ NOT framed. That direction is
61
+ * deliberate and is the only one that is safe here: it means an unknown context
62
+ * behaves like today's shipped top-level case rather than silently withholding a
63
+ * control on the creator's own phone. The framed case this exists for is a real
64
+ * browser with a real `window`, where the comparison is exact.
65
+ */
66
+ export function isFramed(view) {
67
+ const w = view ?? (typeof window === "undefined" ? undefined : window);
68
+ if (!w)
69
+ return false;
70
+ try {
71
+ return w.self !== w.top;
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
@@ -1,35 +1,11 @@
1
- /**
2
- * The compiled-in preview-apex allowlist. Previews sit on their own registrable
3
- * domain, `vincentt.dev` (f3's `D-Preview-is-its-own-registrable-domain`); staging
4
- * and dev are subdomains of it (`*.staging.vincentt.dev`, `*.local.vincentt.dev`),
5
- * so a single `.vincentt.dev` suffix matches every environment. The leading dot is a
6
- * real subdomain-label boundary, so `<label>.vincentt.dev` matches but a bare
7
- * `vincentt.dev` or a look-alike (`evilvincentt.dev`) does not. This is a public DNS
8
- * name, not a secret — the same host the viewer's address bar already shows.
9
- */
10
- export declare const PREVIEW_APEXES: readonly [".vincentt.dev"];
1
+ import { type FramingView } from "./previewOrigin.js";
2
+ export { isPhone, isPreviewOrigin, PREVIEW_APEXES } from "./previewOrigin.js";
11
3
  /**
12
4
  * The canonical preview URL to encode — `origin + "/"`, stable across in-app SPA
13
5
  * navigation. Reads ONLY the origin, so a deep path, query-param secret, or hash is
14
6
  * never encoded into the QR or the text field.
15
7
  */
16
8
  export declare function deriveShareUrl(location: Pick<Location, "origin">): string;
17
- /**
18
- * Is the viewer a phone? True iff BOTH `(pointer: coarse)` and `(max-width: 820px)`
19
- * match — a coarse-pointer narrow viewer is the device you would hand off to, so the
20
- * button is pointless there. A missing or THROWING `matchMedia`, or a viewer we cannot
21
- * classify, folds to `false` ⇒ the button shows (the "unknown shows" rule: a missing
22
- * handoff on a desktop is worse than a stray, send-nothing button on an odd device).
23
- * `matchMedia` is injected so the predicate is pure and testable without a DOM.
24
- */
25
- export declare function isPhone(matchMedia?: typeof window.matchMedia): boolean;
26
- /**
27
- * Is this a real, phone-reachable preview origin? `https:` AND the hostname ends with
28
- * a `PREVIEW_APEXES` suffix (`.vincentt.dev`). On the creator's plain local loop
29
- * (`http://localhost`) or a self-signed LAN origin (`https://192.168.1.20`, `foo.local`)
30
- * there is no phone-reachable preview link to encode, so the button does not appear.
31
- */
32
- export declare function isPreviewOrigin(loc: Pick<Location, "protocol" | "hostname">): boolean;
33
9
  /**
34
10
  * Render the preview URL as an inline SVG QR. Same params as the CLI's `qrPage.ts`
35
11
  * (`margin:2`, ECC `M`) so the on-screen QR is identical to the proven-scannable
@@ -41,13 +17,23 @@ export interface ShareButtonOptions {
41
17
  matchMedia?: typeof window.matchMedia;
42
18
  /** Injectable for tests. Defaults to `window.location`. */
43
19
  location?: Pick<Location, "origin" | "protocol" | "hostname">;
20
+ /** Injectable for tests. Defaults to `window`'s own handles. */
21
+ view?: FramingView;
44
22
  }
45
23
  /**
46
24
  * Mount the icon-only Share chip into the shared cluster and wire its popover.
47
25
  *
48
- * The show-gate is evaluated ONCE, here at mount: render iff the viewer is not a
49
- * phone AND the origin is a real preview origin. If the gate fails, nothing is
50
- * 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.
51
37
  *
52
38
  * Returns an unmount function that removes the chip + popover, tears down listeners,
53
39
  * and releases the shared cluster.
@@ -11,16 +11,12 @@
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
- /**
15
- * The compiled-in preview-apex allowlist. Previews sit on their own registrable
16
- * domain, `vincentt.dev` (f3's `D-Preview-is-its-own-registrable-domain`); staging
17
- * and dev are subdomains of it (`*.staging.vincentt.dev`, `*.local.vincentt.dev`),
18
- * so a single `.vincentt.dev` suffix matches every environment. The leading dot is a
19
- * real subdomain-label boundary, so `<label>.vincentt.dev` matches but a bare
20
- * `vincentt.dev` or a look-alike (`evilvincentt.dev`) does not. This is a public DNS
21
- * name, not a secret — the same host the viewer's address bar already shows.
22
- */
23
- export const PREVIEW_APEXES = [".vincentt.dev"];
14
+ import { isFramed, isPhone, isPreviewOrigin } from "./previewOrigin.js";
15
+ // The viewer/origin predicates moved to the dependency-free `previewOrigin.ts` so
16
+ // the feedback chip can gate on them without importing this module (and with it
17
+ // `qrcode`). Re-exported here because this is the path every existing caller and
18
+ // test imports them from, and the wire/API surface should not move for a refactor.
19
+ export { isPhone, isPreviewOrigin, PREVIEW_APEXES } from "./previewOrigin.js";
24
20
  /**
25
21
  * The canonical preview URL to encode — `origin + "/"`, stable across in-app SPA
26
22
  * navigation. Reads ONLY the origin, so a deep path, query-param secret, or hash is
@@ -29,35 +25,6 @@ export const PREVIEW_APEXES = [".vincentt.dev"];
29
25
  export function deriveShareUrl(location) {
30
26
  return location.origin + "/";
31
27
  }
32
- /**
33
- * Is the viewer a phone? True iff BOTH `(pointer: coarse)` and `(max-width: 820px)`
34
- * match — a coarse-pointer narrow viewer is the device you would hand off to, so the
35
- * button is pointless there. A missing or THROWING `matchMedia`, or a viewer we cannot
36
- * classify, folds to `false` ⇒ the button shows (the "unknown shows" rule: a missing
37
- * handoff on a desktop is worse than a stray, send-nothing button on an odd device).
38
- * `matchMedia` is injected so the predicate is pure and testable without a DOM.
39
- */
40
- export function isPhone(matchMedia) {
41
- if (typeof matchMedia !== "function")
42
- return false;
43
- try {
44
- return (matchMedia("(pointer: coarse)").matches && matchMedia("(max-width: 820px)").matches);
45
- }
46
- catch {
47
- // A throwing matchMedia is "unknown", never an error to propagate ⇒ show.
48
- return false;
49
- }
50
- }
51
- /**
52
- * Is this a real, phone-reachable preview origin? `https:` AND the hostname ends with
53
- * a `PREVIEW_APEXES` suffix (`.vincentt.dev`). On the creator's plain local loop
54
- * (`http://localhost`) or a self-signed LAN origin (`https://192.168.1.20`, `foo.local`)
55
- * there is no phone-reachable preview link to encode, so the button does not appear.
56
- */
57
- export function isPreviewOrigin(loc) {
58
- return (loc.protocol === "https:" &&
59
- PREVIEW_APEXES.some((apex) => loc.hostname.endsWith(apex)));
60
- }
61
28
  /**
62
29
  * Render the preview URL as an inline SVG QR. Same params as the CLI's `qrPage.ts`
63
30
  * (`margin:2`, ECC `M`) so the on-screen QR is identical to the proven-scannable
@@ -79,9 +46,17 @@ const QR_GLYPH_SVG = `<svg width="18" height="18" viewBox="0 0 24 24" fill="curr
79
46
  /**
80
47
  * Mount the icon-only Share chip into the shared cluster and wire its popover.
81
48
  *
82
- * The show-gate is evaluated ONCE, here at mount: render iff the viewer is not a
83
- * phone AND the origin is a real preview origin. If the gate fails, nothing is
84
- * 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.
85
60
  *
86
61
  * Returns an unmount function that removes the chip + popover, tears down listeners,
87
62
  * and releases the shared cluster.
@@ -93,7 +68,7 @@ export function mountShareButton(opts = {}) {
93
68
  const location = opts.location ?? (typeof window !== "undefined" ? window.location : undefined);
94
69
  if (!location)
95
70
  return () => undefined;
96
- const show = !isPhone(matchMedia) && isPreviewOrigin(location);
71
+ const show = !isFramed(opts.view) && !isPhone(matchMedia) && isPreviewOrigin(location);
97
72
  if (!show)
98
73
  return () => undefined;
99
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;