Refactor core data classes (#671)
This commit is contained in:
parent
ab453580ad
commit
d1de81d919
24 changed files with 605 additions and 433 deletions
90
passKit/Controllers/CoreDataStack.swift
Normal file
90
passKit/Controllers/CoreDataStack.swift
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
//
|
||||
// CoreDataStack.swift
|
||||
// passKit
|
||||
//
|
||||
// Created by Mingshen Sun on 12/28/24.
|
||||
// Copyright © 2024 Bob Sun. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
|
||||
public class PersistenceController {
|
||||
public static let shared = PersistenceController()
|
||||
static let modelName = "pass"
|
||||
|
||||
public func viewContext() -> NSManagedObjectContext {
|
||||
container.viewContext
|
||||
}
|
||||
|
||||
let container: NSPersistentContainer
|
||||
|
||||
init(isUnitTest: Bool = false) {
|
||||
self.container = NSPersistentContainer(name: Self.modelName, managedObjectModel: .sharedModel)
|
||||
|
||||
if isUnitTest {
|
||||
let description = NSPersistentStoreDescription()
|
||||
description.url = URL(fileURLWithPath: "/dev/null")
|
||||
container.persistentStoreDescriptions = [description]
|
||||
}
|
||||
}
|
||||
|
||||
public func setup() {
|
||||
container.loadPersistentStores { _, error in
|
||||
if error != nil {
|
||||
self.reinitializePersistentStore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reinitializePersistentStore() {
|
||||
deletePersistentStore()
|
||||
container.loadPersistentStores { _, finalError in
|
||||
if let finalError {
|
||||
fatalError("Failed to load persistent stores: \(finalError.localizedDescription)")
|
||||
}
|
||||
}
|
||||
PasswordEntity.initPasswordEntityCoreData(url: Globals.repositoryURL, in: container.viewContext)
|
||||
try? container.viewContext.save()
|
||||
}
|
||||
|
||||
func deletePersistentStore(inMemoryStore: Bool = false) {
|
||||
let coordinator = container.persistentStoreCoordinator
|
||||
|
||||
guard let storeURL = container.persistentStoreDescriptions.first?.url else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
if #available(iOS 15.0, *) {
|
||||
let storeType: NSPersistentStore.StoreType = inMemoryStore ? .inMemory : .sqlite
|
||||
try coordinator.destroyPersistentStore(at: storeURL, type: storeType)
|
||||
} else {
|
||||
let storeType: String = inMemoryStore ? NSInMemoryStoreType : NSSQLiteStoreType
|
||||
try coordinator.destroyPersistentStore(at: storeURL, ofType: storeType)
|
||||
}
|
||||
} catch {
|
||||
fatalError("Failed to destroy persistent store: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
public func save() {
|
||||
let context = viewContext()
|
||||
|
||||
if context.hasChanges {
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
fatalError("Failed to save changes: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NSManagedObjectModel {
|
||||
static let sharedModel: NSManagedObjectModel = {
|
||||
let url = Bundle(identifier: Globals.passKitBundleIdentifier)!.url(forResource: "pass", withExtension: "momd")!
|
||||
guard let managedObjectModel = NSManagedObjectModel(contentsOf: url) else {
|
||||
fatalError("Failed to create managed object model: \(url)")
|
||||
}
|
||||
return managedObjectModel
|
||||
}()
|
||||
}
|
||||
|
|
@ -27,7 +27,8 @@ public final class Globals {
|
|||
public static let pgpPublicKeyPath = documentPath + "/gpg_key.pub"
|
||||
public static let pgpPrivateKeyPath = documentPath + "/gpg_key"
|
||||
public static let gitSSHPrivateKeyPath = documentPath + "/ssh_key"
|
||||
public static let repositoryPath = libraryPath + "/password-store"
|
||||
public static let repositoryURL = sharedContainerURL.appendingPathComponent("Library/password-store/")
|
||||
|
||||
public static let dbPath = documentPath + "/pass.sqlite"
|
||||
|
||||
public static let iTunesFileSharingPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import OneTimePassword
|
|||
|
||||
public class Password {
|
||||
public var name: String
|
||||
public var url: URL
|
||||
public var path: String
|
||||
public var plainText: String
|
||||
|
||||
public var changed: Int = 0
|
||||
|
|
@ -28,11 +28,11 @@ public class Password {
|
|||
}
|
||||
|
||||
public var namePath: String {
|
||||
url.deletingPathExtension().path
|
||||
(path as NSString).deletingPathExtension
|
||||
}
|
||||
|
||||
public var nameFromPath: String? {
|
||||
url.deletingPathExtension().path.split(separator: "/").last.map { String($0) }
|
||||
URL(string: path)?.deletingPathExtension().pathComponents.last
|
||||
}
|
||||
|
||||
public var password: String {
|
||||
|
|
@ -75,15 +75,15 @@ public class Password {
|
|||
additions.map(\.title).filter(Constants.isOtpKeyword).count - (firstLineIsOTPField ? 1 : 0)
|
||||
}
|
||||
|
||||
public init(name: String, url: URL, plainText: String) {
|
||||
public init(name: String, path: String, plainText: String) {
|
||||
self.name = name
|
||||
self.url = url
|
||||
self.path = path
|
||||
self.plainText = plainText
|
||||
initEverything()
|
||||
}
|
||||
|
||||
public func updatePassword(name: String, url: URL, plainText: String) {
|
||||
guard self.plainText != plainText || self.url != url else {
|
||||
public func updatePassword(name: String, path: String, plainText: String) {
|
||||
guard self.plainText != plainText || self.path != path else {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -91,8 +91,8 @@ public class Password {
|
|||
self.plainText = plainText
|
||||
changed |= PasswordChange.content.rawValue
|
||||
}
|
||||
if self.url != url {
|
||||
self.url = url
|
||||
if self.path != path {
|
||||
self.path = path
|
||||
changed |= PasswordChange.path.rawValue
|
||||
}
|
||||
|
||||
|
|
@ -213,7 +213,7 @@ public class Password {
|
|||
if newOtpauth != nil {
|
||||
lines.append(newOtpauth!)
|
||||
}
|
||||
updatePassword(name: name, url: url, plainText: lines.joined(separator: "\n"))
|
||||
updatePassword(name: name, path: path, plainText: lines.joined(separator: "\n"))
|
||||
|
||||
// get and return the password
|
||||
return otpToken?.currentPassword
|
||||
|
|
|
|||
|
|
@ -6,56 +6,208 @@
|
|||
// Copyright © 2017 Bob Sun. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
import ObjectiveGit
|
||||
import SwiftyUserDefaults
|
||||
|
||||
public extension PasswordEntity {
|
||||
var nameWithCategory: String {
|
||||
if let path {
|
||||
if path.hasSuffix(".gpg") {
|
||||
return String(path.prefix(upTo: path.index(path.endIndex, offsetBy: -4)))
|
||||
}
|
||||
return path
|
||||
}
|
||||
return ""
|
||||
public final class PasswordEntity: NSManagedObject, Identifiable {
|
||||
/// Name of the password, i.e., filename without extension.
|
||||
@NSManaged public var name: String
|
||||
|
||||
/// A Boolean value indicating whether the entity is a directory.
|
||||
@NSManaged public var isDir: Bool
|
||||
|
||||
/// A Boolean value indicating whether the entity is synced with remote repository.
|
||||
@NSManaged public var isSynced: Bool
|
||||
|
||||
/// The relative file path of the password or directory.
|
||||
@NSManaged public var path: String
|
||||
|
||||
/// The thumbnail image of the password if there is a url entry in the password.
|
||||
@NSManaged public var image: Data?
|
||||
|
||||
/// The parent password entity.
|
||||
@NSManaged public var parent: PasswordEntity?
|
||||
|
||||
/// A set of child password entities.
|
||||
@NSManaged public var children: Set<PasswordEntity>
|
||||
|
||||
@nonobjc
|
||||
public static func fetchRequest() -> NSFetchRequest<PasswordEntity> {
|
||||
NSFetchRequest<PasswordEntity>(entityName: "PasswordEntity")
|
||||
}
|
||||
|
||||
func getCategoryText() -> String {
|
||||
getCategoryArray().joined(separator: " > ")
|
||||
/// A String value with password directory and name, i.e., path without extension.
|
||||
public var nameWithDir: String {
|
||||
(path as NSString).deletingPathExtension
|
||||
}
|
||||
|
||||
func getCategoryArray() -> [String] {
|
||||
public var dirText: String {
|
||||
getDirArray().joined(separator: " > ")
|
||||
}
|
||||
|
||||
public func getDirArray() -> [String] {
|
||||
var parentEntity = parent
|
||||
var passwordCategoryArray: [String] = []
|
||||
while parentEntity != nil {
|
||||
passwordCategoryArray.append(parentEntity!.name!)
|
||||
parentEntity = parentEntity!.parent
|
||||
while let current = parentEntity {
|
||||
passwordCategoryArray.append(current.name)
|
||||
parentEntity = current.parent
|
||||
}
|
||||
passwordCategoryArray.reverse()
|
||||
return passwordCategoryArray
|
||||
}
|
||||
|
||||
func getURL() throws -> URL {
|
||||
if let path = getPath().stringByAddingPercentEncodingForRFC3986(), let url = URL(string: path) {
|
||||
return url
|
||||
public static func fetchAll(in context: NSManagedObjectContext) -> [PasswordEntity] {
|
||||
let request = Self.fetchRequest()
|
||||
request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
|
||||
return (try? context.fetch(request) as? [Self]) ?? []
|
||||
}
|
||||
|
||||
public static func fetchAllPassword(in context: NSManagedObjectContext) -> [PasswordEntity] {
|
||||
let request = Self.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "isDir = false")
|
||||
request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
|
||||
return (try? context.fetch(request) as? [Self]) ?? []
|
||||
}
|
||||
|
||||
public static func totalNumber(in context: NSManagedObjectContext) -> Int {
|
||||
let request = Self.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "isDir = false")
|
||||
return (try? context.count(for: request)) ?? 0
|
||||
}
|
||||
|
||||
public static func fetchUnsynced(in context: NSManagedObjectContext) -> [PasswordEntity] {
|
||||
let request = Self.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "isSynced = false")
|
||||
return (try? context.fetch(request) as? [Self]) ?? []
|
||||
}
|
||||
|
||||
public static func fetch(by path: String, in context: NSManagedObjectContext) -> PasswordEntity? {
|
||||
let request = Self.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "path = %@", path)
|
||||
return try? context.fetch(request).first as? Self
|
||||
}
|
||||
|
||||
public static func fetch(by path: String, isDir: Bool, in context: NSManagedObjectContext) -> PasswordEntity? {
|
||||
let request = Self.fetchRequest()
|
||||
|
||||
request.predicate = NSPredicate(format: "path = %@ and isDir = %@", path, isDir as NSNumber)
|
||||
return try? context.fetch(request).first as? Self
|
||||
}
|
||||
|
||||
public static func fetch(by parent: PasswordEntity?, in context: NSManagedObjectContext) -> [PasswordEntity] {
|
||||
let request = Self.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "parent = %@", parent ?? 0)
|
||||
request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
|
||||
return (try? context.fetch(request) as? [Self]) ?? []
|
||||
}
|
||||
|
||||
public static func updateAllToSynced(in context: NSManagedObjectContext) -> Int {
|
||||
let request = NSBatchUpdateRequest(entity: Self.entity())
|
||||
request.resultType = .updatedObjectsCountResultType
|
||||
request.predicate = NSPredicate(format: "isSynced = false")
|
||||
request.propertiesToUpdate = ["isSynced": true]
|
||||
let result = try? context.execute(request) as? NSBatchUpdateResult
|
||||
return result?.result as? Int ?? 0
|
||||
}
|
||||
|
||||
public static func deleteRecursively(entity: PasswordEntity, in context: NSManagedObjectContext) {
|
||||
var currentEntity: PasswordEntity? = entity
|
||||
|
||||
while let node = currentEntity, node.children.isEmpty {
|
||||
let parent = node.parent
|
||||
context.delete(node)
|
||||
try? context.save()
|
||||
currentEntity = parent
|
||||
}
|
||||
throw AppError.other(message: "cannot decode URL")
|
||||
}
|
||||
|
||||
// XXX: define some getters to get core data, we need to consider
|
||||
// manually write models instead auto generation.
|
||||
|
||||
func getImage() -> Data? {
|
||||
image
|
||||
public static func deleteAll(in context: NSManagedObjectContext) {
|
||||
let deleteRequest = NSBatchDeleteRequest(fetchRequest: Self.fetchRequest())
|
||||
_ = try? context.execute(deleteRequest)
|
||||
}
|
||||
|
||||
func getName() -> String {
|
||||
// unwrap non-optional core data
|
||||
name ?? ""
|
||||
public static func exists(password: Password, in context: NSManagedObjectContext) -> Bool {
|
||||
let request = fetchRequest()
|
||||
request.predicate = NSPredicate(format: "name = %@ and path = %@ and isDir = false", password.name, password.path)
|
||||
if let count = try? context.count(for: request) {
|
||||
return count > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getPath() -> String {
|
||||
// unwrap non-optional core data
|
||||
path ?? ""
|
||||
@discardableResult
|
||||
public static func insert(name: String, path: String, isDir: Bool, into context: NSManagedObjectContext) -> PasswordEntity {
|
||||
let entity = PasswordEntity(context: context)
|
||||
entity.name = name
|
||||
entity.path = path
|
||||
entity.isDir = isDir
|
||||
entity.isSynced = false
|
||||
return entity
|
||||
}
|
||||
|
||||
public static func initPasswordEntityCoreData(url: URL, in context: NSManagedObjectContext) {
|
||||
let localFileManager = FileManager.default
|
||||
|
||||
let root = {
|
||||
let entity = PasswordEntity(context: context)
|
||||
entity.name = "root"
|
||||
entity.isDir = true
|
||||
entity.path = ""
|
||||
return entity
|
||||
}()
|
||||
var queue = [root]
|
||||
while !queue.isEmpty {
|
||||
let current = queue.removeFirst()
|
||||
let resourceKeys = Set<URLResourceKey>([.nameKey, .isDirectoryKey])
|
||||
let options = FileManager.DirectoryEnumerationOptions([.skipsHiddenFiles, .skipsSubdirectoryDescendants])
|
||||
let currentURL = url.appendingPathComponent(current.path)
|
||||
guard let directoryEnumerator = localFileManager.enumerator(at: currentURL, includingPropertiesForKeys: Array(resourceKeys), options: options) else {
|
||||
continue
|
||||
}
|
||||
for case let fileURL as URL in directoryEnumerator {
|
||||
guard let resourceValues = try? fileURL.resourceValues(forKeys: resourceKeys),
|
||||
let isDirectory = resourceValues.isDirectory,
|
||||
let name = resourceValues.name
|
||||
else {
|
||||
continue
|
||||
}
|
||||
let passwordEntity = PasswordEntity(context: context)
|
||||
passwordEntity.isDir = isDirectory
|
||||
if isDirectory {
|
||||
passwordEntity.name = name
|
||||
queue.append(passwordEntity)
|
||||
} else {
|
||||
if (name as NSString).pathExtension == "gpg" {
|
||||
passwordEntity.name = (name as NSString).deletingPathExtension
|
||||
} else {
|
||||
passwordEntity.name = name
|
||||
}
|
||||
}
|
||||
passwordEntity.parent = current
|
||||
let path = String(fileURL.path.replacingOccurrences(of: url.path, with: "").drop(while: { $0 == "/" }))
|
||||
passwordEntity.path = path
|
||||
}
|
||||
}
|
||||
context.delete(root)
|
||||
}
|
||||
}
|
||||
|
||||
public extension PasswordEntity {
|
||||
@objc(addChildrenObject:)
|
||||
@NSManaged
|
||||
func addToChildren(_ value: PasswordEntity)
|
||||
|
||||
@objc(removeChildrenObject:)
|
||||
@NSManaged
|
||||
func removeFromChildren(_ value: PasswordEntity)
|
||||
|
||||
@objc(addChildren:)
|
||||
@NSManaged
|
||||
func addToChildren(_ values: NSSet)
|
||||
|
||||
@objc(removeChildren:)
|
||||
@NSManaged
|
||||
func removeFromChildren(_ values: NSSet)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,35 +54,10 @@ public class PasswordStore {
|
|||
}
|
||||
|
||||
private let fileManager = FileManager.default
|
||||
private lazy var context: NSManagedObjectContext = {
|
||||
let modelURL = Bundle(identifier: Globals.passKitBundleIdentifier)!.url(forResource: "pass", withExtension: "momd")!
|
||||
let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL)
|
||||
let container = NSPersistentContainer(name: "pass", managedObjectModel: managedObjectModel!)
|
||||
if FileManager.default.fileExists(atPath: Globals.documentPath) {
|
||||
try! FileManager.default.createDirectory(atPath: Globals.documentPath, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
container.persistentStoreDescriptions = [NSPersistentStoreDescription(url: URL(fileURLWithPath: Globals.dbPath))]
|
||||
container.loadPersistentStores { _, error in
|
||||
if let error = error as NSError? {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
||||
|
||||
// Typical reasons for an error here include:
|
||||
//
|
||||
// * The parent directory does not exist, cannot be created, or disallows writing.
|
||||
// * The persistent store is not accessible, due to permissions or data protection when the device is locked.
|
||||
// * The device is out of space.
|
||||
// * The store could not be migrated to the current model version.
|
||||
//
|
||||
// Check the error message to determine what the actual problem was.
|
||||
fatalError("UnresolvedError".localize("\(error.localizedDescription), \(error.userInfo)"))
|
||||
}
|
||||
}
|
||||
return container.viewContext
|
||||
}()
|
||||
private lazy var context: NSManagedObjectContext = PersistenceController.shared.viewContext()
|
||||
|
||||
public var numberOfPasswords: Int {
|
||||
fetchPasswordEntityCoreData(withDir: false).count
|
||||
PasswordEntity.totalNumber(in: context)
|
||||
}
|
||||
|
||||
public var sizeOfRepositoryByteCount: UInt64 {
|
||||
|
|
@ -111,7 +86,7 @@ public class PasswordStore {
|
|||
storeRepository?.numberOfCommits(inCurrentBranch: nil)
|
||||
}
|
||||
|
||||
init(url: URL = URL(fileURLWithPath: "\(Globals.repositoryPath)")) {
|
||||
init(url: URL = Globals.repositoryURL) {
|
||||
self.storeURL = url
|
||||
|
||||
// Migration
|
||||
|
|
@ -137,27 +112,15 @@ public class PasswordStore {
|
|||
}
|
||||
|
||||
public func repositoryExists() -> Bool {
|
||||
fileManager.fileExists(atPath: Globals.repositoryPath)
|
||||
fileManager.fileExists(atPath: Globals.repositoryURL.path)
|
||||
}
|
||||
|
||||
public func passwordExisted(password: Password) -> Bool {
|
||||
let passwordEntityFetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "PasswordEntity")
|
||||
do {
|
||||
passwordEntityFetchRequest.predicate = NSPredicate(format: "name = %@ and path = %@", password.name, password.url.path)
|
||||
return try context.count(for: passwordEntityFetchRequest) > 0
|
||||
} catch {
|
||||
fatalError("FailedToFetchPasswordEntities".localize(error))
|
||||
}
|
||||
PasswordEntity.exists(password: password, in: context)
|
||||
}
|
||||
|
||||
public func getPasswordEntity(by path: String, isDir: Bool) -> PasswordEntity? {
|
||||
let passwordEntityFetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "PasswordEntity")
|
||||
do {
|
||||
passwordEntityFetchRequest.predicate = NSPredicate(format: "path = %@ and isDir = %@", path, isDir as NSNumber)
|
||||
return try context.fetch(passwordEntityFetchRequest).first as? PasswordEntity
|
||||
} catch {
|
||||
fatalError("FailedToFetchPasswordEntities".localize(error))
|
||||
}
|
||||
PasswordEntity.fetch(by: path, isDir: isDir, in: context)
|
||||
}
|
||||
|
||||
public func cloneRepository(
|
||||
|
|
@ -186,14 +149,15 @@ public class PasswordStore {
|
|||
} catch {
|
||||
Defaults.lastSyncedTime = nil
|
||||
DispatchQueue.main.async {
|
||||
self.deleteCoreData(entityName: "PasswordEntity")
|
||||
self.deleteCoreData()
|
||||
NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil)
|
||||
}
|
||||
throw (error)
|
||||
}
|
||||
Defaults.lastSyncedTime = Date()
|
||||
DispatchQueue.main.async {
|
||||
self.updatePasswordEntityCoreData()
|
||||
self.deleteCoreData()
|
||||
self.initPasswordEntityCoreData()
|
||||
NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil)
|
||||
}
|
||||
}
|
||||
|
|
@ -226,60 +190,14 @@ public class PasswordStore {
|
|||
Defaults.lastSyncedTime = Date()
|
||||
setAllSynced()
|
||||
DispatchQueue.main.async {
|
||||
self.updatePasswordEntityCoreData()
|
||||
self.deleteCoreData()
|
||||
self.initPasswordEntityCoreData()
|
||||
NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func updatePasswordEntityCoreData() {
|
||||
deleteCoreData(entityName: "PasswordEntity")
|
||||
do {
|
||||
var entities = try fileManager.contentsOfDirectory(atPath: storeURL.path)
|
||||
.filter { !$0.hasPrefix(".") }
|
||||
.map { filename -> PasswordEntity in
|
||||
let passwordEntity = NSEntityDescription.insertNewObject(forEntityName: "PasswordEntity", into: context) as! PasswordEntity
|
||||
if filename.hasSuffix(".gpg") {
|
||||
passwordEntity.name = String(filename.prefix(upTo: filename.index(filename.endIndex, offsetBy: -4)))
|
||||
} else {
|
||||
passwordEntity.name = filename
|
||||
}
|
||||
passwordEntity.path = filename
|
||||
passwordEntity.parent = nil
|
||||
return passwordEntity
|
||||
}
|
||||
while !entities.isEmpty {
|
||||
let entity = entities.first!
|
||||
entities.remove(at: 0)
|
||||
guard !entity.name!.hasPrefix(".") else {
|
||||
continue
|
||||
}
|
||||
var isDirectory: ObjCBool = false
|
||||
let filePath = storeURL.appendingPathComponent(entity.path!).path
|
||||
if fileManager.fileExists(atPath: filePath, isDirectory: &isDirectory) {
|
||||
if isDirectory.boolValue {
|
||||
entity.isDir = true
|
||||
let files = try fileManager.contentsOfDirectory(atPath: filePath)
|
||||
.filter { !$0.hasPrefix(".") }
|
||||
.map { filename -> PasswordEntity in
|
||||
let passwordEntity = NSEntityDescription.insertNewObject(forEntityName: "PasswordEntity", into: context) as! PasswordEntity
|
||||
if filename.hasSuffix(".gpg") {
|
||||
passwordEntity.name = String(filename.prefix(upTo: filename.index(filename.endIndex, offsetBy: -4)))
|
||||
} else {
|
||||
passwordEntity.name = filename
|
||||
}
|
||||
passwordEntity.path = "\(entity.path!)/\(filename)"
|
||||
passwordEntity.parent = entity
|
||||
return passwordEntity
|
||||
}
|
||||
entities += files
|
||||
} else {
|
||||
entity.isDir = false
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
private func initPasswordEntityCoreData() {
|
||||
PasswordEntity.initPasswordEntityCoreData(url: storeURL, in: context)
|
||||
saveUpdatedContext()
|
||||
}
|
||||
|
||||
|
|
@ -301,65 +219,31 @@ public class PasswordStore {
|
|||
}
|
||||
|
||||
public func fetchPasswordEntityCoreData(parent: PasswordEntity?) -> [PasswordEntity] {
|
||||
let passwordEntityFetch = NSFetchRequest<NSFetchRequestResult>(entityName: "PasswordEntity")
|
||||
do {
|
||||
passwordEntityFetch.predicate = NSPredicate(format: "parent = %@", parent ?? 0)
|
||||
let fetchedPasswordEntities = try context.fetch(passwordEntityFetch) as! [PasswordEntity]
|
||||
return fetchedPasswordEntities.sorted { $0.name!.caseInsensitiveCompare($1.name!) == .orderedAscending }
|
||||
} catch {
|
||||
fatalError("FailedToFetchPasswords".localize(error))
|
||||
}
|
||||
PasswordEntity.fetch(by: parent, in: context)
|
||||
}
|
||||
|
||||
public func fetchPasswordEntityCoreData(withDir: Bool) -> [PasswordEntity] {
|
||||
let passwordEntityFetch = NSFetchRequest<NSFetchRequestResult>(entityName: "PasswordEntity")
|
||||
do {
|
||||
if !withDir {
|
||||
passwordEntityFetch.predicate = NSPredicate(format: "isDir = false")
|
||||
}
|
||||
let fetchedPasswordEntities = try context.fetch(passwordEntityFetch) as! [PasswordEntity]
|
||||
return fetchedPasswordEntities.sorted { $0.name!.caseInsensitiveCompare($1.name!) == .orderedAscending }
|
||||
} catch {
|
||||
fatalError("FailedToFetchPasswords".localize(error))
|
||||
}
|
||||
public func fetchPasswordEntityCoreData(withDir _: Bool) -> [PasswordEntity] {
|
||||
PasswordEntity.fetchAllPassword(in: context)
|
||||
}
|
||||
|
||||
public func fetchUnsyncedPasswords() -> [PasswordEntity] {
|
||||
let passwordEntityFetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "PasswordEntity")
|
||||
passwordEntityFetchRequest.predicate = NSPredicate(format: "synced = %i", 0)
|
||||
do {
|
||||
return try context.fetch(passwordEntityFetchRequest) as! [PasswordEntity]
|
||||
} catch {
|
||||
fatalError("FailedToFetchPasswords".localize(error))
|
||||
}
|
||||
PasswordEntity.fetchUnsynced(in: context)
|
||||
}
|
||||
|
||||
public func fetchPasswordEntity(with path: String) -> PasswordEntity? {
|
||||
let passwordEntityFetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "PasswordEntity")
|
||||
passwordEntityFetchRequest.predicate = NSPredicate(format: "path = %@", path)
|
||||
do {
|
||||
let passwordEntities = try context.fetch(passwordEntityFetchRequest) as! [PasswordEntity]
|
||||
return passwordEntities.first
|
||||
} catch {
|
||||
fatalError("FailedToFetchPasswords".localize(error))
|
||||
}
|
||||
PasswordEntity.fetch(by: path, in: context)
|
||||
}
|
||||
|
||||
public func setAllSynced() {
|
||||
let passwordEntities = fetchUnsyncedPasswords()
|
||||
if !passwordEntities.isEmpty {
|
||||
for passwordEntity in passwordEntities {
|
||||
passwordEntity.synced = true
|
||||
}
|
||||
saveUpdatedContext()
|
||||
}
|
||||
_ = PasswordEntity.updateAllToSynced(in: context)
|
||||
saveUpdatedContext()
|
||||
}
|
||||
|
||||
public func getLatestUpdateInfo(filename: String) -> String {
|
||||
public func getLatestUpdateInfo(path: String) -> String {
|
||||
guard let storeRepository else {
|
||||
return "Unknown".localize()
|
||||
}
|
||||
guard let blameHunks = try? storeRepository.blame(withFile: filename, options: nil).hunks else {
|
||||
guard let blameHunks = try? storeRepository.blame(withFile: path, options: nil).hunks else {
|
||||
return "Unknown".localize()
|
||||
}
|
||||
guard let latestCommitTime = blameHunks.map({ $0.finalSignature?.time?.timeIntervalSince1970 ?? 0 }).max() else {
|
||||
|
|
@ -393,18 +277,16 @@ public class PasswordStore {
|
|||
}
|
||||
|
||||
private func deleteDirectoryTree(at url: URL) throws {
|
||||
var tempURL = storeURL.appendingPathComponent(url.deletingLastPathComponent().path)
|
||||
var count = try fileManager.contentsOfDirectory(atPath: tempURL.path).count
|
||||
while count == 0 {
|
||||
var tempURL = url.deletingLastPathComponent()
|
||||
while try fileManager.contentsOfDirectory(atPath: tempURL.path).isEmpty {
|
||||
try fileManager.removeItem(at: tempURL)
|
||||
tempURL.deleteLastPathComponent()
|
||||
count = try fileManager.contentsOfDirectory(atPath: tempURL.path).count
|
||||
}
|
||||
}
|
||||
|
||||
private func createDirectoryTree(at url: URL) throws {
|
||||
let tempURL = storeURL.appendingPathComponent(url.deletingLastPathComponent().path)
|
||||
try fileManager.createDirectory(at: tempURL, withIntermediateDirectories: true, attributes: nil)
|
||||
let tempURL = url.deletingLastPathComponent()
|
||||
try fileManager.createDirectory(at: tempURL, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
private func gitMv(from: String, to: String) throws {
|
||||
|
|
@ -460,148 +342,99 @@ public class PasswordStore {
|
|||
throw AppError.passwordDuplicated
|
||||
}
|
||||
|
||||
var passwordURL = password.url
|
||||
var previousPathLength = Int.max
|
||||
var paths: [String] = []
|
||||
while passwordURL.path != "." {
|
||||
paths.append(passwordURL.path)
|
||||
passwordURL = passwordURL.deletingLastPathComponent()
|
||||
// better identify errors before saving a new password
|
||||
if passwordURL.path != ".", passwordURL.path.count >= previousPathLength {
|
||||
throw AppError.wrongPasswordFilename
|
||||
}
|
||||
previousPathLength = passwordURL.path.count
|
||||
}
|
||||
paths.reverse()
|
||||
var parentPasswordEntity: PasswordEntity?
|
||||
for path in paths {
|
||||
let isDir = !path.hasSuffix(".gpg")
|
||||
if let passwordEntity = getPasswordEntity(by: path, isDir: isDir) {
|
||||
passwordEntity.synced = false
|
||||
parentPasswordEntity = passwordEntity
|
||||
} else {
|
||||
let passwordEntity = NSEntityDescription.insertNewObject(forEntityName: "PasswordEntity", into: context) as! PasswordEntity
|
||||
let pathURL = URL(string: path.stringByAddingPercentEncodingForRFC3986()!)!
|
||||
if isDir {
|
||||
passwordEntity.name = pathURL.lastPathComponent
|
||||
} else {
|
||||
passwordEntity.name = pathURL.deletingPathExtension().lastPathComponent
|
||||
}
|
||||
passwordEntity.path = path
|
||||
passwordEntity.parent = parentPasswordEntity
|
||||
passwordEntity.synced = false
|
||||
passwordEntity.isDir = isDir
|
||||
parentPasswordEntity = passwordEntity
|
||||
}
|
||||
var path = password.path
|
||||
while !path.isEmpty {
|
||||
paths.append(path)
|
||||
path = (path as NSString).deletingLastPathComponent
|
||||
}
|
||||
|
||||
var parentPasswordEntity: PasswordEntity?
|
||||
for (index, path) in paths.reversed().enumerated() {
|
||||
if index == paths.count - 1 {
|
||||
let passwordEntity = PasswordEntity.insert(name: password.name, path: path, isDir: false, into: context)
|
||||
passwordEntity.parent = parentPasswordEntity
|
||||
parentPasswordEntity = passwordEntity
|
||||
} else {
|
||||
if let passwordEntity = PasswordEntity.fetch(by: path, isDir: true, in: context) {
|
||||
passwordEntity.isSynced = false
|
||||
parentPasswordEntity = passwordEntity
|
||||
} else {
|
||||
let name = (path as NSString).lastPathComponent
|
||||
let passwordEntity = PasswordEntity.insert(name: name, path: path, isDir: true, into: context)
|
||||
passwordEntity.parent = parentPasswordEntity
|
||||
parentPasswordEntity = passwordEntity
|
||||
}
|
||||
}
|
||||
}
|
||||
saveUpdatedContext()
|
||||
return parentPasswordEntity
|
||||
}
|
||||
|
||||
public func add(password: Password, keyID: String? = nil) throws -> PasswordEntity? {
|
||||
try createDirectoryTree(at: password.url)
|
||||
let saveURL = storeURL.appendingPathComponent(password.url.path)
|
||||
let saveURL = storeURL.appendingPathComponent(password.path)
|
||||
try createDirectoryTree(at: saveURL)
|
||||
try encrypt(password: password, keyID: keyID).write(to: saveURL)
|
||||
try gitAdd(path: password.url.path)
|
||||
_ = try gitCommit(message: "AddPassword.".localize(password.url.deletingPathExtension().path))
|
||||
try gitAdd(path: password.path)
|
||||
_ = try gitCommit(message: "AddPassword.".localize(password.path))
|
||||
let newPasswordEntity = try addPasswordEntities(password: password)
|
||||
NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil)
|
||||
return newPasswordEntity
|
||||
}
|
||||
|
||||
public func delete(passwordEntity: PasswordEntity) throws {
|
||||
let deletedFileURL = try passwordEntity.getURL()
|
||||
try gitRm(path: deletedFileURL.path)
|
||||
let deletedFileURL = storeURL.appendingPathComponent(passwordEntity.path)
|
||||
try gitRm(path: passwordEntity.path)
|
||||
try deletePasswordEntities(passwordEntity: passwordEntity)
|
||||
try deleteDirectoryTree(at: deletedFileURL)
|
||||
_ = try gitCommit(message: "RemovePassword.".localize(deletedFileURL.deletingPathExtension().path.removingPercentEncoding!))
|
||||
_ = try gitCommit(message: "RemovePassword.".localize(passwordEntity.path))
|
||||
NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil)
|
||||
}
|
||||
|
||||
public func edit(passwordEntity: PasswordEntity, password: Password, keyID: String? = nil) throws -> PasswordEntity? {
|
||||
var newPasswordEntity: PasswordEntity? = passwordEntity
|
||||
let url = try passwordEntity.getURL()
|
||||
let url = storeURL.appendingPathComponent(passwordEntity.path)
|
||||
|
||||
if password.changed & PasswordChange.content.rawValue != 0 {
|
||||
let saveURL = storeURL.appendingPathComponent(url.path)
|
||||
try encrypt(password: password, keyID: keyID).write(to: saveURL)
|
||||
try gitAdd(path: url.path)
|
||||
_ = try gitCommit(message: "EditPassword.".localize(url.deletingPathExtension().path.removingPercentEncoding!))
|
||||
try encrypt(password: password, keyID: keyID).write(to: url)
|
||||
try gitAdd(path: password.path)
|
||||
_ = try gitCommit(message: "EditPassword.".localize(passwordEntity.path))
|
||||
newPasswordEntity = passwordEntity
|
||||
newPasswordEntity?.synced = false
|
||||
saveUpdatedContext()
|
||||
newPasswordEntity?.isSynced = false
|
||||
}
|
||||
|
||||
if password.changed & PasswordChange.path.rawValue != 0 {
|
||||
let deletedFileURL = url
|
||||
// add
|
||||
try createDirectoryTree(at: password.url)
|
||||
let newFileURL = storeURL.appendingPathComponent(password.path)
|
||||
try createDirectoryTree(at: newFileURL)
|
||||
newPasswordEntity = try addPasswordEntities(password: password)
|
||||
|
||||
// mv
|
||||
try gitMv(from: deletedFileURL.path, to: password.url.path)
|
||||
try gitMv(from: passwordEntity.path, to: password.path)
|
||||
|
||||
// delete
|
||||
try deleteDirectoryTree(at: deletedFileURL)
|
||||
try deletePasswordEntities(passwordEntity: passwordEntity)
|
||||
_ = try gitCommit(message: "RenamePassword.".localize(deletedFileURL.deletingPathExtension().path.removingPercentEncoding!, password.url.deletingPathExtension().path.removingPercentEncoding!))
|
||||
_ = try gitCommit(message: "RenamePassword.".localize(passwordEntity.path, password.path))
|
||||
}
|
||||
saveUpdatedContext()
|
||||
NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil)
|
||||
return newPasswordEntity
|
||||
}
|
||||
|
||||
private func deletePasswordEntities(passwordEntity: PasswordEntity) throws {
|
||||
var current: PasswordEntity? = passwordEntity
|
||||
// swiftformat:disable:next isEmpty
|
||||
while current != nil, current!.children!.count == 0 || !current!.isDir {
|
||||
let parent = current!.parent
|
||||
context.delete(current!)
|
||||
current = parent
|
||||
}
|
||||
PasswordEntity.deleteRecursively(entity: passwordEntity, in: context)
|
||||
saveUpdatedContext()
|
||||
}
|
||||
|
||||
public func saveUpdatedContext() {
|
||||
do {
|
||||
if context.hasChanges {
|
||||
try context.save()
|
||||
}
|
||||
} catch {
|
||||
fatalError("FailureToSaveContext".localize(error))
|
||||
}
|
||||
PersistenceController.shared.save()
|
||||
}
|
||||
|
||||
public func deleteCoreData(entityName: String) {
|
||||
let deleteFetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: entityName)
|
||||
let deleteRequest = NSBatchDeleteRequest(fetchRequest: deleteFetchRequest)
|
||||
|
||||
do {
|
||||
try context.execute(deleteRequest)
|
||||
try context.save()
|
||||
context.reset()
|
||||
} catch let error as NSError {
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
|
||||
public func updateImage(passwordEntity: PasswordEntity, image: Data?) {
|
||||
guard let image else {
|
||||
return
|
||||
}
|
||||
let privateMOC = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
||||
privateMOC.parent = context
|
||||
privateMOC.perform {
|
||||
passwordEntity.image = image
|
||||
do {
|
||||
try privateMOC.save()
|
||||
self.context.performAndWait {
|
||||
self.saveUpdatedContext()
|
||||
}
|
||||
} catch {
|
||||
fatalError("FailureToSaveContext".localize(error))
|
||||
}
|
||||
}
|
||||
public func deleteCoreData() {
|
||||
PasswordEntity.deleteAll(in: context)
|
||||
PersistenceController.shared.save()
|
||||
}
|
||||
|
||||
public func eraseStoreData() {
|
||||
|
|
@ -610,7 +443,7 @@ public class PasswordStore {
|
|||
try? fileManager.removeItem(at: tempStoreURL)
|
||||
|
||||
// Delete core data.
|
||||
deleteCoreData(entityName: "PasswordEntity")
|
||||
deleteCoreData()
|
||||
|
||||
// Clean up variables inside PasswordStore.
|
||||
storeRepository = nil
|
||||
|
|
@ -652,7 +485,8 @@ public class PasswordStore {
|
|||
}
|
||||
try storeRepository.reset(to: newHead, resetType: .hard)
|
||||
setAllSynced()
|
||||
updatePasswordEntityCoreData()
|
||||
deleteCoreData()
|
||||
initPasswordEntityCoreData()
|
||||
|
||||
NotificationCenter.default.post(name: .passwordStoreUpdated, object: nil)
|
||||
NotificationCenter.default.post(name: .passwordStoreChangeDiscarded, object: nil)
|
||||
|
|
@ -678,11 +512,11 @@ public class PasswordStore {
|
|||
}
|
||||
|
||||
public func decrypt(passwordEntity: PasswordEntity, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Password {
|
||||
let encryptedDataPath = storeURL.appendingPathComponent(passwordEntity.getPath())
|
||||
let encryptedData = try Data(contentsOf: encryptedDataPath)
|
||||
let url = storeURL.appendingPathComponent(passwordEntity.path)
|
||||
let encryptedData = try Data(contentsOf: url)
|
||||
let data: Data? = try {
|
||||
if Defaults.isEnableGPGIDOn {
|
||||
let keyID = keyID ?? findGPGID(from: encryptedDataPath)
|
||||
let keyID = keyID ?? findGPGID(from: url)
|
||||
return try PGPAgent.shared.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
|
||||
}
|
||||
return try PGPAgent.shared.decrypt(encryptedData: encryptedData, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
|
||||
|
|
@ -691,8 +525,7 @@ public class PasswordStore {
|
|||
throw AppError.decryption
|
||||
}
|
||||
let plainText = String(data: decryptedData, encoding: .utf8) ?? ""
|
||||
let url = try passwordEntity.getURL()
|
||||
return Password(name: passwordEntity.getName(), url: url, plainText: plainText)
|
||||
return Password(name: passwordEntity.name, path: passwordEntity.path, plainText: plainText)
|
||||
}
|
||||
|
||||
public func decrypt(path: String, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Password {
|
||||
|
|
@ -706,7 +539,7 @@ public class PasswordStore {
|
|||
}
|
||||
|
||||
public func encrypt(password: Password, keyID: String? = nil) throws -> Data {
|
||||
let encryptedDataPath = storeURL.appendingPathComponent(password.url.path)
|
||||
let encryptedDataPath = storeURL.appendingPathComponent(password.path)
|
||||
let keyID = keyID ?? findGPGID(from: encryptedDataPath)
|
||||
if Defaults.isEnableGPGIDOn {
|
||||
return try PGPAgent.shared.encrypt(plainData: password.plainData, keyID: keyID)
|
||||
|
|
|
|||
|
|
@ -12,19 +12,19 @@ public class PasswordTableEntry: NSObject {
|
|||
public let passwordEntity: PasswordEntity
|
||||
@objc public let title: String
|
||||
public let isDir: Bool
|
||||
public let synced: Bool
|
||||
public let isSynced: Bool
|
||||
public let categoryText: String
|
||||
|
||||
public init(_ entity: PasswordEntity) {
|
||||
self.passwordEntity = entity
|
||||
self.title = entity.name!
|
||||
self.title = entity.name
|
||||
self.isDir = entity.isDir
|
||||
self.synced = entity.synced
|
||||
self.categoryText = entity.getCategoryText()
|
||||
self.isSynced = entity.isSynced
|
||||
self.categoryText = entity.dirText
|
||||
}
|
||||
|
||||
public func matches(_ searchText: String) -> Bool {
|
||||
Self.match(nameWithCategory: passwordEntity.nameWithCategory, searchText: searchText)
|
||||
Self.match(nameWithCategory: passwordEntity.nameWithDir, searchText: searchText)
|
||||
}
|
||||
|
||||
public static func match(nameWithCategory: String, searchText: String) -> Bool {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="12141" systemVersion="16F73" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="PasswordEntity" representedClassName="PasswordEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="image" optional="YES" attributeType="Binary" allowsExternalBinaryDataStorage="YES" syncable="YES"/>
|
||||
<attribute name="isDir" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
|
||||
<attribute name="name" attributeType="String" syncable="YES"/>
|
||||
<attribute name="path" attributeType="String" syncable="YES"/>
|
||||
<attribute name="synced" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
|
||||
<relationship name="children" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PasswordEntity" inverseName="parent" inverseEntity="PasswordEntity" syncable="YES"/>
|
||||
<relationship name="parent" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PasswordEntity" inverseName="children" inverseEntity="PasswordEntity" syncable="YES"/>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23605" systemVersion="24B91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="PasswordEntity" representedClassName=".PasswordEntity" syncable="YES">
|
||||
<attribute name="image" optional="YES" attributeType="Binary" allowsExternalBinaryDataStorage="YES"/>
|
||||
<attribute name="isDir" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="isSynced" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||
<attribute name="name" attributeType="String"/>
|
||||
<attribute name="path" attributeType="String"/>
|
||||
<relationship name="children" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PasswordEntity" inverseName="parent" inverseEntity="PasswordEntity"/>
|
||||
<relationship name="parent" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PasswordEntity" inverseName="children" inverseEntity="PasswordEntity"/>
|
||||
</entity>
|
||||
<elements>
|
||||
<element name="PasswordEntity" positionX="36" positionY="81" width="128" height="150"/>
|
||||
</elements>
|
||||
</model>
|
||||
Loading…
Add table
Add a link
Reference in a new issue