diff --git a/package.json b/package.json index 84a59a7..c066ba2 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "devDependencies": { "@types/react": "^19.0.0", "@types/react-native": "^0.73.0", - "typescript": "^5.9.3" + "axios": "^1.18.0", + "typescript": "^5.9.3", + "zod": "3.23.8" } } diff --git a/packages/common/src/api/index.ts b/packages/common/src/api/index.ts index a3b8a2a..da191ae 100644 --- a/packages/common/src/api/index.ts +++ b/packages/common/src/api/index.ts @@ -1,7 +1,9 @@ +export { useRequest } from './useRequest' export { useApi } from './useApi' export { usePageApi } from './usePageApi' export { RequestError } from './errors' export { setGlobalApiErrorHandler } from './globalErrorHandler' export type { RequestOptions } from './useRequest' export type { ApiMethod } from './useApi' +export type { PageOptions } from './usePageApi' export type { AxiosRequestConfig } from 'axios' diff --git a/packages/common/src/api/usePageApi.ts b/packages/common/src/api/usePageApi.ts index 8bbd195..9a9702f 100644 --- a/packages/common/src/api/usePageApi.ts +++ b/packages/common/src/api/usePageApi.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import type { AxiosRequestConfig } from 'axios' -import type { RequestError, RequestOptions } from './useRequest' +import type { RequestError } from './errors' +import type { RequestOptions } from './useRequest' import { z } from 'zod' import { useApi } from './useApi' diff --git a/packages/common/src/device.ts b/packages/common/src/device.ts index 5145c32..d66540a 100644 --- a/packages/common/src/device.ts +++ b/packages/common/src/device.ts @@ -82,5 +82,7 @@ export async function getDeviceInfo(): Promise { model: String(C.Model ?? ''), osVersion: String(C.Release ?? Platform.Version), pushVendor: detectPushVendor(brand), + manufacturer: String(C.Manufacturer ?? undefined), + vendorVersion: undefined, } } diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index f0a4438..6a1f008 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -24,9 +24,10 @@ export { openXWebView, setXWebViewController, setXWebViewNavigationRef, + setXWebViewScanQRCodeHandler, processJSBridgeMessage, } from './xwebview/XWebViewBridge' -export type { XWebViewNavigationRef } from './xwebview/XWebViewBridge' +export type { XWebViewNavigationRef, ScanQRCodeHandler } from './xwebview/XWebViewBridge' export type { XWebViewClickMenu, XWebViewConfig, diff --git a/packages/common/src/xwebview/XWebViewBridge.ts b/packages/common/src/xwebview/XWebViewBridge.ts index d2bc4e0..10285ba 100644 --- a/packages/common/src/xwebview/XWebViewBridge.ts +++ b/packages/common/src/xwebview/XWebViewBridge.ts @@ -171,6 +171,16 @@ true;`.trim() break } + case 'xuqm.scanQRCode': { + if (!_scanQRCodeHandler) { + respond(fail('scanQRCode not available — call setXWebViewScanQRCodeHandler() in host app')) + break + } + const result = await _scanQRCodeHandler() + respond(ok(result)) + break + } + default: // Unknown xuqm.* action — not handled by built-in bridge return false diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json new file mode 100644 index 0000000..b178bfe --- /dev/null +++ b/packages/common/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-native", + "baseUrl": "../../", + "paths": { + "@react-native-async-storage/async-storage": ["src/shims/async-storage.ts"] + }, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/log/package.json b/packages/log/package.json new file mode 100644 index 0000000..e9d4fef --- /dev/null +++ b/packages/log/package.json @@ -0,0 +1,24 @@ +{ + "name": "@xuqm/rn-log", + "version": "0.1.0", + "description": "XuqmGroup RN SDK — log collection, error tracking, funnel analysis", + "license": "UNLICENSED", + "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.4.0", + "@react-native-async-storage/async-storage": ">=1.21.0", + "react-native": ">=0.76.0" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/packages/log/src/XLog.ts b/packages/log/src/XLog.ts new file mode 100644 index 0000000..f96ab4b --- /dev/null +++ b/packages/log/src/XLog.ts @@ -0,0 +1,133 @@ +import { Platform } from 'react-native' +import { getConfig, isInitialized, getUserId } from '@xuqm/rn-common' +import { LogQueue } from './queue/LogQueue' +import { ErrorCapture } from './capture/ErrorCapture' +import { computeFingerprint } from './fingerprint' +import { FunnelTracker } from './funnel/FunnelTracker' +import type { LogLevel, Environment, IssueEvent, LogEvent, XLogEvent } from './types' + +// ─── Internal state ─────────────────────────────────────────────────────────── + +let _logLevel: LogLevel = 'warn' +let _environment: Environment = 'production' +let _queue: LogQueue | null = null +let _errorCaptureStarted = false + +// Stable session id for the lifetime of the JS runtime +const _sessionId: string = _generateSessionId() + +function _generateSessionId(): string { + return 'xxxx-xxxx'.replace(/x/g, () => ((Math.random() * 16) | 0).toString(16)) +} + +function _levelNum(level: LogLevel): number { + return { debug: 0, info: 1, warn: 2, error: 3 }[level] +} + +function _getPlatform(): 'ios' | 'android' { + return Platform.OS === 'ios' ? 'ios' : 'android' +} + +function _getAppVersion(): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const constants = Platform.constants as Record + const v = constants['appVersion'] ?? constants['Version'] + return typeof v === 'string' ? v : String(v ?? '0.0.0') +} + +function _enqueue(event: XLogEvent): void { + if (!_queue) { + const cfg = getConfig() + if (!cfg.logApiUrl || !cfg.logEnabled) return + _queue = new LogQueue({ logApiUrl: cfg.logApiUrl, appKey: cfg.appKey }) + } + void _queue.push(event) +} + +// ─── XLog public API ────────────────────────────────────────────────────────── + +export const XLog = { + /** Set the minimum log level. Events below this level are discarded. */ + setLogLevel(level: LogLevel): void { + _logLevel = level + }, + + /** Set an environment tag attached to every log event. */ + setEnvironment(env: Environment): void { + _environment = env + }, + + /** + * Record a custom analytics event (funnel analysis, behaviour tracking). + * Also automatically advances any matching funnel step. + */ + event(name: string, properties?: Record): void { + if (!isInitialized()) return + const event: LogEvent = { + type: 'event', + name, + properties: properties ? { ...properties, environment: _environment } : { environment: _environment }, + timestamp: Date.now(), + userId: getUserId() ?? undefined, + sessionId: _sessionId, + appKey: getConfig().appKey, + platform: _getPlatform(), + appVersion: _getAppVersion(), + } + _enqueue(event) + FunnelTracker.track(name, properties) + }, + + /** Upload a JS exception to the log service. */ + captureError(error: unknown, metadata?: Record): void { + if (!isInitialized()) return + const err = error instanceof Error ? error : new Error(String(error)) + const issue: IssueEvent = { + type: 'js_error', + message: err.message, + stack: err.stack, + fingerprint: computeFingerprint('js_error', err.message, err.stack), + timestamp: Date.now(), + userId: getUserId() ?? undefined, + sessionId: _sessionId, + appKey: getConfig().appKey, + platform: _getPlatform(), + appVersion: _getAppVersion(), + metadata: metadata ? { ...metadata, environment: _environment } : { environment: _environment }, + } + _enqueue(issue) + }, + + /** Log a warning (if log level allows). */ + warn(message: string, metadata?: Record): void { + if (_levelNum(_logLevel) > _levelNum('warn')) return + XLog.captureError(new Error(message), { ...metadata, level: 'warn' }) + }, + + /** Log an informational event (if log level allows). */ + info(message: string, metadata?: Record): void { + if (_levelNum(_logLevel) > _levelNum('info')) return + XLog.event('__log_info', { message, ...metadata }) + }, + + /** + * Enable automatic capture of JS global errors and unhandled Promise rejections. + * Call once at app startup after XuqmSDK.initialize(). + */ + startCapture(): void { + if (_errorCaptureStarted) return + _errorCaptureStarted = true + ErrorCapture.start(XLog.captureError.bind(XLog)) + }, + + /** Define a funnel for step-by-step conversion tracking. */ + defineFunnel: FunnelTracker.define.bind(FunnelTracker), + + /** Get client-side funnel progress (server aggregates across sessions). */ + getFunnelProgress: FunnelTracker.getProgress.bind(FunnelTracker), + + /** Flush any pending events immediately (e.g. before app goes to background). */ + async flush(): Promise { + if (_queue) await _queue.flush() + }, +} diff --git a/packages/log/src/capture/ErrorCapture.ts b/packages/log/src/capture/ErrorCapture.ts new file mode 100644 index 0000000..fd2986a --- /dev/null +++ b/packages/log/src/capture/ErrorCapture.ts @@ -0,0 +1,40 @@ +/** + * ErrorCapture — hooks into the RN global error handler and unhandled promise + * rejections to automatically forward errors to XLog.captureError. + */ + +// React Native exposes ErrorUtils on the global object +declare const ErrorUtils: { + getGlobalHandler(): ((error: Error, isFatal?: boolean) => void) | null + setGlobalHandler(handler: (error: Error, isFatal?: boolean) => void): void +} + +// React Native / Hermes exposes onunhandledrejection on global +declare global { + // eslint-disable-next-line no-var + var onunhandledrejection: + | ((event: { reason: unknown }) => void) + | null + | undefined +} + +export const ErrorCapture = { + start(onError: (error: unknown, meta?: Record) => void): void { + // JS global error handler + const prevError = + typeof ErrorUtils !== 'undefined' ? ErrorUtils.getGlobalHandler() : null + if (typeof ErrorUtils !== 'undefined') { + ErrorUtils.setGlobalHandler((error, isFatal) => { + onError(error, { isFatal: isFatal ?? false }) + prevError?.(error, isFatal) + }) + } + + // Unhandled Promise rejection + const prevUnhandled = global.onunhandledrejection + global.onunhandledrejection = (event: { reason: unknown }) => { + onError(event.reason, { type: 'unhandledRejection' }) + prevUnhandled?.(event) + } + }, +} diff --git a/packages/log/src/fingerprint.ts b/packages/log/src/fingerprint.ts new file mode 100644 index 0000000..663e594 --- /dev/null +++ b/packages/log/src/fingerprint.ts @@ -0,0 +1,149 @@ +/** + * Pure-JS SHA-256 implementation. + * Hermes does not support Node.js `crypto` module, so we inline a minimal + * SHA-256 implementation compatible with the Hermes JS engine. + */ + +// ─── SHA-256 ────────────────────────────────────────────────────────────────── + +const K: number[] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, + 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, + 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, + 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, + 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +] + +function rotr32(x: number, n: number): number { + return ((x >>> n) | (x << (32 - n))) >>> 0 +} + +function sha256(message: string): string { + // Encode to UTF-8 bytes + const bytes: number[] = [] + for (let i = 0; i < message.length; i++) { + let c = message.charCodeAt(i) + if (c < 0x80) { + bytes.push(c) + } else if (c < 0x800) { + bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)) + } else if (c < 0xd800 || c >= 0xe000) { + bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)) + } else { + // Surrogate pair + i++ + c = 0x10000 + (((c & 0x3ff) << 10) | (message.charCodeAt(i) & 0x3ff)) + bytes.push( + 0xf0 | (c >> 18), + 0x80 | ((c >> 12) & 0x3f), + 0x80 | ((c >> 6) & 0x3f), + 0x80 | (c & 0x3f), + ) + } + } + + const msgLen = bytes.length + bytes.push(0x80) + while ((bytes.length % 64) !== 56) bytes.push(0) + + // Append length as 64-bit big-endian (we only support messages < 2^32 bytes) + const bitLen = msgLen * 8 + bytes.push(0, 0, 0, 0) + bytes.push((bitLen >>> 24) & 0xff, (bitLen >>> 16) & 0xff, (bitLen >>> 8) & 0xff, bitLen & 0xff) + + let h0 = 0x6a09e667 + let h1 = 0xbb67ae85 + let h2 = 0x3c6ef372 + let h3 = 0xa54ff53a + let h4 = 0x510e527f + let h5 = 0x9b05688c + let h6 = 0x1f83d9ab + let h7 = 0x5be0cd19 + + const w = new Array(64) + + for (let i = 0; i < bytes.length; i += 64) { + for (let j = 0; j < 16; j++) { + w[j] = + ((bytes[i + j * 4] << 24) | + (bytes[i + j * 4 + 1] << 16) | + (bytes[i + j * 4 + 2] << 8) | + bytes[i + j * 4 + 3]) >>> + 0 + } + for (let j = 16; j < 64; j++) { + const s0 = (rotr32(w[j - 15], 7) ^ rotr32(w[j - 15], 18) ^ (w[j - 15] >>> 3)) >>> 0 + const s1 = (rotr32(w[j - 2], 17) ^ rotr32(w[j - 2], 19) ^ (w[j - 2] >>> 10)) >>> 0 + w[j] = (w[j - 16] + s0 + w[j - 7] + s1) >>> 0 + } + + let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7 + + for (let j = 0; j < 64; j++) { + const S1 = (rotr32(e, 6) ^ rotr32(e, 11) ^ rotr32(e, 25)) >>> 0 + const ch = ((e & f) ^ (~e & g)) >>> 0 + const temp1 = (h + S1 + ch + K[j] + w[j]) >>> 0 + const S0 = (rotr32(a, 2) ^ rotr32(a, 13) ^ rotr32(a, 22)) >>> 0 + const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0 + const temp2 = (S0 + maj) >>> 0 + + h = g; g = f; f = e + e = (d + temp1) >>> 0 + d = c; c = b; b = a + a = (temp1 + temp2) >>> 0 + } + + h0 = (h0 + a) >>> 0 + h1 = (h1 + b) >>> 0 + h2 = (h2 + c) >>> 0 + h3 = (h3 + d) >>> 0 + h4 = (h4 + e) >>> 0 + h5 = (h5 + f) >>> 0 + h6 = (h6 + g) >>> 0 + h7 = (h7 + h) >>> 0 + } + + return [h0, h1, h2, h3, h4, h5, h6, h7] + .map(v => v.toString(16).padStart(8, '0')) + .join('') +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +function normalizeMessage(msg: string): string { + return msg.replace(/\b\d{4,}\b/g, 'N').replace(/[a-f0-9]{32,}/gi, 'H') +} + +function extractTop3Frames(stack?: string): string { + if (!stack) return '' + return stack + .split('\n') + .filter(l => l.includes('at ')) + .slice(0, 3) + .join('|') +} + +/** + * Compute a SHA-256 fingerprint for deduplication. + * fingerprint = SHA-256(type + ":" + normalizedMessage + ":" + top3Frames) + */ +export function computeFingerprint( + type: string, + message: string, + stack?: string, +): string { + const top3 = extractTop3Frames(stack) + const raw = `${type}:${normalizeMessage(message)}:${top3}` + return sha256(raw) +} diff --git a/packages/log/src/funnel/FunnelTracker.ts b/packages/log/src/funnel/FunnelTracker.ts new file mode 100644 index 0000000..9303fb7 --- /dev/null +++ b/packages/log/src/funnel/FunnelTracker.ts @@ -0,0 +1,64 @@ +/** + * FunnelTracker — client-side funnel step tracking. + * Tracks which steps of a defined funnel have been completed in sequence. + * Final aggregation is done server-side using the events uploaded by LogQueue. + */ + +interface FunnelDefinition { + id: string + steps: string[] // event names in order +} + +interface FunnelProgress { + funnelId: string + completedSteps: string[] + completedAt?: number +} + +const _funnels: Map = new Map() +const _progress: Map = new Map() + +export const FunnelTracker = { + /** Define a funnel with ordered steps. Resets progress if already defined. */ + define(funnel: FunnelDefinition): void { + _funnels.set(funnel.id, funnel) + _progress.set(funnel.id, { funnelId: funnel.id, completedSteps: [] }) + }, + + /** + * Called automatically by XLog.event for each event. + * Advances any funnel whose next expected step matches eventName. + */ + track(eventName: string, _properties?: Record): void { + for (const [id, funnel] of _funnels) { + const progress = _progress.get(id) + if (!progress) continue + const nextStep = funnel.steps[progress.completedSteps.length] + if (nextStep === eventName) { + progress.completedSteps.push(eventName) + if (progress.completedSteps.length === funnel.steps.length) { + progress.completedAt = Date.now() + } + } + } + }, + + /** Get the current progress for a funnel (undefined if not defined). */ + getProgress(funnelId: string): FunnelProgress | undefined { + return _progress.get(funnelId) + }, + + /** Reset a specific funnel's progress (e.g. on logout). */ + reset(funnelId: string): void { + if (_funnels.has(funnelId)) { + _progress.set(funnelId, { funnelId, completedSteps: [] }) + } + }, + + /** Reset all funnels' progress. */ + resetAll(): void { + for (const [id] of _funnels) { + _progress.set(id, { funnelId: id, completedSteps: [] }) + } + }, +} diff --git a/packages/log/src/index.ts b/packages/log/src/index.ts new file mode 100644 index 0000000..817bdd5 --- /dev/null +++ b/packages/log/src/index.ts @@ -0,0 +1,3 @@ +export { XLog } from './XLog' +export type { LogLevel, Environment, LogEvent, IssueEvent, XLogEvent } from './types' +export type { UpdateDownloadProgress } from './types' diff --git a/packages/log/src/interceptor/HttpInterceptor.ts b/packages/log/src/interceptor/HttpInterceptor.ts new file mode 100644 index 0000000..08e9e51 --- /dev/null +++ b/packages/log/src/interceptor/HttpInterceptor.ts @@ -0,0 +1,51 @@ +/** + * HttpInterceptor — wraps the global fetch to automatically report + * HTTP error responses (4xx / 5xx) through XLog.captureError. + * + * Usage: + * HttpInterceptor.start(XLog.captureError.bind(XLog)) + */ + +type OnError = (error: unknown, meta?: Record) => void + +let _originalFetch: typeof fetch | null = null + +export const HttpInterceptor = { + start(onError: OnError): void { + if (_originalFetch) return // already installed + + _originalFetch = global.fetch + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(global as any).fetch = async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + const original = _originalFetch! + try { + const res = await original(input, init) + if (!res.ok) { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + onError(new Error(`HTTP ${res.status} ${res.statusText}`), { + type: 'api_error', + url, + status: res.status, + }) + } + return res + } catch (e) { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + onError(e, { type: 'api_error', url }) + throw e + } + } + }, + + stop(): void { + if (_originalFetch) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(global as any).fetch = _originalFetch + _originalFetch = null + } + }, +} diff --git a/packages/log/src/native/NativeLogReporter.ts b/packages/log/src/native/NativeLogReporter.ts new file mode 100644 index 0000000..4cd58bc --- /dev/null +++ b/packages/log/src/native/NativeLogReporter.ts @@ -0,0 +1,26 @@ +/** + * NativeLogReporter — thin wrapper around the LogReporter TurboModule. + * Falls back gracefully if the native module is not linked. + */ +import { NativeModules } from 'react-native' + +interface LogReporterModuleInterface { + startNativeCrashCapture(logApiUrl: string, appKey: string): void + stopNativeCrashCapture(): void +} + +const _native = NativeModules.LogReporter as LogReporterModuleInterface | undefined + +export const NativeLogReporter = { + isAvailable(): boolean { + return !!_native + }, + + startNativeCrashCapture(logApiUrl: string, appKey: string): void { + _native?.startNativeCrashCapture(logApiUrl, appKey) + }, + + stopNativeCrashCapture(): void { + _native?.stopNativeCrashCapture() + }, +} diff --git a/packages/log/src/queue/LogQueue.ts b/packages/log/src/queue/LogQueue.ts new file mode 100644 index 0000000..6d898cb --- /dev/null +++ b/packages/log/src/queue/LogQueue.ts @@ -0,0 +1,75 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import type { XLogEvent } from '../types' + +const QUEUE_KEY = '@xuqm_log:queue' +const BATCH_SIZE = 30 +const FLUSH_INTERVAL_MS = 10_000 // 10 seconds +const MAX_QUEUE_SIZE = 500 + +export class LogQueue { + private flushTimer: ReturnType | null = null + + constructor(private cfg: { logApiUrl: string; appKey: string }) { + this.flushTimer = setInterval(() => { + void this.flush() + }, FLUSH_INTERVAL_MS) + } + + async push(event: XLogEvent): Promise { + const queue = await this._read() + if (queue.length >= MAX_QUEUE_SIZE) queue.shift() // drop oldest + queue.push(event) + await this._write(queue) + } + + async flush(): Promise { + const queue = await this._read() + if (queue.length === 0) return + + const batch = queue.splice(0, BATCH_SIZE) + await this._write(queue) // remove from queue first to prevent duplicate sends + + const issues = batch.filter(e => e.type !== 'event') + const events = batch.filter(e => e.type === 'event') + + try { + if (issues.length > 0) await this._post('/log/v1/issues/batch', issues) + if (events.length > 0) await this._post('/log/v1/events/batch', events) + } catch { + // On failure, push batch back to front of queue (retry once on next flush) + const current = await this._read() + await this._write([...batch, ...current]) + } + } + + destroy(): void { + if (this.flushTimer !== null) { + clearInterval(this.flushTimer) + this.flushTimer = null + } + } + + private async _post(path: string, data: XLogEvent[]): Promise { + const body = JSON.stringify({ events: data }) + const res = await fetch(`${this.cfg.logApiUrl}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-App-Key': this.cfg.appKey, + }, + body, + }) + if (!res.ok) { + throw new Error(`[XuqmLog] Upload failed: ${res.status}`) + } + } + + private async _read(): Promise { + const raw = await AsyncStorage.getItem(QUEUE_KEY) + return raw ? (JSON.parse(raw) as XLogEvent[]) : [] + } + + private async _write(queue: XLogEvent[]): Promise { + await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue)) + } +} diff --git a/packages/log/src/types.ts b/packages/log/src/types.ts new file mode 100644 index 0000000..9657d23 --- /dev/null +++ b/packages/log/src/types.ts @@ -0,0 +1,32 @@ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' +export type Environment = 'development' | 'staging' | 'production' + +export interface LogEvent { + type: 'event' + name: string + properties?: Record + timestamp: number + userId?: string + sessionId: string + appKey: string + platform: 'ios' | 'android' + appVersion: string + fingerprint?: string +} + +export interface IssueEvent { + type: 'js_error' | 'native_crash' | 'api_error' | 'warning' + message: string + stack?: string + fingerprint: string + count?: number + timestamp: number + userId?: string + sessionId: string + appKey: string + platform: 'ios' | 'android' + appVersion: string + metadata?: Record +} + +export type XLogEvent = LogEvent | IssueEvent diff --git a/packages/log/tsconfig.json b/packages/log/tsconfig.json new file mode 100644 index 0000000..3357662 --- /dev/null +++ b/packages/log/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-native", + "baseUrl": "../../", + "paths": { + "@xuqm/rn-common": ["packages/common/src"], + "@react-native-async-storage/async-storage": ["src/shims/async-storage.ts"], + "@nozbe/watermelondb": ["src/shims/watermelondb.ts"], + "@nozbe/watermelondb/decorators": ["src/shims/watermelondb.ts"], + "@nozbe/watermelondb/adapters/sqlite": ["src/shims/watermelondb.ts"] + }, + "noEmit": true + }, + "include": ["src/**/*", "specs/**/*"], + "exclude": ["node_modules", "dist", "metro"] +} diff --git a/packages/update/package.json b/packages/update/package.json index c2d282f..d2f9008 100644 --- a/packages/update/package.json +++ b/packages/update/package.json @@ -1,6 +1,6 @@ { "name": "@xuqm/rn-update", - "version": "0.3.0", + "version": "0.4.0", "description": "XuqmGroup RN SDK — Update module (App update, RN plugin hot-update)", "license": "UNLICENSED", "main": "src/index.ts", diff --git a/packages/update/src/UpdateSDK.ts b/packages/update/src/UpdateSDK.ts index c469181..5c1c2a1 100644 --- a/packages/update/src/UpdateSDK.ts +++ b/packages/update/src/UpdateSDK.ts @@ -41,6 +41,13 @@ export interface PluginUpdateInfo { changeLog?: string } +export type UpdateDownloadProgress = { + bytesDownloaded: number + totalBytes: number + /** 0-100 */ + percent: number +} + export type { CachedRnBundle } interface CachedRnBundle { moduleId: string @@ -54,6 +61,32 @@ interface CachedRnBundle { const _pluginRegistry = new Set() +// ─── Update check cache ──────────────────────────────────────────────────────── + +const UPDATE_APP_CACHE_KEY = 'xuqm_update_app_cache' +const UPDATE_PLUGIN_CACHE_KEY_PREFIX = 'xuqm_update_plugin_cache_' +const UPDATE_CACHE_TTL_MS = 30 * 60 * 1000 // 30 minutes + +async function _readUpdateCache(key: string): Promise { + try { + const raw = await AsyncStorage.getItem(key) + if (!raw) return null + const cached = JSON.parse(raw) as { ts: number; data: T } + if (Date.now() - cached.ts < UPDATE_CACHE_TTL_MS) return cached.data + return null + } catch { + return null + } +} + +async function _writeUpdateCache(key: string, data: T): Promise { + try { + await AsyncStorage.setItem(key, JSON.stringify({ ts: Date.now(), data })) + } catch { + // cache write failure is non-fatal + } +} + let _writeBundleCallback: ((moduleId: string, source: string) => Promise) | null = null let _reloadBundleCallback: ((moduleId: string) => Promise) | null = null @@ -188,6 +221,12 @@ export const UpdateSDK = { // ── App 整包更新 ────────────────────────────────────────────────────────── async checkAppUpdate(bypassIgnore?: boolean): Promise { + // Return cached result if within TTL (cache is skipped when bypassIgnore is set) + if (!bypassIgnore) { + const cached = await _readUpdateCache(UPDATE_APP_CACHE_KEY) + if (cached) return cached + } + const config = getConfig() const params: Record = { appKey: config.appKey, @@ -202,7 +241,9 @@ export const UpdateSDK = { skipAuth: true, params, }) - return { ...result, downloadUrl: normalizeDownloadUrl(result.downloadUrl) } + const normalized = { ...result, downloadUrl: normalizeDownloadUrl(result.downloadUrl) } + await _writeUpdateCache(UPDATE_APP_CACHE_KEY, normalized) + return normalized }, async openStore(appStoreUrl?: string, marketUrl?: string): Promise { @@ -210,6 +251,96 @@ export const UpdateSDK = { if (url) await Linking.openURL(url) }, + /** + * 下载 APK 文件,返回 ArrayBuffer。 + * 支持进度回调(UpdateDownloadProgress),向下兼容(options 可选)。 + */ + async downloadApk( + updateInfo: AppUpdateInfo, + options?: { + onProgress?: (progress: UpdateDownloadProgress) => void + }, + ): Promise { + const url = updateInfo.downloadUrl + if (!url) throw new Error('[UpdateSDK] downloadApk: no downloadUrl in updateInfo') + + if (!options?.onProgress) { + return _downloadBinary(url) + } + + // Streaming download with detailed progress + const response = await fetch(url) + if (!response.ok) throw new Error(`[UpdateSDK] downloadApk failed: ${response.status}`) + const contentLength = Number(response.headers.get('Content-Length') ?? '0') + const reader = response.body?.getReader() + if (!reader) return response.arrayBuffer() + + const chunks: Uint8Array[] = [] + let received = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + received += value.length + options.onProgress({ + bytesDownloaded: received, + totalBytes: contentLength, + percent: contentLength > 0 ? Math.min(100, Math.round((received / contentLength) * 100)) : 0, + }) + } + const totalLength = chunks.reduce((acc, c) => acc + c.length, 0) + const combined = new Uint8Array(totalLength) + let offset = 0 + for (const chunk of chunks) { + combined.set(chunk, offset) + offset += chunk.length + } + return combined.buffer + }, + + /** + * 下载插件 bundle,返回 bundle 文本内容(JS 源码)。 + * 支持进度回调(UpdateDownloadProgress),向下兼容(options 可选)。 + */ + async downloadPlugin( + moduleId: string, + updateInfo: PluginUpdateInfo, + options?: { + onProgress?: (progress: UpdateDownloadProgress) => void + }, + ): Promise { + if (!updateInfo.downloadUrl) { + throw new Error(`[UpdateSDK] downloadPlugin(${moduleId}): no downloadUrl in updateInfo`) + } + + if (!options?.onProgress) { + return _downloadText(updateInfo.downloadUrl) + } + + // Streaming download with detailed progress + const response = await fetch(updateInfo.downloadUrl) + if (!response.ok) throw new Error(`[UpdateSDK] downloadPlugin failed: ${response.status}`) + const contentLength = Number(response.headers.get('Content-Length') ?? '0') + const reader = response.body?.getReader() + if (!reader) return response.text() + + const chunks: Uint8Array[] = [] + let received = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + received += value.length + options.onProgress({ + bytesDownloaded: received, + totalBytes: contentLength, + percent: contentLength > 0 ? Math.min(100, Math.round((received / contentLength) * 100)) : 0, + }) + } + const decoder = new TextDecoder() + return chunks.map(c => decoder.decode(c, { stream: true })).join('') + decoder.decode() + }, + /** * Android APK 直接下载并调起系统安装器。 */ @@ -247,6 +378,12 @@ export const UpdateSDK = { 'Call UpdateSDK.registerPlugins([{ moduleId }]) first.', ) } + + // Return cached result if within TTL + const cacheKey = `${UPDATE_PLUGIN_CACHE_KEY_PREFIX}${moduleId}` + const cached = await _readUpdateCache(cacheKey) + if (cached) return cached + const config = getConfig() const currentVersion = await _getCachedVersion(moduleId) const userId = getUserId()?.trim() @@ -262,11 +399,13 @@ export const UpdateSDK = { skipAuth: true, params, }) - return { + const normalized = { ...result, currentVersion, downloadUrl: normalizeDownloadUrl(result.downloadUrl) ?? result.downloadUrl, } + await _writeUpdateCache(cacheKey, normalized) + return normalized }, /** diff --git a/packages/update/src/index.ts b/packages/update/src/index.ts index 5895205..0f37210 100644 --- a/packages/update/src/index.ts +++ b/packages/update/src/index.ts @@ -1,2 +1,3 @@ export { UpdateSDK } from './UpdateSDK' -export type { PluginRegistration, PluginMeta, AppUpdateInfo, PluginUpdateInfo, CachedRnBundle } from './UpdateSDK' +export type { PluginRegistration, PluginMeta, AppUpdateInfo, PluginUpdateInfo, CachedRnBundle, UpdateDownloadProgress } from './UpdateSDK' +export { NativeBundle } from './NativeBundle' diff --git a/packages/xwebview/src/index.ts b/packages/xwebview/src/index.ts index 0ee7c24..f219633 100644 --- a/packages/xwebview/src/index.ts +++ b/packages/xwebview/src/index.ts @@ -1,5 +1,12 @@ -export { getXWebViewConfig, openXWebView, setXWebViewController, XWebViewControl } from '@xuqm/rn-common' +export { + getXWebViewConfig, + openXWebView, + setXWebViewController, + setXWebViewScanQRCodeHandler, + XWebViewControl, +} from '@xuqm/rn-common' export type { + ScanQRCodeHandler, XWebViewClickMenu, XWebViewConfig, XWebViewControllerAPI, diff --git a/yarn.lock b/yarn.lock index eb47cb6..04a1d98 100644 --- a/yarn.lock +++ b/yarn.lock @@ -421,6 +421,13 @@ acorn@^8.15.0: resolved "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz" integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== +agent-base@6: + version "6.0.2" + resolved "https://nexus.xuqinmin.com/repository/npm/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + agent-base@^7.1.2: version "7.1.4" resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz" @@ -453,6 +460,21 @@ asap@~2.0.6: resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz" integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== +asynckit@^0.4.0: + version "0.4.0" + resolved "https://nexus.xuqinmin.com/repository/npm/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +axios@^1.18.0, axios@^1.7.0: + version "1.18.0" + resolved "https://nexus.xuqinmin.com/repository/npm/axios/-/axios-1.18.0.tgz#8a7f8854af280fcaae063272df2ed9f3837d2398" + integrity sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw== + dependencies: + follow-redirects "^1.16.0" + form-data "^4.0.5" + https-proxy-agent "^5.0.1" + proxy-from-env "^2.1.0" + babel-plugin-syntax-hermes-parser@0.33.3: version "0.33.3" resolved "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.33.3.tgz" @@ -522,6 +544,14 @@ buffer-from@^1.0.0: resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://nexus.xuqinmin.com/repository/npm/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + camelcase@^5.0.0: version "5.3.1" resolved "https://nexus.xuqinmin.com/repository/npm/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" @@ -606,6 +636,13 @@ color-name@~1.1.4: resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://nexus.xuqinmin.com/repository/npm/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + commander@^12.0.0: version "12.1.0" resolved "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz" @@ -693,6 +730,11 @@ decode-uri-component@^0.2.2: resolved "https://nexus.xuqinmin.com/repository/npm/decode-uri-component/-/decode-uri-component-0.2.2.tgz" integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ== +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://nexus.xuqinmin.com/repository/npm/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + depd@2.0.0, depd@~2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz" @@ -738,6 +780,15 @@ domutils@^3.0.1: domelementtype "^2.3.0" domhandler "^5.0.3" +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://nexus.xuqinmin.com/repository/npm/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz" @@ -775,6 +826,33 @@ error-stack-parser@^2.0.6: dependencies: stackframe "^1.3.4" +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://nexus.xuqinmin.com/repository/npm/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://nexus.xuqinmin.com/repository/npm/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.2" + resolved "https://nexus.xuqinmin.com/repository/npm/es-object-atoms/-/es-object-atoms-1.1.2.tgz#a2d0b373205724dfa525d23b0c3e1b1ca582c99b" + integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw== + dependencies: + es-errors "^1.3.0" + +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://nexus.xuqinmin.com/repository/npm/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz" @@ -865,11 +943,32 @@ flow-enums-runtime@^0.0.6: resolved "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz" integrity sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw== +follow-redirects@^1.16.0: + version "1.16.0" + resolved "https://nexus.xuqinmin.com/repository/npm/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" + integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== + +form-data@^4.0.5: + version "4.0.6" + resolved "https://nexus.xuqinmin.com/repository/npm/form-data/-/form-data-4.0.6.tgz#28e864e1b786dbebb68db1f452f9635278665827" + integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.4" + mime-types "^2.1.35" + fresh@~0.5.2: version "0.5.2" resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz" integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== +function-bind@^1.1.2: + version "1.1.2" + resolved "https://nexus.xuqinmin.com/repository/npm/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz" @@ -880,6 +979,30 @@ get-caller-file@^2.0.1, get-caller-file@^2.0.5: resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== +get-intrinsic@^1.2.6: + version "1.3.0" + resolved "https://nexus.xuqinmin.com/repository/npm/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + +get-proto@^1.0.1: + version "1.0.1" + resolved "https://nexus.xuqinmin.com/repository/npm/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + glob@13.0.1: version "13.0.1" resolved "https://nexus.xuqinmin.com/repository/npm/glob/-/glob-13.0.1.tgz" @@ -889,6 +1012,11 @@ glob@13.0.1: minipass "^7.1.2" path-scurry "^2.0.0" +gopd@^1.2.0: + version "1.2.0" + resolved "https://nexus.xuqinmin.com/repository/npm/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + graceful-fs@^4.2.4, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz" @@ -899,6 +1027,25 @@ has-flag@^4.0.0: resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== +has-symbols@^1.0.3, has-symbols@^1.1.0: + version "1.1.0" + resolved "https://nexus.xuqinmin.com/repository/npm/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://nexus.xuqinmin.com/repository/npm/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +hasown@^2.0.2, hasown@^2.0.4: + version "2.0.4" + resolved "https://nexus.xuqinmin.com/repository/npm/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== + dependencies: + function-bind "^1.1.2" + hermes-compiler@250829098.0.10: version "250829098.0.10" resolved "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-250829098.0.10.tgz" @@ -939,6 +1086,14 @@ http-errors@~2.0.1: statuses "~2.0.2" toidentifier "~1.0.1" +https-proxy-agent@^5.0.1: + version "5.0.1" + resolved "https://nexus.xuqinmin.com/repository/npm/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + https-proxy-agent@^7.0.5: version "7.0.6" resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz" @@ -1113,6 +1268,11 @@ marky@^1.2.2: resolved "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz" integrity sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ== +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://nexus.xuqinmin.com/repository/npm/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + mdn-data@2.0.14: version "2.0.14" resolved "https://nexus.xuqinmin.com/repository/npm/mdn-data/-/mdn-data-2.0.14.tgz" @@ -1336,11 +1496,23 @@ micromatch@^4.0.4: braces "^3.0.3" picomatch "^2.3.1" +mime-db@1.52.0: + version "1.52.0" + resolved "https://nexus.xuqinmin.com/repository/npm/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + mime-db@^1.54.0: version "1.54.0" resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz" integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== +mime-types@^2.1.35: + version "2.1.35" + resolved "https://nexus.xuqinmin.com/repository/npm/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + mime-types@^3.0.0, mime-types@^3.0.1: version "3.0.2" resolved "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz" @@ -1533,6 +1705,11 @@ prop-types@^15.8.0: object-assign "^4.1.1" react-is "^16.13.1" +proxy-from-env@^2.1.0: + version "2.1.0" + resolved "https://nexus.xuqinmin.com/repository/npm/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba" + integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA== + qrcode@^1.5.4: version "1.5.4" resolved "https://nexus.xuqinmin.com/repository/npm/qrcode/-/qrcode-1.5.4.tgz#5cb81d86eb57c675febb08cf007fff963405da88" @@ -1668,6 +1845,11 @@ react-refresh@^0.14.0: resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz" integrity sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA== +react@^19.0.0: + version "19.2.7" + resolved "https://nexus.xuqinmin.com/repository/npm/react/-/react-19.2.7.tgz#1f47a1bfc06f8ec885752c6f4af14369a9f8260b" + integrity sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ== + regenerator-runtime@^0.13.2: version "0.13.11" resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz" @@ -2041,3 +2223,13 @@ yargs@^17.6.2: string-width "^4.2.3" y18n "^5.0.5" yargs-parser "^21.1.1" + +zod@3.23.8: + version "3.23.8" + resolved "https://nexus.xuqinmin.com/repository/npm/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" + integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== + +zod@^3.23.0: + version "3.25.76" + resolved "https://nexus.xuqinmin.com/repository/npm/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==