parent
114e1abe12
commit
615f7d47bd
|
@ -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)
|
||||
|
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
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<UUID> {
|
||||
Set(profiles
|
||||
.filter {
|
||||
statuses[$0.id] != .excluded
|
||||
}
|
||||
.map(\.id))
|
||||
}
|
||||
}
|
|
@ -28,54 +28,81 @@ import SwiftUI
|
|||
|
||||
extension MigrateView {
|
||||
struct SectionView: View {
|
||||
let step: Model.Step
|
||||
|
||||
let profiles: [MigratableProfile]
|
||||
|
||||
@Binding
|
||||
var excluded: Set<UUID>
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,56 +28,71 @@ import SwiftUI
|
|||
|
||||
extension MigrateView {
|
||||
struct TableView: View {
|
||||
let step: Model.Step
|
||||
|
||||
let profiles: [MigratableProfile]
|
||||
|
||||
@Binding
|
||||
var excluded: Set<UUID>
|
||||
|
||||
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<Bool> {
|
||||
Binding {
|
||||
!excluded.contains(profileId)
|
||||
} set: {
|
||||
if $0 {
|
||||
excluded.remove(profileId)
|
||||
} else {
|
||||
excluded.insert(profileId)
|
||||
}
|
||||
private extension MigrateView.TableView {
|
||||
func isIncludedBinding(for profileId: UUID) -> Binding<Bool> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<UUID> = []
|
||||
|
||||
@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<UUID>
|
||||
|
||||
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<UUID> = []
|
||||
|
||||
#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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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..<maxMigrationTime)))
|
||||
}
|
||||
if simulation.randomFailures, Bool.random() {
|
||||
throw PassepartoutError(.unhandled)
|
||||
}
|
||||
}
|
||||
guard let profile = try await self.profileStrategy.fetchProfile(withId: profileId) else {
|
||||
await onUpdate(profileId, .failure)
|
||||
try await self.simulateBehavior()
|
||||
guard let profile = try await self.simulateMigrateProfile(withId: profileId) else {
|
||||
await onUpdate(profileId, .failed)
|
||||
return nil
|
||||
}
|
||||
await onUpdate(profileId, .success)
|
||||
await onUpdate(profileId, .migrated)
|
||||
return profile
|
||||
} catch {
|
||||
await onUpdate(profileId, .failure)
|
||||
await onUpdate(profileId, .failed)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
@ -104,6 +102,59 @@ extension MigrationManager {
|
|||
return profiles
|
||||
}
|
||||
}
|
||||
|
||||
public func importProfiles(
|
||||
_ profiles: [Profile],
|
||||
into manager: ProfileManager,
|
||||
onUpdate: @escaping @MainActor (UUID, MigrationStatus) -> 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..<maxMigrationTime)))
|
||||
}
|
||||
if simulation.randomFailures, Bool.random() {
|
||||
throw PassepartoutError(.unhandled)
|
||||
}
|
||||
}
|
||||
|
||||
func simulateMigrateProfile(withId profileId: UUID) async throws -> 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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -103,6 +103,7 @@ extension AppContext {
|
|||
)
|
||||
#if DEBUG
|
||||
let migrationManager = MigrationManager(profileStrategy: profileStrategy, simulation: .init(
|
||||
fakeProfiles: true,
|
||||
maxMigrationTime: 3.0,
|
||||
randomFailures: true
|
||||
))
|
||||
|
|
Loading…
Reference in New Issue