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