Add initial support for providers (#723)
Initial integration of providers via API: - Generic views and modifiers for provider/server selection - Add in OpenVPNView - Prepare in WireGuardView Also: - Introduce ProfileProcessor, move IAP processing there - Move .asModuleView() to ModuleViewModifier for proper animation - Use .themeModal() rather than .sheet()
This commit is contained in:
parent
5c91eb4bf1
commit
da87ca698a
|
@ -168,9 +168,9 @@
|
|||
0E7E3D5B2B9345FD002BBDB4 /* App.entitlements */,
|
||||
0E7C3CCC2C9AF44600B72E69 /* AppDelegate.swift */,
|
||||
0EB08B962CA46F4900A02591 /* AppPlist.strings */,
|
||||
0E7E3D5C2B9345FD002BBDB4 /* Assets.xcassets */,
|
||||
0EC066D02C7DC47600D88A94 /* LaunchScreen.storyboard */,
|
||||
0E7E3D5F2B9345FD002BBDB4 /* PassepartoutApp.swift */,
|
||||
0E7E3D5C2B9345FD002BBDB4 /* Assets.xcassets */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
|
|
|
@ -76,6 +76,7 @@ let package = Package(
|
|||
.target(
|
||||
name: "CommonLibrary",
|
||||
dependencies: [
|
||||
.product(name: "PassepartoutAPIBundle", package: "passepartoutkit-source"),
|
||||
.product(name: "PassepartoutKit", package: "passepartoutkit-source"),
|
||||
.product(name: "PassepartoutOpenVPNOpenSSL", package: "passepartoutkit-source-openvpn-openssl"),
|
||||
.product(name: "PassepartoutWireGuardGo", package: "passepartoutkit-source-wireguard-go")
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
//
|
||||
// IAPManager+ProfileProcessor.swift
|
||||
// ProviderFactory.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 9/10/24.
|
||||
// Created by Davide De Rosa on 10/8/24.
|
||||
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
|
||||
//
|
||||
// https://github.com/passepartoutvpn
|
||||
|
@ -26,23 +26,14 @@
|
|||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
||||
extension IAPManager: ProfileProcessor {
|
||||
func processedProfile(_ profile: Profile) throws -> Profile {
|
||||
var builder = profile.builder()
|
||||
@MainActor
|
||||
public final class ProviderFactory: ObservableObject {
|
||||
public let providerManager: ProviderManager
|
||||
|
||||
// suppress on-demand rules if not eligible
|
||||
if !isEligible(for: .onDemand) {
|
||||
pp_log(.app, .notice, "Suppress on-demand rules, not eligible")
|
||||
public let vpnProviderManager: VPNProviderManager
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
return try builder.tryBuild()
|
||||
public init(providerManager: ProviderManager, vpnProviderManager: VPNProviderManager) {
|
||||
self.providerManager = providerManager
|
||||
self.vpnProviderManager = vpnProviderManager
|
||||
}
|
||||
}
|
|
@ -36,6 +36,8 @@ public final class AppContext: ObservableObject {
|
|||
|
||||
public let profileManager: ProfileManager
|
||||
|
||||
public let profileProcessor: ProfileProcessor
|
||||
|
||||
public let tunnel: Tunnel
|
||||
|
||||
public let tunnelEnvironment: TunnelEnvironment
|
||||
|
@ -44,6 +46,8 @@ public final class AppContext: ObservableObject {
|
|||
|
||||
public let registry: Registry
|
||||
|
||||
public let providerFactory: ProviderFactory
|
||||
|
||||
private let constants: Constants
|
||||
|
||||
private var subscriptions: Set<AnyCancellable>
|
||||
|
@ -51,13 +55,16 @@ public final class AppContext: ObservableObject {
|
|||
public init(
|
||||
iapManager: IAPManager,
|
||||
profileManager: ProfileManager,
|
||||
profileProcessor: ProfileProcessor,
|
||||
tunnel: Tunnel,
|
||||
tunnelEnvironment: TunnelEnvironment,
|
||||
registry: Registry,
|
||||
providerFactory: ProviderFactory,
|
||||
constants: Constants
|
||||
) {
|
||||
self.iapManager = iapManager
|
||||
self.profileManager = profileManager
|
||||
self.profileProcessor = profileProcessor
|
||||
self.tunnel = tunnel
|
||||
self.tunnelEnvironment = tunnelEnvironment
|
||||
connectionObserver = ConnectionObserver(
|
||||
|
@ -66,6 +73,7 @@ public final class AppContext: ObservableObject {
|
|||
interval: constants.connection.refreshInterval
|
||||
)
|
||||
self.registry = registry
|
||||
self.providerFactory = providerFactory
|
||||
self.constants = constants
|
||||
subscriptions = []
|
||||
|
||||
|
@ -100,7 +108,7 @@ private extension AppContext {
|
|||
|
||||
private extension AppContext {
|
||||
func installSavedProfile(_ profile: Profile) async throws {
|
||||
try await tunnel.install(profile, processor: iapManager)
|
||||
try await tunnel.install(profile, processor: profileProcessor)
|
||||
}
|
||||
|
||||
func uninstallRemovedProfiles(withIds profileIds: [Profile.ID]) {
|
||||
|
@ -125,7 +133,7 @@ private extension AppContext {
|
|||
return
|
||||
}
|
||||
if tunnel.status == .active {
|
||||
try await tunnel.connect(with: profile, processor: iapManager)
|
||||
try await tunnel.connect(with: profile, processor: profileProcessor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -93,37 +93,19 @@ extension ProfileEditor {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Metadata
|
||||
// MARK: - Editing
|
||||
|
||||
extension ProfileEditor {
|
||||
var id: Profile.ID {
|
||||
editableProfile.id
|
||||
}
|
||||
|
||||
var name: String {
|
||||
var profile: EditableProfile {
|
||||
get {
|
||||
editableProfile.name
|
||||
editableProfile
|
||||
}
|
||||
set {
|
||||
editableProfile.name = newValue
|
||||
editableProfile = newValue
|
||||
}
|
||||
}
|
||||
|
||||
func displayName(forModuleWithId moduleId: UUID) -> String? {
|
||||
editableProfile.displayName(forModuleWithId: moduleId)
|
||||
}
|
||||
|
||||
func name(forModuleWithId moduleId: UUID) -> String? {
|
||||
editableProfile.name(forModuleWithId: moduleId)
|
||||
}
|
||||
|
||||
func setName(_ name: String, forModuleWithId moduleId: UUID) {
|
||||
editableProfile.setName(name, forModuleWithId: moduleId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Modules
|
||||
|
||||
extension ProfileEditor {
|
||||
var modules: [any ModuleBuilder] {
|
||||
editableProfile.modules
|
||||
|
|
|
@ -26,6 +26,11 @@
|
|||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
||||
protocol ProfileProcessor {
|
||||
func processedProfile(_ profile: Profile) throws -> Profile
|
||||
@MainActor
|
||||
public final class ProfileProcessor: ObservableObject {
|
||||
public let process: (Profile) throws -> Profile
|
||||
|
||||
public init(process: @escaping (Profile) throws -> Profile) {
|
||||
self.process = process
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,12 +30,12 @@ import PassepartoutKit
|
|||
@MainActor
|
||||
extension Tunnel {
|
||||
func install(_ profile: Profile, processor: ProfileProcessor) async throws {
|
||||
let newProfile = try processor.processedProfile(profile)
|
||||
let newProfile = try processor.process(profile)
|
||||
try await install(newProfile, connect: false, title: \.name)
|
||||
}
|
||||
|
||||
func connect(with profile: Profile, processor: ProfileProcessor) async throws {
|
||||
let newProfile = try processor.processedProfile(profile)
|
||||
let newProfile = try processor.process(profile)
|
||||
try await install(newProfile, connect: true, title: \.name)
|
||||
}
|
||||
|
||||
|
|
|
@ -58,14 +58,10 @@ struct EditableProfile: MutableProfileType {
|
|||
|
||||
builder.modulesMetadata = modulesMetadata?.reduce(into: [:]) {
|
||||
var metadata = $1.value
|
||||
guard let name = metadata.name else {
|
||||
return
|
||||
if var trimmedName = metadata.name {
|
||||
trimmedName = trimmedName.trimmingCharacters(in: .whitespaces)
|
||||
metadata.name = !trimmedName.isEmpty ? trimmedName : nil
|
||||
}
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedName.isEmpty else {
|
||||
return
|
||||
}
|
||||
metadata.name = trimmedName
|
||||
$0[$1.key] = metadata
|
||||
}
|
||||
|
||||
|
|
|
@ -81,7 +81,7 @@ struct Issue: Identifiable {
|
|||
.replacingOccurrences(of: "$appLine", with: appLine ?? "unknown")
|
||||
.replacingOccurrences(of: "$osLine", with: osLine)
|
||||
.replacingOccurrences(of: "$deviceLine", with: deviceLine ?? "unknown")
|
||||
// FIXME: #614, replace with provider later
|
||||
// FIXME: #703, report provider in issue
|
||||
.replacingOccurrences(of: "$providerName", with: "none")
|
||||
.replacingOccurrences(of: "$providerLastUpdate", with: "unknown")
|
||||
.replacingOccurrences(of: "$purchasedProducts", with: purchasedProducts.map(\.rawValue).description)
|
||||
|
|
|
@ -30,7 +30,7 @@ extension ModuleBuilder {
|
|||
|
||||
@MainActor
|
||||
func description(inEditor editor: ProfileEditor) -> String {
|
||||
editor.displayName(forModuleWithId: id) ?? typeDescription
|
||||
editor.profile.displayName(forModuleWithId: id) ?? typeDescription
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -144,6 +144,31 @@ extension OnDemandModule.Policy: LocalizableEntity {
|
|||
}
|
||||
}
|
||||
|
||||
extension VPNServer {
|
||||
public var sortableRegion: String {
|
||||
[countryCodes.first?.localizedAsRegionCode, area]
|
||||
.compactMap { $0 }
|
||||
.joined(separator: " - ")
|
||||
}
|
||||
|
||||
public var sortableAddresses: String {
|
||||
if let hostname {
|
||||
return hostname
|
||||
}
|
||||
if let ipAddresses {
|
||||
return ipAddresses
|
||||
.compactMap {
|
||||
guard let address = Address(data: $0) else {
|
||||
return nil
|
||||
}
|
||||
return address.description
|
||||
}
|
||||
.joined(separator: ", ")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
extension OpenVPN.Credentials.OTPMethod: StyledLocalizableEntity {
|
||||
public enum Style {
|
||||
case entity
|
||||
|
|
|
@ -243,6 +243,8 @@ public enum Strings {
|
|||
public static let profile = Strings.tr("Localizable", "global.profile", fallback: "Profile")
|
||||
/// Protocol
|
||||
public static let `protocol` = Strings.tr("Localizable", "global.protocol", fallback: "Protocol")
|
||||
/// Provider
|
||||
public static let provider = Strings.tr("Localizable", "global.provider", fallback: "Provider")
|
||||
/// Public key
|
||||
public static let publicKey = Strings.tr("Localizable", "global.public_key", fallback: "Public key")
|
||||
/// Purchase
|
||||
|
@ -585,6 +587,12 @@ public enum Strings {
|
|||
public static let newProfile = Strings.tr("Localizable", "views.profiles.toolbar.new_profile", fallback: "New profile")
|
||||
}
|
||||
}
|
||||
public enum Provider {
|
||||
public enum Vpn {
|
||||
/// Refresh infrastructure
|
||||
public static let refreshInfrastructure = Strings.tr("Localizable", "views.provider.vpn.refresh_infrastructure", fallback: "Refresh infrastructure")
|
||||
}
|
||||
}
|
||||
public enum Settings {
|
||||
public enum Rows {
|
||||
/// Ask before quit
|
||||
|
|
|
@ -29,8 +29,6 @@ import Foundation
|
|||
import PassepartoutKit
|
||||
import UtilsLibrary
|
||||
|
||||
// MARK: AppContext
|
||||
|
||||
extension AppContext {
|
||||
public static let mock: AppContext = .mock(withRegistry: Registry())
|
||||
|
||||
|
@ -56,9 +54,16 @@ extension AppContext {
|
|||
}
|
||||
return ProfileManager(profiles: profiles)
|
||||
}(),
|
||||
profileProcessor: ProfileProcessor {
|
||||
try $0.withProviderModules()
|
||||
},
|
||||
tunnel: Tunnel(strategy: FakeTunnelStrategy(environment: env)),
|
||||
tunnelEnvironment: env,
|
||||
registry: registry,
|
||||
providerFactory: ProviderFactory(
|
||||
providerManager: ProviderManager(repository: InMemoryProviderRepository()),
|
||||
vpnProviderManager: VPNProviderManager(repository: InMemoryVPNProviderRepository())
|
||||
),
|
||||
constants: .shared
|
||||
)
|
||||
}
|
||||
|
@ -76,6 +81,18 @@ extension ProfileManager {
|
|||
}
|
||||
}
|
||||
|
||||
extension ProviderFactory {
|
||||
public static var mock: ProviderFactory {
|
||||
AppContext.mock.providerFactory
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileProcessor {
|
||||
public static var mock: ProfileProcessor {
|
||||
AppContext.mock.profileProcessor
|
||||
}
|
||||
}
|
||||
|
||||
extension Tunnel {
|
||||
public static var mock: Tunnel {
|
||||
AppContext.mock.tunnel
|
||||
|
|
|
@ -47,6 +47,7 @@
|
|||
"global.private_key" = "Private key";
|
||||
"global.profile" = "Profile";
|
||||
"global.protocol" = "Protocol";
|
||||
"global.provider" = "Provider";
|
||||
"global.public_key" = "Public key";
|
||||
"global.purchase" = "Purchase";
|
||||
"global.remove" = "Delete";
|
||||
|
@ -127,6 +128,8 @@
|
|||
"views.profile.rows.add_module" = "Add module";
|
||||
"views.profile.module_list.section.footer" = "Drag modules to rearrange them, as their order determines priority.";
|
||||
|
||||
"views.provider.vpn.refresh_infrastructure" = "Refresh infrastructure";
|
||||
|
||||
"views.settings.sections.icloud.footer" = "To erase the iCloud store securely, do so on all your synced devices. This will not affect local profiles.";
|
||||
"views.settings.rows.confirm_quit" = "Ask before quit";
|
||||
"views.settings.rows.lock_in_background" = "Lock in background";
|
||||
|
|
|
@ -30,7 +30,7 @@ import UtilsLibrary
|
|||
|
||||
extension DNSModule.Builder: ModuleViewProviding {
|
||||
func moduleView(with editor: ProfileEditor) -> some View {
|
||||
DNSView(editor: editor, original: self)
|
||||
DNSView(editor: editor, module: self)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -45,9 +45,9 @@ private struct DNSView: View {
|
|||
@Binding
|
||||
private var draft: DNSModule.Builder
|
||||
|
||||
init(editor: ProfileEditor, original: DNSModule.Builder) {
|
||||
init(editor: ProfileEditor, module: DNSModule.Builder) {
|
||||
self.editor = editor
|
||||
_draft = editor.binding(forModule: original)
|
||||
_draft = editor.binding(forModule: module)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
@ -62,7 +62,7 @@ private struct DNSView: View {
|
|||
.labelsHidden()
|
||||
}
|
||||
.themeManualInput()
|
||||
.asModuleView(with: editor, draft: draft)
|
||||
.moduleView(editor: editor, draft: draft)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -29,7 +29,7 @@ import UtilsLibrary
|
|||
|
||||
extension HTTPProxyModule.Builder: ModuleViewProviding {
|
||||
func moduleView(with editor: ProfileEditor) -> some View {
|
||||
HTTPProxyView(editor: editor, original: self)
|
||||
HTTPProxyView(editor: editor, module: self)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -44,9 +44,9 @@ private struct HTTPProxyView: View {
|
|||
@Binding
|
||||
private var draft: HTTPProxyModule.Builder
|
||||
|
||||
init(editor: ProfileEditor, original: HTTPProxyModule.Builder) {
|
||||
init(editor: ProfileEditor, module: HTTPProxyModule.Builder) {
|
||||
self.editor = editor
|
||||
_draft = editor.binding(forModule: original)
|
||||
_draft = editor.binding(forModule: module)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
@ -58,7 +58,7 @@ private struct HTTPProxyView: View {
|
|||
}
|
||||
.labelsHidden()
|
||||
.themeManualInput()
|
||||
.asModuleView(with: editor, draft: draft)
|
||||
.moduleView(editor: editor, draft: draft)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -111,7 +111,7 @@ private extension IPView.RouteView {
|
|||
Button("Add route") {
|
||||
isPresented = true
|
||||
}
|
||||
.sheet(isPresented: $isPresented) {
|
||||
.themeModal(isPresented: $isPresented) {
|
||||
NavigationStack {
|
||||
IPView.RouteView(family: .v4) {
|
||||
route = $0
|
||||
|
|
|
@ -29,7 +29,7 @@ import UtilsLibrary
|
|||
|
||||
extension IPModule.Builder: ModuleViewProviding {
|
||||
func moduleView(with editor: ProfileEditor) -> some View {
|
||||
IPView(editor: editor, original: self)
|
||||
IPView(editor: editor, module: self)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -44,9 +44,9 @@ struct IPView: View {
|
|||
@State
|
||||
private var routePresentation: RoutePresentation?
|
||||
|
||||
init(editor: ProfileEditor, original: IPModule.Builder) {
|
||||
init(editor: ProfileEditor, module: IPModule.Builder) {
|
||||
self.editor = editor
|
||||
_draft = editor.binding(forModule: original)
|
||||
_draft = editor.binding(forModule: module)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
@ -55,7 +55,7 @@ struct IPView: View {
|
|||
ipSections(for: .v6)
|
||||
interfaceSection
|
||||
}
|
||||
.asModuleView(with: editor, draft: draft)
|
||||
.moduleView(editor: editor, draft: draft)
|
||||
.themeModal(item: $routePresentation, content: routeModal)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,7 +29,7 @@ import UtilsLibrary
|
|||
|
||||
extension OnDemandModule.Builder: ModuleViewProviding {
|
||||
func moduleView(with editor: ProfileEditor) -> some View {
|
||||
OnDemandView(editor: editor, original: self)
|
||||
OnDemandView(editor: editor, module: self)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -54,12 +54,12 @@ private struct OnDemandView: View {
|
|||
|
||||
init(
|
||||
editor: ProfileEditor,
|
||||
original: OnDemandModule.Builder,
|
||||
module: OnDemandModule.Builder,
|
||||
observer: WifiObserver? = nil
|
||||
) {
|
||||
self.editor = editor
|
||||
wifi = Wifi(observer: observer ?? CoreLocationWifiObserver())
|
||||
_draft = editor.binding(forModule: original)
|
||||
_draft = editor.binding(forModule: module)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
@ -67,7 +67,7 @@ private struct OnDemandView: View {
|
|||
enabledSection
|
||||
restrictedArea
|
||||
}
|
||||
.asModuleView(with: editor, draft: draft)
|
||||
.moduleView(editor: editor, draft: draft)
|
||||
.modifier(PaywallModifier(reason: $paywallReason))
|
||||
}
|
||||
}
|
||||
|
@ -257,7 +257,7 @@ private extension OnDemandView {
|
|||
return module.preview {
|
||||
OnDemandView(
|
||||
editor: $0,
|
||||
original: $1,
|
||||
module: $1,
|
||||
observer: MockWifi()
|
||||
)
|
||||
}
|
||||
|
|
|
@ -28,7 +28,7 @@ import SwiftUI
|
|||
|
||||
extension OpenVPNModule.Builder: ModuleViewProviding {
|
||||
func moduleView(with editor: ProfileEditor) -> some View {
|
||||
OpenVPNView(editor: editor, original: self)
|
||||
OpenVPNView(editor: editor, module: self)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -45,9 +45,6 @@ extension OpenVPNModule.Builder: InteractiveViewProviding {
|
|||
}
|
||||
|
||||
struct OpenVPNView: View {
|
||||
private enum Subroute: Hashable {
|
||||
case credentials
|
||||
}
|
||||
|
||||
@ObservedObject
|
||||
private var editor: ProfileEditor
|
||||
|
@ -57,22 +54,85 @@ struct OpenVPNView: View {
|
|||
@Binding
|
||||
private var draft: OpenVPNModule.Builder
|
||||
|
||||
@Binding
|
||||
private var providerId: ProviderID?
|
||||
|
||||
@Binding
|
||||
private var providerEntity: VPNEntity<OpenVPN.Configuration>?
|
||||
|
||||
init(serverConfiguration: OpenVPN.Configuration) {
|
||||
let module = OpenVPNModule.Builder(configurationBuilder: serverConfiguration.builder())
|
||||
editor = ProfileEditor(modules: [module])
|
||||
let editor = ProfileEditor(modules: [module])
|
||||
|
||||
self.editor = editor
|
||||
_draft = .constant(module)
|
||||
_providerId = .constant(nil)
|
||||
_providerEntity = .constant(nil)
|
||||
isServerPushed = true
|
||||
}
|
||||
|
||||
init(editor: ProfileEditor, original: OpenVPNModule.Builder) {
|
||||
init(editor: ProfileEditor, module: OpenVPNModule.Builder) {
|
||||
self.editor = editor
|
||||
_draft = editor.binding(forModule: original)
|
||||
_draft = editor.binding(forModule: module)
|
||||
_providerId = editor.binding(forProviderOf: module.id)
|
||||
_providerEntity = editor.binding(forProviderEntityOf: module.id)
|
||||
isServerPushed = false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
debugChanges()
|
||||
return contentView
|
||||
.modifier(providerModifier)
|
||||
.moduleView(editor: editor, draft: draft, withName: !isServerPushed)
|
||||
.navigationDestination(for: Subroute.self, destination: destination)
|
||||
.themeAnimation(on: providerId, category: .modules)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
private extension OpenVPNView {
|
||||
var configuration: OpenVPN.Configuration.Builder {
|
||||
draft.configurationBuilder
|
||||
}
|
||||
|
||||
var providerModifier: some ViewModifier {
|
||||
ProviderPanelModifier(
|
||||
providerId: $providerId,
|
||||
selectedEntity: $providerEntity,
|
||||
providerContent: providerContentView
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var contentView: some View {
|
||||
credentialsView
|
||||
if providerId == nil {
|
||||
manualView
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func providerContentView(providerId: ProviderID, entity: VPNEntity<OpenVPN.Configuration>?) -> some View {
|
||||
NavigationLink(value: Subroute.providerServer(id: providerId)) {
|
||||
HStack {
|
||||
Text("Server")
|
||||
if let entity {
|
||||
Spacer()
|
||||
Text(entity.server.hostname ?? entity.server.serverId)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
credentialsView
|
||||
}
|
||||
|
||||
var credentialsView: some View {
|
||||
moduleSection(for: accountRows, header: Strings.Global.account)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var manualView: some View {
|
||||
moduleSection(for: remotesRows, header: Strings.Modules.Openvpn.remotes)
|
||||
if !isServerPushed {
|
||||
moduleSection(for: pullRows, header: Strings.Modules.Openvpn.pull)
|
||||
|
@ -95,9 +155,36 @@ struct OpenVPNView: View {
|
|||
}
|
||||
moduleSection(for: otherRows, header: Strings.Global.other)
|
||||
}
|
||||
.asModuleView(with: editor, draft: draft, withName: !isServerPushed)
|
||||
.navigationDestination(for: Subroute.self) { route in
|
||||
}
|
||||
|
||||
private extension OpenVPNView {
|
||||
func onSelect(server: VPNServer, preset: VPNPreset<OpenVPN.Configuration>) {
|
||||
providerEntity = VPNEntity(server: server, preset: preset)
|
||||
}
|
||||
|
||||
func importConfiguration(from url: URL) {
|
||||
// TODO: #657, import draft from external URL
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Destinations
|
||||
|
||||
private extension OpenVPNView {
|
||||
enum Subroute: Hashable {
|
||||
case providerServer(id: ProviderID)
|
||||
|
||||
case credentials
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func destination(for route: Subroute) -> some View {
|
||||
switch route {
|
||||
case .providerServer(let id):
|
||||
VPNProviderServerView<OpenVPN.Configuration>(
|
||||
providerId: id,
|
||||
onSelect: onSelect
|
||||
)
|
||||
|
||||
case .credentials:
|
||||
CredentialsView(
|
||||
isInteractive: $draft.isInteractive,
|
||||
|
@ -105,23 +192,13 @@ struct OpenVPNView: View {
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
private extension OpenVPNView {
|
||||
var configuration: OpenVPN.Configuration.Builder {
|
||||
draft.configurationBuilder
|
||||
}
|
||||
|
||||
var pullRows: [ModuleRow]? {
|
||||
configuration.pullMask?.map {
|
||||
.text(caption: $0.localizedDescription, value: nil)
|
||||
}
|
||||
.nilIfEmpty
|
||||
}
|
||||
|
||||
var accountRows: [ModuleRow]? {
|
||||
guard configuration.authUserPass == true else {
|
||||
guard configuration.authUserPass == true || providerId != nil else {
|
||||
return nil
|
||||
}
|
||||
return [.push(caption: Strings.Modules.Openvpn.credentials, route: HashableRoute(Subroute.credentials))]
|
||||
|
@ -136,6 +213,13 @@ private extension OpenVPNView {
|
|||
.nilIfEmpty
|
||||
}
|
||||
|
||||
var pullRows: [ModuleRow]? {
|
||||
configuration.pullMask?.map {
|
||||
.text(caption: $0.localizedDescription, value: nil)
|
||||
}
|
||||
.nilIfEmpty
|
||||
}
|
||||
|
||||
func ipRows(for ip: IPSettings?, routes: [Route]?) -> [ModuleRow]? {
|
||||
var rows: [ModuleRow] = []
|
||||
if let ip {
|
||||
|
@ -319,12 +403,6 @@ private extension OpenVPNView {
|
|||
}
|
||||
}
|
||||
|
||||
private extension OpenVPNView {
|
||||
func importConfiguration(from url: URL) {
|
||||
// TODO: #657, import draft from external URL
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
// swiftlint: disable force_try
|
||||
|
|
|
@ -29,11 +29,14 @@ import SwiftUI
|
|||
|
||||
extension WireGuardModule.Builder: ModuleViewProviding {
|
||||
func moduleView(with editor: ProfileEditor) -> some View {
|
||||
WireGuardView(editor: editor, original: self)
|
||||
WireGuardView(editor: editor, module: self)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WireGuardView: View {
|
||||
private enum Subroute: Hashable {
|
||||
case providerServer(id: ProviderID)
|
||||
}
|
||||
|
||||
@ObservedObject
|
||||
private var editor: ProfileEditor
|
||||
|
@ -41,28 +44,64 @@ private struct WireGuardView: View {
|
|||
@Binding
|
||||
private var draft: WireGuardModule.Builder
|
||||
|
||||
init(editor: ProfileEditor, original: WireGuardModule.Builder) {
|
||||
// @Binding
|
||||
// private var providerId: ProviderID?
|
||||
//
|
||||
// @State
|
||||
// private var providerServer: VPNServer?
|
||||
|
||||
init(editor: ProfileEditor, module: WireGuardModule.Builder) {
|
||||
self.editor = editor
|
||||
_draft = editor.binding(forModule: original)
|
||||
_draft = editor.binding(forModule: module)
|
||||
// _providerId = editor.binding(forProviderOf: module.id)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
moduleSection(for: interfaceRows, header: Strings.Modules.Wireguard.interface)
|
||||
moduleSection(for: dnsRows, header: Strings.Unlocalized.dns)
|
||||
ForEach(Array(zip(configuration.peers.indices, configuration.peers)), id: \.1.publicKey) { index, peer in
|
||||
moduleSection(for: peersRows(for: peer), header: Strings.Modules.Wireguard.peer(index + 1))
|
||||
}
|
||||
}
|
||||
.asModuleView(with: editor, draft: draft)
|
||||
contentView
|
||||
// .modifier(providerModifier)
|
||||
.moduleView(editor: editor, draft: draft)
|
||||
// .navigationDestination(for: Subroute.self) {
|
||||
// switch $0 {
|
||||
// case .providerServer(let id):
|
||||
// VPNProviderServerView<WireGuard.Configuration>(providerId: id) {
|
||||
// providerServer = $1
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
private extension WireGuardView {
|
||||
var configuration: WireGuard.Configuration.Builder {
|
||||
draft.configurationBuilder
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var contentView: some View {
|
||||
moduleSection(for: interfaceRows, header: Strings.Modules.Wireguard.interface)
|
||||
moduleSection(for: dnsRows, header: Strings.Unlocalized.dns)
|
||||
ForEach(Array(zip(configuration.peers.indices, configuration.peers)), id: \.1.publicKey) { index, peer in
|
||||
moduleSection(for: peersRows(for: peer), header: Strings.Modules.Wireguard.peer(index + 1))
|
||||
}
|
||||
}
|
||||
|
||||
// var providerModifier: some ViewModifier {
|
||||
// ProviderPanelModifier(
|
||||
// providerId: $providerId,
|
||||
// selectedServer: $providerServer,
|
||||
// configurationType: WireGuard.Configuration.self,
|
||||
// serverRoute: {
|
||||
// Subroute.providerServer(id: $0)
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
private extension WireGuardView {
|
||||
var interfaceRows: [ModuleRow]? {
|
||||
var rows: [ModuleRow] = []
|
||||
rows.append(.longContent(caption: Strings.Global.privateKey, value: configuration.interface.privateKey))
|
||||
|
|
|
@ -49,7 +49,7 @@ struct ProfileEditView: View, Routable {
|
|||
debugChanges()
|
||||
return List {
|
||||
NameSection(
|
||||
name: $profileEditor.name,
|
||||
name: $profileEditor.profile.name,
|
||||
placeholder: Strings.Placeholders.Profile.name
|
||||
)
|
||||
Group {
|
||||
|
|
|
@ -64,7 +64,7 @@ struct ModuleListView: View, Routable {
|
|||
}
|
||||
.onDeleteCommand(perform: removeSelectedModule)
|
||||
.toolbar(content: toolbarContent)
|
||||
.navigationTitle(profileEditor.name)
|
||||
.navigationTitle(profileEditor.profile.name)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -35,7 +35,7 @@ struct ProfileGeneralView: View {
|
|||
var body: some View {
|
||||
Form {
|
||||
NameSection(
|
||||
name: $profileEditor.name,
|
||||
name: $profileEditor.profile.name,
|
||||
placeholder: Strings.Placeholders.Profile.name
|
||||
)
|
||||
StorageSection(
|
||||
|
|
|
@ -52,22 +52,3 @@ private extension ProfileEditor {
|
|||
return (provider, module.typeDescription)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
@MainActor
|
||||
func asModuleView<T>(with editor: ProfileEditor, draft: T, withName: Bool = true) -> some View where T: ModuleBuilder, T: Equatable {
|
||||
Form {
|
||||
if withName {
|
||||
NameSection(
|
||||
name: editor.binding(forNameOf: draft.id),
|
||||
placeholder: draft.typeDescription
|
||||
)
|
||||
}
|
||||
self
|
||||
}
|
||||
.themeForm()
|
||||
.themeManualInput()
|
||||
.themeAnimation(on: draft, category: .modules)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -70,12 +70,6 @@ struct HashableRoute: Hashable {
|
|||
}
|
||||
}
|
||||
|
||||
extension Collection {
|
||||
var nilIfEmpty: [Element]? {
|
||||
!isEmpty ? Array(self) : nil
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func moduleSection(for rows: [ModuleRow]?, header: String) -> some View {
|
||||
rows.map { rows in
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
//
|
||||
// ModuleViewModifier.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/9/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
|
||||
|
||||
struct ModuleViewModifier<T>: ViewModifier where T: ModuleBuilder & Equatable {
|
||||
|
||||
@ObservedObject
|
||||
var editor: ProfileEditor
|
||||
|
||||
let draft: T
|
||||
|
||||
let withName: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
Form {
|
||||
if withName {
|
||||
NameSection(
|
||||
name: editor.binding(forNameOf: draft.id),
|
||||
placeholder: draft.typeDescription
|
||||
)
|
||||
}
|
||||
content
|
||||
}
|
||||
.themeForm()
|
||||
.themeManualInput()
|
||||
.themeAnimation(on: draft, category: .modules)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func moduleView<T>(editor: ProfileEditor, draft: T, withName: Bool = true) -> some View where T: ModuleBuilder & Equatable {
|
||||
modifier(ModuleViewModifier(editor: editor, draft: draft, withName: withName))
|
||||
}
|
||||
}
|
|
@ -29,9 +29,25 @@ import SwiftUI
|
|||
extension ProfileEditor {
|
||||
func binding(forNameOf moduleId: UUID) -> Binding<String> {
|
||||
Binding { [weak self] in
|
||||
self?.name(forModuleWithId: moduleId) ?? ""
|
||||
self?.profile.name(forModuleWithId: moduleId) ?? ""
|
||||
} set: { [weak self] in
|
||||
self?.setName($0, forModuleWithId: moduleId)
|
||||
self?.profile.setName($0, forModuleWithId: moduleId)
|
||||
}
|
||||
}
|
||||
|
||||
func binding(forProviderOf moduleId: UUID) -> Binding<ProviderID?> {
|
||||
Binding { [weak self] in
|
||||
self?.profile.providerId(forModuleWithId: moduleId)
|
||||
} set: { [weak self] in
|
||||
self?.profile.setProviderId($0, forModuleWithId: moduleId)
|
||||
}
|
||||
}
|
||||
|
||||
func binding<E>(forProviderEntityOf moduleId: UUID) -> Binding<E?> where E: ProviderEntity & Codable {
|
||||
Binding { [weak self] in
|
||||
try? self?.profile.providerEntity(E.self, forModuleWithId: moduleId)
|
||||
} set: { [weak self] in
|
||||
try? self?.profile.setProviderEntity($0, forModuleWithId: moduleId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,135 @@
|
|||
//
|
||||
// ProviderPanelModifier.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/7/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 AppLibrary
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
struct ProviderPanelModifier<Entity, ProviderContent>: ViewModifier where Entity: ProviderEntity, Entity.Configuration: ProviderConfigurationIdentifiable & Codable, ProviderContent: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var providerManager: ProviderManager
|
||||
|
||||
var apis: [APIMapper] = API.shared
|
||||
|
||||
@Binding
|
||||
var providerId: ProviderID?
|
||||
|
||||
@Binding
|
||||
var selectedEntity: Entity?
|
||||
|
||||
@ViewBuilder
|
||||
let providerContent: (ProviderID, Entity?) -> ProviderContent
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
providerPicker
|
||||
.task {
|
||||
await refreshIndex()
|
||||
}
|
||||
|
||||
if let providerId {
|
||||
providerContent(providerId, selectedEntity)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ProviderPanelModifier {
|
||||
var supportedProviders: [ProviderMetadata] {
|
||||
providerManager.providers.filter {
|
||||
$0.supports(Entity.Configuration.self)
|
||||
}
|
||||
}
|
||||
|
||||
var providersPlusEmpty: [ProviderMetadata] {
|
||||
[ProviderMetadata("", description: Strings.Global.none)] + supportedProviders
|
||||
}
|
||||
|
||||
var providerPicker: some View {
|
||||
let hasProviders = !supportedProviders.isEmpty
|
||||
return Picker(Strings.Global.provider, selection: $providerId) {
|
||||
if hasProviders {
|
||||
ForEach(providersPlusEmpty, id: \.id) {
|
||||
Text($0.description)
|
||||
.tag($0.id.nilIfEmpty)
|
||||
}
|
||||
} else {
|
||||
Text(" ") // enforce constant picker height on iOS
|
||||
.tag(providerId) // tag always exists
|
||||
}
|
||||
}
|
||||
.onChange(of: providerId) { _ in
|
||||
selectedEntity = nil
|
||||
}
|
||||
.disabled(!hasProviders)
|
||||
}
|
||||
}
|
||||
|
||||
private extension ProviderPanelModifier {
|
||||
|
||||
// FIXME: #707, fetch bundled providers on launch
|
||||
// FIXME: #704, rate-limit fetch
|
||||
func refreshIndex() async {
|
||||
do {
|
||||
try await providerManager.fetchIndex(from: apis)
|
||||
} catch {
|
||||
pp_log(.app, .error, "Unable to fetch index: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ProviderID {
|
||||
var nilIfEmpty: ProviderID? {
|
||||
!rawValue.isEmpty ? self : nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
@State
|
||||
var providerId: ProviderID? = .hideme
|
||||
|
||||
@State
|
||||
var vpnEntity: VPNEntity<OpenVPN.Configuration>?
|
||||
|
||||
return List {
|
||||
EmptyView()
|
||||
.modifier(ProviderPanelModifier(
|
||||
apis: [API.bundled],
|
||||
providerId: $providerId,
|
||||
selectedEntity: $vpnEntity,
|
||||
providerContent: { id, entity in
|
||||
HStack {
|
||||
Text("Server")
|
||||
Spacer()
|
||||
Text(entity?.server.serverId ?? "None")
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
.withMockEnvironment()
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
//
|
||||
// VPNFiltersModifier.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/9/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
|
||||
|
||||
struct VPNFiltersModifier<Configuration>: ViewModifier where Configuration: Decodable {
|
||||
|
||||
@ObservedObject
|
||||
var manager: VPNProviderManager
|
||||
|
||||
let providerId: ProviderID
|
||||
|
||||
let onRefresh: () async -> Void
|
||||
|
||||
@State
|
||||
var isFiltersPresented = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
contentView(with: content)
|
||||
.onChange(of: manager.filters) { _ in
|
||||
Task {
|
||||
await manager.applyFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,180 @@
|
|||
//
|
||||
// VPNFiltersView.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/9/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 AppLibrary
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
// FIXME: #703, providers UI
|
||||
|
||||
struct VPNFiltersView<Configuration>: View where Configuration: Decodable {
|
||||
|
||||
@ObservedObject
|
||||
var manager: VPNProviderManager
|
||||
|
||||
let providerId: ProviderID
|
||||
|
||||
let onRefresh: () async -> Void
|
||||
|
||||
@State
|
||||
private var isRefreshing = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
categoryPicker
|
||||
countryPicker
|
||||
presetPicker
|
||||
#if os(iOS)
|
||||
clearFiltersButton
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
#else
|
||||
HStack {
|
||||
Spacer()
|
||||
clearFiltersButton
|
||||
refreshButton
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
Section {
|
||||
refreshButton
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension VPNFiltersView {
|
||||
var categoryPicker: some View {
|
||||
Picker("Category", selection: $manager.filters.categoryName) {
|
||||
Text("Any")
|
||||
.tag(nil as String?)
|
||||
ForEach(categories, id: \.self) {
|
||||
Text($0.capitalized)
|
||||
.tag($0 as String?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var countryPicker: some View {
|
||||
Picker("Country", selection: $manager.filters.countryCode) {
|
||||
Text("Any")
|
||||
.tag(nil as String?)
|
||||
ForEach(countries, id: \.code) {
|
||||
Text($0.description)
|
||||
.tag($0.code as String?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var presetPicker: some View {
|
||||
if manager.anyPresets.count > 1 {
|
||||
Picker("Preset", selection: $manager.filters.presetId) {
|
||||
Text("Any")
|
||||
.tag(nil as String?)
|
||||
ForEach(presets, id: \.presetId) {
|
||||
Text($0.description)
|
||||
.tag($0.presetId as String?)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var clearFiltersButton: some View {
|
||||
Button("Clear filters", role: .destructive) {
|
||||
Task {
|
||||
await manager.resetFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var refreshButton: some View {
|
||||
Button {
|
||||
Task {
|
||||
isRefreshing = true
|
||||
await onRefresh()
|
||||
isRefreshing = false
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(Strings.Views.Provider.Vpn.refreshInfrastructure)
|
||||
#if os(iOS)
|
||||
if isRefreshing {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.disabled(isRefreshing)
|
||||
}
|
||||
}
|
||||
|
||||
private extension VPNFiltersView {
|
||||
var categories: [String] {
|
||||
let allCategories = manager
|
||||
.allServers
|
||||
.values
|
||||
.map(\.provider.categoryName)
|
||||
|
||||
return Set(allCategories)
|
||||
.sorted()
|
||||
}
|
||||
|
||||
var countries: [(code: String, description: String)] {
|
||||
let allCodes = manager
|
||||
.allServers
|
||||
.values
|
||||
.flatMap(\.countryCodes)
|
||||
|
||||
return Set(allCodes)
|
||||
.map {
|
||||
(code: $0, description: $0.localizedAsRegionCode ?? $0)
|
||||
}
|
||||
.sorted {
|
||||
$0.description < $1.description
|
||||
}
|
||||
}
|
||||
|
||||
var presets: [VPNPreset<Configuration>] {
|
||||
manager
|
||||
.presets(ofType: Configuration.self)
|
||||
.sorted {
|
||||
$0.description < $1.description
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
VPNFiltersView<String>(
|
||||
manager: ProviderFactory.mock.vpnProviderManager,
|
||||
providerId: .hideme,
|
||||
onRefresh: {}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,148 @@
|
|||
//
|
||||
// VPNProviderServerView.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/7/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 AppLibrary
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
struct VPNProviderServerView<Configuration>: View where Configuration: ProviderConfigurationIdentifiable & Hashable & Codable {
|
||||
|
||||
@EnvironmentObject
|
||||
private var providerManager: ProviderManager
|
||||
|
||||
@EnvironmentObject
|
||||
private var vpnProviderManager: VPNProviderManager
|
||||
|
||||
@Environment(\.dismiss)
|
||||
private var dismiss
|
||||
|
||||
var apis: [APIMapper] = API.shared
|
||||
|
||||
let providerId: ProviderID
|
||||
|
||||
let onSelect: (_ server: VPNServer, _ preset: VPNPreset<Configuration>) -> Void
|
||||
|
||||
@State
|
||||
private var isLoading = true
|
||||
|
||||
@State
|
||||
var sortOrder = [
|
||||
KeyPathComparator(\VPNServer.sortableRegion)
|
||||
]
|
||||
|
||||
@State
|
||||
var sortedServers: [VPNServer] = []
|
||||
|
||||
// FIXME: #703, flickers on appear
|
||||
var body: some View {
|
||||
serversView
|
||||
.modifier(VPNFiltersModifier<Configuration>(
|
||||
manager: vpnProviderManager,
|
||||
providerId: providerId,
|
||||
onRefresh: {
|
||||
await refreshInfrastructure(for: providerId)
|
||||
}
|
||||
))
|
||||
.themeAnimation(on: isLoading, category: .providers)
|
||||
.navigationTitle(providerMetadata?.description ?? Strings.Global.servers)
|
||||
.task {
|
||||
await loadInfrastructure(for: providerId)
|
||||
}
|
||||
.onReceive(vpnProviderManager.$filteredServers, perform: onFilteredServers)
|
||||
}
|
||||
}
|
||||
|
||||
private extension VPNProviderServerView {
|
||||
var providerMetadata: ProviderMetadata? {
|
||||
providerManager.metadata(withId: providerId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
extension VPNProviderServerView {
|
||||
func onFilteredServers(_ servers: [String: VPNServer]) {
|
||||
sortedServers = servers
|
||||
.values
|
||||
.sorted(using: sortOrder)
|
||||
}
|
||||
|
||||
func selectServer(_ server: VPNServer) {
|
||||
guard let preset = compatiblePreset(with: server) else {
|
||||
// FIXME: #703, alert select a preset
|
||||
return
|
||||
}
|
||||
onSelect(server, preset)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private extension VPNProviderServerView {
|
||||
func compatiblePreset(with server: VPNServer) -> VPNPreset<Configuration>? {
|
||||
vpnProviderManager
|
||||
.presets(ofType: Configuration.self)
|
||||
.first {
|
||||
if let supportedIds = server.provider.supportedPresetIds {
|
||||
return supportedIds.contains($0.presetId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func loadInfrastructure(for providerId: ProviderID) async {
|
||||
await vpnProviderManager.setProvider(providerId)
|
||||
if await vpnProviderManager.lastUpdated() == nil {
|
||||
await refreshInfrastructure(for: providerId)
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
// FIXME: #704, rate-limit fetch
|
||||
func refreshInfrastructure(for providerId: ProviderID) async {
|
||||
do {
|
||||
isLoading = true
|
||||
try await vpnProviderManager.fetchInfrastructure(
|
||||
from: apis,
|
||||
for: providerId,
|
||||
ofType: Configuration.self
|
||||
)
|
||||
isLoading = false
|
||||
} catch {
|
||||
// FIXME: #703, alert unable to refresh infrastructure
|
||||
pp_log(.app, .error, "Unable to refresh infrastructure: \(error)")
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
VPNProviderServerView<OpenVPN.Configuration>(apis: [API.bundled], providerId: .protonvpn) { _, _ in
|
||||
}
|
||||
}
|
||||
.withMockEnvironment()
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
//
|
||||
// VPNFiltersModifier+iOS.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/9/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/>.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// FIXME: #703, providers UI
|
||||
|
||||
extension VPNFiltersModifier {
|
||||
func contentView(with content: Content) -> some View {
|
||||
List {
|
||||
content
|
||||
}
|
||||
.toolbar {
|
||||
Button {
|
||||
isFiltersPresented = true
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal.decrease")
|
||||
}
|
||||
}
|
||||
.themeModal(isPresented: $isFiltersPresented) {
|
||||
NavigationStack {
|
||||
VPNFiltersView<Configuration>(manager: manager, providerId: providerId, onRefresh: onRefresh)
|
||||
.navigationTitle("Filters")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -0,0 +1,44 @@
|
|||
//
|
||||
// VPNProviderServerView+iOS.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/9/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/>.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// FIXME: #703, providers UI
|
||||
|
||||
extension VPNProviderServerView {
|
||||
var serversView: some View {
|
||||
sortedServers.nilIfEmpty.map { servers in
|
||||
ForEach(sortedServers) { server in
|
||||
Button("\(server.hostname ?? server.id) \(server.countryCodes)") {
|
||||
selectServer(server)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -0,0 +1,40 @@
|
|||
//
|
||||
// VPNFiltersModifier+macOS.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/9/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/>.
|
||||
//
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension VPNFiltersModifier {
|
||||
func contentView(with content: Content) -> some View {
|
||||
VStack {
|
||||
VPNFiltersView<Configuration>(manager: manager, providerId: providerId, onRefresh: onRefresh)
|
||||
.padding()
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -0,0 +1,52 @@
|
|||
//
|
||||
// VPNProviderServerView+macOS.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/9/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/>.
|
||||
//
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// FIXME: #703, providers UI
|
||||
|
||||
extension VPNProviderServerView {
|
||||
var serversView: some View {
|
||||
Table(sortedServers, sortOrder: $sortOrder) {
|
||||
TableColumn("Region", value: \.sortableRegion)
|
||||
.width(max: 200.0)
|
||||
|
||||
TableColumn("Address", value: \.sortableAddresses)
|
||||
|
||||
TableColumn("", value: \.serverId) { server in
|
||||
Button {
|
||||
selectServer(server)
|
||||
} label: {
|
||||
Text("Select")
|
||||
}
|
||||
}
|
||||
.width(min: 100.0, max: 100.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -329,13 +329,15 @@ struct ThemeTipModifier: ViewModifier {
|
|||
// MARK: - Views
|
||||
|
||||
public enum ThemeAnimationCategory: CaseIterable {
|
||||
case diagnostics
|
||||
|
||||
case modules
|
||||
|
||||
case profiles
|
||||
|
||||
case profilesLayout
|
||||
|
||||
case modules
|
||||
|
||||
case diagnostics
|
||||
case providers
|
||||
}
|
||||
|
||||
struct ThemeImage: View {
|
||||
|
|
|
@ -30,7 +30,7 @@ import SwiftUI
|
|||
extension Theme {
|
||||
public convenience init() {
|
||||
self.init(dummy: ())
|
||||
animationCategories = [.profiles, .modules, .diagnostics]
|
||||
animationCategories = [.diagnostics, .modules, .profiles, .providers]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -32,7 +32,7 @@ extension Theme {
|
|||
self.init(dummy: Void())
|
||||
rootModalSize = CGSize(width: 700, height: 400)
|
||||
secondaryModalSize = CGSize(width: 500.0, height: 200.0)
|
||||
animationCategories = [.profiles, .diagnostics]
|
||||
animationCategories = [.diagnostics, .profiles, .providers]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -75,7 +75,7 @@ private extension ConnectionStatusView {
|
|||
#Preview("Connected") {
|
||||
ConnectionStatusView(tunnel: .mock)
|
||||
.task {
|
||||
try? await Tunnel.mock.connect(with: .mock, processor: IAPManager.mock)
|
||||
try? await Tunnel.mock.connect(with: .mock, processor: .mock)
|
||||
}
|
||||
.frame(width: 100, height: 100)
|
||||
.withMockEnvironment()
|
||||
|
@ -94,7 +94,7 @@ private extension ConnectionStatusView {
|
|||
}
|
||||
return ConnectionStatusView(tunnel: .mock)
|
||||
.task {
|
||||
try? await Tunnel.mock.connect(with: profile, processor: IAPManager.mock)
|
||||
try? await Tunnel.mock.connect(with: profile, processor: .mock)
|
||||
}
|
||||
.frame(width: 100, height: 100)
|
||||
.withMockEnvironment()
|
||||
|
|
|
@ -43,7 +43,7 @@ struct StorageSection: View {
|
|||
sharingToggle
|
||||
ThemeCopiableText(
|
||||
title: Strings.Unlocalized.uuid,
|
||||
value: profileEditor.id.flatString.localizedDescription(style: .quartets),
|
||||
value: profileEditor.profile.id.flatString.localizedDescription(style: .quartets),
|
||||
valueView: {
|
||||
Text($0)
|
||||
.monospaced()
|
||||
|
|
|
@ -30,7 +30,7 @@ import UtilsLibrary
|
|||
struct TunnelRestartButton<Label>: View where Label: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var iapManager: IAPManager
|
||||
private var profileProcessor: ProfileProcessor
|
||||
|
||||
@ObservedObject
|
||||
var tunnel: Tunnel
|
||||
|
@ -55,7 +55,7 @@ struct TunnelRestartButton<Label>: View where Label: View {
|
|||
pendingTask?.cancel()
|
||||
pendingTask = Task {
|
||||
do {
|
||||
try await tunnel.connect(with: profile, processor: iapManager)
|
||||
try await tunnel.connect(with: profile, processor: profileProcessor)
|
||||
} catch {
|
||||
errorHandler.handle(
|
||||
error,
|
||||
|
|
|
@ -43,6 +43,9 @@ struct TunnelToggleButton<Label>: View, TunnelContextProviding, ThemeProviding w
|
|||
@EnvironmentObject
|
||||
private var iapManager: IAPManager
|
||||
|
||||
@EnvironmentObject
|
||||
private var profileProcessor: ProfileProcessor
|
||||
|
||||
var style: Style = .plain
|
||||
|
||||
@ObservedObject
|
||||
|
@ -127,12 +130,12 @@ private extension TunnelToggleButton {
|
|||
do {
|
||||
if isInstalled {
|
||||
if canConnect {
|
||||
try await tunnel.connect(with: profile, processor: iapManager)
|
||||
try await tunnel.connect(with: profile, processor: profileProcessor)
|
||||
} else {
|
||||
try await tunnel.disconnect()
|
||||
}
|
||||
} else {
|
||||
try await tunnel.connect(with: profile, processor: iapManager)
|
||||
try await tunnel.connect(with: profile, processor: profileProcessor)
|
||||
}
|
||||
} catch {
|
||||
errorHandler.handle(
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import AppLibrary
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
|
@ -31,6 +32,9 @@ extension View {
|
|||
environmentObject(theme)
|
||||
.environmentObject(context.iapManager)
|
||||
.environmentObject(context.connectionObserver)
|
||||
.environmentObject(context.providerFactory.providerManager)
|
||||
.environmentObject(context.providerFactory.vpnProviderManager)
|
||||
.environmentObject(context.profileProcessor)
|
||||
}
|
||||
|
||||
public func withMockEnvironment() -> some View {
|
||||
|
|
|
@ -38,6 +38,10 @@ public struct Constants: Decodable, Sendable {
|
|||
public struct Websites: Decodable, Sendable {
|
||||
public let home: URL
|
||||
|
||||
public var api: URL {
|
||||
home.appendingPathComponent("api/")
|
||||
}
|
||||
|
||||
public var faq: URL {
|
||||
home.appendingPathComponent("faq/")
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import PassepartoutAPIBundle
|
||||
import PassepartoutKit
|
||||
import PassepartoutWireGuardGo
|
||||
|
||||
|
@ -55,3 +56,38 @@ 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 = API.bundledURL 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)
|
||||
}()
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// Array+Extensions.swift
|
||||
// Collection+Extensions.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 8/18/24.
|
||||
|
@ -30,3 +30,9 @@ extension Array where Element == String {
|
|||
last?.trimmingCharacters(in: .whitespaces) == ""
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection {
|
||||
public var nilIfEmpty: [Element]? {
|
||||
!isEmpty ? Array(self) : nil
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
//
|
||||
// String+Extensions.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/8/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
|
||||
|
||||
extension String {
|
||||
public var nilIfEmpty: String? {
|
||||
!isEmpty ? self : nil
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
public var localizedAsRegionCode: String? {
|
||||
Locale
|
||||
.current
|
||||
.localizedString(forRegionCode: self)?
|
||||
.capitalized
|
||||
}
|
||||
|
||||
public var localizedAsLanguageCode: String? {
|
||||
Locale
|
||||
.current
|
||||
.localizedString(forLanguageCode: self)?
|
||||
.capitalized
|
||||
}
|
||||
}
|
|
@ -137,7 +137,7 @@ private extension GenericCreditsView {
|
|||
|
||||
var sortedLanguages: [String] {
|
||||
credits.translations.keys.sorted {
|
||||
$0.localizedAsCountryCode < $1.localizedAsCountryCode
|
||||
($0.localizedAsLanguageCode ?? $0) < ($1.localizedAsLanguageCode ?? $1)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -178,7 +178,7 @@ private extension GenericCreditsView {
|
|||
Section {
|
||||
ForEach(sortedLanguages, id: \.self) { code in
|
||||
HStack {
|
||||
Text(code.localizedAsCountryCode)
|
||||
Text(code.localizedAsLanguageCode ?? code)
|
||||
Spacer()
|
||||
credits.translations[code].map { authors in
|
||||
VStack(spacing: 4) {
|
||||
|
@ -205,12 +205,6 @@ private extension GenericCreditsView {
|
|||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var localizedAsCountryCode: String {
|
||||
Locale.current.localizedString(forLanguageCode: self)?.capitalized ?? self
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
@MainActor
|
||||
|
|
|
@ -44,7 +44,7 @@ extension ProfileEditorTests {
|
|||
DNSModule.Builder(),
|
||||
IPModule.Builder()
|
||||
])
|
||||
XCTAssertTrue(sut.name.isEmpty)
|
||||
XCTAssertTrue(sut.profile.name.isEmpty)
|
||||
XCTAssertTrue(sut.modules[0] is DNSModule.Builder)
|
||||
XCTAssertTrue(sut.modules[1] is IPModule.Builder)
|
||||
}
|
||||
|
@ -60,7 +60,7 @@ extension ProfileEditorTests {
|
|||
).tryBuild()
|
||||
|
||||
let sut = ProfileEditor(profile: profile)
|
||||
XCTAssertEqual(sut.name, name)
|
||||
XCTAssertEqual(sut.profile.name, name)
|
||||
XCTAssertTrue(sut.modules[0] is DNSModule.Builder)
|
||||
XCTAssertTrue(sut.modules[1] is IPModule.Builder)
|
||||
XCTAssertEqual(sut.activeModulesIds, [dns.id])
|
||||
|
@ -193,7 +193,7 @@ extension ProfileEditorTests {
|
|||
func test_givenProfile_whenBuild_thenSucceeds() throws {
|
||||
let wg = WireGuardModule.Builder(configurationBuilder: .default)
|
||||
let sut = ProfileEditor(modules: [wg])
|
||||
sut.name = "hello"
|
||||
sut.profile.name = "hello"
|
||||
|
||||
let profile = try sut.build()
|
||||
XCTAssertEqual(profile.name, "hello")
|
||||
|
|
|
@ -27,6 +27,7 @@ import AppData
|
|||
import AppDataProfiles
|
||||
import AppLibrary
|
||||
import AppUI
|
||||
import CommonLibrary
|
||||
import Foundation
|
||||
import PassepartoutKit
|
||||
import UtilsLibrary
|
||||
|
@ -35,9 +36,11 @@ extension AppContext {
|
|||
static let shared = AppContext(
|
||||
iapManager: .shared,
|
||||
profileManager: .shared,
|
||||
profileProcessor: .shared,
|
||||
tunnel: .shared,
|
||||
tunnelEnvironment: .shared,
|
||||
registry: .shared,
|
||||
providerFactory: .shared,
|
||||
constants: .shared
|
||||
)
|
||||
}
|
||||
|
@ -114,6 +117,34 @@ extension IAPManager {
|
|||
}
|
||||
}
|
||||
|
||||
extension ProfileProcessor {
|
||||
static let shared = ProfileProcessor { profile in
|
||||
var builder = profile.builder()
|
||||
|
||||
// suppress on-demand rules if not eligible
|
||||
if !IAPManager.shared.isEligible(for: .onDemand) {
|
||||
pp_log(.app, .notice, "Suppress on-demand rules, not eligible")
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
let processed = try builder.tryBuild()
|
||||
do {
|
||||
return try processed.withProviderModules()
|
||||
} catch {
|
||||
// FIXME: #703, alert unable to build provider server
|
||||
pp_log(.app, .error, "Unable to inject provider modules: \(error)")
|
||||
return processed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
|
@ -167,6 +198,16 @@ private var neRepository: NETunnelManagerRepository {
|
|||
|
||||
// MARK: -
|
||||
|
||||
// FIXME: #705, store providers to Core Data
|
||||
extension ProviderFactory {
|
||||
static let shared = ProviderFactory(
|
||||
providerManager: ProviderManager(repository: InMemoryProviderRepository()),
|
||||
vpnProviderManager: VPNProviderManager(repository: InMemoryVPNProviderRepository())
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
extension CoreDataPersistentStoreLogger where Self == DefaultCoreDataPersistentStoreLogger {
|
||||
static var `default`: CoreDataPersistentStoreLogger {
|
||||
DefaultCoreDataPersistentStoreLogger()
|
||||
|
|
Loading…
Reference in New Issue