@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.
@@ -24,11 +24,10 @@ export interface HarnessProviderProps {
24
24
  captureNetwork?: boolean;
25
25
  captureTrace?: boolean;
26
26
  /**
27
- * Mount the floating "Send feedback" button (the reverse-channel capture
28
- * overlay). Present in code and shares the Share control's cluster mount, but
29
- * defaults to OFF (`false`) pending a later decision a creator sees only the
30
- * Share control today. Re-displaying it is a one-line default flip; the button
31
- * takes the adjacent flex slot with no layout move.
27
+ * Mount the Send-feedback chip (the reverse-channel capture overlay) into the
28
+ * shared top-right cluster. Defaults to `enabled` on in preview, tree-shaken
29
+ * out of production. It is the phone's only way to speak, and f12's central
30
+ * scenario is unreachable without it.
32
31
  */
33
32
  feedback?: boolean;
34
33
  /**
@@ -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";
@@ -90,13 +91,53 @@ export function HarnessProvider(props) {
90
91
  captureTrace: props.captureTrace,
91
92
  });
92
93
  });
94
+ let unmountShare;
93
95
  let unmountFeedback;
94
- // Feedback defaults OFF (opt-in with `feedback={true}`) the code path stays
95
- // present so re-enabling it later is a one-line default flip, but a creator
96
- // sees only the Share control today.
97
- if (props.feedback === true) {
98
- // Separate dynamic import so the capture overlay (and its DOM code) also
99
- // drops from a production bundle.
96
+ // SHARE MOUNTS FIRST, AND THE ORDER IS SEQUENCED, NOT RACED.
97
+ //
98
+ // Insertion order into the cluster IS flex order, and Share is the cluster's
99
+ // established first member — a new control must take the adjacent slot
100
+ // without moving it. Two independent `import(...).then(...)` chains resolve
101
+ // in whatever order the loader finishes them, so leaving these parallel puts
102
+ // the cluster's layout at the mercy of module-fetch timing. Feedback is
103
+ // therefore mounted from INSIDE Share's continuation.
104
+ //
105
+ // Both remain separate dynamic imports, which is what lets a production
106
+ // build drop each overlay (and `qrcode`, reached only from share.js). The
107
+ // share import is also written FIRST in this file, so the ordering is legible
108
+ // to a reader (and to a source-level assertion) and not only to the runtime.
109
+ //
110
+ // Share defaults to `enabled`. Feedback defaults to `enabled` too (opt OUT
111
+ // with `feedback={false}`); the chip gates itself on the origin at mount, so
112
+ // the flag decides whether the module is fetched at all — as does the
113
+ // framing check hoisted into `mountFeedback` below.
114
+ if (props.share !== false) {
115
+ void import("./share.js").then(({ mountShareButton }) => {
116
+ if (cancelled)
117
+ return;
118
+ unmountShare = mountShareButton({});
119
+ // Only now — so Share holds slot one even when its module resolves last.
120
+ mountFeedback();
121
+ });
122
+ }
123
+ else {
124
+ // No Share at all: feedback is the cluster's only member, so there is no
125
+ // order to preserve and nothing to wait for.
126
+ mountFeedback();
127
+ }
128
+ function mountFeedback() {
129
+ if (props.feedback === false)
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;
100
141
  void import("./annotate.js").then(({ mountFeedbackButton }) => {
101
142
  if (cancelled)
102
143
  return;
@@ -110,16 +151,6 @@ export function HarnessProvider(props) {
110
151
  });
111
152
  });
112
153
  }
113
- let unmountShare;
114
- // Share defaults to `enabled`. Separate dynamic import so the overlay's DOM
115
- // code and `qrcode` (reached only from here) drop from a production bundle.
116
- if (props.share !== false) {
117
- void import("./share.js").then(({ mountShareButton }) => {
118
- if (cancelled)
119
- return;
120
- unmountShare = mountShareButton({});
121
- });
122
- }
123
154
  return () => {
124
155
  cancelled = true;
125
156
  teardown?.();
@@ -1,3 +1,4 @@
1
+ import { type FramingView } from "./previewOrigin.js";
1
2
  import type { AnnotationInput, AnnotationSpec } from "../shared/events.js";
2
3
  /**
3
4
  * Capture the current frame as a PNG data URL. Prefers the largest <canvas> (the
@@ -18,17 +19,85 @@ export declare function sendAnnotation(input: AnnotationInput, opts?: SendAnnota
18
19
  id: string;
19
20
  seq: number;
20
21
  }>;
22
+ /** The counter's current value. Exported for tests; not part of the app surface. */
23
+ export declare function getConsecutiveFailures(): number;
24
+ /**
25
+ * ANY failed POST increments — non-2xx, network error, abort alike. The device
26
+ * cannot distinguish a dead tunnel from a dead relay from a dead preview, and
27
+ * must not try.
28
+ */
29
+ export declare function recordSendFailure(): number;
30
+ /**
31
+ * A successful POST resets to 0, UNCONDITIONALLY. One send getting through proves
32
+ * the channel is alive, so the next failure is a first failure again.
33
+ *
34
+ * Nothing else calls this. Not the panel closing, not the chip being tapped, not
35
+ * a timer, not a visibility change — see the module comment.
36
+ */
37
+ export declare function recordSendSuccess(): void;
38
+ /** Test-only: restore the module counter to its initial state. */
39
+ export declare function __resetFailureCounterForTests(): void;
40
+ /** At 2+ consecutive failures the channel is gone, not having a bad moment. */
41
+ export declare function isStranded(): boolean;
21
42
  export interface FeedbackButtonOptions extends SendAnnotationOptions {
22
43
  sessionId?: string;
23
- /** Collect the creator's message. Default: window.prompt. */
24
- promptMessage?: () => string | null | Promise<string | null>;
25
44
  /** Override the captured spec (strokes/pins/labels). Default: empty. */
26
45
  spec?: AnnotationSpec;
46
+ /** Injectable for tests. Defaults to `window.matchMedia`. */
47
+ matchMedia?: typeof window.matchMedia;
48
+ /** Injectable for tests. Defaults to `window.location`. */
49
+ location?: Pick<Location, "origin" | "protocol" | "hostname">;
50
+ /** Injectable for tests. Defaults to the real POST. */
51
+ send?: (input: AnnotationInput) => Promise<unknown>;
52
+ /** Injectable for tests. Defaults to `captureScreenshot`. */
53
+ capture?: () => string;
54
+ /** Injectable for tests. Defaults to the real `window` (self/top identity). */
55
+ view?: FramingView;
27
56
  }
28
57
  /**
29
- * Mount a minimal floating "Send feedback" button. On click it collects a message,
30
- * captures the frame, and sends the annotation. Returns an unmount function. This
31
- * is the smallest useful Send action; a richer draw-on-frame overlay can replace
32
- * the capture/prompt without changing the wire contract.
58
+ * Is the feedback chip shown? The MIRROR of Share's rule, because the two are
59
+ * opposite handoffs. Share is `!isPhone(mm) && isPreviewOrigin(loc)`; feedback
60
+ * keeps the origin arm and DROPS the phone veto, because a phone is the device
61
+ * this control exists for. So the gate is the origin arm alone:
62
+ *
63
+ * | context | Share | feedback |
64
+ * |-------------------------------|--------|----------|
65
+ * | coarse-pointer / narrow | absent | PRESENT |
66
+ * | desktop | present| present |
67
+ * | real preview origin | req'd | req'd |
68
+ * | plain localhost / LAN | absent | ABSENT — no relay reachable |
69
+ *
70
+ * `matchMedia` is NOT a parameter: no viewport class can change this answer, and
71
+ * taking one would imply a veto that does not exist. Never rendered disabled or
72
+ * explained-away — on localhost nothing is appended at all.
73
+ */
74
+ export declare function shouldShowFeedback(loc: Pick<Location, "protocol" | "hostname">): boolean;
75
+ /**
76
+ * Mount the icon-only Send-feedback chip into the shared cluster and wire its
77
+ * composing panel.
78
+ *
79
+ * The show-gate is evaluated ONCE, here at mount. If it fails, nothing is
80
+ * appended to the DOM (no disabled state) and the returned unmount is a no-op.
81
+ *
82
+ * Share mounts first from HarnessProvider, and insertion order IS flex order, so
83
+ * appending here always leaves Share as the cluster's first member.
84
+ *
85
+ * THE FRAMED VETO (f13 `D-Feedback-does-not-mount-in-the-frame`, security
86
+ * MUST-FIX 5). Send POSTs to a route whose own comment states it is
87
+ * "unauthenticated and reachable by anyone holding the capability URL", and the
88
+ * write lands in the creator's coding-agent context. Inside a frame that control
89
+ * is positioned by the embedder's CSS on a page the creator did not choose to
90
+ * load it on, so a UI-redress lure (drag-and-paste into the textarea, a
91
+ * "click twice to continue" chain) reaches a text-injection channel needing no
92
+ * browser permission grant at all — the class of harm the frame's absent `allow`
93
+ * was built to stop, arriving through a door `allow` does not cover.
94
+ *
95
+ * THE MOUNT is gated, not the visibility. A hidden-but-mounted chip still holds
96
+ * the write path, and a hidden control is exactly what an overlay attack wants.
97
+ * Nothing is appended; there is no disabled state to re-enable.
98
+ *
99
+ * The cost is small and known: a creator viewing their own preview framed in
100
+ * their own console cannot Send from inside the frame. It is their own agent and
101
+ * the terminal is where they are already talking to it.
33
102
  */
34
103
  export declare function mountFeedbackButton(opts?: FeedbackButtonOptions): () => void;