Import migrated profiles (#867)

Finalize basic flow started in #866.
This commit is contained in:
Davide 2024-11-14 15:11:25 +01:00 committed by GitHub
parent 114e1abe12
commit 615f7d47bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 512 additions and 208 deletions

View File

@ -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:

View File

@ -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
}
}
}
}

View File

@ -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))
}
}

View File

@ -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)
} }
} }

View File

@ -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)
} }
} }
} }

View File

@ -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()
}
} }
} }

View File

@ -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

View File

@ -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
}
}

View File

@ -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
} }

View File

@ -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
)) ))