diff --git a/Package.swift b/Package.swift index a4ba93a..43def58 100644 --- a/Package.swift +++ b/Package.swift @@ -54,6 +54,7 @@ let package = Package( ), .target( name: "XuqmWebViewSDK", + dependencies: ["XuqmFileSDK"], path: "Sources/XuqmWebViewSDK", swiftSettings: [.enableExperimentalFeature("StrictConcurrency")] ), diff --git a/README.md b/README.md index 50e0ac2..6293508 100644 --- a/README.md +++ b/README.md @@ -6,27 +6,21 @@ ``` XuqmGroup-iOSSDK/ -├── Package.swift # SPM 包定义 -├── XuqmSDK.podspec # CocoaPods 发版描述 -├── XuqmWebViewSDK.podspec # WebView 独立模块发版描述 -├── PUBLISH.md # 发版操作手册 -├── Sources/XuqmSDK/ - ├── Core/ - │ ├── XuqmSDK.swift # 入口:init / setToken - │ ├── SDKConfig.swift # 配置结构体 - │ ├── ApiClient.swift # HTTP 客户端(URLSession) - │ └── TokenStore.swift # Keychain Token 存储 - ├── IM/ - │ └── ImClient.swift # WebSocket IM 客户端 - ├── Push/ - │ └── PushSDK.swift # APNs Token 注册 - └── Update/ - └── UpdateSDK.swift # 版本检查 / App 更新 -└── Sources/XuqmWebViewSDK/ - ├── XWebViewBridge.swift # 页面配置 / 控制器桥接 - ├── XWebViewPage.swift # 独立页面 - ├── XWebViewTypes.swift # 配置 / 控制器协议 - └── XWebViewView.swift # 嵌入式组件 +├── Package.swift # SPM 包定义 +├── XuqmSDK.podspec # CocoaPods 发版描述 +├── XuqmWebViewSDK.podspec # WebView 独立模块发版描述 +├── PUBLISH.md # 发版操作手册 +├── Sources/XuqmCoreSDK/ # 核心:初始化、HTTP、Token +├── Sources/XuqmImSDK/ # IM:WebSocket 实时通信 +├── Sources/XuqmPushSDK/ # 推送:APNs Token 注册 +├── Sources/XuqmUpdateSDK/ # 版本管理:检查更新 +├── Sources/XuqmLicenseSDK/ # 授权管理 +├── Sources/XuqmFileSDK/ # 文件上传、下载、本地打开 +└── Sources/XuqmWebViewSDK/ # WebView 嵌入式组件 / 独立页面 + ├── XWebViewBridge.swift # 页面配置 / 控制器桥接 + ├── XWebViewPage.swift # 独立页面 + ├── XWebViewTypes.swift # 配置 / 控制器协议 + └── XWebViewView.swift # 嵌入式组件 ``` ## 集成 @@ -264,6 +258,123 @@ if result.needsUpdate, let info = result.info { --- +## XuqmFileSDK + +文件上传、下载、本地打开的统一入口。`XuqmImSDK` 和 `XuqmWebViewSDK` 均依赖此模块。 + +### 上传 + +```swift +import XuqmFileSDK + +let result: FileUploadResult = try await FileSDK.shared.upload( + fileURL: localFileURL, + thumbnailData: thumbnailJpegData // 可选 +) +// result.url / .thumbnailUrl / .hash / .size / .mimeType / .ext +``` + +### 下载 + +```swift +// 下载存储目标 +public enum FileDownloadDestination: Sendable { + case sandbox // App Documents 目录(在 Files App 中以 App 名称显示) + case userPick // 弹出系统存储面板,由用户选择保存位置 +} + +// URL 下载,实时进度回调 +let localURL: URL = try await FileSDK.shared.download( + url: "https://example.com/report.pdf", + fileName: "report.pdf", // 可选,默认从 URL 推断 + destination: .sandbox, + onProgress: { progress in // 0–100 + print("下载进度:\(progress)%") + } +) + +// Blob / base64 数据写盘 +let localURL: URL = try FileSDK.shared.saveBlobDownload( + base64Data: b64String, + fileName: "export.xlsx", + destination: .userPick +) +``` + +--- + +## XuqmWebViewSDK + +### XWebViewConfig 完整参数 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `url` | String | `""` | 初始加载地址 | +| `title` | String | `""` | 页面标题(独立页面模式使用) | +| `hideToolbar` | Bool | `false` | 隐藏独立页面顶栏 | +| `hideStatusBar` | Bool | `false` | 隐藏状态栏 | +| `userAgent` | String? | `nil` | 自定义 User-Agent | +| `injectedJavaScript` | String? | `nil` | 页面加载后注入的额外 JS | +| `downloadDestination` | FileDownloadDestination | `.sandbox` | 下载文件存储目标 | +| `onMessage` | `(String) -> Void`? | `nil` | H5 发送消息的回调 | + +### 文件选择与拍照 + +WKWebView 原生支持 ``,SDK 额外处理: + +- `` 摄像头:iOS 15+ 通过 `requestMediaCapturePermissionFor` 自动授权 +- 麦克风 / 摄像头 WebRTC `getUserMedia()`:自动授权 + +### 下载拦截与存储 + +```swift +XWebViewView( + config: XWebViewConfig( + url: "https://example.com", + downloadDestination: .userPick // 弹出系统 Files 存储面板 + ) +) +``` + +- `.sandbox`:下载完成后弹出 `UIActivityViewController`(分享 / 存储到 Files) +- `.userPick`:下载完成后弹出 `UIDocumentPickerViewController`(直接选择存储位置) + +### H5 监听下载进度 + +```javascript +// 下载进度(0–100) +window.addEventListener('__xwvDownloadProgress', (e) => { + console.log(e.detail.url, e.detail.progress) +}) + +// 下载完成 +window.addEventListener('__xwvDownloadDone', (e) => { + if (e.detail.success) { + console.log('下载成功', e.detail.url) + } else { + console.error('下载失败', e.detail.error) + } +}) +``` + +### H5 ↔ Native 消息通信 + +```javascript +// H5 → Native(触发 onMessage 回调) +window.webkit.messageHandlers.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'login', token: '...' }) +) +``` + +```swift +// Native → H5 +XWebViewBridge.shared.currentController()?.postMessageToWeb( + "window.dispatchEvent(new CustomEvent('nativeMsg', { detail: { key: 'value' } }))" +) +``` + +--- + ## 发版 ### SPM(推荐) diff --git a/Sources/XuqmFileSDK/FileSDK.swift b/Sources/XuqmFileSDK/FileSDK.swift index a2b7fe5..b91a3e7 100644 --- a/Sources/XuqmFileSDK/FileSDK.swift +++ b/Sources/XuqmFileSDK/FileSDK.swift @@ -12,12 +12,89 @@ public struct FileUploadResult: Codable, Sendable { public let ext: String? } +public enum FileDownloadDestination: Sendable { + /// App's Documents directory — private to the app, visible in Files under the app name. + case sandbox + /// Prompts the user with a system save panel (UIDocumentPickerViewController) to choose any location. + case userPick +} + public final class FileSDK: @unchecked Sendable { public static let shared = FileSDK() private init() {} + // MARK: - Download + + public func download( + url: String, + fileName: String? = nil, + destination: FileDownloadDestination = .sandbox, + onProgress: @Sendable @escaping (Int) -> Void = { _ in } + ) async throws -> URL { + guard let downloadURL = URL(string: url) else { + throw URLError(.badURL) + } + let resolvedName = fileName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + ?? downloadURL.lastPathComponent.components(separatedBy: "?").first?.nilIfEmpty + ?? "download.bin" + + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + "_" + resolvedName) + + let (asyncBytes, response) = try await URLSession.shared.bytes(from: downloadURL) + let totalSize = (response as? HTTPURLResponse)?.expectedContentLength ?? -1 + + var received: Int64 = 0 + var buffer = Data() + buffer.reserveCapacity(totalSize > 0 ? Int(min(totalSize, 1024 * 1024)) : 65536) + for try await byte in asyncBytes { + buffer.append(byte) + received += 1 + if totalSize > 0 { + onProgress(Int(received * 100 / totalSize)) + } + } + onProgress(100) + try buffer.write(to: tempURL) + + switch destination { + case .sandbox: + let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let target = uniqueURL(in: docsDir, name: resolvedName) + try FileManager.default.moveItem(at: tempURL, to: target) + return target + case .userPick: + // Return the temp URL; caller must present UIDocumentPickerViewController. + // The picker moves/copies the file to the user-chosen location. + return tempURL + } + } + + public func saveBlobDownload( + base64Data: String, + fileName: String, + destination: FileDownloadDestination = .sandbox + ) throws -> URL { + guard let data = Data(base64Encoded: base64Data) else { + throw CocoaError(.fileReadCorruptFile) + } + switch destination { + case .sandbox: + let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let target = uniqueURL(in: docsDir, name: fileName.nilIfEmpty ?? "download.bin") + try data.write(to: target) + return target + case .userPick: + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + "_" + (fileName.nilIfEmpty ?? "download.bin")) + try data.write(to: tempURL) + return tempURL + } + } + + // MARK: - Upload + public func upload(fileURL: URL, thumbnailData: Data? = nil) async throws -> FileUploadResult { - let config = await XuqmSDK.shared.requireConfig() + _ = await XuqmSDK.shared.requireConfig() let tokenStore = await XuqmSDK.shared.tokenStore let boundary = "Boundary-\(UUID().uuidString)" @@ -84,3 +161,20 @@ private extension Data { } } } + +private extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} + +private func uniqueURL(in directory: URL, name: String) -> URL { + let base = (name as NSString).deletingPathExtension + let ext = (name as NSString).pathExtension + let extSuffix = ext.isEmpty ? "" : ".\(ext)" + var candidate = directory.appendingPathComponent(name) + var index = 1 + while FileManager.default.fileExists(atPath: candidate.path) { + candidate = directory.appendingPathComponent("\(base)(\(index))\(extSuffix)") + index += 1 + } + return candidate +} diff --git a/Sources/XuqmWebViewSDK/XWebViewTypes.swift b/Sources/XuqmWebViewSDK/XWebViewTypes.swift index 4efe8ce..3c2aa07 100644 --- a/Sources/XuqmWebViewSDK/XWebViewTypes.swift +++ b/Sources/XuqmWebViewSDK/XWebViewTypes.swift @@ -1,4 +1,5 @@ import Foundation +import XuqmFileSDK public struct XWebViewConfig: Sendable { public var url: String @@ -7,6 +8,8 @@ public struct XWebViewConfig: Sendable { public var hideStatusBar: Bool public var userAgent: String? public var injectedJavaScript: String? + /// Where intercepted WebView downloads are saved. + public var downloadDestination: FileDownloadDestination public var onMessage: (@Sendable (String) -> Void)? public init( @@ -16,6 +19,7 @@ public struct XWebViewConfig: Sendable { hideStatusBar: Bool = false, userAgent: String? = nil, injectedJavaScript: String? = nil, + downloadDestination: FileDownloadDestination = .sandbox, onMessage: (@Sendable (String) -> Void)? = nil ) { self.url = url @@ -24,6 +28,7 @@ public struct XWebViewConfig: Sendable { self.hideStatusBar = hideStatusBar self.userAgent = userAgent self.injectedJavaScript = injectedJavaScript + self.downloadDestination = downloadDestination self.onMessage = onMessage } } diff --git a/Sources/XuqmWebViewSDK/XWebViewView.swift b/Sources/XuqmWebViewSDK/XWebViewView.swift index 33f4eaa..1b13977 100644 --- a/Sources/XuqmWebViewSDK/XWebViewView.swift +++ b/Sources/XuqmWebViewSDK/XWebViewView.swift @@ -1,4 +1,5 @@ import SwiftUI +import XuqmFileSDK // JS bridge name used by DIALOG_OVERRIDE_JS to post messages to native. private let kBridgeName = "ReactNativeWebView" @@ -181,19 +182,97 @@ public struct XWebViewView: UIViewRepresentable { didReceive message: WKScriptMessage ) { guard let raw = message.body as? String else { return } - // Filter internal __xwv messages; forward the rest to onMessage. - if let data = raw.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let xwv = json["__xwv"] as? String { - switch xwv { - case "log": return // debug only, not forwarded - case "alert", "confirm", "prompt", "download", "blobdownload", "bloberror": return - default: break - } + guard let data = raw.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + config.onMessage?(raw) + return + } + guard let xwv = json["__xwv"] as? String else { + config.onMessage?(raw) + return + } + switch xwv { + case "log", "alert", "confirm", "prompt", "bloberror": + return + case "download": + let url = json["url"] as? String ?? "" + let filename = (json["filename"] as? String)?.nilIfEmpty + Task { await handleDownload(url: url, filename: filename) } + case "blobdownload": + let url = json["url"] as? String ?? "" + let filename = (json["filename"] as? String)?.nilIfEmpty ?? "download.bin" + let b64 = json["data"] as? String ?? "" + Task { await handleBlobDownload(url: url, filename: filename, base64Data: b64) } + default: + break } - config.onMessage?(raw) } + private func dispatchDownloadEvent(_ name: String, url: String, extra: String = "") { + let escaped = url.jsEscaped + let js = "window.dispatchEvent(new CustomEvent('\(name)',{detail:{url:'\(escaped)'\(extra)}}));" + webView?.evaluateJavaScript(js, completionHandler: nil) + } + + private func handleDownload(url: String, filename: String?) async { + do { + let localURL = try await FileSDK.shared.download( + url: url, + fileName: filename, + destination: config.downloadDestination + ) { [weak self] progress in + Task { @MainActor [weak self] in + self?.dispatchDownloadEvent("__xwvDownloadProgress", url: url, extra: ",progress:\(progress)") + } + } + dispatchDownloadEvent("__xwvDownloadDone", url: url, extra: ",success:true") + openOrSave(localURL: localURL, destination: config.downloadDestination) + } catch { + let msg = error.localizedDescription.jsEscaped + dispatchDownloadEvent("__xwvDownloadDone", url: url, extra: ",success:false,error:'\(msg)'") + } + } + + private func handleBlobDownload(url: String, filename: String, base64Data: String) async { + do { + let localURL = try FileSDK.shared.saveBlobDownload( + base64Data: base64Data, + fileName: filename, + destination: config.downloadDestination + ) + dispatchDownloadEvent("__xwvDownloadDone", url: url, extra: ",success:true") + openOrSave(localURL: localURL, destination: config.downloadDestination) + } catch { + let msg = error.localizedDescription.jsEscaped + dispatchDownloadEvent("__xwvDownloadDone", url: url, extra: ",success:false,error:'\(msg)'") + } + } + + private func openOrSave(localURL: URL, destination: FileDownloadDestination) { + guard let wv = webView else { return } + #if canImport(UIKit) + switch destination { + case .sandbox: + let vc = UIActivityViewController(activityItems: [localURL], applicationActivities: nil) + topViewController(from: wv)?.present(vc, animated: true) + case .userPick: + let picker = UIDocumentPickerViewController(forExporting: [localURL], asCopy: true) + topViewController(from: wv)?.present(picker, animated: true) + } + #endif + } + + #if canImport(UIKit) + private func topViewController(from view: UIView) -> UIViewController? { + var responder: UIResponder? = view + while let r = responder { + if let vc = r as? UIViewController { return vc } + responder = r.next + } + return nil + } + #endif + @available(iOS 15.0, *) public func webView( _ webView: WKWebView, @@ -207,6 +286,16 @@ public struct XWebViewView: UIViewRepresentable { } } +private extension String { + var jsEscaped: String { + replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + } + var nilIfEmpty: String? { isEmpty ? nil : self } +} + #else public struct XWebViewView: View {