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",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
|
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "1b6bf03bb94e650852faabaa6b2161fe8b478151"
|
"revision" : "cd54765853982204f83d721a6c67a26742dc99e3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -40,7 +40,7 @@ let package = Package(
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.9.0"),
|
// .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(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", from: "0.9.1"),
|
||||||
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"),
|
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"),
|
||||||
|
|
|
@ -85,8 +85,10 @@ private extension AppData {
|
||||||
cdProfile.name = profile.name
|
cdProfile.name = profile.name
|
||||||
cdProfile.encoded = encoded
|
cdProfile.encoded = encoded
|
||||||
|
|
||||||
|
// redundant but convenient
|
||||||
let attributes = profile.attributes
|
let attributes = profile.attributes
|
||||||
cdProfile.isAvailableForTV = attributes.isAvailableForTV.map(NSNumber.init(value:))
|
cdProfile.isAvailableForTV = attributes.isAvailableForTV.map(NSNumber.init(value:))
|
||||||
|
cdProfile.expirationDate = attributes.expirationDate
|
||||||
cdProfile.lastUpdate = attributes.lastUpdate
|
cdProfile.lastUpdate = attributes.lastUpdate
|
||||||
cdProfile.fingerprint = attributes.fingerprint
|
cdProfile.fingerprint = attributes.fingerprint
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,7 @@ final class CDProfileV3: NSManagedObject {
|
||||||
@NSManaged var name: String?
|
@NSManaged var name: String?
|
||||||
@NSManaged var encoded: String?
|
@NSManaged var encoded: String?
|
||||||
@NSManaged var isAvailableForTV: NSNumber?
|
@NSManaged var isAvailableForTV: NSNumber?
|
||||||
|
@NSManaged var expirationDate: Date?
|
||||||
@NSManaged var lastUpdate: Date?
|
@NSManaged var lastUpdate: Date?
|
||||||
@NSManaged var fingerprint: UUID?
|
@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="">
|
<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">
|
<entity name="CDProfileV3" representedClassName="CDProfileV3" elementID="CDProfile" versionHashModifier="1" syncable="YES">
|
||||||
<attribute name="encoded" optional="YES" attributeType="String" allowsCloudEncryption="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="fingerprint" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
<attribute name="isAvailableForTV" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
<attribute name="isAvailableForTV" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
<attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
<attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
<attribute name="name" optional="YES" attributeType="String"/>
|
<attribute name="name" optional="YES" attributeType="String"/>
|
||||||
<attribute name="uuid" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
<attribute name="uuid" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
</entity>
|
</entity>
|
||||||
</model>
|
</model>
|
|
@ -82,9 +82,9 @@ private extension OnDemandView {
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var restrictedArea: some View {
|
var restrictedArea: some View {
|
||||||
switch iapManager.paywallReason(forFeature: .onDemand) {
|
switch iapManager.paywallReason(forFeature: .onDemand) {
|
||||||
case .purchase(let appFeature):
|
case .purchase(let feature):
|
||||||
Button(Strings.Modules.OnDemand.purchase) {
|
Button(Strings.Modules.OnDemand.purchase) {
|
||||||
paywallReason = .purchase(appFeature)
|
paywallReason = .purchase(feature)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .restricted:
|
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
|
@ObservedObject
|
||||||
var profileEditor: ProfileEditor
|
var profileEditor: ProfileEditor
|
||||||
|
|
||||||
@State
|
@Binding
|
||||||
private var paywallReason: PaywallReason?
|
var paywallReason: PaywallReason?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
debugChanges()
|
debugChanges()
|
||||||
return Group {
|
return Group {
|
||||||
sharingToggle
|
sharingToggle
|
||||||
tvToggle
|
|
||||||
ThemeCopiableText(
|
|
||||||
title: Strings.Unlocalized.uuid,
|
|
||||||
value: profileEditor.profile.id,
|
|
||||||
valueView: {
|
|
||||||
Text($0.flatString.localizedDescription(style: .quartets))
|
|
||||||
.monospaced()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.themeSection(
|
.themeSection(
|
||||||
header: Strings.Global.storage,
|
header: Strings.Global.storage,
|
||||||
footer: Strings.Modules.General.Sections.Storage.footer
|
footer: Strings.Modules.General.Sections.Storage.footer
|
||||||
)
|
)
|
||||||
.modifier(PaywallModifier(reason: $paywallReason))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension StorageSection {
|
private extension StorageSection {
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
var sharingToggle: some View {
|
var sharingToggle: some View {
|
||||||
switch iapManager.paywallReason(forFeature: .sharing) {
|
Toggle(Strings.Modules.General.Rows.icloudSharing, isOn: $profileEditor.isShared)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
Form {
|
Form {
|
||||||
StorageSection(
|
StorageSection(
|
||||||
profileEditor: ProfileEditor()
|
profileEditor: ProfileEditor(),
|
||||||
)
|
paywallReason: .constant(nil)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.themeForm()
|
.themeForm()
|
||||||
.withMockEnvironment()
|
.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
|
@State
|
||||||
private var malformedModuleIds: [UUID] = []
|
private var malformedModuleIds: [UUID] = []
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var paywallReason: PaywallReason?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
debugChanges()
|
debugChanges()
|
||||||
return List {
|
return List {
|
||||||
|
@ -52,21 +55,18 @@ struct ProfileEditView: View, Routable {
|
||||||
name: $profileEditor.profile.name,
|
name: $profileEditor.profile.name,
|
||||||
placeholder: Strings.Placeholders.Profile.name
|
placeholder: Strings.Placeholders.Profile.name
|
||||||
)
|
)
|
||||||
Group {
|
modulesSection
|
||||||
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
|
|
||||||
)
|
|
||||||
StorageSection(
|
StorageSection(
|
||||||
profileEditor: profileEditor
|
profileEditor: profileEditor,
|
||||||
|
paywallReason: $paywallReason
|
||||||
)
|
)
|
||||||
|
AppleTVSection(
|
||||||
|
profileEditor: profileEditor,
|
||||||
|
paywallReason: $paywallReason
|
||||||
|
)
|
||||||
|
UUIDSection(uuid: profileEditor.profile.id)
|
||||||
}
|
}
|
||||||
|
.modifier(PaywallModifier(reason: $paywallReason))
|
||||||
.toolbar(content: toolbarContent)
|
.toolbar(content: toolbarContent)
|
||||||
.navigationTitle(Strings.Global.profile)
|
.navigationTitle(Strings.Global.profile)
|
||||||
.navigationBarBackButtonHidden(true)
|
.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 {
|
func moduleRow(for module: any ModuleBuilder) -> some View {
|
||||||
EditorModuleToggle(profileEditor: profileEditor, module: module) {
|
EditorModuleToggle(profileEditor: profileEditor, module: module) {
|
||||||
Button {
|
Button {
|
||||||
|
|
|
@ -25,6 +25,7 @@
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
|
||||||
|
import CommonLibrary
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ProfileGeneralView: View {
|
struct ProfileGeneralView: View {
|
||||||
|
@ -32,6 +33,9 @@ struct ProfileGeneralView: View {
|
||||||
@ObservedObject
|
@ObservedObject
|
||||||
var profileEditor: ProfileEditor
|
var profileEditor: ProfileEditor
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var paywallReason: PaywallReason?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
NameSection(
|
NameSection(
|
||||||
|
@ -39,9 +43,16 @@ struct ProfileGeneralView: View {
|
||||||
placeholder: Strings.Placeholders.Profile.name
|
placeholder: Strings.Placeholders.Profile.name
|
||||||
)
|
)
|
||||||
StorageSection(
|
StorageSection(
|
||||||
profileEditor: profileEditor
|
profileEditor: profileEditor,
|
||||||
|
paywallReason: $paywallReason
|
||||||
)
|
)
|
||||||
|
AppleTVSection(
|
||||||
|
profileEditor: profileEditor,
|
||||||
|
paywallReason: $paywallReason
|
||||||
|
)
|
||||||
|
UUIDSection(uuid: profileEditor.profile.id)
|
||||||
}
|
}
|
||||||
|
.modifier(PaywallModifier(reason: $paywallReason))
|
||||||
.themeForm()
|
.themeForm()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -135,9 +135,9 @@ private extension ProviderContentModifier {
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var purchaseButton: some View {
|
var purchaseButton: some View {
|
||||||
switch iapManager.paywallReason(forFeature: .providers) {
|
switch iapManager.paywallReason(forFeature: .providers) {
|
||||||
case .purchase(let appFeature):
|
case .purchase(let feature):
|
||||||
Button(Strings.Providers.Picker.purchase) {
|
Button(Strings.Providers.Picker.purchase) {
|
||||||
paywallReason = .purchase(appFeature)
|
paywallReason = .purchase(feature)
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
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 profileTitleFormat: String
|
||||||
|
|
||||||
public let refreshInterval: TimeInterval
|
public let refreshInterval: TimeInterval
|
||||||
|
|
||||||
|
public let tvExpirationMinutes: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct API: Decodable, Sendable {
|
public struct API: Decodable, Sendable {
|
||||||
|
|
|
@ -30,16 +30,18 @@ import PassepartoutKit
|
||||||
public struct ProfileAttributes: Hashable, Codable {
|
public struct ProfileAttributes: Hashable, Codable {
|
||||||
public var isAvailableForTV: Bool?
|
public var isAvailableForTV: Bool?
|
||||||
|
|
||||||
|
public var expirationDate: Date?
|
||||||
|
|
||||||
public var lastUpdate: Date?
|
public var lastUpdate: Date?
|
||||||
|
|
||||||
public var fingerprint: UUID?
|
public var fingerprint: UUID?
|
||||||
|
|
||||||
public init(isAvailableForTV: Bool? = false) {
|
public init() {
|
||||||
self.isAvailableForTV = isAvailableForTV
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
isAvailableForTV: Bool?,
|
isAvailableForTV: Bool?,
|
||||||
|
expirationDate: Date?,
|
||||||
lastUpdate: Date?,
|
lastUpdate: Date?,
|
||||||
fingerprint: UUID?
|
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
|
// MARK: - ProfileUserInfoTransformable
|
||||||
|
|
||||||
// FIXME: #570, test user info encoding/decoding with JSONSerialization
|
// FIXME: #570, test user info encoding/decoding with JSONSerialization
|
||||||
|
|
|
@ -40,8 +40,6 @@ public enum AppFeature: String {
|
||||||
|
|
||||||
case routing
|
case routing
|
||||||
|
|
||||||
case sharing
|
|
||||||
|
|
||||||
case siri
|
case siri
|
||||||
|
|
||||||
public static let fullVersionFeaturesV2: [AppFeature] = [
|
public static let fullVersionFeaturesV2: [AppFeature] = [
|
||||||
|
@ -50,7 +48,6 @@ public enum AppFeature: String {
|
||||||
.onDemand,
|
.onDemand,
|
||||||
.providers,
|
.providers,
|
||||||
.routing,
|
.routing,
|
||||||
.sharing,
|
|
||||||
.siri
|
.siri
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,8 @@
|
||||||
},
|
},
|
||||||
"tunnel": {
|
"tunnel": {
|
||||||
"profileTitleFormat": "Passepartout: %@",
|
"profileTitleFormat": "Passepartout: %@",
|
||||||
"refreshInterval": 3.0
|
"refreshInterval": 3.0,
|
||||||
|
"tvExpirationMinutes": 10
|
||||||
},
|
},
|
||||||
"api": {
|
"api": {
|
||||||
"timeoutInterval": 5.0
|
"timeoutInterval": 5.0
|
||||||
|
|
|
@ -52,6 +52,9 @@ extension AppError: LocalizedError {
|
||||||
extension PassepartoutError: LocalizedError {
|
extension PassepartoutError: LocalizedError {
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch code {
|
switch code {
|
||||||
|
case .App.expiredProfile:
|
||||||
|
return Strings.Errors.App.expiredProfile
|
||||||
|
|
||||||
case .connectionModuleRequired:
|
case .connectionModuleRequired:
|
||||||
return Strings.Errors.App.Passepartout.connectionModuleRequired
|
return Strings.Errors.App.Passepartout.connectionModuleRequired
|
||||||
|
|
||||||
|
@ -108,6 +111,9 @@ extension PassepartoutError.Code: StyledLocalizableEntity {
|
||||||
case .tunnel:
|
case .tunnel:
|
||||||
let V = Strings.Errors.Tunnel.self
|
let V = Strings.Errors.Tunnel.self
|
||||||
switch self {
|
switch self {
|
||||||
|
case .App.expiredProfile:
|
||||||
|
return V.expired
|
||||||
|
|
||||||
case .authentication:
|
case .authentication:
|
||||||
return V.auth
|
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.")
|
public static let `default` = Strings.tr("Localizable", "errors.app.default", fallback: "Unable to complete operation.")
|
||||||
/// Profile name is empty.
|
/// Profile name is empty.
|
||||||
public static let emptyProfileName = Strings.tr("Localizable", "errors.app.empty_profile_name", fallback: "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. %@
|
/// Module %@ is malformed. %@
|
||||||
public static func malformedModule(_ p1: Any, _ p2: Any) -> String {
|
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. %@")
|
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")
|
public static let dns = Strings.tr("Localizable", "errors.tunnel.dns", fallback: "DNS failed")
|
||||||
/// Encryption failed
|
/// Encryption failed
|
||||||
public static let encryption = Strings.tr("Localizable", "errors.tunnel.encryption", fallback: "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
|
/// Failed
|
||||||
public static let generic = Strings.tr("Localizable", "errors.tunnel.generic", fallback: "Failed")
|
public static let generic = Strings.tr("Localizable", "errors.tunnel.generic", fallback: "Failed")
|
||||||
/// Missing routing
|
/// Missing routing
|
||||||
|
@ -333,26 +337,34 @@ public enum Strings {
|
||||||
}
|
}
|
||||||
public enum General {
|
public enum General {
|
||||||
public enum Rows {
|
public enum Rows {
|
||||||
/// Shared on %@
|
/// %@
|
||||||
public static func appleTv(_ p1: Any) -> String {
|
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
|
/// Shared on iCloud
|
||||||
public static let icloudSharing = Strings.tr("Localizable", "modules.general.rows.icloud_sharing", fallback: "Shared on iCloud")
|
public static let icloudSharing = Strings.tr("Localizable", "modules.general.rows.icloud_sharing", fallback: "Shared on iCloud")
|
||||||
/// Import from file...
|
/// Import from file...
|
||||||
public static let importFromFile = Strings.tr("Localizable", "modules.general.rows.import_from_file", fallback: "Import from file...")
|
public static let importFromFile = Strings.tr("Localizable", "modules.general.rows.import_from_file", fallback: "Import from file...")
|
||||||
public enum AppleTv {
|
public enum AppleTv {
|
||||||
/// Share on %@
|
/// Drop time restriction
|
||||||
public static func purchase(_ p1: Any) -> String {
|
public static let purchase = Strings.tr("Localizable", "modules.general.rows.apple_tv.purchase", fallback: "Drop time restriction")
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public enum Sections {
|
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 {
|
public enum Storage {
|
||||||
/// Profiles are stored to iCloud encrypted.
|
/// 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.")
|
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(),
|
receiptReader: MockAppReceiptReader(),
|
||||||
unrestrictedFeatures: [
|
unrestrictedFeatures: [
|
||||||
.interactiveLogin,
|
.interactiveLogin,
|
||||||
.onDemand,
|
.onDemand
|
||||||
.sharing
|
|
||||||
],
|
],
|
||||||
productsAtBuild: { _ in
|
productsAtBuild: { _ in
|
||||||
[]
|
[]
|
||||||
|
|
|
@ -170,8 +170,9 @@
|
||||||
// MARK: - Module views
|
// MARK: - Module views
|
||||||
|
|
||||||
"modules.general.sections.storage.footer" = "Profiles are stored to iCloud encrypted.";
|
"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.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.general.rows.import_from_file" = "Import from file...";
|
||||||
|
|
||||||
"modules.dns.servers.add" = "Add address";
|
"modules.dns.servers.add" = "Add address";
|
||||||
|
@ -251,8 +252,9 @@
|
||||||
|
|
||||||
// MARK: - Paywalls
|
// MARK: - Paywalls
|
||||||
|
|
||||||
"modules.general.rows.icloud_sharing.purchase" = "Share on iCloud";
|
"modules.general.sections.apple_tv.footer.purchase.1" = "TV profiles expire after %d minutes.";
|
||||||
"modules.general.rows.apple_tv.purchase" = "Share on %@";
|
"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.on_demand.purchase" = "Add on-demand rules";
|
||||||
"modules.openvpn.credentials.interactive.purchase" = "Log in interactively";
|
"modules.openvpn.credentials.interactive.purchase" = "Log in interactively";
|
||||||
"providers.picker.purchase" = "Add more providers";
|
"providers.picker.purchase" = "Add more providers";
|
||||||
|
@ -268,6 +270,7 @@
|
||||||
// MARK: - Errors
|
// MARK: - Errors
|
||||||
|
|
||||||
"errors.app.empty_profile_name" = "Profile name is empty.";
|
"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.malformed_module" = "Module %@ is malformed. %@";
|
||||||
"errors.app.provider.required" = "No provider selected.";
|
"errors.app.provider.required" = "No provider selected.";
|
||||||
"errors.app.default" = "Unable to complete operation.";
|
"errors.app.default" = "Unable to complete operation.";
|
||||||
|
@ -285,6 +288,7 @@
|
||||||
"errors.tunnel.compression" = "Compression unsupported";
|
"errors.tunnel.compression" = "Compression unsupported";
|
||||||
"errors.tunnel.dns" = "DNS failed";
|
"errors.tunnel.dns" = "DNS failed";
|
||||||
"errors.tunnel.encryption" = "Encryption failed";
|
"errors.tunnel.encryption" = "Encryption failed";
|
||||||
|
"errors.tunnel.expired" = "Expired";
|
||||||
"errors.tunnel.routing" = "Missing routing";
|
"errors.tunnel.routing" = "Missing routing";
|
||||||
"errors.tunnel.shutdown" = "Server shutdown";
|
"errors.tunnel.shutdown" = "Server shutdown";
|
||||||
"errors.tunnel.timeout" = "Timeout";
|
"errors.tunnel.timeout" = "Timeout";
|
||||||
|
|
|
@ -115,9 +115,9 @@ private extension OpenVPNCredentialsView {
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var restrictedArea: some View {
|
var restrictedArea: some View {
|
||||||
switch iapManager.paywallReason(forFeature: .interactiveLogin) {
|
switch iapManager.paywallReason(forFeature: .interactiveLogin) {
|
||||||
case .purchase(let appFeature):
|
case .purchase(let feature):
|
||||||
Button(Strings.Modules.Openvpn.Credentials.Interactive.purchase) {
|
Button(Strings.Modules.Openvpn.Credentials.Interactive.purchase) {
|
||||||
paywallReason = .purchase(appFeature)
|
paywallReason = .purchase(feature)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .restricted:
|
case .restricted:
|
||||||
|
|
|
@ -96,7 +96,6 @@ extension IAPManagerTests {
|
||||||
XCTAssertTrue(sut.isEligible(for: .httpProxy))
|
XCTAssertTrue(sut.isEligible(for: .httpProxy))
|
||||||
XCTAssertFalse(sut.isEligible(for: .onDemand))
|
XCTAssertFalse(sut.isEligible(for: .onDemand))
|
||||||
XCTAssertTrue(sut.isEligible(for: .routing))
|
XCTAssertTrue(sut.isEligible(for: .routing))
|
||||||
XCTAssertFalse(sut.isEligible(for: .sharing))
|
|
||||||
XCTAssertTrue(sut.isEligible(for: .siri))
|
XCTAssertTrue(sut.isEligible(for: .siri))
|
||||||
XCTAssertFalse(sut.isEligible(for: AppFeature.fullVersionFeaturesV2))
|
XCTAssertFalse(sut.isEligible(for: AppFeature.fullVersionFeaturesV2))
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,7 +41,7 @@ extension AppContext {
|
||||||
customUserLevel: Configuration.IAPManager.customUserLevel,
|
customUserLevel: Configuration.IAPManager.customUserLevel,
|
||||||
receiptReader: KvittoReceiptReader(),
|
receiptReader: KvittoReceiptReader(),
|
||||||
// FIXME: #662, omit unrestrictedFeatures on release!
|
// FIXME: #662, omit unrestrictedFeatures on release!
|
||||||
unrestrictedFeatures: [.interactiveLogin, .sharing],
|
unrestrictedFeatures: [.interactiveLogin],
|
||||||
productsAtBuild: Configuration.IAPManager.productsAtBuild
|
productsAtBuild: Configuration.IAPManager.productsAtBuild
|
||||||
)
|
)
|
||||||
let processor = ProfileProcessor(
|
let processor = ProfileProcessor(
|
||||||
|
@ -52,8 +52,26 @@ extension AppContext {
|
||||||
isIncluded: { _, profile in
|
isIncluded: { _, profile in
|
||||||
Configuration.ProfileManager.isProfileIncluded(profile)
|
Configuration.ProfileManager.isProfileIncluded(profile)
|
||||||
},
|
},
|
||||||
willSave: { _, builder in
|
willSave: { iap, builder in
|
||||||
builder
|
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
|
willConnect: { iap, profile in
|
||||||
var builder = profile.builder()
|
var builder = profile.builder()
|
||||||
|
|
|
@ -43,6 +43,9 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
|
||||||
registry: .shared,
|
registry: .shared,
|
||||||
environment: .shared
|
environment: .shared
|
||||||
)
|
)
|
||||||
|
if let expirationDate = fwd?.profile.attributes.expirationDate {
|
||||||
|
try checkExpirationDate(expirationDate, environment: .shared)
|
||||||
|
}
|
||||||
try await fwd?.startTunnel(options: options)
|
try await fwd?.startTunnel(options: options)
|
||||||
} catch {
|
} catch {
|
||||||
pp_log(.app, .fault, "Unable to start tunnel: \(error)")
|
pp_log(.app, .fault, "Unable to start tunnel: \(error)")
|
||||||
|
@ -74,3 +77,26 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
|
||||||
await fwd?.sleep()
|
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