refactor: SDK monorepo with modular packages + clean init API

- Restructure as yarn workspace with packages/common, im, push, update
- @xuqm/rn-common: built-in URLs (no apiBaseUrl/imWsUrl in init), init({appId, debug})
- @xuqm/rn-im: login(userId) handles token internally, no token in public API
- @xuqm/rn-update: registerPlugin({moduleId,version}) for self-registration,
  checkAppUpdate() auto-detects version via XuqmVersionModule native bridge,
  checkRnUpdate(moduleId) uses registered version (no app-layer arg)
- Add XuqmVersionModule native stubs for Android/iOS
- Keep @xuqm/rn-sdk as convenience meta-package re-exporting all

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-04-24 16:16:31 +08:00
父节点 97150538bc
当前提交 febefc8d69
共有 23 个文件被更改,包括 906 次插入16 次删除

1
.nvmrc 普通文件
查看文件

@ -0,0 +1 @@
22

查看文件

@ -1,27 +1,33 @@
{ {
"name": "@xuqm/rn-sdk", "name": "@xuqm/rn-sdk",
"version": "0.1.0", "version": "0.2.0",
"description": "XuqmGroup React Native SDK — IM, Push, Version Management", "description": "XuqmGroup React Native SDK — meta-package (IM, Push, Update, Common)",
"main": "src/index.ts", "main": "src/index.ts",
"react-native": "src/index.ts", "react-native": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",
"private": false, "private": false,
"workspaces": ["packages/*"],
"publishConfig": { "publishConfig": {
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/" "registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
}, },
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "jest" "typecheck:all": "yarn workspaces run typecheck"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18.0.0", "react": ">=18.0.0",
"react-native": ">=0.76.0", "react-native": ">=0.76.0",
"@react-native-async-storage/async-storage": ">=1.21.0" "@react-native-async-storage/async-storage": ">=1.21.0"
}, },
"dependencies": {
"@xuqm/rn-common": "*",
"@xuqm/rn-im": "*",
"@xuqm/rn-push": "*",
"@xuqm/rn-update": "*"
},
"devDependencies": { "devDependencies": {
"typescript": "^5.9.3", "typescript": "^5.9.3",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-native": "^0.73.0" "@types/react-native": "^0.73.0"
}, }
"dependencies": {}
} }

查看文件

@ -0,0 +1,21 @@
{
"name": "@xuqm/rn-common",
"version": "0.2.0",
"description": "XuqmGroup RN SDK — core: init, network, token management",
"main": "src/index.ts",
"react-native": "src/index.ts",
"types": "src/index.ts",
"private": false,
"publishConfig": {
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
},
"scripts": { "typecheck": "tsc --noEmit" },
"peerDependencies": {
"react-native": ">=0.76.0",
"@react-native-async-storage/async-storage": ">=1.21.0"
},
"devDependencies": {
"typescript": "^5.9.3",
"@types/react-native": "^0.73.0"
}
}

查看文件

@ -0,0 +1,36 @@
import { API_BASE_URL, IM_WS_URL } from './constants'
export interface XuqmInitOptions {
appId: string
appKey?: string
debug?: boolean
}
export interface XuqmConfig {
appId: string
appKey: string
apiBaseUrl: string
imWsUrl: string
debug: boolean
}
let _config: XuqmConfig | null = null
export function initConfig(options: XuqmInitOptions): void {
_config = {
appId: options.appId,
appKey: options.appKey ?? options.appId,
apiBaseUrl: API_BASE_URL,
imWsUrl: IM_WS_URL,
debug: options.debug ?? false,
}
}
export function getConfig(): XuqmConfig {
if (!_config) throw new Error('[XuqmSDK] Not initialized — call XuqmSDK.init() first.')
return _config
}
export function isInitialized(): boolean {
return _config !== null
}

查看文件

@ -0,0 +1,2 @@
export const API_BASE_URL = 'https://sentry.xuqinmin.com'
export const IM_WS_URL = 'wss://sentry.xuqinmin.com/ws/im'

58
packages/common/src/http.ts 普通文件
查看文件

@ -0,0 +1,58 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { getConfig } from './config'
const TOKEN_KEY = '@xuqm:token'
export async function _getToken(): Promise<string | null> {
return AsyncStorage.getItem(TOKEN_KEY)
}
export async function _saveToken(token: string): Promise<void> {
return AsyncStorage.setItem(TOKEN_KEY, token)
}
export async function _clearToken(): Promise<void> {
return AsyncStorage.removeItem(TOKEN_KEY)
}
export async function apiRequest<T>(
path: string,
options: {
method?: string
body?: unknown
params?: Record<string, string>
skipAuth?: boolean
} = {},
): Promise<T> {
const config = getConfig()
let url = config.apiBaseUrl + path
if (options.params) {
const qs = new URLSearchParams(options.params).toString()
url += (url.includes('?') ? '&' : '?') + qs
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
}
if (!options.skipAuth) {
const token = await _getToken()
if (token) headers['Authorization'] = `Bearer ${token}`
}
const res = await fetch(url, {
method: options.method ?? 'GET',
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }))
throw new Error((err as { message?: string }).message ?? `HTTP ${res.status}`)
}
const json = await res.json()
return (json.data ?? json) as T
}

查看文件

@ -0,0 +1,5 @@
export { XuqmSDK } from './sdk'
export type { XuqmInitOptions } from './config'
export { getConfig, isInitialized } from './config'
export { apiRequest, _getToken, _saveToken, _clearToken } from './http'
export { API_BASE_URL, IM_WS_URL } from './constants'

15
packages/common/src/sdk.ts 普通文件
查看文件

@ -0,0 +1,15 @@
import { initConfig, isInitialized, type XuqmInitOptions } from './config'
export const XuqmSDK = {
/**
* Initialize the SDK. Must be called once before using any module.
*
* @param options.appId - Your application ID (from the tenant platform)
* @param options.appKey - Optional; defaults to appId
* @param options.debug - Enable verbose logging
*/
init(options: XuqmInitOptions): void {
if (isInitialized()) return
initConfig(options)
},
}

21
packages/im/package.json 普通文件
查看文件

@ -0,0 +1,21 @@
{
"name": "@xuqm/rn-im",
"version": "0.2.0",
"description": "XuqmGroup RN SDK — IM module (single chat, group chat, 13 message types)",
"main": "src/index.ts",
"react-native": "src/index.ts",
"types": "src/index.ts",
"private": false,
"publishConfig": {
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
},
"scripts": { "typecheck": "tsc --noEmit" },
"peerDependencies": {
"@xuqm/rn-common": ">=0.2.0",
"react-native": ">=0.76.0"
},
"devDependencies": {
"typescript": "^5.9.3",
"@types/react-native": "^0.73.0"
}
}

209
packages/im/src/ImClient.ts 普通文件
查看文件

@ -0,0 +1,209 @@
import type { ImEventListener, ImMessage, SendMessageParams } from './types'
interface StompFrame {
command: string
headers: Record<string, string>
body: string
}
export class ImClient {
private ws: WebSocket | null = null
private listeners: ImEventListener[] = []
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private reconnectDelay = 3000
private shouldReconnect = true
private readonly subscriptionId = 'sub-user-queue'
private groupSubscriptions = new Set<string>()
constructor(
private readonly wsUrl: string,
private readonly token: string,
private readonly appId: string,
) {}
connect() {
this.shouldReconnect = true
this.openSocket()
}
sendMessage(
toId: string,
chatType: SendMessageParams['chatType'],
msgType: SendMessageParams['msgType'],
content: string,
mentionedUserIds?: string,
) {
this.send({
toId,
chatType,
msgType,
content,
mentionedUserIds,
})
}
send(params: SendMessageParams) {
if (this.ws?.readyState !== WebSocket.OPEN) {
throw new Error('IM not connected')
}
this.sendFrame(
'SEND',
{
destination: '/app/chat.send',
'content-type': 'application/json',
},
JSON.stringify({
appId: this.appId,
toId: params.toId,
chatType: params.chatType,
msgType: params.msgType,
content: params.content,
mentionedUserIds: params.mentionedUserIds ?? '',
}),
)
}
revoke(messageId: string) {
if (this.ws?.readyState !== WebSocket.OPEN) {
throw new Error('IM not connected')
}
this.sendFrame(
'SEND',
{
destination: '/app/chat.revoke',
'content-type': 'application/json',
},
JSON.stringify({
appId: this.appId,
messageId,
}),
)
}
subscribeGroup(groupId: string) {
this.groupSubscriptions.add(groupId)
if (this.ws?.readyState === WebSocket.OPEN) {
this.subscribe(`/topic/group/${groupId}`, `group-${groupId}`)
}
}
addListener(listener: ImEventListener) {
this.listeners.push(listener)
}
removeListener(listener: ImEventListener) {
this.listeners = this.listeners.filter(item => item !== listener)
}
disconnect() {
this.shouldReconnect = false
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
if (this.ws?.readyState === WebSocket.OPEN) {
this.sendFrame('DISCONNECT')
}
this.ws?.close(1000, 'User disconnect')
this.ws = null
}
isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN
}
private openSocket() {
this.ws = new WebSocket(this.wsUrl)
this.ws.onopen = () => {
this.sendFrame('CONNECT', {
'accept-version': '1.2',
Authorization: `Bearer ${this.token}`,
'heart-beat': '10000,10000',
})
}
this.ws.onmessage = event => {
try {
const frames = this.parseFrames(String(event.data))
frames.forEach(frame => this.handleFrame(frame))
} catch {
this.listeners.forEach(listener => listener.onError?.('Parse error'))
}
}
this.ws.onclose = event => {
this.listeners.forEach(listener => listener.onDisconnected?.(event.reason))
if (this.shouldReconnect) {
this.reconnectTimer = setTimeout(() => {
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000)
this.openSocket()
}, this.reconnectDelay)
}
}
this.ws.onerror = () => {
this.listeners.forEach(listener => listener.onError?.('WebSocket error'))
}
}
private handleFrame(frame: StompFrame) {
if (frame.command === 'CONNECTED') {
this.reconnectDelay = 3000
this.subscribe('/user/queue/messages', this.subscriptionId)
this.groupSubscriptions.forEach(groupId => {
this.subscribe(`/topic/group/${groupId}`, `group-${groupId}`)
})
this.listeners.forEach(listener => listener.onConnected?.())
return
}
if (frame.command === 'MESSAGE') {
const message: ImMessage = JSON.parse(frame.body)
if (message.chatType === 'GROUP') {
this.listeners.forEach(listener => listener.onGroupMessage?.(message))
return
}
this.listeners.forEach(listener => listener.onMessage?.(message))
return
}
if (frame.command === 'ERROR') {
this.listeners.forEach(listener => listener.onError?.(frame.body || 'WebSocket error'))
}
}
private subscribe(destination: string, id: string) {
this.sendFrame('SUBSCRIBE', { destination, id })
}
private sendFrame(command: string, headers: Record<string, string> = {}, body = '') {
if (!this.ws) return
const headerLines = Object.entries(headers)
.map(([key, value]) => `${key}:${value}`)
.join('\n')
const frame = `${command}\n${headerLines}\n\n${body}\u0000`
this.ws.send(frame)
}
private parseFrames(raw: string): StompFrame[] {
return raw
.split('\u0000')
.map(frame => frame.replace(/^\n+/, '').trim())
.filter(Boolean)
.map(frame => {
const separatorIndex = frame.indexOf('\n\n')
const headerBlock = separatorIndex >= 0 ? frame.slice(0, separatorIndex) : frame
const body = separatorIndex >= 0 ? frame.slice(separatorIndex + 2) : ''
const [command, ...headerLines] = headerBlock.split('\n').filter(Boolean)
const headers = Object.fromEntries(
headerLines
.filter(line => line.includes(':'))
.map(line => {
const index = line.indexOf(':')
return [line.slice(0, index), line.slice(index + 1)]
}),
)
return { command, headers, body }
})
}
}

103
packages/im/src/ImSDK.ts 普通文件
查看文件

@ -0,0 +1,103 @@
import { apiRequest, _getToken, _saveToken, getConfig } from '@xuqm/rn-common'
import { ImClient } from './ImClient'
import type { ChatType, ImEventListener, ImGroup, ImMessage, MsgType } from './types'
let client: ImClient | null = null
export const ImSDK = {
/**
* Login to IM service. Fetches a token internally and opens the WebSocket connection.
*/
async login(userId: string, nickname?: string, avatar?: string): Promise<void> {
const config = getConfig()
const res = await apiRequest<{ token: string }>('/api/im/auth/login', {
method: 'POST',
skipAuth: true,
params: {
appId: config.appId,
userId,
...(nickname ? { nickname } : {}),
...(avatar ? { avatar } : {}),
},
})
await _saveToken(res.token)
client = new ImClient(config.imWsUrl, res.token, config.appId)
client.connect()
},
async reconnect(): Promise<void> {
const config = getConfig()
const token = await _getToken()
if (!token) throw new Error('[ImSDK] No active session — call login() first.')
client = new ImClient(config.imWsUrl, token, config.appId)
client.connect()
},
async fetchHistory(toId: string, page = 0, size = 20): Promise<ImMessage[]> {
const config = getConfig()
const res = await apiRequest<{ content?: ImMessage[] } | ImMessage[]>(
`/api/im/messages/history/${encodeURIComponent(toId)}`,
{ params: { appId: config.appId, page: String(page), size: String(size) } },
)
return Array.isArray(res) ? res : (res.content ?? [])
},
async sendMessage(
toId: string,
chatType: ChatType,
msgType: MsgType,
content: string,
mentionedUserIds?: string,
): Promise<ImMessage> {
const config = getConfig()
return apiRequest<ImMessage>('/api/im/messages/send', {
method: 'POST',
params: { appId: config.appId },
body: { toId, chatType, msgType, content, mentionedUserIds: mentionedUserIds ?? '' },
})
},
async revokeMessage(messageId: string): Promise<ImMessage> {
const config = getConfig()
return apiRequest<ImMessage>(`/api/im/messages/${encodeURIComponent(messageId)}/revoke`, {
method: 'POST',
params: { appId: config.appId },
})
},
async createGroup(name: string, memberIds: string[]): Promise<ImGroup> {
const config = getConfig()
return apiRequest<ImGroup>('/api/im/groups', {
method: 'POST',
params: { appId: config.appId },
body: { name, memberIds },
})
},
async listGroups(): Promise<ImGroup[]> {
const config = getConfig()
const res = await apiRequest<ImGroup[] | { content?: ImGroup[] }>('/api/im/groups', {
params: { appId: config.appId },
})
return Array.isArray(res) ? res : (res.content ?? [])
},
async fetchGroupHistory(groupId: string, page = 0, size = 50): Promise<ImMessage[]> {
const config = getConfig()
const res = await apiRequest<{ content?: ImMessage[] } | ImMessage[]>(
`/api/im/messages/history/${encodeURIComponent(groupId)}`,
{ params: { appId: config.appId, page: String(page), size: String(size) } },
)
return Array.isArray(res) ? res : (res.content ?? [])
},
addListener(listener: ImEventListener): void { client?.addListener(listener) },
removeListener(listener: ImEventListener): void { client?.removeListener(listener) },
subscribeGroup(groupId: string): void { client?.subscribeGroup(groupId) },
isConnected(): boolean { return client?.isConnected() ?? false },
disconnect(): void {
client?.disconnect()
client = null
},
}

6
packages/im/src/index.ts 普通文件
查看文件

@ -0,0 +1,6 @@
export { ImSDK } from './ImSDK'
export { ImClient } from './ImClient'
export type {
ImMessage, ImGroup, ChatType, MsgType, MsgStatus,
ImEventListener, SendMessageParams,
} from './types'

57
packages/im/src/types.ts 普通文件
查看文件

@ -0,0 +1,57 @@
export type ChatType = 'SINGLE' | 'GROUP'
export type MsgType =
| 'TEXT'
| 'IMAGE'
| 'VIDEO'
| 'AUDIO'
| 'FILE'
| 'CUSTOM'
| 'LOCATION'
| 'NOTIFY'
| 'RICH_TEXT'
| 'CALL_AUDIO'
| 'CALL_VIDEO'
| 'REVOKED'
| 'FORWARD'
export type MsgStatus = 'SENT' | 'DELIVERED' | 'READ' | 'REVOKED'
export interface ImMessage {
id: string
appId: string
fromUserId: string
toId: string
chatType: ChatType
msgType: MsgType
content: string
status: MsgStatus
mentionedUserIds?: string
createdAt: string
}
export interface ImEventListener {
onConnected?: () => void
onDisconnected?: (reason?: string) => void
onMessage?: (msg: ImMessage) => void
onGroupMessage?: (msg: ImMessage) => void
onError?: (error: string) => void
}
export interface SendMessageParams {
toId: string
chatType: ChatType
msgType: MsgType
content: string
mentionedUserIds?: string
}
export interface ImGroup {
id: string
appId: string
name: string
creatorId: string
memberIds: string
adminIds: string
createdAt: string
}

21
packages/push/package.json 普通文件
查看文件

@ -0,0 +1,21 @@
{
"name": "@xuqm/rn-push",
"version": "0.2.0",
"description": "XuqmGroup RN SDK — Push module (device token registration)",
"main": "src/index.ts",
"react-native": "src/index.ts",
"types": "src/index.ts",
"private": false,
"publishConfig": {
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
},
"scripts": { "typecheck": "tsc --noEmit" },
"peerDependencies": {
"@xuqm/rn-common": ">=0.2.0",
"react-native": ">=0.76.0"
},
"devDependencies": {
"typescript": "^5.9.3",
"@types/react-native": "^0.73.0"
}
}

查看文件

@ -0,0 +1,36 @@
import { Platform } from 'react-native'
import { apiRequest, getConfig } from '@xuqm/rn-common'
export type PushVendor = 'HUAWEI' | 'XIAOMI' | 'OPPO' | 'VIVO' | 'HONOR' | 'APNS' | 'FCM'
export const PushSDK = {
/**
* Register a push device token for the given user.
* Call this after obtaining a vendor push token (e.g. via Firebase or APNS callbacks).
*
* @param userId - The logged-in user's ID
* @param vendor - Push vendor (e.g. HUAWEI, XIAOMI, APNS)
* @param token - The device push token from the vendor
*/
async registerToken(userId: string, vendor: PushVendor, token: string): Promise<void> {
const config = getConfig()
await apiRequest('/api/push/register', {
method: 'POST',
params: {
appId: config.appId,
userId,
vendor,
token,
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
},
})
},
async unregisterToken(userId: string): Promise<void> {
const config = getConfig()
await apiRequest('/api/push/unregister', {
method: 'DELETE',
params: { appId: config.appId, userId },
})
},
}

查看文件

@ -0,0 +1,2 @@
export { PushSDK } from './PushSDK'
export type { PushVendor } from './PushSDK'

查看文件

@ -0,0 +1,43 @@
package com.xuqm.update;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
public class XuqmVersionModule extends ReactContextBaseJavaModule {
public XuqmVersionModule(ReactApplicationContext ctx) {
super(ctx);
}
@Override
public String getName() {
return "XuqmVersionModule";
}
@ReactMethod(isBlockingSynchronousMethod = true)
public int getVersionCode() {
try {
PackageInfo info = getReactApplicationContext()
.getPackageManager()
.getPackageInfo(getReactApplicationContext().getPackageName(), 0);
return (int) info.getLongVersionCode();
} catch (PackageManager.NameNotFoundException e) {
return 0;
}
}
@ReactMethod(isBlockingSynchronousMethod = true)
public String getVersionName() {
try {
PackageInfo info = getReactApplicationContext()
.getPackageManager()
.getPackageInfo(getReactApplicationContext().getPackageName(), 0);
return info.versionName;
} catch (PackageManager.NameNotFoundException e) {
return "0.0.0";
}
}
}

查看文件

@ -0,0 +1,21 @@
#import <React/RCTBridgeModule.h>
@interface XuqmVersionModule : NSObject <RCTBridgeModule>
@end
@implementation XuqmVersionModule
RCT_EXPORT_MODULE();
RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getVersionCode) {
NSString *build = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleVersion"];
return @([build integerValue]);
}
RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getVersionName) {
NSString *version = [[[NSBundle mainBundle] infoDictionary]
objectForKey:@"CFBundleShortVersionString"];
return version ?: @"0.0.0";
}
@end

查看文件

@ -0,0 +1,22 @@
{
"name": "@xuqm/rn-update",
"version": "0.2.0",
"description": "XuqmGroup RN SDK — Update module (App update, RN plugin hot-update)",
"main": "src/index.ts",
"react-native": "src/index.ts",
"types": "src/index.ts",
"private": false,
"publishConfig": {
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
},
"scripts": { "typecheck": "tsc --noEmit" },
"peerDependencies": {
"@xuqm/rn-common": ">=0.2.0",
"react-native": ">=0.76.0",
"@react-native-async-storage/async-storage": ">=1.21.0"
},
"devDependencies": {
"typescript": "^5.9.3",
"@types/react-native": "^0.73.0"
}
}

查看文件

@ -0,0 +1,40 @@
import { NativeModules } from 'react-native'
/**
* Native module interface. Provided by XuqmVersionModule (auto-linked).
*
* Android: reads BuildConfig.VERSION_CODE / VERSION_NAME
* iOS: reads CFBundleVersion / CFBundleShortVersionString
*
* If the native module is not linked (e.g. JS-only dev environment),
* falls back to a value set via _devSetAppVersionCode().
*/
interface XuqmVersionModuleInterface {
getVersionCode: () => number
getVersionName: () => string
}
const _native = NativeModules.XuqmVersionModule as XuqmVersionModuleInterface | undefined
let _devVersionCode = 0
let _devVersionName = '0.0.0'
/** Only for dev environments where the native module is not linked. */
export function _devSetAppVersion(versionCode: number, versionName = '0.0.0'): void {
_devVersionCode = versionCode
_devVersionName = versionName
}
export function getAppVersionCode(): number {
try {
if (_native?.getVersionCode) return _native.getVersionCode()
} catch {}
return _devVersionCode
}
export function getAppVersionName(): string {
try {
if (_native?.getVersionName) return _native.getVersionName()
} catch {}
return _devVersionName
}

查看文件

@ -0,0 +1,158 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { Linking, Platform } from 'react-native'
import { apiRequest, getConfig } from '@xuqm/rn-common'
import { getAppVersionCode, getAppVersionName, _devSetAppVersion } from './NativeVersion'
export interface PluginMeta {
moduleId: string
version: string
}
export interface AppUpdateInfo {
needsUpdate: boolean
versionName?: string
versionCode?: number
downloadUrl?: string
changeLog?: string
forceUpdate?: boolean
appStoreUrl?: string
marketUrl?: string
}
export interface RnUpdateInfo {
needsUpdate: boolean
latestVersion: string
downloadUrl: string
md5: string
minCommonVersion: string
note: string
}
export interface CachedRnBundle {
moduleId: string
version: string
md5: string
downloadedAt: string
source: string
}
const _pluginRegistry = new Map<string, PluginMeta>()
function bundleCacheKey(moduleId: string) {
return `@xuqm:bundle:${moduleId}`
}
function normalizeDownloadUrl(rawUrl?: string): string | undefined {
if (!rawUrl) return rawUrl
if (rawUrl.includes('/api/v1/updates/api/v1/rn/files/')) {
return rawUrl.replace('/api/v1/updates/api/v1/rn/files/', '/api/v1/rn/files/')
}
if (rawUrl.includes('/files/apk/')) {
try {
const url = new URL(rawUrl)
if (url.pathname.startsWith('/files/apk/')) {
return `${url.origin}/api/v1/updates${url.pathname}${url.search}`
}
} catch {}
}
return rawUrl
}
export const UpdateSDK = {
/**
* Register a plugin's metadata. Call this at the top of the plugin's bundle entry file.
*
* @example
* // In your plugin's index.ts:
* import meta from './plugin.json'
* UpdateSDK.registerPlugin(meta)
*/
registerPlugin(meta: PluginMeta): void {
_pluginRegistry.set(meta.moduleId, meta)
},
/**
* For dev/simulator environments where the native XuqmVersionModule is not linked.
* Do NOT call this in production the native module provides the value automatically.
*/
_devSetAppVersion(versionCode: number, versionName?: string): void {
_devSetAppVersion(versionCode, versionName)
},
/**
* Check if there is a newer App version available.
* App version is read automatically from native code (XuqmVersionModule).
*/
async checkAppUpdate(): Promise<AppUpdateInfo> {
const config = getConfig()
const currentVersionCode = getAppVersionCode()
const result = await apiRequest<AppUpdateInfo>('/api/v1/updates/app/check', {
skipAuth: true,
params: {
appId: config.appId,
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
currentVersionCode: String(currentVersionCode),
},
})
return { ...result, downloadUrl: normalizeDownloadUrl(result.downloadUrl) }
},
async openStore(appStoreUrl?: string, marketUrl?: string): Promise<void> {
const url = Platform.OS === 'ios' ? appStoreUrl : marketUrl
if (url) await Linking.openURL(url)
},
/**
* Check if a newer RN bundle exists for the given plugin.
* The plugin must have been registered via registerPlugin() first.
*/
async checkRnUpdate(moduleId: string): Promise<RnUpdateInfo> {
const config = getConfig()
const meta = _pluginRegistry.get(moduleId)
if (!meta) {
throw new Error(
`[UpdateSDK] Plugin "${moduleId}" not registered. ` +
'Call UpdateSDK.registerPlugin({ moduleId, version }) at bundle load time.',
)
}
const result = await apiRequest<RnUpdateInfo>('/api/v1/rn/update/check', {
skipAuth: true,
params: {
appId: config.appId,
moduleId,
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
currentVersion: meta.version,
},
})
return { ...result, downloadUrl: normalizeDownloadUrl(result.downloadUrl) ?? result.downloadUrl }
},
async downloadRnBundle(downloadUrl: string): Promise<string> {
const response = await fetch(downloadUrl)
if (!response.ok) throw new Error(`[UpdateSDK] Bundle download failed: ${response.status}`)
return response.text()
},
async cacheRnBundle(moduleId: string, version: string, md5: string, source: string): Promise<CachedRnBundle> {
const payload: CachedRnBundle = {
moduleId, version, md5, source,
downloadedAt: new Date().toISOString(),
}
await AsyncStorage.setItem(bundleCacheKey(moduleId), JSON.stringify(payload))
return payload
},
async getCachedRnBundle(moduleId: string): Promise<CachedRnBundle | null> {
const raw = await AsyncStorage.getItem(bundleCacheKey(moduleId))
return raw ? (JSON.parse(raw) as CachedRnBundle) : null
},
/** Returns the currently running version of a registered plugin. */
getRegisteredPluginVersion(moduleId: string): string | undefined {
return _pluginRegistry.get(moduleId)?.version
},
/** Returns the current app versionCode (read from native). */
getAppVersionCode,
getAppVersionName,
}

查看文件

@ -0,0 +1,2 @@
export { UpdateSDK } from './UpdateSDK'
export type { PluginMeta, AppUpdateInfo, RnUpdateInfo, CachedRnBundle } from './UpdateSDK'

查看文件

@ -1,14 +1,19 @@
export { initSDK, getConfig } from './core/config' // @xuqm/rn-sdk — convenience meta-package re-exporting all modules.
export type { XuqmSDKConfig } from './core/config' // For tree-shaking and smaller bundles, import from individual packages:
export { getToken, saveToken, clearToken } from './core/http' // @xuqm/rn-common | @xuqm/rn-im | @xuqm/rn-push | @xuqm/rn-update
export { XuqmSDK } from './core/sdk'
export { ImSDK } from './im/imSDK' export { XuqmSDK } from '@xuqm/rn-common'
export { ImClient } from './im/imClient' export type { XuqmInitOptions } from '@xuqm/rn-common'
export type { ImMessage, ChatType, MsgType, MsgStatus, ImEventListener, SendMessageParams, ImGroup } from './im/types'
export { PushSDK } from './push/pushSDK' export { ImSDK } from '@xuqm/rn-im'
export type { PushVendor } from './push/pushSDK' export { ImClient } from '@xuqm/rn-im'
export type {
ImMessage, ImGroup, ChatType, MsgType, MsgStatus,
ImEventListener, SendMessageParams,
} from '@xuqm/rn-im'
export { UpdateSDK } from './update/updateSDK' export { PushSDK } from '@xuqm/rn-push'
export type { AppUpdateInfo, RnUpdateInfo, CachedRnBundle } from './update/updateSDK' export type { PushVendor } from '@xuqm/rn-push'
export { UpdateSDK } from '@xuqm/rn-update'
export type { PluginMeta, AppUpdateInfo, RnUpdateInfo, CachedRnBundle } from '@xuqm/rn-update'