import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http'; export const AGC_DEVELOPMENT_API_BASE_URL = 'https://dev.genarrative.world'; export const AGC_RELEASE_API_BASE_URL = 'https://www.genarrative.world'; export const AGC_CLIENT_MARKER_HEADER = 'X-Genarrative-Client'; export const AGC_CLIENT_MARKER_VALUE = 'agc'; export type ClientServerPreset = 'release' | 'dev' | 'custom'; export type ClientServerSelection = { preset: ClientServerPreset; customBaseUrl: string; }; const CLIENT_SERVER_SELECTION_STORAGE_KEY = 'genarrative.client.server-selection.v1'; function defaultClientServerPreset(): Exclude { return import.meta.env.DEV ? 'dev' : 'release'; } function isClientServerPreset(value: unknown): value is ClientServerPreset { return value === 'release' || value === 'dev' || value === 'custom'; } export function normalizeClientServerBaseUrl(value: string) { const normalized = value.trim().replace(/\/+$/u, ''); let parsed: URL; try { parsed = new URL(normalized); } catch { throw new Error('服务器地址无效'); } if ( !['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash ) { throw new Error('服务器地址必须是纯 HTTP(S) 地址'); } const isLoopback = ['localhost', '127.0.0.1', '[::1]'].includes( parsed.hostname, ); if (parsed.protocol === 'http:' && !isLoopback) { throw new Error('非本机服务器必须使用 HTTPS'); } return normalized; } function readStoredClientServerSelection(): ClientServerSelection { const fallback: ClientServerSelection = { preset: defaultClientServerPreset(), customBaseUrl: '', }; if (typeof window === 'undefined') return fallback; try { const raw = window.localStorage.getItem( CLIENT_SERVER_SELECTION_STORAGE_KEY, ); if (!raw) return fallback; const parsed = JSON.parse(raw) as { preset?: unknown; customBaseUrl?: unknown; }; if (!isClientServerPreset(parsed.preset)) return fallback; const customBaseUrl = typeof parsed.customBaseUrl === 'string' ? parsed.customBaseUrl : ''; if (parsed.preset === 'custom') { normalizeClientServerBaseUrl(customBaseUrl); } return { preset: parsed.preset, customBaseUrl }; } catch { return fallback; } } export function getClientServerSelection() { return readStoredClientServerSelection(); } export function setClientServerSelection( selection: ClientServerSelection, ): ClientServerSelection { const next: ClientServerSelection = { preset: selection.preset, customBaseUrl: selection.preset === 'custom' ? normalizeClientServerBaseUrl(selection.customBaseUrl) : selection.customBaseUrl.trim(), }; if (typeof window !== 'undefined') { window.localStorage.setItem( CLIENT_SERVER_SELECTION_STORAGE_KEY, JSON.stringify(next), ); } return next; } export function resetClientServerSelectionForTests() { if (typeof window !== 'undefined') { window.localStorage.removeItem(CLIENT_SERVER_SELECTION_STORAGE_KEY); } } export function getClientServerBaseUrl( selection: ClientServerSelection = getClientServerSelection(), ) { if (selection.preset === 'release') return AGC_RELEASE_API_BASE_URL; if (selection.preset === 'dev') return AGC_DEVELOPMENT_API_BASE_URL; return normalizeClientServerBaseUrl(selection.customBaseUrl); } type ClientHttpContext = { isDevelopment: boolean; isTauri: boolean; pageProtocol: string; mode?: string; serverBaseUrl?: string; }; type ClientHttpTarget = { transport: 'web' | 'tauri-http'; url: string; }; function withAgcClientMarker(init: RequestInit): RequestInit { const headers = new Headers(init.headers); headers.set(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE); return { ...init, headers }; } function currentClientHttpContext(): ClientHttpContext { return { isDevelopment: import.meta.env.DEV, isTauri: typeof window !== 'undefined' && Boolean(window.__TAURI__), pageProtocol: typeof window === 'undefined' ? '' : window.location.protocol, mode: import.meta.env.MODE, }; } export function resolveClientHttpTarget( url: string, context: ClientHttpContext = currentClientHttpContext(), ): ClientHttpTarget { // Existing unit fixtures omit mode; retain the Vite-relative transport for // them while real development/release clients use the selected server. if ( !context.serverBaseUrl && (context.mode === 'test' || (!context.mode && context.isDevelopment)) ) { return { transport: 'web', url }; } const serverBaseUrl = context.serverBaseUrl ?? getClientServerBaseUrl(getClientServerSelection()); const target = new URL(url, `${serverBaseUrl}/`); if (target.origin !== serverBaseUrl) { throw new Error('请求目标不在当前选择的服务器范围内'); } if (!context.isTauri) { return { transport: 'web', url: target.toString() }; } return { transport: 'tauri-http', url: target.toString() }; } export async function fetchClientHttp( url: string, init: RequestInit, options: { serverBaseUrl?: string } = {}, ): Promise { const currentContext = currentClientHttpContext(); const serverBaseUrl = options.serverBaseUrl ? normalizeClientServerBaseUrl(options.serverBaseUrl) : undefined; // Unit fixtures intentionally use the relative Vite transport. Real clients bind every // auth transaction to the explicit origin captured before its first request. const target = resolveClientHttpTarget( url, currentContext.mode === 'test' ? currentContext : { ...currentContext, serverBaseUrl }, ); const markedInit = withAgcClientMarker(init); if (target.transport === 'tauri-http') { return tauriHttpFetch(target.url, markedInit); } return fetch(target.url, markedInit); }