Fine-tune profile management with additional attributes (#807)

Additions to the domain:

- Update rather than replace existing Core Data profile
- Attach ProfileAttributes to Profile.userInfo
- Store one-off `fingerprint` UUID on each save

With the above in place, fix and improve ProfileManager to:

- Use `fingerprint` to compare local/remote profiles in history and thus
avoid local re-import of shared profiles
- Use `deletingRemotely` to delete local profiles when removed from the
remote repository (default false)
- Use `isIncluded` filter to exclude certain profiles from the local
repository (default nil)
This commit is contained in:
Davide 2024-11-03 23:35:45 +01:00 committed by GitHub
parent d59f408db8
commit a22584c630
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 249 additions and 57 deletions

View File

@ -41,7 +41,7 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source", "location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
"state" : { "state" : {
"revision" : "b32b63ab8e09883f965737bb6214dfb81e38283a" "revision" : "1b6bf03bb94e650852faabaa6b2161fe8b478151"
} }
}, },
{ {

View File

@ -40,7 +40,7 @@ let package = Package(
], ],
dependencies: [ dependencies: [
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.9.0"), // .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.9.0"),
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "b32b63ab8e09883f965737bb6214dfb81e38283a"), .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "1b6bf03bb94e650852faabaa6b2161fe8b478151"),
// .package(path: "../../../passepartoutkit-source"), // .package(path: "../../../passepartoutkit-source"),
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", from: "0.9.1"), .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", from: "0.9.1"),
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"), // .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"),

View File

@ -48,27 +48,56 @@ extension AppData {
.init(key: "lastUpdate", ascending: true) .init(key: "lastUpdate", ascending: true)
] ]
} fromMapper: { } fromMapper: {
guard let encoded = $0.encoded else { try fromMapper($0, registry: registry, coder: coder)
return nil
}
return try registry.decodedProfile(from: encoded, with: coder)
} toMapper: { } toMapper: {
let encoded = try registry.encodedProfile($0, with: coder) try toMapper($0, $1, $2, registry: registry, coder: coder)
let cdProfile = CDProfileV3(context: $1)
cdProfile.uuid = $0.id
cdProfile.name = $0.name
cdProfile.encoded = encoded
cdProfile.lastUpdate = Date()
return cdProfile
} onResultError: { } onResultError: {
onResultError?($0) ?? .ignore onResultError?($0) ?? .ignore
} }
return repository return repository
} }
} }
private extension AppData {
static func fromMapper(
_ cdEntity: CDProfileV3,
registry: Registry,
coder: ProfileCoder
) throws -> Profile? {
guard let encoded = cdEntity.encoded else {
return nil
}
let profile = try registry.decodedProfile(from: encoded, with: coder)
var builder = profile.builder()
builder.attributes = ProfileAttributes(
lastUpdate: cdEntity.lastUpdate,
fingerprint: cdEntity.fingerprint
)
return try builder.tryBuild()
}
static func toMapper(
_ profile: Profile,
_ oldCdEntity: CDProfileV3?,
_ context: NSManagedObjectContext,
registry: Registry,
coder: ProfileCoder
) throws -> CDProfileV3 {
let encoded = try registry.encodedProfile(profile, with: coder)
let cdProfile = oldCdEntity ?? CDProfileV3(context: context)
cdProfile.uuid = profile.id
cdProfile.name = profile.name
cdProfile.encoded = encoded
let attributes = profile.attributes
cdProfile.lastUpdate = attributes.lastUpdate
cdProfile.fingerprint = attributes.fingerprint
return cdProfile
}
}
// MARK: - Specialization // MARK: - Specialization
extension CDProfileV3: CoreDataUniqueEntity { extension CDProfileV3: CoreDataUniqueEntity {

View File

@ -36,4 +36,5 @@ final class CDProfileV3: NSManagedObject {
@NSManaged var name: String? @NSManaged var name: String?
@NSManaged var encoded: String? @NSManaged var encoded: String?
@NSManaged var lastUpdate: Date? @NSManaged var lastUpdate: Date?
@NSManaged var fingerprint: UUID?
} }

View File

@ -1,7 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23G93" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23H124" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
<entity name="CDProfileV3" representedClassName="CDProfileV3" elementID="CDProfile" versionHashModifier="1" syncable="YES"> <entity name="CDProfileV3" representedClassName="CDProfileV3" elementID="CDProfile" versionHashModifier="1" syncable="YES">
<attribute name="encoded" optional="YES" attributeType="String" allowsCloudEncryption="YES"/> <attribute name="encoded" optional="YES" attributeType="String" allowsCloudEncryption="YES"/>
<attribute name="fingerprint" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="name" optional="YES" attributeType="String"/> <attribute name="name" optional="YES" attributeType="String"/>
<attribute name="uuid" optional="YES" attributeType="UUID" usesScalarValueType="NO"/> <attribute name="uuid" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>

View File

@ -191,10 +191,8 @@ extension AppCoordinator {
func enterDetail(of profile: Profile) { func enterDetail(of profile: Profile) {
profilePath = NavigationPath() profilePath = NavigationPath()
profileEditor.editProfile( let isShared = profileManager.isRemotelyShared(profileWithId: profile.id)
profile, profileEditor.editProfile(profile, isShared: isShared)
isShared: profileManager.isRemotelyShared(profileWithId: profile.id)
)
present(.editProfile) present(.editProfile)
} }

View File

@ -45,7 +45,7 @@ public final class InMemoryProfileRepository: ProfileRepository {
profilesSubject.eraseToAnyPublisher() profilesSubject.eraseToAnyPublisher()
} }
public func saveProfile(_ profile: Profile) async throws { public func saveProfile(_ profile: Profile) {
pp_log(.app, .info, "Save profile: \(profile.id))") pp_log(.app, .info, "Save profile: \(profile.id))")
if let index = profiles.firstIndex(where: { $0.id == profile.id }) { if let index = profiles.firstIndex(where: { $0.id == profile.id }) {
profiles[index] = profile profiles[index] = profile
@ -54,7 +54,7 @@ public final class InMemoryProfileRepository: ProfileRepository {
} }
} }
public func removeProfiles(withIds ids: [Profile.ID]) async throws { public func removeProfiles(withIds ids: [Profile.ID]) {
pp_log(.app, .info, "Remove profiles: \(ids)") pp_log(.app, .info, "Remove profiles: \(ids)")
profiles = profiles.filter { profiles = profiles.filter {
!ids.contains($0.id) !ids.contains($0.id)

View File

@ -91,7 +91,9 @@ private extension NEProfileRepository {
func onLoadedManagers(_ managers: [Profile.ID: NETunnelProviderManager]) { func onLoadedManagers(_ managers: [Profile.ID: NETunnelProviderManager]) {
let profiles = managers.values.compactMap { let profiles = managers.values.compactMap {
do { do {
return try repository.profile(from: $0) let profile = try repository.profile(from: $0)
pp_log(.app, .debug, "Attributes for profile \(profile.id): \(profile.attributes)")
return profile
} catch { } catch {
pp_log(.app, .error, "Unable to decode profile from NE manager '\($0.localizedDescription ?? "")': \(error)") pp_log(.app, .error, "Unable to decode profile from NE manager '\($0.localizedDescription ?? "")': \(error)")
return nil return nil
@ -107,7 +109,7 @@ private extension NEProfileRepository {
managers.keys.contains($0.id) managers.keys.contains($0.id)
} }
let removedProfilesDesc = profilesSubject let removedProfilesDescription = profilesSubject
.value .value
.filter { .filter {
!managers.keys.contains($0.id) !managers.keys.contains($0.id)
@ -116,7 +118,7 @@ private extension NEProfileRepository {
"\($0.name)(\($0.id)" "\($0.name)(\($0.id)"
} }
pp_log(.app, .info, "Sync profiles removed externally: \(removedProfilesDesc)") pp_log(.app, .info, "Sync profiles removed externally: \(removedProfilesDescription)")
profilesSubject.send(profiles) profilesSubject.send(profiles)
} }

View File

@ -41,6 +41,10 @@ public final class ProfileManager: ObservableObject {
private let remoteRepository: (any ProfileRepository)? private let remoteRepository: (any ProfileRepository)?
private let deletingRemotely: Bool
private let isIncluded: ((Profile) -> Bool)?
@Published @Published
private var profiles: [Profile] private var profiles: [Profile]
@ -63,6 +67,8 @@ public final class ProfileManager: ObservableObject {
repository = InMemoryProfileRepository(profiles: profiles) repository = InMemoryProfileRepository(profiles: profiles)
backupRepository = nil backupRepository = nil
remoteRepository = nil remoteRepository = nil
deletingRemotely = false
isIncluded = nil
self.profiles = [] self.profiles = []
allProfiles = profiles.reduce(into: [:]) { allProfiles = profiles.reduce(into: [:]) {
$0[$1.id] = $1 $0[$1.id] = $1
@ -77,11 +83,16 @@ public final class ProfileManager: ObservableObject {
public init( public init(
repository: any ProfileRepository, repository: any ProfileRepository,
backupRepository: (any ProfileRepository)? = nil, backupRepository: (any ProfileRepository)? = nil,
remoteRepository: (any ProfileRepository)? remoteRepository: (any ProfileRepository)?,
deletingRemotely: Bool = false,
isIncluded: ((Profile) -> Bool)? = nil
) { ) {
precondition(!deletingRemotely || remoteRepository != nil, "deletingRemotely requires a non-nil remoteRepository")
self.repository = repository self.repository = repository
self.backupRepository = backupRepository self.backupRepository = backupRepository
self.remoteRepository = remoteRepository self.remoteRepository = remoteRepository
self.deletingRemotely = deletingRemotely
self.isIncluded = isIncluded
profiles = [] profiles = []
allProfiles = [:] allProfiles = [:]
allRemoteProfiles = [:] allRemoteProfiles = [:]
@ -120,39 +131,39 @@ extension ProfileManager {
} }
public func save(_ profile: Profile, isShared: Bool? = nil) async throws { public func save(_ profile: Profile, isShared: Bool? = nil) async throws {
pp_log(.app, .notice, "Save profile \(profile.id)...")
// inject attributes
var builder = profile.builder()
builder.attributes.lastUpdate = Date()
builder.attributes.fingerprint = UUID()
let historifiedProfile = try builder.tryBuild()
pp_log(.app, .notice, "Save profile \(historifiedProfile.id)...")
do { do {
let existingProfile = allProfiles[profile.id] try await repository.saveProfile(historifiedProfile)
if existingProfile == nil || profile != existingProfile { if let backupRepository {
try await repository.saveProfile(profile) Task.detached {
try await backupRepository.saveProfile(historifiedProfile)
if let backupRepository {
Task.detached {
try? await backupRepository.saveProfile(profile)
}
} }
allProfiles[profile.id] = profile
didChange.send(.save(profile))
} else {
pp_log(.app, .notice, "Profile \(profile.id) not modified, not saving")
} }
allProfiles[historifiedProfile.id] = historifiedProfile
didChange.send(.save(historifiedProfile))
} catch { } catch {
pp_log(.app, .fault, "Unable to save profile \(profile.id): \(error)") pp_log(.app, .fault, "Unable to save profile \(historifiedProfile.id): \(error)")
throw error throw error
} }
do { do {
if let isShared, let remoteRepository { if let isShared, let remoteRepository {
if isShared { if isShared {
pp_log(.app, .notice, "Enable remote sharing of profile \(profile.id)...") pp_log(.app, .notice, "Enable remote sharing of profile \(historifiedProfile.id)...")
try await remoteRepository.saveProfile(profile) try await remoteRepository.saveProfile(historifiedProfile)
} else { } else {
pp_log(.app, .notice, "Disable remote sharing of profile \(profile.id)...") pp_log(.app, .notice, "Disable remote sharing of profile \(historifiedProfile.id)...")
try await remoteRepository.removeProfiles(withIds: [profile.id]) try await remoteRepository.removeProfiles(withIds: [historifiedProfile.id])
} }
} }
} catch { } catch {
pp_log(.app, .fault, "Unable to save/remove remote profile \(profile.id): \(error)") pp_log(.app, .fault, "Unable to save/remove remote profile \(historifiedProfile.id): \(error)")
throw error throw error
} }
} }
@ -190,7 +201,7 @@ extension ProfileManager {
} }
} }
// MARK: - Remote // MARK: - Remote/Attributes
extension ProfileManager { extension ProfileManager {
public func isRemotelyShared(profileWithId profileId: Profile.ID) -> Bool { public func isRemotelyShared(profileWithId profileId: Profile.ID) -> Bool {
@ -288,6 +299,21 @@ private extension ProfileManager {
allProfiles = result.reduce(into: [:]) { allProfiles = result.reduce(into: [:]) {
$0[$1.id] = $1 $0[$1.id] = $1
} }
if let isIncluded {
let idsToRemove: [Profile.ID] = allProfiles
.filter {
!isIncluded($0.value)
}
.map(\.key)
if !idsToRemove.isEmpty {
pp_log(.app, .info, "Delete non-included local profile: \(idsToRemove)")
Task.detached {
try await self.repository.removeProfiles(withIds: idsToRemove)
}
}
}
} }
func reloadRemoteProfiles(_ result: [Profile]) { func reloadRemoteProfiles(_ result: [Profile]) {
@ -295,6 +321,17 @@ private extension ProfileManager {
allRemoteProfiles = result.reduce(into: [:]) { allRemoteProfiles = result.reduce(into: [:]) {
$0[$1.id] = $1 $0[$1.id] = $1
} }
if deletingRemotely {
let idsToRemove = Set(allProfiles.keys).subtracting(Set(allRemoteProfiles.keys))
if !idsToRemove.isEmpty {
pp_log(.app, .info, "Delete local profiles removed remotely: \(idsToRemove)")
Task.detached {
try await self.repository.removeProfiles(withIds: Array(idsToRemove))
}
}
}
objectWillChange.send() objectWillChange.send()
} }
@ -303,9 +340,24 @@ private extension ProfileManager {
let profilesToImport = result let profilesToImport = result
pp_log(.app, .info, "Try to import remote profiles: \(result.map(\.id))") pp_log(.app, .info, "Try to import remote profiles: \(result.map(\.id))")
let allFingerprints = allProfiles.values.reduce(into: [:]) {
$0[$1.id] = $1.attributes.fingerprint
}
Task.detached { [weak self] in Task.detached { [weak self] in
for remoteProfile in profilesToImport { for remoteProfile in profilesToImport {
do { do {
guard self?.isIncluded?(remoteProfile) ?? true else {
pp_log(.app, .info, "Delete non-included remote profile \(remoteProfile.id)")
try? await self?.repository.removeProfiles(withIds: [remoteProfile.id])
continue
}
if let localFingerprint = allFingerprints[remoteProfile.id] {
guard remoteProfile.attributes.fingerprint != localFingerprint else {
pp_log(.app, .info, "Skip re-importing local profile \(remoteProfile.id)")
continue
}
}
pp_log(.app, .notice, "Import remote profile \(remoteProfile.id)...") pp_log(.app, .notice, "Import remote profile \(remoteProfile.id)...")
try await self?.save(remoteProfile) try await self?.save(remoteProfile)
} catch { } catch {

View File

@ -43,11 +43,11 @@ public final class ProviderFavoritesManager: ObservableObject {
public var serverIds: Set<String> { public var serverIds: Set<String> {
get { get {
allFavorites.servers(forModuleWithID: moduleId) allFavorites.servers(forModuleWithId: moduleId)
} }
set { set {
objectWillChange.send() objectWillChange.send()
allFavorites.setServers(newValue, forModuleWithID: moduleId) allFavorites.setServers(newValue, forModuleWithId: moduleId)
} }
} }

View File

@ -0,0 +1,84 @@
//
// ProfileAttributes.swift
// Passepartout
//
// Created by Davide De Rosa on 11/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 <http://www.gnu.org/licenses/>.
//
import CommonUtils
import Foundation
import PassepartoutKit
public struct ProfileAttributes: Hashable, Codable {
public var lastUpdate: Date?
public var fingerprint: UUID?
public init() {
}
public init(
lastUpdate: Date?,
fingerprint: UUID?
) {
self.lastUpdate = lastUpdate
self.fingerprint = fingerprint
}
}
extension ProfileAttributes: ProfileUserInfoTransformable {
public var userInfo: [String: AnyHashable]? {
do {
let data = try JSONEncoder().encode(self)
return try JSONSerialization.jsonObject(with: data) as? [String: AnyHashable] ?? [:]
} catch {
pp_log(.app, .error, "Unable to encode ProfileAttributes to dictionary: \(error)")
return [:]
}
}
public init?(userInfo: [String: AnyHashable]?) {
do {
let data = try JSONSerialization.data(withJSONObject: userInfo ?? [:])
self = try JSONDecoder().decode(ProfileAttributes.self, from: data)
} catch {
pp_log(.app, .error, "Unable to decode ProfileAttributes from dictionary: \(error)")
return nil
}
}
}
extension Profile {
public var attributes: ProfileAttributes {
userInfo() ?? ProfileAttributes()
}
}
extension Profile.Builder {
public var attributes: ProfileAttributes {
get {
userInfo() ?? ProfileAttributes()
}
set {
setUserInfo(newValue)
}
}
}

View File

@ -32,11 +32,11 @@ public struct ProviderFavoriteServers {
map = [:] map = [:]
} }
public func servers(forModuleWithID moduleId: UUID) -> Set<String> { public func servers(forModuleWithId moduleId: UUID) -> Set<String> {
map[moduleId] ?? [] map[moduleId] ?? []
} }
public mutating func setServers(_ servers: Set<String>, forModuleWithID moduleId: UUID) { public mutating func setServers(_ servers: Set<String>, forModuleWithId moduleId: UUID) {
map[moduleId] = servers map[moduleId] = servers
} }
} }

View File

@ -52,7 +52,7 @@ public actor CoreDataRepository<CD, T>: NSObject,
private let fromMapper: (CD) throws -> T? private let fromMapper: (CD) throws -> T?
private let toMapper: (T, NSManagedObjectContext) throws -> CD private let toMapper: (T, CD?, NSManagedObjectContext) throws -> CD
private let onResultError: ((Error) -> CoreDataResultAction)? private let onResultError: ((Error) -> CoreDataResultAction)?
@ -66,7 +66,7 @@ public actor CoreDataRepository<CD, T>: NSObject,
observingResults: Bool, observingResults: Bool,
beforeFetch: ((NSFetchRequest<CD>) -> Void)? = nil, beforeFetch: ((NSFetchRequest<CD>) -> Void)? = nil,
fromMapper: @escaping (CD) throws -> T?, fromMapper: @escaping (CD) throws -> T?,
toMapper: @escaping (T, NSManagedObjectContext) throws -> CD, toMapper: @escaping (T, CD?, NSManagedObjectContext) throws -> CD,
onResultError: ((Error) -> CoreDataResultAction)? = nil onResultError: ((Error) -> CoreDataResultAction)? = nil
) { ) {
guard let entityName = CD.entity().name else { guard let entityName = CD.entity().name else {
@ -127,9 +127,11 @@ public actor CoreDataRepository<CD, T>: NSObject,
existingIds existingIds
) )
let existing = try context.fetch(request) let existing = try context.fetch(request)
existing.forEach(context.delete)
for entity in entities { for entity in entities {
_ = try self.toMapper(entity, context) let oldCdEntity = existing.first {
$0.uuid == entity.uuid
}
_ = try self.toMapper(entity, oldCdEntity, context)
} }
try context.save() try context.save()
} catch { } catch {

View File

@ -23,6 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>. // along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
// //
import CommonLibrary
import Foundation import Foundation
import PassepartoutKit import PassepartoutKit
@ -37,6 +38,8 @@ public struct EditableProfile: MutableProfileType {
public var modulesMetadata: [UUID: ModuleMetadata]? public var modulesMetadata: [UUID: ModuleMetadata]?
public var userInfo: [String: AnyHashable]?
public func builder() throws -> Profile.Builder { public func builder() throws -> Profile.Builder {
var builder = Profile.Builder(id: id) var builder = Profile.Builder(id: id)
builder.modules = try modules.compactMap { builder.modules = try modules.compactMap {
@ -63,10 +66,23 @@ public struct EditableProfile: MutableProfileType {
$0[$1.key] = metadata $0[$1.key] = metadata
} }
builder.userInfo = userInfo
return builder return builder
} }
} }
extension EditableProfile {
var attributes: ProfileAttributes {
get {
userInfo() ?? ProfileAttributes()
}
set {
setUserInfo(newValue)
}
}
}
extension Profile { extension Profile {
public func editable() -> EditableProfile { public func editable() -> EditableProfile {
EditableProfile( EditableProfile(
@ -74,7 +90,8 @@ extension Profile {
name: name, name: name,
modules: modulesBuilders, modules: modulesBuilders,
activeModulesIds: activeModulesIds, activeModulesIds: activeModulesIds,
modulesMetadata: modulesMetadata modulesMetadata: modulesMetadata,
userInfo: userInfo
) )
} }

View File

@ -236,7 +236,13 @@ extension ProfileEditorTests {
.sink { .sink {
switch $0 { switch $0 {
case .save(let savedProfile): case .save(let savedProfile):
XCTAssertEqual(savedProfile, profile) do {
let lhs = try savedProfile.withoutUserInfo()
let rhs = try profile.withoutUserInfo()
XCTAssertEqual(lhs, rhs)
} catch {
XCTFail(error.localizedDescription)
}
exp.fulfill() exp.fulfill()
default: default: