基于NSFilePromiseProvider的文件拖拽至iTerm/TextEdit路径插入失败排查
我开发了一个展示文件的表格视图,希望能将1行或多行拖拽至iTerm或TextEdit中,以文件路径字符串形式插入。我使用现代的NSFilePromiseProvider实现该功能,参照苹果官方《Support Image Export by Providing File Promises》示例编写了FilePromiseProvider类:
class FilePromiseProvider: NSFilePromiseProvider { struct UserInfoKeys { static let url = "url" } override func writableTypes(for pasteboard: NSPasteboard) -> [NSPasteboard.PasteboardType] { var types = super.writableTypes(for: pasteboard) types.append(contentsOf: [.fileURL, .string]) // <-- Added .string return types } override func pasteboardPropertyList(forType type: NSPasteboard.PasteboardType) -> Any? { guard let userInfoDict = userInfo as? [String: Any] else { return nil } switch type { case .fileURL: // Incoming type is "public.file-url", return (from our userInfo) the URL. if let url = userInfoDict[FilePromiseProvider.UserInfoKeys.url] as? NSURL { return url.pasteboardPropertyList(forType: type) } case .string: if let url = userInfoDict[FilePromiseProvider.UserInfoKeys.url] as? NSURL { return url.path } default: break } return super.pasteboardPropertyList(forType: type) } public override func writingOptions(forType type: NSPasteboard.PasteboardType, pasteboard: NSPasteboard) -> NSPasteboard.WritingOptions { return super.writingOptions(forType: type, pasteboard: pasteboard) } }
此外,我已将其与表格视图完成绑定:
class ResultsViewController: NSTableViewDelegate { // ... etc // Queue used for reading and writing file promises. var filePromiseQueue: OperationQueue = { let queue = OperationQueue() return queue }() func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? { var provider: FilePromiseProvider if let assets = filesArrayController.arrangedObjects as? [MyFile] { let asset = assets[row] let ext = asset.url.pathExtension if #available(macOS 11.0, *) { let typeIdentifier = UTType(filenameExtension: ext) provider = FilePromiseProvider(fileType: typeIdentifier!.identifier, delegate: self) provider.userInfo = [FilePromiseProvider.UserInfoKeys.url: asset.url as Any] } else { let typeIdentifier = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, ext as CFString, nil) provider = FilePromiseProvider(fileType: typeIdentifier!.takeRetainedValue() as String, delegate: self) } return provider } return nil } } extension ResultsViewController: NSFilePromiseProviderDelegate { func operationQueue(for filePromiseProvider: NSFilePromiseProvider) -> OperationQueue { return filePromiseQueue } // Never called. Why not? func filePromiseProvider(_ provider: NSFilePromiseProvider, fileNameForType fileType: String) -> String { let asset = assetFromFilePromiseProvider(provider: provider) let fileName = (asset?.url.lastPathComponent)! return (asset?.url.lastPathComponent)! } func filePromiseProvider(_ provider: NSFilePromiseProvider, writePromiseTo url: URL, completionHandler: @escaping (Error?) -> Void) { do { if let asset = assetFromFilePromiseProvider(provider: provider) { /** Copy the file to the location provided to us. We always do a copy, not a move. It's important you call the completion handler. */ try FileManager.default.copyItem(at: asset.url, to: url) } completionHandler(nil) } catch let error { OperationQueue.main.addOperation { self.presentError(error, modalFor: self.view.window!, delegate: nil, didPresent: nil, contextInfo: nil) } completionHandler(error) } } func assetFromFilePromiseProvider(provider: NSFilePromiseProvider) -> MyFile? { var returnAsset: MyFile? if let userInfo = provider.userInfo as? [String: Any], let row = userInfo[FilePromiseProvider.UserInfoKeys.rowNumber] as? Int { if let assets = filesArrayController.arrangedObjects as? [MyFile] { returnAsset = assets[row] } } return returnAsset } }
目前功能在部分场景正常:
- 拖拽至Finder可完成文件复制
- 拖拽至打开/保存对话框可切换文件夹或文件名
- 拖拽至Terminal.app可插入文件路径
但拖拽至TextEdit或iTerm 2时无任何效果,目标窗口无变化。而从Finder或Panic的Transmit拖拽文件至这些应用却能正常插入路径,说明我的实现存在问题。
我通过Pasteboard Viewer查看了拖拽项的类型标识符:
| 类型标识符 | 值 |
|---|---|
com.apple.NSFilePromiseItemMetaData | 二进制plist,显示public.jpeg |
com.apple.pasteboard.promised-file-name | 空 |
com.apple.pasteboard.promised-suggested-file-name | 空 |
com.apple.pasteboard.promised-file-content-type | public.jpeg |
com.apple.pasteboard.NSFilePromiseID | 每个拖拽项唯一 |
public.utf8-plain-text | 示例:/path/to/file name.jpg |
public.file-url | 示例:file:///path/to/file%20name.jpg |
dyn.xxxxx | 两个此类类型,分别为56字节和0字节,未知用途 |
com.apple.pasteboard.promised-file-url | 空 |
我还发现:
filePromiseProvider(_:fileNameForType:)方法从未被调用promised-file-name类型值为空(但Panic的实现中该值也为空,可能属正常情况)- 拖拽时未显示正确的复制指针(Finder拖拽也无此指针但功能正常,Transmit拖拽时会显示绿色复制指针)
- 我在
tableView(:validateDrop:proposedRow:proposedDropOperation:)中返回了.copy,但该代理方法仅在拖放目标为表格自身时才会被调用
请问我哪里出错了?
1. 核心逻辑错误:assetFromFilePromiseProvider无法正确获取文件对象
你在FilePromiseProvider的userInfo中存储的是url,但在assetFromFilePromiseProvider里却尝试读取不存在的rowNumber,这会导致所有依赖该方法的逻辑失效,包括fileNameForType和文件复制操作。
修复:
修改tableView(_:pasteboardWriterForRow:)中设置userInfo的代码,同时存入行号:
provider.userInfo = [ FilePromiseProvider.UserInfoKeys.url: asset.url as Any, "rowNumber": row // 新增行号存储 ]
2. 未支持多行拖拽
当前实现仅处理单行拖拽,当表格选中多行时,需要返回包含多个NSFilePromiseProvider的数组,而非单个provider。
修复:
更新tableView(_:pasteboardWriterForRow:)方法,支持多选行:
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? { guard let assets = filesArrayController.arrangedObjects as? [MyFile] else { return nil } let selectedRows = tableView.selectedRows // 处理多行拖拽 if selectedRows.count > 1 { let providers = selectedRows.map { row -> FilePromiseProvider in let asset = assets[row] let ext = asset.url.pathExtension let typeIdentifier: String if #available(macOS 11.0, *) { typeIdentifier = UTType(filenameExtension: ext)!.identifier } else { let utType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, ext as CFString, nil) typeIdentifier = utType!.takeRetainedValue() as String } let provider = FilePromiseProvider(fileType: typeIdentifier, delegate: self) provider.userInfo = [ FilePromiseProvider.UserInfoKeys.url: asset.url as Any, "rowNumber": row ] return provider } return NSArray(array: providers) as NSPasteboardWriting } // 单行拖拽逻辑 else { let asset = assets[row] let ext = asset.url.pathExtension let typeIdentifier: String if #available(macOS 11.0, *) { typeIdentifier = UTType(filenameExtension: ext)!.identifier } else { let utType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, ext as CFString, nil) typeIdentifier = utType!.takeRetainedValue() as String } let provider = FilePromiseProvider(fileType: typeIdentifier, delegate: self) provider.userInfo = [ FilePromiseProvider.UserInfoKeys.url: asset.url as Any, "rowNumber": row ] return provider } }
3. 拖拽类型优先级问题
TextEdit和iTerm会优先读取public.utf8-plain-text或public.file-url,但你当前是将这两个类型追加到默认类型列表末尾,目标应用可能优先读取文件promise类型而非路径字符串。
修复:
调整writableTypes中类型的顺序,将字符串和文件URL类型移到开头:
override func writableTypes(for pasteboard: NSPasteboard) -> [NSPasteboard.PasteboardType] { var types = super.writableTypes(for: pasteboard) // 将字符串和文件URL类型放在最前面,确保目标应用优先读取 types.insert(contentsOf: [.string, .fileURL], at: 0) return types }
4. 拖拽指针显示问题
表格视图作为拖拽源时,需要通过专门的代理方法设置操作掩码,而非仅在内部拖放时设置。
修复:
添加拖拽会话操作掩码设置方法:
func tableView(_ tableView: NSTableView, draggingSession session: NSDraggingSession, sourceOperationMaskForDraggingContext context: NSDraggingContext) -> NSDragOperation { return .copy // 明确设置为复制操作,部分应用会显示对应的复制指针 }
关于fileNameForType未被调用的说明
该方法仅在目标应用主动请求文件promise的文件名时才会触发,TextEdit/iTerm这类应用直接读取了路径字符串类型,因此不会调用这个方法。修复上述逻辑后,若有应用需要该方法,也能正确返回结果。
内容的提问来源于stack exchange,提问作者Nick K9

