From a4ca8cc9964d8e537a4fdcd4d5f6dc506d250cba Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Sat, 9 Sep 2023 20:29:04 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + Passepartout.xcodeproj/project.pbxproj | 4 ++ Passepartout/App/Context/AppContext.swift | 24 +++++++ Passepartout/App/Context/CoreContext.swift | 40 +++++++++--- Passepartout/App/Domain/AppPreference.swift | 2 + .../Extensions/KeyValueStore+CloudKit.swift | 63 +++++++++++++++++++ .../DefaultUpgradeManagerStrategy.swift | 13 +++- .../App/Managers/PersistenceManager.swift | 4 +- .../App/Managers/UpgradeManager.swift | 13 +++- .../App/Managers/UpgradeManagerStrategy.swift | 3 +- .../App/Views/DiagnosticsSection.swift | 12 ++-- .../App/Views/OrganizerView+Profiles.swift | 5 +- Passepartout/App/Views/ProfileView.swift | 4 +- Passepartout/App/Views/SettingsView.swift | 18 +++++- Passepartout/App/en.lproj/Localizable.strings | 1 + .../Constants/SwiftGen+Strings.swift | 4 ++ .../Reusable/KeyValueStore.swift | 2 +- .../Managers/ProfileManager.swift | 14 ++++- .../Strategies/UserDefaultsStore.swift | 6 +- 19 files changed, 198 insertions(+), 35 deletions(-) create mode 100644 Passepartout/App/Extensions/KeyValueStore+CloudKit.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b1761cd0..2aa815de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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) - Convert trusted networks to on-demand activation. [#119](https://github.com/passepartoutvpn/passepartout-apple/issues/119) diff --git a/Passepartout.xcodeproj/project.pbxproj b/Passepartout.xcodeproj/project.pbxproj index 5d86baab..dac9983b 100644 --- a/Passepartout.xcodeproj/project.pbxproj +++ b/Passepartout.xcodeproj/project.pbxproj @@ -53,6 +53,7 @@ 0E3A3C132AAB7C480003A5F6 /* UpgradeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3A3C112AAB7C470003A5F6 /* UpgradeManager.swift */; }; 0E3A3C142AAB7C480003A5F6 /* DefaultUpgradeManagerStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3A3C122AAB7C480003A5F6 /* DefaultUpgradeManagerStrategy.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 */; }; 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 */; }; @@ -346,6 +347,7 @@ 0E3A3C112AAB7C470003A5F6 /* UpgradeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpgradeManager.swift; sourceTree = ""; }; 0E3A3C122AAB7C480003A5F6 /* DefaultUpgradeManagerStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultUpgradeManagerStrategy.swift; sourceTree = ""; }; 0E3A3C152AAB8AB80003A5F6 /* UpgradeManagerStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpgradeManagerStrategy.swift; sourceTree = ""; }; + 0E3A3C0F2AA9AA530003A5F6 /* KeyValueStore+CloudKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyValueStore+CloudKit.swift"; sourceTree = ""; }; 0E3A593B2A50975700B3FE40 /* ErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorHandler.swift; sourceTree = ""; }; 0E3B7FCC27E47B3700C66F13 /* AddHostView+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AddHostView+Name.swift"; sourceTree = ""; }; 0E3B7FD527E5173A00C66F13 /* ProfileView+VPN.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileView+VPN.swift"; sourceTree = ""; }; @@ -744,6 +746,7 @@ isa = PBXGroup; children = ( 0EBC075C27EC529000208AD9 /* DebugLog+Constants.swift */, + 0E3A3C0F2AA9AA530003A5F6 /* KeyValueStore+CloudKit.swift */, 0EB17EB927D2560300D473B5 /* PassepartoutProviders+Extensions.swift */, 0E35C099280E95BB0071FA35 /* ProviderProfileAvailability.swift */, 0E2DE71B27DCCFE80067B9E1 /* TunnelKit+Extensions.swift */, @@ -1542,6 +1545,7 @@ 0E53249927D26B51002565C3 /* ProductManager.swift in Sources */, 0E9C233027F47032007D5FC7 /* IntentsManager.swift in Sources */, 0EB4042C27CA0E8C00378B1A /* Unlocalized.swift in Sources */, + 0E3A3C102AA9AA530003A5F6 /* KeyValueStore+CloudKit.swift in Sources */, 0EB4042E27CA136300378B1A /* AddingTextField.swift in Sources */, 0EE8B7E327FF340F00B68621 /* VPNProtocolType+FileExtensions.swift in Sources */, 0EF2212B27E667EA001D0BD7 /* AddProviderView+Name.swift in Sources */, diff --git a/Passepartout/App/Context/AppContext.swift b/Passepartout/App/Context/AppContext.swift index 33a290ca..06c1419e 100644 --- a/Passepartout/App/Context/AppContext.swift +++ b/Passepartout/App/Context/AppContext.swift @@ -31,6 +31,8 @@ import PassepartoutLibrary final class AppContext { private let coreContext: CoreContext + private var lastIsCloudSyncingEnabled: Bool? + let productManager: ProductManager private let reviewer: Reviewer @@ -114,3 +116,25 @@ private extension AppContext { 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 + } + } +} diff --git a/Passepartout/App/Context/CoreContext.swift b/Passepartout/App/Context/CoreContext.swift index a10ced28..21760d4f 100644 --- a/Passepartout/App/Context/CoreContext.swift +++ b/Passepartout/App/Context/CoreContext.swift @@ -31,6 +31,10 @@ import TunnelKitManager @MainActor final class CoreContext { + let store: KeyValueStore + + private let persistenceManager: PersistenceManager + let upgradeManager: UpgradeManager let providerManager: ProviderManager @@ -42,6 +46,8 @@ final class CoreContext { private var cancellables: Set = [] init(store: KeyValueStore) { + self.store = store + let logger = SwiftyBeaverLogger( logFile: Constants.Log.App.url, logLevel: Constants.Log.level, @@ -50,18 +56,20 @@ final class CoreContext { Passepartout.shared.logger = logger 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( store: store, 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( appBuild: Constants.Global.appBuildNumber, @@ -127,3 +135,19 @@ private extension CoreContext { }.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 + } +} diff --git a/Passepartout/App/Domain/AppPreference.swift b/Passepartout/App/Domain/AppPreference.swift index f15ec4bc..daa44e66 100644 --- a/Passepartout/App/Domain/AppPreference.swift +++ b/Passepartout/App/Domain/AppPreference.swift @@ -29,6 +29,8 @@ import PassepartoutLibrary enum AppPreference: String, KeyStoreDomainLocation { case launchesOnLogin + case shouldEnableCloudSyncing + case isShowingFavorites case didHandleSubreddit diff --git a/Passepartout/App/Extensions/KeyValueStore+CloudKit.swift b/Passepartout/App/Extensions/KeyValueStore+CloudKit.swift new file mode 100644 index 00000000..d4be177e --- /dev/null +++ b/Passepartout/App/Extensions/KeyValueStore+CloudKit.swift @@ -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 . +// + +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 + } +} diff --git a/Passepartout/App/Managers/DefaultUpgradeManagerStrategy.swift b/Passepartout/App/Managers/DefaultUpgradeManagerStrategy.swift index 9646649a..4fb9dd8e 100644 --- a/Passepartout/App/Managers/DefaultUpgradeManagerStrategy.swift +++ b/Passepartout/App/Managers/DefaultUpgradeManagerStrategy.swift @@ -30,11 +30,22 @@ public final class DefaultUpgradeManagerStrategy: UpgradeManagerStrategy { 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 { pp_log.debug("Fresh install") return } pp_log.debug("Upgrade from \(lastVersion)") } + + public func migrateData() { + } } diff --git a/Passepartout/App/Managers/PersistenceManager.swift b/Passepartout/App/Managers/PersistenceManager.swift index 34ac59dd..16f908bc 100644 --- a/Passepartout/App/Managers/PersistenceManager.swift +++ b/Passepartout/App/Managers/PersistenceManager.swift @@ -39,8 +39,8 @@ final class PersistenceManager { } } - func vpnPersistence(withName containerName: String) -> VPNPersistence { - VPNPersistence(withName: containerName, cloudKit: true, author: persistenceAuthor) + func vpnPersistence(withName containerName: String, cloudKit: Bool) -> VPNPersistence { + VPNPersistence(withName: containerName, cloudKit: cloudKit, author: persistenceAuthor) } func providersPersistence(withName containerName: String) -> ProvidersPersistence { diff --git a/Passepartout/App/Managers/UpgradeManager.swift b/Passepartout/App/Managers/UpgradeManager.swift index 3983608d..0d7de479 100644 --- a/Passepartout/App/Managers/UpgradeManager.swift +++ b/Passepartout/App/Managers/UpgradeManager.swift @@ -47,11 +47,14 @@ public final class UpgradeManager: ObservableObject { self.strategy = strategy } - public func doMigrations(toVersion currentVersion: String, profileManager: ProfileManager) { + public func migrate(toVersion currentVersion: String) { if let lastVersion, currentVersion > lastVersion { - strategy.doMigrate(store: store, lastVersion: lastVersion) + strategy.migrate(store: store, lastVersion: lastVersion) } lastVersion = currentVersion + } + + public func migrateData(profileManager: ProfileManager) { isDoingMigrations = false } } @@ -69,8 +72,12 @@ private extension UpgradeManager { } } -private extension UpgradeManager { +extension UpgradeManager { enum StoreKey: String, KeyStoreDomainLocation { + + @available(*, deprecated, message: "Retain temporarily for migrations") + case existingKeyBefore_2_2_0 = "didMigrateToV2" + case lastVersion var domain: String { diff --git a/Passepartout/App/Managers/UpgradeManagerStrategy.swift b/Passepartout/App/Managers/UpgradeManagerStrategy.swift index a55caa97..65c80003 100644 --- a/Passepartout/App/Managers/UpgradeManagerStrategy.swift +++ b/Passepartout/App/Managers/UpgradeManagerStrategy.swift @@ -27,5 +27,6 @@ import Foundation import PassepartoutCore public protocol UpgradeManagerStrategy { - func doMigrate(store: KeyValueStore, lastVersion: String?) + func migrate(store: KeyValueStore, lastVersion: String?) + func migrateData() } diff --git a/Passepartout/App/Views/DiagnosticsSection.swift b/Passepartout/App/Views/DiagnosticsSection.swift index d8653c27..ae129331 100644 --- a/Passepartout/App/Views/DiagnosticsSection.swift +++ b/Passepartout/App/Views/DiagnosticsSection.swift @@ -26,16 +26,14 @@ import PassepartoutLibrary import SwiftUI -struct DiagnosticsSection: View { +struct DiagnosticsRow: View { @ObservedObject var currentProfile: ObservableProfile var body: some View { - Section { - NavigationLink { - DiagnosticsView(profile: currentProfile.value) - } label: { - Text(L10n.Diagnostics.title) - } + NavigationLink { + DiagnosticsView(profile: currentProfile.value) + } label: { + Text(L10n.Diagnostics.title) } } } diff --git a/Passepartout/App/Views/OrganizerView+Profiles.swift b/Passepartout/App/Views/OrganizerView+Profiles.swift index be899269..169276e8 100644 --- a/Passepartout/App/Views/OrganizerView+Profiles.swift +++ b/Passepartout/App/Views/OrganizerView+Profiles.swift @@ -183,10 +183,7 @@ private extension OrganizerView.ProfilesList { func performMigrationsIfNeeded() { Task { @MainActor in - UpgradeManager.shared.doMigrations( - toVersion: Constants.Global.appVersionNumber, - profileManager: profileManager - ) + UpgradeManager.shared.migrateData(profileManager: profileManager) } } } diff --git a/Passepartout/App/Views/ProfileView.swift b/Passepartout/App/Views/ProfileView.swift index 56628c87..ba6e60ee 100644 --- a/Passepartout/App/Views/ProfileView.swift +++ b/Passepartout/App/Views/ProfileView.swift @@ -105,7 +105,9 @@ private extension ProfileView { modalType: $modalType ) ExtraSection(currentProfile: currentProfile) - DiagnosticsSection(currentProfile: currentProfile) + Section { + DiagnosticsRow(currentProfile: currentProfile) + } } else { ProgressView() } diff --git a/Passepartout/App/Views/SettingsView.swift b/Passepartout/App/Views/SettingsView.swift index c0e34cc6..78bdbf7d 100644 --- a/Passepartout/App/Views/SettingsView.swift +++ b/Passepartout/App/Views/SettingsView.swift @@ -35,19 +35,24 @@ struct SettingsView: View { @AppStorage(AppPreference.locksInBackground.key) private var locksInBackground = false + @Binding private var shouldEnableCloudSyncing: Bool + private let versionString = Constants.Global.appVersionString init() { profileManager = .shared productManager = .shared + + _shouldEnableCloudSyncing = .init { + AppContext.shared.shouldEnableCloudSyncing + } set: { + AppContext.shared.shouldEnableCloudSyncing = $0 + } } var body: some View { List { - #if !targetEnvironment(macCatalyst) preferencesSection - #endif - DiagnosticsSection(currentProfile: profileManager.currentProfile) aboutSection }.toolbar { themeCloseItem(presentationMode: presentationMode) @@ -61,7 +66,12 @@ struct SettingsView: View { private extension SettingsView { var preferencesSection: some View { Section { + #if !targetEnvironment(macCatalyst) 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: { Text(L10n.Settings.Items.Donate.caption) }.disabled(!productManager.canMakePayments()) + + DiagnosticsRow(currentProfile: profileManager.currentProfile) } footer: { HStack { Spacer() diff --git a/Passepartout/App/en.lproj/Localizable.strings b/Passepartout/App/en.lproj/Localizable.strings index bcc7b8a8..4a890f92 100644 --- a/Passepartout/App/en.lproj/Localizable.strings +++ b/Passepartout/App/en.lproj/Localizable.strings @@ -332,6 +332,7 @@ "settings.title" = "Settings"; "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"; /* MARK: AboutView */ diff --git a/Passepartout/AppShared/Constants/SwiftGen+Strings.swift b/Passepartout/AppShared/Constants/SwiftGen+Strings.swift index a3a914c7..31b9a02c 100644 --- a/Passepartout/AppShared/Constants/SwiftGen+Strings.swift +++ b/Passepartout/AppShared/Constants/SwiftGen+Strings.swift @@ -925,6 +925,10 @@ internal enum L10n { /// 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 { diff --git a/PassepartoutLibrary/Sources/PassepartoutCore/Reusable/KeyValueStore.swift b/PassepartoutLibrary/Sources/PassepartoutCore/Reusable/KeyValueStore.swift index 6a5eff2a..7dc61ecd 100644 --- a/PassepartoutLibrary/Sources/PassepartoutCore/Reusable/KeyValueStore.swift +++ b/PassepartoutLibrary/Sources/PassepartoutCore/Reusable/KeyValueStore.swift @@ -33,7 +33,7 @@ public protocol KeyStoreDomainLocation: KeyStoreLocation { var domain: String { get } } -public protocol KeyValueStore { +public protocol KeyValueStore: AnyObject { func setValue(_ value: V?, forLocation location: L) where L: KeyStoreLocation func value(forLocation location: L) -> V? where L: KeyStoreLocation diff --git a/PassepartoutLibrary/Sources/PassepartoutVPN/Managers/ProfileManager.swift b/PassepartoutLibrary/Sources/PassepartoutVPN/Managers/ProfileManager.swift index 0063c08e..c7a7f8cb 100644 --- a/PassepartoutLibrary/Sources/PassepartoutVPN/Managers/ProfileManager.swift +++ b/PassepartoutLibrary/Sources/PassepartoutVPN/Managers/ProfileManager.swift @@ -42,7 +42,7 @@ public final class ProfileManager: ObservableObject { private let providerManager: ProviderManager - private let profileRepository: ProfileRepository + private var profileRepository: ProfileRepository 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 extension ProfileManager { diff --git a/PassepartoutLibrary/Sources/PassepartoutVPNImpl/Strategies/UserDefaultsStore.swift b/PassepartoutLibrary/Sources/PassepartoutVPNImpl/Strategies/UserDefaultsStore.swift index 838a09ee..5558981e 100644 --- a/PassepartoutLibrary/Sources/PassepartoutVPNImpl/Strategies/UserDefaultsStore.swift +++ b/PassepartoutLibrary/Sources/PassepartoutVPNImpl/Strategies/UserDefaultsStore.swift @@ -26,7 +26,7 @@ import Foundation import PassepartoutCore -public struct UserDefaultsStore: KeyValueStore { +public class UserDefaultsStore: KeyValueStore { private let defaults: UserDefaults private let key: (any KeyStoreLocation) -> String @@ -41,11 +41,11 @@ public struct UserDefaultsStore: KeyValueStore { defaults.removeObject(forKey: key(location)) return } - defaults.setValue(value, forKey: key(location)) + defaults.set(value, forKey: key(location)) } public func value(forLocation location: L) -> V? where L: KeyStoreLocation { - defaults.value(forKey: key(location)) as? V + defaults.object(forKey: key(location)) as? V } public func removeValue(forLocation location: L) where L: KeyStoreLocation {