NSServicesでFinderのコンテキストメニューからイベントを受け取る
ちゃんとしたmacOSアプリでしばしば見られる、Finderのコンテキストメニューからイベントを受け取るやつありますよね。あれを実装します。
ファイルに対して右クリックして出てくるコンテキストメニュー
実装方法にはNSServicesを使う方法とFinder Sync Extensionを使う方法の2種類がありますが、今回は前者について具体的な実装パターンを紹介します。
開発環境
- Xcode 16
- Swift 6
- SwiftUIベース
下準備
Target > Info > Services
にアイテムを追加します。
Target > Info > Services
各項目
Role | Property List Key | Value |
---|---|---|
Method Name | NSMessage | ServicesProviderのメソッド名 |
Port Name | NSPortName | プロダクトネームを決め打ちで入れてOK |
Menu Item | NSMenuItem | コンテキストメニューに表示する文言 |
Send Types | NSSendTypes | サービスから受け取るデータの形式 |
Return Types | NSReturnTypes | サービスに返すデータの形式 |
指定するデータ形式にはUTType
のIDとNSPasteboardTypeが使えます。
Additional service propertiesのところは「Click here to add additional service properties」と書いてありますが、クリックしても何も追加できないので、基本的には自動的に追加されるInfo.plist
をいじりましょう。なお、Info.plist
を編集した後保存してもTarget > Info > Services
には反映されないので、一度プロジェクトのウインドウを閉じて再び開きましょう。
Info.plist
Additional service properties
Property List Key | Value |
---|---|
NSKeyEquivalent | コンテキストメニューのコマンドを呼び出すキーボードショートカット |
NSRequiredContext | サービスが表示されるタイミングを制限するための指定 |
NSRestricted | デフォルトNO、YESにするとSandBoxを逸脱する可能性があるという宣言になる |
NSSendFileTypes | サービスが操作できるファイル形式を指定する(UTTypeのIDのみ) |
NSServiceDescription | サービスの説明 |
NSTimeout | サービスの応答時間の上限(ミリ秒)、デフォルト30000ミリ秒 |
NSUserData | ServicesProviderの同名のメソッドを別の動作をさせたい場合に利用する |
NSRequiredContext
Property List Key | Value |
---|---|
NSApplicationIdentifier | 指定されたバンドルIDのみにサービスを表示させられる |
NSTextContent | テキストがどんな属性(URL、Date、Address、Email、FilePath)を含むか指定できる |
NSTextLanguage | 指定した言語のみにサービスを表示させられる |
NSTextScript | ???、Latn やCyrl のようなスクリプトタグ |
NSWordLimit | サービスが操作可能な文字列の長さの最大数を指定できる |
ファイル(複数可)を受け取る場合
画像や動画やPDFなどData
型になれるものを受け取る例です。
<dict>
<key>NSServices</key>
<array>
<dict>
<key>NSMenuItem</key>
<dict>
<key>default</key>
<string>Send Sample Event</string>
</dict>
<key>NSMessage</key>
<string>sampleEventHandler</string>
<key>NSPortName</key>
<string>$(PRODUCT_NAME)</string>
<key>NSSendTypes</key>
<array>
<string>NSPasteboardTypeURL</string>
</array>
<key>NSSendFileTypes</key>
<array>
<string>public.data</string>
</array>
<key>NSRequiredContext</key>
<dict>
<key>NSTextContent</key>
<string>FilePath</string>
</dict>
</dict>
</array>
</dict>
NSSendTypes
はNSPasteboardTypeURL
にしないでUTType
のpublic.url
やpublic.file-url
にするとなぜかフォルダも対象になってしまいます(これに気づくのに2日かかりました)。
フォルダを受け取る場合
要はファイルの時とNSSendFileTypes
を変えれば良いだけです。
<dict>
<key>NSServices</key>
<array>
<dict>
<key>NSMenuItem</key>
<dict>
<key>default</key>
<string>Send Sample Event</string>
</dict>
<key>NSMessage</key>
<string>sampleEventHandler</string>
<key>NSPortName</key>
<string>$(PRODUCT_NAME)</string>
<key>NSSendTypes</key>
<array>
<string>NSPasteboardTypeURL</string>
</array>
<key>NSSendFileTypes</key>
<array>
<string>public.directory</string>
</array>
<key>NSRequiredContext</key>
<dict>
<key>NSTextContent</key>
<string>FilePath</string>
</dict>
</dict>
</array>
</dict>
実装
ServicesProvider
を作ります(クラス名は実はどうでもいい)。
import AppKit
@MainActor final class ServicesProvider: NSObject {
static let shared = ServicesProvider()
// NSMessageで指定したものと同名にする
@objc func sampleEventHandler(
_ pboard: NSPasteboard,
userData: String,
error errorPointer: AutoreleasingUnsafeMutablePointer<NSString?>
) {
// サービスが選択された時の処理
// URLの配列を取る場合(NSTextContentにFilePathを指定しているからできる)
guard let urls = pboard.readObjects(forClasses: [NSURL.self]) as? [URL] else {
return
}
}
}
AppDelegate
でServicesProvider
を使います。
import AppKit
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.servicesProvider = ServicesProvider.shared
NSUpdateDynamicServices()
}
}
SwiftUIベースの場合はDelegateAdaptor
でAppDelegate
を紐づけます。
import SwiftUI
@main
struct ServicesSampleApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
多言語対応
String CatalogファイルをServicesMenu.xcstrings
というファイル名で作成すれば多言語対応に使えます。String Catalogの自動検出の仕組みには引っかからないのでマニュアルで登録する必要があります。NSMenuItem
のdefault
とNSServiceDescription
が多言語対応の対象です。
キーはそれぞれの<string>文言</string>
の文言そのままです。
デバッグ方法
NSServices
のデバッグは難しいです。
まず、macOS Sonoma以上だとサービス一覧は、設定アプリ > キーボード > キーボードショートカット > サービスにあります。ここでサービスが存在するかの確認と有効にするかどうかの切り替えができます。
Info.plist
を書き換えてビルドしても基本的にサービスがコンテキストメニューに表示される条件を反映してくれません。確実に反映させたい場合は、ビルドアーカイブがあれば削除し、クリーンビルドして、前の状態をもつInfo.plist
を含む.app
パッケージがコンピューター常に存在しないようにします。その上でコンテキストメニューを開き、もしもコマンドが存在する場合は選択します。すると開けるアプリがないとアラートが出ます。これをコマンドが存在しなくなるまで繰り返します。コマンドがどんなファイルやフォルダでも表示されなくなれば、サービスは完全に抹消されています。その状態でビルドすれば最新のInfo.plist
を反映させたサービスが登録されます。
受け取ったURLをViewに送る
import AppKit
import Combine
import SwiftUI
@MainActor final class ServicesProvider: NSObject {
static let shared = ServicesProvider()
private let urlsSubject = CurrentValueSubject<[URL], Never>([])
public var urlsPublisher: Publishers.Share<AnyPublisher<[URL], Never>>
override private init() {
urlsPublisher = urlsSubject.eraseToAnyPublisher().share()
super.init()
}
@objc func sampleEventHandler(
_ pboard: NSPasteboard,
userData: String,
error errorPointer: AutoreleasingUnsafeMutablePointer<NSString?>
) {
guard let urls = pboard.readObjects(forClasses: [NSURL.self]) as? [URL] else {
return
}
urlsSubject.send(urls)
}
}
typealias OnRequestSampleEventAction = ([URL]) -> Void
struct OnRequestSampleEvent: ViewModifier {
let action: OnRequestSampleEventAction
func body(content: Content) -> some View {
content.onReceive(ServicesProvider.shared.urlsPublisher) { urls in
if !urls.isEmpty { action(urls) }
}
}
}
public extension View {
func onRequestSampleEvent(perform action: @escaping (_ urls: [URL]) -> Void) -> some View {
modifier(OnRequestSampleEvent(action: action))
}
}
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
.onRequestSampleEvent { urls in
// urlsを使った処理
}
}
}
Discussion