import { defineStore } from 'pinia' import { ref, watch } from 'vue' import { rpcClient } from '@/api/rpc-client' /** Apps that set X-Frame-Options or CSP frame-ancestors, blocking iframe embedding. * Verified by checking response headers from each app container. * These always open in a new tab. Other apps load in the iframe overlay. */ /** Hostnames of external sites that block iframes via X-Frame-Options or CSP. * Sites listed here that also appear in EXTERNAL_PROXY will be proxied (not blocked). */ const IFRAME_BLOCKED_HOSTS: string[] = [] /** External sites proxied through nginx path-based locations (strips X-Frame-Options). * Uses /ext/{key}/ paths on the main nginx port so it works over Tailscale too. */ const EXTERNAL_PROXY_PATH: Record = { 'botfights.net': '/ext/botfights/', '484.kitchen': '/ext/484-kitchen/', 'present.l484.com': '/ext/arch-presentation/', } function mustOpenInNewTab(url: string): boolean { try { const u = new URL(url) // External sites that block iframes if (IFRAME_BLOCKED_HOSTS.some(h => u.hostname === h || u.hostname.endsWith(`.${h}`))) { return true } // Local apps with X-Frame-Options or CSP frame-ancestors blocking iframes if ( u.port === '23000' || // BTCPay — X-Frame-Options: DENY u.port === '3000' || // Grafana — X-Frame-Options: deny u.port === '8082' || // Vaultwarden — X-Frame-Options: SAMEORIGIN + CSP frame-ancestors u.port === '2342' || // PhotoPrism — X-Frame-Options: DENY + CSP frame-ancestors: 'none' u.port === '8085' || // Nextcloud — X-Frame-Options: SAMEORIGIN u.port === '3001' || // Uptime Kuma — X-Frame-Options: SAMEORIGIN u.port === '8123' // Home Assistant — X-Frame-Options: SAMEORIGIN ) { return true } return false } catch { return false } } /** Port → proxy path for apps (nginx strips X-Frame-Options + avoids mixed content) */ 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/', '8176': '/app/fedimint-gateway/', '3100': '/app/dwn/', '18081': '/app/nostr-rs-relay/', } /** Rewrite to same-origin proxy ONLY when needed for HTTPS mixed-content. * On HTTP, direct port URLs are used — they avoid subpath routing issues * (apps' root-relative asset paths like /static/main.js break under /app/xxx/). * On HTTPS, must proxy to avoid mixed-content blocks; nginx also strips X-Frame-Options. */ function toEmbeddableUrl(url: string): string { try { const u = new URL(url) const origin = window.location.origin // External sites proxied through nginx path-based locations const extPath = EXTERNAL_PROXY_PATH[u.hostname] if (extPath) { return `${origin}${extPath}` } const proxyPath = PORT_TO_PROXY[u.port] const sameHost = u.hostname === window.location.hostname const needsProxy = window.location.protocol === 'https:' && u.protocol === 'http:' if (proxyPath && sameHost && needsProxy) { 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 }) { if (payload.openInNewTab || mustOpenInNewTab(payload.url)) { // New tab: always use direct port URL so app assets load correctly window.open(payload.url, '_blank', 'noopener,noreferrer') return } const embeddableUrl = toEmbeddableUrl(payload.url) 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() }) } } // NIP-07 postMessage handler — responds to nostr-request from iframe apps async function handleNostrRequest(event: MessageEvent) { if (!event.data || event.data.type !== 'nostr-request') return const { id, method, params } = event.data const source = event.source as Window | null if (!source) return try { let result: unknown if (method === 'getPublicKey') { const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' }) result = res.nostr_pubkey } else if (method === 'signEvent') { const res = await rpcClient.call({ method: 'identity.nostr-sign', params: { event: params.event } }) result = res } else if (method === 'getRelays') { result = {} } else { throw new Error(`Unsupported NIP-07 method: ${method}`) } source.postMessage({ type: 'nostr-response', id, result }, '*') } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error' source.postMessage({ type: 'nostr-response', id, error: message }, '*') } } // Listen for NIP-07 requests only while an app is open watch(isOpen, (open) => { if (open) { window.addEventListener('message', handleNostrRequest) } else { window.removeEventListener('message', handleNostrRequest) } }) return { isOpen, url, title, open, close, } })