From febefc8d69d20649df65c54d91464fe61004e823 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Fri, 24 Apr 2026 16:16:31 +0800 Subject: [PATCH] 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 --- .nvmrc | 1 + package.json | 16 +- packages/common/package.json | 21 ++ packages/common/src/config.ts | 36 +++ packages/common/src/constants.ts | 2 + packages/common/src/http.ts | 58 +++++ packages/common/src/index.ts | 5 + packages/common/src/sdk.ts | 15 ++ packages/im/package.json | 21 ++ packages/im/src/ImClient.ts | 209 ++++++++++++++++++ packages/im/src/ImSDK.ts | 103 +++++++++ packages/im/src/index.ts | 6 + packages/im/src/types.ts | 57 +++++ packages/push/package.json | 21 ++ packages/push/src/PushSDK.ts | 36 +++ packages/push/src/index.ts | 2 + .../com/xuqm/update/XuqmVersionModule.java | 43 ++++ packages/update/ios/XuqmVersionModule.m | 21 ++ packages/update/package.json | 22 ++ packages/update/src/NativeVersion.ts | 40 ++++ packages/update/src/UpdateSDK.ts | 158 +++++++++++++ packages/update/src/index.ts | 2 + src/index.ts | 27 ++- 23 files changed, 906 insertions(+), 16 deletions(-) create mode 100644 .nvmrc create mode 100644 packages/common/package.json create mode 100644 packages/common/src/config.ts create mode 100644 packages/common/src/constants.ts create mode 100644 packages/common/src/http.ts create mode 100644 packages/common/src/index.ts create mode 100644 packages/common/src/sdk.ts create mode 100644 packages/im/package.json create mode 100644 packages/im/src/ImClient.ts create mode 100644 packages/im/src/ImSDK.ts create mode 100644 packages/im/src/index.ts create mode 100644 packages/im/src/types.ts create mode 100644 packages/push/package.json create mode 100644 packages/push/src/PushSDK.ts create mode 100644 packages/push/src/index.ts create mode 100644 packages/update/android/src/main/java/com/xuqm/update/XuqmVersionModule.java create mode 100644 packages/update/ios/XuqmVersionModule.m create mode 100644 packages/update/package.json create mode 100644 packages/update/src/NativeVersion.ts create mode 100644 packages/update/src/UpdateSDK.ts create mode 100644 packages/update/src/index.ts diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/package.json b/package.json index 7afd2e0..b85b8f4 100644 --- a/package.json +++ b/package.json @@ -1,27 +1,33 @@ { "name": "@xuqm/rn-sdk", - "version": "0.1.0", - "description": "XuqmGroup React Native SDK — IM, Push, Version Management", + "version": "0.2.0", + "description": "XuqmGroup React Native SDK — meta-package (IM, Push, Update, Common)", "main": "src/index.ts", "react-native": "src/index.ts", "types": "src/index.ts", "private": false, + "workspaces": ["packages/*"], "publishConfig": { "registry": "https://nexus.xuqinmin.com/repository/npm-hosted/" }, "scripts": { "typecheck": "tsc --noEmit", - "test": "jest" + "typecheck:all": "yarn workspaces run typecheck" }, "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.76.0", "@react-native-async-storage/async-storage": ">=1.21.0" }, + "dependencies": { + "@xuqm/rn-common": "*", + "@xuqm/rn-im": "*", + "@xuqm/rn-push": "*", + "@xuqm/rn-update": "*" + }, "devDependencies": { "typescript": "^5.9.3", "@types/react": "^19.0.0", "@types/react-native": "^0.73.0" - }, - "dependencies": {} + } } diff --git a/packages/common/package.json b/packages/common/package.json new file mode 100644 index 0000000..208806a --- /dev/null +++ b/packages/common/package.json @@ -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" + } +} diff --git a/packages/common/src/config.ts b/packages/common/src/config.ts new file mode 100644 index 0000000..400fc62 --- /dev/null +++ b/packages/common/src/config.ts @@ -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 +} diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts new file mode 100644 index 0000000..c5fca8e --- /dev/null +++ b/packages/common/src/constants.ts @@ -0,0 +1,2 @@ +export const API_BASE_URL = 'https://sentry.xuqinmin.com' +export const IM_WS_URL = 'wss://sentry.xuqinmin.com/ws/im' diff --git a/packages/common/src/http.ts b/packages/common/src/http.ts new file mode 100644 index 0000000..e4e83bf --- /dev/null +++ b/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 { + return AsyncStorage.getItem(TOKEN_KEY) +} + +export async function _saveToken(token: string): Promise { + return AsyncStorage.setItem(TOKEN_KEY, token) +} + +export async function _clearToken(): Promise { + return AsyncStorage.removeItem(TOKEN_KEY) +} + +export async function apiRequest( + path: string, + options: { + method?: string + body?: unknown + params?: Record + skipAuth?: boolean + } = {}, +): Promise { + 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 = { + '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 +} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts new file mode 100644 index 0000000..56de25a --- /dev/null +++ b/packages/common/src/index.ts @@ -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' diff --git a/packages/common/src/sdk.ts b/packages/common/src/sdk.ts new file mode 100644 index 0000000..6ceb053 --- /dev/null +++ b/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) + }, +} diff --git a/packages/im/package.json b/packages/im/package.json new file mode 100644 index 0000000..b947bb6 --- /dev/null +++ b/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" + } +} diff --git a/packages/im/src/ImClient.ts b/packages/im/src/ImClient.ts new file mode 100644 index 0000000..5403692 --- /dev/null +++ b/packages/im/src/ImClient.ts @@ -0,0 +1,209 @@ +import type { ImEventListener, ImMessage, SendMessageParams } from './types' + +interface StompFrame { + command: string + headers: Record + body: string +} + +export class ImClient { + private ws: WebSocket | null = null + private listeners: ImEventListener[] = [] + private reconnectTimer: ReturnType | null = null + private reconnectDelay = 3000 + private shouldReconnect = true + private readonly subscriptionId = 'sub-user-queue' + private groupSubscriptions = new Set() + + 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 = {}, 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 } + }) + } +} diff --git a/packages/im/src/ImSDK.ts b/packages/im/src/ImSDK.ts new file mode 100644 index 0000000..beab32e --- /dev/null +++ b/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 { + 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 { + 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 { + 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 { + const config = getConfig() + return apiRequest('/api/im/messages/send', { + method: 'POST', + params: { appId: config.appId }, + body: { toId, chatType, msgType, content, mentionedUserIds: mentionedUserIds ?? '' }, + }) + }, + + async revokeMessage(messageId: string): Promise { + const config = getConfig() + return apiRequest(`/api/im/messages/${encodeURIComponent(messageId)}/revoke`, { + method: 'POST', + params: { appId: config.appId }, + }) + }, + + async createGroup(name: string, memberIds: string[]): Promise { + const config = getConfig() + return apiRequest('/api/im/groups', { + method: 'POST', + params: { appId: config.appId }, + body: { name, memberIds }, + }) + }, + + async listGroups(): Promise { + const config = getConfig() + const res = await apiRequest('/api/im/groups', { + params: { appId: config.appId }, + }) + return Array.isArray(res) ? res : (res.content ?? []) + }, + + async fetchGroupHistory(groupId: string, page = 0, size = 50): Promise { + 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 + }, +} diff --git a/packages/im/src/index.ts b/packages/im/src/index.ts new file mode 100644 index 0000000..dce34e3 --- /dev/null +++ b/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' diff --git a/packages/im/src/types.ts b/packages/im/src/types.ts new file mode 100644 index 0000000..62ae252 --- /dev/null +++ b/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 +} diff --git a/packages/push/package.json b/packages/push/package.json new file mode 100644 index 0000000..82c56fa --- /dev/null +++ b/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" + } +} diff --git a/packages/push/src/PushSDK.ts b/packages/push/src/PushSDK.ts new file mode 100644 index 0000000..8820eb7 --- /dev/null +++ b/packages/push/src/PushSDK.ts @@ -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 { + 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 { + const config = getConfig() + await apiRequest('/api/push/unregister', { + method: 'DELETE', + params: { appId: config.appId, userId }, + }) + }, +} diff --git a/packages/push/src/index.ts b/packages/push/src/index.ts new file mode 100644 index 0000000..35d022d --- /dev/null +++ b/packages/push/src/index.ts @@ -0,0 +1,2 @@ +export { PushSDK } from './PushSDK' +export type { PushVendor } from './PushSDK' diff --git a/packages/update/android/src/main/java/com/xuqm/update/XuqmVersionModule.java b/packages/update/android/src/main/java/com/xuqm/update/XuqmVersionModule.java new file mode 100644 index 0000000..d092b9f --- /dev/null +++ b/packages/update/android/src/main/java/com/xuqm/update/XuqmVersionModule.java @@ -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"; + } + } +} diff --git a/packages/update/ios/XuqmVersionModule.m b/packages/update/ios/XuqmVersionModule.m new file mode 100644 index 0000000..8d0b660 --- /dev/null +++ b/packages/update/ios/XuqmVersionModule.m @@ -0,0 +1,21 @@ +#import + +@interface XuqmVersionModule : NSObject +@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 diff --git a/packages/update/package.json b/packages/update/package.json new file mode 100644 index 0000000..9ab95c6 --- /dev/null +++ b/packages/update/package.json @@ -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" + } +} diff --git a/packages/update/src/NativeVersion.ts b/packages/update/src/NativeVersion.ts new file mode 100644 index 0000000..1f94936 --- /dev/null +++ b/packages/update/src/NativeVersion.ts @@ -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 +} diff --git a/packages/update/src/UpdateSDK.ts b/packages/update/src/UpdateSDK.ts new file mode 100644 index 0000000..3cd3892 --- /dev/null +++ b/packages/update/src/UpdateSDK.ts @@ -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() + +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 { + const config = getConfig() + const currentVersionCode = getAppVersionCode() + const result = await apiRequest('/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 { + 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 { + 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('/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 { + 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 { + 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 { + 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, +} diff --git a/packages/update/src/index.ts b/packages/update/src/index.ts new file mode 100644 index 0000000..60c95e5 --- /dev/null +++ b/packages/update/src/index.ts @@ -0,0 +1,2 @@ +export { UpdateSDK } from './UpdateSDK' +export type { PluginMeta, AppUpdateInfo, RnUpdateInfo, CachedRnBundle } from './UpdateSDK' diff --git a/src/index.ts b/src/index.ts index 524d018..257c5d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,19 @@ -export { initSDK, getConfig } from './core/config' -export type { XuqmSDKConfig } from './core/config' -export { getToken, saveToken, clearToken } from './core/http' -export { XuqmSDK } from './core/sdk' +// @xuqm/rn-sdk — convenience meta-package re-exporting all modules. +// For tree-shaking and smaller bundles, import from individual packages: +// @xuqm/rn-common | @xuqm/rn-im | @xuqm/rn-push | @xuqm/rn-update -export { ImSDK } from './im/imSDK' -export { ImClient } from './im/imClient' -export type { ImMessage, ChatType, MsgType, MsgStatus, ImEventListener, SendMessageParams, ImGroup } from './im/types' +export { XuqmSDK } from '@xuqm/rn-common' +export type { XuqmInitOptions } from '@xuqm/rn-common' -export { PushSDK } from './push/pushSDK' -export type { PushVendor } from './push/pushSDK' +export { ImSDK } from '@xuqm/rn-im' +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 type { AppUpdateInfo, RnUpdateInfo, CachedRnBundle } from './update/updateSDK' +export { PushSDK } from '@xuqm/rn-push' +export type { PushVendor } from '@xuqm/rn-push' + +export { UpdateSDK } from '@xuqm/rn-update' +export type { PluginMeta, AppUpdateInfo, RnUpdateInfo, CachedRnBundle } from '@xuqm/rn-update'