532 lines
19 KiB
Markdown
532 lines
19 KiB
Markdown
# 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(("📱 <b>APP / FEATURE</b>"))
|
||
|
||
SR["🔀 <b>STORAGE ROUTER</b><br/>(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 -.->|<b>3. AUTOMATIC MIGRATION</b>| 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
|
||
- **StorageProviding** - Abstraction for storage operations
|
||
- **KeyMaterialProviding** - Supplies external key material for encryption
|
||
- **StorageMigration** - Protocol-based migration workflows
|
||
- **ConditionalMigration** - Marker protocol for conditional migrations
|
||
- **TransformingMigration** - Migrations that transform source values
|
||
- **AggregatingMigration** - Migrations that aggregate multiple sources
|
||
|
||
### 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
|
||
- **StorageKey** - Typed storage configuration (generic over Value)
|
||
- **StorageKeyDescriptor** - Audit snapshot of a key’s storage metadata
|
||
- **AnyStorageKey** - Type-erased storage key for catalogs
|
||
- **AnyCodable** - Type-erased Codable for mixed-type payloads
|
||
- **AnyStorageMigration** - Type-erased migration for catalogs and registrations
|
||
- **MigrationContext** - Context for conditional migrations
|
||
- **MigrationResult** - Migration outcome and error reporting
|
||
- **MigrationError** - Migration error cases
|
||
|
||
### Utilities
|
||
- **DeviceInfo** - Device metadata used in migration context
|
||
- **SystemInfo** - System metrics used in migration context
|
||
- **MigrationUtils** - Common migration helpers
|
||
|
||
## Usage
|
||
|
||
### 1. Define Keys
|
||
Extend `StorageKey` with typed static keys:
|
||
|
||
```swift
|
||
import LocalData
|
||
|
||
extension StorageKey where Value == String {
|
||
static let userToken = StorageKey(
|
||
name: "user_token",
|
||
domain: .keychain(service: "com.myapp"),
|
||
security: .keychain(
|
||
accessibility: .afterFirstUnlock,
|
||
accessControl: .biometryAny
|
||
),
|
||
serializer: .json,
|
||
owner: "AuthService",
|
||
description: "Stores the current user auth token.",
|
||
availability: .phoneOnly,
|
||
syncPolicy: .never
|
||
)
|
||
}
|
||
```
|
||
If you omit `security`, it defaults to `SecurityPolicy.recommended`.
|
||
|
||
### 2. Use StorageRouter
|
||
```swift
|
||
// Save
|
||
let key = StorageKey.userToken
|
||
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 StorageKey where Value == UserProfile {
|
||
static let profile = StorageKey(
|
||
name: "user_profile",
|
||
domain: .fileSystem(directory: .documents),
|
||
owner: "ProfileService",
|
||
description: "Stores the current user profile."
|
||
)
|
||
}
|
||
|
||
// ... other properties
|
||
}
|
||
}
|
||
|
||
## Data Migration
|
||
|
||
LocalData supports protocol-based migrations between storage keys and domains (e.g., from a legacy `UserDefaults` string key to a modern secure `Keychain` key).
|
||
|
||
### 1. Automatic Migration (Lazy)
|
||
When you define a `migration` on a key, `StorageRouter.get(key)` will automatically run it if the primary key is not found. If data exists:
|
||
- 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.
|
||
|
||
```swift
|
||
extension StorageKey where Value == String {
|
||
static let legacyToken = StorageKey(
|
||
name: "legacy_token",
|
||
domain: .userDefaults(suite: nil),
|
||
security: .none,
|
||
serializer: .json,
|
||
owner: "AuthService",
|
||
description: "Legacy token stored in UserDefaults."
|
||
)
|
||
|
||
static let modernToken = StorageKey(
|
||
name: "modern_token",
|
||
domain: .keychain(service: "com.myapp"),
|
||
owner: "AuthService",
|
||
description: "Stores the current user auth token.",
|
||
migration: { key in
|
||
AnyStorageMigration(
|
||
SimpleLegacyMigration(
|
||
destinationKey: key,
|
||
sourceKey: .key(StorageKey.legacyToken)
|
||
)
|
||
)
|
||
}
|
||
)
|
||
}
|
||
```
|
||
|
||
### 2. Protocol-Based Migration (Recommended)
|
||
For complex migrations, implement `StorageMigration` and attach it to the key.
|
||
|
||
```swift
|
||
struct TokenMigration: StorageMigration {
|
||
typealias Value = String
|
||
|
||
let destinationKey = StorageKey.userToken
|
||
let legacyKey = StorageKey.legacyToken
|
||
|
||
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
|
||
try await router.exists(legacyKey)
|
||
}
|
||
|
||
func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
|
||
let legacyToken = try await router.get(legacyKey)
|
||
try await router.set(legacyToken, for: destinationKey)
|
||
try await router.remove(legacyKey)
|
||
return MigrationResult(success: true, migratedCount: 1)
|
||
}
|
||
}
|
||
|
||
extension StorageKey where Value == String {
|
||
static let userToken = StorageKey(
|
||
name: "user_token",
|
||
domain: .keychain(service: "com.myapp"),
|
||
owner: "AuthService",
|
||
description: "Stores the current user auth token.",
|
||
migration: { _ in AnyStorageMigration(TokenMigration()) }
|
||
)
|
||
}
|
||
```
|
||
|
||
### 3. 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.forceMigration(for: StorageKey.modernToken)
|
||
```
|
||
|
||
#### 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())
|
||
|
||
// Module B
|
||
try await StorageRouter.shared.registerCatalog(ProfileCatalog())
|
||
```
|
||
|
||
## 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 `StorageKey` values.
|
||
|
||
### 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.
|
||
|
||
### Bootstrapping on Launch
|
||
To ensure the watch receives the latest values after app relaunch or reconnection, call:
|
||
|
||
```swift
|
||
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
|
||
```
|
||
|
||
This re-sends stored values for any registered keys that are eligible for sync. Apps typically call this on launch and when WatchConnectivity becomes reachable.
|
||
|
||
### Responding to Watch-Initiated Sync Requests
|
||
If your watch app asks for an explicit refresh, you can build a snapshot of syncable data and reply via WCSession messaging:
|
||
|
||
```swift
|
||
let snapshot = await StorageRouter.shared.syncSnapshot()
|
||
// Reply with snapshot (key: Data) via WCSession.sendMessage
|
||
```
|
||
|
||
### Platform Sync Guide
|
||
For end-to-end iOS + watchOS setup (including a launch-order-safe handshake), see:
|
||
|
||
`Documentation/PlatformSync.md`
|
||
|
||
## Platforms
|
||
- iOS 17+
|
||
- watchOS 10+
|
||
|
||
## Testing
|
||
- Unit tests use Swift Testing (`Testing` package)
|
||
|
||
## DocC Documentation
|
||
|
||
DocC uses the SwiftPM-generated workspace under `.swiftpm/xcode/`.
|
||
|
||
Build the documentation archive from the package root:
|
||
|
||
```bash
|
||
./Documentation/build-docc.sh
|
||
```
|
||
|
||
Docs live in two places:
|
||
- `Sources/LocalData/Documentation.docc` (DocC guides and Home page)
|
||
- `Documentation/` (additional reference docs)
|
||
|
||
## 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` is generic over `Value`, which means you cannot store different key value types in a single array using `[StorageKey]`. Swift requires type erasure for heterogeneous key 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 {
|
||
let allKeys: [AnyStorageKey] = [
|
||
.key(StorageKey.appVersion),
|
||
.key(StorageKey.userPreferences)
|
||
]
|
||
}
|
||
```
|
||
|
||
2) Generate a report:
|
||
|
||
```swift
|
||
let report = StorageAuditReport.renderText(AppStorageCatalog())
|
||
print(report)
|
||
```
|
||
|
||
3) Create and register the catalog to enforce usage and catch duplicates:
|
||
|
||
```swift
|
||
let appCatalog = AppStorageCatalog()
|
||
do {
|
||
try await StorageRouter.shared.registerCatalog(appCatalog)
|
||
} 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.
|