@ait-co/devtools 0.1.73 → 0.1.75

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.
Files changed (38) hide show
  1. package/dist/devtools-opener-BbUXBzgA.js.map +1 -1
  2. package/dist/devtools-opener-Bp671YXu.cjs.map +1 -1
  3. package/dist/devtools-opener-D84kZFtR.js.map +1 -1
  4. package/dist/devtools-opener-h6A-UjzC.cjs.map +1 -1
  5. package/dist/mcp/cli.js +191 -76
  6. package/dist/mcp/cli.js.map +1 -1
  7. package/dist/mcp/server.js +1 -1
  8. package/dist/mock/index.d.ts +50 -2
  9. package/dist/mock/index.d.ts.map +1 -1
  10. package/dist/mock/index.js +1210 -1110
  11. package/dist/mock/index.js.map +1 -1
  12. package/dist/panel/index.js +828 -820
  13. package/dist/panel/index.js.map +1 -1
  14. package/dist/{qr-http-server-Ditd2ndz.js → qr-http-server-CDO6o2nr.js} +69 -12
  15. package/dist/qr-http-server-CDO6o2nr.js.map +1 -0
  16. package/dist/{qr-http-server-0uN5jxLW.cjs → qr-http-server-D0v9ooAD.cjs} +69 -12
  17. package/dist/qr-http-server-D0v9ooAD.cjs.map +1 -0
  18. package/dist/{qr-http-server-TQG61eI4.js → qr-http-server-DznDIcJF.js} +69 -12
  19. package/dist/qr-http-server-DznDIcJF.js.map +1 -0
  20. package/dist/{qr-http-server-BTjpFS3p.cjs → qr-http-server-jMC1nVqY.cjs} +69 -12
  21. package/dist/qr-http-server-jMC1nVqY.cjs.map +1 -0
  22. package/dist/{tunnel-BXAWl2tI.cjs → tunnel-D7f-0enB.cjs} +3 -2
  23. package/dist/{tunnel-BXAWl2tI.cjs.map → tunnel-D7f-0enB.cjs.map} +1 -1
  24. package/dist/{tunnel-BxGnLAat.js → tunnel-km3KkZrF.js} +3 -2
  25. package/dist/{tunnel-BxGnLAat.js.map → tunnel-km3KkZrF.js.map} +1 -1
  26. package/dist/unplugin/index.cjs +1 -1
  27. package/dist/unplugin/index.js +1 -1
  28. package/dist/unplugin/tunnel.cjs +2 -1
  29. package/dist/unplugin/tunnel.cjs.map +1 -1
  30. package/dist/unplugin/tunnel.d.cts.map +1 -1
  31. package/dist/unplugin/tunnel.d.ts.map +1 -1
  32. package/dist/unplugin/tunnel.js +2 -1
  33. package/dist/unplugin/tunnel.js.map +1 -1
  34. package/package.json +1 -1
  35. package/dist/qr-http-server-0uN5jxLW.cjs.map +0 -1
  36. package/dist/qr-http-server-BTjpFS3p.cjs.map +0 -1
  37. package/dist/qr-http-server-Ditd2ndz.js.map +0 -1
  38. package/dist/qr-http-server-TQG61eI4.js.map +0 -1
@@ -238,1073 +238,1312 @@ if (!globalRef[SINGLETON_KEY]) globalRef[SINGLETON_KEY] = new AitStateManager();
238
238
  const aitState = globalRef[SINGLETON_KEY];
239
239
  if (typeof window !== "undefined") window.__ait = aitState;
240
240
  //#endregion
241
- //#region src/mock/safe-area-bridge.ts
241
+ //#region src/mock/device/_helpers.ts
242
242
  /**
243
- * env-2 safe-area inset bridge (#484, slice 2).
244
- *
245
- * In the AITC Sandbox PWA (env 2) the dev app runs inside the launcher's
246
- * full-viewport `<iframe>`. The launcher is the top-level document, so its
247
- * `env(safe-area-inset-*)` measurement is the ground truth for the real device
248
- * geometry. The framed page's mock would otherwise report a synthetic preset
249
- * value (e.g. top=54), which sdk-example then double-pads on top of a viewport
250
- * that already starts below the status bar — the env-2 "dead band" defect.
251
- *
252
- * The launcher forwards its measured insets to the framed page with
253
- * `postMessage({ type: 'ait:safe-area-insets', insets })` (on iframe load and on
254
- * resize/orientationchange). This module installs the receive half: it validates
255
- * the envelope and writes the real insets into the mock `SafeAreaInsets` state,
256
- * which fires the existing subscribe path (see navigation/index.ts) so apps that
257
- * subscribe re-read the corrected values.
258
- *
259
- * Origin: the inset values are non-sensitive geometry (four small numbers), so
260
- * we do NOT restrict by origin — the launcher posts cross-origin from a
261
- * *.trycloudflare.com tunnel with targetOrigin '*'. Shape + range validation is
262
- * still mandatory: a malformed or out-of-range message is silently ignored so a
263
- * stray postMessage from any frame can never corrupt the mock state.
264
- *
265
- * Message-driven by design: env 1 (desktop browser, no launcher) never receives
266
- * this message, so the panel preset stays authoritative there with zero special
267
- * casing here.
243
+ * 디바이스 모듈 내부 공유 헬퍼
268
244
  */
269
- /** The postMessage envelope the launcher posts to the framed dev app. */
270
- const SAFE_AREA_INSETS_MESSAGE_TYPE = "ait:safe-area-insets";
271
- const MAX_INSET_PX = 200;
272
- function isValidInset(value) {
273
- return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= MAX_INSET_PX;
245
+ function generatePlaceholderImage(width, height, text, color) {
246
+ const canvas = document.createElement("canvas");
247
+ canvas.width = width;
248
+ canvas.height = height;
249
+ const ctx = canvas.getContext("2d");
250
+ if (!ctx) {
251
+ 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>`;
252
+ return `data:image/svg+xml;base64,${btoa(svg)}`;
253
+ }
254
+ ctx.fillStyle = color;
255
+ ctx.fillRect(0, 0, width, height);
256
+ ctx.fillStyle = "white";
257
+ ctx.font = "16px sans-serif";
258
+ ctx.textAlign = "center";
259
+ ctx.textBaseline = "middle";
260
+ ctx.fillText(text, width / 2, height / 2);
261
+ return canvas.toDataURL("image/png");
274
262
  }
275
- /**
276
- * Parse + validate a raw postMessage payload into a `SafeAreaInsets`, or return
277
- * null when it is not a well-formed `ait:safe-area-insets` message. Pure — unit
278
- * tested without a real MessageEvent.
279
- */
280
- function parseSafeAreaInsetsMessage(data) {
281
- if (typeof data !== "object" || data === null) return null;
282
- if (data.type !== "ait:safe-area-insets") return null;
283
- const insets = data.insets;
284
- if (typeof insets !== "object" || insets === null) return null;
285
- const { top, bottom, left, right } = insets;
286
- if (!isValidInset(top) || !isValidInset(bottom) || !isValidInset(left) || !isValidInset(right)) return null;
287
- return {
288
- top,
289
- bottom,
290
- left,
291
- right
292
- };
263
+ const DEFAULT_PLACEHOLDERS = [
264
+ {
265
+ text: "Mock Photo 1",
266
+ color: "#3182F6"
267
+ },
268
+ {
269
+ text: "Mock Photo 2",
270
+ color: "#27ae60"
271
+ },
272
+ {
273
+ text: "Mock Photo 3",
274
+ color: "#e67e22"
275
+ }
276
+ ];
277
+ let cachedPlaceholders = null;
278
+ function getDefaultPlaceholderImages() {
279
+ if (!cachedPlaceholders) cachedPlaceholders = DEFAULT_PLACEHOLDERS.map((p) => generatePlaceholderImage(320, 240, p.text, p.color));
280
+ return [...cachedPlaceholders];
293
281
  }
294
- /**
295
- * Apply forwarded insets to the mock state. Skips the write (and the resulting
296
- * subscribe notify) when nothing changed, so repeated identical messages from a
297
- * resize storm don't churn subscribers.
298
- */
299
- function applyForwardedSafeAreaInsets(insets) {
300
- const current = aitState.state.safeAreaInsets;
301
- if (current.top === insets.top && current.bottom === insets.bottom && current.left === insets.left && current.right === insets.right) return;
302
- aitState.update({ safeAreaInsets: insets });
282
+ /** @internal device 모듈 내부 전용 */
283
+ function getMockImages() {
284
+ const images = aitState.state.mockData.images;
285
+ if (images.length > 0) return images;
286
+ return getDefaultPlaceholderImages();
303
287
  }
304
- let installed = false;
305
- /**
306
- * Install the window `message` listener that receives forwarded insets. Safe to
307
- * call multiple times (idempotent) and a no-op outside a browser (SSR/jsdom
308
- * without a window). Imported for its side effect by the mock barrel so any
309
- * consumer that aliases `@apps-in-toss/web-framework` to the mock gets it wired.
310
- */
311
- function installSafeAreaInsetsBridge() {
312
- if (installed || typeof window === "undefined") return;
313
- installed = true;
314
- window.addEventListener("message", (event) => {
315
- const insets = parseSafeAreaInsetsMessage(event.data);
316
- if (insets) applyForwardedSafeAreaInsets(insets);
288
+ const PROMPT_TIMEOUT_MS = 3e4;
289
+ /** @internal device 모듈 내부 전용 */
290
+ function waitForPromptResponse(type) {
291
+ return new Promise((resolve, reject) => {
292
+ const eventName = `__ait:prompt-response:${type}`;
293
+ const cancelName = "__ait:prompt-cancel";
294
+ function cleanup() {
295
+ clearTimeout(timer);
296
+ window.removeEventListener(eventName, handler);
297
+ window.removeEventListener(cancelName, cancelHandler);
298
+ }
299
+ const timer = setTimeout(() => {
300
+ cleanup();
301
+ const hint = !!document.querySelector(".ait-panel") ? "Please provide input via the DevTools panel." : "Is @ait-co/devtools/panel imported?";
302
+ reject(/* @__PURE__ */ new Error(`[@ait-co/devtools] Prompt timeout for "${type}" after ${PROMPT_TIMEOUT_MS / 1e3}s. ${hint}`));
303
+ }, PROMPT_TIMEOUT_MS);
304
+ const handler = (e) => {
305
+ cleanup();
306
+ resolve(e.detail);
307
+ };
308
+ const cancelHandler = () => {
309
+ cleanup();
310
+ reject(/* @__PURE__ */ new Error(`[@ait-co/devtools] Prompt cancelled for "${type}"`));
311
+ };
312
+ window.addEventListener(eventName, handler);
313
+ window.addEventListener(cancelName, cancelHandler);
314
+ window.dispatchEvent(new CustomEvent("__ait:prompt-request", { detail: { type } }));
317
315
  });
318
316
  }
319
317
  //#endregion
320
- //#region src/mock/observe.ts
318
+ //#region src/mock/permissions.ts
321
319
  /**
322
- * fn을 observe로 감싼다.
323
- *
324
- * @param apiName - 로그에 기록할 SDK 메서드 이름 (예: `'setScreenAwakeMode'`)
325
- * @param fidelity - 이 mock의 fidelity grade ('faithful' | 'partial' | 'inert')
326
- * @param fn - 실제 mock 구현체. 시그니처를 그대로 통과시킨다.
327
- * @returns fn과 동일한 타입의 래퍼 함수
320
+ * 권한 시스템 mock
321
+ * 각 디바이스 API (.getPermission, .openPermissionDialog)에 부착된다.
328
322
  */
329
- function observe(apiName, fidelity, fn) {
330
- return (...args) => {
331
- const timestamp = Date.now();
332
- const safeArgs = args.map((a) => safeSerialize(a));
333
- const result = fn(...args);
334
- if (result instanceof Promise) {
335
- aitState.logSdkCall({
336
- method: apiName,
337
- args: safeArgs,
338
- timestamp,
339
- status: "pending",
340
- fidelity
341
- });
342
- result.then((value) => {
343
- aitState.logSdkCall({
344
- method: apiName,
345
- args: safeArgs,
346
- timestamp,
347
- status: "resolved",
348
- result: safeSerialize(value),
349
- fidelity
350
- });
351
- }, (err) => {
352
- aitState.logSdkCall({
353
- method: apiName,
354
- args: safeArgs,
355
- timestamp,
356
- status: "rejected",
357
- error: err instanceof Error ? err.message : String(err),
358
- fidelity
359
- });
360
- });
361
- return result;
362
- }
363
- aitState.logSdkCall({
364
- method: apiName,
365
- args: safeArgs,
366
- timestamp,
367
- status: "resolved",
368
- result: safeSerialize(result),
369
- fidelity
370
- });
371
- return result;
372
- };
373
- }
374
323
  /**
375
- * 값을 JSON-safe한 형태로 변환한다.
376
- * - null / primitive — 그대로.
377
- * - 함수 — `'[Function: name]'` 문자열.
378
- * - 기타 객체 — JSON.stringify 실패 시 `'[unserializable]'`.
324
+ * web-framework 3.0+ 권한 에러 기반 클래스.
325
+ * `instanceof PermissionError`로 체크하는 코드와 호환된다.
379
326
  */
380
- function safeSerialize(value) {
381
- if (value === null || value === void 0) return value;
382
- if (typeof value === "function") return `[Function: ${value.name || "anonymous"}]`;
383
- if (typeof value !== "object") return value;
384
- try {
385
- return JSON.parse(JSON.stringify(value));
386
- } catch {
387
- return "[unserializable]";
327
+ var PermissionError = class extends Error {
328
+ constructor({ methodName, message }) {
329
+ super(message ?? `${methodName}: permission denied`);
330
+ this.name = `${methodName}PermissionError`;
388
331
  }
389
- }
390
- //#endregion
391
- //#region src/mock/proxy.ts
332
+ };
333
+ /** openCamera 권한 에러 */
334
+ var OpenCameraPermissionError = class extends PermissionError {
335
+ constructor() {
336
+ super({ methodName: "openCamera" });
337
+ }
338
+ };
339
+ /** fetchAlbumPhotos 권한 에러 */
340
+ var FetchAlbumPhotosPermissionError = class extends PermissionError {
341
+ constructor() {
342
+ super({ methodName: "fetchAlbumPhotos" });
343
+ }
344
+ };
345
+ /** fetchContacts 권한 에러 */
346
+ var FetchContactsPermissionError = class extends PermissionError {
347
+ constructor() {
348
+ super({ methodName: "fetchContacts" });
349
+ }
350
+ };
351
+ /** getCurrentLocation 권한 에러 */
352
+ var GetCurrentLocationPermissionError = class extends PermissionError {
353
+ constructor() {
354
+ super({ methodName: "getCurrentLocation" });
355
+ }
356
+ };
357
+ /** getClipboardText 권한 에러 */
358
+ var GetClipboardTextPermissionError = class extends PermissionError {
359
+ constructor() {
360
+ super({ methodName: "getClipboardText" });
361
+ }
362
+ };
363
+ /** setClipboardText 권한 에러 */
364
+ var SetClipboardTextPermissionError = class extends PermissionError {
365
+ constructor() {
366
+ super({ methodName: "setClipboardText" });
367
+ }
368
+ };
392
369
  /**
393
- * 미구현 API용 Proxy 트립와이어.
394
- *
395
- * 미구현 프로퍼티에 접근하면 throw한다. 이는 "devtools에서는 멀쩡히 돌지만
396
- * 실 SDK에선 실제로 동작하는" 시나리오를 차단하기 위한 의도적 선택이다.
397
- * mock이 미구현인 API는 실 SDK에서는 존재할 수 있고, 사용자가 이를 인지하지
398
- * 못한 채 개발을 이어가면 배포 시점에 놀라게 된다. 에러 메시지에 이슈 URL을
399
- * 포함해 사용자가 mock 누락을 제보할 수 있게 한다.
400
- *
401
- * ## KNOWN_UNIMPLEMENTED 정책
402
- * SDK에 존재하는 것으로 알려져 있으나 현재 mock이 없는 API 이름만 이 집합에 둔다.
403
- * 이 경우에만 throw 대신 🔴 inert no-op을 반환하고 sdkCallLog에 기록한다.
404
- * 완전히 미지의 이름은 여전히 throw — "잘 되는 척" 방지.
370
+ * startUpdateLocation 권한 에러.
371
+ * web-framework 3.0에서 GetCurrentLocationPermissionError의 alias.
405
372
  */
406
- const ISSUES_URL = "https://github.com/apps-in-toss-community/devtools/issues";
407
- /**
408
- * SDK에 존재하나 mock이 아직 없는 것으로 확인된 이름 목록.
409
- * 새 API가 SDK에 추가되면 여기에 추가하고 별도 PR에서 mock 구현으로 이동한다.
410
- * 확인되지 않은 이름은 절대 여기에 추가하지 않는다 — throw가 더 안전하다.
373
+ const StartUpdateLocationPermissionError = GetCurrentLocationPermissionError;
374
+ const permissionErrorMap = {
375
+ openCamera: OpenCameraPermissionError,
376
+ fetchAlbumPhotos: FetchAlbumPhotosPermissionError,
377
+ fetchAlbumItems: FetchAlbumPhotosPermissionError,
378
+ fetchContacts: FetchContactsPermissionError,
379
+ getCurrentLocation: GetCurrentLocationPermissionError,
380
+ getClipboardText: GetClipboardTextPermissionError,
381
+ setClipboardText: SetClipboardTextPermissionError
382
+ };
383
+ async function getPermission(name) {
384
+ return aitState.state.permissions[name];
385
+ }
386
+ async function openPermissionDialog(name) {
387
+ if (aitState.state.permissions[name] === "allowed") return "allowed";
388
+ aitState.patch("permissions", { [name]: "allowed" });
389
+ return "allowed";
390
+ }
391
+ async function requestPermission(permission) {
392
+ return openPermissionDialog(permission.name);
393
+ }
394
+ /** 권한이 필요한 함수에 .getPermission(), .openPermissionDialog()를 부착 */
395
+ function withPermission(fn, permissionName) {
396
+ const enhanced = fn;
397
+ enhanced.getPermission = () => getPermission(permissionName);
398
+ enhanced.openPermissionDialog = () => openPermissionDialog(permissionName);
399
+ return enhanced;
400
+ }
401
+ /**
402
+ * 권한 체크 후 denied면 per-API *PermissionError 서브클래스를 throw한다.
403
+ * 실 3.0 SDK 동작과 일치 — `instanceof PermissionError` 분기가 mock에서도 동작한다 (#372).
411
404
  */
412
- const KNOWN_UNIMPLEMENTED = /* @__PURE__ */ new Set([]);
413
- function createMockProxy(moduleName, implementations) {
414
- return new Proxy(implementations, { get(target, prop) {
415
- if (typeof prop === "symbol") return void 0;
416
- if (prop in target) return target[prop];
417
- const name = String(prop);
418
- if (KNOWN_UNIMPLEMENTED.has(name)) return (...args) => {
419
- console.warn(`[@ait-co/devtools] ${moduleName}.${name} is known-unimplemented (🔴 inert). Returning undefined. Please file or upvote an issue: ${ISSUES_URL}`);
420
- aitState.logSdkCall({
421
- method: `${moduleName}.${name}`,
422
- args,
423
- timestamp: Date.now(),
424
- status: "resolved",
425
- result: void 0,
426
- fidelity: "inert"
427
- });
428
- };
429
- throw new Error(`[@ait-co/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}`);
430
- } });
405
+ function checkPermission(name, fnName) {
406
+ if (aitState.state.permissions[name] === "denied") {
407
+ const ErrorClass = permissionErrorMap[fnName];
408
+ if (ErrorClass) throw new ErrorClass();
409
+ throw new PermissionError({ methodName: fnName });
410
+ }
431
411
  }
432
412
  //#endregion
433
- //#region src/mock/ads/index.ts
413
+ //#region src/mock/device/camera.ts
434
414
  /**
435
- * 광고 mock (GoogleAdMob, TossAds, FullScreenAd)
436
- *
437
- * 변경 이력 (#196):
438
- * - slot 레지스트리로 TossAds destroy/destroyAll 누수 수정 (🟡→🟢)
439
- * - attachBanner BannerSlotCallbacks 발화 (onAdRendered/onAdImpression/onNoFill 등)
440
- * - initialize onInitialized/onInitializationFailed 발화
441
- * - AdMob reward 파라미터화 (state.ads.rewardUnitType/rewardAmount)
442
- * - 모든 호출 observe()로 sdkCallLog에 기록
415
+ * Camera & Album Photos & Album Items mock
416
+ * mock/web/prompt 모드 지원
443
417
  */
444
- function withIsSupported(fn) {
445
- fn.isSupported = () => true;
446
- return fn;
447
- }
448
- const _slotRegistry = /* @__PURE__ */ new Map();
449
- let _slotCounter = 0;
450
- function _nextSlotId(adGroupId) {
451
- _slotCounter += 1;
452
- return `mock-slot-${adGroupId}-${_slotCounter}`;
418
+ async function openCameraMock() {
419
+ const images = getMockImages();
420
+ return {
421
+ id: crypto.randomUUID(),
422
+ dataUri: images[0]
423
+ };
453
424
  }
454
- const GoogleAdMob = createMockProxy("GoogleAdMob", {
455
- loadAppsInTossAdMob: withIsSupported(observe("GoogleAdMob.loadAppsInTossAdMob", "faithful", (args) => {
456
- setTimeout(() => {
457
- if (aitState.state.ads.forceNoFill) {
458
- args.onError(/* @__PURE__ */ new Error("No fill"));
425
+ async function openCameraWeb() {
426
+ return new Promise((resolve, reject) => {
427
+ const input = document.createElement("input");
428
+ input.type = "file";
429
+ input.accept = "image/*";
430
+ input.capture = "environment";
431
+ let settled = false;
432
+ input.onchange = () => {
433
+ settled = true;
434
+ const file = input.files?.[0];
435
+ if (!file) {
436
+ reject(/* @__PURE__ */ new Error("No file selected"));
459
437
  return;
460
438
  }
461
- aitState.patch("ads", { isLoaded: true });
462
- args.onEvent({
463
- type: "loaded",
464
- data: { adGroupId: args.options?.adGroupId }
465
- });
466
- }, 200);
467
- return () => {};
468
- })),
469
- showAppsInTossAdMob: withIsSupported(observe("GoogleAdMob.showAppsInTossAdMob", "faithful", (args) => {
470
- if (!aitState.state.ads.isLoaded) {
471
- args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
472
- return () => {};
473
- }
474
- setTimeout(() => args.onEvent({ type: "requested" }), 50);
475
- setTimeout(() => args.onEvent({ type: "show" }), 100);
476
- setTimeout(() => args.onEvent({ type: "impression" }), 150);
477
- setTimeout(() => {
478
- const { rewardUnitType, rewardAmount } = aitState.state.ads;
479
- args.onEvent({
480
- type: "userEarnedReward",
481
- data: {
482
- unitType: rewardUnitType,
483
- unitAmount: rewardAmount
484
- }
439
+ const reader = new FileReader();
440
+ reader.onload = () => resolve({
441
+ id: crypto.randomUUID(),
442
+ dataUri: reader.result
485
443
  });
486
- }, 1e3);
487
- setTimeout(() => {
488
- args.onEvent({ type: "dismissed" });
489
- aitState.patch("ads", { isLoaded: false });
490
- }, 1500);
491
- return () => {};
492
- })),
493
- isAppsInTossAdMobLoaded: withIsSupported(observe("GoogleAdMob.isAppsInTossAdMobLoaded", "faithful", async (_options) => aitState.state.ads.isLoaded))
494
- });
495
- const TossAds = createMockProxy("TossAds", {
496
- initialize: withIsSupported(observe("TossAds.initialize", "partial", (options) => {
497
- if (aitState.state.ads.forceNoFill) {
498
- options.callbacks?.onInitializationFailed?.(/* @__PURE__ */ new Error("No fill"));
499
- return;
500
- }
501
- options.callbacks?.onInitialized?.();
502
- })),
503
- attach: withIsSupported(observe("TossAds.attach", "partial", (_adGroupId, target, _options) => {
504
- const el = typeof target === "string" ? document.querySelector(target) : target;
505
- if (el) {
506
- const placeholder = document.createElement("div");
507
- placeholder.style.cssText = "background:#f0f0f0;border:1px dashed #999;padding:16px;text-align:center;color:#666;font-size:14px;";
508
- placeholder.textContent = "[@ait-co/devtools] TossAds Placeholder";
509
- el.appendChild(placeholder);
510
- }
511
- })),
512
- attachBanner: withIsSupported(observe("TossAds.attachBanner", "faithful", (adGroupId, target, options) => {
513
- const el = typeof target === "string" ? document.querySelector(target) : target;
514
- const slotId = _nextSlotId(adGroupId);
515
- const placeholder = document.createElement("div");
516
- const theme = options?.theme ?? "auto";
517
- const variant = options?.variant ?? "card";
518
- const isDark = theme === "dark" || theme === "auto" && typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches;
519
- const bg = isDark ? "#1a1a1a" : "#f0f0f0";
520
- const textColor = isDark ? "#aaa" : "#666";
521
- const borderColor = isDark ? "#555" : "#999";
522
- const height = variant === "expanded" ? "120px" : "60px";
523
- placeholder.dataset.aitSlotId = slotId;
524
- 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;`;
525
- placeholder.textContent = `[@ait-co/devtools] Banner Ad (${variant})`;
526
- if (el) {
527
- el.appendChild(placeholder);
528
- _slotRegistry.set(slotId, placeholder);
529
- }
530
- const destroySlot = () => {
531
- const registered = _slotRegistry.get(slotId);
532
- if (registered) {
533
- registered.remove();
534
- _slotRegistry.delete(slotId);
535
- }
444
+ reader.onerror = () => reject(/* @__PURE__ */ new Error("Failed to read file"));
445
+ reader.readAsDataURL(file);
536
446
  };
537
- setTimeout(() => {
538
- if (aitState.state.ads.forceNoFill) {
539
- options?.callbacks?.onNoFill?.({
540
- slotId,
541
- adGroupId,
542
- adMetadata: {}
543
- });
544
- options?.callbacks?.onAdFailedToRender?.({
545
- slotId,
546
- adGroupId,
547
- adMetadata: {},
548
- error: {
549
- code: 0,
550
- message: "No fill"
551
- }
552
- });
447
+ const onFocus = () => {
448
+ setTimeout(() => {
449
+ if (!settled) reject(/* @__PURE__ */ new Error("File picker cancelled"));
450
+ window.removeEventListener("focus", onFocus);
451
+ }, 300);
452
+ };
453
+ window.addEventListener("focus", onFocus);
454
+ input.click();
455
+ });
456
+ }
457
+ async function openCameraPrompt() {
458
+ const dataUri = await waitForPromptResponse("camera");
459
+ return {
460
+ id: crypto.randomUUID(),
461
+ dataUri
462
+ };
463
+ }
464
+ const _openCamera = async (_options) => {
465
+ checkPermission("camera", "openCamera");
466
+ const mode = aitState.state.deviceModes.camera;
467
+ if (mode === "web") return openCameraWeb();
468
+ if (mode === "prompt") return openCameraPrompt();
469
+ return openCameraMock();
470
+ };
471
+ const openCamera = withPermission(_openCamera, "camera");
472
+ async function fetchAlbumPhotosMock(maxCount) {
473
+ return getMockImages().slice(0, maxCount).map((dataUri) => ({
474
+ id: crypto.randomUUID(),
475
+ dataUri
476
+ }));
477
+ }
478
+ async function fetchAlbumPhotosWeb(maxCount) {
479
+ return new Promise((resolve, reject) => {
480
+ const input = document.createElement("input");
481
+ input.type = "file";
482
+ input.accept = "image/*";
483
+ input.multiple = true;
484
+ let settled = false;
485
+ input.onchange = async () => {
486
+ settled = true;
487
+ const files = Array.from(input.files ?? []).slice(0, maxCount);
488
+ if (files.length === 0) {
489
+ reject(/* @__PURE__ */ new Error("No files selected"));
553
490
  return;
554
491
  }
555
- const eventPayload = {
556
- slotId,
557
- adGroupId,
558
- adMetadata: {
559
- creativeId: `mock-creative-${slotId}`,
560
- requestId: `mock-req-${slotId}`
561
- }
562
- };
563
- options?.callbacks?.onAdRendered?.(eventPayload);
564
- options?.callbacks?.onAdImpression?.(eventPayload);
565
- }, 100);
566
- return { destroy: destroySlot };
567
- })),
568
- destroy: withIsSupported(observe("TossAds.destroy", "faithful", (slotId) => {
569
- const el = _slotRegistry.get(slotId);
570
- if (el) {
571
- el.remove();
572
- _slotRegistry.delete(slotId);
573
- }
574
- })),
575
- destroyAll: withIsSupported(observe("TossAds.destroyAll", "faithful", () => {
576
- for (const el of _slotRegistry.values()) el.remove();
577
- _slotRegistry.clear();
578
- }))
579
- });
580
- const loadFullScreenAd = withIsSupported(observe("loadFullScreenAd", "faithful", (args) => {
581
- setTimeout(() => {
582
- if (aitState.state.ads.forceNoFill) {
583
- args.onError(/* @__PURE__ */ new Error("No fill"));
584
- return;
585
- }
586
- aitState.patch("ads", { isLoaded: true });
587
- args.onEvent({
588
- type: "loaded",
589
- data: { adGroupId: args.options?.adGroupId }
590
- });
591
- }, 200);
592
- return () => {};
593
- }));
594
- const showFullScreenAd = withIsSupported(observe("showFullScreenAd", "faithful", (args) => {
595
- if (!aitState.state.ads.isLoaded) {
596
- args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
597
- return () => {};
598
- }
599
- setTimeout(() => args.onEvent({ type: "show" }), 100);
600
- setTimeout(() => args.onEvent({ type: "dismissed" }), 1500);
601
- return () => {};
602
- }));
492
+ resolve(await Promise.all(files.map((file) => new Promise((res, rej) => {
493
+ const reader = new FileReader();
494
+ reader.onload = () => res({
495
+ id: crypto.randomUUID(),
496
+ dataUri: reader.result
497
+ });
498
+ reader.onerror = () => rej(/* @__PURE__ */ new Error("Failed to read file"));
499
+ reader.readAsDataURL(file);
500
+ }))));
501
+ };
502
+ const onFocus = () => {
503
+ setTimeout(() => {
504
+ if (!settled) reject(/* @__PURE__ */ new Error("File picker cancelled"));
505
+ window.removeEventListener("focus", onFocus);
506
+ }, 300);
507
+ };
508
+ window.addEventListener("focus", onFocus);
509
+ input.click();
510
+ });
511
+ }
512
+ async function fetchAlbumPhotosPrompt(maxCount) {
513
+ return (await waitForPromptResponse("photos")).slice(0, maxCount).map((dataUri) => ({
514
+ id: crypto.randomUUID(),
515
+ dataUri
516
+ }));
517
+ }
518
+ const _fetchAlbumPhotos = async (options) => {
519
+ checkPermission("photos", "fetchAlbumPhotos");
520
+ const maxCount = options?.maxCount ?? 10;
521
+ const mode = aitState.state.deviceModes.photos;
522
+ if (mode === "web") return fetchAlbumPhotosWeb(maxCount);
523
+ if (mode === "prompt") return fetchAlbumPhotosPrompt(maxCount);
524
+ return fetchAlbumPhotosMock(maxCount);
525
+ };
526
+ const fetchAlbumPhotos = withPermission(_fetchAlbumPhotos, "photos");
527
+ async function fetchAlbumItemsMock(maxCount, types) {
528
+ return getMockImages().slice(0, maxCount).filter(() => types.includes("PHOTO")).map((dataUri) => ({
529
+ id: crypto.randomUUID(),
530
+ dataUri,
531
+ type: "PHOTO"
532
+ }));
533
+ }
534
+ async function fetchAlbumItemsWeb(maxCount, types) {
535
+ return new Promise((resolve) => {
536
+ const input = document.createElement("input");
537
+ input.type = "file";
538
+ input.accept = types.includes("VIDEO") ? "image/*,video/*" : "image/*";
539
+ input.multiple = true;
540
+ let settled = false;
541
+ input.onchange = async () => {
542
+ settled = true;
543
+ const files = Array.from(input.files ?? []).slice(0, maxCount);
544
+ if (files.length === 0) {
545
+ resolve([]);
546
+ return;
547
+ }
548
+ resolve(await Promise.all(files.map((file) => new Promise((res, rej) => {
549
+ const itemType = file.type.startsWith("video/") ? "VIDEO" : "PHOTO";
550
+ const reader = new FileReader();
551
+ reader.onload = () => res({
552
+ id: crypto.randomUUID(),
553
+ dataUri: reader.result,
554
+ type: itemType
555
+ });
556
+ reader.onerror = () => rej(/* @__PURE__ */ new Error("Failed to read file"));
557
+ reader.readAsDataURL(file);
558
+ }))));
559
+ };
560
+ const onFocus = () => {
561
+ setTimeout(() => {
562
+ if (!settled) resolve([]);
563
+ window.removeEventListener("focus", onFocus);
564
+ }, 300);
565
+ };
566
+ window.addEventListener("focus", onFocus);
567
+ input.click();
568
+ });
569
+ }
570
+ async function fetchAlbumItemsPrompt(maxCount) {
571
+ return (await waitForPromptResponse("photos")).slice(0, maxCount).map((dataUri) => ({
572
+ id: crypto.randomUUID(),
573
+ dataUri,
574
+ type: "PHOTO"
575
+ }));
576
+ }
577
+ const _fetchAlbumItems = async (options) => {
578
+ checkPermission("photos", "fetchAlbumItems");
579
+ const maxCount = options?.maxCount ?? 10;
580
+ const types = options?.types ?? ["PHOTO"];
581
+ const mode = aitState.state.deviceModes.photos;
582
+ if (mode === "web") return fetchAlbumItemsWeb(maxCount, types);
583
+ if (mode === "prompt") return fetchAlbumItemsPrompt(maxCount);
584
+ return fetchAlbumItemsMock(maxCount, types);
585
+ };
586
+ const fetchAlbumItems = withPermission(_fetchAlbumItems, "photos");
603
587
  //#endregion
604
- //#region src/mock/analytics/index.ts
588
+ //#region src/mock/device/clipboard.ts
605
589
  /**
606
- * Analytics mock
590
+ * Clipboard mock
591
+ * mock/web 모드 지원
607
592
  */
608
- const Analytics = {
609
- screen: (params) => {
610
- aitState.logAnalytics({
611
- type: "screen",
612
- params: params ?? {}
613
- });
614
- return Promise.resolve();
615
- },
616
- impression: (params) => {
617
- aitState.logAnalytics({
618
- type: "impression",
619
- params: params ?? {}
620
- });
621
- return Promise.resolve();
622
- },
623
- click: (params) => {
624
- aitState.logAnalytics({
625
- type: "click",
626
- params: params ?? {}
627
- });
628
- return Promise.resolve();
593
+ const _getClipboardText = async () => {
594
+ checkPermission("clipboard", "getClipboardText");
595
+ if (aitState.state.deviceModes.clipboard === "mock") return aitState.state.mockData.clipboardText;
596
+ try {
597
+ return await navigator.clipboard.readText();
598
+ } catch {
599
+ return "";
629
600
  }
630
601
  };
631
- async function eventLog(params) {
632
- aitState.logAnalytics({
633
- type: params.log_type,
634
- params: {
635
- log_name: params.log_name,
636
- ...params.params
637
- }
638
- });
639
- }
602
+ const getClipboardText = withPermission(_getClipboardText, "clipboard");
603
+ const _setClipboardText = async (text) => {
604
+ checkPermission("clipboard", "setClipboardText");
605
+ if (aitState.state.deviceModes.clipboard === "mock") {
606
+ aitState.patch("mockData", { clipboardText: text });
607
+ return;
608
+ }
609
+ await navigator.clipboard.writeText(text);
610
+ };
611
+ const setClipboardText = withPermission(_setClipboardText, "clipboard");
640
612
  //#endregion
641
- //#region src/mock/auth/index.ts
613
+ //#region src/mock/device/contacts.ts
642
614
  /**
643
- * 인증/로그인 mock
615
+ * Contacts mock
644
616
  */
645
- async function appLogin() {
646
- return {
647
- authorizationCode: `mock-auth-${crypto.randomUUID()}`,
648
- referrer: aitState.state.environment === "toss" ? "DEFAULT" : "SANDBOX"
649
- };
650
- }
651
- async function getIsTossLoginIntegratedService() {
652
- return aitState.state.auth.isTossLoginIntegrated;
653
- }
654
- async function getUserKeyForGame() {
655
- if (!aitState.state.auth.userKeyHash) return void 0;
656
- return {
657
- hash: aitState.state.auth.userKeyHash,
658
- type: "HASH"
659
- };
660
- }
661
- async function getAnonymousKey() {
662
- if (!aitState.state.auth.anonymousKeyHash) return void 0;
617
+ const _fetchContacts = async (options) => {
618
+ checkPermission("contacts", "fetchContacts");
619
+ let contacts = aitState.state.contacts;
620
+ if (options.query?.contains) {
621
+ const q = options.query.contains.toLowerCase();
622
+ contacts = contacts.filter((c) => c.name.toLowerCase().includes(q) || c.phoneNumber.includes(q));
623
+ }
624
+ const sliced = contacts.slice(options.offset, options.offset + options.size);
625
+ const nextOffset = options.offset + options.size;
663
626
  return {
664
- hash: aitState.state.auth.anonymousKeyHash,
665
- type: "HASH"
627
+ result: sliced,
628
+ nextOffset: nextOffset < contacts.length ? nextOffset : null,
629
+ done: nextOffset >= contacts.length
666
630
  };
667
- }
668
- async function appsInTossSignTossCert(_params) {
669
- console.log("[@ait-co/devtools] appsInTossSignTossCert called (no-op in mock)");
670
- }
631
+ };
632
+ const fetchContacts = withPermission(_fetchContacts, "contacts");
671
633
  //#endregion
672
- //#region src/mock/device/_helpers.ts
634
+ //#region src/mock/device/haptic.ts
673
635
  /**
674
- * 디바이스 모듈 내부 공유 헬퍼
636
+ * Haptic Feedback & saveBase64Data mock
637
+ *
638
+ * generateHapticFeedback — 영역 3 (하드웨어 API 관측):
639
+ * - 10종 HapticFeedbackType을 navigator.vibrate 패턴으로 매핑(근사, best-effort).
640
+ * - `typeof navigator.vibrate === 'function'` 가드 — API 없는 환경에서 throw 없이 skip.
641
+ * - sdkCallLog에 🟡(partial)로 기록. params: { hapticType, vibrated: boolean }.
642
+ * - 시그니처 불변 — __typecheck.ts의 Assert<Mock, Original> 통과.
675
643
  */
676
- function generatePlaceholderImage(width, height, text, color) {
677
- const canvas = document.createElement("canvas");
678
- canvas.width = width;
679
- canvas.height = height;
680
- const ctx = canvas.getContext("2d");
681
- if (!ctx) {
682
- 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>`;
683
- return `data:image/svg+xml;base64,${btoa(svg)}`;
684
- }
685
- ctx.fillStyle = color;
686
- ctx.fillRect(0, 0, width, height);
687
- ctx.fillStyle = "white";
688
- ctx.font = "16px sans-serif";
689
- ctx.textAlign = "center";
690
- ctx.textBaseline = "middle";
691
- ctx.fillText(text, width / 2, height / 2);
692
- return canvas.toDataURL("image/png");
693
- }
694
- const DEFAULT_PLACEHOLDERS = [
695
- {
696
- text: "Mock Photo 1",
697
- color: "#3182F6"
698
- },
699
- {
700
- text: "Mock Photo 2",
701
- color: "#27ae60"
702
- },
703
- {
704
- text: "Mock Photo 3",
705
- color: "#e67e22"
706
- }
707
- ];
708
- let cachedPlaceholders = null;
709
- function getDefaultPlaceholderImages() {
710
- if (!cachedPlaceholders) cachedPlaceholders = DEFAULT_PLACEHOLDERS.map((p) => generatePlaceholderImage(320, 240, p.text, p.color));
711
- return [...cachedPlaceholders];
712
- }
713
- /** @internal device 모듈 내부 전용 */
714
- function getMockImages() {
715
- const images = aitState.state.mockData.images;
716
- if (images.length > 0) return images;
717
- return getDefaultPlaceholderImages();
718
- }
719
- const PROMPT_TIMEOUT_MS = 3e4;
720
- /** @internal device 모듈 내부 전용 */
721
- function waitForPromptResponse(type) {
722
- return new Promise((resolve, reject) => {
723
- const eventName = `__ait:prompt-response:${type}`;
724
- const cancelName = "__ait:prompt-cancel";
725
- function cleanup() {
726
- clearTimeout(timer);
727
- window.removeEventListener(eventName, handler);
728
- window.removeEventListener(cancelName, cancelHandler);
729
- }
730
- const timer = setTimeout(() => {
731
- cleanup();
732
- const hint = !!document.querySelector(".ait-panel") ? "Please provide input via the DevTools panel." : "Is @ait-co/devtools/panel imported?";
733
- reject(/* @__PURE__ */ new Error(`[@ait-co/devtools] Prompt timeout for "${type}" after ${PROMPT_TIMEOUT_MS / 1e3}s. ${hint}`));
734
- }, PROMPT_TIMEOUT_MS);
735
- const handler = (e) => {
736
- cleanup();
737
- resolve(e.detail);
738
- };
739
- const cancelHandler = () => {
740
- cleanup();
741
- reject(/* @__PURE__ */ new Error(`[@ait-co/devtools] Prompt cancelled for "${type}"`));
742
- };
743
- window.addEventListener(eventName, handler);
744
- window.addEventListener(cancelName, cancelHandler);
745
- window.dispatchEvent(new CustomEvent("__ait:prompt-request", { detail: { type } }));
746
- });
747
- }
748
- //#endregion
749
- //#region src/mock/permissions.ts
750
- /**
751
- * 권한 시스템 mock
752
- * 각 디바이스 API (.getPermission, .openPermissionDialog)에 부착된다.
753
- */
754
- /**
755
- * web-framework 3.0+ 권한 에러 기반 클래스.
756
- * `instanceof PermissionError`로 체크하는 코드와 호환된다.
757
- */
758
- var PermissionError = class extends Error {
759
- constructor({ methodName, message }) {
760
- super(message ?? `${methodName}: permission denied`);
761
- this.name = `${methodName}PermissionError`;
762
- }
763
- };
764
- /** openCamera 권한 에러 */
765
- var OpenCameraPermissionError = class extends PermissionError {
766
- constructor() {
767
- super({ methodName: "openCamera" });
768
- }
769
- };
770
- /** fetchAlbumPhotos 권한 에러 */
771
- var FetchAlbumPhotosPermissionError = class extends PermissionError {
772
- constructor() {
773
- super({ methodName: "fetchAlbumPhotos" });
774
- }
775
- };
776
- /** fetchContacts 권한 에러 */
777
- var FetchContactsPermissionError = class extends PermissionError {
778
- constructor() {
779
- super({ methodName: "fetchContacts" });
780
- }
781
- };
782
- /** getCurrentLocation 권한 에러 */
783
- var GetCurrentLocationPermissionError = class extends PermissionError {
784
- constructor() {
785
- super({ methodName: "getCurrentLocation" });
786
- }
787
- };
788
- /** getClipboardText 권한 에러 */
789
- var GetClipboardTextPermissionError = class extends PermissionError {
790
- constructor() {
791
- super({ methodName: "getClipboardText" });
792
- }
793
- };
794
- /** setClipboardText 권한 에러 */
795
- var SetClipboardTextPermissionError = class extends PermissionError {
796
- constructor() {
797
- super({ methodName: "setClipboardText" });
798
- }
799
- };
800
644
  /**
801
- * startUpdateLocation 권한 에러.
802
- * web-framework 3.0에서 GetCurrentLocationPermissionError의 alias.
645
+ * HapticFeedbackType 10종 → navigator.vibrate 패턴 매핑.
646
+ * 숫자: 진동 ms. 배열: [진동, 정지, 진동, …] 교대 패턴.
803
647
  */
804
- const StartUpdateLocationPermissionError = GetCurrentLocationPermissionError;
805
- const permissionErrorMap = {
806
- openCamera: OpenCameraPermissionError,
807
- fetchAlbumPhotos: FetchAlbumPhotosPermissionError,
808
- fetchAlbumItems: FetchAlbumPhotosPermissionError,
809
- fetchContacts: FetchContactsPermissionError,
810
- getCurrentLocation: GetCurrentLocationPermissionError,
811
- getClipboardText: GetClipboardTextPermissionError,
812
- setClipboardText: SetClipboardTextPermissionError
648
+ const HAPTIC_VIBRATE_PATTERN = {
649
+ tickWeak: 10,
650
+ tap: 20,
651
+ tickMedium: 30,
652
+ softMedium: 40,
653
+ basicWeak: 15,
654
+ basicMedium: 50,
655
+ success: [
656
+ 10,
657
+ 40,
658
+ 10
659
+ ],
660
+ error: [
661
+ 40,
662
+ 30,
663
+ 40
664
+ ],
665
+ wiggle: [
666
+ 20,
667
+ 20,
668
+ 20,
669
+ 20,
670
+ 20
671
+ ],
672
+ confetti: [
673
+ 10,
674
+ 20,
675
+ 10,
676
+ 20,
677
+ 10,
678
+ 20,
679
+ 10
680
+ ]
813
681
  };
814
- async function getPermission(name) {
815
- return aitState.state.permissions[name];
816
- }
817
- async function openPermissionDialog(name) {
818
- if (aitState.state.permissions[name] === "allowed") return "allowed";
819
- aitState.patch("permissions", { [name]: "allowed" });
820
- return "allowed";
821
- }
822
- async function requestPermission(permission) {
823
- return openPermissionDialog(permission.name);
824
- }
825
- /** 권한이 필요한 함수에 .getPermission(), .openPermissionDialog()를 부착 */
826
- function withPermission(fn, permissionName) {
827
- const enhanced = fn;
828
- enhanced.getPermission = () => getPermission(permissionName);
829
- enhanced.openPermissionDialog = () => openPermissionDialog(permissionName);
830
- return enhanced;
682
+ async function generateHapticFeedback(options) {
683
+ const timestamp = Date.now();
684
+ aitState.logAnalytics({
685
+ type: "haptic",
686
+ params: { hapticType: options.type }
687
+ });
688
+ const pattern = HAPTIC_VIBRATE_PATTERN[options.type] ?? 30;
689
+ const vibrated = typeof navigator.vibrate === "function" ? navigator.vibrate(pattern) : false;
690
+ aitState.logSdkCall({
691
+ method: "generateHapticFeedback",
692
+ args: [{ type: options.type }],
693
+ timestamp,
694
+ status: "resolved",
695
+ result: {
696
+ hapticType: options.type,
697
+ vibrated
698
+ },
699
+ fidelity: "partial"
700
+ });
831
701
  }
832
- /**
833
- * 권한 체크 후 denied면 per-API *PermissionError 서브클래스를 throw한다.
834
- * 실 3.0 SDK 동작과 일치 — `instanceof PermissionError` 분기가 mock에서도 동작한다 (#372).
835
- */
836
- function checkPermission(name, fnName) {
837
- if (aitState.state.permissions[name] === "denied") {
838
- const ErrorClass = permissionErrorMap[fnName];
839
- if (ErrorClass) throw new ErrorClass();
840
- throw new PermissionError({ methodName: fnName });
841
- }
702
+ async function saveBase64Data(params) {
703
+ const a = document.createElement("a");
704
+ a.href = `data:${params.mimeType};base64,${params.data}`;
705
+ a.download = params.fileName;
706
+ a.click();
842
707
  }
843
708
  //#endregion
844
- //#region src/mock/device/camera.ts
709
+ //#region src/mock/device/location.ts
845
710
  /**
846
- * Camera & Album Photos & Album Items mock
711
+ * Location mock (getCurrentLocation, startUpdateLocation)
847
712
  * mock/web/prompt 모드 지원
848
713
  */
849
- async function openCameraMock() {
850
- const images = getMockImages();
714
+ var Accuracy = /* @__PURE__ */ function(Accuracy) {
715
+ Accuracy[Accuracy["Lowest"] = 1] = "Lowest";
716
+ Accuracy[Accuracy["Low"] = 2] = "Low";
717
+ Accuracy[Accuracy["Balanced"] = 3] = "Balanced";
718
+ Accuracy[Accuracy["High"] = 4] = "High";
719
+ Accuracy[Accuracy["Highest"] = 5] = "Highest";
720
+ Accuracy[Accuracy["BestForNavigation"] = 6] = "BestForNavigation";
721
+ return Accuracy;
722
+ }(Accuracy || {});
723
+ function buildLocation() {
851
724
  return {
852
- id: crypto.randomUUID(),
853
- dataUri: images[0]
725
+ coords: { ...aitState.state.location.coords },
726
+ timestamp: Date.now(),
727
+ accessLocation: aitState.state.location.accessLocation
854
728
  };
855
729
  }
856
- async function openCameraWeb() {
857
- return new Promise((resolve, reject) => {
858
- const input = document.createElement("input");
859
- input.type = "file";
860
- input.accept = "image/*";
861
- input.capture = "environment";
862
- let settled = false;
863
- input.onchange = () => {
864
- settled = true;
865
- const file = input.files?.[0];
866
- if (!file) {
867
- reject(/* @__PURE__ */ new Error("No file selected"));
868
- return;
869
- }
870
- const reader = new FileReader();
871
- reader.onload = () => resolve({
872
- id: crypto.randomUUID(),
873
- dataUri: reader.result
874
- });
875
- reader.onerror = () => reject(/* @__PURE__ */ new Error("Failed to read file"));
876
- reader.readAsDataURL(file);
877
- };
878
- const onFocus = () => {
879
- setTimeout(() => {
880
- if (!settled) reject(/* @__PURE__ */ new Error("File picker cancelled"));
881
- window.removeEventListener("focus", onFocus);
882
- }, 300);
883
- };
884
- window.addEventListener("focus", onFocus);
885
- input.click();
886
- });
730
+ async function getCurrentLocationMock() {
731
+ return buildLocation();
887
732
  }
888
- async function openCameraPrompt() {
889
- const dataUri = await waitForPromptResponse("camera");
890
- return {
891
- id: crypto.randomUUID(),
892
- dataUri
893
- };
894
- }
895
- const _openCamera = async (_options) => {
896
- checkPermission("camera", "openCamera");
897
- const mode = aitState.state.deviceModes.camera;
898
- if (mode === "web") return openCameraWeb();
899
- if (mode === "prompt") return openCameraPrompt();
900
- return openCameraMock();
901
- };
902
- const openCamera = withPermission(_openCamera, "camera");
903
- async function fetchAlbumPhotosMock(maxCount) {
904
- return getMockImages().slice(0, maxCount).map((dataUri) => ({
905
- id: crypto.randomUUID(),
906
- dataUri
907
- }));
908
- }
909
- async function fetchAlbumPhotosWeb(maxCount) {
910
- return new Promise((resolve, reject) => {
911
- const input = document.createElement("input");
912
- input.type = "file";
913
- input.accept = "image/*";
914
- input.multiple = true;
915
- let settled = false;
916
- input.onchange = async () => {
917
- settled = true;
918
- const files = Array.from(input.files ?? []).slice(0, maxCount);
919
- if (files.length === 0) {
920
- reject(/* @__PURE__ */ new Error("No files selected"));
921
- return;
922
- }
923
- resolve(await Promise.all(files.map((file) => new Promise((res, rej) => {
924
- const reader = new FileReader();
925
- reader.onload = () => res({
926
- id: crypto.randomUUID(),
927
- dataUri: reader.result
928
- });
929
- reader.onerror = () => rej(/* @__PURE__ */ new Error("Failed to read file"));
930
- reader.readAsDataURL(file);
931
- }))));
932
- };
933
- const onFocus = () => {
934
- setTimeout(() => {
935
- if (!settled) reject(/* @__PURE__ */ new Error("File picker cancelled"));
936
- window.removeEventListener("focus", onFocus);
937
- }, 300);
938
- };
939
- window.addEventListener("focus", onFocus);
940
- input.click();
733
+ async function getCurrentLocationWeb() {
734
+ return new Promise((resolve) => {
735
+ if (!navigator.geolocation) {
736
+ console.warn("[@ait-co/devtools] Geolocation API not available, falling back to mock");
737
+ resolve(buildLocation());
738
+ return;
739
+ }
740
+ navigator.geolocation.getCurrentPosition((pos) => {
741
+ resolve({
742
+ coords: {
743
+ latitude: pos.coords.latitude,
744
+ longitude: pos.coords.longitude,
745
+ altitude: pos.coords.altitude ?? 0,
746
+ accuracy: pos.coords.accuracy,
747
+ altitudeAccuracy: pos.coords.altitudeAccuracy ?? 0,
748
+ heading: pos.coords.heading ?? 0
749
+ },
750
+ timestamp: pos.timestamp,
751
+ accessLocation: "FINE"
752
+ });
753
+ }, () => {
754
+ console.warn("[@ait-co/devtools] Geolocation failed, falling back to mock");
755
+ resolve(buildLocation());
756
+ });
941
757
  });
942
758
  }
943
- async function fetchAlbumPhotosPrompt(maxCount) {
944
- return (await waitForPromptResponse("photos")).slice(0, maxCount).map((dataUri) => ({
945
- id: crypto.randomUUID(),
946
- dataUri
947
- }));
759
+ async function getCurrentLocationPrompt() {
760
+ return waitForPromptResponse("location");
948
761
  }
949
- const _fetchAlbumPhotos = async (options) => {
950
- checkPermission("photos", "fetchAlbumPhotos");
951
- const maxCount = options?.maxCount ?? 10;
952
- const mode = aitState.state.deviceModes.photos;
953
- if (mode === "web") return fetchAlbumPhotosWeb(maxCount);
954
- if (mode === "prompt") return fetchAlbumPhotosPrompt(maxCount);
955
- return fetchAlbumPhotosMock(maxCount);
762
+ const _getCurrentLocation = async (_options) => {
763
+ checkPermission("geolocation", "getCurrentLocation");
764
+ const mode = aitState.state.deviceModes.location;
765
+ if (mode === "web") return getCurrentLocationWeb();
766
+ if (mode === "prompt") return getCurrentLocationPrompt();
767
+ return getCurrentLocationMock();
956
768
  };
957
- const fetchAlbumPhotos = withPermission(_fetchAlbumPhotos, "photos");
958
- async function fetchAlbumItemsMock(maxCount, types) {
959
- return getMockImages().slice(0, maxCount).filter(() => types.includes("PHOTO")).map((dataUri) => ({
960
- id: crypto.randomUUID(),
961
- dataUri,
962
- type: "PHOTO"
963
- }));
769
+ const getCurrentLocation = withPermission(_getCurrentLocation, "geolocation");
770
+ function startUpdateLocationMock(eventParams) {
771
+ const { onEvent, options } = eventParams;
772
+ const interval = Math.max(options.timeInterval, 500);
773
+ const id = setInterval(() => {
774
+ const loc = buildLocation();
775
+ loc.coords.latitude += (Math.random() - .5) * 1e-4;
776
+ loc.coords.longitude += (Math.random() - .5) * 1e-4;
777
+ onEvent(loc);
778
+ }, interval);
779
+ return () => clearInterval(id);
964
780
  }
965
- async function fetchAlbumItemsWeb(maxCount, types) {
966
- return new Promise((resolve) => {
967
- const input = document.createElement("input");
968
- input.type = "file";
969
- input.accept = types.includes("VIDEO") ? "image/*,video/*" : "image/*";
970
- input.multiple = true;
971
- let settled = false;
972
- input.onchange = async () => {
973
- settled = true;
974
- const files = Array.from(input.files ?? []).slice(0, maxCount);
975
- if (files.length === 0) {
976
- resolve([]);
977
- return;
978
- }
979
- resolve(await Promise.all(files.map((file) => new Promise((res, rej) => {
980
- const itemType = file.type.startsWith("video/") ? "VIDEO" : "PHOTO";
981
- const reader = new FileReader();
982
- reader.onload = () => res({
983
- id: crypto.randomUUID(),
984
- dataUri: reader.result,
985
- type: itemType
986
- });
987
- reader.onerror = () => rej(/* @__PURE__ */ new Error("Failed to read file"));
988
- reader.readAsDataURL(file);
989
- }))));
990
- };
991
- const onFocus = () => {
992
- setTimeout(() => {
993
- if (!settled) resolve([]);
994
- window.removeEventListener("focus", onFocus);
995
- }, 300);
996
- };
997
- window.addEventListener("focus", onFocus);
998
- input.click();
999
- });
781
+ function startUpdateLocationWeb(eventParams) {
782
+ const { onEvent, onError } = eventParams;
783
+ if (!navigator.geolocation) {
784
+ console.warn("[@ait-co/devtools] Geolocation API not available, falling back to mock");
785
+ return startUpdateLocationMock(eventParams);
786
+ }
787
+ const watchId = navigator.geolocation.watchPosition((pos) => {
788
+ onEvent({
789
+ coords: {
790
+ latitude: pos.coords.latitude,
791
+ longitude: pos.coords.longitude,
792
+ altitude: pos.coords.altitude ?? 0,
793
+ accuracy: pos.coords.accuracy,
794
+ altitudeAccuracy: pos.coords.altitudeAccuracy ?? 0,
795
+ heading: pos.coords.heading ?? 0
796
+ },
797
+ timestamp: pos.timestamp,
798
+ accessLocation: "FINE"
799
+ });
800
+ }, (err) => onError(err));
801
+ return () => navigator.geolocation.clearWatch(watchId);
1000
802
  }
1001
- async function fetchAlbumItemsPrompt(maxCount) {
1002
- return (await waitForPromptResponse("photos")).slice(0, maxCount).map((dataUri) => ({
1003
- id: crypto.randomUUID(),
1004
- dataUri,
1005
- type: "PHOTO"
1006
- }));
803
+ function startUpdateLocationPrompt(eventParams) {
804
+ const { onEvent } = eventParams;
805
+ const handler = (e) => {
806
+ onEvent(e.detail);
807
+ };
808
+ window.addEventListener("__ait:prompt-response:location-update", handler);
809
+ window.dispatchEvent(new CustomEvent("__ait:prompt-request", { detail: { type: "location-update" } }));
810
+ return () => window.removeEventListener("__ait:prompt-response:location-update", handler);
1007
811
  }
1008
- const _fetchAlbumItems = async (options) => {
1009
- checkPermission("photos", "fetchAlbumItems");
1010
- const maxCount = options?.maxCount ?? 10;
1011
- const types = options?.types ?? ["PHOTO"];
1012
- const mode = aitState.state.deviceModes.photos;
1013
- if (mode === "web") return fetchAlbumItemsWeb(maxCount, types);
1014
- if (mode === "prompt") return fetchAlbumItemsPrompt(maxCount);
1015
- return fetchAlbumItemsMock(maxCount, types);
812
+ const _startUpdateLocation = (eventParams) => {
813
+ const mode = aitState.state.deviceModes.location;
814
+ if (mode === "web") return startUpdateLocationWeb(eventParams);
815
+ if (mode === "prompt") return startUpdateLocationPrompt(eventParams);
816
+ return startUpdateLocationMock(eventParams);
1016
817
  };
1017
- const fetchAlbumItems = withPermission(_fetchAlbumItems, "photos");
818
+ const startUpdateLocation = withPermission(_startUpdateLocation, "geolocation");
1018
819
  //#endregion
1019
- //#region src/mock/device/clipboard.ts
820
+ //#region src/mock/device/network.ts
1020
821
  /**
1021
- * Clipboard mock
1022
- * mock/web 모드 지원
822
+ * Network Status mock (mode-aware helper)
823
+ * navigation 모듈에서 사용. circular dep 방지를 위해 device에 위치.
1023
824
  */
1024
- const _getClipboardText = async () => {
1025
- checkPermission("clipboard", "getClipboardText");
1026
- if (aitState.state.deviceModes.clipboard === "mock") return aitState.state.mockData.clipboardText;
1027
- try {
1028
- return await navigator.clipboard.readText();
1029
- } catch {
1030
- return "";
1031
- }
1032
- };
1033
- const getClipboardText = withPermission(_getClipboardText, "clipboard");
1034
- const _setClipboardText = async (text) => {
1035
- checkPermission("clipboard", "setClipboardText");
1036
- if (aitState.state.deviceModes.clipboard === "mock") {
1037
- aitState.patch("mockData", { clipboardText: text });
1038
- return;
1039
- }
1040
- await navigator.clipboard.writeText(text);
1041
- };
1042
- const setClipboardText = withPermission(_setClipboardText, "clipboard");
1043
- //#endregion
1044
- //#region src/mock/device/contacts.ts
1045
825
  /**
1046
- * Contacts mock
826
+ * Web mode: uses navigator.connection.effectiveType (4g/3g/2g) and navigator.onLine.
827
+ * Limitations: WIFI, 5G, WWAN cannot be detected via the Network Information API.
828
+ * Falls back to state-based value when effectiveType is unavailable.
1047
829
  */
1048
- const _fetchContacts = async (options) => {
1049
- checkPermission("contacts", "fetchContacts");
1050
- let contacts = aitState.state.contacts;
1051
- if (options.query?.contains) {
1052
- const q = options.query.contains.toLowerCase();
1053
- contacts = contacts.filter((c) => c.name.toLowerCase().includes(q) || c.phoneNumber.includes(q));
830
+ function getNetworkStatusByMode() {
831
+ const mode = aitState.state.deviceModes.network;
832
+ if (mode === "mock") return null;
833
+ if (mode === "web") {
834
+ if (!navigator.onLine) return "OFFLINE";
835
+ const conn = navigator.connection;
836
+ if (conn?.effectiveType) return {
837
+ "4g": "4G",
838
+ "3g": "3G",
839
+ "2g": "2G",
840
+ "slow-2g": "2G"
841
+ }[conn.effectiveType] ?? "UNKNOWN";
842
+ return aitState.state.networkStatus;
1054
843
  }
1055
- const sliced = contacts.slice(options.offset, options.offset + options.size);
1056
- const nextOffset = options.offset + options.size;
1057
- return {
1058
- result: sliced,
1059
- nextOffset: nextOffset < contacts.length ? nextOffset : null,
1060
- done: nextOffset >= contacts.length
1061
- };
1062
- };
1063
- const fetchContacts = withPermission(_fetchContacts, "contacts");
844
+ return null;
845
+ }
1064
846
  //#endregion
1065
- //#region src/mock/device/haptic.ts
847
+ //#region src/mock/device/pdf.ts
1066
848
  /**
1067
- * Haptic Feedback & saveBase64Data mock
849
+ * Base64로 인코딩된 PDF 데이터를 네이티브 PDF 뷰어로 여는 mock.
850
+ * mock 환경에서는 즉시 `'CLOSE'`를 반환한다.
851
+ */
852
+ async function openPDFViewer(_params) {
853
+ await Promise.resolve();
854
+ return "CLOSE";
855
+ }
856
+ //#endregion
857
+ //#region src/mock/proxy.ts
858
+ /**
859
+ * 미구현 API용 Proxy 트립와이어.
1068
860
  *
1069
- * generateHapticFeedback 영역 3 (하드웨어 API 관측):
1070
- * - 10종 HapticFeedbackType을 navigator.vibrate 패턴으로 매핑(근사, best-effort).
1071
- * - `typeof navigator.vibrate === 'function'` 가드 API 없는 환경에서 throw 없이 skip.
1072
- * - sdkCallLog에 🟡(partial)로 기록. params: { hapticType, vibrated: boolean }.
1073
- * - 시그니처 불변 __typecheck.ts의 Assert<Mock, Original> 통과.
861
+ * 미구현 프로퍼티에 접근하면 throw한다. 이는 "devtools에서는 멀쩡히 돌지만
862
+ * SDK에선 실제로 동작하는" 시나리오를 차단하기 위한 의도적 선택이다.
863
+ * mock이 미구현인 API는 SDK에서는 존재할 있고, 사용자가 이를 인지하지
864
+ * 못한 개발을 이어가면 배포 시점에 놀라게 된다. 에러 메시지에 이슈 URL을
865
+ * 포함해 사용자가 mock 누락을 제보할 있게 한다.
866
+ *
867
+ * ## KNOWN_UNIMPLEMENTED 정책
868
+ * SDK에 존재하는 것으로 알려져 있으나 현재 mock이 없는 API 이름만 이 집합에 둔다.
869
+ * 이 경우에만 throw 대신 🔴 inert no-op을 반환하고 sdkCallLog에 기록한다.
870
+ * 완전히 미지의 이름은 여전히 throw — "잘 되는 척" 방지.
1074
871
  */
872
+ const ISSUES_URL = "https://github.com/apps-in-toss-community/devtools/issues";
1075
873
  /**
1076
- * HapticFeedbackType 10종 navigator.vibrate 패턴 매핑.
1077
- * 숫자: 진동 ms. 배열: [진동, 정지, 진동, …] 교대 패턴.
874
+ * SDK에 존재하나 mock이 아직 없는 것으로 확인된 이름 목록.
875
+ * API가 SDK에 추가되면 여기에 추가하고 별도 PR에서 mock 구현으로 이동한다.
876
+ * 확인되지 않은 이름은 절대 여기에 추가하지 않는다 — throw가 더 안전하다.
1078
877
  */
1079
- const HAPTIC_VIBRATE_PATTERN = {
1080
- tickWeak: 10,
1081
- tap: 20,
1082
- tickMedium: 30,
1083
- softMedium: 40,
1084
- basicWeak: 15,
1085
- basicMedium: 50,
1086
- success: [
1087
- 10,
1088
- 40,
1089
- 10
1090
- ],
1091
- error: [
1092
- 40,
1093
- 30,
1094
- 40
1095
- ],
1096
- wiggle: [
1097
- 20,
1098
- 20,
1099
- 20,
1100
- 20,
1101
- 20
1102
- ],
1103
- confetti: [
1104
- 10,
1105
- 20,
1106
- 10,
1107
- 20,
1108
- 10,
1109
- 20,
1110
- 10
1111
- ]
1112
- };
1113
- async function generateHapticFeedback(options) {
1114
- const timestamp = Date.now();
1115
- aitState.logAnalytics({
1116
- type: "haptic",
1117
- params: { hapticType: options.type }
1118
- });
1119
- const pattern = HAPTIC_VIBRATE_PATTERN[options.type] ?? 30;
1120
- const vibrated = typeof navigator.vibrate === "function" ? navigator.vibrate(pattern) : false;
1121
- aitState.logSdkCall({
1122
- method: "generateHapticFeedback",
1123
- args: [{ type: options.type }],
1124
- timestamp,
1125
- status: "resolved",
1126
- result: {
1127
- hapticType: options.type,
1128
- vibrated
1129
- },
1130
- fidelity: "partial"
1131
- });
1132
- }
1133
- async function saveBase64Data(params) {
1134
- const a = document.createElement("a");
1135
- a.href = `data:${params.mimeType};base64,${params.data}`;
1136
- a.download = params.fileName;
1137
- a.click();
878
+ const KNOWN_UNIMPLEMENTED = /* @__PURE__ */ new Set([]);
879
+ function createMockProxy(moduleName, implementations) {
880
+ return new Proxy(implementations, { get(target, prop) {
881
+ if (typeof prop === "symbol") return void 0;
882
+ if (prop in target) return target[prop];
883
+ const name = String(prop);
884
+ if (KNOWN_UNIMPLEMENTED.has(name)) return (...args) => {
885
+ console.warn(`[@ait-co/devtools] ${moduleName}.${name} is known-unimplemented (🔴 inert). Returning undefined. Please file or upvote an issue: ${ISSUES_URL}`);
886
+ aitState.logSdkCall({
887
+ method: `${moduleName}.${name}`,
888
+ args,
889
+ timestamp: Date.now(),
890
+ status: "resolved",
891
+ result: void 0,
892
+ fidelity: "inert"
893
+ });
894
+ };
895
+ throw new Error(`[@ait-co/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}`);
896
+ } });
1138
897
  }
1139
898
  //#endregion
1140
- //#region src/mock/device/location.ts
899
+ //#region src/mock/device/storage.ts
1141
900
  /**
1142
- * Location mock (getCurrentLocation, startUpdateLocation)
1143
- * mock/web/prompt 모드 지원
901
+ * Storage mock
902
+ * localStorage에 `__ait_storage:` prefix로 저장하여 앱 자체 localStorage와 분리
1144
903
  */
1145
- var Accuracy = /* @__PURE__ */ function(Accuracy) {
1146
- Accuracy[Accuracy["Lowest"] = 1] = "Lowest";
1147
- Accuracy[Accuracy["Low"] = 2] = "Low";
1148
- Accuracy[Accuracy["Balanced"] = 3] = "Balanced";
1149
- Accuracy[Accuracy["High"] = 4] = "High";
1150
- Accuracy[Accuracy["Highest"] = 5] = "Highest";
1151
- Accuracy[Accuracy["BestForNavigation"] = 6] = "BestForNavigation";
1152
- return Accuracy;
1153
- }(Accuracy || {});
1154
- function buildLocation() {
1155
- return {
1156
- coords: { ...aitState.state.location.coords },
1157
- timestamp: Date.now(),
1158
- accessLocation: aitState.state.location.accessLocation
904
+ const Storage = createMockProxy("Storage", {
905
+ getItem: async (key) => {
906
+ return localStorage.getItem(`__ait_storage:${key}`);
907
+ },
908
+ setItem: async (key, value) => {
909
+ localStorage.setItem(`__ait_storage:${key}`, value);
910
+ },
911
+ removeItem: async (key) => {
912
+ localStorage.removeItem(`__ait_storage:${key}`);
913
+ },
914
+ clearItems: async () => {
915
+ const keys = Object.keys(localStorage).filter((k) => k.startsWith("__ait_storage:"));
916
+ for (const k of keys) localStorage.removeItem(k);
917
+ }
918
+ });
919
+ //#endregion
920
+ //#region src/mock/observe.ts
921
+ /**
922
+ * fn을 observe로 감싼다.
923
+ *
924
+ * @param apiName - 로그에 기록할 SDK 메서드 이름 (예: `'setScreenAwakeMode'`)
925
+ * @param fidelity - 이 mock의 fidelity grade ('faithful' | 'partial' | 'inert')
926
+ * @param fn - 실제 mock 구현체. 시그니처를 그대로 통과시킨다.
927
+ * @returns fn과 동일한 타입의 래퍼 함수
928
+ */
929
+ function observe(apiName, fidelity, fn) {
930
+ return (...args) => {
931
+ const timestamp = Date.now();
932
+ const safeArgs = args.map((a) => safeSerialize(a));
933
+ const result = fn(...args);
934
+ if (result instanceof Promise) {
935
+ aitState.logSdkCall({
936
+ method: apiName,
937
+ args: safeArgs,
938
+ timestamp,
939
+ status: "pending",
940
+ fidelity
941
+ });
942
+ result.then((value) => {
943
+ aitState.logSdkCall({
944
+ method: apiName,
945
+ args: safeArgs,
946
+ timestamp,
947
+ status: "resolved",
948
+ result: safeSerialize(value),
949
+ fidelity
950
+ });
951
+ }, (err) => {
952
+ aitState.logSdkCall({
953
+ method: apiName,
954
+ args: safeArgs,
955
+ timestamp,
956
+ status: "rejected",
957
+ error: err instanceof Error ? err.message : String(err),
958
+ fidelity
959
+ });
960
+ });
961
+ return result;
962
+ }
963
+ aitState.logSdkCall({
964
+ method: apiName,
965
+ args: safeArgs,
966
+ timestamp,
967
+ status: "resolved",
968
+ result: safeSerialize(result),
969
+ fidelity
970
+ });
971
+ return result;
1159
972
  };
1160
973
  }
1161
- async function getCurrentLocationMock() {
1162
- return buildLocation();
974
+ /**
975
+ * 값을 JSON-safe한 형태로 변환한다.
976
+ * - null / primitive — 그대로.
977
+ * - 함수 — `'[Function: name]'` 문자열.
978
+ * - 기타 객체 — JSON.stringify 실패 시 `'[unserializable]'`.
979
+ */
980
+ function safeSerialize(value) {
981
+ if (value === null || value === void 0) return value;
982
+ if (typeof value === "function") return `[Function: ${value.name || "anonymous"}]`;
983
+ if (typeof value !== "object") return value;
984
+ try {
985
+ return JSON.parse(JSON.stringify(value));
986
+ } catch {
987
+ return "[unserializable]";
988
+ }
1163
989
  }
1164
- async function getCurrentLocationWeb() {
1165
- return new Promise((resolve) => {
1166
- if (!navigator.geolocation) {
1167
- console.warn("[@ait-co/devtools] Geolocation API not available, falling back to mock");
1168
- resolve(buildLocation());
1169
- return;
1170
- }
1171
- navigator.geolocation.getCurrentPosition((pos) => {
1172
- resolve({
1173
- coords: {
1174
- latitude: pos.coords.latitude,
1175
- longitude: pos.coords.longitude,
1176
- altitude: pos.coords.altitude ?? 0,
1177
- accuracy: pos.coords.accuracy,
1178
- altitudeAccuracy: pos.coords.altitudeAccuracy ?? 0,
1179
- heading: pos.coords.heading ?? 0
1180
- },
1181
- timestamp: pos.timestamp,
1182
- accessLocation: "FINE"
1183
- });
1184
- }, () => {
1185
- console.warn("[@ait-co/devtools] Geolocation failed, falling back to mock");
1186
- resolve(buildLocation());
1187
- });
1188
- });
990
+ //#endregion
991
+ //#region src/mock/navigation/index.ts
992
+ /**
993
+ * 화면/네비게이션/이벤트 mock
994
+ */
995
+ async function closeView() {
996
+ console.log("[@ait-co/devtools] closeView called");
997
+ window.history.back();
1189
998
  }
1190
- async function getCurrentLocationPrompt() {
1191
- return waitForPromptResponse("location");
999
+ async function openURL(url) {
1000
+ console.log("[@ait-co/devtools] openURL:", url);
1001
+ window.open(url, "_blank");
1192
1002
  }
1193
- const _getCurrentLocation = async (_options) => {
1194
- checkPermission("geolocation", "getCurrentLocation");
1195
- const mode = aitState.state.deviceModes.location;
1196
- if (mode === "web") return getCurrentLocationWeb();
1197
- if (mode === "prompt") return getCurrentLocationPrompt();
1198
- return getCurrentLocationMock();
1199
- };
1200
- const getCurrentLocation = withPermission(_getCurrentLocation, "geolocation");
1201
- function startUpdateLocationMock(eventParams) {
1202
- const { onEvent, options } = eventParams;
1203
- const interval = Math.max(options.timeInterval, 500);
1204
- const id = setInterval(() => {
1205
- const loc = buildLocation();
1206
- loc.coords.latitude += (Math.random() - .5) * 1e-4;
1207
- loc.coords.longitude += (Math.random() - .5) * 1e-4;
1208
- onEvent(loc);
1209
- }, interval);
1210
- return () => clearInterval(id);
1003
+ async function share(message) {
1004
+ if (navigator.share) {
1005
+ await navigator.share({ text: message.message });
1006
+ return;
1007
+ }
1008
+ console.log("[@ait-co/devtools] share:", message.message);
1211
1009
  }
1212
- function startUpdateLocationWeb(eventParams) {
1213
- const { onEvent, onError } = eventParams;
1214
- if (!navigator.geolocation) {
1215
- console.warn("[@ait-co/devtools] Geolocation API not available, falling back to mock");
1216
- return startUpdateLocationMock(eventParams);
1010
+ async function getTossShareLink(path, _ogImageUrl) {
1011
+ return `https://toss.im/share/mock${path}`;
1012
+ }
1013
+ async function setIosSwipeGestureEnabled(options) {
1014
+ console.log("[@ait-co/devtools] setIosSwipeGestureEnabled:", options.isEnabled);
1015
+ aitState.patch("navigation", { iosSwipeGestureEnabled: options.isEnabled });
1016
+ }
1017
+ async function setDeviceOrientation(options) {
1018
+ const current = aitState.state.viewport.orientation;
1019
+ if (current === "auto") {
1020
+ console.log("[@ait-co/devtools] setDeviceOrientation:", options.type);
1021
+ aitState.patch("viewport", { appOrientation: options.type });
1022
+ return;
1217
1023
  }
1218
- const watchId = navigator.geolocation.watchPosition((pos) => {
1219
- onEvent({
1220
- coords: {
1221
- latitude: pos.coords.latitude,
1222
- longitude: pos.coords.longitude,
1223
- altitude: pos.coords.altitude ?? 0,
1224
- accuracy: pos.coords.accuracy,
1225
- altitudeAccuracy: pos.coords.altitudeAccuracy ?? 0,
1226
- heading: pos.coords.heading ?? 0
1227
- },
1228
- timestamp: pos.timestamp,
1229
- accessLocation: "FINE"
1230
- });
1231
- }, (err) => onError(err));
1232
- return () => navigator.geolocation.clearWatch(watchId);
1024
+ console.warn(`[@ait-co/devtools] setDeviceOrientation(${options.type}) ignored — Panel is forcing "${current}". Change the Viewport tab's orientation to "auto" to let the app control rotation.`);
1233
1025
  }
1234
- function startUpdateLocationPrompt(eventParams) {
1235
- const { onEvent } = eventParams;
1026
+ const setScreenAwakeMode = observe("setScreenAwakeMode", "inert", async (options) => {
1027
+ console.log("[@ait-co/devtools] setScreenAwakeMode:", options.enabled);
1028
+ return { enabled: options.enabled };
1029
+ });
1030
+ const setSecureScreen = observe("setSecureScreen", "inert", async (options) => {
1031
+ console.log("[@ait-co/devtools] setSecureScreen:", options.enabled);
1032
+ return { enabled: options.enabled };
1033
+ });
1034
+ const requestReview = observe("requestReview", "inert", async () => {
1035
+ console.log("[@ait-co/devtools] requestReview called");
1036
+ });
1037
+ requestReview.isSupported = () => true;
1038
+ function getPlatformOS() {
1039
+ return aitState.state.platform;
1040
+ }
1041
+ function getOperationalEnvironment() {
1042
+ return aitState.state.environment;
1043
+ }
1044
+ function getTossAppVersion() {
1045
+ return aitState.state.appVersion;
1046
+ }
1047
+ function isMinVersionSupported(minVersions) {
1048
+ const required = aitState.state.platform === "ios" ? minVersions.ios : minVersions.android;
1049
+ if (required === "always") return true;
1050
+ if (required === "never") return false;
1051
+ const current = aitState.state.appVersion.split(".").map(Number);
1052
+ const min = required.split(".").map(Number);
1053
+ for (let i = 0; i < 3; i++) {
1054
+ if ((current[i] ?? 0) > (min[i] ?? 0)) return true;
1055
+ if ((current[i] ?? 0) < (min[i] ?? 0)) return false;
1056
+ }
1057
+ return true;
1058
+ }
1059
+ function getSchemeUri() {
1060
+ return aitState.state.schemeUri || window.location.pathname;
1061
+ }
1062
+ function getLocale() {
1063
+ return aitState.state.locale;
1064
+ }
1065
+ function getDeviceId() {
1066
+ return aitState.state.deviceId;
1067
+ }
1068
+ function getGroupId() {
1069
+ return aitState.state.groupId;
1070
+ }
1071
+ async function getNetworkStatus() {
1072
+ const modeResult = getNetworkStatusByMode();
1073
+ if (modeResult) return modeResult;
1074
+ return aitState.state.networkStatus;
1075
+ }
1076
+ async function getServerTime() {
1077
+ return Date.now();
1078
+ }
1079
+ getServerTime.isSupported = () => true;
1080
+ /**
1081
+ * 현재 backEvent 구독자 수. graniteEvent.addEventListener('backEvent', …)가
1082
+ * 증가시키고, 반환된 cleanup이 감소시킨다. 호스트 back 메시지 처리 시 인터셉트
1083
+ * 여부를 판단하는 데 쓰인다.
1084
+ *
1085
+ * @internal 테스트 및 safe-area-bridge에서만 사용.
1086
+ */
1087
+ let _backEventSubscriberCount = 0;
1088
+ const graniteEvent = { addEventListener(event, { onEvent, onError }) {
1089
+ const handler = () => {
1090
+ try {
1091
+ onEvent();
1092
+ } catch (e) {
1093
+ onError?.(e instanceof Error ? e : new Error(String(e)));
1094
+ }
1095
+ };
1096
+ window.addEventListener(`__ait:${event}`, handler);
1097
+ if (event === "backEvent") _backEventSubscriberCount++;
1098
+ let cleaned = false;
1099
+ return () => {
1100
+ if (cleaned) return;
1101
+ cleaned = true;
1102
+ window.removeEventListener(`__ait:${event}`, handler);
1103
+ if (event === "backEvent") _backEventSubscriberCount--;
1104
+ };
1105
+ } };
1106
+ /**
1107
+ * 호스트 back 내비게이션을 처리한다.
1108
+ *
1109
+ * backEvent 구독자가 1명 이상이면 `window.dispatchEvent(new CustomEvent('__ait:backEvent'))`만
1110
+ * 발사한다 — 미니앱이 back을 가로채는(intercept) 채널이고 실제 토스 호스트와 동일한 시맨틱.
1111
+ * 구독자가 없으면 `history.back()`을 호출해 기본 브라우저 뒤로가기를 수행한다.
1112
+ *
1113
+ * env 1 패널의 back 버튼(`src/panel/viewport.ts` `aitState.trigger('backEvent')`)과
1114
+ * 동일한 경로를 거쳐 back 시맨틱의 단일 소유처를 navigation 모듈에 유지한다.
1115
+ */
1116
+ function dispatchHostBackNavigation() {
1117
+ if (_backEventSubscriberCount > 0) window.dispatchEvent(new CustomEvent("__ait:backEvent"));
1118
+ else history.back();
1119
+ }
1120
+ const appsInTossEvent = { addEventListener(_event, _handlers) {
1121
+ return () => {};
1122
+ } };
1123
+ const tdsEvent = { addEventListener(event, { onEvent }) {
1236
1124
  const handler = (e) => {
1237
- onEvent(e.detail);
1125
+ const detail = e.detail;
1126
+ onEvent(detail);
1238
1127
  };
1239
- window.addEventListener("__ait:prompt-response:location-update", handler);
1240
- window.dispatchEvent(new CustomEvent("__ait:prompt-request", { detail: { type: "location-update" } }));
1241
- return () => window.removeEventListener("__ait:prompt-response:location-update", handler);
1128
+ window.addEventListener(`__ait:${event}`, handler);
1129
+ return () => window.removeEventListener(`__ait:${event}`, handler);
1130
+ } };
1131
+ /**
1132
+ * @deprecated web-framework 3.0 에서 제거됨. 2.x 소비자 back-compat용으로 유지.
1133
+ */
1134
+ function onVisibilityChangedByTransparentServiceWeb(eventParams) {
1135
+ const handler = () => eventParams.onEvent(!document.hidden);
1136
+ document.addEventListener("visibilitychange", handler);
1137
+ return () => document.removeEventListener("visibilitychange", handler);
1242
1138
  }
1243
- const _startUpdateLocation = (eventParams) => {
1244
- const mode = aitState.state.deviceModes.location;
1245
- if (mode === "web") return startUpdateLocationWeb(eventParams);
1246
- if (mode === "prompt") return startUpdateLocationPrompt(eventParams);
1247
- return startUpdateLocationMock(eventParams);
1139
+ const env = { getDeploymentId: () => aitState.state.deploymentId };
1140
+ function getAppsInTossGlobals() {
1141
+ return {
1142
+ deploymentId: aitState.state.deploymentId,
1143
+ brandDisplayName: aitState.state.brand.displayName,
1144
+ brandIcon: aitState.state.brand.icon,
1145
+ brandPrimaryColor: aitState.state.brand.primaryColor
1146
+ };
1147
+ }
1148
+ const SafeAreaInsets = {
1149
+ get: () => ({ ...aitState.state.safeAreaInsets }),
1150
+ subscribe: ({ onEvent }) => {
1151
+ return aitState.subscribe(() => onEvent({ ...aitState.state.safeAreaInsets }));
1152
+ }
1248
1153
  };
1249
- const startUpdateLocation = withPermission(_startUpdateLocation, "geolocation");
1154
+ /** @deprecated */
1155
+ function getSafeAreaInsets() {
1156
+ return aitState.state.safeAreaInsets.top;
1157
+ }
1158
+ //#endregion
1159
+ //#region src/mock/safe-area-bridge.ts
1160
+ /**
1161
+ * env-2 postMessage bridges (#484, #510).
1162
+ *
1163
+ * In the AITC Sandbox PWA (env 2) the dev app runs inside the launcher's
1164
+ * full-viewport `<iframe>`. The launcher is the top-level document, so its
1165
+ * `env(safe-area-inset-*)` measurement is the ground truth for the real device
1166
+ * geometry. The framed page's mock would otherwise report a synthetic preset
1167
+ * value (e.g. top=54), which sdk-example then double-pads on top of a viewport
1168
+ * that already starts below the status bar — the env-2 "dead band" defect.
1169
+ *
1170
+ * This module installs receive-half listeners for two message types:
1171
+ *
1172
+ * 1. `ait:safe-area-insets` (#484): the launcher forwards its real env() insets
1173
+ * to the framed page on iframe load and resize/orientationchange. Validates the
1174
+ * envelope and writes real insets into the mock SafeAreaInsets state, firing the
1175
+ * subscribe path (see navigation/index.ts) so apps that subscribe re-read the
1176
+ * corrected values.
1177
+ *
1178
+ * 2. `ait:navigate-back` (#510): the launcher partner bar's `←` button posts this
1179
+ * command to the framed page. The receive half calls `dispatchHostBackNavigation()`
1180
+ * (navigation/index.ts): if backEvent subscribers are present, a `__ait:backEvent`
1181
+ * CustomEvent is dispatched (the mini-app intercept channel, matching the env-1
1182
+ * panel path); otherwise `history.back()` is called. No data other than `type` is
1183
+ * read from or written to the message — shape validation rejects anything that
1184
+ * carries extra fields with the wrong type. Apps that do not install this mock
1185
+ * (older builds) silently ignore the message (natural no-op).
1186
+ *
1187
+ * Origin policy: neither message type carries sensitive data, so we do NOT
1188
+ * restrict by origin — the launcher posts cross-origin from a *.trycloudflare.com
1189
+ * tunnel with targetOrigin '*'. Shape validation is still mandatory: a malformed
1190
+ * or out-of-range message is silently ignored so a stray postMessage can never
1191
+ * corrupt the mock state or trigger spurious navigation.
1192
+ *
1193
+ * Message-driven by design: env 1 (desktop browser, no launcher) never receives
1194
+ * these messages, so the panel preset stays authoritative there with zero special
1195
+ * casing here.
1196
+ */
1197
+ /** The postMessage envelope the launcher posts to the framed dev app (inset forward). */
1198
+ const SAFE_AREA_INSETS_MESSAGE_TYPE = "ait:safe-area-insets";
1199
+ /**
1200
+ * The postMessage command the launcher partner bar's `←` button sends to the
1201
+ * framed dev app (#510). The framed page calls `history.back()` in response.
1202
+ *
1203
+ * Protocol: only `{ type: 'ait:navigate-back' }` is valid. No other fields are
1204
+ * read or acted on — extra fields are silently ignored by the shape guard.
1205
+ * Game variant never sends this message (back button is partner-bar-only).
1206
+ */
1207
+ const NAVIGATE_BACK_MESSAGE_TYPE = "ait:navigate-back";
1208
+ const MAX_INSET_PX = 200;
1209
+ function isValidInset(value) {
1210
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= MAX_INSET_PX;
1211
+ }
1212
+ /**
1213
+ * Parse + validate a raw postMessage payload into a `SafeAreaInsets`, or return
1214
+ * null when it is not a well-formed `ait:safe-area-insets` message. Pure — unit
1215
+ * tested without a real MessageEvent.
1216
+ */
1217
+ function parseSafeAreaInsetsMessage(data) {
1218
+ if (typeof data !== "object" || data === null) return null;
1219
+ if (data.type !== "ait:safe-area-insets") return null;
1220
+ const insets = data.insets;
1221
+ if (typeof insets !== "object" || insets === null) return null;
1222
+ const { top, bottom, left, right } = insets;
1223
+ if (!isValidInset(top) || !isValidInset(bottom) || !isValidInset(left) || !isValidInset(right)) return null;
1224
+ return {
1225
+ top,
1226
+ bottom,
1227
+ left,
1228
+ right
1229
+ };
1230
+ }
1231
+ /**
1232
+ * Apply forwarded insets to the mock state. Skips the write (and the resulting
1233
+ * subscribe notify) when nothing changed, so repeated identical messages from a
1234
+ * resize storm don't churn subscribers.
1235
+ */
1236
+ function applyForwardedSafeAreaInsets(insets) {
1237
+ const current = aitState.state.safeAreaInsets;
1238
+ if (current.top === insets.top && current.bottom === insets.bottom && current.left === insets.left && current.right === insets.right) return;
1239
+ aitState.update({ safeAreaInsets: insets });
1240
+ }
1241
+ let installed = false;
1242
+ /**
1243
+ * Install the window `message` listener that receives forwarded insets. Safe to
1244
+ * call multiple times (idempotent) and a no-op outside a browser (SSR/jsdom
1245
+ * without a window). Imported for its side effect by the mock barrel so any
1246
+ * consumer that aliases `@apps-in-toss/web-framework` to the mock gets it wired.
1247
+ */
1248
+ function installSafeAreaInsetsBridge() {
1249
+ if (installed || typeof window === "undefined") return;
1250
+ installed = true;
1251
+ window.addEventListener("message", (event) => {
1252
+ const insets = parseSafeAreaInsetsMessage(event.data);
1253
+ if (insets) applyForwardedSafeAreaInsets(insets);
1254
+ });
1255
+ }
1256
+ /**
1257
+ * Parse a raw postMessage payload as an `ait:navigate-back` command.
1258
+ *
1259
+ * Returns true when the payload is a well-formed navigate-back command
1260
+ * (`{ type: 'ait:navigate-back' }`), false otherwise. Pure — unit tested
1261
+ * without a real MessageEvent.
1262
+ *
1263
+ * Shape guard: only the `type` field is inspected; any extra fields are
1264
+ * ignored so future extensions do not break older receivers. The function
1265
+ * does NOT read any data field beyond `type` — no sensitive values, no host
1266
+ * disclosure (same principle as the insets bridge).
1267
+ */
1268
+ function isNavigateBackMessage(data) {
1269
+ if (typeof data !== "object" || data === null) return false;
1270
+ return data.type === NAVIGATE_BACK_MESSAGE_TYPE;
1271
+ }
1272
+ let navigateBackInstalled = false;
1273
+ /**
1274
+ * Install the window `message` listener that handles `ait:navigate-back`
1275
+ * commands (#510). When the launcher partner bar's `←` button is clicked it
1276
+ * posts `{ type: 'ait:navigate-back' }` to the framed dev app; this listener
1277
+ * calls `dispatchHostBackNavigation()` from the navigation module.
1278
+ *
1279
+ * Dispatch semantics: if there are any `graniteEvent.addEventListener('backEvent', …)`
1280
+ * subscribers the CustomEvent `__ait:backEvent` is fired (same path as the env-1
1281
+ * panel back button — the mini-app intercept channel). When there are no
1282
+ * subscribers `history.back()` is called as the fallback. Back semantics are
1283
+ * owned entirely by the navigation module; this bridge only delegates.
1284
+ *
1285
+ * Safe to call multiple times (idempotent) and a no-op outside a browser.
1286
+ * Installed together with the inset bridge by `installBridges()` so any consumer
1287
+ * of the mock barrel gets both wired automatically.
1288
+ *
1289
+ * No-op on apps that predate this bridge — the launcher posts the message but
1290
+ * older mocks simply have no listener (harmless).
1291
+ */
1292
+ function installNavigateBackBridge() {
1293
+ if (navigateBackInstalled || typeof window === "undefined") return;
1294
+ navigateBackInstalled = true;
1295
+ window.addEventListener("message", (event) => {
1296
+ if (isNavigateBackMessage(event.data)) dispatchHostBackNavigation();
1297
+ });
1298
+ }
1299
+ /**
1300
+ * Install both env-2 postMessage bridges in one call (#484 insets + #510
1301
+ * navigate-back). The mock barrel calls this at import time so consumers get
1302
+ * all bridges wired without any explicit setup.
1303
+ */
1304
+ function installBridges() {
1305
+ installSafeAreaInsetsBridge();
1306
+ installNavigateBackBridge();
1307
+ }
1308
+ //#endregion
1309
+ //#region src/mock/ads/index.ts
1310
+ /**
1311
+ * 광고 mock (GoogleAdMob, TossAds, FullScreenAd)
1312
+ *
1313
+ * 변경 이력 (#196):
1314
+ * - slot 레지스트리로 TossAds destroy/destroyAll 누수 수정 (🟡→🟢)
1315
+ * - attachBanner BannerSlotCallbacks 발화 (onAdRendered/onAdImpression/onNoFill 등)
1316
+ * - initialize onInitialized/onInitializationFailed 발화
1317
+ * - AdMob reward 파라미터화 (state.ads.rewardUnitType/rewardAmount)
1318
+ * - 모든 호출 observe()로 sdkCallLog에 기록
1319
+ */
1320
+ function withIsSupported(fn) {
1321
+ fn.isSupported = () => true;
1322
+ return fn;
1323
+ }
1324
+ const _slotRegistry = /* @__PURE__ */ new Map();
1325
+ let _slotCounter = 0;
1326
+ function _nextSlotId(adGroupId) {
1327
+ _slotCounter += 1;
1328
+ return `mock-slot-${adGroupId}-${_slotCounter}`;
1329
+ }
1330
+ const GoogleAdMob = createMockProxy("GoogleAdMob", {
1331
+ loadAppsInTossAdMob: withIsSupported(observe("GoogleAdMob.loadAppsInTossAdMob", "faithful", (args) => {
1332
+ setTimeout(() => {
1333
+ if (aitState.state.ads.forceNoFill) {
1334
+ args.onError(/* @__PURE__ */ new Error("No fill"));
1335
+ return;
1336
+ }
1337
+ aitState.patch("ads", { isLoaded: true });
1338
+ args.onEvent({
1339
+ type: "loaded",
1340
+ data: { adGroupId: args.options?.adGroupId }
1341
+ });
1342
+ }, 200);
1343
+ return () => {};
1344
+ })),
1345
+ showAppsInTossAdMob: withIsSupported(observe("GoogleAdMob.showAppsInTossAdMob", "faithful", (args) => {
1346
+ if (!aitState.state.ads.isLoaded) {
1347
+ args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
1348
+ return () => {};
1349
+ }
1350
+ setTimeout(() => args.onEvent({ type: "requested" }), 50);
1351
+ setTimeout(() => args.onEvent({ type: "show" }), 100);
1352
+ setTimeout(() => args.onEvent({ type: "impression" }), 150);
1353
+ setTimeout(() => {
1354
+ const { rewardUnitType, rewardAmount } = aitState.state.ads;
1355
+ args.onEvent({
1356
+ type: "userEarnedReward",
1357
+ data: {
1358
+ unitType: rewardUnitType,
1359
+ unitAmount: rewardAmount
1360
+ }
1361
+ });
1362
+ }, 1e3);
1363
+ setTimeout(() => {
1364
+ args.onEvent({ type: "dismissed" });
1365
+ aitState.patch("ads", { isLoaded: false });
1366
+ }, 1500);
1367
+ return () => {};
1368
+ })),
1369
+ isAppsInTossAdMobLoaded: withIsSupported(observe("GoogleAdMob.isAppsInTossAdMobLoaded", "faithful", async (_options) => aitState.state.ads.isLoaded))
1370
+ });
1371
+ const TossAds = createMockProxy("TossAds", {
1372
+ initialize: withIsSupported(observe("TossAds.initialize", "partial", (options) => {
1373
+ if (aitState.state.ads.forceNoFill) {
1374
+ options.callbacks?.onInitializationFailed?.(/* @__PURE__ */ new Error("No fill"));
1375
+ return;
1376
+ }
1377
+ options.callbacks?.onInitialized?.();
1378
+ })),
1379
+ attach: withIsSupported(observe("TossAds.attach", "partial", (_adGroupId, target, _options) => {
1380
+ const el = typeof target === "string" ? document.querySelector(target) : target;
1381
+ if (el) {
1382
+ const placeholder = document.createElement("div");
1383
+ placeholder.style.cssText = "background:#f0f0f0;border:1px dashed #999;padding:16px;text-align:center;color:#666;font-size:14px;";
1384
+ placeholder.textContent = "[@ait-co/devtools] TossAds Placeholder";
1385
+ el.appendChild(placeholder);
1386
+ }
1387
+ })),
1388
+ attachBanner: withIsSupported(observe("TossAds.attachBanner", "faithful", (adGroupId, target, options) => {
1389
+ const el = typeof target === "string" ? document.querySelector(target) : target;
1390
+ const slotId = _nextSlotId(adGroupId);
1391
+ const placeholder = document.createElement("div");
1392
+ const theme = options?.theme ?? "auto";
1393
+ const variant = options?.variant ?? "card";
1394
+ const isDark = theme === "dark" || theme === "auto" && typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches;
1395
+ const bg = isDark ? "#1a1a1a" : "#f0f0f0";
1396
+ const textColor = isDark ? "#aaa" : "#666";
1397
+ const borderColor = isDark ? "#555" : "#999";
1398
+ const height = variant === "expanded" ? "120px" : "60px";
1399
+ placeholder.dataset.aitSlotId = slotId;
1400
+ 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;`;
1401
+ placeholder.textContent = `[@ait-co/devtools] Banner Ad (${variant})`;
1402
+ if (el) {
1403
+ el.appendChild(placeholder);
1404
+ _slotRegistry.set(slotId, placeholder);
1405
+ }
1406
+ const destroySlot = () => {
1407
+ const registered = _slotRegistry.get(slotId);
1408
+ if (registered) {
1409
+ registered.remove();
1410
+ _slotRegistry.delete(slotId);
1411
+ }
1412
+ };
1413
+ setTimeout(() => {
1414
+ if (aitState.state.ads.forceNoFill) {
1415
+ options?.callbacks?.onNoFill?.({
1416
+ slotId,
1417
+ adGroupId,
1418
+ adMetadata: {}
1419
+ });
1420
+ options?.callbacks?.onAdFailedToRender?.({
1421
+ slotId,
1422
+ adGroupId,
1423
+ adMetadata: {},
1424
+ error: {
1425
+ code: 0,
1426
+ message: "No fill"
1427
+ }
1428
+ });
1429
+ return;
1430
+ }
1431
+ const eventPayload = {
1432
+ slotId,
1433
+ adGroupId,
1434
+ adMetadata: {
1435
+ creativeId: `mock-creative-${slotId}`,
1436
+ requestId: `mock-req-${slotId}`
1437
+ }
1438
+ };
1439
+ options?.callbacks?.onAdRendered?.(eventPayload);
1440
+ options?.callbacks?.onAdImpression?.(eventPayload);
1441
+ }, 100);
1442
+ return { destroy: destroySlot };
1443
+ })),
1444
+ destroy: withIsSupported(observe("TossAds.destroy", "faithful", (slotId) => {
1445
+ const el = _slotRegistry.get(slotId);
1446
+ if (el) {
1447
+ el.remove();
1448
+ _slotRegistry.delete(slotId);
1449
+ }
1450
+ })),
1451
+ destroyAll: withIsSupported(observe("TossAds.destroyAll", "faithful", () => {
1452
+ for (const el of _slotRegistry.values()) el.remove();
1453
+ _slotRegistry.clear();
1454
+ }))
1455
+ });
1456
+ const loadFullScreenAd = withIsSupported(observe("loadFullScreenAd", "faithful", (args) => {
1457
+ setTimeout(() => {
1458
+ if (aitState.state.ads.forceNoFill) {
1459
+ args.onError(/* @__PURE__ */ new Error("No fill"));
1460
+ return;
1461
+ }
1462
+ aitState.patch("ads", { isLoaded: true });
1463
+ args.onEvent({
1464
+ type: "loaded",
1465
+ data: { adGroupId: args.options?.adGroupId }
1466
+ });
1467
+ }, 200);
1468
+ return () => {};
1469
+ }));
1470
+ const showFullScreenAd = withIsSupported(observe("showFullScreenAd", "faithful", (args) => {
1471
+ if (!aitState.state.ads.isLoaded) {
1472
+ args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
1473
+ return () => {};
1474
+ }
1475
+ setTimeout(() => args.onEvent({ type: "show" }), 100);
1476
+ setTimeout(() => args.onEvent({ type: "dismissed" }), 1500);
1477
+ return () => {};
1478
+ }));
1250
1479
  //#endregion
1251
- //#region src/mock/device/network.ts
1252
- /**
1253
- * Network Status mock (mode-aware helper)
1254
- * navigation 모듈에서 사용. circular dep 방지를 위해 device에 위치.
1255
- */
1480
+ //#region src/mock/analytics/index.ts
1256
1481
  /**
1257
- * Web mode: uses navigator.connection.effectiveType (4g/3g/2g) and navigator.onLine.
1258
- * Limitations: WIFI, 5G, WWAN cannot be detected via the Network Information API.
1259
- * Falls back to state-based value when effectiveType is unavailable.
1482
+ * Analytics mock
1260
1483
  */
1261
- function getNetworkStatusByMode() {
1262
- const mode = aitState.state.deviceModes.network;
1263
- if (mode === "mock") return null;
1264
- if (mode === "web") {
1265
- if (!navigator.onLine) return "OFFLINE";
1266
- const conn = navigator.connection;
1267
- if (conn?.effectiveType) return {
1268
- "4g": "4G",
1269
- "3g": "3G",
1270
- "2g": "2G",
1271
- "slow-2g": "2G"
1272
- }[conn.effectiveType] ?? "UNKNOWN";
1273
- return aitState.state.networkStatus;
1484
+ const Analytics = {
1485
+ screen: (params) => {
1486
+ aitState.logAnalytics({
1487
+ type: "screen",
1488
+ params: params ?? {}
1489
+ });
1490
+ return Promise.resolve();
1491
+ },
1492
+ impression: (params) => {
1493
+ aitState.logAnalytics({
1494
+ type: "impression",
1495
+ params: params ?? {}
1496
+ });
1497
+ return Promise.resolve();
1498
+ },
1499
+ click: (params) => {
1500
+ aitState.logAnalytics({
1501
+ type: "click",
1502
+ params: params ?? {}
1503
+ });
1504
+ return Promise.resolve();
1274
1505
  }
1275
- return null;
1506
+ };
1507
+ async function eventLog(params) {
1508
+ aitState.logAnalytics({
1509
+ type: params.log_type,
1510
+ params: {
1511
+ log_name: params.log_name,
1512
+ ...params.params
1513
+ }
1514
+ });
1276
1515
  }
1277
1516
  //#endregion
1278
- //#region src/mock/device/pdf.ts
1517
+ //#region src/mock/auth/index.ts
1279
1518
  /**
1280
- * Base64로 인코딩된 PDF 데이터를 네이티브 PDF 뷰어로 여는 mock.
1281
- * mock 환경에서는 즉시 `'CLOSE'`를 반환한다.
1519
+ * 인증/로그인 mock
1282
1520
  */
1283
- async function openPDFViewer(_params) {
1284
- await Promise.resolve();
1285
- return "CLOSE";
1521
+ async function appLogin() {
1522
+ return {
1523
+ authorizationCode: `mock-auth-${crypto.randomUUID()}`,
1524
+ referrer: aitState.state.environment === "toss" ? "DEFAULT" : "SANDBOX"
1525
+ };
1526
+ }
1527
+ async function getIsTossLoginIntegratedService() {
1528
+ return aitState.state.auth.isTossLoginIntegrated;
1529
+ }
1530
+ async function getUserKeyForGame() {
1531
+ if (!aitState.state.auth.userKeyHash) return void 0;
1532
+ return {
1533
+ hash: aitState.state.auth.userKeyHash,
1534
+ type: "HASH"
1535
+ };
1536
+ }
1537
+ async function getAnonymousKey() {
1538
+ if (!aitState.state.auth.anonymousKeyHash) return void 0;
1539
+ return {
1540
+ hash: aitState.state.auth.anonymousKeyHash,
1541
+ type: "HASH"
1542
+ };
1543
+ }
1544
+ async function appsInTossSignTossCert(_params) {
1545
+ console.log("[@ait-co/devtools] appsInTossSignTossCert called (no-op in mock)");
1286
1546
  }
1287
- //#endregion
1288
- //#region src/mock/device/storage.ts
1289
- /**
1290
- * Storage mock
1291
- * localStorage에 `__ait_storage:` prefix로 저장하여 앱 자체 localStorage와 분리
1292
- */
1293
- const Storage = createMockProxy("Storage", {
1294
- getItem: async (key) => {
1295
- return localStorage.getItem(`__ait_storage:${key}`);
1296
- },
1297
- setItem: async (key, value) => {
1298
- localStorage.setItem(`__ait_storage:${key}`, value);
1299
- },
1300
- removeItem: async (key) => {
1301
- localStorage.removeItem(`__ait_storage:${key}`);
1302
- },
1303
- clearItems: async () => {
1304
- const keys = Object.keys(localStorage).filter((k) => k.startsWith("__ait_storage:"));
1305
- for (const k of keys) localStorage.removeItem(k);
1306
- }
1307
- });
1308
1547
  //#endregion
1309
1548
  //#region src/mock/game/index.ts
1310
1549
  /**
@@ -1474,145 +1713,6 @@ const requestTossPayPaysBilling = Object.assign(async function requestTossPayPay
1474
1713
  };
1475
1714
  }, { isSupported: () => true });
1476
1715
  //#endregion
1477
- //#region src/mock/navigation/index.ts
1478
- /**
1479
- * 화면/네비게이션/이벤트 mock
1480
- */
1481
- async function closeView() {
1482
- console.log("[@ait-co/devtools] closeView called");
1483
- window.history.back();
1484
- }
1485
- async function openURL(url) {
1486
- console.log("[@ait-co/devtools] openURL:", url);
1487
- window.open(url, "_blank");
1488
- }
1489
- async function share(message) {
1490
- if (navigator.share) {
1491
- await navigator.share({ text: message.message });
1492
- return;
1493
- }
1494
- console.log("[@ait-co/devtools] share:", message.message);
1495
- }
1496
- async function getTossShareLink(path, _ogImageUrl) {
1497
- return `https://toss.im/share/mock${path}`;
1498
- }
1499
- async function setIosSwipeGestureEnabled(options) {
1500
- console.log("[@ait-co/devtools] setIosSwipeGestureEnabled:", options.isEnabled);
1501
- aitState.patch("navigation", { iosSwipeGestureEnabled: options.isEnabled });
1502
- }
1503
- async function setDeviceOrientation(options) {
1504
- const current = aitState.state.viewport.orientation;
1505
- if (current === "auto") {
1506
- console.log("[@ait-co/devtools] setDeviceOrientation:", options.type);
1507
- aitState.patch("viewport", { appOrientation: options.type });
1508
- return;
1509
- }
1510
- console.warn(`[@ait-co/devtools] setDeviceOrientation(${options.type}) ignored — Panel is forcing "${current}". Change the Viewport tab's orientation to "auto" to let the app control rotation.`);
1511
- }
1512
- const setScreenAwakeMode = observe("setScreenAwakeMode", "inert", async (options) => {
1513
- console.log("[@ait-co/devtools] setScreenAwakeMode:", options.enabled);
1514
- return { enabled: options.enabled };
1515
- });
1516
- const setSecureScreen = observe("setSecureScreen", "inert", async (options) => {
1517
- console.log("[@ait-co/devtools] setSecureScreen:", options.enabled);
1518
- return { enabled: options.enabled };
1519
- });
1520
- const requestReview = observe("requestReview", "inert", async () => {
1521
- console.log("[@ait-co/devtools] requestReview called");
1522
- });
1523
- requestReview.isSupported = () => true;
1524
- function getPlatformOS() {
1525
- return aitState.state.platform;
1526
- }
1527
- function getOperationalEnvironment() {
1528
- return aitState.state.environment;
1529
- }
1530
- function getTossAppVersion() {
1531
- return aitState.state.appVersion;
1532
- }
1533
- function isMinVersionSupported(minVersions) {
1534
- const required = aitState.state.platform === "ios" ? minVersions.ios : minVersions.android;
1535
- if (required === "always") return true;
1536
- if (required === "never") return false;
1537
- const current = aitState.state.appVersion.split(".").map(Number);
1538
- const min = required.split(".").map(Number);
1539
- for (let i = 0; i < 3; i++) {
1540
- if ((current[i] ?? 0) > (min[i] ?? 0)) return true;
1541
- if ((current[i] ?? 0) < (min[i] ?? 0)) return false;
1542
- }
1543
- return true;
1544
- }
1545
- function getSchemeUri() {
1546
- return aitState.state.schemeUri || window.location.pathname;
1547
- }
1548
- function getLocale() {
1549
- return aitState.state.locale;
1550
- }
1551
- function getDeviceId() {
1552
- return aitState.state.deviceId;
1553
- }
1554
- function getGroupId() {
1555
- return aitState.state.groupId;
1556
- }
1557
- async function getNetworkStatus() {
1558
- const modeResult = getNetworkStatusByMode();
1559
- if (modeResult) return modeResult;
1560
- return aitState.state.networkStatus;
1561
- }
1562
- async function getServerTime() {
1563
- return Date.now();
1564
- }
1565
- getServerTime.isSupported = () => true;
1566
- const graniteEvent = { addEventListener(event, { onEvent, onError }) {
1567
- const handler = () => {
1568
- try {
1569
- onEvent();
1570
- } catch (e) {
1571
- onError?.(e instanceof Error ? e : new Error(String(e)));
1572
- }
1573
- };
1574
- window.addEventListener(`__ait:${event}`, handler);
1575
- return () => window.removeEventListener(`__ait:${event}`, handler);
1576
- } };
1577
- const appsInTossEvent = { addEventListener(_event, _handlers) {
1578
- return () => {};
1579
- } };
1580
- const tdsEvent = { addEventListener(event, { onEvent }) {
1581
- const handler = (e) => {
1582
- const detail = e.detail;
1583
- onEvent(detail);
1584
- };
1585
- window.addEventListener(`__ait:${event}`, handler);
1586
- return () => window.removeEventListener(`__ait:${event}`, handler);
1587
- } };
1588
- /**
1589
- * @deprecated web-framework 3.0 에서 제거됨. 2.x 소비자 back-compat용으로 유지.
1590
- */
1591
- function onVisibilityChangedByTransparentServiceWeb(eventParams) {
1592
- const handler = () => eventParams.onEvent(!document.hidden);
1593
- document.addEventListener("visibilitychange", handler);
1594
- return () => document.removeEventListener("visibilitychange", handler);
1595
- }
1596
- const env = { getDeploymentId: () => aitState.state.deploymentId };
1597
- function getAppsInTossGlobals() {
1598
- return {
1599
- deploymentId: aitState.state.deploymentId,
1600
- brandDisplayName: aitState.state.brand.displayName,
1601
- brandIcon: aitState.state.brand.icon,
1602
- brandPrimaryColor: aitState.state.brand.primaryColor
1603
- };
1604
- }
1605
- const SafeAreaInsets = {
1606
- get: () => ({ ...aitState.state.safeAreaInsets }),
1607
- subscribe: ({ onEvent }) => {
1608
- return aitState.subscribe(() => onEvent({ ...aitState.state.safeAreaInsets }));
1609
- }
1610
- };
1611
- /** @deprecated */
1612
- function getSafeAreaInsets() {
1613
- return aitState.state.safeAreaInsets.top;
1614
- }
1615
- //#endregion
1616
1716
  //#region src/mock/notification.ts
1617
1717
  /**
1618
1718
  * 알림 동의 mock
@@ -1935,8 +2035,8 @@ function captureCurrentState(snapshot) {
1935
2035
  * @apps-in-toss/web-framework의 모든 export를 mock으로 대체한다.
1936
2036
  * 번들러 alias로 원본 대신 이 모듈이 resolve된다.
1937
2037
  */
1938
- installSafeAreaInsetsBridge();
2038
+ installBridges();
1939
2039
  //#endregion
1940
- export { Accuracy, Analytics, FetchAlbumPhotosPermissionError, FetchContactsPermissionError, GetClipboardTextPermissionError, GetCurrentLocationPermissionError, GoogleAdMob, IAP, OpenCameraPermissionError, PermissionError, SAFE_AREA_INSETS_MESSAGE_TYPE, SafeAreaInsets, SetClipboardTextPermissionError, StartUpdateLocationPermissionError, Storage, TossAds, aitState, appLogin, applyForwardedSafeAreaInsets, applyPreset, appsInTossEvent, appsInTossSignTossCert, builtInPresets, captureCurrentState, checkoutPayment, closeView, contactsViral, deleteUserPreset, env, eventLog, fetchAlbumItems, fetchAlbumPhotos, fetchContacts, generateHapticFeedback, getAnonymousKey, getAppsInTossGlobals, getClipboardText, getCurrentLocation, getDefaultPlaceholderImages, getDeviceId, getGameCenterGameProfile, getGroupId, getIsTossLoginIntegratedService, getLocale, getNetworkStatus, getOperationalEnvironment, getPermission, getPlatformOS, getSafeAreaInsets, getSchemeUri, getServerTime, getTossAppVersion, getTossShareLink, getUserKeyForGame, graniteEvent, grantPromotionReward, grantPromotionRewardForGame, installSafeAreaInsetsBridge, isMinVersionSupported, listUserPresets, loadFullScreenAd, matchesPreset, onVisibilityChangedByTransparentServiceWeb, openCamera, openGameCenterLeaderboard, openPDFViewer, openPermissionDialog, openURL, parseSafeAreaInsetsMessage, partner, requestNotificationAgreement, requestPermission, requestReview, requestTossPayPaysBilling, saveBase64Data, saveUserPreset, setClipboardText, setDeviceOrientation, setIosSwipeGestureEnabled, setScreenAwakeMode, setSecureScreen, share, showFullScreenAd, startUpdateLocation, submitGameCenterLeaderBoardScore, tdsEvent };
2040
+ export { Accuracy, Analytics, FetchAlbumPhotosPermissionError, FetchContactsPermissionError, GetClipboardTextPermissionError, GetCurrentLocationPermissionError, GoogleAdMob, IAP, NAVIGATE_BACK_MESSAGE_TYPE, OpenCameraPermissionError, PermissionError, SAFE_AREA_INSETS_MESSAGE_TYPE, SafeAreaInsets, SetClipboardTextPermissionError, StartUpdateLocationPermissionError, Storage, TossAds, aitState, appLogin, applyForwardedSafeAreaInsets, applyPreset, appsInTossEvent, appsInTossSignTossCert, builtInPresets, captureCurrentState, checkoutPayment, closeView, contactsViral, deleteUserPreset, env, eventLog, fetchAlbumItems, fetchAlbumPhotos, fetchContacts, generateHapticFeedback, getAnonymousKey, getAppsInTossGlobals, getClipboardText, getCurrentLocation, 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, partner, requestNotificationAgreement, requestPermission, requestReview, requestTossPayPaysBilling, saveBase64Data, saveUserPreset, setClipboardText, setDeviceOrientation, setIosSwipeGestureEnabled, setScreenAwakeMode, setSecureScreen, share, showFullScreenAd, startUpdateLocation, submitGameCenterLeaderBoardScore, tdsEvent };
1941
2041
 
1942
2042
  //# sourceMappingURL=index.js.map