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:
parent
d59f408db8
commit
a22584c630
|
@ -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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -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"),
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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?
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"/>
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
Loading…
Reference in New Issue