feat(sdk): 新增文件上传下载功能并完善WebView组件
- 在Android SDK中新增FileSDK模块,提供统一的文件上传、下载、打开接口 - 实现Android端文件下载到沙盒目录或公共Downloads目录,并支持通知栏进度显示 - 完善Android WebView组件,增加文件选择、拍照、下载拦截、H5双向通信能力 - 在iOS SDK中新增XuqmFileSDK模块,提供文件上传下载功能 - 实现iOS端WebView组件的文件下载拦截和原生文件选择器集成 - 更新文档说明Android和iOS SDK的文件操作API使用方法 - 重构iOS SDK项目结构,按功能拆分为多个独立模块便于集成 - 添加文件下载进度通知和完成后的文件打开功能
这个提交包含在:
父节点
25b80ce9e3
当前提交
5b34c60838
@ -54,6 +54,7 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "XuqmWebViewSDK",
|
name: "XuqmWebViewSDK",
|
||||||
|
dependencies: ["XuqmFileSDK"],
|
||||||
path: "Sources/XuqmWebViewSDK",
|
path: "Sources/XuqmWebViewSDK",
|
||||||
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]
|
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]
|
||||||
),
|
),
|
||||||
|
|||||||
137
README.md
137
README.md
@ -10,19 +10,13 @@ XuqmGroup-iOSSDK/
|
|||||||
├── XuqmSDK.podspec # CocoaPods 发版描述
|
├── XuqmSDK.podspec # CocoaPods 发版描述
|
||||||
├── XuqmWebViewSDK.podspec # WebView 独立模块发版描述
|
├── XuqmWebViewSDK.podspec # WebView 独立模块发版描述
|
||||||
├── PUBLISH.md # 发版操作手册
|
├── PUBLISH.md # 发版操作手册
|
||||||
├── Sources/XuqmSDK/
|
├── Sources/XuqmCoreSDK/ # 核心:初始化、HTTP、Token
|
||||||
├── Core/
|
├── Sources/XuqmImSDK/ # IM:WebSocket 实时通信
|
||||||
│ ├── XuqmSDK.swift # 入口:init / setToken
|
├── Sources/XuqmPushSDK/ # 推送:APNs Token 注册
|
||||||
│ ├── SDKConfig.swift # 配置结构体
|
├── Sources/XuqmUpdateSDK/ # 版本管理:检查更新
|
||||||
│ ├── ApiClient.swift # HTTP 客户端(URLSession)
|
├── Sources/XuqmLicenseSDK/ # 授权管理
|
||||||
│ └── TokenStore.swift # Keychain Token 存储
|
├── Sources/XuqmFileSDK/ # 文件上传、下载、本地打开
|
||||||
├── IM/
|
└── Sources/XuqmWebViewSDK/ # WebView 嵌入式组件 / 独立页面
|
||||||
│ └── ImClient.swift # WebSocket IM 客户端
|
|
||||||
├── Push/
|
|
||||||
│ └── PushSDK.swift # APNs Token 注册
|
|
||||||
└── Update/
|
|
||||||
└── UpdateSDK.swift # 版本检查 / App 更新
|
|
||||||
└── Sources/XuqmWebViewSDK/
|
|
||||||
├── XWebViewBridge.swift # 页面配置 / 控制器桥接
|
├── XWebViewBridge.swift # 页面配置 / 控制器桥接
|
||||||
├── XWebViewPage.swift # 独立页面
|
├── XWebViewPage.swift # 独立页面
|
||||||
├── XWebViewTypes.swift # 配置 / 控制器协议
|
├── XWebViewTypes.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 原生支持 `<input type="file">`,SDK 额外处理:
|
||||||
|
|
||||||
|
- `<input type="file" capture>` 摄像头: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(推荐)
|
### SPM(推荐)
|
||||||
|
|||||||
@ -12,12 +12,89 @@ public struct FileUploadResult: Codable, Sendable {
|
|||||||
public let ext: String?
|
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 final class FileSDK: @unchecked Sendable {
|
||||||
public static let shared = FileSDK()
|
public static let shared = FileSDK()
|
||||||
private init() {}
|
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 {
|
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 tokenStore = await XuqmSDK.shared.tokenStore
|
||||||
let boundary = "Boundary-\(UUID().uuidString)"
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import XuqmFileSDK
|
||||||
|
|
||||||
public struct XWebViewConfig: Sendable {
|
public struct XWebViewConfig: Sendable {
|
||||||
public var url: String
|
public var url: String
|
||||||
@ -7,6 +8,8 @@ public struct XWebViewConfig: Sendable {
|
|||||||
public var hideStatusBar: Bool
|
public var hideStatusBar: Bool
|
||||||
public var userAgent: String?
|
public var userAgent: String?
|
||||||
public var injectedJavaScript: String?
|
public var injectedJavaScript: String?
|
||||||
|
/// Where intercepted WebView downloads are saved.
|
||||||
|
public var downloadDestination: FileDownloadDestination
|
||||||
public var onMessage: (@Sendable (String) -> Void)?
|
public var onMessage: (@Sendable (String) -> Void)?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@ -16,6 +19,7 @@ public struct XWebViewConfig: Sendable {
|
|||||||
hideStatusBar: Bool = false,
|
hideStatusBar: Bool = false,
|
||||||
userAgent: String? = nil,
|
userAgent: String? = nil,
|
||||||
injectedJavaScript: String? = nil,
|
injectedJavaScript: String? = nil,
|
||||||
|
downloadDestination: FileDownloadDestination = .sandbox,
|
||||||
onMessage: (@Sendable (String) -> Void)? = nil
|
onMessage: (@Sendable (String) -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
self.url = url
|
self.url = url
|
||||||
@ -24,6 +28,7 @@ public struct XWebViewConfig: Sendable {
|
|||||||
self.hideStatusBar = hideStatusBar
|
self.hideStatusBar = hideStatusBar
|
||||||
self.userAgent = userAgent
|
self.userAgent = userAgent
|
||||||
self.injectedJavaScript = injectedJavaScript
|
self.injectedJavaScript = injectedJavaScript
|
||||||
|
self.downloadDestination = downloadDestination
|
||||||
self.onMessage = onMessage
|
self.onMessage = onMessage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import XuqmFileSDK
|
||||||
|
|
||||||
// JS bridge name used by DIALOG_OVERRIDE_JS to post messages to native.
|
// JS bridge name used by DIALOG_OVERRIDE_JS to post messages to native.
|
||||||
private let kBridgeName = "ReactNativeWebView"
|
private let kBridgeName = "ReactNativeWebView"
|
||||||
@ -181,18 +182,96 @@ public struct XWebViewView: UIViewRepresentable {
|
|||||||
didReceive message: WKScriptMessage
|
didReceive message: WKScriptMessage
|
||||||
) {
|
) {
|
||||||
guard let raw = message.body as? String else { return }
|
guard let raw = message.body as? String else { return }
|
||||||
// Filter internal __xwv messages; forward the rest to onMessage.
|
guard let data = raw.data(using: .utf8),
|
||||||
if let data = raw.data(using: .utf8),
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
config.onMessage?(raw)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, *)
|
@available(iOS 15.0, *)
|
||||||
public func webView(
|
public func webView(
|
||||||
@ -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
|
#else
|
||||||
|
|
||||||
public struct XWebViewView: View {
|
public struct XWebViewView: View {
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户