From 20edd31abbd0dd1bce6ca9ceaadc814c94dfcf57 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 20 Jul 2026 06:07:28 +0100 Subject: [PATCH 1/6] fix(ui): play the intro on backend-confirmed fresh nodes despite stale browser flags neode_intro_seen / neode_onboarding_complete are per-origin browser state: after a reinstall (or another node on a DHCP-recycled IP) they describe the previous node and muted a genuinely fresh install's intro. Root boots now always ask the backend; a confirmed-fresh answer plays the intro and clears the stale flag. Co-Authored-By: Claude Fable 5 --- neode-ui/src/App.vue | 19 ++++++-- .../src/utils/__tests__/introSplash.test.ts | 46 +++++++++++++++++++ neode-ui/src/utils/introSplash.ts | 11 ++++- 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index db124e89..2f2aaa17 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -424,10 +424,14 @@ onMounted(async () => { const { IS_DEMO } = await import('@/composables/useDemoIntro') if (IS_DEMO && bootPath === '/') replayRequested = true let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null - const splashCandidate = !seenIntro - && (fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot')) + // Root boots always ask the backend — even when this browser thinks it has + // seen the intro. Both `neode_intro_seen` and `neode_onboarding_complete` + // are per-origin browser state: after a reinstall (or another node coming + // up on a DHCP-recycled IP) they describe the PREVIOUS node and would mute + // a fresh install's intro / misroute it to login. + const splashCandidate = fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot') - if (splashCandidate && onboardingComplete !== true) { + if (splashCandidate) { try { const { checkOnboardingStatus } = await import('@/composables/useOnboarding') // Bound the pre-splash status check: its retry ladder can spend ~30s @@ -437,10 +441,17 @@ onMounted(async () => { // the splash play (a fresh install IS the slow-backend case; onboarded // nodes answer in milliseconds, so their suppression path is intact). // handleSplashComplete re-checks with full retries after the intro. - onboardingComplete = await Promise.race([ + const live = await Promise.race([ checkOnboardingStatus(), new Promise((resolve) => setTimeout(() => resolve(null), 2500)), ]) + if (live !== null) onboardingComplete = live + if (live === false && seenIntro) { + // Backend-confirmed fresh node behind a browser with a stale flag — + // drop it so this boot (and every later one) plays the intro. + try { localStorage.removeItem('neode_intro_seen') } catch { /* noop */ } + seenIntro = false + } } catch { onboardingComplete = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null } diff --git a/neode-ui/src/utils/__tests__/introSplash.test.ts b/neode-ui/src/utils/__tests__/introSplash.test.ts index bad1d133..c454f822 100644 --- a/neode-ui/src/utils/__tests__/introSplash.test.ts +++ b/neode-ui/src/utils/__tests__/introSplash.test.ts @@ -38,4 +38,50 @@ describe('shouldShowIntroSplash', () => { replayRequested: true, })).toBe(true) }) + + it('a confirmed-fresh node plays the intro despite a stale per-origin seenIntro flag (reinstall / DHCP-recycled IP)', () => { + expect(shouldShowIntroSplash({ + seenIntro: true, + routePath: '/', + fromBoot: false, + onboardingComplete: false, + })).toBe(true) + }) + + it('a confirmed-fresh node plays the intro on the boot-screen handoff too', () => { + expect(shouldShowIntroSplash({ + seenIntro: true, + routePath: '/login', + fromBoot: true, + onboardingComplete: false, + })).toBe(true) + }) + + it('stale seenIntro still suppresses when the backend answer is unknown', () => { + expect(shouldShowIntroSplash({ + seenIntro: true, + routePath: '/', + fromBoot: false, + onboardingComplete: null, + })).toBe(false) + }) + + it('fresh node on a deep route without boot handoff stays suppressed', () => { + expect(shouldShowIntroSplash({ + seenIntro: false, + routePath: '/onboarding/seed', + fromBoot: false, + onboardingComplete: false, + })).toBe(false) + }) + + it('boot dev mode never root-boots into the intro', () => { + expect(shouldShowIntroSplash({ + seenIntro: false, + routePath: '/', + fromBoot: false, + devMode: 'boot', + onboardingComplete: false, + })).toBe(false) + }) }) diff --git a/neode-ui/src/utils/introSplash.ts b/neode-ui/src/utils/introSplash.ts index 5b116de1..dc14ef05 100644 --- a/neode-ui/src/utils/introSplash.ts +++ b/neode-ui/src/utils/introSplash.ts @@ -10,10 +10,19 @@ export interface IntroSplashDecisionInput { export function shouldShowIntroSplash(input: IntroSplashDecisionInput): boolean { if (input.replayRequested) return true + + const isDirectRoute = input.routePath !== '/' + // A node the backend CONFIRMS has never completed onboarding always gets + // the full intro on a root boot. `seenIntro` is per-origin browser state — + // after a reinstall (or a DHCP-recycled IP), the browser still carries the + // previous node's flag at the same origin, which silently muted the intro + // on genuinely fresh installs. + if (input.onboardingComplete === false && (input.fromBoot || (!isDirectRoute && input.devMode !== 'boot'))) { + return true + } if (input.seenIntro) return false if (input.onboardingComplete === true) return false - const isDirectRoute = input.routePath !== '/' if (input.fromBoot) return true if (input.devMode === 'boot') return false return !isDirectRoute From aad6faa6d2df9902bfd9ef493acda12acd36816c Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 20 Jul 2026 06:07:35 +0100 Subject: [PATCH 2/6] fix(ui): harden companion-overlay WireGuard provisioning and hide it in the companion app - Never show the get-the-app pitch inside the companion WebView itself - Don't guess peer-exists when list-peers is unreachable: try create, fall back to peer-config on already-exists errors - Translate raw 'Failed to fetch' into an actionable network hint and add a Try again button instead of a dead-end error Co-Authored-By: Claude Fable 5 --- .../src/components/CompanionIntroOverlay.vue | 58 ++++++++++++++++--- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/neode-ui/src/components/CompanionIntroOverlay.vue b/neode-ui/src/components/CompanionIntroOverlay.vue index f4ac9019..5461fe39 100644 --- a/neode-ui/src/components/CompanionIntroOverlay.vue +++ b/neode-ui/src/components/CompanionIntroOverlay.vue @@ -161,6 +161,14 @@

{{ wgError }}

+ @@ -318,7 +326,15 @@ const POST_INTRO_GRACE_MS = 2000 let calmTicker: ReturnType | null = null +// Running inside the companion app's own WebView (it injects this JS bridge). +// The "get the companion app" pitch is nonsense there — the user is already in +// it — and the WG steps mid-pairing race the phone's changing network (the +// "Failed to fetch" dead-end QR). Server/tunnel management for connected +// companions lives in the NESMenu instead. +const IN_COMPANION_APP = typeof (window as { ArchipelagoNative?: unknown }).ArchipelagoNative !== 'undefined' + onMounted(() => { + if (IN_COMPANION_APP) return try { if (localStorage.getItem(STORAGE_KEY) !== '1') { setTimeout(maybeShow, BASE_DELAY_MS) @@ -481,12 +497,28 @@ async function loadWgPeer() { try { const listed = await rpcClient .call<{ peers: { name: string }[] }>({ method: 'vpn.list-peers' }) - .catch(() => ({ peers: [] as { name: string }[] })) - const exists = (listed.peers || []).some((p) => p.name === WG_PEER_NAME) - const res = await rpcClient.call<{ config: string; peer_ip: string }>({ - method: exists ? 'vpn.peer-config' : 'vpn.create-peer', - params: { name: WG_PEER_NAME }, - }) + .catch(() => null) + // list-peers unreachable → don't guess "doesn't exist": create-peer on an + // existing name would fail. Try create first, fall back to peer-config. + const exists = listed === null + ? null + : (listed.peers || []).some((p) => p.name === WG_PEER_NAME) + let res: { config: string; peer_ip: string } + if (exists === true) { + res = await rpcClient.call({ method: 'vpn.peer-config', params: { name: WG_PEER_NAME } }) + } else { + try { + res = await rpcClient.call({ method: 'vpn.create-peer', params: { name: WG_PEER_NAME } }) + } catch (e) { + // Peer already provisioned on a previous visit (list failed or raced). + const msg = e instanceof Error ? e.message : '' + if (/exist|duplicate/i.test(msg)) { + res = await rpcClient.call({ method: 'vpn.peer-config', params: { name: WG_PEER_NAME } }) + } else { + throw e + } + } + } wgConfig.value = res.config wgQrDataUrl.value = await QRCode.toDataURL(res.config, { width: 512, @@ -498,12 +530,24 @@ async function loadWgPeer() { }, }) } catch (e) { - wgError.value = e instanceof Error ? e.message : 'Failed to generate the tunnel config' + // fetch()'s raw "Failed to fetch" means the node itself was unreachable — + // on this screen that's usually the phone's network mid-change (WiFi drop, + // or a half-configured tunnel already routing 10.44.0.0/16). Say so, and + // leave a Retry path instead of a dead end. + const raw = e instanceof Error ? e.message : '' + wgError.value = /failed to fetch|networkerror|load failed|abort/i.test(raw) + ? "Can't reach your node. Check the phone is on the same network as the node (and any half-set-up tunnel is switched off), then tap Try again." + : raw || 'Failed to generate the tunnel config' } finally { wgLoading.value = false } } +function retryWgPeer() { + wgError.value = '' + void loadWgPeer() +} + // Same-device path: hand the config to the WireGuard app as an importable // .conf file (a phone can't scan its own screen). function downloadWgConfig() { From e5f8b5d789e0fadac1f033c80a0a6ecfb15aeb1f Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 20 Jul 2026 06:07:42 +0100 Subject: [PATCH 3/6] fix(ui): keep kiosk-mode selectors fully inside :global() (v1.7.104 white screen) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With :global(html.kiosk-mode) .bg-layer the SFC compiler drops the descendant part and emits bare html.kiosk-mode rules — including display: none !important — blanking the whole document on kiosk. Co-Authored-By: Claude Fable 5 --- neode-ui/src/views/OnboardingWrapper.vue | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/neode-ui/src/views/OnboardingWrapper.vue b/neode-ui/src/views/OnboardingWrapper.vue index fce979de..58c14fef 100644 --- a/neode-ui/src/views/OnboardingWrapper.vue +++ b/neode-ui/src/views/OnboardingWrapper.vue @@ -691,20 +691,24 @@ video.bg-layer { why the kiosk login/onboarding background still went black. Keep 2D opacity crossfades; drop 3D transforms, blur filters, and blend-mode glitch overlays. */ -:global(html.kiosk-mode) .bg-perspective-container, -:global(html.kiosk-mode) .perspective-container { +/* The full selector must live inside :global() — with `:global(html.kiosk-mode) + .bg-layer` the SFC compiler drops the descendant part, emitting bare + `html.kiosk-mode { display: none !important }` rules that blank the whole + document on kiosk (the v1.7.104 white-screen). */ +:global(html.kiosk-mode .bg-perspective-container), +:global(html.kiosk-mode .perspective-container) { perspective: none !important; } -:global(html.kiosk-mode) .bg-layer, -:global(html.kiosk-mode) .view-wrapper { +:global(html.kiosk-mode .bg-layer), +:global(html.kiosk-mode .view-wrapper) { transform: none !important; transform-style: flat !important; backface-visibility: visible !important; will-change: auto !important; filter: none !important; } -:global(html.kiosk-mode) .login-glitch-layer, -:global(html.kiosk-mode) .login-glitch-scan { +:global(html.kiosk-mode .login-glitch-layer), +:global(html.kiosk-mode .login-glitch-scan) { display: none !important; } From cf4a8eef0ea6a50c6f61a6576ff0f26fd1e94d87 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 20 Jul 2026 06:07:56 +0100 Subject: [PATCH 4/6] fix(core): throttle volume-ownership sweep to first-seen + hourly per container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep podman-execs into every running container; doing that on each 30s reconcile tick was a permanent conmon 'Failed to create container' storm on hosts where exec from the backend's cgroup context fails (Debian 13 first boot). Ownership drift is an install/OTA-time event — sweep on first pass after a container appears, then at most hourly. Co-Authored-By: Claude Fable 5 --- .../src/container/prod_orchestrator.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 269a38e5..d3e0e23a 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -301,6 +301,33 @@ fn unrepairable_ownership() -> &'static std::sync::Mutex bool { + const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60 * 60); + static LAST: std::sync::OnceLock< + std::sync::Mutex>, + > = std::sync::OnceLock::new(); + let map = LAST.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())); + let Ok(mut map) = map.lock() else { + return true; + }; + let now = std::time::Instant::now(); + match map.get(name) { + Some(last) if now.duration_since(*last) < SWEEP_INTERVAL => false, + _ => { + map.insert(name.to_string(), now); + true + } + } +} + /// App-agnostic, userns-mapping-proof volume-ownership repair for a RUNNING /// container. /// @@ -1739,6 +1766,11 @@ impl ProdContainerOrchestrator { if crate::app_ops::lifecycle_op_in_flight(&c.name) { continue; } + // Throttled: first pass after the container appears, then + // hourly — not on every 30s tick (see ownership_sweep_due). + if !ownership_sweep_due(&c.name) { + continue; + } if ensure_running_container_ownership(&c.name).await { tracing::info!(container = %c.name, "volume ownership repaired during reconcile — restarting to recover"); let _ = tokio::process::Command::new("podman") From ae12ff25174a0cbf2ded5c4e66000bdb06db88f7 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 20 Jul 2026 06:08:04 +0100 Subject: [PATCH 5/6] fix(iso): fail builds on missing VPN binaries, skip units cleanly, invalidate stale rootfs cache v1.7.104 shipped ISOs whose units crash-looped (nostr-relay 3s loop, archipelago-diag 203/EXEC) because build-time extraction failures were mere warnings and the cached rootfs never tracked its recipe: - hash the rootfs-defining region of the build script and rebuild when it changes (stale cache shipped ISOs without wpasupplicant/iw/rfkill) - refuse to build without nvpn / nostr-rs-relay unless ALLOW_MISSING_VPN_BINARIES=1 - ConditionPathExists on nostr-relay/nostr-vpn/diag units so a stripped image skips them instead of crash-looping - post-install test: every enabled archipelago*/nostr* unit must have an existing ExecStart payload Co-Authored-By: Claude Fable 5 --- .../_archived/build-auto-installer-iso.sh | 36 ++++++++++++++++++- image-recipe/configs/nostr-relay.service | 3 ++ image-recipe/configs/nostr-vpn.service | 3 ++ scripts/run-post-install-tests.sh | 17 +++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index bdcd18ed..cac6683a 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -254,8 +254,17 @@ container_pull() { echo "📦 Step 1: Building root filesystem..." ROOTFS_TAR="$WORK_DIR/archipelago-rootfs.tar" +ROOTFS_STAMP="$WORK_DIR/archipelago-rootfs.recipe.sha256" -if [ ! -f "$ROOTFS_TAR" ] || [ "$1" == "--rebuild" ]; then +# The cached rootfs must be invalidated when its recipe changes: a stale +# archipelago-rootfs.tar on the build machine shipped ISOs with NO +# wpasupplicant/iw/rfkill (WiFi dead on laptops) long after those packages +# were added to the Dockerfile below — the cache condition never looked at +# the recipe. Hash the rootfs-defining region of this script; any edit to it +# forces a rebuild. `--rebuild` still forces one unconditionally. +RECIPE_HASH=$(sed -n '/^# STEP 1: Build complete root filesystem/,/^# STEP 2: Build minimal installer/p' "$0" | sha256sum | cut -d' ' -f1) + +if [ ! -f "$ROOTFS_TAR" ] || [ "${1:-}" == "--rebuild" ] || [ "$(cat "$ROOTFS_STAMP" 2>/dev/null)" != "$RECIPE_HASH" ]; then echo " Using Docker to create Debian root filesystem..." # Create a Dockerfile for building the rootfs @@ -694,6 +703,7 @@ SYSTEMDSERVICE $CONTAINER_CMD export archipelago-rootfs-tmp > "$ROOTFS_TAR" $CONTAINER_CMD rm archipelago-rootfs-tmp + echo "$RECIPE_HASH" > "$ROOTFS_STAMP" echo "✅ Root filesystem created: $(du -h "$ROOTFS_TAR" | cut -f1)" else echo "✅ Using cached root filesystem: $(du -h "$ROOTFS_TAR" | cut -f1)" @@ -1218,6 +1228,23 @@ else echo " ⚠ nostr-rs-relay image not available — relay binary will be missing" fi +# A missing nvpn/nostr-rs-relay used to be a warning, and the resulting ISO +# shipped units that crash-looped (or silently lacked VPN signaling) on every +# install. Refuse to produce that ISO unless explicitly overridden. +MISSING_VPN_BINARIES="" +[ -f "$ARCH_DIR/bin/nvpn" ] || MISSING_VPN_BINARIES="$MISSING_VPN_BINARIES nvpn" +[ -f "$ARCH_DIR/bin/nostr-rs-relay" ] || MISSING_VPN_BINARIES="$MISSING_VPN_BINARIES nostr-rs-relay" +if [ -n "$MISSING_VPN_BINARIES" ]; then + if [ "${ALLOW_MISSING_VPN_BINARIES:-0}" = "1" ]; then + echo " ⚠ Building WITHOUT:$MISSING_VPN_BINARIES (ALLOW_MISSING_VPN_BINARIES=1)" + else + echo " ❌ Required binaries not extracted:$MISSING_VPN_BINARIES" + echo " The registry (146.59.87.168:3000) must be reachable and hold the images," + echo " or set ALLOW_MISSING_VPN_BINARIES=1 to ship without VPN signaling." + exit 1 + fi +fi + # Copy WireGuard helper script if [ -f "$WORK_DIR/archipelago-wg" ]; then cp "$WORK_DIR/archipelago-wg" "$ARCH_DIR/bin/archipelago-wg" @@ -3345,6 +3372,11 @@ echo "" echo "=== Done ===" DIAGSCRIPT chmod +x /mnt/target/opt/archipelago/scripts/first-boot-diag.sh +# v1.7.104 shipped installs where this script was missing while its unit was +# enabled (203/EXEC forever). Verify the write actually landed, loudly. +if [ ! -x /mnt/target/opt/archipelago/scripts/first-boot-diag.sh ]; then + echo "ERROR: first-boot-diag.sh was not written to the target" >&2 +fi # Systemd oneshot service for first-boot diagnostics cat > /mnt/target/etc/systemd/system/archipelago-diag.service <<'DIAGSVC' @@ -3352,6 +3384,8 @@ cat > /mnt/target/etc/systemd/system/archipelago-diag.service <<'DIAGSVC' Description=Archipelago First Boot Diagnostics After=multi-user.target archipelago.service nginx.service ConditionPathExists=!/var/log/archipelago-first-boot-diag.log +# Skip cleanly (instead of failing 203/EXEC) if the script is missing. +ConditionPathExists=/opt/archipelago/scripts/first-boot-diag.sh [Service] Type=oneshot diff --git a/image-recipe/configs/nostr-relay.service b/image-recipe/configs/nostr-relay.service index 8d1c133a..27366ca0 100644 --- a/image-recipe/configs/nostr-relay.service +++ b/image-recipe/configs/nostr-relay.service @@ -3,6 +3,9 @@ Description=Archipelago Private Nostr Relay After=network-online.target Wants=network-online.target Before=nostr-vpn.service +# An ISO built without the relay binary (registry unreachable at build time) +# must not crash-loop every 3s forever — skip cleanly instead. +ConditionPathExists=/usr/local/bin/nostr-rs-relay [Service] Type=simple diff --git a/image-recipe/configs/nostr-vpn.service b/image-recipe/configs/nostr-vpn.service index 19437a57..995601a4 100644 --- a/image-recipe/configs/nostr-vpn.service +++ b/image-recipe/configs/nostr-vpn.service @@ -4,6 +4,9 @@ After=network-online.target tor.service archipelago.service Wants=network-online.target StartLimitIntervalSec=300 StartLimitBurst=10 +# An ISO built without the nvpn binary (registry unreachable at build time) +# must not restart-loop — skip cleanly instead. +ConditionPathExists=/usr/local/bin/nvpn [Service] Type=simple diff --git a/scripts/run-post-install-tests.sh b/scripts/run-post-install-tests.sh index 47ac80cd..f6a1069f 100755 --- a/scripts/run-post-install-tests.sh +++ b/scripts/run-post-install-tests.sh @@ -103,6 +103,23 @@ for f in /usr/local/bin/archipelago \ fi done +# 1.1b — Every enabled archipelago/nostr unit must have an existing ExecStart +# payload. v1.7.104 shipped nostr-relay.service without its binary (3s +# crash-loop forever) and archipelago-diag.service without its script +# (203/EXEC) — catch that class of ISO defect on first boot, by generic rule. +for unit in /etc/systemd/system/archipelago*.service /etc/systemd/system/nostr*.service; do + [ -f "$unit" ] || continue + systemctl is-enabled "$(basename "$unit")" >/dev/null 2>&1 || continue + exec_bin=$(grep -m1 '^ExecStart=' "$unit" | sed 's/^ExecStart=[-+@!:]*//' | awk '{print $1}') + case "$exec_bin" in + /*) if [ -e "$exec_bin" ]; then + pass "Unit payload exists: $(basename "$unit") → $exec_bin" + else + fail "Unit payload missing" "$(basename "$unit") → $exec_bin" + fi ;; + esac +done + # 1.2 — Critical services active for svc in archipelago nginx; do if systemctl is-active "$svc" >/dev/null 2>&1; then From 0636d611e06fad1ae8fd4dd329c1a8325e6f1569 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 20 Jul 2026 06:45:34 +0100 Subject: [PATCH 6/6] fix(ui): auto-retry the tunnel-QR provisioning while a first install settles On a fresh node the wgqr step is often reached while the backend is still settling (services starting, backend restarting during orchestration): a single 'Failed to fetch' left a permanently blank QR. Retry network-class failures on a 2s/4s/8s ladder while the step is still on screen, then fall back to the friendly error + Try again button. Co-Authored-By: Claude Fable 5 --- .../src/components/CompanionIntroOverlay.vue | 113 +++++++++++------- 1 file changed, 69 insertions(+), 44 deletions(-) diff --git a/neode-ui/src/components/CompanionIntroOverlay.vue b/neode-ui/src/components/CompanionIntroOverlay.vue index 5461fe39..c86b47ee 100644 --- a/neode-ui/src/components/CompanionIntroOverlay.vue +++ b/neode-ui/src/components/CompanionIntroOverlay.vue @@ -487,6 +487,19 @@ function backFromPair() { } } +// Network-class failures: the node itself was unreachable (as opposed to the +// backend answering with an RPC error). Includes the client's own timeout. +const WG_NETWORK_ERR = /failed to fetch|networkerror|load failed|abort|request timeout/i + +// Auto-retry ladder for network-class failures. On a first install this step +// is often reached while the backend is still settling (services starting, +// backend restarting during container orchestration) — a single failed fetch +// left a permanently blank QR unless the user spotted the retry button. +const WG_RETRY_DELAYS_MS = [2000, 4000, 8000] + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) +const stillOnWgStep = () => visible.value && step.value === 'wgqr' + // Create (or fetch) the phone's VPN peer and render its config as a QR. // Reuses the same RPCs as the Server page's Add Device modal; the peer is // looked up first so reopening the modal never duplicates it. @@ -494,53 +507,65 @@ async function loadWgPeer() { if (wgQrDataUrl.value || wgLoading.value) return wgLoading.value = true wgError.value = '' - try { - const listed = await rpcClient - .call<{ peers: { name: string }[] }>({ method: 'vpn.list-peers' }) - .catch(() => null) - // list-peers unreachable → don't guess "doesn't exist": create-peer on an - // existing name would fail. Try create first, fall back to peer-config. - const exists = listed === null - ? null - : (listed.peers || []).some((p) => p.name === WG_PEER_NAME) - let res: { config: string; peer_ip: string } - if (exists === true) { - res = await rpcClient.call({ method: 'vpn.peer-config', params: { name: WG_PEER_NAME } }) - } else { - try { - res = await rpcClient.call({ method: 'vpn.create-peer', params: { name: WG_PEER_NAME } }) - } catch (e) { - // Peer already provisioned on a previous visit (list failed or raced). - const msg = e instanceof Error ? e.message : '' - if (/exist|duplicate/i.test(msg)) { - res = await rpcClient.call({ method: 'vpn.peer-config', params: { name: WG_PEER_NAME } }) - } else { - throw e - } + for (let attempt = 0; ; attempt++) { + try { + await provisionWgPeer() + break + } catch (e) { + const raw = e instanceof Error ? e.message : '' + const isNetworkErr = WG_NETWORK_ERR.test(raw) + if (isNetworkErr && attempt < WG_RETRY_DELAYS_MS.length && stillOnWgStep()) { + await sleep(WG_RETRY_DELAYS_MS[attempt]) + if (stillOnWgStep()) continue + } + // fetch()'s raw "Failed to fetch" means the node itself was unreachable — + // after the retry ladder that's usually the phone's network mid-change + // (WiFi drop, or a half-configured tunnel already routing 10.44.0.0/16). + // Say so, and leave a Retry path instead of a dead end. + wgError.value = isNetworkErr + ? "Can't reach your node. Check the phone is on the same network as the node (and any half-set-up tunnel is switched off), then tap Try again." + : raw || 'Failed to generate the tunnel config' + break + } + } + wgLoading.value = false +} + +async function provisionWgPeer() { + const listed = await rpcClient + .call<{ peers: { name: string }[] }>({ method: 'vpn.list-peers' }) + .catch(() => null) + // list-peers unreachable → don't guess "doesn't exist": create-peer on an + // existing name would fail. Try create first, fall back to peer-config. + const exists = listed === null + ? null + : (listed.peers || []).some((p) => p.name === WG_PEER_NAME) + let res: { config: string; peer_ip: string } + if (exists === true) { + res = await rpcClient.call({ method: 'vpn.peer-config', params: { name: WG_PEER_NAME } }) + } else { + try { + res = await rpcClient.call({ method: 'vpn.create-peer', params: { name: WG_PEER_NAME } }) + } catch (e) { + // Peer already provisioned on a previous visit (list failed or raced). + const msg = e instanceof Error ? e.message : '' + if (/exist|duplicate/i.test(msg)) { + res = await rpcClient.call({ method: 'vpn.peer-config', params: { name: WG_PEER_NAME } }) + } else { + throw e } } - wgConfig.value = res.config - wgQrDataUrl.value = await QRCode.toDataURL(res.config, { - width: 512, - margin: 3, - errorCorrectionLevel: 'M', - color: { - dark: '#111111', - light: '#ffffff', - }, - }) - } catch (e) { - // fetch()'s raw "Failed to fetch" means the node itself was unreachable — - // on this screen that's usually the phone's network mid-change (WiFi drop, - // or a half-configured tunnel already routing 10.44.0.0/16). Say so, and - // leave a Retry path instead of a dead end. - const raw = e instanceof Error ? e.message : '' - wgError.value = /failed to fetch|networkerror|load failed|abort/i.test(raw) - ? "Can't reach your node. Check the phone is on the same network as the node (and any half-set-up tunnel is switched off), then tap Try again." - : raw || 'Failed to generate the tunnel config' - } finally { - wgLoading.value = false } + wgConfig.value = res.config + wgQrDataUrl.value = await QRCode.toDataURL(res.config, { + width: 512, + margin: 3, + errorCorrectionLevel: 'M', + color: { + dark: '#111111', + light: '#ffffff', + }, + }) } function retryWgPeer() {