import Foundation import XuqmCoreSDK 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? } 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 { _ = await XuqmSDK.shared.requireConfig() 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.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) } } } 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 }