Allow graceful period to work around slow receipt validation (#1139)

#1070 is very tricky. When the device boots, StoreKit operations seem to
be severely affected by on-demand VPN profiles. Slowdowns are huge and
unpredictable, as per my [report on the Apple
forums](https://developer.apple.com/forums/thread/773723). I found no
easy way to work around the chicken-and-egg situation where the VPN
requires StoreKit validation to start, but StoreKit requires network
access.

On the other hand, without StoreKit validations, the on-demand tunnel
starts on boot just fine, and so does the app. No eternal activity
indicators. StoreKit is clearly the culprit here.

Therefore, below is the strategy that this PR implements for a decent
trade-off:

- Configure a graceful period for the VPN to start without limitations.
This is initially set to 2 minutes in production, and 10 minutes in
TestFlight. Postpone StoreKit validation until then.
- After the graceful period, StoreKit validation is more likely to
complete fast
- At this point, paying users have their receipts validated and the
connection will silently keep going
- Non-paying users, instead, will see their connection hit the "Purchase
required" message

On the UI side, adjust the app accordingly:

- Drop the "Purchase required" icon from the list/grid of profiles
- The paywall informs that the connection will start, but it will
disconnect after the graceful period if the receipt is not valid
- Add a note that receipt validation may take a while if the device has
just started

This PR also introduces changes in TestFlight behavior:

- Profiles can be saved without limitations
- Profiles using free features work as usual
- Profiles using paid features work for 10 minutes
- Eligibility based on local receipt is ignored (deprecated in iOS 18)

Beta users may therefore test all paid features on iOS/macOS/tvOS for 10
minutes. Until now, paid features were only available to paying iOS
users and unavailable on macOS/tvOS. The tvOS beta was, in fact,
completely useless.

The downside is that paying iOS users will see beta builds restricted
like anybody else. I'll see if I can find a better solution later.
This commit is contained in:
Davide 2025-02-05 13:00:42 +01:00 committed by GitHub
parent 7ea6b6d37d
commit d6c2a7f58c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 505 additions and 348 deletions

View File

@ -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:),

View File

@ -37,7 +37,7 @@ struct AboutContentView<LinkContent, AboutDestination, LogDestination>: 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)
}
}

View File

@ -39,7 +39,7 @@ struct AboutContentView<LinkContent, AboutDestination, LogDestination>: 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)

View File

@ -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<AppFeature>) {
public func onPurchaseRequired(_ features: Set<AppFeature>, 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
}
}

View File

@ -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)
}

View File

@ -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)
}
}
}

View File

@ -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")

View File

@ -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()
}

View File

@ -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

View File

@ -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<AppFeature>) {
public func onPurchaseRequired(_ features: Set<AppFeature>, 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
}
}

View File

@ -38,7 +38,7 @@ public enum AppUserLevel: Int, Sendable {
}
extension AppUserLevel {
public var isRestricted: Bool {
public var isBeta: Bool {
self == .beta
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)

View File

@ -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
}

View File

@ -57,3 +57,9 @@ extension IAPManager {
}
}
}
extension IAPManager {
public var verificationDelayMinutes: Int {
Constants.shared.tunnel.verificationDelayMinutes(isBeta: isBeta)
}
}

View File

@ -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",

View File

@ -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
}

View File

@ -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<InAppReceipt?, Never>?
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()
}
}

View File

@ -26,12 +26,6 @@
import Foundation
extension Bundle {
public var appStoreProductionReceiptURL: URL? {
appStoreReceiptURL?
.deletingLastPathComponent()
.appendingPathComponent("receipt") // could be "sandboxReceipt"
}
public func unsafeDecode<T: Decodable>(_ type: T.Type, filename: String) -> T {
guard let jsonURL = url(forResource: filename, withExtension: "json") else {
fatalError("Unable to find \(filename).json in bundle")

View File

@ -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 <http://www.gnu.org/licenses/>.
//
import Foundation
public struct TaskTimeoutError: Error {
}
public func performTask<T>(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()
}
}

View File

@ -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?

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -46,8 +46,6 @@ public final class AppContext: ObservableObject, Sendable {
public let tunnel: ExtendedTunnel
private let tunnelReceiptURL: URL?
private let onEligibleFeaturesBlock: ((Set<AppFeature>) async -> Void)?
private var launchTask: Task<Void, Error>?
@ -64,7 +62,6 @@ public final class AppContext: ObservableObject, Sendable {
profileManager: ProfileManager,
registry: Registry,
tunnel: ExtendedTunnel,
tunnelReceiptURL: URL?,
onEligibleFeaturesBlock: ((Set<AppFeature>) 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)

View File

@ -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)
}
}

View File

@ -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

View File

@ -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 {

View File

@ -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

View File

@ -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 %@.
///

View File

@ -65,8 +65,7 @@ extension AppContext {
preferencesManager: PreferencesManager(),
profileManager: profileManager,
registry: Registry(),
tunnel: tunnel,
tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt
tunnel: tunnel
)
}()
}

View File

@ -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";

View File

@ -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" = "Δεν υπάρχουν διακομιστές";

View File

@ -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";

View File

@ -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";

View File

@ -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 sarrê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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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" = "Нет серверов";

View File

@ -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";

View File

@ -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" = "Немає серверів";

View File

@ -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" = "无服务器";

View File

@ -37,7 +37,7 @@ public protocol AppCoordinatorConforming {
func onProviderEntityRequired(_ profile: Profile, force: Bool)
func onPurchaseRequired(_ features: Set<AppFeature>)
func onPurchaseRequired(_ features: Set<AppFeature>, onCancel: (() -> Void)?)
func onError(_ error: Error, profile: Profile)
}

View File

@ -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)
}
}

View File

@ -34,12 +34,16 @@ extension PaywallModifier {
public let needsConfirmation: Bool
public let forConnecting: Bool
public init(
_ requiredFeatures: Set<AppFeature>,
needsConfirmation: Bool = false
needsConfirmation: Bool = true,
forConnecting: Bool = true
) {
self.requiredFeatures = requiredFeatures
self.needsConfirmation = needsConfirmation
self.forConnecting = forConnecting
}
}
}

View File

@ -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<PaywallReason?>,
okTitle: String? = nil,
okAction: (() -> Void)? = nil
) {
public init(reason: Binding<PaywallReason?>, 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<AppFeature>) -> Set<AppFeature> {
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<AppFeature>) -> Set<AppFeature> {
features.filter {
!isEligible(for: $0)
}
}
}

View File

@ -39,7 +39,7 @@ public struct PurchaseRequiredView<Content>: View where Content: View {
let content: (_ isRestricted: Bool) -> Content
public var body: some View {
content(iapManager.isRestricted)
content(iapManager.isBeta)
.opaque(!isEligible)
}
}

View File

@ -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 {

View File

@ -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])
}
}

@ -1 +1 @@
Subproject commit 04972b0ef8628c93fe8f7362d089a31d2f767173
Subproject commit 10da14db697d8c22d91f1d430ce94c31b6f93c7d

View File

@ -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

View File

@ -69,8 +69,7 @@ extension AppContext {
preferencesManager: preferencesManager,
profileManager: profileManager,
registry: registry,
tunnel: tunnel,
tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt
tunnel: tunnel
)
}
}

View File

@ -76,7 +76,6 @@ extension DefaultAppProcessor: AppTunnelProcessor {
}
func willInstall(_ profile: Profile) throws -> Profile {
try iapManager.verify(profile)
// validate provider modules
do {

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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<Bool>("Tunnel.onHold")
}