diff --git a/Passepartout/App/PassepartoutApp.swift b/Passepartout/App/PassepartoutApp.swift
index 156a89a3..6e0ca21c 100644
--- a/Passepartout/App/PassepartoutApp.swift
+++ b/Passepartout/App/PassepartoutApp.swift
@@ -55,8 +55,8 @@ struct PassepartoutApp: App {
.defaultSize(width: 600.0, height: 400.0)
Settings {
- SettingsView()
- .frame(minWidth: 300, minHeight: 100)
+ SettingsView(profileManager: context.profileManager)
+ .frame(minWidth: 300, minHeight: 200)
}
#endif
}
diff --git a/Passepartout/Library/Sources/AppLibrary/Business/ProfileManager.swift b/Passepartout/Library/Sources/AppLibrary/Business/ProfileManager.swift
index 81b4bd39..d59964cf 100644
--- a/Passepartout/Library/Sources/AppLibrary/Business/ProfileManager.swift
+++ b/Passepartout/Library/Sources/AppLibrary/Business/ProfileManager.swift
@@ -174,6 +174,10 @@ extension ProfileManager {
try await remoteRepository.removeEntities(withIds: [profileId])
}
}
+
+ public func eraseRemoteProfiles() async throws {
+ try await remoteRepository?.removeEntities(withIds: Array(allRemoteProfiles.keys))
+ }
}
// MARK: - Shortcuts
@@ -258,6 +262,7 @@ private extension ProfileManager {
allRemoteProfiles = result.entities.reduce(into: [:]) {
$0[$1.id] = $1
}
+ objectWillChange.send()
// pull remote updates into local profiles (best-effort)
let profilesToImport = allRemoteProfiles.values
diff --git a/Passepartout/Library/Sources/AppUI/L10n/Strings+Unlocalized.swift b/Passepartout/Library/Sources/AppUI/L10n/Strings+Unlocalized.swift
index fabdc876..47278919 100644
--- a/Passepartout/Library/Sources/AppUI/L10n/Strings+Unlocalized.swift
+++ b/Passepartout/Library/Sources/AppUI/L10n/Strings+Unlocalized.swift
@@ -108,6 +108,8 @@ extension Strings {
static let httpProxy = "HTTP Proxy"
+ static let iCloud = "iCloud"
+
static let ip = "IP"
static let ipv4 = "IPv4"
diff --git a/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift b/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift
index ea101e17..2f748a26 100644
--- a/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift
+++ b/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift
@@ -428,6 +428,12 @@ public enum Strings {
public static let name = Strings.tr("Localizable", "placeholders.profile.name", fallback: "My profile")
}
}
+ public enum Theme {
+ public enum Confirmation {
+ /// Are you sure?
+ public static let message = Strings.tr("Localizable", "theme.confirmation.message", fallback: "Are you sure?")
+ }
+ }
public enum Ui {
public enum ConnectionStatus {
/// (on-demand)
@@ -579,6 +585,8 @@ public enum Strings {
public enum Rows {
/// Confirm quit
public static let confirmQuit = Strings.tr("Localizable", "views.settings.rows.confirm_quit", fallback: "Confirm quit")
+ /// Erase iCloud store
+ public static let eraseIcloud = Strings.tr("Localizable", "views.settings.rows.erase_icloud", fallback: "Erase iCloud store")
/// Lock in background
public static let lockInBackground = Strings.tr("Localizable", "views.settings.rows.lock_in_background", fallback: "Lock in background")
public enum LockInBackground {
@@ -586,6 +594,12 @@ public enum Strings {
public static let message = Strings.tr("Localizable", "views.settings.rows.lock_in_background.message", fallback: "Passepartout is locked")
}
}
+ public enum Sections {
+ public enum Icloud {
+ /// To erase the iCloud store securely, do so on all your synced devices. This will not affect local profiles.
+ public static let footer = Strings.tr("Localizable", "views.settings.sections.icloud.footer", fallback: "To erase the iCloud store securely, do so on all your synced devices. This will not affect local profiles.")
+ }
+ }
}
}
}
diff --git a/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings b/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings
index 612b9a51..9b536673 100644
--- a/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings
+++ b/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings
@@ -102,6 +102,10 @@
"placeholders.username" = "username";
"placeholders.secret" = "secret";
+// MARK: - Theme
+
+"theme.confirmation.message" = "Are you sure?";
+
// MARK: - Views
"views.profiles.rows.not_installed" = "Select a profile";
@@ -121,9 +125,11 @@
"views.profile.rows.add_module" = "Add module";
"views.profile.module_list.section.footer" = "Drag modules to rearrange them, as their order determines priority.";
+"views.settings.sections.icloud.footer" = "To erase the iCloud store securely, do so on all your synced devices. This will not affect local profiles.";
"views.settings.rows.confirm_quit" = "Confirm quit";
"views.settings.rows.lock_in_background" = "Lock in background";
"views.settings.rows.lock_in_background.message" = "Passepartout is locked";
+"views.settings.rows.erase_icloud" = "Erase iCloud store";
"views.about.title" = "About";
"views.about.sections.resources" = "Resources";
diff --git a/Passepartout/Library/Sources/AppUI/Views/About/AboutRouterView.swift b/Passepartout/Library/Sources/AppUI/Views/About/AboutRouterView.swift
index 41129e06..87977387 100644
--- a/Passepartout/Library/Sources/AppUI/Views/About/AboutRouterView.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/About/AboutRouterView.swift
@@ -33,14 +33,12 @@ struct AboutRouterView: View {
@Environment(\.dismiss)
var dismiss
+ let profileManager: ProfileManager
+
let tunnel: Tunnel
@State
var navigationRoute: NavigationRoute?
-
- init(tunnel: Tunnel) {
- self.tunnel = tunnel
- }
}
extension AboutRouterView {
@@ -95,6 +93,7 @@ extension AboutRouterView {
#Preview {
AboutRouterView(
+ profileManager: .mock,
tunnel: .mock
)
.withMockEnvironment()
diff --git a/Passepartout/Library/Sources/AppUI/Views/About/AboutView.swift b/Passepartout/Library/Sources/AppUI/Views/About/AboutView.swift
index d53504bb..5232ca48 100644
--- a/Passepartout/Library/Sources/AppUI/Views/About/AboutView.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/About/AboutView.swift
@@ -23,12 +23,14 @@
// along with Passepartout. If not, see .
//
+import AppLibrary
import CommonLibrary
import PassepartoutKit
import SwiftUI
import UtilsLibrary
struct AboutView: View {
+ let profileManager: ProfileManager
@Binding
var navigationRoute: AboutRouterView.NavigationRoute?
@@ -64,6 +66,8 @@ private extension AboutView {
#Preview {
AboutView(
+ profileManager: .mock,
navigationRoute: .constant(nil)
)
+ .withMockEnvironment()
}
diff --git a/Passepartout/Library/Sources/AppUI/Views/About/iOS/AboutRouterView+iOS.swift b/Passepartout/Library/Sources/AppUI/Views/About/iOS/AboutRouterView+iOS.swift
index ed8368b0..b1d08f40 100644
--- a/Passepartout/Library/Sources/AppUI/Views/About/iOS/AboutRouterView+iOS.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/About/iOS/AboutRouterView+iOS.swift
@@ -32,6 +32,7 @@ extension AboutRouterView {
var body: some View {
NavigationStack {
AboutView(
+ profileManager: profileManager,
navigationRoute: $navigationRoute
)
.toolbar {
diff --git a/Passepartout/Library/Sources/AppUI/Views/About/iOS/AboutView+iOS.swift b/Passepartout/Library/Sources/AppUI/Views/About/iOS/AboutView+iOS.swift
index 0cfdc2cf..27c92f93 100644
--- a/Passepartout/Library/Sources/AppUI/Views/About/iOS/AboutView+iOS.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/About/iOS/AboutView+iOS.swift
@@ -31,7 +31,7 @@ import SwiftUI
extension AboutView {
var listView: some View {
List {
- SettingsSection()
+ SettingsSectionGroup(profileManager: profileManager)
Section {
// TODO: #585, donations
// donateLink
diff --git a/Passepartout/Library/Sources/AppUI/Views/About/macOS/AboutRouterView+macOS.swift b/Passepartout/Library/Sources/AppUI/Views/About/macOS/AboutRouterView+macOS.swift
index b40feb67..6f6d2901 100644
--- a/Passepartout/Library/Sources/AppUI/Views/About/macOS/AboutRouterView+macOS.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/About/macOS/AboutRouterView+macOS.swift
@@ -32,6 +32,7 @@ extension AboutRouterView {
var body: some View {
NavigationSplitView {
AboutView(
+ profileManager: profileManager,
navigationRoute: $navigationRoute
)
} detail: {
diff --git a/Passepartout/Library/Sources/AppUI/Views/App/AppInlineCoordinator.swift b/Passepartout/Library/Sources/AppUI/Views/App/AppInlineCoordinator.swift
index bcf6d2fc..7b4cb939 100644
--- a/Passepartout/Library/Sources/AppUI/Views/App/AppInlineCoordinator.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/App/AppInlineCoordinator.swift
@@ -129,10 +129,13 @@ private extension AppInlineCoordinator {
func modalDestination(for item: ModalRoute?) -> some View {
switch item {
case .settings:
- SettingsView()
+ SettingsView(profileManager: profileManager)
case .about:
- AboutRouterView(tunnel: tunnel)
+ AboutRouterView(
+ profileManager: profileManager,
+ tunnel: tunnel
+ )
default:
EmptyView()
diff --git a/Passepartout/Library/Sources/AppUI/Views/App/AppModalCoordinator.swift b/Passepartout/Library/Sources/AppUI/Views/App/AppModalCoordinator.swift
index e6a8e5b5..af45ff22 100644
--- a/Passepartout/Library/Sources/AppUI/Views/App/AppModalCoordinator.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/App/AppModalCoordinator.swift
@@ -120,10 +120,13 @@ extension AppModalCoordinator {
}
case .settings:
- SettingsView()
+ SettingsView(profileManager: profileManager)
case .about:
- AboutRouterView(tunnel: tunnel)
+ AboutRouterView(
+ profileManager: profileManager,
+ tunnel: tunnel
+ )
default:
EmptyView()
diff --git a/Passepartout/Library/Sources/AppUI/Views/Settings/SettingsSectionGroup.swift b/Passepartout/Library/Sources/AppUI/Views/Settings/SettingsSectionGroup.swift
new file mode 100644
index 00000000..71ba50f0
--- /dev/null
+++ b/Passepartout/Library/Sources/AppUI/Views/Settings/SettingsSectionGroup.swift
@@ -0,0 +1,103 @@
+//
+// SettingsSectionGroup.swift
+// Passepartout
+//
+// Created by Davide De Rosa on 10/3/24.
+// Copyright (c) 2024 Davide De Rosa. All rights reserved.
+//
+// https://github.com/passepartoutvpn
+//
+// This file is part of Passepartout.
+//
+// Passepartout is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Passepartout is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Passepartout. If not, see .
+//
+
+import AppLibrary
+import CommonLibrary
+import PassepartoutKit
+import SwiftUI
+import UtilsLibrary
+
+struct SettingsSectionGroup: View {
+
+ @AppStorage(AppPreference.confirmsQuit.key)
+ private var confirmsQuit = true
+
+ @AppStorage(AppPreference.locksInBackground.key)
+ private var locksInBackground = false
+
+ let profileManager: ProfileManager
+
+ @State
+ private var isConfirmingEraseiCloud = false
+
+ @State
+ private var isErasingiCloud = false
+
+ var body: some View {
+ Section {
+#if os(macOS)
+ confirmsQuitToggle
+#endif
+#if os(iOS)
+ lockInBackgroundToggle
+#endif
+ } header: {
+ Text(Strings.Global.general)
+ }
+ Group {
+ eraseCloudKitButton
+ }
+ .themeSection(
+ header: Strings.Unlocalized.iCloud,
+ footer: Strings.Views.Settings.Sections.Icloud.footer
+ )
+ }
+}
+
+private extension SettingsSectionGroup {
+ var confirmsQuitToggle: some View {
+ Toggle(Strings.Views.Settings.Rows.confirmQuit, isOn: $confirmsQuit)
+ }
+
+ var lockInBackgroundToggle: some View {
+ Toggle(Strings.Views.Settings.Rows.lockInBackground, isOn: $locksInBackground)
+ }
+
+ var eraseCloudKitButton: some View {
+ Button(Strings.Views.Settings.Rows.eraseIcloud, role: .destructive) {
+ isConfirmingEraseiCloud = true
+ }
+ .themeConfirmation(
+ isPresented: $isConfirmingEraseiCloud,
+ title: Strings.Views.Settings.Rows.eraseIcloud
+ ) {
+ isErasingiCloud = true
+ Task {
+ do {
+ pp_log(.app, .info, "Erase CloudKit profiles...")
+ try await profileManager.eraseRemoteProfiles()
+
+ let containerId = BundleConfiguration.mainString(for: .cloudKitId)
+ pp_log(.app, .info, "Erase CloudKit store with identifier \(containerId)...")
+ try await Utils.eraseCloudKitStore(fromContainerWithId: containerId)
+ } catch {
+ pp_log(.app, .error, "Unable to erase CloudKit store: \(error)")
+ }
+ isErasingiCloud = false
+ }
+ }
+ .disabled(isErasingiCloud)
+ }
+}
diff --git a/Passepartout/Library/Sources/AppUI/Views/Settings/SettingsView.swift b/Passepartout/Library/Sources/AppUI/Views/Settings/SettingsView.swift
index 57df4157..f1fed795 100644
--- a/Passepartout/Library/Sources/AppUI/Views/Settings/SettingsView.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/Settings/SettingsView.swift
@@ -23,19 +23,22 @@
// along with Passepartout. If not, see .
//
+import AppLibrary
import SwiftUI
public struct SettingsView: View {
+ let profileManager: ProfileManager
@State
private var path = NavigationPath()
- public init() {
+ public init(profileManager: ProfileManager) {
+ self.profileManager = profileManager
}
public var body: some View {
Form {
- SettingsSection()
+ SettingsSectionGroup(profileManager: profileManager)
}
.themeForm()
.navigationTitle(Strings.Global.settings)
diff --git a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+UI.swift b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+UI.swift
index 3151e5cd..d9d2fc18 100644
--- a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+UI.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+UI.swift
@@ -102,6 +102,26 @@ struct ThemeItemModalModifier: ViewModifier where Modal: View, T: Iden
}
}
+struct ThemeConfirmationModifier: ViewModifier {
+
+ @Binding
+ var isPresented: Bool
+
+ let title: String
+
+ let action: () -> Void
+
+ func body(content: Content) -> some View {
+ content
+ .confirmationDialog(title, isPresented: $isPresented) {
+ Button(Strings.Global.ok, action: action)
+ Text(Strings.Global.cancel)
+ } message: {
+ Text(Strings.Theme.Confirmation.message)
+ }
+ }
+}
+
struct ThemeNavigationStackModifier: ViewModifier {
@Environment(\.dismiss)
diff --git a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme.swift b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme.swift
index 4a8a3517..f0959e78 100644
--- a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme.swift
@@ -162,6 +162,10 @@ extension View {
))
}
+ public func themeConfirmation(isPresented: Binding, title: String, action: @escaping () -> Void) -> some View {
+ modifier(ThemeConfirmationModifier(isPresented: isPresented, title: title, action: action))
+ }
+
public func themeNavigationStack(if condition: Bool, closable: Bool = false, path: Binding) -> some View {
modifier(ThemeNavigationStackModifier(condition: condition, closable: closable, path: path))
}
diff --git a/Passepartout/Library/Sources/AppUI/Views/UI/ProfileCardView.swift b/Passepartout/Library/Sources/AppUI/Views/UI/ProfileCardView.swift
index dcdd3a25..c2ad081b 100644
--- a/Passepartout/Library/Sources/AppUI/Views/UI/ProfileCardView.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/UI/ProfileCardView.swift
@@ -23,6 +23,7 @@
// along with Passepartout. If not, see .
//
+import AppLibrary
import PassepartoutKit
import SwiftUI
@@ -37,7 +38,8 @@ struct ProfileCardView: View {
let header: ProfileHeader
- let isShared: Bool
+ @ObservedObject
+ var profileManager: ProfileManager
var body: some View {
switch style {
@@ -70,6 +72,12 @@ struct ProfileCardView: View {
}
}
+private extension ProfileCardView {
+ var isShared: Bool {
+ profileManager.isRemotelyShared(profileWithId: header.id)
+ }
+}
+
// MARK: - Previews
#Preview {
@@ -78,14 +86,14 @@ struct ProfileCardView: View {
ProfileCardView(
style: .compact,
header: Profile.mock.header(),
- isShared: true
+ profileManager: .mock
)
}
Section {
ProfileCardView(
style: .full,
header: Profile.mock.header(),
- isShared: true
+ profileManager: .mock
)
}
}
diff --git a/Passepartout/Library/Sources/AppUI/Views/UI/ProfileRowView.swift b/Passepartout/Library/Sources/AppUI/Views/UI/ProfileRowView.swift
index 6f29d24c..2ddefda8 100644
--- a/Passepartout/Library/Sources/AppUI/Views/UI/ProfileRowView.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/UI/ProfileRowView.swift
@@ -86,7 +86,7 @@ private extension ProfileRowView {
ProfileCardView(
style: style,
header: header,
- isShared: profileManager.isRemotelyShared(profileWithId: header.id)
+ profileManager: profileManager
)
.frame(maxWidth: .infinity)
.contentShape(.rect)
diff --git a/Passepartout/Library/Sources/AppUI/Views/Settings/SettingsSection.swift b/Passepartout/Library/Sources/UtilsLibrary/Extensions/Utils+CloudKit.swift
similarity index 52%
rename from Passepartout/Library/Sources/AppUI/Views/Settings/SettingsSection.swift
rename to Passepartout/Library/Sources/UtilsLibrary/Extensions/Utils+CloudKit.swift
index 0fbc0509..d1f3f44c 100644
--- a/Passepartout/Library/Sources/AppUI/Views/Settings/SettingsSection.swift
+++ b/Passepartout/Library/Sources/UtilsLibrary/Extensions/Utils+CloudKit.swift
@@ -1,5 +1,5 @@
//
-// SettingsSection.swift
+// Empty.swift
// Passepartout
//
// Created by Davide De Rosa on 10/3/24.
@@ -23,39 +23,15 @@
// along with Passepartout. If not, see .
//
-import CommonLibrary
-import SwiftUI
+import CloudKit
+import Foundation
-struct SettingsSection: View {
+extension Utils {
+ private static let cloudKitZone = CKRecordZone.ID(zoneName: "com.apple.coredata.cloudkit.zone")
- @AppStorage(AppPreference.confirmsQuit.key)
- private var confirmsQuit = true
-
- @AppStorage(AppPreference.locksInBackground.key)
- private var locksInBackground = false
-
- var header: String?
-
- var body: some View {
- Section {
-#if os(macOS)
- confirmsQuitToggle
-#endif
-#if os(iOS)
- lockInBackgroundToggle
-#endif
- } header: {
- header.map(Text.init)
- }
- }
-}
-
-private extension SettingsSection {
- var confirmsQuitToggle: some View {
- Toggle(Strings.Views.Settings.Rows.confirmQuit, isOn: $confirmsQuit)
- }
-
- var lockInBackgroundToggle: some View {
- Toggle(Strings.Views.Settings.Rows.lockInBackground, isOn: $locksInBackground)
+ public static func eraseCloudKitStore(fromContainerWithId containerId: String) async throws {
+ let container = CKContainer(identifier: containerId)
+ let db = container.privateCloudDatabase
+ try await db.deleteRecordZone(withID: Self.cloudKitZone)
}
}