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:
parent
9e5beff23a
commit
d3e344670b
|
@ -41,7 +41,7 @@
|
|||
"kind" : "remoteSourceControl",
|
||||
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
|
||||
"state" : {
|
||||
"revision" : "d74cd0f02ba844beff2be55bf5f93796a3c43a6d"
|
||||
"revision" : "39cd828d3ee7cb502c4c0e36e3dc42e45bfae10b"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -44,7 +44,7 @@ let package = Package(
|
|||
],
|
||||
dependencies: [
|
||||
// .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(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"),
|
||||
|
|
|
@ -31,28 +31,22 @@ import Foundation
|
|||
import PassepartoutKit
|
||||
|
||||
extension AppData {
|
||||
public static func cdProviderRepositoryV3(
|
||||
context: NSManagedObjectContext,
|
||||
backgroundContext: NSManagedObjectContext
|
||||
) -> ProviderRepository {
|
||||
CDProviderRepositoryV3(context: context, backgroundContext: backgroundContext)
|
||||
public static func cdProviderRepositoryV3(context: NSManagedObjectContext) -> ProviderRepository {
|
||||
CDProviderRepositoryV3(context: context)
|
||||
}
|
||||
}
|
||||
|
||||
actor CDProviderRepositoryV3: NSObject, ProviderRepository {
|
||||
private nonisolated let context: NSManagedObjectContext
|
||||
|
||||
private nonisolated let backgroundContext: 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, backgroundContext: NSManagedObjectContext) {
|
||||
init(context: NSManagedObjectContext) {
|
||||
self.context = context
|
||||
self.backgroundContext = backgroundContext
|
||||
providersSubject = CurrentValueSubject([])
|
||||
lastUpdateSubject = CurrentValueSubject([:])
|
||||
|
||||
|
@ -90,7 +84,7 @@ actor CDProviderRepositoryV3: NSObject, ProviderRepository {
|
|||
}
|
||||
|
||||
func store(_ index: [ProviderMetadata]) async throws {
|
||||
try await backgroundContext.perform { [weak self] in
|
||||
try await context.perform { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
@ -101,25 +95,25 @@ actor CDProviderRepositoryV3: NSObject, ProviderRepository {
|
|||
let lastUpdatesByProvider = results.reduce(into: [:]) {
|
||||
$0[$1.providerId] = $1.lastUpdate
|
||||
}
|
||||
results.forEach(backgroundContext.delete)
|
||||
results.forEach(context.delete)
|
||||
|
||||
// replace but retain last update
|
||||
let mapper = CoreDataMapper(context: backgroundContext)
|
||||
let mapper = CoreDataMapper(context: context)
|
||||
index.forEach {
|
||||
let lastUpdate = lastUpdatesByProvider[$0.id.rawValue]
|
||||
mapper.cdProvider(from: $0, lastUpdate: lastUpdate)
|
||||
}
|
||||
|
||||
try backgroundContext.save()
|
||||
try context.save()
|
||||
} catch {
|
||||
backgroundContext.rollback()
|
||||
context.rollback()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return
|
||||
}
|
||||
|
@ -138,15 +132,15 @@ actor CDProviderRepositoryV3: NSObject, ProviderRepository {
|
|||
let serverRequest = CDVPNServerV3.fetchRequest()
|
||||
serverRequest.predicate = predicate
|
||||
let servers = try serverRequest.execute()
|
||||
servers.forEach(backgroundContext.delete)
|
||||
servers.forEach(context.delete)
|
||||
|
||||
let presetRequest = CDVPNPresetV3.fetchRequest()
|
||||
presetRequest.predicate = predicate
|
||||
let presets = try presetRequest.execute()
|
||||
presets.forEach(backgroundContext.delete)
|
||||
presets.forEach(context.delete)
|
||||
|
||||
// create new entities
|
||||
let mapper = CoreDataMapper(context: backgroundContext)
|
||||
let mapper = CoreDataMapper(context: context)
|
||||
try infrastructure.servers.forEach {
|
||||
try mapper.cdServer(from: $0)
|
||||
}
|
||||
|
@ -154,9 +148,9 @@ actor CDProviderRepositoryV3: NSObject, ProviderRepository {
|
|||
try mapper.cdPreset(from: $0)
|
||||
}
|
||||
|
||||
try backgroundContext.save()
|
||||
try context.save()
|
||||
} catch {
|
||||
backgroundContext.rollback()
|
||||
context.rollback()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
|
|
@ -57,9 +57,6 @@ public final class ProfileManager: ObservableObject {
|
|||
|
||||
// MARK: State
|
||||
|
||||
@Published
|
||||
private var profiles: [Profile]
|
||||
|
||||
private var allProfiles: [Profile.ID: Profile] {
|
||||
didSet {
|
||||
reloadFilteredProfiles(with: searchSubject.value)
|
||||
|
@ -68,14 +65,11 @@ public final class ProfileManager: ObservableObject {
|
|||
|
||||
private var allRemoteProfiles: [Profile.ID: Profile]
|
||||
|
||||
private var filteredProfiles: [Profile]
|
||||
|
||||
@Published
|
||||
public private(set) var isRemoteImportingEnabled: Bool
|
||||
|
||||
public var isReady: Bool {
|
||||
waitingObservers.isEmpty
|
||||
}
|
||||
|
||||
@Published
|
||||
private var waitingObservers: Set<Observer>
|
||||
|
||||
// MARK: Publishers
|
||||
|
@ -84,9 +78,13 @@ public final class ProfileManager: ObservableObject {
|
|||
|
||||
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
|
||||
public init(profiles: [Profile]) {
|
||||
|
@ -98,18 +96,18 @@ public final class ProfileManager: ObservableObject {
|
|||
mirrorsRemoteRepository = false
|
||||
processor = nil
|
||||
|
||||
self.profiles = []
|
||||
allProfiles = profiles.reduce(into: [:]) {
|
||||
$0[$1.id] = $1
|
||||
}
|
||||
allRemoteProfiles = [:]
|
||||
filteredProfiles = []
|
||||
isRemoteImportingEnabled = false
|
||||
waitingObservers = []
|
||||
|
||||
didChange = PassthroughSubject()
|
||||
searchSubject = CurrentValueSubject("")
|
||||
subscriptions = []
|
||||
remoteSubscriptions = []
|
||||
|
||||
observeSearch()
|
||||
}
|
||||
|
||||
public init(
|
||||
|
@ -126,9 +124,9 @@ public final class ProfileManager: ObservableObject {
|
|||
self.mirrorsRemoteRepository = mirrorsRemoteRepository
|
||||
self.processor = processor
|
||||
|
||||
profiles = []
|
||||
allProfiles = [:]
|
||||
allRemoteProfiles = [:]
|
||||
filteredProfiles = []
|
||||
isRemoteImportingEnabled = false
|
||||
if remoteRepositoryBlock != nil {
|
||||
waitingObservers = [.local, .remote]
|
||||
|
@ -138,16 +136,20 @@ public final class ProfileManager: ObservableObject {
|
|||
|
||||
didChange = PassthroughSubject()
|
||||
searchSubject = CurrentValueSubject("")
|
||||
subscriptions = []
|
||||
remoteSubscriptions = []
|
||||
|
||||
observeSearch()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CRUD
|
||||
// MARK: - View
|
||||
|
||||
extension ProfileManager {
|
||||
public var isReady: Bool {
|
||||
waitingObservers.isEmpty
|
||||
}
|
||||
|
||||
public var hasProfiles: Bool {
|
||||
!profiles.isEmpty
|
||||
!filteredProfiles.isEmpty
|
||||
}
|
||||
|
||||
public var isSearching: Bool {
|
||||
|
@ -155,21 +157,25 @@ extension ProfileManager {
|
|||
}
|
||||
|
||||
public var headers: [ProfileHeader] {
|
||||
profiles.map {
|
||||
filteredProfiles.map {
|
||||
$0.header()
|
||||
}
|
||||
}
|
||||
|
||||
public func profile(withId profileId: Profile.ID) -> Profile? {
|
||||
filteredProfiles.first {
|
||||
$0.id == profileId
|
||||
}
|
||||
}
|
||||
|
||||
public func search(byName name: String) {
|
||||
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 {
|
||||
let profile: Profile
|
||||
if force {
|
||||
|
@ -194,7 +200,6 @@ extension ProfileManager {
|
|||
try await backupRepository.saveProfile(profile)
|
||||
}
|
||||
}
|
||||
allProfiles[profile.id] = profile
|
||||
didChange.send(.save(profile))
|
||||
} else {
|
||||
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)")
|
||||
throw error
|
||||
}
|
||||
do {
|
||||
if let remotelyShared, let remoteRepository {
|
||||
do {
|
||||
if remotelyShared {
|
||||
pp_log(.App.profiles, .notice, "\tEnable remote sharing of profile \(profile.id)...")
|
||||
try await remoteRepository.saveProfile(profile)
|
||||
|
@ -212,11 +217,11 @@ extension ProfileManager {
|
|||
pp_log(.App.profiles, .notice, "\tDisable remote sharing of profile \(profile.id)...")
|
||||
try await remoteRepository.removeProfiles(withIds: [profile.id])
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
pp_log(.App.profiles, .fault, "\tUnable to save/remove remote profile \(profile.id): \(error)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
pp_log(.App.profiles, .notice, "Finished saving profile \(profile.id)")
|
||||
}
|
||||
|
||||
|
@ -227,21 +232,8 @@ extension ProfileManager {
|
|||
public func remove(withIds profileIds: [Profile.ID]) async {
|
||||
pp_log(.App.profiles, .notice, "Remove profiles \(profileIds)...")
|
||||
do {
|
||||
// remove local profiles
|
||||
var newAllProfiles = allProfiles
|
||||
try await repository.removeProfiles(withIds: profileIds)
|
||||
profileIds.forEach {
|
||||
newAllProfiles.removeValue(forKey: $0)
|
||||
}
|
||||
|
||||
// remove remote counterpart too
|
||||
try? await remoteRepository?.removeProfiles(withIds: profileIds)
|
||||
profileIds.forEach {
|
||||
allRemoteProfiles.removeValue(forKey: $0)
|
||||
}
|
||||
|
||||
// publish update
|
||||
allProfiles = newAllProfiles
|
||||
didChange.send(.remove(profileIds))
|
||||
} catch {
|
||||
pp_log(.App.profiles, .fault, "Unable to remove profiles \(profileIds): \(error)")
|
||||
|
@ -299,7 +291,7 @@ extension ProfileManager {
|
|||
|
||||
private extension ProfileManager {
|
||||
func firstUniqueName(from name: String) -> String {
|
||||
let allNames = profiles.map(\.name)
|
||||
let allNames = Set(allProfiles.values.map(\.name))
|
||||
var newName = name
|
||||
var index = 1
|
||||
while true {
|
||||
|
@ -315,27 +307,18 @@ private extension ProfileManager {
|
|||
// MARK: - Observation
|
||||
|
||||
extension ProfileManager {
|
||||
public func observeLocal(searchDebounce: Int = 200) async throws {
|
||||
subscriptions.removeAll()
|
||||
|
||||
public func observeLocal() async throws {
|
||||
localSubscription = nil
|
||||
let initialProfiles = try await repository.fetchProfiles()
|
||||
reloadLocalProfiles(initialProfiles)
|
||||
|
||||
repository
|
||||
localSubscription = repository
|
||||
.profilesPublisher
|
||||
.dropFirst()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] in
|
||||
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 {
|
||||
|
@ -348,21 +331,30 @@ extension ProfileManager {
|
|||
}
|
||||
|
||||
self.isRemoteImportingEnabled = isRemoteImportingEnabled
|
||||
remoteSubscriptions.removeAll()
|
||||
|
||||
remoteSubscription = nil
|
||||
let newRepository = remoteRepositoryBlock(isRemoteImportingEnabled)
|
||||
let initialProfiles = try await newRepository.fetchProfiles()
|
||||
reloadRemoteProfiles(initialProfiles)
|
||||
remoteRepository = newRepository
|
||||
|
||||
remoteRepository?
|
||||
remoteSubscription = remoteRepository?
|
||||
.profilesPublisher
|
||||
.dropFirst()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] in
|
||||
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
|
||||
}
|
||||
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
|
||||
if let processor {
|
||||
func deleteExcludedProfiles() {
|
||||
guard let processor else {
|
||||
return
|
||||
}
|
||||
let idsToRemove: [Profile.ID] = allProfiles
|
||||
.filter {
|
||||
!processor.isIncluded($0.value)
|
||||
|
@ -391,49 +403,49 @@ private extension ProfileManager {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
Task.detached { [weak self] in
|
||||
guard let self else {
|
||||
func importRemoteProfiles(_ profiles: [Profile]) {
|
||||
guard !profiles.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
pp_log(.App.profiles, .info, "Start importing remote profiles...")
|
||||
assert(result.count == Set(result.map(\.id)).count, "Remote repository must not have duplicates")
|
||||
pp_log(.App.profiles, .info, "Start importing remote profiles: \(profiles.map(\.id)))")
|
||||
assert(profiles.count == Set(profiles.map(\.id)).count, "Remote repository must not have duplicates")
|
||||
|
||||
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
|
||||
pp_log(.App.profiles, .debug, "\t\($1.id) = \($1.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
|
||||
pp_log(.App.profiles, .debug, "\t\($1.id) = \($1.attributes)")
|
||||
}
|
||||
|
||||
let profilesToImport = result
|
||||
let remotelyDeletedIds = await Set(allProfiles.keys).subtracting(Set(allRemoteProfiles.keys))
|
||||
let remotelyDeletedIds = Set(allProfiles.keys).subtracting(Set(allRemoteProfiles.keys))
|
||||
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] = []
|
||||
if !remotelyDeletedIds.isEmpty {
|
||||
pp_log(.App.profiles, .info, "Will \(mirrorsRemoteRepository ? "delete" : "retain") local profiles not present in remote repository: \(remotelyDeletedIds)")
|
||||
|
||||
if mirrorsRemoteRepository {
|
||||
idsToRemove.append(contentsOf: remotelyDeletedIds)
|
||||
}
|
||||
}
|
||||
for remoteProfile in profilesToImport {
|
||||
for remoteProfile in profiles {
|
||||
do {
|
||||
guard processor?.isIncluded(remoteProfile) ?? true else {
|
||||
pp_log(.App.profiles, .info, "Will delete non-included remote profile \(remoteProfile.id)")
|
||||
|
@ -452,19 +464,26 @@ private extension ProfileManager {
|
|||
} catch {
|
||||
pp_log(.App.profiles, .error, "Unable to import remote profile: \(error)")
|
||||
}
|
||||
}
|
||||
pp_log(.App.profiles, .notice, "Finished importing remote profiles, delete stale profiles: \(idsToRemove)")
|
||||
try? await repository.removeProfiles(withIds: idsToRemove)
|
||||
guard !Task.isCancelled else {
|
||||
pp_log(.App.profiles, .info, "Cancelled import of remote profiles: \(profiles.map(\.id))")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func performSearch(_ search: String) {
|
||||
pp_log(.App.profiles, .notice, "Filter profiles with '\(search)'")
|
||||
reloadFilteredProfiles(with: search)
|
||||
pp_log(.App.profiles, .notice, "Finished importing remote profiles, delete stale profiles: \(idsToRemove)")
|
||||
do {
|
||||
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) {
|
||||
profiles = allProfiles
|
||||
filteredProfiles = allProfiles
|
||||
.values
|
||||
.filter {
|
||||
if !search.isEmpty {
|
||||
|
@ -475,5 +494,9 @@ private extension ProfileManager {
|
|||
.sorted {
|
||||
$0.name.lowercased() < $1.name.lowercased()
|
||||
}
|
||||
|
||||
pp_log(.App.profiles, .notice, "Filter profiles with '\(search)' (\(filteredProfiles.count) results)")
|
||||
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -261,7 +261,6 @@ extension IAPManager {
|
|||
}
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
|
||||
} catch {
|
||||
pp_log(.App.iap, .error, "Unable to fetch in-app products: \(error)")
|
||||
}
|
||||
|
|
|
@ -50,6 +50,8 @@ public actor CoreDataRepository<CD, T>: NSObject,
|
|||
|
||||
private let observingResults: Bool
|
||||
|
||||
private let beforeFetch: ((NSFetchRequest<CD>) -> Void)?
|
||||
|
||||
private nonisolated let fromMapper: (CD) throws -> T?
|
||||
|
||||
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>
|
||||
|
||||
// cannot easily use CD as generic
|
||||
private var resultsController: NSFetchedResultsController<CD>
|
||||
private var resultsController: NSFetchedResultsController<CD>?
|
||||
|
||||
public init(
|
||||
context: NSManagedObjectContext,
|
||||
|
@ -76,19 +77,11 @@ public actor CoreDataRepository<CD, T>: NSObject,
|
|||
self.entityName = entityName
|
||||
self.context = context
|
||||
self.observingResults = observingResults
|
||||
self.beforeFetch = beforeFetch
|
||||
self.fromMapper = fromMapper
|
||||
self.toMapper = toMapper
|
||||
self.onResultError = onResultError
|
||||
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> {
|
||||
|
@ -183,17 +176,24 @@ private extension CoreDataRepository {
|
|||
|
||||
@discardableResult
|
||||
func filter(byPredicate predicate: NSPredicate?) async throws -> [T] {
|
||||
let request = resultsController.fetchRequest
|
||||
let request = newFetchRequest()
|
||||
request.predicate = predicate
|
||||
resultsController = NSFetchedResultsController(
|
||||
beforeFetch?(request)
|
||||
|
||||
let newController = try await context.perform {
|
||||
let newController = NSFetchedResultsController(
|
||||
fetchRequest: request,
|
||||
managedObjectContext: context,
|
||||
managedObjectContext: self.context,
|
||||
sectionNameKeyPath: nil,
|
||||
cacheName: nil
|
||||
)
|
||||
resultsController.delegate = self
|
||||
try resultsController.performFetch()
|
||||
return await sendResults(from: resultsController)
|
||||
newController.delegate = self
|
||||
try newController.performFetch()
|
||||
return newController
|
||||
}
|
||||
|
||||
resultsController = newController
|
||||
return await sendResults(from: newController)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
|
|
|
@ -43,6 +43,8 @@ public final class AppContext: ObservableObject {
|
|||
|
||||
public let tunnel: ExtendedTunnel
|
||||
|
||||
private let tunnelReceiptURL: URL
|
||||
|
||||
private var launchTask: Task<Void, Error>?
|
||||
|
||||
private var pendingTask: Task<Void, Never>?
|
||||
|
@ -55,7 +57,8 @@ public final class AppContext: ObservableObject {
|
|||
profileManager: ProfileManager,
|
||||
providerManager: ProviderManager,
|
||||
registry: Registry,
|
||||
tunnel: ExtendedTunnel
|
||||
tunnel: ExtendedTunnel,
|
||||
tunnelReceiptURL: URL
|
||||
) {
|
||||
self.iapManager = iapManager
|
||||
self.migrationManager = migrationManager
|
||||
|
@ -63,6 +66,7 @@ public final class AppContext: ObservableObject {
|
|||
self.providerManager = providerManager
|
||||
self.registry = registry
|
||||
self.tunnel = tunnel
|
||||
self.tunnelReceiptURL = tunnelReceiptURL
|
||||
subscriptions = []
|
||||
}
|
||||
}
|
||||
|
@ -84,12 +88,14 @@ private extension AppContext {
|
|||
func onLaunch() async throws {
|
||||
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()
|
||||
|
||||
pp_log(.App.profiles, .info, "\tObserve in-app events...")
|
||||
iapManager.observeObjects()
|
||||
await iapManager.reloadReceipt()
|
||||
|
||||
pp_log(.App.profiles, .info, "\tObserve eligible features...")
|
||||
iapManager
|
||||
.$eligibleFeatures
|
||||
.removeDuplicates()
|
||||
|
@ -100,6 +106,7 @@ private extension AppContext {
|
|||
}
|
||||
.store(in: &subscriptions)
|
||||
|
||||
pp_log(.App.profiles, .info, "\tObserve changes in ProfileManager...")
|
||||
profileManager
|
||||
.didChange
|
||||
.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)
|
||||
if let appReceiptURL = Bundle.main.appStoreProductionReceiptURL {
|
||||
let tunnelReceiptURL = BundleConfiguration.urlForBetaReceipt
|
||||
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.copyItem(at: appReceiptURL, to: tunnelReceiptURL)
|
||||
} 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 {
|
||||
pp_log(.app, .notice, "Fetch providers index...")
|
||||
pp_log(.app, .info, "\tFetch providers index...")
|
||||
try await providerManager.fetchIndex(from: API.shared)
|
||||
} 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")
|
||||
pendingTask = Task {
|
||||
do {
|
||||
pp_log(.App.profiles, .info, "Refresh local profiles observers...")
|
||||
pp_log(.App.profiles, .info, "\tRefresh local profiles observers...")
|
||||
try await profileManager.observeLocal()
|
||||
} 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()
|
||||
|
@ -165,10 +171,10 @@ private extension AppContext {
|
|||
// toggle sync based on .sharing eligibility
|
||||
let isEligibleForSharing = features.contains(.sharing)
|
||||
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)
|
||||
} 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
|
||||
|
@ -180,29 +186,29 @@ private extension AppContext {
|
|||
|
||||
pp_log(.app, .notice, "Application did save profile (\(profile.id))")
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
pendingTask = Task {
|
||||
do {
|
||||
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()
|
||||
return
|
||||
}
|
||||
do {
|
||||
pp_log(.app, .info, "Reconnect profile \(profile.id)")
|
||||
pp_log(.app, .info, "\tReconnect profile \(profile.id)")
|
||||
try await tunnel.connect(with: profile)
|
||||
} 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()
|
||||
}
|
||||
} 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
|
||||
|
|
|
@ -83,7 +83,8 @@ extension AppContext {
|
|||
profileManager: profileManager,
|
||||
providerManager: providerManager,
|
||||
registry: registry,
|
||||
tunnel: tunnel
|
||||
tunnel: tunnel,
|
||||
tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -87,10 +87,7 @@ extension AppContext {
|
|||
cloudKitIdentifier: nil,
|
||||
author: nil
|
||||
)
|
||||
let repository = AppData.cdProviderRepositoryV3(
|
||||
context: store.context,
|
||||
backgroundContext: store.backgroundContext
|
||||
)
|
||||
let repository = AppData.cdProviderRepositoryV3(context: store.backgroundContext)
|
||||
return ProviderManager(repository: repository)
|
||||
}()
|
||||
|
||||
|
@ -119,7 +116,8 @@ extension AppContext {
|
|||
profileManager: profileManager,
|
||||
providerManager: providerManager,
|
||||
registry: .shared,
|
||||
tunnel: tunnel
|
||||
tunnel: tunnel,
|
||||
tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt
|
||||
)
|
||||
}()
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue