issue-sticker 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
- import { u as uploadScreenshotDataUrls, a as uploadSignedMediaWithApi, d as directUploadToSignedUrl, p as pristineFetch, g as getElementPosition, c as captureWithModernScreenshot, b as buildJiraCreateFields, e as buildNotionCreateFields, f as buildGuestOwnerQuery, h as composeIssueFilterGroups, i as buildIssueIntegrationsPayload, j as devLogger, W as WebSocketCollector, C as ConsoleCollector, N as NetworkCollector, s as selectNetworkLogsForSubmit, k as filterExcludeServiceRelatedLogs, l as filterExcludeServiceRelatedConsoleLogs, m as compactNetworkLogsForTransport, n as CONSOLE_LOG_SUBMIT_LIMIT, o as buildExternalUserPayload, q as shrinkNetworkLogsToFit, r as measureJsonBytes, t as sanitizeString, v as serializeSocketTranscript, w as uploadSocketTranscript, x as getCurrentPageUrl, S as ScreenRecordingController, y as SessionReplayCaptureController, z as preloadModernScreenshot, I as ISSUE_STICKER_INTERNAL_LOG_PREFIX } from "./externalUserPayload-BbOEmKZD.js";
2
- import { r } from "./three-DQdLNs7v.js";
1
+ import { u as uploadScreenshotDataUrls, a as uploadSignedMediaWithApi, d as directUploadToSignedUrl, p as pristineFetch, g as getElementPosition, c as captureViewportAndElementShots, b as captureScreenshotWithMasks, i as isCapturedScreenshot, M as MaskingCaptureError, e as buildJiraCreateFields, f as buildNotionCreateFields, h as buildGuestOwnerQuery, j as composeIssueFilterGroups, k as buildIssueIntegrationsPayload, l as devLogger, W as WebSocketCollector, C as ConsoleCollector, N as NetworkCollector, s as selectNetworkLogsForSubmit, m as filterExcludeServiceRelatedLogs, n as filterExcludeServiceRelatedConsoleLogs, o as compactNetworkLogsForTransport, q as CONSOLE_LOG_SUBMIT_LIMIT, r as buildExternalUserPayload, t as shrinkNetworkLogsToFit, v as measureJsonBytes, w as sanitizeString, x as runMaskedSubmit, y as applySubmitMasking, z as collectAttachmentUploads, A as maskPositionForSubmit, B as hasReplayUpload, D as appendMaskingNotice, E as screenshotExclusionNotice, F as collectSnapshotSelectors, G as MaskingScreenRulesChangedError, H as screenRulesChangedMessage, I as MaskingPolicyUnavailableError, J as MaskingSelectorUnsupportedError, K as assertScreenRulesCovered, L as isMaskingFailure, O as maskScreenshotsForSubmit, P as getCurrentPageUrl, Q as createIssueUploadQueues, S as ScreenRecordingController, R as SessionReplayCaptureController, T as preloadModernScreenshot, U as enqueueIssueUploads, V as normalizeUploadMimeType, X as ISSUE_STICKER_INTERNAL_LOG_PREFIX, Y as MASKING_POLICY_PREFETCH_TIMEOUT_MS, Z as toSessionReplayMeta, _ as loadCaptureSelectors, $ as loadMaskingPolicyForSubmit, a0 as resolveEffectivePolicy, a1 as createMaskingPolicySource, a2 as loadTusClient, a3 as createIndexedDbPendingUploadStore, a4 as ATTACHMENT_UPLOAD_DB_NAME, a5 as VIDEO_UPLOAD_DB_NAME, a6 as createMediaUploadApi, a7 as createMemoryMaskingPolicyCache } from "./externalUserPayload-CdzgCq4t.js";
2
+ import { M as MASKING_COPY, R as ReplayMaskingError } from "./three-FamoZZKK.js";
3
+ import { r } from "./three-FamoZZKK.js";
3
4
  import "react/jsx-runtime";
4
5
  import "react";
5
6
  const WINDOW_MS = 6e4;
@@ -30,10 +31,11 @@ class AutoIssueRateLimit {
30
31
  }
31
32
  }
32
33
  class IssueStickerHeadlessError extends Error {
33
- constructor(code, message, cause) {
34
+ constructor(code, message, cause, status) {
34
35
  super(message);
35
36
  this.code = code;
36
37
  this.cause = cause;
38
+ this.status = status;
37
39
  this.name = "IssueStickerHeadlessError";
38
40
  }
39
41
  }
@@ -98,7 +100,8 @@ class HeadlessTransport {
98
100
  const execute = (nextHeaders) => pristineFetch(`${getApiBaseUrl()}${endpoint}`, {
99
101
  method: options.method ?? "GET",
100
102
  headers: nextHeaders,
101
- body: options.body === void 0 ? void 0 : JSON.stringify(options.body)
103
+ body: options.body === void 0 ? void 0 : JSON.stringify(options.body),
104
+ ...options.cache ? { cache: options.cache } : {}
102
105
  });
103
106
  let response = await execute(headers);
104
107
  if (!options.skipAuth && response.status === 401 && this.options.getAuthMode() === "member") {
@@ -114,10 +117,12 @@ class HeadlessTransport {
114
117
  }
115
118
  const json = await response.json().catch(() => null);
116
119
  if (!response.ok || !(json == null ? void 0 : json.success)) {
120
+ const unauthorizedCode = this.options.getAuthMode() === "member" ? "MEMBER_AUTH_REQUIRED" : "AUTH_FAILED";
117
121
  throw new IssueStickerHeadlessError(
118
- response.status === 401 ? "MEMBER_AUTH_REQUIRED" : "NETWORK_ERROR",
122
+ response.status === 401 ? unauthorizedCode : "NETWORK_ERROR",
119
123
  extractErrorMessage(json == null ? void 0 : json.error, (json == null ? void 0 : json.message) || `요청 실패: ${response.status}`),
120
- json
124
+ json,
125
+ response.status
121
126
  );
122
127
  }
123
128
  return json;
@@ -471,28 +476,34 @@ function buildHeadlessCaptureContext({
471
476
  sessionReplayPreview: null
472
477
  };
473
478
  }
479
+ const NO_HEADLESS_SCREENSHOTS = {
480
+ screenshots: [],
481
+ maskingDropped: false
482
+ };
474
483
  async function captureHeadlessScreenshots({
475
484
  target,
476
- excludeSelectors
485
+ excludeSelectors,
486
+ maskSelectors
477
487
  }) {
478
488
  const targetIsDocumentRoot = target === document.body || target === document.documentElement;
479
- const screenshots = await Promise.all([
480
- captureWithModernScreenshot({
481
- element: document.documentElement,
482
- excludeSelectors,
483
- width: window.innerWidth,
484
- height: window.innerHeight
485
- }).catch(() => void 0),
486
- ...targetIsDocumentRoot ? [] : [
487
- captureWithModernScreenshot({
488
- element: target,
489
- excludeSelectors
490
- }).catch(() => void 0)
491
- ]
492
- ]);
493
- return screenshots.filter(
494
- (dataUrl) => typeof dataUrl === "string" && dataUrl.length > 0
495
- );
489
+ const viewport = await captureViewportAndElementShots({
490
+ element: target,
491
+ excludeSelectors,
492
+ maskSelectors
493
+ });
494
+ let maskingDropped = Boolean(viewport.maskingDropped);
495
+ const elementShot = targetIsDocumentRoot || viewport.element ? viewport.element : await captureScreenshotWithMasks({
496
+ element: target,
497
+ excludeSelectors,
498
+ maskSelectors
499
+ }).catch((error) => {
500
+ maskingDropped || (maskingDropped = error instanceof MaskingCaptureError);
501
+ return void 0;
502
+ });
503
+ return {
504
+ screenshots: [viewport.full, elementShot].filter(isCapturedScreenshot),
505
+ maskingDropped
506
+ };
496
507
  }
497
508
  function normalizeCustomInputType(fieldType) {
498
509
  if (fieldType === "number") return "number";
@@ -954,20 +965,6 @@ class HeadlessPendingMedia {
954
965
  this.file = null;
955
966
  }
956
967
  }
957
- function createReplayFile(snapshot) {
958
- return new File([snapshot.payload], "session-replay.gz", {
959
- type: "application/gzip"
960
- });
961
- }
962
- function createSessionReplayMeta(snapshot) {
963
- return {
964
- window_seconds: snapshot.windowSeconds,
965
- event_count: snapshot.eventCount,
966
- size_bytes: snapshot.sizeBytes,
967
- rrweb_version: snapshot.rrwebVersion,
968
- privacy_config_version: snapshot.privacyConfigVersion
969
- };
970
- }
971
968
  function createInitialHeadlessSnapshot() {
972
969
  return {
973
970
  phase: "idle",
@@ -993,6 +990,8 @@ function createInitialHeadlessSnapshot() {
993
990
  enabled: true,
994
991
  error: null
995
992
  },
993
+ videoUploads: [],
994
+ attachmentUploads: [],
996
995
  formSchema: null,
997
996
  filterSchema: null,
998
997
  error: null
@@ -1104,7 +1103,8 @@ function buildIssueCreatePayload({
1104
1103
  uploads,
1105
1104
  logs,
1106
1105
  currentUrl,
1107
- userAgent
1106
+ userAgent,
1107
+ maskingPolicyVersion
1108
1108
  }) {
1109
1109
  var _a, _b, _c;
1110
1110
  const title = (_a = draft.title) == null ? void 0 : _a.trim();
@@ -1117,17 +1117,14 @@ function buildIssueCreatePayload({
1117
1117
  ...intent ? { intent } : {},
1118
1118
  position: draft.position,
1119
1119
  ...uploads.screenshotUploadIds.length > 0 ? { screenshot_upload_ids: uploads.screenshotUploadIds } : {},
1120
- ...uploads.videoUpload ? { video_upload_id: uploads.videoUpload.id } : {},
1121
- ...uploads.replayUpload ? {
1122
- session_replay_upload_id: uploads.replayUpload.uploadId,
1123
- session_replay_meta: uploads.replayUpload.meta
1124
- } : {},
1125
- ...uploads.socketUpload ? { socket_log_upload_id: uploads.socketUpload.uploadId } : {},
1120
+ ...uploads.videoPending ? { video_pending: true } : {},
1121
+ ...uploads.replayPending ? { session_replay_pending: true } : {},
1126
1122
  console_logs: logs.consoleLogs.length > 0 ? logs.consoleLogs : void 0,
1127
1123
  user_agent: userAgent,
1128
1124
  tenant_id: auth.tenantId,
1129
1125
  project_id: projectId,
1130
1126
  account_id: auth.accountId ?? void 0,
1127
+ masking_policy_version: maskingPolicyVersion,
1131
1128
  ...buildExternalUserPayload(config.user)
1132
1129
  };
1133
1130
  const { networkLogs, stillOverBudget } = shrinkNetworkLogsToFit(
@@ -1142,8 +1139,67 @@ function buildIssueCreatePayload({
1142
1139
  stillOverBudget
1143
1140
  };
1144
1141
  }
1142
+ async function prepareHeadlessScreenAttachments(input) {
1143
+ var _a, _b;
1144
+ const { context } = input;
1145
+ const includeScreenshots = input.draft.mediaMode === "screenshot" || Boolean((_a = context.rules) == null ? void 0 : _a.screen.exclude_video);
1146
+ const screenshots = includeScreenshots ? input.screenshots : [];
1147
+ const sessionReplay = input.draft.sessionReplayEnabled ? input.sessionReplay : null;
1148
+ await (sessionReplay == null ? void 0 : sessionReplay.whenRestarted());
1149
+ const memoSelectors = (sessionReplay == null ? void 0 : sessionReplay.getMaskMemoSelectors()) ?? null;
1150
+ assertScreenRulesCovered(context.rules, [
1151
+ ...screenshots.map((shot) => shot.masks.selectors),
1152
+ ...memoSelectors ? [memoSelectors] : []
1153
+ ]);
1154
+ const replay = sessionReplay ? await sessionReplay.takeSnapshot(((_b = context.rules) == null ? void 0 : _b.screen.mask_selectors) ?? []).catch((error) => {
1155
+ if (isMaskingFailure(error)) throw error;
1156
+ return null;
1157
+ }) : null;
1158
+ const uploads = includeScreenshots ? await input.transport.uploadScreenshotPreviews(
1159
+ await maskScreenshotsForSubmit(screenshots, context.rules)
1160
+ ) : [];
1161
+ return {
1162
+ screenshotUploadIds: uploads.map((upload) => upload.id),
1163
+ replay,
1164
+ screenshotExcluded: includeScreenshots && input.screenshotsDroppedByMasking ? uploads.length > 0 ? "partial" : "all" : null
1165
+ };
1166
+ }
1167
+ function toHeadlessMaskingError(error, options) {
1168
+ var _a;
1169
+ if (error instanceof MaskingScreenRulesChangedError) {
1170
+ (_a = options.onScreenRulesChanged) == null ? void 0 : _a.call(options);
1171
+ return new IssueStickerHeadlessError(
1172
+ "MASKING_POLICY_CHANGED",
1173
+ screenRulesChangedMessage({
1174
+ screenshots: options.recapturesScreenshots,
1175
+ replay: options.replayRecording
1176
+ }),
1177
+ error
1178
+ );
1179
+ }
1180
+ if (error instanceof MaskingPolicyUnavailableError) {
1181
+ return new IssueStickerHeadlessError(
1182
+ "MASKING_POLICY_UNAVAILABLE",
1183
+ MASKING_COPY.policyUnavailable,
1184
+ error
1185
+ );
1186
+ }
1187
+ if (error instanceof MaskingSelectorUnsupportedError) {
1188
+ return new IssueStickerHeadlessError(
1189
+ "MASKING_SELECTOR_UNSUPPORTED",
1190
+ MASKING_COPY.selectorUnsupported,
1191
+ error
1192
+ );
1193
+ }
1194
+ if (error instanceof ReplayMaskingError) {
1195
+ options.restartReplay();
1196
+ return new IssueStickerHeadlessError("CAPTURE_FAILED", MASKING_COPY.replayMaskFailed, error);
1197
+ }
1198
+ return error;
1199
+ }
1145
1200
  async function submitHeadlessIssueDraft({
1146
1201
  draft,
1202
+ target,
1147
1203
  pendingVideoFile,
1148
1204
  transport,
1149
1205
  issueRepository,
@@ -1154,41 +1210,104 @@ async function submitHeadlessIssueDraft({
1154
1210
  logs,
1155
1211
  currentUrl,
1156
1212
  userAgent,
1157
- uploadSessionReplaySnapshot,
1158
- getSocketConnections
1213
+ screenshots,
1214
+ screenshotsDroppedByMasking,
1215
+ sessionReplay,
1216
+ getSocketConnections,
1217
+ maskingPolicy,
1218
+ onScreenRulesChanged,
1219
+ recapturesScreenshots
1159
1220
  }) {
1160
- const screenshotUploads = draft.mediaMode === "screenshot" ? await transport.uploadScreenshotPreviews(draft.screenshotPreviewUrls) : [];
1161
- const screenshotUploadIds = screenshotUploads.map((upload) => upload.id);
1162
- const videoUpload = draft.mediaMode === "video" && pendingVideoFile ? await transport.uploadSignedMedia(pendingVideoFile, "video") : null;
1163
- const replayUpload = draft.sessionReplayEnabled ? await uploadSessionReplaySnapshot() : null;
1164
- const socketSnapshot = await serializeSocketTranscript(getSocketConnections());
1165
- const socketUpload = socketSnapshot ? await uploadSocketTranscript(
1166
- {
1167
- post: (endpoint, body) => transport.request(endpoint, { method: "POST", body }),
1168
- directUpload: directUploadToSignedUrl
1169
- },
1170
- socketSnapshot
1171
- ) : null;
1172
- const { payload: issueData, stillOverBudget } = buildIssueCreatePayload({
1173
- draft,
1174
- auth,
1175
- config,
1176
- projectId,
1177
- uploads: { screenshotUploadIds, videoUpload, replayUpload, socketUpload },
1178
- logs,
1179
- currentUrl,
1180
- userAgent
1181
- });
1182
- if (stillOverBudget) {
1183
- throw new IssueStickerHeadlessError(
1184
- "PAYLOAD_TOO_LARGE",
1185
- "이슈 데이터가 전송 한도를 넘어 저장할 수 없습니다. 설명을 줄이거나 첨부를 빼고 다시 시도해주세요."
1186
- );
1187
- }
1221
+ const hasVideo = draft.mediaMode === "video" && Boolean(pendingVideoFile);
1222
+ const socketConnections = getSocketConnections();
1188
1223
  const integrations = await issueRepository.buildIntegrationsPayload(draft, formSchema);
1189
- const createdIssue = await issueRepository.createIssue({ ...issueData, integrations });
1190
- await issueRepository.syncDynamicFields(createdIssue, draft, formSchema);
1191
- return createdIssue;
1224
+ let result;
1225
+ try {
1226
+ result = await runMaskedSubmit({
1227
+ source: maskingPolicy,
1228
+ projectId,
1229
+ prepareScreen: (context) => prepareHeadlessScreenAttachments({
1230
+ context,
1231
+ draft,
1232
+ transport,
1233
+ screenshots,
1234
+ screenshotsDroppedByMasking,
1235
+ sessionReplay
1236
+ }),
1237
+ submit: async (context, screen) => {
1238
+ var _a, _b, _c;
1239
+ const masked = applySubmitMasking({
1240
+ snapshot: context.snapshot,
1241
+ projectId,
1242
+ networkLogs: logs.networkLogs,
1243
+ consoleLogs: logs.consoleLogs,
1244
+ socketConnections,
1245
+ pageUrl: currentUrl
1246
+ });
1247
+ const videoExcluded = hasVideo && Boolean((_a = masked.rules) == null ? void 0 : _a.screen.exclude_video);
1248
+ const replay = screen.replay;
1249
+ const attachments = await collectAttachmentUploads({
1250
+ takeReplay: replay ? async () => replay : void 0,
1251
+ getConnections: () => masked.socketConnections
1252
+ });
1253
+ const maskSelectors = ((_b = context.rules) == null ? void 0 : _b.screen.mask_selectors) ?? [];
1254
+ const position = {
1255
+ ...draft.position,
1256
+ ...maskPositionForSubmit(draft.position, target, maskSelectors)
1257
+ };
1258
+ const { payload: issueData, stillOverBudget } = buildIssueCreatePayload({
1259
+ draft: {
1260
+ ...draft,
1261
+ position,
1262
+ // 마스킹 정책 때문에 버린 스크린샷은 이슈에도 남긴다. 말없이 빼지 않는다.
1263
+ ...screen.screenshotExcluded ? {
1264
+ description: appendMaskingNotice(
1265
+ draft.description,
1266
+ screenshotExclusionNotice(screen.screenshotExcluded)
1267
+ )
1268
+ } : {}
1269
+ },
1270
+ auth,
1271
+ config,
1272
+ projectId,
1273
+ uploads: {
1274
+ screenshotUploadIds: screen.screenshotUploadIds,
1275
+ videoPending: hasVideo && !videoExcluded,
1276
+ replayPending: hasReplayUpload(attachments)
1277
+ },
1278
+ logs: { networkLogs: masked.networkLogs, consoleLogs: masked.consoleLogs },
1279
+ currentUrl: masked.pageUrl,
1280
+ userAgent,
1281
+ maskingPolicyVersion: masked.version
1282
+ });
1283
+ if (stillOverBudget) {
1284
+ throw new IssueStickerHeadlessError(
1285
+ "PAYLOAD_TOO_LARGE",
1286
+ "이슈 데이터가 전송 한도를 넘어 저장할 수 없습니다. 설명을 줄이거나 첨부를 빼고 다시 시도해주세요."
1287
+ );
1288
+ }
1289
+ const issue = await issueRepository.createIssue({ ...issueData, integrations });
1290
+ if (draft.sessionReplayEnabled && ((_c = sessionReplay.getMaskMemoSelectors()) == null ? void 0 : _c.length) === 0 && collectSnapshotSelectors(context.snapshot).length > 0) {
1291
+ sessionReplay.restartRecording();
1292
+ }
1293
+ return {
1294
+ issue,
1295
+ attachments,
1296
+ videoExcluded,
1297
+ screenshotExcluded: screen.screenshotExcluded !== null
1298
+ };
1299
+ }
1300
+ });
1301
+ } catch (error) {
1302
+ throw toHeadlessMaskingError(error, {
1303
+ onScreenRulesChanged,
1304
+ replayRecording: sessionReplay.isRecording(),
1305
+ recapturesScreenshots,
1306
+ restartReplay: () => sessionReplay.restartRecording()
1307
+ });
1308
+ }
1309
+ await issueRepository.syncDynamicFields(result.issue, draft, formSchema);
1310
+ return result;
1192
1311
  }
1193
1312
  const HEADLESS_CAPTURE_STYLE_ID = "issue-sticker-headless-capture-style";
1194
1313
  const HEADLESS_CAPTURE_STYLE = `
@@ -1280,6 +1399,28 @@ const DEFAULT_EXCLUDE_SELECTORS = [
1280
1399
  "#issue-sticker-host",
1281
1400
  '[data-issue-sticker-headless-auth="true"]'
1282
1401
  ];
1402
+ function toHeadlessVideoUpload(state) {
1403
+ return {
1404
+ issueId: state.issueId,
1405
+ status: state.status,
1406
+ progress: state.bytesTotal > 0 ? state.bytesSent / state.bytesTotal : 0
1407
+ };
1408
+ }
1409
+ function toHeadlessAttachmentUpload(state) {
1410
+ return {
1411
+ issueId: state.issueId,
1412
+ kind: state.mediaType === "socket" ? "socket" : "replay",
1413
+ status: state.status,
1414
+ progress: state.bytesTotal > 0 ? state.bytesSent / state.bytesTotal : 0
1415
+ };
1416
+ }
1417
+ function withExclusions(issue, flags) {
1418
+ return {
1419
+ ...issue,
1420
+ ...flags.videoExcluded ? { videoExcluded: true } : {},
1421
+ ...flags.screenshotExcluded ? { screenshotExcluded: true } : {}
1422
+ };
1423
+ }
1283
1424
  class HeadlessClientImpl {
1284
1425
  constructor(config) {
1285
1426
  var _a;
@@ -1290,7 +1431,14 @@ class HeadlessClientImpl {
1290
1431
  this.initializePromise = null;
1291
1432
  this.captureCleanup = null;
1292
1433
  this.captureSession = null;
1434
+ this.livePolicySource = {
1435
+ getMaskingPolicySnapshot: () => this.maskingPolicy.getMaskingPolicySnapshot(),
1436
+ refreshMaskingPolicySnapshot: (options) => this.maskingPolicy.refreshMaskingPolicySnapshot(options)
1437
+ };
1438
+ this.maskingPolicyPrefetch = null;
1439
+ this.maskingPolicyFetchedAt = null;
1293
1440
  this.suppressNextVideoStopSelection = false;
1441
+ this.destroyed = false;
1294
1442
  if (!((_a = config.apiKey) == null ? void 0 : _a.trim())) {
1295
1443
  throw new IssueStickerHeadlessError("VALIDATION_ERROR", "apiKey가 필요합니다.");
1296
1444
  }
@@ -1298,12 +1446,13 @@ class HeadlessClientImpl {
1298
1446
  apiKey: config.apiKey,
1299
1447
  getTenantId: () => this.snapshot.auth.tenantId,
1300
1448
  getProjectId: () => this.snapshot.auth.projectId,
1301
- onToken: (result) => this.state.applyBridgeToken(result)
1449
+ onToken: (result) => this.applyBridgeToken(result)
1302
1450
  });
1303
1451
  this.transport = new HeadlessTransport({
1304
1452
  getAuthMode: () => this.snapshot.auth.mode,
1305
1453
  resolveAuthorizationHeader: (forceRefreshMemberToken) => this.resolveAuthorizationHeader(forceRefreshMemberToken)
1306
1454
  });
1455
+ this.maskingPolicy = this.createMaskingPolicy();
1307
1456
  this.issueRepository = new HeadlessIssueRepository({
1308
1457
  transport: this.transport,
1309
1458
  getProjectId: () => this.state.requireProjectId(),
@@ -1314,6 +1463,22 @@ class HeadlessClientImpl {
1314
1463
  },
1315
1464
  getCurrentUrl: () => getCurrentPageUrl() || void 0
1316
1465
  });
1466
+ this.uploadQueues = createIssueUploadQueues({
1467
+ api: createMediaUploadApi({
1468
+ post: (endpoint, body) => this.transport.request(endpoint, { method: "POST", body })
1469
+ }),
1470
+ clientInfo: "issue-sticker-sdk",
1471
+ videoStore: createIndexedDbPendingUploadStore(VIDEO_UPLOAD_DB_NAME),
1472
+ attachmentStore: createIndexedDbPendingUploadStore(ATTACHMENT_UPLOAD_DB_NAME),
1473
+ loadTus: loadTusClient,
1474
+ onEvent: (event) => event.mediaType === "video" ? void this.handleVideoUploadEvent(event) : this.handleAttachmentUploadEvent(event)
1475
+ });
1476
+ this.uploadQueues.video.subscribe((states) => {
1477
+ this.state.setSnapshot({ videoUploads: states.map(toHeadlessVideoUpload) });
1478
+ });
1479
+ this.uploadQueues.attachments.subscribe((states) => {
1480
+ this.state.setSnapshot({ attachmentUploads: states.map(toHeadlessAttachmentUpload) });
1481
+ });
1317
1482
  this.logCollectors = new HeadlessLogCollectors(this.config, getApiBaseUrl);
1318
1483
  this.screenRecordingController = new ScreenRecordingController({
1319
1484
  onStopped: (file) => {
@@ -1330,7 +1495,14 @@ class HeadlessClientImpl {
1330
1495
  });
1331
1496
  });
1332
1497
  this.sessionReplayController = new SessionReplayCaptureController({
1333
- loadCapabilities: () => this.request("/api/session-replays/capabilities")
1498
+ loadCapabilities: () => this.request("/api/session-replays/capabilities"),
1499
+ // 녹화는 초기화(initializeInternal)가 정책 요청을 띄운 뒤에 시작한다. 같은 요청을 기다린다.
1500
+ maskingPolicy: {
1501
+ source: this.livePolicySource,
1502
+ pendingRefresh: () => this.maskingPolicyPrefetch,
1503
+ refreshedWithin: (maxAgeMs) => this.maskingPolicyFetchedAt !== null && Date.now() - this.maskingPolicyFetchedAt <= maxAgeMs
1504
+ },
1505
+ reportInProgress: () => this.snapshot.draft !== null
1334
1506
  });
1335
1507
  this.sessionReplayController.subscribe((sessionReplay) => {
1336
1508
  this.applySessionReplaySnapshot(sessionReplay);
@@ -1366,7 +1538,7 @@ class HeadlessClientImpl {
1366
1538
  this.state.setSnapshot({ phase: "loadingIssues", error: null });
1367
1539
  try {
1368
1540
  const issues = await this.issueRepository.fetchAllIssueStickers(filters);
1369
- this.state.setSnapshot({ issues, phase: "idle" });
1541
+ this.state.setSnapshot({ issues: this.keepKnownVideos(issues), phase: "idle" });
1370
1542
  } catch (error) {
1371
1543
  const nextError = this.toHeadlessError(error);
1372
1544
  this.state.setSnapshot({ phase: "idle", error: nextError });
@@ -1502,18 +1674,24 @@ class HeadlessClientImpl {
1502
1674
  this.state.setSnapshot({ phase: "submittingIssue", error: null });
1503
1675
  try {
1504
1676
  const session = this.captureSession;
1505
- const screenshotPreviewUrls = (session == null ? void 0 : session.screenshots) ? await session.screenshots : this.state.requireDraft().screenshotPreviewUrls;
1677
+ const captured = (session == null ? void 0 : session.screenshots) ? await session.screenshots : NO_HEADLESS_SCREENSHOTS;
1506
1678
  const formSchema = this.snapshot.formSchema ?? ((session == null ? void 0 : session.formSchema) ? await session.formSchema : null);
1507
1679
  const submission = {
1508
1680
  ...this.state.requireDraft(),
1509
- screenshotPreviewUrls,
1510
1681
  title: input.title,
1511
1682
  description: input.description,
1512
1683
  intent: input.intent,
1513
1684
  dynamicFields: input.dynamicFields ?? {}
1514
1685
  };
1515
- const createdIssue = await submitHeadlessIssueDraft({
1686
+ const videoFile = submission.mediaMode === "video" ? this.pendingMedia.videoFile : null;
1687
+ const {
1688
+ issue: createdIssue,
1689
+ attachments,
1690
+ videoExcluded,
1691
+ screenshotExcluded
1692
+ } = await submitHeadlessIssueDraft({
1516
1693
  draft: submission,
1694
+ target: (session == null ? void 0 : session.target) ?? null,
1517
1695
  pendingVideoFile: this.pendingMedia.videoFile,
1518
1696
  transport: this.transport,
1519
1697
  issueRepository: this.issueRepository,
@@ -1524,8 +1702,27 @@ class HeadlessClientImpl {
1524
1702
  logs: this.logCollectors.collect(),
1525
1703
  currentUrl: getCurrentPageUrl(),
1526
1704
  userAgent: typeof navigator !== "undefined" ? navigator.userAgent : void 0,
1527
- uploadSessionReplaySnapshot: () => this.uploadSessionReplaySnapshot(),
1528
- getSocketConnections: () => this.logCollectors.getSocketConnections()
1705
+ screenshots: captured.screenshots,
1706
+ screenshotsDroppedByMasking: captured.maskingDropped,
1707
+ sessionReplay: this.submitReplay(),
1708
+ getSocketConnections: () => this.logCollectors.getSocketConnections(),
1709
+ maskingPolicy: this.livePolicySource,
1710
+ // 화면 규칙이 바뀌어 제출이 실패하면 불린다. 같은 대상을 다시 찍어 미리보기를 바꾸고
1711
+ // 리플레이 녹화를 새로 시작한다. 재시작은 거절하지 않는다.
1712
+ onScreenRulesChanged: () => {
1713
+ this.recaptureScreenshots();
1714
+ void this.sessionReplayController.restartRecording();
1715
+ },
1716
+ // 스크린샷 모드로 시작한 캡처만 다시 찍는다(recaptureScreenshots).
1717
+ recapturesScreenshots: Boolean(session == null ? void 0 : session.screenshots)
1718
+ });
1719
+ await enqueueIssueUploads({
1720
+ videoQueue: this.uploadQueues.video,
1721
+ attachmentQueue: this.uploadQueues.attachments,
1722
+ issueId: createdIssue.id,
1723
+ projectKey: this.state.requireProjectId(),
1724
+ video: videoFile && !videoExcluded ? { blob: videoFile, mimeType: normalizeUploadMimeType(videoFile.type, "video") } : null,
1725
+ attachments
1529
1726
  });
1530
1727
  this.clearCaptureListeners();
1531
1728
  this.clearPendingMedia();
@@ -1536,26 +1733,44 @@ class HeadlessClientImpl {
1536
1733
  formSchema: null,
1537
1734
  issues: [createdIssue, ...this.snapshot.issues]
1538
1735
  });
1539
- return createdIssue;
1736
+ return withExclusions(createdIssue, { videoExcluded, screenshotExcluded });
1540
1737
  } catch (error) {
1541
1738
  const nextError = this.toHeadlessError(error);
1542
1739
  this.state.setSnapshot({ phase: "draftingIssue", error: nextError });
1543
1740
  throw nextError;
1544
1741
  }
1545
1742
  }
1743
+ retryVideoUpload(issueId) {
1744
+ this.uploadQueues.retry(issueId);
1745
+ }
1546
1746
  async createIssue(input) {
1547
1747
  var _a, _b;
1748
+ const onError = input == null ? void 0 : input.onError;
1749
+ let created = null;
1548
1750
  try {
1549
- if (!((_a = input.title) == null ? void 0 : _a.trim()) || !((_b = input.description) == null ? void 0 : _b.trim())) {
1550
- return this.skipCreateIssue("제목과 설명이 모두 필요합니다.");
1751
+ if (!((_a = input == null ? void 0 : input.title) == null ? void 0 : _a.trim()) || !((_b = input.description) == null ? void 0 : _b.trim())) {
1752
+ return this.skipCreateIssue(
1753
+ new IssueStickerHeadlessError("VALIDATION_ERROR", "제목과 설명이 모두 필요합니다."),
1754
+ onError
1755
+ );
1551
1756
  }
1552
1757
  if (typeof document === "undefined" || typeof window === "undefined") {
1553
- return this.skipCreateIssue("브라우저 DOM 에서만 이슈를 만들 수 있습니다.");
1758
+ return this.skipCreateIssue(
1759
+ new IssueStickerHeadlessError(
1760
+ "VALIDATION_ERROR",
1761
+ "브라우저 DOM 에서만 이슈를 만들 수 있습니다."
1762
+ ),
1763
+ onError
1764
+ );
1554
1765
  }
1555
1766
  await this.ensureInitialized();
1556
1767
  if (!this.autoIssueRateLimit.claim()) {
1557
1768
  return this.skipCreateIssue(
1558
- `${this.autoIssueRateLimit.label}까지 호출할 수 있습니다. 호출 조건을 다시 확인해주세요.`
1769
+ new IssueStickerHeadlessError(
1770
+ "RATE_LIMITED",
1771
+ `${this.autoIssueRateLimit.label}까지 호출할 수 있습니다. 호출 조건을 다시 확인해주세요.`
1772
+ ),
1773
+ onError
1559
1774
  );
1560
1775
  }
1561
1776
  const target = input.target ?? document.body;
@@ -1565,18 +1780,21 @@ class HeadlessClientImpl {
1565
1780
  sessionReplayEnabled: Boolean(sessionReplay.enabled && sessionReplay.canAttach)
1566
1781
  });
1567
1782
  const logs = this.logCollectors.collect();
1568
- const screenshotPreviewUrls = input.media === "none" ? [] : await this.logCollectors.pauseNetworkWhile(
1569
- () => captureHeadlessScreenshots({ target, excludeSelectors: this.getExcludeSelectors() })
1570
- );
1783
+ const captured = input.media === "none" ? NO_HEADLESS_SCREENSHOTS : await this.captureScreenshots(target, await this.freshCaptureSelectors());
1571
1784
  const formSchema = this.snapshot.auth.mode === "member" && Object.keys(input.dynamicFields ?? {}).length > 0 ? await this.loadFormSchema({}) : null;
1572
- const createdIssue = await submitHeadlessIssueDraft({
1785
+ const {
1786
+ issue: createdIssue,
1787
+ attachments,
1788
+ screenshotExcluded
1789
+ } = await submitHeadlessIssueDraft({
1573
1790
  draft: {
1574
1791
  ...draft,
1575
- screenshotPreviewUrls,
1792
+ screenshotPreviewUrls: [],
1576
1793
  title: input.title,
1577
1794
  description: input.description,
1578
1795
  dynamicFields: input.dynamicFields ?? {}
1579
1796
  },
1797
+ target,
1580
1798
  pendingVideoFile: null,
1581
1799
  transport: this.transport,
1582
1800
  issueRepository: this.issueRepository,
@@ -1587,28 +1805,72 @@ class HeadlessClientImpl {
1587
1805
  logs,
1588
1806
  currentUrl: getCurrentPageUrl(),
1589
1807
  userAgent: typeof navigator !== "undefined" ? navigator.userAgent : void 0,
1590
- uploadSessionReplaySnapshot: () => this.uploadSessionReplaySnapshot(),
1591
- getSocketConnections: () => this.logCollectors.getSocketConnections()
1808
+ screenshots: captured.screenshots,
1809
+ screenshotsDroppedByMasking: captured.maskingDropped,
1810
+ sessionReplay: this.submitReplay(),
1811
+ getSocketConnections: () => this.logCollectors.getSocketConnections(),
1812
+ maskingPolicy: this.livePolicySource,
1813
+ // 미리보기가 없어 다시 찍지 않는다. 다음 호출이 새 규칙을 덮도록 리플레이 녹화만 새로 시작한다.
1814
+ onScreenRulesChanged: () => void this.sessionReplayController.restartRecording(),
1815
+ recapturesScreenshots: false
1816
+ });
1817
+ created = withExclusions(createdIssue, { screenshotExcluded });
1818
+ await enqueueIssueUploads({
1819
+ videoQueue: this.uploadQueues.video,
1820
+ attachmentQueue: this.uploadQueues.attachments,
1821
+ issueId: createdIssue.id,
1822
+ projectKey: this.state.requireProjectId(),
1823
+ video: null,
1824
+ attachments
1592
1825
  });
1593
1826
  this.state.setSnapshot({
1594
1827
  issues: [createdIssue, ...this.snapshot.issues]
1595
1828
  });
1596
- return createdIssue;
1829
+ return created;
1597
1830
  } catch (error) {
1598
- return this.skipCreateIssue("이슈를 생성하지 못했습니다.", error);
1831
+ if (created) {
1832
+ console.warn(
1833
+ ISSUE_STICKER_INTERNAL_LOG_PREFIX,
1834
+ "[headless] createIssue: 이슈를 생성한 뒤 후속 처리에 실패했습니다.",
1835
+ error
1836
+ );
1837
+ return created;
1838
+ }
1839
+ const replay = this.submitReplay();
1840
+ const mapped = toHeadlessMaskingError(error, {
1841
+ onScreenRulesChanged: () => replay.restartRecording(),
1842
+ replayRecording: replay.isRecording(),
1843
+ recapturesScreenshots: false,
1844
+ restartReplay: () => replay.restartRecording()
1845
+ });
1846
+ return this.skipCreateIssue(this.toHeadlessError(mapped), onError);
1599
1847
  }
1600
1848
  }
1601
1849
  /**
1602
- * createIssue 가 이슈를 만들지 않고 끝난 이유를 남긴다.
1850
+ * createIssue 가 이슈를 만들지 않고 끝난 이유를 남기고 onError 로 넘긴다.
1603
1851
  *
1604
1852
  * devLogger 는 프로덕션 빌드에서 침묵하는데, 이 실패는 예외로 드러나지 않으므로
1605
1853
  * 통합한 쪽이 프로덕션에서도 알아야 한다. 접두어가 붙어 있어 우리 수집기가
1606
1854
  * 자기 로그로 걸러내므로 고객 이슈의 콘솔 로그를 오염시키지 않는다.
1855
+ * onError 가 던져도 createIssue 는 던지지 않는다 — 전역 핸들러 안에서 부른 경우 루프가 된다.
1607
1856
  */
1608
- skipCreateIssue(reason, cause) {
1609
- const args = [ISSUE_STICKER_INTERNAL_LOG_PREFIX, "[headless] createIssue:", reason];
1610
- if (cause !== void 0) args.push(cause);
1857
+ skipCreateIssue(error, onError) {
1858
+ const args = [
1859
+ ISSUE_STICKER_INTERNAL_LOG_PREFIX,
1860
+ "[headless] createIssue:",
1861
+ error.message
1862
+ ];
1863
+ if (error.cause !== void 0) args.push(error.cause);
1611
1864
  console.warn(...args);
1865
+ try {
1866
+ onError == null ? void 0 : onError(error);
1867
+ } catch (callbackError) {
1868
+ console.warn(
1869
+ ISSUE_STICKER_INTERNAL_LOG_PREFIX,
1870
+ "[headless] createIssue onError:",
1871
+ callbackError
1872
+ );
1873
+ }
1612
1874
  return null;
1613
1875
  }
1614
1876
  async loginAsMember() {
@@ -1642,6 +1904,8 @@ class HeadlessClientImpl {
1642
1904
  void this.sessionReplayController.refresh();
1643
1905
  }
1644
1906
  destroy() {
1907
+ this.destroyed = true;
1908
+ this.uploadQueues.stop();
1645
1909
  this.clearCaptureListeners();
1646
1910
  this.suppressNextVideoStopSelection = true;
1647
1911
  this.captureSession = null;
@@ -1651,8 +1915,35 @@ class HeadlessClientImpl {
1651
1915
  this.logCollectors.stop();
1652
1916
  this.autoIssueRateLimit.reset();
1653
1917
  this.authBridge.destroy();
1918
+ this.maskingPolicy = this.createMaskingPolicy();
1919
+ this.maskingPolicyPrefetch = null;
1920
+ this.maskingPolicyFetchedAt = null;
1654
1921
  this.state.reset();
1655
1922
  }
1923
+ createMaskingPolicy() {
1924
+ const inner = createMaskingPolicySource({
1925
+ // 제출 직전 재확인이 브라우저 캐시의 옛 버전을 받으면 409 를 부른다.
1926
+ fetchSnapshot: () => this.transport.request("/api/sdk/masking-policy", { cache: "no-store" }),
1927
+ cache: createMemoryMaskingPolicyCache()
1928
+ });
1929
+ const source = {
1930
+ getMaskingPolicySnapshot: () => inner.getMaskingPolicySnapshot(),
1931
+ refreshMaskingPolicySnapshot: async (options) => {
1932
+ const snapshot = await inner.refreshMaskingPolicySnapshot(options);
1933
+ if (this.maskingPolicy === source) this.maskingPolicyFetchedAt = Date.now();
1934
+ return snapshot;
1935
+ }
1936
+ };
1937
+ return source;
1938
+ }
1939
+ /** 정책을 미리 받는다. 녹화 시작은 캐시가 없으면 이 요청을 기다린다. */
1940
+ prefetchMaskingPolicy() {
1941
+ const prefetch = this.maskingPolicy.refreshMaskingPolicySnapshot({
1942
+ timeoutMs: MASKING_POLICY_PREFETCH_TIMEOUT_MS
1943
+ });
1944
+ prefetch.catch(() => void 0);
1945
+ this.maskingPolicyPrefetch = prefetch;
1946
+ }
1656
1947
  async initializeInternal() {
1657
1948
  this.state.setSnapshot({ phase: "initializing", error: null });
1658
1949
  try {
@@ -1675,6 +1966,12 @@ class HeadlessClientImpl {
1675
1966
  filterSchema: { groups: [], unavailableReason: "public-mode" }
1676
1967
  });
1677
1968
  this.logCollectors.start();
1969
+ this.prefetchMaskingPolicy();
1970
+ const { video, attachments } = this.uploadQueues;
1971
+ void this.uploadQueues.resume([result.projectId]).then(() => {
1972
+ void video.pruneExpired();
1973
+ void attachments.pruneExpired();
1974
+ });
1678
1975
  this.authBridge.mountHiddenBridgeFrame();
1679
1976
  void this.sessionReplayController.initialize();
1680
1977
  } catch (error) {
@@ -1687,28 +1984,97 @@ class HeadlessClientImpl {
1687
1984
  const sessionReplay = this.sessionReplayController.getSnapshot();
1688
1985
  if (!sessionReplay.enabled || !sessionReplay.canAttach) return null;
1689
1986
  try {
1690
- const snapshot = await this.sessionReplayController.takeSnapshot();
1691
- return snapshot ? createSessionReplayMeta(snapshot) : null;
1987
+ const snapshot = await this.sessionReplayController.takeSnapshot([]);
1988
+ return snapshot ? toSessionReplayMeta(snapshot) : null;
1692
1989
  } catch {
1693
1990
  return null;
1694
1991
  }
1695
1992
  }
1696
- async uploadSessionReplaySnapshot() {
1697
- const sessionReplay = this.sessionReplayController.getSnapshot();
1698
- if (!sessionReplay.enabled || !sessionReplay.canAttach) return null;
1699
- try {
1700
- const snapshot = await this.sessionReplayController.takeSnapshot();
1701
- if (!snapshot) return null;
1702
- if (snapshot.eventCount <= 0) return null;
1703
- const upload = await this.transport.uploadSignedMedia(createReplayFile(snapshot), "replay");
1993
+ /**
1994
+ * 제출이 사용하는 리플레이 녹화. 꺼졌거나 한도를 넘으면 메모와 스냅샷이 null 이다. 스냅샷의
1995
+ * 마스킹 실패는 삼키지 않는다 — submitDraft 가 이슈 생성을 멈추고 CAPTURE_FAILED 로 바꾼다.
1996
+ */
1997
+ submitReplay() {
1998
+ const controller = this.sessionReplayController;
1999
+ const attached = () => {
2000
+ const sessionReplay = controller.getSnapshot();
2001
+ return sessionReplay.enabled && sessionReplay.canAttach;
2002
+ };
2003
+ return {
2004
+ whenRestarted: () => controller.whenRestarted(),
2005
+ getMaskMemoSelectors: () => attached() ? controller.getMaskMemoSelectors() : null,
2006
+ takeSnapshot: async (maskSelectors) => attached() ? controller.takeSnapshot(maskSelectors) : null,
2007
+ isRecording: () => controller.isRecording(),
2008
+ restartRecording: () => void controller.restartRecording()
2009
+ };
2010
+ }
2011
+ /**
2012
+ * 다른 워크스페이스·프로젝트 컨텍스트로 바뀌면 정책을 새로 받고, 그 정책 선택자로 리플레이 녹화를
2013
+ * 새로 시작한다.
2014
+ */
2015
+ applyBridgeToken(result) {
2016
+ const { tenantId, projectId } = this.snapshot.auth;
2017
+ this.state.applyBridgeToken(result);
2018
+ const next = this.snapshot.auth;
2019
+ if (tenantId !== null && (next.tenantId !== tenantId || next.projectId !== projectId)) {
2020
+ this.maskingPolicy = this.createMaskingPolicy();
2021
+ this.maskingPolicyFetchedAt = null;
2022
+ this.prefetchMaskingPolicy();
2023
+ void this.sessionReplayController.restartRecording();
2024
+ }
2025
+ }
2026
+ /**
2027
+ * 스티커 목록에는 영상 URL 도 업로드를 포기한 까닭도 없다. 업로드가 끝날 때 받아 둔 값을 같은
2028
+ * 이슈에 남긴다. 까닭은 여전히 실패 상태일 때만 남긴다.
2029
+ */
2030
+ keepKnownVideos(issues) {
2031
+ const known = new Map(this.snapshot.issues.map((issue) => [issue.id, issue]));
2032
+ return issues.map((issue) => {
2033
+ const previous = known.get(issue.id);
2034
+ const video = !issue.video ? previous == null ? void 0 : previous.video : void 0;
2035
+ const reason = issue.video_status === "failed" ? previous == null ? void 0 : previous.video_failure_reason : void 0;
2036
+ if (!video && !reason) return issue;
1704
2037
  return {
1705
- uploadId: upload.id,
1706
- url: upload.url,
1707
- meta: createSessionReplayMeta(snapshot)
2038
+ ...issue,
2039
+ ...video ? { video } : {},
2040
+ ...reason ? { video_failure_reason: reason } : {}
1708
2041
  };
1709
- } catch {
1710
- return null;
1711
- }
2042
+ });
2043
+ }
2044
+ /**
2045
+ * 업로드가 끝나면 목록·상세의 같은 이슈에 영상 상태를 반영한다. 완료는 재조회해 영상 URL 까지
2046
+ * 채우고, 재조회가 안 되면(config.user 없는 게스트 등) 상태만 고친다.
2047
+ */
2048
+ async handleVideoUploadEvent(event) {
2049
+ if (event.type === "failed" || this.destroyed) return;
2050
+ const refreshed = event.type === "completed" ? await this.issueRepository.openIssue(event.issueId).catch(() => null) : null;
2051
+ if (this.destroyed) return;
2052
+ const videoStatus = event.type === "completed" ? "ready" : "failed";
2053
+ const failure = event.reason ? { video_failure_reason: event.reason } : {};
2054
+ const { issues, activeIssue } = this.snapshot;
2055
+ this.state.setSnapshot({
2056
+ issues: issues.map(
2057
+ (issue) => issue.id === event.issueId ? {
2058
+ ...issue,
2059
+ video: (refreshed == null ? void 0 : refreshed.video) ?? issue.video,
2060
+ video_status: videoStatus,
2061
+ ...failure
2062
+ } : issue
2063
+ ),
2064
+ activeIssue: (activeIssue == null ? void 0 : activeIssue.id) === event.issueId ? refreshed ?? { ...activeIssue, video_status: videoStatus, ...failure } : activeIssue
2065
+ });
2066
+ }
2067
+ /** 리플레이가 연결되거나 포기되면 목록·상세의 같은 이슈에 상태를 반영한다. 소켓 전사는 상태가 없다. */
2068
+ handleAttachmentUploadEvent(event) {
2069
+ if (event.mediaType !== "replay" || event.type === "failed" || this.destroyed) return;
2070
+ const replayStatus = event.type === "completed" ? "ready" : "failed";
2071
+ const { issues, activeIssue } = this.snapshot;
2072
+ this.state.setSnapshot({
2073
+ issues: issues.map(
2074
+ (issue) => issue.id === event.issueId ? { ...issue, replay_status: replayStatus } : issue
2075
+ ),
2076
+ activeIssue: (activeIssue == null ? void 0 : activeIssue.id) === event.issueId ? { ...activeIssue, replay_status: replayStatus } : activeIssue
2077
+ });
1712
2078
  }
1713
2079
  async handleRecordedVideoStopped(file) {
1714
2080
  if (this.suppressNextVideoStopSelection) {
@@ -1778,15 +2144,16 @@ class HeadlessClientImpl {
1778
2144
  this.beginBackgroundCapturePreparation(draft, target);
1779
2145
  }
1780
2146
  beginBackgroundCapturePreparation(draft, target) {
1781
- var _a, _b;
2147
+ var _a;
1782
2148
  const token = {};
1783
2149
  this.captureSession = {
1784
2150
  token,
2151
+ target,
1785
2152
  screenshots: draft.mediaMode === "screenshot" ? (
1786
- // 스크린샷은 웹폰트·이미지를 fetch 로 인라인한다. 그 사이 수집을 멈추지 않으면
1787
- // 최근 50건이 그 요청들로 채워져 진짜 앱 요청이 밀려난다.
1788
- this.logCollectors.pauseNetworkWhile(
1789
- () => captureHeadlessScreenshots({ target, excludeSelectors: this.getExcludeSelectors() })
2153
+ // 영역은 초기화 때 받아 둔 정책 캐시의 선택자로 계산한다. 캐시가 없으면 빈 목록이고,
2154
+ // 미리보기를 칠할 때 덮개 검사에 걸려 한 번 다시 찍는다.
2155
+ loadCaptureSelectors(() => this.livePolicySource.getMaskingPolicySnapshot()).then(
2156
+ (selectors) => this.captureScreenshots(target, selectors ?? [])
1790
2157
  )
1791
2158
  ) : null,
1792
2159
  formSchema: this.loadFormSchema({})
@@ -1795,11 +2162,10 @@ class HeadlessClientImpl {
1795
2162
  var _a2;
1796
2163
  return ((_a2 = this.captureSession) == null ? void 0 : _a2.token) === token;
1797
2164
  };
1798
- void ((_a = this.captureSession.screenshots) == null ? void 0 : _a.then((screenshotPreviewUrls) => {
1799
- if (!isCurrentSession()) return;
1800
- this.mergeDraft({ screenshotPreviewUrls });
1801
- }));
1802
- void ((_b = this.captureSession.formSchema) == null ? void 0 : _b.then((formSchema) => {
2165
+ if (this.captureSession.screenshots) {
2166
+ this.publishPreview(this.captureSession.screenshots, true);
2167
+ }
2168
+ void ((_a = this.captureSession.formSchema) == null ? void 0 : _a.then((formSchema) => {
1803
2169
  if (!isCurrentSession()) return;
1804
2170
  this.state.setSnapshot({ formSchema });
1805
2171
  }));
@@ -1812,6 +2178,64 @@ class HeadlessClientImpl {
1812
2178
  });
1813
2179
  }
1814
2180
  }
2181
+ /**
2182
+ * 화면 규칙이 바뀌어 지금 원본을 쓸 수 없다. 같은 대상을 다시 찍어 미리보기를 바꾼다.
2183
+ *
2184
+ * 선택자는 캐시가 아니라 제출과 같은 방식으로 다시 받아 읽는다. 받은 스냅샷의 캐시 쓰기는
2185
+ * 기다리지 않고 떠나므로 캐시만 읽으면 바뀌기 전 선택자로 찍을 수 있다. 정책을 받지 못하면
2186
+ * 스크린샷 없이 두고, 마스킹 정책 때문에 버렸다고 표시해 제출 때 알린다.
2187
+ */
2188
+ recaptureScreenshots(autoRecapture = true) {
2189
+ const session = this.captureSession;
2190
+ if (!(session == null ? void 0 : session.screenshots)) return;
2191
+ const screenshots = this.freshCaptureSelectors().then(
2192
+ (selectors) => this.captureScreenshots(session.target, selectors),
2193
+ () => ({ screenshots: [], maskingDropped: true })
2194
+ );
2195
+ this.captureSession = { ...session, screenshots };
2196
+ this.mergeDraft({ screenshotPreviewUrls: [] });
2197
+ this.publishPreview(screenshots, autoRecapture);
2198
+ }
2199
+ /**
2200
+ * 원본이 준비되면 칠한 미리보기를 draft 에 싣는다. 칠하지 못하면 원본을 내보내지 않고 비워 둔다.
2201
+ *
2202
+ * 덮개 검사에 걸리면(캐시 없이 빈 선택자로 찍었거나 그 사이 정책이 바뀌었다) 그대로 두면 첫 제출이
2203
+ * 반드시 실패하므로 같은 대상을 한 번 다시 찍는다. 다시 찍은 캡처는 또 다시 찍지 않는다.
2204
+ */
2205
+ publishPreview(screenshots, autoRecapture) {
2206
+ const isCurrent = () => {
2207
+ var _a;
2208
+ return ((_a = this.captureSession) == null ? void 0 : _a.screenshots) === screenshots;
2209
+ };
2210
+ void screenshots.then((captured) => this.previewScreenshots(captured.screenshots)).then(
2211
+ (screenshotPreviewUrls) => {
2212
+ if (isCurrent()) this.mergeDraft({ screenshotPreviewUrls });
2213
+ },
2214
+ (error) => {
2215
+ if (isCurrent() && autoRecapture && error instanceof MaskingScreenRulesChangedError) {
2216
+ this.recaptureScreenshots(false);
2217
+ }
2218
+ }
2219
+ );
2220
+ }
2221
+ /** 제출과 같은 방식(짧게 다시 받고 실패하면 캐시)으로 읽은 정책 선택자. 둘 다 없으면 실패한다. */
2222
+ async freshCaptureSelectors() {
2223
+ return collectSnapshotSelectors(await loadMaskingPolicyForSubmit(this.livePolicySource));
2224
+ }
2225
+ captureScreenshots(target, maskSelectors) {
2226
+ return this.logCollectors.pauseNetworkWhile(
2227
+ () => captureHeadlessScreenshots({
2228
+ target,
2229
+ excludeSelectors: this.getExcludeSelectors(),
2230
+ maskSelectors
2231
+ })
2232
+ );
2233
+ }
2234
+ async previewScreenshots(shots) {
2235
+ const snapshot = await loadMaskingPolicyForSubmit(this.livePolicySource);
2236
+ const rules = resolveEffectivePolicy(snapshot, this.state.requireProjectId());
2237
+ return maskScreenshotsForSubmit(shots, rules);
2238
+ }
1815
2239
  mergeDraft(patch) {
1816
2240
  const draft = this.snapshot.draft;
1817
2241
  if (!draft) return;