@ait-co/devtools 0.1.7 → 0.1.9
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.md +37 -2
- package/dist/mock/index.d.ts +86 -1
- package/dist/mock/index.d.ts.map +1 -1
- package/dist/mock/index.js +317 -2
- package/dist/mock/index.js.map +1 -1
- package/dist/panel/index.js +597 -3
- package/dist/panel/index.js.map +1 -1
- package/package.json +1 -1
package/dist/panel/index.js
CHANGED
|
@@ -71,7 +71,9 @@ const DEFAULT_STATE = {
|
|
|
71
71
|
},
|
|
72
72
|
ads: {
|
|
73
73
|
isLoaded: false,
|
|
74
|
-
nextEvent: "loaded"
|
|
74
|
+
nextEvent: "loaded",
|
|
75
|
+
forceNoFill: false,
|
|
76
|
+
lastEvent: null
|
|
75
77
|
},
|
|
76
78
|
game: {
|
|
77
79
|
profile: {
|
|
@@ -115,6 +117,7 @@ function generateDeviceId() {
|
|
|
115
117
|
var AitStateManager = class {
|
|
116
118
|
_state;
|
|
117
119
|
_listeners = /* @__PURE__ */ new Set();
|
|
120
|
+
_inTransaction = false;
|
|
118
121
|
constructor() {
|
|
119
122
|
this._state = structuredClone(DEFAULT_STATE);
|
|
120
123
|
try {
|
|
@@ -153,6 +156,34 @@ var AitStateManager = class {
|
|
|
153
156
|
this._listeners.add(listener);
|
|
154
157
|
return () => this._listeners.delete(listener);
|
|
155
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* 한 묶음의 update/patch 호출을 묶어 listener notify 1회로 만든다.
|
|
161
|
+
* preset 적용처럼 여러 슬라이스를 동시에 바꿀 때 panel re-render 폭주를
|
|
162
|
+
* 방지한다. 중첩 호출은 outermost transaction이 끝날 때 한 번만 notify
|
|
163
|
+
* (inner도 throw해도 outer finally가 flag를 복구한다).
|
|
164
|
+
*
|
|
165
|
+
* Rollback은 없다 — `fn`이 throw해도 그때까지의 state 변경은 유지된다.
|
|
166
|
+
* 구독자가 partial state를 영원히 못 보는 사고를 막기 위해, throw 여부와
|
|
167
|
+
* 무관하게 항상 한 번 notify한 뒤 throw를 propagate한다. DB transaction이
|
|
168
|
+
* 아니라 "여러 mutation을 한 notify로 묶는 batch"라고 생각하면 된다.
|
|
169
|
+
*
|
|
170
|
+
* Listener는 throw해선 안 된다 — finally 안의 `_notify()`가 throw하면 원래
|
|
171
|
+
* `fn`의 throw를 덮어버린다. 우리 구독자는 panel re-render뿐이라 실제
|
|
172
|
+
* 발생 사례는 없지만, 외부에서 listener를 등록할 때 주의.
|
|
173
|
+
*/
|
|
174
|
+
transaction(fn) {
|
|
175
|
+
if (this._inTransaction) {
|
|
176
|
+
fn();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
this._inTransaction = true;
|
|
180
|
+
try {
|
|
181
|
+
fn();
|
|
182
|
+
} finally {
|
|
183
|
+
this._inTransaction = false;
|
|
184
|
+
this._notify();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
156
187
|
/** 분석 로그 추가 */
|
|
157
188
|
logAnalytics(entry) {
|
|
158
189
|
this._state = {
|
|
@@ -177,6 +208,7 @@ var AitStateManager = class {
|
|
|
177
208
|
this._notify();
|
|
178
209
|
}
|
|
179
210
|
_notify() {
|
|
211
|
+
if (this._inTransaction) return;
|
|
180
212
|
for (const listener of this._listeners) listener();
|
|
181
213
|
}
|
|
182
214
|
};
|
|
@@ -571,6 +603,38 @@ const PANEL_STYLES = `
|
|
|
571
603
|
color: #e53e3e; /* readable on both light (#fff) and dark (#1a1a2e) panel backgrounds */
|
|
572
604
|
}
|
|
573
605
|
|
|
606
|
+
/* Presets tab */
|
|
607
|
+
.ait-preset-row {
|
|
608
|
+
display: flex;
|
|
609
|
+
align-items: center;
|
|
610
|
+
justify-content: space-between;
|
|
611
|
+
gap: 8px;
|
|
612
|
+
padding: 4px 0;
|
|
613
|
+
border-bottom: 1px solid #2a2a4a;
|
|
614
|
+
}
|
|
615
|
+
.ait-preset-row.ait-preset-active .ait-preset-label {
|
|
616
|
+
color: #4ade80;
|
|
617
|
+
font-weight: 600;
|
|
618
|
+
}
|
|
619
|
+
.ait-preset-label {
|
|
620
|
+
font-size: 12px;
|
|
621
|
+
color: #ddd;
|
|
622
|
+
flex: 1;
|
|
623
|
+
word-break: break-word;
|
|
624
|
+
}
|
|
625
|
+
.ait-preset-actions {
|
|
626
|
+
display: flex;
|
|
627
|
+
gap: 4px;
|
|
628
|
+
flex-shrink: 0;
|
|
629
|
+
}
|
|
630
|
+
.ait-preset-description {
|
|
631
|
+
font-size: 11px;
|
|
632
|
+
color: #777;
|
|
633
|
+
padding: 0 0 6px 4px;
|
|
634
|
+
border-bottom: 1px solid #2a2a4a;
|
|
635
|
+
margin-bottom: 0;
|
|
636
|
+
}
|
|
637
|
+
|
|
574
638
|
/* Viewport tab status rows */
|
|
575
639
|
.ait-status-row {
|
|
576
640
|
display: flex;
|
|
@@ -1335,6 +1399,192 @@ function renderDeviceTab() {
|
|
|
1335
1399
|
return container;
|
|
1336
1400
|
}
|
|
1337
1401
|
//#endregion
|
|
1402
|
+
//#region src/mock/ads/index.ts
|
|
1403
|
+
/**
|
|
1404
|
+
* 광고 mock (GoogleAdMob, TossAds, FullScreenAd)
|
|
1405
|
+
*/
|
|
1406
|
+
function withIsSupported(fn) {
|
|
1407
|
+
fn.isSupported = () => true;
|
|
1408
|
+
return fn;
|
|
1409
|
+
}
|
|
1410
|
+
const GoogleAdMob = createMockProxy("GoogleAdMob", {
|
|
1411
|
+
loadAppsInTossAdMob: withIsSupported((args) => {
|
|
1412
|
+
setTimeout(() => {
|
|
1413
|
+
if (aitState.state.ads.forceNoFill) {
|
|
1414
|
+
args.onError(/* @__PURE__ */ new Error("No fill"));
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
aitState.patch("ads", { isLoaded: true });
|
|
1418
|
+
args.onEvent({
|
|
1419
|
+
type: "loaded",
|
|
1420
|
+
data: { adGroupId: args.options?.adGroupId }
|
|
1421
|
+
});
|
|
1422
|
+
}, 200);
|
|
1423
|
+
return () => {};
|
|
1424
|
+
}),
|
|
1425
|
+
showAppsInTossAdMob: withIsSupported((args) => {
|
|
1426
|
+
if (!aitState.state.ads.isLoaded) {
|
|
1427
|
+
args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
|
|
1428
|
+
return () => {};
|
|
1429
|
+
}
|
|
1430
|
+
setTimeout(() => args.onEvent({ type: "requested" }), 50);
|
|
1431
|
+
setTimeout(() => args.onEvent({ type: "show" }), 100);
|
|
1432
|
+
setTimeout(() => args.onEvent({ type: "impression" }), 150);
|
|
1433
|
+
setTimeout(() => {
|
|
1434
|
+
args.onEvent({
|
|
1435
|
+
type: "userEarnedReward",
|
|
1436
|
+
data: {
|
|
1437
|
+
unitType: "coins",
|
|
1438
|
+
unitAmount: 10
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
}, 1e3);
|
|
1442
|
+
setTimeout(() => {
|
|
1443
|
+
args.onEvent({ type: "dismissed" });
|
|
1444
|
+
aitState.patch("ads", { isLoaded: false });
|
|
1445
|
+
}, 1500);
|
|
1446
|
+
return () => {};
|
|
1447
|
+
}),
|
|
1448
|
+
isAppsInTossAdMobLoaded: withIsSupported(async (_options) => aitState.state.ads.isLoaded)
|
|
1449
|
+
});
|
|
1450
|
+
createMockProxy("TossAds", {
|
|
1451
|
+
initialize: withIsSupported((_options) => {
|
|
1452
|
+
console.log("[@ait-co/devtools] TossAds.initialize (mock)");
|
|
1453
|
+
}),
|
|
1454
|
+
attach: withIsSupported((_adGroupId, target, _options) => {
|
|
1455
|
+
const el = typeof target === "string" ? document.querySelector(target) : target;
|
|
1456
|
+
if (el) {
|
|
1457
|
+
const placeholder = document.createElement("div");
|
|
1458
|
+
placeholder.style.cssText = "background:#f0f0f0;border:1px dashed #999;padding:16px;text-align:center;color:#666;font-size:14px;";
|
|
1459
|
+
placeholder.textContent = "[@ait-co/devtools] TossAds Placeholder";
|
|
1460
|
+
el.appendChild(placeholder);
|
|
1461
|
+
}
|
|
1462
|
+
}),
|
|
1463
|
+
attachBanner: withIsSupported((_adGroupId, target, _options) => {
|
|
1464
|
+
const el = typeof target === "string" ? document.querySelector(target) : target;
|
|
1465
|
+
if (el) {
|
|
1466
|
+
const placeholder = document.createElement("div");
|
|
1467
|
+
placeholder.style.cssText = "background:#f0f0f0;border:1px dashed #999;padding:12px;text-align:center;color:#666;font-size:12px;";
|
|
1468
|
+
placeholder.textContent = "[@ait-co/devtools] Banner Ad Placeholder";
|
|
1469
|
+
el.appendChild(placeholder);
|
|
1470
|
+
}
|
|
1471
|
+
return { destroy: () => {} };
|
|
1472
|
+
}),
|
|
1473
|
+
destroy: withIsSupported((_slotId) => {}),
|
|
1474
|
+
destroyAll: withIsSupported(() => {})
|
|
1475
|
+
});
|
|
1476
|
+
const loadFullScreenAd = withIsSupported((args) => {
|
|
1477
|
+
setTimeout(() => {
|
|
1478
|
+
if (aitState.state.ads.forceNoFill) {
|
|
1479
|
+
args.onError(/* @__PURE__ */ new Error("No fill"));
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
aitState.patch("ads", { isLoaded: true });
|
|
1483
|
+
args.onEvent({
|
|
1484
|
+
type: "loaded",
|
|
1485
|
+
data: { adGroupId: args.options?.adGroupId }
|
|
1486
|
+
});
|
|
1487
|
+
}, 200);
|
|
1488
|
+
return () => {};
|
|
1489
|
+
});
|
|
1490
|
+
const showFullScreenAd = withIsSupported((args) => {
|
|
1491
|
+
if (!aitState.state.ads.isLoaded) {
|
|
1492
|
+
args.onError(/* @__PURE__ */ new Error("Ad not loaded"));
|
|
1493
|
+
return () => {};
|
|
1494
|
+
}
|
|
1495
|
+
setTimeout(() => args.onEvent({ type: "show" }), 100);
|
|
1496
|
+
setTimeout(() => args.onEvent({ type: "dismissed" }), 1500);
|
|
1497
|
+
return () => {};
|
|
1498
|
+
});
|
|
1499
|
+
//#endregion
|
|
1500
|
+
//#region src/panel/tabs/ads.ts
|
|
1501
|
+
function recordEvent(type) {
|
|
1502
|
+
aitState.patch("ads", { lastEvent: {
|
|
1503
|
+
type,
|
|
1504
|
+
timestamp: Date.now()
|
|
1505
|
+
} });
|
|
1506
|
+
}
|
|
1507
|
+
function recordError(message) {
|
|
1508
|
+
recordEvent(`error: ${message}`);
|
|
1509
|
+
}
|
|
1510
|
+
function statusRow(label, value) {
|
|
1511
|
+
return h("div", { className: "ait-row" }, h("label", {}, label), h("span", { style: "font-family:SF Mono,Menlo,monospace;font-size:11px;color:#aaa" }, value));
|
|
1512
|
+
}
|
|
1513
|
+
function lastEventLine() {
|
|
1514
|
+
const last = aitState.state.ads.lastEvent;
|
|
1515
|
+
if (!last) return h("div", { className: "ait-log-entry" }, h("span", { style: "color:#555" }, "No events yet"));
|
|
1516
|
+
const time = new Date(last.timestamp).toLocaleTimeString();
|
|
1517
|
+
return h("div", { className: "ait-log-entry" }, h("span", {
|
|
1518
|
+
className: "ait-log-type",
|
|
1519
|
+
style: last.type.startsWith("error:") ? "color:#e74c3c" : ""
|
|
1520
|
+
}, last.type), h("span", { className: "ait-log-time" }, time));
|
|
1521
|
+
}
|
|
1522
|
+
function adSection(title, onLoad, onShow, disabled) {
|
|
1523
|
+
const loadBtn = h("button", { className: "ait-btn ait-btn-sm" }, "Load");
|
|
1524
|
+
const showBtn = h("button", { className: "ait-btn ait-btn-sm" }, "Show");
|
|
1525
|
+
if (disabled) {
|
|
1526
|
+
loadBtn.disabled = true;
|
|
1527
|
+
showBtn.disabled = true;
|
|
1528
|
+
}
|
|
1529
|
+
loadBtn.addEventListener("click", onLoad);
|
|
1530
|
+
showBtn.addEventListener("click", onShow);
|
|
1531
|
+
return h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, title), h("div", { className: "ait-btn-row" }, loadBtn, showBtn));
|
|
1532
|
+
}
|
|
1533
|
+
function renderAdsTab() {
|
|
1534
|
+
const s = aitState.state;
|
|
1535
|
+
const disabled = !s.panelEditable;
|
|
1536
|
+
const container = h("div");
|
|
1537
|
+
if (disabled) container.appendChild(monitoringNotice());
|
|
1538
|
+
const forceNoFillCb = h("input", {
|
|
1539
|
+
type: "checkbox",
|
|
1540
|
+
className: "ait-checkbox"
|
|
1541
|
+
});
|
|
1542
|
+
forceNoFillCb.checked = s.ads.forceNoFill;
|
|
1543
|
+
if (disabled) forceNoFillCb.disabled = true;
|
|
1544
|
+
forceNoFillCb.addEventListener("change", () => {
|
|
1545
|
+
aitState.patch("ads", { forceNoFill: forceNoFillCb.checked });
|
|
1546
|
+
});
|
|
1547
|
+
container.append(h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, "Ads State"), statusRow("isLoaded", String(s.ads.isLoaded)), h("div", { className: "ait-row" }, h("label", {}, "Force \"no fill\""), forceNoFillCb), lastEventLine()), adSection("GoogleAdMob", () => {
|
|
1548
|
+
GoogleAdMob.loadAppsInTossAdMob({
|
|
1549
|
+
onEvent: (e) => recordEvent(e.type),
|
|
1550
|
+
onError: (err) => recordError(err.message)
|
|
1551
|
+
});
|
|
1552
|
+
}, () => {
|
|
1553
|
+
GoogleAdMob.showAppsInTossAdMob({
|
|
1554
|
+
onEvent: (e) => recordEvent(e.type),
|
|
1555
|
+
onError: (err) => recordError(err.message)
|
|
1556
|
+
});
|
|
1557
|
+
}, disabled), adSection("TossAds", () => {
|
|
1558
|
+
if (aitState.state.ads.forceNoFill) {
|
|
1559
|
+
recordError("No fill");
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
aitState.patch("ads", { isLoaded: true });
|
|
1563
|
+
recordEvent("loaded");
|
|
1564
|
+
}, () => {
|
|
1565
|
+
if (!aitState.state.ads.isLoaded) {
|
|
1566
|
+
recordError("Ad not loaded");
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
recordEvent("show");
|
|
1570
|
+
setTimeout(() => {
|
|
1571
|
+
recordEvent("dismissed");
|
|
1572
|
+
aitState.patch("ads", { isLoaded: false });
|
|
1573
|
+
}, 1500);
|
|
1574
|
+
}, disabled), adSection("FullScreenAd", () => {
|
|
1575
|
+
loadFullScreenAd({
|
|
1576
|
+
onEvent: (e) => recordEvent(e.type),
|
|
1577
|
+
onError: (err) => recordError(err.message)
|
|
1578
|
+
});
|
|
1579
|
+
}, () => {
|
|
1580
|
+
showFullScreenAd({
|
|
1581
|
+
onEvent: (e) => recordEvent(e.type),
|
|
1582
|
+
onError: (err) => recordError(err.message)
|
|
1583
|
+
});
|
|
1584
|
+
}, disabled));
|
|
1585
|
+
return container;
|
|
1586
|
+
}
|
|
1587
|
+
//#endregion
|
|
1338
1588
|
//#region src/panel/tabs/analytics.ts
|
|
1339
1589
|
function renderAnalyticsTab() {
|
|
1340
1590
|
const disabled = !aitState.state.panelEditable;
|
|
@@ -1593,6 +1843,340 @@ function renderPermissionsTab() {
|
|
|
1593
1843
|
return container;
|
|
1594
1844
|
}
|
|
1595
1845
|
//#endregion
|
|
1846
|
+
//#region src/mock/preset-store.ts
|
|
1847
|
+
const PREFIX = "__ait_preset:";
|
|
1848
|
+
function safeLocalStorage() {
|
|
1849
|
+
try {
|
|
1850
|
+
if (typeof localStorage === "undefined") return null;
|
|
1851
|
+
return localStorage;
|
|
1852
|
+
} catch {
|
|
1853
|
+
return null;
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
function isObject(v) {
|
|
1857
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* Storage에서 읽은 임의 JSON을 MockPreset으로 검증. id/label 필수, state는
|
|
1861
|
+
* object여야 함. 실패하면 null — caller가 storage entry를 무시하거나 정리하면 된다.
|
|
1862
|
+
*
|
|
1863
|
+
* `state`의 내부 키/값은 검증하지 않는다. `applyPreset`이 `pickKnownKeys`로
|
|
1864
|
+
* 키만 거른 뒤 그대로 state에 패치하므로 잘못된 enum 값이 통과될 수 있지만,
|
|
1865
|
+
* mock state라 보안 위협은 없다 — 새 enum 값이 추가됐을 때 저장된 preset을
|
|
1866
|
+
* reject하지 않으려는 의도.
|
|
1867
|
+
*/
|
|
1868
|
+
function parsePreset(raw) {
|
|
1869
|
+
try {
|
|
1870
|
+
const parsed = JSON.parse(raw);
|
|
1871
|
+
if (!isObject(parsed)) return null;
|
|
1872
|
+
const { id, label, description, state } = parsed;
|
|
1873
|
+
if (typeof id !== "string" || id.length === 0) return null;
|
|
1874
|
+
if (typeof label !== "string" || label.length === 0) return null;
|
|
1875
|
+
if (!isObject(state)) return null;
|
|
1876
|
+
return {
|
|
1877
|
+
id,
|
|
1878
|
+
label,
|
|
1879
|
+
description: typeof description === "string" ? description : void 0,
|
|
1880
|
+
state
|
|
1881
|
+
};
|
|
1882
|
+
} catch {
|
|
1883
|
+
return null;
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
function listUserPresets() {
|
|
1887
|
+
const ls = safeLocalStorage();
|
|
1888
|
+
if (!ls) return [];
|
|
1889
|
+
const out = [];
|
|
1890
|
+
for (let i = 0; i < ls.length; i++) {
|
|
1891
|
+
const key = ls.key(i);
|
|
1892
|
+
if (!key?.startsWith(PREFIX)) continue;
|
|
1893
|
+
const raw = ls.getItem(key);
|
|
1894
|
+
if (!raw) continue;
|
|
1895
|
+
const preset = parsePreset(raw);
|
|
1896
|
+
if (preset) out.push(preset);
|
|
1897
|
+
}
|
|
1898
|
+
return out.sort((a, b) => a.label.localeCompare(b.label));
|
|
1899
|
+
}
|
|
1900
|
+
/**
|
|
1901
|
+
* Preset을 저장한다. label에서 slug를 derive — 같은 slug가 이미 있으면 `-2`, `-3`
|
|
1902
|
+
* suffix를 붙여 새 entry를 만든다 (기존 entry 덮어쓰기 아님). UI는 label만 받으면 된다.
|
|
1903
|
+
*
|
|
1904
|
+
* Throws:
|
|
1905
|
+
* - label trim한 뒤 빈 문자열일 때
|
|
1906
|
+
* - localStorage 미가용 환경일 때 (SSR 등)
|
|
1907
|
+
* - `setItem` 실패 (`QuotaExceededError` 등) — caller가 처리해야 함
|
|
1908
|
+
*/
|
|
1909
|
+
function saveUserPreset(label, state, description) {
|
|
1910
|
+
const trimmed = label.trim();
|
|
1911
|
+
if (trimmed.length === 0) throw new Error("Preset label cannot be empty");
|
|
1912
|
+
const ls = safeLocalStorage();
|
|
1913
|
+
if (!ls) throw new Error("localStorage not available");
|
|
1914
|
+
const id = generateId(trimmed, ls);
|
|
1915
|
+
const preset = {
|
|
1916
|
+
id,
|
|
1917
|
+
label: trimmed,
|
|
1918
|
+
state,
|
|
1919
|
+
...description !== void 0 && description.length > 0 ? { description } : {}
|
|
1920
|
+
};
|
|
1921
|
+
ls.setItem(PREFIX + id, JSON.stringify(preset));
|
|
1922
|
+
return preset;
|
|
1923
|
+
}
|
|
1924
|
+
function deleteUserPreset(id) {
|
|
1925
|
+
const ls = safeLocalStorage();
|
|
1926
|
+
if (!ls) return;
|
|
1927
|
+
ls.removeItem(PREFIX + id);
|
|
1928
|
+
}
|
|
1929
|
+
/** 충돌 시 `-2`, `-3` 등 suffix를 붙여 unique한 id 만든다. */
|
|
1930
|
+
function generateId(label, ls) {
|
|
1931
|
+
const base = label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "preset";
|
|
1932
|
+
let candidate = base;
|
|
1933
|
+
let n = 2;
|
|
1934
|
+
while (ls.getItem(PREFIX + candidate) !== null) {
|
|
1935
|
+
candidate = `${base}-${n}`;
|
|
1936
|
+
n += 1;
|
|
1937
|
+
}
|
|
1938
|
+
return candidate;
|
|
1939
|
+
}
|
|
1940
|
+
//#endregion
|
|
1941
|
+
//#region src/mock/presets.ts
|
|
1942
|
+
const builtInPresets = [
|
|
1943
|
+
{
|
|
1944
|
+
id: "all-allowed",
|
|
1945
|
+
label: "All allowed (default-ish)",
|
|
1946
|
+
description: "모든 권한 허용, WIFI, 로그인됨, IAP success",
|
|
1947
|
+
state: {
|
|
1948
|
+
networkStatus: "WIFI",
|
|
1949
|
+
permissions: {
|
|
1950
|
+
camera: "allowed",
|
|
1951
|
+
photos: "allowed",
|
|
1952
|
+
geolocation: "allowed",
|
|
1953
|
+
clipboard: "allowed",
|
|
1954
|
+
contacts: "allowed",
|
|
1955
|
+
microphone: "allowed"
|
|
1956
|
+
},
|
|
1957
|
+
auth: { isLoggedIn: true },
|
|
1958
|
+
iap: { nextResult: "success" },
|
|
1959
|
+
ads: { forceNoFill: false },
|
|
1960
|
+
payment: {
|
|
1961
|
+
nextResult: "success",
|
|
1962
|
+
failReason: ""
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
},
|
|
1966
|
+
{
|
|
1967
|
+
id: "permission-denied",
|
|
1968
|
+
label: "Permissions denied",
|
|
1969
|
+
description: "camera / photos / geolocation / contacts 거부",
|
|
1970
|
+
state: { permissions: {
|
|
1971
|
+
camera: "denied",
|
|
1972
|
+
photos: "denied",
|
|
1973
|
+
geolocation: "denied",
|
|
1974
|
+
contacts: "denied"
|
|
1975
|
+
} }
|
|
1976
|
+
},
|
|
1977
|
+
{
|
|
1978
|
+
id: "offline",
|
|
1979
|
+
label: "Offline",
|
|
1980
|
+
description: "getNetworkStatus → OFFLINE, IAP NETWORK_ERROR",
|
|
1981
|
+
state: {
|
|
1982
|
+
networkStatus: "OFFLINE",
|
|
1983
|
+
iap: { nextResult: "NETWORK_ERROR" },
|
|
1984
|
+
payment: {
|
|
1985
|
+
nextResult: "fail",
|
|
1986
|
+
failReason: "NETWORK_ERROR"
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
},
|
|
1990
|
+
{
|
|
1991
|
+
id: "logged-out",
|
|
1992
|
+
label: "Logged out",
|
|
1993
|
+
description: "auth.isLoggedIn=false. login flow 검증용",
|
|
1994
|
+
state: { auth: { isLoggedIn: false } }
|
|
1995
|
+
},
|
|
1996
|
+
{
|
|
1997
|
+
id: "iap-pending",
|
|
1998
|
+
label: "IAP payment pending",
|
|
1999
|
+
description: "결제 진행 중 분기 검증",
|
|
2000
|
+
state: { iap: { nextResult: "PAYMENT_PENDING" } }
|
|
2001
|
+
},
|
|
2002
|
+
{
|
|
2003
|
+
id: "ads-no-fill",
|
|
2004
|
+
label: "Ads — no fill",
|
|
2005
|
+
description: "광고 fill 실패 분기 검증",
|
|
2006
|
+
state: {
|
|
2007
|
+
networkStatus: "WIFI",
|
|
2008
|
+
ads: { forceNoFill: true }
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
];
|
|
2012
|
+
/**
|
|
2013
|
+
* Preset의 nested slice를 검증된 키만 골라서 풀어낸다. Forward-compat 차원에서
|
|
2014
|
+
* 알지 못하는 키는 drop, drop된 키 전부를 모아 한 번에 warn한다.
|
|
2015
|
+
*
|
|
2016
|
+
* Value 단위 검증은 하지 않는다 — `permissions.camera`에 enum 외 값이 들어와도
|
|
2017
|
+
* 그대로 통과한다. mock state라 잘못된 값은 mock 함수 분기 결과만 흔든다.
|
|
2018
|
+
* 새 enum 값이 추가됐을 때 저장된 preset을 reject하지 않으려는 의도.
|
|
2019
|
+
*/
|
|
2020
|
+
function pickKnownKeys(input, allowed) {
|
|
2021
|
+
if (typeof input !== "object" || input === null) return {};
|
|
2022
|
+
const out = {};
|
|
2023
|
+
const dropped = [];
|
|
2024
|
+
for (const [key, value] of Object.entries(input)) if (allowed.includes(key)) out[key] = value;
|
|
2025
|
+
else dropped.push(key);
|
|
2026
|
+
if (dropped.length > 0) console.warn(`[@ait-co/devtools] Preset dropped unknown keys: ${dropped.join(", ")}`);
|
|
2027
|
+
return out;
|
|
2028
|
+
}
|
|
2029
|
+
const PERMISSION_KEYS = [
|
|
2030
|
+
"camera",
|
|
2031
|
+
"photos",
|
|
2032
|
+
"geolocation",
|
|
2033
|
+
"clipboard",
|
|
2034
|
+
"contacts",
|
|
2035
|
+
"microphone"
|
|
2036
|
+
];
|
|
2037
|
+
const AUTH_KEYS = [
|
|
2038
|
+
"isLoggedIn",
|
|
2039
|
+
"isTossLoginIntegrated",
|
|
2040
|
+
"userKeyHash"
|
|
2041
|
+
];
|
|
2042
|
+
const IAP_KEYS = ["nextResult"];
|
|
2043
|
+
const ADS_KEYS = [
|
|
2044
|
+
"isLoaded",
|
|
2045
|
+
"nextEvent",
|
|
2046
|
+
"forceNoFill",
|
|
2047
|
+
"lastEvent"
|
|
2048
|
+
];
|
|
2049
|
+
const PAYMENT_KEYS = ["nextResult", "failReason"];
|
|
2050
|
+
/**
|
|
2051
|
+
* Preset state를 현재 `aitState`에 적용한다. 정의된 키만 덮어쓰고, 알지 못하는 키는
|
|
2052
|
+
* 조용히 drop한다 (한 번 warn). 여러 슬라이스를 적용해도 listener notify는 한 번이다
|
|
2053
|
+
* (`aitState.transaction` 사용 — panel re-render 폭주 방지).
|
|
2054
|
+
*/
|
|
2055
|
+
function applyPreset(state) {
|
|
2056
|
+
aitState.transaction(() => {
|
|
2057
|
+
if (state.networkStatus !== void 0) aitState.update({ networkStatus: state.networkStatus });
|
|
2058
|
+
if (state.permissions !== void 0) aitState.patch("permissions", pickKnownKeys(state.permissions, PERMISSION_KEYS));
|
|
2059
|
+
if (state.auth !== void 0) aitState.patch("auth", pickKnownKeys(state.auth, AUTH_KEYS));
|
|
2060
|
+
if (state.iap !== void 0) {
|
|
2061
|
+
const picked = pickKnownKeys(state.iap, IAP_KEYS);
|
|
2062
|
+
aitState.patch("iap", picked);
|
|
2063
|
+
}
|
|
2064
|
+
if (state.ads !== void 0) aitState.patch("ads", pickKnownKeys(state.ads, ADS_KEYS));
|
|
2065
|
+
if (state.payment !== void 0) aitState.patch("payment", pickKnownKeys(state.payment, PAYMENT_KEYS));
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
/**
|
|
2069
|
+
* Preset의 모든 정의된 슬라이스가 현재 state와 일치하는지 검사. UI에서 dirty
|
|
2070
|
+
* indicator를 그릴 때 쓴다.
|
|
2071
|
+
*
|
|
2072
|
+
* 일치한다 = preset이 정의한 키 전부가 그대로다. preset이 정의하지 않은 키는
|
|
2073
|
+
* 비교 대상이 아니다 — preset은 partial이므로 다른 토글이 바뀌어도 dirty가 아니다.
|
|
2074
|
+
*/
|
|
2075
|
+
function matchesPreset(snapshot, preset) {
|
|
2076
|
+
if (preset.networkStatus !== void 0 && snapshot.networkStatus !== preset.networkStatus) return false;
|
|
2077
|
+
if (preset.permissions !== void 0) for (const k of PERMISSION_KEYS) {
|
|
2078
|
+
const want = preset.permissions[k];
|
|
2079
|
+
if (want !== void 0 && snapshot.permissions[k] !== want) return false;
|
|
2080
|
+
}
|
|
2081
|
+
if (preset.auth !== void 0) for (const k of AUTH_KEYS) {
|
|
2082
|
+
const want = preset.auth[k];
|
|
2083
|
+
if (want !== void 0 && snapshot.auth[k] !== want) return false;
|
|
2084
|
+
}
|
|
2085
|
+
if (preset.iap !== void 0) {
|
|
2086
|
+
if (preset.iap.nextResult !== void 0 && snapshot.iap.nextResult !== preset.iap.nextResult) return false;
|
|
2087
|
+
}
|
|
2088
|
+
if (preset.ads !== void 0) {
|
|
2089
|
+
if (preset.ads.forceNoFill !== void 0 && snapshot.ads.forceNoFill !== preset.ads.forceNoFill) return false;
|
|
2090
|
+
if (preset.ads.isLoaded !== void 0 && snapshot.ads.isLoaded !== preset.ads.isLoaded) return false;
|
|
2091
|
+
if (preset.ads.nextEvent !== void 0 && snapshot.ads.nextEvent !== preset.ads.nextEvent) return false;
|
|
2092
|
+
}
|
|
2093
|
+
if (preset.payment !== void 0) for (const k of PAYMENT_KEYS) {
|
|
2094
|
+
const want = preset.payment[k];
|
|
2095
|
+
if (want !== void 0 && snapshot.payment[k] !== want) return false;
|
|
2096
|
+
}
|
|
2097
|
+
return true;
|
|
2098
|
+
}
|
|
2099
|
+
/**
|
|
2100
|
+
* 현재 state에서 preset에 저장할 만한 슬라이스를 추출. "save current as preset"에서 쓴다.
|
|
2101
|
+
*/
|
|
2102
|
+
function captureCurrentState(snapshot) {
|
|
2103
|
+
return {
|
|
2104
|
+
networkStatus: snapshot.networkStatus,
|
|
2105
|
+
permissions: { ...snapshot.permissions },
|
|
2106
|
+
auth: {
|
|
2107
|
+
isLoggedIn: snapshot.auth.isLoggedIn,
|
|
2108
|
+
isTossLoginIntegrated: snapshot.auth.isTossLoginIntegrated,
|
|
2109
|
+
userKeyHash: snapshot.auth.userKeyHash
|
|
2110
|
+
},
|
|
2111
|
+
iap: { nextResult: snapshot.iap.nextResult },
|
|
2112
|
+
ads: {
|
|
2113
|
+
forceNoFill: snapshot.ads.forceNoFill,
|
|
2114
|
+
isLoaded: snapshot.ads.isLoaded,
|
|
2115
|
+
nextEvent: snapshot.ads.nextEvent
|
|
2116
|
+
},
|
|
2117
|
+
payment: { ...snapshot.payment }
|
|
2118
|
+
};
|
|
2119
|
+
}
|
|
2120
|
+
//#endregion
|
|
2121
|
+
//#region src/panel/tabs/presets.ts
|
|
2122
|
+
function renderPresetsTab(refreshPanel) {
|
|
2123
|
+
const disabled = !aitState.state.panelEditable;
|
|
2124
|
+
const container = h("div");
|
|
2125
|
+
if (disabled) container.appendChild(monitoringNotice());
|
|
2126
|
+
const userPresets = listUserPresets();
|
|
2127
|
+
const snapshot = aitState.state;
|
|
2128
|
+
container.append(renderSection("Built-in scenarios", builtInPresets, disabled, snapshot, refreshPanel, false));
|
|
2129
|
+
container.append(renderSection(`Saved presets (${userPresets.length})`, userPresets, disabled, snapshot, refreshPanel, true));
|
|
2130
|
+
const saveBtn = h("button", { className: "ait-btn ait-btn-sm" }, "Save current as preset");
|
|
2131
|
+
if (disabled) saveBtn.disabled = true;
|
|
2132
|
+
saveBtn.addEventListener("click", () => {
|
|
2133
|
+
const label = window.prompt("Preset label?");
|
|
2134
|
+
if (label === null) return;
|
|
2135
|
+
try {
|
|
2136
|
+
saveUserPreset(label, captureCurrentState(aitState.state));
|
|
2137
|
+
} catch (err) {
|
|
2138
|
+
window.alert(err.message);
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
refreshPanel();
|
|
2142
|
+
});
|
|
2143
|
+
container.append(h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, "Save"), h("div", { style: "color:#888;font-size:11px;margin-bottom:6px" }, "Capture network / permissions / auth / IAP / ads / payment slices."), saveBtn));
|
|
2144
|
+
return container;
|
|
2145
|
+
}
|
|
2146
|
+
function renderSection(title, presets, disabled, snapshot, refreshPanel, deletable) {
|
|
2147
|
+
const section = h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, title));
|
|
2148
|
+
if (presets.length === 0) {
|
|
2149
|
+
section.append(h("div", { style: "color:#555;font-size:12px" }, deletable ? "No saved presets yet." : "No built-in presets."));
|
|
2150
|
+
return section;
|
|
2151
|
+
}
|
|
2152
|
+
for (const preset of presets) {
|
|
2153
|
+
const isActive = matchesPreset(snapshot, preset.state);
|
|
2154
|
+
const labelEl = h("span", { className: "ait-preset-label" }, isActive ? `✓ ${preset.label}` : preset.label);
|
|
2155
|
+
const applyBtn = h("button", { className: "ait-btn ait-btn-sm" }, isActive ? "Re-apply" : "Apply");
|
|
2156
|
+
if (disabled) applyBtn.disabled = true;
|
|
2157
|
+
applyBtn.addEventListener("click", () => {
|
|
2158
|
+
applyPreset(preset.state);
|
|
2159
|
+
refreshPanel();
|
|
2160
|
+
});
|
|
2161
|
+
const buttons = [applyBtn];
|
|
2162
|
+
if (deletable) {
|
|
2163
|
+
const delBtn = h("button", { className: "ait-btn ait-btn-sm ait-btn-danger" }, "Delete");
|
|
2164
|
+
if (disabled) delBtn.disabled = true;
|
|
2165
|
+
delBtn.addEventListener("click", () => {
|
|
2166
|
+
if (!window.confirm(`Delete preset "${preset.label}"?`)) return;
|
|
2167
|
+
deleteUserPreset(preset.id);
|
|
2168
|
+
refreshPanel();
|
|
2169
|
+
});
|
|
2170
|
+
buttons.push(delBtn);
|
|
2171
|
+
}
|
|
2172
|
+
const actions = h("span", { className: "ait-preset-actions" }, ...buttons);
|
|
2173
|
+
const row = h("div", { className: `ait-preset-row${isActive ? " ait-preset-active" : ""}` }, labelEl, actions);
|
|
2174
|
+
section.append(row);
|
|
2175
|
+
if (preset.description) section.append(h("div", { className: "ait-preset-description" }, preset.description));
|
|
2176
|
+
}
|
|
2177
|
+
return section;
|
|
2178
|
+
}
|
|
2179
|
+
//#endregion
|
|
1596
2180
|
//#region src/panel/tabs/storage.ts
|
|
1597
2181
|
function renderStorageTab(refreshPanel) {
|
|
1598
2182
|
const disabled = !aitState.state.panelEditable;
|
|
@@ -2261,6 +2845,10 @@ const TABS = [
|
|
|
2261
2845
|
id: "env",
|
|
2262
2846
|
label: "Environment"
|
|
2263
2847
|
},
|
|
2848
|
+
{
|
|
2849
|
+
id: "presets",
|
|
2850
|
+
label: "Presets"
|
|
2851
|
+
},
|
|
2264
2852
|
{
|
|
2265
2853
|
id: "viewport",
|
|
2266
2854
|
label: "Viewport"
|
|
@@ -2281,6 +2869,10 @@ const TABS = [
|
|
|
2281
2869
|
id: "iap",
|
|
2282
2870
|
label: "IAP"
|
|
2283
2871
|
},
|
|
2872
|
+
{
|
|
2873
|
+
id: "ads",
|
|
2874
|
+
label: "Ads"
|
|
2875
|
+
},
|
|
2284
2876
|
{
|
|
2285
2877
|
id: "events",
|
|
2286
2878
|
label: "Events"
|
|
@@ -2297,11 +2889,13 @@ const TABS = [
|
|
|
2297
2889
|
function createTabRenderers(refreshPanel) {
|
|
2298
2890
|
return {
|
|
2299
2891
|
env: renderEnvironmentTab,
|
|
2892
|
+
presets: () => renderPresetsTab(refreshPanel),
|
|
2300
2893
|
permissions: renderPermissionsTab,
|
|
2301
2894
|
location: renderLocationTab,
|
|
2302
2895
|
device: renderDeviceTab,
|
|
2303
2896
|
viewport: renderViewportTab,
|
|
2304
2897
|
iap: renderIapTab,
|
|
2898
|
+
ads: renderAdsTab,
|
|
2305
2899
|
events: renderEventsTab,
|
|
2306
2900
|
analytics: renderAnalyticsTab,
|
|
2307
2901
|
storage: () => renderStorageTab(refreshPanel)
|
|
@@ -2502,7 +3096,7 @@ function mount() {
|
|
|
2502
3096
|
mockBadge.textContent = aitState.state.panelEditable ? "EDIT" : "READ-ONLY";
|
|
2503
3097
|
refreshPanel();
|
|
2504
3098
|
});
|
|
2505
|
-
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.
|
|
3099
|
+
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.9`), closeBtn);
|
|
2506
3100
|
const header = h("div", { className: "ait-panel-header" }, h("span", {}, "AIT DevTools"), headerRight);
|
|
2507
3101
|
tabsEl = h("div", { className: "ait-panel-tabs" });
|
|
2508
3102
|
for (const tab of TABS) {
|
|
@@ -2541,7 +3135,7 @@ function mount() {
|
|
|
2541
3135
|
});
|
|
2542
3136
|
aitState.subscribe(() => {
|
|
2543
3137
|
try {
|
|
2544
|
-
if (isOpen && (currentTab === "analytics" || currentTab === "storage" || currentTab === "device" || currentTab === "viewport" || currentTab === "iap")) refreshPanel();
|
|
3138
|
+
if (isOpen && (currentTab === "analytics" || currentTab === "storage" || currentTab === "device" || currentTab === "viewport" || currentTab === "iap" || currentTab === "ads" || currentTab === "presets")) refreshPanel();
|
|
2545
3139
|
} catch (err) {
|
|
2546
3140
|
console.error("[@ait-co/devtools] Error in subscribe callback:", err);
|
|
2547
3141
|
}
|