"use client" import { IS_MOBILE_BUILD, SUITE_APP, appScheme, useNativeRuntime } from "@/lib/platform" import type { OidcRuntimeConfig, RuntimeConfig } from "./types" export type { RuntimeConfig, OidcRuntimeConfig } from "./types" export const RUNTIME_CONFIG_STORAGE_KEY = "ulti-runtime-config" let current: RuntimeConfig | null = null const listeners = new Set<(cfg: RuntimeConfig | null) => void>() /** Native redirect URI for this app shell, e.g. `ultimail://oauth/callback`. */ export function nativeRedirectUri(): string { return `${appScheme()}://oauth/callback` } function trimSlash(url: string): string { return url.endsWith("/") ? url : `${url}/` } function stripTrailingSlash(url: string): string { return url.replace(/\/+$/, "") } function wsFromOrigin(origin: string): string { try { const u = new URL(origin) const proto = u.protocol === "https:" ? "wss:" : "ws:" return `${proto}//${u.host}/ws` } catch { return `${origin.replace(/^http/, "ws")}/ws` } } // --------------------------------------------------------------------------- // Current config accessors // --------------------------------------------------------------------------- export function getRuntimeConfig(): RuntimeConfig | null { if (current) return current if (typeof window !== "undefined" && useNativeRuntime()) { current = loadPersistedRuntimeConfig() } return current } export function setRuntimeConfig(cfg: RuntimeConfig | null) { current = cfg if (typeof window !== "undefined") { try { if (cfg) { localStorage.setItem(RUNTIME_CONFIG_STORAGE_KEY, JSON.stringify(cfg)) } else { localStorage.removeItem(RUNTIME_CONFIG_STORAGE_KEY) } } catch { /* private mode / quota */ } } for (const fn of listeners) fn(cfg) } export function onRuntimeConfigChange( fn: (cfg: RuntimeConfig | null) => void ): () => void { listeners.add(fn) return () => listeners.delete(fn) } export function loadPersistedRuntimeConfig(): RuntimeConfig | null { if (typeof window === "undefined") return null try { const raw = localStorage.getItem(RUNTIME_CONFIG_STORAGE_KEY) if (!raw) return null return JSON.parse(raw) as RuntimeConfig } catch { return null } } // --------------------------------------------------------------------------- // Resolved values (web env fallback vs native runtime config) // --------------------------------------------------------------------------- /** Backend base URL (absolute on native, proxied `/api/v1` on web). */ export function getApiBaseUrl(): string { if (useNativeRuntime()) { const cfg = getRuntimeConfig() if (cfg) return cfg.apiBaseUrl } return process.env.NEXT_PUBLIC_API_URL ?? "/api/v1" } /** Realtime WS URL. */ export function getWsUrl(): string | null { if (useNativeRuntime()) { const cfg = getRuntimeConfig() if (cfg) return cfg.wsUrl } if (process.env.NEXT_PUBLIC_WS_URL) return process.env.NEXT_PUBLIC_WS_URL if (typeof window !== "undefined") { const proto = window.location.protocol === "https:" ? "wss:" : "ws:" return `${proto}//${window.location.host}/ws` } return null } export function getOnlyOfficeUrl(): string | undefined { if (useNativeRuntime()) { const cfg = getRuntimeConfig() if (cfg?.onlyOfficeUrl) return cfg.onlyOfficeUrl } return process.env.NEXT_PUBLIC_ONLYOFFICE_URL } export function getAiOrigin(): string | undefined { if (useNativeRuntime()) { const cfg = getRuntimeConfig() if (cfg?.aiOrigin) return cfg.aiOrigin } return process.env.NEXT_PUBLIC_AI_ORIGIN } // --------------------------------------------------------------------------- // Discovery: derive a full RuntimeConfig from an instance origin (server picker) // --------------------------------------------------------------------------- type OidcDiscoveryDoc = { authorization_endpoint: string token_endpoint: string end_session_endpoint?: string issuer?: string } /** Issuer path segment within an instance, configurable for exotic setups. */ function issuerPath(): string { return ( process.env.NEXT_PUBLIC_OIDC_ISSUER_PATH ?? "/auth/application/o/ulti/" ) } function mobileClientId(): string { // Shared public PKCE client across the suite enables cross-app SSO. return process.env.NEXT_PUBLIC_OIDC_CLIENT_ID ?? "ulti-mobile" } export class RuntimeConfigError extends Error {} /** * Build a RuntimeConfig for an instance origin by running OIDC discovery. * Used by the server picker (both the UltiSpace preset and self-hosted URLs). */ export async function deriveRuntimeConfig( rawOrigin: string, opts?: { label?: string } ): Promise { let origin: string try { const u = new URL( rawOrigin.includes("://") ? rawOrigin : `https://${rawOrigin}` ) origin = stripTrailingSlash(u.origin) } catch { throw new RuntimeConfigError(`URL d'instance invalide : ${rawOrigin}`) } const issuer = trimSlash(`${origin}${issuerPath()}`) const discoveryUrl = `${issuer}.well-known/openid-configuration` let doc: OidcDiscoveryDoc try { const res = await fetch(discoveryUrl, { headers: { Accept: "application/json" } }) if (!res.ok) { throw new RuntimeConfigError( `Découverte OIDC échouée (${res.status}) sur ${origin}` ) } doc = (await res.json()) as OidcDiscoveryDoc } catch (err) { if (err instanceof RuntimeConfigError) throw err throw new RuntimeConfigError( `Impossible de joindre ${origin}. Vérifie l'URL et ta connexion.` ) } const oidc: OidcRuntimeConfig = { issuer: doc.issuer ? trimSlash(doc.issuer) : issuer, clientId: mobileClientId(), redirectUri: nativeRedirectUri(), authorizationEndpoint: doc.authorization_endpoint, tokenEndpoint: doc.token_endpoint, endSessionEndpoint: doc.end_session_endpoint ?? `${issuer}end-session/`, enrollmentUrl: `${origin}/auth/if/flow/ulti-enrollment/`, } return { label: opts?.label ?? origin.replace(/^https?:\/\//, ""), instanceOrigin: origin, apiBaseUrl: `${origin}/api/v1`, wsUrl: wsFromOrigin(origin), oidc, aiOrigin: origin, aiPublicPath: process.env.NEXT_PUBLIC_AI_PUBLIC_PATH ?? "/ai", onlyOfficeUrl: `${origin}/office`, hocuspocusUrl: `${wsFromOrigin(origin).replace(/\/ws$/, "")}/collab`, } } /** Origin of the hosted UltiSpace offering (preset option in the picker). */ export function ultiSpaceOrigin(): string { return process.env.NEXT_PUBLIC_ULTISPACE_ORIGIN ?? "https://space.ulti.app" } /** Whether a server has been selected yet (native only). */ export function hasRuntimeConfig(): boolean { return getRuntimeConfig() !== null } /** Convenience used by debug/logging. */ export const RUNTIME_INFO = { isMobileBuild: IS_MOBILE_BUILD, suiteApp: SUITE_APP, }