目标:一台 macOS 上同时运行两个相互隔离的飞书(主实例 Lark.app + 第二实例 Lark-org2.app),登录不同账号;数据目录、TCC/通知身份互不影响;第二实例不与主实例抢全局快捷键、不会被官方升级覆盖、菜单栏图标可区分。 本文自包含:不依赖任何现成脚本,按步骤逐条执行即可完成。实测版本:143.0.7499.203(macOS,Apple Silicon)。 这是飞书折腾系列第三篇:撤回机制与加密的逆向原理见《飞书撤回消息逆向分析》,防撤回 asar 补丁见《飞书防撤回补丁复现指南》


0. 前置条件

要求
系统 macOS(Apple Silicon;Intel 需把下文 clang 的 -arch 换成 x86_64 自测)
源应用 /Applications/Lark.app 已安装(是否打了别的补丁无所谓,副本会原样带上)
工具 python3 + Pillow(pip3 install pillow)、clang(Xcode CLT)、codesign/install_name_tool/otool(随 CLT)
磁盘 预留一份飞书体积(~1.5GB)

约定:下文 $VERDIR 一律指当前飞书的版本目录,先取好:

VERDIR="$(ls "/Applications/Lark.app/Contents/Frameworks/Lark Framework.framework/Versions" | grep -E '^[0-9]' | sort -V | tail -1)"
FW="/Applications/Lark-org2.app/Contents/Frameworks/Lark Framework.framework/Versions/$VERDIR"
DATA="$HOME/Library/Application Support/LarkShell-org2"

1. 步骤一:复制 .app 并改身份

cp -R /Applications/Lark.app /Applications/Lark-org2.app

/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier com.electron.lark.org2" /Applications/Lark-org2.app/Contents/Info.plist
/usr/libexec/PlistBuddy -c "Set :CFBundleName Feishu2"                       /Applications/Lark-org2.app/Contents/Info.plist
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName 飞书2"                  /Applications/Lark-org2.app/Contents/Info.plist 2>/dev/null || true

CFBundleIdentifier 后,macOS 视其为独立 app:TCC(屏幕录制/麦克风等)、通知、 Dock 固定各自一套,与主实例互不干扰。


2. 步骤二:核心开关——manifest 的 userDataPath

**这是双开的充要条件。**飞书主进程(原生壳)用 Chromium ProcessSingleton 强制单 实例,锁文件在 <userDataPath>/SingletonLock;数据目录名 LarkShell 硬编码在壳 二进制里,命令行 --user-data-dir 与环境变量 LARK_DIR_USER_DATA 均无效, 唯一有效开关是 Framework Resources 下的 manifest.json(注意不在 webcontent 目录里):

python3 - <<PY
import json
p = "$FW/Resources/manifest.json"
d = json.load(open(p))
d['userDataPath'] = "$DATA"
json.dump(d, open(p, 'w'), ensure_ascii=False, separators=(',', ':'))
print('userDataPath =', json.load(open(p))['userDataPath'])
PY

改完 manifest,SingletonLock 落到新目录,双开即成立。只做第 1、2 步已经能双开, 以下步骤是体验加固(防升级/快捷键/图标)。


3. 步骤三:本地化显示名

菜单栏/Finder 的显示名取自各 .lproj/InfoPlist.strings,不改的话 UI 一半「飞书」 一半「飞书2」:

python3 - <<PY
import os, re
base = "/Applications/Lark-org2.app/Contents/Resources"
bad = re.compile(r'CFBundle(Name|DisplayName)')
for lproj in sorted(os.listdir(base)):
    s = os.path.join(base, lproj, 'InfoPlist.strings')
    if not lproj.endswith('.lproj') or not os.path.isfile(s):
        continue
    n = '飞书2' if lproj in ('zh-Hans.lproj', 'zh_CN.lproj') else \
        ('飛書2' if lproj in ('zh-Hant.lproj', 'zh_TW.lproj', 'zh_HK.lproj') else 'Feishu2')
    kept = [l for l in open(s, encoding='utf-8').read().splitlines() if not bad.search(l)]
    kept += ['', '"CFBundleName" = "%s";' % n, '"CFBundleDisplayName" = "%s";' % n]
    open(s, 'w', encoding='utf-8').write('\n'.join(kept) + '\n')
print('ok')
PY

4. 步骤四:禁用自动升级

org2 必须钉在当前版本(升级会覆盖二进制、丢弃下两步的注入)。把数据目录下的 update 路径占死为 0 字节只读文件:

mkdir -p "$DATA"
rm -rf "$DATA/update" && touch "$DATA/update" && chmod 444 "$DATA/update"

效果:官方更新器写不进 update 目录,org2 永不升级;主实例照常升级互不影响。


5. 步骤五:菜单栏状态图标换文字(代理 dylib)

背景(为什么必须用 dylib):菜单栏纸飞机图标不在主进程,在 Lark Helper 进程里,由 Libraries/libbackend.dylib(Rust)从 frame_*.pak 解码 resource id 50485 后调 [NSStatusBarButton setImage:];frame_*.pak 任何字节改动都会 触发渲染进程启动期完整性 CHECK 直接崩溃(§9 的 resources.pak 没有该校验, 可安全替换),所以只能拦 setImage。

做法:真身改名,代理顶替原路径,转发全部符号,命中 18x16 模板图时换成自绘文字图。

5.1 渲染文字图(以 “lark” 为例,白色模板,超采样防缩糊):

WORK="$(mktemp -d)"
python3 - "$WORK" <<'PY'
import sys
from PIL import Image, ImageDraw, ImageFont
work = sys.argv[1]
FP, IDX, TEXT, GH, SCALE = "/System/Library/Fonts/Helvetica.ttc", 1, "lark", 11, 10
f160 = ImageFont.truetype(FP, 160, index=IDX)
bb = ImageDraw.Draw(Image.new("RGBA", (8, 8))).textbbox((0, 0), TEXT, font=f160)
tw, th = bb[2] - bb[0], bb[3] - bb[1]
W1 = round(tw * GH / th) + 3
fsize = round(160 * (GH * SCALE) / th)
big = Image.new("RGBA", (W1 * SCALE, 16 * SCALE), (0, 0, 0, 0))
d = ImageDraw.Draw(big)
fb = ImageFont.truetype(FP, fsize, index=IDX)
b2 = d.textbbox((0, 0), TEXT, font=fb)
d.text(((big.width - (b2[2] - b2[0])) // 2 - b2[0], (big.height - (b2[3] - b2[1])) // 2 - b2[1]),
       TEXT, font=fb, fill=(255, 255, 255, 255))
img1x = big.resize((W1, 16), Image.LANCZOS)
img2x = big.resize((W1 * 2, 32), Image.LANCZOS)
img1x.save(f"{work}/1x.png"); img2x.save(f"{work}/2x.png")
nz = sum(1 for p in img1x.getchannel("A").getdata() if p > 20)
assert nz > 60, f"文字像素过少({nz}),渲染异常"
print(f"lark 图: 1x={W1}x16, 2x={W1*2}x32")
PY

(字号必须乘 SCALE,否则 LANCZOS 缩完只剩一个点;末尾断言非透明像素数。)

5.2 生成代理源码(把两张 PNG 内嵌成 C 数组):

python3 - "$WORK" > "$WORK/proxy.m" <<'PY'
import struct, sys
work = sys.argv[1]
def carr(path, name):
    d = open(path, 'rb').read()
    rows = [",".join(str(b) for b in d[i:i + 16]) for i in range(0, len(d), 16)]
    return "static const unsigned char %s[] = {\n" % name + ",\n".join(rows) + "\n};\n"
w1 = struct.unpack('>I', open(f"{work}/1x.png", 'rb').read()[16:20])[0]
print('#import <AppKit/AppKit.h>')
print('#import <objc/runtime.h>')
print('static const int FRK_W1 = %d;' % w1)
print(carr(f"{work}/1x.png", "frk_png_1x"), end='')
print(carr(f"{work}/2x.png", "frk_png_2x"), end='')
print('''static IMP frk_orig_setImage = NULL;
static NSImage *frk_replacement = nil;
static NSImage *frk_build(void) {
    if (frk_replacement) return frk_replacement;
    NSBitmapImageRep *r1 = [[NSBitmapImageRep alloc] initWithData:[NSData dataWithBytes:frk_png_1x length:sizeof(frk_png_1x)]];
    NSBitmapImageRep *r2 = [[NSBitmapImageRep alloc] initWithData:[NSData dataWithBytes:frk_png_2x length:sizeof(frk_png_2x)]];
    if (!r1 || !r2) return nil;
    [r1 setSize:NSMakeSize(FRK_W1, 16)];
    [r2 setSize:NSMakeSize(FRK_W1 * 2, 32)];
    NSImage *im = [[NSImage alloc] init];
    [im addRepresentation:r1];
    [im addRepresentation:r2];
    [im setTemplate:YES];
    frk_replacement = im;
    return frk_replacement;
}
static void frk_hook_setImage(id self, SEL _cmd, NSImage *img) {
    if (img && (int)img.size.width == 18 && (int)img.size.height == 16 && img.isTemplate) {
        NSImage *rep = frk_build();
        if (rep) img = rep;
    }
    ((void(*)(id,SEL,id))frk_orig_setImage)(self, _cmd, img);
}
__attribute__((constructor))
static void frk_init(void) {
    Class c = NSClassFromString(@"NSStatusBarButton");
    Method m = c ? class_getInstanceMethod(c, @selector(setImage:)) : NULL;
    if (m) frk_orig_setImage = method_setImplementation(m, (IMP)frk_hook_setImage);
}''')
PY

5.3 真身让位 + 编译代理 + 验证:

LIBDIR="$FW/Libraries"
mv "$LIBDIR/libbackend.dylib" "$LIBDIR/libbackend_real.dylib"
install_name_tool -id "@loader_path/libbackend_real.dylib" "$LIBDIR/libbackend_real.dylib"
codesign -f -s - "$LIBDIR/libbackend_real.dylib"

clang -x objective-c -arch arm64 -dynamiclib -framework AppKit -fobjc-arc \
  -install_name "@loader_path/libbackend.dylib" \
  -o "$LIBDIR/libbackend.dylib" "$WORK/proxy.m" \
  -Wl,-reexport_library,"$LIBDIR/libbackend_real.dylib"

otool -l "$LIBDIR/libbackend.dylib" | grep -q LC_REEXPORT_DYLIB \
  && codesign -f -s - "$LIBDIR/libbackend.dylib" \
  && echo "代理 ok" \
  || { echo "失败,还原真身"; mv "$LIBDIR/libbackend_real.dylib" "$LIBDIR/libbackend.dylib"; }

原理:LC_REEXPORT_DYLIB 把真身全部符号经代理转发,dlsym 透明,Lark Helper 无感知;状态项按钮自适应宽度,文字图自动放宽。


6. 步骤六:屏蔽 org2 全局快捷键

两个实例同时注册 Carbon RegisterEventHotKey 会抢注打架(截图/唤起等失灵)。 做法:org2 专用 interpose dylib 让注册恒失败,再往主程序 Mach-O 头部空隙 注入一条 LC_LOAD_DYLIB 加载它。

6.1 编译 interpose dylib(frk_nk 文件名必须 ≤12 字符,头隙只塞得下 56 字节命令):

cat > /tmp/frk_nokey_src.m <<'MEOF'
#import <Carbon/Carbon.h>
extern OSStatus RegisterEventHotKey(UInt32 keyCode, UInt32 mods, EventHotKeyID id,
                                    EventTargetRef target, OptionBits options,
                                    EventHotKeyRef *outRef);
static OSStatus nk_RegisterEventHotKey(UInt32 keyCode, UInt32 mods, EventHotKeyID id,
                                       EventTargetRef target, OptionBits options,
                                       EventHotKeyRef *outRef) {
    if (outRef) *outRef = NULL;
    return eventHotKeyExistsErr;
}
#define FRK_INTERPOSE(repl, orig) \
  __attribute__((used)) static struct { const void *r; const void *o; } \
  _interpose_##orig __attribute__((section("__DATA,__interpose"))) = \
  { (const void *)(unsigned long)&repl, (const void *)(unsigned long)&orig };
FRK_INTERPOSE(nk_RegisterEventHotKey, RegisterEventHotKey)
MEOF

clang -x objective-c -arch arm64 -arch arm64e -dynamiclib -framework Carbon \
  -install_name @executable_path/frk_nk.dylib \
  -o /Applications/Lark-org2.app/Contents/MacOS/frk_nk.dylib /tmp/frk_nokey_src.m
codesign -f -s - /Applications/Lark-org2.app/Contents/MacOS/frk_nk.dylib

6.2 往主程序头部空隙注入 LC_LOAD_DYLIB(解析 load commands,在尾部与 __text 首地址之间的空隙追加一条加载命令并同步 ncmds/sizeofcmds):

python3 - <<'PY'
import struct
p = "/Applications/Lark-org2.app/Contents/MacOS/Feishu"
d = bytearray(open(p, 'rb').read())
magic, cputype, cpusub, ft, ncmds, sizeofcmds, flags = struct.unpack('<7I', d[:28])
off, seen, text_off = 32, 0, None
while seen < ncmds:
    cmd, cmdsize = struct.unpack('<II', d[off:off+8])
    if cmd == 0x19:                      # LC_SEGMENT
        name = d[off+8:off+24].rstrip(b'\0').decode()
        nsects = struct.unpack('<I', d[off+64:off+68])[0]
        so = off + 72
        for i in range(nsects):
            sect = d[so:so+80]
            if name == '__TEXT' and sect[0:16].rstrip(b'\0') == b'__text':
                text_off = struct.unpack('<I', sect[48:52])[0]
            so += 80
    off += cmdsize; seen += 1
name = b"@executable_path/frk_nk.dylib"
cmdsize = (24 + len(name) + 1 + 7) & ~7
end = 32 + sizeofcmds
assert end + cmdsize <= text_off, "header gap 不足"
cmd = struct.pack('<III', 0xC, cmdsize, 24) + struct.pack('<III', 2, 0, 0) + name.ljust(cmdsize-24, b'\0')
d[end:end+cmdsize] = cmd
struct.pack_into('<I', d, 16, ncmds+1)
struct.pack_into('<I', d, 20, sizeofcmds+cmdsize)
open(p, 'wb').write(d)
print('LC_LOAD_DYLIB 注入 ok')
PY

只改了 org2 的二进制,主实例快捷键不受影响。


7. 步骤七:整包重签 + 注册

# 改过二进制必须重签;⚠️ 绝不能带 entitlements——含 keychain-access-groups 的受限
# 权限会被内核拒 spawn(POSIX 162,app 起不来)。ad-hoc 无 runtime flag 本就不受限,
# 实测登录态无损。
codesign -f -s - --identifier com.electron.lark /Applications/Lark-org2.app

/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \
  -f /Applications/Lark-org2.app

8. 首次启动

open /Applications/Lark-org2.app
  • 登录第二个账号,登录态/消息落在 LarkShell-org2,与主实例( LarkShell)完全隔离;
  • org2 首启会重置 com.electron.lark-notifier 通知权限;若主实例通知失效, 系统设置 → 通知 → 飞书,重新允许一次;
  • org2 重启后聊天页常停在空白页(不自动加载 messenger),用深链唤醒: open -a /Applications/Lark-org2.app "lark://client/chat" (必须带 -a 定向;双开时裸 lark:// 可能路由到主实例)。

9. 可选:Dock 图标与站内 logo 差异化(纯外观)

  • Dock/Finder 图标:任取一个 icns(如紫色 + 角标 2 的自制图)覆盖: cp 你的.icns /Applications/Lark-org2.app/Contents/Resources/app.icns && touch /Applications/Lark-org2.app

  • 站内 UI logo(关于页/加载页):在 resources.pak 内 16/24/64/128 四张 PNG 原位字节替换(只动 resources.pak,绝不动 frame_*.pak——后者有完整性 CHECK 必崩)。 先从当前版本 pak 提取原图字节(存成 16_orig.png 等),做一张同尺寸不大于原图的 替换图(存成 16_new.png 等),然后:

    python3 - "$FW/Resources/resources.pak" /path/to/logodir <<'PY'
    import sys, os
    pak, logo_dir = sys.argv[1], sys.argv[2]
    data = open(pak, 'rb').read()
    patched = 0
    for f in sorted(os.listdir(logo_dir)):
        if not f.endswith('_orig.png'):
            continue
        orig = open(os.path.join(logo_dir, f), 'rb').read()
        new = open(os.path.join(logo_dir, f.replace('_orig.png', '_new.png')), 'rb').read()
        assert len(new) <= len(orig), f + " 替换图必须不大于原图"
        i = data.find(orig)
        if i < 0:
            print(f'跳过 {f}(字节未命中,版本已变)')
            continue
        data = data[:i] + new + b'\x00' * (len(orig) - len(new)) + data[i + len(orig):]
        patched += 1
    open(pak, 'wb').write(data)
    print(f'替换 {patched} 张 logo')
    PY
    

    提取原图的简易办法:用 PIL 以已知尺寸(16/24/64/128)在 pak 里做 PNG 魔数扫描 (\x89PNG\r\n\x1a\n)+ IHDR 尺寸匹配定位,截取完整 IDAT 段存盘。字节对不上就 跳过,无害。


10. 验证清单

pgrep -fl Feishu          # 双实例:两条不同路径的主进程
ls -la "$DATA/update"     # 0 字节只读占位(=永不升级)
检查 预期
同时开两个飞书 互不顶掉、各自登录不同账号
关于页/Dock org2 显示「飞书2」,主实例「飞书」
菜单栏 org2 是白色 “lark” 文字图标,主实例纸飞机
全局快捷键 双开下主实例截图/唤起正常(org2 注册恒失败,无抢注)
升级 主实例升级后 org2 版本不变

11. 升级与运维

场景 操作
主实例飞书升级 /Applications/Lark-org2.app(登录态在 LarkShell-org2,不丢)→ 从第 1 步重做(约 2 分钟);若主实例带防撤回补丁,副本自动带上
只重启 org2 pgrep -f "Lark-org2.app/Contents/MacOS/Feishu" | xargs kill -9;禁止 pkill -x Feishu(会杀两个)
只重启主实例 pgrep -f "/Applications/Lark.app/Contents/MacOS/Feishu" | xargs kill -9(全路径模式不会误杀 org2)
防撤回类 asar 补丁 org2 与主实例各打各的(asar 在各自 .app 内,互不跟随)

12. 常见偏差对照

现象 原因与处理
org2 启动把主实例顶掉(单实例弹窗) manifest userDataPath 没改到——确认改的是最新版本目录下的 manifest.json(见 §0 的 VERDIR 取法)
org2 闪退,报 POSIX 162 整包重签带了 entitlements——用 §7 的裸命令重签
org2 一启动就崩(渲染进程 EXC_BREAKPOINT) 动了 frame_*.pak 字节——菜单栏图标只走 §5 代理 dylib,pak 不可改
双开后 lark:// 深链没反应 路由到了另一个实例,加 -a <app 路径> 定向
org2 被官方升级覆盖 update 占位失效——检查 $DATA/update 是否 0 字节且 chmod 444
菜单栏仍是纸飞机 §5 步骤被跳过(缺 clang/PIL)或新版本图标尺寸变了(不再 18x16,调整 hook 里的尺寸判断)
Dock 图标没变 覆盖 icns 后没 touch .app,Launch Services 缓存未刷新