@ait-co/devtools 0.1.8 → 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.
@@ -117,6 +117,7 @@ function generateDeviceId() {
117
117
  var AitStateManager = class {
118
118
  _state;
119
119
  _listeners = /* @__PURE__ */ new Set();
120
+ _inTransaction = false;
120
121
  constructor() {
121
122
  this._state = structuredClone(DEFAULT_STATE);
122
123
  try {
@@ -155,6 +156,34 @@ var AitStateManager = class {
155
156
  this._listeners.add(listener);
156
157
  return () => this._listeners.delete(listener);
157
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
+ }
158
187
  /** 분석 로그 추가 */
159
188
  logAnalytics(entry) {
160
189
  this._state = {
@@ -179,6 +208,7 @@ var AitStateManager = class {
179
208
  this._notify();
180
209
  }
181
210
  _notify() {
211
+ if (this._inTransaction) return;
182
212
  for (const listener of this._listeners) listener();
183
213
  }
184
214
  };
@@ -573,6 +603,38 @@ const PANEL_STYLES = `
573
603
  color: #e53e3e; /* readable on both light (#fff) and dark (#1a1a2e) panel backgrounds */
574
604
  }
575
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
+
576
638
  /* Viewport tab status rows */
577
639
  .ait-status-row {
578
640
  display: flex;
@@ -1781,6 +1843,340 @@ function renderPermissionsTab() {
1781
1843
  return container;
1782
1844
  }
1783
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
1784
2180
  //#region src/panel/tabs/storage.ts
1785
2181
  function renderStorageTab(refreshPanel) {
1786
2182
  const disabled = !aitState.state.panelEditable;
@@ -2449,6 +2845,10 @@ const TABS = [
2449
2845
  id: "env",
2450
2846
  label: "Environment"
2451
2847
  },
2848
+ {
2849
+ id: "presets",
2850
+ label: "Presets"
2851
+ },
2452
2852
  {
2453
2853
  id: "viewport",
2454
2854
  label: "Viewport"
@@ -2489,6 +2889,7 @@ const TABS = [
2489
2889
  function createTabRenderers(refreshPanel) {
2490
2890
  return {
2491
2891
  env: renderEnvironmentTab,
2892
+ presets: () => renderPresetsTab(refreshPanel),
2492
2893
  permissions: renderPermissionsTab,
2493
2894
  location: renderLocationTab,
2494
2895
  device: renderDeviceTab,
@@ -2695,7 +3096,7 @@ function mount() {
2695
3096
  mockBadge.textContent = aitState.state.panelEditable ? "EDIT" : "READ-ONLY";
2696
3097
  refreshPanel();
2697
3098
  });
2698
- 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.8`), 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);
2699
3100
  const header = h("div", { className: "ait-panel-header" }, h("span", {}, "AIT DevTools"), headerRight);
2700
3101
  tabsEl = h("div", { className: "ait-panel-tabs" });
2701
3102
  for (const tab of TABS) {
@@ -2734,7 +3135,7 @@ function mount() {
2734
3135
  });
2735
3136
  aitState.subscribe(() => {
2736
3137
  try {
2737
- if (isOpen && (currentTab === "analytics" || currentTab === "storage" || currentTab === "device" || currentTab === "viewport" || currentTab === "iap" || currentTab === "ads")) refreshPanel();
3138
+ if (isOpen && (currentTab === "analytics" || currentTab === "storage" || currentTab === "device" || currentTab === "viewport" || currentTab === "iap" || currentTab === "ads" || currentTab === "presets")) refreshPanel();
2738
3139
  } catch (err) {
2739
3140
  console.error("[@ait-co/devtools] Error in subscribe callback:", err);
2740
3141
  }