Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit 624a2418ed
1578 changed files with 333060 additions and 0 deletions
@@ -0,0 +1,200 @@
<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">
<svg class="w-8 h-8 text-orange-300 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 7v10a2 2 0 002 2h12a2 2 0 002-2V7M4 7l8 5 8-5M4 7l8-4 8 4" />
</svg>
</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">
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
<svg 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">{{ 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="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'
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
// 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,140 @@
<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 selector -->
<div class="relative" ref="modeMenuRef">
<button
class="app-session-btn"
aria-label="Display mode"
title="Display mode"
@click="showModeMenu = !showModeMenu"
>
<!-- Panel icon -->
<svg v-if="displayMode === 'panel'" 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>
<!-- Overlay icon -->
<svg v-else-if="displayMode === 'overlay'" 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>
<!-- Fullscreen icon -->
<svg v-else 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>
<!-- Dropdown -->
<Transition name="menu-fade">
<div v-if="showModeMenu" class="absolute right-0 top-full mt-1 w-48 bg-black/90 border border-white/10 rounded-lg backdrop-blur-xl shadow-2xl overflow-hidden z-50">
<button
class="mode-option"
:class="{ 'mode-option-active': displayMode === 'panel' }"
@click="selectMode('panel')"
>
<svg class="w-4 h-4 shrink-0" 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>
<span>Right panel</span>
</button>
<button
class="mode-option"
:class="{ 'mode-option-active': displayMode === 'overlay' }"
@click="selectMode('overlay')"
>
<svg class="w-4 h-4 shrink-0" 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>
<span>Over whole app</span>
</button>
<button
class="mode-option"
:class="{ 'mode-option-active': displayMode === 'fullscreen' }"
@click="selectMode('fullscreen')"
>
<svg class="w-4 h-4 shrink-0" 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>
<span>Open fullscreen</span>
</button>
</div>
</Transition>
</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 { ref, onMounted, onBeforeUnmount } from 'vue'
import type { DisplayMode } from './appSessionConfig'
defineProps<{
appTitle: string
isRefreshing: boolean
displayMode: DisplayMode
}>()
const emit = defineEmits<{
goBack: []
goForward: []
refresh: []
openNewTab: []
close: []
setMode: [mode: DisplayMode]
}>()
const showModeMenu = ref(false)
const modeMenuRef = ref<HTMLElement | null>(null)
function selectMode(mode: DisplayMode) {
showModeMenu.value = false
emit('setMode', mode)
}
function onClickOutside(e: MouseEvent) {
if (showModeMenu.value && modeMenuRef.value && !modeMenuRef.value.contains(e.target as Node)) {
showModeMenu.value = false
}
}
onMounted(() => {
document.addEventListener('click', onClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside)
})
</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 { 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.168.1.228' },
writable: true,
configurable: true,
})
expect(resolveAppUrl('mempool')).toBe('http://192.168.1.228:4080')
expect(resolveAppUrl('indeedhub')).toBe('http://192.168.1.228:7778')
expect(resolveAppUrl('botfights')).toBe('http://192.168.1.228:9100')
})
it('uses manifest-generated launch ports for apps outside the manual override list', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.228' },
writable: true,
configurable: true,
})
expect(resolveAppUrl('did-wallet')).toBe('http://192.168.1.228:8083')
})
it('does not treat service-only tcp ports as web launch surfaces', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.228' },
writable: true,
configurable: true,
})
expect(resolveAppUrl('meshtastic')).toBe('')
})
it('keeps NetBird on the unified dashboard proxy port', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.228' },
writable: true,
configurable: true,
})
expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('http://192.168.1.228:8087')
})
it('uses backend runtime URLs for apps with dynamic launch surfaces', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.228' },
writable: true,
configurable: true,
})
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.168.1.228:18083')
})
})
@@ -0,0 +1,116 @@
/** 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'
/** 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 'http://' + window.location.hostname + ':8334'
}
if (runtimeUrl && id !== 'netbird') {
let base = runtimeUrl.replace(/localhost/i, window.location.hostname)
if (routeQueryPath) base += routeQueryPath
return base
}
// Local apps launch by host port.
const port = APP_PORTS[id]
if (!port) return ''
let base = 'http://' + window.location.hostname + ':' + String(port)
if (routeQueryPath) base += routeQueryPath
return base
}
/** 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,84 @@
/** 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,
"botfights": 9100,
"btcpay-server": 23000,
"did-wallet": 8083,
"electrumx": 50002,
"fedimint": 8175,
"filebrowser": 8083,
"gitea": 3001,
"grafana": 3000,
"homeassistant": 8123,
"indeedhub": 7778,
"jellyfin": 8096,
"lnd-ui": 18083,
"mempool": 4080,
"mempool-api": 8999,
"morphos-server": 8086,
"nextcloud": 8085,
"nostr-rs-relay": 18081,
"photoprism": 2342,
"portainer": 9000,
"router": 8084,
"searxng": 8888,
"strfry": 8082,
"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",
"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",
"fedimint-gateway": "Fedimint Gateway",
"filebrowser": "File Browser",
"gitea": "Gitea",
"grafana": "Grafana",
"homeassistant": "Home Assistant",
"indeedhub": "IndeeHub",
"jellyfin": "Jellyfin",
"lightning-stack": "Lightning Stack",
"lnd": "LND",
"lnd-ui": "LND UI",
"mempool": "Mempool Explorer",
"mempool-api": "Mempool API",
"meshtastic": "Meshtastic",
"morphos-server": "MorphOS Server",
"nextcloud": "Nextcloud",
"nostr-rs-relay": "Nostr Relay (Rust)",
"photoprism": "PhotoPrism",
"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",
"nextcloud",
"photoprism",
"portainer",
"uptime-kuma",
"vaultwarden",
])
@@ -0,0 +1,78 @@
/** Composable for managing app identity selection and NIP-07 identity injection */
import { type Ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
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() {
if (isIdentityAwareApp(appId.value)) {
const stored = getStoredIdentity()
if (stored) sendIdentity(stored)
else showIdentityPicker.value = true
}
}
/** Handle identity request messages from iframe */
function handleIdentityRequest() {
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 }
}