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>
这个提交包含在:
父节点
bed221f536
当前提交
b1e8e307d2
@ -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 的完整服务配置(IM、Push、文件服务等 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.
|
||||
* 用户认证核心枢纽。登录成功后调用一次,所有子 SDK(Push、IM、License 等)自动同步状态。
|
||||
* 登出时传 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:应用标识(来自配置文件)
|
||||
* - platform:ANDROID / 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
|
||||
}
|
||||
|
||||
// ─── 订阅 setUserInfo(userId 用于定向更新) ──────────────────────────────────
|
||||
|
||||
_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 使用 appStoreUrl,Android 使用 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,
|
||||
|
||||
66
src/sdk.ts
66
src/sdk.ts
@ -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'
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户