Update LocalData.swift, Models, Protocols (+1 more) + tests + docs + config

Summary:
- Sources: LocalData.swift, Models, Protocols, Services
- Tests: LocalDataTests.swift
- Docs: Proposal, README
- Config: Package
- Other: .gitignore
- Added symbols: enum StorageKeys, struct AnyCodable, func encode, enum FileDirectory, func url, enum KeychainAccessControl (+49 more)

Stats:
- 19 files changed, 814 insertions(+)
This commit is contained in:
Matt Bruce 2026-01-13 21:13:17 -06:00
commit b9dca68c5d
19 changed files with 814 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.build/
.swiftpm/
.DS_Store

27
Package.swift Normal file
View File

@ -0,0 +1,27 @@
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "LocalData",
platforms: [
.iOS(.v17),
.watchOS(.v10)
],
products: [
.library(
name: "LocalData",
targets: ["LocalData"]
),
],
targets: [
.target(
name: "LocalData"
),
.testTarget(
name: "LocalDataTests",
dependencies: ["LocalData"]
),
]
)

37
Proposal.md Normal file
View File

@ -0,0 +1,37 @@
# LocalData Package Proposal
## Goal
Create a single, typed, discoverable namespace for persisted app data with consistent security guarantees and clear ownership. This makes it obvious what is stored, where it is stored, how it is secured, how it is serialized, who owns it, and which platforms it belongs to or should sync to.
## Package Placement
- localPackages/LocalData/
- Sources/LocalData/
- Tests/LocalDataTests/
## Dependencies
- Foundation
- Security (Keychain)
- CryptoKit (encryption)
- WatchConnectivity (sync helpers)
## Implemented Scope
- StorageKey protocol and StorageKeys namespace for app-defined keys
- StorageRouter actor with async set, get, and remove
- StorageDomain options for user defaults, keychain, file system, and encrypted file system
- SecurityPolicy options for none, keychain, and encrypted data
- Serializer for JSON, property lists, raw Data, or custom encode and decode
- PlatformAvailability and SyncPolicy metadata
- AnyCodable for simple mixed-type payloads
## Usage Pattern
Apps extend StorageKeys with their own key types and use StorageRouter.shared. This follows the Notification.Name pattern for discoverable keys.
## Sync Behavior
StorageRouter can call WCSession.updateApplicationContext for manual or automaticSmall sync policies when availability allows it. Session activation and receiving data are owned by the app.
## Future Ideas (Not Implemented)
- Migration helpers for legacy storage
- Key rotation strategies for encrypted data
- Watch-optimized data representations
Any future changes should keep LocalData documentation in sync with code changes.

26
README.md Normal file
View File

@ -0,0 +1,26 @@
# LocalData
LocalData provides a typed, discoverable namespace for persisted app data across UserDefaults, Keychain, and file storage with optional encryption.
## What ships in the package
- StorageKey protocol and StorageKeys namespace for app-defined keys
- StorageRouter actor (StorageProviding) with async set/get/remove
- StorageDomain options for user defaults, keychain, and file storage
- SecurityPolicy options for none, keychain, or encrypted data
- Serializer for JSON, property lists, raw Data, or custom encode and decode
- PlatformAvailability and SyncPolicy metadata for watch behavior
- AnyCodable utility for structured payloads
## Usage
Define keys in your app by extending StorageKeys. Use StorageRouter.shared to set, get, and remove values. See SecureStorgageSample/SecureStorgageSample for working examples.
## Sync behavior
StorageRouter can call WCSession.updateApplicationContext when SyncPolicy is manual or automaticSmall and availability is all or phoneWithWatchSync. The app is responsible for activating WCSession and handling incoming updates.
## Platforms
- iOS 17+
- watchOS 10+
## Notes
- LocalData does not include sample key definitions or models.
- Keys should be stable and unique per domain or service.

View File

@ -0,0 +1,5 @@
// The Swift Programming Language
// https://docs.swift.org/swift-book
public enum StorageKeys {
}

View File

@ -0,0 +1,50 @@
import Foundation
public struct AnyCodable: Codable, @unchecked Sendable {
public let value: Any
public init(_ value: Any) {
self.value = value
}
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let bool = try? container.decode(Bool.self) {
value = bool
} else if let int = try? container.decode(Int.self) {
value = int
} else if let double = try? container.decode(Double.self) {
value = double
} else if let string = try? container.decode(String.self) {
value = string
} else if let array = try? container.decode([AnyCodable].self) {
value = array.map { $0.value }
} else if let dictionary = try? container.decode([String: AnyCodable].self) {
value = dictionary.mapValues { $0.value }
} else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "AnyCodable value cannot be decoded")
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch value {
case let bool as Bool:
try container.encode(bool)
case let int as Int:
try container.encode(int)
case let double as Double:
try container.encode(double)
case let string as String:
try container.encode(string)
case let array as [Any]:
try container.encode(array.map { AnyCodable($0) })
case let dictionary as [String: Any]:
try container.encode(dictionary.mapValues { AnyCodable($0) })
default:
throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: container.codingPath, debugDescription: "AnyCodable value cannot be encoded"))
}
}
}

View File

@ -0,0 +1,15 @@
import Foundation
public enum FileDirectory: Sendable {
case documents, caches, custom(URL)
public func url() -> URL {
switch self {
case .documents:
return URL.documentsDirectory
case .caches:
return URL.cachesDirectory
case .custom(let url):
return url
}
}
}

View File

@ -0,0 +1,27 @@
import Foundation
import Security
public enum KeychainAccessControl: Sendable {
case userPresence
// Add more as needed
func accessControl(accessibility: KeychainAccessibility) -> SecAccessControl? {
let accessibilityValue: CFString
switch accessibility {
case .whenUnlocked:
accessibilityValue = kSecAttrAccessibleWhenUnlocked
case .afterFirstUnlock:
accessibilityValue = kSecAttrAccessibleAfterFirstUnlock
}
switch self {
case .userPresence:
return SecAccessControlCreateWithFlags(
nil,
accessibilityValue,
.userPresence,
nil
)
}
}
}

View File

@ -0,0 +1,15 @@
import Foundation
import Security
public enum KeychainAccessibility: Sendable {
case whenUnlocked
case afterFirstUnlock
// Add more as needed
var cfString: CFString {
switch self {
case .whenUnlocked: return kSecAttrAccessibleWhenUnlocked
case .afterFirstUnlock: return kSecAttrAccessibleAfterFirstUnlock
}
}
}

View File

@ -0,0 +1,8 @@
import Foundation
public enum PlatformAvailability: Sendable {
case all // iPhone + Watch (small only!)
case phoneOnly // iPhone only (large/sensitive)
case watchOnly // Watch local only
case phoneWithWatchSync // Small data for explicit sync
}

View File

@ -0,0 +1,17 @@
import Foundation
import CryptoKit
import Security
public enum SecurityPolicy: Sendable {
case none
case encrypted(EncryptionPolicy)
case keychain(accessibility: KeychainAccessibility, accessControl: KeychainAccessControl?)
public enum EncryptionPolicy: Sendable {
case aes256(keyDerivation: KeyDerivation)
}
public enum KeyDerivation: Sendable {
case pbkdf2(iterations: Int, salt: Data? = nil)
}
}

View File

@ -0,0 +1,41 @@
import Foundation
public struct Serializer<Value: Codable & Sendable>: Sendable {
public let encode: @Sendable (Value) throws -> Data
public let decode: @Sendable (Data) throws -> Value
public init(
encode: @escaping @Sendable (Value) throws -> Data,
decode: @escaping @Sendable (Data) throws -> Value
) {
self.encode = encode
self.decode = decode
}
public static var json: Serializer<Value> {
Serializer<Value>(
encode: { try JSONEncoder().encode($0) },
decode: { try JSONDecoder().decode(Value.self, from: $0) }
)
}
public static var plist: Serializer<Value> {
Serializer<Value>(
encode: { try PropertyListEncoder().encode($0) },
decode: { try PropertyListDecoder().decode(Value.self, from: $0) }
)
}
public static func custom(
encode: @escaping @Sendable (Value) throws -> Data,
decode: @escaping @Sendable (Data) throws -> Value
) -> Serializer<Value> {
Serializer<Value>(encode: encode, decode: decode)
}
}
public extension Serializer where Value == Data {
static var data: Serializer<Value> {
Serializer<Value>(encode: { $0 }, decode: { $0 })
}
}

View File

@ -0,0 +1,8 @@
import Foundation
public enum StorageDomain: Sendable {
case userDefaults(suite: String?)
case keychain(service: String)
case fileSystem(directory: FileDirectory)
case encryptedFileSystem(directory: FileDirectory)
}

View File

@ -0,0 +1,16 @@
import Foundation
public enum StorageError: Error {
case serializationFailed, deserializationFailed
case securityApplicationFailed
case keychainError(OSStatus)
case fileError(Error)
case phoneOnlyKeyAccessedOnWatch(String)
case watchOnlyKeyAccessedOnPhone(String)
case invalidUserDefaultsSuite(String)
case dataTooLargeForSync
case notFound
// ...
}
extension StorageError: @unchecked Sendable {}

View File

@ -0,0 +1,7 @@
import Foundation
public enum SyncPolicy: Sendable {
case never // Default for most
case manual // Manual WCSession send
case automaticSmall // Auto-sync if small
}

View File

@ -0,0 +1,14 @@
import Foundation
public protocol StorageKey: Sendable {
associatedtype Value: Codable & Sendable
var name: String { get }
var domain: StorageDomain { get }
var security: SecurityPolicy { get }
var serializer: Serializer<Value> { get }
var owner: String { get }
var availability: PlatformAvailability { get }
var syncPolicy: SyncPolicy { get }
}

View File

@ -0,0 +1,7 @@
import Foundation
public protocol StorageProviding: Sendable {
func set<Key: StorageKey>(_ value: Key.Value, for key: Key) async throws
func get<Key: StorageKey>(_ key: Key) async throws -> Key.Value
func remove<Key: StorageKey>(_ key: Key) async throws
}

View File

@ -0,0 +1,390 @@
import Foundation
import CryptoKit
import Security
#if os(iOS) || os(watchOS)
import WatchConnectivity
#endif
public actor StorageRouter: StorageProviding {
public static let shared = StorageRouter()
private enum EncryptionConstants {
static let masterKeyService = "LocalData.MasterKey"
static let masterKeyAccount = "LocalData.MasterKey"
static let masterKeyLength = 32
}
private init() {}
public func set<Key: StorageKey>(_ value: Key.Value, for key: Key) async throws {
try validatePlatformAvailability(for: key)
let data = try serialize(value, with: key.serializer)
let securedData = try applySecurity(data, for: key, isEncrypt: true)
try await store(securedData, for: key)
try await handleSync(key, data: securedData)
}
public func get<Key: StorageKey>(_ key: Key) async throws -> Key.Value {
try validatePlatformAvailability(for: key)
guard let securedData = try await retrieve(for: key) else {
throw StorageError.notFound
}
let data = try applySecurity(securedData, for: key, isEncrypt: false)
return try deserialize(data, with: key.serializer)
}
public func remove<Key: StorageKey>(_ key: Key) async throws {
try validatePlatformAvailability(for: key)
try await delete(for: key)
}
// MARK: - Platform Validation
private func validatePlatformAvailability<Key: StorageKey>(for key: Key) throws {
#if os(watchOS)
if key.availability == .phoneOnly {
throw StorageError.phoneOnlyKeyAccessedOnWatch(key.name)
}
#elseif os(iOS)
if key.availability == .watchOnly {
throw StorageError.watchOnlyKeyAccessedOnPhone(key.name)
}
#endif
}
// MARK: - Serialization
private func serialize<Value: Codable & Sendable>(_ value: Value, with serializer: Serializer<Value>) throws -> Data {
do {
return try serializer.encode(value)
} catch {
throw StorageError.serializationFailed
}
}
private func deserialize<Value: Codable & Sendable>(_ data: Data, with serializer: Serializer<Value>) throws -> Value {
do {
return try serializer.decode(data)
} catch {
throw StorageError.deserializationFailed
}
}
// MARK: - Security
private func applySecurity(_ data: Data, for key: any StorageKey, isEncrypt: Bool) throws -> Data {
switch key.security {
case .none:
return data
case .encrypted(let encryptionPolicy):
let derivedKey = try encryptionKey(for: key, policy: encryptionPolicy)
return isEncrypt ? try encrypt(data, using: derivedKey) : try decrypt(data, using: derivedKey)
case .keychain:
// Keychain security is handled in store/retrieve
return data
}
}
private func encrypt(_ data: Data, using key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.seal(data, using: key)
guard let combined = sealedBox.combined else {
throw StorageError.securityApplicationFailed
}
return combined
}
private func decrypt(_ data: Data, using key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.SealedBox(combined: data)
return try AES.GCM.open(sealedBox, using: key)
}
private func encryptionKey(for key: any StorageKey, policy: SecurityPolicy.EncryptionPolicy) throws -> SymmetricKey {
switch policy {
case .aes256(let derivation):
let password = try masterKeyData()
let salt = derivation.salt ?? Data(key.name.utf8)
let derivedKeyData = try pbkdf2SHA256(
password: password,
salt: salt,
iterations: derivation.iterations,
keyLength: EncryptionConstants.masterKeyLength
)
return SymmetricKey(data: derivedKeyData)
}
}
private func masterKeyData() throws -> Data {
if let existing = try keychainGet(
service: EncryptionConstants.masterKeyService,
key: EncryptionConstants.masterKeyAccount
) {
return existing
}
var bytes = [UInt8](repeating: 0, count: EncryptionConstants.masterKeyLength)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
guard status == errSecSuccess else {
throw StorageError.securityApplicationFailed
}
let data = Data(bytes)
try keychainSet(
data,
service: EncryptionConstants.masterKeyService,
key: EncryptionConstants.masterKeyAccount,
policy: .keychain(accessibility: .afterFirstUnlock, accessControl: nil)
)
return data
}
private func pbkdf2SHA256(password: Data, salt: Data, iterations: Int, keyLength: Int) throws -> Data {
guard iterations > 0 else {
throw StorageError.securityApplicationFailed
}
var derivedKey = Data()
var blockIndex: UInt32 = 1
while derivedKey.count < keyLength {
var block = Data()
block.append(salt)
block.append(uint32Data(blockIndex))
var u = hmacSHA256(key: password, data: block)
var t = u
if iterations > 1 {
for _ in 1..<iterations {
u = hmacSHA256(key: password, data: u)
t = xor(t, u)
}
}
derivedKey.append(t)
blockIndex += 1
}
return derivedKey.prefix(keyLength)
}
private func hmacSHA256(key: Data, data: Data) -> Data {
let symmetricKey = SymmetricKey(data: key)
let mac = HMAC<SHA256>.authenticationCode(for: data, using: symmetricKey)
return Data(mac)
}
private func xor(_ left: Data, _ right: Data) -> Data {
let xored = zip(left, right).map { $0 ^ $1 }
return Data(xored)
}
private func uint32Data(_ value: UInt32) -> Data {
var bigEndian = value.bigEndian
return Data(bytes: &bigEndian, count: MemoryLayout<UInt32>.size)
}
// MARK: - Storage
private func store(_ data: Data, for key: any StorageKey) async throws {
switch key.domain {
case .userDefaults(let suite):
let defaults = try userDefaults(for: suite)
defaults.set(data, forKey: key.name)
case .keychain(let service):
try keychainSet(data, service: service, key: key.name, policy: key.security)
case .fileSystem(let directory):
let url = directory.url().appending(path: key.name)
try ensureDirectoryExists(at: url.deletingLastPathComponent())
try writeData(data, to: url, options: [.atomic])
case .encryptedFileSystem(let directory):
let url = directory.url().appending(path: key.name)
try ensureDirectoryExists(at: url.deletingLastPathComponent())
try writeData(data, to: url, options: [.atomic, .completeFileProtection])
}
}
private func retrieve(for key: any StorageKey) async throws -> Data? {
switch key.domain {
case .userDefaults(let suite):
let defaults = try userDefaults(for: suite)
return defaults.data(forKey: key.name)
case .keychain(let service):
return try keychainGet(service: service, key: key.name)
case .fileSystem(let directory):
let url = directory.url().appending(path: key.name)
return try readData(from: url)
case .encryptedFileSystem(let directory):
let url = directory.url().appending(path: key.name)
return try readData(from: url)
}
}
private func delete(for key: any StorageKey) async throws {
switch key.domain {
case .userDefaults(let suite):
let defaults = try userDefaults(for: suite)
defaults.removeObject(forKey: key.name)
case .keychain(let service):
try keychainDelete(service: service, key: key.name)
case .fileSystem(let directory), .encryptedFileSystem(let directory):
let url = directory.url().appending(path: key.name)
try deleteItemIfExists(at: url)
}
}
private func userDefaults(for suite: String?) throws -> UserDefaults {
guard let suite else { return .standard }
guard let defaults = UserDefaults(suiteName: suite) else {
throw StorageError.invalidUserDefaultsSuite(suite)
}
return defaults
}
private func ensureDirectoryExists(at url: URL) throws {
do {
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
} catch {
throw StorageError.fileError(error)
}
}
private func writeData(_ data: Data, to url: URL, options: Data.WritingOptions) throws {
do {
try data.write(to: url, options: options)
} catch {
throw StorageError.fileError(error)
}
}
private func readData(from url: URL) throws -> Data? {
guard FileManager.default.fileExists(atPath: url.path) else {
return nil
}
do {
return try Data(contentsOf: url)
} catch {
throw StorageError.fileError(error)
}
}
private func deleteItemIfExists(at url: URL) throws {
guard FileManager.default.fileExists(atPath: url.path) else {
return
}
do {
try FileManager.default.removeItem(at: url)
} catch {
throw StorageError.fileError(error)
}
}
// MARK: - Keychain Helpers
private func keychainSet(_ data: Data, service: String, key: String, policy: SecurityPolicy) throws {
guard case let .keychain(accessibility, accessControl) = policy else {
throw StorageError.securityApplicationFailed
}
var attributes: [String: Any] = [
kSecValueData as String: data
]
if let accessControl {
guard let accessControlValue = accessControl.accessControl(accessibility: accessibility) else {
throw StorageError.securityApplicationFailed
}
attributes[kSecAttrAccessControl as String] = accessControlValue
} else {
attributes[kSecAttrAccessible as String] = accessibility.cfString
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
let addQuery = query.merging(attributes) { _, new in new }
let status = SecItemAdd(addQuery as CFDictionary, nil)
if status == errSecDuplicateItem {
let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary)
if updateStatus != errSecSuccess {
throw StorageError.keychainError(updateStatus)
}
} else if status != errSecSuccess {
throw StorageError.keychainError(status)
}
}
private func keychainGet(service: String, key: String) throws -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecSuccess {
return item as? Data
} else if status == errSecItemNotFound {
return nil
} else {
throw StorageError.keychainError(status)
}
}
private func keychainDelete(service: String, key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
throw StorageError.keychainError(status)
}
}
// MARK: - Sync
#if os(iOS) || os(watchOS)
private func handleSync(_ key: any StorageKey, data: Data) async throws {
guard key.availability == .all || key.availability == .phoneWithWatchSync else {
return
}
switch key.syncPolicy {
case .never:
return
case .automaticSmall:
if data.count > 100_000 { throw StorageError.dataTooLargeForSync }
fallthrough
case .manual:
guard WCSession.isSupported() else { return }
let session = WCSession.default
guard session.activationState == .activated else { return }
#if os(iOS)
guard session.isPaired, session.isWatchAppInstalled else { return }
#endif
try session.updateApplicationContext([key.name: data])
}
}
#else
private func handleSync(_ key: any StorageKey, data: Data) async throws {
// No sync on other platforms
}
#endif
}

View File

@ -0,0 +1,101 @@
import Foundation
import XCTest
@testable import LocalData
private struct TestUserDefaultsKey: StorageKey {
typealias Value = String
let name: String
let domain: StorageDomain
let security: SecurityPolicy = .none
let serializer: Serializer<String> = .json
let owner: String = "LocalDataTests"
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
init(name: String, suiteName: String) {
self.name = name
self.domain = .userDefaults(suite: suiteName)
}
}
private struct TestFileKey: StorageKey {
typealias Value = String
let name: String
let domain: StorageDomain
let security: SecurityPolicy = .none
let serializer: Serializer<String> = .json
let owner: String = "LocalDataTests"
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
init(name: String, directory: URL) {
self.name = name
self.domain = .fileSystem(directory: .custom(directory))
}
}
final class LocalDataTests: XCTestCase {
private var suiteName: String = ""
private var tempDirectory: URL = .temporaryDirectory
override func setUp() {
super.setUp()
suiteName = "LocalDataTests.\(UUID().uuidString)"
tempDirectory = FileManager.default.temporaryDirectory
.appending(path: "LocalDataTests")
.appending(path: UUID().uuidString)
}
override func tearDown() {
if let defaults = UserDefaults(suiteName: suiteName) {
defaults.removePersistentDomain(forName: suiteName)
}
try? FileManager.default.removeItem(at: tempDirectory)
super.tearDown()
}
func testUserDefaultsRoundTrip() async throws {
let key = TestUserDefaultsKey(name: "test.string", suiteName: suiteName)
let storedValue = "1.0.0"
try await StorageRouter.shared.set(storedValue, for: key)
let fetched = try await StorageRouter.shared.get(key)
XCTAssertEqual(fetched, storedValue)
try await StorageRouter.shared.remove(key)
do {
_ = try await StorageRouter.shared.get(key)
XCTFail("Expected notFound error after removal")
} catch StorageError.notFound {
return
} catch {
XCTFail("Unexpected error: \(error)")
}
}
func testFileSystemRoundTrip() async throws {
let key = TestFileKey(name: "test.json", directory: tempDirectory)
let storedValue = "payload"
try await StorageRouter.shared.set(storedValue, for: key)
let fetched = try await StorageRouter.shared.get(key)
XCTAssertEqual(fetched, storedValue)
try await StorageRouter.shared.remove(key)
do {
_ = try await StorageRouter.shared.get(key)
XCTFail("Expected notFound error after removal")
} catch StorageError.notFound {
return
} catch {
XCTFail("Unexpected error: \(error)")
}
}
}