Revisit in-app eligibility for iCloud sharing (#837)

Restore .sharing feature:

- Merge "Apple TV" into "iCloud" section
  - "Enabled", disabled if ineligible for .sharing
  - "Apple TV", disabled if ineligible for .appleTV || !isShared
- Footer about TV restrictions

Paywalls:

- "Share on iCloud" if ineligible for .sharing
- "Drop TV restriction" if eligible for .sharing but not for .appleTV
  - Applies to full version products (user level 2)
  - Suggest Apple TV product

Restrictions:

- Toggle CloudKit sync on remote repository based on .sharing
eligibility
- Do not start tunnel on Apple TV if ineligible for .appleTV

Fixes:

- Incorrect zip() publishers in remote repository
- Resolve duplicates in Core Data, first profile wins sorted by
lastUpdate descending
- Reload receipt on OOB IAPManager events
This commit is contained in:
Davide 2024-11-09 15:20:59 +01:00 committed by GitHub
parent d209b0d9b0
commit e07833b2a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 712 additions and 659 deletions

View File

@ -20,7 +20,7 @@
0EC066D12C7DC47600D88A94 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0EC066D02C7DC47600D88A94 /* LaunchScreen.storyboard */; platformFilter = ios; };
0EC332CA2B8A1808000B9C2F /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0EC332C92B8A1808000B9C2F /* NetworkExtension.framework */; };
0EC332D22B8A1808000B9C2F /* PassepartoutTunnel.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0EC332C82B8A1808000B9C2F /* PassepartoutTunnel.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
0EC797422B9378E000C093B7 /* Shared+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EC797402B9378E000C093B7 /* Shared+App.swift */; };
0EC797422B9378E000C093B7 /* AppContext+Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EC797402B9378E000C093B7 /* AppContext+Shared.swift */; };
0EC797432B9378E000C093B7 /* Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EC797412B9378E000C093B7 /* Shared.swift */; };
0EC797442B93790600C093B7 /* Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EC797412B9378E000C093B7 /* Shared.swift */; };
0ED61CF82CD0418C008FE259 /* App+macOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ED61CF72CD0418C008FE259 /* App+macOS.swift */; };
@ -112,7 +112,7 @@
0EC066D02C7DC47600D88A94 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
0EC332C82B8A1808000B9C2F /* PassepartoutTunnel.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PassepartoutTunnel.appex; sourceTree = BUILT_PRODUCTS_DIR; };
0EC332C92B8A1808000B9C2F /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; };
0EC797402B9378E000C093B7 /* Shared+App.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Shared+App.swift"; sourceTree = "<group>"; };
0EC797402B9378E000C093B7 /* AppContext+Shared.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AppContext+Shared.swift"; sourceTree = "<group>"; };
0EC797412B9378E000C093B7 /* Shared.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Shared.swift; sourceTree = "<group>"; };
0ED1EFDA2C33059600CBD9BD /* App.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = App.plist; sourceTree = "<group>"; };
0ED61CF72CD0418C008FE259 /* App+macOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App+macOS.swift"; sourceTree = "<group>"; };
@ -231,8 +231,8 @@
0E7E3D612B9345FD002BBDB4 /* Shared */ = {
isa = PBXGroup;
children = (
0EC797402B9378E000C093B7 /* AppContext+Shared.swift */,
0EC797412B9378E000C093B7 /* Shared.swift */,
0EC797402B9378E000C093B7 /* Shared+App.swift */,
);
path = Shared;
sourceTree = "<group>";
@ -463,7 +463,7 @@
0E7C3CCD2C9AF44600B72E69 /* AppDelegate.swift in Sources */,
0ED61CFA2CD04192008FE259 /* App+iOS.swift in Sources */,
0E7E3D6B2B9345FD002BBDB4 /* PassepartoutApp.swift in Sources */,
0EC797422B9378E000C093B7 /* Shared+App.swift in Sources */,
0EC797422B9378E000C093B7 /* AppContext+Shared.swift in Sources */,
0EE8D7E12CD112C200F6600C /* App+tvOS.swift in Sources */,
0EC797432B9378E000C093B7 /* Shared.swift in Sources */,
);

View File

@ -41,7 +41,7 @@
"kind" : "remoteSourceControl",
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
"state" : {
"revision" : "fe192115ca6f8e49447717dbe0a64347bd722aec"
"revision" : "b31816d060e40583a27d22ea5c59cc686c057aaf"
}
},
{

View File

@ -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: "fe192115ca6f8e49447717dbe0a64347bd722aec"),
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "b31816d060e40583a27d22ea5c59cc686c057aaf"),
// .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"),

View File

@ -45,7 +45,7 @@ extension AppData {
) {
$0.sortDescriptors = [
.init(key: "name", ascending: true, selector: #selector(NSString.caseInsensitiveCompare)),
.init(key: "lastUpdate", ascending: true)
.init(key: "lastUpdate", ascending: false)
]
} fromMapper: {
try fromMapper($0, registry: registry, coder: coder)
@ -87,7 +87,6 @@ private extension AppData {
// redundant but convenient
let attributes = profile.attributes
cdProfile.isAvailableForTV = attributes.isAvailableForTV.map(NSNumber.init(value:))
cdProfile.expirationDate = attributes.expirationDate
cdProfile.lastUpdate = attributes.lastUpdate
cdProfile.fingerprint = attributes.fingerprint

View File

@ -36,7 +36,6 @@ final class CDProfileV3: NSManagedObject {
@NSManaged var name: String?
@NSManaged var encoded: String?
@NSManaged var isAvailableForTV: NSNumber?
@NSManaged var expirationDate: Date?
@NSManaged var lastUpdate: Date?
@NSManaged var fingerprint: UUID?
}

View File

@ -2,7 +2,6 @@
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23H124" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
<entity name="CDProfileV3" representedClassName="CDProfileV3" elementID="CDProfile" versionHashModifier="1" syncable="YES">
<attribute name="encoded" optional="YES" attributeType="String" allowsCloudEncryption="YES"/>
<attribute name="expirationDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="fingerprint" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="isAvailableForTV" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>

View File

@ -140,12 +140,12 @@ private extension ProfileRowView {
}
var sharedImage: some View {
ThemeImage(.cloud)
.help(Strings.Modules.General.Rows.icloudSharing)
ThemeImage(profileManager.isRemoteImportingEnabled ? .cloudOn : .cloudOff)
.help(Strings.Modules.General.Rows.shared)
}
var tvImage: some View {
ThemeImage(.tv)
ThemeImage(profileManager.isRemoteImportingEnabled ? .tvOn : .tvOff)
.help(Strings.Modules.General.Rows.appleTv(Strings.Unlocalized.appleTV))
}
}

View File

@ -1,100 +0,0 @@
//
// AppleTVSection.swift
// Passepartout
//
// Created by Davide De Rosa on 11/4/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import SwiftUI
struct AppleTVSection: View {
@EnvironmentObject
private var iapManager: IAPManager
@ObservedObject
var profileEditor: ProfileEditor
@Binding
var paywallReason: PaywallReason?
var body: some View {
debugChanges()
return Group {
availableToggle
.themeRow(footer: footer)
purchaseButton
}
.themeSection(footer: footer)
.disabled(!profileEditor.isShared)
}
}
private extension AppleTVSection {
var availableToggle: some View {
Toggle(Strings.Modules.General.Rows.appleTv(Strings.Unlocalized.appleTV), isOn: $profileEditor.isAvailableForTV)
}
var purchaseButton: some View {
EmptyView()
.modifier(PurchaseButtonModifier(
Strings.Modules.General.Rows.AppleTv.purchase,
feature: .appleTV,
suggesting: .Features.appleTV,
showsIfRestricted: true,
paywallReason: $paywallReason
))
}
var footer: String {
var desc = [Strings.Modules.General.Sections.AppleTv.footer]
let expirationDesc = {
Strings.Modules.General.Sections.AppleTv.Footer.Purchase._1( Constants.shared.tunnel.tvExpirationMinutes)
}
let purchaseDesc = {
Strings.Modules.General.Sections.AppleTv.Footer.Purchase._2
}
switch iapManager.paywallReason(forFeature: .appleTV, suggesting: nil) {
case .purchase:
desc.append(expirationDesc())
desc.append(purchaseDesc())
case .restricted:
desc.append(expirationDesc())
default:
break
}
return desc.joined(separator: " ")
}
}
#Preview {
Form {
AppleTVSection(
profileEditor: ProfileEditor(),
paywallReason: .constant(nil)
)
}
.themeForm()
.withMockEnvironment()
}

View File

@ -39,17 +39,80 @@ struct StorageSection: View {
var body: some View {
debugChanges()
return sharingToggle
.themeSectionWithSingleRow(
header: Strings.Global.storage,
footer: Strings.Modules.General.Sections.Storage.footer
)
return Group {
sharingToggle
tvToggle
.themeRow(footer: footer)
purchaseButton
}
.themeSection(
header: header,
footer: footer
)
}
}
private extension StorageSection {
var sharingToggle: some View {
Toggle(Strings.Modules.General.Rows.icloudSharing, isOn: $profileEditor.isShared)
Toggle(Strings.Modules.General.Rows.shared, isOn: $profileEditor.isShared)
.disabled(!iapManager.isEligible(for: .sharing))
}
var tvToggle: some View {
Toggle(Strings.Modules.General.Rows.appleTv(Strings.Unlocalized.appleTV), isOn: $profileEditor.isAvailableForTV)
.disabled(!iapManager.isEligible(for: .appleTV) || !profileEditor.isShared)
}
@ViewBuilder
var purchaseButton: some View {
if !iapManager.isEligible(for: .sharing) {
purchaseSharingButton
} else if !iapManager.isEligible(for: .appleTV) {
purchaseTVButton
}
}
var purchaseSharingButton: some View {
EmptyView()
.modifier(PurchaseButtonModifier(
Strings.Modules.General.Rows.Shared.purchase,
feature: .sharing,
suggesting: nil,
showsIfRestricted: false,
paywallReason: $paywallReason
))
}
var purchaseTVButton: some View {
EmptyView()
.modifier(PurchaseButtonModifier(
Strings.Modules.General.Rows.AppleTv.purchase,
feature: .appleTV,
suggesting: .Features.appleTV,
showsIfRestricted: false,
paywallReason: $paywallReason
))
}
var header: String {
Strings.Modules.General.Sections.Storage.header(Strings.Unlocalized.iCloud)
}
var footer: String {
var desc = [
Strings.Modules.General.Sections.Storage.footer(Strings.Unlocalized.iCloud)
]
switch iapManager.paywallReason(forFeature: .appleTV, suggesting: nil) {
case .purchase:
desc.append(Strings.Modules.General.Sections.Storage.Footer.Purchase.tvRelease)
case .restricted:
desc.append(Strings.Modules.General.Sections.Storage.Footer.Purchase.tvBeta)
default:
break
}
return desc.joined(separator: " ")
}
}

View File

@ -60,10 +60,6 @@ struct ProfileEditView: View, Routable {
profileEditor: profileEditor,
paywallReason: $paywallReason
)
AppleTVSection(
profileEditor: profileEditor,
paywallReason: $paywallReason
)
UUIDSection(uuid: profileEditor.profile.id)
}
.modifier(PaywallModifier(reason: $paywallReason))
@ -126,7 +122,7 @@ private extension ProfileEditView {
var addModuleButton: some View {
let moduleTypes = profileEditor.availableModuleTypes.sorted {
$0.localizedDescription < $1.localizedDescription
$0.localizedDescription.lowercased() < $1.localizedDescription.lowercased()
}
return Menu {
ForEach(moduleTypes) { selectedType in

View File

@ -46,10 +46,6 @@ struct ProfileGeneralView: View {
profileEditor: profileEditor,
paywallReason: $paywallReason
)
AppleTVSection(
profileEditor: profileEditor,
paywallReason: $paywallReason
)
UUIDSection(uuid: profileEditor.profile.id)
}
.modifier(PaywallModifier(reason: $paywallReason))

View File

@ -35,13 +35,15 @@ public final class ProfileManager: ObservableObject {
case remove([Profile.ID])
}
private let repository: any ProfileRepository
private let repository: ProfileRepository
private let backupRepository: (any ProfileRepository)?
private let backupRepository: ProfileRepository?
private let remoteRepository: (any ProfileRepository)?
private let remoteRepositoryBlock: ((Bool) -> ProfileRepository)?
private let deletingRemotely: Bool
private var remoteRepository: ProfileRepository?
private let mirrorsRemoteRepository: Bool
private let processor: ProfileProcessor?
@ -54,6 +56,9 @@ public final class ProfileManager: ObservableObject {
}
}
@Published
public private(set) var isRemoteImportingEnabled: Bool
private var allRemoteProfiles: [Profile.ID: Profile]
public let didChange: PassthroughSubject<Event, Never>
@ -62,12 +67,14 @@ public final class ProfileManager: ObservableObject {
private var subscriptions: Set<AnyCancellable>
private var remoteSubscriptions: Set<AnyCancellable>
// for testing/previews
public init(profiles: [Profile]) {
repository = InMemoryProfileRepository(profiles: profiles)
backupRepository = nil
remoteRepository = nil
deletingRemotely = false
remoteRepositoryBlock = nil
mirrorsRemoteRepository = false
processor = nil
self.profiles = []
allProfiles = profiles.reduce(into: [:]) {
@ -77,21 +84,23 @@ public final class ProfileManager: ObservableObject {
didChange = PassthroughSubject()
searchSubject = CurrentValueSubject("")
isRemoteImportingEnabled = false
subscriptions = []
remoteSubscriptions = []
}
public init(
repository: any ProfileRepository,
backupRepository: (any ProfileRepository)? = nil,
remoteRepository: (any ProfileRepository)?,
deletingRemotely: Bool = false,
repository: ProfileRepository,
backupRepository: ProfileRepository? = nil,
remoteRepositoryBlock: ((Bool) -> ProfileRepository)?,
mirrorsRemoteRepository: Bool = false,
processor: ProfileProcessor? = nil
) {
precondition(!deletingRemotely || remoteRepository != nil, "deletingRemotely requires a non-nil remoteRepository")
precondition(!mirrorsRemoteRepository || remoteRepositoryBlock != nil, "mirrorsRemoteRepository requires a non-nil remoteRepositoryBlock")
self.repository = repository
self.backupRepository = backupRepository
self.remoteRepository = remoteRepository
self.deletingRemotely = deletingRemotely
self.remoteRepositoryBlock = remoteRepositoryBlock
self.mirrorsRemoteRepository = mirrorsRemoteRepository
self.processor = processor
profiles = []
allProfiles = [:]
@ -99,7 +108,9 @@ public final class ProfileManager: ObservableObject {
didChange = PassthroughSubject()
searchSubject = CurrentValueSubject("")
isRemoteImportingEnabled = false
subscriptions = []
remoteSubscriptions = []
}
}
@ -130,7 +141,7 @@ extension ProfileManager {
}
}
public func save(_ originalProfile: Profile, force: Bool = false, isShared: Bool? = nil) async throws {
public func save(_ originalProfile: Profile, force: Bool = false, remotelyShared: Bool? = nil) async throws {
let profile: Profile
if force {
var builder = originalProfile.builder()
@ -164,8 +175,8 @@ extension ProfileManager {
throw error
}
do {
if let isShared, let remoteRepository {
if isShared {
if let remotelyShared, let remoteRepository {
if remotelyShared {
pp_log(.App.profiles, .notice, "\tEnable remote sharing of profile \(profile.id)...")
try await remoteRepository.saveProfile(profile)
} else {
@ -284,27 +295,6 @@ extension ProfileManager {
}
.store(in: &subscriptions)
// observe remote after first local profiles
let remotePublisher = remoteRepository?
.profilesPublisher
.zip(repository.profilesPublisher)
remotePublisher?
.first()
.receive(on: DispatchQueue.main)
.sink { [weak self] remote, _ in
self?.loadInitialRemoteProfiles(remote)
}
.store(in: &subscriptions)
remotePublisher?
.dropFirst()
.receive(on: DispatchQueue.main)
.sink { [weak self] remote, _ in
self?.reloadRemoteProfiles(remote)
}
.store(in: &subscriptions)
searchSubject
.debounce(for: .milliseconds(searchDebounce), scheduler: DispatchQueue.main)
.sink { [weak self] in
@ -312,6 +302,39 @@ extension ProfileManager {
}
.store(in: &subscriptions)
}
public func enableRemoteImporting(_ isRemoteImportingEnabled: Bool) {
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?
.profilesPublisher
.dropFirst()
.receive(on: DispatchQueue.main)
.sink { [weak self] in
self?.reloadRemoteProfiles($0)
}
.store(in: &remoteSubscriptions)
}
}
private extension ProfileManager {
@ -322,10 +345,10 @@ private extension ProfileManager {
}
// should not be imported at all, but you never know
if let isIncluded = processor?.isIncluded {
if let processor {
let idsToRemove: [Profile.ID] = allProfiles
.filter {
!isIncluded($0.value)
!processor.isIncluded($0.value)
}
.map(\.key)
@ -359,6 +382,7 @@ private extension ProfileManager {
}
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, .debug, "Local attributes:")
let localAttributes: [Profile.ID: ProfileAttributes] = await allProfiles.values.reduce(into: [:]) {
@ -373,13 +397,13 @@ private extension ProfileManager {
let profilesToImport = result
let remotelyDeletedIds = await Set(allProfiles.keys).subtracting(Set(allRemoteProfiles.keys))
let deletingRemotely = deletingRemotely
let mirrorsRemoteRepository = mirrorsRemoteRepository
var idsToRemove: [Profile.ID] = []
if !remotelyDeletedIds.isEmpty {
pp_log(.App.profiles, .info, "Will \(deletingRemotely ? "delete" : "retain") local profiles not present in remote repository: \(remotelyDeletedIds)")
pp_log(.App.profiles, .info, "Will \(mirrorsRemoteRepository ? "delete" : "retain") local profiles not present in remote repository: \(remotelyDeletedIds)")
if deletingRemotely {
if mirrorsRemoteRepository {
idsToRemove.append(contentsOf: remotelyDeletedIds)
}
}

View File

@ -48,6 +48,6 @@ public enum AppError: Error {
extension PassepartoutError.Code {
public enum App {
public static let expiredProfile = PassepartoutError.Code("App.expiredProfile")
public static let ineligibleProfile = PassepartoutError.Code("App.ineligibleProfile")
}
}

View File

@ -97,13 +97,6 @@ public struct Constants: Decodable, Sendable {
public let profileTitleFormat: String
public let refreshInterval: TimeInterval
public let tvExpirationMinutes: Int
public func newTVExpirationDate() -> Date {
Date()
.addingTimeInterval(Double(tvExpirationMinutes) * 60.0)
}
}
public struct API: Decodable, Sendable {

View File

@ -34,21 +34,17 @@ public struct ProfileAttributes: Hashable, Codable {
public var isAvailableForTV: Bool?
public var expirationDate: Date?
public init() {
}
public init(
fingerprint: UUID?,
lastUpdate: Date?,
isAvailableForTV: Bool?,
expirationDate: Date?
isAvailableForTV: Bool?
) {
self.fingerprint = fingerprint
self.lastUpdate = lastUpdate
self.isAvailableForTV = isAvailableForTV
self.expirationDate = expirationDate
}
}
@ -63,9 +59,6 @@ extension ProfileAttributes: CustomDebugStringConvertible {
},
isAvailableForTV.map {
"isAvailableForTV: \($0)"
},
expirationDate.map {
"expirationDate: \($0)"
}
].compactMap { $0 }
@ -73,15 +66,6 @@ extension ProfileAttributes: CustomDebugStringConvertible {
}
}
extension ProfileAttributes {
public var isExpired: Bool {
if let expirationDate {
return Date().distance(to: expirationDate) <= .zero
}
return false
}
}
// MARK: - ProfileUserInfoTransformable
// FIXME: #570, test user info encoding/decoding with JSONSerialization

View File

@ -40,6 +40,8 @@ public enum AppFeature: String, CaseIterable {
case routing
case sharing
public static let allButAppleTV: [AppFeature] = allCases.filter {
$0 != .appleTV
}

View File

@ -47,15 +47,20 @@ extension AppUserLevel: AppFeatureProviding {
extension AppProduct: AppFeatureProviding {
var features: [AppFeature] {
switch self {
// MARK: Current
case .Features.appleTV:
return [.appleTV, .sharing]
case .Full.Recurring.monthly, .Full.Recurring.yearly:
return AppFeature.allCases
// MARK: Discontinued
case .Features.allProviders:
return [.providers]
case .Features.appleTV:
return [.appleTV]
case .Features.networkSettings:
return [.dns, .httpProxy, .routing]

View File

@ -27,42 +27,19 @@ import Foundation
extension AppProduct {
public enum Features {
public static let allProviders = AppProduct(featureId: "all_providers")
public static let appleTV = AppProduct(featureId: "appletv")
public static let interactiveLogin = AppProduct(featureId: "interactive_login")
public static let networkSettings = AppProduct(featureId: "network_settings")
public static let trustedNetworks = AppProduct(featureId: "trusted_networks")
static let all: [AppProduct] = [
.Features.allProviders,
.Features.appleTV,
.Features.interactiveLogin,
.Features.networkSettings,
.Features.trustedNetworks
]
}
public enum Full {
public static let iOS = AppProduct(featureId: "full_version")
public static let macOS = AppProduct(featureId: "full_mac_version")
public static let allPlatforms = AppProduct(featureId: "full_multi_version")
public enum Recurring {
public static let monthly = AppProduct(featureId: "full.monthly")
public static let yearly = AppProduct(featureId: "full.yearly")
}
static let all: [AppProduct] = [
.Full.allPlatforms,
.Full.iOS,
.Full.macOS,
.Full.allPlatforms,
.Full.Recurring.monthly,
.Full.Recurring.yearly
]
@ -78,3 +55,35 @@ extension AppProduct {
rawValue.hasPrefix(Self.featurePrefix)
}
}
// MARK: - Current
extension AppProduct.Features {
public static let appleTV = AppProduct(featureId: "appletv")
}
extension AppProduct.Full {
public enum Recurring {
public static let monthly = AppProduct(featureId: "full.monthly")
public static let yearly = AppProduct(featureId: "full.yearly")
}
}
// MARK: - Discontinued
extension AppProduct.Features {
static let allProviders = AppProduct(featureId: "all_providers")
public static let networkSettings = AppProduct(featureId: "network_settings")
static let trustedNetworks = AppProduct(featureId: "trusted_networks")
}
extension AppProduct.Full {
static let allPlatforms = AppProduct(featureId: "full_multi_version")
public static let iOS = AppProduct(featureId: "full_version")
static let macOS = AppProduct(featureId: "full_mac_version")
}

View File

@ -46,7 +46,8 @@ public final class IAPManager: ObservableObject {
public private(set) var purchasedProducts: Set<AppProduct>
private var eligibleFeatures: Set<AppFeature>
@Published
public private(set) var eligibleFeatures: Set<AppFeature>
private var pendingReceiptTask: Task<Void, Never>?
@ -68,8 +69,6 @@ public final class IAPManager: ObservableObject {
purchasedProducts = []
eligibleFeatures = []
subscriptions = []
observeObjects()
}
}
@ -106,6 +105,7 @@ extension IAPManager {
await pendingReceiptTask.value
}
pendingReceiptTask = Task {
await fetchLevelIfNeeded()
await asyncReloadReceipt()
}
await pendingReceiptTask?.value
@ -161,9 +161,9 @@ private extension IAPManager {
func asyncReloadReceipt() async {
pp_log(.App.iap, .notice, "Start reloading in-app receipt...")
purchasedAppBuild = nil
purchasedProducts.removeAll()
eligibleFeatures.removeAll()
var purchasedAppBuild: Int?
var purchasedProducts: Set<AppProduct> = []
var eligibleFeatures: Set<AppFeature> = []
if let receipt = await receiptReader.receipt(at: userLevel) {
if let originalBuildNumber = receipt.originalBuildNumber {
@ -236,17 +236,18 @@ private extension IAPManager {
pp_log(.App.iap, .notice, "\tPurchased products: \(purchasedProducts.map(\.rawValue))")
pp_log(.App.iap, .notice, "\tEligible features: \(eligibleFeatures)")
objectWillChange.send()
self.purchasedAppBuild = purchasedAppBuild
self.purchasedProducts = purchasedProducts
self.eligibleFeatures = eligibleFeatures // @Published -> objectWillChange.send()
}
}
// MARK: - Observation
private extension IAPManager {
func observeObjects() {
extension IAPManager {
public func observeObjects() {
Task {
await fetchLevelIfNeeded()
await reloadReceipt()
do {
let products = try await inAppHelper.fetchProducts()
pp_log(.App.iap, .info, "Available in-app products: \(products.map(\.key))")
@ -266,7 +267,9 @@ private extension IAPManager {
}
}
}
}
private extension IAPManager {
func fetchLevelIfNeeded() async {
guard userLevel == .undefined else {
return

View File

@ -23,8 +23,7 @@
},
"tunnel": {
"profileTitleFormat": "Passepartout: %@",
"refreshInterval": 3.0,
"tvExpirationMinutes": 10
"refreshInterval": 3.0
},
"api": {
"timeoutInterval": 5.0

View File

@ -201,11 +201,26 @@ private extension CoreDataRepository {
}
nonisolated func sendResults(from controller: NSFetchedResultsController<CD>) {
guard let cdEntities = controller.fetchedObjects else {
return
}
Task.detached { [weak self] in
await self?.context.perform { [weak self] in
guard let cdEntities = controller.fetchedObjects else {
return
}
// strip duplicates by sort order (first entry wins)
var knownUUIDs = Set<UUID>()
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)
}
do {
let entities = try cdEntities.compactMap {
do {
@ -230,7 +245,7 @@ private extension CoreDataRepository {
let result = EntitiesResult(entities, isFiltering: controller.fetchRequest.predicate != nil)
self?.entitiesSubject.send(result)
} catch {
// ResultError
NSLog("Unable to send Core Data entities: \(error)")
}
}
}

View File

@ -33,12 +33,12 @@ import PassepartoutKit
public final class AppContext: ObservableObject {
public let iapManager: IAPManager
public let registry: Registry
public let profileManager: ProfileManager
public let tunnel: ExtendedTunnel
public let registry: Registry
public let providerManager: ProviderManager
private var isActivating = false
@ -47,15 +47,15 @@ public final class AppContext: ObservableObject {
public init(
iapManager: IAPManager,
registry: Registry,
profileManager: ProfileManager,
tunnel: ExtendedTunnel,
registry: Registry,
providerManager: ProviderManager
) {
self.iapManager = iapManager
self.registry = registry
self.profileManager = profileManager
self.tunnel = tunnel
self.registry = registry
self.providerManager = providerManager
subscriptions = []
@ -77,8 +77,11 @@ public final class AppContext: ObservableObject {
pp_log(.app, .fault, "Unable to prepare tunnel: \(error)")
}
}
group.addTask {
await self.iapManager.reloadReceipt()
group.addTask { [weak self] in
guard let self else {
return
}
await iapManager.reloadReceipt()
}
}
isActivating = false
@ -90,7 +93,19 @@ public final class AppContext: ObservableObject {
private extension AppContext {
func observeObjects() {
profileManager.observeObjects()
iapManager
.observeObjects()
iapManager
.$eligibleFeatures
.removeDuplicates()
.sink { [weak self] in
self?.syncEligibleFeatures($0)
}
.store(in: &subscriptions)
profileManager
.observeObjects()
profileManager
.didChange
@ -108,6 +123,19 @@ private extension AppContext {
}
private extension AppContext {
var isCloudKitEnabled: Bool {
#if os(tvOS)
true
#else
FileManager.default.ubiquityIdentityToken != nil
#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

View File

@ -85,7 +85,7 @@ extension ProfileEditor {
!moduleTypes.contains($0)
}
.sorted {
$0.localizedDescription < $1.localizedDescription
$0.localizedDescription.lowercased() < $1.localizedDescription.lowercased()
}
}
}
@ -198,7 +198,7 @@ extension ProfileEditor {
public func save(to profileManager: ProfileManager) async throws {
do {
let newProfile = try build()
try await profileManager.save(newProfile, force: true, isShared: isShared)
try await profileManager.save(newProfile, force: true, remotelyShared: isShared)
} catch {
pp_log(.app, .fault, "Unable to save edited profile: \(error)")
throw error

View File

@ -55,8 +55,8 @@ extension AppError: LocalizedError {
extension PassepartoutError: LocalizedError {
public var errorDescription: String? {
switch code {
case .App.expiredProfile:
return Strings.Errors.App.expiredProfile
case .App.ineligibleProfile:
return Strings.Errors.App.ineligibleProfile
case .connectionModuleRequired:
return Strings.Errors.App.Passepartout.connectionModuleRequired
@ -114,8 +114,8 @@ extension PassepartoutError.Code: StyledLocalizableEntity {
case .tunnel:
let V = Strings.Errors.Tunnel.self
switch self {
case .App.expiredProfile:
return V.expired
case .App.ineligibleProfile:
return V.ineligible
case .authentication:
return V.auth

View File

@ -51,6 +51,9 @@ extension AppFeature: LocalizableEntity {
case .routing:
return V.routing(Strings.Global.routing)
case .sharing:
return V.sharing(Strings.Unlocalized.iCloud)
}
}
}

View File

@ -118,8 +118,8 @@ public enum Strings {
public static let emptyProducts = Strings.tr("Localizable", "errors.app.empty_products", fallback: "Unable to fetch products, please retry later.")
/// Profile name is empty.
public static let emptyProfileName = Strings.tr("Localizable", "errors.app.empty_profile_name", fallback: "Profile name is empty.")
/// Profile is expired.
public static let expiredProfile = Strings.tr("Localizable", "errors.app.expired_profile", fallback: "Profile is expired.")
/// A purchase is required for this profile to work.
public static let ineligibleProfile = Strings.tr("Localizable", "errors.app.ineligible_profile", fallback: "A purchase is required for this profile to work.")
/// Module %@ is malformed. %@
public static func malformedModule(_ p1: Any, _ p2: Any) -> String {
return Strings.tr("Localizable", "errors.app.malformed_module", String(describing: p1), String(describing: p2), fallback: "Module %@ is malformed. %@")
@ -162,10 +162,10 @@ public enum Strings {
public static let dns = Strings.tr("Localizable", "errors.tunnel.dns", fallback: "DNS failed")
/// Encryption failed
public static let encryption = Strings.tr("Localizable", "errors.tunnel.encryption", fallback: "Encryption failed")
/// Expired
public static let expired = Strings.tr("Localizable", "errors.tunnel.expired", fallback: "Expired")
/// Failed
public static let generic = Strings.tr("Localizable", "errors.tunnel.generic", fallback: "Failed")
/// Purchase required
public static let ineligible = Strings.tr("Localizable", "errors.tunnel.ineligible", fallback: "Purchase required")
/// Missing routing
public static let routing = Strings.tr("Localizable", "errors.tunnel.routing", fallback: "Missing routing")
/// Server shutdown
@ -201,6 +201,10 @@ public enum Strings {
public static func routing(_ p1: Any) -> String {
return Strings.tr("Localizable", "features.routing", String(describing: p1), fallback: "%@")
}
/// %@
public static func sharing(_ p1: Any) -> String {
return Strings.tr("Localizable", "features.sharing", String(describing: p1), fallback: "%@")
}
}
public enum Global {
/// About
@ -341,8 +345,6 @@ public enum Strings {
public static let show = Strings.tr("Localizable", "global.show", fallback: "Show")
/// Status
public static let status = Strings.tr("Localizable", "global.status", fallback: "Status")
/// Storage
public static let storage = Strings.tr("Localizable", "global.storage", fallback: "Storage")
/// Subnet
public static let subnet = Strings.tr("Localizable", "global.subnet", fallback: "Subnet")
/// Unknown
@ -369,34 +371,38 @@ public enum Strings {
public static func appleTv(_ p1: Any) -> String {
return Strings.tr("Localizable", "modules.general.rows.apple_tv", String(describing: p1), fallback: "%@")
}
/// Shared on iCloud
public static let icloudSharing = Strings.tr("Localizable", "modules.general.rows.icloud_sharing", fallback: "Shared on iCloud")
/// Import from file...
public static let importFromFile = Strings.tr("Localizable", "modules.general.rows.import_from_file", fallback: "Import from file...")
/// Enabled
public static let shared = Strings.tr("Localizable", "modules.general.rows.shared", fallback: "Enabled")
public enum AppleTv {
/// Drop time restriction
public static let purchase = Strings.tr("Localizable", "modules.general.rows.apple_tv.purchase", fallback: "Drop time restriction")
/// Drop TV restriction
public static let purchase = Strings.tr("Localizable", "modules.general.rows.apple_tv.purchase", fallback: "Drop TV restriction")
}
public enum Shared {
/// Share on iCloud
public static let purchase = Strings.tr("Localizable", "modules.general.rows.shared.purchase", fallback: "Share on iCloud")
}
}
public enum Sections {
public enum AppleTv {
/// Requires iCloud sharing.
public static let footer = Strings.tr("Localizable", "modules.general.sections.apple_tv.footer", fallback: "Requires iCloud sharing.")
public enum Storage {
/// Profiles are stored to %@ encrypted.
public static func footer(_ p1: Any) -> String {
return Strings.tr("Localizable", "modules.general.sections.storage.footer", String(describing: p1), fallback: "Profiles are stored to %@ encrypted.")
}
/// %@
public static func header(_ p1: Any) -> String {
return Strings.tr("Localizable", "modules.general.sections.storage.header", String(describing: p1), fallback: "%@")
}
public enum Footer {
public enum Purchase {
/// TV profiles expire after %d minutes.
public static func _1(_ p1: Int) -> String {
return Strings.tr("Localizable", "modules.general.sections.apple_tv.footer.purchase.1", p1, fallback: "TV profiles expire after %d minutes.")
}
/// Purchase to drop the restriction.
public static let _2 = Strings.tr("Localizable", "modules.general.sections.apple_tv.footer.purchase.2", fallback: "Purchase to drop the restriction.")
/// TV profiles do not work in beta builds.
public static let tvBeta = Strings.tr("Localizable", "modules.general.sections.storage.footer.purchase.tv_beta", fallback: "TV profiles do not work in beta builds.")
/// TV profiles do not work without a purchase.
public static let tvRelease = Strings.tr("Localizable", "modules.general.sections.storage.footer.purchase.tv_release", fallback: "TV profiles do not work without a purchase.")
}
}
}
public enum Storage {
/// Profiles are stored to iCloud encrypted.
public static let footer = Strings.tr("Localizable", "modules.general.sections.storage.footer", fallback: "Profiles are stored to iCloud encrypted.")
}
}
}
public enum HttpProxy {

View File

@ -78,9 +78,9 @@ extension AppContext {
)
return AppContext(
iapManager: iapManager,
registry: registry,
profileManager: profileManager,
tunnel: tunnel,
registry: registry,
providerManager: providerManager
)
}

View File

@ -68,7 +68,6 @@
"global.settings" = "Settings";
"global.show" = "Show";
"global.status" = "Status";
"global.storage" = "Storage";
"global.subnet" = "Subnet";
"global.unknown" = "Unknown";
"global.username" = "Username";
@ -173,9 +172,9 @@
// MARK: - Module views
"modules.general.sections.storage.footer" = "Profiles are stored to iCloud encrypted.";
"modules.general.sections.apple_tv.footer" = "Requires iCloud sharing.";
"modules.general.rows.icloud_sharing" = "Shared on iCloud";
"modules.general.sections.storage.header" = "%@";
"modules.general.sections.storage.footer" = "Profiles are stored to %@ encrypted.";
"modules.general.rows.shared" = "Enabled";
"modules.general.rows.apple_tv" = "%@";
"modules.general.rows.import_from_file" = "Import from file...";
@ -271,10 +270,12 @@
"features.onDemand" = "%@";
"features.providers" = "All Providers";
"features.routing" = "%@";
"features.sharing" = "%@";
"modules.general.sections.apple_tv.footer.purchase.1" = "TV profiles expire after %d minutes.";
"modules.general.sections.apple_tv.footer.purchase.2" = "Purchase to drop the restriction.";
"modules.general.rows.apple_tv.purchase" = "Drop time restriction";
"modules.general.sections.storage.footer.purchase.tv_beta" = "TV profiles do not work in beta builds.";
"modules.general.sections.storage.footer.purchase.tv_release" = "TV profiles do not work without a purchase.";
"modules.general.rows.shared.purchase" = "Share on iCloud";
"modules.general.rows.apple_tv.purchase" = "Drop TV restriction";
"modules.on_demand.purchase" = "Add on-demand rules";
"modules.openvpn.credentials.interactive.purchase" = "Log in interactively";
"providers.picker.purchase" = "Add more providers";
@ -291,7 +292,7 @@
"errors.app.empty_products" = "Unable to fetch products, please retry later.";
"errors.app.empty_profile_name" = "Profile name is empty.";
"errors.app.expired_profile" = "Profile is expired.";
"errors.app.ineligible_profile" = "A purchase is required for this profile to work.";
"errors.app.malformed_module" = "Module %@ is malformed. %@";
"errors.app.provider.required" = "No provider selected.";
"errors.app.default" = "Unable to complete operation.";
@ -309,7 +310,7 @@
"errors.tunnel.compression" = "Compression unsupported";
"errors.tunnel.dns" = "DNS failed";
"errors.tunnel.encryption" = "Encryption failed";
"errors.tunnel.expired" = "Expired";
"errors.tunnel.ineligible" = "Purchase required";
"errors.tunnel.routing" = "Missing routing";
"errors.tunnel.shutdown" = "Server shutdown";
"errors.tunnel.timeout" = "Timeout";

View File

@ -29,7 +29,8 @@ extension Theme {
public enum ImageName {
case add
case close
case cloud
case cloudOff
case cloudOn
case contextDuplicate
case contextRemove
case copy
@ -61,7 +62,8 @@ extension Theme {
case tunnelRestart
case tunnelToggle
case tunnelUninstall
case tv
case tvOff
case tvOn
}
}
@ -71,7 +73,8 @@ extension Theme.ImageName {
switch $0 {
case .add: return "plus"
case .close: return "xmark"
case .cloud: return "icloud"
case .cloudOff: return "icloud.slash"
case .cloudOn: return "icloud"
case .contextDuplicate: return "plus.square.on.square"
case .contextRemove: return "trash"
case .copy: return "doc.on.doc"
@ -103,7 +106,8 @@ extension Theme.ImageName {
case .tunnelRestart: return "arrow.clockwise"
case .tunnelToggle: return "power"
case .tunnelUninstall: return "arrow.uturn.down"
case .tv: return "tv"
case .tvOff: return "tv.slash"
case .tvOn: return "tv"
}
}
}

View File

@ -77,7 +77,7 @@ public struct OpenVPNCredentialsView: View {
.modifier(PurchaseButtonModifier(
Strings.Modules.Openvpn.Credentials.Interactive.purchase,
feature: .interactiveLogin,
suggesting: .Features.interactiveLogin,
suggesting: nil,
showsIfRestricted: false,
paywallReason: $paywallReason
))

View File

@ -94,7 +94,7 @@ private extension PaywallView {
var subscriptionFeatures: [AppFeature] {
AppFeature.allCases.sorted {
$0.localizedDescription < $1.localizedDescription
$0.localizedDescription.lowercased() < $1.localizedDescription.lowercased()
}
}

View File

@ -96,6 +96,7 @@ extension IAPManagerTests {
XCTAssertTrue(sut.isEligible(for: .httpProxy))
XCTAssertFalse(sut.isEligible(for: .onDemand))
XCTAssertTrue(sut.isEligible(for: .routing))
XCTAssertFalse(sut.isEligible(for: .sharing))
XCTAssertFalse(sut.isEligible(for: AppFeature.allButAppleTV))
}

View File

@ -0,0 +1,212 @@
//
// AppContext+Shared.swift
// Passepartout
//
// Created by Davide De Rosa on 2/24/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import AppData
import AppDataProfiles
import AppDataProviders
import CommonLibrary
import CommonUtils
import Foundation
import PassepartoutKit
import UILibrary
// shared registry and environment are picked from Shared.swift
extension AppContext {
static let shared: AppContext = {
// MARK: ProfileManager
let remoteRepositoryBlock: (Bool) -> ProfileRepository = {
let remoteStore = CoreDataPersistentStore(
logger: .default,
containerName: Constants.shared.containers.remote,
model: AppData.cdProfilesModel,
cloudKitIdentifier: $0 ? BundleConfiguration.mainString(for: .cloudKitId) : nil,
author: nil
)
return AppData.cdProfileRepositoryV3(
registry: .shared,
coder: CodableProfileCoder(),
context: remoteStore.context,
observingResults: true
) { error in
pp_log(.app, .error, "Unable to decode remote result: \(error)")
return .ignore
}
}
let profileManager: ProfileManager = {
return ProfileManager(
repository: Configuration.ProfileManager.mainProfileRepository,
backupRepository: Configuration.ProfileManager.backupProfileRepository,
remoteRepositoryBlock: remoteRepositoryBlock,
mirrorsRemoteRepository: Configuration.ProfileManager.mirrorsRemoteRepository,
processor: IAPManager.sharedProcessor
)
}()
// MARK: ExtendedTunnel
let tunnel = ExtendedTunnel(
tunnel: Tunnel(strategy: Configuration.ExtendedTunnel.strategy),
environment: .shared,
processor: IAPManager.sharedProcessor,
interval: Constants.shared.tunnel.refreshInterval
)
// MARK: ProviderManager
let providerManager: ProviderManager = {
let store = CoreDataPersistentStore(
logger: .default,
containerName: Constants.shared.containers.providers,
model: AppData.cdProvidersModel,
cloudKitIdentifier: nil,
author: nil
)
let repository = AppData.cdProviderRepositoryV3(
context: store.context,
backgroundContext: store.backgroundContext
)
return ProviderManager(repository: repository)
}()
return AppContext(
iapManager: .shared,
registry: .shared,
profileManager: profileManager,
tunnel: tunnel,
providerManager: providerManager
)
}()
}
// MARK: - Configuration
private extension Configuration {
enum ExtendedTunnel {
}
}
// MARK: Simulator
#if targetEnvironment(simulator)
@MainActor
private extension Configuration.ProfileManager {
static var mainProfileRepository: ProfileRepository {
coreDataProfileRepository
}
static var backupProfileRepository: ProfileRepository? {
nil
}
}
private extension Configuration.ExtendedTunnel {
static var strategy: TunnelObservableStrategy {
FakeTunnelStrategy(environment: .shared, dataCountInterval: 1000)
}
}
#else
// MARK: Device
@MainActor
private extension Configuration.ProfileManager {
static var mainProfileRepository: ProfileRepository {
neProfileRepository
}
static var backupProfileRepository: ProfileRepository? {
coreDataProfileRepository
}
}
@MainActor
private extension Configuration.ExtendedTunnel {
static var strategy: TunnelObservableStrategy {
Configuration.ProfileManager.neStrategy
}
}
#endif
// MARK: Common
@MainActor
private extension Configuration.ProfileManager {
static let neProfileRepository: ProfileRepository = {
NEProfileRepository(repository: neStrategy) {
sharedTitle($0)
}
}()
static let neStrategy: NETunnelStrategy = {
NETunnelStrategy(
bundleIdentifier: BundleConfiguration.mainString(for: .tunnelId),
coder: Registry.sharedProtocolCoder,
environment: .shared
)
}()
static let coreDataProfileRepository: ProfileRepository = {
let store = CoreDataPersistentStore(
logger: .default,
containerName: Constants.shared.containers.local,
model: AppData.cdProfilesModel,
cloudKitIdentifier: nil,
author: nil
)
return AppData.cdProfileRepositoryV3(
registry: .shared,
coder: CodableProfileCoder(),
context: store.context,
observingResults: false
) { error in
pp_log(.app, .error, "Unable to decode local result: \(error)")
return .ignore
}
}()
}
// MARK: - Logging
private extension CoreDataPersistentStoreLogger where Self == DefaultCoreDataPersistentStoreLogger {
static var `default`: CoreDataPersistentStoreLogger {
DefaultCoreDataPersistentStoreLogger()
}
}
private struct DefaultCoreDataPersistentStoreLogger: CoreDataPersistentStoreLogger {
func debug(_ msg: String) {
pp_log(.app, .info, msg)
}
func warning(_ msg: String) {
pp_log(.app, .error, msg)
}
}

View File

@ -1,350 +0,0 @@
//
// Shared+App.swift
// Passepartout
//
// Created by Davide De Rosa on 2/24/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import AppData
import AppDataProfiles
import AppDataProviders
import CommonLibrary
import CommonUtils
import Foundation
import PassepartoutKit
import UILibrary
extension AppContext {
static let shared: AppContext = {
let tunnelEnvironment: TunnelEnvironment = .shared
let registry: Registry = .shared
let iapHelpers = Configuration.IAPManager.helpers
let iapManager = IAPManager(
customUserLevel: Configuration.Environment.userLevel,
inAppHelper: iapHelpers.productHelper,
receiptReader: iapHelpers.receiptReader,
// FIXME: #662, omit unrestrictedFeatures on release!
unrestrictedFeatures: [.interactiveLogin],
productsAtBuild: Configuration.IAPManager.productsAtBuild
)
let processor = ProfileProcessor(
iapManager: iapManager,
title: {
Configuration.ProfileManager.sharedTitle($0)
},
isIncluded: { _, profile in
Configuration.ProfileManager.isProfileIncluded(profile)
},
willSave: { iap, builder in
var copy = builder
var attributes = copy.attributes
// preprocess TV profiles
if attributes.isAvailableForTV == true {
// ineligible, set expiration date unless already set
if !iap.isEligible(for: .appleTV),
attributes.expirationDate == nil || attributes.isExpired {
let expirationDate = Constants.shared.tunnel.newTVExpirationDate()
pp_log(.app, .notice, "Ineligible, apply expiration date: \(expirationDate)")
attributes.expirationDate = expirationDate
} else {
attributes.expirationDate = nil
}
}
copy.attributes = attributes
return copy
},
willConnect: { iap, profile in
var builder = profile.builder()
// ineligible, suppress on-demand rules
if !iap.isEligible(for: .onDemand) {
pp_log(.app, .notice, "Ineligible, suppress on-demand rules")
if let onDemandModuleIndex = builder.modules.firstIndex(where: { $0 is OnDemandModule }),
let onDemandModule = builder.modules[onDemandModuleIndex] as? OnDemandModule {
var onDemandBuilder = onDemandModule.builder()
onDemandBuilder.policy = .any
builder.modules[onDemandModuleIndex] = onDemandBuilder.tryBuild()
}
}
// validate provider modules
let profile = try builder.tryBuild()
do {
_ = try profile.withProviderModules()
return profile
} catch {
pp_log(.app, .error, "Unable to inject provider modules: \(error)")
throw error
}
}
)
let profileManager: ProfileManager = {
let remoteStore = CoreDataPersistentStore(
logger: .default,
containerName: Constants.shared.containers.remote,
model: AppData.cdProfilesModel,
cloudKitIdentifier: BundleConfiguration.mainString(for: .cloudKitId),
author: nil
)
let remoteRepository = AppData.cdProfileRepositoryV3(
registry: .shared,
coder: CodableProfileCoder(),
context: remoteStore.context,
observingResults: true
) { error in
pp_log(.app, .error, "Unable to decode remote result: \(error)")
return .ignore
}
return ProfileManager(
repository: Configuration.ProfileManager.mainProfileRepository,
backupRepository: Configuration.ProfileManager.backupProfileRepository,
remoteRepository: remoteRepository,
deletingRemotely: Configuration.ProfileManager.deletingRemotely,
processor: processor
)
}()
let tunnel = ExtendedTunnel(
tunnel: Tunnel(strategy: Configuration.ExtendedTunnel.strategy),
environment: tunnelEnvironment,
processor: processor,
interval: Constants.shared.tunnel.refreshInterval
)
let providerManager: ProviderManager = {
let store = CoreDataPersistentStore(
logger: .default,
containerName: Constants.shared.containers.providers,
model: AppData.cdProvidersModel,
cloudKitIdentifier: nil,
author: nil
)
let repository = AppData.cdProviderRepositoryV3(
context: store.context,
backgroundContext: store.backgroundContext
)
return ProviderManager(repository: repository)
}()
return AppContext(
iapManager: iapManager,
profileManager: profileManager,
tunnel: tunnel,
registry: registry,
providerManager: providerManager
)
}()
}
// MARK: - Configuration
private enum Configuration {
enum Environment {
static var isFakeIAP: Bool {
ProcessInfo.processInfo.environment["PP_FAKE_IAP"] == "1"
}
static var userLevel: AppUserLevel? {
if let envString = ProcessInfo.processInfo.environment["PP_USER_LEVEL"],
let envValue = Int(envString),
let testAppType = AppUserLevel(rawValue: envValue) {
return testAppType
}
if let infoValue = BundleConfiguration.mainIntegerIfPresent(for: .userLevel),
let testAppType = AppUserLevel(rawValue: infoValue) {
return testAppType
}
return nil
}
}
}
extension Configuration {
enum IAPManager {
@MainActor
static var helpers: (productHelper: any AppProductHelper, receiptReader: AppReceiptReader) {
guard !Environment.isFakeIAP else {
let mockHelper = MockAppProductHelper()
return (mockHelper, mockHelper.receiptReader)
}
let productHelper = StoreKitHelper(
products: AppProduct.all,
inAppIdentifier: {
let prefix = BundleConfiguration.mainString(for: .iapBundlePrefix)
return "\(prefix).\($0.rawValue)"
}
)
let receiptReader = FallbackReceiptReader(
reader: StoreKitReceiptReader(),
localReader: {
KvittoReceiptReader(url: $0)
}
)
return (productHelper, receiptReader)
}
static let productsAtBuild: BuildProducts<AppProduct> = {
#if os(iOS)
if $0 <= 2016 {
return [.Full.iOS]
} else if $0 <= 3000 {
return [.Features.networkSettings]
}
return []
#elseif os(macOS)
if $0 <= 3000 {
return [.Features.networkSettings]
}
return []
#else
return []
#endif
}
}
}
extension Configuration {
enum ProfileManager {
static let sharedTitle: @Sendable (Profile) -> String = {
String(format: Constants.shared.tunnel.profileTitleFormat, $0.name)
}
#if os(tvOS)
static let deletingRemotely = true
static let isProfileIncluded: @Sendable (Profile) -> Bool = {
$0.attributes.isAvailableForTV == true
}
#else
static let deletingRemotely = false
static let isProfileIncluded: @Sendable (Profile) -> Bool = { _ in
true
}
#endif
}
}
#if targetEnvironment(simulator)
extension Configuration {
enum ExtendedTunnel {
static var strategy: TunnelObservableStrategy {
FakeTunnelStrategy(environment: .shared, dataCountInterval: 1000)
}
}
}
@MainActor
extension Configuration.ProfileManager {
static var mainProfileRepository: ProfileRepository {
coreDataProfileRepository
}
static var backupProfileRepository: ProfileRepository? {
nil
}
}
#else
extension Configuration {
@MainActor
enum ExtendedTunnel {
static var strategy: TunnelObservableStrategy {
ProfileManager.neStrategy
}
}
}
@MainActor
extension Configuration.ProfileManager {
static var mainProfileRepository: ProfileRepository {
neProfileRepository
}
static var backupProfileRepository: ProfileRepository? {
coreDataProfileRepository
}
}
#endif
@MainActor
extension Configuration.ProfileManager {
static let neProfileRepository: ProfileRepository = {
NEProfileRepository(repository: neStrategy) {
sharedTitle($0)
}
}()
static let neStrategy: NETunnelStrategy = {
NETunnelStrategy(
bundleIdentifier: BundleConfiguration.mainString(for: .tunnelId),
coder: Registry.sharedProtocolCoder,
environment: .shared
)
}()
static let coreDataProfileRepository: ProfileRepository = {
let store = CoreDataPersistentStore(
logger: .default,
containerName: Constants.shared.containers.local,
model: AppData.cdProfilesModel,
cloudKitIdentifier: nil,
author: nil
)
return AppData.cdProfileRepositoryV3(
registry: .shared,
coder: CodableProfileCoder(),
context: store.context,
observingResults: false
) { error in
pp_log(.app, .error, "Unable to decode local result: \(error)")
return .ignore
}
}()
}
// MARK: -
extension CoreDataPersistentStoreLogger where Self == DefaultCoreDataPersistentStoreLogger {
static var `default`: CoreDataPersistentStoreLogger {
DefaultCoreDataPersistentStoreLogger()
}
}
private struct DefaultCoreDataPersistentStoreLogger: CoreDataPersistentStoreLogger {
func debug(_ msg: String) {
pp_log(.app, .info, msg)
}
func warning(_ msg: String) {
pp_log(.app, .error, msg)
}
}

View File

@ -23,11 +23,15 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import CommonUtils
import CPassepartoutOpenVPNOpenSSL
import Foundation
import PassepartoutKit
import PassepartoutWireGuardGo
// MARK: Registry
extension Registry {
static let shared = Registry(
withKnownHandlers: true,
@ -74,7 +78,7 @@ extension Registry {
}
}
// MARK: -
// MARK: TunnelEnvironment
extension TunnelEnvironment where Self == AppGroupEnvironment {
static var shared: Self {
@ -84,3 +88,161 @@ extension TunnelEnvironment where Self == AppGroupEnvironment {
)
}
}
// MARK: IAPManager
extension IAPManager {
static let shared: IAPManager = {
let iapHelpers = Configuration.IAPManager.helpers
return IAPManager(
customUserLevel: Configuration.Environment.userLevel,
inAppHelper: iapHelpers.productHelper,
receiptReader: iapHelpers.receiptReader,
// FIXME: #662, omit unrestrictedFeatures on release!
unrestrictedFeatures: [.interactiveLogin, .sharing],
productsAtBuild: Configuration.IAPManager.productsAtBuild
)
}()
static let sharedProcessor = ProfileProcessor(
iapManager: shared,
title: {
Configuration.ProfileManager.sharedTitle($0)
},
isIncluded: {
Configuration.ProfileManager.isIncluded($0, $1)
},
willSave: {
$1
},
willConnect: { iap, profile in
var builder = profile.builder()
// ineligible, suppress on-demand rules
if !iap.isEligible(for: .onDemand) {
pp_log(.app, .notice, "Ineligible, suppress on-demand rules")
if let onDemandModuleIndex = builder.modules.firstIndex(where: { $0 is OnDemandModule }),
let onDemandModule = builder.modules[onDemandModuleIndex] as? OnDemandModule {
var onDemandBuilder = onDemandModule.builder()
onDemandBuilder.policy = .any
builder.modules[onDemandModuleIndex] = onDemandBuilder.tryBuild()
}
}
// validate provider modules
let profile = try builder.tryBuild()
do {
_ = try profile.withProviderModules()
return profile
} catch {
pp_log(.app, .error, "Unable to inject provider modules: \(error)")
throw error
}
}
)
}
// MARK: - Configuration
enum Configuration {
enum Environment {
}
enum ProfileManager {
}
enum IAPManager {
}
}
// MARK: Environment
private extension Configuration.Environment {
static var isFakeIAP: Bool {
ProcessInfo.processInfo.environment["PP_FAKE_IAP"] == "1"
}
static var userLevel: AppUserLevel? {
if let envString = ProcessInfo.processInfo.environment["PP_USER_LEVEL"],
let envValue = Int(envString),
let testAppType = AppUserLevel(rawValue: envValue) {
return testAppType
}
if let infoValue = BundleConfiguration.mainIntegerIfPresent(for: .userLevel),
let testAppType = AppUserLevel(rawValue: infoValue) {
return testAppType
}
return nil
}
}
// MARK: ProfileManager
extension Configuration.ProfileManager {
static let sharedTitle: @Sendable (Profile) -> String = {
String(format: Constants.shared.tunnel.profileTitleFormat, $0.name)
}
#if os(tvOS)
static let mirrorsRemoteRepository = true
static let isIncluded: @MainActor @Sendable (CommonLibrary.IAPManager, Profile) -> Bool = {
$1.attributes.isAvailableForTV == true
}
#else
static let mirrorsRemoteRepository = false
static let isIncluded: @MainActor @Sendable (CommonLibrary.IAPManager, Profile) -> Bool = { _, _ in
true
}
#endif
}
// MARK: IAPManager
private extension Configuration.IAPManager {
@MainActor
static var helpers: (productHelper: any AppProductHelper, receiptReader: AppReceiptReader) {
guard !Configuration.Environment.isFakeIAP else {
let mockHelper = MockAppProductHelper()
return (mockHelper, mockHelper.receiptReader)
}
let productHelper = StoreKitHelper(
products: AppProduct.all,
inAppIdentifier: {
let prefix = BundleConfiguration.mainString(for: .iapBundlePrefix)
return "\(prefix).\($0.rawValue)"
}
)
let receiptReader = FallbackReceiptReader(
reader: StoreKitReceiptReader(),
localReader: {
KvittoReceiptReader(url: $0)
}
)
return (productHelper, receiptReader)
}
static let productsAtBuild: BuildProducts<AppProduct> = {
#if os(iOS)
if $0 <= 2016 {
return [.Full.iOS]
} else if $0 <= 3000 {
return [.Features.networkSettings]
}
return []
#elseif os(macOS)
if $0 <= 3000 {
return [.Features.networkSettings]
}
return []
#else
return []
#endif
}
}

View File

@ -36,6 +36,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
parameters: Constants.shared.log,
logsPrivateData: UserDefaults.appGroup.bool(forKey: AppPreference.logsPrivateData.key)
)
try await checkEligibility(environment: .shared)
do {
fwd = try await NEPTPForwarder(
provider: self,
@ -43,9 +44,6 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
registry: .shared,
environment: .shared
)
if let expirationDate = fwd?.profile.attributes.expirationDate {
try checkExpirationDate(expirationDate, environment: .shared)
}
try await fwd?.startTunnel(options: options)
} catch {
pp_log(.app, .fault, "Unable to start tunnel: \(error)")
@ -78,25 +76,27 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
}
}
@MainActor
private extension PacketTunnelProvider {
func checkExpirationDate(_ expirationDate: Date, environment: TunnelEnvironment) throws {
let error = PassepartoutError(.App.expiredProfile)
var iapManager: IAPManager {
.shared
}
// already expired?
let delay = Int(expirationDate.timeIntervalSinceNow)
if delay < .zero {
pp_log(.app, .error, "Tunnel expired on \(expirationDate)")
var isEligible: Bool {
#if os(tvOS)
iapManager.isEligible(for: .appleTV)
#else
true
#endif
}
func checkEligibility(environment: TunnelEnvironment) async throws {
await iapManager.reloadReceipt()
guard isEligible else {
let error = PassepartoutError(.App.ineligibleProfile)
environment.setEnvironmentValue(error.code, forKey: TunnelEnvironmentKeys.lastErrorCode)
pp_log(.app, .fault, "Profile is ineligible, purchase required")
throw error
}
// schedule connection expiration
Task { [weak self] in
pp_log(.app, .notice, "Schedule tunnel expiration on \(expirationDate) (\(delay) seconds from now)")
try? await Task.sleep(for: .seconds(delay))
pp_log(.app, .error, "Tunnel expired on \(expirationDate)")
environment.setEnvironmentValue(error.code, forKey: TunnelEnvironmentKeys.lastErrorCode)
self?.cancelTunnelWithError(error)
}
}
}