diff --git a/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b4dd124d..e14806e4 100644 --- a/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -41,7 +41,7 @@ "kind" : "remoteSourceControl", "location" : "git@github.com:passepartoutvpn/passepartoutkit-source", "state" : { - "revision" : "b31816d060e40583a27d22ea5c59cc686c057aaf" + "revision" : "3a4c78af67dfe181acc657a5539ee3d62d1c9361" } }, { diff --git a/Passepartout/App/AppDelegate.swift b/Passepartout/App/AppDelegate.swift index 2e244fe6..9e6c0e53 100644 --- a/Passepartout/App/AppDelegate.swift +++ b/Passepartout/App/AppDelegate.swift @@ -36,10 +36,5 @@ final class AppDelegate: NSObject { func configure(with uiConfiguring: UILibraryConfiguring) { UILibrary(uiConfiguring) .configure(with: context) - - Task { - pp_log(.app, .notice, "Fetch providers index...") - try await context.providerManager.fetchIndex(from: API.shared) } - } } diff --git a/Passepartout/Library/Package.swift b/Passepartout/Library/Package.swift index eed823aa..5f15f7e0 100644 --- a/Passepartout/Library/Package.swift +++ b/Passepartout/Library/Package.swift @@ -46,7 +46,7 @@ let package = Package( ], dependencies: [ // .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(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"), diff --git a/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift b/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift index 55b162da..9d1937c8 100644 --- a/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift +++ b/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift @@ -41,19 +41,23 @@ extension AppData { ) -> ProfileRepository { let repository = CoreDataRepository( context: context, - observingResults: observingResults - ) { - $0.sortDescriptors = [ - .init(key: "name", ascending: true, selector: #selector(NSString.caseInsensitiveCompare)), - .init(key: "lastUpdate", ascending: false) - ] - } fromMapper: { - try fromMapper($0, registry: registry, coder: coder) - } toMapper: { - try toMapper($0, $1, registry: registry, coder: coder) - } onResultError: { - onResultError?($0) ?? .ignore - } + observingResults: observingResults, + beforeFetch: { + $0.sortDescriptors = [ + .init(key: "name", ascending: true, selector: #selector(NSString.caseInsensitiveCompare)), + .init(key: "lastUpdate", ascending: false) + ] + }, + fromMapper: { + try fromMapper($0, registry: registry, coder: coder) + }, + toMapper: { + try toMapper($0, $1, registry: registry, coder: coder) + }, + onResultError: { + onResultError?($0) ?? .ignore + } + ) return repository } } @@ -112,6 +116,10 @@ extension CoreDataRepository: ProfileRepository where T == Profile { .eraseToAnyPublisher() } + public func fetchProfiles() async throws -> [Profile] { + try await fetchAllEntities() + } + public func saveProfile(_ profile: Profile) async throws { try await saveEntities([profile]) } diff --git a/Passepartout/Library/Sources/CommonLibrary/Business/ExtendedTunnel.swift b/Passepartout/Library/Sources/CommonLibrary/Business/ExtendedTunnel.swift index 29ab3455..7065ae9a 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Business/ExtendedTunnel.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Business/ExtendedTunnel.swift @@ -98,11 +98,6 @@ extension ExtendedTunnel { 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 { pp_log(.app, .notice, "Install profile \(profile.id)...") let newProfile = try processedProfile(profile) diff --git a/Passepartout/Library/Sources/CommonLibrary/Business/InMemoryProfileRepository.swift b/Passepartout/Library/Sources/CommonLibrary/Business/InMemoryProfileRepository.swift index e65e39f9..d98d335a 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Business/InMemoryProfileRepository.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Business/InMemoryProfileRepository.swift @@ -45,6 +45,10 @@ public final class InMemoryProfileRepository: ProfileRepository { profilesSubject.eraseToAnyPublisher() } + public func fetchProfiles() async throws -> [Profile] { + profiles + } + public func saveProfile(_ profile: Profile) { pp_log(.App.profiles, .info, "Save profile: \(profile.id))") if let index = profiles.firstIndex(where: { $0.id == profile.id }) { diff --git a/Passepartout/Library/Sources/CommonLibrary/Business/NEProfileRepository.swift b/Passepartout/Library/Sources/CommonLibrary/Business/NEProfileRepository.swift index 63b992fd..9e531de2 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Business/NEProfileRepository.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Business/NEProfileRepository.swift @@ -43,14 +43,6 @@ public final class NEProfileRepository: ProfileRepository { profilesSubject = CurrentValueSubject([]) subscriptions = [] - repository - .managersPublisher - .first() - .sink { [weak self] in - self?.onLoadedManagers($0) - } - .store(in: &subscriptions) - repository .managersPublisher .dropFirst() @@ -64,6 +56,20 @@ public final class NEProfileRepository: ProfileRepository { 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 { try await repository.save(profile, forConnecting: false, title: title) 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 { + guard !profileIds.isEmpty else { + return + } var removedIds: Set = [] defer { profilesSubject.value.removeAll { @@ -92,18 +101,6 @@ public final class NEProfileRepository: ProfileRepository { } 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]) { let profiles = profilesSubject .value diff --git a/Passepartout/Library/Sources/CommonLibrary/Business/ProfileManager.swift b/Passepartout/Library/Sources/CommonLibrary/Business/ProfileManager.swift index 26485e4a..bba4f100 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Business/ProfileManager.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Business/ProfileManager.swift @@ -286,9 +286,15 @@ private extension ProfileManager { // MARK: - Observation 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 .profilesPublisher + .dropFirst() .receive(on: DispatchQueue.main) .sink { [weak self] in self?.reloadLocalProfiles($0) @@ -303,35 +309,29 @@ extension ProfileManager { .store(in: &subscriptions) } - public func enableRemoteImporting(_ isRemoteImportingEnabled: Bool) { + public func observeRemote(_ isRemoteImportingEnabled: Bool) async throws { guard let remoteRepositoryBlock else { // preconditionFailure("Missing remoteRepositoryBlock") return } - guard remoteRepository == nil || isRemoteImportingEnabled != self.isRemoteImportingEnabled else { return } + self.isRemoteImportingEnabled = isRemoteImportingEnabled - remoteSubscriptions.removeAll() - remoteRepository = remoteRepositoryBlock(isRemoteImportingEnabled) - remoteRepository? - .profilesPublisher - .first() - .receive(on: DispatchQueue.main) - .sink { [weak self] in - self?.loadInitialRemoteProfiles($0) - } - .store(in: &remoteSubscriptions) + remoteRepository = remoteRepositoryBlock(isRemoteImportingEnabled) + if let initialProfiles = try await remoteRepository?.fetchProfiles() { + reloadRemoteProfiles(initialProfiles, importing: false) + } remoteRepository? .profilesPublisher .dropFirst() .receive(on: DispatchQueue.main) .sink { [weak self] in - self?.reloadRemoteProfiles($0) + self?.reloadRemoteProfiles($0, importing: true) } .store(in: &remoteSubscriptions) } @@ -343,6 +343,7 @@ private extension ProfileManager { allProfiles = result.reduce(into: [:]) { $0[$1.id] = $1 } + // objectWillChange implicit from updating profiles in didSet // should not be imported at all, but you never know if let processor { @@ -361,21 +362,17 @@ private extension ProfileManager { } } - func loadInitialRemoteProfiles(_ result: [Profile]) { - 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]) { + func reloadRemoteProfiles(_ result: [Profile], importing: Bool) { pp_log(.App.profiles, .info, "Reload remote profiles: \(result.map(\.id))") allRemoteProfiles = result.reduce(into: [:]) { $0[$1.id] = $1 } objectWillChange.send() + guard importing else { + return + } + Task.detached { [weak self] in guard let self else { return diff --git a/Passepartout/Library/Sources/CommonLibrary/Business/ProfileRepository.swift b/Passepartout/Library/Sources/CommonLibrary/Business/ProfileRepository.swift index c7cacbcf..cbddada0 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Business/ProfileRepository.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Business/ProfileRepository.swift @@ -30,6 +30,8 @@ import PassepartoutKit public protocol ProfileRepository { var profilesPublisher: AnyPublisher<[Profile], Never> { get } + func fetchProfiles() async throws -> [Profile] + func saveProfile(_ profile: Profile) async throws func removeProfiles(withIds profileIds: [Profile.ID]) async throws diff --git a/Passepartout/Library/Sources/CommonLibrary/Domain/AppError.swift b/Passepartout/Library/Sources/CommonLibrary/Domain/AppError.swift index c159d108..7bf3a2a2 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Domain/AppError.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Domain/AppError.swift @@ -27,6 +27,8 @@ import Foundation import PassepartoutKit public enum AppError: Error { + case couldNotLaunch(reason: Error) + case emptyProducts case emptyProfileName diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager.swift index 4b962861..b23f3051 100644 --- a/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager.swift +++ b/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager.swift @@ -279,6 +279,9 @@ private extension IAPManager { pp_log(.App.iap, .info, "App level (custom): \(userLevel)") } else { let isBeta = await SandboxChecker().isBeta + guard userLevel == .undefined else { + return + } userLevel = isBeta ? .beta : .freemium pp_log(.App.iap, .info, "App level: \(userLevel)") } diff --git a/Passepartout/Library/Sources/CommonUtils/Business/CoreDataRepository.swift b/Passepartout/Library/Sources/CommonUtils/Business/CoreDataRepository.swift index 5cbb0def..03e0d7a4 100644 --- a/Passepartout/Library/Sources/CommonUtils/Business/CoreDataRepository.swift +++ b/Passepartout/Library/Sources/CommonUtils/Business/CoreDataRepository.swift @@ -89,16 +89,6 @@ public actor CoreDataRepository: NSObject, sectionNameKeyPath: nil, cacheName: nil ) - - super.init() - - resultsController.delegate = self - do { - try resultsController.performFetch() - sendResults(from: resultsController) - } catch { - // - } } public nonisolated var entitiesPublisher: AnyPublisher, Never> { @@ -114,6 +104,10 @@ public actor CoreDataRepository: NSObject, try await filter(byPredicate: nil) } + public func fetchAllEntities() async throws -> [T] { + try await filter(byPredicate: nil) + } + public func saveEntities(_ entities: [T]) async throws { try await context.perform { [weak self] in guard let self else { @@ -154,7 +148,6 @@ public actor CoreDataRepository: NSObject, do { let existing = try context.fetch(request) existing.forEach(context.delete) - try context.save() } catch { context.rollback() @@ -173,7 +166,9 @@ public actor CoreDataRepository: NSObject, guard let cdController = controller as? NSFetchedResultsController else { 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) } - func filter(byPredicate predicate: NSPredicate?) async throws { + nonisolated func newFetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: entityName) + } + + @discardableResult + func filter(byPredicate predicate: NSPredicate?) async throws -> [T] { let request = resultsController.fetchRequest request.predicate = predicate resultsController = NSFetchedResultsController( @@ -193,61 +193,73 @@ private extension CoreDataRepository { ) resultsController.delegate = self try resultsController.performFetch() - sendResults(from: resultsController) + return await sendResults(from: resultsController) } - nonisolated func newFetchRequest() -> NSFetchRequest { - NSFetchRequest(entityName: entityName) + @discardableResult + func sendResults(from controller: NSFetchedResultsController) async -> [T] { + await context.perform { + self.unsafeSendResults(from: controller) + } } - nonisolated func sendResults(from controller: NSFetchedResultsController) { - Task.detached { [weak self] in - await self?.context.perform { [weak self] in - guard let cdEntities = controller.fetchedObjects else { - return - } + @discardableResult + func unsafeSendResults(from controller: NSFetchedResultsController) -> [T] { + guard let cdEntities = controller.fetchedObjects else { + return [] + } - // strip duplicates by sort order (first entry wins) - var knownUUIDs = Set() - cdEntities.forEach { - guard let uuid = $0.uuid else { - return - } - guard !knownUUIDs.contains(uuid) else { - NSLog("Strip duplicate \(String(describing: CD.self)) with UUID \(uuid)") - self?.context.delete($0) - return - } - knownUUIDs.insert(uuid) - } + var entitiesToDelete: [CD] = [] + // strip duplicates by sort order (first entry wins) + var knownUUIDs = Set() + cdEntities.forEach { + guard let uuid = $0.uuid else { + return + } + guard !knownUUIDs.contains(uuid) else { + NSLog("Strip duplicate \(String(describing: CD.self)) with UUID \(uuid)") + entitiesToDelete.append($0) + return + } + knownUUIDs.insert(uuid) + } + + do { + let entities = try cdEntities.compactMap { do { - let entities = try cdEntities.compactMap { - do { - return try self?.fromMapper($0) - } catch { - switch self?.onResultError?(error) { - case .discard: - self?.context.delete($0) - - case .halt: - throw ResultError.mapping(error) - - default: - break - } - return nil - } - } - - try self?.context.save() - - let result = EntitiesResult(entities, isFiltering: controller.fetchRequest.predicate != nil) - self?.entitiesSubject.send(result) + return try fromMapper($0) } catch { - NSLog("Unable to send Core Data entities: \(error)") + switch onResultError?(error) { + case .discard: + entitiesToDelete.append($0) + + case .halt: + throw ResultError.mapping(error) + + default: + break + } + return nil } } + + 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) + entitiesSubject.send(result) + return result.entities + } catch { + NSLog("Unable to send Core Data entities: \(error)") + return [] } } } diff --git a/Passepartout/Library/Sources/CommonUtils/IAP/StoreKitHelper.swift b/Passepartout/Library/Sources/CommonUtils/IAP/StoreKitHelper.swift index d5b98d77..34853967 100644 --- a/Passepartout/Library/Sources/CommonUtils/IAP/StoreKitHelper.swift +++ b/Passepartout/Library/Sources/CommonUtils/IAP/StoreKitHelper.swift @@ -37,11 +37,7 @@ public final class StoreKitHelper: InAppHelper where ProductType: R private var nativeProducts: [ProductType: InAppProduct] - private var activeTransactions: Set { - didSet { - didUpdateSubject.send() - } - } + private var activeTransactions: Set private let didUpdateSubject: PassthroughSubject @@ -96,11 +92,13 @@ extension StoreKitHelper { } switch try await skProduct.purchase() { case .success(let verificationResult): - if let transaction = try? verificationResult.payloadValue { - activeTransactions.insert(transaction) - await transaction.finish() - return .done + guard let transaction = try? verificationResult.payloadValue else { + break } + activeTransactions.insert(transaction) + didUpdateSubject.send() + await transaction.finish() + return .done case .pending: return .pending @@ -143,5 +141,6 @@ private extension StoreKitHelper { } } self.activeTransactions = activeTransactions + didUpdateSubject.send() } } diff --git a/Passepartout/Library/Sources/UILibrary/Business/AppContext.swift b/Passepartout/Library/Sources/UILibrary/Business/AppContext.swift index ef5b93d1..e9d7d084 100644 --- a/Passepartout/Library/Sources/UILibrary/Business/AppContext.swift +++ b/Passepartout/Library/Sources/UILibrary/Business/AppContext.swift @@ -41,7 +41,9 @@ public final class AppContext: ObservableObject { public let providerManager: ProviderManager - private var isActivating = false + private var launchTask: Task? + + private var pendingTask: Task? private var subscriptions: Set @@ -58,70 +60,167 @@ public final class AppContext: ObservableObject { self.tunnel = tunnel self.providerManager = providerManager 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 +// 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 { - func observeObjects() { - iapManager - .observeObjects() + func onLaunch() async throws { + pp_log(.app, .notice, "Application did launch") + + pp_log(.App.profiles, .info, "Read and observe local profiles...") + try await profileManager.observeLocal() + + iapManager.observeObjects() + await iapManager.reloadReceipt() iapManager .$eligibleFeatures .removeDuplicates() - .sink { [weak self] in - self?.syncEligibleFeatures($0) + .sink { [weak self] eligible in + Task { + try await self?.onEligibleFeatures(eligible) + } } .store(in: &subscriptions) - profileManager - .observeObjects() - profileManager .didChange .sink { [weak self] event in switch event { case .save(let profile): - self?.syncTunnelIfCurrentProfile(profile) + Task { + try await self?.onSaveProfile(profile) + } default: break } } .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) 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 { var isCloudKitEnabled: Bool { #if os(tvOS) @@ -130,29 +229,4 @@ private extension AppContext { FileManager.default.ubiquityIdentityToken != nil #endif } - - func syncEligibleFeatures(_ eligible: Set) { - 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() - } - } - } } diff --git a/Passepartout/Library/Sources/UILibrary/L10n/AppError+L10n.swift b/Passepartout/Library/Sources/UILibrary/L10n/AppError+L10n.swift index 2ef16b3a..7cd374e5 100644 --- a/Passepartout/Library/Sources/UILibrary/L10n/AppError+L10n.swift +++ b/Passepartout/Library/Sources/UILibrary/L10n/AppError+L10n.swift @@ -32,6 +32,9 @@ extension AppError: LocalizedError { public var errorDescription: String? { let V = Strings.Errors.App.self switch self { + case .couldNotLaunch(let reason): + return reason.localizedDescription + case .emptyProducts: return V.emptyProducts