---
# System prepended metadata

title: 給 NSTableView 新增對 App Bundle 檔案的拽入辨識功能

---

# 給 NSTableView 新增對 App Bundle 檔案的拽入辨識功能

威注音輸入法 3.3.8 原訂加入一個功能，但這個功能推遲到 3.3.9 版了。寫這篇文章時，3.3.9 版還正在開發。

這個功能是：允許使用者將 Finder 視窗內的 App Bundle 檔案直接拽入至輸入法的客體管理視窗內，藉由此方式迅速登記要管理的客體。

這個功能的設計目的，源於一個苦衷：

> 任何輸入法在 macOS 10.9 - 10.11 系統下，只要當前的輸入方式是該輸入法，那麼就會在藉由該輸入法本身點開 ComDlg32 檔案視窗（也就是系統公用的檔案開啟/存檔視窗）的時候出現「輸入法無限 hang，且輸入法從現在開始沾過的任何客體軟體都會跟著 hang」這樣一種災難狀況，除非你 SSH 連到這台電腦上用終端指令 `killall vChewing` 幹掉輸入法的執行緒、或者你切換到其它輸入方式之後等上幾分鐘讓 hang 狀態自動解除。
>
> 本人沒有測試過 macOS 10.12 是否有這問題，只知道 macOS 10.13 開始是沒有問題的。因為這個問題，只能忍痛對專門給 macOS 10.9 - 10.12 的 [威注音 Aqua 特別版](https://github.com/vChewing/vChewing-OSX-Legacy/releases) 閹割了「磁帶模式」和「使用者辭典目錄自訂」的功能，以防止使用者踩到這個地雷。（當然，也還有與 Sandbox Bookmark 特性支援有關的原因，這裡先不繼續扯下去了。）

簡要講一下實現條件與原理。條件如下：

1. NSTableView 要有被指派 DataSource 物件：
```myNSTableViewInstance.dataSource = XXX```
2. 這個 DataSource 物件有遵循 NSTableViewDataSource 協定，且針對這個 DataSource 物件實作兩個與此有關的 NSTableViewDataSource 協定函式。
3. 在 .windowDidLoad() / .viewDidLoad() 等合適的時候正確登記 NSTableView 的 `.registerForDraggedTypes`。

先提第三點，登記方式有兩種。第一種是可以用給 macOS 10.9 的：
```swift
// 這裡的 .init 是 NSPasteboard.PasteboardType 的建構子 (Constructor)。
tblClients.registerForDraggedTypes([.init(rawValue: kUTTypeFileURL as String)])
// macOS 10.13 開始的寫法（語法糖）：
tblClients.registerForDraggedTypes([.fileURL])
```
同理，稍後我們要講的第二點當中也會用到類似的參數，但只有一個寫法：
```swift
// 這裡的 .init 是 NSPasteboard.PasteboardType 的建構子：
.init(rawValue: kUTTypeApplicationBundle as String)
```
（於是要開始講第二點了…）這個參數要用在第二點當中提到的兩個協定函式實作過程當中。

> 下述程式碼當中的 NSTableView 的副本被命名為 tblClients，且其 NSWindowController 本身被定義為 tblClients 的 dataSource、遵循 NSTableViewDataSource 協定。

由於第一個函式與第二個函式存在著一定篇幅的共用內容，所以我們將這一部分抽象出來、且給定 lambda expression 特性。
```swift=
/// 檢查傳入的 NSDraggingInfo 當中的 URL 對應的物件是否是 App Bundle。
/// - Parameters:
///   - info: 傳入的 NSDraggingInfo 物件。
///   - onError: 當不滿足判定條件時，執行給定的 lambda expression。
///   - handler: 當滿足判定條件時，讓傳入的 lambda expression 處理已經整理出來的 URL 陣列。
private func validatePasteboardForAppBundles(
  neta info: NSDraggingInfo, onError: @escaping () -> Void?, handler: (([URL]) -> Void)? = nil
) {
  let board = info.draggingPasteboard
  let type = NSPasteboard.PasteboardType(rawValue: kUTTypeApplicationBundle as String)
  let options: [NSPasteboard.ReadingOptionKey: Any] = [
    .urlReadingFileURLsOnly: true,
    .urlReadingContentsConformToTypes: [type],
  ]
  guard let urls = board.readObjects(forClasses: [NSURL.self], options: options) as? [URL], !urls.isEmpty else {
    onError()
    return
  }
  if let handler = handler {
    handler(urls)
  }
}
```

其實也不是非得用 lambda expression，筆者只是為了節省篇幅而已。

接下來是第一個實作函式，用來決定 NSTableView 是否要對拽給自己的 Finder 內容物件做出響應。可以在這裡規定要給哪些物件做出響應。

```swift=
func tableView(
  _: NSTableView, validateDrop info: NSDraggingInfo, proposedRow _: Int,
  proposedDropOperation _: NSTableView.DropOperation
) -> NSDragOperation {
  var result = NSDragOperation.copy
  validatePasteboardForAppBundles(
    neta: info, onError: { result = .init(rawValue: 0) } // 對應 NSDragOperationNone。
  )
  return result
}
```

接下來是第二個實作函式，用來規定：在鬆開滑鼠按鍵時，該怎樣處理那些被拽入進來的物件。

```swift=
func tableView(
  _: NSTableView, acceptDrop info: NSDraggingInfo,
  row _: Int, dropOperation _: NSTableView.DropOperation
) -> Bool {
  var result = true // 對外結果記錄：操作是否成功。
  validatePasteboardForAppBundles(
    neta: info, onError: { result = false } // 對應 NSDragOperationNone。
  ) { theURLs in
    var dealt = false // 操作成功狀態記錄器，有成功操作至少一次的話才設為 true。
    theURLs.forEach { url in
      // 先看是不是真的能當作 Application Bundle 來讀取、且能讀到 bundleID。
      guard let bundle = Bundle(url: url), let bundleID = bundle.bundleIdentifier else { return }
      // 然後就是拿這個 bundleID 執行自己自訂的操作。
      self.applyNewValue(bundleID, highMitigation: true)
      // 記錄這次成功的操作。
      dealt = true
    }
    // 根據上文是否有成功操作，決定對外結果記錄的內容。
    result = dealt
  }
  defer { if result { tblClients.reloadData() } }
  return result
}
```

這樣就應該算是完成了。如果還有故障的話，那一定是有其它地方出錯。

P.S.: 該方法與 App Sandbox 相容。

$ EOF.

---
# English ver.

I solved this. Prerequisites:
1. NSTableView instance must have its DataSource object assigned:
```swift myNSTableViewInstance.dataSource = XXX ```
2. The DataSource object conforms to NSTableViewDataSource protocol, having two necessary functions implemented (will discuss below).
3. Whenever appropriate (e.g.: .windowDidLoad(), .viewDidLoad(), etc.), register the `.registerForDraggedTypes` for NSTableView instance.4.

In the prerequisite 3, the registration method is:
```swift
// Constructing the NSPasteboard.PasteboardType.
tblClients.registerForDraggedTypes([.init(rawValue: kUTTypeFileURL as String)])  // Compatible with macOS 10.9 Mavericks.
tblClients.registerForDraggedTypes([.fileURL])  // Since macOS 10.13 High Sierra.
```
In the prerequisite 2, the registration method is:
```swift
// Constructing the NSPasteboard.PasteboardType.
.init(rawValue: kUTTypeApplicationBundle as String)
```
We start implementing. Suppose that (in this case):
1. the DataSource object is the WindowController itself.
2. the name of the NSTableView instance is `tblClients`.

Since the two functions mentioned above share a lot of things, we extract them as a standalone function with lambda expressions:
```swift=
/// See whether the incoming NSDraggingInfo is an array of App Bundles.
/// - Parameters:
///   - info: the incoming NSDraggingInfo object.
///   - onError: On error perform this given lambda expression.
///   - handler: When conditions are met, perform this given lambda expression to process the URL array.
private func validatePasteboardForAppBundles(
  neta info: NSDraggingInfo, onError: @escaping () -> Void?, handler: (([URL]) -> Void)? = nil
) {
  let board = info.draggingPasteboard
  let type = NSPasteboard.PasteboardType(rawValue: kUTTypeApplicationBundle as String)
  let options: [NSPasteboard.ReadingOptionKey: Any] = [
    .urlReadingFileURLsOnly: true,
    .urlReadingContentsConformToTypes: [type],
  ]
  guard let urls = board.readObjects(forClasses: [NSURL.self], options: options) as? [URL], !urls.isEmpty else {
    onError()
    return
  }
  if let handler = handler {
    handler(urls)
  }
}
```

Finally, the two implementations.

1. The one to check whether the dragged file object is the one we need to let the NSTableView response to:
```swift=
func tableView(
  _: NSTableView, validateDrop info: NSDraggingInfo, proposedRow _: Int,
  proposedDropOperation _: NSTableView.DropOperation
) -> NSDragOperation {
  var result = NSDragOperation.copy
  validatePasteboardForAppBundles(
    neta: info, onError: { result = .init(rawValue: 0) } // NSDragOperationNone.
  )
  return result
}
```

2. The handling process once the mouse click releases:

```swift=
func tableView(
  _: NSTableView, acceptDrop info: NSDraggingInfo,
  row _: Int, dropOperation _: NSTableView.DropOperation
) -> Bool {
  var result = true // Outer report: Operation successful or not.
  validatePasteboardForAppBundles(
    neta: info, onError: { result = false } // NSDragOperationNone.
  ) { theURLs in
    var dealt = false // Inner report. Will be true if at least one URL entry is processed with successful result.
    theURLs.forEach { url in
      // See whether this is an app bundle with valid bundleID:
      guard let bundle = Bundle(url: url), let bundleID = bundle.bundleIdentifier else { return }
      // Performing customized operations toward this bundleID string:
      self.applyNewValue(bundleID, highMitigation: true)
      // Mark this operation as "successful".
      dealt = true
    }
    // Tell the outer report that whether we did at least one successful operation.
    result = dealt
  }
  defer { if result { tblClients.reloadData() } }
  return result
}
```

This should work unless there are other things screwed up.

$ EOF.