Move CloudKit logic to PersistenceManager (#355)

Observe updates rather than execute operations imperatively. Also refine
responsibilities of AppContext and CoreContext.
This commit is contained in:
Davide De Rosa 2023-09-10 10:34:42 +02:00 committed by GitHub
parent c645f39254
commit 0872c27fce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 151 additions and 186 deletions

View File

@ -50,7 +50,6 @@
0E34AC7827F840890042F2AB /* OrganizerView+Scene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E34AC7727F840890042F2AB /* OrganizerView+Scene.swift */; };
0E34AC8227F892C40042F2AB /* OnDemandView+SSID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E34AC8127F892C40042F2AB /* OnDemandView+SSID.swift */; };
0E35C09A280E95BB0071FA35 /* ProviderProfileAvailability.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E35C099280E95BB0071FA35 /* ProviderProfileAvailability.swift */; };
0E3A3C102AA9AA530003A5F6 /* KeyValueStore+CloudKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3A3C0F2AA9AA530003A5F6 /* KeyValueStore+CloudKit.swift */; };
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 */; };
@ -344,7 +343,6 @@
0E34AC7727F840890042F2AB /* OrganizerView+Scene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OrganizerView+Scene.swift"; sourceTree = "<group>"; };
0E34AC8127F892C40042F2AB /* OnDemandView+SSID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OnDemandView+SSID.swift"; sourceTree = "<group>"; };
0E35C099280E95BB0071FA35 /* ProviderProfileAvailability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderProfileAvailability.swift; sourceTree = "<group>"; };
0E3A3C0F2AA9AA530003A5F6 /* KeyValueStore+CloudKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyValueStore+CloudKit.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>"; };
0E3A3C152AAB8AB80003A5F6 /* UpgradeManagerStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpgradeManagerStrategy.swift; sourceTree = "<group>"; };
@ -746,7 +744,6 @@
isa = PBXGroup;
children = (
0EBC075C27EC529000208AD9 /* DebugLog+Constants.swift */,
0E3A3C0F2AA9AA530003A5F6 /* KeyValueStore+CloudKit.swift */,
0EB17EB927D2560300D473B5 /* PassepartoutProviders+Extensions.swift */,
0E35C099280E95BB0071FA35 /* ProviderProfileAvailability.swift */,
0E2DE71B27DCCFE80067B9E1 /* TunnelKit+Extensions.swift */,
@ -1545,7 +1542,6 @@
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 */,

View File

@ -28,20 +28,26 @@ import PassepartoutLibrary
// safer alternative to @EnvironmentObject
extension AppContext {
private static let coreContext = CoreContext(store: UserDefaultsStore(defaults: .standard, key: \.key))
// MARK: App
static let shared = AppContext(coreContext: coreContext)
extension AppContext {
static let shared = AppContext(store: UserDefaultsStore(defaults: .standard, key: \.key))
}
extension UpgradeManager {
static let shared = AppContext.shared.upgradeManager
}
extension ProductManager {
static let shared = AppContext.shared.productManager
}
extension UpgradeManager {
static let shared = AppContext.shared.upgradeManager
extension PersistenceManager {
static let shared = AppContext.shared.persistenceManager
}
// MARK: App -> Core
extension ProfileManager {
static let shared = AppContext.shared.profileManager
}

View File

@ -31,34 +31,48 @@ import PassepartoutLibrary
final class AppContext {
private let coreContext: CoreContext
private var lastIsCloudSyncingEnabled: Bool?
let upgradeManager: UpgradeManager
let productManager: ProductManager
let persistenceManager: PersistenceManager
private let reviewer: Reviewer
private var cancellables: Set<AnyCancellable> = []
init(coreContext: CoreContext) {
self.coreContext = coreContext
init(store: KeyValueStore) {
let logger = SwiftyBeaverLogger(
logFile: Constants.Log.App.url,
logLevel: Constants.Log.level,
logFormat: Constants.Log.App.format
)
Passepartout.shared.logger = logger
pp_log.info("Logging to: \(logger.logFile!)")
upgradeManager = UpgradeManager(
store: store,
strategy: DefaultUpgradeManagerStrategy()
)
upgradeManager.migrate(toVersion: Constants.Global.appVersionNumber)
productManager = ProductManager(
overriddenAppType: Constants.InApp.overriddenAppType,
buildProducts: Constants.InApp.buildProducts
)
persistenceManager = PersistenceManager(store: store)
reviewer = Reviewer()
reviewer.eventCountBeforeRating = Constants.Rating.eventCount
coreContext = CoreContext(persistenceManager: persistenceManager)
// post
configureObjects()
}
var upgradeManager: UpgradeManager {
coreContext.upgradeManager
}
var providerManager: ProviderManager {
coreContext.providerManager
}
@ -116,29 +130,3 @@ 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
}
}
func eraseCloudKitStore() async {
await coreContext.eraseCloudKitStore()
}
}

View File

@ -34,12 +34,6 @@ import TunnelKitManager
final class CoreContext {
let store: KeyValueStore
private let persistenceManager: PersistenceManager
private(set) var vpnPersistence: VPNPersistence
let upgradeManager: UpgradeManager
let providerManager: ProviderManager
let profileManager: ProfileManager
@ -48,27 +42,11 @@ final class CoreContext {
private var cancellables: Set<AnyCancellable> = []
init(store: KeyValueStore) {
self.store = store
init(persistenceManager: PersistenceManager) {
store = persistenceManager.store
let logger = SwiftyBeaverLogger(
logFile: Constants.Log.App.url,
logLevel: Constants.Log.level,
logFormat: Constants.Log.App.format
)
Passepartout.shared.logger = logger
pp_log.info("Logging to: \(logger.logFile!)")
upgradeManager = UpgradeManager(
store: store,
strategy: DefaultUpgradeManagerStrategy()
)
upgradeManager.migrate(toVersion: Constants.Global.appVersionNumber)
persistenceManager = PersistenceManager(store: store)
vpnPersistence = persistenceManager.vpnPersistence(
withName: Constants.Persistence.profilesContainerName,
cloudKit: store.isCloudSyncingEnabled
let vpnPersistence = persistenceManager.vpnPersistence(
withName: Constants.Persistence.profilesContainerName
)
let providersPersistence = persistenceManager.providersPersistence(
withName: Constants.Persistence.providersContainerName
@ -119,12 +97,12 @@ final class CoreContext {
// post
configureObjects()
configureObjects(persistenceManager: persistenceManager)
}
}
private extension CoreContext {
func configureObjects() {
func configureObjects(persistenceManager: PersistenceManager) {
providerManager.rateLimitMilliseconds = Constants.RateLimit.providerManager
vpnManager.tunnelLogPath = Constants.Log.Tunnel.path
vpnManager.tunnelLogFormat = Constants.Log.Tunnel.format
@ -133,27 +111,25 @@ private extension CoreContext {
vpnManager.observeUpdates()
CoreConfiguration.masksPrivateData = vpnManager.masksPrivateData
vpnManager.didUpdatePreferences.sink {
CoreConfiguration.masksPrivateData = $0.masksPrivateData
}.store(in: &cancellables)
vpnManager.didUpdatePreferences
.sink {
CoreConfiguration.masksPrivateData = $0.masksPrivateData
}.store(in: &cancellables)
persistenceManager.didChangePersistence
.sink { [weak self] in
self?.reloadCloudKitObjects(persistenceManager: persistenceManager)
}.store(in: &cancellables)
}
}
// MARK: CloudKit
extension CoreContext {
func reloadCloudKitObjects(isEnabled: Bool) {
vpnPersistence = persistenceManager.vpnPersistence(
withName: Constants.Persistence.profilesContainerName,
cloudKit: isEnabled
func reloadCloudKitObjects(persistenceManager: PersistenceManager) {
let vpnPersistence = persistenceManager.vpnPersistence(
withName: Constants.Persistence.profilesContainerName
)
profileManager.swapProfileRepository(vpnPersistence.profileRepository())
}
func eraseCloudKitStore() async {
await vpnPersistence.eraseCloudKitStore(
fromContainerWithId: Constants.CloudKit.containerId,
zoneId: .init(zoneName: Constants.CloudKit.coreDataZone)
)
}
}

View File

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

View File

@ -1,63 +0,0 @@
//
// 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

@ -36,7 +36,7 @@ public final class DefaultUpgradeManagerStrategy: UpgradeManagerStrategy {
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)
store.setValue(true, forLocation: PersistenceManager.StoreKey.shouldEnableCloudSyncing)
store.removeValue(forLocation: UpgradeManager.StoreKey.existingKeyBefore_2_2_0)
}

View File

@ -23,15 +23,29 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CloudKit
import Combine
import CoreData
import Foundation
import PassepartoutLibrary
final class PersistenceManager {
private let store: KeyValueStore
final class PersistenceManager: ObservableObject {
let store: KeyValueStore
private(set) var isCloudSyncingEnabled: Bool {
didSet {
pp_log.info("CloudKit enabled: \(isCloudSyncingEnabled)")
didChangePersistence.send()
}
}
@Published private(set) var isErasingCloudKitStore = false
let didChangePersistence = PassthroughSubject<Void, Never>()
init(store: KeyValueStore) {
self.store = store
isCloudSyncingEnabled = store.canEnableCloudSyncing
// set once
if persistenceAuthor == nil {
@ -39,8 +53,8 @@ final class PersistenceManager {
}
}
func vpnPersistence(withName containerName: String, cloudKit: Bool) -> VPNPersistence {
VPNPersistence(withName: containerName, cloudKit: cloudKit, author: persistenceAuthor)
func vpnPersistence(withName containerName: String) -> VPNPersistence {
VPNPersistence(withName: containerName, cloudKit: isCloudSyncingEnabled, author: persistenceAuthor)
}
func providersPersistence(withName containerName: String) -> ProvidersPersistence {
@ -48,8 +62,59 @@ final class PersistenceManager {
}
}
// MARK: CloudKit
extension PersistenceManager {
func eraseCloudKitStore() async {
await MainActor.run {
isErasingCloudKitStore = true
}
await Self.eraseCloudKitStore(
fromContainerWithId: Constants.CloudKit.containerId,
zoneId: .init(zoneName: Constants.CloudKit.coreDataZone)
)
await MainActor.run {
isErasingCloudKitStore = false
}
}
// WARNING: this is not running on main actor
private static func eraseCloudKitStore(fromContainerWithId containerId: String, zoneId: CKRecordZone.ID) async {
do {
let container = CKContainer(identifier: containerId)
let db = container.privateCloudDatabase
try await db.deleteRecordZone(withID: zoneId)
} catch {
pp_log.error("Unable to erase CloudKit store: \(error)")
}
}
}
// MARK: KeyValueStore
private extension KeyValueStore {
private var cloudKitToken: Any? {
FileManager.default.ubiquityIdentityToken
}
private var isCloudKitSupported: Bool {
cloudKitToken != nil
}
var canEnableCloudSyncing: Bool {
isCloudKitSupported && shouldEnableCloudSyncing
}
var shouldEnableCloudSyncing: Bool {
get {
value(forLocation: PersistenceManager.StoreKey.shouldEnableCloudSyncing) ?? false
}
set {
setValue(newValue, forLocation: PersistenceManager.StoreKey.shouldEnableCloudSyncing)
}
}
}
extension PersistenceManager {
private(set) var persistenceAuthor: String? {
get {
@ -59,12 +124,34 @@ extension PersistenceManager {
store.setValue(newValue, forLocation: StoreKey.persistenceAuthor)
}
}
var shouldEnableCloudSyncing: Bool {
get {
store.shouldEnableCloudSyncing
}
set {
objectWillChange.send()
store.shouldEnableCloudSyncing = newValue
// iCloud may be externally disabled from the device settings
let newIsCloudSyncingEnabled = store.canEnableCloudSyncing
guard newIsCloudSyncingEnabled != isCloudSyncingEnabled else {
pp_log.debug("CloudKit state did not change")
return
}
isCloudSyncingEnabled = newIsCloudSyncingEnabled
}
}
}
private extension PersistenceManager {
// TODO: iCloud, restore private after dropping migration from 2.2.0
// private extension PersistenceManager {
extension PersistenceManager {
enum StoreKey: String, KeyStoreDomainLocation {
case persistenceAuthor
case shouldEnableCloudSyncing
var domain: String {
"Passepartout.PersistenceManager"
}

View File

@ -31,27 +31,18 @@ struct SettingsView: View {
@ObservedObject private var productManager: ProductManager
@ObservedObject private var persistenceManager: PersistenceManager
@Environment(\.presentationMode) private var presentationMode
@AppStorage(AppPreference.locksInBackground.key) private var locksInBackground = false
@Binding private var shouldEnableCloudSyncing: Bool
@State private var isErasingCloudStore = false
private let versionString = Constants.Global.appVersionString
init() {
profileManager = .shared
productManager = .shared
_shouldEnableCloudSyncing = .init {
AppContext.shared.shouldEnableCloudSyncing
} set: { isEnabled in
withAnimation {
AppContext.shared.shouldEnableCloudSyncing = isEnabled
}
}
persistenceManager = .shared
}
var body: some View {
@ -82,15 +73,13 @@ private extension SettingsView {
var iCloudSection: some View {
Section {
Toggle(L10n.Settings.Items.ShouldEnableCloudSyncing.caption, isOn: $shouldEnableCloudSyncing)
Toggle(L10n.Settings.Items.ShouldEnableCloudSyncing.caption, isOn: $persistenceManager.shouldEnableCloudSyncing.themeAnimation())
Button(L10n.Settings.Items.EraseCloudStore.caption) {
isErasingCloudStore = true
Task {
await AppContext.shared.eraseCloudKitStore()
isErasingCloudStore = false
await persistenceManager.eraseCloudKitStore()
}
}.withTrailingProgress(when: isErasingCloudStore)
.disabled(shouldEnableCloudSyncing || isErasingCloudStore)
}.withTrailingProgress(when: persistenceManager.isErasingCloudKitStore)
.disabled(persistenceManager.shouldEnableCloudSyncing || persistenceManager.isErasingCloudKitStore)
} header: {
Text(Unlocalized.Other.iCloud)
} footer: {

View File

@ -43,8 +43,8 @@ extension LaunchOnLoginItem {
guard SMLoginItemSetEnabled(Constants.Mac.appLauncherId as CFString, newValue) else {
return
}
launchesOnLogin = newValue
objectWillChange.send()
launchesOnLogin = newValue
}
}

View File

@ -23,7 +23,6 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CloudKit
import CoreData
import Foundation
import PassepartoutCore
@ -55,15 +54,4 @@ public final class VPNPersistence {
public func profileRepository() -> ProfileRepository {
CDProfileRepository(store.context)
}
// WARNING: this is not running on main actor
public func eraseCloudKitStore(fromContainerWithId containerId: String, zoneId: CKRecordZone.ID) async {
do {
let container = CKContainer(identifier: containerId)
let db = container.privateCloudDatabase
try await db.deleteRecordZone(withID: zoneId)
} catch {
pp_log.error("Unable to erase CloudKit store: \(error)")
}
}
}