diff --git a/Passepartout/App/App.entitlements b/Passepartout/App/App.entitlements
index 090aad11..3beadaec 100644
--- a/Passepartout/App/App.entitlements
+++ b/Passepartout/App/App.entitlements
@@ -2,26 +2,39 @@
+ aps-environment
+ development
+ com.apple.developer.aps-environment
+ development
+ com.apple.developer.icloud-container-identifiers
+
+ $(CFG_CLOUDKIT_ID)
+ $(CFG_LEGACY_V2_CLOUDKIT_ID)
+
+ com.apple.developer.icloud-services
+
+ CloudKit
+
com.apple.developer.networking.networkextension
packet-tunnel-provider
com.apple.developer.networking.wifi-info
+ com.apple.security.app-sandbox
+
com.apple.security.application-groups
$(CFG_GROUP_ID)
- com.apple.security.app-sandbox
-
com.apple.security.files.user-selected.read-only
- com.apple.security.personal-information.location
-
com.apple.security.network.client
com.apple.security.network.server
+ com.apple.security.personal-information.location
+
keychain-access-groups
$(AppIdentifierPrefix)$(CFG_GROUP_ID)
diff --git a/Passepartout/App/App.plist b/Passepartout/App/App.plist
index 023681b7..142827ed 100644
--- a/Passepartout/App/App.plist
+++ b/Passepartout/App/App.plist
@@ -6,6 +6,8 @@
appStoreId
$(CFG_APP_STORE_ID)
+ cloudKitId
+ $(CFG_CLOUDKIT_ID)
groupId
$(CFG_GROUP_ID)
iapBundlePrefix
@@ -14,8 +16,14 @@
$(CFG_TEAM_ID).$(CFG_GROUP_ID)
profilesContainerName
$(CFG_PROFILES_CONTAINER_NAME)
+ remoteProfilesContainerName
+ $(CFG_PROFILES_CONTAINER_NAME).remote
tunnelId
$(CFG_TUNNEL_ID)
+ legacyV2CloudKitId
+ $(CFG_LEGACY_V2_CLOUDKIT_ID)
+ legacyV2ProfilesContainerName
+ $(CFG_LEGACY_V2_PROFILES_CONTAINER_NAME)
CFBundleDocumentTypes
@@ -52,5 +60,9 @@
CustomIntentIntent
+ UIBackgroundModes
+
+ remote-notification
+
diff --git a/Passepartout/Config.xcconfig b/Passepartout/Config.xcconfig
index c4ee640b..3c6d1eea 100644
--- a/Passepartout/Config.xcconfig
+++ b/Passepartout/Config.xcconfig
@@ -28,6 +28,7 @@
CFG_APP_ID = com.algoritmico.ios.Passepartout
CFG_APP_STORE_ID = 1433648537
+CFG_CLOUDKIT_ID = iCloud.com.algoritmico.Passepartout.v3
CFG_COPYRIGHT = Copyright © 2024 Davide De Rosa. All rights reserved.
CFG_DISPLAY_NAME = Passepartout
CFG_GROUP_ID[sdk=appletvos*] = $(CFG_RAW_GROUP_ID)
@@ -42,6 +43,9 @@ CFG_RAW_GROUP_ID = group.com.algoritmico.Passepartout
CFG_TEAM_ID = DTDYD63ZX9
CFG_TUNNEL_ID = $(CFG_APP_ID).Tunnel
+CFG_LEGACY_V2_CLOUDKIT_ID = iCloud.com.algoritmico.Passepartout
+CFG_LEGACY_V2_PROFILES_CONTAINER_NAME = Profiles
+
PATH = $(PATH):/opt/homebrew/bin:/usr/local/bin:/usr/local/go/bin
CUSTOM_SCRIPT_PATH = $(PATH)
diff --git a/Passepartout/Library/.swiftpm/xcode/xcshareddata/xcschemes/IntentsLibrary.xcscheme b/Passepartout/Library/.swiftpm/xcode/xcshareddata/xcschemes/IntentsLibrary.xcscheme
deleted file mode 100644
index 497600ff..00000000
--- a/Passepartout/Library/.swiftpm/xcode/xcshareddata/xcschemes/IntentsLibrary.xcscheme
+++ /dev/null
@@ -1,67 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Passepartout/Library/Package.swift b/Passepartout/Library/Package.swift
index 9f48a3e7..2766677e 100644
--- a/Passepartout/Library/Package.swift
+++ b/Passepartout/Library/Package.swift
@@ -24,13 +24,6 @@ let package = Package(
"AppUI"
]
),
- .library(
- name: "IntentsLibrary",
- targets: [
- "AppDataProfiles",
- "AppLibrary"
- ]
- ),
.library(
name: "TunnelLibrary",
targets: ["CommonLibrary"]
@@ -72,6 +65,7 @@ let package = Package(
"AppData",
"CommonLibrary",
"Kvitto",
+ "LegacyV2",
"UtilsLibrary"
]
),
@@ -93,6 +87,15 @@ let package = Package(
.process("Resources")
]
),
+ .target(
+ name: "LegacyV2",
+ dependencies: [
+ .product(name: "PassepartoutKit", package: "passepartoutkit")
+ ],
+ resources: [
+ .process("Profiles.xcdatamodeld")
+ ]
+ ),
.target(
name: "UtilsLibrary"
),
diff --git a/Passepartout/Library/Sources/AppDataProfiles/AppData+Profiles.swift b/Passepartout/Library/Sources/AppDataProfiles/AppData+Profiles.swift
index e80a2c7b..6e0f516f 100644
--- a/Passepartout/Library/Sources/AppDataProfiles/AppData+Profiles.swift
+++ b/Passepartout/Library/Sources/AppDataProfiles/AppData+Profiles.swift
@@ -28,12 +28,10 @@ import CoreData
import Foundation
extension AppData {
-
- @MainActor
- public static let cdProfilesModel: NSManagedObjectModel = {
+ public static var cdProfilesModel: NSManagedObjectModel {
guard let model: NSManagedObjectModel = .mergedModel(from: [.module]) else {
- fatalError("Unable to build Core Data model")
+ fatalError("Unable to build Core Data model (Profiles v3)")
}
return model
- }()
+ }
}
diff --git a/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepository.swift b/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift
similarity index 88%
rename from Passepartout/Library/Sources/AppDataProfiles/CDProfileRepository.swift
rename to Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift
index 0598c9db..c26dbef9 100644
--- a/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepository.swift
+++ b/Passepartout/Library/Sources/AppDataProfiles/CDProfileRepositoryV3.swift
@@ -1,5 +1,5 @@
//
-// CDProfileRepository.swift
+// CDProfileRepositoryV3.swift
// Passepartout
//
// Created by Davide De Rosa on 8/11/24.
@@ -30,14 +30,15 @@ import PassepartoutKit
import UtilsLibrary
extension AppData {
+
// TODO: #656, make non-static
- public static func cdProfileRepository(
+ public static func cdProfileRepositoryV3(
registry: Registry,
coder: ProfileCoder,
context: NSManagedObjectContext,
onResultError: ((Error) -> CoreDataResultAction)?
) -> any ProfileRepository {
- let repository = CoreDataRepository(context: context) {
+ let repository = CoreDataRepository(context: context) {
$0.sortDescriptors = [
.init(key: "name", ascending: true, selector: #selector(NSString.caseInsensitiveCompare)),
.init(key: "lastUpdate", ascending: true)
@@ -50,7 +51,7 @@ extension AppData {
} toMapper: {
let encoded = try registry.encodedProfile($0, with: coder)
- let cdProfile = CDProfile(context: $1)
+ let cdProfile = CDProfileV3(context: $1)
cdProfile.uuid = $0.id
cdProfile.name = $0.name
cdProfile.encoded = encoded
@@ -64,7 +65,7 @@ extension AppData {
}
}
-extension CDProfile: CoreDataUniqueEntity {
+extension CDProfileV3: CoreDataUniqueEntity {
}
extension CoreDataRepository: ProfileRepository where T == Profile {
diff --git a/Passepartout/Library/Sources/AppDataProfiles/CDProfile.swift b/Passepartout/Library/Sources/AppDataProfiles/CDProfileV3.swift
similarity index 92%
rename from Passepartout/Library/Sources/AppDataProfiles/CDProfile.swift
rename to Passepartout/Library/Sources/AppDataProfiles/CDProfileV3.swift
index df79911f..05bb6f31 100644
--- a/Passepartout/Library/Sources/AppDataProfiles/CDProfile.swift
+++ b/Passepartout/Library/Sources/AppDataProfiles/CDProfileV3.swift
@@ -1,5 +1,5 @@
//
-// CDProfile.swift
+// CDProfileV3.swift
// Passepartout
//
// Created by Davide De Rosa on 8/11/24.
@@ -26,8 +26,8 @@
import CoreData
import Foundation
-@objc(CDProfile)
-final class CDProfile: NSManagedObject {
+@objc(CDProfileV3)
+final class CDProfileV3: NSManagedObject {
@NSManaged var uuid: UUID?
@NSManaged var name: String?
@NSManaged var encoded: String?
diff --git a/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/.xccurrentversion b/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/.xccurrentversion
new file mode 100644
index 00000000..f2ff7746
--- /dev/null
+++ b/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/.xccurrentversion
@@ -0,0 +1,8 @@
+
+
+
+
+ _XCCurrentVersionName
+ ProfilesV3.xcdatamodel
+
+
diff --git a/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/Profiles.xcdatamodel/contents b/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/Profiles.xcdatamodel/contents
index 0256120b..f7dbaa9b 100644
--- a/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/Profiles.xcdatamodel/contents
+++ b/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/Profiles.xcdatamodel/contents
@@ -1,6 +1,6 @@
-
-
+
+
diff --git a/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/ProfilesV3.xcdatamodel/contents b/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/ProfilesV3.xcdatamodel/contents
new file mode 100644
index 00000000..c4707a12
--- /dev/null
+++ b/Passepartout/Library/Sources/AppDataProfiles/Profiles.xcdatamodeld/ProfilesV3.xcdatamodel/contents
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Passepartout/Library/Sources/AppLibrary/Business/ProfileManager.swift b/Passepartout/Library/Sources/AppLibrary/Business/ProfileManager.swift
index fd308596..c94a5520 100644
--- a/Passepartout/Library/Sources/AppLibrary/Business/ProfileManager.swift
+++ b/Passepartout/Library/Sources/AppLibrary/Business/ProfileManager.swift
@@ -35,22 +35,28 @@ public final class ProfileManager: ObservableObject {
case save(Profile)
case remove([Profile.ID])
-
- case update([Profile])
}
public var beforeSave: ((Profile) async throws -> Void)?
public var afterRemove: (([Profile.ID]) async -> Void)?
- public let didChange: PassthroughSubject
+ private let repository: any ProfileRepository
+
+ private let remoteRepository: (any ProfileRepository)?
@Published
private var profiles: [Profile]
- private var allProfileIds: Set
+ private var allProfiles: [Profile.ID: Profile] {
+ didSet {
+ reloadFilteredProfiles()
+ }
+ }
- private let repository: any ProfileRepository
+ private var allRemoteProfiles: [Profile.ID: Profile]
+
+ public let didChange: PassthroughSubject
private let searchSubject: CurrentValueSubject
@@ -58,21 +64,27 @@ public final class ProfileManager: ObservableObject {
// for testing/previews
public init(profiles: [Profile]) {
- didChange = PassthroughSubject()
- self.profiles = profiles.sorted {
- $0.name.lowercased() < $1.name.lowercased()
- }
- allProfileIds = []
repository = MockProfileRepository(profiles: profiles)
+ remoteRepository = nil
+ self.profiles = []
+ allProfiles = profiles.reduce(into: [:]) {
+ $0[$1.id] = $1
+ }
+ allRemoteProfiles = [:]
+
+ didChange = PassthroughSubject()
searchSubject = CurrentValueSubject("")
subscriptions = []
}
- public init(repository: any ProfileRepository) {
- didChange = PassthroughSubject()
- profiles = []
- allProfileIds = []
+ public init(repository: any ProfileRepository, remoteRepository: (any ProfileRepository)?) {
self.repository = repository
+ self.remoteRepository = remoteRepository
+ profiles = []
+ allProfiles = [:]
+ allRemoteProfiles = [:]
+
+ didChange = PassthroughSubject()
searchSubject = CurrentValueSubject("")
subscriptions = []
}
@@ -109,6 +121,7 @@ extension ProfileManager {
do {
try await beforeSave?(profile)
try await repository.saveEntities([profile])
+ allProfiles[profile.id] = profile
didChange.send(.save(profile))
} catch {
pp_log(.app, .fault, "Unable to save profile \(profile.id): \(error)")
@@ -122,9 +135,13 @@ extension ProfileManager {
public func remove(withIds profileIds: [Profile.ID]) async {
do {
- allProfileIds.subtract(profileIds)
+ var newAllProfiles = allProfiles
try await repository.removeEntities(withIds: profileIds)
+ profileIds.forEach {
+ newAllProfiles.removeValue(forKey: $0)
+ }
await afterRemove?(profileIds)
+ allProfiles = newAllProfiles
didChange.send(.remove(profileIds))
} catch {
pp_log(.app, .fault, "Unable to remove profiles \(profileIds): \(error)")
@@ -132,7 +149,30 @@ extension ProfileManager {
}
public func exists(withId profileId: Profile.ID) -> Bool {
- allProfileIds.contains(profileId)
+ allProfiles.keys.contains(profileId)
+ }
+}
+
+// MARK: - Remote
+
+extension ProfileManager {
+ public func isRemotelyShared(profileWithId profileId: Profile.ID) -> Bool {
+ allRemoteProfiles.keys.contains(profileId)
+ }
+
+ public func setRemotelyShared(_ shared: Bool, profileWithId profileId: Profile.ID) async throws {
+ guard let remoteRepository else {
+ pp_log(.app, .error, "Unable to share remotely when no remoteRepository is set")
+ return
+ }
+ guard let profile = allProfiles[profileId] else {
+ return
+ }
+ if shared {
+ try await remoteRepository.saveEntities([profile])
+ } else {
+ try await remoteRepository.removeEntities(withIds: [profileId])
+ }
}
}
@@ -183,9 +223,18 @@ extension ProfileManager {
public func observeObjects(searchDebounce: Int = 200) {
repository
.entitiesPublisher
+ .first()
.receive(on: DispatchQueue.main)
.sink { [weak self] in
- self?.notifyUpdatedEntities($0)
+ self?.notifyLocalEntities($0)
+ }
+ .store(in: &subscriptions)
+
+ remoteRepository?
+ .entitiesPublisher
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] in
+ self?.notifyRemoteEntities($0)
}
.store(in: &subscriptions)
@@ -199,29 +248,45 @@ extension ProfileManager {
}
private extension ProfileManager {
- func notifyUpdatedEntities(_ result: EntitiesResult) {
- let oldProfiles = profiles.reduce(into: [:]) {
+ func notifyLocalEntities(_ result: EntitiesResult) {
+ allProfiles = result.entities.reduce(into: [:]) {
$0[$1.id] = $1
}
- let newProfiles = result.entities
- let updatedProfiles = newProfiles.filter {
- $0 != oldProfiles[$0.id] // includes new profiles
+ }
+
+ func notifyRemoteEntities(_ result: EntitiesResult) {
+ allRemoteProfiles = result.entities.reduce(into: [:]) {
+ $0[$1.id] = $1
}
- if !result.isFiltering {
- allProfileIds = Set(newProfiles.map(\.id))
+ // pull remote updates into local profiles (best-effort)
+ for remoteProfile in allRemoteProfiles.values {
+ Task.detached { [weak self] in
+ do {
+ pp_log(.app, .notice, "Import remote profile \(remoteProfile.id)...")
+ try await self?.save(remoteProfile)
+ } catch {
+ pp_log(.app, .error, "Unable to import remote profile: \(error)")
+ }
+ }
}
- profiles = newProfiles
- didChange.send(.update(updatedProfiles))
}
func performSearch(_ search: String) {
- Task {
- guard !search.isEmpty else {
- try await repository.resetFilter()
- return
+ reloadFilteredProfiles(with: search)
+ }
+
+ func reloadFilteredProfiles(with search: String? = nil) {
+ profiles = allProfiles
+ .values
+ .filter {
+ if let search, !search.isEmpty {
+ return $0.name.lowercased().contains(search.lowercased())
+ }
+ return true
+ }
+ .sorted {
+ $0.name.lowercased() < $1.name.lowercased()
}
- try await repository.filter(byName: search)
- }
}
}
diff --git a/Passepartout/Library/Sources/AppUI/Business/ProfileEditor.swift b/Passepartout/Library/Sources/AppUI/Business/ProfileEditor.swift
index 4cf78d6c..a7cf5822 100644
--- a/Passepartout/Library/Sources/AppUI/Business/ProfileEditor.swift
+++ b/Passepartout/Library/Sources/AppUI/Business/ProfileEditor.swift
@@ -36,6 +36,9 @@ final class ProfileEditor: ObservableObject {
@Published
var name: String
+ @Published
+ var isShared: Bool
+
@Published
private(set) var modules: [any EditableModule]
@@ -58,6 +61,7 @@ final class ProfileEditor: ObservableObject {
activeModulesIds = Set(modules.map(\.id))
moduleNames = [:]
removedModules = [:]
+ isShared = false
}
init(profile: Profile) {
@@ -67,15 +71,17 @@ final class ProfileEditor: ObservableObject {
activeModulesIds = profile.activeModulesIds
moduleNames = profile.moduleNames
removedModules = [:]
+ isShared = false
}
- func editProfile(_ profile: Profile) {
+ func editProfile(_ profile: Profile, isShared: Bool) {
id = profile.id
name = profile.name
modules = profile.modulesBuilders
activeModulesIds = profile.activeModulesIds
moduleNames = profile.moduleNames
removedModules = [:]
+ self.isShared = isShared
}
}
@@ -267,6 +273,7 @@ extension ProfileEditor {
do {
let newProfile = try build()
try await profileManager.save(newProfile)
+ try await profileManager.setRemotelyShared(isShared, profileWithId: newProfile.id)
} catch {
pp_log(.app, .fault, "Unable to save edited profile: \(error)")
throw error
diff --git a/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift b/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift
index 14b12f24..76f46fd8 100644
--- a/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift
+++ b/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift
@@ -285,6 +285,22 @@ public enum Strings {
public static let add = Strings.tr("Localizable", "modules.dns.servers.add", fallback: "Add address")
}
}
+ public enum General {
+ public enum Sections {
+ public enum Storage {
+ /// Profiles are stored to iCloud encrypted.
+ public static let footer = Strings.tr("Localizable", "modules.general.sections.storage.footer", fallback: "Profiles are stored to iCloud encrypted.")
+ }
+ }
+ public enum Storage {
+ /// Share on iCloud
+ public static let shared = Strings.tr("Localizable", "modules.general.storage.shared", fallback: "Share on iCloud")
+ public enum Shared {
+ /// Share on iCloud
+ public static let purchase = Strings.tr("Localizable", "modules.general.storage.shared.purchase", fallback: "Share on iCloud")
+ }
+ }
+ }
public enum HttpProxy {
public enum BypassDomains {
/// Add bypass domain
diff --git a/Passepartout/Library/Sources/AppUI/Mock/Mock.swift b/Passepartout/Library/Sources/AppUI/Mock/Mock.swift
index ec83b959..ca3368ae 100644
--- a/Passepartout/Library/Sources/AppUI/Mock/Mock.swift
+++ b/Passepartout/Library/Sources/AppUI/Mock/Mock.swift
@@ -41,6 +41,7 @@ extension AppContext {
iapManager: IAPManager(
customUserLevel: nil,
receiptReader: MockReceiptReader(),
+ unrestrictedFeatures: [.sharing],
productsAtBuild: { _ in
[]
}
diff --git a/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings b/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings
index 57ebda04..7f7f80ed 100644
--- a/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings
+++ b/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings
@@ -159,6 +159,10 @@
// MARK: - Module views
+"modules.general.sections.storage.footer" = "Profiles are stored to iCloud encrypted.";
+"modules.general.storage.shared" = "Share on iCloud";
+"modules.general.storage.shared.purchase" = "Share on iCloud";
+
"modules.dns.servers.add" = "Add address";
"modules.dns.search_domains.add" = "Add domain";
"modules.http_proxy.bypass_domains.add" = "Add bypass domain";
diff --git a/Passepartout/Library/Sources/AppUI/Views/App/AppInlineCoordinator.swift b/Passepartout/Library/Sources/AppUI/Views/App/AppInlineCoordinator.swift
index f11a5b09..bcf6d2fc 100644
--- a/Passepartout/Library/Sources/AppUI/Views/App/AppInlineCoordinator.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/App/AppInlineCoordinator.swift
@@ -140,7 +140,7 @@ private extension AppInlineCoordinator {
}
func enterDetail(of profile: Profile) {
- profileEditor.editProfile(profile)
+ profileEditor.editProfile(profile, isShared: profileManager.isRemotelyShared(profileWithId: profile.id))
push(.editProfile)
}
diff --git a/Passepartout/Library/Sources/AppUI/Views/App/AppModalCoordinator.swift b/Passepartout/Library/Sources/AppUI/Views/App/AppModalCoordinator.swift
index 57e0ce56..e6a8e5b5 100644
--- a/Passepartout/Library/Sources/AppUI/Views/App/AppModalCoordinator.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/App/AppModalCoordinator.swift
@@ -132,7 +132,7 @@ extension AppModalCoordinator {
func enterDetail(of profile: Profile) {
profilePath = NavigationPath()
- profileEditor.editProfile(profile)
+ profileEditor.editProfile(profile, isShared: profileManager.isRemotelyShared(profileWithId: profile.id))
modalRoute = .editProfile
}
}
diff --git a/Passepartout/Library/Sources/AppUI/Views/Profile/iOS/ProfileEditView+iOS.swift b/Passepartout/Library/Sources/AppUI/Views/Profile/iOS/ProfileEditView+iOS.swift
index 127be364..0df60d86 100644
--- a/Passepartout/Library/Sources/AppUI/Views/Profile/iOS/ProfileEditView+iOS.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/Profile/iOS/ProfileEditView+iOS.swift
@@ -64,7 +64,7 @@ struct ProfileEditView: View, Routable {
Text(Strings.Views.Profile.ModuleList.Section.footer)
}
StorageSection(
- uuid: profileEditor.id
+ profileEditor: profileEditor
)
}
.toolbar(content: toolbarContent)
diff --git a/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ProfileGeneralView+macOS.swift b/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ProfileGeneralView+macOS.swift
index 7132fc15..a56427c1 100644
--- a/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ProfileGeneralView+macOS.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ProfileGeneralView+macOS.swift
@@ -39,7 +39,7 @@ struct ProfileGeneralView: View {
placeholder: Strings.Placeholders.Profile.name
)
StorageSection(
- uuid: profileEditor.id
+ profileEditor: profileEditor
)
}
.themeForm()
diff --git a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+ImageName.swift b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+ImageName.swift
index 1dbf1b14..9aba8129 100644
--- a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+ImageName.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+ImageName.swift
@@ -29,6 +29,7 @@ extension Theme {
public enum ImageName {
case add
case close
+ case cloud
case contextDuplicate
case contextRemove
case copy
diff --git a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme.swift b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme.swift
index 0a2e05b2..4a8a3517 100644
--- a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme.swift
@@ -80,6 +80,7 @@ public final class Theme: ObservableObject {
switch $0 {
case .add: return "plus"
case .close: return "xmark"
+ case .cloud: return "cloud"
case .contextDuplicate: return "plus.square.on.square"
case .contextRemove: return "trash"
case .copy: return "doc.on.doc"
diff --git a/Passepartout/Library/Sources/AppUI/Views/UI/ProfileCardView.swift b/Passepartout/Library/Sources/AppUI/Views/UI/ProfileCardView.swift
index f0b8bc81..dcdd3a25 100644
--- a/Passepartout/Library/Sources/AppUI/Views/UI/ProfileCardView.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/UI/ProfileCardView.swift
@@ -37,18 +37,30 @@ struct ProfileCardView: View {
let header: ProfileHeader
+ let isShared: Bool
+
var body: some View {
switch style {
case .compact:
- Text(header.name)
- .themeTruncating()
- .frame(maxWidth: .infinity, alignment: .leading)
+ HStack {
+ Text(header.name)
+ .themeTruncating()
+ if isShared {
+ ThemeImage(.cloud)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
case .full:
VStack(alignment: .leading) {
- Text(header.name)
- .font(.headline)
- .themeTruncating()
+ HStack {
+ Text(header.name)
+ .font(.headline)
+ .themeTruncating()
+ if isShared {
+ ThemeImage(.cloud)
+ }
+ }
Text(Strings.Views.Profiles.Rows.modules(header.modules.count))
.font(.subheadline)
.foregroundStyle(.secondary)
@@ -63,10 +75,19 @@ struct ProfileCardView: View {
#Preview {
List {
Section {
- ProfileCardView(style: .compact, header: Profile.mock.header())
+ ProfileCardView(
+ style: .compact,
+ header: Profile.mock.header(),
+ isShared: true
+ )
}
Section {
- ProfileCardView(style: .full, header: Profile.mock.header())
+ ProfileCardView(
+ style: .full,
+ header: Profile.mock.header(),
+ isShared: true
+ )
}
}
+ .withMockEnvironment()
}
diff --git a/Passepartout/Library/Sources/AppUI/Views/UI/ProfileRowView.swift b/Passepartout/Library/Sources/AppUI/Views/UI/ProfileRowView.swift
index 9a7b92e4..6f29d24c 100644
--- a/Passepartout/Library/Sources/AppUI/Views/UI/ProfileRowView.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/UI/ProfileRowView.swift
@@ -83,9 +83,13 @@ private extension ProfileRowView {
interactiveManager: interactiveManager,
errorHandler: errorHandler
) { _ in
- ProfileCardView(style: style, header: header)
- .frame(maxWidth: .infinity)
- .contentShape(.rect)
+ ProfileCardView(
+ style: style,
+ header: header,
+ isShared: profileManager.isRemotelyShared(profileWithId: header.id)
+ )
+ .frame(maxWidth: .infinity)
+ .contentShape(.rect)
}
}
diff --git a/Passepartout/Library/Sources/AppUI/Views/UI/StorageSection.swift b/Passepartout/Library/Sources/AppUI/Views/UI/StorageSection.swift
index ab319a1c..b9ec5d49 100644
--- a/Passepartout/Library/Sources/AppUI/Views/UI/StorageSection.swift
+++ b/Passepartout/Library/Sources/AppUI/Views/UI/StorageSection.swift
@@ -27,29 +27,58 @@ import Foundation
import SwiftUI
struct StorageSection: View {
- let uuid: UUID
+
+ @EnvironmentObject
+ private var iapManager: IAPManager
+
+ @ObservedObject
+ var profileEditor: ProfileEditor
+
+ @State
+ private var paywallReason: PaywallReason?
var body: some View {
-#if DEBUG
debugChanges()
- return Section {
+ return Group {
+ sharingToggle
+#if DEBUG
ThemeCopiableText(
title: Strings.Unlocalized.uuid,
- value: uuid.uuidString
+ value: profileEditor.id.uuidString
)
- } header: {
- Text(Strings.Global.storage)
- }
-#else
- EmptyView()
#endif
+ }
+ .themeSection(
+ header: Strings.Global.storage,
+ footer: Strings.Modules.General.Sections.Storage.footer
+ )
+ .modifier(PaywallModifier(reason: $paywallReason))
+ }
+}
+
+private extension StorageSection {
+
+ @ViewBuilder
+ var sharingToggle: some View {
+ switch iapManager.paywallReason(forFeature: .sharing) {
+ case .purchase(let appFeature):
+ Button(Strings.Modules.General.Storage.Shared.purchase) {
+ paywallReason = .purchase(appFeature)
+ }
+
+ case .restricted:
+ EmptyView()
+
+ default:
+ Toggle(Strings.Modules.General.Storage.shared, isOn: $profileEditor.isShared)
+ }
}
}
#Preview {
Form {
StorageSection(
- uuid: ProfileEditor().id
+ profileEditor: ProfileEditor()
)
}
.themeForm()
diff --git a/Passepartout/Library/Sources/CommonLibrary/Domain/BundleConfiguration+Main.swift b/Passepartout/Library/Sources/CommonLibrary/Domain/BundleConfiguration+Main.swift
index f7fcb427..d598f562 100644
--- a/Passepartout/Library/Sources/CommonLibrary/Domain/BundleConfiguration+Main.swift
+++ b/Passepartout/Library/Sources/CommonLibrary/Domain/BundleConfiguration+Main.swift
@@ -31,6 +31,8 @@ extension BundleConfiguration {
public enum BundleKey: String {
case appStoreId
+ case cloudKitId
+
case customUserLevel
case groupId
@@ -41,7 +43,15 @@ extension BundleConfiguration {
case profilesContainerName
+ case remoteProfilesContainerName
+
case tunnelId
+
+ // legacy v2
+
+ case legacyV2CloudKitId
+
+ case legacyV2ProfilesContainerName
}
public static var mainDisplayName: String {
diff --git a/Passepartout/Library/Sources/LegacyV2/CDProfile.swift b/Passepartout/Library/Sources/LegacyV2/CDProfile.swift
new file mode 100644
index 00000000..781f3bdd
--- /dev/null
+++ b/Passepartout/Library/Sources/LegacyV2/CDProfile.swift
@@ -0,0 +1,16 @@
+import CoreData
+import Foundation
+
+@objc(CDProfile)
+final class CDProfile: NSManagedObject {
+ @nonobjc static func fetchRequest() -> NSFetchRequest {
+ return NSFetchRequest(entityName: "CDProfile")
+ }
+
+ @NSManaged var json: Data?
+ @NSManaged var encryptedJSON: Data?
+ @NSManaged var name: String?
+ @NSManaged var providerName: String?
+ @NSManaged var uuid: UUID?
+ @NSManaged var lastUpdate: Date?
+}
diff --git a/Passepartout/Library/Sources/LegacyV2/CDProfileRepositoryV2.swift b/Passepartout/Library/Sources/LegacyV2/CDProfileRepositoryV2.swift
new file mode 100644
index 00000000..677ec528
--- /dev/null
+++ b/Passepartout/Library/Sources/LegacyV2/CDProfileRepositoryV2.swift
@@ -0,0 +1,71 @@
+//
+// CDProfileRepositoryV2.swift
+// Passepartout
+//
+// Created by Davide De Rosa on 10/1/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 CoreData
+import Foundation
+import PassepartoutKit
+
+final class CDProfileRepositoryV2 {
+ static var model: NSManagedObjectModel {
+ guard let model: NSManagedObjectModel = .mergedModel(from: [.module]) else {
+ fatalError("Unable to build Core Data model (Profiles v2)")
+ }
+ return model
+ }
+
+ private let context: NSManagedObjectContext
+
+ init(context: NSManagedObjectContext) {
+ self.context = context
+ }
+
+ // FIXME: #586, migrate profiles properly
+ func migratedProfiles() async throws -> [Profile] {
+ try await context.perform { [weak self] in
+ guard let self else {
+ return []
+ }
+ do {
+ let request = CDProfile.fetchRequest()
+ let existing = try context.fetch(request)
+// existing.forEach {
+// guard let json = $0.encryptedJSON,
+// let string = String(data: json, encoding: .utf8) else {
+// return
+// }
+// print(">>> \(string)")
+// }
+ return existing.compactMap {
+ guard let name = $0.name else {
+ return nil
+ }
+ return try? Profile.Builder(name: name).tryBuild()
+ }
+ } catch {
+ throw error
+ }
+ }
+ }
+}
diff --git a/Passepartout/Library/Sources/LegacyV2/LegacyV2.swift b/Passepartout/Library/Sources/LegacyV2/LegacyV2.swift
new file mode 100644
index 00000000..4463efa3
--- /dev/null
+++ b/Passepartout/Library/Sources/LegacyV2/LegacyV2.swift
@@ -0,0 +1,54 @@
+//
+// LegacyV2.swift
+// Passepartout
+//
+// Created by Davide De Rosa on 10/1/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 Foundation
+import PassepartoutKit
+import UtilsLibrary
+
+public final class LegacyV2 {
+ private let profilesRepository: CDProfileRepositoryV2
+
+ private let cloudKitIdentifier: String
+
+ public init(
+ profilesContainerName: String,
+ cloudKitIdentifier: String,
+ coreDataLogger: CoreDataPersistentStoreLogger
+ ) {
+ let store = CoreDataPersistentStore(
+ logger: coreDataLogger,
+ containerName: profilesContainerName,
+ model: CDProfileRepositoryV2.model,
+ cloudKitIdentifier: cloudKitIdentifier,
+ author: nil
+ )
+ profilesRepository = CDProfileRepositoryV2(context: store.context)
+ self.cloudKitIdentifier = cloudKitIdentifier
+ }
+
+ public func fetchProfiles() async throws -> [Profile] {
+ try await profilesRepository.migratedProfiles()
+ }
+}
diff --git a/Passepartout/Library/Sources/LegacyV2/Profiles.xcdatamodeld/.xccurrentversion b/Passepartout/Library/Sources/LegacyV2/Profiles.xcdatamodeld/.xccurrentversion
new file mode 100644
index 00000000..b47e4a93
--- /dev/null
+++ b/Passepartout/Library/Sources/LegacyV2/Profiles.xcdatamodeld/.xccurrentversion
@@ -0,0 +1,8 @@
+
+
+
+
+ _XCCurrentVersionName
+ Profiles.xcdatamodel
+
+
diff --git a/Passepartout/Library/Sources/LegacyV2/Profiles.xcdatamodeld/Profiles.xcdatamodel/contents b/Passepartout/Library/Sources/LegacyV2/Profiles.xcdatamodeld/Profiles.xcdatamodel/contents
new file mode 100644
index 00000000..347ee32b
--- /dev/null
+++ b/Passepartout/Library/Sources/LegacyV2/Profiles.xcdatamodeld/Profiles.xcdatamodel/contents
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Passepartout/Library/Sources/UtilsLibrary/Business/CoreDataPersistentStore.swift b/Passepartout/Library/Sources/UtilsLibrary/Business/CoreDataPersistentStore.swift
index ae4a51cf..335a24db 100644
--- a/Passepartout/Library/Sources/UtilsLibrary/Business/CoreDataPersistentStore.swift
+++ b/Passepartout/Library/Sources/UtilsLibrary/Business/CoreDataPersistentStore.swift
@@ -43,14 +43,13 @@ public final class CoreDataPersistentStore {
logger: CoreDataPersistentStoreLogger,
containerName: String,
model: NSManagedObjectModel,
- cloudKit: Bool,
cloudKitIdentifier: String?,
author: String?
) {
let container: NSPersistentContainer
- if cloudKit {
+ if let cloudKitIdentifier {
container = NSPersistentCloudKitContainer(name: containerName, managedObjectModel: model)
- logger.debug("Set up CloudKit container: \(containerName)")
+ logger.debug("Set up CloudKit container (\(cloudKitIdentifier)): \(containerName)")
} else {
container = NSPersistentContainer(name: containerName, managedObjectModel: model)
logger.debug("Set up local container: \(containerName)")
@@ -98,7 +97,7 @@ public final class CoreDataPersistentStore {
container.viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
container.viewContext.automaticallyMergesChangesFromParent = true
- if let author = author {
+ if let author {
logger.debug("Setting transaction author: \(author)")
container.viewContext.transactionAuthor = author
}
diff --git a/Passepartout/Shared/Shared+AppLibrary.swift b/Passepartout/Shared/Shared+AppLibrary.swift
index 44b3a178..81209055 100644
--- a/Passepartout/Shared/Shared+AppLibrary.swift
+++ b/Passepartout/Shared/Shared+AppLibrary.swift
@@ -39,21 +39,35 @@ extension ProfileManager {
logger: .default,
containerName: BundleConfiguration.mainString(for: .profilesContainerName),
model: model,
- cloudKit: false,
cloudKitIdentifier: nil,
author: nil
)
-
- let repository = AppData.cdProfileRepository(
+ let repository = AppData.cdProfileRepositoryV3(
registry: .shared,
coder: CodableProfileCoder(),
context: store.context
) { error in
- pp_log(.app, .error, "Unable to decode result: \(error)")
+ pp_log(.app, .error, "Unable to decode local result: \(error)")
return .ignore
}
- return ProfileManager(repository: repository)
+ let remoteStore = CoreDataPersistentStore(
+ logger: .default,
+ containerName: BundleConfiguration.mainString(for: .remoteProfilesContainerName),
+ model: model,
+ cloudKitIdentifier: BundleConfiguration.mainString(for: .cloudKitId),
+ author: nil
+ )
+ let remoteRepository = AppData.cdProfileRepositoryV3(
+ registry: .shared,
+ coder: CodableProfileCoder(),
+ context: remoteStore.context
+ ) { error in
+ pp_log(.app, .error, "Unable to decode remote result: \(error)")
+ return .ignore
+ }
+
+ return ProfileManager(repository: repository, remoteRepository: remoteRepository)
}()
}