@lightworkai.official/debug-capture 0.17.1 → 0.17.3

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.
@@ -19,8 +19,15 @@ export interface FrameHandlers {
19
19
  /** It could not sign in or never came up — the frame is gone; the caller's own card carries the call. */
20
20
  failed(reason: string): void;
21
21
  }
22
- /** The support host's page for this call. Pure, for the tests. */
23
- export declare function frameUrl(supportHost: string, realmKey: string, token: string, id: string): string;
22
+ /**
23
+ * The support host's page for this call. Pure, for the tests. Without a token (0.17.2): `hand=1` — the page asks for it
24
+ * by message, so it can load while the host is still fetching it.
25
+ */
26
+ export declare function frameUrl(supportHost: string, realmKey: string, token: string | null, id: string): string;
27
+ /** รับสาย pressed on our card: the frame answers the moment it is ready — now, if it is. */
28
+ export declare function answerInFrame(id: string): void;
29
+ /** ทดสอบไมค์และลำโพง pressed on our card: the frame's voice settings, the moment it is ready. */
30
+ export declare function voiceInFrame(id: string): void;
24
31
  /** Within the window, by at least the margin — a corner dragged off screen comes back. Pure, for the tests. */
25
32
  export declare function clampTo(p: {
26
33
  x: number;
@@ -46,5 +53,6 @@ export declare function fromFrame(e: {
46
53
  }): FrameMessage | null;
47
54
  export declare function callFrameFor(): string | null;
48
55
  /** Open the call's frame — once per call; the same call again changes nothing. Shown when it says it is ready. */
49
- export declare function openCallFrame(src: string, id: string, h: FrameHandlers, readyWithinMs?: number): void;
56
+ export declare function openCallFrame(src: string, id: string, h: FrameHandlers, readyWithinMs?: number, token?: Promise<string | null> | null): void;
50
57
  export declare function closeCallFrame(): void;
58
+ export declare function nameTabAtRest(handle: string, readableBy: string): void;
package/dist/ui/ring.d.ts CHANGED
@@ -4,14 +4,24 @@ export interface RingCall {
4
4
  ticketTitle: string;
5
5
  }
6
6
  export interface RingHandlers {
7
- /** Runs INSIDE the press: a tab can be opened only there, before anything is awaited. */
7
+ /** The portal's way: runs INSIDE the press — a tab can be opened only there, before anything is awaited. */
8
8
  open: () => void;
9
9
  decline: () => void;
10
+ /** The call's frame is coming (0.17.2): รับสาย here — the frame answers the moment it is ready. */
11
+ answer?: () => void;
12
+ /** …and ทดสอบไมค์และลำโพง — the frame's voice settings, the moment it is ready. */
13
+ voice?: () => void;
10
14
  }
15
+ /** `frame`: the call's own card while its frame comes; `portal`: answer in a new tab (no frame, or it failed). */
16
+ export type RingMode = "frame" | "portal";
11
17
  /** "เรื่อง CALL-12 · title" — the line under the card's title. Pure, for the tests. */
12
18
  export declare function ringAbout(template: string, call: RingCall): string;
13
19
  /** Show the ring for this call — once; the same call again changes nothing. */
14
- export declare function showRing(call: RingCall, handlers: RingHandlers): void;
20
+ export declare function showRing(call: RingCall, handlers: RingHandlers, mode?: RingMode): void;
21
+ /** รับสาย pressed while the call's frame comes: said, and nothing more to press until it is there. */
22
+ export declare function ringAnswering(id: string): void;
23
+ /** The call's frame could not come up: the same card, now the portal's way — a new tab, which needs a fresh press. */
24
+ export declare function ringFallback(call: RingCall, handlers: RingHandlers): void;
15
25
  /** Opened: the card says where to answer, and stops ringing. `blockedUrl` — the browser refused the tab: a link, which is a fresh press. */
16
26
  export declare function ringOpened(blockedUrl?: string): void;
17
27
  /**
@@ -113,5 +113,12 @@ export interface Strings {
113
113
  ringBlocked: string;
114
114
  ringClose: string;
115
115
  ringTab: string;
116
+ /** The call's own card, while its frame is coming (0.17.2): the same words as the support host's ring card. */
117
+ ringHint: string;
118
+ ringTest: string;
119
+ ringAnswer: string;
120
+ ringAnswering: string;
121
+ /** The frame could not come up: answer in a new tab instead. */
122
+ ringFallback: string;
116
123
  }
117
124
  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.17.1",
3
+ "version": "0.17.3",
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/live.ts CHANGED
@@ -24,8 +24,10 @@
24
24
  */
25
25
  import { getConfig } from "./config";
26
26
  import { preparePortalUrl } from "./portal";
27
- import { hideRing, quietRing, ringOpened, ringShownFor, showRing } from "./ui/ring";
28
- import { callFrameFor, closeCallFrame, frameUrl, openCallFrame } from "./ui/callFrame";
27
+ import { hideRing, quietRing, ringAnswering, ringFallback, ringOpened, ringShownFor, showRing, type RingHandlers } from "./ui/ring";
28
+ import { answerInFrame, callFrameFor, closeCallFrame, frameUrl, nameTabAtRest, openCallFrame, voiceInFrame } from "./ui/callFrame";
29
+ import { readInkOp } from "./ink";
30
+ import { inkOnPage, removeInkLayer } from "./ui/inkOverlay";
29
31
 
30
32
  export interface RingingCall {
31
33
  id: string;
@@ -98,6 +100,12 @@ export function startLiveRing(): void {
98
100
  if (!config.identity || config.ring === false) return;
99
101
  started = true;
100
102
  stopped = false;
103
+ warmUp(config.host);
104
+ try {
105
+ nameTabAtRest(`lw-tab:${tabId()}`, new URL(config.host).origin);
106
+ } catch {
107
+ /* not a host we can name */
108
+ }
101
109
  void connect();
102
110
  }
103
111
 
@@ -165,6 +173,8 @@ async function connect(): Promise<void> {
165
173
  socket = ws;
166
174
  ws.onopen = () => {
167
175
  retry = 0;
176
+ // Which tab this is (§21): the call's marks for a screen shared from here reach it on this socket.
177
+ ws.send(JSON.stringify({ type: "tab", id: tabId() }));
168
178
  clearInterval(pinger);
169
179
  pinger = setInterval(() => {
170
180
  if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
@@ -172,8 +182,16 @@ async function connect(): Promise<void> {
172
182
  };
173
183
  ws.onmessage = (e) => {
174
184
  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);
185
+ const frame = JSON.parse(String(e.data)) as { type?: string; call?: RingingCall | string; op?: unknown };
186
+ if (frame.type === "call-state" && frame.call && typeof frame.call === "object") hear(frame.call);
187
+ // The team's marks on this page, shared from the portal's call (§21) — read again, never drawn as received.
188
+ else if (frame.type === "call-ink" && typeof frame.call === "string") {
189
+ const op = readInkOp(frame.op);
190
+ if (op) {
191
+ inkCall = frame.call;
192
+ inkOnPage(op);
193
+ }
194
+ }
177
195
  } catch {
178
196
  /* not ours to read */
179
197
  }
@@ -203,36 +221,91 @@ function markFramed(id: string | null): void {
203
221
  }
204
222
  }
205
223
 
206
- let framing: string | null = null;
224
+ /**
225
+ * The support host, met before the first ring (0.17.2): its name looked up and its connection made while the page is
226
+ * idle, so the call's frame does not start with them. A hint the browser may ignore; nothing is fetched.
227
+ */
228
+ let warmed = false;
229
+ function warmUp(supportHost: string): void {
230
+ if (warmed || typeof document === "undefined") return;
231
+ warmed = true;
232
+ try {
233
+ const href = new URL(supportHost).origin;
234
+ for (const rel of ["dns-prefetch", "preconnect"]) {
235
+ const link = document.createElement("link");
236
+ link.rel = rel;
237
+ link.href = href;
238
+ if (rel === "preconnect") link.crossOrigin = "anonymous";
239
+ link.dataset.debugReporter = "true";
240
+ document.head.append(link);
241
+ }
242
+ } catch {
243
+ /* not a host we can name */
244
+ }
245
+ }
246
+
207
247
  /** 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> {
248
+ function frameFor(call: RingingCall): void {
209
249
  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;
250
+ if (config.callFrame === false || callFrameFor() === call.id) return;
215
251
  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);
252
+ // At once (0.17.2): the frame loads while the host fetches its token — handed over when the frame says hello.
253
+ openCallFrame(
254
+ frameUrl(config.host, config.realmKey, null, call.id),
255
+ call.id,
256
+ {
257
+ ready: () => quietRing(call.id),
258
+ done: () => {
259
+ markFramed(null);
260
+ hideRing(call.id);
261
+ },
262
+ // The same card, now the portal's way: a new tab — a fresh press, since one cannot be opened after a wait.
263
+ failed: () => {
264
+ markFramed(null);
265
+ ringFallback(call, portalWay(call));
266
+ },
221
267
  },
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
- });
268
+ 15_000,
269
+ hostToken(),
270
+ );
228
271
  }
272
+ const portalWay = (call: RingingCall): RingHandlers => ({ open: () => answer(call), decline: () => void decline(call) });
273
+
274
+ /** This tab's own id (§21), kept across its reloads — what its name says, and what its socket says. */
275
+ let ownTabId: string | null = null;
276
+ function tabId(): string {
277
+ if (ownTabId) return ownTabId;
278
+ try {
279
+ ownTabId = sessionStorage.getItem(TAB_KEY);
280
+ } catch {
281
+ /* no storage: a new id each load */
282
+ }
283
+ if (!ownTabId || !/^[A-Za-z0-9_-]{8,40}$/.test(ownTabId)) {
284
+ const bytes = new Uint8Array(12);
285
+ crypto.getRandomValues(bytes);
286
+ ownTabId = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
287
+ try {
288
+ sessionStorage.setItem(TAB_KEY, ownTabId);
289
+ } catch {
290
+ /* this load only */
291
+ }
292
+ }
293
+ return ownTabId;
294
+ }
295
+ const TAB_KEY = "lw-tab-id";
296
+ /** The call whose marks are on this page (§21): they go with it. */
297
+ let inkCall: string | null = null;
229
298
 
230
299
  /** 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
300
  function hear(call: RingingCall): void {
301
+ if (!LIVE_STATES.has(call.status) && inkCall === call.id && callFrameFor() !== call.id) {
302
+ inkCall = null;
303
+ removeInkLayer();
304
+ }
232
305
  if (!ringsHere(call)) {
233
306
  hideRing(call.id);
234
307
  // 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);
308
+ if (call.status === "active" && framedHere() === call.id && !callFrameFor()) frameFor(call);
236
309
  // Over: the frame says its last word and goes — and goes anyway if it cannot.
237
310
  if (!LIVE_STATES.has(call.status) && callFrameFor() === call.id) {
238
311
  setTimeout(() => {
@@ -244,8 +317,23 @@ function hear(call: RingingCall): void {
244
317
  }
245
318
  return;
246
319
  }
247
- showRing(call, { open: () => answer(call), decline: () => void decline(call) });
248
- void frameFor(call);
320
+ if (getConfig().callFrame === false) showRing(call, portalWay(call), "portal");
321
+ else {
322
+ // The call's own card from the first moment — รับสาย pressed before its frame is up is kept, and done when it is.
323
+ showRing(
324
+ call,
325
+ {
326
+ ...portalWay(call),
327
+ answer: () => {
328
+ ringAnswering(call.id);
329
+ answerInFrame(call.id);
330
+ },
331
+ voice: () => voiceInFrame(call.id),
332
+ },
333
+ "frame",
334
+ );
335
+ frameFor(call);
336
+ }
249
337
  // Never longer than it rings: a socket that dropped mid-ring must not leave a card up for good.
250
338
  clearTimeout(expiry);
251
339
  const left = new Date(call.ringUntil).getTime() - Date.now();
@@ -12,6 +12,11 @@
12
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
13
  * during a call does not photograph it.
14
14
  *
15
+ * And at once (0.17.2, the support host's §20): the owner, "it shows [the widget's card] a few seconds then turns into
16
+ * [the call's card] — why". The frame used to wait for the host's token before it began to load. Now it opens the
17
+ * moment the call rings and says `hello`; the token follows by message when the host gives it (and is never in an
18
+ * address). A รับสาย pressed on our card meanwhile is kept and passed on the moment the frame is ready (`answer`).
19
+ *
15
20
  * And the team's marks (0.17, ชี้ · วาด · กรอบ): the frame hands each one up (`ink`) and ui/inkOverlay draws it over
16
21
  * the page. So the call can tell whether the tab being shared is this one — where the marks can be seen — this page
17
22
  * is named for the call while it lasts (Capture Handle, Chrome and Edge: `lw-call:<id>`, readable by the support host
@@ -39,6 +44,8 @@ export interface FrameMessage {
39
44
  reason?: string;
40
45
  op?: unknown;
41
46
  }
47
+ /** What we tell the frame: where its corner is, the host's token, and a press it should act on. */
48
+ type HostMessage = { type: "corner"; x: number; y: number; width: number } | { type: "token"; token: string } | { type: "answer" } | { type: "voice" };
42
49
  export interface FrameHandlers {
43
50
  /** The call is on screen in the frame. */
44
51
  ready(): void;
@@ -57,21 +64,47 @@ let layout: FrameLayout = "card";
57
64
  let size = { width: CARD_WIDTH, height: 0 };
58
65
  let readyTimer: ReturnType<typeof setTimeout> | undefined;
59
66
  let isReady = false;
67
+ /** The host's token, on its way since the call rang — handed to the frame when it says hello (0.17.2). */
68
+ let tokenOnItsWay: Promise<string | null> | null = null;
69
+ /** Presses on our card while the frame was coming: passed on once it is ready. */
70
+ let pending: { answer: boolean; voice: boolean } = { answer: false, voice: false };
60
71
  /** Where the corner was moved to (left, top), or null: bottom right. Kept for this tab. */
61
72
  let placed: { x: number; y: number } | null = null;
62
73
  /** A drag of the call's top line: where the press began (screen), where the frame was, and the last place it was moved to. */
63
74
  let drag: { sx: number; sy: number; x: number; y: number; lx: number; ly: number } | null = null;
64
75
  const PLACE_KEY = "lw-call-frame-place";
65
76
 
66
- /** The support host's page for this call. Pure, for the tests. */
67
- export function frameUrl(supportHost: string, realmKey: string, token: string, id: string): string {
77
+ /**
78
+ * The support host's page for this call. Pure, for the tests. Without a token (0.17.2): `hand=1` — the page asks for it
79
+ * by message, so it can load while the host is still fetching it.
80
+ */
81
+ export function frameUrl(supportHost: string, realmKey: string, token: string | null, id: string): string {
68
82
  const url = new URL("embed/call", supportHost.endsWith("/") ? supportHost : `${supportHost}/`);
69
83
  url.searchParams.set("realm", realmKey);
70
- url.searchParams.set("token", token);
84
+ if (token) url.searchParams.set("token", token);
85
+ else url.searchParams.set("hand", "1");
71
86
  url.searchParams.set("call", id);
72
87
  return url.toString();
73
88
  }
74
89
 
90
+ /** Tell the frame something — only the support host's origin may read it. */
91
+ function tellFrame(m: HostMessage): void {
92
+ frame?.contentWindow?.postMessage({ source: "lw-call-host", ...m }, origin);
93
+ }
94
+
95
+ /** รับสาย pressed on our card: the frame answers the moment it is ready — now, if it is. */
96
+ export function answerInFrame(id: string): void {
97
+ if (callId !== id) return;
98
+ pending.answer = true;
99
+ if (isReady) tellFrame({ type: "answer" });
100
+ }
101
+ /** ทดสอบไมค์และลำโพง pressed on our card: the frame's voice settings, the moment it is ready. */
102
+ export function voiceInFrame(id: string): void {
103
+ if (callId !== id) return;
104
+ pending.voice = true;
105
+ if (isReady) tellFrame({ type: "voice" });
106
+ }
107
+
75
108
  /** Within the window, by at least the margin — a corner dragged off screen comes back. Pure, for the tests. */
76
109
  export function clampTo(p: { x: number; y: number }, box: { w: number; h: number }, view: { w: number; h: number }): { x: number; y: number } {
77
110
  return {
@@ -92,13 +125,15 @@ export function callFrameFor(): string | null {
92
125
  }
93
126
 
94
127
  /** Open the call's frame — once per call; the same call again changes nothing. Shown when it says it is ready. */
95
- export function openCallFrame(src: string, id: string, h: FrameHandlers, readyWithinMs = 15_000): void {
128
+ export function openCallFrame(src: string, id: string, h: FrameHandlers, readyWithinMs = 15_000, token: Promise<string | null> | null = null): void {
96
129
  if (typeof document === "undefined") return;
97
130
  if (callId === id && host?.isConnected) return;
98
131
  closeCallFrame();
99
132
  callId = id;
100
133
  handlers = h;
101
134
  isReady = false;
135
+ tokenOnItsWay = token;
136
+ pending = { answer: false, voice: false };
102
137
  layout = "card";
103
138
  size = { width: CARD_WIDTH, height: 0 };
104
139
  origin = new URL(src).origin;
@@ -134,7 +169,7 @@ iframe.on { visibility: visible; }`;
134
169
  }
135
170
 
136
171
  export function closeCallFrame(): void {
137
- if (host) nameTab(null);
172
+ if (host) nameTab(resting?.handle ?? null, resting?.readableBy);
138
173
  removeInkLayer();
139
174
  clearTimeout(readyTimer);
140
175
  window.removeEventListener("message", onMessage);
@@ -146,6 +181,8 @@ export function closeCallFrame(): void {
146
181
  callId = null;
147
182
  handlers = null;
148
183
  drag = null;
184
+ tokenOnItsWay = null;
185
+ pending = { answer: false, voice: false };
149
186
  }
150
187
 
151
188
  function fail(reason: string): void {
@@ -159,11 +196,22 @@ function onMessage(e: MessageEvent): void {
159
196
  const m = fromFrame(e, { origin, window: frame.contentWindow });
160
197
  if (!m) return;
161
198
  switch (m.type) {
199
+ case "hello":
200
+ // The page is up and asks for the host's token (it was not in its address): given when the host has it.
201
+ void tokenOnItsWay?.then((token) => {
202
+ if (!frame || callId === null) return;
203
+ if (token) tellFrame({ type: "token", token });
204
+ else fail("no_token");
205
+ });
206
+ return;
162
207
  case "ready":
163
208
  isReady = true;
164
209
  clearTimeout(readyTimer);
165
210
  frame.classList.add("on");
166
211
  handlers?.ready();
212
+ // What was pressed on our card while it came: done now.
213
+ if (pending.answer) tellFrame({ type: "answer" });
214
+ if (pending.voice) tellFrame({ type: "voice" });
167
215
  return;
168
216
  case "size":
169
217
  if (typeof m.width === "number" && typeof m.height === "number") size = { width: m.width, height: m.height };
@@ -202,15 +250,26 @@ function onMessage(e: MessageEvent): void {
202
250
  * browser without Capture Handle simply has no name to give, and the call says it cannot tell.
203
251
  */
204
252
  type CaptureHandleConfig = { handle?: string; exposeOrigin?: boolean; permittedOrigins?: string[] };
205
- function nameTab(handle: string | null): void {
253
+ function nameTab(handle: string | null, readableBy = origin): void {
206
254
  const md = (typeof navigator === "undefined" ? undefined : navigator.mediaDevices) as (MediaDevices & { setCaptureHandleConfig?: (c: CaptureHandleConfig) => void }) | undefined;
207
255
  try {
208
- md?.setCaptureHandleConfig?.(handle ? { handle, exposeOrigin: false, permittedOrigins: [origin] } : {});
256
+ md?.setCaptureHandleConfig?.(handle && readableBy ? { handle, exposeOrigin: false, permittedOrigins: [readableBy] } : {});
209
257
  } catch {
210
258
  /* not a top-level page, or not this browser */
211
259
  }
212
260
  }
213
261
 
262
+ /**
263
+ * The tab's name between calls (0.17.3, the support host's §21): `lw-tab:<id>` — so a call answered on the support
264
+ * host's portal, with THIS tab shared, can tell it is a tab of ours and have the team's marks drawn here (through the
265
+ * live socket). A call's frame names the tab for its call while it lasts, and gives this name back when it goes.
266
+ */
267
+ let resting: { handle: string; readableBy: string } | null = null;
268
+ export function nameTabAtRest(handle: string, readableBy: string): void {
269
+ resting = { handle, readableBy };
270
+ if (!host?.isConnected) nameTab(handle, readableBy);
271
+ }
272
+
214
273
  /** The corner moved by its top line: from where the press began, by how far the pointer went on the screen. */
215
274
  function onDrag(m: FrameMessage): void {
216
275
  if (!frame || layout !== "card" || typeof m.x !== "number" || typeof m.y !== "number") return;
@@ -260,7 +319,7 @@ function followHere(on: boolean): void {
260
319
  function tellCorner(): void {
261
320
  if (!frame?.contentWindow) return;
262
321
  const r = frame.getBoundingClientRect();
263
- frame.contentWindow.postMessage({ source: "lw-call-host", type: "corner", x: Math.round(r.left), y: Math.round(r.top), width: Math.round(r.width) }, origin);
322
+ tellFrame({ type: "corner", x: Math.round(r.left), y: Math.round(r.top), width: Math.round(r.width) });
264
323
  }
265
324
 
266
325
  function savePlace(): void {