fix(connection): fix 6 correctness bugs in auth/connect/diagnostics flow (#131)

1. buildRequestHeaders: UTF-8-encode Basic-auth credentials before btoa()
   so non-ASCII usernames/passwords don't throw (Hermes' btoa is Latin1-only
   and the throw was an unhandled rejection that hung the connect spinner).

2. diagnostics classify(): check root.ok (server reachable) before
   !internet.ok, so a reachable-but-failing server (e.g. wrong auth) is no
   longer misdiagnosed as "no internet" just because the public-internet
   probe also failed (captive portal, Tailscale-only network, etc).

3. sdk.ts createClient: strip trailing slashes from baseUrl once, so a
   trailing-slash URL from Advanced mode / Edit screen doesn't produce a
   double slash on every request path.

4. add.tsx / [id].tsx: wrap addConnection/updateConnection in try/catch so
   a SecureStore failure after a successful test resets the spinner and
   shows an alert instead of hanging forever. Adds
   connection.shared.alerts.saveFailedTitle/saveFailedMessage (en + zh-Hans).

5. add.tsx / [id].tsx: build the diagnostics probe's auth with buildAuth()
   instead of a hand-rolled expression, so the probe reproduces the real
   request's credentials (previously Quick Connect's password-only case
   sent no auth to the probe at all).

6. add.tsx handleQuickConnect: stop sending the shared `username` state,
   which could carry a stray value typed earlier in Advanced mode and
   silently override the "opencode" default after "Back to Quick".


Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6

Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Den
2026-07-18 16:04:33 -07:00
committed by GitHub
parent 5b47f9ee13
commit df4a3618c4
9 changed files with 150 additions and 64 deletions

View File

@@ -19,6 +19,7 @@ import type { ConnectionType } from "../../src/lib/types"
import { probeConnection, shareReport } from "../../src/lib/diagnostics"
import { captureDiagnostic } from "../../src/lib/sentry"
import { parseUrl } from "../../src/lib/diagnostics-classify"
import { buildAuth } from "../../src/lib/auth"
// labelKey (not literal text): this is a module-level constant evaluated
// before i18next is guaranteed ready, so the label is resolved with t() at
@@ -101,10 +102,7 @@ export default function EditConnectionScreen() {
}
// Failed: run active diagnostics, capture to Sentry, offer a shareable report.
const report = await probeConnection(
url.trim(),
username.trim() && password ? { username: username.trim(), password } : undefined,
)
const report = await probeConnection(url.trim(), buildAuth(username, password))
captureDiagnostic(report)
setIsTesting(false)
@@ -136,28 +134,36 @@ export default function EditConnectionScreen() {
}
setIsSaving(true)
await updateConnection(
connection.id,
{
name: name.trim(),
type,
url: url.trim(),
directory: directory.trim() || undefined,
username: username.trim() || undefined,
},
// Empty = keep existing password (the field loads blank); a typed value
// rotates it in SecureStore.
password || undefined,
)
// If this was the active connection, the SSE loop may have stopped
// retrying after a prior 401 (see events.ts) — reconnect now with the
// freshly saved credentials instead of leaving the user stuck until
// they relaunch the app.
if (useConnections.getState().activeConnection?.id === connection.id) {
useEvents.getState().connect()
try {
await updateConnection(
connection.id,
{
name: name.trim(),
type,
url: url.trim(),
directory: directory.trim() || undefined,
username: username.trim() || undefined,
},
// Empty = keep existing password (the field loads blank); a typed value
// rotates it in SecureStore.
password || undefined,
)
// If this was the active connection, the SSE loop may have stopped
// retrying after a prior 401 (see events.ts) — reconnect now with the
// freshly saved credentials instead of leaving the user stuck until
// they relaunch the app.
if (useConnections.getState().activeConnection?.id === connection.id) {
useEvents.getState().connect()
}
setIsSaving(false)
router.back()
} catch {
setIsSaving(false)
Alert.alert(
t("connection.shared.alerts.saveFailedTitle"),
t("connection.shared.alerts.saveFailedMessage"),
)
}
setIsSaving(false)
router.back()
}
const handleDelete = () => {