From 4d2b834f967d7899b0385118adaf7fea0cc74b9e Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Thu, 25 Jun 2026 17:50:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20=E4=B8=8B=E8=BD=BD=E7=9B=B8?= =?UTF-8?q?=E5=86=8C/=E8=BF=9B=E5=BA=A6=E3=80=81XWebView=E5=86=85=E8=81=94?= =?UTF-8?q?config=E3=80=81=E4=B8=8A=E6=8A=A5=E9=99=90=E9=A2=91=E9=87=87?= =?UTF-8?q?=E6=A0=B7=E3=80=81=E7=89=88=E6=9C=AC=E5=BF=BD=E7=95=A5=E3=80=81?= =?UTF-8?q?push=E6=A1=A5=E6=8E=A5=E5=8E=9F=E7=94=9Fsdk-push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/bugcollect/src/BugCollect.ts | 58 ++++- packages/common/metro/index.js | 18 +- packages/common/src/index.ts | 1 + packages/common/src/xwebview/types.ts | 6 + packages/push/android/build.gradle | 47 ++++ .../push/android/src/main/AndroidManifest.xml | 1 + .../java/com/xuqm/push/XuqmPushModule.java | 224 ------------------ .../main/java/com/xuqm/push/XuqmPushModule.kt | 83 +++++++ .../java/com/xuqm/push/XuqmPushPackage.java | 22 ++ packages/push/ios/XuqmPushModule.m | 48 +--- packages/push/ios/XuqmPushModule.swift | 36 +++ packages/push/react-native.config.js | 11 + packages/push/src/NativePush.ts | 48 ++-- packages/push/src/PushSDK.ts | 134 ++--------- packages/update/src/UpdateSDK.ts | 38 ++- packages/xwebview/package.json | 5 + packages/xwebview/src/XWebViewDownload.ts | 174 +++++++++++++- packages/xwebview/src/XWebViewScreen.tsx | 15 +- packages/xwebview/src/XWebViewView.tsx | 26 +- packages/xwebview/src/index.ts | 3 +- 20 files changed, 573 insertions(+), 425 deletions(-) create mode 100644 packages/push/android/build.gradle create mode 100644 packages/push/android/src/main/AndroidManifest.xml delete mode 100644 packages/push/android/src/main/java/com/xuqm/push/XuqmPushModule.java create mode 100644 packages/push/android/src/main/java/com/xuqm/push/XuqmPushModule.kt create mode 100644 packages/push/android/src/main/java/com/xuqm/push/XuqmPushPackage.java create mode 100644 packages/push/ios/XuqmPushModule.swift create mode 100644 packages/push/react-native.config.js diff --git a/packages/bugcollect/src/BugCollect.ts b/packages/bugcollect/src/BugCollect.ts index a0472b7..d916e0e 100644 --- a/packages/bugcollect/src/BugCollect.ts +++ b/packages/bugcollect/src/BugCollect.ts @@ -2,6 +2,7 @@ import { Platform, Dimensions } from 'react-native' import { getConfig, isInitialized, getUserId } from '@xuqm/rn-common' import { LogQueue } from './queue/LogQueue' import { ErrorCapture } from './capture/ErrorCapture' +import { HttpInterceptor } from './interceptor/HttpInterceptor' import { computeFingerprint } from './fingerprint' import { FunnelTracker } from './funnel/FunnelTracker' import type { @@ -25,8 +26,40 @@ let _logLevel: LogLevel = 'warn' let _environment: Environment = 'production' let _queue: LogQueue | null = null let _errorCaptureStarted = false +let _httpInterceptorStarted = false 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() + +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() function _generateSessionId(): string { @@ -76,6 +109,7 @@ function _enqueue(event: BugCollectEvent): void { if (!cfg.bugCollectApiUrl || !cfg.bugCollectEnabled) return _queue = new LogQueue({ bugCollectApiUrl: cfg.bugCollectApiUrl, appKey: cfg.appKey }) } + if (!_shouldReport(event)) return void _queue.push(event) } @@ -115,6 +149,11 @@ export const BugCollect = { _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). */ addBreadcrumb(item: Omit & { timestamp?: number }): void { 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(). */ startCapture(): void { - if (_errorCaptureStarted) return - _errorCaptureStarted = true - ErrorCapture.start(BugCollect.captureError.bind(BugCollect)) + if (!_errorCaptureStarted) { + _errorCaptureStarted = true + ErrorCapture.start(BugCollect.captureError.bind(BugCollect)) + } + if (!_httpInterceptorStarted) { + _httpInterceptorStarted = true + HttpInterceptor.start(BugCollect.captureError.bind(BugCollect)) + } }, defineFunnel: FunnelTracker.define.bind(FunnelTracker), @@ -222,6 +267,11 @@ export const BugCollect = { _queue.destroy() _queue = null } + if (_httpInterceptorStarted) { + HttpInterceptor.stop() + _httpInterceptorStarted = false + } + _fingerprintHits.clear() _breadcrumbs = [] }, } diff --git a/packages/common/metro/index.js b/packages/common/metro/index.js index 58e838a..43509de 100644 --- a/packages/common/metro/index.js +++ b/packages/common/metro/index.js @@ -46,13 +46,25 @@ function findConfigFile(projectRoot) { 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) { const projectRoot = metroConfig.projectRoot ?? process.cwd(); const configFile = findConfigFile(projectRoot); if (!configFile) { // 未找到配置文件,返回原配置(autoInit 会静默跳过) - return metroConfig; + return applyBugCollect(metroConfig); } // 读取加密配置内容 @@ -71,7 +83,7 @@ function withXuqmConfig(metroConfig) { // 拦截模块解析,将 @xuqm/autoinit-config 指向虚拟文件 const existingResolveRequest = metroConfig.resolver?.resolveRequest; - return { + return applyBugCollect({ ...metroConfig, resolver: { ...metroConfig.resolver, @@ -90,7 +102,7 @@ function withXuqmConfig(metroConfig) { return context.resolveRequest(context, moduleName, platform); }, }, - }; + }); } module.exports = { withXuqmConfig }; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 45b2095..386b780 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -33,6 +33,7 @@ export type { XWebViewConfig, XWebViewControllerAPI, XWebViewDownloadDecision, + XWebViewDownloadDestination, XWebViewDownloadProgress, XWebViewDownloadRequest, XWebViewDownloadResult, diff --git a/packages/common/src/xwebview/types.ts b/packages/common/src/xwebview/types.ts index 1e71ce4..73e73c4 100644 --- a/packages/common/src/xwebview/types.ts +++ b/packages/common/src/xwebview/types.ts @@ -54,6 +54,8 @@ export type XWebViewControllerAPI = { getTitle: () => string } +export type XWebViewDownloadDestination = 'sandbox' | 'publicDownloads' + export type XWebViewConfig = { showTopBar?: boolean showStatusBar?: boolean @@ -78,5 +80,9 @@ export type XWebViewConfig = { request: XWebViewDownloadRequest, ) => XWebViewDownloadDecision | Promise downloadConflict?: 'rename' | 'overwrite' + /** 下载保存位置;与原生 Android SDK FileDownloadDestination 对齐。 */ + downloadDestination?: XWebViewDownloadDestination + /** 非空时显示状态栏下载进度通知。 */ + downloadNotificationTitle?: string onClose?: () => void } diff --git a/packages/push/android/build.gradle b/packages/push/android/build.gradle new file mode 100644 index 0000000..4906c86 --- /dev/null +++ b/packages/push/android/build.gradle @@ -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" +} diff --git a/packages/push/android/src/main/AndroidManifest.xml b/packages/push/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..94cbbcf --- /dev/null +++ b/packages/push/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/packages/push/android/src/main/java/com/xuqm/push/XuqmPushModule.java b/packages/push/android/src/main/java/com/xuqm/push/XuqmPushModule.java deleted file mode 100644 index a1537f6..0000000 --- a/packages/push/android/src/main/java/com/xuqm/push/XuqmPushModule.java +++ /dev/null @@ -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); - } -} diff --git a/packages/push/android/src/main/java/com/xuqm/push/XuqmPushModule.kt b/packages/push/android/src/main/java/com/xuqm/push/XuqmPushModule.kt new file mode 100644 index 0000000..2d2cac3 --- /dev/null +++ b/packages/push/android/src/main/java/com/xuqm/push/XuqmPushModule.kt @@ -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) + } + } +} diff --git a/packages/push/android/src/main/java/com/xuqm/push/XuqmPushPackage.java b/packages/push/android/src/main/java/com/xuqm/push/XuqmPushPackage.java new file mode 100644 index 0000000..6a24728 --- /dev/null +++ b/packages/push/android/src/main/java/com/xuqm/push/XuqmPushPackage.java @@ -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 createNativeModules(ReactApplicationContext reactContext) { + return Collections.singletonList(new XuqmPushModule(reactContext)); + } + + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } +} diff --git a/packages/push/ios/XuqmPushModule.m b/packages/push/ios/XuqmPushModule.m index 7394aca..61d8a8d 100644 --- a/packages/push/ios/XuqmPushModule.m +++ b/packages/push/ios/XuqmPushModule.m @@ -1,48 +1,10 @@ #import -#import -@interface XuqmPushModule : RCTEventEmitter -@end +// Swift 实现见 XuqmPushModule.swift(RCT_EXTERN_MODULE 桥接)。 +@interface RCT_EXTERN_MODULE(XuqmPushModule, NSObject) -@implementation XuqmPushModule - -RCT_EXPORT_MODULE(XuqmPushModule); - -- (NSArray *)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"}]; -} +RCT_EXTERN_METHOD(setUserInfo:(NSDictionary *)info + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) @end diff --git a/packages/push/ios/XuqmPushModule.swift b/packages/push/ios/XuqmPushModule.swift new file mode 100644 index 0000000..24c9c1d --- /dev/null +++ b/packages/push/ios/XuqmPushModule.swift @@ -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 + * 暂无对应 API,故本模块不导出(JS 侧检测到方法缺失会自动 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) + } +} diff --git a/packages/push/react-native.config.js b/packages/push/react-native.config.js new file mode 100644 index 0000000..ab91d05 --- /dev/null +++ b/packages/push/react-native.config.js @@ -0,0 +1,11 @@ +module.exports = { + dependency: { + platforms: { + android: { + sourceDir: './android', + packageImportPath: 'import com.xuqm.push.XuqmPushPackage;', + packageInstance: 'new XuqmPushPackage()', + }, + }, + }, +} diff --git a/packages/push/src/NativePush.ts b/packages/push/src/NativePush.ts index 5a1a2b3..4b6b45f 100644 --- a/packages/push/src/NativePush.ts +++ b/packages/push/src/NativePush.ts @@ -1,33 +1,41 @@ -import { NativeModules, NativeEventEmitter, Platform } from 'react-native' +import { NativeModules } from 'react-native' -interface XuqmPushModuleInterface { - detectVendor: () => Promise - registerPush: () => Promise +import type { XuqmUserInfo } from '@xuqm/rn-common' + +interface XuqmPushNative { + /** 转发到原生 XuqmSDK.setUserInfo;原生自动完成厂商检测/token/绑定。传 null 登出。 */ + setUserInfo(info: Pick | null): Promise + setOfflinePushEnabled(enabled: boolean): Promise + setQuietHours(start: string, end: string): Promise + clearQuietHours(): Promise } -const _native = NativeModules.XuqmPushModule as XuqmPushModuleInterface | undefined -const _eventEmitter = _native ? new NativeEventEmitter(_native as any) : null +const _native = NativeModules.XuqmPushModule as XuqmPushNative | undefined export function isNativePushAvailable(): boolean { return !!_native } -export async function detectVendorNative(): Promise { - if (!_native?.detectVendor) return Platform.OS === 'ios' ? 'APNS' : 'FCM' - return _native.detectVendor() +export async function nativeSetUserInfo(info: XuqmUserInfo | null): Promise { + if (!_native?.setUserInfo) return + await _native.setUserInfo( + info + ? { userId: info.userId, userSig: info.userSig, name: info.name, phone: info.phone } + : null, + ) } -export async function registerPushNative(): Promise { - if (!_native?.registerPush) return - await _native.registerPush() +export async function nativeSetOfflinePushEnabled(enabled: boolean): Promise { + if (!_native?.setOfflinePushEnabled) return + await _native.setOfflinePushEnabled(enabled) } -export function addPushTokenListener( - callback: (event: { token: string; vendor: string }) => void, -): () => void { - if (!_eventEmitter) { - return () => {} - } - const subscription = _eventEmitter.addListener('XuqmPushToken', callback) - return () => subscription.remove() +export async function nativeSetQuietHours(start: string, end: string): Promise { + if (!_native?.setQuietHours) return + await _native.setQuietHours(start, end) +} + +export async function nativeClearQuietHours(): Promise { + if (!_native?.clearQuietHours) return + await _native.clearQuietHours() } diff --git a/packages/push/src/PushSDK.ts b/packages/push/src/PushSDK.ts index bf036ed..cb503e2 100644 --- a/packages/push/src/PushSDK.ts +++ b/packages/push/src/PushSDK.ts @@ -1,139 +1,41 @@ -import { - apiRequest, - getConfig, - getDeviceInfo, - getUserId as getCommonUserId, - _registerUserInfoHandler, -} from '@xuqm/rn-common' +import { _registerUserInfoHandler } 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 } -// ─── Internal state ──────────────────────────────────────────────────────────── - -let _registeredUserId: string | null = null -let _tokenUnsubscribe: (() => void) | null = null - -// ─── Internal helpers ────────────────────────────────────────────────────────── - -async function _registerDevice(userId: string, token: string, vendor: PushVendor): Promise { - 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 { - 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 { - // 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,由原生 sdk-push 自动完成 +// 厂商检测 + token 获取 + 绑定到服务端(/api/push/register)。RN 侧不再做 vendor/token/register。 _registerUserInfoHandler(async (info: XuqmUserInfo | null) => { - if (!info) { - // 登出:解绑 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(() => {}) + await nativeSetUserInfo(info).catch(() => {}) }) // ─── PushSDK 公开 API ────────────────────────────────────────────────────────── export const PushSDK = { - /** - * 设置离线推送开关。 - */ + /** 设置离线推送开关(委托原生 PushSDK,随绑定同步到服务端)。 */ async setOfflinePushEnabled(enabled: boolean): Promise { - const config = getConfig() - const userId = getCommonUserId() - if (!userId) return - await apiRequest('/api/push/settings/offline', { - method: 'PUT', - params: { appKey: config.appKey, userId, enabled: String(enabled) }, - }) + await nativeSetOfflinePushEnabled(enabled) }, - /** - * 设置免打扰时间段(24 小时制,如 '22:00'、'08:00')。 - */ + /** 设置免打扰时间段(24 小时制,如 '22:00'、'08:00')。 */ async setQuietHours(start: string, end: string): Promise { - const config = getConfig() - const userId = getCommonUserId() - if (!userId) return - await apiRequest('/api/push/settings/quiet-hours', { - method: 'PUT', - params: { appKey: config.appKey, userId, start, end }, - }) + await nativeSetQuietHours(start, end) }, - /** - * 清除免打扰设置。 - */ + /** 清除免打扰设置。 */ async clearQuietHours(): Promise { - const config = getConfig() - const userId = getCommonUserId() - if (!userId) return - await apiRequest('/api/push/settings/quiet-hours', { - method: 'DELETE', - params: { appKey: config.appKey, userId }, - }) + await nativeClearQuietHours() }, - /** - * 登出推送(通常无需手动调用,XuqmSDK.setUserInfo(null) 会自动触发)。 - */ + /** 登出推送(通常无需手动调用,XuqmSDK.setUserInfo(null) 会自动触发)。 */ async logout(): Promise { - const userId = _registeredUserId ?? getCommonUserId() - if (!userId) return - await _unregisterDevice(userId).catch(() => {}) - if (_tokenUnsubscribe) { - _tokenUnsubscribe() - _tokenUnsubscribe = null - } + await nativeSetUserInfo(null) }, } diff --git a/packages/update/src/UpdateSDK.ts b/packages/update/src/UpdateSDK.ts index acd3c93..f624846 100644 --- a/packages/update/src/UpdateSDK.ts +++ b/packages/update/src/UpdateSDK.ts @@ -65,6 +65,15 @@ const _pluginRegistry = new Set() const UPDATE_APP_CACHE_KEY = 'xuqm_update_app_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 { + 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 async function _readUpdateCache(key: string): Promise { @@ -221,10 +230,19 @@ export const UpdateSDK = { // ── App 整包更新 ────────────────────────────────────────────────────────── async checkAppUpdate(bypassIgnore?: boolean): Promise { + // 应用客户端忽略:非强制更新且 versionCode 已被忽略时视为无更新。 + const applyIgnore = async (info: AppUpdateInfo): Promise => { + 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) if (!bypassIgnore) { const cached = await _readUpdateCache(UPDATE_APP_CACHE_KEY) - if (cached) return cached + if (cached) return applyIgnore(cached) } const config = getConfig() @@ -249,7 +267,23 @@ export const UpdateSDK = { }) const normalized = { ...result, downloadUrl: normalizeDownloadUrl(result.downloadUrl) } await _writeUpdateCache(UPDATE_APP_CACHE_KEY, normalized) - return normalized + return applyIgnore(normalized) + }, + + /** 忽略指定版本(强制更新不受影响)。 */ + async ignoreAppVersion(versionCode: number): Promise { + if (!Number.isFinite(versionCode)) return + await AsyncStorage.setItem(UPDATE_IGNORED_VERSION_KEY, String(versionCode)) + }, + + /** 清除已忽略的版本(恢复提示)。 */ + async clearIgnoredAppVersion(): Promise { + await AsyncStorage.removeItem(UPDATE_IGNORED_VERSION_KEY) + }, + + /** 当前被忽略的 versionCode(无则 null)。 */ + async getIgnoredAppVersion(): Promise { + return _getIgnoredVersionCode() }, async openStore(appStoreUrl?: string, marketUrl?: string): Promise { diff --git a/packages/xwebview/package.json b/packages/xwebview/package.json index da6bb5c..51c1749 100644 --- a/packages/xwebview/package.json +++ b/packages/xwebview/package.json @@ -28,6 +28,11 @@ "react-native-safe-area-context": ">=4.0.0", "@xuqm/rn-common": ">=0.2.2" }, + "peerDependenciesMeta": { + "@react-native-camera-roll/camera-roll": { + "optional": true + } + }, "devDependencies": { "@types/react": "^19.0.0", "@react-navigation/native": "^7.0.0", diff --git a/packages/xwebview/src/XWebViewDownload.ts b/packages/xwebview/src/XWebViewDownload.ts index 1a5d228..63a12c3 100644 --- a/packages/xwebview/src/XWebViewDownload.ts +++ b/packages/xwebview/src/XWebViewDownload.ts @@ -2,6 +2,7 @@ import { Platform } from 'react-native' import type { XWebViewDownloadDecision, + XWebViewDownloadDestination, XWebViewDownloadProgress, XWebViewDownloadRequest, XWebViewDownloadResult, @@ -12,6 +13,63 @@ import type { // 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 MIME_BY_EXT: Record = { + 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 } | 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 { + 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 { const rfc5987 = header.match(/filename\*=(?:[^']*'[^']*')?([^;\s]+)/i) if (rfc5987?.[1]) { @@ -91,20 +149,66 @@ async function resolveFilePath( return candidate } +/** + * 用系统查看器打开已下载文件(Android: actionViewIntent;iOS: openDocument)。 + * best-effort,失败不抛出。 + */ +export async function openFile(filePath: string, filenameOrMime?: string): Promise { + 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( base64: string, filename: string, savePath: string | undefined, conflict: 'rename' | 'overwrite', + destination: XWebViewDownloadDestination = 'sandbox', ): Promise { - const { dirs } = getRNFetchBlob().fs + const blob = getRNFetchBlob() + const { dirs } = blob.fs + + // 公共 Downloads(Android):先写沙盒,再通过 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 = savePath ?? (Platform.OS === 'android' ? (dirs.DownloadDir ?? dirs.DocumentDir) : dirs.DocumentDir) const filePath = await resolveFilePath(dir, filename, conflict) - await getRNFetchBlob().fs.writeFile(filePath, base64, 'base64') + await blob.fs.writeFile(filePath, base64, 'base64') return filePath } @@ -125,6 +229,8 @@ export async function handleDownloadRequest( autoDownload?: boolean downloadConflict?: 'rename' | 'overwrite' savePath?: string + downloadDestination?: XWebViewDownloadDestination + downloadNotificationTitle?: string onDownloadDecide?: ( request: XWebViewDownloadRequest, ) => XWebViewDownloadDecision | Promise @@ -138,6 +244,8 @@ export async function handleDownloadRequest( autoDownload = true, downloadConflict = 'rename', savePath, + downloadDestination = 'sandbox', + downloadNotificationTitle, onDownloadDecide, onDownloadStart, onDownloadProgress, @@ -168,6 +276,7 @@ export async function handleDownloadRequest( p => onDownloadProgress?.(p), r => onDownloadComplete?.(r), err => onDownloadError?.(url, err), + { destination: downloadDestination, notificationTitle: downloadNotificationTitle }, ) } @@ -179,8 +288,67 @@ export function startDownload( onProgress: (p: XWebViewDownloadProgress) => void, onComplete: (r: XWebViewDownloadResult) => void, onError: (error: string) => void, + options: { + destination?: XWebViewDownloadDestination + notificationTitle?: string + } = {}, ): DownloadHandle { - const { dirs } = getRNFetchBlob().fs + const { destination = 'sandbox', notificationTitle } = options + const blob = getRNFetchBlob() + const { dirs } = blob.fs + + // 公共 Downloads(Android):下载到沙盒(保留 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 = savePath ?? (Platform.OS === 'android' diff --git a/packages/xwebview/src/XWebViewScreen.tsx b/packages/xwebview/src/XWebViewScreen.tsx index fcc9de9..0686167 100644 --- a/packages/xwebview/src/XWebViewScreen.tsx +++ b/packages/xwebview/src/XWebViewScreen.tsx @@ -261,6 +261,8 @@ export function XWebViewScreen() { onPermissionRequest, autoDownload = true, downloadConflict = 'rename', + downloadDestination = 'sandbox', + downloadNotificationTitle, onDownloadStart, onDownloadProgress, onDownloadComplete, @@ -358,13 +360,19 @@ export function XWebViewScreen() { onDownloadStart?.({ ...info, suggestedFilename: finalFilename }) 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 }) } catch (err) { onDownloadError?.(blobUrl, String(err)) } }, - [autoDownload, downloadConflict, onDownloadDecide, onDownloadStart, onDownloadComplete, onDownloadError], + [autoDownload, downloadConflict, downloadDestination, onDownloadDecide, onDownloadStart, onDownloadComplete, onDownloadError], ) const processDownload = useCallback( @@ -392,9 +400,10 @@ export function XWebViewScreen() { p => onDownloadProgress?.(p), r => onDownloadComplete?.(r), 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( diff --git a/packages/xwebview/src/XWebViewView.tsx b/packages/xwebview/src/XWebViewView.tsx index d2e8004..afa3c47 100644 --- a/packages/xwebview/src/XWebViewView.tsx +++ b/packages/xwebview/src/XWebViewView.tsx @@ -135,10 +135,13 @@ const DIALOG_OVERRIDE_JS = ` true; ` -export function XWebViewView() { +export function XWebViewView({ config: configProp }: { config?: XWebViewConfig } = {}) { const webViewRef = useRef(null) - const config = getXWebViewConfig() + // configProp 用于内联嵌入(不依赖全局单例 config,可同屏多实例); + // 不传时回退到 openXWebView 设置的全局 config(全屏/miniapp 用法)。 + const inline = configProp != null + const config = configProp ?? getXWebViewConfig() const { showStatusBar = true, title: initialTitle = '', @@ -149,6 +152,8 @@ export function XWebViewView() { onPermissionRequest, autoDownload = true, downloadConflict = 'rename', + downloadDestination = 'sandbox', + downloadNotificationTitle, onDownloadStart, onDownloadProgress, onDownloadComplete, @@ -165,6 +170,8 @@ export function XWebViewView() { const [loadError, setLoadError] = useState(null) useEffect(() => { + // 内联模式不注册全局 controller,避免与全屏 XWebView 实例互相覆盖。 + if (inline) return setXWebViewController({ refresh: () => webViewRef.current?.reload(), close: () => onClose?.(), @@ -175,7 +182,7 @@ export function XWebViewView() { getTitle: () => currentTitle, }) return () => setXWebViewController(null) - }, [currentUrl, currentTitle, onClose]) + }, [inline, currentUrl, currentTitle, onClose]) const handleNavigationStateChange = useCallback((state: WebViewNavigation) => { setCanGoBack(state.canGoBack) @@ -205,13 +212,19 @@ export function XWebViewView() { onDownloadStart?.({ ...info, suggestedFilename: finalFilename }) 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 }) } catch (err) { onDownloadError?.(blobUrl, String(err)) } }, - [autoDownload, downloadConflict, onDownloadDecide, onDownloadStart, onDownloadComplete, onDownloadError], + [autoDownload, downloadConflict, downloadDestination, onDownloadDecide, onDownloadStart, onDownloadComplete, onDownloadError], ) const processDownload = useCallback( @@ -237,9 +250,10 @@ export function XWebViewView() { p => onDownloadProgress?.(p), r => onDownloadComplete?.(r), 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( diff --git a/packages/xwebview/src/index.ts b/packages/xwebview/src/index.ts index e9e8c53..c5d8bdd 100644 --- a/packages/xwebview/src/index.ts +++ b/packages/xwebview/src/index.ts @@ -11,6 +11,7 @@ export type { XWebViewConfig, XWebViewControllerAPI, XWebViewDownloadDecision, + XWebViewDownloadDestination, XWebViewDownloadProgress, XWebViewDownloadRequest, XWebViewDownloadResult, @@ -21,5 +22,5 @@ export type { export { default as XWebViewProgress } from './XWebViewProgress' export { XWebViewView } from './XWebViewView' export { XWebViewScreen } from './XWebViewScreen' -export { handleDownloadRequest, fetchDownloadInfo, saveBase64File, startDownload } from './XWebViewDownload' +export { handleDownloadRequest, fetchDownloadInfo, saveBase64File, startDownload, openFile } from './XWebViewDownload' export type { DownloadHandle } from './XWebViewDownload'