archy/neode-ui/src/stores/appLauncher.ts
2026-05-05 11:29:18 -04:00

381 lines
13 KiB
TypeScript

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<string, string> = {
'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<string> {
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<NostrConsentRequest | null>(null)
const showConsent = ref(false)
let previousActiveElement: HTMLElement | null = null
/** Active app in panel mode (store-based, no route change) */
const panelAppId = ref<string | null>(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<string, string> = {
'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<boolean> {
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<string, unknown>).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<unknown>({
method: 'identity.nostr-sign',
params: { id: appIdentityId, event: params.event }
})
result = res
} else {
const res = await rpcClient.call<unknown>({ 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,
}
})