From b6ae089c4aa51437eb369f35787bbdcf91393134 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 1 Jun 2026 09:16:12 +0000 Subject: [PATCH 1/2] chore: update Apple SDK to 18.0.0 --- CHANGELOG.md | 7 + README.md | 2 +- Sources/Appwrite/Client.swift | 110 +++++++++++-- Sources/Appwrite/Services/Avatars.swift | 4 +- Sources/Appwrite/Services/Presences.swift | 151 +++--------------- .../{Theme.swift => BrowserTheme.swift} | 2 +- Sources/AppwriteModels/Presence.swift | 25 +-- Sources/AppwriteModels/PresenceList.swift | 8 +- docs/examples/avatars/get-screenshot.md | 2 +- 9 files changed, 138 insertions(+), 173 deletions(-) rename Sources/AppwriteEnums/{Theme.swift => BrowserTheme.swift} (66%) diff --git a/CHANGELOG.md b/CHANGELOG.md index debf5ca..9eea076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Change Log +## 18.0.0 + +* Breaking: `avatars.getScreenshot` `theme` parameter now uses the `BrowserTheme` enum +* Breaking: Removed generic type parameters from `presences` service methods +* Added: `BrowserTheme` enum +* Updated: `Presence` model is now concrete and adds a `metadata` field + ## 17.1.1 * Fixed: Removed `Advisor` service and `Insight`, `InsightCTA`, `InsightList`, `Report`, `ReportList` models (admin-only endpoints, not intended for client SDKs) diff --git a/README.md b/README.md index 477f860..e262c07 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Add the package to your `Package.swift` dependencies: ```swift dependencies: [ - .package(url: "git@github.com:appwrite/sdk-for-apple.git", from: "17.1.1"), + .package(url: "git@github.com:appwrite/sdk-for-apple.git", from: "18.0.0"), ], ``` diff --git a/Sources/Appwrite/Client.swift b/Sources/Appwrite/Client.swift index 13341e9..17f521f 100644 --- a/Sources/Appwrite/Client.swift +++ b/Sources/Appwrite/Client.swift @@ -16,6 +16,7 @@ open class Client { // MARK: Properties public static var chunkSize = 5 * 1024 * 1024 // 5MB + public static var maxConcurrentUploads = 8 open var endPoint = "https://cloud.appwrite.io/v1" @@ -26,7 +27,7 @@ open class Client { "x-sdk-name": "Apple", "x-sdk-platform": "client", "x-sdk-language": "apple", - "x-sdk-version": "17.1.1", + "x-sdk-version": "18.0.0", "x-appwrite-response-format": "1.9.5" ] @@ -551,6 +552,7 @@ open class Client { var offset = 0 var result = [String:Any]() + var uploadId = idParamName != nil ? params[idParamName!] as? String : nil if idParamName != nil { // Make a request to check if a file already exists @@ -564,37 +566,113 @@ open class Client { ) let chunksUploaded = map["chunksUploaded"] as! Int offset = chunksUploaded * Client.chunkSize + result = map } catch { // File does not exist yet, swallow exception } } - while offset < size { - let slice = (input.data as! ByteBuffer).getSlice(at: offset, length: Client.chunkSize) - ?? (input.data as! ByteBuffer).getSlice(at: offset, length: Int(size - offset)) + let totalChunks = Int(ceil(Double(size) / Double(Client.chunkSize))) + var nextChunk = offset / Client.chunkSize + var completedChunks = nextChunk + var uploadedBytes = min(offset, size) + var completedResponse: [String: Any]? = nil + var lastChunkResponse: [String: Any]? = nil + let baseParams = params + let baseHeaders = headers + + func isUploadComplete(_ response: [String: Any]) -> Bool { + guard let chunksUploaded = response["chunksUploaded"] as? Int else { + return false + } + + let chunksTotal = response["chunksTotal"] as? Int ?? totalChunks + return chunksUploaded >= chunksTotal + } - params[paramName] = InputFile.fromBuffer(slice!, filename: input.filename, mimeType: input.mimeType) - headers["content-range"] = "bytes \(offset)-\(min((offset + Client.chunkSize) - 1, size - 1))/\(size)" + func uploadChunk(index: Int, uploadId: String?) async throws -> (Int, Int, [String: Any]) { + let chunkOffset = index * Client.chunkSize + let chunkLength = min(Client.chunkSize, size - chunkOffset) + guard let slice = (input.data as! ByteBuffer).getSlice(at: chunkOffset, length: chunkLength) else { + throw AppwriteError(message: "Failed to read upload chunk") + } + var chunkParams = baseParams + var chunkHeaders = baseHeaders + chunkParams[paramName] = InputFile.fromBuffer(slice, filename: input.filename, mimeType: input.mimeType) + chunkHeaders["content-range"] = "bytes \(chunkOffset)-\(chunkOffset + chunkLength - 1)/\(size)" + if let uploadId = uploadId { + chunkHeaders["x-appwrite-id"] = uploadId + } - result = try await call( + let chunkResult = try await call( method: "POST", path: path, - headers: headers, - params: params, + headers: chunkHeaders, + params: chunkParams, converter: { return $0 as! [String: Any] } ) - offset += Client.chunkSize - headers["x-appwrite-id"] = result["$id"] as? String + return (index, chunkLength, chunkResult) + } + + if nextChunk == 0 { + let first = try await uploadChunk(index: 0, uploadId: uploadId) + result = first.2 + uploadId = result["$id"] as? String + nextChunk = 1 + completedChunks = 1 + uploadedBytes = first.1 onProgress?(UploadProgress( - id: result["$id"] as? String ?? "", - progress: Double(min(offset, size))/Double(size) * 100.0, - sizeUploaded: min(offset, size), - chunksTotal: result["chunksTotal"] as? Int ?? -1, - chunksUploaded: result["chunksUploaded"] as? Int ?? -1 + id: uploadId ?? "", + progress: Double(uploadedBytes)/Double(size) * 100.0, + sizeUploaded: uploadedBytes, + chunksTotal: result["chunksTotal"] as? Int ?? totalChunks, + chunksUploaded: result["chunksUploaded"] as? Int ?? completedChunks )) } + let maxConcurrency = Client.maxConcurrentUploads + + try await withThrowingTaskGroup(of: (Int, Int, [String: Any]).self) { group in + var inFlight = 0 + + while inFlight < maxConcurrency && nextChunk < totalChunks { + let index = nextChunk + let currentUploadId = uploadId + group.addTask { try await uploadChunk(index: index, uploadId: currentUploadId) } + nextChunk += 1 + inFlight += 1 + } + + while let chunk = try await group.next() { + inFlight -= 1 + completedChunks += 1 + uploadedBytes += chunk.1 + lastChunkResponse = chunk.2 + if isUploadComplete(chunk.2) { + completedResponse = chunk.2 + } + + onProgress?(UploadProgress( + id: uploadId ?? "", + progress: Double(min(uploadedBytes, size))/Double(size) * 100.0, + sizeUploaded: min(uploadedBytes, size), + chunksTotal: chunk.2["chunksTotal"] as? Int ?? totalChunks, + chunksUploaded: chunk.2["chunksUploaded"] as? Int ?? completedChunks + )) + + while inFlight < maxConcurrency && nextChunk < totalChunks { + let index = nextChunk + let currentUploadId = uploadId + group.addTask { try await uploadChunk(index: index, uploadId: currentUploadId) } + nextChunk += 1 + inFlight += 1 + } + } + } + + result = completedResponse ?? lastChunkResponse ?? result + return try converter!(result) } diff --git a/Sources/Appwrite/Services/Avatars.swift b/Sources/Appwrite/Services/Avatars.swift index 8601e22..b673f44 100644 --- a/Sources/Appwrite/Services/Avatars.swift +++ b/Sources/Appwrite/Services/Avatars.swift @@ -308,7 +308,7 @@ open class Avatars: Service { /// - viewportWidth: Int (optional) /// - viewportHeight: Int (optional) /// - scale: Double (optional) - /// - theme: AppwriteEnums.Theme (optional) + /// - theme: AppwriteEnums.BrowserTheme (optional) /// - userAgent: String (optional) /// - fullpage: Bool (optional) /// - locale: String (optional) @@ -332,7 +332,7 @@ open class Avatars: Service { viewportWidth: Int? = nil, viewportHeight: Int? = nil, scale: Double? = nil, - theme: AppwriteEnums.Theme? = nil, + theme: AppwriteEnums.BrowserTheme? = nil, userAgent: String? = nil, fullpage: Bool? = nil, locale: String? = nil, diff --git a/Sources/Appwrite/Services/Presences.swift b/Sources/Appwrite/Services/Presences.swift index 803a3c6..715e963 100644 --- a/Sources/Appwrite/Services/Presences.swift +++ b/Sources/Appwrite/Services/Presences.swift @@ -17,14 +17,13 @@ open class Presences: Service { /// - total: Bool (optional) /// - ttl: Int (optional) /// - Throws: Exception if the request fails - /// - Returns: AppwriteModels.PresenceList + /// - Returns: AppwriteModels.PresenceList /// - open func list( + open func list( queries: [String]? = nil, total: Bool? = nil, - ttl: Int? = nil, - nestedType: T.Type - ) async throws -> AppwriteModels.PresenceList { + ttl: Int? = nil + ) async throws -> AppwriteModels.PresenceList { let apiPath: String = "/presences" let apiParams: [String: Any?] = [ @@ -35,7 +34,7 @@ open class Presences: Service { let apiHeaders: [String: String] = [:] - let converter: (Any) throws -> AppwriteModels.PresenceList = { response in + let converter: (Any) throws -> AppwriteModels.PresenceList = { response in return AppwriteModels.PresenceList.from(map: response as! [String: Any]) } @@ -48,30 +47,6 @@ open class Presences: Service { ) } - /// - /// List presence logs. Expired entries are filtered out automatically. - /// - /// - /// - Parameters: - /// - queries: [String] (optional) - /// - total: Bool (optional) - /// - ttl: Int (optional) - /// - Throws: Exception if the request fails - /// - Returns: AppwriteModels.PresenceList - /// - open func list( - queries: [String]? = nil, - total: Bool? = nil, - ttl: Int? = nil - ) async throws -> AppwriteModels.PresenceList<[String: AnyCodable]> { - return try await list( - queries: queries, - total: total, - ttl: ttl, - nestedType: [String: AnyCodable].self - ) - } - /// /// Get a presence log by its unique ID. Entries whose `expiresAt` is in the /// past are treated as not found. @@ -80,12 +55,11 @@ open class Presences: Service { /// - Parameters: /// - presenceId: String /// - Throws: Exception if the request fails - /// - Returns: AppwriteModels.Presence + /// - Returns: AppwriteModels.Presence /// - open func get( - presenceId: String, - nestedType: T.Type - ) async throws -> AppwriteModels.Presence { + open func get( + presenceId: String + ) async throws -> AppwriteModels.Presence { let apiPath: String = "/presences/{presenceId}" .replacingOccurrences(of: "{presenceId}", with: presenceId) @@ -93,7 +67,7 @@ open class Presences: Service { let apiHeaders: [String: String] = [:] - let converter: (Any) throws -> AppwriteModels.Presence = { response in + let converter: (Any) throws -> AppwriteModels.Presence = { response in return AppwriteModels.Presence.from(map: response as! [String: Any]) } @@ -106,25 +80,6 @@ open class Presences: Service { ) } - /// - /// Get a presence log by its unique ID. Entries whose `expiresAt` is in the - /// past are treated as not found. - /// - /// - /// - Parameters: - /// - presenceId: String - /// - Throws: Exception if the request fails - /// - Returns: AppwriteModels.Presence - /// - open func get( - presenceId: String - ) async throws -> AppwriteModels.Presence<[String: AnyCodable]> { - return try await get( - presenceId: presenceId, - nestedType: [String: AnyCodable].self - ) - } - /// /// Create or update a presence log by its user ID. /// @@ -136,16 +91,15 @@ open class Presences: Service { /// - expiresAt: String (optional) /// - metadata: Any (optional) /// - Throws: Exception if the request fails - /// - Returns: AppwriteModels.Presence + /// - Returns: AppwriteModels.Presence /// - open func upsert( + open func upsert( presenceId: String, status: String, permissions: [String]? = nil, expiresAt: String? = nil, - metadata: Any? = nil, - nestedType: T.Type - ) async throws -> AppwriteModels.Presence { + metadata: Any? = nil + ) async throws -> AppwriteModels.Presence { let apiPath: String = "/presences/{presenceId}" .replacingOccurrences(of: "{presenceId}", with: presenceId) @@ -160,7 +114,7 @@ open class Presences: Service { "content-type": "application/json" ] - let converter: (Any) throws -> AppwriteModels.Presence = { response in + let converter: (Any) throws -> AppwriteModels.Presence = { response in return AppwriteModels.Presence.from(map: response as! [String: Any]) } @@ -173,36 +127,6 @@ open class Presences: Service { ) } - /// - /// Create or update a presence log by its user ID. - /// - /// - /// - Parameters: - /// - presenceId: String - /// - status: String - /// - permissions: [String] (optional) - /// - expiresAt: String (optional) - /// - metadata: Any (optional) - /// - Throws: Exception if the request fails - /// - Returns: AppwriteModels.Presence - /// - open func upsert( - presenceId: String, - status: String, - permissions: [String]? = nil, - expiresAt: String? = nil, - metadata: Any? = nil - ) async throws -> AppwriteModels.Presence<[String: AnyCodable]> { - return try await upsert( - presenceId: presenceId, - status: status, - permissions: permissions, - expiresAt: expiresAt, - metadata: metadata, - nestedType: [String: AnyCodable].self - ) - } - /// /// Update a presence log by its unique ID. Using the patch method you can pass /// only specific fields that will get updated. @@ -216,17 +140,16 @@ open class Presences: Service { /// - permissions: [String] (optional) /// - purge: Bool (optional) /// - Throws: Exception if the request fails - /// - Returns: AppwriteModels.Presence + /// - Returns: AppwriteModels.Presence /// - open func update( + open func update( presenceId: String, status: String? = nil, expiresAt: String? = nil, metadata: Any? = nil, permissions: [String]? = nil, - purge: Bool? = nil, - nestedType: T.Type - ) async throws -> AppwriteModels.Presence { + purge: Bool? = nil + ) async throws -> AppwriteModels.Presence { let apiPath: String = "/presences/{presenceId}" .replacingOccurrences(of: "{presenceId}", with: presenceId) @@ -242,7 +165,7 @@ open class Presences: Service { "content-type": "application/json" ] - let converter: (Any) throws -> AppwriteModels.Presence = { response in + let converter: (Any) throws -> AppwriteModels.Presence = { response in return AppwriteModels.Presence.from(map: response as! [String: Any]) } @@ -255,40 +178,6 @@ open class Presences: Service { ) } - /// - /// Update a presence log by its unique ID. Using the patch method you can pass - /// only specific fields that will get updated. - /// - /// - /// - Parameters: - /// - presenceId: String - /// - status: String (optional) - /// - expiresAt: String (optional) - /// - metadata: Any (optional) - /// - permissions: [String] (optional) - /// - purge: Bool (optional) - /// - Throws: Exception if the request fails - /// - Returns: AppwriteModels.Presence - /// - open func update( - presenceId: String, - status: String? = nil, - expiresAt: String? = nil, - metadata: Any? = nil, - permissions: [String]? = nil, - purge: Bool? = nil - ) async throws -> AppwriteModels.Presence<[String: AnyCodable]> { - return try await update( - presenceId: presenceId, - status: status, - expiresAt: expiresAt, - metadata: metadata, - permissions: permissions, - purge: purge, - nestedType: [String: AnyCodable].self - ) - } - /// /// Delete a presence log by its unique ID. /// diff --git a/Sources/AppwriteEnums/Theme.swift b/Sources/AppwriteEnums/BrowserTheme.swift similarity index 66% rename from Sources/AppwriteEnums/Theme.swift rename to Sources/AppwriteEnums/BrowserTheme.swift index f81a4b2..ac273b1 100644 --- a/Sources/AppwriteEnums/Theme.swift +++ b/Sources/AppwriteEnums/BrowserTheme.swift @@ -1,6 +1,6 @@ import Foundation -public enum Theme: String, Codable, CustomStringConvertible { +public enum BrowserTheme: String, Codable, CustomStringConvertible { case light = "light" case dark = "dark" diff --git a/Sources/AppwriteModels/Presence.swift b/Sources/AppwriteModels/Presence.swift index d60c69b..da34ff0 100644 --- a/Sources/AppwriteModels/Presence.swift +++ b/Sources/AppwriteModels/Presence.swift @@ -2,7 +2,7 @@ import Foundation import JSONCodable /// Presence -open class Presence: Codable { +open class Presence: Codable { enum CodingKeys: String, CodingKey { case id = "$id" @@ -32,8 +32,8 @@ open class Presence: Codable { public let source: String /// Presence expiry date in ISO 8601 format. public let expiresAt: String? - /// Additional properties - public let metadata: T + /// Presence metadata. + public let metadata: [String: AnyCodable]? init( id: String, @@ -44,7 +44,7 @@ open class Presence: Codable { status: String?, source: String, expiresAt: String?, - metadata: T + metadata: [String: AnyCodable]? ) { self.id = id self.createdAt = createdAt @@ -68,7 +68,7 @@ open class Presence: Codable { self.status = try container.decodeIfPresent(String.self, forKey: .status) self.source = try container.decode(String.self, forKey: .source) self.expiresAt = try container.decodeIfPresent(String.self, forKey: .expiresAt) - self.metadata = try container.decode(T.self, forKey: .metadata) + self.metadata = try container.decodeIfPresent([String: AnyCodable].self, forKey: .metadata) } public func encode(to encoder: Encoder) throws { @@ -82,7 +82,7 @@ open class Presence: Codable { try container.encodeIfPresent(status, forKey: .status) try container.encode(source, forKey: .source) try container.encodeIfPresent(expiresAt, forKey: .expiresAt) - try container.encode(metadata, forKey: .metadata) + try container.encodeIfPresent(metadata, forKey: .metadata) } public func toMap() -> [String: Any] { @@ -95,7 +95,7 @@ open class Presence: Codable { "status": status as Any, "source": source as Any, "expiresAt": expiresAt as Any, - "metadata": (try! JSONSerialization.jsonObject(with: JSONEncoder().encode(metadata))) as? [String: Any] ?? [:] + "metadata": metadata as Any ] } @@ -109,16 +109,7 @@ open class Presence: Codable { status: map["status"] as? String, source: map["source"] as! String, expiresAt: map["expiresAt"] as? String, - metadata: try! JSONDecoder().decode(T.self, from: { - let raw = map["metadata"] - if let dict = raw as? [String: Any] { - return try! JSONSerialization.data(withJSONObject: dict, options: []) - } else if let raw = raw, JSONSerialization.isValidJSONObject(raw) { - return try! JSONSerialization.data(withJSONObject: raw, options: []) - } else { - return try! JSONSerialization.data(withJSONObject: [:] as [String: Any], options: []) - } - }()) + metadata: (map["metadata"] as? [String: Any] ?? [:]).mapValues { AnyCodable($0) } ) } } diff --git a/Sources/AppwriteModels/PresenceList.swift b/Sources/AppwriteModels/PresenceList.swift index 97548f6..6d149fb 100644 --- a/Sources/AppwriteModels/PresenceList.swift +++ b/Sources/AppwriteModels/PresenceList.swift @@ -2,7 +2,7 @@ import Foundation import JSONCodable /// Presences List -open class PresenceList: Codable { +open class PresenceList: Codable { enum CodingKeys: String, CodingKey { case total = "total" @@ -12,11 +12,11 @@ open class PresenceList: Codable { /// Total number of presences that matched your query. public let total: Int /// List of presences. - public let presences: [Presence] + public let presences: [Presence] init( total: Int, - presences: [Presence] + presences: [Presence] ) { self.total = total self.presences = presences @@ -26,7 +26,7 @@ open class PresenceList: Codable { let container = try decoder.container(keyedBy: CodingKeys.self) self.total = try container.decode(Int.self, forKey: .total) - self.presences = try container.decode([Presence].self, forKey: .presences) + self.presences = try container.decode([Presence].self, forKey: .presences) } public func encode(to encoder: Encoder) throws { diff --git a/docs/examples/avatars/get-screenshot.md b/docs/examples/avatars/get-screenshot.md index 11dd7a0..7711677 100644 --- a/docs/examples/avatars/get-screenshot.md +++ b/docs/examples/avatars/get-screenshot.md @@ -21,7 +21,7 @@ let bytes = try await avatars.getScreenshot( userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15", // optional fullpage: true, // optional locale: "en-US", // optional - timezone: .americaNewYork, // optional + timezone: .africaAbidjan, // optional latitude: 37.7749, // optional longitude: -122.4194, // optional accuracy: 100, // optional From f1840fcbe8006a149a73b6b890b23efe7ab80ad1 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 1 Jun 2026 10:23:16 +0000 Subject: [PATCH 2/2] chore: update Apple SDK to 18.0.0 --- Sources/AppwriteModels/Document.swift | 11 +---------- Sources/AppwriteModels/Row.swift | 11 +---------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/Sources/AppwriteModels/Document.swift b/Sources/AppwriteModels/Document.swift index bd83adb..a3cba78 100644 --- a/Sources/AppwriteModels/Document.swift +++ b/Sources/AppwriteModels/Document.swift @@ -100,16 +100,7 @@ open class Document: Codable { createdAt: map["$createdAt"] as? String ?? "", updatedAt: map["$updatedAt"] as? String ?? "", permissions: map["$permissions"] as? [String] ?? [], - data: try! JSONDecoder().decode(T.self, from: { - let raw = map["data"] - if let dict = raw as? [String: Any] { - return try! JSONSerialization.data(withJSONObject: dict, options: []) - } else if let raw = raw, JSONSerialization.isValidJSONObject(raw) { - return try! JSONSerialization.data(withJSONObject: raw, options: []) - } else { - return try! JSONSerialization.data(withJSONObject: [:] as [String: Any], options: []) - } - }()) + data: try! JSONDecoder().decode(T.self, from: JSONSerialization.data(withJSONObject: map["data"] as? [String: Any] ?? map, options: [])) ) } } diff --git a/Sources/AppwriteModels/Row.swift b/Sources/AppwriteModels/Row.swift index 331af18..2ee0565 100644 --- a/Sources/AppwriteModels/Row.swift +++ b/Sources/AppwriteModels/Row.swift @@ -100,16 +100,7 @@ open class Row: Codable { createdAt: map["$createdAt"] as! String, updatedAt: map["$updatedAt"] as! String, permissions: map["$permissions"] as! [String], - data: try! JSONDecoder().decode(T.self, from: { - let raw = map["data"] - if let dict = raw as? [String: Any] { - return try! JSONSerialization.data(withJSONObject: dict, options: []) - } else if let raw = raw, JSONSerialization.isValidJSONObject(raw) { - return try! JSONSerialization.data(withJSONObject: raw, options: []) - } else { - return try! JSONSerialization.data(withJSONObject: [:] as [String: Any], options: []) - } - }()) + data: try! JSONDecoder().decode(T.self, from: JSONSerialization.data(withJSONObject: map["data"] as? [String: Any] ?? map, options: [])) ) } }