passepartout-apple/Passepartout/Library/Sources/AppDataProviders/Strategy/CDProviderRepositoryV3.swift
Davide d3e344670b
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
2024-11-17 11:34:43 +01:00

174 lines
5.9 KiB
Swift

//
// CDProviderRepositoryV3.swift
// Passepartout
//
// Created by Davide De Rosa on 10/26/24.
// Copyright (c) 2024 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 AppData
import Combine
import CommonUtils
import CoreData
import Foundation
import PassepartoutKit
extension AppData {
public static func cdProviderRepositoryV3(context: NSManagedObjectContext) -> ProviderRepository {
CDProviderRepositoryV3(context: context)
}
}
actor CDProviderRepositoryV3: NSObject, ProviderRepository {
private nonisolated let context: NSManagedObjectContext
private nonisolated let providersSubject: CurrentValueSubject<[ProviderMetadata], Never>
private nonisolated let lastUpdateSubject: CurrentValueSubject<[ProviderID: Date], Never>
private nonisolated let providersController: NSFetchedResultsController<CDProviderV3>
init(context: NSManagedObjectContext) {
self.context = context
providersSubject = CurrentValueSubject([])
lastUpdateSubject = CurrentValueSubject([:])
let request = CDProviderV3.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(key: "providerId", ascending: true)
]
providersController = .init(
fetchRequest: request,
managedObjectContext: context,
sectionNameKeyPath: nil,
cacheName: nil
)
super.init()
Task {
try await context.perform { [weak self] in
self?.providersController.delegate = self
try self?.providersController.performFetch()
}
}
}
nonisolated var indexPublisher: AnyPublisher<[ProviderMetadata], Never> {
providersSubject
.removeDuplicates()
.eraseToAnyPublisher()
}
nonisolated var lastUpdatePublisher: AnyPublisher<[ProviderID: Date], Never> {
lastUpdateSubject
.removeDuplicates()
.eraseToAnyPublisher()
}
func store(_ index: [ProviderMetadata]) async throws {
try await context.perform { [weak self] in
guard let self else {
return
}
do {
// fetch existing for last update and deletion
let request = CDProviderV3.fetchRequest()
let results = try request.execute()
let lastUpdatesByProvider = results.reduce(into: [:]) {
$0[$1.providerId] = $1.lastUpdate
}
results.forEach(context.delete)
// replace but retain last update
let mapper = CoreDataMapper(context: context)
index.forEach {
let lastUpdate = lastUpdatesByProvider[$0.id.rawValue]
mapper.cdProvider(from: $0, lastUpdate: lastUpdate)
}
try context.save()
} catch {
context.rollback()
throw error
}
}
}
func store(_ infrastructure: VPNInfrastructure, for providerId: ProviderID) async throws {
try await context.perform { [weak self] in
guard let self else {
return
}
do {
let predicate = providerId.predicate
// signal update of related provider
let providerRequest = CDProviderV3.fetchRequest()
providerRequest.predicate = predicate
let providers = try providerRequest.execute()
if let provider = providers.first {
provider.lastUpdate = infrastructure.lastUpdate
}
// delete all provider entities
let serverRequest = CDVPNServerV3.fetchRequest()
serverRequest.predicate = predicate
let servers = try serverRequest.execute()
servers.forEach(context.delete)
let presetRequest = CDVPNPresetV3.fetchRequest()
presetRequest.predicate = predicate
let presets = try presetRequest.execute()
presets.forEach(context.delete)
// create new entities
let mapper = CoreDataMapper(context: context)
try infrastructure.servers.forEach {
try mapper.cdServer(from: $0)
}
try infrastructure.presets.forEach {
try mapper.cdPreset(from: $0)
}
try context.save()
} catch {
context.rollback()
throw error
}
}
}
nonisolated func vpnServerRepository(for providerId: ProviderID) -> VPNProviderServerRepository {
CDVPNProviderServerRepositoryV3(context: context, providerId: providerId)
}
}
extension CDProviderRepositoryV3: NSFetchedResultsControllerDelegate {
nonisolated func controllerDidChangeContent(_ controller: NSFetchedResultsController<any NSFetchRequestResult>) {
guard let entities = controller.fetchedObjects as? [CDProviderV3] else {
return
}
let mapper = DomainMapper()
providersSubject.send(entities.compactMap(mapper.provider(from:)))
lastUpdateSubject.send(mapper.lastUpdate(from: entities))
}
}