MacOSX XPC 入门

一、简介

  • XPC 是一种 OS X 进程间通信技术,通过权限分离机制来对应用沙箱机制做了一个补充。其中,权限分离是根据每个部分所需的系统资源访问将应用程序分成多个部分,每个部分可以使用提前声明的权限(沙箱)。这种单个组件称为XPC 服务

    将应用程序分成多个部分,还可以提高程序的可靠性,防止程序的部分代码崩溃导致整个程序的退出。

  • 每个 XPC 服务都位于自己的沙箱,即 XPC 服务有自己的容器一组权限。包含在应用程序中 XPC 服务只能由应用程序自己访问。当应用程序启动时,系统会自动将它找到的每个 XPC 服务注册到应用程序可见的命名空间中。之后应用程序便可以与 XPC 服务通信并执行请求。

  • XPC 服务的特点:权限分离 + 错误隔离

  • XPC 服务有 launchd 所管理,当 XPC 服务被意外终止(或者崩溃)后,该服务将会被 launchd 重启。

二、XPC Service 使用入门

由于网上的例子中 Object-C 的例子较多,而 C 语言的 XPC 例子较少,因此这里也用 Object-C 学习 XPC。

虽然还没学 Object-C 还不大会…

1. 创建项目

打开 XCode,新建项目,选择 XPC Service。

image-20220103163708457

之后输入 Product Name 和 Organization Identifier,最后的 Bundle Identifier 将会生成一个反向 DNS 名称格式的字符串。这个 Bundle ID 有大用,最好设置成应用程序的 subdomain(子域名),不过这里先忽略。

image-20220103163952631

之后,XCode 将会存放一个 XPC 的示例代码,功能类似于 echo server。

接下来我们将慢慢研究这个示例代码,并顺带学习一下 Objective-c。

要是对 Objective-C 不太熟就对着这个看 Objective-C 基础知识 - 菜鸟教程

2. Service 简单示例

a. protocol

在使用 XPC 前,必须先声明一个接口(interface)。接口主要有协议(Protocol)组成,描述了应该在远程进程中调用哪些方法。

以下是 XCode 自生成的 protocol 声明。这里声明了一个名为 XPCDemoProtocol 的协议,同时还定义了一个 upperCaseString 的接口函数:

protocol 个人感觉有点类似于 C++ 中的虚类,不实现任何函数,只是简单的定义函数接口。

1
2
3
4
5
6
7
8
9
10
11
// XPCDemoProtocol.h

#import <Foundation/Foundation.h>

// The protocol that this service will vend as its API. This header file will also need to be visible to the process hosting the service.
@protocol XPCDemoProtocol

// Replace the API of this protocol with an API appropriate to the service you are vending.
- (void)upperCaseString:(NSString *)aString withReply:(void (^)(NSString *))reply;

@end

protocol 主要用于限制调用程序XPC 服务之间的编程接口。所有需要在调用程序中调用的方法必须在 protocol 中指定。需要注意的是:XPC 通信是异步的,因此 protocol 中的方法的返回值都只能是 void,如果需要返回数据则使用返回块,即正如上面代码中 upperCaseString 函数的第二个参数,类似于 callback。(什么是块?

b. interface

声明完 protocol 后,我们需要实现一个描述它的接口。因此这里的代码声明了 XPCDemo 类,继承自该 protocol:

1
2
3
4
5
6
7
8
//  XPCDemo.h

#import <Foundation/Foundation.h>
#import "XPCDemoProtocol.h"

// This object implements the protocol which we have defined. It provides the actual behavior for the service. It is 'exported' by the service to make it available to the process hosting the service over an NSXPCConnection.
@interface XPCDemo : NSObject <XPCDemoProtocol>
@end

并实现类功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
//  XPCDemo.m

#import "XPCDemo.h"

@implementation XPCDemo

// This implements the example protocol. Replace the body of this class with the implementation of this service's protocol.
- (void)upperCaseString:(NSString *)aString withReply:(void (^)(NSString *))reply {
NSString *response = [aString uppercaseString];
reply(response);
}

@end

上面的代码主要做了两件事情:

  1. 定义一个 protocol,即远程进程可以调用的函数接口
  2. 创建一个继承自该 protocol 的类,并实现这些函数接口。

这里的 upperCaseString 函数只做了一件事情:将传入的字符串全部转换为大写,并调用 callback 将结果返回

c. NSXPCListener

看上去还挺好理解,那就继续看看 main 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
int main(int argc, const char *argv[])
{
// Create the delegate for the service.
ServiceDelegate *delegate = [ServiceDelegate new];

// Set up the one NSXPCListener for this service. It will handle all incoming connections.
NSXPCListener *listener = [NSXPCListener serviceListener];
listener.delegate = delegate;

// Resuming the serviceListener starts this service. This method does not return.
[listener resume];
return 0;
}

main 函数中创建了一个 NSXPCListener 类,并设置 listener 的委托,之后执行 resume 函数。

看上去有点不明觉厉,找了下 NSXPCListener 的类声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Each NSXPCListener instance has a private serial queue. This queue is used when sending the delegate messages.
API_AVAILABLE(macos(10.8), ios(6.0), watchos(2.0), tvos(9.0))
@interface NSXPCListener : NSObject

// If your listener is an XPCService (that is, in the XPCServices folder of an application or framework), then use this method to get the shared, singleton NSXPCListener object that will await new connections. When the resume method is called on this listener, it will not return. Instead it hands over control to the object and allows it to service the listener as appropriate. This makes it ideal for use in your main() function. For more info on XPCServices, please refer to the developer documentation.
+ (NSXPCListener *)serviceListener;

[...]

// The delegate for the connection listener. If no delegate is set, all new connections will be rejected. See the protocol for more information on how to implement it.
@property (nullable, weak) id <NSXPCListenerDelegate> delegate;

[...]

// All listeners start suspended and must be resumed before they will process incoming requests. If called on the serviceListener, this method will never return. Call it as the last step inside your main function in your XPC service after setting up desired initial state and the listener itself. If called on any other NSXPCListener, the connection is resumed and the method returns immediately.
- (void)resume;

// Suspend the listener. Suspends must be balanced with resumes before the listener may be invalidated.
- (void)suspend;

// Invalidate the listener. No more connections will be created. Once a listener is invalidated it may not be resumed or suspended.
- (void)invalidate;

@end

可以看到,

  • 对于 XPCService 而言,serviceListener 属性是 XPCService 用于监听 XPC connection 的监听器。
  • 当有新 XPC 连接到来时,连接将通过所设置的 delegate 进行处理。
  • 在 XPC Service 初始执行并完成一系列初始化步骤后,调用 listener 的 resume 方法以开始提供 XPC 服务,该方法将不会返回

d. NSXPCListenerDelegate

main 函数现在理解的差不多了,现在研究一下 NSXPCListenerDelegate,以下是它的协议声明:

1
2
3
4
5
6
@protocol NSXPCListenerDelegate <NSObject>
@optional
// Accept or reject a new connection to the listener. This is a good time to set up properties on the new connection, like its exported object and interfaces. If a value of NO is returned, the connection object will be invalidated after this method returns. Be sure to resume the new connection and return YES when you are finished configuring it and are ready to receive messages. You may delay resuming the connection if you wish, but still return YES from this method if you want the connection to be accepted.
- (BOOL)listener:(NSXPCListener *)listener shouldAcceptNewConnection:(NSXPCConnection *)newConnection;

@end

该协议中声明了一个可选实现的 listener 接口。这个接口的参数分别为:

  • listenerNSXPCListener 类型*,
  • newConnectionNSXPCConnection 类型*,新传入的连接

返回值是 BOOL 类型,可选值为 YESNO

Objective-C 还有两种布尔类型,分别是 bool (true, false)Boolean (TRUE, FALSE)

该函数用于为新连接设置属性时所执行的函数,类似于处理。该函数可以选择接收或者拒绝传入的连接,并且还可以自由选择什么时候恢复连接。我们再来看看该函数默认生成所执行的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//  main.m

#import <Foundation/Foundation.h>
#import "XPCDemo.h"

@interface ServiceDelegate : NSObject <NSXPCListenerDelegate>
@end

@implementation ServiceDelegate

- (BOOL)listener:(NSXPCListener *)listener shouldAcceptNewConnection:(NSXPCConnection *)newConnection {
// This method is where the NSXPCListener configures, accepts, and resumes a new incoming NSXPCConnection.

// Configure the connection.
// First, set the interface that the exported object implements.
newConnection.exportedInterface = [NSXPCInterface interfaceWithProtocol:@protocol(XPCDemoProtocol)];

// Next, set the object that the connection exports. All messages sent on the connection to this service will be sent to the exported object to handle. The connection retains the exported object.
XPCDemo *exportedObject = [XPCDemo new];
newConnection.exportedObject = exportedObject;

// Resuming the connection allows the system to deliver more incoming messages.
[newConnection resume];

// Returning YES from this method tells the system that you have accepted this connection. If you want to reject the connection for some reason, call -invalidate on the connection and return NO.
return YES;
}

@end

该函数将会为每个新连接设置其 exportedInterfaceexportedObject ,并恢复该连接,换句话说,该函数会在处理连接之前设置传入连接的两个成员。

至于这种设置是为了什么,我们需要再看看 NSXPCConnection 类的声明,以下是截取出的部分声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// This object is the main configuration mechanism for the communication between two processes. Each NSXPCConnection instance has a private serial queue. This queue is used when sending messages to reply handlers, interruption handlers, and invalidation handlers.
API_AVAILABLE(macos(10.8), ios(6.0), watchos(2.0), tvos(9.0))
@interface NSXPCConnection : NSObject <NSXPCProxyCreating>

[...]

// The interface that describes messages that are allowed to be received by the exported object on this connection. This value is required if a exported object is set.
@property (nullable, retain) NSXPCInterface *exportedInterface;

// Set an exported object for the connection. Messages sent to the remoteObjectProxy from the other side of the connection will be dispatched to this object. Messages delivered to exported objects are serialized and sent on a non-main queue. The receiver is responsible for handling the messages on a different queue or thread if it is required.
@property (nullable, retain) id exportedObject;

[...]

// All connections start suspended. You must resume them before they will start processing received messages or sending messages through the remoteObjectProxy. Note: Calling resume does not immediately launch the XPC service. The service will be started on demand when the first message is sent. However, if the name specified when creating the connection is determined to be invalid, your invalidation handler will be called immediately (and asynchronously) after calling resume.
- (void)resume;

[...]

@end

也就是说该函数实际是为每个新连接指定了处理连接的方法

  • exportedInterface:用于描述应向连接的另一端提供的方法
  • exportedObject:包含一个本地对象,用于处理来自连接另一端的方法调用

当应用程序调用 NSXPCConnection 上代理的方法时,应用程序的 NSXPCCoonnection 将调用存储在 exportedObject 类上的目标方法,即实现远程进程调用。

e. Info.plist

Info.plist 在 XPC Service 中承担着较为重要的一部分。XPC Service 要求在 Info.plist 中指定一些特殊的键值对,以下是其中的一些类型:

  • CFBundleIdentifier:指定当前 XPC Service 的反向 DNS 样式的服务名称字符串。应用程序将通过这串 BundleID 来访问 XPC 服务。

    还记得创建 XPC 服务项目时指定的 Bundle ID 么 :)

  • CFBundlePackageType:一个指定 Bundle Package 类型的字符串,XPC Service 中必须是 XPC!

  • XPCService:一个字典

    • EnvironmentVariables:字典类型,用于指定 XPC 服务运行时的环境变量。
    • JoinExistingSession:布尔值,表示 XPC 服务是否与调用方在同一个安全会话中运行。
    • RunLoopType:字符串,用于指定服务的 runloop 类型,默认是 dispatch_main;还有一种是 NSRunLoop

3. Client 简单示例

现在我们已经可以让 XPC Service 跑起来了,现在需要编写一个程序来使用 XPC Service。XPC Service 默认模板中提供了如下的 client 代码,它将发送一串字符给 XPC service 并将返回的结果输出:

  • 创建 XPC 连接:

    1
    2
    3
    NSXPCConnection *_connectionToService = [[NSXPCConnection alloc] initWithServiceName:@"io.kiprey.github.XPCDemo"];
    _connectionToService.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(XPCDemoProtocol)];
    [_connectionToService resume];
  • 发送请求

    1
    2
    3
    4
    [[_connectionToService remoteObjectProxy] upperCaseString:@"hello" withReply:^(NSString *aString) {
    // We have received a response. Update our text field, but do it on the main thread.
    NSLog(@"Result string was: %@", aString);
    }];
  • 不需要连接时再来断开连接

    1
    [_connectionToService invalidate];

正如代码所示,

  1. Client 会使用 XPC Service 中的 Bundle ID 来查找并与 XPC Service 建立连接。

  2. 之后 Client 指定了 remoteObjectInterface 属性,以规范调用接口的类型。

  3. 接下来,恢复 XPC 连接,并通过 NSXPCConnection 对象中的 remoteObjectProxy 属性,间接且透明的调用 XPC Service 上的接口。当XPC Service 完成服务后,返回的信息会被异步输出至控制台。

  4. 最后,关闭 XPC 连接。

4. 启动 XPC Service & Client

需要特别说明一下如何使用 XPC Service,并让 Client 成功连接上(这个绕了我半天)。

a. 局部 XPC Service

即,将 XPC Service 内嵌进 App 中。

首先,建立一个 App:

坑点:不能是 Command Line Tool

因为 Command Line Tool 不具有类似 App 的结构,因此无法托管 XPC Service。

image-20220104171022403

之后,在接下来这个界面中选一个 Language 为 Objective-C 的 Interface,Interface 是 GUI 相关的暂时不用管:

image-20220104171411076

项目创建后,选择 File -> New -> Target,新建一个 XPC Service。注意到在新建的最后一步中会有一个 Embed in Application选项:

image-20220104171917464

这样,这个新建的 XPC Service 就会被内置进这个 Application 中:

image-20220104172028284

之后,为了简单,我们直接将 main.m 中的原始代码:

1
2
3
4
5
6
7
8
9
#import <Cocoa/Cocoa.h>

int main(int argc, const char * argv[]) {

@autoreleasepool {
// Setup code that might create autoreleased objects goes here.
}
return NSApplicationMain(argc, argv);
}

替换成如下调用 XPC 服务的代码,简单粗暴:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#import "XPCServiceProtocol.h"

int main(int argc, const char * argv[]) {
// Try connect to XPC Service
NSXPCConnection* _connectionToService = [[NSXPCConnection alloc] initWithServiceName:@"io.github.kiprey.XPCService"];
_connectionToService.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(XPCServiceProtocol)];
[_connectionToService resume];

// Try using XPC Service interface
[[_connectionToService remoteObjectProxy] upperCaseString:@"hello" withReply:^(NSString *aString) {
// We have received a response. Update our text field, but do it on the main thread.
NSLog(@"Result string was: %@", aString);
}];

// Wait for XPC Service response
NSLog(@"Sleep 5s...");
sleep(5);

[_connectionToService invalidate];

NSLog(@"Bye.");
}

需要注意的是:当调用者向 XPC Service 请求服务后,由于请求是异步执行的,因此执行到程序末尾后可能调用者还没有接收到 XPC Service 的返回结果,此时调用者需要等待千万不能立即调用 invalidate 方法。

调用 invalidate 方法将会立即终止连接,不会等到 XPC Service 返回信息后再终止连接。

之后先编译 XPCService,再编译 Client。以下是执行结果:

image-20220104180112370

b. 全局 XPC Service

上面那种方法简单说明了如何将 XPC Service 内嵌进 App 中并使用,启动和管理也较为方便。

但要是希望生成的 XPC Service 可以被任意程序调用,那该如何启动?

首先,编写一个 XPCDemo.plist,这种编写的 plist 称之为 launchd.plist。内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>io.kiprey.github.XPCDemo</string>
<key>Program</key>
<string>/Users/kiprey/Desktop/Mach_test/XPCDemo/Build/Products/Debug/XPCDemo.xpc/Contents/MacOS/XPCDemo</string>
<key>KeepAlive</key>
<true/>
<key>POSIXSpawnType</key>
<string>Interactive</string>
<key>MachServices</key>
<dict/>
</dict>
</plist>

其中指定了:

  1. Label即其他进程用于索引当前 XPC Service 的标签
  2. Program:待被启动的守护进程的路径
  3. KeepAlive:表示是否需要让 launchd 在该守护进程崩溃后重启

更多关于 lanchd.plist 的细节可以在 man launchd.plist 文档中找到,这里不再赘述。

之后,我们可以让 launchd 来启动并管理我们的 XPC Service

原先是想将 XPCDemo.plist 文件拷贝进 /System/Library/LaunchDaemons 文件夹下,但是执行 cp 操作时,提示 Read-only file system,即该目标文件夹不允许写入操作。无论是关闭 SIP 还是执行sudo mount -uw / 以修改根路径的挂载权限,都无法写入该文件夹下。其他方式也不想再折腾了,因此放弃将该 plist 文件拷贝进 System Launch Daemons 文件夹的打算。

这种错误可能是因为目标文件夹是 /System 打头的路径。

但我们仍然可以将 plist 复制进 /Library/LaunchDaemons 文件夹中。

但即便我们不将 plist 文件复制进 Launch Daemons 文件夹下,我们依然可以让 launchd 来启动我们的 XPC Service:

  • 首先,执行 chown 修改刚刚创建的 XPCDemo.plist 文件所有权

    1
    sudo chown root:wheel XPCDemo.plist
  • 之后执行以下命令,使 launchd 启动目标程序

    1
    sudo launchctl bootstrap system XPCDemo.plist
  • 当我们希望 launchd 关闭目标 XPC Service 时,执行以下命令

    1
    sudo launchctl bootout system XPCDemo.plist

当 launchd 开始管理我们的全局 XPC Service 后,如果该 XPC Service 异常崩溃,则 launchd 会每隔 10s 重启一次服务:

图中是之前测试时,XPCDemo 老是一开就挂,因此 Launchd 会每隔 10s 重启一次,并且一直重启下去。

log 查看命令:log show --predicate 'processID == 0' --last 1h | grep "XPC"

image-20220104202227521

需要注意的是,单独使用 XCode 的 XPC Service 项目编译出的程序无法直接执行,因此不能挂在 launchd 下面跑,必须参照 Signing a Daemon with a Restricted Entitlement 将 XPC Service 以类 app 形式编译出一个可执行文件来。

5. NSXPC 架构

查看下面这张图,我们可以看到上面 [ServiceDelegate listener] 函数所做的就是设置 NSXPC Service 这方的 Exported Object

img

而这张图说明了整个 XPC 通信的过程:

img

三、C-Stype XPC Service

当我们可以理解 Objective-C 的 XPC Service 后,C 风格的 XPC Service 也就更容易理解。

具体细节就不再赘述了,这里贴出两个 C-Stype XPC 的相关资料:

四、参考

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2020-2022 Kiprey
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~