Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
624a2418ed |
+3
-3
@@ -7,10 +7,10 @@
|
|||||||
- "Add Service" in the Tor panel now works for every app, not just a fixed list — the node reads the app's actual web port, so apps like Gitea, Jellyfin, Nextcloud, and Uptime Kuma no longer fail with "see server logs".
|
- "Add Service" in the Tor panel now works for every app, not just a fixed list — the node reads the app's actual web port, so apps like Gitea, Jellyfin, Nextcloud, and Uptime Kuma no longer fail with "see server logs".
|
||||||
- Renaming your node now genuinely renames it everywhere: the machine's hostname, its .local network name (re-announced immediately), the local hosts file, and the HTTPS certificate all follow — so http and https links using your node's name keep working right after a rename.
|
- Renaming your node now genuinely renames it everywhere: the machine's hostname, its .local network name (re-announced immediately), the local hosts file, and the HTTPS certificate all follow — so http and https links using your node's name keep working right after a rename.
|
||||||
- The node no longer mistakes a VPN tunnel for its own address. On fresh installs with NetBird, apps could launch on an internal 10.x address instead of your LAN IP; the node now reads its address from the actual network route, fixing app launch links, generated app configs, and VPN setup.
|
- The node no longer mistakes a VPN tunnel for its own address. On fresh installs with NetBird, apps could launch on an internal 10.x address instead of your LAN IP; the node now reads its address from the actual network route, fixing app launch links, generated app configs, and VPN setup.
|
||||||
- Your cloud got a real layout: Apps-style tabs with categories for Folders, My Files, and Peer Files, readable file rows, a search that also finds files shared by your federated peer nodes — and music now opens in the bottom-bar player instead of a broken preview window.
|
- Your cloud got a real layout: Apps-style tabs with categories for Folders, My Files, and Peer Files, readable file rows, and a search that also finds files shared by your federated peer nodes.
|
||||||
- The first-login experience flows again: the dashboard entrance animation is back, "Replay Intro" in Settings actually replays it, and the login screen keeps the intro video until your first login (switching to rotating backgrounds after).
|
- The first-login dashboard entrance animation is back, and "Replay Intro" in Settings actually replays it.
|
||||||
- Changing DNS settings no longer blanks the page, and the DNS and WiFi dialogs now cover the whole app instead of only the right panel.
|
- Changing DNS settings no longer blanks the page, and the DNS and WiFi dialogs now cover the whole app instead of only the right panel.
|
||||||
- The public demo is richer and truer: the intro plays on every fresh visit, Ark wallet flows, working DNS and Tor service management, and a library of peer content with previews that never break.
|
- The public demo is richer and truer: Ark wallet flows, working DNS and Tor service management, and a library of peer content with previews that never break.
|
||||||
- Assorted fixes: failed installs clean up after themselves properly, and the transactions view fits mobile screens (capped at 60% of the visible viewport).
|
- Assorted fixes: failed installs clean up after themselves properly, and the transactions view fits mobile screens (capped at 60% of the visible viewport).
|
||||||
|
|
||||||
## v1.7.100-alpha (2026-07-14)
|
## v1.7.100-alpha (2026-07-14)
|
||||||
|
|||||||
@@ -187,9 +187,8 @@ http {
|
|||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cache static assets (media too — the intro video/audio are versioned
|
# Cache static assets
|
||||||
# with ?v=N query busters, so immutable is safe)
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|mp4|webm|mp3|woff2?)$ {
|
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,261 +0,0 @@
|
|||||||
/**
|
|
||||||
* End-to-end verification of the FIRST-VISIT cinematic:
|
|
||||||
* splash (tap logo → alien typing → Welcome Noderunner + speech + synthwave
|
|
||||||
* → Archipelago logo) → onboarding intro → login (video background, finale
|
|
||||||
* armed) → dashboard full reveal (background zoom + interface assembly +
|
|
||||||
* oomph) → welcome typing — and that a SECOND login stays deliberately
|
|
||||||
* low-key.
|
|
||||||
*
|
|
||||||
* Run against the local demo stack:
|
|
||||||
* DEMO=1 node mock-backend.js (port 5959)
|
|
||||||
* VITE_DEMO=1 npx vite --port 8100
|
|
||||||
* ARCHY_BASE_URL=http://localhost:8100 npx playwright test e2e/intro-experience.spec.ts
|
|
||||||
*
|
|
||||||
* Audio can't be heard headless, so every HTMLMediaElement.play() and
|
|
||||||
* WebAudio oscillator start is recorded into window.__audioLog via an init
|
|
||||||
* script — the assertions check the right cues fired at the right phases.
|
|
||||||
*/
|
|
||||||
import { test, expect, type Page } from '@playwright/test'
|
|
||||||
|
|
||||||
// Real Chrome (new headless): the bundled headless-shell has no working video
|
|
||||||
// pipeline (0 fps, ~80% dropped frames), which makes every media assertion
|
|
||||||
// meaningless there. Requires Google Chrome installed.
|
|
||||||
test.use({ channel: 'chrome' })
|
|
||||||
|
|
||||||
const BASE = process.env.ARCHY_BASE_URL ?? 'http://localhost:8100'
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
__audioLog: string[]
|
|
||||||
__videoStats: { stalls: number; waiting: number; dropped: number; total: number; readyStateAtPlay: number }
|
|
||||||
__revealSeen: { zoom?: boolean; glass?: boolean }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function instrumentAudioAndVideo(page: Page) {
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
window.__audioLog = []
|
|
||||||
window.__videoStats = { stalls: 0, waiting: 0, dropped: 0, total: 0, readyStateAtPlay: -1 }
|
|
||||||
|
|
||||||
// The dashboard reveal classes live only ~8s; a slow run can burn that
|
|
||||||
// window between two sequential expect() polls. Record their appearance
|
|
||||||
// the moment it happens instead, and let the test assert on the record.
|
|
||||||
window.__revealSeen = {}
|
|
||||||
new MutationObserver(() => {
|
|
||||||
if (!window.__revealSeen.zoom && document.querySelector('.zoom-reveal-bg')) window.__revealSeen.zoom = true
|
|
||||||
if (!window.__revealSeen.glass && document.querySelector('.glass-throw-active')) window.__revealSeen.glass = true
|
|
||||||
}).observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] })
|
|
||||||
|
|
||||||
const origPlay = HTMLMediaElement.prototype.play
|
|
||||||
HTMLMediaElement.prototype.play = function (...args) {
|
|
||||||
const src = (this.currentSrc || this.src || (this.querySelector?.('source') as HTMLSourceElement | null)?.src || 'unknown')
|
|
||||||
window.__audioLog.push(`media-play:${src.split('/').pop()}`)
|
|
||||||
if (this.tagName === 'VIDEO') {
|
|
||||||
const v = this as HTMLVideoElement
|
|
||||||
if (window.__videoStats.readyStateAtPlay === -1) window.__videoStats.readyStateAtPlay = v.readyState
|
|
||||||
v.addEventListener('stalled', () => { window.__videoStats.stalls++ })
|
|
||||||
v.addEventListener('waiting', () => { window.__videoStats.waiting++ })
|
|
||||||
}
|
|
||||||
return origPlay.apply(this, args)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebAudio: oscillator/buffer starts = synth pops, oomph layers, synthwave.
|
|
||||||
const OrigOsc = OscillatorNode.prototype.start
|
|
||||||
OscillatorNode.prototype.start = function (...args) {
|
|
||||||
window.__audioLog.push('osc-start')
|
|
||||||
return OrigOsc.apply(this, args)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function freshVisit(page: Page) {
|
|
||||||
await page.goto(BASE + '/')
|
|
||||||
await page.evaluate(() => { localStorage.clear(); sessionStorage.clear() })
|
|
||||||
await page.goto(BASE + '/')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Walk the full splash from tap-to-start through completion (real time). */
|
|
||||||
async function runSplash(page: Page, { skip }: { skip: boolean }) {
|
|
||||||
// Phase 1: tap-to-start — "Enter to Exit" + logo (the overlay animates away
|
|
||||||
// on click, so don't wait for post-click actionability)
|
|
||||||
await expect(page.getByText('Enter to Exit')).toBeVisible({ timeout: 15_000 })
|
|
||||||
await page.locator('.tap-to-start-logo').click({ noWaitAfter: true, force: true })
|
|
||||||
|
|
||||||
// Phase 2: alien typing begins (first line types out)
|
|
||||||
await expect(page.getByText('In the future there will be 3 types', { exact: false }))
|
|
||||||
.toBeVisible({ timeout: 10_000 })
|
|
||||||
|
|
||||||
if (skip) {
|
|
||||||
await page.getByRole('button', { name: 'Skip Intro' }).click({ noWaitAfter: true })
|
|
||||||
} else {
|
|
||||||
// Let all four lines type out for real (~20s)
|
|
||||||
await expect(page.getByText('And Noderunners...', { exact: false })).toBeVisible({ timeout: 45_000 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 3+4 (Welcome Noderunner → logo) mount the background video. The
|
|
||||||
// text is only on screen ~6s, so verify the phases via durable signals:
|
|
||||||
// the video's live health here, and the audio log (speech/song) after.
|
|
||||||
await page.waitForFunction(() => !!document.querySelector('video'), { timeout: 30_000 })
|
|
||||||
// Wait for actual smooth playback (cold-cache buffering right after mount
|
|
||||||
// is masked by the design's 0.3-opacity fade — smoothness is what matters).
|
|
||||||
await page.waitForFunction(() => {
|
|
||||||
const v = document.querySelector('video')
|
|
||||||
return !!v && v.readyState >= 3 && v.currentTime > 0.3
|
|
||||||
}, { timeout: 20_000 })
|
|
||||||
const s1 = await page.evaluate(() => {
|
|
||||||
const v = document.querySelector('video')!
|
|
||||||
const q = v.getVideoPlaybackQuality?.()
|
|
||||||
return { t: v.currentTime, dropped: q?.droppedVideoFrames ?? 0, total: q?.totalVideoFrames ?? 0 }
|
|
||||||
})
|
|
||||||
await page.waitForTimeout(2_000)
|
|
||||||
const s2 = await page.evaluate(() => {
|
|
||||||
const v = document.querySelector('video')
|
|
||||||
if (!v) return null
|
|
||||||
const q = v.getVideoPlaybackQuality?.()
|
|
||||||
return { t: v.currentTime, dropped: q?.droppedVideoFrames ?? 0, total: q?.totalVideoFrames ?? 0 }
|
|
||||||
})
|
|
||||||
expect(s2).not.toBeNull()
|
|
||||||
// The 8.1s video loops; a loop wrap makes (t - t1) negative — treat as full progress.
|
|
||||||
const progressed = s2!.t >= s1.t ? s2!.t - s1.t : s2!.t + (8.1 - s1.t)
|
|
||||||
expect(progressed).toBeGreaterThan(1.2) // ≥1.2s progress in 2s wall = playing smoothly
|
|
||||||
// Steady-state frame drops over the sample window (startup catch-up excluded).
|
|
||||||
const dTotal = s2!.total - s1.total
|
|
||||||
const dDropped = s2!.dropped - s1.dropped
|
|
||||||
if (dTotal > 30) expect(dDropped / dTotal).toBeLessThan(0.2)
|
|
||||||
|
|
||||||
// Splash completes → demo routes to the onboarding intro
|
|
||||||
await page.waitForURL('**/onboarding/intro', { timeout: 60_000 })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enterDemoAndLogin(page: Page) {
|
|
||||||
// The CTA unmounts mid-click when the router transitions away — dispatch
|
|
||||||
// once, swallow the detach retry, and trust the URL change instead.
|
|
||||||
const cta = page.getByRole('button', { name: /Enter the demo/ })
|
|
||||||
await cta.waitFor({ timeout: 15_000 })
|
|
||||||
await Promise.all([
|
|
||||||
page.waitForURL('**/login', { timeout: 15_000 }),
|
|
||||||
cta.click({ noWaitAfter: true }).catch(() => {}),
|
|
||||||
])
|
|
||||||
// Demo prefills the password; the finale flag must be armed at this point.
|
|
||||||
expect(await page.evaluate(() => sessionStorage.getItem('archy_onboarding_finale'))).toBe('1')
|
|
||||||
const loginBtn = page.getByRole('button', { name: /log ?in/i })
|
|
||||||
await loginBtn.waitFor({ timeout: 10_000 })
|
|
||||||
await Promise.all([
|
|
||||||
page.waitForURL('**/dashboard**', { timeout: 25_000 }),
|
|
||||||
loginBtn.click({ noWaitAfter: true }).catch(() => {}),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('first-visit cinematic', () => {
|
|
||||||
test('full no-skip run: sounds, video health, zoom reveal, welcome typing', async ({ page }) => {
|
|
||||||
test.setTimeout(240_000)
|
|
||||||
await instrumentAudioAndVideo(page)
|
|
||||||
await freshVisit(page)
|
|
||||||
|
|
||||||
await runSplash(page, { skip: false })
|
|
||||||
|
|
||||||
// Cinematic audio fired: intro typing loop, the Welcome Noderunner speech
|
|
||||||
// and the synthwave bed (cosmic-updrift) — the exact regression reported.
|
|
||||||
const log = await page.evaluate(() => window.__audioLog.join('|'))
|
|
||||||
expect(log).toContain('welcome-noderunner.mp3')
|
|
||||||
expect(log).toContain('cosmic-updrift.mp3')
|
|
||||||
|
|
||||||
await enterDemoAndLogin(page)
|
|
||||||
|
|
||||||
// FULL first-entry reveal: big background zoom + glass assembly classes
|
|
||||||
// (recorded by the init-script observer the instant they appear — the
|
|
||||||
// classes only live ~8s and sequential polling can miss the window).
|
|
||||||
await expect
|
|
||||||
.poll(() => page.evaluate(() => window.__revealSeen), { timeout: 15_000 })
|
|
||||||
.toMatchObject({ zoom: true, glass: true })
|
|
||||||
|
|
||||||
// Welcome typing kicks in ~4s into the reveal and animates the home cards.
|
|
||||||
await expect(page.locator('.home-card-animate').first()).toBeVisible({ timeout: 15_000 })
|
|
||||||
|
|
||||||
// The reveal runs 8s, then the zoom layer class clears.
|
|
||||||
await expect(page.locator('.zoom-reveal-bg')).toHaveCount(0, { timeout: 20_000 })
|
|
||||||
|
|
||||||
// The dashboard oomph is WebAudio oscillators — at least the login pop +
|
|
||||||
// oomph layers must have started after the splash's own sounds.
|
|
||||||
const oscCount = await page.evaluate(() => window.__audioLog.filter(e => e === 'osc-start').length)
|
|
||||||
expect(oscCount).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('skip-intro run still gets speech, song and the dashboard reveal', async ({ page }) => {
|
|
||||||
test.setTimeout(180_000)
|
|
||||||
await instrumentAudioAndVideo(page)
|
|
||||||
await freshVisit(page)
|
|
||||||
|
|
||||||
await runSplash(page, { skip: true })
|
|
||||||
|
|
||||||
const log = await page.evaluate(() => window.__audioLog.join('|'))
|
|
||||||
expect(log).toContain('welcome-noderunner.mp3')
|
|
||||||
expect(log).toContain('cosmic-updrift.mp3')
|
|
||||||
|
|
||||||
await enterDemoAndLogin(page)
|
|
||||||
await expect
|
|
||||||
.poll(() => page.evaluate(() => window.__revealSeen.zoom), { timeout: 15_000 })
|
|
||||||
.toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('second login is deliberately low-key (no zoom reveal)', async ({ page }) => {
|
|
||||||
test.setTimeout(180_000)
|
|
||||||
await instrumentAudioAndVideo(page)
|
|
||||||
await freshVisit(page)
|
|
||||||
await runSplash(page, { skip: true })
|
|
||||||
await enterDemoAndLogin(page)
|
|
||||||
await expect
|
|
||||||
.poll(() => page.evaluate(() => window.__revealSeen.zoom), { timeout: 15_000 })
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
// End the session the way logout does (auth token only — the intro/login
|
|
||||||
// flags survive) and revisit login directly. An authenticated /login visit
|
|
||||||
// would just bounce back to the dashboard via the router guard.
|
|
||||||
await page.evaluate(() => localStorage.removeItem('neode-auth'))
|
|
||||||
// Direct /login navigation (no splash — not a root boot) and re-login.
|
|
||||||
await page.goto(BASE + '/login')
|
|
||||||
await page.locator('#login-password').waitFor({ timeout: 15_000 })
|
|
||||||
// First login happened → static rotated background, not the video.
|
|
||||||
await expect(page.locator('.bg-login-static')).toBeVisible({ timeout: 10_000 })
|
|
||||||
expect(await page.evaluate(() => localStorage.getItem('neode_first_login_done'))).toBe('1')
|
|
||||||
|
|
||||||
const reloginBtn = page.getByRole('button', { name: /log ?in/i })
|
|
||||||
await Promise.all([
|
|
||||||
page.waitForURL('**/dashboard**', { timeout: 25_000 }),
|
|
||||||
reloginBtn.click({ noWaitAfter: true }).catch(() => {}),
|
|
||||||
])
|
|
||||||
// Low-key entrance: NO zoom reveal on a regular re-login. The observer
|
|
||||||
// reset with the /login reload, so it would have caught even a transient
|
|
||||||
// reveal since then.
|
|
||||||
await page.waitForTimeout(1500)
|
|
||||||
expect(await page.evaluate(() => window.__revealSeen.zoom ?? false)).toBe(false)
|
|
||||||
await expect(page.locator('.zoom-reveal-bg')).toHaveCount(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('demo replays the cinematic on a fresh boot at root', async ({ page }) => {
|
|
||||||
test.setTimeout(120_000)
|
|
||||||
await freshVisit(page)
|
|
||||||
await runSplash(page, { skip: true })
|
|
||||||
await enterDemoAndLogin(page)
|
|
||||||
|
|
||||||
// Reload at root = a fresh boot → the splash must return even though
|
|
||||||
// neode_intro_seen is now set.
|
|
||||||
await page.goto(BASE + '/')
|
|
||||||
await expect(page.getByText('Enter to Exit')).toBeVisible({ timeout: 15_000 })
|
|
||||||
})
|
|
||||||
|
|
||||||
test('video is warmed before the splash needs it', async ({ page }) => {
|
|
||||||
await freshVisit(page)
|
|
||||||
// The warm-up is a detached <video preload=auto> kept on window (Chromium
|
|
||||||
// has no <link rel=preload as=video>). Give it a moment to buffer.
|
|
||||||
await expect
|
|
||||||
.poll(async () => page.evaluate(() => {
|
|
||||||
const w = (window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm
|
|
||||||
return w ? { src: w.src, readyState: w.readyState } : null
|
|
||||||
}), { timeout: 15_000 })
|
|
||||||
.toMatchObject({ src: expect.stringContaining('video-intro.mp4') })
|
|
||||||
const ready = await page.evaluate(() =>
|
|
||||||
(window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm?.readyState ?? 0)
|
|
||||||
expect(ready).toBeGreaterThanOrEqual(1) // metadata in = download underway
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Binary file not shown.
+3
-37
@@ -400,19 +400,14 @@ onMounted(async () => {
|
|||||||
// onboarded node).
|
// onboarded node).
|
||||||
let replayRequested = sessionStorage.getItem('archipelago_replay_intro') === '1'
|
let replayRequested = sessionStorage.getItem('archipelago_replay_intro') === '1'
|
||||||
if (replayRequested) sessionStorage.removeItem('archipelago_replay_intro')
|
if (replayRequested) sessionStorage.removeItem('archipelago_replay_intro')
|
||||||
// The route object still holds the router's START_LOCATION ('/') here —
|
|
||||||
// the initial navigation resolves asynchronously — so a deep-route load
|
|
||||||
// (e.g. a direct /login visit) would masquerade as a root boot. The URL
|
|
||||||
// bar is the only truthful source this early.
|
|
||||||
const bootPath = window.location.pathname
|
|
||||||
// Public demo: every fresh boot at the root (first visit or a browser
|
// Public demo: every fresh boot at the root (first visit or a browser
|
||||||
// refresh) starts with the typing splash for the full effect. In-session SPA
|
// refresh) starts with the typing splash for the full effect. In-session SPA
|
||||||
// navigation never remounts App, and deep-route refreshes keep their place.
|
// navigation never remounts App, and deep-route refreshes keep their place.
|
||||||
const { IS_DEMO } = await import('@/composables/useDemoIntro')
|
const { IS_DEMO } = await import('@/composables/useDemoIntro')
|
||||||
if (IS_DEMO && bootPath === '/') replayRequested = true
|
if (IS_DEMO && route.path === '/') replayRequested = true
|
||||||
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
|
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
|
||||||
const splashCandidate = !seenIntro
|
const splashCandidate = !seenIntro
|
||||||
&& (fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
|
&& (fromBoot || (route.path === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
|
||||||
|
|
||||||
if (splashCandidate && onboardingComplete !== true) {
|
if (splashCandidate && onboardingComplete !== true) {
|
||||||
try {
|
try {
|
||||||
@@ -432,30 +427,12 @@ onMounted(async () => {
|
|||||||
|
|
||||||
if (shouldShowIntroSplash({
|
if (shouldShowIntroSplash({
|
||||||
seenIntro,
|
seenIntro,
|
||||||
routePath: bootPath,
|
routePath: route.path,
|
||||||
fromBoot,
|
fromBoot,
|
||||||
devMode: import.meta.env.VITE_DEV_MODE,
|
devMode: import.meta.env.VITE_DEV_MODE,
|
||||||
onboardingComplete,
|
onboardingComplete,
|
||||||
replayRequested,
|
replayRequested,
|
||||||
})) {
|
})) {
|
||||||
// The intro is definitely playing — unmute the whole cinematic (speech,
|
|
||||||
// synthwave, pops) even on browsers whose localStorage says onboarding is
|
|
||||||
// complete; the sound gate otherwise silences replays.
|
|
||||||
const { enableCinematicSounds } = await import('@/composables/useLoginSounds')
|
|
||||||
enableCinematicSounds()
|
|
||||||
// Kick off the intro video download NOW: the splash's <video> element only
|
|
||||||
// mounts ~20s in (after the typing sequence), and on a cold cache the file
|
|
||||||
// would otherwise start fetching mid-sequence and stutter. A detached
|
|
||||||
// <video preload=auto> is used because Chromium does not support
|
|
||||||
// <link rel=preload as=video> — the media cache then serves the splash's
|
|
||||||
// element, which uses the identical URL.
|
|
||||||
try {
|
|
||||||
const warm = document.createElement('video')
|
|
||||||
warm.muted = true
|
|
||||||
warm.preload = 'auto'
|
|
||||||
warm.src = '/assets/video/video-intro.mp4?v=8'
|
|
||||||
;(window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm = warm
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
// Coming from boot screen — show the full splash intro (Enter to Exit → typing → logo)
|
// Coming from boot screen — show the full splash intro (Enter to Exit → typing → logo)
|
||||||
showSplash.value = true
|
showSplash.value = true
|
||||||
} else {
|
} else {
|
||||||
@@ -528,17 +505,6 @@ async function handleSplashComplete() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Demo: the cinematic always continues into the onboarding intro page —
|
|
||||||
// the mock backend reports "onboarded", which would otherwise route
|
|
||||||
// straight to /login and cut the sequence short.
|
|
||||||
{
|
|
||||||
const { IS_DEMO } = await import('@/composables/useDemoIntro')
|
|
||||||
if (IS_DEMO) {
|
|
||||||
router.push('/onboarding/intro').catch(() => {})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
|
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
|
||||||
const seenOnboarding = await checkOnboardingStatus()
|
const seenOnboarding = await checkOnboardingStatus()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
preload="auto"
|
preload="auto"
|
||||||
poster="/assets/img/bg-intro.jpg"
|
poster="/assets/img/bg-intro.jpg"
|
||||||
>
|
>
|
||||||
<source src="/assets/video/video-intro.mp4?v=8" type="video/mp4">
|
<source src="/assets/video/video-intro.mp4?v=7" type="video/mp4">
|
||||||
<!-- Fallback to image if video fails -->
|
<!-- Fallback to image if video fails -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0"
|
class="absolute inset-0"
|
||||||
@@ -266,12 +266,16 @@ watch([showWelcome, showLogo], ([welcome, logo]) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// NOTE: whether the intro should play is decided ONCE by App.vue
|
// Check if user has seen intro
|
||||||
// (shouldShowIntroSplash) — this component is only mounted when the answer was
|
// Also detect returning users who cleared cache: if we're on a /dashboard route,
|
||||||
// yes, so it must not re-check neode_intro_seen itself. The old internal
|
// the backend session is still active so the user has been here before.
|
||||||
// re-check silently skipped the whole sequence for any browser that had seen
|
const storedSeenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||||
// the intro before, even when App.vue explicitly asked for a replay (demo
|
const isOnDashboard = window.location.pathname.startsWith('/dashboard')
|
||||||
// fresh visits, the Replay Intro link).
|
const seenIntro = storedSeenIntro || isOnDashboard
|
||||||
|
// Persist the flag so subsequent loads don't re-check
|
||||||
|
if (!storedSeenIntro && isOnDashboard) {
|
||||||
|
localStorage.setItem('neode_intro_seen', '1')
|
||||||
|
}
|
||||||
|
|
||||||
function handleEnterKey(e: KeyboardEvent) {
|
function handleEnterKey(e: KeyboardEvent) {
|
||||||
if (e.key === 'Enter' && showTapToStart.value && !tapStartTransitioning.value) {
|
if (e.key === 'Enter' && showTapToStart.value && !tapStartTransitioning.value) {
|
||||||
@@ -464,7 +468,13 @@ function startAlienIntro() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
if (seenIntro) {
|
||||||
|
showSplash.value = false
|
||||||
|
document.body.classList.add('splash-complete')
|
||||||
|
emit('complete')
|
||||||
|
} else {
|
||||||
window.addEventListener('keydown', handleEnterKey)
|
window.addEventListener('keydown', handleEnterKey)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
@@ -8,27 +8,14 @@
|
|||||||
* exempt and continue to play regardless.
|
* exempt and continue to play regardless.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** One-way, per-page-load override: when App.vue decides to run the intro
|
|
||||||
* splash (demo fresh boot, prod first install, Replay Intro), the WHOLE
|
|
||||||
* cinematic — speech, synthwave, pops — must be audible even though
|
|
||||||
* localStorage already says onboarding is complete. Without this the
|
|
||||||
* replayed intro runs silent (lost "Welcome Noderunner" + song). */
|
|
||||||
let cinematicMode = false
|
|
||||||
export function enableCinematicSounds() {
|
|
||||||
cinematicMode = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the node has not yet completed onboarding — i.e. we're
|
/** True when the node has not yet completed onboarding — i.e. we're
|
||||||
* still in the first-install cinematic — or when the intro is being
|
* still in the first-install cinematic. Reads the localStorage cache
|
||||||
* deliberately replayed this page load (see enableCinematicSounds).
|
* set by useOnboarding (which is re-seeded from the backend on each
|
||||||
* Reads the localStorage cache set by useOnboarding (which is
|
* successful check), so this stays correct after a browser clear
|
||||||
* re-seeded from the backend on each successful check), so this stays
|
* once the onboarding-complete probe runs. Sound calls that fire
|
||||||
* correct after a browser clear once the onboarding-complete probe
|
* before that probe completes will fall through silent on an already-
|
||||||
* runs. Sound calls that fire before that probe completes will fall
|
* onboarded node — which is exactly what we want. */
|
||||||
* through silent on an already-onboarded node — which is exactly what
|
|
||||||
* we want: ordinary re-logins stay quiet. */
|
|
||||||
function isFirstInstallPhase(): boolean {
|
function isFirstInstallPhase(): boolean {
|
||||||
if (cinematicMode) return true
|
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem('neode_onboarding_complete') !== '1'
|
return localStorage.getItem('neode_onboarding_complete') !== '1'
|
||||||
} catch {
|
} catch {
|
||||||
@@ -273,10 +260,7 @@ export function playKeyboardTypingSound() {
|
|||||||
*/
|
*/
|
||||||
export function playDashboardLoadOomph(force = false) {
|
export function playDashboardLoadOomph(force = false) {
|
||||||
if (!force && !isFirstInstallPhase()) return
|
if (!force && !isFirstInstallPhase()) return
|
||||||
// The login→dashboard route change runs stopAllAudio(), which CLOSES the
|
const ctx = getContext()
|
||||||
// AudioContext — so the context must be recreated here, not just fetched.
|
|
||||||
// The login click's sticky user activation lets the fresh context run.
|
|
||||||
const ctx = ensureContext()
|
|
||||||
if (!ctx) return
|
if (!ctx) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -426,8 +426,6 @@ async function handleSetup() {
|
|||||||
playLoginSuccessWhoosh()
|
playLoginSuccessWhoosh()
|
||||||
loginTransition.setJustCompletedOnboarding(true)
|
loginTransition.setJustCompletedOnboarding(true)
|
||||||
loginTransition.setJustLoggedIn(true)
|
loginTransition.setJustLoggedIn(true)
|
||||||
// First password setup counts as the first login (video → static rotation).
|
|
||||||
try { localStorage.setItem('neode_first_login_done', '1') } catch { /* ignore */ }
|
|
||||||
await new Promise(r => setTimeout(r, 520))
|
await new Promise(r => setTimeout(r, 520))
|
||||||
await router.replace(loginRedirectTo.value).catch(() => {
|
await router.replace(loginRedirectTo.value).catch(() => {
|
||||||
window.location.href = loginRedirectTo.value
|
window.location.href = loginRedirectTo.value
|
||||||
@@ -540,17 +538,13 @@ async function handleTotpVerify() {
|
|||||||
/** A login right after the onboarding wizard (flag set by OnboardingDone)
|
/** A login right after the onboarding wizard (flag set by OnboardingDone)
|
||||||
* gets the FULL dashboard entrance — zoom + oomph — even though it's a
|
* gets the FULL dashboard entrance — zoom + oomph — even though it's a
|
||||||
* regular password login (e.g. the demo). Subsequent logins stay
|
* regular password login (e.g. the demo). Subsequent logins stay
|
||||||
* deliberately low-key (justLoggedIn only).
|
* deliberately low-key (justLoggedIn only). */
|
||||||
* Also stamps neode_first_login_done: the login page keeps the intro VIDEO
|
|
||||||
* background until someone has logged in once (OnboardingWrapper reads it
|
|
||||||
* to pick video vs the rotating static backgrounds). */
|
|
||||||
function consumeOnboardingFinale() {
|
function consumeOnboardingFinale() {
|
||||||
try {
|
try {
|
||||||
if (sessionStorage.getItem('archy_onboarding_finale') === '1') {
|
if (sessionStorage.getItem('archy_onboarding_finale') === '1') {
|
||||||
sessionStorage.removeItem('archy_onboarding_finale')
|
sessionStorage.removeItem('archy_onboarding_finale')
|
||||||
loginTransition.setJustCompletedOnboarding(true)
|
loginTransition.setJustCompletedOnboarding(true)
|
||||||
}
|
}
|
||||||
localStorage.setItem('neode_first_login_done', '1')
|
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,22 +69,12 @@ onMounted(() => {
|
|||||||
}, 2100)
|
}, 2100)
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Any exit from the intro INTO login is the end of the cinematic — hand the
|
|
||||||
* login the one-shot finale flag so the dashboard plays its full first-entry
|
|
||||||
* reveal (zoom + oomph). OnboardingDone sets the same flag at the end of the
|
|
||||||
* full wizard; these are the shortcut exits that used to skip it (which is
|
|
||||||
* why the demo never got the big entrance). */
|
|
||||||
function armOnboardingFinale() {
|
|
||||||
try { sessionStorage.setItem('archy_onboarding_finale', '1') } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToOptions() {
|
function goToOptions() {
|
||||||
playNavSound('action')
|
playNavSound('action')
|
||||||
// Demo: skip the onboarding wizard (seed/identity setup) entirely — go straight
|
// Demo: skip the onboarding wizard (seed/identity setup) entirely — go straight
|
||||||
// to login, which is prefilled with the demo password.
|
// to login, which is prefilled with the demo password.
|
||||||
if (isDemo) {
|
if (isDemo) {
|
||||||
localStorage.setItem('neode_onboarding_complete', '1')
|
localStorage.setItem('neode_onboarding_complete', '1')
|
||||||
armOnboardingFinale()
|
|
||||||
router.push('/login').catch(() => {})
|
router.push('/login').catch(() => {})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -99,7 +89,6 @@ function goToRestore() {
|
|||||||
function goToLogin() {
|
function goToLogin() {
|
||||||
playNavSound('action')
|
playNavSound('action')
|
||||||
localStorage.setItem('neode_onboarding_complete', '1')
|
localStorage.setItem('neode_onboarding_complete', '1')
|
||||||
armOnboardingFinale()
|
|
||||||
router.push('/login').catch(() => {})
|
router.push('/login').catch(() => {})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
@pause.prevent="handleVideoPause"
|
@pause.prevent="handleVideoPause"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
>
|
>
|
||||||
<source src="/assets/video/video-intro.mp4?v=8" type="video/mp4">
|
<source src="/assets/video/video-intro.mp4?v=7" type="video/mp4">
|
||||||
</video>
|
</video>
|
||||||
|
|
||||||
<!-- Login: static background + archipelago-style glitch (no zoom) -->
|
<!-- Login: static background + archipelago-style glitch (no zoom) -->
|
||||||
@@ -86,22 +86,22 @@ const videoBackgroundRoutes = ['/onboarding/intro', '/login']
|
|||||||
// Login uses video when coming from splash, or static + glitch when direct
|
// Login uses video when coming from splash, or static + glitch when direct
|
||||||
const isLoginRoute = computed(() => route.path === '/login')
|
const isLoginRoute = computed(() => route.path === '/login')
|
||||||
|
|
||||||
|
// True once onboarding is complete. Used to skip the intro video on
|
||||||
// Check if current route should use video background.
|
// the /login route so that returning (logged-out) users go straight
|
||||||
// The FIRST login (nobody has logged in / set a password yet) keeps the VIDEO
|
// to the screensaver-style static + glitch background instead of
|
||||||
// running so the splash → login handoff is one continuous shot; only after a
|
// replaying the full intro every time.
|
||||||
// successful login does /login switch to the rotating static backgrounds.
|
const onboardingDone = computed(() => {
|
||||||
// Login.vue sets neode_first_login_done on every successful login.
|
|
||||||
const hasLoggedInBefore = () => {
|
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem('neode_first_login_done') === '1'
|
return localStorage.getItem('neode_onboarding_complete') === '1'
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
|
// Check if current route should use video background
|
||||||
const useVideoBackground = computed(() => {
|
const useVideoBackground = computed(() => {
|
||||||
if (!videoBackgroundRoutes.includes(route.path)) return false
|
if (!videoBackgroundRoutes.includes(route.path)) return false
|
||||||
if (route.path === '/login' && hasLoggedInBefore()) return false
|
if (route.path === '/login' && onboardingDone.value) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -122,12 +122,9 @@ const routeBackgrounds: Record<string, string> = {
|
|||||||
'/login': 'bg-intro.jpg' // Video loops from splash (same as intro)
|
'/login': 'bg-intro.jpg' // Video loops from splash (same as intro)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Until the user has logged in (or set their password) once, the login
|
// Rotate the login background so the lock screen doesn't look
|
||||||
// background stays PINNED to the same still the intro video ends on — the
|
// identical on every logout. Cycles through bg-intro-1..6 using a
|
||||||
// video → login handoff only reads as one continuous shot when both show the
|
// counter persisted to localStorage so subsequent visits advance.
|
||||||
// identical frame. From the second login onward the lock screen rotates
|
|
||||||
// through bg-intro-1..6 for variety (counter persisted in localStorage).
|
|
||||||
// Login.vue sets neode_first_login_done on every successful login.
|
|
||||||
const LOGIN_BACKGROUNDS = [
|
const LOGIN_BACKGROUNDS = [
|
||||||
'bg-intro-1.webp',
|
'bg-intro-1.webp',
|
||||||
'bg-intro-2.jpg',
|
'bg-intro-2.jpg',
|
||||||
@@ -138,14 +135,13 @@ const LOGIN_BACKGROUNDS = [
|
|||||||
]
|
]
|
||||||
function pickNextLoginBackground(): string {
|
function pickNextLoginBackground(): string {
|
||||||
try {
|
try {
|
||||||
if (localStorage.getItem('neode_first_login_done') !== '1') return 'bg-intro.jpg'
|
|
||||||
const raw = localStorage.getItem('neode_login_bg_idx')
|
const raw = localStorage.getItem('neode_login_bg_idx')
|
||||||
const prev = raw !== null ? parseInt(raw, 10) : -1
|
const prev = raw !== null ? parseInt(raw, 10) : -1
|
||||||
const next = (Number.isFinite(prev) ? prev + 1 : 0) % LOGIN_BACKGROUNDS.length
|
const next = (Number.isFinite(prev) ? prev + 1 : 0) % LOGIN_BACKGROUNDS.length
|
||||||
localStorage.setItem('neode_login_bg_idx', String(next))
|
localStorage.setItem('neode_login_bg_idx', String(next))
|
||||||
return LOGIN_BACKGROUNDS[next]!
|
return LOGIN_BACKGROUNDS[next]!
|
||||||
} catch {
|
} catch {
|
||||||
return 'bg-intro.jpg'
|
return LOGIN_BACKGROUNDS[Math.floor(Math.random() * LOGIN_BACKGROUNDS.length)]!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const loginBackground = ref(pickNextLoginBackground())
|
const loginBackground = ref(pickNextLoginBackground())
|
||||||
|
|||||||
@@ -374,10 +374,10 @@ init()
|
|||||||
<p>"Add Service" in the Tor panel now works for every app, not just a fixed list — the node reads the app's actual web port, so apps like Gitea, Jellyfin, Nextcloud, and Uptime Kuma no longer fail with "see server logs".</p>
|
<p>"Add Service" in the Tor panel now works for every app, not just a fixed list — the node reads the app's actual web port, so apps like Gitea, Jellyfin, Nextcloud, and Uptime Kuma no longer fail with "see server logs".</p>
|
||||||
<p>Renaming your node now genuinely renames it everywhere: the machine's hostname, its .local network name (re-announced immediately), the local hosts file, and the HTTPS certificate all follow — so http and https links using your node's name keep working right after a rename.</p>
|
<p>Renaming your node now genuinely renames it everywhere: the machine's hostname, its .local network name (re-announced immediately), the local hosts file, and the HTTPS certificate all follow — so http and https links using your node's name keep working right after a rename.</p>
|
||||||
<p>The node no longer mistakes a VPN tunnel for its own address. On fresh installs with NetBird, apps could launch on an internal 10.x address instead of your LAN IP; the node now reads its address from the actual network route, fixing app launch links, generated app configs, and VPN setup.</p>
|
<p>The node no longer mistakes a VPN tunnel for its own address. On fresh installs with NetBird, apps could launch on an internal 10.x address instead of your LAN IP; the node now reads its address from the actual network route, fixing app launch links, generated app configs, and VPN setup.</p>
|
||||||
<p>Your cloud got a real layout: Apps-style tabs with categories for Folders, My Files, and Peer Files, readable file rows, a search that also finds files shared by your federated peer nodes — and music now opens in the bottom-bar player instead of a broken preview window.</p>
|
<p>Your cloud got a real layout: Apps-style tabs with categories for Folders, My Files, and Peer Files, readable file rows, and a search that also finds files shared by your federated peer nodes.</p>
|
||||||
<p>The first-login experience flows again: the dashboard entrance animation is back, "Replay Intro" in Settings actually replays it, and the login screen keeps the intro video until your first login (switching to rotating backgrounds after).</p>
|
<p>The first-login dashboard entrance animation is back, and "Replay Intro" in Settings actually replays it.</p>
|
||||||
<p>Changing DNS settings no longer blanks the page, and the DNS and WiFi dialogs now cover the whole app instead of only the right panel.</p>
|
<p>Changing DNS settings no longer blanks the page, and the DNS and WiFi dialogs now cover the whole app instead of only the right panel.</p>
|
||||||
<p>The public demo is richer and truer: the intro plays on every fresh visit, Ark wallet flows, working DNS and Tor service management, and a library of peer content with previews that never break.</p>
|
<p>The public demo is richer and truer: Ark wallet flows, working DNS and Tor service management, and a library of peer content with previews that never break.</p>
|
||||||
<p>Assorted fixes: failed installs clean up after themselves properly, and the transactions view fits mobile screens (capped at 60% of the visible viewport).</p>
|
<p>Assorted fixes: failed installs clean up after themselves properly, and the transactions view fits mobile screens (capped at 60% of the visible viewport).</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user