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>
@@ -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"), {
|
||||
|
||||
|
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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }, '*')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||