Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
250 changes: 250 additions & 0 deletions PotassiumProviderCore/KDriveMachineNamespaceResolver.swift
Original file line number Diff line number Diff line change
@@ -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<String> = []
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
}
}
16 changes: 16 additions & 0 deletions PotassiumProviderCore/ProviderDomainConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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/<Mac name>`.
case machineNamespace
}

public struct ProviderAccount: Codable, Equatable, Identifiable, Sendable {
public var id: String { accountIdentifier }

Expand Down Expand Up @@ -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

Expand All @@ -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()
) {
Expand All @@ -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
}
Expand Down Expand Up @@ -186,6 +197,7 @@ public struct ProviderDomainConfiguration: Codable, Equatable, Identifiable, Sen
case driveID
case driveName
case rootFileID
case knownFolderLayout
case createdAt
case updatedAt
}
Expand All @@ -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)
}
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<current Mac name>` on the selected kDrive. Existing active domains
created before this layout remain directly under `Private` until sync is
stopped and enabled again.

## License

Expand Down
23 changes: 18 additions & 5 deletions doc/APP_AND_DOMAINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<current Mac name>`. 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
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion doc/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<current Mac name>` 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
Expand Down
13 changes: 10 additions & 3 deletions doc/FILE_PROVIDER_LIFECYCLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<current Mac name>` 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.
Expand Down
Loading