Merge remote-tracking branch 'gitea-ai/fix/companion-autologin-replay-intro'
Demo images / Build & push demo images (push) Successful in 2m49s

This commit is contained in:
archipelago
2026-07-20 01:57:19 -04:00
10 changed files with 263 additions and 35 deletions
@@ -161,6 +161,14 @@
</div>
</div>
<p v-if="wgError" class="text-xs text-red-400 text-center mb-3">{{ wgError }}</p>
<button
v-if="wgError && !wgLoading"
type="button"
class="inline-flex w-full items-center justify-center rounded-lg bg-white/5 border border-white/15 px-4 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors mb-3"
@click="retryWgPeer"
>
Try again
</button>
<!-- Same-device path: a phone can't scan its own screen, so offer
the config as a file WireGuard can import. -->
@@ -318,7 +326,15 @@ const POST_INTRO_GRACE_MS = 2000
let calmTicker: ReturnType<typeof setInterval> | 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)
@@ -471,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<void>((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.
@@ -478,30 +507,70 @@ 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(() => ({ 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 },
})
wgConfig.value = res.config
wgQrDataUrl.value = await QRCode.toDataURL(res.config, {
width: 512,
margin: 3,
errorCorrectionLevel: 'M',
color: {
dark: '#111111',
light: '#ffffff',
},
})
} catch (e) {
wgError.value = e instanceof Error ? e.message : 'Failed to generate the tunnel config'
} finally {
wgLoading.value = false
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',
},
})
}
function retryWgPeer() {
wgError.value = ''
void loadWgPeer()
}
// Same-device path: hand the config to the WireGuard app as an importable