diff --git a/LeStorage.xcodeproj/project.pbxproj b/LeStorage.xcodeproj/project.pbxproj index 7646a7c..39bbc24 100644 --- a/LeStorage.xcodeproj/project.pbxproj +++ b/LeStorage.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ C400D7232CC2AF560092237C /* GetSyncData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400D7222CC2AF560092237C /* GetSyncData.swift */; }; - C400D7252CC2B5CF0092237C /* SyncResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400D7242CC2B5CF0092237C /* SyncResponse.swift */; }; C425D4392B6D24E1002A7B48 /* LeStorage.docc in Sources */ = {isa = PBXBuildFile; fileRef = C425D4382B6D24E1002A7B48 /* LeStorage.docc */; }; C425D4452B6D24E1002A7B48 /* LeStorage.h in Headers */ = {isa = PBXBuildFile; fileRef = C425D4372B6D24E1002A7B48 /* LeStorage.h */; settings = {ATTRIBUTES = (Public, ); }; }; C425D4582B6D2519002A7B48 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D4572B6D2519002A7B48 /* Store.swift */; }; @@ -33,6 +32,8 @@ C4A47D9B2B7CFFDA00ADC637 /* ApiCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D992B7CFFC500ADC637 /* ApiCall.swift */; }; C4A47D9C2B7CFFE000ADC637 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47D9A2B7CFFC500ADC637 /* Settings.swift */; }; C4A47DAF2B85FD3800ADC637 /* Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A47DAE2B85FD3800ADC637 /* Errors.swift */; }; + C4AC9CE52CEFB12100CC13DF /* DataAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AC9CE42CEFB12100CC13DF /* DataAccess.swift */; }; + C4AC9CE82CF0A13B00CC13DF /* ClassLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AC9CE72CF0A13B00CC13DF /* ClassLoader.swift */; }; C4C33F6F2C9B06B7006316DE /* LeStorage.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C425D4342B6D24E1002A7B48 /* LeStorage.framework */; }; C4D477972CB66EEA0077713D /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D477962CB66EEA0077713D /* Date+Extensions.swift */; }; C4D4779D2CB923720077713D /* DataLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D4779C2CB923720077713D /* DataLog.swift */; }; @@ -56,7 +57,6 @@ /* Begin PBXFileReference section */ C400D7222CC2AF560092237C /* GetSyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSyncData.swift; sourceTree = ""; }; - C400D7242CC2B5CF0092237C /* SyncResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncResponse.swift; sourceTree = ""; }; C425D4342B6D24E1002A7B48 /* LeStorage.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LeStorage.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C425D4372B6D24E1002A7B48 /* LeStorage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LeStorage.h; sourceTree = ""; }; C425D4382B6D24E1002A7B48 /* LeStorage.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = LeStorage.docc; sourceTree = ""; }; @@ -82,6 +82,8 @@ C4A47D992B7CFFC500ADC637 /* ApiCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCall.swift; sourceTree = ""; }; C4A47D9A2B7CFFC500ADC637 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; C4A47DAE2B85FD3800ADC637 /* Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Errors.swift; sourceTree = ""; }; + C4AC9CE42CEFB12100CC13DF /* DataAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataAccess.swift; sourceTree = ""; }; + C4AC9CE72CF0A13B00CC13DF /* ClassLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassLoader.swift; sourceTree = ""; }; C4C33F6B2C9B06B7006316DE /* LeStorageTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LeStorageTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C4D477962CB66EEA0077713D /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; C4D4779C2CB923720077713D /* DataLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLog.swift; sourceTree = ""; }; @@ -173,6 +175,7 @@ C4A47D832B7B97F000ADC637 /* KeychainStore.swift */, C4A47D522B6D2C5F00ADC637 /* Logger.swift */, C4FAE69B2CEB8E9500790446 /* URLManager.swift */, + C4AC9CE72CF0A13B00CC13DF /* ClassLoader.swift */, ); path = Utils; sourceTree = ""; @@ -194,7 +197,7 @@ C4FC2E302C353E7B0021F3BF /* Log.swift */, C4A47D9A2B7CFFC500ADC637 /* Settings.swift */, C400D7222CC2AF560092237C /* GetSyncData.swift */, - C400D7242CC2B5CF0092237C /* SyncResponse.swift */, + C4AC9CE42CEFB12100CC13DF /* DataAccess.swift */, ); path = Codables; sourceTree = ""; @@ -319,12 +322,13 @@ C4FC2E312C353E7B0021F3BF /* Log.swift in Sources */, C4D477A12CB9586A0077713D /* StoredCollection+Sync.swift in Sources */, C4A47D512B6D2C4E00ADC637 /* Codable+Extensions.swift in Sources */, + C4AC9CE52CEFB12100CC13DF /* DataAccess.swift in Sources */, C4FAE69A2CEB84B300790446 /* WebSocketManager.swift in Sources */, C4D4779F2CB92FD80077713D /* SyncedStorable.swift in Sources */, C425D4392B6D24E1002A7B48 /* LeStorage.docc in Sources */, + C4AC9CE82CF0A13B00CC13DF /* ClassLoader.swift in Sources */, C4A47DAF2B85FD3800ADC637 /* Errors.swift in Sources */, C4A47D612B6D3C1300ADC637 /* Services.swift in Sources */, - C400D7252CC2B5CF0092237C /* SyncResponse.swift in Sources */, C4A47D552B6D2DBF00ADC637 /* FileUtils.swift in Sources */, C456EFE22BE52379007388E2 /* StoredSingleton.swift in Sources */, C467AAE32CD2467500D76CD2 /* Formatter.swift in Sources */, diff --git a/LeStorage/Codables/DataAccess.swift b/LeStorage/Codables/DataAccess.swift new file mode 100644 index 0000000..8808b4a --- /dev/null +++ b/LeStorage/Codables/DataAccess.swift @@ -0,0 +1,36 @@ +// +// DataAcces.swift +// LeStorage +// +// Created by Laurent Morvillier on 21/11/2024. +// + +import Foundation + +class DataAccess: ModelObject, SyncedStorable { + var lastUpdate: Date + + static func tokenExemptedMethods() -> [HTTPMethod] { return [] } + static func resourceName() -> String { return "data-access" } + static func filterByStoreIdentifier() -> Bool { return false } + + func copy(from other: any Storable) { + guard let dataAccess = other as? DataAccess else { return } + self.id = dataAccess.id + self.owner = dataAccess.owner + self.sharedWith = dataAccess.sharedWith + self.modelName = dataAccess.modelName + self.modelId = dataAccess.modelId + self.grantedAt = dataAccess.grantedAt +// self.lastHierarchyUpdate = dataAccess.lastHierarchyUpdate + } + + var id: String + var owner: String + var sharedWith: [String] + var modelName: String + var modelId: String + var grantedAt: Date +// var lastHierarchyUpdate: Date + +} diff --git a/LeStorage/Codables/GetSyncData.swift b/LeStorage/Codables/GetSyncData.swift index 22c3c76..9e9300f 100644 --- a/LeStorage/Codables/GetSyncData.swift +++ b/LeStorage/Codables/GetSyncData.swift @@ -28,7 +28,7 @@ class GetSyncData: ModelObject, SyncedStorable, URLParameterConvertible { } fileprivate var _formattedLastUpdate: String { - let formattedDate = ISO8601DateFormatter().string(from: self.lastUpdate) + let formattedDate = Date.iso8601FractionalFormatter.string(from: self.lastUpdate) let encodedDate = formattedDate.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" return encodedDate.replacingOccurrences(of: "+", with: "%2B") diff --git a/LeStorage/Codables/SyncResponse.swift b/LeStorage/Codables/SyncResponse.swift deleted file mode 100644 index 1403130..0000000 --- a/LeStorage/Codables/SyncResponse.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// SyncResponse.swift -// LeStorage -// -// Created by Laurent Morvillier on 18/10/2024. -// - -import Foundation - -struct SyncResponse: Codable { - let updates: [String: [Codable]] - let deletions: [String: [Int]] - let date: String? - - enum CodingKeys: String, CodingKey { - case updates, deletions, date - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - deletions = try container.decode([String: [Int]].self, forKey: .deletions) - date = try container.decodeIfPresent(String.self, forKey: .date) - - let updatesContainer = try container.nestedContainer( - keyedBy: DynamicCodingKeys.self, forKey: .updates) - var updatesDict = [String: [AnyCodable]]() - - for key in updatesContainer.allKeys { -// let swiftClass = try SyncResponse._classFromClassName(key.stringValue) - let decodedArray = try updatesContainer.decode([AnyCodable].self, forKey: key) - let typedArray = decodedArray.compactMap { $0.value as? AnyCodable } - updatesDict[key.stringValue] = typedArray - } - updates = updatesDict - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(deletions, forKey: .deletions) - try container.encodeIfPresent(date, forKey: .date) - - var updatesContainer = container.nestedContainer( - keyedBy: DynamicCodingKeys.self, forKey: .updates) - for (key, value) in updates { - let encodableArray = value.map { AnyCodable($0) } - try updatesContainer.encode( - encodableArray, forKey: DynamicCodingKeys(stringValue: key)!) - } - } - - struct DynamicCodingKeys: CodingKey { - var stringValue: String - init?(stringValue: String) { - self.stringValue = stringValue - } - var intValue: Int? - init?(intValue: Int) { - return nil - } - } - -// fileprivate static func _classFromClassName(_ className: String) throws -> Codable.Type { -// -// let fullClassName = "PadelClub.\(className)" -// let modelClass: AnyClass? = NSClassFromString(fullClassName) -// if let type = modelClass as? Codable.Type { -// return type -// } else { -// throw LeStorageError.cantFindClassFromName(name: className) -// } -// -// } - -} - -struct AnyCodable: Codable { - let value: Any - - init(_ value: Any) { - self.value = value - } - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let intValue = try? container.decode(Int.self) { - value = intValue - } else if let doubleValue = try? container.decode(Double.self) { - value = doubleValue - } else if let boolValue = try? container.decode(Bool.self) { - value = boolValue - } else if let stringValue = try? container.decode(String.self) { - value = stringValue - } else if let arrayValue = try? container.decode([AnyCodable].self) { - value = arrayValue.map { $0.value } - } else if let dictionaryValue = try? container.decode([String: AnyCodable].self) { - value = dictionaryValue.mapValues { $0.value } - } else { - throw DecodingError.dataCorruptedError( - in: container, debugDescription: "AnyCodable value cannot be decoded") - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch value { - case let intValue as Int: - try container.encode(intValue) - case let doubleValue as Double: - try container.encode(doubleValue) - case let boolValue as Bool: - try container.encode(boolValue) - case let stringValue as String: - try container.encode(stringValue) - case let arrayValue as [Any]: - try container.encode(arrayValue.map { AnyCodable($0) }) - case let dictionaryValue as [String: Any]: - try container.encode(dictionaryValue.mapValues { AnyCodable($0) }) - default: - throw EncodingError.invalidValue( - value, - EncodingError.Context( - codingPath: container.codingPath, - debugDescription: "AnyCodable value cannot be encoded")) - } - } -} diff --git a/LeStorage/NetworkMonitor.swift b/LeStorage/NetworkMonitor.swift index aada980..e4304b6 100644 --- a/LeStorage/NetworkMonitor.swift +++ b/LeStorage/NetworkMonitor.swift @@ -11,8 +11,9 @@ import Foundation public class NetworkMonitor { public static let shared = NetworkMonitor() - private var monitor: NWPathMonitor - private var queue = DispatchQueue(label: "NetworkMonitor") + + private var _monitor: NWPathMonitor + private var _queue = DispatchQueue(label: "lestorage.queue.network_monitor") public var isConnected: Bool { get { @@ -26,12 +27,12 @@ public class NetworkMonitor { var onConnectionEstablished: (() -> Void)? private init() { - monitor = NWPathMonitor() + _monitor = NWPathMonitor() self._startMonitoring() } private func _startMonitoring() { - monitor.pathUpdateHandler = { [weak self] path in + _monitor.pathUpdateHandler = { [weak self] path in guard let self = self else { return } // Update status @@ -49,11 +50,11 @@ public class NetworkMonitor { } - monitor.start(queue: queue) + self._monitor.start(queue: self._queue) } func stopMonitoring() { - monitor.cancel() + self._monitor.cancel() } } diff --git a/LeStorage/Services.swift b/LeStorage/Services.swift index 792bfa9..55df8fb 100644 --- a/LeStorage/Services.swift +++ b/LeStorage/Services.swift @@ -88,7 +88,6 @@ public class Services { ) async throws -> V { let debugURL = request.url?.absoluteString ?? "" // print("Run \(request.httpMethod ?? "") \(debugURL)") - let date = Date() let task: (Data, URLResponse) = try await URLSession.shared.data(for: request) print("\(apiCall.method.rawValue) \(String(describing: T.self)) => \(String(data: task.0, encoding: .utf8) ?? "")") @@ -100,7 +99,7 @@ public class Services { try await StoreCenter.main.deleteApiCallById(type: T.self, id: apiCall.id) if T.self == GetSyncData.self { - StoreCenter.main.synchronizeContent(task.0, date: date) + StoreCenter.main.synchronizeContent(task.0) } default: // error @@ -352,7 +351,8 @@ public class Services { formattedDate.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" let encodedDateWithPlus = encodedDate.replacingOccurrences(of: "+", with: "%2B") let urlString = baseURL + "data/?last_update=\(encodedDateWithPlus)" - + Logger.log("urlString = \(urlString)") + guard let url = URL(string: urlString) else { throw ServiceError.urlCreationError(url: urlString) } @@ -372,7 +372,6 @@ public class Services { /// - request: The synchronization request fileprivate func _runGetSyncLogRequest(_ request: URLRequest) async throws { let debugURL = request.url?.absoluteString ?? "" - let date = Date() // print("Run \(request.httpMethod ?? "") \(debugURL)") let task: (Data, URLResponse) = try await URLSession.shared.data(for: request) @@ -383,7 +382,7 @@ public class Services { print("\(debugURL) ended, status code = \(statusCode)") switch statusCode { case 200..<300: // success - StoreCenter.main.synchronizeContent(task.0, date: date) + StoreCenter.main.synchronizeContent(task.0) default: // error Logger.log( "Failed Run \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")") diff --git a/LeStorage/Store.swift b/LeStorage/Store.swift index 26843d4..b3adc65 100644 --- a/LeStorage/Store.swift +++ b/LeStorage/Store.swift @@ -236,6 +236,12 @@ final public class Store { collection.deleteByStringIdNoSync(id) } + /// Calls deleteById from the collection corresponding to the instance + func revokeNoSync(type: T.Type, id: String) throws { + let collection: StoredCollection = try self.collection() + collection.revokeByStringIdNoSync(id) + } + // MARK: - Write /// Returns the directory URL of the store diff --git a/LeStorage/StoreCenter.swift b/LeStorage/StoreCenter.swift index f7ed6e6..4095559 100644 --- a/LeStorage/StoreCenter.swift +++ b/LeStorage/StoreCenter.swift @@ -16,15 +16,6 @@ public class StoreCenter { /// A dictionary of Stores associated to their id fileprivate var _stores: [String: Store] = [:] - /// The URL of the django API -// public var synchronizationApiURL: String? { -// didSet { -// if let url = synchronizationApiURL { -// self._services = Services(url: url) -// } -// } -// } - /// Indicates to Stored Collection if they can synchronize public var collectionsCanSynchronize: Bool = true @@ -44,12 +35,12 @@ public class StoreCenter { /// The dictionary of registered StoredCollections fileprivate var _apiCallCollections: [String: any SomeCallCollection] = [:] - /// A collection of DataLog objects, used for the synchronization -// fileprivate var _syncGetRequests: ApiCallCollection - /// A collection of DataLog objects, used for the synchronization fileprivate var _dataLogs: StoredCollection + /// A synchronized collection of DataAccess + fileprivate var _dataAccess: StoredCollection? = nil + /// A collection storing FailedAPICall objects fileprivate var _failedAPICallsCollection: StoredCollection? = nil @@ -66,6 +57,7 @@ public class StoreCenter { // self._syncGetRequests = ApiCallCollection() self._dataLogs = Store.main.registerCollection() + self._setupNotifications() self.loadApiCallCollection(type: GetSyncData.self) @@ -79,6 +71,8 @@ public class StoreCenter { let urlManager: URLManager = URLManager(httpScheme: httpScheme, domain: domain) self._urlManager = urlManager self._services = Services(url: urlManager.api) + self._dataAccess = Store.main.registerSynchronizedCollection() + Logger.log("Sync URL: \(urlManager.api)") if self.userId != nil { @@ -421,7 +415,7 @@ public class StoreCenter { // try await self._services?.synchronizeLastUpdates(since: lastSync) } - func synchronizeContent(_ data: Data, date: Date) { + func synchronizeContent(_ data: Data) { do { guard @@ -449,9 +443,33 @@ public class StoreCenter { Logger.error(error) } } + + if let updates = json["grants"] as? [String: Any] { + do { + try self._parseSyncUpdates(updates) + } catch { + StoreCenter.main.log(message: error.localizedDescription) + Logger.error(error) + } + } + + if let deletions = json["revocations"] as? [String: Any] { + do { + try self._parseSyncRevocations(deletions) + } catch { + StoreCenter.main.log(message: error.localizedDescription) + Logger.error(error) + } + } - self._settingsStorage.update { settings in - settings.lastSynchronization = date + if let dateString = json["date"] as? String, + let date = Date.iso8601FractionalFormatter.date(from: dateString) { + Logger.log("date = \(date)") + self._settingsStorage.update { settings in + settings.lastSynchronization = date + } + } else { + Logger.w("no date set for the last sync!!!") } } catch { @@ -478,6 +496,7 @@ public class StoreCenter { let storeId: String? = decodedObject.getStoreId() StoreCenter.main.synchronizationAddOrUpdate(decodedObject, storeId: storeId) } catch { + Logger.w("Issue with json decoding: \(updateItem)") Logger.error(error) } } @@ -485,13 +504,13 @@ public class StoreCenter { } fileprivate func _parseSyncDeletions(_ deletions: [String: Any]) throws { - for (className, updateDeletions) in deletions { - guard let deletedItem = updateDeletions as? [Any] else { + for (className, deleteData) in deletions { + guard let deletedItems = deleteData as? [Any] else { Logger.w("Invalid update data for \(className)") continue } - for deleted in deletedItem { + for deleted in deletedItems { do { let data = try JSONSerialization.data(withJSONObject: deleted, options: []) @@ -506,19 +525,34 @@ public class StoreCenter { } } - static func classFromName(_ className: String) throws -> any SyncedStorable.Type { + fileprivate func _parseSyncRevocations(_ deletions: [String: Any]) throws { + for (className, revocationData) in deletions { + guard let rovokedItems = revocationData as? [Any] else { + Logger.w("Invalid update data for \(className)") + continue + } + + for revoked in rovokedItems { + + do { + let data = try JSONSerialization.data(withJSONObject: revoked, options: []) + let deletedObject = try JSON.decoder.decode(DeletedObject.self, from: data) + + StoreCenter.main.synchronizationDelete(id: deletedObject.modelId, model: className, storeId: deletedObject.storeId) + } catch { + Logger.error(error) + } - guard let projectName = Bundle.main.infoDictionary?["CFBundleName"] as? String else { - throw LeStorageError.cantAccessCFBundleName + } } - - let modelClass: AnyClass? = NSClassFromString("\(projectName).\(className)") - if let type = modelClass as? any SyncedStorable.Type { + } + + static func classFromName(_ className: String) throws -> any SyncedStorable.Type { + if let type = ClassLoader.getClass(className) as? any SyncedStorable.Type { return type } else { throw LeStorageError.cantFindClassFromName(name: className) } - } fileprivate func _store(id: String?) -> Store { @@ -563,6 +597,20 @@ public class StoreCenter { } } + func synchronizationRevoke(id: String, model: String, storeId: String?) { + + DispatchQueue.main.async { + do { + let type = try StoreCenter.classFromName(model) + try self._store(id: storeId).revokeNoSync(type: type, id: id) + } catch { + Logger.error(error) + } + self._cleanupDataLog(dataId: id) + } + } + + // func synchronizationDelete(instance: T, storeId: String?) { // DispatchQueue.main.async { // self._store(id: storeId)?.deleteNoSync(instance: instance) diff --git a/LeStorage/StoredCollection+Sync.swift b/LeStorage/StoredCollection+Sync.swift index c3e85a2..0b3acb8 100644 --- a/LeStorage/StoredCollection+Sync.swift +++ b/LeStorage/StoredCollection+Sync.swift @@ -97,6 +97,21 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable { } } + /// Deletes the instance in the collection without synchronization + func revokeByStringIdNoSync(_ id: String) { + defer { + self.setChanged() + } + if let realId = self._buildRealId(id: id) { + if let instance = self.findById(realId) { + self.deleteItemIfUnused(instance) + } + } else { + Logger.w("CRITICAL: collection \(T.resourceName()) could not build id from \(id)") + StoreCenter.main.log(message: "Could not build an id from \(id)") + } + } + fileprivate func _buildRealId(id: String) -> T.ID? { switch T.ID.self { case is String.Type: @@ -104,7 +119,7 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable { case is Int64.Type: return Formatter.number.number(from: id)?.int64Value as? T.ID default: - fatalError("ID is neither String nor Int") + fatalError("ID \(type(of: T.ID.self)) is neither String nor Int, can't parse \(id)") // return nil } } @@ -142,11 +157,27 @@ extension StoredCollection: SomeSyncedCollection where T : SyncedStorable { } + /// Deletes all items of the sequence by id and sets the collection as changed to trigger a write + public func delete(contentOfs sequence: any Sequence) { + + defer { + self.setChanged() + } + + for instance in sequence { + self._deleteNoWrite(instance: instance) + } + } + public func delete(instance: T) { defer { self.setChanged() } + self._deleteNoWrite(instance: instance) + } + + fileprivate func _deleteNoWrite(instance: T) { self.deleteItem(instance) StoreCenter.main.createDeleteLog(instance) self._sendDeletionIfNecessary(instance) diff --git a/LeStorage/StoredCollection.swift b/LeStorage/StoredCollection.swift index 13a0ef1..21f7908 100644 --- a/LeStorage/StoredCollection.swift +++ b/LeStorage/StoredCollection.swift @@ -263,6 +263,16 @@ public class StoredCollection: RandomAccessCollection, SomeCollecti self._indexes?.removeValue(forKey: instance.id) } + func deleteItemIfUnused(_ instance: T) { + + // find if instance if referenced elsewhere + // if so, delete + + instance.deleteDependencies() + self.items.removeAll { $0.id == instance.id } + self._indexes?.removeValue(forKey: instance.id) + } + /// Returns the instance corresponding to the provided [id] public func findById(_ id: T.ID) -> T? { if let index = self._indexes, let instance = index[id] { diff --git a/LeStorage/Utils/ClassLoader.swift b/LeStorage/Utils/ClassLoader.swift new file mode 100644 index 0000000..2aa2bf3 --- /dev/null +++ b/LeStorage/Utils/ClassLoader.swift @@ -0,0 +1,41 @@ +// +// ClassLoader.swift +// LeStorage +// +// Created by Laurent Morvillier on 22/11/2024. +// + +import Foundation + +class ClassLoader { + static var classCache: [String: AnyClass] = [:] + + static func getClass(_ className: String) -> AnyClass? { + if let cachedClass = classCache[className] { + return cachedClass + } + + if let projectName = Bundle.main.infoDictionary?["CFBundleName"] as? String { + let fullName = "\(projectName).\(className)" + if let projectClass = _getClass(fullName) { + return projectClass + } + } + + let fullName = "LeStorage.\(className)" + if let projectClass = _getClass(fullName) { + return projectClass + } + + return nil + } + + static func _getClass(_ className: String) -> AnyClass? { + if let loadedClass = NSClassFromString(className) { + classCache[className] = loadedClass + return loadedClass + } + return nil + } + +} diff --git a/LeStorage/Utils/Codable+Extensions.swift b/LeStorage/Utils/Codable+Extensions.swift index 2aaa6cc..5fd14e7 100644 --- a/LeStorage/Utils/Codable+Extensions.swift +++ b/LeStorage/Utils/Codable+Extensions.swift @@ -15,14 +15,31 @@ class JSON { #if DEBUG encoder.outputFormatting = .prettyPrinted #endif - encoder.dateEncodingStrategy = .iso8601 + encoder.dateEncodingStrategy = .custom { date, encoder in + let dateString = Date.iso8601FractionalFormatter.string(from: date) + var container = encoder.singleValueContainer() + try container.encode(dateString) + } // need dates with thousandth precision return encoder }() static var decoder: JSONDecoder = { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase - decoder.dateDecodingStrategy = .iso8601 + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + if let date = Date.iso8601FractionalFormatter.date(from: dateString) { + return date + } else if let date = Date.iso8601Formatter.date(from: dateString) { + return date + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid date format: \(dateString)" + ) + } + } // need dates with thousandth precision return decoder }() diff --git a/LeStorage/Utils/Date+Extensions.swift b/LeStorage/Utils/Date+Extensions.swift index 2936908..7c4a10c 100644 --- a/LeStorage/Utils/Date+Extensions.swift +++ b/LeStorage/Utils/Date+Extensions.swift @@ -16,4 +16,11 @@ extension Date { return iso8601Formatter } + public static var iso8601FractionalFormatter: ISO8601DateFormatter { + let iso8601Formatter = ISO8601DateFormatter() + iso8601Formatter.timeZone = TimeZone(abbreviation: "CET") + iso8601Formatter.formatOptions = [.withInternetDateTime, .withTimeZone, .withFractionalSeconds] + return iso8601Formatter + } + } diff --git a/LeStorage/WebSocketManager.swift b/LeStorage/WebSocketManager.swift index bf83026..1bd39ab 100644 --- a/LeStorage/WebSocketManager.swift +++ b/LeStorage/WebSocketManager.swift @@ -11,12 +11,11 @@ import Combine class WebSocketManager: ObservableObject { - private var webSocketTask: URLSessionWebSocketTask? -// @Published var messages: [String] = [] - private var timer: Timer? + fileprivate var _webSocketTask: URLSessionWebSocketTask? + fileprivate var _timer: Timer? fileprivate var _url: String - - @Published var status: String = "status" + + fileprivate var _reconnectAttempts = 0 init(urlString: String) { self._url = urlString @@ -35,27 +34,26 @@ class WebSocketManager: ObservableObject { } let session = URLSession(configuration: .default) - webSocketTask = session.webSocketTask(with: url) - webSocketTask?.resume() + _webSocketTask = session.webSocketTask(with: url) + _webSocketTask?.resume() - receiveMessage() + self._receiveMessage() // Setup a ping timer to keep the connection alive - self.timer?.invalidate() - timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { _ in - self.ping() + self._timer?.invalidate() + _timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { _ in + self._ping() } } - private func receiveMessage() { - webSocketTask?.receive { result in + private func _receiveMessage() { + _webSocketTask?.receive { result in switch result { case .failure(let error): - self.changeStatus(error.localizedDescription) print("Error in receiving message: \(error)") + self._handleWebSocketError(error) - - self._setupWebSocket() +// self._setupWebSocket() case .success(let message): switch message { case .string(let text): @@ -76,31 +74,36 @@ class WebSocketManager: ObservableObject { @unknown default: print("received other = \(message)") } - self.changeStatus("success") - self.receiveMessage() + self._receiveMessage() } } } - - func changeStatus(_ status: String) { - DispatchQueue.main.async { - self.status = status + + private func _handleWebSocketError(_ error: Error) { + print("WebSocket error: \(error)") + + // Exponential backoff for reconnection + let delay = min(Double(self._reconnectAttempts), 10.0) + self._reconnectAttempts += 1 + + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self = self else { return } + print("Attempting to reconnect... (Attempt #\(self._reconnectAttempts))") + _setupWebSocket() } } func send(_ message: String) { - webSocketTask?.send(.string(message)) { error in + self._webSocketTask?.send(.string(message)) { error in if let error = error { print("Error in sending message: \(error)") - self.changeStatus("send failed: \(error.localizedDescription)") } } } - private func ping() { - webSocketTask?.sendPing { error in - self.changeStatus("ping failed: \(error?.localizedDescription ?? "")") + private func _ping() { + self._webSocketTask?.sendPing { error in if let error: NSError = error as NSError?, error.domain == NSPOSIXErrorDomain && error.code == 57 { @@ -110,9 +113,8 @@ class WebSocketManager: ObservableObject { } func disconnect() { - self.changeStatus("disconnected") - webSocketTask?.cancel(with: .goingAway, reason: nil) - timer?.invalidate() + self._webSocketTask?.cancel(with: .goingAway, reason: nil) + self._timer?.invalidate() } }