From 114e1abe1258fac3d783334b9bc94b9889ddb730 Mon Sep 17 00:00:00 2001 From: Davide Date: Thu, 14 Nov 2024 11:02:26 +0100 Subject: [PATCH] Add initial migration UI (#866) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repurpose LegacyManager as MigrationManager. Present initial migration UI from "+" menu in app home. Different styles: - iOS → Section / ForEach - macOS → Table --- Passepartout.xcodeproj/project.pbxproj | 10 +- .../CDProfileRepositoryV3.swift | 0 .../CDProviderRepositoryV3.swift | 0 .../CDVPNProviderServerRepositoryV3.swift | 0 .../Views/About/AboutRouterView.swift | 32 +-- .../AppUIMain/Views/About/AboutView.swift | 12 +- .../AppUIMain/Views/App/AddProfileMenu.swift | 41 +++- .../AppUIMain/Views/App/AppCoordinator.swift | 25 +- .../AppUIMain/Views/App/AppToolbar.swift | 6 +- .../Views/Migration/MigrateView+Section.swift | 99 ++++++++ .../Views/Migration/MigrateView+Table.swift | 90 +++++++ .../Views/Migration/MigrateView.swift | 230 ++++++++++++++++++ .../Business/MigrationManager.swift | 122 ++++++++++ .../Domain/MigratableProfile.swift | 2 +- .../Domain/MigrationStatus.swift | 36 +++ .../Bundle+Extensions.swift | 0 ...PassepartoutConfiguration+Extensions.swift | 0 .../InMemoryProfileRepository.swift | 0 .../NEProfileRepository.swift | 0 .../Strategy/ProfileMigrationStrategy.swift | 33 +++ .../ProfileRepository.swift | 0 .../CDProfileRepositoryV2.swift | 20 +- .../ProfileV2MigrationStrategy.swift} | 39 ++- .../UILibrary/Business/AppContext.swift | 20 +- .../Extensions/View+Environment.swift | 1 + .../UILibrary/L10n/SwiftGen+Strings.swift | 8 + .../UILibrary/Mock/AppContext+Mock.swift | 8 +- .../Resources/en.lproj/Localizable.strings | 4 + .../UILibrary/Theme/Theme+ImageName.swift | 6 + Passepartout/Shared/AppContext+Shared.swift | 24 +- ...ests.swift => MigrationManagerTests.swift} | 78 +++--- 31 files changed, 820 insertions(+), 126 deletions(-) rename Passepartout/Library/Sources/AppDataProfiles/{ => Strategy}/CDProfileRepositoryV3.swift (100%) rename Passepartout/Library/Sources/AppDataProviders/{ => Strategy}/CDProviderRepositoryV3.swift (100%) rename Passepartout/Library/Sources/AppDataProviders/{ => Strategy}/CDVPNProviderServerRepositoryV3.swift (100%) create mode 100644 Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Section.swift create mode 100644 Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Table.swift create mode 100644 Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView.swift create mode 100644 Passepartout/Library/Sources/CommonLibrary/Business/MigrationManager.swift create mode 100644 Passepartout/Library/Sources/CommonLibrary/Domain/MigrationStatus.swift rename Passepartout/Library/Sources/CommonLibrary/{Business => Extensions}/Bundle+Extensions.swift (100%) rename Passepartout/Library/Sources/CommonLibrary/{Business => Extensions}/PassepartoutConfiguration+Extensions.swift (100%) rename Passepartout/Library/Sources/CommonLibrary/{Business => Strategy}/InMemoryProfileRepository.swift (100%) rename Passepartout/Library/Sources/CommonLibrary/{Business => Strategy}/NEProfileRepository.swift (100%) create mode 100644 Passepartout/Library/Sources/CommonLibrary/Strategy/ProfileMigrationStrategy.swift rename Passepartout/Library/Sources/CommonLibrary/{Business => Strategy}/ProfileRepository.swift (100%) rename Passepartout/Library/Sources/LegacyV2/{ => Strategy}/CDProfileRepositoryV2.swift (86%) rename Passepartout/Library/Sources/LegacyV2/{LegacyV2.swift => Strategy/ProfileV2MigrationStrategy.swift} (69%) rename Passepartout/Tests/{LegacyV2CoreDataTests.swift => MigrationManagerTests.swift} (78%) diff --git a/Passepartout.xcodeproj/project.pbxproj b/Passepartout.xcodeproj/project.pbxproj index 358f224c..de94885d 100644 --- a/Passepartout.xcodeproj/project.pbxproj +++ b/Passepartout.xcodeproj/project.pbxproj @@ -10,7 +10,7 @@ 0E3E22962CE53510005135DF /* AppUIMain in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, macos, ); productRef = 0E3E22952CE53510005135DF /* AppUIMain */; }; 0E3E22982CE53510005135DF /* AppUITV in Frameworks */ = {isa = PBXBuildFile; platformFilters = (tvos, ); productRef = 0E3E22972CE53510005135DF /* AppUITV */; }; 0E3FF4BA2CE3AFBC00BFF640 /* Profiles.sqlite in Resources */ = {isa = PBXBuildFile; fileRef = 0E3FF4B72CE3AFBC00BFF640 /* Profiles.sqlite */; }; - 0E3FF4BB2CE3AFBC00BFF640 /* LegacyV2CoreDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3FF4B92CE3AFBC00BFF640 /* LegacyV2CoreDataTests.swift */; }; + 0E3FF4BB2CE3AFBC00BFF640 /* MigrationManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3FF4B92CE3AFBC00BFF640 /* MigrationManagerTests.swift */; }; 0E60512C2CE5393C00F763D4 /* PassepartoutImplementations in Frameworks */ = {isa = PBXBuildFile; productRef = 0E60512B2CE5393C00F763D4 /* PassepartoutImplementations */; }; 0E757F132CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E757F122CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift */; }; 0E757F202CD0D22B006E13E1 /* PassepartoutLoginItem.app in Embed Login Item */ = {isa = PBXBuildFile; fileRef = 0E757F102CD0CFFC006E13E1 /* PassepartoutLoginItem.app */; platformFilters = (macos, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -108,7 +108,7 @@ 0E06D18F2B87629100176E1D /* Passepartout.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Passepartout.app; sourceTree = BUILT_PRODUCTS_DIR; }; 0E3FF4AE2CE3AF6F00BFF640 /* PassepartoutTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PassepartoutTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 0E3FF4B72CE3AFBC00BFF640 /* Profiles.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = Profiles.sqlite; sourceTree = ""; }; - 0E3FF4B92CE3AFBC00BFF640 /* LegacyV2CoreDataTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyV2CoreDataTests.swift; sourceTree = ""; }; + 0E3FF4B92CE3AFBC00BFF640 /* MigrationManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MigrationManagerTests.swift; sourceTree = ""; }; 0E5DFDDC2CDB8F9100F2DE70 /* Passepartout.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Passepartout.storekit; sourceTree = ""; }; 0E757F102CD0CFFC006E13E1 /* PassepartoutLoginItem.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PassepartoutLoginItem.app; sourceTree = BUILT_PRODUCTS_DIR; }; 0E757F122CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassepartoutLoginItemApp.swift; sourceTree = ""; }; @@ -219,7 +219,7 @@ isa = PBXGroup; children = ( 0E3FF4B82CE3AFBC00BFF640 /* Resources */, - 0E3FF4B92CE3AFBC00BFF640 /* LegacyV2CoreDataTests.swift */, + 0E3FF4B92CE3AFBC00BFF640 /* MigrationManagerTests.swift */, ); path = Tests; sourceTree = ""; @@ -554,7 +554,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0E3FF4BB2CE3AFBC00BFF640 /* LegacyV2CoreDataTests.swift in Sources */, + 0E3FF4BB2CE3AFBC00BFF640 /* MigrationManagerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -876,6 +876,7 @@ 0E3FF4B52CE3AF6F00BFF640 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES; BUNDLE_LOADER = "$(TEST_HOST)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; PRODUCT_BUNDLE_IDENTIFIER = com.algoritmico.ios.PassepartoutTests; @@ -888,6 +889,7 @@ 0E3FF4B62CE3AF6F00BFF640 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES; BUNDLE_LOADER = "$(TEST_HOST)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; PRODUCT_BUNDLE_IDENTIFIER = com.algoritmico.ios.PassepartoutTests; diff --git a/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift b/Passepartout/Library/Sources/AppDataProfiles/Strategy/CDProfileRepositoryV3.swift similarity index 100% rename from Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift rename to Passepartout/Library/Sources/AppDataProfiles/Strategy/CDProfileRepositoryV3.swift diff --git a/Passepartout/Library/Sources/AppDataProviders/CDProviderRepositoryV3.swift b/Passepartout/Library/Sources/AppDataProviders/Strategy/CDProviderRepositoryV3.swift similarity index 100% rename from Passepartout/Library/Sources/AppDataProviders/CDProviderRepositoryV3.swift rename to Passepartout/Library/Sources/AppDataProviders/Strategy/CDProviderRepositoryV3.swift diff --git a/Passepartout/Library/Sources/AppDataProviders/CDVPNProviderServerRepositoryV3.swift b/Passepartout/Library/Sources/AppDataProviders/Strategy/CDVPNProviderServerRepositoryV3.swift similarity index 100% rename from Passepartout/Library/Sources/AppDataProviders/CDVPNProviderServerRepositoryV3.swift rename to Passepartout/Library/Sources/AppDataProviders/Strategy/CDVPNProviderServerRepositoryV3.swift diff --git a/Passepartout/Library/Sources/AppUIMain/Views/About/AboutRouterView.swift b/Passepartout/Library/Sources/AppUIMain/Views/About/AboutRouterView.swift index 47d7870b..d4644b4e 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/About/AboutRouterView.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/About/AboutRouterView.swift @@ -45,24 +45,28 @@ struct AboutRouterView: View { extension AboutRouterView { enum NavigationRoute: Hashable { - case donate + case appDebugLog(title: String) + + case credits case diagnostics - case appDebugLog(title: String) - - case tunnelDebugLog(title: String, url: URL?) + case donate case links - case credits + case tunnelDebugLog(title: String, url: URL?) } @ViewBuilder func pushDestination(for item: NavigationRoute?) -> some View { switch item { - case .donate: - DonateView() + case .appDebugLog(let title): + DebugLogView.withApp(parameters: Constants.shared.log) + .navigationTitle(title) + + case .credits: + CreditsView() case .diagnostics: DiagnosticsView( @@ -70,9 +74,11 @@ extension AboutRouterView { tunnel: tunnel ) - case .appDebugLog(let title): - DebugLogView.withApp(parameters: Constants.shared.log) - .navigationTitle(title) + case .donate: + DonateView() + + case .links: + LinksView() case .tunnelDebugLog(let title, let url): if let url { @@ -83,12 +89,6 @@ extension AboutRouterView { .navigationTitle(title) } - case .links: - LinksView() - - case .credits: - CreditsView() - default: Text(Strings.Global.noSelection) .themeEmptyMessage() diff --git a/Passepartout/Library/Sources/AppUIMain/Views/About/AboutView.swift b/Passepartout/Library/Sources/AppUIMain/Views/About/AboutView.swift index 1c23ccee..795bd259 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/About/AboutView.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/About/AboutView.swift @@ -44,20 +44,20 @@ struct AboutView: View { } extension AboutView { - var donateLink: some View { - navLink(Strings.Views.Donate.title, to: .donate) + var creditsLink: some View { + navLink(Strings.Views.About.Credits.title, to: .credits) } var diagnosticsLink: some View { navLink(Strings.Views.Diagnostics.title, to: .diagnostics) } - var linksLink: some View { - navLink(Strings.Views.About.Links.title, to: .links) + var donateLink: some View { + navLink(Strings.Views.Donate.title, to: .donate) } - var creditsLink: some View { - navLink(Strings.Views.About.Credits.title, to: .credits) + var linksLink: some View { + navLink(Strings.Views.About.Links.title, to: .links) } } diff --git a/Passepartout/Library/Sources/AppUIMain/Views/App/AddProfileMenu.swift b/Passepartout/Library/Sources/AppUIMain/Views/App/AddProfileMenu.swift index aef00aba..48b2f278 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/App/AddProfileMenu.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/App/AddProfileMenu.swift @@ -33,23 +33,42 @@ struct AddProfileMenu: View { @Binding var isImporting: Bool + let onMigrateProfiles: () -> Void + let onNewProfile: (Profile) -> Void var body: some View { Menu { - Button { - let profile = profileManager.new(withName: Strings.Entities.Profile.Name.new) - onNewProfile(profile) - } label: { - ThemeImageLabel(Strings.Views.Profiles.Toolbar.newProfile, .profileEdit) - } - Button { - isImporting = true - } label: { - ThemeImageLabel(Strings.Views.Profiles.Toolbar.importProfile, .profileImport) - } + newProfileButton + importProfileButton + migrateProfilesButton } label: { ThemeImage(.add) } } } + +private extension AddProfileMenu { + var newProfileButton: some View { + Button { + let profile = profileManager.new(withName: Strings.Entities.Profile.Name.new) + onNewProfile(profile) + } label: { + ThemeImageLabel(Strings.Views.Profiles.Toolbar.newProfile, .profileEdit) + } + } + + var importProfileButton: some View { + Button { + isImporting = true + } label: { + ThemeImageLabel(Strings.Views.Profiles.Toolbar.importProfile, .profileImport) + } + } + + var migrateProfilesButton: some View { + Button(action: onMigrateProfiles) { + ThemeImageLabel(Strings.Views.Profiles.Toolbar.migrateProfiles, .profileMigrate) + } + } +} diff --git a/Passepartout/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift b/Passepartout/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift index 3a6c0792..4bc0163d 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift @@ -51,6 +51,9 @@ public struct AppCoordinator: View, AppCoordinatorConforming { @State private var profilePath = NavigationPath() + @State + private var migrationPath = NavigationPath() + @StateObject private var errorHandler: ErrorHandler = .default() @@ -86,6 +89,8 @@ extension AppCoordinator { case editProviderEntity(Profile, Module, ModuleMetadata.Provider) + case migrateProfiles + case settings case about @@ -94,8 +99,9 @@ extension AppCoordinator { switch self { case .editProfile: return 1 case .editProviderEntity: return 2 - case .settings: return 3 - case .about: return 4 + case .migrateProfiles: return 3 + case .settings: return 4 + case .about: return 5 } } @@ -146,6 +152,9 @@ extension AppCoordinator { onAbout: { present(.about) }, + onMigrateProfiles: { + present(.migrateProfiles) + }, onNewProfile: enterDetail ) } @@ -176,6 +185,10 @@ extension AppCoordinator { errorHandler: errorHandler ) + case .migrateProfiles: + MigrateView(style: migrateViewStyle) + .themeNavigationStack(closable: true, path: $migrationPath) + case .settings: SettingsView(profileManager: profileManager) @@ -190,6 +203,14 @@ extension AppCoordinator { } } + var migrateViewStyle: MigrateView.Style { +#if os(iOS) + .section +#else + .table +#endif + } + func enterDetail(of profile: Profile) { profilePath = NavigationPath() let isShared = profileManager.isRemotelyShared(profileWithId: profile.id) diff --git a/Passepartout/Library/Sources/AppUIMain/Views/App/AppToolbar.swift b/Passepartout/Library/Sources/AppUIMain/Views/App/AppToolbar.swift index eaa30fc8..f349b8ab 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/App/AppToolbar.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/App/AppToolbar.swift @@ -48,6 +48,8 @@ struct AppToolbar: ToolbarContent, SizeClassProviding { let onAbout: () -> Void + let onMigrateProfiles: () -> Void + let onNewProfile: (Profile) -> Void var body: some ToolbarContent { @@ -74,6 +76,7 @@ private extension AppToolbar { AddProfileMenu( profileManager: profileManager, isImporting: $isImporting, + onMigrateProfiles: onMigrateProfiles, onNewProfile: onNewProfile ) } @@ -109,7 +112,8 @@ private extension AppToolbar { isImporting: .constant(false), onSettings: {}, onAbout: {}, - onNewProfile: { _ in} + onMigrateProfiles: {}, + onNewProfile: { _ in } ) } .frame(width: 600, height: 400) diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Section.swift b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Section.swift new file mode 100644 index 00000000..a87d7779 --- /dev/null +++ b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Section.swift @@ -0,0 +1,99 @@ +// +// MigrateView+Section.swift +// Passepartout +// +// Created by Davide De Rosa on 11/13/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 SwiftUI + +extension MigrateView { + struct SectionView: View { + let profiles: [MigratableProfile] + + @Binding + var excluded: Set + + let statuses: [UUID: MigrationStatus] + + var body: some View { + Section { + ForEach(profiles, id: \.id) { + if let status = statuses[$0.id] { + row(forProfile: $0, status: status) + } else { + button(forProfile: $0) + } + } + } + } + + func button(forProfile profile: MigratableProfile) -> some View { + Button { + if excluded.contains(profile.id) { + excluded.remove(profile.id) + } else { + excluded.insert(profile.id) + } + } label: { + row(forProfile: profile, status: nil) + } + } + + func row(forProfile profile: MigratableProfile, status: MigrationStatus?) -> some View { + HStack { + VStack(alignment: .leading) { + Text(profile.name) + .font(.headline) + + 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) + } + } + } + + @ViewBuilder + func icon(forStatus status: MigrationStatus) -> some View { + switch status { + case .excluded: + EmptyView() + + case .pending: + ProgressView() + + case .success: + ThemeImage(.marked) + + case .failure: + ThemeImage(.failure) + } + } + } +} diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Table.swift b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Table.swift new file mode 100644 index 00000000..b2a580ec --- /dev/null +++ b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Table.swift @@ -0,0 +1,90 @@ +// +// MigrateView+Table.swift +// Passepartout +// +// Created by Davide De Rosa on 11/13/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 SwiftUI + +extension MigrateView { + struct TableView: View { + let profiles: [MigratableProfile] + + @Binding + var excluded: Set + + let statuses: [UUID: MigrationStatus] + + var body: some View { + Table(profiles) { + TableColumn(Strings.Global.name, value: \.name) + TableColumn(Strings.Global.lastUpdate, value: \.timestamp) + TableColumn("") { profile in + if let status = statuses[profile.id] { + imageName(forStatus: status) + .map { + ThemeImage($0) + } + } else { + Toggle("", isOn: isOnBinding(for: profile.id)) + .labelsHidden() + } + } + } + } + + func isOnBinding(for profileId: UUID) -> Binding { + Binding { + !excluded.contains(profileId) + } set: { + if $0 { + excluded.remove(profileId) + } else { + excluded.insert(profileId) + } + } + } + + func imageName(forStatus status: MigrationStatus) -> Theme.ImageName? { + switch status { + case .excluded: + return nil + + case .pending: + return .progress + + case .success: + return .marked + + case .failure: + return .failure + } + } + } +} + +private extension MigratableProfile { + var timestamp: String { + lastUpdate?.localizedDescription(style: .timestamp) ?? "" + } +} diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView.swift b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView.swift new file mode 100644 index 00000000..f03febb8 --- /dev/null +++ b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView.swift @@ -0,0 +1,230 @@ +// +// MigrateView.swift +// Passepartout +// +// Created by Davide De Rosa on 11/13/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 CommonUtils +import PassepartoutKit +import SwiftUI + +// FIXME: ###, migrations UI + +struct MigrateView: View { + enum Style { + case section + + case table + } + + @EnvironmentObject + private var migrationManager: MigrationManager + + let style: Style + + @State + private var isFetching = true + + @State + private var isMigrating = false + + @State + private var profiles: [MigratableProfile] = [] + + @State + private var excluded: Set = [] + + @State + private var statuses: [UUID: MigrationStatus] = [:] + + @StateObject + private var errorHandler: ErrorHandler = .default() + + var body: some View { + Form { + Subview( + style: style, + profiles: profiles, + excluded: $excluded, + statuses: statuses + ) + .disabled(isMigrating) + } + .themeForm() + .themeProgress(if: isFetching) + .themeEmptyContent(if: !isFetching && profiles.isEmpty, message: "Nothing to migrate") + .navigationTitle(title) + .toolbar(content: toolbarContent) + .task { + await fetch() + } + .withErrorHandler(errorHandler) + } +} + +private extension MigrateView { + var title: String { + Strings.Views.Migrate.title + } + + func toolbarContent() -> some ToolbarContent { + ToolbarItem(placement: .confirmationAction) { + Button("Proceed") { + Task { + await migrate() + } + } + } + } +} + +private extension MigrateView { + func fetch() async { + do { + isFetching = true + profiles = try await migrationManager.fetchMigratableProfiles() + isFetching = false + } catch { + pp_log(.App.migration, .error, "Unable to fetch migratable profiles: \(error)") + errorHandler.handle(error, title: title) + isFetching = false + } + } + + func migrate() async { + do { + isMigrating = true + let selection = Set(profiles.map(\.id)).symmetricDifference(excluded) + let migrated = try await migrationManager.migrateProfiles(profiles, selection: selection) { + statuses[$0] = $1 + } + print(">>> Migrated: \(migrated.count)") + _ = migrated + // FIXME: ###, import migrated + } 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 + ) + } + } + + 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() + } + } +} diff --git a/Passepartout/Library/Sources/CommonLibrary/Business/MigrationManager.swift b/Passepartout/Library/Sources/CommonLibrary/Business/MigrationManager.swift new file mode 100644 index 00000000..77700a5a --- /dev/null +++ b/Passepartout/Library/Sources/CommonLibrary/Business/MigrationManager.swift @@ -0,0 +1,122 @@ +// +// MigrationManager.swift +// Passepartout +// +// Created by Davide De Rosa on 11/13/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 Foundation +import PassepartoutKit + +@MainActor +public final class MigrationManager: ObservableObject { + public struct Simulation { + public let maxMigrationTime: Double? + + public let randomFailures: Bool + + public init(maxMigrationTime: Double?, randomFailures: Bool) { + self.maxMigrationTime = maxMigrationTime + self.randomFailures = randomFailures + } + } + + private let profileStrategy: ProfileMigrationStrategy + + private nonisolated let simulation: Simulation? + + public convenience init(profileStrategy: ProfileMigrationStrategy? = nil) { + self.init(profileStrategy: profileStrategy, simulation: nil) + } + + public init(profileStrategy: ProfileMigrationStrategy? = nil, simulation: Simulation?) { + self.profileStrategy = profileStrategy ?? DummyProfileStrategy() + self.simulation = simulation + } +} + +extension MigrationManager { + public func fetchMigratableProfiles() async throws -> [MigratableProfile] { + try await profileStrategy.fetchMigratableProfiles() + } + + public func migrateProfile(withId profileId: UUID) async throws -> Profile? { + try await profileStrategy.fetchProfile(withId: profileId) + } + + public func migrateProfiles( + _ profiles: [MigratableProfile], + selection: Set, + onUpdate: @escaping @MainActor (UUID, MigrationStatus) -> Void + ) async throws -> [Profile] { + profiles.forEach { + onUpdate($0.id, selection.contains($0.id) ? .pending : .excluded) + } + return try await withThrowingTaskGroup(of: Profile?.self, returning: [Profile].self) { group in + 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.. [MigratableProfile] { + [] + } + + func fetchProfile(withId profileId: UUID) async throws -> Profile? { + nil + } +} diff --git a/Passepartout/Library/Sources/CommonLibrary/Domain/MigratableProfile.swift b/Passepartout/Library/Sources/CommonLibrary/Domain/MigratableProfile.swift index d2fff9eb..1a34320c 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Domain/MigratableProfile.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Domain/MigratableProfile.swift @@ -25,7 +25,7 @@ import Foundation -public struct MigratableProfile: Sendable { +public struct MigratableProfile: Identifiable, Sendable { public let id: UUID public let name: String diff --git a/Passepartout/Library/Sources/CommonLibrary/Domain/MigrationStatus.swift b/Passepartout/Library/Sources/CommonLibrary/Domain/MigrationStatus.swift new file mode 100644 index 00000000..ffd3d85b --- /dev/null +++ b/Passepartout/Library/Sources/CommonLibrary/Domain/MigrationStatus.swift @@ -0,0 +1,36 @@ +// +// MigrationStatus.swift +// Passepartout +// +// Created by Davide De Rosa on 11/13/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 Foundation + +public enum MigrationStatus { + case excluded + + case pending + + case success + + case failure +} diff --git a/Passepartout/Library/Sources/CommonLibrary/Business/Bundle+Extensions.swift b/Passepartout/Library/Sources/CommonLibrary/Extensions/Bundle+Extensions.swift similarity index 100% rename from Passepartout/Library/Sources/CommonLibrary/Business/Bundle+Extensions.swift rename to Passepartout/Library/Sources/CommonLibrary/Extensions/Bundle+Extensions.swift diff --git a/Passepartout/Library/Sources/CommonLibrary/Business/PassepartoutConfiguration+Extensions.swift b/Passepartout/Library/Sources/CommonLibrary/Extensions/PassepartoutConfiguration+Extensions.swift similarity index 100% rename from Passepartout/Library/Sources/CommonLibrary/Business/PassepartoutConfiguration+Extensions.swift rename to Passepartout/Library/Sources/CommonLibrary/Extensions/PassepartoutConfiguration+Extensions.swift diff --git a/Passepartout/Library/Sources/CommonLibrary/Business/InMemoryProfileRepository.swift b/Passepartout/Library/Sources/CommonLibrary/Strategy/InMemoryProfileRepository.swift similarity index 100% rename from Passepartout/Library/Sources/CommonLibrary/Business/InMemoryProfileRepository.swift rename to Passepartout/Library/Sources/CommonLibrary/Strategy/InMemoryProfileRepository.swift diff --git a/Passepartout/Library/Sources/CommonLibrary/Business/NEProfileRepository.swift b/Passepartout/Library/Sources/CommonLibrary/Strategy/NEProfileRepository.swift similarity index 100% rename from Passepartout/Library/Sources/CommonLibrary/Business/NEProfileRepository.swift rename to Passepartout/Library/Sources/CommonLibrary/Strategy/NEProfileRepository.swift diff --git a/Passepartout/Library/Sources/CommonLibrary/Strategy/ProfileMigrationStrategy.swift b/Passepartout/Library/Sources/CommonLibrary/Strategy/ProfileMigrationStrategy.swift new file mode 100644 index 00000000..b3a379c7 --- /dev/null +++ b/Passepartout/Library/Sources/CommonLibrary/Strategy/ProfileMigrationStrategy.swift @@ -0,0 +1,33 @@ +// +// ProfileMigrationStrategy.swift +// Passepartout +// +// Created by Davide De Rosa on 11/13/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 Foundation +import PassepartoutKit + +public protocol ProfileMigrationStrategy { + func fetchMigratableProfiles() async throws -> [MigratableProfile] + + func fetchProfile(withId profileId: UUID) async throws -> Profile? +} diff --git a/Passepartout/Library/Sources/CommonLibrary/Business/ProfileRepository.swift b/Passepartout/Library/Sources/CommonLibrary/Strategy/ProfileRepository.swift similarity index 100% rename from Passepartout/Library/Sources/CommonLibrary/Business/ProfileRepository.swift rename to Passepartout/Library/Sources/CommonLibrary/Strategy/ProfileRepository.swift diff --git a/Passepartout/Library/Sources/LegacyV2/CDProfileRepositoryV2.swift b/Passepartout/Library/Sources/LegacyV2/Strategy/CDProfileRepositoryV2.swift similarity index 86% rename from Passepartout/Library/Sources/LegacyV2/CDProfileRepositoryV2.swift rename to Passepartout/Library/Sources/LegacyV2/Strategy/CDProfileRepositoryV2.swift index 99cf3019..8e6e4a83 100644 --- a/Passepartout/Library/Sources/LegacyV2/CDProfileRepositoryV2.swift +++ b/Passepartout/Library/Sources/LegacyV2/Strategy/CDProfileRepositoryV2.swift @@ -24,11 +24,11 @@ // import CommonLibrary -import CoreData +@preconcurrency import CoreData import Foundation import PassepartoutKit -final class CDProfileRepositoryV2 { +final class CDProfileRepositoryV2: Sendable { static var model: NSManagedObjectModel { guard let model: NSManagedObjectModel = .mergedModel(from: [.module]) else { fatalError("Unable to build Core Data model (Profiles v2)") @@ -63,9 +63,18 @@ final class CDProfileRepositoryV2 { ) } - func profiles() async throws -> [ProfileV2] { + func profile(withId profileId: UUID) async throws -> ProfileV2? { + try await profiles(withIds: [profileId]).first + } + + func profiles(withIds profileIds: Set?) async throws -> [ProfileV2] { let decoder = JSONDecoder() - return try await fetchProfiles( + let profiles: [ProfileV2] = try await fetchProfiles( + prefetch: { + if let profileIds { + $0.predicate = NSPredicate(format: "any uuid in %@", profileIds) + } + }, map: { $0.compactMap { guard let json = $0.value.encryptedJSON ?? $0.value.json else { @@ -81,10 +90,11 @@ final class CDProfileRepositoryV2 { } } ) + return profiles } } -private extension CDProfileRepositoryV2 { +extension CDProfileRepositoryV2 { func fetchProfiles( prefetch: ((NSFetchRequest) -> Void)? = nil, map: @escaping ([UUID: CDProfile]) -> [T] diff --git a/Passepartout/Library/Sources/LegacyV2/LegacyV2.swift b/Passepartout/Library/Sources/LegacyV2/Strategy/ProfileV2MigrationStrategy.swift similarity index 69% rename from Passepartout/Library/Sources/LegacyV2/LegacyV2.swift rename to Passepartout/Library/Sources/LegacyV2/Strategy/ProfileV2MigrationStrategy.swift index 00d42423..ab557105 100644 --- a/Passepartout/Library/Sources/LegacyV2/LegacyV2.swift +++ b/Passepartout/Library/Sources/LegacyV2/Strategy/ProfileV2MigrationStrategy.swift @@ -1,5 +1,5 @@ // -// LegacyV2.swift +// ProfileV2MigrationStrategy.swift // Passepartout // // Created by Davide De Rosa on 10/1/24. @@ -28,7 +28,7 @@ import CommonUtils import Foundation import PassepartoutKit -public final class LegacyV2 { +public final class ProfileV2MigrationStrategy: ProfileMigrationStrategy, Sendable { private let profilesRepository: CDProfileRepositoryV2 private let cloudKitIdentifier: String? @@ -52,40 +52,31 @@ public final class LegacyV2 { } } -// MARK: - Mapping +// MARK: - ProfileMigrationStrategy -extension LegacyV2 { +extension ProfileV2MigrationStrategy { public func fetchMigratableProfiles() async throws -> [MigratableProfile] { try await profilesRepository.migratableProfiles() } - public func fetchProfiles(selection: Set) async throws -> (migrated: [Profile], failed: Set) { - let profilesV2 = try await profilesRepository.profiles() - - var migrated: [Profile] = [] - var failed: Set = [] + public func fetchProfile(withId profileId: UUID) async throws -> Profile? { let mapper = MapperV2() - - profilesV2.forEach { - guard selection.contains($0.id) else { - return - } - do { - let mapped = try mapper.toProfileV3($0) - migrated.append(mapped) - } catch { - pp_log(.App.migration, .error, "Unable to migrate profile \($0.id): \(error)") - failed.insert($0.id) + do { + guard let profile = try await profilesRepository.profile(withId: profileId) else { + return nil } + return try mapper.toProfileV3(profile) + } catch { + pp_log(.App.migration, .error, "Unable to migrate profile \(profileId): \(error)") + return nil } - return (migrated, failed) } } -// MARK: - Legacy profiles +// MARK: - Internal -extension LegacyV2 { +extension ProfileV2MigrationStrategy { func fetchProfilesV2() async throws -> [ProfileV2] { - try await profilesRepository.profiles() + try await profilesRepository.profiles(withIds: nil) } } diff --git a/Passepartout/Library/Sources/UILibrary/Business/AppContext.swift b/Passepartout/Library/Sources/UILibrary/Business/AppContext.swift index b024822c..b0e307c9 100644 --- a/Passepartout/Library/Sources/UILibrary/Business/AppContext.swift +++ b/Passepartout/Library/Sources/UILibrary/Business/AppContext.swift @@ -33,14 +33,16 @@ import PassepartoutKit public final class AppContext: ObservableObject { public let iapManager: IAPManager - public let registry: Registry + public let migrationManager: MigrationManager public let profileManager: ProfileManager - public let tunnel: ExtendedTunnel - public let providerManager: ProviderManager + public let registry: Registry + + public let tunnel: ExtendedTunnel + private var launchTask: Task? private var pendingTask: Task? @@ -49,16 +51,18 @@ public final class AppContext: ObservableObject { public init( iapManager: IAPManager, - registry: Registry, + migrationManager: MigrationManager, profileManager: ProfileManager, - tunnel: ExtendedTunnel, - providerManager: ProviderManager + providerManager: ProviderManager, + registry: Registry, + tunnel: ExtendedTunnel ) { self.iapManager = iapManager - self.registry = registry + self.migrationManager = migrationManager self.profileManager = profileManager - self.tunnel = tunnel self.providerManager = providerManager + self.registry = registry + self.tunnel = tunnel subscriptions = [] } } diff --git a/Passepartout/Library/Sources/UILibrary/Extensions/View+Environment.swift b/Passepartout/Library/Sources/UILibrary/Extensions/View+Environment.swift index b2670d98..8605dbb7 100644 --- a/Passepartout/Library/Sources/UILibrary/Extensions/View+Environment.swift +++ b/Passepartout/Library/Sources/UILibrary/Extensions/View+Environment.swift @@ -30,6 +30,7 @@ extension View { public func withEnvironment(from context: AppContext, theme: Theme) -> some View { environmentObject(theme) .environmentObject(context.iapManager) + .environmentObject(context.migrationManager) .environmentObject(context.providerManager) } diff --git a/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift b/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift index 70c7569a..da16033b 100644 --- a/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift +++ b/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift @@ -275,6 +275,8 @@ public enum Strings { public static let keepAlive = Strings.tr("Localizable", "global.keep_alive", fallback: "Keep-alive") /// Key public static let key = Strings.tr("Localizable", "global.key", fallback: "Key") + /// Last update + public static let lastUpdate = Strings.tr("Localizable", "global.last_update", fallback: "Last update") /// Loading public static let loading = Strings.tr("Localizable", "global.loading", fallback: "Loading") /// Method @@ -717,6 +719,10 @@ public enum Strings { } } } + public enum Migrate { + /// Migrate + public static let title = Strings.tr("Localizable", "views.migrate.title", fallback: "Migrate") + } public enum Profile { public enum ModuleList { public enum Section { @@ -761,6 +767,8 @@ public enum Strings { public enum Toolbar { /// Import profile public static let importProfile = Strings.tr("Localizable", "views.profiles.toolbar.import_profile", fallback: "Import profile") + /// Migrate profiles + public static let migrateProfiles = Strings.tr("Localizable", "views.profiles.toolbar.migrate_profiles", fallback: "Migrate profiles") /// New profile public static let newProfile = Strings.tr("Localizable", "views.profiles.toolbar.new_profile", fallback: "New profile") } diff --git a/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift b/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift index 6121c090..15f9dce4 100644 --- a/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift +++ b/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift @@ -76,12 +76,14 @@ extension AppContext { let providerManager = ProviderManager( repository: InMemoryProviderRepository() ) + let migrationManager = MigrationManager() return AppContext( iapManager: iapManager, - registry: registry, + migrationManager: migrationManager, profileManager: profileManager, - tunnel: tunnel, - providerManager: providerManager + providerManager: providerManager, + registry: registry, + tunnel: tunnel ) } } diff --git a/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings b/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings index c563ea69..89289fd2 100644 --- a/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings +++ b/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings @@ -35,6 +35,7 @@ "global.interface" = "Interface"; "global.keep_alive" = "Keep-alive"; "global.key" = "Key"; +"global.last_update" = "Last update"; "global.loading" = "Loading"; "global.method" = "Method"; "global.modules" = "Modules"; @@ -122,6 +123,7 @@ "views.profiles.folders.no_profiles" = "No profiles"; "views.profiles.toolbar.new_profile" = "New profile"; "views.profiles.toolbar.import_profile" = "Import profile"; +"views.profiles.toolbar.migrate_profiles" = "Migrate profiles"; "views.profiles.errors.tunnel" = "Unable to execute tunnel operation."; "views.profiles.errors.duplicate" = "Unable to duplicate profile '%@'."; "views.profiles.errors.import" = "Unable to import profiles."; @@ -155,6 +157,8 @@ "views.about.credits.notices" = "Notices"; "views.about.credits.translations" = "Translations"; +"views.migrate.title" = "Migrate"; + "views.donate.title" = "Make a donation"; "views.donate.sections.main.footer" = "If you want to display gratitude for my work, here are a couple amounts you can donate instantly.\n\nYou will only be charged once per donation, and you can donate multiple times."; "views.donate.alerts.thank_you.message" = "This means a lot to me and I really hope you keep using and promoting this app."; diff --git a/Passepartout/Library/Sources/UILibrary/Theme/Theme+ImageName.swift b/Passepartout/Library/Sources/UILibrary/Theme/Theme+ImageName.swift index f5e7758d..4678cef8 100644 --- a/Passepartout/Library/Sources/UILibrary/Theme/Theme+ImageName.swift +++ b/Passepartout/Library/Sources/UILibrary/Theme/Theme+ImageName.swift @@ -37,6 +37,7 @@ extension Theme { case disclose case editableSectionEdit case editableSectionRemove + case failure case favoriteOff case favoriteOn case filters @@ -48,8 +49,10 @@ extension Theme { case pending case profileEdit case profileImport + case profileMigrate case profilesGrid case profilesList + case progress case remove case search case settings @@ -81,6 +84,7 @@ extension Theme.ImageName { case .disclose: return "chevron.down" case .editableSectionEdit: return "arrow.up.arrow.down" case .editableSectionRemove: return "trash" + case .failure: return "exclamationmark.triangle" case .favoriteOff: return "star" case .favoriteOn: return "star.fill" case .filters: return "line.3.horizontal.decrease" @@ -92,8 +96,10 @@ extension Theme.ImageName { case .pending: return "clock" case .profileEdit: return "square.and.pencil" case .profileImport: return "square.and.arrow.down" + case .profileMigrate: return "arrow.up.square" case .profilesGrid: return "square.grid.2x2" case .profilesList: return "rectangle.grid.1x2" + case .progress: return "clock" case .remove: return "minus" case .search: return "magnifyingglass" case .settings: return "gearshape" diff --git a/Passepartout/Shared/AppContext+Shared.swift b/Passepartout/Shared/AppContext+Shared.swift index 7c3b8862..1b37a0d8 100644 --- a/Passepartout/Shared/AppContext+Shared.swift +++ b/Passepartout/Shared/AppContext+Shared.swift @@ -29,6 +29,7 @@ import AppDataProviders import CommonLibrary import CommonUtils import Foundation +import LegacyV2 import PassepartoutKit import UILibrary @@ -93,12 +94,29 @@ extension AppContext { return ProviderManager(repository: repository) }() + // MARK: MigrationManager + + let profileStrategy = ProfileV2MigrationStrategy( + coreDataLogger: .default, + profilesContainerName: Constants.shared.containers.legacyV2, + cloudKitIdentifier: BundleConfiguration.mainString(for: .legacyV2CloudKitId) + ) +#if DEBUG + let migrationManager = MigrationManager(profileStrategy: profileStrategy, simulation: .init( + maxMigrationTime: 3.0, + randomFailures: true + )) +#else + let migrationManager = MigrationManager(profileStrategy: profileStrategy) +#endif + return AppContext( iapManager: .shared, - registry: .shared, + migrationManager: migrationManager, profileManager: profileManager, - tunnel: tunnel, - providerManager: providerManager + providerManager: providerManager, + registry: .shared, + tunnel: tunnel ) }() } diff --git a/Passepartout/Tests/LegacyV2CoreDataTests.swift b/Passepartout/Tests/MigrationManagerTests.swift similarity index 78% rename from Passepartout/Tests/LegacyV2CoreDataTests.swift rename to Passepartout/Tests/MigrationManagerTests.swift index 508634ef..73021a36 100644 --- a/Passepartout/Tests/LegacyV2CoreDataTests.swift +++ b/Passepartout/Tests/MigrationManagerTests.swift @@ -1,5 +1,5 @@ // -// LegacyV2CoreDataTests.swift +// MigrationManagerTests.swift // Passepartout // // Created by Davide De Rosa on 11/12/24. @@ -23,15 +23,19 @@ // along with Passepartout. If not, see . // -import CommonUtils +import CommonLibrary import Foundation @testable import LegacyV2 import PassepartoutKit import XCTest -final class LegacyV2CoreDataTests: XCTestCase { - func test_givenStore_whenFetchV2_thenReturnsProfilesV2() async throws { - let sut = newStore() +@MainActor +final class MigrationManagerTests: XCTestCase { +} + +extension MigrationManagerTests { + func test_givenStrategy_whenFetchV2_thenReturnsProfilesV2() async throws { + let sut = newStrategy() let profilesV2 = try await sut.fetchProfilesV2() XCTAssertEqual(profilesV2.count, 6) @@ -45,8 +49,8 @@ final class LegacyV2CoreDataTests: XCTestCase { ]) } - func test_givenStore_whenFetch_thenReturnsMigratableProfiles() async throws { - let sut = newStore() + func test_givenManager_whenFetch_thenReturnsMigratableProfiles() async throws { + let sut = newManager() let migratable = try await sut.fetchMigratableProfiles() let expectedIDs = [ @@ -71,16 +75,13 @@ final class LegacyV2CoreDataTests: XCTestCase { XCTAssertEqual(Set(migratable.map(\.name)), Set(expectedNames)) } - func test_givenStore_whenMigrateHideMe_thenIsExpected() async throws { - let sut = newStore() + func test_givenManager_whenMigrateHideMe_thenIsExpected() async throws { + let sut = newManager() let id = try XCTUnwrap(UUID(uuidString: "8A568345-85C4-44C1-A9C4-612E8B07ADC5")) - let result = try await sut.fetchProfiles(selection: [id]) - let migrated = result.migrated - XCTAssertEqual(migrated.count, 1) - XCTAssertTrue(result.failed.isEmpty) + let migrated = try await sut.migrateProfile(withId: id) + let profile = try XCTUnwrap(migrated) - let profile = try XCTUnwrap(migrated.first) XCTAssertEqual(profile.id, id) XCTAssertEqual(profile.name, "Hide.me") XCTAssertEqual(profile.attributes.lastUpdate, Date(timeIntervalSinceReferenceDate: 673117681.24825)) @@ -109,17 +110,13 @@ final class LegacyV2CoreDataTests: XCTestCase { ]) } - func test_givenStore_whenMigrateProtonVPN_thenIsExpected() async throws { - let sut = newStore() + func test_givenManager_whenMigrateProtonVPN_thenIsExpected() async throws { + let sut = newManager() let id = try XCTUnwrap(UUID(uuidString: "981E7CBD-7733-4CF3-9A51-2777614ED5D4")) - let result = try await sut.fetchProfiles(selection: [id]) - let migrated = result.migrated - XCTAssertEqual(migrated.count, 1) - XCTAssertTrue(result.failed.isEmpty) + let migrated = try await sut.migrateProfile(withId: id) + let profile = try XCTUnwrap(migrated) - XCTAssertEqual(migrated.count, 1) - let profile = try XCTUnwrap(migrated.first) XCTAssertEqual(profile.id, id) XCTAssertEqual(profile.name, "ProtonVPN") XCTAssertEqual(profile.attributes.lastUpdate, Date(timeIntervalSinceReferenceDate: 724509584.854822)) @@ -137,17 +134,13 @@ final class LegacyV2CoreDataTests: XCTestCase { XCTAssertEqual(openVPN.credentials?.password, "bar") } - func test_givenStore_whenMigrateVPSOpenVPN_thenIsExpected() async throws { - let sut = newStore() + func test_givenManager_whenMigrateVPSOpenVPN_thenIsExpected() async throws { + let sut = newManager() let id = try XCTUnwrap(UUID(uuidString: "239AD322-7440-4198-990A-D91379916FE2")) - let result = try await sut.fetchProfiles(selection: [id]) - let migrated = result.migrated - XCTAssertEqual(migrated.count, 1) - XCTAssertTrue(result.failed.isEmpty) + let migrated = try await sut.migrateProfile(withId: id) + let profile = try XCTUnwrap(migrated) - XCTAssertEqual(migrated.count, 1) - let profile = try XCTUnwrap(migrated.first) XCTAssertEqual(profile.id, id) XCTAssertEqual(profile.name, "vps-ta-cert-cbc256-lzo") XCTAssertEqual(profile.attributes.lastUpdate, Date(timeIntervalSinceReferenceDate: 726164772.28976)) @@ -174,17 +167,13 @@ final class LegacyV2CoreDataTests: XCTestCase { XCTAssertEqual(cfg.tlsWrap?.strategy, .auth) } - func test_givenStore_whenMigrateVPSWireGuard_thenIsExpected() async throws { - let sut = newStore() + func test_givenManager_whenMigrateVPSWireGuard_thenIsExpected() async throws { + let sut = newManager() let id = try XCTUnwrap(UUID(uuidString: "069F76BD-1F6B-425C-AD83-62477A8B6558")) - let result = try await sut.fetchProfiles(selection: [id]) - let migrated = result.migrated - XCTAssertEqual(migrated.count, 1) - XCTAssertTrue(result.failed.isEmpty) + let migrated = try await sut.migrateProfile(withId: id) + let profile = try XCTUnwrap(migrated) - XCTAssertEqual(migrated.count, 1) - let profile = try XCTUnwrap(migrated.first) XCTAssertEqual(profile.id, id) XCTAssertEqual(profile.name, "vps-wg") XCTAssertEqual(profile.attributes.lastUpdate, Date(timeIntervalSinceReferenceDate: 727398252.46203)) @@ -217,16 +206,21 @@ final class LegacyV2CoreDataTests: XCTestCase { } } -private extension LegacyV2CoreDataTests { - func newStore() -> LegacyV2 { - guard let baseURL = Bundle(for: LegacyV2CoreDataTests.self).resourceURL else { +private extension MigrationManagerTests { + func newStrategy() -> ProfileV2MigrationStrategy { + guard let baseURL = Bundle(for: MigrationManagerTests.self).resourceURL else { fatalError() } - return LegacyV2( + return ProfileV2MigrationStrategy( coreDataLogger: nil, profilesContainerName: "Profiles", baseURL: baseURL, cloudKitIdentifier: nil ) } + + func newManager() -> MigrationManager { + let strategy = newStrategy() + return MigrationManager(profileStrategy: strategy) + } }