Demo images / Build & push demo images (push) Failing after 3m45s
A container that is up but hasn't answered its probe yet rendered the hard failure overlay — padlock icon, "App not reachable", "the container is stopped". Both bitcoind (RPC -28 for its whole warm-up) and lnd (unreachable until the wallet unlocks) sit in that window on every boot, so the node looked broken while it was working normally. The retry machinery was already correct: 6 × 10s of automatic re-checks, and the app appears on its own when it answers. Only the headline was wrong. While those retries are in flight AND the package reports running/starting/restarting (or health "starting"), the overlay now shows the app's own pulsing icon, "<App> is starting…", and says the container is running. Once retries are exhausted the failure is real again and the original copy returns. Follows the ElectrumX sync-screen precedent already in this file, which suppresses the same overlay for the same reason. The explicit blocked-reason and must-open-new-tab paths are untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
742 lines
24 KiB
Vue
742 lines
24 KiB
Vue
<template>
|
||
<!-- The root stays in the layout as a rect placeholder; the session itself
|
||
ALWAYS lives under <body>. Toggling Teleport's disabled re-parented the
|
||
subtree on panel<->overlay switches, and moving an iframe node reloads
|
||
it — inline mode is now emulated with a fixed-position rect synced from
|
||
this placeholder, so the iframe never moves in the DOM. -->
|
||
<div class="app-session-root" ref="rootRef">
|
||
<Teleport to="body">
|
||
<div
|
||
:class="backdropClasses"
|
||
:style="inlineRectStyle"
|
||
@click.self="handleBackdropClick"
|
||
>
|
||
<div
|
||
ref="sessionRef"
|
||
:class="panelClasses"
|
||
@click.stop
|
||
>
|
||
<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"
|
||
/>
|
||
|
||
<AppSessionFrame
|
||
ref="frameRef"
|
||
:app-url="appUrl"
|
||
:app-id="appId"
|
||
:app-title="appTitle"
|
||
:app-icon="appIcon"
|
||
:loading="loading"
|
||
:iframe-blocked="iframeBlocked"
|
||
:must-open-new-tab="mustOpenNewTab"
|
||
:auto-retry-count="autoRetryCount"
|
||
:refresh-key="refreshKey"
|
||
:blocked-reason="blockedReason"
|
||
:blocked-title="blockedTitle"
|
||
:warming-up="warmingUp"
|
||
:electrs-sync="electrsSync"
|
||
@iframe-load="onLoad"
|
||
@iframe-error="onError"
|
||
@refresh="refresh"
|
||
@open-new-tab-and-back="openNewTabAndBack"
|
||
/>
|
||
|
||
<!-- Mobile: gamepad for botfights (with utility buttons), browser bar for everything else -->
|
||
<MobileGamepad
|
||
v-if="isMobile && appId === 'botfights'"
|
||
:iframe-ref="iframeRef ?? null"
|
||
:player="1"
|
||
@refresh="refresh"
|
||
@openBrowser="openNewTab"
|
||
@close="closeSession"
|
||
/>
|
||
<div v-else class="md:hidden app-session-mobile-bar">
|
||
<button class="app-session-bar-btn" aria-label="Back" @click="iframeGoBack">
|
||
<svg 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-bar-btn" aria-label="Forward" @click="iframeGoForward">
|
||
<svg 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>
|
||
<button class="app-session-bar-btn" aria-label="Refresh" @click="refresh">
|
||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" :class="{ 'animate-spin': isRefreshing }">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v6h6M20 20v-6h-6M5.64 15.36A8 8 0 0018.36 18M18.36 8.64A8 8 0 005.64 6" />
|
||
</svg>
|
||
</button>
|
||
<button class="app-session-bar-btn" aria-label="Open in new tab" @click="openNewTab">
|
||
<svg 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-bar-btn" aria-label="Close" @click="closeSession">
|
||
<svg 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>
|
||
</div>
|
||
</div>
|
||
|
||
<NostrIdentityPicker
|
||
:show="showIdentityPicker"
|
||
:app-name="appTitle"
|
||
@select="identity.onIdentitySelected"
|
||
@cancel="showIdentityPicker = false"
|
||
/>
|
||
</div>
|
||
</Teleport>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, nextTick, onMounted, onBeforeUnmount, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||
import { useAppStore } from '@/stores/app'
|
||
import { useScreensaverStore } from '@/stores/screensaver'
|
||
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
||
import AppSessionHeader from './appSession/AppSessionHeader.vue'
|
||
import AppSessionFrame from './appSession/AppSessionFrame.vue'
|
||
import MobileGamepad from './appSession/MobileGamepad.vue'
|
||
import {
|
||
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
|
||
initialDisplayMode, resolveAppUrl, resolveAppTitle,
|
||
} from './appSession/appSessionConfig'
|
||
import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig'
|
||
import { PackageState } from '@/types/api'
|
||
import { useAppIdentity } from './appSession/useAppIdentity'
|
||
import { useNostrBridge } from './appSession/useNostrBridge'
|
||
import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal'
|
||
import { useElectrsSync } from '@/composables/useElectrsSync'
|
||
import { IS_DEMO, isDemoApp, isDemoExternal } from '@/composables/useDemoIntro'
|
||
|
||
const props = defineProps<{
|
||
appIdProp?: string
|
||
/** Deep-link path inside the app (store-driven sessions), e.g. /tx/<hash> */
|
||
pathProp?: string
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
close: []
|
||
}>()
|
||
|
||
/** True when rendered inline via store (panel mode), false when route-based */
|
||
const isInlinePanel = computed(() => !!props.appIdProp)
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const store = useAppStore()
|
||
const screensaverStore = useScreensaverStore()
|
||
|
||
const sessionRef = 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 autoRetryCount = ref(0)
|
||
let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||
let autoRetryId: ReturnType<typeof setTimeout> | null = null
|
||
let iframeCheckId: ReturnType<typeof setTimeout> | null = null
|
||
|
||
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('/dashboard/apps')
|
||
return ''
|
||
}
|
||
return id
|
||
})
|
||
|
||
// Display mode -- per-app user choice → per-app default → last global → panel
|
||
const displayMode = ref<DisplayMode>(initialDisplayMode(appId.value))
|
||
|
||
const appTitle = computed(() => resolveAppTitle(appId.value))
|
||
const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null)
|
||
const appIcon = computed(() =>
|
||
packageEntry.value
|
||
? resolveAppIcon(appId.value, packageEntry.value)
|
||
: `/assets/img/app-icons/${appId.value}.png`
|
||
)
|
||
const blockedReason = computed(() => launchBlockedReason(appId.value, packageEntry.value))
|
||
|
||
// A container that is up but not yet answering its probe is STARTING, not
|
||
// broken — bitcoind serves RPC error -28 for its whole warm-up and lnd is
|
||
// unreachable until the wallet unlocks, so both spent that window reading as
|
||
// a hard "App not reachable" failure. The retry machinery below already
|
||
// tolerates it (6 × 10s); this only makes the headline tell the truth while
|
||
// those retries are still in flight. Once they are exhausted, the failure is
|
||
// real again and the copy reverts.
|
||
const MAX_AUTO_RETRIES = 6
|
||
const warmingUp = computed(() =>
|
||
iframeBlocked.value &&
|
||
!mustOpenNewTab.value &&
|
||
!blockedReason.value &&
|
||
autoRetryCount.value < MAX_AUTO_RETRIES &&
|
||
(packageEntry.value?.state === PackageState.Running ||
|
||
packageEntry.value?.state === PackageState.Starting ||
|
||
packageEntry.value?.state === PackageState.Restarting ||
|
||
packageEntry.value?.health === 'starting')
|
||
)
|
||
const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value === 'fedimintd' ? 'Waiting for Bitcoin sync' : 'App not ready')
|
||
// Reactive so the overlay/teleport/footer/animation decisions track the live
|
||
// viewport (and match the CSS `md` breakpoint) instead of a stale one-shot read.
|
||
const isMobile = ref(typeof window !== 'undefined' && window.innerWidth < 768)
|
||
function updateIsMobile() { isMobile.value = window.innerWidth < 768 }
|
||
// In the demo, apps backed by a real external site that blocks iframing open
|
||
// in a new tab rather than the in-app session frame. Demoable apps are served
|
||
// same-origin by the mock backend, so the prod new-tab list doesn't apply.
|
||
const mustOpenNewTab = computed(() =>
|
||
(NEW_TAB_APPS.has(appId.value) && !(IS_DEMO && isDemoApp(appId.value))) ||
|
||
(IS_DEMO && isDemoExternal(appId.value))
|
||
)
|
||
|
||
// ElectrumX shows a sync screen before its real UI (the Electrum server only
|
||
// serves clients once its index is built). Poll /electrs-status while this is
|
||
// the Electrum app; pass the status to the frame only while still syncing.
|
||
const isElectrsApp = computed(() =>
|
||
['electrumx', 'electrs-ui', 'archy-electrs-ui'].includes(appId.value)
|
||
)
|
||
const { status: electrsStatus, syncing: electrsSyncing, start: startElectrsPoll, stop: stopElectrsPoll } =
|
||
useElectrsSync()
|
||
const electrsSync = computed(() =>
|
||
isElectrsApp.value && electrsSyncing.value ? electrsStatus.value : null
|
||
)
|
||
watch(
|
||
isElectrsApp,
|
||
(on) => { if (on) startElectrsPoll(); else stopElectrsPoll() },
|
||
{ immediate: true }
|
||
)
|
||
const screensaverReason = computed(() => `app-session:${appId.value}`)
|
||
const screensaverSuppressedApps = new Set([
|
||
'indeedhub',
|
||
'jellyfin',
|
||
'immich',
|
||
'photoprism',
|
||
'filebrowser',
|
||
])
|
||
|
||
const appUrl = computed(() => {
|
||
const runtimeUrl = store.data?.['package-data']?.[appId.value]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
|
||
const deepPath = props.pathProp ?? (route.query.path as string | undefined)
|
||
return resolveAppUrl(appId.value, deepPath, runtimeUrl)
|
||
})
|
||
|
||
function closeRouteSession() {
|
||
const fallback = route.query.returnTo
|
||
const fallbackPath = typeof fallback === 'string' && fallback.startsWith('/dashboard')
|
||
? fallback
|
||
: '/dashboard/apps'
|
||
router.replace(fallbackPath).catch(() => {})
|
||
}
|
||
|
||
// --- 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) {
|
||
if (displayMode.value === 'fullscreen' && document.fullscreenElement) {
|
||
document.exitFullscreen().catch(() => {})
|
||
}
|
||
displayMode.value = mode
|
||
// Strictly per-app: the pick is remembered for THIS app only (no global
|
||
// key — one app's mode must never change how another opens).
|
||
if (appId.value) localStorage.setItem(`${DISPLAY_MODE_KEY}:${appId.value}`, mode)
|
||
|
||
// Route-based sessions (deep links) hand off to the store-driven session so
|
||
// the app keeps floating above the dashboard instead of owning the route.
|
||
if (!isInlinePanel.value && mode === 'panel') {
|
||
const id = appId.value
|
||
const launcher = useAppLauncherStore()
|
||
const fallback = route.query.returnTo
|
||
const fallbackPath = typeof fallback === 'string' && fallback.startsWith('/dashboard')
|
||
? fallback
|
||
: '/dashboard/apps'
|
||
router.push(fallbackPath).then(() => {
|
||
launcher.panelAppId = id
|
||
})
|
||
return
|
||
}
|
||
|
||
}
|
||
|
||
// Reactive classes based on display mode. The store-driven session honors the
|
||
// selected display mode in place: panel renders inline beside the page,
|
||
// overlay/fullscreen render above it — the underlying route never changes.
|
||
// Mobile always uses the full overlay.
|
||
const inlinePanelMode = computed(() =>
|
||
isInlinePanel.value && !isMobile.value && displayMode.value === 'panel'
|
||
)
|
||
|
||
// Inline-mode rect emulation: the always-body-teleported backdrop pins itself
|
||
// to the placeholder's box so "inline" looks identical to the old in-place
|
||
// render while the iframe stays put in the DOM across mode switches.
|
||
const rootRef = ref<HTMLElement | null>(null)
|
||
const inlineRect = ref<{ top: number; left: number; width: number; height: number } | null>(null)
|
||
let rectObserver: ResizeObserver | null = null
|
||
|
||
function syncInlineRect() {
|
||
const el = rootRef.value
|
||
if (!el) return
|
||
const r = el.getBoundingClientRect()
|
||
// A hidden/unmounted placeholder measures 0x0 — keep the last good rect.
|
||
if (r.width > 0 && r.height > 0) {
|
||
inlineRect.value = { top: r.top, left: r.left, width: r.width, height: r.height }
|
||
}
|
||
}
|
||
|
||
const inlineRectStyle = computed<Record<string, string> | undefined>(() => {
|
||
if (!inlinePanelMode.value) return undefined
|
||
const r = inlineRect.value
|
||
// Never paint the inline backdrop over the whole viewport while unmeasured.
|
||
if (!r) {
|
||
const hidden: Record<string, string> = { visibility: 'hidden' }
|
||
return hidden
|
||
}
|
||
const style: Record<string, string> = {
|
||
position: 'fixed',
|
||
top: `${r.top}px`,
|
||
left: `${r.left}px`,
|
||
width: `${r.width}px`,
|
||
height: `${r.height}px`,
|
||
zIndex: '100',
|
||
}
|
||
return style
|
||
})
|
||
|
||
watch(inlinePanelMode, (on) => {
|
||
if (on) void nextTick(syncInlineRect)
|
||
})
|
||
|
||
const backdropClasses = computed(() => {
|
||
if (inlinePanelMode.value) return 'app-session-backdrop-inline'
|
||
return 'app-session-backdrop-overlay'
|
||
})
|
||
|
||
const panelClasses = computed(() => {
|
||
const base = 'app-session-panel glass-card'
|
||
if (inlinePanelMode.value) return `${base} app-session-inline`
|
||
if (displayMode.value === 'fullscreen' && !isMobile.value) return `${base} app-session-fullscreen`
|
||
return `${base} app-session-overlay`
|
||
})
|
||
|
||
// --- Lifecycle handlers ---
|
||
|
||
function onLoad() {
|
||
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
|
||
if (autoRetryId) { clearTimeout(autoRetryId); autoRetryId = null }
|
||
loading.value = false
|
||
isRefreshing.value = false
|
||
autoRetryCount.value = 0
|
||
// TV/keyboard: hand focus to the app so keys (incl. the gamepad bridge's
|
||
// virtual keyboard) flow into the iframe without needing a pointer click.
|
||
try { frameRef.value?.iframeRef?.focus() } catch { /* cross-origin is fine */ }
|
||
// Check if iframe actually loaded content (same-origin only)
|
||
iframeCheckId = setTimeout(() => {
|
||
try {
|
||
const iframe = frameRef.value?.iframeRef
|
||
const doc = iframe?.contentDocument
|
||
if (doc) {
|
||
const body = doc.body
|
||
if (!body || (body.children.length === 0 && body.innerText.trim() === '')) {
|
||
iframeBlocked.value = true
|
||
}
|
||
}
|
||
} catch {
|
||
// Cross-origin -- can't check, assume OK
|
||
}
|
||
}, 1000)
|
||
identity.onIframeLoadIdentity()
|
||
}
|
||
|
||
function onError() {
|
||
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
|
||
loading.value = false
|
||
isRefreshing.value = false
|
||
iframeBlocked.value = true
|
||
// Auto-retry up to 6 times (60s total) for apps that are still starting
|
||
if (!mustOpenNewTab.value && autoRetryCount.value < MAX_AUTO_RETRIES) {
|
||
autoRetryId = setTimeout(() => {
|
||
autoRetryCount.value++
|
||
refresh()
|
||
}, 10000)
|
||
}
|
||
}
|
||
|
||
function refresh() {
|
||
if (autoRetryId) { clearTimeout(autoRetryId); autoRetryId = null }
|
||
isRefreshing.value = true
|
||
loading.value = true
|
||
iframeBlocked.value = false
|
||
refreshKey.value++
|
||
startLoadTimeout()
|
||
}
|
||
|
||
function startLoadTimeout() {
|
||
if (loadTimeoutId) clearTimeout(loadTimeoutId)
|
||
loadTimeoutId = setTimeout(() => {
|
||
if (loading.value) {
|
||
loading.value = false
|
||
iframeBlocked.value = true
|
||
}
|
||
}, 12000)
|
||
}
|
||
|
||
function openNewTabAndBack() {
|
||
if (appUrl.value) openExternalUrl(appUrl.value)
|
||
closeSession()
|
||
}
|
||
|
||
function openNewTab() {
|
||
if (appUrl.value) openExternalUrl(appUrl.value)
|
||
}
|
||
|
||
function iframeGoBack() {
|
||
try { frameRef.value?.iframeRef?.contentWindow?.history.back() } catch {}
|
||
}
|
||
|
||
function iframeGoForward() {
|
||
try { frameRef.value?.iframeRef?.contentWindow?.history.forward() } catch {}
|
||
}
|
||
|
||
function handleBackdropClick() {
|
||
closeSession()
|
||
}
|
||
|
||
function closeSession() {
|
||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||
if (isInlinePanel.value) emit('close')
|
||
else closeRouteSession()
|
||
}
|
||
|
||
function onKeyDown(e: KeyboardEvent) {
|
||
if (e.key === 'Escape') {
|
||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||
else closeSession()
|
||
e.preventDefault()
|
||
}
|
||
}
|
||
|
||
function onFullscreenChange() {
|
||
if (!document.fullscreenElement && displayMode.value === 'fullscreen') {
|
||
displayMode.value = 'overlay'
|
||
if (appId.value) localStorage.setItem(`${DISPLAY_MODE_KEY}:${appId.value}`, 'overlay')
|
||
}
|
||
}
|
||
|
||
function onMessage(e: MessageEvent) {
|
||
if (e.data?.type === 'nostr-request') nostrBridge.handleNostrRequest(e)
|
||
if (e.data?.type === 'archipelago:identity:request') identity.handleIdentityRequest()
|
||
if (e.data?.type === 'archipelago:media:playing') screensaverStore.suppress(screensaverReason.value)
|
||
if (e.data?.type === 'archipelago:media:idle') screensaverStore.resume(screensaverReason.value)
|
||
}
|
||
|
||
// Enter fullscreen on mount if mode is fullscreen
|
||
watch(displayMode, (mode) => {
|
||
if (mode !== 'fullscreen') return
|
||
// The panel may teleport to <body> on this mode change — request fullscreen
|
||
// after the DOM settles so we grab the element at its new location.
|
||
void nextTick(() => {
|
||
if (displayMode.value === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
|
||
sessionRef.value.requestFullscreen().catch(() => {})
|
||
}
|
||
})
|
||
})
|
||
|
||
onMounted(() => {
|
||
// Apps that block iframes (X-Frame-Options) can't be shown in the session.
|
||
// Open them directly instead of showing a "this app opens in a tab"
|
||
// interstitial: desktop → new browser tab; mobile → in-app WebView (companion)
|
||
// or new tab (PWA). Then dismiss the (empty) session surface.
|
||
if (mustOpenNewTab.value && appUrl.value) {
|
||
if (isMobile.value) openInAppOrNewTab(appUrl.value)
|
||
else window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
||
if (isInlinePanel.value) emit('close')
|
||
else closeRouteSession()
|
||
return
|
||
}
|
||
|
||
window.addEventListener('keydown', onKeyDown, true)
|
||
window.addEventListener('message', onMessage)
|
||
window.addEventListener('resize', updateIsMobile)
|
||
window.addEventListener('resize', syncInlineRect)
|
||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||
// Track the placeholder's box (sidebar collapse, layout shifts) for the
|
||
// inline-mode fixed-position emulation. Sync before first paint so the
|
||
// inline backdrop never flashes at the wrong rect.
|
||
syncInlineRect()
|
||
if (rootRef.value && typeof ResizeObserver !== 'undefined') {
|
||
rectObserver = new ResizeObserver(syncInlineRect)
|
||
rectObserver.observe(rootRef.value)
|
||
}
|
||
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
|
||
loading.value = false
|
||
iframeBlocked.value = true
|
||
} else {
|
||
startLoadTimeout()
|
||
}
|
||
if (displayMode.value === 'fullscreen') {
|
||
requestAnimationFrame(() => {
|
||
sessionRef.value?.requestFullscreen().catch(() => {})
|
||
})
|
||
}
|
||
if (screensaverSuppressedApps.has(appId.value)) {
|
||
screensaverStore.suppress(screensaverReason.value)
|
||
}
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
if (loadTimeoutId) clearTimeout(loadTimeoutId)
|
||
if (autoRetryId) clearTimeout(autoRetryId)
|
||
if (iframeCheckId) clearTimeout(iframeCheckId)
|
||
window.removeEventListener('keydown', onKeyDown, true)
|
||
window.removeEventListener('message', onMessage)
|
||
window.removeEventListener('resize', updateIsMobile)
|
||
window.removeEventListener('resize', syncInlineRect)
|
||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||
rectObserver?.disconnect()
|
||
rectObserver = null
|
||
screensaverStore.resume(screensaverReason.value)
|
||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||
})
|
||
</script>
|
||
|
||
<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 */
|
||
.app-session-backdrop-inline {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(0, 0, 0, 0.4);
|
||
padding: 0;
|
||
}
|
||
|
||
.app-session-inline {
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 0;
|
||
border: none;
|
||
}
|
||
|
||
@media (min-width: 768px) {
|
||
.app-session-backdrop-inline {
|
||
padding: 1.5rem;
|
||
}
|
||
.app-session-inline {
|
||
border-radius: 1rem;
|
||
max-width: calc(100% - 1rem);
|
||
max-height: calc(100vh - 6rem);
|
||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||
}
|
||
}
|
||
|
||
/* Overlay mode -- covers entire viewport including sidebar */
|
||
.app-session-backdrop-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 2400;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0;
|
||
background: rgba(0, 0, 0, 0.6);
|
||
backdrop-filter: blur(12px);
|
||
}
|
||
|
||
@media (min-width: 768px) {
|
||
.app-session-backdrop-overlay {
|
||
padding: 2.5rem;
|
||
}
|
||
}
|
||
|
||
.app-session-overlay {
|
||
position: relative;
|
||
z-index: 10;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 0;
|
||
border: none;
|
||
box-shadow: none;
|
||
}
|
||
|
||
@media (min-width: 768px) {
|
||
.app-session-overlay {
|
||
max-width: calc(100vw - 5rem);
|
||
max-height: calc(100vh - 5rem);
|
||
border-radius: 1rem;
|
||
}
|
||
}
|
||
|
||
/* Fullscreen mode */
|
||
.app-session-fullscreen {
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 0 !important;
|
||
max-width: none !important;
|
||
max-height: none !important;
|
||
}
|
||
|
||
/* Shared */
|
||
.app-session-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 8px;
|
||
color: rgba(255, 255, 255, 0.7);
|
||
transition: all 0.15s ease;
|
||
flex-shrink: 0;
|
||
}
|
||
.app-session-btn:hover {
|
||
background: rgba(255, 255, 255, 0.15);
|
||
color: white;
|
||
}
|
||
.app-session-btn:disabled {
|
||
opacity: 0.5;
|
||
}
|
||
|
||
/* Active display-mode button in the header bar */
|
||
.app-session-btn-active {
|
||
color: #fb923c;
|
||
background: rgba(251, 146, 60, 0.12);
|
||
}
|
||
.app-session-btn-active:hover {
|
||
color: #fb923c;
|
||
background: rgba(251, 146, 60, 0.18);
|
||
}
|
||
|
||
.content-fade-enter-active,
|
||
.content-fade-leave-active {
|
||
transition: opacity 0.2s ease;
|
||
}
|
||
.content-fade-enter-from,
|
||
.content-fade-leave-to {
|
||
opacity: 0;
|
||
}
|
||
|
||
.app-session-frame-scroll-host {
|
||
overflow: auto;
|
||
overscroll-behavior: contain;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
|
||
/* Mobile: full-bleed app sessions — no border, no radius, no shadow */
|
||
@media (max-width: 767px) {
|
||
.app-session-root {
|
||
height: 100%;
|
||
}
|
||
.app-session-inline {
|
||
height: 100%;
|
||
}
|
||
.app-session-overlay,
|
||
.app-session-fullscreen {
|
||
height: 100vh;
|
||
height: 100dvh;
|
||
height: var(--visual-viewport-height, 100dvh);
|
||
padding-top: var(--safe-area-top, env(safe-area-inset-top, 0px));
|
||
background: black;
|
||
}
|
||
.app-session-panel.glass-card {
|
||
border: none !important;
|
||
border-radius: 0 !important;
|
||
box-shadow: none !important;
|
||
}
|
||
.app-session-backdrop-overlay {
|
||
padding: 0;
|
||
backdrop-filter: none;
|
||
background: black;
|
||
}
|
||
.app-session-frame-safe {
|
||
flex: none !important;
|
||
height: calc(100vh - var(--app-session-mobile-bar-height, 84px) - var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
||
height: calc(100dvh - var(--app-session-mobile-bar-height, 84px) - var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
||
height: calc(var(--visual-viewport-height, 100dvh) - var(--app-session-mobile-bar-height, 84px) - var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
||
padding-bottom: 0;
|
||
}
|
||
}
|
||
|
||
/* Mobile bottom browser bar — sized like the main tab bar.
|
||
Uses !important-free display so Tailwind md:hidden can override. */
|
||
@media (min-width: 768px) {
|
||
.app-session-mobile-bar { display: none !important; }
|
||
}
|
||
.app-session-mobile-bar {
|
||
display: flex;
|
||
position: fixed;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 2600;
|
||
justify-content: space-around;
|
||
align-items: center;
|
||
flex-shrink: 0;
|
||
min-height: var(--app-session-mobile-bar-height, 84px);
|
||
padding: 10px 16px;
|
||
padding-bottom: calc(10px + max(var(--safe-area-bottom, 0px), env(safe-area-inset-bottom, 0px), 10px));
|
||
/* Solid black, not translucent: the app iframe's theme colour bled
|
||
through the bar and its safe-area strip on phones. */
|
||
background: #000;
|
||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||
transform: translateZ(0);
|
||
}
|
||
|
||
.app-session-inline .app-session-mobile-bar {
|
||
position: absolute;
|
||
z-index: 20;
|
||
}
|
||
|
||
|
||
.app-session-bar-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 52px;
|
||
height: 52px;
|
||
min-height: 52px;
|
||
border-radius: 13px;
|
||
color: rgba(255, 255, 255, 0.65);
|
||
transition: color 0.15s ease, background 0.15s ease;
|
||
}
|
||
.app-session-bar-btn svg {
|
||
width: 24px;
|
||
height: 24px;
|
||
}
|
||
.app-session-bar-btn:active {
|
||
color: white;
|
||
background: rgba(255, 255, 255, 0.12);
|
||
}
|
||
</style>
|