diff --git a/core/archipelago/src/api/rpc/system/handlers.rs b/core/archipelago/src/api/rpc/system/handlers.rs index 22e5a5c6..508d4f0f 100644 --- a/core/archipelago/src/api/rpc/system/handlers.rs +++ b/core/archipelago/src/api/rpc/system/handlers.rs @@ -139,9 +139,17 @@ impl RpcHandler { .await .map(|s| s.trim().to_string()) .unwrap_or_else(|_| "archipelago".to_string()); + // LAN IPv4 rides along for the companion pairing QR: when the + // operator's browser reaches this node over Tailscale/VPN or + // localhost, that origin is useless to a phone on the LAN — the QR + // must advertise an address the phone can actually dial + // (2026-07-22: a pairing QR carried a tailnet 100.x IP and the + // companion could never connect). + let lan_ip = crate::host_ip::primary_host_ipv4().await; Ok(serde_json::json!({ "hostname": hostname, "mdns_hostname": format!("{hostname}.local"), + "lan_ip": lan_ip, })) } diff --git a/neode-ui/src/components/CompanionIntroOverlay.vue b/neode-ui/src/components/CompanionIntroOverlay.vue index 537df118..cfb06cf7 100644 --- a/neode-ui/src/components/CompanionIntroOverlay.vue +++ b/neode-ui/src/components/CompanionIntroOverlay.vue @@ -252,21 +252,39 @@ watch(visible, async (isVisible) => { }) }, { immediate: true, flush: 'post' }) +/** Tailscale/CGNAT range 100.64.0.0/10 — reachable only inside the tailnet. */ +function isTailnetIp(host: string): boolean { + const m = host.match(/^100\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/) + return !!m && Number(m[1]) >= 64 && Number(m[1]) <= 127 +} + /** * The server URL the companion app should connect to. The demo advertises its - * public https origin; a real node advertises whatever address the browser is - * already using — except on the kiosk, where that is localhost and useless to - * a phone, so fall back to the node's mDNS .local name. + * public https origin; a real node advertises the browser's own origin ONLY + * when a phone could plausibly reach it too. Two origins that a phone on the + * LAN can never dial get substituted with the node's real LAN address: + * - localhost/127.0.0.1 (the kiosk browses itself) + * - a tailnet 100.x address (operator browsing over Tailscale — a scanned + * QR carried one of these on 2026-07-22 and the companion sat there + * dialing an IP the phone had no route to) */ async function resolveServerUrl(): Promise { if (IS_DEMO) return DEMO_SERVER_URL const { hostname, origin } = window.location - if (hostname !== 'localhost' && hostname !== '127.0.0.1') return origin + const phoneUnreachable = + hostname === 'localhost' || hostname === '127.0.0.1' || isTailnetIp(hostname) + if (!phoneUnreachable) return origin try { - const res = await rpcClient.call<{ mdns_hostname?: string }>({ method: 'system.get-hostname' }) + const res = await rpcClient.call<{ mdns_hostname?: string; lan_ip?: string | null }>({ + method: 'system.get-hostname', + }) + // Prefer the LAN IP (phones resolve it without mDNS support — Android + // notoriously lacks .local); fall back to the mDNS name. + if (res?.lan_ip) return `http://${res.lan_ip}` if (res?.mdns_hostname) return `http://${res.mdns_hostname}` } catch { - // RPC unavailable — fall through to the (localhost) origin + // RPC unavailable — fall through to the (unreachable) origin; the app + // still lets the user edit the address by hand. } return origin }