🍁

NSServicesでFinderのコンテキストメニューからイベントを受け取る

2024/11/11に公開

ちゃんとした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 ???、LatnCyrlのようなスクリプトタグ
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>

NSSendTypesNSPasteboardTypeURLにしないでUTTypepublic.urlpublic.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を作ります(クラス名は実はどうでもいい)。

ServicesProvider.swift
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
        }
    }
}

AppDelegateServicesProviderを使います。

AppDelegate.swift
import AppKit

final class AppDelegate: NSObject, NSApplicationDelegate {
    func applicationDidFinishLaunching(_ notification: Notification) {
        NSApp.servicesProvider = ServicesProvider.shared
        NSUpdateDynamicServices()
    }
}

SwiftUIベースの場合はDelegateAdaptorAppDelegateを紐づけます。

App
import SwiftUI

@main
struct ServicesSampleApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

多言語対応

String CatalogファイルをServicesMenu.xcstringsというファイル名で作成すれば多言語対応に使えます。String Catalogの自動検出の仕組みには引っかからないのでマニュアルで登録する必要があります。NSMenuItemdefaultNSServiceDescriptionが多言語対応の対象です。
キーはそれぞれの<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