feat(sdk): 下载相册/进度、XWebView内联config、上报限频采样、版本忽略、push桥接原生sdk-push

- xwebview: 下载支持公共Downloads/相册(CameraRoll懒加载)/JS进度/openFile;XWebViewView 新增内联 config prop(同屏多实例不污染全局)
- bugcollect: startCapture 自动启用 HttpInterceptor + 按指纹滑动窗口限频 + setSampleRate 采样(降低服务端存储压力)
- update: ignoreAppVersion/clearIgnoredAppVersion/getIgnoredAppVersion 客户端忽略版本(强制更新除外)
- common/metro: withXuqmConfig 自动链式 withBugCollect(Release 包 sourcemap 自动上传)
- push: 桥接完整原生 sdk-push(Android Kotlin + iOS Swift + JS 薄层),删除旧反射实现;单一 XuqmSDK.setUserInfo 驱动厂商检测/token/绑定

Co-Authored-By: Claude <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-06-25 17:50:29 +08:00
父节点 1567c673ea
当前提交 4d2b834f96
共有 20 个文件被更改,包括 573 次插入425 次删除

查看文件

@ -2,6 +2,7 @@ import { Platform, Dimensions } from 'react-native'
import { getConfig, isInitialized, getUserId } from '@xuqm/rn-common' import { getConfig, isInitialized, getUserId } from '@xuqm/rn-common'
import { LogQueue } from './queue/LogQueue' import { LogQueue } from './queue/LogQueue'
import { ErrorCapture } from './capture/ErrorCapture' import { ErrorCapture } from './capture/ErrorCapture'
import { HttpInterceptor } from './interceptor/HttpInterceptor'
import { computeFingerprint } from './fingerprint' import { computeFingerprint } from './fingerprint'
import { FunnelTracker } from './funnel/FunnelTracker' import { FunnelTracker } from './funnel/FunnelTracker'
import type { import type {
@ -25,8 +26,40 @@ let _logLevel: LogLevel = 'warn'
let _environment: Environment = 'production' let _environment: Environment = 'production'
let _queue: LogQueue | null = null let _queue: LogQueue | null = null
let _errorCaptureStarted = false let _errorCaptureStarted = false
let _httpInterceptorStarted = false
let _breadcrumbs: BreadcrumbItem[] = [] let _breadcrumbs: BreadcrumbItem[] = []
// ─── 上报频率控制 / 采样(降低服务端存储与流量压力)───────────────────────────
// 同一指纹在滑动窗口内最多上报 N 次,避免相同错误刷屏;fatal 不受限、不抽样。
const RATE_WINDOW_MS = 60_000
const RATE_MAX_PER_FINGERPRINT = 5
let _sampleRate = 1 // 1=全量;0~1 对非致命事件抽样
const _fingerprintHits = new Map<string, number[]>()
function _isFatal(event: BugCollectEvent): boolean {
return event.type === 'issue' && event.level === 'fatal'
}
function _eventKey(event: BugCollectEvent): string {
return event.type === 'issue' ? event.fingerprint : `event:${event.name}`
}
/** 是否允许上报fatal 必报;否则先采样、再按指纹滑动窗口限频。 */
function _shouldReport(event: BugCollectEvent): boolean {
if (_isFatal(event)) return true
if (_sampleRate < 1 && Math.random() > _sampleRate) return false
const key = _eventKey(event)
const now = Date.now()
const hits = (_fingerprintHits.get(key) ?? []).filter(t => now - t < RATE_WINDOW_MS)
if (hits.length >= RATE_MAX_PER_FINGERPRINT) {
_fingerprintHits.set(key, hits)
return false
}
hits.push(now)
_fingerprintHits.set(key, hits)
return true
}
const _sessionId: string = _generateSessionId() const _sessionId: string = _generateSessionId()
function _generateSessionId(): string { function _generateSessionId(): string {
@ -76,6 +109,7 @@ function _enqueue(event: BugCollectEvent): void {
if (!cfg.bugCollectApiUrl || !cfg.bugCollectEnabled) return if (!cfg.bugCollectApiUrl || !cfg.bugCollectEnabled) return
_queue = new LogQueue({ bugCollectApiUrl: cfg.bugCollectApiUrl, appKey: cfg.appKey }) _queue = new LogQueue({ bugCollectApiUrl: cfg.bugCollectApiUrl, appKey: cfg.appKey })
} }
if (!_shouldReport(event)) return
void _queue.push(event) void _queue.push(event)
} }
@ -115,6 +149,11 @@ export const BugCollect = {
_environment = env _environment = env
}, },
/** 设置非致命事件的采样率0~1,默认 1=全量。fatal 始终全量上报。 */
setSampleRate(rate: number): void {
_sampleRate = Math.max(0, Math.min(1, rate))
},
/** Add a breadcrumb to the in-memory circular buffer (max 100). */ /** Add a breadcrumb to the in-memory circular buffer (max 100). */
addBreadcrumb(item: Omit<BreadcrumbItem, 'timestamp'> & { timestamp?: number }): void { addBreadcrumb(item: Omit<BreadcrumbItem, 'timestamp'> & { timestamp?: number }): void {
const crumb: BreadcrumbItem = { ...item, timestamp: item.timestamp ?? Date.now() } const crumb: BreadcrumbItem = { ...item, timestamp: item.timestamp ?? Date.now() }
@ -201,13 +240,19 @@ export const BugCollect = {
}, },
/** /**
* Enable automatic capture of JS global errors and unhandled Promise rejections. * Enable automatic capture of JS global errors and unhandled Promise rejections,
* plus HTTP/ global.fetch
* Call once at app startup after XuqmSDK.initialize(). * Call once at app startup after XuqmSDK.initialize().
*/ */
startCapture(): void { startCapture(): void {
if (_errorCaptureStarted) return if (!_errorCaptureStarted) {
_errorCaptureStarted = true _errorCaptureStarted = true
ErrorCapture.start(BugCollect.captureError.bind(BugCollect)) ErrorCapture.start(BugCollect.captureError.bind(BugCollect))
}
if (!_httpInterceptorStarted) {
_httpInterceptorStarted = true
HttpInterceptor.start(BugCollect.captureError.bind(BugCollect))
}
}, },
defineFunnel: FunnelTracker.define.bind(FunnelTracker), defineFunnel: FunnelTracker.define.bind(FunnelTracker),
@ -222,6 +267,11 @@ export const BugCollect = {
_queue.destroy() _queue.destroy()
_queue = null _queue = null
} }
if (_httpInterceptorStarted) {
HttpInterceptor.stop()
_httpInterceptorStarted = false
}
_fingerprintHits.clear()
_breadcrumbs = [] _breadcrumbs = []
}, },
} }

查看文件

@ -46,13 +46,25 @@ function findConfigFile(projectRoot) {
return null; return null;
} }
function applyBugCollect(metroConfig) {
// 可选链式:安装了 @xuqm/rn-bugcollect 时,自动启用 Release 包 SourceMap 上传。
// 未安装则原样返回(不强制依赖)。
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { withBugCollect } = require('@xuqm/rn-bugcollect/metro');
return typeof withBugCollect === 'function' ? withBugCollect(metroConfig) : metroConfig;
} catch {
return metroConfig;
}
}
function withXuqmConfig(metroConfig) { function withXuqmConfig(metroConfig) {
const projectRoot = metroConfig.projectRoot ?? process.cwd(); const projectRoot = metroConfig.projectRoot ?? process.cwd();
const configFile = findConfigFile(projectRoot); const configFile = findConfigFile(projectRoot);
if (!configFile) { if (!configFile) {
// 未找到配置文件,返回原配置autoInit 会静默跳过) // 未找到配置文件,返回原配置autoInit 会静默跳过)
return metroConfig; return applyBugCollect(metroConfig);
} }
// 读取加密配置内容 // 读取加密配置内容
@ -71,7 +83,7 @@ function withXuqmConfig(metroConfig) {
// 拦截模块解析,将 @xuqm/autoinit-config 指向虚拟文件 // 拦截模块解析,将 @xuqm/autoinit-config 指向虚拟文件
const existingResolveRequest = metroConfig.resolver?.resolveRequest; const existingResolveRequest = metroConfig.resolver?.resolveRequest;
return { return applyBugCollect({
...metroConfig, ...metroConfig,
resolver: { resolver: {
...metroConfig.resolver, ...metroConfig.resolver,
@ -90,7 +102,7 @@ function withXuqmConfig(metroConfig) {
return context.resolveRequest(context, moduleName, platform); return context.resolveRequest(context, moduleName, platform);
}, },
}, },
}; });
} }
module.exports = { withXuqmConfig }; module.exports = { withXuqmConfig };

查看文件

@ -33,6 +33,7 @@ export type {
XWebViewConfig, XWebViewConfig,
XWebViewControllerAPI, XWebViewControllerAPI,
XWebViewDownloadDecision, XWebViewDownloadDecision,
XWebViewDownloadDestination,
XWebViewDownloadProgress, XWebViewDownloadProgress,
XWebViewDownloadRequest, XWebViewDownloadRequest,
XWebViewDownloadResult, XWebViewDownloadResult,

查看文件

@ -54,6 +54,8 @@ export type XWebViewControllerAPI = {
getTitle: () => string getTitle: () => string
} }
export type XWebViewDownloadDestination = 'sandbox' | 'publicDownloads'
export type XWebViewConfig = { export type XWebViewConfig = {
showTopBar?: boolean showTopBar?: boolean
showStatusBar?: boolean showStatusBar?: boolean
@ -78,5 +80,9 @@ export type XWebViewConfig = {
request: XWebViewDownloadRequest, request: XWebViewDownloadRequest,
) => XWebViewDownloadDecision | Promise<XWebViewDownloadDecision> ) => XWebViewDownloadDecision | Promise<XWebViewDownloadDecision>
downloadConflict?: 'rename' | 'overwrite' downloadConflict?: 'rename' | 'overwrite'
/** 下载保存位置;与原生 Android SDK FileDownloadDestination 对齐。 */
downloadDestination?: XWebViewDownloadDestination
/** 非空时显示状态栏下载进度通知。 */
downloadNotificationTitle?: string
onClose?: () => void onClose?: () => void
} }

查看文件

@ -0,0 +1,47 @@
buildscript {
repositories {
google()
mavenCentral()
maven { url "https://nexus.xuqinmin.com/repository/android/" }
}
dependencies {
classpath("com.android.tools.build:gradle:8.1.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24")
}
}
apply plugin: "com.android.library"
apply plugin: "org.jetbrains.kotlin.android"
android {
compileSdkVersion 34
namespace "com.xuqm.push"
defaultConfig {
minSdkVersion 24
targetSdkVersion 34
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
repositories {
mavenCentral()
google()
maven { url "https://nexus.xuqinmin.com/repository/android/" }
}
dependencies {
implementation "com.facebook.react:react-android"
// sdk-push sdk-core XuqmGroup-AndroidSDK/gradle.properties
// SDK_PUSH_VERSION / SDK_CORE_VERSION host app Gradle
implementation "com.xuqm:sdk-push:1.1.3"
implementation "com.xuqm:sdk-core:1.1.6-SNAPSHOT"
}

查看文件

@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />

查看文件

@ -1,224 +0,0 @@
package com.xuqm.push;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import androidx.annotation.NonNull;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.Promise;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import java.lang.reflect.Proxy;
public class XuqmPushModule extends ReactContextBaseJavaModule {
private static final String TAG = "XuqmPushModule";
private static final String EVENT_TOKEN = "XuqmPushToken";
public XuqmPushModule(ReactApplicationContext reactContext) {
super(reactContext);
}
@NonNull
@Override
public String getName() {
return "XuqmPushModule";
}
@ReactMethod
public void detectVendor(Promise promise) {
String manufacturer = Build.MANUFACTURER != null ? Build.MANUFACTURER.toUpperCase() : "";
String vendor;
switch (manufacturer) {
case "HUAWEI":
vendor = "HUAWEI";
break;
case "XIAOMI":
case "REDMI":
vendor = "XIAOMI";
break;
case "HONOR":
vendor = "HONOR";
break;
case "OPPO":
case "REALME":
vendor = "OPPO";
break;
case "VIVO":
case "IQOO":
vendor = "VIVO";
break;
default:
vendor = "FCM";
break;
}
promise.resolve(vendor);
}
@ReactMethod
public void registerPush(Promise promise) {
Context context = getReactApplicationContext();
String vendor = resolveVendor();
try {
switch (vendor) {
case "HUAWEI":
registerHuawei(context);
break;
case "XIAOMI":
registerXiaomi(context);
break;
case "OPPO":
registerOppo(context);
break;
case "VIVO":
registerVivo(context);
break;
case "HONOR":
registerHonor(context);
break;
default:
registerFcm(context);
break;
}
promise.resolve(null);
} catch (Exception e) {
Log.w(TAG, "registerPush failed: " + e.getMessage());
promise.reject("PUSH_REGISTER_ERROR", e.getMessage(), e);
}
}
private String resolveVendor() {
String manufacturer = Build.MANUFACTURER != null ? Build.MANUFACTURER.toUpperCase() : "";
switch (manufacturer) {
case "HUAWEI": return "HUAWEI";
case "XIAOMI":
case "REDMI": return "XIAOMI";
case "HONOR": return "HONOR";
case "OPPO":
case "REALME": return "OPPO";
case "VIVO":
case "IQOO": return "VIVO";
default: return "FCM";
}
}
private void emitToken(String token, String vendor) {
com.facebook.react.bridge.WritableMap map = new com.facebook.react.bridge.WritableNativeMap();
map.putString("token", token);
map.putString("vendor", vendor);
getReactApplicationContext()
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
.emit(EVENT_TOKEN, map);
}
// Vendor registrations (via reflection)
private void registerHuawei(Context context) throws Exception {
Class<?> hmsClass = Class.forName("com.huawei.hms.aaid.HmsInstanceId");
Object instance = hmsClass.getMethod("getInstance", Context.class).invoke(null, context);
String appId = getHuaweiAppId(context);
if (appId.isEmpty()) {
Log.w(TAG, "Huawei appId not found");
return;
}
Object token = hmsClass.getMethod("getToken", String.class, String.class)
.invoke(instance, appId, "HCM");
if (token instanceof String && !((String) token).isEmpty()) {
emitToken((String) token, "HUAWEI");
}
}
private String getHuaweiAppId(Context context) {
try {
Class<?> configClass = Class.forName("com.huawei.agconnect.config.AGConnectServicesConfig");
Object config = configClass.getMethod("fromContext", Context.class).invoke(null, context);
return (String) configClass.getMethod("getString", String.class).invoke(config, "client/app_id");
} catch (Exception e) {
return "";
}
}
private void registerXiaomi(Context context) throws Exception {
Class<?> miPushClass = Class.forName("com.xiaomi.mipush.sdk.MiPushClient");
Bundle meta = context.getPackageManager().getApplicationInfo(
context.getPackageName(), PackageManager.GET_META_DATA).metaData;
if (meta == null) return;
String appId = meta.getString("XUQM_XIAOMI_APP_ID", "");
String appKey = meta.getString("XUQM_XIAOMI_APP_KEY", "");
if (!appId.isEmpty() && !appKey.isEmpty()) {
miPushClass.getMethod("registerPush", Context.class, String.class, String.class)
.invoke(null, context, appId, appKey);
} else {
Log.w(TAG, "Xiaomi appId/appKey not configured in meta-data");
}
}
private void registerOppo(Context context) throws Exception {
Class<?> pushManagerClass = Class.forName("com.heytap.mcssdk.PushManager");
Object instance = pushManagerClass.getMethod("getInstance").invoke(null);
Bundle meta = context.getPackageManager().getApplicationInfo(
context.getPackageName(), PackageManager.GET_META_DATA).metaData;
if (meta == null) return;
String appKey = meta.getString("XUQM_OPPO_APP_KEY", "");
String appSecret = meta.getString("XUQM_OPPO_APP_SECRET", "");
if (appKey.isEmpty() || appSecret.isEmpty()) {
Log.w(TAG, "OPPO appKey/appSecret not configured in meta-data");
return;
}
Class<?> callbackClass = Class.forName("com.heytap.mcssdk.callback.PushCallback");
Object proxy = Proxy.newProxyInstance(
callbackClass.getClassLoader(),
new Class<?>[]{callbackClass},
(proxy1, method, args) -> {
if ("onRegister".equals(method.getName()) && args != null && args.length > 1) {
String regId = args[1] instanceof String ? (String) args[1] : null;
if (regId != null && !regId.isEmpty()) {
emitToken(regId, "OPPO");
}
}
return null;
}
);
pushManagerClass.getMethod("register", Context.class, String.class, String.class, callbackClass)
.invoke(instance, context, appKey, appSecret, proxy);
}
private void registerVivo(Context context) throws Exception {
Class<?> pushClientClass = Class.forName("com.vivo.push.PushClient");
Object instance = pushClientClass.getMethod("getInstance", Context.class).invoke(null, context);
pushClientClass.getMethod("initialize").invoke(instance);
}
private void registerHonor(Context context) throws Exception {
Class<?> honorPushClass = Class.forName("com.hihonor.push.sdk.HonorPushClient");
Object client = honorPushClass.getMethod("getInstance").invoke(null);
honorPushClass.getMethod("init", Context.class, boolean.class).invoke(client, context, true);
}
private void registerFcm(Context context) throws Exception {
Class<?> fcmClass = Class.forName("com.google.firebase.messaging.FirebaseMessaging");
Object instance = fcmClass.getMethod("getInstance").invoke(null);
Object task = fcmClass.getMethod("getToken").invoke(instance);
Class<?> onSuccessClass = Class.forName("com.google.android.gms.tasks.OnSuccessListener");
Object proxy = Proxy.newProxyInstance(
onSuccessClass.getClassLoader(),
new Class<?>[]{onSuccessClass},
(proxy1, method, args) -> {
if (args != null && args.length > 0 && args[0] instanceof String) {
String token = (String) args[0];
if (!token.isEmpty()) {
emitToken(token, "FCM");
}
}
return null;
}
);
task.getClass().getMethod("addOnSuccessListener", onSuccessClass).invoke(task, proxy);
}
}

查看文件

@ -0,0 +1,83 @@
package com.xuqm.push
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableMap
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.XuqmUserInfo
import com.xuqm.sdk.push.PushSDK
/**
* push 桥接薄封装完整原生 sdk-push
*
* 设计要点 docs / [[rnsdk-prefer-pure-rn]]
* - SDK 初始化由 sdk-core XuqmMergedProvider(ContentProvider) 在进程启动时自动完成 assets/config 下的 .xuqmconfig
* RN 侧无需调用 initialize
* - 用户态由唯一的 setUserInfo 驱动JS XuqmSDK.setUserInfo() 本模块 setUserInfo() 原生 XuqmSDK.setUserInfo()
* 原生 notifyOptionalModules("onSdkLogin") 自动触发 PushSDK.onSdkLogin()完成厂商检测/token/绑定
* - 不在 RN 侧做厂商检测/token/注册避免与原生重复
*/
class XuqmPushModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = NAME
companion object {
const val NAME = "XuqmPushModule"
}
/** 登录/登出统一入口:转发到原生 XuqmSDK.setUserInfo,原生自动扇出到 push 等模块。 */
@ReactMethod
fun setUserInfo(info: ReadableMap?, promise: Promise) {
try {
if (info == null || !info.hasKey("userId")) {
XuqmSDK.setUserInfo(null)
} else {
XuqmSDK.setUserInfo(
XuqmUserInfo(
userId = info.getString("userId") ?: "",
userSig = if (info.hasKey("userSig")) info.getString("userSig") else null,
name = if (info.hasKey("name")) info.getString("name") else null,
phone = if (info.hasKey("phone")) info.getString("phone") else null,
),
)
}
promise.resolve(null)
} catch (e: Throwable) {
promise.reject("SET_USER_INFO_ERROR", e.message, e)
}
}
/** 离线推送开关(本地存储,随绑定同步)。 */
@ReactMethod
fun setOfflinePushEnabled(enabled: Boolean, promise: Promise) {
try {
PushSDK.setOfflinePushEnabled(enabled)
promise.resolve(null)
} catch (e: Throwable) {
promise.reject("OFFLINE_PUSH_ERROR", e.message, e)
}
}
@ReactMethod
fun setQuietHours(start: String, end: String, promise: Promise) {
try {
PushSDK.setQuietHours(start, end)
promise.resolve(null)
} catch (e: Throwable) {
promise.reject("QUIET_HOURS_ERROR", e.message, e)
}
}
@ReactMethod
fun clearQuietHours(promise: Promise) {
try {
PushSDK.clearQuietHours()
promise.resolve(null)
} catch (e: Throwable) {
promise.reject("QUIET_HOURS_ERROR", e.message, e)
}
}
}

查看文件

@ -0,0 +1,22 @@
package com.xuqm.push;
import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import java.util.Collections;
import java.util.List;
public class XuqmPushPackage implements ReactPackage {
@Override
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
return Collections.<NativeModule>singletonList(new XuqmPushModule(reactContext));
}
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}
}

查看文件

@ -1,48 +1,10 @@
#import <React/RCTBridgeModule.h> #import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
@interface XuqmPushModule : RCTEventEmitter <RCTBridgeModule> // Swift XuqmPushModule.swiftRCT_EXTERN_MODULE
@end @interface RCT_EXTERN_MODULE(XuqmPushModule, NSObject)
@implementation XuqmPushModule RCT_EXTERN_METHOD(setUserInfo:(NSDictionary *)info
resolver:(RCTPromiseResolveBlock)resolve
RCT_EXPORT_MODULE(XuqmPushModule); rejecter:(RCTPromiseRejectBlock)reject)
- (NSArray<NSString *> *)supportedEvents {
return @[@"XuqmPushToken"];
}
+ (BOOL)requiresMainQueueSetup {
return NO;
}
RCT_EXPORT_METHOD(detectVendor:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject) {
NSString *vendor = @"APNS";
#if TARGET_OS_SIMULATOR
vendor = @"FCM";
#endif
resolve(vendor);
}
RCT_EXPORT_METHOD(registerPush:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject) {
dispatch_async(dispatch_get_main_queue(), ^{
[[UIApplication sharedApplication] registerForRemoteNotifications];
resolve(@(YES));
});
}
// Called by the AppDelegate to report the device token
- (void)didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
const unsigned char *dataBuffer = (const unsigned char *)[deviceToken bytes];
NSUInteger dataLength = [deviceToken length];
NSMutableString *hexString = [NSMutableString stringWithCapacity:(dataLength * 2)];
for (NSUInteger i = 0; i < dataLength; ++i) {
[hexString appendFormat:@"%02x", dataBuffer[i]];
}
NSString *token = [hexString copy];
[self sendEventWithName:@"XuqmPushToken" body:@{@"token": token, @"vendor": @"APNS"}];
}
@end @end

查看文件

@ -0,0 +1,36 @@
import Foundation
import UIKit
import React
import XuqmCoreSDK
/**
* push iOS XuqmCoreSDK
*
* Android JS XuqmSDK.setUserInfo() setUserInfo()
* XuqmSDK.shared.setUserInfo()/clearUserInfo()APNs token XuqmPushSDK
* AppDelegate swizzle XuqmSDK.registerDeviceToken userId
*
* SDK XuqmCoreSDK offline/quiet-hours iOS PushSDK
* APIJS no-op
*/
@objc(XuqmPushModule)
class XuqmPushModule: NSObject {
@objc static func requiresMainQueueSetup() -> Bool { false }
@objc(setUserInfo:resolver:rejecter:)
func setUserInfo(_ info: NSDictionary?,
resolver resolve: RCTPromiseResolveBlock,
rejecter reject: RCTPromiseRejectBlock) {
if let info = info, let userId = info["userId"] as? String, !userId.isEmpty {
let nickname = info["name"] as? String
XuqmSDK.shared.setUserInfo(userId: userId, nickname: nickname, avatar: nil)
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
} else {
XuqmSDK.shared.clearUserInfo()
}
resolve(nil)
}
}

查看文件

@ -0,0 +1,11 @@
module.exports = {
dependency: {
platforms: {
android: {
sourceDir: './android',
packageImportPath: 'import com.xuqm.push.XuqmPushPackage;',
packageInstance: 'new XuqmPushPackage()',
},
},
},
}

查看文件

@ -1,33 +1,41 @@
import { NativeModules, NativeEventEmitter, Platform } from 'react-native' import { NativeModules } from 'react-native'
interface XuqmPushModuleInterface { import type { XuqmUserInfo } from '@xuqm/rn-common'
detectVendor: () => Promise<string>
registerPush: () => Promise<boolean> interface XuqmPushNative {
/** 转发到原生 XuqmSDK.setUserInfo;原生自动完成厂商检测/token/绑定。传 null 登出。 */
setUserInfo(info: Pick<XuqmUserInfo, 'userId' | 'userSig' | 'name' | 'phone'> | null): Promise<void>
setOfflinePushEnabled(enabled: boolean): Promise<void>
setQuietHours(start: string, end: string): Promise<void>
clearQuietHours(): Promise<void>
} }
const _native = NativeModules.XuqmPushModule as XuqmPushModuleInterface | undefined const _native = NativeModules.XuqmPushModule as XuqmPushNative | undefined
const _eventEmitter = _native ? new NativeEventEmitter(_native as any) : null
export function isNativePushAvailable(): boolean { export function isNativePushAvailable(): boolean {
return !!_native return !!_native
} }
export async function detectVendorNative(): Promise<string> { export async function nativeSetUserInfo(info: XuqmUserInfo | null): Promise<void> {
if (!_native?.detectVendor) return Platform.OS === 'ios' ? 'APNS' : 'FCM' if (!_native?.setUserInfo) return
return _native.detectVendor() await _native.setUserInfo(
info
? { userId: info.userId, userSig: info.userSig, name: info.name, phone: info.phone }
: null,
)
} }
export async function registerPushNative(): Promise<void> { export async function nativeSetOfflinePushEnabled(enabled: boolean): Promise<void> {
if (!_native?.registerPush) return if (!_native?.setOfflinePushEnabled) return
await _native.registerPush() await _native.setOfflinePushEnabled(enabled)
} }
export function addPushTokenListener( export async function nativeSetQuietHours(start: string, end: string): Promise<void> {
callback: (event: { token: string; vendor: string }) => void, if (!_native?.setQuietHours) return
): () => void { await _native.setQuietHours(start, end)
if (!_eventEmitter) { }
return () => {}
} export async function nativeClearQuietHours(): Promise<void> {
const subscription = _eventEmitter.addListener('XuqmPushToken', callback) if (!_native?.clearQuietHours) return
return () => subscription.remove() await _native.clearQuietHours()
} }

查看文件

@ -1,139 +1,41 @@
import { import { _registerUserInfoHandler } from '@xuqm/rn-common'
apiRequest,
getConfig,
getDeviceInfo,
getUserId as getCommonUserId,
_registerUserInfoHandler,
} from '@xuqm/rn-common'
import type { PushVendor, XuqmUserInfo } from '@xuqm/rn-common' import type { PushVendor, XuqmUserInfo } from '@xuqm/rn-common'
import { detectVendorNative, registerPushNative, addPushTokenListener } from './NativePush' import {
nativeSetUserInfo,
nativeSetOfflinePushEnabled,
nativeSetQuietHours,
nativeClearQuietHours,
} from './NativePush'
export type { PushVendor } export type { PushVendor }
// ─── Internal state ────────────────────────────────────────────────────────────
let _registeredUserId: string | null = null
let _tokenUnsubscribe: (() => void) | null = null
// ─── Internal helpers ──────────────────────────────────────────────────────────
async function _registerDevice(userId: string, token: string, vendor: PushVendor): Promise<void> {
const config = getConfig()
const device = await getDeviceInfo()
await apiRequest('/api/push/register', {
method: 'POST',
params: {
appKey: config.appKey,
userId,
vendor,
token,
platform: device.platform,
deviceId: device.deviceId,
brand: device.brand,
model: device.model,
osVersion: device.osVersion,
},
})
_registeredUserId = userId
}
async function _unregisterDevice(userId: string): Promise<void> {
const config = getConfig()
const { deviceId } = await getDeviceInfo()
await apiRequest('/api/push/unregister', {
method: 'DELETE',
params: { appKey: config.appKey, userId, deviceId },
})
if (_registeredUserId === userId) _registeredUserId = null
}
async function _startRegistrationFlow(userId: string): Promise<void> {
// 1. 检测厂商并请求原生注册
await registerPushNative()
// 2. 监听 token 回调,收到后自动上报
if (_tokenUnsubscribe) _tokenUnsubscribe()
_tokenUnsubscribe = addPushTokenListener(async (event) => {
const currentUserId = getCommonUserId()
if (!currentUserId) return
const vendor = (await detectVendorNative()) as PushVendor ?? (event.vendor as PushVendor)
await _registerDevice(currentUserId, event.token, vendor).catch(() => {})
})
}
// ─── 订阅 XuqmSDK.setUserInfo ────────────────────────────────────────────────── // ─── 订阅 XuqmSDK.setUserInfo ──────────────────────────────────────────────────
// 唯一入口:登录/登出都只转发到原生 XuqmSDK.setUserInfo,由原生 sdk-push 自动完成
// 厂商检测 + token 获取 + 绑定到服务端(/api/push/register。RN 侧不再做 vendor/token/register。
_registerUserInfoHandler(async (info: XuqmUserInfo | null) => { _registerUserInfoHandler(async (info: XuqmUserInfo | null) => {
if (!info) { await nativeSetUserInfo(info).catch(() => {})
// 登出:解绑 token
const userId = _registeredUserId
if (userId) {
await _unregisterDevice(userId).catch(() => {})
}
if (_tokenUnsubscribe) {
_tokenUnsubscribe()
_tokenUnsubscribe = null
}
return
}
// 登录:启动设备注册流程(幂等,同一用户不重复注册)
if (_registeredUserId === info.userId) return
await _startRegistrationFlow(info.userId).catch(() => {})
}) })
// ─── PushSDK 公开 API ────────────────────────────────────────────────────────── // ─── PushSDK 公开 API ──────────────────────────────────────────────────────────
export const PushSDK = { export const PushSDK = {
/** /** 设置离线推送开关(委托原生 PushSDK,随绑定同步到服务端。 */
* 线
*/
async setOfflinePushEnabled(enabled: boolean): Promise<void> { async setOfflinePushEnabled(enabled: boolean): Promise<void> {
const config = getConfig() await nativeSetOfflinePushEnabled(enabled)
const userId = getCommonUserId()
if (!userId) return
await apiRequest('/api/push/settings/offline', {
method: 'PUT',
params: { appKey: config.appKey, userId, enabled: String(enabled) },
})
}, },
/** /** 设置免打扰时间段24 小时制,如 '22:00'、'08:00')。 */
* 24 '22:00''08:00'
*/
async setQuietHours(start: string, end: string): Promise<void> { async setQuietHours(start: string, end: string): Promise<void> {
const config = getConfig() await nativeSetQuietHours(start, end)
const userId = getCommonUserId()
if (!userId) return
await apiRequest('/api/push/settings/quiet-hours', {
method: 'PUT',
params: { appKey: config.appKey, userId, start, end },
})
}, },
/** /** 清除免打扰设置。 */
*
*/
async clearQuietHours(): Promise<void> { async clearQuietHours(): Promise<void> {
const config = getConfig() await nativeClearQuietHours()
const userId = getCommonUserId()
if (!userId) return
await apiRequest('/api/push/settings/quiet-hours', {
method: 'DELETE',
params: { appKey: config.appKey, userId },
})
}, },
/** /** 登出推送通常无需手动调用,XuqmSDK.setUserInfo(null) 会自动触发)。 */
* XuqmSDK.setUserInfo(null)
*/
async logout(): Promise<void> { async logout(): Promise<void> {
const userId = _registeredUserId ?? getCommonUserId() await nativeSetUserInfo(null)
if (!userId) return
await _unregisterDevice(userId).catch(() => {})
if (_tokenUnsubscribe) {
_tokenUnsubscribe()
_tokenUnsubscribe = null
}
}, },
} }

查看文件

@ -65,6 +65,15 @@ const _pluginRegistry = new Set<string>()
const UPDATE_APP_CACHE_KEY = 'xuqm_update_app_cache' const UPDATE_APP_CACHE_KEY = 'xuqm_update_app_cache'
const UPDATE_PLUGIN_CACHE_KEY_PREFIX = 'xuqm_update_plugin_cache_' const UPDATE_PLUGIN_CACHE_KEY_PREFIX = 'xuqm_update_plugin_cache_'
// 用户「忽略此版本」持久化(按 versionCode。强制更新不受忽略影响。
const UPDATE_IGNORED_VERSION_KEY = 'xuqm_update_ignored_version_code'
async function _getIgnoredVersionCode(): Promise<number | null> {
const raw = await AsyncStorage.getItem(UPDATE_IGNORED_VERSION_KEY).catch(() => null)
if (!raw) return null
const n = parseInt(raw, 10)
return Number.isFinite(n) ? n : null
}
const UPDATE_CACHE_TTL_MS = 30 * 60 * 1000 // 30 minutes const UPDATE_CACHE_TTL_MS = 30 * 60 * 1000 // 30 minutes
async function _readUpdateCache<T>(key: string): Promise<T | null> { async function _readUpdateCache<T>(key: string): Promise<T | null> {
@ -221,10 +230,19 @@ export const UpdateSDK = {
// ── App 整包更新 ────────────────────────────────────────────────────────── // ── App 整包更新 ──────────────────────────────────────────────────────────
async checkAppUpdate(bypassIgnore?: boolean): Promise<AppUpdateInfo> { async checkAppUpdate(bypassIgnore?: boolean): Promise<AppUpdateInfo> {
// 应用客户端忽略:非强制更新且 versionCode 已被忽略时视为无更新。
const applyIgnore = async (info: AppUpdateInfo): Promise<AppUpdateInfo> => {
if (bypassIgnore || !info.needsUpdate || info.forceUpdate) return info
const ignored = await _getIgnoredVersionCode()
return ignored != null && info.versionCode === ignored
? { ...info, needsUpdate: false }
: info
}
// Return cached result if within TTL (cache is skipped when bypassIgnore is set) // Return cached result if within TTL (cache is skipped when bypassIgnore is set)
if (!bypassIgnore) { if (!bypassIgnore) {
const cached = await _readUpdateCache<AppUpdateInfo>(UPDATE_APP_CACHE_KEY) const cached = await _readUpdateCache<AppUpdateInfo>(UPDATE_APP_CACHE_KEY)
if (cached) return cached if (cached) return applyIgnore(cached)
} }
const config = getConfig() const config = getConfig()
@ -249,7 +267,23 @@ export const UpdateSDK = {
}) })
const normalized = { ...result, downloadUrl: normalizeDownloadUrl(result.downloadUrl) } const normalized = { ...result, downloadUrl: normalizeDownloadUrl(result.downloadUrl) }
await _writeUpdateCache(UPDATE_APP_CACHE_KEY, normalized) await _writeUpdateCache(UPDATE_APP_CACHE_KEY, normalized)
return normalized return applyIgnore(normalized)
},
/** 忽略指定版本(强制更新不受影响)。 */
async ignoreAppVersion(versionCode: number): Promise<void> {
if (!Number.isFinite(versionCode)) return
await AsyncStorage.setItem(UPDATE_IGNORED_VERSION_KEY, String(versionCode))
},
/** 清除已忽略的版本(恢复提示)。 */
async clearIgnoredAppVersion(): Promise<void> {
await AsyncStorage.removeItem(UPDATE_IGNORED_VERSION_KEY)
},
/** 当前被忽略的 versionCode无则 null。 */
async getIgnoredAppVersion(): Promise<number | null> {
return _getIgnoredVersionCode()
}, },
async openStore(appStoreUrl?: string, marketUrl?: string): Promise<void> { async openStore(appStoreUrl?: string, marketUrl?: string): Promise<void> {

查看文件

@ -28,6 +28,11 @@
"react-native-safe-area-context": ">=4.0.0", "react-native-safe-area-context": ">=4.0.0",
"@xuqm/rn-common": ">=0.2.2" "@xuqm/rn-common": ">=0.2.2"
}, },
"peerDependenciesMeta": {
"@react-native-camera-roll/camera-roll": {
"optional": true
}
},
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@react-navigation/native": "^7.0.0", "@react-navigation/native": "^7.0.0",

查看文件

@ -2,6 +2,7 @@ import { Platform } from 'react-native'
import type { import type {
XWebViewDownloadDecision, XWebViewDownloadDecision,
XWebViewDownloadDestination,
XWebViewDownloadProgress, XWebViewDownloadProgress,
XWebViewDownloadRequest, XWebViewDownloadRequest,
XWebViewDownloadResult, XWebViewDownloadResult,
@ -12,6 +13,63 @@ import type {
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
const getRNFetchBlob = () => (require('react-native-blob-util') as { default: typeof import('react-native-blob-util').default }).default const getRNFetchBlob = () => (require('react-native-blob-util') as { default: typeof import('react-native-blob-util').default }).default
const MIME_BY_EXT: Record<string, string> = {
pdf: 'application/pdf',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
bmp: 'image/bmp',
svg: 'image/svg+xml',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
zip: 'application/zip',
rar: 'application/vnd.rar',
txt: 'text/plain',
csv: 'text/csv',
json: 'application/json',
mp4: 'video/mp4',
mp3: 'audio/mpeg',
}
function guessMime(filename: string): string {
const ext = filename.includes('.') ? filename.split('.').pop()!.toLowerCase() : ''
return MIME_BY_EXT[ext] ?? 'application/octet-stream'
}
function isImageMime(mime: string): boolean {
return mime.startsWith('image/')
}
// 懒加载 CameraRoll可选依赖,缺失时静默跳过不强制宿主安装
function getCameraRoll(): { save: (uri: string, opts?: { type?: string; album?: string }) => Promise<string> } | null {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mod = require('@react-native-camera-roll/camera-roll')
return mod?.CameraRoll ?? mod?.default?.CameraRoll ?? null
} catch {
return null
}
}
/** 图片存入系统相册best-effort,缺少 CameraRoll 或失败时返回 false。 */
async function saveToGallery(filePath: string): Promise<boolean> {
const cameraRoll = getCameraRoll()
if (!cameraRoll) return false
try {
const uri = filePath.startsWith('file://') ? filePath : `file://${filePath}`
await cameraRoll.save(uri, { type: 'photo' })
return true
} catch {
return false
}
}
function parseContentDispositionFilename(header: string): string | null { function parseContentDispositionFilename(header: string): string | null {
const rfc5987 = header.match(/filename\*=(?:[^']*'[^']*')?([^;\s]+)/i) const rfc5987 = header.match(/filename\*=(?:[^']*'[^']*')?([^;\s]+)/i)
if (rfc5987?.[1]) { if (rfc5987?.[1]) {
@ -91,20 +149,66 @@ async function resolveFilePath(
return candidate return candidate
} }
/**
* Android: actionViewIntentiOS: openDocument
* best-effort
*/
export async function openFile(filePath: string, filenameOrMime?: string): Promise<boolean> {
const mime =
filenameOrMime && filenameOrMime.includes('/')
? filenameOrMime
: guessMime(filenameOrMime ?? filePath)
try {
if (Platform.OS === 'android') {
const res = await getRNFetchBlob().android.actionViewIntent(filePath, mime)
return res !== false
}
await getRNFetchBlob().ios.openDocument(filePath)
return true
} catch {
return false
}
}
export async function saveBase64File( export async function saveBase64File(
base64: string, base64: string,
filename: string, filename: string,
savePath: string | undefined, savePath: string | undefined,
conflict: 'rename' | 'overwrite', conflict: 'rename' | 'overwrite',
destination: XWebViewDownloadDestination = 'sandbox',
): Promise<string> { ): Promise<string> {
const { dirs } = getRNFetchBlob().fs const blob = getRNFetchBlob()
const { dirs } = blob.fs
// 公共 DownloadsAndroid先写沙盒,再通过 DownloadManager 登记到“下载”列表。
// Android 10+ 直接写公共目录会 EACCES,故走沙盒 + addCompleteDownload。
// 指定了 savePath 时尊重调用方目录,不走此分支。
if (Platform.OS === 'android' && destination === 'publicDownloads' && !savePath) {
const sandboxPath = await resolveFilePath(dirs.DownloadDir ?? dirs.DocumentDir, filename, conflict)
await blob.fs.writeFile(sandboxPath, base64, 'base64')
const mime = guessMime(filename)
try {
await blob.android.addCompleteDownload({
title: filename,
description: filename,
mime,
path: sandboxPath,
showNotification: true,
})
} catch {
// 登记失败不影响文件已落盘
}
if (isImageMime(mime)) await saveToGallery(sandboxPath)
return sandboxPath
}
const dir = const dir =
savePath ?? savePath ??
(Platform.OS === 'android' (Platform.OS === 'android'
? (dirs.DownloadDir ?? dirs.DocumentDir) ? (dirs.DownloadDir ?? dirs.DocumentDir)
: dirs.DocumentDir) : dirs.DocumentDir)
const filePath = await resolveFilePath(dir, filename, conflict) const filePath = await resolveFilePath(dir, filename, conflict)
await getRNFetchBlob().fs.writeFile(filePath, base64, 'base64') await blob.fs.writeFile(filePath, base64, 'base64')
return filePath return filePath
} }
@ -125,6 +229,8 @@ export async function handleDownloadRequest(
autoDownload?: boolean autoDownload?: boolean
downloadConflict?: 'rename' | 'overwrite' downloadConflict?: 'rename' | 'overwrite'
savePath?: string savePath?: string
downloadDestination?: XWebViewDownloadDestination
downloadNotificationTitle?: string
onDownloadDecide?: ( onDownloadDecide?: (
request: XWebViewDownloadRequest, request: XWebViewDownloadRequest,
) => XWebViewDownloadDecision | Promise<XWebViewDownloadDecision> ) => XWebViewDownloadDecision | Promise<XWebViewDownloadDecision>
@ -138,6 +244,8 @@ export async function handleDownloadRequest(
autoDownload = true, autoDownload = true,
downloadConflict = 'rename', downloadConflict = 'rename',
savePath, savePath,
downloadDestination = 'sandbox',
downloadNotificationTitle,
onDownloadDecide, onDownloadDecide,
onDownloadStart, onDownloadStart,
onDownloadProgress, onDownloadProgress,
@ -168,6 +276,7 @@ export async function handleDownloadRequest(
p => onDownloadProgress?.(p), p => onDownloadProgress?.(p),
r => onDownloadComplete?.(r), r => onDownloadComplete?.(r),
err => onDownloadError?.(url, err), err => onDownloadError?.(url, err),
{ destination: downloadDestination, notificationTitle: downloadNotificationTitle },
) )
} }
@ -179,8 +288,67 @@ export function startDownload(
onProgress: (p: XWebViewDownloadProgress) => void, onProgress: (p: XWebViewDownloadProgress) => void,
onComplete: (r: XWebViewDownloadResult) => void, onComplete: (r: XWebViewDownloadResult) => void,
onError: (error: string) => void, onError: (error: string) => void,
options: {
destination?: XWebViewDownloadDestination
notificationTitle?: string
} = {},
): DownloadHandle { ): DownloadHandle {
const { dirs } = getRNFetchBlob().fs const { destination = 'sandbox', notificationTitle } = options
const blob = getRNFetchBlob()
const { dirs } = blob.fs
// 公共 DownloadsAndroid下载到沙盒保留 JS 进度回调),完成后通过 DownloadManager
// 登记到“下载”列表完成通知、图片另存系统相册。Android 10+ 不能直接写公共目录,故沙盒落地 + 登记。
// 指定了 savePath 时尊重调用方目录,不走此分支。
if (Platform.OS === 'android' && destination === 'publicDownloads' && !savePath) {
let cancelled = false
let cancelFn = () => {
cancelled = true
}
const mime = guessMime(filename)
resolveFilePath(dirs.DocumentDir, filename, conflict)
.then(cachePath => {
if (cancelled) return
const task = blob.config({ path: cachePath }).fetch('GET', url)
cancelFn = () => task.cancel()
task.progress({ interval: 300 }, (received: number, total: number) => {
const recv = Number(received)
const tot = Number(total)
onProgress({
url,
filename,
received: recv,
total: tot,
percentage: tot > 0 ? Math.round((recv / tot) * 100) : -1,
})
})
task
.then(async (res: { path: () => string }) => {
const filePath = res.path()
try {
await blob.android.addCompleteDownload({
title: notificationTitle?.trim() || filename,
description: filename,
mime,
path: filePath,
showNotification: true,
})
} catch {
// 登记失败不影响文件已落盘
}
if (isImageMime(mime)) await saveToGallery(filePath)
if (!cancelled) onComplete({ url, filename, filePath, fileSize: 0 })
})
.catch((err: Error) => {
if (!cancelled && !String(err?.message ?? '').toLowerCase().includes('cancel')) {
onError(err?.message ?? String(err))
}
})
})
.catch(err => onError(String(err)))
return { cancel: () => cancelFn() }
}
const dir = const dir =
savePath ?? savePath ??
(Platform.OS === 'android' (Platform.OS === 'android'

查看文件

@ -261,6 +261,8 @@ export function XWebViewScreen() {
onPermissionRequest, onPermissionRequest,
autoDownload = true, autoDownload = true,
downloadConflict = 'rename', downloadConflict = 'rename',
downloadDestination = 'sandbox',
downloadNotificationTitle,
onDownloadStart, onDownloadStart,
onDownloadProgress, onDownloadProgress,
onDownloadComplete, onDownloadComplete,
@ -358,13 +360,19 @@ export function XWebViewScreen() {
onDownloadStart?.({ ...info, suggestedFilename: finalFilename }) onDownloadStart?.({ ...info, suggestedFilename: finalFilename })
try { try {
const filePath = await saveBase64File(base64, finalFilename, finalSavePath, downloadConflict) const filePath = await saveBase64File(
base64,
finalFilename,
finalSavePath,
downloadConflict,
downloadDestination,
)
onDownloadComplete?.({ url: blobUrl, filename: finalFilename, filePath, fileSize: 0 }) onDownloadComplete?.({ url: blobUrl, filename: finalFilename, filePath, fileSize: 0 })
} catch (err) { } catch (err) {
onDownloadError?.(blobUrl, String(err)) onDownloadError?.(blobUrl, String(err))
} }
}, },
[autoDownload, downloadConflict, onDownloadDecide, onDownloadStart, onDownloadComplete, onDownloadError], [autoDownload, downloadConflict, downloadDestination, onDownloadDecide, onDownloadStart, onDownloadComplete, onDownloadError],
) )
const processDownload = useCallback( const processDownload = useCallback(
@ -392,9 +400,10 @@ export function XWebViewScreen() {
p => onDownloadProgress?.(p), p => onDownloadProgress?.(p),
r => onDownloadComplete?.(r), r => onDownloadComplete?.(r),
err => onDownloadError?.(downloadUrl, err), err => onDownloadError?.(downloadUrl, err),
{ destination: downloadDestination, notificationTitle: downloadNotificationTitle },
) )
}, },
[autoDownload, downloadConflict, onDownloadDecide, onDownloadStart, onDownloadProgress, onDownloadComplete, onDownloadError], [autoDownload, downloadConflict, downloadDestination, downloadNotificationTitle, onDownloadDecide, onDownloadStart, onDownloadProgress, onDownloadComplete, onDownloadError],
) )
const handleMessage = useCallback( const handleMessage = useCallback(

查看文件

@ -135,10 +135,13 @@ const DIALOG_OVERRIDE_JS = `
true; true;
` `
export function XWebViewView() { export function XWebViewView({ config: configProp }: { config?: XWebViewConfig } = {}) {
const webViewRef = useRef<WebView>(null) const webViewRef = useRef<WebView>(null)
const config = getXWebViewConfig() // configProp 用于内联嵌入(不依赖全局单例 config,可同屏多实例
// 不传时回退到 openXWebView 设置的全局 config全屏/miniapp 用法)。
const inline = configProp != null
const config = configProp ?? getXWebViewConfig()
const { const {
showStatusBar = true, showStatusBar = true,
title: initialTitle = '', title: initialTitle = '',
@ -149,6 +152,8 @@ export function XWebViewView() {
onPermissionRequest, onPermissionRequest,
autoDownload = true, autoDownload = true,
downloadConflict = 'rename', downloadConflict = 'rename',
downloadDestination = 'sandbox',
downloadNotificationTitle,
onDownloadStart, onDownloadStart,
onDownloadProgress, onDownloadProgress,
onDownloadComplete, onDownloadComplete,
@ -165,6 +170,8 @@ export function XWebViewView() {
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
// 内联模式不注册全局 controller,避免与全屏 XWebView 实例互相覆盖。
if (inline) return
setXWebViewController({ setXWebViewController({
refresh: () => webViewRef.current?.reload(), refresh: () => webViewRef.current?.reload(),
close: () => onClose?.(), close: () => onClose?.(),
@ -175,7 +182,7 @@ export function XWebViewView() {
getTitle: () => currentTitle, getTitle: () => currentTitle,
}) })
return () => setXWebViewController(null) return () => setXWebViewController(null)
}, [currentUrl, currentTitle, onClose]) }, [inline, currentUrl, currentTitle, onClose])
const handleNavigationStateChange = useCallback((state: WebViewNavigation) => { const handleNavigationStateChange = useCallback((state: WebViewNavigation) => {
setCanGoBack(state.canGoBack) setCanGoBack(state.canGoBack)
@ -205,13 +212,19 @@ export function XWebViewView() {
onDownloadStart?.({ ...info, suggestedFilename: finalFilename }) onDownloadStart?.({ ...info, suggestedFilename: finalFilename })
try { try {
const filePath = await saveBase64File(base64, finalFilename, finalSavePath, downloadConflict) const filePath = await saveBase64File(
base64,
finalFilename,
finalSavePath,
downloadConflict,
downloadDestination,
)
onDownloadComplete?.({ url: blobUrl, filename: finalFilename, filePath, fileSize: 0 }) onDownloadComplete?.({ url: blobUrl, filename: finalFilename, filePath, fileSize: 0 })
} catch (err) { } catch (err) {
onDownloadError?.(blobUrl, String(err)) onDownloadError?.(blobUrl, String(err))
} }
}, },
[autoDownload, downloadConflict, onDownloadDecide, onDownloadStart, onDownloadComplete, onDownloadError], [autoDownload, downloadConflict, downloadDestination, onDownloadDecide, onDownloadStart, onDownloadComplete, onDownloadError],
) )
const processDownload = useCallback( const processDownload = useCallback(
@ -237,9 +250,10 @@ export function XWebViewView() {
p => onDownloadProgress?.(p), p => onDownloadProgress?.(p),
r => onDownloadComplete?.(r), r => onDownloadComplete?.(r),
err => onDownloadError?.(downloadUrl, err), err => onDownloadError?.(downloadUrl, err),
{ destination: downloadDestination, notificationTitle: downloadNotificationTitle },
) )
}, },
[autoDownload, downloadConflict, onDownloadDecide, onDownloadStart, onDownloadProgress, onDownloadComplete, onDownloadError], [autoDownload, downloadConflict, downloadDestination, downloadNotificationTitle, onDownloadDecide, onDownloadStart, onDownloadProgress, onDownloadComplete, onDownloadError],
) )
const handleMessage = useCallback( const handleMessage = useCallback(

查看文件

@ -11,6 +11,7 @@ export type {
XWebViewConfig, XWebViewConfig,
XWebViewControllerAPI, XWebViewControllerAPI,
XWebViewDownloadDecision, XWebViewDownloadDecision,
XWebViewDownloadDestination,
XWebViewDownloadProgress, XWebViewDownloadProgress,
XWebViewDownloadRequest, XWebViewDownloadRequest,
XWebViewDownloadResult, XWebViewDownloadResult,
@ -21,5 +22,5 @@ export type {
export { default as XWebViewProgress } from './XWebViewProgress' export { default as XWebViewProgress } from './XWebViewProgress'
export { XWebViewView } from './XWebViewView' export { XWebViewView } from './XWebViewView'
export { XWebViewScreen } from './XWebViewScreen' export { XWebViewScreen } from './XWebViewScreen'
export { handleDownloadRequest, fetchDownloadInfo, saveBase64File, startDownload } from './XWebViewDownload' export { handleDownloadRequest, fetchDownloadInfo, saveBase64File, startDownload, openFile } from './XWebViewDownload'
export type { DownloadHandle } from './XWebViewDownload' export type { DownloadHandle } from './XWebViewDownload'