464 lines
12 KiB
Vue
464 lines
12 KiB
Vue
<template>
|
|
<div class="app-session-root">
|
|
<Teleport to="body" :disabled="isInlinePanel">
|
|
<div
|
|
:class="backdropClasses"
|
|
@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"
|
|
: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="identity.onIdentitySelected"
|
|
@cancel="showIdentityPicker = false"
|
|
/>
|
|
</div>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
|
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
|
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
|
|
}>()
|
|
|
|
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 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
|
|
|
|
// 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) {
|
|
if (displayMode.value === 'fullscreen' && document.fullscreenElement) {
|
|
document.exitFullscreen().catch(() => {})
|
|
}
|
|
displayMode.value = mode
|
|
localStorage.setItem(DISPLAY_MODE_KEY, mode)
|
|
|
|
// Switch from inline panel to route-based overlay/fullscreen
|
|
if (isInlinePanel.value && mode !== 'panel') {
|
|
const id = appId.value
|
|
emit('close')
|
|
router.push({ name: 'app-session', params: { appId: id } })
|
|
return
|
|
}
|
|
|
|
// Switch from route-based to inline panel
|
|
if (!isInlinePanel.value && mode === 'panel') {
|
|
const id = appId.value
|
|
const launcher = useAppLauncherStore()
|
|
router.push({ name: 'apps' }).then(() => {
|
|
launcher.panelAppId = id
|
|
})
|
|
return
|
|
}
|
|
|
|
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
|
|
sessionRef.value.requestFullscreen().catch(() => {})
|
|
}
|
|
}
|
|
|
|
// Reactive classes based on display mode
|
|
const backdropClasses = computed(() => {
|
|
if (isInlinePanel.value) return 'app-session-backdrop-inline'
|
|
return 'app-session-backdrop-overlay'
|
|
})
|
|
|
|
const panelClasses = computed(() => {
|
|
const base = 'app-session-panel glass-card'
|
|
if (isInlinePanel.value) return `${base} app-session-inline`
|
|
if (displayMode.value === 'fullscreen') 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
|
|
// 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 < 6) {
|
|
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) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
|
closeSession()
|
|
}
|
|
|
|
function openNewTab() {
|
|
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
|
}
|
|
|
|
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 router.back()
|
|
}
|
|
|
|
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'
|
|
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) {
|
|
sessionRef.value.requestFullscreen().catch(() => {})
|
|
}
|
|
})
|
|
|
|
onMounted(() => {
|
|
// Apps that block iframes (X-Frame-Options) -- open in new tab, close session
|
|
if (mustOpenNewTab.value && appUrl.value) {
|
|
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
|
if (isInlinePanel.value) emit('close')
|
|
else router.back()
|
|
return
|
|
}
|
|
|
|
window.addEventListener('keydown', onKeyDown, true)
|
|
window.addEventListener('message', onMessage)
|
|
document.addEventListener('fullscreenchange', onFullscreenChange)
|
|
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
|
|
loading.value = false
|
|
iframeBlocked.value = true
|
|
} else {
|
|
startLoadTimeout()
|
|
}
|
|
if (displayMode.value === 'fullscreen') {
|
|
requestAnimationFrame(() => {
|
|
sessionRef.value?.requestFullscreen().catch(() => {})
|
|
})
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (loadTimeoutId) clearTimeout(loadTimeoutId)
|
|
if (autoRetryId) clearTimeout(autoRetryId)
|
|
if (iframeCheckId) clearTimeout(iframeCheckId)
|
|
window.removeEventListener('keydown', onKeyDown, true)
|
|
window.removeEventListener('message', onMessage)
|
|
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
|
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;
|
|
}
|
|
|
|
@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;
|
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6);
|
|
}
|
|
|
|
@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;
|
|
}
|
|
|
|
/* Mode dropdown */
|
|
.mode-option {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
width: 100%;
|
|
padding: 10px 14px;
|
|
font-size: 13px;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
transition: all 0.15s ease;
|
|
text-align: left;
|
|
}
|
|
.mode-option:hover {
|
|
background: rgba(255, 255, 255, 0.08);
|
|
color: white;
|
|
}
|
|
.mode-option-active {
|
|
color: #fb923c;
|
|
background: rgba(251, 146, 60, 0.08);
|
|
}
|
|
|
|
.menu-fade-enter-active,
|
|
.menu-fade-leave-active {
|
|
transition: opacity 0.15s ease, transform 0.15s ease;
|
|
}
|
|
.menu-fade-enter-from,
|
|
.menu-fade-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(-4px);
|
|
}
|
|
|
|
.content-fade-enter-active,
|
|
.content-fade-leave-active {
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
.content-fade-enter-from,
|
|
.content-fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
</style>
|