@h-ai/feedback 0.1.0-alpha.69 → 0.1.0-alpha.71

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,6 +1,46 @@
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-KGHZL7K6.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
46
  var PRIVATE_SELECTOR = 'input,textarea,select,[contenteditable]:not([contenteditable="false"]),[data-feedback-private],.rr-block,.rr-mask';
@@ -15,13 +55,15 @@ async function captureFeedbackScreenshot() {
15
55
  }
16
56
  const { default: html2canvas } = await import('html2canvas');
17
57
  const canvas = await html2canvas(document.body, {
18
- width: Math.min(window.innerWidth, 1920),
19
- height: Math.min(window.innerHeight, 1200),
20
- scale: 1,
58
+ width: window.innerWidth,
59
+ height: window.innerHeight,
60
+ scale: Math.min(1, 1920 / window.innerWidth, 1200 / window.innerHeight),
21
61
  // 原生 SVG foreignObject 保留 CSS mask 图标与字体基线,避免 CanvasRenderer 裁切文本。
22
62
  foreignObjectRendering: true,
23
- scrollX: -window.scrollX,
24
- scrollY: -window.scrollY,
63
+ x: window.scrollX,
64
+ y: window.scrollY,
65
+ scrollX: window.scrollX,
66
+ scrollY: window.scrollY,
25
67
  useCORS: false,
26
68
  allowTaint: false,
27
69
  logging: false,
@@ -78,6 +120,11 @@ async function annotateFeedbackImage(bytes, annotation) {
78
120
  try {
79
121
  image.src = imageUrl;
80
122
  await image.decode();
123
+ if (annotation.coordinateSpace === "ratio") {
124
+ if ([annotation.x, annotation.y, annotation.width, annotation.height].some((value) => value < 0 || value > 1))
125
+ throw new Error("Invalid annotation ratio");
126
+ annotation = { ...annotation, x: annotation.x * image.naturalWidth, y: annotation.y * image.naturalHeight, width: annotation.width * image.naturalWidth, height: annotation.height * image.naturalHeight };
127
+ }
81
128
  canvas.width = image.naturalWidth;
82
129
  canvas.height = image.naturalHeight;
83
130
  const context = canvas.getContext("2d");
@@ -86,7 +133,9 @@ async function annotateFeedbackImage(bytes, annotation) {
86
133
  context.drawImage(image, 0, 0);
87
134
  if (annotation.mode === "mask") {
88
135
  context.fillStyle = "#111";
89
- context.fillRect(annotation.x, annotation.y, annotation.width, annotation.height);
136
+ const left = Math.floor(annotation.x);
137
+ const top = Math.floor(annotation.y);
138
+ context.fillRect(left, top, Math.ceil(annotation.x + annotation.width) - left, Math.ceil(annotation.y + annotation.height) - top);
90
139
  } else {
91
140
  context.strokeStyle = "#e11d48";
92
141
  context.lineWidth = 3;
@@ -153,14 +202,14 @@ function sanitizeFeedbackReplay(value) {
153
202
  if (key === "attributes" && item && typeof item === "object") {
154
203
  const attributes = {};
155
204
  for (const [attribute, content] of Object.entries(item)) {
156
- if (/^on|^(?:srcdoc|action|formaction|href|srcset)$/i.test(attribute) || CREDENTIAL_NAME.test(attribute))
205
+ if (/^on|^(?:srcdoc|action|formaction|href|srcset|rr_src)$/i.test(attribute) || CREDENTIAL_NAME.test(attribute))
157
206
  continue;
158
207
  if (attribute === "src" && typeof content === "string" && !/^data:image\/(?:png|jpeg|webp|gif);base64,/i.test(content)) {
159
208
  attributes["data-feedback-missing-resource"] = true;
160
209
  continue;
161
210
  }
162
- if (attribute === "style" && typeof content === "string" && /url\s*\(/i.test(content)) {
163
- attributes.style = sanitizeFeedbackText(content).replace(/url\s*\([^)]*\)/gi, "");
211
+ if ((attribute === "style" || attribute === "_cssText") && typeof content === "string") {
212
+ attributes[attribute] = sanitizeFeedbackText(content).replace(/(?:url|@import)\s*\([^)]*\)|@import\s+["'][^"']+["']/gi, "");
164
213
  attributes["data-feedback-missing-resource"] = true;
165
214
  } else {
166
215
  attributes[attribute] = visit(content, depth + 1);
@@ -248,8 +297,36 @@ async function restoreFeedbackCheckpoint(key) {
248
297
  // src/client/feedback-client-main.ts
249
298
  function createFeedbackClient() {
250
299
  let config;
251
- let initializing = false;
252
300
  let draft;
301
+ let sessionVersion = 0;
302
+ let policyVerified = false;
303
+ let preparedPage = "";
304
+ let budget = resolveFeedbackBudget();
305
+ const previews = /* @__PURE__ */ new Map();
306
+ let discardOnClose = false;
307
+ let saveController = new AbortController();
308
+ function releasePreviews() {
309
+ for (const preview of previews.values()) URL.revokeObjectURL(preview.url);
310
+ previews.clear();
311
+ }
312
+ function getView() {
313
+ const files = draft?.attachments ?? [];
314
+ for (const [id, preview] of previews) {
315
+ if (!files.some((file) => file.id === id && file.checksum === preview.checksum)) {
316
+ URL.revokeObjectURL(preview.url);
317
+ previews.delete(id);
318
+ }
319
+ }
320
+ return { attachments: files.map((file) => {
321
+ let preview = previews.get(file.id);
322
+ if (!preview) {
323
+ preview = { checksum: file.checksum, url: URL.createObjectURL(new Blob([new Uint8Array(file.bytes).buffer], { type: file.contentType })) };
324
+ previews.set(file.id, preview);
325
+ }
326
+ return { id: file.id, name: file.name, size: file.size, mimeType: file.contentType, url: preview.url };
327
+ }), evidence: draft?.evidence ?? [], missing: draft?.missing ?? [], budget: { ...budget } };
328
+ }
329
+ let initializing = false;
253
330
  let sequence = 0;
254
331
  let buffer = [];
255
332
  let bufferBytes = 0;
@@ -265,6 +342,7 @@ function createFeedbackClient() {
265
342
  let persistTimer;
266
343
  let flushTimer;
267
344
  let stopReplay;
345
+ let screenshotCaptures = 0;
268
346
  let unsubscribeLogger;
269
347
  let source = "";
270
348
  let keyScreenshots = [];
@@ -281,10 +359,13 @@ function createFeedbackClient() {
281
359
  let replayChunks = [];
282
360
  function failure(code = "OPERATION_FAILED", cause) {
283
361
  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);
362
+ const traceId = core.context.get().traceId ?? core.context.get().requestId ?? core.id.uuid();
363
+ const error = { ...HaiFeedbackError[code], traceId, message: feedbackM(key), cause };
364
+ core.logger[cause === void 0 ? "warn" : "error"]("Feedback client operation failed", { module: "feedback", traceId, error });
365
+ return err({ ...error, message: core.error.formatMessage(error) });
285
366
  }
286
367
  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 };
368
+ 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
369
  }
289
370
  function schedulePersist() {
290
371
  if (!config?.persistDraft || persistTimer)
@@ -350,6 +431,10 @@ function createFeedbackClient() {
350
431
  missing.add("browser-error-windows-truncated");
351
432
  }
352
433
  schedulePersist();
434
+ appendToDraft(item, size);
435
+ }
436
+ function appendToDraft(item, size) {
437
+ const bounds = limits();
353
438
  if (draft && !collectionFinished && !collectionSealed && (Date.now() <= deadline && afterCount < bounds.after || item.kind === "environment") && draft.evidence.length < bounds.events && draftBytes + size <= bounds.bytes) {
354
439
  draft.evidence.push(item);
355
440
  draftBytes += size;
@@ -374,7 +459,18 @@ function createFeedbackClient() {
374
459
  const numeric = [input.maxEvents, input.maxBytes, input.maxAgeMs, input.beforeCount, input.afterCount, input.afterTimeoutMs, input.screenshotTimeoutMs, input.maxKeyScreenshots, input.keyScreenshotThrottleMs];
375
460
  if (numeric.some((value) => value !== void 0 && (!Number.isSafeInteger(value) || value < 0)) || input.persistDraft && !input.persistenceKey)
376
461
  return failure("INVALID_INPUT");
462
+ 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)
463
+ return failure("INVALID_INPUT");
464
+ const initVersion = sessionVersion;
465
+ const policy = input.actions.policy ? await input.actions.policy().catch(() => failure("OPERATION_FAILED")) : ok(input.budget ?? resolveFeedbackBudget());
466
+ if (initVersion !== sessionVersion)
467
+ return failure("NOT_INITIALIZED");
468
+ policyVerified = policy.success;
469
+ budget = FeedbackBudgetSchema.parse(policy.success ? policy.data : input.budget ?? resolveFeedbackBudget());
470
+ if (!policy.success)
471
+ missing.add("server-policy-unavailable");
377
472
  config = input;
473
+ saveController = new AbortController();
378
474
  closing = false;
379
475
  source = `browser:${core.id.uuid()}`;
380
476
  storageKey = `${input.appId}:${input.persistenceKey ?? ""}`;
@@ -412,7 +508,7 @@ function createFeedbackClient() {
412
508
  unsubscribeLogger = core.logger.observe((entry) => {
413
509
  if (entry.context.module === "feedback" || entry.context.feedback === true)
414
510
  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 } });
511
+ 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
512
  });
417
513
  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
514
  listen("unhandledrejection", (event) => record({ kind: "error", data: { reason: event.reason } }));
@@ -469,19 +565,22 @@ function createFeedbackClient() {
469
565
  async function startReplay(activeConfig) {
470
566
  try {
471
567
  const { record: start } = await import('@rrweb/record');
472
- if (closing || config !== activeConfig)
568
+ if (closing || config !== activeConfig || screenshotCaptures || stopReplay)
473
569
  return;
570
+ let firstEvent = true;
474
571
  stopReplay = start({
475
572
  maskAllInputs: true,
476
573
  maskTextSelector: "[data-feedback-private],.rr-mask",
477
574
  blockSelector: "[data-feedback-ignore],[data-feedback-private],.rr-block,iframe,canvas,video",
478
- inlineImages: true,
575
+ // rrweb 内联跨域图像会修改原 img 的 crossOrigin,可能破坏带凭据的头像等资源。
576
+ inlineImages: false,
479
577
  inlineStylesheet: true,
480
578
  collectFonts: false,
481
579
  checkoutEveryNms: 3e4,
482
580
  emit: (event, isCheckout) => {
483
- if (isCheckout || replayChunks.length === 0)
581
+ if (firstEvent || isCheckout || replayChunks.length === 0)
484
582
  replayChunks.push([]);
583
+ firstEvent = false;
485
584
  let payload;
486
585
  try {
487
586
  payload = sanitizeFeedbackReplay(event);
@@ -493,6 +592,7 @@ function createFeedbackClient() {
493
592
  if (!safe.success)
494
593
  return;
495
594
  replayChunks.at(-1)?.push(safe.data);
595
+ appendToDraft(safe.data, new TextEncoder().encode(JSON.stringify(safe.data)).byteLength);
496
596
  while (replayChunks.length > 2)
497
597
  replayChunks.shift();
498
598
  const count = replayChunks.reduce((total, chunk) => total + chunk.length, 0);
@@ -515,10 +615,10 @@ function createFeedbackClient() {
515
615
  lastKeyScreenshot = Date.now();
516
616
  keyScreenshotPromise = (async () => {
517
617
  try {
518
- const bytes = await bounded(activeConfig.captureScreenshot?.() ?? captureFeedbackScreenshot(), activeConfig.screenshotTimeoutMs ?? 3e3);
618
+ const bytes = await takeScreenshot(activeConfig);
519
619
  if (closing || config !== activeConfig)
520
620
  return;
521
- if (bytes.byteLength > 5 * 1024 * 1024) {
621
+ if (bytes.byteLength > budget.maxAttachmentBytes) {
522
622
  missing.add("key-screenshot-size-limit-exceeded");
523
623
  return;
524
624
  }
@@ -536,6 +636,21 @@ function createFeedbackClient() {
536
636
  });
537
637
  return keyScreenshotPromise;
538
638
  }
639
+ function takeScreenshot(activeConfig) {
640
+ screenshotCaptures++;
641
+ stopReplay?.();
642
+ stopReplay = void 0;
643
+ const captured = (async () => {
644
+ try {
645
+ return await (activeConfig.captureScreenshot?.() ?? captureFeedbackScreenshot());
646
+ } finally {
647
+ screenshotCaptures--;
648
+ if (!screenshotCaptures && !closing && config === activeConfig && activeConfig.replay)
649
+ await startReplay(activeConfig);
650
+ }
651
+ })();
652
+ return bounded(captured, activeConfig.screenshotTimeoutMs ?? 3e3);
653
+ }
539
654
  async function doPrepare() {
540
655
  if (!config || closing)
541
656
  return failure("NOT_INITIALIZED");
@@ -550,8 +665,14 @@ function createFeedbackClient() {
550
665
  if (closing || config !== activeConfig)
551
666
  return failure("NOT_INITIALIZED");
552
667
  }
553
- if (draft && !collectionFinished)
668
+ if (draft && !collectionFinished) {
669
+ if (!draft.submission && config.screenshot !== false && preparedPage !== pageIdentity()) {
670
+ const captured = await captureScreenshot();
671
+ if (!captured.success)
672
+ return captured;
673
+ }
554
674
  return ok(draft);
675
+ }
555
676
  collectionFinished = false;
556
677
  collectionSealed = false;
557
678
  initialIds.clear();
@@ -578,7 +699,7 @@ function createFeedbackClient() {
578
699
  if (closing || config !== activeConfig)
579
700
  return failure("NOT_INITIALIZED");
580
701
  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 };
702
+ 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
703
  while (draft.evidence.length > limits().events || new TextEncoder().encode(JSON.stringify(draft.evidence)).byteLength > limits().bytes) {
583
704
  draft.evidence.shift();
584
705
  missing.add("browser-draft-truncated");
@@ -588,30 +709,24 @@ function createFeedbackClient() {
588
709
  draft.missing.push(`browser-buffer-truncated:${dropped}`);
589
710
  afterCount = frozenWindow?.after ?? (anchor ? Math.max(0, logs.length - anchorIndex - 1) : 0);
590
711
  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 ?? []) } });
712
+ recordEnvironment();
592
713
  if (config.screenshot !== false) {
593
714
  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)}`);
715
+ const captured = await captureScreenshot(void 0, "screenshot.png");
716
+ if (closing || config !== activeConfig || draft !== current)
717
+ return failure("NOT_INITIALIZED");
718
+ if (!captured.success) {
719
+ current.missing.push(captured.error.code === HaiFeedbackError.LIMIT_EXCEEDED.code ? "screenshot-size-limit-exceeded" : "screenshot-unavailable");
720
+ current.missing.push(`screenshot-error:${captured.error.message.slice(0, 300)}`);
612
721
  }
613
722
  }
723
+ for (const { attachment: image } of keyScreenshots) {
724
+ if (image.size > budget.maxAttachmentBytes || draft.attachments.length >= budget.maxAttachmentCount || draft.attachments.reduce((sum, file) => sum + file.size, image.size) > budget.maxAttachmentsBytes)
725
+ draft.missing.push("key-screenshot-budget-exceeded");
726
+ else draft.attachments.push(image);
727
+ }
614
728
  schedulePersist();
729
+ preparedPage = pageIdentity();
615
730
  return ok(draft);
616
731
  }
617
732
  function prepare() {
@@ -620,6 +735,52 @@ function createFeedbackClient() {
620
735
  });
621
736
  return preparePromise;
622
737
  }
738
+ function recordEnvironment() {
739
+ if (!config)
740
+ return;
741
+ 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 ?? []) } });
742
+ }
743
+ function pageIdentity() {
744
+ return `${location.origin}${location.pathname}${location.hash.startsWith("#/") ? location.hash.split("?")[0] : ""}`;
745
+ }
746
+ async function captureScreenshot(attachmentId, name) {
747
+ if (!config || !draft || closing)
748
+ return failure("NOT_INITIALIZED");
749
+ const current = draft;
750
+ const activeConfig = config;
751
+ const previous = attachmentId ? current.attachments.find((item) => item.id === attachmentId) : void 0;
752
+ const previousChecksum = previous?.checksum;
753
+ if (current.submission)
754
+ return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_conflict"));
755
+ if (attachmentId && (!previous || !previous.contentType.startsWith("image/")))
756
+ return failure("INVALID_INPUT");
757
+ if (!previous && current.attachments.length >= budget.maxAttachmentCount)
758
+ return failure("LIMIT_EXCEEDED");
759
+ try {
760
+ const bytes = await takeScreenshot(activeConfig);
761
+ if (bytes.byteLength > budget.maxAttachmentBytes)
762
+ return failure("LIMIT_EXCEEDED");
763
+ const previousName = previous && (/\.[^.]+$/.test(previous.name) ? previous.name.replace(/\.[^.]+$/, ".png") : `${previous.name}.png`);
764
+ const image = await attachment(name ?? previousName ?? `screenshot-${core.id.uuid()}.png`, "image/png", bytes);
765
+ if (closing || config !== activeConfig || draft !== current || current.submission || previous && (!current.attachments.includes(previous) || previous.checksum !== previousChecksum))
766
+ return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_conflict"));
767
+ if (current.attachments.reduce((total, item) => total + (item === previous ? 0 : item.size), image.size) > budget.maxAttachmentsBytes)
768
+ return failure("LIMIT_EXCEEDED");
769
+ if (previous)
770
+ current.attachments = current.attachments.map((item) => item === previous ? { ...image, id: previous.id } : item);
771
+ else if (current.attachments.length < budget.maxAttachmentCount)
772
+ current.attachments.unshift(image);
773
+ else
774
+ return failure("LIMIT_EXCEEDED");
775
+ if (!name)
776
+ recordEnvironment();
777
+ preparedPage = pageIdentity();
778
+ schedulePersist();
779
+ return ok(void 0);
780
+ } catch (cause) {
781
+ return failure("OPERATION_FAILED", cause);
782
+ }
783
+ }
623
784
  async function submit(input = {}) {
624
785
  if (!config || closing)
625
786
  return failure("NOT_INITIALIZED");
@@ -629,6 +790,23 @@ function createFeedbackClient() {
629
790
  return prepared;
630
791
  const current = prepared.data;
631
792
  try {
793
+ if (!policyVerified && activeConfig.actions.policy) {
794
+ const policy = await activeConfig.actions.policy();
795
+ if (closing || config !== activeConfig || draft !== current)
796
+ return failure("NOT_INITIALIZED");
797
+ if (!policy.success)
798
+ return policy;
799
+ budget = FeedbackBudgetSchema.parse(policy.data);
800
+ policyVerified = true;
801
+ missing.delete("server-policy-unavailable");
802
+ current.missing = current.missing.filter((reason) => reason !== "server-policy-unavailable");
803
+ while (current.evidence.length && (current.evidence.length > limits().events || new TextEncoder().encode(JSON.stringify(current.evidence)).byteLength > limits().bytes)) {
804
+ current.evidence.shift();
805
+ if (!current.missing.includes("browser-draft-truncated"))
806
+ current.missing.push("browser-draft-truncated");
807
+ }
808
+ draftBytes = new TextEncoder().encode(JSON.stringify(current.evidence)).byteLength;
809
+ }
632
810
  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
811
  if (closing || config !== activeConfig || draft !== current)
634
812
  return failure("NOT_INITIALIZED");
@@ -689,7 +867,7 @@ function createFeedbackClient() {
689
867
  if (pending.some((item) => !initialIds.has(item.id)))
690
868
  return failure();
691
869
  drainPasses++;
692
- if (drainPasses >= 20)
870
+ if (drainPasses >= budget.maxAttachmentCount)
693
871
  return ok(void 0);
694
872
  }
695
873
  if (collectionSealed) {
@@ -727,6 +905,8 @@ function createFeedbackClient() {
727
905
  const bytes = await annotateFeedbackImage(originalBytes, mark);
728
906
  const name = /\.[^.]+$/.test(item.name) ? item.name.replace(/\.[^.]+$/, ".png") : `${item.name}.png`;
729
907
  const updated = await attachment(name, "image/png", bytes);
908
+ if (updated.size > budget.maxAttachmentBytes || current.attachments.reduce((sum, file) => sum + (file === item ? 0 : file.size), updated.size) > budget.maxAttachmentsBytes)
909
+ return failure("LIMIT_EXCEEDED");
730
910
  if (closing || draft !== current || current.submission || !current.attachments.includes(item) || item.bytes !== originalBytes)
731
911
  return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_conflict"));
732
912
  Object.assign(item, updated, { id });
@@ -736,19 +916,19 @@ function createFeedbackClient() {
736
916
  return failure("OPERATION_FAILED", cause);
737
917
  }
738
918
  }
739
- async function addFiles(files) {
919
+ async function addFiles(files, removedAttachmentIds = []) {
740
920
  if (!draft)
741
921
  return failure("NOT_INITIALIZED");
742
- if (draft.submission && files.length)
922
+ if (draft.submission && (files.length || removedAttachmentIds.length))
743
923
  return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_retryInputChanged"));
744
924
  const current = draft;
745
925
  const items = [];
746
926
  const newMissing = [];
747
927
  try {
748
928
  for (const file of files) {
749
- if (items.length >= 20 || file.size > 5 * 1024 * 1024)
929
+ if (items.length >= budget.maxAttachmentCount || file.size > budget.maxAttachmentBytes)
750
930
  return failure("LIMIT_EXCEEDED");
751
- if (!["image/png", "image/jpeg", "image/webp", "text/plain", "application/json"].includes(file.type))
931
+ if (!["image/png", "image/jpeg", "image/webp", "video/webm", "video/mp4", "text/plain", "application/json"].includes(file.type))
752
932
  return failure("INVALID_INPUT");
753
933
  let bytes;
754
934
  if (file.type.startsWith("text/") || file.type === "application/json") {
@@ -759,7 +939,7 @@ function createFeedbackClient() {
759
939
  } else {
760
940
  bytes = new Uint8Array(await file.arrayBuffer());
761
941
  }
762
- if (bytes.byteLength > 5 * 1024 * 1024)
942
+ if (bytes.byteLength > budget.maxAttachmentBytes)
763
943
  return failure("LIMIT_EXCEEDED");
764
944
  const item = await attachment(file.name, file.type, bytes);
765
945
  if (!items.some((existing) => existing.name === item.name && existing.checksum === item.checksum))
@@ -767,14 +947,14 @@ function createFeedbackClient() {
767
947
  }
768
948
  if (draft !== current || closing)
769
949
  return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_conflict"));
770
- if (current.submission && files.length)
950
+ if (current.submission && (files.length || removedAttachmentIds.length))
771
951
  return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_retryInputChanged"));
772
- const merged = [...current.attachments];
952
+ const merged = current.attachments.filter((file) => !removedAttachmentIds.includes(file.id));
773
953
  for (const item of items) {
774
954
  if (!merged.some((existing) => existing.name === item.name && existing.checksum === item.checksum))
775
955
  merged.push(item);
776
956
  }
777
- if (merged.length > 20)
957
+ if (merged.length > budget.maxAttachmentCount || merged.reduce((total, item) => total + item.size, 0) > budget.maxAttachmentsBytes)
778
958
  return failure("LIMIT_EXCEEDED");
779
959
  current.attachments = merged;
780
960
  current.missing.push(...newMissing);
@@ -797,24 +977,26 @@ function createFeedbackClient() {
797
977
  attachments.splice(0, attachments.length, ...draft.attachments);
798
978
  } else {
799
979
  for (const file of input.files ?? []) {
800
- if (attachments.length >= 20 || file.size > 5 * 1024 * 1024)
980
+ if (attachments.length >= budget.maxAttachmentCount || file.size > budget.maxAttachmentBytes)
801
981
  return failure("LIMIT_EXCEEDED");
802
- if (!["image/png", "image/jpeg", "image/webp", "text/plain", "application/json"].includes(file.type))
982
+ if (!["image/png", "image/jpeg", "image/webp", "video/webm", "video/mp4", "text/plain", "application/json"].includes(file.type))
803
983
  return failure("INVALID_INPUT");
804
984
  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)
985
+ if (bytes.byteLength > budget.maxAttachmentBytes)
806
986
  return failure("LIMIT_EXCEEDED");
807
987
  const item = await attachment(file.name, file.type, bytes);
808
988
  if (!attachments.some((existing) => existing.name === item.name && existing.checksum === item.checksum))
809
989
  attachments.push(item);
810
990
  }
811
991
  }
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"] : []])] });
992
+ 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
993
  } catch (cause) {
814
994
  return failure("OPERATION_FAILED", cause);
815
995
  }
816
996
  }
817
- function close() {
997
+ function close(options = {}) {
998
+ sessionVersion++;
999
+ discardOnClose ||= options.discard === true;
818
1000
  closePromise ??= doClose().finally(() => {
819
1001
  closePromise = void 0;
820
1002
  });
@@ -822,6 +1004,7 @@ function createFeedbackClient() {
822
1004
  }
823
1005
  async function doClose() {
824
1006
  closing = true;
1007
+ saveController.abort();
825
1008
  unsubscribeLogger?.();
826
1009
  unsubscribeLogger = void 0;
827
1010
  stopReplay?.();
@@ -838,9 +1021,14 @@ function createFeedbackClient() {
838
1021
  await keyScreenshotPromise;
839
1022
  if (draft?.report)
840
1023
  await flush();
841
- if (config?.persistDraft && draft && !collectionFinished)
1024
+ if (discardOnClose && storageKey)
1025
+ await deleteFeedbackDraft(storageKey).catch(() => void 0);
1026
+ else if (config?.persistDraft && draft && !collectionFinished)
842
1027
  await saveFeedbackDraft(storageKey, draft).catch(() => void 0);
1028
+ releasePreviews();
1029
+ discardOnClose = false;
843
1030
  config = void 0;
1031
+ preparedPage = "";
844
1032
  buffer = [];
845
1033
  bufferBytes = 0;
846
1034
  draft = void 0;
@@ -856,7 +1044,17 @@ function createFeedbackClient() {
856
1044
  collectionFinished = false;
857
1045
  collectionSealed = false;
858
1046
  }
859
- return { init, prepare, submit, flush, record, getDraft: () => draft, annotate, addFiles, exportDraft, close };
1047
+ return { init, prepare, captureScreenshot, submit, flush, record, getDraft: () => draft, getView, releasePreviews, annotate, addFiles, exportDraft, async saveDraft(input) {
1048
+ const session = config;
1049
+ const exported = await exportDraft(input);
1050
+ if (!exported.success)
1051
+ return exported;
1052
+ if (closing || !session || session !== config)
1053
+ return failure("NOT_INITIALIZED");
1054
+ return saveFeedbackFile(exported.data, `feedback-${draft?.submissionId ?? "local"}.zip`, "application/zip", saveController.signal);
1055
+ }, close, get budget() {
1056
+ return { ...budget };
1057
+ } };
860
1058
  }
861
1059
  var feedbackClient = createFeedbackClient();
862
1060
  async function bounded(promise, ms) {
@@ -883,7 +1081,75 @@ function base64(bytes) {
883
1081
  value += String.fromCharCode(...bytes.subarray(offset, offset + 32768));
884
1082
  return btoa(value);
885
1083
  }
1084
+ function createFeedbackReview(actions) {
1085
+ let controller = new AbortController();
1086
+ const failed = () => err(HaiFeedbackError.OPERATION_FAILED, feedbackM("feedback_operationFailed"));
1087
+ return {
1088
+ close() {
1089
+ controller.abort();
1090
+ controller = new AbortController();
1091
+ },
1092
+ async loadReplay(id) {
1093
+ const signal = controller.signal;
1094
+ const events = [];
1095
+ try {
1096
+ for (let page = 1; page <= 100; page++) {
1097
+ const result = await actions.getEvidence({ id, kind: "replay", page, pageSize: 100 });
1098
+ if (signal.aborted)
1099
+ return failed();
1100
+ if (!result.success)
1101
+ return result;
1102
+ events.push(...result.data.items);
1103
+ if (events.length >= result.data.total || !result.data.items.length)
1104
+ return ok(events);
1105
+ }
1106
+ } catch {
1107
+ return failed();
1108
+ }
1109
+ return failed();
1110
+ },
1111
+ async exportReport(id) {
1112
+ const signal = controller.signal;
1113
+ try {
1114
+ const started = await actions.export({ id });
1115
+ if (!started.success)
1116
+ return started;
1117
+ for (let attempt = 0; attempt < 120; attempt++) {
1118
+ if (signal.aborted)
1119
+ return failed();
1120
+ const detail = await actions.get({ id });
1121
+ if (!detail.success)
1122
+ return detail;
1123
+ if (signal.aborted)
1124
+ return failed();
1125
+ const job = detail.data.jobs.find((job2) => job2.id === started.data.id);
1126
+ if (!job || job.status === "failed")
1127
+ return failed();
1128
+ if (job.status === "succeeded") {
1129
+ const file = await actions.getExport({ id, jobId: job.id });
1130
+ if (signal.aborted)
1131
+ return failed();
1132
+ return file.success ? saveFeedbackFile(feedbackDataBytes(file.data), file.data.name, file.data.contentType, signal) : file;
1133
+ }
1134
+ await new Promise((resolve) => {
1135
+ let timer;
1136
+ const done = () => {
1137
+ clearTimeout(timer);
1138
+ signal.removeEventListener("abort", done);
1139
+ resolve();
1140
+ };
1141
+ timer = setTimeout(done, 500);
1142
+ signal.addEventListener("abort", done, { once: true });
1143
+ });
1144
+ }
1145
+ } catch {
1146
+ return failed();
1147
+ }
1148
+ return failed();
1149
+ }
1150
+ };
1151
+ }
886
1152
 
887
- export { createFeedbackClient, feedbackClient };
1153
+ export { configureFeedbackSaving, createFeedbackClient, createFeedbackReview, feedbackClient, feedbackDataBytes, saveFeedbackFile };
888
1154
  //# sourceMappingURL=index.js.map
889
1155
  //# sourceMappingURL=index.js.map