@apps-in-toss/devtools 3.0.5 → 3.1.0-beta.1

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,30 +1,3 @@
1
- //#region src/mock/deep-merge.ts
2
- function isPlainObject(value) {
3
- return typeof value === "object" && value !== null && !Array.isArray(value);
4
- }
5
- function mergeValue(base, override) {
6
- if (override === void 0) return base;
7
- if (Array.isArray(override)) return override;
8
- if (isPlainObject(base) && isPlainObject(override)) {
9
- const result = { ...base };
10
- for (const key of Object.keys(override)) {
11
- if (!(key in base)) continue;
12
- result[key] = mergeValue(base[key], override[key]);
13
- }
14
- return result;
15
- }
16
- return override;
17
- }
18
- /**
19
- * `base`(보통 `DEFAULT_STATE`)에 `override`를 재귀 병합한 새 객체를
20
- * 반환한다. `base`는 변형하지 않는다(불변). `override`가 `undefined`이면
21
- * `base`를 그대로 반환한다.
22
- */
23
- function deepMergeState(base, override) {
24
- if (override === void 0) return base;
25
- return mergeValue(base, override);
26
- }
27
- //#endregion
28
1
  //#region src/mock/throttle-registry.ts
29
2
  /**
30
3
  * throttle 레지스트리 (devtools#834).
@@ -54,10 +27,6 @@ function resetThrottleRegistry() {
54
27
  }
55
28
  //#endregion
56
29
  //#region src/mock/state.ts
57
- /**
58
- * @apps-in-toss/devtools 중앙 상태 관리
59
- * DevTools Panel과 mock 구현체가 이 상태를 공유한다.
60
- */
61
30
  /** SDK 호출 로그 ring buffer 상한 */
62
31
  const SDK_CALL_LOG_MAX = 200;
63
32
  const DEFAULT_STATE = {
@@ -117,14 +86,6 @@ const DEFAULT_STATE = {
117
86
  displayAmount: "1,000원",
118
87
  iconUrl: "",
119
88
  description: "게임에서 사용할 수 있는 보석 100개"
120
- }, {
121
- sku: "mock-sub-monthly",
122
- type: "SUBSCRIPTION",
123
- displayName: "월간 구독",
124
- displayAmount: "4,900원",
125
- iconUrl: "",
126
- description: "매달 자동으로 갱신되는 구독 상품",
127
- renewalCycle: "MONTHLY"
128
89
  }],
129
90
  nextResult: "success",
130
91
  pendingOrders: [],
@@ -187,22 +148,6 @@ const DEFAULT_STATE = {
187
148
  },
188
149
  failureModes: { sdkLine: "2.x" }
189
150
  };
190
- /**
191
- * `initialState` unplugin 옵션(Vite `define`로 주입된 `__AIT_INITIAL_STATE__`)이
192
- * 있으면 `DEFAULT_STATE`에 재귀 병합한 결과를, 없으면 `DEFAULT_STATE`를 그대로
193
- * 반환한다. devtools 자체 빌드/테스트에는 이 define이 없으므로
194
- * `typeof` 가드로 ReferenceError를 막는다(env.d.ts 참고).
195
- *
196
- * 모듈 로드 시 한 번만 계산해 생성자·`reset()` 양쪽에서 재사용한다 — `reset()`도
197
- * 이 병합된 값으로 되돌아가야, 소비자가 선언한 커스텀 카탈로그가 패널의
198
- * "초기화" 이후에도 사라지지 않는다(초기화는 "SDK 기본값"이 아니라 "이
199
- * 프로젝트가 선언한 기본값"으로 돌아가는 것이 사용자 기대에 맞다).
200
- */
201
- function resolveDefaultState() {
202
- const override = typeof __AIT_INITIAL_STATE__ !== "undefined" ? __AIT_INITIAL_STATE__ : void 0;
203
- return override ? deepMergeState(DEFAULT_STATE, override) : DEFAULT_STATE;
204
- }
205
- const RESOLVED_DEFAULT_STATE = resolveDefaultState();
206
151
  function generateDeviceId() {
207
152
  const stored = localStorage.getItem("__ait_device_id");
208
153
  if (stored) return stored;
@@ -215,7 +160,7 @@ var AitStateManager = class {
215
160
  _listeners = /* @__PURE__ */ new Set();
216
161
  _inTransaction = false;
217
162
  constructor() {
218
- this._state = structuredClone(RESOLVED_DEFAULT_STATE);
163
+ this._state = structuredClone(DEFAULT_STATE);
219
164
  try {
220
165
  this._state.deviceId = generateDeviceId();
221
166
  } catch {
@@ -311,7 +256,7 @@ var AitStateManager = class {
311
256
  reset() {
312
257
  const deviceId = this._state.deviceId;
313
258
  this._state = {
314
- ...structuredClone(RESOLVED_DEFAULT_STATE),
259
+ ...structuredClone(DEFAULT_STATE),
315
260
  deviceId
316
261
  };
317
262
  resetThrottleRegistry();
@@ -328,6 +273,83 @@ if (!globalRef[SINGLETON_KEY]) globalRef[SINGLETON_KEY] = new AitStateManager();
328
273
  const aitState = globalRef[SINGLETON_KEY];
329
274
  if (typeof window !== "undefined") window.__ait = aitState;
330
275
  //#endregion
276
+ //#region src/mock/device/_helpers.ts
277
+ /**
278
+ * 디바이스 모듈 내부 공유 헬퍼
279
+ */
280
+ function generatePlaceholderImage(width, height, text, color) {
281
+ const canvas = document.createElement("canvas");
282
+ canvas.width = width;
283
+ canvas.height = height;
284
+ const ctx = canvas.getContext("2d");
285
+ if (!ctx) {
286
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect fill="${color}" width="${width}" height="${height}"/><text x="50%" y="50%" fill="white" font-size="16" text-anchor="middle" dominant-baseline="middle">${text}</text></svg>`;
287
+ return `data:image/svg+xml;base64,${btoa(svg)}`;
288
+ }
289
+ ctx.fillStyle = color;
290
+ ctx.fillRect(0, 0, width, height);
291
+ ctx.fillStyle = "white";
292
+ ctx.font = "16px sans-serif";
293
+ ctx.textAlign = "center";
294
+ ctx.textBaseline = "middle";
295
+ ctx.fillText(text, width / 2, height / 2);
296
+ return canvas.toDataURL("image/png");
297
+ }
298
+ const DEFAULT_PLACEHOLDERS = [
299
+ {
300
+ text: "Mock Photo 1",
301
+ color: "#3182F6"
302
+ },
303
+ {
304
+ text: "Mock Photo 2",
305
+ color: "#27ae60"
306
+ },
307
+ {
308
+ text: "Mock Photo 3",
309
+ color: "#e67e22"
310
+ }
311
+ ];
312
+ let cachedPlaceholders = null;
313
+ function getDefaultPlaceholderImages() {
314
+ if (!cachedPlaceholders) cachedPlaceholders = DEFAULT_PLACEHOLDERS.map((p) => generatePlaceholderImage(320, 240, p.text, p.color));
315
+ return [...cachedPlaceholders];
316
+ }
317
+ /** @internal device 모듈 내부 전용 */
318
+ function getMockImages() {
319
+ const images = aitState.state.mockData.images;
320
+ if (images.length > 0) return images;
321
+ return getDefaultPlaceholderImages();
322
+ }
323
+ const PROMPT_TIMEOUT_MS = 3e4;
324
+ /** @internal device 모듈 내부 전용 */
325
+ function waitForPromptResponse(type) {
326
+ return new Promise((resolve, reject) => {
327
+ const eventName = `__ait:prompt-response:${type}`;
328
+ const cancelName = "__ait:prompt-cancel";
329
+ function cleanup() {
330
+ clearTimeout(timer);
331
+ window.removeEventListener(eventName, handler);
332
+ window.removeEventListener(cancelName, cancelHandler);
333
+ }
334
+ const timer = setTimeout(() => {
335
+ cleanup();
336
+ const hint = !!document.querySelector(".ait-panel") ? "Please provide input via the DevTools panel." : "Is @apps-in-toss/devtools/panel imported?";
337
+ reject(/* @__PURE__ */ new Error(`[@apps-in-toss/devtools] Prompt timeout for "${type}" after ${PROMPT_TIMEOUT_MS / 1e3}s. ${hint}`));
338
+ }, PROMPT_TIMEOUT_MS);
339
+ const handler = (e) => {
340
+ cleanup();
341
+ resolve(e.detail);
342
+ };
343
+ const cancelHandler = () => {
344
+ cleanup();
345
+ reject(/* @__PURE__ */ new Error(`[@apps-in-toss/devtools] Prompt cancelled for "${type}"`));
346
+ };
347
+ window.addEventListener(eventName, handler);
348
+ window.addEventListener(cancelName, cancelHandler);
349
+ window.dispatchEvent(new CustomEvent("__ait:prompt-request", { detail: { type } }));
350
+ });
351
+ }
352
+ //#endregion
331
353
  //#region src/mock/native-error.ts
332
354
  /**
333
355
  * 실패-모드 다이얼 — 네이티브 에러 envelope 조립 (devtools#770).
@@ -428,118 +450,313 @@ function buildNativeError(code) {
428
450
  return err;
429
451
  }
430
452
  //#endregion
431
- //#region src/mock/observe.ts
453
+ //#region src/mock/permissions.ts
432
454
  /**
433
- * fn을 observe로 감싼다.
434
- *
435
- * @param apiName - 로그에 기록할 SDK 메서드 이름 (예: `'setScreenAwakeMode'`)
436
- * @param fidelity - 이 mock의 fidelity grade ('faithful' | 'partial' | 'inert')
437
- * @param fn - 실제 mock 구현체. 시그니처를 그대로 통과시킨다.
438
- * @returns fn과 동일한 타입의 래퍼 함수
455
+ * web-framework 3.0+ 권한 에러 기반 클래스.
456
+ * `instanceof PermissionError`로 체크하는 코드와 호환된다.
439
457
  */
440
- function observe(apiName, fidelity, fn) {
441
- return (...args) => {
442
- const timestamp = Date.now();
443
- const safeArgs = args.map((a) => safeSerialize(a));
444
- const result = fn(...args);
445
- if (result instanceof Promise) {
446
- aitState.logSdkCall({
447
- method: apiName,
448
- args: safeArgs,
449
- timestamp,
450
- status: "pending",
451
- fidelity
452
- });
453
- result.then((value) => {
454
- aitState.logSdkCall({
455
- method: apiName,
456
- args: safeArgs,
457
- timestamp,
458
- status: "resolved",
459
- result: safeSerialize(value),
460
- fidelity
461
- });
462
- }, (err) => {
463
- aitState.logSdkCall({
464
- method: apiName,
465
- args: safeArgs,
466
- timestamp,
467
- status: "rejected",
468
- error: err instanceof Error ? err.message : String(err),
469
- fidelity
470
- });
471
- });
472
- return result;
473
- }
474
- aitState.logSdkCall({
475
- method: apiName,
476
- args: safeArgs,
477
- timestamp,
478
- status: "resolved",
479
- result: safeSerialize(result),
480
- fidelity
481
- });
482
- return result;
483
- };
458
+ var PermissionError = class extends Error {
459
+ constructor({ methodName, message }) {
460
+ super(message ?? `${methodName}: permission denied`);
461
+ this.name = `${methodName}PermissionError`;
462
+ }
463
+ };
464
+ /** openCamera 권한 에러 */
465
+ var OpenCameraPermissionError = class extends PermissionError {
466
+ constructor() {
467
+ super({ methodName: "openCamera" });
468
+ }
469
+ };
470
+ /** fetchAlbumPhotos 권한 에러 */
471
+ var FetchAlbumPhotosPermissionError = class extends PermissionError {
472
+ constructor() {
473
+ super({ methodName: "fetchAlbumPhotos" });
474
+ }
475
+ };
476
+ /** fetchContacts 권한 에러 */
477
+ var FetchContactsPermissionError = class extends PermissionError {
478
+ constructor() {
479
+ super({ methodName: "fetchContacts" });
480
+ }
481
+ };
482
+ /** getCurrentLocation 권한 에러 */
483
+ var GetCurrentLocationPermissionError = class extends PermissionError {
484
+ constructor() {
485
+ super({ methodName: "getCurrentLocation" });
486
+ }
487
+ };
488
+ /** getClipboardText 권한 에러 */
489
+ var GetClipboardTextPermissionError = class extends PermissionError {
490
+ constructor() {
491
+ super({ methodName: "getClipboardText" });
492
+ }
493
+ };
494
+ /** setClipboardText 권한 에러 */
495
+ var SetClipboardTextPermissionError = class extends PermissionError {
496
+ constructor() {
497
+ super({ methodName: "setClipboardText" });
498
+ }
499
+ };
500
+ /**
501
+ * startUpdateLocation 권한 에러.
502
+ * web-framework 3.0에서 GetCurrentLocationPermissionError의 alias.
503
+ */
504
+ const StartUpdateLocationPermissionError = GetCurrentLocationPermissionError;
505
+ const permissionErrorMap = {
506
+ openCamera: OpenCameraPermissionError,
507
+ fetchAlbumPhotos: FetchAlbumPhotosPermissionError,
508
+ fetchAlbumItems: FetchAlbumPhotosPermissionError,
509
+ fetchContacts: FetchContactsPermissionError,
510
+ getCurrentLocation: GetCurrentLocationPermissionError,
511
+ getClipboardText: GetClipboardTextPermissionError,
512
+ setClipboardText: SetClipboardTextPermissionError
513
+ };
514
+ async function getPermission(permission) {
515
+ const failureCode = permissionGateCode(permission);
516
+ if (failureCode) throw buildNativeError(failureCode);
517
+ return aitState.state.permissions[permission.name];
484
518
  }
485
519
  /**
486
- * 값을 JSON-safe한 형태로 변환한다.
487
- * - null / primitive — 그대로.
488
- * - 함수`'[Function: name]'` 문자열.
489
- * - 기타 객체 — JSON.stringify 실패 시 `'[unserializable]'`.
520
+ * 선언 게이트에 걸리는 이름·access 조합이면 다이얼에 등록된 native errorCode를
521
+ * 돌려준다. 권한 API(`getPermission`/`requestPermission`/`openPermissionDialog`)가
522
+ * **걸리는 조건**은 공유하지만 **떨어지는 코드**는 공유하지 않는다 아래 참조.
490
523
  */
491
- function safeSerialize(value) {
492
- if (value === null || value === void 0) return value;
493
- if (typeof value === "function") return `[Function: ${value.name || "anonymous"}]`;
494
- if (typeof value !== "object") return value;
495
- try {
496
- return JSON.parse(JSON.stringify(value));
497
- } catch {
498
- return "[unserializable]";
499
- }
524
+ function permissionGateCode(permission) {
525
+ if (permission.access === "access") return;
526
+ return aitState.state.failureModes.getPermission?.[permission.name];
500
527
  }
501
- //#endregion
502
- //#region src/mock/proxy.ts
503
528
  /**
504
- * 미구현 API용 Proxy 트립와이어.
529
+ * `openPermissionDialog`가 선언 게이트에 걸렸을 때의 코드.
505
530
  *
506
- * 미구현 프로퍼티에 접근하면 throw한다. 이는 "devtools에서는 멀쩡히 돌지만
507
- * SDK에선 실제로 동작하는" 시나리오를 차단하기 위한 의도적 선택이다.
508
- * mock이 미구현인 API는 실 SDK에서는 존재할 수 있고, 사용자가 이를 인지하지
509
- * 못한 채 개발을 이어가면 배포 시점에 놀라게 된다. 에러 메시지에 이슈 URL을
510
- * 포함해 사용자가 mock 누락을 제보할 수 있게 한다.
531
+ * 형제 API와 갈린다 env3 run11 실측(2.x/iOS, sdk-example#313에서 시나리오 키가
532
+ * 통일되며 비교 대상에 들어온 값):
511
533
  *
512
- * ## KNOWN_UNIMPLEMENTED 정책
513
- * SDK에 존재하는 것으로 알려져 있으나 현재 mock이 없는 API 이름만 이 집합에 둔다.
514
- * 경우에만 throw 대신 🔴 inert no-op을 반환하고 sdkCallLog에 기록한다.
515
- * 완전히 미지의 이름은 여전히 throw — "잘 되는 척" 방지.
534
+ * getPermission { geolocation, read } → rejected NO_PERMISSION
535
+ * requestPermission { geolocation, read } → rejected NO_PERMISSION
536
+ * openPermissionDialog { geolocation, read } → rejected INVALID_REQUEST
537
+ * openPermissionDialog { camera, access } → resolved
538
+ *
539
+ * 즉 **걸리는 조건은 셋이 같고(access 축 모델 그대로) 코드만 갈린다.** 처음엔
540
+ * `requestPermission`이 `openPermissionDialog`에 위임하니 게이트도 위임하면
541
+ * 된다고 보고 한 지점에만 배선했는데, 그 모델은 코드 층위에서 실측과 어긋났다
542
+ * (env1 NO_PERMISSION ↔ env3 INVALID_REQUEST). 그래서 `requestPermission`은
543
+ * 위임 **전에** 자기 게이트를 먼저 타고, 다이얼로그를 여는 호출만 이 코드를 쓴다.
544
+ *
545
+ * 관측이 geolocation/read 한 조합뿐이라 이름·access별 분기는 두지 않는다 —
546
+ * 근거 없는 확장은 #783에서 이름 단위 맵을 택한 원칙에 어긋난다.
516
547
  */
517
- const ISSUES_URL = "https://github.com/apps-in-toss-community/devtools/issues";
518
- /**
519
- * SDK에 존재하나 mock이 아직 없는 것으로 확인된 이름 목록.
520
- * API가 SDK에 추가되면 여기에 추가하고 별도 PR에서 mock 구현으로 이동한다.
521
- * 확인되지 않은 이름은 절대 여기에 추가하지 않는다 — throw가 더 안전하다.
548
+ const OPEN_DIALOG_GATE_CODE = "INVALID_REQUEST";
549
+ async function openPermissionDialog(permission) {
550
+ if (permissionGateCode(permission)) throw buildNativeError(OPEN_DIALOG_GATE_CODE);
551
+ if (aitState.state.permissions[permission.name] === "allowed") return "allowed";
552
+ aitState.patch("permissions", { [permission.name]: "allowed" });
553
+ return "allowed";
554
+ }
555
+ async function requestPermission(permission) {
556
+ const failureCode = permissionGateCode(permission);
557
+ if (failureCode) throw buildNativeError(failureCode);
558
+ return openPermissionDialog(permission);
559
+ }
560
+ /** 권한이 필요한 함수에 .getPermission(), .openPermissionDialog()를 부착 */
561
+ function withPermission(fn, permissionName) {
562
+ const enhanced = fn;
563
+ enhanced.getPermission = () => getPermission({
564
+ name: permissionName,
565
+ access: "access"
566
+ });
567
+ enhanced.openPermissionDialog = () => openPermissionDialog({
568
+ name: permissionName,
569
+ access: "access"
570
+ });
571
+ return enhanced;
572
+ }
573
+ /**
574
+ * 권한 체크 후 denied면 per-API *PermissionError 서브클래스를 throw한다.
575
+ * 실 3.0 SDK 동작과 일치 — `instanceof PermissionError` 분기가 mock에서도 동작한다 (#372).
522
576
  */
523
- const KNOWN_UNIMPLEMENTED = /* @__PURE__ */ new Set([]);
524
- function createMockProxy(moduleName, implementations) {
525
- return new Proxy(implementations, { get(target, prop) {
526
- if (typeof prop === "symbol") return void 0;
527
- if (prop in target) return target[prop];
528
- const name = String(prop);
529
- if (KNOWN_UNIMPLEMENTED.has(name)) return (...args) => {
530
- console.warn(`[@apps-in-toss/devtools] ${moduleName}.${name} is known-unimplemented (🔴 inert). Returning undefined. Please file or upvote an issue: ${ISSUES_URL}`);
531
- aitState.logSdkCall({
532
- method: `${moduleName}.${name}`,
533
- args,
534
- timestamp: Date.now(),
535
- status: "resolved",
536
- result: void 0,
537
- fidelity: "inert"
577
+ function checkPermission(name, fnName) {
578
+ if (aitState.state.permissions[name] === "denied") {
579
+ const ErrorClass = permissionErrorMap[fnName];
580
+ if (ErrorClass) throw new ErrorClass();
581
+ throw new PermissionError({ methodName: fnName });
582
+ }
583
+ }
584
+ //#endregion
585
+ //#region src/mock/device/camera.ts
586
+ /**
587
+ * Camera & Album Photos & Album Items mock
588
+ * mock/web/prompt 모드 지원
589
+ */
590
+ async function openCameraMock() {
591
+ const images = getMockImages();
592
+ return {
593
+ id: crypto.randomUUID(),
594
+ dataUri: images[0]
595
+ };
596
+ }
597
+ async function openCameraWeb() {
598
+ return new Promise((resolve, reject) => {
599
+ const input = document.createElement("input");
600
+ input.type = "file";
601
+ input.accept = "image/*";
602
+ input.capture = "environment";
603
+ let settled = false;
604
+ input.onchange = () => {
605
+ settled = true;
606
+ const file = input.files?.[0];
607
+ if (!file) {
608
+ reject(/* @__PURE__ */ new Error("No file selected"));
609
+ return;
610
+ }
611
+ const reader = new FileReader();
612
+ reader.onload = () => resolve({
613
+ id: crypto.randomUUID(),
614
+ dataUri: reader.result
538
615
  });
616
+ reader.onerror = () => reject(/* @__PURE__ */ new Error("Failed to read file"));
617
+ reader.readAsDataURL(file);
539
618
  };
540
- throw new Error(`[@apps-in-toss/devtools] ${moduleName}.${prop} is not mocked. This API may exist in @apps-in-toss/web-framework, but devtools' mock does not cover it yet. Please file an issue: ${ISSUES_URL}`);
541
- } });
619
+ const onFocus = () => {
620
+ setTimeout(() => {
621
+ if (!settled) reject(/* @__PURE__ */ new Error("File picker cancelled"));
622
+ window.removeEventListener("focus", onFocus);
623
+ }, 300);
624
+ };
625
+ window.addEventListener("focus", onFocus);
626
+ input.click();
627
+ });
628
+ }
629
+ async function openCameraPrompt() {
630
+ const dataUri = await waitForPromptResponse("camera");
631
+ return {
632
+ id: crypto.randomUUID(),
633
+ dataUri
634
+ };
635
+ }
636
+ const _openCamera = async (_options) => {
637
+ checkPermission("camera", "openCamera");
638
+ const mode = aitState.state.deviceModes.camera;
639
+ if (mode === "web") return openCameraWeb();
640
+ if (mode === "prompt") return openCameraPrompt();
641
+ return openCameraMock();
642
+ };
643
+ const openCamera = withPermission(_openCamera, "camera");
644
+ async function fetchAlbumPhotosMock(maxCount) {
645
+ return getMockImages().slice(0, maxCount).map((dataUri) => ({
646
+ id: crypto.randomUUID(),
647
+ dataUri
648
+ }));
649
+ }
650
+ async function fetchAlbumPhotosWeb(maxCount) {
651
+ return new Promise((resolve, reject) => {
652
+ const input = document.createElement("input");
653
+ input.type = "file";
654
+ input.accept = "image/*";
655
+ input.multiple = true;
656
+ let settled = false;
657
+ input.onchange = async () => {
658
+ settled = true;
659
+ const files = Array.from(input.files ?? []).slice(0, maxCount);
660
+ if (files.length === 0) {
661
+ reject(/* @__PURE__ */ new Error("No files selected"));
662
+ return;
663
+ }
664
+ resolve(await Promise.all(files.map((file) => new Promise((res, rej) => {
665
+ const reader = new FileReader();
666
+ reader.onload = () => res({
667
+ id: crypto.randomUUID(),
668
+ dataUri: reader.result
669
+ });
670
+ reader.onerror = () => rej(/* @__PURE__ */ new Error("Failed to read file"));
671
+ reader.readAsDataURL(file);
672
+ }))));
673
+ };
674
+ const onFocus = () => {
675
+ setTimeout(() => {
676
+ if (!settled) reject(/* @__PURE__ */ new Error("File picker cancelled"));
677
+ window.removeEventListener("focus", onFocus);
678
+ }, 300);
679
+ };
680
+ window.addEventListener("focus", onFocus);
681
+ input.click();
682
+ });
683
+ }
684
+ async function fetchAlbumPhotosPrompt(maxCount) {
685
+ return (await waitForPromptResponse("photos")).slice(0, maxCount).map((dataUri) => ({
686
+ id: crypto.randomUUID(),
687
+ dataUri
688
+ }));
689
+ }
690
+ const _fetchAlbumPhotos = async (options) => {
691
+ checkPermission("photos", "fetchAlbumPhotos");
692
+ const maxCount = options?.maxCount ?? 10;
693
+ const mode = aitState.state.deviceModes.photos;
694
+ if (mode === "web") return fetchAlbumPhotosWeb(maxCount);
695
+ if (mode === "prompt") return fetchAlbumPhotosPrompt(maxCount);
696
+ return fetchAlbumPhotosMock(maxCount);
697
+ };
698
+ const fetchAlbumPhotos = withPermission(_fetchAlbumPhotos, "photos");
699
+ async function fetchAlbumItemsMock(maxCount, types) {
700
+ return getMockImages().slice(0, maxCount).filter(() => types.includes("PHOTO")).map((dataUri) => ({
701
+ id: crypto.randomUUID(),
702
+ dataUri,
703
+ type: "PHOTO"
704
+ }));
705
+ }
706
+ async function fetchAlbumItemsWeb(maxCount, types) {
707
+ return new Promise((resolve) => {
708
+ const input = document.createElement("input");
709
+ input.type = "file";
710
+ input.accept = types.includes("VIDEO") ? "image/*,video/*" : "image/*";
711
+ input.multiple = true;
712
+ let settled = false;
713
+ input.onchange = async () => {
714
+ settled = true;
715
+ const files = Array.from(input.files ?? []).slice(0, maxCount);
716
+ if (files.length === 0) {
717
+ resolve([]);
718
+ return;
719
+ }
720
+ resolve(await Promise.all(files.map((file) => new Promise((res, rej) => {
721
+ const itemType = file.type.startsWith("video/") ? "VIDEO" : "PHOTO";
722
+ const reader = new FileReader();
723
+ reader.onload = () => res({
724
+ id: crypto.randomUUID(),
725
+ dataUri: reader.result,
726
+ type: itemType
727
+ });
728
+ reader.onerror = () => rej(/* @__PURE__ */ new Error("Failed to read file"));
729
+ reader.readAsDataURL(file);
730
+ }))));
731
+ };
732
+ const onFocus = () => {
733
+ setTimeout(() => {
734
+ if (!settled) resolve([]);
735
+ window.removeEventListener("focus", onFocus);
736
+ }, 300);
737
+ };
738
+ window.addEventListener("focus", onFocus);
739
+ input.click();
740
+ });
542
741
  }
742
+ async function fetchAlbumItemsPrompt(maxCount) {
743
+ return (await waitForPromptResponse("photos")).slice(0, maxCount).map((dataUri) => ({
744
+ id: crypto.randomUUID(),
745
+ dataUri,
746
+ type: "PHOTO"
747
+ }));
748
+ }
749
+ const _fetchAlbumItems = async (options) => {
750
+ checkPermission("photos", "fetchAlbumItems");
751
+ const maxCount = options?.maxCount ?? 10;
752
+ const types = options?.types ?? ["PHOTO"];
753
+ const mode = aitState.state.deviceModes.photos;
754
+ if (mode === "web") return fetchAlbumItemsWeb(maxCount, types);
755
+ if (mode === "prompt") return fetchAlbumItemsPrompt(maxCount);
756
+ return fetchAlbumItemsMock(maxCount, types);
757
+ };
758
+ const _fetchAlbumItemsWithPermission = withPermission(_fetchAlbumItems, "photos");
759
+ const fetchAlbumItems = Object.assign(_fetchAlbumItemsWithPermission, { isSupported: () => true });
543
760
  //#endregion
544
761
  //#region src/mock/throttle.ts
545
762
  /**
@@ -585,1017 +802,1195 @@ function checkThrottle(method) {
585
802
  if (err) throw err;
586
803
  }
587
804
  //#endregion
588
- //#region src/mock/ads/index.ts
589
- function withIsSupported(fn) {
590
- fn.isSupported = () => true;
591
- return fn;
592
- }
593
- const _slotRegistry = /* @__PURE__ */ new Map();
594
- let _slotCounter = 0;
595
- function _nextSlotId(adGroupId) {
596
- _slotCounter += 1;
597
- return `mock-slot-${adGroupId}-${_slotCounter}`;
598
- }
599
- const GoogleAdMob$1 = createMockProxy("GoogleAdMob", {
600
- loadAppsInTossAdMob: withIsSupported(observe("GoogleAdMob.loadAppsInTossAdMob", "faithful", (args) => {
601
- setTimeout(() => {
602
- const failureCode = aitState.state.failureModes.loadAdMob;
603
- if (failureCode) {
604
- args.onError(buildNativeError(failureCode));
605
- return;
606
- }
607
- const throttleError = throttleErrorFor("loadAppsInTossAdMob");
608
- if (throttleError) {
609
- args.onError(throttleError);
610
- return;
611
- }
612
- if (aitState.state.ads.forceNoFill) {
613
- args.onError(/* @__PURE__ */ new Error("No fill"));
614
- return;
615
- }
616
- aitState.patch("ads", { isLoaded: true });
617
- args.onEvent({
618
- type: "loaded",
619
- data: {
620
- adGroupId: args.options.adGroupId,
621
- adUnitId: `mock-unit-${args.options.adGroupId}`,
622
- responseInfo: {
623
- responseId: `mock-response-${args.options.adGroupId}`,
624
- adNetworkInfoArray: [],
625
- loadedAdNetworkInfo: null
626
- }
627
- }
628
- });
629
- }, 200);
630
- return () => {};
631
- })),
632
- showAppsInTossAdMob: withIsSupported(observe("GoogleAdMob.showAppsInTossAdMob", "faithful", (args) => {
633
- if (!aitState.state.ads.isLoaded) {
634
- args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
635
- return () => {};
636
- }
637
- const { rewardUnitType, rewardAmount } = aitState.state.ads;
638
- setTimeout(() => args.onEvent({
639
- type: "userEarnedReward",
640
- data: {
641
- unitType: rewardUnitType,
642
- unitAmount: rewardAmount
643
- }
644
- }), 1e3);
645
- setTimeout(() => {
646
- args.onEvent({ type: "dismissed" });
647
- aitState.patch("ads", { isLoaded: false });
648
- }, 1500);
649
- return () => {};
650
- })),
651
- isAppsInTossAdMobLoaded: withIsSupported(observe("GoogleAdMob.isAppsInTossAdMobLoaded", "faithful", async (_options) => {
652
- if (_options?.adGroupId !== void 0 && _options.adGroupId.trim() === "") throw buildNativeError("INVALID_REQUEST");
653
- return aitState.state.ads.isLoaded;
654
- }))
655
- });
656
- const TossAds = createMockProxy("TossAds", {
657
- initialize: withIsSupported(observe("TossAds.initialize", "partial", (options) => {
658
- if (aitState.state.ads.forceNoFill) {
659
- options.callbacks?.onInitializationFailed?.(/* @__PURE__ */ new Error("No fill"));
660
- return;
661
- }
662
- options.callbacks?.onInitialized?.();
663
- })),
664
- attach: withIsSupported(observe("TossAds.attach", "partial", (_adGroupId, target, _options) => {
665
- const el = typeof target === "string" ? document.querySelector(target) : target;
666
- if (el) {
667
- const placeholder = document.createElement("div");
668
- placeholder.style.cssText = "background:#f0f0f0;border:1px dashed #999;padding:16px;text-align:center;color:#666;font-size:14px;";
669
- placeholder.textContent = "[@apps-in-toss/devtools] TossAds Placeholder";
670
- el.appendChild(placeholder);
671
- }
672
- })),
673
- attachBanner: withIsSupported(observe("TossAds.attachBanner", "faithful", (adGroupId, target, options) => {
674
- const el = typeof target === "string" ? document.querySelector(target) : target;
675
- const slotId = _nextSlotId(adGroupId);
676
- const placeholder = document.createElement("div");
677
- const theme = options?.theme ?? "auto";
678
- const variant = options?.variant ?? "card";
679
- const isDark = theme === "dark" || theme === "auto" && typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches;
680
- const bg = isDark ? "#1a1a1a" : "#f0f0f0";
681
- const textColor = isDark ? "#aaa" : "#666";
682
- const borderColor = isDark ? "#555" : "#999";
683
- const height = variant === "expanded" ? "120px" : "60px";
684
- placeholder.dataset.aitSlotId = slotId;
685
- placeholder.style.cssText = `background:${bg};border:1px dashed ${borderColor};padding:8px 12px;text-align:center;color:${textColor};font-size:12px;min-height:${height};display:flex;align-items:center;justify-content:center;`;
686
- placeholder.textContent = `[@apps-in-toss/devtools] Banner Ad (${variant})`;
687
- if (el) {
688
- el.appendChild(placeholder);
689
- _slotRegistry.set(slotId, placeholder);
690
- }
691
- const destroySlot = () => {
692
- const registered = _slotRegistry.get(slotId);
693
- if (registered) {
694
- registered.remove();
695
- _slotRegistry.delete(slotId);
696
- }
697
- };
698
- setTimeout(() => {
699
- if (aitState.state.ads.forceNoFill) {
700
- options?.callbacks?.onNoFill?.({
701
- slotId,
702
- adGroupId,
703
- adMetadata: {}
704
- });
705
- options?.callbacks?.onAdFailedToRender?.({
706
- slotId,
707
- adGroupId,
708
- adMetadata: {},
709
- error: {
710
- code: 0,
711
- message: "No fill"
712
- }
713
- });
714
- return;
715
- }
716
- const eventPayload = {
717
- slotId,
718
- adGroupId,
719
- adMetadata: {
720
- creativeId: `mock-creative-${slotId}`,
721
- requestId: `mock-req-${slotId}`
722
- }
723
- };
724
- options?.callbacks?.onAdRendered?.(eventPayload);
725
- options?.callbacks?.onAdImpression?.(eventPayload);
726
- }, 100);
727
- return { destroy: destroySlot };
728
- })),
729
- destroy: withIsSupported(observe("TossAds.destroy", "faithful", (slotId) => {
730
- const el = _slotRegistry.get(slotId);
731
- if (el) {
732
- el.remove();
733
- _slotRegistry.delete(slotId);
734
- }
735
- })),
736
- destroyAll: withIsSupported(observe("TossAds.destroyAll", "faithful", () => {
737
- for (const el of _slotRegistry.values()) el.remove();
738
- _slotRegistry.clear();
739
- }))
740
- });
741
- const loadFullScreenAd$1 = observe("loadFullScreenAd", "faithful", (args) => {
742
- setTimeout(() => {
743
- const failureCode = aitState.state.failureModes.loadFullScreenAd;
744
- if (failureCode) {
745
- args.onError(buildNativeError(failureCode));
746
- return;
747
- }
748
- const throttleError = throttleErrorFor("loadFullScreenAd");
749
- if (throttleError) {
750
- args.onError(throttleError);
751
- return;
752
- }
753
- if (aitState.state.ads.forceNoFill) {
754
- args.onError(/* @__PURE__ */ new Error("No fill"));
755
- return;
756
- }
757
- aitState.patch("ads", { isLoaded: true });
758
- args.onEvent({ type: "loaded" });
759
- }, 200);
760
- return () => {};
761
- });
762
- const showFullScreenAd$1 = withIsSupported(observe("showFullScreenAd", "faithful", (args) => {
763
- if (!aitState.state.ads.isLoaded) {
764
- args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
765
- return () => {};
766
- }
767
- setTimeout(() => args.onEvent({ type: "clicked" }), 100);
768
- setTimeout(() => args.onEvent({ type: "dismissed" }), 1500);
769
- return () => {};
770
- }));
771
- //#endregion
772
- //#region src/mock/analytics/index.ts
805
+ //#region src/mock/device/clipboard.ts
773
806
  /**
774
- * Analytics mock
807
+ * Clipboard mock
808
+ * mock/web 모드 지원
775
809
  */
776
- const Analytics$1 = {
777
- screen: (params) => {
778
- aitState.logAnalytics({
779
- type: "screen",
780
- params: params ?? {}
781
- });
782
- return Promise.resolve(null);
783
- },
784
- impression: (params) => {
785
- aitState.logAnalytics({
786
- type: "impression",
787
- params: params ?? {}
788
- });
789
- return Promise.resolve(null);
790
- },
791
- click: (params) => {
792
- aitState.logAnalytics({
793
- type: "click",
794
- params: params ?? {}
795
- });
796
- return Promise.resolve(null);
810
+ const _getClipboardText = async () => {
811
+ checkPermission("clipboard", "getClipboardText");
812
+ checkThrottle("getClipboardText");
813
+ if (aitState.state.deviceModes.clipboard === "mock") return aitState.state.mockData.clipboardText;
814
+ try {
815
+ return await navigator.clipboard.readText();
816
+ } catch {
817
+ return "";
797
818
  }
798
819
  };
799
- async function eventLog(params) {
800
- aitState.logAnalytics({
801
- type: params.log_type,
802
- params: {
803
- log_name: params.log_name,
804
- ...params.params
805
- }
806
- });
807
- return null;
808
- }
820
+ const getClipboardText = withPermission(_getClipboardText, "clipboard");
821
+ const _setClipboardText = async (text) => {
822
+ checkPermission("clipboard", "setClipboardText");
823
+ checkThrottle("setClipboardText");
824
+ if (aitState.state.deviceModes.clipboard === "mock") {
825
+ aitState.patch("mockData", { clipboardText: text });
826
+ return { text };
827
+ }
828
+ await navigator.clipboard.writeText(text);
829
+ return { text };
830
+ };
831
+ const setClipboardText = withPermission(_setClipboardText, "clipboard");
809
832
  //#endregion
810
- //#region src/mock/auth/index.ts
811
- /**
812
- * 인증/로그인 mock
813
- */
814
- async function appLogin() {
815
- const failureCode = aitState.state.failureModes.appLogin;
816
- if (failureCode) throw buildNativeError(failureCode);
817
- return {
818
- authorizationCode: `mock-auth-${crypto.randomUUID()}`,
819
- referrer: aitState.state.environment === "toss" ? "DEFAULT" : "SANDBOX"
820
- };
821
- }
822
- async function getIsTossLoginIntegratedService$1() {
823
- const failureCode = aitState.state.failureModes.getIsTossLoginIntegratedService;
824
- if (failureCode) throw buildNativeError(failureCode);
825
- return aitState.state.auth.isTossLoginIntegrated;
826
- }
827
- async function getUserKeyForGame$1() {
828
- return {
829
- hash: aitState.state.auth.userKeyHash ?? "",
830
- type: "HASH"
831
- };
832
- }
833
- async function getAnonymousKey$1() {
834
- if (!aitState.state.auth.anonymousKeyHash) return void 0;
833
+ //#region src/mock/device/contacts.ts
834
+ const _fetchContacts = async (options) => {
835
+ checkPermission("contacts", "fetchContacts");
836
+ let contacts = aitState.state.contacts;
837
+ if (options.query?.contains) {
838
+ const q = options.query.contains.toLowerCase();
839
+ contacts = contacts.filter((c) => c.name.toLowerCase().includes(q) || c.phoneNumber.includes(q));
840
+ }
841
+ const sliced = contacts.slice(options.offset, options.offset + options.size);
842
+ const nextOffset = options.offset + options.size;
835
843
  return {
836
- hash: aitState.state.auth.anonymousKeyHash,
837
- type: "HASH"
844
+ result: sliced,
845
+ nextOffset: nextOffset < contacts.length ? nextOffset : null,
846
+ done: nextOffset >= contacts.length
838
847
  };
839
- }
840
- async function appsInTossSignTossCert$1(_params) {
841
- console.log("[@apps-in-toss/devtools] appsInTossSignTossCert called (no-op in mock)");
842
- }
848
+ };
843
849
  /**
844
- * 사용자 동의 기반 데이터 mock (devtools#798 — env1에 배선 부재였던 실 export).
850
+ * 상류 SDK는 `fetchContacts`의 타입에 `.getPermission`/`.openPermissionDialog`를
851
+ * `PermissionFunctionWithDialog`로 선언하지만, 실기기(2.x×iOS)에는 그 메서드가
852
+ * **런타임에 붙어 있지 않다**(devtools#795 — 호출 시 `fetchContacts.getPermission
853
+ * is not a function` native `TypeError`). standalone `getPermission({name:
854
+ * 'contacts', access: 'access'})`는 실기기에서 정상 resolve하므로(env3 run11,
855
+ * `../permissions.ts`) 부재는 fetchContacts에 **부착된** 메서드에만 해당하는
856
+ * 상류 타입↔런타임 불일치다.
845
857
  *
846
- * SDK는 API `@apps-in-toss/web-bridge` 호환 export로 노출하며 2.x와 3.x GA
847
- * 양쪽 facade에서 제공한다. 선언 시그니처는 `Promise<Partial<Record<ConsentedUserDataKey, string>>
848
- * | undefined>` appLogin과 같은 async bridge 모양이라 항상 resolve하는 낙관적
849
- * 패턴을 따른다.
858
+ * mock은 다른 device API처럼 `withPermission()`으로 감싸지 않고, bare async fn을
859
+ * 상류 시그니처로만 캐스트한다 `.getPermission`/`.openPermissionDialog` 접근은
860
+ * `undefined`가 되고, 호출하면 `undefined()` native `TypeError`로 떨어져
861
+ * 실기기와 일치한다(`__typecheck.ts`/`__typecheck-2x.ts`는 캐스트 타입에 두
862
+ * 메서드가 여전히 남아 있어 그대로 통과). `_fetchContacts` 내부에서 이미
863
+ * `checkPermission`을 호출하므로 메인 동작(권한 거부 시
864
+ * `FetchContactsPermissionError`)은 변화 없다.
850
865
  *
851
- * 어떤 키가 채워지는지는 콘솔에 등록된 동의문/데이터 묶음(`consentedUserDataKey`)에
852
- * 달려 있고 매핑은 서버 설정이라 mock이 알 수 없다 — 호출 파라미터와 무관하게
853
- * 상태에 저장된 최소 plausible 객체를 그대로 resolve한다. SDK 선언 밖의 필드는
854
- * 추가하지 않는다(devtools#783실측/타입 추정 금지).
866
+ * 직접 관측된 것은 `getPermission` 부재뿐이다. `openPermissionDialog` 부재는
867
+ * "상류가 fetchContacts에 권한 헬퍼 전체를 붙이지 않는다"는 합리적 추론이지
868
+ * 별도 실측은 아니다. 다른 `withPermission` API(clipboard/camera/location)로는
869
+ * 부재를 확장하지 않는다 — 그쪽 부착 메서드가 실기기에서 없다는 관측은
870
+ * 없다(#783 "측정 밖 확장 금지" 원칙).
855
871
  */
856
- async function getConsentedUserData$1(_options) {
857
- return aitState.state.auth.consentedUserData;
858
- }
859
- async function getDeclaredAgeRange$1(_params) {
860
- return { ...aitState.state.auth.declaredAgeRange };
861
- }
872
+ const fetchContacts = _fetchContacts;
862
873
  //#endregion
863
- //#region src/mock/device/_helpers.ts
874
+ //#region src/mock/device/haptic.ts
864
875
  /**
865
- * 디바이스 모듈 내부 공유 헬퍼
876
+ * Haptic Feedback & saveBase64Data mock
877
+ *
878
+ * generateHapticFeedback — 영역 3 (하드웨어 API 관측):
879
+ * - 10종 HapticFeedbackType을 navigator.vibrate 패턴으로 매핑(근사, best-effort).
880
+ * - `typeof navigator.vibrate === 'function'` 가드 — API 없는 환경에서 throw 없이 skip.
881
+ * - @ait-co/polyfill 동시 사용 시 재귀 방지: polyfill이 navigator.vibrate를 override하고
882
+ * 내부에서 mock의 generateHapticFeedback을 호출하므로 무한 재귀가 발생한다. polyfill이
883
+ * 원본 vibrate를 BACKUP_KEY(Symbol.for('@ait-co/polyfill/vibrate.original'))에 저장하면
884
+ * 그 원본을 직접 호출해 재귀를 끊는다.
885
+ * - sdkCallLog에 🟡(partial)로 기록. params: { hapticType, vibrated: boolean }.
886
+ * - 시그니처 불변 — __typecheck.ts의 Assert<Mock, Original> 통과.
866
887
  */
867
- function generatePlaceholderImage(width, height, text, color) {
868
- const canvas = document.createElement("canvas");
869
- canvas.width = width;
870
- canvas.height = height;
871
- const ctx = canvas.getContext("2d");
872
- if (!ctx) {
873
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect fill="${color}" width="${width}" height="${height}"/><text x="50%" y="50%" fill="white" font-size="16" text-anchor="middle" dominant-baseline="middle">${text}</text></svg>`;
874
- return `data:image/svg+xml;base64,${btoa(svg)}`;
875
- }
876
- ctx.fillStyle = color;
877
- ctx.fillRect(0, 0, width, height);
878
- ctx.fillStyle = "white";
879
- ctx.font = "16px sans-serif";
880
- ctx.textAlign = "center";
881
- ctx.textBaseline = "middle";
882
- ctx.fillText(text, width / 2, height / 2);
883
- return canvas.toDataURL("image/png");
884
- }
885
- const DEFAULT_PLACEHOLDERS = [
886
- {
887
- text: "Mock Photo 1",
888
- color: "#3182F6"
889
- },
890
- {
891
- text: "Mock Photo 2",
892
- color: "#27ae60"
893
- },
894
- {
895
- text: "Mock Photo 3",
896
- color: "#e67e22"
897
- }
898
- ];
899
- let cachedPlaceholders = null;
900
- function getDefaultPlaceholderImages() {
901
- if (!cachedPlaceholders) cachedPlaceholders = DEFAULT_PLACEHOLDERS.map((p) => generatePlaceholderImage(320, 240, p.text, p.color));
902
- return [...cachedPlaceholders];
903
- }
904
- /** @internal device 모듈 내부 전용 */
905
- function getMockImages() {
906
- const images = aitState.state.mockData.images;
907
- if (images.length > 0) return images;
908
- return getDefaultPlaceholderImages();
909
- }
910
- const PROMPT_TIMEOUT_MS = 3e4;
911
- /** @internal device 모듈 내부 전용 */
912
- function waitForPromptResponse(type) {
913
- return new Promise((resolve, reject) => {
914
- const eventName = `__ait:prompt-response:${type}`;
915
- const cancelName = "__ait:prompt-cancel";
916
- function cleanup() {
917
- clearTimeout(timer);
918
- window.removeEventListener(eventName, handler);
919
- window.removeEventListener(cancelName, cancelHandler);
920
- }
921
- const timer = setTimeout(() => {
922
- cleanup();
923
- const hint = !!document.querySelector(".ait-panel") ? "Please provide input via the DevTools panel." : "Is @apps-in-toss/devtools/panel imported?";
924
- reject(/* @__PURE__ */ new Error(`[@apps-in-toss/devtools] Prompt timeout for "${type}" after ${PROMPT_TIMEOUT_MS / 1e3}s. ${hint}`));
925
- }, PROMPT_TIMEOUT_MS);
926
- const handler = (e) => {
927
- cleanup();
928
- resolve(e.detail);
929
- };
930
- const cancelHandler = () => {
931
- cleanup();
932
- reject(/* @__PURE__ */ new Error(`[@apps-in-toss/devtools] Prompt cancelled for "${type}"`));
933
- };
934
- window.addEventListener(eventName, handler);
935
- window.addEventListener(cancelName, cancelHandler);
936
- window.dispatchEvent(new CustomEvent("__ait:prompt-request", { detail: { type } }));
937
- });
938
- }
939
- //#endregion
940
- //#region src/mock/permissions.ts
941
888
  /**
942
- * web-framework 3.0+ 권한 에러 기반 클래스.
943
- * `instanceof PermissionError`로 체크하는 코드와 호환된다.
889
+ * HapticFeedbackType 10종 navigator.vibrate 패턴 매핑.
890
+ * 숫자: 진동 ms. 배열: [진동, 정지, 진동, …] 교대 패턴.
944
891
  */
945
- var PermissionError = class extends Error {
946
- constructor({ methodName, message }) {
947
- super(message ?? `${methodName}: permission denied`);
948
- this.name = `${methodName}PermissionError`;
949
- }
950
- };
951
- /** openCamera 권한 에러 */
952
- var OpenCameraPermissionError = class extends PermissionError {
953
- constructor() {
954
- super({ methodName: "openCamera" });
955
- }
956
- };
957
- /** fetchAlbumPhotos 권한 에러 */
958
- var FetchAlbumPhotosPermissionError = class extends PermissionError {
959
- constructor() {
960
- super({ methodName: "fetchAlbumPhotos" });
961
- }
962
- };
963
- /** fetchContacts 권한 에러 */
964
- var FetchContactsPermissionError = class extends PermissionError {
965
- constructor() {
966
- super({ methodName: "fetchContacts" });
967
- }
968
- };
969
- /** getCurrentLocation 권한 에러 */
970
- var GetCurrentLocationPermissionError = class extends PermissionError {
971
- constructor() {
972
- super({ methodName: "getCurrentLocation" });
973
- }
974
- };
975
- /** getClipboardText 권한 에러 */
976
- var GetClipboardTextPermissionError = class extends PermissionError {
977
- constructor() {
978
- super({ methodName: "getClipboardText" });
979
- }
980
- };
981
- /** setClipboardText 권한 에러 */
982
- var SetClipboardTextPermissionError = class extends PermissionError {
983
- constructor() {
984
- super({ methodName: "setClipboardText" });
985
- }
892
+ const HAPTIC_VIBRATE_PATTERN = {
893
+ tickWeak: 10,
894
+ tap: 20,
895
+ tickMedium: 30,
896
+ softMedium: 40,
897
+ basicWeak: 15,
898
+ basicMedium: 50,
899
+ success: [
900
+ 10,
901
+ 40,
902
+ 10
903
+ ],
904
+ error: [
905
+ 40,
906
+ 30,
907
+ 40
908
+ ],
909
+ wiggle: [
910
+ 20,
911
+ 20,
912
+ 20,
913
+ 20,
914
+ 20
915
+ ],
916
+ confetti: [
917
+ 10,
918
+ 20,
919
+ 10,
920
+ 20,
921
+ 10,
922
+ 20,
923
+ 10
924
+ ]
986
925
  };
987
926
  /**
988
- * startUpdateLocation 권한 에러.
989
- * web-framework 3.0에서 GetCurrentLocationPermissionError의 alias.
927
+ * navigator.vibrate를 안전하게 호출한다.
928
+ *
929
+ * @ait-co/polyfill/auto가 설치된 환경에서는 navigator.vibrate가 polyfill shim으로
930
+ * override되어 있고, 그 shim은 내부적으로 mock의 generateHapticFeedback을 호출한다.
931
+ * mock이 다시 navigator.vibrate(현재 = shim)를 호출하면 무한 재귀가 발생한다.
932
+ * polyfill은 원본 vibrate를 BACKUP_KEY에 저장하므로 그쪽을 직접 호출한다.
990
933
  */
991
- const StartUpdateLocationPermissionError = GetCurrentLocationPermissionError;
992
- const permissionErrorMap = {
993
- openCamera: OpenCameraPermissionError,
994
- fetchAlbumPhotos: FetchAlbumPhotosPermissionError,
995
- fetchAlbumItems: FetchAlbumPhotosPermissionError,
996
- fetchContacts: FetchContactsPermissionError,
997
- getCurrentLocation: GetCurrentLocationPermissionError,
998
- getClipboardText: GetClipboardTextPermissionError,
999
- setClipboardText: SetClipboardTextPermissionError
1000
- };
1001
- async function getPermission(permission) {
1002
- const failureCode = permissionGateCode(permission);
1003
- if (failureCode) throw buildNativeError(failureCode);
1004
- return aitState.state.permissions[permission.name];
934
+ const POLYFILL_VIBRATE_BACKUP = Symbol.for("@ait-co/polyfill/vibrate.original");
935
+ function callVibrate(pattern) {
936
+ if (typeof navigator === "undefined") return false;
937
+ const nav = navigator;
938
+ const original = POLYFILL_VIBRATE_BACKUP in nav ? nav[POLYFILL_VIBRATE_BACKUP] : null;
939
+ if (typeof original === "function") return original(pattern);
940
+ return typeof navigator.vibrate === "function" ? navigator.vibrate(pattern) : false;
1005
941
  }
1006
- /**
1007
- * 선언 게이트에 걸리는 이름·access 조합이면 다이얼에 등록된 native errorCode를
1008
- * 돌려준다. 권한 API(`getPermission`/`requestPermission`/`openPermissionDialog`)
1009
- * **걸리는 조건**은 공유하지만 **떨어지는 코드**는 공유하지 않는다 — 아래 참조.
1010
- */
1011
- function permissionGateCode(permission) {
1012
- if (permission.access === "access") return;
1013
- return aitState.state.failureModes.getPermission?.[permission.name];
942
+ async function generateHapticFeedback(options) {
943
+ if (!Object.hasOwn(HAPTIC_VIBRATE_PATTERN, options.type)) throw buildNativeError("EXECUTION_ERROR");
944
+ const timestamp = Date.now();
945
+ aitState.logAnalytics({
946
+ type: "haptic",
947
+ params: { hapticType: options.type }
948
+ });
949
+ const vibrated = callVibrate(HAPTIC_VIBRATE_PATTERN[options.type] ?? 30);
950
+ aitState.logSdkCall({
951
+ method: "generateHapticFeedback",
952
+ args: [{ type: options.type }],
953
+ timestamp,
954
+ status: "resolved",
955
+ result: {
956
+ hapticType: options.type,
957
+ vibrated
958
+ },
959
+ fidelity: "partial"
960
+ });
1014
961
  }
1015
- /**
1016
- * `openPermissionDialog`가 선언 게이트에 걸렸을 때의 코드.
1017
- *
1018
- * 형제 API와 갈린다 — env3 run11 실측(2.x/iOS, sdk-example#313에서 시나리오 키가
1019
- * 통일되며 비교 대상에 들어온 값):
1020
- *
1021
- * getPermission { geolocation, read } → rejected NO_PERMISSION
1022
- * requestPermission { geolocation, read } → rejected NO_PERMISSION
1023
- * openPermissionDialog { geolocation, read } → rejected INVALID_REQUEST
1024
- * openPermissionDialog { camera, access } → resolved
1025
- *
1026
- * 즉 **걸리는 조건은 셋이 같고(access 축 모델 그대로) 코드만 갈린다.** 처음엔
1027
- * `requestPermission`이 `openPermissionDialog`에 위임하니 게이트도 위임하면
1028
- * 된다고 보고 한 지점에만 배선했는데, 그 모델은 코드 층위에서 실측과 어긋났다
1029
- * (env1 NO_PERMISSION ↔ env3 INVALID_REQUEST). 그래서 `requestPermission`은
1030
- * 위임 **전에** 자기 게이트를 먼저 타고, 다이얼로그를 여는 호출만 이 코드를 쓴다.
1031
- *
1032
- * 관측이 geolocation/read 한 조합뿐이라 이름·access별 분기는 두지 않는다 —
1033
- * 근거 없는 확장은 #783에서 이름 단위 맵을 택한 원칙에 어긋난다.
1034
- */
1035
- const OPEN_DIALOG_GATE_CODE = "INVALID_REQUEST";
1036
- async function openPermissionDialog(permission) {
1037
- if (permissionGateCode(permission)) throw buildNativeError(OPEN_DIALOG_GATE_CODE);
1038
- if (aitState.state.permissions[permission.name] === "allowed") return "allowed";
1039
- aitState.patch("permissions", { [permission.name]: "allowed" });
1040
- return "allowed";
1041
- }
1042
- async function requestPermission(permission) {
1043
- const failureCode = permissionGateCode(permission);
1044
- if (failureCode) throw buildNativeError(failureCode);
1045
- return openPermissionDialog(permission);
1046
- }
1047
- /** 권한이 필요한 함수에 .getPermission(), .openPermissionDialog()를 부착 */
1048
- function withPermission(fn, permissionName) {
1049
- const enhanced = fn;
1050
- enhanced.getPermission = () => getPermission({
1051
- name: permissionName,
1052
- access: "access"
1053
- });
1054
- enhanced.openPermissionDialog = () => openPermissionDialog({
1055
- name: permissionName,
1056
- access: "access"
1057
- });
1058
- return enhanced;
1059
- }
1060
- /**
1061
- * 권한 체크 후 denied면 per-API *PermissionError 서브클래스를 throw한다.
1062
- * 실 3.0 SDK 동작과 일치 — `instanceof PermissionError` 분기가 mock에서도 동작한다 (#372).
1063
- */
1064
- function checkPermission(name, fnName) {
1065
- if (aitState.state.permissions[name] === "denied") {
1066
- const ErrorClass = permissionErrorMap[fnName];
1067
- if (ErrorClass) throw new ErrorClass();
1068
- throw new PermissionError({ methodName: fnName });
1069
- }
962
+ async function saveBase64Data$1(params) {
963
+ if (params.data === "") throw buildNativeError("INVALID_DATA");
964
+ const a = document.createElement("a");
965
+ a.href = `data:${params.mimeType};base64,${params.data}`;
966
+ a.download = params.fileName;
967
+ a.click();
1070
968
  }
1071
969
  //#endregion
1072
- //#region src/mock/device/camera.ts
970
+ //#region src/mock/device/location.ts
1073
971
  /**
1074
- * Camera & Album Photos & Album Items mock
972
+ * Location mock (getCurrentLocation, startUpdateLocation)
1075
973
  * mock/web/prompt 모드 지원
1076
974
  */
1077
- async function openCameraMock() {
1078
- const images = getMockImages();
975
+ var Accuracy = /* @__PURE__ */ function(Accuracy) {
976
+ Accuracy[Accuracy["Lowest"] = 1] = "Lowest";
977
+ Accuracy[Accuracy["Low"] = 2] = "Low";
978
+ Accuracy[Accuracy["Balanced"] = 3] = "Balanced";
979
+ Accuracy[Accuracy["High"] = 4] = "High";
980
+ Accuracy[Accuracy["Highest"] = 5] = "Highest";
981
+ Accuracy[Accuracy["BestForNavigation"] = 6] = "BestForNavigation";
982
+ return Accuracy;
983
+ }(Accuracy || {});
984
+ function buildLocation() {
1079
985
  return {
1080
- id: crypto.randomUUID(),
1081
- dataUri: images[0]
986
+ coords: { ...aitState.state.location.coords },
987
+ timestamp: Date.now(),
988
+ accessLocation: aitState.state.location.accessLocation
1082
989
  };
1083
990
  }
1084
- async function openCameraWeb() {
1085
- return new Promise((resolve, reject) => {
1086
- const input = document.createElement("input");
1087
- input.type = "file";
1088
- input.accept = "image/*";
1089
- input.capture = "environment";
1090
- let settled = false;
1091
- input.onchange = () => {
1092
- settled = true;
1093
- const file = input.files?.[0];
1094
- if (!file) {
1095
- reject(/* @__PURE__ */ new Error("No file selected"));
1096
- return;
1097
- }
1098
- const reader = new FileReader();
1099
- reader.onload = () => resolve({
1100
- id: crypto.randomUUID(),
1101
- dataUri: reader.result
991
+ async function getCurrentLocationMock() {
992
+ const { coords, timestamp } = buildLocation();
993
+ return {
994
+ coords,
995
+ timestamp
996
+ };
997
+ }
998
+ async function getCurrentLocationWeb() {
999
+ return new Promise((resolve) => {
1000
+ if (!navigator.geolocation) {
1001
+ console.warn("[@apps-in-toss/devtools] Geolocation API not available, falling back to mock");
1002
+ resolve(buildLocation());
1003
+ return;
1004
+ }
1005
+ navigator.geolocation.getCurrentPosition((pos) => {
1006
+ resolve({
1007
+ coords: {
1008
+ latitude: pos.coords.latitude,
1009
+ longitude: pos.coords.longitude,
1010
+ altitude: pos.coords.altitude ?? 0,
1011
+ accuracy: pos.coords.accuracy,
1012
+ altitudeAccuracy: pos.coords.altitudeAccuracy ?? 0,
1013
+ heading: pos.coords.heading ?? 0
1014
+ },
1015
+ timestamp: pos.timestamp
1102
1016
  });
1103
- reader.onerror = () => reject(/* @__PURE__ */ new Error("Failed to read file"));
1104
- reader.readAsDataURL(file);
1105
- };
1106
- const onFocus = () => {
1107
- setTimeout(() => {
1108
- if (!settled) reject(/* @__PURE__ */ new Error("File picker cancelled"));
1109
- window.removeEventListener("focus", onFocus);
1110
- }, 300);
1111
- };
1112
- window.addEventListener("focus", onFocus);
1113
- input.click();
1017
+ }, () => {
1018
+ console.warn("[@apps-in-toss/devtools] Geolocation failed, falling back to mock");
1019
+ resolve(buildLocation());
1020
+ });
1114
1021
  });
1115
1022
  }
1116
- async function openCameraPrompt() {
1117
- const dataUri = await waitForPromptResponse("camera");
1023
+ async function getCurrentLocationPrompt() {
1024
+ const { coords, timestamp } = await waitForPromptResponse("location");
1118
1025
  return {
1119
- id: crypto.randomUUID(),
1120
- dataUri
1026
+ coords,
1027
+ timestamp
1121
1028
  };
1122
1029
  }
1123
- const _openCamera = async (_options) => {
1124
- checkPermission("camera", "openCamera");
1125
- const mode = aitState.state.deviceModes.camera;
1126
- if (mode === "web") return openCameraWeb();
1127
- if (mode === "prompt") return openCameraPrompt();
1128
- return openCameraMock();
1030
+ const _getCurrentLocation = async (_options) => {
1031
+ checkPermission("geolocation", "getCurrentLocation");
1032
+ checkThrottle("getCurrentLocation");
1033
+ const mode = aitState.state.deviceModes.location;
1034
+ if (mode === "web") return getCurrentLocationWeb();
1035
+ if (mode === "prompt") return getCurrentLocationPrompt();
1036
+ return getCurrentLocationMock();
1129
1037
  };
1130
- const openCamera = withPermission(_openCamera, "camera");
1131
- async function fetchAlbumPhotosMock(maxCount) {
1132
- return getMockImages().slice(0, maxCount).map((dataUri) => ({
1133
- id: crypto.randomUUID(),
1134
- dataUri
1135
- }));
1038
+ const getCurrentLocation = withPermission(_getCurrentLocation, "geolocation");
1039
+ function startUpdateLocationMock(eventParams) {
1040
+ const { onEvent, options } = eventParams;
1041
+ const interval = Math.max(options.timeInterval, 500);
1042
+ const id = setInterval(() => {
1043
+ const loc = buildLocation();
1044
+ loc.coords.latitude += (Math.random() - .5) * 1e-4;
1045
+ loc.coords.longitude += (Math.random() - .5) * 1e-4;
1046
+ onEvent(loc);
1047
+ }, interval);
1048
+ return () => clearInterval(id);
1136
1049
  }
1137
- async function fetchAlbumPhotosWeb(maxCount) {
1138
- return new Promise((resolve, reject) => {
1139
- const input = document.createElement("input");
1140
- input.type = "file";
1141
- input.accept = "image/*";
1142
- input.multiple = true;
1143
- let settled = false;
1144
- input.onchange = async () => {
1145
- settled = true;
1146
- const files = Array.from(input.files ?? []).slice(0, maxCount);
1147
- if (files.length === 0) {
1148
- reject(/* @__PURE__ */ new Error("No files selected"));
1149
- return;
1150
- }
1151
- resolve(await Promise.all(files.map((file) => new Promise((res, rej) => {
1152
- const reader = new FileReader();
1153
- reader.onload = () => res({
1154
- id: crypto.randomUUID(),
1155
- dataUri: reader.result
1156
- });
1157
- reader.onerror = () => rej(/* @__PURE__ */ new Error("Failed to read file"));
1158
- reader.readAsDataURL(file);
1159
- }))));
1160
- };
1161
- const onFocus = () => {
1162
- setTimeout(() => {
1163
- if (!settled) reject(/* @__PURE__ */ new Error("File picker cancelled"));
1164
- window.removeEventListener("focus", onFocus);
1165
- }, 300);
1166
- };
1167
- window.addEventListener("focus", onFocus);
1168
- input.click();
1169
- });
1050
+ function startUpdateLocationWeb(eventParams) {
1051
+ const { onEvent, onError } = eventParams;
1052
+ if (!navigator.geolocation) {
1053
+ console.warn("[@apps-in-toss/devtools] Geolocation API not available, falling back to mock");
1054
+ return startUpdateLocationMock(eventParams);
1055
+ }
1056
+ const watchId = navigator.geolocation.watchPosition((pos) => {
1057
+ onEvent({
1058
+ coords: {
1059
+ latitude: pos.coords.latitude,
1060
+ longitude: pos.coords.longitude,
1061
+ altitude: pos.coords.altitude ?? 0,
1062
+ accuracy: pos.coords.accuracy,
1063
+ altitudeAccuracy: pos.coords.altitudeAccuracy ?? 0,
1064
+ heading: pos.coords.heading ?? 0
1065
+ },
1066
+ timestamp: pos.timestamp,
1067
+ accessLocation: "FINE"
1068
+ });
1069
+ }, (err) => onError(err));
1070
+ return () => navigator.geolocation.clearWatch(watchId);
1170
1071
  }
1171
- async function fetchAlbumPhotosPrompt(maxCount) {
1172
- return (await waitForPromptResponse("photos")).slice(0, maxCount).map((dataUri) => ({
1173
- id: crypto.randomUUID(),
1174
- dataUri
1175
- }));
1072
+ function startUpdateLocationPrompt(eventParams) {
1073
+ const { onEvent } = eventParams;
1074
+ const handler = (e) => {
1075
+ onEvent(e.detail);
1076
+ };
1077
+ window.addEventListener("__ait:prompt-response:location-update", handler);
1078
+ window.dispatchEvent(new CustomEvent("__ait:prompt-request", { detail: { type: "location-update" } }));
1079
+ return () => window.removeEventListener("__ait:prompt-response:location-update", handler);
1176
1080
  }
1177
- const _fetchAlbumPhotos = async (options) => {
1178
- checkPermission("photos", "fetchAlbumPhotos");
1179
- const maxCount = options?.maxCount ?? 10;
1180
- const mode = aitState.state.deviceModes.photos;
1181
- if (mode === "web") return fetchAlbumPhotosWeb(maxCount);
1182
- if (mode === "prompt") return fetchAlbumPhotosPrompt(maxCount);
1183
- return fetchAlbumPhotosMock(maxCount);
1184
- };
1185
- const fetchAlbumPhotos = withPermission(_fetchAlbumPhotos, "photos");
1186
- async function fetchAlbumItemsMock(maxCount, types) {
1187
- return getMockImages().slice(0, maxCount).filter(() => types.includes("PHOTO")).map((dataUri) => ({
1188
- id: crypto.randomUUID(),
1189
- dataUri,
1190
- type: "PHOTO"
1191
- }));
1192
- }
1193
- async function fetchAlbumItemsWeb(maxCount, types) {
1194
- return new Promise((resolve) => {
1195
- const input = document.createElement("input");
1196
- input.type = "file";
1197
- input.accept = types.includes("VIDEO") ? "image/*,video/*" : "image/*";
1198
- input.multiple = true;
1199
- let settled = false;
1200
- input.onchange = async () => {
1201
- settled = true;
1202
- const files = Array.from(input.files ?? []).slice(0, maxCount);
1203
- if (files.length === 0) {
1204
- resolve([]);
1205
- return;
1206
- }
1207
- resolve(await Promise.all(files.map((file) => new Promise((res, rej) => {
1208
- const itemType = file.type.startsWith("video/") ? "VIDEO" : "PHOTO";
1209
- const reader = new FileReader();
1210
- reader.onload = () => res({
1211
- id: crypto.randomUUID(),
1212
- dataUri: reader.result,
1213
- type: itemType
1214
- });
1215
- reader.onerror = () => rej(/* @__PURE__ */ new Error("Failed to read file"));
1216
- reader.readAsDataURL(file);
1217
- }))));
1218
- };
1219
- const onFocus = () => {
1220
- setTimeout(() => {
1221
- if (!settled) resolve([]);
1222
- window.removeEventListener("focus", onFocus);
1223
- }, 300);
1224
- };
1225
- window.addEventListener("focus", onFocus);
1226
- input.click();
1227
- });
1228
- }
1229
- async function fetchAlbumItemsPrompt(maxCount) {
1230
- return (await waitForPromptResponse("photos")).slice(0, maxCount).map((dataUri) => ({
1231
- id: crypto.randomUUID(),
1232
- dataUri,
1233
- type: "PHOTO"
1234
- }));
1235
- }
1236
- const _fetchAlbumItems = async (options) => {
1237
- checkPermission("photos", "fetchAlbumItems");
1238
- const maxCount = options?.maxCount ?? 10;
1239
- const types = options?.types ?? ["PHOTO"];
1240
- const mode = aitState.state.deviceModes.photos;
1241
- if (mode === "web") return fetchAlbumItemsWeb(maxCount, types);
1242
- if (mode === "prompt") return fetchAlbumItemsPrompt(maxCount);
1243
- return fetchAlbumItemsMock(maxCount, types);
1081
+ const _startUpdateLocation = (eventParams) => {
1082
+ const mode = aitState.state.deviceModes.location;
1083
+ if (mode === "web") return startUpdateLocationWeb(eventParams);
1084
+ if (mode === "prompt") return startUpdateLocationPrompt(eventParams);
1085
+ return startUpdateLocationMock(eventParams);
1244
1086
  };
1245
- const _fetchAlbumItemsWithPermission = withPermission(_fetchAlbumItems, "photos");
1246
- const fetchAlbumItems = Object.assign(_fetchAlbumItemsWithPermission, { isSupported: () => true });
1087
+ const startUpdateLocation = withPermission(_startUpdateLocation, "geolocation");
1247
1088
  //#endregion
1248
- //#region src/mock/device/clipboard.ts
1089
+ //#region src/mock/device/network.ts
1249
1090
  /**
1250
- * Clipboard mock
1251
- * mock/web 모드 지원
1091
+ * Network Status mock (mode-aware helper)
1092
+ * navigation 모듈에서 사용. circular dep 방지를 위해 device에 위치.
1252
1093
  */
1253
- const _getClipboardText = async () => {
1254
- checkPermission("clipboard", "getClipboardText");
1255
- checkThrottle("getClipboardText");
1256
- if (aitState.state.deviceModes.clipboard === "mock") return aitState.state.mockData.clipboardText;
1257
- try {
1258
- return await navigator.clipboard.readText();
1259
- } catch {
1260
- return "";
1261
- }
1262
- };
1263
- const getClipboardText = withPermission(_getClipboardText, "clipboard");
1264
- const _setClipboardText = async (text) => {
1265
- checkPermission("clipboard", "setClipboardText");
1266
- checkThrottle("setClipboardText");
1267
- if (aitState.state.deviceModes.clipboard === "mock") {
1268
- aitState.patch("mockData", { clipboardText: text });
1269
- return { text };
1094
+ /**
1095
+ * Web mode: uses navigator.connection.effectiveType (4g/3g/2g) and navigator.onLine.
1096
+ * Limitations: WIFI, 5G, WWAN cannot be detected via the Network Information API.
1097
+ * Falls back to state-based value when effectiveType is unavailable.
1098
+ */
1099
+ function getNetworkStatusByMode() {
1100
+ const mode = aitState.state.deviceModes.network;
1101
+ if (mode === "mock") return null;
1102
+ if (mode === "web") {
1103
+ if (!navigator.onLine) return "OFFLINE";
1104
+ const conn = navigator.connection;
1105
+ if (conn?.effectiveType) return {
1106
+ "4g": "4G",
1107
+ "3g": "3G",
1108
+ "2g": "2G",
1109
+ "slow-2g": "2G"
1110
+ }[conn.effectiveType] ?? "UNKNOWN";
1111
+ return aitState.state.networkStatus;
1270
1112
  }
1271
- await navigator.clipboard.writeText(text);
1272
- return { text };
1273
- };
1274
- const setClipboardText = withPermission(_setClipboardText, "clipboard");
1113
+ return null;
1114
+ }
1275
1115
  //#endregion
1276
- //#region src/mock/device/contacts.ts
1277
- const _fetchContacts = async (options) => {
1278
- checkPermission("contacts", "fetchContacts");
1279
- let contacts = aitState.state.contacts;
1280
- if (options.query?.contains) {
1281
- const q = options.query.contains.toLowerCase();
1282
- contacts = contacts.filter((c) => c.name.toLowerCase().includes(q) || c.phoneNumber.includes(q));
1283
- }
1284
- const sliced = contacts.slice(options.offset, options.offset + options.size);
1285
- const nextOffset = options.offset + options.size;
1286
- return {
1287
- result: sliced,
1288
- nextOffset: nextOffset < contacts.length ? nextOffset : null,
1289
- done: nextOffset >= contacts.length
1290
- };
1291
- };
1116
+ //#region src/mock/device/pdf.ts
1292
1117
  /**
1293
- * 상류 SDK는 `fetchContacts`의 타입에 `.getPermission`/`.openPermissionDialog`를
1294
- * `PermissionFunctionWithDialog`로 선언하지만, 실기기(2.x×iOS)에는 메서드가
1295
- * **런타임에 붙어 있지 않다**(devtools#795 — 호출 시 `fetchContacts.getPermission
1296
- * is not a function` native `TypeError`). standalone `getPermission({name:
1297
- * 'contacts', access: 'access'})`는 실기기에서 정상 resolve하므로(env3 run11,
1298
- * `../permissions.ts`) 부재는 fetchContacts에 **부착된** 메서드에만 해당하는
1299
- * 상류 타입↔런타임 불일치다.
1300
- *
1301
- * mock은 다른 device API처럼 `withPermission()`으로 감싸지 않고, bare async fn을
1302
- * 상류 시그니처로만 캐스트한다 — `.getPermission`/`.openPermissionDialog` 접근은
1303
- * `undefined`가 되고, 호출하면 `undefined()` → native `TypeError`로 떨어져
1304
- * 실기기와 일치한다(`__typecheck.ts`/`__typecheck-2x.ts`는 캐스트 타입에 두
1305
- * 메서드가 여전히 남아 있어 그대로 통과). `_fetchContacts` 내부에서 이미
1306
- * `checkPermission`을 호출하므로 메인 동작(권한 거부 시
1307
- * `FetchContactsPermissionError`)은 변화 없다.
1308
- *
1309
- * 직접 관측된 것은 `getPermission` 부재뿐이다. `openPermissionDialog` 부재는
1310
- * "상류가 fetchContacts에 권한 헬퍼 전체를 붙이지 않는다"는 합리적 추론이지
1311
- * 별도 실측은 아니다. 다른 `withPermission` API(clipboard/camera/location)로는
1312
- * 이 부재를 확장하지 않는다 — 그쪽 부착 메서드가 실기기에서 없다는 관측은
1313
- * 없다(#783 "측정 밖 확장 금지" 원칙).
1118
+ * Base64로 인코딩된 PDF 데이터를 네이티브 PDF 뷰어로 여는 mock.
1119
+ * mock 환경에서는 즉시 `'CLOSE'`를 반환한다.
1314
1120
  */
1315
- const fetchContacts = _fetchContacts;
1121
+ const _openPDFViewerImpl = async (_params) => {
1122
+ await Promise.resolve();
1123
+ return "CLOSE";
1124
+ };
1125
+ const openPDFViewer$1 = Object.assign(_openPDFViewerImpl, { isSupported: () => true });
1316
1126
  //#endregion
1317
- //#region src/mock/device/haptic.ts
1127
+ //#region src/mock/proxy.ts
1318
1128
  /**
1319
- * Haptic Feedback & saveBase64Data mock
1129
+ * 미구현 API용 Proxy 트립와이어.
1320
1130
  *
1321
- * generateHapticFeedback 영역 3 (하드웨어 API 관측):
1322
- * - 10종 HapticFeedbackType을 navigator.vibrate 패턴으로 매핑(근사, best-effort).
1323
- * - `typeof navigator.vibrate === 'function'` 가드 API 없는 환경에서 throw 없이 skip.
1324
- * - @ait-co/polyfill 동시 사용 재귀 방지: polyfill이 navigator.vibrate를 override하고
1325
- * 내부에서 mock generateHapticFeedback을 호출하므로 무한 재귀가 발생한다. polyfill이
1326
- * 원본 vibrate를 BACKUP_KEY(Symbol.for('@ait-co/polyfill/vibrate.original'))에 저장하면
1327
- * 원본을 직접 호출해 재귀를 끊는다.
1328
- * - sdkCallLog🟡(partial)로 기록. params: { hapticType, vibrated: boolean }.
1329
- * - 시그니처 불변 __typecheck.ts의 Assert<Mock, Original> 통과.
1131
+ * 미구현 프로퍼티에 접근하면 throw한다. 이는 "devtools에서는 멀쩡히 돌지만
1132
+ * SDK에선 실제로 동작하는" 시나리오를 차단하기 위한 의도적 선택이다.
1133
+ * mock이 미구현인 API는 SDK에서는 존재할 있고, 사용자가 이를 인지하지
1134
+ * 못한 개발을 이어가면 배포 시점에 놀라게 된다. 에러 메시지에 이슈 URL을
1135
+ * 포함해 사용자가 mock 누락을 제보할 있게 한다.
1136
+ *
1137
+ * ## KNOWN_UNIMPLEMENTED 정책
1138
+ * SDK존재하는 것으로 알려져 있으나 현재 mock이 없는 API 이름만 이 집합에 둔다.
1139
+ * 경우에만 throw 대신 🔴 inert no-op을 반환하고 sdkCallLog에 기록한다.
1140
+ * 완전히 미지의 이름은 여전히 throw — "잘 되는 척" 방지.
1330
1141
  */
1142
+ const ISSUES_URL = "https://github.com/apps-in-toss-community/devtools/issues";
1331
1143
  /**
1332
- * HapticFeedbackType 10종 navigator.vibrate 패턴 매핑.
1333
- * 숫자: 진동 ms. 배열: [진동, 정지, 진동, …] 교대 패턴.
1144
+ * SDK에 존재하나 mock이 아직 없는 것으로 확인된 이름 목록.
1145
+ * API가 SDK에 추가되면 여기에 추가하고 별도 PR에서 mock 구현으로 이동한다.
1146
+ * 확인되지 않은 이름은 절대 여기에 추가하지 않는다 — throw가 더 안전하다.
1334
1147
  */
1335
- const HAPTIC_VIBRATE_PATTERN = {
1336
- tickWeak: 10,
1337
- tap: 20,
1338
- tickMedium: 30,
1339
- softMedium: 40,
1340
- basicWeak: 15,
1341
- basicMedium: 50,
1342
- success: [
1343
- 10,
1344
- 40,
1345
- 10
1346
- ],
1347
- error: [
1348
- 40,
1349
- 30,
1350
- 40
1351
- ],
1352
- wiggle: [
1353
- 20,
1354
- 20,
1355
- 20,
1356
- 20,
1357
- 20
1358
- ],
1359
- confetti: [
1360
- 10,
1361
- 20,
1362
- 10,
1363
- 20,
1364
- 10,
1365
- 20,
1366
- 10
1367
- ]
1368
- };
1148
+ const KNOWN_UNIMPLEMENTED = /* @__PURE__ */ new Set([]);
1149
+ function createMockProxy(moduleName, implementations) {
1150
+ return new Proxy(implementations, { get(target, prop) {
1151
+ if (typeof prop === "symbol") return void 0;
1152
+ if (prop in target) return target[prop];
1153
+ const name = String(prop);
1154
+ if (KNOWN_UNIMPLEMENTED.has(name)) return (...args) => {
1155
+ console.warn(`[@apps-in-toss/devtools] ${moduleName}.${name} is known-unimplemented (🔴 inert). Returning undefined. Please file or upvote an issue: ${ISSUES_URL}`);
1156
+ aitState.logSdkCall({
1157
+ method: `${moduleName}.${name}`,
1158
+ args,
1159
+ timestamp: Date.now(),
1160
+ status: "resolved",
1161
+ result: void 0,
1162
+ fidelity: "inert"
1163
+ });
1164
+ };
1165
+ throw new Error(`[@apps-in-toss/devtools] ${moduleName}.${prop} is not mocked. This API may exist in @apps-in-toss/web-framework, but devtools' mock does not cover it yet. Please file an issue: ${ISSUES_URL}`);
1166
+ } });
1167
+ }
1168
+ //#endregion
1169
+ //#region src/mock/device/storage.ts
1369
1170
  /**
1370
- * navigator.vibrate를 안전하게 호출한다.
1171
+ * Storage mock
1172
+ * localStorage에 `__ait_storage:` prefix로 저장하여 앱 자체 localStorage와 분리
1371
1173
  *
1372
- * @ait-co/polyfill/auto가 설치된 환경에서는 navigator.vibrate가 polyfill shim으로
1373
- * override되어 있고, shim은 내부적으로 mock의 generateHapticFeedback을 호출한다.
1374
- * mock이 다시 navigator.vibrate(현재 = shim)를 호출하면 무한 재귀가 발생한다.
1375
- * polyfill은 원본 vibrate를 BACKUP_KEY에 저장하므로 그쪽을 직접 호출한다.
1174
+ * 실기기(2.x×iOS) capture는 `setItem`/`removeItem`/`clearItems` 메서드가
1175
+ * `undefined`가 아니라 `null`로 resolve됨을 보였다(devtools#770). 원본 SDK 타입
1176
+ * 선언은 여전히 `Promise<void>`이므로 시그니처는 그대로 두고, 런타임 반환값만
1177
+ * `null`로 캐스트해 실측과 동치시킨다 Analytics·setClipboardText와 같은 처리(#775).
1178
+ * `getItem`은 이미 `string | null`이라 실측과 일치하므로 손대지 않는다.
1376
1179
  */
1377
- const POLYFILL_VIBRATE_BACKUP = Symbol.for("@ait-co/polyfill/vibrate.original");
1378
- function callVibrate(pattern) {
1379
- if (typeof navigator === "undefined") return false;
1380
- const nav = navigator;
1381
- const original = POLYFILL_VIBRATE_BACKUP in nav ? nav[POLYFILL_VIBRATE_BACKUP] : null;
1382
- if (typeof original === "function") return original(pattern);
1383
- return typeof navigator.vibrate === "function" ? navigator.vibrate(pattern) : false;
1384
- }
1385
- async function generateHapticFeedback(options) {
1386
- if (!Object.hasOwn(HAPTIC_VIBRATE_PATTERN, options.type)) throw buildNativeError("EXECUTION_ERROR");
1387
- const timestamp = Date.now();
1388
- aitState.logAnalytics({
1389
- type: "haptic",
1390
- params: { hapticType: options.type }
1391
- });
1392
- const vibrated = callVibrate(HAPTIC_VIBRATE_PATTERN[options.type] ?? 30);
1393
- aitState.logSdkCall({
1394
- method: "generateHapticFeedback",
1395
- args: [{ type: options.type }],
1396
- timestamp,
1397
- status: "resolved",
1398
- result: {
1399
- hapticType: options.type,
1400
- vibrated
1401
- },
1402
- fidelity: "partial"
1403
- });
1404
- }
1405
- async function saveBase64Data$1(params) {
1406
- if (params.data === "") throw buildNativeError("INVALID_DATA");
1407
- const a = document.createElement("a");
1408
- a.href = `data:${params.mimeType};base64,${params.data}`;
1409
- a.download = params.fileName;
1410
- a.click();
1411
- }
1180
+ const Storage = createMockProxy("Storage", {
1181
+ getItem: async (key) => {
1182
+ return localStorage.getItem(`__ait_storage:${key}`);
1183
+ },
1184
+ setItem: async (key, value) => {
1185
+ localStorage.setItem(`__ait_storage:${key}`, value);
1186
+ return null;
1187
+ },
1188
+ removeItem: async (key) => {
1189
+ localStorage.removeItem(`__ait_storage:${key}`);
1190
+ return null;
1191
+ },
1192
+ clearItems: async () => {
1193
+ const keys = Object.keys(localStorage).filter((k) => k.startsWith("__ait_storage:"));
1194
+ for (const k of keys) localStorage.removeItem(k);
1195
+ return null;
1196
+ }
1197
+ });
1412
1198
  //#endregion
1413
- //#region src/mock/device/location.ts
1199
+ //#region src/mock/observe.ts
1414
1200
  /**
1415
- * Location mock (getCurrentLocation, startUpdateLocation)
1416
- * mock/web/prompt 모드 지원
1201
+ * fn을 observe로 감싼다.
1202
+ *
1203
+ * @param apiName - 로그에 기록할 SDK 메서드 이름 (예: `'setScreenAwakeMode'`)
1204
+ * @param fidelity - 이 mock의 fidelity grade ('faithful' | 'partial' | 'inert')
1205
+ * @param fn - 실제 mock 구현체. 시그니처를 그대로 통과시킨다.
1206
+ * @returns fn과 동일한 타입의 래퍼 함수
1417
1207
  */
1418
- var Accuracy = /* @__PURE__ */ function(Accuracy) {
1419
- Accuracy[Accuracy["Lowest"] = 1] = "Lowest";
1420
- Accuracy[Accuracy["Low"] = 2] = "Low";
1421
- Accuracy[Accuracy["Balanced"] = 3] = "Balanced";
1422
- Accuracy[Accuracy["High"] = 4] = "High";
1423
- Accuracy[Accuracy["Highest"] = 5] = "Highest";
1424
- Accuracy[Accuracy["BestForNavigation"] = 6] = "BestForNavigation";
1425
- return Accuracy;
1426
- }(Accuracy || {});
1427
- function buildLocation() {
1428
- return {
1429
- coords: { ...aitState.state.location.coords },
1430
- timestamp: Date.now(),
1431
- accessLocation: aitState.state.location.accessLocation
1432
- };
1433
- }
1434
- async function getCurrentLocationMock() {
1435
- const { coords, timestamp } = buildLocation();
1436
- return {
1437
- coords,
1438
- timestamp
1439
- };
1440
- }
1441
- async function getCurrentLocationWeb() {
1442
- return new Promise((resolve) => {
1443
- if (!navigator.geolocation) {
1444
- console.warn("[@apps-in-toss/devtools] Geolocation API not available, falling back to mock");
1445
- resolve(buildLocation());
1446
- return;
1447
- }
1448
- navigator.geolocation.getCurrentPosition((pos) => {
1449
- resolve({
1450
- coords: {
1451
- latitude: pos.coords.latitude,
1452
- longitude: pos.coords.longitude,
1453
- altitude: pos.coords.altitude ?? 0,
1454
- accuracy: pos.coords.accuracy,
1455
- altitudeAccuracy: pos.coords.altitudeAccuracy ?? 0,
1456
- heading: pos.coords.heading ?? 0
1457
- },
1458
- timestamp: pos.timestamp
1208
+ function observe(apiName, fidelity, fn) {
1209
+ return (...args) => {
1210
+ const timestamp = Date.now();
1211
+ const safeArgs = args.map((a) => safeSerialize(a));
1212
+ const result = fn(...args);
1213
+ if (result instanceof Promise) {
1214
+ aitState.logSdkCall({
1215
+ method: apiName,
1216
+ args: safeArgs,
1217
+ timestamp,
1218
+ status: "pending",
1219
+ fidelity
1459
1220
  });
1460
- }, () => {
1461
- console.warn("[@apps-in-toss/devtools] Geolocation failed, falling back to mock");
1462
- resolve(buildLocation());
1221
+ result.then((value) => {
1222
+ aitState.logSdkCall({
1223
+ method: apiName,
1224
+ args: safeArgs,
1225
+ timestamp,
1226
+ status: "resolved",
1227
+ result: safeSerialize(value),
1228
+ fidelity
1229
+ });
1230
+ }, (err) => {
1231
+ aitState.logSdkCall({
1232
+ method: apiName,
1233
+ args: safeArgs,
1234
+ timestamp,
1235
+ status: "rejected",
1236
+ error: err instanceof Error ? err.message : String(err),
1237
+ fidelity
1238
+ });
1239
+ });
1240
+ return result;
1241
+ }
1242
+ aitState.logSdkCall({
1243
+ method: apiName,
1244
+ args: safeArgs,
1245
+ timestamp,
1246
+ status: "resolved",
1247
+ result: safeSerialize(result),
1248
+ fidelity
1463
1249
  });
1464
- });
1465
- }
1466
- async function getCurrentLocationPrompt() {
1467
- const { coords, timestamp } = await waitForPromptResponse("location");
1468
- return {
1469
- coords,
1470
- timestamp
1250
+ return result;
1471
1251
  };
1472
1252
  }
1473
- const _getCurrentLocation = async (_options) => {
1474
- checkPermission("geolocation", "getCurrentLocation");
1475
- checkThrottle("getCurrentLocation");
1476
- const mode = aitState.state.deviceModes.location;
1477
- if (mode === "web") return getCurrentLocationWeb();
1478
- if (mode === "prompt") return getCurrentLocationPrompt();
1479
- return getCurrentLocationMock();
1480
- };
1481
- const getCurrentLocation = withPermission(_getCurrentLocation, "geolocation");
1482
- function startUpdateLocationMock(eventParams) {
1483
- const { onEvent, options } = eventParams;
1484
- const interval = Math.max(options.timeInterval, 500);
1485
- const id = setInterval(() => {
1486
- const loc = buildLocation();
1487
- loc.coords.latitude += (Math.random() - .5) * 1e-4;
1488
- loc.coords.longitude += (Math.random() - .5) * 1e-4;
1489
- onEvent(loc);
1490
- }, interval);
1491
- return () => clearInterval(id);
1253
+ /**
1254
+ * 값을 JSON-safe한 형태로 변환한다.
1255
+ * - null / primitive — 그대로.
1256
+ * - 함수 — `'[Function: name]'` 문자열.
1257
+ * - 기타 객체 JSON.stringify 실패 시 `'[unserializable]'`.
1258
+ */
1259
+ function safeSerialize(value) {
1260
+ if (value === null || value === void 0) return value;
1261
+ if (typeof value === "function") return `[Function: ${value.name || "anonymous"}]`;
1262
+ if (typeof value !== "object") return value;
1263
+ try {
1264
+ return JSON.parse(JSON.stringify(value));
1265
+ } catch {
1266
+ return "[unserializable]";
1267
+ }
1492
1268
  }
1493
- function startUpdateLocationWeb(eventParams) {
1494
- const { onEvent, onError } = eventParams;
1495
- if (!navigator.geolocation) {
1496
- console.warn("[@apps-in-toss/devtools] Geolocation API not available, falling back to mock");
1497
- return startUpdateLocationMock(eventParams);
1269
+ //#endregion
1270
+ //#region src/mock/navigation/index.ts
1271
+ async function closeView() {
1272
+ console.log("[@apps-in-toss/devtools] closeView called");
1273
+ window.history.back();
1274
+ }
1275
+ async function openURL(url) {
1276
+ console.log("[@apps-in-toss/devtools] openURL:", url);
1277
+ window.open(url, "_blank");
1278
+ }
1279
+ async function share(message) {
1280
+ if (navigator.share) {
1281
+ await navigator.share({ text: message.message });
1282
+ return;
1498
1283
  }
1499
- const watchId = navigator.geolocation.watchPosition((pos) => {
1500
- onEvent({
1501
- coords: {
1502
- latitude: pos.coords.latitude,
1503
- longitude: pos.coords.longitude,
1504
- altitude: pos.coords.altitude ?? 0,
1505
- accuracy: pos.coords.accuracy,
1506
- altitudeAccuracy: pos.coords.altitudeAccuracy ?? 0,
1507
- heading: pos.coords.heading ?? 0
1508
- },
1509
- timestamp: pos.timestamp,
1510
- accessLocation: "FINE"
1511
- });
1512
- }, (err) => onError(err));
1513
- return () => navigator.geolocation.clearWatch(watchId);
1284
+ console.log("[@apps-in-toss/devtools] share:", message.message);
1514
1285
  }
1515
- function startUpdateLocationPrompt(eventParams) {
1516
- const { onEvent } = eventParams;
1517
- const handler = (e) => {
1518
- onEvent(e.detail);
1519
- };
1520
- window.addEventListener("__ait:prompt-response:location-update", handler);
1521
- window.dispatchEvent(new CustomEvent("__ait:prompt-request", { detail: { type: "location-update" } }));
1522
- return () => window.removeEventListener("__ait:prompt-response:location-update", handler);
1286
+ const URI_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
1287
+ async function getTossShareLink(path, _ogImageUrl) {
1288
+ if (!URI_SCHEME_PATTERN.test(path)) throw buildNativeError("EXECUTION_ERROR");
1289
+ return `https://toss.im/share/mock${path}`;
1523
1290
  }
1524
- const _startUpdateLocation = (eventParams) => {
1525
- const mode = aitState.state.deviceModes.location;
1526
- if (mode === "web") return startUpdateLocationWeb(eventParams);
1527
- if (mode === "prompt") return startUpdateLocationPrompt(eventParams);
1528
- return startUpdateLocationMock(eventParams);
1529
- };
1530
- const startUpdateLocation = withPermission(_startUpdateLocation, "geolocation");
1531
- //#endregion
1532
- //#region src/mock/device/network.ts
1533
- /**
1534
- * Network Status mock (mode-aware helper)
1535
- * navigation 모듈에서 사용. circular dep 방지를 위해 device에 위치.
1536
- */
1291
+ async function setIosSwipeGestureEnabled(options) {
1292
+ console.log("[@apps-in-toss/devtools] setIosSwipeGestureEnabled:", options.isEnabled);
1293
+ aitState.patch("navigation", { iosSwipeGestureEnabled: options.isEnabled });
1294
+ }
1295
+ async function setDeviceOrientation$1(options) {
1296
+ const current = aitState.state.viewport.orientation;
1297
+ if (current === "auto") {
1298
+ console.log("[@apps-in-toss/devtools] setDeviceOrientation:", options.type);
1299
+ aitState.patch("viewport", { appOrientation: options.type });
1300
+ return;
1301
+ }
1302
+ console.warn(`[@apps-in-toss/devtools] setDeviceOrientation(${options.type}) ignored Panel is forcing "${current}". Change the Viewport tab's orientation to "auto" to let the app control rotation.`);
1303
+ }
1304
+ const setScreenAwakeMode = observe("setScreenAwakeMode", "inert", async (options) => {
1305
+ console.log("[@apps-in-toss/devtools] setScreenAwakeMode:", options.enabled);
1306
+ return { enabled: options.enabled };
1307
+ });
1308
+ const setSecureScreen = observe("setSecureScreen", "inert", async (options) => {
1309
+ console.log("[@apps-in-toss/devtools] setSecureScreen:", options.enabled);
1310
+ return { enabled: options.enabled };
1311
+ });
1312
+ const requestReview$1 = observe("requestReview", "inert", async () => {
1313
+ console.log("[@apps-in-toss/devtools] requestReview called");
1314
+ });
1315
+ requestReview$1.isSupported = () => true;
1316
+ function getTossAppVersion() {
1317
+ return aitState.state.appVersion;
1318
+ }
1319
+ function getGroupId() {
1320
+ return aitState.state.groupId;
1321
+ }
1322
+ async function getNetworkStatus() {
1323
+ const modeResult = getNetworkStatusByMode();
1324
+ if (modeResult) return modeResult;
1325
+ return aitState.state.networkStatus;
1326
+ }
1327
+ const _getServerTimeImpl = async () => {
1328
+ return Date.now();
1329
+ };
1330
+ const getServerTime$1 = Object.assign(_getServerTimeImpl, { isSupported: () => true });
1331
+ /**
1332
+ * 현재 backEvent 구독자 수. graniteEvent.addEventListener('backEvent', …)가
1333
+ * 증가시키고, 반환된 cleanup이 감소시킨다. 호스트 back 메시지 처리 시 인터셉트
1334
+ * 여부를 판단하는 데 쓰인다.
1335
+ *
1336
+ * @internal 테스트 및 safe-area-bridge에서만 사용.
1337
+ */
1338
+ let _backEventSubscriberCount = 0;
1339
+ const graniteEvent = { addEventListener(event, { onEvent, onError }) {
1340
+ const handler = () => {
1341
+ try {
1342
+ onEvent();
1343
+ } catch (e) {
1344
+ onError?.(e instanceof Error ? e : new Error(String(e)));
1345
+ }
1346
+ };
1347
+ window.addEventListener(`__ait:${event}`, handler);
1348
+ if (event === "backEvent") _backEventSubscriberCount++;
1349
+ let cleaned = false;
1350
+ return () => {
1351
+ if (cleaned) return;
1352
+ cleaned = true;
1353
+ window.removeEventListener(`__ait:${event}`, handler);
1354
+ if (event === "backEvent") _backEventSubscriberCount--;
1355
+ };
1356
+ } };
1357
+ /**
1358
+ * 호스트 back 내비게이션을 처리한다.
1359
+ *
1360
+ * backEvent 구독자가 1명 이상이면 `window.dispatchEvent(new CustomEvent('__ait:backEvent'))`만
1361
+ * 발사한다 — 미니앱이 back을 가로채는(intercept) 채널이고 실제 토스 호스트와 동일한 시맨틱.
1362
+ * 구독자가 없으면 `history.back()`을 호출해 기본 브라우저 뒤로가기를 수행한다.
1363
+ *
1364
+ * env 1 패널의 back 버튼(`src/panel/viewport.ts` `aitState.trigger('backEvent')`)과
1365
+ * 동일한 경로를 거쳐 back 시맨틱의 단일 소유처를 navigation 모듈에 유지한다.
1366
+ */
1367
+ function dispatchHostBackNavigation() {
1368
+ if (_backEventSubscriberCount > 0) window.dispatchEvent(new CustomEvent("__ait:backEvent"));
1369
+ else history.back();
1370
+ }
1371
+ const appsInTossEvent = { addEventListener(_event, _handlers) {
1372
+ return () => {};
1373
+ } };
1374
+ const tdsEvent = { addEventListener(event, { onEvent }) {
1375
+ const handler = (e) => {
1376
+ const detail = e.detail;
1377
+ onEvent(detail);
1378
+ };
1379
+ window.addEventListener(`__ait:${event}`, handler);
1380
+ return () => window.removeEventListener(`__ait:${event}`, handler);
1381
+ } };
1382
+ /**
1383
+ * @deprecated web-framework 3.0 에서 제거됨. 2.x 소비자 back-compat용으로 유지.
1384
+ */
1385
+ function onVisibilityChangedByTransparentServiceWeb(eventParams) {
1386
+ const handler = () => eventParams.onEvent(!document.hidden);
1387
+ document.addEventListener("visibilitychange", handler);
1388
+ return () => document.removeEventListener("visibilitychange", handler);
1389
+ }
1390
+ const env = { getDeploymentId: () => aitState.state.deploymentId };
1391
+ function getAppsInTossGlobals() {
1392
+ return {
1393
+ deploymentId: aitState.state.deploymentId,
1394
+ brandDisplayName: aitState.state.brand.displayName,
1395
+ brandIcon: aitState.state.brand.icon,
1396
+ brandPrimaryColor: aitState.state.brand.primaryColor
1397
+ };
1398
+ }
1399
+ const SafeAreaInsets = {
1400
+ get: () => ({ ...aitState.state.safeAreaInsets }),
1401
+ subscribe: ({ onEvent }) => {
1402
+ return aitState.subscribe(() => onEvent({ ...aitState.state.safeAreaInsets }));
1403
+ }
1404
+ };
1405
+ //#endregion
1406
+ //#region src/mock/safe-area-bridge.ts
1407
+ /**
1408
+ * env-2 postMessage bridges (#484, #510).
1409
+ *
1410
+ * In the AITC Sandbox PWA (env 2) the dev app runs inside the launcher's
1411
+ * full-viewport `<iframe>`. The launcher is the top-level document, so its
1412
+ * `env(safe-area-inset-*)` measurement is the ground truth for the real device
1413
+ * geometry. The framed page's mock would otherwise report a synthetic preset
1414
+ * value (e.g. top=54), which sdk-example then double-pads on top of a viewport
1415
+ * that already starts below the status bar — the env-2 "dead band" defect.
1416
+ *
1417
+ * This module installs receive-half listeners for two message types:
1418
+ *
1419
+ * 1. `ait:safe-area-insets` (#484): the launcher forwards its real env() insets
1420
+ * to the framed page on iframe load and resize/orientationchange. Validates the
1421
+ * envelope and writes real insets into the mock SafeAreaInsets state, firing the
1422
+ * subscribe path (see navigation/index.ts) so apps that subscribe re-read the
1423
+ * corrected values.
1424
+ *
1425
+ * 2. `ait:navigate-back` (#510): the launcher partner bar's `←` button posts this
1426
+ * command to the framed page. The receive half calls `dispatchHostBackNavigation()`
1427
+ * (navigation/index.ts): if backEvent subscribers are present, a `__ait:backEvent`
1428
+ * CustomEvent is dispatched (the mini-app intercept channel, matching the env-1
1429
+ * panel path); otherwise `history.back()` is called. No data other than `type` is
1430
+ * read from or written to the message — shape validation rejects anything that
1431
+ * carries extra fields with the wrong type. Apps that do not install this mock
1432
+ * (older builds) silently ignore the message (natural no-op).
1433
+ *
1434
+ * Origin policy: neither message type carries sensitive data, so we do NOT
1435
+ * restrict by origin — the launcher posts cross-origin from a *.trycloudflare.com
1436
+ * tunnel with targetOrigin '*'. Shape validation is still mandatory: a malformed
1437
+ * or out-of-range message is silently ignored so a stray postMessage can never
1438
+ * corrupt the mock state or trigger spurious navigation.
1439
+ *
1440
+ * Message-driven by design: env 1 (desktop browser, no launcher) never receives
1441
+ * these messages, so the panel preset stays authoritative there with zero special
1442
+ * casing here.
1443
+ */
1444
+ /** The postMessage envelope the launcher posts to the framed dev app (inset forward). */
1445
+ const SAFE_AREA_INSETS_MESSAGE_TYPE = "ait:safe-area-insets";
1446
+ /**
1447
+ * The postMessage command the launcher partner bar's `←` button sends to the
1448
+ * framed dev app (#510). The framed page calls `history.back()` in response.
1449
+ *
1450
+ * Protocol: only `{ type: 'ait:navigate-back' }` is valid. No other fields are
1451
+ * read or acted on — extra fields are silently ignored by the shape guard.
1452
+ * Game variant never sends this message (back button is partner-bar-only).
1453
+ */
1454
+ const NAVIGATE_BACK_MESSAGE_TYPE = "ait:navigate-back";
1455
+ /**
1456
+ * The postMessage envelope the framed mini-app self-reports its webViewType
1457
+ * with (#580). The mini-app knows its own type from the build constant
1458
+ * `__WEB_VIEW_TYPE__` (`granite.config.ts`'s `webViewProps.type`, injected by
1459
+ * the devtools unplugin). The launcher is cross-origin so it cannot read that
1460
+ * constant directly — the mini-app posts it to `window.parent` once so the
1461
+ * launcher (env-2 PWA shell) switches to game mode automatically, with no
1462
+ * manual `?navBarType=game` URL edit.
1463
+ *
1464
+ * Direction: this is the SEND side's contract (posted from inside the iframe by
1465
+ * `@ait-co/debug-console`'s `packages/debug-console/src/attach.ts`). The
1466
+ * launcher's receive half lives in
1467
+ * `e2e/fixture/launcher/Launcher.tsx` and mirrors the same value enum inline,
1468
+ * staying decoupled from the mock package internals — the same pattern the
1469
+ * other launcher message types follow.
1470
+ *
1471
+ * Value enum: only `'partner'` and `'game'` are valid. The SDK's deprecated
1472
+ * `'external'` alias of `partner` (web-framework 2.6.1) is mapped to `'partner'`
1473
+ * at the send site so the wire only ever carries the two shapes the launcher
1474
+ * emulates.
1475
+ */
1476
+ const WEB_VIEW_TYPE_MESSAGE_TYPE = "ait:web-view-type";
1477
+ const MAX_INSET_PX = 200;
1478
+ function isValidInset(value) {
1479
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= MAX_INSET_PX;
1480
+ }
1481
+ /**
1482
+ * Parse + validate a raw postMessage payload into a `SafeAreaInsets`, or return
1483
+ * null when it is not a well-formed `ait:safe-area-insets` message. Pure — unit
1484
+ * tested without a real MessageEvent.
1485
+ */
1486
+ function parseSafeAreaInsetsMessage(data) {
1487
+ if (typeof data !== "object" || data === null) return null;
1488
+ if (data.type !== "ait:safe-area-insets") return null;
1489
+ const insets = data.insets;
1490
+ if (typeof insets !== "object" || insets === null) return null;
1491
+ const { top, bottom, left, right } = insets;
1492
+ if (!isValidInset(top) || !isValidInset(bottom) || !isValidInset(left) || !isValidInset(right)) return null;
1493
+ return {
1494
+ top,
1495
+ bottom,
1496
+ left,
1497
+ right
1498
+ };
1499
+ }
1500
+ /**
1501
+ * Parse + validate a raw postMessage payload into a webViewType value
1502
+ * (`'partner'` | `'game'`), or return `null` when it is not a well-formed
1503
+ * `ait:web-view-type` message (#580). Pure — unit tested without a real
1504
+ * MessageEvent.
1505
+ *
1506
+ * Strict shape guard (the safety boundary for the cross-origin receive path):
1507
+ * the payload must be a non-null object whose `type` is exactly
1508
+ * {@link WEB_VIEW_TYPE_MESSAGE_TYPE} and whose `value` is exactly `'partner'`
1509
+ * or `'game'` (an enum allow-list). Anything else — a foreign type, a missing
1510
+ * or non-string value, the deprecated `'external'` alias, or any other string —
1511
+ * returns `null` so a stray postMessage can never flip the launcher's visual
1512
+ * mode. The send site is responsible for collapsing `'external'` → `'partner'`
1513
+ * before posting; the parser does NOT silently accept it.
1514
+ */
1515
+ function parseWebViewTypeMessage(data) {
1516
+ if (typeof data !== "object" || data === null) return null;
1517
+ if (data.type !== "ait:web-view-type") return null;
1518
+ const value = data.value;
1519
+ if (value === "partner" || value === "game") return value;
1520
+ return null;
1521
+ }
1522
+ /**
1523
+ * Apply forwarded insets to the mock state. Skips the write (and the resulting
1524
+ * subscribe notify) when nothing changed, so repeated identical messages from a
1525
+ * resize storm don't churn subscribers.
1526
+ */
1527
+ function applyForwardedSafeAreaInsets(insets) {
1528
+ const current = aitState.state.safeAreaInsets;
1529
+ if (current.top === insets.top && current.bottom === insets.bottom && current.left === insets.left && current.right === insets.right) return;
1530
+ aitState.update({ safeAreaInsets: insets });
1531
+ }
1532
+ /**
1533
+ * The `id` attribute of the `<style>` element injected by
1534
+ * {@link applyEnv2Compensation}. Using a stable id makes the injection
1535
+ * idempotent — the element is reused on every subsequent message rather than
1536
+ * accumulating multiple `<style>` nodes.
1537
+ */
1538
+ const ENV2_COMPENSATION_STYLE_ID = "ait-env2-safe-area-compensation";
1539
+ /**
1540
+ * The CSS text injected when the launcher forwards partner-mode insets
1541
+ * (`top === 0`). The negative margin pulls the mini-app document up by exactly
1542
+ * the device's status-bar inset so the launcher's own bar offset is not
1543
+ * double-counted.
1544
+ *
1545
+ * jsdom cannot evaluate `env()` expressions, so unit tests assert on this
1546
+ * string literal rather than computed geometry.
1547
+ */
1548
+ const ENV2_COMPENSATION_CSS = "body { margin-top: calc(-1 * env(safe-area-inset-top)); }";
1549
+ /**
1550
+ * Inject or remove the env-2 safe-area compensation `<style>` based on the
1551
+ * forwarded insets received from the launcher.
1552
+ *
1553
+ * ### Why this is needed
1554
+ *
1555
+ * In env 2 (AITC Sandbox PWA) the launcher frames the mini-app in a
1556
+ * full-bleed `<iframe>` whose top edge is already positioned BELOW the
1557
+ * launcher's partner bar (at `env(safe-area-inset-top) + 54px`). However,
1558
+ * inside the cross-origin child iframe, CSS `env(safe-area-inset-top)` STILL
1559
+ * reports the full device status-bar inset (e.g. 62 px on an iPhone 15). Any
1560
+ * mini-app that pads itself by that CSS `env()` value ends up double-counting
1561
+ * the status-bar, producing a ≈62 px white dead-band between the partner bar
1562
+ * and the first content.
1563
+ *
1564
+ * The fix is a compensating negative margin on `body` inside the iframe. When
1565
+ * the launcher forwards partner insets (`top === 0`, meaning "the iframe
1566
+ * already starts below the bar"), we inject:
1567
+ *
1568
+ * ```css
1569
+ * body { margin-top: calc(-1 * env(safe-area-inset-top)); }
1570
+ * ```
1571
+ *
1572
+ * This cancels the duplicate padding without knowing the app's internal DOM
1573
+ * structure. The launcher's html/body `screen.height` force (#527) lives in the
1574
+ * LAUNCHER document (cross-origin) and never touches the mini-app document, so
1575
+ * margin and height force compose independently — they cannot interfere.
1576
+ *
1577
+ * ### Gating
1578
+ *
1579
+ * - **`top === 0`** (partner mode) → install the style (dead-band compensation).
1580
+ * - **`top > 0`** (game mode — launcher forwards the raw device inset for the
1581
+ * floating status-bar capsule) → remove any previously installed style so
1582
+ * `env()` is the authoritative clearance and nothing is double-removed.
1583
+ *
1584
+ * On every insets message (resize/orientation re-post) this function is called
1585
+ * so the gate is re-evaluated and the style toggled accordingly.
1586
+ *
1587
+ * ### env 1 / env 3 / env 4 no-regression
1588
+ *
1589
+ * This function is only called from the `message` listener installed by
1590
+ * {@link installSafeAreaInsetsBridge}. That listener fires only when the
1591
+ * launcher posts an `ait:safe-area-insets` message, which never happens in
1592
+ * env 1 (desktop browser, no launcher), env 3 (intoss-private WebView), or
1593
+ * env 4 (live relay) — they have no launcher posting to the iframe.
1594
+ *
1595
+ * @param top - The forwarded `top` inset value from the launcher message.
1596
+ */
1597
+ function applyEnv2Compensation(top) {
1598
+ if (typeof document === "undefined") return;
1599
+ const existing = document.getElementById(ENV2_COMPENSATION_STYLE_ID);
1600
+ if (top === 0) {
1601
+ if (existing !== null) return;
1602
+ const style = document.createElement("style");
1603
+ style.id = ENV2_COMPENSATION_STYLE_ID;
1604
+ style.textContent = ENV2_COMPENSATION_CSS;
1605
+ document.head.appendChild(style);
1606
+ } else if (existing !== null) existing.remove();
1607
+ }
1608
+ let installed = false;
1609
+ /**
1610
+ * Install the window `message` listener that receives forwarded insets. Safe to
1611
+ * call multiple times (idempotent) and a no-op outside a browser (SSR/jsdom
1612
+ * without a window). Imported for its side effect by the mock barrel so any
1613
+ * consumer that aliases `@apps-in-toss/web-framework` to the mock gets it wired.
1614
+ *
1615
+ * On each valid `ait:safe-area-insets` message:
1616
+ * 1. Writes the corrected insets into the mock `SafeAreaInsets` state
1617
+ * (existing #484 behaviour — for apps that still read SDK insets).
1618
+ * 2. Drives the env-2 dead-band compensation style via
1619
+ * {@link applyEnv2Compensation} — injects a `body { margin-top: calc(-1 *
1620
+ * env(safe-area-inset-top)) }` style when `top === 0` (partner mode) and
1621
+ * removes it when `top > 0` (game / full-bleed mode).
1622
+ */
1623
+ function installSafeAreaInsetsBridge() {
1624
+ if (installed || typeof window === "undefined") return;
1625
+ installed = true;
1626
+ window.addEventListener("message", (event) => {
1627
+ const insets = parseSafeAreaInsetsMessage(event.data);
1628
+ if (insets) {
1629
+ applyForwardedSafeAreaInsets(insets);
1630
+ applyEnv2Compensation(insets.top);
1631
+ }
1632
+ });
1633
+ }
1634
+ /**
1635
+ * Parse a raw postMessage payload as an `ait:navigate-back` command.
1636
+ *
1637
+ * Returns true when the payload is a well-formed navigate-back command
1638
+ * (`{ type: 'ait:navigate-back' }`), false otherwise. Pure — unit tested
1639
+ * without a real MessageEvent.
1640
+ *
1641
+ * Shape guard: only the `type` field is inspected; any extra fields are
1642
+ * ignored so future extensions do not break older receivers. The function
1643
+ * does NOT read any data field beyond `type` — no sensitive values, no host
1644
+ * disclosure (same principle as the insets bridge).
1645
+ */
1646
+ function isNavigateBackMessage(data) {
1647
+ if (typeof data !== "object" || data === null) return false;
1648
+ return data.type === NAVIGATE_BACK_MESSAGE_TYPE;
1649
+ }
1650
+ let navigateBackInstalled = false;
1651
+ /**
1652
+ * Install the window `message` listener that handles `ait:navigate-back`
1653
+ * commands (#510). When the launcher partner bar's `←` button is clicked it
1654
+ * posts `{ type: 'ait:navigate-back' }` to the framed dev app; this listener
1655
+ * calls `dispatchHostBackNavigation()` from the navigation module.
1656
+ *
1657
+ * Dispatch semantics: if there are any `graniteEvent.addEventListener('backEvent', …)`
1658
+ * subscribers the CustomEvent `__ait:backEvent` is fired (same path as the env-1
1659
+ * panel back button — the mini-app intercept channel). When there are no
1660
+ * subscribers `history.back()` is called as the fallback. Back semantics are
1661
+ * owned entirely by the navigation module; this bridge only delegates.
1662
+ *
1663
+ * Safe to call multiple times (idempotent) and a no-op outside a browser.
1664
+ * Installed together with the inset bridge by `installBridges()` so any consumer
1665
+ * of the mock barrel gets both wired automatically.
1666
+ *
1667
+ * No-op on apps that predate this bridge — the launcher posts the message but
1668
+ * older mocks simply have no listener (harmless).
1669
+ */
1670
+ function installNavigateBackBridge() {
1671
+ if (navigateBackInstalled || typeof window === "undefined") return;
1672
+ navigateBackInstalled = true;
1673
+ window.addEventListener("message", (event) => {
1674
+ if (isNavigateBackMessage(event.data)) dispatchHostBackNavigation();
1675
+ });
1676
+ }
1677
+ /**
1678
+ * Install both env-2 postMessage bridges in one call (#484 insets + #510
1679
+ * navigate-back). The mock barrel calls this at import time so consumers get
1680
+ * all bridges wired without any explicit setup.
1681
+ */
1682
+ function installBridges() {
1683
+ installSafeAreaInsetsBridge();
1684
+ installNavigateBackBridge();
1685
+ }
1686
+ //#endregion
1687
+ //#region src/mock/ads/index.ts
1688
+ function withIsSupported(fn) {
1689
+ fn.isSupported = () => true;
1690
+ return fn;
1691
+ }
1692
+ const _slotRegistry = /* @__PURE__ */ new Map();
1693
+ let _slotCounter = 0;
1694
+ function _nextSlotId(adGroupId) {
1695
+ _slotCounter += 1;
1696
+ return `mock-slot-${adGroupId}-${_slotCounter}`;
1697
+ }
1698
+ const GoogleAdMob$1 = createMockProxy("GoogleAdMob", {
1699
+ loadAppsInTossAdMob: withIsSupported(observe("GoogleAdMob.loadAppsInTossAdMob", "faithful", (args) => {
1700
+ setTimeout(() => {
1701
+ const failureCode = aitState.state.failureModes.loadAdMob;
1702
+ if (failureCode) {
1703
+ args.onError(buildNativeError(failureCode));
1704
+ return;
1705
+ }
1706
+ const throttleError = throttleErrorFor("loadAppsInTossAdMob");
1707
+ if (throttleError) {
1708
+ args.onError(throttleError);
1709
+ return;
1710
+ }
1711
+ if (aitState.state.ads.forceNoFill) {
1712
+ args.onError(/* @__PURE__ */ new Error("No fill"));
1713
+ return;
1714
+ }
1715
+ aitState.patch("ads", { isLoaded: true });
1716
+ args.onEvent({
1717
+ type: "loaded",
1718
+ data: {
1719
+ adGroupId: args.options.adGroupId,
1720
+ adUnitId: `mock-unit-${args.options.adGroupId}`,
1721
+ responseInfo: {
1722
+ responseId: `mock-response-${args.options.adGroupId}`,
1723
+ adNetworkInfoArray: [],
1724
+ loadedAdNetworkInfo: null
1725
+ }
1726
+ }
1727
+ });
1728
+ }, 200);
1729
+ return () => {};
1730
+ })),
1731
+ showAppsInTossAdMob: withIsSupported(observe("GoogleAdMob.showAppsInTossAdMob", "faithful", (args) => {
1732
+ if (!aitState.state.ads.isLoaded) {
1733
+ args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
1734
+ return () => {};
1735
+ }
1736
+ const { rewardUnitType, rewardAmount } = aitState.state.ads;
1737
+ setTimeout(() => args.onEvent({
1738
+ type: "userEarnedReward",
1739
+ data: {
1740
+ unitType: rewardUnitType,
1741
+ unitAmount: rewardAmount
1742
+ }
1743
+ }), 1e3);
1744
+ setTimeout(() => {
1745
+ args.onEvent({ type: "dismissed" });
1746
+ aitState.patch("ads", { isLoaded: false });
1747
+ }, 1500);
1748
+ return () => {};
1749
+ })),
1750
+ isAppsInTossAdMobLoaded: withIsSupported(observe("GoogleAdMob.isAppsInTossAdMobLoaded", "faithful", async (_options) => {
1751
+ if (_options?.adGroupId !== void 0 && _options.adGroupId.trim() === "") throw buildNativeError("INVALID_REQUEST");
1752
+ return aitState.state.ads.isLoaded;
1753
+ }))
1754
+ });
1755
+ const TossAds = createMockProxy("TossAds", {
1756
+ initialize: withIsSupported(observe("TossAds.initialize", "partial", (options) => {
1757
+ if (aitState.state.ads.forceNoFill) {
1758
+ options.callbacks?.onInitializationFailed?.(/* @__PURE__ */ new Error("No fill"));
1759
+ return;
1760
+ }
1761
+ options.callbacks?.onInitialized?.();
1762
+ })),
1763
+ attach: withIsSupported(observe("TossAds.attach", "partial", (_adGroupId, target, _options) => {
1764
+ const el = typeof target === "string" ? document.querySelector(target) : target;
1765
+ if (el) {
1766
+ const placeholder = document.createElement("div");
1767
+ placeholder.style.cssText = "background:#f0f0f0;border:1px dashed #999;padding:16px;text-align:center;color:#666;font-size:14px;";
1768
+ placeholder.textContent = "[@apps-in-toss/devtools] TossAds Placeholder";
1769
+ el.appendChild(placeholder);
1770
+ }
1771
+ })),
1772
+ attachBanner: withIsSupported(observe("TossAds.attachBanner", "faithful", (adGroupId, target, options) => {
1773
+ const el = typeof target === "string" ? document.querySelector(target) : target;
1774
+ const slotId = _nextSlotId(adGroupId);
1775
+ const placeholder = document.createElement("div");
1776
+ const theme = options?.theme ?? "auto";
1777
+ const variant = options?.variant ?? "card";
1778
+ const isDark = theme === "dark" || theme === "auto" && typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches;
1779
+ const bg = isDark ? "#1a1a1a" : "#f0f0f0";
1780
+ const textColor = isDark ? "#aaa" : "#666";
1781
+ const borderColor = isDark ? "#555" : "#999";
1782
+ const height = variant === "expanded" ? "120px" : "60px";
1783
+ placeholder.dataset.aitSlotId = slotId;
1784
+ placeholder.style.cssText = `background:${bg};border:1px dashed ${borderColor};padding:8px 12px;text-align:center;color:${textColor};font-size:12px;min-height:${height};display:flex;align-items:center;justify-content:center;`;
1785
+ placeholder.textContent = `[@apps-in-toss/devtools] Banner Ad (${variant})`;
1786
+ if (el) {
1787
+ el.appendChild(placeholder);
1788
+ _slotRegistry.set(slotId, placeholder);
1789
+ }
1790
+ const destroySlot = () => {
1791
+ const registered = _slotRegistry.get(slotId);
1792
+ if (registered) {
1793
+ registered.remove();
1794
+ _slotRegistry.delete(slotId);
1795
+ }
1796
+ };
1797
+ setTimeout(() => {
1798
+ if (aitState.state.ads.forceNoFill) {
1799
+ options?.callbacks?.onNoFill?.({
1800
+ slotId,
1801
+ adGroupId,
1802
+ adMetadata: {}
1803
+ });
1804
+ options?.callbacks?.onAdFailedToRender?.({
1805
+ slotId,
1806
+ adGroupId,
1807
+ adMetadata: {},
1808
+ error: {
1809
+ code: 0,
1810
+ message: "No fill"
1811
+ }
1812
+ });
1813
+ return;
1814
+ }
1815
+ const eventPayload = {
1816
+ slotId,
1817
+ adGroupId,
1818
+ adMetadata: {
1819
+ creativeId: `mock-creative-${slotId}`,
1820
+ requestId: `mock-req-${slotId}`
1821
+ }
1822
+ };
1823
+ options?.callbacks?.onAdRendered?.(eventPayload);
1824
+ options?.callbacks?.onAdImpression?.(eventPayload);
1825
+ }, 100);
1826
+ return { destroy: destroySlot };
1827
+ })),
1828
+ destroy: withIsSupported(observe("TossAds.destroy", "faithful", (slotId) => {
1829
+ const el = _slotRegistry.get(slotId);
1830
+ if (el) {
1831
+ el.remove();
1832
+ _slotRegistry.delete(slotId);
1833
+ }
1834
+ })),
1835
+ destroyAll: withIsSupported(observe("TossAds.destroyAll", "faithful", () => {
1836
+ for (const el of _slotRegistry.values()) el.remove();
1837
+ _slotRegistry.clear();
1838
+ }))
1839
+ });
1840
+ const loadFullScreenAd$1 = observe("loadFullScreenAd", "faithful", (args) => {
1841
+ setTimeout(() => {
1842
+ const failureCode = aitState.state.failureModes.loadFullScreenAd;
1843
+ if (failureCode) {
1844
+ args.onError(buildNativeError(failureCode));
1845
+ return;
1846
+ }
1847
+ const throttleError = throttleErrorFor("loadFullScreenAd");
1848
+ if (throttleError) {
1849
+ args.onError(throttleError);
1850
+ return;
1851
+ }
1852
+ if (aitState.state.ads.forceNoFill) {
1853
+ args.onError(/* @__PURE__ */ new Error("No fill"));
1854
+ return;
1855
+ }
1856
+ aitState.patch("ads", { isLoaded: true });
1857
+ args.onEvent({ type: "loaded" });
1858
+ }, 200);
1859
+ return () => {};
1860
+ });
1861
+ const showFullScreenAd$1 = withIsSupported(observe("showFullScreenAd", "faithful", (args) => {
1862
+ if (!aitState.state.ads.isLoaded) {
1863
+ args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
1864
+ return () => {};
1865
+ }
1866
+ setTimeout(() => args.onEvent({ type: "clicked" }), 100);
1867
+ setTimeout(() => args.onEvent({ type: "dismissed" }), 1500);
1868
+ return () => {};
1869
+ }));
1870
+ const Ads$1 = createMockProxy("Ads", {
1871
+ showTossAdOrShoppingAds: withIsSupported(observe("Ads.showTossAdOrShoppingAds", "faithful", (args) => {
1872
+ if (!aitState.state.ads.isLoaded) {
1873
+ args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
1874
+ return () => {};
1875
+ }
1876
+ const { rewardUnitType, rewardAmount } = aitState.state.ads;
1877
+ setTimeout(() => args.onEvent({
1878
+ type: "userEarnedReward",
1879
+ data: {
1880
+ unitType: rewardUnitType,
1881
+ unitAmount: rewardAmount
1882
+ }
1883
+ }), 1e3);
1884
+ setTimeout(() => {
1885
+ args.onEvent({ type: "dismissed" });
1886
+ aitState.patch("ads", { isLoaded: false });
1887
+ }, 1500);
1888
+ return () => {};
1889
+ })),
1890
+ showTossShoppingAds: withIsSupported(observe("Ads.showTossShoppingAds", "faithful", (args) => {
1891
+ const { rewardUnitType, rewardAmount } = aitState.state.ads;
1892
+ setTimeout(() => args.onEvent({
1893
+ type: "userEarnedReward",
1894
+ data: {
1895
+ unitType: rewardUnitType,
1896
+ unitAmount: rewardAmount
1897
+ }
1898
+ }), 1e3);
1899
+ setTimeout(() => args.onEvent({ type: "dismissed" }), 1500);
1900
+ return () => {};
1901
+ }))
1902
+ });
1903
+ //#endregion
1904
+ //#region src/mock/analytics/index.ts
1537
1905
  /**
1538
- * Web mode: uses navigator.connection.effectiveType (4g/3g/2g) and navigator.onLine.
1539
- * Limitations: WIFI, 5G, WWAN cannot be detected via the Network Information API.
1540
- * Falls back to state-based value when effectiveType is unavailable.
1906
+ * Analytics mock
1541
1907
  */
1542
- function getNetworkStatusByMode() {
1543
- const mode = aitState.state.deviceModes.network;
1544
- if (mode === "mock") return null;
1545
- if (mode === "web") {
1546
- if (!navigator.onLine) return "OFFLINE";
1547
- const conn = navigator.connection;
1548
- if (conn?.effectiveType) return {
1549
- "4g": "4G",
1550
- "3g": "3G",
1551
- "2g": "2G",
1552
- "slow-2g": "2G"
1553
- }[conn.effectiveType] ?? "UNKNOWN";
1554
- return aitState.state.networkStatus;
1908
+ const Analytics$1 = {
1909
+ screen: (params) => {
1910
+ aitState.logAnalytics({
1911
+ type: "screen",
1912
+ params: params ?? {}
1913
+ });
1914
+ return Promise.resolve(null);
1915
+ },
1916
+ impression: (params) => {
1917
+ aitState.logAnalytics({
1918
+ type: "impression",
1919
+ params: params ?? {}
1920
+ });
1921
+ return Promise.resolve(null);
1922
+ },
1923
+ click: (params) => {
1924
+ aitState.logAnalytics({
1925
+ type: "click",
1926
+ params: params ?? {}
1927
+ });
1928
+ return Promise.resolve(null);
1555
1929
  }
1930
+ };
1931
+ async function eventLog(params) {
1932
+ aitState.logAnalytics({
1933
+ type: params.log_type,
1934
+ params: {
1935
+ log_name: params.log_name,
1936
+ ...params.params
1937
+ }
1938
+ });
1556
1939
  return null;
1557
1940
  }
1558
1941
  //#endregion
1559
- //#region src/mock/device/pdf.ts
1942
+ //#region src/mock/auth/index.ts
1560
1943
  /**
1561
- * Base64로 인코딩된 PDF 데이터를 네이티브 PDF 뷰어로 여는 mock.
1562
- * mock 환경에서는 즉시 `'CLOSE'`를 반환한다.
1944
+ * 인증/로그인 mock
1563
1945
  */
1564
- const _openPDFViewerImpl = async (_params) => {
1565
- await Promise.resolve();
1566
- return "CLOSE";
1567
- };
1568
- const openPDFViewer$1 = Object.assign(_openPDFViewerImpl, { isSupported: () => true });
1569
- //#endregion
1570
- //#region src/mock/device/storage.ts
1946
+ async function appLogin() {
1947
+ const failureCode = aitState.state.failureModes.appLogin;
1948
+ if (failureCode) throw buildNativeError(failureCode);
1949
+ return {
1950
+ authorizationCode: `mock-auth-${crypto.randomUUID()}`,
1951
+ referrer: aitState.state.environment === "toss" ? "DEFAULT" : "SANDBOX"
1952
+ };
1953
+ }
1954
+ async function getIsTossLoginIntegratedService$1() {
1955
+ const failureCode = aitState.state.failureModes.getIsTossLoginIntegratedService;
1956
+ if (failureCode) throw buildNativeError(failureCode);
1957
+ return aitState.state.auth.isTossLoginIntegrated;
1958
+ }
1959
+ async function getUserKeyForGame$1() {
1960
+ return {
1961
+ hash: aitState.state.auth.userKeyHash ?? "",
1962
+ type: "HASH"
1963
+ };
1964
+ }
1965
+ async function getAnonymousKey$1() {
1966
+ if (!aitState.state.auth.anonymousKeyHash) return void 0;
1967
+ return {
1968
+ hash: aitState.state.auth.anonymousKeyHash,
1969
+ type: "HASH"
1970
+ };
1971
+ }
1972
+ async function appsInTossSignTossCert$1(_params) {
1973
+ console.log("[@apps-in-toss/devtools] appsInTossSignTossCert called (no-op in mock)");
1974
+ }
1571
1975
  /**
1572
- * Storage mock
1573
- * localStorage에 `__ait_storage:` prefix로 저장하여 앱 자체 localStorage와 분리
1976
+ * 사용자 동의 기반 데이터 mock (devtools#798 — env1에 배선 부재였던 실 export).
1574
1977
  *
1575
- * 실기기(2.x×iOS) capture는 `setItem`/`removeItem`/`clearItems` 메서드가
1576
- * `undefined`가 아니라 `null`로 resolve됨을 보였다(devtools#770). 원본 SDK 타입
1577
- * 선언은 여전히 `Promise<void>`이므로 시그니처는 그대로 두고, 런타임 반환값만
1578
- * `null`로 캐스트해 실측과 동치시킨다 — Analytics·setClipboardText와 같은 처리(#775).
1579
- * `getItem`은 이미 `string | null`이라 실측과 일치하므로 손대지 않는다.
1978
+ * SDK이 API를 `@apps-in-toss/web-bridge` 호환 export로 노출하며 2.x와 3.x GA
1979
+ * 양쪽 facade에서 제공한다. 선언 시그니처는 `Promise<Partial<Record<ConsentedUserDataKey, string>>
1980
+ * | undefined>` appLogin과 같은 async bridge 모양이라 항상 resolve하는 낙관적
1981
+ * 패턴을 따른다.
1982
+ *
1983
+ * 어떤 키가 채워지는지는 콘솔에 등록된 동의문/데이터 묶음(`consentedUserDataKey`)에
1984
+ * 달려 있고 그 매핑은 서버 쪽 설정이라 mock이 알 수 없다 — 호출 파라미터와 무관하게
1985
+ * 상태에 저장된 최소 plausible 객체를 그대로 resolve한다. SDK 선언 밖의 필드는
1986
+ * 추가하지 않는다(devtools#783 — 실측/타입 밖 추정 금지).
1580
1987
  */
1581
- const Storage = createMockProxy("Storage", {
1582
- getItem: async (key) => {
1583
- return localStorage.getItem(`__ait_storage:${key}`);
1584
- },
1585
- setItem: async (key, value) => {
1586
- localStorage.setItem(`__ait_storage:${key}`, value);
1587
- return null;
1588
- },
1589
- removeItem: async (key) => {
1590
- localStorage.removeItem(`__ait_storage:${key}`);
1591
- return null;
1592
- },
1593
- clearItems: async () => {
1594
- const keys = Object.keys(localStorage).filter((k) => k.startsWith("__ait_storage:"));
1595
- for (const k of keys) localStorage.removeItem(k);
1596
- return null;
1597
- }
1598
- });
1988
+ async function getConsentedUserData$1(_options) {
1989
+ return aitState.state.auth.consentedUserData;
1990
+ }
1991
+ async function getDeclaredAgeRange$1(_params) {
1992
+ return { ...aitState.state.auth.declaredAgeRange };
1993
+ }
1599
1994
  //#endregion
1600
1995
  //#region src/mock/iap/index.ts
1601
1996
  let orderCounter = 0;
@@ -1730,133 +2125,6 @@ const requestTossPayPaysBilling$1 = Object.assign(async function requestTossPayP
1730
2125
  };
1731
2126
  }, { isSupported: () => true });
1732
2127
  //#endregion
1733
- //#region src/mock/navigation/index.ts
1734
- async function closeView() {
1735
- console.log("[@apps-in-toss/devtools] closeView called");
1736
- window.history.back();
1737
- }
1738
- async function openURL(url) {
1739
- console.log("[@apps-in-toss/devtools] openURL:", url);
1740
- window.open(url, "_blank");
1741
- }
1742
- async function share(message) {
1743
- if (navigator.share) {
1744
- await navigator.share({ text: message.message });
1745
- return;
1746
- }
1747
- console.log("[@apps-in-toss/devtools] share:", message.message);
1748
- }
1749
- const URI_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
1750
- async function getTossShareLink(path, _ogImageUrl) {
1751
- if (!URI_SCHEME_PATTERN.test(path)) throw buildNativeError("EXECUTION_ERROR");
1752
- return `https://toss.im/share/mock${path}`;
1753
- }
1754
- async function setIosSwipeGestureEnabled(options) {
1755
- console.log("[@apps-in-toss/devtools] setIosSwipeGestureEnabled:", options.isEnabled);
1756
- aitState.patch("navigation", { iosSwipeGestureEnabled: options.isEnabled });
1757
- }
1758
- async function setDeviceOrientation$1(options) {
1759
- const current = aitState.state.viewport.orientation;
1760
- if (current === "auto") {
1761
- console.log("[@apps-in-toss/devtools] setDeviceOrientation:", options.type);
1762
- aitState.patch("viewport", { appOrientation: options.type });
1763
- return;
1764
- }
1765
- console.warn(`[@apps-in-toss/devtools] setDeviceOrientation(${options.type}) ignored — Panel is forcing "${current}". Change the Viewport tab's orientation to "auto" to let the app control rotation.`);
1766
- }
1767
- let _wakeLockSentinel = null;
1768
- const setScreenAwakeMode = observe("setScreenAwakeMode", "partial", async (options) => {
1769
- console.log("[@apps-in-toss/devtools] setScreenAwakeMode:", options.enabled);
1770
- const wakeLock = navigator.wakeLock;
1771
- if (options.enabled) {
1772
- if (wakeLock && !_wakeLockSentinel) try {
1773
- _wakeLockSentinel = await wakeLock.request("screen");
1774
- } catch (err) {
1775
- console.warn("[@apps-in-toss/devtools] setScreenAwakeMode: wakeLock.request 실패 (무시됨):", err);
1776
- }
1777
- } else if (_wakeLockSentinel) {
1778
- const sentinel = _wakeLockSentinel;
1779
- _wakeLockSentinel = null;
1780
- try {
1781
- await sentinel.release();
1782
- } catch {}
1783
- }
1784
- return { enabled: options.enabled };
1785
- });
1786
- const setSecureScreen = observe("setSecureScreen", "inert", async (options) => {
1787
- console.log("[@apps-in-toss/devtools] setSecureScreen:", options.enabled);
1788
- return { enabled: options.enabled };
1789
- });
1790
- const requestReview$1 = observe("requestReview", "inert", async () => {
1791
- console.log("[@apps-in-toss/devtools] requestReview called");
1792
- });
1793
- requestReview$1.isSupported = () => true;
1794
- function getTossAppVersion() {
1795
- return aitState.state.appVersion;
1796
- }
1797
- function getGroupId() {
1798
- return aitState.state.groupId;
1799
- }
1800
- async function getNetworkStatus() {
1801
- const modeResult = getNetworkStatusByMode();
1802
- if (modeResult) return modeResult;
1803
- return aitState.state.networkStatus;
1804
- }
1805
- const _getServerTimeImpl = async () => {
1806
- return Date.now();
1807
- };
1808
- const getServerTime$1 = Object.assign(_getServerTimeImpl, { isSupported: () => true });
1809
- const graniteEvent = { addEventListener(event, { onEvent, onError }) {
1810
- const handler = () => {
1811
- try {
1812
- onEvent();
1813
- } catch (e) {
1814
- onError?.(e instanceof Error ? e : new Error(String(e)));
1815
- }
1816
- };
1817
- window.addEventListener(`__ait:${event}`, handler);
1818
- let cleaned = false;
1819
- return () => {
1820
- if (cleaned) return;
1821
- cleaned = true;
1822
- window.removeEventListener(`__ait:${event}`, handler);
1823
- };
1824
- } };
1825
- const appsInTossEvent = { addEventListener(_event, _handlers) {
1826
- return () => {};
1827
- } };
1828
- const tdsEvent = { addEventListener(event, { onEvent }) {
1829
- const handler = (e) => {
1830
- const detail = e.detail;
1831
- onEvent(detail);
1832
- };
1833
- window.addEventListener(`__ait:${event}`, handler);
1834
- return () => window.removeEventListener(`__ait:${event}`, handler);
1835
- } };
1836
- /**
1837
- * @deprecated web-framework 3.0 에서 제거됨. 2.x 소비자 back-compat용으로 유지.
1838
- */
1839
- function onVisibilityChangedByTransparentServiceWeb(eventParams) {
1840
- const handler = () => eventParams.onEvent(!document.hidden);
1841
- document.addEventListener("visibilitychange", handler);
1842
- return () => document.removeEventListener("visibilitychange", handler);
1843
- }
1844
- const env = { getDeploymentId: () => aitState.state.deploymentId };
1845
- function getAppsInTossGlobals() {
1846
- return {
1847
- deploymentId: aitState.state.deploymentId,
1848
- brandDisplayName: aitState.state.brand.displayName,
1849
- brandIcon: aitState.state.brand.icon,
1850
- brandPrimaryColor: aitState.state.brand.primaryColor
1851
- };
1852
- }
1853
- const SafeAreaInsets = {
1854
- get: () => ({ ...aitState.state.safeAreaInsets }),
1855
- subscribe: ({ onEvent }) => {
1856
- return aitState.subscribe(() => onEvent({ ...aitState.state.safeAreaInsets }));
1857
- }
1858
- };
1859
- //#endregion
1860
2128
  //#region src/mock/bridge-factories.ts
1861
2129
  /** Generic bridge escape hatches exposed by both SDK lines. */
1862
2130
  const asyncBridgeRegistry = {
@@ -2005,36 +2273,13 @@ const _requestNotificationAgreementImpl = (params) => {
2005
2273
  const requestNotificationAgreement$1 = Object.assign(_requestNotificationAgreementImpl, { isSupported: () => true });
2006
2274
  //#endregion
2007
2275
  //#region src/mock/partner/index.ts
2008
- /**
2009
- * Partner / TDS mock
2010
- */
2011
- /** navigationAccessoryEvent 브릿지 이벤트 이름 — tdsEvent.addEventListener가 구독하는
2012
- * `__ait:${event}` 컨벤션(state.ts의 `trigger()`)을 따른다. 이벤트 payload 계약
2013
- * (`{ id: string }`)은 web-framework의 `TdsEvent['navigationAccessoryEvent']`와 동일. */
2014
- const NAVIGATION_ACCESSORY_EVENT = "navigationAccessoryEvent";
2015
- let _accessoryButtonEl = null;
2016
- function removeAccessoryButtonEl() {
2017
- _accessoryButtonEl?.remove();
2018
- _accessoryButtonEl = null;
2019
- }
2020
2276
  const partner = {
2021
- addAccessoryButton: observe("partner.addAccessoryButton", "partial", async (options) => {
2022
- removeAccessoryButtonEl();
2023
- const button = document.createElement("button");
2024
- button.type = "button";
2025
- button.dataset.aitAccessoryButtonId = options.id;
2026
- button.textContent = options.title;
2027
- button.title = `[@apps-in-toss/devtools] partner.addAccessoryButton (icon: ${options.icon.name})`;
2028
- button.style.cssText = "position:fixed;top:8px;right:8px;z-index:2147483000;padding:6px 10px;border:1px dashed #999;border-radius:6px;background:#f0f0f0;color:#333;font-size:12px;line-height:1.4;cursor:pointer;";
2029
- button.addEventListener("click", () => {
2030
- window.dispatchEvent(new CustomEvent(`__ait:${NAVIGATION_ACCESSORY_EVENT}`, { detail: { id: options.id } }));
2031
- });
2032
- document.body.appendChild(button);
2033
- _accessoryButtonEl = button;
2034
- }),
2035
- removeAccessoryButton: observe("partner.removeAccessoryButton", "partial", async () => {
2036
- removeAccessoryButtonEl();
2037
- })
2277
+ async addAccessoryButton(options) {
2278
+ console.log("[@apps-in-toss/devtools] partner.addAccessoryButton:", options);
2279
+ },
2280
+ async removeAccessoryButton() {
2281
+ console.log("[@apps-in-toss/devtools] partner.removeAccessoryButton");
2282
+ }
2038
2283
  };
2039
2284
  //#endregion
2040
2285
  //#region src/mock/preset-store.ts
@@ -2312,6 +2557,15 @@ function captureCurrentState(snapshot) {
2312
2557
  };
2313
2558
  }
2314
2559
  //#endregion
2560
+ //#region src/mock/index.ts
2561
+ /**
2562
+ * @apps-in-toss/devtools/mock
2563
+ *
2564
+ * @apps-in-toss/web-framework의 모든 export를 mock으로 대체한다.
2565
+ * 번들러 alias로 원본 대신 이 모듈이 resolve된다.
2566
+ */
2567
+ installBridges();
2568
+ //#endregion
2315
2569
  //#region src/mock/sdk-support.ts
2316
2570
  /**
2317
2571
  * Attach the metadata exposed by web-framework 3.x without wrapping the
@@ -2472,6 +2726,14 @@ const showFullScreenAd = Object.assign((args) => showFullScreenAd$1({
2472
2726
  options: args.options ?? { adGroupId: "mock-ad-group" },
2473
2727
  onError: (error) => args.onError(error instanceof Error ? error : new Error(String(error)))
2474
2728
  }), { isSupported: () => true });
2729
+ const shoppingAdsMin = {
2730
+ android: "5.273.0",
2731
+ ios: "5.273.0"
2732
+ };
2733
+ const Ads = {
2734
+ showTossAdOrShoppingAds: supported(Ads$1.showTossAdOrShoppingAds, shoppingAdsMin),
2735
+ showTossShoppingAds: supported(Ads$1.showTossShoppingAds, shoppingAdsMin)
2736
+ };
2475
2737
  const IAP = {
2476
2738
  createOneTimePurchaseOrder: supported(IAP$1.createOneTimePurchaseOrder, V.iapOneTime),
2477
2739
  createSubscriptionPurchaseOrder: supported(IAP$1.createSubscriptionPurchaseOrder, V.iapSubscription),
@@ -2577,4 +2839,4 @@ const User = {
2577
2839
  getDeclaredAgeRange
2578
2840
  };
2579
2841
  //#endregion
2580
- export { Accuracy, Analytics, Clipboard, Device, Environment, FetchAlbumPhotosPermissionError, FetchContactsPermissionError, File, Game, GetClipboardTextPermissionError, GetCurrentLocationPermissionError, GoogleAdMob, IAP, Notification, OpenCameraPermissionError, PermissionError, Promotion, Review, SafeArea, SafeAreaInsets, Screen, SetClipboardTextPermissionError, Share, StartUpdateLocationPermissionError, Storage, TossAds, TossAuth, TossPay, User, aitState, appLogin, applyPreset, appsInTossEvent, appsInTossSignTossCert, builtInPresets, captureCurrentState, checkoutPayment, closeView, contactsViral, createAsyncBridge, createConstantBridge, createEventBridge, deleteUserPreset, env, eventLog, fetchAlbumItems, fetchAlbumPhotos, fetchContacts, generateHapticFeedback, getAnonymousKey, getAppsInTossGlobals, getClipboardText, getConsentedUserData, getCurrentLocation, getDeclaredAgeRange, getDefaultPlaceholderImages, getDeviceId, getGameCenterGameProfile, getGroupId, getIsTossLoginIntegratedService, getLocale, getNetworkStatus, getOperationalEnvironment, getPermission, getPlatformOS, getSafeAreaInsets, getSchemeUri, getServerTime, getTossAppVersion, getTossShareLink, getUserKeyForGame, graniteEvent, grantPromotionReward, grantPromotionRewardForGame, isMinVersionSupported, listUserPresets, loadFullScreenAd, matchesPreset, onVisibilityChangedByTransparentServiceWeb, openCamera, openGameCenterLeaderboard, openPDFViewer, openPermissionDialog, openURL, partner, requestNotificationAgreement, requestPermission, requestReview, requestTossPayPaysBilling, saveBase64Data, saveUserPreset, setClipboardText, setDeviceOrientation, setIosSwipeGestureEnabled, setScreenAwakeMode, setSecureScreen, share, showFullScreenAd, startUpdateLocation, submitGameCenterLeaderBoardScore, tdsEvent };
2842
+ export { Accuracy, Ads, Analytics, Clipboard, Device, Environment, FetchAlbumPhotosPermissionError, FetchContactsPermissionError, File, Game, GetClipboardTextPermissionError, GetCurrentLocationPermissionError, GoogleAdMob, IAP, NAVIGATE_BACK_MESSAGE_TYPE, Notification, OpenCameraPermissionError, PermissionError, Promotion, Review, SAFE_AREA_INSETS_MESSAGE_TYPE, SafeArea, SafeAreaInsets, Screen, SetClipboardTextPermissionError, Share, StartUpdateLocationPermissionError, Storage, TossAds, TossAuth, TossPay, User, WEB_VIEW_TYPE_MESSAGE_TYPE, aitState, appLogin, applyForwardedSafeAreaInsets, applyPreset, appsInTossEvent, appsInTossSignTossCert, builtInPresets, captureCurrentState, checkoutPayment, closeView, contactsViral, createAsyncBridge, createConstantBridge, createEventBridge, deleteUserPreset, env, eventLog, fetchAlbumItems, fetchAlbumPhotos, fetchContacts, generateHapticFeedback, getAnonymousKey, getAppsInTossGlobals, getClipboardText, getConsentedUserData, getCurrentLocation, getDeclaredAgeRange, getDefaultPlaceholderImages, getDeviceId, getGameCenterGameProfile, getGroupId, getIsTossLoginIntegratedService, getLocale, getNetworkStatus, getOperationalEnvironment, getPermission, getPlatformOS, getSafeAreaInsets, getSchemeUri, getServerTime, getTossAppVersion, getTossShareLink, getUserKeyForGame, graniteEvent, grantPromotionReward, grantPromotionRewardForGame, installBridges, installNavigateBackBridge, installSafeAreaInsetsBridge, isMinVersionSupported, isNavigateBackMessage, listUserPresets, loadFullScreenAd, matchesPreset, onVisibilityChangedByTransparentServiceWeb, openCamera, openGameCenterLeaderboard, openPDFViewer, openPermissionDialog, openURL, parseSafeAreaInsetsMessage, parseWebViewTypeMessage, partner, requestNotificationAgreement, requestPermission, requestReview, requestTossPayPaysBilling, saveBase64Data, saveUserPreset, setClipboardText, setDeviceOrientation, setIosSwipeGestureEnabled, setScreenAwakeMode, setSecureScreen, share, showFullScreenAd, startUpdateLocation, submitGameCenterLeaderBoardScore, tdsEvent };