From 29b2809f24bdd2264ece528651c8c7921c9e9ba8 Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sun, 19 Jul 2026 09:06:35 +0200 Subject: [PATCH 1/9] feat: model file provider storage locations --- .../ProviderDomainConfiguration.swift | 97 ++++++++++- .../ProviderDomainRelocation.swift | 137 +++++++++++++++ .../PotassiumProviderAppModel.swift | 49 +++--- potassiumProvider/ProviderStatusView.swift | 6 +- ...iderDomainConfigurationIdentityTests.swift | 164 ++++++++++++++++++ ...ProviderDomainRelocationJournalTests.swift | 47 +++++ 6 files changed, 467 insertions(+), 33 deletions(-) create mode 100644 PotassiumProviderCore/ProviderDomainRelocation.swift create mode 100644 potassiumProviderTests/ProviderDomainConfigurationIdentityTests.swift create mode 100644 potassiumProviderTests/ProviderDomainRelocationJournalTests.swift diff --git a/PotassiumProviderCore/ProviderDomainConfiguration.swift b/PotassiumProviderCore/ProviderDomainConfiguration.swift index 2d74144..922bb55 100644 --- a/PotassiumProviderCore/ProviderDomainConfiguration.swift +++ b/PotassiumProviderCore/ProviderDomainConfiguration.swift @@ -138,30 +138,44 @@ 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 @@ -169,10 +183,39 @@ public struct ProviderDomainConfiguration: Codable, Equatable, Identifiable, Sen 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 @@ -191,6 +234,7 @@ public struct ProviderDomainConfiguration: Codable, Equatable, Identifiable, Sen } private enum CodingKeys: String, CodingKey { + case configurationIdentifier case domainIdentifier case accountIdentifier case displayName @@ -198,6 +242,7 @@ public struct ProviderDomainConfiguration: Codable, Equatable, Identifiable, Sen case driveName case rootFileID case knownFolderLayout + case storageLocation case createdAt case updatedAt } @@ -205,6 +250,8 @@ public struct ProviderDomainConfiguration: Codable, Equatable, Identifiable, Sen 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) @@ -216,6 +263,10 @@ 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) } @@ -223,11 +274,30 @@ public struct ProviderDomainConfiguration: Codable, Equatable, Identifiable, Sen 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 @@ -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 { diff --git a/PotassiumProviderCore/ProviderDomainRelocation.swift b/PotassiumProviderCore/ProviderDomainRelocation.swift new file mode 100644 index 0000000..44d1ff9 --- /dev/null +++ b/PotassiumProviderCore/ProviderDomainRelocation.swift @@ -0,0 +1,137 @@ +import Foundation + +/// Durable progress for a remove-and-recreate File Provider storage transition. +/// +/// File Provider does not provide an in-place domain move API. Keeping this journal in +/// the app group lets the containing app distinguish an unavailable external volume +/// from an interrupted transition and offer a deterministic repair path after relaunch. +public struct ProviderDomainRelocationJournal: Codable, Equatable, Identifiable, Sendable { + public var id: String { configurationIdentifier } + + public let configurationIdentifier: String + public var sourceConfiguration: ProviderDomainConfiguration + public var targetStorageLocation: ProviderDomainStorageLocation + public var targetDomainIdentifier: String? + public var knownFoldersWereActive: Bool + public var phase: ProviderDomainRelocationPhase + public var createdAt: Date + public var updatedAt: Date + + public init( + configurationIdentifier: String, + sourceConfiguration: ProviderDomainConfiguration, + targetStorageLocation: ProviderDomainStorageLocation, + targetDomainIdentifier: String? = nil, + knownFoldersWereActive: Bool, + phase: ProviderDomainRelocationPhase = .preparing, + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.configurationIdentifier = configurationIdentifier + self.sourceConfiguration = sourceConfiguration + self.targetStorageLocation = targetStorageLocation + self.targetDomainIdentifier = targetDomainIdentifier + self.knownFoldersWereActive = knownFoldersWereActive + self.phase = phase + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +public enum ProviderDomainRelocationPhase: String, Codable, Equatable, Sendable { + case preparing + case knownFoldersReleased + case sourceRemoved + case targetConfigurationSaved + case targetRegistered + case knownFolderReclaimRequired + case needsRepair +} + +public protocol ProviderDomainRelocationJournaling: Sendable { + func allJournals() async throws -> [ProviderDomainRelocationJournal] + func journal(configurationIdentifier: String) async throws -> ProviderDomainRelocationJournal? + func save(_ journal: ProviderDomainRelocationJournal) async throws + func remove(configurationIdentifier: String) async throws +} + +public actor ProviderDomainRelocationFileStore: ProviderDomainRelocationJournaling { + private let directoryURL: URL + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + public init(directoryURL: URL) { + self.directoryURL = directoryURL + self.encoder = JSONEncoder() + self.decoder = JSONDecoder() + self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + self.encoder.dateEncodingStrategy = .iso8601 + self.decoder.dateDecodingStrategy = .iso8601 + } + + public init(appGroupIdentifier: String = ProviderConstants.appGroupIdentifier) throws { + guard let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) else { + throw ProviderDomainRelocationStoreError.missingAppGroupContainer(appGroupIdentifier) + } + self.init(directoryURL: containerURL.appendingPathComponent("DomainRelocations", isDirectory: true)) + } + + public func allJournals() throws -> [ProviderDomainRelocationJournal] { + try ensureDirectoryExists() + return try FileManager.default.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + .filter { $0.pathExtension == "json" } + .map { try decoder.decode(ProviderDomainRelocationJournal.self, from: Data(contentsOf: $0)) } + .sorted { $0.createdAt < $1.createdAt } + } + + public func journal(configurationIdentifier: String) throws -> ProviderDomainRelocationJournal? { + let url = fileURL(for: configurationIdentifier) + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + return try decoder.decode(ProviderDomainRelocationJournal.self, from: Data(contentsOf: url)) + } + + public func save(_ journal: ProviderDomainRelocationJournal) throws { + try ensureDirectoryExists() + let data = try encoder.encode(journal) + try data.write(to: fileURL(for: journal.configurationIdentifier), options: [.atomic]) + } + + 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) + } + + private func ensureDirectoryExists() throws { + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + } + + private func fileURL(for configurationIdentifier: String) -> URL { + directoryURL + .appendingPathComponent(Self.safeFileName(for: configurationIdentifier)) + .appendingPathExtension("json") + } + + private static func safeFileName(for value: String) -> String { + value.map { character in + character.isLetter || character.isNumber || character == "-" || character == "_" ? character : "-" + }.reduce(into: "") { $0.append($1) } + } +} + +public enum ProviderDomainRelocationStoreError: Error, Equatable, LocalizedError, Sendable { + case missingAppGroupContainer(String) + + public var errorDescription: String? { + switch self { + case .missingAppGroupContainer(let identifier): + return "The shared app group container '\(identifier)' is not available." + } + } +} diff --git a/potassiumProvider/PotassiumProviderAppModel.swift b/potassiumProvider/PotassiumProviderAppModel.swift index 130373b..ecc0253 100644 --- a/potassiumProvider/PotassiumProviderAppModel.swift +++ b/potassiumProvider/PotassiumProviderAppModel.swift @@ -29,8 +29,8 @@ final class PotassiumProviderAppModel: ObservableObject { @Published private(set) var domains: [ProviderDomainConfiguration] = [] @Published private(set) var isConnecting = false @Published private(set) var loadingDriveAccountIdentifiers: Set = [] - @Published private(set) var knownFolderSyncStatesByDomainIdentifier: [String: ProviderKnownFolderSyncState] = [:] - @Published private(set) var knownFolderTransitionDomainIdentifiers: Set = [] + @Published private(set) var knownFolderSyncStatesByConfigurationIdentifier: [String: ProviderKnownFolderSyncState] = [:] + @Published private(set) var domainTransitionConfigurationIdentifiers: Set = [] @Published private(set) var activeDriveActions: [ProviderDriveKey: ProviderDriveAction] = [:] @Published private(set) var isReloadingStoredState = false @Published private(set) var statusMessage: String? @@ -140,11 +140,11 @@ final class PotassiumProviderAppModel: ObservableObject { } func knownFolderSyncState(for configuration: ProviderDomainConfiguration) -> ProviderKnownFolderSyncState { - knownFolderSyncStatesByDomainIdentifier[configuration.domainIdentifier] ?? .unavailable + knownFolderSyncStatesByConfigurationIdentifier[configuration.configurationIdentifier] ?? .unavailable } func isChangingKnownFolderSync(for configuration: ProviderDomainConfiguration) -> Bool { - knownFolderTransitionDomainIdentifiers.contains(configuration.domainIdentifier) + domainTransitionConfigurationIdentifiers.contains(configuration.configurationIdentifier) } func knownFolderRemotePath(for configuration: ProviderDomainConfiguration) -> String { @@ -724,20 +724,20 @@ final class PotassiumProviderAppModel: ObservableObject { try await domainRegistrar.removeDomain(for: configuration) try await snapshotStore?.removeSnapshots(domainIdentifier: configuration.domainIdentifier) try await eventStore?.removeEvents(domainIdentifier: configuration.domainIdentifier) - try await domainStore.remove(domainIdentifier: configuration.domainIdentifier) - knownFolderSyncStatesByDomainIdentifier[configuration.domainIdentifier] = nil + try await domainStore.remove(configurationIdentifier: configuration.configurationIdentifier) + knownFolderSyncStatesByConfigurationIdentifier[configuration.configurationIdentifier] = nil } private func rollbackFailedDomainAddition(_ configuration: ProviderDomainConfiguration) async { try? await snapshotStore?.removeSnapshots(domainIdentifier: configuration.domainIdentifier) - try? await domainStore.remove(domainIdentifier: configuration.domainIdentifier) + try? await domainStore.remove(configurationIdentifier: configuration.configurationIdentifier) if let synchronizedState = try? await synchronizedDomainConfigurations() { domains = synchronizedState.configurations } else if let storedDomains = try? await domainStore.allConfigurations() { domains = storedDomains } else { - domains.removeAll { $0.domainIdentifier == configuration.domainIdentifier } + domains.removeAll { $0.configurationIdentifier == configuration.configurationIdentifier } } } @@ -783,18 +783,23 @@ final class PotassiumProviderAppModel: ObservableObject { ) -> Bool { let key = driveKey(for: configuration) guard beginDriveAction(action, for: key) else { return false } - knownFolderTransitionDomainIdentifiers.insert(configuration.domainIdentifier) + guard domainTransitionConfigurationIdentifiers.insert( + configuration.configurationIdentifier + ).inserted else { + endDriveAction(for: key) + return false + } return true } private func endKnownFolderTransition(for configuration: ProviderDomainConfiguration) { - knownFolderTransitionDomainIdentifiers.remove(configuration.domainIdentifier) + domainTransitionConfigurationIdentifiers.remove(configuration.configurationIdentifier) endDriveAction(for: driveKey(for: configuration)) } private func replaceDomainConfiguration(_ configuration: ProviderDomainConfiguration) { guard let index = domains.firstIndex(where: { - $0.domainIdentifier == configuration.domainIdentifier + $0.configurationIdentifier == configuration.configurationIdentifier }) else { return } @@ -803,9 +808,9 @@ final class PotassiumProviderAppModel: ObservableObject { private func refreshKnownFolderSyncStates() async throws { let systemStates = try await domainRegistrar.knownFolderSyncStates() - knownFolderSyncStatesByDomainIdentifier = Dictionary(uniqueKeysWithValues: domains.map { configuration in + knownFolderSyncStatesByConfigurationIdentifier = Dictionary(uniqueKeysWithValues: domains.map { configuration in ( - configuration.domainIdentifier, + configuration.configurationIdentifier, systemStates[configuration.domainIdentifier] ?? .unavailable ) }) @@ -956,7 +961,7 @@ final class PotassiumProviderAppModel: ObservableObject { var registrationError: Error? for index in configurations.indices { - let desiredDisplayName = displayNames[configurations[index].domainIdentifier] ?? configurations[index].displayName + let desiredDisplayName = displayNames[configurations[index].configurationIdentifier] ?? configurations[index].displayName if configurations[index].displayName != desiredDisplayName { configurations[index].displayName = desiredDisplayName configurations[index].updatedAt = Date() @@ -983,23 +988,23 @@ final class PotassiumProviderAppModel: ObservableObject { accounts: [String: ProviderAccount] ) -> [String: String] { let baseNames = Dictionary(uniqueKeysWithValues: configurations.map { - ($0.domainIdentifier, ProviderDomainConfiguration.finderDisplayName(forDriveName: $0.driveName)) + ($0.configurationIdentifier, ProviderDomainConfiguration.finderDisplayName(forDriveName: $0.driveName)) }) let groupedByBaseName = Dictionary(grouping: configurations) { configuration in - baseNames[configuration.domainIdentifier]?.localizedLowercase ?? configuration.driveName.localizedLowercase + baseNames[configuration.configurationIdentifier]?.localizedLowercase ?? configuration.driveName.localizedLowercase } var names: [String: String] = [:] for (_, group) in groupedByBaseName { if group.count == 1, let configuration = group.first { - names[configuration.domainIdentifier] = baseNames[configuration.domainIdentifier] + names[configuration.configurationIdentifier] = baseNames[configuration.configurationIdentifier] continue } for configuration in group { - let baseName = baseNames[configuration.domainIdentifier] ?? "kDrive" + let baseName = baseNames[configuration.configurationIdentifier] ?? "kDrive" let accountName = accounts[configuration.accountIdentifier]?.displayName ?? "Account" - names[configuration.domainIdentifier] = "\(baseName) (\(accountName))" + names[configuration.configurationIdentifier] = "\(baseName) (\(accountName))" } } @@ -1014,14 +1019,14 @@ final class PotassiumProviderAppModel: ObservableObject { suffix: (ProviderDomainConfiguration) -> String ) -> [String: String] { let groupedNames = Dictionary(grouping: configurations) { configuration in - names[configuration.domainIdentifier] ?? configuration.displayName + names[configuration.configurationIdentifier] ?? configuration.displayName } var updatedNames = names for (_, group) in groupedNames where group.count > 1 { for configuration in group { - let currentName = updatedNames[configuration.domainIdentifier] ?? configuration.displayName - updatedNames[configuration.domainIdentifier] = currentName + suffix(configuration) + let currentName = updatedNames[configuration.configurationIdentifier] ?? configuration.displayName + updatedNames[configuration.configurationIdentifier] = currentName + suffix(configuration) } } diff --git a/potassiumProvider/ProviderStatusView.swift b/potassiumProvider/ProviderStatusView.swift index 8468a50..192a0f4 100644 --- a/potassiumProvider/ProviderStatusView.swift +++ b/potassiumProvider/ProviderStatusView.swift @@ -92,7 +92,7 @@ struct ProviderStatusView: View { private var statusRefreshID: String { var parts: [String] = [] parts.append(appModel.accounts.map { "\($0.accountIdentifier):\($0.displayName):\($0.authenticationKind.rawValue)" }.joined(separator: ",")) - parts.append(appModel.domains.map { "\($0.domainIdentifier):\($0.accountIdentifier):\($0.driveID):\($0.displayName):\($0.driveName)" }.joined(separator: ",")) + parts.append(appModel.domains.map { "\($0.configurationIdentifier):\($0.domainIdentifier):\($0.accountIdentifier):\($0.driveID):\($0.displayName):\($0.driveName)" }.joined(separator: ",")) parts.append(appModel.loadingDriveAccountIdentifiers.sorted().joined(separator: ",")) parts.append(appModel.drivesByAccountIdentifier.keys.sorted().map { accountIdentifier in let drives = appModel.drivesByAccountIdentifier[accountIdentifier, default: []] @@ -253,6 +253,7 @@ struct ProviderStatusDashboard: Equatable { let eventStats = eventStatisticsByDomain[domain.domainIdentifier] ?? KDriveProviderEventDomainStatistics(domainIdentifier: domain.domainIdentifier) let accountName = accountsByIdentifier[domain.accountIdentifier]?.displayName ?? "Account" return ProviderStatusDrive( + configurationIdentifier: domain.configurationIdentifier, domainIdentifier: domain.domainIdentifier, accountIdentifier: domain.accountIdentifier, accountName: accountName, @@ -347,8 +348,9 @@ struct ProviderStatusAccount: Equatable, Identifiable { } struct ProviderStatusDrive: Equatable, Identifiable { - var id: String { domainIdentifier } + var id: String { configurationIdentifier } + let configurationIdentifier: String let domainIdentifier: String let accountIdentifier: String let accountName: String diff --git a/potassiumProviderTests/ProviderDomainConfigurationIdentityTests.swift b/potassiumProviderTests/ProviderDomainConfigurationIdentityTests.swift new file mode 100644 index 0000000..11c4539 --- /dev/null +++ b/potassiumProviderTests/ProviderDomainConfigurationIdentityTests.swift @@ -0,0 +1,164 @@ +import Foundation +import PotassiumProviderCore +import Testing + +@Suite(.serialized) +struct ProviderDomainConfigurationIdentityTests { + @Test func legacyConfigurationUsesDomainIdentifierAsStableIdentityAndMacStorage() throws { + let json = """ + { + "domainIdentifier": "legacy-domain", + "accountIdentifier": "account-1", + "displayName": "Work Drive", + "driveID": 42, + "driveName": "Work Drive", + "rootFileID": 1, + "createdAt": "1970-01-01T00:16:40Z", + "updatedAt": "1970-01-01T00:16:40Z" + } + """ + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let configuration = try decoder.decode( + ProviderDomainConfiguration.self, + from: Data(json.utf8) + ) + + #expect(configuration.configurationIdentifier == "legacy-domain") + #expect(configuration.domainIdentifier == "legacy-domain") + #expect(configuration.id == "legacy-domain") + #expect(configuration.storageLocation == .onThisMac) + } + + @Test func storeKeepsFilenameAndIdentityStableWhenDomainIdentifierChanges() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let store = DomainConfigurationFileStore(directoryURL: directory) + let volumeUUID = UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")! + var configuration = ProviderDomainConfiguration( + domainIdentifier: "initial-domain", + configurationIdentifier: "configuration/1", + accountIdentifier: "account-1", + displayName: "Work Drive", + driveID: 42, + driveName: "Work Drive", + storageLocation: .externalVolume(uuid: volumeUUID, displayName: "External SSD"), + createdAt: Date(timeIntervalSince1970: 1_000), + updatedAt: Date(timeIntervalSince1970: 1_000) + ) + + try await store.save(configuration) + #expect(try jsonFileNames(in: directory) == ["configuration-1.json"]) + + configuration.domainIdentifier = "replacement-domain" + configuration.updatedAt = Date(timeIntervalSince1970: 2_000) + try await store.save(configuration) + + #expect(try jsonFileNames(in: directory) == ["configuration-1.json"]) + #expect(try await store.configuration( + configurationIdentifier: "configuration/1" + ) == configuration) + #expect(try await store.configuration(domainIdentifier: "initial-domain") == nil) + #expect(try await store.configuration(domainIdentifier: "replacement-domain") == configuration) + #expect(configuration.id == "configuration/1") + #expect(configuration.storageLocation == .externalVolume( + uuid: volumeUUID, + displayName: "External SSD" + )) + + try await store.remove(domainIdentifier: "replacement-domain") + #expect(try jsonFileNames(in: directory).isEmpty) + } + + @Test func storeCanRemoveConfigurationByStableIdentifier() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let store = DomainConfigurationFileStore(directoryURL: directory) + let configuration = ProviderDomainConfiguration( + domainIdentifier: "generated-domain", + configurationIdentifier: "stable-configuration", + displayName: "Work Drive", + driveID: 42, + driveName: "Work Drive", + createdAt: Date(timeIntervalSince1970: 1_000), + updatedAt: Date(timeIntervalSince1970: 1_000) + ) + try await store.save(configuration) + + #expect(try await store.configuration( + configurationIdentifier: "stable-configuration" + ) == configuration) + + try await store.remove(configurationIdentifier: "stable-configuration") + + #expect(try await store.configuration( + configurationIdentifier: "stable-configuration" + ) == nil) + #expect(try await store.configuration(domainIdentifier: "generated-domain") == nil) + } + + @Test func protocolDefaultsPreserveExistingDomainIdentifierBasedStores() async throws { + let configuration = ProviderDomainConfiguration( + domainIdentifier: "generated-domain", + configurationIdentifier: "stable-configuration", + displayName: "Work Drive", + driveID: 42, + driveName: "Work Drive" + ) + let store = DomainIdentifierBasedConfigurationStore(configuration: configuration) + + #expect(try await store.configuration( + configurationIdentifier: "stable-configuration" + ) == configuration) + + try await store.remove(configurationIdentifier: "stable-configuration") + + #expect(await store.allConfigurations().isEmpty) + } + + private func temporaryDirectory() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("provider-domain-identity-\(UUID().uuidString)", isDirectory: true) + } + + private func jsonFileNames(in directory: URL) throws -> [String] { + try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + .filter { $0.pathExtension == "json" } + .map(\.lastPathComponent) + .sorted() + } +} + +private actor DomainIdentifierBasedConfigurationStore: DomainConfigurationStoring { + private var configurations: [ProviderDomainConfiguration] + + init(configuration: ProviderDomainConfiguration) { + self.configurations = [configuration] + } + + func allConfigurations() -> [ProviderDomainConfiguration] { + configurations + } + + func configuration(domainIdentifier: String) -> ProviderDomainConfiguration? { + configurations.first { $0.domainIdentifier == domainIdentifier } + } + + func save(_ configuration: ProviderDomainConfiguration) { + configurations.removeAll { + $0.domainIdentifier == configuration.domainIdentifier + } + configurations.append(configuration) + } + + func remove(domainIdentifier: String) { + configurations.removeAll { $0.domainIdentifier == domainIdentifier } + } +} diff --git a/potassiumProviderTests/ProviderDomainRelocationJournalTests.swift b/potassiumProviderTests/ProviderDomainRelocationJournalTests.swift new file mode 100644 index 0000000..a6e7a43 --- /dev/null +++ b/potassiumProviderTests/ProviderDomainRelocationJournalTests.swift @@ -0,0 +1,47 @@ +import Foundation +import PotassiumProviderCore +import Testing + +@Suite("Provider domain relocation journal") +struct ProviderDomainRelocationJournalTests { + @Test + func persistsUpdatesAndRemovesJournalByStableConfigurationIdentifier() async throws { + let directoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("ProviderDomainRelocationJournalTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let store = ProviderDomainRelocationFileStore(directoryURL: directoryURL) + let source = ProviderDomainConfiguration( + configurationIdentifier: "configuration-1", + domainIdentifier: "domain-old", + displayName: "Team", + driveID: 42, + driveName: "Team", + storageLocation: .onThisMac + ) + var journal = ProviderDomainRelocationJournal( + configurationIdentifier: source.configurationIdentifier, + sourceConfiguration: source, + targetStorageLocation: .externalVolume( + volumeUUID: UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!, + displayName: "Portable" + ), + knownFoldersWereActive: true + ) + + try await store.save(journal) + #expect(try await store.journal(configurationIdentifier: "configuration-1") == journal) + + journal.phase = .targetConfigurationSaved + journal.targetDomainIdentifier = "domain-new" + journal.updatedAt = journal.updatedAt.addingTimeInterval(1) + try await store.save(journal) + + let allJournals = try await store.allJournals() + #expect(allJournals == [journal]) + #expect(allJournals.first?.sourceConfiguration.domainIdentifier == "domain-old") + + try await store.remove(configurationIdentifier: "configuration-1") + #expect(try await store.journal(configurationIdentifier: "configuration-1") == nil) + } +} From a76b16d3546f280c0cd8b78aa66c1e37cc1f8d55 Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sun, 19 Jul 2026 09:09:33 +0200 Subject: [PATCH 2/9] feat: connect external file provider domains safely --- .../ProviderExternalDomainBinding.swift | 60 ++++++++++ .../FileProviderRuntime.swift | 105 +++++++++++++++++- ...ileProviderExtension+ExternalVolumes.swift | 29 +++++ .../PotassiumFileProviderExtension.swift | 41 +++++-- .../ProviderExternalDomainBindingTests.swift | 49 ++++++++ 5 files changed, 273 insertions(+), 11 deletions(-) create mode 100644 PotassiumProviderCore/ProviderExternalDomainBinding.swift create mode 100644 potassiumProviderFileProvider/PotassiumFileProviderExtension+ExternalVolumes.swift create mode 100644 potassiumProviderTests/ProviderExternalDomainBindingTests.swift diff --git a/PotassiumProviderCore/ProviderExternalDomainBinding.swift b/PotassiumProviderCore/ProviderExternalDomainBinding.swift new file mode 100644 index 0000000..87d4443 --- /dev/null +++ b/PotassiumProviderCore/ProviderExternalDomainBinding.swift @@ -0,0 +1,60 @@ +import Foundation + +public struct ProviderExternalDomainBinding: Equatable, Sendable { + public let configurationIdentifier: String + + public init(configurationIdentifier: String) { + self.configurationIdentifier = configurationIdentifier + } +} + +public enum ProviderExternalDomainUserInfoCodec { + public static let currentSchemaVersion = 1 + public static let schemaVersionKey = "net.weavee.potassiumProvider.externalBindingSchemaVersion" + public static let configurationIdentifierKey = "net.weavee.potassiumProvider.configurationIdentifier" + + public static func userInfo(configurationIdentifier: String) -> [String: Any] { + [ + schemaVersionKey: NSNumber(value: currentSchemaVersion), + configurationIdentifierKey: configurationIdentifier, + ] + } + + public static func containsBinding(in userInfo: [AnyHashable: Any]?) -> Bool { + guard let userInfo else { return false } + return userInfo[schemaVersionKey] != nil || userInfo[configurationIdentifierKey] != nil + } + + public static func decode(_ userInfo: [AnyHashable: Any]?) throws -> ProviderExternalDomainBinding { + guard let userInfo, + let schemaVersion = userInfo[schemaVersionKey] as? NSNumber, + schemaVersion.intValue == currentSchemaVersion + else { + throw ProviderExternalDomainBindingDecodingError.unsupportedSchema + } + + guard let rawConfigurationIdentifier = userInfo[configurationIdentifierKey] as? String else { + throw ProviderExternalDomainBindingDecodingError.missingConfigurationIdentifier + } + let configurationIdentifier = rawConfigurationIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) + guard configurationIdentifier.isEmpty == false else { + throw ProviderExternalDomainBindingDecodingError.missingConfigurationIdentifier + } + + return ProviderExternalDomainBinding(configurationIdentifier: configurationIdentifier) + } +} + +public enum ProviderExternalDomainBindingDecodingError: Error, Equatable, LocalizedError, Sendable { + case unsupportedSchema + case missingConfigurationIdentifier + + public var errorDescription: String? { + switch self { + case .unsupportedSchema: + return "The external File Provider domain uses an unsupported binding format." + case .missingConfigurationIdentifier: + return "The external File Provider domain has no configuration binding." + } + } +} diff --git a/potassiumProviderFileProvider/FileProviderRuntime.swift b/potassiumProviderFileProvider/FileProviderRuntime.swift index 064f29a..e579df1 100644 --- a/potassiumProviderFileProvider/FileProviderRuntime.swift +++ b/potassiumProviderFileProvider/FileProviderRuntime.swift @@ -78,11 +78,84 @@ struct FileProviderRuntime: Sendable { FileProviderLog.runtime.debug("load configuration for domain(\(domain.identifier.rawValue, privacy: .public)) from app group") let configurationStore = try makeConfigurationStore(domain: domain) - guard let configuration = try await configurationStore.configuration(domainIdentifier: domain.identifier.rawValue) else { - FileProviderLog.runtime.error("missing configuration for domain(\(domain.identifier.rawValue, privacy: .public)); returning notAuthenticated") + do { + let configuration = try await resolveConfiguration( + domain: domain, + configurationStore: configurationStore + ) + FileProviderLog.runtime.debug("loaded configuration for domain(\(configuration.domainIdentifier, privacy: .public)) driveID(\(configuration.driveID, privacy: .public)) displayName(\(configuration.displayName, privacy: .private))") + return configuration + } catch let error as ExternalDomainBindingError { + FileProviderLog.runtime.error("invalid local binding for domain(\(domain.identifier.rawValue, privacy: .public)): \(error.localizedDescription, privacy: .public); returning notAuthenticated") throw NSFileProviderError(.notAuthenticated) } - FileProviderLog.runtime.debug("loaded configuration for domain(\(configuration.domainIdentifier, privacy: .public)) driveID(\(configuration.driveID, privacy: .public)) displayName(\(configuration.displayName, privacy: .private))") + } + + #if os(macOS) + static func approveExternalDomainConnection(domain: NSFileProviderDomain) async throws { + guard domain.volumeUUID != nil else { + throw ExternalDomainBindingError.storageLocationMismatch + } + + let configurationStore = try DomainConfigurationFileStore( + appGroupIdentifier: ProviderConstants.appGroupIdentifier + ) + let configuration = try await resolveConfiguration( + domain: domain, + configurationStore: configurationStore + ) + let tokenStore = KeychainOAuthTokenStore(accessGroup: ProviderConstants.keychainAccessGroup) + guard let token = try await tokenStore.loadToken(accountIdentifier: configuration.accountIdentifier), + token.shouldRefresh() == false || token.refreshToken != nil + else { + throw ExternalDomainBindingError.missingCredentials + } + } + #endif + + private static func resolveConfiguration( + domain: NSFileProviderDomain, + configurationStore: DomainConfigurationFileStore + ) async throws -> ProviderDomainConfiguration { + #if os(macOS) + if ProviderExternalDomainUserInfoCodec.containsBinding(in: domain.userInfo) || domain.volumeUUID != nil { + let binding: ProviderExternalDomainBinding + do { + binding = try ProviderExternalDomainUserInfoCodec.decode(domain.userInfo) + } catch { + throw ExternalDomainBindingError.invalidBinding + } + guard let configuration = try await configurationStore.configuration( + configurationIdentifier: binding.configurationIdentifier + ) else { + throw ExternalDomainBindingError.missingConfiguration + } + guard configuration.domainIdentifier == domain.identifier.rawValue else { + throw ExternalDomainBindingError.domainIdentifierMismatch + } + + switch configuration.storageLocation { + case .onThisMac: + guard domain.volumeUUID == nil else { + throw ExternalDomainBindingError.storageLocationMismatch + } + case .externalVolume(let expectedVolumeUUID, _): + guard let actualVolumeUUID = domain.volumeUUID else { + throw ExternalDomainBindingError.storageLocationMismatch + } + guard actualVolumeUUID == expectedVolumeUUID else { + throw ExternalDomainBindingError.volumeIdentifierMismatch + } + } + return configuration + } + #endif + + guard let configuration = try await configurationStore.configuration( + domainIdentifier: domain.identifier.rawValue + ) else { + throw ExternalDomainBindingError.missingConfiguration + } return configuration } @@ -141,6 +214,32 @@ struct FileProviderRuntime: Sendable { } } +private enum ExternalDomainBindingError: Error, Equatable, LocalizedError, Sendable { + case invalidBinding + case missingConfiguration + case domainIdentifierMismatch + case storageLocationMismatch + case volumeIdentifierMismatch + case missingCredentials + + var errorDescription: String? { + switch self { + case .invalidBinding: + return "The external File Provider domain has an invalid configuration binding." + case .missingConfiguration: + return "This Mac has no configuration for the external File Provider domain." + case .domainIdentifierMismatch: + return "The external File Provider domain does not match its local configuration." + case .storageLocationMismatch: + return "The File Provider domain storage location does not match its local configuration." + case .volumeIdentifierMismatch: + return "The external File Provider domain is on a different volume than its local configuration." + case .missingCredentials: + return "This Mac has no usable credentials for the external File Provider domain." + } + } +} + enum FileProviderPageCodec { static func cursor(from page: NSFileProviderPage) -> String? { let initialPageSortedByDate = NSFileProviderPage.initialPageSortedByDate as NSFileProviderPage diff --git a/potassiumProviderFileProvider/PotassiumFileProviderExtension+ExternalVolumes.swift b/potassiumProviderFileProvider/PotassiumFileProviderExtension+ExternalVolumes.swift new file mode 100644 index 0000000..3b2ebb7 --- /dev/null +++ b/potassiumProviderFileProvider/PotassiumFileProviderExtension+ExternalVolumes.swift @@ -0,0 +1,29 @@ +#if os(macOS) +import FileProvider +import OSLog + +@available(macOS 15.0, *) +extension PotassiumFileProviderExtension: NSFileProviderExternalVolumeHandling { + public func shouldConnectExternalDomain( + completionHandler: @escaping (Error?) -> Void + ) { + let domain = fileProviderDomain + Task { [weak self] in + do { + try await FileProviderRuntime.approveExternalDomainConnection(domain: domain) + guard let self else { + completionHandler(NSFileProviderError(.notAuthenticated)) + return + } + self.startRemotePolling() + FileProviderLog.replicatedExtension.info("approved external domain connection for domain(\(domain.identifier.rawValue, privacy: .public))") + completionHandler(nil) + } catch { + self?.stopRemotePolling() + FileProviderLog.replicatedExtension.error("rejected external domain connection for domain(\(domain.identifier.rawValue, privacy: .public)): \(error.localizedDescription, privacy: .public)") + completionHandler(NSFileProviderError(.notAuthenticated)) + } + } + } +} +#endif diff --git a/potassiumProviderFileProvider/PotassiumFileProviderExtension.swift b/potassiumProviderFileProvider/PotassiumFileProviderExtension.swift index 45f7278..466bcfa 100644 --- a/potassiumProviderFileProvider/PotassiumFileProviderExtension.swift +++ b/potassiumProviderFileProvider/PotassiumFileProviderExtension.swift @@ -14,7 +14,7 @@ public final class PotassiumFileProviderExtension: NSObject, NSFileProviderRepli let domain: NSFileProviderDomain let manager: NSFileProviderManager - private let temporaryDirectoryURL: URL + private let temporaryDirectoryURL: URL? private var remotePollingTask: Task? var fileProviderDomain: NSFileProviderDomain { @@ -24,15 +24,27 @@ public final class PotassiumFileProviderExtension: NSObject, NSFileProviderRepli public required init(domain: NSFileProviderDomain) { self.domain = domain self.manager = NSFileProviderManager(for: domain)! - self.temporaryDirectoryURL = (try? manager.temporaryDirectoryURL()) ?? FileManager.default.temporaryDirectory + #if os(macOS) + let isStoredOnExternalVolume = domain.volumeUUID != nil + #else + let isStoredOnExternalVolume = false + #endif + self.temporaryDirectoryURL = isStoredOnExternalVolume + ? nil + : ((try? manager.temporaryDirectoryURL()) ?? FileManager.default.temporaryDirectory) super.init() - startRemotePolling() - FileProviderLog.replicatedExtension.info("init replicated extension for domain(\(self.domain.identifier.rawValue, privacy: .public)) displayName(\(self.domain.displayName, privacy: .private)) temporaryDirectory(\(self.temporaryDirectoryURL.path, privacy: .private))") + if isStoredOnExternalVolume == false { + startRemotePolling() + } + if let temporaryDirectoryURL { + FileProviderLog.replicatedExtension.info("init replicated extension for domain(\(self.domain.identifier.rawValue, privacy: .public)) displayName(\(self.domain.displayName, privacy: .private)) temporaryDirectory(\(temporaryDirectoryURL.path, privacy: .private))") + } else { + FileProviderLog.replicatedExtension.info("init replicated extension for external domain(\(self.domain.identifier.rawValue, privacy: .public)) displayName(\(self.domain.displayName, privacy: .private)); waiting for connection approval") + } } public func invalidate() { - remotePollingTask?.cancel() - remotePollingTask = nil + stopRemotePolling() FileProviderLog.replicatedExtension.debug("invalidate replicated extension for domain(\(self.domain.identifier.rawValue, privacy: .public))") } @@ -143,7 +155,8 @@ public final class PotassiumFileProviderExtension: NSObject, NSFileProviderRepli FileProviderLog.replicatedExtension.debug("fetchContents(for:\(itemIdentifier.rawValue, privacy: .public)) requestedVersion(\(logVersionDescription(requestedVersion), privacy: .public)) @ domainVersion(\(request.logDomainVersion, privacy: .public))") let progress = Progress.fileTransfer(operationKind: .downloading) let domain = self.domain - let temporaryDirectoryURL = self.temporaryDirectoryURL + let configuredTemporaryDirectoryURL = self.temporaryDirectoryURL + let manager = self.manager let lifecycle = FileProviderOperationLifecycle(progress: progress) { FileProviderLog.replicatedExtension.debug("cancel fetchContents(for:\(itemIdentifier.rawValue, privacy: .public))") completionHandler(nil, nil, NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError)) @@ -152,6 +165,12 @@ public final class PotassiumFileProviderExtension: NSObject, NSFileProviderRepli lifecycle.start { lifecycle in var runtime: FileProviderRuntime? do { + let temporaryDirectoryURL: URL + if let configuredTemporaryDirectoryURL { + temporaryDirectoryURL = configuredTemporaryDirectoryURL + } else { + temporaryDirectoryURL = try manager.temporaryDirectoryURL() + } let loadedRuntime = try await FileProviderRuntime.load(domain: domain) runtime = loadedRuntime let identifier = try KDriveItemIdentifier(rawValue: itemIdentifier.rawValue) @@ -762,7 +781,8 @@ public final class PotassiumFileProviderExtension: NSObject, NSFileProviderRepli await signalWorkingSet(runtime: runtime) } - private func startRemotePolling() { + func startRemotePolling() { + guard remotePollingTask == nil else { return } remotePollingTask = Task { [weak self] in while Task.isCancelled == false { do { @@ -782,6 +802,11 @@ public final class PotassiumFileProviderExtension: NSObject, NSFileProviderRepli } } + func stopRemotePolling() { + remotePollingTask?.cancel() + remotePollingTask = nil + } + private func pollWorkingSet( runtime: FileProviderRuntime, minimumInterval: TimeInterval = KDriveWorkingSetPollCoordinator.pollingInterval diff --git a/potassiumProviderTests/ProviderExternalDomainBindingTests.swift b/potassiumProviderTests/ProviderExternalDomainBindingTests.swift new file mode 100644 index 0000000..85b5f91 --- /dev/null +++ b/potassiumProviderTests/ProviderExternalDomainBindingTests.swift @@ -0,0 +1,49 @@ +import Foundation +import PotassiumProviderCore +import Testing + +struct ProviderExternalDomainBindingTests { + @Test func codecRoundTripsOpaqueConfigurationIdentifier() throws { + let userInfo = ProviderExternalDomainUserInfoCodec.userInfo( + configurationIdentifier: "configuration-1" + ) + + #expect( + userInfo[ProviderExternalDomainUserInfoCodec.schemaVersionKey] as? NSNumber + == NSNumber(value: ProviderExternalDomainUserInfoCodec.currentSchemaVersion) + ) + #expect( + try ProviderExternalDomainUserInfoCodec.decode(userInfo) + == ProviderExternalDomainBinding(configurationIdentifier: "configuration-1") + ) + } + + @Test func codecRecognizesPartialBindingWithoutAcceptingIt() { + let userInfo: [AnyHashable: Any] = [ + ProviderExternalDomainUserInfoCodec.configurationIdentifierKey: "configuration-1" + ] + + #expect(ProviderExternalDomainUserInfoCodec.containsBinding(in: userInfo)) + #expect(throws: ProviderExternalDomainBindingDecodingError.unsupportedSchema) { + try ProviderExternalDomainUserInfoCodec.decode(userInfo) + } + } + + @Test func codecRejectsUnknownSchemaAndBlankIdentifier() { + let unknownSchema: [AnyHashable: Any] = [ + ProviderExternalDomainUserInfoCodec.schemaVersionKey: NSNumber(value: 2), + ProviderExternalDomainUserInfoCodec.configurationIdentifierKey: "configuration-1", + ] + #expect(throws: ProviderExternalDomainBindingDecodingError.unsupportedSchema) { + try ProviderExternalDomainUserInfoCodec.decode(unknownSchema) + } + + let blankIdentifier: [AnyHashable: Any] = [ + ProviderExternalDomainUserInfoCodec.schemaVersionKey: NSNumber(value: 1), + ProviderExternalDomainUserInfoCodec.configurationIdentifierKey: " ", + ] + #expect(throws: ProviderExternalDomainBindingDecodingError.missingConfigurationIdentifier) { + try ProviderExternalDomainUserInfoCodec.decode(blankIdentifier) + } + } +} From 82bb81bd44c47195d88f635a4dae5741ac2dfd49 Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sun, 19 Jul 2026 09:13:36 +0200 Subject: [PATCH 3/9] feat: add external volume domain support --- .../FileProviderDomainRegistrar.swift | 379 ++++++++++++++++-- ...oviderExternalVolumeSelectionService.swift | 261 ++++++++++++ ...erDomainRegistrarExternalVolumeTests.swift | 353 ++++++++++++++++ 3 files changed, 951 insertions(+), 42 deletions(-) create mode 100644 potassiumProvider/ProviderExternalVolumeSelectionService.swift create mode 100644 potassiumProviderTests/ProviderDomainRegistrarExternalVolumeTests.swift diff --git a/potassiumProvider/FileProviderDomainRegistrar.swift b/potassiumProvider/FileProviderDomainRegistrar.swift index bbc3d90..49ecb4c 100644 --- a/potassiumProvider/FileProviderDomainRegistrar.swift +++ b/potassiumProvider/FileProviderDomainRegistrar.swift @@ -7,6 +7,17 @@ import PotassiumProviderCore protocol ProviderDomainRegistering { func addDomain(for configuration: ProviderDomainConfiguration) async throws func removeDomain(for configuration: ProviderDomainConfiguration) async throws + func prepareDomain( + configurationIdentifier: String, + domainIdentifier: String, + displayName: String, + target: ProviderDomainPreparationTarget + ) throws -> ProviderPreparedDomain + func addPreparedDomain(_ preparedDomain: ProviderPreparedDomain) async throws + func registeredDomainStates() async throws -> [ProviderRegisteredDomainState] + func waitForStabilization(for configuration: ProviderDomainConfiguration) async throws + func removeDomainPreservingDirtyUserData(for configuration: ProviderDomainConfiguration) async throws -> URL? + func reconnectDomain(for configuration: ProviderDomainConfiguration) async throws func knownFolderSyncStates() async throws -> [String: ProviderKnownFolderSyncState] func claimKnownFolders(for configuration: ProviderDomainConfiguration, parentFileID: Int) async throws func releaseKnownFolders(for configuration: ProviderDomainConfiguration) async throws @@ -14,6 +25,30 @@ protocol ProviderDomainRegistering { func signalWorkingSet(for configuration: ProviderDomainConfiguration) async throws } +enum ProviderDomainPreparationTarget: Equatable, Sendable { + case onThisMac + case externalVolume(URL) +} + +@MainActor +struct ProviderPreparedDomain { + let configurationIdentifier: String + let domainIdentifier: String + let volumeUUID: UUID? + + let fileProviderDomain: NSFileProviderDomain +} + +struct ProviderRegisteredDomainState: Equatable, Sendable { + let configurationIdentifier: String? + let domainIdentifier: String + let displayName: String + let volumeUUID: UUID? + let isDisconnected: Bool + let isUserEnabled: Bool + let knownFolderSyncState: ProviderKnownFolderSyncState +} + enum ProviderKnownFolderSyncState: Equatable, Sendable { case unavailable case inactive @@ -22,6 +57,36 @@ enum ProviderKnownFolderSyncState: Equatable, Sendable { } extension ProviderDomainRegistering { + func prepareDomain( + configurationIdentifier _: String, + domainIdentifier _: String, + displayName _: String, + target _: ProviderDomainPreparationTarget + ) throws -> ProviderPreparedDomain { + throw ProviderDomainRegistrationError.preparedDomainUnsupported + } + + func addPreparedDomain(_: ProviderPreparedDomain) async throws { + throw ProviderDomainRegistrationError.preparedDomainUnsupported + } + + func registeredDomainStates() async throws -> [ProviderRegisteredDomainState] { + [] + } + + func waitForStabilization(for _: ProviderDomainConfiguration) async throws { + throw ProviderDomainRegistrationError.stabilizationUnsupported + } + + func removeDomainPreservingDirtyUserData(for configuration: ProviderDomainConfiguration) async throws -> URL? { + try await removeDomain(for: configuration) + return nil + } + + func reconnectDomain(for _: ProviderDomainConfiguration) async throws { + throw ProviderDomainRegistrationError.reconnectionUnsupported + } + func knownFolderSyncStates() async throws -> [String: ProviderKnownFolderSyncState] { [:] } @@ -47,54 +112,142 @@ extension ProviderDomainRegistering { struct FileProviderDomainRegistrar: ProviderDomainRegistering { nonisolated private static let logger = ProviderLog.domain + private let system: FileProviderDomainSystemClient + + init(system: FileProviderDomainSystemClient? = nil) { + self.system = system ?? .live + } + func addDomain(for configuration: ProviderDomainConfiguration) async throws { - let domain = makeDomain(for: configuration) + let domain = try await domainForExistingOperation(configuration) Self.logger.info("addDomain(\(configuration.domainIdentifier, privacy: .public)) displayName(\(configuration.displayName, privacy: .private)) driveID(\(configuration.driveID, privacy: .public))") - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - NSFileProviderManager.add(domain) { error in - if let error { - Self.logger.error("failed to addDomain(\(configuration.domainIdentifier, privacy: .public)): \(error.localizedDescription, privacy: .public)") - continuation.resume(throwing: error) - } else { - Self.logger.info("added domain(\(configuration.domainIdentifier, privacy: .public)) to File Provider") - continuation.resume() - } - } + do { + try await system.addDomain(domain) + Self.logger.info("added domain(\(configuration.domainIdentifier, privacy: .public)) to File Provider") + } catch { + Self.logger.error("failed to addDomain(\(configuration.domainIdentifier, privacy: .public)): \(error.localizedDescription, privacy: .public)") + throw error } } func removeDomain(for configuration: ProviderDomainConfiguration) async throws { - let domain = makeDomain(for: configuration) + let domain = try await domainForExistingOperation(configuration) Self.logger.info("removeDomain(\(configuration.domainIdentifier, privacy: .public)) displayName(\(configuration.displayName, privacy: .private))") - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - NSFileProviderManager.remove(domain) { error in - if let error { - Self.logger.error("failed to removeDomain(\(configuration.domainIdentifier, privacy: .public)): \(error.localizedDescription, privacy: .public)") - continuation.resume(throwing: error) - } else { - Self.logger.info("removed domain(\(configuration.domainIdentifier, privacy: .public)) from File Provider") - continuation.resume() - } - } + do { + try await system.removeDomain(domain) + Self.logger.info("removed domain(\(configuration.domainIdentifier, privacy: .public)) from File Provider") + } catch { + Self.logger.error("failed to removeDomain(\(configuration.domainIdentifier, privacy: .public)): \(error.localizedDescription, privacy: .public)") + throw error } } + func prepareDomain( + configurationIdentifier: String, + domainIdentifier: String, + displayName: String, + target: ProviderDomainPreparationTarget + ) throws -> ProviderPreparedDomain { + let domain: NSFileProviderDomain + let volumeUUID: UUID? + switch target { + case .onThisMac: + domain = NSFileProviderDomain( + identifier: NSFileProviderDomainIdentifier(rawValue: domainIdentifier), + displayName: displayName + ) + volumeUUID = nil + case .externalVolume(let volumeURL): + #if os(macOS) + domain = NSFileProviderDomain( + displayName: displayName, + userInfo: ProviderExternalDomainUserInfoCodec.userInfo( + configurationIdentifier: configurationIdentifier + ), + volumeURL: volumeURL + ) + volumeUUID = domain.volumeUUID + #else + throw ProviderDomainRegistrationError.externalVolumesUnsupported + #endif + } + + #if os(macOS) + domain.supportedKnownFolders = [.desktop, .documents] + #endif + + return ProviderPreparedDomain( + configurationIdentifier: configurationIdentifier, + domainIdentifier: domain.identifier.rawValue, + volumeUUID: volumeUUID, + fileProviderDomain: domain + ) + } + + func addPreparedDomain(_ preparedDomain: ProviderPreparedDomain) async throws { + Self.logger.info("add prepared domain(\(preparedDomain.domainIdentifier, privacy: .public)) configuration(\(preparedDomain.configurationIdentifier, privacy: .public))") + try await system.addDomain(preparedDomain.fileProviderDomain) + } + + func registeredDomainStates() async throws -> [ProviderRegisteredDomainState] { + #if os(macOS) + try await registeredDomains().map { domain in + ProviderRegisteredDomainState( + configurationIdentifier: try? ProviderExternalDomainUserInfoCodec.decode( + domain.userInfo + ).configurationIdentifier, + domainIdentifier: domain.identifier.rawValue, + displayName: domain.displayName, + volumeUUID: domain.volumeUUID, + isDisconnected: domain.isDisconnected, + isUserEnabled: domain.userEnabled, + knownFolderSyncState: Self.knownFolderSyncState(for: domain) + ) + } + #else + return [] + #endif + } + + func waitForStabilization(for configuration: ProviderDomainConfiguration) async throws { + #if os(macOS) + let domain = try await registeredDomain(for: configuration) + Self.logger.info("wait for stabilization of domain(\(configuration.domainIdentifier, privacy: .public))") + try await system.waitForStabilization(domain) + #else + throw ProviderDomainRegistrationError.externalVolumesUnsupported + #endif + } + + func removeDomainPreservingDirtyUserData( + for configuration: ProviderDomainConfiguration + ) async throws -> URL? { + #if os(macOS) + let domain = try await registeredDomain(for: configuration) + Self.logger.info("remove domain preserving dirty user data(\(configuration.domainIdentifier, privacy: .public))") + return try await system.removeDomainPreservingDirtyUserData(domain) + #else + throw ProviderDomainRegistrationError.externalVolumesUnsupported + #endif + } + + func reconnectDomain(for configuration: ProviderDomainConfiguration) async throws { + #if os(macOS) + let domain = try await registeredDomain(for: configuration) + Self.logger.info("reconnect domain(\(configuration.domainIdentifier, privacy: .public))") + try await system.reconnectDomain(domain) + #else + throw ProviderDomainRegistrationError.externalVolumesUnsupported + #endif + } + func knownFolderSyncStates() async throws -> [String: ProviderKnownFolderSyncState] { #if os(macOS) let domains = try await registeredDomains() return Dictionary(uniqueKeysWithValues: domains.map { domain in - let folders = domain.replicatedKnownFolders - let state: ProviderKnownFolderSyncState - if folders.contains(.desktop), folders.contains(.documents) { - state = .active - } else if folders.contains(.desktop) || folders.contains(.documents) { - state = .partial - } else { - state = .inactive - } - return (domain.identifier.rawValue, state) + (domain.identifier.rawValue, Self.knownFolderSyncState(for: domain)) }) #else return [:] @@ -157,6 +310,21 @@ struct FileProviderDomainRegistrar: ProviderDomainRegistering { return domain } + private func domainForExistingOperation( + _ configuration: ProviderDomainConfiguration + ) async throws -> NSFileProviderDomain { + switch configuration.storageLocation { + case .onThisMac: + return makeDomain(for: configuration) + case .externalVolume: + #if os(macOS) + return try await registeredDomain(for: configuration) + #else + throw ProviderDomainRegistrationError.externalVolumesUnsupported + #endif + } + } + #if os(macOS) static func makeKnownFolderLocations(parentFileID: Int) -> NSFileProviderKnownFolderLocations { let parentIdentifier = NSFileProviderItemIdentifier(KDriveItemIdentifier.item(parentFileID).rawValue) @@ -174,10 +342,7 @@ struct FileProviderDomainRegistrar: ProviderDomainRegistering { #endif private func manager(for configuration: ProviderDomainConfiguration) async throws -> NSFileProviderManager { - let identifier = NSFileProviderDomainIdentifier(rawValue: configuration.domainIdentifier) - guard let domain = try await registeredDomains().first(where: { $0.identifier == identifier }) else { - throw ProviderKnownFolderRegistrationError.domainNotRegistered(configuration.domainIdentifier) - } + let domain = try await registeredDomain(for: configuration) guard let manager = NSFileProviderManager(for: domain) else { throw ProviderKnownFolderRegistrationError.managerUnavailable(configuration.domainIdentifier) } @@ -185,14 +350,144 @@ struct FileProviderDomainRegistrar: ProviderDomainRegistering { } private func registeredDomains() async throws -> [NSFileProviderDomain] { - try await withCheckedThrowingContinuation { continuation in - NSFileProviderManager.getDomainsWithCompletionHandler { domains, error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume(returning: domains) + try await system.registeredDomains() + } + + private func registeredDomain( + for configuration: ProviderDomainConfiguration + ) async throws -> NSFileProviderDomain { + let identifier = NSFileProviderDomainIdentifier(rawValue: configuration.domainIdentifier) + guard let domain = try await registeredDomains().first(where: { $0.identifier == identifier }) else { + throw ProviderDomainRegistrationError.domainNotRegistered(configuration.domainIdentifier) + } + return domain + } + + private static func knownFolderSyncState( + for domain: NSFileProviderDomain + ) -> ProviderKnownFolderSyncState { + let folders = domain.replicatedKnownFolders + if folders.contains(.desktop), folders.contains(.documents) { + return .active + } else if folders.contains(.desktop) || folders.contains(.documents) { + return .partial + } else { + return .inactive + } + } +} + +@MainActor +struct FileProviderDomainSystemClient { + var addDomain: (NSFileProviderDomain) async throws -> Void + var removeDomain: (NSFileProviderDomain) async throws -> Void + var registeredDomains: () async throws -> [NSFileProviderDomain] + var waitForStabilization: (NSFileProviderDomain) async throws -> Void + var removeDomainPreservingDirtyUserData: (NSFileProviderDomain) async throws -> URL? + var reconnectDomain: (NSFileProviderDomain) async throws -> Void + + static let live = FileProviderDomainSystemClient( + addDomain: { domain in + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + NSFileProviderManager.add(domain) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + }, + removeDomain: { domain in + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + NSFileProviderManager.remove(domain) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } } } + }, + registeredDomains: { + try await withCheckedThrowingContinuation { continuation in + NSFileProviderManager.getDomainsWithCompletionHandler { domains, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: domains) + } + } + } + }, + waitForStabilization: { domain in + guard let manager = NSFileProviderManager(for: domain) else { + throw ProviderDomainRegistrationError.managerUnavailable(domain.identifier.rawValue) + } + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + manager.waitForStabilization { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + }, + removeDomainPreservingDirtyUserData: { domain in + #if os(macOS) + return try await withCheckedThrowingContinuation { continuation in + NSFileProviderManager.remove(domain, mode: .preserveDirtyUserData) { preservedLocation, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: preservedLocation) + } + } + } + #else + throw ProviderDomainRegistrationError.externalVolumesUnsupported + #endif + }, + reconnectDomain: { domain in + guard let manager = NSFileProviderManager(for: domain) else { + throw ProviderDomainRegistrationError.managerUnavailable(domain.identifier.rawValue) + } + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + manager.reconnect { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + } + ) +} + +enum ProviderDomainRegistrationError: Error, Equatable, LocalizedError, Sendable { + case externalVolumesUnsupported + case preparedDomainUnsupported + case stabilizationUnsupported + case reconnectionUnsupported + case domainNotRegistered(String) + case managerUnavailable(String) + + var errorDescription: String? { + switch self { + case .externalVolumesUnsupported: + return "External File Provider storage is available only on macOS." + case .preparedDomainUnsupported: + return "This File Provider registrar cannot prepare domains." + case .stabilizationUnsupported: + return "This File Provider registrar cannot wait for domain stabilization." + case .reconnectionUnsupported: + return "This File Provider registrar cannot reconnect domains." + case .domainNotRegistered: + return "The selected kDrive is not registered with File Provider." + case .managerUnavailable: + return "File Provider could not open the selected kDrive domain." } } } diff --git a/potassiumProvider/ProviderExternalVolumeSelectionService.swift b/potassiumProvider/ProviderExternalVolumeSelectionService.swift new file mode 100644 index 0000000..d6f008e --- /dev/null +++ b/potassiumProvider/ProviderExternalVolumeSelectionService.swift @@ -0,0 +1,261 @@ +import Foundation + +#if os(macOS) +import AppKit +import FileProvider +#endif + +enum ProviderExternalVolumeUnsupportedReason: String, CaseIterable, Equatable, Hashable, Sendable { + case unknown + case nonAPFS + case nonEncrypted + case readOnly + case network + case quarantined + + var userFacingDescription: String { + switch self { + case .unknown: + return "macOS could not determine whether this drive supports File Provider storage." + case .nonAPFS: + return "The drive must use APFS." + case .nonEncrypted: + return "The drive must be encrypted." + case .readOnly: + return "The drive is read-only." + case .network: + return "Network volumes cannot store File Provider domains." + case .quarantined: + return "The drive is quarantined by macOS." + } + } +} + +enum ProviderExternalVolumeEligibility: Equatable, Sendable { + case eligible + case ineligible(Set) +} + +struct ProviderExternalVolume: Equatable, Sendable { + let url: URL + let uuid: UUID + let displayName: String + let totalCapacity: Int64? + let availableCapacity: Int64? + let eligibility: ProviderExternalVolumeEligibility + + fileprivate let securityScopedURL: URL +} + +@MainActor +protocol ProviderExternalVolumeSelecting { + func selectExternalVolume() async throws -> ProviderExternalVolume? + func inspectVolume(at selectedURL: URL) throws -> ProviderExternalVolume + func mountedVolume(uuid: UUID) throws -> ProviderExternalVolume? + func withSecurityScopedAccess( + to volume: ProviderExternalVolume, + operation: @MainActor (URL) async throws -> T + ) async throws -> T +} + +enum ProviderExternalVolumeSelectionError: Error, Equatable, LocalizedError, Sendable { + case unsupportedPlatform + case missingVolumeRoot(URL) + case missingVolumeUUID(URL) + + var errorDescription: String? { + switch self { + case .unsupportedPlatform: + return "External File Provider storage is available only on macOS." + case .missingVolumeRoot: + return "The selected location is not on an accessible volume." + case .missingVolumeUUID: + return "macOS could not identify the selected volume." + } + } +} + +@MainActor +struct ProviderExternalVolumeSelectionService: ProviderExternalVolumeSelecting { + private let chooseURL: () async throws -> URL? + private let startAccessing: (URL) -> Bool + private let stopAccessing: (URL) -> Void + private let volumeRoot: (URL) throws -> URL + private let volumeMetadata: (URL) throws -> ProviderExternalVolumeMetadata + private let checkEligibility: (URL) throws -> ProviderExternalVolumeEligibility + private let mountedVolumeURLs: () -> [URL] + + init() { + #if os(macOS) + chooseURL = Self.presentOpenPanel + startAccessing = { $0.startAccessingSecurityScopedResource() } + stopAccessing = { $0.stopAccessingSecurityScopedResource() } + volumeRoot = Self.systemVolumeRoot + volumeMetadata = Self.systemVolumeMetadata + checkEligibility = Self.systemEligibility + mountedVolumeURLs = { + FileManager.default.mountedVolumeURLs( + includingResourceValuesForKeys: [.volumeUUIDStringKey], + options: [.skipHiddenVolumes] + ) ?? [] + } + #else + chooseURL = { throw ProviderExternalVolumeSelectionError.unsupportedPlatform } + startAccessing = { _ in false } + stopAccessing = { _ in } + volumeRoot = { throw ProviderExternalVolumeSelectionError.missingVolumeRoot($0) } + volumeMetadata = { throw ProviderExternalVolumeSelectionError.missingVolumeUUID($0) } + checkEligibility = { _ in throw ProviderExternalVolumeSelectionError.unsupportedPlatform } + mountedVolumeURLs = { [] } + #endif + } + + init( + chooseURL: @escaping () async throws -> URL?, + startAccessing: @escaping (URL) -> Bool, + stopAccessing: @escaping (URL) -> Void, + volumeRoot: @escaping (URL) throws -> URL, + volumeMetadata: @escaping (URL) throws -> ProviderExternalVolumeMetadata, + checkEligibility: @escaping (URL) throws -> ProviderExternalVolumeEligibility, + mountedVolumeURLs: @escaping () -> [URL] = { [] } + ) { + self.chooseURL = chooseURL + self.startAccessing = startAccessing + self.stopAccessing = stopAccessing + self.volumeRoot = volumeRoot + self.volumeMetadata = volumeMetadata + self.checkEligibility = checkEligibility + self.mountedVolumeURLs = mountedVolumeURLs + } + + func selectExternalVolume() async throws -> ProviderExternalVolume? { + guard let selectedURL = try await chooseURL() else { return nil } + return try inspectVolume(at: selectedURL) + } + + func inspectVolume(at selectedURL: URL) throws -> ProviderExternalVolume { + let didStartAccessing = startAccessing(selectedURL) + defer { + if didStartAccessing { + stopAccessing(selectedURL) + } + } + + let normalizedURL = try volumeRoot(selectedURL) + let metadata = try volumeMetadata(normalizedURL) + return ProviderExternalVolume( + url: normalizedURL, + uuid: metadata.uuid, + displayName: metadata.displayName, + totalCapacity: metadata.totalCapacity, + availableCapacity: metadata.availableCapacity, + eligibility: try checkEligibility(normalizedURL), + securityScopedURL: selectedURL + ) + } + + func mountedVolume(uuid: UUID) throws -> ProviderExternalVolume? { + for url in mountedVolumeURLs() { + guard let volume = try? inspectVolume(at: url), volume.uuid == uuid else { + continue + } + return volume + } + return nil + } + + func withSecurityScopedAccess( + to volume: ProviderExternalVolume, + operation: @MainActor (URL) async throws -> T + ) async throws -> T { + let didStartAccessing = startAccessing(volume.securityScopedURL) + defer { + if didStartAccessing { + stopAccessing(volume.securityScopedURL) + } + } + return try await operation(volume.url) + } +} + +struct ProviderExternalVolumeMetadata: Equatable, Sendable { + let uuid: UUID + let displayName: String + let totalCapacity: Int64? + let availableCapacity: Int64? +} + +#if os(macOS) +extension ProviderExternalVolumeSelectionService { + static func unsupportedReasons( + from reasons: NSFileProviderVolumeUnsupportedReason + ) -> Set { + var result: Set = [] + if reasons.contains(.unknown) { result.insert(.unknown) } + if reasons.contains(.nonAPFS) { result.insert(.nonAPFS) } + if reasons.contains(.nonEncrypted) { result.insert(.nonEncrypted) } + if reasons.contains(.readOnly) { result.insert(.readOnly) } + if reasons.contains(.network) { result.insert(.network) } + if reasons.contains(.quarantined) { result.insert(.quarantined) } + return result + } + + private static func presentOpenPanel() async -> URL? { + await withCheckedContinuation { continuation in + let panel = NSOpenPanel() + panel.title = "Choose an External Drive" + panel.message = "Choose any folder on the encrypted APFS drive where macOS should store this File Provider domain." + panel.prompt = "Choose Drive" + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.canCreateDirectories = false + panel.resolvesAliases = true + panel.begin { response in + continuation.resume(returning: response == .OK ? panel.url : nil) + } + } + } + + static func systemVolumeRoot(for url: URL) throws -> URL { + let values = try url.resourceValues(forKeys: [.volumeURLKey]) + guard let volume = values.volume else { + throw ProviderExternalVolumeSelectionError.missingVolumeRoot(url) + } + return volume.standardizedFileURL + } + + private static func systemVolumeMetadata(for volumeURL: URL) throws -> ProviderExternalVolumeMetadata { + let values = try volumeURL.resourceValues(forKeys: [ + .volumeUUIDStringKey, + .volumeNameKey, + .volumeLocalizedNameKey, + .volumeTotalCapacityKey, + .volumeAvailableCapacityForImportantUsageKey, + ]) + guard let uuidString = values.volumeUUIDString, + let uuid = UUID(uuidString: uuidString) + else { + throw ProviderExternalVolumeSelectionError.missingVolumeUUID(volumeURL) + } + let displayName = values.volumeLocalizedName + ?? values.volumeName + ?? volumeURL.lastPathComponent + return ProviderExternalVolumeMetadata( + uuid: uuid, + displayName: displayName.isEmpty ? "External Drive" : displayName, + totalCapacity: values.volumeTotalCapacity.map(Int64.init), + availableCapacity: values.volumeAvailableCapacityForImportantUsage + ) + } + + private static func systemEligibility(for volumeURL: URL) throws -> ProviderExternalVolumeEligibility { + switch try NSFileProviderManager.checkDomainsCanBeStoredOnVolume(at: volumeURL) { + case .eligible: + return .eligible + case .ineligible(let reasons): + return .ineligible(unsupportedReasons(from: reasons)) + } + } +} +#endif diff --git a/potassiumProviderTests/ProviderDomainRegistrarExternalVolumeTests.swift b/potassiumProviderTests/ProviderDomainRegistrarExternalVolumeTests.swift new file mode 100644 index 0000000..52e9064 --- /dev/null +++ b/potassiumProviderTests/ProviderDomainRegistrarExternalVolumeTests.swift @@ -0,0 +1,353 @@ +#if os(macOS) +import FileProvider +import Foundation +import PotassiumProviderCore +import Testing +@testable import potassiumProvider + +@Suite(.serialized) +@MainActor +struct ProviderDomainRegistrarExternalVolumeTests { + @Test func preparesExternalDomainWithOpaqueBindingAndRegistersExactObject() async throws { + let system = RecordingFileProviderDomainSystem() + let registrar = FileProviderDomainRegistrar(system: system.client) + let prepared = try registrar.prepareDomain( + configurationIdentifier: "configuration-1", + domainIdentifier: "ignored-for-external-domain", + displayName: "External kDrive", + target: .externalVolume(FileManager.default.temporaryDirectory) + ) + + let binding = try ProviderExternalDomainUserInfoCodec.decode( + prepared.fileProviderDomain.userInfo + ) + #expect(binding.configurationIdentifier == "configuration-1") + #expect(prepared.configurationIdentifier == "configuration-1") + #expect(prepared.domainIdentifier == prepared.fileProviderDomain.identifier.rawValue) + #expect(prepared.fileProviderDomain.supportedKnownFolders == [.desktop, .documents]) + + try await registrar.addPreparedDomain(prepared) + + let addedDomain = try #require(system.addedDomains.first) + #expect(addedDomain === prepared.fileProviderDomain) + } + + @Test func preparesInternalDomainWithRequestedIdentifier() throws { + let registrar = FileProviderDomainRegistrar(system: RecordingFileProviderDomainSystem().client) + + let prepared = try registrar.prepareDomain( + configurationIdentifier: "configuration-1", + domainIdentifier: "internal-domain-1", + displayName: "Local kDrive", + target: .onThisMac + ) + + #expect(prepared.configurationIdentifier == "configuration-1") + #expect(prepared.domainIdentifier == "internal-domain-1") + #expect(prepared.volumeUUID == nil) + #expect(ProviderExternalDomainUserInfoCodec.containsBinding( + in: prepared.fileProviderDomain.userInfo + ) == false) + #expect(prepared.fileProviderDomain.supportedKnownFolders == [.desktop, .documents]) + } + + @Test func reportsRegisteredExternalBindingAndLocalDomainState() async throws { + let externalDomain = NSFileProviderDomain( + displayName: "External kDrive", + userInfo: ProviderExternalDomainUserInfoCodec.userInfo( + configurationIdentifier: "configuration-1" + ), + volumeURL: FileManager.default.temporaryDirectory + ) + let localDomain = NSFileProviderDomain( + identifier: NSFileProviderDomainIdentifier(rawValue: "local-domain"), + displayName: "Local kDrive" + ) + let system = RecordingFileProviderDomainSystem(registeredDomains: [externalDomain, localDomain]) + let registrar = FileProviderDomainRegistrar(system: system.client) + + let states = try await registrar.registeredDomainStates() + + let externalState = try #require(states.first { + $0.domainIdentifier == externalDomain.identifier.rawValue + }) + #expect(externalState.configurationIdentifier == "configuration-1") + #expect(externalState.displayName == "External kDrive") + #expect(externalState.isDisconnected == false) + #expect(externalState.knownFolderSyncState == .inactive) + + let localState = try #require(states.first { $0.domainIdentifier == "local-domain" }) + #expect(localState.configurationIdentifier == nil) + } + + @Test func existingExternalOperationsUseRegisteredObjectAndPreserveDirtyData() async throws { + let externalDomain = NSFileProviderDomain( + displayName: "External kDrive", + userInfo: ProviderExternalDomainUserInfoCodec.userInfo( + configurationIdentifier: "configuration-1" + ), + volumeURL: FileManager.default.temporaryDirectory + ) + let preservedURL = URL(fileURLWithPath: "/private/tmp/preserved-user-data") + let system = RecordingFileProviderDomainSystem( + registeredDomains: [externalDomain], + preservedURL: preservedURL + ) + let registrar = FileProviderDomainRegistrar(system: system.client) + let configuration = ProviderDomainConfiguration( + configurationIdentifier: "configuration-1", + domainIdentifier: externalDomain.identifier.rawValue, + displayName: "External kDrive", + driveID: 42, + driveName: "External kDrive", + storageLocation: .externalVolume( + uuid: externalDomain.volumeUUID ?? UUID(), + displayName: "External Drive" + ) + ) + + try await registrar.addDomain(for: configuration) + try await registrar.waitForStabilization(for: configuration) + try await registrar.reconnectDomain(for: configuration) + let returnedPreservedURL = try await registrar.removeDomainPreservingDirtyUserData( + for: configuration + ) + try await registrar.removeDomain(for: configuration) + + #expect(system.addedDomains.first === externalDomain) + #expect(system.stabilizedDomains.first === externalDomain) + #expect(system.reconnectedDomains.first === externalDomain) + #expect(system.preserveRemovedDomains.first === externalDomain) + #expect(system.removedDomains.first === externalDomain) + #expect(returnedPreservedURL == preservedURL) + } + + @Test func externalExistingOperationFailsRatherThanReconstructingMissingDomain() async throws { + let registrar = FileProviderDomainRegistrar(system: RecordingFileProviderDomainSystem().client) + let configuration = ProviderDomainConfiguration( + configurationIdentifier: "configuration-1", + domainIdentifier: "missing-domain", + displayName: "External kDrive", + driveID: 42, + driveName: "External kDrive", + storageLocation: .externalVolume(uuid: UUID(), displayName: "External Drive") + ) + + await #expect(throws: ProviderDomainRegistrationError.domainNotRegistered("missing-domain")) { + try await registrar.addDomain(for: configuration) + } + await #expect(throws: ProviderDomainRegistrationError.domainNotRegistered("missing-domain")) { + try await registrar.removeDomain(for: configuration) + } + } + + @Test func mapsEveryExternalVolumeUnsupportedReasonIncludingCombinations() { + let reasons: NSFileProviderVolumeUnsupportedReason = [ + .unknown, + .nonAPFS, + .nonEncrypted, + .readOnly, + .network, + .quarantined, + ] + + #expect(ProviderExternalVolumeSelectionService.unsupportedReasons(from: reasons) == Set( + ProviderExternalVolumeUnsupportedReason.allCases + )) + #expect(ProviderExternalVolumeSelectionService.unsupportedReasons(from: []) == []) + #expect(ProviderExternalVolumeUnsupportedReason.nonAPFS.userFacingDescription.contains("APFS")) + #expect(ProviderExternalVolumeUnsupportedReason.nonEncrypted.userFacingDescription.contains("encrypted")) + } + + @Test func selectionNormalizesToVolumeRootAndBalancesSecurityScope() async throws { + let selectedURL = URL(fileURLWithPath: "/Volumes/Test Drive/Folder") + let volumeURL = URL(fileURLWithPath: "/Volumes/Test Drive") + let uuid = UUID() + var startedURLs: [URL] = [] + var stoppedURLs: [URL] = [] + var inspectedURLs: [URL] = [] + let service = ProviderExternalVolumeSelectionService( + chooseURL: { selectedURL }, + startAccessing: { + startedURLs.append($0) + return true + }, + stopAccessing: { stoppedURLs.append($0) }, + volumeRoot: { _ in volumeURL }, + volumeMetadata: { + inspectedURLs.append($0) + return ProviderExternalVolumeMetadata( + uuid: uuid, + displayName: "Test Drive", + totalCapacity: 2_000, + availableCapacity: 1_000 + ) + }, + checkEligibility: { + inspectedURLs.append($0) + return .eligible + } + ) + + let volume = try #require(try await service.selectExternalVolume()) + + #expect(volume.url == volumeURL) + #expect(volume.uuid == uuid) + #expect(volume.displayName == "Test Drive") + #expect(volume.totalCapacity == 2_000) + #expect(volume.availableCapacity == 1_000) + #expect(volume.eligibility == .eligible) + #expect(startedURLs == [selectedURL]) + #expect(stoppedURLs == [selectedURL]) + #expect(inspectedURLs == [volumeURL, volumeURL]) + } + + @Test func selectionCancellationAndUnscopedAccessDoNotStopSecurityScope() async throws { + var starts = 0 + var stops = 0 + let cancelledService = ProviderExternalVolumeSelectionService( + chooseURL: { nil }, + startAccessing: { _ in starts += 1; return true }, + stopAccessing: { _ in stops += 1 }, + volumeRoot: { $0 }, + volumeMetadata: { _ in + ProviderExternalVolumeMetadata( + uuid: UUID(), + displayName: "Unused", + totalCapacity: nil, + availableCapacity: nil + ) + }, + checkEligibility: { _ in .eligible } + ) + #expect(try await cancelledService.selectExternalVolume() == nil) + #expect(starts == 0) + #expect(stops == 0) + + let selectedURL = URL(fileURLWithPath: "/Volumes/Already Accessible") + let unscopedService = ProviderExternalVolumeSelectionService( + chooseURL: { selectedURL }, + startAccessing: { _ in starts += 1; return false }, + stopAccessing: { _ in stops += 1 }, + volumeRoot: { $0 }, + volumeMetadata: { _ in + ProviderExternalVolumeMetadata( + uuid: UUID(), + displayName: "Already Accessible", + totalCapacity: nil, + availableCapacity: nil + ) + }, + checkEligibility: { _ in .eligible } + ) + _ = try await unscopedService.selectExternalVolume() + #expect(starts == 1) + #expect(stops == 0) + } + + @Test func explicitSecurityScopeSpansAsyncOperationAndBalancesOnSuccessAndThrow() async throws { + let selectedURL = URL(fileURLWithPath: "/Volumes/Test Drive/Selected Folder") + let volumeURL = URL(fileURLWithPath: "/Volumes/Test Drive") + var starts = 0 + var stops = 0 + let service = ProviderExternalVolumeSelectionService( + chooseURL: { selectedURL }, + startAccessing: { url in + #expect(url == selectedURL) + starts += 1 + return true + }, + stopAccessing: { url in + #expect(url == selectedURL) + stops += 1 + }, + volumeRoot: { _ in volumeURL }, + volumeMetadata: { _ in + ProviderExternalVolumeMetadata( + uuid: UUID(), + displayName: "Test Drive", + totalCapacity: nil, + availableCapacity: nil + ) + }, + checkEligibility: { _ in .eligible } + ) + let volume = try #require(try await service.selectExternalVolume()) + #expect(starts == 1) + #expect(stops == 1) + + let result = try await service.withSecurityScopedAccess(to: volume) { accessibleVolumeURL in + #expect(accessibleVolumeURL == volumeURL) + await Task.yield() + #expect(stops == 1) + return "registered" + } + #expect(result == "registered") + #expect(starts == 2) + #expect(stops == 2) + + await #expect(throws: ProviderExternalVolumeFoundationTestError.registrationFailed) { + try await service.withSecurityScopedAccess(to: volume) { accessibleVolumeURL in + #expect(accessibleVolumeURL == volumeURL) + await Task.yield() + throw ProviderExternalVolumeFoundationTestError.registrationFailed + } + } + #expect(starts == 3) + #expect(stops == 3) + } + + @Test func systemNormalizationUsesContainingVolumeNotSelectedFolder() throws { + let selectedURL = FileManager.default.temporaryDirectory + let expectedVolume = try FileManager.default.temporaryDirectory + .resourceValues(forKeys: [.volumeURLKey]) + .volume? + .standardizedFileURL + + let normalizedVolume = try ProviderExternalVolumeSelectionService.systemVolumeRoot( + for: selectedURL + ) + + #expect(normalizedVolume == expectedVolume) + #expect(normalizedVolume != selectedURL) + } +} + +private enum ProviderExternalVolumeFoundationTestError: Error { + case registrationFailed +} + +@MainActor +private final class RecordingFileProviderDomainSystem { + var addedDomains: [NSFileProviderDomain] = [] + var removedDomains: [NSFileProviderDomain] = [] + var stabilizedDomains: [NSFileProviderDomain] = [] + var preserveRemovedDomains: [NSFileProviderDomain] = [] + var reconnectedDomains: [NSFileProviderDomain] = [] + + private let domains: [NSFileProviderDomain] + private let preservedURL: URL? + + init( + registeredDomains: [NSFileProviderDomain] = [], + preservedURL: URL? = nil + ) { + domains = registeredDomains + self.preservedURL = preservedURL + } + + var client: FileProviderDomainSystemClient { + FileProviderDomainSystemClient( + addDomain: { [self] in addedDomains.append($0) }, + removeDomain: { [self] in removedDomains.append($0) }, + registeredDomains: { [self] in domains }, + waitForStabilization: { [self] in stabilizedDomains.append($0) }, + removeDomainPreservingDirtyUserData: { [self] in + preserveRemovedDomains.append($0) + return preservedURL + }, + reconnectDomain: { [self] in reconnectedDomains.append($0) } + ) + } +} +#endif From af5af9c8a475414fc920e485441cfde82d0e3560 Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sun, 19 Jul 2026 09:35:33 +0200 Subject: [PATCH 4/9] feat: add recoverable domain storage transitions --- .../FileProviderDomainRegistrar.swift | 4 + .../PotassiumProviderAppModel.swift | 744 +++++++++++++++++- .../ProviderDomainPlacementState.swift | 72 ++ ...oviderExternalVolumeSelectionService.swift | 2 + ...ppModelExternalStorageLifecycleTests.swift | 699 ++++++++++++++++ ...ProviderDomainRelocationJournalTests.swift | 9 +- 6 files changed, 1516 insertions(+), 14 deletions(-) create mode 100644 potassiumProvider/ProviderDomainPlacementState.swift create mode 100644 potassiumProviderTests/PotassiumProviderAppModelExternalStorageLifecycleTests.swift diff --git a/potassiumProvider/FileProviderDomainRegistrar.swift b/potassiumProvider/FileProviderDomainRegistrar.swift index 49ecb4c..35d7901 100644 --- a/potassiumProvider/FileProviderDomainRegistrar.swift +++ b/potassiumProvider/FileProviderDomainRegistrar.swift @@ -450,6 +450,7 @@ struct FileProviderDomainSystemClient { #endif }, reconnectDomain: { domain in + #if os(macOS) guard let manager = NSFileProviderManager(for: domain) else { throw ProviderDomainRegistrationError.managerUnavailable(domain.identifier.rawValue) } @@ -462,6 +463,9 @@ struct FileProviderDomainSystemClient { } } } + #else + throw ProviderDomainRegistrationError.externalVolumesUnsupported + #endif } ) } diff --git a/potassiumProvider/PotassiumProviderAppModel.swift b/potassiumProvider/PotassiumProviderAppModel.swift index ecc0253..20872c4 100644 --- a/potassiumProvider/PotassiumProviderAppModel.swift +++ b/potassiumProvider/PotassiumProviderAppModel.swift @@ -33,6 +33,8 @@ final class PotassiumProviderAppModel: ObservableObject { @Published private(set) var domainTransitionConfigurationIdentifiers: Set = [] @Published private(set) var activeDriveActions: [ProviderDriveKey: ProviderDriveAction] = [:] @Published private(set) var isReloadingStoredState = false + @Published private(set) var placementStatesByConfigurationIdentifier: [String: ProviderDomainPlacementState] = [:] + @Published var preservedDataLocation: ProviderPreservedDataLocation? @Published private(set) var statusMessage: String? @Published var errorMessage: String? @Published var manualAccessToken = "" @@ -45,6 +47,8 @@ final class PotassiumProviderAppModel: ObservableObject { private let tokenStore: any OAuthTokenStoring private let oauthAuthenticator: any KDriveOAuthAuthenticating private let domainRegistrar: any ProviderDomainRegistering + private let relocationJournalStore: any ProviderDomainRelocationJournaling + private let externalVolumeSelector: any ProviderExternalVolumeSelecting private let snapshotStore: (any KDriveSnapshotStoring)? private let eventStore: (any KDriveProviderEventStoring)? private let fileProviderFactory: (String) -> any KDriveFileProviding @@ -58,6 +62,8 @@ final class PotassiumProviderAppModel: ObservableObject { tokenStore: (any OAuthTokenStoring)? = nil, oauthAuthenticator: (any KDriveOAuthAuthenticating)? = nil, domainRegistrar: (any ProviderDomainRegistering)? = nil, + relocationJournalStore: (any ProviderDomainRelocationJournaling)? = nil, + externalVolumeSelector: (any ProviderExternalVolumeSelecting)? = nil, snapshotStore: (any KDriveSnapshotStoring)? = nil, eventStore: (any KDriveProviderEventStoring)? = nil, automaticallyReloadStoredState: Bool = true, @@ -72,6 +78,8 @@ final class PotassiumProviderAppModel: ObservableObject { self.tokenStore = tokenStore ?? KeychainOAuthTokenStore(accessGroup: ProviderConstants.keychainAccessGroup) self.oauthAuthenticator = oauthAuthenticator ?? KDriveOAuthWebAuthenticator() self.domainRegistrar = domainRegistrar ?? FileProviderDomainRegistrar() + self.relocationJournalStore = relocationJournalStore ?? Self.makeDefaultRelocationJournalStore() + self.externalVolumeSelector = externalVolumeSelector ?? ProviderExternalVolumeSelectionService() self.snapshotStore = snapshotStore ?? Self.makeDefaultSnapshotStore() self.eventStore = eventStore ?? Self.makeDefaultEventStore() self.fileProviderFactory = fileProviderFactory @@ -173,6 +181,36 @@ final class PotassiumProviderAppModel: ObservableObject { return activeDriveActions[driveKey(for: configuration)] != nil } + func placementState(for configuration: ProviderDomainConfiguration) -> ProviderDomainPlacementState { + placementStatesByConfigurationIdentifier[configuration.configurationIdentifier] ?? .registering + } + + func isTransitioning(_ configuration: ProviderDomainConfiguration) -> Bool { + domainTransitionConfigurationIdentifiers.contains(configuration.configurationIdentifier) + } + + func canMutate(_ configuration: ProviderDomainConfiguration) -> Bool { + guard isTransitioning(configuration) == false else { return false } + switch placementState(for: configuration) { + case .connected: + return true + case .authenticationRequired, .volumeUnavailable, .registering, .moving, .needsRepair: + return false + } + } + + func selectExternalVolume() async -> ProviderExternalVolume? { + do { + let volume = try await externalVolumeSelector.selectExternalVolume() + errorMessage = nil + return volume + } catch { + errorMessage = "Could not inspect the external drive: \(error.localizedDescription)" + statusMessage = nil + return nil + } + } + func selectedDriveID(for accountIdentifier: String) -> Int? { selectedDriveIDs[accountIdentifier] } @@ -207,7 +245,7 @@ final class PotassiumProviderAppModel: ObservableObject { accounts = try await accountStore.allAccounts() let synchronizedState = try await synchronizedDomainConfigurations() domains = synchronizedState.configurations - try await refreshKnownFolderSyncStates() + try await refreshDomainSystemStates() seedDraftState() if let synchronizationError = synchronizedState.registrationError { @@ -368,7 +406,7 @@ final class PotassiumProviderAppModel: ObservableObject { if let registrationError = synchronizedState.registrationError { throw registrationError } - try await refreshKnownFolderSyncStates() + try await refreshDomainSystemStates() statusMessage = "Added \(configuration.driveName) to Files." errorMessage = nil } catch { @@ -393,16 +431,272 @@ final class PotassiumProviderAppModel: ObservableObject { await addDomain(accountIdentifier: accountIdentifier) } + func addDomain( + accountIdentifier: String, + drive: KDriveDriveSummary, + externalVolume: ProviderExternalVolume + ) async { + selectedDriveIDs[accountIdentifier] = drive.id + manualDriveIDs[accountIdentifier] = String(drive.id) + manualDriveNames[accountIdentifier] = drive.name + + guard account(accountIdentifier: accountIdentifier) != nil else { + errorMessage = "Choose an account before adding a domain." + statusMessage = nil + return + } + guard isConfigured(accountIdentifier: accountIdentifier, driveID: drive.id) == false else { + errorMessage = "\(drive.name) is already available in Files." + statusMessage = nil + return + } + guard case .eligible = externalVolume.eligibility else { + errorMessage = "Choose an eligible encrypted APFS drive." + statusMessage = nil + return + } + + let configurationIdentifier = UUID().uuidString + domainTransitionConfigurationIdentifiers.insert(configurationIdentifier) + placementStatesByConfigurationIdentifier[configurationIdentifier] = .registering + defer { domainTransitionConfigurationIdentifiers.remove(configurationIdentifier) } + + var savedConfiguration: ProviderDomainConfiguration? + do { + let configuration = try await externalVolumeSelector.withSecurityScopedAccess( + to: externalVolume + ) { volumeURL in + let prepared = try self.domainRegistrar.prepareDomain( + configurationIdentifier: configurationIdentifier, + domainIdentifier: UUID().uuidString, + displayName: ProviderDomainConfiguration.finderDisplayName(forDriveName: drive.name), + target: .externalVolume(volumeURL) + ) + guard let preparedVolumeUUID = prepared.volumeUUID, + preparedVolumeUUID == externalVolume.uuid + else { + throw PotassiumProviderAppModelError.externalVolumeIdentityMismatch + } + + let now = Date() + let configuration = ProviderDomainConfiguration( + configurationIdentifier: configurationIdentifier, + domainIdentifier: prepared.domainIdentifier, + accountIdentifier: accountIdentifier, + displayName: ProviderDomainConfiguration.finderDisplayName(forDriveName: drive.name), + driveID: drive.id, + driveName: drive.name, + storageLocation: .externalVolume( + uuid: preparedVolumeUUID, + displayName: externalVolume.displayName + ), + createdAt: now, + updatedAt: now + ) + try await self.domainStore.save(configuration) + savedConfiguration = configuration + try await self.domainRegistrar.addPreparedDomain(prepared) + return configuration + } + + domains = try await domainStore.allConfigurations() + try await refreshDomainSystemStates() + statusMessage = "Added \(configuration.driveName) to Files on \(externalVolume.displayName)." + errorMessage = nil + } catch { + if let savedConfiguration { + await rollbackFailedDomainAddition(savedConfiguration) + } + placementStatesByConfigurationIdentifier[configurationIdentifier] = nil + await recordAppFailure( + kind: .domainManagement, + summary: "Could not add the provider domain on an external drive.", + error: error, + category: .fileProvider + ) + errorMessage = "Could not add the provider domain: \(error.localizedDescription)" + statusMessage = nil + } + } + + func moveDomain( + _ configuration: ProviderDomainConfiguration, + toExternalVolume externalVolume: ProviderExternalVolume? + ) async { + let targetStorageLocation: ProviderDomainStorageLocation + if let externalVolume { + guard case .eligible = externalVolume.eligibility else { + errorMessage = "Choose an eligible encrypted APFS drive." + statusMessage = nil + return + } + targetStorageLocation = .externalVolume( + uuid: externalVolume.uuid, + displayName: externalVolume.displayName + ) + } else { + targetStorageLocation = .onThisMac + } + + guard targetStorageLocation != configuration.storageLocation else { return } + guard canMutate(configuration), + domainTransitionConfigurationIdentifiers.insert(configuration.configurationIdentifier).inserted + else { return } + placementStatesByConfigurationIdentifier[configuration.configurationIdentifier] = .moving + defer { domainTransitionConfigurationIdentifiers.remove(configuration.configurationIdentifier) } + + do { + if let externalVolume { + try await externalVolumeSelector.withSecurityScopedAccess(to: externalVolume) { volumeURL in + try await self.performDomainMove( + configuration, + targetStorageLocation: targetStorageLocation, + targetVolumeURL: volumeURL + ) + } + } else { + try await performDomainMove( + configuration, + targetStorageLocation: targetStorageLocation, + targetVolumeURL: nil + ) + } + } catch { + await recordAppFailure( + kind: .domainManagement, + summary: "Could not change File Provider storage.", + error: error, + category: .fileProvider + ) + errorMessage = "Could not change storage for \(configuration.driveName): \(error.localizedDescription)" + statusMessage = nil + } + } + + func repairDomain(_ configuration: ProviderDomainConfiguration) async { + guard domainTransitionConfigurationIdentifiers.insert(configuration.configurationIdentifier).inserted else { + return + } + placementStatesByConfigurationIdentifier[configuration.configurationIdentifier] = .moving + defer { domainTransitionConfigurationIdentifiers.remove(configuration.configurationIdentifier) } + + do { + guard var journal = try await relocationJournalStore.journal( + configurationIdentifier: configuration.configurationIdentifier + ) else { + if case .authenticationRequired = placementState(for: configuration) { + try await domainRegistrar.reconnectDomain(for: configuration) + try await refreshDomainSystemStates() + errorMessage = nil + return + } + + let repairJournal = ProviderDomainRelocationJournal( + configurationIdentifier: configuration.configurationIdentifier, + sourceConfiguration: configuration, + targetStorageLocation: configuration.storageLocation, + knownFoldersWereActive: false, + phase: .needsRepair + ) + try await relocationJournalStore.save(repairJournal) + try await registerRepairTarget(from: repairJournal) + statusMessage = "Repaired File Provider storage for \(configuration.driveName)." + errorMessage = nil + return + } + + let registeredStates = try await domainRegistrar.registeredDomainStates() + let registeredDomainIdentifiers = Set(registeredStates.map(\.domainIdentifier)) + let currentConfiguration = try await domainStore.configuration( + configurationIdentifier: configuration.configurationIdentifier + ) + + if journal.phase == .knownFolderReclaimRequired { + try await reclaimKnownFolders(for: configuration) + try await relocationJournalStore.remove( + configurationIdentifier: configuration.configurationIdentifier + ) + try await refreshDomainSystemStates() + statusMessage = "Repaired Desktop and Documents for \(configuration.driveName)." + errorMessage = nil + return + } + + + if let currentConfiguration, + currentConfiguration.domainIdentifier == journal.targetDomainIdentifier, + registeredDomainIdentifiers.contains(currentConfiguration.domainIdentifier) { + try await snapshotStore?.removeSnapshots( + domainIdentifier: journal.sourceConfiguration.domainIdentifier + ) + try await eventStore?.removeEvents( + domainIdentifier: journal.sourceConfiguration.domainIdentifier + ) + if journal.knownFoldersWereActive { + try await reclaimKnownFolders(for: currentConfiguration) + } + try await relocationJournalStore.remove( + configurationIdentifier: configuration.configurationIdentifier + ) + domains = try await domainStore.allConfigurations() + try await refreshDomainSystemStates() + statusMessage = "Finished repairing \(configuration.driveName)." + errorMessage = nil + return + } + + if let currentConfiguration, + currentConfiguration.domainIdentifier == journal.sourceConfiguration.domainIdentifier, + registeredDomainIdentifiers.contains(journal.sourceConfiguration.domainIdentifier) { + if journal.knownFoldersWereActive { + try await refreshKnownFolderSyncStates() + if knownFolderSyncState(for: currentConfiguration) != .active { + try await reclaimKnownFolders(for: currentConfiguration) + } + } + try await relocationJournalStore.remove( + configurationIdentifier: configuration.configurationIdentifier + ) + try await refreshDomainSystemStates() + statusMessage = "Restored the original storage for \(configuration.driveName)." + errorMessage = nil + return + } + + journal.phase = .needsRepair + journal.updatedAt = Date() + try await relocationJournalStore.save(journal) + try await registerRepairTarget(from: journal) + statusMessage = "Repaired File Provider storage for \(configuration.driveName)." + errorMessage = nil + } catch { + placementStatesByConfigurationIdentifier[configuration.configurationIdentifier] = .needsRepair( + error.localizedDescription + ) + errorMessage = "Could not repair \(configuration.driveName): \(error.localizedDescription)" + statusMessage = nil + } + } + func removeDomain(_ configuration: ProviderDomainConfiguration) async { let key = driveKey(for: configuration) - guard beginDriveAction(.removingFromFiles, for: key) else { return } - defer { endDriveAction(for: key) } + guard canMutate(configuration), + beginDriveAction(.removingFromFiles, for: key), + domainTransitionConfigurationIdentifiers.insert(configuration.configurationIdentifier).inserted + else { + endDriveAction(for: key) + return + } + defer { + domainTransitionConfigurationIdentifiers.remove(configuration.configurationIdentifier) + endDriveAction(for: key) + } do { try await removeDomainAndLocalState(configuration) let synchronizedState = try await synchronizedDomainConfigurations() domains = synchronizedState.configurations - try await refreshKnownFolderSyncStates() + try await refreshDomainSystemStates() statusMessage = "Removed \(configuration.displayName) from Files." errorMessage = nil } catch { @@ -553,8 +847,20 @@ final class PotassiumProviderAppModel: ObservableObject { return } + let accountDomains = domains(for: account.accountIdentifier) + guard let unavailableDomain = accountDomains.first(where: { canMutate($0) == false }) else { + let identifiers = Set(accountDomains.map(\.configurationIdentifier)) + domainTransitionConfigurationIdentifiers.formUnion(identifiers) + defer { domainTransitionConfigurationIdentifiers.subtract(identifiers) } + await performLogout(account, domains: accountDomains) + return + } + errorMessage = "Connect \(unavailableDomain.storageLocation.userFacingTitle) and finish or repair its File Provider operation before logging out." + statusMessage = nil + } + + private func performLogout(_ account: ProviderAccount, domains accountDomains: [ProviderDomainConfiguration]) async { do { - let accountDomains = domains(for: account.accountIdentifier) for domain in accountDomains { try await removeDomainAndLocalState(domain) } @@ -570,7 +876,7 @@ final class PotassiumProviderAppModel: ObservableObject { accounts = try await accountStore.allAccounts() let synchronizedState = try await synchronizedDomainConfigurations() domains = synchronizedState.configurations - try await refreshKnownFolderSyncStates() + try await refreshDomainSystemStates() statusMessage = "Logged out \(account.displayName)." errorMessage = nil } catch { @@ -594,7 +900,7 @@ final class PotassiumProviderAppModel: ObservableObject { accounts = try await accountStore.allAccounts() let synchronizedState = try await synchronizedDomainConfigurations() domains = synchronizedState.configurations - try await refreshKnownFolderSyncStates() + try await refreshDomainSystemStates() errorMessage = nil } catch { await recordAppFailure( @@ -719,16 +1025,322 @@ final class PotassiumProviderAppModel: ObservableObject { return Data(base64Encoded: payload) } + private func performDomainMove( + _ sourceConfiguration: ProviderDomainConfiguration, + targetStorageLocation: ProviderDomainStorageLocation, + targetVolumeURL: URL? + ) async throws { + try await refreshKnownFolderSyncStates() + let knownFoldersWereActive: Bool + switch knownFolderSyncState(for: sourceConfiguration) { + case .active, .partial: + knownFoldersWereActive = true + case .inactive, .unavailable: + knownFoldersWereActive = false + } + + var journal = ProviderDomainRelocationJournal( + configurationIdentifier: sourceConfiguration.configurationIdentifier, + sourceConfiguration: sourceConfiguration, + targetStorageLocation: targetStorageLocation, + knownFoldersWereActive: knownFoldersWereActive + ) + try await relocationJournalStore.save(journal) + + var sourceWasRemoved = false + var targetWasRegistered = false + do { + try await domainRegistrar.waitForStabilization(for: sourceConfiguration) + if knownFoldersWereActive { + try await domainRegistrar.releaseKnownFolders(for: sourceConfiguration) + journal.phase = .knownFoldersReleased + journal.updatedAt = Date() + try await relocationJournalStore.save(journal) + try await domainRegistrar.waitForStabilization(for: sourceConfiguration) + } + + let prepared = try prepareReplacementDomain( + for: sourceConfiguration, + targetStorageLocation: targetStorageLocation, + targetVolumeURL: targetVolumeURL + ) + journal.targetDomainIdentifier = prepared.domainIdentifier + journal.updatedAt = Date() + try await relocationJournalStore.save(journal) + + let preservedURL = try await domainRegistrar.removeDomainPreservingDirtyUserData( + for: sourceConfiguration + ) + sourceWasRemoved = true + if let preservedURL { + preservedDataLocation = ProviderPreservedDataLocation( + url: preservedURL, + driveName: sourceConfiguration.driveName + ) + } + journal.phase = .sourceRemoved + journal.updatedAt = Date() + try await relocationJournalStore.save(journal) + + var replacementConfiguration = sourceConfiguration + replacementConfiguration.domainIdentifier = prepared.domainIdentifier + replacementConfiguration.storageLocation = targetStorageLocation + replacementConfiguration.updatedAt = Date() + try await domainStore.save(replacementConfiguration) + journal.phase = .targetConfigurationSaved + journal.updatedAt = Date() + try await relocationJournalStore.save(journal) + + try await domainRegistrar.addPreparedDomain(prepared) + targetWasRegistered = true + journal.phase = .targetRegistered + journal.updatedAt = Date() + try await relocationJournalStore.save(journal) + + domains = try await domainStore.allConfigurations() + try await snapshotStore?.removeSnapshots( + domainIdentifier: sourceConfiguration.domainIdentifier + ) + try await eventStore?.removeEvents( + domainIdentifier: sourceConfiguration.domainIdentifier + ) + + if knownFoldersWereActive { + do { + try await reclaimKnownFolders(for: replacementConfiguration) + } catch { + journal.phase = .knownFolderReclaimRequired + journal.updatedAt = Date() + try await relocationJournalStore.save(journal) + placementStatesByConfigurationIdentifier[sourceConfiguration.configurationIdentifier] = .needsRepair( + "Storage changed, but Desktop & Documents still need permission." + ) + statusMessage = "Moved \(sourceConfiguration.driveName), but Desktop and Documents need repair." + errorMessage = nil + return + } + } + + try await relocationJournalStore.remove( + configurationIdentifier: sourceConfiguration.configurationIdentifier + ) + try await refreshDomainSystemStates() + statusMessage = "Moved \(sourceConfiguration.driveName) to \(targetStorageLocation.userFacingTitle)." + errorMessage = nil + } catch { + let registeredStatesAfterFailure = try? await domainRegistrar.registeredDomainStates() + let targetIsInRegisteredStates = registeredStatesAfterFailure?.contains { + $0.domainIdentifier == journal.targetDomainIdentifier + } == true + let targetAppearsRegistered = targetWasRegistered || targetIsInRegisteredStates + if targetAppearsRegistered { + journal.phase = .targetRegistered + journal.updatedAt = Date() + try? await relocationJournalStore.save(journal) + placementStatesByConfigurationIdentifier[sourceConfiguration.configurationIdentifier] = .needsRepair( + "The new storage is registered, but cleanup still needs to finish." + ) + } else if sourceWasRemoved { + do { + try await recoverSourcePlacement(from: journal) + } catch let recoveryError { + journal.phase = .needsRepair + journal.updatedAt = Date() + try? await relocationJournalStore.save(journal) + placementStatesByConfigurationIdentifier[sourceConfiguration.configurationIdentifier] = .needsRepair( + recoveryError.localizedDescription + ) + throw PotassiumProviderAppModelError.storageMoveAndRecoveryFailed( + move: error.localizedDescription, + recovery: recoveryError.localizedDescription + ) + } + } else { + if knownFoldersWereActive, journal.phase == .knownFoldersReleased { + do { + try await reclaimKnownFolders(for: sourceConfiguration) + } catch { + journal.phase = .knownFolderReclaimRequired + journal.updatedAt = Date() + try? await relocationJournalStore.save(journal) + placementStatesByConfigurationIdentifier[sourceConfiguration.configurationIdentifier] = .needsRepair( + "Desktop & Documents need permission after the canceled storage change." + ) + throw error + } + } + try? await relocationJournalStore.remove( + configurationIdentifier: sourceConfiguration.configurationIdentifier + ) + try? await refreshDomainSystemStates() + } + throw error + } + } + + private func prepareReplacementDomain( + for sourceConfiguration: ProviderDomainConfiguration, + targetStorageLocation: ProviderDomainStorageLocation, + targetVolumeURL: URL? + ) throws -> ProviderPreparedDomain { + let target: ProviderDomainPreparationTarget + switch targetStorageLocation { + case .onThisMac: + target = .onThisMac + case .externalVolume(let expectedVolumeUUID, _): + guard let targetVolumeURL else { + throw PotassiumProviderAppModelError.externalVolumeUnavailable + } + target = .externalVolume(targetVolumeURL) + let prepared = try domainRegistrar.prepareDomain( + configurationIdentifier: sourceConfiguration.configurationIdentifier, + domainIdentifier: UUID().uuidString, + displayName: sourceConfiguration.displayName, + target: target + ) + guard prepared.volumeUUID == expectedVolumeUUID else { + throw PotassiumProviderAppModelError.externalVolumeIdentityMismatch + } + return prepared + } + + return try domainRegistrar.prepareDomain( + configurationIdentifier: sourceConfiguration.configurationIdentifier, + domainIdentifier: UUID().uuidString, + displayName: sourceConfiguration.displayName, + target: target + ) + } + + private func recoverSourcePlacement( + from journal: ProviderDomainRelocationJournal + ) async throws { + let source = journal.sourceConfiguration + let recovered: ProviderDomainConfiguration + + switch source.storageLocation { + case .onThisMac: + recovered = try await prepareSaveAndRegisterRecovery( + source, + targetStorageLocation: .onThisMac, + volumeURL: nil + ) + case .externalVolume(let volumeUUID, _): + guard let mountedVolume = try externalVolumeSelector.mountedVolume(uuid: volumeUUID) else { + throw PotassiumProviderAppModelError.externalVolumeUnavailable + } + recovered = try await externalVolumeSelector.withSecurityScopedAccess(to: mountedVolume) { volumeURL in + try await self.prepareSaveAndRegisterRecovery( + source, + targetStorageLocation: source.storageLocation, + volumeURL: volumeURL + ) + } + } + + try await snapshotStore?.removeSnapshots(domainIdentifier: source.domainIdentifier) + try await eventStore?.removeEvents(domainIdentifier: source.domainIdentifier) + if journal.knownFoldersWereActive { + try await reclaimKnownFolders(for: recovered) + } + try await relocationJournalStore.remove( + configurationIdentifier: source.configurationIdentifier + ) + domains = try await domainStore.allConfigurations() + try await refreshDomainSystemStates() + } + + private func prepareSaveAndRegisterRecovery( + _ source: ProviderDomainConfiguration, + targetStorageLocation: ProviderDomainStorageLocation, + volumeURL: URL? + ) async throws -> ProviderDomainConfiguration { + let prepared = try prepareReplacementDomain( + for: source, + targetStorageLocation: targetStorageLocation, + targetVolumeURL: volumeURL + ) + var recovered = source + recovered.domainIdentifier = prepared.domainIdentifier + recovered.storageLocation = targetStorageLocation + recovered.updatedAt = Date() + try await domainStore.save(recovered) + try await domainRegistrar.addPreparedDomain(prepared) + return recovered + } + + private func registerRepairTarget( + from journal: ProviderDomainRelocationJournal + ) async throws { + let source = journal.sourceConfiguration + let repaired: ProviderDomainConfiguration + switch journal.targetStorageLocation { + case .onThisMac: + repaired = try await prepareSaveAndRegisterRecovery( + source, + targetStorageLocation: .onThisMac, + volumeURL: nil + ) + case .externalVolume(let volumeUUID, _): + guard let mountedVolume = try externalVolumeSelector.mountedVolume(uuid: volumeUUID) else { + throw PotassiumProviderAppModelError.externalVolumeUnavailable + } + repaired = try await externalVolumeSelector.withSecurityScopedAccess(to: mountedVolume) { volumeURL in + try await self.prepareSaveAndRegisterRecovery( + source, + targetStorageLocation: journal.targetStorageLocation, + volumeURL: volumeURL + ) + } + } + + try await snapshotStore?.removeSnapshots(domainIdentifier: source.domainIdentifier) + try await eventStore?.removeEvents(domainIdentifier: source.domainIdentifier) + if journal.knownFoldersWereActive { + do { + try await reclaimKnownFolders(for: repaired) + } catch { + var reclaimJournal = journal + reclaimJournal.phase = .knownFolderReclaimRequired + reclaimJournal.updatedAt = Date() + try await relocationJournalStore.save(reclaimJournal) + domains = try await domainStore.allConfigurations() + try await refreshDomainSystemStates() + return + } + } + try await relocationJournalStore.remove(configurationIdentifier: source.configurationIdentifier) + domains = try await domainStore.allConfigurations() + try await refreshDomainSystemStates() + } + + private func reclaimKnownFolders(for configuration: ProviderDomainConfiguration) async throws { + let token = try await usableToken(accountIdentifier: configuration.accountIdentifier) + let parentFileID = try await KDrivePrivateDirectoryResolver.resolveFileID( + driveID: configuration.driveID, + rootFileID: configuration.rootFileID, + remote: fileProviderFactory(token.accessToken) + ) + try await domainRegistrar.claimKnownFolders( + for: configuration, + parentFileID: parentFileID + ) + } + private func removeDomainAndLocalState(_ configuration: ProviderDomainConfiguration) async throws { try await releaseKnownFoldersBeforeRemovingDomain(configuration) try await domainRegistrar.removeDomain(for: configuration) try await snapshotStore?.removeSnapshots(domainIdentifier: configuration.domainIdentifier) try await eventStore?.removeEvents(domainIdentifier: configuration.domainIdentifier) try await domainStore.remove(configurationIdentifier: configuration.configurationIdentifier) + try await relocationJournalStore.remove( + configurationIdentifier: configuration.configurationIdentifier + ) knownFolderSyncStatesByConfigurationIdentifier[configuration.configurationIdentifier] = nil } private func rollbackFailedDomainAddition(_ configuration: ProviderDomainConfiguration) async { + try? await domainRegistrar.removeDomain(for: configuration) try? await snapshotStore?.removeSnapshots(domainIdentifier: configuration.domainIdentifier) try? await domainStore.remove(configurationIdentifier: configuration.configurationIdentifier) @@ -816,6 +1428,77 @@ final class PotassiumProviderAppModel: ObservableObject { }) } + private func refreshDomainSystemStates() async throws { + try await refreshKnownFolderSyncStates() + try await refreshPlacementStates() + } + + private func refreshPlacementStates() async throws { + let registeredStates = try await domainRegistrar.registeredDomainStates() + let registeredByDomainIdentifier = Dictionary( + uniqueKeysWithValues: registeredStates.map { ($0.domainIdentifier, $0) } + ) + let journals = try await relocationJournalStore.allJournals() + let journalsByConfigurationIdentifier = Dictionary( + uniqueKeysWithValues: journals.map { ($0.configurationIdentifier, $0) } + ) + + var states: [String: ProviderDomainPlacementState] = [:] + for configuration in domains { + if let journal = journalsByConfigurationIdentifier[configuration.configurationIdentifier] { + switch journal.phase { + case .preparing, .knownFoldersReleased, .sourceRemoved, + .targetConfigurationSaved, .targetRegistered: + states[configuration.configurationIdentifier] = .needsRepair( + "A storage change was interrupted. Repair it before continuing." + ) + case .knownFolderReclaimRequired: + states[configuration.configurationIdentifier] = .needsRepair( + "Storage changed, but Desktop & Documents still need permission." + ) + case .needsRepair: + states[configuration.configurationIdentifier] = .needsRepair( + "File Provider could not finish the storage change." + ) + } + continue + } + + if let registeredState = registeredByDomainIdentifier[configuration.domainIdentifier] { + if case .externalVolume(let volumeUUID, _) = configuration.storageLocation, + registeredState.configurationIdentifier != configuration.configurationIdentifier || + registeredState.volumeUUID != volumeUUID { + states[configuration.configurationIdentifier] = .needsRepair( + "The registered external domain does not match this Mac's configuration." + ) + } else if registeredState.isDisconnected { + states[configuration.configurationIdentifier] = .authenticationRequired + } else { + states[configuration.configurationIdentifier] = .connected + } + continue + } + + switch configuration.storageLocation { + case .onThisMac: + #if os(macOS) + states[configuration.configurationIdentifier] = .needsRepair( + "The File Provider domain is missing from this Mac." + ) + #else + // Other platforms do not expose registered-domain state. + states[configuration.configurationIdentifier] = .connected + #endif + case .externalVolume(let volumeUUID, _): + let mountedVolume = try externalVolumeSelector.mountedVolume(uuid: volumeUUID) + states[configuration.configurationIdentifier] = mountedVolume == nil + ? .volumeUnavailable + : .needsRepair("The drive is connected, but its File Provider domain is missing.") + } + } + placementStatesByConfigurationIdentifier = states + } + private func releaseKnownFoldersBeforeRemovingDomain(_ configuration: ProviderDomainConfiguration) async throws { #if os(macOS) try await refreshKnownFolderSyncStates() @@ -836,6 +1519,7 @@ final class PotassiumProviderAppModel: ObservableObject { .sink { [weak self] _ in Task { @MainActor [weak self] in try? await self?.refreshKnownFolderSyncStates() + try? await self?.refreshPlacementStates() } } #endif @@ -897,6 +1581,22 @@ final class PotassiumProviderAppModel: ObservableObject { ) } + private static func makeDefaultRelocationJournalStore() -> any ProviderDomainRelocationJournaling { + if let appGroupStore = try? ProviderDomainRelocationFileStore( + appGroupIdentifier: ProviderConstants.appGroupIdentifier + ) { + return appGroupStore + } + + let applicationSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first ?? FileManager.default.temporaryDirectory + return ProviderDomainRelocationFileStore( + directoryURL: applicationSupport + .appendingPathComponent("potassiumProvider", isDirectory: true) + .appendingPathComponent("DomainRelocations", isDirectory: true) + ) + } + private static func makeDefaultSnapshotStore() -> (any KDriveSnapshotStoring)? { try? KDriveSnapshotSQLiteStore(appGroupIdentifier: ProviderConstants.appGroupIdentifier) } @@ -956,6 +1656,9 @@ final class PotassiumProviderAppModel: ObservableObject { private func synchronizedDomainConfigurations() async throws -> (configurations: [ProviderDomainConfiguration], registrationError: Error?) { var configurations = try await domainStore.allConfigurations() + let pendingRelocationConfigurationIdentifiers = Set( + try await relocationJournalStore.allJournals().map(\.configurationIdentifier) + ) let accountLookup = Dictionary(uniqueKeysWithValues: accounts.map { ($0.accountIdentifier, $0) }) let displayNames = desiredDomainDisplayNames(for: configurations, accounts: accountLookup) var registrationError: Error? @@ -968,10 +1671,15 @@ final class PotassiumProviderAppModel: ObservableObject { try await domainStore.save(configurations[index]) } - do { - try await domainRegistrar.addDomain(for: configurations[index]) - } catch { - registrationError = registrationError ?? error + if case .onThisMac = configurations[index].storageLocation, + pendingRelocationConfigurationIdentifiers.contains( + configurations[index].configurationIdentifier + ) == false { + do { + try await domainRegistrar.addDomain(for: configurations[index]) + } catch { + registrationError = registrationError ?? error + } } } @@ -1129,6 +1837,10 @@ enum PotassiumProviderAppModelError: Error, Equatable, LocalizedError { case missingAccount case missingToken case expiredToken + case externalVolumeUnavailable + case externalVolumeIdentityMismatch + case registeredTargetMissing + case storageMoveAndRecoveryFailed(move: String, recovery: String) var errorDescription: String? { switch self { @@ -1138,6 +1850,14 @@ enum PotassiumProviderAppModelError: Error, Equatable, LocalizedError { return "Connect to kDrive before loading drives." case .expiredToken: return "The saved access token has expired. Reconnect to kDrive." + case .externalVolumeUnavailable: + return "Connect the configured external drive and try again." + case .externalVolumeIdentityMismatch: + return "The selected drive changed while File Provider was preparing it." + case .registeredTargetMissing: + return "The replacement File Provider domain is no longer registered." + case .storageMoveAndRecoveryFailed(let move, let recovery): + return "The storage move failed (\(move)) and the original placement could not be recovered (\(recovery))." } } } diff --git a/potassiumProvider/ProviderDomainPlacementState.swift b/potassiumProvider/ProviderDomainPlacementState.swift new file mode 100644 index 0000000..76c7c2c --- /dev/null +++ b/potassiumProvider/ProviderDomainPlacementState.swift @@ -0,0 +1,72 @@ +import Foundation +import PotassiumProviderCore + +enum ProviderDomainPlacementState: Equatable, Sendable { + case connected + case authenticationRequired + case volumeUnavailable + case registering + case moving + case needsRepair(String) + + var isAttentionRequired: Bool { + switch self { + case .authenticationRequired, .volumeUnavailable, .needsRepair: + true + case .connected, .registering, .moving: + false + } + } + + var title: String { + switch self { + case .connected: + "Connected" + case .authenticationRequired: + "Authentication Required" + case .volumeUnavailable: + "External Drive Disconnected" + case .registering: + "Registering" + case .moving: + "Moving" + case .needsRepair: + "Needs Repair" + } + } + + var detail: String? { + switch self { + case .authenticationRequired: + "Reconnect this account, then repair the File Provider connection." + case .volumeUnavailable: + "Connect the configured external drive to continue." + case .needsRepair(let detail): + detail + case .connected, .registering, .moving: + nil + } + } +} + +struct ProviderPreservedDataLocation: Identifiable, Equatable, Sendable { + let id = UUID() + let url: URL + let driveName: String +} + +extension ProviderDomainStorageLocation { + var userFacingTitle: String { + switch self { + case .onThisMac: + "On This Mac" + case .externalVolume(_, let displayName): + displayName + } + } + + var isExternal: Bool { + if case .externalVolume = self { return true } + return false + } +} diff --git a/potassiumProvider/ProviderExternalVolumeSelectionService.swift b/potassiumProvider/ProviderExternalVolumeSelectionService.swift index d6f008e..1841e56 100644 --- a/potassiumProvider/ProviderExternalVolumeSelectionService.swift +++ b/potassiumProvider/ProviderExternalVolumeSelectionService.swift @@ -255,6 +255,8 @@ extension ProviderExternalVolumeSelectionService { return .eligible case .ineligible(let reasons): return .ineligible(unsupportedReasons(from: reasons)) + @unknown default: + return .ineligible([.unknown]) } } } diff --git a/potassiumProviderTests/PotassiumProviderAppModelExternalStorageLifecycleTests.swift b/potassiumProviderTests/PotassiumProviderAppModelExternalStorageLifecycleTests.swift new file mode 100644 index 0000000..0e74929 --- /dev/null +++ b/potassiumProviderTests/PotassiumProviderAppModelExternalStorageLifecycleTests.swift @@ -0,0 +1,699 @@ +#if os(macOS) +import FileProvider +import Foundation +import PotassiumProviderCore +import Testing +@testable import potassiumProvider + +@Suite(.serialized) +@MainActor +struct PotassiumProviderAppModelExternalStorageLifecycleTests { + @Test func movingInternalDomainToExternalStoragePreservesIdentityAndClearsOldState() async throws { + let context = try await makeContext(knownFolderState: .inactive) + defer { try? FileManager.default.removeItem(at: context.directoryURL) } + + await context.model.moveDomain( + context.sourceConfiguration, + toExternalVolume: context.externalVolume + ) + + let movedConfiguration = try #require(try await context.domainStore.configuration( + configurationIdentifier: context.sourceConfiguration.configurationIdentifier + )) + #expect(movedConfiguration.configurationIdentifier == context.sourceConfiguration.configurationIdentifier) + #expect(movedConfiguration.domainIdentifier != context.sourceConfiguration.domainIdentifier) + #expect(movedConfiguration.storageLocation == .externalVolume( + uuid: context.externalVolume.uuid, + displayName: context.externalVolume.displayName + )) + + let stabilizationIndex = try #require(context.registrar.events.firstIndex { + $0 == .stabilize(domainIdentifier: context.sourceConfiguration.domainIdentifier) + }) + let removalIndex = try #require(context.registrar.events.firstIndex { + $0 == .removePreservingData(domainIdentifier: context.sourceConfiguration.domainIdentifier) + }) + let additionIndex = try #require(context.registrar.events.firstIndex { + if case .addPrepared(let domainIdentifier) = $0 { + return domainIdentifier == movedConfiguration.domainIdentifier + } + return false + }) + #expect(stabilizationIndex < removalIndex) + #expect(removalIndex < additionIndex) + + #expect(await context.snapshotStore.removedDomainIdentifiers == [ + context.sourceConfiguration.domainIdentifier, + ]) + #expect(await context.eventStore.removedDomainIdentifiers == [ + context.sourceConfiguration.domainIdentifier, + ]) + #expect(try await context.journalStore.journal( + configurationIdentifier: context.sourceConfiguration.configurationIdentifier + ) == nil) + #expect(context.model.placementState(for: movedConfiguration) == .connected) + #expect(context.model.errorMessage == nil) + } + + @Test func failedTargetRegistrationRecreatesSourcePlacementWithFreshDomainIdentifier() async throws { + let context = try await makeContext( + knownFolderState: .inactive, + failExternalRegistration: true + ) + defer { try? FileManager.default.removeItem(at: context.directoryURL) } + + await context.model.moveDomain( + context.sourceConfiguration, + toExternalVolume: context.externalVolume + ) + + let failedTargetIdentifier = try #require(context.registrar.events.compactMap { event in + if case .prepare( + domainIdentifier: let domainIdentifier, + target: .externalVolume + ) = event { + return domainIdentifier + } + return nil + }.first) + let recoveredSourceIdentifier = try #require(context.registrar.events.compactMap { event in + if case .prepare( + domainIdentifier: let domainIdentifier, + target: .onThisMac + ) = event { + return domainIdentifier + } + return nil + }.last) + let recoveredConfiguration = try #require(try await context.domainStore.configuration( + configurationIdentifier: context.sourceConfiguration.configurationIdentifier + )) + + #expect(recoveredConfiguration.configurationIdentifier == context.sourceConfiguration.configurationIdentifier) + #expect(recoveredConfiguration.domainIdentifier == recoveredSourceIdentifier) + #expect(recoveredConfiguration.domainIdentifier != context.sourceConfiguration.domainIdentifier) + #expect(recoveredConfiguration.domainIdentifier != failedTargetIdentifier) + #expect(recoveredConfiguration.storageLocation == .onThisMac) + #expect(context.registrar.events.contains( + .addPrepared(domainIdentifier: recoveredSourceIdentifier) + )) + #expect(try await context.journalStore.journal( + configurationIdentifier: context.sourceConfiguration.configurationIdentifier + ) == nil) + #expect(await context.snapshotStore.removedDomainIdentifiers == [ + context.sourceConfiguration.domainIdentifier, + ]) + #expect(await context.eventStore.removedDomainIdentifiers == [ + context.sourceConfiguration.domainIdentifier, + ]) + #expect(context.model.placementState(for: recoveredConfiguration) == .connected) + #expect(context.model.errorMessage?.contains("Could not change storage") == true) + } + + @Test func knownFolderReclaimFailureKeepsSuccessfulPlacementAndPersistsRepairState() async throws { + let context = try await makeContext( + knownFolderState: .active, + failKnownFolderClaim: true + ) + defer { try? FileManager.default.removeItem(at: context.directoryURL) } + + await context.model.moveDomain( + context.sourceConfiguration, + toExternalVolume: context.externalVolume + ) + + let movedConfiguration = try #require(try await context.domainStore.configuration( + configurationIdentifier: context.sourceConfiguration.configurationIdentifier + )) + let journal = try #require(try await context.journalStore.journal( + configurationIdentifier: context.sourceConfiguration.configurationIdentifier + )) + + #expect(movedConfiguration.configurationIdentifier == context.sourceConfiguration.configurationIdentifier) + #expect(movedConfiguration.domainIdentifier != context.sourceConfiguration.domainIdentifier) + #expect(movedConfiguration.storageLocation == .externalVolume( + uuid: context.externalVolume.uuid, + displayName: context.externalVolume.displayName + )) + #expect(journal.phase == .knownFolderReclaimRequired) + #expect(journal.knownFoldersWereActive) + #expect(journal.targetDomainIdentifier == movedConfiguration.domainIdentifier) + #expect(context.registrar.events.contains( + .claim( + domainIdentifier: movedConfiguration.domainIdentifier, + parentFileID: ExternalStorageLifecycleRemote.privateDirectoryFileID + ) + )) + #expect(await context.snapshotStore.removedDomainIdentifiers == [ + context.sourceConfiguration.domainIdentifier, + ]) + #expect(await context.eventStore.removedDomainIdentifiers == [ + context.sourceConfiguration.domainIdentifier, + ]) + + if case .needsRepair(let detail) = context.model.placementState(for: movedConfiguration) { + #expect(detail.contains("Desktop & Documents")) + } else { + Issue.record("Expected a Needs Repair placement after known-folder reclaim failed.") + } + #expect(context.model.statusMessage?.contains("need repair") == true) + #expect(context.model.errorMessage == nil) + } + + @Test func reloadAndRepairRestoreRegisteredSourceWithoutCreatingDuplicateDomain() async throws { + let context = try await makeContext(knownFolderState: .inactive) + defer { try? FileManager.default.removeItem(at: context.directoryURL) } + + let journal = ProviderDomainRelocationJournal( + configurationIdentifier: context.sourceConfiguration.configurationIdentifier, + sourceConfiguration: context.sourceConfiguration, + targetStorageLocation: .externalVolume( + uuid: context.externalVolume.uuid, + displayName: context.externalVolume.displayName + ), + knownFoldersWereActive: false, + phase: .preparing + ) + try await context.journalStore.save(journal) + context.registrar.resetEvents() + + await context.model.reloadStoredState() + + #expect(context.registrar.events.contains( + .addExisting(domainIdentifier: context.sourceConfiguration.domainIdentifier) + ) == false) + #expect(context.model.canMutate(context.sourceConfiguration) == false) + + context.registrar.resetEvents() + await context.model.repairDomain(context.sourceConfiguration) + + #expect(context.registrar.events.contains { event in + if case .prepare = event { return true } + return false + } == false) + #expect(context.registrar.events.contains { event in + if case .addPrepared = event { return true } + return false + } == false) + #expect(try await context.journalStore.journal( + configurationIdentifier: context.sourceConfiguration.configurationIdentifier + ) == nil) + #expect(context.model.placementState(for: context.sourceConfiguration) == .connected) + #expect(context.model.errorMessage == nil) + } + + private func makeContext( + knownFolderState: ProviderKnownFolderSyncState, + failExternalRegistration: Bool = false, + failKnownFolderClaim: Bool = false + ) async throws -> ExternalStorageLifecycleContext { + let directoryURL = FileManager.default.temporaryDirectory.appendingPathComponent( + "external-storage-lifecycle-\(UUID().uuidString)", + isDirectory: true + ) + let accountStore = ProviderAccountFileStore( + directoryURL: directoryURL.appendingPathComponent("Accounts", isDirectory: true) + ) + let domainStore = DomainConfigurationFileStore( + directoryURL: directoryURL.appendingPathComponent("Domains", isDirectory: true) + ) + let journalStore = ProviderDomainRelocationFileStore( + directoryURL: directoryURL.appendingPathComponent("Relocations", isDirectory: true) + ) + let tokenStore = ExternalStorageLifecycleTokenStore() + let snapshotStore = ExternalStorageLifecycleSnapshotStore() + let eventStore = ExternalStorageLifecycleEventStore() + let account = ProviderAccount( + accountIdentifier: "account-1", + displayName: "Test Account", + authenticationKind: .manualAccessToken + ) + let sourceConfiguration = ProviderDomainConfiguration( + configurationIdentifier: "configuration-1", + domainIdentifier: "source-domain-1", + accountIdentifier: account.accountIdentifier, + displayName: "Test Drive", + driveID: 42, + driveName: "Test Drive", + rootFileID: 1, + storageLocation: .onThisMac, + createdAt: Date(timeIntervalSince1970: 1), + updatedAt: Date(timeIntervalSince1970: 1) + ) + try await accountStore.save(account) + try await domainStore.save(sourceConfiguration) + await tokenStore.setToken( + KDriveOAuthToken( + accessToken: "test-token", + tokenType: "Bearer", + refreshToken: nil, + scope: nil, + idToken: nil, + expiresAt: nil + ), + accountIdentifier: account.accountIdentifier + ) + + let volumeURL = directoryURL.appendingPathComponent("External Drive", isDirectory: true) + let volumeUUID = UUID(uuidString: "81B6B7A0-3822-48EF-9C72-6759862DD158")! + let volumeSelector = ProviderExternalVolumeSelectionService( + chooseURL: { volumeURL }, + startAccessing: { _ in false }, + stopAccessing: { _ in }, + volumeRoot: { $0 }, + volumeMetadata: { _ in + ProviderExternalVolumeMetadata( + uuid: volumeUUID, + displayName: "External Drive", + totalCapacity: 2_000, + availableCapacity: 1_000 + ) + }, + checkEligibility: { _ in .eligible }, + mountedVolumeURLs: { [volumeURL] } + ) + let externalVolume = try volumeSelector.inspectVolume(at: volumeURL) + let registrar = ExternalStorageLifecycleRegistrar( + initialConfiguration: sourceConfiguration, + knownFolderState: knownFolderState, + externalVolumeUUID: volumeUUID, + failExternalRegistration: failExternalRegistration, + failKnownFolderClaim: failKnownFolderClaim + ) + let remote = ExternalStorageLifecycleRemote() + let model = PotassiumProviderAppModel( + accountStore: accountStore, + domainStore: domainStore, + tokenStore: tokenStore, + domainRegistrar: registrar, + relocationJournalStore: journalStore, + externalVolumeSelector: volumeSelector, + snapshotStore: snapshotStore, + eventStore: eventStore, + automaticallyReloadStoredState: false, + fileProviderFactory: { _ in remote } + ) + await model.reloadStoredState() + registrar.resetEvents() + + return ExternalStorageLifecycleContext( + directoryURL: directoryURL, + sourceConfiguration: sourceConfiguration, + externalVolume: externalVolume, + domainStore: domainStore, + journalStore: journalStore, + snapshotStore: snapshotStore, + eventStore: eventStore, + registrar: registrar, + model: model + ) + } +} + +@MainActor +private struct ExternalStorageLifecycleContext { + let directoryURL: URL + let sourceConfiguration: ProviderDomainConfiguration + let externalVolume: ProviderExternalVolume + let domainStore: DomainConfigurationFileStore + let journalStore: ProviderDomainRelocationFileStore + let snapshotStore: ExternalStorageLifecycleSnapshotStore + let eventStore: ExternalStorageLifecycleEventStore + let registrar: ExternalStorageLifecycleRegistrar + let model: PotassiumProviderAppModel +} + +private enum ExternalStorageLifecyclePreparationTarget: Equatable { + case onThisMac + case externalVolume +} + +@MainActor +private final class ExternalStorageLifecycleRegistrar: ProviderDomainRegistering { + enum Event: Equatable { + case addExisting(domainIdentifier: String) + case prepare(domainIdentifier: String, target: ExternalStorageLifecyclePreparationTarget) + case addPrepared(domainIdentifier: String) + case refreshRegisteredDomains + case refreshKnownFolders + case stabilize(domainIdentifier: String) + case removePreservingData(domainIdentifier: String) + case reconnect(domainIdentifier: String) + case claim(domainIdentifier: String, parentFileID: Int) + case release(domainIdentifier: String) + case remove(domainIdentifier: String) + } + + private struct Registration { + let configurationIdentifier: String + let domainIdentifier: String + let displayName: String + let volumeUUID: UUID? + } + + private(set) var events: [Event] = [] + private var registrations: [String: Registration] = [:] + private var knownFolderStates: [String: ProviderKnownFolderSyncState] + private var preparedTargets: [String: ProviderDomainPreparationTarget] = [:] + private let externalVolumeUUID: UUID + private let failExternalRegistration: Bool + private let failKnownFolderClaim: Bool + + init( + initialConfiguration: ProviderDomainConfiguration, + knownFolderState: ProviderKnownFolderSyncState, + externalVolumeUUID: UUID, + failExternalRegistration: Bool, + failKnownFolderClaim: Bool + ) { + self.knownFolderStates = [initialConfiguration.domainIdentifier: knownFolderState] + self.externalVolumeUUID = externalVolumeUUID + self.failExternalRegistration = failExternalRegistration + self.failKnownFolderClaim = failKnownFolderClaim + } + + func resetEvents() { + events = [] + } + + func addDomain(for configuration: ProviderDomainConfiguration) async throws { + events.append(.addExisting(domainIdentifier: configuration.domainIdentifier)) + registrations[configuration.domainIdentifier] = Registration( + configurationIdentifier: configuration.configurationIdentifier, + domainIdentifier: configuration.domainIdentifier, + displayName: configuration.displayName, + volumeUUID: nil + ) + } + + func removeDomain(for configuration: ProviderDomainConfiguration) async throws { + events.append(.remove(domainIdentifier: configuration.domainIdentifier)) + registrations[configuration.domainIdentifier] = nil + knownFolderStates[configuration.domainIdentifier] = nil + } + + func prepareDomain( + configurationIdentifier: String, + domainIdentifier: String, + displayName: String, + target: ProviderDomainPreparationTarget + ) throws -> ProviderPreparedDomain { + preparedTargets[domainIdentifier] = target + let volumeUUID: UUID? + switch target { + case .onThisMac: + events.append(.prepare(domainIdentifier: domainIdentifier, target: .onThisMac)) + volumeUUID = nil + case .externalVolume: + events.append(.prepare(domainIdentifier: domainIdentifier, target: .externalVolume)) + volumeUUID = externalVolumeUUID + } + return ProviderPreparedDomain( + configurationIdentifier: configurationIdentifier, + domainIdentifier: domainIdentifier, + volumeUUID: volumeUUID, + fileProviderDomain: NSFileProviderDomain( + identifier: NSFileProviderDomainIdentifier(rawValue: domainIdentifier), + displayName: displayName + ) + ) + } + + func addPreparedDomain(_ preparedDomain: ProviderPreparedDomain) async throws { + events.append(.addPrepared(domainIdentifier: preparedDomain.domainIdentifier)) + if failExternalRegistration, + case .externalVolume = preparedTargets[preparedDomain.domainIdentifier] { + throw ExternalStorageLifecycleTestError.externalRegistrationFailed + } + registrations[preparedDomain.domainIdentifier] = Registration( + configurationIdentifier: preparedDomain.configurationIdentifier, + domainIdentifier: preparedDomain.domainIdentifier, + displayName: preparedDomain.fileProviderDomain.displayName, + volumeUUID: preparedDomain.volumeUUID + ) + knownFolderStates[preparedDomain.domainIdentifier] = .inactive + } + + func registeredDomainStates() async throws -> [ProviderRegisteredDomainState] { + events.append(.refreshRegisteredDomains) + return registrations.values.map { registration in + ProviderRegisteredDomainState( + configurationIdentifier: registration.configurationIdentifier, + domainIdentifier: registration.domainIdentifier, + displayName: registration.displayName, + volumeUUID: registration.volumeUUID, + isDisconnected: false, + isUserEnabled: true, + knownFolderSyncState: knownFolderStates[registration.domainIdentifier] ?? .inactive + ) + } + } + + func waitForStabilization(for configuration: ProviderDomainConfiguration) async throws { + events.append(.stabilize(domainIdentifier: configuration.domainIdentifier)) + } + + func removeDomainPreservingDirtyUserData( + for configuration: ProviderDomainConfiguration + ) async throws -> URL? { + events.append(.removePreservingData(domainIdentifier: configuration.domainIdentifier)) + registrations[configuration.domainIdentifier] = nil + knownFolderStates[configuration.domainIdentifier] = nil + return nil + } + + func reconnectDomain(for configuration: ProviderDomainConfiguration) async throws { + events.append(.reconnect(domainIdentifier: configuration.domainIdentifier)) + } + + func knownFolderSyncStates() async throws -> [String: ProviderKnownFolderSyncState] { + events.append(.refreshKnownFolders) + return knownFolderStates + } + + func claimKnownFolders( + for configuration: ProviderDomainConfiguration, + parentFileID: Int + ) async throws { + events.append(.claim( + domainIdentifier: configuration.domainIdentifier, + parentFileID: parentFileID + )) + if failKnownFolderClaim { + throw ExternalStorageLifecycleTestError.knownFolderClaimFailed + } + knownFolderStates[configuration.domainIdentifier] = .active + } + + func releaseKnownFolders(for configuration: ProviderDomainConfiguration) async throws { + events.append(.release(domainIdentifier: configuration.domainIdentifier)) + knownFolderStates[configuration.domainIdentifier] = .inactive + } +} + +private actor ExternalStorageLifecycleTokenStore: OAuthTokenStoring { + private var tokens: [String: KDriveOAuthToken] = [:] + + func setToken(_ token: KDriveOAuthToken, accountIdentifier: String) { + tokens[accountIdentifier] = token + } + + func loadToken(accountIdentifier: String) throws -> KDriveOAuthToken? { + tokens[accountIdentifier] + } + + func saveToken(_ token: KDriveOAuthToken, accountIdentifier: String) throws { + tokens[accountIdentifier] = token + } + + func deleteToken(accountIdentifier: String) throws { + tokens[accountIdentifier] = nil + } + + func loadLegacyToken() throws -> KDriveOAuthToken? { + nil + } + + func deleteLegacyToken() throws {} +} + +private actor ExternalStorageLifecycleSnapshotStore: KDriveSnapshotStoring { + private(set) var removedDomainIdentifiers: [String] = [] + + func snapshot(domainIdentifier: String, containerIdentifier: String) throws -> KDriveSnapshot? { + nil + } + + func item(domainIdentifier: String, fileID: Int) throws -> KDriveRemoteItem? { + nil + } + + func save( + _ snapshot: KDriveSnapshot, + domainIdentifier: String, + containerIdentifier: String, + condition: KDriveSnapshotSaveCondition + ) throws {} + + func removeSnapshot(domainIdentifier: String, containerIdentifier: String) throws {} + + func removeSnapshots(domainIdentifier: String) throws { + removedDomainIdentifiers.append(domainIdentifier) + } +} + +private actor ExternalStorageLifecycleEventStore: KDriveProviderEventStoring { + private(set) var removedDomainIdentifiers: [String] = [] + + func saveConflict(_ event: KDriveConflictEvent) throws {} + + func recordActivity(_ event: KDriveProviderActivityEvent) throws {} + + func recentConflicts(domainIdentifier: String?, limit: Int) throws -> [KDriveConflictEvent] { + [] + } + + func recentActivity( + domainIdentifier: String?, + limit: Int + ) throws -> [KDriveProviderActivityEvent] { + [] + } + + func recentActivity( + domainIdentifier: String?, + outcome: KDriveProviderActivityOutcome?, + limit: Int + ) throws -> [KDriveProviderActivityEvent] { + [] + } + + func removeActivityAndResolvedConflicts(domainIdentifier: String?) throws {} + + func removeEvents(domainIdentifier: String) throws { + removedDomainIdentifiers.append(domainIdentifier) + } +} + +private struct ExternalStorageLifecycleRemote: KDriveFileProviding { + static let privateDirectoryFileID = 77 + + func listDrives() async throws -> [KDriveDriveSummary] { + throw ExternalStorageLifecycleTestError.unexpectedRemoteCall + } + + func item(driveID: Int, fileID: Int) async throws -> KDriveRemoteItem { + throw ExternalStorageLifecycleTestError.unexpectedRemoteCall + } + + func listDirectory( + driveID: Int, + folderID: Int, + cursor: String?, + limit: Int + ) async throws -> KDriveItemPage { + KDriveItemPage( + items: [KDriveRemoteItem( + id: Self.privateDirectoryFileID, + name: KDrivePrivateDirectoryResolver.directoryName, + type: "dir", + status: "ok", + driveID: driveID, + parentID: folderID, + path: "/private", + size: nil, + mimeType: nil, + createdAt: nil, + modifiedAt: Date(timeIntervalSince1970: 1), + updatedAt: Date(timeIntervalSince1970: 1) + )], + nextCursor: nil, + hasMore: false + ) + } + + func listAdvancedDirectory( + driveID: Int, + folderID: Int, + cursor: String?, + limit: Int + ) async throws -> KDriveAdvancedItemPage { + throw ExternalStorageLifecycleTestError.unexpectedRemoteCall + } + + func listTrash(driveID: Int, cursor: String?, limit: Int) async throws -> KDriveItemPage { + throw ExternalStorageLifecycleTestError.unexpectedRemoteCall + } + + func downloadFile(driveID: Int, fileID: Int) async throws -> Data { + throw ExternalStorageLifecycleTestError.unexpectedRemoteCall + } + + func thumbnail(driveID: Int, fileID: Int, width: Int?, height: Int?) async throws -> Data { + throw ExternalStorageLifecycleTestError.unexpectedRemoteCall + } + + func uploadFile( + driveID: Int, + parentID: Int, + fileName: String, + contents: Data, + lastModifiedAt: Date?, + conflictStrategy: KDriveUploadConflictStrategy + ) async throws -> KDriveRemoteItem { + throw ExternalStorageLifecycleTestError.unexpectedRemoteCall + } + + func replaceFile( + driveID: Int, + parentID: Int, + fileName: String, + contents: Data, + lastModifiedAt: Date? + ) async throws -> KDriveRemoteItem { + throw ExternalStorageLifecycleTestError.unexpectedRemoteCall + } + + func createDirectory(driveID: Int, parentID: Int, name: String) async throws -> KDriveRemoteItem { + throw ExternalStorageLifecycleTestError.unexpectedRemoteCall + } + + func renameItem(driveID: Int, fileID: Int, name: String) async throws { + throw ExternalStorageLifecycleTestError.unexpectedRemoteCall + } + + func moveItem( + driveID: Int, + fileID: Int, + destinationParentID: Int, + name: String? + ) async throws { + throw ExternalStorageLifecycleTestError.unexpectedRemoteCall + } + + func trashItem(driveID: Int, fileID: Int) async throws { + throw ExternalStorageLifecycleTestError.unexpectedRemoteCall + } + + func deleteTrashedItem(driveID: Int, fileID: Int) async throws { + throw ExternalStorageLifecycleTestError.unexpectedRemoteCall + } +} + +private enum ExternalStorageLifecycleTestError: LocalizedError { + case externalRegistrationFailed + case knownFolderClaimFailed + case unexpectedRemoteCall + + var errorDescription: String? { + switch self { + case .externalRegistrationFailed: + "The external domain registration failed." + case .knownFolderClaimFailed: + "The known-folder claim failed." + case .unexpectedRemoteCall: + "The test made an unexpected remote call." + } + } +} +#endif diff --git a/potassiumProviderTests/ProviderDomainRelocationJournalTests.swift b/potassiumProviderTests/ProviderDomainRelocationJournalTests.swift index a6e7a43..8ba9d56 100644 --- a/potassiumProviderTests/ProviderDomainRelocationJournalTests.swift +++ b/potassiumProviderTests/ProviderDomainRelocationJournalTests.swift @@ -11,13 +11,16 @@ struct ProviderDomainRelocationJournalTests { defer { try? FileManager.default.removeItem(at: directoryURL) } let store = ProviderDomainRelocationFileStore(directoryURL: directoryURL) + let timestamp = Date(timeIntervalSince1970: 1_000) let source = ProviderDomainConfiguration( configurationIdentifier: "configuration-1", domainIdentifier: "domain-old", displayName: "Team", driveID: 42, driveName: "Team", - storageLocation: .onThisMac + storageLocation: .onThisMac, + createdAt: timestamp, + updatedAt: timestamp ) var journal = ProviderDomainRelocationJournal( configurationIdentifier: source.configurationIdentifier, @@ -26,7 +29,9 @@ struct ProviderDomainRelocationJournalTests { volumeUUID: UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!, displayName: "Portable" ), - knownFoldersWereActive: true + knownFoldersWereActive: true, + createdAt: timestamp, + updatedAt: timestamp ) try await store.save(journal) From 8ed53c8db96a2e7169ed2400ad6902fcde2168e0 Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sun, 19 Jul 2026 09:35:43 +0200 Subject: [PATCH 5/9] feat: add external storage controls and status --- potassiumProvider/ProviderSetupView.swift | 168 +++++++++- potassiumProvider/ProviderStatusView.swift | 133 +++++++- .../ProviderStorageSelectionSheet.swift | 297 ++++++++++++++++++ .../ProviderStatusPlacementTests.swift | 167 ++++++++++ 4 files changed, 757 insertions(+), 8 deletions(-) create mode 100644 potassiumProvider/ProviderStorageSelectionSheet.swift create mode 100644 potassiumProviderTests/ProviderStatusPlacementTests.swift diff --git a/potassiumProvider/ProviderSetupView.swift b/potassiumProvider/ProviderSetupView.swift index 6abe331..a92dbf7 100644 --- a/potassiumProvider/ProviderSetupView.swift +++ b/potassiumProvider/ProviderSetupView.swift @@ -1,6 +1,10 @@ import PotassiumProviderCore import SwiftUI +#if os(macOS) +import AppKit +#endif + enum ProviderSetupRoute: Hashable { case addAccount case account(String) @@ -102,6 +106,25 @@ struct ProviderSetupView: View { reduceMotion ? .easeOut(duration: 0.16) : .snappy(duration: 0.28, extraBounce: 0), value: model.errorMessage ) + #if os(macOS) + .alert( + "Local Data Preserved", + isPresented: preservedDataAlertBinding, + presenting: model.preservedDataLocation + ) { location in + Button("Reveal in Finder") { + NSWorkspace.shared.activateFileViewerSelecting([location.url]) + model.preservedDataLocation = nil + } + .accessibilityIdentifier("preserved-data-reveal-in-finder") + Button("Dismiss", role: .cancel) { + model.preservedDataLocation = nil + } + .accessibilityIdentifier("preserved-data-dismiss") + } message: { location in + Text("macOS preserved local data from \(location.driveName) at \(location.url.path). Review it before deleting it.") + } + #endif } private var accountList: some View { @@ -209,6 +232,18 @@ struct ProviderSetupView: View { .topBarTrailing #endif } + + #if os(macOS) + private var preservedDataAlertBinding: Binding { + Binding { + model.preservedDataLocation != nil + } set: { isPresented in + if isPresented == false { + model.preservedDataLocation = nil + } + } + } + #endif } private struct ProviderAccountRow: View { @@ -520,6 +555,9 @@ private struct ProviderDriveManagementView: View { @State private var isRemovalConfirmationPresented = false @State private var isStopSyncConfirmationPresented = false + #if os(macOS) + @State private var isStorageSelectionPresented = false + #endif var body: some View { Group { @@ -578,6 +616,16 @@ private struct ProviderDriveManagementView: View { } message: { Text("macOS will stop replicating both folders with kDrive. Remote files in \(knownFolderRemotePath) are not deleted.") } + .sheet(isPresented: $isStorageSelectionPresented) { + if let descriptor { + ProviderStorageSelectionSheet( + purpose: storageSelectionPurpose(for: descriptor), + selectExternalVolume: model.selectExternalVolume + ) { externalVolume in + applyStorageSelection(externalVolume, to: descriptor) + } + } + } #endif } @@ -594,7 +642,13 @@ private struct ProviderDriveManagementView: View { } private var isBusy: Bool { - activeAction != nil || model.isLoadingDrives(for: key.accountIdentifier) + activeAction != nil || + model.isLoadingDrives(for: key.accountIdentifier) || + (descriptor?.configuration.map(model.isTransitioning) ?? false) + } + + private var canMutateConfiguredDomain: Bool { + descriptor?.configuration.map(model.canMutate) ?? true } private var knownFolderRemotePath: String { @@ -639,12 +693,16 @@ private struct ProviderDriveManagementView: View { if descriptor.configuration == nil, let remote = descriptor.remote { Button { + #if os(macOS) + isStorageSelectionPresented = true + #else Task { await model.addDomain( accountIdentifier: key.accountIdentifier, drive: remote ) } + #endif } label: { actionLabel( title: "Add to Files", @@ -679,7 +737,7 @@ private struct ProviderDriveManagementView: View { ) #endif } - .disabled(isBusy) + .disabled(isBusy || canMutateConfiguredDomain == false) .accessibilityIdentifier("drive.showInFiles") Button { @@ -691,13 +749,14 @@ private struct ProviderDriveManagementView: View { action: .syncingNow ) } - .disabled(isBusy) + .disabled(isBusy || canMutateConfiguredDomain == false) .accessibilityIdentifier("drive.syncNow") } } #if os(macOS) if let configuration = descriptor.configuration { + storageSection(configuration) knownFolderSection(configuration) } #endif @@ -713,7 +772,7 @@ private struct ProviderDriveManagementView: View { Button("Remove from Files", role: .destructive) { isRemovalConfirmationPresented = true } - .disabled(isBusy) + .disabled(isBusy || canMutateConfiguredDomain == false) .accessibilityIdentifier("drive.removeFromFiles") } footer: { Text("Removing this drive clears its provider-local state but does not delete remote kDrive files.") @@ -740,6 +799,103 @@ private struct ProviderDriveManagementView: View { } #if os(macOS) + private func storageSection(_ configuration: ProviderDomainConfiguration) -> some View { + let state = model.placementState(for: configuration) + return Section { + LabeledContent("Location", value: configuration.storageLocation.userFacingTitle) + Label(state.title, systemImage: placementStateSystemImage(state)) + .foregroundStyle(state.isAttentionRequired ? .orange : .secondary) + .accessibilityIdentifier("domain-storage-state-\(configuration.configurationIdentifier)") + + if let detail = state.detail { + Text(detail) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + if model.isTransitioning(configuration) { + HStack { + ProgressView() + .controlSize(.small) + Text(state.title) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + } else if state.isAttentionRequired { + Button("Repair Storage") { + Task { await model.repairDomain(configuration) } + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("domain-storage-repair-\(configuration.configurationIdentifier)") + } else { + Button("Change Storage") { + isStorageSelectionPresented = true + } + .disabled(isBusy || model.canMutate(configuration) == false) + .accessibilityIdentifier("domain-storage-change-\(configuration.configurationIdentifier)") + } + } header: { + Text("Storage") + } footer: { + Text("Changing storage recreates the local File Provider cache. Offline files may be downloaded again.") + } + .accessibilityIdentifier("domain-storage-\(configuration.configurationIdentifier)") + } + + private func storageSelectionPurpose( + for descriptor: ProviderDriveDescriptor + ) -> ProviderStorageSelectionPurpose { + if let configuration = descriptor.configuration { + return .move( + driveName: descriptor.name, + currentStorageLocation: configuration.storageLocation + ) + } + return .add(driveName: descriptor.name) + } + + private func applyStorageSelection( + _ externalVolume: ProviderExternalVolume?, + to descriptor: ProviderDriveDescriptor + ) { + Task { + if let configuration = descriptor.configuration { + await model.moveDomain( + configuration, + toExternalVolume: externalVolume + ) + } else if let remote = descriptor.remote { + if let externalVolume { + await model.addDomain( + accountIdentifier: key.accountIdentifier, + drive: remote, + externalVolume: externalVolume + ) + } else { + await model.addDomain( + accountIdentifier: key.accountIdentifier, + drive: remote + ) + } + } + } + } + + private func placementStateSystemImage(_ state: ProviderDomainPlacementState) -> String { + switch state { + case .connected: + "checkmark.circle.fill" + case .authenticationRequired: + "person.crop.circle.badge.exclamationmark" + case .volumeUnavailable: + "externaldrive.badge.exclamationmark" + case .registering, .moving: + "arrow.triangle.2.circlepath" + case .needsRepair: + "wrench.and.screwdriver.fill" + } + } + private func knownFolderSection(_ configuration: ProviderDomainConfiguration) -> some View { let state = model.knownFolderSyncState(for: configuration) let remotePath = model.knownFolderRemotePath(for: configuration) @@ -754,7 +910,7 @@ private struct ProviderDriveManagementView: View { Button("Stop Syncing", role: .destructive) { isStopSyncConfirmationPresented = true } - .disabled(isBusy) + .disabled(isBusy || canMutateConfiguredDomain == false) .accessibilityIdentifier("drive.stopKnownFolders") case .inactive: Button { @@ -767,7 +923,7 @@ private struct ProviderDriveManagementView: View { ) } .buttonStyle(.borderedProminent) - .disabled(isBusy) + .disabled(isBusy || canMutateConfiguredDomain == false) .accessibilityIdentifier("drive.enableKnownFolders") case .unavailable: Label("File Provider status unavailable", systemImage: "exclamationmark.circle") diff --git a/potassiumProvider/ProviderStatusView.swift b/potassiumProvider/ProviderStatusView.swift index 192a0f4..54093aa 100644 --- a/potassiumProvider/ProviderStatusView.swift +++ b/potassiumProvider/ProviderStatusView.swift @@ -85,7 +85,8 @@ struct ProviderStatusView: View { accounts: appModel.accounts, domains: appModel.domains, drivesByAccountIdentifier: appModel.drivesByAccountIdentifier, - loadingDriveAccountIdentifiers: appModel.loadingDriveAccountIdentifiers + loadingDriveAccountIdentifiers: appModel.loadingDriveAccountIdentifiers, + placementStatesByConfigurationIdentifier: appModel.placementStatesByConfigurationIdentifier ) } @@ -100,6 +101,10 @@ struct ProviderStatusView: View { .joined(separator: ",") return "\(accountIdentifier)=\(drives)" }.joined(separator: "|")) + parts.append(appModel.placementStatesByConfigurationIdentifier.keys.sorted().map { configurationIdentifier in + let state = appModel.placementStatesByConfigurationIdentifier[configurationIdentifier] ?? .registering + return "\(configurationIdentifier)=\(state.statusRefreshIdentifier)" + }.joined(separator: "|")) return parts.joined(separator: "#") } @@ -200,6 +205,21 @@ struct ProviderStatusInput: Equatable { let domains: [ProviderDomainConfiguration] let drivesByAccountIdentifier: [String: [KDriveDriveSummary]] let loadingDriveAccountIdentifiers: Set + let placementStatesByConfigurationIdentifier: [String: ProviderDomainPlacementState] + + init( + accounts: [ProviderAccount], + domains: [ProviderDomainConfiguration], + drivesByAccountIdentifier: [String: [KDriveDriveSummary]], + loadingDriveAccountIdentifiers: Set, + placementStatesByConfigurationIdentifier: [String: ProviderDomainPlacementState] = [:] + ) { + self.accounts = accounts + self.domains = domains + self.drivesByAccountIdentifier = drivesByAccountIdentifier + self.loadingDriveAccountIdentifiers = loadingDriveAccountIdentifiers + self.placementStatesByConfigurationIdentifier = placementStatesByConfigurationIdentifier + } } struct ProviderStatusDashboard: Equatable { @@ -252,6 +272,7 @@ struct ProviderStatusDashboard: Equatable { let snapshotStats = snapshotStatisticsByDomain[domain.domainIdentifier] ?? KDriveSnapshotDomainStatistics(domainIdentifier: domain.domainIdentifier) let eventStats = eventStatisticsByDomain[domain.domainIdentifier] ?? KDriveProviderEventDomainStatistics(domainIdentifier: domain.domainIdentifier) let accountName = accountsByIdentifier[domain.accountIdentifier]?.displayName ?? "Account" + let placementState = input.placementStatesByConfigurationIdentifier[domain.configurationIdentifier] ?? .registering return ProviderStatusDrive( configurationIdentifier: domain.configurationIdentifier, domainIdentifier: domain.domainIdentifier, @@ -261,6 +282,8 @@ struct ProviderStatusDashboard: Equatable { driveID: domain.driveID, driveName: domain.driveName, rootFileID: domain.rootFileID, + storageLocation: domain.storageLocation, + placementState: placementState, role: loadedDrive?.role, status: loadedDrive?.status, isInMaintenance: loadedDrive?.isInMaintenance ?? false, @@ -358,6 +381,8 @@ struct ProviderStatusDrive: Equatable, Identifiable { let driveID: Int let driveName: String let rootFileID: Int + let storageLocation: ProviderDomainStorageLocation + let placementState: ProviderDomainPlacementState let role: String? let status: String? let isInMaintenance: Bool @@ -376,9 +401,39 @@ struct ProviderStatusDrive: Equatable, Identifiable { let latestActivityAt: Date? var issueCount: Int { - unresolvedConflictCount + blockedConflictCount + failedConflictCount + recentFailureCount + (isInMaintenance ? 1 : 0) + unresolvedConflictCount + + blockedConflictCount + + failedConflictCount + + recentFailureCount + + (isInMaintenance ? 1 : 0) + + (placementState.isAttentionRequired ? 1 : 0) + } + + var storageTitle: String { + switch storageLocation { + case .onThisMac: + "On This Mac" + case .externalVolume: + "External Drive" + } + } + + var storageVolume: String? { + guard case .externalVolume(_, let displayName) = storageLocation else { return nil } + return displayName + } + + var storageDetail: String { + if let storageVolume { + return "\(storageTitle) · \(storageVolume)" + } + return storageTitle } + var placementTitle: String { placementState.title } + var placementDetail: String? { placementState.detail } + var placementRequiresAttention: Bool { placementState.isAttentionRequired } + var health: ProviderStatusDriveHealth { if issueCount > 0 { return .attention } if snapshotContainerCount == 0 && latestActivityAt == nil { return .quiet } @@ -646,6 +701,33 @@ private struct ProviderStatusDriveCard: View { .labelStyle(.titleAndIcon) } + HStack(alignment: .firstTextBaseline, spacing: 12) { + Label("Storage: \(drive.storageDetail)", systemImage: drive.storageVolume == nil ? "internaldrive" : "externaldrive") + + Spacer(minLength: 8) + + Label("Placement: \(drive.placementTitle)", systemImage: drive.placementState.statusSystemImage) + .foregroundStyle(drive.placementState.statusTint) + } + .font(.caption.weight(.medium)) + + if drive.placementRequiresAttention { + VStack(alignment: .leading, spacing: 4) { + Label(drive.placementTitle, systemImage: "exclamationmark.triangle.fill") + .font(.subheadline.weight(.semibold)) + + if let placementDetail = drive.placementDetail { + Text(placementDetail) + .font(.caption) + } + } + .foregroundStyle(.orange) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 7)) + .accessibilityElement(children: .combine) + } + Divider() LazyVGrid(columns: [GridItem(.adaptive(minimum: 92), spacing: 8)], alignment: .leading, spacing: 8) { @@ -710,3 +792,50 @@ private extension View { } } } + +private extension ProviderDomainPlacementState { + var statusRefreshIdentifier: String { + switch self { + case .connected: + "connected" + case .authenticationRequired: + "authentication-required" + case .volumeUnavailable: + "volume-unavailable" + case .registering: + "registering" + case .moving: + "moving" + case .needsRepair(let detail): + "needs-repair:\(detail)" + } + } + + var statusSystemImage: String { + switch self { + case .connected: + "checkmark.circle" + case .authenticationRequired: + "person.crop.circle.badge.exclamationmark" + case .volumeUnavailable: + "externaldrive.badge.exclamationmark" + case .registering: + "arrow.trianglehead.2.clockwise.rotate.90" + case .moving: + "arrow.left.arrow.right" + case .needsRepair: + "wrench.and.screwdriver" + } + } + + var statusTint: Color { + switch self { + case .connected: + .green + case .registering, .moving: + .blue + case .authenticationRequired, .volumeUnavailable, .needsRepair: + .orange + } + } +} diff --git a/potassiumProvider/ProviderStorageSelectionSheet.swift b/potassiumProvider/ProviderStorageSelectionSheet.swift new file mode 100644 index 0000000..d0335e3 --- /dev/null +++ b/potassiumProvider/ProviderStorageSelectionSheet.swift @@ -0,0 +1,297 @@ +#if os(macOS) +import Foundation +import PotassiumProviderCore +import SwiftUI + +enum ProviderStorageSelectionPurpose { + case add(driveName: String) + case move(driveName: String, currentStorageLocation: ProviderDomainStorageLocation) + + var title: String { + switch self { + case .add(let driveName): + "Use \(driveName) in Files" + case .move(let driveName, _): + "Change Storage for \(driveName)" + } + } + + var explanation: String { + switch self { + case .add: + "Choose where macOS stores this File Provider domain and its local files." + case .move(_, let currentStorageLocation): + "Currently stored on \(currentStorageLocation.userFacingTitle). Choose a new location." + } + } + + var confirmationTitle: String { + switch self { + case .add: + "Use in Files" + case .move: + "Change Storage" + } + } + + var accessibilityIdentifier: String { + switch self { + case .add: + "add-domain-storage-sheet" + case .move: + "change-domain-storage-sheet" + } + } + + var currentStorageLocation: ProviderDomainStorageLocation? { + if case .move(_, let currentStorageLocation) = self { + return currentStorageLocation + } + return nil + } + + var isMove: Bool { + if case .move = self { return true } + return false + } +} + +private enum ProviderStorageSelection: Hashable { + case onThisMac + case externalVolume +} + +struct ProviderStorageSelectionSheet: View { + let purpose: ProviderStorageSelectionPurpose + let selectExternalVolume: @MainActor () async -> ProviderExternalVolume? + let confirm: (ProviderExternalVolume?) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var selection = ProviderStorageSelection.onThisMac + @State private var externalVolume: ProviderExternalVolume? + @State private var isSelectingExternalVolume = false + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + Form { + Section { + Text(purpose.explanation) + .foregroundStyle(.secondary) + + storageChoiceButton( + selection: .onThisMac, + title: "On This Mac", + detail: "Keep File Provider content on this Mac.", + systemImage: "internaldrive" + ) { + selection = .onThisMac + } + .accessibilityIdentifier("storage-location-on-this-mac") + + storageChoiceButton( + selection: .externalVolume, + title: "External Drive", + detail: "Store File Provider content on an encrypted APFS drive.", + systemImage: "externaldrive" + ) { + chooseExternalVolume() + } + .accessibilityIdentifier("storage-location-external-drive") + } header: { + Text("Storage Location") + } + + if selection == .externalVolume { + Section("External Drive") { + if isSelectingExternalVolume { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Inspecting drive…") + .foregroundStyle(.secondary) + } + } else if let externalVolume { + externalVolumeDetails(externalVolume) + Button("Choose a Different Drive…") { + chooseExternalVolume() + } + .accessibilityIdentifier("storage-location-choose-another-external-drive") + } else { + Text("No external drive selected.") + .foregroundStyle(.secondary) + Button("Choose Drive…") { + chooseExternalVolume() + } + .accessibilityIdentifier("storage-location-choose-external-drive") + } + + Label( + "macOS chooses the folder it uses on the selected drive. Keep the drive connected whenever you use this kDrive.", + systemImage: "externaldrive.badge.exclamationmark" + ) + .foregroundStyle(.secondary) + .accessibilityIdentifier("storage-location-external-drive-warning") + } + } + + if purpose.isMove { + Section("Before Changing Storage") { + Label( + "Changing storage recreates the local File Provider cache, so offline files may be downloaded again.", + systemImage: "arrow.triangle.2.circlepath" + ) + Label( + "If Desktop & Documents sync is enabled, macOS may ask for folder permission again.", + systemImage: "folder.badge.questionmark" + ) + } + .accessibilityIdentifier("storage-change-consequences") + } + } + .formStyle(.grouped) + + Divider() + + HStack { + Button("Cancel", role: .cancel) { + dismiss() + } + .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("storage-selection-cancel") + + Spacer() + + Button(purpose.confirmationTitle) { + confirm(selection == .externalVolume ? externalVolume : nil) + dismiss() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(canConfirm == false) + .accessibilityIdentifier("storage-selection-confirm") + } + .padding() + } + .navigationTitle(purpose.title) + } + .frame(minWidth: 540, minHeight: 500) + .accessibilityIdentifier(purpose.accessibilityIdentifier) + } + + private func storageChoiceButton( + selection option: ProviderStorageSelection, + title: String, + detail: String, + systemImage: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: systemImage) + .font(.title3) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .foregroundStyle(.primary) + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: selection == option ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selection == option ? Color.accentColor : Color.secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(selection == option ? .isSelected : []) + } + + @ViewBuilder + private func externalVolumeDetails(_ volume: ProviderExternalVolume) -> some View { + VStack(alignment: .leading, spacing: 5) { + Label(volume.displayName, systemImage: "externaldrive.fill") + .font(.headline) + Text(capacityDescription(for: volume)) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("storage-location-external-drive-capacity") + + switch volume.eligibility { + case .eligible: + Label("Eligible for File Provider storage", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .accessibilityIdentifier("storage-location-external-drive-eligibility") + case .ineligible(let reasons): + VStack(alignment: .leading, spacing: 4) { + Label("This drive cannot be used", systemImage: "xmark.octagon.fill") + .foregroundStyle(.red) + ForEach(displayedUnsupportedReasons(reasons), id: \.self) { reason in + Text(reason.userFacingDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .accessibilityIdentifier("storage-location-external-drive-eligibility") + } + } + } + + private var canConfirm: Bool { + switch selection { + case .onThisMac: + return purpose.currentStorageLocation != .onThisMac + case .externalVolume: + guard let externalVolume, + case .eligible = externalVolume.eligibility + else { + return false + } + if case .externalVolume(let currentUUID, _) = purpose.currentStorageLocation { + return externalVolume.uuid != currentUUID + } + return true + } + } + + private func chooseExternalVolume() { + selection = .externalVolume + guard isSelectingExternalVolume == false else { return } + isSelectingExternalVolume = true + Task { + let selectedVolume = await selectExternalVolume() + if let selectedVolume { + externalVolume = selectedVolume + } + isSelectingExternalVolume = false + } + } + + private func capacityDescription(for volume: ProviderExternalVolume) -> String { + let formattedAvailable = volume.availableCapacity.map(formatByteCount) + let formattedTotal = volume.totalCapacity.map(formatByteCount) + switch (formattedAvailable, formattedTotal) { + case let (available?, total?): + return "\(available) available of \(total)" + case let (available?, nil): + return "\(available) available" + case let (nil, total?): + return "\(total) capacity" + case (nil, nil): + return "Capacity unavailable" + } + } + + private func formatByteCount(_ byteCount: Int64) -> String { + ByteCountFormatter.string(fromByteCount: byteCount, countStyle: .file) + } + + private func displayedUnsupportedReasons( + _ reasons: Set + ) -> [ProviderExternalVolumeUnsupportedReason] { + let displayReasons = reasons.isEmpty ? [.unknown] : Array(reasons) + return displayReasons.sorted(by: { $0.rawValue < $1.rawValue }) + } +} +#endif diff --git a/potassiumProviderTests/ProviderStatusPlacementTests.swift b/potassiumProviderTests/ProviderStatusPlacementTests.swift new file mode 100644 index 0000000..1363361 --- /dev/null +++ b/potassiumProviderTests/ProviderStatusPlacementTests.swift @@ -0,0 +1,167 @@ +import Foundation +import PotassiumProviderCore +import Testing +@testable import potassiumProvider + +@Suite +@MainActor +struct ProviderStatusPlacementTests { + @Test func aggregatesOnlyPlacementStatesThatRequireAttention() throws { + let states: [(String, ProviderDomainPlacementState)] = [ + ("connected", .connected), + ("authentication", .authenticationRequired), + ("disconnected", .volumeUnavailable), + ("registering", .registering), + ("moving", .moving), + ("repair", .needsRepair("Choose Repair to reconnect this drive.")), + ] + let domains = states.enumerated().map { index, entry in + makeDomain( + configurationIdentifier: entry.0, + domainIdentifier: "domain-\(index)", + storageLocation: index == 2 + ? .externalVolume(uuid: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, displayName: "Archive SSD") + : .onThisMac + ) + } + let dashboard = makeDashboard( + domains: domains, + placementStates: Dictionary(uniqueKeysWithValues: states) + ) + + #expect(dashboard.summary.configuredDriveCount == 6) + #expect(dashboard.summary.issueCount == 3) + #expect(dashboard.drives.filter(\.placementRequiresAttention).count == 3) + + let connected = try #require(dashboard.drives.first { $0.id == "connected" }) + #expect(connected.issueCount == 0) + #expect(connected.placementTitle == "Connected") + + let registering = try #require(dashboard.drives.first { $0.id == "registering" }) + #expect(registering.issueCount == 0) + #expect(registering.placementTitle == "Registering") + + let moving = try #require(dashboard.drives.first { $0.id == "moving" }) + #expect(moving.issueCount == 0) + #expect(moving.placementTitle == "Moving") + + let disconnected = try #require(dashboard.drives.first { $0.id == "disconnected" }) + #expect(disconnected.issueCount == 1) + #expect(disconnected.storageTitle == "External Drive") + #expect(disconnected.storageVolume == "Archive SSD") + #expect(disconnected.storageDetail == "External Drive · Archive SSD") + #expect(disconnected.placementTitle == "External Drive Disconnected") + #expect(disconnected.placementDetail == "Connect the configured external drive to continue.") + + let repair = try #require(dashboard.drives.first { $0.id == "repair" }) + #expect(repair.issueCount == 1) + #expect(repair.placementDetail == "Choose Repair to reconnect this drive.") + } + + @Test func placementIssueAddsToExistingDriveAndAppIssues() throws { + let domain = makeDomain( + configurationIdentifier: "configuration-1", + domainIdentifier: "domain-1" + ) + let dashboard = ProviderStatusDashboard.make( + input: ProviderStatusInput( + accounts: [], + domains: [domain], + drivesByAccountIdentifier: [:], + loadingDriveAccountIdentifiers: [], + placementStatesByConfigurationIdentifier: [domain.configurationIdentifier: .authenticationRequired] + ), + snapshotStatistics: [], + eventStatistics: [ + KDriveProviderEventDomainStatistics( + domainIdentifier: domain.domainIdentifier, + unresolvedConflictCount: 1, + blockedConflictCount: 1, + failedConflictCount: 1, + recentFailureCount: 2 + ), + KDriveProviderEventDomainStatistics( + domainIdentifier: ProviderConstants.appActivityDomainIdentifier, + recentFailureCount: 2 + ), + ], + warnings: [] + ) + + let drive = try #require(dashboard.drives.first) + #expect(drive.issueCount == 6) + #expect(dashboard.summary.issueCount == 8) + #expect(drive.placementTitle == "Authentication Required") + #expect(drive.placementRequiresAttention) + } + + @Test func driveIdentityStaysStableWhenFileProviderDomainAndStorageChange() throws { + let configurationIdentifier = "stable-configuration" + let original = makeDomain( + configurationIdentifier: configurationIdentifier, + domainIdentifier: "local-domain" + ) + let migrated = makeDomain( + configurationIdentifier: configurationIdentifier, + domainIdentifier: "NSFPExternal-generated-domain", + storageLocation: .externalVolume( + uuid: UUID(uuidString: "00000000-0000-0000-0000-000000000042")!, + displayName: "Portable SSD" + ) + ) + + let originalDrive = try #require(makeDashboard( + domains: [original], + placementStates: [configurationIdentifier: .moving] + ).drives.first) + let migratedDrive = try #require(makeDashboard( + domains: [migrated], + placementStates: [configurationIdentifier: .connected] + ).drives.first) + + #expect(originalDrive.id == configurationIdentifier) + #expect(migratedDrive.id == configurationIdentifier) + #expect(originalDrive.id == migratedDrive.id) + #expect(originalDrive.domainIdentifier != migratedDrive.domainIdentifier) + #expect(originalDrive.storageDetail == "On This Mac") + #expect(migratedDrive.storageDetail == "External Drive · Portable SSD") + #expect(originalDrive.placementTitle == "Moving") + #expect(migratedDrive.placementTitle == "Connected") + } + + private func makeDashboard( + domains: [ProviderDomainConfiguration], + placementStates: [String: ProviderDomainPlacementState] + ) -> ProviderStatusDashboard { + ProviderStatusDashboard.make( + input: ProviderStatusInput( + accounts: [], + domains: domains, + drivesByAccountIdentifier: [:], + loadingDriveAccountIdentifiers: [], + placementStatesByConfigurationIdentifier: placementStates + ), + snapshotStatistics: [], + eventStatistics: [], + warnings: [] + ) + } + + private func makeDomain( + configurationIdentifier: String, + domainIdentifier: String, + storageLocation: ProviderDomainStorageLocation = .onThisMac + ) -> ProviderDomainConfiguration { + ProviderDomainConfiguration( + domainIdentifier: domainIdentifier, + configurationIdentifier: configurationIdentifier, + accountIdentifier: "account-1", + displayName: configurationIdentifier, + driveID: 42, + driveName: configurationIdentifier, + storageLocation: storageLocation, + createdAt: Date(timeIntervalSince1970: 100), + updatedAt: Date(timeIntervalSince1970: 100) + ) + } +} From dc6afd7adeff4a16b77a22b1d3309b947049786c Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sun, 19 Jul 2026 09:35:52 +0200 Subject: [PATCH 6/9] fix: clean up relocated file provider domains --- .../FileProviderUninstall.swift | 63 ++++++++++++- .../FileProviderUninstallCommand.swift | 89 ++++++++++++++++--- .../FileProviderUninstallTests.swift | 74 +++++++++++++++ 3 files changed, 209 insertions(+), 17 deletions(-) diff --git a/PotassiumProviderCore/FileProviderUninstall.swift b/PotassiumProviderCore/FileProviderUninstall.swift index ec23267..4ce4bf6 100644 --- a/PotassiumProviderCore/FileProviderUninstall.swift +++ b/PotassiumProviderCore/FileProviderUninstall.swift @@ -113,6 +113,7 @@ public struct FileProviderUninstallDomainListingFailure: Equatable, Sendable { public enum FileProviderUninstallStateItemKind: String, Equatable, Sendable { case domainConfiguration + case relocationJournal case sqliteRows } @@ -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? @@ -155,6 +158,7 @@ public struct FileProviderUninstallPlan: Equatable, Sendable { registeredDomains: [FileProviderUninstallRegisteredDomain], domainListingFailure: FileProviderUninstallDomainListingFailure? = nil, storedConfigurations: [ProviderDomainConfiguration], + storedRelocationJournals: [ProviderDomainRelocationJournal] = [], storedAccounts: [ProviderAccount], stateItems: [FileProviderUninstallStateItem], conflictStaging: FileProviderUninstallConflictStagingPlan? @@ -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 @@ -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 || @@ -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) async throws -> [FileProviderUninstallStateItem] + func stateItems( + forConfigurationIdentifiers configurationIdentifiers: Set, + domainIdentifiers: Set + ) 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, + domainIdentifiers: Set + ) 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 @@ -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? @@ -290,8 +328,21 @@ 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( @@ -299,6 +350,7 @@ public struct FileProviderUninstallCoordinator: Sendable { registeredDomains: registeredDomains, domainListingFailure: domainListingFailure, storedConfigurations: storedConfigurations, + storedRelocationJournals: storedRelocationJournals, storedAccounts: storedAccounts, stateItems: stateItems, conflictStaging: conflictStaging @@ -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() diff --git a/potassiumProvider/FileProviderUninstallCommand.swift b/potassiumProvider/FileProviderUninstallCommand.swift index 2d03221..1f638d9 100644 --- a/potassiumProvider/FileProviderUninstallCommand.swift +++ b/potassiumProvider/FileProviderUninstallCommand.swift @@ -192,10 +192,21 @@ private struct SystemFileProviderUninstallDomainManager: FileProviderUninstallDo _ domain: FileProviderUninstallRegisteredDomain, mode: FileProviderUninstallDomainRemovalMode ) async throws -> URL? { - let fileProviderDomain = NSFileProviderDomain( - identifier: NSFileProviderDomainIdentifier(rawValue: domain.identifier), - displayName: domain.displayName - ) + let registeredDomains = try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation<[NSFileProviderDomain], Error>) in + NSFileProviderManager.getDomainsWithCompletionHandler { domains, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: domains) + } + } + } + guard let fileProviderDomain = registeredDomains.first(where: { + $0.identifier.rawValue == domain.identifier + }) else { + throw NSFileProviderError(.noSuchItem) + } return try await withCheckedThrowingContinuation { continuation in NSFileProviderManager.remove(fileProviderDomain, mode: mode.fileProviderMode) { preservedLocation, error in @@ -240,6 +251,7 @@ private struct AppGroupFileProviderUninstallLocalState: FileProviderUninstallLoc private let containerURL: URL private let accountsURL: URL private let domainConfigurationsURL: URL + private let domainRelocationsURL: URL private let snapshotsDatabaseURL: URL private let conflictStagingURL: URL @@ -251,6 +263,7 @@ private struct AppGroupFileProviderUninstallLocalState: FileProviderUninstallLoc self.containerURL = containerURL self.accountsURL = containerURL.appendingPathComponent("Accounts", isDirectory: true) self.domainConfigurationsURL = containerURL.appendingPathComponent("DomainConfigurations", isDirectory: true) + self.domainRelocationsURL = containerURL.appendingPathComponent("DomainRelocations", isDirectory: true) self.snapshotsDatabaseURL = containerURL.appendingPathComponent("Snapshots.sqlite3") self.conflictStagingURL = containerURL.appendingPathComponent("ConflictStaging", isDirectory: true) } @@ -289,19 +302,54 @@ private struct AppGroupFileProviderUninstallLocalState: FileProviderUninstallLoc .sorted { $0.accountIdentifier < $1.accountIdentifier } } + func storedRelocationJournals() throws -> [ProviderDomainRelocationJournal] { + guard FileManager.default.fileExists(atPath: domainRelocationsURL.path) else { + return [] + } + + return try FileManager.default.contentsOfDirectory( + at: domainRelocationsURL, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + .filter { $0.pathExtension == "json" } + .map { try Self.makeDecoder().decode(ProviderDomainRelocationJournal.self, from: Data(contentsOf: $0)) } + .sorted { $0.configurationIdentifier < $1.configurationIdentifier } + } + func stateItems(forDomainIdentifiers domainIdentifiers: Set) throws -> [FileProviderUninstallStateItem] { + try stateItems( + forConfigurationIdentifiers: domainIdentifiers, + domainIdentifiers: domainIdentifiers + ) + } + + func stateItems( + forConfigurationIdentifiers configurationIdentifiers: Set, + domainIdentifiers: Set + ) throws -> [FileProviderUninstallStateItem] { + let sortedConfigurationIdentifiers = configurationIdentifiers.sorted() let sortedDomainIdentifiers = domainIdentifiers.sorted() var items: [FileProviderUninstallStateItem] = [] - for domainIdentifier in sortedDomainIdentifiers { - let configurationURL = domainConfigurationURL(domainIdentifier: domainIdentifier) + for configurationIdentifier in sortedConfigurationIdentifiers { + let configurationURL = domainConfigurationURL(configurationIdentifier: configurationIdentifier) if FileManager.default.fileExists(atPath: configurationURL.path) { items.append(FileProviderUninstallStateItem( kind: .domainConfiguration, - domainIdentifier: domainIdentifier, + domainIdentifier: configurationIdentifier, description: "Domain configuration \(configurationURL.path)" )) } + + let relocationURL = domainRelocationURL(configurationIdentifier: configurationIdentifier) + if FileManager.default.fileExists(atPath: relocationURL.path) { + items.append(FileProviderUninstallStateItem( + kind: .relocationJournal, + domainIdentifier: configurationIdentifier, + description: "Domain relocation journal \(relocationURL.path)" + )) + } } if FileManager.default.fileExists(atPath: snapshotsDatabaseURL.path) { @@ -340,11 +388,6 @@ private struct AppGroupFileProviderUninstallLocalState: FileProviderUninstallLoc } func removeLocalState(domainIdentifier: String) async throws { - let configurationURL = domainConfigurationURL(domainIdentifier: domainIdentifier) - if FileManager.default.fileExists(atPath: configurationURL.path) { - try FileManager.default.removeItem(at: configurationURL) - } - guard FileManager.default.fileExists(atPath: snapshotsDatabaseURL.path) else { return } @@ -356,6 +399,18 @@ private struct AppGroupFileProviderUninstallLocalState: FileProviderUninstallLoc try await eventStore.removeEvents(domainIdentifier: domainIdentifier) } + func removeConfiguration(configurationIdentifier: String) throws { + let url = domainConfigurationURL(configurationIdentifier: configurationIdentifier) + guard FileManager.default.fileExists(atPath: url.path) else { return } + try FileManager.default.removeItem(at: url) + } + + func removeRelocationJournal(configurationIdentifier: String) throws { + let url = domainRelocationURL(configurationIdentifier: configurationIdentifier) + guard FileManager.default.fileExists(atPath: url.path) else { return } + try FileManager.default.removeItem(at: url) + } + func removeAccountRecords(accountIdentifiers: [String]) throws { guard FileManager.default.fileExists(atPath: accountsURL.path) else { return @@ -378,9 +433,15 @@ private struct AppGroupFileProviderUninstallLocalState: FileProviderUninstallLoc try FileManager.default.removeItem(at: conflictStagingURL) } - private func domainConfigurationURL(domainIdentifier: String) -> URL { + private func domainConfigurationURL(configurationIdentifier: String) -> URL { domainConfigurationsURL - .appendingPathComponent(Self.safeFileName(for: domainIdentifier)) + .appendingPathComponent(Self.safeFileName(for: configurationIdentifier)) + .appendingPathExtension("json") + } + + private func domainRelocationURL(configurationIdentifier: String) -> URL { + domainRelocationsURL + .appendingPathComponent(Self.safeFileName(for: configurationIdentifier)) .appendingPathExtension("json") } diff --git a/potassiumProviderTests/FileProviderUninstallTests.swift b/potassiumProviderTests/FileProviderUninstallTests.swift index 08cbbb9..e9358a6 100644 --- a/potassiumProviderTests/FileProviderUninstallTests.swift +++ b/potassiumProviderTests/FileProviderUninstallTests.swift @@ -313,6 +313,55 @@ struct FileProviderUninstallTests { #expect(await tokenStore.didDeleteToken() == false) } + @Test func cleanupSeparatesStableConfigurationsFromCurrentAndJournaledDomainState() async throws { + let configuration = ProviderDomainConfiguration( + configurationIdentifier: "configuration-1", + domainIdentifier: "domain-current", + displayName: "Team", + driveID: 42, + driveName: "Team" + ) + let journal = ProviderDomainRelocationJournal( + configurationIdentifier: configuration.configurationIdentifier, + sourceConfiguration: ProviderDomainConfiguration( + configurationIdentifier: configuration.configurationIdentifier, + domainIdentifier: "domain-source", + displayName: "Team", + driveID: 42, + driveName: "Team" + ), + targetStorageLocation: .onThisMac, + targetDomainIdentifier: "domain-target", + knownFoldersWereActive: false, + phase: .needsRepair + ) + let localState = RecordingUninstallLocalState( + storedConfigurations: [configuration], + storedRelocationJournals: [journal], + stateItems: [] + ) + let coordinator = FileProviderUninstallCoordinator( + domainManager: RecordingUninstallDomainManager(domains: [registeredDomain("domain-current")]), + localState: localState + ) + + let result = try await coordinator.run(options: FileProviderUninstallOptions(confirmed: true)) + + #expect(result.plan.cleanupConfigurationIdentifiers == ["configuration-1"]) + #expect(result.plan.cleanupDomainIdentifiers == [ + "domain-current", + "domain-source", + "domain-target", + ]) + #expect(await localState.removedConfigurationIdentifiers() == ["configuration-1"]) + #expect(await localState.removedJournalIdentifiers() == ["configuration-1"]) + #expect(Set(result.removedLocalStateDomainIdentifiers) == Set([ + "domain-current", + "domain-source", + "domain-target", + ])) + } + private func parsedOptions(_ arguments: [String]) throws -> FileProviderUninstallOptions { guard case .run(let options) = try FileProviderUninstallArgumentParser.parse(arguments: arguments) else { throw FileProviderUninstallTestError.expectedRunInvocation @@ -418,20 +467,25 @@ private actor RecordingUninstallDomainManager: FileProviderUninstallDomainManagi private actor RecordingUninstallLocalState: FileProviderUninstallLocalStateManaging { private let configurations: [ProviderDomainConfiguration] + private let relocationJournals: [ProviderDomainRelocationJournal] private let accounts: [ProviderAccount] private let plannedStateItems: [FileProviderUninstallStateItem] private let conflictStagingFileNames: [String] private var removedIdentifiers: [String] = [] + private var removedConfigurationIDs: [String] = [] + private var removedJournalIDs: [String] = [] private var removedAccounts: [String] = [] private var removedConflictStaging = false init( storedConfigurations: [ProviderDomainConfiguration], + storedRelocationJournals: [ProviderDomainRelocationJournal] = [], storedAccounts: [ProviderAccount] = [], stateItems: [FileProviderUninstallStateItem], conflictStagingFileNames: [String] = [] ) { self.configurations = storedConfigurations + self.relocationJournals = storedRelocationJournals self.accounts = storedAccounts self.plannedStateItems = stateItems self.conflictStagingFileNames = conflictStagingFileNames @@ -441,6 +495,10 @@ private actor RecordingUninstallLocalState: FileProviderUninstallLocalStateManag configurations } + func storedRelocationJournals() -> [ProviderDomainRelocationJournal] { + relocationJournals + } + func storedAccounts() -> [ProviderAccount] { accounts } @@ -470,6 +528,14 @@ private actor RecordingUninstallLocalState: FileProviderUninstallLocalStateManag removedIdentifiers.append(domainIdentifier) } + func removeConfiguration(configurationIdentifier: String) { + removedConfigurationIDs.append(configurationIdentifier) + } + + func removeRelocationJournal(configurationIdentifier: String) { + removedJournalIDs.append(configurationIdentifier) + } + func removeAccountRecords(accountIdentifiers: [String]) { removedAccounts.append(contentsOf: accountIdentifiers) } @@ -482,6 +548,14 @@ private actor RecordingUninstallLocalState: FileProviderUninstallLocalStateManag removedIdentifiers } + func removedConfigurationIdentifiers() -> [String] { + removedConfigurationIDs + } + + func removedJournalIdentifiers() -> [String] { + removedJournalIDs + } + func didRemoveConflictStaging() -> Bool { removedConflictStaging } From 7e8f90699049004c8e583f6c2148a71b3a03ff6b Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sun, 19 Jul 2026 09:38:49 +0200 Subject: [PATCH 7/9] fix: keep external volume setup macOS-only --- .../PotassiumFileProviderExtension.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/potassiumProviderFileProvider/PotassiumFileProviderExtension.swift b/potassiumProviderFileProvider/PotassiumFileProviderExtension.swift index 466bcfa..a9b86de 100644 --- a/potassiumProviderFileProvider/PotassiumFileProviderExtension.swift +++ b/potassiumProviderFileProvider/PotassiumFileProviderExtension.swift @@ -26,12 +26,14 @@ public final class PotassiumFileProviderExtension: NSObject, NSFileProviderRepli self.manager = NSFileProviderManager(for: domain)! #if os(macOS) let isStoredOnExternalVolume = domain.volumeUUID != nil - #else - let isStoredOnExternalVolume = false - #endif self.temporaryDirectoryURL = isStoredOnExternalVolume ? nil : ((try? manager.temporaryDirectoryURL()) ?? FileManager.default.temporaryDirectory) + #else + let isStoredOnExternalVolume = false + self.temporaryDirectoryURL = (try? manager.temporaryDirectoryURL()) + ?? FileManager.default.temporaryDirectory + #endif super.init() if isStoredOnExternalVolume == false { startRemotePolling() From 8c835f4a791427be59bad7cb1a01f739c0714961 Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sun, 19 Jul 2026 09:39:09 +0200 Subject: [PATCH 8/9] docs: describe external file provider storage --- README.md | 38 +++++++++- doc/APP_AND_DOMAINS.md | 132 +++++++++++++++++++++++++++------ doc/ARCHITECTURE.md | 49 +++++++++--- doc/FILE_PROVIDER_CLEANUP.md | 40 +++++++++- doc/FILE_PROVIDER_LIFECYCLE.md | 71 +++++++++++++++++- doc/PERSISTENCE.md | 72 ++++++++++++++++-- doc/TESTING_AND_DEVELOPMENT.md | 65 ++++++++++++++++ 7 files changed, 423 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 6edca12..19a1e65 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,13 @@ data. - [Architecture](doc/ARCHITECTURE.md): targets, modules, persistence, runtime boundaries, and high-level data flow. - [App And Domains](doc/APP_AND_DOMAINS.md): SwiftUI setup app, kDrive loading, - File Provider domain registration, and macOS Desktop & Documents controls. + File Provider domain registration, macOS storage placement, and Desktop & + Documents controls. - [Authentication](doc/AUTHENTICATION.md): OAuth PKCE, manual token entry, keychain storage, refresh behavior, and secret-handling rules. - [File Provider Lifecycle](doc/FILE_PROVIDER_LIFECYCLE.md): Apple callbacks, - known-folder locations, mutations, enumeration, and SQLite touch points. + local and external-volume domains, known-folder locations, mutations, + enumeration, and SQLite touch points. - [Contextual Actions](doc/CONTEXTUAL_ACTIONS.md): Finder/Files favorite, duplicate, restore, share-link, and version-history actions. - [Listing And Versioning](doc/LISTING_AND_VERSIONING.md): how Apple @@ -135,6 +137,36 @@ the command-line matrix above. Use `xcodebuild -showdestinations` to copy exact Mac or visionOS destinations if local Xcode requires a more specific variant. +## External File Provider Storage On macOS + +On macOS 15 or later, a kDrive File Provider domain can be placed on this Mac or +on an eligible external volume. The picker accepts a folder only so the user can +grant access; the app normalizes that choice to its containing volume, and macOS +chooses the provider-managed folder on that volume. It is not arbitrary-folder +sync and the selected folder is not used as a kDrive root. + +Before registration, the app asks Apple's +[`checkDomainsCanBeStoredOnVolume(at:)`](https://developer.apple.com/documentation/fileprovider/nsfileprovidermanager/checkdomainscanbestoredonvolume(at:)) +API whether the volume is eligible. The intended physical target is writable, +local, encrypted APFS. The UI reports Apple's unsupported reasons for an unknown, +non-APFS, unencrypted, read-only, network, or quarantined volume. + +External domains are bound to a stable local configuration identifier, while +Apple generates the File Provider domain identifier. Changing storage therefore +removes and recreates the system domain without changing the drive's identity in +the app. The external domain's opaque `userInfo` contains only a binding schema +version and that local configuration identifier—never account identifiers, +tokens, URLs, or other credentials. Connection approval requires the matching +configuration and usable keychain credentials on the same Mac. + +Setup provides add, Change Storage, and Repair flows. Status shows the configured +volume and live placement state. Keep the external drive connected while using +the domain; an absent volume is surfaced as an actionable warning and blocks +unsafe removal/logout until the drive is reconnected or the placement is +repaired. See [App And Domains](doc/APP_AND_DOMAINS.md) and +[Testing And Development](doc/TESTING_AND_DEVELOPMENT.md) for the lifecycle and +physical-drive validation matrix. + ## Safety Notes - Do not commit bearer tokens, refresh tokens, account identifiers, private @@ -148,6 +180,8 @@ local Xcode requires a more specific variant. `Private/` on the selected kDrive. Existing active domains created before this layout remain directly under `Private` until sync is stopped and enabled again. +- External File Provider storage is experimental and must be validated with a + disposable encrypted APFS volume and non-customer data before relying on it. ## License diff --git a/doc/APP_AND_DOMAINS.md b/doc/APP_AND_DOMAINS.md index 04631d2..27a4ad2 100644 --- a/doc/APP_AND_DOMAINS.md +++ b/doc/APP_AND_DOMAINS.md @@ -16,6 +16,9 @@ The app handles: and usable account credentials are available - building a domain configuration for the selected account and drive - registering and removing `NSFileProviderDomain` entries +- choosing local or eligible external-volume storage on macOS 15 or later +- changing storage through a journaled remove-and-recreate transition and + repairing interrupted transitions - enabling or releasing macOS Desktop & Documents known-folder sync - logging out one account without touching other accounts - showing a Status dashboard for configured accounts, drives, cached snapshots, @@ -51,12 +54,18 @@ development. While stored state is being restored, Setup shows an explicit loading row instead of briefly presenting the empty-account state. Setup errors appear in a nonmodal, dismissible banner so background refreshes do not interrupt navigation with a transient alert. +The Status empty state links to Setup, where accounts, domains, storage, and +repairs are managed. The Status dashboard only uses local/provider-safe state: local account records, -configured domain records, currently loaded kDrive summaries, SQLite listing -snapshot aggregates, and sanitized activity/conflict counts. It does not fetch -remote account profile data, quotas, OAuth token details, private links, or file -contents. +configured domain records, live File Provider placement state, currently loaded +kDrive summaries, SQLite listing snapshot aggregates, and sanitized +activity/conflict counts. Each drive card names its storage location and reports +Connected, Registering, Moving, Authentication Required, External Drive +Disconnected, or Needs Repair. Authentication, disconnected-volume, and repair +states contribute to the Issues total; registering and moving are informational. +The dashboard does not fetch remote account profile data, quotas, OAuth token +details, private links, or file contents. Each configured drive's management screen has a Show in Finder button on macOS or Show in Files on iOS and visionOS. The app asks that domain's @@ -98,18 +107,27 @@ unavailable. `ProviderDomainConfiguration` is the local record that connects an Apple File Provider domain to a kDrive: -- `domainIdentifier`: stable identifier used as `NSFileProviderDomainIdentifier` +- `configurationIdentifier`: stable app identity used for the configuration + JSON filename, SwiftUI row identity, repair journal, and external-domain binding +- `domainIdentifier`: current `NSFileProviderDomainIdentifier`; local placement + can use the requested identifier, while Apple generates this identifier for an + external domain and it can change when storage is recreated - `accountIdentifier`: local account whose keychain token should be used - `displayName`: Finder/Files-visible name derived from `driveName`, for example `Work Drive` - `driveID`: kDrive identifier used in API calls - `driveName`: display name returned by kDrive or entered manually - `rootFileID`: kDrive root folder ID; currently defaults to `1` +- `storageLocation`: either `onThisMac` or an external volume's UUID and local + display name - `createdAt` and `updatedAt`: local metadata for the configuration Domain configurations are stored as JSON files in the app group under -`DomainConfigurations/`. Legacy JSON without `accountIdentifier` is migrated to -the fixed `legacy-account` local account. +`DomainConfigurations/`, keyed by `configurationIdentifier`. Legacy JSON without +`configurationIdentifier` treats its existing domain identifier as the stable +configuration identity; legacy JSON without `accountIdentifier` is migrated to +the fixed `legacy-account` local account. Legacy JSON without `storageLocation` +defaults to `onThisMac`. Known-folder activation is not stored in domain JSON. On macOS, `NSFileProviderDomain.replicatedKnownFolders` is the source of truth. @@ -126,24 +144,88 @@ The add flow is: 2. The app creates a local account record and saves the token under that account. 3. The app loads kDrives for that account through `PotassiumKDriveService.listDrives()`. 4. The user opens a discovered drive and chooses **Add to Files** on its - management screen. -5. `PotassiumProviderAppModel.addDomain()` creates a + management screen. On macOS, a storage sheet offers On This Mac or External + Drive; other platforms use local storage. +5. `PotassiumProviderAppModel.addDomain()` creates a stable + `configurationIdentifier` and a `ProviderDomainConfiguration` whose display name is derived from the drive name and, when needed, the account display name. -6. The app saves the configuration to the app group. -7. `FileProviderDomainRegistrar.addDomain(for:)` registers an - `NSFileProviderDomain` with Apple's File Provider manager. On macOS 15 or - later, the domain advertises support for Desktop and Documents together. +6. For an external target, the app holds security-scoped access through + preparation and registration, normalizes the selected folder to its volume + root, verifies Apple's eligibility result and the prepared domain's volume + UUID, and records Apple's generated domain identifier. +7. The app saves the configuration to the app group, then registers the exact + prepared `NSFileProviderDomain`. On macOS 15 or later, the domain advertises + support for Desktop and Documents together. 8. If registration fails, the app rolls back the saved configuration and removes any snapshots for that domain. The configuration is saved before registration so the extension can find it when the system starts calling into the new domain. -On reload, the app also normalizes stored configurations to the current -Finder-visible display-name policy and re-adds each stored -`NSFileProviderDomain`. Re-adding a domain with the same identifier updates the -system's registered display name. +On reload, the app normalizes stored configurations to the current Finder-visible +display-name policy and compares them with registered system domains. An external +domain must be looked up as the exact registered object using its persisted, +Apple-generated identifier; the app never reconstructs an external domain from +display name and identifier. Missing or mismatched registrations and interrupted +relocation journals become repair states instead of silent replacement domains. + +## External Volume Storage On macOS 15 Or Later + +External placement is volume selection, not folder selection. The open panel +accepts any folder so macOS can grant security-scoped access, but the app resolves +the containing volume root before inspection and registration. Apple's File +Provider subsystem chooses and owns the actual domain storage folder. + +The app calls +[`NSFileProviderManager.checkDomainsCanBeStoredOnVolume(at:)`](https://developer.apple.com/documentation/fileprovider/nsfileprovidermanager/checkdomainscanbestoredonvolume(at:)) +and permits registration only when Apple returns eligible. The supported target +is writable, local, encrypted APFS. Ineligible UI explains all Apple reason bits: +unknown eligibility, non-APFS, unencrypted, read-only, network, and quarantined. +The sheet also shows the volume name and capacity information available from +macOS. + +The external initializer stores an opaque, versioned binding in domain +`userInfo`: only the stable `configurationIdentifier` is present. It never +contains an account identifier, bearer or refresh token, path, private URL, or +customer data. The extension's external-volume connection callback resolves the +configuration from this Mac's app group, verifies the current generated domain +identifier and volume UUID, and requires usable credentials from this Mac's +keychain. Moving the physical drive to another Mac does not transfer an approved +kDrive session. + +Disconnecting a configured volume leaves the configuration record intact. Setup +and Status surface the placement state; Setup supplies the Repair action. When +the state is External Drive Disconnected, mutation controls—including Remove +from Files—are disabled, and logout is blocked before credentials or local +records are deleted. Reconnect the same volume, then finish or repair the File +Provider operation. + +## Changing Storage And Recovery + +File Provider has no in-place domain move operation, so Change Storage is a +remove-and-recreate transition: + +1. Record the source, target, and known-folder state in a relocation journal, + then wait for the source domain to stabilize. +2. If Desktop & Documents are active, release both known folders, persist that + phase, and wait again. +3. Prepare the exact target domain. External targets receive a new Apple-generated + domain identifier; the stable configuration identifier does not change. +4. Remove the source with preserve-dirty-user-data mode. If macOS returns a + preserved-data URL, the app offers to reveal it in Finder. +5. Save the replacement configuration and register the prepared target. +6. Remove SQLite rows keyed by the old domain identifier. +7. Reclaim Desktop & Documents when they were active, including renewed consent + when macOS requires it. + +Every material phase is recorded under `DomainRelocations/`. A failure before +source removal can restore known folders and keep the source. A failure after +source removal attempts to recreate the source placement. A registered target +whose cleanup is incomplete, a failed source recovery, or an incomplete +known-folder reclaim remains journaled and appears as Needs Repair after relaunch. +Repair completes the safest phase supported by the journal and mounted-volume +state rather than guessing from a display name. ## Desktop & Documents On macOS @@ -189,15 +271,18 @@ The remove flow is: 1. On macOS, the app refreshes live known-folder state and releases Desktop and Documents when this domain owns them. A release failure aborts removal. -2. `NSFileProviderManager.remove(_:)` removes the domain from the system. +2. `NSFileProviderManager.remove(_:)` removes the exact registered domain from + the system. External domains are not reconstructed for removal. 3. `KDriveSnapshotStoring.removeSnapshots(domainIdentifier:)` deletes all SQLite snapshots for that domain. -4. `DomainConfigurationFileStore.remove(domainIdentifier:)` deletes the domain - JSON file. +4. `DomainConfigurationFileStore.remove(configurationIdentifier:)` deletes the + stable configuration JSON file. 5. The app refreshes its visible domain list. Removing a domain only removes provider state from this app. It does not delete -remote kDrive files. +remote kDrive files. A disconnected external placement is not removed by +discarding JSON: the UI blocks removal until the volume is reconnected or the +placement can be repaired safely. ## Configured Drive Actions @@ -221,7 +306,10 @@ activity/conflict rows, and thumbnail cache entries. On macOS this includes releasing any known folders owned by those domains; a release failure stops logout. Only after domain cleanup succeeds does the app delete that account's keychain token and account JSON. Domains and tokens for other accounts are left -untouched, and remote kDrive files are never deleted by logout. +untouched, and remote kDrive files are never deleted by logout. Logout is also +blocked while one of the account's external volumes is unavailable or a storage +transition must be finished or repaired, so credentials cannot disappear before +File Provider cleanup. For development, `scripts/uninstall-file-provider.sh` can perform the same domain-detach path outside the UI. It runs the signed macOS app with a hidden diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index 7681155..f6937f2 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -10,6 +10,8 @@ flowchart LR User["User / Files app"] --> FP["potassiumProviderFileProvider"] User --> Actions["potassiumProviderActions"] App["SwiftUI app"] --> Domain["NSFileProviderManager domains"] + Domain --> LocalStorage["System-managed local storage"] + Domain --> ExternalVolume["Eligible encrypted APFS volume (macOS 15+)"] App --> Core["PotassiumProviderCore"] FP --> Core Actions --> Core @@ -19,6 +21,7 @@ flowchart LR Core --> AppGroup["App group storage"] AppGroup --> Accounts["Accounts/*.json"] AppGroup --> DomainJSON["DomainConfigurations/*.json"] + AppGroup --> Relocations["DomainRelocations/*.json"] AppGroup --> Snapshots["Snapshots.sqlite3"] ``` @@ -26,8 +29,8 @@ flowchart LR - `potassiumProvider`: SwiftUI app used to connect multiple local accounts, load kDrives per account, register File Provider domains, remove configured - domains, control macOS Desktop & Documents sync, and log out accounts - independently. + domains, choose and change macOS storage placement, repair interrupted moves, + control Desktop & Documents sync, and log out accounts independently. - `potassiumProviderFileProvider`: `NSFileProviderReplicatedExtension` implementation used by the system to enumerate, fetch, create, modify, trash, and delete items, provide macOS known-folder locations, and execute @@ -46,12 +49,16 @@ flowchart LR ## Ownership Boundaries -- The app owns account setup, domain registration, live known-folder state, - user-triggered claim/release, domain removal, and independent account logout. +- The app owns account setup, domain registration, external-volume selection and + eligibility checks, durable relocation/recovery, live placement and + known-folder state, user-triggered claim/release, domain removal, and + independent account logout. - The File Provider extension owns Apple's runtime callbacks and maps those callbacks to `KDriveFileProviding` operations. On macOS it also maps Desktop and Documents to `Private/` on the selected drive, while - preserving active legacy domains that still point directly at `Private`. + preserving active legacy domains that still point directly at `Private`. For + an external domain it approves connection only after validating the opaque + local binding, current volume UUID, and usable same-Mac keychain credentials. - `PotassiumProviderCore` owns typed provider models, persistence protocols, OAuth utilities, and the `PotassiumKDriveService` adapter. - `potassiumChannel` owns the typed request builders and service calls for @@ -61,13 +68,37 @@ flowchart LR - The keychain access group is the shared credential boundary. Tokens are keyed by local account identifier. +## Domain And Placement Identity + +`configurationIdentifier` is the stable app identity for one configured kDrive. +It keys app-group JSON, relocation journals, SwiftUI/status identity, and the +opaque external-domain binding. `domainIdentifier` identifies the current system +domain and can change when storage is recreated; Apple generates it for domains +created with the external-volume initializer. SQLite snapshots and activity are +keyed by this current domain identifier, so a successful move removes the old +domain's rows. + +External `NSFileProviderDomain.userInfo` contains only a schema version and the +stable configuration identifier. Account selection and all credentials remain +in the app group/keychain on the Mac. A selected folder is normalized to its +containing volume before the app asks Apple to create the system-managed domain; +no arbitrary user folder becomes part of the provider architecture. + ## Runtime Flow At runtime, the extension constructs a `FileProviderRuntime` for each callback. -That runtime loads the domain configuration from the app group, uses the -configuration's `accountIdentifier` to load and refresh the correct OAuth token -from keychain when needed, creates a `PotassiumKDriveService`, and opens the -SQLite snapshot store. +A local domain resolves configuration by its current domain identifier. An +external domain decodes its stable configuration binding, then verifies that the +stored current domain identifier and volume UUID match the system domain. The +runtime uses the resulting configuration's `accountIdentifier` to load and +refresh the correct OAuth token from keychain when needed, creates a +`PotassiumKDriveService`, and opens the SQLite snapshot store. + +Changing placement is explicitly transactional at the application level rather +than an in-place mutation: stabilize, release known folders when active, prepare +the target, remove the source while preserving dirty data, save/register the +target, clean old domain-keyed rows, and reclaim known folders. The durable +`DomainRelocations` journal is the recovery boundary across crashes and relaunch. Neither extension keeps a long-lived process-level sync engine. Each File Provider callback or contextual panel loads account-scoped runtime state, diff --git a/doc/FILE_PROVIDER_CLEANUP.md b/doc/FILE_PROVIDER_CLEANUP.md index 62b563f..3fb0fca 100644 --- a/doc/FILE_PROVIDER_CLEANUP.md +++ b/doc/FILE_PROVIDER_CLEANUP.md @@ -65,8 +65,8 @@ that contains XCTest injection libraries. ## App Command Behavior `--dry-run` prints the plan and does not remove domains, SQLite rows, thumbnail -cache files, domain configuration JSON, account JSON, ConflictStaging contents, -or OAuth tokens. +cache files, domain configuration/relocation JSON, account JSON, ConflictStaging +contents, or OAuth tokens. Without `--yes`, a non-dry run prints the plan and exits without mutating local state. @@ -76,6 +76,7 @@ state. - removes this app's registered File Provider domains through `NSFileProviderManager` using the preserve-dirty-user-data mode; - removes matching `DomainConfigurations` JSON files; +- removes matching `DomainRelocations` JSON files; - removes matching SQLite snapshot, conflict, and activity rows; - keeps ConflictStaging contents; - keeps all saved account records and OAuth tokens. @@ -96,7 +97,33 @@ key if it still exists. - deletes every stored account record, every account-scoped OAuth token, and the legacy single-token key if it still exists; - may use the File Provider remove-all fallback if domain listing failed and - targeted removal by saved configuration also fails. +targeted removal by saved configuration also fails. + +## Stable And Generated Identifiers + +External-volume domains use an Apple-generated File Provider domain identifier, +and changing storage can replace it. The app's `configurationIdentifier` remains +stable. An interrupted relocation journal can also contain both a source domain +ID and a prepared/registered target domain ID. + +The uninstall plan therefore keeps two explicit cleanup sets: + +- configuration identifiers from stored configurations and relocation journals, + used to remove `DomainConfigurations` and `DomainRelocations` files; +- domain identifiers from the actual registered-domain list, current stored + configurations, and journal source/target fields, used to remove all matching + SQLite and provider-local state. + +Do not replace this plan with display-name matching or assume one stable domain +ID per kDrive. Dry-run output should be checked especially carefully after an +interrupted move. Targeted domain removal resolves the exact object from Apple's +current registered-domain list before removal; it does not reconstruct an +external domain. + +Preserve-dirty-user-data removal may return one or more local preserved-data +locations. The command prints those paths for manual review and does not delete +them. A listing failure, missing/disconnected external domain, or stale saved ID +causes plain `--yes` to stop rather than silently escalating to remove-all. ## Safety Boundary @@ -107,6 +134,13 @@ the app before running the uninstall wrapper. The wrapper's existing reset modes remove domains and local provider state; they do not offer a separate known-folder release workflow. +In the normal UI, an unavailable external volume disables Remove from Files and +blocks account logout until that placement is reconnected or repaired. Keep that +safety boundary when diagnosing a missing drive. The uninstall wrapper is a +development recovery tool, not a user-facing workaround; inspect `--dry-run`, +prefer plain `--yes`, and reconnect the volume when targeted removal requires the +registered external domain. + The cleanup script also does not directly delete Finder storage, `~/Library/CloudStorage`, or private File Provider system databases. If File Provider system state is corrupt beyond the supported APIs and the stale archive diff --git a/doc/FILE_PROVIDER_LIFECYCLE.md b/doc/FILE_PROVIDER_LIFECYCLE.md index 59c1976..f712f58 100644 --- a/doc/FILE_PROVIDER_LIFECYCLE.md +++ b/doc/FILE_PROVIDER_LIFECYCLE.md @@ -4,11 +4,44 @@ The extension implements `NSFileProviderReplicatedExtension`. The system owns th local file-provider storage and calls the extension when it needs metadata, bytes, mutations, or enumeration. +## Local And External Storage + +On macOS 15 or later, the containing app can create a domain with Apple's +[`NSFileProviderDomain(displayName:userInfo:volumeURL:)`](https://developer.apple.com/documentation/fileprovider/nsfileproviderdomain/init(displayname:userinfo:volumeurl:)) +initializer. `volumeURL` is the normalized volume root. It tells File Provider +which volume should hold the system-managed domain; it does not select a folder +inside that volume and does not map an arbitrary local folder into kDrive. + +The app first calls +[`NSFileProviderManager.checkDomainsCanBeStoredOnVolume(at:)`](https://developer.apple.com/documentation/fileprovider/nsfileprovidermanager/checkdomainscanbestoredonvolume(at:)). +Eligible targets are writable, local, encrypted APFS volumes. The implementation +maps Apple's complete unsupported-reason set: unknown, non-APFS, unencrypted, +read-only, network, and quarantined. Security-scoped access obtained by the +picker remains balanced and covers preparation plus registration; the selected +folder URL is used only for the security-scoped access grant while the operation +uses its volume root. + +Apple generates the external domain identifier. The app saves that exact value +as the configuration's current `domainIdentifier` and always passes the same +prepared `NSFileProviderDomain` instance to registration. Existing external +operations look up the registered system domain by that identifier instead of +reconstructing one. + +The opaque external-domain `userInfo` has two non-secret fields: binding schema +version and stable `configurationIdentifier`. It contains no account identifier, +credential, path, URL, or customer data. The extension's +`NSFileProviderExternalVolumeHandling` connection callback approves the domain +only when this Mac has the matching configuration, the generated domain ID and +volume UUID agree, and the associated keychain credential is usable. Otherwise +it fails closed as not authenticated. + ## Runtime Loading Most callbacks begin by loading `FileProviderRuntime`: -1. Load the domain configuration from app group JSON. +1. Load the domain configuration from app group JSON. Local domains resolve by + current domain identifier; external domains resolve by the stable opaque + configuration binding and verify the generated domain ID and volume UUID. 2. Load the account-scoped OAuth token from keychain using the domain configuration's `accountIdentifier`. 3. Refresh that account's token when needed and possible. @@ -19,6 +52,14 @@ If configuration or credentials are missing, the callback fails with a mapped File Provider error. Runtime-loading and authentication failures are also recorded as sanitized failure activity when the activity database can be opened. +External placement state is refreshed from registered domains and mounted-volume +identity. A missing external registration with an absent configured volume is +reported as External Drive Disconnected. A connected volume with a missing or +mismatched domain, or a durable interrupted relocation, is reported as Needs +Repair. Registering and Moving remain informational. The app blocks removal and +account logout while the external volume is unavailable so it does not delete +local identity or credentials before system-domain cleanup can finish. + ## Desktop & Documents Known Folders On macOS 15 or later, registered domains advertise support for Desktop and @@ -47,6 +88,34 @@ locations, keeps its default binary-compatibility symlink behavior, and manages the local known-folder transition. Ordinary enumeration and mutation callbacks then synchronize their contents like other provider items. +A storage move records whether known folders were active, releases both before +the source domain is removed, and attempts to reclaim both after the target is +registered. Consent may be required again. Failure to reclaim is a durable +repair state; it does not erase the successful storage placement. + +## Storage Change Lifecycle + +File Provider has no in-place placement change, so the app uses a durable +remove-and-recreate transaction: + +1. Write a relocation journal and wait for source stabilization. +2. Release active Desktop & Documents and persist that phase. +3. Prepare the exact target domain and journal its generated/current domain ID. +4. Remove the source with `.preserveDirtyUserData`; surface any returned + preserved-data URL to the user. +5. Save the new `domainIdentifier` and storage location under the unchanged + `configurationIdentifier`, then register the exact prepared target. +6. Delete snapshot/event state keyed by the old domain identifier. +7. Reclaim known folders and remove the journal only when all required work is + complete. + +If the target cannot be registered after source removal, recovery attempts to +recreate the original placement; recovering an external source requires the +same volume UUID to be mounted. Relaunch converts any unfinished journal into a +Needs Repair state. Repair can finish cleanup for an already registered target, +recreate a missing target/source as the journal allows, or retry known-folder +reclaim. It never guesses based only on a volume display name. + ## `item(for:)` Purpose: resolve metadata for one item identifier. diff --git a/doc/PERSISTENCE.md b/doc/PERSISTENCE.md index c3ff0da..215cac4 100644 --- a/doc/PERSISTENCE.md +++ b/doc/PERSISTENCE.md @@ -2,7 +2,8 @@ The app and extension share state through the configured app group and keychain access group. The app group stores local account records, domain configuration, -and listing snapshots. The keychain stores account-scoped OAuth tokens. +durable storage-relocation journals, and listing snapshots. The keychain stores +account-scoped OAuth tokens. ## App Group Storage @@ -12,6 +13,8 @@ Current app group contents: - `Accounts/*.json` - `DomainConfigurations/*.json` +- `DomainRelocations/*.json` while a File Provider storage change or repair is + incomplete - `Snapshots.sqlite3`, including listing snapshots, conflict events, and recent provider/app activity - `ConflictStaging/*.upload` while a stale-content conflict copy is being sent @@ -44,8 +47,18 @@ These files are used by: - the extension, to load the account identifier used for account-scoped token lookup -Filenames are sanitized from domain identifiers. Removing a domain deletes its -configuration JSON after the File Provider domain is removed. +Each record includes two distinct identities: + +- `configurationIdentifier` is stable across storage changes and keys the JSON + filename, app/status row, relocation journal, and external-domain binding. +- `domainIdentifier` is the current system domain identity. Apple generates it + for external placement, and it changes when a domain is recreated during a + storage move or repair. + +The record also persists `storageLocation`: `onThisMac`, or an external volume's +UUID and local display name. Filenames are sanitized from stable configuration +identifiers. Removing a domain deletes its configuration JSON only after the +exact registered File Provider domain is removed. macOS Desktop & Documents activation is not persisted here or in SQLite. The app derives it from each registered `NSFileProviderDomain.replicatedKnownFolders`. @@ -58,6 +71,38 @@ remote namespace identifier. JSON without the field decodes as Legacy domain JSON that does not contain `accountIdentifier` decodes to the fixed `legacy-account` local account. The app rewrites those configurations during reload so future extension loads can use explicit account-scoped token lookup. +Legacy JSON without `configurationIdentifier` uses its existing domain ID as the +stable configuration identity, and legacy JSON without `storageLocation` +defaults to `onThisMac`. + +External `NSFileProviderDomain.userInfo` is not a credential store. It contains +only a binding schema version and `configurationIdentifier`. The extension uses +that value to load this Mac's JSON, verifies the current generated domain ID and +external volume UUID, and then looks up credentials separately in this Mac's +keychain. Account identifiers, bearer/refresh tokens, paths, and private URLs are +never written into domain `userInfo`. + +## DomainRelocations + +`ProviderDomainRelocationFileStore` keeps one JSON journal per stable +configuration while changing File Provider placement. A journal records: + +- stable `configurationIdentifier` +- complete source configuration, including old domain ID and storage location +- target storage location and prepared target domain ID when known +- whether Desktop & Documents were active +- phase and local creation/update dates + +Phases cover preparation, known-folder release, source removal, target +configuration save, target registration, known-folder reclaim required, and a +general repair state. The file is removed only after the target and cleanup are +complete. On relaunch, an unfinished journal deliberately overrides an apparently +healthy row with Needs Repair so the app can complete or recover the transaction. + +The journal does not contain tokens. When recovery needs an external source or +target, the app searches mounted volumes by persisted UUID and holds a new +security-scoped access session through prepare/save/register. A matching display +name alone is insufficient. ## Snapshots.sqlite3 @@ -217,6 +262,13 @@ already stored for File Provider enumeration, conflict tracking, and activity display. They do not expose OAuth tokens, remote account profile data, private links, file bytes, or thumbnail bytes. +The Status dashboard combines those aggregates with in-memory File Provider +placement state keyed by stable configuration identifier. Placement state is not +stored in SQLite: it is recomputed from current registered domains, mounted +volume identity, and any durable relocation journal. Authentication-required, +external-volume-unavailable, and repair states add one issue each; registering +and moving do not. + ## What Is Cached SQLite caches metadata needed to enumerate and diff containers: @@ -315,7 +367,9 @@ scanned item ID, avoiding whole-container dictionaries and sets. On macOS, the app releases Desktop and Documents first when the domain currently replicates them; a release failure leaves the domain and local records intact. -After File Provider removes the domain, the app calls +External domains are removed using the exact registered domain object and are +not reconstructed from their saved fields. A missing external volume blocks the +normal UI remove/logout path. After File Provider removes the domain, the app calls `removeSnapshots(domainIdentifier:)` and `removeEvents(domainIdentifier:)`. That deletes all snapshot, materialization, working-set poll/change, conflict, and activity rows for the domain. @@ -323,9 +377,13 @@ and activity rows for the domain. For local development resets, `scripts/uninstall-file-provider.sh` invokes the signed app's hidden uninstall command. That command removes registered File Provider domains through `NSFileProviderManager`, deletes matching -`DomainConfigurations` JSON files, and removes per-domain SQLite snapshot, -conflict, and activity rows. It preserves account records, `ConflictStaging`, -and account-scoped OAuth tokens by default; +`DomainConfigurations` and `DomainRelocations` JSON files, and removes per-domain +SQLite snapshot, conflict, and activity rows. Its plan gathers actual registered +domain IDs, each configuration's current generated domain ID, and journaled +source/target IDs; local JSON/journals are keyed separately by stable +configuration ID. This prevents a storage move from leaving old domain-keyed +state behind. It preserves account records, `ConflictStaging`, and account-scoped +OAuth tokens by default; `--hard-purge` removes `ConflictStaging`, and `--full-logout` or `--hard-purge` deletes all account-scoped tokens, the legacy single-token key, and stored account records. See diff --git a/doc/TESTING_AND_DEVELOPMENT.md b/doc/TESTING_AND_DEVELOPMENT.md index 76bddfa..9ddcfd4 100644 --- a/doc/TESTING_AND_DEVELOPMENT.md +++ b/doc/TESTING_AND_DEVELOPMENT.md @@ -143,12 +143,77 @@ Provider release gates remain outside its scope. should not be committed. - Desktop & Documents known-folder testing requires macOS 15 or later and a test drive with an existing root-level directory named `Private`. +- External File Provider storage testing requires macOS 15 or later and a + disposable, local, encrypted APFS volume. A disk image is useful for some + eligibility and interruption checks, but it does not replace disconnecting and + reconnecting a physical drive. Manually verify that Apple presents consent, both folders appear under `Private`, changes synchronize in both directions, live state survives relaunch and external domain changes, stopping sync releases both folders, and domain removal or logout cannot continue after a release failure. +## External Storage Validation + +Unit tests use injected File Provider and volume services to cover exact prepared +domain identity, generated identifiers, opaque binding validation, eligibility +reason mapping, folder-to-volume normalization, balanced security-scoped access, +journal phases/recovery, cleanup identifiers, status aggregation, and stable UI +identity. Those tests cannot prove Apple's on-disk placement, unplug behavior, +consent UI, preserved-data handling, or another Mac's connection decision. + +Run the following physical matrix on macOS 15 or later with disposable kDrive +test data. Start with `scripts/uninstall-file-provider.sh --dry-run`, and record +the selected volume UUID and the current generated domain ID without recording +account or credential data. + +1. Add the same test kDrive On This Mac, remove it, then add it to an encrypted + APFS external drive. Select a nested folder in the picker and confirm the UI + reports the containing volume—not the folder—as storage. Confirm Finder shows + the domain and materialization is physically backed by that volume. +2. Try representative ineligible targets: unencrypted APFS, non-APFS, read-only, + and network storage. When practical also verify quarantined/unknown results. + Confirm the sheet reports Apple's specific reason and registration remains + disabled. Use + [`checkDomainsCanBeStoredOnVolume(at:)`](https://developer.apple.com/documentation/fileprovider/nsfileprovidermanager/checkdomainscanbestoredonvolume(at:)) + as the eligibility source of truth. +3. Relaunch with the external drive attached. Confirm the generated domain ID, + persisted volume UUID, Status card identity, and kDrive configuration remain + associated through the stable configuration ID. Confirm no token, account ID, + or private URL appears in external-domain `userInfo`. +4. Eject the drive while idle, then during a disposable download/upload. Confirm + the domain becomes unavailable without losing configuration, Status shows an + actionable disconnected warning, and Setup disables unsafe Remove from Files. + Confirm account logout is blocked before token deletion. Reattach the same + UUID and use Repair if required; verify synchronization resumes. +5. Move On This Mac → external → On This Mac. Confirm the source stabilizes, + offline files can be downloaded again, generated domain IDs change as expected, + stable configuration/UI identity does not, and any preserved dirty-data URL is + shown with Reveal in Finder rather than deleted automatically. +6. Interrupt a move after known-folder release, source removal, target save, and + target registration in separate runs. Relaunch and confirm the journal yields + Needs Repair, Repair completes or safely restores the source, and stale old + domain-keyed SQLite rows are removed only after the authoritative placement is + known. +7. With Desktop & Documents active, repeat both move directions. Confirm both + folders are released together before removal, reclaimed together afterward, + renewed Apple consent is handled, and a failed reclaim remains a repairable + state without rolling back a successfully registered target. +8. Attach the volume to a different Mac or clean test user that lacks this app's + matching app-group configuration and keychain token. Confirm external-domain + connection is rejected; the opaque stable binding alone must not authorize + access. +9. Run cleanup dry-run during normal external placement and again with an + interrupted relocation journal. Confirm the plan includes actual registered + IDs, the configuration's current generated domain ID, journal source/target + IDs, and stable configuration IDs. Plain `--yes` must preserve dirty data and + report any returned location; reserve `--hard-purge` for deliberately + destructive local recovery. + +Also keep iOS Simulator and visionOS builds in the regression matrix. They must +continue to use On This Mac behavior and must not expose the macOS-only external +storage UI or APIs. + ## File Provider Dev Uninstall Use the dev uninstall wrapper to remove this app's registered File Provider From 69e54fd6a9488091bc25936a887f7e827f4fc246 Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sun, 19 Jul 2026 09:41:33 +0200 Subject: [PATCH 9/9] test: model registered known folder domain state --- .../FileProviderDomainRegistrar.swift | 2 ++ .../KnownFolderLifecycleTests.swift | 16 +++++++++++ .../potassiumProviderTests.swift | 27 ++++++++++++++++--- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/potassiumProvider/FileProviderDomainRegistrar.swift b/potassiumProvider/FileProviderDomainRegistrar.swift index 35d7901..420bb85 100644 --- a/potassiumProvider/FileProviderDomainRegistrar.swift +++ b/potassiumProvider/FileProviderDomainRegistrar.swift @@ -363,6 +363,7 @@ struct FileProviderDomainRegistrar: ProviderDomainRegistering { return domain } + #if os(macOS) private static func knownFolderSyncState( for domain: NSFileProviderDomain ) -> ProviderKnownFolderSyncState { @@ -375,6 +376,7 @@ struct FileProviderDomainRegistrar: ProviderDomainRegistering { return .inactive } } + #endif } @MainActor diff --git a/potassiumProviderTests/KnownFolderLifecycleTests.swift b/potassiumProviderTests/KnownFolderLifecycleTests.swift index 567b856..4903f19 100644 --- a/potassiumProviderTests/KnownFolderLifecycleTests.swift +++ b/potassiumProviderTests/KnownFolderLifecycleTests.swift @@ -274,6 +274,7 @@ private final class RecordingKnownFolderRegistrar: ProviderDomainRegistering { private(set) var events: [Event] = [] private(set) var state: ProviderKnownFolderSyncState private let claimError: Error? + private var registeredConfiguration: ProviderDomainConfiguration? private let releaseError: Error? init( @@ -292,10 +293,25 @@ private final class RecordingKnownFolderRegistrar: ProviderDomainRegistering { func addDomain(for configuration: ProviderDomainConfiguration) async throws { events.append(.add(domainIdentifier: configuration.domainIdentifier)) + registeredConfiguration = configuration } func removeDomain(for configuration: ProviderDomainConfiguration) async throws { events.append(.remove(domainIdentifier: configuration.domainIdentifier)) + registeredConfiguration = nil + } + + func registeredDomainStates() async throws -> [ProviderRegisteredDomainState] { + guard let registeredConfiguration else { return [] } + return [ProviderRegisteredDomainState( + configurationIdentifier: registeredConfiguration.configurationIdentifier, + domainIdentifier: registeredConfiguration.domainIdentifier, + displayName: registeredConfiguration.displayName, + volumeUUID: nil, + isDisconnected: false, + isUserEnabled: true, + knownFolderSyncState: state + )] } func knownFolderSyncStates() async throws -> [String: ProviderKnownFolderSyncState] { diff --git a/potassiumProviderTests/potassiumProviderTests.swift b/potassiumProviderTests/potassiumProviderTests.swift index 6bfd33f..96f0793 100644 --- a/potassiumProviderTests/potassiumProviderTests.swift +++ b/potassiumProviderTests/potassiumProviderTests.swift @@ -2848,9 +2848,30 @@ private actor FakeProviderEventStore: KDriveProviderEventStoring, KDriveProvider } @MainActor -private struct NoopProviderDomainRegistrar: ProviderDomainRegistering { - func addDomain(for configuration: ProviderDomainConfiguration) async throws {} - func removeDomain(for configuration: ProviderDomainConfiguration) async throws {} +private final class NoopProviderDomainRegistrar: ProviderDomainRegistering { + private var registeredConfigurationsByDomainIdentifier: [String: ProviderDomainConfiguration] = [:] + + func addDomain(for configuration: ProviderDomainConfiguration) async throws { + registeredConfigurationsByDomainIdentifier[configuration.domainIdentifier] = configuration + } + + func removeDomain(for configuration: ProviderDomainConfiguration) async throws { + registeredConfigurationsByDomainIdentifier[configuration.domainIdentifier] = nil + } + + func registeredDomainStates() async throws -> [ProviderRegisteredDomainState] { + registeredConfigurationsByDomainIdentifier.values.map { configuration in + ProviderRegisteredDomainState( + configurationIdentifier: configuration.configurationIdentifier, + domainIdentifier: configuration.domainIdentifier, + displayName: configuration.displayName, + volumeUUID: nil, + isDisconnected: false, + isUserEnabled: true, + knownFolderSyncState: .unavailable + ) + } + } } @MainActor