From 615f7d47bd10555ed5467d3affa7cb9217a31e87 Mon Sep 17 00:00:00 2001 From: Davide Date: Thu, 14 Nov 2024 15:11:25 +0100 Subject: [PATCH] Import migrated profiles (#867) Finalize basic flow started in #866. --- .../AppUIMain/Views/App/AppCoordinator.swift | 7 +- .../Views/Migration/MigrateView+Content.swift | 145 ++++++++++++ .../Views/Migration/MigrateView+Model.swift | 95 ++++++++ .../Views/Migration/MigrateView+Section.swift | 93 +++++--- .../Views/Migration/MigrateView+Table.swift | 71 +++--- .../Views/Migration/MigrateView.swift | 217 +++++++----------- .../Business/MigrationManager.swift | 77 +++++-- .../Domain/MigratableProfile.swift | 6 + .../Domain/MigrationStatus.swift | 8 +- Passepartout/Shared/AppContext+Shared.swift | 1 + 10 files changed, 512 insertions(+), 208 deletions(-) create mode 100644 Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Content.swift create mode 100644 Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Model.swift diff --git a/Passepartout/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift b/Passepartout/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift index 4bc0163d..23b1ac96 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift @@ -186,8 +186,11 @@ extension AppCoordinator { ) case .migrateProfiles: - MigrateView(style: migrateViewStyle) - .themeNavigationStack(closable: true, path: $migrationPath) + MigrateView( + style: migrateViewStyle, + profileManager: profileManager + ) + .themeNavigationStack(closable: true, path: $migrationPath) case .settings: SettingsView(profileManager: profileManager) diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Content.swift b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Content.swift new file mode 100644 index 00000000..71cad4ba --- /dev/null +++ b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Content.swift @@ -0,0 +1,145 @@ +// +// MigrateView+Content.swift +// Passepartout +// +// Created by Davide De Rosa on 11/14/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 . +// + +import CommonLibrary +import PassepartoutKit +import SwiftUI + +extension MigrateView { + struct ContentView: View { + let style: Style + + let step: Model.Step + + let profiles: [MigratableProfile] + + @Binding + var statuses: [UUID: MigrationStatus] + + var body: some View { + switch style { + case .section: + MigrateView.SectionView( + step: step, + profiles: profiles, + statuses: $statuses + ) + + case .table: + MigrateView.TableView( + step: step, + profiles: profiles, + statuses: $statuses + ) + } + } + } +} + +extension Optional where Wrapped == MigrationStatus { + var style: some ShapeStyle { + self != .excluded ? .primary : .secondary + } +} + +extension Dictionary where Key == UUID, Value == MigrationStatus { + func style(for profileId: UUID) -> some ShapeStyle { + self[profileId].style + } +} + +// MARK: - Previews + +#Preview("Fetched") { + PrivatePreviews.MigratePreview( + step: .fetched, + profiles: PrivatePreviews.profiles, + initialStatuses: [ + PrivatePreviews.profiles[1].id: .excluded, + PrivatePreviews.profiles[2].id: .excluded + ] + ) + .withMockEnvironment() +} + +#Preview("Migrated") { + PrivatePreviews.MigratePreview( + step: .migrated([]), + profiles: PrivatePreviews.profiles, + initialStatuses: [ + PrivatePreviews.profiles[0].id: .excluded, + PrivatePreviews.profiles[1].id: .pending, + PrivatePreviews.profiles[2].id: .migrated, + PrivatePreviews.profiles[3].id: .imported, + PrivatePreviews.profiles[4].id: .failed + ] + ) + .withMockEnvironment() +} + +private struct PrivatePreviews { + static let oneDay: TimeInterval = 24 * 60 * 60 + + static let profiles: [MigratableProfile] = [ + .init(id: UUID(), name: "1 One", lastUpdate: Date().addingTimeInterval(-oneDay)), + .init(id: UUID(), name: "2 Two", lastUpdate: Date().addingTimeInterval(-3 * oneDay)), + .init(id: UUID(), name: "3 Three", lastUpdate: Date().addingTimeInterval(-90 * oneDay)), + .init(id: UUID(), name: "4 Four", lastUpdate: Date().addingTimeInterval(-180 * oneDay)), + .init(id: UUID(), name: "5 Five", lastUpdate: Date().addingTimeInterval(-240 * oneDay)) + ] + + struct MigratePreview: View { + let step: MigrateView.Model.Step + + let profiles: [MigratableProfile] + + let initialStatuses: [UUID: MigrationStatus] + + @State + private var statuses: [UUID: MigrationStatus] = [:] + +#if os(iOS) + private let style: MigrateView.Style = .section +#else + private let style: MigrateView.Style = .table +#endif + + var body: some View { + Form { + MigrateView.ContentView( + style: style, + step: step, + profiles: profiles, + statuses: $statuses + ) + } + .navigationTitle("Migrate") + .themeNavigationStack() + .task { + statuses = initialStatuses + } + } + } +} diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Model.swift b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Model.swift new file mode 100644 index 00000000..0e001680 --- /dev/null +++ b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Model.swift @@ -0,0 +1,95 @@ +// +// MigrateView+Model.swift +// Passepartout +// +// Created by Davide De Rosa on 11/14/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 . +// + +import CommonLibrary +import Foundation +import PassepartoutKit + +extension MigrateView { + struct Model: Equatable { + enum Step: Equatable { + case initial + + case fetching + + case fetched + + case migrating + + case migrated([Profile]) + + case importing + + case imported + } + + var step: Step = .initial + + var profiles: [MigratableProfile] = [] + + var statuses: [UUID: MigrationStatus] = [:] + + mutating func excludeFailed() { + statuses.forEach { + if statuses[$0.key] == .failed { + statuses[$0.key] = .excluded + } + } + } + } +} + +extension MigrateView.Model { + + // XXX: filtering out the excluded rows may crash on macOS, because ThemeImage is + // momentarily removed from the hierarchy and loses access to the Theme + // .environmentObject(). this is certainly a SwiftUI bug + // + // https://github.com/passepartoutvpn/passepartout/pull/867#issuecomment-2476293204 + // + var visibleProfiles: [MigratableProfile] { + profiles +// .filter { +// switch step { +// case .initial, .fetching, .fetched: +// return true +// +// case .migrating, .migrated, .importing, .imported: +// return statuses[$0.id] != .excluded +// } +// } + .sorted { + $0.name.lowercased() < $1.name.lowercased() + } + } + + var selection: Set { + Set(profiles + .filter { + statuses[$0.id] != .excluded + } + .map(\.id)) + } +} diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Section.swift b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Section.swift index a87d7779..fbf98fc5 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Section.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Section.swift @@ -28,54 +28,81 @@ import SwiftUI extension MigrateView { struct SectionView: View { + let step: Model.Step + let profiles: [MigratableProfile] @Binding - var excluded: Set - - let statuses: [UUID: MigrationStatus] + var statuses: [UUID: MigrationStatus] var body: some View { Section { ForEach(profiles, id: \.id) { - if let status = statuses[$0.id] { - row(forProfile: $0, status: status) - } else { + switch step { + case .initial, .fetching, .fetched: button(forProfile: $0) + + default: + row(forProfile: $0, status: statuses[$0.id]) } } } } + } +} - func button(forProfile profile: MigratableProfile) -> some View { - Button { - if excluded.contains(profile.id) { - excluded.remove(profile.id) - } else { - excluded.insert(profile.id) +private extension MigrateView.SectionView { + func button(forProfile profile: MigratableProfile) -> some View { + Button { + if statuses[profile.id] == .excluded { + statuses.removeValue(forKey: profile.id) + } else { + statuses[profile.id] = .excluded + } + } label: { + row(forProfile: profile, status: nil) + } + } + + func row(forProfile profile: MigratableProfile, status: MigrationStatus?) -> some View { + HStack { + CardView(profile: profile) + Spacer() + StatusView(isIncluded: statuses[profile.id] != .excluded, status: status) + } + .foregroundStyle(statuses[profile.id].style) + } +} + +private extension MigrateView.SectionView { + struct CardView: View { + let profile: MigratableProfile + + var body: some View { + VStack(alignment: .leading) { + Text(profile.name) + .font(.headline) + + profile.lastUpdate.map { + Text($0.localizedDescription(style: .timestamp)) + .font(.subheadline) } - } label: { - row(forProfile: profile, status: nil) } } + } +} - func row(forProfile profile: MigratableProfile, status: MigrationStatus?) -> some View { - HStack { - VStack(alignment: .leading) { - Text(profile.name) - .font(.headline) +private extension MigrateView.SectionView { + struct StatusView: View { + let isIncluded: Bool - profile.lastUpdate.map { - Text($0.localizedDescription(style: .timestamp)) - .font(.subheadline) - } - } - Spacer() - if let status { - icon(forStatus: status) - } else if !excluded.contains(profile.id) { - ThemeImage(.marked) - } + let status: MigrationStatus? + + var body: some View { + if let status { + icon(forStatus: status) + } else if isIncluded { + ThemeImage(.marked) } } @@ -83,15 +110,15 @@ extension MigrateView { func icon(forStatus status: MigrationStatus) -> some View { switch status { case .excluded: - EmptyView() + Text("--") case .pending: ProgressView() - case .success: + case .migrated, .imported: ThemeImage(.marked) - case .failure: + case .failed: ThemeImage(.failure) } } diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Table.swift b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Table.swift index b2a580ec..48ca730e 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Table.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Table.swift @@ -28,56 +28,71 @@ import SwiftUI extension MigrateView { struct TableView: View { + let step: Model.Step + let profiles: [MigratableProfile] @Binding - var excluded: Set - - let statuses: [UUID: MigrationStatus] + var statuses: [UUID: MigrationStatus] var body: some View { Table(profiles) { - TableColumn(Strings.Global.name, value: \.name) - TableColumn(Strings.Global.lastUpdate, value: \.timestamp) + TableColumn(Strings.Global.name) { + Text($0.name) + .foregroundStyle(statuses.style(for: $0.id)) + } + TableColumn(Strings.Global.lastUpdate) { + Text($0.timestamp) + .foregroundStyle(statuses.style(for: $0.id)) + } TableColumn("") { profile in - if let status = statuses[profile.id] { - imageName(forStatus: status) - .map { - ThemeImage($0) - } - } else { - Toggle("", isOn: isOnBinding(for: profile.id)) + switch step { + case .initial, .fetching, .fetched: + Toggle("", isOn: isIncludedBinding(for: profile.id)) .labelsHidden() + + default: + if let status = statuses[profile.id] { + StatusView(status: status) + } } } } } + } +} - func isOnBinding(for profileId: UUID) -> Binding { - Binding { - !excluded.contains(profileId) - } set: { - if $0 { - excluded.remove(profileId) - } else { - excluded.insert(profileId) - } +private extension MigrateView.TableView { + func isIncludedBinding(for profileId: UUID) -> Binding { + Binding { + statuses[profileId] != .excluded + } set: { + if $0 { + statuses.removeValue(forKey: profileId) + } else { + statuses[profileId] = .excluded } } + } +} - func imageName(forStatus status: MigrationStatus) -> Theme.ImageName? { +private extension MigrateView.TableView { + struct StatusView: View { + let status: MigrationStatus + + var body: some View { switch status { case .excluded: - return nil + Text("--") case .pending: - return .progress + ThemeImage(.progress) - case .success: - return .marked + case .migrated, .imported: + ThemeImage(.marked) - case .failure: - return .failure + case .failed: + ThemeImage(.failure) } } } diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView.swift b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView.swift index f03febb8..e8767dce 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView.swift @@ -40,39 +40,34 @@ struct MigrateView: View { @EnvironmentObject private var migrationManager: MigrationManager + @Environment(\.dismiss) + private var dismiss + let style: Style - @State - private var isFetching = true + @ObservedObject + var profileManager: ProfileManager @State - private var isMigrating = false - - @State - private var profiles: [MigratableProfile] = [] - - @State - private var excluded: Set = [] - - @State - private var statuses: [UUID: MigrationStatus] = [:] + private var model = Model() @StateObject private var errorHandler: ErrorHandler = .default() var body: some View { Form { - Subview( + ContentView( style: style, - profiles: profiles, - excluded: $excluded, - statuses: statuses + step: model.step, + profiles: model.visibleProfiles, + statuses: $model.statuses ) - .disabled(isMigrating) + .disabled(model.step != .fetched) } .themeForm() - .themeProgress(if: isFetching) - .themeEmptyContent(if: !isFetching && profiles.isEmpty, message: "Nothing to migrate") + .themeProgress(if: model.step == .fetching) + .themeEmptyContent(if: model.step == .fetched && model.profiles.isEmpty, message: "Nothing to migrate") + .themeAnimation(on: model, category: .profiles) .navigationTitle(title) .toolbar(content: toolbarContent) .task { @@ -89,142 +84,106 @@ private extension MigrateView { func toolbarContent() -> some ToolbarContent { ToolbarItem(placement: .confirmationAction) { - Button("Proceed") { + Button(itemTitle(at: model.step)) { Task { - await migrate() + await itemPerform(at: model.step) } } + .disabled(!itemEnabled(at: model.step)) } } } private extension MigrateView { + func itemTitle(at step: Model.Step) -> String { + switch step { + case .initial, .fetching, .fetched: + return "Proceed" + + case .migrating, .migrated: + return "Import" + + case .importing, .imported: + return "Done" + } + } + + func itemEnabled(at step: Model.Step) -> Bool { + switch step { + case .initial, .fetching, .migrating, .importing: + return false + + case .fetched: + return !model.profiles.isEmpty + + case .migrated(let profiles): + return !profiles.isEmpty + + case .imported: + return true + } + } + + func itemPerform(at step: Model.Step) async { + switch step { + case .fetched: + await migrate() + + case .migrated(let profiles): + await save(profiles) + + case .imported: + dismiss() + + default: + fatalError("No action allowed at step \(step)") + } + } + func fetch() async { + guard model.step == .initial else { + return + } do { - isFetching = true - profiles = try await migrationManager.fetchMigratableProfiles() - isFetching = false + model.step = .fetching + let migratable = try await migrationManager.fetchMigratableProfiles() + let knownIDs = Set(profileManager.headers.map(\.id)) + model.profiles = migratable.filter { + !knownIDs.contains($0.id) + } + model.step = .fetched } catch { pp_log(.App.migration, .error, "Unable to fetch migratable profiles: \(error)") errorHandler.handle(error, title: title) - isFetching = false + model.step = .initial } } func migrate() async { + guard model.step == .fetched else { + fatalError("Must call fetch() and succeed") + } do { - isMigrating = true - let selection = Set(profiles.map(\.id)).symmetricDifference(excluded) - let migrated = try await migrationManager.migrateProfiles(profiles, selection: selection) { - statuses[$0] = $1 + model.step = .migrating + let profiles = try await migrationManager.migrateProfiles(model.profiles, selection: model.selection) { + model.statuses[$0] = $1 } - print(">>> Migrated: \(migrated.count)") - _ = migrated - // FIXME: ###, import migrated + model.step = .migrated(profiles) } catch { pp_log(.App.migration, .error, "Unable to migrate profiles: \(error)") errorHandler.handle(error, title: title) } } -} -// MARK: - - -private extension MigrateView { - struct Subview: View { - let style: Style - - let profiles: [MigratableProfile] - - @Binding - var excluded: Set - - let statuses: [UUID: MigrationStatus] - - var body: some View { - switch style { - case .section: - MigrateView.SectionView( - profiles: sortedProfiles, - excluded: $excluded, - statuses: statuses - ) - - case .table: - MigrateView.TableView( - profiles: sortedProfiles, - excluded: $excluded, - statuses: statuses - ) - } + func save(_ profiles: [Profile]) async { + guard case .migrated(let profiles) = model.step, !profiles.isEmpty else { + fatalError("Must call migrate() and succeed with non-empty profiles") } - - var sortedProfiles: [MigratableProfile] { - profiles.sorted { - $0.name.lowercased() < $1.name.lowercased() - } - } - } -} - -// MARK: - Previews - -#Preview("Before") { - PrivatePreviews.MigratePreview( - profiles: PrivatePreviews.profiles, - statuses: [:] - ) - .withMockEnvironment() -} - -#Preview("After") { - PrivatePreviews.MigratePreview( - profiles: PrivatePreviews.profiles, - statuses: [ - PrivatePreviews.profiles[0].id: .excluded, - PrivatePreviews.profiles[1].id: .pending, - PrivatePreviews.profiles[2].id: .failure, - PrivatePreviews.profiles[3].id: .success - ] - ) - .withMockEnvironment() -} - -private struct PrivatePreviews { - static let oneDay: TimeInterval = 24 * 60 * 60 - - static let profiles: [MigratableProfile] = [ - .init(id: UUID(), name: "1One", lastUpdate: Date().addingTimeInterval(-oneDay)), - .init(id: UUID(), name: "2Two", lastUpdate: Date().addingTimeInterval(-3 * oneDay)), - .init(id: UUID(), name: "3Three", lastUpdate: Date().addingTimeInterval(-90 * oneDay)), - .init(id: UUID(), name: "4Four", lastUpdate: Date().addingTimeInterval(-180 * oneDay)) - ] - - struct MigratePreview: View { - let profiles: [MigratableProfile] - - let statuses: [UUID: MigrationStatus] - - @State - private var excluded: Set = [] - -#if os(iOS) - private let style: MigrateView.Style = .section -#else - private let style: MigrateView.Style = .table -#endif - - var body: some View { - Form { - MigrateView.Subview( - style: style, - profiles: profiles, - excluded: $excluded, - statuses: statuses - ) - } - .navigationTitle("Migrate") - .themeNavigationStack() + model.step = .importing + model.excludeFailed() + await migrationManager.importProfiles(profiles, into: profileManager) { + model.statuses[$0] = $1 } + model.step = .imported } } diff --git a/Passepartout/Library/Sources/CommonLibrary/Business/MigrationManager.swift b/Passepartout/Library/Sources/CommonLibrary/Business/MigrationManager.swift index 77700a5a..c836da2d 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Business/MigrationManager.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Business/MigrationManager.swift @@ -29,11 +29,14 @@ import PassepartoutKit @MainActor public final class MigrationManager: ObservableObject { public struct Simulation { + public let fakeProfiles: Bool + public let maxMigrationTime: Double? public let randomFailures: Bool - public init(maxMigrationTime: Double?, randomFailures: Bool) { + public init(fakeProfiles: Bool, maxMigrationTime: Double?, randomFailures: Bool) { + self.fakeProfiles = fakeProfiles self.maxMigrationTime = maxMigrationTime self.randomFailures = randomFailures } @@ -53,6 +56,8 @@ public final class MigrationManager: ObservableObject { } } +// MARK: - Public interface + extension MigrationManager { public func fetchMigratableProfiles() async throws -> [MigratableProfile] { try await profileStrategy.fetchMigratableProfiles() @@ -74,22 +79,15 @@ extension MigrationManager { selection.forEach { profileId in group.addTask { do { - if let simulation = self.simulation { - if let maxMigrationTime = simulation.maxMigrationTime { - try await Task.sleep(for: .seconds(.random(in: 1.0.. Void + ) async { + profiles.forEach { + onUpdate($0.id, .pending) + } + await withTaskGroup(of: Void.self) { group in + profiles.forEach { profile in + group.addTask { + do { + try await self.simulateBehavior() + try await self.simulateSaveProfile(profile, manager: manager) + await onUpdate(profile.id, .imported) + } catch { + await onUpdate(profile.id, .failed) + } + } + } + } + } +} + +// MARK: - Simulation + +private extension MigrationManager { + func simulateBehavior() async throws { + guard let simulation else { + return + } + if let maxMigrationTime = simulation.maxMigrationTime { + try await Task.sleep(for: .seconds(.random(in: 1.0.. Profile? { + if simulation?.fakeProfiles ?? false { + return try? Profile.Builder(id: profileId).tryBuild() + } + return try await profileStrategy.fetchProfile(withId: profileId) + } + + func simulateSaveProfile(_ profile: Profile, manager: ProfileManager) async throws { + if simulation?.fakeProfiles ?? false { + return + } + try await manager.save(profile, force: true) + } } // MARK: - Dummy diff --git a/Passepartout/Library/Sources/CommonLibrary/Domain/MigratableProfile.swift b/Passepartout/Library/Sources/CommonLibrary/Domain/MigratableProfile.swift index 1a34320c..0b0ca134 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Domain/MigratableProfile.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Domain/MigratableProfile.swift @@ -38,3 +38,9 @@ public struct MigratableProfile: Identifiable, Sendable { self.lastUpdate = lastUpdate } } + +extension MigratableProfile: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Passepartout/Library/Sources/CommonLibrary/Domain/MigrationStatus.swift b/Passepartout/Library/Sources/CommonLibrary/Domain/MigrationStatus.swift index ffd3d85b..f445b3a7 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Domain/MigrationStatus.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Domain/MigrationStatus.swift @@ -25,12 +25,14 @@ import Foundation -public enum MigrationStatus { +public enum MigrationStatus: Equatable { case excluded case pending - case success + case migrated - case failure + case imported + + case failed } diff --git a/Passepartout/Shared/AppContext+Shared.swift b/Passepartout/Shared/AppContext+Shared.swift index 1b37a0d8..4b7f304b 100644 --- a/Passepartout/Shared/AppContext+Shared.swift +++ b/Passepartout/Shared/AppContext+Shared.swift @@ -103,6 +103,7 @@ extension AppContext { ) #if DEBUG let migrationManager = MigrationManager(profileStrategy: profileStrategy, simulation: .init( + fakeProfiles: true, maxMigrationTime: 3.0, randomFailures: true ))