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>
这个提交包含在:
父节点
97150538bc
当前提交
febefc8d69
16
package.json
16
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": {}
|
||||
}
|
||||
}
|
||||
|
||||
21
packages/common/package.json
普通文件
21
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"
|
||||
}
|
||||
}
|
||||
36
packages/common/src/config.ts
普通文件
36
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
|
||||
}
|
||||
@ -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
普通文件
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
|
||||
}
|
||||
5
packages/common/src/index.ts
普通文件
5
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'
|
||||
15
packages/common/src/sdk.ts
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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"
|
||||
}
|
||||
}
|
||||
36
packages/push/src/PushSDK.ts
普通文件
36
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<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 },
|
||||
})
|
||||
},
|
||||
}
|
||||
2
packages/push/src/index.ts
普通文件
2
packages/push/src/index.ts
普通文件
@ -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
|
||||
22
packages/update/package.json
普通文件
22
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"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
158
packages/update/src/UpdateSDK.ts
普通文件
158
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<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,
|
||||
}
|
||||
2
packages/update/src/index.ts
普通文件
2
packages/update/src/index.ts
普通文件
@ -0,0 +1,2 @@
|
||||
export { UpdateSDK } from './UpdateSDK'
|
||||
export type { PluginMeta, AppUpdateInfo, RnUpdateInfo, CachedRnBundle } from './UpdateSDK'
|
||||
27
src/index.ts
27
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'
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户