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>
This commit is contained in:
Dorian
2026-03-18 13:53:27 +00:00
parent 934d120243
commit 870ff095d8
48 changed files with 2979 additions and 2196 deletions

View File

@@ -176,12 +176,13 @@ onMounted(async () => {
window.addEventListener('touchstart', onUserActivity)
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
const isDirectRoute = route.path !== '/'
const fromBoot = route.query.intro === '1'
const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1'
if (fromBoot) sessionStorage.removeItem('archipelago_from_boot')
if (import.meta.env.DEV) console.log('[App] onMounted — seenIntro:', seenIntro, 'fromBoot:', fromBoot)
if (fromBoot && !seenIntro) {
// Coming from boot screen — show the full splash intro
// Coming from boot screen — show the full splash intro (Enter to Exit → typing → logo)
showSplash.value = true
// SplashScreen will emit 'complete' → handleSplashComplete
} else if (!seenIntro && !isDirectRoute && import.meta.env.VITE_DEV_MODE !== 'boot') {
// Normal first visit (not boot mode) — show splash intro
showSplash.value = true

View File

@@ -21,6 +21,7 @@ function getCsrfToken(): string | null {
}
class RPCClient {
private static _sessionExpiredRedirecting = false
private baseUrl: string
constructor(baseUrl: string = '/rpc/v1') {
@@ -55,9 +56,16 @@ class RPCClient {
clearTimeout(timeoutId)
if (!response.ok) {
// Session expired — redirect to login
// Session expired — debounced redirect to login
// Use a single shared timeout to prevent redirect storms when
// multiple parallel requests all get 401 at once
if (response.status === 401 && method !== 'auth.login') {
window.location.href = '/login'
if (!RPCClient._sessionExpiredRedirecting) {
RPCClient._sessionExpiredRedirecting = true
setTimeout(() => {
window.location.href = '/login'
}, 300)
}
throw new Error('Session expired')
}
const err = new Error(`HTTP ${response.status}: ${response.statusText}`)

View File

@@ -1,40 +1,38 @@
<template>
<Transition name="boot-fade">
<div v-if="visible" class="boot-screen" @click="handleClick">
<div v-if="visible" class="boot-screen">
<!-- Particle starfield -->
<canvas ref="canvasRef" class="boot-stars" />
<!-- Two-column layout: terminal left, orb right -->
<div class="boot-layout" :class="{ 'boot-layout-centered': bootDone }">
<!-- Left: Terminal log (fades out when done) -->
<Transition name="terminal-fade">
<div v-if="!bootDone" class="boot-left">
<div class="boot-terminal" ref="terminalRef">
<p v-for="(line, i) in logLines" :key="i" class="boot-log-line" :class="line.type">
<span class="boot-log-ts">{{ line.prefix }}</span>
<span>{{ line.text }}</span>
</p>
<span class="boot-cursor">_</span>
</div>
<div class="boot-progress-wrap">
<svg class="boot-arc" viewBox="0 0 200 12" preserveAspectRatio="none">
<rect x="0" y="4" width="200" height="4" rx="2" fill="rgba(255,255,255,0.06)" />
<rect x="0" y="4" :width="progress * 2" height="4" rx="2" fill="url(#boot-grad)" />
<defs>
<linearGradient id="boot-grad" x1="0" y1="0" x2="200" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fb923c" />
<stop offset="1" stop-color="#f59e0b" />
</linearGradient>
</defs>
</svg>
<span class="boot-pct">{{ Math.round(progress) }}%</span>
</div>
<div class="boot-layout">
<!-- Left: Terminal log -->
<div class="boot-left">
<div class="boot-terminal" ref="terminalRef">
<p v-for="(line, i) in logLines" :key="i" class="boot-log-line" :class="line.type">
<span class="boot-log-ts">{{ line.prefix }}</span>
<span>{{ line.text }}</span>
</p>
<span class="boot-cursor">_</span>
</div>
</Transition>
<div class="boot-progress-wrap">
<svg class="boot-arc" viewBox="0 0 200 12" preserveAspectRatio="none">
<rect x="0" y="4" width="200" height="4" rx="2" fill="rgba(255,255,255,0.06)" />
<rect x="0" y="4" :width="progress * 2" height="4" rx="2" fill="url(#boot-grad)" />
<defs>
<linearGradient id="boot-grad" x1="0" y1="0" x2="200" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="rgba(255,255,255,0.5)" />
<stop offset="1" stop-color="rgba(255,255,255,0.9)" />
</linearGradient>
</defs>
</svg>
<span class="boot-pct">{{ Math.round(progress) }}%</span>
</div>
</div>
<!-- Right (or center when done): The orb / screensaver -->
<!-- Right: The orb -->
<div class="boot-right">
<div class="boot-orb" :class="{ 'boot-orb-screensaver': bootDone }">
<div class="boot-orb">
<!-- Viz ring segments -->
<div class="boot-viz-ring">
<div
@@ -46,23 +44,17 @@
/>
</div>
<!-- Center: screensaver-style bordered frame with pixel icon / logo -->
<!-- Center: gradient-bordered frame with cycling icons -->
<div class="boot-center-icon">
<div class="logo-gradient-border boot-icon-frame">
<div class="boot-icon-inner">
<Transition name="icon-morph" mode="out-in">
<div v-if="!bootDone" :key="currentIcon" class="boot-pixel-wrap" :class="{ 'boot-glitch': glitching }">
<div v-html="sanitizedIcon" />
</div>
<div v-else key="logo" class="boot-logo-inner-logo">
<AnimatedLogo size="xl" no-border fit />
</div>
</Transition>
</div>
<div class="boot-icon-frame boot-gradient-border">
<Transition name="icon-morph" mode="out-in">
<div :key="currentIcon" class="boot-pixel-wrap" :class="{ 'boot-glitch': glitching }">
<img :src="iconSources[currentIcon]" class="boot-icon-img" />
</div>
</Transition>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -70,9 +62,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import DOMPurify from 'dompurify'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{ ready: [] }>()
@@ -80,61 +70,19 @@ const emit = defineEmits<{ ready: [] }>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
const terminalRef = ref<HTMLElement | null>(null)
const bootDone = ref(false)
const currentIcon = ref(0)
const progress = ref(0)
const litBars = ref(0)
const glitching = ref(false)
// 16x16 pixel art icons
const icons = [
// Big smiley — warm and friendly
`<svg viewBox="0 0 16 16" class="boot-svg">
<rect x="4" y="5" width="2" height="3" fill="white"/>
<rect x="10" y="5" width="2" height="3" fill="white"/>
<rect x="4" y="5" width="2" height="1" fill="rgba(255,255,255,0.5)"/>
<rect x="10" y="5" width="2" height="1" fill="rgba(255,255,255,0.5)"/>
<rect x="3" y="10" width="2" height="1" fill="white"/>
<rect x="11" y="10" width="2" height="1" fill="white"/>
<rect x="5" y="11" width="6" height="1" fill="white"/>
</svg>`,
// Bitcoin
`<svg viewBox="0 0 16 16" class="boot-svg">
<rect x="5" y="1" width="2" height="2" fill="#f7931a"/><rect x="9" y="1" width="2" height="2" fill="#f7931a"/>
<rect x="4" y="3" width="2" height="10" fill="#f7931a"/><rect x="6" y="3" width="4" height="2" fill="#f7931a"/>
<rect x="10" y="4" width="2" height="3" fill="#f7931a"/><rect x="6" y="7" width="4" height="2" fill="#f7931a"/>
<rect x="10" y="8" width="2" height="4" fill="#f7931a"/><rect x="6" y="11" width="4" height="2" fill="#f7931a"/>
<rect x="5" y="13" width="2" height="2" fill="#f7931a"/><rect x="9" y="13" width="2" height="2" fill="#f7931a"/>
</svg>`,
// Lightning
`<svg viewBox="0 0 16 16" class="boot-svg">
<rect x="8" y="0" width="3" height="3" fill="#fbbf24"/><rect x="6" y="3" width="3" height="3" fill="#fbbf24"/>
<rect x="4" y="6" width="8" height="2" fill="#fbbf24"/><rect x="7" y="8" width="3" height="3" fill="#fbbf24"/>
<rect x="5" y="11" width="3" height="3" fill="#fbbf24"/><rect x="3" y="14" width="3" height="2" fill="#fbbf24"/>
</svg>`,
// Shield
`<svg viewBox="0 0 16 16" class="boot-svg">
<rect x="3" y="1" width="10" height="2" fill="#60a5fa"/><rect x="2" y="3" width="2" height="7" fill="#60a5fa"/>
<rect x="12" y="3" width="2" height="7" fill="#60a5fa"/><rect x="4" y="10" width="2" height="2" fill="#60a5fa"/>
<rect x="10" y="10" width="2" height="2" fill="#60a5fa"/><rect x="6" y="12" width="4" height="2" fill="#60a5fa"/>
<rect x="7" y="5" width="2" height="4" fill="white" opacity="0.5"/><rect x="6" y="6" width="4" height="2" fill="white" opacity="0.5"/>
</svg>`,
// Key
`<svg viewBox="0 0 16 16" class="boot-svg">
<circle cx="5" cy="6" r="3" fill="none" stroke="#4ade80" stroke-width="1.5"/>
<rect x="7" y="5" width="7" height="2" fill="#4ade80"/>
<rect x="12" y="7" width="2" height="2" fill="#4ade80"/><rect x="10" y="7" width="2" height="2" fill="#4ade80"/>
</svg>`,
// Mesh nodes
`<svg viewBox="0 0 16 16" class="boot-svg">
<circle cx="8" cy="8" r="2" fill="white"/>
<circle cx="3" cy="3" r="1.5" fill="#a78bfa"/><circle cx="13" cy="3" r="1.5" fill="#a78bfa"/>
<circle cx="3" cy="13" r="1.5" fill="#a78bfa"/><circle cx="13" cy="13" r="1.5" fill="#a78bfa"/>
<line x1="8" y1="8" x2="3" y2="3" stroke="#a78bfa" stroke-width="0.5" opacity="0.5"/>
<line x1="8" y1="8" x2="13" y2="3" stroke="#a78bfa" stroke-width="0.5" opacity="0.5"/>
<line x1="8" y1="8" x2="3" y2="13" stroke="#a78bfa" stroke-width="0.5" opacity="0.5"/>
<line x1="8" y1="8" x2="13" y2="13" stroke="#a78bfa" stroke-width="0.5" opacity="0.5"/>
</svg>`,
// Boot screen icons — from /assets/icon/ directory
const iconSources = [
'/assets/icon/bitcoin.svg',
'/assets/icon/cloud-done.svg',
'/assets/icon/github.svg',
'/assets/icon/save.svg',
'/assets/icon/batteries.svg',
'/assets/icon/barbarian.svg',
]
interface LogLine { prefix: string; text: string; type: string }
@@ -159,8 +107,6 @@ const bootMessages = [
{ delay: 23500, prefix: '***', text: 'ALL SYSTEMS OPERATIONAL', type: 'ready' },
]
const sanitizedIcon = computed(() => DOMPurify.sanitize(icons[currentIcon.value] || '', { USE_PROFILES: { svg: true } }))
// Starfield
let animFrame = 0
const stars: { x: number; y: number; z: number }[] = []
@@ -191,15 +137,6 @@ function drawStars(c: HTMLCanvasElement, ctx: CanvasRenderingContext2D) {
function triggerGlitch() { glitching.value = true; setTimeout(() => { glitching.value = false }, 200) }
function handleClick() {
if (!bootDone.value) return
// Clear intro flag so App.vue's SplashScreen plays the full intro sequence
localStorage.removeItem('neode_intro_seen')
// Also clear onboarding flag so it goes through onboarding after intro
localStorage.removeItem('neode_onboarding_complete')
emit('ready')
}
// Health check
async function checkHealth(): Promise<boolean> {
try {
@@ -221,10 +158,11 @@ let logTimeouts: ReturnType<typeof setTimeout>[] = []
function startPolling() {
iconInterval = setInterval(() => {
if (!bootDone.value) { currentIcon.value = (currentIcon.value + 1) % icons.length; triggerGlitch() }
currentIcon.value = (currentIcon.value + 1) % iconSources.length
triggerGlitch()
}, 2500)
// Feed boot log messages — the visual sequence drives the timeline
// Feed boot log messages
const lastMsgDelay = bootMessages[bootMessages.length - 1]!.delay
for (const msg of bootMessages) {
logTimeouts.push(setTimeout(() => {
@@ -237,23 +175,29 @@ function startPolling() {
}, msg.delay))
}
// After the last message, start polling for real server readiness
// (visual sequence must complete before we transition)
// After the last message, poll for server readiness then immediately transition
let finished = false
logTimeouts.push(setTimeout(() => {
// In dev/mock mode the server may already be ready — check and complete
const finishBoot = () => {
if (finished) return
finished = true
stopPolling()
progress.value = 100
litBars.value = 48
setTimeout(() => { bootDone.value = true }, 1200)
if (import.meta.env.DEV) console.log('[Boot] finishBoot — emitting ready in 800ms')
setTimeout(() => {
if (import.meta.env.DEV) console.log('[Boot] emitting ready now')
emit('ready')
}, 800)
}
// Check immediately
checkHealth().then(r => {
if (import.meta.env.DEV) console.log('[Boot] health check result:', r)
if (r) { finishBoot(); return }
// Not ready yet — poll until it is
pollInterval = setInterval(async () => {
if (await checkHealth()) finishBoot()
const healthy = await checkHealth()
if (import.meta.env.DEV) console.log('[Boot] poll health:', healthy)
if (healthy) finishBoot()
}, 2000)
})
}, lastMsgDelay + 1500))
@@ -280,10 +224,16 @@ function initCanvas() {
if (ctx) { initStars(c); drawStars(c, ctx) }
}
watch(() => props.visible, v => { if (v) { startPolling(); nextTick(initCanvas) } })
onMounted(() => { if (props.visible) { startPolling(); nextTick(initCanvas) } })
let started = false
function startIfNeeded() {
if (started) return
started = true
startPolling()
nextTick(initCanvas)
}
watch(() => props.visible, v => { if (v) startIfNeeded() })
onMounted(() => { if (props.visible) startIfNeeded() })
onBeforeUnmount(() => { stopPolling(); cancelAnimationFrame(animFrame) })
defineExpose({ startPolling })
</script>
<style scoped>
@@ -292,7 +242,6 @@ defineExpose({ startPolling })
display: flex; align-items: center; justify-content: center;
cursor: default; overflow: hidden;
}
.boot-screen:has(.boot-click-prompt) { cursor: pointer; }
.boot-stars { position: absolute; inset: 0; width: 100%; height: 100%; }
/* Two-column layout */
@@ -300,9 +249,7 @@ defineExpose({ startPolling })
position: relative; z-index: 1;
display: flex; align-items: center; gap: 3rem;
max-width: 900px; width: 90%; padding: 0 1rem;
transition: justify-content 0.8s ease;
}
.boot-layout-centered { justify-content: center; }
/* Left column: terminal */
.boot-left {
@@ -318,19 +265,19 @@ defineExpose({ startPolling })
}
.boot-log-line { white-space: nowrap; overflow: hidden; animation: log-in 0.3s ease both; }
.boot-log-line.info { color: rgba(255,255,255,0.35); }
.boot-log-line.success { color: #4ade80; }
.boot-log-line.ready { color: #fb923c; font-weight: 600; text-shadow: 0 0 10px rgba(251,146,60,0.5); }
.boot-log-line.success { color: rgba(255,255,255,0.7); }
.boot-log-line.ready { color: white; font-weight: 600; text-shadow: 0 0 10px rgba(255,255,255,0.4); }
.boot-log-ts { color: rgba(255,255,255,0.15); margin-right: 8px; font-weight: 500; }
.boot-log-line.success .boot-log-ts { color: rgba(74,222,128,0.4); }
.boot-log-line.ready .boot-log-ts { color: rgba(251,146,60,0.6); }
.boot-log-line.success .boot-log-ts { color: rgba(255,255,255,0.35); }
.boot-log-line.ready .boot-log-ts { color: rgba(255,255,255,0.5); }
@keyframes log-in { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } }
.boot-cursor { color: rgba(251,146,60,0.7); animation: blink 1s step-end infinite; font-family: monospace; font-size: 12px; }
.boot-cursor { color: rgba(255,255,255,0.5); animation: blink 1s step-end infinite; font-family: monospace; font-size: 12px; }
@keyframes blink { 50% { opacity: 0; } }
.boot-progress-wrap { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
.boot-arc { flex: 1; height: 12px; }
.boot-pct { font-family: 'SF Mono', monospace; font-size: 10px; color: rgba(255,255,255,0.25); min-width: 28px; text-align: right; }
.boot-pct { font-family: 'SF Mono', monospace; font-size: 10px; color: rgba(255,255,255,0.3); min-width: 28px; text-align: right; }
/* Right column: orb */
.boot-right {
@@ -339,24 +286,14 @@ defineExpose({ startPolling })
.boot-orb {
position: relative; width: 220px; height: 220px;
transition: width 0.8s ease, height 0.8s ease;
}
@media (min-width: 640px) { .boot-orb { width: 280px; height: 280px; } }
@media (min-width: 768px) { .boot-orb { width: 320px; height: 320px; } }
.boot-orb-screensaver {
width: 280px; height: 280px;
}
@media (min-width: 640px) { .boot-orb-screensaver { width: 360px; height: 360px; } }
@media (min-width: 768px) { .boot-orb-screensaver { width: 400px; height: 400px; } }
/* Viz ring */
.boot-viz-ring { position: absolute; inset: 0; --vr: 100px; }
@media (min-width: 640px) { .boot-viz-ring { --vr: 130px; } }
@media (min-width: 768px) { .boot-viz-ring { --vr: 150px; } }
.boot-orb-screensaver .boot-viz-ring { --vr: 130px; }
@media (min-width: 640px) { .boot-orb-screensaver .boot-viz-ring { --vr: 170px; } }
@media (min-width: 768px) { .boot-orb-screensaver .boot-viz-ring { --vr: 190px; } }
.boot-viz-seg {
position: absolute; left: 50%; top: 50%;
@@ -367,22 +304,10 @@ defineExpose({ startPolling })
transition: background 0.4s ease, height 0.4s ease, box-shadow 0.4s ease;
}
.boot-seg-lit {
background: linear-gradient(to bottom, rgba(251,146,60,0.8), rgba(245,158,11,0.3));
box-shadow: 0 0 5px rgba(251,146,60,0.2);
background: linear-gradient(to bottom, rgba(255,255,255,0.7), rgba(255,255,255,0.2));
box-shadow: 0 0 6px rgba(255,255,255,0.15);
height: 22px; margin-top: -11px;
}
/* When done, all segments pulse like screensaver */
.boot-orb-screensaver .boot-viz-seg {
background: linear-gradient(to bottom, rgba(255,255,255,0.4), rgba(255,255,255,0.1));
box-shadow: none; height: 24px; margin-top: -12px;
animation: seg-pulse 14s ease-in-out infinite;
animation-delay: calc(var(--si) * 0.02s);
}
@keyframes seg-pulse {
0%,14.3%,28.6%,42.9%,57.1%,71.4%,92.9%,100% { opacity:0.3; transform: rotate(var(--sd)) translateY(calc(-1 * var(--vr))) scaleY(0.4); }
7.1%,21.4%,35.7%,50%,64.3% { opacity:0.9; transform: rotate(var(--sd)) translateY(calc(-1 * var(--vr))) scaleY(1); }
78.6%,85.7% { opacity:1; transform: rotate(var(--sd)) translateY(calc(-1 * var(--vr))) scaleY(1.5); }
}
/* Center icon */
.boot-center-icon {
@@ -390,29 +315,39 @@ defineExpose({ startPolling })
filter: drop-shadow(0 0 30px rgba(255,255,255,0.1));
}
.boot-icon-frame {
width: 140px; height: 140px;
display: flex; align-items: center; justify-content: center; overflow: hidden;
}
@media (min-width: 640px) { .boot-icon-frame { width: 180px; height: 180px; } }
@media (min-width: 768px) { .boot-icon-frame { width: 220px; height: 220px; } }
.boot-orb-screensaver .boot-icon-frame {
width: 192px; height: 192px;
}
@media (min-width: 640px) { .boot-orb-screensaver .boot-icon-frame { width: 256px; height: 256px; } }
@media (min-width: 768px) { .boot-orb-screensaver .boot-icon-frame { width: 320px; height: 320px; } }
.boot-icon-inner {
position: absolute; inset: 3px;
width: 120px; height: 120px;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.85); border-radius: inherit;
}
@media (min-width: 640px) { .boot-icon-frame { width: 160px; height: 160px; } }
@media (min-width: 768px) { .boot-icon-frame { width: 200px; height: 200px; } }
/* Gradient border — circular, matches logo-gradient-border style */
.boot-gradient-border {
position: relative;
border-radius: 9999px;
padding: 3px;
background: linear-gradient(135deg, rgba(255,255,255,0.6) 0%, rgba(0,0,0,0.8) 100%);
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
}
.boot-gradient-border::after {
content: '';
position: absolute;
inset: 3px;
border-radius: 9999px;
background: #000;
z-index: 0;
}
.boot-pixel-wrap { width: 72px; height: 72px; }
@media (min-width: 640px) { .boot-pixel-wrap { width: 90px; height: 90px; } }
.boot-pixel-wrap {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
position: relative; z-index: 1;
}
.boot-logo-inner-logo { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
:deep(.boot-svg) { width: 100%; height: 100%; image-rendering: pixelated; image-rendering: crisp-edges; }
.boot-icon-img {
width: 55%; height: 55%; object-fit: contain;
filter: brightness(0) invert(1) drop-shadow(0 0 8px rgba(255,255,255,0.15));
}
/* Glitch */
.boot-glitch { animation: glitch 0.2s steps(3) both; }
@@ -430,22 +365,6 @@ defineExpose({ startPolling })
.icon-morph-enter-from { opacity:0; transform: scale(0.5) rotate(-10deg); filter: blur(4px); }
.icon-morph-leave-to { opacity:0; transform: scale(1.4) rotate(10deg); filter: blur(4px); }
/* Click prompt */
.boot-click-prompt {
color: rgba(255,255,255,0.4); font-size: 0.8rem; font-weight: 500;
letter-spacing: 0.1em; text-transform: uppercase;
animation: prompt-breathe 3s ease-in-out infinite;
}
@keyframes prompt-breathe {
0%,100% { opacity: 0.3; } 50% { opacity: 0.7; }
}
.prompt-fade-enter-active { transition: opacity 1s ease 0.5s; }
.prompt-fade-enter-from { opacity: 0; }
/* Terminal fade out */
.terminal-fade-leave-active { transition: opacity 0.8s ease, transform 0.8s ease; }
.terminal-fade-leave-to { opacity: 0; transform: translateX(-30px); }
/* Boot screen fade out */
.boot-fade-leave-active { transition: opacity 1.2s ease; }
.boot-fade-leave-to { opacity: 0; }
@@ -455,6 +374,5 @@ defineExpose({ startPolling })
.boot-layout { flex-direction: column-reverse; gap: 2rem; }
.boot-left { max-width: 100%; }
.boot-orb { width: 200px; height: 200px; }
.boot-orb-screensaver { width: 260px; height: 260px; }
}
</style>

View File

@@ -593,7 +593,7 @@ async function handleNostrRequest(event: MessageEvent) {
if (!source) return
const storedIdentity = getStoredIdentity()
const identityId = storedIdentity?.id || null
console.log(`[NIP-07] ${method} identityId=${identityId} storedPubkey=${storedIdentity?.nostr_pubkey?.slice(0, 12) || 'none'}`)
if (import.meta.env.DEV) console.log(`[NIP-07] ${method} identityId=${identityId} storedPubkey=${storedIdentity?.nostr_pubkey?.slice(0, 12) || 'none'}`)
try {
let result: unknown
@@ -601,7 +601,7 @@ async function handleNostrRequest(event: MessageEvent) {
// Use stored nostr_pubkey directly if available (avoids RPC call that may 401)
if (storedIdentity?.nostr_pubkey) {
result = storedIdentity.nostr_pubkey
console.log('[NIP-07] getPublicKey from stored identity:', (result as string).slice(0, 12))
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
@@ -610,13 +610,13 @@ async function handleNostrRequest(event: MessageEvent) {
result = res.nostr_pubkey
}
} else if (method === 'signEvent') {
console.log(`[NIP-07] signEvent kind=${params.event?.kind} using identity=${identityId || 'node-default'}`)
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 } })
}
console.log('[NIP-07] signEvent OK')
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 }
@@ -625,7 +625,7 @@ async function handleNostrRequest(event: MessageEvent) {
else { throw new Error(`Unsupported NIP-07 method: ${method}`) }
source.postMessage({ type: 'nostr-response', id, result }, '*')
} catch (err) {
console.error(`[NIP-07] ${method} FAILED:`, err instanceof Error ? err.message : err)
if (import.meta.env.DEV) console.error(`[NIP-07] ${method} FAILED:`, err instanceof Error ? err.message : err)
source.postMessage({ type: 'nostr-response', id, error: err instanceof Error ? err.message : 'Unknown error' }, '*')
}
}

View File

@@ -982,10 +982,11 @@ function dismissNotification(id: string) {
.sidebar-inner {
overflow: hidden;
opacity: 0;
}
/* Only hide sidebar content when doing the login entrance animation */
.sidebar-animate .sidebar-inner {
opacity: 0;
animation: sidebar-inner-draw 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
animation-delay: 6.1s;
}

View File

@@ -14,12 +14,11 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useRouter } from 'vue-router'
import { isOnboardingComplete } from '@/composables/useOnboarding'
import BootScreen from '@/components/BootScreen.vue'
const router = useRouter()
const route = useRoute()
const showBootScreen = ref(false)
async function quickHealthCheck(): Promise<boolean> {
@@ -66,20 +65,24 @@ async function proceedToApp() {
}
function onServerReady() {
// Clear flags so splash intro plays on reload
if (import.meta.env.DEV) console.log('[RootRedirect] onServerReady — setting flag and reloading')
localStorage.removeItem('neode_intro_seen')
localStorage.removeItem('neode_onboarding_complete')
// Reload with ?intro=1 so we know to skip boot and let App.vue handle splash
window.location.href = '/?intro=1'
sessionStorage.setItem('archipelago_from_boot', '1')
window.location.href = '/'
}
onMounted(async () => {
const devMode = import.meta.env.VITE_DEV_MODE
// Coming back from boot screen — do nothing, let App.vue's SplashScreen take over
if (route.query.intro === '1') {
// Clean the URL without navigating
window.history.replaceState({}, '', '/')
// Coming back from boot screen — let App.vue's SplashScreen take over
if (sessionStorage.getItem('archipelago_from_boot') === '1') {
return
}
// Splash already completed this session — go to app
if (sessionStorage.getItem('archipelago_from_splash') === '1') {
proceedToApp()
return
}
@@ -89,7 +92,7 @@ onMounted(async () => {
return
}
// Boot dev mode — always show boot screen
// Boot dev mode — always show boot screen (first load only)
if (devMode === 'boot') {
showBootScreen.value = true
return

View File

@@ -1009,7 +1009,7 @@ async function saveServerName() {
try {
await rpcClient.call({ method: 'server.set-name', params: { name } })
} catch (e) {
console.error('Failed to rename server:', e)
if (import.meta.env.DEV) console.error('Failed to rename server:', e)
}
editingServerName.value = false
}