Handle TV requirements on connection (#922)

Fixes #913
This commit is contained in:
Davide 2024-11-24 01:01:04 +01:00 committed by GitHub
parent bad9e8b58e
commit cb530d8a65
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 287 additions and 93 deletions

View File

@ -168,15 +168,15 @@ extension AppCoordinator {
}
enterDetail(of: profile)
},
onEditProviderEntity: {
onMigrateProfiles: {
modalRoute = .migrateProfiles
},
onProviderEntityRequired: {
guard let pair = $0.selectedProvider else {
return
}
present(.editProviderEntity($0, pair.module, pair.selection))
},
onMigrateProfiles: {
modalRoute = .migrateProfiles
},
onPurchaseRequired: { features in
setLater(.purchase(features)) {
paywallReason = $0

View File

@ -137,7 +137,7 @@ private extension InstalledProfileView {
.selectedProvider
.map { _, selection in
Button {
flow?.onEditProviderEntity(profile!)
flow?.onProviderEntityRequired(profile!)
} label: {
providerSelectorLabel(with: selection)
}
@ -202,7 +202,7 @@ private struct ToggleButton: View {
interactiveManager: interactiveManager,
errorHandler: errorHandler,
onProviderEntityRequired: {
flow?.onEditProviderEntity($0)
flow?.onProviderEntityRequired($0)
},
onPurchaseRequired: {
flow?.onPurchaseRequired($0)

View File

@ -96,7 +96,7 @@ private extension ProfileContainerView {
InteractiveCoordinator(style: .modal, manager: interactiveManager) {
errorHandler.handle(
$0,
title: Strings.Global.Nouns.connection,
title: interactiveManager.editor.profile.name,
message: Strings.Views.App.Errors.tunnel
)
}

View File

@ -71,7 +71,7 @@ private extension ProfileContextMenu {
interactiveManager: interactiveManager,
errorHandler: errorHandler,
onProviderEntityRequired: {
flow?.onEditProviderEntity($0)
flow?.onProviderEntityRequired($0)
},
onPurchaseRequired: {
flow?.onPurchaseRequired($0)
@ -90,7 +90,7 @@ private extension ProfileContextMenu {
.selectedProvider
.map { _ in
Button(Strings.Views.App.ProfileContext.connectTo) {
flow?.onEditProviderEntity(profile!)
flow?.onProviderEntityRequired(profile!)
}
}
}

View File

@ -30,9 +30,9 @@ import PassepartoutKit
struct ProfileFlow {
let onEditProfile: (ProfilePreview) -> Void
let onEditProviderEntity: (Profile) -> Void
let onMigrateProfiles: () -> Void
let onProviderEntityRequired: (Profile) -> Void
let onPurchaseRequired: (Set<AppFeature>) -> Void
}

View File

@ -135,7 +135,7 @@ private extension ProfileRowView {
interactiveManager: interactiveManager,
errorHandler: errorHandler,
onProviderEntityRequired: {
flow?.onEditProviderEntity($0)
flow?.onProviderEntityRequired($0)
},
onPurchaseRequired: {
flow?.onPurchaseRequired($0)

View File

@ -59,7 +59,7 @@ struct TunnelRestartButton<Label>: View where Label: View {
} catch {
errorHandler.handle(
error,
title: Strings.Global.Nouns.connection,
title: profile.name,
message: Strings.Views.App.Errors.tunnel
)
}

View File

@ -73,15 +73,13 @@ struct ProfileCoordinator: View {
var body: some View {
contentView
.modifier(PaywallModifier(reason: $paywallReason))
.alert(Strings.Views.Profile.Alerts.Purchase.title, isPresented: $requiresPurchase) {
Button(Strings.Global.Actions.purchase) {
paywallReason = .purchase(requiredFeatures, nil)
}
Button(Strings.Views.Profile.Alerts.Purchase.Buttons.ok, action: onDismiss)
Button(Strings.Global.Actions.cancel, role: .cancel, action: {})
} message: {
Text(purchaseMessage)
}
.modifier(PurchaseAlertModifier(
isPresented: $requiresPurchase,
paywallReason: $paywallReason,
requiredFeatures: requiredFeatures,
okTitle: Strings.Views.Profile.Alerts.Purchase.Buttons.ok,
okAction: onDismiss
))
.withErrorHandler(errorHandler)
}
}
@ -117,13 +115,6 @@ private extension ProfileCoordinator {
)
#endif
}
var purchaseMessage: String {
let msg = Strings.Views.Profile.Alerts.Purchase.message
return msg + "\n\n" + requiredFeatures
.map(\.localizedDescription)
.joined(separator: "\n")
}
}
private extension ProfileCoordinator {

View File

@ -24,8 +24,10 @@
//
import CommonLibrary
import CommonUtils
import PassepartoutKit
import SwiftUI
import UILibrary
public struct AppCoordinator: View, AppCoordinatorConforming {
private let profileManager: ProfileManager
@ -34,6 +36,18 @@ public struct AppCoordinator: View, AppCoordinatorConforming {
private let registry: Registry
@State
private var requiresPurchase = false
@State
private var requiredFeatures: Set<AppFeature> = []
@State
private var paywallReason: PaywallReason?
@StateObject
private var errorHandler: ErrorHandler = .default()
public init(profileManager: ProfileManager, tunnel: ExtendedTunnel, registry: Registry) {
self.profileManager = profileManager
self.tunnel = tunnel
@ -60,13 +74,28 @@ public struct AppCoordinator: View, AppCoordinatorConforming {
}
}
.navigationDestination(for: AppCoordinatorRoute.self, destination: pushDestination)
.withErrorHandler(errorHandler)
.modifier(PaywallModifier(reason: $paywallReason))
.modifier(PurchaseAlertModifier(
isPresented: $requiresPurchase,
paywallReason: $paywallReason,
requiredFeatures: requiredFeatures
))
}
}
}
private extension AppCoordinator {
var profileView: some View {
ProfileView(profileManager: profileManager, tunnel: tunnel)
ProfileView(
profileManager: profileManager,
tunnel: tunnel,
errorHandler: errorHandler,
flow: .init(
onProviderEntityRequired: onProviderEntityRequired,
onPurchaseRequired: onPurchaseRequired
)
)
}
// var searchView: some View {
@ -109,6 +138,20 @@ private extension AppCoordinator {
}
}
private extension AppCoordinator {
func onProviderEntityRequired(_ profile: Profile) {
errorHandler.handle(
title: profile.name,
message: Strings.Alerts.Providers.MissingServer.message
)
}
func onPurchaseRequired(_ features: Set<AppFeature>) {
requiredFeatures = features
requiresPurchase = true
}
}
// MARK: -
#Preview {

View File

@ -0,0 +1,34 @@
//
// AppFlow.swift
// Passepartout
//
// Created by Davide De Rosa on 11/23/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 Foundation
import PassepartoutKit
struct AppFlow {
let onProviderEntityRequired: (Profile) -> Void
let onPurchaseRequired: (Set<AppFeature>) -> Void
}

View File

@ -53,6 +53,8 @@ struct ActiveProfileView: View {
@ObservedObject
var errorHandler: ErrorHandler
var flow: AppFlow?
var body: some View {
VStack(spacing: .zero) {
VStack {
@ -167,15 +169,13 @@ private extension ActiveProfileView {
}
}
// MARK: -
private extension ActiveProfileView {
func onProviderEntityRequired(_ profile: Profile) {
// FIXME: #913, TV missing provider entity
flow?.onProviderEntityRequired(profile)
}
func onPurchaseRequired(_ features: Set<AppFeature>) {
// FIXME: #913, TV purchase required
flow?.onPurchaseRequired(features)
}
}

View File

@ -45,6 +45,8 @@ struct ProfileListView: View {
@ObservedObject
var errorHandler: ErrorHandler
var flow: AppFlow?
var body: some View {
VStack {
headerView
@ -100,15 +102,13 @@ private extension ProfileListView {
}
}
// MARK: -
private extension ProfileListView {
func onProviderEntityRequired(_ profile: Profile) {
// FIXME: #913, TV missing provider entity
flow?.onProviderEntityRequired(profile)
}
func onPurchaseRequired(_ features: Set<AppFeature>) {
// FIXME: #913, TV purchase required
flow?.onPurchaseRequired(features)
}
}

View File

@ -29,7 +29,7 @@ import PassepartoutKit
import SwiftUI
import UILibrary
struct ProfileView: View, TunnelInstallationProviding {
struct ProfileView: View, Routable, TunnelInstallationProviding {
enum Field: Hashable {
case connect
@ -47,6 +47,11 @@ struct ProfileView: View, TunnelInstallationProviding {
@ObservedObject
var tunnel: ExtendedTunnel
@ObservedObject
var errorHandler: ErrorHandler
var flow: AppFlow?
@State
var showsSidePanel = false
@ -56,9 +61,6 @@ struct ProfileView: View, TunnelInstallationProviding {
@StateObject
private var interactiveManager = InteractiveManager()
@StateObject
private var errorHandler: ErrorHandler = .default()
var body: some View {
GeometryReader { geo in
HStack(spacing: .zero) {
@ -80,7 +82,6 @@ struct ProfileView: View, TunnelInstallationProviding {
.ignoresSafeArea(edges: .horizontal)
.background(theme.primaryColor.opacity(0.6).gradient)
.themeAnimation(on: showsSidePanel, category: .profiles)
.withErrorHandler(errorHandler)
.defaultFocus($focusedField, .switchProfile)
.onChange(of: tunnel.status, onTunnelStatus)
.onChange(of: tunnel.currentProfile, onTunnelCurrentProfile)
@ -104,7 +105,8 @@ private extension ProfileView {
isSwitching: $showsSidePanel,
focusedField: $focusedField,
interactiveManager: interactiveManager,
errorHandler: errorHandler
errorHandler: errorHandler,
flow: flow
)
}
@ -126,7 +128,7 @@ private extension ProfileView {
InteractiveCoordinator(style: .inline(withCancel: false), manager: interactiveManager) {
errorHandler.handle(
$0,
title: Strings.Global.Nouns.connection,
title: interactiveManager.editor.profile.name,
message: Strings.Views.App.Errors.tunnel
)
}
@ -144,7 +146,8 @@ private extension ProfileView {
tunnel: tunnel,
focusedField: $focusedField,
interactiveManager: interactiveManager,
errorHandler: errorHandler
errorHandler: errorHandler,
flow: flow
)
}
}
@ -189,6 +192,7 @@ private extension ProfileView {
ProfileView(
profileManager: .mock,
tunnel: .mock,
errorHandler: .default(),
showsSidePanel: true
)
.withMockEnvironment()
@ -198,6 +202,7 @@ private extension ProfileView {
ProfileView(
profileManager: ProfileManager(profiles: []),
tunnel: .mock,
errorHandler: .default(),
showsSidePanel: true
)
.withMockEnvironment()

View File

@ -32,6 +32,10 @@ extension View {
}
}
public func setLater<T>(_ value: T?, millis: Int = 50, block: @escaping (T?) -> Void) {
globalSetLater(value, millis: millis, block: block)
}
@ViewBuilder
public func `if`(_ condition: Bool) -> some View {
if condition {
@ -63,14 +67,6 @@ extension View {
)
}
}
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 {
@ -79,6 +75,18 @@ extension ViewModifier {
Self._printChanges()
}
}
public func setLater<T>(_ value: T?, millis: Int = 50, block: @escaping (T?) -> Void) {
globalSetLater(value, millis: millis, block: block)
}
}
private func globalSetLater<T>(_ value: T?, millis: Int = 50, block: @escaping (T?) -> Void) {
Task {
block(nil)
try await Task.sleep(for: .milliseconds(millis))
block(value)
}
}
#if !os(tvOS)

View File

@ -21,6 +21,12 @@ public enum Strings {
public static let ok = Strings.tr("Localizable", "alerts.import.passphrase.ok", fallback: "Decrypt")
}
}
public enum Providers {
public enum MissingServer {
/// No provider server selected. Please select a destination server on your iOS/macOS device.
public static let message = Strings.tr("Localizable", "alerts.providers.missing_server.message", fallback: "No provider server selected. Please select a destination server on your iOS/macOS device.")
}
}
}
public enum Entities {
public enum Dns {
@ -770,10 +776,6 @@ public enum Strings {
public enum Profile {
public enum Alerts {
public enum Purchase {
/// This profile requires paid features to work.
public static let message = Strings.tr("Localizable", "views.profile.alerts.purchase.message", fallback: "This profile requires paid features to work.")
/// Purchase required
public static let title = Strings.tr("Localizable", "views.profile.alerts.purchase.title", fallback: "Purchase required")
public enum Buttons {
/// Save anyway
public static let ok = Strings.tr("Localizable", "views.profile.alerts.purchase.buttons.ok", fallback: "Save anyway")
@ -828,6 +830,12 @@ public enum Strings {
/// (on-demand)
public static let onDemandSuffix = Strings.tr("Localizable", "views.ui.connection_status.on_demand_suffix", fallback: " (on-demand)")
}
public enum PurchaseAlert {
/// This profile requires paid features to work.
public static let message = Strings.tr("Localizable", "views.ui.purchase_alert.message", fallback: "This profile requires paid features to work.")
/// Purchase required
public static let title = Strings.tr("Localizable", "views.ui.purchase_alert.title", fallback: "Purchase required")
}
public enum PurchaseRequired {
public enum Purchase {
/// Purchase required

View File

@ -78,9 +78,7 @@
"views.profile.rows.add_module" = "Add module";
"views.profile.module_list.section.footer" = "Drag modules to rearrange them, as their order determines priority.";
"views.profile.alerts.purchase.title" = "Purchase required";
"views.profile.alerts.purchase.buttons.ok" = "Save anyway";
"views.profile.alerts.purchase.message" = "This profile requires paid features to work.";
"views.providers.no_provider" = "None";
"views.providers.select_provider" = "Select a provider";
@ -95,6 +93,8 @@
"views.providers.vpn.no_servers" = "No servers";
"views.ui.connection_status.on_demand_suffix" = " (on-demand)";
"views.ui.purchase_alert.title" = "Purchase required";
"views.ui.purchase_alert.message" = "This profile requires paid features to work.";
"views.ui.purchase_required.purchase.help" = "Purchase required";
"views.ui.purchase_required.restricted.help" = "Feature is restricted";
@ -286,6 +286,7 @@
"alerts.import.passphrase.message" = "Enter passphrase for '%@'.";
"alerts.import.passphrase.ok" = "Decrypt";
"alerts.providers.missing_server.message" = "No provider server selected. Please select a destination server on your iOS/macOS device.";
// MARK: Global (App errors)

View File

@ -61,7 +61,9 @@ struct PaywallView: View {
var body: some View {
paywallView
.themeProgress(if: isFetchingProducts)
#if !os(tvOS)
.toolbar(content: toolbarContent)
#endif
.alert(
Strings.Global.Actions.purchase,
isPresented: $isPurchasePendingConfirmation,
@ -80,6 +82,12 @@ private extension PaywallView {
Strings.Global.Actions.purchase
}
var otherFeatures: [AppFeature] {
AppFeature.allCases.filter {
!features.contains($0)
}
}
var paywallView: some View {
Form {
requiredFeaturesView
@ -91,12 +99,6 @@ private extension PaywallView {
.disabled(purchasingIdentifier != nil)
}
var otherFeatures: [AppFeature] {
AppFeature.allCases.filter {
!features.contains($0)
}
}
@ViewBuilder
var productsView: some View {
oneTimeProduct.map {
@ -127,21 +129,21 @@ private extension PaywallView {
FeatureListView(
style: .list,
header: Strings.Views.Paywall.Sections.Features.Required.header,
features: Array(features)
) {
Text($0.localizedDescription)
.fontWeight(.bold)
}
features: Array(features),
content: {
featureView(for: $0)
.fontWeight(.bold)
}
)
}
var otherFeaturesView: some View {
FeatureListView(
style: otherFeaturesStyle,
header: Strings.Views.Paywall.Sections.Features.Other.header,
features: otherFeatures
) {
Text($0.localizedDescription)
}
features: otherFeatures,
content: featureView(for:)
)
}
var otherFeaturesStyle: FeatureListViewStyle {
@ -152,6 +154,16 @@ private extension PaywallView {
#endif
}
func featureView(for feature: AppFeature) -> some View {
#if os(tvOS)
Button(feature.localizedDescription) {
//
}
#else
Text(feature.localizedDescription)
#endif
}
var restoreView: some View {
RestorePurchasesButton(errorHandler: errorHandler)
.themeSectionWithSingleRow(

View File

@ -64,16 +64,18 @@ private extension ProductView {
@ViewBuilder
func withPaywallStyle(_ paywallStyle: PaywallProductViewStyle) -> some View {
#if os(tvOS)
productViewStyle(.compact)
.padding()
#else
switch paywallStyle {
case .recurring:
productViewStyle(.compact)
case .oneTime, .recurring:
productViewStyle(.regular)
.listRowBackground(Color.clear)
.listRowInsets(.init())
case .oneTime, .donation:
case .donation:
productViewStyle(.compact)
.padding()
}
#else
productViewStyle(.compact)
#endif
}
}

View File

@ -0,0 +1,85 @@
//
// PurchaseAlertModifier.swift
// Passepartout
//
// Created by Davide De Rosa on 11/23/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 CommonIAP
import CommonUtils
import SwiftUI
public struct PurchaseAlertModifier: ViewModifier {
@Binding
private var isPresented: Bool
@Binding
private var paywallReason: PaywallReason?
private let requiredFeatures: Set<AppFeature>
private let okTitle: String?
private let okAction: (() -> Void)?
public init(
isPresented: Binding<Bool>,
paywallReason: Binding<PaywallReason?>,
requiredFeatures: Set<AppFeature>,
okTitle: String? = nil,
okAction: (() -> Void)? = nil
) {
_isPresented = isPresented
_paywallReason = paywallReason
self.requiredFeatures = requiredFeatures
self.okTitle = okTitle
self.okAction = okAction
}
public func body(content: Content) -> some View {
content
.alert(Strings.Views.Ui.PurchaseAlert.title, isPresented: $isPresented) {
Button(Strings.Global.Actions.purchase) {
setLater(.purchase(requiredFeatures, nil)) {
paywallReason = $0
}
}
if let okTitle {
Button(okTitle) {
okAction?()
}
}
Button(Strings.Global.Actions.cancel, role: .cancel, action: {})
} message: {
Text(purchaseMessage)
}
}
}
private extension PurchaseAlertModifier {
var purchaseMessage: String {
let msg = Strings.Views.Ui.PurchaseAlert.message
return msg + "\n\n" + requiredFeatures
.map(\.localizedDescription)
.joined(separator: "\n")
}
}

View File

@ -24,6 +24,7 @@
//
import CommonLibrary
import CommonUtils
import PassepartoutKit
import SwiftUI

View File

@ -120,7 +120,7 @@ private extension TunnelToggleButton {
guard let provider = providerModule.provider else {
errorHandler.handle(
PassepartoutError(.providerRequired),
title: Strings.Global.Nouns.connection
title: profile.name
)
return
}
@ -168,7 +168,7 @@ private extension TunnelToggleButton {
} catch {
errorHandler.handle(
error,
title: Strings.Global.Nouns.connection,
title: profile.name,
message: Strings.Views.App.Errors.tunnel
)
}

View File

@ -711,17 +711,21 @@ private extension ProfileManagerTests {
) async throws {
let exp = expectation(description: description)
var wasMet = false
sut.objectWillChange
.sink {
guard !wasMet else {
return
}
if condition(sut) {
wasMet = true
exp.fulfill()
}
Publishers.Merge(
sut.objectWillChange,
sut.didChange.map { _ in }
)
.sink {
guard !wasMet else {
return
}
.store(in: &subscriptions)
if condition(sut) {
wasMet = true
exp.fulfill()
}
}
.store(in: &subscriptions)
try await action(sut)
await fulfillment(of: [exp], timeout: timeout)