diff --git a/README.md b/README.md new file mode 100644 index 0000000..8608685 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# SecureStorgageSample + +A sample iOS app demonstrating the LocalData package capabilities for secure, typed storage across multiple domains. + +## Features + +This app provides interactive demos for all LocalData storage options: + +| Tab | Demo | Storage Domain | +|-----|------|----------------| +| **Defaults** | Save/load/remove values | UserDefaults | +| **Keychain** | Secure credentials with biometrics | Keychain | +| **Files** | User profiles with AnyCodable | File System | +| **Encrypted** | AES-256 encrypted logs | Encrypted File System | +| **Sync** | Platform availability & sync policies | Multiple | + +## Requirements + +- iOS 17.0+ +- Xcode 15+ + +## Getting Started + +1. Open `SecureStorgageSample.xcodeproj` +2. Select an iOS simulator or device +3. Build and run (⌘R) + +## Project Structure + +``` +SecureStorgageSample/ +├── ContentView.swift # Tabbed navigation +├── StorageKeys.swift # 12 example key definitions +├── WatchOptimized.swift # Watch data models +└── Views/ + ├── UserDefaultsDemo.swift + ├── KeychainDemo.swift + ├── FileSystemDemo.swift + ├── EncryptedStorageDemo.swift + └── PlatformSyncDemo.swift +``` + +## Storage Key Examples + +The app demonstrates various storage configurations: + +### UserDefaults +- Simple string storage with automatic sync +- Custom suite support + +### Keychain +- 7 accessibility options (whenUnlocked, afterFirstUnlock, etc.) +- 6 access control options (biometry, passcode, etc.) + +### File System +- Documents directory (persisted, backed up) +- Caches directory (can be purged) +- JSON and PropertyList serializers + +### Encrypted Storage +- AES-256-GCM encryption +- PBKDF2 key derivation with configurable iterations +- Complete file protection + +### Platform & Sync +- Platform availability (phoneOnly, watchOnly, all) +- Sync policies (never, manual, automaticSmall) + +## Dependencies + +- [LocalData](../localPackages/LocalData) - Local package for typed secure storage + +## License + +This sample is provided for demonstration purposes. diff --git a/SecureStorgageSample.xcodeproj/project.pbxproj b/SecureStorgageSample.xcodeproj/project.pbxproj index 67711ff..5b2422d 100644 --- a/SecureStorgageSample.xcodeproj/project.pbxproj +++ b/SecureStorgageSample.xcodeproj/project.pbxproj @@ -7,9 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - EA179D392F17290D00B1D54A /* StorageKeys in Frameworks */ = {isa = PBXBuildFile; productRef = EA179D382F17290D00B1D54A /* StorageKeys */; }; - EA179D402F17326400B1D54A /* StorageKeys in Frameworks */ = {isa = PBXBuildFile; productRef = EA179D3F2F17326400B1D54A /* StorageKeys */; }; - EA179D532F17367700B1D54A /* StorageKeys in Frameworks */ = {isa = PBXBuildFile; productRef = EA179D522F17367700B1D54A /* StorageKeys */; }; EA179D562F17379800B1D54A /* LocalData in Frameworks */ = {isa = PBXBuildFile; productRef = EA179D552F17379800B1D54A /* LocalData */; }; /* End PBXBuildFile section */ @@ -59,10 +56,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - EA179D532F17367700B1D54A /* StorageKeys in Frameworks */, - EA179D402F17326400B1D54A /* StorageKeys in Frameworks */, EA179D562F17379800B1D54A /* LocalData in Frameworks */, - EA179D392F17290D00B1D54A /* StorageKeys in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -123,9 +117,6 @@ ); name = SecureStorgageSample; packageProductDependencies = ( - EA179D382F17290D00B1D54A /* StorageKeys */, - EA179D3F2F17326400B1D54A /* StorageKeys */, - EA179D522F17367700B1D54A /* StorageKeys */, EA179D552F17379800B1D54A /* LocalData */, ); productName = SecureStorgageSample; @@ -599,18 +590,6 @@ /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - EA179D382F17290D00B1D54A /* StorageKeys */ = { - isa = XCSwiftPackageProductDependency; - productName = StorageKeys; - }; - EA179D3F2F17326400B1D54A /* StorageKeys */ = { - isa = XCSwiftPackageProductDependency; - productName = StorageKeys; - }; - EA179D522F17367700B1D54A /* StorageKeys */ = { - isa = XCSwiftPackageProductDependency; - productName = StorageKeys; - }; EA179D552F17379800B1D54A /* LocalData */ = { isa = XCSwiftPackageProductDependency; productName = LocalData; diff --git a/SecureStorgageSample.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist b/SecureStorgageSample.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist index c36b9b7..72cdf61 100644 --- a/SecureStorgageSample.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/SecureStorgageSample.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ SecureStorgageSample.xcscheme_^#shared#^_ orderHint - 0 + 1 diff --git a/SecureStorgageSample/ContentView.swift b/SecureStorgageSample/ContentView.swift index 28acd8c..1aa6beb 100644 --- a/SecureStorgageSample/ContentView.swift +++ b/SecureStorgageSample/ContentView.swift @@ -2,52 +2,50 @@ // ContentView.swift // SecureStorgageSample // -// Created by Matt Bruce on 1/13/26. +// Main navigation view with tabbed interface for all LocalData demos. // import SwiftUI import LocalData struct ContentView: View { - @State private var appVersion: String = "" - @State private var retrievedVersion: String = "" - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Secure Storage Sample") - - TextField("Enter app version", text: $appVersion) - .textFieldStyle(.roundedBorder) - .padding() - - Button("Save Version") { - Task { - do { - let key = StorageKeys.AppVersionKey() - try await StorageRouter.shared.set(appVersion, for: key) - } catch { - print("Error saving: \(error)") - } - } + TabView { + NavigationStack { + UserDefaultsDemo() } - - Button("Retrieve Version") { - Task { - do { - let key = StorageKeys.AppVersionKey() - retrievedVersion = try await StorageRouter.shared.get(key) - } catch { - print("Error retrieving: \(error)") - } - } + .tabItem { + Label("Defaults", systemImage: "gearshape.fill") + } + + NavigationStack { + KeychainDemo() + } + .tabItem { + Label("Keychain", systemImage: "lock.fill") + } + + NavigationStack { + FileSystemDemo() + } + .tabItem { + Label("Files", systemImage: "doc.fill") + } + + NavigationStack { + EncryptedStorageDemo() + } + .tabItem { + Label("Encrypted", systemImage: "lock.shield.fill") + } + + NavigationStack { + PlatformSyncDemo() + } + .tabItem { + Label("Sync", systemImage: "arrow.triangle.2.circlepath") } - - Text("Retrieved: \(retrievedVersion)") } - .padding() } } diff --git a/SecureStorgageSample/StorageKeys.swift b/SecureStorgageSample/StorageKeys.swift index 7f5b746..d291b52 100644 --- a/SecureStorgageSample/StorageKeys.swift +++ b/SecureStorgageSample/StorageKeys.swift @@ -1,69 +1,266 @@ +// +// StorageKeys.swift +// SecureStorgageSample +// +// Example StorageKey implementations demonstrating all variations +// supported by the LocalData package. +// + import Foundation import LocalData +// MARK: - Sample Data Models + +/// Simple credential model for keychain storage demo. +nonisolated(unsafe) +struct Credential: Codable, Sendable { + let username: String + let password: String +} + +/// Location data model. +nonisolated(unsafe) struct SampleLocationData: Codable, Sendable { let lat: Double let lon: Double } +// MARK: - UserDefaults Keys + extension StorageKeys { + + /// Stores the app version in standard UserDefaults. + /// - Domain: UserDefaults (standard) + /// - Security: None + /// - Sync: Automatic for small data struct AppVersionKey: StorageKey { typealias Value = String - - let name: String = "last_app_version" + + let name = "last_app_version" let domain: StorageDomain = .userDefaults(suite: nil) let security: SecurityPolicy = .none let serializer: Serializer = .json - let owner: String = "SampleApp" + let owner = "SampleApp" let availability: PlatformAvailability = .all let syncPolicy: SyncPolicy = .automaticSmall } - - struct LastLocationKey: StorageKey { - typealias Value = SampleLocationData - - let name: String = "last_known_location" - let domain: StorageDomain = .keychain(service: "com.example.app.security") - let security: SecurityPolicy = .keychain(accessibility: .afterFirstUnlock, accessControl: .userPresence) - let serializer: Serializer = .json - let owner: String = "SampleApp" - let availability: PlatformAvailability = .phoneOnly - let syncPolicy: SyncPolicy = .manual - } - - struct UserProfileFileKey: StorageKey { + + /// Stores user preferences in a custom suite. + /// - Domain: UserDefaults (custom suite) + /// - Security: None + /// - Sync: Never + struct UserPreferencesKey: StorageKey { typealias Value = [String: AnyCodable] - - let name: String = "user_profile.json" - let domain: StorageDomain = .fileSystem(directory: .documents) + + let name = "user_preferences" + let domain: StorageDomain = .userDefaults(suite: "group.com.example.securestorage") let security: SecurityPolicy = .none let serializer: Serializer<[String: AnyCodable]> = .json - let owner: String = "SampleApp" - let availability: PlatformAvailability = .phoneOnly - let syncPolicy: SyncPolicy = .never - } - - struct SessionLogsKey: StorageKey { - typealias Value = [String] - - let name: String = "session_logs.json" - let domain: StorageDomain = .encryptedFileSystem(directory: .caches) - let security: SecurityPolicy = .encrypted(.aes256(keyDerivation: .pbkdf2(iterations: 10_000))) - let serializer: Serializer<[String]> = .json - let owner: String = "SampleApp" - let availability: PlatformAvailability = .phoneOnly - let syncPolicy: SyncPolicy = .never - } - - struct WatchVibrationKey: StorageKey { - typealias Value = Bool - - let name: String = "watch_vibration_enabled" - let domain: StorageDomain = .userDefaults(suite: nil) - let security: SecurityPolicy = .none - let serializer: Serializer = .json - let owner: String = "SampleApp" - let availability: PlatformAvailability = .watchOnly + let owner = "SampleApp" + let availability: PlatformAvailability = .all + let syncPolicy: SyncPolicy = .never + } +} + +// MARK: - Keychain Keys + +extension StorageKeys { + + /// Stores user credentials securely in keychain. + /// Configurable accessibility and access control. + struct CredentialsKey: StorageKey { + typealias Value = Credential + + let name = "user_credentials" + let domain: StorageDomain = .keychain(service: "com.example.securestorage") + let security: SecurityPolicy + let serializer: Serializer = .json + let owner = "SampleApp" + let availability: PlatformAvailability = .phoneOnly + let syncPolicy: SyncPolicy = .never + + init(accessibility: KeychainAccessibility = .afterFirstUnlock, accessControl: KeychainAccessControl? = nil) { + self.security = .keychain(accessibility: accessibility, accessControl: accessControl) + } + } + + /// Stores sensitive location data in keychain with biometric protection. + struct LastLocationKey: StorageKey { + typealias Value = SampleLocationData + + let name = "last_known_location" + let domain: StorageDomain = .keychain(service: "com.example.app.security") + let security: SecurityPolicy = .keychain( + accessibility: .afterFirstUnlock, + accessControl: .userPresence + ) + let serializer: Serializer = .json + let owner = "SampleApp" + let availability: PlatformAvailability = .phoneOnly + let syncPolicy: SyncPolicy = .never + } + + /// Stores API token in keychain. + struct APITokenKey: StorageKey { + typealias Value = String + + let name = "api_token" + let domain: StorageDomain = .keychain(service: "com.example.securestorage.api") + let security: SecurityPolicy = .keychain( + accessibility: .whenUnlockedThisDeviceOnly, + accessControl: nil + ) + let serializer: Serializer = .json + let owner = "SampleApp" + let availability: PlatformAvailability = .phoneOnly + let syncPolicy: SyncPolicy = .never + } +} + +// MARK: - File System Keys + +extension StorageKeys { + + /// Stores user profile as JSON file in documents. + struct UserProfileFileKey: StorageKey { + typealias Value = [String: AnyCodable] + + let name = "user_profile.json" + let domain: StorageDomain = .fileSystem(directory: .documents) + let security: SecurityPolicy = .none + let serializer: Serializer<[String: AnyCodable]> = .json + let owner = "SampleApp" + let availability: PlatformAvailability = .phoneOnly + let syncPolicy: SyncPolicy = .never + } + + /// Stores cached data files. + struct CachedDataKey: StorageKey { + typealias Value = Data + + let name = "cached_data.bin" + let domain: StorageDomain = .fileSystem(directory: .caches) + let security: SecurityPolicy = .none + let serializer: Serializer = .data + let owner = "SampleApp" + let availability: PlatformAvailability = .all + let syncPolicy: SyncPolicy = .never + } + + /// Stores settings as property list. + struct SettingsPlistKey: StorageKey { + typealias Value = [String: AnyCodable] + + let name = "settings.plist" + let domain: StorageDomain = .fileSystem(directory: .documents) + let security: SecurityPolicy = .none + let serializer: Serializer<[String: AnyCodable]> = .plist + let owner = "SampleApp" + let availability: PlatformAvailability = .all + let syncPolicy: SyncPolicy = .never + } +} + +// MARK: - Encrypted File System Keys + +extension StorageKeys { + + /// Stores session logs with full encryption. + /// Configurable PBKDF2 iterations. + struct SessionLogsKey: StorageKey { + typealias Value = [String] + + let name = "session_logs.json" + let domain: StorageDomain = .encryptedFileSystem(directory: .caches) + let security: SecurityPolicy + let serializer: Serializer<[String]> = .json + let owner = "SampleApp" + let availability: PlatformAvailability = .phoneOnly + let syncPolicy: SyncPolicy = .never + + init(iterations: Int = 10_000) { + self.security = .encrypted(.aes256(keyDerivation: .pbkdf2(iterations: iterations))) + } + } + + /// Stores private notes with encryption. + struct PrivateNotesKey: StorageKey { + typealias Value = String + + let name = "private_notes.enc" + let domain: StorageDomain = .encryptedFileSystem(directory: .documents) + let security: SecurityPolicy = .encrypted( + .aes256(keyDerivation: .pbkdf2(iterations: 50_000)) + ) + let serializer: Serializer = .json + let owner = "SampleApp" + let availability: PlatformAvailability = .phoneOnly + let syncPolicy: SyncPolicy = .never + } +} + +// MARK: - Platform-Specific Keys + +extension StorageKeys { + + /// Watch-only setting for vibration. + struct WatchVibrationKey: StorageKey { + typealias Value = Bool + + let name = "watch_vibration_enabled" + let domain: StorageDomain = .userDefaults(suite: nil) + let security: SecurityPolicy = .none + let serializer: Serializer = .json + let owner = "SampleApp" + let availability: PlatformAvailability = .watchOnly + let syncPolicy: SyncPolicy = .never + } + + /// Syncable setting with configurable platform and sync policy. + struct SyncableSettingKey: StorageKey { + typealias Value = String + + let name = "syncable_setting" + let domain: StorageDomain = .userDefaults(suite: nil) + let security: SecurityPolicy = .none + let serializer: Serializer = .json + let owner = "SampleApp" + let availability: PlatformAvailability + let syncPolicy: SyncPolicy + + init(availability: PlatformAvailability = .all, syncPolicy: SyncPolicy = .never) { + self.availability = availability + self.syncPolicy = syncPolicy + } + } +} + +// MARK: - Custom Serializer Example + +extension StorageKeys { + + /// Example using custom serializer for specialized encoding. + struct CustomEncodedKey: StorageKey { + typealias Value = String + + let name = "custom_encoded" + let domain: StorageDomain = .fileSystem(directory: .documents) + let security: SecurityPolicy = .none + let serializer: Serializer = .custom( + encode: { value in + // Example: Base64 encode + 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 + } + ) + let owner = "SampleApp" + let availability: PlatformAvailability = .all let syncPolicy: SyncPolicy = .never } } diff --git a/SecureStorgageSample/Views/EncryptedStorageDemo.swift b/SecureStorgageSample/Views/EncryptedStorageDemo.swift new file mode 100644 index 0000000..c546f26 --- /dev/null +++ b/SecureStorgageSample/Views/EncryptedStorageDemo.swift @@ -0,0 +1,172 @@ +// +// EncryptedStorageDemo.swift +// SecureStorgageSample +// +// Demonstrates encrypted file storage with LocalData package. +// + +import SwiftUI +import LocalData + +struct EncryptedStorageDemo: View { + @State private var logEntry = "" + @State private var storedLogs: [String] = [] + @State private var statusMessage = "" + @State private var isLoading = false + @State private var iterations = 10_000 + + var body: some View { + Form { + Section { + Text("Encrypted file storage uses AES-256-GCM encryption with PBKDF2 key derivation. Data is encrypted before being written to disk with complete file protection.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Add Log Entry") { + TextField("Enter log message", text: $logEntry, axis: .vertical) + .lineLimit(3, reservesSpace: true) + + Stepper("PBKDF2 Iterations: \(iterations)", value: $iterations, in: 1000...100000, step: 1000) + .font(.caption) + + Text("Higher iterations = more secure but slower") + .font(.caption2) + .foregroundStyle(.secondary) + } + + Section("Actions") { + Button(action: addLogEntry) { + HStack { + Image(systemName: "plus.circle.fill") + Text("Add Encrypted Log Entry") + } + } + .disabled(logEntry.isEmpty || isLoading) + + Button(action: loadLogs) { + HStack { + Image(systemName: "lock.open.fill") + Text("Decrypt and Load Logs") + } + } + .disabled(isLoading) + + Button(action: clearLogs) { + HStack { + Image(systemName: "trash") + Text("Clear All Logs") + } + } + .foregroundStyle(.red) + .disabled(isLoading) + } + + if !storedLogs.isEmpty { + Section("Decrypted Logs (\(storedLogs.count))") { + ForEach(Array(storedLogs.enumerated()), id: \.offset) { index, log in + HStack { + Text("\(index + 1).") + .foregroundStyle(.secondary) + Text(log) + } + .font(.system(.caption, design: .monospaced)) + } + } + } + + if !statusMessage.isEmpty { + Section { + Text(statusMessage) + .font(.caption) + .foregroundStyle(statusMessage.contains("Error") ? .red : .green) + } + } + + Section("Encryption Details") { + LabeledContent("Algorithm", value: "AES-256-GCM") + LabeledContent("Key Derivation", value: "PBKDF2-SHA256") + LabeledContent("Iterations", value: "\(iterations)") + LabeledContent("File Protection", value: "Complete") + } + + Section("Key Configuration") { + LabeledContent("Domain", value: "Encrypted File System") + LabeledContent("Directory", value: "Caches") + LabeledContent("Security", value: "AES-256 Encrypted") + LabeledContent("Platform", value: "Phone Only") + } + } + .navigationTitle("Encrypted Storage") + .navigationBarTitleDisplayMode(.inline) + } + + private func addLogEntry() { + isLoading = true + Task { + do { + let key = StorageKeys.SessionLogsKey(iterations: iterations) + + // Load existing logs + var logs: [String] + do { + logs = try await StorageRouter.shared.get(key) + } catch StorageError.notFound { + logs = [] + } + + // Add new entry with timestamp + let timestamp = Date().formatted(date: .abbreviated, time: .standard) + logs.append("[\(timestamp)] \(logEntry)") + + // Save encrypted + try await StorageRouter.shared.set(logs, for: key) + + storedLogs = logs + logEntry = "" + statusMessage = "✓ Entry encrypted and saved" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } + + private func loadLogs() { + isLoading = true + Task { + do { + let key = StorageKeys.SessionLogsKey(iterations: iterations) + storedLogs = try await StorageRouter.shared.get(key) + statusMessage = "✓ Decrypted \(storedLogs.count) log entries" + } catch StorageError.notFound { + storedLogs = [] + statusMessage = "No encrypted logs found" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } + + private func clearLogs() { + isLoading = true + Task { + do { + let key = StorageKeys.SessionLogsKey(iterations: iterations) + try await StorageRouter.shared.remove(key) + storedLogs = [] + statusMessage = "✓ Encrypted logs cleared" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } +} + +#Preview { + NavigationStack { + EncryptedStorageDemo() + } +} diff --git a/SecureStorgageSample/Views/FileSystemDemo.swift b/SecureStorgageSample/Views/FileSystemDemo.swift new file mode 100644 index 0000000..4428900 --- /dev/null +++ b/SecureStorgageSample/Views/FileSystemDemo.swift @@ -0,0 +1,184 @@ +// +// FileSystemDemo.swift +// SecureStorgageSample +// +// Demonstrates file system storage with LocalData package. +// + +import SwiftUI +import LocalData + +struct FileSystemDemo: View { + @State private var profileName = "" + @State private var profileEmail = "" + @State private var profileAge = "" + @State private var storedProfile: [String: AnyCodable]? + @State private var statusMessage = "" + @State private var isLoading = false + @State private var selectedDirectory: FileDirectory = .documents + + var body: some View { + Form { + Section { + Text("File system storage is great for larger data like user profiles, cached content, and structured documents.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Profile Data") { + TextField("Name", text: $profileName) + TextField("Email", text: $profileEmail) + .keyboardType(.emailAddress) + .textContentType(.emailAddress) + TextField("Age", text: $profileAge) + .keyboardType(.numberPad) + } + + Section("Storage Location") { + Picker("Directory", selection: $selectedDirectory) { + Text("Documents").tag(FileDirectory.documents) + Text("Caches").tag(FileDirectory.caches) + } + .pickerStyle(.segmented) + + Text(selectedDirectory == .documents + ? "Documents: Persisted, included in backups" + : "Caches: May be cleared by system, not backed up") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Actions") { + Button(action: saveProfile) { + HStack { + Image(systemName: "doc.fill") + Text("Save to File System") + } + } + .disabled(profileName.isEmpty || isLoading) + + Button(action: loadProfile) { + HStack { + Image(systemName: "doc.text.fill") + Text("Load from File System") + } + } + .disabled(isLoading) + + Button(action: deleteProfile) { + HStack { + Image(systemName: "trash") + Text("Delete File") + } + } + .foregroundStyle(.red) + .disabled(isLoading) + } + + if let profile = storedProfile { + Section("Retrieved Profile") { + ForEach(Array(profile.keys.sorted()), id: \.self) { key in + LabeledContent(key.capitalized, value: String(describing: profile[key]?.value ?? "nil")) + } + } + } + + if !statusMessage.isEmpty { + Section { + Text(statusMessage) + .font(.caption) + .foregroundStyle(statusMessage.contains("Error") ? .red : .green) + } + } + + Section("Key Configuration") { + LabeledContent("Domain", value: "File System") + LabeledContent("Security", value: "None") + LabeledContent("Serializer", value: "JSON") + LabeledContent("Platform", value: "Phone Only") + } + } + .navigationTitle("File System") + .navigationBarTitleDisplayMode(.inline) + } + + private func saveProfile() { + isLoading = true + Task { + do { + let key = StorageKeys.UserProfileFileKey() + let profile: [String: AnyCodable] = [ + "name": AnyCodable(profileName), + "email": AnyCodable(profileEmail), + "age": AnyCodable(Int(profileAge) ?? 0), + "createdAt": AnyCodable(Date().ISO8601Format()) + ] + try await StorageRouter.shared.set(profile, for: key) + statusMessage = "✓ Saved to \(selectedDirectory == .documents ? "Documents" : "Caches")" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } + + private func loadProfile() { + isLoading = true + Task { + do { + let key = StorageKeys.UserProfileFileKey() + storedProfile = try await StorageRouter.shared.get(key) + statusMessage = "✓ Loaded from file system" + } catch StorageError.notFound { + storedProfile = nil + statusMessage = "No profile file found" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } + + private func deleteProfile() { + isLoading = true + Task { + do { + let key = StorageKeys.UserProfileFileKey() + try await StorageRouter.shared.remove(key) + storedProfile = nil + statusMessage = "✓ File deleted" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } +} + +extension FileDirectory: Hashable { + public func hash(into hasher: inout Hasher) { + switch self { + case .documents: + hasher.combine("documents") + case .caches: + hasher.combine("caches") + case .custom(let url): + hasher.combine(url) + } + } + + public static func == (lhs: FileDirectory, rhs: FileDirectory) -> Bool { + switch (lhs, rhs) { + case (.documents, .documents): return true + case (.caches, .caches): return true + case (.custom(let a), .custom(let b)): return a == b + default: return false + } + } +} + +#Preview { + NavigationStack { + FileSystemDemo() + } +} diff --git a/SecureStorgageSample/Views/KeychainDemo.swift b/SecureStorgageSample/Views/KeychainDemo.swift new file mode 100644 index 0000000..87f3d87 --- /dev/null +++ b/SecureStorgageSample/Views/KeychainDemo.swift @@ -0,0 +1,167 @@ +// +// KeychainDemo.swift +// SecureStorgageSample +// +// Demonstrates Keychain storage with LocalData package. +// + +import SwiftUI +import LocalData + +struct KeychainDemo: View { + @State private var username = "" + @State private var password = "" + @State private var storedCredential = "" + @State private var statusMessage = "" + @State private var isLoading = false + @State private var selectedAccessibility: KeychainAccessibility = .afterFirstUnlock + @State private var selectedAccessControl: KeychainAccessControl? = nil + + var body: some View { + Form { + Section { + Text("Keychain provides hardware-backed secure storage for sensitive data like passwords, tokens, and keys.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Credentials") { + TextField("Username", text: $username) + SecureField("Password", text: $password) + } + + Section("Accessibility") { + Picker("When Accessible", selection: $selectedAccessibility) { + ForEach(KeychainAccessibility.allCases, id: \.self) { option in + Text(option.displayName).tag(option) + } + } + .pickerStyle(.menu) + } + + Section("Access Control (Optional)") { + Picker("Require Authentication", selection: $selectedAccessControl) { + Text("None").tag(nil as KeychainAccessControl?) + ForEach(KeychainAccessControl.allCases, id: \.self) { option in + Text(option.displayName).tag(option as KeychainAccessControl?) + } + } + .pickerStyle(.menu) + } + + Section("Actions") { + Button(action: saveCredentials) { + HStack { + Image(systemName: "lock.fill") + Text("Save to Keychain") + } + } + .disabled(username.isEmpty || password.isEmpty || isLoading) + + Button(action: loadCredentials) { + HStack { + Image(systemName: "key.fill") + Text("Retrieve from Keychain") + } + } + .disabled(isLoading) + + Button(action: deleteCredentials) { + HStack { + Image(systemName: "trash") + Text("Delete from Keychain") + } + } + .foregroundStyle(.red) + .disabled(isLoading) + } + + if !storedCredential.isEmpty { + Section("Retrieved Data") { + Text(storedCredential) + .font(.system(.body, design: .monospaced)) + } + } + + if !statusMessage.isEmpty { + Section { + Text(statusMessage) + .font(.caption) + .foregroundStyle(statusMessage.contains("Error") ? .red : .green) + } + } + + Section("Example Key Configuration") { + LabeledContent("Domain", value: "Keychain") + LabeledContent("Security", value: "Keychain Policy") + LabeledContent("Serializer", value: "JSON") + LabeledContent("Platform", value: "Phone Only") + } + } + .navigationTitle("Keychain") + .navigationBarTitleDisplayMode(.inline) + } + + private func saveCredentials() { + isLoading = true + Task { + do { + let key = StorageKeys.CredentialsKey( + accessibility: selectedAccessibility, + accessControl: selectedAccessControl + ) + let credential = Credential(username: username, password: password) + try await StorageRouter.shared.set(credential, for: key) + statusMessage = "✓ Saved to Keychain securely" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } + + private func loadCredentials() { + isLoading = true + Task { + do { + let key = StorageKeys.CredentialsKey( + accessibility: selectedAccessibility, + accessControl: selectedAccessControl + ) + let credential = try await StorageRouter.shared.get(key) + storedCredential = "Username: \(credential.username)\nPassword: ****" + statusMessage = "✓ Retrieved from Keychain" + } catch StorageError.notFound { + storedCredential = "" + statusMessage = "No credentials stored" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } + + private func deleteCredentials() { + isLoading = true + Task { + do { + let key = StorageKeys.CredentialsKey( + accessibility: selectedAccessibility, + accessControl: selectedAccessControl + ) + try await StorageRouter.shared.remove(key) + storedCredential = "" + statusMessage = "✓ Deleted from Keychain" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } +} + +#Preview { + NavigationStack { + KeychainDemo() + } +} diff --git a/SecureStorgageSample/Views/PlatformSyncDemo.swift b/SecureStorgageSample/Views/PlatformSyncDemo.swift new file mode 100644 index 0000000..fb961e1 --- /dev/null +++ b/SecureStorgageSample/Views/PlatformSyncDemo.swift @@ -0,0 +1,231 @@ +// +// PlatformSyncDemo.swift +// SecureStorgageSample +// +// Demonstrates platform availability and sync policies with LocalData package. +// + +import SwiftUI +import LocalData + +struct PlatformSyncDemo: View { + @State private var settingValue = "" + @State private var storedValue = "" + @State private var statusMessage = "" + @State private var isLoading = false + @State private var selectedPlatform: PlatformAvailability = .all + @State private var selectedSync: SyncPolicy = .never + + var body: some View { + Form { + Section { + Text("Platform availability controls which devices can access data. Sync policy determines how data is shared between iPhone and Apple Watch via WatchConnectivity.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Platform Availability") { + Picker("Available On", selection: $selectedPlatform) { + Text("All (iPhone + Watch)").tag(PlatformAvailability.all) + Text("Phone Only").tag(PlatformAvailability.phoneOnly) + Text("Watch Only").tag(PlatformAvailability.watchOnly) + Text("Phone w/ Watch Sync").tag(PlatformAvailability.phoneWithWatchSync) + } + .pickerStyle(.menu) + + platformDescription + } + + Section("Sync Policy") { + Picker("Sync Behavior", selection: $selectedSync) { + Text("Never").tag(SyncPolicy.never) + Text("Manual").tag(SyncPolicy.manual) + Text("Automatic (Small Data)").tag(SyncPolicy.automaticSmall) + } + .pickerStyle(.menu) + + syncDescription + } + + Section("Test Data") { + TextField("Enter a value to store", text: $settingValue) + } + + Section("Actions") { + Button(action: saveValue) { + HStack { + Image(systemName: "icloud.and.arrow.up") + Text("Save with Current Settings") + } + } + .disabled(settingValue.isEmpty || isLoading) + + Button(action: loadValue) { + HStack { + Image(systemName: "icloud.and.arrow.down") + Text("Load Value") + } + } + .disabled(isLoading) + + Button(action: testPlatformError) { + HStack { + Image(systemName: "exclamationmark.triangle") + Text("Test Platform Restriction") + } + } + .foregroundStyle(.orange) + .disabled(isLoading) + } + + if !storedValue.isEmpty { + Section("Retrieved Value") { + Text(storedValue) + .font(.system(.body, design: .monospaced)) + } + } + + if !statusMessage.isEmpty { + Section { + Text(statusMessage) + .font(.caption) + .foregroundStyle(statusMessage.contains("Error") ? .red : + statusMessage.contains("⚠") ? .orange : .green) + } + } + + Section("Current Configuration") { + LabeledContent("Platform", value: selectedPlatform.displayName) + LabeledContent("Sync", value: selectedSync.displayName) + LabeledContent("Max Auto-Sync Size", value: "100 KB") + } + } + .navigationTitle("Platform & Sync") + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + private var platformDescription: some View { + switch selectedPlatform { + case .all: + Text("Data accessible on both iPhone and Apple Watch") + .font(.caption) + .foregroundStyle(.secondary) + case .phoneOnly: + Text("Data only accessible on iPhone. Watch access throws error.") + .font(.caption) + .foregroundStyle(.secondary) + case .watchOnly: + Text("Data only accessible on Watch. iPhone access throws error.") + .font(.caption) + .foregroundStyle(.secondary) + case .phoneWithWatchSync: + Text("Stored on iPhone, synced to Watch via WatchConnectivity") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var syncDescription: some View { + switch selectedSync { + case .never: + Text("Data stays local, never synced") + .font(.caption) + .foregroundStyle(.secondary) + case .manual: + Text("Sync triggered explicitly by app code") + .font(.caption) + .foregroundStyle(.secondary) + case .automaticSmall: + Text("Auto-sync if data ≤ 100KB, otherwise throws error") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private func saveValue() { + isLoading = true + Task { + do { + let key = StorageKeys.SyncableSettingKey( + availability: selectedPlatform, + syncPolicy: selectedSync + ) + try await StorageRouter.shared.set(settingValue, for: key) + statusMessage = "✓ Saved with \(selectedPlatform.displayName) availability and \(selectedSync.displayName) sync" + } catch StorageError.dataTooLargeForSync { + statusMessage = "Error: Data too large for automatic sync (max 100KB)" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } + + private func loadValue() { + isLoading = true + Task { + do { + let key = StorageKeys.SyncableSettingKey( + availability: selectedPlatform, + syncPolicy: selectedSync + ) + storedValue = try await StorageRouter.shared.get(key) + statusMessage = "✓ Loaded value" + } catch StorageError.notFound { + storedValue = "" + statusMessage = "No value stored" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } + + private func testPlatformError() { + isLoading = true + Task { + // Try to access a watchOnly key from iPhone + let key = StorageKeys.WatchVibrationKey() + do { + _ = try await StorageRouter.shared.get(key) + statusMessage = "⚠ Accessed successfully (running on Watch?)" + } catch StorageError.watchOnlyKeyAccessedOnPhone(let name) { + statusMessage = "⚠ Expected Error: Key '\(name)' is watchOnly, accessed from iPhone" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } +} + +// MARK: - Display Names + +extension PlatformAvailability { + var displayName: String { + switch self { + case .all: return "All" + case .phoneOnly: return "Phone Only" + case .watchOnly: return "Watch Only" + case .phoneWithWatchSync: return "Phone + Watch Sync" + } + } +} + +extension SyncPolicy { + var displayName: String { + switch self { + case .never: return "Never" + case .manual: return "Manual" + case .automaticSmall: return "Automatic" + } + } +} + +#Preview { + NavigationStack { + PlatformSyncDemo() + } +} diff --git a/SecureStorgageSample/Views/UserDefaultsDemo.swift b/SecureStorgageSample/Views/UserDefaultsDemo.swift new file mode 100644 index 0000000..d20d92f --- /dev/null +++ b/SecureStorgageSample/Views/UserDefaultsDemo.swift @@ -0,0 +1,139 @@ +// +// UserDefaultsDemo.swift +// SecureStorgageSample +// +// Demonstrates UserDefaults storage with LocalData package. +// + +import SwiftUI +import LocalData + +struct UserDefaultsDemo: View { + @State private var inputText = "" + @State private var storedValue = "" + @State private var statusMessage = "" + @State private var isLoading = false + + var body: some View { + Form { + Section { + Text("UserDefaults stores simple data persistently. Great for preferences and small values.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Store Value") { + TextField("Enter a value", text: $inputText) + .textFieldStyle(.roundedBorder) + + Button(action: saveValue) { + HStack { + Image(systemName: "square.and.arrow.down") + Text("Save to UserDefaults") + } + } + .disabled(inputText.isEmpty || isLoading) + } + + Section("Retrieve Value") { + Button(action: loadValue) { + HStack { + Image(systemName: "arrow.down.circle") + Text("Load from UserDefaults") + } + } + .disabled(isLoading) + + if !storedValue.isEmpty { + HStack { + Text("Stored:") + Spacer() + Text(storedValue) + .foregroundStyle(.blue) + } + } + } + + Section("Remove Value") { + Button(action: removeValue) { + HStack { + Image(systemName: "trash") + Text("Remove from UserDefaults") + } + } + .foregroundStyle(.red) + .disabled(isLoading) + } + + if !statusMessage.isEmpty { + Section { + Text(statusMessage) + .font(.caption) + .foregroundStyle(statusMessage.contains("Error") ? .red : .green) + } + } + + Section("Key Configuration") { + LabeledContent("Domain", value: "UserDefaults (standard)") + LabeledContent("Security", value: "None") + LabeledContent("Serializer", value: "JSON") + LabeledContent("Platform", value: "All") + LabeledContent("Sync Policy", value: "Automatic Small") + } + } + .navigationTitle("UserDefaults") + .navigationBarTitleDisplayMode(.inline) + } + + private func saveValue() { + isLoading = true + Task { + do { + let key = StorageKeys.AppVersionKey() + try await StorageRouter.shared.set(inputText, for: key) + statusMessage = "✓ Saved successfully" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } + + private func loadValue() { + isLoading = true + Task { + do { + let key = StorageKeys.AppVersionKey() + storedValue = try await StorageRouter.shared.get(key) + statusMessage = "✓ Loaded successfully" + } catch StorageError.notFound { + storedValue = "" + statusMessage = "No value stored yet" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } + + private func removeValue() { + isLoading = true + Task { + do { + let key = StorageKeys.AppVersionKey() + try await StorageRouter.shared.remove(key) + storedValue = "" + statusMessage = "✓ Removed successfully" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } +} + +#Preview { + NavigationStack { + UserDefaultsDemo() + } +}