# 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
```
```mermaid
%%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '16px', 'primaryColor': '#ffffff', 'lineColor': '#000000', 'textColor': '#000000', 'mainBkg': '#ffffff', 'nodeBorder': '#000000' }}}%%
flowchart TD
%% Global White Container
subgraph Architecture ["LocalData Architecture"]
direction TB
%% Vertical Flow
App(("📱 APP / FEATURE"))
SR["🔀 STORAGE ROUTER
(The Central Engine)"]
subgraph Config ["⚙️ GLOBAL CONFIGURATION"]
direction TB
C1["Encryption & Sync Config"]
C2["App Group & File Config"]
end
subgraph Helpers ["🛠️ INTERNAL HELPER ACTORS"]
direction TB
H1["Keychain & File Storage Helpers"]
H2["UserDefaults & Sync Helpers"]
H3["🔐 Encryption Service"]
end
subgraph Storage ["💾 HARDWARE STORAGE LAYER"]
direction LR
S1[("🗝️ Keychain")]
S2[("📁 File System")]
S3[("⚙️ UserDefaults")]
end
%% Relationships
App -->|StorageKey| SR
SR -.->|Resolves| Config
SR ==>|1. Orchestrate| Helpers
Helpers ==>|2. Persist| Storage
%% Migration Loop
Storage -.->|3. AUTOMATIC MIGRATION| SR
end
%% Explicit Styling for High Contrast
style Architecture fill:#ffffff,stroke:#000,stroke-width:2px
style App fill:#ffffff,stroke:#000,stroke-width:3px
style SR fill:#e1f5fe,stroke:#000,stroke-width:4px
style Config fill:#fffde7,stroke:#000,stroke-dasharray: 5 5
style Helpers fill:#f5f5f5,stroke:#000,stroke-width:2px
style Storage fill:#e8f5e9,stroke:#000,stroke-width:3px
%% Link Styling
linkStyle default stroke:#000,stroke-width:2px,color:#000
```
## 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:
```swift
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 = .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
```swift
// 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.
```swift
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.
> [!NOTE]
> **Modular Registration**: `registerCatalog` is additive. You can call it multiple times from different modules to build an aggregate registry. The library will throw an error if multiple catalogs attempt to register the same key name.
```swift
// Module A
try await StorageRouter.shared.registerCatalog(AuthCatalog.self)
// Module B
try await StorageRouter.shared.registerCatalog(ProfileCatalog.self)
```
## 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?
1. **Safety**: The compiler prevents typos. You can't accidentally load from `"user_name"` and save to `"username"`.
2. **Codable Support**: Keys define their own value types. You can store complex `Codable` structs or classes just as easily as strings, and the library handles the JSON/Plist serialization automatically.
3. **Visibility**: All data your app stores is discoverable in the `StorageKeys/` folder. It serves as a manifest of your app's persistence layer.
4. **Migration**: You can move a piece of data from `UserDefaults` to `EncryptedFileSystem` just by changing the `domain` in 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 unlocked
- `afterFirstUnlock` - After first unlock until restart
- `whenUnlockedThisDeviceOnly` - No migration to new device
- `afterFirstUnlockThisDeviceOnly` - No migration
- `always` - Always accessible (least secure)
- `alwaysThisDeviceOnly` - Always, no migration
- `whenPasscodeSetThisDeviceOnly` - Requires passcode
### Access Control
- `userPresence` - Any authentication
- `biometryAny` - Face ID or Touch ID
- `biometryCurrentSet` - Current enrolled biometric only
- `devicePasscode` - Passcode only
- `biometryAnyOrDevicePasscode` - Biometric preferred, passcode fallback
- `biometryCurrentSetOrDevicePasscode` - 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:
```swift
let config = EncryptionConfiguration(
masterKeyService: "com.myapp.LocalData",
masterKeyAccount: "MasterKey",
pbkdf2Iterations: 50_000
)
await StorageRouter.shared.updateEncryptionConfiguration(config)
```
> [!WARNING]
> Changing the `masterKeyService`, `masterKeyAccount`, or `pbkdf2Iterations` in 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:
```swift
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):
```swift
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 `subDirectory` in 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:
```swift
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"
```swift
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:
- `availability` is `.all` or `.phoneWithWatchSync`
- `syncPolicy` is `.manual` or `.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 (`Testing` package)
## 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.
1) Define a catalog in your app that lists all keys:
```swift
struct AppStorageCatalog: StorageKeyCatalog {
static var allKeys: [AnyStorageKey] {
[
.key(StorageKeys.AppVersionKey()),
.key(StorageKeys.UserPreferencesKey())
]
}
}
```
2) Generate a report:
```swift
let report = StorageAuditReport.renderText(for: AppStorageCatalog.self)
print(report)
```
3) Register the catalog to enforce usage and catch duplicates:
```swift
do {
try await StorageRouter.shared.registerCatalog(AppStorageCatalog.self)
} catch {
assertionFailure("Storage catalog registration failed: \(error)")
}
```
4) Render a global report of all registered keys across all catalogs:
```swift
// Flat list
let globalReport = await StorageAuditReport.renderGlobalRegistry()
print(globalReport)
// Grouped by catalog module
let groupedReport = await StorageAuditReport.renderGlobalRegistryGrouped()
print(groupedReport)
```
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.