//
// AppContext.swift
// Passepartout
//
// Created by Davide De Rosa on 8/29/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 .
//
import Combine
import CommonLibrary
import CommonUtils
import Foundation
import PassepartoutKit
@MainActor
public final class AppContext: ObservableObject {
public let iapManager: IAPManager
public let registry: Registry
public let profileManager: ProfileManager
public let tunnel: ExtendedTunnel
public let providerManager: ProviderManager
private var launchTask: Task?
private var pendingTask: Task?
private var subscriptions: Set
public init(
iapManager: IAPManager,
registry: Registry,
profileManager: ProfileManager,
tunnel: ExtendedTunnel,
providerManager: ProviderManager
) {
self.iapManager = iapManager
self.registry = registry
self.profileManager = profileManager
self.tunnel = tunnel
self.providerManager = providerManager
subscriptions = []
}
}
// 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 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] eligible in
Task {
try await self?.onEligibleFeatures(eligible)
}
}
.store(in: &subscriptions)
profileManager
.didChange
.sink { [weak self] event in
switch event {
case .save(let 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)
true
#else
FileManager.default.ubiquityIdentityToken != nil
#endif
}
}