Review incorrect behavior in preferences (#989)
- Save/rollback was done outside of MOC - Use different contexts for module/provider preferences - Save providers → also saves modules - Discard modules → also discards providers - Use background context because it's not automatically merged (can rollback) - Expose ModulePreferences in OpenVPNView as StateObject - Rework Blacklist to a more reusable ObservableList - Reapply #988
This commit is contained in:
parent
c72a69829b
commit
93a15cd766
|
@ -87,6 +87,7 @@ private final class CDModulePreferencesRepositoryV3: ModulePreferencesRepository
|
|||
}
|
||||
|
||||
func save() throws {
|
||||
try context.performAndWait {
|
||||
guard context.hasChanges else {
|
||||
return
|
||||
}
|
||||
|
@ -98,3 +99,10 @@ private final class CDModulePreferencesRepositoryV3: ModulePreferencesRepository
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func discard() {
|
||||
context.performAndWait {
|
||||
context.rollback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -112,6 +112,7 @@ private final class CDProviderPreferencesRepositoryV3: ProviderPreferencesReposi
|
|||
}
|
||||
|
||||
func save() throws {
|
||||
try context.performAndWait {
|
||||
guard context.hasChanges else {
|
||||
return
|
||||
}
|
||||
|
@ -123,3 +124,4 @@ private final class CDProviderPreferencesRepositoryV3: ProviderPreferencesReposi
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@ extension OpenVPNView {
|
|||
let credentialsRoute: (any Hashable)?
|
||||
|
||||
@ObservedObject
|
||||
var allowedEndpoints: Blacklist<ExtendedEndpoint>
|
||||
var excludedEndpoints: ObservableList<ExtendedEndpoint>
|
||||
|
||||
var body: some View {
|
||||
moduleSection(for: accountRows, header: Strings.Global.Nouns.account)
|
||||
|
@ -75,7 +75,7 @@ private extension OpenVPNView.ConfigurationView {
|
|||
SelectableRemoteButton(
|
||||
remote: remote,
|
||||
all: Set(remotes),
|
||||
allowedEndpoints: allowedEndpoints
|
||||
excludedEndpoints: excludedEndpoints
|
||||
)
|
||||
}
|
||||
.themeSection(header: Strings.Modules.Openvpn.remotes)
|
||||
|
@ -89,16 +89,16 @@ private struct SelectableRemoteButton: View {
|
|||
let all: Set<ExtendedEndpoint>
|
||||
|
||||
@ObservedObject
|
||||
var allowedEndpoints: Blacklist<ExtendedEndpoint>
|
||||
var excludedEndpoints: ObservableList<ExtendedEndpoint>
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
if allowedEndpoints.isAllowed(remote) {
|
||||
if remaining.count > 1 {
|
||||
allowedEndpoints.deny(remote)
|
||||
}
|
||||
if excludedEndpoints.contains(remote) {
|
||||
excludedEndpoints.remove(remote)
|
||||
} else {
|
||||
allowedEndpoints.allow(remote)
|
||||
if remaining.count > 1 {
|
||||
excludedEndpoints.add(remote)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
|
@ -112,7 +112,7 @@ private struct SelectableRemoteButton: View {
|
|||
}
|
||||
Spacer()
|
||||
ThemeImage(.marked)
|
||||
.opaque(allowedEndpoints.isAllowed(remote))
|
||||
.opaque(!excludedEndpoints.contains(remote))
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
|
@ -121,7 +121,7 @@ private struct SelectableRemoteButton: View {
|
|||
|
||||
private var remaining: Set<ExtendedEndpoint> {
|
||||
all.filter {
|
||||
allowedEndpoints.isAllowed($0)
|
||||
!excludedEndpoints.contains($0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -340,11 +340,11 @@ private extension OpenVPNView.ConfigurationView {
|
|||
struct Preview: View {
|
||||
|
||||
@StateObject
|
||||
private var allowedEndpoints = Blacklist<ExtendedEndpoint> { _ in
|
||||
private var excludedEndpoints = ObservableList<ExtendedEndpoint> { _ in
|
||||
true
|
||||
} allow: { _ in
|
||||
} add: { _ in
|
||||
//
|
||||
} deny: { _ in
|
||||
} remove: { _ in
|
||||
//
|
||||
}
|
||||
|
||||
|
@ -354,7 +354,7 @@ private extension OpenVPNView.ConfigurationView {
|
|||
isServerPushed: false,
|
||||
configuration: .forPreviews,
|
||||
credentialsRoute: nil,
|
||||
allowedEndpoints: allowedEndpoints
|
||||
excludedEndpoints: excludedEndpoints
|
||||
)
|
||||
}
|
||||
.withMockEnvironment()
|
||||
|
|
|
@ -51,6 +51,9 @@ struct OpenVPNView: View, ModuleDraftEditing {
|
|||
@State
|
||||
private var paywallReason: PaywallReason?
|
||||
|
||||
@StateObject
|
||||
private var preferences = ModulePreferences()
|
||||
|
||||
@StateObject
|
||||
private var providerPreferences = ProviderPreferences()
|
||||
|
||||
|
@ -85,6 +88,13 @@ struct OpenVPNView: View, ModuleDraftEditing {
|
|||
.navigationDestination(for: Subroute.self, destination: destination)
|
||||
.themeAnimation(on: draft.wrappedValue.providerId, category: .modules)
|
||||
.withErrorHandler(errorHandler)
|
||||
.onLoad {
|
||||
editor.loadPreferences(
|
||||
preferences,
|
||||
from: preferencesManager,
|
||||
forModuleWithId: module.id
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -99,7 +109,7 @@ private extension OpenVPNView {
|
|||
isServerPushed: isServerPushed,
|
||||
configuration: configuration,
|
||||
credentialsRoute: Subroute.credentials,
|
||||
allowedEndpoints: allowedEndpoints
|
||||
excludedEndpoints: excludedEndpoints
|
||||
)
|
||||
} else {
|
||||
emptyConfigurationView
|
||||
|
@ -175,7 +185,7 @@ private extension OpenVPNView {
|
|||
isServerPushed: false,
|
||||
configuration: configuration.builder(),
|
||||
credentialsRoute: nil,
|
||||
allowedEndpoints: allowedEndpoints
|
||||
excludedEndpoints: excludedEndpoints
|
||||
)
|
||||
}
|
||||
.themeForm()
|
||||
|
@ -199,15 +209,11 @@ private extension OpenVPNView {
|
|||
// MARK: - Logic
|
||||
|
||||
private extension OpenVPNView {
|
||||
var preferences: ModulePreferences {
|
||||
editor.preferences(forModuleWithId: module.id, manager: preferencesManager)
|
||||
}
|
||||
|
||||
var allowedEndpoints: Blacklist<ExtendedEndpoint> {
|
||||
var excludedEndpoints: ObservableList<ExtendedEndpoint> {
|
||||
if draft.wrappedValue.providerSelection != nil {
|
||||
return providerPreferences.allowedEndpoints()
|
||||
return providerPreferences.excludedEndpoints()
|
||||
} else {
|
||||
return preferences.allowedEndpoints()
|
||||
return preferences.excludedEndpoints()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -136,10 +136,7 @@ private extension ProfileCoordinator {
|
|||
|
||||
// standard: always save, warn if purchase required
|
||||
func onCommitEditingStandard() async throws {
|
||||
let savedProfile = try await profileEditor.save(
|
||||
to: profileManager,
|
||||
preferencesManager: preferencesManager
|
||||
)
|
||||
let savedProfile = try await profileEditor.save(to: profileManager)
|
||||
do {
|
||||
try iapManager.verify(savedProfile)
|
||||
} catch AppError.ineligibleProfile(let requiredFeatures) {
|
||||
|
@ -157,10 +154,7 @@ private extension ProfileCoordinator {
|
|||
paywallReason = .init(requiredFeatures)
|
||||
return
|
||||
}
|
||||
try await profileEditor.save(
|
||||
to: profileManager,
|
||||
preferencesManager: preferencesManager
|
||||
)
|
||||
try await profileEditor.save(to: profileManager)
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
|
|
|
@ -85,6 +85,9 @@ private final class DummyModulePreferencesRepository: ModulePreferencesRepositor
|
|||
|
||||
func save() throws {
|
||||
}
|
||||
|
||||
func discard() {
|
||||
}
|
||||
}
|
||||
|
||||
private final class DummyProviderPreferencesRepository: ProviderPreferencesRepository {
|
||||
|
|
|
@ -34,17 +34,13 @@ public final class ModulePreferences: ObservableObject {
|
|||
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
|
||||
public func excludedEndpoints() -> ObservableList<ExtendedEndpoint> {
|
||||
ObservableList { [weak self] in
|
||||
self?.repository?.isExcludedEndpoint($0) == true
|
||||
} add: { [weak self] in
|
||||
self?.repository?.addExcludedEndpoint($0)
|
||||
} remove: { [weak self] in
|
||||
self?.repository?.removeExcludedEndpoint($0)
|
||||
}
|
||||
}
|
||||
|
||||
public func save() throws {
|
||||
try repository?.save()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,13 +44,13 @@ public final class ProviderPreferences: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
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
|
||||
public func excludedEndpoints() -> ObservableList<ExtendedEndpoint> {
|
||||
ObservableList { [weak self] in
|
||||
self?.repository?.isExcludedEndpoint($0) == true
|
||||
} add: { [weak self] in
|
||||
self?.repository?.addExcludedEndpoint($0)
|
||||
} remove: { [weak self] in
|
||||
self?.repository?.removeExcludedEndpoint($0)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -34,4 +34,6 @@ public protocol ModulePreferencesRepository {
|
|||
func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint)
|
||||
|
||||
func save() throws
|
||||
|
||||
func discard()
|
||||
}
|
||||
|
|
|
@ -114,7 +114,7 @@ extension CoreDataPersistentStore {
|
|||
container.viewContext
|
||||
}
|
||||
|
||||
public var backgroundContext: NSManagedObjectContext {
|
||||
public func backgroundContext() -> NSManagedObjectContext {
|
||||
container.newBackgroundContext()
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// Blacklist.swift
|
||||
// ObservableList.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 12/8/24.
|
||||
|
@ -26,34 +26,34 @@
|
|||
import Foundation
|
||||
|
||||
@MainActor
|
||||
public final class Blacklist<T>: ObservableObject where T: Equatable {
|
||||
private let isAllowed: (T) -> Bool
|
||||
public final class ObservableList<T>: ObservableObject where T: Equatable {
|
||||
private let contains: (T) -> Bool
|
||||
|
||||
private let allow: (T) -> Void
|
||||
private let add: (T) -> Void
|
||||
|
||||
private let deny: (T) -> Void
|
||||
private let remove: (T) -> Void
|
||||
|
||||
public init(
|
||||
isAllowed: @escaping (T) -> Bool,
|
||||
allow: @escaping (T) -> Void,
|
||||
deny: @escaping (T) -> Void
|
||||
contains: @escaping (T) -> Bool,
|
||||
add: @escaping (T) -> Void,
|
||||
remove: @escaping (T) -> Void
|
||||
) {
|
||||
self.isAllowed = isAllowed
|
||||
self.allow = allow
|
||||
self.deny = deny
|
||||
self.contains = contains
|
||||
self.add = add
|
||||
self.remove = remove
|
||||
}
|
||||
|
||||
public func isAllowed(_ value: T) -> Bool {
|
||||
isAllowed(value)
|
||||
public func contains(_ value: T) -> Bool {
|
||||
contains(value)
|
||||
}
|
||||
|
||||
public func allow(_ value: T) {
|
||||
public func add(_ value: T) {
|
||||
objectWillChange.send()
|
||||
allow(value)
|
||||
add(value)
|
||||
}
|
||||
|
||||
public func deny(_ value: T) {
|
||||
public func remove(_ value: T) {
|
||||
objectWillChange.send()
|
||||
deny(value)
|
||||
remove(value)
|
||||
}
|
||||
}
|
|
@ -66,8 +66,8 @@ public final class ProfileV2MigrationStrategy: ProfileMigrationStrategy, Sendabl
|
|||
cloudKitIdentifier: tvProfilesContainer.cloudKitIdentifier,
|
||||
author: nil
|
||||
)
|
||||
profilesRepository = CDProfileRepositoryV2(context: store.backgroundContext)
|
||||
tvProfilesRepository = CDProfileRepositoryV2(context: tvStore.backgroundContext)
|
||||
profilesRepository = CDProfileRepositoryV2(context: store.backgroundContext())
|
||||
tvProfilesRepository = CDProfileRepositoryV2(context: tvStore.backgroundContext())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -37,7 +37,8 @@ public final class ProfileEditor: ObservableObject {
|
|||
@Published
|
||||
public var isShared: Bool
|
||||
|
||||
private var trackedPreferences: [UUID: ModulePreferences]
|
||||
@Published
|
||||
private var trackedPreferences: [UUID: ModulePreferencesRepository]
|
||||
|
||||
private(set) var removedModules: [UUID: any ModuleBuilder]
|
||||
|
||||
|
@ -207,23 +208,23 @@ extension ProfileEditor {
|
|||
removedModules = [:]
|
||||
}
|
||||
|
||||
public func preferences(forModuleWithId moduleId: UUID, manager: PreferencesManager) -> ModulePreferences {
|
||||
public func loadPreferences(
|
||||
_ preferences: ModulePreferences,
|
||||
from manager: PreferencesManager,
|
||||
forModuleWithId moduleId: UUID
|
||||
) {
|
||||
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
|
||||
let repository = try trackedPreferences[moduleId] ?? manager.preferencesRepository(forModuleWithId: moduleId)
|
||||
preferences.repository = repository
|
||||
trackedPreferences[moduleId] = repository // @Published
|
||||
} catch {
|
||||
pp_log(.App.profiles, .error, "Unable to track preferences for module \(moduleId): \(error)")
|
||||
return ModulePreferences()
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func save(
|
||||
to profileManager: ProfileManager,
|
||||
preferencesManager: PreferencesManager
|
||||
) async throws -> Profile {
|
||||
public func save(to profileManager: ProfileManager) async throws -> Profile {
|
||||
do {
|
||||
let newProfile = try build()
|
||||
try await profileManager.save(newProfile, isLocal: true, remotelyShared: isShared)
|
||||
|
@ -244,6 +245,11 @@ extension ProfileEditor {
|
|||
}
|
||||
|
||||
public func discard() {
|
||||
trackedPreferences.forEach {
|
||||
pp_log(.App.profiles, .debug, "Discard tracked preferences for module \($0.key)")
|
||||
$0.value.discard()
|
||||
}
|
||||
trackedPreferences.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -251,7 +251,7 @@ extension ProfileEditorTests {
|
|||
}
|
||||
.store(in: &subscriptions)
|
||||
|
||||
try await sut.save(to: manager, preferencesManager: PreferencesManager())
|
||||
try await sut.save(to: manager)
|
||||
await fulfillment(of: [exp])
|
||||
}
|
||||
}
|
||||
|
|
|
@ -93,7 +93,7 @@ extension AppContext {
|
|||
cloudKitIdentifier: nil,
|
||||
author: nil
|
||||
)
|
||||
let repository = AppData.cdProviderRepositoryV3(context: store.backgroundContext)
|
||||
let repository = AppData.cdProviderRepositoryV3(context: store.backgroundContext())
|
||||
return ProviderManager(repository: repository)
|
||||
}()
|
||||
|
||||
|
|
|
@ -42,10 +42,16 @@ extension Dependencies {
|
|||
)
|
||||
return PreferencesManager(
|
||||
modulesFactory: {
|
||||
try AppData.cdModulePreferencesRepositoryV3(context: preferencesStore.context, moduleId: $0)
|
||||
try AppData.cdModulePreferencesRepositoryV3(
|
||||
context: preferencesStore.backgroundContext(),
|
||||
moduleId: $0
|
||||
)
|
||||
},
|
||||
providersFactory: {
|
||||
try AppData.cdProviderPreferencesRepositoryV3(context: preferencesStore.context, providerId: $0)
|
||||
try AppData.cdProviderPreferencesRepositoryV3(
|
||||
context: preferencesStore.backgroundContext(),
|
||||
providerId: $0
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue