Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
@@ -0,0 +1,206 @@
<template>
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden app-session-frame-safe">
<Transition name="content-fade">
<!-- Suppressed while the ElectrumX sync overlay below is showing — both
conditions can be true at once during launch (generic loader fires
first, then sync status arrives), and the sync screen is strictly
more informative, so it takes precedence instead of the two
rendering on top of each other. -->
<AppLoadingScreen v-if="loading && !(electrsSync && !electrsSync.stale)" :icon="appIcon" :title="appTitle" :progress="loadProgress" />
</Transition>
<!-- ElectrumX sync screen — shown before the real UI while the on-chain
index is still being built (the Electrum server can't serve clients
until then). Mirrors the Fedimint Guardian "wait page" design. -->
<Transition name="content-fade">
<!-- Sync overlay only while ElectrumX is actively indexing. If the status
goes stale (ElectrumX disconnected/unresponsive) we stop blocking and
let the app's own UI load instead of a loader stuck on top (B7). -->
<div v-if="electrsSync && !electrsSync.stale" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="text-center px-8 w-full max-w-md">
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center overflow-hidden animate-pulse">
<img :src="appIcon" :alt="appTitle" class="w-full h-full object-cover" @error="handleImageError" />
</div>
<h3 class="text-lg font-semibold text-white mb-2">{{ appTitle }} is syncing</h3>
<p class="text-white/50 text-sm mb-5">
ElectrumX is building its index from the blockchain. The UI opens
automatically once it's ready — you can keep using the rest of Archipelago.
</p>
<div class="w-full h-2 rounded-full bg-white/10 overflow-hidden mb-2">
<div
class="h-full bg-orange-400/80 transition-all duration-700"
:style="{ width: `${Math.min(100, Math.max(2, electrsSync.progress_pct)).toFixed(1)}%` }"
></div>
</div>
<p class="text-white/70 text-sm font-medium mb-1">{{ electrsSync.progress_pct.toFixed(1) }}%</p>
<p class="text-white/40 text-xs">
Block {{ electrsSync.indexed_height.toLocaleString() }} of {{ electrsSync.network_height.toLocaleString() }}
<template v-if="electrsSync.index_size"> · {{ electrsSync.index_size }} indexed</template>
</p>
<p v-if="electrsSync.stale" class="text-yellow-400/70 text-xs mt-2">Reconnecting to ElectrumX…</p>
</div>
</div>
</Transition>
<div
v-if="appUrl && !iframeBlocked && (!electrsSync || electrsSync.stale)"
class="absolute inset-0 app-session-frame-scroll-host"
tabindex="-1"
@pointerdown="focusIframe"
@focusin="focusIframe"
>
<iframe
ref="iframeRef"
:key="refreshKey"
:src="appUrl"
class="w-full h-full border-0 iframe-scrollbar-hide"
title="App content"
tabindex="0"
@load="handleIframeLoad"
@error="$emit('iframeError')"
/>
</div>
<!-- Iframe blocked fallback. Suppressed while the ElectrumX sync screen
(the "pre UI") is showing: a still-syncing Electrum server isn't
reachable yet, so the "App not reachable / retry" overlay would just
paint over the sync progress and read as a hard error. -->
<Transition name="content-fade">
<div v-if="iframeBlocked && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="text-center px-8">
<!-- Warm-up uses the app's own icon, pulsing, rather than the padlock:
the padlock reads as "blocked/denied" and this state is neither. -->
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center overflow-hidden" :class="{ 'animate-pulse': warmingUp }">
<img v-if="warmingUp" :src="appIcon" :alt="appTitle" class="w-full h-full object-cover" @error="handleImageError" />
<svg v-else class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-white mb-2">{{ warmingUp ? `${appTitle} is starting…` : blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}</h3>
<p class="text-white/50 text-sm mb-6">
<template v-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
<template v-else-if="warmingUp">The container is running but hasn't finished warming up yet.<br>This screen opens on its own as soon as it answers.<span v-if="autoRetryCount > 0" class="block text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
<template v-else-if="blockedReason">{{ blockedReason }}<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
<template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template>
</p>
<div class="flex flex-wrap items-center justify-center gap-3">
<button
v-if="!mustOpenNewTab"
@click="$emit('refresh')"
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Retry now
</button>
<button
@click="$emit('openNewTabAndBack')"
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open in new tab
</button>
</div>
</div>
</div>
</Transition>
<div v-if="!appUrl" class="absolute inset-0 flex items-center justify-center">
<div class="text-center px-8">
<h3 class="text-lg font-semibold text-white mb-2">App not configured</h3>
<p class="text-white/50 text-sm">No URL found for {{ appId }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
import { handleImageError } from '@/views/apps/appsConfig'
const props = defineProps<{
appUrl: string
appId: string
appTitle: string
appIcon: string
loading: boolean
iframeBlocked: boolean
mustOpenNewTab: boolean
autoRetryCount: number
refreshKey: number
blockedReason?: string
blockedTitle?: string
// True while the container is up but its probe hasn't answered yet and the
// auto-retries are still in flight — a warm-up, not a failure.
warmingUp?: boolean
// Non-null only for ElectrumX while its index is still building — shows the
// sync screen and gates the iframe until status flips to "synced".
electrsSync?: ElectrsSyncStatus | null
}>()
const emit = defineEmits<{
iframeLoad: []
iframeError: []
refresh: []
openNewTabAndBack: []
}>()
const iframeRef = ref<HTMLIFrameElement | null>(null)
// Faux load progress for the loading screen. Cross-origin iframes give no real
// progress events, so ease toward ~92% while loading and snap to 100% on load —
// far better UX than a black screen with a bare spinner.
const loadProgress = ref(0)
let progressTimer: ReturnType<typeof setInterval> | null = null
function stopProgress() {
if (progressTimer) { clearInterval(progressTimer); progressTimer = null }
}
function startProgress() {
stopProgress()
loadProgress.value = 8
progressTimer = setInterval(() => {
// Decelerate as it approaches the cap so it never visually "finishes" early.
const remaining = 92 - loadProgress.value
loadProgress.value += Math.max(0.4, remaining * 0.08)
if (loadProgress.value >= 92) { loadProgress.value = 92; stopProgress() }
}, 180)
}
watch(() => props.loading, (isLoading) => {
if (isLoading) {
startProgress()
} else {
stopProgress()
loadProgress.value = 100
}
}, { immediate: true })
watch(() => props.refreshKey, () => { if (props.loading) startProgress() })
onBeforeUnmount(stopProgress)
function focusIframe() {
iframeRef.value?.focus({ preventScroll: true })
}
async function handleIframeLoad() {
emit('iframeLoad')
await nextTick()
requestAnimationFrame(focusIframe)
}
watch(() => [props.appUrl, props.refreshKey, props.iframeBlocked], async () => {
if (!props.appUrl || props.iframeBlocked) return
await nextTick()
requestAnimationFrame(focusIframe)
}, { immediate: true })
defineExpose({ iframeRef })
</script>
@@ -0,0 +1,95 @@
<template>
<div class="sticky top-0 z-10 hidden md:flex items-center gap-3 border-b border-white/10 px-4 py-3 bg-black/60 backdrop-blur-md md:bg-transparent md:backdrop-blur-none">
<!-- Back / Forward navigation -->
<div class="flex items-center gap-0.5">
<button class="app-session-btn" aria-label="Back" title="Go back" @click="$emit('goBack')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button class="app-session-btn" aria-label="Forward" title="Go forward" @click="$emit('goForward')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<span class="flex-1 truncate text-sm font-medium text-white/90">{{ appTitle }}</span>
<button class="app-session-btn" aria-label="Refresh" :disabled="isRefreshing" @click="$emit('refresh')">
<svg class="w-5 h-5 transition-transform duration-300" :class="{ 'animate-spin': isRefreshing }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<!-- Display mode: one-click switch -->
<div class="flex items-center gap-0.5">
<button
class="app-session-btn"
:class="{ 'app-session-btn-active': displayMode === 'panel' }"
aria-label="Right panel"
title="Right panel"
@click="$emit('setMode', 'panel')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v18m12-18H3a1 1 0 00-1 1v16a1 1 0 001 1h18a1 1 0 001-1V4a1 1 0 00-1-1z" />
</svg>
</button>
<button
class="app-session-btn"
:class="{ 'app-session-btn-active': displayMode === 'overlay' }"
aria-label="Over whole app"
title="Over whole app"
@click="$emit('setMode', 'overlay')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5z" />
</svg>
</button>
<button
class="app-session-btn"
:class="{ 'app-session-btn-active': displayMode === 'fullscreen' }"
aria-label="Open fullscreen"
title="Open fullscreen"
@click="$emit('setMode', 'fullscreen')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
</svg>
</button>
</div>
<button class="app-session-btn" aria-label="Open in new tab" title="Open in new tab" @click="$emit('openNewTab')">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
<button class="app-session-btn" aria-label="Close" @click="$emit('close')">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
</div>
</template>
<script setup lang="ts">
import type { DisplayMode } from './appSessionConfig'
defineProps<{
appTitle: string
isRefreshing: boolean
displayMode: DisplayMode
}>()
defineEmits<{
goBack: []
goForward: []
refresh: []
openNewTab: []
close: []
setMode: [mode: DisplayMode]
}>()
</script>
@@ -0,0 +1,292 @@
<template>
<!-- Mobile gamepad overlay — NES-styled D-pad + action buttons.
Sends postMessage({ type: 'arcade-input', key, player, action }) to iframe. -->
<div class="mobile-gamepad">
<!-- D-Pad (left side) -->
<div class="gamepad-dpad">
<button
class="dpad-btn dpad-up"
@touchstart.prevent="down('ArrowUp')"
@touchend.prevent="up('ArrowUp')"
@touchcancel.prevent="up('ArrowUp')"
aria-label="Up"
>
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 4l-6 8h12z"/></svg>
</button>
<button
class="dpad-btn dpad-left"
@touchstart.prevent="down('ArrowLeft')"
@touchend.prevent="up('ArrowLeft')"
@touchcancel.prevent="up('ArrowLeft')"
aria-label="Left"
>
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4 12l8-6v12z"/></svg>
</button>
<div class="dpad-center" />
<button
class="dpad-btn dpad-right"
@touchstart.prevent="down('ArrowRight')"
@touchend.prevent="up('ArrowRight')"
@touchcancel.prevent="up('ArrowRight')"
aria-label="Right"
>
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 12l-8-6v12z"/></svg>
</button>
<button
class="dpad-btn dpad-down"
@touchstart.prevent="down('ArrowDown')"
@touchend.prevent="up('ArrowDown')"
@touchcancel.prevent="up('ArrowDown')"
aria-label="Down"
>
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 20l6-8H6z"/></svg>
</button>
</div>
<!-- Center: START / SELECT + utility buttons -->
<div class="gamepad-meta">
<div class="meta-row">
<button
class="meta-btn"
@touchstart.prevent="tap('Escape')"
aria-label="Select"
>SEL</button>
<button
class="meta-btn"
@touchstart.prevent="tap('Enter')"
aria-label="Start"
>START</button>
</div>
<div class="meta-utility">
<button class="util-btn" aria-label="Refresh" @click="$emit('refresh')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button class="util-btn" aria-label="Open in browser" @click="$emit('openBrowser')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
<button class="util-btn" aria-label="Close" @click="$emit('close')">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Action buttons (right side) — triangle layout -->
<div class="gamepad-actions">
<button
class="action-btn action-c"
@touchstart.prevent="down('c')"
@touchend.prevent="up('c')"
@touchcancel.prevent="up('c')"
aria-label="Special"
></button>
<div class="action-row">
<button
class="action-btn action-b"
@touchstart.prevent="down('b')"
@touchend.prevent="up('b')"
@touchcancel.prevent="up('b')"
aria-label="Kick"
></button>
<button
class="action-btn action-a"
@touchstart.prevent="down('a')"
@touchend.prevent="up('a')"
@touchcancel.prevent="up('a')"
aria-label="Punch"
></button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
iframeRef: HTMLIFrameElement | null
player?: number
}>()
defineEmits<{
refresh: []
openBrowser: []
close: []
}>()
function send(key: string, action: 'down' | 'up') {
props.iframeRef?.contentWindow?.postMessage(
{ type: 'arcade-input', key, player: props.player ?? 1, action },
'*'
)
}
function down(key: string) { send(key, 'down') }
function up(key: string) { send(key, 'up') }
function tap(key: string) { send(key, 'down'); setTimeout(() => send(key, 'up'), 80) }
</script>
<style scoped>
.mobile-gamepad {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: 12px 20px;
padding-bottom: calc(12px + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
background: rgba(0, 0, 0, 0.85);
border-top: 1px solid rgba(255, 255, 255, 0.06);
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
/* ── D-Pad ── */
.gamepad-dpad {
display: grid;
grid-template-columns: 48px 48px 48px;
grid-template-rows: 48px 48px 48px;
gap: 2px;
flex-shrink: 0;
}
.dpad-btn {
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
color: rgba(255, 255, 255, 0.7);
transition: background 0.1s;
}
.dpad-btn:active {
background: rgba(251, 146, 60, 0.3);
color: white;
}
.dpad-btn svg {
width: 20px;
height: 20px;
}
.dpad-up { grid-column: 2; grid-row: 1; }
.dpad-left { grid-column: 1; grid-row: 2; }
.dpad-center {
grid-column: 2;
grid-row: 2;
background: rgba(255, 255, 255, 0.03);
border-radius: 4px;
}
.dpad-right { grid-column: 3; grid-row: 2; }
.dpad-down { grid-column: 2; grid-row: 3; }
/* ── Meta buttons (START / SELECT) ── */
.gamepad-meta {
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
}
.meta-row {
display: flex;
gap: 8px;
align-items: center;
}
.meta-utility {
display: flex;
gap: 6px;
align-items: center;
}
.util-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.35);
transition: background 0.15s, color 0.15s;
}
.util-btn:active {
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.7);
}
.meta-btn {
padding: 6px 16px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.45);
font-size: 10px;
font-weight: 700;
letter-spacing: 1.5px;
text-transform: uppercase;
}
.meta-btn:active {
background: rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.8);
}
/* ── Action buttons — triangle layout ── */
.gamepad-actions {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
gap: 6px;
}
.action-row {
display: flex;
gap: 10px;
}
.action-btn {
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid;
transition: background 0.1s, transform 0.1s;
}
.action-btn:active {
transform: scale(0.92);
}
.action-a {
background: rgba(251, 146, 60, 0.2);
border-color: rgba(251, 146, 60, 0.5);
color: #fb923c;
}
.action-a:active {
background: rgba(251, 146, 60, 0.45);
}
.action-b {
background: rgba(96, 165, 250, 0.2);
border-color: rgba(96, 165, 250, 0.5);
color: #60a5fa;
}
.action-b:active {
background: rgba(96, 165, 250, 0.45);
}
.action-c {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.4);
color: #ffffff;
}
.action-c:active {
background: rgba(255, 255, 255, 0.3);
}
</style>
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import AppSessionFrame from '../AppSessionFrame.vue'
// Regression cover for the operator-reported defect: a container that is up
// but has not finished warming up (bitcoind serving RPC -28, lnd before the
// wallet unlocks) rendered the hard "App not reachable" failure copy for the
// whole warm-up window. The retry machinery already tolerated it — only the
// headline lied.
function mountFrame(props: Record<string, unknown> = {}) {
return mount(AppSessionFrame, {
props: {
appUrl: 'http://localhost:8332/',
appId: 'bitcoin-knots',
appTitle: 'Bitcoin',
appIcon: '/icons/bitcoin.png',
loading: false,
iframeBlocked: true,
mustOpenNewTab: false,
autoRetryCount: 1,
refreshKey: 0,
...props,
},
global: { stubs: { AppLoadingScreen: true, Transition: false } },
})
}
describe('AppSessionFrame warm-up state', () => {
it('reads as starting, not unreachable, while the container is warming up', () => {
const text = mountFrame({ warmingUp: true }).text()
expect(text).toContain('Bitcoin is starting…')
expect(text).not.toContain('App not reachable')
})
it('says the container is running so the copy does not imply it is stopped', () => {
const text = mountFrame({ warmingUp: true }).text()
expect(text).toContain("container is running but hasn't finished warming up")
expect(text).not.toContain('the container is stopped')
})
it('still surfaces the automatic re-check while warming up', () => {
expect(mountFrame({ warmingUp: true, autoRetryCount: 3 }).text()).toContain(
'Checking again automatically (3)',
)
})
it('reverts to the real failure once warm-up is over (retries exhausted)', () => {
const text = mountFrame({ warmingUp: false, autoRetryCount: 6 }).text()
expect(text).toContain('App not reachable')
expect(text).not.toContain('is starting…')
})
it('leaves the explicit blocked-reason path untouched', () => {
const text = mountFrame({
warmingUp: false,
blockedReason: 'Waiting for Bitcoin to finish syncing.',
blockedTitle: 'Waiting for Bitcoin sync',
}).text()
expect(text).toContain('Waiting for Bitcoin sync')
expect(text).not.toContain('App not reachable')
})
it('leaves the new-tab path untouched', () => {
const text = mountFrame({ warmingUp: false, mustOpenNewTab: true }).text()
expect(text).toContain('This app opens in a new tab')
})
})
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { appOrigin, matchPageScheme } from '../appSessionConfig'
// An HTTPS dashboard cannot embed an HTTP app frame — browsers block it as
// mixed content — so the app origin has to follow the page's scheme. Plain-HTTP
// nodes must be completely unaffected, which is what most of these pin.
function setLocation(protocol: string, hostname: string) {
Object.defineProperty(window, 'location', {
value: { protocol, hostname },
writable: true,
configurable: true,
})
}
afterEach(() => vi.unstubAllGlobals())
describe('appOrigin', () => {
it('stays on http for an http dashboard', () => {
setLocation('http:', 'test-node.local')
expect(appOrigin(8334)).toBe('http://test-node.local:8334')
})
it('follows an https dashboard onto the app port', () => {
setLocation('https:', 'test-node.local')
expect(appOrigin(8334)).toBe('https://test-node.local:8334')
})
it('keeps the hostname the user actually typed, not a fixed name', () => {
setLocation('https:', '100.64.0.5')
expect(appOrigin(3000)).toBe('https://100.64.0.5:3000')
})
})
describe('matchPageScheme', () => {
it('leaves backend-reported http URLs alone on an http page', () => {
setLocation('http:', 'node')
expect(matchPageScheme('http://node:8080/app')).toBe('http://node:8080/app')
})
it('upgrades a backend-reported http URL on an https page', () => {
setLocation('https:', 'node')
expect(matchPageScheme('http://node:8080/app')).toBe('https://node:8080/app')
})
it('does not touch anything but the scheme', () => {
setLocation('https:', 'node')
expect(matchPageScheme('http://node:8080/a/b?c=1#d')).toBe('https://node:8080/a/b?c=1#d')
})
it('leaves an already-https URL untouched', () => {
setLocation('https:', 'node')
expect(matchPageScheme('https://node:8080/app')).toBe('https://node:8080/app')
})
})
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { NEW_TAB_APPS, resolveAppUrl } from '../appSessionConfig'
import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
describe('appSessionConfig', () => {
it('keeps manifest-owned new-tab apps marked on every viewport', () => {
expect(NEW_TAB_APPS.has('btcpay-server')).toBe(true)
expect(NEW_TAB_APPS.has('photoprism')).toBe(true)
expect(GENERATED_NEW_TAB_APPS.has('photoprism')).toBe(true)
})
it('keeps frontend-only new-tab overrides for apps without generated metadata', () => {
expect(NEW_TAB_APPS.has('tailscale')).toBe(true)
expect(GENERATED_NEW_TAB_APPS.has('tailscale')).toBe(false)
})
it('resolves direct app ports against the current browser host', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.0.2.10' },
writable: true,
configurable: true,
})
expect(resolveAppUrl('mempool')).toBe('http://192.0.2.10:4080')
expect(resolveAppUrl('indeedhub')).toBe('http://192.0.2.10:7778')
expect(resolveAppUrl('botfights')).toBe('http://192.0.2.10:9100')
})
it('uses manifest-generated launch ports for apps outside the manual override list', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.0.2.10' },
writable: true,
configurable: true,
})
// did-wallet's manifest publishes host port 8088 (apps/did-wallet/
// manifest.yml) — assert against the manifest-generated value, which is
// exactly what this test exists to protect.
expect(resolveAppUrl('did-wallet')).toBe('http://192.0.2.10:8088')
})
it('does not treat service-only tcp ports as web launch surfaces', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.0.2.10' },
writable: true,
configurable: true,
})
expect(resolveAppUrl('meshtastic')).toBe('')
})
it('keeps NetBird on the unified dashboard proxy port', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.0.2.10' },
writable: true,
configurable: true,
})
expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('http://192.0.2.10:8087')
})
it('uses backend runtime URLs for apps with dynamic launch surfaces', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.0.2.10' },
writable: true,
configurable: true,
})
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.0.2.10:18083')
})
})
@@ -0,0 +1,172 @@
/** Static configuration maps for app session routing and display */
import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig'
import { IS_DEMO, demoAppUrl } from '@/composables/useDemoIntro'
export type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
export const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
/** Per-app default display mode. Used when the user hasn't explicitly picked
* a mode for that app (an explicit pick is remembered per app and wins).
* Apps not listed default to 'panel'. */
export const APP_DEFAULT_DISPLAY_MODE: Record<string, DisplayMode> = {
}
/** Initial display mode for an app session: per-app user choice → per-app
* default → panel. Strictly per-app — deliberately NO global fallback, so
* one app's mode change can never affect how another app opens. */
export function initialDisplayMode(id: string): DisplayMode {
const perApp = localStorage.getItem(`${DISPLAY_MODE_KEY}:${id}`) as DisplayMode | null
if (perApp === 'panel' || perApp === 'overlay' || perApp === 'fullscreen') return perApp
return APP_DEFAULT_DISPLAY_MODE[id] ?? 'panel'
}
/** Container apps: manifest-generated launch ports plus overrides for companions and aliases. */
export const APP_PORTS: Record<string, number> = {
...GENERATED_APP_PORTS,
'bitcoin-knots': 8334,
'bitcoin-core': 8334,
'bitcoin-ui': 8334,
'electrumx': 50002,
'electrs': 50002,
'archy-electrs-ui': 50002,
'mempool-electrs': 50002,
'lnd': 18083,
'archy-lnd-ui': 18083,
'mempool-web': 4080,
'ollama': 11434,
'immich': 2283,
'immich_server': 2283,
'nginx-proxy-manager': 8081,
'netbird': 8087,
'tailscale': 8240,
'fedimintd': 8175,
'fedimint-gateway': 8176,
'endurain': 8080,
}
/** Apps that need nginx proxy for iframe embedding.
* IndeeHub web UI is on 7778. Port 7777 is the Nostr relay. */
export const PROXY_APPS: Record<string, string> = {
'gitea': '/app/gitea/',
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
'uptime-kuma': '/app/uptime-kuma/',
}
/** App launches use direct ports. Do not route through /app/... path proxies. */
export const HTTPS_PROXY_PATHS: Record<string, string> = {
}
/** External HTTPS apps -- always loaded directly */
export const EXTERNAL_URLS: Record<string, string> = {
'nostrudel': 'https://nostrudel.ninja',
}
export const APP_TITLES: Record<string, string> = {
...GENERATED_APP_TITLES,
'bitcoin-knots': 'Bitcoin Knots', 'bitcoin-core': 'Bitcoin Core',
'btcpay-server': 'BTCPay Server', 'indeedhub': 'Indeehub',
'botfights': 'BotFights', 'gitea': 'Gitea',
'homeassistant': 'Home Assistant', 'uptime-kuma': 'Uptime Kuma',
'nginx-proxy-manager': 'Nginx Proxy Manager',
'nostrudel': 'noStrudel',
}
/** Apps that set X-Frame-Options and MUST open in a new tab (can't iframe) */
export const NEW_TAB_APPS = new Set([
...GENERATED_NEW_TAB_APPS,
'nginx-proxy-manager',
'tailscale',
])
/** Sites known to block iframes -- skip the timeout and go straight to fallback */
export const IFRAME_BLOCKED_APPS = new Set<string>([])
/** Resolve app URL using direct port mapping (source of truth) */
export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?: string): string {
// Demo: route to the app's mock UI or real external site (mempool.space,
// indee.tx1138.com). Carry through a deep-link path (e.g. /tx/<hash> for
// mempool). Non-demoable apps fall through to a generic notice page.
if (IS_DEMO) {
const base = demoAppUrl(id)
if (base) {
if (!routeQueryPath) return base
// Join without a double slash (/app/mempool/ + /tx/x → /app/mempool/tx/x)
return base.replace(/\/+$/, '') + (routeQueryPath.startsWith('/') ? routeQueryPath : '/' + routeQueryPath)
}
return `/app/${id}/`
}
// External HTTPS apps
const ext = EXTERNAL_URLS[id]
if (ext) return ext
// Bitcoin UI is a host-network companion on :8334. Do not launch it via
// /app/bitcoin-ui/: the static UI is built for root and renders a blank
// shell when proxied under a path prefix on some nodes.
if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') {
if (import.meta.env.DEV) return '/app/bitcoin-ui/'
return appOrigin(8334)
}
if (runtimeUrl && id !== 'netbird') {
let base = runtimeUrl.replace(/localhost/i, window.location.hostname)
// The backend reports runtime URLs as http:// because that is how the app
// binds locally. Sent to a browser on an HTTPS dashboard that is mixed
// content and the frame is blocked outright, so follow the page instead.
base = matchPageScheme(base)
if (routeQueryPath) base += routeQueryPath
return base
}
// Local apps launch by host port.
const port = APP_PORTS[id]
if (!port) return ''
let base = appOrigin(port)
if (routeQueryPath) base += routeQueryPath
return base
}
/**
* An app's origin on this host, on the SAME scheme as the page.
*
* An HTTPS dashboard cannot embed an HTTP frame at all — browsers block it as
* mixed content before any cookie question arises — and it is also what makes
* the two origins schemefully cross-site, so the session cookie is withheld.
* Following the page's scheme fixes both at once and keeps plain HTTP working
* exactly as before on nodes that serve the dashboard over HTTP.
*
* On HTTPS this requires the app port to actually serve TLS with a certificate
* the browser trusts — see scripts/setup-node-ca.sh and Settings → System →
* Node certificate. A certificate warning cannot be accepted inside an iframe,
* so an untrusted app port renders nothing rather than prompting.
*/
export function appOrigin(port: number): string {
return `${pageScheme()}//${window.location.hostname}:${port}`
}
/** Rewrite a URL's scheme to the page's, leaving everything else alone. */
export function matchPageScheme(url: string): string {
if (pageScheme() !== 'https:') return url
return url.replace(/^http:\/\//i, 'https://')
}
/**
* The page's scheme, defaulting to http.
*
* A real browser always has location.protocol; this defends the non-browser
* cases (tests, SSR-ish contexts) where it can be absent. Defaulting to http
* is the safe direction — it preserves today's behaviour rather than inventing
* an https URL for a port that may not serve TLS.
*/
function pageScheme(): string {
const p = window.location?.protocol
return p === 'https:' || p === 'http:' ? p : 'http:'
}
/** Resolve a human-readable title for an app */
export function resolveAppTitle(id: string): string {
return APP_TITLES[id] || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
@@ -0,0 +1,110 @@
/** Generated by scripts/generate-app-catalog.py. Do not edit manually. */
export const GENERATED_APP_PORTS: Record<string, number> = {
"aiui": 5180,
"archy-mempool-web": 4080,
"archy-nbxplorer": 32838,
"bitcoin-ui": 8334,
"botfights": 9100,
"btcpay-server": 23000,
"did-wallet": 8088,
"electrs-ui": 50002,
"electrumx": 50002,
"fedimint": 8175,
"filebrowser": 8083,
"fips-ui": 8336,
"gitea": 3001,
"grafana": 3000,
"homeassistant": 8123,
"immich": 2283,
"indeedhub": 7778,
"jellyfin": 8096,
"lnd-ui": 18083,
"mempool": 4080,
"mempool-api": 8999,
"morphos-server": 8089,
"netbird": 8087,
"nextcloud": 8085,
"nostr-rs-relay": 18081,
"photoprism": 2342,
"pine": 10380,
"portainer": 9000,
"router": 8084,
"searxng": 8888,
"strfry": 8090,
"uptime-kuma": 3002,
"vaultwarden": 8082,
}
export const GENERATED_APP_TITLES: Record<string, string> = {
"aiui": "AI Assistant",
"archy-btcpay-db": "BTCPay Postgres",
"archy-mempool-db": "Mempool MariaDB",
"archy-mempool-web": "Mempool Web",
"archy-nbxplorer": "NBXplorer",
"barkd": "Ark Wallet",
"bitcoin-core": "Bitcoin Core",
"bitcoin-knots": "Bitcoin Knots",
"bitcoin-ui": "Bitcoin UI",
"botfights": "BotFights",
"btcpay-server": "BTCPay Server",
"core-lightning": "Core Lightning (CLN)",
"did-wallet": "Web5 DID Wallet",
"electrs-ui": "Electrs UI",
"electrumx": "ElectrumX",
"fedimint": "Fedimint Guardian",
"fedimint-clientd": "Fedimint Client",
"fedimint-gateway": "Fedimint Gateway",
"filebrowser": "File Browser",
"fips-ui": "FIPS Mesh",
"gitea": "Gitea",
"grafana": "Grafana",
"homeassistant": "Home Assistant",
"immich": "Immich",
"immich-postgres": "Immich Postgres",
"immich-redis": "Immich Redis",
"indeedhub": "IndeeHub",
"indeedhub-api": "IndeedHub API",
"indeedhub-ffmpeg": "IndeedHub FFmpeg Worker",
"indeedhub-minio": "IndeedHub MinIO",
"indeedhub-postgres": "IndeedHub Postgres",
"indeedhub-redis": "IndeedHub Redis",
"indeedhub-relay": "IndeedHub Nostr Relay",
"jellyfin": "Jellyfin",
"lightning-stack": "Lightning Stack",
"lnd": "LND",
"lnd-ui": "LND UI",
"mempool": "Mempool Explorer",
"mempool-api": "Mempool API",
"morphos-server": "MorphOS Server",
"netbird": "NetBird",
"netbird-dashboard": "NetBird Dashboard",
"netbird-server": "NetBird Server",
"nextcloud": "Nextcloud",
"nostr-rs-relay": "Nostr Relay (Rust)",
"photoprism": "PhotoPrism",
"pine": "Pine",
"pine-openwakeword": "Pine Wake Word (openWakeWord)",
"pine-piper": "Pine Piper (TTS)",
"pine-whisper": "Pine Whisper (STT)",
"portainer": "Portainer",
"router": "Mesh Router",
"searxng": "SearXNG",
"strfry": "Strfry Nostr Relay",
"uptime-kuma": "Uptime Kuma",
"vaultwarden": "Vaultwarden",
}
export const GENERATED_NEW_TAB_APPS = new Set<string>([
"btcpay-server",
"gitea",
"grafana",
"homeassistant",
"immich",
"nextcloud",
"photoprism",
"pine",
"portainer",
"uptime-kuma",
"vaultwarden",
])
@@ -0,0 +1,85 @@
/** Composable for managing app identity selection and NIP-07 identity injection */
import { type Ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { IS_DEMO } from '@/composables/useDemoIntro'
const IDENTITY_KEY = 'archipelago_app_identity_'
export interface SelectedIdentity {
id: string
name: string
did: string
pubkey: string
nostr_pubkey?: string
nostr_npub?: string
}
function isIdentityAwareApp(id: string): boolean {
return id === 'indeedhub' || id === 'nostrudel'
}
export function useAppIdentity(
appId: Ref<string>,
iframeRef: Ref<HTMLIFrameElement | null>,
showIdentityPicker: Ref<boolean>,
) {
function getStoredIdentity(): SelectedIdentity | null {
try {
const stored = localStorage.getItem(IDENTITY_KEY + appId.value)
return stored ? JSON.parse(stored) as SelectedIdentity : null
} catch { return null }
}
function storeIdentity(identity: SelectedIdentity) {
try { localStorage.setItem(IDENTITY_KEY + appId.value, JSON.stringify(identity)) } catch {}
}
async function sendIdentity(identity: SelectedIdentity) {
try {
const challenge = `archipelago-identity:${Date.now()}`
const sigRes = await rpcClient.call<{ signature: string }>({ method: 'identity.sign', params: { id: identity.id, message: challenge } })
iframeRef.value?.contentWindow?.postMessage({
type: 'archipelago:identity', did: identity.did, name: identity.name,
pubkey: identity.pubkey, nostr_pubkey: identity.nostr_pubkey || null,
nostr_npub: identity.nostr_npub || null, challenge, signature: sigRes.signature
}, '*')
} catch {}
}
function onIdentitySelected(identity: SelectedIdentity) {
showIdentityPicker.value = false
storeIdentity(identity)
sendIdentity(identity)
}
/** Called on iframe load to inject identity if the app supports it */
function onIframeLoadIdentity() {
// Public demo: never interrupt the visitor with the identity picker —
// the embedded IndeeHub is already signed in via the seeded throwaway
// demo account (see docker/indee-demo-signin.js). Real-node behavior is
// untouched (IS_DEMO is compile-time false there).
if (IS_DEMO) return
if (isIdentityAwareApp(appId.value)) {
const stored = getStoredIdentity()
if (stored) sendIdentity(stored)
else showIdentityPicker.value = true
}
}
/** Handle identity request messages from iframe */
function handleIdentityRequest() {
if (IS_DEMO) return
const stored = getStoredIdentity()
if (stored) sendIdentity(stored)
else showIdentityPicker.value = true
}
return {
getStoredIdentity,
sendIdentity,
onIdentitySelected,
onIframeLoadIdentity,
handleIdentityRequest,
}
}
@@ -0,0 +1,58 @@
/** Composable for NIP-07 Nostr signing bridge between parent and iframe */
import { rpcClient } from '@/api/rpc-client'
import type { SelectedIdentity } from './useAppIdentity'
export function useNostrBridge(
getStoredIdentity: () => SelectedIdentity | null,
getAppUrl: () => string,
) {
async function handleNostrRequest(event: MessageEvent) {
const { id, method, params } = event.data
const source = event.source as Window | null
if (!source) return
const storedIdentity = getStoredIdentity()
const identityId = storedIdentity?.id || null
if (import.meta.env.DEV) console.log(`[NIP-07] ${method} identityId=${identityId} storedPubkey=${storedIdentity?.nostr_pubkey?.slice(0, 12) || 'none'}`)
try {
let result: unknown
if (method === 'getPublicKey') {
// Use stored nostr_pubkey directly if available (avoids RPC call that may 401)
if (storedIdentity?.nostr_pubkey) {
result = storedIdentity.nostr_pubkey
if (import.meta.env.DEV) 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 {
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' })
result = res.nostr_pubkey
}
} else if (method === 'signEvent') {
if (import.meta.env.DEV) 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 } })
}
if (import.meta.env.DEV) 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 }
else if (method === 'nip44.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
else if (method === 'nip44.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
else { throw new Error(`Unsupported NIP-07 method: ${method}`) }
const url = getAppUrl()
const targetOrigin = url ? new URL(url).origin : '*'
source.postMessage({ type: 'nostr-response', id, result }, targetOrigin)
} catch (err) {
if (import.meta.env.DEV) console.error(`[NIP-07] ${method} FAILED:`, err instanceof Error ? err.message : err)
const url = getAppUrl()
const targetOrigin = url ? new URL(url).origin : '*'
source.postMessage({ type: 'nostr-response', id, error: err instanceof Error ? err.message : 'Unknown error' }, targetOrigin)
}
}
return { handleNostrRequest }
}