@ait-co/devtools 0.1.137 → 0.1.139

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.
Files changed (119) hide show
  1. package/dist/{attach-orchestrator-Cjdhir2U.js → attach-orchestrator-6kz_ZdWL.js} +5 -3
  2. package/dist/{attach-orchestrator-Cjdhir2U.js.map → attach-orchestrator-6kz_ZdWL.js.map} +1 -1
  3. package/dist/{attach-orchestrator-CE0S09YU.js → attach-orchestrator-BEiz3wAx.js} +5 -3
  4. package/dist/{attach-orchestrator-CMoDuG2A.js.map → attach-orchestrator-BEiz3wAx.js.map} +1 -1
  5. package/dist/{attach-orchestrator-CMoDuG2A.js → attach-orchestrator-BG8TGJOZ.js} +5 -3
  6. package/dist/{attach-orchestrator-CE0S09YU.js.map → attach-orchestrator-BG8TGJOZ.js.map} +1 -1
  7. package/dist/{bundle-BKqyhEK9.d.ts → bundle-C796JIwG.d.ts} +65 -2
  8. package/dist/bundle-C796JIwG.d.ts.map +1 -0
  9. package/dist/{capture-ltuV0gZa.d.ts → capture-DsP525OZ.d.ts} +1 -1
  10. package/dist/{capture-ltuV0gZa.d.ts.map → capture-DsP525OZ.d.ts.map} +1 -1
  11. package/dist/{cdp-connection-BYE9meXe.d.ts → cdp-connection-rP1WdnH5.d.ts} +1 -1
  12. package/dist/{cdp-connection-BYE9meXe.d.ts.map → cdp-connection-rP1WdnH5.d.ts.map} +1 -1
  13. package/dist/{cell-D1y4shoV.js → cell-BMlFh08P.js} +2 -2
  14. package/dist/cell-BMlFh08P.js.map +1 -0
  15. package/dist/{cell-DHA578lX.js → cell-Dtgtusog.js} +90 -16
  16. package/dist/cell-Dtgtusog.js.map +1 -0
  17. package/dist/{cell-BIdSQb9T.js → cell-KsRXFdDs.js} +122 -16
  18. package/dist/cell-KsRXFdDs.js.map +1 -0
  19. package/dist/{debug-server-CBK7dn8O.js → debug-server-6c22qSWa.js} +132 -9
  20. package/dist/debug-server-6c22qSWa.js.map +1 -0
  21. package/dist/{debug-server-BQplCzf8.js → debug-server-BQHbaKPT.js} +134 -11
  22. package/dist/debug-server-BQHbaKPT.js.map +1 -0
  23. package/dist/{debug-server-D2wpPK3_.js → debug-server-DWat7d-9.js} +82 -6
  24. package/dist/debug-server-DWat7d-9.js.map +1 -0
  25. package/dist/devtools-opener-3Drge_RJ.js +75 -0
  26. package/dist/devtools-opener-3Drge_RJ.js.map +1 -0
  27. package/dist/devtools-opener-CJpEsXXQ.js +76 -0
  28. package/dist/devtools-opener-CJpEsXXQ.js.map +1 -0
  29. package/dist/devtools-opener-CxtryS8c.js +75 -0
  30. package/dist/devtools-opener-CxtryS8c.js.map +1 -0
  31. package/dist/in-app/auto.js +59 -12
  32. package/dist/in-app/auto.js.map +1 -1
  33. package/dist/in-app/index.d.ts +21 -2
  34. package/dist/in-app/index.d.ts.map +1 -1
  35. package/dist/in-app/index.js +60 -13
  36. package/dist/in-app/index.js.map +1 -1
  37. package/dist/mcp/cli.js +299 -60
  38. package/dist/mcp/cli.js.map +1 -1
  39. package/dist/mcp/server.js +1 -1
  40. package/dist/mock/index.d.ts +79 -1
  41. package/dist/mock/index.d.ts.map +1 -1
  42. package/dist/mock/index.js +218 -14
  43. package/dist/mock/index.js.map +1 -1
  44. package/dist/panel/index.js +171 -10
  45. package/dist/panel/index.js.map +1 -1
  46. package/dist/{pool-CmfvXnwh.d.ts → pool-CrP5CPvU.d.ts} +3 -3
  47. package/dist/{pool-CmfvXnwh.d.ts.map → pool-CrP5CPvU.d.ts.map} +1 -1
  48. package/dist/{qr-http-server-ZkT6F7is.js → qr-http-server-D1hpoyBF.js} +1 -1
  49. package/dist/{qr-http-server-ZkT6F7is.js.map → qr-http-server-D1hpoyBF.js.map} +1 -1
  50. package/dist/qr-http-server-D4rGz8M7.js +1640 -0
  51. package/dist/qr-http-server-D4rGz8M7.js.map +1 -0
  52. package/dist/{qr-http-server-C7q8Gn04.js → qr-http-server-DIjCV7h_.js} +1 -1
  53. package/dist/{qr-http-server-C7q8Gn04.js.map → qr-http-server-DIjCV7h_.js.map} +1 -1
  54. package/dist/{relay-factory-DVzpBRxz.js → relay-factory-C1X3G3WY.js} +71 -15
  55. package/dist/relay-factory-C1X3G3WY.js.map +1 -0
  56. package/dist/{relay-secret-store-CLEGyHou.js → relay-secret-store-Bmyleu0A.js} +1 -1
  57. package/dist/{relay-secret-store-CLEGyHou.js.map → relay-secret-store-Bmyleu0A.js.map} +1 -1
  58. package/dist/{relay-secret-store-DhzAnnj-.js → relay-secret-store-CQenfcSL.js} +2 -2
  59. package/dist/{relay-secret-store-DhzAnnj-.js.map → relay-secret-store-CQenfcSL.js.map} +1 -1
  60. package/dist/{relay-secret-store-BcVrWwTq.js → relay-secret-store-DKxs7zwq.js} +1 -1
  61. package/dist/{relay-secret-store-BcVrWwTq.js.map → relay-secret-store-DKxs7zwq.js.map} +1 -1
  62. package/dist/{relay-secret-store-DGduVJhs.js → relay-secret-store-WJ8EGkIl.js} +1 -1
  63. package/dist/{relay-secret-store-DGduVJhs.js.map → relay-secret-store-WJ8EGkIl.js.map} +1 -1
  64. package/dist/{relay-url-store-CKW8RQzf.js → relay-url-store-BR2XodiO.js} +2 -2
  65. package/dist/{relay-url-store-CKW8RQzf.js.map → relay-url-store-BR2XodiO.js.map} +1 -1
  66. package/dist/{relay-url-store-CdA58fgw.js → relay-url-store-CH63fVCm.js} +2 -2
  67. package/dist/{relay-url-store-B0X8TsGr.js.map → relay-url-store-CH63fVCm.js.map} +1 -1
  68. package/dist/{relay-url-store-CwKT7i04.js → relay-url-store-DaY1QPes.js} +2 -2
  69. package/dist/{relay-url-store-CwKT7i04.js.map → relay-url-store-DaY1QPes.js.map} +1 -1
  70. package/dist/{relay-url-store-B0X8TsGr.js → relay-url-store-xmUuTjXA.js} +2 -2
  71. package/dist/{relay-url-store-CdA58fgw.js.map → relay-url-store-xmUuTjXA.js.map} +1 -1
  72. package/dist/{relay-worker-6fy1eaIo.js → relay-worker-BUXI0K0b.js} +8 -4
  73. package/dist/{relay-worker-6fy1eaIo.js.map → relay-worker-BUXI0K0b.js.map} +1 -1
  74. package/dist/{relay-worker-DJnZkXza.d.ts → relay-worker-DcyboK43.d.ts} +30 -5
  75. package/dist/relay-worker-DcyboK43.d.ts.map +1 -0
  76. package/dist/{runtime-DfHHZms2.d.ts → runtime-BiigOuvb.d.ts} +30 -3
  77. package/dist/runtime-BiigOuvb.d.ts.map +1 -0
  78. package/dist/test-runner/bin.js +376 -38
  79. package/dist/test-runner/bin.js.map +1 -1
  80. package/dist/test-runner/bundle.d.ts +2 -2
  81. package/dist/test-runner/bundle.js +66 -13
  82. package/dist/test-runner/bundle.js.map +1 -1
  83. package/dist/test-runner/capture.d.ts +1 -1
  84. package/dist/test-runner/config.d.ts +58 -5
  85. package/dist/test-runner/config.d.ts.map +1 -1
  86. package/dist/test-runner/config.js +1 -1
  87. package/dist/test-runner/method-pace.d.ts +82 -0
  88. package/dist/test-runner/method-pace.d.ts.map +1 -0
  89. package/dist/test-runner/method-pace.js +120 -0
  90. package/dist/test-runner/method-pace.js.map +1 -0
  91. package/dist/test-runner/pool.d.ts +1 -1
  92. package/dist/test-runner/pool.js +1 -1
  93. package/dist/test-runner/relay-factory.d.ts +57 -4
  94. package/dist/test-runner/relay-factory.d.ts.map +1 -1
  95. package/dist/test-runner/relay-factory.js +70 -14
  96. package/dist/test-runner/relay-factory.js.map +1 -1
  97. package/dist/test-runner/relay-worker.d.ts +1 -1
  98. package/dist/test-runner/relay-worker.js +1 -1
  99. package/dist/test-runner/report.d.ts +3 -3
  100. package/dist/test-runner/rpc.d.ts +2 -2
  101. package/dist/test-runner/runtime.d.ts +2 -2
  102. package/dist/test-runner/runtime.js +83 -6
  103. package/dist/test-runner/runtime.js.map +1 -1
  104. package/dist/test-runner/task-graph.d.ts +1 -1
  105. package/dist/throttle-DKKzX1qC.js +59 -0
  106. package/dist/throttle-DKKzX1qC.js.map +1 -0
  107. package/dist/totp-Dwft0Kz7.js +3 -0
  108. package/package.json +1 -1
  109. package/dist/bundle-BKqyhEK9.d.ts.map +0 -1
  110. package/dist/cell-BIdSQb9T.js.map +0 -1
  111. package/dist/cell-D1y4shoV.js.map +0 -1
  112. package/dist/cell-DHA578lX.js.map +0 -1
  113. package/dist/debug-server-BQplCzf8.js.map +0 -1
  114. package/dist/debug-server-CBK7dn8O.js.map +0 -1
  115. package/dist/debug-server-D2wpPK3_.js.map +0 -1
  116. package/dist/relay-factory-DVzpBRxz.js.map +0 -1
  117. package/dist/relay-worker-DJnZkXza.d.ts.map +0 -1
  118. package/dist/runtime-DfHHZms2.d.ts.map +0 -1
  119. package/dist/totp-D1pulXLa.js +0 -3
@@ -0,0 +1,1640 @@
1
+ import { createServer } from "node:http";
2
+ //#region src/i18n/en.ts
3
+ const en = {
4
+ "panel.title": "AIT DevTools",
5
+ "panel.toggle.title": "AIT DevTools",
6
+ "panel.close": "Close",
7
+ "panel.editMode.on": "EDIT",
8
+ "panel.editMode.off": "READ-ONLY",
9
+ "panel.editMode.toggleTitle": "Toggle panel edit mode",
10
+ "panel.tabError": "Error rendering \"{tab}\" tab.",
11
+ "panel.tab.env": "Environment",
12
+ "panel.tab.presets": "Presets",
13
+ "panel.tab.viewport": "Viewport",
14
+ "panel.tab.permissions": "Permissions",
15
+ "panel.tab.notifications": "Notifications",
16
+ "panel.tab.location": "Location",
17
+ "panel.tab.device": "Device",
18
+ "panel.tab.iap": "IAP",
19
+ "panel.tab.ads": "Ads",
20
+ "panel.tab.events": "Events",
21
+ "panel.tab.analytics": "Analytics",
22
+ "panel.tab.storage": "Storage",
23
+ "common.readOnly": "Read-only — mock responses are controlled at build time.",
24
+ "env.section.platform": "Platform",
25
+ "env.row.os": "OS",
26
+ "env.row.appVersion": "App Version",
27
+ "env.row.environment": "Environment",
28
+ "env.row.locale": "Locale",
29
+ "env.section.network": "Network",
30
+ "env.row.networkStatus": "Status",
31
+ "env.section.safeArea": "Safe Area Insets",
32
+ "env.row.safeArea.top": "Top",
33
+ "env.row.safeArea.bottom": "Bottom",
34
+ "env.section.navigation": "Navigation",
35
+ "env.row.iosSwipeGesture": "iOS swipe-back",
36
+ "env.value.iosSwipeGesture.unset": "not called",
37
+ "env.value.iosSwipeGesture.enabled": "enabled",
38
+ "env.value.iosSwipeGesture.disabled": "disabled",
39
+ "env.hint.iosSwipeGesture": "Last value passed to setIosSwipeGestureEnabled. Switching Environment to toss lets a toss-gated guard toggle this.",
40
+ "env.section.language": "Language",
41
+ "env.language.row": "Language",
42
+ "env.language.ko": "한국어",
43
+ "env.language.en": "English",
44
+ "permissions.section.device": "Device Permissions",
45
+ "location.section.current": "Current Location",
46
+ "location.row.latitude": "Latitude",
47
+ "location.row.longitude": "Longitude",
48
+ "location.row.accuracy": "Accuracy",
49
+ "device.section.modes": "Device API Modes",
50
+ "device.row.camera": "Camera",
51
+ "device.row.photos": "Photos",
52
+ "device.row.location": "Location",
53
+ "device.row.network": "Network",
54
+ "device.row.clipboard": "Clipboard",
55
+ "device.section.mockImages": "Mock Images ({count})",
56
+ "device.btn.add": "+ Add",
57
+ "device.btn.useDefaults": "Use defaults",
58
+ "device.btn.clear": "Clear",
59
+ "device.prompt.camera.title": "Camera Prompt — Select an image",
60
+ "device.prompt.photos.title": "Photos Prompt — Select images",
61
+ "device.prompt.location.title": "Location Prompt — Enter coordinates",
62
+ "device.prompt.locationUpdate.title": "Location Update — Send coordinates",
63
+ "device.prompt.fallbackTitle": "Prompt: {type}",
64
+ "device.prompt.label.lat": "Lat",
65
+ "device.prompt.label.lng": "Lng",
66
+ "device.prompt.send": "Send",
67
+ "device.prompt.cancel": "Cancel",
68
+ "device.section.haptic": "Haptic",
69
+ "device.haptic.lastCall": "Last haptic",
70
+ "device.haptic.noneYet": "(none yet)",
71
+ "device.haptic.trigger": "Trigger haptic",
72
+ "viewport.section.device": "Device",
73
+ "viewport.row.preset": "Preset",
74
+ "viewport.row.orientation": "Orientation",
75
+ "viewport.row.notchSide": "Notch side",
76
+ "viewport.section.custom": "Custom size",
77
+ "viewport.row.width": "Width (px)",
78
+ "viewport.row.height": "Height (px)",
79
+ "viewport.section.appearance": "Appearance",
80
+ "viewport.row.showFrame": "Show frame",
81
+ "viewport.row.showAitNavBar": "Show Apps in Toss nav bar",
82
+ "viewport.row.navBarType": "Nav bar type",
83
+ "viewport.status.noConstraint": "No viewport constraint — body fills the window.",
84
+ "viewport.status.cssPhysical": "CSS / physical",
85
+ "viewport.status.safeArea": "Safe area",
86
+ "viewport.status.aitNavBar": "AIT nav bar",
87
+ "viewport.status.aitNavBarValue": "{height}px → SafeArea top · {type}",
88
+ "viewport.orientation.autoSuffix": "{orient} (auto)",
89
+ "iap.section.simulator": "IAP Simulator",
90
+ "iap.row.nextResult": "Next Purchase Result",
91
+ "iap.section.tossPay": "TossPay",
92
+ "iap.row.tossPayResult": "Next Payment Result",
93
+ "iap.section.pending": "Pending Orders ({count})",
94
+ "iap.empty.pending": "(no pending orders)",
95
+ "iap.section.completed": "Completed Orders ({count})",
96
+ "iap.empty.completed": "(no completed orders)",
97
+ "iap.btn.complete": "Complete",
98
+ "iap.label.pending": "PENDING",
99
+ "events.section.navigation": "Navigation Events",
100
+ "events.btn.triggerBack": "Trigger Back Event",
101
+ "events.btn.triggerHome": "Trigger Home Event",
102
+ "events.section.login": "Login",
103
+ "events.row.loggedIn": "Logged In",
104
+ "events.row.tossLoginIntegrated": "Toss Login Integrated",
105
+ "analytics.section.log": "Analytics Log ({count})",
106
+ "analytics.btn.clear": "Clear",
107
+ "analytics.calls.section": "SDK Calls ({count})",
108
+ "analytics.calls.btn.clear": "Clear",
109
+ "analytics.calls.empty": "(no SDK calls yet)",
110
+ "storage.section.title": "Storage ({count} items)",
111
+ "storage.btn.clearAll": "Clear All",
112
+ "storage.empty": "No items in storage",
113
+ "presets.section.builtIn": "Built-in scenarios",
114
+ "presets.section.saved": "Saved presets ({count})",
115
+ "presets.section.save": "Save",
116
+ "presets.save.description": "Capture network / permissions / auth / IAP / ads / payment slices.",
117
+ "presets.btn.saveCurrent": "Save current as preset",
118
+ "presets.btn.apply": "Apply",
119
+ "presets.btn.reApply": "Re-apply",
120
+ "presets.btn.delete": "Delete",
121
+ "presets.empty.saved": "No saved presets yet.",
122
+ "presets.empty.builtIn": "No built-in presets.",
123
+ "presets.prompt.label": "Preset label?",
124
+ "presets.confirm.delete": "Delete preset \"{label}\"?",
125
+ "ads.section.state": "Ads State",
126
+ "ads.row.isLoaded": "isLoaded",
127
+ "ads.row.forceNoFill": "Force \"no fill\"",
128
+ "ads.empty.events": "No events yet",
129
+ "ads.section.googleAdMob": "GoogleAdMob",
130
+ "ads.section.tossAds": "TossAds",
131
+ "ads.section.fullScreenAd": "FullScreenAd",
132
+ "ads.btn.load": "Load",
133
+ "ads.btn.show": "Show",
134
+ "ads.section.tossAdsBanner": "TossAds Banner",
135
+ "ads.row.rewardUnitType": "Reward unit type",
136
+ "ads.row.rewardAmount": "Reward amount",
137
+ "ads.btn.render": "Render",
138
+ "ads.btn.noFill": "No-fill",
139
+ "ads.btn.click": "Click",
140
+ "ads.btn.destroy": "Destroy",
141
+ "notifications.section.title": "requestNotificationAgreement",
142
+ "notifications.option.newAgreement": "newAgreement (first-time agree)",
143
+ "notifications.option.alreadyAgreed": "alreadyAgreed (already opted-in)",
144
+ "notifications.option.agreementRejected": "agreementRejected (user declined)",
145
+ "dashboard.lang.ko": "한국어",
146
+ "dashboard.lang.en": "English",
147
+ "dashboard.title": "AIT Debug Dashboard",
148
+ "dashboard.updated": "Last updated: {ts}",
149
+ "dashboard.tunnel.section": "Tunnel status",
150
+ "dashboard.tunnel.up": "Connected",
151
+ "dashboard.tunnel.down": "Disconnected",
152
+ "dashboard.attach.section": "Attach QR",
153
+ "dashboard.attach.hint": "Call the start_attach MCP tool to show the QR here.",
154
+ "dashboard.attach.tunnelDown": "Relay disconnected — this QR is no longer valid. Restart the relay, then regenerate the QR.",
155
+ "dashboard.pages.section": "Connected Pages",
156
+ "dashboard.pages.empty": "No attached pages",
157
+ "dashboard.url.copy": "Copy",
158
+ "dashboard.url.copied": "Copied",
159
+ "dashboard.inspector.section": "Inspector",
160
+ "dashboard.inspector.open": "Open DevTools",
161
+ "dashboard.inspector.waiting": "Attach a page to enable the \"Open DevTools\" button",
162
+ "inspector.error.noTarget": "No page attached. Attach a device and try again.",
163
+ "inspector.error.relayDown": "Relay is not active. Start a relay session first.",
164
+ "dashboard.watchdog.title": "Server has shut down",
165
+ "dashboard.watchdog.body": "The MCP server has stopped. This session is no longer active. You may close this tab.",
166
+ "dashboard.watchdog.close": "Close tab",
167
+ "dashboard.conn.lost": "Connection lost — reconnecting…",
168
+ "dashboard.session.completeTitle": "Test run complete",
169
+ "dashboard.session.completeBody": "The runner exited cleanly. See your terminal for results.",
170
+ "dashboard.session.shutdownTitle": "Debug server stopped",
171
+ "dashboard.session.shutdownBody": "The MCP server has shut down; this session is no longer live. You can close this tab.",
172
+ "attach.title": "AIT Debug Session — QR Scan",
173
+ "attach.deployment": "deployment: {label}",
174
+ "attach.steps.section": "How to scan",
175
+ "attach.faq.section": "Troubleshooting checklist",
176
+ "attach.url.section": "URL (fallback)",
177
+ "attach.mode.sandbox": "env 2 — AITC Sandbox App (PWA)",
178
+ "attach.mode.intossDev": "env 3 — intoss-private relay dev",
179
+ "attach.sandbox.step1": "Launch the launcher PWA icon on your home screen (if the Safari address bar is visible, it is not standalone).",
180
+ "attach.sandbox.step2": "Scan this QR code with <strong>\"Scan QR with camera\"</strong> inside the launcher.",
181
+ "attach.sandbox.step3": "The mini-app opens fullscreen and the debug session attaches automatically.",
182
+ "attach.sandbox.faq.notInstalled": "<strong>Launcher is not installed</strong> — open <code>devtools.aitc.dev/launcher/</code> once and add it to your home screen",
183
+ "attach.sandbox.faq.cameraApp": "<strong>Scanning with the camera app opens a Safari tab (bottom tab bar visible)</strong> — relaunch from the launcher icon and use the in-app scanner",
184
+ "attach.sandbox.faq.totp": "<strong>QR expired (TOTP — 30-second step, ±6 steps (~3 min) accepted)</strong> — scan a fresh QR code",
185
+ "attach.sandbox.faq.chii": "<strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import",
186
+ "attach.intoss.step1": "Open the Toss app.",
187
+ "attach.intoss.step2": "Scan the QR code with your phone camera app.",
188
+ "attach.intoss.step3": "Tap <strong>\"Open in Toss\"</strong> when the popup appears.",
189
+ "attach.intoss.step4": "The mini-app opens and the debug session attaches automatically.",
190
+ "attach.intoss.faq.appNotOpen": "<strong>Toss app does not open</strong> — check app version; scan with the system camera app (not the Toss in-app QR reader)",
191
+ "attach.intoss.faq.prepare": "<strong>Mini-app stuck in PREPARE state</strong> — verify the deep-link has a <code>_deploymentId</code> parameter",
192
+ "attach.intoss.faq.chii": "<strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import",
193
+ "attach.intoss.faq.totp": "<strong>TOTP gate Layer C is inactive</strong> — check that <code>AIT_DEBUG_TOTP_SECRET</code> is set on the relay server",
194
+ "launcher.title": "AITC DevTools Launcher",
195
+ "launcher.description": "Scan the terminal QR code or paste the tunnel URL.",
196
+ "launcher.installCta": "Install launcher to your phone",
197
+ "launcher.urlPlaceholder": "https://example.trycloudflare.com",
198
+ "launcher.openBtn": "Open",
199
+ "launcher.scanBtn": "Scan QR with camera",
200
+ "launcher.noCamera": "No camera available — paste the URL instead.",
201
+ "launcher.cameraError": "Could not access the camera — paste the URL instead.",
202
+ "launcher.invalidUrlHttps": "Enter a valid https:// URL (the tunnel URL from your terminal).",
203
+ "launcher.invalidUrl": "Enter a valid http(s):// URL.",
204
+ "launcher.debugAuthFailed": "Debug connection authentication failed",
205
+ "launcher.debugAuthFailedHint": "The QR code may have expired. Scan a fresh QR code.",
206
+ "launcher.debugAuthExpiredHint": "The debug session has expired. Scan a fresh QR from the attach page on your Mac.",
207
+ "launcher.debugAuthRescanCta": "Scan a new QR",
208
+ "launcher.diagTitle": "Viewport diagnostics",
209
+ "launcher.diagYes": "yes",
210
+ "launcher.diagNo": "no",
211
+ "launcher.letterboxDetected": "An iOS viewport constraint may clip the bottom {pt}pt — rotating to landscape and back to portrait may resolve it.",
212
+ "launcher.letterboxClipped": "An iOS viewport bug makes the bottom {pt}pt unusable — rotating to landscape and back to portrait may recover it.",
213
+ "launcher.diagVerdictLabel": "Verdict reason",
214
+ "launcher.diagSafeAreaTrace": "top re-measure trace",
215
+ "launcher.diagVerdict.detected": "✓ letterbox correction",
216
+ "launcher.diagVerdict.notStandalone": "not standalone",
217
+ "launcher.diagVerdict.landscape": "landscape",
218
+ "launcher.diagVerdict.shortfallTooSmall": "shortfall too small",
219
+ "launcher.diagVerdict.safeAreaTopZero": "top=0 (env() stale?)",
220
+ "launcher.navbar.defaultTitle": "Mini App",
221
+ "launcher.navbar.back": "Back",
222
+ "launcher.navbar.menu": "Menu",
223
+ "launcher.navbar.close": "Close",
224
+ "launcher.navbar.menuRescan": "Rescan",
225
+ "launcher.navbar.menuDiag": "Viewport diagnostics",
226
+ "launcher.navbar.menuLanguage": "Language"
227
+ };
228
+ //#endregion
229
+ //#region src/i18n/index.ts
230
+ /**
231
+ * Vanilla TS i18n for the floating DevTools panel.
232
+ *
233
+ * Public surface:
234
+ * - `t(key, vars?)` — look up a UI string, with `{name}` placeholder
235
+ * interpolation. Falls back to the key itself if a translation is missing.
236
+ * - `getLocale()` / `setLocale(locale)` — read/persist the active locale.
237
+ * `setLocale` dispatches `__ait:localechange` so the panel can remount.
238
+ * - `detectLocale()` — first-run heuristic from `navigator.language`.
239
+ *
240
+ * `ko` is the source of truth (keys are typed from it). `en` is also a full
241
+ * `Record<StringKey, string>` (devtools is developer-facing, en is a real
242
+ * audience). The `Partial` lookup table preserves the runtime `?? key` safety
243
+ * net even though we ship complete catalogs today.
244
+ */
245
+ const tables = {
246
+ ko: {
247
+ "panel.title": "AIT DevTools",
248
+ "panel.toggle.title": "AIT DevTools",
249
+ "panel.close": "Close",
250
+ "panel.editMode.on": "EDIT",
251
+ "panel.editMode.off": "READ-ONLY",
252
+ "panel.editMode.toggleTitle": "패널 편집 모드 전환",
253
+ "panel.tabError": "\"{tab}\" 탭 렌더링 중 오류가 발생했습니다.",
254
+ "panel.tab.env": "Environment",
255
+ "panel.tab.presets": "Presets",
256
+ "panel.tab.viewport": "Viewport",
257
+ "panel.tab.permissions": "Permissions",
258
+ "panel.tab.notifications": "Notifications",
259
+ "panel.tab.location": "Location",
260
+ "panel.tab.device": "Device",
261
+ "panel.tab.iap": "IAP",
262
+ "panel.tab.ads": "Ads",
263
+ "panel.tab.events": "Events",
264
+ "panel.tab.analytics": "Analytics",
265
+ "panel.tab.storage": "Storage",
266
+ "common.readOnly": "읽기 전용 — mock 응답은 빌드 타임에 고정됩니다.",
267
+ "env.section.platform": "Platform",
268
+ "env.row.os": "OS",
269
+ "env.row.appVersion": "App Version",
270
+ "env.row.environment": "Environment",
271
+ "env.row.locale": "Locale",
272
+ "env.section.network": "Network",
273
+ "env.row.networkStatus": "Status",
274
+ "env.section.safeArea": "Safe Area Insets",
275
+ "env.row.safeArea.top": "Top",
276
+ "env.row.safeArea.bottom": "Bottom",
277
+ "env.section.navigation": "Navigation",
278
+ "env.row.iosSwipeGesture": "iOS swipe-back",
279
+ "env.value.iosSwipeGesture.unset": "미호출",
280
+ "env.value.iosSwipeGesture.enabled": "enabled",
281
+ "env.value.iosSwipeGesture.disabled": "disabled",
282
+ "env.hint.iosSwipeGesture": "setIosSwipeGestureEnabled의 마지막 호출값. Environment를 toss로 바꾸면 toss-gated 가드가 이 값을 토글합니다.",
283
+ "env.section.language": "Language",
284
+ "env.language.row": "Language",
285
+ "env.language.ko": "한국어",
286
+ "env.language.en": "English",
287
+ "permissions.section.device": "Device Permissions",
288
+ "location.section.current": "Current Location",
289
+ "location.row.latitude": "Latitude",
290
+ "location.row.longitude": "Longitude",
291
+ "location.row.accuracy": "Accuracy",
292
+ "device.section.modes": "Device API Modes",
293
+ "device.row.camera": "Camera",
294
+ "device.row.photos": "Photos",
295
+ "device.row.location": "Location",
296
+ "device.row.network": "Network",
297
+ "device.row.clipboard": "Clipboard",
298
+ "device.section.mockImages": "Mock Images ({count})",
299
+ "device.btn.add": "+ Add",
300
+ "device.btn.useDefaults": "Use defaults",
301
+ "device.btn.clear": "Clear",
302
+ "device.prompt.camera.title": "Camera Prompt — 이미지를 선택하세요",
303
+ "device.prompt.photos.title": "Photos Prompt — 이미지를 선택하세요",
304
+ "device.prompt.location.title": "Location Prompt — 좌표 입력",
305
+ "device.prompt.locationUpdate.title": "Location Update — 좌표 전송",
306
+ "device.prompt.fallbackTitle": "Prompt: {type}",
307
+ "device.prompt.label.lat": "Lat",
308
+ "device.prompt.label.lng": "Lng",
309
+ "device.prompt.send": "Send",
310
+ "device.prompt.cancel": "Cancel",
311
+ "device.section.haptic": "Haptic",
312
+ "device.haptic.lastCall": "마지막 haptic",
313
+ "device.haptic.noneYet": "(아직 없음)",
314
+ "device.haptic.trigger": "Haptic 트리거",
315
+ "viewport.section.device": "Device",
316
+ "viewport.row.preset": "Preset",
317
+ "viewport.row.orientation": "Orientation",
318
+ "viewport.row.notchSide": "Notch side",
319
+ "viewport.section.custom": "Custom size",
320
+ "viewport.row.width": "Width (px)",
321
+ "viewport.row.height": "Height (px)",
322
+ "viewport.section.appearance": "Appearance",
323
+ "viewport.row.showFrame": "Show frame",
324
+ "viewport.row.showAitNavBar": "Apps in Toss 내비게이션 바 표시",
325
+ "viewport.row.navBarType": "Nav bar type",
326
+ "viewport.status.noConstraint": "뷰포트 제약 없음 — body가 창을 가득 채웁니다.",
327
+ "viewport.status.cssPhysical": "CSS / physical",
328
+ "viewport.status.safeArea": "Safe area",
329
+ "viewport.status.aitNavBar": "AIT nav bar",
330
+ "viewport.status.aitNavBarValue": "{height}px → SafeArea top · {type}",
331
+ "viewport.orientation.autoSuffix": "{orient} (auto)",
332
+ "iap.section.simulator": "IAP Simulator",
333
+ "iap.row.nextResult": "Next Purchase Result",
334
+ "iap.section.tossPay": "TossPay",
335
+ "iap.row.tossPayResult": "Next Payment Result",
336
+ "iap.section.pending": "Pending Orders ({count})",
337
+ "iap.empty.pending": "(대기 중인 주문 없음)",
338
+ "iap.section.completed": "Completed Orders ({count})",
339
+ "iap.empty.completed": "(완료된 주문 없음)",
340
+ "iap.btn.complete": "Complete",
341
+ "iap.label.pending": "PENDING",
342
+ "events.section.navigation": "Navigation Events",
343
+ "events.btn.triggerBack": "Back 이벤트 발생",
344
+ "events.btn.triggerHome": "Home 이벤트 발생",
345
+ "events.section.login": "Login",
346
+ "events.row.loggedIn": "Logged In",
347
+ "events.row.tossLoginIntegrated": "Toss Login Integrated",
348
+ "analytics.section.log": "Analytics Log ({count})",
349
+ "analytics.btn.clear": "Clear",
350
+ "analytics.calls.section": "SDK Calls ({count})",
351
+ "analytics.calls.btn.clear": "Clear",
352
+ "analytics.calls.empty": "(아직 SDK 호출 없음)",
353
+ "storage.section.title": "Storage ({count} items)",
354
+ "storage.btn.clearAll": "Clear All",
355
+ "storage.empty": "저장된 항목이 없습니다",
356
+ "presets.section.builtIn": "Built-in scenarios",
357
+ "presets.section.saved": "Saved presets ({count})",
358
+ "presets.section.save": "Save",
359
+ "presets.save.description": "network / permissions / auth / IAP / ads / payment 슬라이스를 캡처합니다.",
360
+ "presets.btn.saveCurrent": "현재 상태를 프리셋으로 저장",
361
+ "presets.btn.apply": "Apply",
362
+ "presets.btn.reApply": "Re-apply",
363
+ "presets.btn.delete": "Delete",
364
+ "presets.empty.saved": "저장된 프리셋이 아직 없습니다.",
365
+ "presets.empty.builtIn": "내장 프리셋이 없습니다.",
366
+ "presets.prompt.label": "프리셋 라벨을 입력하세요",
367
+ "presets.confirm.delete": "\"{label}\" 프리셋을 삭제할까요?",
368
+ "ads.section.state": "Ads State",
369
+ "ads.row.isLoaded": "isLoaded",
370
+ "ads.row.forceNoFill": "강제 \"no fill\"",
371
+ "ads.empty.events": "아직 이벤트가 없습니다",
372
+ "ads.section.googleAdMob": "GoogleAdMob",
373
+ "ads.section.tossAds": "TossAds",
374
+ "ads.section.fullScreenAd": "FullScreenAd",
375
+ "ads.btn.load": "Load",
376
+ "ads.btn.show": "Show",
377
+ "ads.section.tossAdsBanner": "TossAds 배너",
378
+ "ads.row.rewardUnitType": "리워드 단위 타입",
379
+ "ads.row.rewardAmount": "리워드 수량",
380
+ "ads.btn.render": "Render",
381
+ "ads.btn.noFill": "No-fill",
382
+ "ads.btn.click": "Click",
383
+ "ads.btn.destroy": "Destroy",
384
+ "notifications.section.title": "requestNotificationAgreement",
385
+ "notifications.option.newAgreement": "newAgreement (최초 동의)",
386
+ "notifications.option.alreadyAgreed": "alreadyAgreed (이미 동의됨)",
387
+ "notifications.option.agreementRejected": "agreementRejected (사용자 거절)",
388
+ "dashboard.lang.ko": "한국어",
389
+ "dashboard.lang.en": "English",
390
+ "dashboard.title": "AIT 디버그 Dashboard",
391
+ "dashboard.updated": "마지막 갱신: {ts}",
392
+ "dashboard.tunnel.section": "터널 상태",
393
+ "dashboard.tunnel.up": "연결됨",
394
+ "dashboard.tunnel.down": "끊어짐",
395
+ "dashboard.attach.section": "Attach QR",
396
+ "dashboard.attach.hint": "start_attach MCP tool을 호출하면 QR이 여기에 표시됩니다.",
397
+ "dashboard.attach.tunnelDown": "relay 연결이 끊겼습니다 — 이 QR은 더 이상 유효하지 않습니다. relay를 재시작한 뒤 QR을 다시 생성하세요.",
398
+ "dashboard.pages.section": "연결된 Pages",
399
+ "dashboard.pages.empty": "attach된 페이지 없음",
400
+ "dashboard.url.copy": "복사",
401
+ "dashboard.url.copied": "복사됨",
402
+ "dashboard.inspector.section": "인스펙터",
403
+ "dashboard.inspector.open": "디버그 툴 열기",
404
+ "dashboard.inspector.waiting": "페이지를 attach하면 \"디버그 툴 열기\" 버튼이 표시됩니다",
405
+ "inspector.error.noTarget": "연결된 페이지가 없습니다. 기기를 attach한 후 다시 시도하세요.",
406
+ "inspector.error.relayDown": "relay가 활성화되지 않았습니다. start_debug로 relay를 기동하세요.",
407
+ "dashboard.watchdog.title": "서버가 종료되었습니다",
408
+ "dashboard.watchdog.body": "MCP server가 종료되어 이 세션이 더 이상 유효하지 않습니다. 이 탭을 닫아도 됩니다.",
409
+ "dashboard.watchdog.close": "탭 닫기",
410
+ "dashboard.conn.lost": "연결 끊김 — 재연결 시도 중…",
411
+ "dashboard.session.completeTitle": "테스트 실행 완료",
412
+ "dashboard.session.completeBody": "러너가 정상 종료되었습니다. 결과는 터미널에서 확인하세요.",
413
+ "dashboard.session.shutdownTitle": "디버그 서버가 종료되었습니다",
414
+ "dashboard.session.shutdownBody": "MCP server가 종료되어 이 세션이 더 이상 유효하지 않습니다. 이 탭을 닫아도 됩니다.",
415
+ "attach.title": "AIT 디버그 세션 — QR 스캔",
416
+ "attach.deployment": "deployment: {label}",
417
+ "attach.steps.section": "스캔 절차",
418
+ "attach.faq.section": "진단 체크리스트",
419
+ "attach.url.section": "URL (fallback)",
420
+ "attach.mode.sandbox": "환경 2 — AITC Sandbox App (PWA)",
421
+ "attach.mode.intossDev": "환경 3 — intoss-private relay dev",
422
+ "attach.sandbox.step1": "홈 화면의 launcher PWA 아이콘으로 실행하세요 (Safari 주소창이 보이면 standalone이 아닙니다).",
423
+ "attach.sandbox.step2": "launcher 안의 <strong>\"QR 카메라로 스캔\"</strong>으로 이 QR 코드를 스캔하세요.",
424
+ "attach.sandbox.step3": "미니앱이 풀스크린으로 열리고 디버그 세션이 자동으로 attach됩니다.",
425
+ "attach.sandbox.faq.notInstalled": "<strong>launcher가 설치돼 있지 않은 경우</strong> — <code>devtools.aitc.dev/launcher/</code>를 한 번 열어 홈 화면에 추가하세요",
426
+ "attach.sandbox.faq.cameraApp": "<strong>카메라 앱으로 스캔하면 Safari 탭으로 열립니다 (하단 탭 바 노출)</strong> — launcher 아이콘으로 다시 실행해 인앱 스캔을 사용하세요",
427
+ "attach.sandbox.faq.totp": "<strong>QR이 만료된 경우 (TOTP — 코드 1개는 30초 창, 만료 후 ~3분(±6 step) 이내 소급 허용)</strong> — 새 QR을 다시 스캔하세요",
428
+ "attach.sandbox.faq.chii": "<strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인",
429
+ "attach.intoss.step1": "토스 앱을 실행하세요.",
430
+ "attach.intoss.step2": "폰 카메라 앱으로 QR 코드를 스캔하세요.",
431
+ "attach.intoss.step3": "팝업이 뜨면 <strong>\"토스로 열기\"</strong>를 탭하세요.",
432
+ "attach.intoss.step4": "미니앱이 열리고 디버그 세션이 자동으로 attach됩니다.",
433
+ "attach.intoss.faq.appNotOpen": "<strong>토스 앱이 안 열리는 경우</strong> — 앱 버전 확인, 카메라 앱으로 스캔 (토스 앱 내 QR 리더 X)",
434
+ "attach.intoss.faq.prepare": "<strong>미니앱이 PREPARE 상태에서 멈추는 경우</strong> — deep-link에 <code>_deploymentId</code> 파라미터가 있는지 확인",
435
+ "attach.intoss.faq.chii": "<strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인",
436
+ "attach.intoss.faq.totp": "<strong>TOTP gate Layer C가 비활성인 경우</strong> — relay 서버에 <code>AIT_DEBUG_TOTP_SECRET</code>이 설정돼 있는지 확인",
437
+ "launcher.title": "AITC DevTools Launcher",
438
+ "launcher.description": "터미널 QR을 스캔하거나 URL을 입력하세요.",
439
+ "launcher.installCta": "폰에 런처 설치하기",
440
+ "launcher.urlPlaceholder": "https://example.trycloudflare.com",
441
+ "launcher.openBtn": "Open",
442
+ "launcher.scanBtn": "QR 카메라로 스캔",
443
+ "launcher.noCamera": "카메라를 사용할 수 없습니다 — URL을 직접 붙여넣으세요.",
444
+ "launcher.cameraError": "카메라에 접근할 수 없습니다 — URL을 직접 붙여넣으세요.",
445
+ "launcher.invalidUrlHttps": "올바른 https:// URL을 입력하세요 (터미널의 터널 URL).",
446
+ "launcher.invalidUrl": "올바른 http(s):// URL을 입력하세요.",
447
+ "launcher.debugAuthFailed": "디버그 연결 인증 실패",
448
+ "launcher.debugAuthFailedHint": "QR 코드가 만료되었을 수 있어요. 새 QR을 다시 스캔하세요.",
449
+ "launcher.debugAuthExpiredHint": "디버그 세션이 만료됐어요. Mac의 attach 페이지에서 새 QR을 스캔하세요.",
450
+ "launcher.debugAuthRescanCta": "새 QR 스캔하기",
451
+ "launcher.diagTitle": "뷰포트 진단",
452
+ "launcher.diagYes": "예",
453
+ "launcher.diagNo": "아니요",
454
+ "launcher.letterboxDetected": "iOS 뷰포트 제약으로 화면 아래 {pt}pt가 잘릴 수 있습니다 — 기기를 가로로 돌렸다 세로로 복귀하면 해소될 수 있어요.",
455
+ "launcher.letterboxClipped": "iOS 뷰포트 버그로 화면 아래 {pt}pt를 쓸 수 없습니다 — 기기를 가로로 돌렸다 세로로 돌리면 복구될 수 있어요.",
456
+ "launcher.diagVerdictLabel": "판정 사유",
457
+ "launcher.diagSafeAreaTrace": "top 재측정 추이",
458
+ "launcher.diagVerdict.detected": "✓ letterbox 보정",
459
+ "launcher.diagVerdict.notStandalone": "홈 화면 앱 아님",
460
+ "launcher.diagVerdict.landscape": "가로 모드",
461
+ "launcher.diagVerdict.shortfallTooSmall": "높이 차이 미달",
462
+ "launcher.diagVerdict.safeAreaTopZero": "top=0 (env() stale?)",
463
+ "launcher.navbar.defaultTitle": "미니앱",
464
+ "launcher.navbar.back": "뒤로가기",
465
+ "launcher.navbar.menu": "메뉴",
466
+ "launcher.navbar.close": "닫기",
467
+ "launcher.navbar.menuRescan": "다시 스캔",
468
+ "launcher.navbar.menuDiag": "뷰포트 진단",
469
+ "launcher.navbar.menuLanguage": "언어"
470
+ },
471
+ en
472
+ };
473
+ /**
474
+ * Decide a locale from a BCP-47 language tag. `ko` (and `ko-*`) → `'ko'`,
475
+ * everything else → `'en'`. Shared by the browser (`navigator.language`) and
476
+ * Node (`Accept-Language` header) paths so both resolve identically.
477
+ */
478
+ function localeFromLanguageTag(lang) {
479
+ return /^ko\b/i.test(lang) ? "ko" : "en";
480
+ }
481
+ /**
482
+ * Decide a locale from an HTTP `Accept-Language` header value. The Node-served
483
+ * surfaces (e.g. the qr-http-server dashboard) have no `navigator`, so the
484
+ * request header is the only language signal. Reads the FIRST language tag
485
+ * (highest priority, ignoring `q=` weights — good enough for ko/en) and feeds
486
+ * it through the same `ko`-vs-`en` heuristic `detectLocale` uses. Returns `'ko'`
487
+ * for an empty/missing header (ko is the primary locale).
488
+ */
489
+ function parseAcceptLanguage(header) {
490
+ if (!header) return "ko";
491
+ return localeFromLanguageTag(header.split(",")[0]?.trim().split(";")[0]?.trim() ?? "");
492
+ }
493
+ /**
494
+ * A locale-bound string resolver for surfaces that can't use the in-memory
495
+ * `getLocale()` cache — notably the Node HTTP server, which resolves locale
496
+ * per-request from `Accept-Language` rather than from a process-global. Returns
497
+ * a `t`-compatible closure over the SAME `ko`/`en` tables (single source of
498
+ * truth), so the dashboard/attach HTML shares the exact 169-key catalog the
499
+ * browser surfaces use. The `key: StringKey` signature keeps compile-time key
500
+ * safety on the Node path identical to `t()`.
501
+ */
502
+ function resolveLocaleStrings(locale) {
503
+ const table = tables[locale];
504
+ return (key, vars) => {
505
+ const raw = table[key] ?? key;
506
+ if (!vars) return raw;
507
+ return raw.replace(/\{(\w+)\}/g, (match, name) => {
508
+ const value = vars[name];
509
+ return value === void 0 ? match : String(value);
510
+ });
511
+ };
512
+ }
513
+ //#endregion
514
+ //#region src/mcp/dashboard.generated.ts
515
+ const dashboardChromeHtmlKo = `<!DOCTYPE html>
516
+ <html lang="ko"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>AIT 디버그 Dashboard</title><style>
517
+ *, *::before, *::after { box-sizing: border-box; }
518
+ body {
519
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
520
+ background: #0d1117; color: #c9d1d9;
521
+ display: flex; flex-direction: column; align-items: center;
522
+ min-height: 100vh; margin: 0; padding: 2rem 1rem;
523
+ gap: 1.5rem;
524
+ }
525
+ h1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }
526
+ .updated { font-size: 0.75rem; opacity: 0.4; font-family: monospace; margin: 0; }
527
+ section { width: 100%; max-width: 520px; }
528
+ h2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }
529
+ .status { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; }
530
+ .status-up { background: #238636; color: #fff; }
531
+ .status-down { background: #6e7681; color: #fff; }
532
+ img.qr {
533
+ width: min(80vw, 300px); height: auto;
534
+ image-rendering: pixelated;
535
+ background: #fff; padding: 0.75rem; border-radius: 10px;
536
+ display: block; margin: 0.5rem auto;
537
+ }
538
+ .url-row {
539
+ display: flex; align-items: stretch; gap: 0; margin: 0.5rem 0 0;
540
+ border-radius: 6px; border: 1px solid #30363d; overflow: hidden;
541
+ }
542
+ .url-box {
543
+ font-family: monospace; font-size: 0.7rem;
544
+ word-break: break-all; opacity: 0.45;
545
+ background: #161b22; padding: 0.6rem 0.85rem;
546
+ flex: 1; cursor: pointer; border: none; border-radius: 0;
547
+ }
548
+ .url-box:hover { opacity: 0.65; }
549
+ .copy-btn {
550
+ flex-shrink: 0; padding: 0.4rem 0.7rem;
551
+ background: #21262d; border: none; border-left: 1px solid #30363d;
552
+ color: #58a6ff; font-size: 0.7rem; cursor: pointer; white-space: nowrap;
553
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
554
+ }
555
+ .copy-btn:hover { background: #30363d; }
556
+ .hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }
557
+ .hint.error { color: #f85149; opacity: 1; font-weight: 600; }
558
+ .inspector-link {
559
+ display: inline-block; margin-top: 0.5rem;
560
+ padding: 0.45rem 1rem; border-radius: 6px;
561
+ background: #1f6feb; color: #fff; font-size: 0.85rem; font-weight: 600;
562
+ text-decoration: none; text-align: center;
563
+ }
564
+ .inspector-link:hover { background: #388bfd; }
565
+ .inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }
566
+ ul { margin: 0; padding-left: 1.25rem; }
567
+ li { margin-bottom: 0.35rem; font-size: 0.85rem; line-height: 1.5; }
568
+ li.empty { opacity: 0.4; list-style: none; padding-left: 0; }
569
+ .page-id { font-family: monospace; font-size: 0.75rem; opacity: 0.5; margin-right: 0.4rem; }
570
+ .page-url { word-break: break-all; }
571
+ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0; }
572
+ .lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }
573
+ .lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
574
+ .lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
575
+ </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><hr/><section id="inspector-section"><h2>인스펙터</h2>__INSPECTOR_SECTION__</section>__PAGES_SECTION__</body></html>`;
576
+ const attachChromeHtmlKoSandbox = `<!DOCTYPE html>
577
+ <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>
578
+ *, *::before, *::after { box-sizing: border-box; }
579
+ body {
580
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
581
+ background: #0d1117; color: #c9d1d9;
582
+ display: flex; flex-direction: column; align-items: center;
583
+ min-height: 100vh; margin: 0; padding: 2rem 1rem;
584
+ gap: 1.5rem;
585
+ }
586
+ h1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }
587
+ .mode-label {
588
+ font-size: 0.78rem; font-weight: 600; color: #79c0ff;
589
+ background: #161b22; border: 1px solid #30363d; border-radius: 999px;
590
+ padding: 0.25rem 0.75rem; margin: 0;
591
+ }
592
+ .label { font-size: 0.8rem; opacity: 0.5; font-family: monospace; margin: 0; }
593
+ img.qr {
594
+ width: min(90vw, 360px); height: auto;
595
+ image-rendering: pixelated;
596
+ background: #fff; padding: 1rem; border-radius: 12px;
597
+ display: block; margin: 0 auto;
598
+ }
599
+ section { width: 100%; max-width: 480px; }
600
+ .hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }
601
+ .hint.error { color: #f85149; opacity: 1; font-weight: 600; }
602
+ h2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }
603
+ ol, ul { margin: 0; padding-left: 1.25rem; }
604
+ li { margin-bottom: 0.4rem; font-size: 0.9rem; line-height: 1.5; }
605
+ .url-row {
606
+ display: flex; align-items: stretch; gap: 0;
607
+ border-radius: 6px; border: 1px solid #30363d; overflow: hidden;
608
+ }
609
+ .url-box {
610
+ font-family: monospace; font-size: 0.72rem;
611
+ word-break: break-all; opacity: 0.4;
612
+ background: #161b22; padding: 0.75rem 1rem;
613
+ flex: 1; cursor: pointer; border: none; border-radius: 0;
614
+ }
615
+ .url-box:hover { opacity: 0.6; }
616
+ .copy-btn {
617
+ flex-shrink: 0; padding: 0.5rem 0.8rem;
618
+ background: #21262d; border: none; border-left: 1px solid #30363d;
619
+ color: #58a6ff; font-size: 0.75rem; cursor: pointer; white-space: nowrap;
620
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
621
+ }
622
+ .copy-btn:hover { background: #30363d; }
623
+ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0; }
624
+ .lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }
625
+ .lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
626
+ .lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
627
+ .inspector-link {
628
+ display: inline-block; margin-top: 0.5rem;
629
+ padding: 0.45rem 1rem; border-radius: 6px;
630
+ background: #1f6feb; color: #fff; font-size: 0.85rem; font-weight: 600;
631
+ text-decoration: none; text-align: center;
632
+ }
633
+ .inspector-link:hover { background: #388bfd; }
634
+ .inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }
635
+ </style></head><body><h1>AIT 디버그 세션 — QR 스캔</h1>__MODE_LABEL____LANG_SWITCHER__<div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>스캔 절차</h2><ol><li>홈 화면의 launcher PWA 아이콘으로 실행하세요 (Safari 주소창이 보이면 standalone이 아닙니다).</li><li>launcher 안의 <strong>"QR 카메라로 스캔"</strong>으로 이 QR 코드를 스캔하세요.</li><li>미니앱이 풀스크린으로 열리고 디버그 세션이 자동으로 attach됩니다.</li></ol></section><hr/><section><h2>진단 체크리스트</h2><ul><li><strong>launcher가 설치돼 있지 않은 경우</strong> — <code>devtools.aitc.dev/launcher/</code>를 한 번 열어 홈 화면에 추가하세요</li><li><strong>카메라 앱으로 스캔하면 Safari 탭으로 열립니다 (하단 탭 바 노출)</strong> — launcher 아이콘으로 다시 실행해 인앱 스캔을 사용하세요</li><li><strong>QR이 만료된 경우 (TOTP — 코드 1개는 30초 창, 만료 후 ~3분(±6 step) 이내 소급 허용)</strong> — 새 QR을 다시 스캔하세요</li><li><strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인</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><hr/><section id="inspector-section"><h2>인스펙터</h2>__INSPECTOR_SECTION__</section></body></html>`;
636
+ const attachChromeHtmlKoIntoss = `<!DOCTYPE html>
637
+ <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>
638
+ *, *::before, *::after { box-sizing: border-box; }
639
+ body {
640
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
641
+ background: #0d1117; color: #c9d1d9;
642
+ display: flex; flex-direction: column; align-items: center;
643
+ min-height: 100vh; margin: 0; padding: 2rem 1rem;
644
+ gap: 1.5rem;
645
+ }
646
+ h1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }
647
+ .mode-label {
648
+ font-size: 0.78rem; font-weight: 600; color: #79c0ff;
649
+ background: #161b22; border: 1px solid #30363d; border-radius: 999px;
650
+ padding: 0.25rem 0.75rem; margin: 0;
651
+ }
652
+ .label { font-size: 0.8rem; opacity: 0.5; font-family: monospace; margin: 0; }
653
+ img.qr {
654
+ width: min(90vw, 360px); height: auto;
655
+ image-rendering: pixelated;
656
+ background: #fff; padding: 1rem; border-radius: 12px;
657
+ display: block; margin: 0 auto;
658
+ }
659
+ section { width: 100%; max-width: 480px; }
660
+ .hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }
661
+ .hint.error { color: #f85149; opacity: 1; font-weight: 600; }
662
+ h2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }
663
+ ol, ul { margin: 0; padding-left: 1.25rem; }
664
+ li { margin-bottom: 0.4rem; font-size: 0.9rem; line-height: 1.5; }
665
+ .url-row {
666
+ display: flex; align-items: stretch; gap: 0;
667
+ border-radius: 6px; border: 1px solid #30363d; overflow: hidden;
668
+ }
669
+ .url-box {
670
+ font-family: monospace; font-size: 0.72rem;
671
+ word-break: break-all; opacity: 0.4;
672
+ background: #161b22; padding: 0.75rem 1rem;
673
+ flex: 1; cursor: pointer; border: none; border-radius: 0;
674
+ }
675
+ .url-box:hover { opacity: 0.6; }
676
+ .copy-btn {
677
+ flex-shrink: 0; padding: 0.5rem 0.8rem;
678
+ background: #21262d; border: none; border-left: 1px solid #30363d;
679
+ color: #58a6ff; font-size: 0.75rem; cursor: pointer; white-space: nowrap;
680
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
681
+ }
682
+ .copy-btn:hover { background: #30363d; }
683
+ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0; }
684
+ .lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }
685
+ .lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
686
+ .lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
687
+ .inspector-link {
688
+ display: inline-block; margin-top: 0.5rem;
689
+ padding: 0.45rem 1rem; border-radius: 6px;
690
+ background: #1f6feb; color: #fff; font-size: 0.85rem; font-weight: 600;
691
+ text-decoration: none; text-align: center;
692
+ }
693
+ .inspector-link:hover { background: #388bfd; }
694
+ .inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }
695
+ </style></head><body><h1>AIT 디버그 세션 — QR 스캔</h1>__MODE_LABEL____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><hr/><section id="inspector-section"><h2>인스펙터</h2>__INSPECTOR_SECTION__</section></body></html>`;
696
+ const dashboardChromeHtmlEn = `<!DOCTYPE html>
697
+ <html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>AIT Debug Dashboard</title><style>
698
+ *, *::before, *::after { box-sizing: border-box; }
699
+ body {
700
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
701
+ background: #0d1117; color: #c9d1d9;
702
+ display: flex; flex-direction: column; align-items: center;
703
+ min-height: 100vh; margin: 0; padding: 2rem 1rem;
704
+ gap: 1.5rem;
705
+ }
706
+ h1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }
707
+ .updated { font-size: 0.75rem; opacity: 0.4; font-family: monospace; margin: 0; }
708
+ section { width: 100%; max-width: 520px; }
709
+ h2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }
710
+ .status { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; }
711
+ .status-up { background: #238636; color: #fff; }
712
+ .status-down { background: #6e7681; color: #fff; }
713
+ img.qr {
714
+ width: min(80vw, 300px); height: auto;
715
+ image-rendering: pixelated;
716
+ background: #fff; padding: 0.75rem; border-radius: 10px;
717
+ display: block; margin: 0.5rem auto;
718
+ }
719
+ .url-row {
720
+ display: flex; align-items: stretch; gap: 0; margin: 0.5rem 0 0;
721
+ border-radius: 6px; border: 1px solid #30363d; overflow: hidden;
722
+ }
723
+ .url-box {
724
+ font-family: monospace; font-size: 0.7rem;
725
+ word-break: break-all; opacity: 0.45;
726
+ background: #161b22; padding: 0.6rem 0.85rem;
727
+ flex: 1; cursor: pointer; border: none; border-radius: 0;
728
+ }
729
+ .url-box:hover { opacity: 0.65; }
730
+ .copy-btn {
731
+ flex-shrink: 0; padding: 0.4rem 0.7rem;
732
+ background: #21262d; border: none; border-left: 1px solid #30363d;
733
+ color: #58a6ff; font-size: 0.7rem; cursor: pointer; white-space: nowrap;
734
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
735
+ }
736
+ .copy-btn:hover { background: #30363d; }
737
+ .hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }
738
+ .hint.error { color: #f85149; opacity: 1; font-weight: 600; }
739
+ .inspector-link {
740
+ display: inline-block; margin-top: 0.5rem;
741
+ padding: 0.45rem 1rem; border-radius: 6px;
742
+ background: #1f6feb; color: #fff; font-size: 0.85rem; font-weight: 600;
743
+ text-decoration: none; text-align: center;
744
+ }
745
+ .inspector-link:hover { background: #388bfd; }
746
+ .inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }
747
+ ul { margin: 0; padding-left: 1.25rem; }
748
+ li { margin-bottom: 0.35rem; font-size: 0.85rem; line-height: 1.5; }
749
+ li.empty { opacity: 0.4; list-style: none; padding-left: 0; }
750
+ .page-id { font-family: monospace; font-size: 0.75rem; opacity: 0.5; margin-right: 0.4rem; }
751
+ .page-url { word-break: break-all; }
752
+ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0; }
753
+ .lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }
754
+ .lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
755
+ .lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
756
+ </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><hr/><section id="inspector-section"><h2>Inspector</h2>__INSPECTOR_SECTION__</section>__PAGES_SECTION__</body></html>`;
757
+ const attachChromeHtmlEnSandbox = `<!DOCTYPE html>
758
+ <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>
759
+ *, *::before, *::after { box-sizing: border-box; }
760
+ body {
761
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
762
+ background: #0d1117; color: #c9d1d9;
763
+ display: flex; flex-direction: column; align-items: center;
764
+ min-height: 100vh; margin: 0; padding: 2rem 1rem;
765
+ gap: 1.5rem;
766
+ }
767
+ h1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }
768
+ .mode-label {
769
+ font-size: 0.78rem; font-weight: 600; color: #79c0ff;
770
+ background: #161b22; border: 1px solid #30363d; border-radius: 999px;
771
+ padding: 0.25rem 0.75rem; margin: 0;
772
+ }
773
+ .label { font-size: 0.8rem; opacity: 0.5; font-family: monospace; margin: 0; }
774
+ img.qr {
775
+ width: min(90vw, 360px); height: auto;
776
+ image-rendering: pixelated;
777
+ background: #fff; padding: 1rem; border-radius: 12px;
778
+ display: block; margin: 0 auto;
779
+ }
780
+ section { width: 100%; max-width: 480px; }
781
+ .hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }
782
+ .hint.error { color: #f85149; opacity: 1; font-weight: 600; }
783
+ h2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }
784
+ ol, ul { margin: 0; padding-left: 1.25rem; }
785
+ li { margin-bottom: 0.4rem; font-size: 0.9rem; line-height: 1.5; }
786
+ .url-row {
787
+ display: flex; align-items: stretch; gap: 0;
788
+ border-radius: 6px; border: 1px solid #30363d; overflow: hidden;
789
+ }
790
+ .url-box {
791
+ font-family: monospace; font-size: 0.72rem;
792
+ word-break: break-all; opacity: 0.4;
793
+ background: #161b22; padding: 0.75rem 1rem;
794
+ flex: 1; cursor: pointer; border: none; border-radius: 0;
795
+ }
796
+ .url-box:hover { opacity: 0.6; }
797
+ .copy-btn {
798
+ flex-shrink: 0; padding: 0.5rem 0.8rem;
799
+ background: #21262d; border: none; border-left: 1px solid #30363d;
800
+ color: #58a6ff; font-size: 0.75rem; cursor: pointer; white-space: nowrap;
801
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
802
+ }
803
+ .copy-btn:hover { background: #30363d; }
804
+ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0; }
805
+ .lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }
806
+ .lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
807
+ .lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
808
+ .inspector-link {
809
+ display: inline-block; margin-top: 0.5rem;
810
+ padding: 0.45rem 1rem; border-radius: 6px;
811
+ background: #1f6feb; color: #fff; font-size: 0.85rem; font-weight: 600;
812
+ text-decoration: none; text-align: center;
813
+ }
814
+ .inspector-link:hover { background: #388bfd; }
815
+ .inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }
816
+ </style></head><body><h1>AIT Debug Session — QR Scan</h1>__MODE_LABEL____LANG_SWITCHER__<div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>How to scan</h2><ol><li>Launch the launcher PWA icon on your home screen (if the Safari address bar is visible, it is not standalone).</li><li>Scan this QR code with <strong>"Scan QR with camera"</strong> inside the launcher.</li><li>The mini-app opens fullscreen and the debug session attaches automatically.</li></ol></section><hr/><section><h2>Troubleshooting checklist</h2><ul><li><strong>Launcher is not installed</strong> — open <code>devtools.aitc.dev/launcher/</code> once and add it to your home screen</li><li><strong>Scanning with the camera app opens a Safari tab (bottom tab bar visible)</strong> — relaunch from the launcher icon and use the in-app scanner</li><li><strong>QR expired (TOTP — 30-second step, ±6 steps (~3 min) accepted)</strong> — scan a fresh QR code</li><li><strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import</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><hr/><section id="inspector-section"><h2>Inspector</h2>__INSPECTOR_SECTION__</section></body></html>`;
817
+ const attachChromeHtmlEnIntoss = `<!DOCTYPE html>
818
+ <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>
819
+ *, *::before, *::after { box-sizing: border-box; }
820
+ body {
821
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
822
+ background: #0d1117; color: #c9d1d9;
823
+ display: flex; flex-direction: column; align-items: center;
824
+ min-height: 100vh; margin: 0; padding: 2rem 1rem;
825
+ gap: 1.5rem;
826
+ }
827
+ h1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }
828
+ .mode-label {
829
+ font-size: 0.78rem; font-weight: 600; color: #79c0ff;
830
+ background: #161b22; border: 1px solid #30363d; border-radius: 999px;
831
+ padding: 0.25rem 0.75rem; margin: 0;
832
+ }
833
+ .label { font-size: 0.8rem; opacity: 0.5; font-family: monospace; margin: 0; }
834
+ img.qr {
835
+ width: min(90vw, 360px); height: auto;
836
+ image-rendering: pixelated;
837
+ background: #fff; padding: 1rem; border-radius: 12px;
838
+ display: block; margin: 0 auto;
839
+ }
840
+ section { width: 100%; max-width: 480px; }
841
+ .hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }
842
+ .hint.error { color: #f85149; opacity: 1; font-weight: 600; }
843
+ h2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }
844
+ ol, ul { margin: 0; padding-left: 1.25rem; }
845
+ li { margin-bottom: 0.4rem; font-size: 0.9rem; line-height: 1.5; }
846
+ .url-row {
847
+ display: flex; align-items: stretch; gap: 0;
848
+ border-radius: 6px; border: 1px solid #30363d; overflow: hidden;
849
+ }
850
+ .url-box {
851
+ font-family: monospace; font-size: 0.72rem;
852
+ word-break: break-all; opacity: 0.4;
853
+ background: #161b22; padding: 0.75rem 1rem;
854
+ flex: 1; cursor: pointer; border: none; border-radius: 0;
855
+ }
856
+ .url-box:hover { opacity: 0.6; }
857
+ .copy-btn {
858
+ flex-shrink: 0; padding: 0.5rem 0.8rem;
859
+ background: #21262d; border: none; border-left: 1px solid #30363d;
860
+ color: #58a6ff; font-size: 0.75rem; cursor: pointer; white-space: nowrap;
861
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
862
+ }
863
+ .copy-btn:hover { background: #30363d; }
864
+ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0; }
865
+ .lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }
866
+ .lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
867
+ .lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
868
+ .inspector-link {
869
+ display: inline-block; margin-top: 0.5rem;
870
+ padding: 0.45rem 1rem; border-radius: 6px;
871
+ background: #1f6feb; color: #fff; font-size: 0.85rem; font-weight: 600;
872
+ text-decoration: none; text-align: center;
873
+ }
874
+ .inspector-link:hover { background: #388bfd; }
875
+ .inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }
876
+ </style></head><body><h1>AIT Debug Session — QR Scan</h1>__MODE_LABEL____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><hr/><section id="inspector-section"><h2>Inspector</h2>__INSPECTOR_SECTION__</section></body></html>`;
877
+ /** Map from Locale to the precompiled dashboard chrome string. */
878
+ const dashboardChromeByLocale = {
879
+ ko: dashboardChromeHtmlKo,
880
+ en: dashboardChromeHtmlEn
881
+ };
882
+ /** Map from Locale × copy family to the precompiled attach page chrome string (#468). */
883
+ const attachChromeByLocale = {
884
+ ko: {
885
+ sandbox: attachChromeHtmlKoSandbox,
886
+ intoss: attachChromeHtmlKoIntoss
887
+ },
888
+ en: {
889
+ sandbox: attachChromeHtmlEnSandbox,
890
+ intoss: attachChromeHtmlEnIntoss
891
+ }
892
+ };
893
+ //#endregion
894
+ //#region src/mcp/qr-http-server.ts
895
+ /** mode → 어느 precompiled attach chrome family를 쓰는가 (#468). */
896
+ function attachFamilyForMode(mode) {
897
+ return mode === "relay-mobile" ? "sandbox" : "intoss";
898
+ }
899
+ /**
900
+ * mode → 페이지 상단 환경 라벨 HTML (`__MODE_LABEL__` 토큰 채움, #468).
901
+ * 사용자가 fidelity 사다리의 어느 겹에 있는지 즉시 알게 하는 환경 가시화 배지.
902
+ * mode 미지정/'mock'은 빈 문자열 — 알 수 없는 환경을 거짓으로 라벨링하지 않는다.
903
+ */
904
+ function buildModeLabel(mode, s) {
905
+ let label;
906
+ switch (mode) {
907
+ case "relay-mobile":
908
+ label = s("attach.mode.sandbox");
909
+ break;
910
+ case "relay-dev":
911
+ label = s("attach.mode.intossDev");
912
+ break;
913
+ case "mock":
914
+ case void 0: return "";
915
+ }
916
+ return `<p class="mode-label">${escapeHtml(label)}</p>`;
917
+ }
918
+ /** HTML 특수문자를 이스케이프한다. */
919
+ function escapeHtml(s) {
920
+ return s.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`);
921
+ }
922
+ /**
923
+ * 현재 path+query에서 lang 파라미터만 교체한 ko/en 토글 링크를 생성한다.
924
+ *
925
+ * SECRET-HANDLING: u= (attachUrl, TOTP at= 캡슐 포함) 등 기존 query를 보존한다.
926
+ * lang= 만 덮어쓴다. 링크 href에 at= 코드가 들어가는 건 의도된 전달 경로.
927
+ */
928
+ function buildLangSwitcher(path, existingParams, locale, s) {
929
+ function switcherHref(targetLang) {
930
+ const p = new URLSearchParams(existingParams);
931
+ p.set("lang", targetLang);
932
+ return `${escapeHtml(path)}?${p.toString()}`;
933
+ }
934
+ const koLabel = escapeHtml(s("dashboard.lang.ko"));
935
+ const enLabel = escapeHtml(s("dashboard.lang.en"));
936
+ const koClass = locale === "ko" ? "active" : "";
937
+ const enClass = locale === "en" ? "active" : "";
938
+ return `<div class="lang-switcher"><a href="${switcherHref("ko")}" class="${koClass}">${koLabel}</a><a href="${switcherHref("en")}" class="${enClass}">${enLabel}</a></div>`;
939
+ }
940
+ /**
941
+ * Dashboard HTML — precompiled chrome에 per-request 동적 값을 채워 완성한다.
942
+ *
943
+ * 토큰 채우기 순서:
944
+ * 1. chrome string(locale별 precompile)을 가져온다.
945
+ * 2. 동적 부분을 단순 replaceAll로 채운다 (토큰이 HTML context 밖에 있으므로 안전).
946
+ * 3. inline SSE <script>를 </body> 직전에 주입한다.
947
+ *
948
+ * 동적 파트 분류:
949
+ * - "token-fill": 단일 값 교체 (__NOW__, __TUNNEL_CLASS__, __TUNNEL_STATUS__,
950
+ * __ATTACH_SECTION__, __INSPECTOR_SECTION__)
951
+ * - "runtime builder": 가변 길이 구조 (__PAGES_SECTION__ — 조건부 렌더 + 가변 rows)
952
+ * - "suffix": inline SSE <script> (빌드 파이프라인 없는 클라이언트 스크립트, locale
953
+ * aware 문자열 포함)
954
+ *
955
+ * SECRET-HANDLING:
956
+ * - attachUrl은 url-box 안에서만 노출 (TOTP at= 코드 캡슐 그대로).
957
+ * - inspectorUrl은 anchor href 안에서만 노출 (TOTP at= 코드 캡슐 그대로).
958
+ * relay host + TOTP 코드가 담길 수 있으나 대시보드 HTML은 의도된 transport.
959
+ * - tunnel wssUrl은 "터널 연결됨" 상태 표시에서 UP/DOWN만 노출.
960
+ * wssUrl 값 자체는 dashboard HTML에 넣지 않는다.
961
+ */
962
+ function buildDashboardHtml(state, qrDataUrl, locale, path = "/", params = new URLSearchParams(), devtoolsEntryUrl = null, sseRefreshIntervalMs = 9e4) {
963
+ const s = resolveLocaleStrings(locale);
964
+ const now = (/* @__PURE__ */ new Date()).toISOString();
965
+ const tunnelStatus = state.tunnel.up ? s("dashboard.tunnel.up") : s("dashboard.tunnel.down");
966
+ const tunnelClass = state.tunnel.up ? "status-up" : "status-down";
967
+ let attachSection;
968
+ if (!state.tunnel.up) attachSection = `<p class="hint error">${escapeHtml(s("dashboard.attach.tunnelDown"))}</p>`;
969
+ else if (qrDataUrl && state.attachUrl) {
970
+ const safeAttachUrl = escapeHtml(state.attachUrl);
971
+ const copyLabel = escapeHtml(s("dashboard.url.copy"));
972
+ 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>`;
973
+ } else attachSection = `<p class="hint">${escapeHtml(s("dashboard.attach.hint"))}</p>`;
974
+ const pagesAttached = Array.isArray(state.pages) && state.pages.length > 0;
975
+ let inspectorSection;
976
+ if (pagesAttached && devtoolsEntryUrl) inspectorSection = `<a class="inspector-link" id="inspector-link" href="${escapeHtml(devtoolsEntryUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(s("dashboard.inspector.open"))}</a>`;
977
+ else inspectorSection = `<span class="inspector-hint" id="inspector-link">${escapeHtml(s("dashboard.inspector.waiting"))}</span>`;
978
+ 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) => {
979
+ return `<li><span class="page-id">${escapeHtml(p.id)}</span> <span class="page-url">${escapeHtml(p.url.slice(0, 120))}</span></li>`;
980
+ }).join("\n") : `<li class="empty">${escapeHtml(s("dashboard.pages.empty"))}</li>`}</ul></section>`;
981
+ const watchdogTimeoutMs = sseRefreshIntervalMs < 5e3 ? sseRefreshIntervalMs * 3 : sseRefreshIntervalMs * 2 + 3e4;
982
+ const sseStrings = {
983
+ tunnelUp: JSON.stringify(s("dashboard.tunnel.up")),
984
+ tunnelDown: JSON.stringify(s("dashboard.tunnel.down")),
985
+ pagesEmpty: JSON.stringify(s("dashboard.pages.empty")),
986
+ attachHint: JSON.stringify(s("dashboard.attach.hint")),
987
+ attachTunnelDown: JSON.stringify(s("dashboard.attach.tunnelDown")),
988
+ copyLabel: JSON.stringify(s("dashboard.url.copy")),
989
+ copiedLabel: JSON.stringify(s("dashboard.url.copied")),
990
+ inspectorOpenLabel: JSON.stringify(s("dashboard.inspector.open")),
991
+ inspectorWaitingLabel: JSON.stringify(s("dashboard.inspector.waiting")),
992
+ dashboardSurface: true,
993
+ watchdogTimeoutMs,
994
+ watchdogTitle: JSON.stringify(s("dashboard.watchdog.title")),
995
+ watchdogBody: JSON.stringify(s("dashboard.watchdog.body")),
996
+ watchdogCloseLabel: JSON.stringify(s("dashboard.watchdog.close")),
997
+ connectionLost: JSON.stringify(s("dashboard.conn.lost")),
998
+ completeTitle: JSON.stringify(s("dashboard.session.completeTitle")),
999
+ completeBody: JSON.stringify(s("dashboard.session.completeBody")),
1000
+ shutdownTitle: JSON.stringify(s("dashboard.session.shutdownTitle")),
1001
+ shutdownBody: JSON.stringify(s("dashboard.session.shutdownBody"))
1002
+ };
1003
+ const langSwitcher = buildLangSwitcher(path, params, locale, s);
1004
+ 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("__INSPECTOR_SECTION__", inspectorSection).replaceAll("__PAGES_SECTION__", pagesSection);
1005
+ const sseScript = buildSseScript(sseStrings);
1006
+ return filled.replace("</body>", `${sseScript}\n</body>`);
1007
+ }
1008
+ /**
1009
+ * Inline SSE client <script> — injected into the dashboard HTML at runtime.
1010
+ *
1011
+ * Subscribes to /events and updates the DOM without a build pipeline.
1012
+ * client side: attachUrl은 DOM에 렌더링, wssUrl은 절대 렌더링하지 않는다.
1013
+ * pages === null 이면 섹션을 건드리지 않는다 (#411).
1014
+ *
1015
+ * 두 표면(dashboard / attach) 분기:
1016
+ * - dashboard (dashboardSurface=true): #attach-section innerHTML 전체 교체 방식 유지.
1017
+ * url-box도 innerHTML 재렌더 안에 포함되어 갱신됨.
1018
+ * - /attach (dashboardSurface=false): #attach-section의 img src만 교체하고,
1019
+ * url-box는 #url-box textContent만 갱신한다. (#attach-section에 url-box가 없으므로
1020
+ * innerHTML 교체 시 url-box가 새로 생겨 이중 표시되는 결함을 방지 — #458 결함 수정.)
1021
+ *
1022
+ * 복사 기능: 이벤트 위임으로 document에 단일 핸들러. innerHTML 재렌더 후에도 생존.
1023
+ * - .url-box 클릭 또는 .copy-btn 클릭 → 현재 #url-box textContent 복사.
1024
+ * - clipboard: navigator.clipboard.writeText → 실패/부재 시 textarea execCommand fallback.
1025
+ * - 피드백: 버튼 라벨이 COPIED_LABEL로 ~1.5초 전환 후 COPY_LABEL로 복귀.
1026
+ *
1027
+ * SSE watchdog (#681): SSE 주기 갱신이 `watchdogTimeoutMs` 동안 수신되지 않으면
1028
+ * server가 종료된 것으로 판단한다. `window.close()`를 시도하고, 닫히지 않으면
1029
+ * 폴백 안내 화면으로 교체해 stale QR이 계속 보이는 오해를 항상 방지한다.
1030
+ *
1031
+ * 문자열 인자는 빌드타임에 ko/en 테이블에서 가져와 JSON.stringify로 이미 escape됨.
1032
+ *
1033
+ * SECRET-HANDLING: URL 값을 console.log 등으로 출력하지 않는다.
1034
+ */
1035
+ function buildSseScript(strings) {
1036
+ const isDashboard = strings.dashboardSurface;
1037
+ return `<script>
1038
+ // SSE — /events 구독해 상태 자동 갱신. 빌드 파이프라인 없는 인라인 스크립트.
1039
+ (function () {
1040
+ var TUNNEL_UP = ${strings.tunnelUp};
1041
+ var TUNNEL_DOWN = ${strings.tunnelDown};
1042
+ var PAGES_EMPTY = ${strings.pagesEmpty};
1043
+ var ATTACH_HINT = ${strings.attachHint};
1044
+ var ATTACH_TUNNEL_DOWN = ${strings.attachTunnelDown};
1045
+ var COPY_LABEL = ${strings.copyLabel};
1046
+ var COPIED_LABEL = ${strings.copiedLabel};
1047
+ var INSPECTOR_OPEN_LABEL = ${strings.inspectorOpenLabel};
1048
+ var INSPECTOR_WAITING_LABEL = ${strings.inspectorWaitingLabel};
1049
+
1050
+ // ── SSE watchdog 상수 (#681) ──────────────────────────────────────────
1051
+ // server의 SSE refresh 주기(기본 90s)보다 넉넉히 큰 값 — 주기 × 2 + 30s 여유.
1052
+ // token-fill 패턴으로 server에서 계산해 주입한다.
1053
+ var WATCHDOG_TIMEOUT_MS = ${strings.watchdogTimeoutMs};
1054
+ var WATCHDOG_TITLE = ${strings.watchdogTitle};
1055
+ var WATCHDOG_BODY = ${strings.watchdogBody};
1056
+ var WATCHDOG_CLOSE_LABEL = ${strings.watchdogCloseLabel};
1057
+
1058
+ // ── 실시간 상태 표면 상수 (#730) ───────────────────────────────────────
1059
+ var CONN_LOST = ${strings.connectionLost};
1060
+ var COMPLETE_TITLE = ${strings.completeTitle};
1061
+ var COMPLETE_BODY = ${strings.completeBody};
1062
+ var SHUTDOWN_TITLE = ${strings.shutdownTitle};
1063
+ var SHUTDOWN_BODY = ${strings.shutdownBody};
1064
+ var disconnectedShown = false;
1065
+
1066
+ // ── 클립보드 복사 헬퍼 ────────────────────────────────────────────────
1067
+ function copyText(text) {
1068
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1069
+ return navigator.clipboard.writeText(text);
1070
+ }
1071
+ // fallback: textarea + execCommand
1072
+ return new Promise(function (resolve, reject) {
1073
+ var ta = document.createElement('textarea');
1074
+ ta.value = text;
1075
+ ta.style.position = 'fixed';
1076
+ ta.style.opacity = '0';
1077
+ document.body.appendChild(ta);
1078
+ ta.focus();
1079
+ ta.select();
1080
+ try {
1081
+ document.execCommand('copy') ? resolve() : reject(new Error('execCommand failed'));
1082
+ } catch (err) {
1083
+ reject(err);
1084
+ } finally {
1085
+ document.body.removeChild(ta);
1086
+ }
1087
+ });
1088
+ }
1089
+
1090
+ // ── 복사 피드백 ───────────────────────────────────────────────────────
1091
+ var copyTimer = null;
1092
+ function triggerCopy() {
1093
+ var urlBox = document.getElementById('url-box');
1094
+ if (!urlBox) return;
1095
+ var text = urlBox.textContent || '';
1096
+ if (!text) return;
1097
+ copyText(text).then(function () {
1098
+ var btn = document.getElementById('copy-btn');
1099
+ if (btn) {
1100
+ btn.textContent = COPIED_LABEL;
1101
+ if (copyTimer) clearTimeout(copyTimer);
1102
+ copyTimer = setTimeout(function () {
1103
+ btn.textContent = COPY_LABEL;
1104
+ copyTimer = null;
1105
+ }, 1500);
1106
+ }
1107
+ }).catch(function () { /* 복사 실패 시 조용히 무시 */ });
1108
+ }
1109
+
1110
+ // ── 이벤트 위임 — document 레벨에서 단일 핸들러 (innerHTML 재렌더 후에도 생존) ──
1111
+ document.addEventListener('click', function (e) {
1112
+ var target = e.target;
1113
+ if (!target) return;
1114
+ // .copy-btn 또는 .url-box 클릭 시 복사
1115
+ if (target.closest && (target.closest('.copy-btn') || target.closest('.url-box'))) {
1116
+ triggerCopy();
1117
+ }
1118
+ });
1119
+
1120
+ // ── SSE watchdog (#681) ───────────────────────────────────────────────
1121
+ // 마지막으로 SSE 메시지를 수신한 시각. 스크립트 로드 시각으로 초기화한다.
1122
+ // (서버가 /events 연결 즉시 초기 상태를 push하므로 onmessage가 곧 갱신한다.)
1123
+ var lastSeen = Date.now();
1124
+ // watchdog이 한 번 발화하면 타이머를 clear해 중복 실행 방지.
1125
+ var watchdogFired = false;
1126
+ var watchdogTimer = null;
1127
+
1128
+ // 폴백 UI — window.close()가 무시되면 stale QR 대신 안내 화면을 표시한다.
1129
+ function showWatchdogFallback() {
1130
+ if (document.body) {
1131
+ document.body.innerHTML =
1132
+ '<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;' +
1133
+ 'height:100vh;font-family:sans-serif;text-align:center;padding:2rem;box-sizing:border-box;">' +
1134
+ '<p style="font-size:1.4rem;font-weight:bold;margin-bottom:1rem;">' + WATCHDOG_TITLE + '</p>' +
1135
+ '<p style="color:#555;margin-bottom:2rem;">' + WATCHDOG_BODY + '</p>' +
1136
+ '<button onclick="window.close()" ' +
1137
+ 'style="padding:0.6rem 1.4rem;font-size:1rem;cursor:pointer;border:1px solid #ccc;' +
1138
+ 'border-radius:6px;background:#f5f5f5;">' + WATCHDOG_CLOSE_LABEL + '</button>' +
1139
+ '</div>';
1140
+ }
1141
+ }
1142
+
1143
+ // ── Terminal 상태 배너 (#730) ─────────────────────────────────────────
1144
+ // "완료"/"종료"는 watchdog(최대 210s 지연)을 기다리지 않고 close() 직전에
1145
+ // 서버가 직접 push하는 마지막 SSE 프레임으로 즉시 렌더한다. watchdogFired를
1146
+ // 함께 세워 뒤이은 onerror의 showConnLost가 이 배너를 덮어쓰지 않게 한다.
1147
+ function showTerminalBanner(title, body) {
1148
+ if (watchdogTimer) { clearInterval(watchdogTimer); watchdogTimer = null; }
1149
+ watchdogFired = true; // 이후 watchdog/onerror 발화를 억제
1150
+ if (document.body) {
1151
+ document.body.innerHTML =
1152
+ '<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;' +
1153
+ 'height:100vh;font-family:sans-serif;text-align:center;padding:2rem;box-sizing:border-box;">' +
1154
+ '<p style="font-size:1.4rem;font-weight:bold;margin-bottom:1rem;">' + title + '</p>' +
1155
+ '<p style="color:#555;">' + body + '</p></div>';
1156
+ }
1157
+ }
1158
+
1159
+ // ── SSE onerror 즉시 신호 (#730) ──────────────────────────────────────
1160
+ // EventSource는 "재시도 중"과 "서버 종료"를 구분하지 못하므로, onerror에서
1161
+ // 즉시 구분되는 '연결 끊김' 상태를 tunnel-status 배지에 표시한다. 이미
1162
+ // terminal 배너가 뜬 뒤(watchdogFired)라면 덮어쓰지 않는다.
1163
+ function showConnLost() {
1164
+ if (disconnectedShown || watchdogFired) return;
1165
+ disconnectedShown = true;
1166
+ var el = document.getElementById('tunnel-status');
1167
+ if (el) { el.textContent = CONN_LOST; el.className = 'status status-down'; }
1168
+ }
1169
+ function clearConnLost() {
1170
+ disconnectedShown = false;
1171
+ }
1172
+
1173
+ function fireWatchdog() {
1174
+ if (watchdogFired) return;
1175
+ watchdogFired = true;
1176
+ if (watchdogTimer) { clearInterval(watchdogTimer); watchdogTimer = null; }
1177
+ // 폴백 UI를 먼저 표시한다 — close가 성공하면 어차피 페이지가 닫혀 보이지 않는다.
1178
+ // close 실패(opener 없는 탭)에 대비해 stale QR 대신 안내 화면을 항상 보장한다.
1179
+ showWatchdogFallback();
1180
+ // window.close() 시도 — OS가 연 탭은 opener가 없어 무시될 수 있다.
1181
+ window.close();
1182
+ }
1183
+
1184
+ // 주기적으로 lastSeen을 검사 — WATCHDOG_TIMEOUT_MS 초과 시 발화.
1185
+ // 검사 간격: WATCHDOG_TIMEOUT_MS / 6, 단 최소 1s 이상이되 timeout 자체보다 크지 않게 한다.
1186
+ // (sseRefreshIntervalMs가 작을 때 최솟값 1s가 timeout을 초과하는 경우 방지 — 테스트 격리.)
1187
+ var checkIntervalMs = Math.min(
1188
+ Math.max(1000, Math.round(WATCHDOG_TIMEOUT_MS / 6)),
1189
+ WATCHDOG_TIMEOUT_MS,
1190
+ );
1191
+ watchdogTimer = setInterval(function () {
1192
+ if (watchdogFired) return;
1193
+ if (Date.now() - lastSeen > WATCHDOG_TIMEOUT_MS) {
1194
+ fireWatchdog();
1195
+ }
1196
+ }, checkIntervalMs);
1197
+
1198
+ // ── SSE 구독 ──────────────────────────────────────────────────────────
1199
+ var src = new EventSource('/events');
1200
+ src.onopen = function () {
1201
+ // 연결 복구 시 lastSeen 갱신 — 일시적 끊김이 watchdog을 발화시키지 않게.
1202
+ // watchdog이 이미 발화했으면 갱신하지 않는다(폴백 UI를 그대로 유지).
1203
+ if (!watchdogFired) { lastSeen = Date.now(); clearConnLost(); }
1204
+ };
1205
+ src.onmessage = function (e) {
1206
+ // SSE 메시지 수신 — lastSeen 갱신으로 watchdog 리셋.
1207
+ // watchdog 발화 후라면 갱신해도 타이머가 이미 clear됐으므로 효과 없음.
1208
+ if (!watchdogFired) { lastSeen = Date.now(); }
1209
+ try {
1210
+ var s = JSON.parse(e.data);
1211
+ // 어떤 프레임이든 성공적으로 수신됐다는 건 서버가 살아있다는 뜻 —
1212
+ // onerror가 세운 '연결 끊김' 표시를 해제한다 (#730).
1213
+ clearConnLost();
1214
+ // phase gate (#730) — 'complete'/'shutdown'은 terminal 상태이므로
1215
+ // 나머지 필드 렌더보다 먼저 처리하고 즉시 반환한다.
1216
+ if (s.phase === 'complete') { showTerminalBanner(COMPLETE_TITLE, COMPLETE_BODY); return; }
1217
+ if (s.phase === 'shutdown') { showTerminalBanner(SHUTDOWN_TITLE, SHUTDOWN_BODY); return; }
1218
+ // 터널 상태 갱신
1219
+ var el = document.getElementById('tunnel-status');
1220
+ if (el) {
1221
+ el.textContent = s.tunnel && s.tunnel.up ? TUNNEL_UP : TUNNEL_DOWN;
1222
+ el.className = 'status ' + (s.tunnel && s.tunnel.up ? 'status-up' : 'status-down');
1223
+ }
1224
+ // page 목록 갱신 — pages === null(env 2)이면 섹션 자체를 숨긴 채 둔다.
1225
+ // 정적 렌더가 #pages-section을 아예 안 그렸으므로 여기서도 손대지 않아
1226
+ // SSE push 때 섹션이 되살아나지 않는다(#411). 배열일 때만 목록을 채운다.
1227
+ if (s.pages !== null && s.pages !== undefined) {
1228
+ var ul = document.getElementById('pages-list');
1229
+ if (ul) {
1230
+ if (s.pages.length === 0) {
1231
+ ul.innerHTML = '<li class="empty">' + PAGES_EMPTY + '</li>';
1232
+ } else {
1233
+ ul.innerHTML = s.pages.map(function (p) {
1234
+ var sid = String(p.id || '').slice(0, 36).replace(/[<>&"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });
1235
+ var su = String(p.url || '').slice(0, 120).replace(/[<>&"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });
1236
+ return '<li><span class="page-id">' + sid + '</span> <span class="page-url">' + su + '</span></li>';
1237
+ }).join('');
1238
+ }
1239
+ }
1240
+ }
1241
+ // attachUrl QR + url-box 갱신
1242
+ // SECRET-HANDLING: URL 값을 로그로 출력하지 않는다.
1243
+ // 터널이 죽으면(s.tunnel.up=false) attachUrl이 남아 있어도 QR을
1244
+ // 그리지 않고 에러 상태로 교체한다 — 죽은 QR이 스캔되는 것을 막는다(#631).
1245
+ var sec = document.getElementById('attach-section');
1246
+ if (sec) {
1247
+ if (!(s.tunnel && s.tunnel.up)) {
1248
+ sec.innerHTML = '<p class=\\"hint error\\">' + ATTACH_TUNNEL_DOWN + '</p>';
1249
+ } else if (s.attachUrl) {
1250
+ var encoded = encodeURIComponent(s.attachUrl);
1251
+ var safeUrl = String(s.attachUrl).slice(0, 2000).replace(/[<>&"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });
1252
+ ${isDashboard ? `// dashboard: #attach-section innerHTML 전체 교체 (img + url-row).
1253
+ // url-box id="url-box" 를 포함해 복사 핸들러가 계속 동작함.
1254
+ sec.innerHTML =
1255
+ '<img class="qr" src="/qr.png?u=' + encoded + '" alt="attach QR" />' +
1256
+ '<div class=\\"url-row\\">' +
1257
+ '<p class=\\"url-box\\" id=\\"url-box\\">' + safeUrl + '</p>' +
1258
+ '<button class=\\"copy-btn\\" id=\\"copy-btn\\" type=\\"button\\" aria-label=\\"' + COPY_LABEL + '\\">' + COPY_LABEL + '</button>' +
1259
+ '</div>';` : `// /attach: img src만 교체 — url-box는 별도 #url-section에서 관리해 이중 표시 방지(#458).
1260
+ // QR img src 교체: img가 있으면 src만 갱신, 없으면 img 요소 생성.
1261
+ var img = sec.querySelector('img.qr');
1262
+ if (img) {
1263
+ img.src = '/qr.png?u=' + encoded;
1264
+ } else {
1265
+ sec.innerHTML = '<img class=\\"qr\\" src=\\"/qr.png?u=' + encoded + '\\" alt=\\"attach QR\\" />';
1266
+ }
1267
+ // url-box textContent만 갱신 (innerHTML 교체하지 않아 복사 버튼/핸들러 생존).
1268
+ var ub = document.getElementById('url-box');
1269
+ if (ub) ub.textContent = s.attachUrl;`}
1270
+ } else {
1271
+ ${isDashboard ? `sec.innerHTML = '<p class=\\"hint\\">' + ATTACH_HINT + '</p>';` : `// /attach에서 hint가 필요한 경우는 없으나 방어 처리.
1272
+ sec.innerHTML = '<p class=\\"hint\\">' + ATTACH_HINT + '</p>';`}
1273
+ }
1274
+ }
1275
+ // 인스펙터 링크 갱신 — #inspector-link (#503, gate 보정 #544).
1276
+ // 게이트: pages.length > 0 (페이지 attach 여부) — inspectorUrl 존재 여부가 아님.
1277
+ // #530 이후 inspectorUrl은 항상 안정 URL이므로 null 게이트는 사실상 항상 활성이었다.
1278
+ // pages.length > 0 으로 바꿔 미attach 시 대기 힌트를 보여주도록 수정.
1279
+ // SECRET-HANDLING: inspectorUrl을 console.log 등으로 출력하지 않는다.
1280
+ var insp = document.getElementById('inspector-link');
1281
+ if (insp) {
1282
+ var pagesAttachedSse = Array.isArray(s.pages) && s.pages.length > 0;
1283
+ if (pagesAttachedSse && s.inspectorUrl) {
1284
+ var safeInspUrl = String(s.inspectorUrl).slice(0, 2000).replace(/[<>&"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });
1285
+ insp.outerHTML = '<a class=\\"inspector-link\\" id=\\"inspector-link\\" href=\\"' + safeInspUrl + '\\" target=\\"_blank\\" rel=\\"noopener noreferrer\\">' + INSPECTOR_OPEN_LABEL + '</a>';
1286
+ } else {
1287
+ insp.outerHTML = '<span class=\\"inspector-hint\\" id=\\"inspector-link\\">' + INSPECTOR_WAITING_LABEL + '</span>';
1288
+ }
1289
+ }
1290
+ // 갱신 시각 (dashboard만 #updated 요소 있음)
1291
+ var upd = document.getElementById('updated');
1292
+ if (upd) upd.textContent = upd.textContent.replace(/[^ ]+$/, new Date().toISOString());
1293
+ } catch (_) { /* 파싱 오류 무시 */ }
1294
+ };
1295
+ src.onerror = function () {
1296
+ // EventSource는 "재시도 중"과 "서버 종료"를 구분하지 못하므로 즉시
1297
+ // 구분되는 "연결 끊김"을 표시한다(#730). 네이티브 auto-reconnect는 그대로
1298
+ // 진행되며 onopen/onmessage가 성공 즉시 이 상태를 해제한다. watchdog은
1299
+ // 여전히 2차 fallback으로 남는다(최종 폴백 UI 전환).
1300
+ showConnLost();
1301
+ };
1302
+ })();
1303
+ <\/script>`;
1304
+ }
1305
+ /**
1306
+ * Attach 페이지 HTML — precompiled chrome에 per-request 동적 값을 채워 완성한다.
1307
+ *
1308
+ * 동적 파트:
1309
+ * - __QR_DATA_URL__ : base64 data URL (QR 이미지)
1310
+ * - __SAFE_LABEL__ : HTML-escaped deploymentId label (intoss family에만 존재)
1311
+ * - __SAFE_ATTACH_URL__ : HTML-escaped attach URL (TOTP at= 코드 포함 — 의도된 전달)
1312
+ * - __MODE_LABEL__ : 환경 배지 (`<p class="mode-label">…</p>` 또는 빈 문자열, #468)
1313
+ * - __INSPECTOR_SECTION__ : "디버그 툴 열기" 버튼 또는 대기 힌트 (#544)
1314
+ *
1315
+ * mode-aware 분기 (#468): mode가 `relay-mobile`이면 sandbox family chrome(launcher
1316
+ * PWA 절차), 그 외는 intoss family chrome(토스 앱 절차)을 선택한다.
1317
+ * relay-live (env 4) 제거 (#665) — positive-allowlist kill-switch.
1318
+ *
1319
+ * SSE 스크립트도 주입 — `#attach-section` hook이 있으면 `/events` push 때 QR이
1320
+ * `/qr.png?u=<fresh attachUrl>`로 자동 갱신된다. `#inspector-link`도 SSE push로
1321
+ * pages.length > 0 게이트에 따라 활성/비활성 전환된다 (#544).
1322
+ *
1323
+ * SECRET-HANDLING: TOTP at= 코드는 attachUrl 캡슐 안에서만 노출 — 의도된 transport.
1324
+ * inspectorStableUrl은 /inspector 안정 URL (127.0.0.1, 시크릿 없음) — 노출 가능.
1325
+ */
1326
+ function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl, locale, path = "/attach", params = new URLSearchParams(), mode, pagesAttached = false, inspectorStableUrl = null, sseRefreshIntervalMs = 9e4) {
1327
+ const s = resolveLocaleStrings(locale);
1328
+ const langSwitcher = buildLangSwitcher(path, params, locale, s);
1329
+ const family = attachFamilyForMode(mode);
1330
+ let inspectorSection;
1331
+ if (pagesAttached && inspectorStableUrl) inspectorSection = `<a class="inspector-link" id="inspector-link" href="${escapeHtml(inspectorStableUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(s("dashboard.inspector.open"))}</a>`;
1332
+ else inspectorSection = `<span class="inspector-hint" id="inspector-link">${escapeHtml(s("dashboard.inspector.waiting"))}</span>`;
1333
+ const filled = attachChromeByLocale[locale][family].replaceAll("__LANG_SWITCHER__", langSwitcher).replaceAll("__MODE_LABEL__", buildModeLabel(mode, s)).replaceAll("__QR_DATA_URL__", qrDataUrl).replaceAll("__SAFE_LABEL__", safeLabel).replaceAll("__SAFE_ATTACH_URL__", safeAttachUrl).replaceAll("__INSPECTOR_SECTION__", inspectorSection);
1334
+ const watchdogTimeoutMs = sseRefreshIntervalMs < 5e3 ? sseRefreshIntervalMs * 3 : sseRefreshIntervalMs * 2 + 3e4;
1335
+ const sseScript = buildSseScript({
1336
+ tunnelUp: JSON.stringify(s("dashboard.tunnel.up")),
1337
+ tunnelDown: JSON.stringify(s("dashboard.tunnel.down")),
1338
+ pagesEmpty: JSON.stringify(s("dashboard.pages.empty")),
1339
+ attachHint: JSON.stringify(s("dashboard.attach.hint")),
1340
+ attachTunnelDown: JSON.stringify(s("dashboard.attach.tunnelDown")),
1341
+ copyLabel: JSON.stringify(s("dashboard.url.copy")),
1342
+ copiedLabel: JSON.stringify(s("dashboard.url.copied")),
1343
+ inspectorOpenLabel: JSON.stringify(s("dashboard.inspector.open")),
1344
+ inspectorWaitingLabel: JSON.stringify(s("dashboard.inspector.waiting")),
1345
+ dashboardSurface: false,
1346
+ watchdogTimeoutMs,
1347
+ watchdogTitle: JSON.stringify(s("dashboard.watchdog.title")),
1348
+ watchdogBody: JSON.stringify(s("dashboard.watchdog.body")),
1349
+ watchdogCloseLabel: JSON.stringify(s("dashboard.watchdog.close")),
1350
+ connectionLost: JSON.stringify(s("dashboard.conn.lost")),
1351
+ completeTitle: JSON.stringify(s("dashboard.session.completeTitle")),
1352
+ completeBody: JSON.stringify(s("dashboard.session.completeBody")),
1353
+ shutdownTitle: JSON.stringify(s("dashboard.session.shutdownTitle")),
1354
+ shutdownBody: JSON.stringify(s("dashboard.session.shutdownBody"))
1355
+ });
1356
+ return filled.replace("</body>", `${sseScript}\n</body>`);
1357
+ }
1358
+ /**
1359
+ * base 포트부터 EADDRINUSE 시 +1씩 증가하며 bind를 시도한다. EADDRINUSE가
1360
+ * 아닌 에러는 즉시 reject한다(스캔 대상 아님). `range`번 모두 점유 상태면
1361
+ * ephemeral(포트 0)로 폴백하고 한국어 안내를 stderr에 1회 출력한다.
1362
+ *
1363
+ * 매 시도마다 이전에 등록한 `error` 리스너를 제거한다 — 성공 시 listener가
1364
+ * 누적돼 다음 실패에서 중복 reject/EventEmitter 경고가 나는 것을 방지한다.
1365
+ *
1366
+ * `basePort === 0`이면 스캔 없이 즉시 ephemeral bind (명시적 opt-out, 기존
1367
+ * 순수 랜덤 포트 동작 유지).
1368
+ */
1369
+ async function bindWithIncrement(server, basePort, range = 20) {
1370
+ if (basePort === 0) return new Promise((resolve, reject) => {
1371
+ const onError = (err) => reject(err);
1372
+ server.once("error", onError);
1373
+ server.listen(0, "127.0.0.1", () => {
1374
+ server.removeListener("error", onError);
1375
+ resolve();
1376
+ });
1377
+ });
1378
+ for (let attempt = 0; attempt < range; attempt++) {
1379
+ const candidatePort = basePort + attempt;
1380
+ try {
1381
+ await new Promise((resolve, reject) => {
1382
+ const onError = (err) => {
1383
+ server.removeListener("error", onError);
1384
+ reject(err);
1385
+ };
1386
+ server.once("error", onError);
1387
+ server.listen(candidatePort, "127.0.0.1", () => {
1388
+ server.removeListener("error", onError);
1389
+ resolve();
1390
+ });
1391
+ });
1392
+ return;
1393
+ } catch (err) {
1394
+ if (err.code !== "EADDRINUSE") throw err;
1395
+ }
1396
+ }
1397
+ process.stderr.write(`devtools: 대시보드 포트 ${basePort}~${basePort + range - 1}이 모두 사용 중이라 임의 포트로 대체합니다.\n`);
1398
+ return new Promise((resolve, reject) => {
1399
+ const onError = (err) => reject(err);
1400
+ server.once("error", onError);
1401
+ server.listen(0, "127.0.0.1", () => {
1402
+ server.removeListener("error", onError);
1403
+ resolve();
1404
+ });
1405
+ });
1406
+ }
1407
+ /**
1408
+ * 로컬 HTTP 서버를 127.0.0.1에 고정 base 포트({@link DEFAULT_DASHBOARD_PORT},
1409
+ * `AIT_DEBUG_HTTP_PORT` env, 또는 `options.dashboardPort`)에서 시작한다.
1410
+ * base 포트가 점유(EADDRINUSE) 상태면 +1씩 증가하며 재시도하고(최대
1411
+ * {@link PORT_SCAN_RANGE}회), 전부 점유면 ephemeral 포트로 폴백한다 —
1412
+ * rerun 시 대시보드 URL이 안정적으로 유지되어 브라우저 탭/북마크가
1413
+ * run마다 무효화되지 않는다 (devtools#752). base 포트를 명시적으로 `0`으로
1414
+ * 주면(env 또는 옵션) 기존 순수 ephemeral 동작을 유지한다.
1415
+ *
1416
+ * MCP debug server 생애주기에 묶어 사용 — `runDebugServer` shutdown 시 `close()`로 정리.
1417
+ *
1418
+ * @param getDashboardState - dashboard 상태를 반환하는 클로저. 주입 시 `GET /` dashboard와
1419
+ * `GET /events` SSE 스트림이 활성화된다. 미주입 시 두 라우트는 204/서비스 없음으로 응답.
1420
+ * @param options - 서버 옵션. `sseRefreshIntervalMs`로 idle 탭 TOTP 만료 방지 주기를 조정.
1421
+ * `getDirectInspectorUrl`로 /inspector 라우트에서 직접 조립 URL을 제공해 redirect 루프를 방지.
1422
+ * `dashboardPort`로 bind base 포트를 override.
1423
+ */
1424
+ async function startQrHttpServer(getDashboardState, options) {
1425
+ const { default: QRCode } = await import("qrcode");
1426
+ /** SSE 활성 연결 목록 — `notifyStateChange()` 시 전체 push. */
1427
+ const sseClients = [];
1428
+ const refreshIntervalMs = options?.sseRefreshIntervalMs ?? 9e4;
1429
+ /** SSE 연결 하나에 상태 이벤트를 flush한다. */
1430
+ function pushStateToClient(res, state) {
1431
+ const payload = JSON.stringify({
1432
+ tunnel: {
1433
+ up: state.tunnel.up,
1434
+ wssUrl: state.tunnel.wssUrl
1435
+ },
1436
+ pages: state.pages,
1437
+ attachUrl: state.attachUrl,
1438
+ inspectorUrl: state.inspectorUrl ?? null,
1439
+ phase: state.phase ?? "active",
1440
+ manualPrompt: state.manualPrompt ?? null
1441
+ });
1442
+ res.write(`data: ${payload}\n\n`);
1443
+ }
1444
+ const server = createServer(async (req, res) => {
1445
+ const [path, query = ""] = (req.url ?? "/").split("?", 2);
1446
+ const params = new URLSearchParams(query ?? "");
1447
+ const langParam = params.get("lang");
1448
+ const locale = langParam === "ko" || langParam === "en" ? langParam : parseAcceptLanguage(req.headers["accept-language"]);
1449
+ if (path === "/") {
1450
+ if (!getDashboardState) {
1451
+ res.writeHead(204, { "Content-Type": "text/plain; charset=utf-8" });
1452
+ res.end();
1453
+ return;
1454
+ }
1455
+ const state = getDashboardState();
1456
+ let qrDataUrl = null;
1457
+ if (state.attachUrl) try {
1458
+ qrDataUrl = await QRCode.toDataURL(state.attachUrl, {
1459
+ type: "image/png",
1460
+ errorCorrectionLevel: "M"
1461
+ });
1462
+ } catch {}
1463
+ const devtoolsEntryUrl = (() => {
1464
+ if (!options?.getDirectInspectorUrl) return null;
1465
+ const addr = server.address();
1466
+ if (!addr || typeof addr === "string") return null;
1467
+ return `http://127.0.0.1:${addr.port}/devtools/`;
1468
+ })();
1469
+ const html = buildDashboardHtml(state, qrDataUrl, locale, path, params, devtoolsEntryUrl, refreshIntervalMs);
1470
+ res.writeHead(200, {
1471
+ "Content-Type": "text/html; charset=utf-8",
1472
+ "Cache-Control": "no-store"
1473
+ });
1474
+ res.end(html);
1475
+ return;
1476
+ }
1477
+ if (path === "/events") {
1478
+ if (!getDashboardState) {
1479
+ res.writeHead(204, { "Content-Type": "text/plain; charset=utf-8" });
1480
+ res.end();
1481
+ return;
1482
+ }
1483
+ res.writeHead(200, {
1484
+ "Content-Type": "text/event-stream",
1485
+ "Cache-Control": "no-cache",
1486
+ Connection: "keep-alive",
1487
+ "X-Accel-Buffering": "no"
1488
+ });
1489
+ pushStateToClient(res, getDashboardState());
1490
+ sseClients.push(res);
1491
+ req.once("close", () => {
1492
+ const idx = sseClients.indexOf(res);
1493
+ if (idx !== -1) sseClients.splice(idx, 1);
1494
+ });
1495
+ return;
1496
+ }
1497
+ if (path === "/attach") {
1498
+ const encodedU = params.get("u") ?? "";
1499
+ let attachUrl;
1500
+ try {
1501
+ attachUrl = decodeURIComponent(encodedU);
1502
+ } catch {
1503
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
1504
+ res.end("잘못된 u 파라미터입니다.");
1505
+ return;
1506
+ }
1507
+ let deploymentIdLabel = "attach";
1508
+ try {
1509
+ const dpMatch = attachUrl.match(/[?&]_deploymentId=([^&]+)/);
1510
+ if (dpMatch?.[1]) deploymentIdLabel = decodeURIComponent(dpMatch[1]).slice(0, 36);
1511
+ } catch {}
1512
+ const currentState = getDashboardState?.();
1513
+ const mode = currentState?.mode;
1514
+ const pagesAttached = Array.isArray(currentState?.pages) && (currentState?.pages.length ?? 0) > 0;
1515
+ const inspectorStableUrlForAttach = (() => {
1516
+ if (!options?.getDirectInspectorUrl) return null;
1517
+ const addr = server.address();
1518
+ if (!addr || typeof addr === "string") return null;
1519
+ return `http://127.0.0.1:${addr.port}/inspector`;
1520
+ })();
1521
+ QRCode.toDataURL(attachUrl, {
1522
+ type: "image/png",
1523
+ errorCorrectionLevel: "M"
1524
+ }).then((dataUrl) => {
1525
+ const html = buildAttachHtml(dataUrl, escapeHtml(deploymentIdLabel), escapeHtml(attachUrl), locale, path, params, mode, pagesAttached, inspectorStableUrlForAttach, refreshIntervalMs);
1526
+ res.writeHead(200, {
1527
+ "Content-Type": "text/html; charset=utf-8",
1528
+ "Cache-Control": "no-store"
1529
+ });
1530
+ res.end(html);
1531
+ }).catch(() => {
1532
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
1533
+ res.end("QR 생성에 실패했습니다.");
1534
+ });
1535
+ return;
1536
+ }
1537
+ if (path === "/inspector" || path === "/devtools" || path === "/devtools/") {
1538
+ const getDirectInspectorUrl = options?.getDirectInspectorUrl;
1539
+ if (!getDirectInspectorUrl) {
1540
+ const body = path === "/inspector" ? "Inspector endpoint is not available in this server mode." : "relay 연결 세션에서만 DevTools UI를 열 수 있습니다.";
1541
+ res.writeHead(503, { "Content-Type": "text/plain; charset=utf-8" });
1542
+ res.end(body);
1543
+ return;
1544
+ }
1545
+ const result = getDirectInspectorUrl();
1546
+ const s = resolveLocaleStrings(locale);
1547
+ if (!result.ok) {
1548
+ const body = `<!DOCTYPE html><html lang="${locale}"><head><meta charset="utf-8"><title>Inspector</title></head><body><p>${escapeHtml(s(result.reason === "noTarget" ? "inspector.error.noTarget" : "inspector.error.relayDown"))}</p><p style="font-size:0.9em;color:#666">` + (locale === "ko" ? "(<a href=\"/\">대시보드로 돌아가기</a>)" : "(<a href=\"/\">Back to dashboard</a>)") + `</p></body></html>`;
1549
+ res.writeHead(502, {
1550
+ "Content-Type": "text/html; charset=utf-8",
1551
+ "Cache-Control": "no-store"
1552
+ });
1553
+ res.end(body);
1554
+ return;
1555
+ }
1556
+ res.writeHead(302, {
1557
+ Location: result.url,
1558
+ "Cache-Control": "no-store"
1559
+ });
1560
+ res.end();
1561
+ return;
1562
+ }
1563
+ if (path === "/qr.png") {
1564
+ const encodedU = params.get("u") ?? "";
1565
+ if (encodedU === "") {
1566
+ res.writeHead(204, { "Cache-Control": "no-store" });
1567
+ res.end();
1568
+ return;
1569
+ }
1570
+ let attachUrl;
1571
+ try {
1572
+ attachUrl = decodeURIComponent(encodedU);
1573
+ } catch {
1574
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
1575
+ res.end("잘못된 u 파라미터입니다.");
1576
+ return;
1577
+ }
1578
+ if (attachUrl === "") {
1579
+ res.writeHead(204, { "Cache-Control": "no-store" });
1580
+ res.end();
1581
+ return;
1582
+ }
1583
+ QRCode.toBuffer(attachUrl, {
1584
+ type: "png",
1585
+ errorCorrectionLevel: "M"
1586
+ }).then((buf) => {
1587
+ res.writeHead(200, {
1588
+ "Content-Type": "image/png",
1589
+ "Cache-Control": "no-store",
1590
+ "Content-Length": String(buf.length)
1591
+ });
1592
+ res.end(buf);
1593
+ }).catch(() => {
1594
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
1595
+ res.end("QR PNG 생성에 실패했습니다.");
1596
+ });
1597
+ return;
1598
+ }
1599
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
1600
+ res.end("Not Found");
1601
+ });
1602
+ await bindWithIncrement(server, options?.dashboardPort ?? (process.env.AIT_DEBUG_HTTP_PORT !== void 0 ? Number(process.env.AIT_DEBUG_HTTP_PORT) : 8317), options?.portScanRange);
1603
+ const address = server.address();
1604
+ if (!address || typeof address === "string") throw new Error("qr-http-server: server.address()가 예상하지 못한 형태입니다.");
1605
+ const port = address.port;
1606
+ /** idle 탭 TOTP 만료 방지용 주기 SSE 갱신 interval. */
1607
+ function notifyStateChangeInternal() {
1608
+ if (!getDashboardState) return;
1609
+ const state = getDashboardState();
1610
+ for (const client of sseClients) try {
1611
+ pushStateToClient(client, state);
1612
+ } catch {}
1613
+ }
1614
+ const refreshHandle = setInterval(() => {
1615
+ if (sseClients.length > 0 && getDashboardState) notifyStateChangeInternal();
1616
+ }, refreshIntervalMs).unref();
1617
+ return {
1618
+ port,
1619
+ buildAttachPageUrl(_attachUrl) {
1620
+ return `http://127.0.0.1:${port}/`;
1621
+ },
1622
+ get inspectorStableUrl() {
1623
+ return `http://127.0.0.1:${port}/inspector`;
1624
+ },
1625
+ notifyStateChange() {
1626
+ notifyStateChangeInternal();
1627
+ },
1628
+ close() {
1629
+ clearInterval(refreshHandle);
1630
+ server.closeAllConnections();
1631
+ return new Promise((resolve, reject) => {
1632
+ server.close((err) => err ? reject(err) : resolve());
1633
+ });
1634
+ }
1635
+ };
1636
+ }
1637
+ //#endregion
1638
+ export { startQrHttpServer };
1639
+
1640
+ //# sourceMappingURL=qr-http-server-D4rGz8M7.js.map