2026-05-03 00:11:06 +08:00
|
|
|
import Foundation
|
2026-05-23 01:20:57 +08:00
|
|
|
import XuqmCoreSDK
|
2026-05-03 00:11:06 +08:00
|
|
|
import UniformTypeIdentifiers
|
|
|
|
|
|
|
|
|
|
public struct FileUploadResult: Codable, Sendable {
|
|
|
|
|
public let url: String
|
|
|
|
|
public let thumbnailUrl: String?
|
|
|
|
|
public let hash: String
|
|
|
|
|
public let size: Int64
|
|
|
|
|
public let originalName: String?
|
|
|
|
|
public let mimeType: String?
|
|
|
|
|
public let ext: String?
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 15:48:08 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 00:11:06 +08:00
|
|
|
public final class FileSDK: @unchecked Sendable {
|
|
|
|
|
public static let shared = FileSDK()
|
|
|
|
|
private init() {}
|
|
|
|
|
|
2026-06-05 15:48:08 +08:00
|
|
|
// 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
|
|
|
|
|
|
2026-05-03 00:11:06 +08:00
|
|
|
public func upload(fileURL: URL, thumbnailData: Data? = nil) async throws -> FileUploadResult {
|
2026-06-05 15:48:08 +08:00
|
|
|
_ = await XuqmSDK.shared.requireConfig()
|
2026-05-03 00:11:06 +08:00
|
|
|
let tokenStore = await XuqmSDK.shared.tokenStore
|
|
|
|
|
let boundary = "Boundary-\(UUID().uuidString)"
|
|
|
|
|
|
|
|
|
|
let url = SDKEndpoints.apiBaseURL.appendingPathComponent("api/file/upload")
|
|
|
|
|
var request = URLRequest(url: url)
|
|
|
|
|
request.httpMethod = "POST"
|
|
|
|
|
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
|
|
|
|
if let token = tokenStore?.get() {
|
|
|
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let body = try createMultipartBody(fileURL: fileURL, boundary: boundary, thumbnailData: thumbnailData)
|
|
|
|
|
let (data, response) = try await URLSession.shared.upload(for: request, from: body)
|
|
|
|
|
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
|
|
|
|
throw URLError(.badServerResponse)
|
|
|
|
|
}
|
|
|
|
|
let wrapper = try JSONDecoder().decode(ApiResponse<FileUploadResult>.self, from: data)
|
|
|
|
|
guard let result = wrapper.data else {
|
|
|
|
|
throw URLError(.cannotDecodeContentData)
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func createMultipartBody(fileURL: URL, boundary: String, thumbnailData: Data?) throws -> Data {
|
|
|
|
|
var body = Data()
|
|
|
|
|
let filename = fileURL.lastPathComponent
|
|
|
|
|
|
|
|
|
|
let mimeType: String
|
|
|
|
|
if #available(iOS 14.0, macOS 11.0, *) {
|
|
|
|
|
if let uti = UTType(filenameExtension: (fileURL.path as NSString).pathExtension),
|
|
|
|
|
let preferred = uti.preferredMIMEType {
|
|
|
|
|
mimeType = preferred
|
|
|
|
|
} else {
|
|
|
|
|
mimeType = "application/octet-stream"
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
mimeType = "application/octet-stream"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body.append("--\(boundary)\r\n")
|
|
|
|
|
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n")
|
|
|
|
|
body.append("Content-Type: \(mimeType)\r\n\r\n")
|
|
|
|
|
body.append(try Data(contentsOf: fileURL))
|
|
|
|
|
body.append("\r\n")
|
|
|
|
|
|
|
|
|
|
if let thumbnailData {
|
|
|
|
|
let thumbFilename = "\((filename as NSString).deletingPathExtension)_thumb.jpg"
|
|
|
|
|
body.append("--\(boundary)\r\n")
|
|
|
|
|
body.append("Content-Disposition: form-data; name=\"thumbnail\"; filename=\"\(thumbFilename)\"\r\n")
|
|
|
|
|
body.append("Content-Type: image/jpeg\r\n\r\n")
|
|
|
|
|
body.append(thumbnailData)
|
|
|
|
|
body.append("\r\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body.append("--\(boundary)--\r\n")
|
|
|
|
|
return body
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private extension Data {
|
|
|
|
|
mutating func append(_ string: String) {
|
|
|
|
|
if let data = string.data(using: .utf8) {
|
|
|
|
|
append(data)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-05 15:48:08 +08:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|