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:
Davide 2024-09-30 14:56:20 +02:00 committed by GitHub
parent a29495a69c
commit a9fa6a2f62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 193 additions and 212 deletions

View File

@ -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"
} }
}, },
{ {

View File

@ -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"),

View File

@ -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)
}
}
}

View File

@ -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()
}
} }
} }

View File

@ -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 = []
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() 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)
default:
break
} }
guard profile.id == tunnel.installedProfile?.id else { }
.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 return
} }
Task { Task {
if profile.isInteractive { if profile.isInteractive {
try await self.tunnel.disconnect() try await tunnel.disconnect()
return return
} }
if self.tunnel.status == .active { if tunnel.status == .active {
try await self.tunnel.reconnect(with: profile, processor: self.iapManager) try await tunnel.connect(with: profile, processor: iapManager)
} else {
try await self.tunnel.reinstate(profile, processor: self.iapManager)
} }
} }
} }
.store(in: &subscriptions)
profileManager
.didUpdate
.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)
}
} }

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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

View File

@ -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";

View File

@ -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,

View File

@ -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,

View File

@ -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)

View File

@ -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)
} }

View File

@ -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,

View File

@ -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(

View File

@ -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()
}
}
}

View File

@ -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)
} }

View File

@ -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 {
case .save(let savedProfile):
XCTAssertEqual(savedProfile, profile)
exp.fulfill() exp.fulfill()
default:
break
}
} }
.store(in: &subscriptions) .store(in: &subscriptions)

View File

@ -57,11 +57,17 @@ extension ProfileImporterTests {
let exp = expectation(description: "Save") let exp = expectation(description: "Save")
profileManager profileManager
.didSave .didChange
.sink { profile in .sink {
switch $0 {
case .save(let profile):
XCTAssertEqual(profile.modules.count, 1) XCTAssertEqual(profile.modules.count, 1)
XCTAssertTrue(profile.modules.first is SomeModule) XCTAssertTrue(profile.modules.first is SomeModule)
exp.fulfill() 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 {
switch $0 {
case .save(let profile):
XCTAssertEqual(profile.modules.count, 1) XCTAssertEqual(profile.modules.count, 1)
XCTAssertTrue(profile.modules.first is SomeModule) XCTAssertTrue(profile.modules.first is SomeModule)
exp.fulfill() exp.fulfill()
default:
break
}
} }
.store(in: &subscriptions) .store(in: &subscriptions)