Add Credential, UserPreferencesKey, Value, StorageKeys (+29 more)
This commit is contained in:
parent
0dd3ddcbae
commit
e111b9a1d3
75
README.md
Normal file
75
README.md
Normal file
@ -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.
|
||||
@ -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;
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
<key>SecureStorgageSample.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
@ -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()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Defaults", systemImage: "gearshape.fill")
|
||||
}
|
||||
|
||||
Button("Retrieve Version") {
|
||||
Task {
|
||||
do {
|
||||
let key = StorageKeys.AppVersionKey()
|
||||
retrievedVersion = try await StorageRouter.shared.get(key)
|
||||
} catch {
|
||||
print("Error retrieving: \(error)")
|
||||
}
|
||||
NavigationStack {
|
||||
KeychainDemo()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Keychain", systemImage: "lock.fill")
|
||||
}
|
||||
|
||||
Text("Retrieved: \(retrievedVersion)")
|
||||
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")
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<String> = .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<SampleLocationData> = .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<Bool> = .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<Credential> = .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<SampleLocationData> = .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<String> = .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> = .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<String> = .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<Bool> = .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<String> = .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<String> = .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
|
||||
}
|
||||
}
|
||||
|
||||
172
SecureStorgageSample/Views/EncryptedStorageDemo.swift
Normal file
172
SecureStorgageSample/Views/EncryptedStorageDemo.swift
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
184
SecureStorgageSample/Views/FileSystemDemo.swift
Normal file
184
SecureStorgageSample/Views/FileSystemDemo.swift
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
167
SecureStorgageSample/Views/KeychainDemo.swift
Normal file
167
SecureStorgageSample/Views/KeychainDemo.swift
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
231
SecureStorgageSample/Views/PlatformSyncDemo.swift
Normal file
231
SecureStorgageSample/Views/PlatformSyncDemo.swift
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
139
SecureStorgageSample/Views/UserDefaultsDemo.swift
Normal file
139
SecureStorgageSample/Views/UserDefaultsDemo.swift
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user