Makito's Notebook
生活觀察筆記
获取 macOS 网易云音乐的正在播放 —— 使用 LLDB 验证思路

8102 年和 9102 年可谓是 Virtual YouTuber 爆发的时代,每天的乐趣也多了一项去 YouTube 看可爱的 VTuber 们唱歌玩游戏和闲聊日常,这也让我想起来以前在哔哩哔哩上看绘师们直播画画的日子。

macOS 的用户数量相对于 Windows 的用户数量来说还是差距不小的,使用 Windows 进行直播的主播们应该还是占有很大比例的。而直播中又经常会有使用到 BGM 的地方,这里便产生了一个将当前 BGM 的信息显示在直播画面上的需求,有的主播选择直接将播放器放在直播画面中,而有的则选择了使用插件来获取软件当前播放的歌曲信息,这些插件也多是为运行在 Windows 上的音乐程序而开发的,有的是依靠取得窗口标题来获得当前播放的歌曲信息,一般来说能获得歌曲名与歌手名和/或专辑名就足够了;还有一些插件依靠分析网络请求来从服务器响应的 JSON 中获得歌曲信息;而在 macOS 上也有读取网易云音乐在本地存储的和网络请求相关的历史文件来获得歌曲信息的方案,不过我测试时似乎并不是那么好用。这些插件多是和 OBS 配合使用,毕竟 OBS 有一个很好用的功能——将文本文件的内容显示在画面上。外部插件写入文本文件,OBS 读取文本文件,一个很简单的管道就产生了,虽然并不能保证安全。

疑问

上面说到了插件们想尽办法获取当前播放歌曲的信息,到这里你可能会问:为什么这一切都这么困难呢?

要是各大音乐软件都能学习一下 iTunes 和 Spotify 就好了,好吧,我也知道这不可能。

如果像 Spotify 这样既有 Scripting Bridge 支持又甚至为你提供了正在播放曲目的 Web API 的话,就好了。用过三个月 Spotify Premium 会员的我感觉这个可以在一些社交网络上同步我正在收听的歌曲的功能虽然无关痛痒,但缺失的话,自己实现总没有直接能用来得轻松。

不过我之后还是转回了网易云音乐,一是 Spotify 美区的歌曲库对我来说有些不够,二是……。咳。

摸索

好了,来说说获取 macOS 版网易云音乐的正在播放是如何实现的吧。

前面说到,有一部分插件是靠读取程序窗口的标题来获得当前播放歌曲的,听上去也确实是个常用的方案,所以我们先来试试获得窗口标题。

AppleScript 十分方便,例如我们可以使用下面的语句来列出 Chrome 所有窗口的名称:

tell application "System Events" to get the title of every window of process "Chrome"

得到的输出为:

{"", "Google - Google Chrome"}

很好,其中「Google」正是当前网页的标题,那么我们把「Chrome」换成「NeteaseMusic」试一下,看看标题会不会是当前歌曲的信息:

tell application "System Events" to get the title of every window of process "NeteaseMusic"

得到输出:

{"NeteaseMusic"}

看来窗口标题默认的便是 Executable 的名称,由于 macOS 网易云音乐并没有使用传统的窗口标题栏,因此用当前播放信息来更新标题栏更是不大可能,因此依靠窗口标题来获取当前播放信息的方案并不可用。不过常用 macOS 网易云音乐的用户可能会知道,在切换歌曲的时候,网易云音乐会发送通知气泡来显示新的正在播放歌曲信息,而相应的 Dock 菜单也会被更新。

这样的话,我们便大致可以推断出在切换歌曲的时候,程序会先更新正在播放的歌曲信息,然后再通知对应的 UI 组件更新文字或是发送通知,那么我们只需要在这个时间点获得更新后的歌曲信息便可以实现对外部提供获得正在播放歌曲信息的需求。

分析

本文编写时 macOS 版网易云音乐的最新版本为 Version 2.0.0 (730),以下部分内容将以此版本为准。

首先选择自己喜欢用的 Disassembler 来对二进制文件进行静态分析,这里我们选择跟踪 Dock 菜单的更新操作,由于 Dock 菜单中的艺术家名与专辑名之间使用「-」连接,推测是使用了 %s - %s,即 Objective-C 中的 %@ - %@ 进行字符串格式化的结果。

之后可以找到使用这一字符串的函数 - (void) refreshDockMenuTitlesForPlayLoadModel 位于 YYYApp 类下。

- (void) refreshDockMenuTitlesForPlayLoadModel
...
095069  mov   r14, qword [0x1001f6f60]         ; @selector(songName)
095070  mov   rdi, r13                         ; argument "instance" for method _objc_retain
095073  call  qword [_objc_retain_10019e420]   ; _objc_retain
095079  mov   qword [rbp+var_40], rax
09507d  mov   rdi, r13                         ; argument "instance" for method _objc_msgSend
095080  mov   rsi, r14                         ; argument "selector" for method _objc_msgSend
095083  call  qword [_objc_msgSend_10019e410]  ; _objc_msgSend

可以看到 0x00095083 附近的调用获得了歌曲名,让我们用 LLDB 测试一下。

(lldb) br s -a 0x0000000100262000+0x00095089
Breakpoint 1: address = 0x00000001002f7089
(lldb) c
Process ... resuming
Process ... stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
    frame #0: 0x00000001002f7089:
->  0x1002f7089 <+56>: movq   %rax, %rdi
    0x1002f708c <+59>: callq  0x100385a2c
    0x1002f7091 <+64>: movq   %rax, %rbx
    0x1002f7094 <+67>: testq  %rbx, %rbx
Target 0: (NeteaseMusic) stopped.
(lldb) po $rax
春風

果不其然,上一步的返回值指向了歌曲名,如此效仿也可以在后面得到艺术家名与专辑名。

(get song name) -> (get artist name) -> (get album name) -> (set dock menu items)

整理出来的大致流程如上。

思考 🤔

手动输入 LLDB 的命令有点不现实 ,更何况这样一来还有三个断点需要设置,每次启动程序的时候还要去找基址(多亏了 ASLR)。不过听说 LLDB 与 Python 的关系还不错,那么我们是不是可以写一个 Python 模块来完成自动化呢?

按照官方文档的说明,先 import lldb,如果这个 package 找不到的话,可以在 /Applications/Xcode.app/Contents/SharedFrameworks/LLDB.framework/Versions/A/Resources/Python 寻找一下,或者也可以简单粗暴地复制到当前项目的目录下,因为这份代码并不会直接在命令行中执行,之后又添加了 lldb 在载入模块时会默认调用的方法 __lldb_init_module,这一步中可以对 Debugger 进行更多的操作。

import lldb


def __lldb_init_module(debugger, internal_dict):
    pass

之前提到了由于 ASLR 的存在,我们在已知想要设置断点的位置在 __TEXT 中的相对地址之后还需要获得程序载入到内存中的基址才能计算出来真正设置断点的地址,这时可以用下面的方法获得程序载入后的基址供以后使用,由于它本身会是第一个被载入的,因此只需要取得第一个地址即可。

base = 0

def find_base(ci):
    ret = lldb.SBCommandReturnObject()
    ci.HandleCommand('im list', ret)
    if ret.Succeeded():
        for m in ret.GetOutput().split('\n')[0].split(' '):
            if m.startswith('0x'):
                return int(m, 16)
    return None

有了基址之后就可以继续写设置断点部分的代码了:

def setup_breakpoints(ci, base):
    print('Setting up breakpoints...')
    ret = lldb.SBCommandReturnObject()
    ci.HandleCommand('br s -a {}'.format(hex(base + 0x95089)), ret)
    ci.HandleCommand('br s -a {}'.format(hex(base + 0x950f7)), ret)
    ci.HandleCommand('br s -a {}'.format(hex(base + 0x9510f)), ret)

设置断点之后,程序运行到断点处时会被暂停,这时 LLDB 通常需要用户与之互动给出下一步的动作,或调试进程或恢复进程或结束进程才可以继续,但我们希望这一切都由程序自动完成,于是我们需要将触发断点后的动作流程也指定出来。

def po_rax():
    ci = lldb.debugger.GetCommandInterpreter()
    ret = lldb.SBCommandReturnObject()
    ci.HandleCommand('po $rax', ret)
    if ret.Succeeded():
        return ret.GetOutput()
    return None


def hook_song_name(f, bp, d):
    result = po_rax()
    print('  Song: {}'.format(result.strip()))
    # resume process
    lldb.debugger.HandleCommand('c')


def hook_artist_name(f, bp, d):
    result = po_rax()
    print('Artist: {}'.format(result.strip()))
    # resume process
    lldb.debugger.HandleCommand('c')


def hook_album_name(f, bp, d):
    result = po_rax()
    print(' Album: {}'.format(result.strip()))
    # resume process
    lldb.debugger.HandleCommand('c')


def setup_hooks(ci):
    print('Setting up hooks...')
    ret = lldb.SBCommandReturnObject()
    ci.HandleCommand('command script import "ncmnp.py"', ret)
    ci.HandleCommand('br com a 1 -F ncmnp.hook_song_name', ret)
    ci.HandleCommand('br com a 2 -F ncmnp.hook_artist_name', ret)
    ci.HandleCommand('br com a 3 -F ncmnp.hook_album_name', ret)

最后,再加上初始化流程,补全默认的初始化函数。

def __lldb_init_module(debugger, internal_dict):
    debugger.HandleCommand('command script add -f ncmnp.ncmnp_init ncmnp_init')


def ncmnp_init(debugger, command, result, internal_dict):
    ci = debugger.GetCommandInterpreter()
    base = find_base(ci)
    print('Base address: {}'.format(hex(base)))
    setup_breakpoints(ci, base)
    setup_hooks(ci)

这样的话,我们就可以在 LLDB 中使用如下命令来自动完成设置断点与设置断点处理的流程。

(lldb) attach -n "NeteaseMusic"
...
(lldb) command script import "ncmnp.py"
(lldb) ncmnp_init
Base address: 0x100262000
Setting up breakpoints...
Setting up hooks...
(lldb) c
...

在当前播放的歌曲发生变化的时候,断点将被触发:

...
Process ... stopped
  Song: 少女たちの終わらない夜
Artist: ORESAMA
 Album: H△G × ORESAMA
Process ... resuming

这个方案减少了我们需要输入命令的数量,但还并不是最佳的,我们还需要将这些 LLDB 命令的执行也自动化起来。

于是我们可以新建一个文本文件,命名为 lldb_commands,文件中保存我们要执行的命令:

attach -n "NeteaseMusic"
command script import "ncmnp.py"
ncmnp_init
c

之后只需要运行一行命令,即可将剩下的工作交给 LLDB 来完成:

lldb -n NeteaseMusic -s lldb_commands

建议在使用结束时在 LLDB 中先使用 detach 命令 Detach debugger,否则可能会使网易云音乐进程被 Kill 或出现其他奇怪的问题。

不过……

新的问题

在实际运行时可能会发现正在播放信息发生变化时,Python 连续输出了很多次相同的歌曲信息,如果这里的处理动作是每次都向文件中写入歌曲信息的话,就会带来不必要的性能负担。这里推测是网易云音乐在切换歌曲时多次调用这个函数所导致的,因此可以在进行 I/O 操作前检查一下歌曲信息是否真正发生改变。

此外,LLDB = Low Level Debugger,它的本质毕竟还是个 Debugger,虽然功能强大,但我们用得到的以及用不到的功能都会被载入到内存中。

如果作为直播时获得当前播放信息的插件来常驻使用,这样的内存占用或许并不那么理想。

还能抢救一下

本文是对于使用注入法获取网易云音乐当前播放信息的思路的一个验证过程,针对上面所发现的问题,后文将使用脱离 LLDB 的方案解决。

未经许可 禁止转载