Files
Termind/TFI/UI/Components/MobileDropPayload.swift
T
2026-09-12 23:24:04 +08:00

247 lines
9.8 KiB
Swift

import Foundation
import UniformTypeIdentifiers
nonisolated enum MobileDropKind: Hashable, Sendable {
case files
case images
}
nonisolated enum MobileDropFileMode: Sendable {
case referenced
case staged
}
nonisolated struct MobileDropPayload: Sendable {
var fileURLs: [URL] = []
var images: [Data] = []
var failures: [String] = []
var stagedRoot: URL?
}
@MainActor
enum MobileDropPayloadLoader {
private enum Representation {
case fileURL
case file(UTType)
case image(String)
}
static func acceptsUpload(_ providers: [NSItemProvider]) -> Bool {
accepts(providers, kinds: [.files], fileMode: .staged)
}
static func accepts(
_ providers: [NSItemProvider],
kinds: Set<MobileDropKind>,
fileMode: MobileDropFileMode = .referenced
) -> Bool {
providers.contains {
representation(of: $0, kinds: kinds, fileMode: fileMode) != nil
}
}
static func load(
_ providers: [NSItemProvider],
kinds: Set<MobileDropKind>,
fileMode: MobileDropFileMode = .referenced
) async -> MobileDropPayload {
var payload = MobileDropPayload()
if fileMode == .staged {
do {
payload.stagedRoot = try makeStagingRoot()
} catch {
payload.failures.append(error.localizedDescription)
return payload
}
}
for provider in providers {
do {
try Task.checkCancellation()
switch representation(of: provider, kinds: kinds, fileMode: fileMode) {
case .fileURL:
guard
let url = try await fileURL(
from: provider,
stagingRoot: payload.stagedRoot
), url.isFileURL
else {
throw CocoaError(.fileReadUnknown)
}
payload.fileURLs.append(url)
case .file(let type):
if let root = payload.stagedRoot {
payload.fileURLs.append(try await fileRepresentation(from: provider, type: type, in: root))
}
case .image(let identifier):
payload.images.append(try await data(from: provider, typeIdentifier: identifier))
case nil:
break
}
} catch is CancellationError {
break
} catch {
payload.failures.append(error.localizedDescription)
}
}
if Task.isCancelled {
payload.fileURLs = []
payload.images = []
}
if payload.fileURLs.isEmpty, let stagedRoot = payload.stagedRoot {
try? FileManager.default.removeItem(at: stagedRoot)
payload.stagedRoot = nil
}
return payload
}
private static func representation(
of provider: NSItemProvider, kinds: Set<MobileDropKind>, fileMode: MobileDropFileMode
) -> Representation? {
if kinds.contains(.files), provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
return .fileURL
}
// Assistant image attachments keep their image semantics, even when files are staged.
if kinds.contains(.images), let identifier = provider.registeredTypeIdentifiers.first(where: isImageType) {
return .image(identifier)
}
guard kinds.contains(.files), fileMode == .staged,
!provider.hasItemConformingToTypeIdentifier(UTType.url.identifier)
else { return nil }
let named = provider.suggestedName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
let inPlaceTypes = provider.registeredTypeIdentifiers(fileOptions: .openInPlace)
let type = provider.registeredTypeIdentifiers.compactMap(UTType.init).first {
if $0.conforms(to: .directory) { return true }
guard $0.conforms(to: .data), !$0.conforms(to: .url) else { return false }
// A text selection is terminal input. Files can advertise the same UTI,
// but supply a filename or an in-place file representation.
return !$0.conforms(to: .text) || named || inPlaceTypes.contains($0.identifier)
}
return type.map(Representation.file)
}
private static func isImageType(_ identifier: String) -> Bool {
UTType(identifier)?.conforms(to: .image) == true
}
nonisolated private static func fileURL(
from provider: NSItemProvider,
stagingRoot: URL?
) async throws -> URL? {
try await withCheckedThrowingContinuation { continuation in
provider.loadObject(ofClass: NSURL.self) { item, error in
if let error {
continuation.resume(throwing: error)
return
}
if let url = item as? NSURL {
guard (url as URL).isFileURL else {
continuation.resume(throwing: CocoaError(.fileReadUnknown))
return
}
do {
continuation.resume(
returning: try stagingRoot.map {
try stage(url as URL, in: $0)
} ?? (url as URL)
)
} catch {
continuation.resume(throwing: error)
}
return
}
continuation.resume(returning: nil)
}
}
}
nonisolated private static func fileRepresentation(
from provider: NSItemProvider, type: UTType, in root: URL
) async throws -> URL {
let suggestedName = provider.suggestedName
return try await withCheckedThrowingContinuation { continuation in
provider.loadFileRepresentation(forTypeIdentifier: type.identifier) { url, error in
do {
guard let url else { throw error ?? CocoaError(.fileReadUnknown) }
let name = stagedName(suggestedName, source: url, type: type)
// UIKit deletes its temporary representation when this callback returns.
continuation.resume(returning: try stage(url, in: root, name: name))
} catch {
continuation.resume(throwing: error)
}
}
}
}
nonisolated private static func stagedName(_ suggestedName: String?, source: URL, type: UTType) -> String {
var name = suggestedName.map { ($0 as NSString).lastPathComponent } ?? source.lastPathComponent
if name.isEmpty || name == "." || name == ".." || name == "/" {
name = source.lastPathComponent
}
if !type.conforms(to: .directory), (name as NSString).pathExtension.isEmpty,
let fileExtension = type.preferredFilenameExtension
{
name += "." + fileExtension
}
return name
}
private static func makeStagingRoot() throws -> URL {
let parent = FileManager.default.temporaryDirectory.appending(
path: "TermindDropUploads", directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true)
let expiration = Date().addingTimeInterval(-7 * 24 * 60 * 60)
for candidate in try FileManager.default.contentsOfDirectory(
at: parent,
includingPropertiesForKeys: [.contentModificationDateKey],
options: [.skipsHiddenFiles]
) {
let modified = try? candidate.resourceValues(
forKeys: [.contentModificationDateKey]
).contentModificationDate
if modified.map({ $0 < expiration }) == true {
try? FileManager.default.removeItem(at: candidate)
}
}
let root = parent.appending(path: UUID().uuidString, directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
return root
}
nonisolated private static func stage(_ source: URL, in root: URL, name: String? = nil) throws -> URL {
let itemRoot = root.appending(path: UUID().uuidString, directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: itemRoot, withIntermediateDirectories: true)
let destination = itemRoot.appending(path: name ?? source.lastPathComponent)
let accessed = source.startAccessingSecurityScopedResource()
defer { if accessed { source.stopAccessingSecurityScopedResource() } }
do {
var coordinationError: NSError?
var copyResult: Result<Void, any Error> = .failure(CocoaError(.fileReadUnknown))
NSFileCoordinator().coordinate(readingItemAt: source, options: .withoutChanges, error: &coordinationError) {
coordinatedURL in
copyResult = Result { try FileManager.default.copyItem(at: coordinatedURL, to: destination) }
}
if let coordinationError { throw coordinationError }
try copyResult.get()
} catch {
try? FileManager.default.removeItem(at: itemRoot)
throw error
}
return destination
}
nonisolated private static func data(
from provider: NSItemProvider,
typeIdentifier: String
) async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, error in
if let data {
continuation.resume(returning: data)
} else {
continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown))
}
}
}
}
}