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