Refactor AppUI/AppUIMain to accomodate TV (#797)

- Move InteractiveView to AppUI for use in TV, with
OpenVPNCredentialsView
- Move non-UI entities to AppLibrary (IAP, ExtendedTunnel,
ProfileProcessor)
- Take API out of CommonLibrary (tunnel extension does not need it)
- Reorganize theme views/modifiers into separate files
This commit is contained in:
Davide 2024-11-01 23:32:35 +01:00 committed by GitHub
parent ca18aadddf
commit 357c505cc0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
92 changed files with 898 additions and 687 deletions

View File

@ -12,7 +12,7 @@
{
"identity" : "generic-json-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zoul/generic-json-swift",
"location" : "https://github.com/iwill/generic-json-swift",
"state" : {
"revision" : "0a06575f4038b504e78ac330913d920f1630f510",
"version" : "2.0.2"
@ -41,7 +41,7 @@
"kind" : "remoteSourceControl",
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
"state" : {
"revision" : "31aff403169c7cebe91a07fb8d225ab844a9a9ff"
"revision" : "e95c7b54dc11e744d9b40a722fccf752436ac0ef"
}
},
{

View File

@ -49,6 +49,13 @@ let package = Package(
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "APILibrary",
dependencies: ["CommonLibrary"],
resources: [
.copy("API")
]
),
.target(
name: "AppData",
dependencies: []
@ -57,8 +64,7 @@ let package = Package(
name: "AppDataProfiles",
dependencies: [
"AppData",
"AppLibrary",
"UtilsLibrary"
"AppLibrary"
],
resources: [
.process("Profiles.xcdatamodeld")
@ -68,8 +74,7 @@ let package = Package(
name: "AppDataProviders",
dependencies: [
"AppData",
"AppLibrary",
"UtilsLibrary"
"AppLibrary"
],
resources: [
.process("Providers.xcdatamodeld")
@ -77,16 +82,18 @@ let package = Package(
),
.target(
name: "AppLibrary",
dependencies: ["CommonLibrary"]
dependencies: [
"APILibrary",
"Kvitto",
"UtilsLibrary"
]
),
.target(
name: "AppUI",
dependencies: [
"AppDataProfiles",
"AppDataProviders",
"AppLibrary",
"Kvitto",
"UtilsLibrary"
"AppLibrary"
],
resources: [
.process("Resources")
@ -114,7 +121,6 @@ let package = Package(
.product(name: "PassepartoutWireGuardGo", package: "passepartoutkit-source-wireguard-go")
],
resources: [
.copy("API"),
.process("Resources")
]
),

View File

@ -0,0 +1,63 @@
//
// Shared.swift
// Passepartout
//
// Created by Davide De Rosa on 11/1/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
// TODO: #716, move to Environment
extension API {
public static var shared: [APIMapper] {
#if DEBUG
[API.bundled]
#else
API.remoteThenBundled
#endif
}
private static let remoteThenBundled: [APIMapper] = [
Self.remote,
Self.bundled
]
public static let bundled: APIMapper = {
guard let url = Bundle.module.url(forResource: "API", withExtension: nil) else {
fatalError("Unable to find bundled API")
}
let ws = API.V5.DefaultWebServices(
url,
timeout: Constants.shared.api.timeoutInterval
)
return API.V5.Mapper(webServices: ws)
}()
public static let remote: APIMapper = {
let ws = API.V5.DefaultWebServices(
Constants.shared.websites.api,
timeout: Constants.shared.api.timeoutInterval
)
return API.V5.Mapper(webServices: ws)
}()
}

View File

@ -50,7 +50,7 @@ extension AppProduct: InAppIdentifierProviding {
}
extension AppProduct {
static var all: [Self] {
public static var all: [Self] {
Features.all + Full.all + Donations.all
}

View File

@ -26,18 +26,18 @@
import Foundation
import UtilsLibrary
actor MockAppProductHelper: AppProductHelper {
private(set) var products: [AppProduct: InAppProduct]
public actor MockAppProductHelper: AppProductHelper {
public private(set) var products: [AppProduct: InAppProduct]
init() {
public init() {
products = [:]
}
nonisolated var canMakePurchases: Bool {
public nonisolated var canMakePurchases: Bool {
true
}
func fetchProducts() async throws {
public func fetchProducts() async throws {
products = AppProduct.all.reduce(into: [:]) {
$0[$1] = InAppProduct(
productIdentifier: $1.rawValue,
@ -48,10 +48,10 @@ actor MockAppProductHelper: AppProductHelper {
}
}
func purchase(productWithIdentifier productIdentifier: AppProduct) async throws -> InAppPurchaseResult {
public func purchase(productWithIdentifier productIdentifier: AppProduct) async throws -> InAppPurchaseResult {
.done
}
func restorePurchases() async throws {
public func restorePurchases() async throws {
}
}

View File

@ -26,14 +26,14 @@
import Foundation
import UtilsLibrary
actor MockReceiptReader: AppReceiptReader {
public actor MockAppReceiptReader: AppReceiptReader {
private var receipt: InAppReceipt?
init(receipt: InAppReceipt? = nil) {
public init(receipt: InAppReceipt? = nil) {
self.receipt = receipt
}
func setReceipt(withBuild build: Int, products: [AppProduct], cancelledProducts: Set<AppProduct> = []) {
public func setReceipt(withBuild build: Int, products: [AppProduct], cancelledProducts: Set<AppProduct> = []) {
receipt = InAppReceipt(originalBuildNumber: build, purchaseReceipts: products.map {
.init(productIdentifier: $0.rawValue,
cancellationDate: cancelledProducts.contains($0) ? Date() : nil,
@ -41,7 +41,7 @@ actor MockReceiptReader: AppReceiptReader {
})
}
func receipt(for userLevel: AppUserLevel) -> InAppReceipt? {
public func receipt(for userLevel: AppUserLevel) -> InAppReceipt? {
receipt
}
}

View File

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

View File

@ -37,7 +37,7 @@ extension AppContext {
return AppContext(
iapManager: IAPManager(
customUserLevel: nil,
receiptReader: MockReceiptReader(),
receiptReader: MockAppReceiptReader(),
unrestrictedFeatures: [
.interactiveLogin,
.onDemand,

View File

@ -25,7 +25,7 @@
import SwiftUI
protocol InteractiveViewProviding {
public protocol InteractiveViewProviding {
associatedtype InteractiveContent: View
@MainActor

View File

@ -26,6 +26,7 @@
#if os(iOS)
import SwiftUI
import UtilsLibrary
extension Theme {
public convenience init() {
@ -35,7 +36,62 @@ extension Theme {
}
}
// MARK: - Modifiers
// MARK: - Shortcuts
extension View {
public func themePopover<Content>(
isPresented: Binding<Bool>,
content: @escaping () -> Content
) -> some View where Content: View {
modifier(ThemeBooleanPopoverModifier(
isPresented: isPresented,
popover: content
))
}
public func themeLockScreen() -> some View {
modifier(ThemeLockScreenModifier(lockedContent: LogoView.init))
}
}
// MARK: - Presentation modifiers
struct ThemeBooleanPopoverModifier<Popover>: ViewModifier, SizeClassProviding where Popover: View {
@EnvironmentObject
private var theme: Theme
@Environment(\.horizontalSizeClass)
var hsClass
@Environment(\.verticalSizeClass)
var vsClass
@Binding
var isPresented: Bool
@ViewBuilder
let popover: Popover
func body(content: Content) -> some View {
if isBigDevice {
content
.popover(isPresented: $isPresented) {
popover
.frame(minWidth: theme.popoverSize?.width, minHeight: theme.popoverSize?.height)
.themeLockScreen()
}
} else {
content
.sheet(isPresented: $isPresented) {
popover
.themeLockScreen()
}
}
}
}
// MARK: - Content modifiers
extension ThemeWindowModifier {
func body(content: Content) -> some View {

View File

@ -36,6 +36,14 @@ extension Theme {
}
}
// MARK: - Shortcuts
extension View {
public func themeLockScreen() -> some View {
self
}
}
// MARK: - Modifiers
extension ThemeWindowModifier {

View File

@ -33,4 +33,48 @@ extension Theme {
}
}
// MARK: - Shortcuts
extension View {
public func themeLockScreen() -> some View {
self
}
}
// MARK: - Modifiers
extension ThemeManualInputModifier {
func body(content: Content) -> some View {
content
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
}
extension ThemeSectionWithHeaderFooterModifier {
func body(content: Content) -> some View {
Section {
content
} header: {
header.map(Text.init)
} footer: {
footer.map(Text.init)
}
}
}
// MARK: - Views
extension ThemeTextField {
public var body: some View {
commonView
}
}
extension ThemeSecureField {
public var body: some View {
commonView
}
}
#endif

View File

@ -1,5 +1,5 @@
//
// ExtendedTunnel+Theme.swift
// Theme+Extensions.swift
// Passepartout
//
// Created by Davide De Rosa on 9/6/24.
@ -23,6 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import AppLibrary
import PassepartoutKit
import SwiftUI

View File

@ -28,6 +28,10 @@ import UtilsLibrary
@MainActor
public final class Theme: ObservableObject {
private var animation: Animation = .spring
public internal(set) var animationCategories: Set<ThemeAnimationCategory> = Set(ThemeAnimationCategory.allCases)
public internal(set) var rootModalSize: CGSize?
public internal(set) var secondaryModalSize: CGSize?
@ -64,10 +68,6 @@ public final class Theme: ObservableObject {
public internal(set) var errorColor: Color = .red
private var animation: Animation = .spring
public internal(set) var animationCategories: Set<ThemeAnimationCategory> = Set(ThemeAnimationCategory.allCases)
public internal(set) var logoImage = "Logo"
public internal(set) var systemImageName: (ImageName) -> String = Theme.ImageName.defaultSystemName
@ -81,163 +81,3 @@ public final class Theme: ObservableObject {
animationCategories.contains(category) ? animation : nil
}
}
#if !os(tvOS)
// MARK: - Modifiers
extension View {
public func themeWindow(width: CGFloat, height: CGFloat) -> some View {
modifier(ThemeWindowModifier(size: .init(width: width, height: height)))
}
public func themeNavigationDetail() -> some View {
modifier(ThemeNavigationDetailModifier())
}
public func themeForm() -> some View {
modifier(ThemeFormModifier())
}
public func themeModal<Content>(
isPresented: Binding<Bool>,
isRoot: Bool = false,
isInteractive: Bool = true,
content: @escaping () -> Content
) -> some View where Content: View {
modifier(ThemeBooleanModalModifier(
isPresented: isPresented,
isRoot: isRoot,
isInteractive: isInteractive,
modal: content
))
}
public func themeModal<Content, T>(
item: Binding<T?>,
isRoot: Bool = false,
isInteractive: Bool = true,
content: @escaping (T) -> Content
) -> some View where Content: View, T: Identifiable {
modifier(ThemeItemModalModifier(
item: item,
isRoot: isRoot,
isInteractive: isInteractive,
modal: content
))
}
public func themePopover<Content>(
isPresented: Binding<Bool>,
content: @escaping () -> Content
) -> some View where Content: View {
modifier(ThemeBooleanPopoverModifier(
isPresented: isPresented,
popover: content
))
}
public func themeConfirmation(
isPresented: Binding<Bool>,
title: String,
isDestructive: Bool = false,
action: @escaping () -> Void
) -> some View {
modifier(ThemeConfirmationModifier(
isPresented: isPresented,
title: title,
isDestructive: isDestructive,
action: action
))
}
public func themeNavigationStack(if condition: Bool, closable: Bool = false, path: Binding<NavigationPath>) -> some View {
modifier(ThemeNavigationStackModifier(condition: condition, closable: closable, path: path))
}
public func themePlainButton(action: @escaping () -> Void) -> some View {
modifier(ThemePlainButtonModifier(action: action))
}
public func themeManualInput() -> some View {
modifier(ThemeManualInputModifier())
}
@ViewBuilder
public func themeMultiLine(_ isMultiLine: Bool) -> some View {
if isMultiLine {
multilineTextAlignment(.leading)
} else {
themeTruncating()
}
}
public func themeTruncating(_ mode: Text.TruncationMode = .middle) -> some View {
lineLimit(1)
.truncationMode(mode)
}
public func themeEmptyMessage() -> some View {
modifier(ThemeEmptyMessageModifier())
}
public func themeError(_ isError: Bool) -> some View {
modifier(ThemeErrorModifier(isError: isError))
}
public func themeAnimation<T>(on value: T, category: ThemeAnimationCategory) -> some View where T: Equatable {
modifier(ThemeAnimationModifier(value: value, category: category))
}
public func themeTrailingValue(_ value: CustomStringConvertible?, truncationMode: Text.TruncationMode = .tail) -> some View {
modifier(ThemeTrailingValueModifier(value: value, truncationMode: truncationMode))
}
public func themeSection(header: String? = nil, footer: String? = nil) -> some View {
modifier(ThemeSectionWithHeaderFooterModifier(header: header, footer: footer))
}
public func themeGridHeader(title: String?) -> some View {
modifier(ThemeGridSectionModifier(title: title))
}
public func themeGridCell(isSelected: Bool) -> some View {
modifier(ThemeGridCellModifier(isSelected: isSelected))
}
public func themeHoverListRow() -> some View {
modifier(ThemeHoverListRowModifier())
}
public func themeLockScreen() -> some View {
modifier(ThemeLockScreenModifier(lockedContent: LogoView.init))
}
public func themeTip(_ text: String, edge: Edge) -> some View {
modifier(ThemeTipModifier(text: text, edge: edge))
}
}
// MARK: - Views
extension Theme {
public func listSection<ItemView: View, T: EditableValue>(
_ title: String,
addTitle: String,
originalItems: Binding<[T]>,
emptyValue: (() async -> T)? = nil,
@ViewBuilder itemLabel: @escaping (Bool, Binding<T>) -> ItemView
) -> some View {
EditableListSection(
title,
addTitle: addTitle,
originalItems: originalItems,
emptyValue: emptyValue,
itemLabel: itemLabel,
removeLabel: ThemeEditableListSection.RemoveLabel.init(action:),
editLabel: ThemeEditableListSection.EditLabel.init
)
}
}
#endif

View File

@ -0,0 +1,38 @@
//
// ThemeAnimationCategory.swift
// Passepartout
//
// Created by Davide De Rosa on 11/1/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
public enum ThemeAnimationCategory: CaseIterable {
case diagnostics
case modules
case profiles
case profilesLayout
case providers
}

View File

@ -1,8 +1,8 @@
//
// Theme+UI.swift
// Theme+Modifiers.swift
// Passepartout
//
// Created by Davide De Rosa on 8/28/24.
// Created by Davide De Rosa on 11/1/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
@ -30,24 +30,130 @@ import LocalAuthentication
import SwiftUI
import UtilsLibrary
// MARK: - Modifiers
// MARK: Shortcuts
extension View {
public func themeModal<Content>(
isPresented: Binding<Bool>,
isRoot: Bool = false,
isInteractive: Bool = true,
content: @escaping () -> Content
) -> some View where Content: View {
modifier(ThemeBooleanModalModifier(
isPresented: isPresented,
isRoot: isRoot,
isInteractive: isInteractive,
modal: content
))
}
public func themeModal<Content, T>(
item: Binding<T?>,
isRoot: Bool = false,
isInteractive: Bool = true,
content: @escaping (T) -> Content
) -> some View where Content: View, T: Identifiable {
modifier(ThemeItemModalModifier(
item: item,
isRoot: isRoot,
isInteractive: isInteractive,
modal: content
))
}
public func themeConfirmation(
isPresented: Binding<Bool>,
title: String,
isDestructive: Bool = false,
action: @escaping () -> Void
) -> some View {
modifier(ThemeConfirmationModifier(
isPresented: isPresented,
title: title,
isDestructive: isDestructive,
action: action
))
}
public func themeNavigationStack(if condition: Bool, closable: Bool = false, path: Binding<NavigationPath>) -> some View {
modifier(ThemeNavigationStackModifier(condition: condition, closable: closable, path: path))
}
public func themeForm() -> some View {
modifier(ThemeFormModifier())
}
public func themeManualInput() -> some View {
modifier(ThemeManualInputModifier())
}
public func themeSection(header: String? = nil, footer: String? = nil) -> some View {
modifier(ThemeSectionWithHeaderFooterModifier(header: header, footer: footer))
}
#if !os(tvOS)
struct ThemeWindowModifier: ViewModifier {
let size: CGSize
}
struct ThemeNavigationDetailModifier: ViewModifier {
}
struct ThemeFormModifier: ViewModifier {
func body(content: Content) -> some View {
content
.formStyle(.grouped)
public func themeWindow(width: CGFloat, height: CGFloat) -> some View {
modifier(ThemeWindowModifier(size: .init(width: width, height: height)))
}
public func themeNavigationDetail() -> some View {
modifier(ThemeNavigationDetailModifier())
}
public func themePlainButton(action: @escaping () -> Void) -> some View {
modifier(ThemePlainButtonModifier(action: action))
}
@ViewBuilder
public func themeMultiLine(_ isMultiLine: Bool) -> some View {
if isMultiLine {
multilineTextAlignment(.leading)
} else {
themeTruncating()
}
}
public func themeTruncating(_ mode: Text.TruncationMode = .middle) -> some View {
lineLimit(1)
.truncationMode(mode)
}
public func themeEmptyMessage() -> some View {
modifier(ThemeEmptyMessageModifier())
}
public func themeError(_ isError: Bool) -> some View {
modifier(ThemeErrorModifier(isError: isError))
}
public func themeAnimation<T>(on value: T, category: ThemeAnimationCategory) -> some View where T: Equatable {
modifier(ThemeAnimationModifier(value: value, category: category))
}
public func themeTrailingValue(_ value: CustomStringConvertible?, truncationMode: Text.TruncationMode = .tail) -> some View {
modifier(ThemeTrailingValueModifier(value: value, truncationMode: truncationMode))
}
public func themeGridHeader(title: String?) -> some View {
modifier(ThemeGridSectionModifier(title: title))
}
public func themeGridCell(isSelected: Bool) -> some View {
modifier(ThemeGridCellModifier(isSelected: isSelected))
}
public func themeHoverListRow() -> some View {
modifier(ThemeHoverListRowModifier())
}
public func themeTip(_ text: String, edge: Edge) -> some View {
modifier(ThemeTipModifier(text: text, edge: edge))
}
#endif
}
// MARK: - Presentation modifiers
struct ThemeBooleanModalModifier<Modal>: ViewModifier where Modal: View {
@EnvironmentObject
@ -106,41 +212,6 @@ struct ThemeItemModalModifier<Modal, T>: ViewModifier where Modal: View, T: Iden
}
}
struct ThemeBooleanPopoverModifier<Popover>: ViewModifier, SizeClassProviding where Popover: View {
@EnvironmentObject
private var theme: Theme
@Environment(\.horizontalSizeClass)
var hsClass
@Environment(\.verticalSizeClass)
var vsClass
@Binding
var isPresented: Bool
@ViewBuilder
let popover: Popover
func body(content: Content) -> some View {
if isBigDevice {
content
.popover(isPresented: $isPresented) {
popover
.frame(minWidth: theme.popoverSize?.width, minHeight: theme.popoverSize?.height)
.themeLockScreen()
}
} else {
content
.sheet(isPresented: $isPresented) {
popover
.themeLockScreen()
}
}
}
}
struct ThemeConfirmationModifier: ViewModifier {
@Binding
@ -197,13 +268,37 @@ struct ThemeNavigationStackModifier: ViewModifier {
}
}
struct ThemePlainButtonModifier: ViewModifier {
let action: () -> Void
// MARK: - Content modifiers
struct ThemeFormModifier: ViewModifier {
func body(content: Content) -> some View {
content
.formStyle(.grouped)
}
}
struct ThemeManualInputModifier: ViewModifier {
}
struct ThemeSectionWithHeaderFooterModifier: ViewModifier {
let header: String?
let footer: String?
}
#if !os(tvOS)
struct ThemeWindowModifier: ViewModifier {
let size: CGSize
}
struct ThemeNavigationDetailModifier: ViewModifier {
}
struct ThemePlainButtonModifier: ViewModifier {
let action: () -> Void
}
struct ThemeEmptyMessageModifier: ViewModifier {
@EnvironmentObject
@ -268,12 +363,6 @@ struct ThemeTrailingValueModifier: ViewModifier {
}
}
struct ThemeSectionWithHeaderFooterModifier: ViewModifier {
let header: String?
let footer: String?
}
struct ThemeGridSectionModifier: ViewModifier {
@EnvironmentObject
@ -394,312 +483,3 @@ struct ThemeTipModifier: ViewModifier {
}
#endif
// MARK: - Views
public enum ThemeAnimationCategory: CaseIterable {
case diagnostics
case modules
case profiles
case profilesLayout
case providers
}
public struct ThemeImage: View {
@EnvironmentObject
private var theme: Theme
private let name: Theme.ImageName
public init(_ name: Theme.ImageName) {
self.name = name
}
public var body: some View {
Image(systemName: theme.systemImageName(name))
}
}
public struct ThemeImageLabel: View {
@EnvironmentObject
private var theme: Theme
private let title: String
private let name: Theme.ImageName
public init(_ title: String, _ name: Theme.ImageName) {
self.title = title
self.name = name
}
public var body: some View {
Label {
Text(title)
} icon: {
ThemeImage(name)
}
}
}
public struct ThemeCountryFlag: View {
private let code: String?
private let placeholderTip: String?
private let countryTip: ((String) -> String?)?
public init(_ code: String?, placeholderTip: String? = nil, countryTip: ((String) -> String?)? = nil) {
self.code = code
self.placeholderTip = placeholderTip
self.countryTip = countryTip
}
public var body: some View {
if let code {
text(withString: code.asCountryCodeEmoji, tip: countryTip?(code))
} else {
text(withString: "🌐", tip: placeholderTip)
}
}
@ViewBuilder
private func text(withString string: String, tip: String?) -> some View {
if let tip {
Text(verbatim: string)
.help(tip)
} else {
Text(verbatim: string)
}
}
}
#if !os(tvOS)
public struct ThemeMenuImage: View {
@EnvironmentObject
private var theme: Theme
private let name: Theme.MenuImageName
public init(_ name: Theme.MenuImageName) {
self.name = name
}
public var body: some View {
Image(theme.menuImageName(name))
}
}
public struct ThemeDisclosableMenu<Content, Label>: View where Content: View, Label: View {
@ViewBuilder
private let content: () -> Content
@ViewBuilder
private let label: () -> Label
public init(content: @escaping () -> Content, label: @escaping () -> Label) {
self.content = content
self.label = label
}
public var body: some View {
Menu(content: content) {
HStack(alignment: .firstTextBaseline) {
label()
ThemeImage(.disclose)
}
.contentShape(.rect)
}
.foregroundStyle(.primary)
#if os(macOS)
.buttonStyle(.plain)
#endif
}
}
public struct ThemeCopiableText<Value, ValueView>: View where Value: CustomStringConvertible, ValueView: View {
@EnvironmentObject
private var theme: Theme
private let title: String?
private let value: Value
private let isMultiLine: Bool
private let valueView: (Value) -> ValueView
public init(
title: String? = nil,
value: Value,
isMultiLine: Bool = true,
valueView: @escaping (Value) -> ValueView
) {
self.title = title
self.value = value
self.isMultiLine = isMultiLine
self.valueView = valueView
}
public var body: some View {
HStack {
if let title {
Text(title)
Spacer()
}
valueView(value)
.foregroundStyle(title == nil ? theme.titleColor : theme.valueColor)
.themeMultiLine(isMultiLine)
if title == nil {
Spacer()
}
Button {
copyToPasteboard(value.description)
} label: {
ThemeImage(.copy)
}
// TODO: #584, necessary to avoid cell selection
.buttonStyle(.borderless)
}
}
}
public struct ThemeTappableText: View {
private let title: String
private let action: () -> Void
public init(title: String, action: @escaping () -> Void) {
self.title = title
self.action = action
}
var commonView: some View {
Button(action: action) {
Text(title)
.themeTruncating()
}
}
}
public struct ThemeTextField: View {
private let title: String?
@Binding
private var text: String
private let placeholder: String
public init(_ title: String, text: Binding<String>, placeholder: String) {
self.title = title
_text = text
self.placeholder = placeholder
}
@ViewBuilder
var commonView: some View {
if let title {
LabeledContent {
fieldView
} label: {
Text(title)
}
} else {
fieldView
}
}
private var fieldView: some View {
TextField(title ?? "", text: $text, prompt: Text(placeholder))
}
}
public struct ThemeSecureField: View {
private let title: String?
@Binding
private var text: String
private let placeholder: String
public init(title: String?, text: Binding<String>, placeholder: String) {
self.title = title
_text = text
self.placeholder = placeholder
}
@ViewBuilder
var commonView: some View {
if let title {
LabeledContent {
fieldView
} label: {
Text(title)
}
} else {
fieldView
}
}
private var fieldView: some View {
RevealingSecureField(title ?? "", text: $text, prompt: Text(placeholder), imageWidth: 30.0) {
ThemeImage(.hide)
.foregroundStyle(Color.accentColor)
} revealImage: {
ThemeImage(.show)
.foregroundStyle(Color.accentColor)
}
}
}
public struct ThemeRemovableItemRow<ItemView>: View where ItemView: View {
private let isEditing: Bool
@ViewBuilder
private let itemView: () -> ItemView
let removeAction: () -> Void
public init(
isEditing: Bool,
@ViewBuilder itemView: @escaping () -> ItemView,
removeAction: @escaping () -> Void
) {
self.isEditing = isEditing
self.itemView = itemView
self.removeAction = removeAction
}
public var body: some View {
RemovableItemRow(
isEditing: isEditing,
itemView: itemView,
removeView: removeView
)
}
}
public enum ThemeEditableListSection {
public struct RemoveLabel: View {
let action: () -> Void
public init(action: @escaping () -> Void) {
self.action = action
}
}
public struct EditLabel: View {
}
}
#endif

View File

@ -0,0 +1,348 @@
//
// Theme+Views.swift
// Passepartout
//
// Created by Davide De Rosa on 11/1/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
import UtilsLibrary
// MARK: Shortcuts
#if !os(tvOS)
extension Theme {
public func listSection<ItemView: View, T: EditableValue>(
_ title: String,
addTitle: String,
originalItems: Binding<[T]>,
emptyValue: (() async -> T)? = nil,
@ViewBuilder itemLabel: @escaping (Bool, Binding<T>) -> ItemView
) -> some View {
EditableListSection(
title,
addTitle: addTitle,
originalItems: originalItems,
emptyValue: emptyValue,
itemLabel: itemLabel,
removeLabel: ThemeEditableListSection.RemoveLabel.init(action:),
editLabel: ThemeEditableListSection.EditLabel.init
)
}
}
#endif
// MARK: - Views
public struct ThemeImage: View {
@EnvironmentObject
private var theme: Theme
private let name: Theme.ImageName
public init(_ name: Theme.ImageName) {
self.name = name
}
public var body: some View {
Image(systemName: theme.systemImageName(name))
}
}
public struct ThemeImageLabel: View {
@EnvironmentObject
private var theme: Theme
private let title: String
private let name: Theme.ImageName
public init(_ title: String, _ name: Theme.ImageName) {
self.title = title
self.name = name
}
public var body: some View {
Label {
Text(title)
} icon: {
ThemeImage(name)
}
}
}
public struct ThemeCountryFlag: View {
private let code: String?
private let placeholderTip: String?
private let countryTip: ((String) -> String?)?
public init(_ code: String?, placeholderTip: String? = nil, countryTip: ((String) -> String?)? = nil) {
self.code = code
self.placeholderTip = placeholderTip
self.countryTip = countryTip
}
public var body: some View {
if let code {
text(withString: code.asCountryCodeEmoji, tip: countryTip?(code))
} else {
text(withString: "🌐", tip: placeholderTip)
}
}
@ViewBuilder
private func text(withString string: String, tip: String?) -> some View {
if let tip {
Text(verbatim: string)
.help(tip)
} else {
Text(verbatim: string)
}
}
}
public struct ThemeTextField: View {
private let title: String?
@Binding
private var text: String
private let placeholder: String
public init(_ title: String, text: Binding<String>, placeholder: String) {
self.title = title
_text = text
self.placeholder = placeholder
}
@ViewBuilder
var commonView: some View {
if let title {
LabeledContent {
fieldView
} label: {
Text(title)
}
} else {
fieldView
}
}
private var fieldView: some View {
TextField(title ?? "", text: $text, prompt: Text(placeholder))
}
}
public struct ThemeSecureField: View {
private let title: String?
@Binding
private var text: String
private let placeholder: String
public init(title: String?, text: Binding<String>, placeholder: String) {
self.title = title
_text = text
self.placeholder = placeholder
}
@ViewBuilder
var commonView: some View {
if let title {
LabeledContent {
fieldView
} label: {
Text(title)
}
} else {
fieldView
}
}
private var fieldView: some View {
RevealingSecureField(title ?? "", text: $text, prompt: Text(placeholder), imageWidth: 30.0) {
ThemeImage(.hide)
.foregroundStyle(Color.accentColor)
} revealImage: {
ThemeImage(.show)
.foregroundStyle(Color.accentColor)
}
}
}
#if !os(tvOS)
public struct ThemeMenuImage: View {
@EnvironmentObject
private var theme: Theme
private let name: Theme.MenuImageName
public init(_ name: Theme.MenuImageName) {
self.name = name
}
public var body: some View {
Image(theme.menuImageName(name))
}
}
public struct ThemeDisclosableMenu<Content, Label>: View where Content: View, Label: View {
@ViewBuilder
private let content: () -> Content
@ViewBuilder
private let label: () -> Label
public init(content: @escaping () -> Content, label: @escaping () -> Label) {
self.content = content
self.label = label
}
public var body: some View {
Menu(content: content) {
HStack(alignment: .firstTextBaseline) {
label()
ThemeImage(.disclose)
}
.contentShape(.rect)
}
.foregroundStyle(.primary)
#if os(macOS)
.buttonStyle(.plain)
#endif
}
}
public struct ThemeCopiableText<Value, ValueView>: View where Value: CustomStringConvertible, ValueView: View {
@EnvironmentObject
private var theme: Theme
private let title: String?
private let value: Value
private let isMultiLine: Bool
private let valueView: (Value) -> ValueView
public init(
title: String? = nil,
value: Value,
isMultiLine: Bool = true,
valueView: @escaping (Value) -> ValueView
) {
self.title = title
self.value = value
self.isMultiLine = isMultiLine
self.valueView = valueView
}
public var body: some View {
HStack {
if let title {
Text(title)
Spacer()
}
valueView(value)
.foregroundStyle(title == nil ? theme.titleColor : theme.valueColor)
.themeMultiLine(isMultiLine)
if title == nil {
Spacer()
}
Button {
copyToPasteboard(value.description)
} label: {
ThemeImage(.copy)
}
// TODO: #584, necessary to avoid cell selection
.buttonStyle(.borderless)
}
}
}
public struct ThemeTappableText: View {
private let title: String
private let action: () -> Void
public init(title: String, action: @escaping () -> Void) {
self.title = title
self.action = action
}
var commonView: some View {
Button(action: action) {
Text(title)
.themeTruncating()
}
}
}
public struct ThemeRemovableItemRow<ItemView>: View where ItemView: View {
private let isEditing: Bool
@ViewBuilder
private let itemView: () -> ItemView
let removeAction: () -> Void
public init(
isEditing: Bool,
@ViewBuilder itemView: @escaping () -> ItemView,
removeAction: @escaping () -> Void
) {
self.isEditing = isEditing
self.itemView = itemView
self.removeAction = removeAction
}
public var body: some View {
RemovableItemRow(
isEditing: isEditing,
itemView: itemView,
removeView: removeView
)
}
}
public enum ThemeEditableListSection {
public struct RemoveLabel: View {
let action: () -> Void
public init(action: @escaping () -> Void) {
self.action = action
}
}
public struct EditLabel: View {
}
}
#endif

View File

@ -0,0 +1,39 @@
//
// OpenVPNModule+Extensions.swift
// Passepartout
//
// Created by Davide De Rosa on 11/1/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 PassepartoutKit
import SwiftUI
extension OpenVPNModule.Builder: InteractiveViewProviding {
public func interactiveView(with editor: ProfileEditor) -> some View {
let draft = editor[self]
return OpenVPNCredentialsView(
isInteractive: draft.isInteractive,
credentials: draft.credentials,
isAuthenticating: true
)
}
}

View File

@ -23,23 +23,23 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import AppLibrary
import PassepartoutKit
import SwiftUI
import UtilsLibrary
extension OpenVPNView {
struct CredentialsView: View {
public struct OpenVPNCredentialsView: View {
@EnvironmentObject
private var iapManager: IAPManager
@Binding
var isInteractive: Bool
private var isInteractive: Bool
@Binding
var credentials: OpenVPN.Credentials?
private var credentials: OpenVPN.Credentials?
var isAuthenticating = false
private var isAuthenticating = false
@State
private var builder = OpenVPN.Credentials.Builder()
@ -47,12 +47,21 @@ extension OpenVPNView {
@State
private var paywallReason: PaywallReason?
var body: some View {
public init(
isInteractive: Binding<Bool>,
credentials: Binding<OpenVPN.Credentials?>,
isAuthenticating: Bool = false
) {
_isInteractive = isInteractive
_credentials = credentials
self.isAuthenticating = isAuthenticating
}
public var body: some View {
Form {
restrictedArea
inputSection
}
.themeAnimation(on: isInteractive, category: .modules)
.themeManualInput()
.themeForm()
.navigationTitle(Strings.Modules.Openvpn.credentials)
@ -70,12 +79,10 @@ extension OpenVPNView {
}
credentials = copy.build()
}
.modifier(PaywallModifier(reason: $paywallReason))
}
}
}
private extension OpenVPNView.CredentialsView {
private extension OpenVPNCredentialsView {
var isEligibleForInteractiveLogin: Bool {
iapManager.isEligible(for: .interactiveLogin)
}
@ -164,7 +171,7 @@ private extension OpenVPNView.CredentialsView {
var isInteractive = true
return NavigationStack {
OpenVPNView.CredentialsView(
OpenVPNCredentialsView(
isInteractive: $isInteractive,
credentials: $credentials
)

View File

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

View File

@ -1,5 +1,5 @@
//
// ThemeFavoriteToggle.swift
// FavoriteToggle.swift
// Passepartout
//
// Created by Davide De Rosa on 10/25/24.
@ -25,16 +25,21 @@
import SwiftUI
struct FavoriteToggle<ID>: View where ID: Hashable {
let value: ID
public struct FavoriteToggle<ID>: View where ID: Hashable {
private let value: ID
@Binding
var selection: Set<ID>
private var selection: Set<ID>
@State
private var hover: ID?
var body: some View {
public init(value: ID, selection: Binding<Set<ID>>) {
self.value = value
_selection = selection
}
public var body: some View {
Button {
if selection.contains(value) {
selection.remove(value)
@ -45,18 +50,20 @@ struct FavoriteToggle<ID>: View where ID: Hashable {
ThemeImage(selection.contains(value) ? .favoriteOn : .favoriteOff)
.opacity(opacity)
}
#if os(macOS)
.onHover {
hover = $0 ? value : nil
}
#endif
}
}
private extension FavoriteToggle {
var opacity: Double {
#if os(iOS)
1.0
#else
#if os(macOS)
selection.contains(value) || value == hover ? 1.0 : 0.0
#else
1.0
#endif
}
}

View File

@ -26,14 +26,19 @@
import PassepartoutKit
import SwiftUI
struct InteractiveView: View {
public struct InteractiveView: View {
@ObservedObject
var manager: InteractiveManager
private var manager: InteractiveManager
let onError: (Error) -> Void
private let onError: (Error) -> Void
var body: some View {
public init(manager: InteractiveManager, onError: @escaping (Error) -> Void) {
self.manager = manager
self.onError = onError
}
public var body: some View {
manager
.editor
.interactiveProvider

View File

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

View File

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

View File

@ -25,6 +25,7 @@
#if os(iOS)
import AppLibrary
import CommonLibrary
import Foundation
import PassepartoutKit
@ -33,6 +34,7 @@ import UIKit
#else
import AppKit
import AppLibrary
import CommonLibrary
import Foundation
import PassepartoutKit

View File

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

View File

@ -25,6 +25,7 @@
#if os(macOS)
import AppLibrary
import PassepartoutKit
import SwiftUI

View File

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

View File

@ -32,18 +32,6 @@ extension OpenVPNModule.Builder: ModuleViewProviding {
}
}
extension OpenVPNModule.Builder: InteractiveViewProviding {
func interactiveView(with editor: ProfileEditor) -> some View {
let draft = editor[self]
return OpenVPNView.CredentialsView(
isInteractive: draft.isInteractive,
credentials: draft.credentials,
isAuthenticating: true
)
}
}
extension OpenVPNModule: ProviderEntityViewProviding {
func providerEntityView(
with provider: ModuleMetadata.Provider,

View File

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

View File

@ -23,6 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import AppLibrary
import CPassepartoutOpenVPNOpenSSL
import PassepartoutKit
import SwiftUI
@ -225,10 +226,12 @@ private extension OpenVPNView {
}
case .credentials:
CredentialsView(
OpenVPNCredentialsView(
isInteractive: draft.isInteractive,
credentials: draft.credentials
)
.themeAnimation(on: draft.wrappedValue.isInteractive, category: .modules)
.modifier(PaywallModifier(reason: $paywallReason))
}
}
}

View File

@ -23,6 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import AppLibrary
import SwiftUI
struct PaywallModifier: ViewModifier {

View File

@ -23,6 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import AppLibrary
import SwiftUI
struct PaywallView: View {

View File

@ -23,7 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import Foundation
import AppLibrary
import SwiftUI
struct StorageSection: View {

View File

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

View File

@ -25,6 +25,8 @@
#if os(iOS)
import AppLibrary
import CommonLibrary
import PassepartoutKit
import SwiftUI

View File

@ -25,6 +25,7 @@
#if os(macOS)
import CommonLibrary
import PassepartoutKit
import SwiftUI

View File

@ -23,16 +23,15 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import Foundation
@MainActor
final class ProviderFavoritesManager: ObservableObject {
public final class ProviderFavoritesManager: ObservableObject {
private let defaults: UserDefaults
private var allFavorites: ProviderFavoriteServers
var moduleId: UUID {
public var moduleId: UUID {
didSet {
guard let rawValue = defaults.string(forKey: AppPreference.providerFavoriteServers.key) else {
allFavorites = ProviderFavoriteServers()
@ -42,7 +41,7 @@ final class ProviderFavoritesManager: ObservableObject {
}
}
var serverIds: Set<String> {
public var serverIds: Set<String> {
get {
allFavorites.servers(forModuleWithID: moduleId)
}
@ -52,13 +51,13 @@ final class ProviderFavoritesManager: ObservableObject {
}
}
init(defaults: UserDefaults = .standard) {
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
allFavorites = ProviderFavoriteServers()
moduleId = UUID()
}
func save() {
public func save() {
defaults.set(allFavorites.rawValue, forKey: AppPreference.providerFavoriteServers.key)
}
}

View File

@ -100,8 +100,6 @@ public struct Constants: Decodable, Sendable {
}
public struct API: Decodable, Sendable {
public let bundlePath: String
public let timeoutInterval: TimeInterval
}

View File

@ -26,7 +26,6 @@
"refreshInterval": 3.0
},
"api": {
"bundlePath": "API",
"timeoutInterval": 5.0
},
"log": {

View File

@ -55,38 +55,3 @@ extension UserDefaults {
return defaults
}()
}
// TODO: #716, move to Environment
extension API {
public static var shared: [APIMapper] {
#if DEBUG
[API.bundled]
#else
API.remoteThenBundled
#endif
}
private static let remoteThenBundled: [APIMapper] = [
Self.remote,
Self.bundled
]
public static let bundled: APIMapper = {
guard let url = Bundle.module.url(forResource: Constants.shared.api.bundlePath, withExtension: nil) else {
fatalError("Unable to find bundled API")
}
let ws = API.V5.DefaultWebServices(
url,
timeout: Constants.shared.api.timeoutInterval
)
return API.V5.Mapper(webServices: ws)
}()
public static let remote: APIMapper = {
let ws = API.V5.DefaultWebServices(
Constants.shared.websites.api,
timeout: Constants.shared.api.timeoutInterval
)
return API.V5.Mapper(webServices: ws)
}()
}

View File

@ -23,7 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
@testable import AppUI
@testable import AppLibrary
import Foundation
import PassepartoutKit
import XCTest

View File

@ -23,7 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
@testable import AppUI
@testable import AppLibrary
import Foundation
import XCTest
@ -43,7 +43,7 @@ extension IAPManagerTests {
// MARK: Build products
func test_givenBuildProducts_whenOlder_thenFullVersion() async {
let reader = MockReceiptReader()
let reader = MockAppReceiptReader()
await reader.setReceipt(withBuild: olderBuildNumber, products: [])
let sut = IAPManager(receiptReader: reader) { build in
if build <= self.defaultBuildNumber {
@ -56,7 +56,7 @@ extension IAPManagerTests {
}
func test_givenBuildProducts_whenNewer_thenFreeVersion() async {
let reader = MockReceiptReader()
let reader = MockAppReceiptReader()
await reader.setReceipt(withBuild: newerBuildNumber, products: [])
let sut = IAPManager(receiptReader: reader) { build in
if build <= self.defaultBuildNumber {
@ -71,7 +71,7 @@ extension IAPManagerTests {
// MARK: Eligibility
func test_givenPurchasedFeature_whenReloadReceipt_thenIsEligible() async {
let reader = MockReceiptReader()
let reader = MockAppReceiptReader()
let sut = IAPManager(receiptReader: reader)
XCTAssertFalse(sut.isEligible(for: AppFeature.fullVersionFeaturesV2))
@ -84,7 +84,7 @@ extension IAPManagerTests {
}
func test_givenPurchasedFeatures_thenIsOnlyEligibleForFeatures() async {
let reader = MockReceiptReader()
let reader = MockAppReceiptReader()
await reader.setReceipt(withBuild: defaultBuildNumber, products: [
.Features.siriShortcuts,
.Features.networkSettings
@ -102,7 +102,7 @@ extension IAPManagerTests {
}
func test_givenPurchasedAndCancelledFeature_thenIsNotEligible() async {
let reader = MockReceiptReader()
let reader = MockAppReceiptReader()
await reader.setReceipt(
withBuild: defaultBuildNumber,
products: [.Full.allPlatforms],
@ -115,7 +115,7 @@ extension IAPManagerTests {
}
func test_givenFreeVersion_thenIsNotEligibleForAnyFeature() async {
let reader = MockReceiptReader()
let reader = MockAppReceiptReader()
await reader.setReceipt(withBuild: defaultBuildNumber, products: [])
let sut = IAPManager(receiptReader: reader)
@ -127,7 +127,7 @@ extension IAPManagerTests {
}
func test_givenFreeVersion_thenIsNotEligibleForAppleTV() async {
let reader = MockReceiptReader()
let reader = MockAppReceiptReader()
await reader.setReceipt(withBuild: defaultBuildNumber, products: [])
let sut = IAPManager(receiptReader: reader)
@ -136,7 +136,7 @@ extension IAPManagerTests {
}
func test_givenFullVersion_thenIsEligibleForAnyFeatureExceptAppleTV() async {
let reader = MockReceiptReader()
let reader = MockAppReceiptReader()
await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Full.allPlatforms])
let sut = IAPManager(receiptReader: reader)
@ -148,7 +148,7 @@ extension IAPManagerTests {
}
func test_givenAppleTV_thenIsEligibleForAppleTV() async {
let reader = MockReceiptReader()
let reader = MockAppReceiptReader()
await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Features.appleTV])
let sut = IAPManager(receiptReader: reader)
@ -157,7 +157,7 @@ extension IAPManagerTests {
}
func test_givenPlatformVersion_thenIsFullVersionForPlatform() async {
let reader = MockReceiptReader()
let reader = MockAppReceiptReader()
let sut = IAPManager(receiptReader: reader)
#if os(macOS)
@ -172,7 +172,7 @@ extension IAPManagerTests {
}
func test_givenPlatformVersion_thenIsNotFullVersionForOtherPlatform() async {
let reader = MockReceiptReader()
let reader = MockAppReceiptReader()
let sut = IAPManager(receiptReader: reader)
#if os(macOS)
@ -193,7 +193,7 @@ extension IAPManagerTests {
// MARK: App level
func test_givenBetaApp_thenIsRestricted() async {
let reader = MockReceiptReader()
let reader = MockAppReceiptReader()
let sut = IAPManager(customUserLevel: .beta, receiptReader: reader)
await sut.reloadReceipt()
@ -201,7 +201,7 @@ extension IAPManagerTests {
}
func test_givenBetaApp_thenIsNotEligibleForAnyFeature() async {
let reader = MockReceiptReader()
let reader = MockAppReceiptReader()
let sut = IAPManager(customUserLevel: .beta, receiptReader: reader)
await sut.reloadReceipt()
@ -209,7 +209,7 @@ extension IAPManagerTests {
}
func test_givenBetaApp_thenIsEligibleForUnrestrictedFeature() async {
let reader = MockReceiptReader()
let reader = MockAppReceiptReader()
let sut = IAPManager(customUserLevel: .beta, receiptReader: reader, unrestrictedFeatures: [.onDemand])
await sut.reloadReceipt()
@ -223,7 +223,7 @@ extension IAPManagerTests {
}
func test_givenFullApp_thenIsFullVersion() async {
let reader = MockReceiptReader()
let reader = MockAppReceiptReader()
let sut = IAPManager(customUserLevel: .fullVersion, receiptReader: reader)
await sut.reloadReceipt()
@ -231,7 +231,7 @@ extension IAPManagerTests {
}
func test_givenFullPlusTVApp_thenIsFullVersion() async {
let reader = MockReceiptReader()
let reader = MockAppReceiptReader()
let sut = IAPManager(customUserLevel: .fullVersionPlusTV, receiptReader: reader)
await sut.reloadReceipt()
@ -239,7 +239,7 @@ extension IAPManagerTests {
}
func test_givenFullApp_thenIsEligibleForAnyFeatureExceptAppleTV() async {
let reader = MockReceiptReader()
let reader = MockAppReceiptReader()
let sut = IAPManager(customUserLevel: .fullVersion, receiptReader: reader)
await sut.reloadReceipt()
@ -250,7 +250,7 @@ extension IAPManagerTests {
}
func test_givenFullPlusTVApp_thenIsEligibleForAnyFeature() async {
let reader = MockReceiptReader()
let reader = MockAppReceiptReader()
let sut = IAPManager(customUserLevel: .fullVersionPlusTV, receiptReader: reader)
await sut.reloadReceipt()

View File

@ -23,6 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import AppLibrary
@testable import AppUIMain
import Foundation
import XCTest

View File

@ -1,5 +1,5 @@
#!/bin/bash
DESTINATION="Passepartout/Library/Sources/CommonLibrary/Resources/API"
DESTINATION="Passepartout/Library/Sources/APILibrary/API"
API_VERSION="v5"
mkdir tmp