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:
@@ -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 = () => {
|
||||
|
||||
@@ -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"
|
||||
import { AnalyticsEvent, track } from "../../src/lib/analytics"
|
||||
import { submitWaitlistSignup, buildWaitlistMailtoUrl } from "../../src/lib/waitlist"
|
||||
|
||||
@@ -75,14 +76,17 @@ export default function AddConnectionScreen() {
|
||||
track(AnalyticsEvent.ConnectionFormSubmitted, { mode: "quick" })
|
||||
setIsConnecting(true)
|
||||
|
||||
// Test connection first
|
||||
// Test connection first. Quick Connect has no username field, so the
|
||||
// connection is intentionally saved without one — buildAuth() defaults
|
||||
// it to "opencode" wherever auth is built. Sending the `username` state
|
||||
// here would leak a value typed earlier in Advanced mode (issue: Back to
|
||||
// Quick silently overriding the default).
|
||||
const result = await testConnection(
|
||||
{
|
||||
id: "",
|
||||
name: name || t("connection.shared.namePlaceholder"),
|
||||
type: "local",
|
||||
url: serverUrl,
|
||||
username: username.trim() || undefined,
|
||||
},
|
||||
"onboarding",
|
||||
password || undefined,
|
||||
@@ -90,23 +94,27 @@ export default function AddConnectionScreen() {
|
||||
|
||||
if (result.ok) {
|
||||
// Save and go back
|
||||
await addConnection(
|
||||
{
|
||||
name: name.trim() || t("connection.shared.namePlaceholder"),
|
||||
type: "local",
|
||||
url: serverUrl,
|
||||
username: username.trim() || undefined,
|
||||
},
|
||||
password || undefined,
|
||||
)
|
||||
setIsConnecting(false)
|
||||
router.back()
|
||||
try {
|
||||
await addConnection(
|
||||
{
|
||||
name: name.trim() || t("connection.shared.namePlaceholder"),
|
||||
type: "local",
|
||||
url: serverUrl,
|
||||
},
|
||||
password || undefined,
|
||||
)
|
||||
setIsConnecting(false)
|
||||
router.back()
|
||||
} catch {
|
||||
setIsConnecting(false)
|
||||
Alert.alert(
|
||||
t("connection.shared.alerts.saveFailedTitle"),
|
||||
t("connection.shared.alerts.saveFailedMessage"),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Failed: run active diagnostics, capture to Sentry, offer a shareable report.
|
||||
const report = await probeConnection(
|
||||
serverUrl,
|
||||
username.trim() && password ? { username: username.trim(), password } : undefined,
|
||||
)
|
||||
const report = await probeConnection(serverUrl, buildAuth(undefined, password))
|
||||
captureDiagnostic(report)
|
||||
setIsConnecting(false)
|
||||
Alert.alert(
|
||||
@@ -160,28 +168,33 @@ export default function AddConnectionScreen() {
|
||||
)
|
||||
|
||||
if (result.ok) {
|
||||
await addConnection(
|
||||
{
|
||||
name: name.trim(),
|
||||
type,
|
||||
url: url.trim(),
|
||||
directory: directory.trim() || undefined,
|
||||
username: username.trim() || undefined,
|
||||
},
|
||||
password || undefined,
|
||||
)
|
||||
setIsConnecting(false)
|
||||
router.back()
|
||||
try {
|
||||
await addConnection(
|
||||
{
|
||||
name: name.trim(),
|
||||
type,
|
||||
url: url.trim(),
|
||||
directory: directory.trim() || undefined,
|
||||
username: username.trim() || undefined,
|
||||
},
|
||||
password || undefined,
|
||||
)
|
||||
setIsConnecting(false)
|
||||
router.back()
|
||||
} catch {
|
||||
setIsConnecting(false)
|
||||
Alert.alert(
|
||||
t("connection.shared.alerts.saveFailedTitle"),
|
||||
t("connection.shared.alerts.saveFailedMessage"),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Failed: same "Connection Failed" alert as Quick Connect — run active
|
||||
// diagnostics, capture to Sentry, and offer a shareable report instead of
|
||||
// silently persisting an unreachable/unauthorized connection.
|
||||
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)
|
||||
setIsConnecting(false)
|
||||
Alert.alert(
|
||||
|
||||
Reference in New Issue
Block a user