From bba661f1043094bc43de3340f194f6a746f40b70 Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 5 Nov 2024 10:03:54 +0100 Subject: [PATCH] Implement TV profile expiration (#811) Based on in-app eligibility, expire TV profiles after 10 minutes. Refactor/redesign general sections and offer .sharing feature for free, it makes it simpler to focus on Apple TV product. --- .../xcshareddata/swiftpm/Package.resolved | 2 +- Passepartout/Library/Package.swift | 2 +- .../CDProfileRepositoryV3.swift | 2 + .../AppDataProfiles/Domain/CDProfileV3.swift | 1 + .../ProfilesV3.xcdatamodel/contents | 3 +- .../Views/Modules/OnDemandView.swift | 4 +- .../Views/Profile/AppleTVSection.swift | 101 ++++++++++++++++++ .../Views/Profile/StorageSection.swift | 51 ++------- .../AppUIMain/Views/Profile/UUIDSection.swift | 43 ++++++++ .../Profile/iOS/ProfileEditView+iOS.swift | 38 ++++--- .../macOS/ProfileGeneralView+macOS.swift | 13 ++- .../Provider/ProviderContentModifier.swift | 4 +- .../CommonLibrary/Domain/AppError.swift | 6 ++ .../CommonLibrary/Domain/Constants.swift | 2 + .../Domain/ProfileAttributes.swift | 15 ++- .../CommonLibrary/IAP/AppFeature.swift | 3 - .../CommonLibrary/Resources/Constants.json | 3 +- .../UILibrary/L10n/AppError+L10n.swift | 6 ++ .../UILibrary/L10n/SwiftGen+Strings.swift | 32 ++++-- .../UILibrary/Mock/AppContext+Mock.swift | 3 +- .../Resources/en.lproj/Localizable.strings | 10 +- .../Modules/OpenVPNView+Credentials.swift | 4 +- .../CommonLibraryTests/IAPManagerTests.swift | 1 - Passepartout/Shared/Shared+App.swift | 24 ++++- .../Tunnel/PacketTunnelProvider.swift | 26 +++++ 25 files changed, 307 insertions(+), 92 deletions(-) create mode 100644 Passepartout/Library/Sources/AppUIMain/Views/Profile/AppleTVSection.swift create mode 100644 Passepartout/Library/Sources/AppUIMain/Views/Profile/UUIDSection.swift diff --git a/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4c74c67f..8c37e601 100644 --- a/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -41,7 +41,7 @@ "kind" : "remoteSourceControl", "location" : "git@github.com:passepartoutvpn/passepartoutkit-source", "state" : { - "revision" : "1b6bf03bb94e650852faabaa6b2161fe8b478151" + "revision" : "cd54765853982204f83d721a6c67a26742dc99e3" } }, { diff --git a/Passepartout/Library/Package.swift b/Passepartout/Library/Package.swift index 3d07cbc3..c4ef0ec4 100644 --- a/Passepartout/Library/Package.swift +++ b/Passepartout/Library/Package.swift @@ -40,7 +40,7 @@ let package = Package( ], dependencies: [ // .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.9.0"), - .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "1b6bf03bb94e650852faabaa6b2161fe8b478151"), + .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "cd54765853982204f83d721a6c67a26742dc99e3"), // .package(path: "../../../passepartoutkit-source"), .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", from: "0.9.1"), // .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"), diff --git a/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift b/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift index 3bbffa22..e1ea7ac5 100644 --- a/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift +++ b/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift @@ -85,8 +85,10 @@ private extension AppData { cdProfile.name = profile.name cdProfile.encoded = encoded + // redundant but convenient let attributes = profile.attributes cdProfile.isAvailableForTV = attributes.isAvailableForTV.map(NSNumber.init(value:)) + cdProfile.expirationDate = attributes.expirationDate cdProfile.lastUpdate = attributes.lastUpdate cdProfile.fingerprint = attributes.fingerprint diff --git a/Passepartout/Library/Sources/AppDataProfiles/Domain/CDProfileV3.swift b/Passepartout/Library/Sources/AppDataProfiles/Domain/CDProfileV3.swift index 7cb81b0a..4ab8dd84 100644 --- a/Passepartout/Library/Sources/AppDataProfiles/Domain/CDProfileV3.swift +++ b/Passepartout/Library/Sources/AppDataProfiles/Domain/CDProfileV3.swift @@ -36,6 +36,7 @@ final class CDProfileV3: NSManagedObject { @NSManaged var name: String? @NSManaged var encoded: String? @NSManaged var isAvailableForTV: NSNumber? + @NSManaged var expirationDate: Date? @NSManaged var lastUpdate: Date? @NSManaged var fingerprint: UUID? } diff --git a/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/ProfilesV3.xcdatamodel/contents b/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/ProfilesV3.xcdatamodel/contents index 3074c765..1810f407 100644 --- a/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/ProfilesV3.xcdatamodel/contents +++ b/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/ProfilesV3.xcdatamodel/contents @@ -2,10 +2,11 @@ + - + \ No newline at end of file diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Modules/OnDemandView.swift b/Passepartout/Library/Sources/AppUIMain/Views/Modules/OnDemandView.swift index a15af91c..294b8371 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/Modules/OnDemandView.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/Modules/OnDemandView.swift @@ -82,9 +82,9 @@ private extension OnDemandView { @ViewBuilder var restrictedArea: some View { switch iapManager.paywallReason(forFeature: .onDemand) { - case .purchase(let appFeature): + case .purchase(let feature): Button(Strings.Modules.OnDemand.purchase) { - paywallReason = .purchase(appFeature) + paywallReason = .purchase(feature) } case .restricted: diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Profile/AppleTVSection.swift b/Passepartout/Library/Sources/AppUIMain/Views/Profile/AppleTVSection.swift new file mode 100644 index 00000000..575555b6 --- /dev/null +++ b/Passepartout/Library/Sources/AppUIMain/Views/Profile/AppleTVSection.swift @@ -0,0 +1,101 @@ +// +// AppleTVSection.swift +// Passepartout +// +// Created by Davide De Rosa on 11/4/24. +// Copyright (c) 2024 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of Passepartout. +// +// Passepartout is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Passepartout is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Passepartout. If not, see . +// + +import CommonLibrary +import SwiftUI + +struct AppleTVSection: View { + + @EnvironmentObject + private var iapManager: IAPManager + + @ObservedObject + var profileEditor: ProfileEditor + + @Binding + var paywallReason: PaywallReason? + + var body: some View { + debugChanges() + return Group { + availableToggle + purchaseButton + } + .themeSection(footer: footer) + .disabled(!profileEditor.isShared) + } +} + +private extension AppleTVSection { + var availableToggle: some View { + Toggle(Strings.Modules.General.Rows.appleTv(Strings.Unlocalized.appleTV), isOn: $profileEditor.isAvailableForTV) + } + + @ViewBuilder + var purchaseButton: some View { + switch iapManager.paywallReason(forFeature: .appleTV) { + case .purchase(let feature): + Button(Strings.Modules.General.Rows.AppleTv.purchase) { + paywallReason = .purchase(feature) + } + + default: + EmptyView() + } + } + + var footer: String { + var desc = [Strings.Modules.General.Sections.AppleTv.footer] + let expirationDesc = { + Strings.Modules.General.Sections.AppleTv.Footer.Purchase._1( Constants.shared.tunnel.tvExpirationMinutes) + } + let purchaseDesc = { + Strings.Modules.General.Sections.AppleTv.Footer.Purchase._2 + } + switch iapManager.paywallReason(forFeature: .appleTV) { + case .purchase: + desc.append(expirationDesc()) + desc.append(purchaseDesc()) + + case .restricted: + desc.append(expirationDesc()) + + default: + break + } + return desc.joined(separator: " ") + } +} + +#Preview { + Form { + AppleTVSection( + profileEditor: ProfileEditor(), + paywallReason: .constant(nil) + ) + } + .themeForm() + .withMockEnvironment() +} diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Profile/StorageSection.swift b/Passepartout/Library/Sources/AppUIMain/Views/Profile/StorageSection.swift index 85dd5423..773db740 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/Profile/StorageSection.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/Profile/StorageSection.swift @@ -34,72 +34,33 @@ struct StorageSection: View { @ObservedObject var profileEditor: ProfileEditor - @State - private var paywallReason: PaywallReason? + @Binding + var paywallReason: PaywallReason? var body: some View { debugChanges() return Group { sharingToggle - tvToggle - ThemeCopiableText( - title: Strings.Unlocalized.uuid, - value: profileEditor.profile.id, - valueView: { - Text($0.flatString.localizedDescription(style: .quartets)) - .monospaced() - } - ) } .themeSection( header: Strings.Global.storage, footer: Strings.Modules.General.Sections.Storage.footer ) - .modifier(PaywallModifier(reason: $paywallReason)) } } private extension StorageSection { - - @ViewBuilder var sharingToggle: some View { - switch iapManager.paywallReason(forFeature: .sharing) { - case .purchase(let appFeature): - Button(Strings.Modules.General.Rows.IcloudSharing.purchase) { - paywallReason = .purchase(appFeature) - } - - case .restricted: - EmptyView() - - default: - Toggle(Strings.Modules.General.Rows.icloudSharing, isOn: $profileEditor.isShared) - } - } - - @ViewBuilder - var tvToggle: some View { - switch iapManager.paywallReason(forFeature: .appleTV) { - case .purchase(let appFeature): - Button(Strings.Modules.General.Rows.AppleTv.purchase(Strings.Unlocalized.appleTV)) { - paywallReason = .purchase(appFeature) - } - - case .restricted: - EmptyView() - - default: - Toggle(Strings.Modules.General.Rows.appleTv(Strings.Unlocalized.appleTV), isOn: $profileEditor.isAvailableForTV) - .disabled(!profileEditor.isShared) - } + Toggle(Strings.Modules.General.Rows.icloudSharing, isOn: $profileEditor.isShared) } } #Preview { Form { StorageSection( - profileEditor: ProfileEditor() - ) + profileEditor: ProfileEditor(), + paywallReason: .constant(nil) + ) } .themeForm() .withMockEnvironment() diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Profile/UUIDSection.swift b/Passepartout/Library/Sources/AppUIMain/Views/Profile/UUIDSection.swift new file mode 100644 index 00000000..4144c89a --- /dev/null +++ b/Passepartout/Library/Sources/AppUIMain/Views/Profile/UUIDSection.swift @@ -0,0 +1,43 @@ +// +// UUIDSection.swift +// Passepartout +// +// Created by Davide De Rosa on 11/4/24. +// Copyright (c) 2024 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of Passepartout. +// +// Passepartout is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Passepartout is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Passepartout. If not, see . +// + +import SwiftUI + +struct UUIDSection: View { + let uuid: UUID + + var body: some View { + Section { + ThemeCopiableText( + title: Strings.Unlocalized.uuid, + value: uuid, + valueView: { + Text($0.flatString.localizedDescription(style: .quartets)) + .monospaced() + } + ) + } + } +} diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Profile/iOS/ProfileEditView+iOS.swift b/Passepartout/Library/Sources/AppUIMain/Views/Profile/iOS/ProfileEditView+iOS.swift index 1134d119..b0f1580e 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/Profile/iOS/ProfileEditView+iOS.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/Profile/iOS/ProfileEditView+iOS.swift @@ -45,6 +45,9 @@ struct ProfileEditView: View, Routable { @State private var malformedModuleIds: [UUID] = [] + @State + private var paywallReason: PaywallReason? + var body: some View { debugChanges() return List { @@ -52,21 +55,18 @@ struct ProfileEditView: View, Routable { name: $profileEditor.profile.name, placeholder: Strings.Placeholders.Profile.name ) - Group { - ForEach(profileEditor.modules, id: \.id, content: moduleRow) - .onMove(perform: moveModules) - .onDelete(perform: removeModules) - - addModuleButton - } - .themeSection( - header: Strings.Global.modules, - footer: Strings.Views.Profile.ModuleList.Section.footer - ) + modulesSection StorageSection( - profileEditor: profileEditor + profileEditor: profileEditor, + paywallReason: $paywallReason ) + AppleTVSection( + profileEditor: profileEditor, + paywallReason: $paywallReason + ) + UUIDSection(uuid: profileEditor.profile.id) } + .modifier(PaywallModifier(reason: $paywallReason)) .toolbar(content: toolbarContent) .navigationTitle(Strings.Global.profile) .navigationBarBackButtonHidden(true) @@ -95,6 +95,20 @@ private extension ProfileEditView { } } + var modulesSection: some View { + Group { + ForEach(profileEditor.modules, id: \.id, content: moduleRow) + .onMove(perform: moveModules) + .onDelete(perform: removeModules) + + addModuleButton + } + .themeSection( + header: Strings.Global.modules, + footer: Strings.Views.Profile.ModuleList.Section.footer + ) + } + func moduleRow(for module: any ModuleBuilder) -> some View { EditorModuleToggle(profileEditor: profileEditor, module: module) { Button { diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Profile/macOS/ProfileGeneralView+macOS.swift b/Passepartout/Library/Sources/AppUIMain/Views/Profile/macOS/ProfileGeneralView+macOS.swift index 184bb9cf..e17e343c 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/Profile/macOS/ProfileGeneralView+macOS.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/Profile/macOS/ProfileGeneralView+macOS.swift @@ -25,6 +25,7 @@ #if os(macOS) +import CommonLibrary import SwiftUI struct ProfileGeneralView: View { @@ -32,6 +33,9 @@ struct ProfileGeneralView: View { @ObservedObject var profileEditor: ProfileEditor + @State + private var paywallReason: PaywallReason? + var body: some View { Form { NameSection( @@ -39,9 +43,16 @@ struct ProfileGeneralView: View { placeholder: Strings.Placeholders.Profile.name ) StorageSection( - profileEditor: profileEditor + profileEditor: profileEditor, + paywallReason: $paywallReason ) + AppleTVSection( + profileEditor: profileEditor, + paywallReason: $paywallReason + ) + UUIDSection(uuid: profileEditor.profile.id) } + .modifier(PaywallModifier(reason: $paywallReason)) .themeForm() } } diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Provider/ProviderContentModifier.swift b/Passepartout/Library/Sources/AppUIMain/Views/Provider/ProviderContentModifier.swift index e0c4c38e..740bda01 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/Provider/ProviderContentModifier.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/Provider/ProviderContentModifier.swift @@ -135,9 +135,9 @@ private extension ProviderContentModifier { @ViewBuilder var purchaseButton: some View { switch iapManager.paywallReason(forFeature: .providers) { - case .purchase(let appFeature): + case .purchase(let feature): Button(Strings.Providers.Picker.purchase) { - paywallReason = .purchase(appFeature) + paywallReason = .purchase(feature) } default: diff --git a/Passepartout/Library/Sources/CommonLibrary/Domain/AppError.swift b/Passepartout/Library/Sources/CommonLibrary/Domain/AppError.swift index ce1a75a6..6c01e7bc 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Domain/AppError.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Domain/AppError.swift @@ -43,3 +43,9 @@ public enum AppError: Error { } } } + +extension PassepartoutError.Code { + public enum App { + public static let expiredProfile = PassepartoutError.Code("App.expiredProfile") + } +} diff --git a/Passepartout/Library/Sources/CommonLibrary/Domain/Constants.swift b/Passepartout/Library/Sources/CommonLibrary/Domain/Constants.swift index 090e8325..f9424800 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Domain/Constants.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Domain/Constants.swift @@ -97,6 +97,8 @@ public struct Constants: Decodable, Sendable { public let profileTitleFormat: String public let refreshInterval: TimeInterval + + public let tvExpirationMinutes: Int } public struct API: Decodable, Sendable { diff --git a/Passepartout/Library/Sources/CommonLibrary/Domain/ProfileAttributes.swift b/Passepartout/Library/Sources/CommonLibrary/Domain/ProfileAttributes.swift index dcf48f3d..f8b24de7 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Domain/ProfileAttributes.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Domain/ProfileAttributes.swift @@ -30,16 +30,18 @@ import PassepartoutKit public struct ProfileAttributes: Hashable, Codable { public var isAvailableForTV: Bool? + public var expirationDate: Date? + public var lastUpdate: Date? public var fingerprint: UUID? - public init(isAvailableForTV: Bool? = false) { - self.isAvailableForTV = isAvailableForTV + public init() { } public init( isAvailableForTV: Bool?, + expirationDate: Date?, lastUpdate: Date?, fingerprint: UUID? ) { @@ -53,6 +55,15 @@ public struct ProfileAttributes: Hashable, Codable { } } +extension ProfileAttributes { + public var isExpired: Bool { + if let expirationDate { + return Date().distance(to: expirationDate) <= .zero + } + return false + } +} + // MARK: - ProfileUserInfoTransformable // FIXME: #570, test user info encoding/decoding with JSONSerialization diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeature.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeature.swift index 25745b8a..19f210aa 100644 --- a/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeature.swift +++ b/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeature.swift @@ -40,8 +40,6 @@ public enum AppFeature: String { case routing - case sharing - case siri public static let fullVersionFeaturesV2: [AppFeature] = [ @@ -50,7 +48,6 @@ public enum AppFeature: String { .onDemand, .providers, .routing, - .sharing, .siri ] } diff --git a/Passepartout/Library/Sources/CommonLibrary/Resources/Constants.json b/Passepartout/Library/Sources/CommonLibrary/Resources/Constants.json index 3def3bdc..fb5e7eba 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Resources/Constants.json +++ b/Passepartout/Library/Sources/CommonLibrary/Resources/Constants.json @@ -23,7 +23,8 @@ }, "tunnel": { "profileTitleFormat": "Passepartout: %@", - "refreshInterval": 3.0 + "refreshInterval": 3.0, + "tvExpirationMinutes": 10 }, "api": { "timeoutInterval": 5.0 diff --git a/Passepartout/Library/Sources/UILibrary/L10n/AppError+L10n.swift b/Passepartout/Library/Sources/UILibrary/L10n/AppError+L10n.swift index 7f6939b2..23d4d4bf 100644 --- a/Passepartout/Library/Sources/UILibrary/L10n/AppError+L10n.swift +++ b/Passepartout/Library/Sources/UILibrary/L10n/AppError+L10n.swift @@ -52,6 +52,9 @@ extension AppError: LocalizedError { extension PassepartoutError: LocalizedError { public var errorDescription: String? { switch code { + case .App.expiredProfile: + return Strings.Errors.App.expiredProfile + case .connectionModuleRequired: return Strings.Errors.App.Passepartout.connectionModuleRequired @@ -108,6 +111,9 @@ extension PassepartoutError.Code: StyledLocalizableEntity { case .tunnel: let V = Strings.Errors.Tunnel.self switch self { + case .App.expiredProfile: + return V.expired + case .authentication: return V.auth diff --git a/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift b/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift index ef71dcd2..8196c753 100644 --- a/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift +++ b/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift @@ -116,6 +116,8 @@ public enum Strings { public static let `default` = Strings.tr("Localizable", "errors.app.default", fallback: "Unable to complete operation.") /// Profile name is empty. public static let emptyProfileName = Strings.tr("Localizable", "errors.app.empty_profile_name", fallback: "Profile name is empty.") + /// Profile is expired. + public static let expiredProfile = Strings.tr("Localizable", "errors.app.expired_profile", fallback: "Profile is expired.") /// Module %@ is malformed. %@ public static func malformedModule(_ p1: Any, _ p2: Any) -> String { return Strings.tr("Localizable", "errors.app.malformed_module", String(describing: p1), String(describing: p2), fallback: "Module %@ is malformed. %@") @@ -158,6 +160,8 @@ public enum Strings { public static let dns = Strings.tr("Localizable", "errors.tunnel.dns", fallback: "DNS failed") /// Encryption failed public static let encryption = Strings.tr("Localizable", "errors.tunnel.encryption", fallback: "Encryption failed") + /// Expired + public static let expired = Strings.tr("Localizable", "errors.tunnel.expired", fallback: "Expired") /// Failed public static let generic = Strings.tr("Localizable", "errors.tunnel.generic", fallback: "Failed") /// Missing routing @@ -333,26 +337,34 @@ public enum Strings { } public enum General { public enum Rows { - /// Shared on %@ + /// %@ public static func appleTv(_ p1: Any) -> String { - return Strings.tr("Localizable", "modules.general.rows.apple_tv", String(describing: p1), fallback: "Shared on %@") + return Strings.tr("Localizable", "modules.general.rows.apple_tv", String(describing: p1), fallback: "%@") } /// Shared on iCloud public static let icloudSharing = Strings.tr("Localizable", "modules.general.rows.icloud_sharing", fallback: "Shared on iCloud") /// Import from file... public static let importFromFile = Strings.tr("Localizable", "modules.general.rows.import_from_file", fallback: "Import from file...") public enum AppleTv { - /// Share on %@ - public static func purchase(_ p1: Any) -> String { - return Strings.tr("Localizable", "modules.general.rows.apple_tv.purchase", String(describing: p1), fallback: "Share on %@") - } - } - public enum IcloudSharing { - /// Share on iCloud - public static let purchase = Strings.tr("Localizable", "modules.general.rows.icloud_sharing.purchase", fallback: "Share on iCloud") + /// Drop time restriction + public static let purchase = Strings.tr("Localizable", "modules.general.rows.apple_tv.purchase", fallback: "Drop time restriction") } } public enum Sections { + public enum AppleTv { + /// Requires iCloud sharing. + public static let footer = Strings.tr("Localizable", "modules.general.sections.apple_tv.footer", fallback: "Requires iCloud sharing.") + public enum Footer { + public enum Purchase { + /// TV profiles expire after %d minutes. + public static func _1(_ p1: Int) -> String { + return Strings.tr("Localizable", "modules.general.sections.apple_tv.footer.purchase.1", p1, fallback: "TV profiles expire after %d minutes.") + } + /// Purchase to drop the restriction. + public static let _2 = Strings.tr("Localizable", "modules.general.sections.apple_tv.footer.purchase.2", fallback: "Purchase to drop the restriction.") + } + } + } public enum Storage { /// Profiles are stored to iCloud encrypted. public static let footer = Strings.tr("Localizable", "modules.general.sections.storage.footer", fallback: "Profiles are stored to iCloud encrypted.") diff --git a/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift b/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift index e71952ce..66574759 100644 --- a/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift +++ b/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift @@ -38,8 +38,7 @@ extension AppContext { receiptReader: MockAppReceiptReader(), unrestrictedFeatures: [ .interactiveLogin, - .onDemand, - .sharing + .onDemand ], productsAtBuild: { _ in [] diff --git a/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings b/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings index 665ff739..589ad6c2 100644 --- a/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings +++ b/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings @@ -170,8 +170,9 @@ // MARK: - Module views "modules.general.sections.storage.footer" = "Profiles are stored to iCloud encrypted."; +"modules.general.sections.apple_tv.footer" = "Requires iCloud sharing."; "modules.general.rows.icloud_sharing" = "Shared on iCloud"; -"modules.general.rows.apple_tv" = "Shared on %@"; +"modules.general.rows.apple_tv" = "%@"; "modules.general.rows.import_from_file" = "Import from file..."; "modules.dns.servers.add" = "Add address"; @@ -251,8 +252,9 @@ // MARK: - Paywalls -"modules.general.rows.icloud_sharing.purchase" = "Share on iCloud"; -"modules.general.rows.apple_tv.purchase" = "Share on %@"; +"modules.general.sections.apple_tv.footer.purchase.1" = "TV profiles expire after %d minutes."; +"modules.general.sections.apple_tv.footer.purchase.2" = "Purchase to drop the restriction."; +"modules.general.rows.apple_tv.purchase" = "Drop time restriction"; "modules.on_demand.purchase" = "Add on-demand rules"; "modules.openvpn.credentials.interactive.purchase" = "Log in interactively"; "providers.picker.purchase" = "Add more providers"; @@ -268,6 +270,7 @@ // MARK: - Errors "errors.app.empty_profile_name" = "Profile name is empty."; +"errors.app.expired_profile" = "Profile is expired."; "errors.app.malformed_module" = "Module %@ is malformed. %@"; "errors.app.provider.required" = "No provider selected."; "errors.app.default" = "Unable to complete operation."; @@ -285,6 +288,7 @@ "errors.tunnel.compression" = "Compression unsupported"; "errors.tunnel.dns" = "DNS failed"; "errors.tunnel.encryption" = "Encryption failed"; +"errors.tunnel.expired" = "Expired"; "errors.tunnel.routing" = "Missing routing"; "errors.tunnel.shutdown" = "Server shutdown"; "errors.tunnel.timeout" = "Timeout"; diff --git a/Passepartout/Library/Sources/UILibrary/Views/Modules/OpenVPNView+Credentials.swift b/Passepartout/Library/Sources/UILibrary/Views/Modules/OpenVPNView+Credentials.swift index 27936059..e7a1dc8b 100644 --- a/Passepartout/Library/Sources/UILibrary/Views/Modules/OpenVPNView+Credentials.swift +++ b/Passepartout/Library/Sources/UILibrary/Views/Modules/OpenVPNView+Credentials.swift @@ -115,9 +115,9 @@ private extension OpenVPNCredentialsView { @ViewBuilder var restrictedArea: some View { switch iapManager.paywallReason(forFeature: .interactiveLogin) { - case .purchase(let appFeature): + case .purchase(let feature): Button(Strings.Modules.Openvpn.Credentials.Interactive.purchase) { - paywallReason = .purchase(appFeature) + paywallReason = .purchase(feature) } case .restricted: diff --git a/Passepartout/Library/Tests/CommonLibraryTests/IAPManagerTests.swift b/Passepartout/Library/Tests/CommonLibraryTests/IAPManagerTests.swift index cf631b37..23c3a497 100644 --- a/Passepartout/Library/Tests/CommonLibraryTests/IAPManagerTests.swift +++ b/Passepartout/Library/Tests/CommonLibraryTests/IAPManagerTests.swift @@ -96,7 +96,6 @@ extension IAPManagerTests { XCTAssertTrue(sut.isEligible(for: .httpProxy)) XCTAssertFalse(sut.isEligible(for: .onDemand)) XCTAssertTrue(sut.isEligible(for: .routing)) - XCTAssertFalse(sut.isEligible(for: .sharing)) XCTAssertTrue(sut.isEligible(for: .siri)) XCTAssertFalse(sut.isEligible(for: AppFeature.fullVersionFeaturesV2)) } diff --git a/Passepartout/Shared/Shared+App.swift b/Passepartout/Shared/Shared+App.swift index 2d3532fd..9985a44c 100644 --- a/Passepartout/Shared/Shared+App.swift +++ b/Passepartout/Shared/Shared+App.swift @@ -41,7 +41,7 @@ extension AppContext { customUserLevel: Configuration.IAPManager.customUserLevel, receiptReader: KvittoReceiptReader(), // FIXME: #662, omit unrestrictedFeatures on release! - unrestrictedFeatures: [.interactiveLogin, .sharing], + unrestrictedFeatures: [.interactiveLogin], productsAtBuild: Configuration.IAPManager.productsAtBuild ) let processor = ProfileProcessor( @@ -52,8 +52,26 @@ extension AppContext { isIncluded: { _, profile in Configuration.ProfileManager.isProfileIncluded(profile) }, - willSave: { _, builder in - builder + willSave: { iap, builder in + var copy = builder + var attributes = copy.attributes + + // preprocess TV profiles + if attributes.isAvailableForTV == true { + + // if ineligible, set expiration date unless already set + if !iap.isEligible(for: .appleTV), + attributes.expirationDate == nil || attributes.isExpired { + + attributes.expirationDate = Date() + .addingTimeInterval(Double(Constants.shared.tunnel.tvExpirationMinutes) * 60.0) + } else { + attributes.expirationDate = nil + } + } + + copy.attributes = attributes + return copy }, willConnect: { iap, profile in var builder = profile.builder() diff --git a/Passepartout/Tunnel/PacketTunnelProvider.swift b/Passepartout/Tunnel/PacketTunnelProvider.swift index 96db2188..7ce8ca34 100644 --- a/Passepartout/Tunnel/PacketTunnelProvider.swift +++ b/Passepartout/Tunnel/PacketTunnelProvider.swift @@ -43,6 +43,9 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { registry: .shared, environment: .shared ) + if let expirationDate = fwd?.profile.attributes.expirationDate { + try checkExpirationDate(expirationDate, environment: .shared) + } try await fwd?.startTunnel(options: options) } catch { pp_log(.app, .fault, "Unable to start tunnel: \(error)") @@ -74,3 +77,26 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { await fwd?.sleep() } } + +private extension PacketTunnelProvider { + func checkExpirationDate(_ expirationDate: Date, environment: TunnelEnvironment) throws { + let error = PassepartoutError(.App.expiredProfile) + + // already expired? + let delay = Int(expirationDate.timeIntervalSinceNow) + if delay < .zero { + pp_log(.app, .error, "Tunnel expired on \(expirationDate)") + environment.setEnvironmentValue(error.code, forKey: TunnelEnvironmentKeys.lastErrorCode) + throw error + } + + // schedule connection expiration + Task { [weak self] in + pp_log(.app, .notice, "Schedule tunnel expiration on \(expirationDate) (\(delay) seconds from now)") + try? await Task.sleep(for: .seconds(delay)) + pp_log(.app, .error, "Tunnel expired on \(expirationDate)") + environment.setEnvironmentValue(error.code, forKey: TunnelEnvironmentKeys.lastErrorCode) + self?.cancelTunnelWithError(error) + } + } +}