import AsyncStorage from '@react-native-async-storage/async-storage' import { z } from 'zod' import { _notifyInitialized, _setInitializationSnapshot, _setPrivacyConsent, _setUserInfo, getInitializationSnapshot, getUserId as getCommonUserId, getUserInfo as getCommonUserInfo, initConfigFromRemote, isInitialized, type InternalInitOptions, type XuqmLoginOptions, type XuqmUserInfo, } from './config' import { DEFAULT_TENANT_PLATFORM_URL } from './constants' import { sha256Hex } from './crypto' import { XuqmError } from './errors' import { _clearAccessToken, _saveAccessToken, configureHttp } from './http' import { retryWithBackoff } from './retry' import { isCacheRecordFresh, openLocalCache, sealLocalCache } from './secureCache' import type { DecryptedConfig } from './crypto' let _initPromise: Promise | null = null let _resolvedConfigInitPromise: Promise | null = null let _resolvedConfigRequest: { config: DecryptedConfig options?: { debug?: boolean; bugCollectMode?: 'platform' | 'disabled' } } | null = null let _initializationKey: string | null = null let _sessionTransition: Promise = Promise.resolve() let _activeSessionKey: string | null = null const REMOTE_CONFIG_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1_000 const REMOTE_CONFIG_CACHE_PREFIX = '@xuqm_common:remote_config:v1:' const flatRemoteConfigSchema = z.object({ apiUrl: z.string().optional(), imApiUrl: z.string().optional(), imWsUrl: z.string().optional(), fileServiceUrl: z.string().optional(), imEnabled: z.boolean().optional(), pushEnabled: z.boolean().optional(), bugCollectApiUrl: z.string().optional(), bugCollectEnabled: z.boolean().optional(), updateRequiresLogin: z.boolean().optional(), }) const remoteConfigResponseSchema = z .object({ apiUrl: z.string().optional(), imApiUrl: z.string().optional(), imWsUrl: z.string().optional(), fileServiceUrl: z.string().optional(), imEnabled: z.boolean().optional(), pushEnabled: z.boolean().optional(), bugCollectApiUrl: z.string().optional(), bugCollectEnabled: z.boolean().optional(), updateRequiresLogin: z.boolean().optional(), allowAnonymousUpdateCheck: z.boolean().optional(), features: z .object({ im: z.boolean().optional(), push: z.boolean().optional(), bugCollect: z.boolean().optional(), }) .optional(), }) .loose() interface CachedRemoteConfig { cachedAt: number remote: { apiUrl?: string imApiUrl?: string imWsUrl?: string fileServiceUrl?: string imEnabled?: boolean pushEnabled?: boolean bugCollectApiUrl?: string bugCollectEnabled?: boolean updateRequiresLogin?: boolean } } function runtimePlatform(): 'ANDROID' | 'IOS' { try { // 延迟加载避免 common 的纯 Node 工具与测试被 React Native 的 Flow 源码绑定。 // Metro 仍能静态发现该依赖,移动端运行时以 React Native Platform 为唯一事实。 const reactNative = require('react-native') as { Platform?: { OS?: string } } return reactNative.Platform?.OS === 'ios' ? 'IOS' : 'ANDROID' } catch { return 'ANDROID' } } function initializationKey(options: InternalInitOptions): string { return `${options.appKey.trim()}\n${(options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL).replace(/\/$/, '')}\n${options.packageName ?? ''}` } function storageKey(options: InternalInitOptions): string { return `${REMOTE_CONFIG_CACHE_PREFIX}${sha256Hex(initializationKey(options))}` } async function readCachedRemoteConfig( options: InternalInitOptions, signingKey?: string, ): Promise { if (!signingKey) return null try { const encrypted = await AsyncStorage.getItem(storageKey(options)) if (!encrypted) return null const raw = openLocalCache(encrypted, signingKey, initializationKey(options)) const cached = JSON.parse(raw) as CachedRemoteConfig const parsedRemote = flatRemoteConfigSchema.safeParse(cached?.remote) if ( !cached || !isCacheRecordFresh(cached.cachedAt, Date.now(), REMOTE_CONFIG_CACHE_TTL_MS) || !parsedRemote.success ) { await AsyncStorage.removeItem(storageKey(options)) return null } return { cachedAt: cached.cachedAt, remote: parsedRemote.data } } catch { await AsyncStorage.removeItem(storageKey(options)).catch(() => undefined) return null } } async function writeCachedRemoteConfig( options: InternalInitOptions, remote: CachedRemoteConfig['remote'], signingKey?: string, ): Promise { if (!signingKey) return try { const encrypted = sealLocalCache( JSON.stringify({ cachedAt: Date.now(), remote } satisfies CachedRemoteConfig), signingKey, initializationKey(options), ) await AsyncStorage.setItem(storageKey(options), encrypted) } catch { // 安全随机数或沙箱存储不可用时跳过缓存,不能降级为明文,也不能阻断初始化。 } } function startInitialization(options: InternalInitOptions, signingKey?: string): Promise { const nextKey = initializationKey(options) if (isInitialized()) { if (_initializationKey !== nextKey) { return Promise.reject(new Error('[XuqmSDK] Already initialized with a different config.')) } return Promise.resolve() } if (_initPromise) { if (_initializationKey !== nextKey) { return Promise.reject( new Error('[XuqmSDK] Initialization is already running with a different config.'), ) } return _initPromise } _initializationKey = nextKey _initPromise = (async () => { _setInitializationSnapshot({ state: 'initializing' }) const platformUrl = (options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL).replace(/\/$/, '') const configParams = new URLSearchParams({ appKey: options.appKey, platform: runtimePlatform(), }) if (options.packageName) configParams.set('packageName', options.packageName) const configUrl = `${platformUrl}/api/sdk/config?${configParams.toString()}` let remote: CachedRemoteConfig['remote'] let source: 'remote' | 'cache' = 'remote' try { const res = await retryWithBackoff( async () => { const response = await fetch(configUrl) if (response.ok) return response if (response.status >= 500) { throw new Error(`[XuqmSDK] Platform config request failed: ${response.status}`) } throw new XuqmError( 'XUQM_NOT_READY', `[XuqmSDK] Platform config request failed: ${response.status}`, ) }, { maxAttempts: 3, shouldRetry: error => !(error instanceof XuqmError), }, ) const json = (await res.json()) as Record const raw = remoteConfigResponseSchema.parse(json.data ?? json) const features = raw.features ?? {} remote = { apiUrl: raw.apiUrl, imApiUrl: raw.imApiUrl, imWsUrl: raw.imWsUrl, fileServiceUrl: raw.fileServiceUrl, imEnabled: features.im ?? raw.imEnabled, pushEnabled: features.push ?? raw.pushEnabled, bugCollectApiUrl: raw.bugCollectApiUrl, bugCollectEnabled: features.bugCollect ?? raw.bugCollectEnabled, updateRequiresLogin: raw.updateRequiresLogin ?? (raw.allowAnonymousUpdateCheck === undefined ? undefined : !raw.allowAnonymousUpdateCheck), } await writeCachedRemoteConfig(options, remote, signingKey) } catch (error) { const cached = await readCachedRemoteConfig(options, signingKey) if (!cached) { throw new XuqmError( 'XUQM_NOT_READY', '[XuqmSDK] Tenant configuration is unavailable and no valid cache exists.', error, ) } remote = cached.remote source = 'cache' } initConfigFromRemote({ ...options, platformUrl, configSource: source }, remote, signingKey) // Xuqm 平台接口始终回到租户平台。remote.apiUrl 是业务扩展服务地址, // 二者职责不同;混用会把版本检查错误地发送到 App 自身的业务服务。 configureHttp({ baseUrl: platformUrl, debug: options.debug, }) await _notifyInitialized() _setInitializationSnapshot({ state: source === 'remote' ? 'ready' : 'degraded', source, }) })().catch(error => { _initPromise = null _initializationKey = null const structured = error instanceof XuqmError ? error : new XuqmError('XUQM_NOT_READY', '[XuqmSDK] Initialization failed.', error) _setInitializationSnapshot({ state: 'failed', error: { code: structured.code, message: structured.message }, }) throw structured }) return _initPromise } function enqueueSessionTransition(action: () => Promise): Promise { const next = _sessionTransition.catch(() => undefined).then(action) _sessionTransition = next return next } export const XuqmSDK = { /** * 等待初始化完成。在任意子 SDK 内部调用以确保 XuqmSDK 已就绪。 */ async awaitInitialization(): Promise { await awaitInitialization() }, getInitializationState() { return getInitializationSnapshot() }, /** * 宿主隐私声明的唯一同步入口。SDK 不弹隐私提示,也不自行推断用户是否同意。 */ async setPrivacyConsent(consented: boolean): Promise { await _setPrivacyConsent(consented) }, getUserId(): string | null { return getCommonUserId() }, /** * 用户认证核心枢纽。登录成功后调用一次,所有已安装的扩展 SDK 自动同步状态。 * 登出时传 null,触发所有子 SDK 登出。 */ async login(options: XuqmLoginOptions): Promise { const userId = options.userId.trim() if (!userId) throw new Error('[XuqmSDK] userId is required.') const accessToken = options.accessToken?.trim() || undefined const normalized = { ...options, accessToken, userId } const nextSessionKey = JSON.stringify(normalized) await enqueueSessionTransition(async () => { await awaitInitialization() if (_activeSessionKey === nextSessionKey && getCommonUserInfo()) return if (accessToken) await _saveAccessToken(accessToken) else await _clearAccessToken() const { accessToken: _accessToken, ...userInfo } = normalized await _setUserInfo(userInfo) _activeSessionKey = nextSessionKey }) }, async logout(): Promise { await enqueueSessionTransition(async () => { await _clearAccessToken() if (getCommonUserInfo()) await _setUserInfo(null) _activeSessionKey = null }) }, getUserInfo(): XuqmUserInfo | null { return getCommonUserInfo() }, } export async function awaitInitialization(): Promise { if (isInitialized()) return const pendingInitialization = _resolvedConfigInitPromise ?? _initPromise ?? (_resolvedConfigRequest ? _initializeFromResolvedConfig(_resolvedConfigRequest.config, _resolvedConfigRequest.options) : null) if (!pendingInitialization) { throw new XuqmError( 'XUQM_NOT_READY', '[XuqmSDK] Automatic initialization did not start. Place exactly one .xuqmconfig file in src/assets/config and wrap Metro with withXuqmConfig().', ) } await pendingInitialization } /** Metro 自动初始化专用入口;配置已在可信构建进程中完成解密和格式校验。 */ export function _initializeFromResolvedConfig( config: DecryptedConfig, options?: { debug?: boolean; bugCollectMode?: 'platform' | 'disabled' }, ): Promise { _resolvedConfigRequest = { config, options } if (_resolvedConfigInitPromise) return _resolvedConfigInitPromise _resolvedConfigInitPromise = (async () => { const platformUrl = config.serverUrl await startInitialization( { appKey: config.appKey, platformUrl, debug: options?.debug, bugCollectMode: options?.bugCollectMode, packageName: runtimePlatform() === 'IOS' ? (config.iosBundleId ?? config.packageName) : config.packageName, }, config.signingKey, ) })().catch(error => { _resolvedConfigInitPromise = null throw error }) return _resolvedConfigInitPromise }