@lightworkai.official/debug-capture 0.14.3 → 0.16.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,63 @@
1
+ /**
2
+ * The call, in this app — the support host's own call UI in a frame (0.16; the support host's docs/CALLS-PLAN.md §15).
3
+ *
4
+ * A reporter used to answer in a new tab on the support host: a hand-off, a popup a browser might block, and the app
5
+ * with the problem left behind just when the team asks to see it. Now the support host's `/embed/call` opens here, in a
6
+ * frame of our own — the same ring card, call corner, screen-sharing question and voice settings as its portal, so
7
+ * they never drift apart — signed in by the token the portal button uses, given the microphone, camera and screen.
8
+ *
9
+ * The frame says how to hold it (its messages, below): ready, its size and whether a moment covers the page, a drag
10
+ * of its top line, done, failed. We listen only to that frame's own window, from the support host's origin.
11
+ *
12
+ * Its own shadow root, like the ring card: it lands on pages we do not control. Marked as ours, so a report filed
13
+ * during a call does not photograph it.
14
+ */
15
+ export type FrameLayout = "card" | "full";
16
+ export interface FrameMessage {
17
+ source?: string;
18
+ type?: string;
19
+ width?: number;
20
+ height?: number;
21
+ layout?: FrameLayout;
22
+ phase?: "start" | "move" | "end";
23
+ x?: number;
24
+ y?: number;
25
+ reason?: string;
26
+ }
27
+ export interface FrameHandlers {
28
+ /** The call is on screen in the frame. */
29
+ ready(): void;
30
+ /** Nothing left to show — the frame is gone already. */
31
+ done(): void;
32
+ /** It could not sign in or never came up — the frame is gone; the caller's own card carries the call. */
33
+ failed(reason: string): void;
34
+ }
35
+ /** The support host's page for this call. Pure, for the tests. */
36
+ export declare function frameUrl(supportHost: string, realmKey: string, token: string, id: string): string;
37
+ /** Within the window, by at least the margin — a corner dragged off screen comes back. Pure, for the tests. */
38
+ export declare function clampTo(p: {
39
+ x: number;
40
+ y: number;
41
+ }, box: {
42
+ w: number;
43
+ h: number;
44
+ }, view: {
45
+ w: number;
46
+ h: number;
47
+ }): {
48
+ x: number;
49
+ y: number;
50
+ };
51
+ /** Is this message from our frame, about the call? */
52
+ export declare function fromFrame(e: {
53
+ origin: string;
54
+ source: unknown;
55
+ data: unknown;
56
+ }, expected: {
57
+ origin: string;
58
+ window: unknown;
59
+ }): FrameMessage | null;
60
+ export declare function callFrameFor(): string | null;
61
+ /** Open the call's frame — once per call; the same call again changes nothing. Shown when it says it is ready. */
62
+ export declare function openCallFrame(src: string, id: string, h: FrameHandlers, readyWithinMs?: number): void;
63
+ export declare function closeCallFrame(): void;
@@ -0,0 +1,25 @@
1
+ export interface RingCall {
2
+ id: string;
3
+ ticketKey: string;
4
+ ticketTitle: string;
5
+ }
6
+ export interface RingHandlers {
7
+ /** Runs INSIDE the press: a tab can be opened only there, before anything is awaited. */
8
+ open: () => void;
9
+ decline: () => void;
10
+ }
11
+ /** "เรื่อง CALL-12 · title" — the line under the card's title. Pure, for the tests. */
12
+ export declare function ringAbout(template: string, call: RingCall): string;
13
+ /** Show the ring for this call — once; the same call again changes nothing. */
14
+ export declare function showRing(call: RingCall, handlers: RingHandlers): void;
15
+ /** Opened: the card says where to answer, and stops ringing. `blockedUrl` — the browser refused the tab: a link, which is a fresh press. */
16
+ export declare function ringOpened(blockedUrl?: string): void;
17
+ /**
18
+ * The call's own frame is up (ui/callFrame, 0.16): its ring card is the one to press, so ours steps out of sight —
19
+ * the ring and the title stay, rung from this page, until the call stops ringing. `false` brings ours back (the frame
20
+ * failed: our card carries the call again).
21
+ */
22
+ export declare function quietRing(id: string, quiet?: boolean): void;
23
+ /** The call is no longer ringing for this person — answered, declined, missed or ended. */
24
+ export declare function hideRing(id?: string): void;
25
+ export declare function ringShownFor(): string | null;
@@ -103,5 +103,15 @@ export interface Strings {
103
103
  /** Carry `{n}`. */
104
104
  days: string;
105
105
  hours: string;
106
+ /** A call from the support team (ui/ring). `ringAbout` carries `{key}` and `{title}`. */
107
+ ringTitle: string;
108
+ ringAbout: string;
109
+ ringHow: string;
110
+ ringOpen: string;
111
+ ringDecline: string;
112
+ ringOpened: string;
113
+ ringBlocked: string;
114
+ ringClose: string;
115
+ ringTab: string;
106
116
  }
107
117
  export declare function strings(locale: "th" | "en" | undefined): Strings;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightworkai.official/debug-capture",
3
- "version": "0.14.3",
3
+ "version": "0.16.0",
4
4
  "description": "Report-a-problem widget: rolling network/console/action capture, optional rrweb session replay, annotated screenshot, and submission to a Lightwork Support host",
5
5
  "type": "module",
6
6
  "module": "./dist/index.mjs",
package/src/config.ts CHANGED
@@ -149,6 +149,32 @@ export interface DebugCaptureConfig {
149
149
  /** UI language. Only affects the built-in reporter's own strings. */
150
150
  locale?: "th" | "en";
151
151
 
152
+ /**
153
+ * The ring for a call from the support team. Default true.
154
+ *
155
+ * With `identity` set and calls switched on for the desk, the reporter's own
156
+ * live connection to the support host is held here — the one their portal
157
+ * tab holds, proven with the same signed token — so a call from the team
158
+ * rings in this app, as a card, and opens the reporter's request on the
159
+ * support host to answer it (the call itself happens there). `false` leaves
160
+ * calls to the support host and the invitation the team sends.
161
+ */
162
+ ring?: boolean;
163
+
164
+ /**
165
+ * The call itself, in this app. Default true (0.16).
166
+ *
167
+ * When a call rings, the support host's own call — its ring card, its call
168
+ * corner, its screen-sharing question, its voice settings — opens in a frame
169
+ * in this page, signed in with the same token the portal button uses: the
170
+ * reporter answers, talks and shows their screen without leaving the app.
171
+ * The frame needs nothing of the host but leave to frame the support host and
172
+ * to give it the microphone, camera and screen (no `frame-src` or
173
+ * `Permissions-Policy` that forbids them). Should the frame not come up, the
174
+ * card of 0.15 carries the call: open the portal to answer. `false`: always that.
175
+ */
176
+ callFrame?: boolean;
177
+
152
178
  /**
153
179
  * Where this library's own notifications go.
154
180
  *
package/src/index.ts CHANGED
@@ -42,6 +42,8 @@ export { redactBody, redactHeaders, redactLabel, redactUrl, REDACTED } from "./c
42
42
  // `defineReporterElement()` builds it in the browser, where it exists.
43
43
  export { defineReporterElement, setErroredQueriesSource, REPORTER_TAG } from "./ui/element";
44
44
  export { showToast } from "./ui/toast";
45
+ // A call from the support team, rung in the host app — started by install; a host that renders its own UI may start or stop it.
46
+ export { startLiveRing, stopLiveRing } from "./live";
45
47
 
46
48
  /*
47
49
  * The annotator ENGINE, shared by every framework package.
package/src/install.ts CHANGED
@@ -14,6 +14,7 @@ import { strings } from "./ui/strings";
14
14
  import { showToast } from "./ui/toast";
15
15
  import { defineReporterElement, REPORTER_TAG } from "./ui/element";
16
16
  import { loadRequestTypes } from "./requests";
17
+ import { startLiveRing } from "./live";
17
18
 
18
19
  let armed = false;
19
20
 
@@ -69,4 +70,12 @@ export function installDebugCapture(options: InstallOptions = {}): void {
69
70
  const idle = (window as Window & { requestIdleCallback?: (cb: () => void, o?: { timeout: number }) => number }).requestIdleCallback;
70
71
  if (idle) idle(() => void loadRequestTypes(), { timeout: 5000 });
71
72
  else setTimeout(() => void loadRequestTypes(), 2000);
73
+
74
+ /*
75
+ * A call from the support team rings here (live.ts) — when the host vouches
76
+ * for who is signed in and the desk has calls on. Late, like the request
77
+ * types: the page came for something else.
78
+ */
79
+ if (idle) idle(() => startLiveRing(), { timeout: 8000 });
80
+ else setTimeout(() => startLiveRing(), 3000);
72
81
  }
package/src/live.ts ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * The ring for a call from the support team, in the host app.
3
+ *
4
+ * A reporter lives in the app they report from, not on the support host; a
5
+ * call from the team used to reach them only by the invitation in their
6
+ * email. So, when the host vouches for who is signed in (`identity`) and the
7
+ * desk has calls switched on, the widget holds the reporter's OWN live
8
+ * connection to the support host — the one their portal tab holds, traded
9
+ * for the host's signed token at `/ingest/live-ticket`, told about their own
10
+ * requests and calls and nothing else — and a call rings here as a card.
11
+ *
12
+ * Answering happens in THIS app since 0.16 (`callFrame`, default on): the
13
+ * support host's own call — ring card, call corner, screen sharing, voice
14
+ * settings — opens in a frame here (ui/callFrame), signed in by the same
15
+ * token the portal button uses; our card steps aside once the frame is up,
16
+ * and comes back if it never does. Before 0.16, and with `callFrame: false`,
17
+ * the card's button opens the reporter's request on the support host — a tab
18
+ * opened INSIDE the press (a browser allows nothing later), its address set
19
+ * once the token is in hand.
20
+ *
21
+ * Quiet by design. No identity, calls off, a support host older than this
22
+ * package, a token refused: no socket, no card, nothing in the console — the
23
+ * widget does exactly what it did before.
24
+ */
25
+ import { getConfig } from "./config";
26
+ import { preparePortalUrl } from "./portal";
27
+ import { hideRing, quietRing, ringOpened, ringShownFor, showRing } from "./ui/ring";
28
+ import { callFrameFor, closeCallFrame, frameUrl, openCallFrame } from "./ui/callFrame";
29
+
30
+ export interface RingingCall {
31
+ id: string;
32
+ ticketId: string;
33
+ ticketKey: string;
34
+ ticketTitle: string;
35
+ status: string;
36
+ ringUntil: string;
37
+ side?: string | null;
38
+ }
39
+
40
+ const RETRY_MS = [2000, 5000, 15_000, 30_000, 60_000];
41
+ const LIVE_STATES = new Set(["ringing", "active"]);
42
+ const PING_MS = 20_000;
43
+
44
+ let started = false;
45
+ let stopped = false;
46
+ /** Asked for a token and got none — nobody signed in yet. A few slow tries, then quiet: never a request a minute forever. */
47
+ let tokenless = 0;
48
+ const TOKENLESS_MAX = 5;
49
+ let socket: WebSocket | null = null;
50
+ let retry = 0;
51
+ let pinger: ReturnType<typeof setInterval> | undefined;
52
+ let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
53
+ let expiry: ReturnType<typeof setTimeout> | undefined;
54
+
55
+ const base = (host: string) => (host.endsWith("/") ? host : `${host}/`);
56
+
57
+ /** `wss://support.example/api/presence/ws?t=…` — the support host's scheme, the socket's own path. Pure, for the tests. */
58
+ export function liveSocketUrl(host: string, ticket: string): string {
59
+ const url = new URL("api/presence/ws", base(host));
60
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
61
+ url.searchParams.set("t", ticket);
62
+ return url.toString();
63
+ }
64
+
65
+ /** Where the card sends them: their request, with the call — the support host rings it there. Pure, for the tests. */
66
+ export function answerPath(call: Pick<RingingCall, "id" | "ticketId">): string {
67
+ return `/me/tickets/${call.ticketId}?call=${call.id}`;
68
+ }
69
+
70
+ /** Is this a call ringing for the person at this widget? */
71
+ export function ringsHere(call: Pick<RingingCall, "status" | "side">): boolean {
72
+ return call.status === "ringing" && (call.side === "reporter" || call.side === undefined || call.side === null);
73
+ }
74
+
75
+ async function hostToken(): Promise<string | null> {
76
+ const provider = getConfig().identity;
77
+ if (!provider) return null;
78
+ try {
79
+ return (await provider()) ?? null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ async function post(path: string, token: string): Promise<Response> {
86
+ const config = getConfig();
87
+ return fetch(new URL(path, base(config.host)).toString(), {
88
+ method: "POST",
89
+ headers: { "content-type": "application/json", "x-realm-key": config.realmKey, authorization: `Bearer ${token}` },
90
+ body: "{}",
91
+ });
92
+ }
93
+
94
+ /** Start listening for calls. Idempotent; a no-op without identity, or with `ring: false`. */
95
+ export function startLiveRing(): void {
96
+ if (started || typeof window === "undefined" || typeof WebSocket === "undefined") return;
97
+ const config = getConfig();
98
+ if (!config.identity || config.ring === false) return;
99
+ started = true;
100
+ stopped = false;
101
+ void connect();
102
+ }
103
+
104
+ /** Let go — the socket, the card, the timers. For a host that unmounts, and for the tests. */
105
+ export function stopLiveRing(): void {
106
+ stopped = true;
107
+ started = false;
108
+ clearTimeout(reconnectTimer);
109
+ clearInterval(pinger);
110
+ clearTimeout(expiry);
111
+ try {
112
+ socket?.close(1000, "bye");
113
+ } catch {
114
+ /* already */
115
+ }
116
+ socket = null;
117
+ hideRing();
118
+ closeCallFrame();
119
+ }
120
+
121
+ function schedule(slow = false): void {
122
+ if (stopped) return;
123
+ const wait = slow ? RETRY_MS[RETRY_MS.length - 1]! : RETRY_MS[Math.min(retry++, RETRY_MS.length - 1)]!;
124
+ clearTimeout(reconnectTimer);
125
+ reconnectTimer = setTimeout(() => void connect(), wait);
126
+ }
127
+
128
+ async function connect(): Promise<void> {
129
+ if (stopped) return;
130
+ const token = await hostToken();
131
+ // Nobody signed in to the host yet: ask again, slowly — a few times, then stop.
132
+ if (!token) {
133
+ if (++tokenless >= TOKENLESS_MAX) {
134
+ stopped = true;
135
+ return;
136
+ }
137
+ return schedule(true);
138
+ }
139
+ tokenless = 0;
140
+ let res: Response;
141
+ try {
142
+ res = await post("ingest/live-ticket", token);
143
+ } catch {
144
+ return schedule();
145
+ }
146
+ // A token the desk does not accept, or a support host from before the ring: nothing to do, and nothing to say.
147
+ if (res.status === 401 || res.status === 404 || res.status === 403) {
148
+ stopped = true;
149
+ return;
150
+ }
151
+ if (!res.ok) return schedule(res.status === 429);
152
+ let out: { calls?: boolean; ticket?: string | null; call?: RingingCall | null };
153
+ try {
154
+ out = await res.json();
155
+ } catch {
156
+ return schedule();
157
+ }
158
+ // Calls off for this desk: no socket at all.
159
+ if (!out.calls || !out.ticket) {
160
+ stopped = true;
161
+ return;
162
+ }
163
+ if (out.call) hear(out.call);
164
+ const ws = new WebSocket(liveSocketUrl(getConfig().host, out.ticket));
165
+ socket = ws;
166
+ ws.onopen = () => {
167
+ retry = 0;
168
+ clearInterval(pinger);
169
+ pinger = setInterval(() => {
170
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
171
+ }, PING_MS);
172
+ };
173
+ ws.onmessage = (e) => {
174
+ try {
175
+ const frame = JSON.parse(String(e.data)) as { type?: string; call?: RingingCall };
176
+ if (frame.type === "call-state" && frame.call) hear(frame.call);
177
+ } catch {
178
+ /* not ours to read */
179
+ }
180
+ };
181
+ ws.onclose = () => {
182
+ clearInterval(pinger);
183
+ if (socket === ws) socket = null;
184
+ schedule();
185
+ };
186
+ }
187
+
188
+ /** The call this tab holds in its frame — taken back after a reload, so the call goes on. */
189
+ const FRAMED_KEY = "lw-call-framed";
190
+ function framedHere(): string | null {
191
+ try {
192
+ return sessionStorage.getItem(FRAMED_KEY);
193
+ } catch {
194
+ return null;
195
+ }
196
+ }
197
+ function markFramed(id: string | null): void {
198
+ try {
199
+ if (id) sessionStorage.setItem(FRAMED_KEY, id);
200
+ else sessionStorage.removeItem(FRAMED_KEY);
201
+ } catch {
202
+ /* this tab only, when it can */
203
+ }
204
+ }
205
+
206
+ let framing: string | null = null;
207
+ /** The call in a frame here (0.16): the support host's own call UI, signed in with the host's token. */
208
+ async function frameFor(call: RingingCall): Promise<void> {
209
+ const config = getConfig();
210
+ if (config.callFrame === false || callFrameFor() === call.id || framing === call.id) return;
211
+ framing = call.id;
212
+ const token = await hostToken();
213
+ framing = null;
214
+ if (!token) return;
215
+ markFramed(call.id);
216
+ openCallFrame(frameUrl(config.host, config.realmKey, token, call.id), call.id, {
217
+ ready: () => quietRing(call.id),
218
+ done: () => {
219
+ markFramed(null);
220
+ hideRing(call.id);
221
+ },
222
+ // Our card carries the call again — still ringing, its button opens the portal to answer.
223
+ failed: () => {
224
+ markFramed(null);
225
+ quietRing(call.id, false);
226
+ },
227
+ });
228
+ }
229
+
230
+ /** The support host's word on a call: ringing here shows the card (and its frame); anything else takes the card away — the frame stays, it IS the call. */
231
+ function hear(call: RingingCall): void {
232
+ if (!ringsHere(call)) {
233
+ hideRing(call.id);
234
+ // Answered in this tab's frame, then the page reloaded: the frame comes back and takes the call back.
235
+ if (call.status === "active" && framedHere() === call.id && !callFrameFor()) void frameFor(call);
236
+ // Over: the frame says its last word and goes — and goes anyway if it cannot.
237
+ if (!LIVE_STATES.has(call.status) && callFrameFor() === call.id) {
238
+ setTimeout(() => {
239
+ if (callFrameFor() === call.id) {
240
+ closeCallFrame();
241
+ markFramed(null);
242
+ }
243
+ }, 12_000);
244
+ }
245
+ return;
246
+ }
247
+ showRing(call, { open: () => answer(call), decline: () => void decline(call) });
248
+ void frameFor(call);
249
+ // Never longer than it rings: a socket that dropped mid-ring must not leave a card up for good.
250
+ clearTimeout(expiry);
251
+ const left = new Date(call.ringUntil).getTime() - Date.now();
252
+ expiry = setTimeout(() => {
253
+ if (ringShownFor() === call.id) hideRing(call.id);
254
+ }, Math.max(5000, left + 5000));
255
+ }
256
+
257
+ /**
258
+ * เปิดเพื่อรับสาย. The tab is opened HERE, inside the press — after an await the
259
+ * browser would call it an unasked-for popup — then sent to the signed-in
260
+ * link once the host's token arrives. The support host is told the reporter
261
+ * is coming, so the ring waits for the hand-off and the team sees it.
262
+ */
263
+ function answer(call: RingingCall): void {
264
+ const tab = window.open("about:blank", "_blank");
265
+ void (async () => {
266
+ const token = await hostToken();
267
+ if (token) void post(`ingest/calls/${call.id}/coming`, token).catch(() => undefined);
268
+ let url: string;
269
+ try {
270
+ url = await preparePortalUrl(answerPath(call));
271
+ } catch {
272
+ tab?.close();
273
+ return;
274
+ }
275
+ if (tab && !tab.closed) {
276
+ tab.location.href = url;
277
+ try {
278
+ tab.opener = null;
279
+ } catch {
280
+ /* open either way */
281
+ }
282
+ ringOpened();
283
+ } else ringOpened(url);
284
+ })();
285
+ }
286
+
287
+ async function decline(call: RingingCall): Promise<void> {
288
+ hideRing(call.id);
289
+ const token = await hostToken();
290
+ if (token) await post(`ingest/calls/${call.id}/decline`, token).catch(() => undefined);
291
+ }
package/src/portal.ts CHANGED
@@ -79,6 +79,9 @@ async function prepareUrl(returnTo?: string): Promise<string> {
79
79
  return supportPortalUrl(config.host, config.realmKey, token, returnTo);
80
80
  }
81
81
 
82
+ /** The signed-in link to a path on the support host — for a caller that opened its own tab first (the call ring, ui/ring). */
83
+ export const preparePortalUrl = prepareUrl;
84
+
82
85
  /** False when the browser refused — a popup blocker, almost always. */
83
86
  function navigate(url: string, target: OpenPortalOptions["target"]): boolean {
84
87
  if (target === "_self") {