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 {