import { defineStore } from 'pinia' import { ref } from 'vue' /** 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. */ function mustOpenInNewTab(url: string): boolean { try { const u = new URL(url) // 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) u.port === '8085' || // Nextcloud (subpath breaks CSS/assets) u.port === '2283' // Immich (subpath breaks SPA) ) { 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 } catch { return false } } /** Port → proxy path for apps (nginx strips X-Frame-Options) */ const PORT_TO_PROXY: Record = { '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) */ function toEmbeddableUrl(url: string): string { try { const u = new URL(url) const origin = window.location.origin 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}` } } catch { /* ignore */ } return url } export const useAppLauncherStore = defineStore('appLauncher', () => { const isOpen = ref(false) const url = ref('') const title = ref('') let previousActiveElement: HTMLElement | null = null function open(payload: { url: string; title: string; openInNewTab?: boolean }) { const embeddableUrl = toEmbeddableUrl(payload.url) if (payload.openInNewTab || mustOpenInNewTab(payload.url)) { window.open(embeddableUrl, '_blank', 'noopener,noreferrer') return } previousActiveElement = (document.activeElement as HTMLElement) || null url.value = embeddableUrl title.value = payload.title isOpen.value = true } function close() { const toRestore = previousActiveElement previousActiveElement = null isOpen.value = false url.value = '' title.value = '' if (toRestore && typeof toRestore.focus === 'function') { requestAnimationFrame(() => { toRestore.focus() }) } } return { isOpen, url, title, open, close, } })