- auth.rs now infers onboarding-complete from setup_complete + password_hash so nodes stop bouncing users through the intro wizard after browser clear / update / reboot; the flag self-heals to disk on next check - frontend: "backend uncertain" no longer defaults to /onboarding/intro — useOnboarding returns null + callers poll / retry instead of flashing the wizard - login sounds (synthwave, welcome voice, pop, whoosh, oomph) gated by isFirstInstallPhase(); typing sounds unaffected - removed FIPS app, Nostr Relay, Nostr VPN, Routstr, Penpot from catalog, frontend config, Rust AppMetadata + install dispatch + install_penpot_stack; docker/fips-ui + docker/nostr-vpn-ui + apps/penpot dirs and 5 icons deleted; 15 image versions deleted from tx1138, .168, gitea-local registries (.160 Gitea was 502 at release time — follow-up) - AIUI baked into frontend release tarball via demo/aiui/; deploy-to-target falls back to demo/aiui/ when the AIUI sibling checkout is missing - prebuild hook syncs app-catalog/catalog.json → public/catalog.json so the two copies can no longer drift (was the source of the "apps still visible" bug — public/ had stale data) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
319 lines
11 KiB
TypeScript
319 lines
11 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
|
|
'3001', // Uptime Kuma — X-Frame-Options: SAMEORIGIN
|
|
'9001', // Penpot — not reachable
|
|
// IndeedHub (7777) uses proxy path for NIP-07 nostr-provider.js — NOT new tab
|
|
])
|
|
|
|
function mustOpenInNewTab(url: string): boolean {
|
|
try {
|
|
const u = new URL(url)
|
|
return NEW_TAB_PORTS.has(u.port)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/** Port → app ID for resolving URLs to AppSession routes */
|
|
const PORT_TO_APP_ID: Record<string, string> = {
|
|
'81': 'nginx-proxy-manager',
|
|
'3000': 'grafana',
|
|
'3001': 'uptime-kuma',
|
|
'8080': 'endurain',
|
|
'8081': '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',
|
|
'7777': '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'
|
|
if (mode === 'panel') {
|
|
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 }) {
|
|
// Route to full-page session if we can resolve an app ID from the URL
|
|
const resolvedId = resolveAppIdFromUrl(payload.url)
|
|
if (resolvedId) {
|
|
openSession(resolvedId)
|
|
return
|
|
}
|
|
// Apps that block iframes — open directly in new tab
|
|
if (payload.openInNewTab || mustOpenInNewTab(payload.url)) {
|
|
window.open(payload.url, '_blank', 'noopener,noreferrer')
|
|
return
|
|
}
|
|
previousActiveElement = (document.activeElement as HTMLElement) || null
|
|
url.value = payload.url
|
|
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 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,
|
|
}
|
|
})
|