😵‍💫

Swift + macOSでXPCの呼び出しでコールバック形式の返値やErrorを投げても正しく判定できない問題

2024/03/21に公開

macOS/iOSにはXPCというプロセス間通信の仕組みが用意されており、Swift/Objective-Cのコードから NSXPCConnection を使って通信できます(より低レベルなAPIであるlibXPCも用意されていますが、今回は省略します)。 macOSのSandboxを使用しているアプリでは呼び出し元のプロセスとは異なる権限をApp Sandbox Entitlementで設定できるため、特別な権限をXPCにだけ与えることができます。

この記事ではメインでSwiftを使ったmacOSプログラムでXPCを使っていて遭遇した次の2つの問題を紹介します。

  1. (解決済) クロージャによるコールバックによる返答を受け取る形式でXPC呼び出しをしてもクロージャが呼び出されない
  2. enumで定義したErrorを呼び出し先から投げると呼び出し元で正しく比較できない

実験は以下の環境で行いました。

  • macOS 14.4 (Apple Silicon)
  • Xcode 15.3

実験に使用したソースコードを以下に公開しています。ライセンスはApache 2.0です。

https://github.com/mtgto/example-nsxpc-throws-error

(解決済) クロージャによるコールバックによる返答を受け取る形式でXPC呼び出しをしてもクロージャが呼び出されない

XcodeでXPC形式のターゲットを追加すると作成されるサンプルコードにはクロージャコールバックによるXPC呼び出しされるメソッドのサンプルが入っています。このメソッドは2つの整数を受け取り、受け取ったクロージャにその和を渡すだけのシンプルなものです。

このコードはコンパイルが通りますが、XPC実行しても呼びだされる側 (受信側) では処理が始まりますが、呼び出し先でクロージャを呼んでも呼び出し側 (送信側) のクロージャはなぜか呼び出されません。

受信する側

import Foundation

@objc public protocol ExampleXpcProtocol {
    // XcodeでTarget追加→XPCを選ぶと追加されるテンプレートにあるサンプルコード
    func performCalculation(firstNumber: Int, secondNumber: Int, with reply: @escaping (Int) -> Void)
}

class ExampleXpc: NSObject, ExampleXpcProtocol {
    @objc func performCalculation(firstNumber: Int, secondNumber: Int, with reply: @escaping (Int) -> Void) {
        let response = firstNumber + secondNumber
        reply(response)
    }
}

送信する側

let service = NSXPCConnection(serviceName: "net.mtgto.example-nsxpc-throws-error.ExampleXpc")
service.remoteObjectInterface = NSXPCInterface(with: ExampleXpc.ExampleXpcProtocol.self)
service.activate()

guard let proxy = service.remoteObjectProxy as? any ExampleXpcProtocol else { return }
// 2024-04-15追記: コールバックで受け取る場合、deferでNSXPCConnection#invalidateしてはいけない。またXPCはコストが高いためそもそもinvalidateはしょっちゅう呼ぶ必要がない
defer {
    service.invalidate()
}
proxy.performCalculation(firstNumber: 100, secondNumber: 200) { sum in
	// いつまでもここには来ない
	print("SUM: \(sum)")
}

これはコールバッククロージャで返値を渡すのではなくasync/awaitに変更することでなぜか解決します。

2024-04-15追記: Apple Developer Forumでの回答では「XPCでSwift Concurrencyを使うのはおすすめできない。コールバックで受け取るほうがよい」ともらったので、async/awaitで書くことはおすすめしません。

受信する側 (async)

import Foundation

@objc public protocol ExampleXpcProtocol {
    // XcodeでTarget追加→XPCを選ぶと追加されるテンプレートにあるサンプルコード
    func performCalculation(firstNumber: Int, secondNumber: Int) async -> Int
}

class ExampleXpc: NSObject, ExampleXpcProtocol {
    @objc func performCalculation(firstNumber: Int, secondNumber: Int) async -> Int {
        return firstNumber + secondNumber
    }
}

送信する側 (async)

Task {
	let service = NSXPCConnection(serviceName: "net.mtgto.example-nsxpc-throws-error.ExampleXpc")
	service.remoteObjectInterface = NSXPCInterface(with: ExampleXpcProtocol.self)
	service.activate()
	guard let proxy = service.remoteObjectProxy as? any ExampleXpcProtocol else { return }
	defer {
		service.invalidate()
	}
	let sum = await proxy.performCalculation(firstNumber: 100, secondNumber: 200)
	print("SUM: \(sum)")
}

enumで定義したErrorを呼び出し先から投げると呼び出し元で正しく比較できない

XPCの引数や返値はプロセス間通信で受け渡しが起きるため、NSSecureCodingプロトコルを実装した値でなければ渡すことができません。

Each method must have a return type of void, and all parameters to methods or reply blocks must be either:

ところでさっきの例でXPCにクロージャの代わりにasyncを使用しましたが、ここでthrowsを指定することができます。

呼び出し側 (送信側) では do-catchを使うことでこの例外をキャッチすることができます。
ところがその例外をXPC用のターゲットのエラーと比較することができません。

import Foundation

public enum ExampleXpcError: Error {
    case example
}

@objc public protocol ExampleXpcProtocol {
    func performThrowsError() async throws
}

class ExampleXpc: NSObject, ExampleXpcProtocol {
    @objc func performThrowsError() async throws {
        throw ExampleXpcError.example
    }
}
Task {
	let service = NSXPCConnection(serviceName: "net.mtgto.example-nsxpc-throws-error.ExampleXpc")
	service.remoteObjectInterface = NSXPCInterface(with: ExampleXpcProtocol.self)
	service.activate()
	guard let proxy = service.remoteObjectProxy as? any ExampleXpcProtocol else { return }
	defer {
		service.invalidate()
	}
	do {
		try await proxy.performThrowsError()
	} catch {
		if let error = error as? ExampleXpcError {
			// こっちにきてほしいけどきてくれない
			print("ExampleXpcError is throwed")
		} else {
			let nserror = error as NSError
			// "domain = ExampleXpc.ExampleXpcError, code = 0, userInfo = [:]"
			print("domain = \(nserror.domain), code = \(nserror.code), userInfo = \(nserror.userInfo)")
		}
	}
}

catchの中でブレークポイントで止めてerrorの情報をみてみると、domain = ExampleXpc.ExampleXpcError, code = 0, userInfo = [:] が設定されたNSErrorであることはわかりますが、なぜか error as? ExampleXpcError で判定できず、ExampleXpcErrorだったという情報は消えてしまっているようです。

とりあえずcatchしたerrorのdomain, codeとenum Errorに自動で採番されるdomain, codeを比較することで判定して対処しようかなと思っていますが、なにかいい解決方法をご存じだったらどなたか教えてください。

Discussion