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
co-authored by Claude Opus 4.6
parent 934d120243
commit 870ff095d8
48 changed files with 2979 additions and 2196 deletions
+1 -1
View File
@@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.h5o7c3cl7uo"
"revision": "0.a4nevj6csc4"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><title xmlns="">batteries</title><path fill="currentColor" d="M168.063 21.844c-25.008 0-47.713 5.09-64.97 13.968c-16.938 8.716-29.722 21.962-30.187 38.626h-.03v93.625C59.258 180.325 46.9 197.92 37.75 219.032c-9.94 22.934-14.284 45.82-13 65.187c1.25 18.84 8.173 35.74 23 42.905l.344.156c.302.143.598.274.906.408l23.875 10.343v108.814h.03c.675 16.458 13.396 29.547 30.19 38.187c17.257 8.88 39.97 13.97 64.968 13.97c24.996 0 47.71-5.09 64.968-13.97c16.794-8.64 29.515-21.728 30.19-38.186h.03V420.56l42.844 18.563l.22.094l74.56 32.31l7.72 3.345l.844.375v-.03c15.48 6.212 32.73-.264 47.468-12.345c15.01-12.302 28.71-31.118 38.656-54.063c9.946-22.944 14.315-45.823 13.032-65.187c-1.26-19.01-8.33-36.01-23.438-43.063v-.03l-8.562-3.72L382.03 264.5l-118.78-51.47V76.97c.025-.53.03-1.06.03-1.595c0-.315-.02-.625-.03-.938c-.465-16.663-13.248-29.91-30.188-38.624c-17.256-8.88-39.992-13.97-65-13.97zM140.25 43.062c.03-.005.064.006.094 0c-6.743 3.237-10.906 7.637-10.906 12.5c0 9.93 17.292 17.97 38.625 17.97s38.625-8.04 38.625-17.97c0-4.863-4.164-9.263-10.907-12.5c11.11 2.093 20.927 5.366 28.72 9.376c13.818 7.11 20.094 15.646 20.094 22.937c0 7.29-6.276 15.797-20.094 22.906c-13.818 7.11-34.028 11.907-56.438 11.907s-42.62-4.797-56.437-11.906c-13.818-7.108-20.063-15.614-20.063-22.905c0-7.29 6.245-15.828 20.063-22.938c7.772-3.998 17.554-7.28 28.625-9.374zM72.875 195.656v122l-16.438-7.125c-6.678-2.894-12.003-12.02-13.03-27.53c-1.03-15.51 2.593-35.983 11.5-56.53c5.082-11.73 11.316-22.17 17.968-30.814zm171.688 1.75V445.47c0 7.278-6.24 15.825-20.063 22.936s-34.042 11.906-56.438 11.906c-22.395 0-42.615-4.794-56.437-11.906c-13.822-7.11-20.063-15.658-20.063-22.937V200.31L145.375 280l-36.844 12.875L229.75 420.25l-51.72-105.78l23.907-11.845l-40.156-84.188c2.082.073 4.168.125 6.282.125c24.997 0 47.71-5.09 64.97-13.968c4.134-2.128 8.008-4.537 11.53-7.188m18.687 36l79 34.25l-79 14.78zm187.97 82.78c2.094.035 3.983.433 5.655 1.158c6.69 2.9 12.035 12.026 13.063 27.53c.577 8.715-.333 18.995-2.813 29.97c-.305-7.425-2.682-12.95-7.125-14.875c-9.11-3.95-23.39 8.707-31.875 28.28s-7.953 38.645 1.156 42.594c4.45 1.928 10.12-.104 15.75-4.97c-6.316 9.33-13.207 17.023-19.967 22.563c-12.02 9.85-22.342 12.18-29.032 9.282c-6.688-2.9-12.034-12.027-13.06-27.533c-1.03-15.505 2.618-35.94 11.53-56.5s21.357-37.21 33.375-47.062c9.014-7.388 17.058-10.537 23.344-10.438zm-153.5 8.658c-.9 1.89-1.78 3.8-2.626 5.75c-9.94 22.935-14.315 45.79-13.03 65.156c.307 4.65.966 9.173 1.998 13.47l-20.812-9v-74.126z"/></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><title xmlns="">bitcoin</title><path fill="currentColor" d="M13 3h2v2h2v2H9v4h8v2H9v4h8v2h-2v2h-2v-2h-2v2H9v-2H5v-2h2v-4H5v-2h2V7H5V5h4V3h2v2h2zm4 14v-4h2v4zm0-6V7h2v4z"/></svg>

After

Width:  |  Height:  |  Size: 262 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><title xmlns="">cloud-done</title><path fill="currentColor" d="M16 4h-6v2H8v2H4v2H2v2H0v6h2v2h20v-2h2v-6h-2v-2h-2V8h-2V6h-2zm0 2v2h2v4h4v6H2v-6h2v-2h4V8h2V6zm-6 6H8v2h2v2h2v-2h2v-2h2v-2h-2v2h-2v2h-2z"/></svg>

After

Width:  |  Height:  |  Size: 293 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><title xmlns="">cloud-moon</title><path fill="currentColor" d="M18 2h-8v2H8v2H6v4h2V6h2V4h4v2h-2v4h2v2h4v-2h2v4h-2v2h2v-2h2V6h-2v2h-2v2h-4V6h2V4h2zM8 14v-2h4v2zm0 2v-2H4v2H2v4h2v2h10v-2h2v-4h-2v-2h-2v2h2v4H4v-4zm0 0h2v2H8z"/></svg>

After

Width:  |  Height:  |  Size: 316 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><title xmlns="">debug-off</title><path fill="currentColor" d="M16 2h2v2h-2zm4 7h-2V6h-2V4h-2v2h-2v2h4v5h2v2h4v-2h-4v-2h2zm0 0V7h2v2zM8 20v-9H6V9H4V7H2v2h2v2h2v2H2v2h4v2H4v2H2v2h2v-2h2v3h10v-2zm2-5h2v2h-2zM2 2h2v2H2zm4 4H4V4h2zm2 2H6V6h2zm2 2H8V8h2zm0 0v2h2v2h2v2h2v2h2v2h2v2h2v-2h-2v-2h-2v-2h-2v-2h-2v-2h-2v-2z"/></svg>

After

Width:  |  Height:  |  Size: 404 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><title xmlns="">fill-half</title><path fill="currentColor" d="M9 2h2v2H9zm4 4V4h-2v2H9v2H7v2H5v2H3v2h2v2h2v2h2v2h2v2h2v-2h2v-2h2v-2h2v6h2V12h-2v-2h-2V8h-2V6zm0 0v2h2v2h2v2h2v2H5v-2h2v-2h2V8h2V6z"/></svg>

After

Width:  |  Height:  |  Size: 288 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><title xmlns="">github</title><path fill="currentColor" d="M5 2h4v2H7v2H5zm0 10H3V6h2zm2 2H5v-2h2zm2 2v-2H7v2H3v-2H1v2h2v2h4v4h2v-4h2v-2zm0 0v2H7v-2zm6-12v2H9V4zm4 2h-2V4h-2V2h4zm0 6V6h2v6zm-2 2v-2h2v2zm-2 2v-2h2v2zm0 2h-2v-2h2zm0 0h2v4h-2z"/></svg>

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><title xmlns="">paint-bucket</title><path fill="currentColor" d="M8 3h8v2H8zm0 2H6v4H4v12h16V9h-2V5h-2v4H8zm8 6h2v8H6v-8h2v6h2v-4h2v2h2v-2h2z"/></svg>

After

Width:  |  Height:  |  Size: 235 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><title xmlns="">save</title><path fill="currentColor" d="M4 2h14v2H4v16h2v-6h12v6h2V6h2v16H2V2zm4 18h8v-4H8zM20 6h-2V4h2zM6 6h9v4H6z"/></svg>

After

Width:  |  Height:  |  Size: 226 B

+4 -3
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
+10 -2
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}`)
+104 -186
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>
+5 -5
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' }, '*')
}
}
+2 -1
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;
}
+13 -10
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
+1 -1
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
}