@lightworkai.official/debug-capture 0.6.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/README.md +241 -0
- package/dist/budget.d.ts +22 -0
- package/dist/bundle.d.ts +20 -0
- package/dist/capture/actionTrail.d.ts +7 -0
- package/dist/capture/cause.d.ts +3 -0
- package/dist/capture/consoleBuffer.d.ts +4 -0
- package/dist/capture/crashWatcher.d.ts +1 -0
- package/dist/capture/networkBuffer.d.ts +4 -0
- package/dist/capture/redact.d.ts +9 -0
- package/dist/capture/stepCorrelation.d.ts +41 -0
- package/dist/config.d.ts +217 -0
- package/dist/context.d.ts +2 -0
- package/dist/debug-capture.js +116 -0
- package/dist/embed.d.ts +1 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.mjs +129 -0
- package/dist/install.d.ts +5 -0
- package/dist/mytickets/api.d.ts +115 -0
- package/dist/mytickets/format.d.ts +84 -0
- package/dist/mytickets/sanitize.d.ts +66 -0
- package/dist/mytickets/strings.d.ts +74 -0
- package/dist/mytickets/toolbar.d.ts +17 -0
- package/dist/reporter.d.ts +27 -0
- package/dist/screenshot.d.ts +7 -0
- package/dist/signature.d.ts +18 -0
- package/dist/submit.d.ts +8 -0
- package/dist/types.d.ts +175 -0
- package/dist/ui/annotator.d.ts +34 -0
- package/dist/ui/arrow.d.ts +25 -0
- package/dist/ui/element.d.ts +9 -0
- package/dist/ui/strings.d.ts +42 -0
- package/dist/ui/styles.d.ts +13 -0
- package/dist/ui/toast.d.ts +11 -0
- package/package.json +53 -0
- package/src/budget.ts +62 -0
- package/src/bundle.ts +274 -0
- package/src/capture/actionTrail.ts +693 -0
- package/src/capture/cause.ts +49 -0
- package/src/capture/consoleBuffer.ts +80 -0
- package/src/capture/crashWatcher.ts +61 -0
- package/src/capture/networkBuffer.ts +315 -0
- package/src/capture/redact.ts +117 -0
- package/src/capture/stepCorrelation.ts +160 -0
- package/src/config.ts +299 -0
- package/src/context.ts +81 -0
- package/src/embed.ts +35 -0
- package/src/index.ts +109 -0
- package/src/install.ts +59 -0
- package/src/mytickets/api.ts +226 -0
- package/src/mytickets/format.ts +191 -0
- package/src/mytickets/sanitize.ts +221 -0
- package/src/mytickets/strings.ts +217 -0
- package/src/mytickets/toolbar.ts +48 -0
- package/src/reporter.ts +53 -0
- package/src/screenshot.ts +238 -0
- package/src/signature.ts +40 -0
- package/src/styles.css +400 -0
- package/src/submit.ts +143 -0
- package/src/types.ts +169 -0
- package/src/ui/annotator.ts +698 -0
- package/src/ui/arrow.ts +62 -0
- package/src/ui/element.ts +362 -0
- package/src/ui/strings.ts +113 -0
- package/src/ui/styles.ts +96 -0
- package/src/ui/toast.ts +138 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tickets panel's own words, kept apart from the reporter dialog's so
|
|
3
|
+
* neither grows the other's vocabulary. Thai wording is the original's, verbatim
|
|
4
|
+
* where the original had one.
|
|
5
|
+
*/
|
|
6
|
+
export interface TicketStrings {
|
|
7
|
+
title: string;
|
|
8
|
+
intro: string;
|
|
9
|
+
searchPlaceholder: string;
|
|
10
|
+
searchLabel: string;
|
|
11
|
+
allStatuses: string;
|
|
12
|
+
count: (n: number) => string;
|
|
13
|
+
colNumber: string;
|
|
14
|
+
colTitle: string;
|
|
15
|
+
colModule: string;
|
|
16
|
+
colStatus: string;
|
|
17
|
+
colResponse: string;
|
|
18
|
+
colCreated: string;
|
|
19
|
+
colUpdated: string;
|
|
20
|
+
awaitingReply: string;
|
|
21
|
+
hasSolution: string;
|
|
22
|
+
hasReply: string;
|
|
23
|
+
noReply: string;
|
|
24
|
+
empty: string;
|
|
25
|
+
emptyFiltered: string;
|
|
26
|
+
loading: string;
|
|
27
|
+
listFailed: string;
|
|
28
|
+
detailFailed: string;
|
|
29
|
+
signedOut: string;
|
|
30
|
+
back: string;
|
|
31
|
+
reportedBy: string;
|
|
32
|
+
conversation: string;
|
|
33
|
+
resolution: string;
|
|
34
|
+
noMessages: string;
|
|
35
|
+
you: string;
|
|
36
|
+
team: string;
|
|
37
|
+
/** The role chip beside a reporter's name, as the original prints it. */
|
|
38
|
+
reporterRole: string;
|
|
39
|
+
edited: string;
|
|
40
|
+
edit: string;
|
|
41
|
+
saveEdit: string;
|
|
42
|
+
cancel: string;
|
|
43
|
+
replyPlaceholder: string;
|
|
44
|
+
bold: string;
|
|
45
|
+
italic: string;
|
|
46
|
+
underline: string;
|
|
47
|
+
strike: string;
|
|
48
|
+
code: string;
|
|
49
|
+
blockquote: string;
|
|
50
|
+
bulletList: string;
|
|
51
|
+
orderedList: string;
|
|
52
|
+
link: string;
|
|
53
|
+
linkPrompt: string;
|
|
54
|
+
linkNeedsSelection: string;
|
|
55
|
+
linkInvalid: string;
|
|
56
|
+
attachImage: string;
|
|
57
|
+
uploading: string;
|
|
58
|
+
send: string;
|
|
59
|
+
sending: string;
|
|
60
|
+
sent: string;
|
|
61
|
+
sendFailed: string;
|
|
62
|
+
reopen: string;
|
|
63
|
+
reopenHint: string;
|
|
64
|
+
reopening: string;
|
|
65
|
+
reopened: string;
|
|
66
|
+
reopenFailed: string;
|
|
67
|
+
close: string;
|
|
68
|
+
retry: string;
|
|
69
|
+
page: string;
|
|
70
|
+
of: (page: number, pages: number) => string;
|
|
71
|
+
prev: string;
|
|
72
|
+
next: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const th: TicketStrings = {
|
|
76
|
+
title: "ปัญหาที่แจ้ง",
|
|
77
|
+
// The original names the toolbar button and points at it ("จากปุ่ม 🐞 รายงานปัญหา
|
|
78
|
+
// ที่แถบด้านบนขวา"). A package cannot: the host decides whether that button
|
|
79
|
+
// exists and where it sits, and a sentence describing a button that is not
|
|
80
|
+
// there is worse than no sentence.
|
|
81
|
+
intro: "ปัญหาที่คุณแจ้งไว้ พร้อมสถานะล่าสุด",
|
|
82
|
+
searchPlaceholder: "ค้นหาเลขที่/หัวข้อ/โมดูล",
|
|
83
|
+
searchLabel: "ค้นหาปัญหาที่แจ้ง",
|
|
84
|
+
allStatuses: "ทุกสถานะ",
|
|
85
|
+
count: (n) => `${n} รายการ`,
|
|
86
|
+
colNumber: "เลขที่",
|
|
87
|
+
colTitle: "หัวข้อ",
|
|
88
|
+
colModule: "โมดูล",
|
|
89
|
+
colStatus: "สถานะ",
|
|
90
|
+
colResponse: "คำตอบจากทีม",
|
|
91
|
+
colCreated: "แจ้งเมื่อ",
|
|
92
|
+
colUpdated: "อัปเดตล่าสุด",
|
|
93
|
+
awaitingReply: "รอคุณตอบกลับ",
|
|
94
|
+
hasSolution: "มีวิธีแก้ไข",
|
|
95
|
+
hasReply: "ตอบกลับแล้ว",
|
|
96
|
+
noReply: "ยังไม่มีการตอบกลับ",
|
|
97
|
+
empty: "คุณยังไม่ได้แจ้งปัญหา",
|
|
98
|
+
emptyFiltered: "ไม่พบรายการที่ตรงกับการค้นหา",
|
|
99
|
+
loading: "กำลังโหลด…",
|
|
100
|
+
listFailed: "โหลดรายการไม่สำเร็จ",
|
|
101
|
+
detailFailed: "โหลดรายงานไม่สำเร็จ",
|
|
102
|
+
signedOut: "กรุณาเข้าสู่ระบบเพื่อดูปัญหาที่แจ้งไว้",
|
|
103
|
+
back: "กลับไปรายการ",
|
|
104
|
+
reportedBy: "แจ้งเมื่อ",
|
|
105
|
+
conversation: "การสนทนา",
|
|
106
|
+
resolution: "วิธีแก้ไข",
|
|
107
|
+
noMessages: "ยังไม่มีข้อความ",
|
|
108
|
+
you: "คุณ",
|
|
109
|
+
team: "ทีมงาน",
|
|
110
|
+
reporterRole: "ผู้แจ้ง",
|
|
111
|
+
edited: "แก้ไขแล้ว",
|
|
112
|
+
edit: "แก้ไข",
|
|
113
|
+
saveEdit: "บันทึกการแก้ไข",
|
|
114
|
+
cancel: "ยกเลิก",
|
|
115
|
+
replyPlaceholder: "พิมพ์ข้อความถึงทีมงาน",
|
|
116
|
+
bold: "ตัวหนา",
|
|
117
|
+
italic: "ตัวเอียง",
|
|
118
|
+
underline: "ขีดเส้นใต้",
|
|
119
|
+
strike: "ขีดฆ่า",
|
|
120
|
+
code: "โค้ด",
|
|
121
|
+
blockquote: "ยกคำพูด",
|
|
122
|
+
bulletList: "รายการหัวข้อ",
|
|
123
|
+
orderedList: "รายการตัวเลข",
|
|
124
|
+
link: "ลิงก์",
|
|
125
|
+
linkPrompt: "ใส่ลิงก์",
|
|
126
|
+
linkNeedsSelection: "เลือกข้อความที่ต้องการทำลิงก์ก่อน",
|
|
127
|
+
linkInvalid: "ลิงก์ต้องขึ้นต้นด้วย http:// หรือ https://",
|
|
128
|
+
attachImage: "แนบรูป",
|
|
129
|
+
uploading: "กำลังอัปโหลดรูป…",
|
|
130
|
+
send: "ส่งข้อความ",
|
|
131
|
+
sending: "กำลังส่ง…",
|
|
132
|
+
sent: "ส่งข้อความแล้ว",
|
|
133
|
+
sendFailed: "ส่งไม่สำเร็จ ลองใหม่อีกครั้ง",
|
|
134
|
+
reopen: "ยังมีปัญหา",
|
|
135
|
+
reopenHint: "ถ้าปัญหายังไม่หาย แจ้งทีมงานอีกครั้งได้",
|
|
136
|
+
reopening: "กำลังแจ้ง…",
|
|
137
|
+
reopened: "แจ้งทีมงานอีกครั้งแล้ว — เรากำลังดูให้ครับ",
|
|
138
|
+
reopenFailed: "เปิดเรื่องไม่สำเร็จ ลองใหม่อีกครั้ง",
|
|
139
|
+
close: "ปิด",
|
|
140
|
+
retry: "ลองใหม่",
|
|
141
|
+
page: "หน้า",
|
|
142
|
+
of: (page, pages) => `หน้า ${page} จาก ${pages}`,
|
|
143
|
+
prev: "ก่อนหน้า",
|
|
144
|
+
next: "ถัดไป",
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const en: TicketStrings = {
|
|
148
|
+
title: "My reports",
|
|
149
|
+
intro: "The problems you have reported, with their latest status",
|
|
150
|
+
searchPlaceholder: "Search number / title / module",
|
|
151
|
+
searchLabel: "Search my reports",
|
|
152
|
+
allStatuses: "All statuses",
|
|
153
|
+
count: (n) => `${n} item${n === 1 ? "" : "s"}`,
|
|
154
|
+
colNumber: "No.",
|
|
155
|
+
colTitle: "Title",
|
|
156
|
+
colModule: "Module",
|
|
157
|
+
colStatus: "Status",
|
|
158
|
+
colResponse: "Team response",
|
|
159
|
+
colCreated: "Reported",
|
|
160
|
+
colUpdated: "Last update",
|
|
161
|
+
awaitingReply: "Awaiting your reply",
|
|
162
|
+
hasSolution: "Solution provided",
|
|
163
|
+
hasReply: "Replied",
|
|
164
|
+
noReply: "No reply yet",
|
|
165
|
+
empty: "You have not reported anything yet",
|
|
166
|
+
emptyFiltered: "Nothing matches that search",
|
|
167
|
+
loading: "Loading…",
|
|
168
|
+
listFailed: "Could not load your reports",
|
|
169
|
+
detailFailed: "Could not load this report",
|
|
170
|
+
signedOut: "Sign in to see the problems you have reported",
|
|
171
|
+
back: "Back to the list",
|
|
172
|
+
reportedBy: "Reported",
|
|
173
|
+
conversation: "Conversation",
|
|
174
|
+
resolution: "Solution",
|
|
175
|
+
noMessages: "No messages yet",
|
|
176
|
+
you: "You",
|
|
177
|
+
team: "Support",
|
|
178
|
+
reporterRole: "Reporter",
|
|
179
|
+
edited: "edited",
|
|
180
|
+
edit: "Edit",
|
|
181
|
+
saveEdit: "Save changes",
|
|
182
|
+
cancel: "Cancel",
|
|
183
|
+
replyPlaceholder: "Write a message to the team",
|
|
184
|
+
bold: "Bold",
|
|
185
|
+
italic: "Italic",
|
|
186
|
+
underline: "Underline",
|
|
187
|
+
strike: "Strikethrough",
|
|
188
|
+
code: "Code",
|
|
189
|
+
blockquote: "Quote",
|
|
190
|
+
bulletList: "Bulleted list",
|
|
191
|
+
orderedList: "Numbered list",
|
|
192
|
+
link: "Link",
|
|
193
|
+
linkPrompt: "Enter a link",
|
|
194
|
+
linkNeedsSelection: "Select the text you want to link first",
|
|
195
|
+
linkInvalid: "A link must start with http:// or https://",
|
|
196
|
+
attachImage: "Add an image",
|
|
197
|
+
uploading: "Uploading the image…",
|
|
198
|
+
send: "Send",
|
|
199
|
+
sending: "Sending…",
|
|
200
|
+
sent: "Message sent",
|
|
201
|
+
sendFailed: "Could not send — try again",
|
|
202
|
+
reopen: "Still a problem",
|
|
203
|
+
reopenHint: "If the problem is not fixed, tell the team again",
|
|
204
|
+
reopening: "Sending…",
|
|
205
|
+
reopened: "The team has been notified — we are looking at it",
|
|
206
|
+
reopenFailed: "Could not reopen — try again",
|
|
207
|
+
close: "Close",
|
|
208
|
+
retry: "Try again",
|
|
209
|
+
page: "Page",
|
|
210
|
+
of: (page, pages) => `Page ${page} of ${pages}`,
|
|
211
|
+
prev: "Previous",
|
|
212
|
+
next: "Next",
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
export function ticketStrings(locale: "th" | "en" | undefined): TicketStrings {
|
|
216
|
+
return locale === "en" ? en : th;
|
|
217
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which controls a rich composer offers.
|
|
3
|
+
*
|
|
4
|
+
* A list rather than a fixed set, because the original itself has two —
|
|
5
|
+
* `NOTE_TOOLBAR` for replies and a longer one for articles — and the support app's
|
|
6
|
+
* admin composer wants a few more than a reporter's does. The alternative was a
|
|
7
|
+
* second editor component, which is the thing this package exists to stop.
|
|
8
|
+
*/
|
|
9
|
+
export type EditorTool =
|
|
10
|
+
| "bold"
|
|
11
|
+
| "italic"
|
|
12
|
+
| "underline"
|
|
13
|
+
| "strike"
|
|
14
|
+
| "code"
|
|
15
|
+
| "bulletList"
|
|
16
|
+
| "orderedList"
|
|
17
|
+
| "blockquote"
|
|
18
|
+
| "link"
|
|
19
|
+
| "image";
|
|
20
|
+
|
|
21
|
+
/** The original's own NOTE_TOOLBAR — what a reporter replying to a ticket gets. */
|
|
22
|
+
export const NOTE_TOOLBAR: EditorTool[] = [
|
|
23
|
+
"bold",
|
|
24
|
+
"italic",
|
|
25
|
+
"underline",
|
|
26
|
+
"bulletList",
|
|
27
|
+
"orderedList",
|
|
28
|
+
"link",
|
|
29
|
+
"image",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Everything, for a staff composer. Adds the three the admin side had grown on
|
|
34
|
+
* its own — strike, code and blockquote — so adopting this component costs the
|
|
35
|
+
* app nothing it already had.
|
|
36
|
+
*/
|
|
37
|
+
export const FULL_TOOLBAR: EditorTool[] = [
|
|
38
|
+
"bold",
|
|
39
|
+
"italic",
|
|
40
|
+
"underline",
|
|
41
|
+
"strike",
|
|
42
|
+
"code",
|
|
43
|
+
"bulletList",
|
|
44
|
+
"orderedList",
|
|
45
|
+
"blockquote",
|
|
46
|
+
"link",
|
|
47
|
+
"image",
|
|
48
|
+
];
|
package/src/reporter.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bus between "something happened" and "the reporter opens".
|
|
3
|
+
*
|
|
4
|
+
* Replaces the original's zustand store. Not because zustand is bad, but
|
|
5
|
+
* because it is React's: this package is consumed by Vue and Angular apps too,
|
|
6
|
+
* and a state library in the core would be a framework choice imposed on all of
|
|
7
|
+
* them for the sake of two callbacks and a Set.
|
|
8
|
+
*
|
|
9
|
+
* `launch` must be called INSIDE the user's gesture. Screen capture asks the
|
|
10
|
+
* browser for permission, and a browser only grants that to a real click — an
|
|
11
|
+
* await before it and the request is refused with no useful error.
|
|
12
|
+
*/
|
|
13
|
+
import type { DebugReason } from "./types";
|
|
14
|
+
|
|
15
|
+
type Listener = (reason: DebugReason) => void;
|
|
16
|
+
|
|
17
|
+
const launchListeners = new Set<Listener>();
|
|
18
|
+
const crashListeners = new Set<Listener>();
|
|
19
|
+
|
|
20
|
+
function subscribe(set: Set<Listener>, fn: Listener): () => void {
|
|
21
|
+
set.add(fn);
|
|
22
|
+
return () => set.delete(fn);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function emit(set: Set<Listener>, reason: DebugReason): void {
|
|
26
|
+
// Copied first: a listener that unsubscribes itself while we iterate would
|
|
27
|
+
// otherwise skip the next one.
|
|
28
|
+
for (const fn of [...set]) fn(reason);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The reporter UI subscribes here. */
|
|
32
|
+
export function onLaunch(fn: Listener): () => void {
|
|
33
|
+
return subscribe(launchListeners, fn);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Open the reporter. Call from inside a click handler — see the note above. */
|
|
37
|
+
export function launch(reason: DebugReason = { type: "manual" }): void {
|
|
38
|
+
emit(launchListeners, reason);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A crash was caught. Deliberately NOT the same channel as `launch`: opening a
|
|
43
|
+
* screen-capture prompt on its own, with no gesture behind it, is both refused
|
|
44
|
+
* by the browser and rude. A listener turns this into an invitation — a toast
|
|
45
|
+
* with a button — and that button calls `launch`.
|
|
46
|
+
*/
|
|
47
|
+
export function onCrash(fn: Listener): () => void {
|
|
48
|
+
return subscribe(crashListeners, fn);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function emitCrash(reason: DebugReason): void {
|
|
52
|
+
emit(crashListeners, reason);
|
|
53
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Screenshot capture via the native Screen Capture API (getDisplayMedia). With
|
|
2
|
+
// `preferCurrentTab` this captures the whole current tab — INCLUDING the
|
|
3
|
+
// cross-origin OnlyOffice document iframe, which DOM-rendering libraries cannot
|
|
4
|
+
// reach. Falls back to html-to-image only if the user denies the permission or
|
|
5
|
+
// the browser lacks support.
|
|
6
|
+
|
|
7
|
+
import { getConfig } from './config';
|
|
8
|
+
import type { ScreenshotMethod } from './types';
|
|
9
|
+
|
|
10
|
+
export interface ScreenshotResult {
|
|
11
|
+
dataUrl: string | null;
|
|
12
|
+
method: ScreenshotMethod;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Chromium-only options not yet in the standard lib.dom typings.
|
|
16
|
+
type ExtendedDisplayMediaOptions = DisplayMediaStreamOptions & {
|
|
17
|
+
preferCurrentTab?: boolean;
|
|
18
|
+
selfBrowserSurface?: 'include' | 'exclude';
|
|
19
|
+
surfaceSwitching?: 'include' | 'exclude';
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export async function captureScreen(): Promise<ScreenshotResult> {
|
|
23
|
+
const canNative =
|
|
24
|
+
typeof navigator !== 'undefined' && Boolean(navigator.mediaDevices?.getDisplayMedia);
|
|
25
|
+
|
|
26
|
+
if (canNative) {
|
|
27
|
+
const native = await captureViaDisplayMedia();
|
|
28
|
+
if (native.status === 'ok') return { dataUrl: native.dataUrl, method: 'display-media' };
|
|
29
|
+
// The user explicitly dismissed the share picker → abort the whole report
|
|
30
|
+
// (do NOT silently fall back to a DOM capture).
|
|
31
|
+
if (native.status === 'cancelled') return { dataUrl: null, method: 'cancelled' };
|
|
32
|
+
// status === 'error' → the browser failed for another reason; fall through
|
|
33
|
+
// to the DOM capture below so the feature still works.
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const dom = await captureViaHtmlToImage();
|
|
37
|
+
if (dom) return { dataUrl: dom, method: 'html-to-image' };
|
|
38
|
+
|
|
39
|
+
return { dataUrl: null, method: 'none' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type NativeCaptureResult =
|
|
43
|
+
| { status: 'ok'; dataUrl: string }
|
|
44
|
+
| { status: 'cancelled' }
|
|
45
|
+
| { status: 'error' };
|
|
46
|
+
|
|
47
|
+
async function captureViaDisplayMedia(): Promise<NativeCaptureResult> {
|
|
48
|
+
let stream: MediaStream | null = null;
|
|
49
|
+
try {
|
|
50
|
+
const options: ExtendedDisplayMediaOptions = {
|
|
51
|
+
video: { frameRate: 30 },
|
|
52
|
+
audio: false,
|
|
53
|
+
// Default the picker to the current tab and allow it to capture itself,
|
|
54
|
+
// so the flow is effectively one click.
|
|
55
|
+
preferCurrentTab: true,
|
|
56
|
+
selfBrowserSurface: 'include',
|
|
57
|
+
surfaceSwitching: 'exclude',
|
|
58
|
+
};
|
|
59
|
+
stream = await navigator.mediaDevices.getDisplayMedia(options);
|
|
60
|
+
const dataUrl = await grabFrame(stream);
|
|
61
|
+
return dataUrl ? { status: 'ok', dataUrl } : { status: 'error' };
|
|
62
|
+
} catch (err) {
|
|
63
|
+
// NotAllowedError / AbortError = the user dismissed the picker (Cancel).
|
|
64
|
+
if (
|
|
65
|
+
err instanceof DOMException &&
|
|
66
|
+
(err.name === 'NotAllowedError' || err.name === 'AbortError')
|
|
67
|
+
) {
|
|
68
|
+
return { status: 'cancelled' };
|
|
69
|
+
}
|
|
70
|
+
return { status: 'error' };
|
|
71
|
+
} finally {
|
|
72
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function grabFrame(stream: MediaStream): Promise<string | null> {
|
|
77
|
+
const video = document.createElement('video');
|
|
78
|
+
video.srcObject = stream;
|
|
79
|
+
video.muted = true;
|
|
80
|
+
video.playsInline = true;
|
|
81
|
+
|
|
82
|
+
// Wait for metadata + playback to start (so videoWidth/Height are known).
|
|
83
|
+
await new Promise<void>((resolve) => {
|
|
84
|
+
let settled = false;
|
|
85
|
+
const finish = () => {
|
|
86
|
+
if (settled) return;
|
|
87
|
+
settled = true;
|
|
88
|
+
resolve();
|
|
89
|
+
};
|
|
90
|
+
video.onloadedmetadata = () => {
|
|
91
|
+
video.play().then(finish).catch(finish);
|
|
92
|
+
};
|
|
93
|
+
window.setTimeout(finish, 1500);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Wait for an actually-painted frame before drawing — otherwise the canvas
|
|
97
|
+
// captures an empty frame (the original blank-screenshot bug).
|
|
98
|
+
await waitForPaintedFrame(video);
|
|
99
|
+
|
|
100
|
+
const width = video.videoWidth;
|
|
101
|
+
const height = video.videoHeight;
|
|
102
|
+
if (!width || !height) return null;
|
|
103
|
+
|
|
104
|
+
const canvas = document.createElement('canvas');
|
|
105
|
+
canvas.width = width;
|
|
106
|
+
canvas.height = height;
|
|
107
|
+
const ctx = canvas.getContext('2d');
|
|
108
|
+
if (!ctx) return null;
|
|
109
|
+
|
|
110
|
+
// Draw FIRST, then tear the stream/source down. (Clearing srcObject before
|
|
111
|
+
// drawImage is what produced the blank capture previously.)
|
|
112
|
+
ctx.drawImage(video, 0, 0, width, height);
|
|
113
|
+
video.pause();
|
|
114
|
+
video.srcObject = null;
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
return canvas.toDataURL('image/png');
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type VideoWithFrameCallback = HTMLVideoElement & {
|
|
124
|
+
requestVideoFrameCallback?: (callback: () => void) => number;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
function waitForPaintedFrame(video: HTMLVideoElement): Promise<void> {
|
|
128
|
+
return new Promise((resolve) => {
|
|
129
|
+
let settled = false;
|
|
130
|
+
const finish = () => {
|
|
131
|
+
if (settled) return;
|
|
132
|
+
settled = true;
|
|
133
|
+
resolve();
|
|
134
|
+
};
|
|
135
|
+
const withCallback = video as VideoWithFrameCallback;
|
|
136
|
+
if (typeof withCallback.requestVideoFrameCallback === 'function') {
|
|
137
|
+
withCallback.requestVideoFrameCallback(() => finish());
|
|
138
|
+
window.setTimeout(finish, 1000); // safety net
|
|
139
|
+
} else {
|
|
140
|
+
// Fallback: give the compositor a couple of frames to paint.
|
|
141
|
+
window.setTimeout(finish, 250);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The DOM fallback, supplied by the host.
|
|
148
|
+
*
|
|
149
|
+
* This used to `import('html-to-image')` behind a runtime-built specifier, so a
|
|
150
|
+
* host that never installed it would still build. That does not survive
|
|
151
|
+
* minification — the constant gets folded back into the import, Rollup sees a
|
|
152
|
+
* literal it cannot resolve, and the CONSUMER's build fails. A package cannot
|
|
153
|
+
* name a dependency it does not have.
|
|
154
|
+
*
|
|
155
|
+
* So the host injects it instead:
|
|
156
|
+
*
|
|
157
|
+
* screenshotFallback: () => import('html-to-image').then((m) => m.toPng)
|
|
158
|
+
*
|
|
159
|
+
* Which is also more honest. getDisplayMedia handles the real cases and does it
|
|
160
|
+
* better — it captures cross-origin iframes, which no DOM renderer can reach —
|
|
161
|
+
* so this is a fallback for browsers without the Screen Capture API, and
|
|
162
|
+
* whether that is worth ~40 kB is the host's call, not ours.
|
|
163
|
+
*/
|
|
164
|
+
async function captureViaHtmlToImage(): Promise<string | null> {
|
|
165
|
+
const load = getConfig().screenshotFallback;
|
|
166
|
+
if (!load) return null;
|
|
167
|
+
const toPng = await load().catch(() => null);
|
|
168
|
+
if (!toPng) return null;
|
|
169
|
+
try {
|
|
170
|
+
return await toPng(document.body, {
|
|
171
|
+
cacheBust: true,
|
|
172
|
+
pixelRatio: Math.min(window.devicePixelRatio || 1, 2),
|
|
173
|
+
filter: (node: Node) =>
|
|
174
|
+
!(node instanceof HTMLElement && node.dataset?.debugReporter === 'true'),
|
|
175
|
+
});
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Shrink a screenshot to something the server will accept.
|
|
183
|
+
*
|
|
184
|
+
* A full-resolution PNG of a 4K screen is comfortably over 5 MB, and 5 MB is
|
|
185
|
+
* the per-file cap on the ingest endpoint — so an untouched capture is rejected
|
|
186
|
+
* at the moment the user presses send, after they have written the report and
|
|
187
|
+
* drawn on the image. The original ran every shot through
|
|
188
|
+
* `browser-image-compression` for exactly this reason.
|
|
189
|
+
*
|
|
190
|
+
* Done with a plain canvas instead, because a dependency to resize one image is
|
|
191
|
+
* not worth what every host app would pay for it. JPEG, not PNG: a screenshot
|
|
192
|
+
* is a photograph of a screen, and PNG's lossless promise costs several times
|
|
193
|
+
* the bytes to keep detail nobody is going to inspect.
|
|
194
|
+
*/
|
|
195
|
+
const MAX_EDGE = 2000;
|
|
196
|
+
const QUALITY = 0.82;
|
|
197
|
+
/** Comfortably under the 5 MB cap once base64 has added its third. */
|
|
198
|
+
const TARGET_BYTES = 3_000_000;
|
|
199
|
+
|
|
200
|
+
function loadImage(src: string): Promise<HTMLImageElement> {
|
|
201
|
+
return new Promise((resolve, reject) => {
|
|
202
|
+
const img = new Image();
|
|
203
|
+
img.onload = () => resolve(img);
|
|
204
|
+
img.onerror = () => reject(new Error("could not read the screenshot"));
|
|
205
|
+
img.src = src;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function compressScreenshot(dataUrl: string): Promise<string> {
|
|
210
|
+
try {
|
|
211
|
+
const img = await loadImage(dataUrl);
|
|
212
|
+
const longest = Math.max(img.naturalWidth, img.naturalHeight);
|
|
213
|
+
const scale = Math.min(1, MAX_EDGE / longest);
|
|
214
|
+
const canvas = document.createElement("canvas");
|
|
215
|
+
canvas.width = Math.max(1, Math.round(img.naturalWidth * scale));
|
|
216
|
+
canvas.height = Math.max(1, Math.round(img.naturalHeight * scale));
|
|
217
|
+
const ctx = canvas.getContext("2d");
|
|
218
|
+
if (!ctx) return dataUrl;
|
|
219
|
+
// A white ground: JPEG has no alpha, and without this a transparent capture
|
|
220
|
+
// comes out on black.
|
|
221
|
+
ctx.fillStyle = "#fff";
|
|
222
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
223
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
224
|
+
|
|
225
|
+
let quality = QUALITY;
|
|
226
|
+
let out = canvas.toDataURL("image/jpeg", quality);
|
|
227
|
+
// A dense screenshot can still be large at 2000px. Step down rather than
|
|
228
|
+
// send something that will be refused.
|
|
229
|
+
while (out.length > TARGET_BYTES && quality > 0.4) {
|
|
230
|
+
quality -= 0.15;
|
|
231
|
+
out = canvas.toDataURL("image/jpeg", quality);
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
} catch {
|
|
235
|
+
// Better a large screenshot the server might still take than none.
|
|
236
|
+
return dataUrl;
|
|
237
|
+
}
|
|
238
|
+
}
|
package/src/signature.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dedup key: the failed API call that most likely caused the crash.
|
|
3
|
+
*
|
|
4
|
+
* `METHOD /path/:id STATUS`, e.g. `PUT /documents/:id 500`. Two users hitting
|
|
5
|
+
* the same broken endpoint produce the same key, so the backend merges their
|
|
6
|
+
* reports into one parent with duplicates under it — which is what stops an
|
|
7
|
+
* outage from arriving as forty separate tickets.
|
|
8
|
+
*
|
|
9
|
+
* The only normalisation is templating record ids out of the URL PATH, which is
|
|
10
|
+
* bounded, like a route pattern. No fuzzy text matching: a signature that merges
|
|
11
|
+
* two unrelated failures is worse than no signature, because the second report
|
|
12
|
+
* disappears under the first and nobody reads it.
|
|
13
|
+
*
|
|
14
|
+
* Returns null for a manual report or when there is no clear failure. We never
|
|
15
|
+
* guess — an unmerged ticket is a small cost, a wrongly merged one is a lost bug.
|
|
16
|
+
*/
|
|
17
|
+
import type { DebugBundle } from "./types";
|
|
18
|
+
|
|
19
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
20
|
+
const NUMERIC = /^\d+$/;
|
|
21
|
+
const LONG_HEX = /^[0-9a-f]{16,}$/i;
|
|
22
|
+
|
|
23
|
+
export function errorSignature(bundle: DebugBundle): string | null {
|
|
24
|
+
if (bundle.reason.type === "manual") return null;
|
|
25
|
+
const failed = bundle.cause?.failedRequests?.[0];
|
|
26
|
+
if (!failed?.url) return null;
|
|
27
|
+
|
|
28
|
+
let path: string;
|
|
29
|
+
try {
|
|
30
|
+
path = new URL(failed.url, "http://x").pathname;
|
|
31
|
+
} catch {
|
|
32
|
+
path = failed.url;
|
|
33
|
+
}
|
|
34
|
+
const template = path
|
|
35
|
+
.split("/")
|
|
36
|
+
.map((seg) => (UUID.test(seg) || NUMERIC.test(seg) || LONG_HEX.test(seg) ? ":id" : seg))
|
|
37
|
+
.join("/");
|
|
38
|
+
const status = failed.status ?? (failed.error ? "ERR" : "?");
|
|
39
|
+
return `${failed.method ?? "REQ"} ${template} ${status}`;
|
|
40
|
+
}
|