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:
parent
158200ea6d
commit
bba661f104
|
@ -41,7 +41,7 @@
|
|||
"kind" : "remoteSourceControl",
|
||||
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
|
||||
"state" : {
|
||||
"revision" : "1b6bf03bb94e650852faabaa6b2161fe8b478151"
|
||||
"revision" : "cd54765853982204f83d721a6c67a26742dc99e3"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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?
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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:
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -43,3 +43,9 @@ public enum AppError: Error {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PassepartoutError.Code {
|
||||
public enum App {
|
||||
public static let expiredProfile = PassepartoutError.Code("App.expiredProfile")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
]
|
||||
}
|
||||
|
|
|
@ -23,7 +23,8 @@
|
|||
},
|
||||
"tunnel": {
|
||||
"profileTitleFormat": "Passepartout: %@",
|
||||
"refreshInterval": 3.0
|
||||
"refreshInterval": 3.0,
|
||||
"tvExpirationMinutes": 10
|
||||
},
|
||||
"api": {
|
||||
"timeoutInterval": 5.0
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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.")
|
||||
|
|
|
@ -38,8 +38,7 @@ extension AppContext {
|
|||
receiptReader: MockAppReceiptReader(),
|
||||
unrestrictedFeatures: [
|
||||
.interactiveLogin,
|
||||
.onDemand,
|
||||
.sharing
|
||||
.onDemand
|
||||
],
|
||||
productsAtBuild: { _ in
|
||||
[]
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue