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