Skip to content
63 changes: 60 additions & 3 deletions PotassiumProviderCore/FileProviderUninstall.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ public struct FileProviderUninstallDomainListingFailure: Equatable, Sendable {

public enum FileProviderUninstallStateItemKind: String, Equatable, Sendable {
case domainConfiguration
case relocationJournal
case sqliteRows
}

Expand Down Expand Up @@ -145,7 +146,9 @@ public struct FileProviderUninstallPlan: Equatable, Sendable {
public var registeredDomains: [FileProviderUninstallRegisteredDomain]
public var domainListingFailure: FileProviderUninstallDomainListingFailure?
public var storedConfigurations: [ProviderDomainConfiguration]
public var storedRelocationJournals: [ProviderDomainRelocationJournal]
public var storedAccounts: [ProviderAccount]
public var cleanupConfigurationIdentifiers: [String]
public var cleanupDomainIdentifiers: [String]
public var stateItems: [FileProviderUninstallStateItem]
public var conflictStaging: FileProviderUninstallConflictStagingPlan?
Expand All @@ -155,6 +158,7 @@ public struct FileProviderUninstallPlan: Equatable, Sendable {
registeredDomains: [FileProviderUninstallRegisteredDomain],
domainListingFailure: FileProviderUninstallDomainListingFailure? = nil,
storedConfigurations: [ProviderDomainConfiguration],
storedRelocationJournals: [ProviderDomainRelocationJournal] = [],
storedAccounts: [ProviderAccount],
stateItems: [FileProviderUninstallStateItem],
conflictStaging: FileProviderUninstallConflictStagingPlan?
Expand All @@ -163,9 +167,20 @@ public struct FileProviderUninstallPlan: Equatable, Sendable {
self.registeredDomains = registeredDomains.sorted { $0.identifier < $1.identifier }
self.domainListingFailure = domainListingFailure
self.storedConfigurations = storedConfigurations.sorted { $0.domainIdentifier < $1.domainIdentifier }
self.storedRelocationJournals = storedRelocationJournals.sorted {
$0.configurationIdentifier < $1.configurationIdentifier
}
self.storedAccounts = storedAccounts.sorted { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
self.cleanupConfigurationIdentifiers = Array(Set(
storedConfigurations.map(\.configurationIdentifier) +
storedRelocationJournals.map(\.configurationIdentifier)
)).sorted()
self.cleanupDomainIdentifiers = Array(Set(
registeredDomains.map(\.identifier) + storedConfigurations.map(\.domainIdentifier)
registeredDomains.map(\.identifier) +
storedConfigurations.map(\.domainIdentifier) +
storedRelocationJournals.flatMap {
[$0.sourceConfiguration.domainIdentifier, $0.targetDomainIdentifier].compactMap { $0 }
}
)).sorted()
self.stateItems = stateItems
self.conflictStaging = conflictStaging
Expand All @@ -178,6 +193,7 @@ public struct FileProviderUninstallPlan: Equatable, Sendable {
public var hasWork: Bool {
registeredDomains.isEmpty == false ||
storedConfigurations.isEmpty == false ||
storedRelocationJournals.isEmpty == false ||
(deletesOAuthToken && storedAccounts.isEmpty == false) ||
stateItems.isEmpty == false ||
conflictStaging != nil ||
Expand Down Expand Up @@ -248,14 +264,35 @@ public protocol FileProviderUninstallDomainManaging: Sendable {

public protocol FileProviderUninstallLocalStateManaging: Sendable {
func storedConfigurations() async throws -> [ProviderDomainConfiguration]
func storedRelocationJournals() async throws -> [ProviderDomainRelocationJournal]
func storedAccounts() async throws -> [ProviderAccount]
func stateItems(forDomainIdentifiers domainIdentifiers: Set<String>) async throws -> [FileProviderUninstallStateItem]
func stateItems(
forConfigurationIdentifiers configurationIdentifiers: Set<String>,
domainIdentifiers: Set<String>
) async throws -> [FileProviderUninstallStateItem]
func conflictStagingPlan(removeContents: Bool) async throws -> FileProviderUninstallConflictStagingPlan?
func removeLocalState(domainIdentifier: String) async throws
func removeConfiguration(configurationIdentifier: String) async throws
func removeRelocationJournal(configurationIdentifier: String) async throws
func removeAccountRecords(accountIdentifiers: [String]) async throws
func removeConflictStaging() async throws
}

public extension FileProviderUninstallLocalStateManaging {
func storedRelocationJournals() async throws -> [ProviderDomainRelocationJournal] { [] }

func stateItems(
forConfigurationIdentifiers configurationIdentifiers: Set<String>,
domainIdentifiers: Set<String>
) async throws -> [FileProviderUninstallStateItem] {
try await stateItems(forDomainIdentifiers: configurationIdentifiers.union(domainIdentifiers))
}

func removeConfiguration(configurationIdentifier _: String) async throws {}
func removeRelocationJournal(configurationIdentifier _: String) async throws {}
}

public protocol FileProviderUninstallTokenDeleting: Sendable {
func deleteToken(accountIdentifier: String) async throws
func deleteLegacyToken() async throws
Expand All @@ -278,6 +315,7 @@ public struct FileProviderUninstallCoordinator: Sendable {

public func makePlan(options: FileProviderUninstallOptions) async throws -> FileProviderUninstallPlan {
let storedConfigurations = try await localState.storedConfigurations()
let storedRelocationJournals = try await localState.storedRelocationJournals()
let storedAccounts = try await localState.storedAccounts()
let registeredDomains: [FileProviderUninstallRegisteredDomain]
let domainListingFailure: FileProviderUninstallDomainListingFailure?
Expand All @@ -290,15 +328,29 @@ public struct FileProviderUninstallCoordinator: Sendable {
domainListingFailure = FileProviderUninstallDomainListingFailure(error: error)
}

let cleanupDomainIdentifiers = Set(registeredDomains.map(\.identifier) + storedConfigurations.map(\.domainIdentifier))
let stateItems = try await localState.stateItems(forDomainIdentifiers: cleanupDomainIdentifiers)
let cleanupConfigurationIdentifiers = Set(
storedConfigurations.map(\.configurationIdentifier) +
storedRelocationJournals.map(\.configurationIdentifier)
)
let cleanupDomainIdentifiers = Set(
registeredDomains.map(\.identifier) +
storedConfigurations.map(\.domainIdentifier) +
storedRelocationJournals.flatMap {
[$0.sourceConfiguration.domainIdentifier, $0.targetDomainIdentifier].compactMap { $0 }
}
)
let stateItems = try await localState.stateItems(
forConfigurationIdentifiers: cleanupConfigurationIdentifiers,
domainIdentifiers: cleanupDomainIdentifiers
)
let conflictStaging = try await localState.conflictStagingPlan(removeContents: options.removesConflictStaging)

return FileProviderUninstallPlan(
options: options,
registeredDomains: registeredDomains,
domainListingFailure: domainListingFailure,
storedConfigurations: storedConfigurations,
storedRelocationJournals: storedRelocationJournals,
storedAccounts: storedAccounts,
stateItems: stateItems,
conflictStaging: conflictStaging
Expand Down Expand Up @@ -349,6 +401,11 @@ public struct FileProviderUninstallCoordinator: Sendable {
removedLocalStateDomainIdentifiers.append(domainIdentifier)
}

for configurationIdentifier in plan.cleanupConfigurationIdentifiers {
try await localState.removeConfiguration(configurationIdentifier: configurationIdentifier)
try await localState.removeRelocationJournal(configurationIdentifier: configurationIdentifier)
}

var removedConflictStaging = false
if plan.options.removesConflictStaging {
try await localState.removeConflictStaging()
Expand Down
97 changes: 88 additions & 9 deletions PotassiumProviderCore/ProviderDomainConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,41 +138,84 @@ public enum ProviderAccountStoreError: Error, Equatable, LocalizedError, Sendabl
}
}

public enum ProviderDomainStorageLocation: Codable, Equatable, Sendable {
case onThisMac
case externalVolume(uuid: UUID, displayName: String)

public static func externalVolume(volumeUUID: UUID, displayName: String) -> Self {
.externalVolume(uuid: volumeUUID, displayName: displayName)
}
}

public struct ProviderDomainConfiguration: Codable, Equatable, Identifiable, Sendable {
public var id: String { domainIdentifier }
public var id: String { configurationIdentifier }

public let domainIdentifier: String
public let configurationIdentifier: String
public var domainIdentifier: String
public var accountIdentifier: String
public var displayName: String
public var driveID: Int
public var driveName: String
public var rootFileID: Int
public var knownFolderLayout: ProviderKnownFolderLayout
public var storageLocation: ProviderDomainStorageLocation
public var createdAt: Date
public var updatedAt: Date

public init(
domainIdentifier: String = UUID().uuidString,
configurationIdentifier: String? = nil,
accountIdentifier: String = ProviderConstants.legacyAccountIdentifier,
displayName: String,
driveID: Int,
driveName: String,
rootFileID: Int = ProviderConstants.defaultRootFileID,
knownFolderLayout: ProviderKnownFolderLayout = .machineNamespace,
storageLocation: ProviderDomainStorageLocation = .onThisMac,
createdAt: Date = Date(),
updatedAt: Date = Date()
) {
self.configurationIdentifier = configurationIdentifier ?? domainIdentifier
self.domainIdentifier = domainIdentifier
self.accountIdentifier = accountIdentifier
self.displayName = displayName
self.driveID = driveID
self.driveName = driveName
self.rootFileID = rootFileID
self.knownFolderLayout = knownFolderLayout
self.storageLocation = storageLocation
self.createdAt = createdAt
self.updatedAt = updatedAt
}

public init(
configurationIdentifier: String,
domainIdentifier: String,
accountIdentifier: String = ProviderConstants.legacyAccountIdentifier,
displayName: String,
driveID: Int,
driveName: String,
rootFileID: Int = ProviderConstants.defaultRootFileID,
knownFolderLayout: ProviderKnownFolderLayout = .machineNamespace,
storageLocation: ProviderDomainStorageLocation = .onThisMac,
createdAt: Date = Date(),
updatedAt: Date = Date()
) {
self.init(
domainIdentifier: domainIdentifier,
configurationIdentifier: configurationIdentifier,
accountIdentifier: accountIdentifier,
displayName: displayName,
driveID: driveID,
driveName: driveName,
rootFileID: rootFileID,
knownFolderLayout: knownFolderLayout,
storageLocation: storageLocation,
createdAt: createdAt,
updatedAt: updatedAt
)
}

public static func finderDisplayName(forDriveName driveName: String) -> String {
let trimmedDriveName = driveName.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedDriveName.isEmpty ? "kDrive" : trimmedDriveName
Expand All @@ -191,20 +234,24 @@ public struct ProviderDomainConfiguration: Codable, Equatable, Identifiable, Sen
}

private enum CodingKeys: String, CodingKey {
case configurationIdentifier
case domainIdentifier
case accountIdentifier
case displayName
case driveID
case driveName
case rootFileID
case knownFolderLayout
case storageLocation
case createdAt
case updatedAt
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
domainIdentifier = try container.decode(String.self, forKey: .domainIdentifier)
configurationIdentifier = try container.decodeIfPresent(String.self, forKey: .configurationIdentifier)
?? domainIdentifier
accountIdentifier = try container.decodeIfPresent(String.self, forKey: .accountIdentifier)
?? ProviderConstants.legacyAccountIdentifier
displayName = try container.decode(String.self, forKey: .displayName)
Expand All @@ -216,18 +263,41 @@ public struct ProviderDomainConfiguration: Codable, Equatable, Identifiable, Sen
ProviderKnownFolderLayout.self,
forKey: .knownFolderLayout
) ?? .legacyPrivate
storageLocation = try container.decodeIfPresent(
ProviderDomainStorageLocation.self,
forKey: .storageLocation
) ?? .onThisMac
createdAt = try container.decode(Date.self, forKey: .createdAt)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
}
}

public protocol DomainConfigurationStoring: Sendable {
func allConfigurations() async throws -> [ProviderDomainConfiguration]
func configuration(configurationIdentifier: String) async throws -> ProviderDomainConfiguration?
func configuration(domainIdentifier: String) async throws -> ProviderDomainConfiguration?
func save(_ configuration: ProviderDomainConfiguration) async throws
func remove(configurationIdentifier: String) async throws
func remove(domainIdentifier: String) async throws
}

public extension DomainConfigurationStoring {
func configuration(configurationIdentifier: String) async throws -> ProviderDomainConfiguration? {
try await allConfigurations().first {
$0.configurationIdentifier == configurationIdentifier
}
}

func remove(configurationIdentifier: String) async throws {
guard let configuration = try await configuration(
configurationIdentifier: configurationIdentifier
) else {
return
}
try await remove(domainIdentifier: configuration.domainIdentifier)
}
}

public actor DomainConfigurationFileStore: DomainConfigurationStoring {
private let directoryURL: URL
private let encoder: JSONEncoder
Expand Down Expand Up @@ -263,30 +333,39 @@ public actor DomainConfigurationFileStore: DomainConfigurationStoring {
.sorted { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
}

public func configuration(domainIdentifier: String) throws -> ProviderDomainConfiguration? {
let url = fileURL(for: domainIdentifier)
public func configuration(configurationIdentifier: String) throws -> ProviderDomainConfiguration? {
let url = fileURL(for: configurationIdentifier)
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
return try decoder.decode(ProviderDomainConfiguration.self, from: Data(contentsOf: url))
}

public func configuration(domainIdentifier: String) throws -> ProviderDomainConfiguration? {
try allConfigurations().first { $0.domainIdentifier == domainIdentifier }
}

public func save(_ configuration: ProviderDomainConfiguration) throws {
try ensureDirectoryExists()
let data = try encoder.encode(configuration)
try data.write(to: fileURL(for: configuration.domainIdentifier), options: [.atomic])
try data.write(to: fileURL(for: configuration.configurationIdentifier), options: [.atomic])
}

public func remove(domainIdentifier: String) throws {
let url = fileURL(for: domainIdentifier)
public func remove(configurationIdentifier: String) throws {
let url = fileURL(for: configurationIdentifier)
guard FileManager.default.fileExists(atPath: url.path) else { return }
try FileManager.default.removeItem(at: url)
}

public func remove(domainIdentifier: String) throws {
guard let configuration = try configuration(domainIdentifier: domainIdentifier) else { return }
try remove(configurationIdentifier: configuration.configurationIdentifier)
}

private func ensureDirectoryExists() throws {
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
}

private func fileURL(for domainIdentifier: String) -> URL {
directoryURL.appendingPathComponent(Self.safeFileName(for: domainIdentifier)).appendingPathExtension("json")
private func fileURL(for configurationIdentifier: String) -> URL {
directoryURL.appendingPathComponent(Self.safeFileName(for: configurationIdentifier)).appendingPathExtension("json")
}

private static func safeFileName(for value: String) -> String {
Expand Down
Loading
Loading