import { defineStore } from 'pinia' import { ref, watch } from 'vue' import { rpcClient } from '@/api/rpc-client' import router from '@/router' /** Ports of apps that set X-Frame-Options (can't iframe, must open in new tab) */ const NEW_TAB_PORTS = new Set([ '23000', // BTCPay — X-Frame-Options: DENY '3000', // Grafana — X-Frame-Options: deny '2342', // PhotoPrism — X-Frame-Options: DENY '8123', // Home Assistant — X-Frame-Options: SAMEORIGIN '8082', // Vaultwarden — X-Frame-Options: SAMEORIGIN '8085', // Nextcloud — X-Frame-Options: SAMEORIGIN '3002', // Uptime Kuma — X-Frame-Options: SAMEORIGIN '9001', // Penpot — not reachable // Port 7777 is the Nostr relay; IndeeHub's web UI is exposed on 7778. ]) const NEW_TAB_APP_IDS = new Set([ 'nginx-proxy-manager', 'uptime-kuma', 'gitea', ]) function mustOpenInNewTab(url: string): boolean { try { const u = new URL(url) return NEW_TAB_PORTS.has(u.port) } catch { return false } } function inferAppIdFromTitle(title?: string): string | null { const t = (title || '').toLowerCase() if (!t) return null if (t.includes('indeehub') || t.includes('indeedhub')) return 'indeedhub' if ((t.includes('uptime') && t.includes('kuma')) || t.includes('uptime-kuma')) return 'uptime-kuma' if ((t.includes('nginx') && t.includes('proxy') && t.includes('manager')) || t.includes('nginx-proxy-manager')) return 'nginx-proxy-manager' if (t.includes('gitea')) return 'gitea' return null } function normalizeLaunchUrl(urlStr: string, appIdHint?: string | null): string { try { const u = new URL(urlStr) let rewrittenLocalhost = false if (u.hostname === 'localhost' || u.hostname === '127.0.0.1') { u.hostname = window.location.hostname rewrittenLocalhost = true } const sameHost = u.hostname === window.location.hostname const normalizedPath = u.pathname === '/' ? '' : u.pathname const rebuilt = (port: string) => `${u.protocol}//${u.hostname}:${port}${normalizedPath}${u.search}${u.hash}` if (sameHost && appIdHint === 'indeedhub' && u.port === '7777') { return rebuilt('7778') } if (sameHost && appIdHint === 'uptime-kuma' && u.port === '3001') { return rebuilt('3002') } if (sameHost && appIdHint === 'nginx-proxy-manager' && u.port === '8181') { return rebuilt('81') } return rewrittenLocalhost ? u.toString() : urlStr } catch { return urlStr } } /** Port → app ID for resolving URLs to AppSession routes */ const PORT_TO_APP_ID: Record = { '81': 'nginx-proxy-manager', '8181': 'nginx-proxy-manager', '3000': 'grafana', '3002': 'uptime-kuma', '8080': 'endurain', '18083': 'lnd', '8082': 'vaultwarden', '8083': 'filebrowser', '8085': 'nextcloud', '8096': 'jellyfin', '8123': 'homeassistant', '8240': 'tailscale', '8334': 'bitcoin-knots', '8888': 'searxng', '9000': 'portainer', '9980': 'onlyoffice', '11434': 'ollama', '2283': 'immich', '23000': 'btcpay-server', '2342': 'photoprism', '4080': 'mempool', '8175': 'fedimint', '8176': 'fedimint-gateway', '3100': 'dwn', '7778': 'indeedhub', '50002': 'electrumx', '3010': 'thunderhub', } const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins' function getApprovedOrigins(): Set { try { const stored = localStorage.getItem(APPROVED_ORIGINS_KEY) if (!stored) return new Set() const parsed: unknown = JSON.parse(stored) if (!Array.isArray(parsed)) return new Set() return new Set(parsed.filter((s: unknown) => typeof s === 'string')) } catch { return new Set() } } function saveApprovedOrigin(origin: string) { const origins = getApprovedOrigins() origins.add(origin) try { localStorage.setItem(APPROVED_ORIGINS_KEY, JSON.stringify([...origins])) } catch { /* localStorage full or unavailable */ } } export interface NostrConsentRequest { appName: string method: string eventKind?: number content?: string resolve: (remember: boolean) => void reject: () => void } const DISPLAY_MODE_KEY = 'archipelago_app_display_mode' export const useAppLauncherStore = defineStore('appLauncher', () => { const isOpen = ref(false) const url = ref('') const title = ref('') const consentRequest = ref(null) const showConsent = ref(false) let previousActiveElement: HTMLElement | null = null /** Active app in panel mode (store-based, no route change) */ const panelAppId = ref(null) /** Open app in session view — panel mode uses store, overlay/fullscreen uses route */ function openSession(appId: string) { const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel' const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 if (mode === 'panel' && !isMobile) { panelAppId.value = appId } else { panelAppId.value = null router.push({ name: 'app-session', params: { appId } }) } } function closePanel() { panelAppId.value = null } /** Legacy: open app in iframe overlay (kept for backward compat) */ function open(payload: { url: string; title: string; openInNewTab?: boolean }) { const titleHintId = inferAppIdFromTitle(payload.title) const launchUrl = normalizeLaunchUrl(payload.url, titleHintId) const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId // Force selected apps to open directly in new tab if (resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) { window.open(launchUrl, '_blank', 'noopener,noreferrer') return } // Route to full-page session if we can resolve an app ID from the URL if (resolvedId) { openSession(resolvedId) return } // Unknown apps that block iframes — open directly in new tab if (payload.openInNewTab || mustOpenInNewTab(launchUrl)) { window.open(launchUrl, '_blank', 'noopener,noreferrer') return } previousActiveElement = (document.activeElement as HTMLElement) || null url.value = launchUrl title.value = payload.title isOpen.value = true } /** Resolve an app ID from a URL (port or known external) */ function resolveAppIdFromUrl(urlStr: string): string | null { try { const u = new URL(urlStr) // Check /app/{id}/ path-style routes first (HTTPS proxy mode) const m = u.pathname.match(/^\/app\/([a-z0-9._-]+)(?:\/|$)/i) if (m?.[1]) return m[1].toLowerCase() // Check port-based apps const appId = PORT_TO_APP_ID[u.port] if (appId) return appId // Check external URLs const EXTERNAL_APP_HOSTS: Record = { 'botfights.net': 'botfights', 'nwnn.l484.com': 'nwnn', '484.kitchen': '484-kitchen', 'cta.tx1138.com': 'call-the-operator', 'present.l484.com': 'arch-presentation', 'syntropy.institute': 'syntropy-institute', 'teeminuszero.net': 't-zero', 'nostrudel.ninja': 'nostrudel', } return EXTERNAL_APP_HOSTS[u.hostname] || null } catch { return null } } function close() { const toRestore = previousActiveElement previousActiveElement = null isOpen.value = false url.value = '' title.value = '' // Explicitly remove NIP-07 listener as safety net — if user navigates away // without close() triggering the isOpen watcher, the listener would leak window.removeEventListener('message', handleNostrRequest) if (toRestore && typeof toRestore.focus === 'function') { requestAnimationFrame(() => { toRestore.focus() }) } } function approveConsent(remember: boolean) { if (consentRequest.value) { consentRequest.value.resolve(remember) consentRequest.value = null } showConsent.value = false } function denyConsent() { if (consentRequest.value) { consentRequest.value.reject() consentRequest.value = null } showConsent.value = false } function requestConsent(appName: string, method: string, eventKind?: number, content?: string): Promise { return new Promise((resolve, reject) => { consentRequest.value = { appName, method, eventKind, content, resolve, reject } showConsent.value = true }) } // 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 const origin = url.value || 'unknown' // Check if app has a per-app identity stored (from identity picker) const IDENTITY_KEY = 'archipelago_app_identity_' const appKey = IDENTITY_KEY + (url.value || '').replace(/[^a-z0-9]/gi, '_') let appIdentityId: string | null = null try { const stored = localStorage.getItem(appKey) if (stored) { const parsed: unknown = JSON.parse(stored) if (typeof parsed === 'object' && parsed !== null && 'id' in parsed) { const idVal = (parsed as Record).id appIdentityId = typeof idVal === 'string' ? idVal : null } } } catch { /* ignore */ } try { let result: unknown if (method === 'getPublicKey') { if (appIdentityId) { // Use the app-specific identity's Nostr key const res = await rpcClient.call<{ nostr_pubkey: string; nostr_npub: string; id: string; name: string; pubkey: string; did: string; is_default: boolean }>({ method: 'identity.get', params: { id: appIdentityId } }) result = res.nostr_pubkey } else { const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' }) result = res.nostr_pubkey } } else if (method === 'signEvent') { // Check if origin is pre-approved const approved = getApprovedOrigins() if (!approved.has(origin)) { const eventKind = params?.event?.kind as number | undefined const content = params?.event?.content as string | undefined try { const remember = await requestConsent(title.value || 'App', 'signEvent', eventKind, content) if (remember) saveApprovedOrigin(origin) } catch { source.postMessage({ type: 'nostr-response', id, error: 'User denied signing request' }, origin || '*') return } } if (appIdentityId) { // Sign with the app-specific identity's Nostr key const res = await rpcClient.call({ method: 'identity.nostr-sign', params: { id: appIdentityId, event: params.event } }) result = res } else { const res = await rpcClient.call({ method: 'node.nostr-sign', params: { event: params.event } }) result = res } } else if (method === 'getRelays') { result = {} } else if (method === 'nip04.encrypt') { const res = await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip04', params: { id: appIdentityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } }) result = res.ciphertext } else if (method === 'nip04.decrypt') { const res = await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip04', params: { id: appIdentityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } }) result = res.plaintext } else if (method === 'nip44.encrypt') { const res = await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip44', params: { id: appIdentityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } }) result = res.ciphertext } else if (method === 'nip44.decrypt') { const res = await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip44', params: { id: appIdentityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } }) result = res.plaintext } else { throw new Error(`Unsupported NIP-07 method: ${method}`) } source.postMessage({ type: 'nostr-response', id, result }, origin || '*') } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error' source.postMessage({ type: 'nostr-response', id, error: message }, origin || '*') } } // 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, openSession, close, closePanel, panelAppId, showConsent, consentRequest, approveConsent, denyConsent, } })