From 804c4249fe2c3ecbef6339e1962e225fa1215291 Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sun, 26 Jul 2026 11:39:31 +0200 Subject: [PATCH] feat: namespace known folders by Mac name --- .../KDriveMachineNamespaceResolver.swift | 250 +++++++++++++++ .../ProviderDomainConfiguration.swift | 16 + README.md | 6 +- doc/APP_AND_DOMAINS.md | 23 +- doc/ARCHITECTURE.md | 3 +- doc/FILE_PROVIDER_LIFECYCLE.md | 13 +- doc/PERSISTENCE.md | 5 + .../PotassiumProviderAppModel.swift | 59 +++- potassiumProvider/ProviderSetupView.swift | 23 +- .../FileProviderRuntime.swift | 36 ++- ...umFileProviderExtension+KnownFolders.swift | 30 +- .../KDriveMachineNamespaceResolverTests.swift | 291 ++++++++++++++++++ .../KnownFolderLifecycleTests.swift | 116 +++++-- .../potassiumProviderTests.swift | 11 + 14 files changed, 827 insertions(+), 55 deletions(-) create mode 100644 PotassiumProviderCore/KDriveMachineNamespaceResolver.swift create mode 100644 potassiumProviderTests/KDriveMachineNamespaceResolverTests.swift diff --git a/PotassiumProviderCore/KDriveMachineNamespaceResolver.swift b/PotassiumProviderCore/KDriveMachineNamespaceResolver.swift new file mode 100644 index 0000000..f875c5f --- /dev/null +++ b/PotassiumProviderCore/KDriveMachineNamespaceResolver.swift @@ -0,0 +1,250 @@ +import Foundation +#if os(macOS) +import SystemConfiguration +#endif + +public enum KDriveMachineNamespaceNameError: Error, Equatable, LocalizedError, Sendable { + case computerNameUnavailable + case unusableComputerName + + public var errorDescription: String? { + switch self { + case .computerNameUnavailable: + return "The current Mac name is unavailable." + case .unusableComputerName: + return "The current Mac name cannot be used as a kDrive folder name." + } + } +} + +/// Produces the kDrive directory name used to isolate known folders for one Mac name. +public enum KDriveMachineNamespaceName { + public static let maximumUTF8ByteCount = 255 + private static let hashCharacterCount = 8 + + public static func current() throws -> String { + #if os(macOS) + guard let computerName = SCDynamicStoreCopyComputerName(nil, nil) as String? else { + throw KDriveMachineNamespaceNameError.computerNameUnavailable + } + return try sanitized(computerName) + #else + throw KDriveMachineNamespaceNameError.computerNameUnavailable + #endif + } + + public static func sanitized(_ computerName: String) throws -> String { + let normalizedName = computerName.precomposedStringWithCanonicalMapping + .trimmingCharacters(in: .whitespacesAndNewlines) + let containsUsableScalar = normalizedName.unicodeScalars.contains { scalar in + scalar != "/" + && scalar != ":" + && CharacterSet.controlCharacters.contains(scalar) == false + && CharacterSet.whitespacesAndNewlines.contains(scalar) == false + } + guard containsUsableScalar else { + throw KDriveMachineNamespaceNameError.unusableComputerName + } + let replacedName = String(normalizedName.unicodeScalars.map { scalar in + if scalar == "/" || scalar == ":" || CharacterSet.controlCharacters.contains(scalar) { + return "-" + } + return Character(scalar) + }).trimmingCharacters(in: .whitespacesAndNewlines) + + guard replacedName.isEmpty == false, replacedName != ".", replacedName != ".." else { + throw KDriveMachineNamespaceNameError.unusableComputerName + } + guard replacedName.utf8.count > maximumUTF8ByteCount else { + return replacedName + } + + let suffix = "-\(shortHash(of: replacedName))" + let prefixByteLimit = maximumUTF8ByteCount - suffix.utf8.count + var prefix = "" + for character in replacedName { + let candidate = prefix + String(character) + guard candidate.utf8.count <= prefixByteLimit else { + break + } + prefix = candidate + } + guard prefix.isEmpty == false else { + throw KDriveMachineNamespaceNameError.unusableComputerName + } + return prefix + suffix + } + + private static func shortHash(of value: String) -> String { + var hash: UInt64 = 14_695_981_039_346_656_037 + for byte in value.utf8 { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + return String(String(hash, radix: 16).suffix(hashCharacterCount)) + .leftPadding(toLength: hashCharacterCount, withPad: "0") + } +} + +public struct KDriveMachineNamespace: Equatable, Sendable { + public let name: String + public let fileID: Int + + public init(name: String, fileID: Int) { + self.name = name + self.fileID = fileID + } +} + +public enum KDriveMachineNamespaceResolutionError: Error, Equatable, LocalizedError, Sendable { + case notDirectory(driveID: Int, parentFileID: Int, itemID: Int, name: String) + case ambiguous(driveID: Int, parentFileID: Int, itemIDs: [Int], name: String) + case invalidCreatedDirectory(driveID: Int, parentFileID: Int, itemID: Int, name: String) + + public var errorDescription: String? { + switch self { + case .notDirectory(_, let parentFileID, let itemID, let name): + return "The '\(name)' item '\(itemID)' under Private '\(parentFileID)' is not a directory." + case .ambiguous(_, let parentFileID, let itemIDs, let name): + let identifiers = itemIDs.map(String.init).joined(separator: ", ") + return "Private '\(parentFileID)' contains multiple '\(name)' items (\(identifiers))." + case .invalidCreatedDirectory(_, let parentFileID, let itemID, let name): + return "kDrive returned invalid metadata for the new '\(name)' directory '\(itemID)' under Private '\(parentFileID)'." + } + } +} + +/// Reuses or creates the current Mac's namespace immediately below kDrive `Private`. +public enum KDriveMachineNamespaceResolver { + public static let pageSize = 200 + + public static func resolveOrCreate( + driveID: Int, + privateDirectoryFileID: Int, + computerName: String, + remote: any KDriveFileProviding + ) async throws -> KDriveMachineNamespace { + let namespaceName = try KDriveMachineNamespaceName.sanitized(computerName) + let existingItems = try await matchingItems( + driveID: driveID, + parentFileID: privateDirectoryFileID, + name: namespaceName, + remote: remote + ) + if let namespace = try resolvedNamespace( + from: existingItems, + driveID: driveID, + parentFileID: privateDirectoryFileID, + name: namespaceName + ) { + return namespace + } + + do { + let createdItem = try await remote.createDirectory( + driveID: driveID, + parentID: privateDirectoryFileID, + name: namespaceName + ) + guard createdItem.driveID == driveID, + createdItem.parentID == privateDirectoryFileID, + createdItem.name == namespaceName, + createdItem.isDirectory else { + throw KDriveMachineNamespaceResolutionError.invalidCreatedDirectory( + driveID: driveID, + parentFileID: privateDirectoryFileID, + itemID: createdItem.id, + name: namespaceName + ) + } + return KDriveMachineNamespace(name: namespaceName, fileID: createdItem.id) + } catch let creationError { + let racedItems = try await matchingItems( + driveID: driveID, + parentFileID: privateDirectoryFileID, + name: namespaceName, + remote: remote + ) + if let namespace = try resolvedNamespace( + from: racedItems, + driveID: driveID, + parentFileID: privateDirectoryFileID, + name: namespaceName + ) { + return namespace + } + throw creationError + } + } + + private static func matchingItems( + driveID: Int, + parentFileID: Int, + name: String, + remote: any KDriveFileProviding + ) async throws -> [KDriveRemoteItem] { + var cursor: String? + var seenCursors: Set = [] + var itemsByID: [Int: KDriveRemoteItem] = [:] + + while true { + let page = try await remote.listDirectory( + driveID: driveID, + folderID: parentFileID, + cursor: cursor, + limit: pageSize + ) + for item in page.items where item.name == name && item.parentID == parentFileID { + itemsByID[item.id] = item + } + let nextCursor = try KDriveListingValidator.validatedNextCursor( + currentCursor: cursor, + nextCursor: page.nextCursor, + hasMore: page.hasMore, + seenCursors: &seenCursors + ) + guard page.hasMore else { + break + } + cursor = nextCursor + } + return itemsByID.values.sorted { $0.id < $1.id } + } + + private static func resolvedNamespace( + from items: [KDriveRemoteItem], + driveID: Int, + parentFileID: Int, + name: String + ) throws -> KDriveMachineNamespace? { + switch items.count { + case 0: + return nil + case 1: + let item = items[0] + guard item.isDirectory else { + throw KDriveMachineNamespaceResolutionError.notDirectory( + driveID: driveID, + parentFileID: parentFileID, + itemID: item.id, + name: name + ) + } + return KDriveMachineNamespace(name: name, fileID: item.id) + default: + throw KDriveMachineNamespaceResolutionError.ambiguous( + driveID: driveID, + parentFileID: parentFileID, + itemIDs: items.map(\.id), + name: name + ) + } + } +} + +private extension String { + func leftPadding(toLength: Int, withPad character: Character) -> String { + guard count < toLength else { return self } + return String(repeating: String(character), count: toLength - count) + self + } +} diff --git a/PotassiumProviderCore/ProviderDomainConfiguration.swift b/PotassiumProviderCore/ProviderDomainConfiguration.swift index 3ba2955..2d74144 100644 --- a/PotassiumProviderCore/ProviderDomainConfiguration.swift +++ b/PotassiumProviderCore/ProviderDomainConfiguration.swift @@ -5,6 +5,14 @@ public enum ProviderAccountAuthenticationKind: String, Codable, Equatable, Senda case manualAccessToken } +public enum ProviderKnownFolderLayout: String, Codable, Equatable, Sendable { + /// The pre-namespace layout that places Desktop and Documents directly in `Private`. + case legacyPrivate + + /// The current layout that places Desktop and Documents below `Private/`. + case machineNamespace +} + public struct ProviderAccount: Codable, Equatable, Identifiable, Sendable { public var id: String { accountIdentifier } @@ -139,6 +147,7 @@ public struct ProviderDomainConfiguration: Codable, Equatable, Identifiable, Sen public var driveID: Int public var driveName: String public var rootFileID: Int + public var knownFolderLayout: ProviderKnownFolderLayout public var createdAt: Date public var updatedAt: Date @@ -149,6 +158,7 @@ public struct ProviderDomainConfiguration: Codable, Equatable, Identifiable, Sen driveID: Int, driveName: String, rootFileID: Int = ProviderConstants.defaultRootFileID, + knownFolderLayout: ProviderKnownFolderLayout = .machineNamespace, createdAt: Date = Date(), updatedAt: Date = Date() ) { @@ -158,6 +168,7 @@ public struct ProviderDomainConfiguration: Codable, Equatable, Identifiable, Sen self.driveID = driveID self.driveName = driveName self.rootFileID = rootFileID + self.knownFolderLayout = knownFolderLayout self.createdAt = createdAt self.updatedAt = updatedAt } @@ -186,6 +197,7 @@ public struct ProviderDomainConfiguration: Codable, Equatable, Identifiable, Sen case driveID case driveName case rootFileID + case knownFolderLayout case createdAt case updatedAt } @@ -200,6 +212,10 @@ public struct ProviderDomainConfiguration: Codable, Equatable, Identifiable, Sen driveName = try container.decode(String.self, forKey: .driveName) rootFileID = try container.decodeIfPresent(Int.self, forKey: .rootFileID) ?? ProviderConstants.defaultRootFileID + knownFolderLayout = try container.decodeIfPresent( + ProviderKnownFolderLayout.self, + forKey: .knownFolderLayout + ) ?? .legacyPrivate createdAt = try container.decode(Date.self, forKey: .createdAt) updatedAt = try container.decode(Date.self, forKey: .updatedAt) } diff --git a/README.md b/README.md index 30d41d6..6edca12 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,10 @@ local Xcode requires a more specific variant. - SQLite snapshots cache metadata only. File contents and thumbnails are not stored there. - On macOS 15 or later, Desktop & Documents sync is an explicit, experimental - opt-in that always handles both folders together under the selected kDrive's - existing root-level `Private` directory. + opt-in that always handles both folders together under + `Private/` on the selected kDrive. Existing active domains + created before this layout remain directly under `Private` until sync is + stopped and enabled again. ## License diff --git a/doc/APP_AND_DOMAINS.md b/doc/APP_AND_DOMAINS.md index 2642a77..04631d2 100644 --- a/doc/APP_AND_DOMAINS.md +++ b/doc/APP_AND_DOMAINS.md @@ -151,11 +151,18 @@ On macOS 15 or later, a configured drive's management screen can opt in to Apple's known-folder feature. This is not arbitrary-folder sync: Apple currently permits Desktop and Documents to be claimed only together. -The app resolves the existing root-level kDrive directory named `Private` and -uses its item identifier as the common parent for `Private/Desktop` and -`Private/Documents`. The `Private` directory must already exist and be a -directory. File Provider reuses directory children with the recommended names -or creates them when absent; invalid or colliding locations fail the claim. +The app resolves the existing root-level kDrive directory named `Private`, +sanitizes the current macOS computer name, and reuses or creates the exact +directory `Private/`. That machine namespace is the common +parent for `Desktop` and `Documents`. The `Private` directory must already +exist and be a directory. A file collision or multiple exact namespace matches +fail the claim instead of selecting an arbitrary item. + +The namespace follows the Mac's current name; its remote identifier is not +pinned locally. Macs with the same sanitized name deliberately share the same +namespace. Names are Unicode-normalized, path separators and control characters +are replaced, and long names receive a deterministic suffix while remaining +within kDrive's 255-byte limit. Claiming begins only from the app's explicit control and presents Apple's user consent UI. Cancellation leaves the previous state unchanged. The extension's @@ -164,6 +171,12 @@ initiates a switch outside that claim call. The app reads live state from the registered domain, refreshes it when domain state changes, and provides a matching control to stop syncing both folders through `releaseKnownFolders`. +Stored domain configurations include a known-folder layout marker. Legacy JSON +without the marker decodes to the old direct-`Private` layout. An already active +legacy claim remains there without moving or deleting remote data. Stopping and +re-enabling it, or a new system-initiated claim while inactive, upgrades it to +the current machine namespace. + ## Removing A Domain Removal is initiated from the drive-management screen and requires explicit diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index 6b1abd0..7681155 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -50,7 +50,8 @@ flowchart LR 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 the selected drive's root-level `Private` directory. + and Documents to `Private/` on the selected drive, while + preserving active legacy domains that still point directly at `Private`. - `PotassiumProviderCore` owns typed provider models, persistence protocols, OAuth utilities, and the `PotassiumKDriveService` adapter. - `potassiumChannel` owns the typed request builders and service calls for diff --git a/doc/FILE_PROVIDER_LIFECYCLE.md b/doc/FILE_PROVIDER_LIFECYCLE.md index edec541..59c1976 100644 --- a/doc/FILE_PROVIDER_LIFECYCLE.md +++ b/doc/FILE_PROVIDER_LIFECYCLE.md @@ -28,9 +28,16 @@ stored in app-group JSON or SQLite. The extension adopts `NSFileProviderKnownFolderSupporting` on macOS. `getKnownFolderLocations` resolves the existing root-level kDrive directory -named `Private`, then returns `Desktop` and `Documents` locations with that -directory as their shared parent. It returns locations only for the folders -requested by macOS. A missing or non-directory `Private` item fails closed. +named `Private`, then resolves or creates its exact +`Private/` child and returns `Desktop` and `Documents` +locations with that namespace as their shared parent. It returns locations only +for the folders requested by macOS. Missing, non-directory, or ambiguous +parents fail closed. + +For compatibility, an active domain whose stored configuration predates the +machine namespace continues to return `Private` itself. Once released, the next +claim upgrades that domain to the machine namespace. The transition does not +move or delete remote data. Apple's [`NSFileProviderKnownFolderSupporting`](https://developer.apple.com/documentation/fileprovider/nsfileproviderknownfoldersupporting) documentation is the source of truth for this callback and transition behavior. diff --git a/doc/PERSISTENCE.md b/doc/PERSISTENCE.md index 786c108..c3ff0da 100644 --- a/doc/PERSISTENCE.md +++ b/doc/PERSISTENCE.md @@ -49,6 +49,11 @@ configuration JSON after the 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`. +Domain JSON does persist `knownFolderLayout`, which distinguishes the legacy +direct-`Private` parent from the current `Private/` parent. +This is a compatibility marker, not a cached activation state or a pinned +remote namespace identifier. JSON without the field decodes as +`legacyPrivate`; newly created configurations default to `machineNamespace`. Legacy domain JSON that does not contain `accountIdentifier` decodes to the fixed `legacy-account` local account. The app rewrites those configurations during diff --git a/potassiumProvider/PotassiumProviderAppModel.swift b/potassiumProvider/PotassiumProviderAppModel.swift index 876657c..130373b 100644 --- a/potassiumProvider/PotassiumProviderAppModel.swift +++ b/potassiumProvider/PotassiumProviderAppModel.swift @@ -48,6 +48,7 @@ final class PotassiumProviderAppModel: ObservableObject { private let snapshotStore: (any KDriveSnapshotStoring)? private let eventStore: (any KDriveProviderEventStoring)? private let fileProviderFactory: (String) -> any KDriveFileProviding + private let computerNameProvider: @Sendable () throws -> String private var automaticallyLoadedDriveAccountIdentifiers: Set = [] private var fileProviderDomainChangeCancellable: AnyCancellable? @@ -63,7 +64,8 @@ final class PotassiumProviderAppModel: ObservableObject { initialAccounts: [ProviderAccount] = [], initialDrivesByAccountIdentifier: [String: [KDriveDriveSummary]] = [:], initialDomains: [ProviderDomainConfiguration] = [], - fileProviderFactory: @escaping (String) -> any KDriveFileProviding = { PotassiumKDriveService(bearerToken: $0) } + fileProviderFactory: @escaping (String) -> any KDriveFileProviding = { PotassiumKDriveService(bearerToken: $0) }, + computerNameProvider: @escaping @Sendable () throws -> String = { try KDriveMachineNamespaceName.current() } ) { self.accountStore = accountStore ?? Self.makeDefaultAccountStore() self.domainStore = domainStore ?? Self.makeDefaultDomainStore() @@ -73,6 +75,7 @@ final class PotassiumProviderAppModel: ObservableObject { self.snapshotStore = snapshotStore ?? Self.makeDefaultSnapshotStore() self.eventStore = eventStore ?? Self.makeDefaultEventStore() self.fileProviderFactory = fileProviderFactory + self.computerNameProvider = computerNameProvider accounts = initialAccounts drivesByAccountIdentifier = initialDrivesByAccountIdentifier domains = initialDomains @@ -144,6 +147,17 @@ final class PotassiumProviderAppModel: ObservableObject { knownFolderTransitionDomainIdentifiers.contains(configuration.domainIdentifier) } + func knownFolderRemotePath(for configuration: ProviderDomainConfiguration) -> String { + let state = knownFolderSyncState(for: configuration) + if configuration.knownFolderLayout == .legacyPrivate, state != .inactive { + return "/Private" + } + guard let namespaceName = try? computerNameProvider() else { + return "/Private/" + } + return "/Private/\(namespaceName)" + } + func activeDriveAction(for key: ProviderDriveKey) -> ProviderDriveAction? { activeDriveActions[key] } @@ -408,19 +422,45 @@ final class PotassiumProviderAppModel: ObservableObject { guard beginKnownFolderTransition(.enablingKnownFolders, for: configuration) else { return } defer { endKnownFolderTransition(for: configuration) } + var namespacedConfiguration = configuration + var didClaimKnownFolders = false do { let token = try await usableToken(accountIdentifier: configuration.accountIdentifier) let remote = fileProviderFactory(token.accessToken) - let parentFileID = try await KDrivePrivateDirectoryResolver.resolveFileID( + let privateFileID = try await KDrivePrivateDirectoryResolver.resolveFileID( driveID: configuration.driveID, rootFileID: configuration.rootFileID, remote: remote ) - try await domainRegistrar.claimKnownFolders(for: configuration, parentFileID: parentFileID) + let namespace = try await KDriveMachineNamespaceResolver.resolveOrCreate( + driveID: configuration.driveID, + privateDirectoryFileID: privateFileID, + computerName: try computerNameProvider(), + remote: remote + ) + + namespacedConfiguration.knownFolderLayout = .machineNamespace + namespacedConfiguration.updatedAt = Date() + try await domainStore.save(namespacedConfiguration) + replaceDomainConfiguration(namespacedConfiguration) + + try await domainRegistrar.claimKnownFolders( + for: namespacedConfiguration, + parentFileID: namespace.fileID + ) + didClaimKnownFolders = true try await refreshKnownFolderSyncStates() - statusMessage = "Desktop and Documents now sync with \(configuration.displayName) in kDrive /private." + statusMessage = "Desktop and Documents now sync with \(configuration.displayName) in kDrive /Private/\(namespace.name)." errorMessage = nil } catch { + if didClaimKnownFolders == false, namespacedConfiguration != configuration { + do { + try await domainStore.save(configuration) + replaceDomainConfiguration(configuration) + } catch { + Self.log.error("failed to restore known-folder layout for domain(\(configuration.domainIdentifier, privacy: .public)): \(error.localizedDescription, privacy: .public)") + } + } try? await refreshKnownFolderSyncStates() guard isUserCancellation(error) == false else { return } await recordAppFailure( @@ -429,7 +469,7 @@ final class PotassiumProviderAppModel: ObservableObject { error: error, category: .fileProvider ) - errorMessage = "Could not sync Desktop and Documents with kDrive /private: \(error.localizedDescription)" + errorMessage = "Could not sync Desktop and Documents with this Mac's kDrive namespace: \(error.localizedDescription)" statusMessage = nil } #endif @@ -752,6 +792,15 @@ final class PotassiumProviderAppModel: ObservableObject { endDriveAction(for: driveKey(for: configuration)) } + private func replaceDomainConfiguration(_ configuration: ProviderDomainConfiguration) { + guard let index = domains.firstIndex(where: { + $0.domainIdentifier == configuration.domainIdentifier + }) else { + return + } + domains[index] = configuration + } + private func refreshKnownFolderSyncStates() async throws { let systemStates = try await domainRegistrar.knownFolderSyncStates() knownFolderSyncStatesByDomainIdentifier = Dictionary(uniqueKeysWithValues: domains.map { configuration in diff --git a/potassiumProvider/ProviderSetupView.swift b/potassiumProvider/ProviderSetupView.swift index d819652..6abe331 100644 --- a/potassiumProvider/ProviderSetupView.swift +++ b/potassiumProvider/ProviderSetupView.swift @@ -576,7 +576,7 @@ private struct ProviderDriveManagementView: View { } Button("Cancel", role: .cancel) {} } message: { - Text("macOS will stop replicating both folders with kDrive. Remote files in /private are not deleted.") + Text("macOS will stop replicating both folders with kDrive. Remote files in \(knownFolderRemotePath) are not deleted.") } #endif } @@ -597,6 +597,13 @@ private struct ProviderDriveManagementView: View { activeAction != nil || model.isLoadingDrives(for: key.accountIdentifier) } + private var knownFolderRemotePath: String { + guard let configuration = descriptor?.configuration else { + return "/Private/" + } + return model.knownFolderRemotePath(for: configuration) + } + private func driveForm(_ descriptor: ProviderDriveDescriptor) -> some View { List { Section("Drive") { @@ -735,9 +742,10 @@ private struct ProviderDriveManagementView: View { #if os(macOS) private func knownFolderSection(_ configuration: ProviderDomainConfiguration) -> some View { let state = model.knownFolderSyncState(for: configuration) + let remotePath = model.knownFolderRemotePath(for: configuration) return Section { LabeledContent("Status", value: knownFolderStatusTitle(state)) - Text(knownFolderDetail(state)) + Text(knownFolderDetail(state, remotePath: remotePath)) .font(.subheadline) .foregroundStyle(.secondary) @@ -768,7 +776,7 @@ private struct ProviderDriveManagementView: View { } header: { Text("Desktop & Documents") } footer: { - Text("macOS manages Desktop and Documents together under kDrive /private.") + Text("macOS manages Desktop and Documents together under kDrive \(remotePath).") } } @@ -785,14 +793,17 @@ private struct ProviderDriveManagementView: View { } } - private func knownFolderDetail(_ state: ProviderKnownFolderSyncState) -> String { + private func knownFolderDetail( + _ state: ProviderKnownFolderSyncState, + remotePath: String + ) -> String { switch state { case .active: - "Desktop and Documents sync with /private/Desktop and /private/Documents." + "Desktop and Documents sync with \(remotePath)/Desktop and \(remotePath)/Documents." case .partial: "Only part of the known-folder configuration is active. Stop syncing, then enable it again to repair the setup." case .inactive: - "Sync both folders with this drive’s existing /private directory." + "Sync both folders in this drive’s \(remotePath) directory." case .unavailable: "The live File Provider known-folder state could not be read." } diff --git a/potassiumProviderFileProvider/FileProviderRuntime.swift b/potassiumProviderFileProvider/FileProviderRuntime.swift index d507f4d..064f29a 100644 --- a/potassiumProviderFileProvider/FileProviderRuntime.swift +++ b/potassiumProviderFileProvider/FileProviderRuntime.swift @@ -76,13 +76,7 @@ struct FileProviderRuntime: Sendable { static func loadConfiguration(domain: NSFileProviderDomain) async throws -> ProviderDomainConfiguration { FileProviderLog.runtime.debug("load configuration for domain(\(domain.identifier.rawValue, privacy: .public)) from app group") - let configurationStore: DomainConfigurationFileStore - do { - configurationStore = try DomainConfigurationFileStore(appGroupIdentifier: ProviderConstants.appGroupIdentifier) - } catch { - FileProviderLog.runtime.error("failed to open app group configuration store for domain(\(domain.identifier.rawValue, privacy: .public)): \(error.localizedDescription, privacy: .public)") - throw error - } + 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") @@ -92,6 +86,21 @@ struct FileProviderRuntime: Sendable { return configuration } + static func markMachineNamespaceLayout(domain: NSFileProviderDomain) async throws { + let configurationStore = try makeConfigurationStore(domain: domain) + guard var configuration = try await configurationStore.configuration( + domainIdentifier: domain.identifier.rawValue + ) else { + throw NSFileProviderError(.notAuthenticated) + } + guard configuration.knownFolderLayout != .machineNamespace else { + return + } + configuration.knownFolderLayout = .machineNamespace + configuration.updatedAt = Date() + try await configurationStore.save(configuration) + } + static func makeSnapshotStore() throws -> any KDriveSnapshotStoring { try makeSQLiteStore() } @@ -117,6 +126,19 @@ struct FileProviderRuntime: Sendable { return nil } } + + private static func makeConfigurationStore( + domain: NSFileProviderDomain + ) throws -> DomainConfigurationFileStore { + do { + return try DomainConfigurationFileStore( + appGroupIdentifier: ProviderConstants.appGroupIdentifier + ) + } catch { + FileProviderLog.runtime.error("failed to open app group configuration store for domain(\(domain.identifier.rawValue, privacy: .public)): \(error.localizedDescription, privacy: .public)") + throw error + } + } } enum FileProviderPageCodec { diff --git a/potassiumProviderFileProvider/PotassiumFileProviderExtension+KnownFolders.swift b/potassiumProviderFileProvider/PotassiumFileProviderExtension+KnownFolders.swift index 4b00ee8..5510507 100644 --- a/potassiumProviderFileProvider/PotassiumFileProviderExtension+KnownFolders.swift +++ b/potassiumProviderFileProvider/PotassiumFileProviderExtension+KnownFolders.swift @@ -24,8 +24,28 @@ extension PotassiumFileProviderExtension: NSFileProviderKnownFolderSupporting { rootFileID: runtime.configuration.rootFileID, remote: runtime.remote ) + let parentFileID: Int + let usesActiveLegacyLayout = + runtime.configuration.knownFolderLayout == .legacyPrivate + && self.fileProviderDomain.replicatedKnownFolders.isEmpty == false + if usesActiveLegacyLayout { + parentFileID = privateFileID + } else { + let namespace = try await KDriveMachineNamespaceResolver.resolveOrCreate( + driveID: runtime.configuration.driveID, + privateDirectoryFileID: privateFileID, + computerName: try KDriveMachineNamespaceName.current(), + remote: runtime.remote + ) + parentFileID = namespace.fileID + if runtime.configuration.knownFolderLayout == .legacyPrivate { + try await FileProviderRuntime.markMachineNamespaceLayout( + domain: self.fileProviderDomain + ) + } + } let parentIdentifier = NSFileProviderItemIdentifier( - KDriveItemIdentifier.item(privateFileID).rawValue + KDriveItemIdentifier.item(parentFileID).rawValue ) let locations = NSFileProviderKnownFolderLocations() if requestsDesktop { @@ -41,16 +61,18 @@ extension PotassiumFileProviderExtension: NSFileProviderKnownFolderSupporting { ) } - FileProviderLog.replicatedExtension.info("resolved known folders under kDrive Private item(\(privateFileID, privacy: .public)) for domain(\(self.fileProviderDomain.identifier.rawValue, privacy: .public))") + FileProviderLog.replicatedExtension.info("resolved known folders under kDrive parent item(\(parentFileID, privacy: .public)) for domain(\(self.fileProviderDomain.identifier.rawValue, privacy: .public))") completionHandler(locations, nil) } catch { let mappedError: Error - if error is KDrivePrivateDirectoryResolutionError { + if error is KDrivePrivateDirectoryResolutionError + || error is KDriveMachineNamespaceResolutionError + || error is KDriveMachineNamespaceNameError { mappedError = NSFileProviderError(.cannotSynchronize) } else { mappedError = providerErrorMapping(error).mappedError } - FileProviderLog.replicatedExtension.error("failed to resolve kDrive Private location for known folders in domain(\(self.fileProviderDomain.identifier.rawValue, privacy: .public)): \(error.localizedDescription, privacy: .public)") + FileProviderLog.replicatedExtension.error("failed to resolve the kDrive known-folder location for domain(\(self.fileProviderDomain.identifier.rawValue, privacy: .public)): \(error.localizedDescription, privacy: .private)") completionHandler(nil, mappedError) } } diff --git a/potassiumProviderTests/KDriveMachineNamespaceResolverTests.swift b/potassiumProviderTests/KDriveMachineNamespaceResolverTests.swift new file mode 100644 index 0000000..fdb462d --- /dev/null +++ b/potassiumProviderTests/KDriveMachineNamespaceResolverTests.swift @@ -0,0 +1,291 @@ +import Foundation +import PotassiumProviderCore +import Testing + +@Suite +struct KDriveMachineNamespaceResolverTests { + @Test func sanitizesMacNameForKDriveAndNormalizesUnicode() throws { + let decomposedName = " Cafe\u{301}/Office:\u{1} " + + let name = try KDriveMachineNamespaceName.sanitized(decomposedName) + + #expect(name == "Café-Office--") + #expect(name == name.precomposedStringWithCanonicalMapping) + } + + @Test func truncatesLongNamesOnCharacterBoundaryWithStableHashSuffix() throws { + let longName = String(repeating: "🖥️ Studio ", count: 40) + + let first = try KDriveMachineNamespaceName.sanitized(longName) + let second = try KDriveMachineNamespaceName.sanitized(longName) + + #expect(first == second) + #expect(first.utf8.count <= KDriveMachineNamespaceName.maximumUTF8ByteCount) + #expect(first.contains("�") == false) + #expect(first.suffix(9).first == "-") + } + + @Test func rejectsEmptyAndReservedNames() { + #expect(throws: KDriveMachineNamespaceNameError.unusableComputerName) { + try KDriveMachineNamespaceName.sanitized(" \n ") + } + #expect(throws: KDriveMachineNamespaceNameError.unusableComputerName) { + try KDriveMachineNamespaceName.sanitized("..") + } + #expect(throws: KDriveMachineNamespaceNameError.unusableComputerName) { + try KDriveMachineNamespaceName.sanitized("/:\u{1}") + } + } + + @Test func reusesExistingNamespaceAfterExhaustiveListing() async throws { + let remote = MachineNamespaceRemote( + listings: [ + page(items: [], nextCursor: "next", hasMore: true), + page(items: [item(id: 44, name: "Studio Mac", type: "dir")]), + ], + creation: .failure + ) + + let namespace = try await KDriveMachineNamespaceResolver.resolveOrCreate( + driveID: 10, + privateDirectoryFileID: 77, + computerName: "Studio Mac", + remote: remote + ) + + #expect(namespace == KDriveMachineNamespace(name: "Studio Mac", fileID: 44)) + #expect(await remote.createdNames().isEmpty) + } + + @Test func createsMissingNamespace() async throws { + let remote = MachineNamespaceRemote( + listings: [page(items: [])], + creation: .item(item(id: 45, name: "Studio Mac", type: "dir")) + ) + + let namespace = try await KDriveMachineNamespaceResolver.resolveOrCreate( + driveID: 10, + privateDirectoryFileID: 77, + computerName: "Studio Mac", + remote: remote + ) + + #expect(namespace.fileID == 45) + #expect(await remote.createdNames() == ["Studio Mac"]) + } + + @Test func relistsOnceWhenConcurrentCreateWinsRace() async throws { + let remote = MachineNamespaceRemote( + listings: [ + page(items: []), + page(items: [item(id: 46, name: "Studio Mac", type: "dir")]), + ], + creation: .failure + ) + + let namespace = try await KDriveMachineNamespaceResolver.resolveOrCreate( + driveID: 10, + privateDirectoryFileID: 77, + computerName: "Studio Mac", + remote: remote + ) + + #expect(namespace.fileID == 46) + #expect(await remote.listingCallCount() == 2) + } + + @Test func failsClosedForFileCollisionAndAmbiguousDirectories() async { + let fileRemote = MachineNamespaceRemote( + listings: [page(items: [item(id: 47, name: "Studio Mac", type: "file")])], + creation: .failure + ) + await #expect(throws: KDriveMachineNamespaceResolutionError.notDirectory( + driveID: 10, + parentFileID: 77, + itemID: 47, + name: "Studio Mac" + )) { + try await KDriveMachineNamespaceResolver.resolveOrCreate( + driveID: 10, + privateDirectoryFileID: 77, + computerName: "Studio Mac", + remote: fileRemote + ) + } + + let ambiguousRemote = MachineNamespaceRemote( + listings: [page(items: [ + item(id: 48, name: "Studio Mac", type: "dir"), + item(id: 49, name: "Studio Mac", type: "dir"), + ])], + creation: .failure + ) + await #expect(throws: KDriveMachineNamespaceResolutionError.ambiguous( + driveID: 10, + parentFileID: 77, + itemIDs: [48, 49], + name: "Studio Mac" + )) { + try await KDriveMachineNamespaceResolver.resolveOrCreate( + driveID: 10, + privateDirectoryFileID: 77, + computerName: "Studio Mac", + remote: ambiguousRemote + ) + } + } + + private func page( + items: [KDriveRemoteItem], + nextCursor: String? = nil, + hasMore: Bool = false + ) -> KDriveItemPage { + KDriveItemPage(items: items, nextCursor: nextCursor, hasMore: hasMore) + } + + private func item(id: Int, name: String, type: String) -> KDriveRemoteItem { + KDriveRemoteItem( + id: id, + name: name, + type: type, + status: "ok", + driveID: 10, + parentID: 77, + path: "/Private/\(name)", + size: type == "file" ? 1 : nil, + mimeType: type == "file" ? "application/octet-stream" : nil, + createdAt: nil, + modifiedAt: Date(timeIntervalSince1970: 1), + updatedAt: Date(timeIntervalSince1970: 1) + ) + } +} + +private enum MachineNamespaceCreation: Sendable { + case item(KDriveRemoteItem) + case failure +} + +private enum MachineNamespaceRemoteError: Error { + case createFailed + case unexpectedRequest + case unimplemented +} + +private actor MachineNamespaceRemote: KDriveFileProviding { + private var listings: [KDriveItemPage] + private let creation: MachineNamespaceCreation + private var listedCount = 0 + private var namesCreated: [String] = [] + + init(listings: [KDriveItemPage], creation: MachineNamespaceCreation) { + self.listings = listings + self.creation = creation + } + + func listingCallCount() -> Int { + listedCount + } + + func createdNames() -> [String] { + namesCreated + } + + func listDirectory( + driveID: Int, + folderID: Int, + cursor: String?, + limit: Int + ) async throws -> KDriveItemPage { + guard listings.isEmpty == false else { + throw MachineNamespaceRemoteError.unexpectedRequest + } + listedCount += 1 + return listings.removeFirst() + } + + func createDirectory( + driveID: Int, + parentID: Int, + name: String + ) async throws -> KDriveRemoteItem { + namesCreated.append(name) + switch creation { + case .item(let item): + return item + case .failure: + throw MachineNamespaceRemoteError.createFailed + } + } + + func listDrives() async throws -> [KDriveDriveSummary] { + throw MachineNamespaceRemoteError.unimplemented + } + + func item(driveID: Int, fileID: Int) async throws -> KDriveRemoteItem { + throw MachineNamespaceRemoteError.unimplemented + } + + func listAdvancedDirectory( + driveID: Int, + folderID: Int, + cursor: String?, + limit: Int + ) async throws -> KDriveAdvancedItemPage { + throw MachineNamespaceRemoteError.unimplemented + } + + func listTrash(driveID: Int, cursor: String?, limit: Int) async throws -> KDriveItemPage { + throw MachineNamespaceRemoteError.unimplemented + } + + func downloadFile(driveID: Int, fileID: Int) async throws -> Data { + throw MachineNamespaceRemoteError.unimplemented + } + + func thumbnail(driveID: Int, fileID: Int, width: Int?, height: Int?) async throws -> Data { + throw MachineNamespaceRemoteError.unimplemented + } + + func uploadFile( + driveID: Int, + parentID: Int, + fileName: String, + contents: Data, + lastModifiedAt: Date?, + conflictStrategy: KDriveUploadConflictStrategy + ) async throws -> KDriveRemoteItem { + throw MachineNamespaceRemoteError.unimplemented + } + + func replaceFile( + driveID: Int, + parentID: Int, + fileName: String, + contents: Data, + lastModifiedAt: Date? + ) async throws -> KDriveRemoteItem { + throw MachineNamespaceRemoteError.unimplemented + } + + func renameItem(driveID: Int, fileID: Int, name: String) async throws { + throw MachineNamespaceRemoteError.unimplemented + } + + func moveItem( + driveID: Int, + fileID: Int, + destinationParentID: Int, + name: String? + ) async throws { + throw MachineNamespaceRemoteError.unimplemented + } + + func trashItem(driveID: Int, fileID: Int) async throws { + throw MachineNamespaceRemoteError.unimplemented + } + + func deleteTrashedItem(driveID: Int, fileID: Int) async throws { + throw MachineNamespaceRemoteError.unimplemented + } +} diff --git a/potassiumProviderTests/KnownFolderLifecycleTests.swift b/potassiumProviderTests/KnownFolderLifecycleTests.swift index 9bcd6c5..567b856 100644 --- a/potassiumProviderTests/KnownFolderLifecycleTests.swift +++ b/potassiumProviderTests/KnownFolderLifecycleTests.swift @@ -7,7 +7,7 @@ import Testing @Suite(.serialized) @MainActor struct KnownFolderLifecycleTests { - @Test func enablingSyncClaimsDesktopAndDocumentsUnderPrivateDirectory() async throws { + @Test func enablingSyncClaimsDesktopAndDocumentsUnderMacNamespace() async throws { let registrar = RecordingKnownFolderRegistrar(state: .inactive) try await withContext(registrar: registrar) { context in registrar.resetEvents() @@ -15,19 +15,26 @@ struct KnownFolderLifecycleTests { await context.model.enableKnownFolderSync(for: context.configuration) #expect(registrar.events == [ - .claim(domainIdentifier: context.configuration.domainIdentifier, parentFileID: 77), + .claim(domainIdentifier: context.configuration.domainIdentifier, parentFileID: 88), .refreshStates, ]) #expect(context.model.knownFolderSyncState(for: context.configuration) == .active) #expect(context.model.isChangingKnownFolderSync(for: context.configuration) == false) #expect(context.model.errorMessage == nil) - #expect(context.model.statusMessage?.contains("kDrive /private") == true) + #expect(context.model.statusMessage?.contains("kDrive /Private/Studio Mac") == true) + let storedConfiguration = try #require( + try await context.domainStore.configuration( + domainIdentifier: context.configuration.domainIdentifier + ) + ) + #expect(storedConfiguration.knownFolderLayout == .machineNamespace) } } @Test func disablingSyncReleasesKnownFoldersAndRefreshesState() async throws { let registrar = RecordingKnownFolderRegistrar(state: .active) try await withContext(registrar: registrar) { context in + #expect(context.model.knownFolderRemotePath(for: context.configuration) == "/Private") registrar.resetEvents() await context.model.disableKnownFolderSync(for: context.configuration) @@ -37,11 +44,35 @@ struct KnownFolderLifecycleTests { .refreshStates, ]) #expect(context.model.knownFolderSyncState(for: context.configuration) == .inactive) + #expect( + context.model.knownFolderRemotePath(for: context.configuration) + == "/Private/Studio Mac" + ) #expect(context.model.isChangingKnownFolderSync(for: context.configuration) == false) #expect(context.model.errorMessage == nil) } } + @Test func failedClaimRestoresLegacyLayoutMarker() async throws { + let registrar = RecordingKnownFolderRegistrar( + state: .inactive, + claimError: KnownFolderLifecycleTestError.claimFailed + ) + try await withContext(registrar: registrar) { context in + await context.model.enableKnownFolderSync(for: context.configuration) + + let storedConfiguration = try #require( + try await context.domainStore.configuration( + domainIdentifier: context.configuration.domainIdentifier + ) + ) + #expect(storedConfiguration.knownFolderLayout == .legacyPrivate) + #expect(context.model.domains.first?.knownFolderLayout == .legacyPrivate) + #expect(context.model.knownFolderSyncState(for: context.configuration) == .inactive) + #expect(context.model.errorMessage?.contains("Could not sync") == true) + } + } + @Test func removingActiveDomainReleasesKnownFoldersBeforeRemovingDomain() async throws { let registrar = RecordingKnownFolderRegistrar(state: .active) try await withContext(registrar: registrar) { context in @@ -172,7 +203,8 @@ struct KnownFolderLifecycleTests { displayName: "Test Drive", driveID: 42, driveName: "Test Drive", - rootFileID: 1 + rootFileID: 1, + knownFolderLayout: .legacyPrivate ) try await accountStore.save(account) try await domainStore.save(configuration) @@ -200,7 +232,8 @@ struct KnownFolderLifecycleTests { snapshotStore: snapshotStore, eventStore: eventStore, automaticallyReloadStoredState: false, - fileProviderFactory: { _ in remote } + fileProviderFactory: { _ in remote }, + computerNameProvider: { "Studio Mac" } ) await model.reloadStoredState() #expect(model.knownFolderSyncState(for: configuration) == registrar.state) @@ -240,10 +273,16 @@ private final class RecordingKnownFolderRegistrar: ProviderDomainRegistering { private(set) var events: [Event] = [] private(set) var state: ProviderKnownFolderSyncState + private let claimError: Error? private let releaseError: Error? - init(state: ProviderKnownFolderSyncState, releaseError: Error? = nil) { + init( + state: ProviderKnownFolderSyncState, + claimError: Error? = nil, + releaseError: Error? = nil + ) { self.state = state + self.claimError = claimError self.releaseError = releaseError } @@ -272,6 +311,9 @@ private final class RecordingKnownFolderRegistrar: ProviderDomainRegistering { domainIdentifier: configuration.domainIdentifier, parentFileID: parentFileID )) + if let claimError { + throw claimError + } state = .active } @@ -285,10 +327,16 @@ private final class RecordingKnownFolderRegistrar: ProviderDomainRegistering { } private enum KnownFolderLifecycleTestError: LocalizedError { + case claimFailed case releaseFailed var errorDescription: String? { - "The test release failed." + switch self { + case .claimFailed: + "The test claim failed." + case .releaseFailed: + "The test release failed." + } } } @@ -309,24 +357,19 @@ private struct KnownFolderLifecycleRemote: KDriveFileProviding { cursor: String?, limit: Int ) async throws -> KDriveItemPage { - KDriveItemPage( - items: [KDriveRemoteItem( + let items: [KDriveRemoteItem] + if folderID == 1 { + items = [remoteItem( id: 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 - ) + path: "/Private" + )] + } else { + items = [] + } + return KDriveItemPage(items: items, nextCursor: nil, hasMore: false) } func listAdvancedDirectory( @@ -372,7 +415,13 @@ private struct KnownFolderLifecycleRemote: KDriveFileProviding { } func createDirectory(driveID: Int, parentID: Int, name: String) async throws -> KDriveRemoteItem { - throw KnownFolderLifecycleTestError.releaseFailed + remoteItem( + id: 88, + name: name, + driveID: driveID, + parentID: parentID, + path: "/Private/\(name)" + ) } func renameItem(driveID: Int, fileID: Int, name: String) async throws { @@ -390,5 +439,28 @@ private struct KnownFolderLifecycleRemote: KDriveFileProviding { func deleteTrashedItem(driveID: Int, fileID: Int) async throws { throw KnownFolderLifecycleTestError.releaseFailed } + + private func remoteItem( + id: Int, + name: String, + driveID: Int, + parentID: Int, + path: String + ) -> KDriveRemoteItem { + KDriveRemoteItem( + id: id, + name: name, + type: "dir", + status: "ok", + driveID: driveID, + parentID: parentID, + path: path, + size: nil, + mimeType: nil, + createdAt: nil, + modifiedAt: Date(timeIntervalSince1970: 1), + updatedAt: Date(timeIntervalSince1970: 1) + ) + } } #endif diff --git a/potassiumProviderTests/potassiumProviderTests.swift b/potassiumProviderTests/potassiumProviderTests.swift index ff1e0c4..6bfd33f 100644 --- a/potassiumProviderTests/potassiumProviderTests.swift +++ b/potassiumProviderTests/potassiumProviderTests.swift @@ -77,6 +77,17 @@ struct PotassiumProviderCoreTests { #expect(configuration.accountIdentifier == ProviderConstants.legacyAccountIdentifier) #expect(configuration.driveID == 42) + #expect(configuration.knownFolderLayout == .legacyPrivate) + } + + @Test func newDomainConfigurationUsesMachineNamespaceKnownFolderLayout() { + let configuration = ProviderDomainConfiguration( + displayName: "Work Drive", + driveID: 42, + driveName: "Work Drive" + ) + + #expect(configuration.knownFolderLayout == .machineNamespace) } @Test func inMemoryTokenStoreScopesTokensAndMigratesLegacyToken() async throws {