cubegao

Mac APP 逆向开发:Catalyst 双层架构

2026-06-22

缘起

我们公司 iOS 小组一直在用一款内部 IM 工具,它最初是以 Mac Catalyst 方式从 iOS App 转换而来的。两年前一位同事编译了一份 .dmg,丢在共享文件夹里,大家就这么用了两年。

直到最近,同事反馈在 ARM 架构的 Mac 上显示问题比较多。除此之外,这个 Catalyst 版本本身还有一些”历史遗留问题”:没有工作状态展示、没有日程入口、富文本消息里的图片点不开、视频播放器不能暂停也不能拖进度条。

既然只是小组内部使用,源码工程非常庞大且编译报错,其实逆向就是一个非常适合的办法。

搞清楚是怎么跑起来的:Catalyst 双世界

Mac Catalyst 是一个很微妙的运行时。它本质上是把 UIKit App 跑在 macOS 上,编译目标为 x86_64-apple-ios-macabi。这意味着:

  • UI 层面用的是 UIKit(UIViewUIViewController),而不是 AppKit
  • 但它又运行在真正的 macOS 进程空间里,理论上可以 dlopen 加载纯 macOS 的动态库

这个”双世界”特征正是我们逆向方案的核心支点:在同一个进程里,同时运行 UIKit 注入代码和原生 AppKit 代码

整体思路:双 dylib 协同注入

我们设计了两个协同工作的动态库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌──────────────────────────────────────┐
│ 跨声.app (Catalyst) │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ KKInject │ │ KKMacHelper │ │
│ │ .dylib │ │ .dylib │ │
│ │ │ │ │ │
│ │ UIKit 世界 │◄─┤ AppKit 世界 │ │
│ │ Hook + 拦截 │ │ 原生窗口 │ │
│ │ │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ 事件总线 │ │
│ └─── NSNotification ◄───────┘ │
│ Center │
└──────────────────────────────────────┘
  • KKInject.dylib:编译目标为 x86_64-apple-ios-macabi(与 Catalyst 一致),负责 Hook UIKit 层的类,拦截用户交互和网络请求
  • KKMacHelper.dylib:编译目标为 x86_64-apple-macos(纯 Mac),负责创建原生 NSWindow、处理日程展示等需要 AppKit 的功能

两者的通信通过 macOS 进程内的 NSNotificationCenter 完成——因为它们被加载到同一个进程空间,通知可以无缝广播。

第一步:让 dylib 注入进去

要让你的代码跑在别人 App 的进程里,核心步骤是把 dylib 塞进 App 的加载链。我们选用的是 insert_dylib 这个开源工具,它直接修改 Mach-O 的 Load Commands。

简化后的注入脚本逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 1. 将两个 dylib 复制到 App bundle 内
cp KKInject.dylib "$APP_PATH/Contents/Frameworks/"
cp KKMacHelper.dylib "$APP_PATH/Contents/Frameworks/"

# 2. 用 insert_dylib 向主二进制添加加载命令
insert_dylib @rpath/KKInject.dylib "$APP_PATH/Contents/MacOS/跨声" --all-yes

# 3. 修改 entitlements,允许加载未签名 dylib
# 关键:必须添加 com.apple.security.cs.disable-library-validation
plutil -replace com.apple.security.cs.disable-library-validation -bool YES \
"$APP_PATH/Contents/Resources/entitlements.plist"

# 4. 重签名整个 bundle
codesign --force --deep -s - "$APP_PATH"

KKInject.dylib 是注入入口,它在 constructor 中负责使用 dlopen 加载 KKMacHelper.dylib

1
2
3
4
5
6
7
8
9
10
11
12
__attribute__((constructor))
static void kk_init(void) {
@autoreleasepool {
KKHookHelper *hookHelper = [KKHookHelper sharedInstance];
[hookHelper startAllHooks];

// 加载 Mac 原生动态库
NSString *helperPath = [[NSBundle mainBundle].bundlePath
stringByAppendingPathComponent:@"Contents/Frameworks/KKMacHelper.dylib"];
dlopen([helperPath UTF8String], RTLD_NOW);
}
}

把这一切串起来,一个 start.sh 就能实现一键构建、注入、启动。

核心武器:Method Swizzling 框架

逆向开发中,Method Swizzling 是最基础也最可靠的手段。我们封装了一个 KKHookHelper,它提供了一套安全的方法替换 API。这个库不涉及任何业务逻辑,下面是完整实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// KKHookHelper.h
@interface KKHookHelper : NSObject

+ (instancetype)sharedInstance;

// 实例方法 Hook
- (void)hookInstanceMethod:(SEL)originalSelector
fromClass:(Class)originalClass
withNewClass:(Class)newClass
andNewMethod:(SEL)newSelector;

// 类方法 Hook
- (void)hookClassMethod:(SEL)originalSelector
fromClass:(Class)originalClass
withNewClass:(Class)newClass
andNewMethod:(SEL)newSelector;

// 批量启动所有 Hook 模块
- (void)startAllHooks;

@end
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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// KKHookHelper.m
#import "KKHookHelper.h"
#import "KKLog.h"
#import <objc/runtime.h>

@implementation KKHookHelper

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

- (void)hookInstanceMethod:(SEL)originalSelector
fromClass:(Class)originalClass
withNewClass:(Class)newClass
andNewMethod:(SEL)newSelector {

if (!originalClass || !newClass) {
KKLog(@"Hook failed: nil class - original: %@, new: %@",
originalClass, newClass);
return;
}

Method originalMethod = class_getInstanceMethod(originalClass, originalSelector);
Method newMethod = class_getInstanceMethod(newClass, newSelector);

if (!originalMethod) {
KKLog(@"Warning: original method '%@' not found in class '%@'",
NSStringFromSelector(originalSelector), NSStringFromClass(originalClass));
return;
}

IMP originalIMP = method_getImplementation(originalMethod);
IMP newIMP = method_getImplementation(newMethod);
const char *typeEncoding = method_getTypeEncoding(originalMethod);

// 尝试给原类添加新方法(指向新实现)
BOOL didAdd = class_addMethod(originalClass,
newSelector,
newIMP,
typeEncoding);

if (didAdd) {
// 添加成功,说明原类没有新方法,现在交换
Method addedMethod = class_getInstanceMethod(originalClass, newSelector);
method_exchangeImplementations(originalMethod, addedMethod);
KKLog(@"Hook success (exchange): -[%@ %@]",
NSStringFromClass(originalClass), NSStringFromSelector(originalSelector));
} else {
// 添加失败,说明方法已存在,直接替换
class_replaceMethod(originalClass,
originalSelector,
newIMP,
typeEncoding);
KKLog(@"Hook success (replace): -[%@ %@]",
NSStringFromClass(originalClass), NSStringFromSelector(originalSelector));
}
}

- (void)hookClassMethod:(SEL)originalSelector
fromClass:(Class)originalClass
withNewClass:(Class)newClass
andNewMethod:(SEL)newSelector {

// 类方法 Hook 本质上是对 meta class 进行实例方法 Hook
Class originalMetaClass = object_getClass(originalClass);
Class newMetaClass = object_getClass(newClass);

[self hookInstanceMethod:originalSelector
fromClass:originalMetaClass
withNewClass:newMetaClass
andNewMethod:newSelector];
}

- (void)startAllHooks {
// 在此注册所有 Hook 模块
// 每个模块在 +load 或手动调用中加入 hook 注册队列
[[NSNotificationCenter defaultCenter]
postNotificationName:@"KKStartAllHooks" object:nil];
}

@end

这套框架的精髓在于三点:

  1. 安全检查:Hook 前验证 original class、original method 是否存在,防止因版本差异导致崩溃
  2. 双路径策略class_addMethod + method_exchangeImplementations 是第一选择,class_replaceMethod 是 fallback,兼容各种场景
  3. 统一日志:每次 Hook 都有日志记录,出问题时可以快速定位是哪个 Hook 失败了

打通两个世界:进程内事件总线

KKInject(UIKit 世界)和 KKMacHelper(AppKit 世界)需要在同一个进程中通信。最自然的方式就是 NSNotificationCenter——不需要 Mach Port、不需要 XPC Service,简单直接。

我们将相同的源文件分别编译进两个 dylib:

1
2
3
4
5
6
7
// KKEventBus.h — 进程内事件总线(两份源码,编译进两个 dylib)
@interface KKEventBus : NSObject

+ (void)postEvent:(NSString *)eventType payload:(NSDictionary *)payload;
+ (id)observeEvent:(NSString *)eventType handler:(void(^)(NSDictionary *payload))handler;

@end
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
// KKEventBus.m
#import "KKEventBus.h"

static NSString * const kKKEventNotification = @"com.kk.internal.eventbus";

@implementation KKEventBus

+ (void)postEvent:(NSString *)eventType payload:(NSDictionary *)payload {
NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:payload ?: @{}];
userInfo[@"eventType"] = eventType;

[[NSNotificationCenter defaultCenter]
postNotificationName:kKKEventNotification
object:nil
userInfo:userInfo];
}

+ (id)observeEvent:(NSString *)eventType
handler:(void(^)(NSDictionary *payload))handler {

return [[NSNotificationCenter defaultCenter]
addObserverForName:kKKEventNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification *note) {
NSString *type = note.userInfo[@"eventType"];
if ([type isEqualToString:eventType]) {
handler(note.userInfo);
}
}];
}

@end

这种方式的关键在于:两个 dylib 编译的是同一份源码,注册到的是同一个 NSNotificationCenter 实例(因为它们在同一进程空间)。KKInject 侧 post 一个 schedule_open 事件,KKMacHelper 侧就能收到,然后创建原生 NSWindow。

逐个击破:修复六大问题

问题一:ARM 架构崩溃

现象:M 芯片 Mac 上启动即闪退。

排查:查看崩溃日志,发现是 x86-only 的 dylib 在 ARM 架构下无法加载。Catalyst App 的编译目标从 x86_64-apple-ios 改为 arm64-apple-ios + x86_64-apple-ios 双架构即可。

解决:在 build.sh 中修改 -arch x86_64-arch x86_64 -arch arm64,编译 Universal Binary 的 dylib。同时,insert_dylib 工具也需要替换为支持 ARM64 的版本。

1
2
3
4
5
6
7
8
# 之前
clang -arch x86_64 -target x86_64-apple-ios14.0-macabi ...

# 之后
clang -arch x86_64 -arch arm64 \
-target x86_64-apple-ios14.0-macabi \
-Xlinker -target -Xlinker arm64-apple-ios14.0-macabi \
...

问题二:工作状态不显示

现象:同事的头像旁看不到”在线/忙碌/离开”等状态标签。

分析:通过 Class Dump 分析发现,工作状态由一个 [工作状态视图] 负责渲染。它在 layoutSubviews 时使用系统默认字号,在 Mac 大屏幕上显得不协调。

Hook 方案(伪代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 获取状态视图类
Class statusViewCls = NSClassFromString(@"内部工作状态视图类");

// Hook layoutSubviews
[hookHelper hookInstanceMethod:@selector(layoutSubviews)
fromClass:statusViewCls
withNewClass:[self class]
andNewMethod:@selector(kk_status_layoutSubviews)];

- (void)kk_status_layoutSubviews {
// 1. 调用原始实现
[self kk_status_layoutSubviews];

// 2. 修正字号
UILabel *statusLabel = [self valueForKey:@"内部状态标签属性"];
statusLabel.font = [UIFont systemFontOfSize:14];
}

问题三:缺少日程入口

现象:原始 Catalyst 版本侧边栏只有”消息””文档”等入口,没有日程。

方案:使用 三层 Hook 组合拳——Hook 侧边栏添加按钮、Hook 首页菜单添加快捷入口、Hook 事件路由转发打开指令。

Hook 侧边栏添加日程按钮(伪代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Hook 侧边栏的 initWithFrame:
Class sidebarCls = NSClassFromString(@"内部侧边栏类");
[hookHelper hookInstanceMethod:@selector(initWithFrame:)
fromClass:sidebarCls
withNewClass:[self class]
andNewMethod:@selector(kk_sidebar_initWithFrame:)];

- (instancetype)kk_sidebar_initWithFrame:(CGRect)frame {
// 调用原始初始化
id self_ = [self kk_sidebar_initWithFrame:frame];

// 找到"文档"按钮的位置,在其下方插入"日程"按钮
UIButton *scheduleBtn = [UIButton buttonWithType:UIButtonTypeCustom];
[scheduleBtn setTitle:@"日程" forState:UIControlStateNormal];
[scheduleBtn setImage:[UIImage imageNamed:@"calendar_icon"]
forState:UIControlStateNormal];
[scheduleBtn addTarget:self_ action:@selector(kk_onScheduleTapped)
forControlEvents:UIControlEventTouchUpInside];
[self_ addSubview:scheduleBtn];

return self_;
}

日程窗口的打开:KKInject 侧发出事件,KKMacHelper 侧用 AppKit 创建原生窗口:

1
2
3
4
5
6
7
8
9
10
11
// KKInject 侧:点击日程按钮时
[KKEventBus postEvent:@"schedule_open" payload:@{@"date": dateStr}];

// KKMacHelper 侧:收到事件后创建 NSWindow
+ (void)load {
[KKEventBus observeEvent:@"schedule_open" handler:^(NSDictionary *payload) {
dispatch_async(dispatch_get_main_queue(), ^{
[[KKScheduleWindowController shared] showWindow];
});
}];
}

问题四:富文本消息中图片无法预览

现象:群聊中发送的图文混排消息,点击图片没有反应。

分析:原有逻辑中,图片点击事件被转发到一个仅实现了一半的预览器,图片 URL 拿到了但没有渲染出来。需要 Hook 富文本内容视图的消息绑定方法和图片容器的点击方法。

方案核心(伪代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Hook: 富文本内容视图绑定消息时
Class richTextCls = NSClassFromString(@"内部富文本内容视图");
[hookHelper hookInstanceMethod:@selector(内部UI绑定方法:)
fromClass:richTextCls
withNewClass:[self class]
andNewMethod:@selector(kk_richtext_uiBind:)];

- (void)kk_richtext_uiBind:(id)message {
[self kk_richtext_uiBind:message];

// 注册图片点击回调
// 遍历 attributedText 中的图片链接属性
// 收集所有图片 URL → 传给图片预览器
NSArray *images = /* 从 message 中提取图片URL列表 */;
if (images.count > 0) {
[KKImagePreviewer showWithImageURLs:images
currentIndex:0];
}
}

问题五:图片预览器实现

这是纯业务无关的 UI 代码,可以详细展开。我们基于 WKWebView + Viewer.js 实现了一个全功能图片浏览器:

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
// KKImagePreviewer.h
@interface KKImagePreviewer : UIViewController

+ (void)showWithImageURLs:(NSArray<NSString *> *)urls
currentIndex:(NSInteger)index;

@end

// KKImagePreviewer.m
#import "KKImagePreviewer.h"
#import <WebKit/WebKit.h>

@interface KKImagePreviewer () <WKNavigationDelegate>
@property (nonatomic, strong) WKWebView *webView;
@property (nonatomic, strong) NSArray<NSString *> *imageURLs;
@property (nonatomic, assign) NSInteger currentIndex;
@end

@implementation KKImagePreviewer

+ (void)showWithImageURLs:(NSArray<NSString *> *)urls
currentIndex:(NSInteger)index {

KKImagePreviewer *previewer = [[KKImagePreviewer alloc] init];
previewer.imageURLs = urls;
previewer.currentIndex = index;
previewer.modalPresentationStyle = UIModalPresentationFullScreen;

// 获取顶层 ViewController 并 present
UIViewController *rootVC = /* 获取当前顶层VC */;
[rootVC presentViewController:previewer animated:YES completion:nil];
}

- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor blackColor];

// 关闭按钮
UIButton *closeBtn = [UIButton buttonWithType:UIButtonTypeSystem];
closeBtn.frame = CGRectMake(20, 40, 44, 44);
[closeBtn setTitle:@"✕" forState:UIControlStateNormal];
closeBtn.titleLabel.font = [UIFont systemFontOfSize:24];
[closeBtn addTarget:self action:@selector(dismiss)
forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:closeBtn];

// 下载按钮
UIButton *downloadBtn = [UIButton buttonWithType:UIButtonTypeSystem];
downloadBtn.frame = CGRectMake(self.view.bounds.size.width - 64, 40, 44, 44);
[downloadBtn setTitle:@"↓" forState:UIControlStateNormal];
downloadBtn.titleLabel.font = [UIFont systemFontOfSize:24];
[downloadBtn addTarget:self action:@selector(downloadCurrentImage)
forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:downloadBtn];

// 构建 Viewer.js 的 HTML
NSString *html = [self buildViewerHTML];

self.webView = [[WKWebView alloc] initWithFrame:self.view.bounds];
self.webView.backgroundColor = [UIColor blackColor];
self.webView.navigationDelegate = self;
[self.webView loadHTMLString:html baseURL:nil];
[self.view insertSubview:self.webView atIndex:0];
}

- (NSString *)buildViewerHTML {
// 生成图片列表 HTML
NSMutableString *imagesHTML = [NSMutableString string];
for (NSString *url in self.imageURLs) {
[imagesHTML appendFormat:@"<div><img src=\"%@\" style=\"display:none\"></div>\n", url];
}

// 读取本地 viewer.js 和 viewer.css
NSString *viewerJS = [self loadResourceFile:@"viewer.min.js"];
NSString *viewerCSS = [self loadResourceFile:@"viewer.min.css"];

return [NSString stringWithFormat:@"<!DOCTYPE html>"
"<html><head>"
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1,maximum-scale=1\">"
"<style>%@</style>"
"<style>body{margin:0;background:#000}"
".viewer-toolbar>li{font-size:16px}</style>"
"</head><body>"
"<div id=\"gallery\">%@</div>"
"<script>%@</script>"
"<script>new Viewer(document.getElementById('gallery'),{"
"toolbar:{prev:1,next:1,zoomIn:1,zoomOut:1,"
"oneToOne:1,reset:1,download:function(){"
"window.webkit.messageHandlers.download.postMessage("
"this.image.src)}},"
"keyboard:1,initialViewIndex:%ld})</script>"
"</body></html>",
viewerCSS, imagesHTML, viewerJS, (long)self.currentIndex];
}

- (void)dismiss {
[self dismissViewControllerAnimated:YES completion:nil];
}

- (void)downloadCurrentImage {
// 通过 JS 获取当前图片 URL,下载到 ~/Downloads
[self.webView evaluateJavaScript:
@"document.querySelector('.viewer-canvas img').src"
completionHandler:^(NSString *url, NSError *error) {
if (url) {
NSData *data = [NSData dataWithContentsOfURL:[NSURL URLWithString:url]];
NSString *fileName = [url lastPathComponent];
NSString *downloadPath = [@"~/Downloads" stringByAppendingPathComponent:fileName];
[data writeToFile:[downloadPath stringByExpandingTildeInPath] atomically:YES];
}
}];
}

- (NSString *)loadResourceFile:(NSString *)filename {
NSString *path = [[NSBundle mainBundle]
pathForResource:[filename stringByDeletingPathExtension]
ofType:[filename pathExtension]];
return [NSString stringWithContentsOfFile:path
encoding:NSUTF8StringEncoding error:nil];
}

@end

问题六:视频播放器不能暂停和快进

现象:点击视频消息后,视频开始播放但无法暂停,也无法拖进度条。

方案:同样使用 WKWebView,搭载 Plyr 播放器。Plyr 内置了完整的控制条:播放/暂停按钮、进度条拖拽、快进快退、音量控制等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// KKVideoPreviewer.m 核心 HTML 构建逻辑
- (NSString *)buildPlayerHTML {
NSString *plyrJS = [self loadResourceFile:@"plyr.js"];
NSString *plyrCSS = [self loadResourceFile:@"plyr.css"];

return [NSString stringWithFormat:@"<!DOCTYPE html>"
"<html><head>"
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
"<link rel=\"stylesheet\" href=\"%@\">"
"<style>body{margin:0;display:flex;align-items:center;"
"justify-content:center;background:#000;height:100vh}"
".plyr{width:100%%;max-width:900px}</style>"
"</head><body>"
"<video id=\"player\" controls playsinline>"
"<source src=\"%@\"></video>"
"<script src=\"%@\"></script>"
"<script>new Plyr('#player',{"
"controls:['play-large','play','progress','current-time',"
"'duration','mute','volume','pip','fullscreen'],"
"keyboard:{focused:true,global:true}})</script>"
"</body></html>",
plyrCSS, self.videoURL, plyrJS];
}

这样视频播放就获得了完整的控制能力:暂停、拖拽进度条、快进快退 5 秒、画中画、全屏。

编译、注入、打包一条龙

最终的构建流程由 start.sh 统一管理:

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/bash

# 1. 编译 KKMacHelper(纯 Mac 动态库)
cd KKMacHelper/Scripts
sh build.sh

# 2. 编译 KKInject(Catalyst 动态库)
cd ../../KKInject/Scripts
sh build.sh

# 3. 注入并启动
sh inject.sh 首次启动

build.sh 的核心编译命令:

1
2
3
4
5
6
7
8
clang -dynamiclib \
-arch x86_64 -arch arm64 \
-target x86_64-apple-ios14.0-macabi \
-isysroot $(xcrun --sdk macosx --show-sdk-path) \
-F $(xcrun --sdk macosx --show-sdk-path)/System/Library/Frameworks \
-framework Foundation -framework UIKit -framework WebKit \
-o KKInject.dylib \
Sources/*.m Sources/Modules/*.m

写在最后

整个过程下来,最深的感受是:Mac Catalyst 的”双世界”特性是逆向开发者的福音。同一个进程里可以运行 UIKit Hook 代码和原生 AppKit 代码,这让我们既能拦截 iOS 层的交互,又能创建原生 Mac 窗口。

几个踩坑经验:

  1. +load vs __attribute__((constructor))+load 在 constructor 之前执行,如果 KKMacHelper 的 +load 依赖 KKInject 的初始化,就要注意调用顺序
  2. NSNotificationCenter 的跨 dylib 陷阱:如果用 object: 参数指定 nil 以外的对象,而那个对象的类在另一个 dylib 中只有一份符号表,通知可能收不到。建议 object: 统一传 nil,用 userInfo 中的字段做路由
  3. Viewer.js 在 WKWebView 中的本地文件加载:WKWebView 默认不允许加载本地 JS/CSS 文件。需要在 HTML 中内联 <script><style>,或者将资源文件复制到 App 的 Resources 目录并用 file:// 协议
  4. Catalyst 的 sandbox 限制:注入的 dylib 继承了 App 的 sandbox,写文件只能写到 /Downloads、/Desktop 等用户批准过的目录

逆向不是目的,解决问题才是。当你面对一个没有源码、但有明确痛点的内部工具时,逆向 + Hook 注入可能是最务实的解法。

扫描二维码,分享此文章