Compare commits

...

3 Commits

Author SHA1 Message Date
3019e19d5b Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2026-01-17 12:33:50 -06:00
a8f49c1cf1 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2026-01-17 12:29:30 -06:00
f4a4f1a527 comments
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2026-01-17 12:18:05 -06:00
43 changed files with 152 additions and 40 deletions

View File

@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
import LocalData import LocalData
/// Defines all demo destinations and provides display metadata for the list.
enum DemoDestination: Hashable, CaseIterable { enum DemoDestination: Hashable, CaseIterable {
case userDefaults case userDefaults
case keychain case keychain
@ -16,6 +17,8 @@ enum DemoDestination: Hashable, CaseIterable {
case sync case sync
case migration case migration
/// Type-erased destination view for NavigationStack routing.
/// The enum keeps navigation data-driven and centralized.
var view: some View { var view: some View {
switch self { switch self {
case .userDefaults: case .userDefaults:
@ -68,6 +71,7 @@ enum DemoDestination: Hashable, CaseIterable {
} }
} }
/// Root navigation list that links to each LocalData demo screen.
struct ContentView: View { struct ContentView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {

View File

@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
/// Design tokens for spacing and common UI colors used in the sample.
enum Design { enum Design {
enum Spacing { enum Spacing {
static let xSmall: CGFloat = 4 static let xSmall: CGFloat = 4
@ -10,6 +11,7 @@ enum Design {
} }
extension Color { extension Color {
/// Semantic colors used for status messaging in demo screens.
enum Status { enum Status {
static let success = Color.green static let success = Color.green
static let info = Color.blue static let info = Color.blue
@ -17,6 +19,7 @@ extension Color {
static let error = Color.red static let error = Color.red
} }
/// Semantic text colors for secondary labels.
enum Text { enum Text {
static let secondary = Color.secondary static let secondary = Color.secondary
} }

View File

@ -1,6 +1,7 @@
import Foundation import Foundation
/// Simple credential model for keychain storage demo. /// Simple credential model for the Keychain demo.
/// Using `Codable` keeps the storage path identical to more complex real-world credentials.
nonisolated nonisolated
struct Credential: Codable, Sendable { struct Credential: Codable, Sendable {
let username: String let username: String

View File

@ -1,5 +1,7 @@
import Foundation import Foundation
/// Structured name model used in migration demos.
/// This replaces a legacy single-string name with distinct components.
nonisolated struct ProfileName: Codable, Sendable { nonisolated struct ProfileName: Codable, Sendable {
let firstName: String let firstName: String
let lastName: String let lastName: String

View File

@ -1,6 +1,8 @@
import Foundation import Foundation
import LocalData import LocalData
/// Identifiers for custom key material sources used by the sample.
/// LocalData uses these identifiers to look up registered providers.
nonisolated enum SampleKeyMaterialSources { nonisolated enum SampleKeyMaterialSources {
nonisolated static let external = KeyMaterialSource(id: "sample.external.key") nonisolated static let external = KeyMaterialSource(id: "sample.external.key")
} }

View File

@ -1,6 +1,7 @@
import Foundation import Foundation
/// Location data model. /// Lightweight location model for the Keychain demo.
/// It shows that LocalData can store structured data even in secure domains.
nonisolated nonisolated
struct SampleLocationData: Codable, Sendable { struct SampleLocationData: Codable, Sendable {
let lat: Double let lat: Double

View File

@ -1,5 +1,7 @@
import Foundation import Foundation
/// Aggregated settings model produced by migration demos.
/// It combines multiple legacy settings into a single modern payload.
nonisolated struct UnifiedSettings: Codable, Sendable { nonisolated struct UnifiedSettings: Codable, Sendable {
let notificationsEnabled: Bool let notificationsEnabled: Bool
let theme: String let theme: String

View File

@ -10,9 +10,13 @@ import LocalData
import SharedKit import SharedKit
@main @main
/// App entry point that centralizes LocalData configuration and storage key registration.
/// Keeping this bootstrap in one place makes the sample's storage behavior easy to audit.
struct SecureStorageSampleApp: App { struct SecureStorageSampleApp: App {
init() { init() {
// Log derived identifiers up front so it's obvious which services the sample uses.
StorageServiceIdentifiers.logConfiguration() StorageServiceIdentifiers.logConfiguration()
// Spin up WCSession early so background sync opportunities aren't missed.
_ = WatchConnectivityService.shared _ = WatchConnectivityService.shared
Task { Task {
// 1. Global Encryption Configuration // 1. Global Encryption Configuration
@ -47,15 +51,18 @@ struct SecureStorageSampleApp: App {
) )
await StorageRouter.shared.updateStorageConfiguration(storageConfig) await StorageRouter.shared.updateStorageConfiguration(storageConfig)
// Register keys once so LocalData can validate and migrate them consistently.
do { do {
try await StorageRouter.shared.registerCatalog(AppStorageCatalog(), migrateImmediately: true) try await StorageRouter.shared.registerCatalog(AppStorageCatalog(), migrateImmediately: true)
} catch { } catch {
assertionFailure("Storage catalog registration failed: \(error)") assertionFailure("Storage catalog registration failed: \(error)")
} }
// Provide external key material for the encrypted storage demo.
await StorageRouter.shared.registerKeyMaterialProvider( await StorageRouter.shared.registerKeyMaterialProvider(
ExternalKeyMaterialProvider(), ExternalKeyMaterialProvider(),
for: SampleKeyMaterialSources.external for: SampleKeyMaterialSources.external
) )
// If a watch is paired, send the current syncable payloads on launch.
await StorageRouter.shared.syncRegisteredKeysIfNeeded() await StorageRouter.shared.syncRegisteredKeysIfNeeded()
} }
#if DEBUG #if DEBUG

View File

@ -3,8 +3,11 @@ import LocalData
import SharedKit import SharedKit
/// Catalog of all storage keys used by the sample app.
/// Registering a catalog allows LocalData to audit keys and run migrations up front.
struct AppStorageCatalog: StorageKeyCatalog { struct AppStorageCatalog: StorageKeyCatalog {
var allKeys: [AnyStorageKey] { var allKeys: [AnyStorageKey] {
// Order here is purely for readability in audit reports.
[ [
.key(.appVersion), .key(.appVersion),
.key(.userPreferences), .key(.userPreferences),

View File

@ -2,18 +2,22 @@ import CryptoKit
import Foundation import Foundation
import LocalData import LocalData
/// Supplies external key material for the encrypted storage demo.
/// The provider persists the generated key material so encryption remains stable across launches.
nonisolated nonisolated
struct ExternalKeyMaterialProvider: KeyMaterialProviding { struct ExternalKeyMaterialProvider: KeyMaterialProviding {
private enum Constants { private enum Constants {
static let keyLength = 32 static let keyLength = 32
} }
/// Returns a stable 256-bit key, generating and persisting it on first use.
func keyMaterial(for keyName: String) async throws -> Data { func keyMaterial(for keyName: String) async throws -> Data {
let key = StorageKey.externalKeyMaterial let key = StorageKey.externalKeyMaterial
if let existing = try? await StorageRouter.shared.get(key) { if let existing = try? await StorageRouter.shared.get(key) {
return existing return existing
} }
// CryptoKit ensures the material is cryptographically random.
let symmetricKey = SymmetricKey(size: .bits256) let symmetricKey = SymmetricKey(size: .bits256)
let material = symmetricKey.withUnsafeBytes { Data($0) } let material = symmetricKey.withUnsafeBytes { Data($0) }
guard material.count == Constants.keyLength else { guard material.count == Constants.keyLength else {

View File

@ -4,6 +4,8 @@ import SharedKit
import WatchConnectivity import WatchConnectivity
@MainActor @MainActor
/// iOS-side WatchConnectivity bridge that keeps the watch app in sync with LocalData.
/// This class focuses on coordination and delegates data payload creation to LocalData.
final class WatchConnectivityService: NSObject, WCSessionDelegate { final class WatchConnectivityService: NSObject, WCSessionDelegate {
static let shared = WatchConnectivityService() static let shared = WatchConnectivityService()
@ -12,6 +14,7 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
activateIfSupported() activateIfSupported()
} }
/// Activates WCSession only on supported devices (no-ops on unsupported hardware).
private func activateIfSupported() { private func activateIfSupported() {
guard WCSession.isSupported() else { return } guard WCSession.isSupported() else { return }
let session = WCSession.default let session = WCSession.default
@ -29,6 +32,7 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
} else { } else {
Logger.debug("iOS WCSession activated with state: \(activationState.rawValue)") Logger.debug("iOS WCSession activated with state: \(activationState.rawValue)")
} }
// Try to sync any previously registered keys as soon as WCSession is ready.
Task { Task {
await StorageRouter.shared.syncRegisteredKeysIfNeeded() await StorageRouter.shared.syncRegisteredKeysIfNeeded()
} }
@ -44,6 +48,7 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
func sessionWatchStateDidChange(_ session: WCSession) { func sessionWatchStateDidChange(_ session: WCSession) {
Logger.debug("iOS WCSession watch state changed: paired=\(session.isPaired) installed=\(session.isWatchAppInstalled)") Logger.debug("iOS WCSession watch state changed: paired=\(session.isPaired) installed=\(session.isWatchAppInstalled)")
// A watch install or pairing change is a good time to re-send syncable keys.
Task { Task {
await StorageRouter.shared.syncRegisteredKeysIfNeeded() await StorageRouter.shared.syncRegisteredKeysIfNeeded()
} }
@ -51,12 +56,14 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
func sessionReachabilityDidChange(_ session: WCSession) { func sessionReachabilityDidChange(_ session: WCSession) {
Logger.debug("iOS WCSession reachability changed: reachable=\(session.isReachable)") Logger.debug("iOS WCSession reachability changed: reachable=\(session.isReachable)")
// When reachability flips, attempt to push any pending sync payloads.
Task { Task {
await StorageRouter.shared.syncRegisteredKeysIfNeeded() await StorageRouter.shared.syncRegisteredKeysIfNeeded()
} }
} }
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
// Watch may request a sync without needing a reply (fire-and-forget message).
if let request = message[WatchSyncMessageKeys.requestSync] as? Bool, request { if let request = message[WatchSyncMessageKeys.requestSync] as? Bool, request {
Logger.debug("iOS received watch sync request") Logger.debug("iOS received watch sync request")
Task { Task {
@ -74,6 +81,7 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
didReceiveMessage message: [String: Any], didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void replyHandler: @escaping ([String: Any]) -> Void
) { ) {
// Reply-based handshake: watch expects a payload immediately if reachable.
if let request = message[WatchSyncMessageKeys.requestSync] as? Bool, request { if let request = message[WatchSyncMessageKeys.requestSync] as? Bool, request {
Logger.debug("iOS received watch sync request (reply)") Logger.debug("iOS received watch sync request (reply)")
Task { Task {
@ -84,6 +92,7 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
} }
} }
/// Attempts to build a snapshot payload, retrying briefly in case keys are still initializing.
private func buildSyncReplyPayload() async -> [String: Any] { private func buildSyncReplyPayload() async -> [String: Any] {
let maxAttempts = 3 let maxAttempts = 3
for attempt in 1...maxAttempts { for attempt in 1...maxAttempts {
@ -104,6 +113,7 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
} }
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
// transferUserInfo can deliver requests when the watch was previously unreachable.
if let request = userInfo[WatchSyncMessageKeys.requestSync] as? Bool, request { if let request = userInfo[WatchSyncMessageKeys.requestSync] as? Bool, request {
Logger.debug("iOS received queued watch sync request") Logger.debug("iOS received queued watch sync request")
Task { Task {

View File

@ -2,6 +2,7 @@ import Foundation
import LocalData import LocalData
import SharedKit import SharedKit
/// App Group UserDefaults key used to demonstrate shared preferences between targets.
extension StorageKey where Value == String { extension StorageKey where Value == String {
/// Stores a shared setting in App Group UserDefaults. /// Stores a shared setting in App Group UserDefaults.
/// - Domain: App Group UserDefaults /// - Domain: App Group UserDefaults

View File

@ -2,6 +2,7 @@ import Foundation
import LocalData import LocalData
import SharedKit import SharedKit
/// App Group file key for sharing a user profile across app targets.
extension StorageKey where Value == UserProfile { extension StorageKey where Value == UserProfile {
/// Stores a shared user profile in the App Group container. /// Stores a shared user profile in the App Group container.
/// - Domain: App Group File System /// - Domain: App Group File System
@ -21,6 +22,9 @@ extension StorageKey where Value == UserProfile {
syncPolicy: .never syncPolicy: .never
) )
/// Creates a version of the key for a different App Group directory.
/// Sample-only: production apps should avoid dynamic App Group configuration and
/// migrate when storage settings change.
nonisolated static func appGroupUserProfileKey( nonisolated static func appGroupUserProfileKey(
directory: FileDirectory = .documents directory: FileDirectory = .documents
) -> StorageKey { ) -> StorageKey {

View File

@ -2,6 +2,7 @@ import Foundation
import LocalData import LocalData
import SharedKit import SharedKit
/// App Group UserDefaults key for a generic preferences dictionary.
extension StorageKey where Value == [String: AnyCodable] { extension StorageKey where Value == [String: AnyCodable] {
/// Stores user preferences in App Group UserDefaults. /// Stores user preferences in App Group UserDefaults.
/// - Domain: App Group UserDefaults /// - Domain: App Group UserDefaults

View File

@ -1,6 +1,7 @@
import Foundation import Foundation
import LocalData import LocalData
/// Encrypted file system key that pulls key material from a registered provider.
extension StorageKey where Value == [String] { extension StorageKey where Value == [String] {
/// Stores session logs with encryption using external key material. /// Stores session logs with encryption using external key material.
nonisolated static let externalSessionLogs = StorageKey( nonisolated static let externalSessionLogs = StorageKey(

View File

@ -1,6 +1,7 @@
import Foundation import Foundation
import LocalData import LocalData
/// Encrypted file system key for long-lived private notes in Documents.
extension StorageKey where Value == String { extension StorageKey where Value == String {
/// Stores private notes with encryption. /// Stores private notes with encryption.
nonisolated static let privateNotes = StorageKey( nonisolated static let privateNotes = StorageKey(

View File

@ -1,6 +1,7 @@
import Foundation import Foundation
import LocalData import LocalData
/// Encrypted file system key for log data with configurable key derivation.
extension StorageKey where Value == [String] { extension StorageKey where Value == [String] {
/// Stores session logs with full encryption. /// Stores session logs with full encryption.
/// Configurable PBKDF2 iterations. /// Configurable PBKDF2 iterations.
@ -15,6 +16,9 @@ extension StorageKey where Value == [String] {
syncPolicy: .never syncPolicy: .never
) )
/// Builds a variant with a custom PBKDF2 iteration count for demo purposes.
/// Sample-only: production apps should treat encryption parameters as fixed and
/// perform a migration if they must change.
nonisolated static func sessionLogsKey(iterations: Int) -> StorageKey { nonisolated static func sessionLogsKey(iterations: Int) -> StorageKey {
StorageKey( StorageKey(
name: "session_logs.json", name: "session_logs.json",

View File

@ -1,6 +1,7 @@
import Foundation import Foundation
import LocalData import LocalData
/// File system key for non-critical cache data stored in Caches.
extension StorageKey where Value == Data { extension StorageKey where Value == Data {
/// Stores cached data files. /// Stores cached data files.
nonisolated static let cachedData = StorageKey( nonisolated static let cachedData = StorageKey(

View File

@ -1,27 +0,0 @@
import Foundation
import LocalData
extension StorageKey where Value == String {
/// Example using custom serializer for specialized encoding.
nonisolated static let customEncoded = StorageKey(
name: "custom_encoded",
domain: .fileSystem(directory: .documents),
security: .none,
serializer: .custom(
encode: { value in
Data(value.utf8).base64EncodedData()
},
decode: { data in
guard let decoded = Data(base64Encoded: data),
let string = String(data: decoded, encoding: .utf8) else {
throw StorageError.deserializationFailed
}
return string
}
),
owner: "SampleApp",
description: "Stores custom-encoded string data (Base64 example).",
availability: .all,
syncPolicy: .never
)
}

View File

@ -1,6 +1,7 @@
import Foundation import Foundation
import LocalData import LocalData
/// File system key that persists a property list export.
extension StorageKey where Value == [String: AnyCodable] { extension StorageKey where Value == [String: AnyCodable] {
/// Stores settings as property list. /// Stores settings as property list.
nonisolated static let settingsPlist = StorageKey( nonisolated static let settingsPlist = StorageKey(

View File

@ -2,6 +2,7 @@ import Foundation
import LocalData import LocalData
import SharedKit import SharedKit
/// File system key for the shared UserProfile payload.
extension StorageKey where Value == UserProfile { extension StorageKey where Value == UserProfile {
/// Stores user profile as JSON file in documents. /// Stores user profile as JSON file in documents.
nonisolated static let userProfileFile = StorageKey( nonisolated static let userProfileFile = StorageKey(
@ -15,6 +16,9 @@ extension StorageKey where Value == UserProfile {
syncPolicy: .automaticSmall syncPolicy: .automaticSmall
) )
/// Builds a profile key for an alternate file directory.
/// Sample-only: production apps should keep directory decisions static; if the
/// storage domain changes, migrate instead of toggling the key at runtime.
nonisolated static func userProfileFileKey(directory: FileDirectory = .documents) -> StorageKey { nonisolated static func userProfileFileKey(directory: FileDirectory = .documents) -> StorageKey {
StorageKey( StorageKey(
name: UserProfile.storageKeyName, name: UserProfile.storageKeyName,

View File

@ -2,6 +2,7 @@ import Foundation
import LocalData import LocalData
import SharedKit import SharedKit
/// Keychain key for short-lived API tokens that should not sync off-device.
extension StorageKey where Value == String { extension StorageKey where Value == String {
/// Stores API token in keychain. /// Stores API token in keychain.
nonisolated static let apiToken = StorageKey( nonisolated static let apiToken = StorageKey(

View File

@ -2,6 +2,7 @@ import Foundation
import LocalData import LocalData
import SharedKit import SharedKit
/// Keychain key used for credentials with optional access control variations.
extension StorageKey where Value == Credential { extension StorageKey where Value == Credential {
/// Stores user credentials securely in keychain. /// Stores user credentials securely in keychain.
/// Configurable accessibility and access control. /// Configurable accessibility and access control.
@ -16,6 +17,9 @@ extension StorageKey where Value == Credential {
syncPolicy: .never syncPolicy: .never
) )
/// Builds a key with custom Keychain accessibility or access control options.
/// Sample-only: production apps should not allow dynamic key configuration; treat
/// StorageKey settings as fixed and migrate if security policies change.
nonisolated static func credentialsKey( nonisolated static func credentialsKey(
accessibility: KeychainAccessibility = .afterFirstUnlock, accessibility: KeychainAccessibility = .afterFirstUnlock,
accessControl: KeychainAccessControl? = nil accessControl: KeychainAccessControl? = nil

View File

@ -2,6 +2,7 @@ import Foundation
import LocalData import LocalData
import SharedKit import SharedKit
/// Keychain key for externally provided key material used in encryption demos.
extension StorageKey where Value == Data { extension StorageKey where Value == Data {
/// Stores external key material used for encryption policies. /// Stores external key material used for encryption policies.
nonisolated static let externalKeyMaterial = StorageKey( nonisolated static let externalKeyMaterial = StorageKey(

View File

@ -2,6 +2,7 @@ import Foundation
import LocalData import LocalData
import SharedKit import SharedKit
/// Keychain key for sensitive location data with user presence required.
extension StorageKey where Value == SampleLocationData { extension StorageKey where Value == SampleLocationData {
/// Stores sensitive location data in keychain with biometric protection. /// Stores sensitive location data in keychain with biometric protection.
nonisolated static let lastLocation = StorageKey( nonisolated static let lastLocation = StorageKey(

View File

@ -1,6 +1,7 @@
import Foundation import Foundation
import LocalData import LocalData
/// Legacy notification toggle stored in UserDefaults.
extension StorageKey where Value == Bool { extension StorageKey where Value == Bool {
nonisolated static let legacyNotificationSetting = StorageKey( nonisolated static let legacyNotificationSetting = StorageKey(
name: "legacy_notification_setting", name: "legacy_notification_setting",
@ -14,6 +15,7 @@ extension StorageKey where Value == Bool {
) )
} }
/// Legacy theme preference stored separately from notifications.
extension StorageKey where Value == String { extension StorageKey where Value == String {
nonisolated static let legacyThemeSetting = StorageKey( nonisolated static let legacyThemeSetting = StorageKey(
name: "legacy_theme_setting", name: "legacy_theme_setting",
@ -27,8 +29,9 @@ extension StorageKey where Value == String {
) )
} }
/// Modern unified settings created by aggregating multiple legacy keys.
extension StorageKey where Value == UnifiedSettings { extension StorageKey where Value == UnifiedSettings {
nonisolated static let modernUnifiedSettings = StorageKey( nonisolated static let modernUnifiedSettings = StorageKey(
name: "modern_unified_settings", name: "modern_unified_settings",
domain: .fileSystem(directory: .documents), domain: .fileSystem(directory: .documents),
@ -48,6 +51,7 @@ extension StorageKey where Value == UnifiedSettings {
destinationKey: key, destinationKey: key,
sourceKeys: sources sourceKeys: sources
) { sources in ) { sources in
// Merge legacy values into a single strongly-typed settings model.
var notificationsEnabled = false var notificationsEnabled = false
var theme = "system" var theme = "system"

View File

@ -1,6 +1,8 @@
import Foundation import Foundation
import LocalData import LocalData
import SharedKit
/// Keys that demonstrate conditional migrations based on app version.
extension StorageKey where Value == String { extension StorageKey where Value == String {
nonisolated static let legacyAppMode = StorageKey( nonisolated static let legacyAppMode = StorageKey(
name: "legacy_app_mode", name: "legacy_app_mode",
@ -15,7 +17,7 @@ extension StorageKey where Value == String {
nonisolated static let modernAppMode = StorageKey( nonisolated static let modernAppMode = StorageKey(
name: "modern_app_mode", name: "modern_app_mode",
domain: .keychain(service: "com.mbrucedogs.securestorage"), domain: .keychain(service: StorageServiceIdentifiers.keychainLocation),
security: .keychain( security: .keychain(
accessibility: .afterFirstUnlock, accessibility: .afterFirstUnlock,
accessControl: .userPresence accessControl: .userPresence

View File

@ -1,8 +1,10 @@
import Foundation import Foundation
import LocalData import LocalData
import SharedKit
/// Keys that demonstrate a simple legacy-to-modern migration path.
extension StorageKey where Value == String { extension StorageKey where Value == String {
/// The legacy key where data starts (in UserDefaults) /// The legacy key where data starts (in UserDefaults).
nonisolated static let legacyMigrationSource = StorageKey( nonisolated static let legacyMigrationSource = StorageKey(
name: "legacy_user_id", name: "legacy_user_id",
domain: .userDefaults(suite: nil), domain: .userDefaults(suite: nil),
@ -14,10 +16,10 @@ extension StorageKey where Value == String {
syncPolicy: .never syncPolicy: .never
) )
/// The modern key where data should end up (in Keychain) /// The modern key where data should end up (in Keychain).
nonisolated static let modernMigrationDestination = StorageKey( nonisolated static let modernMigrationDestination = StorageKey(
name: "secure_user_id", name: "secure_user_id",
domain: .keychain(service: "com.mbrucedogs.securestorage"), domain: .keychain(service: StorageServiceIdentifiers.keychainLocation),
security: .keychain( security: .keychain(
accessibility: .afterFirstUnlock, accessibility: .afterFirstUnlock,
accessControl: .userPresence accessControl: .userPresence
@ -28,6 +30,7 @@ extension StorageKey where Value == String {
availability: .all, availability: .all,
syncPolicy: .never, syncPolicy: .never,
migration: { key in migration: { key in
// Simple "move on first read" migration for legacy identifiers.
AnyStorageMigration( AnyStorageMigration(
SimpleLegacyMigration( SimpleLegacyMigration(
destinationKey: key, destinationKey: key,

View File

@ -1,6 +1,8 @@
import Foundation import Foundation
import LocalData import LocalData
import SharedKit
/// Legacy string payload that will be transformed into a structured model.
extension StorageKey where Value == String { extension StorageKey where Value == String {
nonisolated static let legacyProfileName = StorageKey( nonisolated static let legacyProfileName = StorageKey(
name: "legacy_profile_name", name: "legacy_profile_name",
@ -14,10 +16,11 @@ extension StorageKey where Value == String {
) )
} }
/// Modern structured payload produced by transforming migration.
extension StorageKey where Value == ProfileName { extension StorageKey where Value == ProfileName {
nonisolated static let modernProfileName = StorageKey( nonisolated static let modernProfileName = StorageKey(
name: "modern_profile_name", name: "modern_profile_name",
domain: .keychain(service: "com.mbrucedogs.securestorage"), domain: .keychain(service: StorageServiceIdentifiers.keychainLocation),
security: .keychain( security: .keychain(
accessibility: .afterFirstUnlock, accessibility: .afterFirstUnlock,
accessControl: .userPresence accessControl: .userPresence
@ -34,6 +37,7 @@ extension StorageKey where Value == ProfileName {
destinationKey: key, destinationKey: key,
sourceKey: sourceKey sourceKey: sourceKey
) { value in ) { value in
// Split "First Last" into a structured name for safer usage.
let parts = value.split(separator: " ", maxSplits: 1).map(String.init) let parts = value.split(separator: " ", maxSplits: 1).map(String.init)
let firstName = parts.first ?? "" let firstName = parts.first ?? ""
let lastName = parts.count > 1 ? parts[1] : "" let lastName = parts.count > 1 ? parts[1] : ""

View File

@ -1,6 +1,7 @@
import Foundation import Foundation
import LocalData import LocalData
/// UserDefaults key that highlights platform availability and sync policy options.
extension StorageKey where Value == String { extension StorageKey where Value == String {
/// Syncable setting with configurable platform and sync policy. /// Syncable setting with configurable platform and sync policy.
/// Grouped under Platform to highlight availability/sync behavior. /// Grouped under Platform to highlight availability/sync behavior.
@ -15,6 +16,9 @@ extension StorageKey where Value == String {
syncPolicy: .never syncPolicy: .never
) )
/// Builds a variant to demonstrate different availability and sync policies.
/// Sample-only: production apps should keep availability and sync policies static;
/// if these change, migrate the data rather than altering the key at runtime.
nonisolated static func syncableSettingKey( nonisolated static func syncableSettingKey(
availability: PlatformAvailability = .all, availability: PlatformAvailability = .all,
syncPolicy: SyncPolicy = .never syncPolicy: SyncPolicy = .never

View File

@ -1,6 +1,7 @@
import Foundation import Foundation
import LocalData import LocalData
/// UserDefaults key for watch-only preferences, used to demonstrate availability constraints.
extension StorageKey where Value == Bool { extension StorageKey where Value == Bool {
/// Watch-only setting for vibration. /// Watch-only setting for vibration.
/// Grouped under Platform to highlight watch-only availability. /// Grouped under Platform to highlight watch-only availability.

View File

@ -1,6 +1,7 @@
import Foundation import Foundation
import LocalData import LocalData
/// UserDefaults key used in the demo and as a lightweight migration flag.
extension StorageKey where Value == String { extension StorageKey where Value == String {
/// Stores the app version in standard UserDefaults. /// Stores the app version in standard UserDefaults.
/// - Domain: UserDefaults (standard) /// - Domain: UserDefaults (standard)

View File

@ -2,6 +2,7 @@ import SwiftUI
import LocalData import LocalData
@MainActor @MainActor
/// Demonstrates aggregating multiple legacy keys into a UnifiedSettings model.
struct AggregatingMigrationDemo: View { struct AggregatingMigrationDemo: View {
@State private var notificationsEnabled = false @State private var notificationsEnabled = false
@State private var theme = "" @State private var theme = ""
@ -58,6 +59,7 @@ struct AggregatingMigrationDemo: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} }
/// Seeds the legacy keys to simulate pre-migration settings.
private func saveToLegacy() { private func saveToLegacy() {
isLoading = true isLoading = true
Task { Task {
@ -78,6 +80,7 @@ struct AggregatingMigrationDemo: View {
} }
} }
/// Loads the modern unified key, triggering the aggregation migration.
private func loadFromModern() { private func loadFromModern() {
isLoading = true isLoading = true
statusMessage = "Loading unified settings..." statusMessage = "Loading unified settings..."
@ -95,6 +98,7 @@ struct AggregatingMigrationDemo: View {
} }
} }
/// Formats the aggregated settings for display.
private func format(_ value: UnifiedSettings) -> String { private func format(_ value: UnifiedSettings) -> String {
let notificationsText = value.notificationsEnabled ? "On" : "Off" let notificationsText = value.notificationsEnabled ? "On" : "Off"
let themeText = value.theme.isEmpty ? "system" : value.theme let themeText = value.theme.isEmpty ? "system" : value.theme

View File

@ -2,6 +2,7 @@ import SwiftUI
import LocalData import LocalData
@MainActor @MainActor
/// Demonstrates a migration that only runs when app version criteria are met.
struct ConditionalMigrationDemo: View { struct ConditionalMigrationDemo: View {
@State private var legacyValue = "" @State private var legacyValue = ""
@State private var modernValue = "" @State private var modernValue = ""
@ -59,6 +60,7 @@ struct ConditionalMigrationDemo: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} }
/// Seeds legacy data that will migrate when the version condition passes.
private func saveToLegacy() { private func saveToLegacy() {
isLoading = true isLoading = true
Task { Task {
@ -73,6 +75,7 @@ struct ConditionalMigrationDemo: View {
} }
} }
/// Loads the modern key to trigger the conditional migration.
private func loadFromModern() { private func loadFromModern() {
isLoading = true isLoading = true
statusMessage = "Loading modern mode..." statusMessage = "Loading modern mode..."

View File

@ -9,6 +9,7 @@ import SwiftUI
import LocalData import LocalData
@MainActor @MainActor
/// Demonstrates encrypted file storage with PBKDF2 and external key material options.
struct EncryptedStorageDemo: View { struct EncryptedStorageDemo: View {
@State private var logEntry = "" @State private var logEntry = ""
@State private var storedLogs: [String] = [] @State private var storedLogs: [String] = []
@ -127,6 +128,7 @@ struct EncryptedStorageDemo: View {
} }
} }
/// Appends an encrypted log entry using the selected key derivation mode.
private func addLogEntry() { private func addLogEntry() {
isLoading = true isLoading = true
Task { Task {
@ -152,6 +154,7 @@ struct EncryptedStorageDemo: View {
} }
} }
/// Decrypts and loads log entries into memory for display.
private func loadLogs() { private func loadLogs() {
isLoading = true isLoading = true
Task { Task {
@ -174,6 +177,7 @@ struct EncryptedStorageDemo: View {
} }
} }
/// Clears the encrypted log file for a clean slate.
private func clearLogs() { private func clearLogs() {
isLoading = true isLoading = true
Task { Task {
@ -194,6 +198,7 @@ struct EncryptedStorageDemo: View {
} }
} }
/// Loads existing logs, appends the new entry, and returns the updated payload.
private func updatedLogs(for key: StorageKey<[String]>) async throws -> [String] { private func updatedLogs(for key: StorageKey<[String]>) async throws -> [String] {
var logs: [String] var logs: [String]
do { do {
@ -214,6 +219,7 @@ struct EncryptedStorageDemo: View {
} }
} }
/// Visual reminder that PBKDF2 iterations must remain stable to decrypt existing data.
private struct IterationWarningView: View { private struct IterationWarningView: View {
var body: some View { var body: some View {
Text("PBKDF2 iterations must match the value used during encryption. Changing this after saving will prevent decryption.") Text("PBKDF2 iterations must match the value used during encryption. Changing this after saving will prevent decryption.")

View File

@ -10,6 +10,7 @@ import LocalData
import SharedKit import SharedKit
@MainActor @MainActor
/// Demonstrates file storage in Documents/Caches plus App Group file sharing.
struct FileSystemDemo: View { struct FileSystemDemo: View {
@State private var profileName = "" @State private var profileName = ""
@State private var profileEmail = "" @State private var profileEmail = ""
@ -168,6 +169,7 @@ struct FileSystemDemo: View {
} }
} }
/// Saves a UserProfile to the selected file system directory.
private func saveProfile() { private func saveProfile() {
isLoading = true isLoading = true
Task { Task {
@ -188,6 +190,7 @@ struct FileSystemDemo: View {
} }
} }
/// Loads a UserProfile from the selected directory and updates the form.
private func loadProfile() { private func loadProfile() {
isLoading = true isLoading = true
Task { Task {
@ -212,6 +215,7 @@ struct FileSystemDemo: View {
} }
} }
/// Deletes the profile file from the selected directory.
private func deleteProfile() { private func deleteProfile() {
isLoading = true isLoading = true
Task { Task {
@ -227,6 +231,7 @@ struct FileSystemDemo: View {
} }
} }
/// Persists a UserProfile into the App Group container.
private func saveAppGroupProfile() { private func saveAppGroupProfile() {
isLoading = true isLoading = true
Task { Task {
@ -247,6 +252,7 @@ struct FileSystemDemo: View {
} }
} }
/// Loads a UserProfile from the App Group container.
private func loadAppGroupProfile() { private func loadAppGroupProfile() {
isLoading = true isLoading = true
Task { Task {
@ -271,6 +277,7 @@ struct FileSystemDemo: View {
} }
} }
/// Deletes the App Group profile file to demonstrate shared cleanup.
private func deleteAppGroupProfile() { private func deleteAppGroupProfile() {
isLoading = true isLoading = true
Task { Task {

View File

@ -9,6 +9,7 @@ import SwiftUI
import LocalData import LocalData
@MainActor @MainActor
/// Demonstrates Keychain storage with configurable accessibility and access control.
struct KeychainDemo: View { struct KeychainDemo: View {
@State private var username = "" @State private var username = ""
@State private var password = "" @State private var password = ""
@ -113,6 +114,7 @@ struct KeychainDemo: View {
} }
} }
/// Stores credentials using the currently selected Keychain policies.
private func saveCredentials() { private func saveCredentials() {
isLoading = true isLoading = true
Task { Task {
@ -131,6 +133,7 @@ struct KeychainDemo: View {
} }
} }
/// Loads credentials using the currently selected Keychain policies.
private func loadCredentials() { private func loadCredentials() {
isLoading = true isLoading = true
Task { Task {
@ -157,6 +160,7 @@ struct KeychainDemo: View {
} }
} }
/// Removes the stored credentials from the Keychain.
private func deleteCredentials() { private func deleteCredentials() {
isLoading = true isLoading = true
Task { Task {

View File

@ -2,6 +2,7 @@ import SwiftUI
import LocalData import LocalData
@MainActor @MainActor
/// Demonstrates a simple legacy key migration from UserDefaults to Keychain.
struct MigrationDemo: View { struct MigrationDemo: View {
@State private var legacyValue = "" @State private var legacyValue = ""
@State private var modernValue = "" @State private var modernValue = ""
@ -83,6 +84,7 @@ struct MigrationDemo: View {
// MARK: - Actions // MARK: - Actions
/// Seeds the legacy key to simulate pre-migration data.
private func saveToLegacy() { private func saveToLegacy() {
isLoading = true isLoading = true
Task { Task {
@ -97,6 +99,7 @@ struct MigrationDemo: View {
} }
} }
/// Loads the modern key, triggering the configured migration if needed.
private func loadFromModern() { private func loadFromModern() {
isLoading = true isLoading = true
statusMessage = "Retrieving from Modern..." statusMessage = "Retrieving from Modern..."
@ -115,6 +118,7 @@ struct MigrationDemo: View {
} }
} }
/// Forces a migration sweep to drain legacy data even if the modern key exists.
private func runManualMigration() { private func runManualMigration() {
isLoading = true isLoading = true
statusMessage = "Running manual migration..." statusMessage = "Running manual migration..."
@ -132,6 +136,7 @@ struct MigrationDemo: View {
} }
} }
/// Checks whether the legacy key still exists after migration.
private func checkLegacyExists() { private func checkLegacyExists() {
isLoading = true isLoading = true
Task { Task {

View File

@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
/// Entry point for the migration demos, grouped by strategy.
struct MigrationHubView: View { struct MigrationHubView: View {
var body: some View { var body: some View {
Form { Form {

View File

@ -10,6 +10,7 @@ import LocalData
import WatchConnectivity import WatchConnectivity
@MainActor @MainActor
/// Demonstrates platform availability and Watch sync policies for a single key.
struct PlatformSyncDemo: View { struct PlatformSyncDemo: View {
@State private var settingValue = "" @State private var settingValue = ""
@State private var storedValue = "" @State private var storedValue = ""
@ -137,6 +138,7 @@ struct PlatformSyncDemo: View {
} }
} }
/// Saves the value using the current availability and sync policy selection.
private func saveValue() { private func saveValue() {
isLoading = true isLoading = true
Task { Task {
@ -157,6 +159,7 @@ struct PlatformSyncDemo: View {
} }
} }
/// Loads the value using the current availability and sync policy selection.
private func loadValue() { private func loadValue() {
isLoading = true isLoading = true
Task { Task {
@ -180,6 +183,7 @@ struct PlatformSyncDemo: View {
} }
} }
/// Attempts a read to show the platform access error when applicable.
private func testPlatformError() { private func testPlatformError() {
isLoading = true isLoading = true
Task { Task {
@ -202,6 +206,7 @@ struct PlatformSyncDemo: View {
} }
} }
/// Refreshes the WCSession status shown in the UI.
private func refreshWatchStatus() { private func refreshWatchStatus() {
watchStatus = WatchStatus.current() watchStatus = WatchStatus.current()
} }
@ -209,6 +214,7 @@ struct PlatformSyncDemo: View {
// MARK: - Display Names // MARK: - Display Names
/// UI-friendly names for platform availability options.
extension PlatformAvailability { extension PlatformAvailability {
var displayName: String { var displayName: String {
switch self { switch self {
@ -220,6 +226,7 @@ extension PlatformAvailability {
} }
} }
/// UI-friendly names for sync policies.
extension SyncPolicy { extension SyncPolicy {
var displayName: String { var displayName: String {
switch self { switch self {
@ -236,6 +243,7 @@ extension SyncPolicy {
} }
} }
/// Snapshot of current WatchConnectivity state for display purposes.
private struct WatchStatus: Equatable { private struct WatchStatus: Equatable {
let isSupported: Bool let isSupported: Bool
let isPaired: Bool let isPaired: Bool
@ -273,6 +281,7 @@ private struct WatchStatus: Equatable {
} }
} }
/// Read-only UI for displaying WatchConnectivity state.
private struct WatchStatusView: View { private struct WatchStatusView: View {
let status: WatchStatus let status: WatchStatus
@ -305,6 +314,7 @@ private struct WatchStatusView: View {
} }
} }
/// Explanatory text for each platform availability option.
private struct PlatformAvailabilityDescriptionView: View { private struct PlatformAvailabilityDescriptionView: View {
let availability: PlatformAvailability let availability: PlatformAvailability
@ -328,6 +338,7 @@ private struct PlatformAvailabilityDescriptionView: View {
} }
} }
/// Explanatory text for each sync policy option.
private struct SyncPolicyDescriptionView: View { private struct SyncPolicyDescriptionView: View {
let syncPolicy: SyncPolicy let syncPolicy: SyncPolicy
@ -349,6 +360,7 @@ private struct SyncPolicyDescriptionView: View {
} }
} }
/// Predicts the expected behavior given the selected availability and sync policy.
private struct ExpectedOutcomeView: View { private struct ExpectedOutcomeView: View {
let availability: PlatformAvailability let availability: PlatformAvailability
let syncPolicy: SyncPolicy let syncPolicy: SyncPolicy

View File

@ -2,6 +2,7 @@ import SwiftUI
import LocalData import LocalData
@MainActor @MainActor
/// Demonstrates transforming a legacy string into a structured ProfileName.
struct TransformingMigrationDemo: View { struct TransformingMigrationDemo: View {
@State private var legacyValue = "" @State private var legacyValue = ""
@State private var modernValue = "" @State private var modernValue = ""
@ -59,6 +60,7 @@ struct TransformingMigrationDemo: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} }
/// Seeds the legacy string value to simulate pre-migration data.
private func saveToLegacy() { private func saveToLegacy() {
isLoading = true isLoading = true
Task { Task {
@ -73,6 +75,7 @@ struct TransformingMigrationDemo: View {
} }
} }
/// Loads the modern key, triggering the transforming migration.
private func loadFromModern() { private func loadFromModern() {
isLoading = true isLoading = true
statusMessage = "Loading modern profile..." statusMessage = "Loading modern profile..."
@ -91,6 +94,7 @@ struct TransformingMigrationDemo: View {
} }
} }
/// Formats the migrated name for display in the demo UI.
private func format(_ value: ProfileName) -> String { private func format(_ value: ProfileName) -> String {
let trimmedFirst = value.firstName.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedFirst = value.firstName.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedLast = value.lastName.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedLast = value.lastName.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@ -9,6 +9,7 @@ import SwiftUI
import LocalData import LocalData
@MainActor @MainActor
/// Demonstrates typed UserDefaults keys, including App Group sharing.
struct UserDefaultsDemo: View { struct UserDefaultsDemo: View {
@State private var inputText = "" @State private var inputText = ""
@State private var storedValue = "" @State private var storedValue = ""
@ -136,6 +137,7 @@ struct UserDefaultsDemo: View {
} }
} }
/// Persists the current input value with the standard UserDefaults key.
private func saveValue() { private func saveValue() {
isLoading = true isLoading = true
Task { Task {
@ -150,6 +152,7 @@ struct UserDefaultsDemo: View {
} }
} }
/// Loads the standard UserDefaults value and reflects it in the UI.
private func loadValue() { private func loadValue() {
isLoading = true isLoading = true
Task { Task {
@ -169,6 +172,7 @@ struct UserDefaultsDemo: View {
} }
} }
/// Removes the standard UserDefaults value to reset the demo.
private func removeValue() { private func removeValue() {
isLoading = true isLoading = true
Task { Task {
@ -184,6 +188,7 @@ struct UserDefaultsDemo: View {
} }
} }
/// Saves the input value into the App Group UserDefaults container.
private func saveAppGroupValue() { private func saveAppGroupValue() {
isLoading = true isLoading = true
Task { Task {
@ -198,6 +203,7 @@ struct UserDefaultsDemo: View {
} }
} }
/// Loads the App Group value to demonstrate cross-target sharing.
private func loadAppGroupValue() { private func loadAppGroupValue() {
isLoading = true isLoading = true
Task { Task {
@ -217,6 +223,7 @@ struct UserDefaultsDemo: View {
} }
} }
/// Clears the App Group value to demonstrate cleanup behavior.
private func removeAppGroupValue() { private func removeAppGroupValue() {
isLoading = true isLoading = true
Task { Task {

View File

@ -1,22 +1,24 @@
import Foundation import Foundation
// Example shared models for Watch-optimized data /// Example shared models that demonstrate creating Watch-optimized payloads.
/// The goal is to minimize what needs to traverse WatchConnectivity.
struct Workout: Codable { struct Workout: Codable {
let date: Date let date: Date
// Add other properties as needed // Add other properties as needed
} }
/// Preferences included in the full profile; used to derive a slimmer watch payload.
struct Preferences: Codable { struct Preferences: Codable {
let isPremium: Bool let isPremium: Bool
let appearance: Appearance let appearance: Appearance
} }
/// Display preferences for the demo data.
enum Appearance: Codable { enum Appearance: Codable {
case light, dark case light, dark
} }
// Shared model (iOS + watchOS) /// Shared full-fidelity profile used on iOS, with a computed watch-friendly version.
struct FullUserProfile: Codable { struct FullUserProfile: Codable {
let id: UUID let id: UUID
let fullName: String let fullName: String
@ -25,7 +27,7 @@ struct FullUserProfile: Codable {
let allWorkouts: [Workout] // potentially huge let allWorkouts: [Workout] // potentially huge
let preferences: Preferences let preferences: Preferences
// Lightweight Watch version computed /// Lightweight Watch version computed from the full profile.
var watchVersion: WatchUserProfile { var watchVersion: WatchUserProfile {
WatchUserProfile( WatchUserProfile(
id: id, id: id,
@ -39,6 +41,7 @@ struct FullUserProfile: Codable {
} }
} }
/// Slimmed-down payload intended for watch display only.
struct WatchUserProfile: Codable, Sendable { struct WatchUserProfile: Codable, Sendable {
let id: UUID let id: UUID
let displayName: String let displayName: String
@ -54,7 +57,7 @@ struct WatchUserProfile: Codable, Sendable {
// let watchData = try JSONEncoder().encode(profile.watchVersion) // let watchData = try JSONEncoder().encode(profile.watchVersion)
// WCSession.default.updateApplicationContext(["profile": watchData]) // WCSession.default.updateApplicationContext(["profile": watchData])
// Alternative: WatchRepresentable protocol /// Optional protocol to standardize access to a watch-friendly payload.
public protocol WatchRepresentable { public protocol WatchRepresentable {
associatedtype WatchVersion: Codable, Sendable associatedtype WatchVersion: Codable, Sendable
var watchVersion: WatchVersion { get } var watchVersion: WatchVersion { get }