diff --git a/Packages/App/Sources/AppUIMain/Views/About/AboutCoordinator.swift b/Packages/App/Sources/AppUIMain/Views/About/AboutCoordinator.swift index 1f1f030e..f4b05e45 100644 --- a/Packages/App/Sources/AppUIMain/Views/About/AboutCoordinator.swift +++ b/Packages/App/Sources/AppUIMain/Views/About/AboutCoordinator.swift @@ -49,7 +49,7 @@ struct AboutCoordinator: View { var body: some View { AboutContentView( profileManager: profileManager, - isRestricted: iapManager.isRestricted, + isBeta: iapManager.isBeta, path: $path, navigationRoute: $navigationRoute, linkContent: linkView(to:), diff --git a/Packages/App/Sources/AppUIMain/Views/About/iOS/AboutContentView+iOS.swift b/Packages/App/Sources/AppUIMain/Views/About/iOS/AboutContentView+iOS.swift index d529f4cc..816ef630 100644 --- a/Packages/App/Sources/AppUIMain/Views/About/iOS/AboutContentView+iOS.swift +++ b/Packages/App/Sources/AppUIMain/Views/About/iOS/AboutContentView+iOS.swift @@ -37,7 +37,7 @@ struct AboutContentView: View whe let profileManager: ProfileManager - let isRestricted: Bool + let isBeta: Bool @Binding var path: NavigationPath @@ -68,7 +68,7 @@ private extension AboutContentView { linkContent(.version) linkContent(.links) linkContent(.credits) - if !isRestricted { + if !isBeta { linkContent(.donate) } } diff --git a/Packages/App/Sources/AppUIMain/Views/About/macOS/AboutContentView+macOS.swift b/Packages/App/Sources/AppUIMain/Views/About/macOS/AboutContentView+macOS.swift index 1c514f44..d00c79db 100644 --- a/Packages/App/Sources/AppUIMain/Views/About/macOS/AboutContentView+macOS.swift +++ b/Packages/App/Sources/AppUIMain/Views/About/macOS/AboutContentView+macOS.swift @@ -39,7 +39,7 @@ struct AboutContentView: View whe let profileManager: ProfileManager - let isRestricted: Bool + let isBeta: Bool @Binding var path: NavigationPath @@ -82,7 +82,7 @@ private extension AboutContentView { linkContent(.version) linkContent(.links) linkContent(.credits) - if !isRestricted { + if !isBeta { linkContent(.donate) } linkContent(.purchased) diff --git a/Packages/App/Sources/AppUIMain/Views/App/AppCoordinator.swift b/Packages/App/Sources/AppUIMain/Views/App/AppCoordinator.swift index b1430812..a3cf9811 100644 --- a/Packages/App/Sources/AppUIMain/Views/App/AppCoordinator.swift +++ b/Packages/App/Sources/AppUIMain/Views/App/AppCoordinator.swift @@ -58,6 +58,9 @@ public struct AppCoordinator: View, AppCoordinatorConforming, SizeClassProviding @State private var paywallReason: PaywallReason? + @State + private var onCancelPaywall: (() -> Void)? + @State private var modalRoute: ModalRoute? @@ -92,7 +95,10 @@ public struct AppCoordinator: View, AppCoordinatorConforming, SizeClassProviding .toolbar(content: toolbarContent) } .modifier(OnboardingModifier(modalRoute: $modalRoute)) - .modifier(PaywallModifier(reason: $paywallReason)) + .modifier(PaywallModifier( + reason: $paywallReason, + onCancel: onCancelPaywall + )) .themeModal( item: $modalRoute, options: modalRoute?.options(), @@ -267,16 +273,25 @@ extension AppCoordinator { present(.editProviderEntity(profile, force, module)) } - public func onPurchaseRequired(_ features: Set) { + public func onPurchaseRequired(_ features: Set, onCancel: (() -> Void)?) { + pp_log(.app, .info, "Purchase required for features: \(features)") guard !iapManager.isLoadingReceipt else { + let V = Strings.Views.Paywall.Alerts.Verification.self + pp_log(.app, .info, "Present verification alert") errorHandler.handle( - title: Strings.Views.Paywall.Alerts.Verifying.title, - message: Strings.Views.Paywall.Alerts.Verifying.message + title: Strings.Views.Paywall.Alerts.Confirmation.title, + message: [ + V.Connect._1, + V.boot, + V.Connect._2(iapManager.verificationDelayMinutes) + ].joined(separator: " "), + onDismiss: onCancel ) return } - pp_log(.app, .info, "Present paywall for features: \(features)") - setLater(.init(features, needsConfirmation: true)) { + pp_log(.app, .info, "Present paywall") + onCancelPaywall = onCancel + setLater(.init(features)) { paywallReason = $0 } } diff --git a/Packages/App/Sources/AppUIMain/Views/App/ProfileRowView.swift b/Packages/App/Sources/AppUIMain/Views/App/ProfileRowView.swift index 036b74a5..2da78bf5 100644 --- a/Packages/App/Sources/AppUIMain/Views/App/ProfileRowView.swift +++ b/Packages/App/Sources/AppUIMain/Views/App/ProfileRowView.swift @@ -121,10 +121,6 @@ private struct MarkerView: View { ZStack { ThemeImage(profileId == nextProfileId ? .pending : tunnel.statusImageName) .opaque(requiredFeatures == nil && (profileId == nextProfileId || profileId == tunnel.currentProfile?.id)) - - if let requiredFeatures { - PurchaseRequiredView(features: requiredFeatures) - } } .frame(width: 24) } diff --git a/Packages/App/Sources/AppUIMain/Views/App/VerificationView.swift b/Packages/App/Sources/AppUIMain/Views/App/VerificationView.swift index ec26c12d..439fc5d4 100644 --- a/Packages/App/Sources/AppUIMain/Views/App/VerificationView.swift +++ b/Packages/App/Sources/AppUIMain/Views/App/VerificationView.swift @@ -33,7 +33,7 @@ struct VerificationView: View { Text(Strings.Views.App.Folders.default) if isVerifying { Spacer() - Text(Strings.Views.Paywall.Alerts.Verifying.title.withTrailingDots) + Text(Strings.Views.Verification.message.withTrailingDots) } } } diff --git a/Packages/App/Sources/AppUIMain/Views/Migration/MigrateView.swift b/Packages/App/Sources/AppUIMain/Views/Migration/MigrateView.swift index 58f42e20..5dee13ae 100644 --- a/Packages/App/Sources/AppUIMain/Views/Migration/MigrateView.swift +++ b/Packages/App/Sources/AppUIMain/Views/Migration/MigrateView.swift @@ -192,7 +192,7 @@ private extension MigrateView { pp_log(.App.migration, .notice, "Migrated \(migrated.count) profiles") // TODO: ### restore auto-deletion after stable 3.0.0, otherwise users could not downgrade -// if !iapManager.isRestricted { +// if !iapManager.isBeta { // do { // try await migrationManager.deleteMigratableProfiles(withIds: Set(migrated.map(\.id))) // pp_log(.App.migration, .notice, "Discarded \(migrated.count) migrated profiles from old store") diff --git a/Packages/App/Sources/AppUIMain/Views/Profile/ProfileCoordinator.swift b/Packages/App/Sources/AppUIMain/Views/Profile/ProfileCoordinator.swift index a0998ea4..f35f8419 100644 --- a/Packages/App/Sources/AppUIMain/Views/Profile/ProfileCoordinator.swift +++ b/Packages/App/Sources/AppUIMain/Views/Profile/ProfileCoordinator.swift @@ -69,11 +69,7 @@ struct ProfileCoordinator: View { var body: some View { contentView - .modifier(PaywallModifier( - reason: $paywallReason, - okTitle: Strings.Views.Profile.Alerts.Purchase.Buttons.ok, - okAction: onDismiss - )) + .modifier(PaywallModifier(reason: $paywallReason)) .withErrorHandler(errorHandler) } } @@ -121,10 +117,10 @@ private extension ProfileCoordinator { func onCommitEditing() async throws { do { - if !iapManager.isRestricted { + if !iapManager.isBeta { try await onCommitEditingStandard() } else { - try await onCommitEditingRestricted() + try await onCommitEditingBeta() } } catch { errorHandler.handle(error, title: Strings.Global.Actions.save) @@ -132,41 +128,31 @@ private extension ProfileCoordinator { } } - // standard: always save, warn if purchase required + // standard: verify and alert if purchase required func onCommitEditingStandard() async throws { - let savedProfile = try await profileEditor.save(to: profileManager, preferencesManager: preferencesManager) do { - try iapManager.verify(savedProfile, extra: profileEditor.extraFeatures) + let profileToSave = try profileEditor.build() + try iapManager.verify(profileToSave, extra: profileEditor.extraFeatures) + try await profileEditor.save(profileToSave, to: profileManager, preferencesManager: preferencesManager) } catch AppError.ineligibleProfile(let requiredFeatures) { guard !iapManager.isLoadingReceipt else { + let V = Strings.Views.Paywall.Alerts.Verification.self errorHandler.handle( - title: Strings.Views.Paywall.Alerts.Verifying.title, - message: Strings.Views.Paywall.Alerts.Verifying.message + title: Strings.Views.Paywall.Alerts.Confirmation.title, + message: [V.edit, V.boot].joined(separator: " ") ) return } - paywallReason = .init(requiredFeatures, needsConfirmation: true) + paywallReason = .init(requiredFeatures, forConnecting: false) return } onDismiss() } - // restricted: verify before saving - func onCommitEditingRestricted() async throws { - do { - try iapManager.verify(profileEditor.activeModules, extra: profileEditor.extraFeatures) - } catch AppError.ineligibleProfile(let requiredFeatures) { - guard !iapManager.isLoadingReceipt else { - errorHandler.handle( - title: Strings.Views.Paywall.Alerts.Verifying.title, - message: Strings.Views.Paywall.Alerts.Verifying.message - ) - return - } - paywallReason = .init(requiredFeatures) - return - } - try await profileEditor.save(to: profileManager, preferencesManager: preferencesManager) + // beta: skip verification + func onCommitEditingBeta() async throws { + let profileToSave = try profileEditor.build() + try await profileEditor.save(profileToSave, to: profileManager, preferencesManager: preferencesManager) onDismiss() } diff --git a/Packages/App/Sources/AppUIMain/Views/Profile/StorageSection.swift b/Packages/App/Sources/AppUIMain/Views/Profile/StorageSection.swift index 490bb04b..3d4e09f7 100644 --- a/Packages/App/Sources/AppUIMain/Views/Profile/StorageSection.swift +++ b/Packages/App/Sources/AppUIMain/Views/Profile/StorageSection.swift @@ -90,7 +90,7 @@ private extension StorageSection { if iapManager.isEligible(for: .appleTV) { return nil } - if !iapManager.isRestricted { + if !iapManager.isBeta { return Strings.Modules.General.Sections.Storage.Footer.Purchase.tvRelease } else { return Strings.Modules.General.Sections.Storage.Footer.Purchase.tvBeta diff --git a/Packages/App/Sources/AppUITV/Views/App/AppCoordinator.swift b/Packages/App/Sources/AppUITV/Views/App/AppCoordinator.swift index 504ab0df..6e7383ee 100644 --- a/Packages/App/Sources/AppUITV/Views/App/AppCoordinator.swift +++ b/Packages/App/Sources/AppUITV/Views/App/AppCoordinator.swift @@ -43,6 +43,9 @@ public struct AppCoordinator: View, AppCoordinatorConforming { @State private var paywallReason: PaywallReason? + @State + private var onCancelPaywall: (() -> Void)? + @StateObject private var interactiveManager = InteractiveManager() @@ -75,7 +78,10 @@ public struct AppCoordinator: View, AppCoordinatorConforming { } } .navigationDestination(for: AppCoordinatorRoute.self, destination: pushDestination) - .modifier(PaywallModifier(reason: $paywallReason)) + .modifier(PaywallModifier( + reason: $paywallReason, + onCancel: onCancelPaywall + )) .withErrorHandler(errorHandler) } } @@ -149,16 +155,25 @@ extension AppCoordinator { ) } - public func onPurchaseRequired(_ features: Set) { + public func onPurchaseRequired(_ features: Set, onCancel: (() -> Void)?) { + pp_log(.app, .info, "Purchase required for features: \(features)") guard !iapManager.isLoadingReceipt else { + let V = Strings.Views.Paywall.Alerts.Verification.self + pp_log(.app, .info, "Present verification alert") errorHandler.handle( - title: Strings.Views.Paywall.Alerts.Verifying.title, - message: Strings.Views.Paywall.Alerts.Verifying.message + title: Strings.Views.Paywall.Alerts.Confirmation.title, + message: [ + V.Connect._1, + V.boot, + V.Connect._2(iapManager.verificationDelayMinutes) + ].joined(separator: " "), + onDismiss: onCancel ) return } - pp_log(.app, .info, "Present paywall for features: \(features)") - setLater(.init(features, needsConfirmation: true)) { + pp_log(.app, .info, "Present paywall") + onCancelPaywall = onCancel + setLater(.init(features)) { paywallReason = $0 } } diff --git a/Packages/App/Sources/CommonIAP/Domain/AppUserLevel.swift b/Packages/App/Sources/CommonIAP/Domain/AppUserLevel.swift index 4097fc0e..012411d4 100644 --- a/Packages/App/Sources/CommonIAP/Domain/AppUserLevel.swift +++ b/Packages/App/Sources/CommonIAP/Domain/AppUserLevel.swift @@ -38,7 +38,7 @@ public enum AppUserLevel: Int, Sendable { } extension AppUserLevel { - public var isRestricted: Bool { + public var isBeta: Bool { self == .beta } } diff --git a/Packages/App/Sources/CommonLibrary/Business/ExtendedTunnel.swift b/Packages/App/Sources/CommonLibrary/Business/ExtendedTunnel.swift index 44258a96..cf72c503 100644 --- a/Packages/App/Sources/CommonLibrary/Business/ExtendedTunnel.swift +++ b/Packages/App/Sources/CommonLibrary/Business/ExtendedTunnel.swift @@ -29,6 +29,8 @@ import PassepartoutKit @MainActor public final class ExtendedTunnel: ObservableObject { + public static nonisolated let isManualKey = "isManual" + private let defaults: UserDefaults? private let tunnel: Tunnel @@ -102,7 +104,12 @@ extension ExtendedTunnel { public func install(_ profile: Profile) async throws { pp_log(.app, .notice, "Install profile \(profile.id)...") let newProfile = try processedProfile(profile) - try await tunnel.install(newProfile, connect: false, title: processedTitle) + try await tunnel.install( + newProfile, + connect: false, + options: .init(values: [Self.isManualKey: true as NSNumber]), + title: processedTitle + ) } public func connect(with profile: Profile, force: Bool = false) async throws { @@ -111,7 +118,12 @@ extension ExtendedTunnel { if !force && newProfile.isInteractive { throw AppError.interactiveLogin } - try await tunnel.install(newProfile, connect: true, title: processedTitle) + try await tunnel.install( + newProfile, + connect: true, + options: .init(values: [Self.isManualKey: true as NSNumber]), + title: processedTitle + ) } public func disconnect() async throws { @@ -176,10 +188,13 @@ private extension ExtendedTunnel { guard let self else { return } - guard tunnel.status == .active else { - return + if let lastErrorCode = value(forKey: TunnelEnvironmentKeys.lastErrorCode), + lastErrorCode != self.lastErrorCode { + self.lastErrorCode = lastErrorCode + } + if tunnel.status == .active { + dataCount = value(forKey: TunnelEnvironmentKeys.dataCount) } - dataCount = value(forKey: TunnelEnvironmentKeys.dataCount) } .store(in: &subscriptions) } diff --git a/Packages/App/Sources/CommonLibrary/Business/IAPManager.swift b/Packages/App/Sources/CommonLibrary/Business/IAPManager.swift index fc6ff67c..50eb4550 100644 --- a/Packages/App/Sources/CommonLibrary/Business/IAPManager.swift +++ b/Packages/App/Sources/CommonLibrary/Business/IAPManager.swift @@ -86,7 +86,7 @@ extension IAPManager { public func purchasableProducts(for products: [AppProduct]) async throws -> [InAppProduct] { do { - let inAppProducts = try await inAppHelper.fetchProducts() + let inAppProducts = try await inAppHelper.fetchProducts(timeout: Constants.shared.iap.productsTimeoutInterval) return products.compactMap { inAppProducts[$0] } @@ -126,8 +126,8 @@ extension IAPManager { // MARK: - Eligibility extension IAPManager { - public var isRestricted: Bool { - userLevel.isRestricted + public var isBeta: Bool { + userLevel.isBeta } public func isEligible(for feature: AppFeature) -> Bool { @@ -259,7 +259,7 @@ extension IAPManager { .store(in: &subscriptions) if withProducts { - let products = try await inAppHelper.fetchProducts() + let products = try await inAppHelper.fetchProducts(timeout: Constants.shared.iap.productsTimeoutInterval) pp_log(.App.iap, .info, "Available in-app products: \(products.map(\.key))") } } catch { @@ -267,10 +267,8 @@ extension IAPManager { } } } -} -private extension IAPManager { - func fetchLevelIfNeeded() async { + public func fetchLevelIfNeeded() async { guard userLevel == .undefined else { return } diff --git a/Packages/App/Sources/CommonLibrary/Domain/BundleConfiguration+AppGroup.swift b/Packages/App/Sources/CommonLibrary/Domain/BundleConfiguration+AppGroup.swift index 06a4c7c4..29d56965 100644 --- a/Packages/App/Sources/CommonLibrary/Domain/BundleConfiguration+AppGroup.swift +++ b/Packages/App/Sources/CommonLibrary/Domain/BundleConfiguration+AppGroup.swift @@ -36,14 +36,6 @@ extension BundleConfiguration { public static var urlForTunnelLog: URL { urlForCaches.appending(path: Constants.shared.log.tunnelPath) } - - public static var urlForBetaReceipt: URL? { -#if os(iOS) - urlForCaches.appending(path: Constants.shared.tunnel.betaReceiptPath) -#else - nil -#endif - } } // App Group container is not available on tvOS (#1007) diff --git a/Packages/App/Sources/CommonLibrary/Domain/Constants.swift b/Packages/App/Sources/CommonLibrary/Domain/Constants.swift index d048581f..519e6224 100644 --- a/Packages/App/Sources/CommonLibrary/Domain/Constants.swift +++ b/Packages/App/Sources/CommonLibrary/Domain/Constants.swift @@ -98,19 +98,42 @@ public struct Constants: Decodable, Sendable { } public struct Tunnel: Decodable, Sendable { + public struct Verification: Decodable, Sendable { + public struct Parameters: Decodable, Sendable { + public let delay: TimeInterval + + public let interval: TimeInterval + } + + public let production: Parameters + + public let beta: Parameters + } + public let profileTitleFormat: String public let refreshInterval: TimeInterval - public let betaReceiptPath: String + public let verification: Verification - public let eligibilityCheckInterval: TimeInterval + public func verificationDelayMinutes(isBeta: Bool) -> Int { + let params = verificationParameters(isBeta: isBeta) + return Int(params.delay / 60.0) + } + + public func verificationParameters(isBeta: Bool) -> Verification.Parameters { + isBeta ? verification.beta : verification.production + } } public struct API: Decodable, Sendable { public let timeoutInterval: TimeInterval } + public struct IAP: Decodable, Sendable { + public let productsTimeoutInterval: Int + } + public struct Log: Decodable, Sendable { public struct Formatter: Decodable, Sendable { enum CodingKeys: CodingKey { @@ -146,8 +169,6 @@ public struct Constants: Decodable, Sendable { public let sinceLast: TimeInterval public let options: LocalLogger.Options - - public let maxAge: TimeInterval? } public let bundleKey: String @@ -164,5 +185,7 @@ public struct Constants: Decodable, Sendable { public let api: API + public let iap: IAP + public let log: Log } diff --git a/Packages/App/Sources/CommonLibrary/IAP/IAPManager+Verify.swift b/Packages/App/Sources/CommonLibrary/IAP/IAPManager+Verify.swift index d9300751..d7a1e03d 100644 --- a/Packages/App/Sources/CommonLibrary/IAP/IAPManager+Verify.swift +++ b/Packages/App/Sources/CommonLibrary/IAP/IAPManager+Verify.swift @@ -57,3 +57,9 @@ extension IAPManager { } } } + +extension IAPManager { + public var verificationDelayMinutes: Int { + Constants.shared.tunnel.verificationDelayMinutes(isBeta: isBeta) + } +} diff --git a/Packages/App/Sources/CommonLibrary/Resources/Constants.json b/Packages/App/Sources/CommonLibrary/Resources/Constants.json index 22d74bbd..3584c5da 100644 --- a/Packages/App/Sources/CommonLibrary/Resources/Constants.json +++ b/Packages/App/Sources/CommonLibrary/Resources/Constants.json @@ -27,21 +27,32 @@ "tunnel": { "profileTitleFormat": "Passepartout: %@", "refreshInterval": 3.0, - "betaReceiptPath": "beta-receipt", - "eligibilityCheckInterval": 3600.0 + "verification": { + "production": { + "delay": 120.0, + "interval": 3600.0 + }, + "beta": { + "delay": 600.0, + "interval": 600.0 + } + } }, "api": { "timeoutInterval": 5.0 }, + "iap": { + "productsTimeoutInterval": 10.0 + }, "log": { "appPath": "app.log", "tunnelPath": "tunnel.log", - "sinceLast": 86400, + "sinceLast": 86400.0, "options": { "maxLevel": 3, "maxSize": 500000, "maxBufferedLines": 5000, - "maxAge": 86400 + "maxAge": 86400.0 }, "formatter": { "timestamp": "HH:mm:ss", diff --git a/Packages/App/Sources/CommonLibrary/Strategy/Processors.swift b/Packages/App/Sources/CommonLibrary/Strategy/Processors.swift index f4f417d8..592cbb49 100644 --- a/Packages/App/Sources/CommonLibrary/Strategy/Processors.swift +++ b/Packages/App/Sources/CommonLibrary/Strategy/Processors.swift @@ -45,5 +45,5 @@ public protocol AppTunnelProcessor: Sendable { } public protocol PacketTunnelProcessor: Sendable { - nonisolated func willStart(_ profile: Profile) throws -> Profile + nonisolated func willProcess(_ profile: Profile) throws -> Profile } diff --git a/Packages/App/Sources/CommonLibrary/Strategy/FallbackReceiptReader.swift b/Packages/App/Sources/CommonLibrary/Strategy/SharedReceiptReader.swift similarity index 64% rename from Packages/App/Sources/CommonLibrary/Strategy/FallbackReceiptReader.swift rename to Packages/App/Sources/CommonLibrary/Strategy/SharedReceiptReader.swift index e51ff195..95d9618c 100644 --- a/Packages/App/Sources/CommonLibrary/Strategy/FallbackReceiptReader.swift +++ b/Packages/App/Sources/CommonLibrary/Strategy/SharedReceiptReader.swift @@ -1,5 +1,5 @@ // -// FallbackReceiptReader.swift +// SharedReceiptReader.swift // Passepartout // // Created by Davide De Rosa on 11/6/24. @@ -27,19 +27,13 @@ import CommonUtils import Foundation import PassepartoutKit -public actor FallbackReceiptReader: AppReceiptReader { - private let mainReader: InAppReceiptReader - - private nonisolated let betaReader: InAppReceiptReader? +public actor SharedReceiptReader: AppReceiptReader { + private let reader: InAppReceiptReader private var pendingTask: Task? - public init( - main mainReader: InAppReceiptReader & Sendable, - beta betaReader: (InAppReceiptReader & Sendable)? - ) { - self.mainReader = mainReader - self.betaReader = betaReader + public init(reader: InAppReceiptReader & Sendable) { + self.reader = reader } public func receipt(at userLevel: AppUserLevel) async -> InAppReceipt? { @@ -59,17 +53,10 @@ public actor FallbackReceiptReader: AppReceiptReader { } } -private extension FallbackReceiptReader { +private extension SharedReceiptReader { func asyncReceipt(at userLevel: AppUserLevel) async -> InAppReceipt? { pp_log(.App.iap, .info, "\tParse receipt for user level \(userLevel)") - if userLevel == .beta, let betaReader { - pp_log(.App.iap, .info, "\tTestFlight, read beta receipt") - if let receipt = await betaReader.receipt() { - return receipt - } - pp_log(.App.iap, .info, "\tTestFlight, no beta receipt found!") - } - pp_log(.App.iap, .info, "\tProduction, read main receipt") - return await mainReader.receipt() + pp_log(.App.iap, .info, "\tRead receipt") + return await reader.receipt() } } diff --git a/Packages/App/Sources/CommonUtils/Extensions/Bundle+Extensions.swift b/Packages/App/Sources/CommonUtils/Extensions/Bundle+Extensions.swift index 9f022059..d40a9b28 100644 --- a/Packages/App/Sources/CommonUtils/Extensions/Bundle+Extensions.swift +++ b/Packages/App/Sources/CommonUtils/Extensions/Bundle+Extensions.swift @@ -26,12 +26,6 @@ import Foundation extension Bundle { - public var appStoreProductionReceiptURL: URL? { - appStoreReceiptURL? - .deletingLastPathComponent() - .appendingPathComponent("receipt") // could be "sandboxReceipt" - } - public func unsafeDecode(_ type: T.Type, filename: String) -> T { guard let jsonURL = url(forResource: filename, withExtension: "json") else { fatalError("Unable to find \(filename).json in bundle") diff --git a/Packages/App/Sources/CommonUtils/Extensions/TaskTimeout.swift b/Packages/App/Sources/CommonUtils/Extensions/TaskTimeout.swift new file mode 100644 index 00000000..9f7f1e23 --- /dev/null +++ b/Packages/App/Sources/CommonUtils/Extensions/TaskTimeout.swift @@ -0,0 +1,48 @@ +// +// TaskTimeout.swift +// Passepartout +// +// Created by Davide De Rosa on 2/3/25. +// Copyright (c) 2025 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 Foundation + +public struct TaskTimeoutError: Error { +} + +public func performTask(withTimeout timeout: Int, taskBlock: @escaping () async throws -> T) async throws -> T { + let task = Task { + let taskResult = try await taskBlock() + try Task.checkCancellation() + return taskResult + } + let timeoutTask = Task { + try await Task.sleep(for: .seconds(timeout)) + task.cancel() + } + do { + let result = try await task.value + timeoutTask.cancel() + return result + } catch { + throw TaskTimeoutError() + } +} diff --git a/Packages/App/Sources/CommonUtils/IAP/InApp.swift b/Packages/App/Sources/CommonUtils/IAP/InApp.swift index a451fdab..f32c06fe 100644 --- a/Packages/App/Sources/CommonUtils/IAP/InApp.swift +++ b/Packages/App/Sources/CommonUtils/IAP/InApp.swift @@ -71,12 +71,6 @@ public protocol InAppHelper { func restorePurchases() async throws } -extension InAppHelper { - public func fetchProducts() async throws -> [ProductType: InAppProduct] { - try await fetchProducts(timeout: 3) - } -} - public struct InAppReceipt: Sendable { public struct PurchaseReceipt: Sendable { public let productIdentifier: String? diff --git a/Packages/App/Sources/CommonUtils/IAP/StoreKitHelper.swift b/Packages/App/Sources/CommonUtils/IAP/StoreKitHelper.swift index befe7f75..a515989b 100644 --- a/Packages/App/Sources/CommonUtils/IAP/StoreKitHelper.swift +++ b/Packages/App/Sources/CommonUtils/IAP/StoreKitHelper.swift @@ -65,21 +65,8 @@ extension StoreKitHelper { } public func fetchProducts(timeout: Int) async throws -> [ProductType: InAppProduct] { - let skProducts = try await withThrowingTaskGroup(of: [Product]?.self) { group in - group.addTask { - try await Product.products(for: self.products.map(self.inAppIdentifier)) - } - group.addTask { - try await Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000_000) - return nil - } - for try await result in group { - if let products = result { - group.cancelAll() - return products - } - } - throw URLError(.timedOut) + let skProducts = try await performTask(withTimeout: timeout) { + try await Product.products(for: self.products.map(self.inAppIdentifier)) } return skProducts.reduce(into: [:]) { guard let pid = ProductType(rawValue: $1.id) else { diff --git a/Packages/App/Sources/CommonUtils/IAP/StoreKitReceiptReader.swift b/Packages/App/Sources/CommonUtils/IAP/StoreKitReceiptReader.swift index 368a5e2b..b10481fe 100644 --- a/Packages/App/Sources/CommonUtils/IAP/StoreKitReceiptReader.swift +++ b/Packages/App/Sources/CommonUtils/IAP/StoreKitReceiptReader.swift @@ -34,40 +34,9 @@ public final class StoreKitReceiptReader: InAppReceiptReader, Sendable { } public func receipt() async -> InAppReceipt? { - var startDate: Date - var elapsed: TimeInterval + let result = await entitlements() - startDate = Date() - logger.debug("Start fetching original build number...") - let originalBuildNumber: Int? - do { - switch try await AppTransaction.shared { - case .verified(let tx): - originalBuildNumber = Int(tx.originalAppVersion) - default: - originalBuildNumber = nil - } - } catch { - originalBuildNumber = nil - } - elapsed = -startDate.timeIntervalSinceNow - logger.debug("Fetched original build number: \(elapsed)") - - startDate = Date() - logger.debug("Start fetching transactions...") - var transactions: [Transaction] = [] - for await entitlement in Transaction.currentEntitlements { - switch entitlement { - case .verified(let tx): - transactions.append(tx) - default: - break - } - } - elapsed = -startDate.timeIntervalSinceNow - logger.debug("Fetched transactions: \(elapsed)") - - let purchaseReceipts = transactions + let purchaseReceipts = result.txs .compactMap { InAppReceipt.PurchaseReceipt( productIdentifier: $0.productID, @@ -77,6 +46,46 @@ public final class StoreKitReceiptReader: InAppReceiptReader, Sendable { ) } - return InAppReceipt(originalBuildNumber: originalBuildNumber, purchaseReceipts: purchaseReceipts) + return InAppReceipt(originalBuildNumber: result.build, purchaseReceipts: purchaseReceipts) + } +} + +private extension StoreKitReceiptReader { + func entitlements() async -> (build: Int?, txs: [Transaction]) { + async let build = Task { + let startDate = Date() + logger.debug("Start fetching original build number...") + let originalBuildNumber: Int? + do { + switch try await AppTransaction.shared { + case .verified(let tx): + originalBuildNumber = Int(tx.originalAppVersion) + default: + originalBuildNumber = nil + } + } catch { + originalBuildNumber = nil + } + let elapsed = -startDate.timeIntervalSinceNow + logger.debug("Fetched original build number: \(elapsed)") + return originalBuildNumber + } + async let txs = Task { + let startDate = Date() + logger.debug("Start fetching transactions...") + var transactions: [Transaction] = [] + for await entitlement in Transaction.currentEntitlements { + switch entitlement { + case .verified(let tx): + transactions.append(tx) + default: + break + } + } + let elapsed = -startDate.timeIntervalSinceNow + logger.debug("Fetched transactions: \(elapsed)") + return transactions + } + return await (build.value, txs.value) } } diff --git a/Packages/App/Sources/UILibrary/Business/AppContext.swift b/Packages/App/Sources/UILibrary/Business/AppContext.swift index 144f6284..d4841536 100644 --- a/Packages/App/Sources/UILibrary/Business/AppContext.swift +++ b/Packages/App/Sources/UILibrary/Business/AppContext.swift @@ -46,8 +46,6 @@ public final class AppContext: ObservableObject, Sendable { public let tunnel: ExtendedTunnel - private let tunnelReceiptURL: URL? - private let onEligibleFeaturesBlock: ((Set) async -> Void)? private var launchTask: Task? @@ -64,7 +62,6 @@ public final class AppContext: ObservableObject, Sendable { profileManager: ProfileManager, registry: Registry, tunnel: ExtendedTunnel, - tunnelReceiptURL: URL?, onEligibleFeaturesBlock: ((Set) async -> Void)? = nil ) { self.apiManager = apiManager @@ -74,7 +71,6 @@ public final class AppContext: ObservableObject, Sendable { self.profileManager = profileManager self.registry = registry self.tunnel = tunnel - self.tunnelReceiptURL = tunnelReceiptURL self.onEligibleFeaturesBlock = onEligibleFeaturesBlock subscriptions = [] } @@ -136,18 +132,6 @@ private extension AppContext { } .store(in: &subscriptions) - // copy release receipt to tunnel for TestFlight eligibility (once is enough, it won't change) - if let tunnelReceiptURL, - let appReceiptURL = Bundle.main.appStoreProductionReceiptURL { - do { - pp_log(.App.iap, .info, "\tCopy release receipt to tunnel...") - try? FileManager.default.removeItem(at: tunnelReceiptURL) - try FileManager.default.copyItem(at: appReceiptURL, to: tunnelReceiptURL) - } catch { - pp_log(.App.iap, .error, "\tUnable to copy release receipt to tunnel: \(error)") - } - } - do { pp_log(.app, .info, "\tFetch providers index...") try await apiManager.fetchIndex(from: API.shared) diff --git a/Packages/App/Sources/UILibrary/Business/InteractiveManager.swift b/Packages/App/Sources/UILibrary/Business/InteractiveManager.swift index 1a877c50..30e26047 100644 --- a/Packages/App/Sources/UILibrary/Business/InteractiveManager.swift +++ b/Packages/App/Sources/UILibrary/Business/InteractiveManager.swift @@ -29,7 +29,7 @@ import PassepartoutKit @MainActor public final class InteractiveManager: ObservableObject { - public typealias CompletionBlock = (Profile) async throws -> Void + public typealias CompletionBlock = (Profile) throws -> Void @Published public var isPresented = false @@ -48,9 +48,9 @@ public final class InteractiveManager: ObservableObject { isPresented = true } - public func complete() async throws { + public func complete() throws { isPresented = false let newProfile = try editor.build() - try await onComplete?(newProfile) + try onComplete?(newProfile) } } diff --git a/Packages/App/Sources/UILibrary/Business/ProfileEditor.swift b/Packages/App/Sources/UILibrary/Business/ProfileEditor.swift index d971f621..f65f3435 100644 --- a/Packages/App/Sources/UILibrary/Business/ProfileEditor.swift +++ b/Packages/App/Sources/UILibrary/Business/ProfileEditor.swift @@ -202,11 +202,9 @@ extension ProfileEditor { removedModules = [:] } - @discardableResult - public func save(to profileManager: ProfileManager, preferencesManager: PreferencesManager) async throws -> Profile { + public func save(_ profileToSave: Profile, to profileManager: ProfileManager, preferencesManager: PreferencesManager) async throws { do { - let newProfile = try build() - try await profileManager.save(newProfile, isLocal: true, remotelyShared: isShared) + try await profileManager.save(profileToSave, isLocal: true, remotelyShared: isShared) removedModules.keys.forEach { do { @@ -219,8 +217,6 @@ extension ProfileEditor { } } removedModules.removeAll() - - return newProfile } catch { pp_log(.App.profiles, .fault, "Unable to save edited profile: \(error)") throw error diff --git a/Packages/App/Sources/UILibrary/Extensions/AppCoordinatorConforming+Extensions.swift b/Packages/App/Sources/UILibrary/Extensions/AppCoordinatorConforming+Extensions.swift index 1e308a38..d21ce1f3 100644 --- a/Packages/App/Sources/UILibrary/Extensions/AppCoordinatorConforming+Extensions.swift +++ b/Packages/App/Sources/UILibrary/Extensions/AppCoordinatorConforming+Extensions.swift @@ -28,15 +28,23 @@ import Foundation import PassepartoutKit extension AppCoordinatorConforming { - public func onConnect(_ profile: Profile, force: Bool) async { + public func onConnect(_ profile: Profile, force: Bool, verify: Bool = true) async { do { - try iapManager.verify(profile) + if verify { + try iapManager.verify(profile) + } try await tunnel.connect(with: profile, force: force) } catch AppError.ineligibleProfile(let requiredFeatures) { - onPurchaseRequired(requiredFeatures) + onPurchaseRequired(requiredFeatures) { + Task { + await onConnect(profile, force: force, verify: false) + } + } } catch AppError.interactiveLogin { - onInteractiveLogin(profile) { - await onConnect($0, force: true) + onInteractiveLogin(profile) { newProfile in + Task { + await onConnect(newProfile, force: true, verify: verify) + } } } catch let ppError as PassepartoutError { switch ppError.code { diff --git a/Packages/App/Sources/UILibrary/L10n/AppError+L10n.swift b/Packages/App/Sources/UILibrary/L10n/AppError+L10n.swift index f9c468bc..0fb5f1f9 100644 --- a/Packages/App/Sources/UILibrary/L10n/AppError+L10n.swift +++ b/Packages/App/Sources/UILibrary/L10n/AppError+L10n.swift @@ -59,6 +59,12 @@ extension AppError: LocalizedError { } } +extension TaskTimeoutError: PassepartoutErrorMappable { + public var asPassepartoutError: PassepartoutError { + PassepartoutError(.timeout) + } +} + // MARK: - App side extension PassepartoutError: @retroactive LocalizedError { @@ -102,6 +108,9 @@ extension PassepartoutError: @retroactive LocalizedError { case .providerRequired: return Strings.Errors.App.Passepartout.providerRequired + case .timeout: + return Strings.Errors.App.Passepartout.timeout + case .unhandled: return reason?.localizedDescription diff --git a/Packages/App/Sources/UILibrary/L10n/SwiftGen+Strings.swift b/Packages/App/Sources/UILibrary/L10n/SwiftGen+Strings.swift index a9767399..13f13227 100644 --- a/Packages/App/Sources/UILibrary/L10n/SwiftGen+Strings.swift +++ b/Packages/App/Sources/UILibrary/L10n/SwiftGen+Strings.swift @@ -139,6 +139,8 @@ public enum Strings { public static let parsing = Strings.tr("Localizable", "errors.app.passepartout.parsing", fallback: "Unable to parse file.") /// No provider selected. public static let providerRequired = Strings.tr("Localizable", "errors.app.passepartout.provider_required", fallback: "No provider selected.") + /// The operation timed out. + public static let timeout = Strings.tr("Localizable", "errors.app.passepartout.timeout", fallback: "The operation timed out.") } } public enum Tunnel { @@ -596,8 +598,6 @@ public enum Strings { } } public enum App { - /// Verifying purchases... - public static let verifyingPurchases = Strings.tr("Localizable", "views.app.verifying_purchases", fallback: "Verifying purchases...") public enum Folders { /// My profiles public static let `default` = Strings.tr("Localizable", "views.app.folders.default", fallback: "My profiles") @@ -740,6 +740,12 @@ public enum Strings { public static let message = Strings.tr("Localizable", "views.paywall.alerts.confirmation.message", fallback: "This profile requires paid features to work.") /// Purchase required public static let title = Strings.tr("Localizable", "views.paywall.alerts.confirmation.title", fallback: "Purchase required") + public enum Message { + /// You may test the connection for %d minutes. + public static func connect(_ p1: Int) -> String { + return Strings.tr("Localizable", "views.paywall.alerts.confirmation.message.connect", p1, fallback: "You may test the connection for %d minutes.") + } + } } public enum Pending { /// The purchase is pending external confirmation. The feature will be credited upon approval. @@ -751,11 +757,19 @@ public enum Strings { /// Restricted public static let title = Strings.tr("Localizable", "views.paywall.alerts.restricted.title", fallback: "Restricted") } - public enum Verifying { + public enum Verification { + /// This may take a little longer if your device was just started. + public static let boot = Strings.tr("Localizable", "views.paywall.alerts.verification.boot", fallback: "This may take a little longer if your device was just started.") /// Please wait while your purchases are being verified. - public static let message = Strings.tr("Localizable", "views.paywall.alerts.verifying.message", fallback: "Please wait while your purchases are being verified.") - /// Verifying - public static let title = Strings.tr("Localizable", "views.paywall.alerts.verifying.title", fallback: "Verifying") + public static let edit = Strings.tr("Localizable", "views.paywall.alerts.verification.edit", fallback: "Please wait while your purchases are being verified.") + public enum Connect { + /// Your purchases are being verified. + public static let _1 = Strings.tr("Localizable", "views.paywall.alerts.verification.connect.1", fallback: "Your purchases are being verified.") + /// If verification cannot be completed, the connection will end in %d minutes. + public static func _2(_ p1: Int) -> String { + return Strings.tr("Localizable", "views.paywall.alerts.verification.connect.2", p1, fallback: "If verification cannot be completed, the connection will end in %d minutes.") + } + } } } public enum Rows { @@ -895,6 +909,10 @@ public enum Strings { } } } + public enum Verification { + /// Verifying... + public static let message = Strings.tr("Localizable", "views.verification.message", fallback: "Verifying...") + } public enum Version { /// %@ is a project maintained by %@. /// diff --git a/Packages/App/Sources/UILibrary/Previews/AppContext+Previews.swift b/Packages/App/Sources/UILibrary/Previews/AppContext+Previews.swift index b0f7c664..5208ba44 100644 --- a/Packages/App/Sources/UILibrary/Previews/AppContext+Previews.swift +++ b/Packages/App/Sources/UILibrary/Previews/AppContext+Previews.swift @@ -65,8 +65,7 @@ extension AppContext { preferencesManager: PreferencesManager(), profileManager: profileManager, registry: Registry(), - tunnel: tunnel, - tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt + tunnel: tunnel ) }() } diff --git a/Packages/App/Sources/UILibrary/Resources/de.lproj/Localizable.strings b/Packages/App/Sources/UILibrary/Resources/de.lproj/Localizable.strings index c473d3a0..b8d73c35 100644 --- a/Packages/App/Sources/UILibrary/Resources/de.lproj/Localizable.strings +++ b/Packages/App/Sources/UILibrary/Resources/de.lproj/Localizable.strings @@ -36,6 +36,7 @@ "errors.app.passepartout.no_active_modules" = "Das Profil hat keine aktiven Module."; "errors.app.passepartout.parsing" = "Datei konnte nicht analysiert werden."; "errors.app.passepartout.provider_required" = "Kein Anbieter ausgewählt."; +"errors.app.passepartout.timeout" = "Die Operation hat das Zeitlimit überschritten."; "errors.app.permission_denied" = "Zugriff verweigert"; "errors.app.tunnel" = "Aktion konnte nicht ausgeführt werden."; "errors.tunnel.auth" = "Authentifizierung fehlgeschlagen"; @@ -222,7 +223,6 @@ "views.app.toolbar.new_profile.empty" = "Leeres Profil"; "views.app.toolbar.new_profile.provider" = "Anbieter"; "views.app.tv.header" = "Öffne %@ auf deinem iOS- oder macOS-Gerät und aktiviere den \"%@\"-Schalter eines Profils, damit es hier erscheint."; -"views.app.verifying_purchases" = "Käufe werden überprüft..."; "views.app_menu.items.quit" = "Beende %@"; "views.diagnostics.alerts.report_issue.email" = "Das Gerät ist nicht zum Senden von E-Mails konfiguriert."; "views.diagnostics.openvpn.rows.server_configuration" = "Serverkonfiguration"; @@ -244,12 +244,15 @@ "views.migration.sections.main.header" = "Wähle unten die Profile aus alten Versionen von %@ aus, die du importieren möchtest. Wenn deine Profile in iCloud gespeichert sind, kann es eine Weile dauern, bis sie synchronisiert sind. Wenn du sie jetzt nicht siehst, komm später zurück."; "views.migration.title" = "Migrieren"; "views.paywall.alerts.confirmation.message" = "Dieses Profil erfordert kostenpflichtige Funktionen, um zu funktionieren."; +"views.paywall.alerts.confirmation.message.connect" = "Sie können die Verbindung für %d Minuten testen."; "views.paywall.alerts.confirmation.title" = "Kauf erforderlich"; "views.paywall.alerts.pending.message" = "Der Kauf wartet auf eine externe Bestätigung. Die Funktion wird nach Genehmigung gutgeschrieben."; "views.paywall.alerts.restricted.message" = "Einige Funktionen sind in dieser Version nicht verfügbar."; "views.paywall.alerts.restricted.title" = "Eingeschränkt"; -"views.paywall.alerts.verifying.message" = "Bitte warten Sie, während Ihre Käufe überprüft werden."; -"views.paywall.alerts.verifying.title" = "Überprüfung"; +"views.paywall.alerts.verification.boot" = "Dies kann etwas länger dauern, wenn Ihr Gerät gerade gestartet wurde."; +"views.paywall.alerts.verification.connect.1" = "Ihre Käufe werden überprüft."; +"views.paywall.alerts.verification.connect.2" = "Falls die Überprüfung nicht abgeschlossen werden kann, wird die Verbindung in %d Minuten beendet."; +"views.paywall.alerts.verification.edit" = "Bitte warten Sie, während Ihre Käufe überprüft werden."; "views.paywall.rows.restore_purchases" = "Käufe wiederherstellen"; "views.paywall.sections.all_features.header" = "Die Vollversion enthält"; "views.paywall.sections.full_products.header" = "Vollversion"; @@ -286,6 +289,7 @@ "views.ui.connection_status.on_demand_suffix" = " (auf Anfrage)"; "views.ui.purchase_required.purchase.help" = "Kauf erforderlich"; "views.ui.purchase_required.restricted.help" = "Funktion eingeschränkt"; +"views.verification.message" = "Überprüfung"; "views.version.extra" = "%@ ist ein Projekt, das von %@ gepflegt wird.\n\nDer Quellcode ist öffentlich auf GitHub unter der GPLv3-Lizenz verfügbar. Du findest die Links auf der Startseite."; "views.vpn.category.any" = "Alle Kategorien"; "views.vpn.no_servers" = "Keine Server"; diff --git a/Packages/App/Sources/UILibrary/Resources/el.lproj/Localizable.strings b/Packages/App/Sources/UILibrary/Resources/el.lproj/Localizable.strings index 4054144c..e18dc30d 100644 --- a/Packages/App/Sources/UILibrary/Resources/el.lproj/Localizable.strings +++ b/Packages/App/Sources/UILibrary/Resources/el.lproj/Localizable.strings @@ -36,6 +36,7 @@ "errors.app.passepartout.no_active_modules" = "Το προφίλ δεν έχει ενεργές μονάδες."; "errors.app.passepartout.parsing" = "Δεν ήταν δυνατή η ανάλυση αρχείου."; "errors.app.passepartout.provider_required" = "Δεν έχει επιλεγεί πάροχος."; +"errors.app.passepartout.timeout" = "Η λειτουργία έληξε λόγω χρονικού ορίου."; "errors.app.permission_denied" = "Άρνηση άδειας"; "errors.app.tunnel" = "Δεν ήταν δυνατή η εκτέλεση της ενέργειας."; "errors.tunnel.auth" = "Η επαλήθευση απέτυχε"; @@ -222,7 +223,6 @@ "views.app.toolbar.new_profile.empty" = "Κενό προφίλ"; "views.app.toolbar.new_profile.provider" = "Πάροχος"; "views.app.tv.header" = "Ανοίξτε %@ στη συσκευή σας iOS ή macOS και ενεργοποιήστε το διακόπτη \"%@\" ενός προφίλ για να εμφανιστεί εδώ."; -"views.app.verifying_purchases" = "Επαλήθευση αγορών..."; "views.app_menu.items.quit" = "Κλείσιμο %@"; "views.diagnostics.alerts.report_issue.email" = "Η συσκευή δεν είναι ρυθμισμένη για αποστολή email."; "views.diagnostics.openvpn.rows.server_configuration" = "Διαμόρφωση διακομιστή"; @@ -244,12 +244,15 @@ "views.migration.sections.main.header" = "Επιλέξτε παρακάτω τα προφίλ από τις παλιές εκδόσεις του %@ που θέλετε να εισάγετε. Εάν τα προφίλ σας είναι αποθηκευμένα στο iCloud, μπορεί να χρειαστεί λίγος χρόνος για να συγχρονιστούν. Εάν δεν τα βλέπετε τώρα, επιστρέψτε αργότερα."; "views.migration.title" = "Μεταφορά"; "views.paywall.alerts.confirmation.message" = "Αυτό το προφίλ απαιτεί επί πληρωμή λειτουργίες για να λειτουργήσει."; +"views.paywall.alerts.confirmation.message.connect" = "Μπορείτε να δοκιμάσετε τη σύνδεση για %d λεπτά."; "views.paywall.alerts.confirmation.title" = "Απαιτείται αγορά"; "views.paywall.alerts.pending.message" = "Η αγορά εκκρεμεί για εξωτερική επιβεβαίωση. Η λειτουργία θα πιστωθεί μετά την έγκριση."; "views.paywall.alerts.restricted.message" = "Ορισμένες λειτουργίες δεν είναι διαθέσιμες σε αυτήν την έκδοση."; "views.paywall.alerts.restricted.title" = "Περιορισμένο"; -"views.paywall.alerts.verifying.message" = "Παρακαλώ περιμένετε ενώ οι αγορές σας επαληθεύονται."; -"views.paywall.alerts.verifying.title" = "Επαλήθευση"; +"views.paywall.alerts.verification.boot" = "Αυτό μπορεί να διαρκέσει λίγο περισσότερο αν η συσκευή σας μόλις ξεκίνησε."; +"views.paywall.alerts.verification.connect.1" = "Οι αγορές σας επαληθεύονται."; +"views.paywall.alerts.verification.connect.2" = "Αν η επαλήθευση δεν ολοκληρωθεί, η σύνδεση θα τερματιστεί σε %d λεπτά."; +"views.paywall.alerts.verification.edit" = "Παρακαλώ περιμένετε όσο επαληθεύονται οι αγορές σας."; "views.paywall.rows.restore_purchases" = "Επαναφορά αγορών"; "views.paywall.sections.all_features.header" = "Η πλήρης έκδοση περιλαμβάνει"; "views.paywall.sections.full_products.header" = "Πλήρης έκδοση"; @@ -286,6 +289,7 @@ "views.ui.connection_status.on_demand_suffix" = " (κατ' απαίτηση)"; "views.ui.purchase_required.purchase.help" = "Απαιτείται αγορά"; "views.ui.purchase_required.restricted.help" = "Η λειτουργία είναι περιορισμένη"; +"views.verification.message" = "Επαλήθευση"; "views.version.extra" = "Το %@ είναι ένα έργο που συντηρείται από τον/την %@.\n\nΟ πηγαίος κώδικας είναι διαθέσιμος δημόσια στο GitHub υπό την άδεια GPLv3. Μπορείτε να βρείτε τους συνδέσμους στην αρχική σελίδα."; "views.vpn.category.any" = "Όλες οι κατηγορίες"; "views.vpn.no_servers" = "Δεν υπάρχουν διακομιστές"; diff --git a/Packages/App/Sources/UILibrary/Resources/en.lproj/Localizable.strings b/Packages/App/Sources/UILibrary/Resources/en.lproj/Localizable.strings index 475d5e32..dcef1d69 100644 --- a/Packages/App/Sources/UILibrary/Resources/en.lproj/Localizable.strings +++ b/Packages/App/Sources/UILibrary/Resources/en.lproj/Localizable.strings @@ -39,7 +39,6 @@ "views.about.credits.notices" = "Notices"; "views.about.credits.translations" = "Translations"; -"views.app.verifying_purchases" = "Verifying purchases..."; "views.app.installed_profile.none.name" = "No profile"; "views.app.installed_profile.none.status" = "Tap list to connect"; "views.app.profile.no_modules" = "No active modules"; @@ -87,8 +86,11 @@ "views.paywall.rows.restore_purchases" = "Restore purchases"; "views.paywall.alerts.confirmation.title" = "Purchase required"; "views.paywall.alerts.confirmation.message" = "This profile requires paid features to work."; -"views.paywall.alerts.verifying.title" = "Verifying"; -"views.paywall.alerts.verifying.message" = "Please wait while your purchases are being verified."; +"views.paywall.alerts.confirmation.message.connect" = "You may test the connection for %d minutes."; +"views.paywall.alerts.verification.connect.1" = "Your purchases are being verified."; +"views.paywall.alerts.verification.connect.2" = "If verification cannot be completed, the connection will end in %d minutes."; +"views.paywall.alerts.verification.edit" = "Please wait while your purchases are being verified."; +"views.paywall.alerts.verification.boot" = "This may take a little longer if your device was just started."; "views.paywall.alerts.restricted.title" = "Restricted"; "views.paywall.alerts.restricted.message" = "Some features are unavailable in this build."; "views.paywall.alerts.pending.message" = "The purchase is pending external confirmation. The feature will be credited upon approval."; @@ -126,6 +128,8 @@ "views.ui.purchase_required.purchase.help" = "Purchase required"; "views.ui.purchase_required.restricted.help" = "Feature is restricted"; +"views.verification.message" = "Verifying..."; + "views.version.extra" = "%@ is a project maintained by %@.\n\nSource code is publicly available on GitHub under the GPLv3, you can find links in the home page."; "views.vpn.category.any" = "All categories"; @@ -358,6 +362,7 @@ "errors.app.passepartout.no_active_modules" = "The profile has no active modules."; "errors.app.passepartout.parsing" = "Unable to parse file."; "errors.app.passepartout.provider_required" = "No provider selected."; +"errors.app.passepartout.timeout" = "The operation timed out."; "errors.tunnel.auth" = "Auth failed"; "errors.tunnel.compression" = "Compression unsupported"; diff --git a/Packages/App/Sources/UILibrary/Resources/es.lproj/Localizable.strings b/Packages/App/Sources/UILibrary/Resources/es.lproj/Localizable.strings index 58cae043..e5443e53 100644 --- a/Packages/App/Sources/UILibrary/Resources/es.lproj/Localizable.strings +++ b/Packages/App/Sources/UILibrary/Resources/es.lproj/Localizable.strings @@ -36,6 +36,7 @@ "errors.app.passepartout.no_active_modules" = "El perfil no tiene módulos activos."; "errors.app.passepartout.parsing" = "No se pudo analizar el archivo."; "errors.app.passepartout.provider_required" = "No se ha seleccionado proveedor."; +"errors.app.passepartout.timeout" = "La operación agotó el tiempo de espera."; "errors.app.permission_denied" = "Permiso denegado"; "errors.app.tunnel" = "No se pudo ejecutar la operación."; "errors.tunnel.auth" = "Autenticación fallida"; @@ -222,7 +223,6 @@ "views.app.toolbar.new_profile.empty" = "Perfil vacío"; "views.app.toolbar.new_profile.provider" = "Proveedor"; "views.app.tv.header" = "Abre %@ en tu dispositivo iOS o macOS y habilita el interruptor \"%@\" de un perfil para que aparezca aquí."; -"views.app.verifying_purchases" = "Verificando compras..."; "views.app_menu.items.quit" = "Salir de %@"; "views.diagnostics.alerts.report_issue.email" = "El dispositivo no está configurado para enviar correos electrónicos."; "views.diagnostics.openvpn.rows.server_configuration" = "Configuración del servidor"; @@ -244,12 +244,15 @@ "views.migration.sections.main.header" = "Selecciona a continuación los perfiles de versiones antiguas de %@ que deseas importar. Si tus perfiles están almacenados en iCloud, pueden tardar un poco en sincronizarse. Si no los ves ahora, por favor regresa más tarde."; "views.migration.title" = "Migrar"; "views.paywall.alerts.confirmation.message" = "Este perfil requiere características de pago para funcionar."; +"views.paywall.alerts.confirmation.message.connect" = "Puedes probar la conexión durante %d minutos."; "views.paywall.alerts.confirmation.title" = "Compra requerida"; "views.paywall.alerts.pending.message" = "La compra está pendiente de confirmación externa. La característica será acreditada tras la aprobación."; "views.paywall.alerts.restricted.message" = "Algunas características no están disponibles en esta versión."; "views.paywall.alerts.restricted.title" = "Restringido"; -"views.paywall.alerts.verifying.message" = "Por favor, espere mientras se verifican sus compras."; -"views.paywall.alerts.verifying.title" = "Verificación"; +"views.paywall.alerts.verification.boot" = "Esto puede tardar un poco más si tu dispositivo acaba de iniciarse."; +"views.paywall.alerts.verification.connect.1" = "Tus compras están siendo verificadas."; +"views.paywall.alerts.verification.connect.2" = "Si la verificación no se completa, la conexión finalizará en %d minutos."; +"views.paywall.alerts.verification.edit" = "Por favor, espera mientras verificamos tus compras."; "views.paywall.rows.restore_purchases" = "Restaurar compras"; "views.paywall.sections.all_features.header" = "La versión completa incluye"; "views.paywall.sections.full_products.header" = "Versión completa"; @@ -286,6 +289,7 @@ "views.ui.connection_status.on_demand_suffix" = " (a demanda)"; "views.ui.purchase_required.purchase.help" = "Compra requerida"; "views.ui.purchase_required.restricted.help" = "Función restringida"; +"views.verification.message" = "Verificación"; "views.version.extra" = "%@ es un proyecto mantenido por %@.\n\nEl código fuente está disponible públicamente en GitHub bajo la GPLv3, puedes encontrar los enlaces en la página principal."; "views.vpn.category.any" = "Todas las categorías"; "views.vpn.no_servers" = "No hay servidores"; diff --git a/Packages/App/Sources/UILibrary/Resources/fr.lproj/Localizable.strings b/Packages/App/Sources/UILibrary/Resources/fr.lproj/Localizable.strings index 0803e65c..0e6755b6 100644 --- a/Packages/App/Sources/UILibrary/Resources/fr.lproj/Localizable.strings +++ b/Packages/App/Sources/UILibrary/Resources/fr.lproj/Localizable.strings @@ -36,6 +36,7 @@ "errors.app.passepartout.no_active_modules" = "Le profil n'a pas de modules actifs."; "errors.app.passepartout.parsing" = "Impossible d'analyser le fichier."; "errors.app.passepartout.provider_required" = "Aucun fournisseur sélectionné."; +"errors.app.passepartout.timeout" = "L'opération a expiré."; "errors.app.permission_denied" = "Permission refusée"; "errors.app.tunnel" = "Impossible d'exécuter l'opération."; "errors.tunnel.auth" = "Échec de l'authentification"; @@ -222,7 +223,6 @@ "views.app.toolbar.new_profile.empty" = "Profil vide"; "views.app.toolbar.new_profile.provider" = "Fournisseur"; "views.app.tv.header" = "Ouvrez %@ sur votre appareil iOS ou macOS et activez l'interrupteur \"%@\" d'un profil pour le faire apparaître ici."; -"views.app.verifying_purchases" = "Vérification des achats..."; "views.app_menu.items.quit" = "Quitter %@"; "views.diagnostics.alerts.report_issue.email" = "L'appareil n'est pas configuré pour envoyer des e-mails."; "views.diagnostics.openvpn.rows.server_configuration" = "Configuration du serveur"; @@ -244,12 +244,15 @@ "views.migration.sections.main.header" = "Sélectionnez ci-dessous les profils des anciennes versions de %@ que vous souhaitez importer. Si vos profils sont stockés sur iCloud, ils peuvent mettre un certain temps à se synchroniser. Si vous ne les voyez pas maintenant, revenez plus tard."; "views.migration.title" = "Migrer"; "views.paywall.alerts.confirmation.message" = "Ce profil nécessite des fonctionnalités payantes pour fonctionner."; +"views.paywall.alerts.confirmation.message.connect" = "Vous pouvez tester la connexion pendant %d minutes."; "views.paywall.alerts.confirmation.title" = "Achat requis"; "views.paywall.alerts.pending.message" = "L'achat est en attente de confirmation externe. La fonctionnalité sera créditée une fois approuvée."; "views.paywall.alerts.restricted.message" = "Certaines fonctionnalités ne sont pas disponibles dans cette version."; "views.paywall.alerts.restricted.title" = "Restreint"; -"views.paywall.alerts.verifying.message" = "Veuillez patienter pendant la vérification de vos achats."; -"views.paywall.alerts.verifying.title" = "Vérification"; +"views.paywall.alerts.verification.boot" = "Cela peut prendre un peu plus de temps si votre appareil vient d’être démarré."; +"views.paywall.alerts.verification.connect.1" = "Vos achats sont en cours de vérification."; +"views.paywall.alerts.verification.connect.2" = "Si la vérification ne peut être complétée, la connexion s’arrêtera dans %d minutes."; +"views.paywall.alerts.verification.edit" = "Veuillez patienter pendant la vérification de vos achats."; "views.paywall.rows.restore_purchases" = "Restaurer les achats"; "views.paywall.sections.all_features.header" = "La version complète inclut"; "views.paywall.sections.full_products.header" = "Version complète"; @@ -286,6 +289,7 @@ "views.ui.connection_status.on_demand_suffix" = " (à la demande)"; "views.ui.purchase_required.purchase.help" = "Achat requis"; "views.ui.purchase_required.restricted.help" = "Fonction restreinte"; +"views.verification.message" = "Vérification"; "views.version.extra" = "%@ est un projet maintenu par %@.\n\nLe code source est disponible publiquement sur GitHub sous la licence GPLv3, vous pouvez trouver les liens sur la page d'accueil."; "views.vpn.category.any" = "Toutes les catégories"; "views.vpn.no_servers" = "Aucun serveur"; diff --git a/Packages/App/Sources/UILibrary/Resources/it.lproj/Localizable.strings b/Packages/App/Sources/UILibrary/Resources/it.lproj/Localizable.strings index 709a33c0..14adaf33 100644 --- a/Packages/App/Sources/UILibrary/Resources/it.lproj/Localizable.strings +++ b/Packages/App/Sources/UILibrary/Resources/it.lproj/Localizable.strings @@ -36,6 +36,7 @@ "errors.app.passepartout.no_active_modules" = "Il profilo non ha moduli attivi."; "errors.app.passepartout.parsing" = "Impossibile analizzare il file."; "errors.app.passepartout.provider_required" = "Nessun provider selezionato."; +"errors.app.passepartout.timeout" = "L'operazione ha superato il tempo limite."; "errors.app.permission_denied" = "Permesso negato"; "errors.app.tunnel" = "Impossibile eseguire l'operazione."; "errors.tunnel.auth" = "Autenticazione fallita"; @@ -222,7 +223,6 @@ "views.app.toolbar.new_profile.empty" = "Profilo vuoto"; "views.app.toolbar.new_profile.provider" = "Provider"; "views.app.tv.header" = "Apri %@ sul tuo dispositivo iOS o macOS e abilita l'interruttore \"%@\" di un profilo per farlo apparire qui."; -"views.app.verifying_purchases" = "Verifica degli acquisti..."; "views.app_menu.items.quit" = "Esci da %@"; "views.diagnostics.alerts.report_issue.email" = "Il dispositivo non è configurato per inviare e-mail."; "views.diagnostics.openvpn.rows.server_configuration" = "Configurazione del server"; @@ -244,12 +244,15 @@ "views.migration.sections.main.header" = "Seleziona di seguito i profili dalle versioni precedenti di %@ che vuoi importare. Se i tuoi profili sono archiviati su iCloud, potrebbero impiegare un po' a sincronizzarsi. Se non li vedi ora, torna più tardi."; "views.migration.title" = "Migra"; "views.paywall.alerts.confirmation.message" = "Questo profilo richiede funzionalità a pagamento per funzionare."; +"views.paywall.alerts.confirmation.message.connect" = "Puoi provare la connessione per %d minuti."; "views.paywall.alerts.confirmation.title" = "Acquisto richiesto"; "views.paywall.alerts.pending.message" = "L'acquisto è in attesa di conferma esterna. La funzionalità verrà accreditata dopo l'approvazione."; "views.paywall.alerts.restricted.message" = "Alcune funzionalità non sono disponibili in questa versione."; "views.paywall.alerts.restricted.title" = "Ristretto"; -"views.paywall.alerts.verifying.message" = "Attendere mentre i tuoi acquisti vengono verificati."; -"views.paywall.alerts.verifying.title" = "Verifica"; +"views.paywall.alerts.verification.boot" = "Questo potrebbe richiedere più tempo se il dispositivo è stato appena avviato."; +"views.paywall.alerts.verification.connect.1" = "I tuoi acquisti sono in fase di verifica."; +"views.paywall.alerts.verification.connect.2" = "Se la verifica non può essere completata, la connessione terminerà tra %d minuti."; +"views.paywall.alerts.verification.edit" = "Attendere mentre i tuoi acquisti vengono verificati."; "views.paywall.rows.restore_purchases" = "Ripristina acquisti"; "views.paywall.sections.all_features.header" = "La versione completa include"; "views.paywall.sections.full_products.header" = "Versione completa"; @@ -286,6 +289,7 @@ "views.ui.connection_status.on_demand_suffix" = " (on-demand)"; "views.ui.purchase_required.purchase.help" = "Acquisto richiesto"; "views.ui.purchase_required.restricted.help" = "Funzionalità ristretta"; +"views.verification.message" = "Verifica"; "views.version.extra" = "%@ è un progetto mantenuto da %@.\n\nIl codice sorgente è pubblicamente disponibile su GitHub sotto la licenza GPLv3, puoi trovare i link nella home page."; "views.vpn.category.any" = "Tutte le categorie"; "views.vpn.no_servers" = "Nessun server"; diff --git a/Packages/App/Sources/UILibrary/Resources/nl.lproj/Localizable.strings b/Packages/App/Sources/UILibrary/Resources/nl.lproj/Localizable.strings index bb64d6aa..3971665c 100644 --- a/Packages/App/Sources/UILibrary/Resources/nl.lproj/Localizable.strings +++ b/Packages/App/Sources/UILibrary/Resources/nl.lproj/Localizable.strings @@ -36,6 +36,7 @@ "errors.app.passepartout.no_active_modules" = "Het profiel heeft geen actieve modules."; "errors.app.passepartout.parsing" = "Kan bestand niet parseren."; "errors.app.passepartout.provider_required" = "Geen provider geselecteerd."; +"errors.app.passepartout.timeout" = "De bewerking is verlopen."; "errors.app.permission_denied" = "Toegang geweigerd"; "errors.app.tunnel" = "Actie kan niet worden uitgevoerd."; "errors.tunnel.auth" = "Verificatie mislukt"; @@ -222,7 +223,6 @@ "views.app.toolbar.new_profile.empty" = "Leeg profiel"; "views.app.toolbar.new_profile.provider" = "Provider"; "views.app.tv.header" = "Open %@ op je iOS- of macOS-apparaat en schakel de \"%@\"-schakelaar van een profiel in om het hier te laten verschijnen."; -"views.app.verifying_purchases" = "Aankopen worden geverifieerd..."; "views.app_menu.items.quit" = "Stop %@"; "views.diagnostics.alerts.report_issue.email" = "Het apparaat is niet geconfigureerd om e-mails te verzenden."; "views.diagnostics.openvpn.rows.server_configuration" = "Serverconfiguratie"; @@ -244,12 +244,15 @@ "views.migration.sections.main.header" = "Selecteer hieronder de profielen van oudere versies van %@ die je wilt importeren. Als je profielen zijn opgeslagen in iCloud, kan het even duren voordat ze worden gesynchroniseerd. Als je ze nu niet ziet, kom dan later terug."; "views.migration.title" = "Migreren"; "views.paywall.alerts.confirmation.message" = "Dit profiel vereist betaalde functies om te werken."; +"views.paywall.alerts.confirmation.message.connect" = "Je kunt de verbinding %d minuten testen."; "views.paywall.alerts.confirmation.title" = "Aankoop vereist"; "views.paywall.alerts.pending.message" = "De aankoop wacht op externe bevestiging. De functie wordt na goedkeuring gecrediteerd."; "views.paywall.alerts.restricted.message" = "Sommige functies zijn niet beschikbaar in deze versie."; "views.paywall.alerts.restricted.title" = "Beperkt"; -"views.paywall.alerts.verifying.message" = "Even geduld terwijl uw aankopen worden geverifieerd."; -"views.paywall.alerts.verifying.title" = "Verifiëren"; +"views.paywall.alerts.verification.boot" = "Dit kan iets langer duren als je apparaat net is opgestart."; +"views.paywall.alerts.verification.connect.1" = "Je aankopen worden geverifieerd."; +"views.paywall.alerts.verification.connect.2" = "Als de verificatie niet kan worden voltooid, wordt de verbinding over %d minuten beëindigd."; +"views.paywall.alerts.verification.edit" = "Even geduld terwijl we je aankopen verifiëren."; "views.paywall.rows.restore_purchases" = "Aankopen herstellen"; "views.paywall.sections.all_features.header" = "De volledige versie bevat"; "views.paywall.sections.full_products.header" = "Volledige versie"; @@ -286,6 +289,7 @@ "views.ui.connection_status.on_demand_suffix" = " (op aanvraag)"; "views.ui.purchase_required.purchase.help" = "Aankoop vereist"; "views.ui.purchase_required.restricted.help" = "Functie is beperkt"; +"views.verification.message" = "Verifiëren"; "views.version.extra" = "%@ is een project onderhouden door %@.\n\nDe broncode is openbaar beschikbaar op GitHub onder de GPLv3-licentie. Je kunt links vinden op de startpagina."; "views.vpn.category.any" = "Alle categorieën"; "views.vpn.no_servers" = "Geen servers"; diff --git a/Packages/App/Sources/UILibrary/Resources/pl.lproj/Localizable.strings b/Packages/App/Sources/UILibrary/Resources/pl.lproj/Localizable.strings index c50f84de..4368d696 100644 --- a/Packages/App/Sources/UILibrary/Resources/pl.lproj/Localizable.strings +++ b/Packages/App/Sources/UILibrary/Resources/pl.lproj/Localizable.strings @@ -36,6 +36,7 @@ "errors.app.passepartout.no_active_modules" = "Profil nie ma aktywnych modułów."; "errors.app.passepartout.parsing" = "Nie można przeanalizować pliku."; "errors.app.passepartout.provider_required" = "Nie wybrano dostawcy."; +"errors.app.passepartout.timeout" = "Operacja przekroczyła limit czasu."; "errors.app.permission_denied" = "Brak uprawnień"; "errors.app.tunnel" = "Nie można wykonać operacji."; "errors.tunnel.auth" = "Błąd uwierzytelniania"; @@ -222,7 +223,6 @@ "views.app.toolbar.new_profile.empty" = "Pusty profil"; "views.app.toolbar.new_profile.provider" = "Dostawca"; "views.app.tv.header" = "Otwórz %@ na swoim urządzeniu iOS lub macOS i włącz przełącznik \"%@\" w profilu, aby pojawił się tutaj."; -"views.app.verifying_purchases" = "Weryfikacja zakupów..."; "views.app_menu.items.quit" = "Zamknij %@"; "views.diagnostics.alerts.report_issue.email" = "Urządzenie nie jest skonfigurowane do wysyłania wiadomości e-mail."; "views.diagnostics.openvpn.rows.server_configuration" = "Konfiguracja serwera"; @@ -244,12 +244,15 @@ "views.migration.sections.main.header" = "Wybierz poniżej profile ze starszych wersji %@, które chcesz zaimportować. Jeśli Twoje profile są przechowywane w iCloud, synchronizacja może zająć trochę czasu. Jeśli ich teraz nie widzisz, wróć później."; "views.migration.title" = "Migracja"; "views.paywall.alerts.confirmation.message" = "Ten profil wymaga płatnych funkcji do działania."; +"views.paywall.alerts.confirmation.message.connect" = "Możesz przetestować połączenie przez %d minut."; "views.paywall.alerts.confirmation.title" = "Wymagana zakup"; "views.paywall.alerts.pending.message" = "Zakup oczekuje na zewnętrzne potwierdzenie. Funkcja zostanie przypisana po zatwierdzeniu."; "views.paywall.alerts.restricted.message" = "Niektóre funkcje są niedostępne w tej wersji."; "views.paywall.alerts.restricted.title" = "Ograniczone"; -"views.paywall.alerts.verifying.message" = "Proszę czekać, aż zakupy zostaną zweryfikowane."; -"views.paywall.alerts.verifying.title" = "Weryfikacja"; +"views.paywall.alerts.verification.boot" = "Może to potrwać nieco dłużej, jeśli urządzenie zostało właśnie uruchomione."; +"views.paywall.alerts.verification.connect.1" = "Twoje zakupy są weryfikowane."; +"views.paywall.alerts.verification.connect.2" = "Jeśli weryfikacja nie zostanie zakończona, połączenie zostanie zakończone za %d minut."; +"views.paywall.alerts.verification.edit" = "Proszę czekać, trwa weryfikacja zakupów."; "views.paywall.rows.restore_purchases" = "Przywróć zakupy"; "views.paywall.sections.all_features.header" = "Pełna wersja zawiera"; "views.paywall.sections.full_products.header" = "Pełna wersja"; @@ -286,6 +289,7 @@ "views.ui.connection_status.on_demand_suffix" = " (na żądanie)"; "views.ui.purchase_required.purchase.help" = "Wymagana zakup"; "views.ui.purchase_required.restricted.help" = "Funkcja jest ograniczona"; +"views.verification.message" = "Weryfikacja"; "views.version.extra" = "%@ to projekt utrzymywany przez %@.\n\nKod źródłowy jest dostępny publicznie na GitHub pod licencją GPLv3. Linki można znaleźć na stronie głównej."; "views.vpn.category.any" = "Wszystkie kategorie"; "views.vpn.no_servers" = "Brak serwerów"; diff --git a/Packages/App/Sources/UILibrary/Resources/pt.lproj/Localizable.strings b/Packages/App/Sources/UILibrary/Resources/pt.lproj/Localizable.strings index 76240219..4c681e8f 100644 --- a/Packages/App/Sources/UILibrary/Resources/pt.lproj/Localizable.strings +++ b/Packages/App/Sources/UILibrary/Resources/pt.lproj/Localizable.strings @@ -36,6 +36,7 @@ "errors.app.passepartout.no_active_modules" = "O perfil não possui módulos ativos."; "errors.app.passepartout.parsing" = "Não foi possível analisar o arquivo."; "errors.app.passepartout.provider_required" = "Nenhum provedor selecionado."; +"errors.app.passepartout.timeout" = "A operação expirou."; "errors.app.permission_denied" = "Permissão negada"; "errors.app.tunnel" = "Não foi possível executar a operação."; "errors.tunnel.auth" = "Falha na autenticação"; @@ -222,7 +223,6 @@ "views.app.toolbar.new_profile.empty" = "Perfil vazio"; "views.app.toolbar.new_profile.provider" = "Provedor"; "views.app.tv.header" = "Abra %@ no seu dispositivo iOS ou macOS e ative o botão \"%@\" de um perfil para que ele apareça aqui."; -"views.app.verifying_purchases" = "Verificando compras..."; "views.app_menu.items.quit" = "Sair de %@"; "views.diagnostics.alerts.report_issue.email" = "O dispositivo não está configurado para enviar e-mails."; "views.diagnostics.openvpn.rows.server_configuration" = "Configuração do servidor"; @@ -244,12 +244,15 @@ "views.migration.sections.main.header" = "Selecione abaixo os perfis de versões antigas de %@ que você deseja importar. Caso seus perfis estejam armazenados no iCloud, pode levar um tempo para sincronizá-los. Se não os vir agora, volte mais tarde."; "views.migration.title" = "Migrar"; "views.paywall.alerts.confirmation.message" = "Este perfil requer recursos pagos para funcionar."; +"views.paywall.alerts.confirmation.message.connect" = "Você pode testar a conexão por %d minutos."; "views.paywall.alerts.confirmation.title" = "Compra necessária"; "views.paywall.alerts.pending.message" = "A compra está pendente de confirmação externa. O recurso será creditado após a aprovação."; "views.paywall.alerts.restricted.message" = "Alguns recursos estão indisponíveis nesta versão."; "views.paywall.alerts.restricted.title" = "Restrito"; -"views.paywall.alerts.verifying.message" = "Aguarde enquanto suas compras estão sendo verificadas."; -"views.paywall.alerts.verifying.title" = "Verificação"; +"views.paywall.alerts.verification.boot" = "Isso pode levar um pouco mais de tempo se seu dispositivo acabou de ser iniciado."; +"views.paywall.alerts.verification.connect.1" = "Suas compras estão sendo verificadas."; +"views.paywall.alerts.verification.connect.2" = "Se a verificação não for concluída, a conexão será encerrada em %d minutos."; +"views.paywall.alerts.verification.edit" = "Aguarde enquanto suas compras estão sendo verificadas."; "views.paywall.rows.restore_purchases" = "Restaurar compras"; "views.paywall.sections.all_features.header" = "A versão completa inclui"; "views.paywall.sections.full_products.header" = "Versão completa"; @@ -286,6 +289,7 @@ "views.ui.connection_status.on_demand_suffix" = " (sob demanda)"; "views.ui.purchase_required.purchase.help" = "Compra necessária"; "views.ui.purchase_required.restricted.help" = "Recurso restrito"; +"views.verification.message" = "Verificação"; "views.version.extra" = "%@ é um projeto mantido por %@.\n\nO código-fonte está disponível publicamente no GitHub sob a licença GPLv3, você pode encontrar os links na página inicial."; "views.vpn.category.any" = "Todas as categorias"; "views.vpn.no_servers" = "Nenhum servidor"; diff --git a/Packages/App/Sources/UILibrary/Resources/ru.lproj/Localizable.strings b/Packages/App/Sources/UILibrary/Resources/ru.lproj/Localizable.strings index 2e9bf8ed..c5288341 100644 --- a/Packages/App/Sources/UILibrary/Resources/ru.lproj/Localizable.strings +++ b/Packages/App/Sources/UILibrary/Resources/ru.lproj/Localizable.strings @@ -36,6 +36,7 @@ "errors.app.passepartout.no_active_modules" = "В профиле нет активных модулей."; "errors.app.passepartout.parsing" = "Не удалось разобрать файл."; "errors.app.passepartout.provider_required" = "Поставщик не выбран."; +"errors.app.passepartout.timeout" = "Время операции истекло."; "errors.app.permission_denied" = "Доступ запрещен"; "errors.app.tunnel" = "Не удалось выполнить операцию."; "errors.tunnel.auth" = "Ошибка аутентификации"; @@ -222,7 +223,6 @@ "views.app.toolbar.new_profile.empty" = "Пустой профиль"; "views.app.toolbar.new_profile.provider" = "Поставщик"; "views.app.tv.header" = "Откройте %@ на вашем устройстве iOS или macOS и включите переключатель \"%@\" профиля, чтобы он отобразился здесь."; -"views.app.verifying_purchases" = "Проверка покупок..."; "views.app_menu.items.quit" = "Выйти из %@"; "views.diagnostics.alerts.report_issue.email" = "Устройство не настроено для отправки электронной почты."; "views.diagnostics.openvpn.rows.server_configuration" = "Конфигурация сервера"; @@ -244,12 +244,15 @@ "views.migration.sections.main.header" = "Выберите ниже профили из старых версий %@, которые вы хотите импортировать. Если ваши профили хранятся в iCloud, синхронизация может занять некоторое время. Если вы их сейчас не видите, вернитесь позже."; "views.migration.title" = "Миграция"; "views.paywall.alerts.confirmation.message" = "Этот профиль требует платных функций для работы."; +"views.paywall.alerts.confirmation.message.connect" = "Вы можете протестировать подключение в течение %d минут."; "views.paywall.alerts.confirmation.title" = "Требуется покупка"; "views.paywall.alerts.pending.message" = "Покупка ожидает внешнего подтверждения. Функция будет активирована после одобрения."; "views.paywall.alerts.restricted.message" = "Некоторые функции недоступны в этой версии."; "views.paywall.alerts.restricted.title" = "Ограничено"; -"views.paywall.alerts.verifying.message" = "Пожалуйста, подождите, пока проверяются ваши покупки."; -"views.paywall.alerts.verifying.title" = "Проверка"; +"views.paywall.alerts.verification.boot" = "Это может занять немного больше времени, если ваше устройство только что было включено."; +"views.paywall.alerts.verification.connect.1" = "Ваши покупки проверяются."; +"views.paywall.alerts.verification.connect.2" = "Если проверка не будет завершена, соединение завершится через %d минут."; +"views.paywall.alerts.verification.edit" = "Пожалуйста, подождите, пока ваши покупки проверяются."; "views.paywall.rows.restore_purchases" = "Восстановить покупки"; "views.paywall.sections.all_features.header" = "Полная версия включает"; "views.paywall.sections.full_products.header" = "Полная версия"; @@ -286,6 +289,7 @@ "views.ui.connection_status.on_demand_suffix" = " (по требованию)"; "views.ui.purchase_required.purchase.help" = "Требуется покупка"; "views.ui.purchase_required.restricted.help" = "Функция ограничена"; +"views.verification.message" = "Проверка"; "views.version.extra" = "%@ — проект, поддерживаемый %@.\n\nИсходный код доступен на GitHub под лицензией GPLv3, ссылки можно найти на домашней странице."; "views.vpn.category.any" = "Все категории"; "views.vpn.no_servers" = "Нет серверов"; diff --git a/Packages/App/Sources/UILibrary/Resources/sv.lproj/Localizable.strings b/Packages/App/Sources/UILibrary/Resources/sv.lproj/Localizable.strings index a86f4504..e44462a7 100644 --- a/Packages/App/Sources/UILibrary/Resources/sv.lproj/Localizable.strings +++ b/Packages/App/Sources/UILibrary/Resources/sv.lproj/Localizable.strings @@ -36,6 +36,7 @@ "errors.app.passepartout.no_active_modules" = "Profilen har inga aktiva moduler."; "errors.app.passepartout.parsing" = "Kan inte tolka filen."; "errors.app.passepartout.provider_required" = "Ingen leverantör vald."; +"errors.app.passepartout.timeout" = "Åtgärden tog för lång tid."; "errors.app.permission_denied" = "Åtkomst nekad"; "errors.app.tunnel" = "Kunde inte utföra åtgärden."; "errors.tunnel.auth" = "Autentisering misslyckades"; @@ -222,7 +223,6 @@ "views.app.toolbar.new_profile.empty" = "Tom profil"; "views.app.toolbar.new_profile.provider" = "Leverantör"; "views.app.tv.header" = "Öppna %@ på din iOS- eller macOS-enhet och aktivera växeln \"%@\" i en profil för att den ska visas här."; -"views.app.verifying_purchases" = "Verifierar köp..."; "views.app_menu.items.quit" = "Avsluta %@"; "views.diagnostics.alerts.report_issue.email" = "Enheten är inte konfigurerad för att skicka e-post."; "views.diagnostics.openvpn.rows.server_configuration" = "Serverkonfiguration"; @@ -244,12 +244,15 @@ "views.migration.sections.main.header" = "Välj nedan de profiler från äldre versioner av %@ som du vill importera. Om dina profiler är lagrade på iCloud kan det ta en stund att synkronisera. Om du inte ser dem nu, kom tillbaka senare."; "views.migration.title" = "Migrera"; "views.paywall.alerts.confirmation.message" = "Den här profilen kräver betalda funktioner för att fungera."; +"views.paywall.alerts.confirmation.message.connect" = "Du kan testa anslutningen i %d minuter."; "views.paywall.alerts.confirmation.title" = "Köp krävs"; "views.paywall.alerts.pending.message" = "Köpet väntar på extern bekräftelse. Funktionen aktiveras efter godkännande."; "views.paywall.alerts.restricted.message" = "Vissa funktioner är inte tillgängliga i denna version."; "views.paywall.alerts.restricted.title" = "Begränsad"; -"views.paywall.alerts.verifying.message" = "Vänta medan dina köp verifieras."; -"views.paywall.alerts.verifying.title" = "Verifiering"; +"views.paywall.alerts.verification.boot" = "Det kan ta lite längre tid om din enhet just startades."; +"views.paywall.alerts.verification.connect.1" = "Dina köp verifieras."; +"views.paywall.alerts.verification.connect.2" = "Om verifieringen inte kan slutföras kommer anslutningen att avslutas om %d minuter."; +"views.paywall.alerts.verification.edit" = "Vänligen vänta medan dina köp verifieras."; "views.paywall.rows.restore_purchases" = "Återställ köp"; "views.paywall.sections.all_features.header" = "Den fullständiga versionen innehåller"; "views.paywall.sections.full_products.header" = "Fullständig version"; @@ -286,6 +289,7 @@ "views.ui.connection_status.on_demand_suffix" = " (på begäran)"; "views.ui.purchase_required.purchase.help" = "Köp krävs"; "views.ui.purchase_required.restricted.help" = "Funktionen är begränsad"; +"views.verification.message" = "Verifiering"; "views.version.extra" = "%@ är ett projekt som underhålls av %@.\n\nKällkoden är offentligt tillgänglig på GitHub under GPLv3-licensen. Du hittar länkar på hemsidan."; "views.vpn.category.any" = "Alla kategorier"; "views.vpn.no_servers" = "Inga servrar"; diff --git a/Packages/App/Sources/UILibrary/Resources/uk.lproj/Localizable.strings b/Packages/App/Sources/UILibrary/Resources/uk.lproj/Localizable.strings index 53dd2cee..7324a6b7 100644 --- a/Packages/App/Sources/UILibrary/Resources/uk.lproj/Localizable.strings +++ b/Packages/App/Sources/UILibrary/Resources/uk.lproj/Localizable.strings @@ -36,6 +36,7 @@ "errors.app.passepartout.no_active_modules" = "Профіль не має активних модулів."; "errors.app.passepartout.parsing" = "Не вдалося розібрати файл."; "errors.app.passepartout.provider_required" = "Не вибрано постачальника."; +"errors.app.passepartout.timeout" = "Час виконання операції вичерпано."; "errors.app.permission_denied" = "Доступ заборонено"; "errors.app.tunnel" = "Не вдалося виконати операцію."; "errors.tunnel.auth" = "Помилка аутентифікації"; @@ -222,7 +223,6 @@ "views.app.toolbar.new_profile.empty" = "Порожній профіль"; "views.app.toolbar.new_profile.provider" = "Постачальник"; "views.app.tv.header" = "Відкрийте %@ на вашому пристрої iOS або macOS і увімкніть перемикач \"%@\" профілю, щоб він з’явився тут."; -"views.app.verifying_purchases" = "Перевірка покупок..."; "views.app_menu.items.quit" = "Вийти з %@"; "views.diagnostics.alerts.report_issue.email" = "Пристрій не налаштований на надсилання електронних листів."; "views.diagnostics.openvpn.rows.server_configuration" = "Конфігурація сервера"; @@ -244,12 +244,15 @@ "views.migration.sections.main.header" = "Виберіть нижче профілі зі старих версій %@, які ви хочете імпортувати. Якщо ваші профілі зберігаються в iCloud, може знадобитися час для їх синхронізації. Якщо ви не бачите їх зараз, поверніться пізніше."; "views.migration.title" = "Перенесення"; "views.paywall.alerts.confirmation.message" = "Цей профіль потребує платних функцій для роботи."; +"views.paywall.alerts.confirmation.message.connect" = "Ви можете протестувати підключення протягом %d хвилин."; "views.paywall.alerts.confirmation.title" = "Потрібна покупка"; "views.paywall.alerts.pending.message" = "Покупка очікує зовнішнього підтвердження. Функція буде увімкнена після схвалення."; "views.paywall.alerts.restricted.message" = "Деякі функції недоступні в цій версії."; "views.paywall.alerts.restricted.title" = "Обмежено"; -"views.paywall.alerts.verifying.message" = "Будь ласка, зачекайте, поки ваші покупки перевіряються."; -"views.paywall.alerts.verifying.title" = "Перевірка"; +"views.paywall.alerts.verification.boot" = "Це може зайняти трохи більше часу, якщо ваш пристрій щойно запустився."; +"views.paywall.alerts.verification.connect.1" = "Ваші покупки перевіряються."; +"views.paywall.alerts.verification.connect.2" = "Якщо перевірку не вдасться завершити, підключення завершиться через %d хвилин."; +"views.paywall.alerts.verification.edit" = "Будь ласка, зачекайте, поки ваші покупки перевіряються."; "views.paywall.rows.restore_purchases" = "Відновити покупки"; "views.paywall.sections.all_features.header" = "Повна версія включає"; "views.paywall.sections.full_products.header" = "Повна версія"; @@ -286,6 +289,7 @@ "views.ui.connection_status.on_demand_suffix" = " (за запитом)"; "views.ui.purchase_required.purchase.help" = "Потрібна покупка"; "views.ui.purchase_required.restricted.help" = "Функція обмежена"; +"views.verification.message" = "Перевірка"; "views.version.extra" = "%@ є проектом, який підтримується %@.\n\nВихідний код доступний публічно на GitHub під ліцензією GPLv3. Посилання можна знайти на домашній сторінці."; "views.vpn.category.any" = "Усі категорії"; "views.vpn.no_servers" = "Немає серверів"; diff --git a/Packages/App/Sources/UILibrary/Resources/zh-Hans.lproj/Localizable.strings b/Packages/App/Sources/UILibrary/Resources/zh-Hans.lproj/Localizable.strings index 31b87f5d..00d466e4 100644 --- a/Packages/App/Sources/UILibrary/Resources/zh-Hans.lproj/Localizable.strings +++ b/Packages/App/Sources/UILibrary/Resources/zh-Hans.lproj/Localizable.strings @@ -36,6 +36,7 @@ "errors.app.passepartout.no_active_modules" = "配置文件没有激活模块。"; "errors.app.passepartout.parsing" = "无法解析文件。"; "errors.app.passepartout.provider_required" = "未选择提供商。"; +"errors.app.passepartout.timeout" = "操作超时。"; "errors.app.permission_denied" = "权限被拒绝"; "errors.app.tunnel" = "无法执行操作。"; "errors.tunnel.auth" = "认证失败"; @@ -222,7 +223,6 @@ "views.app.toolbar.new_profile.empty" = "空配置文件"; "views.app.toolbar.new_profile.provider" = "提供商"; "views.app.tv.header" = "在您的 iOS 或 macOS 设备上打开 %@,并启用配置文件的 \"%@\" 开关,使其显示在此处。"; -"views.app.verifying_purchases" = "正在验证购买..."; "views.app_menu.items.quit" = "退出 %@"; "views.diagnostics.alerts.report_issue.email" = "设备未配置为发送电子邮件。"; "views.diagnostics.openvpn.rows.server_configuration" = "服务器配置"; @@ -244,12 +244,15 @@ "views.migration.sections.main.header" = "选择以下来自 %@ 的旧版本配置文件进行导入。如果您的配置文件存储在 iCloud 中,可能需要一些时间进行同步。如果现在没有看到,请稍后再试。"; "views.migration.title" = "迁移"; "views.paywall.alerts.confirmation.message" = "此配置文件需要付费功能才能工作。"; +"views.paywall.alerts.confirmation.message.connect" = "您可以试用连接 %d 分钟。"; "views.paywall.alerts.confirmation.title" = "需要购买"; "views.paywall.alerts.pending.message" = "购买正在等待外部确认。功能将在获得批准后被授予。"; "views.paywall.alerts.restricted.message" = "某些功能在此版本中不可用。"; "views.paywall.alerts.restricted.title" = "受限"; -"views.paywall.alerts.verifying.message" = "请稍候,您的购买正在验证中。"; -"views.paywall.alerts.verifying.title" = "验证"; +"views.paywall.alerts.verification.boot" = "如果您的设备刚刚启动,这可能需要更长时间。"; +"views.paywall.alerts.verification.connect.1" = "您的购买正在验证中。"; +"views.paywall.alerts.verification.connect.2" = "如果无法完成验证,连接将在 %d 分钟后断开。"; +"views.paywall.alerts.verification.edit" = "请稍候,您的购买正在验证中。"; "views.paywall.rows.restore_purchases" = "恢复购买"; "views.paywall.sections.all_features.header" = "完整版本包括"; "views.paywall.sections.full_products.header" = "完整版本"; @@ -286,6 +289,7 @@ "views.ui.connection_status.on_demand_suffix" = "(按需)"; "views.ui.purchase_required.purchase.help" = "需要购买"; "views.ui.purchase_required.restricted.help" = "功能受限"; +"views.verification.message" = "验证"; "views.version.extra" = "%@ 是由 %@ 维护的项目。\n\n源代码在 GitHub 上公开提供,遵循 GPLv3 许可协议,您可以在主页找到相关链接。"; "views.vpn.category.any" = "所有类别"; "views.vpn.no_servers" = "无服务器"; diff --git a/Packages/App/Sources/UILibrary/Strategy/AppCoordinatorConforming.swift b/Packages/App/Sources/UILibrary/Strategy/AppCoordinatorConforming.swift index 881a0e53..4eb4ef1c 100644 --- a/Packages/App/Sources/UILibrary/Strategy/AppCoordinatorConforming.swift +++ b/Packages/App/Sources/UILibrary/Strategy/AppCoordinatorConforming.swift @@ -37,7 +37,7 @@ public protocol AppCoordinatorConforming { func onProviderEntityRequired(_ profile: Profile, force: Bool) - func onPurchaseRequired(_ features: Set) + func onPurchaseRequired(_ features: Set, onCancel: (() -> Void)?) func onError(_ error: Error, profile: Profile) } diff --git a/Packages/App/Sources/UILibrary/Views/UI/InteractiveCoordinator.swift b/Packages/App/Sources/UILibrary/Views/UI/InteractiveCoordinator.swift index 83b875a0..5c4d6ef9 100644 --- a/Packages/App/Sources/UILibrary/Views/UI/InteractiveCoordinator.swift +++ b/Packages/App/Sources/UILibrary/Views/UI/InteractiveCoordinator.swift @@ -171,12 +171,10 @@ private extension InteractiveCoordinator { } func confirm() { - Task { - do { - try await manager.complete() - } catch { - onError(error) - } + do { + try manager.complete() + } catch { + onError(error) } } diff --git a/Packages/App/Sources/UILibrary/Views/UI/PaywallModifier+Reason.swift b/Packages/App/Sources/UILibrary/Views/UI/PaywallModifier+Reason.swift index f056b8e1..6911cc0c 100644 --- a/Packages/App/Sources/UILibrary/Views/UI/PaywallModifier+Reason.swift +++ b/Packages/App/Sources/UILibrary/Views/UI/PaywallModifier+Reason.swift @@ -34,12 +34,16 @@ extension PaywallModifier { public let needsConfirmation: Bool + public let forConnecting: Bool + public init( _ requiredFeatures: Set, - needsConfirmation: Bool = false + needsConfirmation: Bool = true, + forConnecting: Bool = true ) { self.requiredFeatures = requiredFeatures self.needsConfirmation = needsConfirmation + self.forConnecting = forConnecting } } } diff --git a/Packages/App/Sources/UILibrary/Views/UI/PaywallModifier.swift b/Packages/App/Sources/UILibrary/Views/UI/PaywallModifier.swift index 86bff2cd..509638b6 100644 --- a/Packages/App/Sources/UILibrary/Views/UI/PaywallModifier.swift +++ b/Packages/App/Sources/UILibrary/Views/UI/PaywallModifier.swift @@ -34,9 +34,7 @@ public struct PaywallModifier: ViewModifier { @Binding private var reason: PaywallReason? - private let okTitle: String? - - private let okAction: (() -> Void)? + private let onCancel: (() -> Void)? @State private var isConfirming = false @@ -47,14 +45,9 @@ public struct PaywallModifier: ViewModifier { @State private var isPurchasing = false - public init( - reason: Binding, - okTitle: String? = nil, - okAction: (() -> Void)? = nil - ) { + public init(reason: Binding, onCancel: (() -> Void)? = nil) { _reason = reason - self.okTitle = okTitle - self.okAction = okAction + self.onCancel = onCancel } public func body(content: Content) -> some View { @@ -90,7 +83,7 @@ public struct PaywallModifier: ViewModifier { guard let reason = $0 else { return } - if !iapManager.isRestricted { + if !iapManager.isBeta { if reason.needsConfirmation { isConfirming = true } else { @@ -104,27 +97,9 @@ public struct PaywallModifier: ViewModifier { } private extension PaywallModifier { - var ineligibleFeatures: [String] { - guard let reason else { - return [] - } - return iapManager - .excludingEligible(from: reason.requiredFeatures) - .map(\.localizedDescription) - .sorted() - } - func alertMessage(startingWith header: String, features: [String]) -> String { - header + "\n\n" + features - .joined(separator: "\n") - } -} + header + "\n\n" + features.joined(separator: "\n") -private extension IAPManager { - func excludingEligible(from features: Set) -> Set { - features.filter { - !isEligible(for: $0) - } } } @@ -138,14 +113,9 @@ private extension PaywallModifier { // IMPORTANT: retain reason because it serves paywall content isPurchasing = true } - if let okTitle { - Button(okTitle) { - reason = nil - okAction?() - } - } Button(Strings.Global.Actions.cancel, role: .cancel) { reason = nil + onCancel?() } } @@ -154,8 +124,13 @@ private extension PaywallModifier { } var confirmationMessageString: String { - alertMessage( - startingWith: Strings.Views.Paywall.Alerts.Confirmation.message, + let V = Strings.Views.Paywall.Alerts.Confirmation.self + var messages = [V.message] + if reason?.forConnecting == true { + messages.append(V.Message.connect(limitedMinutes)) + } + return alertMessage( + startingWith: messages.joined(separator: " "), features: ineligibleFeatures ) } @@ -166,7 +141,7 @@ private extension PaywallModifier { private extension PaywallModifier { func restrictedActions() -> some View { Button(Strings.Global.Nouns.ok) { - // + onCancel?() } } @@ -175,8 +150,13 @@ private extension PaywallModifier { } var restrictedMessageString: String { - alertMessage( - startingWith: Strings.Views.Paywall.Alerts.Restricted.message, + let V = Strings.Views.Paywall.Alerts.self + var messages = [V.Restricted.message] + if reason?.forConnecting == true { + messages.append(V.Confirmation.Message.connect(limitedMinutes)) + } + return alertMessage( + startingWith: messages.joined(separator: " "), features: ineligibleFeatures ) } @@ -186,7 +166,8 @@ private extension PaywallModifier { private extension PaywallModifier { func modalDestination() -> some View { - reason.map { + assert(!iapManager.isLoadingReceipt, "Paywall presented while still loading receipt?") + return reason.map { PaywallView( isPresented: $isPurchasing, features: iapManager.excludingEligible(from: $0.requiredFeatures) @@ -195,3 +176,29 @@ private extension PaywallModifier { } } } + +// MARK: - Logic + +private extension PaywallModifier { + var ineligibleFeatures: [String] { + guard let reason else { + return [] + } + return iapManager + .excludingEligible(from: reason.requiredFeatures) + .map(\.localizedDescription) + .sorted() + } + + var limitedMinutes: Int { + iapManager.verificationDelayMinutes + } +} + +private extension IAPManager { + func excludingEligible(from features: Set) -> Set { + features.filter { + !isEligible(for: $0) + } + } +} diff --git a/Packages/App/Sources/UILibrary/Views/UI/PurchaseRequiredView.swift b/Packages/App/Sources/UILibrary/Views/UI/PurchaseRequiredView.swift index 600765cb..fcbb7853 100644 --- a/Packages/App/Sources/UILibrary/Views/UI/PurchaseRequiredView.swift +++ b/Packages/App/Sources/UILibrary/Views/UI/PurchaseRequiredView.swift @@ -39,7 +39,7 @@ public struct PurchaseRequiredView: View where Content: View { let content: (_ isRestricted: Bool) -> Content public var body: some View { - content(iapManager.isRestricted) + content(iapManager.isBeta) .opaque(!isEligible) } } diff --git a/Packages/App/Tests/CommonLibraryTests/Business/IAPManagerTests.swift b/Packages/App/Tests/CommonLibraryTests/Business/IAPManagerTests.swift index 1ea1a0d5..6c709e8d 100644 --- a/Packages/App/Tests/CommonLibraryTests/Business/IAPManagerTests.swift +++ b/Packages/App/Tests/CommonLibraryTests/Business/IAPManagerTests.swift @@ -405,8 +405,8 @@ extension IAPManagerTests { let sut = IAPManager(customUserLevel: .beta, receiptReader: reader) await sut.reloadReceipt() - XCTAssertTrue(sut.isRestricted) - XCTAssertTrue(sut.userLevel.isRestricted) + XCTAssertTrue(sut.isBeta) + XCTAssertTrue(sut.userLevel.isBeta) } func test_givenBetaApp_thenIsNotEligibleForAllFeatures() async { diff --git a/Packages/App/Tests/UILibraryTests/ProfileEditorTests.swift b/Packages/App/Tests/UILibraryTests/ProfileEditorTests.swift index 1ed8d40c..49ddeb66 100644 --- a/Packages/App/Tests/UILibraryTests/ProfileEditorTests.swift +++ b/Packages/App/Tests/UILibraryTests/ProfileEditorTests.swift @@ -251,7 +251,8 @@ extension ProfileEditorTests { } .store(in: &subscriptions) - try await sut.save(to: manager, preferencesManager: PreferencesManager()) + let target = try sut.build() + try await sut.save(target, to: manager, preferencesManager: PreferencesManager()) await fulfillment(of: [exp]) } } diff --git a/Packages/PassepartoutKit-Source b/Packages/PassepartoutKit-Source index 04972b0e..10da14db 160000 --- a/Packages/PassepartoutKit-Source +++ b/Packages/PassepartoutKit-Source @@ -1 +1 @@ -Subproject commit 04972b0ef8628c93fe8f7362d089a31d2f767173 +Subproject commit 10da14db697d8c22d91f1d430ce94c31b6f93c7d diff --git a/Passepartout/App/Context/AppContext+Shared.swift b/Passepartout/App/Context/AppContext+Shared.swift index 1d9bdadf..8f7b7ef8 100644 --- a/Passepartout/App/Context/AppContext+Shared.swift +++ b/Passepartout/App/Context/AppContext+Shared.swift @@ -81,7 +81,6 @@ extension AppContext { productsAtBuild: dependencies.productsAtBuild() ) let processor = dependencies.appProcessor(with: iapManager) - let tunnelReceiptURL = BundleConfiguration.urlForBetaReceipt let tunnelEnvironment = dependencies.tunnelEnvironment() #if targetEnvironment(simulator) @@ -216,7 +215,6 @@ extension AppContext { profileManager: profileManager, registry: dependencies.registry, tunnel: tunnel, - tunnelReceiptURL: tunnelReceiptURL, onEligibleFeaturesBlock: onEligibleFeaturesBlock ) }() @@ -252,22 +250,11 @@ private extension Dependencies { } return mockHelper.receiptReader } - return FallbackReceiptReader( - main: StoreKitReceiptReader(logger: iapLogger()), - beta: betaReceiptURL.map { - KvittoReceiptReader(url: $0) - } + return SharedReceiptReader( + reader: StoreKitReceiptReader(logger: iapLogger()) ) } - var betaReceiptURL: URL? { -#if os(tvOS) - nil -#else - Bundle.main.appStoreProductionReceiptURL -#endif - } - var mirrorsRemoteRepository: Bool { #if os(tvOS) true diff --git a/Passepartout/App/Context/AppContext+Testing.swift b/Passepartout/App/Context/AppContext+Testing.swift index 81d65676..af14fdc6 100644 --- a/Passepartout/App/Context/AppContext+Testing.swift +++ b/Passepartout/App/Context/AppContext+Testing.swift @@ -69,8 +69,7 @@ extension AppContext { preferencesManager: preferencesManager, profileManager: profileManager, registry: registry, - tunnel: tunnel, - tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt + tunnel: tunnel ) } } diff --git a/Passepartout/App/Context/DefaultAppProcessor.swift b/Passepartout/App/Context/DefaultAppProcessor.swift index a2de0a46..686b2b72 100644 --- a/Passepartout/App/Context/DefaultAppProcessor.swift +++ b/Passepartout/App/Context/DefaultAppProcessor.swift @@ -76,7 +76,6 @@ extension DefaultAppProcessor: AppTunnelProcessor { } func willInstall(_ profile: Profile) throws -> Profile { - try iapManager.verify(profile) // validate provider modules do { diff --git a/Passepartout/Tunnel/Context/DefaultTunnelProcessor.swift b/Passepartout/Tunnel/Context/DefaultTunnelProcessor.swift index 487b1005..61439b4f 100644 --- a/Passepartout/Tunnel/Context/DefaultTunnelProcessor.swift +++ b/Passepartout/Tunnel/Context/DefaultTunnelProcessor.swift @@ -33,7 +33,7 @@ final class DefaultTunnelProcessor: Sendable { } extension DefaultTunnelProcessor: PacketTunnelProcessor { - func willStart(_ profile: Profile) throws -> Profile { + func willProcess(_ profile: Profile) throws -> Profile { do { var builder = profile.builder() try builder.modules.forEach { diff --git a/Passepartout/Tunnel/Context/TunnelContext+Shared.swift b/Passepartout/Tunnel/Context/TunnelContext+Shared.swift index aab851f1..d6665a2f 100644 --- a/Passepartout/Tunnel/Context/TunnelContext+Shared.swift +++ b/Passepartout/Tunnel/Context/TunnelContext+Shared.swift @@ -50,19 +50,8 @@ extension TunnelContext { private extension Dependencies { func tunnelReceiptReader() -> AppReceiptReader { - FallbackReceiptReader( - main: StoreKitReceiptReader(logger: iapLogger()), - beta: betaReceiptURL.map { - KvittoReceiptReader(url: $0) - } + SharedReceiptReader( + reader: StoreKitReceiptReader(logger: iapLogger()) ) } - - var betaReceiptURL: URL? { -#if os(tvOS) - nil -#else - BundleConfiguration.urlForBetaReceipt // copied by AppContext.onLaunch -#endif - } } diff --git a/Passepartout/Tunnel/PacketTunnelProvider.swift b/Passepartout/Tunnel/PacketTunnelProvider.swift index c9cf7492..70122913 100644 --- a/Passepartout/Tunnel/PacketTunnelProvider.swift +++ b/Passepartout/Tunnel/PacketTunnelProvider.swift @@ -43,24 +43,50 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { parameters: Constants.shared.log, logsPrivateData: UserDefaults.appGroup.bool(forKey: AppPreference.logsPrivateData.key) ) + pp_log(.app, .info, "Tunnel started with options: \(options?.description ?? "nil")") + let environment = await dependencies.tunnelEnvironment() + + // check hold flag + if environment.environmentValue(forKey: TunnelEnvironmentKeys.holdFlag) == true { + pp_log(.app, .info, "Tunnel is on hold") + guard options?[ExtendedTunnel.isManualKey] == true as NSNumber else { + pp_log(.app, .error, "Tunnel was started non-interactively, hang here") + return + } + pp_log(.app, .info, "Tunnel was started interactively, clear hold flag") + environment.removeEnvironmentValue(forKey: TunnelEnvironmentKeys.holdFlag) + } + do { fwd = try await NEPTPForwarder( provider: self, decoder: dependencies.neProtocolCoder(), registry: dependencies.registry, environment: environment, - profileBlock: context.processor.willStart + willProcess: context.processor.willProcess ) guard let fwd else { fatalError("NEPTPForwarder nil without throwing error?") } + + await context.iapManager.fetchLevelIfNeeded() + let params = await Constants.shared.tunnel.verificationParameters(isBeta: context.iapManager.isBeta) + pp_log(.app, .info, "Will start profile verification in \(params.delay) seconds") + try await fwd.startTunnel(options: options) // #1070, do not wait for this to start the tunnel. if on-demand is // enabled, networking will stall and StoreKit network calls may // produce a deadlock - verifyEligibility(of: fwd.profile, environment: environment) + Task { + try? await Task.sleep(for: .seconds(params.delay)) + await verifyEligibility( + of: fwd.profile, + environment: environment, + interval: params.interval + ) + } } catch { pp_log(.app, .fault, "Unable to start tunnel: \(error)") PassepartoutConfiguration.shared.flushLog() @@ -95,25 +121,29 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { // MARK: - Eligibility private extension PacketTunnelProvider { - func verifyEligibility(of profile: Profile, environment: TunnelEnvironment) { - Task { - while true { - do { - pp_log(.app, .info, "Verify profile, requires: \(profile.features)") - await context.iapManager.reloadReceipt() - try await context.iapManager.verify(profile) + func verifyEligibility(of profile: Profile, environment: TunnelEnvironment, interval: TimeInterval) async { + while true { + do { + pp_log(.app, .info, "Verify profile, requires: \(profile.features)") + await context.iapManager.reloadReceipt() + try await context.iapManager.verify(profile) + } catch { + let error = PassepartoutError(.App.ineligibleProfile) + environment.setEnvironmentValue(error.code, forKey: TunnelEnvironmentKeys.lastErrorCode) + pp_log(.app, .fault, "Verification failed for profile \(profile.id), shutting down: \(error)") - let interval = Constants.shared.tunnel.eligibilityCheckInterval - pp_log(.app, .info, "Will verify profile again in \(interval) seconds...") - try await Task.sleep(interval: interval) - } catch { - let error = PassepartoutError(.App.ineligibleProfile) - environment.setEnvironmentValue(error.code, forKey: TunnelEnvironmentKeys.lastErrorCode) - pp_log(.app, .fault, "Verification failed for profile \(profile.id), shutting down: \(error)") - cancelTunnelWithError(error) - return - } + // prevent on-demand reconnection + environment.setEnvironmentValue(true, forKey: TunnelEnvironmentKeys.holdFlag) + await fwd?.holdTunnel() + return } + + pp_log(.app, .info, "Will verify profile again in \(interval) seconds...") + try? await Task.sleep(interval: interval) } } } + +private extension TunnelEnvironmentKeys { + static let holdFlag = TunnelEnvironmentKey("Tunnel.onHold") +}