@vincentt-xr/harness 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/HarnessProvider.d.ts +47 -2
- package/dist/client/HarnessProvider.js +71 -12
- package/dist/client/annotate.js +8 -6
- package/dist/client/cluster.d.ts +12 -0
- package/dist/client/cluster.js +51 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +2 -0
- package/dist/client/instrument.js +13 -3
- package/dist/client/share.d.ts +55 -0
- package/dist/client/share.js +318 -0
- package/dist/shared/events.d.ts +110 -4
- package/dist/shared/events.js +25 -1
- package/dist/tunnel/cbor.d.ts +71 -0
- package/dist/tunnel/cbor.js +403 -0
- package/dist/tunnel/client.d.ts +219 -0
- package/dist/tunnel/client.js +620 -0
- package/dist/tunnel/forwardTarget.d.ts +69 -0
- package/dist/tunnel/forwardTarget.js +174 -0
- package/dist/tunnel/frame.d.ts +126 -0
- package/dist/tunnel/frame.js +244 -0
- package/dist/tunnel/index.d.ts +11 -0
- package/dist/tunnel/index.js +11 -0
- package/package.json +15 -3
|
@@ -12,15 +12,60 @@ export interface HarnessProviderProps {
|
|
|
12
12
|
* app). Override for the production review-service sink.
|
|
13
13
|
*/
|
|
14
14
|
relayUrl?: string;
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* The key grouping this tab's events into one TESTER. Defaults to a per-tab
|
|
17
|
+
* CSPRNG id in `sessionStorage`, which is what you want; override only for the
|
|
18
|
+
* production review-service path. A value passed here is client-supplied input
|
|
19
|
+
* like any other — the relay validates it and never renders it, and passing
|
|
20
|
+
* another tab's value merges the two into one tester.
|
|
21
|
+
*/
|
|
16
22
|
sessionId?: string;
|
|
17
23
|
captureConsole?: boolean;
|
|
18
24
|
captureNetwork?: boolean;
|
|
19
25
|
captureTrace?: boolean;
|
|
20
26
|
/**
|
|
21
27
|
* Mount the floating "Send feedback" button (the reverse-channel capture
|
|
22
|
-
* overlay).
|
|
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.
|
|
23
32
|
*/
|
|
24
33
|
feedback?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Mount the in-app Share control (the desktop-to-phone QR handoff overlay).
|
|
36
|
+
* Defaults to `enabled` — on in preview, tree-shaken out of production.
|
|
37
|
+
*/
|
|
38
|
+
share?: boolean;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 128 bits of CSPRNG, hex, as `s2_<32 hex>`.
|
|
42
|
+
*
|
|
43
|
+
* There is deliberately NO Math.random fallback. The previous generator's ~10^6
|
|
44
|
+
* space is exactly what this replaces, and a fallback reintroducing it would be
|
|
45
|
+
* the branch that actually runs on the oldest phone in the room. Both paths here
|
|
46
|
+
* are Web Crypto: randomUUID where it exists, getRandomValues otherwise — and
|
|
47
|
+
* getRandomValues has been in every browser that can run this app for a decade.
|
|
48
|
+
* If neither exists we throw rather than guess, because a weak id is a SILENT
|
|
49
|
+
* merge of two testers' streams, and no id at all is merely un-attributed.
|
|
50
|
+
*/
|
|
51
|
+
export declare function generateClientSessionId(): string;
|
|
52
|
+
/** The slice of Storage this needs — so the rule below is testable without a DOM. */
|
|
53
|
+
export interface SessionIdStorage {
|
|
54
|
+
getItem(key: string): string | null;
|
|
55
|
+
setItem(key: string, value: string): void;
|
|
25
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* The key the relay groups this tab's events by — one TESTER.
|
|
59
|
+
*
|
|
60
|
+
* `sessionStorage`, not `localStorage`, and the choice is load-bearing on both
|
|
61
|
+
* ends: it is per TAB (so one QR scan is one tester even though the tab opens a
|
|
62
|
+
* second connection for assets) and it DIES WITH THE TAB (so the grouping is
|
|
63
|
+
* bounded by construction rather than by a rule someone has to remember, and
|
|
64
|
+
* cannot outlive the session's terminal window).
|
|
65
|
+
*
|
|
66
|
+
* A stored value that misses the current grammar is OVERWRITTEN, not migrated:
|
|
67
|
+
* it was minted at the entropy this replaces. The rewrite happens here at mount,
|
|
68
|
+
* before the first batch, so no id is ever renumbered mid-run.
|
|
69
|
+
*/
|
|
70
|
+
export declare function resolveClientSessionId(storage: SessionIdStorage | undefined): string;
|
|
26
71
|
export declare function HarnessProvider(props: HarnessProviderProps): ReactNode;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// accident. The relay URL and session id default to what the preview loop sets,
|
|
9
9
|
// but both are overridable for the production review-service path later.
|
|
10
10
|
import { useEffect } from "react";
|
|
11
|
+
import { CLIENT_SESSION_ID_RE } from "../shared/events.js";
|
|
11
12
|
function defaultRelayUrl() {
|
|
12
13
|
if (typeof window === "undefined")
|
|
13
14
|
return "ws://localhost:7331";
|
|
@@ -16,19 +17,56 @@ function defaultRelayUrl() {
|
|
|
16
17
|
// so one cloudflared tunnel carries both. The relay CLI serves this path.
|
|
17
18
|
return `${proto}://${window.location.host}/__harness`;
|
|
18
19
|
}
|
|
20
|
+
const SESSION_STORAGE_KEY = "__vincentt_harness_session";
|
|
21
|
+
/**
|
|
22
|
+
* 128 bits of CSPRNG, hex, as `s2_<32 hex>`.
|
|
23
|
+
*
|
|
24
|
+
* There is deliberately NO Math.random fallback. The previous generator's ~10^6
|
|
25
|
+
* space is exactly what this replaces, and a fallback reintroducing it would be
|
|
26
|
+
* the branch that actually runs on the oldest phone in the room. Both paths here
|
|
27
|
+
* are Web Crypto: randomUUID where it exists, getRandomValues otherwise — and
|
|
28
|
+
* getRandomValues has been in every browser that can run this app for a decade.
|
|
29
|
+
* If neither exists we throw rather than guess, because a weak id is a SILENT
|
|
30
|
+
* merge of two testers' streams, and no id at all is merely un-attributed.
|
|
31
|
+
*/
|
|
32
|
+
export function generateClientSessionId() {
|
|
33
|
+
const c = globalThis.crypto;
|
|
34
|
+
if (c?.randomUUID)
|
|
35
|
+
return `s2_${c.randomUUID().replace(/-/g, "")}`;
|
|
36
|
+
if (c?.getRandomValues) {
|
|
37
|
+
const bytes = c.getRandomValues(new Uint8Array(16));
|
|
38
|
+
let hex = "";
|
|
39
|
+
for (const b of bytes)
|
|
40
|
+
hex += b.toString(16).padStart(2, "0");
|
|
41
|
+
return `s2_${hex}`;
|
|
42
|
+
}
|
|
43
|
+
throw new Error("harness: Web Crypto unavailable; cannot mint a session id");
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The key the relay groups this tab's events by — one TESTER.
|
|
47
|
+
*
|
|
48
|
+
* `sessionStorage`, not `localStorage`, and the choice is load-bearing on both
|
|
49
|
+
* ends: it is per TAB (so one QR scan is one tester even though the tab opens a
|
|
50
|
+
* second connection for assets) and it DIES WITH THE TAB (so the grouping is
|
|
51
|
+
* bounded by construction rather than by a rule someone has to remember, and
|
|
52
|
+
* cannot outlive the session's terminal window).
|
|
53
|
+
*
|
|
54
|
+
* A stored value that misses the current grammar is OVERWRITTEN, not migrated:
|
|
55
|
+
* it was minted at the entropy this replaces. The rewrite happens here at mount,
|
|
56
|
+
* before the first batch, so no id is ever renumbered mid-run.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveClientSessionId(storage) {
|
|
59
|
+
const existing = storage?.getItem(SESSION_STORAGE_KEY);
|
|
60
|
+
if (existing && CLIENT_SESSION_ID_RE.test(existing))
|
|
61
|
+
return existing;
|
|
62
|
+
const id = generateClientSessionId();
|
|
63
|
+
storage?.setItem(SESSION_STORAGE_KEY, id);
|
|
64
|
+
return id;
|
|
65
|
+
}
|
|
19
66
|
function defaultSessionId() {
|
|
20
67
|
if (typeof window === "undefined")
|
|
21
68
|
return "server";
|
|
22
|
-
|
|
23
|
-
const existing = window.sessionStorage?.getItem(key);
|
|
24
|
-
if (existing)
|
|
25
|
-
return existing;
|
|
26
|
-
// Per-load id; not security-sensitive, just needs to be distinct enough that
|
|
27
|
-
// two phones on one relay don't collide. No Math.random dependency in the
|
|
28
|
-
// pure layer — this imperative shell may use it.
|
|
29
|
-
const id = `s_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
|
|
30
|
-
window.sessionStorage?.setItem(key, id);
|
|
31
|
-
return id;
|
|
69
|
+
return resolveClientSessionId(window.sessionStorage);
|
|
32
70
|
}
|
|
33
71
|
const DEV = typeof import.meta !== "undefined" &&
|
|
34
72
|
import.meta.env?.DEV;
|
|
@@ -53,19 +91,40 @@ export function HarnessProvider(props) {
|
|
|
53
91
|
});
|
|
54
92
|
});
|
|
55
93
|
let unmountFeedback;
|
|
56
|
-
|
|
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) {
|
|
57
98
|
// Separate dynamic import so the capture overlay (and its DOM code) also
|
|
58
99
|
// drops from a production bundle.
|
|
59
100
|
void import("./annotate.js").then(({ mountFeedbackButton }) => {
|
|
60
101
|
if (cancelled)
|
|
61
102
|
return;
|
|
62
|
-
|
|
103
|
+
// The SAME resolved key the instrumentation sends, not the bare prop.
|
|
104
|
+
// The prop is undefined for every app that does not set it — i.e. the
|
|
105
|
+
// default — so passing it through left every real annotation
|
|
106
|
+
// un-attributed while the tests, which build the input by hand, never
|
|
107
|
+
// exercised this line.
|
|
108
|
+
unmountFeedback = mountFeedbackButton({
|
|
109
|
+
sessionId: props.sessionId ?? defaultSessionId(),
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
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({});
|
|
63
121
|
});
|
|
64
122
|
}
|
|
65
123
|
return () => {
|
|
66
124
|
cancelled = true;
|
|
67
125
|
teardown?.();
|
|
68
126
|
unmountFeedback?.();
|
|
127
|
+
unmountShare?.();
|
|
69
128
|
};
|
|
70
129
|
// Instrumentation is installed once for the provider's lifetime; option
|
|
71
130
|
// changes mid-session are not a supported case.
|
package/dist/client/annotate.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Loaded via dynamic import from HarnessProvider (like the instrumentation), so a
|
|
8
8
|
// production build never ships it. DOM-only, no React.
|
|
9
|
+
import { getClusterContainer, releaseClusterContainer } from "./cluster.js";
|
|
9
10
|
/** Default relay HTTP base: the same origin the app is served on + the harness path. */
|
|
10
11
|
function defaultRelayHttpUrl() {
|
|
11
12
|
return `${window.location.origin}/__harness`;
|
|
@@ -58,10 +59,6 @@ export function mountFeedbackButton(opts = {}) {
|
|
|
58
59
|
const btn = document.createElement("button");
|
|
59
60
|
btn.textContent = "Send feedback";
|
|
60
61
|
Object.assign(btn.style, {
|
|
61
|
-
position: "fixed",
|
|
62
|
-
right: "12px",
|
|
63
|
-
bottom: "12px",
|
|
64
|
-
zIndex: "2147483647",
|
|
65
62
|
padding: "10px 14px",
|
|
66
63
|
borderRadius: "10px",
|
|
67
64
|
border: "none",
|
|
@@ -99,6 +96,11 @@ export function mountFeedbackButton(opts = {}) {
|
|
|
99
96
|
console.error("[harness] annotation send failed:", err);
|
|
100
97
|
}
|
|
101
98
|
});
|
|
102
|
-
|
|
103
|
-
|
|
99
|
+
// Shares the top-right cluster with the other bare-DOM controls (Share today);
|
|
100
|
+
// the container positions it, so no fixed-position styles here.
|
|
101
|
+
getClusterContainer().appendChild(btn);
|
|
102
|
+
return () => {
|
|
103
|
+
btn.remove();
|
|
104
|
+
releaseClusterContainer();
|
|
105
|
+
};
|
|
104
106
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared control container — idempotent. Creates one `#vt-cluster` node on the
|
|
3
|
+
* first call and returns the SAME node on every subsequent call. Each call counts
|
|
4
|
+
* as one holder; pair it with exactly one `releaseClusterContainer()` on unmount.
|
|
5
|
+
*/
|
|
6
|
+
export declare function getClusterContainer(): HTMLElement;
|
|
7
|
+
/**
|
|
8
|
+
* Release one holder of the container. When the last holder releases (count hits
|
|
9
|
+
* zero) the `#vt-cluster` node is removed from the DOM. Extra releases past zero
|
|
10
|
+
* are a no-op, so an over-release cannot go negative or throw.
|
|
11
|
+
*/
|
|
12
|
+
export declare function releaseClusterContainer(): void;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The one shared fixed-position mount for the harness's bare-DOM dev controls.
|
|
2
|
+
// Every floating control (Share today; feedback when re-displayed) appends into
|
|
3
|
+
// this single top-right flex row rather than positioning itself, so controls sit
|
|
4
|
+
// in a stable cluster and a new one takes the adjacent slot with no layout move.
|
|
5
|
+
//
|
|
6
|
+
// DOM-only, no React. Loaded only through the dynamic imports in HarnessProvider,
|
|
7
|
+
// so it never enters a production bundle.
|
|
8
|
+
const CLUSTER_ID = "vt-cluster";
|
|
9
|
+
// Ref-counted so the container is created on the first control's mount and removed
|
|
10
|
+
// only when the last control unmounts. A module-level count is the shared owner:
|
|
11
|
+
// getClusterContainer() increments, releaseClusterContainer() decrements.
|
|
12
|
+
let refCount = 0;
|
|
13
|
+
/**
|
|
14
|
+
* The shared control container — idempotent. Creates one `#vt-cluster` node on the
|
|
15
|
+
* first call and returns the SAME node on every subsequent call. Each call counts
|
|
16
|
+
* as one holder; pair it with exactly one `releaseClusterContainer()` on unmount.
|
|
17
|
+
*/
|
|
18
|
+
export function getClusterContainer() {
|
|
19
|
+
const existing = document.getElementById(CLUSTER_ID);
|
|
20
|
+
if (existing) {
|
|
21
|
+
refCount += 1;
|
|
22
|
+
return existing;
|
|
23
|
+
}
|
|
24
|
+
const el = document.createElement("div");
|
|
25
|
+
el.id = CLUSTER_ID;
|
|
26
|
+
Object.assign(el.style, {
|
|
27
|
+
position: "fixed",
|
|
28
|
+
top: "12px",
|
|
29
|
+
right: "12px",
|
|
30
|
+
zIndex: "2147483647",
|
|
31
|
+
display: "flex",
|
|
32
|
+
gap: "8px",
|
|
33
|
+
alignItems: "flex-start",
|
|
34
|
+
});
|
|
35
|
+
document.body.appendChild(el);
|
|
36
|
+
refCount += 1;
|
|
37
|
+
return el;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Release one holder of the container. When the last holder releases (count hits
|
|
41
|
+
* zero) the `#vt-cluster` node is removed from the DOM. Extra releases past zero
|
|
42
|
+
* are a no-op, so an over-release cannot go negative or throw.
|
|
43
|
+
*/
|
|
44
|
+
export function releaseClusterContainer() {
|
|
45
|
+
if (refCount === 0)
|
|
46
|
+
return;
|
|
47
|
+
refCount -= 1;
|
|
48
|
+
if (refCount === 0) {
|
|
49
|
+
document.getElementById(CLUSTER_ID)?.remove();
|
|
50
|
+
}
|
|
51
|
+
}
|
package/dist/client/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { HarnessProvider } from "./HarnessProvider.js";
|
|
2
2
|
export type { HarnessProviderProps } from "./HarnessProvider.js";
|
|
3
3
|
export { sendAnnotation, captureScreenshot, mountFeedbackButton, type SendAnnotationOptions, type FeedbackButtonOptions, } from "./annotate.js";
|
|
4
|
+
export { mountShareButton, deriveShareUrl, isPhone, isPreviewOrigin, renderQr, PREVIEW_APEXES, type ShareButtonOptions, } from "./share.js";
|
|
5
|
+
export { getClusterContainer, releaseClusterContainer } from "./cluster.js";
|
|
4
6
|
export type { DiagEvent, LogEvent, NetworkEvent, TraceEvent } from "../shared/events.js";
|
|
5
7
|
export type { Annotation, AnnotationInput, AnnotationSpec, AnnotationStroke, AnnotationPin, } from "../shared/events.js";
|
package/dist/client/index.js
CHANGED
|
@@ -4,3 +4,5 @@
|
|
|
4
4
|
// run-from-bin, not imported.
|
|
5
5
|
export { HarnessProvider } from "./HarnessProvider.js";
|
|
6
6
|
export { sendAnnotation, captureScreenshot, mountFeedbackButton, } from "./annotate.js";
|
|
7
|
+
export { mountShareButton, deriveShareUrl, isPhone, isPreviewOrigin, renderQr, PREVIEW_APEXES, } from "./share.js";
|
|
8
|
+
export { getClusterContainer, releaseClusterContainer } from "./cluster.js";
|
|
@@ -16,7 +16,15 @@ export function installInstrumentation(opts) {
|
|
|
16
16
|
if (w.__vincenttHarness)
|
|
17
17
|
return () => undefined;
|
|
18
18
|
w.__vincenttHarness = true;
|
|
19
|
-
|
|
19
|
+
// The hello is the FIRST message on the socket, sent on every open (including a
|
|
20
|
+
// reconnect, which is a fresh connection the relay must re-attribute). It rides
|
|
21
|
+
// the socket directly, NOT the EventBuffer flush: a silent app never flushes, so
|
|
22
|
+
// routing it through the buffer would reintroduce the exact silence-on-connect
|
|
23
|
+
// this exists to kill. The buffer path stays untouched.
|
|
24
|
+
const hello = { type: "hello", sessionId: opts.sessionId };
|
|
25
|
+
const socket = openSocket(opts.relayUrl, () => {
|
|
26
|
+
socket.send(JSON.stringify(hello));
|
|
27
|
+
});
|
|
20
28
|
const buffer = new EventBuffer({
|
|
21
29
|
sessionId: opts.sessionId,
|
|
22
30
|
send: (batch) => {
|
|
@@ -44,8 +52,9 @@ export function installInstrumentation(opts) {
|
|
|
44
52
|
w.__vincenttHarness = false;
|
|
45
53
|
};
|
|
46
54
|
}
|
|
47
|
-
/** A tiny reconnecting WebSocket wrapper.
|
|
48
|
-
|
|
55
|
+
/** A tiny reconnecting WebSocket wrapper. `onOpen` fires on every open (each
|
|
56
|
+
* reconnect is a fresh connection), so the caller can re-send its hello. */
|
|
57
|
+
function openSocket(url, onOpen) {
|
|
49
58
|
let ws = null;
|
|
50
59
|
let closed = false;
|
|
51
60
|
const connect = () => {
|
|
@@ -53,6 +62,7 @@ function openSocket(url) {
|
|
|
53
62
|
return;
|
|
54
63
|
try {
|
|
55
64
|
ws = new WebSocket(url);
|
|
65
|
+
ws.onopen = () => onOpen?.();
|
|
56
66
|
ws.onclose = () => {
|
|
57
67
|
ws = null;
|
|
58
68
|
if (!closed)
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
* The canonical preview URL to encode — `origin + "/"`, stable across in-app SPA
|
|
13
|
+
* navigation. Reads ONLY the origin, so a deep path, query-param secret, or hash is
|
|
14
|
+
* never encoded into the QR or the text field.
|
|
15
|
+
*/
|
|
16
|
+
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
|
+
/**
|
|
34
|
+
* Render the preview URL as an inline SVG QR. Same params as the CLI's `qrPage.ts`
|
|
35
|
+
* (`margin:2`, ECC `M`) so the on-screen QR is identical to the proven-scannable
|
|
36
|
+
* terminal one.
|
|
37
|
+
*/
|
|
38
|
+
export declare function renderQr(url: string): Promise<string>;
|
|
39
|
+
export interface ShareButtonOptions {
|
|
40
|
+
/** Injectable for tests. Defaults to `window.matchMedia`. */
|
|
41
|
+
matchMedia?: typeof window.matchMedia;
|
|
42
|
+
/** Injectable for tests. Defaults to `window.location`. */
|
|
43
|
+
location?: Pick<Location, "origin" | "protocol" | "hostname">;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Mount the icon-only Share chip into the shared cluster and wire its popover.
|
|
47
|
+
*
|
|
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.
|
|
51
|
+
*
|
|
52
|
+
* Returns an unmount function that removes the chip + popover, tears down listeners,
|
|
53
|
+
* and releases the shared cluster.
|
|
54
|
+
*/
|
|
55
|
+
export declare function mountShareButton(opts?: ShareButtonOptions): () => void;
|