Rewrite AppContext event handlers (#839)
Loading remote profiles before local profiles may cause duplicated NE managers. This happened because if local profiles are empty, any remote profile is imported regardless of their former existence in the local store. The importer just doesn't know. Therefore, revisit the sequence of AppContext registrations: - First off - Skip Tunnel prepare() because NEProfileRepository.fetch() does it already - NE is both Tunnel and ProfileRepository, so calling tunnel.prepare() loads local NE profiles twice - onLaunch() - **run this once and before anything else** - Read local profiles - Reload in-app receipt - Observe in-app eligibility → Triggers onEligibleFeatures() - Observe profile save → Triggers onSaveProfile() - Fetch providers index - onForeground() - Read local profiles - Read remote profiles, and toggle CloudKit sync based on eligibility - onEligibleFeatures() - Read remote profiles, and toggle CloudKit sync based on eligibility - onSaveProfile() - Reconnect if necessary
This commit is contained in:
parent
fdbed7442c
commit
21340e9f56
|
@ -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" : "b31816d060e40583a27d22ea5c59cc686c057aaf"
|
"revision" : "3a4c78af67dfe181acc657a5539ee3d62d1c9361"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -36,10 +36,5 @@ final class AppDelegate: NSObject {
|
||||||
func configure(with uiConfiguring: UILibraryConfiguring) {
|
func configure(with uiConfiguring: UILibraryConfiguring) {
|
||||||
UILibrary(uiConfiguring)
|
UILibrary(uiConfiguring)
|
||||||
.configure(with: context)
|
.configure(with: context)
|
||||||
|
|
||||||
Task {
|
|
||||||
pp_log(.app, .notice, "Fetch providers index...")
|
|
||||||
try await context.providerManager.fetchIndex(from: API.shared)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,7 +46,7 @@ let package = Package(
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.9.0"),
|
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.9.0"),
|
||||||
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "b31816d060e40583a27d22ea5c59cc686c057aaf"),
|
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "3a4c78af67dfe181acc657a5539ee3d62d1c9361"),
|
||||||
// .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"),
|
||||||
|
|
|
@ -41,19 +41,23 @@ extension AppData {
|
||||||
) -> ProfileRepository {
|
) -> ProfileRepository {
|
||||||
let repository = CoreDataRepository<CDProfileV3, Profile>(
|
let repository = CoreDataRepository<CDProfileV3, Profile>(
|
||||||
context: context,
|
context: context,
|
||||||
observingResults: observingResults
|
observingResults: observingResults,
|
||||||
) {
|
beforeFetch: {
|
||||||
$0.sortDescriptors = [
|
$0.sortDescriptors = [
|
||||||
.init(key: "name", ascending: true, selector: #selector(NSString.caseInsensitiveCompare)),
|
.init(key: "name", ascending: true, selector: #selector(NSString.caseInsensitiveCompare)),
|
||||||
.init(key: "lastUpdate", ascending: false)
|
.init(key: "lastUpdate", ascending: false)
|
||||||
]
|
]
|
||||||
} fromMapper: {
|
},
|
||||||
|
fromMapper: {
|
||||||
try fromMapper($0, registry: registry, coder: coder)
|
try fromMapper($0, registry: registry, coder: coder)
|
||||||
} toMapper: {
|
},
|
||||||
|
toMapper: {
|
||||||
try toMapper($0, $1, registry: registry, coder: coder)
|
try toMapper($0, $1, registry: registry, coder: coder)
|
||||||
} onResultError: {
|
},
|
||||||
|
onResultError: {
|
||||||
onResultError?($0) ?? .ignore
|
onResultError?($0) ?? .ignore
|
||||||
}
|
}
|
||||||
|
)
|
||||||
return repository
|
return repository
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -112,6 +116,10 @@ extension CoreDataRepository: ProfileRepository where T == Profile {
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func fetchProfiles() async throws -> [Profile] {
|
||||||
|
try await fetchAllEntities()
|
||||||
|
}
|
||||||
|
|
||||||
public func saveProfile(_ profile: Profile) async throws {
|
public func saveProfile(_ profile: Profile) async throws {
|
||||||
try await saveEntities([profile])
|
try await saveEntities([profile])
|
||||||
}
|
}
|
||||||
|
|
|
@ -98,11 +98,6 @@ extension ExtendedTunnel {
|
||||||
tunnel.currentProfile
|
tunnel.currentProfile
|
||||||
}
|
}
|
||||||
|
|
||||||
public func prepare(purge: Bool) async throws {
|
|
||||||
pp_log(.app, .notice, "Prepare tunnel and purge stale data (\(purge))...")
|
|
||||||
try await tunnel.prepare(purge: purge)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func install(_ profile: Profile) async throws {
|
public func install(_ profile: Profile) async throws {
|
||||||
pp_log(.app, .notice, "Install profile \(profile.id)...")
|
pp_log(.app, .notice, "Install profile \(profile.id)...")
|
||||||
let newProfile = try processedProfile(profile)
|
let newProfile = try processedProfile(profile)
|
||||||
|
|
|
@ -45,6 +45,10 @@ public final class InMemoryProfileRepository: ProfileRepository {
|
||||||
profilesSubject.eraseToAnyPublisher()
|
profilesSubject.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func fetchProfiles() async throws -> [Profile] {
|
||||||
|
profiles
|
||||||
|
}
|
||||||
|
|
||||||
public func saveProfile(_ profile: Profile) {
|
public func saveProfile(_ profile: Profile) {
|
||||||
pp_log(.App.profiles, .info, "Save profile: \(profile.id))")
|
pp_log(.App.profiles, .info, "Save profile: \(profile.id))")
|
||||||
if let index = profiles.firstIndex(where: { $0.id == profile.id }) {
|
if let index = profiles.firstIndex(where: { $0.id == profile.id }) {
|
||||||
|
|
|
@ -43,14 +43,6 @@ public final class NEProfileRepository: ProfileRepository {
|
||||||
profilesSubject = CurrentValueSubject([])
|
profilesSubject = CurrentValueSubject([])
|
||||||
subscriptions = []
|
subscriptions = []
|
||||||
|
|
||||||
repository
|
|
||||||
.managersPublisher
|
|
||||||
.first()
|
|
||||||
.sink { [weak self] in
|
|
||||||
self?.onLoadedManagers($0)
|
|
||||||
}
|
|
||||||
.store(in: &subscriptions)
|
|
||||||
|
|
||||||
repository
|
repository
|
||||||
.managersPublisher
|
.managersPublisher
|
||||||
.dropFirst()
|
.dropFirst()
|
||||||
|
@ -64,6 +56,20 @@ public final class NEProfileRepository: ProfileRepository {
|
||||||
profilesSubject.eraseToAnyPublisher()
|
profilesSubject.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func fetchProfiles() async throws -> [Profile] {
|
||||||
|
let managers = try await repository.fetch()
|
||||||
|
let profiles = managers.compactMap {
|
||||||
|
do {
|
||||||
|
return try repository.profile(from: $0)
|
||||||
|
} catch {
|
||||||
|
pp_log(.App.profiles, .error, "Unable to decode profile from NE manager '\($0.localizedDescription ?? "")': \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
profilesSubject.send(profiles)
|
||||||
|
return profiles
|
||||||
|
}
|
||||||
|
|
||||||
public func saveProfile(_ profile: Profile) async throws {
|
public func saveProfile(_ profile: Profile) async throws {
|
||||||
try await repository.save(profile, forConnecting: false, title: title)
|
try await repository.save(profile, forConnecting: false, title: title)
|
||||||
if let index = profilesSubject.value.firstIndex(where: { $0.id == profile.id }) {
|
if let index = profilesSubject.value.firstIndex(where: { $0.id == profile.id }) {
|
||||||
|
@ -74,6 +80,9 @@ public final class NEProfileRepository: ProfileRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func removeProfiles(withIds profileIds: [Profile.ID]) async throws {
|
public func removeProfiles(withIds profileIds: [Profile.ID]) async throws {
|
||||||
|
guard !profileIds.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
var removedIds: Set<Profile.ID> = []
|
var removedIds: Set<Profile.ID> = []
|
||||||
defer {
|
defer {
|
||||||
profilesSubject.value.removeAll {
|
profilesSubject.value.removeAll {
|
||||||
|
@ -92,18 +101,6 @@ public final class NEProfileRepository: ProfileRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension NEProfileRepository {
|
private extension NEProfileRepository {
|
||||||
func onLoadedManagers(_ managers: [Profile.ID: NETunnelProviderManager]) {
|
|
||||||
let profiles = managers.values.compactMap {
|
|
||||||
do {
|
|
||||||
return try repository.profile(from: $0)
|
|
||||||
} catch {
|
|
||||||
pp_log(.App.profiles, .error, "Unable to decode profile from NE manager '\($0.localizedDescription ?? "")': \(error)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
profilesSubject.send(profiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
func onUpdatedManagers(_ managers: [Profile.ID: NETunnelProviderManager]) {
|
func onUpdatedManagers(_ managers: [Profile.ID: NETunnelProviderManager]) {
|
||||||
let profiles = profilesSubject
|
let profiles = profilesSubject
|
||||||
.value
|
.value
|
||||||
|
|
|
@ -286,9 +286,15 @@ private extension ProfileManager {
|
||||||
// MARK: - Observation
|
// MARK: - Observation
|
||||||
|
|
||||||
extension ProfileManager {
|
extension ProfileManager {
|
||||||
public func observeObjects(searchDebounce: Int = 200) {
|
public func observeLocal(searchDebounce: Int = 200) async throws {
|
||||||
|
subscriptions.removeAll()
|
||||||
|
|
||||||
|
let initialProfiles = try await repository.fetchProfiles()
|
||||||
|
reloadLocalProfiles(initialProfiles)
|
||||||
|
|
||||||
repository
|
repository
|
||||||
.profilesPublisher
|
.profilesPublisher
|
||||||
|
.dropFirst()
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] in
|
.sink { [weak self] in
|
||||||
self?.reloadLocalProfiles($0)
|
self?.reloadLocalProfiles($0)
|
||||||
|
@ -303,35 +309,29 @@ extension ProfileManager {
|
||||||
.store(in: &subscriptions)
|
.store(in: &subscriptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func enableRemoteImporting(_ isRemoteImportingEnabled: Bool) {
|
public func observeRemote(_ isRemoteImportingEnabled: Bool) async throws {
|
||||||
guard let remoteRepositoryBlock else {
|
guard let remoteRepositoryBlock else {
|
||||||
// preconditionFailure("Missing remoteRepositoryBlock")
|
// preconditionFailure("Missing remoteRepositoryBlock")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard remoteRepository == nil || isRemoteImportingEnabled != self.isRemoteImportingEnabled else {
|
guard remoteRepository == nil || isRemoteImportingEnabled != self.isRemoteImportingEnabled else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
self.isRemoteImportingEnabled = isRemoteImportingEnabled
|
self.isRemoteImportingEnabled = isRemoteImportingEnabled
|
||||||
|
|
||||||
remoteSubscriptions.removeAll()
|
remoteSubscriptions.removeAll()
|
||||||
remoteRepository = remoteRepositoryBlock(isRemoteImportingEnabled)
|
|
||||||
|
|
||||||
remoteRepository?
|
remoteRepository = remoteRepositoryBlock(isRemoteImportingEnabled)
|
||||||
.profilesPublisher
|
if let initialProfiles = try await remoteRepository?.fetchProfiles() {
|
||||||
.first()
|
reloadRemoteProfiles(initialProfiles, importing: false)
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] in
|
|
||||||
self?.loadInitialRemoteProfiles($0)
|
|
||||||
}
|
}
|
||||||
.store(in: &remoteSubscriptions)
|
|
||||||
|
|
||||||
remoteRepository?
|
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, importing: true)
|
||||||
}
|
}
|
||||||
.store(in: &remoteSubscriptions)
|
.store(in: &remoteSubscriptions)
|
||||||
}
|
}
|
||||||
|
@ -343,6 +343,7 @@ private extension ProfileManager {
|
||||||
allProfiles = result.reduce(into: [:]) {
|
allProfiles = result.reduce(into: [:]) {
|
||||||
$0[$1.id] = $1
|
$0[$1.id] = $1
|
||||||
}
|
}
|
||||||
|
// objectWillChange implicit from updating profiles in didSet
|
||||||
|
|
||||||
// should not be imported at all, but you never know
|
// should not be imported at all, but you never know
|
||||||
if let processor {
|
if let processor {
|
||||||
|
@ -361,21 +362,17 @@ private extension ProfileManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadInitialRemoteProfiles(_ result: [Profile]) {
|
func reloadRemoteProfiles(_ result: [Profile], importing: Bool) {
|
||||||
pp_log(.App.profiles, .info, "Load initial remote profiles: \(result.map(\.id))")
|
|
||||||
allRemoteProfiles = result.reduce(into: [:]) {
|
|
||||||
$0[$1.id] = $1
|
|
||||||
}
|
|
||||||
objectWillChange.send()
|
|
||||||
}
|
|
||||||
|
|
||||||
func reloadRemoteProfiles(_ result: [Profile]) {
|
|
||||||
pp_log(.App.profiles, .info, "Reload remote profiles: \(result.map(\.id))")
|
pp_log(.App.profiles, .info, "Reload remote profiles: \(result.map(\.id))")
|
||||||
allRemoteProfiles = result.reduce(into: [:]) {
|
allRemoteProfiles = result.reduce(into: [:]) {
|
||||||
$0[$1.id] = $1
|
$0[$1.id] = $1
|
||||||
}
|
}
|
||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
|
|
||||||
|
guard importing else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
guard let self else {
|
guard let self else {
|
||||||
return
|
return
|
||||||
|
|
|
@ -30,6 +30,8 @@ import PassepartoutKit
|
||||||
public protocol ProfileRepository {
|
public protocol ProfileRepository {
|
||||||
var profilesPublisher: AnyPublisher<[Profile], Never> { get }
|
var profilesPublisher: AnyPublisher<[Profile], Never> { get }
|
||||||
|
|
||||||
|
func fetchProfiles() async throws -> [Profile]
|
||||||
|
|
||||||
func saveProfile(_ profile: Profile) async throws
|
func saveProfile(_ profile: Profile) async throws
|
||||||
|
|
||||||
func removeProfiles(withIds profileIds: [Profile.ID]) async throws
|
func removeProfiles(withIds profileIds: [Profile.ID]) async throws
|
||||||
|
|
|
@ -27,6 +27,8 @@ import Foundation
|
||||||
import PassepartoutKit
|
import PassepartoutKit
|
||||||
|
|
||||||
public enum AppError: Error {
|
public enum AppError: Error {
|
||||||
|
case couldNotLaunch(reason: Error)
|
||||||
|
|
||||||
case emptyProducts
|
case emptyProducts
|
||||||
|
|
||||||
case emptyProfileName
|
case emptyProfileName
|
||||||
|
|
|
@ -279,6 +279,9 @@ private extension IAPManager {
|
||||||
pp_log(.App.iap, .info, "App level (custom): \(userLevel)")
|
pp_log(.App.iap, .info, "App level (custom): \(userLevel)")
|
||||||
} else {
|
} else {
|
||||||
let isBeta = await SandboxChecker().isBeta
|
let isBeta = await SandboxChecker().isBeta
|
||||||
|
guard userLevel == .undefined else {
|
||||||
|
return
|
||||||
|
}
|
||||||
userLevel = isBeta ? .beta : .freemium
|
userLevel = isBeta ? .beta : .freemium
|
||||||
pp_log(.App.iap, .info, "App level: \(userLevel)")
|
pp_log(.App.iap, .info, "App level: \(userLevel)")
|
||||||
}
|
}
|
||||||
|
|
|
@ -89,16 +89,6 @@ public actor CoreDataRepository<CD, T>: NSObject,
|
||||||
sectionNameKeyPath: nil,
|
sectionNameKeyPath: nil,
|
||||||
cacheName: nil
|
cacheName: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
super.init()
|
|
||||||
|
|
||||||
resultsController.delegate = self
|
|
||||||
do {
|
|
||||||
try resultsController.performFetch()
|
|
||||||
sendResults(from: resultsController)
|
|
||||||
} catch {
|
|
||||||
//
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public nonisolated var entitiesPublisher: AnyPublisher<EntitiesResult<T>, Never> {
|
public nonisolated var entitiesPublisher: AnyPublisher<EntitiesResult<T>, Never> {
|
||||||
|
@ -114,6 +104,10 @@ public actor CoreDataRepository<CD, T>: NSObject,
|
||||||
try await filter(byPredicate: nil)
|
try await filter(byPredicate: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func fetchAllEntities() async throws -> [T] {
|
||||||
|
try await filter(byPredicate: nil)
|
||||||
|
}
|
||||||
|
|
||||||
public func saveEntities(_ entities: [T]) async throws {
|
public func saveEntities(_ entities: [T]) async throws {
|
||||||
try await context.perform { [weak self] in
|
try await context.perform { [weak self] in
|
||||||
guard let self else {
|
guard let self else {
|
||||||
|
@ -154,7 +148,6 @@ public actor CoreDataRepository<CD, T>: NSObject,
|
||||||
do {
|
do {
|
||||||
let existing = try context.fetch(request)
|
let existing = try context.fetch(request)
|
||||||
existing.forEach(context.delete)
|
existing.forEach(context.delete)
|
||||||
|
|
||||||
try context.save()
|
try context.save()
|
||||||
} catch {
|
} catch {
|
||||||
context.rollback()
|
context.rollback()
|
||||||
|
@ -173,7 +166,9 @@ public actor CoreDataRepository<CD, T>: NSObject,
|
||||||
guard let cdController = controller as? NSFetchedResultsController<CD> else {
|
guard let cdController = controller as? NSFetchedResultsController<CD> else {
|
||||||
fatalError("Unable to upcast results to \(CD.self)")
|
fatalError("Unable to upcast results to \(CD.self)")
|
||||||
}
|
}
|
||||||
sendResults(from: cdController)
|
Task.detached { [weak self] in
|
||||||
|
await self?.sendResults(from: cdController)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -182,7 +177,12 @@ private extension CoreDataRepository {
|
||||||
case mapping(Error)
|
case mapping(Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func filter(byPredicate predicate: NSPredicate?) async throws {
|
nonisolated func newFetchRequest() -> NSFetchRequest<CD> {
|
||||||
|
NSFetchRequest(entityName: entityName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func filter(byPredicate predicate: NSPredicate?) async throws -> [T] {
|
||||||
let request = resultsController.fetchRequest
|
let request = resultsController.fetchRequest
|
||||||
request.predicate = predicate
|
request.predicate = predicate
|
||||||
resultsController = NSFetchedResultsController(
|
resultsController = NSFetchedResultsController(
|
||||||
|
@ -193,20 +193,24 @@ private extension CoreDataRepository {
|
||||||
)
|
)
|
||||||
resultsController.delegate = self
|
resultsController.delegate = self
|
||||||
try resultsController.performFetch()
|
try resultsController.performFetch()
|
||||||
sendResults(from: resultsController)
|
return await sendResults(from: resultsController)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func newFetchRequest() -> NSFetchRequest<CD> {
|
@discardableResult
|
||||||
NSFetchRequest(entityName: entityName)
|
func sendResults(from controller: NSFetchedResultsController<CD>) async -> [T] {
|
||||||
|
await context.perform {
|
||||||
|
self.unsafeSendResults(from: controller)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func sendResults(from controller: NSFetchedResultsController<CD>) {
|
@discardableResult
|
||||||
Task.detached { [weak self] in
|
func unsafeSendResults(from controller: NSFetchedResultsController<CD>) -> [T] {
|
||||||
await self?.context.perform { [weak self] in
|
|
||||||
guard let cdEntities = controller.fetchedObjects else {
|
guard let cdEntities = controller.fetchedObjects else {
|
||||||
return
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var entitiesToDelete: [CD] = []
|
||||||
|
|
||||||
// strip duplicates by sort order (first entry wins)
|
// strip duplicates by sort order (first entry wins)
|
||||||
var knownUUIDs = Set<UUID>()
|
var knownUUIDs = Set<UUID>()
|
||||||
cdEntities.forEach {
|
cdEntities.forEach {
|
||||||
|
@ -215,7 +219,7 @@ private extension CoreDataRepository {
|
||||||
}
|
}
|
||||||
guard !knownUUIDs.contains(uuid) else {
|
guard !knownUUIDs.contains(uuid) else {
|
||||||
NSLog("Strip duplicate \(String(describing: CD.self)) with UUID \(uuid)")
|
NSLog("Strip duplicate \(String(describing: CD.self)) with UUID \(uuid)")
|
||||||
self?.context.delete($0)
|
entitiesToDelete.append($0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
knownUUIDs.insert(uuid)
|
knownUUIDs.insert(uuid)
|
||||||
|
@ -224,11 +228,11 @@ private extension CoreDataRepository {
|
||||||
do {
|
do {
|
||||||
let entities = try cdEntities.compactMap {
|
let entities = try cdEntities.compactMap {
|
||||||
do {
|
do {
|
||||||
return try self?.fromMapper($0)
|
return try fromMapper($0)
|
||||||
} catch {
|
} catch {
|
||||||
switch self?.onResultError?(error) {
|
switch onResultError?(error) {
|
||||||
case .discard:
|
case .discard:
|
||||||
self?.context.delete($0)
|
entitiesToDelete.append($0)
|
||||||
|
|
||||||
case .halt:
|
case .halt:
|
||||||
throw ResultError.mapping(error)
|
throw ResultError.mapping(error)
|
||||||
|
@ -240,14 +244,22 @@ private extension CoreDataRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try self?.context.save()
|
if !entitiesToDelete.isEmpty {
|
||||||
|
do {
|
||||||
|
entitiesToDelete.forEach(context.delete)
|
||||||
|
try context.save()
|
||||||
|
} catch {
|
||||||
|
NSLog("Unable to delete Core Data entities: \(error)")
|
||||||
|
context.rollback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let result = EntitiesResult(entities, isFiltering: controller.fetchRequest.predicate != nil)
|
let result = EntitiesResult(entities, isFiltering: controller.fetchRequest.predicate != nil)
|
||||||
self?.entitiesSubject.send(result)
|
entitiesSubject.send(result)
|
||||||
|
return result.entities
|
||||||
} catch {
|
} catch {
|
||||||
NSLog("Unable to send Core Data entities: \(error)")
|
NSLog("Unable to send Core Data entities: \(error)")
|
||||||
}
|
return []
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,11 +37,7 @@ public final class StoreKitHelper<ProductType>: InAppHelper where ProductType: R
|
||||||
|
|
||||||
private var nativeProducts: [ProductType: InAppProduct]
|
private var nativeProducts: [ProductType: InAppProduct]
|
||||||
|
|
||||||
private var activeTransactions: Set<Transaction> {
|
private var activeTransactions: Set<Transaction>
|
||||||
didSet {
|
|
||||||
didUpdateSubject.send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private let didUpdateSubject: PassthroughSubject<Void, Never>
|
private let didUpdateSubject: PassthroughSubject<Void, Never>
|
||||||
|
|
||||||
|
@ -96,11 +92,13 @@ extension StoreKitHelper {
|
||||||
}
|
}
|
||||||
switch try await skProduct.purchase() {
|
switch try await skProduct.purchase() {
|
||||||
case .success(let verificationResult):
|
case .success(let verificationResult):
|
||||||
if let transaction = try? verificationResult.payloadValue {
|
guard let transaction = try? verificationResult.payloadValue else {
|
||||||
|
break
|
||||||
|
}
|
||||||
activeTransactions.insert(transaction)
|
activeTransactions.insert(transaction)
|
||||||
|
didUpdateSubject.send()
|
||||||
await transaction.finish()
|
await transaction.finish()
|
||||||
return .done
|
return .done
|
||||||
}
|
|
||||||
|
|
||||||
case .pending:
|
case .pending:
|
||||||
return .pending
|
return .pending
|
||||||
|
@ -143,5 +141,6 @@ private extension StoreKitHelper {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.activeTransactions = activeTransactions
|
self.activeTransactions = activeTransactions
|
||||||
|
didUpdateSubject.send()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,7 +41,9 @@ public final class AppContext: ObservableObject {
|
||||||
|
|
||||||
public let providerManager: ProviderManager
|
public let providerManager: ProviderManager
|
||||||
|
|
||||||
private var isActivating = false
|
private var launchTask: Task<Void, Error>?
|
||||||
|
|
||||||
|
private var pendingTask: Task<Void, Never>?
|
||||||
|
|
||||||
private var subscriptions: Set<AnyCancellable>
|
private var subscriptions: Set<AnyCancellable>
|
||||||
|
|
||||||
|
@ -58,70 +60,167 @@ public final class AppContext: ObservableObject {
|
||||||
self.tunnel = tunnel
|
self.tunnel = tunnel
|
||||||
self.providerManager = providerManager
|
self.providerManager = providerManager
|
||||||
subscriptions = []
|
subscriptions = []
|
||||||
|
|
||||||
observeObjects()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func onApplicationActive() {
|
|
||||||
guard !isActivating else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isActivating = true
|
|
||||||
pp_log(.app, .notice, "Application became active")
|
|
||||||
Task {
|
|
||||||
await withTaskGroup(of: Void.self) { group in
|
|
||||||
group.addTask {
|
|
||||||
do {
|
|
||||||
try await self.tunnel.prepare(purge: true)
|
|
||||||
} catch {
|
|
||||||
pp_log(.app, .fault, "Unable to prepare tunnel: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
group.addTask { [weak self] in
|
|
||||||
guard let self else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await iapManager.reloadReceipt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isActivating = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Observation
|
// MARK: - Observation
|
||||||
|
|
||||||
|
// invoked by AppDelegate
|
||||||
|
extension AppContext {
|
||||||
|
public func onApplicationActive() {
|
||||||
|
Task {
|
||||||
|
// TODO: ###, should handle AppError.couldNotLaunch (although extremely rare)
|
||||||
|
try await onForeground()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// invoked on internal events
|
||||||
private extension AppContext {
|
private extension AppContext {
|
||||||
func observeObjects() {
|
func onLaunch() async throws {
|
||||||
iapManager
|
pp_log(.app, .notice, "Application did launch")
|
||||||
.observeObjects()
|
|
||||||
|
pp_log(.App.profiles, .info, "Read and observe local profiles...")
|
||||||
|
try await profileManager.observeLocal()
|
||||||
|
|
||||||
|
iapManager.observeObjects()
|
||||||
|
await iapManager.reloadReceipt()
|
||||||
|
|
||||||
iapManager
|
iapManager
|
||||||
.$eligibleFeatures
|
.$eligibleFeatures
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.sink { [weak self] in
|
.sink { [weak self] eligible in
|
||||||
self?.syncEligibleFeatures($0)
|
Task {
|
||||||
|
try await self?.onEligibleFeatures(eligible)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.store(in: &subscriptions)
|
.store(in: &subscriptions)
|
||||||
|
|
||||||
profileManager
|
|
||||||
.observeObjects()
|
|
||||||
|
|
||||||
profileManager
|
profileManager
|
||||||
.didChange
|
.didChange
|
||||||
.sink { [weak self] event in
|
.sink { [weak self] event in
|
||||||
switch event {
|
switch event {
|
||||||
case .save(let profile):
|
case .save(let profile):
|
||||||
self?.syncTunnelIfCurrentProfile(profile)
|
Task {
|
||||||
|
try await self?.onSaveProfile(profile)
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &subscriptions)
|
.store(in: &subscriptions)
|
||||||
|
|
||||||
|
do {
|
||||||
|
pp_log(.app, .notice, "Fetch providers index...")
|
||||||
|
try await providerManager.fetchIndex(from: API.shared)
|
||||||
|
} catch {
|
||||||
|
pp_log(.app, .error, "Unable to fetch providers index: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onForeground() async throws {
|
||||||
|
let didLaunch = try await waitForTasks()
|
||||||
|
guard !didLaunch else {
|
||||||
|
return // foreground is redundant after launch
|
||||||
|
}
|
||||||
|
|
||||||
|
pp_log(.app, .notice, "Application did enter foreground")
|
||||||
|
pendingTask = Task {
|
||||||
|
do {
|
||||||
|
pp_log(.App.profiles, .info, "Refresh local profiles observers...")
|
||||||
|
try await profileManager.observeLocal()
|
||||||
|
} catch {
|
||||||
|
pp_log(.App.profiles, .error, "Unable to re-observe local profiles: \(error)")
|
||||||
|
}
|
||||||
|
await iapManager.reloadReceipt()
|
||||||
|
}
|
||||||
|
await pendingTask?.value
|
||||||
|
pendingTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func onEligibleFeatures(_ features: Set<AppFeature>) async throws {
|
||||||
|
try await waitForTasks()
|
||||||
|
|
||||||
|
pp_log(.app, .notice, "Application did update eligible features")
|
||||||
|
pendingTask = Task {
|
||||||
|
let isEligible = features.contains(.sharing)
|
||||||
|
do {
|
||||||
|
pp_log(.App.profiles, .info, "Refresh remote profiles observers (eligible=\(isEligible), CloudKit=\(isCloudKitEnabled))...")
|
||||||
|
try await profileManager.observeRemote(isEligible && isCloudKitEnabled)
|
||||||
|
} catch {
|
||||||
|
pp_log(.App.profiles, .error, "Unable to re-observe remote profiles: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await pendingTask?.value
|
||||||
|
pendingTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func onSaveProfile(_ profile: Profile) async throws {
|
||||||
|
try await waitForTasks()
|
||||||
|
|
||||||
|
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")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard [.active, .activating].contains(tunnel.status) else {
|
||||||
|
pp_log(.app, .debug, "Connection is not active (\(tunnel.status)), do nothing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingTask = Task {
|
||||||
|
do {
|
||||||
|
if profile.isInteractive {
|
||||||
|
pp_log(.app, .info, "Profile \(profile.id) is interactive, disconnect")
|
||||||
|
try await tunnel.disconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
pp_log(.app, .info, "Reconnect profile \(profile.id)")
|
||||||
|
try await tunnel.connect(with: profile)
|
||||||
|
} catch {
|
||||||
|
pp_log(.app, .error, "Unable 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await pendingTask?.value
|
||||||
|
pendingTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func waitForTasks() async throws -> Bool {
|
||||||
|
var didLaunch = false
|
||||||
|
|
||||||
|
// must launch once before anything else
|
||||||
|
if launchTask == nil {
|
||||||
|
launchTask = Task {
|
||||||
|
do {
|
||||||
|
try await onLaunch()
|
||||||
|
} catch {
|
||||||
|
launchTask = nil // redo launch
|
||||||
|
throw AppError.couldNotLaunch(reason: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
didLaunch = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// will throw on .couldNotLaunch
|
||||||
|
// next wait will re-attempt launch (launchTask == nil)
|
||||||
|
try await launchTask?.value
|
||||||
|
|
||||||
|
// wait for pending task if any
|
||||||
|
await pendingTask?.value
|
||||||
|
pendingTask = nil
|
||||||
|
|
||||||
|
return didLaunch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
private extension AppContext {
|
private extension AppContext {
|
||||||
var isCloudKitEnabled: Bool {
|
var isCloudKitEnabled: Bool {
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
|
@ -130,29 +229,4 @@ private extension AppContext {
|
||||||
FileManager.default.ubiquityIdentityToken != nil
|
FileManager.default.ubiquityIdentityToken != nil
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncEligibleFeatures(_ eligible: Set<AppFeature>) {
|
|
||||||
let canImport = eligible.contains(.sharing)
|
|
||||||
profileManager.enableRemoteImporting(canImport && isCloudKitEnabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
func syncTunnelIfCurrentProfile(_ profile: Profile) {
|
|
||||||
guard profile.id == tunnel.currentProfile?.id else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Task {
|
|
||||||
guard [.active, .activating].contains(tunnel.status) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if profile.isInteractive {
|
|
||||||
try await tunnel.disconnect()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
try await tunnel.connect(with: profile)
|
|
||||||
} catch {
|
|
||||||
try await tunnel.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,6 +32,9 @@ extension AppError: LocalizedError {
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
let V = Strings.Errors.App.self
|
let V = Strings.Errors.App.self
|
||||||
switch self {
|
switch self {
|
||||||
|
case .couldNotLaunch(let reason):
|
||||||
|
return reason.localizedDescription
|
||||||
|
|
||||||
case .emptyProducts:
|
case .emptyProducts:
|
||||||
return V.emptyProducts
|
return V.emptyProducts
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue