@apps-in-toss/devtools 3.0.2
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/LICENSE +28 -0
- package/README.md +1082 -0
- package/dist/mock/2x.d.ts +1275 -0
- package/dist/mock/2x.js +2610 -0
- package/dist/mock/3x.d.ts +1148 -0
- package/dist/mock/3x.js +2801 -0
- package/dist/mock/index.d.ts +1148 -0
- package/dist/mock/index.js +2801 -0
- package/dist/panel/index.d.ts +25 -0
- package/dist/panel/index.js +30630 -0
- package/dist/tunnel-BvEf1qGV.js +186 -0
- package/dist/tunnel-DtCTOUlp.cjs +187 -0
- package/dist/unplugin/index.cjs +312 -0
- package/dist/unplugin/index.d.cts +140 -0
- package/dist/unplugin/index.d.ts +140 -0
- package/dist/unplugin/index.js +301 -0
- package/dist/unplugin/tunnel.cjs +191 -0
- package/dist/unplugin/tunnel.d.cts +140 -0
- package/dist/unplugin/tunnel.d.ts +140 -0
- package/dist/unplugin/tunnel.js +186 -0
- package/package.json +100 -0
|
@@ -0,0 +1,2801 @@
|
|
|
1
|
+
//#region src/mock/throttle-registry.ts
|
|
2
|
+
/**
|
|
3
|
+
* throttle 레지스트리 (devtools#834).
|
|
4
|
+
*
|
|
5
|
+
* 메서드별 마지막 *허용* 호출 시각만 담는 leaf 모듈이다. `state.ts`가 `reset()`에서
|
|
6
|
+
* 이걸 비워야 하는데, 로직 본체(`throttle.ts`)는 거꾸로 `state.ts`를 읽는다 —
|
|
7
|
+
* 그래서 **의존이 없는 이 파일로 레지스트리를 분리해** 순환 import를 피한다.
|
|
8
|
+
*
|
|
9
|
+
* `state.ts`의 `AitStateManager`와 같은 이유로 `globalThis` 싱글턴이어야 한다 (#836):
|
|
10
|
+
* `tsdown.config.ts`가 mock/panel/unplugin entry를 각각 self-contained로 빌드하므로,
|
|
11
|
+
* 소비자가 두 entry를 동시에 import하면 이 모듈이 entry당 하나씩 복제된다. Map을
|
|
12
|
+
* 모듈 지역 변수로 두면 `aitState.reset()`이 비우는 Map(싱글턴을 먼저 생성한 번들의 것)과
|
|
13
|
+
* `throttleErrorFor`가 읽는 Map(mock 번들의 것)이 갈려, 리셋 직후 첫 호출이 낡은
|
|
14
|
+
* 타임스탬프 때문에 거부된다 — 이 파일이 막으려던 바로 그 증상이다.
|
|
15
|
+
*/
|
|
16
|
+
const SINGLETON_KEY$1 = "__aitDevtoolsThrottleRegistry__";
|
|
17
|
+
const globalRef$1 = globalThis;
|
|
18
|
+
if (!globalRef$1[SINGLETON_KEY$1]) globalRef$1[SINGLETON_KEY$1] = /* @__PURE__ */ new Map();
|
|
19
|
+
/** 메서드별 마지막 *허용* 호출 시각(ms). 페이지 안의 모든 entry가 공유한다. */
|
|
20
|
+
const lastAllowedAt = globalRef$1[SINGLETON_KEY$1];
|
|
21
|
+
/**
|
|
22
|
+
* throttle 레지스트리를 비운다. `aitState.reset()`이 호출한다 — 상태를 되돌렸는데
|
|
23
|
+
* 직전 세션의 호출 시각이 남아 있으면 첫 호출이 이유 없이 거부된다.
|
|
24
|
+
*/
|
|
25
|
+
function resetThrottleRegistry() {
|
|
26
|
+
lastAllowedAt.clear();
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/mock/state.ts
|
|
30
|
+
/** SDK 호출 로그 ring buffer 상한 */
|
|
31
|
+
const SDK_CALL_LOG_MAX = 200;
|
|
32
|
+
const DEFAULT_STATE = {
|
|
33
|
+
platform: "ios",
|
|
34
|
+
environment: "sandbox",
|
|
35
|
+
appVersion: "5.240.0",
|
|
36
|
+
locale: "ko-KR",
|
|
37
|
+
schemeUri: "/",
|
|
38
|
+
groupId: "mock-group-id",
|
|
39
|
+
deploymentId: "mock-deployment-id",
|
|
40
|
+
deviceId: "",
|
|
41
|
+
brand: {
|
|
42
|
+
displayName: "Mock App",
|
|
43
|
+
icon: "",
|
|
44
|
+
primaryColor: "#3182F6"
|
|
45
|
+
},
|
|
46
|
+
networkStatus: "WIFI",
|
|
47
|
+
navigation: { iosSwipeGestureEnabled: null },
|
|
48
|
+
permissions: {
|
|
49
|
+
clipboard: "allowed",
|
|
50
|
+
contacts: "allowed",
|
|
51
|
+
photos: "allowed",
|
|
52
|
+
geolocation: "allowed",
|
|
53
|
+
camera: "allowed",
|
|
54
|
+
microphone: "notDetermined"
|
|
55
|
+
},
|
|
56
|
+
location: {
|
|
57
|
+
coords: {
|
|
58
|
+
latitude: 37.5665,
|
|
59
|
+
longitude: 126.978,
|
|
60
|
+
altitude: 0,
|
|
61
|
+
accuracy: 10,
|
|
62
|
+
altitudeAccuracy: 0,
|
|
63
|
+
heading: 0
|
|
64
|
+
},
|
|
65
|
+
timestamp: Date.now(),
|
|
66
|
+
accessLocation: "FINE"
|
|
67
|
+
},
|
|
68
|
+
safeAreaInsets: {
|
|
69
|
+
top: 54,
|
|
70
|
+
bottom: 34,
|
|
71
|
+
left: 0,
|
|
72
|
+
right: 0
|
|
73
|
+
},
|
|
74
|
+
contacts: [{
|
|
75
|
+
name: "홍길동",
|
|
76
|
+
phoneNumber: "010-1234-5678"
|
|
77
|
+
}, {
|
|
78
|
+
name: "김토스",
|
|
79
|
+
phoneNumber: "010-9876-5432"
|
|
80
|
+
}],
|
|
81
|
+
iap: {
|
|
82
|
+
products: [{
|
|
83
|
+
sku: "mock-gem-100",
|
|
84
|
+
type: "CONSUMABLE",
|
|
85
|
+
displayName: "보석 100개",
|
|
86
|
+
displayAmount: "1,000원",
|
|
87
|
+
iconUrl: "",
|
|
88
|
+
description: "게임에서 사용할 수 있는 보석 100개"
|
|
89
|
+
}],
|
|
90
|
+
nextResult: "success",
|
|
91
|
+
pendingOrders: [],
|
|
92
|
+
completedOrders: []
|
|
93
|
+
},
|
|
94
|
+
payment: {
|
|
95
|
+
nextResult: "success",
|
|
96
|
+
failReason: ""
|
|
97
|
+
},
|
|
98
|
+
auth: {
|
|
99
|
+
isLoggedIn: true,
|
|
100
|
+
isTossLoginIntegrated: true,
|
|
101
|
+
userKeyHash: "mock-user-hash-abc123",
|
|
102
|
+
anonymousKeyHash: "mock-anon-hash-xyz789",
|
|
103
|
+
consentedUserData: { USER_NAME: "mock-user-name" },
|
|
104
|
+
declaredAgeRange: {
|
|
105
|
+
status: "NOT_AVAILABLE",
|
|
106
|
+
activeParentalControls: []
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
notification: { nextResult: "newAgreement" },
|
|
110
|
+
ads: {
|
|
111
|
+
isLoaded: false,
|
|
112
|
+
nextEvent: "loaded",
|
|
113
|
+
forceNoFill: false,
|
|
114
|
+
lastEvent: null,
|
|
115
|
+
rewardUnitType: "coins",
|
|
116
|
+
rewardAmount: 10
|
|
117
|
+
},
|
|
118
|
+
game: {
|
|
119
|
+
profile: {
|
|
120
|
+
nickname: "MockPlayer",
|
|
121
|
+
profileImageUri: ""
|
|
122
|
+
},
|
|
123
|
+
leaderboardScores: []
|
|
124
|
+
},
|
|
125
|
+
analyticsLog: [],
|
|
126
|
+
sdkCallLog: [],
|
|
127
|
+
deviceModes: {
|
|
128
|
+
camera: "mock",
|
|
129
|
+
photos: "mock",
|
|
130
|
+
location: "mock",
|
|
131
|
+
network: "mock",
|
|
132
|
+
clipboard: "mock"
|
|
133
|
+
},
|
|
134
|
+
mockData: {
|
|
135
|
+
images: [],
|
|
136
|
+
clipboardText: ""
|
|
137
|
+
},
|
|
138
|
+
panelEditable: true,
|
|
139
|
+
viewport: {
|
|
140
|
+
preset: "none",
|
|
141
|
+
orientation: "auto",
|
|
142
|
+
appOrientation: null,
|
|
143
|
+
customWidth: 402,
|
|
144
|
+
customHeight: 874,
|
|
145
|
+
frame: false,
|
|
146
|
+
aitNavBar: true,
|
|
147
|
+
aitNavBarType: "partner"
|
|
148
|
+
},
|
|
149
|
+
failureModes: { sdkLine: "2.x" }
|
|
150
|
+
};
|
|
151
|
+
function generateDeviceId() {
|
|
152
|
+
const stored = localStorage.getItem("__ait_device_id");
|
|
153
|
+
if (stored) return stored;
|
|
154
|
+
const id = crypto.randomUUID();
|
|
155
|
+
localStorage.setItem("__ait_device_id", id);
|
|
156
|
+
return id;
|
|
157
|
+
}
|
|
158
|
+
var AitStateManager = class {
|
|
159
|
+
_state;
|
|
160
|
+
_listeners = /* @__PURE__ */ new Set();
|
|
161
|
+
_inTransaction = false;
|
|
162
|
+
constructor() {
|
|
163
|
+
this._state = structuredClone(DEFAULT_STATE);
|
|
164
|
+
try {
|
|
165
|
+
this._state.deviceId = generateDeviceId();
|
|
166
|
+
} catch {
|
|
167
|
+
this._state.deviceId = `mock-device-${Math.random().toString(36).slice(2)}`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
get state() {
|
|
171
|
+
return this._state;
|
|
172
|
+
}
|
|
173
|
+
update(partial) {
|
|
174
|
+
this._state = {
|
|
175
|
+
...this._state,
|
|
176
|
+
...partial
|
|
177
|
+
};
|
|
178
|
+
this._notify();
|
|
179
|
+
}
|
|
180
|
+
/** 중첩 객체 업데이트용 */
|
|
181
|
+
patch(key, partial) {
|
|
182
|
+
const current = this._state[key];
|
|
183
|
+
if (typeof current === "object" && current !== null && !Array.isArray(current)) this._state = {
|
|
184
|
+
...this._state,
|
|
185
|
+
[key]: {
|
|
186
|
+
...current,
|
|
187
|
+
...partial
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
else this._state = {
|
|
191
|
+
...this._state,
|
|
192
|
+
[key]: partial
|
|
193
|
+
};
|
|
194
|
+
this._notify();
|
|
195
|
+
}
|
|
196
|
+
subscribe(listener) {
|
|
197
|
+
this._listeners.add(listener);
|
|
198
|
+
return () => this._listeners.delete(listener);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* 한 묶음의 update/patch 호출을 묶어 listener notify 1회로 만든다.
|
|
202
|
+
* preset 적용처럼 여러 슬라이스를 동시에 바꿀 때 panel re-render 폭주를
|
|
203
|
+
* 방지한다. 중첩 호출은 outermost transaction이 끝날 때 한 번만 notify
|
|
204
|
+
* (inner도 throw해도 outer finally가 flag를 복구한다).
|
|
205
|
+
*
|
|
206
|
+
* Rollback은 없다 — `fn`이 throw해도 그때까지의 state 변경은 유지된다.
|
|
207
|
+
* 구독자가 partial state를 영원히 못 보는 사고를 막기 위해, throw 여부와
|
|
208
|
+
* 무관하게 항상 한 번 notify한 뒤 throw를 propagate한다. DB transaction이
|
|
209
|
+
* 아니라 "여러 mutation을 한 notify로 묶는 batch"라고 생각하면 된다.
|
|
210
|
+
*
|
|
211
|
+
* Listener는 throw해선 안 된다 — finally 안의 `_notify()`가 throw하면 원래
|
|
212
|
+
* `fn`의 throw를 덮어버린다. 우리 구독자는 panel re-render뿐이라 실제
|
|
213
|
+
* 발생 사례는 없지만, 외부에서 listener를 등록할 때 주의.
|
|
214
|
+
*/
|
|
215
|
+
transaction(fn) {
|
|
216
|
+
if (this._inTransaction) {
|
|
217
|
+
fn();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
this._inTransaction = true;
|
|
221
|
+
try {
|
|
222
|
+
fn();
|
|
223
|
+
} finally {
|
|
224
|
+
this._inTransaction = false;
|
|
225
|
+
this._notify();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** 분석 로그 추가 */
|
|
229
|
+
logAnalytics(entry) {
|
|
230
|
+
this._state = {
|
|
231
|
+
...this._state,
|
|
232
|
+
analyticsLog: [...this._state.analyticsLog, {
|
|
233
|
+
...entry,
|
|
234
|
+
timestamp: Date.now()
|
|
235
|
+
}]
|
|
236
|
+
};
|
|
237
|
+
this._notify();
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* SDK 호출 로그 추가 (ring buffer, 상한 SDK_CALL_LOG_MAX).
|
|
241
|
+
* `observe()`가 호출하고, proxy의 KNOWN_UNIMPLEMENTED 경로도 직접 호출한다.
|
|
242
|
+
*/
|
|
243
|
+
logSdkCall(entry) {
|
|
244
|
+
const log = this._state.sdkCallLog;
|
|
245
|
+
const next = log.length >= SDK_CALL_LOG_MAX ? log.slice(1 - SDK_CALL_LOG_MAX) : log;
|
|
246
|
+
this._state = {
|
|
247
|
+
...this._state,
|
|
248
|
+
sdkCallLog: [...next, entry]
|
|
249
|
+
};
|
|
250
|
+
this._notify();
|
|
251
|
+
}
|
|
252
|
+
/** 이벤트 트리거 (backEvent, homeEvent 등) */
|
|
253
|
+
trigger(event) {
|
|
254
|
+
window.dispatchEvent(new CustomEvent(`__ait:${event}`));
|
|
255
|
+
}
|
|
256
|
+
reset() {
|
|
257
|
+
const deviceId = this._state.deviceId;
|
|
258
|
+
this._state = {
|
|
259
|
+
...structuredClone(DEFAULT_STATE),
|
|
260
|
+
deviceId
|
|
261
|
+
};
|
|
262
|
+
resetThrottleRegistry();
|
|
263
|
+
this._notify();
|
|
264
|
+
}
|
|
265
|
+
_notify() {
|
|
266
|
+
if (this._inTransaction) return;
|
|
267
|
+
for (const listener of this._listeners) listener();
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
const SINGLETON_KEY = "__aitDevtoolsStateSingleton__";
|
|
271
|
+
const globalRef = globalThis;
|
|
272
|
+
if (!globalRef[SINGLETON_KEY]) globalRef[SINGLETON_KEY] = new AitStateManager();
|
|
273
|
+
const aitState = globalRef[SINGLETON_KEY];
|
|
274
|
+
if (typeof window !== "undefined") window.__ait = aitState;
|
|
275
|
+
//#endregion
|
|
276
|
+
//#region src/mock/device/_helpers.ts
|
|
277
|
+
/**
|
|
278
|
+
* 디바이스 모듈 내부 공유 헬퍼
|
|
279
|
+
*/
|
|
280
|
+
function generatePlaceholderImage(width, height, text, color) {
|
|
281
|
+
const canvas = document.createElement("canvas");
|
|
282
|
+
canvas.width = width;
|
|
283
|
+
canvas.height = height;
|
|
284
|
+
const ctx = canvas.getContext("2d");
|
|
285
|
+
if (!ctx) {
|
|
286
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect fill="${color}" width="${width}" height="${height}"/><text x="50%" y="50%" fill="white" font-size="16" text-anchor="middle" dominant-baseline="middle">${text}</text></svg>`;
|
|
287
|
+
return `data:image/svg+xml;base64,${btoa(svg)}`;
|
|
288
|
+
}
|
|
289
|
+
ctx.fillStyle = color;
|
|
290
|
+
ctx.fillRect(0, 0, width, height);
|
|
291
|
+
ctx.fillStyle = "white";
|
|
292
|
+
ctx.font = "16px sans-serif";
|
|
293
|
+
ctx.textAlign = "center";
|
|
294
|
+
ctx.textBaseline = "middle";
|
|
295
|
+
ctx.fillText(text, width / 2, height / 2);
|
|
296
|
+
return canvas.toDataURL("image/png");
|
|
297
|
+
}
|
|
298
|
+
const DEFAULT_PLACEHOLDERS = [
|
|
299
|
+
{
|
|
300
|
+
text: "Mock Photo 1",
|
|
301
|
+
color: "#3182F6"
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
text: "Mock Photo 2",
|
|
305
|
+
color: "#27ae60"
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
text: "Mock Photo 3",
|
|
309
|
+
color: "#e67e22"
|
|
310
|
+
}
|
|
311
|
+
];
|
|
312
|
+
let cachedPlaceholders = null;
|
|
313
|
+
function getDefaultPlaceholderImages() {
|
|
314
|
+
if (!cachedPlaceholders) cachedPlaceholders = DEFAULT_PLACEHOLDERS.map((p) => generatePlaceholderImage(320, 240, p.text, p.color));
|
|
315
|
+
return [...cachedPlaceholders];
|
|
316
|
+
}
|
|
317
|
+
/** @internal device 모듈 내부 전용 */
|
|
318
|
+
function getMockImages() {
|
|
319
|
+
const images = aitState.state.mockData.images;
|
|
320
|
+
if (images.length > 0) return images;
|
|
321
|
+
return getDefaultPlaceholderImages();
|
|
322
|
+
}
|
|
323
|
+
const PROMPT_TIMEOUT_MS = 3e4;
|
|
324
|
+
/** @internal device 모듈 내부 전용 */
|
|
325
|
+
function waitForPromptResponse(type) {
|
|
326
|
+
return new Promise((resolve, reject) => {
|
|
327
|
+
const eventName = `__ait:prompt-response:${type}`;
|
|
328
|
+
const cancelName = "__ait:prompt-cancel";
|
|
329
|
+
function cleanup() {
|
|
330
|
+
clearTimeout(timer);
|
|
331
|
+
window.removeEventListener(eventName, handler);
|
|
332
|
+
window.removeEventListener(cancelName, cancelHandler);
|
|
333
|
+
}
|
|
334
|
+
const timer = setTimeout(() => {
|
|
335
|
+
cleanup();
|
|
336
|
+
const hint = !!document.querySelector(".ait-panel") ? "Please provide input via the DevTools panel." : "Is @apps-in-toss/devtools/panel imported?";
|
|
337
|
+
reject(/* @__PURE__ */ new Error(`[@apps-in-toss/devtools] Prompt timeout for "${type}" after ${PROMPT_TIMEOUT_MS / 1e3}s. ${hint}`));
|
|
338
|
+
}, PROMPT_TIMEOUT_MS);
|
|
339
|
+
const handler = (e) => {
|
|
340
|
+
cleanup();
|
|
341
|
+
resolve(e.detail);
|
|
342
|
+
};
|
|
343
|
+
const cancelHandler = () => {
|
|
344
|
+
cleanup();
|
|
345
|
+
reject(/* @__PURE__ */ new Error(`[@apps-in-toss/devtools] Prompt cancelled for "${type}"`));
|
|
346
|
+
};
|
|
347
|
+
window.addEventListener(eventName, handler);
|
|
348
|
+
window.addEventListener(cancelName, cancelHandler);
|
|
349
|
+
window.dispatchEvent(new CustomEvent("__ait:prompt-request", { detail: { type } }));
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
//#endregion
|
|
353
|
+
//#region src/mock/native-error.ts
|
|
354
|
+
/**
|
|
355
|
+
* 실패-모드 다이얼 — 네이티브 에러 envelope 조립 (devtools#770).
|
|
356
|
+
*
|
|
357
|
+
* env1(mock)이 env3(실기기)의 프로비저닝-의존 reject 계약을 재현할 수 있게 하는
|
|
358
|
+
* 공용 헬퍼. `aitState.failureModes`(state.ts)에 per-API 코드를 설정하면, 각
|
|
359
|
+
* mock 도메인(auth/ads 등)이 이 모듈의 {@link buildNativeError}로 실기기 캡처와
|
|
360
|
+
* 필드 단위로 일치하는 에러 객체를 만들어 reject/onError한다.
|
|
361
|
+
*
|
|
362
|
+
* 코드 인벤토리와 envelope shape의 정본은 sdk-example#284 2.x↔3.0 diff
|
|
363
|
+
* 매트릭스(2026-07-10 iOS run10 캡처 + 이후 manual 패스)다:
|
|
364
|
+
*
|
|
365
|
+
* - 2.x 라인: 네이티브 브리지가 `{ name, code, userInfo, moduleName, __isError }`
|
|
366
|
+
* envelope을 얹은 `Error` 인스턴스로 reject한다. `errorKeys` 관측이 이 5개
|
|
367
|
+
* 필드로 고정.
|
|
368
|
+
* - 3.x 라인: 같은 실패가 "맨 Error"(위 envelope 필드 없음)로 평탄화된다 —
|
|
369
|
+
* sdk-example#284 "패턴 ① 오류 envelope 평탄화" 표.
|
|
370
|
+
*
|
|
371
|
+
* `@ait-co/debugger`의 `packages/debugger/src/test-runner/bridge-stub.ts`(분리 전
|
|
372
|
+
* 이 repo의 `src/test-runner/bridge-stub.ts`)에 있는
|
|
373
|
+
* `NativeBridgeErrorShape`/`makeNativeError`가
|
|
374
|
+
* 같은 shape을 이미 CI 러너 쪽에서 재현하고 있다(devtools#740) — 이 모듈은 그
|
|
375
|
+
* shape을 mock 소비자 표면(`src/mock/**`)에 대해 재사용 가능한 형태로 옮긴 것이다.
|
|
376
|
+
* 두 모듈은 레이어가 다르다: test-runner 쪽은 env3 blocking-call 인터셉터(빌드에
|
|
377
|
+
* 안 실림, CI 전용), 이쪽은 `@apps-in-toss/devtools` 소비자가 `pnpm dev`에서 실제로
|
|
378
|
+
* import하는 mock 런타임이다 — 서로 import하지 않는다(devtools#740이 test-runner
|
|
379
|
+
* 전용 모듈임을 명시).
|
|
380
|
+
*/
|
|
381
|
+
/** 코드별 기본 message/moduleName — 실기기 캡처 관측값. */
|
|
382
|
+
const CODE_META = {
|
|
383
|
+
APP_LOGIN: {
|
|
384
|
+
message: "Login failed",
|
|
385
|
+
moduleName: "RNTossLogin"
|
|
386
|
+
},
|
|
387
|
+
PLACEMENT_ID_FETCH_FAILED: {
|
|
388
|
+
message: "Failed to fetch placement id",
|
|
389
|
+
moduleName: "RNAdMob"
|
|
390
|
+
},
|
|
391
|
+
EXECUTION_ERROR: {
|
|
392
|
+
message: "Execution error",
|
|
393
|
+
moduleName: "RNFullScreenAd"
|
|
394
|
+
},
|
|
395
|
+
NO_PERMISSION: {
|
|
396
|
+
message: "No permission",
|
|
397
|
+
moduleName: "RNPermissions"
|
|
398
|
+
},
|
|
399
|
+
INVALID_REQUEST: {
|
|
400
|
+
message: "Invalid request",
|
|
401
|
+
moduleName: "RNPermissions"
|
|
402
|
+
},
|
|
403
|
+
INVALID_DATA: {
|
|
404
|
+
message: "Invalid data",
|
|
405
|
+
moduleName: "RNFileSystem"
|
|
406
|
+
},
|
|
407
|
+
FAILED_TO_GET_LOADED_AD: {
|
|
408
|
+
message: "Failed to get loaded ad",
|
|
409
|
+
moduleName: "RNAdMob"
|
|
410
|
+
},
|
|
411
|
+
APP_BRIDGE_THROTTLED: {
|
|
412
|
+
message: "Too many app bridge calls from this method.",
|
|
413
|
+
moduleName: "RNBridge"
|
|
414
|
+
},
|
|
415
|
+
"1006": {
|
|
416
|
+
message: "광고가 로드 중이거나 준비되지 않았습니다",
|
|
417
|
+
moduleName: "RNFullScreenAd"
|
|
418
|
+
},
|
|
419
|
+
"4000": {
|
|
420
|
+
message: "Notification agreement failed",
|
|
421
|
+
moduleName: "RNNotification"
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
/**
|
|
425
|
+
* `aitState.failureModes.sdkLine`이 가리키는 라인으로 네이티브 실패를 조립한다.
|
|
426
|
+
*
|
|
427
|
+
* - `'2.x'`(기본): {@link NativeErrorEnvelope} 필드가 실린 `Error` — 실기기 2.x
|
|
428
|
+
* 캡처(`{name, code, userInfo, moduleName, __isError}`)와 필드 단위 일치.
|
|
429
|
+
* - `'3.x'`: 같은 실패가 "맨 Error"로 평탄화된 것을 재현 — envelope 필드 없이
|
|
430
|
+
* message만 실린 순수 `Error` 인스턴스.
|
|
431
|
+
*
|
|
432
|
+
* 호출부는 두 종류다:
|
|
433
|
+
*
|
|
434
|
+
* - **다이얼 게이트 뒤**(대부분): 프로비저닝처럼 환경에 따라 갈리는 실패는
|
|
435
|
+
* 다이얼이 설정된 경우에만 재현한다 — 미설정 시 기존 동작 무변화
|
|
436
|
+
* (zero behavior change). 호출 전에 다이얼 값을 명시적으로 확인한다.
|
|
437
|
+
* - **무조건**(입력 검증): 빈 `data`나 알 수 없는 haptic type처럼 환경과 무관하게
|
|
438
|
+
* 실기기가 **항상** 거부하는 입력은 다이얼 없이 바로 던진다. 이건 "가끔 일어나는
|
|
439
|
+
* 실패"의 시뮬레이션이 아니라 결정적 계약이므로 opt-in 대상이 아니다.
|
|
440
|
+
*/
|
|
441
|
+
function buildNativeError(code) {
|
|
442
|
+
const meta = CODE_META[code];
|
|
443
|
+
if (aitState.state.failureModes.sdkLine === "3.x") return new Error(meta.message);
|
|
444
|
+
const err = new Error(meta.message);
|
|
445
|
+
err.name = "Error";
|
|
446
|
+
err.code = code;
|
|
447
|
+
err.userInfo = {};
|
|
448
|
+
err.moduleName = meta.moduleName;
|
|
449
|
+
err.__isError = true;
|
|
450
|
+
return err;
|
|
451
|
+
}
|
|
452
|
+
//#endregion
|
|
453
|
+
//#region src/mock/permissions.ts
|
|
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
|
+
};
|
|
500
|
+
/**
|
|
501
|
+
* startUpdateLocation 권한 에러.
|
|
502
|
+
* web-framework 3.0에서 GetCurrentLocationPermissionError의 alias.
|
|
503
|
+
*/
|
|
504
|
+
const StartUpdateLocationPermissionError = GetCurrentLocationPermissionError;
|
|
505
|
+
const permissionErrorMap = {
|
|
506
|
+
openCamera: OpenCameraPermissionError,
|
|
507
|
+
fetchAlbumPhotos: FetchAlbumPhotosPermissionError,
|
|
508
|
+
fetchAlbumItems: FetchAlbumPhotosPermissionError,
|
|
509
|
+
fetchContacts: FetchContactsPermissionError,
|
|
510
|
+
getCurrentLocation: GetCurrentLocationPermissionError,
|
|
511
|
+
getClipboardText: GetClipboardTextPermissionError,
|
|
512
|
+
setClipboardText: SetClipboardTextPermissionError
|
|
513
|
+
};
|
|
514
|
+
async function getPermission(permission) {
|
|
515
|
+
const failureCode = permissionGateCode(permission);
|
|
516
|
+
if (failureCode) throw buildNativeError(failureCode);
|
|
517
|
+
return aitState.state.permissions[permission.name];
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* 선언 게이트에 걸리는 이름·access 조합이면 다이얼에 등록된 native errorCode를
|
|
521
|
+
* 돌려준다. 세 권한 API(`getPermission`/`requestPermission`/`openPermissionDialog`)가
|
|
522
|
+
* **걸리는 조건**은 공유하지만 **떨어지는 코드**는 공유하지 않는다 — 아래 참조.
|
|
523
|
+
*/
|
|
524
|
+
function permissionGateCode(permission) {
|
|
525
|
+
if (permission.access === "access") return;
|
|
526
|
+
return aitState.state.failureModes.getPermission?.[permission.name];
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* `openPermissionDialog`가 선언 게이트에 걸렸을 때의 코드.
|
|
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
|
|
538
|
+
*
|
|
539
|
+
* 즉 **걸리는 조건은 셋이 같고(access 축 모델 그대로) 코드만 갈린다.** 처음엔
|
|
540
|
+
* `requestPermission`이 `openPermissionDialog`에 위임하니 게이트도 위임하면
|
|
541
|
+
* 된다고 보고 한 지점에만 배선했는데, 그 모델은 코드 층위에서 실측과 어긋났다
|
|
542
|
+
* (env1 NO_PERMISSION ↔ env3 INVALID_REQUEST). 그래서 `requestPermission`은
|
|
543
|
+
* 위임 **전에** 자기 게이트를 먼저 타고, 다이얼로그를 여는 호출만 이 코드를 쓴다.
|
|
544
|
+
*
|
|
545
|
+
* 관측이 geolocation/read 한 조합뿐이라 이름·access별 분기는 두지 않는다 —
|
|
546
|
+
* 근거 없는 확장은 #783에서 이름 단위 맵을 택한 원칙에 어긋난다.
|
|
547
|
+
*/
|
|
548
|
+
const OPEN_DIALOG_GATE_CODE = "INVALID_REQUEST";
|
|
549
|
+
async function openPermissionDialog(permission) {
|
|
550
|
+
if (permissionGateCode(permission)) throw buildNativeError(OPEN_DIALOG_GATE_CODE);
|
|
551
|
+
if (aitState.state.permissions[permission.name] === "allowed") return "allowed";
|
|
552
|
+
aitState.patch("permissions", { [permission.name]: "allowed" });
|
|
553
|
+
return "allowed";
|
|
554
|
+
}
|
|
555
|
+
async function requestPermission(permission) {
|
|
556
|
+
const failureCode = permissionGateCode(permission);
|
|
557
|
+
if (failureCode) throw buildNativeError(failureCode);
|
|
558
|
+
return openPermissionDialog(permission);
|
|
559
|
+
}
|
|
560
|
+
/** 권한이 필요한 함수에 .getPermission(), .openPermissionDialog()를 부착 */
|
|
561
|
+
function withPermission(fn, permissionName) {
|
|
562
|
+
const enhanced = fn;
|
|
563
|
+
enhanced.getPermission = () => getPermission({
|
|
564
|
+
name: permissionName,
|
|
565
|
+
access: "access"
|
|
566
|
+
});
|
|
567
|
+
enhanced.openPermissionDialog = () => openPermissionDialog({
|
|
568
|
+
name: permissionName,
|
|
569
|
+
access: "access"
|
|
570
|
+
});
|
|
571
|
+
return enhanced;
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* 권한 체크 후 denied면 per-API *PermissionError 서브클래스를 throw한다.
|
|
575
|
+
* 실 3.0 SDK 동작과 일치 — `instanceof PermissionError` 분기가 mock에서도 동작한다 (#372).
|
|
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
|
|
586
|
+
/**
|
|
587
|
+
* Camera & Album Photos & Album Items mock
|
|
588
|
+
* mock/web/prompt 모드 지원
|
|
589
|
+
*/
|
|
590
|
+
async function openCameraMock() {
|
|
591
|
+
const images = getMockImages();
|
|
592
|
+
return {
|
|
593
|
+
id: crypto.randomUUID(),
|
|
594
|
+
dataUri: images[0]
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
async function openCameraWeb() {
|
|
598
|
+
return new Promise((resolve, reject) => {
|
|
599
|
+
const input = document.createElement("input");
|
|
600
|
+
input.type = "file";
|
|
601
|
+
input.accept = "image/*";
|
|
602
|
+
input.capture = "environment";
|
|
603
|
+
let settled = false;
|
|
604
|
+
input.onchange = () => {
|
|
605
|
+
settled = true;
|
|
606
|
+
const file = input.files?.[0];
|
|
607
|
+
if (!file) {
|
|
608
|
+
reject(/* @__PURE__ */ new Error("No file selected"));
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const reader = new FileReader();
|
|
612
|
+
reader.onload = () => resolve({
|
|
613
|
+
id: crypto.randomUUID(),
|
|
614
|
+
dataUri: reader.result
|
|
615
|
+
});
|
|
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
|
+
};
|
|
738
|
+
window.addEventListener("focus", onFocus);
|
|
739
|
+
input.click();
|
|
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
|
+
}));
|
|
748
|
+
}
|
|
749
|
+
const _fetchAlbumItems = async (options) => {
|
|
750
|
+
checkPermission("photos", "fetchAlbumItems");
|
|
751
|
+
const maxCount = options?.maxCount ?? 10;
|
|
752
|
+
const types = options?.types ?? ["PHOTO"];
|
|
753
|
+
const mode = aitState.state.deviceModes.photos;
|
|
754
|
+
if (mode === "web") return fetchAlbumItemsWeb(maxCount, types);
|
|
755
|
+
if (mode === "prompt") return fetchAlbumItemsPrompt(maxCount);
|
|
756
|
+
return fetchAlbumItemsMock(maxCount, types);
|
|
757
|
+
};
|
|
758
|
+
const _fetchAlbumItemsWithPermission = withPermission(_fetchAlbumItems, "photos");
|
|
759
|
+
const fetchAlbumItems = Object.assign(_fetchAlbumItemsWithPermission, { isSupported: () => true });
|
|
760
|
+
//#endregion
|
|
761
|
+
//#region src/mock/throttle.ts
|
|
762
|
+
/**
|
|
763
|
+
* THROTTLED 시뮬레이션 (devtools#834 — #770 §3 분리).
|
|
764
|
+
*
|
|
765
|
+
* 실기기 네이티브 브리지는 같은 메서드를 짧은 간격으로 연타하면
|
|
766
|
+
* `APP_BRIDGE_THROTTLED`로 거부한다. 이 모듈은 그 per-method rate limit을
|
|
767
|
+
* env1(mock)에서 **opt-in으로** 재현한다 — 다이얼(`failureModes.throttled`)이
|
|
768
|
+
* 미설정이면 아무 일도 하지 않으므로 zero behavior change다.
|
|
769
|
+
*
|
|
770
|
+
* 설계 메모 두 가지:
|
|
771
|
+
*
|
|
772
|
+
* 1. **거부된 호출은 창을 갱신하지 않는다.** 마지막 *허용된* 호출 시각만 기록하므로,
|
|
773
|
+
* `intervalMs` 안에서 계속 연타해도 창이 밀려나지 않고 최초 허용 시점 기준으로
|
|
774
|
+
* 풀린다. 네이티브 rate limit의 통상 동작이자, 연타 중 영원히 막히는(창이 계속
|
|
775
|
+
* 갱신되는) 반-패턴을 피한다.
|
|
776
|
+
* 2. **`observe()`에 걸지 않는다.** `observe()`는 `fn`을 호출하기 *전에* 감싸므로
|
|
777
|
+
* 거기서 throw하면 원래 Promise를 반환하는 API가 **동기 throw**로 바뀐다.
|
|
778
|
+
* `threwSync`는 env1↔env3 동치 diff의 관측 축이라 그 차이가 곧 가짜 불일치가 된다.
|
|
779
|
+
* 그래서 각 mock 구현 본문 안(= 그 API 자신의 sync/async 계약 안)에 수동 삽입한다.
|
|
780
|
+
*/
|
|
781
|
+
/**
|
|
782
|
+
* `method`가 지금 throttle에 걸리면 네이티브 실패 에러를 돌려주고, 아니면
|
|
783
|
+
* 호출 시각을 기록한 뒤 `undefined`를 돌려준다.
|
|
784
|
+
*
|
|
785
|
+
* 반환된 에러를 **호출부가 자신의 계약대로** 흘려보낸다 — async mock은 `throw`
|
|
786
|
+
* (= reject), 콜백형 mock은 `onError(err)`. 그래서 이 함수 자체는 던지지 않는다.
|
|
787
|
+
*/
|
|
788
|
+
function throttleErrorFor(method) {
|
|
789
|
+
const dial = aitState.state.failureModes.throttled;
|
|
790
|
+
if (!dial?.methods?.includes(method)) return void 0;
|
|
791
|
+
const now = Date.now();
|
|
792
|
+
const prev = lastAllowedAt.get(method);
|
|
793
|
+
if (prev !== void 0 && now - prev < dial.intervalMs) return buildNativeError("APP_BRIDGE_THROTTLED");
|
|
794
|
+
lastAllowedAt.set(method, now);
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* `throttleErrorFor`의 throw 버전 — async mock 본문에서 한 줄로 쓴다.
|
|
798
|
+
* async 함수 안에서 던지므로 호출자에게는 rejection으로 도달한다.
|
|
799
|
+
*/
|
|
800
|
+
function checkThrottle(method) {
|
|
801
|
+
const err = throttleErrorFor(method);
|
|
802
|
+
if (err) throw err;
|
|
803
|
+
}
|
|
804
|
+
//#endregion
|
|
805
|
+
//#region src/mock/device/clipboard.ts
|
|
806
|
+
/**
|
|
807
|
+
* Clipboard mock
|
|
808
|
+
* mock/web 모드 지원
|
|
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;
|
|
1114
|
+
}
|
|
1115
|
+
//#endregion
|
|
1116
|
+
//#region src/mock/device/pdf.ts
|
|
1117
|
+
/**
|
|
1118
|
+
* Base64로 인코딩된 PDF 데이터를 네이티브 PDF 뷰어로 여는 mock.
|
|
1119
|
+
* mock 환경에서는 즉시 `'CLOSE'`를 반환한다.
|
|
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
|
+
} });
|
|
1167
|
+
}
|
|
1168
|
+
//#endregion
|
|
1169
|
+
//#region src/mock/device/storage.ts
|
|
1170
|
+
/**
|
|
1171
|
+
* Storage mock
|
|
1172
|
+
* localStorage에 `__ait_storage:` prefix로 저장하여 앱 자체 localStorage와 분리
|
|
1173
|
+
*
|
|
1174
|
+
* 실기기(2.x×iOS) capture는 `setItem`/`removeItem`/`clearItems` 세 메서드가
|
|
1175
|
+
* `undefined`가 아니라 `null`로 resolve됨을 보였다(devtools#770). 원본 SDK 타입
|
|
1176
|
+
* 선언은 여전히 `Promise<void>`이므로 시그니처는 그대로 두고, 런타임 반환값만
|
|
1177
|
+
* `null`로 캐스트해 실측과 동치시킨다 — Analytics·setClipboardText와 같은 처리(#775).
|
|
1178
|
+
* `getItem`은 이미 `string | null`이라 실측과 일치하므로 손대지 않는다.
|
|
1179
|
+
*/
|
|
1180
|
+
const Storage = createMockProxy("Storage", {
|
|
1181
|
+
getItem: async (key) => {
|
|
1182
|
+
return localStorage.getItem(`__ait_storage:${key}`);
|
|
1183
|
+
},
|
|
1184
|
+
setItem: async (key, value) => {
|
|
1185
|
+
localStorage.setItem(`__ait_storage:${key}`, value);
|
|
1186
|
+
return null;
|
|
1187
|
+
},
|
|
1188
|
+
removeItem: async (key) => {
|
|
1189
|
+
localStorage.removeItem(`__ait_storage:${key}`);
|
|
1190
|
+
return null;
|
|
1191
|
+
},
|
|
1192
|
+
clearItems: async () => {
|
|
1193
|
+
const keys = Object.keys(localStorage).filter((k) => k.startsWith("__ait_storage:"));
|
|
1194
|
+
for (const k of keys) localStorage.removeItem(k);
|
|
1195
|
+
return null;
|
|
1196
|
+
}
|
|
1197
|
+
});
|
|
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
|
|
1220
|
+
});
|
|
1221
|
+
result.then((value) => {
|
|
1222
|
+
aitState.logSdkCall({
|
|
1223
|
+
method: apiName,
|
|
1224
|
+
args: safeArgs,
|
|
1225
|
+
timestamp,
|
|
1226
|
+
status: "resolved",
|
|
1227
|
+
result: safeSerialize(value),
|
|
1228
|
+
fidelity
|
|
1229
|
+
});
|
|
1230
|
+
}, (err) => {
|
|
1231
|
+
aitState.logSdkCall({
|
|
1232
|
+
method: apiName,
|
|
1233
|
+
args: safeArgs,
|
|
1234
|
+
timestamp,
|
|
1235
|
+
status: "rejected",
|
|
1236
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1237
|
+
fidelity
|
|
1238
|
+
});
|
|
1239
|
+
});
|
|
1240
|
+
return result;
|
|
1241
|
+
}
|
|
1242
|
+
aitState.logSdkCall({
|
|
1243
|
+
method: apiName,
|
|
1244
|
+
args: safeArgs,
|
|
1245
|
+
timestamp,
|
|
1246
|
+
status: "resolved",
|
|
1247
|
+
result: safeSerialize(result),
|
|
1248
|
+
fidelity
|
|
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 };
|
|
1311
|
+
});
|
|
1312
|
+
const requestReview$1 = observe("requestReview", "inert", async () => {
|
|
1313
|
+
console.log("[@apps-in-toss/devtools] requestReview called");
|
|
1314
|
+
});
|
|
1315
|
+
requestReview$1.isSupported = () => true;
|
|
1316
|
+
function getTossAppVersion() {
|
|
1317
|
+
return aitState.state.appVersion;
|
|
1318
|
+
}
|
|
1319
|
+
function getGroupId() {
|
|
1320
|
+
return aitState.state.groupId;
|
|
1321
|
+
}
|
|
1322
|
+
async function getNetworkStatus() {
|
|
1323
|
+
const modeResult = getNetworkStatusByMode();
|
|
1324
|
+
if (modeResult) return modeResult;
|
|
1325
|
+
return aitState.state.networkStatus;
|
|
1326
|
+
}
|
|
1327
|
+
const _getServerTimeImpl = async () => {
|
|
1328
|
+
return Date.now();
|
|
1329
|
+
};
|
|
1330
|
+
const getServerTime$1 = Object.assign(_getServerTimeImpl, { isSupported: () => true });
|
|
1331
|
+
/**
|
|
1332
|
+
* 현재 backEvent 구독자 수. graniteEvent.addEventListener('backEvent', …)가
|
|
1333
|
+
* 증가시키고, 반환된 cleanup이 감소시킨다. 호스트 back 메시지 처리 시 인터셉트
|
|
1334
|
+
* 여부를 판단하는 데 쓰인다.
|
|
1335
|
+
*
|
|
1336
|
+
* @internal 테스트 및 safe-area-bridge에서만 사용.
|
|
1337
|
+
*/
|
|
1338
|
+
let _backEventSubscriberCount = 0;
|
|
1339
|
+
const graniteEvent = { addEventListener(event, { onEvent, onError }) {
|
|
1340
|
+
const handler = () => {
|
|
1341
|
+
try {
|
|
1342
|
+
onEvent();
|
|
1343
|
+
} catch (e) {
|
|
1344
|
+
onError?.(e instanceof Error ? e : new Error(String(e)));
|
|
1345
|
+
}
|
|
1346
|
+
};
|
|
1347
|
+
window.addEventListener(`__ait:${event}`, handler);
|
|
1348
|
+
if (event === "backEvent") _backEventSubscriberCount++;
|
|
1349
|
+
let cleaned = false;
|
|
1350
|
+
return () => {
|
|
1351
|
+
if (cleaned) return;
|
|
1352
|
+
cleaned = true;
|
|
1353
|
+
window.removeEventListener(`__ait:${event}`, handler);
|
|
1354
|
+
if (event === "backEvent") _backEventSubscriberCount--;
|
|
1355
|
+
};
|
|
1356
|
+
} };
|
|
1357
|
+
/**
|
|
1358
|
+
* 호스트 back 내비게이션을 처리한다.
|
|
1359
|
+
*
|
|
1360
|
+
* backEvent 구독자가 1명 이상이면 `window.dispatchEvent(new CustomEvent('__ait:backEvent'))`만
|
|
1361
|
+
* 발사한다 — 미니앱이 back을 가로채는(intercept) 채널이고 실제 토스 호스트와 동일한 시맨틱.
|
|
1362
|
+
* 구독자가 없으면 `history.back()`을 호출해 기본 브라우저 뒤로가기를 수행한다.
|
|
1363
|
+
*
|
|
1364
|
+
* env 1 패널의 back 버튼(`src/panel/viewport.ts` `aitState.trigger('backEvent')`)과
|
|
1365
|
+
* 동일한 경로를 거쳐 back 시맨틱의 단일 소유처를 navigation 모듈에 유지한다.
|
|
1366
|
+
*/
|
|
1367
|
+
function dispatchHostBackNavigation() {
|
|
1368
|
+
if (_backEventSubscriberCount > 0) window.dispatchEvent(new CustomEvent("__ait:backEvent"));
|
|
1369
|
+
else history.back();
|
|
1370
|
+
}
|
|
1371
|
+
const appsInTossEvent = { addEventListener(_event, _handlers) {
|
|
1372
|
+
return () => {};
|
|
1373
|
+
} };
|
|
1374
|
+
const tdsEvent = { addEventListener(event, { onEvent }) {
|
|
1375
|
+
const handler = (e) => {
|
|
1376
|
+
const detail = e.detail;
|
|
1377
|
+
onEvent(detail);
|
|
1378
|
+
};
|
|
1379
|
+
window.addEventListener(`__ait:${event}`, handler);
|
|
1380
|
+
return () => window.removeEventListener(`__ait:${event}`, handler);
|
|
1381
|
+
} };
|
|
1382
|
+
/**
|
|
1383
|
+
* @deprecated web-framework 3.0 에서 제거됨. 2.x 소비자 back-compat용으로 유지.
|
|
1384
|
+
*/
|
|
1385
|
+
function onVisibilityChangedByTransparentServiceWeb(eventParams) {
|
|
1386
|
+
const handler = () => eventParams.onEvent(!document.hidden);
|
|
1387
|
+
document.addEventListener("visibilitychange", handler);
|
|
1388
|
+
return () => document.removeEventListener("visibilitychange", handler);
|
|
1389
|
+
}
|
|
1390
|
+
const env = { getDeploymentId: () => aitState.state.deploymentId };
|
|
1391
|
+
function getAppsInTossGlobals() {
|
|
1392
|
+
return {
|
|
1393
|
+
deploymentId: aitState.state.deploymentId,
|
|
1394
|
+
brandDisplayName: aitState.state.brand.displayName,
|
|
1395
|
+
brandIcon: aitState.state.brand.icon,
|
|
1396
|
+
brandPrimaryColor: aitState.state.brand.primaryColor
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
const SafeAreaInsets = {
|
|
1400
|
+
get: () => ({ ...aitState.state.safeAreaInsets }),
|
|
1401
|
+
subscribe: ({ onEvent }) => {
|
|
1402
|
+
return aitState.subscribe(() => onEvent({ ...aitState.state.safeAreaInsets }));
|
|
1403
|
+
}
|
|
1404
|
+
};
|
|
1405
|
+
//#endregion
|
|
1406
|
+
//#region src/mock/safe-area-bridge.ts
|
|
1407
|
+
/**
|
|
1408
|
+
* env-2 postMessage bridges (#484, #510).
|
|
1409
|
+
*
|
|
1410
|
+
* In the AITC Sandbox PWA (env 2) the dev app runs inside the launcher's
|
|
1411
|
+
* full-viewport `<iframe>`. The launcher is the top-level document, so its
|
|
1412
|
+
* `env(safe-area-inset-*)` measurement is the ground truth for the real device
|
|
1413
|
+
* geometry. The framed page's mock would otherwise report a synthetic preset
|
|
1414
|
+
* value (e.g. top=54), which sdk-example then double-pads on top of a viewport
|
|
1415
|
+
* that already starts below the status bar — the env-2 "dead band" defect.
|
|
1416
|
+
*
|
|
1417
|
+
* This module installs receive-half listeners for two message types:
|
|
1418
|
+
*
|
|
1419
|
+
* 1. `ait:safe-area-insets` (#484): the launcher forwards its real env() insets
|
|
1420
|
+
* to the framed page on iframe load and resize/orientationchange. Validates the
|
|
1421
|
+
* envelope and writes real insets into the mock SafeAreaInsets state, firing the
|
|
1422
|
+
* subscribe path (see navigation/index.ts) so apps that subscribe re-read the
|
|
1423
|
+
* corrected values.
|
|
1424
|
+
*
|
|
1425
|
+
* 2. `ait:navigate-back` (#510): the launcher partner bar's `←` button posts this
|
|
1426
|
+
* command to the framed page. The receive half calls `dispatchHostBackNavigation()`
|
|
1427
|
+
* (navigation/index.ts): if backEvent subscribers are present, a `__ait:backEvent`
|
|
1428
|
+
* CustomEvent is dispatched (the mini-app intercept channel, matching the env-1
|
|
1429
|
+
* panel path); otherwise `history.back()` is called. No data other than `type` is
|
|
1430
|
+
* read from or written to the message — shape validation rejects anything that
|
|
1431
|
+
* carries extra fields with the wrong type. Apps that do not install this mock
|
|
1432
|
+
* (older builds) silently ignore the message (natural no-op).
|
|
1433
|
+
*
|
|
1434
|
+
* Origin policy: neither message type carries sensitive data, so we do NOT
|
|
1435
|
+
* restrict by origin — the launcher posts cross-origin from a *.trycloudflare.com
|
|
1436
|
+
* tunnel with targetOrigin '*'. Shape validation is still mandatory: a malformed
|
|
1437
|
+
* or out-of-range message is silently ignored so a stray postMessage can never
|
|
1438
|
+
* corrupt the mock state or trigger spurious navigation.
|
|
1439
|
+
*
|
|
1440
|
+
* Message-driven by design: env 1 (desktop browser, no launcher) never receives
|
|
1441
|
+
* these messages, so the panel preset stays authoritative there with zero special
|
|
1442
|
+
* casing here.
|
|
1443
|
+
*/
|
|
1444
|
+
/** The postMessage envelope the launcher posts to the framed dev app (inset forward). */
|
|
1445
|
+
const SAFE_AREA_INSETS_MESSAGE_TYPE = "ait:safe-area-insets";
|
|
1446
|
+
/**
|
|
1447
|
+
* The postMessage command the launcher partner bar's `←` button sends to the
|
|
1448
|
+
* framed dev app (#510). The framed page calls `history.back()` in response.
|
|
1449
|
+
*
|
|
1450
|
+
* Protocol: only `{ type: 'ait:navigate-back' }` is valid. No other fields are
|
|
1451
|
+
* read or acted on — extra fields are silently ignored by the shape guard.
|
|
1452
|
+
* Game variant never sends this message (back button is partner-bar-only).
|
|
1453
|
+
*/
|
|
1454
|
+
const NAVIGATE_BACK_MESSAGE_TYPE = "ait:navigate-back";
|
|
1455
|
+
/**
|
|
1456
|
+
* The postMessage envelope the framed mini-app self-reports its webViewType
|
|
1457
|
+
* with (#580). The mini-app knows its own type from the build constant
|
|
1458
|
+
* `__WEB_VIEW_TYPE__` (`granite.config.ts`'s `webViewProps.type`, injected by
|
|
1459
|
+
* the devtools unplugin). The launcher is cross-origin so it cannot read that
|
|
1460
|
+
* constant directly — the mini-app posts it to `window.parent` once so the
|
|
1461
|
+
* launcher (env-2 PWA shell) switches to game mode automatically, with no
|
|
1462
|
+
* manual `?navBarType=game` URL edit.
|
|
1463
|
+
*
|
|
1464
|
+
* Direction: this is the SEND side's contract (posted from inside the iframe by
|
|
1465
|
+
* `@ait-co/debug-console`'s `packages/debug-console/src/attach.ts`). The
|
|
1466
|
+
* launcher's receive half lives in
|
|
1467
|
+
* `e2e/fixture/launcher/Launcher.tsx` and mirrors the same value enum inline,
|
|
1468
|
+
* staying decoupled from the mock package internals — the same pattern the
|
|
1469
|
+
* other launcher message types follow.
|
|
1470
|
+
*
|
|
1471
|
+
* Value enum: only `'partner'` and `'game'` are valid. The SDK's deprecated
|
|
1472
|
+
* `'external'` alias of `partner` (web-framework 2.6.1) is mapped to `'partner'`
|
|
1473
|
+
* at the send site so the wire only ever carries the two shapes the launcher
|
|
1474
|
+
* emulates.
|
|
1475
|
+
*/
|
|
1476
|
+
const WEB_VIEW_TYPE_MESSAGE_TYPE = "ait:web-view-type";
|
|
1477
|
+
const MAX_INSET_PX = 200;
|
|
1478
|
+
function isValidInset(value) {
|
|
1479
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= MAX_INSET_PX;
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* Parse + validate a raw postMessage payload into a `SafeAreaInsets`, or return
|
|
1483
|
+
* null when it is not a well-formed `ait:safe-area-insets` message. Pure — unit
|
|
1484
|
+
* tested without a real MessageEvent.
|
|
1485
|
+
*/
|
|
1486
|
+
function parseSafeAreaInsetsMessage(data) {
|
|
1487
|
+
if (typeof data !== "object" || data === null) return null;
|
|
1488
|
+
if (data.type !== "ait:safe-area-insets") return null;
|
|
1489
|
+
const insets = data.insets;
|
|
1490
|
+
if (typeof insets !== "object" || insets === null) return null;
|
|
1491
|
+
const { top, bottom, left, right } = insets;
|
|
1492
|
+
if (!isValidInset(top) || !isValidInset(bottom) || !isValidInset(left) || !isValidInset(right)) return null;
|
|
1493
|
+
return {
|
|
1494
|
+
top,
|
|
1495
|
+
bottom,
|
|
1496
|
+
left,
|
|
1497
|
+
right
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* Parse + validate a raw postMessage payload into a webViewType value
|
|
1502
|
+
* (`'partner'` | `'game'`), or return `null` when it is not a well-formed
|
|
1503
|
+
* `ait:web-view-type` message (#580). Pure — unit tested without a real
|
|
1504
|
+
* MessageEvent.
|
|
1505
|
+
*
|
|
1506
|
+
* Strict shape guard (the safety boundary for the cross-origin receive path):
|
|
1507
|
+
* the payload must be a non-null object whose `type` is exactly
|
|
1508
|
+
* {@link WEB_VIEW_TYPE_MESSAGE_TYPE} and whose `value` is exactly `'partner'`
|
|
1509
|
+
* or `'game'` (an enum allow-list). Anything else — a foreign type, a missing
|
|
1510
|
+
* or non-string value, the deprecated `'external'` alias, or any other string —
|
|
1511
|
+
* returns `null` so a stray postMessage can never flip the launcher's visual
|
|
1512
|
+
* mode. The send site is responsible for collapsing `'external'` → `'partner'`
|
|
1513
|
+
* before posting; the parser does NOT silently accept it.
|
|
1514
|
+
*/
|
|
1515
|
+
function parseWebViewTypeMessage(data) {
|
|
1516
|
+
if (typeof data !== "object" || data === null) return null;
|
|
1517
|
+
if (data.type !== "ait:web-view-type") return null;
|
|
1518
|
+
const value = data.value;
|
|
1519
|
+
if (value === "partner" || value === "game") return value;
|
|
1520
|
+
return null;
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Apply forwarded insets to the mock state. Skips the write (and the resulting
|
|
1524
|
+
* subscribe notify) when nothing changed, so repeated identical messages from a
|
|
1525
|
+
* resize storm don't churn subscribers.
|
|
1526
|
+
*/
|
|
1527
|
+
function applyForwardedSafeAreaInsets(insets) {
|
|
1528
|
+
const current = aitState.state.safeAreaInsets;
|
|
1529
|
+
if (current.top === insets.top && current.bottom === insets.bottom && current.left === insets.left && current.right === insets.right) return;
|
|
1530
|
+
aitState.update({ safeAreaInsets: insets });
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* The `id` attribute of the `<style>` element injected by
|
|
1534
|
+
* {@link applyEnv2Compensation}. Using a stable id makes the injection
|
|
1535
|
+
* idempotent — the element is reused on every subsequent message rather than
|
|
1536
|
+
* accumulating multiple `<style>` nodes.
|
|
1537
|
+
*/
|
|
1538
|
+
const ENV2_COMPENSATION_STYLE_ID = "ait-env2-safe-area-compensation";
|
|
1539
|
+
/**
|
|
1540
|
+
* The CSS text injected when the launcher forwards partner-mode insets
|
|
1541
|
+
* (`top === 0`). The negative margin pulls the mini-app document up by exactly
|
|
1542
|
+
* the device's status-bar inset so the launcher's own bar offset is not
|
|
1543
|
+
* double-counted.
|
|
1544
|
+
*
|
|
1545
|
+
* jsdom cannot evaluate `env()` expressions, so unit tests assert on this
|
|
1546
|
+
* string literal rather than computed geometry.
|
|
1547
|
+
*/
|
|
1548
|
+
const ENV2_COMPENSATION_CSS = "body { margin-top: calc(-1 * env(safe-area-inset-top)); }";
|
|
1549
|
+
/**
|
|
1550
|
+
* Inject or remove the env-2 safe-area compensation `<style>` based on the
|
|
1551
|
+
* forwarded insets received from the launcher.
|
|
1552
|
+
*
|
|
1553
|
+
* ### Why this is needed
|
|
1554
|
+
*
|
|
1555
|
+
* In env 2 (AITC Sandbox PWA) the launcher frames the mini-app in a
|
|
1556
|
+
* full-bleed `<iframe>` whose top edge is already positioned BELOW the
|
|
1557
|
+
* launcher's partner bar (at `env(safe-area-inset-top) + 54px`). However,
|
|
1558
|
+
* inside the cross-origin child iframe, CSS `env(safe-area-inset-top)` STILL
|
|
1559
|
+
* reports the full device status-bar inset (e.g. 62 px on an iPhone 15). Any
|
|
1560
|
+
* mini-app that pads itself by that CSS `env()` value ends up double-counting
|
|
1561
|
+
* the status-bar, producing a ≈62 px white dead-band between the partner bar
|
|
1562
|
+
* and the first content.
|
|
1563
|
+
*
|
|
1564
|
+
* The fix is a compensating negative margin on `body` inside the iframe. When
|
|
1565
|
+
* the launcher forwards partner insets (`top === 0`, meaning "the iframe
|
|
1566
|
+
* already starts below the bar"), we inject:
|
|
1567
|
+
*
|
|
1568
|
+
* ```css
|
|
1569
|
+
* body { margin-top: calc(-1 * env(safe-area-inset-top)); }
|
|
1570
|
+
* ```
|
|
1571
|
+
*
|
|
1572
|
+
* This cancels the duplicate padding without knowing the app's internal DOM
|
|
1573
|
+
* structure. The launcher's html/body `screen.height` force (#527) lives in the
|
|
1574
|
+
* LAUNCHER document (cross-origin) and never touches the mini-app document, so
|
|
1575
|
+
* margin and height force compose independently — they cannot interfere.
|
|
1576
|
+
*
|
|
1577
|
+
* ### Gating
|
|
1578
|
+
*
|
|
1579
|
+
* - **`top === 0`** (partner mode) → install the style (dead-band compensation).
|
|
1580
|
+
* - **`top > 0`** (game mode — launcher forwards the raw device inset for the
|
|
1581
|
+
* floating status-bar capsule) → remove any previously installed style so
|
|
1582
|
+
* `env()` is the authoritative clearance and nothing is double-removed.
|
|
1583
|
+
*
|
|
1584
|
+
* On every insets message (resize/orientation re-post) this function is called
|
|
1585
|
+
* so the gate is re-evaluated and the style toggled accordingly.
|
|
1586
|
+
*
|
|
1587
|
+
* ### env 1 / env 3 / env 4 no-regression
|
|
1588
|
+
*
|
|
1589
|
+
* This function is only called from the `message` listener installed by
|
|
1590
|
+
* {@link installSafeAreaInsetsBridge}. That listener fires only when the
|
|
1591
|
+
* launcher posts an `ait:safe-area-insets` message, which never happens in
|
|
1592
|
+
* env 1 (desktop browser, no launcher), env 3 (intoss-private WebView), or
|
|
1593
|
+
* env 4 (live relay) — they have no launcher posting to the iframe.
|
|
1594
|
+
*
|
|
1595
|
+
* @param top - The forwarded `top` inset value from the launcher message.
|
|
1596
|
+
*/
|
|
1597
|
+
function applyEnv2Compensation(top) {
|
|
1598
|
+
if (typeof document === "undefined") return;
|
|
1599
|
+
const existing = document.getElementById(ENV2_COMPENSATION_STYLE_ID);
|
|
1600
|
+
if (top === 0) {
|
|
1601
|
+
if (existing !== null) return;
|
|
1602
|
+
const style = document.createElement("style");
|
|
1603
|
+
style.id = ENV2_COMPENSATION_STYLE_ID;
|
|
1604
|
+
style.textContent = ENV2_COMPENSATION_CSS;
|
|
1605
|
+
document.head.appendChild(style);
|
|
1606
|
+
} else if (existing !== null) existing.remove();
|
|
1607
|
+
}
|
|
1608
|
+
let installed = false;
|
|
1609
|
+
/**
|
|
1610
|
+
* Install the window `message` listener that receives forwarded insets. Safe to
|
|
1611
|
+
* call multiple times (idempotent) and a no-op outside a browser (SSR/jsdom
|
|
1612
|
+
* without a window). Imported for its side effect by the mock barrel so any
|
|
1613
|
+
* consumer that aliases `@apps-in-toss/web-framework` to the mock gets it wired.
|
|
1614
|
+
*
|
|
1615
|
+
* On each valid `ait:safe-area-insets` message:
|
|
1616
|
+
* 1. Writes the corrected insets into the mock `SafeAreaInsets` state
|
|
1617
|
+
* (existing #484 behaviour — for apps that still read SDK insets).
|
|
1618
|
+
* 2. Drives the env-2 dead-band compensation style via
|
|
1619
|
+
* {@link applyEnv2Compensation} — injects a `body { margin-top: calc(-1 *
|
|
1620
|
+
* env(safe-area-inset-top)) }` style when `top === 0` (partner mode) and
|
|
1621
|
+
* removes it when `top > 0` (game / full-bleed mode).
|
|
1622
|
+
*/
|
|
1623
|
+
function installSafeAreaInsetsBridge() {
|
|
1624
|
+
if (installed || typeof window === "undefined") return;
|
|
1625
|
+
installed = true;
|
|
1626
|
+
window.addEventListener("message", (event) => {
|
|
1627
|
+
const insets = parseSafeAreaInsetsMessage(event.data);
|
|
1628
|
+
if (insets) {
|
|
1629
|
+
applyForwardedSafeAreaInsets(insets);
|
|
1630
|
+
applyEnv2Compensation(insets.top);
|
|
1631
|
+
}
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Parse a raw postMessage payload as an `ait:navigate-back` command.
|
|
1636
|
+
*
|
|
1637
|
+
* Returns true when the payload is a well-formed navigate-back command
|
|
1638
|
+
* (`{ type: 'ait:navigate-back' }`), false otherwise. Pure — unit tested
|
|
1639
|
+
* without a real MessageEvent.
|
|
1640
|
+
*
|
|
1641
|
+
* Shape guard: only the `type` field is inspected; any extra fields are
|
|
1642
|
+
* ignored so future extensions do not break older receivers. The function
|
|
1643
|
+
* does NOT read any data field beyond `type` — no sensitive values, no host
|
|
1644
|
+
* disclosure (same principle as the insets bridge).
|
|
1645
|
+
*/
|
|
1646
|
+
function isNavigateBackMessage(data) {
|
|
1647
|
+
if (typeof data !== "object" || data === null) return false;
|
|
1648
|
+
return data.type === NAVIGATE_BACK_MESSAGE_TYPE;
|
|
1649
|
+
}
|
|
1650
|
+
let navigateBackInstalled = false;
|
|
1651
|
+
/**
|
|
1652
|
+
* Install the window `message` listener that handles `ait:navigate-back`
|
|
1653
|
+
* commands (#510). When the launcher partner bar's `←` button is clicked it
|
|
1654
|
+
* posts `{ type: 'ait:navigate-back' }` to the framed dev app; this listener
|
|
1655
|
+
* calls `dispatchHostBackNavigation()` from the navigation module.
|
|
1656
|
+
*
|
|
1657
|
+
* Dispatch semantics: if there are any `graniteEvent.addEventListener('backEvent', …)`
|
|
1658
|
+
* subscribers the CustomEvent `__ait:backEvent` is fired (same path as the env-1
|
|
1659
|
+
* panel back button — the mini-app intercept channel). When there are no
|
|
1660
|
+
* subscribers `history.back()` is called as the fallback. Back semantics are
|
|
1661
|
+
* owned entirely by the navigation module; this bridge only delegates.
|
|
1662
|
+
*
|
|
1663
|
+
* Safe to call multiple times (idempotent) and a no-op outside a browser.
|
|
1664
|
+
* Installed together with the inset bridge by `installBridges()` so any consumer
|
|
1665
|
+
* of the mock barrel gets both wired automatically.
|
|
1666
|
+
*
|
|
1667
|
+
* No-op on apps that predate this bridge — the launcher posts the message but
|
|
1668
|
+
* older mocks simply have no listener (harmless).
|
|
1669
|
+
*/
|
|
1670
|
+
function installNavigateBackBridge() {
|
|
1671
|
+
if (navigateBackInstalled || typeof window === "undefined") return;
|
|
1672
|
+
navigateBackInstalled = true;
|
|
1673
|
+
window.addEventListener("message", (event) => {
|
|
1674
|
+
if (isNavigateBackMessage(event.data)) dispatchHostBackNavigation();
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Install both env-2 postMessage bridges in one call (#484 insets + #510
|
|
1679
|
+
* navigate-back). The mock barrel calls this at import time so consumers get
|
|
1680
|
+
* all bridges wired without any explicit setup.
|
|
1681
|
+
*/
|
|
1682
|
+
function installBridges() {
|
|
1683
|
+
installSafeAreaInsetsBridge();
|
|
1684
|
+
installNavigateBackBridge();
|
|
1685
|
+
}
|
|
1686
|
+
//#endregion
|
|
1687
|
+
//#region src/mock/ads/index.ts
|
|
1688
|
+
function withIsSupported(fn) {
|
|
1689
|
+
fn.isSupported = () => true;
|
|
1690
|
+
return fn;
|
|
1691
|
+
}
|
|
1692
|
+
const _slotRegistry = /* @__PURE__ */ new Map();
|
|
1693
|
+
let _slotCounter = 0;
|
|
1694
|
+
function _nextSlotId(adGroupId) {
|
|
1695
|
+
_slotCounter += 1;
|
|
1696
|
+
return `mock-slot-${adGroupId}-${_slotCounter}`;
|
|
1697
|
+
}
|
|
1698
|
+
const GoogleAdMob$1 = createMockProxy("GoogleAdMob", {
|
|
1699
|
+
loadAppsInTossAdMob: withIsSupported(observe("GoogleAdMob.loadAppsInTossAdMob", "faithful", (args) => {
|
|
1700
|
+
setTimeout(() => {
|
|
1701
|
+
const failureCode = aitState.state.failureModes.loadAdMob;
|
|
1702
|
+
if (failureCode) {
|
|
1703
|
+
args.onError(buildNativeError(failureCode));
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
const throttleError = throttleErrorFor("loadAppsInTossAdMob");
|
|
1707
|
+
if (throttleError) {
|
|
1708
|
+
args.onError(throttleError);
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
if (aitState.state.ads.forceNoFill) {
|
|
1712
|
+
args.onError(/* @__PURE__ */ new Error("No fill"));
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
aitState.patch("ads", { isLoaded: true });
|
|
1716
|
+
args.onEvent({
|
|
1717
|
+
type: "loaded",
|
|
1718
|
+
data: {
|
|
1719
|
+
adGroupId: args.options.adGroupId,
|
|
1720
|
+
adUnitId: `mock-unit-${args.options.adGroupId}`,
|
|
1721
|
+
responseInfo: {
|
|
1722
|
+
responseId: `mock-response-${args.options.adGroupId}`,
|
|
1723
|
+
adNetworkInfoArray: [],
|
|
1724
|
+
loadedAdNetworkInfo: null
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
});
|
|
1728
|
+
}, 200);
|
|
1729
|
+
return () => {};
|
|
1730
|
+
})),
|
|
1731
|
+
showAppsInTossAdMob: withIsSupported(observe("GoogleAdMob.showAppsInTossAdMob", "faithful", (args) => {
|
|
1732
|
+
if (!aitState.state.ads.isLoaded) {
|
|
1733
|
+
args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
|
|
1734
|
+
return () => {};
|
|
1735
|
+
}
|
|
1736
|
+
const { rewardUnitType, rewardAmount } = aitState.state.ads;
|
|
1737
|
+
setTimeout(() => args.onEvent({
|
|
1738
|
+
type: "userEarnedReward",
|
|
1739
|
+
data: {
|
|
1740
|
+
unitType: rewardUnitType,
|
|
1741
|
+
unitAmount: rewardAmount
|
|
1742
|
+
}
|
|
1743
|
+
}), 1e3);
|
|
1744
|
+
setTimeout(() => {
|
|
1745
|
+
args.onEvent({ type: "dismissed" });
|
|
1746
|
+
aitState.patch("ads", { isLoaded: false });
|
|
1747
|
+
}, 1500);
|
|
1748
|
+
return () => {};
|
|
1749
|
+
})),
|
|
1750
|
+
isAppsInTossAdMobLoaded: withIsSupported(observe("GoogleAdMob.isAppsInTossAdMobLoaded", "faithful", async (_options) => {
|
|
1751
|
+
if (_options?.adGroupId !== void 0 && _options.adGroupId.trim() === "") throw buildNativeError("INVALID_REQUEST");
|
|
1752
|
+
return aitState.state.ads.isLoaded;
|
|
1753
|
+
}))
|
|
1754
|
+
});
|
|
1755
|
+
const TossAds = createMockProxy("TossAds", {
|
|
1756
|
+
initialize: withIsSupported(observe("TossAds.initialize", "partial", (options) => {
|
|
1757
|
+
if (aitState.state.ads.forceNoFill) {
|
|
1758
|
+
options.callbacks?.onInitializationFailed?.(/* @__PURE__ */ new Error("No fill"));
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
options.callbacks?.onInitialized?.();
|
|
1762
|
+
})),
|
|
1763
|
+
attach: withIsSupported(observe("TossAds.attach", "partial", (_adGroupId, target, _options) => {
|
|
1764
|
+
const el = typeof target === "string" ? document.querySelector(target) : target;
|
|
1765
|
+
if (el) {
|
|
1766
|
+
const placeholder = document.createElement("div");
|
|
1767
|
+
placeholder.style.cssText = "background:#f0f0f0;border:1px dashed #999;padding:16px;text-align:center;color:#666;font-size:14px;";
|
|
1768
|
+
placeholder.textContent = "[@apps-in-toss/devtools] TossAds Placeholder";
|
|
1769
|
+
el.appendChild(placeholder);
|
|
1770
|
+
}
|
|
1771
|
+
})),
|
|
1772
|
+
attachBanner: withIsSupported(observe("TossAds.attachBanner", "faithful", (adGroupId, target, options) => {
|
|
1773
|
+
const el = typeof target === "string" ? document.querySelector(target) : target;
|
|
1774
|
+
const slotId = _nextSlotId(adGroupId);
|
|
1775
|
+
const placeholder = document.createElement("div");
|
|
1776
|
+
const theme = options?.theme ?? "auto";
|
|
1777
|
+
const variant = options?.variant ?? "card";
|
|
1778
|
+
const isDark = theme === "dark" || theme === "auto" && typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches;
|
|
1779
|
+
const bg = isDark ? "#1a1a1a" : "#f0f0f0";
|
|
1780
|
+
const textColor = isDark ? "#aaa" : "#666";
|
|
1781
|
+
const borderColor = isDark ? "#555" : "#999";
|
|
1782
|
+
const height = variant === "expanded" ? "120px" : "60px";
|
|
1783
|
+
placeholder.dataset.aitSlotId = slotId;
|
|
1784
|
+
placeholder.style.cssText = `background:${bg};border:1px dashed ${borderColor};padding:8px 12px;text-align:center;color:${textColor};font-size:12px;min-height:${height};display:flex;align-items:center;justify-content:center;`;
|
|
1785
|
+
placeholder.textContent = `[@apps-in-toss/devtools] Banner Ad (${variant})`;
|
|
1786
|
+
if (el) {
|
|
1787
|
+
el.appendChild(placeholder);
|
|
1788
|
+
_slotRegistry.set(slotId, placeholder);
|
|
1789
|
+
}
|
|
1790
|
+
const destroySlot = () => {
|
|
1791
|
+
const registered = _slotRegistry.get(slotId);
|
|
1792
|
+
if (registered) {
|
|
1793
|
+
registered.remove();
|
|
1794
|
+
_slotRegistry.delete(slotId);
|
|
1795
|
+
}
|
|
1796
|
+
};
|
|
1797
|
+
setTimeout(() => {
|
|
1798
|
+
if (aitState.state.ads.forceNoFill) {
|
|
1799
|
+
options?.callbacks?.onNoFill?.({
|
|
1800
|
+
slotId,
|
|
1801
|
+
adGroupId,
|
|
1802
|
+
adMetadata: {}
|
|
1803
|
+
});
|
|
1804
|
+
options?.callbacks?.onAdFailedToRender?.({
|
|
1805
|
+
slotId,
|
|
1806
|
+
adGroupId,
|
|
1807
|
+
adMetadata: {},
|
|
1808
|
+
error: {
|
|
1809
|
+
code: 0,
|
|
1810
|
+
message: "No fill"
|
|
1811
|
+
}
|
|
1812
|
+
});
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
const eventPayload = {
|
|
1816
|
+
slotId,
|
|
1817
|
+
adGroupId,
|
|
1818
|
+
adMetadata: {
|
|
1819
|
+
creativeId: `mock-creative-${slotId}`,
|
|
1820
|
+
requestId: `mock-req-${slotId}`
|
|
1821
|
+
}
|
|
1822
|
+
};
|
|
1823
|
+
options?.callbacks?.onAdRendered?.(eventPayload);
|
|
1824
|
+
options?.callbacks?.onAdImpression?.(eventPayload);
|
|
1825
|
+
}, 100);
|
|
1826
|
+
return { destroy: destroySlot };
|
|
1827
|
+
})),
|
|
1828
|
+
destroy: withIsSupported(observe("TossAds.destroy", "faithful", (slotId) => {
|
|
1829
|
+
const el = _slotRegistry.get(slotId);
|
|
1830
|
+
if (el) {
|
|
1831
|
+
el.remove();
|
|
1832
|
+
_slotRegistry.delete(slotId);
|
|
1833
|
+
}
|
|
1834
|
+
})),
|
|
1835
|
+
destroyAll: withIsSupported(observe("TossAds.destroyAll", "faithful", () => {
|
|
1836
|
+
for (const el of _slotRegistry.values()) el.remove();
|
|
1837
|
+
_slotRegistry.clear();
|
|
1838
|
+
}))
|
|
1839
|
+
});
|
|
1840
|
+
const loadFullScreenAd$1 = observe("loadFullScreenAd", "faithful", (args) => {
|
|
1841
|
+
setTimeout(() => {
|
|
1842
|
+
const failureCode = aitState.state.failureModes.loadFullScreenAd;
|
|
1843
|
+
if (failureCode) {
|
|
1844
|
+
args.onError(buildNativeError(failureCode));
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
const throttleError = throttleErrorFor("loadFullScreenAd");
|
|
1848
|
+
if (throttleError) {
|
|
1849
|
+
args.onError(throttleError);
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
if (aitState.state.ads.forceNoFill) {
|
|
1853
|
+
args.onError(/* @__PURE__ */ new Error("No fill"));
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
aitState.patch("ads", { isLoaded: true });
|
|
1857
|
+
args.onEvent({ type: "loaded" });
|
|
1858
|
+
}, 200);
|
|
1859
|
+
return () => {};
|
|
1860
|
+
});
|
|
1861
|
+
const showFullScreenAd$1 = withIsSupported(observe("showFullScreenAd", "faithful", (args) => {
|
|
1862
|
+
if (!aitState.state.ads.isLoaded) {
|
|
1863
|
+
args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
|
|
1864
|
+
return () => {};
|
|
1865
|
+
}
|
|
1866
|
+
setTimeout(() => args.onEvent({ type: "clicked" }), 100);
|
|
1867
|
+
setTimeout(() => args.onEvent({ type: "dismissed" }), 1500);
|
|
1868
|
+
return () => {};
|
|
1869
|
+
}));
|
|
1870
|
+
//#endregion
|
|
1871
|
+
//#region src/mock/analytics/index.ts
|
|
1872
|
+
/**
|
|
1873
|
+
* Analytics mock
|
|
1874
|
+
*/
|
|
1875
|
+
const Analytics$1 = {
|
|
1876
|
+
screen: (params) => {
|
|
1877
|
+
aitState.logAnalytics({
|
|
1878
|
+
type: "screen",
|
|
1879
|
+
params: params ?? {}
|
|
1880
|
+
});
|
|
1881
|
+
return Promise.resolve(null);
|
|
1882
|
+
},
|
|
1883
|
+
impression: (params) => {
|
|
1884
|
+
aitState.logAnalytics({
|
|
1885
|
+
type: "impression",
|
|
1886
|
+
params: params ?? {}
|
|
1887
|
+
});
|
|
1888
|
+
return Promise.resolve(null);
|
|
1889
|
+
},
|
|
1890
|
+
click: (params) => {
|
|
1891
|
+
aitState.logAnalytics({
|
|
1892
|
+
type: "click",
|
|
1893
|
+
params: params ?? {}
|
|
1894
|
+
});
|
|
1895
|
+
return Promise.resolve(null);
|
|
1896
|
+
}
|
|
1897
|
+
};
|
|
1898
|
+
async function eventLog(params) {
|
|
1899
|
+
aitState.logAnalytics({
|
|
1900
|
+
type: params.log_type,
|
|
1901
|
+
params: {
|
|
1902
|
+
log_name: params.log_name,
|
|
1903
|
+
...params.params
|
|
1904
|
+
}
|
|
1905
|
+
});
|
|
1906
|
+
return null;
|
|
1907
|
+
}
|
|
1908
|
+
//#endregion
|
|
1909
|
+
//#region src/mock/auth/index.ts
|
|
1910
|
+
/**
|
|
1911
|
+
* 인증/로그인 mock
|
|
1912
|
+
*/
|
|
1913
|
+
async function appLogin() {
|
|
1914
|
+
const failureCode = aitState.state.failureModes.appLogin;
|
|
1915
|
+
if (failureCode) throw buildNativeError(failureCode);
|
|
1916
|
+
return {
|
|
1917
|
+
authorizationCode: `mock-auth-${crypto.randomUUID()}`,
|
|
1918
|
+
referrer: aitState.state.environment === "toss" ? "DEFAULT" : "SANDBOX"
|
|
1919
|
+
};
|
|
1920
|
+
}
|
|
1921
|
+
async function getIsTossLoginIntegratedService$1() {
|
|
1922
|
+
const failureCode = aitState.state.failureModes.getIsTossLoginIntegratedService;
|
|
1923
|
+
if (failureCode) throw buildNativeError(failureCode);
|
|
1924
|
+
return aitState.state.auth.isTossLoginIntegrated;
|
|
1925
|
+
}
|
|
1926
|
+
async function getUserKeyForGame$1() {
|
|
1927
|
+
return {
|
|
1928
|
+
hash: aitState.state.auth.userKeyHash ?? "",
|
|
1929
|
+
type: "HASH"
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1932
|
+
async function getAnonymousKey$1() {
|
|
1933
|
+
if (!aitState.state.auth.anonymousKeyHash) return void 0;
|
|
1934
|
+
return {
|
|
1935
|
+
hash: aitState.state.auth.anonymousKeyHash,
|
|
1936
|
+
type: "HASH"
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
async function appsInTossSignTossCert$1(_params) {
|
|
1940
|
+
console.log("[@apps-in-toss/devtools] appsInTossSignTossCert called (no-op in mock)");
|
|
1941
|
+
}
|
|
1942
|
+
/**
|
|
1943
|
+
* 사용자 동의 기반 데이터 mock (devtools#798 — env1에 배선 부재였던 실 export).
|
|
1944
|
+
*
|
|
1945
|
+
* SDK는 이 API를 `@apps-in-toss/web-bridge` 호환 export로 노출하며 2.x와 3.x GA
|
|
1946
|
+
* 양쪽 facade에서 제공한다. 선언 시그니처는 `Promise<Partial<Record<ConsentedUserDataKey, string>>
|
|
1947
|
+
* | undefined>` — appLogin과 같은 async bridge 모양이라 항상 resolve하는 낙관적
|
|
1948
|
+
* 패턴을 따른다.
|
|
1949
|
+
*
|
|
1950
|
+
* 어떤 키가 채워지는지는 콘솔에 등록된 동의문/데이터 묶음(`consentedUserDataKey`)에
|
|
1951
|
+
* 달려 있고 그 매핑은 서버 쪽 설정이라 mock이 알 수 없다 — 호출 파라미터와 무관하게
|
|
1952
|
+
* 상태에 저장된 최소 plausible 객체를 그대로 resolve한다. SDK 선언 밖의 필드는
|
|
1953
|
+
* 추가하지 않는다(devtools#783 — 실측/타입 밖 추정 금지).
|
|
1954
|
+
*/
|
|
1955
|
+
async function getConsentedUserData$1(_options) {
|
|
1956
|
+
return aitState.state.auth.consentedUserData;
|
|
1957
|
+
}
|
|
1958
|
+
async function getDeclaredAgeRange$1(_params) {
|
|
1959
|
+
return { ...aitState.state.auth.declaredAgeRange };
|
|
1960
|
+
}
|
|
1961
|
+
//#endregion
|
|
1962
|
+
//#region src/mock/iap/index.ts
|
|
1963
|
+
let orderCounter = 0;
|
|
1964
|
+
function generateOrderId() {
|
|
1965
|
+
return `mock-order-${++orderCounter}-${Date.now()}`;
|
|
1966
|
+
}
|
|
1967
|
+
function buildOrderResult(sku) {
|
|
1968
|
+
const product = aitState.state.iap.products.find((p) => p.sku === sku);
|
|
1969
|
+
const amountStr = product?.displayAmount?.replace(/[^0-9]/g, "") ?? "1000";
|
|
1970
|
+
return {
|
|
1971
|
+
orderId: generateOrderId(),
|
|
1972
|
+
displayName: product?.displayName ?? "Mock Product",
|
|
1973
|
+
displayAmount: product?.displayAmount ?? "1,000원",
|
|
1974
|
+
amount: parseInt(amountStr, 10) || 1e3,
|
|
1975
|
+
currency: "KRW",
|
|
1976
|
+
fraction: 0,
|
|
1977
|
+
miniAppIconUrl: product?.iconUrl || null
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1980
|
+
async function handlePurchase(sku, processProductGrant, onEvent, onError) {
|
|
1981
|
+
const nextResult = aitState.state.iap.nextResult;
|
|
1982
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1983
|
+
if (nextResult !== "success") {
|
|
1984
|
+
onError({ code: nextResult });
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
const result = buildOrderResult(sku);
|
|
1988
|
+
try {
|
|
1989
|
+
if (!await processProductGrant({ orderId: result.orderId })) {
|
|
1990
|
+
onError({ code: "PRODUCT_NOT_GRANTED_BY_PARTNER" });
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
} catch (e) {
|
|
1994
|
+
onError(e);
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
aitState.patch("iap", { completedOrders: [...aitState.state.iap.completedOrders, {
|
|
1998
|
+
orderId: result.orderId,
|
|
1999
|
+
sku,
|
|
2000
|
+
status: "COMPLETED",
|
|
2001
|
+
date: (/* @__PURE__ */ new Date()).toISOString()
|
|
2002
|
+
}] });
|
|
2003
|
+
await onEvent({
|
|
2004
|
+
type: "success",
|
|
2005
|
+
data: result
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
const IAP$1 = createMockProxy("IAP", {
|
|
2009
|
+
createOneTimePurchaseOrder(params) {
|
|
2010
|
+
handlePurchase(params.options.sku ?? params.options.productId ?? "", params.options.processProductGrant, params.onEvent, params.onError).catch((e) => console.error("[@apps-in-toss/devtools] IAP unexpected error:", e));
|
|
2011
|
+
return () => {};
|
|
2012
|
+
},
|
|
2013
|
+
createSubscriptionPurchaseOrder(params) {
|
|
2014
|
+
handlePurchase(params.options.sku, params.options.processProductGrant, params.onEvent, params.onError).catch((e) => console.error("[@apps-in-toss/devtools] IAP unexpected error:", e));
|
|
2015
|
+
return () => {};
|
|
2016
|
+
},
|
|
2017
|
+
async getProductItemList() {
|
|
2018
|
+
return { products: aitState.state.iap.products.map((p) => ({
|
|
2019
|
+
...p,
|
|
2020
|
+
...p.type === "SUBSCRIPTION" ? { renewalCycle: p.renewalCycle ?? "MONTHLY" } : {}
|
|
2021
|
+
})) };
|
|
2022
|
+
},
|
|
2023
|
+
async getPendingOrders() {
|
|
2024
|
+
const orders = [...aitState.state.iap.pendingOrders];
|
|
2025
|
+
return {
|
|
2026
|
+
orders,
|
|
2027
|
+
orderIds: orders.map((o) => o.orderId)
|
|
2028
|
+
};
|
|
2029
|
+
},
|
|
2030
|
+
async getCompletedOrRefundedOrders() {
|
|
2031
|
+
return {
|
|
2032
|
+
hasNext: false,
|
|
2033
|
+
orders: [...aitState.state.iap.completedOrders]
|
|
2034
|
+
};
|
|
2035
|
+
},
|
|
2036
|
+
async completeProductGrant(args) {
|
|
2037
|
+
const idx = aitState.state.iap.pendingOrders.findIndex((o) => o.orderId === args.params.orderId);
|
|
2038
|
+
if (idx !== -1) {
|
|
2039
|
+
const order = aitState.state.iap.pendingOrders[idx];
|
|
2040
|
+
const pendingOrders = aitState.state.iap.pendingOrders.filter((_, i) => i !== idx);
|
|
2041
|
+
const completedOrders = [...aitState.state.iap.completedOrders, {
|
|
2042
|
+
orderId: order.orderId,
|
|
2043
|
+
sku: order.sku,
|
|
2044
|
+
status: "COMPLETED",
|
|
2045
|
+
date: (/* @__PURE__ */ new Date()).toISOString()
|
|
2046
|
+
}];
|
|
2047
|
+
aitState.patch("iap", {
|
|
2048
|
+
pendingOrders,
|
|
2049
|
+
completedOrders
|
|
2050
|
+
});
|
|
2051
|
+
}
|
|
2052
|
+
return true;
|
|
2053
|
+
},
|
|
2054
|
+
async getSubscriptionInfo(_args) {
|
|
2055
|
+
if (aitState.state.failureModes.softResolve?.getSubscriptionInfo) return {};
|
|
2056
|
+
return { subscription: {
|
|
2057
|
+
catalogId: 1,
|
|
2058
|
+
status: "ACTIVE",
|
|
2059
|
+
expiresAt: new Date(Date.now() + 720 * 60 * 60 * 1e3).toISOString(),
|
|
2060
|
+
isAutoRenew: true,
|
|
2061
|
+
gracePeriodExpiresAt: null,
|
|
2062
|
+
isAccessible: true
|
|
2063
|
+
} };
|
|
2064
|
+
}
|
|
2065
|
+
});
|
|
2066
|
+
async function checkoutPayment(options) {
|
|
2067
|
+
const { nextResult, failReason } = aitState.state.payment;
|
|
2068
|
+
console.log("[@apps-in-toss/devtools] checkoutPayment:", options.params.payToken);
|
|
2069
|
+
if (aitState.state.failureModes.softResolve?.checkoutPayment) return {
|
|
2070
|
+
false: "PAYMENT_FAILED",
|
|
2071
|
+
reason: "mock soft-resolve (env3 unprovisioned)"
|
|
2072
|
+
};
|
|
2073
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
2074
|
+
if (nextResult === "success") return { success: true };
|
|
2075
|
+
return {
|
|
2076
|
+
success: false,
|
|
2077
|
+
reason: failReason || "Mock payment failed"
|
|
2078
|
+
};
|
|
2079
|
+
}
|
|
2080
|
+
const requestTossPayPaysBilling$1 = Object.assign(async function requestTossPayPaysBilling(options) {
|
|
2081
|
+
const { nextResult, failReason } = aitState.state.payment;
|
|
2082
|
+
console.log("[@apps-in-toss/devtools] requestTossPayPaysBilling:", options.params.wrappedToken);
|
|
2083
|
+
if (aitState.state.failureModes.softResolve?.requestTossPayPaysBilling) return {
|
|
2084
|
+
false: "BILLING_FAILED",
|
|
2085
|
+
reason: "mock soft-resolve (env3 unprovisioned)"
|
|
2086
|
+
};
|
|
2087
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
2088
|
+
if (nextResult === "success") return { success: true };
|
|
2089
|
+
return {
|
|
2090
|
+
success: false,
|
|
2091
|
+
reason: failReason || "Mock billing auth failed"
|
|
2092
|
+
};
|
|
2093
|
+
}, { isSupported: () => true });
|
|
2094
|
+
//#endregion
|
|
2095
|
+
//#region src/mock/bridge-factories.ts
|
|
2096
|
+
/** Generic bridge escape hatches exposed by both SDK lines. */
|
|
2097
|
+
const asyncBridgeRegistry = {
|
|
2098
|
+
appLogin,
|
|
2099
|
+
appsInTossSignTossCert: appsInTossSignTossCert$1,
|
|
2100
|
+
checkoutPayment,
|
|
2101
|
+
closeView,
|
|
2102
|
+
generateHapticFeedback,
|
|
2103
|
+
getAnonymousKey: getAnonymousKey$1,
|
|
2104
|
+
getConsentedUserData: getConsentedUserData$1,
|
|
2105
|
+
getDeclaredAgeRange: getDeclaredAgeRange$1,
|
|
2106
|
+
getNetworkStatus,
|
|
2107
|
+
getServerTime: getServerTime$1,
|
|
2108
|
+
openPDFViewer: openPDFViewer$1,
|
|
2109
|
+
openURL,
|
|
2110
|
+
requestReview: requestReview$1,
|
|
2111
|
+
saveBase64Data: saveBase64Data$1,
|
|
2112
|
+
share
|
|
2113
|
+
};
|
|
2114
|
+
function createAsyncBridge(method) {
|
|
2115
|
+
return async (...args) => {
|
|
2116
|
+
const fn = asyncBridgeRegistry[method];
|
|
2117
|
+
if (!fn) throw new Error(`@apps-in-toss/devtools: unknown async bridge method "${method}"`);
|
|
2118
|
+
return await fn(...args);
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
2121
|
+
function createConstantBridge(name) {
|
|
2122
|
+
return () => {
|
|
2123
|
+
const constants = {
|
|
2124
|
+
deploymentId: aitState.state.deploymentId,
|
|
2125
|
+
deviceId: aitState.state.deviceId,
|
|
2126
|
+
groupId: aitState.state.groupId,
|
|
2127
|
+
locale: aitState.state.locale,
|
|
2128
|
+
operationalEnvironment: aitState.state.environment,
|
|
2129
|
+
platformOS: aitState.state.platform,
|
|
2130
|
+
safeAreaInsets: { ...aitState.state.safeAreaInsets },
|
|
2131
|
+
schemeUri: aitState.state.schemeUri || window.location.pathname,
|
|
2132
|
+
tossAppVersion: aitState.state.appVersion
|
|
2133
|
+
};
|
|
2134
|
+
if (!(name in constants)) throw new Error(`@apps-in-toss/devtools: unknown constant bridge "${name}"`);
|
|
2135
|
+
return constants[name];
|
|
2136
|
+
};
|
|
2137
|
+
}
|
|
2138
|
+
function createEventBridge(method) {
|
|
2139
|
+
return (args) => {
|
|
2140
|
+
if (method === "onVisibilityChangedByTransparentServiceWeb") return onVisibilityChangedByTransparentServiceWeb({
|
|
2141
|
+
options: args.options,
|
|
2142
|
+
onEvent: args.onEvent,
|
|
2143
|
+
onError: (error) => args.onError(error instanceof Error ? error : new Error(String(error)))
|
|
2144
|
+
});
|
|
2145
|
+
throw new Error(`@apps-in-toss/devtools: unknown event bridge method "${method}"`);
|
|
2146
|
+
};
|
|
2147
|
+
}
|
|
2148
|
+
//#endregion
|
|
2149
|
+
//#region src/mock/game/index.ts
|
|
2150
|
+
async function grantPromotionReward$1(params) {
|
|
2151
|
+
console.log("[@apps-in-toss/devtools] grantPromotionReward:", params.params);
|
|
2152
|
+
if (aitState.state.failureModes.softResolve?.grantPromotionReward) return {
|
|
2153
|
+
errorCode: "PROMOTION_NOT_FOUND",
|
|
2154
|
+
message: "no active promotion"
|
|
2155
|
+
};
|
|
2156
|
+
return { key: `mock-reward-${Date.now()}` };
|
|
2157
|
+
}
|
|
2158
|
+
async function grantPromotionRewardForGame$1(params) {
|
|
2159
|
+
console.log("[@apps-in-toss/devtools] grantPromotionRewardForGame:", params.params);
|
|
2160
|
+
if (aitState.state.failureModes.softResolve?.grantPromotionRewardForGame) return {
|
|
2161
|
+
errorCode: "PROMOTION_NOT_FOUND",
|
|
2162
|
+
message: "no active promotion"
|
|
2163
|
+
};
|
|
2164
|
+
return { key: `mock-reward-${Date.now()}` };
|
|
2165
|
+
}
|
|
2166
|
+
async function submitGameCenterLeaderBoardScore$1(params) {
|
|
2167
|
+
aitState.patch("game", { leaderboardScores: [...aitState.state.game.leaderboardScores, {
|
|
2168
|
+
score: params.score,
|
|
2169
|
+
timestamp: Date.now()
|
|
2170
|
+
}] });
|
|
2171
|
+
return { statusCode: "SUCCESS" };
|
|
2172
|
+
}
|
|
2173
|
+
async function getGameCenterGameProfile$1() {
|
|
2174
|
+
const profile = aitState.state.game.profile;
|
|
2175
|
+
if (!profile) return { statusCode: "PROFILE_NOT_FOUND" };
|
|
2176
|
+
return {
|
|
2177
|
+
statusCode: "SUCCESS",
|
|
2178
|
+
gameSessionId: "mock-session",
|
|
2179
|
+
nickname: profile.nickname,
|
|
2180
|
+
profileImageUri: profile.profileImageUri
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
2183
|
+
async function openGameCenterLeaderboard$1() {
|
|
2184
|
+
console.log("[@apps-in-toss/devtools] openGameCenterLeaderboard (no-op in browser)");
|
|
2185
|
+
}
|
|
2186
|
+
function contactsViral$1(params) {
|
|
2187
|
+
setTimeout(() => {
|
|
2188
|
+
params.onEvent({
|
|
2189
|
+
type: "close",
|
|
2190
|
+
data: {
|
|
2191
|
+
closeReason: "noReward",
|
|
2192
|
+
sentRewardsCount: 0
|
|
2193
|
+
}
|
|
2194
|
+
});
|
|
2195
|
+
}, 500);
|
|
2196
|
+
return () => {};
|
|
2197
|
+
}
|
|
2198
|
+
//#endregion
|
|
2199
|
+
//#region src/mock/notification.ts
|
|
2200
|
+
/**
|
|
2201
|
+
* 알림 동의 mock
|
|
2202
|
+
*
|
|
2203
|
+
* 상류 SDK 타입 선언은 `requestNotificationAgreement(params)`이 callback-style로
|
|
2204
|
+
* 즉시 cancel 함수를 반환한다고 선언하지만, 실기기(2.x×iOS) capture는 반환값이
|
|
2205
|
+
* 함수가 아니라 **object**임을 보였다(devtools#806 — env3 재캡처, "Expected
|
|
2206
|
+
* function, received object" 단언 실패 2건: happy-default cancel 단언 +
|
|
2207
|
+
* A1-빈-templateCode 단언, 동일 원인 이중 측정). object의 내부 shape은 이번
|
|
2208
|
+
* run에서 미기록(단언이 "function인가"에서 끊겨 뒤 관측이 안 남음).
|
|
2209
|
+
*
|
|
2210
|
+
* mock은 #775 원칙대로 선언 타입은 상류와 동일하게 두고(`__typecheck.ts`/
|
|
2211
|
+
* `__typecheck-2x.ts`가 계속 컴파일되도록) 반환값만 실측 있는 수준까지
|
|
2212
|
+
* 캐스트한다 — 1차 착수는 "함수가 아니라 object"까지만, shape은 과잉 발명하지
|
|
2213
|
+
* 않는다(#783 "측정 밖 확장 금지"). 다음 재캡처에서 object keys가 잡히면 그때
|
|
2214
|
+
* shape을 채운다.
|
|
2215
|
+
*
|
|
2216
|
+
* 결과는 panel(Notifications 탭)이 토글한
|
|
2217
|
+
* `aitState.state.notification.nextResult`를 그대로 사용한다.
|
|
2218
|
+
*
|
|
2219
|
+
* `agreementRejected`도 정상 결과의 한 종류이므로 `onEvent`로 전달한다.
|
|
2220
|
+
* `onError`는 `onEvent` 호출 자체가 throw할 때만 들어간다 (실제 SDK도 reject를
|
|
2221
|
+
* error가 아닌 event type으로 표현한다).
|
|
2222
|
+
*/
|
|
2223
|
+
const _requestNotificationAgreementImpl = (params) => {
|
|
2224
|
+
Promise.resolve().then(async () => {
|
|
2225
|
+
const failureCode = aitState.state.failureModes.requestNotificationAgreement;
|
|
2226
|
+
if (failureCode) {
|
|
2227
|
+
await params.onError(buildNativeError(failureCode));
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
const type = aitState.state.notification.nextResult;
|
|
2231
|
+
console.log("[@apps-in-toss/devtools] requestNotificationAgreement:", params.options.templateCode, "→", type);
|
|
2232
|
+
try {
|
|
2233
|
+
params.onEvent({ type });
|
|
2234
|
+
} catch (e) {
|
|
2235
|
+
await params.onError(e);
|
|
2236
|
+
}
|
|
2237
|
+
});
|
|
2238
|
+
return {};
|
|
2239
|
+
};
|
|
2240
|
+
const requestNotificationAgreement$1 = Object.assign(_requestNotificationAgreementImpl, { isSupported: () => true });
|
|
2241
|
+
//#endregion
|
|
2242
|
+
//#region src/mock/partner/index.ts
|
|
2243
|
+
const partner = {
|
|
2244
|
+
async addAccessoryButton(options) {
|
|
2245
|
+
console.log("[@apps-in-toss/devtools] partner.addAccessoryButton:", options);
|
|
2246
|
+
},
|
|
2247
|
+
async removeAccessoryButton() {
|
|
2248
|
+
console.log("[@apps-in-toss/devtools] partner.removeAccessoryButton");
|
|
2249
|
+
}
|
|
2250
|
+
};
|
|
2251
|
+
//#endregion
|
|
2252
|
+
//#region src/mock/preset-store.ts
|
|
2253
|
+
const PREFIX = "__ait_preset:";
|
|
2254
|
+
function safeLocalStorage() {
|
|
2255
|
+
try {
|
|
2256
|
+
if (typeof localStorage === "undefined") return null;
|
|
2257
|
+
return localStorage;
|
|
2258
|
+
} catch {
|
|
2259
|
+
return null;
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
function isObject(v) {
|
|
2263
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2264
|
+
}
|
|
2265
|
+
/**
|
|
2266
|
+
* Storage에서 읽은 임의 JSON을 MockPreset으로 검증. id/label 필수, state는
|
|
2267
|
+
* object여야 함. 실패하면 null — caller가 storage entry를 무시하거나 정리하면 된다.
|
|
2268
|
+
*
|
|
2269
|
+
* `state`의 내부 키/값은 검증하지 않는다. `applyPreset`이 `pickKnownKeys`로
|
|
2270
|
+
* 키만 거른 뒤 그대로 state에 패치하므로 잘못된 enum 값이 통과될 수 있지만,
|
|
2271
|
+
* mock state라 보안 위협은 없다 — 새 enum 값이 추가됐을 때 저장된 preset을
|
|
2272
|
+
* reject하지 않으려는 의도.
|
|
2273
|
+
*/
|
|
2274
|
+
function parsePreset(raw) {
|
|
2275
|
+
try {
|
|
2276
|
+
const parsed = JSON.parse(raw);
|
|
2277
|
+
if (!isObject(parsed)) return null;
|
|
2278
|
+
const { id, label, description, state } = parsed;
|
|
2279
|
+
if (typeof id !== "string" || id.length === 0) return null;
|
|
2280
|
+
if (typeof label !== "string" || label.length === 0) return null;
|
|
2281
|
+
if (!isObject(state)) return null;
|
|
2282
|
+
return {
|
|
2283
|
+
id,
|
|
2284
|
+
label,
|
|
2285
|
+
description: typeof description === "string" ? description : void 0,
|
|
2286
|
+
state
|
|
2287
|
+
};
|
|
2288
|
+
} catch {
|
|
2289
|
+
return null;
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
function listUserPresets() {
|
|
2293
|
+
const ls = safeLocalStorage();
|
|
2294
|
+
if (!ls) return [];
|
|
2295
|
+
const out = [];
|
|
2296
|
+
for (let i = 0; i < ls.length; i++) {
|
|
2297
|
+
const key = ls.key(i);
|
|
2298
|
+
if (!key?.startsWith(PREFIX)) continue;
|
|
2299
|
+
const raw = ls.getItem(key);
|
|
2300
|
+
if (!raw) continue;
|
|
2301
|
+
const preset = parsePreset(raw);
|
|
2302
|
+
if (preset) out.push(preset);
|
|
2303
|
+
}
|
|
2304
|
+
return out.sort((a, b) => a.label.localeCompare(b.label));
|
|
2305
|
+
}
|
|
2306
|
+
/**
|
|
2307
|
+
* Preset을 저장한다. label에서 slug를 derive — 같은 slug가 이미 있으면 `-2`, `-3`
|
|
2308
|
+
* suffix를 붙여 새 entry를 만든다 (기존 entry 덮어쓰기 아님). UI는 label만 받으면 된다.
|
|
2309
|
+
*
|
|
2310
|
+
* Throws:
|
|
2311
|
+
* - label trim한 뒤 빈 문자열일 때
|
|
2312
|
+
* - localStorage 미가용 환경일 때 (SSR 등)
|
|
2313
|
+
* - `setItem` 실패 (`QuotaExceededError` 등) — caller가 처리해야 함
|
|
2314
|
+
*/
|
|
2315
|
+
function saveUserPreset(label, state, description) {
|
|
2316
|
+
const trimmed = label.trim();
|
|
2317
|
+
if (trimmed.length === 0) throw new Error("Preset label cannot be empty");
|
|
2318
|
+
const ls = safeLocalStorage();
|
|
2319
|
+
if (!ls) throw new Error("localStorage not available");
|
|
2320
|
+
const id = generateId(trimmed, ls);
|
|
2321
|
+
const preset = {
|
|
2322
|
+
id,
|
|
2323
|
+
label: trimmed,
|
|
2324
|
+
state,
|
|
2325
|
+
...description !== void 0 && description.length > 0 ? { description } : {}
|
|
2326
|
+
};
|
|
2327
|
+
ls.setItem(PREFIX + id, JSON.stringify(preset));
|
|
2328
|
+
return preset;
|
|
2329
|
+
}
|
|
2330
|
+
function deleteUserPreset(id) {
|
|
2331
|
+
const ls = safeLocalStorage();
|
|
2332
|
+
if (!ls) return;
|
|
2333
|
+
ls.removeItem(PREFIX + id);
|
|
2334
|
+
}
|
|
2335
|
+
/** 충돌 시 `-2`, `-3` 등 suffix를 붙여 unique한 id 만든다. */
|
|
2336
|
+
function generateId(label, ls) {
|
|
2337
|
+
const base = label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "preset";
|
|
2338
|
+
let candidate = base;
|
|
2339
|
+
let n = 2;
|
|
2340
|
+
while (ls.getItem(PREFIX + candidate) !== null) {
|
|
2341
|
+
candidate = `${base}-${n}`;
|
|
2342
|
+
n += 1;
|
|
2343
|
+
}
|
|
2344
|
+
return candidate;
|
|
2345
|
+
}
|
|
2346
|
+
//#endregion
|
|
2347
|
+
//#region src/mock/presets.ts
|
|
2348
|
+
const builtInPresets = [
|
|
2349
|
+
{
|
|
2350
|
+
id: "all-allowed",
|
|
2351
|
+
label: "All allowed (default-ish)",
|
|
2352
|
+
description: "모든 권한 허용, WIFI, 로그인됨, IAP success",
|
|
2353
|
+
state: {
|
|
2354
|
+
networkStatus: "WIFI",
|
|
2355
|
+
permissions: {
|
|
2356
|
+
camera: "allowed",
|
|
2357
|
+
photos: "allowed",
|
|
2358
|
+
geolocation: "allowed",
|
|
2359
|
+
clipboard: "allowed",
|
|
2360
|
+
contacts: "allowed",
|
|
2361
|
+
microphone: "allowed"
|
|
2362
|
+
},
|
|
2363
|
+
auth: { isLoggedIn: true },
|
|
2364
|
+
iap: { nextResult: "success" },
|
|
2365
|
+
ads: { forceNoFill: false },
|
|
2366
|
+
payment: {
|
|
2367
|
+
nextResult: "success",
|
|
2368
|
+
failReason: ""
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
},
|
|
2372
|
+
{
|
|
2373
|
+
id: "permission-denied",
|
|
2374
|
+
label: "Permissions denied",
|
|
2375
|
+
description: "camera / photos / geolocation / contacts 거부",
|
|
2376
|
+
state: { permissions: {
|
|
2377
|
+
camera: "denied",
|
|
2378
|
+
photos: "denied",
|
|
2379
|
+
geolocation: "denied",
|
|
2380
|
+
contacts: "denied"
|
|
2381
|
+
} }
|
|
2382
|
+
},
|
|
2383
|
+
{
|
|
2384
|
+
id: "offline",
|
|
2385
|
+
label: "Offline",
|
|
2386
|
+
description: "getNetworkStatus → OFFLINE, IAP NETWORK_ERROR",
|
|
2387
|
+
state: {
|
|
2388
|
+
networkStatus: "OFFLINE",
|
|
2389
|
+
iap: { nextResult: "NETWORK_ERROR" },
|
|
2390
|
+
payment: {
|
|
2391
|
+
nextResult: "fail",
|
|
2392
|
+
failReason: "NETWORK_ERROR"
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
},
|
|
2396
|
+
{
|
|
2397
|
+
id: "logged-out",
|
|
2398
|
+
label: "Logged out",
|
|
2399
|
+
description: "auth.isLoggedIn=false. login flow 검증용",
|
|
2400
|
+
state: { auth: { isLoggedIn: false } }
|
|
2401
|
+
},
|
|
2402
|
+
{
|
|
2403
|
+
id: "iap-pending",
|
|
2404
|
+
label: "IAP payment pending",
|
|
2405
|
+
description: "결제 진행 중 분기 검증",
|
|
2406
|
+
state: { iap: { nextResult: "PAYMENT_PENDING" } }
|
|
2407
|
+
},
|
|
2408
|
+
{
|
|
2409
|
+
id: "ads-no-fill",
|
|
2410
|
+
label: "Ads — no fill",
|
|
2411
|
+
description: "광고 fill 실패 분기 검증",
|
|
2412
|
+
state: {
|
|
2413
|
+
networkStatus: "WIFI",
|
|
2414
|
+
ads: { forceNoFill: true }
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
];
|
|
2418
|
+
/**
|
|
2419
|
+
* Preset의 nested slice를 검증된 키만 골라서 풀어낸다. Forward-compat 차원에서
|
|
2420
|
+
* 알지 못하는 키는 drop, drop된 키 전부를 모아 한 번에 warn한다.
|
|
2421
|
+
*
|
|
2422
|
+
* Value 단위 검증은 하지 않는다 — `permissions.camera`에 enum 외 값이 들어와도
|
|
2423
|
+
* 그대로 통과한다. mock state라 잘못된 값은 mock 함수 분기 결과만 흔든다.
|
|
2424
|
+
* 새 enum 값이 추가됐을 때 저장된 preset을 reject하지 않으려는 의도.
|
|
2425
|
+
*/
|
|
2426
|
+
function pickKnownKeys(input, allowed) {
|
|
2427
|
+
if (typeof input !== "object" || input === null) return {};
|
|
2428
|
+
const out = {};
|
|
2429
|
+
const dropped = [];
|
|
2430
|
+
for (const [key, value] of Object.entries(input)) if (allowed.includes(key)) out[key] = value;
|
|
2431
|
+
else dropped.push(key);
|
|
2432
|
+
if (dropped.length > 0) console.warn(`[@apps-in-toss/devtools] Preset dropped unknown keys: ${dropped.join(", ")}`);
|
|
2433
|
+
return out;
|
|
2434
|
+
}
|
|
2435
|
+
const PERMISSION_KEYS = [
|
|
2436
|
+
"camera",
|
|
2437
|
+
"photos",
|
|
2438
|
+
"geolocation",
|
|
2439
|
+
"clipboard",
|
|
2440
|
+
"contacts",
|
|
2441
|
+
"microphone"
|
|
2442
|
+
];
|
|
2443
|
+
const AUTH_KEYS = [
|
|
2444
|
+
"isLoggedIn",
|
|
2445
|
+
"isTossLoginIntegrated",
|
|
2446
|
+
"userKeyHash"
|
|
2447
|
+
];
|
|
2448
|
+
const IAP_KEYS = ["nextResult"];
|
|
2449
|
+
const ADS_KEYS = [
|
|
2450
|
+
"isLoaded",
|
|
2451
|
+
"nextEvent",
|
|
2452
|
+
"forceNoFill",
|
|
2453
|
+
"lastEvent"
|
|
2454
|
+
];
|
|
2455
|
+
const PAYMENT_KEYS = ["nextResult", "failReason"];
|
|
2456
|
+
/**
|
|
2457
|
+
* Preset state를 현재 `aitState`에 적용한다. 정의된 키만 덮어쓰고, 알지 못하는 키는
|
|
2458
|
+
* 조용히 drop한다 (한 번 warn). 여러 슬라이스를 적용해도 listener notify는 한 번이다
|
|
2459
|
+
* (`aitState.transaction` 사용 — panel re-render 폭주 방지).
|
|
2460
|
+
*/
|
|
2461
|
+
function applyPreset(state) {
|
|
2462
|
+
aitState.transaction(() => {
|
|
2463
|
+
if (state.networkStatus !== void 0) aitState.update({ networkStatus: state.networkStatus });
|
|
2464
|
+
if (state.permissions !== void 0) aitState.patch("permissions", pickKnownKeys(state.permissions, PERMISSION_KEYS));
|
|
2465
|
+
if (state.auth !== void 0) aitState.patch("auth", pickKnownKeys(state.auth, AUTH_KEYS));
|
|
2466
|
+
if (state.iap !== void 0) {
|
|
2467
|
+
const picked = pickKnownKeys(state.iap, IAP_KEYS);
|
|
2468
|
+
aitState.patch("iap", picked);
|
|
2469
|
+
}
|
|
2470
|
+
if (state.ads !== void 0) aitState.patch("ads", pickKnownKeys(state.ads, ADS_KEYS));
|
|
2471
|
+
if (state.payment !== void 0) aitState.patch("payment", pickKnownKeys(state.payment, PAYMENT_KEYS));
|
|
2472
|
+
});
|
|
2473
|
+
}
|
|
2474
|
+
/**
|
|
2475
|
+
* Preset의 모든 정의된 슬라이스가 현재 state와 일치하는지 검사. UI에서 dirty
|
|
2476
|
+
* indicator를 그릴 때 쓴다.
|
|
2477
|
+
*
|
|
2478
|
+
* 일치한다 = preset이 정의한 키 전부가 그대로다. preset이 정의하지 않은 키는
|
|
2479
|
+
* 비교 대상이 아니다 — preset은 partial이므로 다른 토글이 바뀌어도 dirty가 아니다.
|
|
2480
|
+
*/
|
|
2481
|
+
function matchesPreset(snapshot, preset) {
|
|
2482
|
+
if (preset.networkStatus !== void 0 && snapshot.networkStatus !== preset.networkStatus) return false;
|
|
2483
|
+
if (preset.permissions !== void 0) for (const k of PERMISSION_KEYS) {
|
|
2484
|
+
const want = preset.permissions[k];
|
|
2485
|
+
if (want !== void 0 && snapshot.permissions[k] !== want) return false;
|
|
2486
|
+
}
|
|
2487
|
+
if (preset.auth !== void 0) for (const k of AUTH_KEYS) {
|
|
2488
|
+
const want = preset.auth[k];
|
|
2489
|
+
if (want !== void 0 && snapshot.auth[k] !== want) return false;
|
|
2490
|
+
}
|
|
2491
|
+
if (preset.iap !== void 0) {
|
|
2492
|
+
if (preset.iap.nextResult !== void 0 && snapshot.iap.nextResult !== preset.iap.nextResult) return false;
|
|
2493
|
+
}
|
|
2494
|
+
if (preset.ads !== void 0) {
|
|
2495
|
+
if (preset.ads.forceNoFill !== void 0 && snapshot.ads.forceNoFill !== preset.ads.forceNoFill) return false;
|
|
2496
|
+
if (preset.ads.isLoaded !== void 0 && snapshot.ads.isLoaded !== preset.ads.isLoaded) return false;
|
|
2497
|
+
if (preset.ads.nextEvent !== void 0 && snapshot.ads.nextEvent !== preset.ads.nextEvent) return false;
|
|
2498
|
+
}
|
|
2499
|
+
if (preset.payment !== void 0) for (const k of PAYMENT_KEYS) {
|
|
2500
|
+
const want = preset.payment[k];
|
|
2501
|
+
if (want !== void 0 && snapshot.payment[k] !== want) return false;
|
|
2502
|
+
}
|
|
2503
|
+
return true;
|
|
2504
|
+
}
|
|
2505
|
+
/**
|
|
2506
|
+
* 현재 state에서 preset에 저장할 만한 슬라이스를 추출. "save current as preset"에서 쓴다.
|
|
2507
|
+
*/
|
|
2508
|
+
function captureCurrentState(snapshot) {
|
|
2509
|
+
return {
|
|
2510
|
+
networkStatus: snapshot.networkStatus,
|
|
2511
|
+
permissions: { ...snapshot.permissions },
|
|
2512
|
+
auth: {
|
|
2513
|
+
isLoggedIn: snapshot.auth.isLoggedIn,
|
|
2514
|
+
isTossLoginIntegrated: snapshot.auth.isTossLoginIntegrated,
|
|
2515
|
+
userKeyHash: snapshot.auth.userKeyHash
|
|
2516
|
+
},
|
|
2517
|
+
iap: { nextResult: snapshot.iap.nextResult },
|
|
2518
|
+
ads: {
|
|
2519
|
+
forceNoFill: snapshot.ads.forceNoFill,
|
|
2520
|
+
isLoaded: snapshot.ads.isLoaded,
|
|
2521
|
+
nextEvent: snapshot.ads.nextEvent
|
|
2522
|
+
},
|
|
2523
|
+
payment: { ...snapshot.payment }
|
|
2524
|
+
};
|
|
2525
|
+
}
|
|
2526
|
+
//#endregion
|
|
2527
|
+
//#region src/mock/index.ts
|
|
2528
|
+
/**
|
|
2529
|
+
* @apps-in-toss/devtools/mock
|
|
2530
|
+
*
|
|
2531
|
+
* @apps-in-toss/web-framework의 모든 export를 mock으로 대체한다.
|
|
2532
|
+
* 번들러 alias로 원본 대신 이 모듈이 resolve된다.
|
|
2533
|
+
*/
|
|
2534
|
+
installBridges();
|
|
2535
|
+
//#endregion
|
|
2536
|
+
//#region src/mock/sdk-support.ts
|
|
2537
|
+
/**
|
|
2538
|
+
* Attach the metadata exposed by web-framework 3.x without wrapping the
|
|
2539
|
+
* function. Keeping identity intact matters for the deprecated flat aliases
|
|
2540
|
+
* that are also referenced by the new domain objects.
|
|
2541
|
+
*/
|
|
2542
|
+
function withSdkSupport(fn, metadata) {
|
|
2543
|
+
return Object.assign(fn, metadata, { isSupported: () => true });
|
|
2544
|
+
}
|
|
2545
|
+
//#endregion
|
|
2546
|
+
//#region src/mock/domains-3x.ts
|
|
2547
|
+
function supported(fn, minTossAppVersion, minOsVersion) {
|
|
2548
|
+
return withSdkSupport(fn, {
|
|
2549
|
+
...minTossAppVersion ? { MIN_TOSS_APP_VERSION: minTossAppVersion } : {},
|
|
2550
|
+
...minOsVersion ? { MIN_OS_VERSION: minOsVersion } : {}
|
|
2551
|
+
});
|
|
2552
|
+
}
|
|
2553
|
+
const V = {
|
|
2554
|
+
anonymousKey: {
|
|
2555
|
+
android: "5.232.0",
|
|
2556
|
+
ios: "5.232.0"
|
|
2557
|
+
},
|
|
2558
|
+
contactsViral: {
|
|
2559
|
+
android: "5.223.0",
|
|
2560
|
+
ios: "5.223.0"
|
|
2561
|
+
},
|
|
2562
|
+
consentedData: {
|
|
2563
|
+
android: "5.264.0",
|
|
2564
|
+
ios: "5.264.0"
|
|
2565
|
+
},
|
|
2566
|
+
fileSave: {
|
|
2567
|
+
android: "5.218.0",
|
|
2568
|
+
ios: "5.216.0"
|
|
2569
|
+
},
|
|
2570
|
+
game: {
|
|
2571
|
+
android: "5.221.0",
|
|
2572
|
+
ios: "5.221.0"
|
|
2573
|
+
},
|
|
2574
|
+
iapCompleteGrant: {
|
|
2575
|
+
android: "5.233.0",
|
|
2576
|
+
ios: "5.233.0"
|
|
2577
|
+
},
|
|
2578
|
+
iapOneTime: {
|
|
2579
|
+
android: "5.219.0",
|
|
2580
|
+
ios: "5.219.0"
|
|
2581
|
+
},
|
|
2582
|
+
iapPending: {
|
|
2583
|
+
android: "5.234.0",
|
|
2584
|
+
ios: "5.231.0"
|
|
2585
|
+
},
|
|
2586
|
+
iapSubscription: {
|
|
2587
|
+
android: "5.248.0",
|
|
2588
|
+
ios: "5.249.0"
|
|
2589
|
+
},
|
|
2590
|
+
iapSubscriptionInfo: {
|
|
2591
|
+
android: "5.253.0",
|
|
2592
|
+
ios: "5.250.0"
|
|
2593
|
+
},
|
|
2594
|
+
notification: {
|
|
2595
|
+
android: "5.255.0",
|
|
2596
|
+
ios: "5.255.0"
|
|
2597
|
+
},
|
|
2598
|
+
pdf: {
|
|
2599
|
+
android: "5.261.0",
|
|
2600
|
+
ios: "5.261.0"
|
|
2601
|
+
},
|
|
2602
|
+
promotion: {
|
|
2603
|
+
android: "5.232.0",
|
|
2604
|
+
ios: "5.232.0"
|
|
2605
|
+
},
|
|
2606
|
+
review: {
|
|
2607
|
+
android: "5.253.0",
|
|
2608
|
+
ios: "5.253.0"
|
|
2609
|
+
},
|
|
2610
|
+
serverTime: {
|
|
2611
|
+
android: "5.245.0",
|
|
2612
|
+
ios: "5.245.0"
|
|
2613
|
+
},
|
|
2614
|
+
sign: {
|
|
2615
|
+
android: "5.233.0",
|
|
2616
|
+
ios: "5.233.0"
|
|
2617
|
+
},
|
|
2618
|
+
tossLogin: {
|
|
2619
|
+
android: "5.237.0",
|
|
2620
|
+
ios: "5.237.0"
|
|
2621
|
+
},
|
|
2622
|
+
tossPayBilling: {
|
|
2623
|
+
android: "5.256.0",
|
|
2624
|
+
ios: "5.256.0"
|
|
2625
|
+
},
|
|
2626
|
+
orientation: {
|
|
2627
|
+
android: "5.215.0",
|
|
2628
|
+
ios: "5.215.0"
|
|
2629
|
+
}
|
|
2630
|
+
};
|
|
2631
|
+
const getIsTossLoginIntegratedService = supported(getIsTossLoginIntegratedService$1, V.tossLogin);
|
|
2632
|
+
const getUserKeyForGame = supported(getUserKeyForGame$1, V.anonymousKey);
|
|
2633
|
+
const getAnonymousKey = supported(getAnonymousKey$1, V.anonymousKey);
|
|
2634
|
+
const appsInTossSignTossCert = supported(appsInTossSignTossCert$1, V.sign);
|
|
2635
|
+
const getConsentedUserData = supported(getConsentedUserData$1, V.consentedData);
|
|
2636
|
+
const getDeclaredAgeRange = supported(getDeclaredAgeRange$1, { ios: "5.266.0" }, { ios: "26.0" });
|
|
2637
|
+
const setDeviceOrientation = supported(setDeviceOrientation$1, V.orientation);
|
|
2638
|
+
const getServerTime = supported(getServerTime$1, V.serverTime);
|
|
2639
|
+
const requestReview = supported(requestReview$1, V.review);
|
|
2640
|
+
const openPDFViewer = supported(openPDFViewer$1, V.pdf);
|
|
2641
|
+
const saveBase64Data = supported(saveBase64Data$1, V.fileSave);
|
|
2642
|
+
const grantPromotionReward = supported(grantPromotionReward$1, V.promotion);
|
|
2643
|
+
const grantPromotionRewardForGame = supported(grantPromotionRewardForGame$1, V.promotion);
|
|
2644
|
+
const submitGameCenterLeaderBoardScore = supported(submitGameCenterLeaderBoardScore$1, V.game);
|
|
2645
|
+
const getGameCenterGameProfile = supported(getGameCenterGameProfile$1, V.game);
|
|
2646
|
+
const openGameCenterLeaderboard = supported(openGameCenterLeaderboard$1, V.game);
|
|
2647
|
+
const contactsViral = supported(contactsViral$1, V.contactsViral);
|
|
2648
|
+
const requestNotificationAgreement = supported(requestNotificationAgreement$1, V.notification);
|
|
2649
|
+
const requestTossPayPaysBilling = supported(requestTossPayPaysBilling$1, V.tossPayBilling);
|
|
2650
|
+
const getPlatformOS = () => aitState.state.platform;
|
|
2651
|
+
const getOperationalEnvironment = () => aitState.state.environment;
|
|
2652
|
+
const getLocale = () => aitState.state.locale;
|
|
2653
|
+
const getDeviceId = () => aitState.state.deviceId;
|
|
2654
|
+
const getSchemeUri = () => aitState.state.schemeUri || window.location.pathname;
|
|
2655
|
+
const getSafeAreaInsets = () => ({ ...aitState.state.safeAreaInsets });
|
|
2656
|
+
const isMinVersionSupported = (minVersions) => {
|
|
2657
|
+
if (aitState.state.environment === "sandbox") return true;
|
|
2658
|
+
const required = aitState.state.platform === "ios" ? minVersions.ios : minVersions.android;
|
|
2659
|
+
if (required === void 0 || required === "never") return false;
|
|
2660
|
+
if (required === "always") return true;
|
|
2661
|
+
const current = aitState.state.appVersion.split(".").map(Number);
|
|
2662
|
+
const minimum = required.split(".").map(Number);
|
|
2663
|
+
for (let i = 0; i < Math.max(current.length, minimum.length); i++) {
|
|
2664
|
+
if ((current[i] ?? 0) > (minimum[i] ?? 0)) return true;
|
|
2665
|
+
if ((current[i] ?? 0) < (minimum[i] ?? 0)) return false;
|
|
2666
|
+
}
|
|
2667
|
+
return true;
|
|
2668
|
+
};
|
|
2669
|
+
const loadAppsInTossAdMob = ((args) => GoogleAdMob$1.loadAppsInTossAdMob({
|
|
2670
|
+
...args,
|
|
2671
|
+
options: args.options ?? { adGroupId: "mock-ad-group" },
|
|
2672
|
+
onError: (error) => args.onError(error instanceof Error ? error : new Error(String(error)))
|
|
2673
|
+
}));
|
|
2674
|
+
loadAppsInTossAdMob.isSupported = () => true;
|
|
2675
|
+
const showAppsInTossAdMob = ((args) => GoogleAdMob$1.showAppsInTossAdMob({
|
|
2676
|
+
...args,
|
|
2677
|
+
options: args.options ?? { adGroupId: "mock-ad-group" },
|
|
2678
|
+
onError: (error) => args.onError(error instanceof Error ? error : new Error(String(error)))
|
|
2679
|
+
}));
|
|
2680
|
+
showAppsInTossAdMob.isSupported = () => true;
|
|
2681
|
+
const GoogleAdMob = {
|
|
2682
|
+
...GoogleAdMob$1,
|
|
2683
|
+
loadAppsInTossAdMob,
|
|
2684
|
+
showAppsInTossAdMob
|
|
2685
|
+
};
|
|
2686
|
+
const loadFullScreenAd = Object.assign((args) => loadFullScreenAd$1({
|
|
2687
|
+
...args,
|
|
2688
|
+
options: args.options ?? { adGroupId: "mock-ad-group" },
|
|
2689
|
+
onError: (error) => args.onError(error instanceof Error ? error : new Error(String(error)))
|
|
2690
|
+
}), { isSupported: () => true });
|
|
2691
|
+
const showFullScreenAd = Object.assign((args) => showFullScreenAd$1({
|
|
2692
|
+
...args,
|
|
2693
|
+
options: args.options ?? { adGroupId: "mock-ad-group" },
|
|
2694
|
+
onError: (error) => args.onError(error instanceof Error ? error : new Error(String(error)))
|
|
2695
|
+
}), { isSupported: () => true });
|
|
2696
|
+
const IAP = {
|
|
2697
|
+
createOneTimePurchaseOrder: supported(IAP$1.createOneTimePurchaseOrder, V.iapOneTime),
|
|
2698
|
+
createSubscriptionPurchaseOrder: supported(IAP$1.createSubscriptionPurchaseOrder, V.iapSubscription),
|
|
2699
|
+
getProductItemList: supported(IAP$1.getProductItemList, V.iapOneTime),
|
|
2700
|
+
getPendingOrders: supported(IAP$1.getPendingOrders, V.iapPending),
|
|
2701
|
+
getCompletedOrRefundedOrders: supported(IAP$1.getCompletedOrRefundedOrders, V.iapPending),
|
|
2702
|
+
completeProductGrant: supported(IAP$1.completeProductGrant, V.iapCompleteGrant),
|
|
2703
|
+
getSubscriptionInfo: supported(IAP$1.getSubscriptionInfo, V.iapSubscriptionInfo)
|
|
2704
|
+
};
|
|
2705
|
+
const Analytics = {
|
|
2706
|
+
log: eventLog,
|
|
2707
|
+
screen: Analytics$1.screen,
|
|
2708
|
+
impression: Analytics$1.impression,
|
|
2709
|
+
click: Analytics$1.click
|
|
2710
|
+
};
|
|
2711
|
+
const Clipboard = {
|
|
2712
|
+
getText: getClipboardText,
|
|
2713
|
+
setText: setClipboardText
|
|
2714
|
+
};
|
|
2715
|
+
const Device = {
|
|
2716
|
+
get locale() {
|
|
2717
|
+
return aitState.state.locale;
|
|
2718
|
+
},
|
|
2719
|
+
get os() {
|
|
2720
|
+
return aitState.state.platform;
|
|
2721
|
+
},
|
|
2722
|
+
getPhotos: fetchAlbumPhotos,
|
|
2723
|
+
getContacts: fetchContacts,
|
|
2724
|
+
getLocation: getCurrentLocation,
|
|
2725
|
+
openCamera,
|
|
2726
|
+
getAlbumItems: fetchAlbumItems,
|
|
2727
|
+
triggerHaptic: generateHapticFeedback,
|
|
2728
|
+
subscribeLocation: startUpdateLocation,
|
|
2729
|
+
openURL
|
|
2730
|
+
};
|
|
2731
|
+
const Environment = {
|
|
2732
|
+
get deviceId() {
|
|
2733
|
+
return aitState.state.deviceId;
|
|
2734
|
+
},
|
|
2735
|
+
get groupId() {
|
|
2736
|
+
return aitState.state.groupId;
|
|
2737
|
+
},
|
|
2738
|
+
get environment() {
|
|
2739
|
+
return aitState.state.environment;
|
|
2740
|
+
},
|
|
2741
|
+
get tossAppVersion() {
|
|
2742
|
+
return aitState.state.appVersion;
|
|
2743
|
+
},
|
|
2744
|
+
get deploymentId() {
|
|
2745
|
+
return aitState.state.deploymentId;
|
|
2746
|
+
},
|
|
2747
|
+
get initialURL() {
|
|
2748
|
+
return aitState.state.schemeUri || window.location.pathname;
|
|
2749
|
+
},
|
|
2750
|
+
getNetworkStatus,
|
|
2751
|
+
getServerTime
|
|
2752
|
+
};
|
|
2753
|
+
const File = {
|
|
2754
|
+
saveBase64: saveBase64Data,
|
|
2755
|
+
openPDFViewer
|
|
2756
|
+
};
|
|
2757
|
+
const Game = {
|
|
2758
|
+
openLeaderboard: openGameCenterLeaderboard,
|
|
2759
|
+
setLeaderboardScore: submitGameCenterLeaderBoardScore,
|
|
2760
|
+
getUserProfile: getGameCenterGameProfile
|
|
2761
|
+
};
|
|
2762
|
+
const Notification = { requestAgreement: requestNotificationAgreement };
|
|
2763
|
+
const Promotion = {
|
|
2764
|
+
grantReward: supported((params) => grantPromotionReward$1({ params }), V.promotion),
|
|
2765
|
+
openContactsInvite: contactsViral
|
|
2766
|
+
};
|
|
2767
|
+
const Review = { request: requestReview };
|
|
2768
|
+
const SafeArea = SafeAreaInsets;
|
|
2769
|
+
const Screen = {
|
|
2770
|
+
close: closeView,
|
|
2771
|
+
setAwakeMode: setScreenAwakeMode,
|
|
2772
|
+
setSecure: setSecureScreen,
|
|
2773
|
+
setIosSwipeBack: setIosSwipeGestureEnabled,
|
|
2774
|
+
setOrientation: setDeviceOrientation
|
|
2775
|
+
};
|
|
2776
|
+
const Share = {
|
|
2777
|
+
createLink: ({ path, ogImageUrl }) => getTossShareLink(path, ogImageUrl),
|
|
2778
|
+
sendMessage: share
|
|
2779
|
+
};
|
|
2780
|
+
const TossAuth = {
|
|
2781
|
+
login: appLogin,
|
|
2782
|
+
isIntegrated: getIsTossLoginIntegratedService,
|
|
2783
|
+
sign: appsInTossSignTossCert
|
|
2784
|
+
};
|
|
2785
|
+
const TossPay = {
|
|
2786
|
+
authorize: (params) => checkoutPayment({ params }),
|
|
2787
|
+
authorizeSubscription: supported((params) => requestTossPayPaysBilling$1({ params }), V.tossPayBilling),
|
|
2788
|
+
checkoutPayment,
|
|
2789
|
+
requestTossPayPaysBilling
|
|
2790
|
+
};
|
|
2791
|
+
const User = {
|
|
2792
|
+
getAnonymousKey: supported(async () => {
|
|
2793
|
+
const result = await getAnonymousKey$1();
|
|
2794
|
+
if (!result || result === "ERROR") throw new Error("Unable to create a mock anonymous key");
|
|
2795
|
+
return result;
|
|
2796
|
+
}, V.anonymousKey),
|
|
2797
|
+
getConsentedData: getConsentedUserData,
|
|
2798
|
+
getDeclaredAgeRange
|
|
2799
|
+
};
|
|
2800
|
+
//#endregion
|
|
2801
|
+
export { Accuracy, Analytics, Clipboard, Device, Environment, FetchAlbumPhotosPermissionError, FetchContactsPermissionError, File, Game, GetClipboardTextPermissionError, GetCurrentLocationPermissionError, GoogleAdMob, IAP, NAVIGATE_BACK_MESSAGE_TYPE, Notification, OpenCameraPermissionError, PermissionError, Promotion, Review, SAFE_AREA_INSETS_MESSAGE_TYPE, SafeArea, SafeAreaInsets, Screen, SetClipboardTextPermissionError, Share, StartUpdateLocationPermissionError, Storage, TossAds, TossAuth, TossPay, User, WEB_VIEW_TYPE_MESSAGE_TYPE, aitState, appLogin, applyForwardedSafeAreaInsets, applyPreset, appsInTossEvent, appsInTossSignTossCert, builtInPresets, captureCurrentState, checkoutPayment, closeView, contactsViral, createAsyncBridge, createConstantBridge, createEventBridge, deleteUserPreset, env, eventLog, fetchAlbumItems, fetchAlbumPhotos, fetchContacts, generateHapticFeedback, getAnonymousKey, getAppsInTossGlobals, getClipboardText, getConsentedUserData, getCurrentLocation, getDeclaredAgeRange, getDefaultPlaceholderImages, getDeviceId, getGameCenterGameProfile, getGroupId, getIsTossLoginIntegratedService, getLocale, getNetworkStatus, getOperationalEnvironment, getPermission, getPlatformOS, getSafeAreaInsets, getSchemeUri, getServerTime, getTossAppVersion, getTossShareLink, getUserKeyForGame, graniteEvent, grantPromotionReward, grantPromotionRewardForGame, installBridges, installNavigateBackBridge, installSafeAreaInsetsBridge, isMinVersionSupported, isNavigateBackMessage, listUserPresets, loadFullScreenAd, matchesPreset, onVisibilityChangedByTransparentServiceWeb, openCamera, openGameCenterLeaderboard, openPDFViewer, openPermissionDialog, openURL, parseSafeAreaInsetsMessage, parseWebViewTypeMessage, partner, requestNotificationAgreement, requestPermission, requestReview, requestTossPayPaysBilling, saveBase64Data, saveUserPreset, setClipboardText, setDeviceOrientation, setIosSwipeGestureEnabled, setScreenAwakeMode, setSecureScreen, share, showFullScreenAd, startUpdateLocation, submitGameCenterLeaderBoardScore, tdsEvent };
|