Improvements

sync2
Laurent 12 months ago
parent 2cf2bf9c61
commit c3c9718cb2
  1. 12
      LeStorage.xcodeproj/project.pbxproj
  2. 36
      LeStorage/Codables/DataAccess.swift
  3. 2
      LeStorage/Codables/GetSyncData.swift
  4. 126
      LeStorage/Codables/SyncResponse.swift
  5. 13
      LeStorage/NetworkMonitor.swift
  6. 9
      LeStorage/Services.swift
  7. 6
      LeStorage/Store.swift
  8. 98
      LeStorage/StoreCenter.swift
  9. 33
      LeStorage/StoredCollection+Sync.swift
  10. 10
      LeStorage/StoredCollection.swift
  11. 41
      LeStorage/Utils/ClassLoader.swift
  12. 21
      LeStorage/Utils/Codable+Extensions.swift
  13. 7
      LeStorage/Utils/Date+Extensions.swift
  14. 62
      LeStorage/WebSocketManager.swift

@ -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 = "<group>"; };
C400D7242CC2B5CF0092237C /* SyncResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncResponse.swift; sourceTree = "<group>"; };
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 = "<group>"; };
C425D4382B6D24E1002A7B48 /* LeStorage.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = LeStorage.docc; sourceTree = "<group>"; };
@ -82,6 +82,8 @@
C4A47D992B7CFFC500ADC637 /* ApiCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiCall.swift; sourceTree = "<group>"; };
C4A47D9A2B7CFFC500ADC637 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; };
C4A47DAE2B85FD3800ADC637 /* Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Errors.swift; sourceTree = "<group>"; };
C4AC9CE42CEFB12100CC13DF /* DataAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataAccess.swift; sourceTree = "<group>"; };
C4AC9CE72CF0A13B00CC13DF /* ClassLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassLoader.swift; sourceTree = "<group>"; };
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 = "<group>"; };
C4D4779C2CB923720077713D /* DataLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLog.swift; sourceTree = "<group>"; };
@ -173,6 +175,7 @@
C4A47D832B7B97F000ADC637 /* KeychainStore.swift */,
C4A47D522B6D2C5F00ADC637 /* Logger.swift */,
C4FAE69B2CEB8E9500790446 /* URLManager.swift */,
C4AC9CE72CF0A13B00CC13DF /* ClassLoader.swift */,
);
path = Utils;
sourceTree = "<group>";
@ -194,7 +197,7 @@
C4FC2E302C353E7B0021F3BF /* Log.swift */,
C4A47D9A2B7CFFC500ADC637 /* Settings.swift */,
C400D7222CC2AF560092237C /* GetSyncData.swift */,
C400D7242CC2B5CF0092237C /* SyncResponse.swift */,
C4AC9CE42CEFB12100CC13DF /* DataAccess.swift */,
);
path = Codables;
sourceTree = "<group>";
@ -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 */,

@ -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
}

@ -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")

@ -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"))
}
}
}

@ -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()
}
}

@ -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 ?? "")")

@ -236,6 +236,12 @@ final public class Store {
collection.deleteByStringIdNoSync(id)
}
/// Calls deleteById from the collection corresponding to the instance
func revokeNoSync<T: SyncedStorable>(type: T.Type, id: String) throws {
let collection: StoredCollection<T> = try self.collection()
collection.revokeByStringIdNoSync(id)
}
// MARK: - Write
/// Returns the directory URL of the store

@ -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<GetSyncData>
/// A collection of DataLog objects, used for the synchronization
fileprivate var _dataLogs: StoredCollection<DataLog>
/// A synchronized collection of DataAccess
fileprivate var _dataAccess: StoredCollection<DataAccess>? = nil
/// A collection storing FailedAPICall objects
fileprivate var _failedAPICallsCollection: StoredCollection<FailedAPICall>? = 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<T: Storable>(instance: T, storeId: String?) {
// DispatchQueue.main.async {
// self._store(id: storeId)?.deleteNoSync(instance: instance)

@ -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<T>) {
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)

@ -263,6 +263,16 @@ public class StoredCollection<T: Storable>: 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] {

@ -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
}
}

@ -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
}()

@ -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
}
}

@ -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()
}
}

Loading…
Cancel
Save