parent
dcdcec4da7
commit
e8d5f2477b
|
@ -28,6 +28,8 @@ import Foundation
|
|||
public enum AppFeature: String, CaseIterable {
|
||||
case appleTV
|
||||
|
||||
case interactiveLogin
|
||||
|
||||
case networkSettings
|
||||
|
||||
case onDemand
|
||||
|
@ -37,6 +39,7 @@ public enum AppFeature: String, CaseIterable {
|
|||
case siri
|
||||
|
||||
public static let allCases: [AppFeature] = [
|
||||
.interactiveLogin,
|
||||
.networkSettings,
|
||||
.onDemand,
|
||||
.providers,
|
||||
|
|
|
@ -35,6 +35,8 @@ public final class IAPManager: ObservableObject {
|
|||
|
||||
private let receiptReader: any AppReceiptReader
|
||||
|
||||
private let unrestrictedFeatures: Set<AppFeature>
|
||||
|
||||
private let productsAtBuild: BuildProducts<AppProduct>?
|
||||
|
||||
private(set) var userLevel: AppUserLevel
|
||||
|
@ -48,10 +50,12 @@ public final class IAPManager: ObservableObject {
|
|||
public init(
|
||||
customUserLevel: AppUserLevel? = nil,
|
||||
receiptReader: any AppReceiptReader,
|
||||
unrestrictedFeatures: Set<AppFeature> = [],
|
||||
productsAtBuild: BuildProducts<AppProduct>? = nil
|
||||
) {
|
||||
self.customUserLevel = customUserLevel
|
||||
self.receiptReader = receiptReader
|
||||
self.unrestrictedFeatures = unrestrictedFeatures
|
||||
self.productsAtBuild = productsAtBuild
|
||||
userLevel = .undefined
|
||||
purchasedProducts = []
|
||||
|
@ -106,6 +110,10 @@ public final class IAPManager: ObservableObject {
|
|||
eligibleFeatures = Set(userLevel.features)
|
||||
}
|
||||
|
||||
unrestrictedFeatures.forEach {
|
||||
eligibleFeatures.insert($0)
|
||||
}
|
||||
|
||||
pp_log(.app, .notice, "Purchased products: \(purchasedProducts.map(\.rawValue))")
|
||||
pp_log(.app, .notice, "Eligible features: \(eligibleFeatures)")
|
||||
objectWillChange.send()
|
||||
|
|
|
@ -47,7 +47,7 @@ public final class KvittoReceiptReader: AppReceiptReader {
|
|||
guard let receipt else {
|
||||
let releaseUrl = url.deletingLastPathComponent().appendingPathComponent("receipt")
|
||||
guard releaseUrl != url else {
|
||||
#if !targetEnvironment(simulator)
|
||||
#if !os(macOS) && !targetEnvironment(simulator)
|
||||
assertionFailure("How can release URL be equal to sandbox URL in TestFlight?")
|
||||
#endif
|
||||
return nil
|
||||
|
|
|
@ -382,6 +382,10 @@ public enum Strings {
|
|||
}
|
||||
}
|
||||
}
|
||||
public enum Purchase {
|
||||
/// Log in interactively
|
||||
public static let interactive = Strings.tr("Localizable", "modules.openvpn.purchase.interactive", fallback: "Log in interactively")
|
||||
}
|
||||
}
|
||||
public enum Wireguard {
|
||||
/// Allowed IPs
|
||||
|
|
|
@ -179,6 +179,7 @@
|
|||
"modules.on_demand.ethernet" = "Ethernet";
|
||||
"modules.on_demand.ssid.add" = "Add SSID";
|
||||
|
||||
"modules.openvpn.purchase.interactive" = "Log in interactively";
|
||||
"modules.openvpn.pull" = "Pull";
|
||||
"modules.openvpn.redirect_gateway" = "Redirect gateway";
|
||||
"modules.openvpn.credentials" = "Credentials";
|
||||
|
|
|
@ -97,5 +97,5 @@ extension AboutRouterView {
|
|||
AboutRouterView(
|
||||
tunnel: .mock
|
||||
)
|
||||
.environmentObject(Theme())
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
|
|
@ -161,6 +161,5 @@ private extension AppInlineCoordinator {
|
|||
tunnel: .mock,
|
||||
registry: Registry()
|
||||
)
|
||||
.environmentObject(Theme())
|
||||
.environmentObject(ConnectionObserver.mock)
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
|
|
@ -149,6 +149,5 @@ extension AppModalCoordinator {
|
|||
tunnel: .mock,
|
||||
registry: Registry()
|
||||
)
|
||||
.environmentObject(Theme())
|
||||
.environmentObject(ConnectionObserver.mock)
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
|
|
@ -118,5 +118,5 @@ private extension AppToolbar {
|
|||
}
|
||||
.frame(width: 600, height: 400)
|
||||
}
|
||||
.environmentObject(Theme())
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
|
|
@ -153,7 +153,6 @@ private struct PreviewView: View {
|
|||
onEdit: { _ in }
|
||||
)
|
||||
}
|
||||
.environmentObject(Theme())
|
||||
.environmentObject(ConnectionObserver.mock)
|
||||
.withMockEnvironment()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -148,6 +148,5 @@ private extension ProfileGridView {
|
|||
onEdit: { _ in }
|
||||
)
|
||||
.themeWindow(width: 600, height: 300)
|
||||
.environmentObject(Theme())
|
||||
.environmentObject(ConnectionObserver.mock)
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
|
|
@ -137,6 +137,5 @@ private extension ProfileListView {
|
|||
errorHandler: .default(),
|
||||
onEdit: { _ in }
|
||||
)
|
||||
.environmentObject(Theme())
|
||||
.environmentObject(ConnectionObserver.mock)
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
|
|
@ -198,7 +198,5 @@ private extension DiagnosticsView {
|
|||
.init(date: Date().addingTimeInterval(-600), url: URL(string: "http://three.com")!)
|
||||
]
|
||||
}
|
||||
.environmentObject(Theme())
|
||||
.environmentObject(ConnectionObserver.mock)
|
||||
.environmentObject(IAPManager.mock)
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
|
|
@ -136,5 +136,5 @@ private extension IPView.RouteView {
|
|||
}
|
||||
|
||||
return Preview()
|
||||
.environmentObject(Theme())
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
|
|
@ -65,7 +65,7 @@ private struct OnDemandView: View {
|
|||
var body: some View {
|
||||
Group {
|
||||
enabledSection
|
||||
restrictedSection
|
||||
restrictedArea
|
||||
}
|
||||
.asModuleView(with: editor, draft: draft)
|
||||
.modifier(PaywallModifier(reason: $paywallReason))
|
||||
|
@ -86,11 +86,11 @@ private extension OnDemandView {
|
|||
}
|
||||
|
||||
@ViewBuilder
|
||||
var restrictedSection: some View {
|
||||
var restrictedArea: some View {
|
||||
switch iapManager.paywallReason(forFeature: .onDemand) {
|
||||
case .purchase(let feature):
|
||||
case .purchase(let appFeature):
|
||||
Button(Strings.Modules.OnDemand.purchase) {
|
||||
paywallReason = .purchase(feature)
|
||||
paywallReason = .purchase(appFeature)
|
||||
}
|
||||
|
||||
case .restricted:
|
||||
|
|
|
@ -29,6 +29,9 @@ import SwiftUI
|
|||
extension OpenVPNView {
|
||||
struct CredentialsView: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var iapManager: IAPManager
|
||||
|
||||
@Binding
|
||||
var isInteractive: Bool
|
||||
|
||||
|
@ -43,11 +46,12 @@ extension OpenVPNView {
|
|||
@State
|
||||
private var otp = ""
|
||||
|
||||
@State
|
||||
private var paywallReason: PaywallReason?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if !isAuthenticating {
|
||||
interactiveSection
|
||||
}
|
||||
restrictedArea
|
||||
inputSection
|
||||
}
|
||||
.themeAnimation(on: isInteractive, category: .modules)
|
||||
|
@ -58,7 +62,7 @@ extension OpenVPNView {
|
|||
builder = credentials?.builder() ?? OpenVPN.Credentials.Builder()
|
||||
}
|
||||
.onChange(of: builder) {
|
||||
if isAuthenticating {
|
||||
if isEligibleForInteractiveLogin, isAuthenticating {
|
||||
credentials = $0.buildForAuthentication(otp: otp)
|
||||
} else {
|
||||
credentials = $0.build()
|
||||
|
@ -67,15 +71,38 @@ extension OpenVPNView {
|
|||
.onChange(of: otp) {
|
||||
credentials = builder.buildForAuthentication(otp: $0)
|
||||
}
|
||||
.modifier(PaywallModifier(reason: $paywallReason))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension OpenVPNView.CredentialsView {
|
||||
var isEligibleForInteractiveLogin: Bool {
|
||||
iapManager.isEligible(for: .interactiveLogin)
|
||||
}
|
||||
|
||||
var otpMethods: [OpenVPN.Credentials.OTPMethod] {
|
||||
[.none, .append, .encode]
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var restrictedArea: some View {
|
||||
switch iapManager.paywallReason(forFeature: .interactiveLogin) {
|
||||
case .purchase(let appFeature):
|
||||
Button(Strings.Modules.Openvpn.Purchase.interactive) {
|
||||
paywallReason = .purchase(appFeature)
|
||||
}
|
||||
|
||||
case .restricted:
|
||||
EmptyView()
|
||||
|
||||
default:
|
||||
if !isAuthenticating {
|
||||
interactiveSection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var interactiveSection: some View {
|
||||
Group {
|
||||
Toggle(Strings.Modules.Openvpn.Credentials.interactive, isOn: $isInteractive)
|
||||
|
@ -108,7 +135,7 @@ private extension OpenVPNView.CredentialsView {
|
|||
ThemeSecureField(title: Strings.Global.password, text: $builder.password, placeholder: Strings.Placeholders.secret)
|
||||
.textContentType(.password)
|
||||
|
||||
if isAuthenticating && builder.otpMethod != .none {
|
||||
if isEligibleForInteractiveLogin, isAuthenticating && builder.otpMethod != .none {
|
||||
ThemeSecureField(title: Strings.Unlocalized.otp, text: $otp, placeholder: Strings.Placeholders.secret)
|
||||
.textContentType(.oneTimeCode)
|
||||
}
|
||||
|
@ -137,6 +164,6 @@ private extension OpenVPNView.CredentialsView {
|
|||
isInteractive: $isInteractive,
|
||||
credentials: $credentials
|
||||
)
|
||||
.environmentObject(Theme())
|
||||
.withMockEnvironment()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -64,5 +64,5 @@ private extension ModuleDetailView {
|
|||
moduleId: Profile.mock.modules.first?.id,
|
||||
moduleViewFactory: DefaultModuleViewFactory()
|
||||
)
|
||||
.environmentObject(Theme())
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
|
|
@ -145,5 +145,5 @@ private extension ProfileCoordinator {
|
|||
path: .constant(NavigationPath()),
|
||||
onDismiss: {}
|
||||
)
|
||||
.environmentObject(Theme())
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
|
|
@ -170,7 +170,7 @@ private extension ProfileEditView {
|
|||
path: .constant(NavigationPath())
|
||||
)
|
||||
}
|
||||
.environmentObject(Theme())
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
|
@ -141,7 +141,7 @@ private extension ModuleListView {
|
|||
selectedModuleId: .constant(nil),
|
||||
malformedModuleIds: .constant([])
|
||||
)
|
||||
.environmentObject(Theme())
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
|
@ -52,7 +52,7 @@ struct ProfileGeneralView: View {
|
|||
ProfileGeneralView(
|
||||
profileEditor: ProfileEditor()
|
||||
)
|
||||
.environmentObject(Theme())
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
|
@ -121,7 +121,7 @@ private extension ProfileSplitView {
|
|||
profileEditor: ProfileEditor(profile: .newProfile()),
|
||||
moduleViewFactory: DefaultModuleViewFactory()
|
||||
)
|
||||
.environmentObject(Theme())
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
|
@ -45,12 +45,3 @@ extension EditableModule where Self: ModuleViewProviding {
|
|||
.withMockEnvironment()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private extension View {
|
||||
func withMockEnvironment() -> some View {
|
||||
environmentObject(Theme())
|
||||
.environmentObject(IAPManager.mock)
|
||||
.environmentObject(ConnectionObserver.mock)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -78,8 +78,7 @@ private extension ConnectionStatusView {
|
|||
try? await Tunnel.mock.connect(with: .mock, processor: IAPManager.mock)
|
||||
}
|
||||
.frame(width: 100, height: 100)
|
||||
.environmentObject(Theme())
|
||||
.environmentObject(ConnectionObserver.mock)
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
||||
#Preview("On-Demand") {
|
||||
|
@ -98,6 +97,5 @@ private extension ConnectionStatusView {
|
|||
try? await Tunnel.mock.connect(with: profile, processor: IAPManager.mock)
|
||||
}
|
||||
.frame(width: 100, height: 100)
|
||||
.environmentObject(Theme())
|
||||
.environmentObject(ConnectionObserver.mock)
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
|
|
@ -195,8 +195,7 @@ private struct CardModifier: ViewModifier {
|
|||
}
|
||||
}
|
||||
.themeForm()
|
||||
.environmentObject(Theme())
|
||||
.environmentObject(ConnectionObserver.mock)
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
||||
#Preview("Grid") {
|
||||
|
@ -209,8 +208,7 @@ private struct CardModifier: ViewModifier {
|
|||
}
|
||||
.padding()
|
||||
}
|
||||
.environmentObject(Theme())
|
||||
.environmentObject(ConnectionObserver.mock)
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
||||
private struct HeaderView: View {
|
||||
|
|
|
@ -42,5 +42,5 @@ struct LogoView: View {
|
|||
|
||||
#Preview {
|
||||
LogoView()
|
||||
.environmentObject(Theme())
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
|
|
@ -56,5 +56,5 @@ struct NameSection: View {
|
|||
)
|
||||
}
|
||||
.themeForm()
|
||||
.environmentObject(Theme())
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
|
|
@ -125,5 +125,5 @@ private extension ProfileContextMenu {
|
|||
)
|
||||
}
|
||||
}
|
||||
.environmentObject(Theme())
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
|
|
@ -53,5 +53,5 @@ struct StorageSection: View {
|
|||
)
|
||||
}
|
||||
.themeForm()
|
||||
.environmentObject(Theme())
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
|
|
@ -109,10 +109,15 @@ private extension TunnelToggleButton {
|
|||
}
|
||||
}
|
||||
if canConnect && profile.isInteractive {
|
||||
interactiveManager.present(with: profile) {
|
||||
await perform(with: $0)
|
||||
if iapManager.isEligible(for: .interactiveLogin) {
|
||||
pp_log(.app, .notice, "Present interactive login")
|
||||
interactiveManager.present(with: profile) {
|
||||
await perform(with: $0)
|
||||
}
|
||||
return
|
||||
} else {
|
||||
pp_log(.app, .notice, "Suppress interactive login, not eligible")
|
||||
}
|
||||
return
|
||||
}
|
||||
await perform(with: profile)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// View+Mock.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/2/24.
|
||||
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
|
||||
//
|
||||
// https://github.com/passepartoutvpn
|
||||
//
|
||||
// This file is part of Passepartout.
|
||||
//
|
||||
// Passepartout is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Passepartout is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
extension View {
|
||||
func withMockEnvironment() -> some View {
|
||||
environmentObject(Theme())
|
||||
.environmentObject(IAPManager.mock)
|
||||
.environmentObject(ConnectionObserver.mock)
|
||||
}
|
||||
}
|
|
@ -267,6 +267,20 @@ extension IAPManagerTests {
|
|||
XCTAssertFalse(sut.isEligible(for: AppFeature.allCases))
|
||||
}
|
||||
|
||||
func test_givenBetaApp_thenIsEligibleForUnrestrictedFeature() async {
|
||||
let reader = MockReceiptReader()
|
||||
let sut = IAPManager(customUserLevel: .beta, receiptReader: reader, unrestrictedFeatures: [.onDemand])
|
||||
|
||||
await sut.reloadReceipt()
|
||||
AppFeature.allCases.forEach {
|
||||
if $0 == .onDemand {
|
||||
XCTAssertTrue(sut.isEligible(for: $0))
|
||||
} else {
|
||||
XCTAssertFalse(sut.isEligible(for: $0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func test_givenFullApp_thenIsFullVersion() async {
|
||||
let reader = MockReceiptReader()
|
||||
let sut = IAPManager(customUserLevel: .fullVersion, receiptReader: reader)
|
||||
|
|
|
@ -43,6 +43,8 @@ extension IAPManager {
|
|||
static let shared = IAPManager(
|
||||
customUserLevel: customUserLevel,
|
||||
receiptReader: KvittoReceiptReader(),
|
||||
// FIXME: #662, omit unrestrictedFeatures on release!
|
||||
unrestrictedFeatures: [.interactiveLogin],
|
||||
productsAtBuild: productsAtBuild
|
||||
)
|
||||
|
||||
|
|
Loading…
Reference in New Issue