@ait-co/devtools 0.1.14 → 0.1.15
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/dist/mock/index.d.ts +4 -10
- package/dist/mock/index.d.ts.map +1 -1
- package/dist/mock/index.js +14 -11
- package/dist/mock/index.js.map +1 -1
- package/dist/panel/index.d.ts.map +1 -1
- package/dist/panel/index.js +518 -3
- package/dist/panel/index.js.map +1 -1
- package/package.json +1 -1
package/dist/panel/index.js
CHANGED
|
@@ -70,6 +70,7 @@ const DEFAULT_STATE = {
|
|
|
70
70
|
userKeyHash: "mock-user-hash-abc123",
|
|
71
71
|
anonymousKeyHash: "mock-anon-hash-xyz789"
|
|
72
72
|
},
|
|
73
|
+
notification: { nextResult: "newAgreement" },
|
|
73
74
|
ads: {
|
|
74
75
|
isLoaded: false,
|
|
75
76
|
nextEvent: "loaded",
|
|
@@ -219,6 +220,429 @@ if (!globalRef[SINGLETON_KEY]) globalRef[SINGLETON_KEY] = new AitStateManager();
|
|
|
219
220
|
const aitState = globalRef[SINGLETON_KEY];
|
|
220
221
|
if (typeof window !== "undefined") window.__ait = aitState;
|
|
221
222
|
//#endregion
|
|
223
|
+
//#region src/telemetry/consent-toast.ts
|
|
224
|
+
/**
|
|
225
|
+
* Consent toast UI — vanilla DOM, fixed bottom-right.
|
|
226
|
+
*
|
|
227
|
+
* Ko-only for now — devtools has no i18n layer.
|
|
228
|
+
* TODO: revisit when/if an i18n layer is added to the panel.
|
|
229
|
+
*
|
|
230
|
+
* Shows once per "undecided + reprompt window cleared" session.
|
|
231
|
+
* Calls onAccept / onDeny callbacks; caller is responsible for persisting state.
|
|
232
|
+
*/
|
|
233
|
+
const TOAST_ID = "__ait-telemetry-toast";
|
|
234
|
+
const TOAST_STYLES = `
|
|
235
|
+
#${TOAST_ID} {
|
|
236
|
+
position: fixed;
|
|
237
|
+
z-index: 100001;
|
|
238
|
+
bottom: 80px;
|
|
239
|
+
right: 16px;
|
|
240
|
+
width: 280px;
|
|
241
|
+
background: #1a1a2e;
|
|
242
|
+
border: 1px solid #3a3a5a;
|
|
243
|
+
border-radius: 10px;
|
|
244
|
+
padding: 14px 16px;
|
|
245
|
+
box-shadow: 0 4px 24px rgba(0,0,0,0.4);
|
|
246
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Pretendard', sans-serif;
|
|
247
|
+
font-size: 13px;
|
|
248
|
+
color: #e0e0e0;
|
|
249
|
+
box-sizing: border-box;
|
|
250
|
+
}
|
|
251
|
+
#${TOAST_ID} .ait-toast-header {
|
|
252
|
+
font-size: 13px;
|
|
253
|
+
font-weight: 600;
|
|
254
|
+
color: #e0e0e0;
|
|
255
|
+
margin-bottom: 6px;
|
|
256
|
+
}
|
|
257
|
+
#${TOAST_ID} .ait-toast-body {
|
|
258
|
+
font-size: 12px;
|
|
259
|
+
color: #aaa;
|
|
260
|
+
margin-bottom: 12px;
|
|
261
|
+
line-height: 1.5;
|
|
262
|
+
}
|
|
263
|
+
#${TOAST_ID} .ait-toast-actions {
|
|
264
|
+
display: flex;
|
|
265
|
+
gap: 8px;
|
|
266
|
+
align-items: center;
|
|
267
|
+
justify-content: flex-end;
|
|
268
|
+
}
|
|
269
|
+
#${TOAST_ID} .ait-toast-btn-primary {
|
|
270
|
+
background: #3182F6;
|
|
271
|
+
color: white;
|
|
272
|
+
border: none;
|
|
273
|
+
border-radius: 4px;
|
|
274
|
+
padding: 6px 12px;
|
|
275
|
+
font-size: 12px;
|
|
276
|
+
cursor: pointer;
|
|
277
|
+
font-family: inherit;
|
|
278
|
+
}
|
|
279
|
+
#${TOAST_ID} .ait-toast-btn-primary:hover { background: #1b6ef3; }
|
|
280
|
+
#${TOAST_ID} .ait-toast-btn-secondary {
|
|
281
|
+
background: #2a2a4a;
|
|
282
|
+
color: #e0e0e0;
|
|
283
|
+
border: 1px solid #3a3a5a;
|
|
284
|
+
border-radius: 4px;
|
|
285
|
+
padding: 6px 12px;
|
|
286
|
+
font-size: 12px;
|
|
287
|
+
cursor: pointer;
|
|
288
|
+
font-family: inherit;
|
|
289
|
+
}
|
|
290
|
+
#${TOAST_ID} .ait-toast-btn-secondary:hover { background: #3a3a5a; }
|
|
291
|
+
#${TOAST_ID} .ait-toast-link {
|
|
292
|
+
font-size: 11px;
|
|
293
|
+
color: #666;
|
|
294
|
+
text-decoration: none;
|
|
295
|
+
margin-right: auto;
|
|
296
|
+
}
|
|
297
|
+
#${TOAST_ID} .ait-toast-link:hover { color: #aaa; }
|
|
298
|
+
`;
|
|
299
|
+
function injectStyles() {
|
|
300
|
+
if (document.getElementById(`${TOAST_ID}-style`)) return;
|
|
301
|
+
const style = document.createElement("style");
|
|
302
|
+
style.id = `${TOAST_ID}-style`;
|
|
303
|
+
style.textContent = TOAST_STYLES;
|
|
304
|
+
document.head.appendChild(style);
|
|
305
|
+
}
|
|
306
|
+
function removeToast() {
|
|
307
|
+
document.getElementById(TOAST_ID)?.remove();
|
|
308
|
+
document.getElementById(`${TOAST_ID}-style`)?.remove();
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Renders and shows the consent toast.
|
|
312
|
+
* If the toast is already visible, does nothing.
|
|
313
|
+
*/
|
|
314
|
+
function showConsentToast({ onAccept, onDeny }) {
|
|
315
|
+
if (document.getElementById(TOAST_ID)) return;
|
|
316
|
+
injectStyles();
|
|
317
|
+
const toast = document.createElement("div");
|
|
318
|
+
toast.id = TOAST_ID;
|
|
319
|
+
const header = document.createElement("div");
|
|
320
|
+
header.className = "ait-toast-header";
|
|
321
|
+
header.textContent = "익명 사용 통계를 보낼까요?";
|
|
322
|
+
const body = document.createElement("div");
|
|
323
|
+
body.className = "ait-toast-body";
|
|
324
|
+
body.textContent = "도구 개선을 위해 익명 이벤트만 수집해요. 언제든 환경 탭에서 끌 수 있어요.";
|
|
325
|
+
const learnMore = document.createElement("a");
|
|
326
|
+
learnMore.className = "ait-toast-link";
|
|
327
|
+
learnMore.href = "https://docs.aitc.dev/privacy";
|
|
328
|
+
learnMore.target = "_blank";
|
|
329
|
+
learnMore.rel = "noopener noreferrer";
|
|
330
|
+
learnMore.textContent = "더 알아보기";
|
|
331
|
+
const yesBtn = document.createElement("button");
|
|
332
|
+
yesBtn.className = "ait-toast-btn-primary";
|
|
333
|
+
yesBtn.textContent = "Yes, send";
|
|
334
|
+
yesBtn.addEventListener("click", () => {
|
|
335
|
+
removeToast();
|
|
336
|
+
onAccept();
|
|
337
|
+
});
|
|
338
|
+
const noBtn = document.createElement("button");
|
|
339
|
+
noBtn.className = "ait-toast-btn-secondary";
|
|
340
|
+
noBtn.textContent = "No, thanks";
|
|
341
|
+
noBtn.addEventListener("click", () => {
|
|
342
|
+
removeToast();
|
|
343
|
+
onDeny();
|
|
344
|
+
});
|
|
345
|
+
const actions = document.createElement("div");
|
|
346
|
+
actions.className = "ait-toast-actions";
|
|
347
|
+
actions.append(learnMore, noBtn, yesBtn);
|
|
348
|
+
toast.append(header, body, actions);
|
|
349
|
+
document.body.appendChild(toast);
|
|
350
|
+
}
|
|
351
|
+
//#endregion
|
|
352
|
+
//#region src/telemetry/state.ts
|
|
353
|
+
const KEY_CONSENT = "__ait_telemetry:consent";
|
|
354
|
+
const KEY_REPROMPT_AFTER = "__ait_telemetry:reprompt_after";
|
|
355
|
+
const KEY_POLICY_VERSION = "__ait_telemetry:policy_version";
|
|
356
|
+
const KEY_ANON_ID = "__ait_telemetry:anon_id";
|
|
357
|
+
/**
|
|
358
|
+
* Current policy version. Bump this string whenever the privacy policy changes.
|
|
359
|
+
* Users who previously granted on an older version will be re-prompted once.
|
|
360
|
+
*/
|
|
361
|
+
const CURRENT_POLICY_VERSION = "2026-05-12";
|
|
362
|
+
/** 30 days in milliseconds */
|
|
363
|
+
const THIRTY_DAYS_MS = 720 * 60 * 60 * 1e3;
|
|
364
|
+
function readConsentState() {
|
|
365
|
+
const raw = localStorage.getItem(KEY_CONSENT);
|
|
366
|
+
if (raw === "granted" || raw === "denied") return raw;
|
|
367
|
+
return "undecided";
|
|
368
|
+
}
|
|
369
|
+
function readRepromptAfter() {
|
|
370
|
+
const raw = localStorage.getItem(KEY_REPROMPT_AFTER);
|
|
371
|
+
if (raw === null) return 0;
|
|
372
|
+
const n = Number(raw);
|
|
373
|
+
return Number.isFinite(n) ? n : 0;
|
|
374
|
+
}
|
|
375
|
+
function readPolicyVersion() {
|
|
376
|
+
return localStorage.getItem(KEY_POLICY_VERSION);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Returns the stored anon_id, or generates + persists a new UUID v4 on first call.
|
|
380
|
+
* Once generated it is never overwritten.
|
|
381
|
+
*/
|
|
382
|
+
function getOrCreateAnonId() {
|
|
383
|
+
const existing = localStorage.getItem(KEY_ANON_ID);
|
|
384
|
+
if (existing) return existing;
|
|
385
|
+
const id = crypto.randomUUID();
|
|
386
|
+
localStorage.setItem(KEY_ANON_ID, id);
|
|
387
|
+
return id;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Resolve effective consent, handling the policy-version bump rule:
|
|
391
|
+
* - If stored = "granted" but stored version ≠ CURRENT → revert to undecided
|
|
392
|
+
* - If stored = "denied" and version changed → stay denied (no re-prompt)
|
|
393
|
+
*
|
|
394
|
+
* Call this at init time to normalise state before checking whether to show a toast.
|
|
395
|
+
* Returns the effective ConsentState after applying the version-bump rule.
|
|
396
|
+
*/
|
|
397
|
+
function resolveEffectiveConsent() {
|
|
398
|
+
const raw = localStorage.getItem(KEY_CONSENT);
|
|
399
|
+
if (raw === "granted") {
|
|
400
|
+
if (readPolicyVersion() !== "2026-05-12") {
|
|
401
|
+
localStorage.removeItem(KEY_CONSENT);
|
|
402
|
+
localStorage.removeItem(KEY_POLICY_VERSION);
|
|
403
|
+
return "undecided";
|
|
404
|
+
}
|
|
405
|
+
return "granted";
|
|
406
|
+
}
|
|
407
|
+
if (raw === "denied") return "denied";
|
|
408
|
+
return "undecided";
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* User clicked "Yes, send".
|
|
412
|
+
* Sets consent = granted, records policy version.
|
|
413
|
+
*/
|
|
414
|
+
function acceptConsent() {
|
|
415
|
+
localStorage.setItem(KEY_CONSENT, "granted");
|
|
416
|
+
localStorage.setItem(KEY_POLICY_VERSION, CURRENT_POLICY_VERSION);
|
|
417
|
+
localStorage.removeItem(KEY_REPROMPT_AFTER);
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* User clicked "No, thanks".
|
|
421
|
+
* First denial: sets reprompt_after = now + 30 days.
|
|
422
|
+
* Second denial (reprompt_after was already set to a past finite value that triggered
|
|
423
|
+
* re-prompt): sets reprompt_after = MAX_SAFE_INTEGER → permanent silence.
|
|
424
|
+
*/
|
|
425
|
+
function denyConsent() {
|
|
426
|
+
localStorage.setItem(KEY_CONSENT, "denied");
|
|
427
|
+
const existing = readRepromptAfter();
|
|
428
|
+
if (existing > 0 && existing < Number.MAX_SAFE_INTEGER) localStorage.setItem(KEY_REPROMPT_AFTER, String(Number.MAX_SAFE_INTEGER));
|
|
429
|
+
else localStorage.setItem(KEY_REPROMPT_AFTER, String(Date.now() + THIRTY_DAYS_MS));
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Environment-tab toggle: free transition between granted/denied.
|
|
433
|
+
* Does NOT touch reprompt_after.
|
|
434
|
+
*/
|
|
435
|
+
function setConsentViaToggle(granted) {
|
|
436
|
+
if (granted) {
|
|
437
|
+
localStorage.setItem(KEY_CONSENT, "granted");
|
|
438
|
+
localStorage.setItem(KEY_POLICY_VERSION, CURRENT_POLICY_VERSION);
|
|
439
|
+
} else localStorage.setItem(KEY_CONSENT, "denied");
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Returns true if the toast should be shown now.
|
|
443
|
+
* Conditions: undecided AND (reprompt_after is 0 OR reprompt_after < now).
|
|
444
|
+
*/
|
|
445
|
+
function shouldShowToast() {
|
|
446
|
+
if (resolveEffectiveConsent() !== "undecided") return false;
|
|
447
|
+
const repromptAfter = readRepromptAfter();
|
|
448
|
+
if (repromptAfter === 0) return true;
|
|
449
|
+
return Date.now() > repromptAfter;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Sends the DELETE request to remove the user's data from the server.
|
|
453
|
+
*/
|
|
454
|
+
async function deleteMyData(endpoint) {
|
|
455
|
+
const anonId = localStorage.getItem(KEY_ANON_ID);
|
|
456
|
+
if (!anonId) return false;
|
|
457
|
+
try {
|
|
458
|
+
return (await fetch(`${endpoint}?anon_id=${encodeURIComponent(anonId)}`, { method: "DELETE" })).ok;
|
|
459
|
+
} catch {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
//#endregion
|
|
464
|
+
//#region src/telemetry/send.ts
|
|
465
|
+
/**
|
|
466
|
+
* Telemetry send + retry.
|
|
467
|
+
*
|
|
468
|
+
* Rules:
|
|
469
|
+
* 1. If consent ≠ "granted" — drop silently.
|
|
470
|
+
* 2. POST event as JSON with 5 s timeout.
|
|
471
|
+
* 3. On network error or non-2xx: retry ONCE after 2 s. On second failure: drop.
|
|
472
|
+
* 4. console.debug on retry, development only (NODE_ENV !== "production").
|
|
473
|
+
* 5. For "session_duration": use sendBeacon if available, fall back to fetch keepalive.
|
|
474
|
+
*
|
|
475
|
+
* Max meta size: 256 bytes (JSON-serialized). Over-size meta is dropped to undefined.
|
|
476
|
+
*/
|
|
477
|
+
/** Meta cap per server contract (JSON bytes). */
|
|
478
|
+
const META_BYTE_CAP = 256;
|
|
479
|
+
function sanitizeMeta(meta) {
|
|
480
|
+
if (meta === void 0) return void 0;
|
|
481
|
+
const serialized = JSON.stringify(meta);
|
|
482
|
+
if (new TextEncoder().encode(serialized).length > META_BYTE_CAP) return;
|
|
483
|
+
return meta;
|
|
484
|
+
}
|
|
485
|
+
async function doFetch(payload) {
|
|
486
|
+
const controller = new AbortController();
|
|
487
|
+
const timeoutId = setTimeout(() => controller.abort(), 5e3);
|
|
488
|
+
try {
|
|
489
|
+
return (await fetch(`${TELEMETRY_ENDPOINT}/e`, {
|
|
490
|
+
method: "POST",
|
|
491
|
+
headers: { "Content-Type": "application/json" },
|
|
492
|
+
body: JSON.stringify(payload),
|
|
493
|
+
signal: controller.signal
|
|
494
|
+
})).ok;
|
|
495
|
+
} catch {
|
|
496
|
+
return false;
|
|
497
|
+
} finally {
|
|
498
|
+
clearTimeout(timeoutId);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function delay(ms) {
|
|
502
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Send a telemetry event. Drops silently if consent is not "granted".
|
|
506
|
+
*/
|
|
507
|
+
async function send(event, version, meta) {
|
|
508
|
+
if (readConsentState() !== "granted") return;
|
|
509
|
+
const payload = {
|
|
510
|
+
source: "devtools",
|
|
511
|
+
event,
|
|
512
|
+
anon_id: getOrCreateAnonId(),
|
|
513
|
+
version,
|
|
514
|
+
ts: Date.now(),
|
|
515
|
+
meta: sanitizeMeta(meta)
|
|
516
|
+
};
|
|
517
|
+
if (await doFetch(payload)) return;
|
|
518
|
+
if (process.env.NODE_ENV !== "production") console.debug("[@ait-co/devtools] telemetry: retrying after failure", event);
|
|
519
|
+
await delay(2e3);
|
|
520
|
+
await doFetch(payload);
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Send the "session_duration" event via sendBeacon (unload-safe).
|
|
524
|
+
* Falls back to fetch with keepalive if sendBeacon is unavailable.
|
|
525
|
+
* No retry during page unload.
|
|
526
|
+
*/
|
|
527
|
+
function sendBeaconEvent(event, version, meta) {
|
|
528
|
+
if (readConsentState() !== "granted") return;
|
|
529
|
+
const payload = {
|
|
530
|
+
source: "devtools",
|
|
531
|
+
event,
|
|
532
|
+
anon_id: getOrCreateAnonId(),
|
|
533
|
+
version,
|
|
534
|
+
ts: Date.now(),
|
|
535
|
+
meta: sanitizeMeta(meta)
|
|
536
|
+
};
|
|
537
|
+
const body = JSON.stringify(payload);
|
|
538
|
+
if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
|
|
539
|
+
navigator.sendBeacon(`${TELEMETRY_ENDPOINT}/e`, new Blob([body], { type: "application/json" }));
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
fetch(`${TELEMETRY_ENDPOINT}/e`, {
|
|
543
|
+
method: "POST",
|
|
544
|
+
headers: { "Content-Type": "application/json" },
|
|
545
|
+
body,
|
|
546
|
+
keepalive: true
|
|
547
|
+
}).catch(() => {});
|
|
548
|
+
}
|
|
549
|
+
//#endregion
|
|
550
|
+
//#region src/telemetry/index.ts
|
|
551
|
+
/**
|
|
552
|
+
* Telemetry client — internal to @ait-co/devtools.
|
|
553
|
+
*
|
|
554
|
+
* NOT exported from src/mock/index.ts — this is panel-internal only.
|
|
555
|
+
*
|
|
556
|
+
* Usage: import { telemetry } from './telemetry/index.js' (from panel code).
|
|
557
|
+
*/
|
|
558
|
+
/**
|
|
559
|
+
* Telemetry ingest endpoint.
|
|
560
|
+
* Overridable at build time via define (e.g., for e2e / local dev).
|
|
561
|
+
* Do NOT expose this as a public env-var surface.
|
|
562
|
+
*/
|
|
563
|
+
function readGlobalString(key) {
|
|
564
|
+
const val = globalThis[key];
|
|
565
|
+
return typeof val === "string" ? val : void 0;
|
|
566
|
+
}
|
|
567
|
+
const TELEMETRY_ENDPOINT = readGlobalString("__TELEMETRY_ENDPOINT__") ?? "https://t.aitc.dev";
|
|
568
|
+
function getVersion() {
|
|
569
|
+
return readGlobalString("__VERSION__") ?? "0.0.0";
|
|
570
|
+
}
|
|
571
|
+
let panelVisibleSince = null;
|
|
572
|
+
let accumulatedMs = 0;
|
|
573
|
+
let pagehideWired = false;
|
|
574
|
+
function onPanelVisible() {
|
|
575
|
+
if (panelVisibleSince === null) panelVisibleSince = Date.now();
|
|
576
|
+
}
|
|
577
|
+
function onPanelHidden() {
|
|
578
|
+
if (panelVisibleSince !== null) {
|
|
579
|
+
accumulatedMs += Date.now() - panelVisibleSince;
|
|
580
|
+
panelVisibleSince = null;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
function wirePagehide() {
|
|
584
|
+
if (pagehideWired) return;
|
|
585
|
+
pagehideWired = true;
|
|
586
|
+
window.addEventListener("pagehide", () => {
|
|
587
|
+
onPanelHidden();
|
|
588
|
+
if (accumulatedMs > 0) sendBeaconEvent("session_duration", getVersion(), { ms: accumulatedMs });
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Call once after panel mounts.
|
|
593
|
+
* Handles: consent check, optional toast, panel_mount event, pagehide wiring.
|
|
594
|
+
*/
|
|
595
|
+
function init() {
|
|
596
|
+
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
597
|
+
wirePagehide();
|
|
598
|
+
if (resolveEffectiveConsent() === "granted") {
|
|
599
|
+
getOrCreateAnonId();
|
|
600
|
+
send("panel_mount", getVersion());
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (shouldShowToast()) {
|
|
604
|
+
const showToast = () => {
|
|
605
|
+
showConsentToast({
|
|
606
|
+
onAccept: () => {
|
|
607
|
+
acceptConsent();
|
|
608
|
+
getOrCreateAnonId();
|
|
609
|
+
send("panel_mount", getVersion());
|
|
610
|
+
},
|
|
611
|
+
onDeny: () => {
|
|
612
|
+
denyConsent();
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
};
|
|
616
|
+
if (typeof requestIdleCallback === "function") requestIdleCallback(showToast, { timeout: 3e3 });
|
|
617
|
+
else setTimeout(showToast, 1500);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Call when the panel is opened/toggled visible.
|
|
622
|
+
*/
|
|
623
|
+
function onPanelOpen() {
|
|
624
|
+
send("panel_open", getVersion());
|
|
625
|
+
onPanelVisible();
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Call when the panel is closed/hidden.
|
|
629
|
+
*/
|
|
630
|
+
function onPanelClose() {
|
|
631
|
+
onPanelHidden();
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Call when the user switches tabs.
|
|
635
|
+
*/
|
|
636
|
+
function onTabView(tabId) {
|
|
637
|
+
send("tab_view", getVersion(), { tab: tabId });
|
|
638
|
+
}
|
|
639
|
+
const telemetry = {
|
|
640
|
+
init,
|
|
641
|
+
onPanelOpen,
|
|
642
|
+
onPanelClose,
|
|
643
|
+
onTabView
|
|
644
|
+
};
|
|
645
|
+
//#endregion
|
|
222
646
|
//#region src/panel/helpers.ts
|
|
223
647
|
/**
|
|
224
648
|
* 공통 DOM 헬퍼 함수
|
|
@@ -1621,9 +2045,54 @@ function renderEnvironmentTab() {
|
|
|
1621
2045
|
"OFFLINE",
|
|
1622
2046
|
"WWAN",
|
|
1623
2047
|
"UNKNOWN"
|
|
1624
|
-
], s.networkStatus, (v) => aitState.update({ networkStatus: v }), disabled)), h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, "Safe Area Insets"), inputRow("Top", String(s.safeAreaInsets.top), (v) => aitState.patch("safeAreaInsets", { top: Number(v) }), disabled), inputRow("Bottom", String(s.safeAreaInsets.bottom), (v) => aitState.patch("safeAreaInsets", { bottom: Number(v) }), disabled)));
|
|
2048
|
+
], s.networkStatus, (v) => aitState.update({ networkStatus: v }), disabled)), h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, "Safe Area Insets"), inputRow("Top", String(s.safeAreaInsets.top), (v) => aitState.patch("safeAreaInsets", { top: Number(v) }), disabled), inputRow("Bottom", String(s.safeAreaInsets.bottom), (v) => aitState.patch("safeAreaInsets", { bottom: Number(v) }), disabled)), buildTelemetrySection());
|
|
1625
2049
|
return container;
|
|
1626
2050
|
}
|
|
2051
|
+
function buildTelemetrySection() {
|
|
2052
|
+
const isGranted = readConsentState() === "granted";
|
|
2053
|
+
const statusLabel = h("span", { style: `font-size:12px;font-weight:600;color:${isGranted ? "#4ade80" : "#888"}` }, isGranted ? "On" : "Off");
|
|
2054
|
+
const toggleBtn = h("button", {
|
|
2055
|
+
className: "ait-btn ait-btn-sm",
|
|
2056
|
+
style: "font-size:11px"
|
|
2057
|
+
}, isGranted ? "Turn off" : "Turn on");
|
|
2058
|
+
toggleBtn.addEventListener("click", () => {
|
|
2059
|
+
setConsentViaToggle(!isGranted);
|
|
2060
|
+
window.dispatchEvent(new CustomEvent("__ait:panel-switch-tab", { detail: { tab: "env" } }));
|
|
2061
|
+
});
|
|
2062
|
+
const statusRow = h("div", { className: "ait-row" }, h("label", {}, "Telemetry"), h("span", { style: "display:flex;align-items:center;gap:8px" }, statusLabel, toggleBtn));
|
|
2063
|
+
const anonId = localStorage.getItem("__ait_telemetry:anon_id") ?? "(not yet set)";
|
|
2064
|
+
const anonIdEl = h("span", {
|
|
2065
|
+
style: "font-family:'SF Mono','Menlo',monospace;font-size:11px;color:#95e6cb;cursor:pointer",
|
|
2066
|
+
title: "Click to copy full anon_id"
|
|
2067
|
+
}, `anon_id: ${anonId.length > 8 ? `${anonId.slice(0, 8)}…` : anonId}`);
|
|
2068
|
+
anonIdEl.addEventListener("click", () => {
|
|
2069
|
+
navigator.clipboard.writeText(anonId).catch(() => {});
|
|
2070
|
+
});
|
|
2071
|
+
const deleteBtn = h("button", { className: "ait-btn ait-btn-sm ait-btn-danger" }, "내 데이터 삭제");
|
|
2072
|
+
const deleteStatus = h("span", { style: "font-size:11px;color:#aaa" });
|
|
2073
|
+
deleteBtn.addEventListener("click", () => {
|
|
2074
|
+
deleteBtn.disabled = true;
|
|
2075
|
+
deleteStatus.textContent = "삭제 중…";
|
|
2076
|
+
deleteMyData(TELEMETRY_ENDPOINT).then((ok) => {
|
|
2077
|
+
deleteStatus.textContent = ok ? "삭제 완료" : "삭제 실패 (다시 시도해주세요)";
|
|
2078
|
+
deleteBtn.disabled = false;
|
|
2079
|
+
}).catch(() => {
|
|
2080
|
+
deleteStatus.textContent = "삭제 실패";
|
|
2081
|
+
deleteBtn.disabled = false;
|
|
2082
|
+
});
|
|
2083
|
+
});
|
|
2084
|
+
const privacyLink = h("a", {
|
|
2085
|
+
href: "https://docs.aitc.dev/privacy",
|
|
2086
|
+
target: "_blank",
|
|
2087
|
+
rel: "noopener noreferrer",
|
|
2088
|
+
style: "font-size:11px;color:#666;text-decoration:none"
|
|
2089
|
+
});
|
|
2090
|
+
privacyLink.textContent = "개인정보 처리방침";
|
|
2091
|
+
return h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, "Telemetry"), statusRow, h("div", { style: "margin-bottom:6px" }, anonIdEl), h("div", {
|
|
2092
|
+
className: "ait-btn-row",
|
|
2093
|
+
style: "align-items:center;gap:8px;margin-top:6px"
|
|
2094
|
+
}, deleteBtn, deleteStatus), h("div", { style: "margin-top:8px" }, privacyLink));
|
|
2095
|
+
}
|
|
1627
2096
|
//#endregion
|
|
1628
2097
|
//#region src/panel/tabs/events.ts
|
|
1629
2098
|
function renderEventsTab() {
|
|
@@ -1832,6 +2301,43 @@ function renderLocationTab() {
|
|
|
1832
2301
|
return container;
|
|
1833
2302
|
}
|
|
1834
2303
|
//#endregion
|
|
2304
|
+
//#region src/panel/tabs/notifications.ts
|
|
2305
|
+
const RESULTS = [
|
|
2306
|
+
{
|
|
2307
|
+
value: "newAgreement",
|
|
2308
|
+
label: "newAgreement (first-time agree)"
|
|
2309
|
+
},
|
|
2310
|
+
{
|
|
2311
|
+
value: "alreadyAgreed",
|
|
2312
|
+
label: "alreadyAgreed (already opted-in)"
|
|
2313
|
+
},
|
|
2314
|
+
{
|
|
2315
|
+
value: "agreementRejected",
|
|
2316
|
+
label: "agreementRejected (user declined)"
|
|
2317
|
+
}
|
|
2318
|
+
];
|
|
2319
|
+
function radioRow(name, current, option, disabled) {
|
|
2320
|
+
const input = h("input", {
|
|
2321
|
+
type: "radio",
|
|
2322
|
+
name,
|
|
2323
|
+
value: option.value
|
|
2324
|
+
});
|
|
2325
|
+
input.checked = current === option.value;
|
|
2326
|
+
if (disabled) input.disabled = true;
|
|
2327
|
+
input.addEventListener("change", () => {
|
|
2328
|
+
if (input.checked) aitState.patch("notification", { nextResult: option.value });
|
|
2329
|
+
});
|
|
2330
|
+
return h("label", { className: "ait-row" }, input, h("span", {}, option.label));
|
|
2331
|
+
}
|
|
2332
|
+
function renderNotificationsTab() {
|
|
2333
|
+
const s = aitState.state;
|
|
2334
|
+
const disabled = !s.panelEditable;
|
|
2335
|
+
const container = h("div");
|
|
2336
|
+
if (disabled) container.appendChild(monitoringNotice());
|
|
2337
|
+
container.append(h("div", { className: "ait-section" }, h("div", { className: "ait-section-title" }, "requestNotificationAgreement"), ...RESULTS.map((opt) => radioRow("ait-notification-result", s.notification.nextResult, opt, disabled))));
|
|
2338
|
+
return container;
|
|
2339
|
+
}
|
|
2340
|
+
//#endregion
|
|
1835
2341
|
//#region src/panel/tabs/permissions.ts
|
|
1836
2342
|
function renderPermissionsTab() {
|
|
1837
2343
|
const s = aitState.state;
|
|
@@ -2888,6 +3394,10 @@ const TABS = [
|
|
|
2888
3394
|
id: "permissions",
|
|
2889
3395
|
label: "Permissions"
|
|
2890
3396
|
},
|
|
3397
|
+
{
|
|
3398
|
+
id: "notifications",
|
|
3399
|
+
label: "Notifications"
|
|
3400
|
+
},
|
|
2891
3401
|
{
|
|
2892
3402
|
id: "location",
|
|
2893
3403
|
label: "Location"
|
|
@@ -2922,6 +3432,7 @@ function createTabRenderers(refreshPanel) {
|
|
|
2922
3432
|
env: renderEnvironmentTab,
|
|
2923
3433
|
presets: () => renderPresetsTab(refreshPanel),
|
|
2924
3434
|
permissions: renderPermissionsTab,
|
|
3435
|
+
notifications: renderNotificationsTab,
|
|
2925
3436
|
location: renderLocationTab,
|
|
2926
3437
|
device: renderDeviceTab,
|
|
2927
3438
|
viewport: renderViewportTab,
|
|
@@ -3114,6 +3625,7 @@ function mount() {
|
|
|
3114
3625
|
closeBtn.addEventListener("click", () => {
|
|
3115
3626
|
isOpen = false;
|
|
3116
3627
|
panelEl.classList.remove("open");
|
|
3628
|
+
telemetry.onPanelClose();
|
|
3117
3629
|
});
|
|
3118
3630
|
const mockBadge = h("span", {
|
|
3119
3631
|
className: `ait-mock-badge ${aitState.state.panelEditable ? "ait-mock-badge-on" : "ait-mock-badge-off"}`,
|
|
@@ -3125,7 +3637,7 @@ function mount() {
|
|
|
3125
3637
|
mockBadge.textContent = aitState.state.panelEditable ? "EDIT" : "READ-ONLY";
|
|
3126
3638
|
refreshPanel();
|
|
3127
3639
|
});
|
|
3128
|
-
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.
|
|
3640
|
+
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.15`), closeBtn);
|
|
3129
3641
|
const header = h("div", { className: "ait-panel-header" }, h("span", {}, "AIT DevTools"), headerRight);
|
|
3130
3642
|
tabsEl = h("div", { className: "ait-panel-tabs" });
|
|
3131
3643
|
for (const tab of TABS) {
|
|
@@ -3135,6 +3647,7 @@ function mount() {
|
|
|
3135
3647
|
}, tab.label);
|
|
3136
3648
|
tabEl.addEventListener("click", () => {
|
|
3137
3649
|
currentTab = tab.id;
|
|
3650
|
+
telemetry.onTabView(tab.id);
|
|
3138
3651
|
refreshPanel();
|
|
3139
3652
|
});
|
|
3140
3653
|
tabsEl.appendChild(tabEl);
|
|
@@ -3150,7 +3663,8 @@ function mount() {
|
|
|
3150
3663
|
if (isOpen) {
|
|
3151
3664
|
updatePanelPosition(toggle);
|
|
3152
3665
|
refreshPanel();
|
|
3153
|
-
|
|
3666
|
+
telemetry.onPanelOpen();
|
|
3667
|
+
} else telemetry.onPanelClose();
|
|
3154
3668
|
});
|
|
3155
3669
|
let resizeRaf = 0;
|
|
3156
3670
|
resizeHandler = () => {
|
|
@@ -3180,6 +3694,7 @@ function mount() {
|
|
|
3180
3694
|
};
|
|
3181
3695
|
window.addEventListener("__ait:panel-switch-tab", panelSwitchTabHandler);
|
|
3182
3696
|
refreshPanel();
|
|
3697
|
+
telemetry.init();
|
|
3183
3698
|
}
|
|
3184
3699
|
/**
|
|
3185
3700
|
* Pairs with `mount()` (and the existing `disposeViewport()`).
|