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",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
|
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "c0a615bc7a85d68a9b00d3703d0dae6efab9bdd2"
|
"revision" : "db02de5247d0231ff06fb3c4d166645a434255be"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -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"),
|
||||||
|
|
|
@ -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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = []
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
|
@ -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)")
|
|
@ -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)
|
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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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?)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
@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()
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue