Resolve excessive profile reloads (#883)

Optimize ProfileManager in several ways:

- Refine control over objectWillChange
- Observe search separately
- Store subscriptions separately (local, remote, search)
- Fix multiple local updates on save/remove/foreground (updating
allProfiles manually)
- Update the library with more optimized NE reloads
- Cancel pending remote import before a new one
- Yield 100ms between imports
- Reorganize code

Extras:

- Only use background context in provider repositories
- Externalize tunnel receipt URL, do not hardcode BundleConfiguration
- Improve some logging

Self-reminder: NEVER use a Core Data background context to observe
changes in CloudKit containers. They just won't be notified (e.g. in
NSFetchedResultsController).

Fixes #857
This commit is contained in:
Davide 2024-11-17 11:34:43 +01:00 committed by GitHub
parent 9e5beff23a
commit d3e344670b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 190 additions and 169 deletions

View File

@ -41,7 +41,7 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source", "location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
"state" : { "state" : {
"revision" : "d74cd0f02ba844beff2be55bf5f93796a3c43a6d" "revision" : "39cd828d3ee7cb502c4c0e36e3dc42e45bfae10b"
} }
}, },
{ {

View File

@ -44,7 +44,7 @@ let package = Package(
], ],
dependencies: [ dependencies: [
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.11.0"), // .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.11.0"),
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "d74cd0f02ba844beff2be55bf5f93796a3c43a6d"), .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "39cd828d3ee7cb502c4c0e36e3dc42e45bfae10b"),
// .package(path: "../../../passepartoutkit-source"), // .package(path: "../../../passepartoutkit-source"),
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", from: "0.9.1"), .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", from: "0.9.1"),
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"), // .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"),

View File

@ -31,28 +31,22 @@ import Foundation
import PassepartoutKit import PassepartoutKit
extension AppData { extension AppData {
public static func cdProviderRepositoryV3( public static func cdProviderRepositoryV3(context: NSManagedObjectContext) -> ProviderRepository {
context: NSManagedObjectContext, CDProviderRepositoryV3(context: context)
backgroundContext: NSManagedObjectContext
) -> ProviderRepository {
CDProviderRepositoryV3(context: context, backgroundContext: backgroundContext)
} }
} }
actor CDProviderRepositoryV3: NSObject, ProviderRepository { actor CDProviderRepositoryV3: NSObject, ProviderRepository {
private nonisolated let context: NSManagedObjectContext private nonisolated let context: NSManagedObjectContext
private nonisolated let backgroundContext: NSManagedObjectContext
private nonisolated let providersSubject: CurrentValueSubject<[ProviderMetadata], Never> private nonisolated let providersSubject: CurrentValueSubject<[ProviderMetadata], Never>
private nonisolated let lastUpdateSubject: CurrentValueSubject<[ProviderID: Date], Never> private nonisolated let lastUpdateSubject: CurrentValueSubject<[ProviderID: Date], Never>
private nonisolated let providersController: NSFetchedResultsController<CDProviderV3> private nonisolated let providersController: NSFetchedResultsController<CDProviderV3>
init(context: NSManagedObjectContext, backgroundContext: NSManagedObjectContext) { init(context: NSManagedObjectContext) {
self.context = context self.context = context
self.backgroundContext = backgroundContext
providersSubject = CurrentValueSubject([]) providersSubject = CurrentValueSubject([])
lastUpdateSubject = CurrentValueSubject([:]) lastUpdateSubject = CurrentValueSubject([:])
@ -90,7 +84,7 @@ actor CDProviderRepositoryV3: NSObject, ProviderRepository {
} }
func store(_ index: [ProviderMetadata]) async throws { func store(_ index: [ProviderMetadata]) async throws {
try await backgroundContext.perform { [weak self] in try await context.perform { [weak self] in
guard let self else { guard let self else {
return return
} }
@ -101,25 +95,25 @@ actor CDProviderRepositoryV3: NSObject, ProviderRepository {
let lastUpdatesByProvider = results.reduce(into: [:]) { let lastUpdatesByProvider = results.reduce(into: [:]) {
$0[$1.providerId] = $1.lastUpdate $0[$1.providerId] = $1.lastUpdate
} }
results.forEach(backgroundContext.delete) results.forEach(context.delete)
// replace but retain last update // replace but retain last update
let mapper = CoreDataMapper(context: backgroundContext) let mapper = CoreDataMapper(context: context)
index.forEach { index.forEach {
let lastUpdate = lastUpdatesByProvider[$0.id.rawValue] let lastUpdate = lastUpdatesByProvider[$0.id.rawValue]
mapper.cdProvider(from: $0, lastUpdate: lastUpdate) mapper.cdProvider(from: $0, lastUpdate: lastUpdate)
} }
try backgroundContext.save() try context.save()
} catch { } catch {
backgroundContext.rollback() context.rollback()
throw error throw error
} }
} }
} }
func store(_ infrastructure: VPNInfrastructure, for providerId: ProviderID) async throws { func store(_ infrastructure: VPNInfrastructure, for providerId: ProviderID) async throws {
try await backgroundContext.perform { [weak self] in try await context.perform { [weak self] in
guard let self else { guard let self else {
return return
} }
@ -138,15 +132,15 @@ actor CDProviderRepositoryV3: NSObject, ProviderRepository {
let serverRequest = CDVPNServerV3.fetchRequest() let serverRequest = CDVPNServerV3.fetchRequest()
serverRequest.predicate = predicate serverRequest.predicate = predicate
let servers = try serverRequest.execute() let servers = try serverRequest.execute()
servers.forEach(backgroundContext.delete) servers.forEach(context.delete)
let presetRequest = CDVPNPresetV3.fetchRequest() let presetRequest = CDVPNPresetV3.fetchRequest()
presetRequest.predicate = predicate presetRequest.predicate = predicate
let presets = try presetRequest.execute() let presets = try presetRequest.execute()
presets.forEach(backgroundContext.delete) presets.forEach(context.delete)
// create new entities // create new entities
let mapper = CoreDataMapper(context: backgroundContext) let mapper = CoreDataMapper(context: context)
try infrastructure.servers.forEach { try infrastructure.servers.forEach {
try mapper.cdServer(from: $0) try mapper.cdServer(from: $0)
} }
@ -154,9 +148,9 @@ actor CDProviderRepositoryV3: NSObject, ProviderRepository {
try mapper.cdPreset(from: $0) try mapper.cdPreset(from: $0)
} }
try backgroundContext.save() try context.save()
} catch { } catch {
backgroundContext.rollback() context.rollback()
throw error throw error
} }
} }

View File

@ -57,9 +57,6 @@ public final class ProfileManager: ObservableObject {
// MARK: State // MARK: State
@Published
private var profiles: [Profile]
private var allProfiles: [Profile.ID: Profile] { private var allProfiles: [Profile.ID: Profile] {
didSet { didSet {
reloadFilteredProfiles(with: searchSubject.value) reloadFilteredProfiles(with: searchSubject.value)
@ -68,14 +65,11 @@ public final class ProfileManager: ObservableObject {
private var allRemoteProfiles: [Profile.ID: Profile] private var allRemoteProfiles: [Profile.ID: Profile]
private var filteredProfiles: [Profile]
@Published @Published
public private(set) var isRemoteImportingEnabled: Bool public private(set) var isRemoteImportingEnabled: Bool
public var isReady: Bool {
waitingObservers.isEmpty
}
@Published
private var waitingObservers: Set<Observer> private var waitingObservers: Set<Observer>
// MARK: Publishers // MARK: Publishers
@ -84,9 +78,13 @@ public final class ProfileManager: ObservableObject {
private let searchSubject: CurrentValueSubject<String, Never> private let searchSubject: CurrentValueSubject<String, Never>
private var subscriptions: Set<AnyCancellable> private var localSubscription: AnyCancellable?
private var remoteSubscriptions: Set<AnyCancellable> private var remoteSubscription: AnyCancellable?
private var searchSubscription: AnyCancellable?
private var remoteImportTask: Task<Void, Never>?
// for testing/previews // for testing/previews
public init(profiles: [Profile]) { public init(profiles: [Profile]) {
@ -98,18 +96,18 @@ public final class ProfileManager: ObservableObject {
mirrorsRemoteRepository = false mirrorsRemoteRepository = false
processor = nil processor = nil
self.profiles = []
allProfiles = profiles.reduce(into: [:]) { allProfiles = profiles.reduce(into: [:]) {
$0[$1.id] = $1 $0[$1.id] = $1
} }
allRemoteProfiles = [:] allRemoteProfiles = [:]
filteredProfiles = []
isRemoteImportingEnabled = false isRemoteImportingEnabled = false
waitingObservers = [] waitingObservers = []
didChange = PassthroughSubject() didChange = PassthroughSubject()
searchSubject = CurrentValueSubject("") searchSubject = CurrentValueSubject("")
subscriptions = []
remoteSubscriptions = [] observeSearch()
} }
public init( public init(
@ -126,9 +124,9 @@ public final class ProfileManager: ObservableObject {
self.mirrorsRemoteRepository = mirrorsRemoteRepository self.mirrorsRemoteRepository = mirrorsRemoteRepository
self.processor = processor self.processor = processor
profiles = []
allProfiles = [:] allProfiles = [:]
allRemoteProfiles = [:] allRemoteProfiles = [:]
filteredProfiles = []
isRemoteImportingEnabled = false isRemoteImportingEnabled = false
if remoteRepositoryBlock != nil { if remoteRepositoryBlock != nil {
waitingObservers = [.local, .remote] waitingObservers = [.local, .remote]
@ -138,16 +136,20 @@ public final class ProfileManager: ObservableObject {
didChange = PassthroughSubject() didChange = PassthroughSubject()
searchSubject = CurrentValueSubject("") searchSubject = CurrentValueSubject("")
subscriptions = []
remoteSubscriptions = [] observeSearch()
} }
} }
// MARK: - CRUD // MARK: - View
extension ProfileManager { extension ProfileManager {
public var isReady: Bool {
waitingObservers.isEmpty
}
public var hasProfiles: Bool { public var hasProfiles: Bool {
!profiles.isEmpty !filteredProfiles.isEmpty
} }
public var isSearching: Bool { public var isSearching: Bool {
@ -155,21 +157,25 @@ extension ProfileManager {
} }
public var headers: [ProfileHeader] { public var headers: [ProfileHeader] {
profiles.map { filteredProfiles.map {
$0.header() $0.header()
} }
} }
public func profile(withId profileId: Profile.ID) -> Profile? {
filteredProfiles.first {
$0.id == profileId
}
}
public func search(byName name: String) { public func search(byName name: String) {
searchSubject.send(name) searchSubject.send(name)
} }
public func profile(withId profileId: Profile.ID) -> Profile? {
profiles.first {
$0.id == profileId
}
} }
// MARK: - CRUD
extension ProfileManager {
public func save(_ originalProfile: Profile, force: Bool = false, remotelyShared: Bool? = nil) async throws { public func save(_ originalProfile: Profile, force: Bool = false, remotelyShared: Bool? = nil) async throws {
let profile: Profile let profile: Profile
if force { if force {
@ -194,7 +200,6 @@ extension ProfileManager {
try await backupRepository.saveProfile(profile) try await backupRepository.saveProfile(profile)
} }
} }
allProfiles[profile.id] = profile
didChange.send(.save(profile)) didChange.send(.save(profile))
} else { } else {
pp_log(.App.profiles, .notice, "\tProfile \(profile.id) not modified, not saving") pp_log(.App.profiles, .notice, "\tProfile \(profile.id) not modified, not saving")
@ -203,8 +208,8 @@ extension ProfileManager {
pp_log(.App.profiles, .fault, "\tUnable to save profile \(profile.id): \(error)") pp_log(.App.profiles, .fault, "\tUnable to save profile \(profile.id): \(error)")
throw error throw error
} }
do {
if let remotelyShared, let remoteRepository { if let remotelyShared, let remoteRepository {
do {
if remotelyShared { if remotelyShared {
pp_log(.App.profiles, .notice, "\tEnable remote sharing of profile \(profile.id)...") pp_log(.App.profiles, .notice, "\tEnable remote sharing of profile \(profile.id)...")
try await remoteRepository.saveProfile(profile) try await remoteRepository.saveProfile(profile)
@ -212,11 +217,11 @@ extension ProfileManager {
pp_log(.App.profiles, .notice, "\tDisable remote sharing of profile \(profile.id)...") pp_log(.App.profiles, .notice, "\tDisable remote sharing of profile \(profile.id)...")
try await remoteRepository.removeProfiles(withIds: [profile.id]) try await remoteRepository.removeProfiles(withIds: [profile.id])
} }
}
} catch { } catch {
pp_log(.App.profiles, .fault, "\tUnable to save/remove remote profile \(profile.id): \(error)") pp_log(.App.profiles, .fault, "\tUnable to save/remove remote profile \(profile.id): \(error)")
throw error throw error
} }
}
pp_log(.App.profiles, .notice, "Finished saving profile \(profile.id)") pp_log(.App.profiles, .notice, "Finished saving profile \(profile.id)")
} }
@ -227,21 +232,8 @@ extension ProfileManager {
public func remove(withIds profileIds: [Profile.ID]) async { public func remove(withIds profileIds: [Profile.ID]) async {
pp_log(.App.profiles, .notice, "Remove profiles \(profileIds)...") pp_log(.App.profiles, .notice, "Remove profiles \(profileIds)...")
do { do {
// remove local profiles
var newAllProfiles = allProfiles
try await repository.removeProfiles(withIds: profileIds) try await repository.removeProfiles(withIds: profileIds)
profileIds.forEach {
newAllProfiles.removeValue(forKey: $0)
}
// remove remote counterpart too
try? await remoteRepository?.removeProfiles(withIds: profileIds) try? await remoteRepository?.removeProfiles(withIds: profileIds)
profileIds.forEach {
allRemoteProfiles.removeValue(forKey: $0)
}
// publish update
allProfiles = newAllProfiles
didChange.send(.remove(profileIds)) didChange.send(.remove(profileIds))
} catch { } catch {
pp_log(.App.profiles, .fault, "Unable to remove profiles \(profileIds): \(error)") pp_log(.App.profiles, .fault, "Unable to remove profiles \(profileIds): \(error)")
@ -299,7 +291,7 @@ extension ProfileManager {
private extension ProfileManager { private extension ProfileManager {
func firstUniqueName(from name: String) -> String { func firstUniqueName(from name: String) -> String {
let allNames = profiles.map(\.name) let allNames = Set(allProfiles.values.map(\.name))
var newName = name var newName = name
var index = 1 var index = 1
while true { while true {
@ -315,27 +307,18 @@ private extension ProfileManager {
// MARK: - Observation // MARK: - Observation
extension ProfileManager { extension ProfileManager {
public func observeLocal(searchDebounce: Int = 200) async throws { public func observeLocal() async throws {
subscriptions.removeAll() localSubscription = nil
let initialProfiles = try await repository.fetchProfiles() let initialProfiles = try await repository.fetchProfiles()
reloadLocalProfiles(initialProfiles) reloadLocalProfiles(initialProfiles)
repository localSubscription = repository
.profilesPublisher .profilesPublisher
.dropFirst() .dropFirst()
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] in .sink { [weak self] in
self?.reloadLocalProfiles($0) self?.reloadLocalProfiles($0)
} }
.store(in: &subscriptions)
searchSubject
.debounce(for: .milliseconds(searchDebounce), scheduler: DispatchQueue.main)
.sink { [weak self] in
self?.performSearch($0)
}
.store(in: &subscriptions)
} }
public func observeRemote(_ isRemoteImportingEnabled: Bool) async throws { public func observeRemote(_ isRemoteImportingEnabled: Bool) async throws {
@ -348,21 +331,30 @@ extension ProfileManager {
} }
self.isRemoteImportingEnabled = isRemoteImportingEnabled self.isRemoteImportingEnabled = isRemoteImportingEnabled
remoteSubscriptions.removeAll()
remoteSubscription = nil
let newRepository = remoteRepositoryBlock(isRemoteImportingEnabled) let newRepository = remoteRepositoryBlock(isRemoteImportingEnabled)
let initialProfiles = try await newRepository.fetchProfiles() let initialProfiles = try await newRepository.fetchProfiles()
reloadRemoteProfiles(initialProfiles) reloadRemoteProfiles(initialProfiles)
remoteRepository = newRepository remoteRepository = newRepository
remoteRepository? remoteSubscription = remoteRepository?
.profilesPublisher .profilesPublisher
.dropFirst() .dropFirst()
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] in .sink { [weak self] in
self?.reloadRemoteProfiles($0) self?.reloadRemoteProfiles($0)
} }
.store(in: &remoteSubscriptions) }
}
private extension ProfileManager {
func observeSearch(debounce: Int = 200) {
searchSubscription = searchSubject
.debounce(for: .milliseconds(debounce), scheduler: DispatchQueue.main)
.sink { [weak self] in
self?.reloadFilteredProfiles(with: $0)
}
} }
} }
@ -373,11 +365,31 @@ private extension ProfileManager {
$0[$1.id] = $1 $0[$1.id] = $1
} }
if waitingObservers.contains(.local) { if waitingObservers.contains(.local) {
waitingObservers.remove(.local) waitingObservers.remove(.local) // @Published
}
deleteExcludedProfiles()
objectWillChange.send()
}
func reloadRemoteProfiles(_ result: [Profile]) {
pp_log(.App.profiles, .info, "Reload remote profiles: \(result.map(\.id))")
allRemoteProfiles = result.reduce(into: [:]) {
$0[$1.id] = $1
}
if waitingObservers.contains(.remote) {
waitingObservers.remove(.remote) // @Published
}
importRemoteProfiles(result)
objectWillChange.send()
} }
// should not be imported at all, but you never know // should not be imported at all, but you never know
if let processor { func deleteExcludedProfiles() {
guard let processor else {
return
}
let idsToRemove: [Profile.ID] = allProfiles let idsToRemove: [Profile.ID] = allProfiles
.filter { .filter {
!processor.isIncluded($0.value) !processor.isIncluded($0.value)
@ -391,49 +403,49 @@ private extension ProfileManager {
} }
} }
} }
}
func reloadRemoteProfiles(_ result: [Profile]) { func importRemoteProfiles(_ profiles: [Profile]) {
pp_log(.App.profiles, .info, "Reload remote profiles: \(result.map(\.id))") guard !profiles.isEmpty else {
allRemoteProfiles = result.reduce(into: [:]) {
$0[$1.id] = $1
}
if waitingObservers.contains(.remote) {
waitingObservers.remove(.remote)
}
Task.detached { [weak self] in
guard let self else {
return return
} }
pp_log(.App.profiles, .info, "Start importing remote profiles...") pp_log(.App.profiles, .info, "Start importing remote profiles: \(profiles.map(\.id)))")
assert(result.count == Set(result.map(\.id)).count, "Remote repository must not have duplicates") assert(profiles.count == Set(profiles.map(\.id)).count, "Remote repository must not have duplicates")
pp_log(.App.profiles, .debug, "Local attributes:") pp_log(.App.profiles, .debug, "Local attributes:")
let localAttributes: [Profile.ID: ProfileAttributes] = await allProfiles.values.reduce(into: [:]) { let localAttributes: [Profile.ID: ProfileAttributes] = allProfiles.values.reduce(into: [:]) {
$0[$1.id] = $1.attributes $0[$1.id] = $1.attributes
pp_log(.App.profiles, .debug, "\t\($1.id) = \($1.attributes)") pp_log(.App.profiles, .debug, "\t\($1.id) = \($1.attributes)")
} }
pp_log(.App.profiles, .debug, "Remote attributes:") pp_log(.App.profiles, .debug, "Remote attributes:")
let remoteAttributes: [Profile.ID: ProfileAttributes] = result.reduce(into: [:]) { let remoteAttributes: [Profile.ID: ProfileAttributes] = profiles.reduce(into: [:]) {
$0[$1.id] = $1.attributes $0[$1.id] = $1.attributes
pp_log(.App.profiles, .debug, "\t\($1.id) = \($1.attributes)") pp_log(.App.profiles, .debug, "\t\($1.id) = \($1.attributes)")
} }
let profilesToImport = result let remotelyDeletedIds = Set(allProfiles.keys).subtracting(Set(allRemoteProfiles.keys))
let remotelyDeletedIds = await Set(allProfiles.keys).subtracting(Set(allRemoteProfiles.keys))
let mirrorsRemoteRepository = mirrorsRemoteRepository let mirrorsRemoteRepository = mirrorsRemoteRepository
let previousTask = remoteImportTask
remoteImportTask = Task.detached { [weak self] in
guard let self else {
return
}
if let previousTask {
pp_log(.App.profiles, .info, "Cancel ongoing remote import...")
previousTask.cancel()
await previousTask.value
}
var idsToRemove: [Profile.ID] = [] var idsToRemove: [Profile.ID] = []
if !remotelyDeletedIds.isEmpty { if !remotelyDeletedIds.isEmpty {
pp_log(.App.profiles, .info, "Will \(mirrorsRemoteRepository ? "delete" : "retain") local profiles not present in remote repository: \(remotelyDeletedIds)") pp_log(.App.profiles, .info, "Will \(mirrorsRemoteRepository ? "delete" : "retain") local profiles not present in remote repository: \(remotelyDeletedIds)")
if mirrorsRemoteRepository { if mirrorsRemoteRepository {
idsToRemove.append(contentsOf: remotelyDeletedIds) idsToRemove.append(contentsOf: remotelyDeletedIds)
} }
} }
for remoteProfile in profilesToImport { for remoteProfile in profiles {
do { do {
guard processor?.isIncluded(remoteProfile) ?? true else { guard processor?.isIncluded(remoteProfile) ?? true else {
pp_log(.App.profiles, .info, "Will delete non-included remote profile \(remoteProfile.id)") pp_log(.App.profiles, .info, "Will delete non-included remote profile \(remoteProfile.id)")
@ -452,19 +464,26 @@ private extension ProfileManager {
} catch { } catch {
pp_log(.App.profiles, .error, "Unable to import remote profile: \(error)") pp_log(.App.profiles, .error, "Unable to import remote profile: \(error)")
} }
} guard !Task.isCancelled else {
pp_log(.App.profiles, .notice, "Finished importing remote profiles, delete stale profiles: \(idsToRemove)") pp_log(.App.profiles, .info, "Cancelled import of remote profiles: \(profiles.map(\.id))")
try? await repository.removeProfiles(withIds: idsToRemove) return
} }
} }
func performSearch(_ search: String) { pp_log(.App.profiles, .notice, "Finished importing remote profiles, delete stale profiles: \(idsToRemove)")
pp_log(.App.profiles, .notice, "Filter profiles with '\(search)'") do {
reloadFilteredProfiles(with: search) try await repository.removeProfiles(withIds: idsToRemove)
} catch {
pp_log(.App.profiles, .error, "Unable to delete stale profiles: \(error)")
}
// yield a little bit
try? await Task.sleep(for: .milliseconds(100))
}
} }
func reloadFilteredProfiles(with search: String) { func reloadFilteredProfiles(with search: String) {
profiles = allProfiles filteredProfiles = allProfiles
.values .values
.filter { .filter {
if !search.isEmpty { if !search.isEmpty {
@ -475,5 +494,9 @@ private extension ProfileManager {
.sorted { .sorted {
$0.name.lowercased() < $1.name.lowercased() $0.name.lowercased() < $1.name.lowercased()
} }
pp_log(.App.profiles, .notice, "Filter profiles with '\(search)' (\(filteredProfiles.count) results)")
objectWillChange.send()
} }
} }

View File

@ -261,7 +261,6 @@ extension IAPManager {
} }
} }
.store(in: &subscriptions) .store(in: &subscriptions)
} catch { } catch {
pp_log(.App.iap, .error, "Unable to fetch in-app products: \(error)") pp_log(.App.iap, .error, "Unable to fetch in-app products: \(error)")
} }

View File

@ -50,6 +50,8 @@ public actor CoreDataRepository<CD, T>: NSObject,
private let observingResults: Bool private let observingResults: Bool
private let beforeFetch: ((NSFetchRequest<CD>) -> Void)?
private nonisolated let fromMapper: (CD) throws -> T? private nonisolated let fromMapper: (CD) throws -> T?
private nonisolated let toMapper: (T, NSManagedObjectContext) throws -> CD private nonisolated let toMapper: (T, NSManagedObjectContext) throws -> CD
@ -58,8 +60,7 @@ public actor CoreDataRepository<CD, T>: NSObject,
private nonisolated let entitiesSubject: CurrentValueSubject<EntitiesResult<T>, Never> private nonisolated let entitiesSubject: CurrentValueSubject<EntitiesResult<T>, Never>
// cannot easily use CD as generic private var resultsController: NSFetchedResultsController<CD>?
private var resultsController: NSFetchedResultsController<CD>
public init( public init(
context: NSManagedObjectContext, context: NSManagedObjectContext,
@ -76,19 +77,11 @@ public actor CoreDataRepository<CD, T>: NSObject,
self.entityName = entityName self.entityName = entityName
self.context = context self.context = context
self.observingResults = observingResults self.observingResults = observingResults
self.beforeFetch = beforeFetch
self.fromMapper = fromMapper self.fromMapper = fromMapper
self.toMapper = toMapper self.toMapper = toMapper
self.onResultError = onResultError self.onResultError = onResultError
entitiesSubject = CurrentValueSubject(EntitiesResult()) entitiesSubject = CurrentValueSubject(EntitiesResult())
let request = NSFetchRequest<CD>(entityName: entityName)
beforeFetch?(request)
resultsController = NSFetchedResultsController(
fetchRequest: request,
managedObjectContext: context,
sectionNameKeyPath: nil,
cacheName: nil
)
} }
public nonisolated var entitiesPublisher: AnyPublisher<EntitiesResult<T>, Never> { public nonisolated var entitiesPublisher: AnyPublisher<EntitiesResult<T>, Never> {
@ -183,17 +176,24 @@ private extension CoreDataRepository {
@discardableResult @discardableResult
func filter(byPredicate predicate: NSPredicate?) async throws -> [T] { func filter(byPredicate predicate: NSPredicate?) async throws -> [T] {
let request = resultsController.fetchRequest let request = newFetchRequest()
request.predicate = predicate request.predicate = predicate
resultsController = NSFetchedResultsController( beforeFetch?(request)
let newController = try await context.perform {
let newController = NSFetchedResultsController(
fetchRequest: request, fetchRequest: request,
managedObjectContext: context, managedObjectContext: self.context,
sectionNameKeyPath: nil, sectionNameKeyPath: nil,
cacheName: nil cacheName: nil
) )
resultsController.delegate = self newController.delegate = self
try resultsController.performFetch() try newController.performFetch()
return await sendResults(from: resultsController) return newController
}
resultsController = newController
return await sendResults(from: newController)
} }
@discardableResult @discardableResult

View File

@ -43,6 +43,8 @@ public final class AppContext: ObservableObject {
public let tunnel: ExtendedTunnel public let tunnel: ExtendedTunnel
private let tunnelReceiptURL: URL
private var launchTask: Task<Void, Error>? private var launchTask: Task<Void, Error>?
private var pendingTask: Task<Void, Never>? private var pendingTask: Task<Void, Never>?
@ -55,7 +57,8 @@ public final class AppContext: ObservableObject {
profileManager: ProfileManager, profileManager: ProfileManager,
providerManager: ProviderManager, providerManager: ProviderManager,
registry: Registry, registry: Registry,
tunnel: ExtendedTunnel tunnel: ExtendedTunnel,
tunnelReceiptURL: URL
) { ) {
self.iapManager = iapManager self.iapManager = iapManager
self.migrationManager = migrationManager self.migrationManager = migrationManager
@ -63,6 +66,7 @@ public final class AppContext: ObservableObject {
self.providerManager = providerManager self.providerManager = providerManager
self.registry = registry self.registry = registry
self.tunnel = tunnel self.tunnel = tunnel
self.tunnelReceiptURL = tunnelReceiptURL
subscriptions = [] subscriptions = []
} }
} }
@ -84,12 +88,14 @@ private extension AppContext {
func onLaunch() async throws { func onLaunch() async throws {
pp_log(.app, .notice, "Application did launch") pp_log(.app, .notice, "Application did launch")
pp_log(.App.profiles, .info, "Read and observe local profiles...") pp_log(.App.profiles, .info, "\tRead and observe local profiles...")
try await profileManager.observeLocal() try await profileManager.observeLocal()
pp_log(.App.profiles, .info, "\tObserve in-app events...")
iapManager.observeObjects() iapManager.observeObjects()
await iapManager.reloadReceipt() await iapManager.reloadReceipt()
pp_log(.App.profiles, .info, "\tObserve eligible features...")
iapManager iapManager
.$eligibleFeatures .$eligibleFeatures
.removeDuplicates() .removeDuplicates()
@ -100,6 +106,7 @@ private extension AppContext {
} }
.store(in: &subscriptions) .store(in: &subscriptions)
pp_log(.App.profiles, .info, "\tObserve changes in ProfileManager...")
profileManager profileManager
.didChange .didChange
.sink { [weak self] event in .sink { [weak self] event in
@ -117,21 +124,20 @@ private extension AppContext {
// copy release receipt to tunnel for TestFlight eligibility (once is enough, it won't change) // copy release receipt to tunnel for TestFlight eligibility (once is enough, it won't change)
if let appReceiptURL = Bundle.main.appStoreProductionReceiptURL { if let appReceiptURL = Bundle.main.appStoreProductionReceiptURL {
let tunnelReceiptURL = BundleConfiguration.urlForBetaReceipt
do { do {
pp_log(.App.iap, .info, "Copy release receipt to tunnel...") pp_log(.App.iap, .info, "\tCopy release receipt to tunnel...")
try? FileManager.default.removeItem(at: tunnelReceiptURL) try? FileManager.default.removeItem(at: tunnelReceiptURL)
try FileManager.default.copyItem(at: appReceiptURL, to: tunnelReceiptURL) try FileManager.default.copyItem(at: appReceiptURL, to: tunnelReceiptURL)
} catch { } catch {
pp_log(.App.iap, .error, "Unable to copy release receipt to tunnel: \(error)") pp_log(.App.iap, .error, "\tUnable to copy release receipt to tunnel: \(error)")
} }
} }
do { do {
pp_log(.app, .notice, "Fetch providers index...") pp_log(.app, .info, "\tFetch providers index...")
try await providerManager.fetchIndex(from: API.shared) try await providerManager.fetchIndex(from: API.shared)
} catch { } catch {
pp_log(.app, .error, "Unable to fetch providers index: \(error)") pp_log(.app, .error, "\tUnable to fetch providers index: \(error)")
} }
} }
@ -144,10 +150,10 @@ private extension AppContext {
pp_log(.app, .notice, "Application did enter foreground") pp_log(.app, .notice, "Application did enter foreground")
pendingTask = Task { pendingTask = Task {
do { do {
pp_log(.App.profiles, .info, "Refresh local profiles observers...") pp_log(.App.profiles, .info, "\tRefresh local profiles observers...")
try await profileManager.observeLocal() try await profileManager.observeLocal()
} catch { } catch {
pp_log(.App.profiles, .error, "Unable to re-observe local profiles: \(error)") pp_log(.App.profiles, .error, "\tUnable to re-observe local profiles: \(error)")
} }
await iapManager.reloadReceipt() await iapManager.reloadReceipt()
@ -165,10 +171,10 @@ private extension AppContext {
// toggle sync based on .sharing eligibility // toggle sync based on .sharing eligibility
let isEligibleForSharing = features.contains(.sharing) let isEligibleForSharing = features.contains(.sharing)
do { do {
pp_log(.App.profiles, .info, "Refresh remote profiles observers (eligible=\(isEligibleForSharing), CloudKit=\(isCloudKitEnabled))...") pp_log(.App.profiles, .info, "\tRefresh remote profiles observers (eligible=\(isEligibleForSharing), CloudKit=\(isCloudKitEnabled))...")
try await profileManager.observeRemote(isEligibleForSharing && isCloudKitEnabled) try await profileManager.observeRemote(isEligibleForSharing && isCloudKitEnabled)
} catch { } catch {
pp_log(.App.profiles, .error, "Unable to re-observe remote profiles: \(error)") pp_log(.App.profiles, .error, "\tUnable to re-observe remote profiles: \(error)")
} }
} }
await pendingTask?.value await pendingTask?.value
@ -180,29 +186,29 @@ private extension AppContext {
pp_log(.app, .notice, "Application did save profile (\(profile.id))") pp_log(.app, .notice, "Application did save profile (\(profile.id))")
guard profile.id == tunnel.currentProfile?.id else { guard profile.id == tunnel.currentProfile?.id else {
pp_log(.app, .debug, "Profile \(profile.id) is not current, do nothing") pp_log(.app, .debug, "\tProfile \(profile.id) is not current, do nothing")
return return
} }
guard [.active, .activating].contains(tunnel.status) else { guard [.active, .activating].contains(tunnel.status) else {
pp_log(.app, .debug, "Connection is not active (\(tunnel.status)), do nothing") pp_log(.app, .debug, "\tConnection is not active (\(tunnel.status)), do nothing")
return return
} }
pendingTask = Task { pendingTask = Task {
do { do {
if profile.isInteractive { if profile.isInteractive {
pp_log(.app, .info, "Profile \(profile.id) is interactive, disconnect") pp_log(.app, .info, "\tProfile \(profile.id) is interactive, disconnect")
try await tunnel.disconnect() try await tunnel.disconnect()
return return
} }
do { do {
pp_log(.app, .info, "Reconnect profile \(profile.id)") pp_log(.app, .info, "\tReconnect profile \(profile.id)")
try await tunnel.connect(with: profile) try await tunnel.connect(with: profile)
} catch { } catch {
pp_log(.app, .error, "Unable to reconnect profile \(profile.id), disconnect: \(error)") pp_log(.app, .error, "\tUnable to reconnect profile \(profile.id), disconnect: \(error)")
try await tunnel.disconnect() try await tunnel.disconnect()
} }
} catch { } catch {
pp_log(.app, .error, "Unable to reinstate connection on save profile \(profile.id): \(error)") pp_log(.app, .error, "\tUnable to reinstate connection on save profile \(profile.id): \(error)")
} }
} }
await pendingTask?.value await pendingTask?.value

View File

@ -83,7 +83,8 @@ extension AppContext {
profileManager: profileManager, profileManager: profileManager,
providerManager: providerManager, providerManager: providerManager,
registry: registry, registry: registry,
tunnel: tunnel tunnel: tunnel,
tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt
) )
} }
} }

View File

@ -87,10 +87,7 @@ extension AppContext {
cloudKitIdentifier: nil, cloudKitIdentifier: nil,
author: nil author: nil
) )
let repository = AppData.cdProviderRepositoryV3( let repository = AppData.cdProviderRepositoryV3(context: store.backgroundContext)
context: store.context,
backgroundContext: store.backgroundContext
)
return ProviderManager(repository: repository) return ProviderManager(repository: repository)
}() }()
@ -119,7 +116,8 @@ extension AppContext {
profileManager: profileManager, profileManager: profileManager,
providerManager: providerManager, providerManager: providerManager,
registry: .shared, registry: .shared,
tunnel: tunnel tunnel: tunnel,
tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt
) )
}() }()
} }