Support iCloud sync as an option (#350)

Sync will be enabled on upgrade for consistency with current behavior,
and disabled for new installs.

Fixes #227
This commit is contained in:
Davide De Rosa 2023-09-09 20:29:04 +02:00 committed by GitHub
parent 2d046181b0
commit a4ca8cc996
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 198 additions and 35 deletions

View File

@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Make iCloud an opt-in preference. [#227](https://github.com/passepartoutvpn/passepartout-apple/issues/227)
- OpenVPN: Endpoint UX. [#332](https://github.com/passepartoutvpn/passepartout-apple/pull/332) - OpenVPN: Endpoint UX. [#332](https://github.com/passepartoutvpn/passepartout-apple/pull/332)
- Convert trusted networks to on-demand activation. [#119](https://github.com/passepartoutvpn/passepartout-apple/issues/119) - Convert trusted networks to on-demand activation. [#119](https://github.com/passepartoutvpn/passepartout-apple/issues/119)

View File

@ -53,6 +53,7 @@
0E3A3C132AAB7C480003A5F6 /* UpgradeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3A3C112AAB7C470003A5F6 /* UpgradeManager.swift */; }; 0E3A3C132AAB7C480003A5F6 /* UpgradeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3A3C112AAB7C470003A5F6 /* UpgradeManager.swift */; };
0E3A3C142AAB7C480003A5F6 /* DefaultUpgradeManagerStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3A3C122AAB7C480003A5F6 /* DefaultUpgradeManagerStrategy.swift */; }; 0E3A3C142AAB7C480003A5F6 /* DefaultUpgradeManagerStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3A3C122AAB7C480003A5F6 /* DefaultUpgradeManagerStrategy.swift */; };
0E3A3C162AAB8AB80003A5F6 /* UpgradeManagerStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3A3C152AAB8AB80003A5F6 /* UpgradeManagerStrategy.swift */; }; 0E3A3C162AAB8AB80003A5F6 /* UpgradeManagerStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3A3C152AAB8AB80003A5F6 /* UpgradeManagerStrategy.swift */; };
0E3A3C102AA9AA530003A5F6 /* KeyValueStore+CloudKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3A3C0F2AA9AA530003A5F6 /* KeyValueStore+CloudKit.swift */; };
0E3A593C2A50975700B3FE40 /* ErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3A593B2A50975700B3FE40 /* ErrorHandler.swift */; }; 0E3A593C2A50975700B3FE40 /* ErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3A593B2A50975700B3FE40 /* ErrorHandler.swift */; };
0E3B7FCD27E47B3700C66F13 /* AddHostView+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3B7FCC27E47B3700C66F13 /* AddHostView+Name.swift */; }; 0E3B7FCD27E47B3700C66F13 /* AddHostView+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3B7FCC27E47B3700C66F13 /* AddHostView+Name.swift */; };
0E3B7FD627E5173A00C66F13 /* ProfileView+VPN.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3B7FD527E5173A00C66F13 /* ProfileView+VPN.swift */; }; 0E3B7FD627E5173A00C66F13 /* ProfileView+VPN.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3B7FD527E5173A00C66F13 /* ProfileView+VPN.swift */; };
@ -346,6 +347,7 @@
0E3A3C112AAB7C470003A5F6 /* UpgradeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpgradeManager.swift; sourceTree = "<group>"; }; 0E3A3C112AAB7C470003A5F6 /* UpgradeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpgradeManager.swift; sourceTree = "<group>"; };
0E3A3C122AAB7C480003A5F6 /* DefaultUpgradeManagerStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultUpgradeManagerStrategy.swift; sourceTree = "<group>"; }; 0E3A3C122AAB7C480003A5F6 /* DefaultUpgradeManagerStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultUpgradeManagerStrategy.swift; sourceTree = "<group>"; };
0E3A3C152AAB8AB80003A5F6 /* UpgradeManagerStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpgradeManagerStrategy.swift; sourceTree = "<group>"; }; 0E3A3C152AAB8AB80003A5F6 /* UpgradeManagerStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpgradeManagerStrategy.swift; sourceTree = "<group>"; };
0E3A3C0F2AA9AA530003A5F6 /* KeyValueStore+CloudKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyValueStore+CloudKit.swift"; sourceTree = "<group>"; };
0E3A593B2A50975700B3FE40 /* ErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorHandler.swift; sourceTree = "<group>"; }; 0E3A593B2A50975700B3FE40 /* ErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorHandler.swift; sourceTree = "<group>"; };
0E3B7FCC27E47B3700C66F13 /* AddHostView+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AddHostView+Name.swift"; sourceTree = "<group>"; }; 0E3B7FCC27E47B3700C66F13 /* AddHostView+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AddHostView+Name.swift"; sourceTree = "<group>"; };
0E3B7FD527E5173A00C66F13 /* ProfileView+VPN.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileView+VPN.swift"; sourceTree = "<group>"; }; 0E3B7FD527E5173A00C66F13 /* ProfileView+VPN.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileView+VPN.swift"; sourceTree = "<group>"; };
@ -744,6 +746,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0EBC075C27EC529000208AD9 /* DebugLog+Constants.swift */, 0EBC075C27EC529000208AD9 /* DebugLog+Constants.swift */,
0E3A3C0F2AA9AA530003A5F6 /* KeyValueStore+CloudKit.swift */,
0EB17EB927D2560300D473B5 /* PassepartoutProviders+Extensions.swift */, 0EB17EB927D2560300D473B5 /* PassepartoutProviders+Extensions.swift */,
0E35C099280E95BB0071FA35 /* ProviderProfileAvailability.swift */, 0E35C099280E95BB0071FA35 /* ProviderProfileAvailability.swift */,
0E2DE71B27DCCFE80067B9E1 /* TunnelKit+Extensions.swift */, 0E2DE71B27DCCFE80067B9E1 /* TunnelKit+Extensions.swift */,
@ -1542,6 +1545,7 @@
0E53249927D26B51002565C3 /* ProductManager.swift in Sources */, 0E53249927D26B51002565C3 /* ProductManager.swift in Sources */,
0E9C233027F47032007D5FC7 /* IntentsManager.swift in Sources */, 0E9C233027F47032007D5FC7 /* IntentsManager.swift in Sources */,
0EB4042C27CA0E8C00378B1A /* Unlocalized.swift in Sources */, 0EB4042C27CA0E8C00378B1A /* Unlocalized.swift in Sources */,
0E3A3C102AA9AA530003A5F6 /* KeyValueStore+CloudKit.swift in Sources */,
0EB4042E27CA136300378B1A /* AddingTextField.swift in Sources */, 0EB4042E27CA136300378B1A /* AddingTextField.swift in Sources */,
0EE8B7E327FF340F00B68621 /* VPNProtocolType+FileExtensions.swift in Sources */, 0EE8B7E327FF340F00B68621 /* VPNProtocolType+FileExtensions.swift in Sources */,
0EF2212B27E667EA001D0BD7 /* AddProviderView+Name.swift in Sources */, 0EF2212B27E667EA001D0BD7 /* AddProviderView+Name.swift in Sources */,

View File

@ -31,6 +31,8 @@ import PassepartoutLibrary
final class AppContext { final class AppContext {
private let coreContext: CoreContext private let coreContext: CoreContext
private var lastIsCloudSyncingEnabled: Bool?
let productManager: ProductManager let productManager: ProductManager
private let reviewer: Reviewer private let reviewer: Reviewer
@ -114,3 +116,25 @@ private extension AppContext {
return true return true
} }
} }
// MARK: CloudKit
extension AppContext {
var shouldEnableCloudSyncing: Bool {
get {
coreContext.store.shouldEnableCloudSyncing
}
set {
coreContext.store.shouldEnableCloudSyncing = newValue
// iCloud may be externally disabled from the device settings
let isCloudSyncingEnabled = coreContext.store.isCloudSyncingEnabled
guard isCloudSyncingEnabled != lastIsCloudSyncingEnabled else {
pp_log.debug("CloudKit state did not change")
return
}
coreContext.reloadCloudKitObjects(isEnabled: isCloudSyncingEnabled)
lastIsCloudSyncingEnabled = isCloudSyncingEnabled
}
}
}

View File

@ -31,6 +31,10 @@ import TunnelKitManager
@MainActor @MainActor
final class CoreContext { final class CoreContext {
let store: KeyValueStore
private let persistenceManager: PersistenceManager
let upgradeManager: UpgradeManager let upgradeManager: UpgradeManager
let providerManager: ProviderManager let providerManager: ProviderManager
@ -42,6 +46,8 @@ final class CoreContext {
private var cancellables: Set<AnyCancellable> = [] private var cancellables: Set<AnyCancellable> = []
init(store: KeyValueStore) { init(store: KeyValueStore) {
self.store = store
let logger = SwiftyBeaverLogger( let logger = SwiftyBeaverLogger(
logFile: Constants.Log.App.url, logFile: Constants.Log.App.url,
logLevel: Constants.Log.level, logLevel: Constants.Log.level,
@ -50,18 +56,20 @@ final class CoreContext {
Passepartout.shared.logger = logger Passepartout.shared.logger = logger
pp_log.info("Logging to: \(logger.logFile!)") pp_log.info("Logging to: \(logger.logFile!)")
let persistenceManager = PersistenceManager(store: store)
let vpnPersistence = persistenceManager.vpnPersistence(
withName: Constants.Persistence.profilesContainerName
)
let providersPersistence = persistenceManager.providersPersistence(
withName: Constants.Persistence.providersContainerName
)
upgradeManager = UpgradeManager( upgradeManager = UpgradeManager(
store: store, store: store,
strategy: DefaultUpgradeManagerStrategy() strategy: DefaultUpgradeManagerStrategy()
) )
upgradeManager.migrate(toVersion: Constants.Global.appVersionNumber)
persistenceManager = PersistenceManager(store: store)
let vpnPersistence = persistenceManager.vpnPersistence(
withName: Constants.Persistence.profilesContainerName,
cloudKit: store.isCloudSyncingEnabled
)
let providersPersistence = persistenceManager.providersPersistence(
withName: Constants.Persistence.providersContainerName
)
let remoteProvidersStrategy = APIRemoteProvidersStrategy( let remoteProvidersStrategy = APIRemoteProvidersStrategy(
appBuild: Constants.Global.appBuildNumber, appBuild: Constants.Global.appBuildNumber,
@ -127,3 +135,19 @@ private extension CoreContext {
}.store(in: &cancellables) }.store(in: &cancellables)
} }
} }
// MARK: CloudKit
extension CoreContext {
func reloadCloudKitObjects(isEnabled: Bool) {
let vpnPersistence = persistenceManager.vpnPersistence(
withName: Constants.Persistence.profilesContainerName,
cloudKit: isEnabled
)
profileManager.swapProfileRepository(vpnPersistence.profileRepository())
}
func eraseCloudKitStore() {
// TODO: iCloud, erase remote records
}
}

View File

@ -29,6 +29,8 @@ import PassepartoutLibrary
enum AppPreference: String, KeyStoreDomainLocation { enum AppPreference: String, KeyStoreDomainLocation {
case launchesOnLogin case launchesOnLogin
case shouldEnableCloudSyncing
case isShowingFavorites case isShowingFavorites
case didHandleSubreddit case didHandleSubreddit

View File

@ -0,0 +1,63 @@
//
// KeyValueStore+CloudKit.swift
// Passepartout
//
// Created by Davide De Rosa on 9/7/23.
// Copyright (c) 2023 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import Foundation
import PassepartoutLibrary
extension KeyValueStore {
// MARK: Support
private var cloudKitToken: Any? {
FileManager.default.ubiquityIdentityToken
}
var isCloudKitSupported: Bool {
cloudKitToken != nil
}
// MARK: Preference
var shouldEnableCloudSyncing: Bool {
get {
value(forLocation: AppPreference.shouldEnableCloudSyncing) ?? false
}
set {
setValue(newValue, forLocation: AppPreference.shouldEnableCloudSyncing)
}
}
// MARK: Computed state
var isCloudSyncingEnabled: Bool {
guard isCloudKitSupported else {
pp_log.debug("CloudKit unavailable")
return false
}
let isEnabled = shouldEnableCloudSyncing
pp_log.debug("CloudKit enabled: \(isEnabled)")
return isEnabled
}
}

View File

@ -30,11 +30,22 @@ public final class DefaultUpgradeManagerStrategy: UpgradeManagerStrategy {
public init() { public init() {
} }
public func doMigrate(store: KeyValueStore, lastVersion: String?) { public func migrate(store: KeyValueStore, lastVersion: String?) {
// legacy check before lastVersion was even stored
let isUpgradeFromBefore_2_2_0: Bool? = store.value(forLocation: UpgradeManager.StoreKey.existingKeyBefore_2_2_0)
if isUpgradeFromBefore_2_2_0 != nil {
pp_log.debug("Upgrading from < 2.2.0, iCloud syncing defaults to enabled")
store.setValue(true, forLocation: AppPreference.shouldEnableCloudSyncing)
}
guard let lastVersion else { guard let lastVersion else {
pp_log.debug("Fresh install") pp_log.debug("Fresh install")
return return
} }
pp_log.debug("Upgrade from \(lastVersion)") pp_log.debug("Upgrade from \(lastVersion)")
} }
public func migrateData() {
}
} }

View File

@ -39,8 +39,8 @@ final class PersistenceManager {
} }
} }
func vpnPersistence(withName containerName: String) -> VPNPersistence { func vpnPersistence(withName containerName: String, cloudKit: Bool) -> VPNPersistence {
VPNPersistence(withName: containerName, cloudKit: true, author: persistenceAuthor) VPNPersistence(withName: containerName, cloudKit: cloudKit, author: persistenceAuthor)
} }
func providersPersistence(withName containerName: String) -> ProvidersPersistence { func providersPersistence(withName containerName: String) -> ProvidersPersistence {

View File

@ -47,11 +47,14 @@ public final class UpgradeManager: ObservableObject {
self.strategy = strategy self.strategy = strategy
} }
public func doMigrations(toVersion currentVersion: String, profileManager: ProfileManager) { public func migrate(toVersion currentVersion: String) {
if let lastVersion, currentVersion > lastVersion { if let lastVersion, currentVersion > lastVersion {
strategy.doMigrate(store: store, lastVersion: lastVersion) strategy.migrate(store: store, lastVersion: lastVersion)
} }
lastVersion = currentVersion lastVersion = currentVersion
}
public func migrateData(profileManager: ProfileManager) {
isDoingMigrations = false isDoingMigrations = false
} }
} }
@ -69,8 +72,12 @@ private extension UpgradeManager {
} }
} }
private extension UpgradeManager { extension UpgradeManager {
enum StoreKey: String, KeyStoreDomainLocation { enum StoreKey: String, KeyStoreDomainLocation {
@available(*, deprecated, message: "Retain temporarily for migrations")
case existingKeyBefore_2_2_0 = "didMigrateToV2"
case lastVersion case lastVersion
var domain: String { var domain: String {

View File

@ -27,5 +27,6 @@ import Foundation
import PassepartoutCore import PassepartoutCore
public protocol UpgradeManagerStrategy { public protocol UpgradeManagerStrategy {
func doMigrate(store: KeyValueStore, lastVersion: String?) func migrate(store: KeyValueStore, lastVersion: String?)
func migrateData()
} }

View File

@ -26,16 +26,14 @@
import PassepartoutLibrary import PassepartoutLibrary
import SwiftUI import SwiftUI
struct DiagnosticsSection: View { struct DiagnosticsRow: View {
@ObservedObject var currentProfile: ObservableProfile @ObservedObject var currentProfile: ObservableProfile
var body: some View { var body: some View {
Section { NavigationLink {
NavigationLink { DiagnosticsView(profile: currentProfile.value)
DiagnosticsView(profile: currentProfile.value) } label: {
} label: { Text(L10n.Diagnostics.title)
Text(L10n.Diagnostics.title)
}
} }
} }
} }

View File

@ -183,10 +183,7 @@ private extension OrganizerView.ProfilesList {
func performMigrationsIfNeeded() { func performMigrationsIfNeeded() {
Task { @MainActor in Task { @MainActor in
UpgradeManager.shared.doMigrations( UpgradeManager.shared.migrateData(profileManager: profileManager)
toVersion: Constants.Global.appVersionNumber,
profileManager: profileManager
)
} }
} }
} }

View File

@ -105,7 +105,9 @@ private extension ProfileView {
modalType: $modalType modalType: $modalType
) )
ExtraSection(currentProfile: currentProfile) ExtraSection(currentProfile: currentProfile)
DiagnosticsSection(currentProfile: currentProfile) Section {
DiagnosticsRow(currentProfile: currentProfile)
}
} else { } else {
ProgressView() ProgressView()
} }

View File

@ -35,19 +35,24 @@ struct SettingsView: View {
@AppStorage(AppPreference.locksInBackground.key) private var locksInBackground = false @AppStorage(AppPreference.locksInBackground.key) private var locksInBackground = false
@Binding private var shouldEnableCloudSyncing: Bool
private let versionString = Constants.Global.appVersionString private let versionString = Constants.Global.appVersionString
init() { init() {
profileManager = .shared profileManager = .shared
productManager = .shared productManager = .shared
_shouldEnableCloudSyncing = .init {
AppContext.shared.shouldEnableCloudSyncing
} set: {
AppContext.shared.shouldEnableCloudSyncing = $0
}
} }
var body: some View { var body: some View {
List { List {
#if !targetEnvironment(macCatalyst)
preferencesSection preferencesSection
#endif
DiagnosticsSection(currentProfile: profileManager.currentProfile)
aboutSection aboutSection
}.toolbar { }.toolbar {
themeCloseItem(presentationMode: presentationMode) themeCloseItem(presentationMode: presentationMode)
@ -61,7 +66,12 @@ struct SettingsView: View {
private extension SettingsView { private extension SettingsView {
var preferencesSection: some View { var preferencesSection: some View {
Section { Section {
#if !targetEnvironment(macCatalyst)
Toggle(L10n.Settings.Items.LocksInBackground.caption, isOn: $locksInBackground) Toggle(L10n.Settings.Items.LocksInBackground.caption, isOn: $locksInBackground)
#endif
Toggle(L10n.Settings.Items.ShouldEnableCloudSyncing.caption, isOn: $shouldEnableCloudSyncing)
} header: {
Text(L10n.Preferences.title)
} }
} }
@ -77,6 +87,8 @@ private extension SettingsView {
} label: { } label: {
Text(L10n.Settings.Items.Donate.caption) Text(L10n.Settings.Items.Donate.caption)
}.disabled(!productManager.canMakePayments()) }.disabled(!productManager.canMakePayments())
DiagnosticsRow(currentProfile: profileManager.currentProfile)
} footer: { } footer: {
HStack { HStack {
Spacer() Spacer()

View File

@ -332,6 +332,7 @@
"settings.title" = "Settings"; "settings.title" = "Settings";
"settings.items.locks_in_background.caption" = "Lock app access"; "settings.items.locks_in_background.caption" = "Lock app access";
"settings.items.should_enable_cloud_syncing.caption" = "Sync with iCloud";
"settings.items.donate.caption" = "Make a donation"; "settings.items.donate.caption" = "Make a donation";
/* MARK: AboutView */ /* MARK: AboutView */

View File

@ -925,6 +925,10 @@ internal enum L10n {
/// Lock app access /// Lock app access
internal static let caption = L10n.tr("Localizable", "settings.items.locks_in_background.caption", fallback: "Lock app access") internal static let caption = L10n.tr("Localizable", "settings.items.locks_in_background.caption", fallback: "Lock app access")
} }
internal enum ShouldEnableCloudSyncing {
/// Sync with iCloud
internal static let caption = L10n.tr("Localizable", "settings.items.should_enable_cloud_syncing.caption", fallback: "Sync with iCloud")
}
} }
} }
internal enum Shortcuts { internal enum Shortcuts {

View File

@ -33,7 +33,7 @@ public protocol KeyStoreDomainLocation: KeyStoreLocation {
var domain: String { get } var domain: String { get }
} }
public protocol KeyValueStore { public protocol KeyValueStore: AnyObject {
func setValue<L, V>(_ value: V?, forLocation location: L) where L: KeyStoreLocation func setValue<L, V>(_ value: V?, forLocation location: L) where L: KeyStoreLocation
func value<L, V>(forLocation location: L) -> V? where L: KeyStoreLocation func value<L, V>(forLocation location: L) -> V? where L: KeyStoreLocation

View File

@ -42,7 +42,7 @@ public final class ProfileManager: ObservableObject {
private let providerManager: ProviderManager private let providerManager: ProviderManager
private let profileRepository: ProfileRepository private var profileRepository: ProfileRepository
private let keychain: SecretRepository private let keychain: SecretRepository
@ -488,6 +488,18 @@ extension ProfileManager {
} }
} }
// MARK: Repository
extension ProfileManager {
public func swapProfileRepository(_ newProfileRepository: ProfileRepository) {
cancellables.removeAll()
objectWillChange.send()
profileRepository = newProfileRepository
observeUpdates()
}
}
// MARK: KeyValueStore // MARK: KeyValueStore
extension ProfileManager { extension ProfileManager {

View File

@ -26,7 +26,7 @@
import Foundation import Foundation
import PassepartoutCore import PassepartoutCore
public struct UserDefaultsStore: KeyValueStore { public class UserDefaultsStore: KeyValueStore {
private let defaults: UserDefaults private let defaults: UserDefaults
private let key: (any KeyStoreLocation) -> String private let key: (any KeyStoreLocation) -> String
@ -41,11 +41,11 @@ public struct UserDefaultsStore: KeyValueStore {
defaults.removeObject(forKey: key(location)) defaults.removeObject(forKey: key(location))
return return
} }
defaults.setValue(value, forKey: key(location)) defaults.set(value, forKey: key(location))
} }
public func value<L, V>(forLocation location: L) -> V? where L: KeyStoreLocation { public func value<L, V>(forLocation location: L) -> V? where L: KeyStoreLocation {
defaults.value(forKey: key(location)) as? V defaults.object(forKey: key(location)) as? V
} }
public func removeValue<L>(forLocation location: L) where L: KeyStoreLocation { public func removeValue<L>(forLocation location: L) where L: KeyStoreLocation {