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.
@@ -860,124 +860,6 @@ body.dark .grid-btns .btn2 {
860
860
  flex: 1 1 auto;
861
861
  min-width: 0;
862
862
  }
863
- .tab-link-popover {
864
- position: fixed;
865
- display: none;
866
- flex-direction: column;
867
- gap: 4px;
868
- padding: 8px 0;
869
- border-radius: 10px;
870
- border: 1px solid rgba(0, 0, 0, 0.12);
871
- background: rgba(255, 255, 255, 0.97);
872
- box-shadow: 0 12px 32px -8px rgba(15, 23, 42, 0.25);
873
- z-index: 9999;
874
- min-width: 240px;
875
- max-width: 420px;
876
- backdrop-filter: blur(6px);
877
- }
878
- body.dark .tab-link-popover {
879
- border-color: rgba(255, 255, 255, 0.08);
880
- background: rgba(17, 24, 39, 0.95);
881
- box-shadow: 0 12px 36px -12px rgba(15, 23, 42, 0.55);
882
- }
883
- .tab-link-popover.visible {
884
- display: flex;
885
- }
886
- .tab-link-popover .tab-link-popover-header {
887
- display: flex;
888
- align-items: center;
889
- gap: 8px;
890
- padding: 10px 14px 6px;
891
- font-size: 11px;
892
- font-weight: 600;
893
- letter-spacing: 0.05em;
894
- text-transform: uppercase;
895
- color: rgba(15, 23, 42, 0.55);
896
- }
897
- .tab-link-popover .tab-link-popover-header i {
898
- font-size: 12px;
899
- }
900
- body.dark .tab-link-popover .tab-link-popover-header {
901
- color: rgba(226, 232, 240, 0.7);
902
- }
903
- .tab-link-popover .tab-link-popover-item {
904
- width: 100%;
905
- border: none;
906
- margin: 0;
907
- padding: 8px 14px;
908
- display: flex;
909
- flex-direction: column;
910
- gap: 2px;
911
- text-align: left;
912
- font: inherit;
913
- color: inherit;
914
- background: transparent;
915
- cursor: pointer;
916
- }
917
- .tab-link-popover .tab-link-popover-item.qr-inline { flex-direction: row; align-items: center; gap: 10px; }
918
- .tab-link-popover .tab-link-popover-item.qr-inline .textcol { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 auto; }
919
- .tab-link-popover .tab-link-popover-item.qr-inline .qr { width: 64px; height: 64px; image-rendering: pixelated; flex: 0 0 auto; margin-left: auto; }
920
- .tab-link-popover .tab-link-popover-item:hover,
921
- .tab-link-popover .tab-link-popover-item:focus-visible {
922
- background: rgba(15, 23, 42, 0.06);
923
- outline: none;
924
- }
925
- body.dark .tab-link-popover .tab-link-popover-item:hover,
926
- body.dark .tab-link-popover .tab-link-popover-item:focus-visible {
927
- background: rgba(148, 163, 184, 0.12);
928
- }
929
- .tab-link-popover .tab-link-popover-item .label {
930
- font-size: 11px;
931
- font-weight: 600;
932
- letter-spacing: 0.04em;
933
- text-transform: uppercase;
934
- color: rgba(15, 23, 42, 0.55);
935
- }
936
- body.dark .tab-link-popover .tab-link-popover-item .label {
937
- color: rgba(226, 232, 240, 0.65);
938
- }
939
- .tab-link-popover .tab-link-popover-item .value {
940
- font-size: 13px;
941
- line-height: 1.35;
942
- word-break: break-word;
943
- color: rgba(15, 23, 42, 0.85);
944
- }
945
- body.dark .tab-link-popover .tab-link-popover-item .value {
946
- color: rgba(226, 232, 240, 0.9);
947
- }
948
- .tab-link-popover .tab-link-popover-item .value .muted {
949
- color: rgba(15, 23, 42, 0.55);
950
- }
951
- body.dark .tab-link-popover .tab-link-popover-item .value .muted {
952
- color: rgba(226, 232, 240, 0.65);
953
- }
954
- .tab-link-popover .tab-link-popover-footer {
955
- border-top: 1px solid rgba(15, 23, 42, 0.08);
956
- margin-top: 4px;
957
- padding-top: 12px;
958
- background: rgba(59, 130, 246, 0.12);
959
- color: #1d4ed8;
960
- }
961
- .tab-link-popover .tab-link-popover-footer .label,
962
- .tab-link-popover .tab-link-popover-footer .value {
963
- color: inherit;
964
- }
965
- .tab-link-popover .tab-link-popover-footer .value {
966
- font-weight: 600;
967
- }
968
- .tab-link-popover .tab-link-popover-footer:hover,
969
- .tab-link-popover .tab-link-popover-footer:focus-visible {
970
- background: rgba(37, 99, 235, 0.2);
971
- }
972
- body.dark .tab-link-popover .tab-link-popover-footer {
973
- border-top-color: rgba(148, 163, 184, 0.2);
974
- background: rgba(96, 165, 250, 0.22);
975
- color: #bfdbfe;
976
- }
977
- body.dark .tab-link-popover .tab-link-popover-footer:hover,
978
- body.dark .tab-link-popover .tab-link-popover-footer:focus-visible {
979
- background: rgba(147, 197, 253, 0.35);
980
- }
981
863
  .tab.has-preview .tab-preview {
982
864
  color: rgba(0, 0, 0, 0.6);
983
865
  min-width: 0;
@@ -3058,6 +2940,7 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
3058
2940
  */
3059
2941
  </style>
3060
2942
  <link href="/app.css" rel="stylesheet"/>
2943
+ <link href="/tab-link-popover.css" rel="stylesheet"/>
3061
2944
  <script src="/window_storage.js"></script>
3062
2945
  <script src="/timeago.min.js"></script>
3063
2946
  <script src="/hotkeys.min.js"></script>
@@ -3081,6 +2964,7 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
3081
2964
  <script src="/fseditor.js"></script>
3082
2965
  <script src="/popper.min.js"></script>
3083
2966
  <script src="/tippy-bundle.umd.min.js"></script>
2967
+ <script src="/tab-link-popover.js"></script>
3084
2968
  </head>
3085
2969
  <body class='<%=theme%>' data-platform="<%=platform%>" data-agent="<%=agent%>">
3086
2970
  <header class='navheader grabbable'>
@@ -3113,7 +2997,7 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
3113
2997
  <div><i class="fa-solid fa-plus"></i></div>
3114
2998
  </button>
3115
2999
  <button class='btn2 hidden' id='close-window' data-tippy-content='close this section'>
3116
- <div><i class="fa-solid fa-xmark"></i></div>
3000
+ <div><i class="fa-solid fa-stop"></i></div>
3117
3001
  </button>
3118
3002
  </h1>
3119
3003
  </header>
@@ -3128,23 +3012,6 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
3128
3012
  <% } else { %>
3129
3013
  <% if (type !== 'files') { %>
3130
3014
  <aside class='active'>
3131
- <!--
3132
- <div class='header-top header-item'>
3133
- <% if (type === "run") { %>
3134
- <div class='app-info'>
3135
- <div class='app-info-card'>
3136
- <% if (config.icon) { %>
3137
- <img src="<%=config.icon%>" onerror="this.src='/pinokio-black.png'"/>
3138
- <% } %>
3139
- <div class='app-info-container'>
3140
- <div class='app-info-title'><%=config.title%></div>
3141
- <div class='app-info-description collapsed'><%=config.description%></div>
3142
- </div>
3143
- </div>
3144
- </div>
3145
- <% } %>
3146
- </div>
3147
- -->
3148
3015
  <div class='menu-container'>
3149
3016
  <div class='m n system' data-type="n">
3150
3017
  <%if (type==='browse') { %>
@@ -3155,9 +3022,6 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
3155
3022
  <% } %>
3156
3023
  <div><%=config.title%></div>
3157
3024
  </div>
3158
- <!--
3159
- <div class='loader'><i class='fa-solid fa-angle-right'></i></div>
3160
- -->
3161
3025
  </a>
3162
3026
 
3163
3027
  <div class="dynamic <%=type==='run' ? '' : 'selected'%>">
@@ -3428,1426 +3292,98 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
3428
3292
  const updatedClass = "tab-updated"
3429
3293
  const tabStateStore = new Map()
3430
3294
  const TAB_STATE_STORAGE_KEY = "tab-state-store"
3431
- const TAB_LINK_POPOVER_ID = "tab-link-popover"
3432
- let tabLinkPopoverEl = null
3433
- let tabLinkActiveLink = null
3434
- let tabLinkPendingLink = null
3435
- let tabLinkHideTimer = null
3436
- let tabLinkLocalInfoPromise = null
3437
- let tabLinkLocalInfoExpiry = 0
3438
- let tabLinkRouterInfoPromise = null
3439
- let tabLinkRouterInfoExpiry = 0
3440
- let tabLinkRouterHttpsActive = null
3441
- let tabLinkPeerInfoPromise = null
3442
- let tabLinkPeerInfoExpiry = 0
3443
-
3444
- const ensureTabLinkPopoverEl = () => {
3445
- if (!tabLinkPopoverEl) {
3446
- tabLinkPopoverEl = document.createElement("div")
3447
- tabLinkPopoverEl.id = TAB_LINK_POPOVER_ID
3448
- tabLinkPopoverEl.className = "tab-link-popover"
3449
- tabLinkPopoverEl.addEventListener("mouseenter", () => {
3450
- if (tabLinkHideTimer) {
3451
- clearTimeout(tabLinkHideTimer)
3452
- tabLinkHideTimer = null
3453
- }
3454
- })
3455
- tabLinkPopoverEl.addEventListener("mouseleave", () => {
3456
- hideTabLinkPopover({ immediate: true })
3457
- })
3458
- tabLinkPopoverEl.addEventListener("click", (event) => {
3459
- const item = event.target.closest(".tab-link-popover-item")
3460
- if (!item) {
3461
- return
3462
- }
3463
- event.preventDefault()
3464
- event.stopPropagation()
3465
- const url = item.getAttribute("data-url")
3466
- if (url) {
3467
- const targetMode = (item.getAttribute("data-target") || "_blank").toLowerCase()
3468
- if (targetMode === "_self") {
3469
- window.location.assign(url)
3470
- } else {
3471
- window.open(url, "_blank", "noopener")
3472
- }
3473
- }
3474
- hideTabLinkPopover({ immediate: true })
3475
- })
3476
- document.body.appendChild(tabLinkPopoverEl)
3477
- }
3478
- return tabLinkPopoverEl
3479
- }
3480
-
3481
- const ensurePeerInfo = async () => {
3482
- const now = Date.now()
3483
- if (!tabLinkPeerInfoPromise || now > tabLinkPeerInfoExpiry) {
3484
- tabLinkPeerInfoPromise = fetch("/pinokio/peer", {
3485
- method: "GET",
3486
- headers: {
3487
- "Accept": "application/json"
3488
- }
3489
- })
3490
- .then((response) => {
3491
- if (!response.ok) {
3492
- throw new Error("Failed to load peer info")
3493
- }
3494
- return response.json()
3495
- })
3496
- .catch(() => null)
3497
- tabLinkPeerInfoExpiry = now + 3000
3498
- }
3499
- return tabLinkPeerInfoPromise
3500
- }
3501
-
3502
- const canonicalizeUrl = (value) => {
3503
- try {
3504
- const parsed = new URL(value, location.origin)
3505
- if (!parsed.protocol) {
3506
- return value
3507
- }
3508
- const protocol = parsed.protocol.toLowerCase()
3509
- if (protocol !== "http:" && protocol !== "https:") {
3510
- return value
3511
- }
3512
- const hostname = parsed.hostname.toLowerCase()
3513
- const port = parsed.port ? `:${parsed.port}` : ""
3514
- let pathname = parsed.pathname || "/"
3515
- if (pathname !== "/") {
3516
- pathname = pathname.replace(/\/+/g, "/")
3517
- if (pathname.length > 1 && pathname.endsWith("/")) {
3518
- pathname = pathname.slice(0, -1)
3519
- }
3520
- }
3521
- const search = parsed.search || ""
3522
- return `${protocol}//${hostname}${port}${pathname}${search}`
3523
- } catch (_) {
3524
- return value
3525
- }
3526
- }
3527
-
3528
- const ensureHttpDirectoryUrl = (value) => {
3529
- try {
3530
- const parsed = new URL(value)
3531
- if (parsed.protocol.toLowerCase() !== "http:") {
3532
- return value
3533
- }
3534
- let pathname = parsed.pathname || "/"
3535
- const lastSegment = pathname.split("/").pop() || ""
3536
- const hasExtension = lastSegment.includes(".")
3537
- if (!hasExtension && !pathname.endsWith("/")) {
3538
- pathname = `${pathname}/`
3539
- parsed.pathname = pathname
3540
- }
3541
- parsed.hash = parsed.hash || ""
3542
- parsed.search = parsed.search || ""
3543
- return parsed.toString()
3544
- } catch (_) {
3545
- return value
3546
- }
3547
- }
3548
-
3549
- const isLocalHostLike = (hostname) => {
3550
- if (!hostname) {
3551
- return false
3552
- }
3553
- const hostLower = hostname.toLowerCase()
3554
- if (hostLower === location.hostname.toLowerCase()) {
3555
- return true
3556
- }
3557
- if (hostLower === "localhost" || hostLower === "0.0.0.0") {
3558
- return true
3559
- }
3560
- if (hostLower.startsWith("127.")) {
3561
- return true
3562
- }
3563
- if (/^\d+\.\d+\.\d+\.\d+$/.test(hostLower)) {
3564
- return true
3295
+ const getWindowStorage = () => {
3296
+ if (typeof windowStorage === "undefined" || !windowStorage) {
3297
+ return null
3565
3298
  }
3566
- return false
3299
+ return windowStorage
3567
3300
  }
3568
-
3569
- const isIPv4Host = (host) => /^(\d{1,3}\.){3}\d{1,3}$/.test((host || '').trim())
3570
-
3571
- const extractProjectSlug = (node) => {
3572
- if (!node) {
3301
+ const frameContextKey = () => {
3302
+ const frameName = window.frameElement?.name || ""
3303
+ if (!frameName) {
3573
3304
  return ""
3574
3305
  }
3575
- const candidates = []
3576
- const targetFull = node.getAttribute("data-target-full")
3577
- if (typeof targetFull === "string" && targetFull.length > 0) {
3578
- candidates.push(targetFull)
3579
- }
3580
- const dataHref = node.getAttribute("href")
3581
- if (typeof dataHref === "string" && dataHref.length > 0) {
3582
- candidates.push(dataHref)
3306
+ const datasetSrc = window.frameElement?.dataset?.src
3307
+ if (typeof datasetSrc === 'string' && datasetSrc.length > 0) {
3308
+ return `${frameName}:${datasetSrc}`
3583
3309
  }
3584
3310
  try {
3585
- const absolute = new URL(node.href, location.origin)
3586
- candidates.push(absolute.pathname)
3587
- } catch (_) {
3588
- // ignore
3589
- }
3590
- for (const candidate of candidates) {
3591
- if (typeof candidate !== "string" || candidate.length === 0) {
3592
- continue
3593
- }
3594
- const assetMatch = candidate.match(/\/asset\/api\/([^\/?#]+)/i)
3595
- if (assetMatch && assetMatch[1]) {
3596
- return assetMatch[1]
3597
- }
3598
- const pageMatch = candidate.match(/\/p\/([^\/?#]+)/i)
3599
- if (pageMatch && pageMatch[1]) {
3600
- return pageMatch[1]
3311
+ const srcUrl = window.frameElement?.src ? new URL(window.frameElement.src, window.location.origin) : null
3312
+ if (srcUrl) {
3313
+ return `${frameName}:${srcUrl.pathname}${srcUrl.search}${srcUrl.hash}`
3601
3314
  }
3602
- }
3603
- return ""
3604
- }
3605
-
3606
- const formatDisplayUrl = (value) => {
3607
- try {
3608
- const parsed = new URL(value, location.origin)
3609
- const host = parsed.host
3610
- const pathname = parsed.pathname || "/"
3611
- const search = parsed.search || ""
3612
- return `${host}${pathname}${search}`
3613
- } catch (_) {
3614
- return value
3615
- }
3616
- }
3617
-
3618
- const isHttpOrHttps = (value) => {
3619
- try {
3620
- const parsed = new URL(value, location.origin)
3621
- const protocol = parsed.protocol.toLowerCase()
3622
- return protocol === "http:" || protocol === "https:"
3623
- } catch (_) {
3624
- return false
3625
- }
3315
+ } catch (_) {}
3316
+ return frameName
3626
3317
  }
3627
-
3628
- const isHttpUrl = (value) => {
3629
- try {
3630
- const parsed = new URL(value, location.origin)
3631
- return parsed.protocol.toLowerCase() === "http:"
3632
- } catch (_) {
3633
- return false
3318
+ const selectionStorageKey = () => {
3319
+ const base = frameContextKey()
3320
+ if (!base) {
3321
+ return ""
3634
3322
  }
3323
+ return `${base}:selector`
3635
3324
  }
3636
-
3637
- const isHttpsUrl = (value) => {
3638
- try {
3639
- const parsed = new URL(value, location.origin)
3640
- return parsed.protocol.toLowerCase() === "https:"
3641
- } catch (_) {
3642
- return false
3325
+ const selectionUrlStorageKey = () => {
3326
+ const base = frameContextKey()
3327
+ if (!base) {
3328
+ return ""
3643
3329
  }
3330
+ return `${base}:url`
3644
3331
  }
3645
-
3646
- const collectUrlsFromLocal = (root) => {
3647
- if (!root || typeof root !== "object") {
3648
- return []
3649
- }
3650
- const queue = [root]
3651
- const visited = new Set()
3652
- const urls = new Set()
3653
- while (queue.length > 0) {
3654
- const current = queue.shift()
3655
- if (!current || typeof current !== "object") {
3656
- continue
3657
- }
3658
- if (visited.has(current)) {
3659
- continue
3660
- }
3661
- visited.add(current)
3662
- const values = Array.isArray(current) ? current : Object.values(current)
3663
- for (const value of values) {
3664
- if (typeof value === "string") {
3665
- if (isHttpOrHttps(value)) {
3666
- urls.add(value)
3667
- }
3668
- } else if (value && typeof value === "object") {
3669
- queue.push(value)
3670
- }
3671
- }
3332
+ const SELECTION_SELECTOR_ATTRS = [
3333
+ 'target',
3334
+ 'data-target-full',
3335
+ 'href',
3336
+ 'data-shell',
3337
+ 'data-script',
3338
+ 'data-action',
3339
+ 'data-run',
3340
+ 'data-command',
3341
+ 'data-filepath'
3342
+ ]
3343
+ const buildFrameLinkSelector = (node) => {
3344
+ if (!node || !node.classList || !node.classList.contains('frame-link')) {
3345
+ return null
3672
3346
  }
3673
- return Array.from(urls)
3674
- }
3675
-
3676
- const collectScriptKeys = (node) => {
3677
- const keys = new Set()
3678
- const scriptAttr = node.getAttribute("data-script")
3679
- if (scriptAttr) {
3680
- const decoded = decodeURIComponent(scriptAttr)
3681
- if (decoded) {
3682
- keys.add(decoded)
3683
- const withoutQuery = decoded.split("?")[0]
3684
- if (withoutQuery) {
3685
- keys.add(withoutQuery)
3686
- }
3347
+ for (const attr of SELECTION_SELECTOR_ATTRS) {
3348
+ const raw = node.getAttribute(attr)
3349
+ if (typeof raw === 'string' && raw.length > 0) {
3350
+ return `.frame-link[${attr}='${escapeTargetSelector(raw)}']`
3687
3351
  }
3688
3352
  }
3689
- const filepathAttr = node.getAttribute("data-filepath")
3690
- if (filepathAttr) {
3691
- keys.add(filepathAttr)
3692
- }
3693
- return Array.from(keys)
3694
- }
3695
-
3696
- const ensureLocalMemory = async () => {
3697
- const now = Date.now()
3698
- if (!tabLinkLocalInfoPromise || now > tabLinkLocalInfoExpiry) {
3699
- tabLinkLocalInfoPromise = fetch("/info/local", {
3700
- method: "GET",
3701
- headers: {
3702
- "Accept": "application/json"
3703
- }
3704
- })
3705
- .then((response) => {
3706
- if (!response.ok) {
3707
- throw new Error("Failed to load local info")
3708
- }
3709
- return response.json()
3710
- })
3711
- .catch(() => ({}))
3712
- tabLinkLocalInfoExpiry = now + 3000
3713
- }
3714
- return tabLinkLocalInfoPromise
3353
+ return null
3715
3354
  }
3716
-
3717
- const normalizeHttpsTarget = (value) => {
3718
- if (!value || typeof value !== "string") {
3719
- return ""
3720
- }
3721
- let trimmed = value.trim()
3722
- if (!trimmed) {
3723
- return ""
3355
+ const findLinkByAbsoluteHref = (href) => {
3356
+ if (!href) {
3357
+ return null
3724
3358
  }
3725
- // If it's already a URL, ensure it's HTTPS and not an IP host
3726
- if (/^https?:\/\//i.test(trimmed)) {
3359
+ return Array.from(document.querySelectorAll('.frame-link')).find((el) => {
3727
3360
  try {
3728
- const parsed = new URL(trimmed)
3729
- const host = (parsed.hostname || '').toLowerCase()
3730
- if (!host || isIPv4Host(host)) {
3731
- return ""
3732
- }
3733
- // Only accept domains (prefer *.localhost) for HTTPS targets
3734
- if (!(host === 'localhost' || host.endsWith('.localhost') || host.includes('.'))) {
3735
- return ""
3736
- }
3737
- let pathname = parsed.pathname || ""
3738
- if (pathname === "/") pathname = ""
3739
- const search = parsed.search || ""
3740
- return `https://${host}${pathname}${search}`
3361
+ return el.href === href
3741
3362
  } catch (_) {
3742
- return ""
3743
- }
3744
- }
3745
- // Not a full URL: accept plain domains (prefer *.localhost), reject IPs
3746
- try {
3747
- const hostCandidate = trimmed.split('/')[0].toLowerCase()
3748
- if (!hostCandidate || isIPv4Host(hostCandidate)) {
3749
- return ""
3750
- }
3751
- if (!(hostCandidate === 'localhost' || hostCandidate.endsWith('.localhost') || hostCandidate.includes('.'))) {
3752
- return ""
3363
+ return false
3753
3364
  }
3754
- return `https://${hostCandidate}`
3755
- } catch (_) {
3756
- return ""
3757
- }
3365
+ }) || null
3758
3366
  }
3759
-
3760
- const parseHostPort = (value) => {
3761
- if (!value || typeof value !== "string") {
3762
- return null
3763
- }
3764
- let trimmed = value.trim()
3765
- if (!trimmed) {
3766
- return null
3367
+ const persistFrameLinkSelection = (node) => {
3368
+ const storage = getWindowStorage()
3369
+ if (!storage || !node || !node.classList || !node.classList.contains('frame-link')) {
3370
+ return
3767
3371
  }
3768
- if (/^https?:\/\//i.test(trimmed)) {
3769
- try {
3770
- const parsed = new URL(trimmed)
3771
- if (!parsed.hostname) {
3372
+ const payload = {
3373
+ selector: buildFrameLinkSelector(node),
3374
+ hrefAttr: node.getAttribute('href') || null,
3375
+ href: (() => {
3376
+ try {
3377
+ return node.href || null
3378
+ } catch (_) {
3772
3379
  return null
3773
3380
  }
3774
- const protocol = parsed.protocol.toLowerCase()
3775
- let port = parsed.port
3776
- if (!port) {
3777
- if (protocol === "http:") {
3778
- port = "80"
3779
- } else if (protocol === "https:") {
3780
- port = "443"
3781
- }
3782
- }
3783
- if (!port) {
3784
- return null
3785
- }
3786
- return {
3787
- host: parsed.hostname.toLowerCase(),
3788
- port
3789
- }
3790
- } catch (_) {
3791
- return null
3792
- }
3793
- }
3794
- const slashIndex = trimmed.indexOf("/")
3795
- if (slashIndex >= 0) {
3796
- trimmed = trimmed.slice(0, slashIndex)
3797
- }
3798
- const match = trimmed.match(/^\[?([^\]]+)\]?(?::([0-9]+))$/)
3799
- if (!match) {
3800
- return null
3801
- }
3802
- const host = match[1] ? match[1].toLowerCase() : ""
3803
- const port = match[2] || ""
3804
- if (!host || !port) {
3805
- return null
3806
- }
3807
- return { host, port }
3808
- }
3809
-
3810
- const ensureRouterInfoMapping = async () => {
3811
- const now = Date.now()
3812
- if (!tabLinkRouterInfoPromise || now > tabLinkRouterInfoExpiry) {
3813
- // Use lightweight router mapping to avoid favicon/installed overhead
3814
- tabLinkRouterInfoPromise = fetch("/info/router", {
3815
- method: "GET",
3816
- headers: {
3817
- "Accept": "application/json"
3818
- }
3819
- })
3820
- .then((response) => {
3821
- if (!response.ok) {
3822
- throw new Error("Failed to load system info")
3823
- }
3824
- return response.json()
3825
- })
3826
- .then((data) => {
3827
- if (typeof data?.https_active === "boolean") {
3828
- tabLinkRouterHttpsActive = data.https_active
3829
- }
3830
- const processes = Array.isArray(data?.router_info) ? data.router_info : []
3831
- const rewriteMapping = data?.rewrite_mapping && typeof data.rewrite_mapping === "object"
3832
- ? Object.values(data.rewrite_mapping)
3833
- : []
3834
- const portMap = new Map()
3835
- const hostPortMap = new Map()
3836
- const externalHttpByExtPort = new Map() // ext port -> Set of host:port (external_ip)
3837
- const externalHttpByIntPort = new Map() // internal port -> Set of host:port (external_ip)
3838
- const hostAliasPortMap = new Map()
3839
- if (data?.router && typeof data.router === "object") {
3840
- Object.entries(data.router).forEach(([dial, hosts]) => {
3841
- const parsedDial = parseHostPort(dial)
3842
- if (!parsedDial || !parsedDial.port) {
3843
- return
3844
- }
3845
- if (!Array.isArray(hosts)) {
3846
- return
3847
- }
3848
- hosts.forEach((host) => {
3849
- if (typeof host !== "string") {
3850
- return
3851
- }
3852
- const trimmed = host.trim().toLowerCase()
3853
- if (!trimmed) {
3854
- return
3855
- }
3856
- if (!hostAliasPortMap.has(trimmed)) {
3857
- hostAliasPortMap.set(trimmed, new Set())
3858
- }
3859
- hostAliasPortMap.get(trimmed).add(parsedDial.port)
3860
- })
3861
- })
3862
- }
3863
- const localAliases = ["127.0.0.1", "localhost", "0.0.0.0", "::1", "[::1]"]
3864
-
3865
- const addHttpMapping = (host, port, httpsSet) => {
3866
- if (!host || !port || !httpsSet || httpsSet.size === 0) {
3867
- return
3868
- }
3869
- const hostLower = host.toLowerCase()
3870
- const keys = new Set([`${hostLower}:${port}`])
3871
- if (localAliases.includes(hostLower)) {
3872
- localAliases.forEach((alias) => keys.add(`${alias}:${port}`))
3873
- }
3874
- keys.forEach((key) => {
3875
- if (!hostPortMap.has(key)) {
3876
- hostPortMap.set(key, new Set())
3877
- }
3878
- const set = hostPortMap.get(key)
3879
- httpsSet.forEach((url) => set.add(url))
3880
- })
3881
- if (localAliases.includes(hostLower)) {
3882
- if (!portMap.has(port)) {
3883
- portMap.set(port, new Set())
3884
- }
3885
- const portSet = portMap.get(port)
3886
- httpsSet.forEach((url) => portSet.add(url))
3887
- }
3888
- }
3889
-
3890
- const gatherHttpsTargets = (value) => {
3891
- const targets = new Set()
3892
- const visit = (input) => {
3893
- if (!input) {
3894
- return
3895
- }
3896
- if (Array.isArray(input)) {
3897
- input.forEach(visit)
3898
- return
3899
- }
3900
- if (typeof input === "object") {
3901
- Object.values(input).forEach(visit)
3902
- return
3903
- }
3904
- if (typeof input !== "string") {
3905
- return
3906
- }
3907
- const normalized = normalizeHttpsTarget(input)
3908
- if (normalized) {
3909
- targets.add(normalized)
3910
- }
3911
- }
3912
- visit(value)
3913
- return targets
3914
- }
3915
-
3916
- const collectHostPort = (value, hostPortCandidates, portCandidates) => {
3917
- if (!value) {
3918
- return
3919
- }
3920
- if (Array.isArray(value)) {
3921
- value.forEach((item) => collectHostPort(item, hostPortCandidates, portCandidates))
3922
- return
3923
- }
3924
- if (typeof value === "object") {
3925
- Object.values(value).forEach((item) => {
3926
- collectHostPort(item, hostPortCandidates, portCandidates)
3927
- })
3928
- return
3929
- }
3930
- if (typeof value !== "string") {
3931
- return
3932
- }
3933
- const parsed = parseHostPort(value)
3934
- let hostLower
3935
- if (parsed && parsed.host && parsed.port) {
3936
- hostLower = parsed.host.toLowerCase()
3937
- hostPortCandidates.add(`${hostLower}:${parsed.port}`)
3938
- if (localAliases.includes(hostLower)) {
3939
- portCandidates.add(parsed.port)
3940
- }
3941
- }
3942
- const rawHost = value.replace(/^https?:\/\//i, "").split("/")[0].toLowerCase()
3943
- const aliasPorts = hostAliasPortMap.get(rawHost)
3944
- if (aliasPorts && aliasPorts.size > 0) {
3945
- aliasPorts.forEach((aliasPort) => {
3946
- hostPortCandidates.add(`${rawHost}:${aliasPort}`)
3947
- if (localAliases.includes(rawHost)) {
3948
- portCandidates.add(aliasPort)
3949
- }
3950
- })
3951
- }
3952
- }
3953
-
3954
- const collectPort = (value, portCandidates) => {
3955
- if (value === null || value === undefined || value === "") {
3956
- return
3957
- }
3958
- if (Array.isArray(value)) {
3959
- value.forEach((item) => collectPort(item, portCandidates))
3960
- return
3961
- }
3962
- const port = `${value}`.trim()
3963
- if (port && /^[0-9]+$/.test(port)) {
3964
- portCandidates.add(port)
3965
- }
3966
- }
3967
-
3968
- const registerEntry = (entry) => {
3969
- if (!entry || typeof entry !== "object") {
3970
- return
3971
- }
3972
- const httpsTargets = new Set()
3973
- const mergeTargets = (targetValue) => {
3974
- const targets = gatherHttpsTargets(targetValue)
3975
- targets.forEach((url) => httpsTargets.add(url))
3976
- }
3977
-
3978
- mergeTargets(entry.external_router)
3979
- mergeTargets(entry.external_domain)
3980
- mergeTargets(entry.https_href)
3981
- mergeTargets(entry.app_href)
3982
- // Some rewrite mapping entries expose domain candidates under `hosts`
3983
- mergeTargets(entry.hosts)
3984
- // Internal router can also include domain aliases (e.g., comfyui.localhost)
3985
- mergeTargets(entry.internal_router)
3986
-
3987
- // Record external http host:port candidates by external and internal ports for later
3988
- if (entry.external_ip && typeof entry.external_ip === 'string') {
3989
- const parsed = parseHostPort(entry.external_ip)
3990
- if (parsed && parsed.port) {
3991
- const keyExt = parsed.port
3992
- if (!externalHttpByExtPort.has(keyExt)) {
3993
- externalHttpByExtPort.set(keyExt, new Set())
3994
- }
3995
- externalHttpByExtPort.get(keyExt).add(`${parsed.host}:${parsed.port}`)
3996
- const keyInt = String(entry.internal_port || '')
3997
- if (keyInt) {
3998
- if (!externalHttpByIntPort.has(keyInt)) {
3999
- externalHttpByIntPort.set(keyInt, new Set())
4000
- }
4001
- externalHttpByIntPort.get(keyInt).add(`${parsed.host}:${parsed.port}`)
4002
- }
4003
- }
4004
- }
4005
-
4006
- if (httpsTargets.size === 0) {
4007
- return
4008
- }
4009
-
4010
- const hostPortCandidates = new Set()
4011
- const portCandidates = new Set()
4012
-
4013
- collectHostPort(entry.external_ip, hostPortCandidates, portCandidates)
4014
- collectHostPort(entry.internal_ip, hostPortCandidates, portCandidates)
4015
- collectHostPort(entry.ip, hostPortCandidates, portCandidates)
4016
- collectHostPort(entry.dial, hostPortCandidates, portCandidates)
4017
- collectHostPort(entry.match, hostPortCandidates, portCandidates)
4018
- collectHostPort(entry.target, hostPortCandidates, portCandidates)
4019
- collectHostPort(entry.forward, hostPortCandidates, portCandidates)
4020
- collectHostPort(entry.internal_router, hostPortCandidates, portCandidates)
4021
- collectHostPort(entry.external_router, hostPortCandidates, portCandidates)
4022
-
4023
- collectPort(entry.port, portCandidates)
4024
- collectPort(entry.internal_port, portCandidates)
4025
- collectPort(entry.external_port, portCandidates)
4026
-
4027
- if (hostPortCandidates.size === 0 && portCandidates.size === 0) {
4028
- httpsTargets.forEach((target) => {
4029
- collectHostPort(target, hostPortCandidates, portCandidates)
4030
- })
4031
- }
4032
-
4033
- if (hostPortCandidates.size === 0 && portCandidates.size === 0) {
4034
- return
4035
- }
4036
-
4037
- hostPortCandidates.forEach((key) => {
4038
- const parsed = parseHostPort(key)
4039
- if (parsed) {
4040
- addHttpMapping(parsed.host, parsed.port, httpsTargets)
4041
- }
4042
- })
4043
-
4044
- portCandidates.forEach((port) => {
4045
- localAliases.forEach((host) => {
4046
- addHttpMapping(host, port, httpsTargets)
4047
- })
4048
- })
4049
- }
4050
-
4051
- const visited = new WeakSet()
4052
- const traverseNode = (node) => {
4053
- if (!node) {
4054
- return
4055
- }
4056
- if (Array.isArray(node)) {
4057
- node.forEach(traverseNode)
4058
- return
4059
- }
4060
- if (typeof node !== "object") {
4061
- return
4062
- }
4063
- if (visited.has(node)) {
4064
- return
4065
- }
4066
- visited.add(node)
4067
- registerEntry(node)
4068
- Object.values(node).forEach((value) => {
4069
- if (value && typeof value === "object") {
4070
- traverseNode(value)
4071
- }
4072
- })
4073
- }
4074
-
4075
- processes.forEach(traverseNode)
4076
- rewriteMapping.forEach(traverseNode)
4077
-
4078
- return {
4079
- portMap,
4080
- hostPortMap,
4081
- externalHttpByExtPort,
4082
- externalHttpByIntPort
4083
- }
4084
- })
4085
- .catch(() => {
4086
- tabLinkRouterHttpsActive = null
4087
- return {
4088
- portMap: new Map(),
4089
- hostPortMap: new Map(),
4090
- externalHttpByExtPort: new Map(),
4091
- externalHttpByIntPort: new Map()
4092
- }
4093
- })
4094
- tabLinkRouterInfoExpiry = now + 3000
4095
- }
4096
- return tabLinkRouterInfoPromise
4097
- }
4098
-
4099
- const collectHttpsUrlsFromRouter = (httpUrl, routerData) => {
4100
- if (!routerData) {
4101
- return []
4102
- }
4103
- let parsed
4104
- try {
4105
- parsed = new URL(httpUrl, location.origin)
4106
- } catch (_) {
4107
- return []
4108
- }
4109
- const protocol = parsed.protocol.toLowerCase()
4110
- if (protocol !== "http:" && protocol !== "https:") {
4111
- return []
4112
- }
4113
- let port = parsed.port
4114
- if (!port) {
4115
- if (protocol === "http:") {
4116
- port = "80"
4117
- } else if (protocol === "https:") {
4118
- port = "443"
4119
- }
4120
- }
4121
- const hostLower = parsed.hostname.toLowerCase()
4122
- const results = new Set()
4123
- if (port) {
4124
- const hostPortKey = `${hostLower}:${port}`
4125
- if (routerData.hostPortMap.has(hostPortKey)) {
4126
- routerData.hostPortMap.get(hostPortKey).forEach((value) => results.add(value))
4127
- }
4128
- if (routerData.portMap.has(port)) {
4129
- routerData.portMap.get(port).forEach((value) => results.add(value))
4130
- }
4131
- }
4132
- return Array.from(results)
4133
- }
4134
-
4135
- const buildTabLinkEntries = async (link) => {
4136
- if (!link || !link.href) {
4137
- return []
4138
- }
4139
-
4140
- const baseHref = link.href
4141
- let canonicalBase = canonicalizeUrl(baseHref)
4142
- if (canonicalBase && isHttpUrl(canonicalBase)) {
4143
- canonicalBase = ensureHttpDirectoryUrl(canonicalBase)
4144
- }
4145
- let parsedBaseUrl = null
4146
- let sameOrigin = false
4147
- let basePortNormalized = ""
4148
- try {
4149
- parsedBaseUrl = new URL(baseHref, location.origin)
4150
- sameOrigin = parsedBaseUrl.origin === location.origin
4151
- if (parsedBaseUrl) {
4152
- basePortNormalized = parsedBaseUrl.port
4153
- if (!basePortNormalized) {
4154
- const proto = parsedBaseUrl.protocol ? parsedBaseUrl.protocol.toLowerCase() : "http:"
4155
- basePortNormalized = proto === "https:" ? "443" : "80"
4156
- }
4157
- }
4158
- } catch (_) {}
4159
- const projectSlug = extractProjectSlug(link).toLowerCase()
4160
- const entries = []
4161
- const entryByUrl = new Map()
4162
- const addEntry = (type, label, url, opts = {}) => {
4163
- if (!url) {
4164
- return
4165
- }
4166
- let canonical = canonicalizeUrl(url)
4167
- if (canonical && type === "http") {
4168
- canonical = ensureHttpDirectoryUrl(canonical)
4169
- }
4170
- if (!canonical) {
4171
- return
4172
- }
4173
- let skip = false
4174
- const allowSameOrigin = opts && opts.allowSameOrigin === true
4175
- try {
4176
- const parsed = new URL(canonical)
4177
- const originLower = parsed.origin.toLowerCase()
4178
- if (!allowSameOrigin && originLower === location.origin.toLowerCase()) {
4179
- skip = true
4180
- }
4181
- } catch (_) {
4182
- // ignore parse failures but do not skip by default
4183
- }
4184
- if (skip) {
4185
- return
4186
- }
4187
- if (entryByUrl.has(canonical)) {
4188
- const existing = entryByUrl.get(canonical)
4189
- if (opts && opts.qr === true) existing.qr = true
4190
- return
4191
- }
4192
- const entry = {
4193
- type,
4194
- label,
4195
- url: canonical,
4196
- display: formatDisplayUrl(canonical),
4197
- qr: opts && opts.qr === true
4198
- }
4199
- entryByUrl.set(canonical, entry)
4200
- entries.push(entry)
4201
- }
4202
-
4203
- if (isHttpUrl(baseHref)) {
4204
- addEntry("http", "HTTP", baseHref, { allowSameOrigin: true })
4205
- } else if (isHttpsUrl(baseHref)) {
4206
- addEntry("https", "HTTPS", baseHref, { allowSameOrigin: true })
4207
- } else {
4208
- addEntry("url", "URL", baseHref, { allowSameOrigin: true })
4209
- }
4210
-
4211
- const httpCandidates = new Map() // url -> { qr: boolean }
4212
- const httpsCandidates = new Set()
4213
-
4214
- if (isHttpUrl(baseHref)) {
4215
- httpCandidates.set(canonicalBase || canonicalizeUrl(baseHref), { qr: false })
4216
- } else if (isHttpsUrl(baseHref)) {
4217
- if (canonicalBase) {
4218
- httpsCandidates.add(canonicalBase)
4219
- } else {
4220
- httpsCandidates.add(canonicalizeUrl(baseHref))
4221
- }
4222
- }
4223
-
4224
- if (projectSlug) {
4225
- try {
4226
- const baseUrl = parsedBaseUrl || new URL(baseHref, location.origin)
4227
- let pathname = baseUrl.pathname || "/"
4228
- if (pathname.endsWith("/index.html")) {
4229
- pathname = pathname.slice(0, -"/index.html".length)
4230
- }
4231
- if (!pathname.endsWith("/")) {
4232
- pathname = `${pathname}/`
4233
- }
4234
- const normalizedPath = pathname.toLowerCase()
4235
- if (normalizedPath.includes(`/asset/api/${projectSlug}`)) {
4236
- const fallbackHttp = `http://127.0.0.1:42000${pathname}`
4237
- httpCandidates.set(canonicalizeUrl(fallbackHttp), { qr: false })
4238
- }
4239
- } catch (_) {
4240
- // ignore fallback errors
4241
- }
4242
- }
4243
-
4244
- const scriptKeys = collectScriptKeys(link)
4245
- if (scriptKeys.length > 0) {
4246
- const localInfo = await ensureLocalMemory()
4247
- scriptKeys.forEach((key) => {
4248
- if (!key) {
4249
- return
4250
- }
4251
- const local = localInfo ? localInfo[key] : undefined
4252
- if (!local) {
4253
- return
4254
- }
4255
- const urls = collectUrlsFromLocal(local)
4256
- urls.forEach((value) => {
4257
- const canonical = canonicalizeUrl(value)
4258
- if (isHttpsUrl(canonical)) {
4259
- httpsCandidates.add(canonical)
4260
- } else if (isHttpUrl(canonical)) {
4261
- const prev = httpCandidates.get(canonical)
4262
- httpCandidates.set(canonical, { qr: prev ? prev.qr === true : false })
4263
- }
4264
- })
4265
- })
4266
- }
4267
-
4268
- const routerData = await ensureRouterInfoMapping()
4269
- if (httpCandidates.size > 0) {
4270
- Array.from(httpCandidates.keys()).forEach((httpUrl) => {
4271
- const mapped = collectHttpsUrlsFromRouter(httpUrl, routerData)
4272
- mapped.forEach((httpsUrl) => {
4273
- httpsCandidates.add(httpsUrl)
4274
- })
4275
- })
4276
- }
4277
-
4278
- // Add external 192.168.* http host:port candidates mapped from the same internal port as base HTTP
4279
- try {
4280
- const base = parsedBaseUrl || new URL(baseHref, location.origin)
4281
- let basePort = base.port
4282
- if (!basePort) {
4283
- basePort = base.protocol.toLowerCase() === 'https:' ? '443' : '80'
4284
- }
4285
- const samePortHosts = routerData && routerData.externalHttpByIntPort ? routerData.externalHttpByIntPort.get(basePort) : null
4286
- if (samePortHosts && samePortHosts.size > 0) {
4287
- samePortHosts.forEach((hostport) => {
4288
- try {
4289
- const hpUrl = `http://${hostport}${base.pathname || '/'}${base.search || ''}`
4290
- const canonical = canonicalizeUrl(hpUrl)
4291
- if (isHttpUrl(canonical)) {
4292
- const prev = httpCandidates.get(canonical)
4293
- httpCandidates.set(canonical, { qr: true || (prev ? prev.qr === true : false) })
4294
- }
4295
- } catch (_) {}
4296
- })
4297
- }
4298
- } catch (_) {}
4299
-
4300
- const httpsList = Array.from(httpsCandidates).sort()
4301
-
4302
- if (httpsList.length > 0) {
4303
- httpsList.forEach((url) => {
4304
- try {
4305
- const parsed = new URL(url)
4306
- if (parsed.protocol.toLowerCase() !== "https:") {
4307
- return
4308
- }
4309
- if (!parsed.port || parsed.port !== "42000") {
4310
- return
4311
- }
4312
- const hostPort = parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname
4313
- const httpUrl = `http://${hostPort}${parsed.pathname || "/"}${parsed.search || ""}`
4314
- const key = canonicalizeUrl(httpUrl)
4315
- const prev = httpCandidates.get(key)
4316
- httpCandidates.set(key, { qr: prev ? prev.qr === true : false })
4317
- } catch (_) {
4318
- // ignore failures
4319
- }
4320
- })
4321
- }
4322
-
4323
- const httpList = Array.from(httpCandidates.keys()).sort()
4324
-
4325
- httpList.forEach((url) => {
4326
- const meta = httpCandidates.get(url) || { qr: false }
4327
- addEntry("http", "HTTP", url, { qr: meta.qr === true })
4328
- })
4329
- httpsList.forEach((url) => {
4330
- addEntry("https", "HTTPS", url)
4331
- })
4332
-
4333
- if (sameOrigin) {
4334
- try {
4335
- const peerInfo = await ensurePeerInfo()
4336
- const peerHost = peerInfo && typeof peerInfo.host === "string" ? peerInfo.host.trim() : ""
4337
- if (peerHost) {
4338
- const peerHostLower = peerHost.toLowerCase()
4339
- // Skip loopback-style peers
4340
- if (peerHostLower !== "localhost" && !peerHostLower.startsWith("127.")) {
4341
- const baseUrl = parsedBaseUrl || new URL(baseHref, location.origin)
4342
- const baseHostLower = (baseUrl.hostname || "").toLowerCase()
4343
- if (peerHostLower !== baseHostLower) {
4344
- const baseProtocol = baseUrl.protocol ? baseUrl.protocol.toLowerCase() : "http:"
4345
- const scheme = baseProtocol === "https:" ? "https://" : "http://"
4346
- const port = baseUrl.port || (baseProtocol === "https:" ? "443" : "80")
4347
- const hostPort = port ? `${peerHostLower}:${port}` : peerHostLower
4348
- const pathSegment = baseUrl.pathname || "/"
4349
- const searchSegment = baseUrl.search || ""
4350
- const fallbackUrl = `${scheme}${hostPort}${pathSegment}${searchSegment}`
4351
- const label = baseProtocol === "https:" ? "HTTPS" : "HTTP"
4352
- addEntry(baseProtocol === "https:" ? "https" : "http", label, fallbackUrl, { qr: true })
4353
- }
4354
- }
4355
- }
4356
- } catch (_) {}
4357
-
4358
- const matchesBasePort = (value) => {
4359
- if (!basePortNormalized) {
4360
- return true
4361
- }
4362
- try {
4363
- const parsed = new URL(value, location.origin)
4364
- let port = parsed.port
4365
- if (!port) {
4366
- const proto = parsed.protocol ? parsed.protocol.toLowerCase() : "http:"
4367
- port = proto === "https:" ? "443" : "80"
4368
- }
4369
- return port === basePortNormalized
4370
- } catch (_) {
4371
- return false
4372
- }
4373
- }
4374
-
4375
- const filteredEntries = entries.filter((entry) => {
4376
- if (!entry || !entry.url) {
4377
- return false
4378
- }
4379
- if (entry.url === canonicalBase) {
4380
- return true
4381
- }
4382
- if (entry.qr === true) {
4383
- return matchesBasePort(entry.url)
4384
- }
4385
- return false
4386
- })
4387
- if (filteredEntries.length > 0) {
4388
- return filteredEntries
4389
- }
4390
- }
4391
-
4392
- return entries
4393
- }
4394
-
4395
- const positionTabLinkPopover = (popover, link) => {
4396
- if (!popover || !link) {
4397
- return
4398
- }
4399
- const rect = link.getBoundingClientRect()
4400
- const minWidth = Math.max(rect.width, 260)
4401
- popover.style.minWidth = `${Math.round(minWidth)}px`
4402
- popover.style.display = "flex"
4403
- popover.classList.add("visible")
4404
- popover.style.visibility = "hidden"
4405
-
4406
- const popoverWidth = popover.offsetWidth
4407
- const popoverHeight = popover.offsetHeight
4408
-
4409
- let left = rect.left
4410
- let top = rect.bottom + 8
4411
-
4412
- if (left + popoverWidth > window.innerWidth - 12) {
4413
- left = window.innerWidth - popoverWidth - 12
4414
- }
4415
- if (left < 12) {
4416
- left = 12
4417
- }
4418
-
4419
- if (top + popoverHeight > window.innerHeight - 12) {
4420
- top = Math.max(12, rect.top - popoverHeight - 8)
4421
- }
4422
-
4423
- popover.style.left = `${Math.round(left)}px`
4424
- popover.style.top = `${Math.round(top)}px`
4425
- popover.style.visibility = ""
4426
- }
4427
-
4428
- const hideTabLinkPopover = ({ immediate = false } = {}) => {
4429
- const applyHide = () => {
4430
- if (tabLinkPopoverEl) {
4431
- tabLinkPopoverEl.classList.remove("visible")
4432
- tabLinkPopoverEl.style.display = "none"
4433
- }
4434
- tabLinkActiveLink = null
4435
- tabLinkPendingLink = null
4436
- tabLinkHideTimer = null
4437
- }
4438
-
4439
- if (tabLinkHideTimer) {
4440
- clearTimeout(tabLinkHideTimer)
4441
- tabLinkHideTimer = null
4442
- }
4443
-
4444
- if (immediate) {
4445
- applyHide()
4446
- } else {
4447
- tabLinkHideTimer = setTimeout(applyHide, 120)
4448
- }
4449
- }
4450
-
4451
- const renderTabLinkPopover = async (link) => {
4452
- if (!link || !link.href) {
4453
- hideTabLinkPopover({ immediate: true })
4454
- return
4455
- }
4456
-
4457
- let sameOrigin = false
4458
- let canonicalBase = canonicalizeUrl(link.href)
4459
- if (canonicalBase && isHttpUrl(canonicalBase)) {
4460
- canonicalBase = ensureHttpDirectoryUrl(canonicalBase)
4461
- }
4462
- let basePortNormalized = ""
4463
- try {
4464
- const linkUrl = new URL(link.href, location.href)
4465
- sameOrigin = linkUrl.origin === location.origin
4466
- canonicalBase = canonicalizeUrl(linkUrl.href)
4467
- if (canonicalBase && isHttpUrl(canonicalBase)) {
4468
- canonicalBase = ensureHttpDirectoryUrl(canonicalBase)
4469
- }
4470
- basePortNormalized = linkUrl.port
4471
- if (!basePortNormalized) {
4472
- const proto = linkUrl.protocol ? linkUrl.protocol.toLowerCase() : "http:"
4473
- basePortNormalized = proto === "https:" ? "443" : "80"
4474
- }
4475
- } catch (_) {
4476
- hideTabLinkPopover({ immediate: true })
4477
- return
4478
- }
4479
-
4480
- if (tabLinkActiveLink === link && tabLinkPopoverEl && tabLinkPopoverEl.classList.contains("visible")) {
4481
- if (tabLinkHideTimer) {
4482
- clearTimeout(tabLinkHideTimer)
4483
- tabLinkHideTimer = null
4484
- }
4485
- return
4486
- }
4487
-
4488
- if (tabLinkPendingLink === link && tabLinkPopoverEl && tabLinkPopoverEl.classList.contains("visible")) {
4489
- return
4490
- }
4491
-
4492
- tabLinkPendingLink = link
4493
- if (tabLinkHideTimer) {
4494
- clearTimeout(tabLinkHideTimer)
4495
- tabLinkHideTimer = null
4496
- }
4497
-
4498
- // Show lightweight loading popover immediately while mapping fetch runs
4499
- try {
4500
- const pop = ensureTabLinkPopoverEl()
4501
- pop.innerHTML = ''
4502
- const header = document.createElement('div')
4503
- header.className = 'tab-link-popover-header'
4504
- header.innerHTML = `<i class="fa-solid fa-arrow-up-right-from-square"></i><span>Open in browser</span>`
4505
- const item = document.createElement('div')
4506
- item.className = 'tab-link-popover-item'
4507
- const label = document.createElement('span')
4508
- label.className = 'label'
4509
- label.textContent = 'Loading…'
4510
- const value = document.createElement('span')
4511
- value.className = 'value muted'
4512
- value.textContent = 'Discovering routes'
4513
- item.append(label, value)
4514
- pop.append(header, item)
4515
- positionTabLinkPopover(pop, link)
4516
- } catch (_) {}
4517
-
4518
- let entries
4519
- try {
4520
- entries = await buildTabLinkEntries(link)
4521
- } catch (_) {
4522
- tabLinkPendingLink = null
4523
- return
4524
- }
4525
-
4526
- if (tabLinkPendingLink !== link) {
4527
- return
4528
- }
4529
-
4530
- if (!entries || entries.length === 0) {
4531
- hideTabLinkPopover({ immediate: true })
4532
- return
4533
- }
4534
-
4535
- if (sameOrigin) {
4536
- const slug = extractProjectSlug(link).toLowerCase()
4537
- const matchesBasePort = (value) => {
4538
- if (!basePortNormalized) {
4539
- return true
4540
- }
4541
- try {
4542
- const parsed = new URL(value, location.origin)
4543
- let port = parsed.port
4544
- if (!port) {
4545
- const proto = parsed.protocol ? parsed.protocol.toLowerCase() : "http:"
4546
- port = proto === "https:" ? "443" : "80"
4547
- }
4548
- return port === basePortNormalized
4549
- } catch (_) {
4550
- return false
4551
- }
4552
- }
4553
-
4554
- if (slug) {
4555
- entries = entries.filter((entry) => {
4556
- if (!entry || !entry.url) {
4557
- return false
4558
- }
4559
- if (entry.url === canonicalBase) {
4560
- return true
4561
- }
4562
- if (entry.qr === true) {
4563
- return matchesBasePort(entry.url)
4564
- }
4565
- try {
4566
- const parsed = new URL(entry.url)
4567
- const hostLower = parsed.hostname ? parsed.hostname.toLowerCase() : ""
4568
- if (isLocalHostLike(hostLower)) {
4569
- if (entry.type === "http") {
4570
- const pathLower = parsed.pathname ? parsed.pathname.toLowerCase() : ""
4571
- if (pathLower.includes(`/asset/api/${slug}`) || pathLower.includes(`/p/${slug}`)) {
4572
- return true
4573
- }
4574
- }
4575
- return false
4576
- }
4577
- const pathLower = parsed.pathname ? parsed.pathname.toLowerCase() : ""
4578
- if (pathLower.includes(`/asset/api/${slug}`)) {
4579
- return true
4580
- }
4581
- if (pathLower.includes(`/p/${slug}`)) {
4582
- return true
4583
- }
4584
- if (hostLower.split(".").some((part) => part === slug)) {
4585
- return true
4586
- }
4587
- } catch (_) {
4588
- return false
4589
- }
4590
- return false
4591
- })
4592
- } else {
4593
- entries = entries.filter((entry) => {
4594
- if (!entry || !entry.url) {
4595
- return false
4596
- }
4597
- if (entry.url === canonicalBase) {
4598
- return true
4599
- }
4600
- if (entry.qr === true) {
4601
- return matchesBasePort(entry.url)
4602
- }
4603
- return false
4604
- })
4605
- }
4606
-
4607
- entries = entries.filter((entry) => {
4608
- if (!entry || !entry.url) {
4609
- return false
4610
- }
4611
- if (entry.url === canonicalBase) {
4612
- return true
4613
- }
4614
- if (entry.qr === true) {
4615
- return matchesBasePort(entry.url)
4616
- }
4617
- return false
4618
- })
4619
-
4620
- const hasAlternate = entries.some((entry) => entry.url !== canonicalBase)
4621
- if (!hasAlternate) {
4622
- hideTabLinkPopover({ immediate: true })
4623
- return
4624
- }
4625
- }
4626
-
4627
- if (!entries || entries.length === 0) {
4628
- hideTabLinkPopover({ immediate: true })
4629
- return
4630
- }
4631
-
4632
- const popover = ensureTabLinkPopoverEl()
4633
- popover.innerHTML = ""
4634
-
4635
- const header = document.createElement("div")
4636
- header.className = "tab-link-popover-header"
4637
- header.innerHTML = `<i class="fa-solid fa-arrow-up-right-from-square"></i><span>Open in browser</span>`
4638
- popover.appendChild(header)
4639
-
4640
- const hasHttpsEntry = entries.some((entry) => entry && entry.type === "https")
4641
-
4642
- entries.forEach((entry) => {
4643
- const item = document.createElement("button")
4644
- item.type = "button"
4645
- item.setAttribute("data-url", entry.url)
4646
- const labelSpan = document.createElement("span")
4647
- labelSpan.className = "label"
4648
- labelSpan.textContent = entry.label
4649
- const valueSpan = document.createElement("span")
4650
- valueSpan.className = "value"
4651
- valueSpan.textContent = entry.display
4652
-
4653
- if (entry.type === 'http' && entry.qr === true) {
4654
- item.className = "tab-link-popover-item qr-inline"
4655
- const textCol = document.createElement('div')
4656
- textCol.className = 'textcol'
4657
- textCol.append(labelSpan, valueSpan)
4658
- const qrImg = document.createElement('img')
4659
- qrImg.className = 'qr'
4660
- qrImg.alt = 'QR'
4661
- qrImg.decoding = 'async'
4662
- qrImg.loading = 'lazy'
4663
- qrImg.src = `/qr?data=${encodeURIComponent(entry.url)}&s=4&m=0`
4664
- item.append(textCol, qrImg)
4665
- } else {
4666
- item.className = "tab-link-popover-item"
4667
- // Keep label and value as direct children so column layout applies
4668
- item.append(labelSpan, valueSpan)
4669
- }
4670
- popover.appendChild(item)
4671
- })
4672
-
4673
- if (tabLinkRouterHttpsActive === false && !hasHttpsEntry) {
4674
- const footerButton = document.createElement("button")
4675
- footerButton.type = "button"
4676
- footerButton.className = "tab-link-popover-item tab-link-popover-footer"
4677
- footerButton.setAttribute("data-url", "/network")
4678
- footerButton.setAttribute("data-target", "_self")
4679
- footerButton.setAttribute("aria-label", "Open network settings to configure local HTTPS")
4680
-
4681
- const footerLabel = document.createElement("span")
4682
- footerLabel.className = "label"
4683
- footerLabel.textContent = "Custom domain not active"
4684
-
4685
- const footerValue = document.createElement("span")
4686
- footerValue.className = "value"
4687
- footerValue.textContent = "Click to activate"
4688
-
4689
- footerButton.append(footerLabel, footerValue)
4690
- popover.appendChild(footerButton)
4691
- }
4692
-
4693
- tabLinkActiveLink = link
4694
- tabLinkPendingLink = null
4695
- positionTabLinkPopover(popover, link)
4696
- }
4697
-
4698
- const setupTabLinkHover = () => {
4699
- const container = document.querySelector(".appcanvas > aside .menu-container")
4700
- if (!container) {
4701
- return
4702
- }
4703
-
4704
- container.addEventListener("mouseover", (event) => {
4705
- const link = event.target.closest(".frame-link")
4706
- if (!link || !container.contains(link)) {
4707
- return
4708
- }
4709
- renderTabLinkPopover(link)
4710
- })
4711
-
4712
- container.addEventListener("mouseout", (event) => {
4713
- const origin = event.target.closest(".frame-link")
4714
- if (!origin || !container.contains(origin)) {
4715
- return
4716
- }
4717
- const related = event.relatedTarget
4718
- const popover = tabLinkPopoverEl || document.getElementById(TAB_LINK_POPOVER_ID)
4719
- if (related && (origin.contains(related) || (popover && popover.contains(related)))) {
4720
- return
4721
- }
4722
- hideTabLinkPopover()
4723
- })
4724
- }
4725
-
4726
- const handleGlobalPointer = (event) => {
4727
- if (!tabLinkPopoverEl || !tabLinkPopoverEl.classList.contains("visible")) {
4728
- return
4729
- }
4730
- if (tabLinkPopoverEl.contains(event.target)) {
4731
- return
4732
- }
4733
- if (tabLinkActiveLink && tabLinkActiveLink.contains(event.target)) {
4734
- return
4735
- }
4736
- hideTabLinkPopover({ immediate: true })
4737
- }
4738
-
4739
- window.addEventListener("scroll", () => {
4740
- if (tabLinkPopoverEl && tabLinkPopoverEl.classList.contains("visible")) {
4741
- hideTabLinkPopover({ immediate: true })
4742
- }
4743
- }, true)
4744
-
4745
- window.addEventListener("resize", () => {
4746
- if (tabLinkPopoverEl && tabLinkPopoverEl.classList.contains("visible") && tabLinkActiveLink) {
4747
- positionTabLinkPopover(tabLinkPopoverEl, tabLinkActiveLink)
4748
- }
4749
- })
4750
-
4751
- document.addEventListener("mousedown", handleGlobalPointer, true)
4752
- try {
4753
- document.addEventListener("touchstart", handleGlobalPointer, { passive: true, capture: true })
4754
- } catch (_) {
4755
- document.addEventListener("touchstart", handleGlobalPointer, true)
4756
- }
4757
- const getWindowStorage = () => {
4758
- if (typeof windowStorage === "undefined" || !windowStorage) {
4759
- return null
4760
- }
4761
- return windowStorage
4762
- }
4763
- const frameContextKey = () => {
4764
- const frameName = window.frameElement?.name || ""
4765
- if (!frameName) {
4766
- return ""
4767
- }
4768
- const datasetSrc = window.frameElement?.dataset?.src
4769
- if (typeof datasetSrc === 'string' && datasetSrc.length > 0) {
4770
- return `${frameName}:${datasetSrc}`
4771
- }
4772
- try {
4773
- const srcUrl = window.frameElement?.src ? new URL(window.frameElement.src, window.location.origin) : null
4774
- if (srcUrl) {
4775
- return `${frameName}:${srcUrl.pathname}${srcUrl.search}${srcUrl.hash}`
4776
- }
4777
- } catch (_) {}
4778
- return frameName
4779
- }
4780
- const selectionStorageKey = () => {
4781
- const base = frameContextKey()
4782
- if (!base) {
4783
- return ""
4784
- }
4785
- return `${base}:selector`
4786
- }
4787
- const selectionUrlStorageKey = () => {
4788
- const base = frameContextKey()
4789
- if (!base) {
4790
- return ""
4791
- }
4792
- return `${base}:url`
4793
- }
4794
- const SELECTION_SELECTOR_ATTRS = [
4795
- 'data-index',
4796
- 'target',
4797
- 'href',
4798
- 'data-target-full',
4799
- 'data-shell',
4800
- 'data-script',
4801
- 'data-action',
4802
- 'data-run',
4803
- 'data-command',
4804
- 'data-filepath'
4805
- ]
4806
- const buildFrameLinkSelector = (node) => {
4807
- if (!node || !node.classList || !node.classList.contains('frame-link')) {
4808
- return null
4809
- }
4810
- for (const attr of SELECTION_SELECTOR_ATTRS) {
4811
- const raw = node.getAttribute(attr)
4812
- if (typeof raw === 'string' && raw.length > 0) {
4813
- return `.frame-link[${attr}='${escapeTargetSelector(raw)}']`
4814
- }
4815
- }
4816
- return null
4817
- }
4818
- const findLinkByAbsoluteHref = (href) => {
4819
- if (!href) {
4820
- return null
4821
- }
4822
- return Array.from(document.querySelectorAll('.frame-link')).find((el) => {
4823
- try {
4824
- return el.href === href
4825
- } catch (_) {
4826
- return false
4827
- }
4828
- }) || null
4829
- }
4830
- const persistFrameLinkSelection = (node) => {
4831
- const storage = getWindowStorage()
4832
- if (!storage || !node || !node.classList || !node.classList.contains('frame-link')) {
4833
- return
4834
- }
4835
- const payload = {
4836
- selector: buildFrameLinkSelector(node),
4837
- hrefAttr: node.getAttribute('href') || null,
4838
- href: (() => {
4839
- try {
4840
- return node.href || null
4841
- } catch (_) {
4842
- return null
4843
- }
4844
- })(),
4845
- target: node.getAttribute('target') || null,
4846
- dataIndex: node.getAttribute('data-index') || null,
4847
- pagePath: (() => {
4848
- try {
4849
- return window.location?.pathname || null
4850
- } catch (_) {
3381
+ })(),
3382
+ target: node.getAttribute('target') || null,
3383
+ pagePath: (() => {
3384
+ try {
3385
+ return window.location?.pathname || null
3386
+ } catch (_) {
4851
3387
  return null
4852
3388
  }
4853
3389
  })()
@@ -4900,6 +3436,12 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
4900
3436
  if (typeof payload === 'string') {
4901
3437
  payload = { selector: payload }
4902
3438
  }
3439
+ if (payload && typeof payload.selector === 'string' && /\[data-index=/.test(payload.selector)) {
3440
+ payload.selector = null
3441
+ }
3442
+ if (payload && Object.prototype.hasOwnProperty.call(payload, 'dataIndex')) {
3443
+ delete payload.dataIndex
3444
+ }
4903
3445
  const trySelector = (selector) => {
4904
3446
  if (!selector || typeof selector !== 'string') {
4905
3447
  return null
@@ -4917,9 +3459,6 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
4917
3459
  if (!node && payload.target) {
4918
3460
  node = trySelector(`.frame-link[target='${escapeTargetSelector(payload.target)}']`)
4919
3461
  }
4920
- if (!node && payload.dataIndex) {
4921
- node = trySelector(`.frame-link[data-index='${escapeTargetSelector(payload.dataIndex)}']`)
4922
- }
4923
3462
  if (!node && payload.href) {
4924
3463
  node = findLinkByAbsoluteHref(payload.href)
4925
3464
  }
@@ -5682,17 +4221,7 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
5682
4221
  target = preselected
5683
4222
  }
5684
4223
 
5685
- if (!target) {
5686
- const defaultSelection = getDefaultSelection()
5687
- if (defaultSelection) {
5688
- target = defaultSelection
5689
- } else if (skipPersistedSelection) {
5690
- scheduleSelectionRetry()
5691
- return
5692
- }
5693
- }
5694
-
5695
- <% if (type === "run" && env.PINOKIO_SCRIPT_DEFAULT && env.PINOKIO_SCRIPT_DEFAULT.toString().toLowerCase() === "true") { %>
4224
+ <% if (autoselect && env.PINOKIO_SCRIPT_DEFAULT && env.PINOKIO_SCRIPT_DEFAULT.toString().toLowerCase() === "true") { %>
5696
4225
  if (!target && !hasPersistedSelection) {
5697
4226
  const defaultSelection = getDefaultSelection()
5698
4227
  if (defaultSelection && defaultSelection.href) {
@@ -5727,10 +4256,6 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
5727
4256
  target = preselected
5728
4257
  }
5729
4258
 
5730
- if (!target) {
5731
- target = document.querySelector(".frame-link")
5732
- }
5733
-
5734
4259
  if (!target) {
5735
4260
  document.querySelector(".container").classList.remove("active")
5736
4261
  document.querySelector("aside").classList.add("active")
@@ -6033,7 +4558,7 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
6033
4558
  item.href = url
6034
4559
  item.setAttribute("data-index", index)
6035
4560
  item.className = "btn header-item frame-link"
6036
- item.innerHTML = `<div class='tab'><i class="fa-solid fa-link"></i><div class='display'>${url}</div><div class='flexible'></div><button class='btn2 del'><i class="fa-solid fa-circle-stop"></i></button></div>`
4561
+ item.innerHTML = `<div class='tab'><i class="fa-solid fa-link"></i><div class='display'>${url}</div><div class='flexible'></div><button class='btn2 del'><i class="fa-solid fa-xmark"></i></button></div>`
6037
4562
 
6038
4563
  document.querySelector(".temp-menu").appendChild(item)
6039
4564
 
@@ -6492,29 +5017,6 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
6492
5017
  if (target) {
6493
5018
  e.preventDefault()
6494
5019
  e.stopPropagation()
6495
- if (typeof e.stopImmediatePropagation === "function") {
6496
- e.stopImmediatePropagation()
6497
- }
6498
- const parentLink = target.closest(".frame-link")
6499
- if (parentLink) {
6500
- let selectorCandidate = ""
6501
- const targetAttr = parentLink.getAttribute("target")
6502
- if (targetAttr) {
6503
- const escapedTarget = escapeTargetSelector(targetAttr)
6504
- if (escapedTarget) {
6505
- selectorCandidate = `.frame-link[target="${escapedTarget}"]`
6506
- }
6507
- }
6508
- if (!selectorCandidate && parentLink.href) {
6509
- const escapedHref = escapeTargetSelector(parentLink.getAttribute("href"))
6510
- if (escapedHref) {
6511
- selectorCandidate = `.frame-link[href="${escapedHref}"]`
6512
- }
6513
- }
6514
- if (selectorCandidate) {
6515
- global_selector = selectorCandidate
6516
- }
6517
- }
6518
5020
  let shell = target.closest("[data-shell]")
6519
5021
  if (shell) {
6520
5022
  let shell_id = shell.getAttribute("data-shell")
@@ -7027,55 +5529,6 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
7027
5529
  refresh_du()
7028
5530
  refresh_du("logs")
7029
5531
  renderSelection({ force: true })
7030
- <% if (type !== 'run') { %>
7031
- /*
7032
- fetch("<%=repos%>").then((res) => {
7033
- return res.text()
7034
- }).then((repos) => {
7035
- if (document.querySelector("#git-repos")) {
7036
- document.querySelector("#git-repos").innerHTML = repos
7037
- }
7038
- })
7039
- refresh()
7040
- */
7041
- <% } %>
7042
- <% if (plugin_menu) { %>
7043
- // document.querySelector(".dynamic .reveal").click()
7044
- <% } %>
7045
- /*
7046
- document.addEventListener("keydown", (e) => {
7047
- let size = document.querySelectorAll(".selectable").length
7048
- e = e || window.event;
7049
- if (e.key === "ArrowUp") {
7050
- if (cursorIndex > 0) {
7051
- cursorIndex--;
7052
- } else {
7053
- cursorIndex = size-1;
7054
- }
7055
- //renderCursor()
7056
- //let cursor = document.querySelector(".selectable.cursor")
7057
- //cursor.scrollIntoView(false)
7058
- //
7059
- } else if (e.key === "ArrowDown") {
7060
- if (cursorIndex < size-1) {
7061
- cursorIndex++;
7062
- } else {
7063
- cursorIndex = 0;
7064
- }
7065
- //renderCursor()
7066
- //let cursor = document.querySelector(".selectable.cursor")
7067
- //cursor.scrollIntoView(false)
7068
- } else if (e.key === "Enter") {
7069
- //let selected = document.querySelector(".line.selected:not(.hidden) .btns a.selected")
7070
- let cursor = document.querySelector(".selectable.cursor")
7071
- if (cursor) {
7072
- e.preventDefault()
7073
- e.stopPropagation()
7074
- cursor.click()
7075
- }
7076
- }
7077
- });
7078
- */
7079
5532
  <% if (type === "browse" || type === "files") { %>
7080
5533
  const repoStatusCache = new Map()
7081
5534
  let lastRepoList = []