Add initial migration UI (#866)

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

Different styles:

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

View File

@ -10,7 +10,7 @@
0E3E22962CE53510005135DF /* AppUIMain in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, macos, ); productRef = 0E3E22952CE53510005135DF /* AppUIMain */; };
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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,99 @@
//
// MigrateView+Section.swift
// Passepartout
//
// Created by Davide De Rosa on 11/13/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import SwiftUI
extension MigrateView {
struct SectionView: View {
let profiles: [MigratableProfile]
@Binding
var excluded: Set<UUID>
let statuses: [UUID: MigrationStatus]
var body: some View {
Section {
ForEach(profiles, id: \.id) {
if let status = statuses[$0.id] {
row(forProfile: $0, status: status)
} else {
button(forProfile: $0)
}
}
}
}
func button(forProfile profile: MigratableProfile) -> some View {
Button {
if excluded.contains(profile.id) {
excluded.remove(profile.id)
} else {
excluded.insert(profile.id)
}
} label: {
row(forProfile: profile, status: nil)
}
}
func row(forProfile profile: MigratableProfile, status: MigrationStatus?) -> some View {
HStack {
VStack(alignment: .leading) {
Text(profile.name)
.font(.headline)
profile.lastUpdate.map {
Text($0.localizedDescription(style: .timestamp))
.font(.subheadline)
}
}
Spacer()
if let status {
icon(forStatus: status)
} else if !excluded.contains(profile.id) {
ThemeImage(.marked)
}
}
}
@ViewBuilder
func icon(forStatus status: MigrationStatus) -> some View {
switch status {
case .excluded:
EmptyView()
case .pending:
ProgressView()
case .success:
ThemeImage(.marked)
case .failure:
ThemeImage(.failure)
}
}
}
}

View File

@ -0,0 +1,90 @@
//
// MigrateView+Table.swift
// Passepartout
//
// Created by Davide De Rosa on 11/13/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import SwiftUI
extension MigrateView {
struct TableView: View {
let profiles: [MigratableProfile]
@Binding
var excluded: Set<UUID>
let statuses: [UUID: MigrationStatus]
var body: some View {
Table(profiles) {
TableColumn(Strings.Global.name, value: \.name)
TableColumn(Strings.Global.lastUpdate, value: \.timestamp)
TableColumn("") { profile in
if let status = statuses[profile.id] {
imageName(forStatus: status)
.map {
ThemeImage($0)
}
} else {
Toggle("", isOn: isOnBinding(for: profile.id))
.labelsHidden()
}
}
}
}
func isOnBinding(for profileId: UUID) -> Binding<Bool> {
Binding {
!excluded.contains(profileId)
} set: {
if $0 {
excluded.remove(profileId)
} else {
excluded.insert(profileId)
}
}
}
func imageName(forStatus status: MigrationStatus) -> Theme.ImageName? {
switch status {
case .excluded:
return nil
case .pending:
return .progress
case .success:
return .marked
case .failure:
return .failure
}
}
}
}
private extension MigratableProfile {
var timestamp: String {
lastUpdate?.localizedDescription(style: .timestamp) ?? ""
}
}

View File

@ -0,0 +1,230 @@
//
// MigrateView.swift
// Passepartout
//
// Created by Davide De Rosa on 11/13/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import CommonUtils
import PassepartoutKit
import SwiftUI
// FIXME: ###, migrations UI
struct MigrateView: View {
enum Style {
case section
case table
}
@EnvironmentObject
private var migrationManager: MigrationManager
let style: Style
@State
private var isFetching = true
@State
private var isMigrating = false
@State
private var profiles: [MigratableProfile] = []
@State
private var excluded: Set<UUID> = []
@State
private var statuses: [UUID: MigrationStatus] = [:]
@StateObject
private var errorHandler: ErrorHandler = .default()
var body: some View {
Form {
Subview(
style: style,
profiles: profiles,
excluded: $excluded,
statuses: statuses
)
.disabled(isMigrating)
}
.themeForm()
.themeProgress(if: isFetching)
.themeEmptyContent(if: !isFetching && profiles.isEmpty, message: "Nothing to migrate")
.navigationTitle(title)
.toolbar(content: toolbarContent)
.task {
await fetch()
}
.withErrorHandler(errorHandler)
}
}
private extension MigrateView {
var title: String {
Strings.Views.Migrate.title
}
func toolbarContent() -> some ToolbarContent {
ToolbarItem(placement: .confirmationAction) {
Button("Proceed") {
Task {
await migrate()
}
}
}
}
}
private extension MigrateView {
func fetch() async {
do {
isFetching = true
profiles = try await migrationManager.fetchMigratableProfiles()
isFetching = false
} catch {
pp_log(.App.migration, .error, "Unable to fetch migratable profiles: \(error)")
errorHandler.handle(error, title: title)
isFetching = false
}
}
func migrate() async {
do {
isMigrating = true
let selection = Set(profiles.map(\.id)).symmetricDifference(excluded)
let migrated = try await migrationManager.migrateProfiles(profiles, selection: selection) {
statuses[$0] = $1
}
print(">>> Migrated: \(migrated.count)")
_ = migrated
// FIXME: ###, import migrated
} catch {
pp_log(.App.migration, .error, "Unable to migrate profiles: \(error)")
errorHandler.handle(error, title: title)
}
}
}
// MARK: -
private extension MigrateView {
struct Subview: View {
let style: Style
let profiles: [MigratableProfile]
@Binding
var excluded: Set<UUID>
let statuses: [UUID: MigrationStatus]
var body: some View {
switch style {
case .section:
MigrateView.SectionView(
profiles: sortedProfiles,
excluded: $excluded,
statuses: statuses
)
case .table:
MigrateView.TableView(
profiles: sortedProfiles,
excluded: $excluded,
statuses: statuses
)
}
}
var sortedProfiles: [MigratableProfile] {
profiles.sorted {
$0.name.lowercased() < $1.name.lowercased()
}
}
}
}
// MARK: - Previews
#Preview("Before") {
PrivatePreviews.MigratePreview(
profiles: PrivatePreviews.profiles,
statuses: [:]
)
.withMockEnvironment()
}
#Preview("After") {
PrivatePreviews.MigratePreview(
profiles: PrivatePreviews.profiles,
statuses: [
PrivatePreviews.profiles[0].id: .excluded,
PrivatePreviews.profiles[1].id: .pending,
PrivatePreviews.profiles[2].id: .failure,
PrivatePreviews.profiles[3].id: .success
]
)
.withMockEnvironment()
}
private struct PrivatePreviews {
static let oneDay: TimeInterval = 24 * 60 * 60
static let profiles: [MigratableProfile] = [
.init(id: UUID(), name: "1One", lastUpdate: Date().addingTimeInterval(-oneDay)),
.init(id: UUID(), name: "2Two", lastUpdate: Date().addingTimeInterval(-3 * oneDay)),
.init(id: UUID(), name: "3Three", lastUpdate: Date().addingTimeInterval(-90 * oneDay)),
.init(id: UUID(), name: "4Four", lastUpdate: Date().addingTimeInterval(-180 * oneDay))
]
struct MigratePreview: View {
let profiles: [MigratableProfile]
let statuses: [UUID: MigrationStatus]
@State
private var excluded: Set<UUID> = []
#if os(iOS)
private let style: MigrateView.Style = .section
#else
private let style: MigrateView.Style = .table
#endif
var body: some View {
Form {
MigrateView.Subview(
style: style,
profiles: profiles,
excluded: $excluded,
statuses: statuses
)
}
.navigationTitle("Migrate")
.themeNavigationStack()
}
}
}

View File

@ -0,0 +1,122 @@
//
// MigrationManager.swift
// Passepartout
//
// Created by Davide De Rosa on 11/13/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import Foundation
import PassepartoutKit
@MainActor
public final class MigrationManager: ObservableObject {
public struct Simulation {
public let maxMigrationTime: Double?
public let randomFailures: Bool
public init(maxMigrationTime: Double?, randomFailures: Bool) {
self.maxMigrationTime = maxMigrationTime
self.randomFailures = randomFailures
}
}
private let profileStrategy: ProfileMigrationStrategy
private nonisolated let simulation: Simulation?
public convenience init(profileStrategy: ProfileMigrationStrategy? = nil) {
self.init(profileStrategy: profileStrategy, simulation: nil)
}
public init(profileStrategy: ProfileMigrationStrategy? = nil, simulation: Simulation?) {
self.profileStrategy = profileStrategy ?? DummyProfileStrategy()
self.simulation = simulation
}
}
extension MigrationManager {
public func fetchMigratableProfiles() async throws -> [MigratableProfile] {
try await profileStrategy.fetchMigratableProfiles()
}
public func migrateProfile(withId profileId: UUID) async throws -> Profile? {
try await profileStrategy.fetchProfile(withId: profileId)
}
public func migrateProfiles(
_ profiles: [MigratableProfile],
selection: Set<UUID>,
onUpdate: @escaping @MainActor (UUID, MigrationStatus) -> Void
) async throws -> [Profile] {
profiles.forEach {
onUpdate($0.id, selection.contains($0.id) ? .pending : .excluded)
}
return try await withThrowingTaskGroup(of: Profile?.self, returning: [Profile].self) { group in
selection.forEach { profileId in
group.addTask {
do {
if let simulation = self.simulation {
if let maxMigrationTime = simulation.maxMigrationTime {
try await Task.sleep(for: .seconds(.random(in: 1.0..<maxMigrationTime)))
}
if simulation.randomFailures, Bool.random() {
throw PassepartoutError(.unhandled)
}
}
guard let profile = try await self.profileStrategy.fetchProfile(withId: profileId) else {
await onUpdate(profileId, .failure)
return nil
}
await onUpdate(profileId, .success)
return profile
} catch {
await onUpdate(profileId, .failure)
return nil
}
}
}
var profiles: [Profile] = []
for try await profile in group {
guard let profile else {
continue
}
profiles.append(profile)
}
return profiles
}
}
}
// MARK: - Dummy
private final class DummyProfileStrategy: ProfileMigrationStrategy {
public init() {
}
public func fetchMigratableProfiles() async throws -> [MigratableProfile] {
[]
}
func fetchProfile(withId profileId: UUID) async throws -> Profile? {
nil
}
}

View File

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

View File

@ -0,0 +1,36 @@
//
// MigrationStatus.swift
// Passepartout
//
// Created by Davide De Rosa on 11/13/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import Foundation
public enum MigrationStatus {
case excluded
case pending
case success
case failure
}

View File

@ -0,0 +1,33 @@
//
// ProfileMigrationStrategy.swift
// Passepartout
//
// Created by Davide De Rosa on 11/13/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import Foundation
import PassepartoutKit
public protocol ProfileMigrationStrategy {
func fetchMigratableProfiles() async throws -> [MigratableProfile]
func fetchProfile(withId profileId: UUID) async throws -> Profile?
}

View File

@ -24,11 +24,11 @@
//
import CommonLibrary
import 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]

View File

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

View File

@ -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 = []
}
}

View File

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

View File

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

View File

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

View File

@ -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.";

View File

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

View File

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

View File

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