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 */; };
|
||||
0E3E22982CE53510005135DF /* AppUITV in Frameworks */ = {isa = PBXBuildFile; platformFilters = (tvos, ); productRef = 0E3E22972CE53510005135DF /* AppUITV */; };
|
||||
0E3FF4BA2CE3AFBC00BFF640 /* Profiles.sqlite in Resources */ = {isa = PBXBuildFile; fileRef = 0E3FF4B72CE3AFBC00BFF640 /* Profiles.sqlite */; };
|
||||
0E3FF4BB2CE3AFBC00BFF640 /* LegacyV2CoreDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3FF4B92CE3AFBC00BFF640 /* LegacyV2CoreDataTests.swift */; };
|
||||
0E3FF4BB2CE3AFBC00BFF640 /* MigrationManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3FF4B92CE3AFBC00BFF640 /* MigrationManagerTests.swift */; };
|
||||
0E60512C2CE5393C00F763D4 /* PassepartoutImplementations in Frameworks */ = {isa = PBXBuildFile; productRef = 0E60512B2CE5393C00F763D4 /* PassepartoutImplementations */; };
|
||||
0E757F132CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E757F122CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift */; };
|
||||
0E757F202CD0D22B006E13E1 /* PassepartoutLoginItem.app in Embed Login Item */ = {isa = PBXBuildFile; fileRef = 0E757F102CD0CFFC006E13E1 /* PassepartoutLoginItem.app */; platformFilters = (macos, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
|
@ -108,7 +108,7 @@
|
|||
0E06D18F2B87629100176E1D /* Passepartout.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Passepartout.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
0E3FF4AE2CE3AF6F00BFF640 /* PassepartoutTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PassepartoutTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
0E3FF4B72CE3AFBC00BFF640 /* Profiles.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = Profiles.sqlite; sourceTree = "<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>"; };
|
||||
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>"; };
|
||||
|
@ -219,7 +219,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
0E3FF4B82CE3AFBC00BFF640 /* Resources */,
|
||||
0E3FF4B92CE3AFBC00BFF640 /* LegacyV2CoreDataTests.swift */,
|
||||
0E3FF4B92CE3AFBC00BFF640 /* MigrationManagerTests.swift */,
|
||||
);
|
||||
path = Tests;
|
||||
sourceTree = "<group>";
|
||||
|
@ -554,7 +554,7 @@
|
|||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
0E3FF4BB2CE3AFBC00BFF640 /* LegacyV2CoreDataTests.swift in Sources */,
|
||||
0E3FF4BB2CE3AFBC00BFF640 /* MigrationManagerTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -876,6 +876,7 @@
|
|||
0E3FF4B52CE3AF6F00BFF640 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES;
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.algoritmico.ios.PassepartoutTests;
|
||||
|
@ -888,6 +889,7 @@
|
|||
0E3FF4B62CE3AF6F00BFF640 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES;
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.algoritmico.ios.PassepartoutTests;
|
||||
|
|
|
@ -45,24 +45,28 @@ struct AboutRouterView: View {
|
|||
|
||||
extension AboutRouterView {
|
||||
enum NavigationRoute: Hashable {
|
||||
case donate
|
||||
case appDebugLog(title: String)
|
||||
|
||||
case credits
|
||||
|
||||
case diagnostics
|
||||
|
||||
case appDebugLog(title: String)
|
||||
|
||||
case tunnelDebugLog(title: String, url: URL?)
|
||||
case donate
|
||||
|
||||
case links
|
||||
|
||||
case credits
|
||||
case tunnelDebugLog(title: String, url: URL?)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func pushDestination(for item: NavigationRoute?) -> some View {
|
||||
switch item {
|
||||
case .donate:
|
||||
DonateView()
|
||||
case .appDebugLog(let title):
|
||||
DebugLogView.withApp(parameters: Constants.shared.log)
|
||||
.navigationTitle(title)
|
||||
|
||||
case .credits:
|
||||
CreditsView()
|
||||
|
||||
case .diagnostics:
|
||||
DiagnosticsView(
|
||||
|
@ -70,9 +74,11 @@ extension AboutRouterView {
|
|||
tunnel: tunnel
|
||||
)
|
||||
|
||||
case .appDebugLog(let title):
|
||||
DebugLogView.withApp(parameters: Constants.shared.log)
|
||||
.navigationTitle(title)
|
||||
case .donate:
|
||||
DonateView()
|
||||
|
||||
case .links:
|
||||
LinksView()
|
||||
|
||||
case .tunnelDebugLog(let title, let url):
|
||||
if let url {
|
||||
|
@ -83,12 +89,6 @@ extension AboutRouterView {
|
|||
.navigationTitle(title)
|
||||
}
|
||||
|
||||
case .links:
|
||||
LinksView()
|
||||
|
||||
case .credits:
|
||||
CreditsView()
|
||||
|
||||
default:
|
||||
Text(Strings.Global.noSelection)
|
||||
.themeEmptyMessage()
|
||||
|
|
|
@ -44,20 +44,20 @@ struct AboutView: View {
|
|||
}
|
||||
|
||||
extension AboutView {
|
||||
var donateLink: some View {
|
||||
navLink(Strings.Views.Donate.title, to: .donate)
|
||||
var creditsLink: some View {
|
||||
navLink(Strings.Views.About.Credits.title, to: .credits)
|
||||
}
|
||||
|
||||
var diagnosticsLink: some View {
|
||||
navLink(Strings.Views.Diagnostics.title, to: .diagnostics)
|
||||
}
|
||||
|
||||
var linksLink: some View {
|
||||
navLink(Strings.Views.About.Links.title, to: .links)
|
||||
var donateLink: some View {
|
||||
navLink(Strings.Views.Donate.title, to: .donate)
|
||||
}
|
||||
|
||||
var creditsLink: some View {
|
||||
navLink(Strings.Views.About.Credits.title, to: .credits)
|
||||
var linksLink: some View {
|
||||
navLink(Strings.Views.About.Links.title, to: .links)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -33,23 +33,42 @@ struct AddProfileMenu: View {
|
|||
@Binding
|
||||
var isImporting: Bool
|
||||
|
||||
let onMigrateProfiles: () -> Void
|
||||
|
||||
let onNewProfile: (Profile) -> Void
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
newProfileButton
|
||||
importProfileButton
|
||||
migrateProfilesButton
|
||||
} label: {
|
||||
ThemeImage(.add)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension AddProfileMenu {
|
||||
var newProfileButton: some View {
|
||||
Button {
|
||||
let profile = profileManager.new(withName: Strings.Entities.Profile.Name.new)
|
||||
onNewProfile(profile)
|
||||
} label: {
|
||||
ThemeImageLabel(Strings.Views.Profiles.Toolbar.newProfile, .profileEdit)
|
||||
}
|
||||
}
|
||||
|
||||
var importProfileButton: some View {
|
||||
Button {
|
||||
isImporting = true
|
||||
} label: {
|
||||
ThemeImageLabel(Strings.Views.Profiles.Toolbar.importProfile, .profileImport)
|
||||
}
|
||||
} 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
|
||||
private var profilePath = NavigationPath()
|
||||
|
||||
@State
|
||||
private var migrationPath = NavigationPath()
|
||||
|
||||
@StateObject
|
||||
private var errorHandler: ErrorHandler = .default()
|
||||
|
||||
|
@ -86,6 +89,8 @@ extension AppCoordinator {
|
|||
|
||||
case editProviderEntity(Profile, Module, ModuleMetadata.Provider)
|
||||
|
||||
case migrateProfiles
|
||||
|
||||
case settings
|
||||
|
||||
case about
|
||||
|
@ -94,8 +99,9 @@ extension AppCoordinator {
|
|||
switch self {
|
||||
case .editProfile: return 1
|
||||
case .editProviderEntity: return 2
|
||||
case .settings: return 3
|
||||
case .about: return 4
|
||||
case .migrateProfiles: return 3
|
||||
case .settings: return 4
|
||||
case .about: return 5
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -146,6 +152,9 @@ extension AppCoordinator {
|
|||
onAbout: {
|
||||
present(.about)
|
||||
},
|
||||
onMigrateProfiles: {
|
||||
present(.migrateProfiles)
|
||||
},
|
||||
onNewProfile: enterDetail
|
||||
)
|
||||
}
|
||||
|
@ -176,6 +185,10 @@ extension AppCoordinator {
|
|||
errorHandler: errorHandler
|
||||
)
|
||||
|
||||
case .migrateProfiles:
|
||||
MigrateView(style: migrateViewStyle)
|
||||
.themeNavigationStack(closable: true, path: $migrationPath)
|
||||
|
||||
case .settings:
|
||||
SettingsView(profileManager: profileManager)
|
||||
|
||||
|
@ -190,6 +203,14 @@ extension AppCoordinator {
|
|||
}
|
||||
}
|
||||
|
||||
var migrateViewStyle: MigrateView.Style {
|
||||
#if os(iOS)
|
||||
.section
|
||||
#else
|
||||
.table
|
||||
#endif
|
||||
}
|
||||
|
||||
func enterDetail(of profile: Profile) {
|
||||
profilePath = NavigationPath()
|
||||
let isShared = profileManager.isRemotelyShared(profileWithId: profile.id)
|
||||
|
|
|
@ -48,6 +48,8 @@ struct AppToolbar: ToolbarContent, SizeClassProviding {
|
|||
|
||||
let onAbout: () -> Void
|
||||
|
||||
let onMigrateProfiles: () -> Void
|
||||
|
||||
let onNewProfile: (Profile) -> Void
|
||||
|
||||
var body: some ToolbarContent {
|
||||
|
@ -74,6 +76,7 @@ private extension AppToolbar {
|
|||
AddProfileMenu(
|
||||
profileManager: profileManager,
|
||||
isImporting: $isImporting,
|
||||
onMigrateProfiles: onMigrateProfiles,
|
||||
onNewProfile: onNewProfile
|
||||
)
|
||||
}
|
||||
|
@ -109,6 +112,7 @@ private extension AppToolbar {
|
|||
isImporting: .constant(false),
|
||||
onSettings: {},
|
||||
onAbout: {},
|
||||
onMigrateProfiles: {},
|
||||
onNewProfile: { _ in }
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
public struct MigratableProfile: Sendable {
|
||||
public struct MigratableProfile: Identifiable, Sendable {
|
||||
public let id: UUID
|
||||
|
||||
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 CoreData
|
||||
@preconcurrency import CoreData
|
||||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
||||
final class CDProfileRepositoryV2 {
|
||||
final class CDProfileRepositoryV2: Sendable {
|
||||
static var model: NSManagedObjectModel {
|
||||
guard let model: NSManagedObjectModel = .mergedModel(from: [.module]) else {
|
||||
fatalError("Unable to build Core Data model (Profiles v2)")
|
||||
|
@ -63,9 +63,18 @@ final class CDProfileRepositoryV2 {
|
|||
)
|
||||
}
|
||||
|
||||
func profiles() async throws -> [ProfileV2] {
|
||||
func profile(withId profileId: UUID) async throws -> ProfileV2? {
|
||||
try await profiles(withIds: [profileId]).first
|
||||
}
|
||||
|
||||
func profiles(withIds profileIds: Set<UUID>?) async throws -> [ProfileV2] {
|
||||
let decoder = JSONDecoder()
|
||||
return try await fetchProfiles(
|
||||
let profiles: [ProfileV2] = try await fetchProfiles(
|
||||
prefetch: {
|
||||
if let profileIds {
|
||||
$0.predicate = NSPredicate(format: "any uuid in %@", profileIds)
|
||||
}
|
||||
},
|
||||
map: {
|
||||
$0.compactMap {
|
||||
guard let json = $0.value.encryptedJSON ?? $0.value.json else {
|
||||
|
@ -81,10 +90,11 @@ final class CDProfileRepositoryV2 {
|
|||
}
|
||||
}
|
||||
)
|
||||
return profiles
|
||||
}
|
||||
}
|
||||
|
||||
private extension CDProfileRepositoryV2 {
|
||||
extension CDProfileRepositoryV2 {
|
||||
func fetchProfiles<T>(
|
||||
prefetch: ((NSFetchRequest<CDProfile>) -> Void)? = nil,
|
||||
map: @escaping ([UUID: CDProfile]) -> [T]
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// LegacyV2.swift
|
||||
// ProfileV2MigrationStrategy.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/1/24.
|
||||
|
@ -28,7 +28,7 @@ import CommonUtils
|
|||
import Foundation
|
||||
import PassepartoutKit
|
||||
|
||||
public final class LegacyV2 {
|
||||
public final class ProfileV2MigrationStrategy: ProfileMigrationStrategy, Sendable {
|
||||
private let profilesRepository: CDProfileRepositoryV2
|
||||
|
||||
private let cloudKitIdentifier: String?
|
||||
|
@ -52,40 +52,31 @@ public final class LegacyV2 {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Mapping
|
||||
// MARK: - ProfileMigrationStrategy
|
||||
|
||||
extension LegacyV2 {
|
||||
extension ProfileV2MigrationStrategy {
|
||||
public func fetchMigratableProfiles() async throws -> [MigratableProfile] {
|
||||
try await profilesRepository.migratableProfiles()
|
||||
}
|
||||
|
||||
public func fetchProfiles(selection: Set<UUID>) async throws -> (migrated: [Profile], failed: Set<UUID>) {
|
||||
let profilesV2 = try await profilesRepository.profiles()
|
||||
|
||||
var migrated: [Profile] = []
|
||||
var failed: Set<UUID> = []
|
||||
public func fetchProfile(withId profileId: UUID) async throws -> Profile? {
|
||||
let mapper = MapperV2()
|
||||
|
||||
profilesV2.forEach {
|
||||
guard selection.contains($0.id) else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
let mapped = try mapper.toProfileV3($0)
|
||||
migrated.append(mapped)
|
||||
guard let profile = try await profilesRepository.profile(withId: profileId) else {
|
||||
return nil
|
||||
}
|
||||
return try mapper.toProfileV3(profile)
|
||||
} catch {
|
||||
pp_log(.App.migration, .error, "Unable to migrate profile \($0.id): \(error)")
|
||||
failed.insert($0.id)
|
||||
pp_log(.App.migration, .error, "Unable to migrate profile \(profileId): \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return (migrated, failed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Legacy profiles
|
||||
// MARK: - Internal
|
||||
|
||||
extension LegacyV2 {
|
||||
extension ProfileV2MigrationStrategy {
|
||||
func fetchProfilesV2() async throws -> [ProfileV2] {
|
||||
try await profilesRepository.profiles()
|
||||
try await profilesRepository.profiles(withIds: nil)
|
||||
}
|
||||
}
|
|
@ -33,14 +33,16 @@ import PassepartoutKit
|
|||
public final class AppContext: ObservableObject {
|
||||
public let iapManager: IAPManager
|
||||
|
||||
public let registry: Registry
|
||||
public let migrationManager: MigrationManager
|
||||
|
||||
public let profileManager: ProfileManager
|
||||
|
||||
public let tunnel: ExtendedTunnel
|
||||
|
||||
public let providerManager: ProviderManager
|
||||
|
||||
public let registry: Registry
|
||||
|
||||
public let tunnel: ExtendedTunnel
|
||||
|
||||
private var launchTask: Task<Void, Error>?
|
||||
|
||||
private var pendingTask: Task<Void, Never>?
|
||||
|
@ -49,16 +51,18 @@ public final class AppContext: ObservableObject {
|
|||
|
||||
public init(
|
||||
iapManager: IAPManager,
|
||||
registry: Registry,
|
||||
migrationManager: MigrationManager,
|
||||
profileManager: ProfileManager,
|
||||
tunnel: ExtendedTunnel,
|
||||
providerManager: ProviderManager
|
||||
providerManager: ProviderManager,
|
||||
registry: Registry,
|
||||
tunnel: ExtendedTunnel
|
||||
) {
|
||||
self.iapManager = iapManager
|
||||
self.registry = registry
|
||||
self.migrationManager = migrationManager
|
||||
self.profileManager = profileManager
|
||||
self.tunnel = tunnel
|
||||
self.providerManager = providerManager
|
||||
self.registry = registry
|
||||
self.tunnel = tunnel
|
||||
subscriptions = []
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,6 +30,7 @@ extension View {
|
|||
public func withEnvironment(from context: AppContext, theme: Theme) -> some View {
|
||||
environmentObject(theme)
|
||||
.environmentObject(context.iapManager)
|
||||
.environmentObject(context.migrationManager)
|
||||
.environmentObject(context.providerManager)
|
||||
}
|
||||
|
||||
|
|
|
@ -275,6 +275,8 @@ public enum Strings {
|
|||
public static let keepAlive = Strings.tr("Localizable", "global.keep_alive", fallback: "Keep-alive")
|
||||
/// Key
|
||||
public static let key = Strings.tr("Localizable", "global.key", fallback: "Key")
|
||||
/// Last update
|
||||
public static let lastUpdate = Strings.tr("Localizable", "global.last_update", fallback: "Last update")
|
||||
/// Loading
|
||||
public static let loading = Strings.tr("Localizable", "global.loading", fallback: "Loading")
|
||||
/// Method
|
||||
|
@ -717,6 +719,10 @@ public enum Strings {
|
|||
}
|
||||
}
|
||||
}
|
||||
public enum Migrate {
|
||||
/// Migrate
|
||||
public static let title = Strings.tr("Localizable", "views.migrate.title", fallback: "Migrate")
|
||||
}
|
||||
public enum Profile {
|
||||
public enum ModuleList {
|
||||
public enum Section {
|
||||
|
@ -761,6 +767,8 @@ public enum Strings {
|
|||
public enum Toolbar {
|
||||
/// Import profile
|
||||
public static let importProfile = Strings.tr("Localizable", "views.profiles.toolbar.import_profile", fallback: "Import profile")
|
||||
/// Migrate profiles
|
||||
public static let migrateProfiles = Strings.tr("Localizable", "views.profiles.toolbar.migrate_profiles", fallback: "Migrate profiles")
|
||||
/// New profile
|
||||
public static let newProfile = Strings.tr("Localizable", "views.profiles.toolbar.new_profile", fallback: "New profile")
|
||||
}
|
||||
|
|
|
@ -76,12 +76,14 @@ extension AppContext {
|
|||
let providerManager = ProviderManager(
|
||||
repository: InMemoryProviderRepository()
|
||||
)
|
||||
let migrationManager = MigrationManager()
|
||||
return AppContext(
|
||||
iapManager: iapManager,
|
||||
registry: registry,
|
||||
migrationManager: migrationManager,
|
||||
profileManager: profileManager,
|
||||
tunnel: tunnel,
|
||||
providerManager: providerManager
|
||||
providerManager: providerManager,
|
||||
registry: registry,
|
||||
tunnel: tunnel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"global.interface" = "Interface";
|
||||
"global.keep_alive" = "Keep-alive";
|
||||
"global.key" = "Key";
|
||||
"global.last_update" = "Last update";
|
||||
"global.loading" = "Loading";
|
||||
"global.method" = "Method";
|
||||
"global.modules" = "Modules";
|
||||
|
@ -122,6 +123,7 @@
|
|||
"views.profiles.folders.no_profiles" = "No profiles";
|
||||
"views.profiles.toolbar.new_profile" = "New profile";
|
||||
"views.profiles.toolbar.import_profile" = "Import profile";
|
||||
"views.profiles.toolbar.migrate_profiles" = "Migrate profiles";
|
||||
"views.profiles.errors.tunnel" = "Unable to execute tunnel operation.";
|
||||
"views.profiles.errors.duplicate" = "Unable to duplicate profile '%@'.";
|
||||
"views.profiles.errors.import" = "Unable to import profiles.";
|
||||
|
@ -155,6 +157,8 @@
|
|||
"views.about.credits.notices" = "Notices";
|
||||
"views.about.credits.translations" = "Translations";
|
||||
|
||||
"views.migrate.title" = "Migrate";
|
||||
|
||||
"views.donate.title" = "Make a donation";
|
||||
"views.donate.sections.main.footer" = "If you want to display gratitude for my work, here are a couple amounts you can donate instantly.\n\nYou will only be charged once per donation, and you can donate multiple times.";
|
||||
"views.donate.alerts.thank_you.message" = "This means a lot to me and I really hope you keep using and promoting this app.";
|
||||
|
|
|
@ -37,6 +37,7 @@ extension Theme {
|
|||
case disclose
|
||||
case editableSectionEdit
|
||||
case editableSectionRemove
|
||||
case failure
|
||||
case favoriteOff
|
||||
case favoriteOn
|
||||
case filters
|
||||
|
@ -48,8 +49,10 @@ extension Theme {
|
|||
case pending
|
||||
case profileEdit
|
||||
case profileImport
|
||||
case profileMigrate
|
||||
case profilesGrid
|
||||
case profilesList
|
||||
case progress
|
||||
case remove
|
||||
case search
|
||||
case settings
|
||||
|
@ -81,6 +84,7 @@ extension Theme.ImageName {
|
|||
case .disclose: return "chevron.down"
|
||||
case .editableSectionEdit: return "arrow.up.arrow.down"
|
||||
case .editableSectionRemove: return "trash"
|
||||
case .failure: return "exclamationmark.triangle"
|
||||
case .favoriteOff: return "star"
|
||||
case .favoriteOn: return "star.fill"
|
||||
case .filters: return "line.3.horizontal.decrease"
|
||||
|
@ -92,8 +96,10 @@ extension Theme.ImageName {
|
|||
case .pending: return "clock"
|
||||
case .profileEdit: return "square.and.pencil"
|
||||
case .profileImport: return "square.and.arrow.down"
|
||||
case .profileMigrate: return "arrow.up.square"
|
||||
case .profilesGrid: return "square.grid.2x2"
|
||||
case .profilesList: return "rectangle.grid.1x2"
|
||||
case .progress: return "clock"
|
||||
case .remove: return "minus"
|
||||
case .search: return "magnifyingglass"
|
||||
case .settings: return "gearshape"
|
||||
|
|
|
@ -29,6 +29,7 @@ import AppDataProviders
|
|||
import CommonLibrary
|
||||
import CommonUtils
|
||||
import Foundation
|
||||
import LegacyV2
|
||||
import PassepartoutKit
|
||||
import UILibrary
|
||||
|
||||
|
@ -93,12 +94,29 @@ extension AppContext {
|
|||
return ProviderManager(repository: repository)
|
||||
}()
|
||||
|
||||
// MARK: MigrationManager
|
||||
|
||||
let profileStrategy = ProfileV2MigrationStrategy(
|
||||
coreDataLogger: .default,
|
||||
profilesContainerName: Constants.shared.containers.legacyV2,
|
||||
cloudKitIdentifier: BundleConfiguration.mainString(for: .legacyV2CloudKitId)
|
||||
)
|
||||
#if DEBUG
|
||||
let migrationManager = MigrationManager(profileStrategy: profileStrategy, simulation: .init(
|
||||
maxMigrationTime: 3.0,
|
||||
randomFailures: true
|
||||
))
|
||||
#else
|
||||
let migrationManager = MigrationManager(profileStrategy: profileStrategy)
|
||||
#endif
|
||||
|
||||
return AppContext(
|
||||
iapManager: .shared,
|
||||
registry: .shared,
|
||||
migrationManager: migrationManager,
|
||||
profileManager: profileManager,
|
||||
tunnel: tunnel,
|
||||
providerManager: providerManager
|
||||
providerManager: providerManager,
|
||||
registry: .shared,
|
||||
tunnel: tunnel
|
||||
)
|
||||
}()
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// LegacyV2CoreDataTests.swift
|
||||
// MigrationManagerTests.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 11/12/24.
|
||||
|
@ -23,15 +23,19 @@
|
|||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import CommonUtils
|
||||
import CommonLibrary
|
||||
import Foundation
|
||||
@testable import LegacyV2
|
||||
import PassepartoutKit
|
||||
import XCTest
|
||||
|
||||
final class LegacyV2CoreDataTests: XCTestCase {
|
||||
func test_givenStore_whenFetchV2_thenReturnsProfilesV2() async throws {
|
||||
let sut = newStore()
|
||||
@MainActor
|
||||
final class MigrationManagerTests: XCTestCase {
|
||||
}
|
||||
|
||||
extension MigrationManagerTests {
|
||||
func test_givenStrategy_whenFetchV2_thenReturnsProfilesV2() async throws {
|
||||
let sut = newStrategy()
|
||||
|
||||
let profilesV2 = try await sut.fetchProfilesV2()
|
||||
XCTAssertEqual(profilesV2.count, 6)
|
||||
|
@ -45,8 +49,8 @@ final class LegacyV2CoreDataTests: XCTestCase {
|
|||
])
|
||||
}
|
||||
|
||||
func test_givenStore_whenFetch_thenReturnsMigratableProfiles() async throws {
|
||||
let sut = newStore()
|
||||
func test_givenManager_whenFetch_thenReturnsMigratableProfiles() async throws {
|
||||
let sut = newManager()
|
||||
|
||||
let migratable = try await sut.fetchMigratableProfiles()
|
||||
let expectedIDs = [
|
||||
|
@ -71,16 +75,13 @@ final class LegacyV2CoreDataTests: XCTestCase {
|
|||
XCTAssertEqual(Set(migratable.map(\.name)), Set(expectedNames))
|
||||
}
|
||||
|
||||
func test_givenStore_whenMigrateHideMe_thenIsExpected() async throws {
|
||||
let sut = newStore()
|
||||
func test_givenManager_whenMigrateHideMe_thenIsExpected() async throws {
|
||||
let sut = newManager()
|
||||
|
||||
let id = try XCTUnwrap(UUID(uuidString: "8A568345-85C4-44C1-A9C4-612E8B07ADC5"))
|
||||
let result = try await sut.fetchProfiles(selection: [id])
|
||||
let migrated = result.migrated
|
||||
XCTAssertEqual(migrated.count, 1)
|
||||
XCTAssertTrue(result.failed.isEmpty)
|
||||
let migrated = try await sut.migrateProfile(withId: id)
|
||||
let profile = try XCTUnwrap(migrated)
|
||||
|
||||
let profile = try XCTUnwrap(migrated.first)
|
||||
XCTAssertEqual(profile.id, id)
|
||||
XCTAssertEqual(profile.name, "Hide.me")
|
||||
XCTAssertEqual(profile.attributes.lastUpdate, Date(timeIntervalSinceReferenceDate: 673117681.24825))
|
||||
|
@ -109,17 +110,13 @@ final class LegacyV2CoreDataTests: XCTestCase {
|
|||
])
|
||||
}
|
||||
|
||||
func test_givenStore_whenMigrateProtonVPN_thenIsExpected() async throws {
|
||||
let sut = newStore()
|
||||
func test_givenManager_whenMigrateProtonVPN_thenIsExpected() async throws {
|
||||
let sut = newManager()
|
||||
|
||||
let id = try XCTUnwrap(UUID(uuidString: "981E7CBD-7733-4CF3-9A51-2777614ED5D4"))
|
||||
let result = try await sut.fetchProfiles(selection: [id])
|
||||
let migrated = result.migrated
|
||||
XCTAssertEqual(migrated.count, 1)
|
||||
XCTAssertTrue(result.failed.isEmpty)
|
||||
let migrated = try await sut.migrateProfile(withId: id)
|
||||
let profile = try XCTUnwrap(migrated)
|
||||
|
||||
XCTAssertEqual(migrated.count, 1)
|
||||
let profile = try XCTUnwrap(migrated.first)
|
||||
XCTAssertEqual(profile.id, id)
|
||||
XCTAssertEqual(profile.name, "ProtonVPN")
|
||||
XCTAssertEqual(profile.attributes.lastUpdate, Date(timeIntervalSinceReferenceDate: 724509584.854822))
|
||||
|
@ -137,17 +134,13 @@ final class LegacyV2CoreDataTests: XCTestCase {
|
|||
XCTAssertEqual(openVPN.credentials?.password, "bar")
|
||||
}
|
||||
|
||||
func test_givenStore_whenMigrateVPSOpenVPN_thenIsExpected() async throws {
|
||||
let sut = newStore()
|
||||
func test_givenManager_whenMigrateVPSOpenVPN_thenIsExpected() async throws {
|
||||
let sut = newManager()
|
||||
|
||||
let id = try XCTUnwrap(UUID(uuidString: "239AD322-7440-4198-990A-D91379916FE2"))
|
||||
let result = try await sut.fetchProfiles(selection: [id])
|
||||
let migrated = result.migrated
|
||||
XCTAssertEqual(migrated.count, 1)
|
||||
XCTAssertTrue(result.failed.isEmpty)
|
||||
let migrated = try await sut.migrateProfile(withId: id)
|
||||
let profile = try XCTUnwrap(migrated)
|
||||
|
||||
XCTAssertEqual(migrated.count, 1)
|
||||
let profile = try XCTUnwrap(migrated.first)
|
||||
XCTAssertEqual(profile.id, id)
|
||||
XCTAssertEqual(profile.name, "vps-ta-cert-cbc256-lzo")
|
||||
XCTAssertEqual(profile.attributes.lastUpdate, Date(timeIntervalSinceReferenceDate: 726164772.28976))
|
||||
|
@ -174,17 +167,13 @@ final class LegacyV2CoreDataTests: XCTestCase {
|
|||
XCTAssertEqual(cfg.tlsWrap?.strategy, .auth)
|
||||
}
|
||||
|
||||
func test_givenStore_whenMigrateVPSWireGuard_thenIsExpected() async throws {
|
||||
let sut = newStore()
|
||||
func test_givenManager_whenMigrateVPSWireGuard_thenIsExpected() async throws {
|
||||
let sut = newManager()
|
||||
|
||||
let id = try XCTUnwrap(UUID(uuidString: "069F76BD-1F6B-425C-AD83-62477A8B6558"))
|
||||
let result = try await sut.fetchProfiles(selection: [id])
|
||||
let migrated = result.migrated
|
||||
XCTAssertEqual(migrated.count, 1)
|
||||
XCTAssertTrue(result.failed.isEmpty)
|
||||
let migrated = try await sut.migrateProfile(withId: id)
|
||||
let profile = try XCTUnwrap(migrated)
|
||||
|
||||
XCTAssertEqual(migrated.count, 1)
|
||||
let profile = try XCTUnwrap(migrated.first)
|
||||
XCTAssertEqual(profile.id, id)
|
||||
XCTAssertEqual(profile.name, "vps-wg")
|
||||
XCTAssertEqual(profile.attributes.lastUpdate, Date(timeIntervalSinceReferenceDate: 727398252.46203))
|
||||
|
@ -217,16 +206,21 @@ final class LegacyV2CoreDataTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
private extension LegacyV2CoreDataTests {
|
||||
func newStore() -> LegacyV2 {
|
||||
guard let baseURL = Bundle(for: LegacyV2CoreDataTests.self).resourceURL else {
|
||||
private extension MigrationManagerTests {
|
||||
func newStrategy() -> ProfileV2MigrationStrategy {
|
||||
guard let baseURL = Bundle(for: MigrationManagerTests.self).resourceURL else {
|
||||
fatalError()
|
||||
}
|
||||
return LegacyV2(
|
||||
return ProfileV2MigrationStrategy(
|
||||
coreDataLogger: nil,
|
||||
profilesContainerName: "Profiles",
|
||||
baseURL: baseURL,
|
||||
cloudKitIdentifier: nil
|
||||
)
|
||||
}
|
||||
|
||||
func newManager() -> MigrationManager {
|
||||
let strategy = newStrategy()
|
||||
return MigrationManager(profileStrategy: strategy)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue