2026-07-26 23:47:30 +08:00
|
|
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
|
|
|
import { z } from 'zod'
|
2026-07-17 13:50:30 +08:00
|
|
|
import {
|
|
|
|
|
_notifyInitialized,
|
2026-07-26 23:47:30 +08:00
|
|
|
_setInitializationSnapshot,
|
|
|
|
|
_setPrivacyConsent,
|
2026-07-17 13:50:30 +08:00
|
|
|
_setUserInfo,
|
2026-07-26 23:47:30 +08:00
|
|
|
getInitializationSnapshot,
|
2026-07-17 13:50:30 +08:00
|
|
|
getUserId as getCommonUserId,
|
|
|
|
|
getUserInfo as getCommonUserInfo,
|
|
|
|
|
initConfigFromRemote,
|
|
|
|
|
isInitialized,
|
2026-07-26 23:47:30 +08:00
|
|
|
type InternalInitOptions,
|
2026-07-17 13:50:30 +08:00
|
|
|
type XuqmLoginOptions,
|
|
|
|
|
type XuqmUserInfo,
|
|
|
|
|
} from './config'
|
2026-06-15 10:57:55 +08:00
|
|
|
import { DEFAULT_TENANT_PLATFORM_URL } from './constants'
|
2026-07-26 23:47:30 +08:00
|
|
|
import { sha256Hex } from './crypto'
|
|
|
|
|
import { XuqmError } from './errors'
|
2026-07-17 13:50:30 +08:00
|
|
|
import { _clearAccessToken, _saveAccessToken, configureHttp } from './http'
|
2026-07-26 23:47:30 +08:00
|
|
|
import { retryWithBackoff } from './retry'
|
|
|
|
|
import { isCacheRecordFresh, openLocalCache, sealLocalCache } from './secureCache'
|
2026-07-20 19:31:43 +08:00
|
|
|
import type { DecryptedConfig } from './crypto'
|
2026-04-24 16:16:31 +08:00
|
|
|
|
2026-05-22 17:57:01 +08:00
|
|
|
let _initPromise: Promise<void> | null = null
|
2026-07-20 19:31:43 +08:00
|
|
|
let _resolvedConfigInitPromise: Promise<void> | null = null
|
|
|
|
|
let _resolvedConfigRequest: {
|
|
|
|
|
config: DecryptedConfig
|
2026-07-26 23:47:30 +08:00
|
|
|
options?: { debug?: boolean; bugCollectMode?: 'platform' | 'disabled' }
|
2026-07-18 06:59:57 +08:00
|
|
|
} | null = null
|
2026-07-17 13:50:30 +08:00
|
|
|
let _initializationKey: string | null = null
|
|
|
|
|
let _sessionTransition: Promise<void> = Promise.resolve()
|
|
|
|
|
let _activeSessionKey: string | null = null
|
2026-07-26 23:47:30 +08:00
|
|
|
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 ?? ''}`
|
|
|
|
|
}
|
2026-05-22 17:57:01 +08:00
|
|
|
|
2026-07-26 23:47:30 +08:00
|
|
|
function storageKey(options: InternalInitOptions): string {
|
|
|
|
|
return `${REMOTE_CONFIG_CACHE_PREFIX}${sha256Hex(initializationKey(options))}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function readCachedRemoteConfig(
|
|
|
|
|
options: InternalInitOptions,
|
|
|
|
|
signingKey?: string,
|
|
|
|
|
): Promise<CachedRemoteConfig | null> {
|
|
|
|
|
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<void> {
|
|
|
|
|
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 {
|
|
|
|
|
// 安全随机数或沙箱存储不可用时跳过缓存,不能降级为明文,也不能阻断初始化。
|
|
|
|
|
}
|
2026-05-22 17:57:01 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-26 23:47:30 +08:00
|
|
|
function startInitialization(options: InternalInitOptions, signingKey?: string): Promise<void> {
|
2026-07-17 13:50:30 +08:00
|
|
|
const nextKey = initializationKey(options)
|
|
|
|
|
if (isInitialized()) {
|
|
|
|
|
if (_initializationKey !== nextKey) {
|
|
|
|
|
return Promise.reject(new Error('[XuqmSDK] Already initialized with a different config.'))
|
|
|
|
|
}
|
|
|
|
|
return Promise.resolve()
|
2026-05-22 17:57:01 +08:00
|
|
|
}
|
2026-07-17 13:50:30 +08:00
|
|
|
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 () => {
|
2026-07-26 23:47:30 +08:00
|
|
|
_setInitializationSnapshot({ state: 'initializing' })
|
2026-07-20 19:31:43 +08:00
|
|
|
const platformUrl = (options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL).replace(/\/$/, '')
|
2026-07-26 23:47:30 +08:00
|
|
|
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<string, unknown>
|
|
|
|
|
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'
|
2026-07-17 13:50:30 +08:00
|
|
|
}
|
2026-07-26 23:47:30 +08:00
|
|
|
initConfigFromRemote({ ...options, platformUrl, configSource: source }, remote, signingKey)
|
2026-07-20 19:31:43 +08:00
|
|
|
// Xuqm 平台接口始终回到租户平台。remote.apiUrl 是业务扩展服务地址,
|
|
|
|
|
// 二者职责不同;混用会把版本检查错误地发送到 App 自身的业务服务。
|
2026-07-17 13:50:30 +08:00
|
|
|
configureHttp({
|
2026-07-20 19:31:43 +08:00
|
|
|
baseUrl: platformUrl,
|
2026-07-17 13:50:30 +08:00
|
|
|
debug: options.debug,
|
|
|
|
|
})
|
|
|
|
|
await _notifyInitialized()
|
2026-07-26 23:47:30 +08:00
|
|
|
_setInitializationSnapshot({
|
|
|
|
|
state: source === 'remote' ? 'ready' : 'degraded',
|
|
|
|
|
source,
|
|
|
|
|
})
|
2026-07-17 13:50:30 +08:00
|
|
|
})().catch(error => {
|
|
|
|
|
_initPromise = null
|
|
|
|
|
_initializationKey = null
|
2026-07-26 23:47:30 +08:00
|
|
|
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
|
2026-07-17 13:50:30 +08:00
|
|
|
})
|
|
|
|
|
return _initPromise
|
2026-05-22 17:57:01 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
function enqueueSessionTransition(action: () => Promise<void>): Promise<void> {
|
|
|
|
|
const next = _sessionTransition.catch(() => undefined).then(action)
|
|
|
|
|
_sessionTransition = next
|
|
|
|
|
return next
|
2026-06-15 10:04:37 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-24 16:16:31 +08:00
|
|
|
export const XuqmSDK = {
|
|
|
|
|
/**
|
2026-07-26 23:47:30 +08:00
|
|
|
* 等待初始化完成。在任意子 SDK 内部调用以确保 XuqmSDK 已就绪。
|
2026-04-25 16:41:19 +08:00
|
|
|
*/
|
2026-07-26 23:47:30 +08:00
|
|
|
async awaitInitialization(): Promise<void> {
|
|
|
|
|
await awaitInitialization()
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
getInitializationState() {
|
|
|
|
|
return getInitializationSnapshot()
|
2026-06-15 01:44:20 +08:00
|
|
|
},
|
|
|
|
|
|
2026-05-22 17:57:01 +08:00
|
|
|
/**
|
2026-07-26 23:47:30 +08:00
|
|
|
* 宿主隐私声明的唯一同步入口。SDK 不弹隐私提示,也不自行推断用户是否同意。
|
2026-05-22 17:57:01 +08:00
|
|
|
*/
|
2026-07-26 23:47:30 +08:00
|
|
|
async setPrivacyConsent(consented: boolean): Promise<void> {
|
|
|
|
|
await _setPrivacyConsent(consented)
|
2026-04-24 16:16:31 +08:00
|
|
|
},
|
2026-04-28 16:55:12 +08:00
|
|
|
|
|
|
|
|
getUserId(): string | null {
|
|
|
|
|
return getCommonUserId()
|
|
|
|
|
},
|
2026-06-15 01:44:20 +08:00
|
|
|
|
|
|
|
|
/**
|
2026-07-17 13:50:30 +08:00
|
|
|
* 用户认证核心枢纽。登录成功后调用一次,所有已安装的扩展 SDK 自动同步状态。
|
2026-06-15 10:57:55 +08:00
|
|
|
* 登出时传 null,触发所有子 SDK 登出。
|
2026-06-15 01:44:20 +08:00
|
|
|
*/
|
2026-07-17 13:50:30 +08:00
|
|
|
async login(options: XuqmLoginOptions): Promise<void> {
|
|
|
|
|
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<void> {
|
|
|
|
|
await enqueueSessionTransition(async () => {
|
|
|
|
|
await _clearAccessToken()
|
|
|
|
|
if (getCommonUserInfo()) await _setUserInfo(null)
|
|
|
|
|
_activeSessionKey = null
|
|
|
|
|
})
|
2026-06-15 01:44:20 +08:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
getUserInfo(): XuqmUserInfo | null {
|
|
|
|
|
return getCommonUserInfo()
|
|
|
|
|
},
|
2026-04-24 16:16:31 +08:00
|
|
|
}
|
2026-05-22 17:57:01 +08:00
|
|
|
|
|
|
|
|
export async function awaitInitialization(): Promise<void> {
|
|
|
|
|
if (isInitialized()) return
|
2026-07-18 06:59:57 +08:00
|
|
|
const pendingInitialization =
|
2026-07-20 19:31:43 +08:00
|
|
|
_resolvedConfigInitPromise ??
|
2026-07-18 06:59:57 +08:00
|
|
|
_initPromise ??
|
2026-07-20 19:31:43 +08:00
|
|
|
(_resolvedConfigRequest
|
|
|
|
|
? _initializeFromResolvedConfig(_resolvedConfigRequest.config, _resolvedConfigRequest.options)
|
2026-07-18 06:59:57 +08:00
|
|
|
: null)
|
2026-07-17 13:50:30 +08:00
|
|
|
if (!pendingInitialization) {
|
2026-07-26 23:47:30 +08:00
|
|
|
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().',
|
2026-07-17 13:50:30 +08:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
await pendingInitialization
|
2026-05-22 17:57:01 +08:00
|
|
|
}
|
2026-06-19 01:27:56 +08:00
|
|
|
|
2026-07-20 19:31:43 +08:00
|
|
|
/** Metro 自动初始化专用入口;配置已在可信构建进程中完成解密和格式校验。 */
|
|
|
|
|
export function _initializeFromResolvedConfig(
|
|
|
|
|
config: DecryptedConfig,
|
2026-07-26 23:47:30 +08:00
|
|
|
options?: { debug?: boolean; bugCollectMode?: 'platform' | 'disabled' },
|
2026-07-17 13:50:30 +08:00
|
|
|
): Promise<void> {
|
2026-07-20 19:31:43 +08:00
|
|
|
_resolvedConfigRequest = { config, options }
|
|
|
|
|
if (_resolvedConfigInitPromise) return _resolvedConfigInitPromise
|
2026-07-17 13:50:30 +08:00
|
|
|
|
2026-07-20 19:31:43 +08:00
|
|
|
_resolvedConfigInitPromise = (async () => {
|
2026-07-26 23:47:30 +08:00
|
|
|
const platformUrl = config.serverUrl
|
2026-07-17 13:50:30 +08:00
|
|
|
await startInitialization(
|
2026-07-26 23:47:30 +08:00
|
|
|
{
|
|
|
|
|
appKey: config.appKey,
|
|
|
|
|
platformUrl,
|
|
|
|
|
debug: options?.debug,
|
|
|
|
|
bugCollectMode: options?.bugCollectMode,
|
|
|
|
|
packageName:
|
|
|
|
|
runtimePlatform() === 'IOS'
|
|
|
|
|
? (config.iosBundleId ?? config.packageName)
|
|
|
|
|
: config.packageName,
|
|
|
|
|
},
|
2026-07-20 19:31:43 +08:00
|
|
|
config.signingKey,
|
2026-07-17 13:50:30 +08:00
|
|
|
)
|
|
|
|
|
})().catch(error => {
|
2026-07-20 19:31:43 +08:00
|
|
|
_resolvedConfigInitPromise = null
|
2026-07-17 13:50:30 +08:00
|
|
|
throw error
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-20 19:31:43 +08:00
|
|
|
return _resolvedConfigInitPromise
|
2026-06-19 01:27:56 +08:00
|
|
|
}
|