archy/neode-ui/src/App.vue

550 lines
22 KiB
Vue
Raw Normal View History

2026-01-24 22:59:20 +00:00
<template>
<div id="app">
<!-- Splash Screen (only on first visit) -->
<SplashScreen v-if="showSplash" @complete="handleSplashComplete" />
<!-- Main App Content - only show after splash and routing is complete -->
<div v-if="!showSplash && !isReady" class="min-h-screen bg-black" />
<RouterView v-else-if="!showSplash && isReady" />
2026-01-24 22:59:20 +00:00
<!-- Spotlight command palette (Cmd+K / Ctrl+K) -->
<SpotlightSearch />
<!-- CLI popup (F key) -->
<CLIPopup />
<!-- App launcher overlay (iframe popup) -->
<AppLauncherOverlay />
<!-- Global toast notifications -->
<ToastStack />
<!-- Screensaver -->
<Screensaver />
<!-- Help guide modal (from spotlight) -->
<HelpGuideModal
:show="spotlightStore.helpModal.show"
:title="spotlightStore.helpModal.title"
:content="spotlightStore.helpModal.content"
:related-path="spotlightStore.helpModal.relatedPath"
@close="spotlightStore.closeHelpModal()"
/>
2026-01-24 22:59:20 +00:00
<!-- PWA Update Prompt -->
<PWAUpdatePrompt />
<!-- PWA Install Prompt (Install app, not just Add to Home Screen) -->
<PWAInstallPrompt />
<!-- Global "mesh device detected" setup flow (fires on any page) -->
<MeshDeviceSetupModal />
<!-- Nudge to back up the Lightning seed once a wallet exists (any page) -->
<LndSeedBackupPrompt />
<!-- Global persistent audio player (bottom bar) -->
<GlobalAudioPlayer />
<!-- Toast notifications - top right, glass style, any page -->
<Teleport to="body">
<Transition name="toast">
<div
v-if="toastMessage.show"
@click="messageToast.dismissToastAndOpenMessages"
class="fixed top-20 right-4 left-4 z-[100] w-auto max-w-md cursor-pointer rounded-xl p-4 transition-all hover:border-white/30 hover:shadow-2xl md:top-6 md:right-6 md:left-auto md:max-w-md toast-glass"
>
<div class="flex items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-orange-500/20">
<svg class="h-5 w-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white">New message</p>
<p class="mt-0.5 text-sm text-white/70 line-clamp-2">{{ toastMessage.text }}</p>
<p class="mt-1 text-xs text-orange-400">Click to view</p>
</div>
<button
@click.stop="messageToast.closeToast"
aria-label="Dismiss notification"
class="-mt-1 -mr-1 shrink-0 rounded-full p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white/80"
>
<svg class="h-4 w-4" 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>
</Transition>
</Teleport>
2026-01-24 22:59:20 +00:00
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
2026-01-24 22:59:20 +00:00
import { useRouter, useRoute } from 'vue-router'
import SplashScreen from './components/SplashScreen.vue'
import PWAUpdatePrompt from './components/PWAUpdatePrompt.vue'
import PWAInstallPrompt from './components/PWAInstallPrompt.vue'
import SpotlightSearch from './components/SpotlightSearch.vue'
import CLIPopup from './components/CLIPopup.vue'
import AppLauncherOverlay from './components/AppLauncherOverlay.vue'
import ToastStack from './components/ToastStack.vue'
import Screensaver from './components/Screensaver.vue'
import HelpGuideModal from './components/HelpGuideModal.vue'
import GlobalAudioPlayer from './components/GlobalAudioPlayer.vue'
import MeshDeviceSetupModal from './components/mesh/MeshDeviceSetupModal.vue'
import LndSeedBackupPrompt from './components/LndSeedBackupPrompt.vue'
import { useMeshStore } from './stores/mesh'
import { useControllerNav } from '@/composables/useControllerNav'
import { playKeyboardTypingSound } from '@/composables/useLoginSounds'
import { useSpotlightStore } from '@/stores/spotlight'
import { useCLIStore } from '@/stores/cli'
import { useMessageToast } from '@/composables/useMessageToast'
import { useAppStore } from '@/stores/app'
import { useScreensaverStore } from '@/stores/screensaver'
import { useUIModeStore } from '@/stores/uiMode'
import { startRemoteRelay, stopRemoteRelay } from '@/api/remote-relay'
import { shouldShowIntroSplash } from '@/utils/introSplash'
2026-01-24 22:59:20 +00:00
const router = useRouter()
const screensaverStore = useScreensaverStore()
const spotlightStore = useSpotlightStore()
const cliStore = useCLIStore()
const appStore = useAppStore()
const uiModeStore = useUIModeStore()
const messageToast = useMessageToast()
const toastMessage = messageToast.toastMessage
useControllerNav()
2026-06-12 03:00:15 -04:00
function syncKioskSafeArea() {
if (typeof document === 'undefined') return
const isKiosk = localStorage.getItem('kiosk') === 'true'
|| new URLSearchParams(window.location.search).has('kiosk')
const rawSafeArea = localStorage.getItem('archipelago_kiosk_safe_area_px') || '0'
const safeArea = /^\d{1,3}$/.test(rawSafeArea) ? Number(rawSafeArea) : 0
const rawSafeAreaX = localStorage.getItem('archipelago_kiosk_safe_area_x_px') || rawSafeArea
const rawSafeAreaY = localStorage.getItem('archipelago_kiosk_safe_area_y_px') || rawSafeArea
const safeAreaX = /^\d{1,3}$/.test(rawSafeAreaX) ? Number(rawSafeAreaX) : safeArea
const safeAreaY = /^\d{1,3}$/.test(rawSafeAreaY) ? Number(rawSafeAreaY) : safeArea
document.documentElement.classList.toggle('kiosk-safe-area', isKiosk && (safeAreaX > 0 || safeAreaY > 0))
if (isKiosk && (safeAreaX > 0 || safeAreaY > 0)) {
document.documentElement.style.setProperty('--kiosk-safe-area-x', `${safeAreaX}px`)
document.documentElement.style.setProperty('--kiosk-safe-area-y', `${safeAreaY}px`)
} else {
document.documentElement.style.removeProperty('--kiosk-safe-area-x')
document.documentElement.style.removeProperty('--kiosk-safe-area-y')
}
}
// Start/stop message polling and remote relay when auth state changes
watch(() => appStore.isAuthenticated, (authenticated) => {
if (authenticated) {
messageToast.startPolling()
screensaverStore.resetInactivityTimer()
// Don't start relay on kiosk — kiosk gets input via xdotool (system-level),
// relay would duplicate every keystroke/click as DOM events
const isKiosk = localStorage.getItem('kiosk') === 'true'
|| new URLSearchParams(window.location.search).has('kiosk')
if (!isKiosk) {
startRemoteRelay()
}
} else {
messageToast.stopPolling()
toastMessage.value = { show: false, text: '', fromPubkey: '' }
screensaverStore.clearInactivityTimer()
screensaverStore.deactivate()
stopRemoteRelay()
}
}, { immediate: true })
// Reset screensaver inactivity on user activity (when authenticated)
function onUserActivity() {
if (appStore.isAuthenticated && !screensaverStore.isActive) {
screensaverStore.resetInactivityTimer()
}
}
function onKeyDown(e: KeyboardEvent) {
const isMac = navigator.platform.toUpperCase().includes('MAC')
const mod = isMac ? e.metaKey : e.ctrlKey
// Cmd+K / Ctrl+K only (modifier required - avoids accidental trigger when typing)
const target = e.target as HTMLElement
const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
if (mod && e.key === 'k') {
e.preventDefault()
spotlightStore.toggle()
return
}
// F key - CLI popup (skip when in input or modifier held)
if ((e.key === 'f' || e.key === 'F') && !isInput && !mod && !e.altKey) {
e.preventDefault()
cliStore.toggle()
return
}
// Cmd+1/2/3 - switch UI mode (skip when in input)
if (mod && !isInput && appStore.isAuthenticated) {
if (e.key === '1') { e.preventDefault(); uiModeStore.setMode('easy'); router.push('/dashboard'); return }
if (e.key === '2') { e.preventDefault(); uiModeStore.setMode('gamer'); router.push('/dashboard'); return }
if (e.key === '3') { e.preventDefault(); router.push('/dashboard/chat'); return }
}
// Cmd+M / Ctrl+M - cycle UI mode (skip when in input)
if (mod && (e.key === 'm' || e.key === 'M') && !isInput && appStore.isAuthenticated) {
e.preventDefault()
uiModeStore.cycleMode()
router.push('/dashboard')
return
}
// 's' key activates screensaver when authenticated (skip if typing in input)
if (e.key === 's' || e.key === 'S') {
if (!isInput && appStore.isAuthenticated && !screensaverStore.isActive && !screensaverStore.isSuppressed) {
e.preventDefault()
screensaverStore.activate()
}
}
// Keyboard typing sound - plays on any character typed in inputs (global)
if (isInput && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
playKeyboardTypingSound()
}
}
2026-01-24 22:59:20 +00:00
const route = useRoute()
// Start with splash hidden — onMounted decides whether to show it
const showSplash = ref(false)
2026-01-24 22:59:20 +00:00
const isReady = ref(false)
let modalOverlayObserver: MutationObserver | null = null
let lockedScrollY = 0
let previousBodyStyles: Partial<CSSStyleDeclaration> = {}
let bodyLockedForModal = false
let modalTouchY: number | null = null
function hasBlockingOverlay() {
if (typeof document === 'undefined') return false
return Array.from(document.querySelectorAll<HTMLElement>('.fixed.inset-0'))
.some((el) => {
const style = window.getComputedStyle(el)
const rect = el.getBoundingClientRect()
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& style.pointerEvents !== 'none'
&& rect.width > 0
&& rect.height > 0
})
}
function visibleBlockingOverlays() {
if (typeof document === 'undefined') return []
return Array.from(document.querySelectorAll<HTMLElement>('.fixed.inset-0'))
.filter((el) => {
const style = window.getComputedStyle(el)
const rect = el.getBoundingClientRect()
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& style.pointerEvents !== 'none'
&& rect.width > 0
&& rect.height > 0
})
}
function closestOverlay(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return null
return visibleBlockingOverlays().find((overlay) => overlay.contains(target)) || null
}
function canScrollInsideOverlay(target: EventTarget | null, overlay: HTMLElement, deltaY: number) {
if (!(target instanceof HTMLElement)) return false
let el: HTMLElement | null = target
while (el && overlay.contains(el)) {
const style = window.getComputedStyle(el)
const canScrollY = /(auto|scroll)/.test(style.overflowY)
&& el.scrollHeight > el.clientHeight
if (canScrollY) {
if (deltaY < 0 && el.scrollTop > 0) return true
if (deltaY > 0 && el.scrollTop + el.clientHeight < el.scrollHeight - 1) return true
}
if (el === overlay) break
el = el.parentElement
}
return false
}
function containModalWheel(ev: WheelEvent) {
if (!bodyLockedForModal) return
const overlay = closestOverlay(ev.target)
if (!overlay || !canScrollInsideOverlay(ev.target, overlay, ev.deltaY)) {
ev.preventDefault()
}
}
function containModalTouchStart(ev: TouchEvent) {
modalTouchY = ev.touches[0]?.clientY ?? null
}
function containModalTouchMove(ev: TouchEvent) {
if (!bodyLockedForModal) return
const currentY = ev.touches[0]?.clientY
if (currentY === undefined || modalTouchY === null) {
ev.preventDefault()
return
}
const deltaY = modalTouchY - currentY
modalTouchY = currentY
const overlay = closestOverlay(ev.target)
if (!overlay || !canScrollInsideOverlay(ev.target, overlay, deltaY)) {
ev.preventDefault()
}
}
function lockBodyForModal() {
if (bodyLockedForModal || typeof document === 'undefined') return
lockedScrollY = window.scrollY || document.documentElement.scrollTop || 0
previousBodyStyles = {
position: document.body.style.position,
top: document.body.style.top,
left: document.body.style.left,
right: document.body.style.right,
width: document.body.style.width,
overflow: document.body.style.overflow,
}
document.body.style.position = 'fixed'
document.body.style.top = `-${lockedScrollY}px`
document.body.style.left = '0'
document.body.style.right = '0'
document.body.style.width = '100%'
document.body.style.overflow = 'hidden'
document.documentElement.classList.add('modal-scroll-locked')
bodyLockedForModal = true
}
function unlockBodyForModal() {
if (!bodyLockedForModal || typeof document === 'undefined') return
document.body.style.position = previousBodyStyles.position || ''
document.body.style.top = previousBodyStyles.top || ''
document.body.style.left = previousBodyStyles.left || ''
document.body.style.right = previousBodyStyles.right || ''
document.body.style.width = previousBodyStyles.width || ''
document.body.style.overflow = previousBodyStyles.overflow || ''
document.documentElement.classList.remove('modal-scroll-locked')
window.scrollTo(0, lockedScrollY)
previousBodyStyles = {}
bodyLockedForModal = false
modalTouchY = null
}
function syncModalBodyLock() {
if (hasBlockingOverlay()) lockBodyForModal()
else unlockBodyForModal()
}
2026-01-24 22:59:20 +00:00
/**
* Determine if splash screen should be shown
* Splash is skipped if:
* - User has already seen the intro
* - User is on a direct route (refresh/bookmark)
*/
// Fix Chromium backdrop-filter rendering bug: when tab loses/regains focus,
// the compositor fails to repaint backdrop-filter layers over animated
// fixed-position overlays (body::before/after with mix-blend-mode).
// On return: strip backdrop-filter via class, wait a frame, then restore.
function onVisibilityChange() {
if (document.hidden) {
document.documentElement.classList.add('tab-hidden')
} else {
fix: production onboarding, CI tests, container security, keyboard nav Install & Onboarding: - Remove DEV_MODE=true from production ISO service file (auto-created users, skipped password setup) - Auto-install no longer overwrites rootfs service file with bad template - Login.vue always checks auth.isSetup — shows password creation form on fresh install without requiring dev build flag - Deploy image-versions.sh to /opt/archipelago/scripts/ on installed nodes - First-boot-containers sources image-versions.sh, runs podman as archipelago user (rootless), enables linger + podman.socket - Correct volume ownership (100000:100000 for rootless UID mapping) Container Security: - FileBrowser: add --cap-add=DAC_OVERRIDE for rootless podman volume access - FileBrowser: add --read-only, /data volume for database, proper cmd args - First-boot script matches backend config (security hardening + health check) CI Pipeline: - Add vue-tsc type check + vitest run to build-iso.yml (runs every push) - Add post-install-tests.yml workflow (workflow_dispatch, SSH to target) - Build report: set +eo pipefail, fix rootfs path, add || true guards - Bundle run-post-install-tests.sh into ISO E2E Test Suite (scripts/run-post-install-tests.sh): - Phase 1: Install verification (files, services, podman, linger, DEV_MODE check) - Phase 2: Onboarding flow (auth.isSetup, auth.setup, login, DID, complete) - Phase 3: Container lifecycle (install 3 apps via package.install RPC, verify running, stop, verify stopped, restart, verify running, health) - Phase 4: Log verification (first-boot log, diagnostics, journal errors) - Correct package.install params: {"id", "dockerImage"} Frontend: - Fix backdrop-filter tab-switch bug (keep animations paused during rebuild) - Dashboard glitch animations paused during tab-hidden - Gamepad nav: auto-focus first container on route change - Tab roving: Left/Right on role="tab" cycles and activates sibling tabs - ContainerApps: data-controller-launch on running app cards - 515 tests passing (fixed 30 broken, added 19 new keyboard nav tests) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:16:57 +00:00
// Step 1: strip backdrop-filter while animations stay paused (tab-hidden)
document.documentElement.classList.add('no-backdrop')
fix: production onboarding, CI tests, container security, keyboard nav Install & Onboarding: - Remove DEV_MODE=true from production ISO service file (auto-created users, skipped password setup) - Auto-install no longer overwrites rootfs service file with bad template - Login.vue always checks auth.isSetup — shows password creation form on fresh install without requiring dev build flag - Deploy image-versions.sh to /opt/archipelago/scripts/ on installed nodes - First-boot-containers sources image-versions.sh, runs podman as archipelago user (rootless), enables linger + podman.socket - Correct volume ownership (100000:100000 for rootless UID mapping) Container Security: - FileBrowser: add --cap-add=DAC_OVERRIDE for rootless podman volume access - FileBrowser: add --read-only, /data volume for database, proper cmd args - First-boot script matches backend config (security hardening + health check) CI Pipeline: - Add vue-tsc type check + vitest run to build-iso.yml (runs every push) - Add post-install-tests.yml workflow (workflow_dispatch, SSH to target) - Build report: set +eo pipefail, fix rootfs path, add || true guards - Bundle run-post-install-tests.sh into ISO E2E Test Suite (scripts/run-post-install-tests.sh): - Phase 1: Install verification (files, services, podman, linger, DEV_MODE check) - Phase 2: Onboarding flow (auth.isSetup, auth.setup, login, DID, complete) - Phase 3: Container lifecycle (install 3 apps via package.install RPC, verify running, stop, verify stopped, restart, verify running, health) - Phase 4: Log verification (first-boot log, diagnostics, journal errors) - Correct package.install params: {"id", "dockerImage"} Frontend: - Fix backdrop-filter tab-switch bug (keep animations paused during rebuild) - Dashboard glitch animations paused during tab-hidden - Gamepad nav: auto-focus first container on route change - Tab roving: Left/Right on role="tab" cycles and activates sibling tabs - ContainerApps: data-controller-launch on running app cards - 515 tests passing (fixed 30 broken, added 19 new keyboard nav tests) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:16:57 +00:00
// Step 2: restore backdrop-filter over static content (clean compositor rebuild)
// Use setTimeout — Chromium batches rAFs on tab return
setTimeout(() => {
document.documentElement.classList.remove('no-backdrop')
// Step 3: resume animations after backdrop-filter layers are established
requestAnimationFrame(() => {
fix: production onboarding, CI tests, container security, keyboard nav Install & Onboarding: - Remove DEV_MODE=true from production ISO service file (auto-created users, skipped password setup) - Auto-install no longer overwrites rootfs service file with bad template - Login.vue always checks auth.isSetup — shows password creation form on fresh install without requiring dev build flag - Deploy image-versions.sh to /opt/archipelago/scripts/ on installed nodes - First-boot-containers sources image-versions.sh, runs podman as archipelago user (rootless), enables linger + podman.socket - Correct volume ownership (100000:100000 for rootless UID mapping) Container Security: - FileBrowser: add --cap-add=DAC_OVERRIDE for rootless podman volume access - FileBrowser: add --read-only, /data volume for database, proper cmd args - First-boot script matches backend config (security hardening + health check) CI Pipeline: - Add vue-tsc type check + vitest run to build-iso.yml (runs every push) - Add post-install-tests.yml workflow (workflow_dispatch, SSH to target) - Build report: set +eo pipefail, fix rootfs path, add || true guards - Bundle run-post-install-tests.sh into ISO E2E Test Suite (scripts/run-post-install-tests.sh): - Phase 1: Install verification (files, services, podman, linger, DEV_MODE check) - Phase 2: Onboarding flow (auth.isSetup, auth.setup, login, DID, complete) - Phase 3: Container lifecycle (install 3 apps via package.install RPC, verify running, stop, verify stopped, restart, verify running, health) - Phase 4: Log verification (first-boot log, diagnostics, journal errors) - Correct package.install params: {"id", "dockerImage"} Frontend: - Fix backdrop-filter tab-switch bug (keep animations paused during rebuild) - Dashboard glitch animations paused during tab-hidden - Gamepad nav: auto-focus first container on route change - Tab roving: Left/Right on role="tab" cycles and activates sibling tabs - ContainerApps: data-controller-launch on running app cards - 515 tests passing (fixed 30 broken, added 19 new keyboard nav tests) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:16:57 +00:00
document.documentElement.classList.remove('tab-hidden')
})
fix: production onboarding, CI tests, container security, keyboard nav Install & Onboarding: - Remove DEV_MODE=true from production ISO service file (auto-created users, skipped password setup) - Auto-install no longer overwrites rootfs service file with bad template - Login.vue always checks auth.isSetup — shows password creation form on fresh install without requiring dev build flag - Deploy image-versions.sh to /opt/archipelago/scripts/ on installed nodes - First-boot-containers sources image-versions.sh, runs podman as archipelago user (rootless), enables linger + podman.socket - Correct volume ownership (100000:100000 for rootless UID mapping) Container Security: - FileBrowser: add --cap-add=DAC_OVERRIDE for rootless podman volume access - FileBrowser: add --read-only, /data volume for database, proper cmd args - First-boot script matches backend config (security hardening + health check) CI Pipeline: - Add vue-tsc type check + vitest run to build-iso.yml (runs every push) - Add post-install-tests.yml workflow (workflow_dispatch, SSH to target) - Build report: set +eo pipefail, fix rootfs path, add || true guards - Bundle run-post-install-tests.sh into ISO E2E Test Suite (scripts/run-post-install-tests.sh): - Phase 1: Install verification (files, services, podman, linger, DEV_MODE check) - Phase 2: Onboarding flow (auth.isSetup, auth.setup, login, DID, complete) - Phase 3: Container lifecycle (install 3 apps via package.install RPC, verify running, stop, verify stopped, restart, verify running, health) - Phase 4: Log verification (first-boot log, diagnostics, journal errors) - Correct package.install params: {"id", "dockerImage"} Frontend: - Fix backdrop-filter tab-switch bug (keep animations paused during rebuild) - Dashboard glitch animations paused during tab-hidden - Gamepad nav: auto-focus first container on route change - Tab roving: Left/Right on role="tab" cycles and activates sibling tabs - ContainerApps: data-controller-launch on running app cards - 515 tests passing (fixed 30 broken, added 19 new keyboard nav tests) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:16:57 +00:00
}, 50)
}
}
onMounted(async () => {
2026-06-12 03:00:15 -04:00
syncKioskSafeArea()
// Light app-wide mesh poll so a freshly plugged-in radio surfaces the
// setup modal on any page (the Mesh view's own poll takes over there).
useMeshStore().startGlobalDetection()
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('mousemove', onUserActivity)
window.addEventListener('mousedown', onUserActivity)
window.addEventListener('keydown', onUserActivity)
window.addEventListener('touchstart', onUserActivity)
window.addEventListener('message', onShareToMeshMessage)
document.addEventListener('wheel', containModalWheel, { capture: true, passive: false })
document.addEventListener('touchstart', containModalTouchStart, { capture: true, passive: true })
document.addEventListener('touchmove', containModalTouchMove, { capture: true, passive: false })
modalOverlayObserver = new MutationObserver(() => {
requestAnimationFrame(syncModalBodyLock)
})
modalOverlayObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'style'],
})
syncModalBodyLock()
let seenIntro = localStorage.getItem('neode_intro_seen') === '1'
feat: rootless podman, session hardening, boot stability, sidebar fix Rootless podman migration (TASK-11): - Remove sudo from all podman calls in PodmanClient + 8 backend files - Remove sudo from all podman/docker calls in deploy script - Restore full systemd security hardening: NoNewPrivileges, RestrictAddressFamilies, MemoryDenyWriteExecute, RestrictRealtime, RestrictNamespaces, RestrictSUIDSGID, SystemCallFilter, ProtectSystem=strict - Enable loginctl linger for rootless container persistence - Remove Ollama from auto-deploy (marketplace-only) Session & auth hardening: - Increase MAX_CONCURRENT_SESSIONS 20→50 (prevents eviction storms) - Debounced 401 redirect in rpc-client.ts (prevents redirect storms) Boot stability: - optimize-debian.sh: adds chrony, swap, removes policy-rc.d - deploy script: pre-restart chrony + swap setup - ISO build: chrony package, swap file creation - BootScreen: no longer clears localStorage (prevents splash replay) - RootRedirect: sole owner of localStorage clearing on server ready UI fixes: - Sidebar opacity default changed from 0→visible (fixes missing sidebar after page-persistence login without entrance animation) - Console.log/error wrapped in import.meta.env.DEV guards - Remove unused route import from RootRedirect Beta tracking: - CLAUDE.md: beta freeze protocol added - MASTER_PLAN.md: TASK-11, TASK-17, phase structure - BETA-PROGRESS.md: initial tracking doc - Tagged v1.2.0-alpha.1 as pre-rootless baseline Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:53:27 +00:00
const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1'
if (fromBoot) sessionStorage.removeItem('archipelago_from_boot')
// One-shot "Replay intro" request from the login screen — must survive the
// auto-re-mark below (which otherwise instantly suppresses the replay on any
// onboarded node).
let replayRequested = sessionStorage.getItem('archipelago_replay_intro') === '1'
if (replayRequested) sessionStorage.removeItem('archipelago_replay_intro')
// Public demo: every fresh boot at the root (first visit or a browser
// refresh) starts with the typing splash for the full effect. In-session SPA
// navigation never remounts App, and deep-route refreshes keep their place.
const { IS_DEMO } = await import('@/composables/useDemoIntro')
if (IS_DEMO && route.path === '/') replayRequested = true
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
const splashCandidate = !seenIntro
&& (fromBoot || (route.path === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
if (splashCandidate && onboardingComplete !== true) {
try {
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
onboardingComplete = await checkOnboardingStatus()
} catch {
onboardingComplete = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
}
}
if (!replayRequested && !seenIntro && onboardingComplete === true) {
try { localStorage.setItem('neode_intro_seen', '1') } catch { /* noop */ }
seenIntro = true
}
if (import.meta.env.DEV) console.log('[App] onMounted — seenIntro:', seenIntro, 'fromBoot:', fromBoot, 'onboardingComplete:', onboardingComplete)
if (shouldShowIntroSplash({
seenIntro,
routePath: route.path,
fromBoot,
devMode: import.meta.env.VITE_DEV_MODE,
onboardingComplete,
replayRequested,
})) {
// Kick off the intro video download NOW: the splash's <video> element only
// mounts ~20s in (after the typing sequence), and on a cold cache the file
// would otherwise start fetching mid-sequence and stutter.
try {
const link = document.createElement('link')
link.rel = 'preload'
link.as = 'video'
link.type = 'video/mp4'
link.href = '/assets/video/video-intro.mp4?v=8'
document.head.appendChild(link)
} catch { /* ignore */ }
feat: rootless podman, session hardening, boot stability, sidebar fix Rootless podman migration (TASK-11): - Remove sudo from all podman calls in PodmanClient + 8 backend files - Remove sudo from all podman/docker calls in deploy script - Restore full systemd security hardening: NoNewPrivileges, RestrictAddressFamilies, MemoryDenyWriteExecute, RestrictRealtime, RestrictNamespaces, RestrictSUIDSGID, SystemCallFilter, ProtectSystem=strict - Enable loginctl linger for rootless container persistence - Remove Ollama from auto-deploy (marketplace-only) Session & auth hardening: - Increase MAX_CONCURRENT_SESSIONS 20→50 (prevents eviction storms) - Debounced 401 redirect in rpc-client.ts (prevents redirect storms) Boot stability: - optimize-debian.sh: adds chrony, swap, removes policy-rc.d - deploy script: pre-restart chrony + swap setup - ISO build: chrony package, swap file creation - BootScreen: no longer clears localStorage (prevents splash replay) - RootRedirect: sole owner of localStorage clearing on server ready UI fixes: - Sidebar opacity default changed from 0→visible (fixes missing sidebar after page-persistence login without entrance animation) - Console.log/error wrapped in import.meta.env.DEV guards - Remove unused route import from RootRedirect Beta tracking: - CLAUDE.md: beta freeze protocol added - MASTER_PLAN.md: TASK-11, TASK-17, phase structure - BETA-PROGRESS.md: initial tracking doc - Tagged v1.2.0-alpha.1 as pre-rootless baseline Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:53:27 +00:00
// Coming from boot screen — show the full splash intro (Enter to Exit → typing → logo)
showSplash.value = true
} else {
// Already seen intro, direct route, or boot mode (boot screen handles intro)
// Set isReady BEFORE hiding splash to prevent flash of partial content
await router.isReady()
2026-01-24 22:59:20 +00:00
isReady.value = true
showSplash.value = false
document.body.classList.add('splash-complete')
2026-01-24 22:59:20 +00:00
}
})
onBeforeUnmount(() => {
2026-06-12 03:00:15 -04:00
document.documentElement.classList.remove('kiosk-safe-area')
document.documentElement.style.removeProperty('--kiosk-safe-area-x')
document.documentElement.style.removeProperty('--kiosk-safe-area-y')
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('mousemove', onUserActivity)
window.removeEventListener('mousedown', onUserActivity)
window.removeEventListener('keydown', onUserActivity)
window.removeEventListener('touchstart', onUserActivity)
window.removeEventListener('message', onShareToMeshMessage)
document.removeEventListener('wheel', containModalWheel, { capture: true })
document.removeEventListener('touchstart', containModalTouchStart, { capture: true })
document.removeEventListener('touchmove', containModalTouchMove, { capture: true })
modalOverlayObserver?.disconnect()
modalOverlayObserver = null
unlockBodyForModal()
})
/**
* Phase 3c: marketplace app iframes share files into mesh chats by POSTing
* to /api/share-to-mesh then postMessaging the CID back to this parent
* window. We stash it in sessionStorage + route to /mesh; Mesh.vue reads the
* stash on mount and stages it as a pending attachment.
*/
function onShareToMeshMessage(ev: MessageEvent) {
// Same-origin senders only (matches Chat.vue's handler) — otherwise any
// embedded frame can force-navigate the UI to /mesh with a staged CID.
if (ev.origin !== window.location.origin) return
const data = ev.data as { type?: string; cid?: string } | null
if (!data || data.type !== 'share-to-mesh' || !data.cid) return
try {
sessionStorage.setItem('archipelago_share_to_mesh', JSON.stringify(data))
} catch {
/* quota — fall through */
}
if (route.path !== '/mesh') {
router.push('/mesh')
} else {
// Already on /mesh — dispatch a synthetic event so the view picks it up.
window.dispatchEvent(new CustomEvent('archipelago:share-to-mesh'))
}
}
2026-01-24 22:59:20 +00:00
/**
* Handle splash screen completion
* Routes user directly to appropriate screen based on onboarding status (from backend)
2026-01-24 22:59:20 +00:00
*/
async function handleSplashComplete() {
2026-01-24 22:59:20 +00:00
showSplash.value = false
document.body.classList.add('splash-complete')
isReady.value = true
sessionStorage.setItem('archipelago_from_splash', '1')
const devMode = import.meta.env.VITE_DEV_MODE
if (devMode === 'setup' || devMode === 'existing') {
router.push('/login').catch(() => {})
return
}
try {
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
const seenOnboarding = await checkOnboardingStatus()
if (seenOnboarding === true) {
router.push('/login').catch(() => {})
return
}
if (seenOnboarding === false) {
router.push('/onboarding/intro').catch(() => {})
return
}
// Backend unreachable after retries. Prefer the localStorage
// cache on THIS browser (if a prior successful check set it) —
// otherwise defer to RootRedirect which polls + retries rather
// than forcing an already-onboarded user through the wizard.
if (localStorage.getItem('neode_onboarding_complete') === '1') {
router.push('/login').catch(() => {})
} else {
router.push('/').catch(() => {})
}
} catch {
// Do NOT default to /onboarding/intro here. RootRedirect has retry
// + polling + boot-screen handling; let it decide.
router.push('/').catch(() => {})
}
2026-01-24 22:59:20 +00:00
}
</script>
<style>
/* Global styles are in style.css */
</style>