feat: bitcoin-ui CSS fix, HTTPS proxy support, deploy script improvements

Bitcoin UI:
- Replace cdn.tailwindcss.com with locally bundled tailwind.css (CSP blocks external scripts)
- Make all asset paths relative for nginx proxy compatibility
- Add bitcoin-ui build/deploy to deploy-to-target.sh (was missing entirely)
- Use --network host (bitcoin-ui proxies Bitcoin RPC at 127.0.0.1:8332)

HTTPS mixed content fix:
- Add HTTPS_PROXY_PATHS in AppSession.vue — when parent page is HTTPS,
  iframe loads through nginx proxy instead of direct HTTP port
- Prevents browser blocking HTTP iframes inside HTTPS pages
- All Tailscale servers use HTTPS, this was breaking all app iframes

Deploy & first-boot improvements:
- first-boot-containers.sh auto-detects disk size for pruning vs txindex
- first-boot-containers.sh checks fallback source path for UI containers
- Added mempool-electrs to APP_PORTS mapping
- ElectrumX container creation in first-boot
- Podman doctor/fix/uptime skills added

Also includes: session persistence, identity management, LND transactions,
ElectrumX status UI, nostr-provider improvements, Web5 enhancements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-16 12:58:35 +00:00
co-authored by Claude Opus 4.6
parent 4e54b8bd4d
commit 367b483a72
49 changed files with 6180 additions and 495 deletions
+175 -50
View File
@@ -1,9 +1,9 @@
<template>
<div class="app-session-root">
<Teleport to="body" :disabled="displayMode === 'panel'">
<Teleport to="body" :disabled="isInlinePanel">
<div
:class="backdropClasses"
@click.self="goBack"
@click.self="handleBackdropClick"
>
<div
ref="sessionRef"
@@ -178,12 +178,24 @@
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { useAppLauncherStore } from '@/stores/appLauncher'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
const props = defineProps<{
appIdProp?: string
}>()
const emit = defineEmits<{
close: []
}>()
/** True when rendered inline via store (panel mode), false when route-based */
const isInlinePanel = computed(() => !!props.appIdProp)
const route = useRoute()
const router = useRouter()
@@ -214,6 +226,25 @@ function setMode(mode: DisplayMode) {
displayMode.value = mode
localStorage.setItem(DISPLAY_MODE_KEY, mode)
showModeMenu.value = false
// Switch from inline panel → route-based overlay/fullscreen
if (isInlinePanel.value && mode !== 'panel') {
const id = appId.value
emit('close')
router.push({ name: 'app-session', params: { appId: id } })
return
}
// Switch from route-based → inline panel
if (!isInlinePanel.value && mode === 'panel') {
const id = appId.value
const launcher = useAppLauncherStore()
router.push({ name: 'apps' }).then(() => {
launcher.panelAppId = id
})
return
}
// Enter fullscreen if selected
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
sessionRef.value.requestFullscreen().catch(() => {})
@@ -222,49 +253,92 @@ function setMode(mode: DisplayMode) {
// Reactive classes based on display mode
const backdropClasses = computed(() => {
if (displayMode.value === 'overlay' || displayMode.value === 'fullscreen') {
return 'app-session-backdrop-overlay'
}
return 'app-session-backdrop-panel'
if (isInlinePanel.value) return 'app-session-backdrop-inline'
return 'app-session-backdrop-overlay'
})
const panelClasses = computed(() => {
const base = 'app-session-panel glass-card'
if (displayMode.value === 'overlay') return `${base} app-session-overlay`
if (isInlinePanel.value) return `${base} app-session-inline`
if (displayMode.value === 'fullscreen') return `${base} app-session-fullscreen`
return `${base} app-session-inpanel`
return `${base} app-session-overlay`
})
const appId = computed(() => route.params.appId as string)
const appId = computed(() => props.appIdProp || (route.params.appId as string))
const APP_URLS: Record<string, string> = {
// Container apps — use nginx proxy paths (strips X-Frame-Options)
/** Container apps: direct port access (avoids root-relative asset breakage under /app/xxx/ proxy) */
const APP_PORTS: Record<string, number> = {
'bitcoin-knots': 8334,
'bitcoin-ui': 8334,
'electrumx': 50002,
'electrs': 50002,
'archy-electrs-ui': 50002,
'mempool-electrs': 50002,
'btcpay-server': 23000,
'lnd': 8081,
'archy-lnd-ui': 8081,
'mempool': 4080,
'mempool-web': 4080,
'archy-mempool-web': 4080,
'homeassistant': 8123,
'grafana': 3000,
'searxng': 8888,
'ollama': 11434,
'onlyoffice': 9980,
'penpot': 9001,
'nextcloud': 8085,
'vaultwarden': 8082,
'jellyfin': 8096,
'photoprism': 2342,
'immich': 2283,
'immich_server': 2283,
'filebrowser': 8083,
'nginx-proxy-manager': 81,
'portainer': 9000,
'uptime-kuma': 3001,
'tailscale': 8240,
'fedimint': 8175,
'fedimint-gateway': 8176,
'nostr-rs-relay': 18081,
'indeedhub': 7777,
'dwn': 3100,
'endurain': 8080,
}
/** Apps that need nginx proxy for iframe embedding.
* IndeedHub loads via direct port 7777 — deploy script removes X-Frame-Options
* from the container's internal nginx so iframe works on all servers. */
const PROXY_APPS: Record<string, string> = {}
/** Nginx proxy paths — used on HTTPS to avoid mixed content (HTTPS parent + HTTP port iframe).
* On HTTP, direct port access is used instead (faster, no proxy). */
const HTTPS_PROXY_PATHS: Record<string, string> = {
'bitcoin-knots': '/app/bitcoin-ui/',
'electrs': '/app/electrs/',
'btcpay-server': '/app/btcpay/',
'bitcoin-ui': '/app/bitcoin-ui/',
'lnd': '/app/lnd/',
'electrumx': '/app/electrs/',
'electrs': '/app/electrs/',
'mempool-electrs': '/app/electrs/',
'mempool': '/app/mempool/',
'homeassistant': '/app/homeassistant/',
'grafana': '/app/grafana/',
'mempool-web': '/app/mempool/',
'archy-mempool-web': '/app/mempool/',
'fedimint': '/app/fedimint/',
'fedimint-gateway': '/app/fedimint-gateway/',
'jellyfin': '/app/jellyfin/',
'searxng': '/app/searxng/',
'filebrowser': '/app/filebrowser/',
'ollama': '/app/ollama/',
'onlyoffice': '/app/onlyoffice/',
'penpot': '/app/penpot/',
'nextcloud': '/app/nextcloud/',
'vaultwarden': '/app/vaultwarden/',
'jellyfin': '/app/jellyfin/',
'photoprism': '/app/photoprism/',
'immich': '/app/immich/',
'filebrowser': '/app/filebrowser/',
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
'portainer': '/app/portainer/',
'uptime-kuma': '/app/uptime-kuma/',
'immich_server': '/app/immich/',
'tailscale': '/app/tailscale/',
'fedimint': '/app/fedimint/',
'nostr-rs-relay': '/app/nostr-rs-relay/',
'endurain': '/app/endurain/',
'indeedhub': '/app/indeedhub/',
'dwn': '/app/dwn/',
'endurain': '/app/endurain/',
}
/** External HTTPS apps — always loaded directly */
const EXTERNAL_URLS: Record<string, string> = {
'botfights': 'https://botfights.net',
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
@@ -286,15 +360,47 @@ const APP_TITLES: Record<string, string> = {
const appTitle = computed(() => APP_TITLES[appId.value] || appId.value.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
/** Apps that set X-Frame-Options and MUST open in a new tab (can't iframe) */
const NEW_TAB_APPS = new Set([
'btcpay-server', // X-Frame-Options: DENY
'grafana', // X-Frame-Options: deny
'photoprism', // X-Frame-Options: DENY
'homeassistant', // X-Frame-Options: SAMEORIGIN
'vaultwarden', // X-Frame-Options: SAMEORIGIN
'nextcloud', // X-Frame-Options: SAMEORIGIN
'uptime-kuma', // X-Frame-Options: SAMEORIGIN
'penpot', // Not reachable / blocks iframe
])
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
const appUrl = computed(() => {
const url = APP_URLS[appId.value]
if (!url) return ''
// Proxy paths — same origin
if (url.startsWith('/')) return `${window.location.origin}${url}`
// External HTTPS sites — direct
if (url.startsWith('https://')) return url
// Fallback: localhost port URLs (shouldn't reach here normally)
return url.replace('localhost', window.location.hostname)
const id = appId.value
// External HTTPS apps — iframe overlay
const ext = EXTERNAL_URLS[id]
if (ext) return ext
// Apps that need nginx proxy (nostr-provider.js injection for NIP-07)
const proxyPath = PROXY_APPS[id]
if (proxyPath) return `${window.location.origin}${proxyPath}`
// HTTPS: use nginx proxy to avoid mixed content (browser blocks HTTP iframes in HTTPS pages)
if (window.location.protocol === 'https:') {
const httpsProxy = HTTPS_PROXY_PATHS[id]
if (httpsProxy) return `${window.location.origin}${httpsProxy}`
}
// HTTP: direct port access (faster, no proxy overhead)
const port = APP_PORTS[id]
if (!port) return ''
let base = `http://${window.location.hostname}:${port}`
// Append sub-path from query param (e.g. ?path=/tx/abc123)
const subpath = route.query.path as string | undefined
if (subpath) base += subpath
return base
})
// --- Identity ---
@@ -325,6 +431,9 @@ function onIdentitySelected(identity: SelectedIdentity) {
showIdentityPicker.value = false
storeIdentity(identity)
sendIdentity(identity)
// NIP-98 auto-login disabled — apps like IndeedHub have their own login flow
// that properly sets up internal account state. We provide window.nostr via
// nostr-provider.js so the app's built-in "Sign In" button works.
}
async function sendIdentity(identity: SelectedIdentity) {
@@ -339,6 +448,8 @@ async function sendIdentity(identity: SelectedIdentity) {
} catch {}
}
// NIP-98 auto-login removed — apps handle their own login via window.nostr (NIP-07)
// --- Lifecycle ---
function onLoad() {
@@ -393,7 +504,7 @@ function startLoadTimeout() {
function openNewTabAndBack() {
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
goBack()
closeSession()
}
function openNewTab() {
@@ -408,14 +519,14 @@ function iframeGoForward() {
try { iframeRef.value?.contentWindow?.history.forward() } catch {}
}
function goBack() {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
router.back()
function handleBackdropClick() {
closeSession()
}
function closeSession() {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
router.push({ name: 'apps' })
if (isInlinePanel.value) emit('close')
else router.push({ name: 'apps' })
}
function onKeyDown(e: KeyboardEvent) {
@@ -463,12 +574,18 @@ async function handleNostrRequest(event: MessageEvent) {
const { id, method, params } = event.data
const source = event.source as Window | null
if (!source) return
const identityId = getStoredIdentity()?.id || null
const storedIdentity = getStoredIdentity()
const identityId = storedIdentity?.id || null
console.log(`[NIP-07] ${method} identityId=${identityId} storedPubkey=${storedIdentity?.nostr_pubkey?.slice(0, 12) || 'none'}`)
try {
let result: unknown
if (method === 'getPublicKey') {
if (identityId) {
// Use stored nostr_pubkey directly if available (avoids RPC call that may 401)
if (storedIdentity?.nostr_pubkey) {
result = storedIdentity.nostr_pubkey
console.log('[NIP-07] getPublicKey from stored identity:', (result as string).slice(0, 12))
} else if (identityId) {
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'identity.get', params: { id: identityId } })
result = res.nostr_pubkey
} else {
@@ -476,11 +593,13 @@ async function handleNostrRequest(event: MessageEvent) {
result = res.nostr_pubkey
}
} else if (method === 'signEvent') {
console.log(`[NIP-07] signEvent kind=${params.event?.kind} using identity=${identityId || 'node-default'}`)
if (identityId) {
result = await rpcClient.call<unknown>({ method: 'identity.nostr-sign', params: { id: identityId, event: params.event } })
} else {
result = await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
}
console.log('[NIP-07] signEvent OK')
} else if (method === 'getRelays') { result = {} }
else if (method === 'nip04.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
else if (method === 'nip04.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
@@ -489,23 +608,30 @@ async function handleNostrRequest(event: MessageEvent) {
else { throw new Error(`Unsupported NIP-07 method: ${method}`) }
source.postMessage({ type: 'nostr-response', id, result }, '*')
} catch (err) {
console.error(`[NIP-07] ${method} FAILED:`, err instanceof Error ? err.message : err)
source.postMessage({ type: 'nostr-response', id, error: err instanceof Error ? err.message : 'Unknown error' }, '*')
}
}
onMounted(() => {
// Apps that block iframes (X-Frame-Options) — open in new tab, close session
if (mustOpenNewTab.value && appUrl.value) {
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
if (isInlinePanel.value) emit('close')
else router.back()
return
}
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('message', onMessage)
document.addEventListener('click', onClickOutside)
document.addEventListener('fullscreenchange', onFullscreenChange)
// Known blocked apps — show fallback immediately
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
loading.value = false
iframeBlocked.value = true
} else {
startLoadTimeout()
}
// Enter fullscreen if that's the stored mode
if (displayMode.value === 'fullscreen') {
requestAnimationFrame(() => {
sessionRef.value?.requestFullscreen().catch(() => {})
@@ -528,19 +654,18 @@ onBeforeUnmount(() => {
width: 100%;
height: 100%;
}
/* Panel mode — edge-to-edge dark overlay with centered glass panel */
.app-session-backdrop-panel {
/* Inline panel mode — fills content area, no blur, original layout */
.app-session-backdrop-inline {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(8px);
padding: 0;
}
.app-session-inpanel {
.app-session-inline {
display: flex;
flex-direction: column;
overflow: hidden;
@@ -550,10 +675,10 @@ onBeforeUnmount(() => {
}
@media (min-width: 768px) {
.app-session-backdrop-panel {
.app-session-backdrop-inline {
padding: 1.5rem;
}
.app-session-inpanel {
.app-session-inline {
border-radius: 1rem;
max-width: calc(100% - 1rem);
max-height: calc(100vh - 6rem);