├── .gitignore
├── LICENSE
├── Package.swift
├── README.md
├── Sources
└── TinyStorage
│ └── TinyStorage.swift
├── banner-dark.png
├── banner-light.png
└── build.sh
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 | *.xcarchive
10 | *.xcframework
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Christian Selig
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: 6.0
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: "TinyStorage",
8 | platforms: [.iOS(.v17), .tvOS(.v17), .visionOS(.v1), .watchOS(.v10), .macOS(.v14)],
9 | products: [
10 | // Products define the executables and libraries a package produces, making them visible to other packages.
11 | .library(
12 | name: "TinyStorage",
13 | type: .dynamic,
14 | targets: ["TinyStorage"]),
15 | ],
16 | dependencies: [],
17 | targets: [
18 | .target(
19 | name: "TinyStorage",
20 | dependencies: []
21 | ),
22 | ]
23 | )
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | 
3 |
4 | # TinyStorage
5 |
6 |
7 | A simple, lightweight replacement for `UserDefaults` with more reliable access and native support for `Codable` types.
8 |
9 | ## Overview
10 |
11 | Born out of [encountering issues with `UserDefaults`](https://christianselig.com/2024/10/beware-userdefaults/). As that blog post discusses, `UserDefaults` has more and more issues as of late with returning nil data when the device is locked and iOS "prelaunches" your app, leaving me honestly sort of unable to trust what `UserDefaults` returns. Combined with an API that doesn't really surface this information well, you can quite easily find yourself in a situation with difficult to track down bugs and data loss. This library seeks to address that fundamentally by not encrypting the backing file, allowing more reliable access to your saved data (if less secure, so don't store sensitive data), with some niceties sprinkled on top.
12 |
13 | This means it's great for preferences and collections of data like bird species the user likes, but not for **sensitive** details. Do not store passwords/keys/tokens/secrets/diary entries/grammy's spaghetti recipe, anything that could be considered sensitive user information, as it's not encrypted on the disk. But don't use `UserDefaults` for sensitive details either as `UserDefaults` data is still fully decrypted when the device is locked so long as the user has unlocked the device once after reboot. Instead use `Keychain` for sensitive data.
14 |
15 | As with `UserDefaults`, `TinyStorage` is intended to be used with values that are relatively small. Don't store massive databases in `TinyStorage` as it's not optimized for that, but it's plenty fast for retrieving stored `Codable` types. As a point of reference I'd say keep it under 1 MB.
16 |
17 | This reliable storing of small, non-sensitive data (to me) is what `UserDefaults` was always intended to do well, so this library attempts to realize that vision. It's pretty simple and just a few hundred lines, far from a marvel of filesystem engineering, but just a nice little utility hopefully!
18 |
19 | (Also to be clear, `TinyStorage` is not a wrapper for `UserDefaults`, it is a full replacement. It does not interface with the `UserDefaults` system in any way.)
20 |
21 | ## 🧪 Experimental/beta
22 |
23 | TinyStorage is still in flux/active development, so APIs might change and there's a definite possibility of bugs. I mostly wanted to get it out early into the world in case anyone found it interesting, but consider forking it or pinning a specific version in Swift Package Manager if you don't want it changing a bunch. Feedback/PRs also more than welcome!
24 |
25 | ## Features
26 |
27 | - Reliable access: even on first reboot or in application prewarming states, `TinyStorage` will read and write data properly
28 | - Read and write Swift `Codable` types easily with the API
29 | - Similar to `UserDefaults` uses an in-memory cache on top of the disk store to increase performance
30 | - Thread-safe through an internal `DispatchQueue` so you can safely read/write across threads without having to coordinate that yourself
31 | - Supports storing backing file in shared app container
32 | - Uses `NSFileCoordinator` for coordinating reading/writing to disk so can be used safely across multiple processes at the same time (main target and widget target, for instance)
33 | - When using across multiple processes, will automatically detect changes to file on disk and update accordingly
34 | - SwiftUI property wrapper for easy use in a SwiftUI hierarchy (Similar to `@AppStorage`)
35 | - Can subscribe to to `TinyStorage.didChangeNotification` in `NotificationCenter`
36 | - Uses `OSLog` for logging
37 | - A function to migrate your `UserDefaults` instance to `TinyStorage`
38 |
39 | ## Limitations
40 |
41 | Unlike `UserDefaults`, `TinyStorage` does not support mixed collections, so if you have a bunch of strings, dates, and integers all in the same array in `UserDefaults` without boxing them in a shared type, `TinyStorage` won't work. Same situation with dictionaries, you can use them fine with `TinyStorage` but the key and value must both be a `Codable` type, so you can't use `[String: Any]` for instance where each string key could hold a different type of value.
42 |
43 | ## Installation
44 |
45 | Simply add a **Swift Package Manager** dependency for https://github.com/christianselig/TinyStorage.git
46 |
47 | `TinyStorage` requires iOS 17 or higher due to using the newer `Observation` framework. If you have to support older versions of iOS @newky2k has kindly forked a version that uses the older `@ObservableObject` system! https://github.com/newky2k/TinyStorage
48 |
49 | ## Usage
50 |
51 | First, either initialize an instance of `TinyStorage` or create a singleton and choose firstly where you want the file on disk to live, and secondly the name of the directory that will be created to house the backing plist file (handy if you want to create multiple TinyStorage instances, just give each a different `name`!). To keep with `UserDefaults` convention I normally create a singleton for the app container:
52 |
53 | ```swift
54 | extension TinyStorage {
55 | static let appGroup: TinyStorage = {
56 | let appGroupID = "group.com.christianselig.example"
57 | let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)!
58 | return .init(insideDirectory: containerURL, name: "tiny-storage-general-prefs")
59 | }()
60 | }
61 | ```
62 |
63 | (You can store it wherever you see fit though, in `URL.documentsDirectory` is also an idea for instance!)
64 |
65 | Then, decide how you want to reference your keys, similar to `UserDefaults` you can use raw strings, but I recommend a more strongly-typed approach, where you simply conform a type to `TinyStorageKey` and return a `var rawValue: String` and then you can use it as a key for your storage without worrying about typos. If you're using something like an `enum`, making it a `String` enum gives you this for free, so no extra work!
66 |
67 | After that you can simply read/write values in and out of your `TinyStorge` instance:
68 |
69 | ```swift
70 | enum AppStorageKeys: String, TinyStorageKey {
71 | case likesIceCream
72 | case pet
73 | case hasBeatFirstLevel
74 | }
75 |
76 | // Read
77 | let pet: Pet? = TinyStorage.appGroup.retrieve(type: Pet.self, forKey: AppStorageKeys.pet)
78 |
79 | // Write
80 | TinyStorage.appGroup.store(true, forKey: AppStorageKeys.likesIceCream)
81 | ```
82 |
83 | (If you have some really weird type or don't want to conform to `Codable`, just convert the type to `Data` through whichever means you prefer and store *that*, as `Data` itself is `Codable`.)
84 |
85 | If a value is not present in storage, attempts to retrieve it will always return `nil`. This is in contrast to `UserDefaults` where some primitives like Int or Bool will return 0 or false respectively when not present, rather than `nil`.
86 |
87 | If you want to use it in SwiftUI and have your view automatically respond to changes for an item in your storage, you can use the `@TinyStorageItem` property wrapper. Simply specify your storage, the key for the item you want to access, and specify a default value.
88 |
89 | ```swift
90 | @TinyStorageItem(AppStorageKey.pet, storage: .appGroup)
91 | var pet = Pet(name: "Boots", species: .fish, hasLegs: false)
92 |
93 | var body: some View {
94 | Text(pet.name)
95 | }
96 | ```
97 |
98 | You can even use Bindings to automatically read/write.
99 |
100 | ```swift
101 | @TinyStorageItem(AppStorageKeys.message, storage: .appGroup)
102 | var message: String = ""
103 |
104 | var body: some View {
105 | VStack {
106 | Text("Stored Value: \(message)")
107 | TextField("Message", text: $message)
108 | }
109 | }
110 | ```
111 |
112 | It also addresses some of the annoyances of `@AppStorage`, such as not being able to store collections:
113 |
114 | ```swift
115 | @TinyStorageItem("names", storage: .appGroup)
116 | var names: [String] = []
117 | ```
118 |
119 | Or better support for optional values:
120 |
121 | ```swift
122 | @TinyStorageItem("nickname", storage: .appGroup)
123 | var nickname: String? = nil // or "Cool Guy"
124 | ```
125 |
126 | You can also migrate from a `UserDefaults` instance to `TinyStorage` with a handy helper function:
127 |
128 | ```swift
129 | let nonBoolKeysToMigrate = ["favoriteIceCream", "appFontSize", "lastFetchDate"]
130 | let boolKeysToMigrate = ["hasEatenIceCreamRecently", "usesCustomTheme"]
131 |
132 | TinyStorage.appGroup.migrate(userDefaults: .standard, nonBoolKeys: nonBoolKeysToMigrate, boolKeys: boolKeysToMigrate, overwriteIfConflict: true)
133 | ```
134 |
135 | Note that you have to specify which keys correspond to boolean values and which do not, as `UserDefaults` itself just stores booleans as integers behind the scenes and as part of migration we want to store booleans as proper Swift `Bool` types, and weTinyStorage unable to know if a `1` in `UserDefaults` is supposed to represent `true` or actually `1` without your input. Read the `migrate` function documentation for other important details!
136 |
137 | If you want to migrate multiple keys manually or store a bunch of things at once, rather than a bunch of single `store` calls you can consolidate them into one call with `bulkStore` which will only write to disk the once:
138 |
139 | ```swift
140 | TinyStorage.appGroup.bulkStore(items: [
141 | AppStorageKeys.pet: pet,
142 | AppStorageKeys.theme: "sunset"
143 | ], skipKeyIfAlreadyPresent: false)
144 | ```
145 |
146 | (`skipKeyIfAlreadyPresent` when set to `true` creates an API akin to `registerDefaults` from `UserDefaults`.)
147 |
148 | Happy storage and hope you enjoy! 💾
149 |
--------------------------------------------------------------------------------
/Sources/TinyStorage/TinyStorage.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TinyStorage.swift
3 | // TinyStorage
4 | //
5 | // Created by Christian Selig on 2024-10-06.
6 | //
7 |
8 | import Foundation
9 | import SwiftUI
10 | import OSLog
11 |
12 | /// Similar to UserDefaults, but does not use disk encryption so files are more readily accessible.
13 | ///
14 | /// Implementation details to consider:
15 | ///
16 | /// - For more performant retrieval, data is also stored in memory
17 | /// - Backed by NSFileCoordinator for disk access, so thread safe and inter-process safe, and backed by a serial dispatch queue for in-memory access
18 | /// - Does NOT use NSFilePresenter due to me being scared. Uses DispatchSource to watch and respond to changes to the backing file.
19 | /// - Internally data is stored as [String: Data] where Data is expected to be Codable (otherwise will error), this is to minimize needing to unmarshal the entire top-level dictionary into Codable objects for each key request/write. We store this [String: Data] object as a binary plist to disk as [String: Data] is not JSON encodable due to Data not being JSON
20 | /// - Uses OSLog for logging
21 | @Observable
22 | public final class TinyStorage: @unchecked Sendable {
23 | private let directoryURL: URL
24 | public let fileURL: URL
25 |
26 | /// Private in-memory store so each request doesn't have to go to disk.
27 | /// Note that as Data is stored (implementation oddity, using Codable you can't encode an abstract [String: any Codable] to Data) rather than the Codable object directly, it is decoded before being returned.
28 | private var dictionaryRepresentation: [String: Data]
29 |
30 | /// Coordinates access to in-memory store
31 | private let dispatchQueue = DispatchQueue(label: "TinyStorageInMemory")
32 |
33 | private var source: DispatchSourceFileSystemObject?
34 |
35 | public static let didChangeNotification = Notification.Name(rawValue: "com.christianselig.TinyStorage.didChangeNotification")
36 | private let logger: Logger
37 |
38 | /// True if this instance of TinyStorage is being created for use in an Xcode SwiftUI Preview, which as of Xcode 16 does not seem to like creating files (so we'll just store things in memory as a work around) nor does it like file watching/monitoring. See [#8](https://github.com/christianselig/TinyStorage/issues/8).
39 | private static let isBeingUsedInXcodePreview: Bool = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
40 |
41 | /// All keys currently present in storage
42 | public var allKeys: [any TinyStorageKey] {
43 | dispatchQueue.sync { return Array(dictionaryRepresentation.keys) }
44 | }
45 |
46 | /// Initialize an instance of `TinyStorage` for you to use.
47 | ///
48 | /// - Parameters:
49 | /// - insideDirectory: The directory where the directory and backing plist file for this TinyStorage instance will live.
50 | /// - name: The name of the directory that will be created to store the backing plist file.
51 | ///
52 | /// - Note: TinyStorage creates a directory that the backing plist files lives in, for instance if you specify your name as "tinystorage-general-prefs" the file will live in ./tiny-storage-general-prefs/tiny-storage.plist where . is the directory you pass as `insideDirectory`.
53 | public init(insideDirectory: URL, name: String) {
54 | let directoryURL = insideDirectory.appending(path: name, directoryHint: .isDirectory)
55 | self.directoryURL = directoryURL
56 |
57 | let fileURL = directoryURL.appending(path: "tiny-storage.plist", directoryHint: .notDirectory)
58 | self.fileURL = fileURL
59 |
60 | let logger = Logger(subsystem: "com.christianselig.TinyStorage", category: "general")
61 | self.logger = logger
62 |
63 | self.dictionaryRepresentation = TinyStorage.retrieveStorageDictionary(directoryURL: directoryURL, fileURL: fileURL, logger: logger) ?? [:]
64 |
65 | logger.debug("Initialized with file path: \(fileURL.path())")
66 |
67 | setUpFileWatch()
68 | }
69 |
70 | deinit {
71 | logger.info("Deinitializing TinyStorage")
72 | source?.cancel()
73 | }
74 |
75 | // MARK: - Public API
76 |
77 | /// Retrieve a value from storage at the given key and decode it as the given type, throwing if there are any errors in attemping to retrieve. Note that it will not throw if the key simply holds nothing currently, and instead will return nil. This function is a handy alternative to `retrieve` if you want more information about errors, for instance if you can recover from them or if you want to log errors to your own logger.
78 | ///
79 | /// - Parameters:
80 | /// - type: The `Codable`-conforming type that the retrieved value should be decoded into.
81 | /// - key: The key at which the value is stored.
82 | public func retrieveOrThrow(type: T.Type, forKey key: any TinyStorageKey) throws -> T? {
83 | return try dispatchQueue.sync {
84 | guard let data = dictionaryRepresentation[key.rawValue] else {
85 | logger.info("No key \(key.rawValue, privacy: .private) found in storage")
86 | return nil
87 | }
88 |
89 | if type == Data.self {
90 | // This should never fail, as Data is Codable and the the type is Data, but the compiler does not know that T is explicitly data (and I don't believe there's a way to mark this) so the cast is required
91 | return data as? T
92 | } else {
93 | return try JSONDecoder().decode(T.self, from: data)
94 | }
95 | }
96 | }
97 |
98 | /// Retrieve a value from storage at the given key and decode it as the given type, but unlike `retrieveOrThrow` this function acts like `UserDefaults` in that it discards errors and simply returns nil. See `retrieveOrThrow` if you would to have more insight into errors.
99 | ///
100 | /// - Parameters:
101 | /// - type: The `Codable`-conforming type that the retrieved value should be decoded into.
102 | /// - key: The key at which the value is stored.
103 | public func retrieve(type: T.Type, forKey key: any TinyStorageKey) -> T? {
104 | do {
105 | return try retrieveOrThrow(type: type, forKey: key)
106 | } catch {
107 | logger.error("Error retrieving JSON data for key: \(key.rawValue, privacy: .private), for type: \(String(reflecting: type)), with error: \(error)")
108 | assertionFailure()
109 | return nil
110 | }
111 | }
112 |
113 | /// Helper function that retrieves the object at the key and if it's a non-nil `Bool` will return its value, but if it's `nil`, will return `false`. Akin to `UserDefaults`' `bool(forKey:)` method.
114 | ///
115 | /// - Note: If there is a type mismatch (for instance the object stored at the key is a `Double`) will still return `false`, so you need to have confidence it was stored correctly.
116 | public func bool(forKey key: any TinyStorageKey) -> Bool {
117 | retrieve(type: Bool.self, forKey: key) ?? false
118 | }
119 |
120 | /// Helper function that retrieves the object at the key and if it's a non-nil `Int` will return its value, but if it's `nil`, will return `0`. Akin to `UserDefaults`' `integer(forKey:)` method.
121 | ///
122 | /// - Note: If there is a type mismatch (for instance the object stored at the key is a `String`) will still return `0`, so you need to have confidence it was stored correctly.
123 | public func integer(forKey key: any TinyStorageKey) -> Int {
124 | retrieve(type: Int.self, forKey: key) ?? 0
125 | }
126 |
127 | /// Helper function that retrieves the object at the key and increments it before saving it back to storage and returns the newly incremented value. If no value is present at the key or there is a non `Int` value stored at the key, this function will assume you intended to initialize the value and thus write `1` to the key.
128 | @discardableResult
129 | public func incrementInteger(forKey key: any TinyStorageKey, by incrementBy: Int = 1) -> Int {
130 | if var value = retrieve(type: Int.self, forKey: key) {
131 | value += incrementBy
132 | store(value, forKey: key)
133 | return value
134 | } else {
135 | store(1, forKey: key)
136 | return 1
137 | }
138 | }
139 |
140 | /// Stores a given value to disk (or removes if nil), throwing errors that occur while attempting to store. Note that thrown errors do not include errors thrown while writing the actual value to disk, only for the in-memory aspect.
141 | ///
142 | /// - Parameters:
143 | /// - value: The `Codable`-conforming instance to store.
144 | /// - key: The key that the value will be stored at.
145 | public func storeOrThrow(_ value: Codable?, forKey key: any TinyStorageKey) throws {
146 | if let value {
147 | // Encode the Codable object back to Data before storing in memory and on disk
148 | let valueData: Data
149 |
150 | if let data = value as? Data {
151 | // Given value is already of type Data, so use directly
152 | valueData = data
153 | } else {
154 | valueData = try JSONEncoder().encode(value)
155 | }
156 |
157 | dispatchQueue.sync {
158 | dictionaryRepresentation[key.rawValue] = valueData
159 |
160 | storeToDisk()
161 | }
162 | } else {
163 | dispatchQueue.sync {
164 | dictionaryRepresentation.removeValue(forKey: key.rawValue)
165 | storeToDisk()
166 | }
167 | }
168 |
169 | NotificationCenter.default.post(name: Self.didChangeNotification, object: self, userInfo: nil)
170 | }
171 |
172 | /// Stores a given value to disk (or removes if nil). Unlike `storeOrThrow` this function is akin to `set` in `UserDefaults` in that any errors thrown are discarded. If you would like more insight into errors see `storeOrThrow`.
173 | ///
174 | /// - Parameters:
175 | /// - value: The `Codable`-conforming instance to store.
176 | /// - key: The key that the value will be stored at.
177 | public func store(_ value: Codable?, forKey key: any TinyStorageKey) {
178 | do {
179 | try storeOrThrow(value, forKey: key)
180 | } catch {
181 | logger.error("Error storing key: \(key.rawValue, privacy: .private), with value: \(String(describing: value), privacy: .private), with error: \(error)")
182 | assertionFailure()
183 | }
184 | }
185 |
186 | /// Removes the value for the given key
187 | public func remove(key: any TinyStorageKey) {
188 | store(nil, forKey: key)
189 | }
190 |
191 | /// Completely resets the storage, removing all values
192 | public func reset() {
193 | guard !Self.isBeingUsedInXcodePreview else { return }
194 |
195 | let coordinator = NSFileCoordinator()
196 | var coordinatorError: NSError?
197 | var successfullyRemoved = false
198 |
199 | dispatchQueue.sync {
200 | coordinator.coordinate(writingItemAt: fileURL, options: [.forDeleting], error: &coordinatorError) { url in
201 | do {
202 | try FileManager.default.removeItem(at: url)
203 | successfullyRemoved = true
204 | } catch {
205 | logger.error("Error removing storage file: \(error)")
206 | assertionFailure()
207 | successfullyRemoved = false
208 | }
209 | }
210 | }
211 |
212 | if let coordinatorError {
213 | logger.error("Error coordinating storage file removal: \(coordinatorError)")
214 | assertionFailure()
215 | return
216 | } else if !successfullyRemoved {
217 | logger.error("Unable to remove storage file")
218 | assertionFailure()
219 | return
220 | }
221 |
222 | NotificationCenter.default.post(name: Self.didChangeNotification, object: self, userInfo: nil)
223 | }
224 |
225 | /// Migrates specified keys from the specified instance of `UserDefaults` into this instance of `TinyStorage` and stores to disk. As `UserDefaults` stores boolean values as 0 or 1 behind the scenes (so it's impossible to know if `1` refers to `true` or the integer value `1`, and `Codable` does care), you will need to specify which keys store Bool and which store non-boolean values in order for the migration to occur. If the key's value is improperly matched or unable to be decoded the logger will print an error and the key will be skipped.
226 | ///
227 | /// - Parameters:
228 | /// - userDefaults: The instance of `UserDefaults` to migrate.
229 | /// - nonBoolKeys: Keys for items in `UserDefaults` that store non-Bool values (eg: Strings, Codable types, Ints, etc.)
230 | /// - boolKeys: Keys for items in `UserDefaults` that store Bool values (ie: `true`/`false`). Arrays and Dictionaries of `Bool` also go here.
231 | /// - overwriteTinyStorageIfConflict: If `true` and a key exists both in this `TinyStorage` instance and the passed `UserDefaults`, the `UserDefaults` value will overwrite `TinyStorage`'s.
232 | ///
233 | /// ## Notes
234 | ///
235 | /// 1. This function leaves the contents of `UserDefaults` intact, if you want `UserDefaults` erased you will need to do that yourself.
236 | /// 2. **It is up to you** to determine that `UserDefaults` is in a valid state prior to calling `migrate`, it is recommended you check both `UIApplication.isProtectedDataAvailable` is `true` and that a trusted key is present (with a value) in your `UserDefaults` instance.
237 | /// 3. You should store a flag (for instance in `TinyStorage`) that this migration is complete once finished so you don't call this function repeatedly.
238 | /// 4. This `migrate` function does not support nested collections due to Swift not having any `AnyCodable` type and the complication in supporting deeply nested types. That means `[String: Any]` is fine, provided `Any` is not another array or dictionary. The same applies to Arrays, `[String]` is okay but `[[String]]` is not. This includes arrays of dictionaries. This does not mean `TinyStorage` itself does not support nested collections (it does), however the migrator does not. You are still free to migrate these types manually as a result (in which case look at the `bulkStore` function).
239 | /// 5. As TinyStorage does not support mixed collection types, neither does this `migrate` function. For instance an array of `[Any]` that contains both a `String` and `Int` is invalid.
240 | /// 6. TinyStorage encodes all floating point values as `Double` and all integer values as `Int`, but this does not preclude you from retrieving floating points as `Float32`, or integers as `Int8` for instance provided they fit.
241 | /// 7. If a value that is intended to be a `Double` is encoded as an `Int` (for instance, it just happens to be `6.0` at time of migration and it thus stored as an `Int`), this is not of concern as you can still retrieve this as a `Double` after the fact.
242 | /// 8. This function could theoretically fetch all the keys in `UserDefaults`, but `UserDefaults` stores a lot of data that Apple/iOS put in there that doesn't necessarily pertain to your app/need to be stored in `TinyStorage`, so it's required that you pass a set of keys for the keys you want to migrate.
243 | /// 9. `UserDefaults` has functions `integer/double(forKey:)` and a corresponding `Bool` method that return `0` and `false` respectively if no key is present (as does TinyStorage) but as part of migration TinyStorage will not store 0/false, for the value if the key is not present, it will simply return skip the key, storing nothing for it.
244 | public func migrate(userDefaults: UserDefaults, nonBoolKeys: Set, boolKeys: Set, overwriteTinyStorageIfConflict: Bool) {
245 | dispatchQueue.sync {
246 | logger.info("Migrating Bool keys")
247 |
248 | for boolKey in boolKeys {
249 | guard let object = userDefaults.object(forKey: boolKey) else {
250 | logger.warning("Requested migration of \(boolKey) but it was not found in your UserDefaults instance")
251 | continue
252 | }
253 |
254 | if shouldSkipKeyDuringMigration(key: boolKey, overwriteTinyStorageIfConflict: overwriteTinyStorageIfConflict) {
255 | continue
256 | }
257 |
258 | migrateBool(boolKey: boolKey, object: object)
259 | }
260 |
261 | logger.info("Migrating non-Bool keys")
262 |
263 | for nonBoolKey in nonBoolKeys {
264 | guard let object = userDefaults.object(forKey: nonBoolKey) else {
265 | logger.warning("Requested migration of \(nonBoolKey) but it was not found in your UserDefaults instance")
266 | continue
267 | }
268 |
269 | if shouldSkipKeyDuringMigration(key: nonBoolKey, overwriteTinyStorageIfConflict: overwriteTinyStorageIfConflict) {
270 | continue
271 | }
272 |
273 | migrateNonBool(nonBoolKey: nonBoolKey, object: object)
274 | }
275 |
276 | storeToDisk()
277 | logger.info("Completed migration of bool and non-bool keys from UserDefaults")
278 | }
279 |
280 | NotificationCenter.default.post(name: Self.didChangeNotification, object: self, userInfo: nil)
281 | }
282 |
283 | /// Store multiple items at once, which will only result in one disk write, rather than a disk write for each individual storage as would happen if you called `store` on many individual items. Handy during a manual migration. Also supports removal by setting a key to `nil`.
284 | ///
285 | /// - Parameters:
286 | /// - items: An array of items to store with a single disk write, Codable is optional so users can set keys to nil as an indication to remove them from storage.
287 | /// - skipKeyIfAlreadyPresent: If `true` (default value is `false`) and the key is already present in the existing store, the new value will not be stored. This turns this function into something akin to `UserDefaults`' `registerDefaults` function, handy for setting up initial values, such as a guess at a user's preferred temperature unit (Celisus or Fahrenheit) based on device locale.
288 | ///
289 | /// - Note: From what I understand Codable is already inherently optional due to Optional being Codable so this just makes it more explicit to the compiler so we can unwrap it easier, in other words there's no way to make it so folks can't pass in non-optional Codables when used as an existential (see: https://mastodon.social/@christianselig/113279213464286112)
290 | public func bulkStore(items: [U: (any Codable)?], skipKeyIfAlreadyPresent: Bool = false) {
291 | dispatchQueue.sync {
292 | for item in items {
293 | if skipKeyIfAlreadyPresent && dictionaryRepresentation[item.key.rawValue] != nil { continue }
294 |
295 | let valueData: Data
296 |
297 | if let itemValue = item.value {
298 | if let data = item.value as? Data {
299 | // Given value is already of type Data, so use directly
300 | valueData = data
301 | } else {
302 | do {
303 | valueData = try JSONEncoder().encode(itemValue)
304 | } catch {
305 | logger.error("Error bulk encoding new value for migration: \(String(describing: itemValue), privacy: .private), with error: \(error)")
306 | assertionFailure()
307 | continue
308 | }
309 | }
310 | } else {
311 | // Nil value, indicating desire to remove
312 | dictionaryRepresentation.removeValue(forKey: item.key.rawValue)
313 | continue
314 | }
315 |
316 | dictionaryRepresentation[item.key.rawValue] = valueData
317 | }
318 |
319 | storeToDisk()
320 | }
321 |
322 | NotificationCenter.default.post(name: Self.didChangeNotification, object: self, userInfo: nil)
323 | }
324 |
325 | // MARK: - Internal API
326 |
327 | private static func createEmptyStorageFile(directoryURL: URL, fileURL: URL, logger: Logger) -> Bool {
328 | // First, create the empty data
329 | let storageDictionaryData: Data
330 |
331 | do {
332 | let emptyStorage: [String: Data] = [:]
333 | storageDictionaryData = try PropertyListSerialization.data(fromPropertyList: emptyStorage, format: .binary, options: 0)
334 | } catch {
335 | logger.error("Error turning storage dictionary into Data: \(error)")
336 | assertionFailure()
337 | return false
338 | }
339 |
340 | // Now create the directory that our file lives within. We create the file inside a directory as DispatchSource's file monitoring when used with atomic writing works better if monitoring a directory rather than a file directly
341 | // Important to not pass nil as the filePresenter: https://khanlou.com/2019/03/file-coordination/
342 | let coordinator = NSFileCoordinator()
343 | var directoryCoordinatorError: NSError?
344 | var directoryCreatedSuccessfully = false
345 |
346 | coordinator.coordinate(writingItemAt: directoryURL, options: [], error: &directoryCoordinatorError) { url in
347 | do {
348 | try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: false, attributes: [.protectionKey: FileProtectionType.none])
349 | directoryCreatedSuccessfully = true
350 | } catch {
351 | let nsError = error as NSError
352 |
353 | if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileWriteFileExistsError {
354 | // Shouldn't happen, but just to be safe
355 | logger.info("Directory had already been created so continuing")
356 | directoryCreatedSuccessfully = true
357 | } else {
358 | logger.error("Error creating directory: \(error)")
359 | assertionFailure()
360 | directoryCreatedSuccessfully = false
361 | }
362 | }
363 | }
364 |
365 | if let directoryCoordinatorError {
366 | logger.error("Unable to coordinate creation of directory: \(directoryCoordinatorError)")
367 | assertionFailure()
368 | return false
369 | }
370 |
371 | guard directoryCreatedSuccessfully else {
372 | logger.error("Returning due to directory creation failing")
373 | assertionFailure()
374 | return false
375 | }
376 |
377 | // Now create the file within that directory
378 | var fileCoordinatorError: NSError?
379 | var fileCreatedSuccessfully = false
380 |
381 | coordinator.coordinate(writingItemAt: fileURL, options: [.forReplacing], error: &fileCoordinatorError) { url in
382 | fileCreatedSuccessfully = FileManager.default.createFile(atPath: url.path(), contents: storageDictionaryData, attributes: [.protectionKey: FileProtectionType.none])
383 | }
384 |
385 | if let fileCoordinatorError {
386 | logger.error("Error coordinating file writing: \(fileCoordinatorError)")
387 | assertionFailure()
388 | return false
389 | }
390 |
391 | return fileCreatedSuccessfully
392 | }
393 |
394 | /// Writes the in-memory store to disk.
395 | ///
396 | /// - Note: should only be called with a DispatchQueue lock on `dictionaryRepresentation``.
397 | private func storeToDisk() {
398 | guard !Self.isBeingUsedInXcodePreview else { return }
399 |
400 | let storageDictionaryData: Data
401 |
402 | do {
403 | storageDictionaryData = try PropertyListSerialization.data(fromPropertyList: dictionaryRepresentation, format: .binary, options: 0)
404 | } catch {
405 | logger.error("Error turning storage dictionary into Data: \(error)")
406 | assertionFailure()
407 | return
408 | }
409 |
410 | let coordinator = NSFileCoordinator()
411 | var coordinatorError: NSError?
412 |
413 | coordinator.coordinate(writingItemAt: fileURL, options: [.forReplacing], error: &coordinatorError) { url in
414 | do {
415 | try storageDictionaryData.write(to: url, options: [.atomic, .noFileProtection])
416 | } catch {
417 | logger.error("Error writing storage dictionary data: \(error)")
418 | assertionFailure()
419 | }
420 | }
421 |
422 | if let coordinatorError {
423 | logger.error("Error coordinating writing to storage file: \(coordinatorError)")
424 | assertionFailure()
425 | }
426 | }
427 |
428 | /// Retrieves from disk the internal storage dictionary that serves as the basis for the storage structure. Static function so it can be called by `init`.
429 | private static func retrieveStorageDictionary(directoryURL: URL, fileURL: URL, logger: Logger) -> [String: Data]? {
430 | let coordinator = NSFileCoordinator()
431 | var storageDictionaryData: Data?
432 | var coordinatorError: NSError?
433 | var needToCreateFile = false
434 |
435 | coordinator.coordinate(readingItemAt: fileURL, options: [], error: &coordinatorError) { url in
436 | do {
437 | storageDictionaryData = try Data(contentsOf: url)
438 | } catch {
439 | let nsError = error as NSError
440 |
441 | if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError {
442 | needToCreateFile = true
443 | } else {
444 | logger.error("Error fetching TinyStorage file data: \(error)")
445 | assertionFailure()
446 | return
447 | }
448 | }
449 | }
450 |
451 | if let coordinatorError {
452 | logger.error("Error coordinating access to read file: \(coordinatorError)")
453 | assertionFailure()
454 | return nil
455 | } else if needToCreateFile {
456 | if Self.isBeingUsedInXcodePreview {
457 | // Don't create files when being used in an Xcode preview
458 | return [:]
459 | } else if createEmptyStorageFile(directoryURL: directoryURL, fileURL: fileURL, logger: logger) {
460 | logger.info("Successfully created empty TinyStorage file at \(fileURL)")
461 |
462 | // We just created it, so it would be empty
463 | return [:]
464 | } else {
465 | logger.error("Tried and failed to create TinyStorage file at \(fileURL.absoluteString) after attempting to retrieve it")
466 | assertionFailure()
467 | return nil
468 | }
469 | }
470 |
471 | guard let storageDictionaryData else { return nil }
472 |
473 | let storageDictionary: [String: Data]
474 |
475 | do {
476 | guard let properlyTypedDictionary = try PropertyListSerialization.propertyList(from: storageDictionaryData, format: nil) as? [String: Data] else {
477 | logger.error("JSON data is not of type [String: Data]")
478 | assertionFailure()
479 | return nil
480 | }
481 |
482 | storageDictionary = properlyTypedDictionary
483 | } catch {
484 | logger.error("Error decoding storage dictionary from Data: \(error)")
485 | assertionFailure()
486 | return nil
487 | }
488 |
489 | return storageDictionary
490 | }
491 |
492 | /// Sets up the monitoring of the file on disk for any changes, so we can detect when other processes modify it
493 | private func setUpFileWatch() {
494 | // Xcode Previews also do not like file watching it seems
495 | guard !Self.isBeingUsedInXcodePreview else { return }
496 |
497 | // Watch the directory rather than the file, as atomic writing will delete the old file which makes tracking it difficult otherwise
498 | let fileSystemRepresentation = FileManager.default.fileSystemRepresentation(withPath: directoryURL.path())
499 | let fileDescriptor = open(fileSystemRepresentation, O_EVTONLY)
500 |
501 | guard fileDescriptor > 0 else {
502 | logger.error("Failed to set up file watch due to invalid file descriptor")
503 | assertionFailure()
504 | return
505 | }
506 |
507 | // Even though atomic deletes the file, DispatchSource still picks this up as a write change, rather than a delete
508 | let source = DispatchSource.makeFileSystemObjectSource(fileDescriptor: fileDescriptor, eventMask: [.write], queue: .main)
509 | self.source = source
510 |
511 | source.setEventHandler { [weak self] in
512 | // Note that this will trigger even for changes we make within the same process (as these calls come in somewhat delayed versus when we change this with NSFileCoordinator), which will lead to this triggering and re-reading from disk again, which is wasteful, but I can't think of a way to get around this without poking holes in DispatchSource that would make it unreliable
513 | self?.processFileChangeEvent()
514 | }
515 |
516 | source.setCancelHandler {
517 | close(fileDescriptor)
518 | }
519 |
520 | source.resume()
521 | }
522 |
523 | /// Responds to the file change event
524 | private func processFileChangeEvent() {
525 | logger.info("Processing a files changed event")
526 | var actualChangeOccurred = false
527 |
528 | dispatchQueue.sync {
529 | let newDictionaryRepresentation = TinyStorage.retrieveStorageDictionary(directoryURL: directoryURL, fileURL: fileURL, logger: logger) ?? [:]
530 |
531 | // Ensure something actually changed
532 | if self.dictionaryRepresentation != newDictionaryRepresentation {
533 | self.dictionaryRepresentation = newDictionaryRepresentation
534 | actualChangeOccurred = true
535 | }
536 | }
537 |
538 | if actualChangeOccurred {
539 | NotificationCenter.default.post(name: Self.didChangeNotification, object: self, userInfo: nil)
540 | }
541 | }
542 |
543 | /// Helper function to perform a preliminary check if a key should be skipped for migration
544 | private func shouldSkipKeyDuringMigration(key: String, overwriteTinyStorageIfConflict: Bool) -> Bool {
545 | if dictionaryRepresentation[key] != nil {
546 | if overwriteTinyStorageIfConflict {
547 | logger.info("Preparing to overwrite existing key \(key) during migration due to UserDefaults having the same key")
548 | return false
549 | } else {
550 | logger.info("Skipping key \(key) during migration due to UserDefaults having the same key and overwriting is disabled")
551 | return true
552 | }
553 | } else {
554 | return false
555 | }
556 | }
557 |
558 | /// Helper function as part of migrate to migrate specifically boolean values, must be called within dispatchQueue to synchronize access
559 | private func migrateBool(boolKey: String, object: Any) {
560 | // How UserDefaults treats Bool: when using object(forKey) it will return the integer value, and thus any value other than 0 or 1 will fail to cast as Bool and return nil (2, 2.5, etc. would return nil). However if you call bool(forKey) in UserDefaults anything non-zero will return true, including 1, 0.5, 1.5, 2, -10, etc., and mismatched values such as a string will return false.
561 | // As a result we are using object(forKey:) for more granularity that to hopefully catch potential user error
562 | if let boolValue = object as? Bool {
563 | do {
564 | let data = try JSONEncoder().encode(boolValue)
565 | dictionaryRepresentation[boolKey] = data
566 | } catch {
567 | logger.error("Error encoding Bool to Data so could not migrate \(boolKey): \(error)")
568 | assertionFailure()
569 | }
570 | } else if let boolArray = object as? [Bool] {
571 | do {
572 | let data = try JSONEncoder().encode(boolArray)
573 | dictionaryRepresentation[boolKey] = data
574 | } catch {
575 | logger.error("Error encoding Bool-array to Data so could not migrate \(boolKey, privacy: .private): \(error)")
576 | assertionFailure()
577 | }
578 | } else if let boolDictionary = object as? [String: Bool] {
579 | // Reminder that strings are the only type UserDefaults permits as dictionary keys
580 | do {
581 | let data = try JSONEncoder().encode(boolDictionary)
582 | dictionaryRepresentation[boolKey] = data
583 | } catch {
584 | logger.error("Error encoding Bool-dictionary to Data so could not migrate \(boolKey, privacy: .private): \(error)")
585 | assertionFailure()
586 | }
587 | } else if let _ = object as? [[Any]] {
588 | logger.error("Designated object at key \(boolKey) as Bool but gave nested array and nested collections are not a supported migration type, please perform migration manually")
589 | assertionFailure()
590 | } else if let _ = object as? [String: Any] {
591 | logger.error("Designated object at key \(boolKey) as Bool but gave nested dictionary and nested collections are not a supported migration type, please perform migration manually")
592 | assertionFailure()
593 | } else {
594 | logger.error("Designated object at key \(boolKey) as Bool but is actually \(type(of: object)) with value \(String(describing: object), privacy: .private) therefore not migrating")
595 | assertionFailure()
596 | return
597 | }
598 | }
599 |
600 | /// Helper function as part of migrate to migrate specifically non-boolean values, must be called within dispatchQueue to synchronize access
601 | private func migrateNonBool(nonBoolKey: String, object: Any) {
602 | // UserDefaults objects must be a property list type per Apple documentation https://developer.apple.com/documentation/foundation/userdefaults#2926904
603 | if let integerObject = object as? Int {
604 | do {
605 | let data = try JSONEncoder().encode(integerObject)
606 | dictionaryRepresentation[nonBoolKey] = data
607 | } catch {
608 | logger.error("Error encoding Int to Data so could not migrate \(nonBoolKey): \(error)")
609 | assertionFailure()
610 | }
611 | } else if let doubleObject = object as? Double {
612 | do {
613 | let data = try JSONEncoder().encode(doubleObject)
614 | dictionaryRepresentation[nonBoolKey] = data
615 | } catch {
616 | logger.error("Error encoding Double to Data so could not migrate \(nonBoolKey): \(error)")
617 | assertionFailure()
618 | }
619 | } else if let stringObject = object as? String {
620 | do {
621 | let data = try JSONEncoder().encode(stringObject)
622 | dictionaryRepresentation[nonBoolKey] = data
623 | } catch {
624 | logger.error("Error encoding String to Data so could not migrate \(nonBoolKey): \(error)")
625 | assertionFailure()
626 | }
627 | } else if let dateObject = object as? Date {
628 | do {
629 | let data = try JSONEncoder().encode(dateObject)
630 | dictionaryRepresentation[nonBoolKey] = data
631 | } catch {
632 | logger.error("Error encoding String to Data so could not migrate \(nonBoolKey): \(error)")
633 | assertionFailure()
634 | }
635 | } else if let arrayObject = object as? [Any] {
636 | // Note that mixed arrays are not permitted as there's not a native AnyCodable type in Swift
637 | if let integerArray = arrayObject as? [Int] {
638 | do {
639 | let data = try JSONEncoder().encode(integerArray)
640 | dictionaryRepresentation[nonBoolKey] = data
641 | } catch {
642 | logger.error("Error encoding Integer array to Data so could not migrate \(nonBoolKey): \(error)")
643 | assertionFailure()
644 | }
645 | } else if let doubleArray = arrayObject as? [Double] {
646 | do {
647 | let data = try JSONEncoder().encode(doubleArray)
648 | dictionaryRepresentation[nonBoolKey] = data
649 | } catch {
650 | logger.error("Error encoding Double array to Data so could not migrate \(nonBoolKey): \(error)")
651 | assertionFailure()
652 | }
653 | } else if let stringArray = arrayObject as? [String] {
654 | do {
655 | let data = try JSONEncoder().encode(stringArray)
656 | dictionaryRepresentation[nonBoolKey] = data
657 | } catch {
658 | logger.error("Error encoding String array to Data so could not migrate \(nonBoolKey): \(error)")
659 | assertionFailure()
660 | }
661 | } else if let dateArray = arrayObject as? [Date] {
662 | do {
663 | let data = try JSONEncoder().encode(dateArray)
664 | dictionaryRepresentation[nonBoolKey] = data
665 | } catch {
666 | logger.error("Error encoding Date array to Data so could not migrate \(nonBoolKey): \(error)")
667 | assertionFailure()
668 | }
669 | } else if let _ = arrayObject as? [[String: Any]] {
670 | logger.error("Nested collection types (in this case: dictionary inside array) are not supported by the migrator so \(nonBoolKey) was not migrated, please perform migration manually")
671 | assertionFailure()
672 | } else if let _ = arrayObject as? [[Any]] {
673 | logger.error("Nested collection types (in this case: array inside array) are not supported by the migrator so \(nonBoolKey) was not migrated, please perform migration manually")
674 | assertionFailure()
675 | } else if let dataArray = arrayObject as? [Data] {
676 | do {
677 | let data = try JSONEncoder().encode(dataArray)
678 | dictionaryRepresentation[nonBoolKey] = data
679 | } catch {
680 | logger.error("Error encoding Data array to Data so could not migrate \(nonBoolKey): \(error)")
681 | assertionFailure()
682 | }
683 | } else {
684 | logger.error("Mixed array type not supported in TinyStorage so not migrating \(nonBoolKey)")
685 | assertionFailure()
686 | }
687 | } else if let dictionaryObject = object as? [String: Any] {
688 | // Note that string dictionaries are the only supported dictionary type in UserDefaults
689 | // Also note that mixed dictionary values are not permitted as there's not a native AnyCodable type in Swift
690 | if let integerDictionary = dictionaryObject as? [String: Int] {
691 | do {
692 | let data = try JSONEncoder().encode(integerDictionary)
693 | dictionaryRepresentation[nonBoolKey] = data
694 | } catch {
695 | logger.error("Error encoding Integer dictionary to Data so could not migrate \(nonBoolKey): \(error)")
696 | assertionFailure()
697 | }
698 | } else if let doubleDictionary = dictionaryObject as? [String: Double] {
699 | do {
700 | let data = try JSONEncoder().encode(doubleDictionary)
701 | dictionaryRepresentation[nonBoolKey] = data
702 | } catch {
703 | logger.error("Error encoding Double dictionary to Data so could not migrate \(nonBoolKey): \(error)")
704 | assertionFailure()
705 | }
706 | } else if let stringDictionary = dictionaryObject as? [String: String] {
707 | do {
708 | let data = try JSONEncoder().encode(stringDictionary)
709 | dictionaryRepresentation[nonBoolKey] = data
710 | } catch {
711 | logger.error("Error encoding String dictionary to Data so could not migrate \(nonBoolKey): \(error)")
712 | assertionFailure()
713 | }
714 | } else if let dateDictionary = dictionaryObject as? [String: Date] {
715 | do {
716 | let data = try JSONEncoder().encode(dateDictionary)
717 | dictionaryRepresentation[nonBoolKey] = data
718 | } catch {
719 | logger.error("Error encoding Date dictionary to Data so could not migrate \(nonBoolKey): \(error)")
720 | assertionFailure()
721 | }
722 | } else if let dataDictionary = dictionaryObject as? [String: Data] {
723 | do {
724 | let data = try JSONEncoder().encode(dataDictionary)
725 | dictionaryRepresentation[nonBoolKey] = data
726 | } catch {
727 | logger.error("Error encoding Data dictionary to Data so could not migrate \(nonBoolKey): \(error)")
728 | assertionFailure()
729 | }
730 | } else if let _ = dictionaryObject as? [String: [String: Any]] {
731 | logger.error("Nested collection types (in this case: dictionary inside dictionary) are not supported by the migrator so \(nonBoolKey) was not migrated, please perform migration manually")
732 | assertionFailure()
733 | } else if let _ = dictionaryObject as? [String: [Any]] {
734 | logger.error("Nested collection types (in this case: array inside dictionary) are not supported by the migrator so \(nonBoolKey) was not migrated, please perform migration manually")
735 | assertionFailure()
736 | } else {
737 | logger.error("Mixed dictionary type not supported in TinyStorage so not migrating \(nonBoolKey)")
738 | assertionFailure()
739 | }
740 | } else if let dataObject = object as? Data {
741 | dictionaryRepresentation[nonBoolKey] = dataObject
742 | } else {
743 | logger.error("Unknown type found in UserDefaults: \(type(of: object))")
744 | assertionFailure()
745 | }
746 | }
747 | }
748 |
749 | public protocol TinyStorageKey: Hashable, Sendable {
750 | var rawValue: String { get }
751 | }
752 |
753 | extension String: TinyStorageKey {
754 | public var rawValue: String { self }
755 | }
756 |
757 | @propertyWrapper
758 | public struct TinyStorageItem: DynamicProperty, Sendable {
759 | @State private var storage: TinyStorage
760 |
761 | private let key: any TinyStorageKey
762 | private let defaultValue: T
763 |
764 | public init(wrappedValue: T, _ key: any TinyStorageKey, storage: TinyStorage) {
765 | self.defaultValue = wrappedValue
766 | self.storage = storage
767 | self.key = key
768 | }
769 |
770 | public var wrappedValue: T {
771 | get { storage.retrieve(type: T.self, forKey: key) ?? defaultValue }
772 | nonmutating set { storage.store(newValue, forKey: key) }
773 | }
774 |
775 | public var projectedValue: Binding {
776 | Binding(
777 | get: { wrappedValue },
778 | set: { wrappedValue = $0 }
779 | )
780 | }
781 | }
782 |
--------------------------------------------------------------------------------
/banner-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christianselig/TinyStorage/9fb291d58efd06cb7062eef673de90a4f4b9cd6e/banner-dark.png
--------------------------------------------------------------------------------
/banner-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christianselig/TinyStorage/9fb291d58efd06cb7062eef673de90a4f4b9cd6e/banner-light.png
--------------------------------------------------------------------------------
/build.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd -P)"
6 | PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
7 |
8 | PROJECT_BUILD_DIR="${PROJECT_BUILD_DIR:-"${PROJECT_ROOT}/build"}"
9 | XCODEBUILD_BUILD_DIR="$PROJECT_BUILD_DIR/xcodebuild"
10 | XCODEBUILD_DERIVED_DATA_PATH="$XCODEBUILD_BUILD_DIR/DerivedData"
11 |
12 | build_framework() {
13 | local sdk="$1"
14 | local destination="$2"
15 | local scheme="$3"
16 |
17 | local XCODEBUILD_ARCHIVE_PATH="./$scheme-$sdk.xcarchive"
18 |
19 | rm -rf "$XCODEBUILD_ARCHIVE_PATH"
20 |
21 | xcodebuild archive \
22 | -scheme $scheme \
23 | -archivePath $XCODEBUILD_ARCHIVE_PATH \
24 | -derivedDataPath "$XCODEBUILD_DERIVED_DATA_PATH" \
25 | -sdk "$sdk" \
26 | -destination "$destination" \
27 | BUILD_LIBRARY_FOR_DISTRIBUTION=YES \
28 | INSTALL_PATH='Library/Frameworks' \
29 | OTHER_SWIFT_FLAGS=-no-verify-emitted-module-interface \
30 | LD_GENERATE_MAP_FILE=YES
31 |
32 | FRAMEWORK_MODULES_PATH="$XCODEBUILD_ARCHIVE_PATH/Products/Library/Frameworks/$scheme.framework/Modules"
33 | mkdir -p "$FRAMEWORK_MODULES_PATH"
34 | cp -r \
35 | "$XCODEBUILD_DERIVED_DATA_PATH/Build/Intermediates.noindex/ArchiveIntermediates/$scheme/BuildProductsPath/Release-$sdk/$scheme.swiftmodule" \
36 | "$FRAMEWORK_MODULES_PATH/$scheme.swiftmodule"
37 | # Delete private swiftinterface
38 | rm -f "$FRAMEWORK_MODULES_PATH/$scheme.swiftmodule/*.private.swiftinterface"
39 | mkdir -p "$scheme-$sdk.xcarchive/LinkMaps"
40 | find "$XCODEBUILD_DERIVED_DATA_PATH" -name "$scheme-LinkMap-*.txt" -exec cp {} "./$scheme-$sdk.xcarchive/LinkMaps/" \;
41 | }
42 |
43 | # Update the Package.swift to build the library as dynamic instead of static
44 | sed -i '' '/Replace this/ s/.*/type: .dynamic,/' Package.swift
45 |
46 | build_framework "iphonesimulator" "generic/platform=iOS Simulator" "TinyStorage"
47 | build_framework "iphoneos" "generic/platform=iOS" "TinyStorage"
48 |
49 | echo "Builds completed successfully."
50 |
51 | rm -rf "TinyStorage.xcframework"
52 | xcodebuild -create-xcframework -framework TinyStorage-iphonesimulator.xcarchive/Products/Library/Frameworks/TinyStorage.framework -framework TinyStorage-iphoneos.xcarchive/Products/Library/Frameworks/TinyStorage.framework -output TinyStorage.xcframework
53 |
54 | cp -r TinyStorage-iphonesimulator.xcarchive/dSYMs TinyStorage.xcframework/ios-arm64_x86_64-simulator
55 | cp -r TinyStorage-iphoneos.xcarchive/dSYMs TinyStorage.xcframework/ios-arm64
--------------------------------------------------------------------------------