@vincentt-xr/harness 1.4.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.
@@ -1,4 +1,4 @@
1
- import { type AnnouncedPreset, type ConsoleSetMediaSourceMessage, type HarnessAnnounceMessage } from "../shared/channel.js";
1
+ import { type AnnouncedPreset, type ConsoleSetMediaSourceMessage, type ConsoleSetRenderHoldMessage, type HarnessAnnounceMessage } from "../shared/channel.js";
2
2
  import { type FramingView } from "./previewOrigin.js";
3
3
  /** The minimum of `window` this needs, so the whole module is testable without a DOM. */
4
4
  export interface ChannelWindow {
@@ -13,6 +13,16 @@ export interface ChannelOptions {
13
13
  presets: AnnouncedPreset[];
14
14
  /** Applied when the console names a preset. The app owns the swap; the console never does. */
15
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;
16
26
  /** The console origins commands may come from. Injected; defaults to the production set. */
17
27
  allowedOrigins?: readonly string[];
18
28
  /** Injectable for tests. Defaults to `window`. */
@@ -21,12 +31,23 @@ export interface ChannelOptions {
21
31
  framing?: FramingView;
22
32
  }
23
33
  /**
24
- * The announce, built from a preset list.
34
+ * The announce, built from a preset list and the app's WIRED handlers.
25
35
  *
26
36
  * Exported so a test can assert the payload's exact shape without a window, and
27
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.
28
49
  */
29
- export declare function buildAnnounce(presets: AnnouncedPreset[]): HarnessAnnounceMessage;
50
+ export declare function buildAnnounce(presets: AnnouncedPreset[], handlers?: Pick<ChannelOptions, "onSetRenderHold">): HarnessAnnounceMessage;
30
51
  /**
31
52
  * Is this a command this app acts on?
32
53
  *
@@ -36,6 +57,15 @@ export declare function buildAnnounce(presets: AnnouncedPreset[]): HarnessAnnoun
36
57
  * by a console that decides to send one.
37
58
  */
38
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;
39
69
  /**
40
70
  * Open the app's side of the channel. Returns a teardown.
41
71
  *
@@ -1,11 +1,16 @@
1
1
  // The app side of the console↔app channel.
2
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
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
6
  // (B-F13-3). The transport is bidirectional — postMessage has no one-way mode —
7
7
  // so the vocabulary in `shared/channel.ts` and the single-send guard below are
8
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.
9
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";
10
15
  import { isAllowedConsoleOrigin, PRODUCTION_CONSOLE_ORIGINS } from "./consoleOrigin.js";
11
16
  import { isFramed } from "./previewOrigin.js";
@@ -39,14 +44,25 @@ function isValidPreset(preset) {
39
44
  return true;
40
45
  }
41
46
  /**
42
- * The announce, built from a preset list.
47
+ * The announce, built from a preset list and the app's WIRED handlers.
43
48
  *
44
49
  * Exported so a test can assert the payload's exact shape without a window, and
45
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.
46
62
  */
47
- export function buildAnnounce(presets) {
63
+ export function buildAnnounce(presets, handlers = {}) {
48
64
  const valid = presets.filter(isValidPreset).slice(0, ANNOUNCE_MAX_PRESETS);
49
- return {
65
+ const announce = {
50
66
  source: HARNESS_MESSAGE_SOURCE,
51
67
  v: CHANNEL_PROTOCOL_VERSION,
52
68
  type: "announce",
@@ -57,6 +73,9 @@ export function buildAnnounce(presets) {
57
73
  mirrored: p.mirrored,
58
74
  })),
59
75
  };
76
+ if (handlers.onSetRenderHold)
77
+ announce.capabilities = ["render-hold"];
78
+ return announce;
60
79
  }
61
80
  /**
62
81
  * Is this a command this app acts on?
@@ -80,6 +99,26 @@ export function isSetMediaSourceCommand(data) {
80
99
  return false;
81
100
  return PRESET_ID_RE.test(m.presetId);
82
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
+ }
83
122
  /**
84
123
  * Open the app's side of the channel. Returns a teardown.
85
124
  *
@@ -107,9 +146,21 @@ export function openConsoleChannel(opts) {
107
146
  // about — and no test could see it, because none delivered a null source.
108
147
  if (event.source !== view.parent)
109
148
  return;
110
- if (!isSetMediaSourceCommand(event.data))
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);
111
156
  return;
112
- opts.onSetMediaSource(event.data.presetId);
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
+ }
113
164
  };
114
165
  view.addEventListener("message", onMessage);
115
166
  // ONE MESSAGE, ONCE. Posted to each allowed console origin explicitly — never
@@ -119,7 +170,7 @@ export function openConsoleChannel(opts) {
119
170
  // There is deliberately no re-announce, no retry and no interval: a console
120
171
  // that mounts after the app misses it and stays in its waiting state, which is
121
172
  // the honest failure the design chose over a channel that keeps talking.
122
- const announce = buildAnnounce(opts.presets);
173
+ const announce = buildAnnounce(opts.presets, opts);
123
174
  for (const origin of allowed) {
124
175
  try {
125
176
  view.parent?.postMessage(announce, origin);
@@ -3,9 +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";
6
+ export { openConsoleChannel, buildAnnounce, isSetMediaSourceCommand, isSetRenderHoldCommand, type ChannelOptions, type ChannelWindow, } from "./channel.js";
7
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";
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";
9
9
  export { getClusterContainer, releaseClusterContainer } from "./cluster.js";
10
10
  export type { DiagEvent, LogEvent, NetworkEvent, TraceEvent } from "../shared/events.js";
11
11
  export type { Annotation, AnnotationInput, AnnotationSpec, AnnotationStroke, AnnotationPin, } from "../shared/events.js";
@@ -6,7 +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";
9
+ export { openConsoleChannel, buildAnnounce, isSetMediaSourceCommand, isSetRenderHoldCommand, } from "./channel.js";
10
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";
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";
12
12
  export { getClusterContainer, releaseClusterContainer } from "./cluster.js";
@@ -1,9 +1,9 @@
1
1
  /** The app→console message names. Exactly one, and adding a second is the failure. */
2
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"];
3
+ /** The console→app message names. Exactly two. */
4
+ export declare const CONSOLE_INBOUND_MESSAGE_TYPES: readonly ["set-media-source", "set-render-hold"];
5
5
  /** The whole channel vocabulary, both directions. */
6
- export declare const CHANNEL_MESSAGE_TYPES: readonly ["announce", "set-media-source"];
6
+ export declare const CHANNEL_MESSAGE_TYPES: readonly ["announce", "set-media-source", "set-render-hold"];
7
7
  export type HarnessOutboundMessageType = (typeof HARNESS_OUTBOUND_MESSAGE_TYPES)[number];
8
8
  export type ConsoleInboundMessageType = (typeof CONSOLE_INBOUND_MESSAGE_TYPES)[number];
9
9
  /** Envelope discriminator for app→console traffic. */
@@ -15,6 +15,18 @@ export declare const CHANNEL_PROTOCOL_VERSION = 1;
15
15
  /** The media kinds a preset may declare. Closed — an unknown kind drops the announce. */
16
16
  export declare const PRESET_KINDS: readonly ["video", "image", "camera"];
17
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];
18
30
  /**
19
31
  * One announced preset — CAPABILITY, not observation.
20
32
  *
@@ -37,14 +49,34 @@ export interface HarnessAnnounceMessage {
37
49
  v: typeof CHANNEL_PROTOCOL_VERSION;
38
50
  type: "announce";
39
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[];
40
59
  }
41
- /** console → app. The only command name that exists. */
60
+ /** console → app. Applies a named media preset. */
42
61
  export interface ConsoleSetMediaSourceMessage {
43
62
  source: typeof CONSOLE_MESSAGE_SOURCE;
44
63
  v: typeof CHANNEL_PROTOCOL_VERSION;
45
64
  type: "set-media-source";
46
65
  presetId: string;
47
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
+ }
48
80
  /**
49
81
  * Caps on an inbound announce, mirrored by the console's own validator.
50
82
  *
@@ -56,5 +88,12 @@ export interface ConsoleSetMediaSourceMessage {
56
88
  export declare const ANNOUNCE_MAX_PRESETS = 32;
57
89
  export declare const PRESET_MAX_ID_LENGTH = 64;
58
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;
59
98
  /** Conservative id grammar. Ids are ours; a label is creator-facing text, an id is not. */
60
99
  export declare const PRESET_ID_RE: RegExp;
@@ -1,6 +1,6 @@
1
1
  // The console↔app channel's wire contract. Pure data: no DOM, no React, no node.
2
2
  //
3
- // The console mirrors these two names in its own `contract.ts`. They ship on
3
+ // The console mirrors these three names in its own `contract.ts`. They ship on
4
4
  // DIFFERENT release cadences — @vincentt-xr/harness is caret-pinned and reaches
5
5
  // old projects on a plain reinstall, the console deploys independently — so a
6
6
  // rename in one lands in creators' apps weeks apart from the other, and the
@@ -13,10 +13,17 @@
13
13
  // observation. postMessage has no one-way mode — both directions of the
14
14
  // transport are open at all times — so the vocabulary is the only cap there is,
15
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.
16
23
  /** The app→console message names. Exactly one, and adding a second is the failure. */
17
24
  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"];
25
+ /** The console→app message names. Exactly two. */
26
+ export const CONSOLE_INBOUND_MESSAGE_TYPES = ["set-media-source", "set-render-hold"];
20
27
  /** The whole channel vocabulary, both directions. */
21
28
  export const CHANNEL_MESSAGE_TYPES = [
22
29
  ...HARNESS_OUTBOUND_MESSAGE_TYPES,
@@ -30,6 +37,17 @@ export const CONSOLE_MESSAGE_SOURCE = "vincentt-console";
30
37
  export const CHANNEL_PROTOCOL_VERSION = 1;
31
38
  /** The media kinds a preset may declare. Closed — an unknown kind drops the announce. */
32
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"];
33
51
  /**
34
52
  * Caps on an inbound announce, mirrored by the console's own validator.
35
53
  *
@@ -41,5 +59,12 @@ export const PRESET_KINDS = ["video", "image", "camera"];
41
59
  export const ANNOUNCE_MAX_PRESETS = 32;
42
60
  export const PRESET_MAX_ID_LENGTH = 64;
43
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;
44
69
  /** Conservative id grammar. Ids are ours; a label is creator-facing text, an id is not. */
45
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.4.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",