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