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:
parent
d209b0d9b0
commit
e07833b2a4
|
@ -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 */,
|
||||
);
|
||||
|
|
|
@ -41,7 +41,7 @@
|
|||
"kind" : "remoteSourceControl",
|
||||
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
|
||||
"state" : {
|
||||
"revision" : "fe192115ca6f8e49447717dbe0a64347bd722aec"
|
||||
"revision" : "b31816d060e40583a27d22ea5c59cc686c057aaf"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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?
|
||||
}
|
||||
|
|
|
@ -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"/>
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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: " ")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -40,6 +40,8 @@ public enum AppFeature: String, CaseIterable {
|
|||
|
||||
case routing
|
||||
|
||||
case sharing
|
||||
|
||||
public static let allButAppleTV: [AppFeature] = allCases.filter {
|
||||
$0 != .appleTV
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -23,8 +23,7 @@
|
|||
},
|
||||
"tunnel": {
|
||||
"profileTitleFormat": "Passepartout: %@",
|
||||
"refreshInterval": 3.0,
|
||||
"tvExpirationMinutes": 10
|
||||
"refreshInterval": 3.0
|
||||
},
|
||||
"api": {
|
||||
"timeoutInterval": 5.0
|
||||
|
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -51,6 +51,9 @@ extension AppFeature: LocalizableEntity {
|
|||
|
||||
case .routing:
|
||||
return V.routing(Strings.Global.routing)
|
||||
|
||||
case .sharing:
|
||||
return V.sharing(Strings.Unlocalized.iCloud)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -78,9 +78,9 @@ extension AppContext {
|
|||
)
|
||||
return AppContext(
|
||||
iapManager: iapManager,
|
||||
registry: registry,
|
||||
profileManager: profileManager,
|
||||
tunnel: tunnel,
|
||||
registry: registry,
|
||||
providerManager: providerManager
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
))
|
||||
|
|
|
@ -94,7 +94,7 @@ private extension PaywallView {
|
|||
|
||||
var subscriptionFeatures: [AppFeature] {
|
||||
AppFeature.allCases.sorted {
|
||||
$0.localizedDescription < $1.localizedDescription
|
||||
$0.localizedDescription.lowercased() < $1.localizedDescription.lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue