@ait-co/devtools 0.1.31 → 0.1.33
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/README.en.md +11 -7
- package/README.md +24 -9
- package/dist/in-app/index.d.ts +68 -14
- package/dist/in-app/index.d.ts.map +1 -1
- package/dist/in-app/index.js +7 -0
- package/dist/in-app/index.js.map +1 -1
- package/dist/mcp/cli.js +226 -26
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +53 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/mock/index.d.ts +129 -22
- package/dist/mock/index.d.ts.map +1 -1
- package/dist/mock/index.js +298 -54
- package/dist/mock/index.js.map +1 -1
- package/dist/panel/index.d.ts.map +1 -1
- package/dist/panel/index.js +746 -115
- package/dist/panel/index.js.map +1 -1
- package/package.json +1 -1
package/dist/panel/index.js
CHANGED
|
@@ -35,6 +35,12 @@ const en = {
|
|
|
35
35
|
"env.section.safeArea": "Safe Area Insets",
|
|
36
36
|
"env.row.safeArea.top": "Top",
|
|
37
37
|
"env.row.safeArea.bottom": "Bottom",
|
|
38
|
+
"env.section.navigation": "Navigation",
|
|
39
|
+
"env.row.iosSwipeGesture": "iOS swipe-back",
|
|
40
|
+
"env.value.iosSwipeGesture.unset": "not called",
|
|
41
|
+
"env.value.iosSwipeGesture.enabled": "enabled",
|
|
42
|
+
"env.value.iosSwipeGesture.disabled": "disabled",
|
|
43
|
+
"env.hint.iosSwipeGesture": "Last value passed to setIosSwipeGestureEnabled. Switching Environment to toss lets a toss-gated guard toggle this.",
|
|
38
44
|
"env.telemetry.section": "Telemetry",
|
|
39
45
|
"env.telemetry.t0Row": "Anonymous usage signal (Tier 0)",
|
|
40
46
|
"env.telemetry.t0On": "On",
|
|
@@ -84,6 +90,10 @@ const en = {
|
|
|
84
90
|
"device.prompt.label.lng": "Lng",
|
|
85
91
|
"device.prompt.send": "Send",
|
|
86
92
|
"device.prompt.cancel": "Cancel",
|
|
93
|
+
"device.section.haptic": "Haptic",
|
|
94
|
+
"device.haptic.lastCall": "Last haptic",
|
|
95
|
+
"device.haptic.noneYet": "(none yet)",
|
|
96
|
+
"device.haptic.trigger": "Trigger haptic",
|
|
87
97
|
"viewport.section.device": "Device",
|
|
88
98
|
"viewport.row.preset": "Preset",
|
|
89
99
|
"viewport.row.orientation": "Orientation",
|
|
@@ -99,7 +109,7 @@ const en = {
|
|
|
99
109
|
"viewport.status.cssPhysical": "CSS / physical",
|
|
100
110
|
"viewport.status.safeArea": "Safe area",
|
|
101
111
|
"viewport.status.aitNavBar": "AIT nav bar",
|
|
102
|
-
"viewport.status.aitNavBarValue": "{height}px
|
|
112
|
+
"viewport.status.aitNavBarValue": "{height}px → SafeArea top · {type}",
|
|
103
113
|
"viewport.orientation.autoSuffix": "{orient} (auto)",
|
|
104
114
|
"iap.section.simulator": "IAP Simulator",
|
|
105
115
|
"iap.row.nextResult": "Next Purchase Result",
|
|
@@ -119,6 +129,9 @@ const en = {
|
|
|
119
129
|
"events.row.tossLoginIntegrated": "Toss Login Integrated",
|
|
120
130
|
"analytics.section.log": "Analytics Log ({count})",
|
|
121
131
|
"analytics.btn.clear": "Clear",
|
|
132
|
+
"analytics.calls.section": "SDK Calls ({count})",
|
|
133
|
+
"analytics.calls.btn.clear": "Clear",
|
|
134
|
+
"analytics.calls.empty": "(no SDK calls yet)",
|
|
122
135
|
"storage.section.title": "Storage ({count} items)",
|
|
123
136
|
"storage.btn.clearAll": "Clear All",
|
|
124
137
|
"storage.empty": "No items in storage",
|
|
@@ -143,6 +156,13 @@ const en = {
|
|
|
143
156
|
"ads.section.fullScreenAd": "FullScreenAd",
|
|
144
157
|
"ads.btn.load": "Load",
|
|
145
158
|
"ads.btn.show": "Show",
|
|
159
|
+
"ads.section.tossAdsBanner": "TossAds Banner",
|
|
160
|
+
"ads.row.rewardUnitType": "Reward unit type",
|
|
161
|
+
"ads.row.rewardAmount": "Reward amount",
|
|
162
|
+
"ads.btn.render": "Render",
|
|
163
|
+
"ads.btn.noFill": "No-fill",
|
|
164
|
+
"ads.btn.click": "Click",
|
|
165
|
+
"ads.btn.destroy": "Destroy",
|
|
146
166
|
"notifications.section.title": "requestNotificationAgreement",
|
|
147
167
|
"notifications.option.newAgreement": "newAgreement (first-time agree)",
|
|
148
168
|
"notifications.option.alreadyAgreed": "alreadyAgreed (already opted-in)",
|
|
@@ -186,6 +206,12 @@ const ko = {
|
|
|
186
206
|
"env.section.safeArea": "Safe Area Insets",
|
|
187
207
|
"env.row.safeArea.top": "Top",
|
|
188
208
|
"env.row.safeArea.bottom": "Bottom",
|
|
209
|
+
"env.section.navigation": "Navigation",
|
|
210
|
+
"env.row.iosSwipeGesture": "iOS swipe-back",
|
|
211
|
+
"env.value.iosSwipeGesture.unset": "미호출",
|
|
212
|
+
"env.value.iosSwipeGesture.enabled": "enabled",
|
|
213
|
+
"env.value.iosSwipeGesture.disabled": "disabled",
|
|
214
|
+
"env.hint.iosSwipeGesture": "setIosSwipeGestureEnabled의 마지막 호출값. Environment를 toss로 바꾸면 toss-gated 가드가 이 값을 토글합니다.",
|
|
189
215
|
"env.telemetry.section": "Telemetry",
|
|
190
216
|
"env.telemetry.t0Row": "익명 사용 신호 (Tier 0)",
|
|
191
217
|
"env.telemetry.t0On": "On",
|
|
@@ -235,6 +261,10 @@ const ko = {
|
|
|
235
261
|
"device.prompt.label.lng": "Lng",
|
|
236
262
|
"device.prompt.send": "Send",
|
|
237
263
|
"device.prompt.cancel": "Cancel",
|
|
264
|
+
"device.section.haptic": "Haptic",
|
|
265
|
+
"device.haptic.lastCall": "마지막 haptic",
|
|
266
|
+
"device.haptic.noneYet": "(아직 없음)",
|
|
267
|
+
"device.haptic.trigger": "Haptic 트리거",
|
|
238
268
|
"viewport.section.device": "Device",
|
|
239
269
|
"viewport.row.preset": "Preset",
|
|
240
270
|
"viewport.row.orientation": "Orientation",
|
|
@@ -250,7 +280,7 @@ const ko = {
|
|
|
250
280
|
"viewport.status.cssPhysical": "CSS / physical",
|
|
251
281
|
"viewport.status.safeArea": "Safe area",
|
|
252
282
|
"viewport.status.aitNavBar": "AIT nav bar",
|
|
253
|
-
"viewport.status.aitNavBarValue": "{height}px
|
|
283
|
+
"viewport.status.aitNavBarValue": "{height}px → SafeArea top · {type}",
|
|
254
284
|
"viewport.orientation.autoSuffix": "{orient} (auto)",
|
|
255
285
|
"iap.section.simulator": "IAP Simulator",
|
|
256
286
|
"iap.row.nextResult": "Next Purchase Result",
|
|
@@ -270,6 +300,9 @@ const ko = {
|
|
|
270
300
|
"events.row.tossLoginIntegrated": "Toss Login Integrated",
|
|
271
301
|
"analytics.section.log": "Analytics Log ({count})",
|
|
272
302
|
"analytics.btn.clear": "Clear",
|
|
303
|
+
"analytics.calls.section": "SDK Calls ({count})",
|
|
304
|
+
"analytics.calls.btn.clear": "Clear",
|
|
305
|
+
"analytics.calls.empty": "(아직 SDK 호출 없음)",
|
|
273
306
|
"storage.section.title": "Storage ({count} items)",
|
|
274
307
|
"storage.btn.clearAll": "Clear All",
|
|
275
308
|
"storage.empty": "저장된 항목이 없습니다",
|
|
@@ -294,6 +327,13 @@ const ko = {
|
|
|
294
327
|
"ads.section.fullScreenAd": "FullScreenAd",
|
|
295
328
|
"ads.btn.load": "Load",
|
|
296
329
|
"ads.btn.show": "Show",
|
|
330
|
+
"ads.section.tossAdsBanner": "TossAds 배너",
|
|
331
|
+
"ads.row.rewardUnitType": "리워드 단위 타입",
|
|
332
|
+
"ads.row.rewardAmount": "리워드 수량",
|
|
333
|
+
"ads.btn.render": "Render",
|
|
334
|
+
"ads.btn.noFill": "No-fill",
|
|
335
|
+
"ads.btn.click": "Click",
|
|
336
|
+
"ads.btn.destroy": "Destroy",
|
|
297
337
|
"notifications.section.title": "requestNotificationAgreement",
|
|
298
338
|
"notifications.option.newAgreement": "newAgreement (최초 동의)",
|
|
299
339
|
"notifications.option.alreadyAgreed": "alreadyAgreed (이미 동의됨)",
|
|
@@ -380,6 +420,8 @@ function t(key, vars) {
|
|
|
380
420
|
}
|
|
381
421
|
//#endregion
|
|
382
422
|
//#region src/mock/state.ts
|
|
423
|
+
/** SDK 호출 로그 ring buffer 상한 */
|
|
424
|
+
const SDK_CALL_LOG_MAX = 200;
|
|
383
425
|
const DEFAULT_STATE = {
|
|
384
426
|
platform: "ios",
|
|
385
427
|
environment: "sandbox",
|
|
@@ -395,6 +437,7 @@ const DEFAULT_STATE = {
|
|
|
395
437
|
primaryColor: "#3182F6"
|
|
396
438
|
},
|
|
397
439
|
networkStatus: "WIFI",
|
|
440
|
+
navigation: { iosSwipeGestureEnabled: null },
|
|
398
441
|
permissions: {
|
|
399
442
|
clipboard: "allowed",
|
|
400
443
|
contacts: "allowed",
|
|
@@ -416,7 +459,7 @@ const DEFAULT_STATE = {
|
|
|
416
459
|
accessLocation: "FINE"
|
|
417
460
|
},
|
|
418
461
|
safeAreaInsets: {
|
|
419
|
-
top:
|
|
462
|
+
top: 54,
|
|
420
463
|
bottom: 34,
|
|
421
464
|
left: 0,
|
|
422
465
|
right: 0
|
|
@@ -456,7 +499,9 @@ const DEFAULT_STATE = {
|
|
|
456
499
|
isLoaded: false,
|
|
457
500
|
nextEvent: "loaded",
|
|
458
501
|
forceNoFill: false,
|
|
459
|
-
lastEvent: null
|
|
502
|
+
lastEvent: null,
|
|
503
|
+
rewardUnitType: "coins",
|
|
504
|
+
rewardAmount: 10
|
|
460
505
|
},
|
|
461
506
|
game: {
|
|
462
507
|
profile: {
|
|
@@ -466,6 +511,7 @@ const DEFAULT_STATE = {
|
|
|
466
511
|
leaderboardScores: []
|
|
467
512
|
},
|
|
468
513
|
analyticsLog: [],
|
|
514
|
+
sdkCallLog: [],
|
|
469
515
|
deviceModes: {
|
|
470
516
|
camera: "mock",
|
|
471
517
|
photos: "mock",
|
|
@@ -578,6 +624,19 @@ var AitStateManager = class {
|
|
|
578
624
|
};
|
|
579
625
|
this._notify();
|
|
580
626
|
}
|
|
627
|
+
/**
|
|
628
|
+
* SDK 호출 로그 추가 (ring buffer, 상한 SDK_CALL_LOG_MAX).
|
|
629
|
+
* `observe()`가 호출하고, proxy의 KNOWN_UNIMPLEMENTED 경로도 직접 호출한다.
|
|
630
|
+
*/
|
|
631
|
+
logSdkCall(entry) {
|
|
632
|
+
const log = this._state.sdkCallLog;
|
|
633
|
+
const next = log.length >= SDK_CALL_LOG_MAX ? log.slice(1 - SDK_CALL_LOG_MAX) : log;
|
|
634
|
+
this._state = {
|
|
635
|
+
...this._state,
|
|
636
|
+
sdkCallLog: [...next, entry]
|
|
637
|
+
};
|
|
638
|
+
this._notify();
|
|
639
|
+
}
|
|
581
640
|
/** 이벤트 트리거 (backEvent, homeEvent 등) */
|
|
582
641
|
trigger(event) {
|
|
583
642
|
window.dispatchEvent(new CustomEvent(`__ait:${event}`));
|
|
@@ -1050,7 +1109,7 @@ function readGlobalString(key) {
|
|
|
1050
1109
|
}
|
|
1051
1110
|
const TELEMETRY_ENDPOINT = readGlobalString("__TELEMETRY_ENDPOINT__") ?? "https://t.aitc.dev";
|
|
1052
1111
|
function getVersion() {
|
|
1053
|
-
return "0.1.
|
|
1112
|
+
return "0.1.33";
|
|
1054
1113
|
}
|
|
1055
1114
|
let panelVisibleSince = null;
|
|
1056
1115
|
let accumulatedMs = 0;
|
|
@@ -1587,28 +1646,39 @@ const PANEL_STYLES = `
|
|
|
1587
1646
|
}
|
|
1588
1647
|
html.ait-viewport-framed body {
|
|
1589
1648
|
border-radius: 36px;
|
|
1649
|
+
/* Reserve the status bar strip above the WebView so the notch sits outside
|
|
1650
|
+
the body (OS notch is outside the WebView; env top=0). */
|
|
1651
|
+
margin-top: 74px;
|
|
1590
1652
|
box-shadow:
|
|
1591
1653
|
0 0 0 10px #1a1a2e,
|
|
1592
1654
|
0 0 0 12px #3a3a5a,
|
|
1593
1655
|
0 24px 48px rgba(0,0,0,0.5);
|
|
1594
1656
|
}
|
|
1595
1657
|
|
|
1596
|
-
/* Notch / Dynamic Island / punch-hole
|
|
1658
|
+
/* Notch / Dynamic Island / punch-hole — drawn in the status bar strip ABOVE
|
|
1659
|
+
the WebView (negative top puts it in the reserved margin), so it never
|
|
1660
|
+
overlaps the nav bar (which sits at the body's top edge). */
|
|
1597
1661
|
.ait-notch {
|
|
1598
1662
|
position: absolute;
|
|
1599
|
-
top: 0;
|
|
1600
1663
|
left: 50%;
|
|
1601
1664
|
transform: translateX(-50%);
|
|
1602
1665
|
background: #000;
|
|
1603
1666
|
z-index: 10;
|
|
1604
1667
|
pointer-events: none;
|
|
1605
1668
|
}
|
|
1606
|
-
.ait-notch-dynamic-island {
|
|
1669
|
+
.ait-notch-dynamic-island {
|
|
1670
|
+
top: -44px;
|
|
1671
|
+
width: 126px; height: 37px; border-radius: 20px;
|
|
1672
|
+
}
|
|
1607
1673
|
.ait-notch-pill {
|
|
1674
|
+
top: -50px;
|
|
1608
1675
|
width: 160px; height: 30px;
|
|
1609
1676
|
border-bottom-left-radius: 20px; border-bottom-right-radius: 20px;
|
|
1610
1677
|
}
|
|
1611
|
-
.ait-notch-punch-hole {
|
|
1678
|
+
.ait-notch-punch-hole {
|
|
1679
|
+
top: -40px;
|
|
1680
|
+
width: 12px; height: 12px; border-radius: 50%;
|
|
1681
|
+
}
|
|
1612
1682
|
|
|
1613
1683
|
/* Home indicator pill (bottom of body, iPhones with safe-area bottom > 0) */
|
|
1614
1684
|
.ait-home-indicator {
|
|
@@ -1624,12 +1694,16 @@ const PANEL_STYLES = `
|
|
|
1624
1694
|
pointer-events: none;
|
|
1625
1695
|
}
|
|
1626
1696
|
|
|
1627
|
-
/* Apps in Toss host nav bar — sits
|
|
1697
|
+
/* Apps in Toss host nav bar — sits at the top of the WebView (body). The OS
|
|
1698
|
+
notch lives outside the WebView (env top=0), so the nav bar bottom is the
|
|
1699
|
+
content's top edge; applyViewport gives body padding-top = nav bar height
|
|
1700
|
+
so content starts exactly below it. */
|
|
1628
1701
|
.ait-navbar {
|
|
1629
1702
|
position: absolute;
|
|
1703
|
+
top: 0;
|
|
1630
1704
|
left: 0;
|
|
1631
1705
|
right: 0;
|
|
1632
|
-
height:
|
|
1706
|
+
height: 54px; /* AIT_NAV_BAR_HEIGHT_PARTNER (relay 실측) */
|
|
1633
1707
|
background: rgba(255, 255, 255, 0.92);
|
|
1634
1708
|
backdrop-filter: blur(8px);
|
|
1635
1709
|
display: flex;
|
|
@@ -2045,6 +2119,75 @@ const _fetchContacts = async (options) => {
|
|
|
2045
2119
|
};
|
|
2046
2120
|
withPermission(_fetchContacts, "contacts");
|
|
2047
2121
|
//#endregion
|
|
2122
|
+
//#region src/mock/device/haptic.ts
|
|
2123
|
+
/**
|
|
2124
|
+
* Haptic Feedback & saveBase64Data mock
|
|
2125
|
+
*
|
|
2126
|
+
* generateHapticFeedback — 영역 3 (하드웨어 API 관측):
|
|
2127
|
+
* - 10종 HapticFeedbackType을 navigator.vibrate 패턴으로 매핑(근사, best-effort).
|
|
2128
|
+
* - `typeof navigator.vibrate === 'function'` 가드 — API 없는 환경에서 throw 없이 skip.
|
|
2129
|
+
* - sdkCallLog에 🟡(partial)로 기록. params: { hapticType, vibrated: boolean }.
|
|
2130
|
+
* - 시그니처 불변 — __typecheck.ts의 Assert<Mock, Original> 통과.
|
|
2131
|
+
*/
|
|
2132
|
+
/**
|
|
2133
|
+
* HapticFeedbackType 10종 → navigator.vibrate 패턴 매핑.
|
|
2134
|
+
* 숫자: 진동 ms. 배열: [진동, 정지, 진동, …] 교대 패턴.
|
|
2135
|
+
*/
|
|
2136
|
+
const HAPTIC_VIBRATE_PATTERN = {
|
|
2137
|
+
tickWeak: 10,
|
|
2138
|
+
tap: 20,
|
|
2139
|
+
tickMedium: 30,
|
|
2140
|
+
softMedium: 40,
|
|
2141
|
+
basicWeak: 15,
|
|
2142
|
+
basicMedium: 50,
|
|
2143
|
+
success: [
|
|
2144
|
+
10,
|
|
2145
|
+
40,
|
|
2146
|
+
10
|
|
2147
|
+
],
|
|
2148
|
+
error: [
|
|
2149
|
+
40,
|
|
2150
|
+
30,
|
|
2151
|
+
40
|
|
2152
|
+
],
|
|
2153
|
+
wiggle: [
|
|
2154
|
+
20,
|
|
2155
|
+
20,
|
|
2156
|
+
20,
|
|
2157
|
+
20,
|
|
2158
|
+
20
|
|
2159
|
+
],
|
|
2160
|
+
confetti: [
|
|
2161
|
+
10,
|
|
2162
|
+
20,
|
|
2163
|
+
10,
|
|
2164
|
+
20,
|
|
2165
|
+
10,
|
|
2166
|
+
20,
|
|
2167
|
+
10
|
|
2168
|
+
]
|
|
2169
|
+
};
|
|
2170
|
+
async function generateHapticFeedback(options) {
|
|
2171
|
+
const timestamp = Date.now();
|
|
2172
|
+
aitState.logAnalytics({
|
|
2173
|
+
type: "haptic",
|
|
2174
|
+
params: { hapticType: options.type }
|
|
2175
|
+
});
|
|
2176
|
+
const pattern = HAPTIC_VIBRATE_PATTERN[options.type] ?? 30;
|
|
2177
|
+
const vibrated = typeof navigator.vibrate === "function" ? navigator.vibrate(pattern) : false;
|
|
2178
|
+
aitState.logSdkCall({
|
|
2179
|
+
method: "generateHapticFeedback",
|
|
2180
|
+
args: [{ type: options.type }],
|
|
2181
|
+
timestamp,
|
|
2182
|
+
status: "resolved",
|
|
2183
|
+
result: {
|
|
2184
|
+
hapticType: options.type,
|
|
2185
|
+
vibrated
|
|
2186
|
+
},
|
|
2187
|
+
fidelity: "partial"
|
|
2188
|
+
});
|
|
2189
|
+
}
|
|
2190
|
+
//#endregion
|
|
2048
2191
|
//#region src/mock/device/location.ts
|
|
2049
2192
|
/**
|
|
2050
2193
|
* Location mock (getCurrentLocation, startUpdateLocation)
|
|
@@ -2156,12 +2299,35 @@ withPermission(_startUpdateLocation, "geolocation");
|
|
|
2156
2299
|
* mock이 미구현인 API는 실 SDK에서는 존재할 수 있고, 사용자가 이를 인지하지
|
|
2157
2300
|
* 못한 채 개발을 이어가면 배포 시점에 놀라게 된다. 에러 메시지에 이슈 URL을
|
|
2158
2301
|
* 포함해 사용자가 mock 누락을 제보할 수 있게 한다.
|
|
2302
|
+
*
|
|
2303
|
+
* ## KNOWN_UNIMPLEMENTED 정책
|
|
2304
|
+
* SDK에 존재하는 것으로 알려져 있으나 현재 mock이 없는 API 이름만 이 집합에 둔다.
|
|
2305
|
+
* 이 경우에만 throw 대신 🔴 inert no-op을 반환하고 sdkCallLog에 기록한다.
|
|
2306
|
+
* 완전히 미지의 이름은 여전히 throw — "잘 되는 척" 방지.
|
|
2159
2307
|
*/
|
|
2160
2308
|
const ISSUES_URL = "https://github.com/apps-in-toss-community/devtools/issues";
|
|
2309
|
+
/**
|
|
2310
|
+
* SDK에 존재하나 mock이 아직 없는 것으로 확인된 이름 목록.
|
|
2311
|
+
* 새 API가 SDK에 추가되면 여기에 추가하고 별도 PR에서 mock 구현으로 이동한다.
|
|
2312
|
+
* 확인되지 않은 이름은 절대 여기에 추가하지 않는다 — throw가 더 안전하다.
|
|
2313
|
+
*/
|
|
2314
|
+
const KNOWN_UNIMPLEMENTED = /* @__PURE__ */ new Set([]);
|
|
2161
2315
|
function createMockProxy(moduleName, implementations) {
|
|
2162
2316
|
return new Proxy(implementations, { get(target, prop) {
|
|
2163
2317
|
if (typeof prop === "symbol") return void 0;
|
|
2164
2318
|
if (prop in target) return target[prop];
|
|
2319
|
+
const name = String(prop);
|
|
2320
|
+
if (KNOWN_UNIMPLEMENTED.has(name)) return (...args) => {
|
|
2321
|
+
console.warn(`[@ait-co/devtools] ${moduleName}.${name} is known-unimplemented (🔴 inert). Returning undefined. Please file or upvote an issue: ${ISSUES_URL}`);
|
|
2322
|
+
aitState.logSdkCall({
|
|
2323
|
+
method: `${moduleName}.${name}`,
|
|
2324
|
+
args,
|
|
2325
|
+
timestamp: Date.now(),
|
|
2326
|
+
status: "resolved",
|
|
2327
|
+
result: void 0,
|
|
2328
|
+
fidelity: "inert"
|
|
2329
|
+
});
|
|
2330
|
+
};
|
|
2165
2331
|
throw new Error(`[@ait-co/devtools] ${moduleName}.${prop} is not mocked. This API may exist in @apps-in-toss/web-framework, but devtools' mock does not cover it yet. Please file an issue: ${ISSUES_URL}`);
|
|
2166
2332
|
} });
|
|
2167
2333
|
}
|
|
@@ -2369,19 +2535,127 @@ function renderDeviceTab() {
|
|
|
2369
2535
|
});
|
|
2370
2536
|
if (disabled) clearImagesBtn.disabled = true;
|
|
2371
2537
|
container.append(h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, t("device.section.mockImages", { count: images.length })), imageGrid, h("div", { className: "ait-btn-row" }, addBtn, defaultsBtn, clearImagesBtn)));
|
|
2538
|
+
container.appendChild(renderHapticSection());
|
|
2372
2539
|
return container;
|
|
2373
2540
|
}
|
|
2541
|
+
function renderHapticSection() {
|
|
2542
|
+
const lastResult = [...aitState.state.sdkCallLog].reverse().find((e) => e.method === "generateHapticFeedback")?.result;
|
|
2543
|
+
const lastCallValue = lastResult ? `${lastResult.hapticType} (vibrated: ${String(lastResult.vibrated)})` : t("device.haptic.noneYet");
|
|
2544
|
+
const lastCallRow = h("div", { className: "ait-row" }, h("label", {}, t("device.haptic.lastCall")), h("span", { className: "ait-value" }, lastCallValue));
|
|
2545
|
+
const hapticTypes = Object.keys(HAPTIC_VIBRATE_PATTERN);
|
|
2546
|
+
const triggerSection = h("div", { className: "ait-section-subtitle" }, t("device.haptic.trigger"));
|
|
2547
|
+
const btnRow = h("div", { className: "ait-btn-row" }, ...hapticTypes.map((type) => {
|
|
2548
|
+
const btn = h("button", {
|
|
2549
|
+
className: "ait-btn-secondary",
|
|
2550
|
+
"data-testid": `haptic-${type}-btn`
|
|
2551
|
+
}, type);
|
|
2552
|
+
btn.addEventListener("click", () => {
|
|
2553
|
+
generateHapticFeedback({ type }).then(() => {
|
|
2554
|
+
refreshPanel$1();
|
|
2555
|
+
});
|
|
2556
|
+
});
|
|
2557
|
+
return btn;
|
|
2558
|
+
}));
|
|
2559
|
+
return h("div", {
|
|
2560
|
+
className: "ait-section",
|
|
2561
|
+
"data-testid": "section-haptic"
|
|
2562
|
+
}, h("div", { className: "ait-section-title" }, t("device.section.haptic")), lastCallRow, triggerSection, btnRow);
|
|
2563
|
+
}
|
|
2564
|
+
//#endregion
|
|
2565
|
+
//#region src/mock/observe.ts
|
|
2566
|
+
/**
|
|
2567
|
+
* fn을 observe로 감싼다.
|
|
2568
|
+
*
|
|
2569
|
+
* @param apiName - 로그에 기록할 SDK 메서드 이름 (예: `'setScreenAwakeMode'`)
|
|
2570
|
+
* @param fidelity - 이 mock의 fidelity grade ('faithful' | 'partial' | 'inert')
|
|
2571
|
+
* @param fn - 실제 mock 구현체. 시그니처를 그대로 통과시킨다.
|
|
2572
|
+
* @returns fn과 동일한 타입의 래퍼 함수
|
|
2573
|
+
*/
|
|
2574
|
+
function observe(apiName, fidelity, fn) {
|
|
2575
|
+
return (...args) => {
|
|
2576
|
+
const timestamp = Date.now();
|
|
2577
|
+
const safeArgs = args.map((a) => safeSerialize(a));
|
|
2578
|
+
const result = fn(...args);
|
|
2579
|
+
if (result instanceof Promise) {
|
|
2580
|
+
aitState.logSdkCall({
|
|
2581
|
+
method: apiName,
|
|
2582
|
+
args: safeArgs,
|
|
2583
|
+
timestamp,
|
|
2584
|
+
status: "pending",
|
|
2585
|
+
fidelity
|
|
2586
|
+
});
|
|
2587
|
+
result.then((value) => {
|
|
2588
|
+
aitState.logSdkCall({
|
|
2589
|
+
method: apiName,
|
|
2590
|
+
args: safeArgs,
|
|
2591
|
+
timestamp,
|
|
2592
|
+
status: "resolved",
|
|
2593
|
+
result: safeSerialize(value),
|
|
2594
|
+
fidelity
|
|
2595
|
+
});
|
|
2596
|
+
}, (err) => {
|
|
2597
|
+
aitState.logSdkCall({
|
|
2598
|
+
method: apiName,
|
|
2599
|
+
args: safeArgs,
|
|
2600
|
+
timestamp,
|
|
2601
|
+
status: "rejected",
|
|
2602
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2603
|
+
fidelity
|
|
2604
|
+
});
|
|
2605
|
+
});
|
|
2606
|
+
return result;
|
|
2607
|
+
}
|
|
2608
|
+
aitState.logSdkCall({
|
|
2609
|
+
method: apiName,
|
|
2610
|
+
args: safeArgs,
|
|
2611
|
+
timestamp,
|
|
2612
|
+
status: "resolved",
|
|
2613
|
+
result: safeSerialize(result),
|
|
2614
|
+
fidelity
|
|
2615
|
+
});
|
|
2616
|
+
return result;
|
|
2617
|
+
};
|
|
2618
|
+
}
|
|
2619
|
+
/**
|
|
2620
|
+
* 값을 JSON-safe한 형태로 변환한다.
|
|
2621
|
+
* - null / primitive — 그대로.
|
|
2622
|
+
* - 함수 — `'[Function: name]'` 문자열.
|
|
2623
|
+
* - 기타 객체 — JSON.stringify 실패 시 `'[unserializable]'`.
|
|
2624
|
+
*/
|
|
2625
|
+
function safeSerialize(value) {
|
|
2626
|
+
if (value === null || value === void 0) return value;
|
|
2627
|
+
if (typeof value === "function") return `[Function: ${value.name || "anonymous"}]`;
|
|
2628
|
+
if (typeof value !== "object") return value;
|
|
2629
|
+
try {
|
|
2630
|
+
return JSON.parse(JSON.stringify(value));
|
|
2631
|
+
} catch {
|
|
2632
|
+
return "[unserializable]";
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2374
2635
|
//#endregion
|
|
2375
2636
|
//#region src/mock/ads/index.ts
|
|
2376
2637
|
/**
|
|
2377
2638
|
* 광고 mock (GoogleAdMob, TossAds, FullScreenAd)
|
|
2639
|
+
*
|
|
2640
|
+
* 변경 이력 (#196):
|
|
2641
|
+
* - slot 레지스트리로 TossAds destroy/destroyAll 누수 수정 (🟡→🟢)
|
|
2642
|
+
* - attachBanner BannerSlotCallbacks 발화 (onAdRendered/onAdImpression/onNoFill 등)
|
|
2643
|
+
* - initialize onInitialized/onInitializationFailed 발화
|
|
2644
|
+
* - AdMob reward 파라미터화 (state.ads.rewardUnitType/rewardAmount)
|
|
2645
|
+
* - 모든 호출 observe()로 sdkCallLog에 기록
|
|
2378
2646
|
*/
|
|
2379
2647
|
function withIsSupported(fn) {
|
|
2380
2648
|
fn.isSupported = () => true;
|
|
2381
2649
|
return fn;
|
|
2382
2650
|
}
|
|
2651
|
+
const _slotRegistry = /* @__PURE__ */ new Map();
|
|
2652
|
+
let _slotCounter = 0;
|
|
2653
|
+
function _nextSlotId(adGroupId) {
|
|
2654
|
+
_slotCounter += 1;
|
|
2655
|
+
return `mock-slot-${adGroupId}-${_slotCounter}`;
|
|
2656
|
+
}
|
|
2383
2657
|
const GoogleAdMob = createMockProxy("GoogleAdMob", {
|
|
2384
|
-
loadAppsInTossAdMob: withIsSupported((args) => {
|
|
2658
|
+
loadAppsInTossAdMob: withIsSupported(observe("GoogleAdMob.loadAppsInTossAdMob", "faithful", (args) => {
|
|
2385
2659
|
setTimeout(() => {
|
|
2386
2660
|
if (aitState.state.ads.forceNoFill) {
|
|
2387
2661
|
args.onError(/* @__PURE__ */ new Error("No fill"));
|
|
@@ -2394,8 +2668,8 @@ const GoogleAdMob = createMockProxy("GoogleAdMob", {
|
|
|
2394
2668
|
});
|
|
2395
2669
|
}, 200);
|
|
2396
2670
|
return () => {};
|
|
2397
|
-
}),
|
|
2398
|
-
showAppsInTossAdMob: withIsSupported((args) => {
|
|
2671
|
+
})),
|
|
2672
|
+
showAppsInTossAdMob: withIsSupported(observe("GoogleAdMob.showAppsInTossAdMob", "faithful", (args) => {
|
|
2399
2673
|
if (!aitState.state.ads.isLoaded) {
|
|
2400
2674
|
args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
|
|
2401
2675
|
return () => {};
|
|
@@ -2404,11 +2678,12 @@ const GoogleAdMob = createMockProxy("GoogleAdMob", {
|
|
|
2404
2678
|
setTimeout(() => args.onEvent({ type: "show" }), 100);
|
|
2405
2679
|
setTimeout(() => args.onEvent({ type: "impression" }), 150);
|
|
2406
2680
|
setTimeout(() => {
|
|
2681
|
+
const { rewardUnitType, rewardAmount } = aitState.state.ads;
|
|
2407
2682
|
args.onEvent({
|
|
2408
2683
|
type: "userEarnedReward",
|
|
2409
2684
|
data: {
|
|
2410
|
-
unitType:
|
|
2411
|
-
unitAmount:
|
|
2685
|
+
unitType: rewardUnitType,
|
|
2686
|
+
unitAmount: rewardAmount
|
|
2412
2687
|
}
|
|
2413
2688
|
});
|
|
2414
2689
|
}, 1e3);
|
|
@@ -2417,14 +2692,18 @@ const GoogleAdMob = createMockProxy("GoogleAdMob", {
|
|
|
2417
2692
|
aitState.patch("ads", { isLoaded: false });
|
|
2418
2693
|
}, 1500);
|
|
2419
2694
|
return () => {};
|
|
2420
|
-
}),
|
|
2421
|
-
isAppsInTossAdMobLoaded: withIsSupported(async (_options) => aitState.state.ads.isLoaded)
|
|
2695
|
+
})),
|
|
2696
|
+
isAppsInTossAdMobLoaded: withIsSupported(observe("GoogleAdMob.isAppsInTossAdMobLoaded", "faithful", async (_options) => aitState.state.ads.isLoaded))
|
|
2422
2697
|
});
|
|
2423
|
-
createMockProxy("TossAds", {
|
|
2424
|
-
initialize: withIsSupported((
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2698
|
+
const TossAds = createMockProxy("TossAds", {
|
|
2699
|
+
initialize: withIsSupported(observe("TossAds.initialize", "partial", (options) => {
|
|
2700
|
+
if (aitState.state.ads.forceNoFill) {
|
|
2701
|
+
options.callbacks?.onInitializationFailed?.(/* @__PURE__ */ new Error("No fill"));
|
|
2702
|
+
return;
|
|
2703
|
+
}
|
|
2704
|
+
options.callbacks?.onInitialized?.();
|
|
2705
|
+
})),
|
|
2706
|
+
attach: withIsSupported(observe("TossAds.attach", "partial", (_adGroupId, target, _options) => {
|
|
2428
2707
|
const el = typeof target === "string" ? document.querySelector(target) : target;
|
|
2429
2708
|
if (el) {
|
|
2430
2709
|
const placeholder = document.createElement("div");
|
|
@@ -2432,21 +2711,76 @@ createMockProxy("TossAds", {
|
|
|
2432
2711
|
placeholder.textContent = "[@ait-co/devtools] TossAds Placeholder";
|
|
2433
2712
|
el.appendChild(placeholder);
|
|
2434
2713
|
}
|
|
2435
|
-
}),
|
|
2436
|
-
attachBanner: withIsSupported((
|
|
2714
|
+
})),
|
|
2715
|
+
attachBanner: withIsSupported(observe("TossAds.attachBanner", "faithful", (adGroupId, target, options) => {
|
|
2437
2716
|
const el = typeof target === "string" ? document.querySelector(target) : target;
|
|
2717
|
+
const slotId = _nextSlotId(adGroupId);
|
|
2718
|
+
const placeholder = document.createElement("div");
|
|
2719
|
+
const theme = options?.theme ?? "auto";
|
|
2720
|
+
const variant = options?.variant ?? "card";
|
|
2721
|
+
const isDark = theme === "dark" || theme === "auto" && typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches;
|
|
2722
|
+
const bg = isDark ? "#1a1a1a" : "#f0f0f0";
|
|
2723
|
+
const textColor = isDark ? "#aaa" : "#666";
|
|
2724
|
+
const borderColor = isDark ? "#555" : "#999";
|
|
2725
|
+
const height = variant === "expanded" ? "120px" : "60px";
|
|
2726
|
+
placeholder.dataset.aitSlotId = slotId;
|
|
2727
|
+
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;`;
|
|
2728
|
+
placeholder.textContent = `[@ait-co/devtools] Banner Ad (${variant})`;
|
|
2438
2729
|
if (el) {
|
|
2439
|
-
const placeholder = document.createElement("div");
|
|
2440
|
-
placeholder.style.cssText = "background:#f0f0f0;border:1px dashed #999;padding:12px;text-align:center;color:#666;font-size:12px;";
|
|
2441
|
-
placeholder.textContent = "[@ait-co/devtools] Banner Ad Placeholder";
|
|
2442
2730
|
el.appendChild(placeholder);
|
|
2731
|
+
_slotRegistry.set(slotId, placeholder);
|
|
2443
2732
|
}
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2733
|
+
const destroySlot = () => {
|
|
2734
|
+
const registered = _slotRegistry.get(slotId);
|
|
2735
|
+
if (registered) {
|
|
2736
|
+
registered.remove();
|
|
2737
|
+
_slotRegistry.delete(slotId);
|
|
2738
|
+
}
|
|
2739
|
+
};
|
|
2740
|
+
setTimeout(() => {
|
|
2741
|
+
if (aitState.state.ads.forceNoFill) {
|
|
2742
|
+
options?.callbacks?.onNoFill?.({
|
|
2743
|
+
slotId,
|
|
2744
|
+
adGroupId,
|
|
2745
|
+
adMetadata: {}
|
|
2746
|
+
});
|
|
2747
|
+
options?.callbacks?.onAdFailedToRender?.({
|
|
2748
|
+
slotId,
|
|
2749
|
+
adGroupId,
|
|
2750
|
+
adMetadata: {},
|
|
2751
|
+
error: {
|
|
2752
|
+
code: 0,
|
|
2753
|
+
message: "No fill"
|
|
2754
|
+
}
|
|
2755
|
+
});
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2758
|
+
const eventPayload = {
|
|
2759
|
+
slotId,
|
|
2760
|
+
adGroupId,
|
|
2761
|
+
adMetadata: {
|
|
2762
|
+
creativeId: `mock-creative-${slotId}`,
|
|
2763
|
+
requestId: `mock-req-${slotId}`
|
|
2764
|
+
}
|
|
2765
|
+
};
|
|
2766
|
+
options?.callbacks?.onAdRendered?.(eventPayload);
|
|
2767
|
+
options?.callbacks?.onAdImpression?.(eventPayload);
|
|
2768
|
+
}, 100);
|
|
2769
|
+
return { destroy: destroySlot };
|
|
2770
|
+
})),
|
|
2771
|
+
destroy: withIsSupported(observe("TossAds.destroy", "faithful", (slotId) => {
|
|
2772
|
+
const el = _slotRegistry.get(slotId);
|
|
2773
|
+
if (el) {
|
|
2774
|
+
el.remove();
|
|
2775
|
+
_slotRegistry.delete(slotId);
|
|
2776
|
+
}
|
|
2777
|
+
})),
|
|
2778
|
+
destroyAll: withIsSupported(observe("TossAds.destroyAll", "faithful", () => {
|
|
2779
|
+
for (const el of _slotRegistry.values()) el.remove();
|
|
2780
|
+
_slotRegistry.clear();
|
|
2781
|
+
}))
|
|
2448
2782
|
});
|
|
2449
|
-
const loadFullScreenAd = withIsSupported((args) => {
|
|
2783
|
+
const loadFullScreenAd = withIsSupported(observe("loadFullScreenAd", "faithful", (args) => {
|
|
2450
2784
|
setTimeout(() => {
|
|
2451
2785
|
if (aitState.state.ads.forceNoFill) {
|
|
2452
2786
|
args.onError(/* @__PURE__ */ new Error("No fill"));
|
|
@@ -2459,8 +2793,8 @@ const loadFullScreenAd = withIsSupported((args) => {
|
|
|
2459
2793
|
});
|
|
2460
2794
|
}, 200);
|
|
2461
2795
|
return () => {};
|
|
2462
|
-
});
|
|
2463
|
-
const showFullScreenAd = withIsSupported((args) => {
|
|
2796
|
+
}));
|
|
2797
|
+
const showFullScreenAd = withIsSupported(observe("showFullScreenAd", "faithful", (args) => {
|
|
2464
2798
|
if (!aitState.state.ads.isLoaded) {
|
|
2465
2799
|
args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
|
|
2466
2800
|
return () => {};
|
|
@@ -2468,7 +2802,7 @@ const showFullScreenAd = withIsSupported((args) => {
|
|
|
2468
2802
|
setTimeout(() => args.onEvent({ type: "show" }), 100);
|
|
2469
2803
|
setTimeout(() => args.onEvent({ type: "dismissed" }), 1500);
|
|
2470
2804
|
return () => {};
|
|
2471
|
-
});
|
|
2805
|
+
}));
|
|
2472
2806
|
//#endregion
|
|
2473
2807
|
//#region src/panel/tabs/ads.ts
|
|
2474
2808
|
function recordEvent(type) {
|
|
@@ -2503,6 +2837,57 @@ function adSection(title, onLoad, onShow, disabled) {
|
|
|
2503
2837
|
showBtn.addEventListener("click", onShow);
|
|
2504
2838
|
return h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, title), h("div", { className: "ait-btn-row" }, loadBtn, showBtn));
|
|
2505
2839
|
}
|
|
2840
|
+
/** TossAds 배너 인터랙티브 섹션 — Render/No-fill/Click/Destroy 버튼 */
|
|
2841
|
+
function tossAdsBannerSection(disabled) {
|
|
2842
|
+
const mountTarget = h("div", { style: "min-height:60px;background:#111;border-radius:4px;margin-bottom:6px;overflow:hidden;" });
|
|
2843
|
+
const renderBtn = h("button", { className: "ait-btn ait-btn-sm" }, t("ads.btn.render"));
|
|
2844
|
+
const noFillBtn = h("button", { className: "ait-btn ait-btn-sm" }, t("ads.btn.noFill"));
|
|
2845
|
+
const clickBtn = h("button", { className: "ait-btn ait-btn-sm" }, t("ads.btn.click"));
|
|
2846
|
+
const destroyBtn = h("button", { className: "ait-btn ait-btn-sm" }, t("ads.btn.destroy"));
|
|
2847
|
+
if (disabled) {
|
|
2848
|
+
renderBtn.disabled = true;
|
|
2849
|
+
noFillBtn.disabled = true;
|
|
2850
|
+
clickBtn.disabled = true;
|
|
2851
|
+
destroyBtn.disabled = true;
|
|
2852
|
+
}
|
|
2853
|
+
let currentHandle = null;
|
|
2854
|
+
renderBtn.addEventListener("click", () => {
|
|
2855
|
+
currentHandle?.destroy();
|
|
2856
|
+
currentHandle = null;
|
|
2857
|
+
mountTarget.innerHTML = "";
|
|
2858
|
+
currentHandle = TossAds.attachBanner("mock-banner-group", mountTarget, {
|
|
2859
|
+
theme: "auto",
|
|
2860
|
+
variant: "card",
|
|
2861
|
+
callbacks: {
|
|
2862
|
+
onAdRendered: (p) => recordEvent(`onAdRendered(${p.slotId})`),
|
|
2863
|
+
onAdImpression: (p) => recordEvent(`onAdImpression(${p.slotId})`),
|
|
2864
|
+
onAdClicked: (p) => recordEvent(`onAdClicked(${p.slotId})`),
|
|
2865
|
+
onAdFailedToRender: (p) => recordEvent(`error: onAdFailedToRender(${p.slotId})`),
|
|
2866
|
+
onNoFill: (p) => recordEvent(`error: onNoFill(${p.slotId})`)
|
|
2867
|
+
}
|
|
2868
|
+
});
|
|
2869
|
+
});
|
|
2870
|
+
noFillBtn.addEventListener("click", () => {
|
|
2871
|
+
currentHandle?.destroy();
|
|
2872
|
+
currentHandle = null;
|
|
2873
|
+
mountTarget.innerHTML = "";
|
|
2874
|
+
const slotId = `mock-slot-no-fill-${Date.now()}`;
|
|
2875
|
+
recordEvent(`error: onNoFill(${slotId})`);
|
|
2876
|
+
recordEvent(`error: onAdFailedToRender(${slotId})`);
|
|
2877
|
+
});
|
|
2878
|
+
clickBtn.addEventListener("click", () => {
|
|
2879
|
+
recordEvent("banner:clicked");
|
|
2880
|
+
});
|
|
2881
|
+
destroyBtn.addEventListener("click", () => {
|
|
2882
|
+
if (currentHandle) {
|
|
2883
|
+
currentHandle.destroy();
|
|
2884
|
+
currentHandle = null;
|
|
2885
|
+
mountTarget.innerHTML = "";
|
|
2886
|
+
recordEvent("banner:destroyed");
|
|
2887
|
+
} else recordError("No banner to destroy");
|
|
2888
|
+
});
|
|
2889
|
+
return h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, t("ads.section.tossAdsBanner")), mountTarget, h("div", { className: "ait-btn-row" }, renderBtn, noFillBtn, clickBtn, destroyBtn));
|
|
2890
|
+
}
|
|
2506
2891
|
function renderAdsTab() {
|
|
2507
2892
|
const s = aitState.state;
|
|
2508
2893
|
const disabled = !s.panelEditable;
|
|
@@ -2517,7 +2902,10 @@ function renderAdsTab() {
|
|
|
2517
2902
|
forceNoFillCb.addEventListener("change", () => {
|
|
2518
2903
|
aitState.patch("ads", { forceNoFill: forceNoFillCb.checked });
|
|
2519
2904
|
});
|
|
2520
|
-
container.append(h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, t("ads.section.state")), statusRow(t("ads.row.isLoaded"), String(s.ads.isLoaded)), h("div", { className: "ait-row" }, h("label", {}, t("ads.row.forceNoFill")), forceNoFillCb),
|
|
2905
|
+
container.append(h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, t("ads.section.state")), statusRow(t("ads.row.isLoaded"), String(s.ads.isLoaded)), h("div", { className: "ait-row" }, h("label", {}, t("ads.row.forceNoFill")), forceNoFillCb), inputRow(t("ads.row.rewardUnitType"), s.ads.rewardUnitType, (v) => aitState.patch("ads", { rewardUnitType: v }), disabled), inputRow(t("ads.row.rewardAmount"), String(s.ads.rewardAmount), (v) => {
|
|
2906
|
+
const n = Number(v);
|
|
2907
|
+
if (!Number.isNaN(n)) aitState.patch("ads", { rewardAmount: n });
|
|
2908
|
+
}, disabled), lastEventLine()), adSection(t("ads.section.googleAdMob"), () => {
|
|
2521
2909
|
GoogleAdMob.loadAppsInTossAdMob({
|
|
2522
2910
|
onEvent: (e) => recordEvent(e.type),
|
|
2523
2911
|
onError: (err) => recordError(err.message)
|
|
@@ -2528,12 +2916,13 @@ function renderAdsTab() {
|
|
|
2528
2916
|
onError: (err) => recordError(err.message)
|
|
2529
2917
|
});
|
|
2530
2918
|
}, disabled), adSection(t("ads.section.tossAds"), () => {
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2919
|
+
TossAds.initialize({ callbacks: {
|
|
2920
|
+
onInitialized: () => {
|
|
2921
|
+
aitState.patch("ads", { isLoaded: true });
|
|
2922
|
+
recordEvent("loaded");
|
|
2923
|
+
},
|
|
2924
|
+
onInitializationFailed: (err) => recordError(err.message)
|
|
2925
|
+
} });
|
|
2537
2926
|
}, () => {
|
|
2538
2927
|
if (!aitState.state.ads.isLoaded) {
|
|
2539
2928
|
recordError("Ad not loaded");
|
|
@@ -2544,7 +2933,7 @@ function renderAdsTab() {
|
|
|
2544
2933
|
recordEvent("dismissed");
|
|
2545
2934
|
aitState.patch("ads", { isLoaded: false });
|
|
2546
2935
|
}, 1500);
|
|
2547
|
-
}, disabled), adSection(t("ads.section.fullScreenAd"), () => {
|
|
2936
|
+
}, disabled), tossAdsBannerSection(disabled), adSection(t("ads.section.fullScreenAd"), () => {
|
|
2548
2937
|
loadFullScreenAd({
|
|
2549
2938
|
onEvent: (e) => recordEvent(e.type),
|
|
2550
2939
|
onError: (err) => recordError(err.message)
|
|
@@ -2559,19 +2948,37 @@ function renderAdsTab() {
|
|
|
2559
2948
|
}
|
|
2560
2949
|
//#endregion
|
|
2561
2950
|
//#region src/panel/tabs/analytics.ts
|
|
2951
|
+
const FIDELITY_BADGE = {
|
|
2952
|
+
faithful: "🟢",
|
|
2953
|
+
partial: "🟡",
|
|
2954
|
+
inert: "🔴"
|
|
2955
|
+
};
|
|
2562
2956
|
function renderAnalyticsTab() {
|
|
2563
2957
|
const disabled = !aitState.state.panelEditable;
|
|
2564
2958
|
const container = h("div");
|
|
2565
2959
|
if (disabled) container.appendChild(monitoringNotice());
|
|
2566
2960
|
const logs = aitState.state.analyticsLog;
|
|
2567
|
-
const
|
|
2568
|
-
if (disabled)
|
|
2569
|
-
|
|
2961
|
+
const clearAnalyticsBtn = h("button", { className: "ait-btn ait-btn-sm ait-btn-danger" }, t("analytics.btn.clear"));
|
|
2962
|
+
if (disabled) clearAnalyticsBtn.disabled = true;
|
|
2963
|
+
clearAnalyticsBtn.addEventListener("click", () => {
|
|
2570
2964
|
aitState.update({ analyticsLog: [] });
|
|
2571
2965
|
});
|
|
2572
|
-
container.append(h("div", { className: "ait-section" }, h("div", { className: "ait-row" }, h("div", { className: "ait-section-title" }, t("analytics.section.log", { count: logs.length })),
|
|
2966
|
+
container.append(h("div", { className: "ait-section" }, h("div", { className: "ait-row" }, h("div", { className: "ait-section-title" }, t("analytics.section.log", { count: logs.length })), clearAnalyticsBtn), ...logs.slice(-30).reverse().map((entry) => {
|
|
2573
2967
|
return h("div", { className: "ait-log-entry" }, h("span", { className: "ait-log-time" }, new Date(entry.timestamp).toLocaleTimeString()), h("span", { className: "ait-log-type" }, entry.type), JSON.stringify(entry.params));
|
|
2574
2968
|
})));
|
|
2969
|
+
const calls = aitState.state.sdkCallLog;
|
|
2970
|
+
const clearCallsBtn = h("button", { className: "ait-btn ait-btn-sm ait-btn-danger" }, t("analytics.calls.btn.clear"));
|
|
2971
|
+
if (disabled) clearCallsBtn.disabled = true;
|
|
2972
|
+
clearCallsBtn.addEventListener("click", () => {
|
|
2973
|
+
aitState.update({ sdkCallLog: [] });
|
|
2974
|
+
});
|
|
2975
|
+
container.append(h("div", { className: "ait-section" }, h("div", { className: "ait-row" }, h("div", { className: "ait-section-title" }, t("analytics.calls.section", { count: calls.length })), clearCallsBtn), calls.length === 0 ? h("div", { className: "ait-log-entry ait-log-empty" }, t("analytics.calls.empty")) : h("div", {}, ...calls.slice(-50).reverse().map((call) => {
|
|
2976
|
+
const badge = FIDELITY_BADGE[call.fidelity] ?? "⬜";
|
|
2977
|
+
const time = new Date(call.timestamp).toLocaleTimeString();
|
|
2978
|
+
const argsStr = call.args.length > 0 ? `(${call.args.map((a) => JSON.stringify(a)).join(", ")})` : "()";
|
|
2979
|
+
const statusSuffix = call.status === "rejected" ? ` ✗ ${call.error ?? "error"}` : call.status === "pending" ? " …" : "";
|
|
2980
|
+
return h("div", { className: `ait-log-entry ait-sdk-call ait-sdk-call-${call.fidelity}` }, h("span", { className: "ait-log-badge" }, badge), h("span", { className: "ait-log-method" }, call.method), h("span", { className: "ait-log-args" }, argsStr), h("span", { className: "ait-log-time" }, ` · ${time}`), statusSuffix ? h("span", { className: "ait-log-status" }, statusSuffix) : "");
|
|
2981
|
+
}))));
|
|
2575
2982
|
return container;
|
|
2576
2983
|
}
|
|
2577
2984
|
//#endregion
|
|
@@ -2590,9 +2997,20 @@ function renderEnvironmentTab() {
|
|
|
2590
2997
|
"OFFLINE",
|
|
2591
2998
|
"WWAN",
|
|
2592
2999
|
"UNKNOWN"
|
|
2593
|
-
], s.networkStatus, (v) => aitState.update({ networkStatus: v }), disabled)), h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, t("env.section.safeArea")), inputRow(t("env.row.safeArea.top"), String(s.safeAreaInsets.top), (v) => aitState.patch("safeAreaInsets", { top: Number(v) }), disabled), inputRow(t("env.row.safeArea.bottom"), String(s.safeAreaInsets.bottom), (v) => aitState.patch("safeAreaInsets", { bottom: Number(v) }), disabled)), buildLanguageSection(), buildTelemetrySection());
|
|
3000
|
+
], s.networkStatus, (v) => aitState.update({ networkStatus: v }), disabled)), h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, t("env.section.safeArea")), inputRow(t("env.row.safeArea.top"), String(s.safeAreaInsets.top), (v) => aitState.patch("safeAreaInsets", { top: Number(v) }), disabled), inputRow(t("env.row.safeArea.bottom"), String(s.safeAreaInsets.bottom), (v) => aitState.patch("safeAreaInsets", { bottom: Number(v) }), disabled)), buildNavigationSection(), buildLanguageSection(), buildTelemetrySection());
|
|
2594
3001
|
return container;
|
|
2595
3002
|
}
|
|
3003
|
+
/**
|
|
3004
|
+
* Navigation 동작 관측 (read-only) — real(토스 WebView)에서 native bridge로 발화하는
|
|
3005
|
+
* no-op API의 마지막 호출값을 보여준다. Environment를 toss로 바꾸면 toss-gated 가드
|
|
3006
|
+
* (예: sdk-example `useDisableIosSwipeGestureInToss`)가 돌면서 이 값이 토글되므로,
|
|
3007
|
+
* "toss 진입 → 가드 실행 → 관측 가능한 state 변화" 루프를 패널에서 한눈에 확인할 수 있다.
|
|
3008
|
+
*/
|
|
3009
|
+
function buildNavigationSection() {
|
|
3010
|
+
const swipe = aitState.state.navigation.iosSwipeGestureEnabled;
|
|
3011
|
+
const valueText = swipe === null ? t("env.value.iosSwipeGesture.unset") : swipe ? t("env.value.iosSwipeGesture.enabled") : t("env.value.iosSwipeGesture.disabled");
|
|
3012
|
+
return h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, t("env.section.navigation")), h("div", { className: "ait-row" }, h("label", {}, t("env.row.iosSwipeGesture")), h("span", { style: `font-family:'SF Mono','Menlo',monospace;font-size:12px;color:${swipe === null ? "#888" : "#95e6cb"}` }, valueText)), h("div", { style: "font-size:11px;color:#666;margin-top:4px" }, t("env.hint.iosSwipeGesture")));
|
|
3013
|
+
}
|
|
2596
3014
|
function buildLanguageSection() {
|
|
2597
3015
|
const current = getLocale();
|
|
2598
3016
|
const select = h("select", { className: "ait-select" });
|
|
@@ -3302,22 +3720,131 @@ async function closeView() {
|
|
|
3302
3720
|
console.log("[@ait-co/devtools] closeView called");
|
|
3303
3721
|
window.history.back();
|
|
3304
3722
|
}
|
|
3305
|
-
async
|
|
3723
|
+
observe("setScreenAwakeMode", "inert", async (options) => {
|
|
3724
|
+
console.log("[@ait-co/devtools] setScreenAwakeMode:", options.enabled);
|
|
3725
|
+
return { enabled: options.enabled };
|
|
3726
|
+
});
|
|
3727
|
+
observe("setSecureScreen", "inert", async (options) => {
|
|
3728
|
+
console.log("[@ait-co/devtools] setSecureScreen:", options.enabled);
|
|
3729
|
+
return { enabled: options.enabled };
|
|
3730
|
+
});
|
|
3731
|
+
const requestReview = observe("requestReview", "inert", async () => {
|
|
3306
3732
|
console.log("[@ait-co/devtools] requestReview called");
|
|
3307
|
-
}
|
|
3733
|
+
});
|
|
3308
3734
|
requestReview.isSupported = () => true;
|
|
3309
3735
|
async function getServerTime() {
|
|
3310
3736
|
return Date.now();
|
|
3311
3737
|
}
|
|
3312
3738
|
getServerTime.isSupported = () => true;
|
|
3313
3739
|
//#endregion
|
|
3740
|
+
//#region src/panel/device-emulation.ts
|
|
3741
|
+
/**
|
|
3742
|
+
* 기기 preset ↔ 브라우저 특성 정합
|
|
3743
|
+
*
|
|
3744
|
+
* Viewport preset이 active일 때(`none`/`custom` 아님), 그 preset이 주장하는 기기와
|
|
3745
|
+
* `navigator.userAgent`·`navigator.platform`·`window.devicePixelRatio`·`screen.*`를
|
|
3746
|
+
* 일치시킨다. 특정 기기 frame 가상환경을 제공하는 이상 UA/DPR만 호스트 데스크톱 값으로
|
|
3747
|
+
* 남으면 비일관적이기 때문이다 (#190).
|
|
3748
|
+
*
|
|
3749
|
+
* 한계 — page-JS override는 **JS 읽기값만** 바꾼다. 실 CSS media query(`@media`),
|
|
3750
|
+
* 실제 터치 이벤트, 엔진 레벨 레이아웃은 호스트 브라우저 값이 그대로다. 픽셀/입력 단위
|
|
3751
|
+
* 완전 emulation이 필요하면 Chrome DevTools device-mode(또는 CDP)를 쓴다. preset이
|
|
3752
|
+
* `none`/`custom`이면 override를 걸지 않아 일반 dev의 호스트 환경을 건드리지 않는다.
|
|
3753
|
+
*
|
|
3754
|
+
* 구현 — 대상 속성은 setter가 없어도 `configurable: true`이므로 `Object.defineProperty`로
|
|
3755
|
+
* getter를 덮어쓸 수 있다. 원복을 위해 최초 override 직전의 디스크립터를 저장한다.
|
|
3756
|
+
*/
|
|
3757
|
+
/** preset id → 플랫폼. Apple 계열은 ios, 그 외(Galaxy)는 android. */
|
|
3758
|
+
function platformForPreset(presetId) {
|
|
3759
|
+
return presetId.startsWith("iphone") || presetId.startsWith("ipad") ? "ios" : "android";
|
|
3760
|
+
}
|
|
3761
|
+
/**
|
|
3762
|
+
* preset + 토스 앱 버전으로 기기 프로필을 합성한다.
|
|
3763
|
+
*
|
|
3764
|
+
* UA는 표준 모바일 UA 뒤에 `AppsInToss TossApp/<appVersion>` 토큰을 붙인다 — #171
|
|
3765
|
+
* 실측(`AppsInToss TossApp/5.261.0`)에서 확인된 토스 WebView UA 형태.
|
|
3766
|
+
*
|
|
3767
|
+
* @param preset portrait 기준 width/height/dpr를 가진 device preset
|
|
3768
|
+
* @param appVersion `aitState.state.appVersion` (UA suffix의 버전 토큰)
|
|
3769
|
+
* @param landscape true면 screen width/height를 swap
|
|
3770
|
+
*/
|
|
3771
|
+
function buildDeviceProfile(preset, appVersion, landscape) {
|
|
3772
|
+
const platform = platformForPreset(preset.id);
|
|
3773
|
+
const tossToken = `AppsInToss TossApp/${appVersion}`;
|
|
3774
|
+
const baseUa = platform === "ios" ? "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148" : "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36";
|
|
3775
|
+
const physWidth = Math.round(preset.width * preset.dpr);
|
|
3776
|
+
const physHeight = Math.round(preset.height * preset.dpr);
|
|
3777
|
+
return {
|
|
3778
|
+
platform,
|
|
3779
|
+
userAgent: `${baseUa} ${tossToken}`,
|
|
3780
|
+
navigatorPlatform: platform === "ios" ? "iPhone" : "Linux armv8l",
|
|
3781
|
+
devicePixelRatio: preset.dpr,
|
|
3782
|
+
screenWidth: landscape ? physHeight : physWidth,
|
|
3783
|
+
screenHeight: landscape ? physWidth : physHeight
|
|
3784
|
+
};
|
|
3785
|
+
}
|
|
3786
|
+
let savedDescriptors = null;
|
|
3787
|
+
function override(target, prop, value, saved) {
|
|
3788
|
+
if (!saved.some((s) => s.target === target && s.prop === prop)) saved.push({
|
|
3789
|
+
target,
|
|
3790
|
+
prop,
|
|
3791
|
+
descriptor: Object.getOwnPropertyDescriptor(target, prop)
|
|
3792
|
+
});
|
|
3793
|
+
try {
|
|
3794
|
+
Object.defineProperty(target, prop, {
|
|
3795
|
+
configurable: true,
|
|
3796
|
+
get: () => value
|
|
3797
|
+
});
|
|
3798
|
+
} catch {}
|
|
3799
|
+
}
|
|
3800
|
+
/**
|
|
3801
|
+
* 기기 프로필을 현재 페이지의 `navigator`/`window`/`screen`에 적용한다.
|
|
3802
|
+
* 호출 시 직전 디스크립터를 저장하므로 `revertDeviceEmulation()`으로 원복 가능.
|
|
3803
|
+
*/
|
|
3804
|
+
function applyDeviceEmulation(profile) {
|
|
3805
|
+
if (typeof navigator === "undefined" || typeof window === "undefined") return;
|
|
3806
|
+
revertDeviceEmulation();
|
|
3807
|
+
const saved = [];
|
|
3808
|
+
override(navigator, "userAgent", profile.userAgent, saved);
|
|
3809
|
+
override(navigator, "platform", profile.navigatorPlatform, saved);
|
|
3810
|
+
override(window, "devicePixelRatio", profile.devicePixelRatio, saved);
|
|
3811
|
+
if (typeof screen !== "undefined") {
|
|
3812
|
+
override(screen, "width", profile.screenWidth, saved);
|
|
3813
|
+
override(screen, "height", profile.screenHeight, saved);
|
|
3814
|
+
}
|
|
3815
|
+
savedDescriptors = saved;
|
|
3816
|
+
}
|
|
3817
|
+
/** `applyDeviceEmulation`이 덮어쓴 속성을 원래 디스크립터로 되돌린다. */
|
|
3818
|
+
function revertDeviceEmulation() {
|
|
3819
|
+
if (!savedDescriptors) return;
|
|
3820
|
+
for (const { target, prop, descriptor } of savedDescriptors) try {
|
|
3821
|
+
if (descriptor) Object.defineProperty(target, prop, descriptor);
|
|
3822
|
+
else delete target[prop];
|
|
3823
|
+
} catch {}
|
|
3824
|
+
savedDescriptors = null;
|
|
3825
|
+
}
|
|
3826
|
+
/**
|
|
3827
|
+
* Viewport state를 받아 preset이 active면 emulation 적용, 아니면 원복.
|
|
3828
|
+
* `applyViewport`가 매 viewport 변경마다 호출한다.
|
|
3829
|
+
*/
|
|
3830
|
+
function syncDeviceEmulation(preset, landscape) {
|
|
3831
|
+
if (!preset || preset.id === "none" || preset.id === "custom") {
|
|
3832
|
+
revertDeviceEmulation();
|
|
3833
|
+
return;
|
|
3834
|
+
}
|
|
3835
|
+
const profile = buildDeviceProfile(preset, aitState.state.appVersion, landscape);
|
|
3836
|
+
applyDeviceEmulation(profile);
|
|
3837
|
+
if (aitState.state.platform !== profile.platform) aitState.update({ platform: profile.platform });
|
|
3838
|
+
}
|
|
3839
|
+
//#endregion
|
|
3314
3840
|
//#region src/panel/viewport.ts
|
|
3315
3841
|
/**
|
|
3316
3842
|
* Viewport 시뮬레이션 유틸
|
|
3317
3843
|
*
|
|
3318
3844
|
* Panel에서 선택한 디바이스 프리셋을 `document.body`에 적용한다. 정적 CSS는
|
|
3319
3845
|
* `panel/styles.ts`에 정의되어 있고 (Panel mount 시 head에 주입), 여기서는 프리셋별
|
|
3320
|
-
* 동적 값(width/height,
|
|
3846
|
+
* 동적 값(width/height, 콘텐츠 push용 body padding-top)만 별도 `<style>` 엘리먼트로
|
|
3847
|
+
* 관리한다.
|
|
3321
3848
|
*/
|
|
3322
3849
|
const VIEWPORT_STORAGE_KEY = "__ait_viewport";
|
|
3323
3850
|
/** Custom width/height의 안전 상한 (CSS px). 4K + 여유. */
|
|
@@ -3329,17 +3856,51 @@ const NONE_PRESET = {
|
|
|
3329
3856
|
height: 0,
|
|
3330
3857
|
dpr: 1,
|
|
3331
3858
|
notch: "none",
|
|
3332
|
-
|
|
3859
|
+
notchInset: 0,
|
|
3860
|
+
navBarHeight: 0,
|
|
3861
|
+
safeAreaBottom: 0
|
|
3862
|
+
};
|
|
3863
|
+
const CUSTOM_PRESET = {
|
|
3864
|
+
id: "custom",
|
|
3865
|
+
label: "Custom",
|
|
3866
|
+
width: 0,
|
|
3867
|
+
height: 0,
|
|
3868
|
+
dpr: 1,
|
|
3869
|
+
notch: "none",
|
|
3870
|
+
notchInset: 0,
|
|
3871
|
+
navBarHeight: 0,
|
|
3333
3872
|
safeAreaBottom: 0
|
|
3334
3873
|
};
|
|
3874
|
+
/** Shorthands used when building preset provenance entries. */
|
|
3875
|
+
const EXTRAPOLATED = { source: "extrapolated" };
|
|
3876
|
+
const PLACEHOLDER = { source: "placeholder" };
|
|
3335
3877
|
/**
|
|
3336
3878
|
* Device presets (2026). CSS viewport 크기는 실제 기기의 `window.innerWidth/innerHeight`.
|
|
3337
3879
|
* iPhone 17 시리즈는 2025-09 출시. iPhone Air는 2026-04 출시.
|
|
3338
3880
|
* Galaxy S26 시리즈는 2026-03-11 출시 — viewport 값은 phone-simulator.com에서 보고된
|
|
3339
|
-
* 측정치를 사용.
|
|
3881
|
+
* 측정치를 사용.
|
|
3882
|
+
*
|
|
3883
|
+
* safe-area 모델 (devtools#190 relay 실측 반영):
|
|
3884
|
+
* - `notchInset` = OS 노치/status bar inset. 기기별 물리값(landscape 측면 inset + 시각
|
|
3885
|
+
* 노치 오버레이용). iPhone 15 Pro 실측에서 `env(safe-area-inset-top)`은 0이었으므로 이
|
|
3886
|
+
* 값은 portrait SDK top에는 들어가지 않는다.
|
|
3887
|
+
* - `navBarHeight` = 토스 호스트 nav bar 높이. partner type portrait의 SDK `top`(실측 54).
|
|
3888
|
+
* 호스트 chrome이라 기기 무관 — 전 preset이 `AIT_NAV_BAR_HEIGHT_PARTNER` 공유.
|
|
3889
|
+
* - `safeAreaBottom` = home-indicator inset. 기기별(노치 iPhone 34, 홈버튼/Android 0).
|
|
3890
|
+
* iPhone 15 Pro 실측 bottom 34와 일치.
|
|
3891
|
+
*
|
|
3892
|
+
* 단, navBarHeight 54는 iOS partner에서만 실측됐다 — Android nav bar 높이와 game type
|
|
3893
|
+
* 미세 차이는 후속 실측 대상(현재는 같은 값을 잠정 적용).
|
|
3340
3894
|
*
|
|
3341
3895
|
* iPhone 17과 17 Pro는 CSS viewport / DPR / safe area가 동일 — 이는 의도이며 카피-페이스트
|
|
3342
3896
|
* 실수가 아니다. Apple의 17 lineup은 base와 Pro의 web-relevant 스펙이 같다.
|
|
3897
|
+
*
|
|
3898
|
+
* safeAreaProvenance: 각 preset의 safe-area 값 신뢰도 출처.
|
|
3899
|
+
* - `measured` — relay 실기기 세션(measure_safe_area)으로 직접 확인한 값.
|
|
3900
|
+
* 현재 iPhone 15 Pro portrait iOS partner만 해당 (devtools#190).
|
|
3901
|
+
* - `extrapolated` — Apple 스펙/같은 시리즈 기기에서 유추한 값.
|
|
3902
|
+
* - `placeholder` — 연결 기기 없이 추정한 값. QA ground truth로 쓰지 말 것.
|
|
3903
|
+
* `measure_safe_area` MCP 툴로 relay 세션에서 `measured`로 승급 필요.
|
|
3343
3904
|
*/
|
|
3344
3905
|
const VIEWPORT_PRESETS = [
|
|
3345
3906
|
NONE_PRESET,
|
|
@@ -3350,8 +3911,26 @@ const VIEWPORT_PRESETS = [
|
|
|
3350
3911
|
height: 667,
|
|
3351
3912
|
dpr: 2,
|
|
3352
3913
|
notch: "none",
|
|
3353
|
-
|
|
3354
|
-
|
|
3914
|
+
notchInset: 20,
|
|
3915
|
+
navBarHeight: 54,
|
|
3916
|
+
safeAreaBottom: 0,
|
|
3917
|
+
safeAreaProvenance: EXTRAPOLATED
|
|
3918
|
+
},
|
|
3919
|
+
{
|
|
3920
|
+
id: "iphone-15-pro",
|
|
3921
|
+
label: "iPhone 15 Pro",
|
|
3922
|
+
width: 393,
|
|
3923
|
+
height: 852,
|
|
3924
|
+
dpr: 3,
|
|
3925
|
+
notch: "dynamic-island",
|
|
3926
|
+
notchInset: 59,
|
|
3927
|
+
navBarHeight: 54,
|
|
3928
|
+
safeAreaBottom: 34,
|
|
3929
|
+
safeAreaProvenance: {
|
|
3930
|
+
source: "measured",
|
|
3931
|
+
device: "iPhone 15 Pro",
|
|
3932
|
+
date: "2026-05-25"
|
|
3933
|
+
}
|
|
3355
3934
|
},
|
|
3356
3935
|
{
|
|
3357
3936
|
id: "iphone-16e",
|
|
@@ -3360,8 +3939,10 @@ const VIEWPORT_PRESETS = [
|
|
|
3360
3939
|
height: 844,
|
|
3361
3940
|
dpr: 3,
|
|
3362
3941
|
notch: "notch",
|
|
3363
|
-
|
|
3364
|
-
|
|
3942
|
+
notchInset: 47,
|
|
3943
|
+
navBarHeight: 54,
|
|
3944
|
+
safeAreaBottom: 34,
|
|
3945
|
+
safeAreaProvenance: EXTRAPOLATED
|
|
3365
3946
|
},
|
|
3366
3947
|
{
|
|
3367
3948
|
id: "iphone-17",
|
|
@@ -3370,8 +3951,10 @@ const VIEWPORT_PRESETS = [
|
|
|
3370
3951
|
height: 874,
|
|
3371
3952
|
dpr: 3,
|
|
3372
3953
|
notch: "dynamic-island",
|
|
3373
|
-
|
|
3374
|
-
|
|
3954
|
+
notchInset: 59,
|
|
3955
|
+
navBarHeight: 54,
|
|
3956
|
+
safeAreaBottom: 34,
|
|
3957
|
+
safeAreaProvenance: EXTRAPOLATED
|
|
3375
3958
|
},
|
|
3376
3959
|
{
|
|
3377
3960
|
id: "iphone-air",
|
|
@@ -3380,8 +3963,10 @@ const VIEWPORT_PRESETS = [
|
|
|
3380
3963
|
height: 912,
|
|
3381
3964
|
dpr: 3,
|
|
3382
3965
|
notch: "dynamic-island",
|
|
3383
|
-
|
|
3384
|
-
|
|
3966
|
+
notchInset: 59,
|
|
3967
|
+
navBarHeight: 54,
|
|
3968
|
+
safeAreaBottom: 34,
|
|
3969
|
+
safeAreaProvenance: EXTRAPOLATED
|
|
3385
3970
|
},
|
|
3386
3971
|
{
|
|
3387
3972
|
id: "iphone-17-pro",
|
|
@@ -3390,8 +3975,10 @@ const VIEWPORT_PRESETS = [
|
|
|
3390
3975
|
height: 874,
|
|
3391
3976
|
dpr: 3,
|
|
3392
3977
|
notch: "dynamic-island",
|
|
3393
|
-
|
|
3394
|
-
|
|
3978
|
+
notchInset: 59,
|
|
3979
|
+
navBarHeight: 54,
|
|
3980
|
+
safeAreaBottom: 34,
|
|
3981
|
+
safeAreaProvenance: EXTRAPOLATED
|
|
3395
3982
|
},
|
|
3396
3983
|
{
|
|
3397
3984
|
id: "iphone-17-pro-max",
|
|
@@ -3400,8 +3987,10 @@ const VIEWPORT_PRESETS = [
|
|
|
3400
3987
|
height: 956,
|
|
3401
3988
|
dpr: 3,
|
|
3402
3989
|
notch: "dynamic-island",
|
|
3403
|
-
|
|
3404
|
-
|
|
3990
|
+
notchInset: 62,
|
|
3991
|
+
navBarHeight: 54,
|
|
3992
|
+
safeAreaBottom: 34,
|
|
3993
|
+
safeAreaProvenance: EXTRAPOLATED
|
|
3405
3994
|
},
|
|
3406
3995
|
{
|
|
3407
3996
|
id: "galaxy-s26",
|
|
@@ -3410,8 +3999,10 @@ const VIEWPORT_PRESETS = [
|
|
|
3410
3999
|
height: 773,
|
|
3411
4000
|
dpr: 3,
|
|
3412
4001
|
notch: "punch-hole-center",
|
|
3413
|
-
|
|
3414
|
-
|
|
4002
|
+
notchInset: 32,
|
|
4003
|
+
navBarHeight: 54,
|
|
4004
|
+
safeAreaBottom: 0,
|
|
4005
|
+
safeAreaProvenance: PLACEHOLDER
|
|
3415
4006
|
},
|
|
3416
4007
|
{
|
|
3417
4008
|
id: "galaxy-s26-plus",
|
|
@@ -3420,8 +4011,10 @@ const VIEWPORT_PRESETS = [
|
|
|
3420
4011
|
height: 1040,
|
|
3421
4012
|
dpr: 3,
|
|
3422
4013
|
notch: "punch-hole-center",
|
|
3423
|
-
|
|
3424
|
-
|
|
4014
|
+
notchInset: 32,
|
|
4015
|
+
navBarHeight: 54,
|
|
4016
|
+
safeAreaBottom: 0,
|
|
4017
|
+
safeAreaProvenance: PLACEHOLDER
|
|
3425
4018
|
},
|
|
3426
4019
|
{
|
|
3427
4020
|
id: "galaxy-s26-ultra",
|
|
@@ -3430,8 +4023,10 @@ const VIEWPORT_PRESETS = [
|
|
|
3430
4023
|
height: 1040,
|
|
3431
4024
|
dpr: 3,
|
|
3432
4025
|
notch: "punch-hole-center",
|
|
3433
|
-
|
|
3434
|
-
|
|
4026
|
+
notchInset: 40,
|
|
4027
|
+
navBarHeight: 54,
|
|
4028
|
+
safeAreaBottom: 0,
|
|
4029
|
+
safeAreaProvenance: PLACEHOLDER
|
|
3435
4030
|
},
|
|
3436
4031
|
{
|
|
3437
4032
|
id: "galaxy-z-flip7",
|
|
@@ -3440,8 +4035,10 @@ const VIEWPORT_PRESETS = [
|
|
|
3440
4035
|
height: 990,
|
|
3441
4036
|
dpr: 3,
|
|
3442
4037
|
notch: "punch-hole-center",
|
|
3443
|
-
|
|
3444
|
-
|
|
4038
|
+
notchInset: 36,
|
|
4039
|
+
navBarHeight: 54,
|
|
4040
|
+
safeAreaBottom: 0,
|
|
4041
|
+
safeAreaProvenance: PLACEHOLDER
|
|
3445
4042
|
},
|
|
3446
4043
|
{
|
|
3447
4044
|
id: "galaxy-z-fold7-folded",
|
|
@@ -3450,8 +4047,10 @@ const VIEWPORT_PRESETS = [
|
|
|
3450
4047
|
height: 870,
|
|
3451
4048
|
dpr: 3,
|
|
3452
4049
|
notch: "punch-hole-center",
|
|
3453
|
-
|
|
3454
|
-
|
|
4050
|
+
notchInset: 32,
|
|
4051
|
+
navBarHeight: 54,
|
|
4052
|
+
safeAreaBottom: 0,
|
|
4053
|
+
safeAreaProvenance: PLACEHOLDER
|
|
3455
4054
|
},
|
|
3456
4055
|
{
|
|
3457
4056
|
id: "galaxy-z-fold7-unfolded",
|
|
@@ -3460,19 +4059,12 @@ const VIEWPORT_PRESETS = [
|
|
|
3460
4059
|
height: 884,
|
|
3461
4060
|
dpr: 2.625,
|
|
3462
4061
|
notch: "punch-hole-center",
|
|
3463
|
-
|
|
3464
|
-
|
|
4062
|
+
notchInset: 32,
|
|
4063
|
+
navBarHeight: 54,
|
|
4064
|
+
safeAreaBottom: 0,
|
|
4065
|
+
safeAreaProvenance: PLACEHOLDER
|
|
3465
4066
|
},
|
|
3466
|
-
|
|
3467
|
-
id: "custom",
|
|
3468
|
-
label: "Custom",
|
|
3469
|
-
width: 0,
|
|
3470
|
-
height: 0,
|
|
3471
|
-
dpr: 1,
|
|
3472
|
-
notch: "none",
|
|
3473
|
-
safeAreaTop: 0,
|
|
3474
|
-
safeAreaBottom: 0
|
|
3475
|
-
}
|
|
4067
|
+
CUSTOM_PRESET
|
|
3476
4068
|
];
|
|
3477
4069
|
function getPreset(id) {
|
|
3478
4070
|
return VIEWPORT_PRESETS.find((p) => p.id === id) ?? NONE_PRESET;
|
|
@@ -3511,23 +4103,30 @@ function resolveViewportSize(state) {
|
|
|
3511
4103
|
};
|
|
3512
4104
|
}
|
|
3513
4105
|
/**
|
|
3514
|
-
* 프리셋 +
|
|
4106
|
+
* 프리셋 + orientation + nav bar 상태로부터 SDK `SafeAreaInsets.get()`이 반환할 insets를
|
|
4107
|
+
* 계산한다. iPhone 15 Pro on-device relay 실측(devtools#190)에 맞춘 모델:
|
|
3515
4108
|
*
|
|
3516
|
-
* - Portrait
|
|
3517
|
-
*
|
|
3518
|
-
*
|
|
3519
|
-
*
|
|
3520
|
-
* -
|
|
4109
|
+
* - **Portrait top = 토스 nav bar 높이** (OS 노치가 아니다). 실측에서
|
|
4110
|
+
* `env(safe-area-inset-top)` = 0, `SafeAreaInsets.get().top` = 54 였고, 그 54는 호스트
|
|
4111
|
+
* nav bar다. 따라서 nav bar가 떠 있고 `partner` type일 때만 `navBarHeight`를 top에 준다.
|
|
4112
|
+
* `game`(투명 오버레이, 콘텐츠 안 밀어냄) 또는 nav bar 미표시면 top = 0.
|
|
4113
|
+
* - **Bottom = `safeAreaBottom`** (home-indicator). 실측 34와 일치.
|
|
4114
|
+
* - **Landscape iPhone(notch/Dynamic Island)**: 노치가 한쪽으로 가므로 `landscapeSide`에
|
|
4115
|
+
* 따라 left/right 한쪽에만 `notchInset`을 준다. top은 0(landscape nav bar 거동은
|
|
4116
|
+
* 미실측 — portrait 모델만 확정), home-indicator는 bottom에 유지.
|
|
4117
|
+
* - **Android punch-hole(status bar)**: landscape에서도 top에 status bar(`notchInset`)가
|
|
4118
|
+
* 유지된다.
|
|
3521
4119
|
*/
|
|
3522
|
-
function computeSafeAreaInsets(preset, landscape, side) {
|
|
4120
|
+
function computeSafeAreaInsets(preset, landscape, side, navBarVisible, navBarType) {
|
|
3523
4121
|
if (preset.id === "none" || preset.id === "custom") return {
|
|
3524
4122
|
top: 0,
|
|
3525
4123
|
bottom: 0,
|
|
3526
4124
|
left: 0,
|
|
3527
4125
|
right: 0
|
|
3528
4126
|
};
|
|
4127
|
+
const navBarTop = navBarVisible && navBarType === "partner" ? preset.navBarHeight : 0;
|
|
3529
4128
|
if (!landscape) return {
|
|
3530
|
-
top:
|
|
4129
|
+
top: navBarTop,
|
|
3531
4130
|
bottom: preset.safeAreaBottom,
|
|
3532
4131
|
left: 0,
|
|
3533
4132
|
right: 0
|
|
@@ -3535,11 +4134,11 @@ function computeSafeAreaInsets(preset, landscape, side) {
|
|
|
3535
4134
|
if (preset.notch === "notch" || preset.notch === "dynamic-island") return {
|
|
3536
4135
|
top: 0,
|
|
3537
4136
|
bottom: preset.safeAreaBottom,
|
|
3538
|
-
left: side === "left" ? preset.
|
|
3539
|
-
right: side === "right" ? preset.
|
|
4137
|
+
left: side === "left" ? preset.notchInset : 0,
|
|
4138
|
+
right: side === "right" ? preset.notchInset : 0
|
|
3540
4139
|
};
|
|
3541
4140
|
return {
|
|
3542
|
-
top: preset.
|
|
4141
|
+
top: preset.notchInset,
|
|
3543
4142
|
bottom: preset.safeAreaBottom,
|
|
3544
4143
|
left: 0,
|
|
3545
4144
|
right: 0
|
|
@@ -3548,7 +4147,7 @@ function computeSafeAreaInsets(preset, landscape, side) {
|
|
|
3548
4147
|
/** viewport preset 또는 orientation이 바뀌면 safe-area insets도 자동 갱신한다. */
|
|
3549
4148
|
function syncSafeAreaFromViewport(state) {
|
|
3550
4149
|
if (state.preset === "none" || state.preset === "custom") return;
|
|
3551
|
-
const next = computeSafeAreaInsets(getPreset(state.preset), effectiveOrientation(state) === "landscape", state.landscapeSide);
|
|
4150
|
+
const next = computeSafeAreaInsets(getPreset(state.preset), effectiveOrientation(state) === "landscape", state.landscapeSide, state.aitNavBar, state.aitNavBarType);
|
|
3552
4151
|
const current = aitState.state.safeAreaInsets;
|
|
3553
4152
|
if (current.top === next.top && current.bottom === next.bottom && current.left === next.left && current.right === next.right) return;
|
|
3554
4153
|
aitState.update({ safeAreaInsets: next });
|
|
@@ -3582,27 +4181,30 @@ function removeNavBarElement() {
|
|
|
3582
4181
|
removeById(NAV_BAR_ELEMENT_ID);
|
|
3583
4182
|
}
|
|
3584
4183
|
/**
|
|
3585
|
-
* Apps in Toss host nav bar 렌더. OS status bar 아래에
|
|
4184
|
+
* Apps in Toss host nav bar 렌더. OS status bar(notch) 아래에 쌓인다.
|
|
3586
4185
|
*
|
|
3587
4186
|
* 변형(SDK `webViewProps.type`과 의미 일치):
|
|
3588
4187
|
* - `partner` (기본): 흰 배경, 좌측 뒤로가기(‹), 앱 아이콘 + 이름(`brand.displayName`),
|
|
3589
4188
|
* 우측 `⋯` + 구분선 + `×`.
|
|
3590
4189
|
* - `game`: 투명 배경, 게임 캔버스를 가리지 않도록 우측 `⋯` + 구분선 + `×`만.
|
|
3591
4190
|
*
|
|
3592
|
-
*
|
|
3593
|
-
*
|
|
4191
|
+
* nav bar는 WebView(body) 좌표계의 최상단(top 0)에 앉는다 — 실기기에서 OS notch는
|
|
4192
|
+
* WebView 밖(status bar)이라 `env(safe-area-inset-top)`이 0이고, WebView 콘텐츠 영역은
|
|
4193
|
+
* nav bar 바로 아래(= SDK `SafeAreaInsets.get().top` = `navBarHeight`)에서 시작한다.
|
|
4194
|
+
* 콘텐츠를 그만큼 밀어내는 건 `applyViewport`의 body `padding-top`이 담당하므로, nav bar
|
|
4195
|
+
* 바닥과 콘텐츠 시작이 정확히 맞물린다. 시각 notch 오버레이는 body 밖 위쪽(status bar
|
|
4196
|
+
* 영역)에 따로 그린다(`renderNotchOverlay`) — body 안이 아니다.
|
|
3594
4197
|
*
|
|
3595
4198
|
* 뒤로가기 버튼은 `__ait:backEvent`를 트리거하고, X 버튼은 `closeView()`를 호출한다.
|
|
3596
4199
|
* 실제 SDK 이벤트 플러밍을 한 곳에서 검증할 수 있다.
|
|
3597
4200
|
*/
|
|
3598
|
-
function renderNavBar(
|
|
4201
|
+
function renderNavBar(displayName, type) {
|
|
3599
4202
|
removeNavBarElement();
|
|
3600
4203
|
const el = h("div", {
|
|
3601
4204
|
id: NAV_BAR_ELEMENT_ID,
|
|
3602
4205
|
className: `ait-navbar ait-navbar-${type}`,
|
|
3603
4206
|
"aria-hidden": "true"
|
|
3604
4207
|
});
|
|
3605
|
-
el.style.top = `${preset.safeAreaTop}px`;
|
|
3606
4208
|
const moreBtn = h("button", {
|
|
3607
4209
|
className: "ait-navbar-btn",
|
|
3608
4210
|
type: "button",
|
|
@@ -3680,12 +4282,13 @@ function disposeViewport() {
|
|
|
3680
4282
|
removeNotchElement();
|
|
3681
4283
|
removeHomeIndicator();
|
|
3682
4284
|
removeNavBarElement();
|
|
4285
|
+
revertDeviceEmulation();
|
|
3683
4286
|
bodyScrollHintEmitted = false;
|
|
3684
4287
|
}
|
|
3685
4288
|
/**
|
|
3686
4289
|
* DOM에 뷰포트 제약을 적용한다.
|
|
3687
4290
|
* - `html.ait-viewport-active` 클래스로 정적 CSS(styles.ts) 활성화
|
|
3688
|
-
* - body의 width/height는 preset 값으로, navbar top offset은
|
|
4291
|
+
* - body의 width/height는 preset 값으로, navbar top offset은 notchInset으로 인라인 주입
|
|
3689
4292
|
*/
|
|
3690
4293
|
function applyViewport(state) {
|
|
3691
4294
|
if (typeof document === "undefined") return;
|
|
@@ -3700,6 +4303,7 @@ function applyViewport(state) {
|
|
|
3700
4303
|
removeNotchElement();
|
|
3701
4304
|
removeHomeIndicator();
|
|
3702
4305
|
removeNavBarElement();
|
|
4306
|
+
syncDeviceEmulation(null, false);
|
|
3703
4307
|
return;
|
|
3704
4308
|
}
|
|
3705
4309
|
if (!bodyScrollHintEmitted) {
|
|
@@ -3710,19 +4314,22 @@ function applyViewport(state) {
|
|
|
3710
4314
|
html.classList.toggle("ait-viewport-framed", state.frame);
|
|
3711
4315
|
const preset = state.preset === "custom" ? null : getPreset(state.preset);
|
|
3712
4316
|
const landscape = effectiveOrientation(state) === "landscape";
|
|
4317
|
+
syncDeviceEmulation(preset, landscape);
|
|
4318
|
+
const contentTop = preset ? computeSafeAreaInsets(preset, landscape, state.landscapeSide, state.aitNavBar, state.aitNavBarType).top : 0;
|
|
3713
4319
|
style.textContent = `
|
|
3714
4320
|
html.ait-viewport-active body {
|
|
3715
4321
|
width: ${size.width}px;
|
|
3716
4322
|
max-width: ${size.width}px;
|
|
3717
4323
|
min-height: ${size.height}px;
|
|
3718
4324
|
max-height: ${size.height}px;
|
|
4325
|
+
padding-top: ${contentTop}px;
|
|
3719
4326
|
}
|
|
3720
4327
|
`;
|
|
3721
4328
|
if (preset && state.frame && !landscape) renderNotchOverlay(preset);
|
|
3722
4329
|
else removeNotchElement();
|
|
3723
4330
|
if (preset && state.frame && !landscape && preset.safeAreaBottom > 0) renderHomeIndicator();
|
|
3724
4331
|
else removeHomeIndicator();
|
|
3725
|
-
if (preset && state.aitNavBar && !landscape) renderNavBar(
|
|
4332
|
+
if (preset && state.aitNavBar && !landscape) renderNavBar(aitState.state.brand.displayName, state.aitNavBarType);
|
|
3726
4333
|
else removeNavBarElement();
|
|
3727
4334
|
}
|
|
3728
4335
|
function isViewportPresetId(v) {
|
|
@@ -3823,6 +4430,24 @@ function initViewport() {
|
|
|
3823
4430
|
}
|
|
3824
4431
|
//#endregion
|
|
3825
4432
|
//#region src/panel/tabs/viewport.ts
|
|
4433
|
+
/**
|
|
4434
|
+
* Renders a small inline provenance badge for safe-area values.
|
|
4435
|
+
* - `measured` — no badge (confirmed value)
|
|
4436
|
+
* - `extrapolated` — "(추정치)" in muted gray
|
|
4437
|
+
* - `placeholder` — "(미측정)" in amber
|
|
4438
|
+
*/
|
|
4439
|
+
function provenanceBadge(provenance) {
|
|
4440
|
+
if (!provenance || provenance.source === "measured") return null;
|
|
4441
|
+
const text = provenance.source === "placeholder" ? "(미측정)" : "(추정치)";
|
|
4442
|
+
const color = provenance.source === "placeholder" ? "#b45309" : "#888";
|
|
4443
|
+
const badge = h("span", {
|
|
4444
|
+
className: "ait-provenance-badge",
|
|
4445
|
+
title: provenance.source === "placeholder" ? "safe-area 값이 미실측 추정치입니다. relay 세션에서 measure_safe_area로 실측 후 승급하세요." : "safe-area 값이 기기 스펙에서 유추한 추정치입니다. relay 세션에서 measure_safe_area로 확인하세요."
|
|
4446
|
+
});
|
|
4447
|
+
badge.textContent = text;
|
|
4448
|
+
badge.style.cssText = `font-size:10px;color:${color};margin-left:4px`;
|
|
4449
|
+
return badge;
|
|
4450
|
+
}
|
|
3826
4451
|
function renderViewportTab() {
|
|
3827
4452
|
const s = aitState.state;
|
|
3828
4453
|
const vp = s.viewport;
|
|
@@ -3941,13 +4566,19 @@ function renderViewportTab() {
|
|
|
3941
4566
|
const orientDisplay = vp.orientation === "auto" ? t("viewport.orientation.autoSuffix", { orient: effOrient }) : effOrient;
|
|
3942
4567
|
rows.push(h("div", { className: "ait-status-row" }, h("span", {}, t("viewport.status.cssPhysical")), h("span", { className: "ait-status-value" }, `${size.width}×${size.height}@${dpr}x | ${physW}×${physH} ${orientDisplay}`)));
|
|
3943
4568
|
if (preset) {
|
|
3944
|
-
const insets = computeSafeAreaInsets(preset, landscape, vp.landscapeSide);
|
|
3945
|
-
|
|
4569
|
+
const insets = computeSafeAreaInsets(preset, landscape, vp.landscapeSide, vp.aitNavBar, vp.aitNavBarType);
|
|
4570
|
+
const safeAreaValueEl = h("span", { className: "ait-status-value" }, `T${insets.top} R${insets.right} B${insets.bottom} L${insets.left}`);
|
|
4571
|
+
const badge = provenanceBadge(preset.safeAreaProvenance);
|
|
4572
|
+
if (badge) safeAreaValueEl.appendChild(badge);
|
|
4573
|
+
rows.push(h("div", { className: "ait-status-row" }, h("span", {}, t("viewport.status.safeArea")), safeAreaValueEl));
|
|
4574
|
+
}
|
|
4575
|
+
if (vp.aitNavBar && !landscape) {
|
|
4576
|
+
const navBarTop = vp.aitNavBarType === "partner" ? preset?.navBarHeight ?? 0 : 0;
|
|
4577
|
+
rows.push(h("div", { className: "ait-status-row" }, h("span", {}, t("viewport.status.aitNavBar")), h("span", { className: "ait-status-value" }, t("viewport.status.aitNavBarValue", {
|
|
4578
|
+
height: navBarTop,
|
|
4579
|
+
type: vp.aitNavBarType
|
|
4580
|
+
}))));
|
|
3946
4581
|
}
|
|
3947
|
-
if (vp.aitNavBar && !landscape) rows.push(h("div", { className: "ait-status-row" }, h("span", {}, t("viewport.status.aitNavBar")), h("span", { className: "ait-status-value" }, t("viewport.status.aitNavBarValue", {
|
|
3948
|
-
height: 48,
|
|
3949
|
-
type: vp.aitNavBarType
|
|
3950
|
-
}))));
|
|
3951
4582
|
for (const row of rows) statusEl.appendChild(row);
|
|
3952
4583
|
}
|
|
3953
4584
|
const deviceSection = h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, t("viewport.section.device")), h("div", { className: "ait-row" }, h("label", {}, t("viewport.row.preset")), presetSelect), h("div", { className: "ait-row" }, h("label", {}, t("viewport.row.orientation")), orientationSelect));
|
|
@@ -4242,7 +4873,7 @@ function mount() {
|
|
|
4242
4873
|
mockBadge.textContent = aitState.state.panelEditable ? t("panel.editMode.on") : t("panel.editMode.off");
|
|
4243
4874
|
refreshPanel();
|
|
4244
4875
|
});
|
|
4245
|
-
const headerRight = h("span", { style: "display:flex;align-items:center;gap:6px" }, mockBadge, h("span", { style: "font-size:11px;color:#666;font-weight:400" }, `v0.1.
|
|
4876
|
+
const headerRight = h("span", { style: "display:flex;align-items:center;gap:6px" }, mockBadge, h("span", { style: "font-size:11px;color:#666;font-weight:400" }, `v0.1.33`), closeBtn);
|
|
4246
4877
|
const header = h("div", { className: "ait-panel-header" }, h("span", {}, t("panel.title")), headerRight);
|
|
4247
4878
|
tabsEl = h("div", { className: "ait-panel-tabs" });
|
|
4248
4879
|
for (const tab of getTabs()) {
|
|
@@ -4284,7 +4915,7 @@ function mount() {
|
|
|
4284
4915
|
window.addEventListener("resize", resizeHandler);
|
|
4285
4916
|
aitStateUnsubscribe = aitState.subscribe(() => {
|
|
4286
4917
|
try {
|
|
4287
|
-
if (isOpen && (currentTab === "analytics" || currentTab === "storage" || currentTab === "device" || currentTab === "viewport" || currentTab === "iap" || currentTab === "ads" || currentTab === "presets")) refreshPanel();
|
|
4918
|
+
if (isOpen && (currentTab === "env" || currentTab === "analytics" || currentTab === "storage" || currentTab === "device" || currentTab === "viewport" || currentTab === "iap" || currentTab === "ads" || currentTab === "presets")) refreshPanel();
|
|
4288
4919
|
} catch (err) {
|
|
4289
4920
|
console.error("[@ait-co/devtools] Error in subscribe callback:", err);
|
|
4290
4921
|
}
|