diff --git a/AGENTS.md b/AGENTS.md index f814dda..af7a57f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,8 +9,9 @@ File Provider experience. - UI: SwiftUI app shell - Build system: Xcode project, not Tuist and not a root Swift Package - Scheme: `potassiumProvider` -- Targets: `potassiumProvider`, `potassiumProviderTests`, - `potassiumProviderUITests` +- Targets: `potassiumProvider`, `PotassiumProviderCore`, + `potassiumProviderFileProvider`, `potassiumProviderActions`, + `potassiumProviderTests`, and `potassiumProviderUITests` - Supported validation platforms: iOS Simulator, macOS, and visionOS - Dependencies: `SQLite.swift` and `potassiumChannel` package products `PotassiumChannelCore`, `PotassiumKDrive`, and `PotassiumOAuth` @@ -21,6 +22,10 @@ File Provider experience. ```text potassiumProvider/ |-- potassiumProvider/ # App target; SwiftUI entry point and views +|-- PotassiumProviderCore/ # Shared models, service adapters, and storage +|-- potassiumProviderFileProvider/ +| # Replicated provider and background actions +|-- potassiumProviderActions/ # File Provider UI contextual panels |-- potassiumProviderTests/ # Swift Testing unit tests |-- potassiumProviderUITests/ # XCTest UI automation tests |-- potassiumProvider.xcodeproj/ # Source of truth for targets, scheme, settings, diff --git a/CHANGELOG.md b/CHANGELOG.md index d9d4865..d38f400 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # Changelog +## 0.3.0 + +### Added + +- Finder/Files actions to add or remove kDrive favorites, duplicate items + server-side, and restore items from trash with root fallback when the + original parent is gone. +- A cross-platform File Provider UI extension for share-link management and + paged document version history with non-destructive “Restore as Copy.” +- Favorite and trash metadata used by native File Provider presentation and + action predicates. +- Native lazy/offline metadata so the system can present Download Now and + Remove Download. +- Per-drive Show in Finder/Files and Sync Now controls in the app. +- Sanitized favorite, duplicate, restore, share-link, and version-restore + activity in the existing timeline. + +### Changed + +- Snapshot generation and legacy snapshot tables now retain nullable favorite + state with an in-place SQLite migration. Older snapshot and working-set JSON + remains decodable. +- Successful local mutations invalidate and signal each affected folder plus + the working set. +- Release version is `0.3.0` with build number `4`. + +### Dependency state + +- potassiumChannel remains pinned to `0.2.0`. +- Contextual operations use only existing typed `PotassiumKDrive` 0.2.0 + service methods; no local ad hoc HTTP requests were added. + +### Security + +- Share URLs and passwords remain view-model memory only. They are not written + to domain JSON, SQLite, activity rows, support logs, or unified logs. + ## 0.2.1 ### Fixed diff --git a/Config/potassiumProviderActions.entitlements b/Config/potassiumProviderActions.entitlements new file mode 100644 index 0000000..cd41cea --- /dev/null +++ b/Config/potassiumProviderActions.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.application-groups + + group.net.weavee.potassiumProvider + + com.apple.security.network.client + + keychain-access-groups + + $(AppIdentifierPrefix)net.weavee.potassiumProvider + + + diff --git a/Config/potassiumProviderActionsInfo.plist b/Config/potassiumProviderActionsInfo.plist new file mode 100644 index 0000000..fdf5008 --- /dev/null +++ b/Config/potassiumProviderActionsInfo.plist @@ -0,0 +1,50 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Potassium Actions + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.fileprovider-actionsui + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ProviderActionViewController + NSExtensionFileProviderActions + + + NSExtensionFileProviderActionIdentifier + net.weavee.potassiumProvider.action.shareLink + NSExtensionFileProviderActionName + Share kDrive Link… + NSExtensionFileProviderActionActivationRule + fileproviderItems.@count == 1 AND SUBQUERY(fileproviderItems, $item, $item.userInfo.isRoot == NO AND $item.userInfo.isTrashed == NO).@count == 1 + + + NSExtensionFileProviderActionIdentifier + net.weavee.potassiumProvider.action.versionHistory + NSExtensionFileProviderActionName + Version History… + NSExtensionFileProviderActionActivationRule + fileproviderItems.@count == 1 AND SUBQUERY(fileproviderItems, $item, $item.userInfo.isRoot == NO AND $item.userInfo.isTrashed == NO AND $item.userInfo.isDirectory == NO).@count == 1 + + + + + diff --git a/Config/potassiumProviderFileProviderInfo.plist b/Config/potassiumProviderFileProviderInfo.plist index add186e..d873767 100644 --- a/Config/potassiumProviderFileProviderInfo.plist +++ b/Config/potassiumProviderFileProviderInfo.plist @@ -26,6 +26,41 @@ NSExtensionFileProviderDocumentGroup group.net.weavee.potassiumProvider + NSExtensionFileProviderActions + + + NSExtensionFileProviderActionIdentifier + net.weavee.potassiumProvider.action.addFavorite + NSExtensionFileProviderActionName + Add to kDrive Favorites + NSExtensionFileProviderActionActivationRule + fileproviderItems.@count == 1 AND SUBQUERY(fileproviderItems, $item, $item.userInfo.isRoot == NO AND $item.userInfo.isTrashed == NO AND $item.userInfo.isFavorite == NO).@count == 1 + + + NSExtensionFileProviderActionIdentifier + net.weavee.potassiumProvider.action.removeFavorite + NSExtensionFileProviderActionName + Remove from kDrive Favorites + NSExtensionFileProviderActionActivationRule + fileproviderItems.@count == 1 AND SUBQUERY(fileproviderItems, $item, $item.userInfo.isRoot == NO AND $item.userInfo.isTrashed == NO AND $item.userInfo.isFavorite == YES).@count == 1 + + + NSExtensionFileProviderActionIdentifier + net.weavee.potassiumProvider.action.duplicate + NSExtensionFileProviderActionName + Duplicate on kDrive + NSExtensionFileProviderActionActivationRule + fileproviderItems.@count == 1 AND SUBQUERY(fileproviderItems, $item, $item.userInfo.isRoot == NO AND $item.userInfo.isTrashed == NO).@count == 1 + + + NSExtensionFileProviderActionIdentifier + net.weavee.potassiumProvider.action.restoreFromTrash + NSExtensionFileProviderActionName + Restore from kDrive Trash + NSExtensionFileProviderActionActivationRule + fileproviderItems.@count == 1 AND SUBQUERY(fileproviderItems, $item, $item.userInfo.isRoot == NO AND $item.userInfo.isTrashed == YES).@count == 1 + + NSExtensionPointIdentifier com.apple.fileprovider-nonui NSExtensionPrincipalClass diff --git a/PotassiumProviderCore/KDriveContextActions.swift b/PotassiumProviderCore/KDriveContextActions.swift new file mode 100644 index 0000000..cc41ccb --- /dev/null +++ b/PotassiumProviderCore/KDriveContextActions.swift @@ -0,0 +1,328 @@ +import Foundation + +public struct KDriveShareLinkConfiguration: Equatable, Sendable { + public enum Access: String, CaseIterable, Equatable, Sendable { + case `public` + case password + } + + public var access: Access + public var password: String? + public var validUntil: Date? + public var allowsDownload: Bool + public var allowsComments: Bool + public var allowsEditing: Bool + public var allowsAccessRequests: Bool + public var showsFileInformation: Bool + public var showsStatistics: Bool + + public init( + access: Access = .public, + password: String? = nil, + validUntil: Date? = nil, + allowsDownload: Bool = true, + allowsComments: Bool = false, + allowsEditing: Bool = false, + allowsAccessRequests: Bool = false, + showsFileInformation: Bool = true, + showsStatistics: Bool = false + ) { + self.access = access + self.password = password + self.validUntil = validUntil + self.allowsDownload = allowsDownload + self.allowsComments = allowsComments + self.allowsEditing = allowsEditing + self.allowsAccessRequests = allowsAccessRequests + self.showsFileInformation = showsFileInformation + self.showsStatistics = showsStatistics + } + + public var isValid: Bool { + access != .password || password?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } + + public func isValid(preservingPasswordFor existingAccess: Access?) -> Bool { + isValid || (access == .password && existingAccess == .password) + } +} + +public struct KDriveShareLinkSummary: Equatable, Sendable { + public let url: URL + public let configuration: KDriveShareLinkConfiguration + public let viewCount: Int? + + public init(url: URL, configuration: KDriveShareLinkConfiguration, viewCount: Int?) { + self.url = url + self.configuration = configuration + self.viewCount = viewCount + } +} + +public struct KDriveFileVersionSummary: Equatable, Identifiable, Sendable { + public let id: Int + public let createdAt: Date + public let modifiedAt: Date? + public let size: Int + public let mimeType: String? + public let editorDisplayName: String + public let isKeptForever: Bool + + public init( + id: Int, + createdAt: Date, + modifiedAt: Date?, + size: Int, + mimeType: String?, + editorDisplayName: String, + isKeptForever: Bool + ) { + self.id = id + self.createdAt = createdAt + self.modifiedAt = modifiedAt + self.size = size + self.mimeType = mimeType + self.editorDisplayName = editorDisplayName + self.isKeptForever = isKeptForever + } +} + +public struct KDriveFileVersionPage: Equatable, Sendable { + public let versions: [KDriveFileVersionSummary] + public let page: Int + public let hasMore: Bool + + public init(versions: [KDriveFileVersionSummary], page: Int, hasMore: Bool) { + self.versions = versions + self.page = page + self.hasMore = hasMore + } +} + +public protocol KDriveContextActionProviding: Sendable { + func setFavorite(driveID: Int, fileID: Int, isFavorite: Bool) async throws + func duplicateItem(driveID: Int, fileID: Int) async throws -> KDriveRemoteItem + func trashedItem(driveID: Int, fileID: Int) async throws -> KDriveRemoteItem + func existingFileIDs(driveID: Int, fileIDs: [Int]) async throws -> Set + func restoreTrashedItem(driveID: Int, fileID: Int, destinationParentID: Int) async throws + func shareLink(driveID: Int, fileID: Int) async throws -> KDriveShareLinkSummary? + func createShareLink( + driveID: Int, + fileID: Int, + configuration: KDriveShareLinkConfiguration + ) async throws -> KDriveShareLinkSummary + func updateShareLink( + driveID: Int, + fileID: Int, + configuration: KDriveShareLinkConfiguration + ) async throws -> KDriveShareLinkSummary + func deleteShareLink(driveID: Int, fileID: Int) async throws + func fileVersions(driveID: Int, fileID: Int, page: Int, pageSize: Int) async throws -> KDriveFileVersionPage + func restoreFileVersion( + driveID: Int, + fileID: Int, + versionID: Int, + destinationParentID: Int, + name: String + ) async throws -> KDriveRemoteItem +} + +public enum KDriveContextActionError: Error, Equatable, LocalizedError, Sendable { + case invalidShareLinkURL + case passwordRequired + case restoredItemUnavailable + + public var errorDescription: String? { + switch self { + case .invalidShareLinkURL: + return "kDrive returned an invalid share link." + case .passwordRequired: + return "Enter a password for the protected share link." + case .restoredItemUnavailable: + return "kDrive restored the version, but its metadata is not available yet." + } + } +} + +public enum ProviderContextActionIdentifier { + public static let addFavorite = ProviderDirectContextAction.addFavorite.rawValue + public static let removeFavorite = ProviderDirectContextAction.removeFavorite.rawValue + public static let duplicate = ProviderDirectContextAction.duplicate.rawValue + public static let restoreFromTrash = ProviderDirectContextAction.restoreFromTrash.rawValue + public static let shareLink = "net.weavee.potassiumProvider.action.shareLink" + public static let versionHistory = "net.weavee.potassiumProvider.action.versionHistory" +} + +public struct ProviderContextItemState: Equatable, Sendable { + public let isDirectory: Bool + public let isFavorite: Bool + public let isTrashed: Bool + public let isRoot: Bool + + public init( + isDirectory: Bool, + isFavorite: Bool, + isTrashed: Bool, + isRoot: Bool = false + ) { + self.isDirectory = isDirectory + self.isFavorite = isFavorite + self.isTrashed = isTrashed + self.isRoot = isRoot + } +} + +public enum ProviderDirectContextAction: String, CaseIterable, Equatable, Sendable { + case addFavorite = "net.weavee.potassiumProvider.action.addFavorite" + case removeFavorite = "net.weavee.potassiumProvider.action.removeFavorite" + case duplicate = "net.weavee.potassiumProvider.action.duplicate" + case restoreFromTrash = "net.weavee.potassiumProvider.action.restoreFromTrash" + + public func isAvailable(for state: ProviderContextItemState) -> Bool { + guard state.isRoot == false else { return false } + switch self { + case .addFavorite: + return state.isTrashed == false && state.isFavorite == false + case .removeFavorite: + return state.isTrashed == false && state.isFavorite + case .duplicate: + return state.isTrashed == false + case .restoreFromTrash: + return state.isTrashed + } + } +} + +public struct KDriveContextActionExecution: Equatable, Sendable { + public let action: ProviderDirectContextAction + public let activityItem: KDriveRemoteItem + public let affectedParentIDs: Set + public let invalidatesTrash: Bool + public let summary: String + + public init( + action: ProviderDirectContextAction, + activityItem: KDriveRemoteItem, + affectedParentIDs: Set, + invalidatesTrash: Bool, + summary: String + ) { + self.action = action + self.activityItem = activityItem + self.affectedParentIDs = affectedParentIDs + self.invalidatesTrash = invalidatesTrash + self.summary = summary + } +} + +/// Coordinates action-specific remote calls and returns the exact provider +/// containers that must be invalidated after the server mutation succeeds. +public struct KDriveContextActionCoordinator: Sendable { + private let driveID: Int + private let rootFileID: Int + private let remote: any KDriveItemMetadataProviding + private let actions: any KDriveContextActionProviding + + public init( + driveID: Int, + rootFileID: Int, + remote: any KDriveItemMetadataProviding, + actions: any KDriveContextActionProviding + ) { + self.driveID = driveID + self.rootFileID = rootFileID + self.remote = remote + self.actions = actions + } + + public func perform( + _ action: ProviderDirectContextAction, + fileID: Int + ) async throws -> KDriveContextActionExecution { + switch action { + case .addFavorite: + return try await setFavorite(true, fileID: fileID, action: action) + case .removeFavorite: + return try await setFavorite(false, fileID: fileID, action: action) + case .duplicate: + try Task.checkCancellation() + let duplicate = try await actions.duplicateItem(driveID: driveID, fileID: fileID) + let authoritativeDuplicate = try await remote.item(driveID: driveID, fileID: duplicate.id) + return KDriveContextActionExecution( + action: action, + activityItem: authoritativeDuplicate, + affectedParentIDs: [authoritativeDuplicate.parentID], + invalidatesTrash: false, + summary: "Duplicated item on kDrive." + ) + case .restoreFromTrash: + let trashedItem = try await actions.trashedItem(driveID: driveID, fileID: fileID) + let originalParentExists: Bool + if trashedItem.parentID == rootFileID { + originalParentExists = true + } else { + originalParentExists = try await actions.existingFileIDs( + driveID: driveID, + fileIDs: [trashedItem.parentID] + ).contains(trashedItem.parentID) + } + let destinationParentID = originalParentExists ? trashedItem.parentID : rootFileID + try Task.checkCancellation() + try await actions.restoreTrashedItem( + driveID: driveID, + fileID: fileID, + destinationParentID: destinationParentID + ) + return KDriveContextActionExecution( + action: action, + activityItem: trashedItem, + affectedParentIDs: [destinationParentID], + invalidatesTrash: true, + summary: originalParentExists + ? "Restored item from kDrive trash." + : "Restored item from kDrive trash to the drive root because its original folder is unavailable." + ) + } + } + + private func setFavorite( + _ isFavorite: Bool, + fileID: Int, + action: ProviderDirectContextAction + ) async throws -> KDriveContextActionExecution { + let currentItem = try await remote.item(driveID: driveID, fileID: fileID) + try Task.checkCancellation() + try await actions.setFavorite(driveID: driveID, fileID: fileID, isFavorite: isFavorite) + let updatedItem = try await remote.item(driveID: driveID, fileID: fileID) + return KDriveContextActionExecution( + action: action, + activityItem: updatedItem, + affectedParentIDs: [currentItem.parentID, updatedItem.parentID], + invalidatesTrash: false, + summary: isFavorite + ? "Added item to kDrive favorites." + : "Removed item from kDrive favorites." + ) + } +} + +public enum KDriveRestoredCopyNaming { + public static func filename( + originalName: String, + restoredAt date: Date, + timeZone: TimeZone = .current, + uniqueSuffix: String = String(UUID().uuidString.prefix(6)) + ) -> String { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone + formatter.dateFormat = "yyyy-MM-dd HH.mm" + + let original = originalName as NSString + let pathExtension = original.pathExtension + let stem = original.deletingPathExtension + let suffix = " (restored \(formatter.string(from: date)) \(uniqueSuffix))" + return pathExtension.isEmpty ? stem + suffix : "\(stem)\(suffix).\(pathExtension)" + } +} diff --git a/PotassiumProviderCore/KDriveModels.swift b/PotassiumProviderCore/KDriveModels.swift index 312862d..58736e7 100644 --- a/PotassiumProviderCore/KDriveModels.swift +++ b/PotassiumProviderCore/KDriveModels.swift @@ -29,6 +29,7 @@ public struct KDriveRemoteItem: Codable, Equatable, Identifiable, Sendable { public let path: String? public let size: Int? public let mimeType: String? + public let isFavorite: Bool? public let createdAt: Date? public let modifiedAt: Date public let updatedAt: Date @@ -43,6 +44,7 @@ public struct KDriveRemoteItem: Codable, Equatable, Identifiable, Sendable { path: String?, size: Int?, mimeType: String?, + isFavorite: Bool? = nil, createdAt: Date?, modifiedAt: Date, updatedAt: Date @@ -56,6 +58,7 @@ public struct KDriveRemoteItem: Codable, Equatable, Identifiable, Sendable { self.path = path self.size = size self.mimeType = mimeType + self.isFavorite = isFavorite self.createdAt = createdAt self.modifiedAt = modifiedAt self.updatedAt = updatedAt diff --git a/PotassiumProviderCore/KDriveRemoteService.swift b/PotassiumProviderCore/KDriveRemoteService.swift index 5761bde..2cc73c6 100644 --- a/PotassiumProviderCore/KDriveRemoteService.swift +++ b/PotassiumProviderCore/KDriveRemoteService.swift @@ -3,9 +3,12 @@ import OSLog import PotassiumChannelCore import PotassiumKDrive -public protocol KDriveFileProviding: Sendable { - func listDrives() async throws -> [KDriveDriveSummary] +public protocol KDriveItemMetadataProviding: Sendable { func item(driveID: Int, fileID: Int) async throws -> KDriveRemoteItem +} + +public protocol KDriveFileProviding: KDriveItemMetadataProviding { + func listDrives() async throws -> [KDriveDriveSummary] func listDirectory(driveID: Int, folderID: Int, cursor: String?, limit: Int) async throws -> KDriveItemPage func listAdvancedDirectory(driveID: Int, folderID: Int, cursor: String?, limit: Int) async throws -> KDriveAdvancedItemPage func listTrash(driveID: Int, cursor: String?, limit: Int) async throws -> KDriveItemPage @@ -141,7 +144,7 @@ public enum KDriveUploadConflictStrategy: String, Sendable { case rename } -public struct PotassiumKDriveService: KDriveFileProviding, KDriveWorkingSetRemoteProviding { +public struct PotassiumKDriveService: KDriveFileProviding, KDriveWorkingSetRemoteProviding, KDriveContextActionProviding { private let apiClient: InfomaniakAPIClient private let driveClient: InfomaniakAPIClient private let service: KDriveService @@ -460,6 +463,214 @@ public struct PotassiumKDriveService: KDriveFileProviding, KDriveWorkingSetRemot } } + public func setFavorite(driveID: Int, fileID: Int, isFavorite: Bool) async throws { + _ = try await performNetworkOperation(isFavorite ? "favoriteItem" : "unfavoriteItem") { + if isFavorite { + try await service.favoriteFile(driveId: driveID, fileId: fileID) + } else { + try await service.unfavoriteFile(driveId: driveID, fileId: fileID) + } + } + } + + public func duplicateItem(driveID: Int, fileID: Int) async throws -> KDriveRemoteItem { + try await performNetworkOperation("duplicateItem") { + try await service.duplicateFile(driveId: driveID, fileId: fileID).data.remoteItem + } + } + + public func trashedItem(driveID: Int, fileID: Int) async throws -> KDriveRemoteItem { + try await performNetworkOperation("trashedItem") { + try await service.getTrashedFile(driveId: driveID, fileId: fileID).data.remoteItem + } + } + + public func existingFileIDs(driveID: Int, fileIDs: [Int]) async throws -> Set { + guard fileIDs.isEmpty == false else { return [] } + return try await performNetworkOperation("existingFileIDs") { + Set(try await service.checkFilesExistence(driveId: driveID, fileIds: fileIDs).data.lazy + .filter(\.result) + .map(\.id)) + } + } + + public func restoreTrashedItem(driveID: Int, fileID: Int, destinationParentID: Int) async throws { + _ = try await performNetworkOperation("restoreTrashedItem") { + try await service.restoreTrashedFile( + driveId: driveID, + fileId: fileID, + destinationDirectoryId: destinationParentID + ) + } + } + + public func shareLink(driveID: Int, fileID: Int) async throws -> KDriveShareLinkSummary? { + do { + return try await performNetworkOperation("shareLink") { + try Self.shareLinkSummary( + try await service.getFileShareLink(driveId: driveID, fileId: fileID).data + ) + } + } catch APIClientError.unacceptableStatusCode(let statusCode, _) where statusCode == 404 { + return nil + } + } + + public func createShareLink( + driveID: Int, + fileID: Int, + configuration: KDriveShareLinkConfiguration + ) async throws -> KDriveShareLinkSummary { + guard configuration.isValid else { + throw KDriveContextActionError.passwordRequired + } + return try await performNetworkOperation("createShareLink") { + let link = try await service.createFileShareLink( + driveId: driveID, + fileId: fileID, + options: Self.createShareLinkOptions(configuration) + ).data + return try Self.shareLinkSummary(link) + } + } + + public func updateShareLink( + driveID: Int, + fileID: Int, + configuration: KDriveShareLinkConfiguration + ) async throws -> KDriveShareLinkSummary { + _ = try await performNetworkOperation("updateShareLink") { + try await service.updateFileShareLink( + driveId: driveID, + fileId: fileID, + options: Self.updateShareLinkOptions(configuration) + ) + } + guard let link = try await shareLink(driveID: driveID, fileID: fileID) else { + throw KDriveContextActionError.invalidShareLinkURL + } + return link + } + + public func deleteShareLink(driveID: Int, fileID: Int) async throws { + _ = try await performNetworkOperation("deleteShareLink") { + try await service.deleteFileShareLink(driveId: driveID, fileId: fileID) + } + } + + public func fileVersions( + driveID: Int, + fileID: Int, + page: Int, + pageSize: Int + ) async throws -> KDriveFileVersionPage { + try await performNetworkOperation("fileVersions") { + let response = try await service.listFileVersions( + driveId: driveID, + fileId: fileID, + page: page, + perPage: pageSize, + total: true, + orderBy: "created_at", + order: "desc" + ) + let resolvedPage = response.page ?? page + let versions = response.data.map { version in + KDriveFileVersionSummary( + id: version.id, + createdAt: Date(timeIntervalSince1970: TimeInterval(version.createdAt)), + modifiedAt: version.lastModifiedAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }, + size: version.size, + mimeType: version.mimeType, + editorDisplayName: Self.displayName(for: version.updatedBy), + isKeptForever: version.keepForever + ) + } + let hasMore = response.pages.map { resolvedPage < $0 } ?? (versions.count == pageSize) + return KDriveFileVersionPage(versions: versions, page: resolvedPage, hasMore: hasMore) + } + } + + public func restoreFileVersion( + driveID: Int, + fileID: Int, + versionID: Int, + destinationParentID: Int, + name: String + ) async throws -> KDriveRemoteItem { + let restoredID = try await performNetworkOperation("restoreFileVersion") { + try await service.restoreFileVersionToDirectory( + driveId: driveID, + fileId: fileID, + versionId: versionID, + destinationDirectoryId: destinationParentID, + options: RestoreKDriveFileVersionToDirectoryOptions(name: name) + ).data.id + } + return try await item(driveID: driveID, fileID: restoredID) + } + + private static func createShareLinkOptions( + _ configuration: KDriveShareLinkConfiguration + ) -> CreateKDriveFileShareLinkOptions { + CreateKDriveFileShareLinkOptions( + right: configuration.access.rawValue, + canComment: configuration.allowsComments, + canDownload: configuration.allowsDownload, + canEdit: configuration.allowsEditing, + canRequestAccess: configuration.allowsAccessRequests, + canSeeInfo: configuration.showsFileInformation, + canSeeStats: configuration.showsStatistics, + password: configuration.access == .password ? configuration.password : nil, + validUntil: configuration.validUntil.map(unixTimestamp) + ) + } + + private static func updateShareLinkOptions( + _ configuration: KDriveShareLinkConfiguration + ) -> UpdateKDriveFileShareLinkOptions { + UpdateKDriveFileShareLinkOptions( + canComment: configuration.allowsComments, + canDownload: configuration.allowsDownload, + canEdit: configuration.allowsEditing, + canRequestAccess: configuration.allowsAccessRequests, + canSeeInfo: configuration.showsFileInformation, + canSeeStats: configuration.showsStatistics, + password: configuration.access == .password ? configuration.password : nil, + right: configuration.access.rawValue, + validUntil: configuration.validUntil.map(unixTimestamp) + ) + } + + private static func shareLinkSummary(_ link: KDriveShareLink) throws -> KDriveShareLinkSummary { + guard let url = URL(string: link.url) else { + throw KDriveContextActionError.invalidShareLinkURL + } + let access = KDriveShareLinkConfiguration.Access(rawValue: link.right) ?? .public + return KDriveShareLinkSummary( + url: url, + configuration: KDriveShareLinkConfiguration( + access: access, + validUntil: link.validUntil.map { Date(timeIntervalSince1970: TimeInterval($0)) }, + allowsDownload: link.capabilities.canDownload, + allowsComments: link.capabilities.canComment, + allowsEditing: link.capabilities.canEdit, + allowsAccessRequests: link.capabilities.canRequestAccess, + showsFileInformation: link.capabilities.canSeeInfo, + showsStatistics: link.capabilities.canSeeStats + ), + viewCount: link.views + ) + } + + private static func displayName(for user: KDriveUser) -> String { + if let displayName = user.displayName, displayName.isEmpty == false { + return displayName + } + let components = [user.firstName, user.lastName].compactMap { $0 }.filter { $0.isEmpty == false } + return components.isEmpty ? "Unknown editor" : components.joined(separator: " ") + } + private func performNetworkOperation( _ operation: String, _ work: () async throws -> Value @@ -587,6 +798,7 @@ extension KDriveFileItem { path: path, size: size, mimeType: mimeType, + isFavorite: isFavorite, createdAt: createdAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }, modifiedAt: Date(timeIntervalSince1970: TimeInterval(lastModifiedAt)), updatedAt: Date(timeIntervalSince1970: TimeInterval(updatedAt)) diff --git a/PotassiumProviderCore/ProviderActionRuntime.swift b/PotassiumProviderCore/ProviderActionRuntime.swift new file mode 100644 index 0000000..fccf7f5 --- /dev/null +++ b/PotassiumProviderCore/ProviderActionRuntime.swift @@ -0,0 +1,71 @@ +import Foundation + +public struct ProviderActionRuntime: Sendable { + public let configuration: ProviderDomainConfiguration + public let remote: any KDriveFileProviding + public let actions: any KDriveContextActionProviding + public let eventStore: (any KDriveProviderEventStoring)? + + public init( + configuration: ProviderDomainConfiguration, + remote: any KDriveFileProviding, + actions: any KDriveContextActionProviding, + eventStore: (any KDriveProviderEventStoring)? + ) { + self.configuration = configuration + self.remote = remote + self.actions = actions + self.eventStore = eventStore + } + + public static func load(domainIdentifier: String) async throws -> ProviderActionRuntime { + let configurationStore = try DomainConfigurationFileStore( + appGroupIdentifier: ProviderConstants.appGroupIdentifier + ) + guard let configuration = try await configurationStore.configuration( + domainIdentifier: domainIdentifier + ) else { + throw ProviderActionRuntimeError.configurationUnavailable + } + + let tokenStore = KeychainOAuthTokenStore(accessGroup: ProviderConstants.keychainAccessGroup) + guard var token = try await tokenStore.loadToken( + accountIdentifier: configuration.accountIdentifier + ) else { + throw ProviderActionRuntimeError.notAuthenticated + } + + if token.shouldRefresh() { + guard let refreshToken = token.refreshToken else { + throw ProviderActionRuntimeError.notAuthenticated + } + token = try await KDriveOAuthClient.refresh(refreshToken: refreshToken) + try await tokenStore.saveToken(token, accountIdentifier: configuration.accountIdentifier) + } + + let service = PotassiumKDriveService(bearerToken: token.accessToken) + let eventStore = try? KDriveProviderEventSQLiteStore( + appGroupIdentifier: ProviderConstants.appGroupIdentifier + ) + return ProviderActionRuntime( + configuration: configuration, + remote: service, + actions: service, + eventStore: eventStore + ) + } +} + +public enum ProviderActionRuntimeError: Error, Equatable, LocalizedError, Sendable { + case configurationUnavailable + case notAuthenticated + + public var errorDescription: String? { + switch self { + case .configurationUnavailable: + return "The selected kDrive is no longer configured." + case .notAuthenticated: + return "Open potassiumProvider and reconnect this account." + } + } +} diff --git a/PotassiumProviderCore/ProviderEventStore.swift b/PotassiumProviderCore/ProviderEventStore.swift index f250df9..6557062 100644 --- a/PotassiumProviderCore/ProviderEventStore.swift +++ b/PotassiumProviderCore/ProviderEventStore.swift @@ -30,6 +30,11 @@ public enum KDriveProviderActivityKind: String, Codable, Equatable, Sendable { case authentication case driveDiscovery case domainManagement + case favorite + case duplicate + case restore + case shareLink + case versionRestore } public enum KDriveProviderActivityScope: String, Codable, Equatable, Sendable { diff --git a/PotassiumProviderCore/SQLiteSnapshotStore.swift b/PotassiumProviderCore/SQLiteSnapshotStore.swift index 26164cf..7fa8f57 100644 --- a/PotassiumProviderCore/SQLiteSnapshotStore.swift +++ b/PotassiumProviderCore/SQLiteSnapshotStore.swift @@ -14,6 +14,7 @@ public actor KDriveSnapshotSQLiteStore: KDriveSnapshotStoring, KDriveSnapshotSta let database = try Connection(databaseURL.path) try Self.configure(database) try Self.createTables(on: database) + try Self.migrateFavoriteMetadata(on: database) try Self.migrateLegacySnapshots(on: database) self.database = database } @@ -861,6 +862,7 @@ public actor KDriveSnapshotSQLiteStore: KDriveSnapshotStoring, KDriveSnapshotSta table.column(Schema.path) table.column(Schema.size) table.column(Schema.mimeType) + table.column(Schema.isFavorite) table.column(Schema.createdAt) table.column(Schema.modifiedAt) table.column(Schema.itemUpdatedAt) @@ -904,6 +906,7 @@ public actor KDriveSnapshotSQLiteStore: KDriveSnapshotStoring, KDriveSnapshotSta table.column(GenerationSchema.path) table.column(GenerationSchema.size) table.column(GenerationSchema.mimeType) + table.column(GenerationSchema.isFavorite) table.column(GenerationSchema.createdAt) table.column(GenerationSchema.modifiedAt) table.column(GenerationSchema.itemUpdatedAt) @@ -949,6 +952,28 @@ public actor KDriveSnapshotSQLiteStore: KDriveSnapshotStoring, KDriveSnapshotSta }) } + private static func migrateFavoriteMetadata(on database: Connection) throws { + if try tableHasColumn("isFavorite", table: "snapshot_items", database: database) == false { + try database.run(Schema.snapshotItems.addColumn(Schema.isFavorite)) + } + if try tableHasColumn("isFavorite", table: "snapshot_generation_items", database: database) == false { + try database.run(GenerationSchema.items.addColumn(GenerationSchema.isFavorite)) + } + } + + private static func tableHasColumn( + _ column: String, + table: String, + database: Connection + ) throws -> Bool { + for row in try database.prepare("PRAGMA table_info(\(table))") { + if row[1] as? String == column { + return true + } + } + return false + } + private static func migrateLegacySnapshots(on database: Connection) throws { try database.transaction { let legacySnapshots = Array(try database.prepare(Schema.containerSnapshots)) @@ -1052,6 +1077,7 @@ public actor KDriveSnapshotSQLiteStore: KDriveSnapshotStoring, KDriveSnapshotSta Schema.path <- item.path, Schema.size <- item.size, Schema.mimeType <- item.mimeType, + Schema.isFavorite <- item.isFavorite, Schema.createdAt <- item.createdAt?.timeIntervalSince1970, Schema.modifiedAt <- item.modifiedAt.timeIntervalSince1970, Schema.itemUpdatedAt <- item.updatedAt.timeIntervalSince1970, @@ -1079,6 +1105,7 @@ public actor KDriveSnapshotSQLiteStore: KDriveSnapshotStoring, KDriveSnapshotSta GenerationSchema.path <- item.path, GenerationSchema.size <- item.size, GenerationSchema.mimeType <- item.mimeType, + GenerationSchema.isFavorite <- item.isFavorite, GenerationSchema.createdAt <- item.createdAt?.timeIntervalSince1970, GenerationSchema.modifiedAt <- item.modifiedAt.timeIntervalSince1970, GenerationSchema.itemUpdatedAt <- item.updatedAt.timeIntervalSince1970, @@ -1096,6 +1123,7 @@ public actor KDriveSnapshotSQLiteStore: KDriveSnapshotStoring, KDriveSnapshotSta path: row[Schema.path], size: row[Schema.size], mimeType: row[Schema.mimeType], + isFavorite: row[Schema.isFavorite], createdAt: row[Schema.createdAt].map { Date(timeIntervalSince1970: $0) }, modifiedAt: Date(timeIntervalSince1970: row[Schema.modifiedAt]), updatedAt: Date(timeIntervalSince1970: row[Schema.itemUpdatedAt]) @@ -1113,6 +1141,7 @@ public actor KDriveSnapshotSQLiteStore: KDriveSnapshotStoring, KDriveSnapshotSta path: row[GenerationSchema.path], size: row[GenerationSchema.size], mimeType: row[GenerationSchema.mimeType], + isFavorite: row[GenerationSchema.isFavorite], createdAt: row[GenerationSchema.createdAt].map { Date(timeIntervalSince1970: $0) }, modifiedAt: Date(timeIntervalSince1970: row[GenerationSchema.modifiedAt]), updatedAt: Date(timeIntervalSince1970: row[GenerationSchema.itemUpdatedAt]) @@ -1235,6 +1264,7 @@ private enum Schema { static let path = Expression("path") static let size = Expression("size") static let mimeType = Expression("mimeType") + static let isFavorite = Expression("isFavorite") static let createdAt = Expression("createdAt") static let modifiedAt = Expression("modifiedAt") static let itemUpdatedAt = Expression("itemUpdatedAt") @@ -1265,6 +1295,7 @@ private enum GenerationSchema { static let path = Expression("path") static let size = Expression("size") static let mimeType = Expression("mimeType") + static let isFavorite = Expression("isFavorite") static let createdAt = Expression("createdAt") static let modifiedAt = Expression("modifiedAt") static let itemUpdatedAt = Expression("itemUpdatedAt") diff --git a/README.md b/README.md index 12e1030..538bea0 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ data. ## Documentation Index -- [0.2.1 Release Notes](CHANGELOG.md): shipped behavior, dependency state, - deferred transfer work, and remaining release gates. +- [0.3.0 Release Notes](CHANGELOG.md): actionable kDrive behavior, dependency + state, validation, and remaining manual release gates. - [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, @@ -31,6 +31,8 @@ data. 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. +- [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 enumeration, sync anchors, kDrive listing APIs, SQLite caching, and item versions fit together. @@ -53,6 +55,7 @@ The root Xcode project is the source of truth: - App target: `potassiumProvider` - File Provider extension target: `potassiumProviderFileProvider` +- File Provider UI extension target: `potassiumProviderActions` - Shared framework target: `PotassiumProviderCore` - Unit tests: `potassiumProviderTests` - UI tests: `potassiumProviderUITests` diff --git a/doc/APP_AND_DOMAINS.md b/doc/APP_AND_DOMAINS.md index 389b93f..3bb3696 100644 --- a/doc/APP_AND_DOMAINS.md +++ b/doc/APP_AND_DOMAINS.md @@ -20,6 +20,8 @@ The app handles: - logging out one account without touching other accounts - showing a Status dashboard for configured accounts, drives, cached snapshots, and sanitized provider activity +- revealing each configured drive through its File Provider user-visible URL +- requesting an immediate working-set refresh for one configured drive - showing Setup for account and drive configuration On macOS, the app runs as an accessory menu bar app: it hides its Dock icon and @@ -38,6 +40,13 @@ snapshot aggregates, and sanitized activity/conflict counts. It does not fetch remote account profile data, quotas, OAuth token details, private links, or file contents. +Each drive card has a Show in Finder button on macOS or Show in Files on iOS and +visionOS. The app asks that domain's `NSFileProviderManager` for the root +container's user-visible URL and opens it through the system. Sync Now signals +the domain's working-set enumerator, then refreshes the local Status dashboard +after File Provider accepts the request. These controls do not enumerate files +or bypass the extension. + The app does not enumerate files itself. File listing is handled by the File Provider extension after the system asks for an enumerator. Each extension callback loads the domain configuration, uses that configuration's diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index 580cdca..6b1abd0 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -1,15 +1,18 @@ # Architecture `potassiumProvider` is split into a SwiftUI setup app, a replicated File -Provider extension, and a shared framework that contains kDrive models, -networking adapters, account-scoped authentication helpers, and persistence. +Provider extension, a File Provider UI action extension, and a shared framework +that contains kDrive models, networking adapters, account-scoped authentication +helpers, action coordination, and persistence. ```mermaid flowchart LR User["User / Files app"] --> FP["potassiumProviderFileProvider"] + User --> Actions["potassiumProviderActions"] App["SwiftUI app"] --> Domain["NSFileProviderManager domains"] App --> Core["PotassiumProviderCore"] FP --> Core + Actions --> Core Core --> KC["potassiumChannel"] KC --> KDrive["Infomaniak kDrive APIs"] Core --> Keychain["Keychain tokens"] @@ -27,11 +30,16 @@ flowchart LR independently. - `potassiumProviderFileProvider`: `NSFileProviderReplicatedExtension` implementation used by the system to enumerate, fetch, create, modify, trash, - and delete items, and to provide macOS known-folder locations. + and delete items, provide macOS known-folder locations, and execute + background favorite, duplicate, and restore actions. +- `potassiumProviderActions`: `FPUIActionExtensionViewController` hosted + SwiftUI for share-link management and document version history on macOS, + iOS, and visionOS. - `PotassiumProviderCore`: shared framework with domain configuration storage, - OAuth/keychain storage, kDrive models, kDrive service adapter, snapshot diffing, - SQLite snapshot storage, unified-log categories, durable activity retention, - and redacted support-log export. + OAuth/keychain storage, kDrive models, kDrive service and contextual-action + adapters, action runtime/coordinator, snapshot diffing, SQLite snapshot + storage, unified-log categories, durable activity retention, and redacted + support-log export. - `potassiumProviderTests`: Swift Testing unit tests for shared behavior and app model flows. - `potassiumProviderUITests`: XCTest UI automation tests. @@ -60,9 +68,10 @@ configuration's `accountIdentifier` to load and refresh the correct OAuth token from keychain when needed, creates a `PotassiumKDriveService`, and opens the SQLite snapshot store. -The extension does not keep a long-lived process-level sync engine. Each File -Provider callback performs the work it was asked to do, then returns via Apple's -completion handler. +Neither extension keeps a long-lived process-level sync engine. Each File +Provider callback or contextual panel loads account-scoped runtime state, +performs the requested work, signals affected enumerators, and completes +through Apple's extension context. ## Local Reference Tree diff --git a/doc/CONTEXTUAL_ACTIONS.md b/doc/CONTEXTUAL_ACTIONS.md new file mode 100644 index 0000000..6e59b1d --- /dev/null +++ b/doc/CONTEXTUAL_ACTIONS.md @@ -0,0 +1,92 @@ +# Contextual Actions + +Version 0.3 adds actionable kDrive commands to Finder and Files while remaining +on potassiumChannel 0.2.0. Every action is single-selection. + +## Direct Provider Actions + +`potassiumProviderFileProvider` adopts `NSFileProviderCustomAction`. Its +extension plist declares: + +- Add to kDrive Favorites +- Remove from kDrive Favorites +- Duplicate on kDrive +- Restore from kDrive Trash + +`FileProviderItem.userInfo` exposes `isDirectory`, `isFavorite`, `isTrashed`, +and `isRoot` for activation predicates. Favorite actions are mutually exclusive, +duplicate is unavailable in trash, restore is available only in trash, and no +contextual action is offered for the provider root. + +`KDriveContextActionCoordinator` performs the remote sequence and returns the +affected parent IDs. Favorite mutations refetch authoritative metadata. +Duplicate uses kDrive's server-side operation and refetches the created item, +without downloading content. Restore checks whether the original parent still +exists and falls back to the drive root when it does not. The extension then +invalidates affected snapshots and signals each parent plus the working set. + +Every direct action uses `FileProviderOperationLifecycle` for cancellable, +exactly-once completion. Cancellation is checked immediately before a remote +mutation. Activity uses only sanitized favorite, duplicate, and restore +summaries. + +## Native Offline Behavior + +Completed remote items report `isUploaded`. Normal items support system +eviction; macOS additionally uses lazy content policy. Finder/Files therefore +owns Download Now and Remove Download presentation. Trash items have the trash +container as their parent, expose trash state, and allow reading and permanent +deletion without rename, move, write, or retrash capabilities. + +The provider does not set `favoriteRank`: potassiumChannel 0.2.0 exposes +favorite state but no portable favorite ordering. + +## UI Actions + +The embedded `potassiumProviderActions` target uses +`FPUIActionExtensionViewController` and hosts SwiftUI on macOS, iOS, and +visionOS. It shares the app group and keychain access group with the app and +provider extension. + +`ProviderActionRuntime` resolves the selected domain from +`extensionContext.domainIdentifier`, loads its account-scoped token, refreshes +OAuth when required, creates the typed service, and opens the shared event +store. Missing configuration or credentials returns a sanitized message that +directs the user to the containing app. + +### Share kDrive Link + +The panel supports one nontrashed file or folder. A 404 from +`getFileShareLink` means no link; other authentication, permission, and network +failures propagate. + +New links default to public read-only access, downloads enabled, file +information visible, and comments, editing, access requests, statistics, and +expiry disabled. The user can choose password access, expiry, downloads, and +comments. Existing links can be copied, sent through the system share sheet, +updated, or disabled after destructive confirmation. + +Passwords and returned URLs remain in view-model memory only. They are never +logged, persisted, placed in activity summaries, or exported in diagnostics. +Success and failure activity uses fixed summaries and numeric/domain error +diagnostics, following the provider callback redaction boundary. + +### Version History + +The panel supports one nontrashed document. It pages the nondeprecated v3 +version list, sorts results newest-first, and displays timestamp, editor, and +size. + +Restore as Copy refetches the current item, uses its current parent, and asks +kDrive to restore under a timestamped name such as +`Report (restored 2026-07-23 22.45 A1B2C3).pdf`. The short random suffix keeps +repeated restores collision-resistant. It never overwrites the current file. +After success, it signals the destination parent and working set. + +## Shared API Boundary + +`KDriveContextActionProviding` contains only action-specific methods: +favorite, duplicate, trash restore, share-link CRUD, version pagination, and +version restore. `PotassiumKDriveService` implements it exclusively with typed +PotassiumKDrive 0.2.0 service calls. Existing `KDriveFileProviding` mutation +semantics remain unchanged. diff --git a/doc/FILE_PROVIDER_LIFECYCLE.md b/doc/FILE_PROVIDER_LIFECYCLE.md index ae23c0f..edec541 100644 --- a/doc/FILE_PROVIDER_LIFECYCLE.md +++ b/doc/FILE_PROVIDER_LIFECYCLE.md @@ -132,6 +132,29 @@ Behavior: SQLite: not updated directly. Later trash/root/folder enumeration reconciles the missing item. +## Contextual Actions + +The extension plist declares four single-item background actions and the +extension adopts `NSFileProviderCustomAction`: + +- add to or remove from kDrive favorites +- duplicate on kDrive +- restore from kDrive trash + +Activation predicates use the item's `userInfo.isFavorite` and +`userInfo.isTrashed` values. Favorite changes refetch authoritative metadata. +Duplicate stays entirely server-side and refetches the resulting item. Restore +uses the original parent when it still exists and otherwise restores to the +drive root. + +All direct actions use `FileProviderOperationLifecycle`. On success they remove +the affected parent snapshots, signal those parent enumerators, and signal the +working set. Restore additionally invalidates and signals trash. Sanitized +activity uses dedicated favorite, duplicate, and restore kinds. + +The separate `potassiumProviderActions` UI extension presents Share kDrive Link +and Version History. See [Contextual Actions](CONTEXTUAL_ACTIONS.md). + ## Progress and Cancellation Every replicated-extension callback returns a parent `Progress` synchronously @@ -166,10 +189,10 @@ state. See [Listing And Versioning](LISTING_AND_VERSIONING.md). `materializedItemsDidChange` acknowledges File Provider immediately and then enumerates the system-owned materialized set in the background. The identifiers and container flags are persisted in SQLite. The active extension performs a -domain-throttled working-set poll every 60 seconds and signals only -`.workingSet` when it finds remote changes. Local mutations also invalidate the -affected cached snapshots and signal `.workingSet`, rather than signaling root, -trash, or arbitrary folder enumerators. +domain-throttled working-set poll every 60 seconds and signals `.workingSet` +when it finds remote changes. Successful local mutations and contextual actions +invalidate affected cached snapshots, signal the corresponding root, trash, or +folder enumerators, and also signal `.workingSet`. This release intentionally uses client polling only. File Provider can receive updates late when the containing app and extension are suspended. diff --git a/doc/KDRIVE_API_MAPPING.md b/doc/KDRIVE_API_MAPPING.md index 1ca017f..3e5c467 100644 --- a/doc/KDRIVE_API_MAPPING.md +++ b/doc/KDRIVE_API_MAPPING.md @@ -4,6 +4,9 @@ The app talks to kDrive through `PotassiumKDriveService`, which implements the local `KDriveFileProviding` protocol. `PotassiumKDriveService` wraps potassiumChannel's typed `KDriveService` and request builders. +Action-only operations are separated behind `KDriveContextActionProviding` so +the existing File Provider mutation protocol remains unchanged. + ## Operation Map | Provider operation | Local method | potassiumChannel call | Visible endpoint | @@ -27,6 +30,17 @@ potassiumChannel's typed `KDriveService` and request builders. | Move | `moveItem(...)` | `moveFile` | `POST /3/drive/{driveId}/files/{fileId}/move/{destinationDirectoryId}` | | Trash | `trashItem(...)` | `trashFileV2` | `DELETE /2/drive/{driveId}/files/{fileId}` | | Permanently delete trashed item | `deleteTrashedItem(...)` | `removeTrashedFile` | `DELETE /2/drive/{driveId}/trash/{fileId}` | +| Favorite/unfavorite | `setFavorite(...)` | `favoriteFile` / `unfavoriteFile` | typed kDrive favorite endpoints | +| Duplicate in place | `duplicateItem(...)` | `duplicateFile` | `POST /3/drive/{driveId}/files/{fileId}/duplicate` | +| Read trashed metadata | `trashedItem(...)` | `getTrashedFile` | typed kDrive trash metadata endpoint | +| Check restore parent | `existingFileIDs(...)` | `checkFilesExistence` | typed kDrive existence endpoint | +| Restore from trash | `restoreTrashedItem(...)` | `restoreTrashedFile` | typed kDrive trash restore endpoint | +| Read share link | `shareLink(...)` | `getFileShareLink` | `GET /2/drive/{driveId}/files/{fileId}/link` | +| Create share link | `createShareLink(...)` | `createFileShareLink` | `POST /2/drive/{driveId}/files/{fileId}/link` | +| Update share link | `updateShareLink(...)` | `updateFileShareLink` | `PUT /2/drive/{driveId}/files/{fileId}/link` | +| Disable share link | `deleteShareLink(...)` | `deleteFileShareLink` | `DELETE /2/drive/{driveId}/files/{fileId}/link` | +| List versions | `fileVersions(...)` | nondeprecated `listFileVersions` | `GET /3/drive/{driveId}/files/{fileId}/versions` | +| Restore version as copy | `restoreFileVersion(...)` | `restoreFileVersionToDirectory` | `POST /3/drive/{driveId}/files/{fileId}/versions/{versionId}/restore/{destinationDirectoryId}` | Some mutation endpoint paths are abstracted behind potassiumChannel service methods in this app. The table names the local operation and service call so the diff --git a/doc/PERSISTENCE.md b/doc/PERSISTENCE.md index c83ee0a..d4a068b 100644 --- a/doc/PERSISTENCE.md +++ b/doc/PERSISTENCE.md @@ -81,7 +81,8 @@ Listing snapshots use three active tables: - commit timestamp `snapshot_generation_items` stores the ordered item metadata for a particular -generation. Its primary key is domain, container, generation, and item ID. +generation, including nullable `isFavorite` state. Its primary key is domain, +container, generation, and item ID. The active generation and its two predecessors are retained. That keeps item and change page tokens stable while a newer snapshot commits, while deliberately @@ -92,6 +93,10 @@ Initialization transactionally moves each legacy container into generation 1 and deletes the migrated legacy rows without changing the working-set, conflict, or activity tables: +Before that migration, initialization adds nullable `isFavorite` columns to +both legacy and generation item tables when upgrading an older database. +Existing rows remain `NULL`, preserving backward compatibility. + `container_snapshots`: - `domainIdentifier` @@ -116,6 +121,7 @@ or activity tables: - `path` - `size` - `mimeType` +- nullable `isFavorite` - `createdAt` - `modifiedAt` - `itemUpdatedAt` @@ -206,6 +212,7 @@ SQLite caches metadata needed to enumerate and diff containers: - parent IDs - type/status - size and MIME type +- nullable kDrive favorite state - timestamps used for File Provider versions - advanced-listing cursor state - whether the container has been fully enumerated @@ -233,6 +240,7 @@ SQLite does not cache: - pending local operations - kDrive version history - private kDrive web URLs +- share-link passwords and returned share URLs File bytes returned from `fetchContents` are written to File Provider temporary storage and handed back to the system. @@ -254,6 +262,11 @@ The Activities support-log export is a separate redacted JSON view of this data. It pseudonymizes identifiers with a fresh per-export salt and omits filenames, paths, staged-upload paths, and raw identifiers. See [Logging](LOGGING.md). +The contextual share-link panel keeps the entered password and returned URL in +memory only. Activity rows record sanitized action summaries, never link +material or passwords. Version-history pages are also transient and are not +cached in SQLite. + ## Snapshot Replacement Saving a snapshot can be unconditional or guarded by `KDriveSnapshotSaveCondition`. diff --git a/doc/TESTING_AND_DEVELOPMENT.md b/doc/TESTING_AND_DEVELOPMENT.md index e26baef..312d31d 100644 --- a/doc/TESTING_AND_DEVELOPMENT.md +++ b/doc/TESTING_AND_DEVELOPMENT.md @@ -9,6 +9,7 @@ source of truth. - Scheme: `potassiumProvider` - App target: `potassiumProvider` - File Provider extension target: `potassiumProviderFileProvider` +- File Provider UI extension target: `potassiumProviderActions` - Shared framework target: `PotassiumProviderCore` - Unit test target: `potassiumProviderTests` - UI test target: `potassiumProviderUITests` @@ -178,7 +179,25 @@ git diff --check Also verify that links from the root `README.md` point to existing files. -## 0.2.0 Manual Release Gates +## 0.3.0 Manual Action Gates + +Use a development account without customer data. On macOS Finder, iOS Files, +and visionOS Files: + +1. Verify favorite/unfavorite, duplicate, and restore actions appear only for + valid single-item states and their results appear without relaunching. +2. Verify trashed items cannot be renamed or trashed again, can be restored, + and can still be permanently deleted. +3. Verify Download Now and Remove Download are system-provided for normal files + and folders. +4. Create public and password-protected links, update options, copy/share the + URL, and disable the link. Inspect activity export and unified logs to ensure + the URL and password never appear. +5. Page a document's version history and restore a version as a collision-safe + copy in its current parent. Confirm the current file is unchanged. +6. Exercise Show in Finder/Files and Sync Now for every configured drive. + +## 0.2.0 Transfer Gates Run these checks on macOS with a development File Provider domain and a test kDrive account. Do not use customer data. diff --git a/potassiumProvider.xcodeproj/project.pbxproj b/potassiumProvider.xcodeproj/project.pbxproj index 4b0c4d8..6dd2f6f 100644 --- a/potassiumProvider.xcodeproj/project.pbxproj +++ b/potassiumProvider.xcodeproj/project.pbxproj @@ -20,6 +20,8 @@ D00000072FF9000000000007 /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = D00001042FF9000000000004 /* SQLite */; }; D10000012FFB000000000001 /* PotassiumProviderCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D00000312FF9000000000001 /* PotassiumProviderCore.framework */; }; D10000022FFB000000000002 /* potassiumProviderFileProvider.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D10000312FFB000000000001 /* potassiumProviderFileProvider.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + E20000012FFC000000000001 /* PotassiumProviderCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D00000312FF9000000000001 /* PotassiumProviderCore.framework */; }; + E20000022FFC000000000002 /* potassiumProviderActions.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = E20000312FFC000000000001 /* potassiumProviderActions.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -65,6 +67,20 @@ remoteGlobalIDString = D00000612FF9000000000001; remoteInfo = PotassiumProviderCore; }; + E20000112FFC000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C098427E2FF847AD00CB8B7E /* Project object */; + proxyType = 1; + remoteGlobalIDString = E20000612FFC000000000001; + remoteInfo = potassiumProviderActions; + }; + E20000122FFC000000000002 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C098427E2FF847AD00CB8B7E /* Project object */; + proxyType = 1; + remoteGlobalIDString = D00000612FF9000000000001; + remoteInfo = PotassiumProviderCore; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -86,6 +102,7 @@ dstSubfolderSpec = 13; files = ( D10000022FFB000000000002 /* potassiumProviderFileProvider.appex in Embed App Extensions */, + E20000022FFC000000000002 /* potassiumProviderActions.appex in Embed App Extensions */, ); name = "Embed App Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -98,6 +115,7 @@ C098429D2FF847B000CB8B7E /* potassiumProviderUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = potassiumProviderUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D00000312FF9000000000001 /* PotassiumProviderCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = PotassiumProviderCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D10000312FFB000000000001 /* potassiumProviderFileProvider.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = potassiumProviderFileProvider.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + E20000312FFC000000000001 /* potassiumProviderActions.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = potassiumProviderActions.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -126,6 +144,11 @@ path = potassiumProviderFileProvider; sourceTree = ""; }; + E20000412FFC000000000001 /* potassiumProviderActions */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = potassiumProviderActions; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -175,6 +198,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E20000512FFC000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E20000012FFC000000000001 /* PotassiumProviderCore.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -184,6 +215,7 @@ C09842882FF847AD00CB8B7E /* potassiumProvider */, D00000412FF9000000000001 /* PotassiumProviderCore */, D10000412FFB000000000001 /* potassiumProviderFileProvider */, + E20000412FFC000000000001 /* potassiumProviderActions */, C09842962FF847B000CB8B7E /* potassiumProviderTests */, C09842A02FF847B000CB8B7E /* potassiumProviderUITests */, C09842872FF847AD00CB8B7E /* Products */, @@ -196,6 +228,7 @@ C09842862FF847AD00CB8B7E /* potassiumProvider.app */, D00000312FF9000000000001 /* PotassiumProviderCore.framework */, D10000312FFB000000000001 /* potassiumProviderFileProvider.appex */, + E20000312FFC000000000001 /* potassiumProviderActions.appex */, C09842932FF847B000CB8B7E /* potassiumProviderTests.xctest */, C098429D2FF847B000CB8B7E /* potassiumProviderUITests.xctest */, ); @@ -230,6 +263,7 @@ dependencies = ( D00000712FF9000000000001 /* PBXTargetDependency */, D10000712FFB000000000001 /* PBXTargetDependency */, + E20000712FFC000000000001 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( C09842882FF847AD00CB8B7E /* potassiumProvider */, @@ -342,6 +376,29 @@ productReference = D10000312FFB000000000001 /* potassiumProviderFileProvider.appex */; productType = "com.apple.product-type.app-extension"; }; + E20000612FFC000000000001 /* potassiumProviderActions */ = { + isa = PBXNativeTarget; + buildConfigurationList = E20000912FFC000000000001 /* Build configuration list for PBXNativeTarget "potassiumProviderActions" */; + buildPhases = ( + E20000532FFC000000000003 /* Sources */, + E20000512FFC000000000001 /* Frameworks */, + E20000522FFC000000000002 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E20000722FFC000000000002 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + E20000412FFC000000000001 /* potassiumProviderActions */, + ); + name = potassiumProviderActions; + packageProductDependencies = ( + ); + productName = potassiumProviderActions; + productReference = E20000312FFC000000000001 /* potassiumProviderActions.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -369,6 +426,9 @@ D10000612FFB000000000001 = { CreatedOnToolsVersion = 26.5; }; + E20000612FFC000000000001 = { + CreatedOnToolsVersion = 26.5; + }; }; }; buildConfigurationList = C09842812FF847AD00CB8B7E /* Build configuration list for PBXProject "potassiumProvider" */; @@ -393,6 +453,7 @@ C09842852FF847AD00CB8B7E /* potassiumProvider */, D00000612FF9000000000001 /* PotassiumProviderCore */, D10000612FFB000000000001 /* potassiumProviderFileProvider */, + E20000612FFC000000000001 /* potassiumProviderActions */, C09842922FF847B000CB8B7E /* potassiumProviderTests */, C098429C2FF847B000CB8B7E /* potassiumProviderUITests */, ); @@ -435,6 +496,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E20000522FFC000000000002 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -473,6 +541,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E20000532FFC000000000003 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -506,6 +581,16 @@ target = D00000612FF9000000000001 /* PotassiumProviderCore */; targetProxy = D10000122FFB000000000002 /* PBXContainerItemProxy */; }; + E20000712FFC000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E20000612FFC000000000001 /* potassiumProviderActions */; + targetProxy = E20000112FFC000000000001 /* PBXContainerItemProxy */; + }; + E20000722FFC000000000002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D00000612FF9000000000001 /* PotassiumProviderCore */; + targetProxy = E20000122FFC000000000002 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -633,7 +718,7 @@ CODE_SIGN_ENTITLEMENTS = Config/potassiumProvider.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 2LST6WT4P6; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -655,7 +740,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 0.2.1; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.weavee.potassiumProvider; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -681,7 +766,7 @@ CODE_SIGN_ENTITLEMENTS = Config/potassiumProvider.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 2LST6WT4P6; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -703,7 +788,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 0.2.1; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.weavee.potassiumProvider; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -727,12 +812,12 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 2LST6WT4P6; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.5; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 0.2.1; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.weavee.potassiumProviderTests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -755,12 +840,12 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 2LST6WT4P6; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.5; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 0.2.1; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.weavee.potassiumProviderTests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -782,12 +867,12 @@ buildSettings = { CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 2LST6WT4P6; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.5; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 0.2.1; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.weavee.potassiumProviderUITests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -809,12 +894,12 @@ buildSettings = { CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 2LST6WT4P6; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.5; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 0.2.1; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.weavee.potassiumProviderUITests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -838,7 +923,7 @@ BUILD_LIBRARY_FOR_DISTRIBUTION = NO; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 2LST6WT4P6; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; @@ -857,7 +942,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 0.2.1; + MARKETING_VERSION = 0.3.0; MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; PRODUCT_BUNDLE_IDENTIFIER = net.weavee.PotassiumProviderCore; @@ -887,7 +972,7 @@ BUILD_LIBRARY_FOR_DISTRIBUTION = NO; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 2LST6WT4P6; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; @@ -906,7 +991,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 0.2.1; + MARKETING_VERSION = 0.3.0; MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; PRODUCT_BUNDLE_IDENTIFIER = net.weavee.PotassiumProviderCore; @@ -936,7 +1021,7 @@ CODE_SIGN_ENTITLEMENTS = Config/potassiumProviderFileProvider.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 2LST6WT4P6; ENABLE_APP_SANDBOX = YES; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; @@ -951,20 +1036,21 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 0.2.1; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.weavee.potassiumProvider.FileProvider; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = auto; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 26.5; }; name = Debug; }; @@ -975,7 +1061,7 @@ CODE_SIGN_ENTITLEMENTS = Config/potassiumProviderFileProvider.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 2LST6WT4P6; ENABLE_APP_SANDBOX = YES; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; @@ -990,20 +1076,101 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 0.2.1; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.weavee.potassiumProvider.FileProvider; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = auto; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 26.5; + }; + name = Release; + }; + E20000812FFC000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = Config/potassiumProviderActions.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 4; + DEVELOPMENT_TEAM = 2LST6WT4P6; + ENABLE_APP_SANDBOX = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Config/potassiumProviderActionsInfo.plist; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../Frameworks", + "@executable_path/../../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 0.3.0; + PRODUCT_BUNDLE_IDENTIFIER = net.weavee.potassiumProvider.Actions; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = auto; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 26.5; + }; + name = Debug; + }; + E20000822FFC000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = Config/potassiumProviderActions.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 4; + DEVELOPMENT_TEAM = 2LST6WT4P6; + ENABLE_APP_SANDBOX = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Config/potassiumProviderActionsInfo.plist; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../Frameworks", + "@executable_path/../../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 0.3.0; + PRODUCT_BUNDLE_IDENTIFIER = net.weavee.potassiumProvider.Actions; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = auto; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 26.5; }; name = Release; }; @@ -1064,6 +1231,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + E20000912FFC000000000001 /* Build configuration list for PBXNativeTarget "potassiumProviderActions" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E20000812FFC000000000001 /* Debug */, + E20000822FFC000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/potassiumProvider.xcodeproj/xcshareddata/xcschemes/potassiumProvider.xcscheme b/potassiumProvider.xcodeproj/xcshareddata/xcschemes/potassiumProvider.xcscheme new file mode 100644 index 0000000..b253dfb --- /dev/null +++ b/potassiumProvider.xcodeproj/xcshareddata/xcschemes/potassiumProvider.xcscheme @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/potassiumProvider.xcodeproj/xcshareddata/xcschemes/potassiumProviderActions.xcscheme b/potassiumProvider.xcodeproj/xcshareddata/xcschemes/potassiumProviderActions.xcscheme new file mode 100644 index 0000000..c3b1744 --- /dev/null +++ b/potassiumProvider.xcodeproj/xcshareddata/xcschemes/potassiumProviderActions.xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/potassiumProvider.xcodeproj/xcshareddata/xcschemes/potassiumProviderFileProvider.xcscheme b/potassiumProvider.xcodeproj/xcshareddata/xcschemes/potassiumProviderFileProvider.xcscheme new file mode 100644 index 0000000..ebb6a0b --- /dev/null +++ b/potassiumProvider.xcodeproj/xcshareddata/xcschemes/potassiumProviderFileProvider.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/potassiumProvider/ConflictLogView.swift b/potassiumProvider/ConflictLogView.swift index 4951618..c707afd 100644 --- a/potassiumProvider/ConflictLogView.swift +++ b/potassiumProvider/ConflictLogView.swift @@ -692,6 +692,16 @@ private extension KDriveProviderActivityKind { return "Drive Discovery" case .domainManagement: return "Domain Management" + case .favorite: + return "Favorite" + case .duplicate: + return "Duplicate" + case .restore: + return "Restore" + case .shareLink: + return "Share Link" + case .versionRestore: + return "Version Restore" } } @@ -727,6 +737,16 @@ private extension KDriveProviderActivityKind { return "externaldrive.badge.questionmark" case .domainManagement: return "folder.badge.gearshape" + case .favorite: + return "star" + case .duplicate: + return "plus.square.on.square" + case .restore: + return "arrow.uturn.backward" + case .shareLink: + return "link" + case .versionRestore: + return "clock.arrow.trianglehead.counterclockwise.rotate.90" } } } diff --git a/potassiumProvider/FileProviderDomainRegistrar.swift b/potassiumProvider/FileProviderDomainRegistrar.swift index 941173c..bbc3d90 100644 --- a/potassiumProvider/FileProviderDomainRegistrar.swift +++ b/potassiumProvider/FileProviderDomainRegistrar.swift @@ -10,6 +10,8 @@ protocol ProviderDomainRegistering { func knownFolderSyncStates() async throws -> [String: ProviderKnownFolderSyncState] func claimKnownFolders(for configuration: ProviderDomainConfiguration, parentFileID: Int) async throws func releaseKnownFolders(for configuration: ProviderDomainConfiguration) async throws + func userVisibleRootURL(for configuration: ProviderDomainConfiguration) async throws -> URL + func signalWorkingSet(for configuration: ProviderDomainConfiguration) async throws } enum ProviderKnownFolderSyncState: Equatable, Sendable { @@ -31,6 +33,14 @@ extension ProviderDomainRegistering { func releaseKnownFolders(for configuration: ProviderDomainConfiguration) async throws { throw ProviderKnownFolderRegistrationError.unsupportedPlatform } + + func userVisibleRootURL(for configuration: ProviderDomainConfiguration) async throws -> URL { + throw ProviderKnownFolderRegistrationError.managerUnavailable(configuration.domainIdentifier) + } + + func signalWorkingSet(for configuration: ProviderDomainConfiguration) async throws { + throw ProviderKnownFolderRegistrationError.managerUnavailable(configuration.domainIdentifier) + } } @MainActor @@ -118,6 +128,24 @@ struct FileProviderDomainRegistrar: ProviderDomainRegistering { #endif } + func userVisibleRootURL(for configuration: ProviderDomainConfiguration) async throws -> URL { + let manager = try await manager(for: configuration) + return try await manager.getUserVisibleURL(for: .rootContainer) + } + + func signalWorkingSet(for configuration: ProviderDomainConfiguration) async throws { + let manager = try await manager(for: configuration) + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + manager.signalEnumerator(for: .workingSet) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + } + func makeDomain(for configuration: ProviderDomainConfiguration) -> NSFileProviderDomain { let domain = NSFileProviderDomain( identifier: NSFileProviderDomainIdentifier(rawValue: configuration.domainIdentifier), @@ -143,6 +171,7 @@ struct FileProviderDomainRegistrar: ProviderDomainRegistering { ) return locations } + #endif private func manager(for configuration: ProviderDomainConfiguration) async throws -> NSFileProviderManager { let identifier = NSFileProviderDomainIdentifier(rawValue: configuration.domainIdentifier) @@ -166,7 +195,6 @@ struct FileProviderDomainRegistrar: ProviderDomainRegistering { } } } - #endif } enum ProviderKnownFolderRegistrationError: Error, Equatable, LocalizedError, Sendable { diff --git a/potassiumProvider/PotassiumProviderAppModel.swift b/potassiumProvider/PotassiumProviderAppModel.swift index 86aca8b..ba568fd 100644 --- a/potassiumProvider/PotassiumProviderAppModel.swift +++ b/potassiumProvider/PotassiumProviderAppModel.swift @@ -17,6 +17,7 @@ final class PotassiumProviderAppModel: ObservableObject { @Published private(set) var loadingDriveAccountIdentifiers: Set = [] @Published private(set) var knownFolderSyncStatesByDomainIdentifier: [String: ProviderKnownFolderSyncState] = [:] @Published private(set) var knownFolderTransitionDomainIdentifiers: Set = [] + @Published private(set) var activeDomainActionIdentifiers: Set = [] @Published private(set) var statusMessage: String? @Published var errorMessage: String? @Published var manualAccessToken = "" @@ -119,6 +120,10 @@ final class PotassiumProviderAppModel: ObservableObject { knownFolderTransitionDomainIdentifiers.contains(configuration.domainIdentifier) } + func isPerformingDomainAction(_ domainIdentifier: String) -> Bool { + activeDomainActionIdentifiers.contains(domainIdentifier) + } + func selectedDriveID(for accountIdentifier: String) -> Int? { selectedDriveIDs[accountIdentifier] } @@ -409,6 +414,51 @@ final class PotassiumProviderAppModel: ObservableObject { #endif } + func userVisibleRootURL(for configuration: ProviderDomainConfiguration) async -> URL? { + guard beginDomainAction(for: configuration) else { return nil } + defer { activeDomainActionIdentifiers.remove(configuration.domainIdentifier) } + + do { + let url = try await domainRegistrar.userVisibleRootURL(for: configuration) + errorMessage = nil + return url + } catch { + await recordAppFailure( + kind: .domainManagement, + summary: "Could not show the File Provider domain.", + error: error, + category: .fileProvider + ) + #if os(macOS) + errorMessage = "Could not show \(configuration.displayName) in Finder: \(error.localizedDescription)" + #else + errorMessage = "Could not show \(configuration.displayName) in Files: \(error.localizedDescription)" + #endif + statusMessage = nil + return nil + } + } + + func syncNow(_ configuration: ProviderDomainConfiguration) async { + guard beginDomainAction(for: configuration) else { return } + defer { activeDomainActionIdentifiers.remove(configuration.domainIdentifier) } + + do { + try await domainRegistrar.signalWorkingSet(for: configuration) + statusMessage = "Requested a fresh sync for \(configuration.displayName)." + errorMessage = nil + } catch { + await recordAppFailure( + kind: .changeSync, + summary: "Could not request a fresh provider sync.", + error: error, + category: .fileProvider + ) + errorMessage = "Could not sync \(configuration.displayName): \(error.localizedDescription)" + statusMessage = nil + } + } + func logoutAccount(_ account: ProviderAccount) async { do { let accountDomains = domains(for: account.accountIdentifier) @@ -621,6 +671,10 @@ final class PotassiumProviderAppModel: ObservableObject { knownFolderTransitionDomainIdentifiers.insert(configuration.domainIdentifier).inserted } + private func beginDomainAction(for configuration: ProviderDomainConfiguration) -> Bool { + activeDomainActionIdentifiers.insert(configuration.domainIdentifier).inserted + } + private func refreshKnownFolderSyncStates() async throws { let systemStates = try await domainRegistrar.knownFolderSyncStates() knownFolderSyncStatesByDomainIdentifier = Dictionary(uniqueKeysWithValues: domains.map { configuration in diff --git a/potassiumProvider/ProviderStatusView.swift b/potassiumProvider/ProviderStatusView.swift index 8468a50..d5fc06d 100644 --- a/potassiumProvider/ProviderStatusView.swift +++ b/potassiumProvider/ProviderStatusView.swift @@ -6,6 +6,7 @@ struct ProviderStatusView: View { @ObservedObject var appModel: PotassiumProviderAppModel @StateObject private var statusModel: ProviderStatusViewModel @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.openURL) private var openURL let showSetup: () -> Void @@ -48,8 +49,32 @@ struct ProviderStatusView: View { ProviderStatusSectionHeader("Drives") VStack(spacing: 10) { ForEach(statusModel.dashboard.drives) { drive in - ProviderStatusDriveCard(drive: drive) + if let configuration = appModel.domains.first(where: { + $0.domainIdentifier == drive.domainIdentifier + }) { + ProviderStatusDriveCard( + drive: drive, + isPerformingAction: appModel.isPerformingDomainAction( + drive.domainIdentifier + ), + showInFiles: { + Task { + if let url = await appModel.userVisibleRootURL( + for: configuration + ) { + openURL(url) + } + } + }, + syncNow: { + Task { + await appModel.syncNow(configuration) + await statusModel.load(input: statusInput) + } + } + ) .transition(cardTransition) + } } } } @@ -617,6 +642,9 @@ private struct ProviderStatusAccountRow: View { private struct ProviderStatusDriveCard: View { let drive: ProviderStatusDrive + let isPerformingAction: Bool + let showInFiles: () -> Void + let syncNow: () -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -674,6 +702,26 @@ private struct ProviderStatusDriveCard: View { } .font(.caption) .foregroundStyle(.secondary) + + HStack { + Button(action: showInFiles) { + #if os(macOS) + Label("Show in Finder", systemImage: "folder") + #else + Label("Show in Files", systemImage: "folder") + #endif + } + .disabled(isPerformingAction) + Button(action: syncNow) { + Label("Sync Now", systemImage: "arrow.triangle.2.circlepath") + } + .disabled(isPerformingAction) + if isPerformingAction { + ProgressView() + .controlSize(.small) + } + } + .buttonStyle(.bordered) } .providerStatusCard() } diff --git a/potassiumProviderActions/ProviderActionViewController.swift b/potassiumProviderActions/ProviderActionViewController.swift new file mode 100644 index 0000000..7e62158 --- /dev/null +++ b/potassiumProviderActions/ProviderActionViewController.swift @@ -0,0 +1,91 @@ +import FileProvider +import FileProviderUI +import PotassiumProviderCore +import SwiftUI + +#if os(macOS) +import AppKit +#else +import UIKit +#endif + +@objc(ProviderActionViewController) +public final class ProviderActionViewController: FPUIActionExtensionViewController { + private var actionModel: ProviderActionViewModel? + + #if os(macOS) + public override func loadView() { + view = NSView() + } + #else + public override func loadView() { + view = UIView() + } + #endif + + public override func prepare( + forAction actionIdentifier: String, + itemIdentifiers: [NSFileProviderItemIdentifier] + ) { + guard let domainIdentifier = extensionContext.domainIdentifier?.rawValue, + itemIdentifiers.count == 1, + let itemIdentifier = itemIdentifiers.first, + let mode = ProviderActionViewModel.Mode(actionIdentifier: actionIdentifier) else { + cancel(with: "The selected kDrive action is unavailable.") + return + } + + let model = ProviderActionViewModel( + mode: mode, + domainIdentifier: domainIdentifier, + itemIdentifier: itemIdentifier + ) + actionModel = model + install( + ProviderActionRootView( + model: model, + complete: { [weak self] in self?.extensionContext.completeRequest() } + ) + ) + Task { await model.load() } + } + + private func cancel(with message: String) { + extensionContext.cancelRequest( + withError: NSError( + domain: FPUIErrorDomain, + code: Int(FPUIExtensionErrorCode.failed.rawValue), + userInfo: [NSLocalizedDescriptionKey: message] + ) + ) + } + + private func install(_ content: Content) { + #if os(macOS) + let hostingController = NSHostingController(rootView: content) + addChild(hostingController) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(hostingController.view) + NSLayoutConstraint.activate([ + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + view.widthAnchor.constraint(greaterThanOrEqualToConstant: 460), + view.heightAnchor.constraint(greaterThanOrEqualToConstant: 520), + ]) + #else + let hostingController = UIHostingController(rootView: content) + addChild(hostingController) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(hostingController.view) + NSLayoutConstraint.activate([ + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + hostingController.didMove(toParent: self) + #endif + } +} diff --git a/potassiumProviderActions/ProviderActionViewModel.swift b/potassiumProviderActions/ProviderActionViewModel.swift new file mode 100644 index 0000000..6065515 --- /dev/null +++ b/potassiumProviderActions/ProviderActionViewModel.swift @@ -0,0 +1,329 @@ +import Combine +import FileProvider +import Foundation +import PotassiumProviderCore + +#if os(macOS) +import AppKit +#else +import UIKit +#endif + +@MainActor +final class ProviderActionViewModel: ObservableObject { + enum Mode: Equatable { + case shareLink + case versionHistory + + init?(actionIdentifier: String) { + switch actionIdentifier { + case ProviderContextActionIdentifier.shareLink: + self = .shareLink + case ProviderContextActionIdentifier.versionHistory: + self = .versionHistory + default: + return nil + } + } + } + + let mode: Mode + let domainIdentifier: String + let itemIdentifier: NSFileProviderItemIdentifier + + @Published private(set) var item: KDriveRemoteItem? + @Published private(set) var shareLink: KDriveShareLinkSummary? + @Published private(set) var versions: [KDriveFileVersionSummary] = [] + @Published private(set) var hasMoreVersions = false + @Published private(set) var isLoading = true + @Published private(set) var isWorking = false + @Published var configuration = KDriveShareLinkConfiguration() + @Published var password = "" + @Published var usesExpiration = false + @Published var expirationDate = Date().addingTimeInterval(7 * 24 * 60 * 60) + @Published var message: String? + @Published var errorMessage: String? + @Published private(set) var initialLoadErrorMessage: String? + + private var runtime: ProviderActionRuntime? + private var nextVersionPage = 1 + private let versionPageSize = 50 + + init( + mode: Mode, + domainIdentifier: String, + itemIdentifier: NSFileProviderItemIdentifier + ) { + self.mode = mode + self.domainIdentifier = domainIdentifier + self.itemIdentifier = itemIdentifier + } + + func load() async { + isLoading = true + defer { isLoading = false } + do { + let runtime = try await ProviderActionRuntime.load(domainIdentifier: domainIdentifier) + let parsedIdentifier = try KDriveItemIdentifier(rawValue: itemIdentifier.rawValue) + guard let fileID = parsedIdentifier.fileID(rootFileID: runtime.configuration.rootFileID) else { + throw ProviderActionRuntimeError.configurationUnavailable + } + let item = try await runtime.remote.item( + driveID: runtime.configuration.driveID, + fileID: fileID + ) + self.runtime = runtime + self.item = item + + switch mode { + case .shareLink: + try await loadShareLink(runtime: runtime, fileID: fileID) + case .versionHistory: + try await loadNextVersionPage() + } + } catch { + errorMessage = error.localizedDescription + initialLoadErrorMessage = error.localizedDescription + await recordFailure(error) + } + } + + func saveShareLink() async { + guard let runtime, let item else { return } + let requestConfiguration = currentConfiguration + if requestConfiguration.isValid( + preservingPasswordFor: shareLink?.configuration.access + ) == false { + errorMessage = KDriveContextActionError.passwordRequired.localizedDescription + return + } + + await performWork { + let link: KDriveShareLinkSummary + let createsLink = self.shareLink == nil + if createsLink { + link = try await runtime.actions.createShareLink( + driveID: runtime.configuration.driveID, + fileID: item.id, + configuration: requestConfiguration + ) + } else { + link = try await runtime.actions.updateShareLink( + driveID: runtime.configuration.driveID, + fileID: item.id, + configuration: requestConfiguration + ) + } + self.shareLink = link + self.apply(link.configuration) + self.password = "" + self.message = createsLink ? "Created share link." : "Saved share-link settings." + await self.record( + kind: .shareLink, + summary: "Updated kDrive share-link settings.", + item: item + ) + await self.signalParentAndWorkingSet(runtime: runtime, parentID: item.parentID) + } + } + + func deleteShareLink() async { + guard let runtime, let item else { return } + await performWork { + try await runtime.actions.deleteShareLink( + driveID: runtime.configuration.driveID, + fileID: item.id + ) + self.shareLink = nil + self.configuration = KDriveShareLinkConfiguration() + self.password = "" + self.usesExpiration = false + self.message = "Disabled share link." + await self.record( + kind: .shareLink, + summary: "Disabled kDrive share link.", + item: item + ) + await self.signalParentAndWorkingSet(runtime: runtime, parentID: item.parentID) + } + } + + func loadNextVersionPage() async throws { + guard let runtime, let item, hasMoreVersions || nextVersionPage == 1 else { return } + let page = try await runtime.actions.fileVersions( + driveID: runtime.configuration.driveID, + fileID: item.id, + page: nextVersionPage, + pageSize: versionPageSize + ) + let knownIDs = Set(versions.map(\.id)) + versions.append(contentsOf: page.versions.filter { knownIDs.contains($0.id) == false }) + versions.sort { $0.createdAt > $1.createdAt } + hasMoreVersions = page.hasMore + nextVersionPage = page.page + 1 + } + + func requestNextVersionPage() async { + await performWork { + try await self.loadNextVersionPage() + } + } + + func restore(_ version: KDriveFileVersionSummary) async { + guard let runtime, let currentItem = item else { return } + await performWork { + let latestItem = try await runtime.remote.item( + driveID: runtime.configuration.driveID, + fileID: currentItem.id + ) + let restoredItem = try await runtime.actions.restoreFileVersion( + driveID: runtime.configuration.driveID, + fileID: latestItem.id, + versionID: version.id, + destinationParentID: latestItem.parentID, + name: KDriveRestoredCopyNaming.filename( + originalName: latestItem.name, + restoredAt: Date() + ) + ) + self.message = "Restored \(restoredItem.name) as a new copy." + await self.record( + kind: .versionRestore, + summary: "Restored a previous file version as a new copy.", + item: restoredItem + ) + await self.signalParentAndWorkingSet(runtime: runtime, parentID: restoredItem.parentID) + } + } + + func copyShareLink() { + guard let url = shareLink?.url else { return } + #if os(macOS) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(url.absoluteString, forType: .string) + #else + UIPasteboard.general.url = url + #endif + message = "Copied share link." + } + + private var currentConfiguration: KDriveShareLinkConfiguration { + var value = configuration + value.password = value.access == .password ? password.nilIfEmpty : nil + value.validUntil = usesExpiration ? expirationDate : nil + return value + } + + private func loadShareLink(runtime: ProviderActionRuntime, fileID: Int) async throws { + let link = try await runtime.actions.shareLink( + driveID: runtime.configuration.driveID, + fileID: fileID + ) + shareLink = link + if let link { + apply(link.configuration) + } + } + + private func apply(_ value: KDriveShareLinkConfiguration) { + configuration = value + usesExpiration = value.validUntil != nil + if let validUntil = value.validUntil { + expirationDate = validUntil + } + } + + private func performWork(_ operation: @escaping () async throws -> Void) async { + guard isWorking == false else { return } + isWorking = true + errorMessage = nil + message = nil + defer { isWorking = false } + do { + try await operation() + } catch { + errorMessage = error.localizedDescription + await recordFailure(error) + } + } + + private func signalParentAndWorkingSet( + runtime: ProviderActionRuntime, + parentID: Int + ) async { + let domain = NSFileProviderDomain( + identifier: NSFileProviderDomainIdentifier(rawValue: runtime.configuration.domainIdentifier), + displayName: runtime.configuration.displayName + ) + guard let manager = NSFileProviderManager(for: domain) else { return } + let parentIdentifier: NSFileProviderItemIdentifier = parentID == runtime.configuration.rootFileID + ? .rootContainer + : NSFileProviderItemIdentifier(KDriveItemIdentifier.item(parentID).rawValue) + for identifier in [parentIdentifier, .workingSet] { + await withCheckedContinuation { continuation in + manager.signalEnumerator(for: identifier) { _ in continuation.resume() } + } + } + } + + private func record( + kind: KDriveProviderActivityKind, + summary: String, + item: KDriveRemoteItem + ) async { + try? await runtime?.eventStore?.recordActivity(KDriveProviderActivityEvent( + domainIdentifier: domainIdentifier, + driveID: item.driveID, + kind: kind, + itemIdentifier: KDriveItemIdentifier.item(item.id).rawValue, + itemName: item.name, + itemPath: item.path, + summary: summary + )) + } + + private func recordFailure(_ error: Error) async { + guard let runtime, let eventStore = runtime.eventStore else { return } + let nsError = error as NSError + let apiRejection = KDriveRemoteErrorClassifier.apiRejection(from: error) + let category: KDriveProviderActivityErrorCategory + if error is ProviderActionRuntimeError || error is KDriveOAuthError { + category = .authentication + } else if nsError.domain == NSURLErrorDomain { + category = .network + } else if apiRejection != nil { + category = .api + } else { + category = .unknown + } + let kind: KDriveProviderActivityKind = mode == .shareLink ? .shareLink : .versionRestore + let summary = mode == .shareLink + ? "Could not complete a kDrive share-link action." + : "Could not complete a kDrive version-history action." + try? await eventStore.recordActivity(KDriveProviderActivityEvent( + domainIdentifier: domainIdentifier, + driveID: runtime.configuration.driveID, + kind: kind, + outcome: .failure, + severity: .error, + itemIdentifier: item.map { KDriveItemIdentifier.item($0.id).rawValue }, + itemName: item?.name, + itemPath: item?.path, + summary: summary, + diagnostic: KDriveProviderActivityErrorDiagnostic( + errorCategory: category, + underlyingErrorDomain: nsError.domain, + underlyingErrorCode: nsError.code, + diagnosticSummary: apiRejection?.diagnosticSummary + ?? "The contextual kDrive action failed." + ) + )) + } +} + +private extension String { + var nilIfEmpty: String? { + let value = trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/potassiumProviderActions/ProviderActionViews.swift b/potassiumProviderActions/ProviderActionViews.swift new file mode 100644 index 0000000..76843d2 --- /dev/null +++ b/potassiumProviderActions/ProviderActionViews.swift @@ -0,0 +1,244 @@ +import PotassiumProviderCore +import SwiftUI + +struct ProviderActionRootView: View { + @ObservedObject var model: ProviderActionViewModel + let complete: () -> Void + + var body: some View { + NavigationStack { + Group { + if model.isLoading { + ProgressView("Loading kDrive…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = model.initialLoadErrorMessage { + ContentUnavailableView( + "Action Unavailable", + systemImage: "exclamationmark.triangle", + description: Text(error) + ) + } else if let item = model.item { + switch model.mode { + case .shareLink: + ShareLinkActionView(model: model, item: item) + case .versionHistory: + VersionHistoryActionView(model: model, item: item) + } + } else { + ContentUnavailableView( + "Action Unavailable", + systemImage: "exclamationmark.triangle", + description: Text("The selected item could not be loaded.") + ) + } + } + .navigationTitle(navigationTitle) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done", action: complete) + .disabled(model.isLoading || model.isWorking) + } + } + } + .frame(minWidth: 360, minHeight: 440) + } + + private var navigationTitle: String { + switch model.mode { + case .shareLink: + return "Share kDrive Link" + case .versionHistory: + return "Version History" + } + } +} + +private struct ShareLinkActionView: View { + @ObservedObject var model: ProviderActionViewModel + let item: KDriveRemoteItem + @State private var confirmsDeletion = false + + var body: some View { + Form { + Section { + Label(item.name, systemImage: item.isDirectory ? "folder" : "doc") + .lineLimit(2) + } + + if let link = model.shareLink { + Section("Link") { + Text(link.url.absoluteString) + .font(.caption.monospaced()) + .textSelection(.enabled) + .lineLimit(3) + HStack { + Button("Copy Link") { + model.copyShareLink() + } + ShareLink(item: link.url) { + Label("Share…", systemImage: "square.and.arrow.up") + } + } + } + } + + Section("Access") { + Picker("Access", selection: $model.configuration.access) { + Text("Public").tag(KDriveShareLinkConfiguration.Access.public) + Text("Password Protected").tag(KDriveShareLinkConfiguration.Access.password) + } + if model.configuration.access == .password { + SecureField( + model.shareLink == nil ? "Password" : "New password (leave blank to keep current)", + text: $model.password + ) + } + Toggle("Allow downloads", isOn: $model.configuration.allowsDownload) + Toggle("Allow comments", isOn: $model.configuration.allowsComments) + Toggle("Expiration date", isOn: $model.usesExpiration) + if model.usesExpiration { + DatePicker( + "Valid until", + selection: $model.expirationDate, + in: Date()..., + displayedComponents: [.date, .hourAndMinute] + ) + } + } + + statusSection + + Section { + Button(model.shareLink == nil ? "Create Link" : "Save Changes") { + Task { await model.saveShareLink() } + } + .disabled(model.isWorking) + + if model.shareLink != nil { + Button("Disable Link", role: .destructive) { + confirmsDeletion = true + } + .disabled(model.isWorking) + } + } + } + .confirmationDialog( + "Disable this share link?", + isPresented: $confirmsDeletion, + titleVisibility: .visible + ) { + Button("Disable Link", role: .destructive) { + Task { await model.deleteShareLink() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Anyone using the current URL will lose access.") + } + } + + @ViewBuilder + private var statusSection: some View { + if model.isWorking { + Section { + ProgressView() + } + } else if let error = model.errorMessage { + Section { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + } + } else if let message = model.message { + Section { + Label(message, systemImage: "checkmark.circle") + .foregroundStyle(.secondary) + } + } + } +} + +private struct VersionHistoryActionView: View { + @ObservedObject var model: ProviderActionViewModel + let item: KDriveRemoteItem + @State private var pendingRestore: KDriveFileVersionSummary? + + var body: some View { + List { + Section { + Label(item.name, systemImage: "doc") + .lineLimit(2) + } + + if model.versions.isEmpty { + ContentUnavailableView( + "No Previous Versions", + systemImage: "clock", + description: Text("kDrive did not return any stored versions for this file.") + ) + } else { + Section("Versions") { + ForEach(model.versions) { version in + VersionRow(version: version) { + pendingRestore = version + } + } + if model.hasMoreVersions { + Button("Load More") { + Task { await model.requestNextVersionPage() } + } + .disabled(model.isWorking) + } + } + } + + if model.isWorking { + ProgressView() + } else if let error = model.errorMessage { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + } else if let message = model.message { + Label(message, systemImage: "checkmark.circle") + .foregroundStyle(.secondary) + } + } + .confirmationDialog( + "Restore this version as a new copy?", + isPresented: Binding( + get: { pendingRestore != nil }, + set: { if $0 == false { pendingRestore = nil } } + ), + titleVisibility: .visible + ) { + if let version = pendingRestore { + Button("Restore as Copy") { + pendingRestore = nil + Task { await model.restore(version) } + } + } + Button("Cancel", role: .cancel) { + pendingRestore = nil + } + } message: { + Text("The current file will not be overwritten.") + } + } +} + +private struct VersionRow: View { + let version: KDriveFileVersionSummary + let restore: () -> Void + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(version.createdAt, format: .dateTime.year().month().day().hour().minute()) + .font(.headline) + Text("\(version.editorDisplayName) · \(ByteCountFormatter.string(fromByteCount: Int64(version.size), countStyle: .file))") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button("Restore", action: restore) + .buttonStyle(.borderless) + } + } +} diff --git a/potassiumProviderFileProvider/FileProviderEnumerator.swift b/potassiumProviderFileProvider/FileProviderEnumerator.swift index 90a0228..28012b7 100644 --- a/potassiumProviderFileProvider/FileProviderEnumerator.swift +++ b/potassiumProviderFileProvider/FileProviderEnumerator.swift @@ -27,7 +27,14 @@ final class FileProviderEnumerator: NSObject, NSFileProviderEnumerator { let loadedRuntime = try await FileProviderRuntime.load(domain: self.domain) runtime = loadedRuntime let itemPage = try await self.listItems(runtime: loadedRuntime, startingAt: page) - observer.didEnumerate(itemPage.items.map { FileProviderItem(remoteItem: $0, rootFileID: loadedRuntime.configuration.rootFileID) }) + let enumeratesTrash = self.containerItemIdentifier == .trashContainer + observer.didEnumerate(itemPage.items.map { + FileProviderItem( + remoteItem: $0, + rootFileID: loadedRuntime.configuration.rootFileID, + isTrashed: enumeratesTrash + ) + }) FileProviderLog.enumeration.info("enumerateItems success container(\(self.containerItemIdentifier.rawValue, privacy: .public)) count(\(itemPage.items.count, privacy: .public)) nextCursorPresent(\(itemPage.nextCursor != nil, privacy: .public)) driveID(\(loadedRuntime.configuration.driveID, privacy: .public))") await ProviderEventRecorder.recordActivity( kind: .enumeration, @@ -684,7 +691,10 @@ final class FileProviderEnumerator: NSObject, NSFileProviderEnumerator { private func emit(_ changes: KDriveSnapshotChangeSet, to observer: NSFileProviderChangeObserver, rootFileID: Int) { if changes.updatedItems.isEmpty == false { - observer.didUpdate(changes.updatedItems.map { FileProviderItem(remoteItem: $0, rootFileID: rootFileID) }) + let enumeratesTrash = containerItemIdentifier == .trashContainer + observer.didUpdate(changes.updatedItems.map { + FileProviderItem(remoteItem: $0, rootFileID: rootFileID, isTrashed: enumeratesTrash) + }) } if changes.deletedItemIDs.isEmpty == false { diff --git a/potassiumProviderFileProvider/FileProviderItem.swift b/potassiumProviderFileProvider/FileProviderItem.swift index 0a98d2a..9313d8d 100644 --- a/potassiumProviderFileProvider/FileProviderItem.swift +++ b/potassiumProviderFileProvider/FileProviderItem.swift @@ -3,6 +3,13 @@ import Foundation import PotassiumProviderCore import UniformTypeIdentifiers +enum FileProviderItemUserInfoKey { + static let isDirectory = "isDirectory" + static let isFavorite = "isFavorite" + static let isTrashed = "isTrashed" + static let isRoot = "isRoot" +} + final class FileProviderItem: NSObject, NSFileProviderItemProtocol { let itemIdentifier: NSFileProviderItemIdentifier let parentItemIdentifier: NSFileProviderItemIdentifier @@ -13,6 +20,14 @@ final class FileProviderItem: NSObject, NSFileProviderItemProtocol { let creationDate: Date? let contentModificationDate: Date? let capabilities: NSFileProviderItemCapabilities + #if !os(macOS) + let isTrashed: Bool + #endif + let isUploaded: Bool + #if os(macOS) + let contentPolicy: NSFileProviderContentPolicy + #endif + let userInfo: [AnyHashable: Any]? init(configuration: ProviderDomainConfiguration) { self.itemIdentifier = .rootContainer @@ -27,14 +42,33 @@ final class FileProviderItem: NSObject, NSFileProviderItemProtocol { self.creationDate = configuration.createdAt self.contentModificationDate = configuration.updatedAt self.capabilities = [.allowsContentEnumerating, .allowsAddingSubItems, .allowsReading] + #if !os(macOS) + self.isTrashed = false + #endif + self.isUploaded = true + #if os(macOS) + self.contentPolicy = .downloadLazily + #endif + self.userInfo = [ + FileProviderItemUserInfoKey.isDirectory: true, + FileProviderItemUserInfoKey.isFavorite: false, + FileProviderItemUserInfoKey.isTrashed: false, + FileProviderItemUserInfoKey.isRoot: true, + ] super.init() } - init(remoteItem: KDriveRemoteItem, rootFileID: Int = ProviderConstants.defaultRootFileID) { + init( + remoteItem: KDriveRemoteItem, + rootFileID: Int = ProviderConstants.defaultRootFileID, + isTrashed: Bool = false + ) { self.itemIdentifier = NSFileProviderItemIdentifier(KDriveItemIdentifier.item(remoteItem.id).rawValue) - self.parentItemIdentifier = remoteItem.parentID == rootFileID - ? .rootContainer - : NSFileProviderItemIdentifier(KDriveItemIdentifier.item(remoteItem.parentID).rawValue) + self.parentItemIdentifier = isTrashed + ? .trashContainer + : remoteItem.parentID == rootFileID + ? .rootContainer + : NSFileProviderItemIdentifier(KDriveItemIdentifier.item(remoteItem.parentID).rawValue) self.filename = remoteItem.name self.contentType = remoteItem.contentType self.itemVersion = NSFileProviderItemVersion( @@ -44,26 +78,52 @@ final class FileProviderItem: NSObject, NSFileProviderItemProtocol { self.documentSize = remoteItem.size.map(NSNumber.init(value:)) self.creationDate = remoteItem.createdAt self.contentModificationDate = remoteItem.modifiedAt + #if !os(macOS) + self.isTrashed = isTrashed + #endif + self.isUploaded = true + #if os(macOS) + self.contentPolicy = .downloadLazily + #endif + var userInfo: [AnyHashable: Any] = [ + FileProviderItemUserInfoKey.isDirectory: remoteItem.isDirectory, + FileProviderItemUserInfoKey.isTrashed: isTrashed, + FileProviderItemUserInfoKey.isRoot: false, + ] + if let isFavorite = remoteItem.isFavorite { + userInfo[FileProviderItemUserInfoKey.isFavorite] = isFavorite + } + self.userInfo = userInfo - if remoteItem.isDirectory { - self.capabilities = [ + if isTrashed { + self.capabilities = [.allowsReading, .allowsDeleting] + } else if remoteItem.isDirectory { + var capabilities: NSFileProviderItemCapabilities = [ .allowsContentEnumerating, .allowsAddingSubItems, .allowsReading, .allowsRenaming, .allowsReparenting, .allowsTrashing, - .allowsDeleting + .allowsDeleting, ] + #if !os(macOS) + capabilities.insert(.allowsEvicting) + #endif + self.capabilities = capabilities } else { - self.capabilities = [ + var capabilities: NSFileProviderItemCapabilities = [ .allowsReading, .allowsWriting, .allowsRenaming, .allowsReparenting, .allowsTrashing, - .allowsDeleting + .allowsDeleting, ] + #if !os(macOS) + capabilities.insert(.allowsEvicting) + #endif + self.capabilities = capabilities } super.init() diff --git a/potassiumProviderFileProvider/FileProviderRuntime.swift b/potassiumProviderFileProvider/FileProviderRuntime.swift index d0c1d33..d507f4d 100644 --- a/potassiumProviderFileProvider/FileProviderRuntime.swift +++ b/potassiumProviderFileProvider/FileProviderRuntime.swift @@ -13,6 +13,7 @@ struct FileProviderRuntime: Sendable { let configuration: ProviderDomainConfiguration let token: KDriveOAuthToken let remote: any KDriveFileProviding + let actions: any KDriveContextActionProviding let workingSetRemote: any KDriveWorkingSetRemoteProviding let snapshotStore: any KDriveSnapshotStoring let workingSetStateStore: any KDriveWorkingSetStateStoring @@ -22,6 +23,7 @@ struct FileProviderRuntime: Sendable { configuration: ProviderDomainConfiguration, token: KDriveOAuthToken, remote: any KDriveFileProviding, + actions: any KDriveContextActionProviding, workingSetRemote: any KDriveWorkingSetRemoteProviding, snapshotStore: any KDriveSnapshotStoring, workingSetStateStore: any KDriveWorkingSetStateStoring, @@ -30,6 +32,7 @@ struct FileProviderRuntime: Sendable { self.configuration = configuration self.token = token self.remote = remote + self.actions = actions self.workingSetRemote = workingSetRemote self.snapshotStore = snapshotStore self.workingSetStateStore = workingSetStateStore @@ -63,6 +66,7 @@ struct FileProviderRuntime: Sendable { configuration: configuration, token: token, remote: remote, + actions: remote, workingSetRemote: remote, snapshotStore: sqliteStore, workingSetStateStore: sqliteStore, diff --git a/potassiumProviderFileProvider/PotassiumFileProviderExtension+Actions.swift b/potassiumProviderFileProvider/PotassiumFileProviderExtension+Actions.swift new file mode 100644 index 0000000..3d90317 --- /dev/null +++ b/potassiumProviderFileProvider/PotassiumFileProviderExtension+Actions.swift @@ -0,0 +1,111 @@ +import FileProvider +import Foundation +import PotassiumProviderCore + +extension PotassiumFileProviderExtension: NSFileProviderCustomAction { + public func performAction( + identifier actionIdentifier: NSFileProviderExtensionActionIdentifier, + onItemsWithIdentifiers itemIdentifiers: [NSFileProviderItemIdentifier], + completionHandler: @escaping (Error?) -> Void + ) -> Progress { + let progress = Progress(totalUnitCount: 1) + let lifecycle = FileProviderOperationLifecycle(progress: progress) { + completionHandler(NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError)) + } + + lifecycle.start { lifecycle in + var runtime: FileProviderRuntime? + let activityKind = Self.activityKind(for: actionIdentifier) + let selectedIdentifier = itemIdentifiers.first + + do { + guard itemIdentifiers.count == 1, let selectedIdentifier else { + throw NSFileProviderError(.cannotSynchronize) + } + let loadedRuntime = try await FileProviderRuntime.load(domain: self.domain) + runtime = loadedRuntime + let parsedIdentifier = try KDriveItemIdentifier(rawValue: selectedIdentifier.rawValue) + guard let fileID = parsedIdentifier.fileID( + rootFileID: loadedRuntime.configuration.rootFileID + ) else { + throw NSFileProviderError(.noSuchItem) + } + guard let action = ProviderDirectContextAction(rawValue: actionIdentifier.rawValue) else { + throw NSFileProviderError(.noSuchItem) + } + + let execution = try await KDriveContextActionCoordinator( + driveID: loadedRuntime.configuration.driveID, + rootFileID: loadedRuntime.configuration.rootFileID, + remote: loadedRuntime.remote, + actions: loadedRuntime.actions + ).perform(action, fileID: fileID) + + let recordedIdentifier = action == .duplicate + ? ProviderEventRecorder.itemIdentifier(for: execution.activityItem) + : selectedIdentifier.rawValue + await ProviderEventRecorder.recordActivity( + kind: Self.activityKind(for: action), + runtime: loadedRuntime, + itemIdentifier: recordedIdentifier, + itemName: execution.activityItem.name, + itemPath: action == .restoreFromTrash ? nil : execution.activityItem.path, + summary: execution.summary + ) + + var containers = self.containerIdentifiers( + forFileIDs: execution.affectedParentIDs.map(Optional.some), + rootFileID: loadedRuntime.configuration.rootFileID + ) + if execution.invalidatesTrash { + containers.append(.trashContainer) + } + await self.invalidateCachedSnapshotsAndSignal( + runtime: loadedRuntime, + containerIdentifiers: containers + ) + await lifecycle.finish(markProgressComplete: true) { + completionHandler(nil) + } + } catch is CancellationError { + await lifecycle.cancel() + } catch { + let mappedError = await self.recordProviderFailure( + error, + runtime: runtime, + fallbackKind: activityKind, + itemIdentifier: selectedIdentifier?.rawValue, + itemName: nil, + itemPath: nil, + summary: "perform contextual action." + ) + await lifecycle.finish(markProgressComplete: false) { + completionHandler(mappedError) + } + } + } + return progress + } + + private static func activityKind( + for actionIdentifier: NSFileProviderExtensionActionIdentifier + ) -> KDriveProviderActivityKind { + guard let action = ProviderDirectContextAction(rawValue: actionIdentifier.rawValue) else { + return .modify + } + return activityKind(for: action) + } + + private static func activityKind( + for action: ProviderDirectContextAction + ) -> KDriveProviderActivityKind { + switch action { + case .addFavorite, .removeFavorite: + return .favorite + case .duplicate: + return .duplicate + case .restoreFromTrash: + return .restore + } + } +} diff --git a/potassiumProviderFileProvider/PotassiumFileProviderExtension.swift b/potassiumProviderFileProvider/PotassiumFileProviderExtension.swift index cff7fa4..45f7278 100644 --- a/potassiumProviderFileProvider/PotassiumFileProviderExtension.swift +++ b/potassiumProviderFileProvider/PotassiumFileProviderExtension.swift @@ -12,8 +12,8 @@ public final class PotassiumFileProviderExtension: NSObject, NSFileProviderRepli static let maximumConcurrentContentFetches = 4 private static let contentTransferLimiter = AsyncOperationLimiter(maxConcurrentOperations: 1) - private let domain: NSFileProviderDomain - private let manager: NSFileProviderManager + let domain: NSFileProviderDomain + let manager: NSFileProviderManager private let temporaryDirectoryURL: URL private var remotePollingTask: Task? @@ -714,20 +714,20 @@ public final class PotassiumFileProviderExtension: NSObject, NSFileProviderRepli ) } - private func containerIdentifiers(forFileIDs fileIDs: [Int?], rootFileID: Int) -> [NSFileProviderItemIdentifier] { + func containerIdentifiers(forFileIDs fileIDs: [Int?], rootFileID: Int) -> [NSFileProviderItemIdentifier] { fileIDs.compactMap { fileID in fileID.map { self.containerIdentifier(forFileID: $0, rootFileID: rootFileID) } } } - private func containerIdentifier(forFileID fileID: Int, rootFileID: Int) -> NSFileProviderItemIdentifier { + func containerIdentifier(forFileID fileID: Int, rootFileID: Int) -> NSFileProviderItemIdentifier { if fileID == rootFileID { return .rootContainer } return NSFileProviderItemIdentifier(KDriveItemIdentifier.item(fileID).rawValue) } - private func invalidateCachedSnapshotsAndSignal( + func invalidateCachedSnapshotsAndSignal( runtime: FileProviderRuntime, containerIdentifiers: [NSFileProviderItemIdentifier] ) async { @@ -756,6 +756,9 @@ public final class PotassiumFileProviderExtension: NSObject, NSFileProviderRepli } } + for containerIdentifier in uniqueIdentifiers where containerIdentifier != .workingSet { + await signalEnumerator(for: containerIdentifier, runtime: runtime) + } await signalWorkingSet(runtime: runtime) } diff --git a/potassiumProviderTests/KDriveContextActionTests.swift b/potassiumProviderTests/KDriveContextActionTests.swift new file mode 100644 index 0000000..7651d2d --- /dev/null +++ b/potassiumProviderTests/KDriveContextActionTests.swift @@ -0,0 +1,417 @@ +import Foundation +import PotassiumProviderCore +import Testing + +@Suite("kDrive contextual actions") +struct KDriveContextActionTests { + @Test func directActionRoutingAndActivationMatchItemState() throws { + #expect(ProviderDirectContextAction( + rawValue: ProviderContextActionIdentifier.addFavorite + ) == .addFavorite) + #expect(ProviderDirectContextAction( + rawValue: ProviderContextActionIdentifier.removeFavorite + ) == .removeFavorite) + #expect(ProviderDirectContextAction( + rawValue: ProviderContextActionIdentifier.duplicate + ) == .duplicate) + #expect(ProviderDirectContextAction( + rawValue: ProviderContextActionIdentifier.restoreFromTrash + ) == .restoreFromTrash) + + let ordinary = ProviderContextItemState( + isDirectory: false, + isFavorite: false, + isTrashed: false + ) + #expect(ProviderDirectContextAction.addFavorite.isAvailable(for: ordinary)) + #expect(ProviderDirectContextAction.duplicate.isAvailable(for: ordinary)) + #expect(ProviderDirectContextAction.removeFavorite.isAvailable(for: ordinary) == false) + #expect(ProviderDirectContextAction.restoreFromTrash.isAvailable(for: ordinary) == false) + + let favorite = ProviderContextItemState( + isDirectory: true, + isFavorite: true, + isTrashed: false + ) + #expect(ProviderDirectContextAction.removeFavorite.isAvailable(for: favorite)) + #expect(ProviderDirectContextAction.addFavorite.isAvailable(for: favorite) == false) + + let trashed = ProviderContextItemState( + isDirectory: false, + isFavorite: true, + isTrashed: true + ) + #expect(ProviderDirectContextAction.restoreFromTrash.isAvailable(for: trashed)) + #expect(ProviderDirectContextAction.addFavorite.isAvailable(for: trashed) == false) + #expect(ProviderDirectContextAction.removeFavorite.isAvailable(for: trashed) == false) + #expect(ProviderDirectContextAction.duplicate.isAvailable(for: trashed) == false) + + let root = ProviderContextItemState( + isDirectory: true, + isFavorite: false, + isTrashed: false, + isRoot: true + ) + for action in ProviderDirectContextAction.allCases { + #expect(action.isAvailable(for: root) == false) + } + } + + @Test func favoriteRefetchesMetadataAndInvalidatesBothParents() async throws { + let current = item(id: 42, parentID: 10, isFavorite: false) + let updated = item(id: 42, parentID: 11, isFavorite: true) + let remote = ContextActionRemoteMock( + metadataResponses: [42: [current, updated]] + ) + let coordinator = KDriveContextActionCoordinator( + driveID: 7, + rootFileID: 1, + remote: remote, + actions: remote + ) + + let execution = try await coordinator.perform(.addFavorite, fileID: 42) + + #expect(execution.activityItem == updated) + #expect(execution.affectedParentIDs == [10, 11]) + #expect(execution.invalidatesTrash == false) + #expect(await remote.calls() == [ + .item(fileID: 42), + .setFavorite(fileID: 42, isFavorite: true), + .item(fileID: 42), + ]) + } + + @Test func duplicateRefetchesAuthoritativeResultAndInvalidatesItsParent() async throws { + let response = item(id: 99, parentID: 10, name: "Copy (pending)") + let authoritative = item(id: 99, parentID: 12, name: "Copy") + let remote = ContextActionRemoteMock( + metadataResponses: [99: [authoritative]], + duplicateResult: response + ) + let coordinator = KDriveContextActionCoordinator( + driveID: 7, + rootFileID: 1, + remote: remote, + actions: remote + ) + + let execution = try await coordinator.perform(.duplicate, fileID: 42) + + #expect(execution.activityItem == authoritative) + #expect(execution.affectedParentIDs == [12]) + #expect(await remote.calls() == [ + .duplicate(fileID: 42), + .item(fileID: 99), + ]) + } + + @Test func restoreUsesOriginalParentWhenItStillExists() async throws { + let trashed = item(id: 42, parentID: 25) + let remote = ContextActionRemoteMock( + trashedResult: trashed, + existingIDs: [25] + ) + let coordinator = KDriveContextActionCoordinator( + driveID: 7, + rootFileID: 1, + remote: remote, + actions: remote + ) + + let execution = try await coordinator.perform(.restoreFromTrash, fileID: 42) + + #expect(execution.affectedParentIDs == [25]) + #expect(execution.invalidatesTrash) + #expect(await remote.calls() == [ + .trashedItem(fileID: 42), + .existingFileIDs([25]), + .restore(fileID: 42, destinationParentID: 25), + ]) + } + + @Test func restoreFallsBackToRootWithoutCorruptingStateOnFailure() async throws { + let trashed = item(id: 42, parentID: 25) + let remote = ContextActionRemoteMock( + trashedResult: trashed, + existingIDs: [], + restoreError: ContextActionMockError.remoteFailure + ) + let coordinator = KDriveContextActionCoordinator( + driveID: 7, + rootFileID: 1, + remote: remote, + actions: remote + ) + + await #expect(throws: ContextActionMockError.remoteFailure) { + _ = try await coordinator.perform(.restoreFromTrash, fileID: 42) + } + #expect(await remote.calls() == [ + .trashedItem(fileID: 42), + .existingFileIDs([25]), + .restore(fileID: 42, destinationParentID: 1), + ]) + } + + @Test func cancellationBeforeRoutingNeverStartsRemoteMutation() async { + let remote = ContextActionRemoteMock( + duplicateResult: item(id: 99, parentID: 1) + ) + let coordinator = KDriveContextActionCoordinator( + driveID: 7, + rootFileID: 1, + remote: remote, + actions: remote + ) + let task = Task { + try await Task.sleep(for: .seconds(30)) + return try await coordinator.perform(.duplicate, fileID: 42) + } + + task.cancel() + await #expect(throws: CancellationError.self) { + _ = try await task.value + } + #expect(await remote.calls().isEmpty) + } + + @Test func shareLinkDefaultsAreSecureAndPasswordsAreValidated() { + let defaults = KDriveShareLinkConfiguration() + #expect(defaults.access == .public) + #expect(defaults.allowsDownload) + #expect(defaults.allowsComments == false) + #expect(defaults.allowsEditing == false) + #expect(defaults.allowsAccessRequests == false) + #expect(defaults.showsFileInformation) + #expect(defaults.showsStatistics == false) + #expect(defaults.validUntil == nil) + #expect(defaults.isValid) + + var protected = defaults + protected.access = .password + #expect(protected.isValid == false) + protected.password = " " + #expect(protected.isValid == false) + protected.password = "memory-only password" + #expect(protected.isValid) + protected.password = nil + #expect(protected.isValid(preservingPasswordFor: .password)) + #expect(protected.isValid(preservingPasswordFor: .public) == false) + } + + @Test func restoredCopyNamePreservesExtensionAndUsesStableTimestamp() throws { + let date = Date(timeIntervalSince1970: 1_753_304_700) + let timeZone = try #require(TimeZone(secondsFromGMT: 0)) + + #expect(KDriveRestoredCopyNaming.filename( + originalName: "Report.pdf", + restoredAt: date, + timeZone: timeZone, + uniqueSuffix: "ABC123" + ) == "Report (restored 2025-07-23 21.05 ABC123).pdf") + #expect(KDriveRestoredCopyNaming.filename( + originalName: "Archive", + restoredAt: date, + timeZone: timeZone, + uniqueSuffix: "ABC123" + ) == "Archive (restored 2025-07-23 21.05 ABC123)") + } + + @Test func extensionPlistsUseSharedIdentifiersAndSingleSelectionPredicates() throws { + let repositoryURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let providerActions = try actionDictionaries( + at: repositoryURL.appendingPathComponent("Config/potassiumProviderFileProviderInfo.plist") + ) + let uiActions = try actionDictionaries( + at: repositoryURL.appendingPathComponent("Config/potassiumProviderActionsInfo.plist") + ) + + #expect(Set(providerActions.compactMap(actionIdentifier)) == Set( + ProviderDirectContextAction.allCases.map(\.rawValue) + )) + #expect(Set(uiActions.compactMap(actionIdentifier)) == [ + ProviderContextActionIdentifier.shareLink, + ProviderContextActionIdentifier.versionHistory, + ]) + for action in providerActions + uiActions { + let predicate = try #require( + action["NSExtensionFileProviderActionActivationRule"] as? String + ) + #expect(predicate.contains("fileproviderItems.@count == 1")) + } + } + + private func actionDictionaries(at url: URL) throws -> [[String: Any]] { + let data = try Data(contentsOf: url) + let propertyList = try PropertyListSerialization.propertyList( + from: data, + format: nil + ) + let root = try #require(propertyList as? [String: Any]) + let extensionDictionary = try #require(root["NSExtension"] as? [String: Any]) + return try #require( + extensionDictionary["NSExtensionFileProviderActions"] as? [[String: Any]] + ) + } + + private func actionIdentifier(_ dictionary: [String: Any]) -> String? { + dictionary["NSExtensionFileProviderActionIdentifier"] as? String + } + + private func item( + id: Int, + parentID: Int, + name: String = "Document.txt", + isFavorite: Bool? = nil + ) -> KDriveRemoteItem { + KDriveRemoteItem( + id: id, + name: name, + type: "file", + status: "ok", + driveID: 7, + parentID: parentID, + path: "/\(name)", + size: 12, + mimeType: "text/plain", + isFavorite: isFavorite, + createdAt: Date(timeIntervalSince1970: 100), + modifiedAt: Date(timeIntervalSince1970: 200), + updatedAt: Date(timeIntervalSince1970: 300) + ) + } +} + +private actor ContextActionRemoteMock: KDriveItemMetadataProviding, KDriveContextActionProviding { + enum Call: Equatable { + case item(fileID: Int) + case setFavorite(fileID: Int, isFavorite: Bool) + case duplicate(fileID: Int) + case trashedItem(fileID: Int) + case existingFileIDs([Int]) + case restore(fileID: Int, destinationParentID: Int) + } + + private var metadataResponses: [Int: [KDriveRemoteItem]] + private let duplicateResult: KDriveRemoteItem? + private let trashedResult: KDriveRemoteItem? + private let existingIDs: Set + private let restoreError: ContextActionMockError? + private var recordedCalls: [Call] = [] + + init( + metadataResponses: [Int: [KDriveRemoteItem]] = [:], + duplicateResult: KDriveRemoteItem? = nil, + trashedResult: KDriveRemoteItem? = nil, + existingIDs: Set = [], + restoreError: ContextActionMockError? = nil + ) { + self.metadataResponses = metadataResponses + self.duplicateResult = duplicateResult + self.trashedResult = trashedResult + self.existingIDs = existingIDs + self.restoreError = restoreError + } + + func calls() -> [Call] { + recordedCalls + } + + func item(driveID: Int, fileID: Int) async throws -> KDriveRemoteItem { + recordedCalls.append(.item(fileID: fileID)) + guard var responses = metadataResponses[fileID], responses.isEmpty == false else { + throw ContextActionMockError.missingFixture + } + let response = responses.removeFirst() + metadataResponses[fileID] = responses + return response + } + + func setFavorite(driveID: Int, fileID: Int, isFavorite: Bool) async throws { + recordedCalls.append(.setFavorite(fileID: fileID, isFavorite: isFavorite)) + } + + func duplicateItem(driveID: Int, fileID: Int) async throws -> KDriveRemoteItem { + recordedCalls.append(.duplicate(fileID: fileID)) + guard let duplicateResult else { + throw ContextActionMockError.missingFixture + } + return duplicateResult + } + + func trashedItem(driveID: Int, fileID: Int) async throws -> KDriveRemoteItem { + recordedCalls.append(.trashedItem(fileID: fileID)) + guard let trashedResult else { + throw ContextActionMockError.missingFixture + } + return trashedResult + } + + func existingFileIDs(driveID: Int, fileIDs: [Int]) async throws -> Set { + recordedCalls.append(.existingFileIDs(fileIDs)) + return existingIDs + } + + func restoreTrashedItem( + driveID: Int, + fileID: Int, + destinationParentID: Int + ) async throws { + recordedCalls.append(.restore(fileID: fileID, destinationParentID: destinationParentID)) + if let restoreError { + throw restoreError + } + } + + func shareLink(driveID: Int, fileID: Int) async throws -> KDriveShareLinkSummary? { + throw ContextActionMockError.unimplemented + } + + func createShareLink( + driveID: Int, + fileID: Int, + configuration: KDriveShareLinkConfiguration + ) async throws -> KDriveShareLinkSummary { + throw ContextActionMockError.unimplemented + } + + func updateShareLink( + driveID: Int, + fileID: Int, + configuration: KDriveShareLinkConfiguration + ) async throws -> KDriveShareLinkSummary { + throw ContextActionMockError.unimplemented + } + + func deleteShareLink(driveID: Int, fileID: Int) async throws { + throw ContextActionMockError.unimplemented + } + + func fileVersions( + driveID: Int, + fileID: Int, + page: Int, + pageSize: Int + ) async throws -> KDriveFileVersionPage { + throw ContextActionMockError.unimplemented + } + + func restoreFileVersion( + driveID: Int, + fileID: Int, + versionID: Int, + destinationParentID: Int, + name: String + ) async throws -> KDriveRemoteItem { + throw ContextActionMockError.unimplemented + } +} + +private enum ContextActionMockError: Error, Equatable { + case missingFixture + case remoteFailure + case unimplemented +} diff --git a/potassiumProviderTests/SnapshotGenerationPagingTests.swift b/potassiumProviderTests/SnapshotGenerationPagingTests.swift index 7d12375..8306c1c 100644 --- a/potassiumProviderTests/SnapshotGenerationPagingTests.swift +++ b/potassiumProviderTests/SnapshotGenerationPagingTests.swift @@ -32,244 +32,354 @@ struct SnapshotGenerationPagingTests { let directory = temporaryDirectory() defer { try? FileManager.default.removeItem(at: directory) } let databaseURL = directory.appendingPathComponent("Snapshots.sqlite3") - let database = try Connection(databaseURL.path) - try database.execute(""" - CREATE TABLE container_snapshots( - domainIdentifier TEXT NOT NULL, - containerIdentifier TEXT NOT NULL, - anchor TEXT NOT NULL, - serverCursor TEXT, - isFullyEnumerated INTEGER NOT NULL, - usesAdvancedListing INTEGER NOT NULL, - updatedAt REAL NOT NULL, - PRIMARY KEY(domainIdentifier, containerIdentifier) - ); - CREATE TABLE snapshot_items( - domainIdentifier TEXT NOT NULL, - containerIdentifier TEXT NOT NULL, - position INTEGER NOT NULL, - itemID INTEGER NOT NULL, - name TEXT NOT NULL, - type TEXT, - status TEXT NOT NULL, - driveID INTEGER NOT NULL, - parentID INTEGER NOT NULL, - path TEXT, - size INTEGER, - mimeType TEXT, - createdAt REAL, - modifiedAt REAL NOT NULL, - itemUpdatedAt REAL NOT NULL, - PRIMARY KEY(domainIdentifier, containerIdentifier, itemID) - ); - CREATE TABLE materialized_items( - domainIdentifier TEXT NOT NULL, - fileID INTEGER NOT NULL, - isContainer INTEGER NOT NULL, - PRIMARY KEY(domainIdentifier, fileID) - ); - INSERT INTO container_snapshots VALUES - ('domain', '42', 'legacy-anchor', 'legacy-cursor', 1, 1, 1000); - INSERT INTO snapshot_items VALUES - ('domain', '42', 0, 7, 'Legacy.txt', 'file', 'ok', 1, 42, - '/Legacy.txt', 12, 'text/plain', 900, 950, 975); - INSERT INTO materialized_items VALUES ('domain', 42, 1); - """) + do { + let database = try Connection(databaseURL.path) + try database.execute(""" + CREATE TABLE container_snapshots( + domainIdentifier TEXT NOT NULL, + containerIdentifier TEXT NOT NULL, + anchor TEXT NOT NULL, + serverCursor TEXT, + isFullyEnumerated INTEGER NOT NULL, + usesAdvancedListing INTEGER NOT NULL, + updatedAt REAL NOT NULL, + PRIMARY KEY(domainIdentifier, containerIdentifier) + ); + CREATE TABLE snapshot_items( + domainIdentifier TEXT NOT NULL, + containerIdentifier TEXT NOT NULL, + position INTEGER NOT NULL, + itemID INTEGER NOT NULL, + name TEXT NOT NULL, + type TEXT, + status TEXT NOT NULL, + driveID INTEGER NOT NULL, + parentID INTEGER NOT NULL, + path TEXT, + size INTEGER, + mimeType TEXT, + createdAt REAL, + modifiedAt REAL NOT NULL, + itemUpdatedAt REAL NOT NULL, + PRIMARY KEY(domainIdentifier, containerIdentifier, itemID) + ); + CREATE TABLE materialized_items( + domainIdentifier TEXT NOT NULL, + fileID INTEGER NOT NULL, + isContainer INTEGER NOT NULL, + PRIMARY KEY(domainIdentifier, fileID) + ); + INSERT INTO container_snapshots VALUES + ('domain', '42', 'legacy-anchor', 'legacy-cursor', 1, 1, 1000); + INSERT INTO snapshot_items VALUES + ('domain', '42', 0, 7, 'Legacy.txt', 'file', 'ok', 1, 42, + '/Legacy.txt', 12, 'text/plain', 900, 950, 975); + INSERT INTO materialized_items VALUES ('domain', 42, 1); + """) + } - let store = try KDriveSnapshotSQLiteStore(databaseURL: databaseURL) - let snapshot = try #require(try await store.snapshot( - domainIdentifier: "domain", - containerIdentifier: "42" - )) - #expect(snapshot.anchor == "legacy-anchor") - #expect(snapshot.serverCursor == "legacy-cursor") - #expect(snapshot.items.map(\.id) == [7]) - #expect(snapshot.items.first?.name == "Legacy.txt") + try await withStore(databaseURL: databaseURL) { store in + let snapshot = try #require(try await store.snapshot( + domainIdentifier: "domain", + containerIdentifier: "42" + )) + #expect(snapshot.anchor == "legacy-anchor") + #expect(snapshot.serverCursor == "legacy-cursor") + #expect(snapshot.items.map(\.id) == [7]) + #expect(snapshot.items.first?.name == "Legacy.txt") + #expect(snapshot.items.first?.isFavorite == nil) - let page = try #require(try await store.snapshotPage( - domainIdentifier: "domain", - containerIdentifier: "42", - after: nil, - limit: 1 - )) - #expect(page.generation == 1) - #expect(page.items == snapshot.items) - #expect(try await store.materializedItems(domainIdentifier: "domain") == [ - KDriveMaterializedItem(fileID: 42, isContainer: true), - ]) + let page = try #require(try await store.snapshotPage( + domainIdentifier: "domain", + containerIdentifier: "42", + after: nil, + limit: 1 + )) + #expect(page.generation == 1) + #expect(page.items == snapshot.items) + #expect(try await store.materializedItems(domainIdentifier: "domain") == [ + KDriveMaterializedItem(fileID: 42, isContainer: true), + ]) + } } - @Test func cachedPagesRemainStableWhileANewerGenerationCommits() async throws { - let (store, directory) = try makeStore() - defer { try? FileManager.default.removeItem(at: directory) } - try await store.save( - snapshot(anchor: "one", ids: [1, 2, 3, 4, 5]), - domainIdentifier: "domain", - containerIdentifier: "42" - ) - let first = try #require(try await store.snapshotPage( - domainIdentifier: "domain", - containerIdentifier: "42", - after: nil, - limit: 2 - )) - #expect(first.items.map(\.id) == [1, 2]) - let token = try #require(first.nextToken) + @Test func favoriteMetadataRoundTripsThroughSnapshotGenerations() async throws { + try await withStore { store in + let favorite = KDriveRemoteItem( + id: 7, + name: "Favorite.txt", + type: "file", + status: "ok", + driveID: 1, + parentID: 42, + path: "/Favorite.txt", + size: 12, + mimeType: "text/plain", + isFavorite: true, + createdAt: Date(timeIntervalSince1970: 900), + modifiedAt: Date(timeIntervalSince1970: 950), + updatedAt: Date(timeIntervalSince1970: 975) + ) - try await store.save( - snapshot(anchor: "two", ids: [10, 11]), - domainIdentifier: "domain", - containerIdentifier: "42" - ) + try await store.save( + KDriveSnapshot(isFullyEnumerated: true, items: [favorite]), + domainIdentifier: "domain", + containerIdentifier: "42" + ) - let second = try #require(try await store.snapshotPage( - domainIdentifier: "domain", - containerIdentifier: "42", - after: token, - limit: 2 - )) - #expect(second.generation == first.generation) - #expect(second.items.map(\.id) == [3, 4]) - let secondToken = try #require(second.nextToken) - let third = try #require(try await store.snapshotPage( - domainIdentifier: "domain", - containerIdentifier: "42", - after: secondToken, - limit: 2 - )) - #expect(third.items.map(\.id) == [5]) - #expect(third.nextToken == nil) + let snapshot = try #require(try await store.snapshot( + domainIdentifier: "domain", + containerIdentifier: "42" + )) + let page = try #require(try await store.snapshotPage( + domainIdentifier: "domain", + containerIdentifier: "42", + after: nil, + limit: 10 + )) + #expect(snapshot.items.first?.isFavorite == true) + #expect(page.items.first?.isFavorite == true) + } } - @Test func expiresItemPageAfterThreeNewerGenerations() async throws { - let (store, directory) = try makeStore() + @Test func legacyWorkingSetJSONDecodesWithoutFavoriteMetadata() async throws { + let directory = temporaryDirectory() defer { try? FileManager.default.removeItem(at: directory) } - try await store.save(snapshot(anchor: "one", ids: [1, 2]), domainIdentifier: "domain", containerIdentifier: "42") - let first = try #require(try await store.snapshotPage( - domainIdentifier: "domain", - containerIdentifier: "42", - after: nil, - limit: 1 - )) - let oldToken = try #require(first.nextToken) + let databaseURL = directory.appendingPathComponent("Snapshots.sqlite3") + try await withStore(databaseURL: databaseURL) { store in + let legacyItem = KDriveRemoteItem( + id: 7, + name: "Legacy.txt", + type: "file", + status: "ok", + driveID: 1, + parentID: 42, + path: "/Legacy.txt", + size: 12, + mimeType: "text/plain", + isFavorite: true, + createdAt: Date(timeIntervalSince1970: 900), + modifiedAt: Date(timeIntervalSince1970: 950), + updatedAt: Date(timeIntervalSince1970: 975) + ) + var json = try #require( + JSONSerialization.jsonObject(with: JSONEncoder().encode(legacyItem)) as? [String: Any] + ) + json.removeValue(forKey: "isFavorite") + let itemsJSON = String( + decoding: try JSONSerialization.data(withJSONObject: [json]), + as: UTF8.self + ) + do { + let database = try Connection(databaseURL.path) + try database.run( + """ + INSERT INTO working_set_poll_state( + domainIdentifier, + workingSetAnchor, + workingSetItemsJSON, + lastPollAttemptAt, + lastSuccessfulPollAt + ) VALUES (?, ?, ?, NULL, NULL) + """, + "domain", + "legacy-anchor", + itemsJSON + ) + } + + let snapshot = try #require(try await store.workingSetSnapshot(domainIdentifier: "domain")) + #expect(snapshot.items.first?.name == "Legacy.txt") + #expect(snapshot.items.first?.isFavorite == nil) + } + } + + @Test func cachedPagesRemainStableWhileANewerGenerationCommits() async throws { + try await withStore { store in + try await store.save( + snapshot(anchor: "one", ids: [1, 2, 3, 4, 5]), + domainIdentifier: "domain", + containerIdentifier: "42" + ) + let first = try #require(try await store.snapshotPage( + domainIdentifier: "domain", + containerIdentifier: "42", + after: nil, + limit: 2 + )) + #expect(first.items.map(\.id) == [1, 2]) + let token = try #require(first.nextToken) - for generation in 2...4 { try await store.save( - snapshot(anchor: "anchor-\(generation)", ids: [generation]), + snapshot(anchor: "two", ids: [10, 11]), domainIdentifier: "domain", containerIdentifier: "42" ) + + let second = try #require(try await store.snapshotPage( + domainIdentifier: "domain", + containerIdentifier: "42", + after: token, + limit: 2 + )) + #expect(second.generation == first.generation) + #expect(second.items.map(\.id) == [3, 4]) + let secondToken = try #require(second.nextToken) + let third = try #require(try await store.snapshotPage( + domainIdentifier: "domain", + containerIdentifier: "42", + after: secondToken, + limit: 2 + )) + #expect(third.items.map(\.id) == [5]) + #expect(third.nextToken == nil) } + } - await #expect(throws: KDriveSnapshotStoreError.expiredGeneration( - domainIdentifier: "domain", - containerIdentifier: "42" - )) { - _ = try await store.snapshotPage( + @Test func expiresItemPageAfterThreeNewerGenerations() async throws { + try await withStore { store in + try await store.save( + snapshot(anchor: "one", ids: [1, 2]), + domainIdentifier: "domain", + containerIdentifier: "42" + ) + let first = try #require(try await store.snapshotPage( domainIdentifier: "domain", containerIdentifier: "42", - after: oldToken, + after: nil, limit: 1 - ) + )) + let oldToken = try #require(first.nextToken) + + for generation in 2...4 { + try await store.save( + snapshot(anchor: "anchor-\(generation)", ids: [generation]), + domainIdentifier: "domain", + containerIdentifier: "42" + ) + } + + await #expect(throws: KDriveSnapshotStoreError.expiredGeneration( + domainIdentifier: "domain", + containerIdentifier: "42" + )) { + _ = try await store.snapshotPage( + domainIdentifier: "domain", + containerIdentifier: "42", + after: oldToken, + limit: 1 + ) + } } } @Test func changePagesAreBoundedAndStableAcrossUpdatesAndDeletes() async throws { - let (store, directory) = try makeStore() - defer { try? FileManager.default.removeItem(at: directory) } - let initial = KDriveSnapshot( - anchor: "initial", - isFullyEnumerated: true, - items: [item(id: 1), item(id: 2), item(id: 3)] - ) - try await store.save(initial, domainIdentifier: "domain", containerIdentifier: "root") - let updated = KDriveSnapshot( - anchor: "updated", - isFullyEnumerated: true, - items: [item(id: 1, name: "Renamed-1"), item(id: 3), item(id: 4)] - ) - try await store.save(updated, domainIdentifier: "domain", containerIdentifier: "root") + try await withStore { store in + let initial = KDriveSnapshot( + anchor: "initial", + isFullyEnumerated: true, + items: [item(id: 1), item(id: 2), item(id: 3)] + ) + try await store.save(initial, domainIdentifier: "domain", containerIdentifier: "root") + let updated = KDriveSnapshot( + anchor: "updated", + isFullyEnumerated: true, + items: [item(id: 1, name: "Renamed-1"), item(id: 3), item(id: 4)] + ) + try await store.save(updated, domainIdentifier: "domain", containerIdentifier: "root") - var token: String? - var updates: [KDriveRemoteItem] = [] - var deletions: [Int] = [] - repeat { - let page = try #require(try await store.snapshotChangePage( - domainIdentifier: "domain", - containerIdentifier: "root", - from: "initial", - after: token, - limit: 1 - )) - #expect(page.changes.updatedItems.count + page.changes.deletedItemIDs.count <= 1) - #expect(page.targetAnchor == "updated") - updates.append(contentsOf: page.changes.updatedItems) - deletions.append(contentsOf: page.changes.deletedItemIDs) - token = page.nextToken - } while token != nil + var token: String? + var updates: [KDriveRemoteItem] = [] + var deletions: [Int] = [] + repeat { + let page = try #require(try await store.snapshotChangePage( + domainIdentifier: "domain", + containerIdentifier: "root", + from: "initial", + after: token, + limit: 1 + )) + #expect(page.changes.updatedItems.count + page.changes.deletedItemIDs.count <= 1) + #expect(page.targetAnchor == "updated") + updates.append(contentsOf: page.changes.updatedItems) + deletions.append(contentsOf: page.changes.deletedItemIDs) + token = page.nextToken + } while token != nil - #expect(updates.map(\.id).sorted() == [1, 4]) - #expect(deletions == [2]) + #expect(updates.map(\.id).sorted() == [1, 4]) + #expect(deletions == [2]) + } } @Test func failedGenerationCommitRollsBackHeadAndItems() async throws { - let (store, directory) = try makeStore() - defer { try? FileManager.default.removeItem(at: directory) } - let initial = snapshot(anchor: "initial", ids: [1, 2, 3]) - try await store.save(initial, domainIdentifier: "domain", containerIdentifier: "42") - let firstPage = try #require(try await store.snapshotPage( - domainIdentifier: "domain", - containerIdentifier: "42", - after: nil, - limit: 1 - )) + try await withStore { store in + let initial = snapshot(anchor: "initial", ids: [1, 2, 3]) + try await store.save(initial, domainIdentifier: "domain", containerIdentifier: "42") + let firstPage = try #require(try await store.snapshotPage( + domainIdentifier: "domain", + containerIdentifier: "42", + after: nil, + limit: 1 + )) - let duplicate = KDriveSnapshot( - anchor: "broken", - isFullyEnumerated: true, - items: [item(id: 9), item(id: 9, name: "Duplicate")] - ) - await #expect(throws: (any Error).self) { - try await store.save(duplicate, domainIdentifier: "domain", containerIdentifier: "42") - } + let duplicate = KDriveSnapshot( + anchor: "broken", + isFullyEnumerated: true, + items: [item(id: 9), item(id: 9, name: "Duplicate")] + ) + await #expect(throws: (any Error).self) { + try await store.save(duplicate, domainIdentifier: "domain", containerIdentifier: "42") + } - #expect(try await store.snapshot(domainIdentifier: "domain", containerIdentifier: "42") == initial) - let continuationToken = try #require(firstPage.nextToken) - let continued = try #require(try await store.snapshotPage( - domainIdentifier: "domain", - containerIdentifier: "42", - after: continuationToken, - limit: 10 - )) - #expect(continued.items.map(\.id) == [2, 3]) + #expect(try await store.snapshot(domainIdentifier: "domain", containerIdentifier: "42") == initial) + let continuationToken = try #require(firstPage.nextToken) + let continued = try #require(try await store.snapshotPage( + domainIdentifier: "domain", + containerIdentifier: "42", + after: continuationToken, + limit: 10 + )) + #expect(continued.items.map(\.id) == [2, 3]) + } } @Test func largeSnapshotReadsOnlyRequestedPageShape() async throws { - let (store, directory) = try makeStore() - defer { try? FileManager.default.removeItem(at: directory) } - try await store.save( - snapshot(anchor: "large", ids: Array(1...2_000)), - domainIdentifier: "domain", - containerIdentifier: "42" - ) - let page = try #require(try await store.snapshotPage( - domainIdentifier: "domain", - containerIdentifier: "42", - after: nil, - limit: 50 - )) - #expect(page.items.count == 50) - #expect(page.nextToken != nil) + try await withStore { store in + try await store.save( + snapshot(anchor: "large", ids: Array(1...2_000)), + domainIdentifier: "domain", + containerIdentifier: "42" + ) + let page = try #require(try await store.snapshotPage( + domainIdentifier: "domain", + containerIdentifier: "42", + after: nil, + limit: 50 + )) + #expect(page.items.count == 50) + #expect(page.nextToken != nil) + } } - private func makeStore() throws -> (KDriveSnapshotSQLiteStore, URL) { + private func withStore( + _ operation: (KDriveSnapshotSQLiteStore) async throws -> Result + ) async throws -> Result { let directory = temporaryDirectory() - return ( - try KDriveSnapshotSQLiteStore(databaseURL: directory.appendingPathComponent("Snapshots.sqlite3")), - directory + defer { try? FileManager.default.removeItem(at: directory) } + return try await withStore( + databaseURL: directory.appendingPathComponent("Snapshots.sqlite3"), + operation ) } + private func withStore( + databaseURL: URL, + _ operation: (KDriveSnapshotSQLiteStore) async throws -> Result + ) async throws -> Result { + let store = try KDriveSnapshotSQLiteStore(databaseURL: databaseURL) + return try await operation(store) + } + private func temporaryDirectory() -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent("snapshot-generation-tests-\(UUID().uuidString)", isDirectory: true) diff --git a/potassiumProviderTests/potassiumProviderTests.swift b/potassiumProviderTests/potassiumProviderTests.swift index 60869d6..e22edd0 100644 --- a/potassiumProviderTests/potassiumProviderTests.swift +++ b/potassiumProviderTests/potassiumProviderTests.swift @@ -1445,7 +1445,7 @@ struct PotassiumProviderCoreTests { conflictStrategy: .version ) #expect(operation.progress.totalUnitCount >= -1) - #expect(await KDriveJSONRequestCapturingURLProtocol.lastRequest() == nil) + #expect(await KDriveJSONRequestCapturingURLProtocol.peekLastRequest() == nil) let item = try await operation.value let request = try #require(await KDriveJSONRequestCapturingURLProtocol.lastRequest()) @@ -1484,7 +1484,7 @@ struct PotassiumProviderCoreTests { let operation = try service.downloadFileOperation(driveID: 100, fileID: 42) #expect(operation.progress.totalUnitCount >= -1) - #expect(await KDriveDataRequestCapturingURLProtocol.lastRequest() == nil) + #expect(await KDriveDataRequestCapturingURLProtocol.peekLastRequest() == nil) let data = try await operation.value let request = try #require(await KDriveDataRequestCapturingURLProtocol.lastRequest()) @@ -1946,6 +1946,41 @@ struct PotassiumProviderCoreTests { #expect(failure.summary == "Could not add the provider domain.") } + @MainActor + @Test func appModelRevealsAndSignalsOneConfiguredDrive() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let registrar = RecordingProviderDomainRegistrar() + let model = PotassiumProviderAppModel( + accountStore: ProviderAccountFileStore( + directoryURL: directory.appendingPathComponent("Accounts", isDirectory: true) + ), + domainStore: DomainConfigurationFileStore( + directoryURL: directory.appendingPathComponent("Domains", isDirectory: true) + ), + tokenStore: InMemoryOAuthTokenStore(), + oauthAuthenticator: FakeKDriveOAuthAuthenticator(), + domainRegistrar: registrar, + automaticallyReloadStoredState: false, + fileProviderFactory: { _ in FakeKDriveFileProvider(drives: []) } + ) + let configuration = ProviderDomainConfiguration( + domainIdentifier: "domain-1", + displayName: "Work Drive", + driveID: 42, + driveName: "Work Drive" + ) + + let visibleURL = await model.userVisibleRootURL(for: configuration) + await model.syncNow(configuration) + + #expect(visibleURL == registrar.visibleRootURL) + #expect(registrar.revealedConfigurations == [configuration]) + #expect(registrar.signaledConfigurations == [configuration]) + #expect(model.statusMessage == "Requested a fresh sync for Work Drive.") + #expect(model.isPerformingDomainAction(configuration.domainIdentifier) == false) + } + @MainActor @Test func appModelRecordsSanitizedFailureActivity() async throws { let directory = temporaryDirectory() @@ -2380,6 +2415,10 @@ private final class KDriveDataRequestCapturingURLProtocol: URLProtocol { await capture.lastRequest() } + static func peekLastRequest() async -> URLRequest? { + await capture.peekLastRequest() + } + static func lastBody() async -> Data? { await capture.lastBody() } @@ -2423,6 +2462,10 @@ private final class KDriveJSONRequestCapturingURLProtocol: URLProtocol { await capture.lastRequest() } + static func peekLastRequest() async -> URLRequest? { + await capture.peekLastRequest() + } + static func lastBody() async -> Data? { await capture.lastBody() } @@ -2500,6 +2543,10 @@ private actor CapturedURLRequestStore { return capturedRequest } + func peekLastRequest() -> URLRequest? { + capturedRequest + } + func lastBody() async -> Data? { await waitForCapture() return capturedBody @@ -2741,6 +2788,9 @@ private struct NoopProviderDomainRegistrar: ProviderDomainRegistering { private final class RecordingProviderDomainRegistrar: ProviderDomainRegistering { private(set) var addedConfigurations: [ProviderDomainConfiguration] = [] private(set) var removedConfigurations: [ProviderDomainConfiguration] = [] + private(set) var revealedConfigurations: [ProviderDomainConfiguration] = [] + private(set) var signaledConfigurations: [ProviderDomainConfiguration] = [] + let visibleRootURL = URL(fileURLWithPath: "/tmp/potassium-provider-visible-root") func addDomain(for configuration: ProviderDomainConfiguration) async throws { addedConfigurations.append(configuration) @@ -2749,6 +2799,15 @@ private final class RecordingProviderDomainRegistrar: ProviderDomainRegistering func removeDomain(for configuration: ProviderDomainConfiguration) async throws { removedConfigurations.append(configuration) } + + func userVisibleRootURL(for configuration: ProviderDomainConfiguration) async throws -> URL { + revealedConfigurations.append(configuration) + return visibleRootURL + } + + func signalWorkingSet(for configuration: ProviderDomainConfiguration) async throws { + signaledConfigurations.append(configuration) + } } @MainActor