bug fixing and deploy and build diagnostics
This commit is contained in:
@@ -10,178 +10,39 @@
|
||||
:class="panelClasses"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Header bar -->
|
||||
<div class="sticky top-0 z-10 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="iframeGoBack">
|
||||
<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="iframeGoForward">
|
||||
<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>
|
||||
<AppSessionHeader
|
||||
:app-title="appTitle"
|
||||
:is-refreshing="isRefreshing"
|
||||
:display-mode="displayMode"
|
||||
@go-back="iframeGoBack"
|
||||
@go-forward="iframeGoForward"
|
||||
@refresh="refresh"
|
||||
@open-new-tab="openNewTab"
|
||||
@close="closeSession"
|
||||
@set-mode="setMode"
|
||||
/>
|
||||
|
||||
<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="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="setMode('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="setMode('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="setMode('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="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="closeSession">
|
||||
<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>
|
||||
|
||||
<!-- App frame -->
|
||||
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden">
|
||||
<Transition name="content-fade">
|
||||
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
|
||||
<svg class="animate-spin h-8 w-8 text-blue-400" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<iframe
|
||||
v-if="appUrl && !iframeBlocked"
|
||||
ref="iframeRef"
|
||||
:key="refreshKey"
|
||||
:src="appUrl"
|
||||
class="absolute inset-0 w-full h-full border-0 iframe-scrollbar-hide"
|
||||
title="App content"
|
||||
@load="onLoad"
|
||||
@error="onError"
|
||||
/>
|
||||
|
||||
<!-- Iframe blocked fallback -->
|
||||
<Transition name="content-fade">
|
||||
<div v-if="iframeBlocked" 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">{{ 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>{{ 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 items-center gap-3">
|
||||
<button
|
||||
v-if="!mustOpenNewTab"
|
||||
@click="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="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>
|
||||
<AppSessionFrame
|
||||
ref="frameRef"
|
||||
:app-url="appUrl"
|
||||
:app-id="appId"
|
||||
:app-title="appTitle"
|
||||
:loading="loading"
|
||||
:iframe-blocked="iframeBlocked"
|
||||
:must-open-new-tab="mustOpenNewTab"
|
||||
:auto-retry-count="autoRetryCount"
|
||||
:refresh-key="refreshKey"
|
||||
@iframe-load="onLoad"
|
||||
@iframe-error="onError"
|
||||
@refresh="refresh"
|
||||
@open-new-tab-and-back="openNewTabAndBack"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NostrIdentityPicker
|
||||
:show="showIdentityPicker"
|
||||
:app-name="appTitle"
|
||||
@select="onIdentitySelected"
|
||||
@select="identity.onIdentitySelected"
|
||||
@cancel="showIdentityPicker = false"
|
||||
/>
|
||||
</div>
|
||||
@@ -192,13 +53,16 @@
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
import AppSessionHeader from './appSession/AppSessionHeader.vue'
|
||||
import AppSessionFrame from './appSession/AppSessionFrame.vue'
|
||||
import {
|
||||
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
|
||||
resolveAppUrl, resolveAppTitle,
|
||||
} from './appSession/appSessionConfig'
|
||||
import { useAppIdentity } from './appSession/useAppIdentity'
|
||||
import { useNostrBridge } from './appSession/useNostrBridge'
|
||||
|
||||
const props = defineProps<{
|
||||
appIdProp?: string
|
||||
@@ -215,37 +79,55 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const sessionRef = ref<HTMLElement | null>(null)
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
||||
const modeMenuRef = ref<HTMLElement | null>(null)
|
||||
const frameRef = ref<InstanceType<typeof AppSessionFrame> | null>(null)
|
||||
const loading = ref(true)
|
||||
const isRefreshing = ref(false)
|
||||
const iframeBlocked = ref(false)
|
||||
const refreshKey = ref(0)
|
||||
const showIdentityPicker = ref(false)
|
||||
const showModeMenu = ref(false)
|
||||
const autoRetryCount = ref(0)
|
||||
let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let autoRetryId: ReturnType<typeof setTimeout> | null = null
|
||||
let iframeCheckId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** Sites known to block iframes — skip the timeout and go straight to fallback */
|
||||
const IFRAME_BLOCKED_APPS = new Set<string>([])
|
||||
|
||||
// Display mode — persisted in localStorage
|
||||
// Display mode -- persisted in localStorage
|
||||
const displayMode = ref<DisplayMode>(
|
||||
(localStorage.getItem(DISPLAY_MODE_KEY) as DisplayMode) || 'panel'
|
||||
)
|
||||
|
||||
const appId = computed(() => {
|
||||
const id = props.appIdProp || (route.params.appId as string)
|
||||
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
|
||||
router.replace('/apps')
|
||||
return ''
|
||||
}
|
||||
return id
|
||||
})
|
||||
|
||||
const appTitle = computed(() => resolveAppTitle(appId.value))
|
||||
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
|
||||
|
||||
const appUrl = computed(() => {
|
||||
return resolveAppUrl(appId.value, route.query.path as string | undefined)
|
||||
})
|
||||
|
||||
// --- Identity & Nostr bridge ---
|
||||
|
||||
const iframeRef = computed(() => frameRef.value?.iframeRef ?? null)
|
||||
|
||||
const identity = useAppIdentity(appId, iframeRef, showIdentityPicker)
|
||||
const nostrBridge = useNostrBridge(identity.getStoredIdentity, () => appUrl.value)
|
||||
|
||||
// --- Display mode ---
|
||||
|
||||
function setMode(mode: DisplayMode) {
|
||||
// Exit fullscreen first if switching away
|
||||
if (displayMode.value === 'fullscreen' && document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {})
|
||||
}
|
||||
displayMode.value = mode
|
||||
localStorage.setItem(DISPLAY_MODE_KEY, mode)
|
||||
showModeMenu.value = false
|
||||
|
||||
// Switch from inline panel → route-based overlay/fullscreen
|
||||
// Switch from inline panel to route-based overlay/fullscreen
|
||||
if (isInlinePanel.value && mode !== 'panel') {
|
||||
const id = appId.value
|
||||
emit('close')
|
||||
@@ -253,7 +135,7 @@ function setMode(mode: DisplayMode) {
|
||||
return
|
||||
}
|
||||
|
||||
// Switch from route-based → inline panel
|
||||
// Switch from route-based to inline panel
|
||||
if (!isInlinePanel.value && mode === 'panel') {
|
||||
const id = appId.value
|
||||
const launcher = useAppLauncherStore()
|
||||
@@ -263,7 +145,6 @@ function setMode(mode: DisplayMode) {
|
||||
return
|
||||
}
|
||||
|
||||
// Enter fullscreen if selected
|
||||
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
|
||||
sessionRef.value.requestFullscreen().catch(() => {})
|
||||
}
|
||||
@@ -282,221 +163,7 @@ const panelClasses = computed(() => {
|
||||
return `${base} app-session-overlay`
|
||||
})
|
||||
|
||||
const appId = computed(() => {
|
||||
const id = props.appIdProp || (route.params.appId as string)
|
||||
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
|
||||
router.replace('/apps')
|
||||
return ''
|
||||
}
|
||||
return id
|
||||
})
|
||||
|
||||
/** 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': 8044,
|
||||
'penpot': 9001,
|
||||
'nextcloud': 8085,
|
||||
'vaultwarden': 8082,
|
||||
'jellyfin': 8096,
|
||||
'photoprism': 2342,
|
||||
'immich': 2283,
|
||||
'immich_server': 2283,
|
||||
'filebrowser': 8083,
|
||||
'nginx-proxy-manager': 8181,
|
||||
'portainer': 9000,
|
||||
'uptime-kuma': 3001,
|
||||
'fedimint': 8175,
|
||||
'fedimintd': 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/',
|
||||
'bitcoin-ui': '/app/bitcoin-ui/',
|
||||
'lnd': '/app/lnd/',
|
||||
'electrumx': '/app/electrs/',
|
||||
'electrs': '/app/electrs/',
|
||||
'mempool-electrs': '/app/electrs/',
|
||||
'mempool': '/app/mempool/',
|
||||
'mempool-web': '/app/mempool/',
|
||||
'archy-mempool-web': '/app/mempool/',
|
||||
'fedimint': '/app/fedimint/',
|
||||
'fedimintd': '/app/fedimint/',
|
||||
'fedimint-gateway': '/app/fedimint-gateway/',
|
||||
'jellyfin': '/app/jellyfin/',
|
||||
'searxng': '/app/searxng/',
|
||||
'filebrowser': '/app/filebrowser/',
|
||||
'ollama': '/app/ollama/',
|
||||
'onlyoffice': '/app/onlyoffice/',
|
||||
'immich': '/app/immich/',
|
||||
'immich_server': '/app/immich/',
|
||||
'portainer': '/app/portainer/',
|
||||
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
|
||||
'uptime-kuma': '/app/uptime-kuma/',
|
||||
'homeassistant': '/app/homeassistant/',
|
||||
'vaultwarden': '/app/vaultwarden/',
|
||||
'photoprism': '/app/photoprism/',
|
||||
'endurain': '/app/endurain/',
|
||||
'dwn': '/app/dwn/',
|
||||
}
|
||||
|
||||
/** 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',
|
||||
'call-the-operator': 'https://cta.tx1138.com',
|
||||
// 'arch-presentation': hidden until X-Frame-Options fixed on present.l484.com
|
||||
'syntropy-institute': 'https://syntropy.institute',
|
||||
't-zero': 'https://teeminuszero.net',
|
||||
'nostrudel': 'https://nostrudel.ninja',
|
||||
'tailscale': 'https://login.tailscale.com/admin/machines',
|
||||
}
|
||||
|
||||
const APP_TITLES: Record<string, string> = {
|
||||
'bitcoin-knots': 'Bitcoin', 'btcpay-server': 'BTCPay Server', 'indeedhub': 'Indeehub',
|
||||
'botfights': 'BotFights', '484-kitchen': '484 Kitchen', 'arch-presentation': 'Presentation',
|
||||
'homeassistant': 'Home Assistant', 'uptime-kuma': 'Uptime Kuma',
|
||||
'nginx-proxy-manager': 'Nginx Proxy Manager', 'nostr-rs-relay': 'Nostr Relay',
|
||||
'call-the-operator': 'Call The Operator', 'syntropy-institute': 'Syntropy Institute',
|
||||
't-zero': 'T-Zero', 'nostrudel': 'noStrudel',
|
||||
}
|
||||
|
||||
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', // Blocks iframe
|
||||
'portainer', // X-Frame-Options: deny
|
||||
'onlyoffice', // X-Frame-Options: SAMEORIGIN
|
||||
'nginx-proxy-manager', // X-Frame-Options blocks
|
||||
'tailscale', // No local web UI — opens Tailscale admin
|
||||
])
|
||||
|
||||
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
|
||||
|
||||
const appUrl = computed(() => {
|
||||
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}`
|
||||
|
||||
// IndeedHub: always direct port (X-Frame-Options removed by deploy script)
|
||||
if (id === 'indeedhub') {
|
||||
const port = APP_PORTS[id]
|
||||
if (port) {
|
||||
let base = `${window.location.protocol}//${window.location.hostname}:${port}`
|
||||
const subpath = route.query.path as string | undefined
|
||||
if (subpath) base += subpath
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
// 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 ---
|
||||
|
||||
function isIdentityAwareApp(id: string): boolean {
|
||||
return id === 'indeedhub' || id === 'nostrudel'
|
||||
}
|
||||
|
||||
const IDENTITY_KEY = 'archipelago_app_identity_'
|
||||
|
||||
interface SelectedIdentity {
|
||||
id: string; name: string; did: string; pubkey: string
|
||||
nostr_pubkey?: string; nostr_npub?: string
|
||||
}
|
||||
|
||||
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 {}
|
||||
}
|
||||
|
||||
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) {
|
||||
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 {}
|
||||
}
|
||||
|
||||
// NIP-98 auto-login removed — apps handle their own login via window.nostr (NIP-07)
|
||||
|
||||
// --- Lifecycle ---
|
||||
// --- Lifecycle handlers ---
|
||||
|
||||
function onLoad() {
|
||||
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
|
||||
@@ -507,7 +174,8 @@ function onLoad() {
|
||||
// Check if iframe actually loaded content (same-origin only)
|
||||
iframeCheckId = setTimeout(() => {
|
||||
try {
|
||||
const doc = iframeRef.value?.contentDocument
|
||||
const iframe = frameRef.value?.iframeRef
|
||||
const doc = iframe?.contentDocument
|
||||
if (doc) {
|
||||
const body = doc.body
|
||||
if (!body || (body.children.length === 0 && body.innerText.trim() === '')) {
|
||||
@@ -515,14 +183,10 @@ function onLoad() {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cross-origin — can't check, assume OK
|
||||
// Cross-origin -- can't check, assume OK
|
||||
}
|
||||
}, 1000)
|
||||
if (isIdentityAwareApp(appId.value)) {
|
||||
const stored = getStoredIdentity()
|
||||
if (stored) sendIdentity(stored)
|
||||
else showIdentityPicker.value = true
|
||||
}
|
||||
identity.onIframeLoadIdentity()
|
||||
}
|
||||
|
||||
function onError() {
|
||||
@@ -568,11 +232,11 @@ function openNewTab() {
|
||||
}
|
||||
|
||||
function iframeGoBack() {
|
||||
try { iframeRef.value?.contentWindow?.history.back() } catch {}
|
||||
try { frameRef.value?.iframeRef?.contentWindow?.history.back() } catch {}
|
||||
}
|
||||
|
||||
function iframeGoForward() {
|
||||
try { iframeRef.value?.contentWindow?.history.forward() } catch {}
|
||||
try { frameRef.value?.iframeRef?.contentWindow?.history.forward() } catch {}
|
||||
}
|
||||
|
||||
function handleBackdropClick() {
|
||||
@@ -593,21 +257,18 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// Close dropdown on outside click
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (showModeMenu.value && modeMenuRef.value && !modeMenuRef.value.contains(e.target as Node)) {
|
||||
showModeMenu.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFullscreenChange() {
|
||||
if (!document.fullscreenElement && displayMode.value === 'fullscreen') {
|
||||
// User exited fullscreen via browser UI — switch to overlay
|
||||
displayMode.value = 'overlay'
|
||||
localStorage.setItem(DISPLAY_MODE_KEY, 'overlay')
|
||||
}
|
||||
}
|
||||
|
||||
function onMessage(e: MessageEvent) {
|
||||
if (e.data?.type === 'nostr-request') nostrBridge.handleNostrRequest(e)
|
||||
if (e.data?.type === 'archipelago:identity:request') identity.handleIdentityRequest()
|
||||
}
|
||||
|
||||
// Enter fullscreen on mount if mode is fullscreen
|
||||
watch(displayMode, (mode) => {
|
||||
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
|
||||
@@ -615,64 +276,8 @@ watch(displayMode, (mode) => {
|
||||
}
|
||||
})
|
||||
|
||||
// --- NIP-07 ---
|
||||
|
||||
function onMessage(e: MessageEvent) {
|
||||
if (e.data?.type === 'nostr-request') handleNostrRequest(e)
|
||||
if (e.data?.type === 'archipelago:identity:request') {
|
||||
const stored = getStoredIdentity()
|
||||
if (stored) sendIdentity(stored)
|
||||
else showIdentityPicker.value = true
|
||||
}
|
||||
}
|
||||
|
||||
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 targetOrigin = appUrl.value ? new URL(appUrl.value).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 targetOrigin = appUrl.value ? new URL(appUrl.value).origin : '*'
|
||||
source.postMessage({ type: 'nostr-response', id, error: err instanceof Error ? err.message : 'Unknown error' }, targetOrigin)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Apps that block iframes (X-Frame-Options) — open in new tab, close session
|
||||
// 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')
|
||||
@@ -682,7 +287,6 @@ onMounted(() => {
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
window.addEventListener('message', onMessage)
|
||||
document.addEventListener('click', onClickOutside)
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
|
||||
loading.value = false
|
||||
@@ -703,18 +307,18 @@ onBeforeUnmount(() => {
|
||||
if (iframeCheckId) clearTimeout(iframeCheckId)
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
window.removeEventListener('message', onMessage)
|
||||
document.removeEventListener('click', onClickOutside)
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style>
|
||||
/* Unscoped so children can use these classes */
|
||||
.app-session-root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* Inline panel mode — fills content area, no blur, original layout */
|
||||
/* Inline panel mode -- fills content area, no blur, original layout */
|
||||
.app-session-backdrop-inline {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -746,7 +350,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
/* Overlay mode — covers entire viewport including sidebar */
|
||||
/* Overlay mode -- covers entire viewport including sidebar */
|
||||
.app-session-backdrop-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
Reference in New Issue
Block a user