Taobao FED

Webkit远程调试协议实战

Webkit远程调试协议实战

上一篇文章 介绍了 DevTools 和 Webkit Debug Protocol 这两个 Web 开发利器的内部原理。本篇主要讲解 iOS 的 Safari 远程调试。

iOS 的 Safari 远程调试,是 iOS7 引入的新功能。它允许开发者通过桌面端 Safari 的调试工具远程检视移动端浏览器打开的页面。这套调试工具,彻底解决了无线开发纯靠 “alert” 的调试困境。Safari 的远程调试中使用到的调试协议,与 Google 开放的 Chrome Debug Protocol 有牵丝万缕的关系,体现了这两家互联网巨头相爱相杀的本质:

  1. 这两套协议本质都是 Webkit Debug Protocol 的衍生产物。大部分的实际功能是一模一样的,例如 DOM 检视、网络请求监控、Console 等。
  2. 自从 Google 与 Webkit 项目撇清关系,分道扬镳后生下亲儿子 blink 后,自己的调试功能越来越强大,并逐渐产生了一些和 Webkit 调试协议不一样的功能,故自成一套 Chrome Debug Protocol。
  3. Apple Safari 这个养在深闺里小女子,虽然主导 Webkit,但是在实际产品使用中,却并没有直接使用 Webkit Debug Protocol,不仅仅使用了 binary plist 来作为序列化方法,还抛弃了 WebSocket 的通讯手段。没有文档、没有代码,我们姑且叫这个不舍得露面的东西叫做 Safari 调试协议吧。

webinspectord 和 lockdown

iOS7 以后,每个 iOS 都有一个 webinspectord 守护进程,负责远程调试的通讯。这个进程暴露了一个服务接口,供外部应用(例如桌面端的 Safari 调试工具)使用。

iOS 上所有的服务(文件浏览、消息推送、app 安装等)都是通过一个 lockdown 服务管理连接上的。

自然的,调试工具也需要透过 USB 接口,通过 lockdown 界面,连接到 webinspector 服务。

由于 iOS 的各种系统组件都极为神秘,没有更多可以解释的了。伟大的开源社区,通过各种手段实现了这些服务的接口,大家可以前去膜拜

Safari 远程调试服务概述

抛开 USB 通讯、lockdown 接口不谈,Safari 远程调试服务所使用的协议本身其实就是 Webkit 调试协议的二次包装。也就是共享了 Webkit 调试协议的大部分功能。

先分析这个协议里面的主体:

  • iOS 设备,iPad、iPhone 等物理设备
    • UDID: 以 40 位 UDID 字符串唯一识别一个设备
  • Application:iOS 设备上运行的开启了 WebView 应用程序,设备上可以同时运行多个 Application
    • Identifier:应用标示符
    • BundleIdentifier:应用的 main bundle 标示符
  • Page:每个 Application 可以打开多个页面
    • Identifier:页面标示符
    • Title
    • URL

还有一些概念字段:

  • ConnectionId, 标示当前连接到 webinspector 服务的连接
  • SenderId, 标示请求方(例如 DevTools )实体

大体可以看出,这个调试服务的接口是有状态的。设备和 DevTools 建立连接后,拥有可以复用的链接作为后续通讯的通道。

假设我们需要发送一条调试指令到 iOS 上的某个 Webkit 内核,它的整个编码流程应该是是这样:

  1. Webkit 能识别的消息对象进行 JSON 序列化为字符串
  2. 构建 Safari 调试协议中使用的 bplist 消息体,来包装之前得到的字符串。这里用到的 selector 就是 _rpc_forwardSocketData
  3. 将消息题通过 Socket 传输到 iOS 上的调试服务
  4. iOS 上调试服务识别消息,并解析 bplist,得倒 Webkit 能识别的消息对象
  5. 将上一步得倒的消息对象传输给 Webkit

所有的消息,都是以 Apple 自己定义的 RPC 消息提格式进行的。但它实际传输的有效数据,还是 Webkit
能够识别的指令。另外,Safari 没有如同 Chrome 那样,使用了 WebSocket 作为暴露出去的应用层协议。它选择了最基本的 Socket 通讯方式和 bplist 作为传输格式。

调试消息的大冒险

下面以一个具体的消息作为例子,来说说这整个过程。

JSON 消息和 Safari 的 RPC 协议

假设我们要开启网络监控这个面板,需要发送一个 JSON 指令。指令序列化之后我们的到:

1
{"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 的。

JSON 到 plist 的转换

plist 和 bplist 都是 Apple 的通讯格式。其中 plist 非常常见。加入你做过 iOS 或者 Mac 开发,你一定写过不少 plist。plist 就是一种拥有自有 DTD 的 XML 文档类型。说白了,它就是 XML 文档。

例如之前的 JSON 指令,转换为 Safari 调试协议能够理解的 plist 文档:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?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 中的两个问题:

  1. 没发现任何 JSON 字符串的内容
  2. WIRSocketDataKey 里面的竟然是 base64 编码的字符串

事实上,Safari 的调试协议中,要求 JSON 字符串是被当作 payload data 传输的。而 plist 标准中,data 数据类型,就是进行 base64 编码的。

plist 到 bplist 的转换

bplist 是 binary plist 的简称。它以二进制编码为基础,可以用来存储 plist 格式中同样的内容。这在 Socket 通讯中十分有用。

要知道 Safari 调试协议只接受 bplist 格式。具体客户端的开发中,没有规定一定要像本文中将一个指令先转换为 plist,再转换为 bplist。安排这样的转换,只是方便大家理解。你完全可以直接将一个 JSON 构造为 bplist。

前文的那段 plist,转换为 binary plist 就是:

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
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 实例。

Safari 调试协议的会话流程

调试协议的会话,本身是有一定流程的。只有一些初始操作完成后,DevTools 才能正确的发送调试指令。

为了方便阅读,消息全部以 plist 格式做演示,实际上我们传输的 bplist。

首先,当主机和 iOS 设备的 webinspector 服务连接(PC<->USB<->webinspectord)创立的时候,会要求汇报这个连接的标示符。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?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 应用:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?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>

针对某个应用,获取其内部的页面列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!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)了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?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 调试协议的原理以及具体通讯方式,目前还有若干缺陷需要进一步研究:

  • 调试协议仅仅在 Development Provision Profile 签名的应用以及 iOS 端 Safari 中开放
  • 受限于 Apple 的开发政策,还无法通过 Wifi 方式连接到调试服务,进行真正的无线调试
  • 在具体使用 DevTools 的时候,发现有一部分功能无法正常使用。原因可能在于 DevTools 前端应用的更新,也有可能在于 Safari 真的不支持全部的调试指令