Skip to content

Commit

Permalink
Start work for iCloud Documents
Browse files Browse the repository at this point in the history
  • Loading branch information
0xLeif committed Mar 22, 2024
1 parent 6d47c27 commit e5e902e
Show file tree
Hide file tree
Showing 10 changed files with 750 additions and 0 deletions.
3 changes: 3 additions & 0 deletions Sources/AppState/Application/Application+internal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
96 changes: 96 additions & 0 deletions Sources/AppState/Application/Application+public.swift
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,8 @@ public extension Application {
column: column
)

print("\(ApplicationState.emoji) Getting State \(String(describing: keyPath)) -> \(appState.value)")

return appState
}

Expand Down Expand Up @@ -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<Value>(
cloudState keyPath: KeyPath<Application, CloudState<Value>>,
_ 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<Value>(
_ keyPath: KeyPath<Application, CloudState<Value>>,
_ fileID: StaticString = #fileID,
_ function: StaticString = #function,
_ line: Int = #line,
_ column: Int = #column
) -> CloudState<Value> {
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<Value>(
initial: @escaping @autoclosure () -> Value,
path: String = "AppState",
filename: String,
isBase64Encoded: Bool = true
) -> CloudState<Value> {
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<Value>(
path: String = "AppState",
filename: String,
isBase64Encoded: Bool = true
) -> CloudState<Value?> {
cloudState(
initial: nil,
path: path,
filename: filename,
isBase64Encoded: isBase64Encoded
)
}
}

25 changes: 25 additions & 0 deletions Sources/AppState/Application/Application.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Bool> = 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#if !os(Linux) && !os(Windows)
import Cache
import Combine
import Foundation
import SwiftUI

extension Application {
public class CloudStateViewModel<Value: Codable & Equatable>: ObservableObject {
@AppDependency(\.icloudDocumentStore) private var cloudDocumentStore: CloudStateStore

@Published var value: Value?

private let scope: Scope
private var filePresenter: FilePresenter<CloudStateViewModel<Value>>?

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<Value>
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<Bool> = 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#if !os(Linux) && !os(Windows)
import Foundation
import SwiftUI

extension Application {
var icloudDocumentStore: Dependency<CloudStateStore> {
dependency(CloudStateStore())
}

var hasExternalChanges: State<Bool> {
state(initial: true)
}

/// `CloudDocumentState` ...
public struct CloudState<Value: Codable & Equatable>: MutableApplicationState {
public static var emoji: Character { "☁️" }

@ObservedObject private var viewModel: CloudStateViewModel<Value>

/// 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<Value>.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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ extension Application {
case stored
case sync
case file
case cloud
}

public static var emoji: Character {
Expand Down Expand Up @@ -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))"
}
}
}
Expand Down
Loading

0 comments on commit e5e902e

Please sign in to comment.