mirror of
https://github.com/passepartoutvpn/passepartout-apple.git
synced 2025-01-31 04:52:05 +00:00
Exclude OpenVPN endpoints (#987)
Exclude endpoints from OpenVPN modules and providers with the generic Blacklist<T> observable. Eventually, rebuild the Profile in PacketTunnelProvider (via DefaultTunnelProcessor) with the applied exclusions from preferences. Revisit approach to preferences: - Module preferences - Tied to the module and therefore to the parent profile - Load/save in ProfileEditor on request (rather than on ProfileEditor.load) - Provider preferences - Shared globally across profiles - Load/save in module view if needed For more consistency with Core Data: - Revert to observables for both module and provider preferences - Treat excluded endpoints as relationships rather than a serialized Array - Add/remove single relationships over bulk delete + re-add - Do not map the relationships, Blacklist only needs exists/add/remove: - isExcludedEndpoint - addExcludedEndpoint - removeExcludedEndpoint Some clean-up: - Move the importer logic to OpenVPNView.ImportModifier - Move the preview data to OpenVPN.Configuration.Builder.forPreviews - Drop objectWillChange.send() on .repository didSet to avoid potential recursion during SwiftUI updates Closes #971
This commit is contained in:
parent
5f20d791c2
commit
fae0200995
@ -1,8 +1,8 @@
|
||||
//
|
||||
// UUID+RawRepresentable.swift
|
||||
// CDExcludedEndpoint.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 12/4/24.
|
||||
// Created by Davide De Rosa on 12/8/24.
|
||||
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
|
||||
//
|
||||
// https://github.com/passepartoutvpn
|
||||
@ -23,14 +23,16 @@
|
||||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
extension UUID: @retroactive RawRepresentable {
|
||||
public init?(rawValue: String) {
|
||||
self.init(uuidString: rawValue)
|
||||
@objc(CDExcludedEndpoint)
|
||||
final class CDExcludedEndpoint: NSManagedObject {
|
||||
@nonobjc static func fetchRequest() -> NSFetchRequest<CDExcludedEndpoint> {
|
||||
NSFetchRequest<CDExcludedEndpoint>(entityName: "CDExcludedEndpoint")
|
||||
}
|
||||
|
||||
public var rawValue: String {
|
||||
uuidString
|
||||
}
|
||||
@NSManaged var endpoint: String?
|
||||
@NSManaged var modulePreferences: CDModulePreferencesV3?
|
||||
@NSManaged var providerPreferences: CDProviderPreferencesV3?
|
||||
}
|
@ -33,4 +33,5 @@ final class CDModulePreferencesV3: NSManagedObject {
|
||||
}
|
||||
|
||||
@NSManaged var uuid: UUID?
|
||||
@NSManaged var excludedEndpoints: Set<CDExcludedEndpoint>?
|
||||
}
|
||||
|
@ -34,4 +34,5 @@ final class CDProviderPreferencesV3: NSManagedObject {
|
||||
|
||||
@NSManaged var providerId: String?
|
||||
@NSManaged var favoriteServerIds: Data?
|
||||
@NSManaged var excludedEndpoints: Set<CDExcludedEndpoint>?
|
||||
}
|
||||
|
@ -29,12 +29,27 @@ import Foundation
|
||||
import PassepartoutKit
|
||||
|
||||
struct DomainMapper {
|
||||
func preferences(from entity: CDModulePreferencesV3) throws -> ModulePreferences {
|
||||
ModulePreferences()
|
||||
func excludedEndpoints(from entities: Set<CDExcludedEndpoint>?) -> Set<ExtendedEndpoint> {
|
||||
entities.map {
|
||||
Set($0.compactMap {
|
||||
$0.endpoint.map {
|
||||
ExtendedEndpoint(rawValue: $0)
|
||||
} ?? nil
|
||||
})
|
||||
} ?? []
|
||||
}
|
||||
}
|
||||
|
||||
struct CoreDataMapper {
|
||||
func set(_ entity: CDModulePreferencesV3, from preferences: ModulePreferences) throws {
|
||||
let context: NSManagedObjectContext
|
||||
|
||||
func cdExcludedEndpoint(from endpoint: ExtendedEndpoint) -> CDExcludedEndpoint {
|
||||
let cdEndpoint = CDExcludedEndpoint(context: context)
|
||||
cdEndpoint.endpoint = endpoint.rawValue
|
||||
return cdEndpoint
|
||||
}
|
||||
|
||||
func cdExcludedEndpoints(from endpoints: Set<ExtendedEndpoint>) -> Set<CDExcludedEndpoint> {
|
||||
Set(endpoints.map(cdExcludedEndpoint(from:)))
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23507" systemVersion="23H222" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
|
||||
<entity name="CDExcludedEndpoint" representedClassName="CDExcludedEndpoint" syncable="YES">
|
||||
<attribute name="endpoint" optional="YES" attributeType="String"/>
|
||||
<relationship name="modulePreferences" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CDModulePreferencesV3" inverseName="excludedEndpoints" inverseEntity="CDModulePreferencesV3"/>
|
||||
<relationship name="providerPreferences" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CDProviderPreferencesV3" inverseName="excludedEndpoints" inverseEntity="CDProviderPreferencesV3"/>
|
||||
</entity>
|
||||
<entity name="CDModulePreferencesV3" representedClassName="CDModulePreferencesV3" syncable="YES">
|
||||
<attribute name="uuid" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<relationship name="excludedEndpoints" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="CDExcludedEndpoint" inverseName="modulePreferences" inverseEntity="CDExcludedEndpoint"/>
|
||||
</entity>
|
||||
<entity name="CDProviderPreferencesV3" representedClassName="CDProviderPreferencesV3" syncable="YES">
|
||||
<attribute name="favoriteServerIds" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="providerId" optional="YES" attributeType="String"/>
|
||||
<relationship name="excludedEndpoints" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="CDExcludedEndpoint" inverseName="providerPreferences" inverseEntity="CDExcludedEndpoint"/>
|
||||
</entity>
|
||||
</model>
|
@ -30,74 +30,71 @@ import Foundation
|
||||
import PassepartoutKit
|
||||
|
||||
extension AppData {
|
||||
public static func cdModulePreferencesRepositoryV3(context: NSManagedObjectContext) -> ModulePreferencesRepository {
|
||||
CDModulePreferencesRepositoryV3(context: context)
|
||||
public static func cdModulePreferencesRepositoryV3(context: NSManagedObjectContext, moduleId: UUID) throws -> ModulePreferencesRepository {
|
||||
try CDModulePreferencesRepositoryV3(context: context, moduleId: moduleId)
|
||||
}
|
||||
}
|
||||
|
||||
private final class CDModulePreferencesRepositoryV3: ModulePreferencesRepository {
|
||||
private nonisolated let context: NSManagedObjectContext
|
||||
|
||||
init(context: NSManagedObjectContext) {
|
||||
private let entity: CDModulePreferencesV3
|
||||
|
||||
init(context: NSManagedObjectContext, moduleId: UUID) throws {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func preferences(for moduleIds: [UUID]) throws -> [UUID: ModulePreferences] {
|
||||
try context.performAndWait {
|
||||
entity = try context.performAndWait {
|
||||
let request = CDModulePreferencesV3.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "any uuid in %@", moduleIds.map(\.uuidString))
|
||||
|
||||
let entities = try request.execute()
|
||||
let mapper = DomainMapper()
|
||||
return entities.reduce(into: [:]) {
|
||||
guard let moduleId = $1.uuid else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
let preferences = try mapper.preferences(from: $1)
|
||||
$0[moduleId] = preferences
|
||||
} catch {
|
||||
pp_log(.app, .error, "Unable to load preferences for module \(moduleId): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func set(_ preferences: [UUID: ModulePreferences]) throws {
|
||||
try context.performAndWait {
|
||||
let request = CDModulePreferencesV3.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "any uuid in %@", Array(preferences.keys))
|
||||
|
||||
var entities = try request.execute()
|
||||
let existingIds = entities.compactMap(\.uuid)
|
||||
let newIds = Set(preferences.keys).subtracting(existingIds)
|
||||
newIds.forEach {
|
||||
let newEntity = CDModulePreferencesV3(context: context)
|
||||
newEntity.uuid = $0
|
||||
entities.append(newEntity)
|
||||
}
|
||||
|
||||
let mapper = CoreDataMapper()
|
||||
try entities.forEach {
|
||||
guard let id = $0.uuid, let entityPreferences = preferences[id] else {
|
||||
return
|
||||
}
|
||||
try mapper.set($0, from: entityPreferences)
|
||||
}
|
||||
|
||||
guard context.hasChanges else {
|
||||
return
|
||||
}
|
||||
request.predicate = NSPredicate(format: "uuid == %@", moduleId.uuidString)
|
||||
do {
|
||||
try context.save()
|
||||
let entity = try request.execute().first ?? CDModulePreferencesV3(context: context)
|
||||
entity.uuid = moduleId
|
||||
return entity
|
||||
} catch {
|
||||
context.rollback()
|
||||
pp_log(.app, .error, "Unable to load preferences for module \(moduleId): \(error)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rollback() {
|
||||
context.rollback()
|
||||
func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool {
|
||||
context.performAndWait {
|
||||
entity.excludedEndpoints?.contains {
|
||||
$0.endpoint == endpoint.rawValue
|
||||
} ?? false
|
||||
}
|
||||
}
|
||||
|
||||
func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
|
||||
context.performAndWait {
|
||||
let mapper = CoreDataMapper(context: context)
|
||||
let cdEndpoint = mapper.cdExcludedEndpoint(from: endpoint)
|
||||
cdEndpoint.modulePreferences = entity
|
||||
entity.excludedEndpoints?.insert(cdEndpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
|
||||
context.performAndWait {
|
||||
guard let found = entity.excludedEndpoints?.first(where: {
|
||||
$0.endpoint == endpoint.rawValue
|
||||
}) else {
|
||||
return
|
||||
}
|
||||
entity.excludedEndpoints?.remove(found)
|
||||
context.delete(found)
|
||||
}
|
||||
}
|
||||
|
||||
func save() throws {
|
||||
guard context.hasChanges else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
context.rollback()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -82,6 +82,35 @@ private final class CDProviderPreferencesRepositoryV3: ProviderPreferencesReposi
|
||||
}
|
||||
}
|
||||
|
||||
func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool {
|
||||
context.performAndWait {
|
||||
entity.excludedEndpoints?.contains {
|
||||
$0.endpoint == endpoint.rawValue
|
||||
} ?? false
|
||||
}
|
||||
}
|
||||
|
||||
func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
|
||||
context.performAndWait {
|
||||
let mapper = CoreDataMapper(context: context)
|
||||
let cdEndpoint = mapper.cdExcludedEndpoint(from: endpoint)
|
||||
cdEndpoint.providerPreferences = entity
|
||||
entity.excludedEndpoints?.insert(cdEndpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
|
||||
context.performAndWait {
|
||||
guard let found = entity.excludedEndpoints?.first(where: {
|
||||
$0.endpoint == endpoint.rawValue
|
||||
}) else {
|
||||
return
|
||||
}
|
||||
entity.excludedEndpoints?.remove(found)
|
||||
context.delete(found)
|
||||
}
|
||||
}
|
||||
|
||||
func save() throws {
|
||||
guard context.hasChanges else {
|
||||
return
|
||||
|
@ -229,11 +229,7 @@ extension AppCoordinator {
|
||||
extension AppCoordinator {
|
||||
public func onInteractiveLogin(_ profile: Profile, _ onComplete: @escaping InteractiveManager.CompletionBlock) {
|
||||
pp_log(.app, .info, "Present interactive login")
|
||||
interactiveManager.present(
|
||||
with: profile,
|
||||
preferencesManager: preferencesManager,
|
||||
onComplete: onComplete
|
||||
)
|
||||
interactiveManager.present(with: profile, onComplete: onComplete)
|
||||
}
|
||||
|
||||
public func onProviderEntityRequired(_ profile: Profile, force: Bool) {
|
||||
@ -302,7 +298,7 @@ private extension AppCoordinator {
|
||||
func editProfile(_ profile: EditableProfile, initialModuleId: UUID?) {
|
||||
profilePath = NavigationPath()
|
||||
let isShared = profileManager.isRemotelyShared(profileWithId: profile.id)
|
||||
profileEditor.load(profile, isShared: isShared, preferencesManager: preferencesManager)
|
||||
profileEditor.load(profile, isShared: isShared)
|
||||
present(.editProfile(initialModuleId))
|
||||
}
|
||||
}
|
||||
|
@ -23,6 +23,8 @@
|
||||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import CommonLibrary
|
||||
import CommonUtils
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
@ -34,9 +36,12 @@ extension OpenVPNView {
|
||||
|
||||
let credentialsRoute: (any Hashable)?
|
||||
|
||||
@ObservedObject
|
||||
var allowedEndpoints: Blacklist<ExtendedEndpoint>
|
||||
|
||||
var body: some View {
|
||||
moduleSection(for: accountRows, header: Strings.Global.Nouns.account)
|
||||
moduleSection(for: remotesRows, header: Strings.Modules.Openvpn.remotes)
|
||||
remotesSection
|
||||
if !isServerPushed {
|
||||
moduleSection(for: pullRows, header: Strings.Modules.Openvpn.pull)
|
||||
}
|
||||
@ -61,6 +66,68 @@ extension OpenVPNView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Editable
|
||||
|
||||
private extension OpenVPNView.ConfigurationView {
|
||||
var remotesSection: some View {
|
||||
configuration.remotes.map { remotes in
|
||||
ForEach(remotes, id: \.rawValue) { remote in
|
||||
SelectableRemoteButton(
|
||||
remote: remote,
|
||||
all: Set(remotes),
|
||||
allowedEndpoints: allowedEndpoints
|
||||
)
|
||||
}
|
||||
.themeSection(header: Strings.Modules.Openvpn.remotes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SelectableRemoteButton: View {
|
||||
let remote: ExtendedEndpoint
|
||||
|
||||
let all: Set<ExtendedEndpoint>
|
||||
|
||||
@ObservedObject
|
||||
var allowedEndpoints: Blacklist<ExtendedEndpoint>
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
if allowedEndpoints.isAllowed(remote) {
|
||||
if remaining.count > 1 {
|
||||
allowedEndpoints.deny(remote)
|
||||
}
|
||||
} else {
|
||||
allowedEndpoints.allow(remote)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(remote.address.rawValue)
|
||||
.font(.headline)
|
||||
|
||||
Text("\(remote.proto.socketType.rawValue):\(remote.proto.port.description)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
ThemeImage(.marked)
|
||||
.opaque(allowedEndpoints.isAllowed(remote))
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var remaining: Set<ExtendedEndpoint> {
|
||||
all.filter {
|
||||
allowedEndpoints.isAllowed($0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Constant
|
||||
|
||||
private extension OpenVPNView.ConfigurationView {
|
||||
var accountRows: [ModuleRow]? {
|
||||
guard let credentialsRoute else {
|
||||
@ -75,20 +142,14 @@ private extension OpenVPNView.ConfigurationView {
|
||||
]
|
||||
}
|
||||
|
||||
var remotesRows: [ModuleRow]? {
|
||||
configuration.remotes?.map {
|
||||
.copiableText(
|
||||
value: "\($0.address.rawValue) → \($0.proto.socketType.rawValue):\($0.proto.port)"
|
||||
)
|
||||
}
|
||||
.nilIfEmpty
|
||||
}
|
||||
|
||||
var pullRows: [ModuleRow]? {
|
||||
configuration.pullMask?.map {
|
||||
.text(caption: $0.localizedDescription, value: nil)
|
||||
}
|
||||
.nilIfEmpty
|
||||
configuration.pullMask?
|
||||
.map(\.localizedDescription)
|
||||
.sorted()
|
||||
.map {
|
||||
.text(caption: $0, value: nil)
|
||||
}
|
||||
.nilIfEmpty
|
||||
}
|
||||
|
||||
func ipRows(for ip: IPSettings?, routes: [Route]?) -> [ModuleRow]? {
|
||||
@ -130,16 +191,15 @@ private extension OpenVPNView.ConfigurationView {
|
||||
configuration.routingPolicies?
|
||||
.compactMap {
|
||||
switch $0 {
|
||||
case .IPv4:
|
||||
return .text(caption: Strings.Unlocalized.ipv4)
|
||||
|
||||
case .IPv6:
|
||||
return .text(caption: Strings.Unlocalized.ipv6)
|
||||
|
||||
default:
|
||||
return nil
|
||||
case .IPv4: return Strings.Unlocalized.ipv4
|
||||
case .IPv6: return Strings.Unlocalized.ipv6
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
.sorted()
|
||||
.map {
|
||||
.text(caption: $0)
|
||||
}
|
||||
.nilIfEmpty
|
||||
}
|
||||
|
||||
@ -273,3 +333,33 @@ private extension OpenVPNView.ConfigurationView {
|
||||
return rows.nilIfEmpty
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview {
|
||||
struct Preview: View {
|
||||
|
||||
@StateObject
|
||||
private var allowedEndpoints = Blacklist<ExtendedEndpoint> { _ in
|
||||
true
|
||||
} allow: { _ in
|
||||
//
|
||||
} deny: { _ in
|
||||
//
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
OpenVPNView.ConfigurationView(
|
||||
isServerPushed: false,
|
||||
configuration: .forPreviews,
|
||||
credentialsRoute: nil,
|
||||
allowedEndpoints: allowedEndpoints
|
||||
)
|
||||
}
|
||||
.withMockEnvironment()
|
||||
}
|
||||
}
|
||||
|
||||
return Preview()
|
||||
}
|
||||
|
@ -0,0 +1,89 @@
|
||||
//
|
||||
// OpenVPNView+Extensions.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 12/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
|
||||
import PassepartoutKit
|
||||
|
||||
// swiftlint: disable force_try
|
||||
extension OpenVPN.Configuration.Builder {
|
||||
static var forPreviews: Self {
|
||||
var builder = OpenVPN.Configuration.Builder(withFallbacks: true)
|
||||
builder.noPullMask = [.proxy]
|
||||
builder.authUserPass = true
|
||||
builder.remotes = [
|
||||
.init(rawValue: "2.2.2.2:UDP:2222")!,
|
||||
.init(rawValue: "6.6.6.6:UDP:6666")!,
|
||||
.init(rawValue: "12.12.12.12:TCP:21212")!,
|
||||
.init(rawValue: "12:12:12:12:20:20:20:20:TCP6:21212")!
|
||||
]
|
||||
builder.ipv4 = IPSettings(subnet: try! .init("5.5.5.5", 24))
|
||||
.including(routes: [
|
||||
.init(defaultWithGateway: .ip("120.1.1.1", .v4)),
|
||||
.init(.init(rawValue: "55.10.20.30/32"), nil)
|
||||
])
|
||||
.excluding(routes: [
|
||||
.init(.init(rawValue: "88.40.30.30/32"), nil),
|
||||
.init(.init(rawValue: "60.60.60.60/32"), .ip("127.0.0.1", .v4))
|
||||
])
|
||||
builder.ipv6 = IPSettings(subnet: try! .init("::5", 24))
|
||||
.including(routes: [
|
||||
.init(defaultWithGateway: .ip("120::1:1:1", .v6)),
|
||||
.init(.init(rawValue: "55:10:20::30/128"), nil),
|
||||
.init(.init(rawValue: "60:60:60::60/128"), .ip("::2", .v6))
|
||||
])
|
||||
.excluding(routes: [
|
||||
.init(.init(rawValue: "88:40:30::30/32"), nil)
|
||||
])
|
||||
builder.routingPolicies = [.IPv4, .IPv6]
|
||||
builder.dnsServers = ["1.2.3.4", "4.5.6.7"]
|
||||
builder.dnsDomain = "domain.com"
|
||||
builder.searchDomains = ["search1.com", "search2.com"]
|
||||
builder.httpProxy = try! .init("10.10.10.10", 1080)
|
||||
builder.httpsProxy = try! .init("10.10.10.10", 8080)
|
||||
builder.proxyAutoConfigurationURL = URL(string: "https://hello.pac")!
|
||||
builder.proxyBypassDomains = ["bypass1.com", "bypass2.com"]
|
||||
builder.xorMethod = .xormask(mask: .init(Data(hex: "1234")))
|
||||
builder.ca = .init(mockPem: "ca-certificate")
|
||||
builder.clientCertificate = .init(mockPem: "client-certificate")
|
||||
builder.clientKey = .init(mockPem: "client-key")
|
||||
builder.tlsWrap = .init(strategy: .auth, key: .init(biData: Data(count: 256)))
|
||||
builder.keepAliveInterval = 10.0
|
||||
builder.renegotiatesAfter = 60.0
|
||||
builder.randomizeEndpoint = true
|
||||
builder.randomizeHostnames = true
|
||||
return builder
|
||||
}
|
||||
}
|
||||
// swiftlint: enable force_try
|
||||
|
||||
private extension OpenVPN.CryptoContainer {
|
||||
init(mockPem: String) {
|
||||
self.init(pem: """
|
||||
-----BEGIN CERTIFICATE-----
|
||||
\(mockPem)
|
||||
-----END CERTIFICATE-----
|
||||
""")
|
||||
}
|
||||
}
|
122
Library/Sources/AppUIMain/Views/Modules/OpenVPNView+Import.swift
Normal file
122
Library/Sources/AppUIMain/Views/Modules/OpenVPNView+Import.swift
Normal file
@ -0,0 +1,122 @@
|
||||
//
|
||||
// OpenVPNView+Import.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 12/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 CommonLibrary
|
||||
import CommonUtils
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
extension OpenVPNView {
|
||||
struct ImportModifier: ViewModifier {
|
||||
|
||||
@Binding
|
||||
var draft: OpenVPNModule.Builder
|
||||
|
||||
let impl: OpenVPNModule.Implementation?
|
||||
|
||||
@Binding
|
||||
var isImporting: Bool
|
||||
|
||||
@ObservedObject
|
||||
var errorHandler: ErrorHandler
|
||||
|
||||
@State
|
||||
private var importURL: URL?
|
||||
|
||||
@State
|
||||
private var importPassphrase: String?
|
||||
|
||||
@State
|
||||
private var requiresPassphrase = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.fileImporter(
|
||||
isPresented: $isImporting,
|
||||
allowedContentTypes: [.item],
|
||||
onCompletion: importConfiguration
|
||||
)
|
||||
.alert(
|
||||
draft.moduleType.localizedDescription,
|
||||
isPresented: $requiresPassphrase,
|
||||
presenting: importURL,
|
||||
actions: { url in
|
||||
SecureField(
|
||||
Strings.Placeholders.secret,
|
||||
text: $importPassphrase ?? ""
|
||||
)
|
||||
Button(Strings.Alerts.Import.Passphrase.ok) {
|
||||
importConfiguration(from: .success(url))
|
||||
}
|
||||
Button(Strings.Global.Actions.cancel, role: .cancel) {
|
||||
isImporting = false
|
||||
}
|
||||
},
|
||||
message: {
|
||||
Text(Strings.Alerts.Import.Passphrase.message($0.lastPathComponent))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension OpenVPNView.ImportModifier {
|
||||
func importConfiguration(from result: Result<URL, Error>) {
|
||||
do {
|
||||
let url = try result.get()
|
||||
guard url.startAccessingSecurityScopedResource() else {
|
||||
throw AppError.permissionDenied
|
||||
}
|
||||
defer {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
importURL = url
|
||||
|
||||
guard let impl else {
|
||||
fatalError("Requires OpenVPNModule implementation")
|
||||
}
|
||||
guard let parser = impl.importer as? StandardOpenVPNParser else {
|
||||
fatalError("OpenVPNModule importer should be StandardOpenVPNParser")
|
||||
}
|
||||
let parsed = try parser.parsed(fromURL: url, passphrase: importPassphrase)
|
||||
|
||||
draft.configurationBuilder = parsed.configuration.builder()
|
||||
} catch StandardOpenVPNParserError.encryptionPassphrase,
|
||||
StandardOpenVPNParserError.unableToDecrypt {
|
||||
Task {
|
||||
// XXX: re-present same alert after artificial delay
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
importPassphrase = nil
|
||||
requiresPassphrase = true
|
||||
}
|
||||
} catch {
|
||||
pp_log(.app, .error, "Unable to import OpenVPN configuration: \(error)")
|
||||
errorHandler.handle(
|
||||
(error as? StandardOpenVPNParserError)?.asPassepartoutError ?? error,
|
||||
title: draft.moduleType.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -30,6 +30,9 @@ import SwiftUI
|
||||
|
||||
struct OpenVPNView: View, ModuleDraftEditing {
|
||||
|
||||
@EnvironmentObject
|
||||
private var preferencesManager: PreferencesManager
|
||||
|
||||
@Environment(\.navigationPath)
|
||||
private var path
|
||||
|
||||
@ -45,18 +48,12 @@ struct OpenVPNView: View, ModuleDraftEditing {
|
||||
@State
|
||||
private var isImporting = false
|
||||
|
||||
@State
|
||||
private var importURL: URL?
|
||||
|
||||
@State
|
||||
private var importPassphrase: String?
|
||||
|
||||
@State
|
||||
private var requiresPassphrase = false
|
||||
|
||||
@State
|
||||
private var paywallReason: PaywallReason?
|
||||
|
||||
@StateObject
|
||||
private var providerPreferences = ProviderPreferences()
|
||||
|
||||
@StateObject
|
||||
private var errorHandler: ErrorHandler = .default()
|
||||
|
||||
@ -64,7 +61,6 @@ struct OpenVPNView: View, ModuleDraftEditing {
|
||||
module = OpenVPNModule.Builder(configurationBuilder: serverConfiguration.builder())
|
||||
editor = ProfileEditor(modules: [module])
|
||||
assert(module.configurationBuilder != nil, "isServerPushed must imply module.configurationBuilder != nil")
|
||||
|
||||
impl = nil
|
||||
isServerPushed = true
|
||||
}
|
||||
@ -79,11 +75,12 @@ struct OpenVPNView: View, ModuleDraftEditing {
|
||||
var body: some View {
|
||||
contentView
|
||||
.moduleView(editor: editor, draft: draft.wrappedValue)
|
||||
.fileImporter(
|
||||
isPresented: $isImporting,
|
||||
allowedContentTypes: [.item],
|
||||
onCompletion: importConfiguration
|
||||
)
|
||||
.modifier(ImportModifier(
|
||||
draft: draft,
|
||||
impl: impl,
|
||||
isImporting: $isImporting,
|
||||
errorHandler: errorHandler
|
||||
))
|
||||
.modifier(PaywallModifier(reason: $paywallReason))
|
||||
.navigationDestination(for: Subroute.self, destination: destination)
|
||||
.themeAnimation(on: draft.wrappedValue.providerId, category: .modules)
|
||||
@ -101,7 +98,8 @@ private extension OpenVPNView {
|
||||
ConfigurationView(
|
||||
isServerPushed: isServerPushed,
|
||||
configuration: configuration,
|
||||
credentialsRoute: Subroute.credentials
|
||||
credentialsRoute: Subroute.credentials,
|
||||
allowedEndpoints: allowedEndpoints
|
||||
)
|
||||
} else {
|
||||
emptyConfigurationView
|
||||
@ -126,31 +124,12 @@ private extension OpenVPNView {
|
||||
Button(Strings.Modules.General.Rows.importFromFile.withTrailingDots) {
|
||||
isImporting = true
|
||||
}
|
||||
.alert(
|
||||
module.moduleType.localizedDescription,
|
||||
isPresented: $requiresPassphrase,
|
||||
presenting: importURL,
|
||||
actions: { url in
|
||||
SecureField(
|
||||
Strings.Placeholders.secret,
|
||||
text: $importPassphrase ?? ""
|
||||
)
|
||||
Button(Strings.Alerts.Import.Passphrase.ok) {
|
||||
importConfiguration(from: .success(url))
|
||||
}
|
||||
Button(Strings.Global.Actions.cancel, role: .cancel) {
|
||||
isImporting = false
|
||||
}
|
||||
},
|
||||
message: {
|
||||
Text(Strings.Alerts.Import.Passphrase.message($0.lastPathComponent))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var providerModifier: some ViewModifier {
|
||||
VPNProviderContentModifier(
|
||||
providerId: providerId,
|
||||
providerPreferences: providerPreferences,
|
||||
selectedEntity: providerEntity,
|
||||
paywallReason: $paywallReason,
|
||||
entityDestination: Subroute.providerServer,
|
||||
@ -165,50 +144,6 @@ private extension OpenVPNView {
|
||||
}
|
||||
}
|
||||
|
||||
private extension OpenVPNView {
|
||||
func onSelectServer(server: VPNServer, preset: VPNPreset<OpenVPN.Configuration>) {
|
||||
draft.wrappedValue.providerEntity = VPNEntity(server: server, preset: preset)
|
||||
path.wrappedValue.removeLast()
|
||||
}
|
||||
|
||||
func importConfiguration(from result: Result<URL, Error>) {
|
||||
do {
|
||||
let url = try result.get()
|
||||
guard url.startAccessingSecurityScopedResource() else {
|
||||
throw AppError.permissionDenied
|
||||
}
|
||||
defer {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
importURL = url
|
||||
|
||||
guard let impl else {
|
||||
fatalError("Requires OpenVPNModule implementation")
|
||||
}
|
||||
guard let parser = impl.importer as? StandardOpenVPNParser else {
|
||||
fatalError("OpenVPNModule importer should be StandardOpenVPNParser")
|
||||
}
|
||||
let parsed = try parser.parsed(fromURL: url, passphrase: importPassphrase)
|
||||
|
||||
draft.wrappedValue.configurationBuilder = parsed.configuration.builder()
|
||||
} catch StandardOpenVPNParserError.encryptionPassphrase,
|
||||
StandardOpenVPNParserError.unableToDecrypt {
|
||||
Task {
|
||||
// XXX: re-present same alert after artificial delay
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
importPassphrase = nil
|
||||
requiresPassphrase = true
|
||||
}
|
||||
} catch {
|
||||
pp_log(.app, .error, "Unable to import OpenVPN configuration: \(error)")
|
||||
errorHandler.handle(
|
||||
(error as? StandardOpenVPNParserError)?.asPassepartoutError ?? error,
|
||||
title: module.moduleType.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Destinations
|
||||
|
||||
private extension OpenVPNView {
|
||||
@ -239,7 +174,8 @@ private extension OpenVPNView {
|
||||
ConfigurationView(
|
||||
isServerPushed: false,
|
||||
configuration: configuration.builder(),
|
||||
credentialsRoute: nil
|
||||
credentialsRoute: nil,
|
||||
allowedEndpoints: allowedEndpoints
|
||||
)
|
||||
}
|
||||
.themeForm()
|
||||
@ -260,66 +196,30 @@ private extension OpenVPNView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
// MARK: - Logic
|
||||
|
||||
// swiftlint: disable force_try
|
||||
#Preview {
|
||||
var builder = OpenVPN.Configuration.Builder(withFallbacks: true)
|
||||
builder.noPullMask = [.proxy]
|
||||
builder.authUserPass = true
|
||||
builder.remotes = [
|
||||
.init(rawValue: "2.2.2.2:UDP:2222")!,
|
||||
.init(rawValue: "6.6.6.6:UDP:6666")!,
|
||||
.init(rawValue: "12.12.12.12:TCP:21212")!,
|
||||
.init(rawValue: "12:12:12:12:20:20:20:20:TCP6:21212")!
|
||||
]
|
||||
builder.ipv4 = IPSettings(subnet: try! .init("5.5.5.5", 24))
|
||||
.including(routes: [
|
||||
.init(defaultWithGateway: .ip("120.1.1.1", .v4)),
|
||||
.init(.init(rawValue: "55.10.20.30/32"), nil)
|
||||
])
|
||||
.excluding(routes: [
|
||||
.init(.init(rawValue: "88.40.30.30/32"), nil),
|
||||
.init(.init(rawValue: "60.60.60.60/32"), .ip("127.0.0.1", .v4))
|
||||
])
|
||||
builder.ipv6 = IPSettings(subnet: try! .init("::5", 24))
|
||||
.including(routes: [
|
||||
.init(defaultWithGateway: .ip("120::1:1:1", .v6)),
|
||||
.init(.init(rawValue: "55:10:20::30/128"), nil),
|
||||
.init(.init(rawValue: "60:60:60::60/128"), .ip("::2", .v6))
|
||||
])
|
||||
.excluding(routes: [
|
||||
.init(.init(rawValue: "88:40:30::30/32"), nil)
|
||||
])
|
||||
builder.routingPolicies = [.IPv4, .IPv6]
|
||||
builder.dnsServers = ["1.2.3.4", "4.5.6.7"]
|
||||
builder.dnsDomain = "domain.com"
|
||||
builder.searchDomains = ["search1.com", "search2.com"]
|
||||
builder.httpProxy = try! .init("10.10.10.10", 1080)
|
||||
builder.httpsProxy = try! .init("10.10.10.10", 8080)
|
||||
builder.proxyAutoConfigurationURL = URL(string: "https://hello.pac")!
|
||||
builder.proxyBypassDomains = ["bypass1.com", "bypass2.com"]
|
||||
builder.xorMethod = .xormask(mask: .init(Data(hex: "1234")))
|
||||
builder.ca = .init(mockPem: "ca-certificate")
|
||||
builder.clientCertificate = .init(mockPem: "client-certificate")
|
||||
builder.clientKey = .init(mockPem: "client-key")
|
||||
builder.tlsWrap = .init(strategy: .auth, key: .init(biData: Data(count: 256)))
|
||||
builder.keepAliveInterval = 10.0
|
||||
builder.renegotiatesAfter = 60.0
|
||||
builder.randomizeEndpoint = true
|
||||
builder.randomizeHostnames = true
|
||||
private extension OpenVPNView {
|
||||
var preferences: ModulePreferences {
|
||||
editor.preferences(forModuleWithId: module.id, manager: preferencesManager)
|
||||
}
|
||||
|
||||
let module = OpenVPNModule.Builder(configurationBuilder: builder)
|
||||
return module.preview(title: "OpenVPN")
|
||||
}
|
||||
// swiftlint: enable force_try
|
||||
var allowedEndpoints: Blacklist<ExtendedEndpoint> {
|
||||
if draft.wrappedValue.providerSelection != nil {
|
||||
return providerPreferences.allowedEndpoints()
|
||||
} else {
|
||||
return preferences.allowedEndpoints()
|
||||
}
|
||||
}
|
||||
|
||||
private extension OpenVPN.CryptoContainer {
|
||||
init(mockPem: String) {
|
||||
self.init(pem: """
|
||||
-----BEGIN CERTIFICATE-----
|
||||
\(mockPem)
|
||||
-----END CERTIFICATE-----
|
||||
""")
|
||||
func onSelectServer(server: VPNServer, preset: VPNPreset<OpenVPN.Configuration>) {
|
||||
draft.wrappedValue.providerEntity = VPNEntity(server: server, preset: preset)
|
||||
path.wrappedValue.removeLast()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview {
|
||||
let module = OpenVPNModule.Builder(configurationBuilder: .forPreviews)
|
||||
return module.preview(title: "OpenVPN")
|
||||
}
|
||||
|
@ -79,6 +79,7 @@ private extension WireGuardView {
|
||||
var providerModifier: some ViewModifier {
|
||||
VPNProviderContentModifier(
|
||||
providerId: providerId,
|
||||
providerPreferences: nil,
|
||||
selectedEntity: providerEntity,
|
||||
paywallReason: $paywallReason,
|
||||
entityDestination: Subroute.providerServer,
|
||||
|
@ -34,11 +34,16 @@ struct ProviderContentModifier<Entity, ProviderRows>: ViewModifier where Entity:
|
||||
@EnvironmentObject
|
||||
private var providerManager: ProviderManager
|
||||
|
||||
@EnvironmentObject
|
||||
private var preferencesManager: PreferencesManager
|
||||
|
||||
let apis: [APIMapper]
|
||||
|
||||
@Binding
|
||||
var providerId: ProviderID?
|
||||
|
||||
let providerPreferences: ProviderPreferences?
|
||||
|
||||
let entityType: Entity.Type
|
||||
|
||||
@Binding
|
||||
@ -57,9 +62,11 @@ struct ProviderContentModifier<Entity, ProviderRows>: ViewModifier where Entity:
|
||||
if let newId {
|
||||
await refreshInfrastructure(for: newId)
|
||||
}
|
||||
loadPreferences(for: newId)
|
||||
onSelectProvider(providerManager, newId, false)
|
||||
}
|
||||
}
|
||||
.onDisappear(perform: savePreferences)
|
||||
.disabled(providerManager.isLoading)
|
||||
|
||||
content
|
||||
@ -147,6 +154,7 @@ private extension ProviderContentModifier {
|
||||
await refreshIndex()
|
||||
if let providerId {
|
||||
onSelectProvider(providerManager, providerId, true)
|
||||
loadPreferences(for: providerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -172,6 +180,35 @@ private extension ProviderContentModifier {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func loadPreferences(for providerId: ProviderID?) {
|
||||
guard let providerPreferences else {
|
||||
return
|
||||
}
|
||||
if let providerId {
|
||||
do {
|
||||
pp_log(.app, .debug, "Load preferences for provider \(providerId)")
|
||||
providerPreferences.repository = try preferencesManager.preferencesRepository(forProviderWithId: providerId)
|
||||
} catch {
|
||||
pp_log(.app, .error, "Unable to load preferences for provider \(providerId): \(error)")
|
||||
providerPreferences.repository = nil
|
||||
}
|
||||
} else {
|
||||
providerPreferences.repository = nil
|
||||
}
|
||||
}
|
||||
|
||||
func savePreferences() {
|
||||
guard let providerPreferences else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
pp_log(.app, .debug, "Save preferences for provider \(providerId.debugDescription)")
|
||||
try providerPreferences.save()
|
||||
} catch {
|
||||
pp_log(.app, .error, "Unable to save preferences for provider \(providerId.debugDescription): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
@ -182,6 +219,7 @@ private extension ProviderContentModifier {
|
||||
.modifier(ProviderContentModifier(
|
||||
apis: [API.bundled],
|
||||
providerId: .constant(.hideme),
|
||||
providerPreferences: nil,
|
||||
entityType: VPNEntity<OpenVPN.Configuration>.self,
|
||||
paywallReason: .constant(nil),
|
||||
providerRows: {},
|
||||
|
@ -34,6 +34,8 @@ struct VPNProviderContentModifier<Configuration, ProviderRows>: ViewModifier whe
|
||||
@Binding
|
||||
var providerId: ProviderID?
|
||||
|
||||
let providerPreferences: ProviderPreferences?
|
||||
|
||||
@Binding
|
||||
var selectedEntity: VPNEntity<Configuration>?
|
||||
|
||||
@ -51,6 +53,7 @@ struct VPNProviderContentModifier<Configuration, ProviderRows>: ViewModifier whe
|
||||
.modifier(ProviderContentModifier(
|
||||
apis: apis,
|
||||
providerId: $providerId,
|
||||
providerPreferences: providerPreferences,
|
||||
entityType: VPNEntity<Configuration>.self,
|
||||
paywallReason: $paywallReason,
|
||||
providerRows: {
|
||||
@ -94,6 +97,7 @@ private extension VPNProviderContentModifier {
|
||||
.modifier(VPNProviderContentModifier(
|
||||
apis: [API.bundled],
|
||||
providerId: .constant(.hideme),
|
||||
providerPreferences: nil,
|
||||
selectedEntity: .constant(nil as VPNEntity<OpenVPN.Configuration>?),
|
||||
paywallReason: .constant(nil),
|
||||
entityDestination: "Destination",
|
||||
|
@ -28,65 +28,78 @@ import Foundation
|
||||
import PassepartoutKit
|
||||
|
||||
public final class PreferencesManager: ObservableObject, Sendable {
|
||||
private let modulesRepository: ModulePreferencesRepository
|
||||
private let modulesFactory: @Sendable (UUID) throws -> ModulePreferencesRepository
|
||||
|
||||
private let providersFactory: @Sendable (ProviderID) throws -> ProviderPreferencesRepository
|
||||
|
||||
public init(
|
||||
modulesRepository: ModulePreferencesRepository? = nil,
|
||||
modulesFactory: (@Sendable (UUID) throws -> ModulePreferencesRepository)? = nil,
|
||||
providersFactory: (@Sendable (ProviderID) throws -> ProviderPreferencesRepository)? = nil
|
||||
) {
|
||||
self.modulesRepository = modulesRepository ?? DummyModulePreferencesRepository()
|
||||
self.modulesFactory = modulesFactory ?? { _ in
|
||||
DummyModulePreferencesRepository()
|
||||
}
|
||||
self.providersFactory = providersFactory ?? { _ in
|
||||
DummyProviderPreferencesRepository()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Modules
|
||||
|
||||
extension PreferencesManager {
|
||||
public func preferences(forProfile profile: Profile) throws -> [UUID: ModulePreferences] {
|
||||
try preferences(forModulesWithIds: profile.modules.map(\.id))
|
||||
public func preferencesRepository(forModuleWithId moduleId: UUID) throws -> ModulePreferencesRepository {
|
||||
try modulesFactory(moduleId)
|
||||
}
|
||||
|
||||
public func preferences(forProfile editableProfile: EditableProfile) throws -> [UUID: ModulePreferences] {
|
||||
try preferences(forModulesWithIds: editableProfile.modules.map(\.id))
|
||||
}
|
||||
|
||||
public func savePreferences(_ preferences: [UUID: ModulePreferences]) throws {
|
||||
try modulesRepository.set(preferences)
|
||||
}
|
||||
}
|
||||
|
||||
private extension PreferencesManager {
|
||||
func preferences(forModulesWithIds moduleIds: [UUID]) throws -> [UUID: ModulePreferences] {
|
||||
try modulesRepository.preferences(for: moduleIds)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Providers
|
||||
|
||||
extension PreferencesManager {
|
||||
public func preferencesRepository(forProviderWithId providerId: ProviderID) throws -> ProviderPreferencesRepository {
|
||||
try providersFactory(providerId)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension PreferencesManager {
|
||||
public func preferences(forModuleWithId moduleId: UUID) throws -> ModulePreferences {
|
||||
let object = ModulePreferences()
|
||||
object.repository = try modulesFactory(moduleId)
|
||||
return object
|
||||
}
|
||||
|
||||
public func preferences(forProviderWithId providerId: ProviderID) throws -> ProviderPreferences {
|
||||
let object = ProviderPreferences()
|
||||
object.repository = try providersFactory(providerId)
|
||||
return object
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dummy
|
||||
|
||||
private final class DummyModulePreferencesRepository: ModulePreferencesRepository {
|
||||
func preferences(for moduleIds: [UUID]) throws -> [UUID: ModulePreferences] {
|
||||
[:]
|
||||
func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
func set(_ preferences: [UUID: ModulePreferences]) throws {
|
||||
func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
|
||||
}
|
||||
|
||||
func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
|
||||
}
|
||||
|
||||
func save() throws {
|
||||
}
|
||||
}
|
||||
|
||||
private final class DummyProviderPreferencesRepository: ProviderPreferencesRepository {
|
||||
var favoriteServers: Set<String> = []
|
||||
|
||||
func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
|
||||
}
|
||||
|
||||
func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
|
||||
}
|
||||
|
||||
func save() throws {
|
||||
}
|
||||
}
|
||||
|
@ -23,10 +23,28 @@
|
||||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import CommonUtils
|
||||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
||||
public struct ModulePreferences: Sendable {
|
||||
@MainActor
|
||||
public final class ModulePreferences: ObservableObject {
|
||||
public var repository: ModulePreferencesRepository?
|
||||
|
||||
public init() {
|
||||
}
|
||||
|
||||
public func allowedEndpoints() -> Blacklist<ExtendedEndpoint> {
|
||||
Blacklist { [weak self] in
|
||||
self?.repository?.isExcludedEndpoint($0) != true
|
||||
} allow: { [weak self] in
|
||||
self?.repository?.removeExcludedEndpoint($0)
|
||||
} deny: { [weak self] in
|
||||
self?.repository?.addExcludedEndpoint($0)
|
||||
}
|
||||
}
|
||||
|
||||
public func save() throws {
|
||||
try repository?.save()
|
||||
}
|
||||
}
|
||||
|
@ -23,16 +23,13 @@
|
||||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import CommonUtils
|
||||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
||||
@MainActor
|
||||
public final class ProviderPreferences: ObservableObject, ProviderPreferencesRepository {
|
||||
public var repository: ProviderPreferencesRepository? {
|
||||
didSet {
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
public final class ProviderPreferences: ObservableObject {
|
||||
public var repository: ProviderPreferencesRepository?
|
||||
|
||||
public init() {
|
||||
}
|
||||
@ -47,6 +44,16 @@ public final class ProviderPreferences: ObservableObject, ProviderPreferencesRep
|
||||
}
|
||||
}
|
||||
|
||||
public func allowedEndpoints() -> Blacklist<ExtendedEndpoint> {
|
||||
Blacklist { [weak self] in
|
||||
self?.repository?.isExcludedEndpoint($0) != true
|
||||
} allow: { [weak self] in
|
||||
self?.repository?.removeExcludedEndpoint($0)
|
||||
} deny: { [weak self] in
|
||||
self?.repository?.addExcludedEndpoint($0)
|
||||
}
|
||||
}
|
||||
|
||||
public func save() throws {
|
||||
try repository?.save()
|
||||
}
|
@ -26,8 +26,12 @@
|
||||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
||||
public protocol ModulePreferencesRepository: Sendable {
|
||||
func preferences(for moduleIds: [UUID]) throws -> [UUID: ModulePreferences]
|
||||
public protocol ModulePreferencesRepository {
|
||||
func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool
|
||||
|
||||
func set(_ preferences: [UUID: ModulePreferences]) throws
|
||||
func addExcludedEndpoint(_ endpoint: ExtendedEndpoint)
|
||||
|
||||
func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint)
|
||||
|
||||
func save() throws
|
||||
}
|
||||
|
@ -29,5 +29,11 @@ import PassepartoutKit
|
||||
public protocol ProviderPreferencesRepository {
|
||||
var favoriteServers: Set<String> { get set }
|
||||
|
||||
func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool
|
||||
|
||||
func addExcludedEndpoint(_ endpoint: ExtendedEndpoint)
|
||||
|
||||
func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint)
|
||||
|
||||
func save() throws
|
||||
}
|
||||
|
59
Library/Sources/CommonUtils/Business/Blacklist.swift
Normal file
59
Library/Sources/CommonUtils/Business/Blacklist.swift
Normal file
@ -0,0 +1,59 @@
|
||||
//
|
||||
// Blacklist.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 12/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
|
||||
|
||||
@MainActor
|
||||
public final class Blacklist<T>: ObservableObject where T: Equatable {
|
||||
private let isAllowed: (T) -> Bool
|
||||
|
||||
private let allow: (T) -> Void
|
||||
|
||||
private let deny: (T) -> Void
|
||||
|
||||
public init(
|
||||
isAllowed: @escaping (T) -> Bool,
|
||||
allow: @escaping (T) -> Void,
|
||||
deny: @escaping (T) -> Void
|
||||
) {
|
||||
self.isAllowed = isAllowed
|
||||
self.allow = allow
|
||||
self.deny = deny
|
||||
}
|
||||
|
||||
public func isAllowed(_ value: T) -> Bool {
|
||||
isAllowed(value)
|
||||
}
|
||||
|
||||
public func allow(_ value: T) {
|
||||
objectWillChange.send()
|
||||
allow(value)
|
||||
}
|
||||
|
||||
public func deny(_ value: T) {
|
||||
objectWillChange.send()
|
||||
deny(value)
|
||||
}
|
||||
}
|
@ -41,9 +41,9 @@ public final class InteractiveManager: ObservableObject {
|
||||
public init() {
|
||||
}
|
||||
|
||||
public func present(with profile: Profile, preferencesManager: PreferencesManager, onComplete: CompletionBlock?) {
|
||||
public func present(with profile: Profile, onComplete: CompletionBlock?) {
|
||||
editor = ProfileEditor()
|
||||
editor.load(profile.editable(), isShared: false, preferencesManager: preferencesManager)
|
||||
editor.load(profile.editable(), isShared: false)
|
||||
self.onComplete = onComplete
|
||||
isPresented = true
|
||||
}
|
||||
|
@ -37,8 +37,7 @@ public final class ProfileEditor: ObservableObject {
|
||||
@Published
|
||||
public var isShared: Bool
|
||||
|
||||
@Published
|
||||
public var preferences: [UUID: ModulePreferences]
|
||||
private var trackedPreferences: [UUID: ModulePreferences]
|
||||
|
||||
private(set) var removedModules: [UUID: any ModuleBuilder]
|
||||
|
||||
@ -50,7 +49,7 @@ public final class ProfileEditor: ObservableObject {
|
||||
public init(profile: Profile) {
|
||||
editableProfile = profile.editable()
|
||||
isShared = false
|
||||
preferences = [:]
|
||||
trackedPreferences = [:]
|
||||
removedModules = [:]
|
||||
}
|
||||
|
||||
@ -61,7 +60,7 @@ public final class ProfileEditor: ObservableObject {
|
||||
activeModulesIds: Set(modules.map(\.id))
|
||||
)
|
||||
isShared = false
|
||||
preferences = [:]
|
||||
trackedPreferences = [:]
|
||||
removedModules = [:]
|
||||
}
|
||||
}
|
||||
@ -202,22 +201,24 @@ extension ProfileEditor {
|
||||
// MARK: - Load/Save
|
||||
|
||||
extension ProfileEditor {
|
||||
public func load(
|
||||
_ profile: EditableProfile,
|
||||
isShared: Bool,
|
||||
preferencesManager: PreferencesManager
|
||||
) {
|
||||
public func load(_ profile: EditableProfile, isShared: Bool) {
|
||||
editableProfile = profile
|
||||
self.isShared = isShared
|
||||
do {
|
||||
preferences = try preferencesManager.preferences(forProfile: profile)
|
||||
} catch {
|
||||
preferences = [:]
|
||||
pp_log(.App.profiles, .error, "Unable to load preferences for profile \(profile.id): \(error)")
|
||||
}
|
||||
removedModules = [:]
|
||||
}
|
||||
|
||||
public func preferences(forModuleWithId moduleId: UUID, manager: PreferencesManager) -> ModulePreferences {
|
||||
do {
|
||||
pp_log(.App.profiles, .debug, "Track preferences for module \(moduleId)")
|
||||
let observable = try trackedPreferences[moduleId] ?? manager.preferences(forModuleWithId: moduleId)
|
||||
trackedPreferences[moduleId] = observable
|
||||
return observable
|
||||
} catch {
|
||||
pp_log(.App.profiles, .error, "Unable to track preferences for module \(moduleId): \(error)")
|
||||
return ModulePreferences()
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func save(
|
||||
to profileManager: ProfileManager,
|
||||
@ -226,11 +227,15 @@ extension ProfileEditor {
|
||||
do {
|
||||
let newProfile = try build()
|
||||
try await profileManager.save(newProfile, isLocal: true, remotelyShared: isShared)
|
||||
do {
|
||||
try preferencesManager.savePreferences(preferences)
|
||||
} catch {
|
||||
pp_log(.App.profiles, .error, "Unable to save preferences for profile \(profile.id): \(error)")
|
||||
trackedPreferences.forEach {
|
||||
do {
|
||||
pp_log(.App.profiles, .debug, "Save tracked preferences for module \($0.key)")
|
||||
try $0.value.save()
|
||||
} catch {
|
||||
pp_log(.App.profiles, .error, "Unable to save preferences for profile \(profile.id): \(error)")
|
||||
}
|
||||
}
|
||||
trackedPreferences.removeAll()
|
||||
return newProfile
|
||||
} catch {
|
||||
pp_log(.App.profiles, .fault, "Unable to save edited profile: \(error)")
|
||||
|
@ -41,12 +41,4 @@ extension ProfileEditor {
|
||||
self?.saveModule($0, activating: false)
|
||||
}
|
||||
}
|
||||
|
||||
public func binding(forPreferencesOf moduleId: UUID) -> Binding<ModulePreferences> {
|
||||
Binding { [weak self] in
|
||||
self?.preferences[moduleId] ?? ModulePreferences()
|
||||
} set: { [weak self] in
|
||||
self?.preferences[moduleId] = $0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -41,7 +41,9 @@ extension Dependencies {
|
||||
author: nil
|
||||
)
|
||||
return PreferencesManager(
|
||||
modulesRepository: AppData.cdModulePreferencesRepositoryV3(context: preferencesStore.context),
|
||||
modulesFactory: {
|
||||
try AppData.cdModulePreferencesRepositoryV3(context: preferencesStore.context, moduleId: $0)
|
||||
},
|
||||
providersFactory: {
|
||||
try AppData.cdProviderPreferencesRepositoryV3(context: preferencesStore.context, providerId: $0)
|
||||
}
|
||||
|
@ -37,6 +37,32 @@ final class DefaultTunnelProcessor: Sendable {
|
||||
|
||||
extension DefaultTunnelProcessor: PacketTunnelProcessor {
|
||||
func willStart(_ profile: Profile) throws -> Profile {
|
||||
profile
|
||||
do {
|
||||
var builder = profile.builder()
|
||||
try builder.modules.forEach {
|
||||
guard var moduleBuilder = $0.moduleBuilder() as? OpenVPNModule.Builder else {
|
||||
return
|
||||
}
|
||||
|
||||
let modulesPreferences = try preferencesManager.preferencesRepository(forModuleWithId: moduleBuilder.id)
|
||||
moduleBuilder.configurationBuilder?.remotes?.removeAll {
|
||||
modulesPreferences.isExcludedEndpoint($0)
|
||||
}
|
||||
|
||||
if let providerId = moduleBuilder.providerId {
|
||||
let providerPreferences = try preferencesManager.preferencesRepository(forProviderWithId: providerId)
|
||||
moduleBuilder.configurationBuilder?.remotes?.removeAll {
|
||||
providerPreferences.isExcludedEndpoint($0)
|
||||
}
|
||||
}
|
||||
|
||||
let module = try moduleBuilder.tryBuild()
|
||||
builder.saveModule(module)
|
||||
}
|
||||
return try builder.tryBuild()
|
||||
} catch {
|
||||
pp_log(.app, .error, "Unable to process profile, revert to original: \(error)")
|
||||
return profile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user