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() {