@h-ai/feedback 0.1.0-alpha.70 → 0.1.0-alpha.72

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.
@@ -1,9 +1,50 @@
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';
1
+ import { feedbackM, HaiFeedbackError, redactFeedbackText } from '../chunk-PYKGS35A.js';
2
+ import { ok, core, err } from '@h-ai/core';
3
+ import { resolveFeedbackBudget, FeedbackSubmitInputSchema, FeedbackBudgetSchema, FeedbackEvidenceInputSchema, FeedbackReportSchema } from '@h-ai/api-contract/feedback';
4
+
5
+ var nativeSaver;
6
+ function configureFeedbackSaving(saver) {
7
+ nativeSaver = saver;
8
+ }
9
+ function feedbackDataBytes(data) {
10
+ return Uint8Array.from(atob(data.dataBase64), (value) => value.charCodeAt(0));
11
+ }
12
+ async function saveFeedbackFile(bytes, filename, contentType = "application/zip", signal) {
13
+ const name = [...filename].map((character) => character.charCodeAt(0) < 32 || /[\\/:*?"<>|]/.test(character) ? "_" : character).join("").slice(0, 200) || "feedback.zip";
14
+ try {
15
+ if (signal?.aborted)
16
+ return ok({ status: "cancelled" });
17
+ if (typeof window === "undefined")
18
+ throw new Error("Browser required");
19
+ const capacitor = Reflect.get(window, "Capacitor");
20
+ const isNative = Reflect.get(window, "__TAURI_INTERNALS__") || capacitor && typeof capacitor === "object" && "isNativePlatform" in capacitor && typeof capacitor.isNativePlatform === "function" && capacitor.isNativePlatform();
21
+ if (isNative) {
22
+ if (!nativeSaver)
23
+ throw new Error("Feedback platform saving adapter is not configured");
24
+ return ok(await nativeSaver(bytes, name, contentType, signal));
25
+ }
26
+ const url = URL.createObjectURL(new Blob([new Uint8Array(bytes).buffer], { type: contentType }));
27
+ const link = document.createElement("a");
28
+ try {
29
+ link.href = url;
30
+ link.download = name;
31
+ document.body.append(link);
32
+ link.click();
33
+ } finally {
34
+ link.remove();
35
+ setTimeout(() => URL.revokeObjectURL(url), 6e4);
36
+ }
37
+ return ok({ status: "saved" });
38
+ } catch (cause) {
39
+ const error = core.error.toPublic({ ...HaiFeedbackError.OPERATION_FAILED, message: feedbackM("feedback_operationFailed") });
40
+ core.logger.error("Feedback file save failed", { traceId: error.traceId, error: cause });
41
+ return err(error);
42
+ }
43
+ }
4
44
 
5
45
  // src/client/feedback-client-image.ts
6
- var PRIVATE_SELECTOR = 'input,textarea,select,[contenteditable]:not([contenteditable="false"]),[data-feedback-private],.rr-block,.rr-mask';
46
+ var PRIVATE_SELECTOR = 'input[type="password"],[data-feedback-private],.rr-block,.rr-mask';
47
+ var CONTROL_SELECTOR = 'input,textarea,select,[contenteditable]:not([contenteditable="false"])';
7
48
  async function captureFeedbackScreenshot() {
8
49
  if (document.fonts) {
9
50
  let timeout;
@@ -15,13 +56,15 @@ async function captureFeedbackScreenshot() {
15
56
  }
16
57
  const { default: html2canvas } = await import('html2canvas');
17
58
  const canvas = await html2canvas(document.body, {
18
- width: Math.min(window.innerWidth, 1920),
19
- height: Math.min(window.innerHeight, 1200),
20
- scale: 1,
59
+ width: window.innerWidth,
60
+ height: window.innerHeight,
61
+ scale: Math.min(1, 1920 / window.innerWidth, 1200 / window.innerHeight),
21
62
  // 原生 SVG foreignObject 保留 CSS mask 图标与字体基线,避免 CanvasRenderer 裁切文本。
22
63
  foreignObjectRendering: true,
23
- scrollX: -window.scrollX,
24
- scrollY: -window.scrollY,
64
+ x: window.scrollX,
65
+ y: window.scrollY,
66
+ scrollX: window.scrollX,
67
+ scrollY: window.scrollY,
25
68
  useCORS: false,
26
69
  allowTaint: false,
27
70
  logging: false,
@@ -53,10 +96,28 @@ async function captureFeedbackScreenshot() {
53
96
  }
54
97
  }
55
98
  }
56
- for (const element of clone.querySelectorAll(PRIVATE_SELECTOR)) {
99
+ for (const element of clone.querySelectorAll(`${PRIVATE_SELECTOR},${CONTROL_SELECTOR}`)) {
100
+ if (!clone.documentElement.contains(element))
101
+ continue;
57
102
  const bounds = element.getBoundingClientRect();
58
103
  const replacement = clone.createElement("div");
59
- replacement.style.cssText = `width:${bounds.width}px;height:${bounds.height}px;background:#111;color:transparent;overflow:hidden`;
104
+ const computed = clone.defaultView?.getComputedStyle(element);
105
+ if (computed) {
106
+ for (const property of Array.from(computed))
107
+ replacement.style.setProperty(property, computed.getPropertyValue(property), "important");
108
+ }
109
+ replacement.style.setProperty("width", `${bounds.width}px`, "important");
110
+ replacement.style.setProperty("height", `${bounds.height}px`, "important");
111
+ replacement.style.setProperty("box-sizing", "border-box", "important");
112
+ replacement.style.setProperty("overflow", "hidden", "important");
113
+ if (element.matches(PRIVATE_SELECTOR)) {
114
+ replacement.style.setProperty("background", "#111", "important");
115
+ replacement.style.setProperty("color", "transparent", "important");
116
+ } else {
117
+ const control = element;
118
+ const value = "value" in control ? control.value : element.textContent;
119
+ replacement.textContent = value ? "\u2022\u2022\u2022\u2022" : element.getAttribute("placeholder") ?? "";
120
+ }
60
121
  replacement.setAttribute("aria-hidden", "true");
61
122
  element.replaceWith(replacement);
62
123
  }
@@ -78,6 +139,11 @@ async function annotateFeedbackImage(bytes, annotation) {
78
139
  try {
79
140
  image.src = imageUrl;
80
141
  await image.decode();
142
+ if (annotation.coordinateSpace === "ratio") {
143
+ if ([annotation.x, annotation.y, annotation.width, annotation.height].some((value) => value < 0 || value > 1))
144
+ throw new Error("Invalid annotation ratio");
145
+ annotation = { ...annotation, x: annotation.x * image.naturalWidth, y: annotation.y * image.naturalHeight, width: annotation.width * image.naturalWidth, height: annotation.height * image.naturalHeight };
146
+ }
81
147
  canvas.width = image.naturalWidth;
82
148
  canvas.height = image.naturalHeight;
83
149
  const context = canvas.getContext("2d");
@@ -86,7 +152,9 @@ async function annotateFeedbackImage(bytes, annotation) {
86
152
  context.drawImage(image, 0, 0);
87
153
  if (annotation.mode === "mask") {
88
154
  context.fillStyle = "#111";
89
- context.fillRect(annotation.x, annotation.y, annotation.width, annotation.height);
155
+ const left = Math.floor(annotation.x);
156
+ const top = Math.floor(annotation.y);
157
+ context.fillRect(left, top, Math.ceil(annotation.x + annotation.width) - left, Math.ceil(annotation.y + annotation.height) - top);
90
158
  } else {
91
159
  context.strokeStyle = "#e11d48";
92
160
  context.lineWidth = 3;
@@ -153,14 +221,14 @@ function sanitizeFeedbackReplay(value) {
153
221
  if (key === "attributes" && item && typeof item === "object") {
154
222
  const attributes = {};
155
223
  for (const [attribute, content] of Object.entries(item)) {
156
- if (/^on|^(?:srcdoc|action|formaction|href|srcset)$/i.test(attribute) || CREDENTIAL_NAME.test(attribute))
224
+ if (/^on|^(?:srcdoc|action|formaction|href|srcset|rr_src)$/i.test(attribute) || CREDENTIAL_NAME.test(attribute))
157
225
  continue;
158
226
  if (attribute === "src" && typeof content === "string" && !/^data:image\/(?:png|jpeg|webp|gif);base64,/i.test(content)) {
159
227
  attributes["data-feedback-missing-resource"] = true;
160
228
  continue;
161
229
  }
162
- if (attribute === "style" && typeof content === "string" && /url\s*\(/i.test(content)) {
163
- attributes.style = sanitizeFeedbackText(content).replace(/url\s*\([^)]*\)/gi, "");
230
+ if ((attribute === "style" || attribute === "_cssText") && typeof content === "string") {
231
+ attributes[attribute] = sanitizeFeedbackText(content).replace(/(?:url|@import)\s*\([^)]*\)|@import\s+["'][^"']+["']/gi, "");
164
232
  attributes["data-feedback-missing-resource"] = true;
165
233
  } else {
166
234
  attributes[attribute] = visit(content, depth + 1);
@@ -246,10 +314,65 @@ async function restoreFeedbackCheckpoint(key) {
246
314
  }
247
315
 
248
316
  // src/client/feedback-client-main.ts
317
+ function feedbackFileContentType(file) {
318
+ const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? "";
319
+ const extensions = /* @__PURE__ */ new Map([
320
+ ["png", "image/png"],
321
+ ["jpg", "image/jpeg"],
322
+ ["jpeg", "image/jpeg"],
323
+ ["webp", "image/webp"],
324
+ ["webm", "video/webm"],
325
+ ["mp4", "video/mp4"],
326
+ ["txt", "text/plain"],
327
+ ["log", "text/plain"],
328
+ ["json", "application/json"],
329
+ ["pdf", "application/pdf"]
330
+ ]);
331
+ if (!type || type === "application/octet-stream")
332
+ return extensions.get(file.name.split(".").at(-1)?.toLowerCase() ?? "");
333
+ const aliases = /* @__PURE__ */ new Map([
334
+ ["image/x-png", "image/png"],
335
+ ["image/jpg", "image/jpeg"],
336
+ ["image/pjpeg", "image/jpeg"],
337
+ ["text/json", "application/json"],
338
+ ["text/x-log", "text/plain"],
339
+ ["application/x-pdf", "application/pdf"]
340
+ ]);
341
+ const normalized = aliases.get(type) ?? type;
342
+ return [...extensions.values()].includes(normalized) ? normalized : void 0;
343
+ }
249
344
  function createFeedbackClient() {
250
345
  let config;
251
- let initializing = false;
252
346
  let draft;
347
+ let sessionVersion = 0;
348
+ let policyVerified = false;
349
+ let preparedPage = "";
350
+ let budget = resolveFeedbackBudget();
351
+ const previews = /* @__PURE__ */ new Map();
352
+ let discardOnClose = false;
353
+ let saveController = new AbortController();
354
+ function releasePreviews() {
355
+ for (const preview of previews.values()) URL.revokeObjectURL(preview.url);
356
+ previews.clear();
357
+ }
358
+ function getView() {
359
+ const files = draft?.attachments ?? [];
360
+ for (const [id, preview] of previews) {
361
+ if (!files.some((file) => file.id === id && file.checksum === preview.checksum)) {
362
+ URL.revokeObjectURL(preview.url);
363
+ previews.delete(id);
364
+ }
365
+ }
366
+ return { attachments: files.map((file) => {
367
+ let preview = previews.get(file.id);
368
+ if (!preview) {
369
+ preview = { checksum: file.checksum, url: URL.createObjectURL(new Blob([new Uint8Array(file.bytes).buffer], { type: file.contentType })) };
370
+ previews.set(file.id, preview);
371
+ }
372
+ return { id: file.id, name: file.name, size: file.size, mimeType: file.contentType, url: preview.url };
373
+ }), evidence: draft?.evidence ?? [], missing: draft?.missing ?? [], budget: { ...budget } };
374
+ }
375
+ let initializing = false;
253
376
  let sequence = 0;
254
377
  let buffer = [];
255
378
  let bufferBytes = 0;
@@ -265,6 +388,7 @@ function createFeedbackClient() {
265
388
  let persistTimer;
266
389
  let flushTimer;
267
390
  let stopReplay;
391
+ let screenshotCaptures = 0;
268
392
  let unsubscribeLogger;
269
393
  let source = "";
270
394
  let keyScreenshots = [];
@@ -279,12 +403,15 @@ function createFeedbackClient() {
279
403
  const cleanups = [];
280
404
  const missing = /* @__PURE__ */ new Set();
281
405
  let replayChunks = [];
282
- function failure(code = "OPERATION_FAILED", cause) {
406
+ function failure(code = "OPERATION_FAILED", cause, message) {
283
407
  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);
408
+ const traceId = core.context.get().traceId ?? core.context.get().requestId ?? core.id.uuid();
409
+ const error = { ...HaiFeedbackError[code], traceId, message: message ?? feedbackM(key), cause };
410
+ core.logger[cause === void 0 ? "warn" : "error"]("Feedback client operation failed", { module: "feedback", traceId, error });
411
+ return err({ ...error, message: core.error.formatMessage(error) });
285
412
  }
286
413
  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 };
414
+ return { events: Math.min(config?.maxEvents ?? 1e3, budget.maxEvidenceCount), bytes: Math.min(config?.maxBytes ?? 2 * 1024 * 1024, budget.maxEvidenceBytes), age: config?.maxAgeMs ?? 3e5, before: config?.beforeCount ?? 100, after: config?.afterCount ?? 100 };
288
415
  }
289
416
  function schedulePersist() {
290
417
  if (!config?.persistDraft || persistTimer)
@@ -350,6 +477,10 @@ function createFeedbackClient() {
350
477
  missing.add("browser-error-windows-truncated");
351
478
  }
352
479
  schedulePersist();
480
+ appendToDraft(item, size);
481
+ }
482
+ function appendToDraft(item, size) {
483
+ const bounds = limits();
353
484
  if (draft && !collectionFinished && !collectionSealed && (Date.now() <= deadline && afterCount < bounds.after || item.kind === "environment") && draft.evidence.length < bounds.events && draftBytes + size <= bounds.bytes) {
354
485
  draft.evidence.push(item);
355
486
  draftBytes += size;
@@ -374,7 +505,18 @@ function createFeedbackClient() {
374
505
  const numeric = [input.maxEvents, input.maxBytes, input.maxAgeMs, input.beforeCount, input.afterCount, input.afterTimeoutMs, input.screenshotTimeoutMs, input.maxKeyScreenshots, input.keyScreenshotThrottleMs];
375
506
  if (numeric.some((value) => value !== void 0 && (!Number.isSafeInteger(value) || value < 0)) || input.persistDraft && !input.persistenceKey)
376
507
  return failure("INVALID_INPUT");
508
+ if (!FeedbackSubmitInputSchema.pick({ appId: true, appVersion: true, buildId: true, commit: true }).safeParse({ appId: input.appId, appVersion: input.appVersion, buildId: input.buildId, commit: input.commit }).success)
509
+ return failure("INVALID_INPUT");
510
+ const initVersion = sessionVersion;
511
+ const policy = input.actions.policy ? await input.actions.policy().catch(() => failure("OPERATION_FAILED")) : ok(input.budget ?? resolveFeedbackBudget());
512
+ if (initVersion !== sessionVersion)
513
+ return failure("NOT_INITIALIZED");
514
+ policyVerified = policy.success;
515
+ budget = FeedbackBudgetSchema.parse(policy.success ? policy.data : input.budget ?? resolveFeedbackBudget());
516
+ if (!policy.success)
517
+ missing.add("server-policy-unavailable");
377
518
  config = input;
519
+ saveController = new AbortController();
378
520
  closing = false;
379
521
  source = `browser:${core.id.uuid()}`;
380
522
  storageKey = `${input.appId}:${input.persistenceKey ?? ""}`;
@@ -412,7 +554,7 @@ function createFeedbackClient() {
412
554
  unsubscribeLogger = core.logger.observe((entry) => {
413
555
  if (entry.context.module === "feedback" || entry.context.feedback === true)
414
556
  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 } });
557
+ record({ kind: ["error", "fatal"].includes(entry.level) ? "error" : "log", requestId: typeof entry.context.requestId === "string" ? entry.context.requestId : typeof entry.context.traceId === "string" ? entry.context.traceId : void 0, data: { level: entry.level, message: entry.message, context: entry.context } });
416
558
  });
417
559
  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
560
  listen("unhandledrejection", (event) => record({ kind: "error", data: { reason: event.reason } }));
@@ -469,19 +611,22 @@ function createFeedbackClient() {
469
611
  async function startReplay(activeConfig) {
470
612
  try {
471
613
  const { record: start } = await import('@rrweb/record');
472
- if (closing || config !== activeConfig)
614
+ if (closing || config !== activeConfig || screenshotCaptures || stopReplay)
473
615
  return;
616
+ let firstEvent = true;
474
617
  stopReplay = start({
475
618
  maskAllInputs: true,
476
619
  maskTextSelector: "[data-feedback-private],.rr-mask",
477
620
  blockSelector: "[data-feedback-ignore],[data-feedback-private],.rr-block,iframe,canvas,video",
478
- inlineImages: true,
621
+ // rrweb 内联跨域图像会修改原 img 的 crossOrigin,可能破坏带凭据的头像等资源。
622
+ inlineImages: false,
479
623
  inlineStylesheet: true,
480
624
  collectFonts: false,
481
625
  checkoutEveryNms: 3e4,
482
626
  emit: (event, isCheckout) => {
483
- if (isCheckout || replayChunks.length === 0)
627
+ if (firstEvent || isCheckout || replayChunks.length === 0)
484
628
  replayChunks.push([]);
629
+ firstEvent = false;
485
630
  let payload;
486
631
  try {
487
632
  payload = sanitizeFeedbackReplay(event);
@@ -493,6 +638,7 @@ function createFeedbackClient() {
493
638
  if (!safe.success)
494
639
  return;
495
640
  replayChunks.at(-1)?.push(safe.data);
641
+ appendToDraft(safe.data, new TextEncoder().encode(JSON.stringify(safe.data)).byteLength);
496
642
  while (replayChunks.length > 2)
497
643
  replayChunks.shift();
498
644
  const count = replayChunks.reduce((total, chunk) => total + chunk.length, 0);
@@ -515,10 +661,10 @@ function createFeedbackClient() {
515
661
  lastKeyScreenshot = Date.now();
516
662
  keyScreenshotPromise = (async () => {
517
663
  try {
518
- const bytes = await bounded(activeConfig.captureScreenshot?.() ?? captureFeedbackScreenshot(), activeConfig.screenshotTimeoutMs ?? 3e3);
664
+ const bytes = await takeScreenshot(activeConfig);
519
665
  if (closing || config !== activeConfig)
520
666
  return;
521
- if (bytes.byteLength > 5 * 1024 * 1024) {
667
+ if (bytes.byteLength > budget.maxAttachmentBytes) {
522
668
  missing.add("key-screenshot-size-limit-exceeded");
523
669
  return;
524
670
  }
@@ -536,6 +682,21 @@ function createFeedbackClient() {
536
682
  });
537
683
  return keyScreenshotPromise;
538
684
  }
685
+ function takeScreenshot(activeConfig) {
686
+ screenshotCaptures++;
687
+ stopReplay?.();
688
+ stopReplay = void 0;
689
+ const captured = (async () => {
690
+ try {
691
+ return await (activeConfig.captureScreenshot?.() ?? captureFeedbackScreenshot());
692
+ } finally {
693
+ screenshotCaptures--;
694
+ if (!screenshotCaptures && !closing && config === activeConfig && activeConfig.replay)
695
+ await startReplay(activeConfig);
696
+ }
697
+ })();
698
+ return bounded(captured, activeConfig.screenshotTimeoutMs ?? 3e3);
699
+ }
539
700
  async function doPrepare() {
540
701
  if (!config || closing)
541
702
  return failure("NOT_INITIALIZED");
@@ -550,8 +711,14 @@ function createFeedbackClient() {
550
711
  if (closing || config !== activeConfig)
551
712
  return failure("NOT_INITIALIZED");
552
713
  }
553
- if (draft && !collectionFinished)
714
+ if (draft && !collectionFinished) {
715
+ if (!draft.submission && config.screenshot !== false && preparedPage !== pageIdentity()) {
716
+ const captured = await captureScreenshot();
717
+ if (!captured.success)
718
+ return captured;
719
+ }
554
720
  return ok(draft);
721
+ }
555
722
  collectionFinished = false;
556
723
  collectionSealed = false;
557
724
  initialIds.clear();
@@ -578,7 +745,7 @@ function createFeedbackClient() {
578
745
  if (closing || config !== activeConfig)
579
746
  return failure("NOT_INITIALIZED");
580
747
  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 };
748
+ 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: [], missing: [...missing], expiresAt: now + 864e5 };
582
749
  while (draft.evidence.length > limits().events || new TextEncoder().encode(JSON.stringify(draft.evidence)).byteLength > limits().bytes) {
583
750
  draft.evidence.shift();
584
751
  missing.add("browser-draft-truncated");
@@ -588,30 +755,24 @@ function createFeedbackClient() {
588
755
  draft.missing.push(`browser-buffer-truncated:${dropped}`);
589
756
  afterCount = frozenWindow?.after ?? (anchor ? Math.max(0, logs.length - anchorIndex - 1) : 0);
590
757
  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 ?? []) } });
758
+ recordEnvironment();
592
759
  if (config.screenshot !== false) {
593
760
  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)}`);
761
+ const captured = await captureScreenshot(void 0, "screenshot.png");
762
+ if (closing || config !== activeConfig || draft !== current)
763
+ return failure("NOT_INITIALIZED");
764
+ if (!captured.success) {
765
+ current.missing.push(captured.error.code === HaiFeedbackError.LIMIT_EXCEEDED.code ? "screenshot-size-limit-exceeded" : "screenshot-unavailable");
766
+ current.missing.push(`screenshot-error:${captured.error.message.slice(0, 300)}`);
612
767
  }
613
768
  }
769
+ for (const { attachment: image } of keyScreenshots) {
770
+ if (image.size > budget.maxAttachmentBytes || draft.attachments.length >= budget.maxAttachmentCount || draft.attachments.reduce((sum, file) => sum + file.size, image.size) > budget.maxAttachmentsBytes)
771
+ draft.missing.push("key-screenshot-budget-exceeded");
772
+ else draft.attachments.push(image);
773
+ }
614
774
  schedulePersist();
775
+ preparedPage = pageIdentity();
615
776
  return ok(draft);
616
777
  }
617
778
  function prepare() {
@@ -620,6 +781,52 @@ function createFeedbackClient() {
620
781
  });
621
782
  return preparePromise;
622
783
  }
784
+ function recordEnvironment() {
785
+ if (!config)
786
+ return;
787
+ 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 ?? []) } });
788
+ }
789
+ function pageIdentity() {
790
+ return `${location.origin}${location.pathname}${location.hash.startsWith("#/") ? location.hash.split("?")[0] : ""}`;
791
+ }
792
+ async function captureScreenshot(attachmentId, name) {
793
+ if (!config || !draft || closing)
794
+ return failure("NOT_INITIALIZED");
795
+ const current = draft;
796
+ const activeConfig = config;
797
+ const previous = attachmentId ? current.attachments.find((item) => item.id === attachmentId) : void 0;
798
+ const previousChecksum = previous?.checksum;
799
+ if (current.submission)
800
+ return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_conflict"));
801
+ if (attachmentId && (!previous || !previous.contentType.startsWith("image/")))
802
+ return failure("INVALID_INPUT");
803
+ if (!previous && current.attachments.length >= budget.maxAttachmentCount)
804
+ return failure("LIMIT_EXCEEDED");
805
+ try {
806
+ const bytes = await takeScreenshot(activeConfig);
807
+ if (bytes.byteLength > budget.maxAttachmentBytes)
808
+ return failure("LIMIT_EXCEEDED");
809
+ const previousName = previous && (/\.[^.]+$/.test(previous.name) ? previous.name.replace(/\.[^.]+$/, ".png") : `${previous.name}.png`);
810
+ const image = await attachment(name ?? previousName ?? `screenshot-${core.id.uuid()}.png`, "image/png", bytes);
811
+ if (closing || config !== activeConfig || draft !== current || current.submission || previous && (!current.attachments.includes(previous) || previous.checksum !== previousChecksum))
812
+ return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_conflict"));
813
+ if (current.attachments.reduce((total, item) => total + (item === previous ? 0 : item.size), image.size) > budget.maxAttachmentsBytes)
814
+ return failure("LIMIT_EXCEEDED");
815
+ if (previous)
816
+ current.attachments = current.attachments.map((item) => item === previous ? { ...image, id: previous.id } : item);
817
+ else if (current.attachments.length < budget.maxAttachmentCount)
818
+ current.attachments.unshift(image);
819
+ else
820
+ return failure("LIMIT_EXCEEDED");
821
+ if (!name)
822
+ recordEnvironment();
823
+ preparedPage = pageIdentity();
824
+ schedulePersist();
825
+ return ok(void 0);
826
+ } catch (cause) {
827
+ return failure("OPERATION_FAILED", cause);
828
+ }
829
+ }
623
830
  async function submit(input = {}) {
624
831
  if (!config || closing)
625
832
  return failure("NOT_INITIALIZED");
@@ -629,6 +836,23 @@ function createFeedbackClient() {
629
836
  return prepared;
630
837
  const current = prepared.data;
631
838
  try {
839
+ if (!policyVerified && activeConfig.actions.policy) {
840
+ const policy = await activeConfig.actions.policy();
841
+ if (closing || config !== activeConfig || draft !== current)
842
+ return failure("NOT_INITIALIZED");
843
+ if (!policy.success)
844
+ return policy;
845
+ budget = FeedbackBudgetSchema.parse(policy.data);
846
+ policyVerified = true;
847
+ missing.delete("server-policy-unavailable");
848
+ current.missing = current.missing.filter((reason) => reason !== "server-policy-unavailable");
849
+ while (current.evidence.length && (current.evidence.length > limits().events || new TextEncoder().encode(JSON.stringify(current.evidence)).byteLength > limits().bytes)) {
850
+ current.evidence.shift();
851
+ if (!current.missing.includes("browser-draft-truncated"))
852
+ current.missing.push("browser-draft-truncated");
853
+ }
854
+ draftBytes = new TextEncoder().encode(JSON.stringify(current.evidence)).byteLength;
855
+ }
632
856
  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
857
  if (closing || config !== activeConfig || draft !== current)
634
858
  return failure("NOT_INITIALIZED");
@@ -689,7 +913,7 @@ function createFeedbackClient() {
689
913
  if (pending.some((item) => !initialIds.has(item.id)))
690
914
  return failure();
691
915
  drainPasses++;
692
- if (drainPasses >= 20)
916
+ if (drainPasses >= budget.maxAttachmentCount)
693
917
  return ok(void 0);
694
918
  }
695
919
  if (collectionSealed) {
@@ -727,6 +951,8 @@ function createFeedbackClient() {
727
951
  const bytes = await annotateFeedbackImage(originalBytes, mark);
728
952
  const name = /\.[^.]+$/.test(item.name) ? item.name.replace(/\.[^.]+$/, ".png") : `${item.name}.png`;
729
953
  const updated = await attachment(name, "image/png", bytes);
954
+ if (updated.size > budget.maxAttachmentBytes || current.attachments.reduce((sum, file) => sum + (file === item ? 0 : file.size), updated.size) > budget.maxAttachmentsBytes)
955
+ return failure("LIMIT_EXCEEDED");
730
956
  if (closing || draft !== current || current.submission || !current.attachments.includes(item) || item.bytes !== originalBytes)
731
957
  return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_conflict"));
732
958
  Object.assign(item, updated, { id });
@@ -736,45 +962,46 @@ function createFeedbackClient() {
736
962
  return failure("OPERATION_FAILED", cause);
737
963
  }
738
964
  }
739
- async function addFiles(files) {
965
+ async function addFiles(files, removedAttachmentIds = []) {
740
966
  if (!draft)
741
967
  return failure("NOT_INITIALIZED");
742
- if (draft.submission && files.length)
968
+ if (draft.submission && (files.length || removedAttachmentIds.length))
743
969
  return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_retryInputChanged"));
744
970
  const current = draft;
745
971
  const items = [];
746
972
  const newMissing = [];
747
973
  try {
748
974
  for (const file of files) {
749
- if (items.length >= 20 || file.size > 5 * 1024 * 1024)
975
+ if (items.length >= budget.maxAttachmentCount || file.size > budget.maxAttachmentBytes)
750
976
  return failure("LIMIT_EXCEEDED");
751
- if (!["image/png", "image/jpeg", "image/webp", "text/plain", "application/json"].includes(file.type))
752
- return failure("INVALID_INPUT");
977
+ const contentType = feedbackFileContentType(file);
978
+ if (!contentType)
979
+ return failure("INVALID_INPUT", void 0, feedbackM("feedback_unsupportedAttachmentType"));
753
980
  let bytes;
754
- if (file.type.startsWith("text/") || file.type === "application/json") {
981
+ if (contentType.startsWith("text/") || contentType === "application/json") {
755
982
  const raw = await file.text();
756
- if (file.type === "application/json" && raw.length > 8192)
983
+ if (contentType === "application/json" && raw.length > 8192)
757
984
  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));
985
+ bytes = contentType === "application/json" ? new TextEncoder().encode(JSON.stringify(sanitizeFeedbackData(JSON.parse(raw)))) : new TextEncoder().encode(sanitizeFeedbackText(raw));
759
986
  } else {
760
987
  bytes = new Uint8Array(await file.arrayBuffer());
761
988
  }
762
- if (bytes.byteLength > 5 * 1024 * 1024)
989
+ if (bytes.byteLength > budget.maxAttachmentBytes)
763
990
  return failure("LIMIT_EXCEEDED");
764
- const item = await attachment(file.name, file.type, bytes);
991
+ const item = await attachment(file.name, contentType, bytes);
765
992
  if (!items.some((existing) => existing.name === item.name && existing.checksum === item.checksum))
766
993
  items.push(item);
767
994
  }
768
995
  if (draft !== current || closing)
769
996
  return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_conflict"));
770
- if (current.submission && files.length)
997
+ if (current.submission && (files.length || removedAttachmentIds.length))
771
998
  return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_retryInputChanged"));
772
- const merged = [...current.attachments];
999
+ const merged = current.attachments.filter((file) => !removedAttachmentIds.includes(file.id));
773
1000
  for (const item of items) {
774
1001
  if (!merged.some((existing) => existing.name === item.name && existing.checksum === item.checksum))
775
1002
  merged.push(item);
776
1003
  }
777
- if (merged.length > 20)
1004
+ if (merged.length > budget.maxAttachmentCount || merged.reduce((total, item) => total + item.size, 0) > budget.maxAttachmentsBytes)
778
1005
  return failure("LIMIT_EXCEEDED");
779
1006
  current.attachments = merged;
780
1007
  current.missing.push(...newMissing);
@@ -797,24 +1024,27 @@ function createFeedbackClient() {
797
1024
  attachments.splice(0, attachments.length, ...draft.attachments);
798
1025
  } else {
799
1026
  for (const file of input.files ?? []) {
800
- if (attachments.length >= 20 || file.size > 5 * 1024 * 1024)
1027
+ if (attachments.length >= budget.maxAttachmentCount || file.size > budget.maxAttachmentBytes)
801
1028
  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)
1029
+ const contentType = feedbackFileContentType(file);
1030
+ if (!contentType)
1031
+ return failure("INVALID_INPUT", void 0, feedbackM("feedback_unsupportedAttachmentType"));
1032
+ const bytes = contentType === "application/json" ? new TextEncoder().encode(JSON.stringify(sanitizeFeedbackData(JSON.parse(await file.text())))) : contentType.startsWith("text/") ? new TextEncoder().encode(sanitizeFeedbackText(await file.text())) : new Uint8Array(await file.arrayBuffer());
1033
+ if (bytes.byteLength > budget.maxAttachmentBytes)
806
1034
  return failure("LIMIT_EXCEEDED");
807
- const item = await attachment(file.name, file.type, bytes);
1035
+ const item = await attachment(file.name, contentType, bytes);
808
1036
  if (!attachments.some((existing) => existing.name === item.name && existing.checksum === item.checksum))
809
1037
  attachments.push(item);
810
1038
  }
811
1039
  }
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"] : []])] });
1040
+ return createFeedbackBundle({ maxBytes: budget.maxTotalBytes, report: { ...draft.report ?? { id: draft.submissionId, appId: config?.appId, appVersion: config?.appVersion, buildId: config?.buildId, commit: config?.commit, url: draft.evidence.filter((item) => item.kind === "environment").at(-1)?.data.url, 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
1041
  } catch (cause) {
814
1042
  return failure("OPERATION_FAILED", cause);
815
1043
  }
816
1044
  }
817
- function close() {
1045
+ function close(options = {}) {
1046
+ sessionVersion++;
1047
+ discardOnClose ||= options.discard === true;
818
1048
  closePromise ??= doClose().finally(() => {
819
1049
  closePromise = void 0;
820
1050
  });
@@ -822,6 +1052,7 @@ function createFeedbackClient() {
822
1052
  }
823
1053
  async function doClose() {
824
1054
  closing = true;
1055
+ saveController.abort();
825
1056
  unsubscribeLogger?.();
826
1057
  unsubscribeLogger = void 0;
827
1058
  stopReplay?.();
@@ -838,9 +1069,14 @@ function createFeedbackClient() {
838
1069
  await keyScreenshotPromise;
839
1070
  if (draft?.report)
840
1071
  await flush();
841
- if (config?.persistDraft && draft && !collectionFinished)
1072
+ if (discardOnClose && storageKey)
1073
+ await deleteFeedbackDraft(storageKey).catch(() => void 0);
1074
+ else if (config?.persistDraft && draft && !collectionFinished)
842
1075
  await saveFeedbackDraft(storageKey, draft).catch(() => void 0);
1076
+ releasePreviews();
1077
+ discardOnClose = false;
843
1078
  config = void 0;
1079
+ preparedPage = "";
844
1080
  buffer = [];
845
1081
  bufferBytes = 0;
846
1082
  draft = void 0;
@@ -856,7 +1092,17 @@ function createFeedbackClient() {
856
1092
  collectionFinished = false;
857
1093
  collectionSealed = false;
858
1094
  }
859
- return { init, prepare, submit, flush, record, getDraft: () => draft, annotate, addFiles, exportDraft, close };
1095
+ return { init, prepare, captureScreenshot, submit, flush, record, getDraft: () => draft, getView, releasePreviews, annotate, addFiles, exportDraft, async saveDraft(input) {
1096
+ const session = config;
1097
+ const exported = await exportDraft(input);
1098
+ if (!exported.success)
1099
+ return exported;
1100
+ if (closing || !session || session !== config)
1101
+ return failure("NOT_INITIALIZED");
1102
+ return saveFeedbackFile(exported.data, `feedback-${draft?.submissionId ?? "local"}.zip`, "application/zip", saveController.signal);
1103
+ }, close, get budget() {
1104
+ return { ...budget };
1105
+ } };
860
1106
  }
861
1107
  var feedbackClient = createFeedbackClient();
862
1108
  async function bounded(promise, ms) {
@@ -883,7 +1129,75 @@ function base64(bytes) {
883
1129
  value += String.fromCharCode(...bytes.subarray(offset, offset + 32768));
884
1130
  return btoa(value);
885
1131
  }
1132
+ function createFeedbackReview(actions) {
1133
+ let controller = new AbortController();
1134
+ const failed = () => err(HaiFeedbackError.OPERATION_FAILED, feedbackM("feedback_operationFailed"));
1135
+ return {
1136
+ close() {
1137
+ controller.abort();
1138
+ controller = new AbortController();
1139
+ },
1140
+ async loadReplay(id) {
1141
+ const signal = controller.signal;
1142
+ const events = [];
1143
+ try {
1144
+ for (let page = 1; page <= 100; page++) {
1145
+ const result = await actions.getEvidence({ id, kind: "replay", page, pageSize: 100 });
1146
+ if (signal.aborted)
1147
+ return failed();
1148
+ if (!result.success)
1149
+ return result;
1150
+ events.push(...result.data.items);
1151
+ if (events.length >= result.data.total || !result.data.items.length)
1152
+ return ok(events);
1153
+ }
1154
+ } catch {
1155
+ return failed();
1156
+ }
1157
+ return failed();
1158
+ },
1159
+ async exportReport(id) {
1160
+ const signal = controller.signal;
1161
+ try {
1162
+ const started = await actions.export({ id });
1163
+ if (!started.success)
1164
+ return started;
1165
+ for (let attempt = 0; attempt < 120; attempt++) {
1166
+ if (signal.aborted)
1167
+ return failed();
1168
+ const detail = await actions.get({ id });
1169
+ if (!detail.success)
1170
+ return detail;
1171
+ if (signal.aborted)
1172
+ return failed();
1173
+ const job = detail.data.jobs.find((job2) => job2.id === started.data.id);
1174
+ if (!job || job.status === "failed")
1175
+ return failed();
1176
+ if (job.status === "succeeded") {
1177
+ const file = await actions.getExport({ id, jobId: job.id });
1178
+ if (signal.aborted)
1179
+ return failed();
1180
+ return file.success ? saveFeedbackFile(feedbackDataBytes(file.data), file.data.name, file.data.contentType, signal) : file;
1181
+ }
1182
+ await new Promise((resolve) => {
1183
+ let timer;
1184
+ const done = () => {
1185
+ clearTimeout(timer);
1186
+ signal.removeEventListener("abort", done);
1187
+ resolve();
1188
+ };
1189
+ timer = setTimeout(done, 500);
1190
+ signal.addEventListener("abort", done, { once: true });
1191
+ });
1192
+ }
1193
+ } catch {
1194
+ return failed();
1195
+ }
1196
+ return failed();
1197
+ }
1198
+ };
1199
+ }
886
1200
 
887
- export { createFeedbackClient, feedbackClient };
1201
+ export { configureFeedbackSaving, createFeedbackClient, createFeedbackReview, feedbackClient, feedbackDataBytes, saveFeedbackFile };
888
1202
  //# sourceMappingURL=index.js.map
889
1203
  //# sourceMappingURL=index.js.map