Summary: - Docs: update docs for README Stats: - 1 file changed, 43 insertions(+), 24 deletions(-) |
||
|---|---|---|
| Sources/LocalData | ||
| Tests/LocalDataTests | ||
| .gitignore | ||
| FutureEnhancements.md | ||
| Package.resolved | ||
| Package.swift | ||
| Proposal.md | ||
| README.md | ||
LocalData
LocalData provides a typed, discoverable namespace for persisted app data across UserDefaults, Keychain, and file storage with optional encryption.
Architecture
The package uses a clean, modular architecture with isolated actors for thread safety:
StorageRouter (main entry point)
├── UserDefaultsHelper
├── KeychainHelper
├── FileStorageHelper
├── EncryptionHelper
└── SyncHelper
flowchart TD
%% Main Architecture Stack
App(["📱 <b>APP / FEATURE LAYER</b>"])
subgraph Config ["⚙️ GLOBAL CONFIGURATION"]
direction LR
SC["StorageConfig"]
EC["EncryptionConfig"]
FC["FileStorageConfig"]
SYC["SyncConfig"]
end
SR["🔀 <b>STORAGE ROUTER</b><br/>(Migration & Orchestration Engine)"]
subgraph Helpers ["🛠️ INTERNAL HELPER ACTORS"]
direction TB
subgraph Logic ["Core Logic"]
direction LR
KH["KeychainHelper"]
FH["FileHelper"]
UH["UserDefaultsHelper"]
end
EH["🔐 EncryptionHelper"]
SH["🔄 SyncHelper"]
end
subgraph Storage ["💾 HARDWARE STORAGE"]
direction LR
KC[("🗝️ Keychain")]
FS[("📁 File System")]
UD[("⚙️ UserDefaults")]
WatchOS["⌚ Apple Watch"]
end
%% Flow Relationships
App -->|StorageKey| SR
SR -.->|Resolves| Config
SR ==>|1. Routing Logic| Helpers
KH --> KC
FH --> FS
UH --> UD
SH -->|WatchConnectivity| WatchOS
KH --- EH
FH --- EH
%% Migration Logic
Storage -.->|<b>2. AUTOMATIC MIGRATION</b>| SR
%% Styling
style App fill:#fff,stroke:#333,stroke-width:2px
style Config fill:#f9f9f9,stroke:#999,stroke-dasharray: 5 5
style SR fill:#e1f5fe,stroke:#01579b,stroke-width:3px
style Storage fill:#f0f7ff,stroke:#0052cc,stroke-width:2px
style Helpers fill:#fff,stroke:#666
What Ships in the Package
Protocols
- StorageKey - Define storage configuration for each data type
- StorageProviding - Abstraction for storage operations
- KeyMaterialProviding - Supplies external key material for encryption
Services (Actors)
- StorageRouter - Main entry point for all storage operations
Internal Helpers (Not Public API)
These helpers are internal implementation details used by StorageRouter. They are not part of the public API and should not be used directly.
- KeychainHelper - Reads/writes secure items with Keychain APIs.
- EncryptionHelper - Handles encryption/decryption and key derivation.
- FileStorageHelper - Reads/writes files with appropriate protection.
- UserDefaultsHelper - Wraps UserDefaults and suites safely.
- SyncHelper - Manages WatchConnectivity sync.
Global Configuration Models
These are used at app lifecycle start to tune library engine behaviors:
- StorageConfiguration - Default Keychain service and App Group IDs
- EncryptionConfiguration - Global encryption settings (Keychain identifiers, key length)
- SyncConfiguration - Global sync settings (Max automatic sync size)
- FileStorageConfiguration - Global file settings (Sub-directory scoping)
Other Models
- StorageDomain - userDefaults, appGroupUserDefaults, keychain, fileSystem, encryptedFileSystem, appGroupFileSystem
- SecurityPolicy - none, keychain, encrypted (AES-256 or ChaCha20-Poly1305)
- Serializer - JSON, plist, Data, or custom
- KeyMaterialSource - Identifier for external key material providers
- PlatformAvailability - all, phoneOnly, watchOnly, phoneWithWatchSync
- SyncPolicy - never, manual, automaticSmall
- KeychainAccessibility - All 7 iOS accessibility options
- KeychainAccessControl - All 6 access control options (biometry, passcode, etc.)
- FileDirectory - documents, caches, custom URL
- StorageError - Comprehensive error types
- StorageKeyDescriptor - Audit snapshot of a key’s storage metadata
- AnyStorageKey - Type-erased storage key for catalogs
- AnyCodable - Type-erased Codable for mixed-type payloads
Usage
1. Define Keys
Extend StorageKeys with your own key types:
import LocalData
extension StorageKeys {
struct UserTokenKey: StorageKey {
typealias Value = String
let name = "user_token"
let domain: StorageDomain = .keychain(service: "com.myapp")
let security: SecurityPolicy = .keychain(
accessibility: .afterFirstUnlock,
accessControl: .biometryAny
)
let serializer: Serializer<String> = .json
let owner = "AuthService"
let description = "Stores the current user auth token."
let availability: PlatformAvailability = .phoneOnly
let syncPolicy: SyncPolicy = .never
}
}
If you omit security, it defaults to SecurityPolicy.recommended.
2. Use StorageRouter
// Save
let key = StorageKeys.UserTokenKey()
try await StorageRouter.shared.set("token123", for: key)
// Retrieve
let token = try await StorageRouter.shared.get(key)
// Remove
try await StorageRouter.shared.remove(key)
Complex Codable Support
LocalData handles complex Codable types automatically. You are not limited to simple strings or integers.
struct UserProfile: Codable {
let id: UUID
let name: String
let settings: [String: String]
}
extension StorageKeys {
struct ProfileKey: StorageKey {
typealias Value = UserProfile // Library handles serialization
let name = "user_profile"
let domain: StorageDomain = .fileSystem(directory: .documents)
// ... other properties
}
}
// ... other properties
}
}
## Data Migration
LocalData supports migrating data between different storage keys and domains (e.g., from a legacy `UserDefaults` string key to a modern secure `Keychain` key).
### 1. Automatic Fallback (Lazy)
When you define `migrationSources` on a key, `StorageRouter.get(key)` will automatically check those sources if the primary key is not found. If data exists in a source:
- It is retrieved using the source's metadata.
- It is saved to the new key using the new security policy.
- It is deleted from the source.
- It is returned to the caller.
### 2. Proactive Sweep (Drain)
To ensure no "ghost data" remains in legacy keys (e.g., if a bug causes old code to write to them again), you can use either a manual call or an automated startup sweep.
#### Manual Call
```swift
try await StorageRouter.shared.migrate(for: StorageKeys.ModernKey())
Automated Startup Sweep
When registering a catalog, you can enable migrateImmediately to perform a global sweep of all legacy keys for every key in the catalog. This ensures your storage is clean at every app launch.
try await StorageRouter.shared.registerCatalog(AppStorageCatalog.self, migrateImmediately: true)
Storage Design Philosophy
This app intentionally uses a Type-Safe Storage Design. Unlike standard iOS development which uses string keys (e.g., UserDefaults.standard.string(forKey: "user_name")), this library requires you to define a StorageKey type.
Why types instead of strings?
- Safety: The compiler prevents typos. You can't accidentally load from
"user_name"and save to"username". - Codable Support: Keys define their own value types. You can store complex
Codablestructs or classes just as easily as strings, and the library handles the JSON/Plist serialization automatically. - Visibility: All data your app stores is discoverable in the
StorageKeys/folder. It serves as a manifest of your app's persistence layer. - Migration: You can move a piece of data from
UserDefaultstoEncryptedFileSystemjust by changing thedomainin the Key definition. No UI code needs to change.
Storage Key Examples
| Domain | Use Case |
|---|---|
userDefaults |
Preferences, small settings |
appGroupUserDefaults |
Shared settings across extensions via App Groups |
keychain |
Credentials, tokens, sensitive data |
fileSystem(directory:) |
Local storage in Documents or Caches |
encryptedFileSystem(directory:) |
Sensitive files with encryption policies |
appGroupFileSystem(id:directory:) |
Shared files across targets via App Groups |
File Directories
The library supports two standard iOS locations via FileDirectory:
| Directory | Persistence | iCloud Backup | Recommended Use |
|---|---|---|---|
.documents |
Permanent | Yes | User data, critical settings |
.caches |
Purgeable* | No | Temporary files, downloaded assets |
*iOS may delete files in .caches if the device runs low on storage.
By configuring a subDirectory in FileStorageConfiguration, you ensure that the library's data is isolated within its own folder in both locations (e.g., Documents/MyData/ and Caches/MyData/).
App Group Support
App Group storage is explicit via StorageDomain.appGroupUserDefaults and StorageDomain.appGroupFileSystem. These require a valid App Group identifier and the corresponding entitlement on every target that needs access. If the identifier is invalid or missing, LocalData throws StorageError.invalidAppGroupIdentifier.
Use standard userDefaults or fileSystem for data that should remain scoped to a single target, even when App Groups are configured.
For app-level configuration (App Group identifiers, keychain service identifiers, etc.), centralize constants in a shared module so keys do not hardcode string literals.
Security Options
Keychain Accessibility
whenUnlocked- Only when device unlockedafterFirstUnlock- After first unlock until restartwhenUnlockedThisDeviceOnly- No migration to new deviceafterFirstUnlockThisDeviceOnly- No migrationalways- Always accessible (least secure)alwaysThisDeviceOnly- Always, no migrationwhenPasscodeSetThisDeviceOnly- Requires passcode
Access Control
userPresence- Any authenticationbiometryAny- Face ID or Touch IDbiometryCurrentSet- Current enrolled biometric onlydevicePasscode- Passcode onlybiometryAnyOrDevicePasscode- Biometric preferred, passcode fallbackbiometryCurrentSetOrDevicePasscode- Current biometric or passcode
Encryption
- AES-256-GCM or ChaCha20-Poly1305
- PBKDF2-SHA256 or HKDF-SHA256 key derivation
- Configurable PBKDF2 iteration count
- Master key stored securely in keychain
- Default security policy:
SecurityPolicy.recommended(ChaCha20-Poly1305 + HKDF) - External key material providers can be registered via
StorageRouter
Global Encryption Configuration
You can customize the identifiers used for the master key in the Keychain:
let config = EncryptionConfiguration(
masterKeyService: "com.myapp.LocalData",
masterKeyAccount: "MasterKey",
pbkdf2Iterations: 50_000
)
await StorageRouter.shared.updateEncryptionConfiguration(config)
Warning
Changing the
masterKeyService,masterKeyAccount, orpbkdf2Iterationsin an existing app will cause the app to look for or derive keys differently. Previously encrypted data will be inaccessible.
Global Sync Configuration
You can customize the maximum size for automatic synchronization:
let syncConfig = SyncConfiguration(maxAutoSyncSize: 50_000) // 50KB limit
await StorageRouter.shared.updateSyncConfiguration(syncConfig)
Global File Storage Configuration
You can scope all library files into a specific sub-directory (e.g., to avoid cluttering the root Documents folder):
let fileConfig = FileStorageConfiguration(subDirectory: "MyAppStorage")
await StorageRouter.shared.updateFileStorageConfiguration(fileConfig)
This will result in paths like:
.../Documents/MyAppStorage/(Main Sandbox).../SharedContainer/Documents/MyAppStorage/(App Group)
Warning
Changing the
subDirectoryin an existing app will cause the library to look in the new location. Existing files in the old location will not be automatically moved.
Global Storage Defaults
To avoid repeating the same Keychain service or App Group identifier in every key, you can set library-wide defaults:
let storageConfig = StorageConfiguration(
defaultKeychainService: "com.myapp.keychain",
defaultAppGroupIdentifier: "group.com.myapp"
)
await StorageRouter.shared.updateStorageConfiguration(storageConfig)
When defaults are set, you can define keys using nil for these identifiers:
.keychain(service: nil)-> Uses "com.myapp.keychain".appGroupUserDefaults(identifier: nil)-> Uses "group.com.myapp"
struct RemoteKeyProvider: KeyMaterialProviding {
func keyMaterial(for keyName: String) async throws -> Data {
// Example only: fetch from service or keychain
Data(repeating: 1, count: 32)
}
}
let source = KeyMaterialSource(id: "remote.key")
await StorageRouter.shared.registerKeyMaterialProvider(RemoteKeyProvider(), for: source)
let policy: SecurityPolicy.EncryptionPolicy = .external(
source: source,
keyDerivation: .hkdf()
)
Sync Behavior
StorageRouter can sync data to Apple Watch via WCSession when:
availabilityis.allor.phoneWithWatchSyncsyncPolicyis.manualor.automaticSmall(≤100KB)- WCSession is activated and watch is paired
The app owns WCSession activation and handling incoming updates.
Platforms
- iOS 17+
- watchOS 10+
Testing
- Unit tests use Swift Testing (
Testingpackage)
Storage Audit
LocalData can generate a catalog of all configured storage keys, even if no data has been written yet. This is useful for security reviews and compliance.
Why AnyStorageKey?
StorageKey has an associated type (Value), which means you cannot store different keys in a single array using [StorageKey] or some StorageKey. Swift requires type erasure for heterogeneous protocol values, so the catalog uses [AnyStorageKey] and builds descriptors behind the scenes.
- Define a catalog in your app that lists all keys:
struct AppStorageCatalog: StorageKeyCatalog {
static var allKeys: [AnyStorageKey] {
[
.key(StorageKeys.AppVersionKey()),
.key(StorageKeys.UserPreferencesKey())
]
}
}
- Generate a report:
let report = StorageAuditReport.renderText(for: AppStorageCatalog.self)
print(report)
- Register the catalog to enforce usage and catch duplicates:
do {
try StorageRouter.shared.registerCatalog(AppStorageCatalog.self)
} catch {
assertionFailure("Storage catalog registration failed: \(error)")
}
Each StorageKey must provide a human-readable description used in audit reports.
Dynamic key names are intentionally not supported in the core API to keep storage auditing strict and predictable.
If you need this later, see FutureEnhancements.md for a proposed design.
Sample App
See SecureStorageSample for working examples of all storage domains and security options.