From e07833b2a4295cfc97dcf5ca08d0f184e602982b Mon Sep 17 00:00:00 2001 From: Davide Date: Sat, 9 Nov 2024 15:20:59 +0100 Subject: [PATCH] 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 --- Passepartout.xcodeproj/project.pbxproj | 8 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- Passepartout/Library/Package.swift | 2 +- .../CDProfileRepositoryV3.swift | 3 +- .../AppDataProfiles/Domain/CDProfileV3.swift | 1 - .../ProfilesV3.xcdatamodel/contents | 1 - .../AppUIMain/Views/App/ProfileRowView.swift | 6 +- .../Views/Profile/AppleTVSection.swift | 100 ----- .../Views/Profile/StorageSection.swift | 75 +++- .../Profile/iOS/ProfileEditView+iOS.swift | 6 +- .../macOS/ProfileGeneralView+macOS.swift | 4 - .../Business/ProfileManager.swift | 108 +++--- .../CommonLibrary/Domain/AppError.swift | 2 +- .../CommonLibrary/Domain/Constants.swift | 7 - .../Domain/ProfileAttributes.swift | 18 +- .../CommonLibrary/IAP/AppFeature.swift | 2 + .../IAP/AppFeatureProviding.swift | 11 +- .../IAP/AppProduct+Features.swift | 57 +-- .../CommonLibrary/IAP/IAPManager.swift | 23 +- .../CommonLibrary/Resources/Constants.json | 3 +- .../Business/CoreDataRepository.swift | 23 +- .../UILibrary/Business/AppContext.swift | 42 ++- .../UILibrary/Business/ProfileEditor.swift | 4 +- .../UILibrary/L10n/AppError+L10n.swift | 8 +- .../UILibrary/L10n/AppFeature+L10n.swift | 3 + .../UILibrary/L10n/SwiftGen+Strings.swift | 52 +-- .../UILibrary/Mock/AppContext+Mock.swift | 2 +- .../Resources/en.lproj/Localizable.strings | 19 +- .../UILibrary/Theme/Theme+ImageName.swift | 12 +- .../Modules/OpenVPNView+Credentials.swift | 2 +- .../UILibrary/Views/Paywall/PaywallView.swift | 2 +- .../CommonLibraryTests/IAPManagerTests.swift | 1 + Passepartout/Shared/AppContext+Shared.swift | 212 +++++++++++ Passepartout/Shared/Shared+App.swift | 350 ------------------ Passepartout/Shared/Shared.swift | 164 +++++++- .../Tunnel/PacketTunnelProvider.swift | 36 +- 36 files changed, 712 insertions(+), 659 deletions(-) delete mode 100644 Passepartout/Library/Sources/AppUIMain/Views/Profile/AppleTVSection.swift create mode 100644 Passepartout/Shared/AppContext+Shared.swift delete mode 100644 Passepartout/Shared/Shared+App.swift diff --git a/Passepartout.xcodeproj/project.pbxproj b/Passepartout.xcodeproj/project.pbxproj index 27151892..5d740621 100644 --- a/Passepartout.xcodeproj/project.pbxproj +++ b/Passepartout.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; + 0EC797402B9378E000C093B7 /* AppContext+Shared.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AppContext+Shared.swift"; sourceTree = ""; }; 0EC797412B9378E000C093B7 /* Shared.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Shared.swift; sourceTree = ""; }; 0ED1EFDA2C33059600CBD9BD /* App.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = App.plist; sourceTree = ""; }; 0ED61CF72CD0418C008FE259 /* App+macOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App+macOS.swift"; sourceTree = ""; }; @@ -231,8 +231,8 @@ 0E7E3D612B9345FD002BBDB4 /* Shared */ = { isa = PBXGroup; children = ( + 0EC797402B9378E000C093B7 /* AppContext+Shared.swift */, 0EC797412B9378E000C093B7 /* Shared.swift */, - 0EC797402B9378E000C093B7 /* Shared+App.swift */, ); path = Shared; sourceTree = ""; @@ -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 */, ); diff --git a/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9224339f..b4dd124d 100644 --- a/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -41,7 +41,7 @@ "kind" : "remoteSourceControl", "location" : "git@github.com:passepartoutvpn/passepartoutkit-source", "state" : { - "revision" : "fe192115ca6f8e49447717dbe0a64347bd722aec" + "revision" : "b31816d060e40583a27d22ea5c59cc686c057aaf" } }, { diff --git a/Passepartout/Library/Package.swift b/Passepartout/Library/Package.swift index baa18233..eed823aa 100644 --- a/Passepartout/Library/Package.swift +++ b/Passepartout/Library/Package.swift @@ -46,7 +46,7 @@ let package = Package( ], dependencies: [ // .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.9.0"), - .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "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"), diff --git a/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift b/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift index 504892e5..55b162da 100644 --- a/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift +++ b/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift @@ -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 diff --git a/Passepartout/Library/Sources/AppDataProfiles/Domain/CDProfileV3.swift b/Passepartout/Library/Sources/AppDataProfiles/Domain/CDProfileV3.swift index 4ab8dd84..7cb81b0a 100644 --- a/Passepartout/Library/Sources/AppDataProfiles/Domain/CDProfileV3.swift +++ b/Passepartout/Library/Sources/AppDataProfiles/Domain/CDProfileV3.swift @@ -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? } diff --git a/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/ProfilesV3.xcdatamodel/contents b/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/ProfilesV3.xcdatamodel/contents index 1810f407..05026434 100644 --- a/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/ProfilesV3.xcdatamodel/contents +++ b/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/ProfilesV3.xcdatamodel/contents @@ -2,7 +2,6 @@ - diff --git a/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift b/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift index a4013617..e7dab326 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift @@ -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)) } } diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Profile/AppleTVSection.swift b/Passepartout/Library/Sources/AppUIMain/Views/Profile/AppleTVSection.swift deleted file mode 100644 index 8fee36b9..00000000 --- a/Passepartout/Library/Sources/AppUIMain/Views/Profile/AppleTVSection.swift +++ /dev/null @@ -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 . -// - -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() -} diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Profile/StorageSection.swift b/Passepartout/Library/Sources/AppUIMain/Views/Profile/StorageSection.swift index cf0884a2..c0fc84ed 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/Profile/StorageSection.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/Profile/StorageSection.swift @@ -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: " ") } } diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Profile/iOS/ProfileEditView+iOS.swift b/Passepartout/Library/Sources/AppUIMain/Views/Profile/iOS/ProfileEditView+iOS.swift index 79e6598e..8d9e1ff8 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/Profile/iOS/ProfileEditView+iOS.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/Profile/iOS/ProfileEditView+iOS.swift @@ -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 diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Profile/macOS/ProfileGeneralView+macOS.swift b/Passepartout/Library/Sources/AppUIMain/Views/Profile/macOS/ProfileGeneralView+macOS.swift index e17e343c..56f1559a 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/Profile/macOS/ProfileGeneralView+macOS.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/Profile/macOS/ProfileGeneralView+macOS.swift @@ -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)) diff --git a/Passepartout/Library/Sources/CommonLibrary/Business/ProfileManager.swift b/Passepartout/Library/Sources/CommonLibrary/Business/ProfileManager.swift index 873c00f4..26485e4a 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Business/ProfileManager.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Business/ProfileManager.swift @@ -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 @@ -62,12 +67,14 @@ public final class ProfileManager: ObservableObject { private var subscriptions: Set + private var remoteSubscriptions: Set + // 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) } } diff --git a/Passepartout/Library/Sources/CommonLibrary/Domain/AppError.swift b/Passepartout/Library/Sources/CommonLibrary/Domain/AppError.swift index ae5aea3f..c159d108 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Domain/AppError.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Domain/AppError.swift @@ -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") } } diff --git a/Passepartout/Library/Sources/CommonLibrary/Domain/Constants.swift b/Passepartout/Library/Sources/CommonLibrary/Domain/Constants.swift index d4fcb913..090e8325 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Domain/Constants.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Domain/Constants.swift @@ -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 { diff --git a/Passepartout/Library/Sources/CommonLibrary/Domain/ProfileAttributes.swift b/Passepartout/Library/Sources/CommonLibrary/Domain/ProfileAttributes.swift index 737d2fa4..8b559464 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Domain/ProfileAttributes.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Domain/ProfileAttributes.swift @@ -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 diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeature.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeature.swift index 3dfa438f..b4f1c764 100644 --- a/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeature.swift +++ b/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeature.swift @@ -40,6 +40,8 @@ public enum AppFeature: String, CaseIterable { case routing + case sharing + public static let allButAppleTV: [AppFeature] = allCases.filter { $0 != .appleTV } diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeatureProviding.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeatureProviding.swift index 8e3e6c5f..a13e76a4 100644 --- a/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeatureProviding.swift +++ b/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeatureProviding.swift @@ -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] diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/AppProduct+Features.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/AppProduct+Features.swift index 19c3b6da..3a515fd9 100644 --- a/Passepartout/Library/Sources/CommonLibrary/IAP/AppProduct+Features.swift +++ b/Passepartout/Library/Sources/CommonLibrary/IAP/AppProduct+Features.swift @@ -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") +} diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager.swift index d1391e83..4b962861 100644 --- a/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager.swift +++ b/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager.swift @@ -46,7 +46,8 @@ public final class IAPManager: ObservableObject { public private(set) var purchasedProducts: Set - private var eligibleFeatures: Set + @Published + public private(set) var eligibleFeatures: Set private var pendingReceiptTask: Task? @@ -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 = [] + var eligibleFeatures: Set = [] 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 diff --git a/Passepartout/Library/Sources/CommonLibrary/Resources/Constants.json b/Passepartout/Library/Sources/CommonLibrary/Resources/Constants.json index fb5e7eba..3def3bdc 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Resources/Constants.json +++ b/Passepartout/Library/Sources/CommonLibrary/Resources/Constants.json @@ -23,8 +23,7 @@ }, "tunnel": { "profileTitleFormat": "Passepartout: %@", - "refreshInterval": 3.0, - "tvExpirationMinutes": 10 + "refreshInterval": 3.0 }, "api": { "timeoutInterval": 5.0 diff --git a/Passepartout/Library/Sources/CommonUtils/Business/CoreDataRepository.swift b/Passepartout/Library/Sources/CommonUtils/Business/CoreDataRepository.swift index 3c5eed68..5cbb0def 100644 --- a/Passepartout/Library/Sources/CommonUtils/Business/CoreDataRepository.swift +++ b/Passepartout/Library/Sources/CommonUtils/Business/CoreDataRepository.swift @@ -201,11 +201,26 @@ private extension CoreDataRepository { } nonisolated func sendResults(from controller: NSFetchedResultsController) { - 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() + 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)") } } } diff --git a/Passepartout/Library/Sources/UILibrary/Business/AppContext.swift b/Passepartout/Library/Sources/UILibrary/Business/AppContext.swift index bb23ed29..ef5b93d1 100644 --- a/Passepartout/Library/Sources/UILibrary/Business/AppContext.swift +++ b/Passepartout/Library/Sources/UILibrary/Business/AppContext.swift @@ -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) { + let canImport = eligible.contains(.sharing) + profileManager.enableRemoteImporting(canImport && isCloudKitEnabled) + } + func syncTunnelIfCurrentProfile(_ profile: Profile) { guard profile.id == tunnel.currentProfile?.id else { return diff --git a/Passepartout/Library/Sources/UILibrary/Business/ProfileEditor.swift b/Passepartout/Library/Sources/UILibrary/Business/ProfileEditor.swift index 427dde53..e6d9baa7 100644 --- a/Passepartout/Library/Sources/UILibrary/Business/ProfileEditor.swift +++ b/Passepartout/Library/Sources/UILibrary/Business/ProfileEditor.swift @@ -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 diff --git a/Passepartout/Library/Sources/UILibrary/L10n/AppError+L10n.swift b/Passepartout/Library/Sources/UILibrary/L10n/AppError+L10n.swift index 1c80ce34..2ef16b3a 100644 --- a/Passepartout/Library/Sources/UILibrary/L10n/AppError+L10n.swift +++ b/Passepartout/Library/Sources/UILibrary/L10n/AppError+L10n.swift @@ -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 diff --git a/Passepartout/Library/Sources/UILibrary/L10n/AppFeature+L10n.swift b/Passepartout/Library/Sources/UILibrary/L10n/AppFeature+L10n.swift index b6ee0915..faa9d77f 100644 --- a/Passepartout/Library/Sources/UILibrary/L10n/AppFeature+L10n.swift +++ b/Passepartout/Library/Sources/UILibrary/L10n/AppFeature+L10n.swift @@ -51,6 +51,9 @@ extension AppFeature: LocalizableEntity { case .routing: return V.routing(Strings.Global.routing) + + case .sharing: + return V.sharing(Strings.Unlocalized.iCloud) } } } diff --git a/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift b/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift index 34e17d49..1abaf8da 100644 --- a/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift +++ b/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift @@ -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 { diff --git a/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift b/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift index 2e1220b6..6121c090 100644 --- a/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift +++ b/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift @@ -78,9 +78,9 @@ extension AppContext { ) return AppContext( iapManager: iapManager, + registry: registry, profileManager: profileManager, tunnel: tunnel, - registry: registry, providerManager: providerManager ) } diff --git a/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings b/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings index 21cca2c9..c7886393 100644 --- a/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings +++ b/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings @@ -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"; diff --git a/Passepartout/Library/Sources/UILibrary/Theme/Theme+ImageName.swift b/Passepartout/Library/Sources/UILibrary/Theme/Theme+ImageName.swift index d0ab1942..f5e7758d 100644 --- a/Passepartout/Library/Sources/UILibrary/Theme/Theme+ImageName.swift +++ b/Passepartout/Library/Sources/UILibrary/Theme/Theme+ImageName.swift @@ -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" } } } diff --git a/Passepartout/Library/Sources/UILibrary/Views/Modules/OpenVPNView+Credentials.swift b/Passepartout/Library/Sources/UILibrary/Views/Modules/OpenVPNView+Credentials.swift index d5031b89..134e7ca1 100644 --- a/Passepartout/Library/Sources/UILibrary/Views/Modules/OpenVPNView+Credentials.swift +++ b/Passepartout/Library/Sources/UILibrary/Views/Modules/OpenVPNView+Credentials.swift @@ -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 )) diff --git a/Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift b/Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift index b7491751..553abbf0 100644 --- a/Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift +++ b/Passepartout/Library/Sources/UILibrary/Views/Paywall/PaywallView.swift @@ -94,7 +94,7 @@ private extension PaywallView { var subscriptionFeatures: [AppFeature] { AppFeature.allCases.sorted { - $0.localizedDescription < $1.localizedDescription + $0.localizedDescription.lowercased() < $1.localizedDescription.lowercased() } } diff --git a/Passepartout/Library/Tests/CommonLibraryTests/IAPManagerTests.swift b/Passepartout/Library/Tests/CommonLibraryTests/IAPManagerTests.swift index 07ee9b8b..3463891c 100644 --- a/Passepartout/Library/Tests/CommonLibraryTests/IAPManagerTests.swift +++ b/Passepartout/Library/Tests/CommonLibraryTests/IAPManagerTests.swift @@ -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)) } diff --git a/Passepartout/Shared/AppContext+Shared.swift b/Passepartout/Shared/AppContext+Shared.swift new file mode 100644 index 00000000..7c3b8862 --- /dev/null +++ b/Passepartout/Shared/AppContext+Shared.swift @@ -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 . +// + +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) + } +} diff --git a/Passepartout/Shared/Shared+App.swift b/Passepartout/Shared/Shared+App.swift deleted file mode 100644 index ab4954b8..00000000 --- a/Passepartout/Shared/Shared+App.swift +++ /dev/null @@ -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 . -// - -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 = { -#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) - } -} diff --git a/Passepartout/Shared/Shared.swift b/Passepartout/Shared/Shared.swift index 44ac3af2..d4f8b29f 100644 --- a/Passepartout/Shared/Shared.swift +++ b/Passepartout/Shared/Shared.swift @@ -23,11 +23,15 @@ // along with Passepartout. If not, see . // +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 = { +#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 + } +} diff --git a/Passepartout/Tunnel/PacketTunnelProvider.swift b/Passepartout/Tunnel/PacketTunnelProvider.swift index 7ce8ca34..175d5d68 100644 --- a/Passepartout/Tunnel/PacketTunnelProvider.swift +++ b/Passepartout/Tunnel/PacketTunnelProvider.swift @@ -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) - } } }