From e8e6e4cacf0ea64c226e97067163e097aa8ef29d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 14 Aug 2026 17:29:49 +0300 Subject: [PATCH] fix(mobile): keep tokenless connections alive across launch and resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A server running with authentication disabled pairs and connects fine, but the saved connection has no bearer token. Auto-connect silently bailed on the missing token and the resume reprobe reported it as 'unreachable', so every return to the app kicked the user to the connect screen. Treat a saved tokenless connection as valid: probe it without a bearer and let the probe decide — auth disabled connects, auth enabled later reports needs-login. Bail out only when an expected token cannot be read. --- CHANGELOG.md | 1 + packages/ui/src/apps/mobileConnections.ts | 34 +++++++++++++---------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33df576f..1a977b7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. - **Chat:** new messages now remain at the end of the conversation instead of jumping before older messages after the message ID sequence rolls over; history loading, revert, and redo follow the same chronological order. - **Stability:** a single internal error no longer shuts down the local server, which made the instance unreachable until it was restarted; the error is logged and the server keeps running. +- Mobile: connecting to a server that has authentication disabled now survives closing and reopening the app — auto-reconnect and the return-to-app check no longer treat the missing password token as a lost connection and kick back to the connect screen. - Browser: restoring or opening a dev server preview while connected to an instance over a relay or other non-standard address no longer crashes the app; the preview reports the tunnel as unavailable instead. ## [1.18.3] - 2026-08-14 diff --git a/packages/ui/src/apps/mobileConnections.ts b/packages/ui/src/apps/mobileConnections.ts index 30ba5d8f..77c83c24 100644 --- a/packages/ui/src/apps/mobileConnections.ts +++ b/packages/ui/src/apps/mobileConnections.ts @@ -1015,20 +1015,20 @@ export const autoConnectLastInstance = async (options?: { fast?: boolean; skipIf logConnect('auto-connect:start', { hasCandidate: Boolean(candidate), fast }); if (!candidate) return { status: 'no-candidate' }; - // The runtime transport needs a bearer token; only auto-connect when one is - // already saved. A missing/expired token must go through the login UI. + // The runtime transport authenticates with a bearer token when the server + // issued one. A connection saved WITHOUT a token means its last successful + // connect was tokenless (server auth disabled) — probe it the same way; the + // probe itself reports needs-login if the server has since enabled auth. Only + // an EXPECTED token that cannot be read must go through the login UI. let token: string | undefined; if (isCapacitorApp()) { - if (!candidate.hasToken) { - return { status: 'no-candidate' }; - } - token = await readSecureToken(secureTokenKeyOf(candidate)); - if (!token) { - return { status: 'no-candidate' }; + if (candidate.hasToken) { + token = await readSecureToken(secureTokenKeyOf(candidate)); + if (!token) return { status: 'no-candidate' }; } } else { token = candidate.clientToken; - if (!token) return { status: 'no-candidate' }; + if (!token && candidate.hasToken) return { status: 'no-candidate' }; } // Fast probe by default: the cold-launch splash should decide in a couple of @@ -1050,7 +1050,7 @@ export const autoConnectLastInstance = async (options?: { fast?: boolean; skipIf return { status: 'no-candidate' }; } await upsertMobileConnection({ id: candidate.id, label: candidate.label, candidates: candidate.candidates }); // bump lastUsedAt (keeps token) - switchToTransport(result.transport, token, { runtimeKey: secureTokenKeyOf(candidate) }); + switchToTransport(result.transport, token ?? null, { runtimeKey: secureTokenKeyOf(candidate) }); return { status: 'connected' }; }; @@ -1209,11 +1209,15 @@ export const reprobeActiveConnection = async (options?: { fast?: boolean }): Pro } else { token = active.clientToken; } - if (!token) { - logConnect('reprobe:no-token', { hasToken: Boolean(active.hasToken) }); + // Tokenless is valid (server auth disabled — the probe reports needs-login if + // that changed); bail only when an EXPECTED token cannot be read. 'unreachable' + // (not needs-login) so the resume retry ladder re-reads the token — a transient + // secure-storage failure must not force a re-login. + if (!token && active.hasToken) { + logConnect('reprobe:no-token', { hasToken: true }); return 'unreachable'; } - logConnect('reprobe:start', { candidates: active.candidates.map((c) => c.kind), fast }); + logConnect('reprobe:start', { candidates: active.candidates.map((c) => c.kind), fast, hasToken: Boolean(token) }); const currentIndex = active.candidates.findIndex( (candidate) => transportMatchesCurrentRuntime(candidate.kind === 'relay' ? { kind: 'relay', relay: candidate.relay } : { kind: 'direct', url: candidate.url }), @@ -1225,7 +1229,7 @@ export const reprobeActiveConnection = async (options?: { fast?: boolean }): Pro logConnect('reprobe:better', { status: better.status, probed: higher.length }); if (better.status === 'ok') { await upsertMobileConnection({ id: active.id, label: active.label, candidates: active.candidates }); - switchToTransport(better.transport, token, { runtimeKey: secureTokenKeyOf(active) }); + switchToTransport(better.transport, token ?? null, { runtimeKey: secureTokenKeyOf(active) }); return 'switched'; } // The shared token was explicitly rejected — no transport will accept it. @@ -1251,7 +1255,7 @@ export const reprobeActiveConnection = async (options?: { fast?: boolean }): Pro logConnect('reprobe:fallback', { status: fallback.status, probed: lower.length }); if (fallback.status === 'ok') { await upsertMobileConnection({ id: active.id, label: active.label, candidates: active.candidates }); - switchToTransport(fallback.transport, token, { runtimeKey: secureTokenKeyOf(active) }); + switchToTransport(fallback.transport, token ?? null, { runtimeKey: secureTokenKeyOf(active) }); return 'switched'; } if (fallback.status === 'needs-login') return 'needs-login';