parent
114e1abe12
commit
615f7d47bd
|
@ -186,7 +186,10 @@ extension AppCoordinator {
|
||||||
)
|
)
|
||||||
|
|
||||||
case .migrateProfiles:
|
case .migrateProfiles:
|
||||||
MigrateView(style: migrateViewStyle)
|
MigrateView(
|
||||||
|
style: migrateViewStyle,
|
||||||
|
profileManager: profileManager
|
||||||
|
)
|
||||||
.themeNavigationStack(closable: true, path: $migrationPath)
|
.themeNavigationStack(closable: true, path: $migrationPath)
|
||||||
|
|
||||||
case .settings:
|
case .settings:
|
||||||
|
|
|
@ -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,31 +28,36 @@ import SwiftUI
|
||||||
|
|
||||||
extension MigrateView {
|
extension MigrateView {
|
||||||
struct SectionView: View {
|
struct SectionView: View {
|
||||||
|
let step: Model.Step
|
||||||
|
|
||||||
let profiles: [MigratableProfile]
|
let profiles: [MigratableProfile]
|
||||||
|
|
||||||
@Binding
|
@Binding
|
||||||
var excluded: Set<UUID>
|
var statuses: [UUID: MigrationStatus]
|
||||||
|
|
||||||
let statuses: [UUID: MigrationStatus]
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section {
|
Section {
|
||||||
ForEach(profiles, id: \.id) {
|
ForEach(profiles, id: \.id) {
|
||||||
if let status = statuses[$0.id] {
|
switch step {
|
||||||
row(forProfile: $0, status: status)
|
case .initial, .fetching, .fetched:
|
||||||
} else {
|
|
||||||
button(forProfile: $0)
|
button(forProfile: $0)
|
||||||
|
|
||||||
|
default:
|
||||||
|
row(forProfile: $0, status: statuses[$0.id])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension MigrateView.SectionView {
|
||||||
func button(forProfile profile: MigratableProfile) -> some View {
|
func button(forProfile profile: MigratableProfile) -> some View {
|
||||||
Button {
|
Button {
|
||||||
if excluded.contains(profile.id) {
|
if statuses[profile.id] == .excluded {
|
||||||
excluded.remove(profile.id)
|
statuses.removeValue(forKey: profile.id)
|
||||||
} else {
|
} else {
|
||||||
excluded.insert(profile.id)
|
statuses[profile.id] = .excluded
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
row(forProfile: profile, status: nil)
|
row(forProfile: profile, status: nil)
|
||||||
|
@ -61,6 +66,19 @@ extension MigrateView {
|
||||||
|
|
||||||
func row(forProfile profile: MigratableProfile, status: MigrationStatus?) -> some View {
|
func row(forProfile profile: MigratableProfile, status: MigrationStatus?) -> some View {
|
||||||
HStack {
|
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) {
|
VStack(alignment: .leading) {
|
||||||
Text(profile.name)
|
Text(profile.name)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
@ -70,28 +88,37 @@ extension MigrateView {
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension MigrateView.SectionView {
|
||||||
|
struct StatusView: View {
|
||||||
|
let isIncluded: Bool
|
||||||
|
|
||||||
|
let status: MigrationStatus?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
if let status {
|
if let status {
|
||||||
icon(forStatus: status)
|
icon(forStatus: status)
|
||||||
} else if !excluded.contains(profile.id) {
|
} else if isIncluded {
|
||||||
ThemeImage(.marked)
|
ThemeImage(.marked)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func icon(forStatus status: MigrationStatus) -> some View {
|
func icon(forStatus status: MigrationStatus) -> some View {
|
||||||
switch status {
|
switch status {
|
||||||
case .excluded:
|
case .excluded:
|
||||||
EmptyView()
|
Text("--")
|
||||||
|
|
||||||
case .pending:
|
case .pending:
|
||||||
ProgressView()
|
ProgressView()
|
||||||
|
|
||||||
case .success:
|
case .migrated, .imported:
|
||||||
ThemeImage(.marked)
|
ThemeImage(.marked)
|
||||||
|
|
||||||
case .failure:
|
case .failed:
|
||||||
ThemeImage(.failure)
|
ThemeImage(.failure)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,56 +28,71 @@ import SwiftUI
|
||||||
|
|
||||||
extension MigrateView {
|
extension MigrateView {
|
||||||
struct TableView: View {
|
struct TableView: View {
|
||||||
|
let step: Model.Step
|
||||||
|
|
||||||
let profiles: [MigratableProfile]
|
let profiles: [MigratableProfile]
|
||||||
|
|
||||||
@Binding
|
@Binding
|
||||||
var excluded: Set<UUID>
|
var statuses: [UUID: MigrationStatus]
|
||||||
|
|
||||||
let statuses: [UUID: MigrationStatus]
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Table(profiles) {
|
Table(profiles) {
|
||||||
TableColumn(Strings.Global.name, value: \.name)
|
TableColumn(Strings.Global.name) {
|
||||||
TableColumn(Strings.Global.lastUpdate, value: \.timestamp)
|
Text($0.name)
|
||||||
TableColumn("") { profile in
|
.foregroundStyle(statuses.style(for: $0.id))
|
||||||
if let status = statuses[profile.id] {
|
|
||||||
imageName(forStatus: status)
|
|
||||||
.map {
|
|
||||||
ThemeImage($0)
|
|
||||||
}
|
}
|
||||||
} else {
|
TableColumn(Strings.Global.lastUpdate) {
|
||||||
Toggle("", isOn: isOnBinding(for: profile.id))
|
Text($0.timestamp)
|
||||||
|
.foregroundStyle(statuses.style(for: $0.id))
|
||||||
|
}
|
||||||
|
TableColumn("") { profile in
|
||||||
|
switch step {
|
||||||
|
case .initial, .fetching, .fetched:
|
||||||
|
Toggle("", isOn: isIncludedBinding(for: profile.id))
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
|
|
||||||
|
default:
|
||||||
|
if let status = statuses[profile.id] {
|
||||||
|
StatusView(status: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func isOnBinding(for profileId: UUID) -> Binding<Bool> {
|
private extension MigrateView.TableView {
|
||||||
|
func isIncludedBinding(for profileId: UUID) -> Binding<Bool> {
|
||||||
Binding {
|
Binding {
|
||||||
!excluded.contains(profileId)
|
statuses[profileId] != .excluded
|
||||||
} set: {
|
} set: {
|
||||||
if $0 {
|
if $0 {
|
||||||
excluded.remove(profileId)
|
statuses.removeValue(forKey: profileId)
|
||||||
} else {
|
} else {
|
||||||
excluded.insert(profileId)
|
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 {
|
switch status {
|
||||||
case .excluded:
|
case .excluded:
|
||||||
return nil
|
Text("--")
|
||||||
|
|
||||||
case .pending:
|
case .pending:
|
||||||
return .progress
|
ThemeImage(.progress)
|
||||||
|
|
||||||
case .success:
|
case .migrated, .imported:
|
||||||
return .marked
|
ThemeImage(.marked)
|
||||||
|
|
||||||
case .failure:
|
case .failed:
|
||||||
return .failure
|
ThemeImage(.failure)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,39 +40,34 @@ struct MigrateView: View {
|
||||||
@EnvironmentObject
|
@EnvironmentObject
|
||||||
private var migrationManager: MigrationManager
|
private var migrationManager: MigrationManager
|
||||||
|
|
||||||
|
@Environment(\.dismiss)
|
||||||
|
private var dismiss
|
||||||
|
|
||||||
let style: Style
|
let style: Style
|
||||||
|
|
||||||
@State
|
@ObservedObject
|
||||||
private var isFetching = true
|
var profileManager: ProfileManager
|
||||||
|
|
||||||
@State
|
@State
|
||||||
private var isMigrating = false
|
private var model = Model()
|
||||||
|
|
||||||
@State
|
|
||||||
private var profiles: [MigratableProfile] = []
|
|
||||||
|
|
||||||
@State
|
|
||||||
private var excluded: Set<UUID> = []
|
|
||||||
|
|
||||||
@State
|
|
||||||
private var statuses: [UUID: MigrationStatus] = [:]
|
|
||||||
|
|
||||||
@StateObject
|
@StateObject
|
||||||
private var errorHandler: ErrorHandler = .default()
|
private var errorHandler: ErrorHandler = .default()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Subview(
|
ContentView(
|
||||||
style: style,
|
style: style,
|
||||||
profiles: profiles,
|
step: model.step,
|
||||||
excluded: $excluded,
|
profiles: model.visibleProfiles,
|
||||||
statuses: statuses
|
statuses: $model.statuses
|
||||||
)
|
)
|
||||||
.disabled(isMigrating)
|
.disabled(model.step != .fetched)
|
||||||
}
|
}
|
||||||
.themeForm()
|
.themeForm()
|
||||||
.themeProgress(if: isFetching)
|
.themeProgress(if: model.step == .fetching)
|
||||||
.themeEmptyContent(if: !isFetching && profiles.isEmpty, message: "Nothing to migrate")
|
.themeEmptyContent(if: model.step == .fetched && model.profiles.isEmpty, message: "Nothing to migrate")
|
||||||
|
.themeAnimation(on: model, category: .profiles)
|
||||||
.navigationTitle(title)
|
.navigationTitle(title)
|
||||||
.toolbar(content: toolbarContent)
|
.toolbar(content: toolbarContent)
|
||||||
.task {
|
.task {
|
||||||
|
@ -89,142 +84,106 @@ private extension MigrateView {
|
||||||
|
|
||||||
func toolbarContent() -> some ToolbarContent {
|
func toolbarContent() -> some ToolbarContent {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("Proceed") {
|
Button(itemTitle(at: model.step)) {
|
||||||
Task {
|
Task {
|
||||||
await migrate()
|
await itemPerform(at: model.step)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(!itemEnabled(at: model.step))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension MigrateView {
|
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 {
|
func fetch() async {
|
||||||
|
guard model.step == .initial else {
|
||||||
|
return
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
isFetching = true
|
model.step = .fetching
|
||||||
profiles = try await migrationManager.fetchMigratableProfiles()
|
let migratable = try await migrationManager.fetchMigratableProfiles()
|
||||||
isFetching = false
|
let knownIDs = Set(profileManager.headers.map(\.id))
|
||||||
|
model.profiles = migratable.filter {
|
||||||
|
!knownIDs.contains($0.id)
|
||||||
|
}
|
||||||
|
model.step = .fetched
|
||||||
} catch {
|
} catch {
|
||||||
pp_log(.App.migration, .error, "Unable to fetch migratable profiles: \(error)")
|
pp_log(.App.migration, .error, "Unable to fetch migratable profiles: \(error)")
|
||||||
errorHandler.handle(error, title: title)
|
errorHandler.handle(error, title: title)
|
||||||
isFetching = false
|
model.step = .initial
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrate() async {
|
func migrate() async {
|
||||||
do {
|
guard model.step == .fetched else {
|
||||||
isMigrating = true
|
fatalError("Must call fetch() and succeed")
|
||||||
let selection = Set(profiles.map(\.id)).symmetricDifference(excluded)
|
|
||||||
let migrated = try await migrationManager.migrateProfiles(profiles, selection: selection) {
|
|
||||||
statuses[$0] = $1
|
|
||||||
}
|
}
|
||||||
print(">>> Migrated: \(migrated.count)")
|
do {
|
||||||
_ = migrated
|
model.step = .migrating
|
||||||
// FIXME: ###, import migrated
|
let profiles = try await migrationManager.migrateProfiles(model.profiles, selection: model.selection) {
|
||||||
|
model.statuses[$0] = $1
|
||||||
|
}
|
||||||
|
model.step = .migrated(profiles)
|
||||||
} catch {
|
} catch {
|
||||||
pp_log(.App.migration, .error, "Unable to migrate profiles: \(error)")
|
pp_log(.App.migration, .error, "Unable to migrate profiles: \(error)")
|
||||||
errorHandler.handle(error, title: title)
|
errorHandler.handle(error, title: title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
|
model.step = .importing
|
||||||
// MARK: -
|
model.excludeFailed()
|
||||||
|
await migrationManager.importProfiles(profiles, into: profileManager) {
|
||||||
private extension MigrateView {
|
model.statuses[$0] = $1
|
||||||
struct Subview: View {
|
}
|
||||||
let style: Style
|
model.step = .imported
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,11 +29,14 @@ import PassepartoutKit
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class MigrationManager: ObservableObject {
|
public final class MigrationManager: ObservableObject {
|
||||||
public struct Simulation {
|
public struct Simulation {
|
||||||
|
public let fakeProfiles: Bool
|
||||||
|
|
||||||
public let maxMigrationTime: Double?
|
public let maxMigrationTime: Double?
|
||||||
|
|
||||||
public let randomFailures: Bool
|
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.maxMigrationTime = maxMigrationTime
|
||||||
self.randomFailures = randomFailures
|
self.randomFailures = randomFailures
|
||||||
}
|
}
|
||||||
|
@ -53,6 +56,8 @@ public final class MigrationManager: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Public interface
|
||||||
|
|
||||||
extension MigrationManager {
|
extension MigrationManager {
|
||||||
public func fetchMigratableProfiles() async throws -> [MigratableProfile] {
|
public func fetchMigratableProfiles() async throws -> [MigratableProfile] {
|
||||||
try await profileStrategy.fetchMigratableProfiles()
|
try await profileStrategy.fetchMigratableProfiles()
|
||||||
|
@ -74,22 +79,15 @@ extension MigrationManager {
|
||||||
selection.forEach { profileId in
|
selection.forEach { profileId in
|
||||||
group.addTask {
|
group.addTask {
|
||||||
do {
|
do {
|
||||||
if let simulation = self.simulation {
|
try await self.simulateBehavior()
|
||||||
if let maxMigrationTime = simulation.maxMigrationTime {
|
guard let profile = try await self.simulateMigrateProfile(withId: profileId) else {
|
||||||
try await Task.sleep(for: .seconds(.random(in: 1.0..<maxMigrationTime)))
|
await onUpdate(profileId, .failed)
|
||||||
}
|
|
||||||
if simulation.randomFailures, Bool.random() {
|
|
||||||
throw PassepartoutError(.unhandled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard let profile = try await self.profileStrategy.fetchProfile(withId: profileId) else {
|
|
||||||
await onUpdate(profileId, .failure)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
await onUpdate(profileId, .success)
|
await onUpdate(profileId, .migrated)
|
||||||
return profile
|
return profile
|
||||||
} catch {
|
} catch {
|
||||||
await onUpdate(profileId, .failure)
|
await onUpdate(profileId, .failed)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -104,6 +102,59 @@ extension MigrationManager {
|
||||||
return profiles
|
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
|
// MARK: - Dummy
|
||||||
|
|
|
@ -38,3 +38,9 @@ public struct MigratableProfile: Identifiable, Sendable {
|
||||||
self.lastUpdate = lastUpdate
|
self.lastUpdate = lastUpdate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension MigratableProfile: Equatable {
|
||||||
|
public static func == (lhs: Self, rhs: Self) -> Bool {
|
||||||
|
lhs.id == rhs.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -25,12 +25,14 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum MigrationStatus {
|
public enum MigrationStatus: Equatable {
|
||||||
case excluded
|
case excluded
|
||||||
|
|
||||||
case pending
|
case pending
|
||||||
|
|
||||||
case success
|
case migrated
|
||||||
|
|
||||||
case failure
|
case imported
|
||||||
|
|
||||||
|
case failed
|
||||||
}
|
}
|
||||||
|
|
|
@ -103,6 +103,7 @@ extension AppContext {
|
||||||
)
|
)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
let migrationManager = MigrationManager(profileStrategy: profileStrategy, simulation: .init(
|
let migrationManager = MigrationManager(profileStrategy: profileStrategy, simulation: .init(
|
||||||
|
fakeProfiles: true,
|
||||||
maxMigrationTime: 3.0,
|
maxMigrationTime: 3.0,
|
||||||
randomFailures: true
|
randomFailures: true
|
||||||
))
|
))
|
||||||
|
|
Loading…
Reference in New Issue