獲取 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 的方案解決。
未經許可 禁止轉載