yunti-browser-runtime 0.1.0

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 (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +256 -0
  3. package/bin/yunti-browser-runtime.js +86 -0
  4. package/docs/EXECUTION_PLAN.md +1278 -0
  5. package/docs/INSTALL.md +205 -0
  6. package/docs/PROJECT_INTENT.md +44 -0
  7. package/docs/PROJECT_STATUS.md +263 -0
  8. package/docs/PUBLISHING_BLOCKERS.md +110 -0
  9. package/docs/RELEASE.md +148 -0
  10. package/docs/ROADMAP.md +42 -0
  11. package/docs/SECURITY.md +56 -0
  12. package/docs/TOOL_GUIDE.md +69 -0
  13. package/extension/background.js +55 -0
  14. package/extension/cdp.js +582 -0
  15. package/extension/content.css +9 -0
  16. package/extension/content.js +946 -0
  17. package/extension/manifest.json +35 -0
  18. package/extension/network-monitor.js +140 -0
  19. package/extension/popup.css +66 -0
  20. package/extension/popup.html +30 -0
  21. package/extension/popup.js +55 -0
  22. package/extension/session-manager.js +332 -0
  23. package/extension/settings.js +94 -0
  24. package/extension/tool-handlers.js +1158 -0
  25. package/lib/runtime-paths.js +39 -0
  26. package/mcp/bridge-hub.js +604 -0
  27. package/mcp/http-server.js +326 -0
  28. package/mcp/json-rpc.js +35 -0
  29. package/mcp/memory.js +126 -0
  30. package/mcp/redaction.js +94 -0
  31. package/mcp/server.js +269 -0
  32. package/mcp/tools.js +1092 -0
  33. package/package.json +60 -0
  34. package/scripts/check-package-metadata.js +131 -0
  35. package/scripts/check-published-package.js +113 -0
  36. package/scripts/doctor.js +163 -0
  37. package/scripts/package-extension.js +137 -0
  38. package/scripts/print-config.js +116 -0
  39. package/scripts/release-check.js +472 -0
  40. package/skills/yunti-browser-runtime/SKILL.md +77 -0
@@ -0,0 +1,35 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "Yunti Browser Runtime",
4
+ "version": "0.1.0",
5
+ "description": "Local browser runtime for AI agents through MCP.",
6
+ "permissions": [
7
+ "activeTab",
8
+ "debugger",
9
+ "storage",
10
+ "tabs",
11
+ "webRequest"
12
+ ],
13
+ "host_permissions": [
14
+ "https://*/*",
15
+ "http://*/*",
16
+ "http://127.0.0.1/*",
17
+ "http://localhost/*"
18
+ ],
19
+ "background": {
20
+ "service_worker": "background.js",
21
+ "type": "module"
22
+ },
23
+ "action": {
24
+ "default_title": "Yunti Browser Runtime",
25
+ "default_popup": "popup.html"
26
+ },
27
+ "content_scripts": [
28
+ {
29
+ "matches": ["http://*/*", "https://*/*"],
30
+ "js": ["content.js"],
31
+ "css": ["content.css"],
32
+ "run_at": "document_idle"
33
+ }
34
+ ]
35
+ }
@@ -0,0 +1,140 @@
1
+ import { DEFAULT_PLATFORM_MATCHES, isPlatformUrl } from "./settings.js"
2
+
3
+ const HTTPS_NETWORK_FILTER = { urls: ["https://*/*"] }
4
+ const SENSITIVE_FIELD_RE =
5
+ /cookie|authorization|token|secret|password|passwd|pwd|sid|session|ticket|tgc|captcha|验证码|密码/i
6
+
7
+ export function installNetworkMonitor({ sessionsByTab, postBridge, getPlatformMatches }) {
8
+ const networkRequests = new Map()
9
+
10
+ chrome.webRequest.onBeforeRequest.addListener(
11
+ (details) => {
12
+ beginNetworkEvent({ details, sessionsByTab, networkRequests, getPlatformMatches })
13
+ },
14
+ HTTPS_NETWORK_FILTER,
15
+ ["requestBody"]
16
+ )
17
+
18
+ chrome.webRequest.onCompleted.addListener((details) => {
19
+ finishNetworkEvent({
20
+ details,
21
+ outcome: { completed: true },
22
+ sessionsByTab,
23
+ networkRequests,
24
+ getPlatformMatches,
25
+ postBridge,
26
+ })
27
+ }, HTTPS_NETWORK_FILTER)
28
+
29
+ chrome.webRequest.onErrorOccurred.addListener((details) => {
30
+ finishNetworkEvent({
31
+ details,
32
+ outcome: { error: details.error || "request failed" },
33
+ sessionsByTab,
34
+ networkRequests,
35
+ getPlatformMatches,
36
+ postBridge,
37
+ })
38
+ }, HTTPS_NETWORK_FILTER)
39
+
40
+ return { networkRequests }
41
+ }
42
+
43
+ function beginNetworkEvent({ details, sessionsByTab, networkRequests, getPlatformMatches }) {
44
+ if (details.tabId < 0) return
45
+ const session = sessionsByTab.get(details.tabId)
46
+ if (!session) return
47
+ if (!isSupportedNetworkUrl(details.url, getPlatformMatches)) return
48
+ networkRequests.set(details.requestId, {
49
+ browserSessionId: session.browserSessionId,
50
+ tabId: details.tabId,
51
+ requestId: details.requestId,
52
+ url: details.url,
53
+ method: details.method,
54
+ type: details.type,
55
+ initiator: details.initiator || "",
56
+ documentUrl: details.documentUrl || "",
57
+ startedAt: new Date(details.timeStamp || Date.now()).toISOString(),
58
+ startTime: details.timeStamp || Date.now(),
59
+ requestBody: summarizeRequestBody(details.requestBody),
60
+ })
61
+ }
62
+
63
+ function finishNetworkEvent({
64
+ details,
65
+ outcome,
66
+ sessionsByTab,
67
+ networkRequests,
68
+ getPlatformMatches,
69
+ postBridge,
70
+ }) {
71
+ if (!isSupportedNetworkUrl(details.url, getPlatformMatches)) return
72
+ const event = networkRequests.get(details.requestId) || {
73
+ tabId: details.tabId,
74
+ requestId: details.requestId,
75
+ url: details.url,
76
+ method: details.method,
77
+ type: details.type,
78
+ initiator: details.initiator || "",
79
+ documentUrl: details.documentUrl || "",
80
+ startedAt: new Date(details.timeStamp || Date.now()).toISOString(),
81
+ startTime: details.timeStamp || Date.now(),
82
+ requestBody: null,
83
+ }
84
+ networkRequests.delete(details.requestId)
85
+ if (!event.browserSessionId && details.tabId >= 0) {
86
+ event.browserSessionId =
87
+ sessionsByTab.get(details.tabId)?.browserSessionId || ""
88
+ }
89
+ if (!event.browserSessionId) return
90
+ const completedAt = new Date(details.timeStamp || Date.now()).toISOString()
91
+ void postBridge("/extension/network-event", {
92
+ ...event,
93
+ completedAt,
94
+ durationMs: Math.max(
95
+ 0,
96
+ Math.round((details.timeStamp || Date.now()) - event.startTime)
97
+ ),
98
+ statusCode: details.statusCode ?? null,
99
+ ok: outcome.completed
100
+ ? details.statusCode >= 200 && details.statusCode < 400
101
+ : false,
102
+ fromCache: Boolean(details.fromCache),
103
+ error: outcome.error || "",
104
+ }).catch(() => {})
105
+ }
106
+
107
+ function isSupportedNetworkUrl(url, getPlatformMatches) {
108
+ return isPlatformUrl(url, {
109
+ platformMatches: getPlatformMatches?.() || DEFAULT_PLATFORM_MATCHES,
110
+ })
111
+ }
112
+
113
+ function summarizeRequestBody(requestBody) {
114
+ if (!requestBody) return null
115
+ const fieldNames = []
116
+ const formData = {}
117
+ if (requestBody.formData && typeof requestBody.formData === "object") {
118
+ for (const [key, values] of Object.entries(requestBody.formData)) {
119
+ fieldNames.push(key)
120
+ formData[key] = safeFormPreview(key, values)
121
+ }
122
+ return { kind: "formData", fieldNames, formData }
123
+ }
124
+ if (Array.isArray(requestBody.raw)) {
125
+ const rawBytes = requestBody.raw.reduce(
126
+ (total, item) => total + (item.bytes?.byteLength || 0),
127
+ 0
128
+ )
129
+ return { kind: "raw", rawBytes }
130
+ }
131
+ return null
132
+ }
133
+
134
+ function safeFormPreview(key, values) {
135
+ if (SENSITIVE_FIELD_RE.test(String(key || ""))) return "[REDACTED]"
136
+ const joined = Array.isArray(values)
137
+ ? values.map((value) => String(value)).join(",")
138
+ : String(values)
139
+ return joined.length > 160 ? `${joined.slice(0, 160)}...` : joined
140
+ }
@@ -0,0 +1,66 @@
1
+ body {
2
+ width: 260px;
3
+ margin: 0;
4
+ font-family:
5
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
6
+ color: #0f172a;
7
+ background: #fff;
8
+ }
9
+
10
+ main {
11
+ display: grid;
12
+ gap: 9px;
13
+ padding: 12px;
14
+ }
15
+
16
+ h1 {
17
+ margin: 0;
18
+ font-size: 16px;
19
+ }
20
+
21
+ p {
22
+ min-height: 34px;
23
+ margin: 0;
24
+ color: #475569;
25
+ font-size: 12px;
26
+ line-height: 1.4;
27
+ white-space: pre-wrap;
28
+ }
29
+
30
+ label {
31
+ display: grid;
32
+ gap: 4px;
33
+ color: #334155;
34
+ font-size: 12px;
35
+ }
36
+
37
+ input,
38
+ textarea {
39
+ width: 100%;
40
+ box-sizing: border-box;
41
+ border: 1px solid #cbd5e1;
42
+ border-radius: 6px;
43
+ padding: 6px 7px;
44
+ color: #0f172a;
45
+ font: 12px/1.3 system-ui;
46
+ }
47
+
48
+ textarea {
49
+ resize: vertical;
50
+ }
51
+
52
+ button {
53
+ min-height: 32px;
54
+ border: 1px solid #cbd5e1;
55
+ border-radius: 7px;
56
+ background: #fff;
57
+ color: #0f172a;
58
+ cursor: pointer;
59
+ font: 500 13px/1 system-ui;
60
+ }
61
+
62
+ button:first-of-type {
63
+ border-color: #0f766e;
64
+ background: #0f766e;
65
+ color: white;
66
+ }
@@ -0,0 +1,30 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Yunti Browser Runtime</title>
7
+ <link rel="stylesheet" href="popup.css" />
8
+ </head>
9
+ <body>
10
+ <main>
11
+ <h1>Yunti Browser Runtime</h1>
12
+ <p id="status">正在读取状态...</p>
13
+ <label>
14
+ Bridge URL
15
+ <input id="bridgeUrl" autocomplete="off" />
16
+ </label>
17
+ <label>
18
+ Bridge Token
19
+ <input id="bridgeToken" autocomplete="off" type="password" />
20
+ </label>
21
+ <label>
22
+ 页面匹配
23
+ <textarea id="platformMatches" rows="3"></textarea>
24
+ </label>
25
+ <button id="save">保存设置</button>
26
+ <button id="refresh">刷新状态</button>
27
+ </main>
28
+ <script type="module" src="popup.js"></script>
29
+ </body>
30
+ </html>
@@ -0,0 +1,55 @@
1
+ const statusEl = document.querySelector("#status")
2
+ const bridgeUrlEl = document.querySelector("#bridgeUrl")
3
+ const bridgeTokenEl = document.querySelector("#bridgeToken")
4
+ const platformMatchesEl = document.querySelector("#platformMatches")
5
+ document.querySelector("#save").addEventListener("click", save)
6
+ document.querySelector("#refresh").addEventListener("click", refresh)
7
+
8
+ void refresh()
9
+
10
+ async function refresh() {
11
+ const state = await send({ type: "yunti_panel_get_state" })
12
+ if (!state.ok) {
13
+ setStatus(state.error || "状态读取失败")
14
+ return
15
+ }
16
+ renderSettings(state.settings || {})
17
+ setStatus(
18
+ state.activeSession
19
+ ? `页面已连接\n${state.activeSession.title || state.activeSession.url}`
20
+ : "请打开任意 http/https 页面,并确认本地 bridge 已启动。"
21
+ )
22
+ }
23
+
24
+ async function save() {
25
+ const state = await send({
26
+ type: "yunti_panel_save_settings",
27
+ settings: {
28
+ bridgeUrl: bridgeUrlEl.value,
29
+ bridgeToken: bridgeTokenEl.value,
30
+ platformMatches: platformMatchesEl.value,
31
+ },
32
+ })
33
+ if (!state.ok) {
34
+ setStatus(state.error || "保存失败")
35
+ return
36
+ }
37
+ renderSettings(state.settings || {})
38
+ setStatus("设置已保存,请刷新目标页面完成重新注册。")
39
+ }
40
+
41
+ function renderSettings(settings) {
42
+ bridgeUrlEl.value = settings.bridgeUrl || "http://127.0.0.1:48887"
43
+ bridgeTokenEl.value = settings.bridgeToken || ""
44
+ platformMatchesEl.value = Array.isArray(settings.platformMatches)
45
+ ? settings.platformMatches.join("\n")
46
+ : "*"
47
+ }
48
+
49
+ function setStatus(text) {
50
+ statusEl.textContent = text
51
+ }
52
+
53
+ function send(message) {
54
+ return chrome.runtime.sendMessage(message)
55
+ }
@@ -0,0 +1,332 @@
1
+ import {
2
+ DEFAULT_BRIDGE_URL,
3
+ bridgeHeaders,
4
+ getSettings,
5
+ isPlatformUrl,
6
+ normalizeBaseUrl,
7
+ normalizeBridgeToken,
8
+ normalizePlatformMatches,
9
+ platformLabelForUrl,
10
+ } from "./settings.js"
11
+
12
+ export function createSessionManager() {
13
+ const sessionsByTab = new Map()
14
+ const pollers = new Map()
15
+ let cachedPlatformMatches = null
16
+ let toolRequestHandler = null
17
+
18
+ function getPlatformMatches() {
19
+ return cachedPlatformMatches
20
+ }
21
+
22
+ function setToolRequestHandler(handler) {
23
+ toolRequestHandler = typeof handler === "function" ? handler : null
24
+ }
25
+
26
+ function forgetTab(tabId) {
27
+ sessionsByTab.delete(tabId)
28
+ pollers.get(tabId)?.abort()
29
+ pollers.delete(tabId)
30
+ }
31
+
32
+ async function activateTab(tabId) {
33
+ const session = sessionsByTab.get(tabId)
34
+ if (!session) return
35
+ await postBridge("/sessions/activate", {
36
+ browserSessionId: session.browserSessionId,
37
+ userId: session.userId || "",
38
+ }).catch(() => {})
39
+ }
40
+
41
+ async function handleMessage(message, sender) {
42
+ switch (message?.type) {
43
+ case "yunti_content_ready":
44
+ return registerContentSession(sender.tab, message.page)
45
+ case "yunti_is_supported_page":
46
+ return checkSupportedPage(message.page)
47
+ case "yunti_panel_get_state":
48
+ return getPanelState()
49
+ case "yunti_panel_save_settings":
50
+ await chrome.storage.local.set({
51
+ localUserName: "local",
52
+ localUserId: "local",
53
+ bridgeUrl: normalizeBaseUrl(
54
+ message.settings?.bridgeUrl || DEFAULT_BRIDGE_URL
55
+ ),
56
+ bridgeToken: normalizeBridgeToken(message.settings?.bridgeToken || ""),
57
+ agentType: "browser_agent",
58
+ platformMatches: normalizePlatformMatches(
59
+ message.settings?.platformMatches
60
+ ),
61
+ })
62
+ return getPanelState()
63
+ case "yunti_panel_refresh_active":
64
+ return refreshActiveTab()
65
+ default:
66
+ return { ok: false, error: `unknown message: ${message?.type}` }
67
+ }
68
+ }
69
+
70
+ async function registerContentSession(tab, page) {
71
+ const settings = await getSettings()
72
+ cachedPlatformMatches = settings.platformMatches
73
+ if (!tab?.id || !isPlatformUrl(page?.url, settings)) {
74
+ return { ok: false, error: "not an Yunti tab" }
75
+ }
76
+ const browserSessionId =
77
+ sessionsByTab.get(tab.id)?.browserSessionId ||
78
+ `yunti-${tab.id}-${crypto.randomUUID ? crypto.randomUUID() : Date.now()}`
79
+ const session = {
80
+ browserSessionId,
81
+ userId: settings.localUserId || "",
82
+ displayName: settings.localUserName || "",
83
+ tabId: tab.id,
84
+ windowId: tab.windowId,
85
+ url: page.url,
86
+ title: page.title || tab.title || platformLabelForUrl(page.url),
87
+ client: normalizeClientInfo({
88
+ ...getBackgroundClientInfo(),
89
+ ...(page.client || {}),
90
+ }),
91
+ auth: normalizePageAuth(page.auth, "content_script_register"),
92
+ registeredAt: new Date().toISOString(),
93
+ }
94
+ sessionsByTab.set(tab.id, session)
95
+ await postBridge("/sessions/register", session).catch(() => null)
96
+ startPolling(tab.id)
97
+ return { ok: true, session, settings }
98
+ }
99
+
100
+ async function checkSupportedPage(page) {
101
+ const settings = await getSettings()
102
+ cachedPlatformMatches = settings.platformMatches
103
+ return {
104
+ ok: true,
105
+ supported: isPlatformUrl(page?.url, settings),
106
+ settings,
107
+ }
108
+ }
109
+
110
+ async function startPolling(tabId) {
111
+ if (pollers.has(tabId)) return
112
+ const controller = new AbortController()
113
+ pollers.set(tabId, controller)
114
+ const loop = async () => {
115
+ while (!controller.signal.aborted) {
116
+ const session = sessionsByTab.get(tabId)
117
+ if (!session) break
118
+ try {
119
+ const settings = await getSettings()
120
+ cachedPlatformMatches = settings.platformMatches
121
+ await postBridge("/sessions/register", session).catch(() => null)
122
+ const url = `${settings.bridgeUrl}/extension/poll?browserSessionId=${encodeURIComponent(
123
+ session.browserSessionId
124
+ )}&timeoutMs=25000`
125
+ const response = await fetch(url, {
126
+ signal: controller.signal,
127
+ headers: bridgeHeaders(settings),
128
+ })
129
+ const event = await response.json()
130
+ if (event?.type === "tool_request" && toolRequestHandler) {
131
+ await toolRequestHandler(tabId, session, event)
132
+ }
133
+ } catch {
134
+ if (!controller.signal.aborted) await delay(1500)
135
+ }
136
+ }
137
+ pollers.delete(tabId)
138
+ }
139
+ void loop()
140
+ }
141
+
142
+ async function forwardConsoleEvent(session, tabId, method, params = {}) {
143
+ let level = "log"
144
+ let text = ""
145
+ let source = "console-api"
146
+ let stackTrace = ""
147
+ let args = []
148
+ let url = ""
149
+ let lineNumber = null
150
+ let columnNumber = null
151
+
152
+ if (method === "Runtime.consoleAPICalled") {
153
+ source = "console-api"
154
+ level = String(params.type || "log").toLowerCase()
155
+ args = (params.args || []).map((arg) => {
156
+ if (arg.type === "string") return String(arg.value || "")
157
+ if (arg.type === "number" || arg.type === "boolean") {
158
+ return String(arg.value)
159
+ }
160
+ if (arg.type === "undefined") return "undefined"
161
+ if (arg.type === "null") return "null"
162
+ if (arg.type === "object" && arg.description) return arg.description
163
+ return arg.type || "unknown"
164
+ })
165
+ text = args.filter((arg) => typeof arg === "string").join(" ") || args.join(" ")
166
+ stackTrace = formatConsoleStackTrace(params.stackTrace)
167
+ } else if (method === "Log.entryAdded") {
168
+ source = "other"
169
+ const entry = params.entry || {}
170
+ level = String(entry.level || "verbose").toLowerCase()
171
+ text = String(entry.text || "").slice(0, 2000)
172
+ url = entry.url || ""
173
+ lineNumber = entry.lineNumber ?? null
174
+ columnNumber = entry.columnNumber ?? null
175
+ if (entry.stackTrace) stackTrace = formatConsoleStackTrace(entry.stackTrace)
176
+ }
177
+
178
+ await postBridge("/extension/console-event", {
179
+ browserSessionId: session.browserSessionId,
180
+ tabId,
181
+ level,
182
+ text: text.slice(0, 2000),
183
+ source,
184
+ stackTrace,
185
+ args,
186
+ url,
187
+ lineNumber,
188
+ columnNumber,
189
+ timestamp: new Date().toISOString(),
190
+ }).catch(() => {})
191
+ }
192
+
193
+ async function postBridge(path, body) {
194
+ const settings = await getSettings()
195
+ cachedPlatformMatches = settings.platformMatches
196
+ const response = await fetch(`${settings.bridgeUrl}${path}`, {
197
+ method: "POST",
198
+ headers: bridgeHeaders(settings),
199
+ body: JSON.stringify(body),
200
+ })
201
+ if (!response.ok) throw new Error(`bridge HTTP ${response.status}`)
202
+ return response.json()
203
+ }
204
+
205
+ async function getPanelState() {
206
+ const settings = await getSettings()
207
+ cachedPlatformMatches = settings.platformMatches
208
+ const [activeTab] = await chrome.tabs.query({
209
+ active: true,
210
+ currentWindow: true,
211
+ })
212
+ const activeSession = activeTab?.id
213
+ ? sessionsByTab.get(activeTab.id) || null
214
+ : null
215
+ let bridge = null
216
+ try {
217
+ const userId = encodeURIComponent(settings.localUserId || "")
218
+ const healthUrl = userId
219
+ ? `${settings.bridgeUrl}/health?userId=${userId}`
220
+ : `${settings.bridgeUrl}/health`
221
+ const response = await fetch(healthUrl, {
222
+ headers: bridgeHeaders(settings),
223
+ })
224
+ bridge = await response.json()
225
+ } catch {
226
+ bridge = { ok: false }
227
+ }
228
+ return {
229
+ ok: true,
230
+ settings,
231
+ activeTab: activeTab
232
+ ? {
233
+ id: activeTab.id,
234
+ url: activeTab.url,
235
+ title: activeTab.title,
236
+ windowId: activeTab.windowId,
237
+ }
238
+ : null,
239
+ activeSession,
240
+ bridge,
241
+ }
242
+ }
243
+
244
+ async function refreshActiveTab() {
245
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
246
+ if (!tab?.id) return { ok: false, error: "no active tab" }
247
+ await chrome.tabs
248
+ .sendMessage(tab.id, { type: "yunti_refresh_registration" })
249
+ .catch(() => null)
250
+ return getPanelState()
251
+ }
252
+
253
+ return {
254
+ sessionsByTab,
255
+ pollers,
256
+ activateTab,
257
+ forgetTab,
258
+ forwardConsoleEvent,
259
+ getPlatformMatches,
260
+ handleMessage,
261
+ postBridge,
262
+ setToolRequestHandler,
263
+ startPolling,
264
+ }
265
+ }
266
+
267
+ function formatConsoleStackTrace(stackTrace) {
268
+ if (!stackTrace?.callFrames) return ""
269
+ return stackTrace.callFrames
270
+ .map(
271
+ (frame) =>
272
+ `${frame.functionName || "(anonymous)"} (${frame.url || ""}:${frame.lineNumber}:${frame.columnNumber})`
273
+ )
274
+ .join("\n")
275
+ }
276
+
277
+ function normalizePageAuth(auth, source = "unknown") {
278
+ if (!auth || typeof auth !== "object") {
279
+ return {
280
+ state: "missing_from_content_script",
281
+ loggedIn: false,
282
+ reason: "Content script did not report page state; reload the extension and refresh the page",
283
+ checkedAt: new Date().toISOString(),
284
+ checkedBy: source,
285
+ }
286
+ }
287
+ return {
288
+ state: String(auth.state || "unknown"),
289
+ loggedIn: Boolean(auth.loggedIn),
290
+ reason: String(auth.reason || ""),
291
+ checkedAt: auth.checkedAt || new Date().toISOString(),
292
+ checkedBy: String(auth.checkedBy || source),
293
+ status: auth.status ?? null,
294
+ apiAuthError: auth.apiAuthError || "",
295
+ userName: auth.userName ? String(auth.userName).slice(0, 120) : "",
296
+ }
297
+ }
298
+
299
+ function normalizeClientInfo(client) {
300
+ const value = client && typeof client === "object" ? client : {}
301
+ return {
302
+ family: normalizeBrowserFamily(value.family || value.userAgent),
303
+ userAgent: String(value.userAgent || "").slice(0, 500),
304
+ platform: String(value.platform || "").slice(0, 120),
305
+ language: String(value.language || "").slice(0, 80),
306
+ }
307
+ }
308
+
309
+ function getBackgroundClientInfo() {
310
+ return {
311
+ userAgent: String(globalThis.navigator?.userAgent || ""),
312
+ platform: String(globalThis.navigator?.platform || ""),
313
+ language: String(globalThis.navigator?.language || ""),
314
+ }
315
+ }
316
+
317
+ function normalizeBrowserFamily(value) {
318
+ const text = String(value || "").toLowerCase()
319
+ if (text === "edge" || /edg\//i.test(text)) return "edge"
320
+ if (text === "chrome" || /chrome\//i.test(text) || /chromium\//i.test(text)) {
321
+ return "chrome"
322
+ }
323
+ if (text === "brave" || /brave\//i.test(text)) return "brave"
324
+ if (text === "opera" || /opr\//i.test(text)) return "opera"
325
+ if (text === "firefox" || /firefox\//i.test(text)) return "firefox"
326
+ if (text === "safari" || /safari\//i.test(text)) return "safari"
327
+ return "unknown"
328
+ }
329
+
330
+ function delay(ms) {
331
+ return new Promise((resolve) => setTimeout(resolve, ms))
332
+ }