让微信 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。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

#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. 模糊搜索联系人

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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. 打开聊天窗口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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:负责联系人的搜索

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    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:负责打开微信跳转

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    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 ,欢迎交流讨论。