pinokiod 3.231.0 → 3.232.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.
- package/kernel/bin/cuda.js +83 -76
- package/kernel/bin/setup.js +2 -0
- package/kernel/prototype.js +13 -0
- package/kernel/router/index.js +41 -0
- package/package.json +1 -1
- package/server/index.js +92 -31
- package/server/public/container-tab-link.js +115 -0
- package/server/public/style.css +4 -0
- package/server/public/tab-link-popover.css +118 -0
- package/server/public/tab-link-popover.js +1391 -0
- package/server/views/app.ejs +79 -1626
- package/server/views/container.ejs +3 -0
- package/server/views/index.ejs +6 -0
- package/server/views/net.ejs +449 -8
- package/server/views/network.ejs +5 -3
- package/server/views/partials/dynamic.ejs +1 -1
- package/server/views/partials/menu.ejs +1 -1
- package/server/views/partials/running.ejs +1 -1
|
@@ -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
|
+
}
|