Rethink eligibility checks (#889)

- Allow unrestricted save, but show PurchaseRequiredButton
- Warn however about paid features (FIXME)
- Redesign features in paywall
- Strip already eligible features from paywall
- List required features in restricted alert
- Localize feature descriptions
- Review propagation of paywall modifiers/reasons

Extra:

- Move more domain entities from UILibrary to CommonLibrary
- Default on-demand policy to .any (free feature)
- Fix modals not reappearing after closing with gesture
- Extend UILibrary start-up assertions
This commit is contained in:
Davide 2024-11-18 17:43:01 +01:00 committed by GitHub
parent e82dac3152
commit 89d7af4df7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
59 changed files with 844 additions and 311 deletions

View File

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

View File

@ -44,7 +44,7 @@ let package = Package(
],
dependencies: [
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.11.0"),
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "c0a615bc7a85d68a9b00d3703d0dae6efab9bdd2"),
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "db02de5247d0231ff06fb3c4d166645a434255be"),
// .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

@ -23,6 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import Foundation
import PassepartoutKit
@_exported import UILibrary
@ -42,19 +43,23 @@ private extension AppUIMain {
.openVPN
]
ModuleType.allCases.forEach { moduleType in
do {
let builder = moduleType.newModule(with: registry)
let module = try builder.tryBuild()
// ModuleViewProviding
guard builder is any ModuleViewProviding else {
fatalError("\(moduleType): is not ModuleViewProviding")
}
// ProviderEntityViewProviding
if providerModuleTypes.contains(moduleType) {
do {
let module = try builder.tryBuild()
guard module is any ProviderEntityViewProviding else {
fatalError("\(moduleType): is not ProviderEntityViewProviding")
}
} catch {
fatalError("\(moduleType): empty module is not buildable")
}
} catch {
fatalError("\(moduleType): empty module is not buildable: \(error)")
}
}
}

View File

@ -54,6 +54,9 @@ public struct AppCoordinator: View, AppCoordinatorConforming {
@State
private var migrationPath = NavigationPath()
@State
private var paywallReason: PaywallReason?
@StateObject
private var errorHandler: ErrorHandler = .default()
@ -72,6 +75,7 @@ public struct AppCoordinator: View, AppCoordinatorConforming {
contentView
.toolbar(content: toolbarContent)
}
.modifier(PaywallModifier(reason: $paywallReason))
.themeModal(
item: $modalRoute,
size: modalRoute?.size ?? .large,
@ -87,6 +91,8 @@ public struct AppCoordinator: View, AppCoordinatorConforming {
extension AppCoordinator {
enum ModalRoute: Identifiable {
case about
case editProfile
case editProviderEntity(Profile, Module, SerializedProvider)
@ -95,15 +101,13 @@ extension AppCoordinator {
case settings
case about
var id: Int {
switch self {
case .editProfile: return 1
case .editProviderEntity: return 2
case .migrateProfiles: return 3
case .settings: return 4
case .about: return 5
case .about: return 1
case .editProfile: return 2
case .editProviderEntity: return 3
case .migrateProfiles: return 4
case .settings: return 5
}
}
@ -171,6 +175,11 @@ extension AppCoordinator {
},
onMigrateProfiles: {
modalRoute = .migrateProfiles
},
onPurchaseRequired: { features in
setLater(.purchase(features)) {
paywallReason = $0
}
}
)
)
@ -197,6 +206,12 @@ extension AppCoordinator {
@ViewBuilder
func modalDestination(for item: ModalRoute?) -> some View {
switch item {
case .about:
AboutRouterView(
profileManager: profileManager,
tunnel: tunnel
)
case .editProfile:
ProfileCoordinator(
profileManager: profileManager,
@ -205,9 +220,7 @@ extension AppCoordinator {
moduleViewFactory: DefaultModuleViewFactory(registry: registry),
modally: true,
path: $profilePath,
onDismiss: {
present(nil)
}
onDismiss: onDismiss
)
case .editProviderEntity(let profile, let module, let provider):
@ -230,12 +243,6 @@ extension AppCoordinator {
case .settings:
SettingsView(profileManager: profileManager)
case .about:
AboutRouterView(
profileManager: profileManager,
tunnel: tunnel
)
default:
EmptyView()
}
@ -256,11 +263,15 @@ extension AppCoordinator {
present(.editProfile)
}
func onDismiss() {
present(nil)
}
func present(_ route: ModalRoute?) {
// XXX: this is a workaround for #791 on iOS 16
Task {
try await Task.sleep(for: .milliseconds(50))
modalRoute = route
setLater(route) {
modalRoute = $0
}
}
}

View File

@ -201,7 +201,12 @@ private struct ToggleButton: View {
nextProfileId: $nextProfileId,
interactiveManager: interactiveManager,
errorHandler: errorHandler,
onProviderEntityRequired: flow?.onEditProviderEntity,
onProviderEntityRequired: {
flow?.onEditProviderEntity($0)
},
onPurchaseRequired: {
flow?.onPurchaseRequired($0)
},
label: { _ in
ThemeImage(.tunnelToggle)
.scaleEffect(1.5, anchor: .trailing)

View File

@ -69,13 +69,20 @@ private extension ProfileContextMenu {
profile: profile,
nextProfileId: .constant(nil),
interactiveManager: interactiveManager,
errorHandler: errorHandler
) {
errorHandler: errorHandler,
onProviderEntityRequired: {
flow?.onEditProviderEntity($0)
},
onPurchaseRequired: {
flow?.onPurchaseRequired($0)
},
label: {
ThemeImageLabel(
$0 ? Strings.Global.enable : Strings.Global.disable,
$0 ? .tunnelEnable : .tunnelDisable
)
}
)
}
var providerConnectToButton: some View {
@ -92,10 +99,14 @@ private extension ProfileContextMenu {
TunnelRestartButton(
tunnel: tunnel,
profile: profile,
errorHandler: errorHandler
) {
errorHandler: errorHandler,
onPurchaseRequired: {
flow?.onPurchaseRequired($0)
},
label: {
ThemeImageLabel(Strings.Global.restart, .tunnelRestart)
}
)
}
var profileEditButton: some View {

View File

@ -23,6 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import Foundation
import PassepartoutKit
@ -32,4 +33,6 @@ struct ProfileFlow {
let onEditProviderEntity: (Profile) -> Void
let onMigrateProfiles: () -> Void
let onPurchaseRequired: (Set<AppFeature>) -> Void
}

View File

@ -122,7 +122,12 @@ private extension ProfileRowView {
nextProfileId: $nextProfileId,
interactiveManager: interactiveManager,
errorHandler: errorHandler,
onProviderEntityRequired: flow?.onEditProviderEntity,
onProviderEntityRequired: {
flow?.onEditProviderEntity($0)
},
onPurchaseRequired: {
flow?.onPurchaseRequired($0)
},
label: { _ in
ProfileCardView(
style: style,

View File

@ -37,6 +37,8 @@ struct TunnelRestartButton<Label>: View where Label: View {
let errorHandler: ErrorHandler
let onPurchaseRequired: (Set<AppFeature>) -> Void
let label: () -> Label
var body: some View {
@ -50,6 +52,8 @@ struct TunnelRestartButton<Label>: View where Label: View {
Task {
do {
try await tunnel.connect(with: profile)
} catch AppError.ineligibleProfile(let requiredFeatures) {
onPurchaseRequired(requiredFeatures)
} catch is CancellationError {
//
} catch {

View File

@ -28,7 +28,7 @@ import CommonUtils
import PassepartoutKit
import SwiftUI
// FIXME: #878, show CloudKit progress
// TODO: #878, show CloudKit progress
struct MigrateView: View {
enum Style {

View File

@ -33,9 +33,6 @@ struct OnDemandView: View, ModuleDraftEditing {
@EnvironmentObject
private var theme: Theme
@EnvironmentObject
private var iapManager: IAPManager
@ObservedObject
var editor: ProfileEditor
@ -59,14 +56,7 @@ struct OnDemandView: View, ModuleDraftEditing {
var body: some View {
Group {
enabledSection
restrictedArea
.modifier(PurchaseButtonModifier(
Strings.Modules.OnDemand.purchase,
feature: .onDemand,
suggesting: nil,
showsIfRestricted: false,
paywallReason: $paywallReason
))
rulesArea
}
.moduleView(editor: editor, draft: draft.wrappedValue)
.modifier(PaywallModifier(reason: $paywallReason))
@ -87,7 +77,7 @@ private extension OnDemandView {
}
@ViewBuilder
var restrictedArea: some View {
var rulesArea: some View {
if draft.wrappedValue.isEnabled {
policySection
if draft.wrappedValue.policy != .any {
@ -98,10 +88,15 @@ private extension OnDemandView {
}
var policySection: some View {
Picker(Strings.Modules.OnDemand.policy, selection: draft.policy) {
Picker(selection: draft.policy) {
ForEach(Self.allPolicies, id: \.self) {
Text($0.localizedDescription)
}
} label: {
HStack {
Text(Strings.Modules.OnDemand.policy)
PurchaseRequiredButton(for: module, paywallReason: $paywallReason)
}
}
.themeSectionWithSingleRow(footer: policyFooterDescription)
}

View File

@ -58,6 +58,12 @@ struct ProfileCoordinator: View {
let onDismiss: () -> Void
@State
private var requiresPurchase = false
@State
private var requiredFeatures: Set<AppFeature> = []
@State
private var paywallReason: PaywallReason?
@ -67,6 +73,15 @@ struct ProfileCoordinator: View {
var body: some View {
contentView
.modifier(PaywallModifier(reason: $paywallReason))
.alert(Strings.Views.Profile.Alerts.Purchase.title, isPresented: $requiresPurchase) {
Button(Strings.Global.purchase) {
paywallReason = .purchase(requiredFeatures, nil)
}
Button(Strings.Views.Profile.Alerts.Purchase.Buttons.ok, action: onDismiss)
Button(Strings.Global.cancel, role: .cancel, action: {})
} message: {
Text(purchaseMessage)
}
.withErrorHandler(errorHandler)
}
}
@ -80,6 +95,7 @@ private extension ProfileCoordinator {
profileEditor: profileEditor,
moduleViewFactory: moduleViewFactory,
path: $path,
paywallReason: $paywallReason,
flow: .init(
onNewModule: onNewModule,
onCommitEditing: onCommitEditing,
@ -92,6 +108,7 @@ private extension ProfileCoordinator {
ProfileSplitView(
profileEditor: profileEditor,
moduleViewFactory: moduleViewFactory,
paywallReason: $paywallReason,
flow: .init(
onNewModule: onNewModule,
onCommitEditing: onCommitEditing,
@ -100,33 +117,17 @@ private extension ProfileCoordinator {
)
#endif
}
var purchaseMessage: String {
let msg = Strings.Views.Profile.Alerts.Purchase.message
return msg + "\n\n" + requiredFeatures
.map(\.localizedDescription)
.joined(separator: "\n")
}
}
private extension ProfileCoordinator {
func onNewModule(_ moduleType: ModuleType) {
switch moduleType {
case .dns:
paywallReason = iapManager.paywallReason(forFeature: .dns, suggesting: nil)
case .httpProxy:
paywallReason = iapManager.paywallReason(forFeature: .httpProxy, suggesting: nil)
case .ip:
paywallReason = iapManager.paywallReason(forFeature: .routing, suggesting: nil)
case .openVPN, .wireGuard:
break
case .onDemand:
break
default:
fatalError("Unhandled module type: \(moduleType)")
}
guard paywallReason == nil else {
return
}
let module = moduleType.newModule(with: registry)
withAnimation(theme.animation(for: .modules)) {
profileEditor.saveModule(module, activating: true)
@ -135,14 +136,42 @@ private extension ProfileCoordinator {
func onCommitEditing() async throws {
do {
try await profileEditor.save(to: profileManager)
onDismiss()
if !iapManager.isRestricted {
try await onCommitEditingStandard()
} else {
try await onCommitEditingRestricted()
}
} catch {
errorHandler.handle(error, title: Strings.Global.save)
throw error
}
}
// standard: always save, warn if purchase required
func onCommitEditingStandard() async throws {
let savedProfile = try await profileEditor.save(to: profileManager)
do {
try iapManager.verify(savedProfile.activeModules)
} catch AppError.ineligibleProfile(let requiredFeatures) {
self.requiredFeatures = requiredFeatures
requiresPurchase = true
return
}
onDismiss()
}
// restricted: verify before saving
func onCommitEditingRestricted() async throws {
do {
try iapManager.verify(profileEditor.activeModules)
} catch AppError.ineligibleProfile(let requiredFeatures) {
paywallReason = .purchase(requiredFeatures)
return
}
try await profileEditor.save(to: profileManager)
onDismiss()
}
func onCancelEditing() {
onDismiss()
}

View File

@ -31,7 +31,7 @@ struct ProfileSaveButton: View {
let title: String
@Binding
var errorModuleIds: [UUID]
var errorModuleIds: Set<UUID>
let action: () async throws -> Void
@ -43,9 +43,6 @@ struct ProfileSaveButton: View {
errorModuleIds = []
} catch {
switch AppError(error) {
case .malformedModule(let module, _):
errorModuleIds = [module.id]
case .generic(let ppError):
switch ppError.code {
case .connectionModuleRequired:
@ -60,12 +57,15 @@ struct ProfileSaveButton: View {
errorModuleIds = []
return
}
errorModuleIds = modules.map(\.id)
errorModuleIds = Set(modules.map(\.id))
default:
errorModuleIds = []
}
case .malformedModule(let module, _):
errorModuleIds = [module.id]
default:
errorModuleIds = []
}

View File

@ -41,8 +41,9 @@ struct StorageSection: View {
debugChanges()
return Group {
sharingToggle
.themeRow(footer: sharingDescription)
tvToggle
.themeRow(footer: footer)
.themeRow(footer: tvDescription)
purchaseButton
}
.themeSection(
@ -102,18 +103,26 @@ private extension StorageSection {
var desc = [
Strings.Modules.General.Sections.Storage.footer(Strings.Unlocalized.iCloud)
]
switch iapManager.paywallReason(forFeature: .appleTV, suggesting: nil) {
case .purchase:
desc.append(Strings.Modules.General.Sections.Storage.Footer.Purchase.tvRelease)
case .restricted:
desc.append(Strings.Modules.General.Sections.Storage.Footer.Purchase.tvBeta)
default:
break
if let tvDescription {
desc.append(tvDescription)
}
return desc.joined(separator: " ")
}
var sharingDescription: String {
Strings.Modules.General.Sections.Storage.footer(Strings.Unlocalized.iCloud)
}
var tvDescription: String? {
if iapManager.isEligible(for: .appleTV) {
return nil
}
if !iapManager.isRestricted {
return Strings.Modules.General.Sections.Storage.Footer.Purchase.tvRelease
} else {
return Strings.Modules.General.Sections.Storage.Footer.Purchase.tvBeta
}
}
}
#Preview {

View File

@ -40,13 +40,13 @@ struct ProfileEditView: View, Routable {
@Binding
var path: NavigationPath
@Binding
var paywallReason: PaywallReason?
var flow: ProfileCoordinator.Flow?
@State
private var malformedModuleIds: [UUID] = []
@State
private var paywallReason: PaywallReason?
private var errorModuleIds: Set<UUID> = []
var body: some View {
debugChanges()
@ -62,7 +62,6 @@ struct ProfileEditView: View, Routable {
)
UUIDSection(uuid: profileEditor.profile.id)
}
.modifier(PaywallModifier(reason: $paywallReason))
.toolbar(content: toolbarContent)
.navigationTitle(Strings.Global.profile)
.navigationBarBackButtonHidden(true)
@ -79,7 +78,7 @@ private extension ProfileEditView {
ToolbarItem(placement: .confirmationAction) {
ProfileSaveButton(
title: Strings.Global.save,
errorModuleIds: $malformedModuleIds
errorModuleIds: $errorModuleIds
) {
try await flow?.onCommitEditing()
}
@ -112,11 +111,19 @@ private extension ProfileEditView {
} label: {
HStack {
Text(module.description(inEditor: profileEditor))
.themeError(malformedModuleIds.contains(module.id))
if errorModuleIds.contains(module.id) {
ThemeImage(.warning)
} else if profileEditor.isActiveModule(withId: module.id) {
PurchaseRequiredButton(
for: module as? AppFeatureRequiring,
paywallReason: $paywallReason
)
}
Spacer()
}
.contentShape(.rect)
}
.buttonStyle(.plain)
}
}
@ -132,7 +139,6 @@ private extension ProfileEditView {
}
} label: {
Text(Strings.Views.Profile.Rows.addModule)
// .frame(maxWidth: .infinity, alignment: .leading)
}
.disabled(moduleTypes.isEmpty)
}
@ -178,7 +184,8 @@ private extension ProfileEditView {
ProfileEditView(
profileEditor: ProfileEditor(profile: .newMockProfile()),
moduleViewFactory: DefaultModuleViewFactory(registry: Registry()),
path: .constant(NavigationPath())
path: .constant(NavigationPath()),
paywallReason: .constant(nil)
)
}
.withMockEnvironment()

View File

@ -25,6 +25,7 @@
#if os(macOS)
import CommonLibrary
import CommonUtils
import PassepartoutKit
import SwiftUI
@ -39,7 +40,10 @@ struct ModuleListView: View, Routable {
var selectedModuleId: UUID?
@Binding
var malformedModuleIds: [UUID]
var errorModuleIds: Set<UUID>
@Binding
var paywallReason: PaywallReason?
var flow: ProfileCoordinator.Flow?
@ -69,7 +73,14 @@ private extension ModuleListView {
func moduleRow(for module: any ModuleBuilder) -> some View {
HStack {
Text(module.description(inEditor: profileEditor))
.themeError(malformedModuleIds.contains(module.id))
if errorModuleIds.contains(module.id) {
ThemeImage(.warning)
} else if profileEditor.isActiveModule(withId: module.id) {
PurchaseRequiredButton(
for: module as? AppFeatureRequiring,
paywallReason: $paywallReason
)
}
Spacer()
EditorModuleToggle(profileEditor: profileEditor, module: module) {
EmptyView()
@ -138,7 +149,8 @@ private extension ModuleListView {
ModuleListView(
profileEditor: ProfileEditor(profile: .mock),
selectedModuleId: .constant(nil),
malformedModuleIds: .constant([])
errorModuleIds: .constant([]),
paywallReason: .constant(nil)
)
.withMockEnvironment()
}

View File

@ -33,8 +33,8 @@ struct ProfileGeneralView: View {
@ObservedObject
var profileEditor: ProfileEditor
@State
private var paywallReason: PaywallReason?
@Binding
var paywallReason: PaywallReason?
var body: some View {
Form {
@ -48,14 +48,14 @@ struct ProfileGeneralView: View {
)
UUIDSection(uuid: profileEditor.profile.id)
}
.modifier(PaywallModifier(reason: $paywallReason))
.themeForm()
}
}
#Preview {
ProfileGeneralView(
profileEditor: ProfileEditor()
profileEditor: ProfileEditor(),
paywallReason: .constant(nil)
)
.withMockEnvironment()
}

View File

@ -25,6 +25,7 @@
#if os(macOS)
import CommonLibrary
import CommonUtils
import PassepartoutKit
import SwiftUI
@ -34,6 +35,9 @@ struct ProfileSplitView: View, Routable {
let moduleViewFactory: any ModuleViewFactory
@Binding
var paywallReason: PaywallReason?
var flow: ProfileCoordinator.Flow?
@State
@ -43,7 +47,7 @@ struct ProfileSplitView: View, Routable {
private var selectedModuleId: UUID? = ModuleListView.generalModuleId
@State
private var malformedModuleIds: [UUID] = []
private var errorModuleIds: Set<UUID> = []
var body: some View {
debugChanges()
@ -51,9 +55,11 @@ struct ProfileSplitView: View, Routable {
ModuleListView(
profileEditor: profileEditor,
selectedModuleId: $selectedModuleId,
malformedModuleIds: $malformedModuleIds,
errorModuleIds: $errorModuleIds,
paywallReason: $paywallReason,
flow: flow
)
.navigationSplitViewColumnWidth(200)
} detail: {
Group {
switch selectedModuleId {
@ -85,7 +91,7 @@ extension ProfileSplitView {
ToolbarItem(placement: .confirmationAction) {
ProfileSaveButton(
title: Strings.Global.save,
errorModuleIds: $malformedModuleIds
errorModuleIds: $errorModuleIds
) {
try await flow?.onCommitEditing()
}
@ -109,7 +115,10 @@ private extension ProfileSplitView {
func detailView(for detail: Detail) -> some View {
switch detail {
case .general:
ProfileGeneralView(profileEditor: profileEditor)
ProfileGeneralView(
profileEditor: profileEditor,
paywallReason: $paywallReason
)
case .module(let id):
ModuleDetailView(
@ -124,7 +133,8 @@ private extension ProfileSplitView {
#Preview {
ProfileSplitView(
profileEditor: ProfileEditor(profile: .newMockProfile()),
moduleViewFactory: DefaultModuleViewFactory(registry: Registry())
moduleViewFactory: DefaultModuleViewFactory(registry: Registry()),
paywallReason: .constant(nil)
)
.withMockEnvironment()
}

View File

@ -30,9 +30,6 @@ import SwiftUI
struct ProviderContentModifier<Entity, ProviderRows>: ViewModifier where Entity: ProviderEntity, Entity.Configuration: ProviderConfigurationIdentifiable & Codable, ProviderRows: View {
@EnvironmentObject
private var iapManager: IAPManager
@EnvironmentObject
private var providerManager: ProviderManager
@ -77,13 +74,11 @@ private extension ProviderContentModifier {
#if os(iOS)
@ViewBuilder
var providerView: some View {
Group {
providerPicker
purchaseButton
}
.themeSection()
Group {
if providerId != nil {
Group {
providerRows
refreshButton {
HStack {
@ -95,18 +90,17 @@ private extension ProviderContentModifier {
}
}
}
}
.themeSection(footer: lastUpdatedString)
}
}
#else
@ViewBuilder
var providerView: some View {
Section {
providerPicker
purchaseButton
}
Section {
if providerId != nil {
Section {
providerRows
HStack {
lastUpdatedString.map {
@ -128,19 +122,9 @@ private extension ProviderContentModifier {
providers: supportedProviders,
providerId: $providerId,
isRequired: true,
isLoading: providerManager.isLoading
)
}
var purchaseButton: some View {
EmptyView()
.modifier(PurchaseButtonModifier(
Strings.Providers.Picker.purchase,
feature: .providers,
suggesting: nil,
showsIfRestricted: true,
isLoading: providerManager.isLoading,
paywallReason: $paywallReason
))
)
}
func refreshButton<Label>(label: () -> Label) -> some View where Label: View {
@ -151,7 +135,7 @@ private extension ProviderContentModifier {
providerManager
.providers
.filter {
iapManager.isEligible(forProvider: $0.id) && $0.supports(Entity.Configuration.self)
$0.supports(Entity.Configuration.self)
}
}

View File

@ -23,6 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import PassepartoutKit
import SwiftUI
@ -36,8 +37,11 @@ struct ProviderPicker: View {
let isLoading: Bool
@Binding
var paywallReason: PaywallReason?
var body: some View {
Picker(Strings.Global.provider, selection: $providerId) {
Picker(selection: $providerId) {
if !providers.isEmpty {
Text(isRequired ? Strings.Providers.selectProvider : Strings.Providers.noProvider)
.tag(nil as ProviderID?)
@ -49,6 +53,11 @@ struct ProviderPicker: View {
Text(isLoading ? Strings.Global.loading : Strings.Global.none)
.tag(providerId) // tag always exists
}
} label: {
HStack {
Text(Strings.Global.provider)
PurchaseRequiredButton(for: providerId, paywallReason: $paywallReason)
}
}
.disabled(isLoading || providers.isEmpty)
}

View File

@ -83,6 +83,12 @@ private extension ActiveProfileView {
nextProfileId: .constant(nil),
interactiveManager: interactiveManager,
errorHandler: errorHandler,
onProviderEntityRequired: { _ in
// FIXME: #788, TV missing provider entity
},
onPurchaseRequired: { _ in
// FIXME: #788, TV purchase required
},
label: {
Text($0 ? Strings.Global.connect : Strings.Global.disconnect)
.frame(maxWidth: .infinity)

View File

@ -69,6 +69,12 @@ private extension ProfileListView {
nextProfileId: .constant(nil),
interactiveManager: interactiveManager,
errorHandler: errorHandler,
onProviderEntityRequired: { _ in
// FIXME: #788, TV missing provider entity
},
onPurchaseRequired: { _ in
// FIXME: #788, TV purchase required
},
label: { _ in
toggleView(for: header)
}

View File

@ -449,7 +449,7 @@ private extension ProfileManager {
}
for remoteProfile in profiles {
do {
guard processor?.isIncluded(remoteProfile) ?? true else {
guard await processor?.isIncluded(remoteProfile) ?? true else {
pp_log(.App.profiles, .info, "Will delete non-included remote profile \(remoteProfile.id)")
idsToRemove.append(remoteProfile.id)
continue

View File

@ -26,6 +26,7 @@
import Foundation
import PassepartoutKit
@MainActor
public final class ProfileProcessor: ObservableObject, Sendable {
private let iapManager: IAPManager

View File

@ -33,6 +33,8 @@ public enum AppError: Error {
case emptyProfileName
case ineligibleProfile(Set<AppFeature>)
case malformedModule(any ModuleBuilder, error: Error)
case permissionDenied

View File

@ -23,23 +23,38 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import Foundation
import PassepartoutKit
public struct EditableProfile: MutableProfileType {
public var id = UUID()
public var id: UUID
public var name: String = ""
public var name: String
public var modules: [any ModuleBuilder] = []
public var modules: [any ModuleBuilder]
public var activeModulesIds: Set<UUID> = []
public var activeModulesIds: Set<UUID>
public var modulesMetadata: [UUID: ModuleMetadata]?
public var userInfo: [String: AnyHashable]?
public init(
id: UUID = UUID(),
name: String = "",
modules: [any ModuleBuilder] = [],
activeModulesIds: Set<UUID>,
modulesMetadata: [UUID: ModuleMetadata]? = nil,
userInfo: [String: AnyHashable]? = nil
) {
self.id = id
self.name = name
self.modules = modules
self.activeModulesIds = activeModulesIds
self.modulesMetadata = modulesMetadata
self.userInfo = userInfo
}
public func builder() throws -> Profile.Builder {
var builder = Profile.Builder(id: id)
builder.modules = try modules.compactMap {
@ -73,7 +88,7 @@ public struct EditableProfile: MutableProfileType {
}
extension EditableProfile {
var attributes: ProfileAttributes {
public var attributes: ProfileAttributes {
get {
userInfo() ?? ProfileAttributes()
}

View File

@ -49,7 +49,9 @@ extension ModuleType {
return IPModule.Builder()
case .onDemand:
return OnDemandModule.Builder()
var builder = OnDemandModule.Builder()
builder.policy = .any
return builder
default:
fatalError("Unknown module type: \(rawValue)")

View File

@ -0,0 +1,87 @@
//
// AppFeatureRequiring.swift
// Passepartout
//
// Created by Davide De Rosa on 11/17/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 Foundation
import PassepartoutKit
public protocol AppFeatureRequiring {
var features: Set<AppFeature> { get }
}
// MARK: - Modules
extension DNSModule.Builder: AppFeatureRequiring {
public var features: Set<AppFeature> {
[.dns]
}
}
extension HTTPProxyModule.Builder: AppFeatureRequiring {
public var features: Set<AppFeature> {
[.httpProxy]
}
}
extension IPModule.Builder: AppFeatureRequiring {
public var features: Set<AppFeature> {
[.routing]
}
}
extension OnDemandModule.Builder: AppFeatureRequiring {
public var features: Set<AppFeature> {
guard isEnabled else {
return []
}
return policy != .any ? [.onDemand] : []
}
}
extension OpenVPNModule.Builder: AppFeatureRequiring {
public var features: Set<AppFeature> {
var list: Set<AppFeature> = []
providerId?.features.forEach {
list.insert($0)
}
if isInteractive {
list.insert(.interactiveLogin)
}
return list
}
}
extension WireGuardModule.Builder: AppFeatureRequiring {
public var features: Set<AppFeature> {
providerId?.features ?? []
}
}
// MARK: - Providers
extension ProviderID: AppFeatureRequiring {
public var features: Set<AppFeature> {
self != .oeck ? [.providers] : []
}
}

View File

@ -0,0 +1,64 @@
//
// IAPManager+Verify.swift
// Passepartout
//
// Created by Davide De Rosa on 11/18/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 Foundation
import PassepartoutKit
extension IAPManager {
public func verify(_ modules: [Module]) throws {
let builders = modules.map {
guard let builder = $0.moduleBuilder() else {
fatalError("Cannot produce ModuleBuilder from Module for IAPManager.verify(): \($0)")
}
return builder
}
try verify(builders)
}
public func verify(_ modulesBuilders: [any ModuleBuilder]) throws {
#if os(tvOS)
guard isEligible(for: .appleTV) else {
throw AppError.ineligibleProfile([.appleTV])
}
#endif
let requirements: [(UUID, Set<AppFeature>)] = modulesBuilders
.compactMap { builder in
guard let requiring = builder as? AppFeatureRequiring else {
return nil
}
return (builder.id, requiring.features)
}
let requiredFeatures = Set(requirements
.flatMap(\.1)
.filter {
!isEligible(for: $0)
})
guard requiredFeatures.isEmpty else {
throw AppError.ineligibleProfile(requiredFeatures)
}
}
}

View File

@ -124,7 +124,7 @@ extension IAPManager {
eligibleFeatures.contains(feature)
}
public func isEligible(for features: [AppFeature]) -> Bool {
public func isEligible<C>(for features: C) -> Bool where C: Collection, C.Element == AppFeature {
features.allSatisfy(eligibleFeatures.contains)
}
@ -143,13 +143,6 @@ extension IAPManager {
#endif
}
public func paywallReason(forFeature feature: AppFeature, suggesting product: AppProduct?) -> PaywallReason? {
if isEligible(for: feature) {
return nil
}
return isRestricted ? .restricted : .purchase(feature, product)
}
public func isPayingUser() -> Bool {
!purchasedProducts.isEmpty
}

View File

@ -26,7 +26,5 @@
import Foundation
public enum PaywallReason: Hashable {
case restricted
case purchase(AppFeature, AppProduct?)
case purchase(Set<AppFeature>, AppProduct? = nil)
}

View File

@ -49,6 +49,14 @@ extension View {
$0.animation = nil
}
}
public func setLater<T>(_ value: T?, millis: Int = 50, block: @escaping (T?) -> Void) {
Task {
block(nil)
try await Task.sleep(for: .milliseconds(millis))
block(value)
}
}
}
extension ViewModifier {
@ -58,3 +66,17 @@ extension ViewModifier {
}
}
}
#if !os(tvOS)
extension Table {
@ViewBuilder
public func withoutColumnHeaders() -> some View {
if #available(iOS 17, macOS 14, *) {
tableColumnHeaders(.hidden)
} else {
self
}
}
}
#endif

View File

@ -23,6 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import Foundation
import PassepartoutKit

View File

@ -117,6 +117,12 @@ extension ProfileEditor {
editableProfile.modules
}
public var activeModules: [any ModuleBuilder] {
editableProfile.modules.filter {
isActiveModule(withId: $0.id)
}
}
public func module(withId moduleId: UUID) -> (any ModuleBuilder)? {
editableProfile.modules.first {
$0.id == moduleId
@ -199,10 +205,13 @@ extension ProfileEditor {
// MARK: - Saving
extension ProfileEditor {
public func save(to profileManager: ProfileManager) async throws {
@discardableResult
public func save(to profileManager: ProfileManager) async throws -> Profile {
do {
let newProfile = try build()
try await profileManager.save(newProfile, force: true, remotelyShared: isShared)
return newProfile
} catch {
pp_log(.app, .fault, "Unable to save edited profile: \(error)")
throw error

View File

@ -41,6 +41,9 @@ extension AppError: LocalizedError {
case .emptyProfileName:
return V.emptyProfileName
case .ineligibleProfile:
return nil
case .malformedModule(let module, let error):
return V.malformedModule(module.moduleType.localizedDescription, error.localizedDescription)
@ -58,9 +61,6 @@ extension AppError: LocalizedError {
extension PassepartoutError: @retroactive LocalizedError {
public var errorDescription: String? {
switch code {
case .App.ineligibleProfile:
return Strings.Errors.App.ineligibleProfile
case .connectionModuleRequired:
return Strings.Errors.App.Passepartout.connectionModuleRequired

View File

@ -44,7 +44,7 @@ extension AppFeature: LocalizableEntity {
return V.interactiveLogin
case .onDemand:
return V.onDemand(Strings.Global.onDemand)
return V.onDemand
case .providers:
return V.providers
@ -57,3 +57,9 @@ extension AppFeature: LocalizableEntity {
}
}
}
extension AppFeature: Comparable {
public static func < (lhs: Self, rhs: Self) -> Bool {
lhs.localizedDescription.lowercased() < rhs.localizedDescription.lowercased()
}
}

View File

@ -23,6 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import Foundation
import PassepartoutKit

View File

@ -23,6 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import CommonUtils
import Foundation
import PassepartoutKit

View File

@ -13,8 +13,8 @@ public enum Strings {
public enum Alerts {
public enum Iap {
public enum Restricted {
/// The requested feature is unavailable in this build.
public static let message = Strings.tr("Localizable", "alerts.iap.restricted.message", fallback: "The requested feature is unavailable in this build.")
/// Some features are unavailable in this build.
public static let message = Strings.tr("Localizable", "alerts.iap.restricted.message", fallback: "Some features are unavailable in this build.")
/// Restricted
public static let title = Strings.tr("Localizable", "alerts.iap.restricted.title", fallback: "Restricted")
}
@ -116,8 +116,6 @@ public enum Strings {
public static let emptyProducts = Strings.tr("Localizable", "errors.app.empty_products", fallback: "Unable to fetch products, please retry later.")
/// Profile name is empty.
public static let emptyProfileName = Strings.tr("Localizable", "errors.app.empty_profile_name", fallback: "Profile name is empty.")
/// A purchase is required for this profile to work.
public static let ineligibleProfile = Strings.tr("Localizable", "errors.app.ineligible_profile", fallback: "A purchase is required for this profile to work.")
/// 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. %@")
@ -179,25 +177,23 @@ public enum Strings {
public static func appleTV(_ p1: Any) -> String {
return Strings.tr("Localizable", "features.appleTV", String(describing: p1), fallback: "%@")
}
/// %@
/// %@ Settings
public static func dns(_ p1: Any) -> String {
return Strings.tr("Localizable", "features.dns", String(describing: p1), fallback: "%@")
return Strings.tr("Localizable", "features.dns", String(describing: p1), fallback: "%@ Settings")
}
/// %@
/// %@ Settings
public static func httpProxy(_ p1: Any) -> String {
return Strings.tr("Localizable", "features.httpProxy", String(describing: p1), fallback: "%@")
return Strings.tr("Localizable", "features.httpProxy", String(describing: p1), fallback: "%@ Settings")
}
/// Interactive Login
public static let interactiveLogin = Strings.tr("Localizable", "features.interactiveLogin", fallback: "Interactive Login")
/// %@
public static func onDemand(_ p1: Any) -> String {
return Strings.tr("Localizable", "features.onDemand", String(describing: p1), fallback: "%@")
}
/// On-Demand Rules
public static let onDemand = Strings.tr("Localizable", "features.onDemand", fallback: "On-Demand Rules")
/// All Providers
public static let providers = Strings.tr("Localizable", "features.providers", fallback: "All Providers")
/// %@
/// Custom %@
public static func routing(_ p1: Any) -> String {
return Strings.tr("Localizable", "features.routing", String(describing: p1), fallback: "%@")
return Strings.tr("Localizable", "features.routing", String(describing: p1), fallback: "Custom %@")
}
/// %@
public static func sharing(_ p1: Any) -> String {
@ -436,8 +432,6 @@ public enum Strings {
public static let mobile = Strings.tr("Localizable", "modules.on_demand.mobile", fallback: "Mobile")
/// Policy
public static let policy = Strings.tr("Localizable", "modules.on_demand.policy", fallback: "Policy")
/// Add on-demand rules
public static let purchase = Strings.tr("Localizable", "modules.on_demand.purchase", fallback: "Add on-demand rules")
public enum Policy {
/// Activate the VPN %@.
public static func footer(_ p1: Any) -> String {
@ -494,8 +488,6 @@ public enum Strings {
public enum Interactive {
/// On-demand will be disabled.
public static let footer = Strings.tr("Localizable", "modules.openvpn.credentials.interactive.footer", fallback: "On-demand will be disabled.")
/// Log in interactively
public static let purchase = Strings.tr("Localizable", "modules.openvpn.credentials.interactive.purchase", fallback: "Log in interactively")
}
public enum OtpMethod {
public enum Approach {
@ -533,8 +525,14 @@ public enum Strings {
}
public enum Sections {
public enum Features {
/// Subscribe for
public static let header = Strings.tr("Localizable", "paywall.sections.features.header", fallback: "Subscribe for")
public enum Other {
/// Also included
public static let header = Strings.tr("Localizable", "paywall.sections.features.other.header", fallback: "Also included")
}
public enum Required {
/// Required features
public static let header = Strings.tr("Localizable", "paywall.sections.features.required.header", fallback: "Required features")
}
}
public enum OneTime {
/// One-time purchase
@ -587,10 +585,6 @@ public enum Strings {
/// Loading...
public static let loading = Strings.tr("Localizable", "providers.last_updated.loading", fallback: "Loading...")
}
public enum Picker {
/// Add more providers
public static let purchase = Strings.tr("Localizable", "providers.picker.purchase", fallback: "Add more providers")
}
public enum Vpn {
/// No servers
public static let noServers = Strings.tr("Localizable", "providers.vpn.no_servers", fallback: "No servers")
@ -627,6 +621,16 @@ public enum Strings {
/// Connect to...
public static let connectTo = Strings.tr("Localizable", "ui.profile_context.connect_to", fallback: "Connect to...")
}
public enum PurchaseRequired {
public enum Purchase {
/// Purchase required
public static let help = Strings.tr("Localizable", "ui.purchase_required.purchase.help", fallback: "Purchase required")
}
public enum Restricted {
/// Feature is restricted
public static let help = Strings.tr("Localizable", "ui.purchase_required.restricted.help", fallback: "Feature is restricted")
}
}
}
public enum Views {
public enum About {
@ -754,6 +758,18 @@ public enum Strings {
}
}
public enum Profile {
public enum Alerts {
public enum Purchase {
/// This profile requires paid features to work.
public static let message = Strings.tr("Localizable", "views.profile.alerts.purchase.message", fallback: "This profile requires paid features to work.")
/// Purchase required
public static let title = Strings.tr("Localizable", "views.profile.alerts.purchase.title", fallback: "Purchase required")
public enum Buttons {
/// Save anyway
public static let ok = Strings.tr("Localizable", "views.profile.alerts.purchase.buttons.ok", fallback: "Save anyway")
}
}
}
public enum ModuleList {
public enum Section {
/// Drag modules to rearrange them, as their order determines priority.

View File

@ -132,6 +132,9 @@
"views.profile.rows.add_module" = "Add module";
"views.profile.module_list.section.footer" = "Drag modules to rearrange them, as their order determines priority.";
"views.profile.alerts.purchase.title" = "Purchase required";
"views.profile.alerts.purchase.buttons.ok" = "Save anyway";
"views.profile.alerts.purchase.message" = "This profile requires paid features to work.";
"views.settings.launches_on_login" = "Launch on login";
"views.settings.launches_on_login.footer" = "Open the app in background after login.";
@ -264,33 +267,33 @@
"ui.connection_status.on_demand_suffix" = " (on-demand)";
"ui.profile_context.connect_to" = "Connect to...";
"ui.purchase_required.purchase.help" = "Purchase required";
"ui.purchase_required.restricted.help" = "Feature is restricted";
// MARK: - Paywall
"paywall.sections.one_time.header" = "One-time purchase";
"paywall.sections.recurring.header" = "All features";
"paywall.sections.features.header" = "Subscribe for";
"paywall.sections.one_time.header" = "One-time purchase";
"paywall.sections.features.required.header" = "Required features";
"paywall.sections.features.other.header" = "Also included";
"paywall.sections.restore.header" = "Restore";
"paywall.sections.restore.footer" = "If you bought this app or feature in the past, you can restore your purchases.";
"paywall.rows.restore_purchases" = "Restore purchases";
"paywall.alerts.pending.message" = "The purchase is pending external confirmation. The feature will be credited upon approval.";
"features.appleTV" = "%@";
"features.dns" = "%@";
"features.httpProxy" = "%@";
"features.dns" = "%@ Settings";
"features.httpProxy" = "%@ Settings";
"features.interactiveLogin" = "Interactive Login";
"features.onDemand" = "%@";
"features.onDemand" = "On-Demand Rules";
"features.providers" = "All Providers";
"features.routing" = "%@";
"features.routing" = "Custom %@";
"features.sharing" = "%@";
"modules.general.sections.storage.footer.purchase.tv_beta" = "TV profiles do not work in beta builds.";
"modules.general.sections.storage.footer.purchase.tv_release" = "TV profiles do not work without a purchase.";
"modules.general.rows.shared.purchase" = "Share on iCloud";
"modules.general.rows.apple_tv.purchase" = "Drop TV restriction";
"modules.on_demand.purchase" = "Add on-demand rules";
"modules.openvpn.credentials.interactive.purchase" = "Log in interactively";
"providers.picker.purchase" = "Add more providers";
// MARK: - Alerts
@ -298,13 +301,12 @@
"alerts.import.passphrase.ok" = "Decrypt";
"alerts.iap.restricted.title" = "Restricted";
"alerts.iap.restricted.message" = "The requested feature is unavailable in this build.";
"alerts.iap.restricted.message" = "Some features are unavailable in this build.";
// MARK: - Errors
"errors.app.empty_products" = "Unable to fetch products, please retry later.";
"errors.app.empty_profile_name" = "Profile name is empty.";
"errors.app.ineligible_profile" = "A purchase is required for this profile to work.";
"errors.app.malformed_module" = "Module %@ is malformed. %@";
"errors.app.provider.required" = "No provider selected.";
"errors.app.default" = "Unable to complete operation.";

View File

@ -92,7 +92,7 @@ extension ThemeRowWithFooterModifier {
footer.map {
Text($0)
.foregroundStyle(.secondary)
.font(.caption)
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: .leading)
}
}

View File

@ -69,6 +69,8 @@ extension Theme {
case tunnelUninstall
case tvOff
case tvOn
case upgrade
case warning
}
}
@ -118,6 +120,8 @@ extension Theme.ImageName {
case .tunnelUninstall: return "arrow.uturn.down"
case .tvOff: return "tv.slash"
case .tvOn: return "tv"
case .upgrade: return "arrow.up.circle"
case .warning: return "exclamationmark.triangle"
}
}
}

View File

@ -62,6 +62,8 @@ public final class Theme: ObservableObject {
public internal(set) var errorColor: Color = .red
public internal(set) var upgradeColor: Color = .orange
public internal(set) var logoImage = "Logo"
public internal(set) var modalSize: (ThemeModalSize) -> CGSize = {

View File

@ -46,6 +46,34 @@ public final class UILibrary: UILibraryConfiguring {
parameters: Constants.shared.log,
logsPrivateData: UserDefaults.appGroup.bool(forKey: AppPreference.logsPrivateData.key)
)
assertMissingImplementations(with: context.registry)
uiConfiguring?.configure(with: context)
}
}
private extension UILibrary {
func assertMissingImplementations(with registry: Registry) {
ModuleType.allCases.forEach { moduleType in
let builder = moduleType.newModule(with: registry)
do {
// ModuleBuilder -> Module
let module = try builder.tryBuild()
// Module -> ModuleBuilder
guard let moduleBuilder = module.moduleBuilder() else {
fatalError("\(moduleType): does not produce a ModuleBuilder")
}
// AppFeatureRequiring
guard builder is any AppFeatureRequiring else {
fatalError("\(moduleType): #1 is not AppFeatureRequiring")
}
guard moduleBuilder is any AppFeatureRequiring else {
fatalError("\(moduleType): #2 is not AppFeatureRequiring")
}
} catch {
fatalError("\(moduleType): empty module is not buildable: \(error)")
}
}
}
}

View File

@ -73,15 +73,9 @@ public struct OpenVPNCredentialsView: View {
public var body: some View {
Group {
restrictedArea
.modifier(PurchaseButtonModifier(
Strings.Modules.Openvpn.Credentials.Interactive.purchase,
feature: .interactiveLogin,
suggesting: nil,
showsIfRestricted: false,
paywallReason: $paywallReason
))
if !isAuthenticating {
interactiveSection
}
inputSection
}
.themeManualInput()
@ -117,20 +111,22 @@ private extension OpenVPNCredentialsView {
iapManager.isEligible(for: .interactiveLogin)
}
var requiredFeatures: Set<AppFeature>? {
isInteractive ? [.interactiveLogin] : nil
}
var otpMethods: [OpenVPN.Credentials.OTPMethod] {
[.none, .append, .encode]
}
@ViewBuilder
var restrictedArea: some View {
if !isAuthenticating {
interactiveSection
}
}
var interactiveSection: some View {
Group {
Toggle(Strings.Modules.Openvpn.Credentials.interactive, isOn: $isInteractive)
Toggle(isOn: $isInteractive) {
HStack {
Text(Strings.Modules.Openvpn.Credentials.interactive)
PurchaseRequiredButton(features: requiredFeatures, paywallReason: $paywallReason)
}
}
.themeRow(footer: interactiveFooter)
if isInteractive {

View File

@ -0,0 +1,62 @@
//
// FeatureListView.swift
// Passepartout
//
// Created by Davide De Rosa on 11/18/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
enum FeatureListViewStyle {
case list
#if !os(tvOS)
case table
#endif
}
struct FeatureListView<Content>: View where Content: View {
let style: FeatureListViewStyle
let header: String
let features: [AppFeature]
let content: (AppFeature) -> Content
var body: some View {
switch style {
case .list:
ForEach(features.sorted(), id: \.id, content: content)
.themeSection(header: header)
#if !os(tvOS)
case .table:
Table(features.sorted()) {
TableColumn("", content: content)
}
.withoutColumnHeaders()
.themeSection(header: header)
#endif
}
}
}

View File

@ -36,7 +36,7 @@ struct PaywallView: View {
@Binding
var isPresented: Bool
let feature: AppFeature
let features: Set<AppFeature>
let suggestedProduct: AppProduct?
@ -68,7 +68,7 @@ struct PaywallView: View {
actions: pendingActions,
message: pendingMessage
)
.task(id: feature) {
.task(id: features) {
await fetchAvailableProducts()
}
.withErrorHandler(errorHandler)
@ -82,17 +82,18 @@ private extension PaywallView {
var paywallView: some View {
Form {
requiredFeaturesView
productsView
subscriptionFeaturesView
otherFeaturesView
restoreView
}
.themeForm()
.disabled(purchasingIdentifier != nil)
}
var subscriptionFeatures: [AppFeature] {
AppFeature.allCases.sorted {
$0.localizedDescription.lowercased() < $1.localizedDescription.lowercased()
var otherFeatures: [AppFeature] {
AppFeature.allCases.filter {
!features.contains($0)
}
}
@ -122,20 +123,34 @@ private extension PaywallView {
.themeSection(header: Strings.Paywall.Sections.Recurring.header)
}
var requiredFeaturesView: some View {
FeatureListView(
style: .list,
header: Strings.Paywall.Sections.Features.Required.header,
features: Array(features)
) {
Text($0.localizedDescription)
.fontWeight(.bold)
}
}
var otherFeaturesView: some View {
FeatureListView(
style: otherFeaturesStyle,
header: Strings.Paywall.Sections.Features.Other.header,
features: otherFeatures
) {
Text($0.localizedDescription)
}
}
var otherFeaturesStyle: FeatureListViewStyle {
#if os(iOS) || os(tvOS)
var subscriptionFeaturesView: some View {
ForEach(subscriptionFeatures, id: \.id) { feature in
Text(feature.localizedDescription)
}
.themeSection(header: Strings.Paywall.Sections.Features.header)
}
.list
#else
var subscriptionFeaturesView: some View {
Table(subscriptionFeatures) {
TableColumn(Strings.Paywall.Sections.Features.header, value: \.localizedDescription)
}
}
.table
#endif
}
var restoreView: some View {
RestorePurchasesButton(errorHandler: errorHandler)
@ -240,7 +255,7 @@ private extension PaywallView {
#Preview {
PaywallView(
isPresented: .constant(true),
feature: .appleTV,
features: [.appleTV],
suggestedProduct: .Features.appleTV
)
.withMockEnvironment()

View File

@ -27,7 +27,7 @@ import CommonUtils
import StoreKit
import SwiftUI
@available(iOS 17, macOS 14, *)
@available(iOS 17, macOS 14, tvOS 17, *)
struct StoreKitProductView: View {
let style: PaywallProductViewStyle
@ -42,7 +42,7 @@ struct StoreKitProductView: View {
var body: some View {
ProductView(id: product.productIdentifier)
.productViewStyle(style.toStoreKitStyle)
.withPaywallStyle(style)
.onInAppPurchaseStart { _ in
purchasingIdentifier = product.productIdentifier
}
@ -58,13 +58,22 @@ struct StoreKitProductView: View {
}
}
@available(iOS 17, macOS 14, *)
private extension PaywallProductViewStyle {
var toStoreKitStyle: some ProductViewStyle {
switch self {
case .oneTime, .recurring, .donation:
return .compact
@available(iOS 17, macOS 14, tvOS 17, *)
private extension ProductView {
@ViewBuilder
func withPaywallStyle(_ paywallStyle: PaywallProductViewStyle) -> some View {
#if !os(tvOS)
switch paywallStyle {
case .recurring:
productViewStyle(.compact)
case .oneTime, .donation:
productViewStyle(.compact)
}
#else
productViewStyle(.compact)
#endif
}
}

View File

@ -23,6 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import CommonUtils
import PassepartoutKit
import SwiftUI
@ -34,6 +35,9 @@ public struct InteractiveCoordinator: View {
case inline(withCancel: Bool)
}
@EnvironmentObject
private var iapManager: IAPManager
private let style: Style
@ObservedObject

View File

@ -28,6 +28,9 @@ import SwiftUI
public struct PaywallModifier: ViewModifier {
@EnvironmentObject
private var iapManager: IAPManager
@Binding
private var reason: PaywallReason?
@ -53,26 +56,27 @@ public struct PaywallModifier: ViewModifier {
}
},
message: {
Text(Strings.Alerts.Iap.Restricted.message)
Text(restrictedMessage)
}
)
.themeModal(item: $paywallArguments) { args in
NavigationStack {
PaywallView(
isPresented: isPresentingPurchase,
feature: args.feature,
features: iapManager.excludingEligible(from: args.features),
suggestedProduct: args.product
)
}
.frame(idealHeight: 400)
.frame(idealHeight: 500)
}
.onChange(of: reason) {
switch $0 {
case .restricted:
case .purchase(let features, let product):
guard !iapManager.isRestricted else {
isPresentingRestricted = true
case .purchase(let feature, let product):
paywallArguments = PaywallArguments(feature: feature, product: product)
return
}
paywallArguments = PaywallArguments(features: features, product: product)
default:
break
@ -93,14 +97,34 @@ private extension PaywallModifier {
}
}
}
var restrictedMessage: String {
guard case .purchase(let features, _) = reason else {
return ""
}
let msg = Strings.Alerts.Iap.Restricted.message
return msg + "\n\n" + iapManager
.excludingEligible(from: features)
.map(\.localizedDescription)
.sorted()
.joined(separator: "\n")
}
}
private struct PaywallArguments: Identifiable {
let feature: AppFeature
let features: Set<AppFeature>
let product: AppProduct?
var id: String {
feature.id
var id: [String] {
features.map(\.id)
}
}
private extension IAPManager {
func excludingEligible(from features: Set<AppFeature>) -> Set<AppFeature> {
features.filter {
!isEligible(for: $0)
}
}
}

View File

@ -61,16 +61,11 @@ public struct PurchaseButtonModifier: ViewModifier {
}
public func body(content: Content) -> some View {
switch iapManager.paywallReason(forFeature: feature, suggesting: suggestedProduct) {
case .purchase:
purchaseView
case .restricted:
if showsIfRestricted {
if iapManager.isEligible(for: feature) {
content
}
default:
} else if !iapManager.isRestricted {
purchaseView
} else if showsIfRestricted {
content
}
}
@ -89,7 +84,7 @@ private extension PurchaseButtonModifier {
var purchaseButton: some View {
Button(title) {
paywallReason = .purchase(feature, suggestedProduct)
paywallReason = .purchase([feature], suggestedProduct)
}
}
}

View File

@ -0,0 +1,87 @@
//
// PurchaseRequiredButton.swift
// Passepartout
//
// Created by Davide De Rosa on 11/17/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 PassepartoutKit
import SwiftUI
public struct PurchaseRequiredButton: View {
@EnvironmentObject
private var theme: Theme
@EnvironmentObject
private var iapManager: IAPManager
private let features: Set<AppFeature>?
@Binding
private var paywallReason: PaywallReason?
public init(for requiring: AppFeatureRequiring?, paywallReason: Binding<PaywallReason?>) {
features = requiring?.features
_paywallReason = paywallReason
}
public init(features: Set<AppFeature>?, paywallReason: Binding<PaywallReason?>) {
self.features = features
_paywallReason = paywallReason
}
public var body: some View {
Button {
guard let features, !isEligible else {
return
}
setLater(.purchase(features)) {
paywallReason = $0
}
} label: {
ThemeImage(iapManager.isRestricted ? .warning : .upgrade)
.imageScale(.large)
.help(helpMessage)
}
#if os(iOS)
.buttonStyle(.plain)
#else
.buttonStyle(.borderless)
#endif
.foregroundStyle(theme.upgradeColor)
.opaque(!isEligible)
}
}
private extension PurchaseRequiredButton {
var isEligible: Bool {
if let features {
return iapManager.isEligible(for: features)
}
return true
}
var helpMessage: String {
iapManager.isRestricted ? Strings.Ui.PurchaseRequired.Restricted.help : Strings.Ui.PurchaseRequired.Purchase.help
}
}

View File

@ -48,7 +48,9 @@ public struct TunnelToggleButton<Label>: View, ThemeProviding where Label: View
private let errorHandler: ErrorHandler
private let onProviderEntityRequired: ((Profile) -> Void)?
private let onProviderEntityRequired: (Profile) -> Void
private let onPurchaseRequired: (Set<AppFeature>) -> Void
private let label: (Bool) -> Label
@ -58,7 +60,8 @@ public struct TunnelToggleButton<Label>: View, ThemeProviding where Label: View
nextProfileId: Binding<Profile.ID?>,
interactiveManager: InteractiveManager,
errorHandler: ErrorHandler,
onProviderEntityRequired: ((Profile) -> Void)? = nil,
onProviderEntityRequired: @escaping (Profile) -> Void,
onPurchaseRequired: @escaping (Set<AppFeature>) -> Void,
label: @escaping (Bool) -> Label
) {
self.tunnel = tunnel
@ -67,6 +70,7 @@ public struct TunnelToggleButton<Label>: View, ThemeProviding where Label: View
self.interactiveManager = interactiveManager
self.errorHandler = errorHandler
self.onProviderEntityRequired = onProviderEntityRequired
self.onPurchaseRequired = onPurchaseRequired
self.label = label
}
@ -134,12 +138,14 @@ private extension TunnelToggleButton {
} else {
try await tunnel.connect(with: profile)
}
} catch AppError.ineligibleProfile(let requiredFeatures) {
onPurchaseRequired(requiredFeatures)
} catch is CancellationError {
//
} catch {
switch (error as? PassepartoutError)?.code {
case .missingProviderEntity:
onProviderEntityRequired?(profile)
onProviderEntityRequired(profile)
return
case .providerRequired:

View File

@ -44,27 +44,13 @@ extension IAPManager {
isIncluded: {
Configuration.ProfileManager.isIncluded($0, $1)
},
willSave: {
$1
willSave: { _, builder in
builder
},
willConnect: { iap, profile in
var builder = profile.builder()
// ineligible, suppress on-demand rules
if !iap.isEligible(for: .onDemand) {
pp_log(.App.iap, .notice, "Ineligible, suppress on-demand rules")
if let onDemandModuleIndex = builder.modules.firstIndex(where: { $0 is OnDemandModule }),
let onDemandModule = builder.modules[onDemandModuleIndex] as? OnDemandModule {
var onDemandBuilder = onDemandModule.builder()
onDemandBuilder.policy = .any
builder.modules[onDemandModuleIndex] = onDemandBuilder.tryBuild()
}
}
try iap.verify(profile.activeModules)
// validate provider modules
let profile = try builder.tryBuild()
do {
_ = try profile.withProviderModules()
return profile

View File

@ -84,30 +84,14 @@ private extension PacketTunnelProvider {
.sharedForTunnel
}
var isEligibleForPlatform: Bool {
#if os(tvOS)
iapManager.isEligible(for: .appleTV)
#else
true
#endif
}
func isEligibleForProviders(_ profile: Profile) -> Bool {
profile.firstProviderModuleWithMetadata == nil || iapManager.isEligible(for: .providers)
}
func checkEligibility(of profile: Profile, environment: TunnelEnvironment) async throws {
await iapManager.reloadReceipt()
guard isEligibleForPlatform else {
do {
try iapManager.verify(profile.activeModules)
} catch {
let error = PassepartoutError(.App.ineligibleProfile)
environment.setEnvironmentValue(error.code, forKey: TunnelEnvironmentKeys.lastErrorCode)
pp_log(.app, .fault, "Profile is ineligible for this platform")
throw error
}
guard isEligibleForProviders(profile) else {
let error = PassepartoutError(.App.ineligibleProfile)
environment.setEnvironmentValue(error.code, forKey: TunnelEnvironmentKeys.lastErrorCode)
pp_log(.app, .fault, "Profile is ineligible for providers")
pp_log(.app, .fault, "Profile \(profile.id) requires a purchase to work, shutting down")
throw error
}
}