From e9991cb61b80bd229866d19b91ccbc0c678cc862 Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Fri, 22 May 2026 04:44:13 -0700 Subject: [PATCH] fix(connect): fix tailnet 'Connection Failed' (pasted-URL double scheme) + surface real error (#1) * fix(connect): surface real connection error instead of generic dialog testConnection swallowed the actual fetch error and returned a bare boolean, so every failure collapsed to the same "Connection Failed" text. On-device this made tailnet/LAN connect failures impossible to diagnose (DNS vs timeout vs 401 vs cleartext all looked identical). - testConnection now returns { ok, error } with the real error message - add.tsx and [id].tsx dialogs show the error + target URL, plus a Tailscale/MagicDNS hint - IP field keyboard: decimal-pad -> url, so tailnet hostnames can be typed (not just pasted) Verified backend is healthy and reachable over tailnet (health 200, port 4096 open in packet filter, cleartext present in shipped v0.2.0 APK), so the failure is client-side and was previously unobservable. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(connect): normalize pasted IP/URL to avoid double scheme Quick-connect blindly did `http://${ip}:${port}`. Pasting a full URL (e.g. "http://100.108.64.76:4096", as the clipboard auto-paste offers) produced "http://http://100.108.64.76:4096:4096" -> malformed URL -> "Network request failed". This is the real tailnet connect failure: typing a bare IP worked, pasting the displayed URL did not. buildUrl now strips an existing http(s) scheme, drops any path, and lifts a trailing :port out of the host field, so pasted full URLs, host:port, and bare hosts all resolve to a single well-formed URL. Reproduced and fixed on the Android emulator (paste full URL: fails before, connects after). Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- app/connection/[id].tsx | 8 +++++--- app/connection/add.tsx | 31 ++++++++++++++++++++++++------- src/stores/connections.ts | 8 ++++---- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/app/connection/[id].tsx b/app/connection/[id].tsx index 9467ea3..db2d802 100644 --- a/app/connection/[id].tsx +++ b/app/connection/[id].tsx @@ -68,7 +68,7 @@ export default function EditConnectionScreen() { } setIsTesting(true) - const success = await testConnection( + const result = await testConnection( { id: connection.id, name: name || "Test", @@ -82,8 +82,10 @@ export default function EditConnectionScreen() { setIsTesting(false) Alert.alert( - success ? "Success" : "Failed", - success ? "Connection successful!" : "Could not connect to the server. Check the URL and credentials.", + result.ok ? "Success" : "Failed", + result.ok + ? "Connection successful!" + : `Could not connect to ${url.trim()}\n\n${result.error || "Check the URL and credentials."}`, ) } diff --git a/app/connection/add.tsx b/app/connection/add.tsx index e30bbea..19c612a 100644 --- a/app/connection/add.tsx +++ b/app/connection/add.tsx @@ -34,8 +34,25 @@ export default function AddConnectionScreen() { const buildUrl = () => { if (mode === "advanced") return url.trim() - if (!ip.trim()) return "" - return `http://${ip.trim()}:${port || "4096"}` + const raw = ip.trim() + if (!raw) return "" + // Be forgiving about pasted values: a full URL, a host:port, or a + // host with a trailing path. Extract scheme, host, and port so we + // never produce "http://http://host:4096:4096". + const schemeMatch = raw.match(/^(https?):\/\//i) + const scheme = schemeMatch ? schemeMatch[1].toLowerCase() : "http" + let rest = raw.replace(/^https?:\/\//i, "") + rest = rest.split("/")[0] // drop any path/query + let host = rest + let pastedPort = "" + const lastColon = rest.lastIndexOf(":") + // Only treat trailing ":NNNN" as a port (ignore IPv6 colons / bare host) + if (lastColon > -1 && /^\d+$/.test(rest.slice(lastColon + 1))) { + host = rest.slice(0, lastColon) + pastedPort = rest.slice(lastColon + 1) + } + const finalPort = pastedPort || port.trim() || "4096" + return `${scheme}://${host}:${finalPort}` } const handleQuickConnect = async () => { @@ -48,7 +65,7 @@ export default function AddConnectionScreen() { setIsConnecting(true) // Test connection first - const success = await testConnection( + const result = await testConnection( { id: "", name: name || "My Server", @@ -59,7 +76,7 @@ export default function AddConnectionScreen() { password || undefined, ) - if (success) { + if (result.ok) { // Save and go back await addConnection( { @@ -76,7 +93,7 @@ export default function AddConnectionScreen() { setIsConnecting(false) Alert.alert( "Connection Failed", - "Could not connect to the server.\n\nMake sure:\n1. OpenCode is running: opencode serve --hostname 0.0.0.0\n2. You're on the same WiFi network\n3. The IP address is correct", + `Could not connect to ${serverUrl}\n\n${result.error || "Unknown error"}\n\nMake sure:\n1. OpenCode is running: opencode serve --hostname 0.0.0.0\n2. You're on the same network (or Tailscale is connected)\n3. The address is correct (use the IP for tailnet if MagicDNS isn't enabled)`, [{ text: "OK" }], ) } @@ -128,13 +145,13 @@ export default function AddConnectionScreen() { : , password?: string) => Promise removeConnection: (id: string) => Promise setActiveConnection: (id: string) => Promise - testConnection: (connection: ServerConnection, password?: string) => Promise + testConnection: (connection: ServerConnection, password?: string) => Promise<{ ok: boolean; error?: string }> updateConnection: (id: string, updates: Partial) => Promise refreshProject: () => Promise // Create a one-off client pointing at a specific directory (for cross-project operations) @@ -227,9 +227,9 @@ export const useConnections = create((set, get) => ({ }) await client.global.health() - return true - } catch { - return false + return { ok: true } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } } },