@h-ai/feedback 0.0.0-stage → 0.1.0-alpha.70
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/LICENSE +202 -0
- package/README.en.md +115 -0
- package/README.ja.md +115 -0
- package/README.md +116 -2
- package/dist/bundle/index.d.ts +212 -0
- package/dist/bundle/index.js +3 -0
- package/dist/bundle/index.js.map +1 -0
- package/dist/chunk-43AOXE4W.js +333 -0
- package/dist/chunk-43AOXE4W.js.map +1 -0
- package/dist/chunk-DYEK2FNM.js +101 -0
- package/dist/chunk-DYEK2FNM.js.map +1 -0
- package/dist/client/index.d.ts +187 -0
- package/dist/client/index.js +889 -0
- package/dist/client/index.js.map +1 -0
- package/dist/index.d.ts +154 -0
- package/dist/index.js +1396 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -3
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
import { HaiFeedbackError, feedbackM, redactFeedbackText } from '../chunk-DYEK2FNM.js';
|
|
2
|
+
import { FeedbackEvidenceInputSchema, FeedbackReportSchema, FeedbackSubmitInputSchema } from '@h-ai/api-contract/feedback';
|
|
3
|
+
import { core, ok, err } from '@h-ai/core';
|
|
4
|
+
|
|
5
|
+
// src/client/feedback-client-image.ts
|
|
6
|
+
var PRIVATE_SELECTOR = 'input,textarea,select,[contenteditable]:not([contenteditable="false"]),[data-feedback-private],.rr-block,.rr-mask';
|
|
7
|
+
async function captureFeedbackScreenshot() {
|
|
8
|
+
if (document.fonts) {
|
|
9
|
+
let timeout;
|
|
10
|
+
try {
|
|
11
|
+
await Promise.race([document.fonts.ready, new Promise((resolve) => timeout = setTimeout(resolve, 500))]);
|
|
12
|
+
} finally {
|
|
13
|
+
clearTimeout(timeout);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const { default: html2canvas } = await import('html2canvas');
|
|
17
|
+
const canvas = await html2canvas(document.body, {
|
|
18
|
+
width: Math.min(window.innerWidth, 1920),
|
|
19
|
+
height: Math.min(window.innerHeight, 1200),
|
|
20
|
+
scale: 1,
|
|
21
|
+
// 原生 SVG foreignObject 保留 CSS mask 图标与字体基线,避免 CanvasRenderer 裁切文本。
|
|
22
|
+
foreignObjectRendering: true,
|
|
23
|
+
scrollX: -window.scrollX,
|
|
24
|
+
scrollY: -window.scrollY,
|
|
25
|
+
useCORS: false,
|
|
26
|
+
allowTaint: false,
|
|
27
|
+
logging: false,
|
|
28
|
+
imageTimeout: 1e3,
|
|
29
|
+
ignoreElements: (element) => element.matches("[data-feedback-ignore],iframe,video"),
|
|
30
|
+
onclone: (clone) => {
|
|
31
|
+
const colorCanvas = clone.createElement("canvas");
|
|
32
|
+
colorCanvas.width = 1;
|
|
33
|
+
colorCanvas.height = 1;
|
|
34
|
+
const paint = colorCanvas.getContext("2d", { willReadFrequently: true });
|
|
35
|
+
const colors = /* @__PURE__ */ new Map();
|
|
36
|
+
if (paint && clone.defaultView) {
|
|
37
|
+
for (const element of clone.querySelectorAll("*")) {
|
|
38
|
+
const computed = clone.defaultView.getComputedStyle(element);
|
|
39
|
+
for (const property of ["color", "background-color", "border-top-color", "border-bottom-color", "border-left-color", "border-right-color", "outline-color", "text-decoration-color", "-webkit-text-stroke-color"]) {
|
|
40
|
+
const raw = computed.getPropertyValue(property);
|
|
41
|
+
if (!/oklch|oklab|color\(|lab\(|lch\(/i.test(raw))
|
|
42
|
+
continue;
|
|
43
|
+
let normalized = colors.get(raw);
|
|
44
|
+
if (!normalized) {
|
|
45
|
+
paint.clearRect(0, 0, 1, 1);
|
|
46
|
+
paint.fillStyle = raw;
|
|
47
|
+
paint.fillRect(0, 0, 1, 1);
|
|
48
|
+
const pixel = paint.getImageData(0, 0, 1, 1).data;
|
|
49
|
+
normalized = `rgba(${pixel[0]},${pixel[1]},${pixel[2]},${pixel[3] / 255})`;
|
|
50
|
+
colors.set(raw, normalized);
|
|
51
|
+
}
|
|
52
|
+
element.style.setProperty(property, normalized, "important");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const element of clone.querySelectorAll(PRIVATE_SELECTOR)) {
|
|
57
|
+
const bounds = element.getBoundingClientRect();
|
|
58
|
+
const replacement = clone.createElement("div");
|
|
59
|
+
replacement.style.cssText = `width:${bounds.width}px;height:${bounds.height}px;background:#111;color:transparent;overflow:hidden`;
|
|
60
|
+
replacement.setAttribute("aria-hidden", "true");
|
|
61
|
+
element.replaceWith(replacement);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
return await canvasBytes(canvas);
|
|
67
|
+
} finally {
|
|
68
|
+
canvas.width = 0;
|
|
69
|
+
canvas.height = 0;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function annotateFeedbackImage(bytes, annotation) {
|
|
73
|
+
if (![annotation.x, annotation.y, annotation.width, annotation.height].every(Number.isFinite) || annotation.width <= 0 || annotation.height <= 0)
|
|
74
|
+
throw new Error("Invalid annotation rectangle");
|
|
75
|
+
const image = new Image();
|
|
76
|
+
const imageUrl = URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: "image/png" }));
|
|
77
|
+
const canvas = document.createElement("canvas");
|
|
78
|
+
try {
|
|
79
|
+
image.src = imageUrl;
|
|
80
|
+
await image.decode();
|
|
81
|
+
canvas.width = image.naturalWidth;
|
|
82
|
+
canvas.height = image.naturalHeight;
|
|
83
|
+
const context = canvas.getContext("2d");
|
|
84
|
+
if (!context)
|
|
85
|
+
throw new Error("Canvas is unavailable");
|
|
86
|
+
context.drawImage(image, 0, 0);
|
|
87
|
+
if (annotation.mode === "mask") {
|
|
88
|
+
context.fillStyle = "#111";
|
|
89
|
+
context.fillRect(annotation.x, annotation.y, annotation.width, annotation.height);
|
|
90
|
+
} else {
|
|
91
|
+
context.strokeStyle = "#e11d48";
|
|
92
|
+
context.lineWidth = 3;
|
|
93
|
+
context.strokeRect(annotation.x, annotation.y, annotation.width, annotation.height);
|
|
94
|
+
}
|
|
95
|
+
return await canvasBytes(canvas);
|
|
96
|
+
} finally {
|
|
97
|
+
URL.revokeObjectURL(imageUrl);
|
|
98
|
+
canvas.width = 0;
|
|
99
|
+
canvas.height = 0;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function canvasBytes(canvas) {
|
|
103
|
+
const blob = await new Promise((resolve, reject) => canvas.toBlob((value) => value ? resolve(value) : reject(new Error("Screenshot encoding failed")), "image/png"));
|
|
104
|
+
return new Uint8Array(await blob.arrayBuffer());
|
|
105
|
+
}
|
|
106
|
+
var CREDENTIAL_NAME = /authorization|password|passcode|secret|token|session|csrf|credential|api.?key|private.?key/i;
|
|
107
|
+
function sanitizeFeedbackText(value) {
|
|
108
|
+
return redactFeedbackText(value).replace(/\bhttps?:\/\/[^\s<>"']+/gi, (url) => core.sanitize.sanitizeUrl(url));
|
|
109
|
+
}
|
|
110
|
+
function sanitizeFeedbackData(value) {
|
|
111
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
112
|
+
function visit(input, depth) {
|
|
113
|
+
if (depth > 8)
|
|
114
|
+
return "[TRUNCATED]";
|
|
115
|
+
if (typeof input === "string")
|
|
116
|
+
return sanitizeFeedbackText(input).slice(0, 8192);
|
|
117
|
+
if (input instanceof Error)
|
|
118
|
+
return visit({ name: input.name, message: input.message, stack: input.stack }, depth + 1);
|
|
119
|
+
if (!input || typeof input !== "object")
|
|
120
|
+
return input === void 0 || typeof input === "function" || typeof input === "symbol" ? null : typeof input === "bigint" ? String(input) : input;
|
|
121
|
+
if (seen.has(input))
|
|
122
|
+
return "[CIRCULAR]";
|
|
123
|
+
seen.add(input);
|
|
124
|
+
if (Array.isArray(input))
|
|
125
|
+
return input.slice(0, 200).map((item) => visit(item, depth + 1));
|
|
126
|
+
const output = {};
|
|
127
|
+
for (const [key, item] of Object.entries(input).slice(0, 100)) {
|
|
128
|
+
if (["__proto__", "constructor", "prototype"].includes(key))
|
|
129
|
+
continue;
|
|
130
|
+
output[key] = CREDENTIAL_NAME.test(key) ? "[REDACTED]" : visit(item, depth + 1);
|
|
131
|
+
}
|
|
132
|
+
return output;
|
|
133
|
+
}
|
|
134
|
+
const safe = core.sanitize.sanitizeSensitiveFields(visit(value, 0));
|
|
135
|
+
return safe && typeof safe === "object" && !Array.isArray(safe) ? safe : { value: safe };
|
|
136
|
+
}
|
|
137
|
+
function sanitizeFeedbackReplay(value) {
|
|
138
|
+
function visit(input, depth) {
|
|
139
|
+
if (depth > 64)
|
|
140
|
+
throw new Error("Replay snapshot exceeds depth limit");
|
|
141
|
+
if (typeof input === "string")
|
|
142
|
+
return sanitizeFeedbackText(input);
|
|
143
|
+
if (input === void 0)
|
|
144
|
+
return null;
|
|
145
|
+
if (!input || typeof input !== "object")
|
|
146
|
+
return input;
|
|
147
|
+
if (Array.isArray(input))
|
|
148
|
+
return input.map((item) => visit(item, depth + 1));
|
|
149
|
+
const output = {};
|
|
150
|
+
for (const [key, item] of Object.entries(input)) {
|
|
151
|
+
if (["__proto__", "constructor", "prototype"].includes(key))
|
|
152
|
+
continue;
|
|
153
|
+
if (key === "attributes" && item && typeof item === "object") {
|
|
154
|
+
const attributes = {};
|
|
155
|
+
for (const [attribute, content] of Object.entries(item)) {
|
|
156
|
+
if (/^on|^(?:srcdoc|action|formaction|href|srcset)$/i.test(attribute) || CREDENTIAL_NAME.test(attribute))
|
|
157
|
+
continue;
|
|
158
|
+
if (attribute === "src" && typeof content === "string" && !/^data:image\/(?:png|jpeg|webp|gif);base64,/i.test(content)) {
|
|
159
|
+
attributes["data-feedback-missing-resource"] = true;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (attribute === "style" && typeof content === "string" && /url\s*\(/i.test(content)) {
|
|
163
|
+
attributes.style = sanitizeFeedbackText(content).replace(/url\s*\([^)]*\)/gi, "");
|
|
164
|
+
attributes["data-feedback-missing-resource"] = true;
|
|
165
|
+
} else {
|
|
166
|
+
attributes[attribute] = visit(content, depth + 1);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
output[key] = attributes;
|
|
170
|
+
} else if (key === "textContent" && typeof item === "string") {
|
|
171
|
+
output[key] = sanitizeFeedbackText(item).replace(/(?:url|@import)\s*\([^)]*\)|@import\s+["'][^"']+["']/gi, "");
|
|
172
|
+
} else {
|
|
173
|
+
output[key] = CREDENTIAL_NAME.test(key) ? "[REDACTED]" : visit(item, depth + 1);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return output;
|
|
177
|
+
}
|
|
178
|
+
return visit(value, 0);
|
|
179
|
+
}
|
|
180
|
+
function collectFeedbackCookies(cookie, allowlist) {
|
|
181
|
+
const allowed = new Set(allowlist.filter((name) => !CREDENTIAL_NAME.test(name)));
|
|
182
|
+
const output = {};
|
|
183
|
+
for (const pair of cookie.split(";")) {
|
|
184
|
+
const separator = pair.indexOf("=");
|
|
185
|
+
if (separator < 0)
|
|
186
|
+
continue;
|
|
187
|
+
const name = pair.slice(0, separator).trim();
|
|
188
|
+
if (!allowed.has(name))
|
|
189
|
+
continue;
|
|
190
|
+
output[name] = redactFeedbackText(pair.slice(separator + 1).trim().slice(0, 256));
|
|
191
|
+
}
|
|
192
|
+
return output;
|
|
193
|
+
}
|
|
194
|
+
function feedbackPageUrl(raw) {
|
|
195
|
+
try {
|
|
196
|
+
const url = new URL(raw);
|
|
197
|
+
return `${url.origin}${url.pathname}`;
|
|
198
|
+
} catch {
|
|
199
|
+
return "[INVALID_URL]";
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/client/feedback-client-store.ts
|
|
204
|
+
var STORE = "drafts";
|
|
205
|
+
async function withStore(callback) {
|
|
206
|
+
const database = await new Promise((resolve, reject) => {
|
|
207
|
+
const request = indexedDB.open("hai-feedback-v1", 1);
|
|
208
|
+
request.onupgradeneeded = () => request.result.createObjectStore(STORE);
|
|
209
|
+
request.onerror = () => reject(request.error);
|
|
210
|
+
request.onsuccess = () => resolve(request.result);
|
|
211
|
+
});
|
|
212
|
+
try {
|
|
213
|
+
return await new Promise((resolve, reject) => {
|
|
214
|
+
const transaction = database.transaction(STORE, "readwrite");
|
|
215
|
+
const request = callback(transaction.objectStore(STORE));
|
|
216
|
+
transaction.oncomplete = () => resolve(request.result);
|
|
217
|
+
transaction.onerror = () => reject(transaction.error);
|
|
218
|
+
transaction.onabort = () => reject(transaction.error);
|
|
219
|
+
});
|
|
220
|
+
} finally {
|
|
221
|
+
database.close();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async function saveFeedbackDraft(appId, draft) {
|
|
225
|
+
await withStore((store) => store.put(draft, appId));
|
|
226
|
+
}
|
|
227
|
+
async function restoreFeedbackDraft(appId) {
|
|
228
|
+
const value = await withStore((store) => store.get(appId));
|
|
229
|
+
if (!value || typeof value !== "object" || !("expiresAt" in value) || typeof value.expiresAt !== "number" || value.expiresAt <= Date.now()) {
|
|
230
|
+
await withStore((store) => store.delete(appId));
|
|
231
|
+
return void 0;
|
|
232
|
+
}
|
|
233
|
+
return value;
|
|
234
|
+
}
|
|
235
|
+
async function deleteFeedbackDraft(appId) {
|
|
236
|
+
await withStore((store) => {
|
|
237
|
+
store.delete(`${appId}:checkpoint`);
|
|
238
|
+
return store.delete(appId);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
async function saveFeedbackCheckpoint(key, evidence) {
|
|
242
|
+
await withStore((store) => store.put({ evidence, expiresAt: Date.now() + 3e5 }, `${key}:checkpoint`));
|
|
243
|
+
}
|
|
244
|
+
async function restoreFeedbackCheckpoint(key) {
|
|
245
|
+
return withStore((store) => store.get(`${key}:checkpoint`));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// src/client/feedback-client-main.ts
|
|
249
|
+
function createFeedbackClient() {
|
|
250
|
+
let config;
|
|
251
|
+
let initializing = false;
|
|
252
|
+
let draft;
|
|
253
|
+
let sequence = 0;
|
|
254
|
+
let buffer = [];
|
|
255
|
+
let bufferBytes = 0;
|
|
256
|
+
let dropped = 0;
|
|
257
|
+
let lastError;
|
|
258
|
+
let errorWindows = [];
|
|
259
|
+
let afterCount = 0;
|
|
260
|
+
let deadline = 0;
|
|
261
|
+
let initialIds = /* @__PURE__ */ new Set();
|
|
262
|
+
const uploadedIds = /* @__PURE__ */ new Set();
|
|
263
|
+
let flushPromise;
|
|
264
|
+
let preparePromise;
|
|
265
|
+
let persistTimer;
|
|
266
|
+
let flushTimer;
|
|
267
|
+
let stopReplay;
|
|
268
|
+
let unsubscribeLogger;
|
|
269
|
+
let source = "";
|
|
270
|
+
let keyScreenshots = [];
|
|
271
|
+
let lastKeyScreenshot = 0;
|
|
272
|
+
let keyScreenshotPromise;
|
|
273
|
+
let closing = false;
|
|
274
|
+
let closePromise;
|
|
275
|
+
let collectionFinished = false;
|
|
276
|
+
let collectionSealed = false;
|
|
277
|
+
let draftBytes = 0;
|
|
278
|
+
let storageKey = "";
|
|
279
|
+
const cleanups = [];
|
|
280
|
+
const missing = /* @__PURE__ */ new Set();
|
|
281
|
+
let replayChunks = [];
|
|
282
|
+
function failure(code = "OPERATION_FAILED", cause) {
|
|
283
|
+
const key = code === "NOT_INITIALIZED" ? "feedback_notInitialized" : code === "LIMIT_EXCEEDED" ? "feedback_limitExceeded" : code === "INVALID_INPUT" ? "feedback_invalidInput" : "feedback_operationFailed";
|
|
284
|
+
return err(HaiFeedbackError[code], feedbackM(key), cause);
|
|
285
|
+
}
|
|
286
|
+
function limits() {
|
|
287
|
+
return { events: config?.maxEvents ?? 1e3, bytes: config?.maxBytes ?? 2 * 1024 * 1024, age: config?.maxAgeMs ?? 3e5, before: config?.beforeCount ?? 100, after: config?.afterCount ?? 100 };
|
|
288
|
+
}
|
|
289
|
+
function schedulePersist() {
|
|
290
|
+
if (!config?.persistDraft || persistTimer)
|
|
291
|
+
return;
|
|
292
|
+
persistTimer = setTimeout(() => {
|
|
293
|
+
persistTimer = void 0;
|
|
294
|
+
if (config && draft && !collectionFinished)
|
|
295
|
+
void saveFeedbackDraft(storageKey, draft).catch(() => missing.add("browser-persistence-unavailable"));
|
|
296
|
+
else if (config)
|
|
297
|
+
void saveFeedbackCheckpoint(storageKey, buffer).catch(() => missing.add("browser-persistence-unavailable"));
|
|
298
|
+
}, 200);
|
|
299
|
+
}
|
|
300
|
+
function record(event) {
|
|
301
|
+
if (!config || closing)
|
|
302
|
+
return;
|
|
303
|
+
const entry = FeedbackEvidenceInputSchema.safeParse({
|
|
304
|
+
id: core.id.uuid(),
|
|
305
|
+
source,
|
|
306
|
+
sequence: ++sequence,
|
|
307
|
+
kind: event.kind,
|
|
308
|
+
timestamp: Date.now(),
|
|
309
|
+
requestId: event.requestId,
|
|
310
|
+
errorId: event.kind === "error" || event.kind === "request" && event.data.success === false ? event.errorId ?? core.id.uuid() : event.errorId,
|
|
311
|
+
data: sanitizeFeedbackData(event.data)
|
|
312
|
+
});
|
|
313
|
+
if (!entry.success)
|
|
314
|
+
return;
|
|
315
|
+
const item = entry.data;
|
|
316
|
+
const size = new TextEncoder().encode(JSON.stringify(item)).byteLength;
|
|
317
|
+
const bounds = limits();
|
|
318
|
+
if (size > Math.min(bounds.bytes, 64 * 1024)) {
|
|
319
|
+
dropped++;
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
buffer.push(item);
|
|
323
|
+
bufferBytes += size;
|
|
324
|
+
while (buffer.length > bounds.events || bufferBytes > bounds.bytes || (buffer[0]?.timestamp ?? 0) < Date.now() - bounds.age) {
|
|
325
|
+
const removed = buffer.shift();
|
|
326
|
+
if (removed)
|
|
327
|
+
bufferBytes -= new TextEncoder().encode(JSON.stringify(removed)).byteLength;
|
|
328
|
+
dropped++;
|
|
329
|
+
}
|
|
330
|
+
const logEntry = item.kind === "log" || item.kind === "error";
|
|
331
|
+
if (logEntry) {
|
|
332
|
+
for (const window2 of errorWindows) {
|
|
333
|
+
if (item.timestamp <= window2.deadline && window2.after < bounds.after) {
|
|
334
|
+
window2.evidence.push(item);
|
|
335
|
+
window2.after++;
|
|
336
|
+
window2.bytes += size;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (item.kind === "error" || item.kind === "request" && item.data.success === false) {
|
|
341
|
+
lastError = item;
|
|
342
|
+
const before = buffer.filter((entry2) => (entry2.kind === "log" || entry2.kind === "error") && entry2.id !== item.id);
|
|
343
|
+
const evidence = [...before.slice(Math.max(0, before.length - bounds.before)), item];
|
|
344
|
+
errorWindows.push({ anchor: item, evidence, after: 0, deadline: item.timestamp + (config.afterTimeoutMs ?? 3e4), bytes: new TextEncoder().encode(JSON.stringify(evidence)).byteLength });
|
|
345
|
+
void captureKeyScreenshot();
|
|
346
|
+
}
|
|
347
|
+
errorWindows = errorWindows.filter((window2) => window2.anchor.timestamp >= Date.now() - bounds.age);
|
|
348
|
+
while (errorWindows.length > 10 || errorWindows.reduce((total, window2) => total + window2.evidence.length, 0) > bounds.events || errorWindows.reduce((total, window2) => total + window2.bytes, 0) > bounds.bytes) {
|
|
349
|
+
errorWindows.shift();
|
|
350
|
+
missing.add("browser-error-windows-truncated");
|
|
351
|
+
}
|
|
352
|
+
schedulePersist();
|
|
353
|
+
if (draft && !collectionFinished && !collectionSealed && (Date.now() <= deadline && afterCount < bounds.after || item.kind === "environment") && draft.evidence.length < bounds.events && draftBytes + size <= bounds.bytes) {
|
|
354
|
+
draft.evidence.push(item);
|
|
355
|
+
draftBytes += size;
|
|
356
|
+
if (item.kind === "log" || item.kind === "error")
|
|
357
|
+
afterCount++;
|
|
358
|
+
schedulePersist();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function listen(name, callback) {
|
|
362
|
+
window.addEventListener(name, callback);
|
|
363
|
+
cleanups.push(() => window.removeEventListener(name, callback));
|
|
364
|
+
}
|
|
365
|
+
async function init(input) {
|
|
366
|
+
if (initializing)
|
|
367
|
+
return err(HaiFeedbackError.INIT_IN_PROGRESS, feedbackM("feedback_initInProgress"));
|
|
368
|
+
initializing = true;
|
|
369
|
+
try {
|
|
370
|
+
if (config || closePromise)
|
|
371
|
+
await close();
|
|
372
|
+
if (typeof window === "undefined" || !input.appId || !input.actions || ["submit", "appendEvidence", "completeCollection", "upload"].some((key) => typeof input.actions[key] !== "function"))
|
|
373
|
+
return failure("INVALID_INPUT");
|
|
374
|
+
const numeric = [input.maxEvents, input.maxBytes, input.maxAgeMs, input.beforeCount, input.afterCount, input.afterTimeoutMs, input.screenshotTimeoutMs, input.maxKeyScreenshots, input.keyScreenshotThrottleMs];
|
|
375
|
+
if (numeric.some((value) => value !== void 0 && (!Number.isSafeInteger(value) || value < 0)) || input.persistDraft && !input.persistenceKey)
|
|
376
|
+
return failure("INVALID_INPUT");
|
|
377
|
+
config = input;
|
|
378
|
+
closing = false;
|
|
379
|
+
source = `browser:${core.id.uuid()}`;
|
|
380
|
+
storageKey = `${input.appId}:${input.persistenceKey ?? ""}`;
|
|
381
|
+
if (input.persistDraft) {
|
|
382
|
+
try {
|
|
383
|
+
const restored = await restoreFeedbackDraft(storageKey);
|
|
384
|
+
if (closing || config !== input)
|
|
385
|
+
return failure("NOT_INITIALIZED");
|
|
386
|
+
if (restored && Array.isArray(restored.evidence) && restored.evidence.every((item) => FeedbackEvidenceInputSchema.safeParse(item).success) && Array.isArray(restored.attachments) && restored.attachments.every((item) => item.bytes instanceof Uint8Array) && (!restored.report || FeedbackReportSchema.safeParse(restored.report).success)) {
|
|
387
|
+
draft = restored;
|
|
388
|
+
missing.add("browser-session-interrupted");
|
|
389
|
+
deadline = Date.now();
|
|
390
|
+
} else {
|
|
391
|
+
const checkpoint = await restoreFeedbackCheckpoint(storageKey);
|
|
392
|
+
if (closing || config !== input)
|
|
393
|
+
return failure("NOT_INITIALIZED");
|
|
394
|
+
if (checkpoint && typeof checkpoint === "object" && "evidence" in checkpoint && Array.isArray(checkpoint.evidence) && "expiresAt" in checkpoint && typeof checkpoint.expiresAt === "number" && checkpoint.expiresAt > Date.now()) {
|
|
395
|
+
const parsed = checkpoint.evidence.map((item) => FeedbackEvidenceInputSchema.safeParse(item));
|
|
396
|
+
if (parsed.every((item) => item.success)) {
|
|
397
|
+
buffer = parsed.filter((item) => item.success).map((item) => item.data);
|
|
398
|
+
bufferBytes = new TextEncoder().encode(JSON.stringify(buffer)).byteLength;
|
|
399
|
+
sequence = Math.max(0, ...buffer.map((item) => item.sequence));
|
|
400
|
+
lastError = buffer.filter((item) => item.kind === "error" || item.kind === "request" && item.data.success === false).at(-1);
|
|
401
|
+
missing.add("browser-session-interrupted");
|
|
402
|
+
missing.add("previous-page-screenshot-unavailable");
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
} catch {
|
|
407
|
+
if (closing || config !== input)
|
|
408
|
+
return failure("NOT_INITIALIZED");
|
|
409
|
+
missing.add("browser-persistence-unavailable");
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
unsubscribeLogger = core.logger.observe((entry) => {
|
|
413
|
+
if (entry.context.module === "feedback" || entry.context.feedback === true)
|
|
414
|
+
return;
|
|
415
|
+
record({ kind: ["error", "fatal"].includes(entry.level) ? "error" : "log", requestId: typeof entry.context.requestId === "string" ? entry.context.requestId : void 0, data: { level: entry.level, message: entry.message, context: entry.context } });
|
|
416
|
+
});
|
|
417
|
+
listen("error", (event) => record({ kind: "error", data: { message: event.message, stack: event.error instanceof Error ? event.error.stack : void 0, filename: feedbackPageUrl(event.filename || location.href), line: event.lineno, column: event.colno } }));
|
|
418
|
+
listen("unhandledrejection", (event) => record({ kind: "error", data: { reason: event.reason } }));
|
|
419
|
+
const click = (event) => {
|
|
420
|
+
if (!(event.target instanceof Element) || event.target.closest("[data-feedback-ignore]"))
|
|
421
|
+
return;
|
|
422
|
+
const target = event.target.closest("[data-feedback-action],button,a");
|
|
423
|
+
if (target && !target.closest("[data-feedback-private]")) {
|
|
424
|
+
record({ kind: "interaction", data: { action: target.getAttribute("data-feedback-action") ?? target.tagName.toLowerCase(), route: location.pathname } });
|
|
425
|
+
if (target.hasAttribute("data-feedback-action"))
|
|
426
|
+
void captureKeyScreenshot();
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
document.addEventListener("click", click, true);
|
|
430
|
+
cleanups.push(() => document.removeEventListener("click", click, true));
|
|
431
|
+
if (input.captureConsole) {
|
|
432
|
+
for (const level of ["debug", "info", "warn", "error"]) {
|
|
433
|
+
const original = globalThis.console[level];
|
|
434
|
+
const wrapper = (...args) => {
|
|
435
|
+
record({ kind: level === "error" ? "error" : "log", data: { channel: "console", level, arguments: args } });
|
|
436
|
+
original.apply(globalThis.console, args);
|
|
437
|
+
};
|
|
438
|
+
globalThis.console[level] = wrapper;
|
|
439
|
+
cleanups.push(() => {
|
|
440
|
+
if (globalThis.console[level] === wrapper)
|
|
441
|
+
globalThis.console[level] = original;
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
listen("pagehide", () => {
|
|
446
|
+
deadline = Date.now();
|
|
447
|
+
if (input.persistDraft && draft)
|
|
448
|
+
void saveFeedbackDraft(storageKey, draft).catch(() => missing.add("browser-persistence-unavailable"));
|
|
449
|
+
else if (input.persistDraft)
|
|
450
|
+
void saveFeedbackCheckpoint(storageKey, buffer).catch(() => missing.add("browser-persistence-unavailable"));
|
|
451
|
+
void flush();
|
|
452
|
+
});
|
|
453
|
+
if (input.replay)
|
|
454
|
+
await startReplay(input);
|
|
455
|
+
if (closing || config !== input)
|
|
456
|
+
return failure("NOT_INITIALIZED");
|
|
457
|
+
flushTimer = setInterval(() => {
|
|
458
|
+
if (draft?.report)
|
|
459
|
+
void flush();
|
|
460
|
+
}, 1e3);
|
|
461
|
+
return ok(void 0);
|
|
462
|
+
} catch (cause) {
|
|
463
|
+
await close();
|
|
464
|
+
return failure("OPERATION_FAILED", cause);
|
|
465
|
+
} finally {
|
|
466
|
+
initializing = false;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
async function startReplay(activeConfig) {
|
|
470
|
+
try {
|
|
471
|
+
const { record: start } = await import('@rrweb/record');
|
|
472
|
+
if (closing || config !== activeConfig)
|
|
473
|
+
return;
|
|
474
|
+
stopReplay = start({
|
|
475
|
+
maskAllInputs: true,
|
|
476
|
+
maskTextSelector: "[data-feedback-private],.rr-mask",
|
|
477
|
+
blockSelector: "[data-feedback-ignore],[data-feedback-private],.rr-block,iframe,canvas,video",
|
|
478
|
+
inlineImages: true,
|
|
479
|
+
inlineStylesheet: true,
|
|
480
|
+
collectFonts: false,
|
|
481
|
+
checkoutEveryNms: 3e4,
|
|
482
|
+
emit: (event, isCheckout) => {
|
|
483
|
+
if (isCheckout || replayChunks.length === 0)
|
|
484
|
+
replayChunks.push([]);
|
|
485
|
+
let payload;
|
|
486
|
+
try {
|
|
487
|
+
payload = sanitizeFeedbackReplay(event);
|
|
488
|
+
} catch {
|
|
489
|
+
missing.add("replay-snapshot-truncated");
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const safe = FeedbackEvidenceInputSchema.safeParse({ id: core.id.uuid(), source, sequence: ++sequence, kind: "replay", timestamp: Date.now(), data: { event: payload } });
|
|
493
|
+
if (!safe.success)
|
|
494
|
+
return;
|
|
495
|
+
replayChunks.at(-1)?.push(safe.data);
|
|
496
|
+
while (replayChunks.length > 2)
|
|
497
|
+
replayChunks.shift();
|
|
498
|
+
const count = replayChunks.reduce((total, chunk) => total + chunk.length, 0);
|
|
499
|
+
const bytes = new TextEncoder().encode(JSON.stringify(replayChunks)).byteLength;
|
|
500
|
+
if (count > limits().events || bytes > limits().bytes) {
|
|
501
|
+
replayChunks = [];
|
|
502
|
+
missing.add("replay-capacity-exceeded");
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}) ?? void 0;
|
|
506
|
+
} catch {
|
|
507
|
+
if (!closing && config === activeConfig)
|
|
508
|
+
missing.add("replay-recorder-unavailable");
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
function captureKeyScreenshot() {
|
|
512
|
+
if (!config || config.screenshot === false || config.keyScreenshots === false || config.maxKeyScreenshots === 0 || keyScreenshotPromise || Date.now() - lastKeyScreenshot < (config.keyScreenshotThrottleMs ?? 1e4))
|
|
513
|
+
return keyScreenshotPromise ?? Promise.resolve();
|
|
514
|
+
const activeConfig = config;
|
|
515
|
+
lastKeyScreenshot = Date.now();
|
|
516
|
+
keyScreenshotPromise = (async () => {
|
|
517
|
+
try {
|
|
518
|
+
const bytes = await bounded(activeConfig.captureScreenshot?.() ?? captureFeedbackScreenshot(), activeConfig.screenshotTimeoutMs ?? 3e3);
|
|
519
|
+
if (closing || config !== activeConfig)
|
|
520
|
+
return;
|
|
521
|
+
if (bytes.byteLength > 5 * 1024 * 1024) {
|
|
522
|
+
missing.add("key-screenshot-size-limit-exceeded");
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const image = await attachment(`operation-${lastKeyScreenshot}.png`, "image/png", bytes);
|
|
526
|
+
if (closing || config !== activeConfig)
|
|
527
|
+
return;
|
|
528
|
+
keyScreenshots.push({ attachment: image, timestamp: lastKeyScreenshot });
|
|
529
|
+
keyScreenshots = keyScreenshots.filter((item) => item.timestamp >= Date.now() - limits().age).slice(-Math.min(activeConfig.maxKeyScreenshots ?? 3, 5));
|
|
530
|
+
} catch {
|
|
531
|
+
if (!closing && config === activeConfig)
|
|
532
|
+
missing.add("key-screenshot-unavailable");
|
|
533
|
+
}
|
|
534
|
+
})().finally(() => {
|
|
535
|
+
keyScreenshotPromise = void 0;
|
|
536
|
+
});
|
|
537
|
+
return keyScreenshotPromise;
|
|
538
|
+
}
|
|
539
|
+
async function doPrepare() {
|
|
540
|
+
if (!config || closing)
|
|
541
|
+
return failure("NOT_INITIALIZED");
|
|
542
|
+
const activeConfig = config;
|
|
543
|
+
if (draft?.report && !collectionFinished) {
|
|
544
|
+
if (Date.now() < deadline && afterCount < limits().after)
|
|
545
|
+
draft.missing.push("collection-ended-by-new-feedback");
|
|
546
|
+
deadline = Date.now();
|
|
547
|
+
const settled = await flush();
|
|
548
|
+
if (!settled.success)
|
|
549
|
+
return settled;
|
|
550
|
+
if (closing || config !== activeConfig)
|
|
551
|
+
return failure("NOT_INITIALIZED");
|
|
552
|
+
}
|
|
553
|
+
if (draft && !collectionFinished)
|
|
554
|
+
return ok(draft);
|
|
555
|
+
collectionFinished = false;
|
|
556
|
+
collectionSealed = false;
|
|
557
|
+
initialIds.clear();
|
|
558
|
+
uploadedIds.clear();
|
|
559
|
+
const now = Date.now();
|
|
560
|
+
buffer = buffer.filter((item) => item.timestamp >= now - limits().age);
|
|
561
|
+
bufferBytes = new TextEncoder().encode(JSON.stringify(buffer)).byteLength;
|
|
562
|
+
errorWindows = errorWindows.filter((window2) => window2.anchor.timestamp >= now - limits().age);
|
|
563
|
+
const frozenWindow = errorWindows.at(-1);
|
|
564
|
+
const anchor = frozenWindow?.anchor ?? (lastError && lastError.timestamp >= now - limits().age && buffer.some((item) => item.id === lastError?.id) ? lastError : void 0);
|
|
565
|
+
const logs = buffer.filter((item) => item.kind === "log" || item.kind === "error");
|
|
566
|
+
const anchorIndex = anchor ? logs.findIndex((item) => item.id === anchor.id) : logs.length;
|
|
567
|
+
const anchorIndexes = anchor ? logs.map((item, index) => item.kind === "error" ? index : -1).filter((index) => index >= 0).slice(-10) : [];
|
|
568
|
+
const selectedIds = /* @__PURE__ */ new Set();
|
|
569
|
+
const selected = anchor ? (errorWindows.length ? errorWindows.flatMap((window2) => window2.evidence) : anchorIndexes.flatMap((index) => logs.slice(Math.max(0, index - limits().before), index + 1 + limits().after))).filter((item) => {
|
|
570
|
+
if (selectedIds.has(item.id))
|
|
571
|
+
return false;
|
|
572
|
+
selectedIds.add(item.id);
|
|
573
|
+
return true;
|
|
574
|
+
}).sort((a, b) => a.sequence - b.sequence) : logs.slice(Math.max(0, logs.length - limits().before));
|
|
575
|
+
const other = buffer.filter((item) => item.kind !== "log" && item.kind !== "error").slice(-100);
|
|
576
|
+
if (keyScreenshotPromise)
|
|
577
|
+
await keyScreenshotPromise;
|
|
578
|
+
if (closing || config !== activeConfig)
|
|
579
|
+
return failure("NOT_INITIALIZED");
|
|
580
|
+
keyScreenshots = keyScreenshots.filter((item) => item.timestamp >= now - limits().age);
|
|
581
|
+
draft = { submissionId: core.id.uuid(), anchorTime: anchor?.timestamp ?? now, anchorKind: anchor ? "error" : "feedback", errorId: anchor?.errorId, evidence: [...selected, ...other, ...replayChunks.flat()].sort((a, b) => a.sequence - b.sequence), attachments: keyScreenshots.map((item) => item.attachment), missing: [...missing], expiresAt: now + 864e5 };
|
|
582
|
+
while (draft.evidence.length > limits().events || new TextEncoder().encode(JSON.stringify(draft.evidence)).byteLength > limits().bytes) {
|
|
583
|
+
draft.evidence.shift();
|
|
584
|
+
missing.add("browser-draft-truncated");
|
|
585
|
+
}
|
|
586
|
+
draftBytes = new TextEncoder().encode(JSON.stringify(draft.evidence)).byteLength;
|
|
587
|
+
if (dropped)
|
|
588
|
+
draft.missing.push(`browser-buffer-truncated:${dropped}`);
|
|
589
|
+
afterCount = frozenWindow?.after ?? (anchor ? Math.max(0, logs.length - anchorIndex - 1) : 0);
|
|
590
|
+
deadline = (anchor?.timestamp ?? now) + (config.afterTimeoutMs ?? 3e4);
|
|
591
|
+
record({ kind: "environment", data: { appId: config.appId, appVersion: config.appVersion, buildId: config.buildId, commit: config.commit, url: feedbackPageUrl(location.href), browser: navigator.userAgent, language: navigator.language, timezone: new Intl.DateTimeFormat().resolvedOptions().timeZone, viewport: { width: innerWidth, height: innerHeight, pixelRatio: devicePixelRatio }, online: navigator.onLine, cookies: collectFeedbackCookies(document.cookie, config.cookieAllowlist ?? []) } });
|
|
592
|
+
if (config.screenshot !== false) {
|
|
593
|
+
const current = draft;
|
|
594
|
+
try {
|
|
595
|
+
const bytes = await bounded(config.captureScreenshot?.() ?? captureFeedbackScreenshot(), config.screenshotTimeoutMs ?? 3e3);
|
|
596
|
+
if (closing || config !== activeConfig || draft !== current)
|
|
597
|
+
return failure("NOT_INITIALIZED");
|
|
598
|
+
if (bytes.byteLength > 5 * 1024 * 1024) {
|
|
599
|
+
current.missing.push("screenshot-size-limit-exceeded");
|
|
600
|
+
} else {
|
|
601
|
+
const image = await attachment("screenshot.png", "image/png", bytes);
|
|
602
|
+
if (closing || config !== activeConfig || draft !== current)
|
|
603
|
+
return failure("NOT_INITIALIZED");
|
|
604
|
+
current.attachments.push(image);
|
|
605
|
+
}
|
|
606
|
+
} catch (cause) {
|
|
607
|
+
if (closing || config !== activeConfig || draft !== current)
|
|
608
|
+
return failure("NOT_INITIALIZED");
|
|
609
|
+
current.missing.push("screenshot-unavailable");
|
|
610
|
+
if (cause instanceof Error)
|
|
611
|
+
current.missing.push(`screenshot-error:${String(sanitizeFeedbackData({ message: cause.message }).message).slice(0, 300)}`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
schedulePersist();
|
|
615
|
+
return ok(draft);
|
|
616
|
+
}
|
|
617
|
+
function prepare() {
|
|
618
|
+
preparePromise ??= doPrepare().finally(() => {
|
|
619
|
+
preparePromise = void 0;
|
|
620
|
+
});
|
|
621
|
+
return preparePromise;
|
|
622
|
+
}
|
|
623
|
+
async function submit(input = {}) {
|
|
624
|
+
if (!config || closing)
|
|
625
|
+
return failure("NOT_INITIALIZED");
|
|
626
|
+
const activeConfig = config;
|
|
627
|
+
const prepared = preparePromise ? await preparePromise : draft ? ok(draft) : await prepare();
|
|
628
|
+
if (!prepared.success)
|
|
629
|
+
return prepared;
|
|
630
|
+
const current = prepared.data;
|
|
631
|
+
try {
|
|
632
|
+
const fingerprint = await digestHex(new TextEncoder().encode(JSON.stringify({ description: sanitizeFeedbackData({ text: input.description ?? "" }).text, category: input.category ?? "", contact: sanitizeFeedbackData({ text: input.contact ?? "" }).text, files: (input.files ?? []).map((item) => ({ name: item.name, size: item.size, lastModified: item.lastModified })), removed: [...input.removedAttachmentIds ?? []].sort() })));
|
|
633
|
+
if (closing || config !== activeConfig || draft !== current)
|
|
634
|
+
return failure("NOT_INITIALIZED");
|
|
635
|
+
if (current.submission && current.userInputFingerprint && fingerprint !== current.userInputFingerprint)
|
|
636
|
+
return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_retryInputChanged"));
|
|
637
|
+
if (!current.submission && input.removedAttachmentIds?.length)
|
|
638
|
+
current.attachments = current.attachments.filter((item) => !input.removedAttachmentIds?.includes(item.id));
|
|
639
|
+
if (!current.submission) {
|
|
640
|
+
const added = await addFiles(input.files ?? []);
|
|
641
|
+
if (!added.success)
|
|
642
|
+
return added;
|
|
643
|
+
}
|
|
644
|
+
if (closing || config !== activeConfig || draft !== current)
|
|
645
|
+
return failure("NOT_INITIALIZED");
|
|
646
|
+
current.submission ??= FeedbackSubmitInputSchema.parse({ submissionId: current.submissionId, appId: config.appId, appVersion: config.appVersion, buildId: config.buildId, commit: config.commit, description: typeof input.description === "string" ? String(sanitizeFeedbackData({ text: input.description }).text) : void 0, category: input.category, contact: input.contact, url: feedbackPageUrl(location.href), anchor: { kind: current.anchorKind, timestamp: current.anchorTime, errorId: current.errorId }, evidence: current.evidence, attachments: current.attachments.map(({ bytes: _bytes, ...metadata }) => metadata) });
|
|
647
|
+
current.userInputFingerprint = fingerprint;
|
|
648
|
+
schedulePersist();
|
|
649
|
+
const result = await activeConfig.actions.submit(current.submission);
|
|
650
|
+
if (!result.success)
|
|
651
|
+
return result;
|
|
652
|
+
if (closing || config !== activeConfig || draft !== current)
|
|
653
|
+
return result;
|
|
654
|
+
current.report = result.data;
|
|
655
|
+
initialIds = new Set(current.submission.evidence?.map((item) => item.id));
|
|
656
|
+
schedulePersist();
|
|
657
|
+
void flush();
|
|
658
|
+
return result;
|
|
659
|
+
} catch (cause) {
|
|
660
|
+
return failure("OPERATION_FAILED", cause);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
async function doFlush() {
|
|
664
|
+
if (!config || !draft?.report)
|
|
665
|
+
return failure("NOT_INITIALIZED");
|
|
666
|
+
if (collectionFinished)
|
|
667
|
+
return ok(void 0);
|
|
668
|
+
try {
|
|
669
|
+
for (const item of draft.attachments) {
|
|
670
|
+
if (uploadedIds.has(item.id))
|
|
671
|
+
continue;
|
|
672
|
+
const result = await config.actions.upload({ id: draft.report.id, attachmentId: item.id, dataBase64: base64(item.bytes) });
|
|
673
|
+
if (!result.success)
|
|
674
|
+
return result;
|
|
675
|
+
uploadedIds.add(item.id);
|
|
676
|
+
}
|
|
677
|
+
let drainPasses = 0;
|
|
678
|
+
while (true) {
|
|
679
|
+
if (closing || Date.now() >= deadline || afterCount >= limits().after)
|
|
680
|
+
collectionSealed = true;
|
|
681
|
+
const pending = draft.evidence.filter((item) => !initialIds.has(item.id)).slice(0, 200);
|
|
682
|
+
if (pending.length === 0)
|
|
683
|
+
break;
|
|
684
|
+
const result = await config.actions.appendEvidence({ id: draft.report.id, evidence: pending });
|
|
685
|
+
if (!result.success)
|
|
686
|
+
return result;
|
|
687
|
+
for (const id of result.data.acknowledged)
|
|
688
|
+
initialIds.add(id);
|
|
689
|
+
if (pending.some((item) => !initialIds.has(item.id)))
|
|
690
|
+
return failure();
|
|
691
|
+
drainPasses++;
|
|
692
|
+
if (drainPasses >= 20)
|
|
693
|
+
return ok(void 0);
|
|
694
|
+
}
|
|
695
|
+
if (collectionSealed) {
|
|
696
|
+
const result = await config.actions.completeCollection({ id: draft.report.id, missing: [.../* @__PURE__ */ new Set([...draft.missing, ...missing])] });
|
|
697
|
+
if (!result.success)
|
|
698
|
+
return result;
|
|
699
|
+
collectionFinished = true;
|
|
700
|
+
const updatedReport = FeedbackReportSchema.safeParse(result.data);
|
|
701
|
+
if (updatedReport.success)
|
|
702
|
+
draft.report = updatedReport.data;
|
|
703
|
+
if (config.persistDraft)
|
|
704
|
+
await deleteFeedbackDraft(storageKey);
|
|
705
|
+
}
|
|
706
|
+
schedulePersist();
|
|
707
|
+
return ok(void 0);
|
|
708
|
+
} catch (cause) {
|
|
709
|
+
return failure("OPERATION_FAILED", cause);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
function flush() {
|
|
713
|
+
flushPromise ??= doFlush().finally(() => {
|
|
714
|
+
flushPromise = void 0;
|
|
715
|
+
});
|
|
716
|
+
return flushPromise;
|
|
717
|
+
}
|
|
718
|
+
async function annotate(id, mark) {
|
|
719
|
+
if (!draft || closing)
|
|
720
|
+
return failure("NOT_INITIALIZED");
|
|
721
|
+
const current = draft;
|
|
722
|
+
const item = current.attachments.find((value) => value.id === id);
|
|
723
|
+
if (!item || current.submission)
|
|
724
|
+
return failure("INVALID_INPUT");
|
|
725
|
+
const originalBytes = item.bytes;
|
|
726
|
+
try {
|
|
727
|
+
const bytes = await annotateFeedbackImage(originalBytes, mark);
|
|
728
|
+
const name = /\.[^.]+$/.test(item.name) ? item.name.replace(/\.[^.]+$/, ".png") : `${item.name}.png`;
|
|
729
|
+
const updated = await attachment(name, "image/png", bytes);
|
|
730
|
+
if (closing || draft !== current || current.submission || !current.attachments.includes(item) || item.bytes !== originalBytes)
|
|
731
|
+
return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_conflict"));
|
|
732
|
+
Object.assign(item, updated, { id });
|
|
733
|
+
schedulePersist();
|
|
734
|
+
return ok(void 0);
|
|
735
|
+
} catch (cause) {
|
|
736
|
+
return failure("OPERATION_FAILED", cause);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
async function addFiles(files) {
|
|
740
|
+
if (!draft)
|
|
741
|
+
return failure("NOT_INITIALIZED");
|
|
742
|
+
if (draft.submission && files.length)
|
|
743
|
+
return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_retryInputChanged"));
|
|
744
|
+
const current = draft;
|
|
745
|
+
const items = [];
|
|
746
|
+
const newMissing = [];
|
|
747
|
+
try {
|
|
748
|
+
for (const file of files) {
|
|
749
|
+
if (items.length >= 20 || file.size > 5 * 1024 * 1024)
|
|
750
|
+
return failure("LIMIT_EXCEEDED");
|
|
751
|
+
if (!["image/png", "image/jpeg", "image/webp", "text/plain", "application/json"].includes(file.type))
|
|
752
|
+
return failure("INVALID_INPUT");
|
|
753
|
+
let bytes;
|
|
754
|
+
if (file.type.startsWith("text/") || file.type === "application/json") {
|
|
755
|
+
const raw = await file.text();
|
|
756
|
+
if (file.type === "application/json" && raw.length > 8192)
|
|
757
|
+
newMissing.push(`attachment-text-truncated:${String(sanitizeFeedbackData({ text: file.name }).text).slice(0, 100)}`);
|
|
758
|
+
bytes = file.type === "application/json" ? new TextEncoder().encode(JSON.stringify(sanitizeFeedbackData(JSON.parse(raw)))) : new TextEncoder().encode(sanitizeFeedbackText(raw));
|
|
759
|
+
} else {
|
|
760
|
+
bytes = new Uint8Array(await file.arrayBuffer());
|
|
761
|
+
}
|
|
762
|
+
if (bytes.byteLength > 5 * 1024 * 1024)
|
|
763
|
+
return failure("LIMIT_EXCEEDED");
|
|
764
|
+
const item = await attachment(file.name, file.type, bytes);
|
|
765
|
+
if (!items.some((existing) => existing.name === item.name && existing.checksum === item.checksum))
|
|
766
|
+
items.push(item);
|
|
767
|
+
}
|
|
768
|
+
if (draft !== current || closing)
|
|
769
|
+
return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_conflict"));
|
|
770
|
+
if (current.submission && files.length)
|
|
771
|
+
return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_retryInputChanged"));
|
|
772
|
+
const merged = [...current.attachments];
|
|
773
|
+
for (const item of items) {
|
|
774
|
+
if (!merged.some((existing) => existing.name === item.name && existing.checksum === item.checksum))
|
|
775
|
+
merged.push(item);
|
|
776
|
+
}
|
|
777
|
+
if (merged.length > 20)
|
|
778
|
+
return failure("LIMIT_EXCEEDED");
|
|
779
|
+
current.attachments = merged;
|
|
780
|
+
current.missing.push(...newMissing);
|
|
781
|
+
schedulePersist();
|
|
782
|
+
return ok(void 0);
|
|
783
|
+
} catch (cause) {
|
|
784
|
+
return failure("INVALID_INPUT", cause);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
async function exportDraft(input = {}) {
|
|
788
|
+
if (!draft)
|
|
789
|
+
return failure("NOT_INITIALIZED");
|
|
790
|
+
try {
|
|
791
|
+
const { createFeedbackBundle } = await import('../bundle/index.js');
|
|
792
|
+
const attachments = [...draft.attachments];
|
|
793
|
+
if (!draft.submission) {
|
|
794
|
+
const added = await addFiles(input.files ?? []);
|
|
795
|
+
if (!added.success)
|
|
796
|
+
return added;
|
|
797
|
+
attachments.splice(0, attachments.length, ...draft.attachments);
|
|
798
|
+
} else {
|
|
799
|
+
for (const file of input.files ?? []) {
|
|
800
|
+
if (attachments.length >= 20 || file.size > 5 * 1024 * 1024)
|
|
801
|
+
return failure("LIMIT_EXCEEDED");
|
|
802
|
+
if (!["image/png", "image/jpeg", "image/webp", "text/plain", "application/json"].includes(file.type))
|
|
803
|
+
return failure("INVALID_INPUT");
|
|
804
|
+
const bytes = file.type === "application/json" ? new TextEncoder().encode(JSON.stringify(sanitizeFeedbackData(JSON.parse(await file.text())))) : file.type.startsWith("text/") ? new TextEncoder().encode(sanitizeFeedbackText(await file.text())) : new Uint8Array(await file.arrayBuffer());
|
|
805
|
+
if (bytes.byteLength > 5 * 1024 * 1024)
|
|
806
|
+
return failure("LIMIT_EXCEEDED");
|
|
807
|
+
const item = await attachment(file.name, file.type, bytes);
|
|
808
|
+
if (!attachments.some((existing) => existing.name === item.name && existing.checksum === item.checksum))
|
|
809
|
+
attachments.push(item);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return createFeedbackBundle({ report: { ...draft.report ?? { id: draft.submissionId, appId: config?.appId, anchor: { kind: draft.anchorKind, timestamp: draft.anchorTime }, evidenceRevision: 1 }, ...sanitizeFeedbackData({ description: input.description ?? draft.report?.description ?? "", category: input.category, contact: input.contact }) }, evidence: draft.evidence, attachments: attachments.filter((item) => !input.removedAttachmentIds?.includes(item.id)), missing: [.../* @__PURE__ */ new Set([...draft.missing, ...missing, ...!draft.report ? ["server-evidence-not-obtained", "feedback-not-yet-accepted"] : []])] });
|
|
813
|
+
} catch (cause) {
|
|
814
|
+
return failure("OPERATION_FAILED", cause);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
function close() {
|
|
818
|
+
closePromise ??= doClose().finally(() => {
|
|
819
|
+
closePromise = void 0;
|
|
820
|
+
});
|
|
821
|
+
return closePromise;
|
|
822
|
+
}
|
|
823
|
+
async function doClose() {
|
|
824
|
+
closing = true;
|
|
825
|
+
unsubscribeLogger?.();
|
|
826
|
+
unsubscribeLogger = void 0;
|
|
827
|
+
stopReplay?.();
|
|
828
|
+
stopReplay = void 0;
|
|
829
|
+
for (const cleanup of cleanups.splice(0))
|
|
830
|
+
cleanup();
|
|
831
|
+
if (flushTimer)
|
|
832
|
+
clearInterval(flushTimer);
|
|
833
|
+
if (persistTimer)
|
|
834
|
+
clearTimeout(persistTimer);
|
|
835
|
+
flushTimer = void 0;
|
|
836
|
+
persistTimer = void 0;
|
|
837
|
+
await preparePromise;
|
|
838
|
+
await keyScreenshotPromise;
|
|
839
|
+
if (draft?.report)
|
|
840
|
+
await flush();
|
|
841
|
+
if (config?.persistDraft && draft && !collectionFinished)
|
|
842
|
+
await saveFeedbackDraft(storageKey, draft).catch(() => void 0);
|
|
843
|
+
config = void 0;
|
|
844
|
+
buffer = [];
|
|
845
|
+
bufferBytes = 0;
|
|
846
|
+
draft = void 0;
|
|
847
|
+
lastError = void 0;
|
|
848
|
+
errorWindows = [];
|
|
849
|
+
replayChunks = [];
|
|
850
|
+
keyScreenshots = [];
|
|
851
|
+
lastKeyScreenshot = 0;
|
|
852
|
+
initialIds.clear();
|
|
853
|
+
uploadedIds.clear();
|
|
854
|
+
missing.clear();
|
|
855
|
+
dropped = 0;
|
|
856
|
+
collectionFinished = false;
|
|
857
|
+
collectionSealed = false;
|
|
858
|
+
}
|
|
859
|
+
return { init, prepare, submit, flush, record, getDraft: () => draft, annotate, addFiles, exportDraft, close };
|
|
860
|
+
}
|
|
861
|
+
var feedbackClient = createFeedbackClient();
|
|
862
|
+
async function bounded(promise, ms) {
|
|
863
|
+
let timer;
|
|
864
|
+
try {
|
|
865
|
+
return await Promise.race([promise, new Promise((_resolve, reject) => {
|
|
866
|
+
timer = setTimeout(() => reject(new Error("Capture timed out")), ms);
|
|
867
|
+
})]);
|
|
868
|
+
} finally {
|
|
869
|
+
clearTimeout(timer);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
async function attachment(name, contentType, bytes) {
|
|
873
|
+
const checksum = await digestHex(bytes);
|
|
874
|
+
return { id: core.id.uuid(), name: String(sanitizeFeedbackData({ text: name }).text).slice(0, 255), contentType, size: bytes.byteLength, checksum, bytes };
|
|
875
|
+
}
|
|
876
|
+
async function digestHex(bytes) {
|
|
877
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", new Uint8Array(bytes));
|
|
878
|
+
return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
|
|
879
|
+
}
|
|
880
|
+
function base64(bytes) {
|
|
881
|
+
let value = "";
|
|
882
|
+
for (let offset = 0; offset < bytes.length; offset += 32768)
|
|
883
|
+
value += String.fromCharCode(...bytes.subarray(offset, offset + 32768));
|
|
884
|
+
return btoa(value);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
export { createFeedbackClient, feedbackClient };
|
|
888
|
+
//# sourceMappingURL=index.js.map
|
|
889
|
+
//# sourceMappingURL=index.js.map
|