@ait-co/devtools 0.1.65 → 0.1.67
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/mcp/cli.js +217 -32
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/panel/index.js +10 -2
- package/dist/panel/index.js.map +1 -1
- package/dist/{qr-http-server-C34O140J.cjs → qr-http-server-B5YndXcS.cjs} +215 -30
- package/dist/qr-http-server-B5YndXcS.cjs.map +1 -0
- package/dist/{qr-http-server-DBgh4rxe.js → qr-http-server-BUfbLGm1.js} +215 -30
- package/dist/qr-http-server-BUfbLGm1.js.map +1 -0
- package/dist/{qr-http-server-Dp3a1AMl.js → qr-http-server-ChC7P6-H.js} +215 -30
- package/dist/qr-http-server-ChC7P6-H.js.map +1 -0
- package/dist/{qr-http-server-CR4p9Y2d.cjs → qr-http-server-DRlwR54D.cjs} +215 -30
- package/dist/qr-http-server-DRlwR54D.cjs.map +1 -0
- package/dist/{tunnel-CIc0oSit.js → tunnel-BNzbSCfB.js} +2 -2
- package/dist/{tunnel-CIc0oSit.js.map → tunnel-BNzbSCfB.js.map} +1 -1
- package/dist/{tunnel-H7VujZz5.cjs → tunnel-CrlCX5sZ.cjs} +2 -2
- package/dist/{tunnel-H7VujZz5.cjs.map → tunnel-CrlCX5sZ.cjs.map} +1 -1
- package/dist/unplugin/index.cjs +1 -1
- package/dist/unplugin/index.js +1 -1
- package/dist/unplugin/tunnel.cjs +1 -1
- package/dist/unplugin/tunnel.js +1 -1
- package/package.json +1 -1
- package/dist/qr-http-server-C34O140J.cjs.map +0 -1
- package/dist/qr-http-server-CR4p9Y2d.cjs.map +0 -1
- package/dist/qr-http-server-DBgh4rxe.js.map +0 -1
- package/dist/qr-http-server-Dp3a1AMl.js.map +0 -1
|
@@ -168,6 +168,8 @@ const en = {
|
|
|
168
168
|
"notifications.option.newAgreement": "newAgreement (first-time agree)",
|
|
169
169
|
"notifications.option.alreadyAgreed": "alreadyAgreed (already opted-in)",
|
|
170
170
|
"notifications.option.agreementRejected": "agreementRejected (user declined)",
|
|
171
|
+
"dashboard.lang.ko": "한국어",
|
|
172
|
+
"dashboard.lang.en": "English",
|
|
171
173
|
"dashboard.title": "AIT Debug Dashboard",
|
|
172
174
|
"dashboard.updated": "Last updated: {ts}",
|
|
173
175
|
"dashboard.tunnel.section": "Tunnel status",
|
|
@@ -177,6 +179,8 @@ const en = {
|
|
|
177
179
|
"dashboard.attach.hint": "Call the build_attach_url MCP tool to show the QR here.",
|
|
178
180
|
"dashboard.pages.section": "Connected Pages",
|
|
179
181
|
"dashboard.pages.empty": "No attached pages",
|
|
182
|
+
"dashboard.url.copy": "Copy",
|
|
183
|
+
"dashboard.url.copied": "Copied",
|
|
180
184
|
"attach.title": "AIT Debug Session — QR Scan",
|
|
181
185
|
"attach.deployment": "deployment: {label}",
|
|
182
186
|
"attach.steps.section": "How to scan",
|
|
@@ -391,6 +395,8 @@ const tables = {
|
|
|
391
395
|
"notifications.option.newAgreement": "newAgreement (최초 동의)",
|
|
392
396
|
"notifications.option.alreadyAgreed": "alreadyAgreed (이미 동의됨)",
|
|
393
397
|
"notifications.option.agreementRejected": "agreementRejected (사용자 거절)",
|
|
398
|
+
"dashboard.lang.ko": "한국어",
|
|
399
|
+
"dashboard.lang.en": "English",
|
|
394
400
|
"dashboard.title": "AIT 디버그 Dashboard",
|
|
395
401
|
"dashboard.updated": "마지막 갱신: {ts}",
|
|
396
402
|
"dashboard.tunnel.section": "터널 상태",
|
|
@@ -400,6 +406,8 @@ const tables = {
|
|
|
400
406
|
"dashboard.attach.hint": "build_attach_url MCP tool을 호출하면 QR이 여기에 표시됩니다.",
|
|
401
407
|
"dashboard.pages.section": "연결된 Pages",
|
|
402
408
|
"dashboard.pages.empty": "attach된 페이지 없음",
|
|
409
|
+
"dashboard.url.copy": "복사",
|
|
410
|
+
"dashboard.url.copied": "복사됨",
|
|
403
411
|
"attach.title": "AIT 디버그 세션 — QR 스캔",
|
|
404
412
|
"attach.deployment": "deployment: {label}",
|
|
405
413
|
"attach.steps.section": "스캔 절차",
|
|
@@ -443,11 +451,11 @@ function localeFromLanguageTag(lang) {
|
|
|
443
451
|
* surfaces (e.g. the qr-http-server dashboard) have no `navigator`, so the
|
|
444
452
|
* request header is the only language signal. Reads the FIRST language tag
|
|
445
453
|
* (highest priority, ignoring `q=` weights — good enough for ko/en) and feeds
|
|
446
|
-
* it through the same `ko`-vs-`en` heuristic `detectLocale` uses. Returns `'
|
|
447
|
-
* for an empty/missing header.
|
|
454
|
+
* it through the same `ko`-vs-`en` heuristic `detectLocale` uses. Returns `'ko'`
|
|
455
|
+
* for an empty/missing header (ko is the primary locale).
|
|
448
456
|
*/
|
|
449
457
|
function parseAcceptLanguage(header) {
|
|
450
|
-
if (!header) return "
|
|
458
|
+
if (!header) return "ko";
|
|
451
459
|
return localeFromLanguageTag(header.split(",")[0]?.trim().split(";")[0]?.trim() ?? "");
|
|
452
460
|
}
|
|
453
461
|
/**
|
|
@@ -495,12 +503,24 @@ img.qr {
|
|
|
495
503
|
background: #fff; padding: 0.75rem; border-radius: 10px;
|
|
496
504
|
display: block; margin: 0.5rem auto;
|
|
497
505
|
}
|
|
506
|
+
.url-row {
|
|
507
|
+
display: flex; align-items: stretch; gap: 0; margin: 0.5rem 0 0;
|
|
508
|
+
border-radius: 6px; border: 1px solid #30363d; overflow: hidden;
|
|
509
|
+
}
|
|
498
510
|
.url-box {
|
|
499
511
|
font-family: monospace; font-size: 0.7rem;
|
|
500
512
|
word-break: break-all; opacity: 0.45;
|
|
501
513
|
background: #161b22; padding: 0.6rem 0.85rem;
|
|
502
|
-
|
|
514
|
+
flex: 1; cursor: pointer; border: none; border-radius: 0;
|
|
515
|
+
}
|
|
516
|
+
.url-box:hover { opacity: 0.65; }
|
|
517
|
+
.copy-btn {
|
|
518
|
+
flex-shrink: 0; padding: 0.4rem 0.7rem;
|
|
519
|
+
background: #21262d; border: none; border-left: 1px solid #30363d;
|
|
520
|
+
color: #58a6ff; font-size: 0.7rem; cursor: pointer; white-space: nowrap;
|
|
521
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
503
522
|
}
|
|
523
|
+
.copy-btn:hover { background: #30363d; }
|
|
504
524
|
.hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }
|
|
505
525
|
ul { margin: 0; padding-left: 1.25rem; }
|
|
506
526
|
li { margin-bottom: 0.35rem; font-size: 0.85rem; line-height: 1.5; }
|
|
@@ -508,7 +528,10 @@ li.empty { opacity: 0.4; list-style: none; padding-left: 0; }
|
|
|
508
528
|
.page-id { font-family: monospace; font-size: 0.75rem; opacity: 0.5; margin-right: 0.4rem; }
|
|
509
529
|
.page-url { word-break: break-all; }
|
|
510
530
|
hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0; }
|
|
511
|
-
|
|
531
|
+
.lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }
|
|
532
|
+
.lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
|
|
533
|
+
.lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
|
|
534
|
+
</style></head><body><h1>AIT 디버그 Dashboard</h1>__LANG_SWITCHER__<p class="updated" id="updated">마지막 갱신: __NOW__</p><section><h2>터널 상태</h2><span class="status __TUNNEL_CLASS__" id="tunnel-status">__TUNNEL_STATUS__</span></section><hr/><section><h2>Attach QR</h2><div id="attach-section">__ATTACH_SECTION__</div></section>__PAGES_SECTION__</body></html>`;
|
|
512
535
|
const attachChromeHtmlKo = `<!DOCTYPE html>
|
|
513
536
|
<html lang="ko"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="preload" as="image" href="__QR_DATA_URL__"/><title>AIT 디버그 세션 — QR 스캔</title><style>
|
|
514
537
|
*, *::before, *::after { box-sizing: border-box; }
|
|
@@ -531,14 +554,29 @@ section { width: 100%; max-width: 480px; }
|
|
|
531
554
|
h2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }
|
|
532
555
|
ol, ul { margin: 0; padding-left: 1.25rem; }
|
|
533
556
|
li { margin-bottom: 0.4rem; font-size: 0.9rem; line-height: 1.5; }
|
|
557
|
+
.url-row {
|
|
558
|
+
display: flex; align-items: stretch; gap: 0;
|
|
559
|
+
border-radius: 6px; border: 1px solid #30363d; overflow: hidden;
|
|
560
|
+
}
|
|
534
561
|
.url-box {
|
|
535
562
|
font-family: monospace; font-size: 0.72rem;
|
|
536
563
|
word-break: break-all; opacity: 0.4;
|
|
537
564
|
background: #161b22; padding: 0.75rem 1rem;
|
|
538
|
-
|
|
565
|
+
flex: 1; cursor: pointer; border: none; border-radius: 0;
|
|
539
566
|
}
|
|
567
|
+
.url-box:hover { opacity: 0.6; }
|
|
568
|
+
.copy-btn {
|
|
569
|
+
flex-shrink: 0; padding: 0.5rem 0.8rem;
|
|
570
|
+
background: #21262d; border: none; border-left: 1px solid #30363d;
|
|
571
|
+
color: #58a6ff; font-size: 0.75rem; cursor: pointer; white-space: nowrap;
|
|
572
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
573
|
+
}
|
|
574
|
+
.copy-btn:hover { background: #30363d; }
|
|
540
575
|
hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0; }
|
|
541
|
-
|
|
576
|
+
.lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }
|
|
577
|
+
.lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
|
|
578
|
+
.lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
|
|
579
|
+
</style></head><body><h1>AIT 디버그 세션 — QR 스캔</h1>__LANG_SWITCHER__<p class="label">deployment: __SAFE_LABEL__</p><div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>스캔 절차</h2><ol><li>토스 앱을 실행하세요.</li><li>폰 카메라 앱으로 QR 코드를 스캔하세요.</li><li>팝업이 뜨면 <strong>"토스로 열기"</strong>를 탭하세요.</li><li>미니앱이 열리고 디버그 세션이 자동으로 attach됩니다.</li></ol></section><hr/><section><h2>진단 체크리스트</h2><ul><li><strong>토스 앱이 안 열리는 경우</strong> — 앱 버전 확인, 카메라 앱으로 스캔 (토스 앱 내 QR 리더 X)</li><li><strong>미니앱이 PREPARE 상태에서 멈추는 경우</strong> — deep-link에 <code>_deploymentId</code> 파라미터가 있는지 확인</li><li><strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인</li><li><strong>TOTP gate Layer C가 비활성인 경우</strong> — relay 서버에 <code>AIT_DEBUG_TOTP_SECRET</code>이 설정돼 있는지 확인</li></ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="복사">복사</button></div></section></body></html>`;
|
|
542
580
|
const dashboardChromeHtmlEn = `<!DOCTYPE html>
|
|
543
581
|
<html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>AIT Debug Dashboard</title><style>
|
|
544
582
|
*, *::before, *::after { box-sizing: border-box; }
|
|
@@ -562,12 +600,24 @@ img.qr {
|
|
|
562
600
|
background: #fff; padding: 0.75rem; border-radius: 10px;
|
|
563
601
|
display: block; margin: 0.5rem auto;
|
|
564
602
|
}
|
|
603
|
+
.url-row {
|
|
604
|
+
display: flex; align-items: stretch; gap: 0; margin: 0.5rem 0 0;
|
|
605
|
+
border-radius: 6px; border: 1px solid #30363d; overflow: hidden;
|
|
606
|
+
}
|
|
565
607
|
.url-box {
|
|
566
608
|
font-family: monospace; font-size: 0.7rem;
|
|
567
609
|
word-break: break-all; opacity: 0.45;
|
|
568
610
|
background: #161b22; padding: 0.6rem 0.85rem;
|
|
569
|
-
|
|
611
|
+
flex: 1; cursor: pointer; border: none; border-radius: 0;
|
|
570
612
|
}
|
|
613
|
+
.url-box:hover { opacity: 0.65; }
|
|
614
|
+
.copy-btn {
|
|
615
|
+
flex-shrink: 0; padding: 0.4rem 0.7rem;
|
|
616
|
+
background: #21262d; border: none; border-left: 1px solid #30363d;
|
|
617
|
+
color: #58a6ff; font-size: 0.7rem; cursor: pointer; white-space: nowrap;
|
|
618
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
619
|
+
}
|
|
620
|
+
.copy-btn:hover { background: #30363d; }
|
|
571
621
|
.hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }
|
|
572
622
|
ul { margin: 0; padding-left: 1.25rem; }
|
|
573
623
|
li { margin-bottom: 0.35rem; font-size: 0.85rem; line-height: 1.5; }
|
|
@@ -575,7 +625,10 @@ li.empty { opacity: 0.4; list-style: none; padding-left: 0; }
|
|
|
575
625
|
.page-id { font-family: monospace; font-size: 0.75rem; opacity: 0.5; margin-right: 0.4rem; }
|
|
576
626
|
.page-url { word-break: break-all; }
|
|
577
627
|
hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0; }
|
|
578
|
-
|
|
628
|
+
.lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }
|
|
629
|
+
.lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
|
|
630
|
+
.lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
|
|
631
|
+
</style></head><body><h1>AIT Debug Dashboard</h1>__LANG_SWITCHER__<p class="updated" id="updated">Last updated: __NOW__</p><section><h2>Tunnel status</h2><span class="status __TUNNEL_CLASS__" id="tunnel-status">__TUNNEL_STATUS__</span></section><hr/><section><h2>Attach QR</h2><div id="attach-section">__ATTACH_SECTION__</div></section>__PAGES_SECTION__</body></html>`;
|
|
579
632
|
const attachChromeHtmlEn = `<!DOCTYPE html>
|
|
580
633
|
<html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="preload" as="image" href="__QR_DATA_URL__"/><title>AIT Debug Session — QR Scan</title><style>
|
|
581
634
|
*, *::before, *::after { box-sizing: border-box; }
|
|
@@ -598,14 +651,29 @@ section { width: 100%; max-width: 480px; }
|
|
|
598
651
|
h2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }
|
|
599
652
|
ol, ul { margin: 0; padding-left: 1.25rem; }
|
|
600
653
|
li { margin-bottom: 0.4rem; font-size: 0.9rem; line-height: 1.5; }
|
|
654
|
+
.url-row {
|
|
655
|
+
display: flex; align-items: stretch; gap: 0;
|
|
656
|
+
border-radius: 6px; border: 1px solid #30363d; overflow: hidden;
|
|
657
|
+
}
|
|
601
658
|
.url-box {
|
|
602
659
|
font-family: monospace; font-size: 0.72rem;
|
|
603
660
|
word-break: break-all; opacity: 0.4;
|
|
604
661
|
background: #161b22; padding: 0.75rem 1rem;
|
|
605
|
-
|
|
662
|
+
flex: 1; cursor: pointer; border: none; border-radius: 0;
|
|
606
663
|
}
|
|
664
|
+
.url-box:hover { opacity: 0.6; }
|
|
665
|
+
.copy-btn {
|
|
666
|
+
flex-shrink: 0; padding: 0.5rem 0.8rem;
|
|
667
|
+
background: #21262d; border: none; border-left: 1px solid #30363d;
|
|
668
|
+
color: #58a6ff; font-size: 0.75rem; cursor: pointer; white-space: nowrap;
|
|
669
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
670
|
+
}
|
|
671
|
+
.copy-btn:hover { background: #30363d; }
|
|
607
672
|
hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0; }
|
|
608
|
-
|
|
673
|
+
.lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }
|
|
674
|
+
.lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
|
|
675
|
+
.lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
|
|
676
|
+
</style></head><body><h1>AIT Debug Session — QR Scan</h1>__LANG_SWITCHER__<p class="label">deployment: __SAFE_LABEL__</p><div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>How to scan</h2><ol><li>Open the Toss app.</li><li>Scan the QR code with your phone camera app.</li><li>Tap <strong>"Open in Toss"</strong> when the popup appears.</li><li>The mini-app opens and the debug session attaches automatically.</li></ol></section><hr/><section><h2>Troubleshooting checklist</h2><ul><li><strong>Toss app does not open</strong> — check app version; scan with the system camera app (not the Toss in-app QR reader)</li><li><strong>Mini-app stuck in PREPARE state</strong> — verify the deep-link has a <code>_deploymentId</code> parameter</li><li><strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import</li><li><strong>TOTP gate Layer C is inactive</strong> — check that <code>AIT_DEBUG_TOTP_SECRET</code> is set on the relay server</li></ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="Copy">Copy</button></div></section></body></html>`;
|
|
609
677
|
/** Map from Locale to the precompiled dashboard chrome string. */
|
|
610
678
|
const dashboardChromeByLocale = {
|
|
611
679
|
ko: dashboardChromeHtmlKo,
|
|
@@ -623,6 +691,24 @@ function escapeHtml(s) {
|
|
|
623
691
|
return s.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
624
692
|
}
|
|
625
693
|
/**
|
|
694
|
+
* 현재 path+query에서 lang 파라미터만 교체한 ko/en 토글 링크를 생성한다.
|
|
695
|
+
*
|
|
696
|
+
* SECRET-HANDLING: u= (attachUrl, TOTP at= 캡슐 포함) 등 기존 query를 보존한다.
|
|
697
|
+
* lang= 만 덮어쓴다. 링크 href에 at= 코드가 들어가는 건 의도된 전달 경로.
|
|
698
|
+
*/
|
|
699
|
+
function buildLangSwitcher(path, existingParams, locale, s) {
|
|
700
|
+
function switcherHref(targetLang) {
|
|
701
|
+
const p = new URLSearchParams(existingParams);
|
|
702
|
+
p.set("lang", targetLang);
|
|
703
|
+
return `${escapeHtml(path)}?${p.toString()}`;
|
|
704
|
+
}
|
|
705
|
+
const koLabel = escapeHtml(s("dashboard.lang.ko"));
|
|
706
|
+
const enLabel = escapeHtml(s("dashboard.lang.en"));
|
|
707
|
+
const koClass = locale === "ko" ? "active" : "";
|
|
708
|
+
const enClass = locale === "en" ? "active" : "";
|
|
709
|
+
return `<div class="lang-switcher"><a href="${switcherHref("ko")}" class="${koClass}">${koLabel}</a><a href="${switcherHref("en")}" class="${enClass}">${enLabel}</a></div>`;
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
626
712
|
* Dashboard HTML — precompiled chrome에 per-request 동적 값을 채워 완성한다.
|
|
627
713
|
*
|
|
628
714
|
* 토큰 채우기 순서:
|
|
@@ -642,14 +728,17 @@ function escapeHtml(s) {
|
|
|
642
728
|
* - tunnel wssUrl은 "터널 연결됨" 상태 표시에서 UP/DOWN만 노출.
|
|
643
729
|
* wssUrl 값 자체는 dashboard HTML에 넣지 않는다.
|
|
644
730
|
*/
|
|
645
|
-
function buildDashboardHtml(state, qrDataUrl, locale) {
|
|
731
|
+
function buildDashboardHtml(state, qrDataUrl, locale, path = "/", params = new URLSearchParams()) {
|
|
646
732
|
const s = resolveLocaleStrings(locale);
|
|
647
733
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
648
734
|
const tunnelStatus = state.tunnel.up ? s("dashboard.tunnel.up") : s("dashboard.tunnel.down");
|
|
649
735
|
const tunnelClass = state.tunnel.up ? "status-up" : "status-down";
|
|
650
736
|
let attachSection;
|
|
651
|
-
if (qrDataUrl && state.attachUrl)
|
|
652
|
-
|
|
737
|
+
if (qrDataUrl && state.attachUrl) {
|
|
738
|
+
const safeAttachUrl = escapeHtml(state.attachUrl);
|
|
739
|
+
const copyLabel = escapeHtml(s("dashboard.url.copy"));
|
|
740
|
+
attachSection = `<img class="qr" src="${qrDataUrl}" alt="attach QR" /><div class="url-row"><p class="url-box" id="url-box">${safeAttachUrl}</p><button class="copy-btn" id="copy-btn" type="button" aria-label="${copyLabel}">${copyLabel}</button></div>`;
|
|
741
|
+
} else attachSection = `<p class="hint">${escapeHtml(s("dashboard.attach.hint"))}</p>`;
|
|
653
742
|
const pagesSection = state.pages === null ? "" : `<hr /><section id="pages-section"><h2>${escapeHtml(s("dashboard.pages.section"))}</h2><ul id="pages-list">${state.pages.length > 0 ? state.pages.map((p) => {
|
|
654
743
|
return `<li><span class="page-id">${escapeHtml(p.id)}</span> <span class="page-url">${escapeHtml(p.url.slice(0, 120))}</span></li>`;
|
|
655
744
|
}).join("\n") : `<li class="empty">${escapeHtml(s("dashboard.pages.empty"))}</li>`}</ul></section>`;
|
|
@@ -657,9 +746,13 @@ function buildDashboardHtml(state, qrDataUrl, locale) {
|
|
|
657
746
|
tunnelUp: JSON.stringify(s("dashboard.tunnel.up")),
|
|
658
747
|
tunnelDown: JSON.stringify(s("dashboard.tunnel.down")),
|
|
659
748
|
pagesEmpty: JSON.stringify(s("dashboard.pages.empty")),
|
|
660
|
-
attachHint: JSON.stringify(s("dashboard.attach.hint"))
|
|
749
|
+
attachHint: JSON.stringify(s("dashboard.attach.hint")),
|
|
750
|
+
copyLabel: JSON.stringify(s("dashboard.url.copy")),
|
|
751
|
+
copiedLabel: JSON.stringify(s("dashboard.url.copied")),
|
|
752
|
+
dashboardSurface: true
|
|
661
753
|
};
|
|
662
|
-
const
|
|
754
|
+
const langSwitcher = buildLangSwitcher(path, params, locale, s);
|
|
755
|
+
const filled = dashboardChromeByLocale[locale].replaceAll("__LANG_SWITCHER__", langSwitcher).replaceAll("__NOW__", escapeHtml(now)).replaceAll("__TUNNEL_CLASS__", tunnelClass).replaceAll("__TUNNEL_STATUS__", escapeHtml(tunnelStatus)).replaceAll("__ATTACH_SECTION__", attachSection).replaceAll("__PAGES_SECTION__", pagesSection);
|
|
663
756
|
const sseScript = buildSseScript(sseStrings);
|
|
664
757
|
return filled.replace("</body>", `${sseScript}\n</body>`);
|
|
665
758
|
}
|
|
@@ -670,9 +763,24 @@ function buildDashboardHtml(state, qrDataUrl, locale) {
|
|
|
670
763
|
* client side: attachUrl은 DOM에 렌더링, wssUrl은 절대 렌더링하지 않는다.
|
|
671
764
|
* pages === null 이면 섹션을 건드리지 않는다 (#411).
|
|
672
765
|
*
|
|
766
|
+
* 두 표면(dashboard / attach) 분기:
|
|
767
|
+
* - dashboard (dashboardSurface=true): #attach-section innerHTML 전체 교체 방식 유지.
|
|
768
|
+
* url-box도 innerHTML 재렌더 안에 포함되어 갱신됨.
|
|
769
|
+
* - /attach (dashboardSurface=false): #attach-section의 img src만 교체하고,
|
|
770
|
+
* url-box는 #url-box textContent만 갱신한다. (#attach-section에 url-box가 없으므로
|
|
771
|
+
* innerHTML 교체 시 url-box가 새로 생겨 이중 표시되는 결함을 방지 — #458 결함 수정.)
|
|
772
|
+
*
|
|
773
|
+
* 복사 기능: 이벤트 위임으로 document에 단일 핸들러. innerHTML 재렌더 후에도 생존.
|
|
774
|
+
* - .url-box 클릭 또는 .copy-btn 클릭 → 현재 #url-box textContent 복사.
|
|
775
|
+
* - clipboard: navigator.clipboard.writeText → 실패/부재 시 textarea execCommand fallback.
|
|
776
|
+
* - 피드백: 버튼 라벨이 COPIED_LABEL로 ~1.5초 전환 후 COPY_LABEL로 복귀.
|
|
777
|
+
*
|
|
673
778
|
* 문자열 인자는 빌드타임에 ko/en 테이블에서 가져와 JSON.stringify로 이미 escape됨.
|
|
779
|
+
*
|
|
780
|
+
* SECRET-HANDLING: URL 값을 console.log 등으로 출력하지 않는다.
|
|
674
781
|
*/
|
|
675
782
|
function buildSseScript(strings) {
|
|
783
|
+
const isDashboard = strings.dashboardSurface;
|
|
676
784
|
return `<script>
|
|
677
785
|
// SSE — /events 구독해 상태 자동 갱신. 빌드 파이프라인 없는 인라인 스크립트.
|
|
678
786
|
(function () {
|
|
@@ -680,6 +788,64 @@ function buildSseScript(strings) {
|
|
|
680
788
|
var TUNNEL_DOWN = ${strings.tunnelDown};
|
|
681
789
|
var PAGES_EMPTY = ${strings.pagesEmpty};
|
|
682
790
|
var ATTACH_HINT = ${strings.attachHint};
|
|
791
|
+
var COPY_LABEL = ${strings.copyLabel};
|
|
792
|
+
var COPIED_LABEL = ${strings.copiedLabel};
|
|
793
|
+
|
|
794
|
+
// ── 클립보드 복사 헬퍼 ────────────────────────────────────────────────
|
|
795
|
+
function copyText(text) {
|
|
796
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
797
|
+
return navigator.clipboard.writeText(text);
|
|
798
|
+
}
|
|
799
|
+
// fallback: textarea + execCommand
|
|
800
|
+
return new Promise(function (resolve, reject) {
|
|
801
|
+
var ta = document.createElement('textarea');
|
|
802
|
+
ta.value = text;
|
|
803
|
+
ta.style.position = 'fixed';
|
|
804
|
+
ta.style.opacity = '0';
|
|
805
|
+
document.body.appendChild(ta);
|
|
806
|
+
ta.focus();
|
|
807
|
+
ta.select();
|
|
808
|
+
try {
|
|
809
|
+
document.execCommand('copy') ? resolve() : reject(new Error('execCommand failed'));
|
|
810
|
+
} catch (err) {
|
|
811
|
+
reject(err);
|
|
812
|
+
} finally {
|
|
813
|
+
document.body.removeChild(ta);
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// ── 복사 피드백 ───────────────────────────────────────────────────────
|
|
819
|
+
var copyTimer = null;
|
|
820
|
+
function triggerCopy() {
|
|
821
|
+
var urlBox = document.getElementById('url-box');
|
|
822
|
+
if (!urlBox) return;
|
|
823
|
+
var text = urlBox.textContent || '';
|
|
824
|
+
if (!text) return;
|
|
825
|
+
copyText(text).then(function () {
|
|
826
|
+
var btn = document.getElementById('copy-btn');
|
|
827
|
+
if (btn) {
|
|
828
|
+
btn.textContent = COPIED_LABEL;
|
|
829
|
+
if (copyTimer) clearTimeout(copyTimer);
|
|
830
|
+
copyTimer = setTimeout(function () {
|
|
831
|
+
btn.textContent = COPY_LABEL;
|
|
832
|
+
copyTimer = null;
|
|
833
|
+
}, 1500);
|
|
834
|
+
}
|
|
835
|
+
}).catch(function () { /* 복사 실패 시 조용히 무시 */ });
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ── 이벤트 위임 — document 레벨에서 단일 핸들러 (innerHTML 재렌더 후에도 생존) ──
|
|
839
|
+
document.addEventListener('click', function (e) {
|
|
840
|
+
var target = e.target;
|
|
841
|
+
if (!target) return;
|
|
842
|
+
// .copy-btn 또는 .url-box 클릭 시 복사
|
|
843
|
+
if (target.closest && (target.closest('.copy-btn') || target.closest('.url-box'))) {
|
|
844
|
+
triggerCopy();
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
// ── SSE 구독 ──────────────────────────────────────────────────────────
|
|
683
849
|
var src = new EventSource('/events');
|
|
684
850
|
src.onmessage = function (e) {
|
|
685
851
|
try {
|
|
@@ -707,23 +873,37 @@ function buildSseScript(strings) {
|
|
|
707
873
|
}
|
|
708
874
|
}
|
|
709
875
|
}
|
|
710
|
-
// attachUrl QR
|
|
876
|
+
// attachUrl QR + url-box 갱신
|
|
877
|
+
// SECRET-HANDLING: URL 값을 로그로 출력하지 않는다.
|
|
711
878
|
var sec = document.getElementById('attach-section');
|
|
712
879
|
if (sec) {
|
|
713
880
|
if (s.attachUrl) {
|
|
714
|
-
// QR은 서버에서 새로 렌더한 /qr.png?u= 로 img src 교체.
|
|
715
|
-
// TOTP at= 코드는 attachUrl 안에 캡슐화 — 별도 노출 없음.
|
|
716
|
-
// wssUrl은 절대 DOM에 렌더하지 않는다 (SECRET-HANDLING).
|
|
717
881
|
var encoded = encodeURIComponent(s.attachUrl);
|
|
718
882
|
var safeUrl = String(s.attachUrl).slice(0, 2000).replace(/[<>&"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });
|
|
883
|
+
${isDashboard ? `// dashboard: #attach-section innerHTML 전체 교체 (img + url-row).
|
|
884
|
+
// url-box id="url-box" 를 포함해 복사 핸들러가 계속 동작함.
|
|
719
885
|
sec.innerHTML =
|
|
720
886
|
'<img class="qr" src="/qr.png?u=' + encoded + '" alt="attach QR" />' +
|
|
721
|
-
'<
|
|
887
|
+
'<div class=\\"url-row\\">' +
|
|
888
|
+
'<p class=\\"url-box\\" id=\\"url-box\\">' + safeUrl + '</p>' +
|
|
889
|
+
'<button class=\\"copy-btn\\" id=\\"copy-btn\\" type=\\"button\\" aria-label=\\"' + COPY_LABEL + '\\">' + COPY_LABEL + '</button>' +
|
|
890
|
+
'</div>';` : `// /attach: img src만 교체 — url-box는 별도 #url-section에서 관리해 이중 표시 방지(#458).
|
|
891
|
+
// QR img src 교체: img가 있으면 src만 갱신, 없으면 img 요소 생성.
|
|
892
|
+
var img = sec.querySelector('img.qr');
|
|
893
|
+
if (img) {
|
|
894
|
+
img.src = '/qr.png?u=' + encoded;
|
|
895
|
+
} else {
|
|
896
|
+
sec.innerHTML = '<img class=\\"qr\\" src=\\"/qr.png?u=' + encoded + '\\" alt=\\"attach QR\\" />';
|
|
897
|
+
}
|
|
898
|
+
// url-box textContent만 갱신 (innerHTML 교체하지 않아 복사 버튼/핸들러 생존).
|
|
899
|
+
var ub = document.getElementById('url-box');
|
|
900
|
+
if (ub) ub.textContent = s.attachUrl;`}
|
|
722
901
|
} else {
|
|
723
|
-
sec.innerHTML = '<p class
|
|
902
|
+
${isDashboard ? `sec.innerHTML = '<p class=\\"hint\\">' + ATTACH_HINT + '</p>';` : `// /attach에서 hint가 필요한 경우는 없으나 방어 처리.
|
|
903
|
+
sec.innerHTML = '<p class=\\"hint\\">' + ATTACH_HINT + '</p>';`}
|
|
724
904
|
}
|
|
725
905
|
}
|
|
726
|
-
// 갱신 시각
|
|
906
|
+
// 갱신 시각 (dashboard만 #updated 요소 있음)
|
|
727
907
|
var upd = document.getElementById('updated');
|
|
728
908
|
if (upd) upd.textContent = upd.textContent.replace(/[^ ]+$/, new Date().toISOString());
|
|
729
909
|
} catch (_) { /* 파싱 오류 무시 */ }
|
|
@@ -748,14 +928,18 @@ function buildSseScript(strings) {
|
|
|
748
928
|
*
|
|
749
929
|
* SECRET-HANDLING: TOTP at= 코드는 attachUrl 캡슐 안에서만 노출 — 의도된 transport.
|
|
750
930
|
*/
|
|
751
|
-
function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl, locale) {
|
|
931
|
+
function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl, locale, path = "/attach", params = new URLSearchParams()) {
|
|
752
932
|
const s = resolveLocaleStrings(locale);
|
|
753
|
-
const
|
|
933
|
+
const langSwitcher = buildLangSwitcher(path, params, locale, s);
|
|
934
|
+
const filled = attachChromeByLocale[locale].replaceAll("__LANG_SWITCHER__", langSwitcher).replaceAll("__QR_DATA_URL__", qrDataUrl).replaceAll("__SAFE_LABEL__", safeLabel).replaceAll("__SAFE_ATTACH_URL__", safeAttachUrl);
|
|
754
935
|
const sseScript = buildSseScript({
|
|
755
936
|
tunnelUp: JSON.stringify(s("dashboard.tunnel.up")),
|
|
756
937
|
tunnelDown: JSON.stringify(s("dashboard.tunnel.down")),
|
|
757
938
|
pagesEmpty: JSON.stringify(s("dashboard.pages.empty")),
|
|
758
|
-
attachHint: JSON.stringify(s("dashboard.attach.hint"))
|
|
939
|
+
attachHint: JSON.stringify(s("dashboard.attach.hint")),
|
|
940
|
+
copyLabel: JSON.stringify(s("dashboard.url.copy")),
|
|
941
|
+
copiedLabel: JSON.stringify(s("dashboard.url.copied")),
|
|
942
|
+
dashboardSurface: false
|
|
759
943
|
});
|
|
760
944
|
return filled.replace("</body>", `${sseScript}\n</body>`);
|
|
761
945
|
}
|
|
@@ -785,7 +969,8 @@ async function startQrHttpServer(getDashboardState) {
|
|
|
785
969
|
const server = createServer(async (req, res) => {
|
|
786
970
|
const [path, query = ""] = (req.url ?? "/").split("?", 2);
|
|
787
971
|
const params = new URLSearchParams(query ?? "");
|
|
788
|
-
const
|
|
972
|
+
const langParam = params.get("lang");
|
|
973
|
+
const locale = langParam === "ko" || langParam === "en" ? langParam : parseAcceptLanguage(req.headers["accept-language"]);
|
|
789
974
|
if (path === "/") {
|
|
790
975
|
if (!getDashboardState) {
|
|
791
976
|
res.writeHead(204, { "Content-Type": "text/plain; charset=utf-8" });
|
|
@@ -800,7 +985,7 @@ async function startQrHttpServer(getDashboardState) {
|
|
|
800
985
|
errorCorrectionLevel: "M"
|
|
801
986
|
});
|
|
802
987
|
} catch {}
|
|
803
|
-
const html = buildDashboardHtml(state, qrDataUrl, locale);
|
|
988
|
+
const html = buildDashboardHtml(state, qrDataUrl, locale, path, params);
|
|
804
989
|
res.writeHead(200, {
|
|
805
990
|
"Content-Type": "text/html; charset=utf-8",
|
|
806
991
|
"Cache-Control": "no-store"
|
|
@@ -847,7 +1032,7 @@ async function startQrHttpServer(getDashboardState) {
|
|
|
847
1032
|
type: "image/png",
|
|
848
1033
|
errorCorrectionLevel: "M"
|
|
849
1034
|
}).then((dataUrl) => {
|
|
850
|
-
const html = buildAttachHtml(dataUrl, escapeHtml(deploymentIdLabel), escapeHtml(attachUrl), locale);
|
|
1035
|
+
const html = buildAttachHtml(dataUrl, escapeHtml(deploymentIdLabel), escapeHtml(attachUrl), locale, path, params);
|
|
851
1036
|
res.writeHead(200, {
|
|
852
1037
|
"Content-Type": "text/html; charset=utf-8",
|
|
853
1038
|
"Cache-Control": "no-store"
|
|
@@ -918,4 +1103,4 @@ async function startQrHttpServer(getDashboardState) {
|
|
|
918
1103
|
//#endregion
|
|
919
1104
|
export { startQrHttpServer };
|
|
920
1105
|
|
|
921
|
-
//# sourceMappingURL=qr-http-server-
|
|
1106
|
+
//# sourceMappingURL=qr-http-server-BUfbLGm1.js.map
|