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.
This commit is contained in:
Davide 2024-11-05 10:03:54 +01:00 committed by GitHub
parent 158200ea6d
commit bba661f104
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 307 additions and 92 deletions

View File

@ -41,7 +41,7 @@
"kind" : "remoteSourceControl",
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
"state" : {
"revision" : "1b6bf03bb94e650852faabaa6b2161fe8b478151"
"revision" : "cd54765853982204f83d721a6c67a26742dc99e3"
}
},
{

View File

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

View File

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

View File

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

View File

@ -2,10 +2,11 @@
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23H124" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
<entity name="CDProfileV3" representedClassName="CDProfileV3" elementID="CDProfile" versionHashModifier="1" syncable="YES">
<attribute name="encoded" optional="YES" attributeType="String" allowsCloudEncryption="YES"/>
<attribute name="expirationDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="fingerprint" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="isAvailableForTV" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="uuid" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
</entity>
</model>
</model>

View File

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

View File

@ -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 <http://www.gnu.org/licenses/>.
//
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()
}

View File

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

View File

@ -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 <http://www.gnu.org/licenses/>.
//
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()
}
)
}
}
}

View File

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

View File

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

View File

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

View File

@ -43,3 +43,9 @@ public enum AppError: Error {
}
}
}
extension PassepartoutError.Code {
public enum App {
public static let expiredProfile = PassepartoutError.Code("App.expiredProfile")
}
}

View File

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

View File

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

View File

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

View File

@ -23,7 +23,8 @@
},
"tunnel": {
"profileTitleFormat": "Passepartout: %@",
"refreshInterval": 3.0
"refreshInterval": 3.0,
"tvExpirationMinutes": 10
},
"api": {
"timeoutInterval": 5.0

View File

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

View File

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

View File

@ -38,8 +38,7 @@ extension AppContext {
receiptReader: MockAppReceiptReader(),
unrestrictedFeatures: [
.interactiveLogin,
.onDemand,
.sharing
.onDemand
],
productsAtBuild: { _ in
[]

View File

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

View File

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

View File

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

View File

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

View File

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