pinokiod 3.231.0 → 3.233.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.
@@ -0,0 +1,1391 @@
1
+ const TAB_LINK_POPOVER_ID = "tab-link-popover"
2
+ let tabLinkPopoverEl = null
3
+ let tabLinkActiveLink = null
4
+ let tabLinkPendingLink = null
5
+ let tabLinkHideTimer = null
6
+ let tabLinkLocalInfoPromise = null
7
+ let tabLinkLocalInfoExpiry = 0
8
+ let tabLinkRouterInfoPromise = null
9
+ let tabLinkRouterInfoExpiry = 0
10
+ let tabLinkRouterHttpsActive = null
11
+ let tabLinkPeerInfoPromise = null
12
+ let tabLinkPeerInfoExpiry = 0
13
+
14
+ const ensureTabLinkPopoverEl = () => {
15
+ if (!tabLinkPopoverEl) {
16
+ tabLinkPopoverEl = document.createElement("div")
17
+ tabLinkPopoverEl.id = TAB_LINK_POPOVER_ID
18
+ tabLinkPopoverEl.className = "tab-link-popover"
19
+ tabLinkPopoverEl.addEventListener("mouseenter", () => {
20
+ if (tabLinkHideTimer) {
21
+ clearTimeout(tabLinkHideTimer)
22
+ tabLinkHideTimer = null
23
+ }
24
+ })
25
+ tabLinkPopoverEl.addEventListener("mouseleave", () => {
26
+ hideTabLinkPopover({ immediate: true })
27
+ })
28
+ tabLinkPopoverEl.addEventListener("click", (event) => {
29
+ const item = event.target.closest(".tab-link-popover-item")
30
+ if (!item) {
31
+ return
32
+ }
33
+ event.preventDefault()
34
+ event.stopPropagation()
35
+ const url = item.getAttribute("data-url")
36
+ if (url) {
37
+ const targetMode = (item.getAttribute("data-target") || "_blank").toLowerCase()
38
+ if (targetMode === "_self") {
39
+ window.location.assign(url)
40
+ } else {
41
+ window.open(url, "_blank", "noopener")
42
+ }
43
+ }
44
+ hideTabLinkPopover({ immediate: true })
45
+ })
46
+ document.body.appendChild(tabLinkPopoverEl)
47
+ }
48
+ return tabLinkPopoverEl
49
+ }
50
+
51
+ const ensurePeerInfo = async () => {
52
+ const now = Date.now()
53
+ if (!tabLinkPeerInfoPromise || now > tabLinkPeerInfoExpiry) {
54
+ tabLinkPeerInfoPromise = fetch("/pinokio/peer", {
55
+ method: "GET",
56
+ headers: {
57
+ "Accept": "application/json"
58
+ }
59
+ })
60
+ .then((response) => {
61
+ if (!response.ok) {
62
+ throw new Error("Failed to load peer info")
63
+ }
64
+ return response.json()
65
+ })
66
+ .catch(() => null)
67
+ tabLinkPeerInfoExpiry = now + 3000
68
+ }
69
+ return tabLinkPeerInfoPromise
70
+ }
71
+
72
+ const canonicalizeUrl = (value) => {
73
+ try {
74
+ const parsed = new URL(value, location.origin)
75
+ if (!parsed.protocol) {
76
+ return value
77
+ }
78
+ const protocol = parsed.protocol.toLowerCase()
79
+ if (protocol !== "http:" && protocol !== "https:") {
80
+ return value
81
+ }
82
+ const hostname = parsed.hostname.toLowerCase()
83
+ const port = parsed.port ? `:${parsed.port}` : ""
84
+ let pathname = parsed.pathname || "/"
85
+ if (pathname !== "/") {
86
+ pathname = pathname.replace(/\/+/g, "/")
87
+ if (pathname.length > 1 && pathname.endsWith("/")) {
88
+ pathname = pathname.slice(0, -1)
89
+ }
90
+ }
91
+ const search = parsed.search || ""
92
+ return `${protocol}//${hostname}${port}${pathname}${search}`
93
+ } catch (_) {
94
+ return value
95
+ }
96
+ }
97
+
98
+ const ensureHttpDirectoryUrl = (value) => {
99
+ try {
100
+ const parsed = new URL(value)
101
+ if (parsed.protocol.toLowerCase() !== "http:") {
102
+ return value
103
+ }
104
+ let pathname = parsed.pathname || "/"
105
+ const lastSegment = pathname.split("/").pop() || ""
106
+ const hasExtension = lastSegment.includes(".")
107
+ if (!hasExtension && !pathname.endsWith("/")) {
108
+ pathname = `${pathname}/`
109
+ parsed.pathname = pathname
110
+ }
111
+ parsed.hash = parsed.hash || ""
112
+ parsed.search = parsed.search || ""
113
+ return parsed.toString()
114
+ } catch (_) {
115
+ return value
116
+ }
117
+ }
118
+
119
+ const isLocalHostLike = (hostname) => {
120
+ if (!hostname) {
121
+ return false
122
+ }
123
+ const hostLower = hostname.toLowerCase()
124
+ if (hostLower === location.hostname.toLowerCase()) {
125
+ return true
126
+ }
127
+ if (hostLower === "localhost" || hostLower === "0.0.0.0") {
128
+ return true
129
+ }
130
+ if (hostLower.startsWith("127.")) {
131
+ return true
132
+ }
133
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(hostLower)) {
134
+ return true
135
+ }
136
+ return false
137
+ }
138
+
139
+ const isIPv4Host = (host) => /^(\d{1,3}\.){3}\d{1,3}$/.test((host || '').trim())
140
+
141
+ const extractProjectSlug = (node) => {
142
+ if (!node) {
143
+ return ""
144
+ }
145
+ const candidates = []
146
+ const targetFull = node.getAttribute("data-target-full")
147
+ if (typeof targetFull === "string" && targetFull.length > 0) {
148
+ candidates.push(targetFull)
149
+ }
150
+ const dataHref = node.getAttribute("href")
151
+ if (typeof dataHref === "string" && dataHref.length > 0) {
152
+ candidates.push(dataHref)
153
+ }
154
+ try {
155
+ const absolute = new URL(node.href, location.origin)
156
+ candidates.push(absolute.pathname)
157
+ } catch (_) {
158
+ // ignore
159
+ }
160
+ for (const candidate of candidates) {
161
+ if (typeof candidate !== "string" || candidate.length === 0) {
162
+ continue
163
+ }
164
+ const assetMatch = candidate.match(/\/asset\/api\/([^\/?#]+)/i)
165
+ if (assetMatch && assetMatch[1]) {
166
+ return assetMatch[1]
167
+ }
168
+ const pageMatch = candidate.match(/\/p\/([^\/?#]+)/i)
169
+ if (pageMatch && pageMatch[1]) {
170
+ return pageMatch[1]
171
+ }
172
+ const apiMatch = candidate.match(/\/api\/([^\/?#]+)/i)
173
+ if (apiMatch && apiMatch[1]) {
174
+ return apiMatch[1]
175
+ }
176
+ }
177
+ return ""
178
+ }
179
+
180
+ const formatDisplayUrl = (value) => {
181
+ try {
182
+ const parsed = new URL(value, location.origin)
183
+ const host = parsed.host
184
+ const pathname = parsed.pathname || "/"
185
+ const search = parsed.search || ""
186
+ return `${host}${pathname}${search}`
187
+ } catch (_) {
188
+ return value
189
+ }
190
+ }
191
+
192
+ const isHttpOrHttps = (value) => {
193
+ try {
194
+ const parsed = new URL(value, location.origin)
195
+ const protocol = parsed.protocol.toLowerCase()
196
+ return protocol === "http:" || protocol === "https:"
197
+ } catch (_) {
198
+ return false
199
+ }
200
+ }
201
+
202
+ const isHttpUrl = (value) => {
203
+ try {
204
+ const parsed = new URL(value, location.origin)
205
+ return parsed.protocol.toLowerCase() === "http:"
206
+ } catch (_) {
207
+ return false
208
+ }
209
+ }
210
+
211
+ const isHttpsUrl = (value) => {
212
+ try {
213
+ const parsed = new URL(value, location.origin)
214
+ return parsed.protocol.toLowerCase() === "https:"
215
+ } catch (_) {
216
+ return false
217
+ }
218
+ }
219
+
220
+ const collectUrlsFromLocal = (root) => {
221
+ if (!root || typeof root !== "object") {
222
+ return []
223
+ }
224
+ const queue = [root]
225
+ const visited = new Set()
226
+ const urls = new Set()
227
+ while (queue.length > 0) {
228
+ const current = queue.shift()
229
+ if (!current || typeof current !== "object") {
230
+ continue
231
+ }
232
+ if (visited.has(current)) {
233
+ continue
234
+ }
235
+ visited.add(current)
236
+ const values = Array.isArray(current) ? current : Object.values(current)
237
+ for (const value of values) {
238
+ if (typeof value === "string") {
239
+ if (isHttpOrHttps(value)) {
240
+ urls.add(value)
241
+ }
242
+ } else if (value && typeof value === "object") {
243
+ queue.push(value)
244
+ }
245
+ }
246
+ }
247
+ return Array.from(urls)
248
+ }
249
+
250
+ const collectScriptKeys = (node) => {
251
+ const keys = new Set()
252
+ const scriptAttr = node.getAttribute("data-script")
253
+ if (scriptAttr) {
254
+ const decoded = decodeURIComponent(scriptAttr)
255
+ if (decoded) {
256
+ keys.add(decoded)
257
+ const withoutQuery = decoded.split("?")[0]
258
+ if (withoutQuery) {
259
+ keys.add(withoutQuery)
260
+ }
261
+ }
262
+ }
263
+ const filepathAttr = node.getAttribute("data-filepath")
264
+ if (filepathAttr) {
265
+ keys.add(filepathAttr)
266
+ }
267
+ return Array.from(keys)
268
+ }
269
+
270
+ const ensureLocalMemory = async () => {
271
+ const now = Date.now()
272
+ if (!tabLinkLocalInfoPromise || now > tabLinkLocalInfoExpiry) {
273
+ tabLinkLocalInfoPromise = fetch("/info/local", {
274
+ method: "GET",
275
+ headers: {
276
+ "Accept": "application/json"
277
+ }
278
+ })
279
+ .then((response) => {
280
+ if (!response.ok) {
281
+ throw new Error("Failed to load local info")
282
+ }
283
+ return response.json()
284
+ })
285
+ .catch(() => ({}))
286
+ tabLinkLocalInfoExpiry = now + 3000
287
+ }
288
+ return tabLinkLocalInfoPromise
289
+ }
290
+
291
+ const normalizeHttpsTarget = (value) => {
292
+ if (!value || typeof value !== "string") {
293
+ return ""
294
+ }
295
+ let trimmed = value.trim()
296
+ if (!trimmed) {
297
+ return ""
298
+ }
299
+ // If it's already a URL, ensure it's HTTPS and not an IP host
300
+ if (/^https?:\/\//i.test(trimmed)) {
301
+ try {
302
+ const parsed = new URL(trimmed)
303
+ const host = (parsed.hostname || '').toLowerCase()
304
+ if (!host || isIPv4Host(host)) {
305
+ return ""
306
+ }
307
+ // Only accept domains (prefer *.localhost) for HTTPS targets
308
+ if (!(host === 'localhost' || host.endsWith('.localhost') || host.includes('.'))) {
309
+ return ""
310
+ }
311
+ let pathname = parsed.pathname || ""
312
+ if (pathname === "/") pathname = ""
313
+ const search = parsed.search || ""
314
+ return `https://${host}${pathname}${search}`
315
+ } catch (_) {
316
+ return ""
317
+ }
318
+ }
319
+ // Not a full URL: accept plain domains (prefer *.localhost), reject IPs
320
+ try {
321
+ const hostCandidate = trimmed.split('/')[0].toLowerCase()
322
+ if (!hostCandidate || isIPv4Host(hostCandidate)) {
323
+ return ""
324
+ }
325
+ if (!(hostCandidate === 'localhost' || hostCandidate.endsWith('.localhost') || hostCandidate.includes('.'))) {
326
+ return ""
327
+ }
328
+ return `https://${hostCandidate}`
329
+ } catch (_) {
330
+ return ""
331
+ }
332
+ }
333
+
334
+ const parseHostPort = (value) => {
335
+ if (!value || typeof value !== "string") {
336
+ return null
337
+ }
338
+ let trimmed = value.trim()
339
+ if (!trimmed) {
340
+ return null
341
+ }
342
+ if (/^https?:\/\//i.test(trimmed)) {
343
+ try {
344
+ const parsed = new URL(trimmed)
345
+ if (!parsed.hostname) {
346
+ return null
347
+ }
348
+ const protocol = parsed.protocol.toLowerCase()
349
+ let port = parsed.port
350
+ if (!port) {
351
+ if (protocol === "http:") {
352
+ port = "80"
353
+ } else if (protocol === "https:") {
354
+ port = "443"
355
+ }
356
+ }
357
+ if (!port) {
358
+ return null
359
+ }
360
+ return {
361
+ host: parsed.hostname.toLowerCase(),
362
+ port
363
+ }
364
+ } catch (_) {
365
+ return null
366
+ }
367
+ }
368
+ const slashIndex = trimmed.indexOf("/")
369
+ if (slashIndex >= 0) {
370
+ trimmed = trimmed.slice(0, slashIndex)
371
+ }
372
+ const match = trimmed.match(/^\[?([^\]]+)\]?(?::([0-9]+))$/)
373
+ if (!match) {
374
+ return null
375
+ }
376
+ const host = match[1] ? match[1].toLowerCase() : ""
377
+ const port = match[2] || ""
378
+ if (!host || !port) {
379
+ return null
380
+ }
381
+ return { host, port }
382
+ }
383
+
384
+ const ensureRouterInfoMapping = async () => {
385
+ const now = Date.now()
386
+ if (!tabLinkRouterInfoPromise || now > tabLinkRouterInfoExpiry) {
387
+ // Use lightweight router mapping to avoid favicon/installed overhead
388
+ tabLinkRouterInfoPromise = fetch("/info/router", {
389
+ method: "GET",
390
+ headers: {
391
+ "Accept": "application/json"
392
+ }
393
+ })
394
+ .then((response) => {
395
+ if (!response.ok) {
396
+ throw new Error("Failed to load system info")
397
+ }
398
+ return response.json()
399
+ })
400
+ .then((data) => {
401
+ if (typeof data?.https_active === "boolean") {
402
+ tabLinkRouterHttpsActive = data.https_active
403
+ }
404
+ const processes = Array.isArray(data?.router_info) ? data.router_info : []
405
+ const rewriteMapping = data?.rewrite_mapping && typeof data.rewrite_mapping === "object"
406
+ ? Object.values(data.rewrite_mapping)
407
+ : []
408
+ const portMap = new Map()
409
+ const hostPortMap = new Map()
410
+ const externalHttpByExtPort = new Map() // ext port -> Set of host:port (external_ip)
411
+ const externalHttpByIntPort = new Map() // internal port -> Set of host:port (external_ip)
412
+ const hostAliasPortMap = new Map()
413
+ if (data?.router && typeof data.router === "object") {
414
+ Object.entries(data.router).forEach(([dial, hosts]) => {
415
+ const parsedDial = parseHostPort(dial)
416
+ if (!parsedDial || !parsedDial.port) {
417
+ return
418
+ }
419
+ if (!Array.isArray(hosts)) {
420
+ return
421
+ }
422
+ hosts.forEach((host) => {
423
+ if (typeof host !== "string") {
424
+ return
425
+ }
426
+ const trimmed = host.trim().toLowerCase()
427
+ if (!trimmed) {
428
+ return
429
+ }
430
+ if (!hostAliasPortMap.has(trimmed)) {
431
+ hostAliasPortMap.set(trimmed, new Set())
432
+ }
433
+ hostAliasPortMap.get(trimmed).add(parsedDial.port)
434
+ })
435
+ })
436
+ }
437
+ const localAliases = ["127.0.0.1", "localhost", "0.0.0.0", "::1", "[::1]"]
438
+
439
+ const addHttpMapping = (host, port, httpsSet) => {
440
+ if (!host || !port || !httpsSet || httpsSet.size === 0) {
441
+ return
442
+ }
443
+ const hostLower = host.toLowerCase()
444
+ const keys = new Set([`${hostLower}:${port}`])
445
+ if (localAliases.includes(hostLower)) {
446
+ localAliases.forEach((alias) => keys.add(`${alias}:${port}`))
447
+ }
448
+ keys.forEach((key) => {
449
+ if (!hostPortMap.has(key)) {
450
+ hostPortMap.set(key, new Set())
451
+ }
452
+ const set = hostPortMap.get(key)
453
+ httpsSet.forEach((url) => set.add(url))
454
+ })
455
+ if (localAliases.includes(hostLower)) {
456
+ if (!portMap.has(port)) {
457
+ portMap.set(port, new Set())
458
+ }
459
+ const portSet = portMap.get(port)
460
+ httpsSet.forEach((url) => portSet.add(url))
461
+ }
462
+ }
463
+
464
+ const gatherHttpsTargets = (value) => {
465
+ const targets = new Set()
466
+ const visit = (input) => {
467
+ if (!input) {
468
+ return
469
+ }
470
+ if (Array.isArray(input)) {
471
+ input.forEach(visit)
472
+ return
473
+ }
474
+ if (typeof input === "object") {
475
+ Object.values(input).forEach(visit)
476
+ return
477
+ }
478
+ if (typeof input !== "string") {
479
+ return
480
+ }
481
+ const normalized = normalizeHttpsTarget(input)
482
+ if (normalized) {
483
+ targets.add(normalized)
484
+ }
485
+ }
486
+ visit(value)
487
+ return targets
488
+ }
489
+
490
+ const collectHostPort = (value, hostPortCandidates, portCandidates) => {
491
+ if (!value) {
492
+ return
493
+ }
494
+ if (Array.isArray(value)) {
495
+ value.forEach((item) => collectHostPort(item, hostPortCandidates, portCandidates))
496
+ return
497
+ }
498
+ if (typeof value === "object") {
499
+ Object.values(value).forEach((item) => {
500
+ collectHostPort(item, hostPortCandidates, portCandidates)
501
+ })
502
+ return
503
+ }
504
+ if (typeof value !== "string") {
505
+ return
506
+ }
507
+ const parsed = parseHostPort(value)
508
+ let hostLower
509
+ if (parsed && parsed.host && parsed.port) {
510
+ hostLower = parsed.host.toLowerCase()
511
+ hostPortCandidates.add(`${hostLower}:${parsed.port}`)
512
+ if (localAliases.includes(hostLower)) {
513
+ portCandidates.add(parsed.port)
514
+ }
515
+ }
516
+ const rawHost = value.replace(/^https?:\/\//i, "").split("/")[0].toLowerCase()
517
+ const aliasPorts = hostAliasPortMap.get(rawHost)
518
+ if (aliasPorts && aliasPorts.size > 0) {
519
+ aliasPorts.forEach((aliasPort) => {
520
+ hostPortCandidates.add(`${rawHost}:${aliasPort}`)
521
+ if (localAliases.includes(rawHost)) {
522
+ portCandidates.add(aliasPort)
523
+ }
524
+ })
525
+ }
526
+ }
527
+
528
+ const collectPort = (value, portCandidates) => {
529
+ if (value === null || value === undefined || value === "") {
530
+ return
531
+ }
532
+ if (Array.isArray(value)) {
533
+ value.forEach((item) => collectPort(item, portCandidates))
534
+ return
535
+ }
536
+ const port = `${value}`.trim()
537
+ if (port && /^[0-9]+$/.test(port)) {
538
+ portCandidates.add(port)
539
+ }
540
+ }
541
+
542
+ const registerEntry = (entry) => {
543
+ if (!entry || typeof entry !== "object") {
544
+ return
545
+ }
546
+ const httpsTargets = new Set()
547
+ const mergeTargets = (targetValue) => {
548
+ const targets = gatherHttpsTargets(targetValue)
549
+ targets.forEach((url) => httpsTargets.add(url))
550
+ }
551
+
552
+ mergeTargets(entry.external_router)
553
+ mergeTargets(entry.external_domain)
554
+ mergeTargets(entry.https_href)
555
+ mergeTargets(entry.app_href)
556
+ // Some rewrite mapping entries expose domain candidates under `hosts`
557
+ mergeTargets(entry.hosts)
558
+ // Internal router can also include domain aliases (e.g., comfyui.localhost)
559
+ mergeTargets(entry.internal_router)
560
+
561
+ // Record external http host:port candidates by external and internal ports for later
562
+ if (entry.external_ip && typeof entry.external_ip === 'string') {
563
+ const parsed = parseHostPort(entry.external_ip)
564
+ if (parsed && parsed.port) {
565
+ const keyExt = parsed.port
566
+ if (!externalHttpByExtPort.has(keyExt)) {
567
+ externalHttpByExtPort.set(keyExt, new Set())
568
+ }
569
+ externalHttpByExtPort.get(keyExt).add(`${parsed.host}:${parsed.port}`)
570
+ const keyInt = String(entry.internal_port || '')
571
+ if (keyInt) {
572
+ if (!externalHttpByIntPort.has(keyInt)) {
573
+ externalHttpByIntPort.set(keyInt, new Set())
574
+ }
575
+ externalHttpByIntPort.get(keyInt).add(`${parsed.host}:${parsed.port}`)
576
+ }
577
+ }
578
+ }
579
+
580
+ if (httpsTargets.size === 0) {
581
+ return
582
+ }
583
+
584
+ const hostPortCandidates = new Set()
585
+ const portCandidates = new Set()
586
+
587
+ collectHostPort(entry.external_ip, hostPortCandidates, portCandidates)
588
+ collectHostPort(entry.internal_ip, hostPortCandidates, portCandidates)
589
+ collectHostPort(entry.ip, hostPortCandidates, portCandidates)
590
+ collectHostPort(entry.dial, hostPortCandidates, portCandidates)
591
+ collectHostPort(entry.match, hostPortCandidates, portCandidates)
592
+ collectHostPort(entry.target, hostPortCandidates, portCandidates)
593
+ collectHostPort(entry.forward, hostPortCandidates, portCandidates)
594
+ collectHostPort(entry.internal_router, hostPortCandidates, portCandidates)
595
+ collectHostPort(entry.external_router, hostPortCandidates, portCandidates)
596
+
597
+ collectPort(entry.port, portCandidates)
598
+ collectPort(entry.internal_port, portCandidates)
599
+ collectPort(entry.external_port, portCandidates)
600
+
601
+ if (hostPortCandidates.size === 0 && portCandidates.size === 0) {
602
+ httpsTargets.forEach((target) => {
603
+ collectHostPort(target, hostPortCandidates, portCandidates)
604
+ })
605
+ }
606
+
607
+ if (hostPortCandidates.size === 0 && portCandidates.size === 0) {
608
+ return
609
+ }
610
+
611
+ hostPortCandidates.forEach((key) => {
612
+ const parsed = parseHostPort(key)
613
+ if (parsed) {
614
+ addHttpMapping(parsed.host, parsed.port, httpsTargets)
615
+ }
616
+ })
617
+
618
+ portCandidates.forEach((port) => {
619
+ localAliases.forEach((host) => {
620
+ addHttpMapping(host, port, httpsTargets)
621
+ })
622
+ })
623
+ }
624
+
625
+ const visited = new WeakSet()
626
+ const traverseNode = (node) => {
627
+ if (!node) {
628
+ return
629
+ }
630
+ if (Array.isArray(node)) {
631
+ node.forEach(traverseNode)
632
+ return
633
+ }
634
+ if (typeof node !== "object") {
635
+ return
636
+ }
637
+ if (visited.has(node)) {
638
+ return
639
+ }
640
+ visited.add(node)
641
+ registerEntry(node)
642
+ Object.values(node).forEach((value) => {
643
+ if (value && typeof value === "object") {
644
+ traverseNode(value)
645
+ }
646
+ })
647
+ }
648
+
649
+ processes.forEach(traverseNode)
650
+ rewriteMapping.forEach(traverseNode)
651
+
652
+ return {
653
+ portMap,
654
+ hostPortMap,
655
+ externalHttpByExtPort,
656
+ externalHttpByIntPort
657
+ }
658
+ })
659
+ .catch(() => {
660
+ tabLinkRouterHttpsActive = null
661
+ return {
662
+ portMap: new Map(),
663
+ hostPortMap: new Map(),
664
+ externalHttpByExtPort: new Map(),
665
+ externalHttpByIntPort: new Map()
666
+ }
667
+ })
668
+ tabLinkRouterInfoExpiry = now + 3000
669
+ }
670
+ return tabLinkRouterInfoPromise
671
+ }
672
+
673
+ const collectHttpsUrlsFromRouter = (httpUrl, routerData) => {
674
+ if (!routerData) {
675
+ return []
676
+ }
677
+ let parsed
678
+ try {
679
+ parsed = new URL(httpUrl, location.origin)
680
+ } catch (_) {
681
+ return []
682
+ }
683
+ const protocol = parsed.protocol.toLowerCase()
684
+ if (protocol !== "http:" && protocol !== "https:") {
685
+ return []
686
+ }
687
+ let port = parsed.port
688
+ if (!port) {
689
+ if (protocol === "http:") {
690
+ port = "80"
691
+ } else if (protocol === "https:") {
692
+ port = "443"
693
+ }
694
+ }
695
+ const hostLower = parsed.hostname.toLowerCase()
696
+ const results = new Set()
697
+ if (port) {
698
+ const hostPortKey = `${hostLower}:${port}`
699
+ if (routerData.hostPortMap.has(hostPortKey)) {
700
+ routerData.hostPortMap.get(hostPortKey).forEach((value) => results.add(value))
701
+ }
702
+ if (routerData.portMap.has(port)) {
703
+ routerData.portMap.get(port).forEach((value) => results.add(value))
704
+ }
705
+ }
706
+ return Array.from(results)
707
+ }
708
+
709
+ const buildTabLinkEntries = async (
710
+ link,
711
+ baseHrefOverride = null,
712
+ { forceCanonicalQr = false, allowQrPortMismatch = false, skipPeerFallback = false } = {}
713
+ ) => {
714
+ const sourceLink = link || null
715
+ const baseHref = baseHrefOverride || (sourceLink ? sourceLink.href : "")
716
+ if (!baseHref) {
717
+ return []
718
+ }
719
+
720
+ let canonicalBase = canonicalizeUrl(baseHref)
721
+ if (canonicalBase && isHttpUrl(canonicalBase)) {
722
+ canonicalBase = ensureHttpDirectoryUrl(canonicalBase)
723
+ }
724
+ let parsedBaseUrl = null
725
+ let sameOrigin = false
726
+ let basePortNormalized = ""
727
+ try {
728
+ parsedBaseUrl = new URL(baseHref, location.origin)
729
+ sameOrigin = parsedBaseUrl.origin === location.origin
730
+ if (parsedBaseUrl) {
731
+ basePortNormalized = parsedBaseUrl.port
732
+ if (!basePortNormalized) {
733
+ const proto = parsedBaseUrl.protocol ? parsedBaseUrl.protocol.toLowerCase() : "http:"
734
+ basePortNormalized = proto === "https:" ? "443" : "80"
735
+ }
736
+ }
737
+ } catch (_) {}
738
+ const projectSlug = extractProjectSlug(sourceLink).toLowerCase()
739
+ const entries = []
740
+ const entryByUrl = new Map()
741
+ const addEntry = (type, label, url, opts = {}) => {
742
+ if (!url) {
743
+ return
744
+ }
745
+ let canonical = canonicalizeUrl(url)
746
+ if (canonical && type === "http") {
747
+ canonical = ensureHttpDirectoryUrl(canonical)
748
+ }
749
+ if (!canonical) {
750
+ return
751
+ }
752
+ let skip = false
753
+ const allowSameOrigin = opts && opts.allowSameOrigin === true
754
+ try {
755
+ const parsed = new URL(canonical)
756
+ const originLower = parsed.origin.toLowerCase()
757
+ if (!allowSameOrigin && originLower === location.origin.toLowerCase()) {
758
+ skip = true
759
+ }
760
+ } catch (_) {
761
+ // ignore parse failures but do not skip by default
762
+ }
763
+ if (skip) {
764
+ return
765
+ }
766
+ if (entryByUrl.has(canonical)) {
767
+ const existing = entryByUrl.get(canonical)
768
+ if (opts && opts.qr === true) existing.qr = true
769
+ return
770
+ }
771
+ const entry = {
772
+ type,
773
+ label,
774
+ url: canonical,
775
+ display: formatDisplayUrl(canonical),
776
+ qr: opts && opts.qr === true
777
+ }
778
+ entryByUrl.set(canonical, entry)
779
+ entries.push(entry)
780
+ }
781
+
782
+ if (isHttpUrl(baseHref)) {
783
+ addEntry("http", "HTTP", baseHref, { allowSameOrigin: true })
784
+ } else if (isHttpsUrl(baseHref)) {
785
+ addEntry("https", "HTTPS", baseHref, { allowSameOrigin: true })
786
+ } else {
787
+ addEntry("url", "URL", baseHref, { allowSameOrigin: true })
788
+ }
789
+
790
+ const httpCandidates = new Map() // url -> { qr: boolean }
791
+ const httpsCandidates = new Set()
792
+
793
+ if (isHttpUrl(baseHref)) {
794
+ httpCandidates.set(canonicalBase || canonicalizeUrl(baseHref), { qr: false })
795
+ } else if (isHttpsUrl(baseHref)) {
796
+ if (canonicalBase) {
797
+ httpsCandidates.add(canonicalBase)
798
+ } else {
799
+ httpsCandidates.add(canonicalizeUrl(baseHref))
800
+ }
801
+ }
802
+
803
+ if (projectSlug) {
804
+ try {
805
+ const baseUrl = parsedBaseUrl || new URL(baseHref, location.origin)
806
+ let pathname = baseUrl.pathname || "/"
807
+ if (pathname.endsWith("/index.html")) {
808
+ pathname = pathname.slice(0, -"/index.html".length)
809
+ }
810
+ if (!pathname.endsWith("/")) {
811
+ pathname = `${pathname}/`
812
+ }
813
+ const normalizedPath = pathname.toLowerCase()
814
+ if (normalizedPath.includes(`/asset/api/${projectSlug}`)) {
815
+ const fallbackHttp = `http://127.0.0.1:42000${pathname}`
816
+ httpCandidates.set(canonicalizeUrl(fallbackHttp), { qr: false })
817
+ } else if (normalizedPath.includes(`/api/${projectSlug}`)) {
818
+ const fallbackHttp = `http://127.0.0.1:42000/asset/api/${projectSlug}/`
819
+ httpCandidates.set(canonicalizeUrl(fallbackHttp), { qr: false })
820
+ }
821
+ } catch (_) {
822
+ // ignore fallback errors
823
+ }
824
+ }
825
+
826
+ const scriptKeys = collectScriptKeys(sourceLink)
827
+ if (scriptKeys.length > 0) {
828
+ const localInfo = await ensureLocalMemory()
829
+ scriptKeys.forEach((key) => {
830
+ if (!key) {
831
+ return
832
+ }
833
+ const local = localInfo ? localInfo[key] : undefined
834
+ if (!local) {
835
+ return
836
+ }
837
+ const urls = collectUrlsFromLocal(local)
838
+ urls.forEach((value) => {
839
+ const canonical = canonicalizeUrl(value)
840
+ if (isHttpsUrl(canonical)) {
841
+ httpsCandidates.add(canonical)
842
+ } else if (isHttpUrl(canonical)) {
843
+ const prev = httpCandidates.get(canonical)
844
+ httpCandidates.set(canonical, { qr: prev ? prev.qr === true : false })
845
+ }
846
+ })
847
+ })
848
+ }
849
+
850
+ const routerData = await ensureRouterInfoMapping()
851
+ if (httpCandidates.size > 0) {
852
+ Array.from(httpCandidates.keys()).forEach((httpUrl) => {
853
+ const mapped = collectHttpsUrlsFromRouter(httpUrl, routerData)
854
+ mapped.forEach((httpsUrl) => {
855
+ httpsCandidates.add(httpsUrl)
856
+ })
857
+ })
858
+ }
859
+
860
+ // Add external 192.168.* http host:port candidates mapped from the same internal port as base HTTP
861
+ try {
862
+ const base = parsedBaseUrl || new URL(baseHref, location.origin)
863
+ let basePort = base.port
864
+ if (!basePort) {
865
+ basePort = base.protocol.toLowerCase() === 'https:' ? '443' : '80'
866
+ }
867
+ const samePortHosts = routerData && routerData.externalHttpByIntPort ? routerData.externalHttpByIntPort.get(basePort) : null
868
+ if (samePortHosts && samePortHosts.size > 0) {
869
+ samePortHosts.forEach((hostport) => {
870
+ try {
871
+ const hpUrl = `http://${hostport}${base.pathname || '/'}${base.search || ''}`
872
+ const canonical = canonicalizeUrl(hpUrl)
873
+ if (isHttpUrl(canonical)) {
874
+ const prev = httpCandidates.get(canonical)
875
+ httpCandidates.set(canonical, { qr: true || (prev ? prev.qr === true : false) })
876
+ }
877
+ } catch (_) {}
878
+ })
879
+ }
880
+ } catch (_) {}
881
+
882
+ const httpsList = Array.from(httpsCandidates).sort()
883
+
884
+ if (httpsList.length > 0) {
885
+ httpsList.forEach((url) => {
886
+ try {
887
+ const parsed = new URL(url)
888
+ if (parsed.protocol.toLowerCase() !== "https:") {
889
+ return
890
+ }
891
+ if (!parsed.port || parsed.port !== "42000") {
892
+ return
893
+ }
894
+ const hostPort = parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname
895
+ const httpUrl = `http://${hostPort}${parsed.pathname || "/"}${parsed.search || ""}`
896
+ const key = canonicalizeUrl(httpUrl)
897
+ const prev = httpCandidates.get(key)
898
+ httpCandidates.set(key, { qr: prev ? prev.qr === true : false })
899
+ } catch (_) {
900
+ // ignore failures
901
+ }
902
+ })
903
+ }
904
+
905
+ const httpList = Array.from(httpCandidates.keys()).sort()
906
+
907
+ httpList.forEach((url) => {
908
+ const meta = httpCandidates.get(url) || { qr: false }
909
+ addEntry("http", "HTTP", url, { qr: meta.qr === true })
910
+ })
911
+ httpsList.forEach((url) => {
912
+ addEntry("https", "HTTPS", url)
913
+ })
914
+
915
+ const matchesBasePort = (value) => {
916
+ if (!basePortNormalized) {
917
+ return true
918
+ }
919
+ try {
920
+ const parsed = new URL(value, location.origin)
921
+ let port = parsed.port
922
+ if (!port) {
923
+ const proto = parsed.protocol ? parsed.protocol.toLowerCase() : "http:"
924
+ port = proto === "https:" ? "443" : "80"
925
+ }
926
+ return port === basePortNormalized
927
+ } catch (_) {
928
+ return false
929
+ }
930
+ }
931
+
932
+ const shouldAddPeerFallback = !skipPeerFallback && (sameOrigin || forceCanonicalQr)
933
+ if (shouldAddPeerFallback) {
934
+ try {
935
+ const peerInfo = await ensurePeerInfo()
936
+ const peerHost = peerInfo && typeof peerInfo.host === "string" ? peerInfo.host.trim() : ""
937
+ if (peerHost) {
938
+ const peerHostLower = peerHost.toLowerCase()
939
+ if (peerHostLower !== "localhost" && !peerHostLower.startsWith("127.")) {
940
+ const baseUrl = parsedBaseUrl || new URL(baseHref, location.origin)
941
+ const baseHostLower = (baseUrl.hostname || "").toLowerCase()
942
+ if (peerHostLower !== baseHostLower) {
943
+ const baseProtocol = baseUrl.protocol ? baseUrl.protocol.toLowerCase() : "http:"
944
+ const scheme = baseProtocol === "https:" ? "https://" : "http://"
945
+ const port = baseUrl.port || (baseProtocol === "https:" ? "443" : "80")
946
+ const hostPort = port ? `${peerHostLower}:${port}` : peerHostLower
947
+ const pathSegment = baseUrl.pathname || "/"
948
+ const searchSegment = baseUrl.search || ""
949
+ const fallbackUrl = `${scheme}${hostPort}${pathSegment}${searchSegment}`
950
+ const label = baseProtocol === "https:" ? "HTTPS" : "HTTP"
951
+ addEntry(baseProtocol === "https:" ? "https" : "http", label, fallbackUrl, { qr: true })
952
+ }
953
+ }
954
+ }
955
+ } catch (_) {}
956
+ }
957
+
958
+ if (sameOrigin) {
959
+
960
+ const filteredEntries = entries.filter((entry) => {
961
+ if (!entry || !entry.url) {
962
+ return false
963
+ }
964
+ if (entry.url === canonicalBase) {
965
+ return true
966
+ }
967
+ if (entry.qr === true) {
968
+ return matchesBasePort(entry.url)
969
+ }
970
+ return false
971
+ })
972
+ if (filteredEntries.length > 0) {
973
+ return filteredEntries
974
+ }
975
+ }
976
+
977
+ return entries
978
+ }
979
+
980
+ const positionTabLinkPopover = (popover, link) => {
981
+ if (!popover || !link) {
982
+ return
983
+ }
984
+ const rect = link.getBoundingClientRect()
985
+ const minWidth = Math.max(rect.width, 260)
986
+ popover.style.minWidth = `${Math.round(minWidth)}px`
987
+ popover.style.display = "flex"
988
+ popover.classList.add("visible")
989
+ popover.style.visibility = "hidden"
990
+
991
+ const popoverWidth = popover.offsetWidth
992
+ const popoverHeight = popover.offsetHeight
993
+
994
+ let left = rect.left
995
+ let top = rect.bottom + 8
996
+
997
+ if (left + popoverWidth > window.innerWidth - 12) {
998
+ left = window.innerWidth - popoverWidth - 12
999
+ }
1000
+ if (left < 12) {
1001
+ left = 12
1002
+ }
1003
+
1004
+ if (top + popoverHeight > window.innerHeight - 12) {
1005
+ top = Math.max(12, rect.top - popoverHeight - 8)
1006
+ }
1007
+
1008
+ popover.style.left = `${Math.round(left)}px`
1009
+ popover.style.top = `${Math.round(top)}px`
1010
+ popover.style.visibility = ""
1011
+ }
1012
+
1013
+ const hideTabLinkPopover = ({ immediate = false } = {}) => {
1014
+ const applyHide = () => {
1015
+ if (tabLinkPopoverEl) {
1016
+ tabLinkPopoverEl.classList.remove("visible")
1017
+ tabLinkPopoverEl.style.display = "none"
1018
+ }
1019
+ tabLinkActiveLink = null
1020
+ tabLinkPendingLink = null
1021
+ tabLinkHideTimer = null
1022
+ }
1023
+
1024
+ if (tabLinkHideTimer) {
1025
+ clearTimeout(tabLinkHideTimer)
1026
+ tabLinkHideTimer = null
1027
+ }
1028
+
1029
+ if (immediate) {
1030
+ applyHide()
1031
+ } else {
1032
+ tabLinkHideTimer = setTimeout(applyHide, 120)
1033
+ }
1034
+ }
1035
+
1036
+ const renderTabLinkPopover = async (link, options = {}) => {
1037
+ const hrefOverride = typeof options.hrefOverride === 'string' ? options.hrefOverride.trim() : ''
1038
+ const effectiveHref = hrefOverride || (link && link.href) || ''
1039
+ if (!link || !effectiveHref) {
1040
+ hideTabLinkPopover({ immediate: true })
1041
+ return
1042
+ }
1043
+
1044
+ const requireAlternate = options && options.requireAlternate === false ? false : true
1045
+ const restrictToBase = options && options.restrictToBase === true
1046
+ const forceCanonicalQr = options && options.forceCanonicalQr === true
1047
+ let sameOrigin = false
1048
+ let canonicalBase = canonicalizeUrl(effectiveHref)
1049
+ if (canonicalBase && isHttpUrl(canonicalBase)) {
1050
+ canonicalBase = ensureHttpDirectoryUrl(canonicalBase)
1051
+ }
1052
+ let basePortNormalized = ""
1053
+ try {
1054
+ const linkUrl = new URL(effectiveHref, location.href)
1055
+ sameOrigin = linkUrl.origin === location.origin
1056
+ canonicalBase = canonicalizeUrl(linkUrl.href)
1057
+ if (canonicalBase && isHttpUrl(canonicalBase)) {
1058
+ canonicalBase = ensureHttpDirectoryUrl(canonicalBase)
1059
+ }
1060
+ basePortNormalized = linkUrl.port
1061
+ if (!basePortNormalized) {
1062
+ const proto = linkUrl.protocol ? linkUrl.protocol.toLowerCase() : "http:"
1063
+ basePortNormalized = proto === "https:" ? "443" : "80"
1064
+ }
1065
+ } catch (_) {
1066
+ hideTabLinkPopover({ immediate: true })
1067
+ return
1068
+ }
1069
+
1070
+ const matchesBasePort = (value) => {
1071
+ if (!basePortNormalized) {
1072
+ return true
1073
+ }
1074
+ try {
1075
+ const parsed = new URL(value, location.origin)
1076
+ let port = parsed.port
1077
+ if (!port) {
1078
+ const proto = parsed.protocol ? parsed.protocol.toLowerCase() : "http:"
1079
+ port = proto === "https:" ? "443" : "80"
1080
+ }
1081
+ return port === basePortNormalized
1082
+ } catch (_) {
1083
+ return false
1084
+ }
1085
+ }
1086
+
1087
+ if (tabLinkActiveLink === link && tabLinkPopoverEl && tabLinkPopoverEl.classList.contains("visible")) {
1088
+ if (tabLinkHideTimer) {
1089
+ clearTimeout(tabLinkHideTimer)
1090
+ tabLinkHideTimer = null
1091
+ }
1092
+ return
1093
+ }
1094
+
1095
+ if (tabLinkPendingLink === link && tabLinkPopoverEl && tabLinkPopoverEl.classList.contains("visible")) {
1096
+ return
1097
+ }
1098
+
1099
+ tabLinkPendingLink = link
1100
+ if (tabLinkHideTimer) {
1101
+ clearTimeout(tabLinkHideTimer)
1102
+ tabLinkHideTimer = null
1103
+ }
1104
+
1105
+ // Show lightweight loading popover immediately while mapping fetch runs
1106
+ try {
1107
+ const pop = ensureTabLinkPopoverEl()
1108
+ pop.innerHTML = ''
1109
+ const header = document.createElement('div')
1110
+ header.className = 'tab-link-popover-header'
1111
+ header.innerHTML = `<i class="fa-solid fa-arrow-up-right-from-square"></i><span>Open in browser</span>`
1112
+ const item = document.createElement('div')
1113
+ item.className = 'tab-link-popover-item'
1114
+ const label = document.createElement('span')
1115
+ label.className = 'label'
1116
+ label.textContent = 'Loading…'
1117
+ const value = document.createElement('span')
1118
+ value.className = 'value muted'
1119
+ value.textContent = 'Discovering routes'
1120
+ item.append(label, value)
1121
+ pop.append(header, item)
1122
+ positionTabLinkPopover(pop, link)
1123
+ } catch (_) {}
1124
+
1125
+ let entries
1126
+ try {
1127
+ entries = await buildTabLinkEntries(link, effectiveHref, {
1128
+ forceCanonicalQr,
1129
+ allowQrPortMismatch: restrictToBase && options && options.allowQrPortMismatch === true,
1130
+ skipPeerFallback: options && options.skipPeerFallback === true
1131
+ })
1132
+ } catch (error) {
1133
+ tabLinkPendingLink = null
1134
+ console.error('[tab-link-popover] failed to build entries', error)
1135
+ hideTabLinkPopover({ immediate: true })
1136
+ return
1137
+ }
1138
+
1139
+ if (tabLinkPendingLink !== link) {
1140
+ return
1141
+ }
1142
+
1143
+ if (sameOrigin) {
1144
+ const slug = extractProjectSlug(link).toLowerCase()
1145
+ if (slug) {
1146
+ entries = entries.filter((entry) => {
1147
+ if (!entry || !entry.url) {
1148
+ return false
1149
+ }
1150
+ if (entry.url === canonicalBase) {
1151
+ return true
1152
+ }
1153
+ if (entry.qr === true) {
1154
+ return matchesBasePort(entry.url)
1155
+ }
1156
+ try {
1157
+ const parsed = new URL(entry.url)
1158
+ const hostLower = parsed.hostname ? parsed.hostname.toLowerCase() : ""
1159
+ if (isLocalHostLike(hostLower)) {
1160
+ if (entry.type === "http") {
1161
+ const pathLower = parsed.pathname ? parsed.pathname.toLowerCase() : ""
1162
+ if (pathLower.includes(`/asset/api/${slug}`) || pathLower.includes(`/p/${slug}`)) {
1163
+ return true
1164
+ }
1165
+ }
1166
+ return false
1167
+ }
1168
+ const pathLower = parsed.pathname ? parsed.pathname.toLowerCase() : ""
1169
+ if (pathLower.includes(`/asset/api/${slug}`)) {
1170
+ return true
1171
+ }
1172
+ if (pathLower.includes(`/p/${slug}`)) {
1173
+ return true
1174
+ }
1175
+ if (hostLower.split(".").some((part) => part === slug)) {
1176
+ return true
1177
+ }
1178
+ } catch (_) {
1179
+ return false
1180
+ }
1181
+ return false
1182
+ })
1183
+ } else {
1184
+ entries = entries.filter((entry) => {
1185
+ if (!entry || !entry.url) {
1186
+ return false
1187
+ }
1188
+ if (entry.url === canonicalBase) {
1189
+ return true
1190
+ }
1191
+ return false
1192
+ })
1193
+ }
1194
+
1195
+ entries = entries.filter((entry) => {
1196
+ if (!entry || !entry.url) {
1197
+ return false
1198
+ }
1199
+ if (entry.url === canonicalBase) {
1200
+ return true
1201
+ }
1202
+ if (entry.qr === true) {
1203
+ return matchesBasePort(entry.url)
1204
+ }
1205
+ return false
1206
+ })
1207
+
1208
+ }
1209
+
1210
+ const allowQrMismatch = options && options.allowQrPortMismatch === true
1211
+ if (restrictToBase) {
1212
+ entries = entries.filter((entry) => {
1213
+ if (!entry || !entry.url) {
1214
+ return false
1215
+ }
1216
+ if (canonicalBase && entry.url === canonicalBase) {
1217
+ return true
1218
+ }
1219
+ if (entry.qr === true) {
1220
+ return allowQrMismatch || matchesBasePort(entry.url)
1221
+ }
1222
+ return false
1223
+ })
1224
+ }
1225
+
1226
+ if (forceCanonicalQr && canonicalBase) {
1227
+ try {
1228
+ const canonicalHost = new URL(canonicalBase).hostname.toLowerCase()
1229
+ const locationHost = (location.hostname || '').toLowerCase()
1230
+ const isLoopbackHost = canonicalHost === 'localhost' || canonicalHost === '0.0.0.0' || canonicalHost.startsWith('127.')
1231
+ if (!isLoopbackHost && isLocalHostLike(canonicalHost) && canonicalHost !== locationHost) {
1232
+ entries.forEach((entry) => {
1233
+ if (entry && entry.url === canonicalBase && entry.type === 'http') {
1234
+ entry.qr = true
1235
+ }
1236
+ })
1237
+ }
1238
+ } catch (_) {}
1239
+ }
1240
+
1241
+ if (!entries || entries.length === 0) {
1242
+ hideTabLinkPopover({ immediate: true })
1243
+ return
1244
+ }
1245
+
1246
+ const hasAlternate = entries.some((entry) => entry && entry.url && entry.url !== canonicalBase)
1247
+ if (requireAlternate && !hasAlternate) {
1248
+ console.debug('[tab-link-popover] no alternate routes for', effectiveHref)
1249
+ hideTabLinkPopover({ immediate: true })
1250
+ return
1251
+ }
1252
+
1253
+ const popover = ensureTabLinkPopoverEl()
1254
+ popover.innerHTML = ""
1255
+
1256
+ const header = document.createElement("div")
1257
+ header.className = "tab-link-popover-header"
1258
+ header.innerHTML = `<i class="fa-solid fa-arrow-up-right-from-square"></i><span>Open in browser</span>`
1259
+ popover.appendChild(header)
1260
+
1261
+ const hasHttpsEntry = entries.some((entry) => entry && entry.type === "https")
1262
+
1263
+ entries.forEach((entry) => {
1264
+ const item = document.createElement("button")
1265
+ item.type = "button"
1266
+ item.setAttribute("data-url", entry.url)
1267
+ const labelSpan = document.createElement("span")
1268
+ labelSpan.className = "label"
1269
+ labelSpan.textContent = entry.label
1270
+ const valueSpan = document.createElement("span")
1271
+ valueSpan.className = "value"
1272
+ valueSpan.textContent = entry.display
1273
+
1274
+ if (entry.type === 'http' && entry.qr === true) {
1275
+ item.className = "tab-link-popover-item qr-inline"
1276
+ const textCol = document.createElement('div')
1277
+ textCol.className = 'textcol'
1278
+ textCol.append(labelSpan, valueSpan)
1279
+ const qrImg = document.createElement('img')
1280
+ qrImg.className = 'qr'
1281
+ qrImg.alt = 'QR'
1282
+ qrImg.decoding = 'async'
1283
+ qrImg.loading = 'lazy'
1284
+ qrImg.src = `/qr?data=${encodeURIComponent(entry.url)}&s=4&m=0`
1285
+ item.append(textCol, qrImg)
1286
+ } else {
1287
+ item.className = "tab-link-popover-item"
1288
+ // Keep label and value as direct children so column layout applies
1289
+ item.append(labelSpan, valueSpan)
1290
+ }
1291
+ popover.appendChild(item)
1292
+ })
1293
+
1294
+ if (tabLinkRouterHttpsActive === false && !hasHttpsEntry) {
1295
+ const footerButton = document.createElement("button")
1296
+ footerButton.type = "button"
1297
+ footerButton.className = "tab-link-popover-item tab-link-popover-footer"
1298
+ footerButton.setAttribute("data-url", "/network")
1299
+ footerButton.setAttribute("data-target", "_self")
1300
+ footerButton.setAttribute("aria-label", "Open network settings to configure local HTTPS")
1301
+
1302
+ const footerLabel = document.createElement("span")
1303
+ footerLabel.className = "label"
1304
+ footerLabel.textContent = "Custom domain not active"
1305
+
1306
+ const footerValue = document.createElement("span")
1307
+ footerValue.className = "value"
1308
+ footerValue.textContent = "Click to activate"
1309
+
1310
+ footerButton.append(footerLabel, footerValue)
1311
+ popover.appendChild(footerButton)
1312
+ }
1313
+
1314
+ tabLinkActiveLink = link
1315
+ tabLinkPendingLink = null
1316
+ positionTabLinkPopover(popover, link)
1317
+ }
1318
+
1319
+ const setupTabLinkHover = () => {
1320
+ const container = document.querySelector(".appcanvas > aside .menu-container")
1321
+ if (!container) {
1322
+ return
1323
+ }
1324
+
1325
+ container.addEventListener("mouseover", (event) => {
1326
+ const link = event.target.closest(".frame-link")
1327
+ if (!link || !container.contains(link)) {
1328
+ return
1329
+ }
1330
+ renderTabLinkPopover(link)
1331
+ })
1332
+
1333
+ container.addEventListener("mouseout", (event) => {
1334
+ const origin = event.target.closest(".frame-link")
1335
+ if (!origin || !container.contains(origin)) {
1336
+ return
1337
+ }
1338
+ const related = event.relatedTarget
1339
+ const popover = tabLinkPopoverEl || document.getElementById(TAB_LINK_POPOVER_ID)
1340
+ if (related && (origin.contains(related) || (popover && popover.contains(related)))) {
1341
+ return
1342
+ }
1343
+ hideTabLinkPopover()
1344
+ })
1345
+ }
1346
+
1347
+ const handleGlobalPointer = (event) => {
1348
+ if (!tabLinkPopoverEl || !tabLinkPopoverEl.classList.contains("visible")) {
1349
+ return
1350
+ }
1351
+ if (tabLinkPopoverEl.contains(event.target)) {
1352
+ return
1353
+ }
1354
+ if (tabLinkActiveLink && tabLinkActiveLink.contains(event.target)) {
1355
+ return
1356
+ }
1357
+ hideTabLinkPopover({ immediate: true })
1358
+ }
1359
+
1360
+ window.addEventListener("scroll", () => {
1361
+ if (tabLinkPopoverEl && tabLinkPopoverEl.classList.contains("visible")) {
1362
+ hideTabLinkPopover({ immediate: true })
1363
+ }
1364
+ }, true)
1365
+
1366
+ window.addEventListener("resize", () => {
1367
+ if (tabLinkPopoverEl && tabLinkPopoverEl.classList.contains("visible") && tabLinkActiveLink) {
1368
+ positionTabLinkPopover(tabLinkPopoverEl, tabLinkActiveLink)
1369
+ }
1370
+ })
1371
+
1372
+ document.addEventListener("mousedown", handleGlobalPointer, true)
1373
+ try {
1374
+ document.addEventListener("touchstart", handleGlobalPointer, { passive: true, capture: true })
1375
+ } catch (_) {
1376
+ document.addEventListener("touchstart", handleGlobalPointer, true)
1377
+ }
1378
+
1379
+ if (typeof window !== 'undefined') {
1380
+ window.renderTabLinkPopover = renderTabLinkPopover
1381
+ window.hideTabLinkPopover = hideTabLinkPopover
1382
+ window.setupTabLinkHover = setupTabLinkHover
1383
+ window.PinokioTabLinkPopover = Object.freeze({
1384
+ renderTabLinkPopover,
1385
+ hideTabLinkPopover,
1386
+ setupTabLinkHover,
1387
+ isLocalHostLike,
1388
+ canonicalizeUrl,
1389
+ ensureHttpDirectoryUrl
1390
+ })
1391
+ }