@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.
@@ -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.7`), closeBtn);
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
  }