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:
parent
e82dac3152
commit
89d7af4df7
|
@ -41,7 +41,7 @@
|
|||
"kind" : "remoteSourceControl",
|
||||
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
|
||||
"state" : {
|
||||
"revision" : "c0a615bc7a85d68a9b00d3703d0dae6efab9bdd2"
|
||||
"revision" : "db02de5247d0231ff06fb3c4d166645a434255be"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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
|
||||
let builder = moduleType.newModule(with: registry)
|
||||
guard builder is any ModuleViewProviding else {
|
||||
fatalError("\(moduleType): is not ModuleViewProviding")
|
||||
}
|
||||
if providerModuleTypes.contains(moduleType) {
|
||||
do {
|
||||
let module = try builder.tryBuild()
|
||||
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) {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -69,13 +69,20 @@ private extension ProfileContextMenu {
|
|||
profile: profile,
|
||||
nextProfileId: .constant(nil),
|
||||
interactiveManager: interactiveManager,
|
||||
errorHandler: errorHandler
|
||||
) {
|
||||
ThemeImageLabel(
|
||||
$0 ? Strings.Global.enable : Strings.Global.disable,
|
||||
$0 ? .tunnelEnable : .tunnelDisable
|
||||
)
|
||||
}
|
||||
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
|
||||
) {
|
||||
ThemeImageLabel(Strings.Global.restart, .tunnelRestart)
|
||||
}
|
||||
errorHandler: errorHandler,
|
||||
onPurchaseRequired: {
|
||||
flow?.onPurchaseRequired($0)
|
||||
},
|
||||
label: {
|
||||
ThemeImageLabel(Strings.Global.restart, .tunnelRestart)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var profileEditButton: some View {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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 = []
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
providerPicker
|
||||
.themeSection()
|
||||
|
||||
if providerId != nil {
|
||||
Group {
|
||||
providerRows
|
||||
refreshButton {
|
||||
HStack {
|
||||
|
@ -95,18 +90,17 @@ private extension ProviderContentModifier {
|
|||
}
|
||||
}
|
||||
}
|
||||
.themeSection(footer: lastUpdatedString)
|
||||
}
|
||||
.themeSection(footer: lastUpdatedString)
|
||||
}
|
||||
#else
|
||||
@ViewBuilder
|
||||
var providerView: some View {
|
||||
Section {
|
||||
providerPicker
|
||||
purchaseButton
|
||||
}
|
||||
Section {
|
||||
if providerId != nil {
|
||||
if providerId != nil {
|
||||
Section {
|
||||
providerRows
|
||||
HStack {
|
||||
lastUpdatedString.map {
|
||||
|
@ -128,21 +122,11 @@ private extension ProviderContentModifier {
|
|||
providers: supportedProviders,
|
||||
providerId: $providerId,
|
||||
isRequired: true,
|
||||
isLoading: providerManager.isLoading
|
||||
isLoading: providerManager.isLoading,
|
||||
paywallReason: $paywallReason
|
||||
)
|
||||
}
|
||||
|
||||
var purchaseButton: some View {
|
||||
EmptyView()
|
||||
.modifier(PurchaseButtonModifier(
|
||||
Strings.Providers.Picker.purchase,
|
||||
feature: .providers,
|
||||
suggesting: nil,
|
||||
showsIfRestricted: true,
|
||||
paywallReason: $paywallReason
|
||||
))
|
||||
}
|
||||
|
||||
func refreshButton<Label>(label: () -> Label) -> some View where Label: View {
|
||||
Button(action: onRefreshInfrastructure, label: label)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
||||
@MainActor
|
||||
public final class ProfileProcessor: ObservableObject, Sendable {
|
||||
private let iapManager: IAPManager
|
||||
|
||||
|
|
|
@ -33,6 +33,8 @@ public enum AppError: Error {
|
|||
|
||||
case emptyProfileName
|
||||
|
||||
case ineligibleProfile(Set<AppFeature>)
|
||||
|
||||
case malformedModule(any ModuleBuilder, error: Error)
|
||||
|
||||
case permissionDenied
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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)")
|
|
@ -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] : []
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -26,7 +26,5 @@
|
|||
import Foundation
|
||||
|
||||
public enum PaywallReason: Hashable {
|
||||
case restricted
|
||||
|
||||
case purchase(AppFeature, AppProduct?)
|
||||
case purchase(Set<AppFeature>, AppProduct? = nil)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import CommonLibrary
|
||||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import CommonLibrary
|
||||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import CommonLibrary
|
||||
import CommonUtils
|
||||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.";
|
||||
|
|
|
@ -92,7 +92,7 @@ extension ThemeRowWithFooterModifier {
|
|||
footer.map {
|
||||
Text($0)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
.font(.subheadline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,21 +111,23 @@ 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)
|
||||
.themeRow(footer: interactiveFooter)
|
||||
Toggle(isOn: $isInteractive) {
|
||||
HStack {
|
||||
Text(Strings.Modules.Openvpn.Credentials.interactive)
|
||||
PurchaseRequiredButton(features: requiredFeatures, paywallReason: $paywallReason)
|
||||
}
|
||||
}
|
||||
.themeRow(footer: interactiveFooter)
|
||||
|
||||
if isInteractive {
|
||||
Picker(Strings.Unlocalized.otp, selection: $builder.otpMethod) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
isPresentingRestricted = true
|
||||
|
||||
case .purchase(let feature, let product):
|
||||
paywallArguments = PaywallArguments(feature: feature, product: product)
|
||||
case .purchase(let features, let product):
|
||||
guard !iapManager.isRestricted else {
|
||||
isPresentingRestricted = true
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -61,16 +61,11 @@ public struct PurchaseButtonModifier: ViewModifier {
|
|||
}
|
||||
|
||||
public func body(content: Content) -> some View {
|
||||
switch iapManager.paywallReason(forFeature: feature, suggesting: suggestedProduct) {
|
||||
case .purchase:
|
||||
if iapManager.isEligible(for: feature) {
|
||||
content
|
||||
} else if !iapManager.isRestricted {
|
||||
purchaseView
|
||||
|
||||
case .restricted:
|
||||
if showsIfRestricted {
|
||||
content
|
||||
}
|
||||
|
||||
default:
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue