虽然标题里面写的是“另一种”,但是先前的方法其实不是我写的?而是来自可爱少女 Makito 的两篇 post ——
于是就看到了直接从 Mach 内核入手的方法,是賢い、かわいい Makito~!不过今天跑去 clone 代码下来尝试的时候似乎会 crash 的样子,毕竟距离上次 update 代码也过去了 9 个月左右了,猜想可能是网易云音乐有所修改导致(在我用别的方法尝试的时候,也是莫名 crash 了)
于是这里就写一个另一种获取 macOS 网易云音乐的正在播放的方法吧~
根据朵朵前面的分析,已知 -[YYYApp refreshDockMenuTitlesForPlayLoadModel:]
是会在播放歌曲改变时会被调用的,于是扔到 Hopper 里看看~
在这个函数里会对歌曲名、歌手和专辑名做一些格式化,填充到模版字符串里,最后显示到 Dock 菜单里
那么理论上其实并不复杂,只需要把下面这个函数 Hook 了,然后从 arg1
中取出 songName
, artistName
和 albumName
就完成了。
- (void)refreshDockMenuTitlesForPlayLoadModel:(id)arg1;
然而事情并没有这么简单(╯°Д°)╯︵ /(.□ . \)
然而似乎事情并没有这么简单(*⁰▿⁰*) 这样写了之后在播放的时候会直接爆炸,猜想可能跟 Makito 的代码在新版网易云音乐下面 crash 的原因应该是一样的,至于是哪里出了问题我暂时也没有去细细研究(其实就是懒)
不过还是有很多别的办法的~☆〜(ゝ。∂)在 Hopper 里面以 dock
, menus
为线索在 YYYApp
里找到了这么一个方法
- (void)setDockMenus;
简单测试之后发现这个方法也会在每次播放的音乐改变时被调用~
#import <Foundation/Foundation.h> #import <Cocoa/Cocoa.h> #import <objc/runtime.h> // `Class` variable of YYYApp static Class YYYAppClass; // Original implementation of -[YYYApp setDockMenus] static void(*orig)(id); /// Our implementation of -[YYYApp setDockMenus] static void hook(id self) { // call original implementation so that instance variable can be initialised orig(self); NSLog(@"invoked: %s", __PRETTY_FUNCTION__); } /// this constructor function will be invoked at loading static void __attribute__((constructor))initializer(void) { // initialise global variables YYYAppClass = NSClassFromString(@"YYYApp"); // save old implementation of -[YYYApp setDockMenus] orig = (decltype(orig))class_getMethodImplementation(YYYAppClass, @selector(setDockMenus)); // set new implementation of -[YYYApp setDockMenus] method_setImplementation(class_getInstanceMethod(YYYAppClass, @selector(setDockMenus)), (IMP)hook); }
Hook 网易云音乐
实际 Hook 的操作如下~将代码保存为 ncmnp.mm
之后,编译成 dylib,然后复制到网易云音乐的 Frameworks
下~
clang -shared -Os -undefined dynamic_lookup -o libncmnp.dylib ncmnp.mm cp libncmnp.dylib /Applications/NeteaseMusic.app/Contents/Frameworks
随后使用 insert_dylib 让网易云音乐依赖这个 dylib,以达到自动加载的目的~
insert_dylib @rpath/libncmnp.dylib NeteaseMusic mv NeteaseMusic NeteaseMusic_orig mv NeteaseMusic_patched NeteaseMusic
insert_dylib 可能会询问是否要移除代码签名,选择 y 即可~
怎么拿到实例变量呢~
那么接下来的问题就是怎么获取正在播放的音乐标题、歌手和专辑名了~
这里就轮到 classdump 上场了~
题外话,,话说不知道现在 classdump 有没有支持 dump Objective-C 和 Swift 混编的二进制,很久之前我在原版上做了一些修改,当然只是让它在遇到 Swift 的 object 时不会 crash,并不是支持 dump 出 Swift 的类文件
在 dump 出头文件之后,就可以重点关注 YYYApp.h
了~grep 一下就会发现里面有这么两个实例变量,_dockSongNameMenuItem
和 _dockArtistAndAlbumMenuItem
。
其次,YYYApp
有一个 sharedApplication
的类方法,可以直接获取到 YYYApp
的单例~这样一来就方便多了,就剩下获取这个 YYYApp
单例的实例变量了
在 Objective-C 中要获取实例变量 (Ivar) 其实并不复杂,以 _dockSongNameMenuItem
来说的话,第一步是拿到对应的 Ivar 结构体,
Ivar songNameMenuItemIvar = class_getInstanceVariable(YYYAppClass, "_dockSongNameMenuItem");
接下来就可以从实例中拿到具体的变量了~
NSMenuItem * songNameMenuItem = object_getIvar(YYYAppSharedApplication, songNameMenuItemIvar);
到目前这一步之后,我们的代码如下~
#import <Foundation/Foundation.h> #import <Cocoa/Cocoa.h> #import <objc/runtime.h> /// small header for YYYApp @interface YYYApp : NSObject + (instancetype)sharedApplication; - (void)setDockMenus; @end // `Class` variable of YYYApp static Class YYYAppClass; // Shared instance of YYYApp +[YYYApp sharedApplication] static id YYYAppSharedApplication = nil; // Original implementation of -[YYYApp setDockMenus] static void(*orig)(id); // Instance variables of YYYApp static NSMenuItem * songNameMenuItem; static NSMenuItem * artistAndAlbumMenuItem; /// Our implementation of -[YYYApp setDockMenus] static void hook(id self) { // call original implementation so that instance variable can be initialised orig(self); // do once static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ // get instance variables that we interested in songNameMenuItem = object_getIvar(YYYAppSharedApplication, class_getInstanceVariable(YYYAppClass, "_dockSongNameMenuItem")); artistAndAlbumMenuItem = object_getIvar(YYYAppSharedApplication, class_getInstanceVariable(YYYAppClass, "_dockArtistAndAlbumMenuItem")); NSLog(@"songNameMenuItem: %p", songNameMenuItem); NSLog(@"artistAndAlbumMenuItem: %p", artistAndAlbumMenuItem); }); } /// this constructor function will be invoked at loading static void __attribute__((constructor))initializer(void) { // initialise global variables YYYAppClass = NSClassFromString(@"YYYApp"); YYYAppSharedApplication = [YYYAppClass performSelector:@selector(sharedApplication)]; // save old implementation of -[YYYApp setDockMenus] orig = (decltype(orig))class_getMethodImplementation(YYYAppClass, @selector(setDockMenus)); // set new implementation of -[YYYApp setDockMenus] method_setImplementation(class_getInstanceMethod(YYYAppClass, @selector(setDockMenus)), (IMP)hook); }
同样的,用新的代码编译,然后复制过去,重新启动网易云音乐即可~
clang -shared -Os -undefined dynamic_lookup -o libncmnp.dylib ncmnp.mm cp libncmnp.dylib /Applications/NeteaseMusic.app/Contents/Frameworks
用 KVO 来监听正在播放的音乐
那么最后就是获取音乐标题、艺术家和专辑名了~这里既然我们都拿到了两个实例变量,那么就直接用 Objective-C 里的 Key-Value-Observer 机制就好~
不过 KVO 需要一个 Objective-C 的 Observer,所以只好写上一个 NCMNP
的类,然后在全局放上一个 NCMNP
的实例,这个实例会在 initializer
那个函数里初始化~
接着在 hook
函数中,它将作为 songNameMenuItem
和 artistAndAlbumMenuItem
的 Observer,监听这两个 NSMenuItem 的 title 变化。
songNameMenuItem
在拿到它的 title 之后只需要 trim 掉两端的空白字符即可,而 artistAndAlbumMenuItem
的话,还需要用 -
来 split 一下字符串(只能期待歌曲的名字里没有 -
了(((o(*゚▽゚*)o))))
最后全部交给 Python 吧~
这样一来,音乐标题、艺术家和专辑名就都有了,最后就可以 pass 给 Python 脚本了~
我的 Python 解释器在 /usr/local/bin/python3
,在编译的时候可以根据实际情况改一下~然后 script.py
文件来自 Makito,但是因为这种方法的话,默认的工作目录是 /
,所以需要将输出的文件路径改一下~
最后,修改的和用到的文件 layout 就是下面这样子的
以及最终的 ncmnp.mm
文件如下~这个项目也放在我的 GitHub 上啦~#/netease-now-playing
#import <Foundation/Foundation.h> #import <Cocoa/Cocoa.h> #import <objc/runtime.h> #define PYTHON_INTERPRETER "/usr/local/bin/python3" #define PATHON_SCRIPT "/Applications/NeteaseMusic.app/Contents/MacOS/script.py" /// small header for YYYApp @interface YYYApp : NSObject + (instancetype)sharedApplication; - (void)setDockMenus; @end /// Netease Cloud Music Now Playing @interface NCMNP : NSObject @property (nonatomic, retain, nullable) NSString * songName; @property (nonatomic, retain, nullable) NSString * artist; @property (nonatomic, retain, nullable) NSString * album; @end // Netease Cloud Music Now Playing Instance static NCMNP * neteaseNowPlayingWatcher = nil; // `Class` variable of YYYApp static Class YYYAppClass; // Shared instance of YYYApp +[YYYApp sharedApplication] static id YYYAppSharedApplication = nil; // Original implementation of -[YYYApp setDockMenus] static void(*orig)(id); // Instance variables of YYYApp static NSMenuItem * songNameMenuItem; static NSMenuItem * artistAndAlbumMenuItem; @implementation NCMNP /// Observe value changes of `songNameMenuItem` and `artistAndAlbumMenuItem` /// @param keyPath "title" /// @param object `songNameMenuItem` or `artistAndAlbumMenuItem` /// @param change unused /// @param context unused - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context { if (object == songNameMenuItem) { self.songName = [[songNameMenuItem title] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; } else if (object == artistAndAlbumMenuItem) { NSArray * artistAndAlbum = [[artistAndAlbumMenuItem title] componentsSeparatedByString:@" - "]; self.artist = [artistAndAlbum[0] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; self.album = [artistAndAlbum[1] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; } // if we have all three // then pass them to python script if (self.songName && self.artist && self.album) { const char * songName = [self.songName UTF8String]; const char * artist = [self.artist UTF8String]; const char * album = [self.album UTF8String]; // basically copy and paste from Makito printf("Now playing:\n"); printf("Song: %s\n", songName); printf("Artist: %s\n", artist); printf("Album: %s\n", album); int pid = fork(); if (pid == -1) { fprintf(stderr, "failed to fork child process to call Python script\n"); } else if (pid == 0) { char * const args[] = {(char * const)PYTHON_INTERPRETER, (char * const)PYTHON_SCRIPT, (char * const)songName, (char * const)artist, (char * const)album, 0}; execve(PYTHON_INTERPRETER, args, NULL); exit(0); } // clean self.songName = nil; self.artist = nil; self.album = nil; } } @end /// Our implementation of -[YYYApp setDockMenus] static void hook(id self) { // call original implementation so that instance variable can be initialised orig(self); // do once static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ // get instance variables that we interested in songNameMenuItem = object_getIvar(YYYAppSharedApplication, class_getInstanceVariable(YYYAppClass, "_dockSongNameMenuItem")); artistAndAlbumMenuItem = object_getIvar(YYYAppSharedApplication, class_getInstanceVariable(YYYAppClass, "_dockArtistAndAlbumMenuItem")); // watch their "title" changes [songNameMenuItem addObserver:neteaseNowPlayingWatcher forKeyPath:@"title" options:NSKeyValueObservingOptionNew context:NULL]; [artistAndAlbumMenuItem addObserver:neteaseNowPlayingWatcher forKeyPath:@"title" options:NSKeyValueObservingOptionNew context:NULL]; }); } /// this constructor function will be invoked at loading static void __attribute__((constructor))initializer(void) { // initialise global variables neteaseNowPlayingWatcher = [[NCMNP alloc] init]; YYYAppClass = NSClassFromString(@"YYYApp"); YYYAppSharedApplication = [YYYAppClass performSelector:@selector(sharedApplication)]; // save old implementation of -[YYYApp setDockMenus] orig = (decltype(orig))class_getMethodImplementation(YYYAppClass, @selector(setDockMenus)); // set new implementation of -[YYYApp setDockMenus] method_setImplementation(class_getInstanceMethod(YYYAppClass, @selector(setDockMenus)), (IMP)hook); }
有没有兴趣做成last.fm的工具呢??
啊 那我去看看last.fm?