Add initial migration UI (#866)

Repurpose LegacyManager as MigrationManager. Present initial migration
UI from "+" menu in app home.

Different styles:

- iOS → Section / ForEach
- macOS → Table
This commit is contained in:
Davide 2024-11-14 11:02:26 +01:00 committed by GitHub
parent bfe1373c4c
commit 114e1abe12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 820 additions and 126 deletions

View File

@ -10,7 +10,7 @@
0E3E22962CE53510005135DF /* AppUIMain in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, macos, ); productRef = 0E3E22952CE53510005135DF /* AppUIMain */; }; 0E3E22962CE53510005135DF /* AppUIMain in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, macos, ); productRef = 0E3E22952CE53510005135DF /* AppUIMain */; };
0E3E22982CE53510005135DF /* AppUITV in Frameworks */ = {isa = PBXBuildFile; platformFilters = (tvos, ); productRef = 0E3E22972CE53510005135DF /* AppUITV */; }; 0E3E22982CE53510005135DF /* AppUITV in Frameworks */ = {isa = PBXBuildFile; platformFilters = (tvos, ); productRef = 0E3E22972CE53510005135DF /* AppUITV */; };
0E3FF4BA2CE3AFBC00BFF640 /* Profiles.sqlite in Resources */ = {isa = PBXBuildFile; fileRef = 0E3FF4B72CE3AFBC00BFF640 /* Profiles.sqlite */; }; 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 */; }; 0E60512C2CE5393C00F763D4 /* PassepartoutImplementations in Frameworks */ = {isa = PBXBuildFile; productRef = 0E60512B2CE5393C00F763D4 /* PassepartoutImplementations */; };
0E757F132CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E757F122CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift */; }; 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, ); }; }; 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; }; 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; }; 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 = "<group>"; }; 0E3FF4B72CE3AFBC00BFF640 /* Profiles.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = Profiles.sqlite; sourceTree = "<group>"; };
0E3FF4B92CE3AFBC00BFF640 /* LegacyV2CoreDataTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyV2CoreDataTests.swift; sourceTree = "<group>"; }; 0E3FF4B92CE3AFBC00BFF640 /* MigrationManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MigrationManagerTests.swift; sourceTree = "<group>"; };
0E5DFDDC2CDB8F9100F2DE70 /* Passepartout.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Passepartout.storekit; sourceTree = "<group>"; }; 0E5DFDDC2CDB8F9100F2DE70 /* Passepartout.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Passepartout.storekit; sourceTree = "<group>"; };
0E757F102CD0CFFC006E13E1 /* PassepartoutLoginItem.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PassepartoutLoginItem.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; 0E757F122CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassepartoutLoginItemApp.swift; sourceTree = "<group>"; };
@ -219,7 +219,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0E3FF4B82CE3AFBC00BFF640 /* Resources */, 0E3FF4B82CE3AFBC00BFF640 /* Resources */,
0E3FF4B92CE3AFBC00BFF640 /* LegacyV2CoreDataTests.swift */, 0E3FF4B92CE3AFBC00BFF640 /* MigrationManagerTests.swift */,
); );
path = Tests; path = Tests;
sourceTree = "<group>"; sourceTree = "<group>";
@ -554,7 +554,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
0E3FF4BB2CE3AFBC00BFF640 /* LegacyV2CoreDataTests.swift in Sources */, 0E3FF4BB2CE3AFBC00BFF640 /* MigrationManagerTests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -876,6 +876,7 @@
0E3FF4B52CE3AF6F00BFF640 /* Debug */ = { 0E3FF4B52CE3AF6F00BFF640 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES;
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.algoritmico.ios.PassepartoutTests; PRODUCT_BUNDLE_IDENTIFIER = com.algoritmico.ios.PassepartoutTests;
@ -888,6 +889,7 @@
0E3FF4B62CE3AF6F00BFF640 /* Release */ = { 0E3FF4B62CE3AF6F00BFF640 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES;
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.algoritmico.ios.PassepartoutTests; PRODUCT_BUNDLE_IDENTIFIER = com.algoritmico.ios.PassepartoutTests;

View File

@ -45,24 +45,28 @@ struct AboutRouterView: View {
extension AboutRouterView { extension AboutRouterView {
enum NavigationRoute: Hashable { enum NavigationRoute: Hashable {
case donate case appDebugLog(title: String)
case credits
case diagnostics case diagnostics
case appDebugLog(title: String) case donate
case tunnelDebugLog(title: String, url: URL?)
case links case links
case credits case tunnelDebugLog(title: String, url: URL?)
} }
@ViewBuilder @ViewBuilder
func pushDestination(for item: NavigationRoute?) -> some View { func pushDestination(for item: NavigationRoute?) -> some View {
switch item { switch item {
case .donate: case .appDebugLog(let title):
DonateView() DebugLogView.withApp(parameters: Constants.shared.log)
.navigationTitle(title)
case .credits:
CreditsView()
case .diagnostics: case .diagnostics:
DiagnosticsView( DiagnosticsView(
@ -70,9 +74,11 @@ extension AboutRouterView {
tunnel: tunnel tunnel: tunnel
) )
case .appDebugLog(let title): case .donate:
DebugLogView.withApp(parameters: Constants.shared.log) DonateView()
.navigationTitle(title)
case .links:
LinksView()
case .tunnelDebugLog(let title, let url): case .tunnelDebugLog(let title, let url):
if let url { if let url {
@ -83,12 +89,6 @@ extension AboutRouterView {
.navigationTitle(title) .navigationTitle(title)
} }
case .links:
LinksView()
case .credits:
CreditsView()
default: default:
Text(Strings.Global.noSelection) Text(Strings.Global.noSelection)
.themeEmptyMessage() .themeEmptyMessage()

View File

@ -44,20 +44,20 @@ struct AboutView: View {
} }
extension AboutView { extension AboutView {
var donateLink: some View { var creditsLink: some View {
navLink(Strings.Views.Donate.title, to: .donate) navLink(Strings.Views.About.Credits.title, to: .credits)
} }
var diagnosticsLink: some View { var diagnosticsLink: some View {
navLink(Strings.Views.Diagnostics.title, to: .diagnostics) navLink(Strings.Views.Diagnostics.title, to: .diagnostics)
} }
var linksLink: some View { var donateLink: some View {
navLink(Strings.Views.About.Links.title, to: .links) navLink(Strings.Views.Donate.title, to: .donate)
} }
var creditsLink: some View { var linksLink: some View {
navLink(Strings.Views.About.Credits.title, to: .credits) navLink(Strings.Views.About.Links.title, to: .links)
} }
} }

View File

@ -33,23 +33,42 @@ struct AddProfileMenu: View {
@Binding @Binding
var isImporting: Bool var isImporting: Bool
let onMigrateProfiles: () -> Void
let onNewProfile: (Profile) -> Void let onNewProfile: (Profile) -> Void
var body: some View { var body: some View {
Menu { Menu {
newProfileButton
importProfileButton
migrateProfilesButton
} label: {
ThemeImage(.add)
}
}
}
private extension AddProfileMenu {
var newProfileButton: some View {
Button { Button {
let profile = profileManager.new(withName: Strings.Entities.Profile.Name.new) let profile = profileManager.new(withName: Strings.Entities.Profile.Name.new)
onNewProfile(profile) onNewProfile(profile)
} label: { } label: {
ThemeImageLabel(Strings.Views.Profiles.Toolbar.newProfile, .profileEdit) ThemeImageLabel(Strings.Views.Profiles.Toolbar.newProfile, .profileEdit)
} }
}
var importProfileButton: some View {
Button { Button {
isImporting = true isImporting = true
} label: { } label: {
ThemeImageLabel(Strings.Views.Profiles.Toolbar.importProfile, .profileImport) ThemeImageLabel(Strings.Views.Profiles.Toolbar.importProfile, .profileImport)
} }
} label: { }
ThemeImage(.add)
var migrateProfilesButton: some View {
Button(action: onMigrateProfiles) {
ThemeImageLabel(Strings.Views.Profiles.Toolbar.migrateProfiles, .profileMigrate)
} }
} }
} }

View File

@ -51,6 +51,9 @@ public struct AppCoordinator: View, AppCoordinatorConforming {
@State @State
private var profilePath = NavigationPath() private var profilePath = NavigationPath()
@State
private var migrationPath = NavigationPath()
@StateObject @StateObject
private var errorHandler: ErrorHandler = .default() private var errorHandler: ErrorHandler = .default()
@ -86,6 +89,8 @@ extension AppCoordinator {
case editProviderEntity(Profile, Module, ModuleMetadata.Provider) case editProviderEntity(Profile, Module, ModuleMetadata.Provider)
case migrateProfiles
case settings case settings
case about case about
@ -94,8 +99,9 @@ extension AppCoordinator {
switch self { switch self {
case .editProfile: return 1 case .editProfile: return 1
case .editProviderEntity: return 2 case .editProviderEntity: return 2
case .settings: return 3 case .migrateProfiles: return 3
case .about: return 4 case .settings: return 4
case .about: return 5
} }
} }
@ -146,6 +152,9 @@ extension AppCoordinator {
onAbout: { onAbout: {
present(.about) present(.about)
}, },
onMigrateProfiles: {
present(.migrateProfiles)
},
onNewProfile: enterDetail onNewProfile: enterDetail
) )
} }
@ -176,6 +185,10 @@ extension AppCoordinator {
errorHandler: errorHandler errorHandler: errorHandler
) )
case .migrateProfiles:
MigrateView(style: migrateViewStyle)
.themeNavigationStack(closable: true, path: $migrationPath)
case .settings: case .settings:
SettingsView(profileManager: profileManager) 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) { func enterDetail(of profile: Profile) {
profilePath = NavigationPath() profilePath = NavigationPath()
let isShared = profileManager.isRemotelyShared(profileWithId: profile.id) let isShared = profileManager.isRemotelyShared(profileWithId: profile.id)

View File

@ -48,6 +48,8 @@ struct AppToolbar: ToolbarContent, SizeClassProviding {
let onAbout: () -> Void let onAbout: () -> Void
let onMigrateProfiles: () -> Void
let onNewProfile: (Profile) -> Void let onNewProfile: (Profile) -> Void
var body: some ToolbarContent { var body: some ToolbarContent {
@ -74,6 +76,7 @@ private extension AppToolbar {
AddProfileMenu( AddProfileMenu(
profileManager: profileManager, profileManager: profileManager,
isImporting: $isImporting, isImporting: $isImporting,
onMigrateProfiles: onMigrateProfiles,
onNewProfile: onNewProfile onNewProfile: onNewProfile
) )
} }
@ -109,7 +112,8 @@ private extension AppToolbar {
isImporting: .constant(false), isImporting: .constant(false),
onSettings: {}, onSettings: {},
onAbout: {}, onAbout: {},
onNewProfile: { _ in} onMigrateProfiles: {},
onNewProfile: { _ in }
) )
} }
.frame(width: 600, height: 400) .frame(width: 600, height: 400)

View File

@ -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 <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import SwiftUI
extension MigrateView {
struct SectionView: View {
let profiles: [MigratableProfile]
@Binding
var excluded: Set<UUID>
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)
}
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import SwiftUI
extension MigrateView {
struct TableView: View {
let profiles: [MigratableProfile]
@Binding
var excluded: Set<UUID>
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<Bool> {
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) ?? ""
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
//
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<UUID> = []
@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<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

@ -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 <http://www.gnu.org/licenses/>.
//
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<UUID>,
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..<maxMigrationTime)))
}
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
}
await onUpdate(profileId, .success)
return profile
} catch {
await onUpdate(profileId, .failure)
return nil
}
}
}
var profiles: [Profile] = []
for try await profile in group {
guard let profile else {
continue
}
profiles.append(profile)
}
return profiles
}
}
}
// MARK: - Dummy
private final class DummyProfileStrategy: ProfileMigrationStrategy {
public init() {
}
public func fetchMigratableProfiles() async throws -> [MigratableProfile] {
[]
}
func fetchProfile(withId profileId: UUID) async throws -> Profile? {
nil
}
}

View File

@ -25,7 +25,7 @@
import Foundation import Foundation
public struct MigratableProfile: Sendable { public struct MigratableProfile: Identifiable, Sendable {
public let id: UUID public let id: UUID
public let name: String public let name: String

View File

@ -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 <http://www.gnu.org/licenses/>.
//
import Foundation
public enum MigrationStatus {
case excluded
case pending
case success
case failure
}

View File

@ -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 <http://www.gnu.org/licenses/>.
//
import Foundation
import PassepartoutKit
public protocol ProfileMigrationStrategy {
func fetchMigratableProfiles() async throws -> [MigratableProfile]
func fetchProfile(withId profileId: UUID) async throws -> Profile?
}

View File

@ -24,11 +24,11 @@
// //
import CommonLibrary import CommonLibrary
import CoreData @preconcurrency import CoreData
import Foundation import Foundation
import PassepartoutKit import PassepartoutKit
final class CDProfileRepositoryV2 { final class CDProfileRepositoryV2: Sendable {
static var model: NSManagedObjectModel { static var model: NSManagedObjectModel {
guard let model: NSManagedObjectModel = .mergedModel(from: [.module]) else { guard let model: NSManagedObjectModel = .mergedModel(from: [.module]) else {
fatalError("Unable to build Core Data model (Profiles v2)") 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<UUID>?) async throws -> [ProfileV2] {
let decoder = JSONDecoder() 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: { map: {
$0.compactMap { $0.compactMap {
guard let json = $0.value.encryptedJSON ?? $0.value.json else { 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<T>( func fetchProfiles<T>(
prefetch: ((NSFetchRequest<CDProfile>) -> Void)? = nil, prefetch: ((NSFetchRequest<CDProfile>) -> Void)? = nil,
map: @escaping ([UUID: CDProfile]) -> [T] map: @escaping ([UUID: CDProfile]) -> [T]

View File

@ -1,5 +1,5 @@
// //
// LegacyV2.swift // ProfileV2MigrationStrategy.swift
// Passepartout // Passepartout
// //
// Created by Davide De Rosa on 10/1/24. // Created by Davide De Rosa on 10/1/24.
@ -28,7 +28,7 @@ import CommonUtils
import Foundation import Foundation
import PassepartoutKit import PassepartoutKit
public final class LegacyV2 { public final class ProfileV2MigrationStrategy: ProfileMigrationStrategy, Sendable {
private let profilesRepository: CDProfileRepositoryV2 private let profilesRepository: CDProfileRepositoryV2
private let cloudKitIdentifier: String? 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] { public func fetchMigratableProfiles() async throws -> [MigratableProfile] {
try await profilesRepository.migratableProfiles() try await profilesRepository.migratableProfiles()
} }
public func fetchProfiles(selection: Set<UUID>) async throws -> (migrated: [Profile], failed: Set<UUID>) { public func fetchProfile(withId profileId: UUID) async throws -> Profile? {
let profilesV2 = try await profilesRepository.profiles()
var migrated: [Profile] = []
var failed: Set<UUID> = []
let mapper = MapperV2() let mapper = MapperV2()
profilesV2.forEach {
guard selection.contains($0.id) else {
return
}
do { do {
let mapped = try mapper.toProfileV3($0) guard let profile = try await profilesRepository.profile(withId: profileId) else {
migrated.append(mapped) return nil
}
return try mapper.toProfileV3(profile)
} catch { } catch {
pp_log(.App.migration, .error, "Unable to migrate profile \($0.id): \(error)") pp_log(.App.migration, .error, "Unable to migrate profile \(profileId): \(error)")
failed.insert($0.id) return nil
} }
} }
return (migrated, failed)
}
} }
// MARK: - Legacy profiles // MARK: - Internal
extension LegacyV2 { extension ProfileV2MigrationStrategy {
func fetchProfilesV2() async throws -> [ProfileV2] { func fetchProfilesV2() async throws -> [ProfileV2] {
try await profilesRepository.profiles() try await profilesRepository.profiles(withIds: nil)
} }
} }

View File

@ -33,14 +33,16 @@ import PassepartoutKit
public final class AppContext: ObservableObject { public final class AppContext: ObservableObject {
public let iapManager: IAPManager public let iapManager: IAPManager
public let registry: Registry public let migrationManager: MigrationManager
public let profileManager: ProfileManager public let profileManager: ProfileManager
public let tunnel: ExtendedTunnel
public let providerManager: ProviderManager public let providerManager: ProviderManager
public let registry: Registry
public let tunnel: ExtendedTunnel
private var launchTask: Task<Void, Error>? private var launchTask: Task<Void, Error>?
private var pendingTask: Task<Void, Never>? private var pendingTask: Task<Void, Never>?
@ -49,16 +51,18 @@ public final class AppContext: ObservableObject {
public init( public init(
iapManager: IAPManager, iapManager: IAPManager,
registry: Registry, migrationManager: MigrationManager,
profileManager: ProfileManager, profileManager: ProfileManager,
tunnel: ExtendedTunnel, providerManager: ProviderManager,
providerManager: ProviderManager registry: Registry,
tunnel: ExtendedTunnel
) { ) {
self.iapManager = iapManager self.iapManager = iapManager
self.registry = registry self.migrationManager = migrationManager
self.profileManager = profileManager self.profileManager = profileManager
self.tunnel = tunnel
self.providerManager = providerManager self.providerManager = providerManager
self.registry = registry
self.tunnel = tunnel
subscriptions = [] subscriptions = []
} }
} }

View File

@ -30,6 +30,7 @@ extension View {
public func withEnvironment(from context: AppContext, theme: Theme) -> some View { public func withEnvironment(from context: AppContext, theme: Theme) -> some View {
environmentObject(theme) environmentObject(theme)
.environmentObject(context.iapManager) .environmentObject(context.iapManager)
.environmentObject(context.migrationManager)
.environmentObject(context.providerManager) .environmentObject(context.providerManager)
} }

View File

@ -275,6 +275,8 @@ public enum Strings {
public static let keepAlive = Strings.tr("Localizable", "global.keep_alive", fallback: "Keep-alive") public static let keepAlive = Strings.tr("Localizable", "global.keep_alive", fallback: "Keep-alive")
/// Key /// Key
public static let key = Strings.tr("Localizable", "global.key", fallback: "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 /// Loading
public static let loading = Strings.tr("Localizable", "global.loading", fallback: "Loading") public static let loading = Strings.tr("Localizable", "global.loading", fallback: "Loading")
/// Method /// 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 Profile {
public enum ModuleList { public enum ModuleList {
public enum Section { public enum Section {
@ -761,6 +767,8 @@ public enum Strings {
public enum Toolbar { public enum Toolbar {
/// Import profile /// Import profile
public static let importProfile = Strings.tr("Localizable", "views.profiles.toolbar.import_profile", fallback: "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 /// New profile
public static let newProfile = Strings.tr("Localizable", "views.profiles.toolbar.new_profile", fallback: "New profile") public static let newProfile = Strings.tr("Localizable", "views.profiles.toolbar.new_profile", fallback: "New profile")
} }

View File

@ -76,12 +76,14 @@ extension AppContext {
let providerManager = ProviderManager( let providerManager = ProviderManager(
repository: InMemoryProviderRepository() repository: InMemoryProviderRepository()
) )
let migrationManager = MigrationManager()
return AppContext( return AppContext(
iapManager: iapManager, iapManager: iapManager,
registry: registry, migrationManager: migrationManager,
profileManager: profileManager, profileManager: profileManager,
tunnel: tunnel, providerManager: providerManager,
providerManager: providerManager registry: registry,
tunnel: tunnel
) )
} }
} }

View File

@ -35,6 +35,7 @@
"global.interface" = "Interface"; "global.interface" = "Interface";
"global.keep_alive" = "Keep-alive"; "global.keep_alive" = "Keep-alive";
"global.key" = "Key"; "global.key" = "Key";
"global.last_update" = "Last update";
"global.loading" = "Loading"; "global.loading" = "Loading";
"global.method" = "Method"; "global.method" = "Method";
"global.modules" = "Modules"; "global.modules" = "Modules";
@ -122,6 +123,7 @@
"views.profiles.folders.no_profiles" = "No profiles"; "views.profiles.folders.no_profiles" = "No profiles";
"views.profiles.toolbar.new_profile" = "New profile"; "views.profiles.toolbar.new_profile" = "New profile";
"views.profiles.toolbar.import_profile" = "Import 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.tunnel" = "Unable to execute tunnel operation.";
"views.profiles.errors.duplicate" = "Unable to duplicate profile '%@'."; "views.profiles.errors.duplicate" = "Unable to duplicate profile '%@'.";
"views.profiles.errors.import" = "Unable to import profiles."; "views.profiles.errors.import" = "Unable to import profiles.";
@ -155,6 +157,8 @@
"views.about.credits.notices" = "Notices"; "views.about.credits.notices" = "Notices";
"views.about.credits.translations" = "Translations"; "views.about.credits.translations" = "Translations";
"views.migrate.title" = "Migrate";
"views.donate.title" = "Make a donation"; "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.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."; "views.donate.alerts.thank_you.message" = "This means a lot to me and I really hope you keep using and promoting this app.";

View File

@ -37,6 +37,7 @@ extension Theme {
case disclose case disclose
case editableSectionEdit case editableSectionEdit
case editableSectionRemove case editableSectionRemove
case failure
case favoriteOff case favoriteOff
case favoriteOn case favoriteOn
case filters case filters
@ -48,8 +49,10 @@ extension Theme {
case pending case pending
case profileEdit case profileEdit
case profileImport case profileImport
case profileMigrate
case profilesGrid case profilesGrid
case profilesList case profilesList
case progress
case remove case remove
case search case search
case settings case settings
@ -81,6 +84,7 @@ extension Theme.ImageName {
case .disclose: return "chevron.down" case .disclose: return "chevron.down"
case .editableSectionEdit: return "arrow.up.arrow.down" case .editableSectionEdit: return "arrow.up.arrow.down"
case .editableSectionRemove: return "trash" case .editableSectionRemove: return "trash"
case .failure: return "exclamationmark.triangle"
case .favoriteOff: return "star" case .favoriteOff: return "star"
case .favoriteOn: return "star.fill" case .favoriteOn: return "star.fill"
case .filters: return "line.3.horizontal.decrease" case .filters: return "line.3.horizontal.decrease"
@ -92,8 +96,10 @@ extension Theme.ImageName {
case .pending: return "clock" case .pending: return "clock"
case .profileEdit: return "square.and.pencil" case .profileEdit: return "square.and.pencil"
case .profileImport: return "square.and.arrow.down" case .profileImport: return "square.and.arrow.down"
case .profileMigrate: return "arrow.up.square"
case .profilesGrid: return "square.grid.2x2" case .profilesGrid: return "square.grid.2x2"
case .profilesList: return "rectangle.grid.1x2" case .profilesList: return "rectangle.grid.1x2"
case .progress: return "clock"
case .remove: return "minus" case .remove: return "minus"
case .search: return "magnifyingglass" case .search: return "magnifyingglass"
case .settings: return "gearshape" case .settings: return "gearshape"

View File

@ -29,6 +29,7 @@ import AppDataProviders
import CommonLibrary import CommonLibrary
import CommonUtils import CommonUtils
import Foundation import Foundation
import LegacyV2
import PassepartoutKit import PassepartoutKit
import UILibrary import UILibrary
@ -93,12 +94,29 @@ extension AppContext {
return ProviderManager(repository: repository) 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( return AppContext(
iapManager: .shared, iapManager: .shared,
registry: .shared, migrationManager: migrationManager,
profileManager: profileManager, profileManager: profileManager,
tunnel: tunnel, providerManager: providerManager,
providerManager: providerManager registry: .shared,
tunnel: tunnel
) )
}() }()
} }

View File

@ -1,5 +1,5 @@
// //
// LegacyV2CoreDataTests.swift // MigrationManagerTests.swift
// Passepartout // Passepartout
// //
// Created by Davide De Rosa on 11/12/24. // Created by Davide De Rosa on 11/12/24.
@ -23,15 +23,19 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>. // along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
// //
import CommonUtils import CommonLibrary
import Foundation import Foundation
@testable import LegacyV2 @testable import LegacyV2
import PassepartoutKit import PassepartoutKit
import XCTest import XCTest
final class LegacyV2CoreDataTests: XCTestCase { @MainActor
func test_givenStore_whenFetchV2_thenReturnsProfilesV2() async throws { final class MigrationManagerTests: XCTestCase {
let sut = newStore() }
extension MigrationManagerTests {
func test_givenStrategy_whenFetchV2_thenReturnsProfilesV2() async throws {
let sut = newStrategy()
let profilesV2 = try await sut.fetchProfilesV2() let profilesV2 = try await sut.fetchProfilesV2()
XCTAssertEqual(profilesV2.count, 6) XCTAssertEqual(profilesV2.count, 6)
@ -45,8 +49,8 @@ final class LegacyV2CoreDataTests: XCTestCase {
]) ])
} }
func test_givenStore_whenFetch_thenReturnsMigratableProfiles() async throws { func test_givenManager_whenFetch_thenReturnsMigratableProfiles() async throws {
let sut = newStore() let sut = newManager()
let migratable = try await sut.fetchMigratableProfiles() let migratable = try await sut.fetchMigratableProfiles()
let expectedIDs = [ let expectedIDs = [
@ -71,16 +75,13 @@ final class LegacyV2CoreDataTests: XCTestCase {
XCTAssertEqual(Set(migratable.map(\.name)), Set(expectedNames)) XCTAssertEqual(Set(migratable.map(\.name)), Set(expectedNames))
} }
func test_givenStore_whenMigrateHideMe_thenIsExpected() async throws { func test_givenManager_whenMigrateHideMe_thenIsExpected() async throws {
let sut = newStore() let sut = newManager()
let id = try XCTUnwrap(UUID(uuidString: "8A568345-85C4-44C1-A9C4-612E8B07ADC5")) let id = try XCTUnwrap(UUID(uuidString: "8A568345-85C4-44C1-A9C4-612E8B07ADC5"))
let result = try await sut.fetchProfiles(selection: [id]) let migrated = try await sut.migrateProfile(withId: id)
let migrated = result.migrated let profile = try XCTUnwrap(migrated)
XCTAssertEqual(migrated.count, 1)
XCTAssertTrue(result.failed.isEmpty)
let profile = try XCTUnwrap(migrated.first)
XCTAssertEqual(profile.id, id) XCTAssertEqual(profile.id, id)
XCTAssertEqual(profile.name, "Hide.me") XCTAssertEqual(profile.name, "Hide.me")
XCTAssertEqual(profile.attributes.lastUpdate, Date(timeIntervalSinceReferenceDate: 673117681.24825)) XCTAssertEqual(profile.attributes.lastUpdate, Date(timeIntervalSinceReferenceDate: 673117681.24825))
@ -109,17 +110,13 @@ final class LegacyV2CoreDataTests: XCTestCase {
]) ])
} }
func test_givenStore_whenMigrateProtonVPN_thenIsExpected() async throws { func test_givenManager_whenMigrateProtonVPN_thenIsExpected() async throws {
let sut = newStore() let sut = newManager()
let id = try XCTUnwrap(UUID(uuidString: "981E7CBD-7733-4CF3-9A51-2777614ED5D4")) let id = try XCTUnwrap(UUID(uuidString: "981E7CBD-7733-4CF3-9A51-2777614ED5D4"))
let result = try await sut.fetchProfiles(selection: [id]) let migrated = try await sut.migrateProfile(withId: id)
let migrated = result.migrated let profile = try XCTUnwrap(migrated)
XCTAssertEqual(migrated.count, 1)
XCTAssertTrue(result.failed.isEmpty)
XCTAssertEqual(migrated.count, 1)
let profile = try XCTUnwrap(migrated.first)
XCTAssertEqual(profile.id, id) XCTAssertEqual(profile.id, id)
XCTAssertEqual(profile.name, "ProtonVPN") XCTAssertEqual(profile.name, "ProtonVPN")
XCTAssertEqual(profile.attributes.lastUpdate, Date(timeIntervalSinceReferenceDate: 724509584.854822)) XCTAssertEqual(profile.attributes.lastUpdate, Date(timeIntervalSinceReferenceDate: 724509584.854822))
@ -137,17 +134,13 @@ final class LegacyV2CoreDataTests: XCTestCase {
XCTAssertEqual(openVPN.credentials?.password, "bar") XCTAssertEqual(openVPN.credentials?.password, "bar")
} }
func test_givenStore_whenMigrateVPSOpenVPN_thenIsExpected() async throws { func test_givenManager_whenMigrateVPSOpenVPN_thenIsExpected() async throws {
let sut = newStore() let sut = newManager()
let id = try XCTUnwrap(UUID(uuidString: "239AD322-7440-4198-990A-D91379916FE2")) let id = try XCTUnwrap(UUID(uuidString: "239AD322-7440-4198-990A-D91379916FE2"))
let result = try await sut.fetchProfiles(selection: [id]) let migrated = try await sut.migrateProfile(withId: id)
let migrated = result.migrated let profile = try XCTUnwrap(migrated)
XCTAssertEqual(migrated.count, 1)
XCTAssertTrue(result.failed.isEmpty)
XCTAssertEqual(migrated.count, 1)
let profile = try XCTUnwrap(migrated.first)
XCTAssertEqual(profile.id, id) XCTAssertEqual(profile.id, id)
XCTAssertEqual(profile.name, "vps-ta-cert-cbc256-lzo") XCTAssertEqual(profile.name, "vps-ta-cert-cbc256-lzo")
XCTAssertEqual(profile.attributes.lastUpdate, Date(timeIntervalSinceReferenceDate: 726164772.28976)) XCTAssertEqual(profile.attributes.lastUpdate, Date(timeIntervalSinceReferenceDate: 726164772.28976))
@ -174,17 +167,13 @@ final class LegacyV2CoreDataTests: XCTestCase {
XCTAssertEqual(cfg.tlsWrap?.strategy, .auth) XCTAssertEqual(cfg.tlsWrap?.strategy, .auth)
} }
func test_givenStore_whenMigrateVPSWireGuard_thenIsExpected() async throws { func test_givenManager_whenMigrateVPSWireGuard_thenIsExpected() async throws {
let sut = newStore() let sut = newManager()
let id = try XCTUnwrap(UUID(uuidString: "069F76BD-1F6B-425C-AD83-62477A8B6558")) let id = try XCTUnwrap(UUID(uuidString: "069F76BD-1F6B-425C-AD83-62477A8B6558"))
let result = try await sut.fetchProfiles(selection: [id]) let migrated = try await sut.migrateProfile(withId: id)
let migrated = result.migrated let profile = try XCTUnwrap(migrated)
XCTAssertEqual(migrated.count, 1)
XCTAssertTrue(result.failed.isEmpty)
XCTAssertEqual(migrated.count, 1)
let profile = try XCTUnwrap(migrated.first)
XCTAssertEqual(profile.id, id) XCTAssertEqual(profile.id, id)
XCTAssertEqual(profile.name, "vps-wg") XCTAssertEqual(profile.name, "vps-wg")
XCTAssertEqual(profile.attributes.lastUpdate, Date(timeIntervalSinceReferenceDate: 727398252.46203)) XCTAssertEqual(profile.attributes.lastUpdate, Date(timeIntervalSinceReferenceDate: 727398252.46203))
@ -217,16 +206,21 @@ final class LegacyV2CoreDataTests: XCTestCase {
} }
} }
private extension LegacyV2CoreDataTests { private extension MigrationManagerTests {
func newStore() -> LegacyV2 { func newStrategy() -> ProfileV2MigrationStrategy {
guard let baseURL = Bundle(for: LegacyV2CoreDataTests.self).resourceURL else { guard let baseURL = Bundle(for: MigrationManagerTests.self).resourceURL else {
fatalError() fatalError()
} }
return LegacyV2( return ProfileV2MigrationStrategy(
coreDataLogger: nil, coreDataLogger: nil,
profilesContainerName: "Profiles", profilesContainerName: "Profiles",
baseURL: baseURL, baseURL: baseURL,
cloudKitIdentifier: nil cloudKitIdentifier: nil
) )
} }
func newManager() -> MigrationManager {
let strategy = newStrategy()
return MigrationManager(profileStrategy: strategy)
}
} }