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", "kind" : "remoteSourceControl",
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source", "location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
"state" : { "state" : {
"revision" : "c0a615bc7a85d68a9b00d3703d0dae6efab9bdd2" "revision" : "db02de5247d0231ff06fb3c4d166645a434255be"
} }
}, },
{ {

View File

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

View File

@ -23,6 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>. // along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
// //
import CommonLibrary
import Foundation import Foundation
import PassepartoutKit import PassepartoutKit
@_exported import UILibrary @_exported import UILibrary
@ -42,19 +43,23 @@ private extension AppUIMain {
.openVPN .openVPN
] ]
ModuleType.allCases.forEach { moduleType in ModuleType.allCases.forEach { moduleType in
do {
let builder = moduleType.newModule(with: registry) let builder = moduleType.newModule(with: registry)
let module = try builder.tryBuild()
// ModuleViewProviding
guard builder is any ModuleViewProviding else { guard builder is any ModuleViewProviding else {
fatalError("\(moduleType): is not ModuleViewProviding") fatalError("\(moduleType): is not ModuleViewProviding")
} }
// ProviderEntityViewProviding
if providerModuleTypes.contains(moduleType) { if providerModuleTypes.contains(moduleType) {
do {
let module = try builder.tryBuild()
guard module is any ProviderEntityViewProviding else { guard module is any ProviderEntityViewProviding else {
fatalError("\(moduleType): is not ProviderEntityViewProviding") 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 @State
private var migrationPath = NavigationPath() private var migrationPath = NavigationPath()
@State
private var paywallReason: PaywallReason?
@StateObject @StateObject
private var errorHandler: ErrorHandler = .default() private var errorHandler: ErrorHandler = .default()
@ -72,6 +75,7 @@ public struct AppCoordinator: View, AppCoordinatorConforming {
contentView contentView
.toolbar(content: toolbarContent) .toolbar(content: toolbarContent)
} }
.modifier(PaywallModifier(reason: $paywallReason))
.themeModal( .themeModal(
item: $modalRoute, item: $modalRoute,
size: modalRoute?.size ?? .large, size: modalRoute?.size ?? .large,
@ -87,6 +91,8 @@ public struct AppCoordinator: View, AppCoordinatorConforming {
extension AppCoordinator { extension AppCoordinator {
enum ModalRoute: Identifiable { enum ModalRoute: Identifiable {
case about
case editProfile case editProfile
case editProviderEntity(Profile, Module, SerializedProvider) case editProviderEntity(Profile, Module, SerializedProvider)
@ -95,15 +101,13 @@ extension AppCoordinator {
case settings case settings
case about
var id: Int { var id: Int {
switch self { switch self {
case .editProfile: return 1 case .about: return 1
case .editProviderEntity: return 2 case .editProfile: return 2
case .migrateProfiles: return 3 case .editProviderEntity: return 3
case .settings: return 4 case .migrateProfiles: return 4
case .about: return 5 case .settings: return 5
} }
} }
@ -171,6 +175,11 @@ extension AppCoordinator {
}, },
onMigrateProfiles: { onMigrateProfiles: {
modalRoute = .migrateProfiles modalRoute = .migrateProfiles
},
onPurchaseRequired: { features in
setLater(.purchase(features)) {
paywallReason = $0
}
} }
) )
) )
@ -197,6 +206,12 @@ extension AppCoordinator {
@ViewBuilder @ViewBuilder
func modalDestination(for item: ModalRoute?) -> some View { func modalDestination(for item: ModalRoute?) -> some View {
switch item { switch item {
case .about:
AboutRouterView(
profileManager: profileManager,
tunnel: tunnel
)
case .editProfile: case .editProfile:
ProfileCoordinator( ProfileCoordinator(
profileManager: profileManager, profileManager: profileManager,
@ -205,9 +220,7 @@ extension AppCoordinator {
moduleViewFactory: DefaultModuleViewFactory(registry: registry), moduleViewFactory: DefaultModuleViewFactory(registry: registry),
modally: true, modally: true,
path: $profilePath, path: $profilePath,
onDismiss: { onDismiss: onDismiss
present(nil)
}
) )
case .editProviderEntity(let profile, let module, let provider): case .editProviderEntity(let profile, let module, let provider):
@ -230,12 +243,6 @@ extension AppCoordinator {
case .settings: case .settings:
SettingsView(profileManager: profileManager) SettingsView(profileManager: profileManager)
case .about:
AboutRouterView(
profileManager: profileManager,
tunnel: tunnel
)
default: default:
EmptyView() EmptyView()
} }
@ -256,11 +263,15 @@ extension AppCoordinator {
present(.editProfile) present(.editProfile)
} }
func onDismiss() {
present(nil)
}
func present(_ route: ModalRoute?) { func present(_ route: ModalRoute?) {
// XXX: this is a workaround for #791 on iOS 16 // XXX: this is a workaround for #791 on iOS 16
Task { setLater(route) {
try await Task.sleep(for: .milliseconds(50)) modalRoute = $0
modalRoute = route
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -58,6 +58,12 @@ struct ProfileCoordinator: View {
let onDismiss: () -> Void let onDismiss: () -> Void
@State
private var requiresPurchase = false
@State
private var requiredFeatures: Set<AppFeature> = []
@State @State
private var paywallReason: PaywallReason? private var paywallReason: PaywallReason?
@ -67,6 +73,15 @@ struct ProfileCoordinator: View {
var body: some View { var body: some View {
contentView contentView
.modifier(PaywallModifier(reason: $paywallReason)) .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) .withErrorHandler(errorHandler)
} }
} }
@ -80,6 +95,7 @@ private extension ProfileCoordinator {
profileEditor: profileEditor, profileEditor: profileEditor,
moduleViewFactory: moduleViewFactory, moduleViewFactory: moduleViewFactory,
path: $path, path: $path,
paywallReason: $paywallReason,
flow: .init( flow: .init(
onNewModule: onNewModule, onNewModule: onNewModule,
onCommitEditing: onCommitEditing, onCommitEditing: onCommitEditing,
@ -92,6 +108,7 @@ private extension ProfileCoordinator {
ProfileSplitView( ProfileSplitView(
profileEditor: profileEditor, profileEditor: profileEditor,
moduleViewFactory: moduleViewFactory, moduleViewFactory: moduleViewFactory,
paywallReason: $paywallReason,
flow: .init( flow: .init(
onNewModule: onNewModule, onNewModule: onNewModule,
onCommitEditing: onCommitEditing, onCommitEditing: onCommitEditing,
@ -100,33 +117,17 @@ private extension ProfileCoordinator {
) )
#endif #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 { private extension ProfileCoordinator {
func onNewModule(_ moduleType: ModuleType) { 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) let module = moduleType.newModule(with: registry)
withAnimation(theme.animation(for: .modules)) { withAnimation(theme.animation(for: .modules)) {
profileEditor.saveModule(module, activating: true) profileEditor.saveModule(module, activating: true)
@ -135,14 +136,42 @@ private extension ProfileCoordinator {
func onCommitEditing() async throws { func onCommitEditing() async throws {
do { do {
try await profileEditor.save(to: profileManager) if !iapManager.isRestricted {
onDismiss() try await onCommitEditingStandard()
} else {
try await onCommitEditingRestricted()
}
} catch { } catch {
errorHandler.handle(error, title: Strings.Global.save) errorHandler.handle(error, title: Strings.Global.save)
throw error 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() { func onCancelEditing() {
onDismiss() onDismiss()
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -33,8 +33,8 @@ struct ProfileGeneralView: 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 {
Form { Form {
@ -48,14 +48,14 @@ struct ProfileGeneralView: View {
) )
UUIDSection(uuid: profileEditor.profile.id) UUIDSection(uuid: profileEditor.profile.id)
} }
.modifier(PaywallModifier(reason: $paywallReason))
.themeForm() .themeForm()
} }
} }
#Preview { #Preview {
ProfileGeneralView( ProfileGeneralView(
profileEditor: ProfileEditor() profileEditor: ProfileEditor(),
paywallReason: .constant(nil)
) )
.withMockEnvironment() .withMockEnvironment()
} }

View File

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

View File

@ -30,9 +30,6 @@ import SwiftUI
struct ProviderContentModifier<Entity, ProviderRows>: ViewModifier where Entity: ProviderEntity, Entity.Configuration: ProviderConfigurationIdentifiable & Codable, ProviderRows: View { struct ProviderContentModifier<Entity, ProviderRows>: ViewModifier where Entity: ProviderEntity, Entity.Configuration: ProviderConfigurationIdentifiable & Codable, ProviderRows: View {
@EnvironmentObject
private var iapManager: IAPManager
@EnvironmentObject @EnvironmentObject
private var providerManager: ProviderManager private var providerManager: ProviderManager
@ -77,13 +74,11 @@ private extension ProviderContentModifier {
#if os(iOS) #if os(iOS)
@ViewBuilder @ViewBuilder
var providerView: some View { var providerView: some View {
Group {
providerPicker providerPicker
purchaseButton
}
.themeSection() .themeSection()
Group {
if providerId != nil { if providerId != nil {
Group {
providerRows providerRows
refreshButton { refreshButton {
HStack { HStack {
@ -95,18 +90,17 @@ private extension ProviderContentModifier {
} }
} }
} }
}
.themeSection(footer: lastUpdatedString) .themeSection(footer: lastUpdatedString)
} }
}
#else #else
@ViewBuilder @ViewBuilder
var providerView: some View { var providerView: some View {
Section { Section {
providerPicker providerPicker
purchaseButton
} }
Section {
if providerId != nil { if providerId != nil {
Section {
providerRows providerRows
HStack { HStack {
lastUpdatedString.map { lastUpdatedString.map {
@ -128,19 +122,9 @@ private extension ProviderContentModifier {
providers: supportedProviders, providers: supportedProviders,
providerId: $providerId, providerId: $providerId,
isRequired: true, isRequired: true,
isLoading: providerManager.isLoading isLoading: providerManager.isLoading,
)
}
var purchaseButton: some View {
EmptyView()
.modifier(PurchaseButtonModifier(
Strings.Providers.Picker.purchase,
feature: .providers,
suggesting: nil,
showsIfRestricted: true,
paywallReason: $paywallReason paywallReason: $paywallReason
)) )
} }
func refreshButton<Label>(label: () -> Label) -> some View where Label: View { func refreshButton<Label>(label: () -> Label) -> some View where Label: View {
@ -151,7 +135,7 @@ private extension ProviderContentModifier {
providerManager providerManager
.providers .providers
.filter { .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/>. // along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
// //
import CommonLibrary
import PassepartoutKit import PassepartoutKit
import SwiftUI import SwiftUI
@ -36,8 +37,11 @@ struct ProviderPicker: View {
let isLoading: Bool let isLoading: Bool
@Binding
var paywallReason: PaywallReason?
var body: some View { var body: some View {
Picker(Strings.Global.provider, selection: $providerId) { Picker(selection: $providerId) {
if !providers.isEmpty { if !providers.isEmpty {
Text(isRequired ? Strings.Providers.selectProvider : Strings.Providers.noProvider) Text(isRequired ? Strings.Providers.selectProvider : Strings.Providers.noProvider)
.tag(nil as ProviderID?) .tag(nil as ProviderID?)
@ -49,6 +53,11 @@ struct ProviderPicker: View {
Text(isLoading ? Strings.Global.loading : Strings.Global.none) Text(isLoading ? Strings.Global.loading : Strings.Global.none)
.tag(providerId) // tag always exists .tag(providerId) // tag always exists
} }
} label: {
HStack {
Text(Strings.Global.provider)
PurchaseRequiredButton(for: providerId, paywallReason: $paywallReason)
}
} }
.disabled(isLoading || providers.isEmpty) .disabled(isLoading || providers.isEmpty)
} }

View File

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

View File

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

View File

@ -449,7 +449,7 @@ private extension ProfileManager {
} }
for remoteProfile in profiles { for remoteProfile in profiles {
do { 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)") pp_log(.App.profiles, .info, "Will delete non-included remote profile \(remoteProfile.id)")
idsToRemove.append(remoteProfile.id) idsToRemove.append(remoteProfile.id)
continue continue

View File

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

View File

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

View File

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

View File

@ -49,7 +49,9 @@ extension ModuleType {
return IPModule.Builder() return IPModule.Builder()
case .onDemand: case .onDemand:
return OnDemandModule.Builder() var builder = OnDemandModule.Builder()
builder.policy = .any
return builder
default: default:
fatalError("Unknown module type: \(rawValue)") 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) 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) features.allSatisfy(eligibleFeatures.contains)
} }
@ -143,13 +143,6 @@ extension IAPManager {
#endif #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 { public func isPayingUser() -> Bool {
!purchasedProducts.isEmpty !purchasedProducts.isEmpty
} }

View File

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

View File

@ -49,6 +49,14 @@ extension View {
$0.animation = nil $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 { 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/>. // along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
// //
import CommonLibrary
import Foundation import Foundation
import PassepartoutKit import PassepartoutKit

View File

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

View File

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

View File

@ -44,7 +44,7 @@ extension AppFeature: LocalizableEntity {
return V.interactiveLogin return V.interactiveLogin
case .onDemand: case .onDemand:
return V.onDemand(Strings.Global.onDemand) return V.onDemand
case .providers: case .providers:
return V.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/>. // along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
// //
import CommonLibrary
import Foundation import Foundation
import PassepartoutKit import PassepartoutKit

View File

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

View File

@ -13,8 +13,8 @@ public enum Strings {
public enum Alerts { public enum Alerts {
public enum Iap { public enum Iap {
public enum Restricted { public enum Restricted {
/// 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: "The requested feature is unavailable in this build.") public static let message = Strings.tr("Localizable", "alerts.iap.restricted.message", fallback: "Some features are unavailable in this build.")
/// Restricted /// Restricted
public static let title = Strings.tr("Localizable", "alerts.iap.restricted.title", fallback: "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.") public static let emptyProducts = Strings.tr("Localizable", "errors.app.empty_products", fallback: "Unable to fetch products, please retry later.")
/// 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.")
/// 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. %@ /// 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. %@")
@ -179,25 +177,23 @@ public enum Strings {
public static func appleTV(_ p1: Any) -> String { public static func appleTV(_ p1: Any) -> String {
return Strings.tr("Localizable", "features.appleTV", String(describing: p1), fallback: "%@") return Strings.tr("Localizable", "features.appleTV", String(describing: p1), fallback: "%@")
} }
/// %@ /// %@ Settings
public static func dns(_ p1: Any) -> String { 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 { 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 /// Interactive Login
public static let interactiveLogin = Strings.tr("Localizable", "features.interactiveLogin", fallback: "Interactive Login") public static let interactiveLogin = Strings.tr("Localizable", "features.interactiveLogin", fallback: "Interactive Login")
/// %@ /// On-Demand Rules
public static func onDemand(_ p1: Any) -> String { public static let onDemand = Strings.tr("Localizable", "features.onDemand", fallback: "On-Demand Rules")
return Strings.tr("Localizable", "features.onDemand", String(describing: p1), fallback: "%@")
}
/// All Providers /// All Providers
public static let providers = Strings.tr("Localizable", "features.providers", fallback: "All Providers") public static let providers = Strings.tr("Localizable", "features.providers", fallback: "All Providers")
/// %@ /// Custom %@
public static func routing(_ p1: Any) -> String { 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 { 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") public static let mobile = Strings.tr("Localizable", "modules.on_demand.mobile", fallback: "Mobile")
/// Policy /// Policy
public static let policy = Strings.tr("Localizable", "modules.on_demand.policy", fallback: "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 { public enum Policy {
/// Activate the VPN %@. /// Activate the VPN %@.
public static func footer(_ p1: Any) -> String { public static func footer(_ p1: Any) -> String {
@ -494,8 +488,6 @@ public enum Strings {
public enum Interactive { public enum Interactive {
/// On-demand will be disabled. /// On-demand will be disabled.
public static let footer = Strings.tr("Localizable", "modules.openvpn.credentials.interactive.footer", fallback: "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 OtpMethod {
public enum Approach { public enum Approach {
@ -533,8 +525,14 @@ public enum Strings {
} }
public enum Sections { public enum Sections {
public enum Features { public enum Features {
/// Subscribe for public enum Other {
public static let header = Strings.tr("Localizable", "paywall.sections.features.header", fallback: "Subscribe for") /// 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 { public enum OneTime {
/// One-time purchase /// One-time purchase
@ -587,10 +585,6 @@ public enum Strings {
/// Loading... /// Loading...
public static let loading = Strings.tr("Localizable", "providers.last_updated.loading", fallback: "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 { public enum Vpn {
/// No servers /// No servers
public static let noServers = Strings.tr("Localizable", "providers.vpn.no_servers", fallback: "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... /// Connect to...
public static let connectTo = Strings.tr("Localizable", "ui.profile_context.connect_to", fallback: "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 Views {
public enum About { public enum About {
@ -754,6 +758,18 @@ public enum Strings {
} }
} }
public enum Profile { 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 ModuleList {
public enum Section { public enum Section {
/// Drag modules to rearrange them, as their order determines priority. /// 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.rows.add_module" = "Add module";
"views.profile.module_list.section.footer" = "Drag modules to rearrange them, as their order determines priority."; "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" = "Launch on login";
"views.settings.launches_on_login.footer" = "Open the app in background after 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.connection_status.on_demand_suffix" = " (on-demand)";
"ui.profile_context.connect_to" = "Connect to..."; "ui.profile_context.connect_to" = "Connect to...";
"ui.purchase_required.purchase.help" = "Purchase required";
"ui.purchase_required.restricted.help" = "Feature is restricted";
// MARK: - Paywall // MARK: - Paywall
"paywall.sections.one_time.header" = "One-time purchase";
"paywall.sections.recurring.header" = "All features"; "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.header" = "Restore";
"paywall.sections.restore.footer" = "If you bought this app or feature in the past, you can restore your purchases."; "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.rows.restore_purchases" = "Restore purchases";
"paywall.alerts.pending.message" = "The purchase is pending external confirmation. The feature will be credited upon approval."; "paywall.alerts.pending.message" = "The purchase is pending external confirmation. The feature will be credited upon approval.";
"features.appleTV" = "%@"; "features.appleTV" = "%@";
"features.dns" = "%@"; "features.dns" = "%@ Settings";
"features.httpProxy" = "%@"; "features.httpProxy" = "%@ Settings";
"features.interactiveLogin" = "Interactive Login"; "features.interactiveLogin" = "Interactive Login";
"features.onDemand" = "%@"; "features.onDemand" = "On-Demand Rules";
"features.providers" = "All Providers"; "features.providers" = "All Providers";
"features.routing" = "%@"; "features.routing" = "Custom %@";
"features.sharing" = "%@"; "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_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.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.shared.purchase" = "Share on iCloud";
"modules.general.rows.apple_tv.purchase" = "Drop TV restriction"; "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 // MARK: - Alerts
@ -298,13 +301,12 @@
"alerts.import.passphrase.ok" = "Decrypt"; "alerts.import.passphrase.ok" = "Decrypt";
"alerts.iap.restricted.title" = "Restricted"; "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 // MARK: - Errors
"errors.app.empty_products" = "Unable to fetch products, please retry later."; "errors.app.empty_products" = "Unable to fetch products, please retry later.";
"errors.app.empty_profile_name" = "Profile name is empty."; "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.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.";

View File

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

View File

@ -69,6 +69,8 @@ extension Theme {
case tunnelUninstall case tunnelUninstall
case tvOff case tvOff
case tvOn case tvOn
case upgrade
case warning
} }
} }
@ -118,6 +120,8 @@ extension Theme.ImageName {
case .tunnelUninstall: return "arrow.uturn.down" case .tunnelUninstall: return "arrow.uturn.down"
case .tvOff: return "tv.slash" case .tvOff: return "tv.slash"
case .tvOn: return "tv" 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 errorColor: Color = .red
public internal(set) var upgradeColor: Color = .orange
public internal(set) var logoImage = "Logo" public internal(set) var logoImage = "Logo"
public internal(set) var modalSize: (ThemeModalSize) -> CGSize = { public internal(set) var modalSize: (ThemeModalSize) -> CGSize = {

View File

@ -46,6 +46,34 @@ public final class UILibrary: UILibraryConfiguring {
parameters: Constants.shared.log, parameters: Constants.shared.log,
logsPrivateData: UserDefaults.appGroup.bool(forKey: AppPreference.logsPrivateData.key) logsPrivateData: UserDefaults.appGroup.bool(forKey: AppPreference.logsPrivateData.key)
) )
assertMissingImplementations(with: context.registry)
uiConfiguring?.configure(with: context) 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 { public var body: some View {
Group { Group {
restrictedArea if !isAuthenticating {
.modifier(PurchaseButtonModifier( interactiveSection
Strings.Modules.Openvpn.Credentials.Interactive.purchase, }
feature: .interactiveLogin,
suggesting: nil,
showsIfRestricted: false,
paywallReason: $paywallReason
))
inputSection inputSection
} }
.themeManualInput() .themeManualInput()
@ -117,20 +111,22 @@ private extension OpenVPNCredentialsView {
iapManager.isEligible(for: .interactiveLogin) iapManager.isEligible(for: .interactiveLogin)
} }
var requiredFeatures: Set<AppFeature>? {
isInteractive ? [.interactiveLogin] : nil
}
var otpMethods: [OpenVPN.Credentials.OTPMethod] { var otpMethods: [OpenVPN.Credentials.OTPMethod] {
[.none, .append, .encode] [.none, .append, .encode]
} }
@ViewBuilder
var restrictedArea: some View {
if !isAuthenticating {
interactiveSection
}
}
var interactiveSection: some View { var interactiveSection: some View {
Group { 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) .themeRow(footer: interactiveFooter)
if isInteractive { 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 @Binding
var isPresented: Bool var isPresented: Bool
let feature: AppFeature let features: Set<AppFeature>
let suggestedProduct: AppProduct? let suggestedProduct: AppProduct?
@ -68,7 +68,7 @@ struct PaywallView: View {
actions: pendingActions, actions: pendingActions,
message: pendingMessage message: pendingMessage
) )
.task(id: feature) { .task(id: features) {
await fetchAvailableProducts() await fetchAvailableProducts()
} }
.withErrorHandler(errorHandler) .withErrorHandler(errorHandler)
@ -82,17 +82,18 @@ private extension PaywallView {
var paywallView: some View { var paywallView: some View {
Form { Form {
requiredFeaturesView
productsView productsView
subscriptionFeaturesView otherFeaturesView
restoreView restoreView
} }
.themeForm() .themeForm()
.disabled(purchasingIdentifier != nil) .disabled(purchasingIdentifier != nil)
} }
var subscriptionFeatures: [AppFeature] { var otherFeatures: [AppFeature] {
AppFeature.allCases.sorted { AppFeature.allCases.filter {
$0.localizedDescription.lowercased() < $1.localizedDescription.lowercased() !features.contains($0)
} }
} }
@ -122,20 +123,34 @@ private extension PaywallView {
.themeSection(header: Strings.Paywall.Sections.Recurring.header) .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) #if os(iOS) || os(tvOS)
var subscriptionFeaturesView: some View { .list
ForEach(subscriptionFeatures, id: \.id) { feature in
Text(feature.localizedDescription)
}
.themeSection(header: Strings.Paywall.Sections.Features.header)
}
#else #else
var subscriptionFeaturesView: some View { .table
Table(subscriptionFeatures) {
TableColumn(Strings.Paywall.Sections.Features.header, value: \.localizedDescription)
}
}
#endif #endif
}
var restoreView: some View { var restoreView: some View {
RestorePurchasesButton(errorHandler: errorHandler) RestorePurchasesButton(errorHandler: errorHandler)
@ -240,7 +255,7 @@ private extension PaywallView {
#Preview { #Preview {
PaywallView( PaywallView(
isPresented: .constant(true), isPresented: .constant(true),
feature: .appleTV, features: [.appleTV],
suggestedProduct: .Features.appleTV suggestedProduct: .Features.appleTV
) )
.withMockEnvironment() .withMockEnvironment()

View File

@ -27,7 +27,7 @@ import CommonUtils
import StoreKit import StoreKit
import SwiftUI import SwiftUI
@available(iOS 17, macOS 14, *) @available(iOS 17, macOS 14, tvOS 17, *)
struct StoreKitProductView: View { struct StoreKitProductView: View {
let style: PaywallProductViewStyle let style: PaywallProductViewStyle
@ -42,7 +42,7 @@ struct StoreKitProductView: View {
var body: some View { var body: some View {
ProductView(id: product.productIdentifier) ProductView(id: product.productIdentifier)
.productViewStyle(style.toStoreKitStyle) .withPaywallStyle(style)
.onInAppPurchaseStart { _ in .onInAppPurchaseStart { _ in
purchasingIdentifier = product.productIdentifier purchasingIdentifier = product.productIdentifier
} }
@ -58,13 +58,22 @@ struct StoreKitProductView: View {
} }
} }
@available(iOS 17, macOS 14, *) @available(iOS 17, macOS 14, tvOS 17, *)
private extension PaywallProductViewStyle { private extension ProductView {
var toStoreKitStyle: some ProductViewStyle {
switch self { @ViewBuilder
case .oneTime, .recurring, .donation: func withPaywallStyle(_ paywallStyle: PaywallProductViewStyle) -> some View {
return .compact #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/>. // along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
// //
import CommonLibrary
import CommonUtils import CommonUtils
import PassepartoutKit import PassepartoutKit
import SwiftUI import SwiftUI
@ -34,6 +35,9 @@ public struct InteractiveCoordinator: View {
case inline(withCancel: Bool) case inline(withCancel: Bool)
} }
@EnvironmentObject
private var iapManager: IAPManager
private let style: Style private let style: Style
@ObservedObject @ObservedObject

View File

@ -28,6 +28,9 @@ import SwiftUI
public struct PaywallModifier: ViewModifier { public struct PaywallModifier: ViewModifier {
@EnvironmentObject
private var iapManager: IAPManager
@Binding @Binding
private var reason: PaywallReason? private var reason: PaywallReason?
@ -53,26 +56,27 @@ public struct PaywallModifier: ViewModifier {
} }
}, },
message: { message: {
Text(Strings.Alerts.Iap.Restricted.message) Text(restrictedMessage)
} }
) )
.themeModal(item: $paywallArguments) { args in .themeModal(item: $paywallArguments) { args in
NavigationStack { NavigationStack {
PaywallView( PaywallView(
isPresented: isPresentingPurchase, isPresented: isPresentingPurchase,
feature: args.feature, features: iapManager.excludingEligible(from: args.features),
suggestedProduct: args.product suggestedProduct: args.product
) )
} }
.frame(idealHeight: 400) .frame(idealHeight: 500)
} }
.onChange(of: reason) { .onChange(of: reason) {
switch $0 { switch $0 {
case .restricted: case .purchase(let features, let product):
guard !iapManager.isRestricted else {
isPresentingRestricted = true isPresentingRestricted = true
return
case .purchase(let feature, let product): }
paywallArguments = PaywallArguments(feature: feature, product: product) paywallArguments = PaywallArguments(features: features, product: product)
default: default:
break 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 { private struct PaywallArguments: Identifiable {
let feature: AppFeature let features: Set<AppFeature>
let product: AppProduct? let product: AppProduct?
var id: String { var id: [String] {
feature.id 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 { public func body(content: Content) -> some View {
switch iapManager.paywallReason(forFeature: feature, suggesting: suggestedProduct) { if iapManager.isEligible(for: feature) {
case .purchase:
purchaseView
case .restricted:
if showsIfRestricted {
content content
} } else if !iapManager.isRestricted {
purchaseView
default: } else if showsIfRestricted {
content content
} }
} }
@ -89,7 +84,7 @@ private extension PurchaseButtonModifier {
var purchaseButton: some View { var purchaseButton: some View {
Button(title) { 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 errorHandler: ErrorHandler
private let onProviderEntityRequired: ((Profile) -> Void)? private let onProviderEntityRequired: (Profile) -> Void
private let onPurchaseRequired: (Set<AppFeature>) -> Void
private let label: (Bool) -> Label private let label: (Bool) -> Label
@ -58,7 +60,8 @@ public struct TunnelToggleButton<Label>: View, ThemeProviding where Label: View
nextProfileId: Binding<Profile.ID?>, nextProfileId: Binding<Profile.ID?>,
interactiveManager: InteractiveManager, interactiveManager: InteractiveManager,
errorHandler: ErrorHandler, errorHandler: ErrorHandler,
onProviderEntityRequired: ((Profile) -> Void)? = nil, onProviderEntityRequired: @escaping (Profile) -> Void,
onPurchaseRequired: @escaping (Set<AppFeature>) -> Void,
label: @escaping (Bool) -> Label label: @escaping (Bool) -> Label
) { ) {
self.tunnel = tunnel self.tunnel = tunnel
@ -67,6 +70,7 @@ public struct TunnelToggleButton<Label>: View, ThemeProviding where Label: View
self.interactiveManager = interactiveManager self.interactiveManager = interactiveManager
self.errorHandler = errorHandler self.errorHandler = errorHandler
self.onProviderEntityRequired = onProviderEntityRequired self.onProviderEntityRequired = onProviderEntityRequired
self.onPurchaseRequired = onPurchaseRequired
self.label = label self.label = label
} }
@ -134,12 +138,14 @@ private extension TunnelToggleButton {
} else { } else {
try await tunnel.connect(with: profile) try await tunnel.connect(with: profile)
} }
} catch AppError.ineligibleProfile(let requiredFeatures) {
onPurchaseRequired(requiredFeatures)
} catch is CancellationError { } catch is CancellationError {
// //
} catch { } catch {
switch (error as? PassepartoutError)?.code { switch (error as? PassepartoutError)?.code {
case .missingProviderEntity: case .missingProviderEntity:
onProviderEntityRequired?(profile) onProviderEntityRequired(profile)
return return
case .providerRequired: case .providerRequired:

View File

@ -44,27 +44,13 @@ extension IAPManager {
isIncluded: { isIncluded: {
Configuration.ProfileManager.isIncluded($0, $1) Configuration.ProfileManager.isIncluded($0, $1)
}, },
willSave: { willSave: { _, builder in
$1 builder
}, },
willConnect: { iap, profile in willConnect: { iap, profile in
var builder = profile.builder() try iap.verify(profile.activeModules)
// 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()
}
}
// validate provider modules // validate provider modules
let profile = try builder.tryBuild()
do { do {
_ = try profile.withProviderModules() _ = try profile.withProviderModules()
return profile return profile

View File

@ -84,30 +84,14 @@ private extension PacketTunnelProvider {
.sharedForTunnel .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 { func checkEligibility(of profile: Profile, environment: TunnelEnvironment) async throws {
await iapManager.reloadReceipt() await iapManager.reloadReceipt()
guard isEligibleForPlatform else { do {
try iapManager.verify(profile.activeModules)
} catch {
let error = PassepartoutError(.App.ineligibleProfile) let error = PassepartoutError(.App.ineligibleProfile)
environment.setEnvironmentValue(error.code, forKey: TunnelEnvironmentKeys.lastErrorCode) environment.setEnvironmentValue(error.code, forKey: TunnelEnvironmentKeys.lastErrorCode)
pp_log(.app, .fault, "Profile is ineligible for this platform") pp_log(.app, .fault, "Profile \(profile.id) requires a purchase to work, shutting down")
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")
throw error throw error
} }
} }