fix(desktop): normalize API base URL across renderer and main

Replace ad-hoc trim/fallback logic with a shared normalizer that strips
trailing slashes and accidental /api suffixes, rejects non-http schemes,
and rewrites Vite dev origins (517x) back to localhost:8080 so the
desktop client never points its API client at the renderer dev server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:56:47 +08:00
parent 7b4d7ccf68
commit 13464c7788
4 changed files with 71 additions and 10 deletions
@@ -22,6 +22,7 @@ import {
normalizeAccountPlatformUid, normalizeAccountPlatformUid,
sameAccountPlatformUid, sameAccountPlatformUid,
} from '../shared/account-identity' } from '../shared/account-identity'
import { normalizeDesktopApiBaseURL } from '../shared/api-base-url'
import { type PublishAccountIdentity, type PublishAccountProfile } from './account-binder' import { type PublishAccountIdentity, type PublishAccountProfile } from './account-binder'
import { import {
ensureAccountReady, ensureAccountReady,
@@ -3267,7 +3268,7 @@ function normalizeSession(
} }
return { return {
api_base_url: session.api_base_url.trim() || 'http://localhost:8080', api_base_url: normalizeDesktopApiBaseURL(session.api_base_url),
mode: session.mode, mode: session.mode,
client_token: session.client_token?.trim() || null, client_token: session.client_token?.trim() || null,
desktop_client: session.desktop_client ? { ...session.desktop_client } : null, desktop_client: session.desktop_client ? { ...session.desktop_client } : null,
@@ -35,6 +35,7 @@ import {
startObservedRequest, startObservedRequest,
} from '../network-observer' } from '../network-observer'
import { canUseRendererDevtoolsProxy, rendererDevtoolsFetch } from '../renderer-devtools-proxy' import { canUseRendererDevtoolsProxy, rendererDevtoolsFetch } from '../renderer-devtools-proxy'
import { normalizeDesktopApiBaseURL } from '../../shared/api-base-url'
type TransportAuthState = 'pending' | 'registered' | 'expired' type TransportAuthState = 'pending' | 'registered' | 'expired'
type TransportDispatchState = 'idle' | 'connecting' | 'streaming' type TransportDispatchState = 'idle' | 'connecting' | 'streaming'
@@ -64,7 +65,7 @@ interface MonitoringCancelTaskResponse {
let desktopApiClient: ApiClient | null = null let desktopApiClient: ApiClient | null = null
let transportSession: DesktopTransportSession = { let transportSession: DesktopTransportSession = {
baseURL: process.env.DESKTOP_API_BASE_URL ?? 'http://localhost:8080', baseURL: normalizeDesktopApiBaseURL(process.env.DESKTOP_API_BASE_URL),
clientToken: null, clientToken: null,
} }
const ACCOUNT_UPSERT_CACHE_TTL_MS = 5 * 60_000 const ACCOUNT_UPSERT_CACHE_TTL_MS = 5 * 60_000
@@ -364,7 +365,7 @@ export function configureTransport(
): ApiClient | null { ): ApiClient | null {
if (!session) { if (!session) {
transportSession = { transportSession = {
baseURL: process.env.DESKTOP_API_BASE_URL ?? 'http://localhost:8080', baseURL: normalizeDesktopApiBaseURL(process.env.DESKTOP_API_BASE_URL),
clientToken: null, clientToken: null,
} }
transportState.baseURL = transportSession.baseURL transportState.baseURL = transportSession.baseURL
@@ -377,7 +378,7 @@ export function configureTransport(
} }
transportSession = { transportSession = {
baseURL: session.api_base_url.trim() || 'http://localhost:8080', baseURL: normalizeDesktopApiBaseURL(session.api_base_url),
clientToken: session.client_token, clientToken: session.client_token,
} }
transportState.baseURL = transportSession.baseURL transportState.baseURL = transportSession.baseURL
@@ -13,6 +13,8 @@ import type {
UserInfo, UserInfo,
} from '@geo/shared-types' } from '@geo/shared-types'
import { normalizeDesktopApiBaseURL } from '../../shared/api-base-url'
type SessionMode = 'authenticated' | 'preview' type SessionMode = 'authenticated' | 'preview'
interface DesktopSession { interface DesktopSession {
@@ -35,6 +37,7 @@ const sessionStorageKey = 'geo.desktop.session.v1'
const apiBaseURLStorageKey = 'geo.desktop.api-base-url' const apiBaseURLStorageKey = 'geo.desktop.api-base-url'
export const DEFAULT_API_BASE_URL = export const DEFAULT_API_BASE_URL =
import.meta.env.VITE_DESKTOP_API_BASE_URL ?? 'https://api.shengxintui.com' import.meta.env.VITE_DESKTOP_API_BASE_URL ?? 'https://api.shengxintui.com'
const normalizedDefaultApiBaseURL = normalizeDesktopApiBaseURL(DEFAULT_API_BASE_URL)
const session = shallowRef<DesktopSession | null>(readStoredSession()) const session = shallowRef<DesktopSession | null>(readStoredSession())
const apiBaseURL = shallowRef(readStoredApiBaseURL()) const apiBaseURL = shallowRef(readStoredApiBaseURL())
@@ -77,18 +80,23 @@ function persistSession(next: DesktopSession | null): void {
function readStoredApiBaseURL(): string { function readStoredApiBaseURL(): string {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return DEFAULT_API_BASE_URL return normalizedDefaultApiBaseURL
} }
return window.localStorage.getItem(apiBaseURLStorageKey) ?? DEFAULT_API_BASE_URL const stored = window.localStorage.getItem(apiBaseURLStorageKey)
const normalized = normalizeDesktopApiBaseURL(stored, normalizedDefaultApiBaseURL)
if (stored && stored !== normalized) {
window.localStorage.setItem(apiBaseURLStorageKey, normalized)
}
return normalized
} }
function persistApiBaseURL(value: string): void { function persistApiBaseURL(value: string): void {
const trimmed = value.trim() || DEFAULT_API_BASE_URL const normalized = normalizeDesktopApiBaseURL(value, normalizedDefaultApiBaseURL)
apiBaseURL.value = trimmed apiBaseURL.value = normalized
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.localStorage.setItem(apiBaseURLStorageKey, trimmed) window.localStorage.setItem(apiBaseURLStorageKey, normalized)
} }
void syncRuntimeSessionToMain() void syncRuntimeSessionToMain()
@@ -528,7 +536,7 @@ async function logout(): Promise<void> {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('storage', (event) => { window.addEventListener('storage', (event) => {
if (event.key === apiBaseURLStorageKey) { if (event.key === apiBaseURLStorageKey) {
apiBaseURL.value = event.newValue?.trim() || DEFAULT_API_BASE_URL apiBaseURL.value = normalizeDesktopApiBaseURL(event.newValue, normalizedDefaultApiBaseURL)
void syncRuntimeSessionToMain() void syncRuntimeSessionToMain()
} }
}) })
@@ -0,0 +1,51 @@
const DEFAULT_LOCAL_API_BASE_URL = 'http://localhost:8080'
const VITE_DEV_PORT_PATTERN = /^517\d$/
function isLoopbackHost(hostname: string): boolean {
const normalized = hostname.toLowerCase()
return (
normalized === 'localhost' ||
normalized === '127.0.0.1' ||
normalized === '::1' ||
normalized === '[::1]'
)
}
function isViteDevOrigin(url: URL): boolean {
return isLoopbackHost(url.hostname) && VITE_DEV_PORT_PATTERN.test(url.port)
}
function cleanApiBaseURL(url: URL): string {
const pathname = url.pathname.replace(/\/+$/, '')
const normalizedPath = pathname.replace(/\/api$/i, '')
url.pathname = normalizedPath || '/'
url.search = ''
url.hash = ''
return url.toString().replace(/\/$/, '')
}
export function normalizeDesktopApiBaseURL(
value: string | null | undefined,
fallback = DEFAULT_LOCAL_API_BASE_URL,
): string {
const trimmed = String(value ?? '').trim()
const candidate = trimmed || fallback
try {
const parsed = new URL(candidate)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return normalizeDesktopApiBaseURL(fallback, DEFAULT_LOCAL_API_BASE_URL)
}
if (isViteDevOrigin(parsed)) {
return DEFAULT_LOCAL_API_BASE_URL
}
return cleanApiBaseURL(parsed)
} catch {
if (candidate === fallback) {
return DEFAULT_LOCAL_API_BASE_URL
}
return normalizeDesktopApiBaseURL(fallback, DEFAULT_LOCAL_API_BASE_URL)
}
}