上一篇文章 介绍了 DevTools 和 Webkit Debug Protocol 这两个 Web 开发利器的内部原理。本篇主要讲解 iOS 的 Safari 远程调试。
iOS 的 Safari 远程调试,是 iOS7 引入的新功能。它允许开发者通过桌面端 Safari 的调试工具远程检视移动端浏览器打开的页面。这套调试工具,彻底解决了无线开发纯靠 “alert” 的调试困境。Safari 的远程调试中使用到的调试协议,与 Google 开放的 Chrome Debug Protocol 有牵丝万缕的关系,体现了这两家互联网巨头相爱相杀的本质:
iOS7 以后,每个 iOS 都有一个 webinspectord 守护进程,负责远程调试的通讯。这个进程暴露了一个服务接口,供外部应用(例如桌面端的 Safari 调试工具)使用。
iOS 上所有的服务(文件浏览、消息推送、app 安装等)都是通过一个 lockdown 服务管理连接上的。
自然的,调试工具也需要透过 USB 接口,通过 lockdown 界面,连接到 webinspector 服务。
由于 iOS 的各种系统组件都极为神秘,没有更多可以解释的了。伟大的开源社区,通过各种手段实现了这些服务的接口,大家可以前去膜拜。
抛开 USB 通讯、lockdown 接口不谈,Safari 远程调试服务所使用的协议本身其实就是 Webkit 调试协议的二次包装。也就是共享了 Webkit 调试协议的大部分功能。
先分析这个协议里面的主体:
还有一些概念字段:
大体可以看出,这个调试服务的接口是有状态的。设备和 DevTools 建立连接后,拥有可以复用的链接作为后续通讯的通道。
假设我们需要发送一条调试指令到 iOS 上的某个 Webkit 内核,它的整个编码流程应该是是这样:
_rpc_forwardSocketData
所有的消息,都是以 Apple 自己定义的 RPC 消息提格式进行的。但它实际传输的有效数据,还是 Webkit
能够识别的指令。另外,Safari 没有如同 Chrome 那样,使用了 WebSocket 作为暴露出去的应用层协议。它选择了最基本的 Socket 通讯方式和 bplist 作为传输格式。
下面以一个具体的消息作为例子,来说说这整个过程。
假设我们要开启网络监控这个面板,需要发送一个 JSON 指令。指令序列化之后我们的到:
{"id":0,"method":"Network.enable"}
就像之前说的,Safari 不直接使用 JSON 字符串作为传输的序列化方案。Safari 远程调试协议有自己的 RPC 规范,所有的消息都都有 __selector
和 __arguments
两个字段。前者说明调用的方法,后者说明调用时的参数。
常见的一些方法(其实是 ObjC selector
的字符串表达)如下:
_rpc_reportIdentifier:
:向 webinspector 服务注册当前链接 (传输 connectionId )_rpc_getConnectedApplications:
:要求获取连接到 webinspector 的 iOS 应用列表_rpc_forwardGetListing:
:获取某个应用的页面列表(传输 connectionId, appId )_rpc_forwardSocketSetup:
:注册当前会话 (传输 connectionId、senderId )_rpc_forwardSocketData:
:利用某个会话传输数据(传输 connectionId、senderId、data )。Webkit 调试协议所传输的 JSON 就是通过这个方法传递的,JSON 字符串的二进制表达被通过这个接口传递到 iOS 设备上的调试服务。另一方面,iOS 端也会传过来很多消息,同样遵循基本消息提的格式,常见的 __selector
有:
_rpc_reportConnectedApplicationList:
:回报连接到 webinspector 的应用列表_rpc_applicationSentListing:
:回报某个应用的页面列表_rpc_applicationConnected:
:某个 iOS 应用连接到了调试服务_rpc_applicationDisconnected:
:某个 iOS 应用从调试服务断开Safari 不选择 WebSocket 作为传输协议应该是从安全性、复杂性的角度去考虑。但选择 bplist 作为传输格式,应该没有太多理由,大概因为 Apple 体系内部都是用 bplist 的。
plist 和 bplist 都是 Apple 的通讯格式。其中 plist 非常常见。加入你做过 iOS 或者 Mac 开发,你一定写过不少 plist。plist 就是一种拥有自有 DTD 的 XML 文档类型。说白了,它就是 XML 文档。
例如之前的 JSON 指令,转换为 Safari 调试协议能够理解的 plist 文档:
<?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>__selector</key>
<string>_rpc_forwardSocketData:</string>
<key>__argument</key>
<dict>
<key>WIRConnectionIdentifierKey</key>
<string>e0e68c53-5cc9-4dd4-9ebb-a7e69e98ef74</string>
<key>WIRApplicationIdentifierKey</key>
<string>PID:1300</string>
<key>WIRPageIdentifierKey</key>
<integer>1</integer>
<key>WIRSenderKey</key>
<string>50c2e189-a91f-4df5-b33a-741225e9bd85</string>
<key>WIRSocketDataKey</key>
<data>eyJpZCI6MCwibWV0aG9kIjoiTmV0d29yay5lbmFibGUifQ==</data>
</dict>
</dict>
</plist>
可以看到 plist 拥有多种标签来定义数据类型,例如 dict、string、data 等;同时节点的顺序,都是遵循 key、value 的顺序编写。也就是说 JSON 是可以和 plist 互相转换的。
这个转换过程中,唯一麻烦的是 data 类型。这个标签是用来存储二进制数据的,JSON 中没有定义。但是在 Node.js 中,可以无缝转换为一个 Buffer。
细心的你一定注意到上面 plist 中的两个问题:
事实上,Safari 的调试协议中,要求 JSON 字符串是被当作 payload data 传输的。而 plist 标准中,data 数据类型,就是进行 base64 编码的。
bplist 是 binary plist 的简称。它以二进制编码为基础,可以用来存储 plist 格式中同样的内容。这在 Socket 通讯中十分有用。
要知道 Safari 调试协议只接受 bplist 格式。具体客户端的开发中,没有规定一定要像本文中将一个指令先转换为 plist,再转换为 bplist。安排这样的转换,只是方便大家理解。你完全可以直接将一个 JSON 构造为 bplist。
前文的那段 plist,转换为 binary plist 就是:
00 00 01 C2 62 70 6C 69 73 74 30 30 D1 01 02 5F 10 12 57 ....bplist00..._..W
49 52 46 69 6E 61 6C 4D 65 73 73 61 67 65 4B 65 79 4F 11 IRFinalMessageKeyO.
01 77 62 70 6C 69 73 74 30 30 D2 01 03 02 04 5A 5F 5F 73 .wbplist00.....Z__s
65 6C 65 63 74 6F 72 5F 10 17 5F 72 70 63 5F 66 6F 72 77 elector_.._rpc_forw
61 72 64 53 6F 63 6B 65 74 44 61 74 61 3A 5A 5F 5F 61 72 ardSocketData:Z__ar
67 75 6D 65 6E 74 D5 05 07 09 0B 0D 06 08 0A gument.........
0C 0E 5F 10 1A 57 49 52 43 6F 6E 6E 65 63 74 69 6F 6E 49 .._..WIRConnectionI
64 65 6E 74 69 66 69 65 72 4B 65 79 5F 10 24 65 30 65 36 dentifierKey_.$e0e6
38 63 35 33 2D 35 63 63 39 2D 34 64 64 34 2D 39 65 62 62 8c53-5cc9-4dd4-9ebb
2D 61 37 65 36 39 65 39 38 65 66 37 34 5F 10 1B 57 49 52 -a7e69e98ef74_..WIR
41 70 70 6C 69 63 61 74 69 6F 6E 49 64 65 6E 74 69 66 69 ApplicationIdentifi
65 72 4B 65 79 58 50 49 44 3A 31 33 30 30 5F 10 14 57 49 erKeyXPID:1300_..WI
52 50 61 67 65 49 64 65 6E 74 69 66 69 65 72 4B 65 79 10 RPageIdentifierKey.
01 5C 57 49 52 53 65 6E 64 65 72 4B 65 79 5F 10 24 35 30 .\WIRSenderKey_.$50
63 32 65 31 38 39 2D 61 39 31 66 2D 34 64 66 35 2D 62 33 c2e189-a91f-4df5-b3
33 61 2D 37 34 31 32 32 35 65 39 62 64 38 35 5F 10 10 57 3a-741225e9bd85_..W
49 52 53 6F 63 6B 65 74 44 61 74 61 4B 65 79 4F 10 22 7B IRSocketDataKeyO."{
22 69 64 22 3A 30 2C 22 6D 65 74 68 6F 64 22 3A 22 4E 65 "id":0,"method":"Ne
74 77 6F 72 6B 2E 65 6E 61 62 6C 65 22 7D A0 00 08 00 0D twork.enable"}.....
00 18 00 32 00 3D 00 48 00 65 00 8C 00 AA 00 B3 00 CA 00 ...2.=.H.e.........
CC 00 D9 01 00 01 13 00 00 00 00 00 00 02 01 00 00 00 00 ...................
00 00 00 0F 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01 ...................
39 A0 00 08 00 0B 00 20 00 00 00 00 00 00 02 01 00 00 00 9...... ...........
00 00 00 00 03 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ...................
01 9C ..
plist 和 bplist 都是有相关文档的,所以大家还是制作了不少第三方工具的:
iOS 上的 webinspector 服务接受到这个 bplist 消息,自然会进行一个逆向操作,得倒 JSON 的字符串表达以及其他信息(appId、pageId、senderId)。
最后,通过 Safair 调试协议中的其他辅助信息,将这个 JSON 指令传输给正确的 Webkit 实例。
调试协议的会话,本身是有一定流程的。只有一些初始操作完成后,DevTools 才能正确的发送调试指令。
为了方便阅读,消息全部以 plist 格式做演示,实际上我们传输的 bplist。
首先,当主机和 iOS 设备的 webinspector 服务连接(PC<->USB<->webinspectord)创立的时候,会要求汇报这个连接的标示符。例如:
<?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>__selector</key>
<string>_rpc_reportIdentifier:</string>
<key>__argument</key>
<dict>
<key>WIRConnectionIdentifierKey</key>
<string>3b417e9a-9635-4059-a63e-ca88c98744bf</string>
</dict>
</dict>
</plist>
然后,我们要获取到已经连接到调试服务的 iOS 应用:
<?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>__selector</key>
<string>_rpc_getConnectedApplications:</string>
<key>__argument</key>
<dict>
<key>WIRConnectionIdentifierKey</key>
<string>3b417e9a-9635-4059-a63e-ca88c98744bf</string>
</dict>
</dict>
</plist>
针对某个应用,获取其内部的页面列表:
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>__selector</key>
<string>_rpc_forwardGetListing:</string>
<key>__argument</key>
<dict>
<key>WIRConnectionIdentifierKey</key>
<string>3b417e9a-9635-4059-a63e-ca88c98744bf</string>
<key>WIRApplicationIdentifierKey</key>
<string>PID:26</string>
</dict>
</dict>
</plist>
拿到了 appId、pageId 之后,就可以开始一个调试会话(注册一个 senderId)了:
<?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>__selector</key>
<string>_rpc_forwardSocketSetup:</string>
<key>__argument</key>
<dict>
<key>WIRConnectionIdentifierKey</key>
<string>2819b1eb-27ae-4c48-b195-6e5df02d0260</string>
<key>WIRApplicationIdentifierKey</key>
<string>PID:2851</string>
<key>WIRPageIdentifierKey</key>
<integer>1</integer>
<key>WIRSenderKey</key>
<string>4e9f5b1b-d9b7-4bcf-a315-25c10146a74d</string>
</dict>
</dict>
</plist>
之后,就可以传输具体的调试指令了。指令的编码过程,就是之前演示的那样。这里不再赘述。
本文分析了 Apple 在 iOS 设备上开放的 Safari 调试协议的原理以及具体通讯方式,目前还有若干缺陷需要进一步研究: