mirror of
https://github.com/passepartoutvpn/passepartout-apple.git
synced 2025-02-13 03:12:11 +00:00
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:
parent
7ea6b6d37d
commit
d6c2a7f58c
@ -49,7 +49,7 @@ struct AboutCoordinator: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
AboutContentView(
|
AboutContentView(
|
||||||
profileManager: profileManager,
|
profileManager: profileManager,
|
||||||
isRestricted: iapManager.isRestricted,
|
isBeta: iapManager.isBeta,
|
||||||
path: $path,
|
path: $path,
|
||||||
navigationRoute: $navigationRoute,
|
navigationRoute: $navigationRoute,
|
||||||
linkContent: linkView(to:),
|
linkContent: linkView(to:),
|
||||||
|
@ -37,7 +37,7 @@ struct AboutContentView<LinkContent, AboutDestination, LogDestination>: View whe
|
|||||||
|
|
||||||
let profileManager: ProfileManager
|
let profileManager: ProfileManager
|
||||||
|
|
||||||
let isRestricted: Bool
|
let isBeta: Bool
|
||||||
|
|
||||||
@Binding
|
@Binding
|
||||||
var path: NavigationPath
|
var path: NavigationPath
|
||||||
@ -68,7 +68,7 @@ private extension AboutContentView {
|
|||||||
linkContent(.version)
|
linkContent(.version)
|
||||||
linkContent(.links)
|
linkContent(.links)
|
||||||
linkContent(.credits)
|
linkContent(.credits)
|
||||||
if !isRestricted {
|
if !isBeta {
|
||||||
linkContent(.donate)
|
linkContent(.donate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -39,7 +39,7 @@ struct AboutContentView<LinkContent, AboutDestination, LogDestination>: View whe
|
|||||||
|
|
||||||
let profileManager: ProfileManager
|
let profileManager: ProfileManager
|
||||||
|
|
||||||
let isRestricted: Bool
|
let isBeta: Bool
|
||||||
|
|
||||||
@Binding
|
@Binding
|
||||||
var path: NavigationPath
|
var path: NavigationPath
|
||||||
@ -82,7 +82,7 @@ private extension AboutContentView {
|
|||||||
linkContent(.version)
|
linkContent(.version)
|
||||||
linkContent(.links)
|
linkContent(.links)
|
||||||
linkContent(.credits)
|
linkContent(.credits)
|
||||||
if !isRestricted {
|
if !isBeta {
|
||||||
linkContent(.donate)
|
linkContent(.donate)
|
||||||
}
|
}
|
||||||
linkContent(.purchased)
|
linkContent(.purchased)
|
||||||
|
@ -58,6 +58,9 @@ public struct AppCoordinator: View, AppCoordinatorConforming, SizeClassProviding
|
|||||||
@State
|
@State
|
||||||
private var paywallReason: PaywallReason?
|
private var paywallReason: PaywallReason?
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var onCancelPaywall: (() -> Void)?
|
||||||
|
|
||||||
@State
|
@State
|
||||||
private var modalRoute: ModalRoute?
|
private var modalRoute: ModalRoute?
|
||||||
|
|
||||||
@ -92,7 +95,10 @@ public struct AppCoordinator: View, AppCoordinatorConforming, SizeClassProviding
|
|||||||
.toolbar(content: toolbarContent)
|
.toolbar(content: toolbarContent)
|
||||||
}
|
}
|
||||||
.modifier(OnboardingModifier(modalRoute: $modalRoute))
|
.modifier(OnboardingModifier(modalRoute: $modalRoute))
|
||||||
.modifier(PaywallModifier(reason: $paywallReason))
|
.modifier(PaywallModifier(
|
||||||
|
reason: $paywallReason,
|
||||||
|
onCancel: onCancelPaywall
|
||||||
|
))
|
||||||
.themeModal(
|
.themeModal(
|
||||||
item: $modalRoute,
|
item: $modalRoute,
|
||||||
options: modalRoute?.options(),
|
options: modalRoute?.options(),
|
||||||
@ -267,16 +273,25 @@ extension AppCoordinator {
|
|||||||
present(.editProviderEntity(profile, force, module))
|
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 {
|
guard !iapManager.isLoadingReceipt else {
|
||||||
|
let V = Strings.Views.Paywall.Alerts.Verification.self
|
||||||
|
pp_log(.app, .info, "Present verification alert")
|
||||||
errorHandler.handle(
|
errorHandler.handle(
|
||||||
title: Strings.Views.Paywall.Alerts.Verifying.title,
|
title: Strings.Views.Paywall.Alerts.Confirmation.title,
|
||||||
message: Strings.Views.Paywall.Alerts.Verifying.message
|
message: [
|
||||||
|
V.Connect._1,
|
||||||
|
V.boot,
|
||||||
|
V.Connect._2(iapManager.verificationDelayMinutes)
|
||||||
|
].joined(separator: " "),
|
||||||
|
onDismiss: onCancel
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pp_log(.app, .info, "Present paywall for features: \(features)")
|
pp_log(.app, .info, "Present paywall")
|
||||||
setLater(.init(features, needsConfirmation: true)) {
|
onCancelPaywall = onCancel
|
||||||
|
setLater(.init(features)) {
|
||||||
paywallReason = $0
|
paywallReason = $0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -121,10 +121,6 @@ private struct MarkerView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
ThemeImage(profileId == nextProfileId ? .pending : tunnel.statusImageName)
|
ThemeImage(profileId == nextProfileId ? .pending : tunnel.statusImageName)
|
||||||
.opaque(requiredFeatures == nil && (profileId == nextProfileId || profileId == tunnel.currentProfile?.id))
|
.opaque(requiredFeatures == nil && (profileId == nextProfileId || profileId == tunnel.currentProfile?.id))
|
||||||
|
|
||||||
if let requiredFeatures {
|
|
||||||
PurchaseRequiredView(features: requiredFeatures)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(width: 24)
|
.frame(width: 24)
|
||||||
}
|
}
|
||||||
|
@ -33,7 +33,7 @@ struct VerificationView: View {
|
|||||||
Text(Strings.Views.App.Folders.default)
|
Text(Strings.Views.App.Folders.default)
|
||||||
if isVerifying {
|
if isVerifying {
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(Strings.Views.Paywall.Alerts.Verifying.title.withTrailingDots)
|
Text(Strings.Views.Verification.message.withTrailingDots)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -192,7 +192,7 @@ private extension MigrateView {
|
|||||||
pp_log(.App.migration, .notice, "Migrated \(migrated.count) profiles")
|
pp_log(.App.migration, .notice, "Migrated \(migrated.count) profiles")
|
||||||
|
|
||||||
// TODO: ### restore auto-deletion after stable 3.0.0, otherwise users could not downgrade
|
// TODO: ### restore auto-deletion after stable 3.0.0, otherwise users could not downgrade
|
||||||
// if !iapManager.isRestricted {
|
// if !iapManager.isBeta {
|
||||||
// do {
|
// do {
|
||||||
// try await migrationManager.deleteMigratableProfiles(withIds: Set(migrated.map(\.id)))
|
// try await migrationManager.deleteMigratableProfiles(withIds: Set(migrated.map(\.id)))
|
||||||
// pp_log(.App.migration, .notice, "Discarded \(migrated.count) migrated profiles from old store")
|
// pp_log(.App.migration, .notice, "Discarded \(migrated.count) migrated profiles from old store")
|
||||||
|
@ -69,11 +69,7 @@ struct ProfileCoordinator: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
contentView
|
contentView
|
||||||
.modifier(PaywallModifier(
|
.modifier(PaywallModifier(reason: $paywallReason))
|
||||||
reason: $paywallReason,
|
|
||||||
okTitle: Strings.Views.Profile.Alerts.Purchase.Buttons.ok,
|
|
||||||
okAction: onDismiss
|
|
||||||
))
|
|
||||||
.withErrorHandler(errorHandler)
|
.withErrorHandler(errorHandler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -121,10 +117,10 @@ private extension ProfileCoordinator {
|
|||||||
|
|
||||||
func onCommitEditing() async throws {
|
func onCommitEditing() async throws {
|
||||||
do {
|
do {
|
||||||
if !iapManager.isRestricted {
|
if !iapManager.isBeta {
|
||||||
try await onCommitEditingStandard()
|
try await onCommitEditingStandard()
|
||||||
} else {
|
} else {
|
||||||
try await onCommitEditingRestricted()
|
try await onCommitEditingBeta()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
errorHandler.handle(error, title: Strings.Global.Actions.save)
|
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 {
|
func onCommitEditingStandard() async throws {
|
||||||
let savedProfile = try await profileEditor.save(to: profileManager, preferencesManager: preferencesManager)
|
|
||||||
do {
|
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) {
|
} catch AppError.ineligibleProfile(let requiredFeatures) {
|
||||||
guard !iapManager.isLoadingReceipt else {
|
guard !iapManager.isLoadingReceipt else {
|
||||||
|
let V = Strings.Views.Paywall.Alerts.Verification.self
|
||||||
errorHandler.handle(
|
errorHandler.handle(
|
||||||
title: Strings.Views.Paywall.Alerts.Verifying.title,
|
title: Strings.Views.Paywall.Alerts.Confirmation.title,
|
||||||
message: Strings.Views.Paywall.Alerts.Verifying.message
|
message: [V.edit, V.boot].joined(separator: " ")
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
paywallReason = .init(requiredFeatures, needsConfirmation: true)
|
paywallReason = .init(requiredFeatures, forConnecting: false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
// restricted: verify before saving
|
// beta: skip verification
|
||||||
func onCommitEditingRestricted() async throws {
|
func onCommitEditingBeta() async throws {
|
||||||
do {
|
let profileToSave = try profileEditor.build()
|
||||||
try iapManager.verify(profileEditor.activeModules, extra: profileEditor.extraFeatures)
|
try await profileEditor.save(profileToSave, to: profileManager, preferencesManager: preferencesManager)
|
||||||
} 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)
|
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -90,7 +90,7 @@ private extension StorageSection {
|
|||||||
if iapManager.isEligible(for: .appleTV) {
|
if iapManager.isEligible(for: .appleTV) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !iapManager.isRestricted {
|
if !iapManager.isBeta {
|
||||||
return Strings.Modules.General.Sections.Storage.Footer.Purchase.tvRelease
|
return Strings.Modules.General.Sections.Storage.Footer.Purchase.tvRelease
|
||||||
} else {
|
} else {
|
||||||
return Strings.Modules.General.Sections.Storage.Footer.Purchase.tvBeta
|
return Strings.Modules.General.Sections.Storage.Footer.Purchase.tvBeta
|
||||||
|
@ -43,6 +43,9 @@ public struct AppCoordinator: View, AppCoordinatorConforming {
|
|||||||
@State
|
@State
|
||||||
private var paywallReason: PaywallReason?
|
private var paywallReason: PaywallReason?
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var onCancelPaywall: (() -> Void)?
|
||||||
|
|
||||||
@StateObject
|
@StateObject
|
||||||
private var interactiveManager = InteractiveManager()
|
private var interactiveManager = InteractiveManager()
|
||||||
|
|
||||||
@ -75,7 +78,10 @@ public struct AppCoordinator: View, AppCoordinatorConforming {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationDestination(for: AppCoordinatorRoute.self, destination: pushDestination)
|
.navigationDestination(for: AppCoordinatorRoute.self, destination: pushDestination)
|
||||||
.modifier(PaywallModifier(reason: $paywallReason))
|
.modifier(PaywallModifier(
|
||||||
|
reason: $paywallReason,
|
||||||
|
onCancel: onCancelPaywall
|
||||||
|
))
|
||||||
.withErrorHandler(errorHandler)
|
.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 {
|
guard !iapManager.isLoadingReceipt else {
|
||||||
|
let V = Strings.Views.Paywall.Alerts.Verification.self
|
||||||
|
pp_log(.app, .info, "Present verification alert")
|
||||||
errorHandler.handle(
|
errorHandler.handle(
|
||||||
title: Strings.Views.Paywall.Alerts.Verifying.title,
|
title: Strings.Views.Paywall.Alerts.Confirmation.title,
|
||||||
message: Strings.Views.Paywall.Alerts.Verifying.message
|
message: [
|
||||||
|
V.Connect._1,
|
||||||
|
V.boot,
|
||||||
|
V.Connect._2(iapManager.verificationDelayMinutes)
|
||||||
|
].joined(separator: " "),
|
||||||
|
onDismiss: onCancel
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pp_log(.app, .info, "Present paywall for features: \(features)")
|
pp_log(.app, .info, "Present paywall")
|
||||||
setLater(.init(features, needsConfirmation: true)) {
|
onCancelPaywall = onCancel
|
||||||
|
setLater(.init(features)) {
|
||||||
paywallReason = $0
|
paywallReason = $0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -38,7 +38,7 @@ public enum AppUserLevel: Int, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension AppUserLevel {
|
extension AppUserLevel {
|
||||||
public var isRestricted: Bool {
|
public var isBeta: Bool {
|
||||||
self == .beta
|
self == .beta
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -29,6 +29,8 @@ import PassepartoutKit
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class ExtendedTunnel: ObservableObject {
|
public final class ExtendedTunnel: ObservableObject {
|
||||||
|
public static nonisolated let isManualKey = "isManual"
|
||||||
|
|
||||||
private let defaults: UserDefaults?
|
private let defaults: UserDefaults?
|
||||||
|
|
||||||
private let tunnel: Tunnel
|
private let tunnel: Tunnel
|
||||||
@ -102,7 +104,12 @@ extension ExtendedTunnel {
|
|||||||
public func install(_ profile: Profile) async throws {
|
public func install(_ profile: Profile) async throws {
|
||||||
pp_log(.app, .notice, "Install profile \(profile.id)...")
|
pp_log(.app, .notice, "Install profile \(profile.id)...")
|
||||||
let newProfile = try processedProfile(profile)
|
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 {
|
public func connect(with profile: Profile, force: Bool = false) async throws {
|
||||||
@ -111,7 +118,12 @@ extension ExtendedTunnel {
|
|||||||
if !force && newProfile.isInteractive {
|
if !force && newProfile.isInteractive {
|
||||||
throw AppError.interactiveLogin
|
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 {
|
public func disconnect() async throws {
|
||||||
@ -176,10 +188,13 @@ private extension ExtendedTunnel {
|
|||||||
guard let self else {
|
guard let self else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard tunnel.status == .active else {
|
if let lastErrorCode = value(forKey: TunnelEnvironmentKeys.lastErrorCode),
|
||||||
return
|
lastErrorCode != self.lastErrorCode {
|
||||||
|
self.lastErrorCode = lastErrorCode
|
||||||
|
}
|
||||||
|
if tunnel.status == .active {
|
||||||
|
dataCount = value(forKey: TunnelEnvironmentKeys.dataCount)
|
||||||
}
|
}
|
||||||
dataCount = value(forKey: TunnelEnvironmentKeys.dataCount)
|
|
||||||
}
|
}
|
||||||
.store(in: &subscriptions)
|
.store(in: &subscriptions)
|
||||||
}
|
}
|
||||||
|
@ -86,7 +86,7 @@ extension IAPManager {
|
|||||||
|
|
||||||
public func purchasableProducts(for products: [AppProduct]) async throws -> [InAppProduct] {
|
public func purchasableProducts(for products: [AppProduct]) async throws -> [InAppProduct] {
|
||||||
do {
|
do {
|
||||||
let inAppProducts = try await inAppHelper.fetchProducts()
|
let inAppProducts = try await inAppHelper.fetchProducts(timeout: Constants.shared.iap.productsTimeoutInterval)
|
||||||
return products.compactMap {
|
return products.compactMap {
|
||||||
inAppProducts[$0]
|
inAppProducts[$0]
|
||||||
}
|
}
|
||||||
@ -126,8 +126,8 @@ extension IAPManager {
|
|||||||
// MARK: - Eligibility
|
// MARK: - Eligibility
|
||||||
|
|
||||||
extension IAPManager {
|
extension IAPManager {
|
||||||
public var isRestricted: Bool {
|
public var isBeta: Bool {
|
||||||
userLevel.isRestricted
|
userLevel.isBeta
|
||||||
}
|
}
|
||||||
|
|
||||||
public func isEligible(for feature: AppFeature) -> Bool {
|
public func isEligible(for feature: AppFeature) -> Bool {
|
||||||
@ -259,7 +259,7 @@ extension IAPManager {
|
|||||||
.store(in: &subscriptions)
|
.store(in: &subscriptions)
|
||||||
|
|
||||||
if withProducts {
|
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))")
|
pp_log(.App.iap, .info, "Available in-app products: \(products.map(\.key))")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -267,10 +267,8 @@ extension IAPManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private extension IAPManager {
|
public func fetchLevelIfNeeded() async {
|
||||||
func fetchLevelIfNeeded() async {
|
|
||||||
guard userLevel == .undefined else {
|
guard userLevel == .undefined else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -36,14 +36,6 @@ extension BundleConfiguration {
|
|||||||
public static var urlForTunnelLog: URL {
|
public static var urlForTunnelLog: URL {
|
||||||
urlForCaches.appending(path: Constants.shared.log.tunnelPath)
|
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)
|
// App Group container is not available on tvOS (#1007)
|
||||||
|
@ -98,19 +98,42 @@ public struct Constants: Decodable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct Tunnel: 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 profileTitleFormat: String
|
||||||
|
|
||||||
public let refreshInterval: TimeInterval
|
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 struct API: Decodable, Sendable {
|
||||||
public let timeoutInterval: TimeInterval
|
public let timeoutInterval: TimeInterval
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct IAP: Decodable, Sendable {
|
||||||
|
public let productsTimeoutInterval: Int
|
||||||
|
}
|
||||||
|
|
||||||
public struct Log: Decodable, Sendable {
|
public struct Log: Decodable, Sendable {
|
||||||
public struct Formatter: Decodable, Sendable {
|
public struct Formatter: Decodable, Sendable {
|
||||||
enum CodingKeys: CodingKey {
|
enum CodingKeys: CodingKey {
|
||||||
@ -146,8 +169,6 @@ public struct Constants: Decodable, Sendable {
|
|||||||
public let sinceLast: TimeInterval
|
public let sinceLast: TimeInterval
|
||||||
|
|
||||||
public let options: LocalLogger.Options
|
public let options: LocalLogger.Options
|
||||||
|
|
||||||
public let maxAge: TimeInterval?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public let bundleKey: String
|
public let bundleKey: String
|
||||||
@ -164,5 +185,7 @@ public struct Constants: Decodable, Sendable {
|
|||||||
|
|
||||||
public let api: API
|
public let api: API
|
||||||
|
|
||||||
|
public let iap: IAP
|
||||||
|
|
||||||
public let log: Log
|
public let log: Log
|
||||||
}
|
}
|
||||||
|
@ -57,3 +57,9 @@ extension IAPManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension IAPManager {
|
||||||
|
public var verificationDelayMinutes: Int {
|
||||||
|
Constants.shared.tunnel.verificationDelayMinutes(isBeta: isBeta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -27,21 +27,32 @@
|
|||||||
"tunnel": {
|
"tunnel": {
|
||||||
"profileTitleFormat": "Passepartout: %@",
|
"profileTitleFormat": "Passepartout: %@",
|
||||||
"refreshInterval": 3.0,
|
"refreshInterval": 3.0,
|
||||||
"betaReceiptPath": "beta-receipt",
|
"verification": {
|
||||||
"eligibilityCheckInterval": 3600.0
|
"production": {
|
||||||
|
"delay": 120.0,
|
||||||
|
"interval": 3600.0
|
||||||
|
},
|
||||||
|
"beta": {
|
||||||
|
"delay": 600.0,
|
||||||
|
"interval": 600.0
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"api": {
|
"api": {
|
||||||
"timeoutInterval": 5.0
|
"timeoutInterval": 5.0
|
||||||
},
|
},
|
||||||
|
"iap": {
|
||||||
|
"productsTimeoutInterval": 10.0
|
||||||
|
},
|
||||||
"log": {
|
"log": {
|
||||||
"appPath": "app.log",
|
"appPath": "app.log",
|
||||||
"tunnelPath": "tunnel.log",
|
"tunnelPath": "tunnel.log",
|
||||||
"sinceLast": 86400,
|
"sinceLast": 86400.0,
|
||||||
"options": {
|
"options": {
|
||||||
"maxLevel": 3,
|
"maxLevel": 3,
|
||||||
"maxSize": 500000,
|
"maxSize": 500000,
|
||||||
"maxBufferedLines": 5000,
|
"maxBufferedLines": 5000,
|
||||||
"maxAge": 86400
|
"maxAge": 86400.0
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"timestamp": "HH:mm:ss",
|
"timestamp": "HH:mm:ss",
|
||||||
|
@ -45,5 +45,5 @@ public protocol AppTunnelProcessor: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public protocol PacketTunnelProcessor: Sendable {
|
public protocol PacketTunnelProcessor: Sendable {
|
||||||
nonisolated func willStart(_ profile: Profile) throws -> Profile
|
nonisolated func willProcess(_ profile: Profile) throws -> Profile
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// FallbackReceiptReader.swift
|
// SharedReceiptReader.swift
|
||||||
// Passepartout
|
// Passepartout
|
||||||
//
|
//
|
||||||
// Created by Davide De Rosa on 11/6/24.
|
// Created by Davide De Rosa on 11/6/24.
|
||||||
@ -27,19 +27,13 @@ import CommonUtils
|
|||||||
import Foundation
|
import Foundation
|
||||||
import PassepartoutKit
|
import PassepartoutKit
|
||||||
|
|
||||||
public actor FallbackReceiptReader: AppReceiptReader {
|
public actor SharedReceiptReader: AppReceiptReader {
|
||||||
private let mainReader: InAppReceiptReader
|
private let reader: InAppReceiptReader
|
||||||
|
|
||||||
private nonisolated let betaReader: InAppReceiptReader?
|
|
||||||
|
|
||||||
private var pendingTask: Task<InAppReceipt?, Never>?
|
private var pendingTask: Task<InAppReceipt?, Never>?
|
||||||
|
|
||||||
public init(
|
public init(reader: InAppReceiptReader & Sendable) {
|
||||||
main mainReader: InAppReceiptReader & Sendable,
|
self.reader = reader
|
||||||
beta betaReader: (InAppReceiptReader & Sendable)?
|
|
||||||
) {
|
|
||||||
self.mainReader = mainReader
|
|
||||||
self.betaReader = betaReader
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func receipt(at userLevel: AppUserLevel) async -> InAppReceipt? {
|
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? {
|
func asyncReceipt(at userLevel: AppUserLevel) async -> InAppReceipt? {
|
||||||
pp_log(.App.iap, .info, "\tParse receipt for user level \(userLevel)")
|
pp_log(.App.iap, .info, "\tParse receipt for user level \(userLevel)")
|
||||||
if userLevel == .beta, let betaReader {
|
pp_log(.App.iap, .info, "\tRead receipt")
|
||||||
pp_log(.App.iap, .info, "\tTestFlight, read beta receipt")
|
return await reader.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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -26,12 +26,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension Bundle {
|
extension Bundle {
|
||||||
public var appStoreProductionReceiptURL: URL? {
|
|
||||||
appStoreReceiptURL?
|
|
||||||
.deletingLastPathComponent()
|
|
||||||
.appendingPathComponent("receipt") // could be "sandboxReceipt"
|
|
||||||
}
|
|
||||||
|
|
||||||
public func unsafeDecode<T: Decodable>(_ type: T.Type, filename: String) -> T {
|
public func unsafeDecode<T: Decodable>(_ type: T.Type, filename: String) -> T {
|
||||||
guard let jsonURL = url(forResource: filename, withExtension: "json") else {
|
guard let jsonURL = url(forResource: filename, withExtension: "json") else {
|
||||||
fatalError("Unable to find \(filename).json in bundle")
|
fatalError("Unable to find \(filename).json in bundle")
|
||||||
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
@ -71,12 +71,6 @@ public protocol InAppHelper {
|
|||||||
func restorePurchases() async throws
|
func restorePurchases() async throws
|
||||||
}
|
}
|
||||||
|
|
||||||
extension InAppHelper {
|
|
||||||
public func fetchProducts() async throws -> [ProductType: InAppProduct] {
|
|
||||||
try await fetchProducts(timeout: 3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct InAppReceipt: Sendable {
|
public struct InAppReceipt: Sendable {
|
||||||
public struct PurchaseReceipt: Sendable {
|
public struct PurchaseReceipt: Sendable {
|
||||||
public let productIdentifier: String?
|
public let productIdentifier: String?
|
||||||
|
@ -65,21 +65,8 @@ extension StoreKitHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func fetchProducts(timeout: Int) async throws -> [ProductType: InAppProduct] {
|
public func fetchProducts(timeout: Int) async throws -> [ProductType: InAppProduct] {
|
||||||
let skProducts = try await withThrowingTaskGroup(of: [Product]?.self) { group in
|
let skProducts = try await performTask(withTimeout: timeout) {
|
||||||
group.addTask {
|
try await Product.products(for: self.products.map(self.inAppIdentifier))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
return skProducts.reduce(into: [:]) {
|
return skProducts.reduce(into: [:]) {
|
||||||
guard let pid = ProductType(rawValue: $1.id) else {
|
guard let pid = ProductType(rawValue: $1.id) else {
|
||||||
|
@ -34,40 +34,9 @@ public final class StoreKitReceiptReader: InAppReceiptReader, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func receipt() async -> InAppReceipt? {
|
public func receipt() async -> InAppReceipt? {
|
||||||
var startDate: Date
|
let result = await entitlements()
|
||||||
var elapsed: TimeInterval
|
|
||||||
|
|
||||||
startDate = Date()
|
let purchaseReceipts = result.txs
|
||||||
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
|
|
||||||
.compactMap {
|
.compactMap {
|
||||||
InAppReceipt.PurchaseReceipt(
|
InAppReceipt.PurchaseReceipt(
|
||||||
productIdentifier: $0.productID,
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -46,8 +46,6 @@ public final class AppContext: ObservableObject, Sendable {
|
|||||||
|
|
||||||
public let tunnel: ExtendedTunnel
|
public let tunnel: ExtendedTunnel
|
||||||
|
|
||||||
private let tunnelReceiptURL: URL?
|
|
||||||
|
|
||||||
private let onEligibleFeaturesBlock: ((Set<AppFeature>) async -> Void)?
|
private let onEligibleFeaturesBlock: ((Set<AppFeature>) async -> Void)?
|
||||||
|
|
||||||
private var launchTask: Task<Void, Error>?
|
private var launchTask: Task<Void, Error>?
|
||||||
@ -64,7 +62,6 @@ public final class AppContext: ObservableObject, Sendable {
|
|||||||
profileManager: ProfileManager,
|
profileManager: ProfileManager,
|
||||||
registry: Registry,
|
registry: Registry,
|
||||||
tunnel: ExtendedTunnel,
|
tunnel: ExtendedTunnel,
|
||||||
tunnelReceiptURL: URL?,
|
|
||||||
onEligibleFeaturesBlock: ((Set<AppFeature>) async -> Void)? = nil
|
onEligibleFeaturesBlock: ((Set<AppFeature>) async -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
self.apiManager = apiManager
|
self.apiManager = apiManager
|
||||||
@ -74,7 +71,6 @@ public final class AppContext: ObservableObject, Sendable {
|
|||||||
self.profileManager = profileManager
|
self.profileManager = profileManager
|
||||||
self.registry = registry
|
self.registry = registry
|
||||||
self.tunnel = tunnel
|
self.tunnel = tunnel
|
||||||
self.tunnelReceiptURL = tunnelReceiptURL
|
|
||||||
self.onEligibleFeaturesBlock = onEligibleFeaturesBlock
|
self.onEligibleFeaturesBlock = onEligibleFeaturesBlock
|
||||||
subscriptions = []
|
subscriptions = []
|
||||||
}
|
}
|
||||||
@ -136,18 +132,6 @@ private extension AppContext {
|
|||||||
}
|
}
|
||||||
.store(in: &subscriptions)
|
.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 {
|
do {
|
||||||
pp_log(.app, .info, "\tFetch providers index...")
|
pp_log(.app, .info, "\tFetch providers index...")
|
||||||
try await apiManager.fetchIndex(from: API.shared)
|
try await apiManager.fetchIndex(from: API.shared)
|
||||||
|
@ -29,7 +29,7 @@ import PassepartoutKit
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class InteractiveManager: ObservableObject {
|
public final class InteractiveManager: ObservableObject {
|
||||||
public typealias CompletionBlock = (Profile) async throws -> Void
|
public typealias CompletionBlock = (Profile) throws -> Void
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
public var isPresented = false
|
public var isPresented = false
|
||||||
@ -48,9 +48,9 @@ public final class InteractiveManager: ObservableObject {
|
|||||||
isPresented = true
|
isPresented = true
|
||||||
}
|
}
|
||||||
|
|
||||||
public func complete() async throws {
|
public func complete() throws {
|
||||||
isPresented = false
|
isPresented = false
|
||||||
let newProfile = try editor.build()
|
let newProfile = try editor.build()
|
||||||
try await onComplete?(newProfile)
|
try onComplete?(newProfile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -202,11 +202,9 @@ extension ProfileEditor {
|
|||||||
removedModules = [:]
|
removedModules = [:]
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
public func save(_ profileToSave: Profile, to profileManager: ProfileManager, preferencesManager: PreferencesManager) async throws {
|
||||||
public func save(to profileManager: ProfileManager, preferencesManager: PreferencesManager) async throws -> Profile {
|
|
||||||
do {
|
do {
|
||||||
let newProfile = try build()
|
try await profileManager.save(profileToSave, isLocal: true, remotelyShared: isShared)
|
||||||
try await profileManager.save(newProfile, isLocal: true, remotelyShared: isShared)
|
|
||||||
|
|
||||||
removedModules.keys.forEach {
|
removedModules.keys.forEach {
|
||||||
do {
|
do {
|
||||||
@ -219,8 +217,6 @@ extension ProfileEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
removedModules.removeAll()
|
removedModules.removeAll()
|
||||||
|
|
||||||
return newProfile
|
|
||||||
} catch {
|
} catch {
|
||||||
pp_log(.App.profiles, .fault, "Unable to save edited profile: \(error)")
|
pp_log(.App.profiles, .fault, "Unable to save edited profile: \(error)")
|
||||||
throw error
|
throw error
|
||||||
|
@ -28,15 +28,23 @@ import Foundation
|
|||||||
import PassepartoutKit
|
import PassepartoutKit
|
||||||
|
|
||||||
extension AppCoordinatorConforming {
|
extension AppCoordinatorConforming {
|
||||||
public func onConnect(_ profile: Profile, force: Bool) async {
|
public func onConnect(_ profile: Profile, force: Bool, verify: Bool = true) async {
|
||||||
do {
|
do {
|
||||||
try iapManager.verify(profile)
|
if verify {
|
||||||
|
try iapManager.verify(profile)
|
||||||
|
}
|
||||||
try await tunnel.connect(with: profile, force: force)
|
try await tunnel.connect(with: profile, force: force)
|
||||||
} catch AppError.ineligibleProfile(let requiredFeatures) {
|
} catch AppError.ineligibleProfile(let requiredFeatures) {
|
||||||
onPurchaseRequired(requiredFeatures)
|
onPurchaseRequired(requiredFeatures) {
|
||||||
|
Task {
|
||||||
|
await onConnect(profile, force: force, verify: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch AppError.interactiveLogin {
|
} catch AppError.interactiveLogin {
|
||||||
onInteractiveLogin(profile) {
|
onInteractiveLogin(profile) { newProfile in
|
||||||
await onConnect($0, force: true)
|
Task {
|
||||||
|
await onConnect(newProfile, force: true, verify: verify)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch let ppError as PassepartoutError {
|
} catch let ppError as PassepartoutError {
|
||||||
switch ppError.code {
|
switch ppError.code {
|
||||||
|
@ -59,6 +59,12 @@ extension AppError: LocalizedError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension TaskTimeoutError: PassepartoutErrorMappable {
|
||||||
|
public var asPassepartoutError: PassepartoutError {
|
||||||
|
PassepartoutError(.timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - App side
|
// MARK: - App side
|
||||||
|
|
||||||
extension PassepartoutError: @retroactive LocalizedError {
|
extension PassepartoutError: @retroactive LocalizedError {
|
||||||
@ -102,6 +108,9 @@ extension PassepartoutError: @retroactive LocalizedError {
|
|||||||
case .providerRequired:
|
case .providerRequired:
|
||||||
return Strings.Errors.App.Passepartout.providerRequired
|
return Strings.Errors.App.Passepartout.providerRequired
|
||||||
|
|
||||||
|
case .timeout:
|
||||||
|
return Strings.Errors.App.Passepartout.timeout
|
||||||
|
|
||||||
case .unhandled:
|
case .unhandled:
|
||||||
return reason?.localizedDescription
|
return reason?.localizedDescription
|
||||||
|
|
||||||
|
@ -139,6 +139,8 @@ public enum Strings {
|
|||||||
public static let parsing = Strings.tr("Localizable", "errors.app.passepartout.parsing", fallback: "Unable to parse file.")
|
public static let parsing = Strings.tr("Localizable", "errors.app.passepartout.parsing", fallback: "Unable to parse file.")
|
||||||
/// No provider selected.
|
/// No provider selected.
|
||||||
public static let providerRequired = Strings.tr("Localizable", "errors.app.passepartout.provider_required", fallback: "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 {
|
public enum Tunnel {
|
||||||
@ -596,8 +598,6 @@ public enum Strings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
public enum App {
|
public enum App {
|
||||||
/// Verifying purchases...
|
|
||||||
public static let verifyingPurchases = Strings.tr("Localizable", "views.app.verifying_purchases", fallback: "Verifying purchases...")
|
|
||||||
public enum Folders {
|
public enum Folders {
|
||||||
/// My profiles
|
/// My profiles
|
||||||
public static let `default` = Strings.tr("Localizable", "views.app.folders.default", fallback: "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.")
|
public static let message = Strings.tr("Localizable", "views.paywall.alerts.confirmation.message", fallback: "This profile requires paid features to work.")
|
||||||
/// Purchase required
|
/// Purchase required
|
||||||
public static let title = Strings.tr("Localizable", "views.paywall.alerts.confirmation.title", fallback: "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 {
|
public enum Pending {
|
||||||
/// The purchase is pending external confirmation. The feature will be credited upon approval.
|
/// The purchase is pending external confirmation. The feature will be credited upon approval.
|
||||||
@ -751,11 +757,19 @@ public enum Strings {
|
|||||||
/// Restricted
|
/// Restricted
|
||||||
public static let title = Strings.tr("Localizable", "views.paywall.alerts.restricted.title", fallback: "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.
|
/// 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.")
|
public static let edit = Strings.tr("Localizable", "views.paywall.alerts.verification.edit", fallback: "Please wait while your purchases are being verified.")
|
||||||
/// Verifying
|
public enum Connect {
|
||||||
public static let title = Strings.tr("Localizable", "views.paywall.alerts.verifying.title", fallback: "Verifying")
|
/// 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 {
|
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 {
|
public enum Version {
|
||||||
/// %@ is a project maintained by %@.
|
/// %@ is a project maintained by %@.
|
||||||
///
|
///
|
||||||
|
@ -65,8 +65,7 @@ extension AppContext {
|
|||||||
preferencesManager: PreferencesManager(),
|
preferencesManager: PreferencesManager(),
|
||||||
profileManager: profileManager,
|
profileManager: profileManager,
|
||||||
registry: Registry(),
|
registry: Registry(),
|
||||||
tunnel: tunnel,
|
tunnel: tunnel
|
||||||
tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt
|
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
@ -36,6 +36,7 @@
|
|||||||
"errors.app.passepartout.no_active_modules" = "Das Profil hat keine aktiven Module.";
|
"errors.app.passepartout.no_active_modules" = "Das Profil hat keine aktiven Module.";
|
||||||
"errors.app.passepartout.parsing" = "Datei konnte nicht analysiert werden.";
|
"errors.app.passepartout.parsing" = "Datei konnte nicht analysiert werden.";
|
||||||
"errors.app.passepartout.provider_required" = "Kein Anbieter ausgewählt.";
|
"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.permission_denied" = "Zugriff verweigert";
|
||||||
"errors.app.tunnel" = "Aktion konnte nicht ausgeführt werden.";
|
"errors.app.tunnel" = "Aktion konnte nicht ausgeführt werden.";
|
||||||
"errors.tunnel.auth" = "Authentifizierung fehlgeschlagen";
|
"errors.tunnel.auth" = "Authentifizierung fehlgeschlagen";
|
||||||
@ -222,7 +223,6 @@
|
|||||||
"views.app.toolbar.new_profile.empty" = "Leeres Profil";
|
"views.app.toolbar.new_profile.empty" = "Leeres Profil";
|
||||||
"views.app.toolbar.new_profile.provider" = "Anbieter";
|
"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.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.app_menu.items.quit" = "Beende %@";
|
||||||
"views.diagnostics.alerts.report_issue.email" = "Das Gerät ist nicht zum Senden von E-Mails konfiguriert.";
|
"views.diagnostics.alerts.report_issue.email" = "Das Gerät ist nicht zum Senden von E-Mails konfiguriert.";
|
||||||
"views.diagnostics.openvpn.rows.server_configuration" = "Serverkonfiguration";
|
"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.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.migration.title" = "Migrieren";
|
||||||
"views.paywall.alerts.confirmation.message" = "Dieses Profil erfordert kostenpflichtige Funktionen, um zu funktionieren.";
|
"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.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.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.message" = "Einige Funktionen sind in dieser Version nicht verfügbar.";
|
||||||
"views.paywall.alerts.restricted.title" = "Eingeschränkt";
|
"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.verification.boot" = "Dies kann etwas länger dauern, wenn Ihr Gerät gerade gestartet wurde.";
|
||||||
"views.paywall.alerts.verifying.title" = "Überprüfung";
|
"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.rows.restore_purchases" = "Käufe wiederherstellen";
|
||||||
"views.paywall.sections.all_features.header" = "Die Vollversion enthält";
|
"views.paywall.sections.all_features.header" = "Die Vollversion enthält";
|
||||||
"views.paywall.sections.full_products.header" = "Vollversion";
|
"views.paywall.sections.full_products.header" = "Vollversion";
|
||||||
@ -286,6 +289,7 @@
|
|||||||
"views.ui.connection_status.on_demand_suffix" = " (auf Anfrage)";
|
"views.ui.connection_status.on_demand_suffix" = " (auf Anfrage)";
|
||||||
"views.ui.purchase_required.purchase.help" = "Kauf erforderlich";
|
"views.ui.purchase_required.purchase.help" = "Kauf erforderlich";
|
||||||
"views.ui.purchase_required.restricted.help" = "Funktion eingeschränkt";
|
"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.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.category.any" = "Alle Kategorien";
|
||||||
"views.vpn.no_servers" = "Keine Server";
|
"views.vpn.no_servers" = "Keine Server";
|
||||||
|
@ -36,6 +36,7 @@
|
|||||||
"errors.app.passepartout.no_active_modules" = "Το προφίλ δεν έχει ενεργές μονάδες.";
|
"errors.app.passepartout.no_active_modules" = "Το προφίλ δεν έχει ενεργές μονάδες.";
|
||||||
"errors.app.passepartout.parsing" = "Δεν ήταν δυνατή η ανάλυση αρχείου.";
|
"errors.app.passepartout.parsing" = "Δεν ήταν δυνατή η ανάλυση αρχείου.";
|
||||||
"errors.app.passepartout.provider_required" = "Δεν έχει επιλεγεί πάροχος.";
|
"errors.app.passepartout.provider_required" = "Δεν έχει επιλεγεί πάροχος.";
|
||||||
|
"errors.app.passepartout.timeout" = "Η λειτουργία έληξε λόγω χρονικού ορίου.";
|
||||||
"errors.app.permission_denied" = "Άρνηση άδειας";
|
"errors.app.permission_denied" = "Άρνηση άδειας";
|
||||||
"errors.app.tunnel" = "Δεν ήταν δυνατή η εκτέλεση της ενέργειας.";
|
"errors.app.tunnel" = "Δεν ήταν δυνατή η εκτέλεση της ενέργειας.";
|
||||||
"errors.tunnel.auth" = "Η επαλήθευση απέτυχε";
|
"errors.tunnel.auth" = "Η επαλήθευση απέτυχε";
|
||||||
@ -222,7 +223,6 @@
|
|||||||
"views.app.toolbar.new_profile.empty" = "Κενό προφίλ";
|
"views.app.toolbar.new_profile.empty" = "Κενό προφίλ";
|
||||||
"views.app.toolbar.new_profile.provider" = "Πάροχος";
|
"views.app.toolbar.new_profile.provider" = "Πάροχος";
|
||||||
"views.app.tv.header" = "Ανοίξτε %@ στη συσκευή σας iOS ή macOS και ενεργοποιήστε το διακόπτη \"%@\" ενός προφίλ για να εμφανιστεί εδώ.";
|
"views.app.tv.header" = "Ανοίξτε %@ στη συσκευή σας iOS ή macOS και ενεργοποιήστε το διακόπτη \"%@\" ενός προφίλ για να εμφανιστεί εδώ.";
|
||||||
"views.app.verifying_purchases" = "Επαλήθευση αγορών...";
|
|
||||||
"views.app_menu.items.quit" = "Κλείσιμο %@";
|
"views.app_menu.items.quit" = "Κλείσιμο %@";
|
||||||
"views.diagnostics.alerts.report_issue.email" = "Η συσκευή δεν είναι ρυθμισμένη για αποστολή email.";
|
"views.diagnostics.alerts.report_issue.email" = "Η συσκευή δεν είναι ρυθμισμένη για αποστολή email.";
|
||||||
"views.diagnostics.openvpn.rows.server_configuration" = "Διαμόρφωση διακομιστή";
|
"views.diagnostics.openvpn.rows.server_configuration" = "Διαμόρφωση διακομιστή";
|
||||||
@ -244,12 +244,15 @@
|
|||||||
"views.migration.sections.main.header" = "Επιλέξτε παρακάτω τα προφίλ από τις παλιές εκδόσεις του %@ που θέλετε να εισάγετε. Εάν τα προφίλ σας είναι αποθηκευμένα στο iCloud, μπορεί να χρειαστεί λίγος χρόνος για να συγχρονιστούν. Εάν δεν τα βλέπετε τώρα, επιστρέψτε αργότερα.";
|
"views.migration.sections.main.header" = "Επιλέξτε παρακάτω τα προφίλ από τις παλιές εκδόσεις του %@ που θέλετε να εισάγετε. Εάν τα προφίλ σας είναι αποθηκευμένα στο iCloud, μπορεί να χρειαστεί λίγος χρόνος για να συγχρονιστούν. Εάν δεν τα βλέπετε τώρα, επιστρέψτε αργότερα.";
|
||||||
"views.migration.title" = "Μεταφορά";
|
"views.migration.title" = "Μεταφορά";
|
||||||
"views.paywall.alerts.confirmation.message" = "Αυτό το προφίλ απαιτεί επί πληρωμή λειτουργίες για να λειτουργήσει.";
|
"views.paywall.alerts.confirmation.message" = "Αυτό το προφίλ απαιτεί επί πληρωμή λειτουργίες για να λειτουργήσει.";
|
||||||
|
"views.paywall.alerts.confirmation.message.connect" = "Μπορείτε να δοκιμάσετε τη σύνδεση για %d λεπτά.";
|
||||||
"views.paywall.alerts.confirmation.title" = "Απαιτείται αγορά";
|
"views.paywall.alerts.confirmation.title" = "Απαιτείται αγορά";
|
||||||
"views.paywall.alerts.pending.message" = "Η αγορά εκκρεμεί για εξωτερική επιβεβαίωση. Η λειτουργία θα πιστωθεί μετά την έγκριση.";
|
"views.paywall.alerts.pending.message" = "Η αγορά εκκρεμεί για εξωτερική επιβεβαίωση. Η λειτουργία θα πιστωθεί μετά την έγκριση.";
|
||||||
"views.paywall.alerts.restricted.message" = "Ορισμένες λειτουργίες δεν είναι διαθέσιμες σε αυτήν την έκδοση.";
|
"views.paywall.alerts.restricted.message" = "Ορισμένες λειτουργίες δεν είναι διαθέσιμες σε αυτήν την έκδοση.";
|
||||||
"views.paywall.alerts.restricted.title" = "Περιορισμένο";
|
"views.paywall.alerts.restricted.title" = "Περιορισμένο";
|
||||||
"views.paywall.alerts.verifying.message" = "Παρακαλώ περιμένετε ενώ οι αγορές σας επαληθεύονται.";
|
"views.paywall.alerts.verification.boot" = "Αυτό μπορεί να διαρκέσει λίγο περισσότερο αν η συσκευή σας μόλις ξεκίνησε.";
|
||||||
"views.paywall.alerts.verifying.title" = "Επαλήθευση";
|
"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.rows.restore_purchases" = "Επαναφορά αγορών";
|
||||||
"views.paywall.sections.all_features.header" = "Η πλήρης έκδοση περιλαμβάνει";
|
"views.paywall.sections.all_features.header" = "Η πλήρης έκδοση περιλαμβάνει";
|
||||||
"views.paywall.sections.full_products.header" = "Πλήρης έκδοση";
|
"views.paywall.sections.full_products.header" = "Πλήρης έκδοση";
|
||||||
@ -286,6 +289,7 @@
|
|||||||
"views.ui.connection_status.on_demand_suffix" = " (κατ' απαίτηση)";
|
"views.ui.connection_status.on_demand_suffix" = " (κατ' απαίτηση)";
|
||||||
"views.ui.purchase_required.purchase.help" = "Απαιτείται αγορά";
|
"views.ui.purchase_required.purchase.help" = "Απαιτείται αγορά";
|
||||||
"views.ui.purchase_required.restricted.help" = "Η λειτουργία είναι περιορισμένη";
|
"views.ui.purchase_required.restricted.help" = "Η λειτουργία είναι περιορισμένη";
|
||||||
|
"views.verification.message" = "Επαλήθευση";
|
||||||
"views.version.extra" = "Το %@ είναι ένα έργο που συντηρείται από τον/την %@.\n\nΟ πηγαίος κώδικας είναι διαθέσιμος δημόσια στο GitHub υπό την άδεια GPLv3. Μπορείτε να βρείτε τους συνδέσμους στην αρχική σελίδα.";
|
"views.version.extra" = "Το %@ είναι ένα έργο που συντηρείται από τον/την %@.\n\nΟ πηγαίος κώδικας είναι διαθέσιμος δημόσια στο GitHub υπό την άδεια GPLv3. Μπορείτε να βρείτε τους συνδέσμους στην αρχική σελίδα.";
|
||||||
"views.vpn.category.any" = "Όλες οι κατηγορίες";
|
"views.vpn.category.any" = "Όλες οι κατηγορίες";
|
||||||
"views.vpn.no_servers" = "Δεν υπάρχουν διακομιστές";
|
"views.vpn.no_servers" = "Δεν υπάρχουν διακομιστές";
|
||||||
|
@ -39,7 +39,6 @@
|
|||||||
"views.about.credits.notices" = "Notices";
|
"views.about.credits.notices" = "Notices";
|
||||||
"views.about.credits.translations" = "Translations";
|
"views.about.credits.translations" = "Translations";
|
||||||
|
|
||||||
"views.app.verifying_purchases" = "Verifying purchases...";
|
|
||||||
"views.app.installed_profile.none.name" = "No profile";
|
"views.app.installed_profile.none.name" = "No profile";
|
||||||
"views.app.installed_profile.none.status" = "Tap list to connect";
|
"views.app.installed_profile.none.status" = "Tap list to connect";
|
||||||
"views.app.profile.no_modules" = "No active modules";
|
"views.app.profile.no_modules" = "No active modules";
|
||||||
@ -87,8 +86,11 @@
|
|||||||
"views.paywall.rows.restore_purchases" = "Restore purchases";
|
"views.paywall.rows.restore_purchases" = "Restore purchases";
|
||||||
"views.paywall.alerts.confirmation.title" = "Purchase required";
|
"views.paywall.alerts.confirmation.title" = "Purchase required";
|
||||||
"views.paywall.alerts.confirmation.message" = "This profile requires paid features to work.";
|
"views.paywall.alerts.confirmation.message" = "This profile requires paid features to work.";
|
||||||
"views.paywall.alerts.verifying.title" = "Verifying";
|
"views.paywall.alerts.confirmation.message.connect" = "You may test the connection for %d minutes.";
|
||||||
"views.paywall.alerts.verifying.message" = "Please wait while your purchases are being verified.";
|
"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.title" = "Restricted";
|
||||||
"views.paywall.alerts.restricted.message" = "Some features are unavailable in this build.";
|
"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.";
|
"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.purchase.help" = "Purchase required";
|
||||||
"views.ui.purchase_required.restricted.help" = "Feature is restricted";
|
"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.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";
|
"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.no_active_modules" = "The profile has no active modules.";
|
||||||
"errors.app.passepartout.parsing" = "Unable to parse file.";
|
"errors.app.passepartout.parsing" = "Unable to parse file.";
|
||||||
"errors.app.passepartout.provider_required" = "No provider selected.";
|
"errors.app.passepartout.provider_required" = "No provider selected.";
|
||||||
|
"errors.app.passepartout.timeout" = "The operation timed out.";
|
||||||
|
|
||||||
"errors.tunnel.auth" = "Auth failed";
|
"errors.tunnel.auth" = "Auth failed";
|
||||||
"errors.tunnel.compression" = "Compression unsupported";
|
"errors.tunnel.compression" = "Compression unsupported";
|
||||||
|
@ -36,6 +36,7 @@
|
|||||||
"errors.app.passepartout.no_active_modules" = "El perfil no tiene módulos activos.";
|
"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.parsing" = "No se pudo analizar el archivo.";
|
||||||
"errors.app.passepartout.provider_required" = "No se ha seleccionado proveedor.";
|
"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.permission_denied" = "Permiso denegado";
|
||||||
"errors.app.tunnel" = "No se pudo ejecutar la operación.";
|
"errors.app.tunnel" = "No se pudo ejecutar la operación.";
|
||||||
"errors.tunnel.auth" = "Autenticación fallida";
|
"errors.tunnel.auth" = "Autenticación fallida";
|
||||||
@ -222,7 +223,6 @@
|
|||||||
"views.app.toolbar.new_profile.empty" = "Perfil vacío";
|
"views.app.toolbar.new_profile.empty" = "Perfil vacío";
|
||||||
"views.app.toolbar.new_profile.provider" = "Proveedor";
|
"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.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.app_menu.items.quit" = "Salir de %@";
|
||||||
"views.diagnostics.alerts.report_issue.email" = "El dispositivo no está configurado para enviar correos electrónicos.";
|
"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";
|
"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.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.migration.title" = "Migrar";
|
||||||
"views.paywall.alerts.confirmation.message" = "Este perfil requiere características de pago para funcionar.";
|
"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.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.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.message" = "Algunas características no están disponibles en esta versión.";
|
||||||
"views.paywall.alerts.restricted.title" = "Restringido";
|
"views.paywall.alerts.restricted.title" = "Restringido";
|
||||||
"views.paywall.alerts.verifying.message" = "Por favor, espere mientras se verifican sus compras.";
|
"views.paywall.alerts.verification.boot" = "Esto puede tardar un poco más si tu dispositivo acaba de iniciarse.";
|
||||||
"views.paywall.alerts.verifying.title" = "Verificación";
|
"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.rows.restore_purchases" = "Restaurar compras";
|
||||||
"views.paywall.sections.all_features.header" = "La versión completa incluye";
|
"views.paywall.sections.all_features.header" = "La versión completa incluye";
|
||||||
"views.paywall.sections.full_products.header" = "Versión completa";
|
"views.paywall.sections.full_products.header" = "Versión completa";
|
||||||
@ -286,6 +289,7 @@
|
|||||||
"views.ui.connection_status.on_demand_suffix" = " (a demanda)";
|
"views.ui.connection_status.on_demand_suffix" = " (a demanda)";
|
||||||
"views.ui.purchase_required.purchase.help" = "Compra requerida";
|
"views.ui.purchase_required.purchase.help" = "Compra requerida";
|
||||||
"views.ui.purchase_required.restricted.help" = "Función restringida";
|
"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.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.category.any" = "Todas las categorías";
|
||||||
"views.vpn.no_servers" = "No hay servidores";
|
"views.vpn.no_servers" = "No hay servidores";
|
||||||
|
@ -36,6 +36,7 @@
|
|||||||
"errors.app.passepartout.no_active_modules" = "Le profil n'a pas de modules actifs.";
|
"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.parsing" = "Impossible d'analyser le fichier.";
|
||||||
"errors.app.passepartout.provider_required" = "Aucun fournisseur sélectionné.";
|
"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.permission_denied" = "Permission refusée";
|
||||||
"errors.app.tunnel" = "Impossible d'exécuter l'opération.";
|
"errors.app.tunnel" = "Impossible d'exécuter l'opération.";
|
||||||
"errors.tunnel.auth" = "Échec de l'authentification";
|
"errors.tunnel.auth" = "Échec de l'authentification";
|
||||||
@ -222,7 +223,6 @@
|
|||||||
"views.app.toolbar.new_profile.empty" = "Profil vide";
|
"views.app.toolbar.new_profile.empty" = "Profil vide";
|
||||||
"views.app.toolbar.new_profile.provider" = "Fournisseur";
|
"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.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.app_menu.items.quit" = "Quitter %@";
|
||||||
"views.diagnostics.alerts.report_issue.email" = "L'appareil n'est pas configuré pour envoyer des e-mails.";
|
"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";
|
"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.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.migration.title" = "Migrer";
|
||||||
"views.paywall.alerts.confirmation.message" = "Ce profil nécessite des fonctionnalités payantes pour fonctionner.";
|
"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.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.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.message" = "Certaines fonctionnalités ne sont pas disponibles dans cette version.";
|
||||||
"views.paywall.alerts.restricted.title" = "Restreint";
|
"views.paywall.alerts.restricted.title" = "Restreint";
|
||||||
"views.paywall.alerts.verifying.message" = "Veuillez patienter pendant la vérification de vos achats.";
|
"views.paywall.alerts.verification.boot" = "Cela peut prendre un peu plus de temps si votre appareil vient d’être démarré.";
|
||||||
"views.paywall.alerts.verifying.title" = "Vérification";
|
"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.rows.restore_purchases" = "Restaurer les achats";
|
||||||
"views.paywall.sections.all_features.header" = "La version complète inclut";
|
"views.paywall.sections.all_features.header" = "La version complète inclut";
|
||||||
"views.paywall.sections.full_products.header" = "Version complète";
|
"views.paywall.sections.full_products.header" = "Version complète";
|
||||||
@ -286,6 +289,7 @@
|
|||||||
"views.ui.connection_status.on_demand_suffix" = " (à la demande)";
|
"views.ui.connection_status.on_demand_suffix" = " (à la demande)";
|
||||||
"views.ui.purchase_required.purchase.help" = "Achat requis";
|
"views.ui.purchase_required.purchase.help" = "Achat requis";
|
||||||
"views.ui.purchase_required.restricted.help" = "Fonction restreinte";
|
"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.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.category.any" = "Toutes les catégories";
|
||||||
"views.vpn.no_servers" = "Aucun serveur";
|
"views.vpn.no_servers" = "Aucun serveur";
|
||||||
|
@ -36,6 +36,7 @@
|
|||||||
"errors.app.passepartout.no_active_modules" = "Il profilo non ha moduli attivi.";
|
"errors.app.passepartout.no_active_modules" = "Il profilo non ha moduli attivi.";
|
||||||
"errors.app.passepartout.parsing" = "Impossibile analizzare il file.";
|
"errors.app.passepartout.parsing" = "Impossibile analizzare il file.";
|
||||||
"errors.app.passepartout.provider_required" = "Nessun provider selezionato.";
|
"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.permission_denied" = "Permesso negato";
|
||||||
"errors.app.tunnel" = "Impossibile eseguire l'operazione.";
|
"errors.app.tunnel" = "Impossibile eseguire l'operazione.";
|
||||||
"errors.tunnel.auth" = "Autenticazione fallita";
|
"errors.tunnel.auth" = "Autenticazione fallita";
|
||||||
@ -222,7 +223,6 @@
|
|||||||
"views.app.toolbar.new_profile.empty" = "Profilo vuoto";
|
"views.app.toolbar.new_profile.empty" = "Profilo vuoto";
|
||||||
"views.app.toolbar.new_profile.provider" = "Provider";
|
"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.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.app_menu.items.quit" = "Esci da %@";
|
||||||
"views.diagnostics.alerts.report_issue.email" = "Il dispositivo non è configurato per inviare e-mail.";
|
"views.diagnostics.alerts.report_issue.email" = "Il dispositivo non è configurato per inviare e-mail.";
|
||||||
"views.diagnostics.openvpn.rows.server_configuration" = "Configurazione del server";
|
"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.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.migration.title" = "Migra";
|
||||||
"views.paywall.alerts.confirmation.message" = "Questo profilo richiede funzionalità a pagamento per funzionare.";
|
"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.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.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.message" = "Alcune funzionalità non sono disponibili in questa versione.";
|
||||||
"views.paywall.alerts.restricted.title" = "Ristretto";
|
"views.paywall.alerts.restricted.title" = "Ristretto";
|
||||||
"views.paywall.alerts.verifying.message" = "Attendere mentre i tuoi acquisti vengono verificati.";
|
"views.paywall.alerts.verification.boot" = "Questo potrebbe richiedere più tempo se il dispositivo è stato appena avviato.";
|
||||||
"views.paywall.alerts.verifying.title" = "Verifica";
|
"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.rows.restore_purchases" = "Ripristina acquisti";
|
||||||
"views.paywall.sections.all_features.header" = "La versione completa include";
|
"views.paywall.sections.all_features.header" = "La versione completa include";
|
||||||
"views.paywall.sections.full_products.header" = "Versione completa";
|
"views.paywall.sections.full_products.header" = "Versione completa";
|
||||||
@ -286,6 +289,7 @@
|
|||||||
"views.ui.connection_status.on_demand_suffix" = " (on-demand)";
|
"views.ui.connection_status.on_demand_suffix" = " (on-demand)";
|
||||||
"views.ui.purchase_required.purchase.help" = "Acquisto richiesto";
|
"views.ui.purchase_required.purchase.help" = "Acquisto richiesto";
|
||||||
"views.ui.purchase_required.restricted.help" = "Funzionalità ristretta";
|
"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.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.category.any" = "Tutte le categorie";
|
||||||
"views.vpn.no_servers" = "Nessun server";
|
"views.vpn.no_servers" = "Nessun server";
|
||||||
|
@ -36,6 +36,7 @@
|
|||||||
"errors.app.passepartout.no_active_modules" = "Het profiel heeft geen actieve modules.";
|
"errors.app.passepartout.no_active_modules" = "Het profiel heeft geen actieve modules.";
|
||||||
"errors.app.passepartout.parsing" = "Kan bestand niet parseren.";
|
"errors.app.passepartout.parsing" = "Kan bestand niet parseren.";
|
||||||
"errors.app.passepartout.provider_required" = "Geen provider geselecteerd.";
|
"errors.app.passepartout.provider_required" = "Geen provider geselecteerd.";
|
||||||
|
"errors.app.passepartout.timeout" = "De bewerking is verlopen.";
|
||||||
"errors.app.permission_denied" = "Toegang geweigerd";
|
"errors.app.permission_denied" = "Toegang geweigerd";
|
||||||
"errors.app.tunnel" = "Actie kan niet worden uitgevoerd.";
|
"errors.app.tunnel" = "Actie kan niet worden uitgevoerd.";
|
||||||
"errors.tunnel.auth" = "Verificatie mislukt";
|
"errors.tunnel.auth" = "Verificatie mislukt";
|
||||||
@ -222,7 +223,6 @@
|
|||||||
"views.app.toolbar.new_profile.empty" = "Leeg profiel";
|
"views.app.toolbar.new_profile.empty" = "Leeg profiel";
|
||||||
"views.app.toolbar.new_profile.provider" = "Provider";
|
"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.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.app_menu.items.quit" = "Stop %@";
|
||||||
"views.diagnostics.alerts.report_issue.email" = "Het apparaat is niet geconfigureerd om e-mails te verzenden.";
|
"views.diagnostics.alerts.report_issue.email" = "Het apparaat is niet geconfigureerd om e-mails te verzenden.";
|
||||||
"views.diagnostics.openvpn.rows.server_configuration" = "Serverconfiguratie";
|
"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.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.migration.title" = "Migreren";
|
||||||
"views.paywall.alerts.confirmation.message" = "Dit profiel vereist betaalde functies om te werken.";
|
"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.confirmation.title" = "Aankoop vereist";
|
||||||
"views.paywall.alerts.pending.message" = "De aankoop wacht op externe bevestiging. De functie wordt na goedkeuring gecrediteerd.";
|
"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.message" = "Sommige functies zijn niet beschikbaar in deze versie.";
|
||||||
"views.paywall.alerts.restricted.title" = "Beperkt";
|
"views.paywall.alerts.restricted.title" = "Beperkt";
|
||||||
"views.paywall.alerts.verifying.message" = "Even geduld terwijl uw aankopen worden geverifieerd.";
|
"views.paywall.alerts.verification.boot" = "Dit kan iets langer duren als je apparaat net is opgestart.";
|
||||||
"views.paywall.alerts.verifying.title" = "Verifiëren";
|
"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.rows.restore_purchases" = "Aankopen herstellen";
|
||||||
"views.paywall.sections.all_features.header" = "De volledige versie bevat";
|
"views.paywall.sections.all_features.header" = "De volledige versie bevat";
|
||||||
"views.paywall.sections.full_products.header" = "Volledige versie";
|
"views.paywall.sections.full_products.header" = "Volledige versie";
|
||||||
@ -286,6 +289,7 @@
|
|||||||
"views.ui.connection_status.on_demand_suffix" = " (op aanvraag)";
|
"views.ui.connection_status.on_demand_suffix" = " (op aanvraag)";
|
||||||
"views.ui.purchase_required.purchase.help" = "Aankoop vereist";
|
"views.ui.purchase_required.purchase.help" = "Aankoop vereist";
|
||||||
"views.ui.purchase_required.restricted.help" = "Functie is beperkt";
|
"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.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.category.any" = "Alle categorieën";
|
||||||
"views.vpn.no_servers" = "Geen servers";
|
"views.vpn.no_servers" = "Geen servers";
|
||||||
|
@ -36,6 +36,7 @@
|
|||||||
"errors.app.passepartout.no_active_modules" = "Profil nie ma aktywnych modułów.";
|
"errors.app.passepartout.no_active_modules" = "Profil nie ma aktywnych modułów.";
|
||||||
"errors.app.passepartout.parsing" = "Nie można przeanalizować pliku.";
|
"errors.app.passepartout.parsing" = "Nie można przeanalizować pliku.";
|
||||||
"errors.app.passepartout.provider_required" = "Nie wybrano dostawcy.";
|
"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.permission_denied" = "Brak uprawnień";
|
||||||
"errors.app.tunnel" = "Nie można wykonać operacji.";
|
"errors.app.tunnel" = "Nie można wykonać operacji.";
|
||||||
"errors.tunnel.auth" = "Błąd uwierzytelniania";
|
"errors.tunnel.auth" = "Błąd uwierzytelniania";
|
||||||
@ -222,7 +223,6 @@
|
|||||||
"views.app.toolbar.new_profile.empty" = "Pusty profil";
|
"views.app.toolbar.new_profile.empty" = "Pusty profil";
|
||||||
"views.app.toolbar.new_profile.provider" = "Dostawca";
|
"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.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.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.alerts.report_issue.email" = "Urządzenie nie jest skonfigurowane do wysyłania wiadomości e-mail.";
|
||||||
"views.diagnostics.openvpn.rows.server_configuration" = "Konfiguracja serwera";
|
"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.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.migration.title" = "Migracja";
|
||||||
"views.paywall.alerts.confirmation.message" = "Ten profil wymaga płatnych funkcji do działania.";
|
"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.confirmation.title" = "Wymagana zakup";
|
||||||
"views.paywall.alerts.pending.message" = "Zakup oczekuje na zewnętrzne potwierdzenie. Funkcja zostanie przypisana po zatwierdzeniu.";
|
"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.message" = "Niektóre funkcje są niedostępne w tej wersji.";
|
||||||
"views.paywall.alerts.restricted.title" = "Ograniczone";
|
"views.paywall.alerts.restricted.title" = "Ograniczone";
|
||||||
"views.paywall.alerts.verifying.message" = "Proszę czekać, aż zakupy zostaną zweryfikowane.";
|
"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.verifying.title" = "Weryfikacja";
|
"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.rows.restore_purchases" = "Przywróć zakupy";
|
||||||
"views.paywall.sections.all_features.header" = "Pełna wersja zawiera";
|
"views.paywall.sections.all_features.header" = "Pełna wersja zawiera";
|
||||||
"views.paywall.sections.full_products.header" = "Pełna wersja";
|
"views.paywall.sections.full_products.header" = "Pełna wersja";
|
||||||
@ -286,6 +289,7 @@
|
|||||||
"views.ui.connection_status.on_demand_suffix" = " (na żądanie)";
|
"views.ui.connection_status.on_demand_suffix" = " (na żądanie)";
|
||||||
"views.ui.purchase_required.purchase.help" = "Wymagana zakup";
|
"views.ui.purchase_required.purchase.help" = "Wymagana zakup";
|
||||||
"views.ui.purchase_required.restricted.help" = "Funkcja jest ograniczona";
|
"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.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.category.any" = "Wszystkie kategorie";
|
||||||
"views.vpn.no_servers" = "Brak serwerów";
|
"views.vpn.no_servers" = "Brak serwerów";
|
||||||
|
@ -36,6 +36,7 @@
|
|||||||
"errors.app.passepartout.no_active_modules" = "O perfil não possui módulos ativos.";
|
"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.parsing" = "Não foi possível analisar o arquivo.";
|
||||||
"errors.app.passepartout.provider_required" = "Nenhum provedor selecionado.";
|
"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.permission_denied" = "Permissão negada";
|
||||||
"errors.app.tunnel" = "Não foi possível executar a operação.";
|
"errors.app.tunnel" = "Não foi possível executar a operação.";
|
||||||
"errors.tunnel.auth" = "Falha na autenticaçã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.empty" = "Perfil vazio";
|
||||||
"views.app.toolbar.new_profile.provider" = "Provedor";
|
"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.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.app_menu.items.quit" = "Sair de %@";
|
||||||
"views.diagnostics.alerts.report_issue.email" = "O dispositivo não está configurado para enviar e-mails.";
|
"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";
|
"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.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.migration.title" = "Migrar";
|
||||||
"views.paywall.alerts.confirmation.message" = "Este perfil requer recursos pagos para funcionar.";
|
"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.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.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.message" = "Alguns recursos estão indisponíveis nesta versão.";
|
||||||
"views.paywall.alerts.restricted.title" = "Restrito";
|
"views.paywall.alerts.restricted.title" = "Restrito";
|
||||||
"views.paywall.alerts.verifying.message" = "Aguarde enquanto suas compras estão sendo verificadas.";
|
"views.paywall.alerts.verification.boot" = "Isso pode levar um pouco mais de tempo se seu dispositivo acabou de ser iniciado.";
|
||||||
"views.paywall.alerts.verifying.title" = "Verificação";
|
"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.rows.restore_purchases" = "Restaurar compras";
|
||||||
"views.paywall.sections.all_features.header" = "A versão completa inclui";
|
"views.paywall.sections.all_features.header" = "A versão completa inclui";
|
||||||
"views.paywall.sections.full_products.header" = "Versão completa";
|
"views.paywall.sections.full_products.header" = "Versão completa";
|
||||||
@ -286,6 +289,7 @@
|
|||||||
"views.ui.connection_status.on_demand_suffix" = " (sob demanda)";
|
"views.ui.connection_status.on_demand_suffix" = " (sob demanda)";
|
||||||
"views.ui.purchase_required.purchase.help" = "Compra necessária";
|
"views.ui.purchase_required.purchase.help" = "Compra necessária";
|
||||||
"views.ui.purchase_required.restricted.help" = "Recurso restrito";
|
"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.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.category.any" = "Todas as categorias";
|
||||||
"views.vpn.no_servers" = "Nenhum servidor";
|
"views.vpn.no_servers" = "Nenhum servidor";
|
||||||
|
@ -36,6 +36,7 @@
|
|||||||
"errors.app.passepartout.no_active_modules" = "В профиле нет активных модулей.";
|
"errors.app.passepartout.no_active_modules" = "В профиле нет активных модулей.";
|
||||||
"errors.app.passepartout.parsing" = "Не удалось разобрать файл.";
|
"errors.app.passepartout.parsing" = "Не удалось разобрать файл.";
|
||||||
"errors.app.passepartout.provider_required" = "Поставщик не выбран.";
|
"errors.app.passepartout.provider_required" = "Поставщик не выбран.";
|
||||||
|
"errors.app.passepartout.timeout" = "Время операции истекло.";
|
||||||
"errors.app.permission_denied" = "Доступ запрещен";
|
"errors.app.permission_denied" = "Доступ запрещен";
|
||||||
"errors.app.tunnel" = "Не удалось выполнить операцию.";
|
"errors.app.tunnel" = "Не удалось выполнить операцию.";
|
||||||
"errors.tunnel.auth" = "Ошибка аутентификации";
|
"errors.tunnel.auth" = "Ошибка аутентификации";
|
||||||
@ -222,7 +223,6 @@
|
|||||||
"views.app.toolbar.new_profile.empty" = "Пустой профиль";
|
"views.app.toolbar.new_profile.empty" = "Пустой профиль";
|
||||||
"views.app.toolbar.new_profile.provider" = "Поставщик";
|
"views.app.toolbar.new_profile.provider" = "Поставщик";
|
||||||
"views.app.tv.header" = "Откройте %@ на вашем устройстве iOS или macOS и включите переключатель \"%@\" профиля, чтобы он отобразился здесь.";
|
"views.app.tv.header" = "Откройте %@ на вашем устройстве iOS или macOS и включите переключатель \"%@\" профиля, чтобы он отобразился здесь.";
|
||||||
"views.app.verifying_purchases" = "Проверка покупок...";
|
|
||||||
"views.app_menu.items.quit" = "Выйти из %@";
|
"views.app_menu.items.quit" = "Выйти из %@";
|
||||||
"views.diagnostics.alerts.report_issue.email" = "Устройство не настроено для отправки электронной почты.";
|
"views.diagnostics.alerts.report_issue.email" = "Устройство не настроено для отправки электронной почты.";
|
||||||
"views.diagnostics.openvpn.rows.server_configuration" = "Конфигурация сервера";
|
"views.diagnostics.openvpn.rows.server_configuration" = "Конфигурация сервера";
|
||||||
@ -244,12 +244,15 @@
|
|||||||
"views.migration.sections.main.header" = "Выберите ниже профили из старых версий %@, которые вы хотите импортировать. Если ваши профили хранятся в iCloud, синхронизация может занять некоторое время. Если вы их сейчас не видите, вернитесь позже.";
|
"views.migration.sections.main.header" = "Выберите ниже профили из старых версий %@, которые вы хотите импортировать. Если ваши профили хранятся в iCloud, синхронизация может занять некоторое время. Если вы их сейчас не видите, вернитесь позже.";
|
||||||
"views.migration.title" = "Миграция";
|
"views.migration.title" = "Миграция";
|
||||||
"views.paywall.alerts.confirmation.message" = "Этот профиль требует платных функций для работы.";
|
"views.paywall.alerts.confirmation.message" = "Этот профиль требует платных функций для работы.";
|
||||||
|
"views.paywall.alerts.confirmation.message.connect" = "Вы можете протестировать подключение в течение %d минут.";
|
||||||
"views.paywall.alerts.confirmation.title" = "Требуется покупка";
|
"views.paywall.alerts.confirmation.title" = "Требуется покупка";
|
||||||
"views.paywall.alerts.pending.message" = "Покупка ожидает внешнего подтверждения. Функция будет активирована после одобрения.";
|
"views.paywall.alerts.pending.message" = "Покупка ожидает внешнего подтверждения. Функция будет активирована после одобрения.";
|
||||||
"views.paywall.alerts.restricted.message" = "Некоторые функции недоступны в этой версии.";
|
"views.paywall.alerts.restricted.message" = "Некоторые функции недоступны в этой версии.";
|
||||||
"views.paywall.alerts.restricted.title" = "Ограничено";
|
"views.paywall.alerts.restricted.title" = "Ограничено";
|
||||||
"views.paywall.alerts.verifying.message" = "Пожалуйста, подождите, пока проверяются ваши покупки.";
|
"views.paywall.alerts.verification.boot" = "Это может занять немного больше времени, если ваше устройство только что было включено.";
|
||||||
"views.paywall.alerts.verifying.title" = "Проверка";
|
"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.rows.restore_purchases" = "Восстановить покупки";
|
||||||
"views.paywall.sections.all_features.header" = "Полная версия включает";
|
"views.paywall.sections.all_features.header" = "Полная версия включает";
|
||||||
"views.paywall.sections.full_products.header" = "Полная версия";
|
"views.paywall.sections.full_products.header" = "Полная версия";
|
||||||
@ -286,6 +289,7 @@
|
|||||||
"views.ui.connection_status.on_demand_suffix" = " (по требованию)";
|
"views.ui.connection_status.on_demand_suffix" = " (по требованию)";
|
||||||
"views.ui.purchase_required.purchase.help" = "Требуется покупка";
|
"views.ui.purchase_required.purchase.help" = "Требуется покупка";
|
||||||
"views.ui.purchase_required.restricted.help" = "Функция ограничена";
|
"views.ui.purchase_required.restricted.help" = "Функция ограничена";
|
||||||
|
"views.verification.message" = "Проверка";
|
||||||
"views.version.extra" = "%@ — проект, поддерживаемый %@.\n\nИсходный код доступен на GitHub под лицензией GPLv3, ссылки можно найти на домашней странице.";
|
"views.version.extra" = "%@ — проект, поддерживаемый %@.\n\nИсходный код доступен на GitHub под лицензией GPLv3, ссылки можно найти на домашней странице.";
|
||||||
"views.vpn.category.any" = "Все категории";
|
"views.vpn.category.any" = "Все категории";
|
||||||
"views.vpn.no_servers" = "Нет серверов";
|
"views.vpn.no_servers" = "Нет серверов";
|
||||||
|
@ -36,6 +36,7 @@
|
|||||||
"errors.app.passepartout.no_active_modules" = "Profilen har inga aktiva moduler.";
|
"errors.app.passepartout.no_active_modules" = "Profilen har inga aktiva moduler.";
|
||||||
"errors.app.passepartout.parsing" = "Kan inte tolka filen.";
|
"errors.app.passepartout.parsing" = "Kan inte tolka filen.";
|
||||||
"errors.app.passepartout.provider_required" = "Ingen leverantör vald.";
|
"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.permission_denied" = "Åtkomst nekad";
|
||||||
"errors.app.tunnel" = "Kunde inte utföra åtgärden.";
|
"errors.app.tunnel" = "Kunde inte utföra åtgärden.";
|
||||||
"errors.tunnel.auth" = "Autentisering misslyckades";
|
"errors.tunnel.auth" = "Autentisering misslyckades";
|
||||||
@ -222,7 +223,6 @@
|
|||||||
"views.app.toolbar.new_profile.empty" = "Tom profil";
|
"views.app.toolbar.new_profile.empty" = "Tom profil";
|
||||||
"views.app.toolbar.new_profile.provider" = "Leverantör";
|
"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.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.app_menu.items.quit" = "Avsluta %@";
|
||||||
"views.diagnostics.alerts.report_issue.email" = "Enheten är inte konfigurerad för att skicka e-post.";
|
"views.diagnostics.alerts.report_issue.email" = "Enheten är inte konfigurerad för att skicka e-post.";
|
||||||
"views.diagnostics.openvpn.rows.server_configuration" = "Serverkonfiguration";
|
"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.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.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" = "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.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.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.message" = "Vissa funktioner är inte tillgängliga i denna version.";
|
||||||
"views.paywall.alerts.restricted.title" = "Begränsad";
|
"views.paywall.alerts.restricted.title" = "Begränsad";
|
||||||
"views.paywall.alerts.verifying.message" = "Vänta medan dina köp verifieras.";
|
"views.paywall.alerts.verification.boot" = "Det kan ta lite längre tid om din enhet just startades.";
|
||||||
"views.paywall.alerts.verifying.title" = "Verifiering";
|
"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.rows.restore_purchases" = "Återställ köp";
|
||||||
"views.paywall.sections.all_features.header" = "Den fullständiga versionen innehåller";
|
"views.paywall.sections.all_features.header" = "Den fullständiga versionen innehåller";
|
||||||
"views.paywall.sections.full_products.header" = "Fullständig version";
|
"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.connection_status.on_demand_suffix" = " (på begäran)";
|
||||||
"views.ui.purchase_required.purchase.help" = "Köp krävs";
|
"views.ui.purchase_required.purchase.help" = "Köp krävs";
|
||||||
"views.ui.purchase_required.restricted.help" = "Funktionen är begränsad";
|
"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.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.category.any" = "Alla kategorier";
|
||||||
"views.vpn.no_servers" = "Inga servrar";
|
"views.vpn.no_servers" = "Inga servrar";
|
||||||
|
@ -36,6 +36,7 @@
|
|||||||
"errors.app.passepartout.no_active_modules" = "Профіль не має активних модулів.";
|
"errors.app.passepartout.no_active_modules" = "Профіль не має активних модулів.";
|
||||||
"errors.app.passepartout.parsing" = "Не вдалося розібрати файл.";
|
"errors.app.passepartout.parsing" = "Не вдалося розібрати файл.";
|
||||||
"errors.app.passepartout.provider_required" = "Не вибрано постачальника.";
|
"errors.app.passepartout.provider_required" = "Не вибрано постачальника.";
|
||||||
|
"errors.app.passepartout.timeout" = "Час виконання операції вичерпано.";
|
||||||
"errors.app.permission_denied" = "Доступ заборонено";
|
"errors.app.permission_denied" = "Доступ заборонено";
|
||||||
"errors.app.tunnel" = "Не вдалося виконати операцію.";
|
"errors.app.tunnel" = "Не вдалося виконати операцію.";
|
||||||
"errors.tunnel.auth" = "Помилка аутентифікації";
|
"errors.tunnel.auth" = "Помилка аутентифікації";
|
||||||
@ -222,7 +223,6 @@
|
|||||||
"views.app.toolbar.new_profile.empty" = "Порожній профіль";
|
"views.app.toolbar.new_profile.empty" = "Порожній профіль";
|
||||||
"views.app.toolbar.new_profile.provider" = "Постачальник";
|
"views.app.toolbar.new_profile.provider" = "Постачальник";
|
||||||
"views.app.tv.header" = "Відкрийте %@ на вашому пристрої iOS або macOS і увімкніть перемикач \"%@\" профілю, щоб він з’явився тут.";
|
"views.app.tv.header" = "Відкрийте %@ на вашому пристрої iOS або macOS і увімкніть перемикач \"%@\" профілю, щоб він з’явився тут.";
|
||||||
"views.app.verifying_purchases" = "Перевірка покупок...";
|
|
||||||
"views.app_menu.items.quit" = "Вийти з %@";
|
"views.app_menu.items.quit" = "Вийти з %@";
|
||||||
"views.diagnostics.alerts.report_issue.email" = "Пристрій не налаштований на надсилання електронних листів.";
|
"views.diagnostics.alerts.report_issue.email" = "Пристрій не налаштований на надсилання електронних листів.";
|
||||||
"views.diagnostics.openvpn.rows.server_configuration" = "Конфігурація сервера";
|
"views.diagnostics.openvpn.rows.server_configuration" = "Конфігурація сервера";
|
||||||
@ -244,12 +244,15 @@
|
|||||||
"views.migration.sections.main.header" = "Виберіть нижче профілі зі старих версій %@, які ви хочете імпортувати. Якщо ваші профілі зберігаються в iCloud, може знадобитися час для їх синхронізації. Якщо ви не бачите їх зараз, поверніться пізніше.";
|
"views.migration.sections.main.header" = "Виберіть нижче профілі зі старих версій %@, які ви хочете імпортувати. Якщо ваші профілі зберігаються в iCloud, може знадобитися час для їх синхронізації. Якщо ви не бачите їх зараз, поверніться пізніше.";
|
||||||
"views.migration.title" = "Перенесення";
|
"views.migration.title" = "Перенесення";
|
||||||
"views.paywall.alerts.confirmation.message" = "Цей профіль потребує платних функцій для роботи.";
|
"views.paywall.alerts.confirmation.message" = "Цей профіль потребує платних функцій для роботи.";
|
||||||
|
"views.paywall.alerts.confirmation.message.connect" = "Ви можете протестувати підключення протягом %d хвилин.";
|
||||||
"views.paywall.alerts.confirmation.title" = "Потрібна покупка";
|
"views.paywall.alerts.confirmation.title" = "Потрібна покупка";
|
||||||
"views.paywall.alerts.pending.message" = "Покупка очікує зовнішнього підтвердження. Функція буде увімкнена після схвалення.";
|
"views.paywall.alerts.pending.message" = "Покупка очікує зовнішнього підтвердження. Функція буде увімкнена після схвалення.";
|
||||||
"views.paywall.alerts.restricted.message" = "Деякі функції недоступні в цій версії.";
|
"views.paywall.alerts.restricted.message" = "Деякі функції недоступні в цій версії.";
|
||||||
"views.paywall.alerts.restricted.title" = "Обмежено";
|
"views.paywall.alerts.restricted.title" = "Обмежено";
|
||||||
"views.paywall.alerts.verifying.message" = "Будь ласка, зачекайте, поки ваші покупки перевіряються.";
|
"views.paywall.alerts.verification.boot" = "Це може зайняти трохи більше часу, якщо ваш пристрій щойно запустився.";
|
||||||
"views.paywall.alerts.verifying.title" = "Перевірка";
|
"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.rows.restore_purchases" = "Відновити покупки";
|
||||||
"views.paywall.sections.all_features.header" = "Повна версія включає";
|
"views.paywall.sections.all_features.header" = "Повна версія включає";
|
||||||
"views.paywall.sections.full_products.header" = "Повна версія";
|
"views.paywall.sections.full_products.header" = "Повна версія";
|
||||||
@ -286,6 +289,7 @@
|
|||||||
"views.ui.connection_status.on_demand_suffix" = " (за запитом)";
|
"views.ui.connection_status.on_demand_suffix" = " (за запитом)";
|
||||||
"views.ui.purchase_required.purchase.help" = "Потрібна покупка";
|
"views.ui.purchase_required.purchase.help" = "Потрібна покупка";
|
||||||
"views.ui.purchase_required.restricted.help" = "Функція обмежена";
|
"views.ui.purchase_required.restricted.help" = "Функція обмежена";
|
||||||
|
"views.verification.message" = "Перевірка";
|
||||||
"views.version.extra" = "%@ є проектом, який підтримується %@.\n\nВихідний код доступний публічно на GitHub під ліцензією GPLv3. Посилання можна знайти на домашній сторінці.";
|
"views.version.extra" = "%@ є проектом, який підтримується %@.\n\nВихідний код доступний публічно на GitHub під ліцензією GPLv3. Посилання можна знайти на домашній сторінці.";
|
||||||
"views.vpn.category.any" = "Усі категорії";
|
"views.vpn.category.any" = "Усі категорії";
|
||||||
"views.vpn.no_servers" = "Немає серверів";
|
"views.vpn.no_servers" = "Немає серверів";
|
||||||
|
@ -36,6 +36,7 @@
|
|||||||
"errors.app.passepartout.no_active_modules" = "配置文件没有激活模块。";
|
"errors.app.passepartout.no_active_modules" = "配置文件没有激活模块。";
|
||||||
"errors.app.passepartout.parsing" = "无法解析文件。";
|
"errors.app.passepartout.parsing" = "无法解析文件。";
|
||||||
"errors.app.passepartout.provider_required" = "未选择提供商。";
|
"errors.app.passepartout.provider_required" = "未选择提供商。";
|
||||||
|
"errors.app.passepartout.timeout" = "操作超时。";
|
||||||
"errors.app.permission_denied" = "权限被拒绝";
|
"errors.app.permission_denied" = "权限被拒绝";
|
||||||
"errors.app.tunnel" = "无法执行操作。";
|
"errors.app.tunnel" = "无法执行操作。";
|
||||||
"errors.tunnel.auth" = "认证失败";
|
"errors.tunnel.auth" = "认证失败";
|
||||||
@ -222,7 +223,6 @@
|
|||||||
"views.app.toolbar.new_profile.empty" = "空配置文件";
|
"views.app.toolbar.new_profile.empty" = "空配置文件";
|
||||||
"views.app.toolbar.new_profile.provider" = "提供商";
|
"views.app.toolbar.new_profile.provider" = "提供商";
|
||||||
"views.app.tv.header" = "在您的 iOS 或 macOS 设备上打开 %@,并启用配置文件的 \"%@\" 开关,使其显示在此处。";
|
"views.app.tv.header" = "在您的 iOS 或 macOS 设备上打开 %@,并启用配置文件的 \"%@\" 开关,使其显示在此处。";
|
||||||
"views.app.verifying_purchases" = "正在验证购买...";
|
|
||||||
"views.app_menu.items.quit" = "退出 %@";
|
"views.app_menu.items.quit" = "退出 %@";
|
||||||
"views.diagnostics.alerts.report_issue.email" = "设备未配置为发送电子邮件。";
|
"views.diagnostics.alerts.report_issue.email" = "设备未配置为发送电子邮件。";
|
||||||
"views.diagnostics.openvpn.rows.server_configuration" = "服务器配置";
|
"views.diagnostics.openvpn.rows.server_configuration" = "服务器配置";
|
||||||
@ -244,12 +244,15 @@
|
|||||||
"views.migration.sections.main.header" = "选择以下来自 %@ 的旧版本配置文件进行导入。如果您的配置文件存储在 iCloud 中,可能需要一些时间进行同步。如果现在没有看到,请稍后再试。";
|
"views.migration.sections.main.header" = "选择以下来自 %@ 的旧版本配置文件进行导入。如果您的配置文件存储在 iCloud 中,可能需要一些时间进行同步。如果现在没有看到,请稍后再试。";
|
||||||
"views.migration.title" = "迁移";
|
"views.migration.title" = "迁移";
|
||||||
"views.paywall.alerts.confirmation.message" = "此配置文件需要付费功能才能工作。";
|
"views.paywall.alerts.confirmation.message" = "此配置文件需要付费功能才能工作。";
|
||||||
|
"views.paywall.alerts.confirmation.message.connect" = "您可以试用连接 %d 分钟。";
|
||||||
"views.paywall.alerts.confirmation.title" = "需要购买";
|
"views.paywall.alerts.confirmation.title" = "需要购买";
|
||||||
"views.paywall.alerts.pending.message" = "购买正在等待外部确认。功能将在获得批准后被授予。";
|
"views.paywall.alerts.pending.message" = "购买正在等待外部确认。功能将在获得批准后被授予。";
|
||||||
"views.paywall.alerts.restricted.message" = "某些功能在此版本中不可用。";
|
"views.paywall.alerts.restricted.message" = "某些功能在此版本中不可用。";
|
||||||
"views.paywall.alerts.restricted.title" = "受限";
|
"views.paywall.alerts.restricted.title" = "受限";
|
||||||
"views.paywall.alerts.verifying.message" = "请稍候,您的购买正在验证中。";
|
"views.paywall.alerts.verification.boot" = "如果您的设备刚刚启动,这可能需要更长时间。";
|
||||||
"views.paywall.alerts.verifying.title" = "验证";
|
"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.rows.restore_purchases" = "恢复购买";
|
||||||
"views.paywall.sections.all_features.header" = "完整版本包括";
|
"views.paywall.sections.all_features.header" = "完整版本包括";
|
||||||
"views.paywall.sections.full_products.header" = "完整版本";
|
"views.paywall.sections.full_products.header" = "完整版本";
|
||||||
@ -286,6 +289,7 @@
|
|||||||
"views.ui.connection_status.on_demand_suffix" = "(按需)";
|
"views.ui.connection_status.on_demand_suffix" = "(按需)";
|
||||||
"views.ui.purchase_required.purchase.help" = "需要购买";
|
"views.ui.purchase_required.purchase.help" = "需要购买";
|
||||||
"views.ui.purchase_required.restricted.help" = "功能受限";
|
"views.ui.purchase_required.restricted.help" = "功能受限";
|
||||||
|
"views.verification.message" = "验证";
|
||||||
"views.version.extra" = "%@ 是由 %@ 维护的项目。\n\n源代码在 GitHub 上公开提供,遵循 GPLv3 许可协议,您可以在主页找到相关链接。";
|
"views.version.extra" = "%@ 是由 %@ 维护的项目。\n\n源代码在 GitHub 上公开提供,遵循 GPLv3 许可协议,您可以在主页找到相关链接。";
|
||||||
"views.vpn.category.any" = "所有类别";
|
"views.vpn.category.any" = "所有类别";
|
||||||
"views.vpn.no_servers" = "无服务器";
|
"views.vpn.no_servers" = "无服务器";
|
||||||
|
@ -37,7 +37,7 @@ public protocol AppCoordinatorConforming {
|
|||||||
|
|
||||||
func onProviderEntityRequired(_ profile: Profile, force: Bool)
|
func onProviderEntityRequired(_ profile: Profile, force: Bool)
|
||||||
|
|
||||||
func onPurchaseRequired(_ features: Set<AppFeature>)
|
func onPurchaseRequired(_ features: Set<AppFeature>, onCancel: (() -> Void)?)
|
||||||
|
|
||||||
func onError(_ error: Error, profile: Profile)
|
func onError(_ error: Error, profile: Profile)
|
||||||
}
|
}
|
||||||
|
@ -171,12 +171,10 @@ private extension InteractiveCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func confirm() {
|
func confirm() {
|
||||||
Task {
|
do {
|
||||||
do {
|
try manager.complete()
|
||||||
try await manager.complete()
|
} catch {
|
||||||
} catch {
|
onError(error)
|
||||||
onError(error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -34,12 +34,16 @@ extension PaywallModifier {
|
|||||||
|
|
||||||
public let needsConfirmation: Bool
|
public let needsConfirmation: Bool
|
||||||
|
|
||||||
|
public let forConnecting: Bool
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
_ requiredFeatures: Set<AppFeature>,
|
_ requiredFeatures: Set<AppFeature>,
|
||||||
needsConfirmation: Bool = false
|
needsConfirmation: Bool = true,
|
||||||
|
forConnecting: Bool = true
|
||||||
) {
|
) {
|
||||||
self.requiredFeatures = requiredFeatures
|
self.requiredFeatures = requiredFeatures
|
||||||
self.needsConfirmation = needsConfirmation
|
self.needsConfirmation = needsConfirmation
|
||||||
|
self.forConnecting = forConnecting
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -34,9 +34,7 @@ public struct PaywallModifier: ViewModifier {
|
|||||||
@Binding
|
@Binding
|
||||||
private var reason: PaywallReason?
|
private var reason: PaywallReason?
|
||||||
|
|
||||||
private let okTitle: String?
|
private let onCancel: (() -> Void)?
|
||||||
|
|
||||||
private let okAction: (() -> Void)?
|
|
||||||
|
|
||||||
@State
|
@State
|
||||||
private var isConfirming = false
|
private var isConfirming = false
|
||||||
@ -47,14 +45,9 @@ public struct PaywallModifier: ViewModifier {
|
|||||||
@State
|
@State
|
||||||
private var isPurchasing = false
|
private var isPurchasing = false
|
||||||
|
|
||||||
public init(
|
public init(reason: Binding<PaywallReason?>, onCancel: (() -> Void)? = nil) {
|
||||||
reason: Binding<PaywallReason?>,
|
|
||||||
okTitle: String? = nil,
|
|
||||||
okAction: (() -> Void)? = nil
|
|
||||||
) {
|
|
||||||
_reason = reason
|
_reason = reason
|
||||||
self.okTitle = okTitle
|
self.onCancel = onCancel
|
||||||
self.okAction = okAction
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func body(content: Content) -> some View {
|
public func body(content: Content) -> some View {
|
||||||
@ -90,7 +83,7 @@ public struct PaywallModifier: ViewModifier {
|
|||||||
guard let reason = $0 else {
|
guard let reason = $0 else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !iapManager.isRestricted {
|
if !iapManager.isBeta {
|
||||||
if reason.needsConfirmation {
|
if reason.needsConfirmation {
|
||||||
isConfirming = true
|
isConfirming = true
|
||||||
} else {
|
} else {
|
||||||
@ -104,27 +97,9 @@ public struct PaywallModifier: ViewModifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension PaywallModifier {
|
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 {
|
func alertMessage(startingWith header: String, features: [String]) -> String {
|
||||||
header + "\n\n" + features
|
header + "\n\n" + features.joined(separator: "\n")
|
||||||
.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
|
// IMPORTANT: retain reason because it serves paywall content
|
||||||
isPurchasing = true
|
isPurchasing = true
|
||||||
}
|
}
|
||||||
if let okTitle {
|
|
||||||
Button(okTitle) {
|
|
||||||
reason = nil
|
|
||||||
okAction?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button(Strings.Global.Actions.cancel, role: .cancel) {
|
Button(Strings.Global.Actions.cancel, role: .cancel) {
|
||||||
reason = nil
|
reason = nil
|
||||||
|
onCancel?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,8 +124,13 @@ private extension PaywallModifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var confirmationMessageString: String {
|
var confirmationMessageString: String {
|
||||||
alertMessage(
|
let V = Strings.Views.Paywall.Alerts.Confirmation.self
|
||||||
startingWith: Strings.Views.Paywall.Alerts.Confirmation.message,
|
var messages = [V.message]
|
||||||
|
if reason?.forConnecting == true {
|
||||||
|
messages.append(V.Message.connect(limitedMinutes))
|
||||||
|
}
|
||||||
|
return alertMessage(
|
||||||
|
startingWith: messages.joined(separator: " "),
|
||||||
features: ineligibleFeatures
|
features: ineligibleFeatures
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -166,7 +141,7 @@ private extension PaywallModifier {
|
|||||||
private extension PaywallModifier {
|
private extension PaywallModifier {
|
||||||
func restrictedActions() -> some View {
|
func restrictedActions() -> some View {
|
||||||
Button(Strings.Global.Nouns.ok) {
|
Button(Strings.Global.Nouns.ok) {
|
||||||
//
|
onCancel?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,8 +150,13 @@ private extension PaywallModifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var restrictedMessageString: String {
|
var restrictedMessageString: String {
|
||||||
alertMessage(
|
let V = Strings.Views.Paywall.Alerts.self
|
||||||
startingWith: Strings.Views.Paywall.Alerts.Restricted.message,
|
var messages = [V.Restricted.message]
|
||||||
|
if reason?.forConnecting == true {
|
||||||
|
messages.append(V.Confirmation.Message.connect(limitedMinutes))
|
||||||
|
}
|
||||||
|
return alertMessage(
|
||||||
|
startingWith: messages.joined(separator: " "),
|
||||||
features: ineligibleFeatures
|
features: ineligibleFeatures
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -186,7 +166,8 @@ private extension PaywallModifier {
|
|||||||
|
|
||||||
private extension PaywallModifier {
|
private extension PaywallModifier {
|
||||||
func modalDestination() -> some View {
|
func modalDestination() -> some View {
|
||||||
reason.map {
|
assert(!iapManager.isLoadingReceipt, "Paywall presented while still loading receipt?")
|
||||||
|
return reason.map {
|
||||||
PaywallView(
|
PaywallView(
|
||||||
isPresented: $isPurchasing,
|
isPresented: $isPurchasing,
|
||||||
features: iapManager.excludingEligible(from: $0.requiredFeatures)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -39,7 +39,7 @@ public struct PurchaseRequiredView<Content>: View where Content: View {
|
|||||||
let content: (_ isRestricted: Bool) -> Content
|
let content: (_ isRestricted: Bool) -> Content
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
content(iapManager.isRestricted)
|
content(iapManager.isBeta)
|
||||||
.opaque(!isEligible)
|
.opaque(!isEligible)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -405,8 +405,8 @@ extension IAPManagerTests {
|
|||||||
let sut = IAPManager(customUserLevel: .beta, receiptReader: reader)
|
let sut = IAPManager(customUserLevel: .beta, receiptReader: reader)
|
||||||
|
|
||||||
await sut.reloadReceipt()
|
await sut.reloadReceipt()
|
||||||
XCTAssertTrue(sut.isRestricted)
|
XCTAssertTrue(sut.isBeta)
|
||||||
XCTAssertTrue(sut.userLevel.isRestricted)
|
XCTAssertTrue(sut.userLevel.isBeta)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_givenBetaApp_thenIsNotEligibleForAllFeatures() async {
|
func test_givenBetaApp_thenIsNotEligibleForAllFeatures() async {
|
||||||
|
@ -251,7 +251,8 @@ extension ProfileEditorTests {
|
|||||||
}
|
}
|
||||||
.store(in: &subscriptions)
|
.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])
|
await fulfillment(of: [exp])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1 +1 @@
|
|||||||
Subproject commit 04972b0ef8628c93fe8f7362d089a31d2f767173
|
Subproject commit 10da14db697d8c22d91f1d430ce94c31b6f93c7d
|
@ -81,7 +81,6 @@ extension AppContext {
|
|||||||
productsAtBuild: dependencies.productsAtBuild()
|
productsAtBuild: dependencies.productsAtBuild()
|
||||||
)
|
)
|
||||||
let processor = dependencies.appProcessor(with: iapManager)
|
let processor = dependencies.appProcessor(with: iapManager)
|
||||||
let tunnelReceiptURL = BundleConfiguration.urlForBetaReceipt
|
|
||||||
|
|
||||||
let tunnelEnvironment = dependencies.tunnelEnvironment()
|
let tunnelEnvironment = dependencies.tunnelEnvironment()
|
||||||
#if targetEnvironment(simulator)
|
#if targetEnvironment(simulator)
|
||||||
@ -216,7 +215,6 @@ extension AppContext {
|
|||||||
profileManager: profileManager,
|
profileManager: profileManager,
|
||||||
registry: dependencies.registry,
|
registry: dependencies.registry,
|
||||||
tunnel: tunnel,
|
tunnel: tunnel,
|
||||||
tunnelReceiptURL: tunnelReceiptURL,
|
|
||||||
onEligibleFeaturesBlock: onEligibleFeaturesBlock
|
onEligibleFeaturesBlock: onEligibleFeaturesBlock
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
@ -252,22 +250,11 @@ private extension Dependencies {
|
|||||||
}
|
}
|
||||||
return mockHelper.receiptReader
|
return mockHelper.receiptReader
|
||||||
}
|
}
|
||||||
return FallbackReceiptReader(
|
return SharedReceiptReader(
|
||||||
main: StoreKitReceiptReader(logger: iapLogger()),
|
reader: StoreKitReceiptReader(logger: iapLogger())
|
||||||
beta: betaReceiptURL.map {
|
|
||||||
KvittoReceiptReader(url: $0)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var betaReceiptURL: URL? {
|
|
||||||
#if os(tvOS)
|
|
||||||
nil
|
|
||||||
#else
|
|
||||||
Bundle.main.appStoreProductionReceiptURL
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
var mirrorsRemoteRepository: Bool {
|
var mirrorsRemoteRepository: Bool {
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
true
|
true
|
||||||
|
@ -69,8 +69,7 @@ extension AppContext {
|
|||||||
preferencesManager: preferencesManager,
|
preferencesManager: preferencesManager,
|
||||||
profileManager: profileManager,
|
profileManager: profileManager,
|
||||||
registry: registry,
|
registry: registry,
|
||||||
tunnel: tunnel,
|
tunnel: tunnel
|
||||||
tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -76,7 +76,6 @@ extension DefaultAppProcessor: AppTunnelProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func willInstall(_ profile: Profile) throws -> Profile {
|
func willInstall(_ profile: Profile) throws -> Profile {
|
||||||
try iapManager.verify(profile)
|
|
||||||
|
|
||||||
// validate provider modules
|
// validate provider modules
|
||||||
do {
|
do {
|
||||||
|
@ -33,7 +33,7 @@ final class DefaultTunnelProcessor: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension DefaultTunnelProcessor: PacketTunnelProcessor {
|
extension DefaultTunnelProcessor: PacketTunnelProcessor {
|
||||||
func willStart(_ profile: Profile) throws -> Profile {
|
func willProcess(_ profile: Profile) throws -> Profile {
|
||||||
do {
|
do {
|
||||||
var builder = profile.builder()
|
var builder = profile.builder()
|
||||||
try builder.modules.forEach {
|
try builder.modules.forEach {
|
||||||
|
@ -50,19 +50,8 @@ extension TunnelContext {
|
|||||||
|
|
||||||
private extension Dependencies {
|
private extension Dependencies {
|
||||||
func tunnelReceiptReader() -> AppReceiptReader {
|
func tunnelReceiptReader() -> AppReceiptReader {
|
||||||
FallbackReceiptReader(
|
SharedReceiptReader(
|
||||||
main: StoreKitReceiptReader(logger: iapLogger()),
|
reader: StoreKitReceiptReader(logger: iapLogger())
|
||||||
beta: betaReceiptURL.map {
|
|
||||||
KvittoReceiptReader(url: $0)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var betaReceiptURL: URL? {
|
|
||||||
#if os(tvOS)
|
|
||||||
nil
|
|
||||||
#else
|
|
||||||
BundleConfiguration.urlForBetaReceipt // copied by AppContext.onLaunch
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -43,24 +43,50 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
|
|||||||
parameters: Constants.shared.log,
|
parameters: Constants.shared.log,
|
||||||
logsPrivateData: UserDefaults.appGroup.bool(forKey: AppPreference.logsPrivateData.key)
|
logsPrivateData: UserDefaults.appGroup.bool(forKey: AppPreference.logsPrivateData.key)
|
||||||
)
|
)
|
||||||
|
pp_log(.app, .info, "Tunnel started with options: \(options?.description ?? "nil")")
|
||||||
|
|
||||||
let environment = await dependencies.tunnelEnvironment()
|
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 {
|
do {
|
||||||
fwd = try await NEPTPForwarder(
|
fwd = try await NEPTPForwarder(
|
||||||
provider: self,
|
provider: self,
|
||||||
decoder: dependencies.neProtocolCoder(),
|
decoder: dependencies.neProtocolCoder(),
|
||||||
registry: dependencies.registry,
|
registry: dependencies.registry,
|
||||||
environment: environment,
|
environment: environment,
|
||||||
profileBlock: context.processor.willStart
|
willProcess: context.processor.willProcess
|
||||||
)
|
)
|
||||||
guard let fwd else {
|
guard let fwd else {
|
||||||
fatalError("NEPTPForwarder nil without throwing error?")
|
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)
|
try await fwd.startTunnel(options: options)
|
||||||
|
|
||||||
// #1070, do not wait for this to start the tunnel. if on-demand is
|
// #1070, do not wait for this to start the tunnel. if on-demand is
|
||||||
// enabled, networking will stall and StoreKit network calls may
|
// enabled, networking will stall and StoreKit network calls may
|
||||||
// produce a deadlock
|
// 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 {
|
} catch {
|
||||||
pp_log(.app, .fault, "Unable to start tunnel: \(error)")
|
pp_log(.app, .fault, "Unable to start tunnel: \(error)")
|
||||||
PassepartoutConfiguration.shared.flushLog()
|
PassepartoutConfiguration.shared.flushLog()
|
||||||
@ -95,25 +121,29 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
|
|||||||
// MARK: - Eligibility
|
// MARK: - Eligibility
|
||||||
|
|
||||||
private extension PacketTunnelProvider {
|
private extension PacketTunnelProvider {
|
||||||
func verifyEligibility(of profile: Profile, environment: TunnelEnvironment) {
|
func verifyEligibility(of profile: Profile, environment: TunnelEnvironment, interval: TimeInterval) async {
|
||||||
Task {
|
while true {
|
||||||
while true {
|
do {
|
||||||
do {
|
pp_log(.app, .info, "Verify profile, requires: \(profile.features)")
|
||||||
pp_log(.app, .info, "Verify profile, requires: \(profile.features)")
|
await context.iapManager.reloadReceipt()
|
||||||
await context.iapManager.reloadReceipt()
|
try await context.iapManager.verify(profile)
|
||||||
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
|
// prevent on-demand reconnection
|
||||||
pp_log(.app, .info, "Will verify profile again in \(interval) seconds...")
|
environment.setEnvironmentValue(true, forKey: TunnelEnvironmentKeys.holdFlag)
|
||||||
try await Task.sleep(interval: interval)
|
await fwd?.holdTunnel()
|
||||||
} catch {
|
return
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user