另一种方法获取 macOS 网易云音乐的正在播放

虽然标题里面写的是“另一种”,但是先前的方法其实不是我写的?而是来自可爱少女 Makito 的两篇 post ——

于是就看到了直接从 Mach 内核入手的方法,是賢い、かわいい Makito~!不过今天跑去 clone 代码下来尝试的时候似乎会 crash 的样子,毕竟距离上次 update 代码也过去了 9 个月左右了,猜想可能是网易云音乐有所修改导致(在我用别的方法尝试的时候,也是莫名 crash 了)

于是这里就写一个另一种获取 macOS 网易云音乐的正在播放的方法吧~

根据朵朵前面的分析,已知 -[YYYApp refreshDockMenuTitlesForPlayLoadModel:] 是会在播放歌曲改变时会被调用的,于是扔到 Hopper 里看看~

在这个函数里会对歌曲名、歌手和专辑名做一些格式化,填充到模版字符串里,最后显示到 Dock 菜单里

那么理论上其实并不复杂,只需要把下面这个函数 Hook 了,然后从 arg1 中取出 songName, artistNamealbumName 就完成了。

- (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 函数中,它将作为 songNameMenuItemartistAndAlbumMenuItem 的 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 上啦~https://github.com/BlueCocoa/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);
}
声明: 本文为 Cocoa 原创, 转载注明出处喵~

3 thoughts on “另一种方法获取 macOS 网易云音乐的正在播放”

Leave a Reply

Your email address will not be published. Required fields are marked *