Maintain one configuration per profile (#636)
Helps with automation. Install the VPN configuration before persisting a profile, so that the 1:1 reference with OS settings is maintained. Likewise, uninstall the VPN configuration after removing a profile. This before-save hook also resolves a problem with multiple imports, where multiple VPN permission alerts coalesce if no VPN configuration is installed. Now the first import waits for the permission synchronously. Fixes #618
This commit is contained in:
parent
a29495a69c
commit
a9fa6a2f62
|
@ -32,7 +32,7 @@
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "git@github.com:passepartoutvpn/passepartoutkit",
|
"location" : "git@github.com:passepartoutvpn/passepartoutkit",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "ed3f54281b672af0f1127f00033579a36a9afed5"
|
"revision" : "263bedc756d07eb107d7bfe3b50dbc5db28675d4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -31,7 +31,7 @@ let package = Package(
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit", from: "0.7.0"),
|
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit", from: "0.7.0"),
|
||||||
.package(url: "git@github.com:passepartoutvpn/passepartoutkit", revision: "ed3f54281b672af0f1127f00033579a36a9afed5"),
|
.package(url: "git@github.com:passepartoutvpn/passepartoutkit", revision: "263bedc756d07eb107d7bfe3b50dbc5db28675d4"),
|
||||||
// .package(path: "../../../passepartoutkit"),
|
// .package(path: "../../../passepartoutkit"),
|
||||||
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-openvpn-openssl", from: "0.6.0"),
|
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-openvpn-openssl", from: "0.6.0"),
|
||||||
// .package(path: "../../../passepartoutkit-openvpn-openssl"),
|
// .package(path: "../../../passepartoutkit-openvpn-openssl"),
|
||||||
|
|
|
@ -27,17 +27,26 @@ import AppData
|
||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import PassepartoutKit
|
import PassepartoutKit
|
||||||
|
import UtilsLibrary
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class ProfileManager: ObservableObject {
|
public final class ProfileManager: ObservableObject {
|
||||||
public let didSave: PassthroughSubject<Profile, Never>
|
public enum Event {
|
||||||
|
case save(Profile)
|
||||||
|
|
||||||
public var didUpdate: AnyPublisher<[Profile], Never> {
|
case remove([Profile.ID])
|
||||||
$profiles.eraseToAnyPublisher()
|
|
||||||
|
case update([Profile])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var beforeSave: ((Profile) async throws -> Void)?
|
||||||
|
|
||||||
|
public var afterRemove: (([Profile.ID]) async -> Void)?
|
||||||
|
|
||||||
|
public let didChange: PassthroughSubject<Event, Never>
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var profiles: [Profile]
|
private var profiles: [Profile]
|
||||||
|
|
||||||
private var allProfileIds: Set<Profile.ID>
|
private var allProfileIds: Set<Profile.ID>
|
||||||
|
|
||||||
|
@ -49,7 +58,7 @@ public final class ProfileManager: ObservableObject {
|
||||||
|
|
||||||
// for testing/previews
|
// for testing/previews
|
||||||
public init(profiles: [Profile]) {
|
public init(profiles: [Profile]) {
|
||||||
didSave = PassthroughSubject()
|
didChange = PassthroughSubject()
|
||||||
self.profiles = profiles.sorted {
|
self.profiles = profiles.sorted {
|
||||||
$0.name.lowercased() < $1.name.lowercased()
|
$0.name.lowercased() < $1.name.lowercased()
|
||||||
}
|
}
|
||||||
|
@ -57,21 +66,21 @@ public final class ProfileManager: ObservableObject {
|
||||||
repository = MockProfileRepository(profiles: profiles)
|
repository = MockProfileRepository(profiles: profiles)
|
||||||
searchSubject = CurrentValueSubject("")
|
searchSubject = CurrentValueSubject("")
|
||||||
subscriptions = []
|
subscriptions = []
|
||||||
|
|
||||||
observeObjects(searchDebounce: 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(repository: any ProfileRepository, searchDebounce: Int = 200) {
|
public init(repository: any ProfileRepository) {
|
||||||
didSave = PassthroughSubject()
|
didChange = PassthroughSubject()
|
||||||
profiles = []
|
profiles = []
|
||||||
allProfileIds = []
|
allProfileIds = []
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
searchSubject = CurrentValueSubject("")
|
searchSubject = CurrentValueSubject("")
|
||||||
subscriptions = []
|
subscriptions = []
|
||||||
|
|
||||||
observeObjects(searchDebounce: searchDebounce)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CRUD
|
||||||
|
|
||||||
|
extension ProfileManager {
|
||||||
public var hasProfiles: Bool {
|
public var hasProfiles: Bool {
|
||||||
!profiles.isEmpty
|
!profiles.isEmpty
|
||||||
}
|
}
|
||||||
|
@ -98,8 +107,9 @@ public final class ProfileManager: ObservableObject {
|
||||||
|
|
||||||
public func save(_ profile: Profile) async throws {
|
public func save(_ profile: Profile) async throws {
|
||||||
do {
|
do {
|
||||||
|
try await beforeSave?(profile)
|
||||||
try await repository.saveEntities([profile])
|
try await repository.saveEntities([profile])
|
||||||
didSave.send(profile)
|
didChange.send(.save(profile))
|
||||||
} catch {
|
} catch {
|
||||||
pp_log(.app, .fault, "Unable to save profile \(profile.id): \(error)")
|
pp_log(.app, .fault, "Unable to save profile \(profile.id): \(error)")
|
||||||
throw error
|
throw error
|
||||||
|
@ -114,6 +124,8 @@ public final class ProfileManager: ObservableObject {
|
||||||
do {
|
do {
|
||||||
allProfileIds.subtract(profileIds)
|
allProfileIds.subtract(profileIds)
|
||||||
try await repository.removeEntities(withIds: profileIds)
|
try await repository.removeEntities(withIds: profileIds)
|
||||||
|
await afterRemove?(profileIds)
|
||||||
|
didChange.send(.remove(profileIds))
|
||||||
} catch {
|
} catch {
|
||||||
pp_log(.app, .fault, "Unable to remove profiles \(profileIds): \(error)")
|
pp_log(.app, .fault, "Unable to remove profiles \(profileIds): \(error)")
|
||||||
}
|
}
|
||||||
|
@ -124,6 +136,8 @@ public final class ProfileManager: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Shortcuts
|
||||||
|
|
||||||
extension ProfileManager {
|
extension ProfileManager {
|
||||||
public func new(withName name: String) -> Profile {
|
public func new(withName name: String) -> Profile {
|
||||||
var builder = Profile.Builder()
|
var builder = Profile.Builder()
|
||||||
|
@ -149,35 +163,6 @@ extension ProfileManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ProfileManager {
|
private extension ProfileManager {
|
||||||
func observeObjects(searchDebounce: Int) {
|
|
||||||
repository
|
|
||||||
.entitiesPublisher
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] in
|
|
||||||
guard let self else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.profiles = $0.entities
|
|
||||||
if !$0.isFiltering {
|
|
||||||
allProfileIds = Set($0.entities.map(\.id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &subscriptions)
|
|
||||||
|
|
||||||
searchSubject
|
|
||||||
.debounce(for: .milliseconds(searchDebounce), scheduler: DispatchQueue.main)
|
|
||||||
.sink { [weak self] search in
|
|
||||||
Task {
|
|
||||||
guard !search.isEmpty else {
|
|
||||||
try await self?.repository.resetFilter()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try await self?.repository.filter(byName: search)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &subscriptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstUniqueName(from name: String) -> String {
|
func firstUniqueName(from name: String) -> String {
|
||||||
let allNames = profiles.map(\.name)
|
let allNames = profiles.map(\.name)
|
||||||
var newName = name
|
var newName = name
|
||||||
|
@ -191,3 +176,52 @@ private extension ProfileManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Observation
|
||||||
|
|
||||||
|
extension ProfileManager {
|
||||||
|
public func observeObjects(searchDebounce: Int = 200) {
|
||||||
|
repository
|
||||||
|
.entitiesPublisher
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] in
|
||||||
|
self?.notifyUpdatedEntities($0)
|
||||||
|
}
|
||||||
|
.store(in: &subscriptions)
|
||||||
|
|
||||||
|
searchSubject
|
||||||
|
.debounce(for: .milliseconds(searchDebounce), scheduler: DispatchQueue.main)
|
||||||
|
.sink { [weak self] in
|
||||||
|
self?.performSearch($0)
|
||||||
|
}
|
||||||
|
.store(in: &subscriptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ProfileManager {
|
||||||
|
func notifyUpdatedEntities(_ result: EntitiesResult<Profile>) {
|
||||||
|
let oldProfiles = profiles.reduce(into: [:]) {
|
||||||
|
$0[$1.id] = $1
|
||||||
|
}
|
||||||
|
let newProfiles = result.entities
|
||||||
|
let updatedProfiles = newProfiles.filter {
|
||||||
|
$0 != oldProfiles[$0.id] // includes new profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.isFiltering {
|
||||||
|
allProfileIds = Set(newProfiles.map(\.id))
|
||||||
|
}
|
||||||
|
profiles = newProfiles
|
||||||
|
didChange.send(.update(updatedProfiles))
|
||||||
|
}
|
||||||
|
|
||||||
|
func performSearch(_ search: String) {
|
||||||
|
Task {
|
||||||
|
guard !search.isEmpty else {
|
||||||
|
try await repository.resetFilter()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try await repository.filter(byName: search)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -32,10 +32,6 @@ public struct AppUI {
|
||||||
|
|
||||||
public static func configure(with context: AppContext) {
|
public static func configure(with context: AppContext) {
|
||||||
assertMissingModuleImplementations()
|
assertMissingModuleImplementations()
|
||||||
Task {
|
|
||||||
await context.iapManager.reloadReceipt()
|
|
||||||
try await context.tunnel.prepare()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -60,61 +60,80 @@ public final class AppContext: ObservableObject {
|
||||||
self.profileManager = profileManager
|
self.profileManager = profileManager
|
||||||
self.tunnel = tunnel
|
self.tunnel = tunnel
|
||||||
self.tunnelEnvironment = tunnelEnvironment
|
self.tunnelEnvironment = tunnelEnvironment
|
||||||
self.registry = registry
|
|
||||||
self.constants = constants
|
|
||||||
subscriptions = []
|
|
||||||
|
|
||||||
connectionObserver = ConnectionObserver(
|
connectionObserver = ConnectionObserver(
|
||||||
tunnel: tunnel,
|
tunnel: tunnel,
|
||||||
environment: tunnelEnvironment,
|
environment: tunnelEnvironment,
|
||||||
interval: constants.connection.refreshInterval
|
interval: constants.connection.refreshInterval
|
||||||
)
|
)
|
||||||
|
self.registry = registry
|
||||||
|
self.constants = constants
|
||||||
|
subscriptions = []
|
||||||
|
|
||||||
observeObjects()
|
profileManager.beforeSave = { [weak self] in
|
||||||
|
try await self?.installSavedProfile($0)
|
||||||
|
}
|
||||||
|
profileManager.afterRemove = { [weak self] in
|
||||||
|
self?.uninstallRemovedProfiles(withIds: $0)
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
try await tunnel.prepare()
|
||||||
|
await iapManager.reloadReceipt()
|
||||||
|
connectionObserver.observeObjects()
|
||||||
|
profileManager.observeObjects()
|
||||||
|
observeObjects()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Observation
|
||||||
|
|
||||||
private extension AppContext {
|
private extension AppContext {
|
||||||
func observeObjects() {
|
func observeObjects() {
|
||||||
profileManager
|
profileManager
|
||||||
.didSave
|
.didChange
|
||||||
.sink { [weak self] profile in
|
.sink { [weak self] event in
|
||||||
guard let self else {
|
switch event {
|
||||||
return
|
case .save(let profile):
|
||||||
}
|
self?.syncTunnelIfCurrentProfile(profile)
|
||||||
guard profile.id == tunnel.installedProfile?.id else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Task {
|
|
||||||
if profile.isInteractive {
|
|
||||||
try await self.tunnel.disconnect()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if self.tunnel.status == .active {
|
|
||||||
try await self.tunnel.reconnect(with: profile, processor: self.iapManager)
|
|
||||||
} else {
|
|
||||||
try await self.tunnel.reinstate(profile, processor: self.iapManager)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &subscriptions)
|
|
||||||
|
|
||||||
profileManager
|
default:
|
||||||
.didUpdate
|
break
|
||||||
.sink { [weak self] _ in
|
|
||||||
guard let self else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let installedProfile = tunnel.installedProfile else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard profileManager.exists(withId: installedProfile.id) else {
|
|
||||||
Task {
|
|
||||||
try await self.tunnel.disconnect()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &subscriptions)
|
.store(in: &subscriptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension AppContext {
|
||||||
|
func installSavedProfile(_ profile: Profile) async throws {
|
||||||
|
try await tunnel.install(profile, processor: iapManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uninstallRemovedProfiles(withIds profileIds: [Profile.ID]) {
|
||||||
|
Task {
|
||||||
|
for id in profileIds {
|
||||||
|
do {
|
||||||
|
try await tunnel.uninstall(profileId: id)
|
||||||
|
} catch {
|
||||||
|
pp_log(.app, .error, "Unable to uninstall profile \(id): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncTunnelIfCurrentProfile(_ profile: Profile) {
|
||||||
|
guard profile.id == tunnel.currentProfile?.id else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
if profile.isInteractive {
|
||||||
|
try await tunnel.disconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tunnel.status == .active {
|
||||||
|
try await tunnel.connect(with: profile, processor: iapManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -65,13 +65,9 @@ public final class ConnectionObserver: ObservableObject {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
self.interval = interval
|
self.interval = interval
|
||||||
subscriptions = []
|
subscriptions = []
|
||||||
|
|
||||||
observeObjects()
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private extension ConnectionObserver {
|
public func observeObjects() {
|
||||||
func observeObjects() {
|
|
||||||
tunnel
|
tunnel
|
||||||
.$status
|
.$status
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
|
|
|
@ -32,28 +32,14 @@ protocol ProfileProcessor {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Tunnel {
|
extension Tunnel {
|
||||||
func reinstate(_ profile: Profile, processor: ProfileProcessor) async throws {
|
func install(_ profile: Profile, processor: ProfileProcessor) async throws {
|
||||||
try await install(profile, processor: processor)
|
let newProfile = try processor.processedProfile(profile)
|
||||||
|
try await install(newProfile, connect: false, title: \.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func connect(with profile: Profile, processor: ProfileProcessor) async throws {
|
func connect(with profile: Profile, processor: ProfileProcessor) async throws {
|
||||||
try await install(profile, processor: processor)
|
let newProfile = try processor.processedProfile(profile)
|
||||||
guard !Task.isCancelled else {
|
try await install(newProfile, connect: true, title: \.name)
|
||||||
return
|
|
||||||
}
|
|
||||||
try await connect()
|
|
||||||
}
|
|
||||||
|
|
||||||
func reconnect(with profile: Profile, processor: ProfileProcessor) async throws {
|
|
||||||
try await disconnect()
|
|
||||||
guard !Task.isCancelled else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try await install(profile, processor: processor)
|
|
||||||
guard !Task.isCancelled else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try await connect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentLog(parameters: Constants.Log) async -> [String] {
|
func currentLog(parameters: Constants.Log) async -> [String] {
|
||||||
|
@ -70,10 +56,3 @@ extension Tunnel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension Tunnel {
|
|
||||||
func install(_ profile: Profile, processor: ProfileProcessor) async throws {
|
|
||||||
let newProfile = try processor.processedProfile(profile)
|
|
||||||
try await install(profile: newProfile, title: \.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -36,25 +36,25 @@ protocol TunnelInstallationProviding {
|
||||||
struct TunnelInstallation {
|
struct TunnelInstallation {
|
||||||
let header: ProfileHeader
|
let header: ProfileHeader
|
||||||
|
|
||||||
let isEnabled: Bool
|
let onDemand: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
extension TunnelInstallationProviding {
|
extension TunnelInstallationProviding {
|
||||||
var installation: TunnelInstallation? {
|
var installation: TunnelInstallation? {
|
||||||
guard let installedProfile = tunnel.installedProfile else {
|
guard let currentProfile = tunnel.currentProfile else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
guard let header = profileManager.headers.first(where: {
|
guard let header = profileManager.headers.first(where: {
|
||||||
$0.id == installedProfile.id
|
$0.id == currentProfile.id
|
||||||
}) else {
|
}) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return TunnelInstallation(header: header, isEnabled: installedProfile.isEnabled)
|
return TunnelInstallation(header: header, onDemand: currentProfile.onDemand)
|
||||||
}
|
}
|
||||||
|
|
||||||
var installedProfile: Profile? {
|
var currentProfile: Profile? {
|
||||||
guard let id = tunnel.installedProfile?.id else {
|
guard let id = tunnel.currentProfile?.id else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return profileManager.profile(withId: id)
|
return profileManager.profile(withId: id)
|
||||||
|
|
|
@ -265,8 +265,6 @@ public enum Strings {
|
||||||
public static let storage = Strings.tr("Localizable", "global.storage", fallback: "Storage")
|
public static let storage = Strings.tr("Localizable", "global.storage", fallback: "Storage")
|
||||||
/// Subnet
|
/// Subnet
|
||||||
public static let subnet = Strings.tr("Localizable", "global.subnet", fallback: "Subnet")
|
public static let subnet = Strings.tr("Localizable", "global.subnet", fallback: "Subnet")
|
||||||
/// Uninstall
|
|
||||||
public static let uninstall = Strings.tr("Localizable", "global.uninstall", fallback: "Uninstall")
|
|
||||||
/// Unknown
|
/// Unknown
|
||||||
public static let unknown = Strings.tr("Localizable", "global.unknown", fallback: "Unknown")
|
public static let unknown = Strings.tr("Localizable", "global.unknown", fallback: "Unknown")
|
||||||
/// Username
|
/// Username
|
||||||
|
|
|
@ -58,7 +58,6 @@
|
||||||
"global.status" = "Status";
|
"global.status" = "Status";
|
||||||
"global.storage" = "Storage";
|
"global.storage" = "Storage";
|
||||||
"global.subnet" = "Subnet";
|
"global.subnet" = "Subnet";
|
||||||
"global.uninstall" = "Uninstall";
|
|
||||||
"global.unknown" = "Unknown";
|
"global.unknown" = "Unknown";
|
||||||
"global.username" = "Username";
|
"global.username" = "Username";
|
||||||
"global.version" = "Version";
|
"global.version" = "Version";
|
||||||
|
|
|
@ -85,7 +85,7 @@ private extension ProfileGridView {
|
||||||
InstalledProfileView(
|
InstalledProfileView(
|
||||||
layout: .grid,
|
layout: .grid,
|
||||||
profileManager: profileManager,
|
profileManager: profileManager,
|
||||||
profile: installedProfile,
|
profile: currentProfile,
|
||||||
tunnel: tunnel,
|
tunnel: tunnel,
|
||||||
interactiveManager: interactiveManager,
|
interactiveManager: interactiveManager,
|
||||||
errorHandler: errorHandler,
|
errorHandler: errorHandler,
|
||||||
|
@ -95,7 +95,7 @@ private extension ProfileGridView {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
installedProfile.map {
|
currentProfile.map {
|
||||||
ProfileContextMenu(
|
ProfileContextMenu(
|
||||||
profileManager: profileManager,
|
profileManager: profileManager,
|
||||||
tunnel: tunnel,
|
tunnel: tunnel,
|
||||||
|
@ -121,7 +121,7 @@ private extension ProfileGridView {
|
||||||
withMarker: true,
|
withMarker: true,
|
||||||
onEdit: onEdit
|
onEdit: onEdit
|
||||||
)
|
)
|
||||||
.themeGridCell(isSelected: header.id == nextProfileId ?? tunnel.installedProfile?.id)
|
.themeGridCell(isSelected: header.id == nextProfileId ?? tunnel.currentProfile?.id)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
ProfileContextMenu(
|
ProfileContextMenu(
|
||||||
profileManager: profileManager,
|
profileManager: profileManager,
|
||||||
|
|
|
@ -76,7 +76,7 @@ private extension ProfileListView {
|
||||||
InstalledProfileView(
|
InstalledProfileView(
|
||||||
layout: .list,
|
layout: .list,
|
||||||
profileManager: profileManager,
|
profileManager: profileManager,
|
||||||
profile: installedProfile,
|
profile: currentProfile,
|
||||||
tunnel: tunnel,
|
tunnel: tunnel,
|
||||||
interactiveManager: interactiveManager,
|
interactiveManager: interactiveManager,
|
||||||
errorHandler: errorHandler,
|
errorHandler: errorHandler,
|
||||||
|
@ -86,7 +86,7 @@ private extension ProfileListView {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
installedProfile.map {
|
currentProfile.map {
|
||||||
ProfileContextMenu(
|
ProfileContextMenu(
|
||||||
profileManager: profileManager,
|
profileManager: profileManager,
|
||||||
tunnel: tunnel,
|
tunnel: tunnel,
|
||||||
|
|
|
@ -52,9 +52,6 @@ struct ProfileContextMenu: View {
|
||||||
profileEditButton
|
profileEditButton
|
||||||
profileDuplicateButton
|
profileDuplicateButton
|
||||||
Divider()
|
Divider()
|
||||||
if isInstalledProfile {
|
|
||||||
tunnelUninstallButton
|
|
||||||
}
|
|
||||||
profileRemoveButton
|
profileRemoveButton
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -86,12 +83,6 @@ private extension ProfileContextMenu {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var tunnelUninstallButton: some View {
|
|
||||||
TunnelUninstallButton(tunnel: tunnel) {
|
|
||||||
ThemeImageLabel(Strings.Global.uninstall, .tunnelUninstall)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var profileEditButton: some View {
|
var profileEditButton: some View {
|
||||||
Button {
|
Button {
|
||||||
onEdit(header)
|
onEdit(header)
|
||||||
|
|
|
@ -71,7 +71,7 @@ struct ProfileRowView: View, TunnelContextProviding {
|
||||||
private extension ProfileRowView {
|
private extension ProfileRowView {
|
||||||
var markerView: some View {
|
var markerView: some View {
|
||||||
ThemeImage(header.id == nextProfileId ? .pending : statusImage)
|
ThemeImage(header.id == nextProfileId ? .pending : statusImage)
|
||||||
.opacity(header.id == nextProfileId || header.id == tunnel.installedProfile?.id ? 1.0 : 0.0)
|
.opacity(header.id == nextProfileId || header.id == tunnel.currentProfile?.id ? 1.0 : 0.0)
|
||||||
.frame(width: 24.0)
|
.frame(width: 24.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -55,7 +55,7 @@ struct TunnelRestartButton<Label>: View where Label: View {
|
||||||
pendingTask?.cancel()
|
pendingTask?.cancel()
|
||||||
pendingTask = Task {
|
pendingTask = Task {
|
||||||
do {
|
do {
|
||||||
try await tunnel.reconnect(with: profile, processor: iapManager)
|
try await tunnel.connect(with: profile, processor: iapManager)
|
||||||
} catch {
|
} catch {
|
||||||
errorHandler.handle(
|
errorHandler.handle(
|
||||||
error,
|
error,
|
||||||
|
|
|
@ -77,7 +77,7 @@ struct TunnelToggleButton<Label>: View, TunnelContextProviding, ThemeProviding w
|
||||||
|
|
||||||
private extension TunnelToggleButton {
|
private extension TunnelToggleButton {
|
||||||
var isInstalled: Bool {
|
var isInstalled: Bool {
|
||||||
profile?.id == tunnel.installedProfile?.id
|
profile?.id == tunnel.currentProfile?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
var canConnect: Bool {
|
var canConnect: Bool {
|
||||||
|
@ -127,7 +127,7 @@ private extension TunnelToggleButton {
|
||||||
try await tunnel.disconnect()
|
try await tunnel.disconnect()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try await tunnel.reconnect(with: profile, processor: iapManager)
|
try await tunnel.connect(with: profile, processor: iapManager)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
errorHandler.handle(
|
errorHandler.handle(
|
||||||
|
|
|
@ -1,50 +0,0 @@
|
||||||
//
|
|
||||||
// TunnelUninstallButton.swift
|
|
||||||
// Passepartout
|
|
||||||
//
|
|
||||||
// Created by Davide De Rosa on 9/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 PassepartoutKit
|
|
||||||
import SwiftUI
|
|
||||||
import UtilsLibrary
|
|
||||||
|
|
||||||
struct TunnelUninstallButton<Label>: View where Label: View {
|
|
||||||
|
|
||||||
@ObservedObject
|
|
||||||
var tunnel: Tunnel
|
|
||||||
|
|
||||||
let label: () -> Label
|
|
||||||
|
|
||||||
@State
|
|
||||||
private var pendingTask: Task<Void, Error>?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button {
|
|
||||||
pendingTask?.cancel()
|
|
||||||
pendingTask = Task {
|
|
||||||
try await tunnel.uninstall()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
label()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -37,10 +37,10 @@ extension ConnectionObserverTests {
|
||||||
let env = InMemoryEnvironment()
|
let env = InMemoryEnvironment()
|
||||||
let tunnel = Tunnel(strategy: FakeTunnelStrategy(environment: env))
|
let tunnel = Tunnel(strategy: FakeTunnelStrategy(environment: env))
|
||||||
let sut = ConnectionObserver(tunnel: tunnel, environment: env, interval: 0.1)
|
let sut = ConnectionObserver(tunnel: tunnel, environment: env, interval: 0.1)
|
||||||
|
sut.observeObjects()
|
||||||
|
|
||||||
let profile = try Profile.Builder().tryBuild()
|
let profile = try Profile.Builder().tryBuild()
|
||||||
try await tunnel.install(profile: profile, title: \.name)
|
try await tunnel.install(profile, connect: true, title: \.name)
|
||||||
try await tunnel.connect()
|
|
||||||
env.setEnvironmentValue(.crypto, forKey: TunnelEnvironmentKeys.lastErrorCode)
|
env.setEnvironmentValue(.crypto, forKey: TunnelEnvironmentKeys.lastErrorCode)
|
||||||
|
|
||||||
try await tunnel.disconnect()
|
try await tunnel.disconnect()
|
||||||
|
@ -52,15 +52,16 @@ extension ConnectionObserverTests {
|
||||||
let env = InMemoryEnvironment()
|
let env = InMemoryEnvironment()
|
||||||
let tunnel = Tunnel(strategy: FakeTunnelStrategy(environment: env))
|
let tunnel = Tunnel(strategy: FakeTunnelStrategy(environment: env))
|
||||||
let sut = ConnectionObserver(tunnel: tunnel, environment: env, interval: 0.1)
|
let sut = ConnectionObserver(tunnel: tunnel, environment: env, interval: 0.1)
|
||||||
|
sut.observeObjects()
|
||||||
|
|
||||||
let profile = try Profile.Builder().tryBuild()
|
let profile = try Profile.Builder().tryBuild()
|
||||||
try await tunnel.install(profile: profile, title: \.name)
|
try await tunnel.install(profile, connect: false, title: \.name)
|
||||||
|
|
||||||
let dataCount = DataCount(500, 700)
|
let dataCount = DataCount(500, 700)
|
||||||
env.setEnvironmentValue(dataCount, forKey: TunnelEnvironmentKeys.dataCount)
|
env.setEnvironmentValue(dataCount, forKey: TunnelEnvironmentKeys.dataCount)
|
||||||
XCTAssertEqual(sut.dataCount, nil)
|
XCTAssertEqual(sut.dataCount, nil)
|
||||||
|
|
||||||
try await tunnel.connect()
|
try await tunnel.install(profile, connect: true, title: \.name)
|
||||||
try await Task.sleep(for: .milliseconds(200))
|
try await Task.sleep(for: .milliseconds(200))
|
||||||
XCTAssertEqual(sut.dataCount, dataCount)
|
XCTAssertEqual(sut.dataCount, dataCount)
|
||||||
}
|
}
|
||||||
|
|
|
@ -230,10 +230,16 @@ extension ProfileEditorTests {
|
||||||
|
|
||||||
let exp = expectation(description: "Save")
|
let exp = expectation(description: "Save")
|
||||||
manager
|
manager
|
||||||
.didSave
|
.didChange
|
||||||
.sink {
|
.sink {
|
||||||
XCTAssertEqual($0, profile)
|
switch $0 {
|
||||||
exp.fulfill()
|
case .save(let savedProfile):
|
||||||
|
XCTAssertEqual(savedProfile, profile)
|
||||||
|
exp.fulfill()
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.store(in: &subscriptions)
|
.store(in: &subscriptions)
|
||||||
|
|
||||||
|
|
|
@ -57,11 +57,17 @@ extension ProfileImporterTests {
|
||||||
|
|
||||||
let exp = expectation(description: "Save")
|
let exp = expectation(description: "Save")
|
||||||
profileManager
|
profileManager
|
||||||
.didSave
|
.didChange
|
||||||
.sink { profile in
|
.sink {
|
||||||
XCTAssertEqual(profile.modules.count, 1)
|
switch $0 {
|
||||||
XCTAssertTrue(profile.modules.first is SomeModule)
|
case .save(let profile):
|
||||||
exp.fulfill()
|
XCTAssertEqual(profile.modules.count, 1)
|
||||||
|
XCTAssertTrue(profile.modules.first is SomeModule)
|
||||||
|
exp.fulfill()
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.store(in: &subscriptions)
|
.store(in: &subscriptions)
|
||||||
|
|
||||||
|
@ -82,11 +88,17 @@ extension ProfileImporterTests {
|
||||||
|
|
||||||
let exp = expectation(description: "Save")
|
let exp = expectation(description: "Save")
|
||||||
profileManager
|
profileManager
|
||||||
.didSave
|
.didChange
|
||||||
.sink { profile in
|
.sink {
|
||||||
XCTAssertEqual(profile.modules.count, 1)
|
switch $0 {
|
||||||
XCTAssertTrue(profile.modules.first is SomeModule)
|
case .save(let profile):
|
||||||
exp.fulfill()
|
XCTAssertEqual(profile.modules.count, 1)
|
||||||
|
XCTAssertTrue(profile.modules.first is SomeModule)
|
||||||
|
exp.fulfill()
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.store(in: &subscriptions)
|
.store(in: &subscriptions)
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue