From e5e902e24fb7195aa29d481b174291455a6cae0a Mon Sep 17 00:00:00 2001 From: Leif Date: Fri, 22 Mar 2024 15:41:12 -0600 Subject: [PATCH] Start work for iCloud Documents --- .../Application/Application+internal.swift | 3 + .../Application/Application+public.swift | 96 +++++++++ .../AppState/Application/Application.swift | 25 +++ .../Application+CloudStateViewModel.swift | 116 +++++++++++ .../Types/State/Application+CloudState.swift | 96 +++++++++ .../Types/State/Application+State.swift | 2 + .../Dependencies/CloudStateStore.swift | 184 ++++++++++++++++++ .../AppState/Dependencies/FilePresenter.swift | 33 ++++ .../PropertyWrappers/State/CloudState.swift | 93 +++++++++ Tests/AppStateTests/CloudStateTests.swift | 102 ++++++++++ 10 files changed, 750 insertions(+) create mode 100644 Sources/AppState/Application/Types/Helper/Application+CloudStateViewModel.swift create mode 100644 Sources/AppState/Application/Types/State/Application+CloudState.swift create mode 100644 Sources/AppState/Dependencies/CloudStateStore.swift create mode 100644 Sources/AppState/Dependencies/FilePresenter.swift create mode 100644 Sources/AppState/PropertyWrappers/State/CloudState.swift create mode 100644 Tests/AppStateTests/CloudStateTests.swift diff --git a/Sources/AppState/Application/Application+internal.swift b/Sources/AppState/Application/Application+internal.swift index 1be0921..47a15c7 100644 --- a/Sources/AppState/Application/Application+internal.swift +++ b/Sources/AppState/Application/Application+internal.swift @@ -60,6 +60,9 @@ extension Application { "AppState/Application+SecureState.swift", "AppState/Application+Slice.swift", "AppState/Application+FileState.swift", + "AppState/Application+CloudState.swift", + "AppState/Application+CloudStateViewModel.swift", + "AppState/CloudStateStore.swift" ] let isFileIDValue: Bool = excludedFileIDs.contains(fileID.description) == false diff --git a/Sources/AppState/Application/Application+public.swift b/Sources/AppState/Application/Application+public.swift index d1f0e1b..9d5f18c 100644 --- a/Sources/AppState/Application/Application+public.swift +++ b/Sources/AppState/Application/Application+public.swift @@ -348,6 +348,8 @@ public extension Application { column: column ) + print("\(ApplicationState.emoji) Getting State \(String(describing: keyPath)) -> \(appState.value)") + return appState } @@ -1143,3 +1145,97 @@ public extension Application { ) } } + +// MARK: - CloudState Functions + +public extension Application { + /// Resets the value to the inital value. If the inital value was `nil`, then the value will be removed from `FileManager` + static func reset( + cloudState keyPath: KeyPath>, + _ fileID: StaticString = #fileID, + _ function: StaticString = #function, + _ line: Int = #line, + _ column: Int = #column + ) { + log( + debug: "☁️ Resetting CloudState \(String(describing: keyPath))", + fileID: fileID, + function: function, + line: line, + column: column + ) + + var cloudState = shared.value(keyPath: keyPath) + cloudState.reset() + } + + /** + Retrieves a stored state from Application instance using the provided keypath. + + - Parameter keyPath: KeyPath of the state value to be fetched + - Returns: The requested state of type `Value`. + */ + static func cloudState( + _ keyPath: KeyPath>, + _ fileID: StaticString = #fileID, + _ function: StaticString = #function, + _ line: Int = #line, + _ column: Int = #column + ) -> CloudState { + let cloudState = shared.value(keyPath: keyPath) + + log( + debug: "☁️ Getting CloudState \(String(describing: keyPath)) -> \(cloudState.value)", + fileID: fileID, + function: function, + line: line, + column: column + ) + + return cloudState + } + + /** + Retrieves a `FileManager` backed state for the provided `path` and `filename`. If the state is not present, it initializes a new state with the `initial` value. + + - Parameters: + - initial: The closure that returns initial state value. + - path: The path to the directory containing the file. The default is `"AppState"`. + - filename: The name of the file to read. + - Returns: The state of type `Value`. + */ + func cloudState( + initial: @escaping @autoclosure () -> Value, + path: String = "AppState", + filename: String, + isBase64Encoded: Bool = true + ) -> CloudState { + CloudState( + initial: initial(), + scope: Scope(name: path, id: filename), + isBase64Encoded: isBase64Encoded + ) + } + + /** + Retrieves a `FileManager` backed state for the provided `path` and `filename` with a default value of `nil`. + + - Parameters: + - path: The path to the directory containing the file. The default is `"AppState"`. + - filename: The name of the file to read. + - Returns: The state of type `Value`. + */ + func cloudState( + path: String = "AppState", + filename: String, + isBase64Encoded: Bool = true + ) -> CloudState { + cloudState( + initial: nil, + path: path, + filename: filename, + isBase64Encoded: isBase64Encoded + ) + } +} + diff --git a/Sources/AppState/Application/Application.swift b/Sources/AppState/Application/Application.swift index dd28a91..9aacb5d 100644 --- a/Sources/AppState/Application/Application.swift +++ b/Sources/AppState/Application/Application.swift @@ -79,6 +79,31 @@ open class Application: NSObject { column: #column ) } + + open func cloudStoreItemDidChange(url: URL) { + Application.log( + debug: """ + ☁️ CloudState was changed externally (\(url.absoluteString)) + """, + fileID: #fileID, + function: #function, + line: #line, + column: #column + ) + + var hasExternalChangesState: State = Application.state(\.hasExternalChanges) + + DispatchQueue.main.async { + self.objectWillChange.send() + hasExternalChangesState.value = true + print("HERE: True") + } + } + + public static func loadCloudDependencies() { + load(dependency: \.icloudStore) + load(dependency: \.icloudDocumentStore) + } #endif /// Loads the default dependencies for use in Application. diff --git a/Sources/AppState/Application/Types/Helper/Application+CloudStateViewModel.swift b/Sources/AppState/Application/Types/Helper/Application+CloudStateViewModel.swift new file mode 100644 index 0000000..7e0e822 --- /dev/null +++ b/Sources/AppState/Application/Types/Helper/Application+CloudStateViewModel.swift @@ -0,0 +1,116 @@ +#if !os(Linux) && !os(Windows) +import Cache +import Combine +import Foundation +import SwiftUI + +extension Application { + public class CloudStateViewModel: ObservableObject { + @AppDependency(\.icloudDocumentStore) private var cloudDocumentStore: CloudStateStore + + @Published var value: Value? + + private let scope: Scope + private var filePresenter: FilePresenter>? + + init(scope: Scope) { + self.scope = scope + Task { + self.filePresenter = await cloudDocumentStore.startMonitoringFile(scope: scope) + self.filePresenter?.observedObject = self + } + + Task { + guard + let viewModel = await cloudDocumentStore.viewModels[scope.key] as? Application.CloudStateViewModel + else { + await cloudDocumentStore.update(viewModel: self, forKey: scope.key) + + return + } + + await MainActor.run { + value = viewModel.value + } + } + } + + deinit { + guard let filePresenter else { return } + + NSFileCoordinator.removeFilePresenter(filePresenter) + + self.filePresenter = nil + } + + func getValue(cachedValue: Value?) { + Task { + do { + let cloudStoreValue: Value = try await cloudDocumentStore.get(scope) + guard cachedValue != cloudStoreValue else { return } + await MainActor.run { + objectWillChange.send() + + shared.cache.set( + value: Application.State( + type: .cloud, + initial: cloudStoreValue, + scope: scope + ), + forKey: scope.key + ) + + var hasExternalChangesState: State = Application.state(\.hasExternalChanges) + hasExternalChangesState.value = false + + print("HERE: False") + } + } catch { + log( + error: error, + message: "☁️ CloudState Fetching", + fileID: #fileID, + function: #function, + line: #line, + column: #column + ) + } + } + } + + func setValue(newValue: Value) { + Task { + do { + try await cloudDocumentStore.set(scope, value: newValue) + } catch { + log( + error: error, + message: "☁️ CloudState Saving", + fileID: #fileID, + function: #function, + line: #line, + column: #column + ) + } + } + } + + func removeValue() { + Task { + do { + try await cloudDocumentStore.remove(scope) + } catch { + log( + error: error, + message: "☁️ CloudState Deleting", + fileID: #fileID, + function: #function, + line: #line, + column: #column + ) + } + } + } + } +} +#endif diff --git a/Sources/AppState/Application/Types/State/Application+CloudState.swift b/Sources/AppState/Application/Types/State/Application+CloudState.swift new file mode 100644 index 0000000..d3d90a2 --- /dev/null +++ b/Sources/AppState/Application/Types/State/Application+CloudState.swift @@ -0,0 +1,96 @@ +#if !os(Linux) && !os(Windows) +import Foundation +import SwiftUI + +extension Application { + var icloudDocumentStore: Dependency { + dependency(CloudStateStore()) + } + + var hasExternalChanges: State { + state(initial: true) + } + + /// `CloudDocumentState` ... + public struct CloudState: MutableApplicationState { + public static var emoji: Character { "☁️" } + + @ObservedObject private var viewModel: CloudStateViewModel + + /// The initial value of the state. + private var initial: () -> Value + + /// The current state value. + public var value: Value { + get { + let cachedValue = shared.cache.get( + scope.key, + as: State.self + ) + + if shared.value(keyPath: \.hasExternalChanges).value { + viewModel.getValue(cachedValue: cachedValue?.value) + } + + if let cachedValue { + return cachedValue.value + } + + return initial() + } + set { + let mirror = Mirror(reflecting: newValue) + + if mirror.displayStyle == .optional, + mirror.children.isEmpty { + shared.cache.remove(scope.key) + + viewModel.removeValue() + } else { + shared.cache.set( + value: Application.State( + type: .cloud, + initial: newValue, + scope: scope + ), + forKey: scope.key + ) + + viewModel.setValue(newValue: newValue) + } + } + } + + /// The scope in which this state exists. + let scope: Scope + + let isBase64Encoded: Bool + + var path: String { scope.name } + var filename: String { scope.id } + + /** + Creates a new state within a given scope initialized with the provided value. + + - Parameters: + - value: The initial value of the state + - scope: The scope in which the state exists + */ + init( + initial: @escaping @autoclosure () -> Value, + scope: Scope, + isBase64Encoded: Bool + ) { + self.initial = initial + self.scope = scope + self.isBase64Encoded = isBase64Encoded + self.viewModel = CloudStateViewModel(scope: scope) + } + + /// Resets the value to the inital value. If the inital value was `nil`, then the value will be removed from `FileManager` + public mutating func reset() { + value = initial() + } + } +} +#endif diff --git a/Sources/AppState/Application/Types/State/Application+State.swift b/Sources/AppState/Application/Types/State/Application+State.swift index 6527ef3..9229473 100644 --- a/Sources/AppState/Application/Types/State/Application+State.swift +++ b/Sources/AppState/Application/Types/State/Application+State.swift @@ -9,6 +9,7 @@ extension Application { case stored case sync case file + case cloud } public static var emoji: Character { @@ -102,6 +103,7 @@ extension Application { case .stored: return "StoredState<\(Value.self)>(\(value)) (\(scope.key))" case .sync: return "SyncState<\(Value.self)>(\(value)) (\(scope.key))" case .file: return "FileState<\(Value.self)>(\(value)) (\(scope.key))" + case .cloud: return "CloudState<\(Value.self)>(\(value)) (\(scope.key))" } } } diff --git a/Sources/AppState/Dependencies/CloudStateStore.swift b/Sources/AppState/Dependencies/CloudStateStore.swift new file mode 100644 index 0000000..e541a8e --- /dev/null +++ b/Sources/AppState/Dependencies/CloudStateStore.swift @@ -0,0 +1,184 @@ +import Cache +import Foundation + +actor CloudStateStore { + struct Blob: Codable { + let value: Value + } + + enum CloudError: LocalizedError { + case invalidURL(String?) + case noData(String?) + + var errorDescription: String? { + switch self { + case .invalidURL(let string): return "Invalid URL \(String(describing: string))" + case .noData(let string): return "No data for \(String(describing: string))" + } + } + } + + private let coordinator: NSFileCoordinator + + @AppDependency(\.fileManager) private var fileManager: FileManager + + private var cloudDocumentsURL: URL? { + fileManager + .url(forUbiquityContainerIdentifier: nil)? + .appendingPathComponent("Documents") + } + + var viewModels: [String: any ObservableObject] + + init() { + self.coordinator = NSFileCoordinator() + self.viewModels = [:] + } + + func update(viewModel: any ObservableObject, forKey key: String) { + viewModels[key] = viewModel + } + + func `get`( + _ scope: Application.Scope + ) throws -> Value { + let documentURL = cloudDocumentsURL?.appendingPathComponent("\(scope.name).\(scope.id)") + + guard let documentURL else { + throw CloudError.invalidURL(documentURL?.absoluteString) + } + + var coordinationError: NSError? + var readData: Data? + var readError: Error? + + coordinator.coordinate( + readingItemAt: documentURL, + options: [], + error: &coordinationError, + byAccessor: { url in + do { + if let cloudDocumentsURL { + try? fileManager.createDirectory(at: cloudDocumentsURL, withIntermediateDirectories: true) + } + readData = try Data(contentsOf: url) + } catch { + readError = error + } + } + ) + + if let error = readError { + throw error + } + + if let coordinationError = coordinationError { + throw coordinationError + } + + guard let data = readData else { + throw CloudError.noData(documentURL.absoluteString) + } + + guard let base64DecodedData = Data(base64Encoded: data) else { + return try JSONDecoder().decode(Blob.self, from: data).value + } + + return try JSONDecoder().decode(Blob.self, from: base64DecodedData).value + } + + func `set`( + _ scope: Application.Scope, + value: Value, + isBase64Encoded: Bool = true + ) throws { + let documentURL = cloudDocumentsURL?.appendingPathComponent("\(scope.name).\(scope.id)") + + guard let documentURL else { + throw CloudError.invalidURL(documentURL?.absoluteString) + } + + let blob = Blob(value: value) + + var data = try JSONEncoder().encode(blob) + + if isBase64Encoded { + data = data.base64EncodedData() + } + + var coordinationError: NSError? + var writeError: Error? + + coordinator.coordinate( + writingItemAt: documentURL, + options: [ + .forDeleting + ], + error: &coordinationError, + byAccessor: { url in + do { + if let cloudDocumentsURL { + try? fileManager.createDirectory(at: cloudDocumentsURL, withIntermediateDirectories: true) + } + try data.write(to: url, options: .atomic) + } catch { + writeError = error + } + } + ) + + if let error = writeError { + throw error + } + + if let coordinationError = coordinationError { + throw coordinationError + } + } + + func remove( + _ scope: Application.Scope + ) throws { + let documentURL = cloudDocumentsURL?.appendingPathComponent("\(scope.name).\(scope.id)") + + guard let documentURL else { + throw CloudError.invalidURL(documentURL?.absoluteString) + } + + var coordinationError: NSError? + var removeError: Error? + + coordinator.coordinate( + writingItemAt: documentURL, + options: [ + .forDeleting + ], + error: &coordinationError, + byAccessor: { url in + do { + try fileManager.removeItem(at: documentURL) + } catch { + removeError = error + } + } + ) + + if let error = removeError { + throw error + } + + if let coordinationError = coordinationError { + throw coordinationError + } + } + + func startMonitoringFile( + scope: Application.Scope + ) -> FilePresenter? { + guard let documentURL = cloudDocumentsURL?.appendingPathComponent("\(scope.name).\(scope.id)") else { + return nil + } + + return FilePresenter(url: documentURL) + } +} diff --git a/Sources/AppState/Dependencies/FilePresenter.swift b/Sources/AppState/Dependencies/FilePresenter.swift new file mode 100644 index 0000000..aa2516b --- /dev/null +++ b/Sources/AppState/Dependencies/FilePresenter.swift @@ -0,0 +1,33 @@ +#if !os(Linux) && !os(Windows) +import Combine +import Foundation + +class FilePresenter: NSObject, NSFilePresenter { + var presentedItemOperationQueue: OperationQueue + var presentedItemURL: URL? + + weak var observedObject: Observed? + + init(url: URL) { + self.presentedItemOperationQueue = .main + self.presentedItemURL = url + super.init() + + NSFileCoordinator.addFilePresenter(self) + } + + deinit { NSFileCoordinator.removeFilePresenter(self) } + + func presentedItemDidChange() { + guard let presentedItemURL else { return } + + Application.shared.cloudStoreItemDidChange(url: presentedItemURL) + + guard + let publisher = observedObject?.objectWillChange as? ObservableObjectPublisher + else { return } + + publisher.send() + } +} +#endif diff --git a/Sources/AppState/PropertyWrappers/State/CloudState.swift b/Sources/AppState/PropertyWrappers/State/CloudState.swift new file mode 100644 index 0000000..2ce0408 --- /dev/null +++ b/Sources/AppState/PropertyWrappers/State/CloudState.swift @@ -0,0 +1,93 @@ +#if !os(Linux) && !os(Windows) +import Foundation +import Combine +import SwiftUI + +/** + + */ +@available(watchOS 9.0, *) +@propertyWrapper public struct CloudState: DynamicProperty { + /// Holds the singleton instance of `Application`. + @ObservedObject private var app: Application = Application.shared + + /// Path for accessing `CloudState` from Application. + private let keyPath: KeyPath> + + private let fileID: StaticString + private let function: StaticString + private let line: Int + private let column: Int + + /// Represents the current value of the `CloudState`. + public var wrappedValue: Value { + get { + Application.cloudState( + keyPath, + fileID, + function, + line, + column + ).value + } + nonmutating set { + Application.log( + debug: "☁️ Setting CloudState \(String(describing: keyPath)) = \(newValue)", + fileID: fileID, + function: function, + line: line, + column: column + ) + + var state = app.value(keyPath: keyPath) + state.value = newValue + } + } + + /// A binding to the `State`'s value, which can be used with SwiftUI views. + public var projectedValue: Binding { + Binding( + get: { wrappedValue }, + set: { wrappedValue = $0 } + ) + } + + /** + Initializes the AppState with a `keyPath` for accessing `CloudState` in Application. + + - Parameter keyPath: The `KeyPath` for accessing `CloudState` in Application. + */ + public init( + _ keyPath: KeyPath>, + _ fileID: StaticString = #fileID, + _ function: StaticString = #function, + _ line: Int = #line, + _ column: Int = #column + ) { + self.keyPath = keyPath + self.fileID = fileID + self.function = function + self.line = line + self.column = column + } + + /// A property wrapper's synthetic storage property. This is just for SwiftUI to mutate the `wrappedValue` and send event through `objectWillChange` publisher when the `wrappedValue` changes + public static subscript( + _enclosingInstance observed: OuterSelf, + wrapped wrappedKeyPath: ReferenceWritableKeyPath, + storage storageKeyPath: ReferenceWritableKeyPath + ) -> Value { + get { + observed[keyPath: storageKeyPath].wrappedValue + } + set { + guard + let publisher = observed.objectWillChange as? ObservableObjectPublisher + else { return } + + publisher.send() + observed[keyPath: storageKeyPath].wrappedValue = newValue + } + } +} +#endif diff --git a/Tests/AppStateTests/CloudStateTests.swift b/Tests/AppStateTests/CloudStateTests.swift new file mode 100644 index 0000000..e67fd46 --- /dev/null +++ b/Tests/AppStateTests/CloudStateTests.swift @@ -0,0 +1,102 @@ +#if !os(Linux) && !os(Windows) +import SwiftUI +import XCTest +@testable import AppState + +@available(watchOS 9.0, *) +fileprivate extension Application { + var cloudValue: CloudState { + cloudState(filename: "cloudValue") + } + + var cloudFailureValue: CloudState { + cloudState(initial: -1, filename: "cloudValue") + } +} + +@available(watchOS 9.0, *) +fileprivate struct ExampleSyncValue { + @CloudState(\.cloudValue) var count +} + + +@available(watchOS 9.0, *) +fileprivate struct ExampleFailureSyncValue { + @CloudState(\.cloudFailureValue) var count +} + + +@available(watchOS 9.0, *) +fileprivate class ExampleStoringViewModel: ObservableObject { + @CloudState(\.cloudValue) var count + + func testPropertyWrapper() { + count = 27 + _ = TextField( + value: $count, + format: .number, + label: { Text("Count") } + ) + } +} + + +@available(watchOS 9.0, *) +final class CloudStateTests: XCTestCase { + override class func setUp() { + Application + .logging(isEnabled: true) + .load(dependency: \.icloudStore) + } + + override class func tearDown() { + Application.logger.debug("CloudStateTests \(Application.description)") + } + + func testCloudState() { + XCTAssertNil(Application.cloudState(\.cloudValue).value) + + let cloudValue = ExampleSyncValue() + + XCTAssertEqual(cloudValue.count, nil) + + cloudValue.count = 1 + + XCTAssertEqual(cloudValue.count, 1) + + Application.logger.debug("CloudStateTests \(Application.description)") + + cloudValue.count = nil + + XCTAssertNil(Application.cloudState(\.cloudValue).value) + } + + func testFailEncodingCloudState() { + XCTAssertNotNil(Application.cloudState(\.cloudFailureValue).value) + + let cloudValue = ExampleFailureSyncValue() + + XCTAssertEqual(cloudValue.count, -1) + + cloudValue.count = Double.infinity + + XCTAssertEqual(cloudValue.count, Double.infinity) + } + + func testStoringViewModel() { + XCTAssertNil(Application.cloudState(\.cloudValue).value) + + let viewModel = ExampleStoringViewModel() + + XCTAssertEqual(viewModel.count, nil) + + viewModel.testPropertyWrapper() + + XCTAssertEqual(viewModel.count, 27) + + Application.reset(cloudState: \.cloudValue) + + XCTAssertNil(viewModel.count) + } +} +#endif