2026-02-17 21:10:16 +00:00
|
|
|
import { defineStore } from 'pinia'
|
|
|
|
|
import { ref } from 'vue'
|
|
|
|
|
|
2026-03-09 00:22:41 +00:00
|
|
|
/** Apps that must open in new tab instead of iframe.
|
|
|
|
|
* - DENY apps: always new tab (X-Frame-Options: DENY)
|
|
|
|
|
* - Redirect apps: new tab on HTTPS (absolute redirects break subpath proxy in iframe)
|
|
|
|
|
* On HTTP, these load via direct port URL so iframe works fine.
|
|
|
|
|
*/
|
2026-02-25 17:23:38 +00:00
|
|
|
function mustOpenInNewTab(url: string): boolean {
|
|
|
|
|
try {
|
|
|
|
|
const u = new URL(url)
|
2026-03-09 00:22:41 +00:00
|
|
|
// Always new tab: X-Frame-Options DENY or subpath fundamentally breaks the app
|
|
|
|
|
if (
|
|
|
|
|
u.port === '23000' || // BTCPay (X-Frame-Options: DENY)
|
|
|
|
|
u.port === '8123' || // Home Assistant (subpath breaks routing)
|
2026-02-25 18:04:41 +00:00
|
|
|
u.port === '8085' || // Nextcloud (subpath breaks CSS/assets)
|
|
|
|
|
u.port === '2283' // Immich (subpath breaks SPA)
|
2026-03-09 00:22:41 +00:00
|
|
|
) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
// On HTTPS, apps with absolute-path redirects break in iframe via proxy
|
|
|
|
|
if (window.location.protocol === 'https:') {
|
|
|
|
|
return (
|
|
|
|
|
u.port === '8096' || // Jellyfin (redirects to /web/index.html)
|
|
|
|
|
u.port === '9000' || // Portainer (redirects to /timeout.html)
|
|
|
|
|
u.port === '2342' || // PhotoPrism (redirects to /library/login)
|
|
|
|
|
u.port === '9980' || // OnlyOffice (redirects to /welcome/)
|
|
|
|
|
u.port === '3001' || // Uptime Kuma (redirects to /dashboard)
|
|
|
|
|
u.port === '8175' // Fedimint (redirects to /login)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
return false
|
2026-02-25 17:23:38 +00:00
|
|
|
} catch {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-01 17:53:18 +00:00
|
|
|
/** Port → proxy path for apps (nginx strips X-Frame-Options) */
|
|
|
|
|
const PORT_TO_PROXY: Record<string, string> = {
|
|
|
|
|
'81': '/app/nginx-proxy-manager/',
|
|
|
|
|
'3000': '/app/grafana/',
|
|
|
|
|
'3001': '/app/uptime-kuma/',
|
|
|
|
|
'8080': '/app/endurain/',
|
|
|
|
|
'8081': '/app/lnd/',
|
|
|
|
|
'8082': '/app/vaultwarden/',
|
|
|
|
|
'8083': '/app/filebrowser/',
|
|
|
|
|
'8085': '/app/nextcloud/',
|
|
|
|
|
'8096': '/app/jellyfin/',
|
|
|
|
|
'8123': '/app/homeassistant/',
|
|
|
|
|
'8240': '/app/tailscale/',
|
|
|
|
|
'8334': '/app/bitcoin-ui/',
|
|
|
|
|
'8888': '/app/searxng/',
|
|
|
|
|
'9000': '/app/portainer/',
|
|
|
|
|
'9001': '/app/penpot/',
|
|
|
|
|
'9980': '/app/onlyoffice/',
|
|
|
|
|
'11434': '/app/ollama/',
|
|
|
|
|
'2283': '/app/immich/',
|
|
|
|
|
'23000': '/app/btcpay/',
|
|
|
|
|
'2342': '/app/photoprism/',
|
|
|
|
|
'4080': '/app/mempool/',
|
|
|
|
|
'50002': '/app/electrs/',
|
|
|
|
|
'8175': '/app/fedimint/',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Rewrite to same-origin proxy so iframe can embed (avoids mixed content on HTTPS) */
|
2026-02-25 18:04:41 +00:00
|
|
|
function toEmbeddableUrl(url: string): string {
|
|
|
|
|
try {
|
|
|
|
|
const u = new URL(url)
|
|
|
|
|
const origin = window.location.origin
|
2026-03-01 17:53:18 +00:00
|
|
|
const proxyPath = PORT_TO_PROXY[u.port]
|
|
|
|
|
const sameHost = u.hostname === window.location.hostname
|
|
|
|
|
const needsProxy = window.location.protocol === 'https:' && u.protocol === 'http:'
|
|
|
|
|
// Use proxy when: (a) mixed content, or (b) vaultwarden/penpot always (subpath required)
|
|
|
|
|
if (proxyPath && sameHost && (needsProxy || u.port === '8082' || u.port === '9001')) {
|
|
|
|
|
return `${origin}${proxyPath}`
|
|
|
|
|
}
|
2026-02-25 18:04:41 +00:00
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
|
|
|
|
return url
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 21:10:16 +00:00
|
|
|
export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|
|
|
|
const isOpen = ref(false)
|
|
|
|
|
const url = ref('')
|
|
|
|
|
const title = ref('')
|
2026-02-18 11:29:05 +00:00
|
|
|
let previousActiveElement: HTMLElement | null = null
|
2026-02-17 21:10:16 +00:00
|
|
|
|
2026-02-25 17:23:38 +00:00
|
|
|
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
|
2026-03-01 17:53:18 +00:00
|
|
|
const embeddableUrl = toEmbeddableUrl(payload.url)
|
2026-02-25 17:23:38 +00:00
|
|
|
if (payload.openInNewTab || mustOpenInNewTab(payload.url)) {
|
2026-03-01 17:53:18 +00:00
|
|
|
window.open(embeddableUrl, '_blank', 'noopener,noreferrer')
|
2026-02-25 17:23:38 +00:00
|
|
|
return
|
|
|
|
|
}
|
2026-02-18 11:29:05 +00:00
|
|
|
previousActiveElement = (document.activeElement as HTMLElement) || null
|
2026-03-01 17:53:18 +00:00
|
|
|
url.value = embeddableUrl
|
2026-02-17 21:10:16 +00:00
|
|
|
title.value = payload.title
|
|
|
|
|
isOpen.value = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function close() {
|
2026-02-18 11:29:05 +00:00
|
|
|
const toRestore = previousActiveElement
|
|
|
|
|
previousActiveElement = null
|
2026-02-17 21:10:16 +00:00
|
|
|
isOpen.value = false
|
|
|
|
|
url.value = ''
|
|
|
|
|
title.value = ''
|
2026-02-18 11:29:05 +00:00
|
|
|
if (toRestore && typeof toRestore.focus === 'function') {
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
toRestore.focus()
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-02-17 21:10:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
isOpen,
|
|
|
|
|
url,
|
|
|
|
|
title,
|
|
|
|
|
open,
|
|
|
|
|
close,
|
|
|
|
|
}
|
|
|
|
|
})
|