- New Settings → App registries page (/dashboard/settings/registries)
that mirrors the update-mirrors experience: list of configured
registries, test reachability, set primary, add/remove. New
registry.set-primary RPC; existing registry.{list,add,remove,test}
reused.
- Default RegistryConfig flipped: VPS (23.182.128.160:3000/lfg2025) is
now Server 1 (primary), tx1138 is Server 2 (fallback).
- Install pipeline now rewrites the first pull to the primary registry
URL before attempting it. Before this, installs always hit whichever
registry the image was hardcoded to, so changing the primary didn't
actually affect where images came from. On failure, the existing
fallback walk skips the primary (already tried) and walks the rest.
- App catalog proxy UPSTREAMS order flipped so the catalog follows the
same VPS-first rule.
- Reboot overlay: animated "a" logo now sits in the center of the ring
(matches the screensaver composition). Extracted the logo-wrapper
pattern inline.
7/7 registry tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
import { createRouter, createWebHistory } from 'vue-router'
|
|
import { nextTick } from 'vue'
|
|
import { useAppStore } from '../stores/app'
|
|
import { stopAllAudio } from '../composables/useLoginSounds'
|
|
|
|
const router = createRouter({
|
|
history: createWebHistory(),
|
|
routes: [
|
|
{
|
|
path: '/',
|
|
component: () => import('../views/OnboardingWrapper.vue'),
|
|
meta: { public: true },
|
|
children: [
|
|
{
|
|
path: '',
|
|
component: () => import('../views/RootRedirect.vue'),
|
|
},
|
|
{
|
|
path: 'login',
|
|
name: 'login',
|
|
component: () => import('../views/Login.vue'),
|
|
},
|
|
{
|
|
path: 'onboarding/intro',
|
|
name: 'onboarding-intro',
|
|
component: () => import('../views/OnboardingIntro.vue'),
|
|
},
|
|
{
|
|
path: 'onboarding/options',
|
|
name: 'onboarding-options',
|
|
component: () => import('../views/OnboardingOptions.vue'),
|
|
},
|
|
{
|
|
path: 'onboarding/path',
|
|
name: 'onboarding-path',
|
|
component: () => import('../views/OnboardingPath.vue'),
|
|
},
|
|
{
|
|
path: 'onboarding/seed',
|
|
name: 'onboarding-seed',
|
|
component: () => import('../views/OnboardingSeedGenerate.vue'),
|
|
},
|
|
{
|
|
path: 'onboarding/seed-verify',
|
|
name: 'onboarding-seed-verify',
|
|
component: () => import('../views/OnboardingSeedVerify.vue'),
|
|
},
|
|
{
|
|
path: 'onboarding/seed-restore',
|
|
name: 'onboarding-seed-restore',
|
|
component: () => import('../views/OnboardingSeedRestore.vue'),
|
|
},
|
|
{
|
|
path: 'onboarding/did',
|
|
name: 'onboarding-did',
|
|
component: () => import('../views/OnboardingDid.vue'),
|
|
},
|
|
{
|
|
path: 'onboarding/identity',
|
|
name: 'onboarding-identity',
|
|
component: () => import('../views/OnboardingIdentity.vue'),
|
|
},
|
|
{
|
|
path: 'onboarding/backup',
|
|
name: 'onboarding-backup',
|
|
component: () => import('../views/OnboardingBackup.vue'),
|
|
},
|
|
{
|
|
path: 'onboarding/verify',
|
|
name: 'onboarding-verify',
|
|
component: () => import('../views/OnboardingVerify.vue'),
|
|
},
|
|
{
|
|
path: 'onboarding/done',
|
|
name: 'onboarding-done',
|
|
component: () => import('../views/OnboardingDone.vue'),
|
|
},
|
|
],
|
|
},
|
|
{
|
|
path: '/recovery',
|
|
name: 'recovery',
|
|
component: () => import('../views/KioskRecovery.vue'),
|
|
meta: { public: true },
|
|
},
|
|
{
|
|
path: '/kiosk',
|
|
name: 'kiosk',
|
|
redirect: '/',
|
|
beforeEnter: () => {
|
|
// Persist kiosk mode before redirect so App.vue can skip the remote relay
|
|
// (relay duplicates xdotool input on the kiosk display)
|
|
localStorage.setItem('kiosk', 'true')
|
|
},
|
|
},
|
|
{
|
|
path: '/dashboard',
|
|
component: () => import('../views/Dashboard.vue'),
|
|
children: [
|
|
{
|
|
path: '',
|
|
name: 'home',
|
|
component: () => import('../views/Home.vue'),
|
|
},
|
|
{
|
|
path: 'apps',
|
|
name: 'apps',
|
|
component: () => import('../views/Apps.vue'),
|
|
},
|
|
{
|
|
path: 'apps/:id',
|
|
name: 'app-details',
|
|
component: () => import('../views/AppDetails.vue'),
|
|
},
|
|
{
|
|
path: 'apps/lnd/channels',
|
|
name: 'lightning-channels',
|
|
component: () => import('../views/apps/LightningChannels.vue'),
|
|
},
|
|
{
|
|
path: 'discover',
|
|
name: 'discover',
|
|
component: () => import('../views/Discover.vue'),
|
|
},
|
|
{
|
|
path: 'marketplace',
|
|
name: 'marketplace',
|
|
component: () => import('../views/Marketplace.vue'),
|
|
},
|
|
{
|
|
path: 'marketplace/:id',
|
|
name: 'marketplace-app-detail',
|
|
component: () => import('../views/MarketplaceAppDetails.vue'),
|
|
},
|
|
{
|
|
path: 'cloud',
|
|
name: 'cloud',
|
|
component: () => import('../views/Cloud.vue'),
|
|
},
|
|
{
|
|
path: 'cloud/peers/:peerId?',
|
|
name: 'peer-files',
|
|
component: () => import('../views/PeerFiles.vue'),
|
|
props: true,
|
|
},
|
|
{
|
|
path: 'cloud/:folderId',
|
|
name: 'cloud-folder',
|
|
component: () => import('../views/CloudFolder.vue'),
|
|
},
|
|
{
|
|
path: 'server',
|
|
name: 'server',
|
|
component: () => import('../views/Server.vue'),
|
|
},
|
|
{
|
|
path: 'monitoring',
|
|
name: 'monitoring',
|
|
component: () => import('../views/Monitoring.vue'),
|
|
},
|
|
{
|
|
path: 'fleet',
|
|
name: 'fleet',
|
|
component: () => import('../views/Fleet.vue'),
|
|
},
|
|
{
|
|
path: 'server/federation',
|
|
name: 'federation',
|
|
component: () => import('../views/Federation.vue'),
|
|
},
|
|
{
|
|
path: 'mesh',
|
|
name: 'mesh',
|
|
component: () => import('../views/Mesh.vue'),
|
|
},
|
|
{
|
|
path: 'web5',
|
|
name: 'web5',
|
|
component: () => import('../views/web5/Web5.vue'),
|
|
},
|
|
{
|
|
path: 'web5/credentials',
|
|
name: 'credentials',
|
|
component: () => import('../views/Credentials.vue'),
|
|
},
|
|
{
|
|
path: 'settings',
|
|
name: 'settings',
|
|
component: () => import('../views/Settings.vue'),
|
|
},
|
|
{
|
|
path: 'settings/update',
|
|
name: 'system-update',
|
|
component: () => import('../views/SystemUpdate.vue'),
|
|
},
|
|
{
|
|
path: 'settings/registries',
|
|
name: 'app-registries',
|
|
component: () => import('../views/AppRegistries.vue'),
|
|
},
|
|
{
|
|
path: 'goals/:goalId',
|
|
name: 'goal-detail',
|
|
component: () => import('../views/GoalDetail.vue'),
|
|
},
|
|
{
|
|
path: 'chat',
|
|
name: 'chat',
|
|
component: () => import('../views/Chat.vue'),
|
|
},
|
|
{
|
|
path: 'app-session/:appId',
|
|
name: 'app-session',
|
|
component: () => import('../views/AppSession.vue'),
|
|
},
|
|
// Containers removed: My Apps serves the same purpose. Redirect old links.
|
|
{
|
|
path: 'containers',
|
|
redirect: () => ({ path: '/dashboard/apps' }),
|
|
},
|
|
{
|
|
path: 'containers/:id',
|
|
redirect: (to) => ({ path: `/dashboard/apps/${to.params.id}` }),
|
|
},
|
|
],
|
|
},
|
|
{
|
|
path: '/:pathMatch(.*)*',
|
|
name: 'not-found',
|
|
component: () => import('../views/NotFound.vue'),
|
|
},
|
|
],
|
|
})
|
|
|
|
// Session check with timeout - avoids endless spinner on mobile/slow networks
|
|
const SESSION_CHECK_TIMEOUT_MS = 8000
|
|
|
|
async function checkSessionWithTimeout(store: ReturnType<typeof useAppStore>): Promise<boolean> {
|
|
try {
|
|
return await Promise.race([
|
|
store.checkSession(),
|
|
new Promise<boolean>((resolve) =>
|
|
setTimeout(() => resolve(false), SESSION_CHECK_TIMEOUT_MS)
|
|
),
|
|
])
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Navigation Guard
|
|
* Handles authentication and onboarding flow routing
|
|
*/
|
|
export function isLocalRedirect(path: unknown): path is string {
|
|
if (typeof path !== 'string') return false
|
|
try {
|
|
if (path.startsWith('//') || path.includes('://')) return false
|
|
const url = new URL(path, window.location.origin)
|
|
return url.origin === window.location.origin
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
router.beforeEach(async (to, _from, next) => {
|
|
const store = useAppStore()
|
|
const isPublic = to.meta.public
|
|
|
|
// Allow all public routes (login, onboarding) without auth check
|
|
if (isPublic) {
|
|
// If authenticated and visiting /login: show login immediately, validate in background.
|
|
// This prevents endless spinner on mobile when checkSession hangs (slow/unreachable network).
|
|
if (to.path === '/login' && store.isAuthenticated) {
|
|
// Redirect back to intended page (from ?redirect= query) or default to home
|
|
const rawRedirect = to.query.redirect
|
|
const redirectTo = isLocalRedirect(rawRedirect) ? rawRedirect : '/dashboard'
|
|
if (store.needsSessionValidation()) {
|
|
next()
|
|
checkSessionWithTimeout(store).then((valid) => {
|
|
if (valid) {
|
|
router.replace(redirectTo).catch(() => {})
|
|
}
|
|
})
|
|
return
|
|
}
|
|
next(redirectTo)
|
|
return
|
|
}
|
|
next()
|
|
return
|
|
}
|
|
|
|
// Protected routes: validate session if stale auth from localStorage
|
|
if (store.needsSessionValidation()) {
|
|
// localStorage says we're authed — proceed immediately, revalidate in background.
|
|
// No timeout wrapper here: a slow server shouldn't bounce the user to login.
|
|
next()
|
|
store.checkSession().then((valid) => {
|
|
if (!valid) {
|
|
router.replace({ path: '/login', query: { redirect: to.fullPath } }).catch(() => {})
|
|
}
|
|
})
|
|
return
|
|
}
|
|
|
|
// Not authenticated at all (with timeout to avoid endless spinner on mobile)
|
|
if (!store.isAuthenticated) {
|
|
const hasSession = await checkSessionWithTimeout(store)
|
|
if (hasSession) {
|
|
next()
|
|
return
|
|
}
|
|
// Check if this is a fresh install that needs onboarding
|
|
try {
|
|
const { isOnboardingComplete, getSavedOnboardingStep } = await import('@/composables/useOnboarding')
|
|
const setupDone = await isOnboardingComplete()
|
|
if (!setupDone) {
|
|
const step = getSavedOnboardingStep()
|
|
next(`/onboarding/${step}`)
|
|
return
|
|
}
|
|
} catch {
|
|
// If we can't check, assume fresh install and show onboarding
|
|
next('/onboarding/intro')
|
|
return
|
|
}
|
|
next({ path: '/login', query: { redirect: to.fullPath } })
|
|
return
|
|
}
|
|
|
|
// Validated and authenticated - ensure WebSocket is connected
|
|
if (!store.isConnected && !store.isReconnecting) {
|
|
store.connectWebSocket().catch((err) => {
|
|
if (import.meta.env.DEV) console.warn('[Router] WebSocket connection failed:', err)
|
|
})
|
|
}
|
|
|
|
next()
|
|
})
|
|
|
|
// Persist onboarding step so page refresh resumes where user left off
|
|
router.afterEach((to) => {
|
|
const match = to.path.match(/^\/onboarding\/(.+)/)
|
|
if (match && match[1] !== 'intro') {
|
|
localStorage.setItem('neode_onboarding_step', match[1]!)
|
|
}
|
|
})
|
|
|
|
// Stop all login/splash audio when entering the dashboard
|
|
router.afterEach((to, from) => {
|
|
if (to.path.startsWith('/dashboard') && !from.path.startsWith('/dashboard')) {
|
|
stopAllAudio()
|
|
}
|
|
})
|
|
|
|
// Focus Home nav item for gamepad when landing on dashboard home (e.g. after login)
|
|
router.afterEach((to) => {
|
|
if (to.path === '/dashboard' || to.path === '/dashboard/') {
|
|
nextTick(() => {
|
|
setTimeout(() => {
|
|
const homeLink = document.querySelector<HTMLAnchorElement>(
|
|
'[data-controller-zone="sidebar"] a[href="/dashboard"], [data-controller-zone="sidebar"] a[href="/dashboard/"]'
|
|
)
|
|
if (homeLink) homeLink.focus()
|
|
}, 150)
|
|
})
|
|
}
|
|
})
|
|
|
|
export default router
|
|
|