diff --git a/ios/Capacitor/Capacitor.xcodeproj/project.pbxproj b/ios/Capacitor/Capacitor.xcodeproj/project.pbxproj index 334639b38..31e025212 100644 --- a/ios/Capacitor/Capacitor.xcodeproj/project.pbxproj +++ b/ios/Capacitor/Capacitor.xcodeproj/project.pbxproj @@ -88,6 +88,7 @@ A38C3D7B2848BE6F004B3680 /* CapacitorCookieManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A38C3D7A2848BE6F004B3680 /* CapacitorCookieManager.swift */; }; A71289E627F380A500DADDF3 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71289E527F380A500DADDF3 /* Router.swift */; }; A71289EB27F380FD00DADDF3 /* RouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71289EA27F380FD00DADDF3 /* RouterTests.swift */; }; + A7BE62CC2B486A5400165ACB /* KeyValueStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7BE62CB2B486A5400165ACB /* KeyValueStore.swift */; }; A7D8B3522B238A840003FAD6 /* JSValueEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7D8B3512B238A840003FAD6 /* JSValueEncoder.swift */; }; A7D8B3632B263B8D0003FAD6 /* NestedCodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7D8B3622B263B8D0003FAD6 /* NestedCodableTests.swift */; }; A7D8B3642B263B8D0003FAD6 /* Capacitor.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50503EDF1FC08594003606DC /* Capacitor.framework */; }; @@ -239,6 +240,7 @@ A38C3D7A2848BE6F004B3680 /* CapacitorCookieManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapacitorCookieManager.swift; sourceTree = ""; }; A71289E527F380A500DADDF3 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; A71289EA27F380FD00DADDF3 /* RouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterTests.swift; sourceTree = ""; }; + A7BE62CB2B486A5400165ACB /* KeyValueStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyValueStore.swift; sourceTree = ""; }; A7D8B3512B238A840003FAD6 /* JSValueEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSValueEncoder.swift; sourceTree = ""; }; A7D8B3562B23B2110003FAD6 /* CodableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableTests.swift; sourceTree = ""; }; A7D8B3602B263B8D0003FAD6 /* CodableTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CodableTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -397,6 +399,7 @@ 373A69F1255C95D0000A6F44 /* NotificationRouter.swift */, 62E79C562638AF7500414164 /* assets */, A71289E527F380A500DADDF3 /* Router.swift */, + A7BE62CB2B486A5400165ACB /* KeyValueStore.swift */, 0F83E884285A332D006C43CB /* AppUUID.swift */, ); path = Capacitor; @@ -717,6 +720,7 @@ A327E6B628DB8B2900CA8B0A /* HttpRequestHandler.swift in Sources */, 62959B422524DA7800A3D7F1 /* DocLinks.swift in Sources */, 62FABD1A25AE5C01007B3814 /* Array+Capacitor.swift in Sources */, + A7BE62CC2B486A5400165ACB /* KeyValueStore.swift in Sources */, 62959B172524DA7800A3D7F1 /* JSExport.swift in Sources */, 373A69C1255C9360000A6F44 /* NotificationHandlerProtocol.swift in Sources */, 0F83E885285A332E006C43CB /* AppUUID.swift in Sources */, diff --git a/ios/Capacitor/Capacitor/AppUUID.swift b/ios/Capacitor/Capacitor/AppUUID.swift index 42ea92d6d..6c3fb302d 100644 --- a/ios/Capacitor/Capacitor/AppUUID.swift +++ b/ios/Capacitor/Capacitor/AppUUID.swift @@ -41,13 +41,11 @@ public class AppUUID { } private static func readUUID() -> String { - let defaults = UserDefaults.standard - return defaults.string(forKey: key) ?? "" + KeyValueStore.standard[key] ?? "" } private static func writeUUID(_ uuid: String) { - let defaults = UserDefaults.standard - defaults.set(uuid, forKey: key) + KeyValueStore.standard[key] = uuid } } diff --git a/ios/Capacitor/Capacitor/CAPBridgeViewController.swift b/ios/Capacitor/Capacitor/CAPBridgeViewController.swift index 484f98527..45ec89633 100644 --- a/ios/Capacitor/Capacitor/CAPBridgeViewController.swift +++ b/ios/Capacitor/Capacitor/CAPBridgeViewController.swift @@ -18,9 +18,9 @@ import Cordova public lazy final var isNewBinary: Bool = { if let curVersionCode = Bundle.main.infoDictionary?["CFBundleVersion"] as? String, let curVersionName = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { - if let lastVersionCode = UserDefaults.standard.string(forKey: "lastBinaryVersionCode"), - let lastVersionName = UserDefaults.standard.string(forKey: "lastBinaryVersionName") { - return (curVersionCode.isEqual(lastVersionCode) == false || curVersionName.isEqual(lastVersionName) == false) + if let lastVersionCode = KeyValueStore.standard["lastBinaryVersionCode", as: String.self], + let lastVersionName = KeyValueStore.standard["lastBinaryVersionName", as: String.self] { + return curVersionCode != lastVersionCode || curVersionName != lastVersionName } return true } @@ -79,7 +79,7 @@ import Cordova open func instanceDescriptor() -> InstanceDescriptor { let descriptor = InstanceDescriptor.init() if !isNewBinary && !descriptor.cordovaDeployDisabled { - if let persistedPath = UserDefaults.standard.string(forKey: "serverBasePath"), !persistedPath.isEmpty { + if let persistedPath = KeyValueStore.standard["serverBasePath", as: String.self], !persistedPath.isEmpty { if let libPath = NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true).first { descriptor.appLocation = URL(fileURLWithPath: libPath, isDirectory: true) .appendingPathComponent("NoCloud") @@ -319,11 +319,10 @@ extension CAPBridgeViewController { let versionName = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { return } - let prefs = UserDefaults.standard - prefs.set(versionCode, forKey: "lastBinaryVersionCode") - prefs.set(versionName, forKey: "lastBinaryVersionName") - prefs.set("", forKey: "serverBasePath") - prefs.synchronize() + let store = KeyValueStore.standard + store["lastBinaryVersionCode"] = versionCode + store["lastBinaryVersionName"] = versionName + store["serverBasePath"] = nil as String? } private func logWarnings(for descriptor: InstanceDescriptor) { diff --git a/ios/Capacitor/Capacitor/KeyValueStore.swift b/ios/Capacitor/Capacitor/KeyValueStore.swift new file mode 100644 index 000000000..21f257107 --- /dev/null +++ b/ios/Capacitor/Capacitor/KeyValueStore.swift @@ -0,0 +1,324 @@ +// +// KeyValueStore.swift +// Capacitor +// +// Created by Steven Sherry on 1/5/24. +// Copyright © 2024 Drifty Co. All rights reserved. +// + +import Foundation + +/// A generic KeyValueStore that allows storing and retrieving values associated with string keys. +/// The store supports both ephemeral (in-memory) storage and persistent (file-based) storage backends +/// by default, however it can also take anything that conforms to ``KeyValueStoreBackend`` as +/// a backend. +/// +/// This class provides methods to get, set and delete key-value pairs for any type of value, provided the +/// types conform to `Codable`. The default ``Backend/ephemeral`` and ``Backend/persistent(suiteName:)`` +/// backends are thread-safe. +/// +/// ## Usage Examples +/// +/// ### Non-throwing API +/// ```swift +/// let store = KeyValueStore.standard +/// // Set +/// store["key"] = "value" +/// +/// // Get +/// if let value = store["key", as: String.self] { +/// // Do something with value +/// } +/// +/// // Delete +/// // The type here is a required argument because +/// // it is unable to be inferred +/// store["key", as: String.self] = nil +/// // or +/// store["key"] = nil as String? +/// ``` +/// +/// +/// ### Throwing API +/// +/// ```swift +/// let store = KeyValueStore.standard +/// do { +/// // Set +/// try store.set("key", value: "value") +/// +/// // Get +/// if let value = try store.get("key", as: String.self) { +/// // Do something with value +/// } +/// +/// // Delete +/// try store.delete("key") +/// } catch { +/// print(error.localizedDescription) +/// } +/// ``` +/// +/// ### Throwing vs Non-throwing +/// +/// Of the built-in backends, both ``Backend/ephemeral`` and ``Backend/persistent(suiteName:)`` will throw in the following cases: +/// * The data read from the file retrieved during ``get(_:)`` or ``get(_:as:)`` is unable to be decoded as the type provided. +/// * The value provided to ``set(_:value:)`` encounters an error during encoding. +/// * This is more likely to happen with types that have custom `Encodable` implementations +/// +/// ``Backend/persistent(suiteName:)`` will throw for the following additional cases: +/// * A file is unable to be read from disk during ``get(_:)`` or ``get(_:as:)`` +/// * The existence of the file on disk is checked before attempting to read the file, so out of the +/// [possible file reading errors](https://developer.apple.com/documentation/foundation/1448136-nserror_codes#file-reading-errors), +/// the only likely candidate would be +/// [NSFileReadCorruptFileError](https://developer.apple.com/documentation/foundation/1448136-nserror_codes/nsfilereadcorruptfileerror). +/// In practice, this should never happen since writes happen atomically. +/// * The data from the value encoded in ``set(_:value:)`` is unable to be written to disk +/// * Of the [possible file writing errors](https://developer.apple.com/documentation/foundation/1448136-nserror_codes#file-writing-errors), +/// the only likely candidates are +/// [NSFileWriteInvalidFileNameError](https://developer.apple.com/documentation/foundation/1448136-nserror_codes/nsfilewriteinvalidfilenameerror) +/// if the key provided makes for an invalid file name and +/// [NSFileWriteOutOfSpaceError](https://developer.apple.com/documentation/foundation/1448136-nserror_codes/nsfilewriteoutofspaceerror) +/// if the user has no space left on disk +/// +/// The throwing API should be used in cases where detailed error information is needed for logging or diagnostics. The non-throwing API should be used +/// in cases where silent failure is preferred. +public class KeyValueStore { + + /// The built-in storage backends + public enum Backend { + /// An in-memory backing store + case ephemeral + /// A persistent file-based backing store using the + /// `suiteName` as an identifier for the collection of files + case persistent(suiteName: String) + } + + private let backend: any KeyValueStoreBackend + + /// Creates an instance of ``KeyValueStore`` with a custom backend + /// - Parameter backend: The custom backend implementation + public init(backend: any KeyValueStoreBackend) { + self.backend = backend + } + + /// Creates an instance of ``KeyValueStore`` with the provided built-in ``Backend`` + /// - Parameter type: The type of ``Backend`` to use + public init(type: Backend) { + switch type { + case .ephemeral: + backend = InMemoryStore() + case .persistent(suiteName: let name): + backend = FileStore.with(name: name) + } + } + + /// Creates an instance of ``KeyValueStore`` with ``Backend/persistent(suiteName:)`` + /// - Parameter suiteName: The suite name to provide ``Backend/persistent(suiteName:)`` + public convenience init(suiteName: String) { + self.init(type: .persistent(suiteName: suiteName)) + } + + /// Retrieves a value of the specified type and key + /// - Parameters: + /// - key: The unique identifier for the value + /// - type: The expected type of the value being retried + /// - Returns: A decoded value of the given type or `nil` if there is no such value + public func `get`(_ key: String, as type: T.Type) throws -> T? where T: Decodable { + try backend.get(key, as: type) + } + + /// Retrieves a value of the specified type and key + /// - Parameter key: The unique identifier for the value + /// - Returns: A decoded value of the given type or `nil` if there is no such value + public func `get`(_ key: String) throws -> T? where T: Decodable { + try backend.get(key, as: T.self) + } + + /// Stores the value under the specified key + /// - Parameters: + /// - key: The unique identifier + /// - value: The value to be stored + public func `set`(_ key: String, value: T) throws where T: Encodable { + try backend.set(key, value: value) + } + + /// Deletes the value for the specified key + public func `delete`(_ key: String) throws { + try backend.delete(key) + } + + /// Convenience for acessing and modifying values in the store without calling ``get(_:)``, ``set(_:value:)``, or ``delete(_:)`` + /// - Parameter key: The unique identifier for the value to access or modify + /// + /// If the generic parameter is unable to be inferred, use ``subscript(_:as:)`` or cast to the appropriate type + /// ```swift + /// let store = KeyValueStore.standard + /// + /// // Get + /// let value = store["key"] as String? + /// + /// // Set - The value is inferrable at the callsite + /// store["key"] = "value" + /// + /// // Delete + /// store["key"] = nil as String? + /// ``` + public subscript (_ key: String) -> T? where T: Codable { + get { try? self.get(key) } + set { + if let newValue { + try? self.set(key, value: newValue) + } else { + try? self.delete(key) + } + } + } + + /// Convenience for accessing and modifying values in the store without calling ``get(_:as:)``, ``set(_:value:)``, or ``delete(_:)`` + /// - Parameters: + /// - key: The unique identifier for the value to access or modify + /// - type: The type the value is stored as + /// + /// This method is only really necessary when accessing a key and the type cannot be inferred from it's context. + /// ```swift + /// let store = KeyValueStore.standard + /// + /// // Get + /// let value = store["key", as: String.self] + /// + /// // Delete + /// store["key", as: String.self] = nil + /// ``` + public subscript (_ key: String, as type: T.Type) -> T? where T: Codable { + get { try? self.get(key) } + set { + if let newValue { + try? self.set(key, value: newValue) + } else { + try? self.delete(key) + } + } + } + + /// A shared persistent instance of ``KeyValueStore`` + public static let standard = KeyValueStore(type: .persistent(suiteName: "standard")) +} + +public protocol KeyValueStoreBackend { + func `get`(_ key: String, as type: T.Type) throws -> T? where T: Decodable + func `set`(_ key: String, value: T) throws where T: Encodable + func `delete`(_ key: String) throws +} + +private class FileStore: KeyValueStoreBackend { + private let cache = ConcurrentDictionary() + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + private let baseUrl: URL + + private init(baseUrl: URL) { + self.baseUrl = baseUrl + } + + func get(_ key: String, as type: T.Type) throws -> T? where T: Decodable { + if let cached = cache[key], + let value = try? decoder.decode(type, from: cached) { + return value + } + + let fileCacheLocation = baseUrl.appendingPathComponent(key) + guard FileManager.default.fileExists(atPath: fileCacheLocation.path) else { return nil } + + let data = try Data(contentsOf: fileCacheLocation) + let decoded = try decoder.decode(type, from: data) + + cache[key] = data + return decoded + } + + func set(_ key: String, value: T) throws where T: Encodable { + let encoded = try encoder.encode(value) + try encoded.write(to: url(for: key), options: .atomic) + cache[key] = encoded + } + + func delete(_ key: String) throws { + cache[key] = nil + try FileManager.default.removeItem(at: url(for: key)) + } + + private func url(for key: String) -> URL { + baseUrl.appendingPathComponent(key) + } + + private static let instances = ConcurrentDictionary() + + // This ensures we essentially have singletons for accessing file based resources + // so we don't have a scenario where two separate instances may be writing to + // the same files. + static func with(name: String) -> FileStore { + if let existing = instances[name] { return existing } + guard let library = try? FileManager + .default + .url( + for: .libraryDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + else { fatalError("⚡️ ❌ Library URL unable to be accessed or created by the current application. This is an impossible state.") } + + let url = library.appendingPathComponent("kvstore").appendingPathComponent(name) + + // Create the folder if it doesn't exist. This should never throw for the current base directory, so we ignore the exception. + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) + + let new = FileStore(baseUrl: url) + instances[name] = new + return new + } +} + +private class InMemoryStore: KeyValueStoreBackend { + private let storage = ConcurrentDictionary() + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + func get(_ key: String, as type: T.Type) throws -> T? where T : Decodable { + guard let data = storage[key] else { return nil } + return try decoder.decode(type, from: data) + } + + func set(_ key: String, value: T) throws where T : Encodable { + let data = try encoder.encode(value) + storage[key] = data + } + + func delete(_ key: String) { + storage[key] = nil + } +} + +private class ConcurrentDictionary { + private var storage: [String: Value] + private let lock = NSLock() + + init(_ initial: [String: Value] = [:]) { + storage = initial + } + + subscript (_ key: String) -> Value? { + get { + lock.withLock { + storage[key] + } + } + + set { + lock.withLock { + storage[key] = newValue + } + } + } +} diff --git a/ios/Capacitor/Capacitor/Plugins/WebView.swift b/ios/Capacitor/Capacitor/Plugins/WebView.swift index b6c9824cc..b073a81f2 100644 --- a/ios/Capacitor/Capacitor/Plugins/WebView.swift +++ b/ios/Capacitor/Capacitor/Plugins/WebView.swift @@ -22,8 +22,7 @@ public class CAPWebViewPlugin: CAPPlugin { @objc func persistServerBasePath(_ call: CAPPluginCall) { if let viewController = bridge?.viewController as? CAPBridgeViewController { let path = viewController.getServerBasePath() - let defaults = UserDefaults.standard - defaults.set(path, forKey: "serverBasePath") + KeyValueStore.standard["serverBasePath"] = path call.resolve() } }