feat(sdk): 按跨平台规范重构所有 SDK 包接口

- common: setUserInfo 成为认证枢纽,分发订阅者;移除 init()/initializeFromLicense();
  initWithConfigFile 解密后调用 initialize() 拉取远程服务配置;XuqmConfig 增加服务开通标志
- push: 移除独立 initialize/registerToken/unregisterToken 等方法;
  改由 _registerUserInfoHandler 订阅,setUserInfo 时自动完成厂商检测+设备注册+token上报
- im: 新增 refreshToken();注册 setUserInfo 订阅,自动登录/断连;移除对 PushSDK 的直接调用
- update: 支持 registerPlugins(批量) + registerPlugin(向后兼容);
  版本号自动从 AsyncStorage 读取,移除 version 字段;新增 updatePlugin(一步完成)、
  downloadAndInstallApk、setBundleCallbacks 注入宿主写入/重载能力
- license: 移除独立 initialize/initializeFromFile;依赖 getConfig() 获取 appKey/licenseUrl
- 顶层 src/sdk.ts: 移除旧 login/logout 包装层,直接重导出 CommonXuqmSDK

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-06-15 10:57:55 +08:00
父节点 bed221f536
当前提交 b1e8e307d2
共有 12 个文件被更改,包括 445 次插入493 次删除

查看文件

@ -8,32 +8,59 @@ export interface XuqmConfig {
appKey: string
apiUrl: string
imWsUrl: string
fileServiceUrl: string // fetched from remote config
fileServiceUrl: string
licenseUrl: string
debug: boolean
// 服务开通状态(由平台远程配置决定)
imEnabled: boolean
pushEnabled: boolean
licenseEnabled: boolean
}
let _config: XuqmConfig | null = null
let _userId: string | null = null
export interface XuqmUserInfo {
userId?: string
userId: string
userSig?: string // IM 登录凭证;未开通 IM 时可不传
name?: string
email?: string
phone?: string
avatar?: string
}
// ─── Internal state ────────────────────────────────────────────────────────────
let _config: XuqmConfig | null = null
let _userId: string | null = null
let _userInfo: XuqmUserInfo | null = null
const _userInfoHandlers: Array<(info: XuqmUserInfo | null) => void> = []
// ─── Config ────────────────────────────────────────────────────────────────────
export interface XuqmRemoteConfig {
imApiUrl?: string
apiUrl?: string
imWsUrl?: string
fileServiceUrl?: string
licenseUrl?: string
imEnabled?: boolean
pushEnabled?: boolean
licenseEnabled?: boolean
}
export function initConfigFromRemote(
options: XuqmInitOptions,
remote: { imWsUrl: string; fileServiceUrl: string; apiUrl: string },
remote: XuqmRemoteConfig,
): void {
const apiUrl = remote.imApiUrl ?? remote.apiUrl ?? ''
_config = {
appKey: options.appKey,
apiUrl: remote.apiUrl,
imWsUrl: remote.imWsUrl,
fileServiceUrl: remote.fileServiceUrl,
debug: options.debug ?? false,
appKey: options.appKey,
apiUrl,
imWsUrl: remote.imWsUrl ?? '',
fileServiceUrl: remote.fileServiceUrl ?? apiUrl,
licenseUrl: remote.licenseUrl ?? apiUrl,
debug: options.debug ?? false,
imEnabled: remote.imEnabled ?? !!remote.imWsUrl,
pushEnabled: remote.pushEnabled ?? true,
licenseEnabled: remote.licenseEnabled ?? false,
}
}
@ -42,6 +69,12 @@ export function getConfig(): XuqmConfig {
return _config
}
export function isInitialized(): boolean {
return _config !== null
}
// ─── UserId ────────────────────────────────────────────────────────────────────
export function setUserId(userId: string | null): void {
_userId = userId
}
@ -50,15 +83,17 @@ export function getUserId(): string | null {
return _userId
}
export function isInitialized(): boolean {
return _config !== null
// ─── UserInfo + subscribers ────────────────────────────────────────────────────
export function _registerUserInfoHandler(handler: (info: XuqmUserInfo | null) => void): void {
_userInfoHandlers.push(handler)
}
export function setUserInfo(info: XuqmUserInfo | null): void {
_userInfo = info
// Sync userId for backward compatibility
if (info?.userId) {
_userId = info.userId
_userId = info?.userId ?? null
for (const handler of _userInfoHandlers) {
try { handler(info) } catch { /* sub-SDK errors must not break the chain */ }
}
}

查看文件

@ -3,7 +3,15 @@ import './autoInit'
export { XuqmSDK } from './sdk'
export type { XuqmInitOptions, XuqmConfig, XuqmUserInfo } from './config'
export { getConfig, isInitialized, setUserId, getUserId } from './config'
export {
getConfig,
isInitialized,
setUserId,
getUserId,
setUserInfo,
getUserInfo,
_registerUserInfoHandler,
} from './config'
export { awaitInitialization } from './sdk'
export { apiRequest, configureHttp, _getToken, _saveToken, _clearToken } from './http'
export { DEFAULT_TENANT_PLATFORM_URL, DEFAULT_IM_WS_URL } from './constants'

查看文件

@ -1,5 +1,5 @@
import { initConfigFromRemote, isInitialized, type XuqmInitOptions, type XuqmUserInfo, setUserId as setCommonUserId, getUserId as getCommonUserId, setUserInfo as setCommonUserInfo, getUserInfo as getCommonUserInfo } from './config'
import { DEFAULT_IM_WS_URL, DEFAULT_TENANT_PLATFORM_URL } from './constants'
import { initConfigFromRemote, isInitialized, type XuqmInitOptions, type XuqmUserInfo, setUserInfo as setCommonUserInfo, getUserInfo as getCommonUserInfo, getUserId as getCommonUserId } from './config'
import { DEFAULT_TENANT_PLATFORM_URL } from './constants'
import { configureHttp } from './http'
import { decryptConfigFile } from './configCrypto'
@ -36,8 +36,14 @@ function markInitializationFailed(e: unknown): void {
export const XuqmSDK = {
/**
* @param options.appKey - Your application key (from the tenant platform)
* @param options.debug - Enable verbose logging
* B
*
* appKey IMPush URL
*
*
* @param options.appKey
* @param options.platformUrl 使
* @param options.debug
*/
async initialize(options: XuqmInitOptions): Promise<void> {
if (isInitialized()) return
@ -46,15 +52,20 @@ export const XuqmSDK = {
const configUrl = `${baseUrl}/api/sdk/config?appKey=${options.appKey}`
try {
const res = await fetch(configUrl)
const json = await res.json()
const remote = json.data ?? json
if (!res.ok) throw new Error(`[XuqmSDK] Platform config request failed: ${res.status}`)
const json = await res.json() as Record<string, unknown>
const remote = (json.data ?? json) as Record<string, unknown>
initConfigFromRemote(options, {
imWsUrl: remote.imWsUrl,
fileServiceUrl: remote.fileServiceUrl,
apiUrl: remote.imApiUrl ?? DEFAULT_TENANT_PLATFORM_URL,
apiUrl: remote.apiUrl as string | undefined,
imWsUrl: remote.imWsUrl as string | undefined,
fileServiceUrl: remote.fileServiceUrl as string | undefined,
licenseUrl: remote.licenseUrl as string | undefined,
imEnabled: remote.imEnabled as boolean | undefined,
pushEnabled: remote.pushEnabled as boolean | undefined,
licenseEnabled: remote.licenseEnabled as boolean | undefined,
})
configureHttp({
baseUrl: remote.imApiUrl ?? DEFAULT_TENANT_PLATFORM_URL,
baseUrl: (remote.apiUrl as string | undefined) ?? baseUrl,
debug: options.debug,
})
markInitialized()
@ -65,77 +76,36 @@ export const XuqmSDK = {
},
/**
* @param options.appKey - Your application key (from the tenant platform)
* @param options.debug - Enable verbose logging
*/
init(options: XuqmInitOptions): void {
if (isInitialized()) return
initConfigFromRemote(options, {
imWsUrl: DEFAULT_IM_WS_URL,
fileServiceUrl: DEFAULT_TENANT_PLATFORM_URL,
apiUrl: DEFAULT_TENANT_PLATFORM_URL,
})
configureHttp({
baseUrl: DEFAULT_TENANT_PLATFORM_URL,
debug: options.debug,
})
markInitialized()
},
/**
* Initialize from a decrypted license file object.
*/
initializeFromLicense(file: { appKey: string; baseUrl?: string; serverUrl?: string }, options?: { debug?: boolean }): void {
if (isInitialized()) return
const serverUrl = file.serverUrl || file.baseUrl || DEFAULT_TENANT_PLATFORM_URL
initConfigFromRemote({ appKey: file.appKey, debug: options?.debug }, {
imWsUrl: DEFAULT_IM_WS_URL,
fileServiceUrl: serverUrl,
apiUrl: serverUrl,
})
configureHttp({ baseUrl: serverUrl, debug: options?.debug })
markInitialized()
},
/**
* SDK
* A SDK
*
* 宿 .xuqmconfig SDK
* XUQM-CONFIG-V1 XUQM-LICENSE-V1
* 宿 .xuqmconfig autoInit SDK initialize()
* index.ts export
*
* @param encryptedContent
* @param options.debug
*
* @example
* // common bundle 入口
* import config from './assets/app.xuqmconfig'
* await XuqmSDK.initWithConfigFile(config)
* @param encryptedContent XUQM-CONFIG-V1 XUQM-LICENSE-V1
*/
async initWithConfigFile(encryptedContent: string, options?: { debug?: boolean }): Promise<void> {
if (isInitialized()) return
const file = await decryptConfigFile(encryptedContent)
this.initializeFromLicense(file, options)
// 配置文件解密后包含 appKey 和 platformUrl字段名 serverUrl 或 baseUrl
const platformUrl = file.serverUrl ?? file.baseUrl ?? undefined
await this.initialize({ appKey: file.appKey, platformUrl, debug: options?.debug })
},
/**
* Wait for initialization to complete.
* SDK XuqmSDK
*/
async awaitInitialization(): Promise<void> {
if (isInitialized()) return
await ensureInitPromise()
},
setUserId(userId: string | null): void {
setCommonUserId(userId)
},
getUserId(): string | null {
return getCommonUserId()
},
/**
* Set user info for gray release targeting and license verification.
* Call this after user login.
* SDKPushIMLicense
* null SDK
*/
setUserInfo(info: XuqmUserInfo | null): void {
setCommonUserInfo(info)

查看文件

@ -1,4 +1,5 @@
import { apiRequest, _getToken, _saveToken, getConfig, getDeviceInfo, setUserId as setCommonUserId, getUserId as getCommonUserId } from '@xuqm/rn-common'
import { apiRequest, _getToken, _saveToken, getConfig, getDeviceInfo, setUserId as setCommonUserId, getUserId as getCommonUserId, _registerUserInfoHandler } from '@xuqm/rn-common'
import type { XuqmUserInfo } from '@xuqm/rn-common'
import { ImClient } from './ImClient'
import { ImDatabase } from './db/ImDatabase'
import type { MessageSearchParams } from './db/ImDatabase'
@ -317,17 +318,6 @@ export const ImSDK = {
client.addListener({
onConnected: () => {
_syncHistoryForAllConversations().catch(() => {})
// Auto-register push token if Push module is installed and a token is pending
import('@xuqm/rn-push')
.then(({ PushSDK }) => {
const pending = PushSDK.getPendingToken?.()
if (pending) {
PushSDK.registerToken(userId, pending.token, pending.vendor).catch(() => {})
}
})
.catch(() => {
// Push module not installed — ignore gracefully
})
},
})
void client.connect()
@ -1504,4 +1494,32 @@ export const ImSDK = {
_currentUserSig = null
setCommonUserId(null)
},
/**
* IM userSig
*/
async refreshToken(userSig: string): Promise<void> {
if (!_currentUserId) throw new Error('[ImSDK] Not logged in — call setUserInfo first.')
await ImSDK.login(_currentUserId, userSig)
},
}
// ─── 订阅 XuqmSDK.setUserInfo ──────────────────────────────────────────────────
_registerUserInfoHandler(async (info: XuqmUserInfo | null) => {
if (!info) {
ImSDK.disconnect()
return
}
// 仅当 IM 服务已开通且 userSig 存在时自动登录
if (!info.userSig) return
try {
const { getConfig, isInitialized } = await import('@xuqm/rn-common')
if (!isInitialized()) return
const config = getConfig()
if (!config.imEnabled) return
} catch {
return
}
await ImSDK.login(info.userId, info.userSig).catch(() => {})
})

查看文件

@ -1,3 +1,2 @@
export { initialize, initializeFromFile, checkLicense, getStatus, getDeviceId, clear } from './license'
export { decryptLicenseFile, decryptConfigFile } from './crypto'
export type { LicenseFile, LicenseUserInfo, LicenseStatus, LicenseResult } from './models'
export { checkLicense, getStatus, getDeviceId, clear } from './license'
export type { LicenseUserInfo, LicenseStatus, LicenseResult } from './models'

查看文件

@ -1,56 +1,27 @@
import { Platform } from 'react-native'
import { getDeviceId as getCommonDeviceId, getDeviceInfo, awaitInitialization } from '@xuqm/rn-common'
import { decryptLicenseFile } from './crypto'
import { getDeviceId as getCommonDeviceId, getDeviceInfo, awaitInitialization, getConfig, getUserId } from '@xuqm/rn-common'
import * as store from './store'
import type { LicenseFile, LicenseResult, LicenseStatus, LicenseUserInfo, RegisterRequest, RegisterResponse, VerifyRequest, VerifyResponse } from './models'
import type { LicenseResult, LicenseStatus, LicenseUserInfo, RegisterRequest, RegisterResponse, VerifyRequest, VerifyResponse } from './models'
const DEFAULT_BASE_URL = 'https://auth.dev.xuqinmin.com'
const CACHE_WINDOW_MS = 10 * 60 * 1000
const STATUS_OK = 'ok'
const STATUS_DENIED = 'denied'
interface LicenseConfig {
appKey: string
baseUrl: string
deviceName?: string
}
let _config: LicenseConfig | null = null
// In-memory cache to avoid repeated AsyncStorage reads within a session
let _cachedStatus: string | null = null
let _cachedStatusTime = 0
function normalize(url: string): string {
const s = url.trim()
return s.endsWith('/') ? s : s + '/'
}
export function initialize(appKey: string, options: { baseUrl?: string; deviceName?: string } = {}): void {
_config = {
appKey,
baseUrl: normalize(options.baseUrl ?? DEFAULT_BASE_URL),
deviceName: options.deviceName,
}
}
// Auto-initialize from license file asset. The asset is embedded as a string (e.g., via react-native-raw-text or require()).
export async function initializeFromFile(encryptedContent: string): Promise<void> {
const file = await decryptLicenseFile(encryptedContent)
initialize(file.appKey, { baseUrl: file.baseUrl })
}
export async function checkLicense(userInfo?: LicenseUserInfo): Promise<LicenseResult> {
if (!_config) {
await awaitInitialization()
if (!_config) return { type: 'error', message: 'LicenseSDK not initialized' }
}
const { appKey, baseUrl } = _config
// 等待 XuqmSDK 初始化完成,使用公共配置
await awaitInitialization()
const config = getConfig()
const { licenseUrl, appKey } = config
const baseUrl = licenseUrl.endsWith('/') ? licenseUrl : licenseUrl + '/'
// In-memory cache check
// 内存缓存
if (_cachedStatus === STATUS_OK && Date.now() - _cachedStatusTime < CACHE_WINDOW_MS) {
return { type: 'success', reason: 'Cached' }
}
// Persistent cache check
// 持久化缓存
const persistedStatus = await store.getStatus()
const persistedTime = await store.getStatusTime()
if (persistedStatus === STATUS_OK && Date.now() - persistedTime < CACHE_WINDOW_MS) {
@ -59,37 +30,37 @@ export async function checkLicense(userInfo?: LicenseUserInfo): Promise<LicenseR
return { type: 'success', reason: 'Cached' }
}
const deviceId = await getOrCreateDeviceId()
const deviceId = await _getOrCreateDeviceId()
const deviceInfo = await getDeviceInfo()
try {
const storedToken = await store.getToken()
if (storedToken) {
const verifyReq: VerifyRequest = { appKey, deviceId, token: storedToken, userInfo }
const verifyResp = await post<VerifyResponse>(`${baseUrl}api/license/verify`, verifyReq)
const verifyResp = await _post<VerifyResponse>(`${baseUrl}api/license/verify`, verifyReq)
if (verifyResp.valid) {
await persistStatus(STATUS_OK)
await _persistStatus(STATUS_OK)
return { type: 'success', reason: 'Verified' }
}
await store.setToken(null)
}
const userId = getUserId()
const regReq: RegisterRequest = {
appKey,
deviceId,
deviceName: _config.deviceName,
deviceModel: deviceInfo.model,
deviceModel: deviceInfo.model,
deviceVendor: Platform.OS === 'ios' ? 'Apple' : deviceInfo.brand,
osVersion: `${Platform.OS === 'ios' ? 'iOS' : 'Android'} ${deviceInfo.osVersion}`,
userInfo,
osVersion: `${Platform.OS === 'ios' ? 'iOS' : 'Android'} ${deviceInfo.osVersion}`,
userInfo: userInfo ?? (userId ? { userId } : undefined),
}
const regResp = await post<RegisterResponse>(`${baseUrl}api/license/register`, regReq)
const regResp = await _post<RegisterResponse>(`${baseUrl}api/license/register`, regReq)
if (regResp.success && regResp.token) {
await store.setToken(regResp.token)
await persistStatus(STATUS_OK)
await _persistStatus(STATUS_OK)
return { type: 'success', reason: 'Registered' }
}
await persistStatus(STATUS_DENIED)
await _persistStatus(STATUS_DENIED)
return { type: 'error', message: regResp.message ?? 'Registration denied' }
} catch (e) {
if (persistedStatus === STATUS_OK) return { type: 'success', reason: 'Offline - cached ok' }
@ -105,7 +76,7 @@ export async function getStatus(): Promise<LicenseStatus> {
}
export async function getDeviceId(): Promise<string> {
return getOrCreateDeviceId()
return _getOrCreateDeviceId()
}
export async function clear(): Promise<void> {
@ -114,7 +85,7 @@ export async function clear(): Promise<void> {
await store.clearAll()
}
async function getOrCreateDeviceId(): Promise<string> {
async function _getOrCreateDeviceId(): Promise<string> {
const existing = await store.getDeviceId()
if (existing) return existing
const id = await getCommonDeviceId()
@ -122,14 +93,14 @@ async function getOrCreateDeviceId(): Promise<string> {
return id
}
async function persistStatus(status: string): Promise<void> {
async function _persistStatus(status: string): Promise<void> {
const now = Date.now()
_cachedStatus = status
_cachedStatusTime = now
await Promise.all([store.setStatus(status), store.setStatusTime(now)])
}
async function post<T>(url: string, body: unknown): Promise<T> {
async function _post<T>(url: string, body: unknown): Promise<T> {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

查看文件

@ -1,147 +1,139 @@
import { apiRequest, getConfig, getDeviceInfo, getUserId as getCommonUserId } from '@xuqm/rn-common'
import type { PushVendor } from '@xuqm/rn-common'
import {
apiRequest,
getConfig,
getDeviceInfo,
getUserId as getCommonUserId,
_registerUserInfoHandler,
} from '@xuqm/rn-common'
import type { PushVendor, XuqmUserInfo } from '@xuqm/rn-common'
import { detectVendorNative, registerPushNative, addPushTokenListener } from './NativePush'
export type { PushVendor }
type PendingDeviceToken = {
token: string
vendor?: PushVendor
}
// ─── Internal state ────────────────────────────────────────────────────────────
let currentUserId: string | null = null
let currentInitializedUserId: string | null = null
let pendingToken: PendingDeviceToken | null = null
let _registeredUserId: string | null = null
let _tokenUnsubscribe: (() => void) | null = null
async function registerPendingToken(): Promise<void> {
const userId = currentUserId ?? getCommonUserId()
if (!userId || !pendingToken) return
const device = await getDeviceInfo()
// ─── Internal helpers ──────────────────────────────────────────────────────────
async function _registerDevice(userId: string, token: string, vendor: PushVendor): Promise<void> {
const config = getConfig()
const device = await getDeviceInfo()
await apiRequest('/api/push/register', {
method: 'POST',
params: {
appKey: config.appKey,
appKey: config.appKey,
userId,
vendor: pendingToken.vendor ?? device.pushVendor,
token: pendingToken.token,
platform: device.platform,
deviceId: device.deviceId,
brand: device.brand,
model: device.model,
vendor,
token,
platform: device.platform,
deviceId: device.deviceId,
brand: device.brand,
model: device.model,
osVersion: device.osVersion,
},
})
_registeredUserId = userId
}
export const PushSDK = {
async initialize(userId?: string): Promise<void> {
const nextUserId = userId ?? getCommonUserId()
if (currentInitializedUserId === nextUserId) {
currentUserId = nextUserId
return
async function _unregisterDevice(userId: string): Promise<void> {
const config = getConfig()
const { deviceId } = await getDeviceInfo()
await apiRequest('/api/push/unregister', {
method: 'DELETE',
params: { appKey: config.appKey, userId, deviceId },
})
if (_registeredUserId === userId) _registeredUserId = null
}
async function _startRegistrationFlow(userId: string): Promise<void> {
// 1. 检测厂商并请求原生注册
await registerPushNative()
// 2. 监听 token 回调,收到后自动上报
if (_tokenUnsubscribe) _tokenUnsubscribe()
_tokenUnsubscribe = addPushTokenListener(async (event) => {
const currentUserId = getCommonUserId()
if (!currentUserId) return
const vendor = (await detectVendorNative()) as PushVendor ?? (event.vendor as PushVendor)
await _registerDevice(currentUserId, event.token, vendor).catch(() => {})
})
}
// ─── 订阅 XuqmSDK.setUserInfo ──────────────────────────────────────────────────
_registerUserInfoHandler(async (info: XuqmUserInfo | null) => {
if (!info) {
// 登出:解绑 token
const userId = _registeredUserId
if (userId) {
await _unregisterDevice(userId).catch(() => {})
}
currentInitializedUserId = nextUserId
currentUserId = nextUserId
await registerPendingToken()
},
/**
* Cache a device token without immediately registering it.
* Call this when the native layer receives a token before the user is logged in.
*/
setPendingToken(token: string, vendor?: PushVendor): void {
pendingToken = { token, vendor }
},
/**
* Get the currently cached pending token, if any.
*/
getPendingToken(): PendingDeviceToken | null {
return pendingToken
},
async setDeviceToken(token: string, vendor?: PushVendor): Promise<void> {
pendingToken = { token, vendor }
await registerPendingToken()
},
/**
* Auto-detect vendor and request native push registration.
* On Android this attempts to register with the vendor SDK (Huawei, Xiaomi, OPPO, vivo, Honor, FCM).
* On iOS this requests APNs registration.
* Listen for token updates via onPushToken(callback).
*/
async requestNativeRegistration(): Promise<void> {
await registerPushNative()
},
/**
* Listen for push tokens from the native layer.
* Call setDeviceToken(token, vendor) inside the callback to register with the server.
*/
onPushToken(callback: (token: string, vendor: string) => void): () => void {
if (_tokenUnsubscribe) {
_tokenUnsubscribe()
_tokenUnsubscribe = null
}
_tokenUnsubscribe = addPushTokenListener((event) => {
callback(event.token, event.vendor)
return
}
// 登录:启动设备注册流程(幂等,同一用户不重复注册)
if (_registeredUserId === info.userId) return
await _startRegistrationFlow(info.userId).catch(() => {})
})
// ─── PushSDK 公开 API ──────────────────────────────────────────────────────────
export const PushSDK = {
/**
* 线
*/
async setOfflinePushEnabled(enabled: boolean): Promise<void> {
const config = getConfig()
const userId = getCommonUserId()
if (!userId) return
await apiRequest('/api/push/settings/offline', {
method: 'PUT',
params: { appKey: config.appKey, userId, enabled: String(enabled) },
})
return () => {
if (_tokenUnsubscribe) {
_tokenUnsubscribe()
_tokenUnsubscribe = null
}
}
},
/**
* Register a push device token for the given user.
* If vendor is omitted, it is auto-detected from the device brand.
*
* @param userId - The logged-in user's ID
* @param token - The device push token from the vendor SDK
* @param vendor - Optional; auto-detected when not provided
* 24 '22:00''08:00'
*/
async registerToken(userId: string, token: string, vendor?: PushVendor): Promise<void> {
currentUserId = userId
pendingToken = { token, vendor }
async setQuietHours(start: string, end: string): Promise<void> {
const config = getConfig()
const device = await getDeviceInfo()
await apiRequest('/api/push/register', {
method: 'POST',
params: {
appKey: config.appKey,
userId,
vendor: vendor ?? device.pushVendor,
token,
platform: device.platform,
deviceId: device.deviceId,
brand: device.brand,
model: device.model,
osVersion: device.osVersion,
},
const userId = getCommonUserId()
if (!userId) return
await apiRequest('/api/push/settings/quiet-hours', {
method: 'PUT',
params: { appKey: config.appKey, userId, start, end },
})
pendingToken = null
},
async unregisterToken(userId: string): Promise<void> {
/**
*
*/
async clearQuietHours(): Promise<void> {
const config = getConfig()
const { deviceId } = await getDeviceInfo()
await apiRequest('/api/push/unregister', {
const userId = getCommonUserId()
if (!userId) return
await apiRequest('/api/push/settings/quiet-hours', {
method: 'DELETE',
params: { appKey: config.appKey, userId, deviceId },
params: { appKey: config.appKey, userId },
})
if (currentUserId === userId) currentUserId = null
if (currentInitializedUserId === userId) currentInitializedUserId = null
if (pendingToken && currentUserId === null) pendingToken = null
},
async logout(userId?: string): Promise<void> {
const targetUserId = userId ?? currentUserId ?? getCommonUserId()
if (!targetUserId) return
await PushSDK.unregisterToken(targetUserId)
/**
* XuqmSDK.setUserInfo(null)
*/
async logout(): Promise<void> {
const userId = _registeredUserId ?? getCommonUserId()
if (!userId) return
await _unregisterDevice(userId).catch(() => {})
if (_tokenUnsubscribe) {
_tokenUnsubscribe()
_tokenUnsubscribe = null
}
},
}

查看文件

@ -1,3 +1,3 @@
export { PushSDK } from './PushSDK'
export type { PushVendor } from './PushSDK'
export { isNativePushAvailable, detectVendorNative, registerPushNative, addPushTokenListener } from './NativePush'
export { isNativePushAvailable } from './NativePush'

查看文件

@ -1,75 +1,48 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { Linking, Platform } from 'react-native'
import { apiRequest, getConfig, getUserId } from '@xuqm/rn-common'
import { apiRequest, getConfig, getUserId, _registerUserInfoHandler } from '@xuqm/rn-common'
import { getAppVersionCode, getAppVersionName, _devSetAppVersion } from './NativeVersion'
import { awaitInitialization } from '@xuqm/rn-common'
// ─── Types ────────────────────────────────────────────────────────────────────
// ─── Types (public) ────────────────────────────────────────────────────────────
/** 插件注册元数据 */
export interface PluginMeta {
/** 插件唯一标识,如 'buz1'、'buz2' */
/** 插件注册(版本号由 SDK 自动获取,不需要 App 传入) */
export interface PluginRegistration {
moduleId: string
/** 当前 bundle 版本号 */
version: string
}
/**
* App
*
* Android SDK UpdateInfo
* SDK UI app
*/
/** @deprecated 使用 PluginRegistration移除 version 字段) */
export interface PluginMeta extends PluginRegistration {
version?: string
}
export interface AppUpdateInfo {
/** 是否需要更新 */
needsUpdate: boolean
/** 最新版本名,如 '2.1.0' */
versionName?: string
/** 最新版本号(整数) */
versionCode?: number
/** APK/安装包直接下载地址Android 有此字段时优先下载) */
downloadUrl?: string
/** 更新日志 */
changeLog?: string
/** 是否强制更新true 时不允许跳过) */
forceUpdate?: boolean
/** iOS App Store 地址 */
appStoreUrl?: string
/** Android 应用商店地址(华为/小米/OPPO 等) */
marketUrl?: string
/** 服务端要求登录后才能检查true 时 needsUpdate 通常为 false */
requiresLogin?: boolean
/** SDK 内部标记APK 是否已下载到本地(仅 Android */
alreadyDownloaded?: boolean
/** APK 文件 SHA-256 校验值 */
apkHash?: string | null
}
/**
* RN Bundle
*
* = appKey + platform + moduleId
* - appKey
* - platformANDROID / IOS
* - moduleId ID 'buz1'
*/
export interface PluginUpdateInfo {
/** 是否需要更新 */
needsUpdate: boolean
/** 最新版本号 */
latestVersion: string
/** bundle 下载地址 */
currentVersion: string
downloadUrl: string
/** bundle 文件 MD5 */
md5: string
/** 要求的最低 common bundle 版本 */
minCommonVersion: string
/** 更新说明 */
note: string
forceUpdate?: boolean
changeLog?: string
}
/** 已缓存的 bundle 元数据 */
export interface CachedRnBundle {
export type { CachedRnBundle }
interface CachedRnBundle {
moduleId: string
version: string
md5: string
@ -77,14 +50,24 @@ export interface CachedRnBundle {
source: string
}
// ─── Internal ─────────────────────────────────────────────────────────────────
// ─── Internal state ────────────────────────────────────────────────────────────
const _pluginRegistry = new Map<string, PluginMeta>()
const _pluginRegistry = new Set<string>()
let _writeBundleCallback: ((moduleId: string, source: string) => Promise<void>) | null = null
let _reloadBundleCallback: ((moduleId: string) => Promise<void>) | null = null
function bundleCacheKey(moduleId: string) {
return `@xuqm:bundle:${moduleId}`
}
async function _getCachedVersion(moduleId: string): Promise<string> {
const raw = await AsyncStorage.getItem(bundleCacheKey(moduleId)).catch(() => null)
if (!raw) return '0.0.0'
const cached = JSON.parse(raw) as CachedRnBundle
return cached.version ?? '0.0.0'
}
function normalizeDownloadUrl(rawUrl?: string): string | undefined {
if (!rawUrl) return rawUrl
if (rawUrl.includes('/api/v1/updates/api/v1/rn/files/')) {
@ -101,66 +84,117 @@ function normalizeDownloadUrl(rawUrl?: string): string | undefined {
return rawUrl
}
async function _downloadText(url: string, onProgress?: (p: number) => void): Promise<string> {
const response = await fetch(url)
if (!response.ok) throw new Error(`[UpdateSDK] Download failed: ${response.status}`)
if (!onProgress) return response.text()
const contentLength = Number(response.headers.get('Content-Length') ?? '0')
const reader = response.body?.getReader()
if (!reader) return response.text()
const chunks: Uint8Array[] = []
let received = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
received += value.length
if (contentLength > 0) onProgress(received / contentLength)
}
const decoder = new TextDecoder()
return chunks.map(c => decoder.decode(c, { stream: true })).join('') + decoder.decode()
}
async function _downloadBinary(url: string, onProgress?: (p: number) => void): Promise<ArrayBuffer> {
const response = await fetch(url)
if (!response.ok) throw new Error(`[UpdateSDK] Download failed: ${response.status}`)
if (!onProgress) return response.arrayBuffer()
const contentLength = Number(response.headers.get('Content-Length') ?? '0')
const reader = response.body?.getReader()
if (!reader) return response.arrayBuffer()
const chunks: Uint8Array[] = []
let received = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
received += value.length
if (contentLength > 0) onProgress(received / contentLength)
}
const totalLength = chunks.reduce((acc, c) => acc + c.length, 0)
const combined = new Uint8Array(totalLength)
let offset = 0
for (const chunk of chunks) {
combined.set(chunk, offset)
offset += chunk.length
}
return combined.buffer
}
// ─── 订阅 setUserInfouserId 用于定向更新) ──────────────────────────────────
_registerUserInfoHandler(() => {
// updatePlugin 检查更新时会实时调用 getUserId(),此处无需额外操作
})
// ─── UpdateSDK ────────────────────────────────────────────────────────────────
export const UpdateSDK = {
// ── 宿主注入(插件化写入/重载能力)──────────────────────────────────────────
/**
* bundle
* 宿 src/core/updater.ts BundleRuntime
*
* @example
* UpdateSDK.setBundleCallbacks({
* writeBundle: writeBundleFile,
* reloadBundle: loadBundle,
* })
*/
setBundleCallbacks(callbacks: {
writeBundle: (moduleId: string, source: string) => Promise<void>
reloadBundle: (moduleId: string) => Promise<void>
}): void {
_writeBundleCallback = callbacks.writeBundle
_reloadBundleCallback = callbacks.reloadBundle
},
// ── 插件注册 ──────────────────────────────────────────────────────────────
/**
* bundle
*
* = moduleId 'buz1'
* = appKey+ platform+ moduleId
* SDK App
*
* @example
* // src/plugins/buz1/bundle.ts
* UpdateSDK.registerPlugin({ moduleId: 'buz1', version: '1.0.0' })
* UpdateSDK.registerPlugins([{ moduleId: 'buz1' }, { moduleId: 'buz2' }])
*/
registerPlugin(meta: PluginMeta): void {
_pluginRegistry.set(meta.moduleId, meta)
registerPlugins(plugins: PluginRegistration[]): void {
for (const p of plugins) {
_pluginRegistry.add(p.moduleId)
}
},
/**
*
* version SDK
*/
getRegisteredPluginVersion(moduleId: string): string | undefined {
return _pluginRegistry.get(moduleId)?.version
registerPlugin(meta: PluginRegistration): void {
_pluginRegistry.add(meta.moduleId)
},
/**
*
*/
getRegisteredPlugins(): PluginMeta[] {
return Array.from(_pluginRegistry.values())
getRegisteredPlugins(): string[] {
return Array.from(_pluginRegistry)
},
// ── App 整包更新 ──────────────────────────────────────────────────────────
/**
* App
*
* SDK iOS/Android
* /
*
* Android SDK UpdateSDK.checkAppUpdate()
* - app UI
* - Android downloadUrl APK downloadUrl marketUrl
* - iOS appStoreUrl App Store
*
* @param bypassIgnore false=
* true =
*/
async checkAppUpdate(bypassIgnore?: boolean): Promise<AppUpdateInfo> {
await awaitInitialization()
const config = getConfig()
const currentVersionCode = getAppVersionCode()
const userId = getUserId()?.trim()
const params: Record<string, string> = {
appKey: config.appKey,
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
currentVersionCode: String(currentVersionCode),
appKey: config.appKey,
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
currentVersionCode: String(getAppVersionCode()),
}
const userId = getUserId()?.trim()
if (userId) params.userId = userId
if (bypassIgnore) params.bypassIgnore = 'true'
@ -171,130 +205,117 @@ export const UpdateSDK = {
return { ...result, downloadUrl: normalizeDownloadUrl(result.downloadUrl) }
},
/**
*
* iOS 使 appStoreUrlAndroid 使 marketUrl
*/
async openStore(appStoreUrl?: string, marketUrl?: string): Promise<void> {
const url = Platform.OS === 'ios' ? appStoreUrl : marketUrl
if (url) await Linking.openURL(url)
},
// ── 插件更新 ──────────────────────────────────────────────────────────────
/**
* Android APK
*/
async downloadAndInstallApk(
downloadUrl: string,
options?: {
onProgress?: (progress: number) => void
sha256?: string
},
): Promise<void> {
if (Platform.OS !== 'android') throw new Error('[UpdateSDK] APK install is Android-only.')
// NativeApkInstall 由宿主提供TurboModule,若未注入则 fallback 到 Linking
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { XuqmApkInstall } = require('./NativeApkInstall') as { XuqmApkInstall: { installApk(url: string, sha256?: string | null): Promise<void> } }
await XuqmApkInstall.installApk(downloadUrl, options?.sha256 ?? null)
} catch {
// Native module not available — fallback to Linking
await Linking.openURL(downloadUrl)
}
},
// ── 插件Bundle热更新 ──────────────────────────────────────────────────
/**
*
*
* = appKey + platform + moduleId
* registerPlugin()
*
* @param moduleId ID 'buz1'
* @returns latestVersion / downloadUrl / md5
* @throws
* SDK AsyncStorage 0.0.0
* registerPlugin / registerPlugins
*/
async checkPluginUpdate(moduleId: string): Promise<PluginUpdateInfo> {
await awaitInitialization()
const config = getConfig()
const meta = _pluginRegistry.get(moduleId)
if (!meta) {
if (!_pluginRegistry.has(moduleId)) {
throw new Error(
`[UpdateSDK] Plugin "${moduleId}" not registered. ` +
'Call UpdateSDK.registerPlugin({ moduleId, version }) at bundle load time.',
'Call UpdateSDK.registerPlugins([{ moduleId }]) first.',
)
}
const config = getConfig()
const currentVersion = await _getCachedVersion(moduleId)
const userId = getUserId()?.trim()
const params: Record<string, string> = {
appKey: config.appKey,
moduleId,
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
currentVersion,
}
if (userId) params.userId = userId
const result = await apiRequest<PluginUpdateInfo>('/api/v1/rn/update/check', {
skipAuth: true,
params: {
appKey: config.appKey,
moduleId,
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
currentVersion: meta.version,
},
params,
})
return { ...result, downloadUrl: normalizeDownloadUrl(result.downloadUrl) ?? result.downloadUrl }
},
/**
* bundle
*
* @param downloadUrl
* @param onProgress (0~1)
*/
async downloadPluginBundle(downloadUrl: string, onProgress?: (progress: number) => void): Promise<string> {
const response = await fetch(downloadUrl)
if (!response.ok) throw new Error(`[UpdateSDK] Bundle download failed: ${response.status}`)
if (!onProgress) return response.text()
const contentLength = Number(response.headers.get('Content-Length') ?? '0')
const reader = response.body?.getReader()
if (!reader) return response.text()
const chunks: Uint8Array[] = []
let received = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
received += value.length
if (contentLength > 0) {
onProgress(received / contentLength)
}
return {
...result,
currentVersion,
downloadUrl: normalizeDownloadUrl(result.downloadUrl) ?? result.downloadUrl,
}
const decoder = new TextDecoder()
return chunks.map(chunk => decoder.decode(chunk, { stream: true })).join('') + decoder.decode()
},
/**
* bundle AsyncStorage
*
*
* SDK bundle silent = false
*
* setBundleCallbacks /
*
* @param silent true = false=
*/
async cachePluginBundle(moduleId: string, version: string, md5: string, source: string): Promise<CachedRnBundle> {
const payload: CachedRnBundle = {
moduleId, version, md5, source,
async updatePlugin(
moduleId: string,
options?: {
onProgress?: (progress: number) => void
silent?: boolean
},
): Promise<void> {
const info = await UpdateSDK.checkPluginUpdate(moduleId)
if (!info.needsUpdate) return
const source = await _downloadText(info.downloadUrl, options?.onProgress)
// 写入 AsyncStorage 缓存(版本记录)
const cachePayload: CachedRnBundle = {
moduleId,
version: info.latestVersion,
md5: info.md5,
source,
downloadedAt: new Date().toISOString(),
}
await AsyncStorage.setItem(bundleCacheKey(moduleId), JSON.stringify(payload))
return payload
},
await AsyncStorage.setItem(bundleCacheKey(moduleId), JSON.stringify(cachePayload))
/**
* bundle
*/
async getCachedPluginBundle(moduleId: string): Promise<CachedRnBundle | null> {
const raw = await AsyncStorage.getItem(bundleCacheKey(moduleId))
return raw ? (JSON.parse(raw) as CachedRnBundle) : null
},
// 写入宿主文件系统(需注入 setBundleCallbacks
if (_writeBundleCallback) {
await _writeBundleCallback(moduleId, source)
}
/**
*
*
* bundle
* reloadPlugin()
*
* @param moduleId ID
* @param onProgress (0~1)
* @returns bundle null
*/
async checkAndCachePlugin(moduleId: string, onProgress?: (progress: number) => void): Promise<CachedRnBundle | null> {
const info = await this.checkPluginUpdate(moduleId)
if (!info.needsUpdate) return null
const source = await this.downloadPluginBundle(info.downloadUrl, onProgress)
return this.cachePluginBundle(moduleId, info.latestVersion, info.md5, source)
// 触发重载(非静默模式)
if (!options?.silent && _reloadBundleCallback) {
await _reloadBundleCallback(moduleId)
}
},
// ── 版本信息 ──────────────────────────────────────────────────────────────
/** 当前 App versionCode原生读取 */
getAppVersionCode,
/** 当前 App versionName原生读取 */
getAppVersionName,
/**
*
*/
_devSetAppVersion(versionCode: number, versionName?: string): void {
_devSetAppVersion(versionCode, versionName)
},

查看文件

@ -1,3 +1,2 @@
export { UpdateSDK } from './UpdateSDK'
export type { PluginMeta, AppUpdateInfo, PluginUpdateInfo, CachedRnBundle } from './UpdateSDK'
export { NativeBundle } from './NativeBundle'
export type { PluginRegistration, PluginMeta, AppUpdateInfo, PluginUpdateInfo, CachedRnBundle } from './UpdateSDK'

查看文件

@ -1,7 +1,6 @@
export { XuqmSDK } from './sdk'
export type { UnifiedLoginOptions } from './sdk'
export type { XuqmInitOptions, XuqmUserInfo, DeviceInfo } from '@xuqm/rn-common'
export { getDeviceId, getDeviceInfo, detectPushVendor, setUserId, getUserId } from '@xuqm/rn-common'
export type { XuqmInitOptions, XuqmUserInfo, DeviceInfo, XuqmConfig } from '@xuqm/rn-common'
export { getDeviceId, getDeviceInfo, detectPushVendor, setUserId, getUserId, setUserInfo, getUserInfo } from '@xuqm/rn-common'
export { ScaledImage } from '@xuqm/rn-common'
export { apiRequest } from '@xuqm/rn-common'
export { ImSDK, ImClient, ImDatabase, uploadFile } from '@xuqm/rn-im'
@ -24,7 +23,7 @@ export type {
export { PushSDK } from '@xuqm/rn-push'
export type { PushVendor } from '@xuqm/rn-push'
export { UpdateSDK } from '@xuqm/rn-update'
export type { PluginMeta, AppUpdateInfo, PluginUpdateInfo, CachedRnBundle } from '@xuqm/rn-update'
export type { PluginRegistration, PluginMeta, AppUpdateInfo, PluginUpdateInfo, CachedRnBundle } from '@xuqm/rn-update'
export {
XWebViewControl,
XWebViewScreen,

查看文件

@ -1,63 +1,3 @@
import { _clearToken, setUserId as setCommonUserId, getUserId as getCommonUserId, XuqmSDK as CommonXuqmSDK } from '@xuqm/rn-common'
import { ImSDK } from '@xuqm/rn-im'
import { PushSDK } from '@xuqm/rn-push'
export interface UnifiedLoginOptions {
userId: string
userSig: string
}
let currentSession: UnifiedLoginOptions | null = null
async function applyLoginSession(session: UnifiedLoginOptions): Promise<void> {
setCommonUserId(session.userId)
try {
await ImSDK.login(session.userId, session.userSig)
} catch (error) {
setCommonUserId(null)
currentSession = null
throw error
}
currentSession = session
try {
await PushSDK.initialize(session.userId)
} catch (error) {
void error
}
}
async function login(options: UnifiedLoginOptions): Promise<void> {
if (
currentSession &&
currentSession.userId === options.userId &&
currentSession.userSig === options.userSig
) {
setCommonUserId(options.userId)
return
}
if (currentSession) {
await logout()
}
await applyLoginSession(options)
}
async function logout(): Promise<void> {
const userId = currentSession?.userId ?? getCommonUserId()
currentSession = null
setCommonUserId(null)
if (userId) {
try {
await PushSDK.logout(userId)
} catch (error) {
void error
}
}
ImSDK.disconnect()
await _clearToken()
}
export const XuqmSDK = {
...CommonXuqmSDK,
login,
logout,
}
// XuqmSDK 统一入口 — 直接重新导出 common 层的 XuqmSDK。
// 原 login/logout 包装层已移除;用户认证统一通过 XuqmSDK.setUserInfo() 完成。
export { XuqmSDK } from '@xuqm/rn-common'