From 93eaaa353a26e9168488f8f72bb9157892e6f587 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Wed, 14 Jan 2026 11:54:40 -0600 Subject: [PATCH] Add AppGroupConfiguration, StorageKeys, AppGroupUserDefaultsKey, Value (+7 more); Remove AppStorageCatalog --- README.md | 7 ++ .../Models/AppGroupConfiguration.swift | 5 + .../Services/AppStorageCatalog.swift | 6 +- .../AppGroup/AppGroupUserDefaultsKey.swift | 21 ++++ .../AppGroup/AppGroupUserProfileKey.swift | 30 ++++++ .../UserPreferencesKey.swift | 0 .../Views/FileSystemDemo.swift | 102 ++++++++++++++++++ .../Views/UserDefaultsDemo.swift | 87 +++++++++++++++ 8 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 SecureStorgageSample/Models/AppGroupConfiguration.swift create mode 100644 SecureStorgageSample/StorageKeys/AppGroup/AppGroupUserDefaultsKey.swift create mode 100644 SecureStorgageSample/StorageKeys/AppGroup/AppGroupUserProfileKey.swift rename SecureStorgageSample/StorageKeys/{UserDefaults => AppGroup}/UserPreferencesKey.swift (100%) diff --git a/README.md b/README.md index 51f2617..4fd5050 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ The project also includes a watchOS companion app target for watch-specific demo 1. Open `SecureStorgageSample.xcodeproj` 2. Select an iOS simulator or device 3. Build and run (⌘R) +4. To use App Group demos, set your App Group identifier in `SecureStorgageSample/SecureStorgageSample/Models/AppGroupConfiguration.swift` and enable the entitlement for each target that should share data. ## Project Structure @@ -49,6 +50,7 @@ SecureStorgageSample/ │ ├── Keychain/ │ ├── FileSystem/ │ ├── EncryptedFileSystem/ +│ ├── AppGroup/ │ └── Platform/ ├── WatchOptimized.swift # Watch data models ├── Services/ @@ -91,6 +93,11 @@ The app demonstrates various storage configurations: - Caches directory (can be purged) - JSON and PropertyList serializers +### App Group Storage +- Shared UserDefaults via App Group identifier +- Shared files in the App Group container +- Requires App Group entitlements in all participating targets + ### Encrypted Storage - AES-256-GCM or ChaCha20-Poly1305 encryption - PBKDF2 or HKDF key derivation diff --git a/SecureStorgageSample/Models/AppGroupConfiguration.swift b/SecureStorgageSample/Models/AppGroupConfiguration.swift new file mode 100644 index 0000000..47e8f0d --- /dev/null +++ b/SecureStorgageSample/Models/AppGroupConfiguration.swift @@ -0,0 +1,5 @@ +import Foundation + +enum AppGroupConfiguration { + static let identifier = "group.com.example.securestorage" +} diff --git a/SecureStorgageSample/Services/AppStorageCatalog.swift b/SecureStorgageSample/Services/AppStorageCatalog.swift index 7a0ab8f..8bcb8a0 100644 --- a/SecureStorgageSample/Services/AppStorageCatalog.swift +++ b/SecureStorgageSample/Services/AppStorageCatalog.swift @@ -3,7 +3,7 @@ import LocalData import SharedKit -struct AppStorageCatalog: StorageKeyCatalog { +nonisolated struct AppStorageCatalog: StorageKeyCatalog { static var allKeys: [AnyStorageKey] { [ .key(StorageKeys.AppVersionKey()), @@ -19,7 +19,9 @@ struct AppStorageCatalog: StorageKeyCatalog { .key(StorageKeys.ExternalSessionLogsKey()), .key(StorageKeys.WatchVibrationKey()), .key(StorageKeys.SyncableSettingKey()), - .key(StorageKeys.ExternalKeyMaterialKey()) + .key(StorageKeys.ExternalKeyMaterialKey()), + .key(StorageKeys.AppGroupUserDefaultsKey()), + .key(StorageKeys.AppGroupUserProfileKey()) ] } } diff --git a/SecureStorgageSample/StorageKeys/AppGroup/AppGroupUserDefaultsKey.swift b/SecureStorgageSample/StorageKeys/AppGroup/AppGroupUserDefaultsKey.swift new file mode 100644 index 0000000..de15c57 --- /dev/null +++ b/SecureStorgageSample/StorageKeys/AppGroup/AppGroupUserDefaultsKey.swift @@ -0,0 +1,21 @@ +import Foundation +import LocalData + +extension StorageKeys { + /// Stores a shared setting in App Group UserDefaults. + /// - Domain: App Group UserDefaults + /// - Security: None + /// - Sync: Never + struct AppGroupUserDefaultsKey: StorageKey { + typealias Value = String + + let name = "app_group_setting" + let domain: StorageDomain = .appGroupUserDefaults(identifier: AppGroupConfiguration.identifier) + let security: SecurityPolicy = .none + let serializer: Serializer = .json + let owner = "SampleApp" + let description = "Stores a shared setting readable by app extensions." + let availability: PlatformAvailability = .all + let syncPolicy: SyncPolicy = .never + } +} diff --git a/SecureStorgageSample/StorageKeys/AppGroup/AppGroupUserProfileKey.swift b/SecureStorgageSample/StorageKeys/AppGroup/AppGroupUserProfileKey.swift new file mode 100644 index 0000000..8ade156 --- /dev/null +++ b/SecureStorgageSample/StorageKeys/AppGroup/AppGroupUserProfileKey.swift @@ -0,0 +1,30 @@ +import Foundation +import LocalData +import SharedKit + +extension StorageKeys { + /// Stores a shared user profile in the App Group container. + /// - Domain: App Group File System + /// - Security: None + /// - Sync: Never + struct AppGroupUserProfileKey: StorageKey { + typealias Value = UserProfile + + let directory: FileDirectory + let name = "app_group_user_profile.json" + let security: SecurityPolicy = .none + let serializer: Serializer = .json + let owner = "SampleApp" + let description = "Stores a profile shared between the app and extensions." + let availability: PlatformAvailability = .all + let syncPolicy: SyncPolicy = .never + + init(directory: FileDirectory = .documents) { + self.directory = directory + } + + var domain: StorageDomain { + .appGroupFileSystem(identifier: AppGroupConfiguration.identifier, directory: directory) + } + } +} diff --git a/SecureStorgageSample/StorageKeys/UserDefaults/UserPreferencesKey.swift b/SecureStorgageSample/StorageKeys/AppGroup/UserPreferencesKey.swift similarity index 100% rename from SecureStorgageSample/StorageKeys/UserDefaults/UserPreferencesKey.swift rename to SecureStorgageSample/StorageKeys/AppGroup/UserPreferencesKey.swift diff --git a/SecureStorgageSample/Views/FileSystemDemo.swift b/SecureStorgageSample/Views/FileSystemDemo.swift index 7b8d61e..0dbbd01 100644 --- a/SecureStorgageSample/Views/FileSystemDemo.swift +++ b/SecureStorgageSample/Views/FileSystemDemo.swift @@ -14,7 +14,9 @@ struct FileSystemDemo: View { @State private var profileEmail = "" @State private var profileAge = "" @State private var storedProfile: UserProfile? + @State private var appGroupProfile: UserProfile? @State private var statusMessage = "" + @State private var appGroupStatusMessage = "" @State private var isLoading = false @State private var selectedDirectory: FileDirectory = .documents @FocusState private var isFieldFocused: Bool @@ -79,6 +81,37 @@ struct FileSystemDemo: View { .foregroundStyle(.red) .disabled(isLoading) } + + Section("App Group Storage") { + Text("Requires App Group entitlement and matching identifier in AppGroupConfiguration.") + .font(.caption) + .foregroundStyle(.secondary) + + Button(action: saveAppGroupProfile) { + HStack { + Image(systemName: "doc.fill") + Text("Save to App Group") + } + } + .disabled(profileName.isEmpty || isLoading) + + Button(action: loadAppGroupProfile) { + HStack { + Image(systemName: "doc.text.fill") + Text("Load from App Group") + } + } + .disabled(isLoading) + + Button(action: deleteAppGroupProfile) { + HStack { + Image(systemName: "trash") + Text("Delete App Group File") + } + } + .foregroundStyle(.red) + .disabled(isLoading) + } if let profile = storedProfile { Section("Retrieved Profile") { @@ -88,6 +121,15 @@ struct FileSystemDemo: View { LabeledContent("Created", value: profile.createdAt.formatted(date: .abbreviated, time: .shortened)) } } + + if let profile = appGroupProfile { + Section("App Group Profile") { + LabeledContent("Name", value: profile.name) + LabeledContent("Email", value: profile.email) + LabeledContent("Age", value: profile.ageDescription) + LabeledContent("Created", value: profile.createdAt.formatted(date: .abbreviated, time: .shortened)) + } + } if !statusMessage.isEmpty { Section { @@ -96,6 +138,14 @@ struct FileSystemDemo: View { .foregroundStyle(statusMessage.contains("Error") ? .red : .green) } } + + if !appGroupStatusMessage.isEmpty { + Section { + Text(appGroupStatusMessage) + .font(.caption) + .foregroundStyle(appGroupStatusMessage.contains("Error") ? .red : .green) + } + } Section("Key Configuration") { LabeledContent("Domain", value: "File System") @@ -167,6 +217,58 @@ struct FileSystemDemo: View { isLoading = false } } + + private func saveAppGroupProfile() { + isLoading = true + Task { + do { + let key = StorageKeys.AppGroupUserProfileKey(directory: selectedDirectory) + let profile = UserProfile( + name: profileName, + email: profileEmail, + age: Int(profileAge), + createdAt: Date() + ) + try await StorageRouter.shared.set(profile, for: key) + appGroupStatusMessage = "✓ App Group saved to \(selectedDirectory == .documents ? "Documents" : "Caches")" + } catch { + appGroupStatusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } + + private func loadAppGroupProfile() { + isLoading = true + Task { + do { + let key = StorageKeys.AppGroupUserProfileKey(directory: selectedDirectory) + appGroupProfile = try await StorageRouter.shared.get(key) + appGroupStatusMessage = "✓ App Group loaded from file system" + } catch StorageError.notFound { + appGroupProfile = nil + appGroupStatusMessage = "No App Group profile file found" + } catch { + appGroupStatusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } + + private func deleteAppGroupProfile() { + isLoading = true + Task { + do { + let key = StorageKeys.AppGroupUserProfileKey(directory: selectedDirectory) + try await StorageRouter.shared.remove(key) + appGroupProfile = nil + appGroupStatusMessage = "✓ App Group file deleted" + } catch { + appGroupStatusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } } #Preview { diff --git a/SecureStorgageSample/Views/UserDefaultsDemo.swift b/SecureStorgageSample/Views/UserDefaultsDemo.swift index d96c6a9..6bbbe2a 100644 --- a/SecureStorgageSample/Views/UserDefaultsDemo.swift +++ b/SecureStorgageSample/Views/UserDefaultsDemo.swift @@ -11,6 +11,7 @@ import LocalData struct UserDefaultsDemo: View { @State private var inputText = "" @State private var storedValue = "" + @State private var appGroupStoredValue = "" @State private var statusMessage = "" @State private var isLoading = false @FocusState private var isInputFocused: Bool @@ -66,6 +67,46 @@ struct UserDefaultsDemo: View { .foregroundStyle(.red) .disabled(isLoading) } + + Section("App Group UserDefaults") { + Text("Requires App Group entitlement and matching identifier in AppGroupConfiguration.") + .font(.caption) + .foregroundStyle(.secondary) + + Button(action: saveAppGroupValue) { + HStack { + Image(systemName: "square.and.arrow.down") + Text("Save to App Group") + } + } + .disabled(inputText.isEmpty || isLoading) + + Button(action: loadAppGroupValue) { + HStack { + Image(systemName: "arrow.down.circle") + Text("Load from App Group") + } + } + .disabled(isLoading) + + Button(action: removeAppGroupValue) { + HStack { + Image(systemName: "trash") + Text("Remove from App Group") + } + } + .foregroundStyle(.red) + .disabled(isLoading) + + if !appGroupStoredValue.isEmpty { + HStack { + Text("App Group Stored:") + Spacer() + Text(appGroupStoredValue) + .foregroundStyle(.blue) + } + } + } if !statusMessage.isEmpty { Section { @@ -140,6 +181,52 @@ struct UserDefaultsDemo: View { isLoading = false } } + + private func saveAppGroupValue() { + isLoading = true + Task { + do { + let key = StorageKeys.AppGroupUserDefaultsKey() + try await StorageRouter.shared.set(inputText, for: key) + statusMessage = "✓ App Group saved successfully" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } + + private func loadAppGroupValue() { + isLoading = true + Task { + do { + let key = StorageKeys.AppGroupUserDefaultsKey() + appGroupStoredValue = try await StorageRouter.shared.get(key) + statusMessage = "✓ App Group loaded successfully" + } catch StorageError.notFound { + appGroupStoredValue = "" + statusMessage = "No App Group value stored yet" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } + + private func removeAppGroupValue() { + isLoading = true + Task { + do { + let key = StorageKeys.AppGroupUserDefaultsKey() + try await StorageRouter.shared.remove(key) + appGroupStoredValue = "" + statusMessage = "✓ App Group value removed" + } catch { + statusMessage = "Error: \(error.localizedDescription)" + } + isLoading = false + } + } } #Preview {