让微信 macOS 客户端支持 Alfred

Alfred 作为 macOS 平台上的效率神器,Dash、Evernote等应用配合 Alfred 能够有很好的效率提升与体验,然而还是有很多应用未能支持得,比如 Telegram、微信等经常使用的一些应用。既然原生未能支持,那么自己来动手搞一个了~

0x00 相关回顾

以上为微信 Tweak 增强的以往的一些文章资料,可供参考。

0x01 需求

  1. 通过 Alfred 模糊搜索联系人并快速跳转到对应聊天界面
  2. 还没想到 (:з」∠)

0x02 Cocoa App 与 Alfred 的通讯

通过 Google 粗略地搜索了一遍,发现 Cocoa App 适配 Alfred 的相关开发教程并没有很多,大概有如下两种方式进行通讯:

1. AppleScript

cocoascriptapp

对于 AppleScript,在 macOS 平台上是随处可见的了,文档 ScriptableCocoaApplications 中也有详细的介绍与教程。但由于需要编写 sdef(Scripting Definition File) 文件让我有点头疼,因此本文选择的方式是后者的方法。

2. 内置 HTTP Server

httpserver

内置 HTTP Server 也就是采用 HTTP GET/POST 的方式进行调用通讯,没有繁琐的配置项,也不用修改 Info.plist 文件,自由度相对较高,不受限于 AppleScript 的调用。因此选用了 GCDWebServer 作为 HTTP Server。

0x03 搜索方案

确定了 Cocoa Application 与 Alfred 的通讯方案之后,那么就需要考虑微信客户端的搜索。第一时间想到的应该是直观界面上的搜索框,通过动态调试找出搜索的方法获得结果。比较底层的方法则是通过获取所有联系人列表的方法,假如能够自定义筛选参数那就更好了。因此动态调试有以下两个关注点:

  1. 搜索栏事件响应与调用
  2. 联系人列表获取方法

继续祭出 Hopper Disassemblerclass-dump 等工具。

0x04 找出入口

首先采用的是第一种搜索方案,通过观察,可以定位到搜索的主要方法在 MMSearchViewController 中,通过调用 - (void)searchWithKeyword:(id)keyword andShowInView:(id)view; 方法无需自己实现模糊匹配即可得到搜索结果MMSearchResultItem 的数组,但是有一点需要注意的是 view 的传值不能为空,可以说是非常方便了。然而际上回调与传值都不方便使用,其中还有 NSTimer 的处理操作……

searchviewcontroller

于是转战第二种搜索方案,经过以往的微信客户端逆向实践,不难找出 ContactStorageWCContactData 这两个与联系人相关的 Header,其中 ContactStorage 更是清楚列出了与联系人操作的相关方法,比如 - (id)GetAllFriendContacts;。但是这个方案就需要自己实现模糊搜索过滤的操作啦~

contactstorage

PS: 其中有一个方法 - (void)SearchContactWithKeyword:(id)arg1; 名字非常美好,然而内部并没有任何实现,只有一个 return,大家不要被骗了!(♯`∧´)

0x05 编写 Tweak

获得以上主要方法之后,就可以进行 Tweak 的实现。

1. HTTP Server

HTTP Server 的实现可以通过单例实现,从应用启动开始一直到应用生命周期结束,也就是被 Terminate。


#import "AlfredManager.h"
#import "WeChatTweakHeaders.h"

@interface AlfredManager()

@property (nonatomic, strong, nullable) GCDWebServer *server;

@end

@implementation AlfredManager

+ (instancetype)sharedInstance {
    static dispatch_once_t onceToken;
    static AlfredManager *shared;
    dispatch_once(&onceToken, ^{
        shared = [[AlfredManager alloc] init];
    });
    return shared;
}

- (void)startListener {
    if (self.server != nil) {
        return;
    }
    self.server = [[GCDWebServer alloc] init];
    // Search contancts
    [self.server addHandlerForMethod:@"GET" path:@"/wechat/search" requestClass:[GCDWebServerRequest class] processBlock:^GCDWebServerResponse * _Nullable(__kindof GCDWebServerRequest * _Nonnull request) {
        // Handle search request
    }];
    // Start chat
    [self.server addHandlerForMethod:@"GET" path:@"/wechat/start" requestClass:[GCDWebServerRequest class] processBlock:^GCDWebServerResponse * _Nullable(__kindof GCDWebServerRequest * _Nonnull request) {
        // Handle start chat request
    }];
    [self.server startWithOptions:@{GCDWebServerOption_Port: @(48065),
                                    GCDWebServerOption_BindToLocalhost: @(YES)} error:nil];
}

- (void)stopListener {
    if (self.server == nil) {
        return;
    }
    [self.server stop];
    [self.server removeAllHandlers];
    self.server = nil;
}

从上面代码可以看到,server 绑定了两个 API,分别是 /wechat/search/wechat/start, 分别对应着搜索联系人和打开聊天窗口。

2. 模糊搜索联系人

NSString *keyword = [request.query[@"keyword"] lowercaseString] ? : @"";
NSArray<WCContactData *> *contacts = ({
    MMServiceCenter *serviceCenter = [objc_getClass("MMServiceCenter") defaultCenter];
    ContactStorage *contactStorage = [serviceCenter getService:objc_getClass("ContactStorage")];
    GroupStorage *groupStorage = [serviceCenter getService:objc_getClass("GroupStorage")];
    NSMutableArray<WCContactData *> *array = [NSMutableArray array];
    [array addObjectsFromArray:[contactStorage GetAllFriendContacts]];
    [array addObjectsFromArray:[groupStorage GetAllGroups]];
    array;
});
NSArray<WCContactData *> *results = ({
    NSMutableArray<WCContactData *> *results = [NSMutableArray array];
    for (WCContactData *contact in contacts) {
        BOOL isFriend = contact.m_uiBrandSubscriptionSettings == 0;
        BOOL containsNickName = [contact.m_nsNickName.lowercaseString containsString:keyword];
        BOOL containsUsername = [contact.m_nsUsrName.lowercaseString containsString:keyword];
        BOOL containsAliasName = [contact.m_nsAliasName.lowercaseString containsString:keyword];
        BOOL containsRemark = [contact.m_nsRemark.lowercaseString containsString:keyword];
        BOOL containsNickNamePinyin = [contact.m_nsFullPY.lowercaseString containsString:keyword];
        BOOL containsRemarkPinyin = [contact.m_nsRemarkPYFull.lowercaseString containsString:keyword];
        BOOL matchRemarkShortPinyin = [contact.m_nsRemarkPYShort.lowercaseString isEqualToString:keyword];
        if (isFriend && (containsNickName || containsUsername || containsAliasName || containsRemark || containsNickNamePinyin || containsRemarkPinyin || matchRemarkShortPinyin)) {
            [results addObject:contact];
        }
    }
    results;
});

3. 打开聊天窗口

WCContactData *contact = ({
    NSString *session = request.query[@"session"];
    WCContactData *contact = nil;
    if (session != nil) {
        MMServiceCenter *serviceCenter = [objc_getClass("MMServiceCenter") defaultCenter];
        if ([session rangeOfString:@"@chatroom"].location == NSNotFound) {
            ContactStorage *contactStorage = [serviceCenter getService:objc_getClass("ContactStorage")];
            contact = [contactStorage GetContact:session];
        } else {
            GroupStorage *groupStorage = [serviceCenter getService:objc_getClass("GroupStorage")];
            contact = [groupStorage GetGroupContact:session];
        }
    }
    contact;
});
dispatch_async(dispatch_get_main_queue(), ^{
    [[objc_getClass("WeChat") sharedInstance] startANewChatWithContact:contact];
    [[objc_getClass("WeChat") sharedInstance] showMainWindow];
    [[NSApplication sharedApplication] activateIgnoringOtherApps: YES];
});

4. 测试接口

当完成了以上步骤,可以通过浏览器测试接口:

apitest

0x06 编写 Workflow

当完成了以上步骤,我已经不想动了 (:з」∠)

Alfred workflow 的编写可以使用各种个样的语言,比如宇宙第一的 PHP。而我选择了人生苦短的 Python。首先肯定上 GitHub 淘个库来用了:alfred-workflow

由于目前接口比较单一,因此主要文件只有两个:

  • search.py:负责联系人的搜索
    def search(query):
        url = 'http://localhost:48065/wechat/search'
        parameters = dict(keyword=query)
        response = web.get(url, parameters)
        response.raise_for_status()
        return response.json()
    
    def main(wf):
        query = wf.args[0]
        for contact in search(query):
            title = None
            if 'm_nsRemark' in contact and len(contact['m_nsRemark']) > 0:
                title = contact['m_nsRemark']
            else:
                title = contact['m_nsNickName']
        wf.add_item(title=title,
                    subtitle=contact['m_nsNickName'],
                    arg=contact['m_nsUsrName'],
                    valid=True)
        wf.send_feedback()
    
  • start.py:负责打开微信跳转
    def start(query):
        url = 'http://localhost:48065/wechat/start'
        parameters = dict(session=query)
        response = web.get(url, parameters)
        response.raise_for_status()
        return response
    
    def main(wf):
        query = wf.args[0]
        start(query)
        wf.send_feedback()
    

0x07 最终效果

resultpreview

GitHub: WeChatTweak-macOS ,欢迎交流讨论。