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:
parent
bfe1373c4c
commit
114e1abe12
|
@ -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;
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) ?? ""
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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?
|
||||||
|
}
|
|
@ -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]
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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 = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.";
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
Loading…
Reference in New Issue