Perform migrate + import in one step (#882)
- Drop the .importing / .imported steps - Animate rows re-sorting during process - Rephrase some strings better - Test fake migration with launch argument
This commit is contained in:
parent
83eb02aa9d
commit
9e5beff23a
|
@ -111,6 +111,11 @@
|
|||
value = "1"
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "PP_FAKE_MIGRATION"
|
||||
value = "1"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../Passepartout/Passepartout.storekit">
|
||||
|
|
|
@ -43,25 +43,19 @@ private extension MigrateButton {
|
|||
return Strings.Views.Migrate.Items.migrate
|
||||
|
||||
case .migrating, .migrated:
|
||||
return Strings.Views.Migrate.Items.import
|
||||
|
||||
case .importing, .imported:
|
||||
return Strings.Global.done
|
||||
}
|
||||
}
|
||||
|
||||
var isEnabled: Bool {
|
||||
switch step {
|
||||
case .initial, .fetching, .migrating, .importing:
|
||||
case .initial, .fetching, .migrating:
|
||||
return false
|
||||
|
||||
case .fetched(let profiles):
|
||||
return !profiles.isEmpty
|
||||
|
||||
case .migrated(let profiles):
|
||||
return !profiles.isEmpty
|
||||
|
||||
case .imported:
|
||||
case .migrated:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -141,7 +141,7 @@ private extension MigrateContentView.ListView {
|
|||
}
|
||||
}
|
||||
Spacer()
|
||||
Button(isEditing ? Strings.Global.delete : Strings.Global.edit, role: isEditing ? .destructive : nil) {
|
||||
Button(title, role: role) {
|
||||
if isEditing {
|
||||
if !selection.isEmpty {
|
||||
onDelete()
|
||||
|
@ -157,6 +157,14 @@ private extension MigrateContentView.ListView {
|
|||
}
|
||||
.frame(height: 30)
|
||||
}
|
||||
|
||||
var title: String {
|
||||
isEditing ? Strings.Views.Migrate.Items.discard : Strings.Global.edit
|
||||
}
|
||||
|
||||
var role: ButtonRole? {
|
||||
isEditing ? .destructive : nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -269,7 +277,7 @@ private extension MigrateContentView.ListView {
|
|||
case .pending:
|
||||
ProgressView()
|
||||
|
||||
case .migrated, .imported:
|
||||
case .done:
|
||||
ThemeImage(.marked)
|
||||
|
||||
case .failed:
|
||||
|
|
|
@ -82,7 +82,7 @@ private extension MigrateContentView.TableView {
|
|||
)
|
||||
.environmentObject(theme) // TODO: #873, Table loses environment
|
||||
}
|
||||
.width(20)
|
||||
.width(30)
|
||||
TableColumn("") { profile in
|
||||
Button {
|
||||
onDelete([profile])
|
||||
|
@ -147,7 +147,7 @@ private extension MigrateContentView.TableView {
|
|||
case .pending:
|
||||
ThemeImage(.progress)
|
||||
|
||||
case .migrated, .imported:
|
||||
case .done:
|
||||
ThemeImage(.marked)
|
||||
|
||||
case .failed:
|
||||
|
|
|
@ -70,7 +70,13 @@ struct MigrateContentView<PerformButton>: View where PerformButton: View {
|
|||
|
||||
extension Optional where Wrapped == MigrationStatus {
|
||||
var style: some ShapeStyle {
|
||||
self != .excluded ? .primary : .secondary
|
||||
switch self {
|
||||
case .excluded, .failed:
|
||||
return .secondary
|
||||
|
||||
default:
|
||||
return .primary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -101,9 +107,8 @@ extension Dictionary where Key == UUID, Value == MigrationStatus {
|
|||
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
|
||||
PrivatePreviews.profiles[2].id: .done,
|
||||
PrivatePreviews.profiles[3].id: .failed
|
||||
]
|
||||
)
|
||||
.withMockEnvironment()
|
||||
|
|
|
@ -46,30 +46,14 @@ extension MigrateView {
|
|||
}
|
||||
|
||||
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 {
|
||||
switch step {
|
||||
case .initial, .fetching, .fetched:
|
||||
return $0.name.lowercased() < $1.name.lowercased()
|
||||
|
||||
case .migrating, .migrated, .importing, .imported:
|
||||
case .migrating, .migrated:
|
||||
return (statuses[$0.id].rank, $0.name.lowercased()) < (statuses[$1.id].rank, $1.name.lowercased())
|
||||
}
|
||||
}
|
||||
|
@ -78,9 +62,15 @@ extension MigrateView.Model {
|
|||
|
||||
private extension Optional where Wrapped == MigrationStatus {
|
||||
var rank: Int {
|
||||
if self == .excluded {
|
||||
return .max
|
||||
switch self {
|
||||
case .failed:
|
||||
return 1
|
||||
|
||||
case .excluded:
|
||||
return 2
|
||||
|
||||
default:
|
||||
return .min
|
||||
}
|
||||
return .min
|
||||
}
|
||||
}
|
||||
|
|
|
@ -79,10 +79,10 @@ struct MigrateView: View {
|
|||
isEmpty: model.profiles.isEmpty,
|
||||
emptyMessage: Strings.Views.Migrate.noProfiles
|
||||
)
|
||||
.themeAnimation(on: model.step, category: .profiles)
|
||||
.themeAnimation(on: model, category: .profiles)
|
||||
.themeConfirmation(
|
||||
isPresented: $isDeleting,
|
||||
title: Strings.Views.Migrate.Alerts.Delete.title,
|
||||
title: Strings.Views.Migrate.Items.discard,
|
||||
message: messageForDeletion,
|
||||
isDestructive: true,
|
||||
action: confirmPendingDeletion
|
||||
|
@ -130,10 +130,7 @@ private extension MigrateView {
|
|||
case .fetched(let profiles):
|
||||
await migrate(profiles)
|
||||
|
||||
case .migrated(let profiles):
|
||||
await save(profiles)
|
||||
|
||||
case .imported:
|
||||
case .migrated:
|
||||
dismiss()
|
||||
|
||||
default:
|
||||
|
@ -180,10 +177,20 @@ private extension MigrateView {
|
|||
do {
|
||||
pp_log(.App.migration, .notice, "Migrate \(profiles.count) profiles...")
|
||||
let profiles = try await migrationManager.migratedProfiles(profiles) {
|
||||
guard $1 != .done else {
|
||||
return
|
||||
}
|
||||
model.statuses[$0] = $1
|
||||
}
|
||||
model.excludeFailed()
|
||||
model.step = .migrated(profiles)
|
||||
pp_log(.App.migration, .notice, "Mapped \(profiles.count) profiles to the new format, saving...")
|
||||
await migrationManager.importProfiles(profiles, into: profileManager) {
|
||||
model.statuses[$0] = $1
|
||||
}
|
||||
let migrated = profiles.filter {
|
||||
model.statuses[$0.id] == .done
|
||||
}
|
||||
pp_log(.App.migration, .notice, "Migrated \(migrated.count) profiles")
|
||||
model.step = .migrated(migrated)
|
||||
} catch {
|
||||
pp_log(.App.migration, .error, "Unable to migrate profiles: \(error)")
|
||||
errorHandler.handle(error, title: title)
|
||||
|
@ -191,28 +198,6 @@ private extension MigrateView {
|
|||
}
|
||||
}
|
||||
|
||||
func save(_ allProfiles: [Profile]) async {
|
||||
guard case .migrated = model.step else {
|
||||
assertionFailure("Must call migrate() and succeed, why is button enabled?")
|
||||
return
|
||||
}
|
||||
|
||||
let profiles = allProfiles.filter {
|
||||
model.statuses[$0.id] != .excluded
|
||||
}
|
||||
guard !profiles.isEmpty else {
|
||||
assertionFailure("Nothing to import, why is button enabled?")
|
||||
return
|
||||
}
|
||||
|
||||
model.step = .importing
|
||||
pp_log(.App.migration, .notice, "Import \(profiles.count) migrated profiles...")
|
||||
await migrationManager.importProfiles(profiles, into: profileManager) {
|
||||
model.statuses[$0] = $1
|
||||
}
|
||||
model.step = .imported
|
||||
}
|
||||
|
||||
func confirmPendingDeletion() {
|
||||
guard let profilesPendingDeletion else {
|
||||
isEditing = false
|
||||
|
|
|
@ -38,10 +38,6 @@ enum MigrateViewStep: Equatable {
|
|||
|
||||
case migrated([Profile])
|
||||
|
||||
case importing
|
||||
|
||||
case imported
|
||||
|
||||
var canSelect: Bool {
|
||||
guard case .fetched = self else {
|
||||
return false
|
||||
|
|
|
@ -82,7 +82,7 @@ extension MigrationManager {
|
|||
await onUpdate(migratable.id, .failed)
|
||||
return nil
|
||||
}
|
||||
await onUpdate(migratable.id, .migrated)
|
||||
await onUpdate(migratable.id, .done)
|
||||
return profile
|
||||
} catch {
|
||||
await onUpdate(migratable.id, .failed)
|
||||
|
@ -115,7 +115,7 @@ extension MigrationManager {
|
|||
do {
|
||||
try await self.simulateBehavior()
|
||||
try await self.simulateSaveProfile(profile, manager: manager)
|
||||
await onUpdate(profile.id, .imported)
|
||||
await onUpdate(profile.id, .done)
|
||||
} catch {
|
||||
await onUpdate(profile.id, .failed)
|
||||
}
|
||||
|
|
|
@ -30,9 +30,7 @@ public enum MigrationStatus: Equatable {
|
|||
|
||||
case pending
|
||||
|
||||
case migrated
|
||||
|
||||
case imported
|
||||
case done
|
||||
|
||||
case failed
|
||||
}
|
||||
|
|
|
@ -734,20 +734,18 @@ public enum Strings {
|
|||
public static func message(_ p1: Any) -> String {
|
||||
return Strings.tr("Localizable", "views.migrate.alerts.delete.message", String(describing: p1), fallback: "Do you want to discard these profiles? You will not be able to recover them later.\n\n%@")
|
||||
}
|
||||
/// Discard
|
||||
public static let title = Strings.tr("Localizable", "views.migrate.alerts.delete.title", fallback: "Discard")
|
||||
}
|
||||
}
|
||||
public enum Items {
|
||||
/// Import
|
||||
public static let `import` = Strings.tr("Localizable", "views.migrate.items.import", fallback: "Import")
|
||||
/// Discard
|
||||
public static let discard = Strings.tr("Localizable", "views.migrate.items.discard", fallback: "Discard")
|
||||
/// Proceed
|
||||
public static let migrate = Strings.tr("Localizable", "views.migrate.items.migrate", fallback: "Proceed")
|
||||
}
|
||||
public enum Sections {
|
||||
public enum Main {
|
||||
/// Select below the profiles from old versions of Passepartout that you want to import. Profiles will disappear from this list once imported successfully.
|
||||
public static let header = Strings.tr("Localizable", "views.migrate.sections.main.header", fallback: "Select below the profiles from old versions of Passepartout that you want to import. Profiles will disappear from this list once imported successfully.")
|
||||
/// Select below the profiles from old versions of Passepartout that you want to import. They will disappear from this list once imported successfully.
|
||||
public static let header = Strings.tr("Localizable", "views.migrate.sections.main.header", fallback: "Select below the profiles from old versions of Passepartout that you want to import. They will disappear from this list once imported successfully.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -161,10 +161,9 @@
|
|||
|
||||
"views.migrate.title" = "Migrate";
|
||||
"views.migrate.no_profiles" = "Nothing to migrate";
|
||||
"views.migrate.items.discard" = "Discard";
|
||||
"views.migrate.items.migrate" = "Proceed";
|
||||
"views.migrate.items.import" = "Import";
|
||||
"views.migrate.sections.main.header" = "Select below the profiles from old versions of Passepartout that you want to import. Profiles will disappear from this list once imported successfully.";
|
||||
"views.migrate.alerts.delete.title" = "Discard";
|
||||
"views.migrate.sections.main.header" = "Select below the profiles from old versions of Passepartout that you want to import. They will disappear from this list once imported successfully.";
|
||||
"views.migrate.alerts.delete.message" = "Do you want to discard these profiles? You will not be able to recover them later.\n\n%@";
|
||||
|
||||
"views.donate.title" = "Make a donation";
|
||||
|
|
|
@ -101,7 +101,17 @@ extension AppContext {
|
|||
profilesContainerName: Constants.shared.containers.legacyV2,
|
||||
cloudKitIdentifier: BundleConfiguration.mainString(for: .legacyV2CloudKitId)
|
||||
)
|
||||
let migrationManager = MigrationManager(profileStrategy: profileStrategy)
|
||||
let migrationSimulation: MigrationManager.Simulation?
|
||||
if Configuration.Environment.isFakeMigration {
|
||||
migrationSimulation = MigrationManager.Simulation(
|
||||
fakeProfiles: true,
|
||||
maxMigrationTime: 3.0,
|
||||
randomFailures: true
|
||||
)
|
||||
} else {
|
||||
migrationSimulation = nil
|
||||
}
|
||||
let migrationManager = MigrationManager(profileStrategy: profileStrategy, simulation: migrationSimulation)
|
||||
|
||||
return AppContext(
|
||||
iapManager: .sharedForApp,
|
||||
|
|
|
@ -106,6 +106,10 @@ extension Configuration.Environment {
|
|||
static var isFakeIAP: Bool {
|
||||
ProcessInfo.processInfo.environment["PP_FAKE_IAP"] == "1"
|
||||
}
|
||||
|
||||
static var isFakeMigration: Bool {
|
||||
ProcessInfo.processInfo.environment["PP_FAKE_MIGRATION"] == "1"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: ProfileManager
|
||||
|
|
Loading…
Reference in New Issue