├── .gitignore ├── LICENSE ├── Package.swift ├── README.md ├── Sources ├── AppCodableStorage.swift ├── DefaultsKeyObservation.swift ├── DefaultsWriter.swift └── PropertyListRepresentable.swift └── Tests └── AppCodableStorageTests └── AppCodableStorageTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Andrew Pouliot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "AppCodableStorage", 8 | platforms: [ 9 | .iOS(.v14), 10 | .macOS(.v11), 11 | .tvOS(.v13), 12 | .watchOS(.v7) 13 | ], 14 | products: [ 15 | .library( 16 | name: "AppCodableStorage", 17 | targets: ["AppCodableStorage"]), 18 | ], 19 | dependencies: [ 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 24 | .target( 25 | name: "AppCodableStorage", 26 | dependencies: [], path: "Sources"), 27 | .testTarget( 28 | name: "AppCodableStorageTests", 29 | dependencies: ["AppCodableStorage"]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AppCodableStorage 2 | 3 | Extends `@AppStorage` in SwiftUI to support any Codable object. I use SwiftUI for quick prototypes, and this makes it a lot easier to stay organized. 4 | 5 | Just swap `@AppCodableStorage` for `@AppStorage` and tag your type as `PropertyListRepresentable` and you're good to go. 6 | 7 | Like [AppStorage](https://developer.apple.com/documentation/swiftui/appstorage) 8 | 9 | ```swift 10 | struct Config: PropertyListRepresentable { 11 | var username: String 12 | var profileColor: NSColor? 13 | } 14 | 15 | struct MyView: View { 16 | 17 | @AppCodableStorage("user") var settings = Config(username: "Steve") 18 | 19 | var body: some View { 20 | TextField($config.username) 21 | ... 22 | } 23 | } 24 | ``` 25 | 26 | ## Supported 27 | 28 | - Use storage in multiple views, they automatically reflect the most recent value 29 | - projectedValue / Bindings so you can pass a sub-object into a sub-view mutably 30 | - Observes UserDefaults so it can interoperate with other code / `defaults write …` changes 31 | 32 | ## Outside of SwiftUI 33 | 34 | The underlying implementation is in `DefaultsWriter`, which is useful if you have other subsystems in your app that want to write to and observe `UserDefaults` in this way. For that purpose, there is also a `DefaultsWriter.objectDidChange` `Publisher` to use when you want the updated value rather than a signal that it's about to change. 35 | 36 | ## Limitations 37 | 38 | - Root must code as a Dictionary 39 | - Encodes to `Data` and back 40 | 41 | The default implementation of PlistReprestable is inelegant, but supports the same use cases as PlistEncoder, since I use PlistEncoder to first convert to Data and then decode the data. 42 | 43 | A "better" solution would be to copy-paste most of the code from [PlistEncoder](https://github.com/apple/swift-corelibs-foundation/blob/main/Darwin/Foundation-swiftoverlay/PlistEncoder.swift) to be able to use `PlistCoder.encodeToTopLevelContainer()`, which is marked `internal` in the standard library. If it were made public, this could be much more elegant. 44 | 45 | ## Alternates considered 46 | 47 | You can use `RawRepresentable` with the built-in `AppStorage to store Codable in a string key in `UserDefaults`. This means that your defaults is now unreadable since there is a weird string or binary data in there. 48 | 49 | I realize Mike Ash independently made [TSUD](https://github.com/mikeash/TSUD), which does something pretty similar prior to SwiftUI. 50 | 51 | ## Warning 52 | 53 | Do not use this to store a large amount of data! No images, unlimited amounts of text, files etc. 54 | 55 | You should sanitize any user input to make sure that it ends up a reasonable size. 56 | -------------------------------------------------------------------------------- /Sources/AppCodableStorage.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @MainActor 4 | @propertyWrapper 5 | public struct AppCodableStorage: DynamicProperty { 6 | private let triggerUpdate: ObservedObject> 7 | // Uses the shared 8 | private let writer: DefaultsWriter 9 | 10 | public init(wrappedValue: Value, _ key: String, defaults: UserDefaults? = nil) { 11 | writer = DefaultsWriter.shared(defaultValue: wrappedValue, key: key, defaults: defaults ?? .standard) 12 | triggerUpdate = .init(wrappedValue: writer) 13 | } 14 | 15 | public var wrappedValue: Value { 16 | get { writer.state } 17 | nonmutating set { writer.state = newValue } 18 | } 19 | 20 | public var projectedValue: Binding { 21 | Binding( 22 | get: { writer.state }, 23 | set: { writer.state = $0 } 24 | ) 25 | } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /Sources/DefaultsKeyObservation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// API to observe UserDefaults with a `String` key (not `KeyPath`), as `AppStorage` and `.string( forKey:)` use 4 | extension UserDefaults { 5 | 6 | // Just a BS object b/c we can't use the newer observation syntax 7 | class UserDefaultsStringKeyObservation: NSObject { 8 | 9 | // Handler recieves the updated value from userdefaults 10 | fileprivate init(defaults: UserDefaults, key: String, handler: @escaping (Any?) -> Void) { 11 | self.defaults = defaults 12 | self.key = key 13 | self.handler = handler 14 | super.init() 15 | // print("Adding observer \(self) for keyPath: \(key)") 16 | defaults.addObserver(self, forKeyPath: key, options: .new, context: nil) 17 | } 18 | let defaults: UserDefaults 19 | let key: String 20 | 21 | // This prevents us from double-removing ourselves as the observer (if we are cancelled, then deinit) 22 | private var isCancelled: Bool = false 23 | 24 | private let handler: (Any?) -> Void 25 | 26 | override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { 27 | guard (object as? UserDefaults) == defaults else { fatalError("AppCodableStorage: Somehow observing wrong defaults") } 28 | let newValue = change?[.newKey] 29 | handler(newValue) 30 | } 31 | 32 | func cancel() { 33 | guard !isCancelled else { return } 34 | isCancelled = true 35 | defaults.removeObserver(self, forKeyPath: key) 36 | } 37 | 38 | deinit { 39 | cancel() 40 | } 41 | } 42 | 43 | func observe(key: String, changeHandler: @escaping (Any?) -> Void) -> UserDefaultsStringKeyObservation { 44 | return UserDefaultsStringKeyObservation(defaults: self, key: key, handler: changeHandler) 45 | } 46 | } 47 | 48 | import Combine 49 | 50 | @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) 51 | extension UserDefaults.UserDefaultsStringKeyObservation: Cancellable {} 52 | -------------------------------------------------------------------------------- /Sources/DefaultsWriter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import Combine 4 | 5 | /// DefaultsWiriter syncs a Codable object tree to UserDefaults 6 | /// - Writes any Codable object to user defaults **as an object tree**, not a String or coded Data. 7 | /// - Observes any external changes to its UserDefaults key 8 | /// After I wrote this, I realized it's pretty similar to [Mike Ash's "Type-Safe User Defaults"](https://github.com/mikeash/TSUD) 9 | /// 10 | public final class DefaultsWriter: ObservableObject { 11 | 12 | public let key: String 13 | public let defaults: UserDefaults 14 | 15 | // Var to get around issue with init and callback fn 16 | private var defaultsObserver: UserDefaults.UserDefaultsStringKeyObservation! 17 | private let defaultValue: Value 18 | private var pausedObservation: Bool = false 19 | 20 | // Experimental API… I had some situations outside of SwiftUI where I need to get the value AFTER the change 21 | public let objectDidChange: AnyPublisher 22 | private let _objectDidChange: PassthroughSubject 23 | 24 | public var state: Value { 25 | willSet{ 26 | objectWillChange.send() 27 | } 28 | didSet { 29 | if let encoded = try? state.propertyListValue { 30 | // We don't want to observe the redundant notification that will come from UserDefaults 31 | pausedObservation = true 32 | defaults.set(encoded, forKey: key) 33 | pausedObservation = false 34 | } 35 | _objectDidChange.send(state) 36 | } 37 | } 38 | 39 | public init(defaultValue: Value, key: String, defaults: UserDefaults? = nil) { 40 | let defaults = defaults ?? .standard 41 | self.key = key 42 | self.state = Self.read(from: defaults, key: key) ?? defaultValue 43 | self.defaults = defaults 44 | self.defaultValue = defaultValue 45 | self.defaultsObserver = nil 46 | self._objectDidChange = PassthroughSubject() 47 | self.objectDidChange = _objectDidChange.eraseToAnyPublisher() 48 | 49 | // When defaults change externally, update our value 50 | // We cannot use the newer defaults.observe() because we have a keyPath String not a KeyPath 51 | // This means we don't force you to declare your keypath in a UserDefaults extension 52 | self.defaultsObserver = defaults.observe(key: key){[weak self] newValue in 53 | guard let self = self, self.pausedObservation == false else { return } 54 | self.observeDefaultsUpdate(newValue) 55 | } 56 | } 57 | 58 | // Take in a new object value from UserDefaults, updating our state 59 | internal func observeDefaultsUpdate(_ newValue: Any?) { 60 | if newValue is NSNull { 61 | self.state = defaultValue 62 | } else if let newValue = newValue { 63 | do { 64 | let newState = try Value(propertyList: newValue) 65 | // print("Observed defaults new state is \(newValue)") 66 | self.state = newState 67 | } catch { 68 | print("DefaultsWriter could not deserialize update from UserDefaults observation. Not updating. \(error)") 69 | } 70 | } else { 71 | self.state = defaultValue 72 | } 73 | } 74 | 75 | internal static func read(from defaults: UserDefaults, key: String) -> Value? { 76 | if let o = defaults.object(forKey: key) { 77 | return try? Value(propertyList: o) 78 | } else { 79 | return nil 80 | } 81 | } 82 | 83 | } 84 | 85 | @MainActor 86 | internal var sharedDefaultsWriters: [WhichDefaultsAndKey: Any /* Any DefaultsWriter<_> */] = [:] 87 | 88 | struct WhichDefaultsAndKey: Hashable { 89 | let defaults: UserDefaults 90 | let key: String 91 | } 92 | 93 | extension DefaultsWriter { 94 | 95 | @MainActor 96 | public static func shared(defaultValue: PropertyListRepresentable, key: String, defaults: UserDefaults) -> Self { 97 | let kdPr = WhichDefaultsAndKey(defaults: defaults, key: key) 98 | if let existing = sharedDefaultsWriters[kdPr] { 99 | guard let typed = existing as? Self else { 100 | fatalError("Type \(Value.self) must remain consistent for key \(key). Existing: \(existing)") 101 | } 102 | return typed 103 | } 104 | let neue = Self(defaultValue: defaultValue as! Value, key: key, defaults: defaults) 105 | sharedDefaultsWriters[kdPr] = neue 106 | return neue 107 | } 108 | } 109 | 110 | -------------------------------------------------------------------------------- /Sources/PropertyListRepresentable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // TODO: do this encoding using a custom plist-encoder instead of this hackery 4 | public protocol PropertyListRepresentable { 5 | init(propertyList: Any) throws 6 | var propertyListValue: Any { get throws } 7 | } 8 | 9 | // Default implementation of PropertyListRepresentable for objects that are Decobable 10 | public extension PropertyListRepresentable where Self: Decodable { 11 | init(propertyList: Any) throws { 12 | let data = try PropertyListSerialization.data(fromPropertyList: propertyList, format: .binary, options: 0) 13 | let dec = PropertyListDecoder() 14 | self = try dec.decode(Self.self, from: data) 15 | } 16 | } 17 | 18 | // Default implementation of PropertyListRepresentable for objects that are Encodable 19 | public extension PropertyListRepresentable where Self: Encodable { 20 | var propertyListValue: Any { 21 | get throws { 22 | // Encode to plist, decode :( 23 | // We can copy https://github.com/apple/swift-corelibs-foundation/blob/main/Darwin/Foundation-swiftoverlay/PlistEncoder.swift 24 | // to fix this, just not slow enough afaik 25 | let data = try PropertyListEncoder().encode(self) 26 | return try PropertyListSerialization.propertyList(from: data, format: nil) 27 | } 28 | } 29 | } 30 | 31 | -------------------------------------------------------------------------------- /Tests/AppCodableStorageTests/AppCodableStorageTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import AppCodableStorage 3 | 4 | let defaultsKey = "Tests_macOS_root" 5 | 6 | let volatileDomain = "AppCodableStorage_tests_domain" 7 | 8 | protocol DefaultValue { 9 | static var defaultValue: Self { get } 10 | } 11 | 12 | @MainActor 13 | final class AppCodableStorageTests: XCTestCase { 14 | 15 | struct DictionaryRecord: Codable, Equatable, PropertyListRepresentable, DefaultValue { 16 | var foo: Double 17 | var bar: String 18 | var bash: String? 19 | 20 | static let defaultValue = DictionaryRecord(foo: 0, bar: "no") 21 | } 22 | 23 | struct MockView { 24 | @AppCodableStorage(defaultsKey) var value = Value.defaultValue 25 | } 26 | 27 | func resetState() { 28 | sharedDefaultsWriters.removeAll() 29 | UserDefaults.standard.removeObject(forKey: defaultsKey) 30 | } 31 | 32 | // Need to restore this during testing 33 | var registeredDefaults: [String: Any] = [:] 34 | 35 | @MainActor 36 | override func setUp() { 37 | resetState() 38 | } 39 | @MainActor 40 | override func tearDown() { 41 | resetState() 42 | } 43 | 44 | func saveRegisteredDefaults() { 45 | registeredDefaults = UserDefaults.standard.volatileDomain(forName: UserDefaults.registrationDomain) 46 | } 47 | 48 | func restoreRegisteredDefaults() { 49 | UserDefaults.standard.setVolatileDomain(registeredDefaults, forName: UserDefaults.registrationDomain) 50 | } 51 | 52 | 53 | func testWritingDefaults() { 54 | let s = MockView() 55 | s.value.bar = "123" 56 | 57 | let s2 = MockView() 58 | XCTAssertEqual(s2.value.bar, "123") 59 | 60 | } 61 | 62 | func testDefaultsStartNil() { 63 | let s = MockView() 64 | 65 | // Until you write, defaults should be unchanged 66 | XCTAssertNil(UserDefaults.standard.object(forKey: defaultsKey)) 67 | 68 | s.value.bar = "ABC" 69 | 70 | XCTAssertNotNil(UserDefaults.standard.object(forKey: defaultsKey)) 71 | } 72 | 73 | func testRegisteredDefaultsAreReflected() { 74 | saveRegisteredDefaults() 75 | // This test must use an alternate defaults key, otherwise the other tests don't work 76 | UserDefaults.standard.register(defaults: [defaultsKey: [ 77 | "foo": 123, 78 | "bar": "111", 79 | "bash": "ABCABC" 80 | ]]) 81 | let s = MockView() 82 | XCTAssertEqual(s.value, DictionaryRecord(foo: 123, bar: "111", bash: "ABCABC")) 83 | 84 | restoreRegisteredDefaults() 85 | resetState() 86 | 87 | let s2 = MockView() 88 | XCTAssertNotEqual(s2.value, DictionaryRecord(foo: 123, bar: "111", bash: "ABCABC")) 89 | 90 | } 91 | 92 | 93 | func testDefaultsAreDictionary() { 94 | // Until you write, defaults should be unchanged 95 | XCTAssertNil(UserDefaults.standard.object(forKey: defaultsKey)) 96 | 97 | let s = MockView() 98 | s.value.bar = "ABC" 99 | s.value.bash = "SNT" 100 | 101 | let expected: [String: Any] = [ 102 | "foo": 0.0, 103 | "bar": "ABC", 104 | "bash": "SNT" 105 | ] 106 | 107 | let actual = UserDefaults.standard.object(forKey: defaultsKey) 108 | XCTAssertEqual(actual! as! NSDictionary, expected as NSDictionary) 109 | } 110 | 111 | 112 | } 113 | 114 | 115 | final class DefaultsWriterTests: XCTestCase { 116 | 117 | func testDefaultsMatchExpected() { 118 | 119 | } 120 | } 121 | --------------------------------------------------------------------------------