普通视图

发现新文章,点击刷新页面。
昨天以前小猪的博客

一段简单逆向之旅-绕开Xcode 13.3最低macOS 12.0限制

作者 DreamPiggy
2022年3月26日 04:47

因为众所周知的原因,苹果的Xcode版本会不断提高自己的最低安装版本,在Xcode 13.0-13.2.1上,这个最低安装版本是macOS 11

而随着Xcode 13.3正式版放出,这个最低部署版本在最后关头被提升到了macOS 12

Why?

一般来说,各位开发者或者众多基建,总有各种各样的原因需要暂时留在老版本的macOS系统上,但是又希望使用新Xcode版本自带的Toolchain进行一些工作开发调试,有些是主观问题,有些是客观限制:

举例子:

  1. macOS 12禁止了sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate off绕过GUI配置,导致一些公司采取Apple Device Management管理的电脑,无法正常关闭TCP拦截,会导致一些服务异常
  2. macOS 12加强了Kernel Extension的安全性,导致GitHub Action和Circle CI截止2022年3月底,迟迟无法更新他们的虚拟化集群到macOS 12,只有11.6的最新版本

这些都是闲聊,进入正题。那么有没有办法能够绕开,或者从原理上来讲,是否这个系统绑定的最低部署版本限制是必要的?

先放结论

  1. 可以绕过这个macOS 12的最低安装版本限制运行
  2. 这个限制是非必要的,绕过以后所有功能正常可用(构建,独立工具集,调试,连接iPhone)

下面来说明具体的逆向流程,和进行绕过的简单Step-by-step手法

安装Xcode

先说明测试机器Mac环境和Xcode环境:

  1. macOS 11.4 (20F71)
  2. Xcode Version 13.2 (13C90):需要保留一份以防万一
  3. Xcode Version 13.3 (13E113):目标安装的版本

首先,作为iOS/macOS开发者,我们肯定会使用dmg的格式,或者使用Xcodes.app来安装我们的Xcode 13.3了(App Store安装Xcode曾经出的坑:App Store version of Xcode 13.2 causing problems for developers,我是不会再用了)

安装完毕后,我们在Finder中看到的Xcode.app是一个画着❎的样子,直接打开会提示如下:

1648210752406_af87e2021743b9639d35f86714f127c7

绕过GUI部分的限制

修改LSMinimumSystemVersion

作为iOS/macOS开发者,我们第一想到的就是,是否是Xcode.app对应的Info.plist中,设置了和最低部署版本相关的字段导致拒绝载入呢?

我们用另一个Xcode(或者plistutil)打开Xcode.app/Contents/Info.plist,果然发现了对应的字段:

1648210752770_b314e1b23bdd01fe803ede1383e29491

这个LSMinimumSystemVersion是Mac应用标准的声明最低部署版本的方式,修改为你的机器当前OS版本之后保存,执行

1
2
touch /Applications/Xcode-13.3.0.app
killall Finder

重新尝试双击。不错,这次我们打开了,初看起来不错(直到我们正式开始编译)!

1648210752322_3d4e9ae78eaab94de9cfc7d1f8eaecf0

绕过CLI部分的限制

神奇的xcrun

但是只要创建一下工程并执行编译,就会发现,各种命令行工具的调用是有问题的,比如我们先通过xcode-select设置为当前的Xcode 13.2,尝试执行:

1648210752844_f21e111a081ff5dfc61dab7137109a39
1648210752266_e83287242af496cbbec7817b4b60f9f0

但是我们如果直接找到,执行对应绝对路径的clang,是可以执行的

1648210752227_fd8cd9f89407475fe62c0489dc61a232

并且,我们可以直接检查clang这个二进制,是否链接时设置了target,这部分可以使用otool -l读取machO Header查看到:

1648210752133_02c1257232efb1e2adebcb51ea25ceb0

好,最低部署版本是macOS 10.14.6;那现在我们有充分的证据说明,一定可以在我当前的电脑运行clang,而上述提示应该是xcrun这个调度器,添加了额外的判断。

通过搜索关键词,可以在Xcode的strings输出中找到这句“Executable requires at least”的关键字:参考仓库:Xcode.app-strings

反编译libxcodebuildLoader

我们定位到这个libxcodebuildLoader.dylib,拖进Hopper尝试反编译理解他检查的原理,伪代码如下:

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
void _checkMinimumOSVersion(int arg0) {
var_2C = 0x0;
rbx = 0x0;
rax = _NSGetExecutablePath(0x0, &var_2C);
rdi = var_2C;
if (rdi != 0x0) {
rbx = malloc(rdi);
}
rax = _NSGetExecutablePath(rbx, &var_2C);
if (rax != 0x0) goto loc_282c;

loc_26fc:
rax = [NSString stringWithUTF8String:rbx];
rax = [rax retain];
r14 = rax;
r15 = [[NSURL fileURLWithPath:rax] retain];
if (rbx != 0x0) {
free(rbx);
}
rbx = CFBundleCopyInfoDictionaryForURL(r15);
CFRelease(r15);
if (rbx == 0x0) goto loc_2867;

loc_276a:
r13 = [CFDictionaryGetValue(rbx, @ DVTMinimumSystemVersion ) retain];
CFRelease(rbx);
if ((r13 == 0x0) || ([r13 length] == 0x0)) goto loc_280c;

loc_27a6:
r12 = *_objc_msgSend;
r15 = [[DVTVersion versionWithStringValue:r13] retain];
rax = [DVTVersion currentSystemVersion];
rax = [rax retain];
rbx = r12;
r12 = rax;
rdx = r15;
if ([rax isEqualToOrNewerThanVersion:rdx] == 0x0) goto loc_288c;

loc_27fb:
[r12 release];
[r15 release];
goto loc_280c;

loc_280c:
[r13 release];
[r14 release];
return;

loc_288c:
r15 = [(rbx)(r15, @selector(stringValue), rdx) retain];
rax = (rbx)(r12, @selector(stringValue), rdx);
rax = [rax retain];
r14 = [(rbx)(@class(NSString), @selector(stringWithFormat:), @ Executable requires at least macOS %@, but is being run on macOS %@, and so is exiting. , r15, rax) retain];
[rax release];
[r15 release];
fprintf(**___stderrp, %s\n , (rbx)(objc_retainAutorelease(r14), @selector(UTF8String), @ Executable requires at least macOS %@, but is being run on macOS %@, and so is exiting. ));
goto loc_292b;

loc_292b:
exit(0x1);
return;

loc_2867:
fwrite( Unable to open executable info dictionary; xcodebuild may be corrupt and should be reinstalled.\n , 0x60, 0x1, **___stderrp);
goto loc_292b;

loc_282c:
_DVTAssertionFailureHandler(*_self, *__cmd, void checkMinimumOSVersion() , /Library/Caches/com.apple.xbs/Sources/IDETools/IDETools-20008/xcodebuildLoader/xcodebuildLoader.m , 0x69, @ 0 , @ Couldn't get executable path to self! );
return;
}

好,阅读伪代码以及查阅资料可知:

xcrun会先一步调用到xcodebuild,检查DVTMinimumSystemVersion这个变量的值是否和当前OS版本匹配。

而这个变量,竟然是通过CFBundleCopyInfoDictionaryForURL打开的。

参考苹果的函数说明,它除了常规的打开一个.bundle的文件夹,解析为NSBundle.infoDictionary以外,竟然能打开存在于二进制__TEXT,__info_plist中的数据来解析为一个字典。所以我们接下来去找xcodebuild的二进制看看。

1648210752247_62087827de34d7b56eea1e48208b261e

参考:

  1. _NSGetExecutablePath函数说明,大概理解获取当前程序的可执行路径

反编译xcodebuild

同时,出于好奇,我们可以再把xcodebuild拖进Hopper去尝试理解,发现它整个程序竟然只有一个main函数,逻辑其实都在libxcodebuildLoader.dylib

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
void _main(int arg0, int arg1) {
r14 = arg1;
rbx = arg0;
rax = dlopen( @rpath/libxcodebuildLoader.dylib , 0x1);
if (rax == 0x0) goto loc_100002b7f;

loc_100002b5c:
rax = dlsym(rax, XcodeBuildMain );
if (rax == 0x0) goto loc_100002bd8;

loc_100002b70:
(rax)(rbx, r14);
return;

loc_100002bd8:
rax = dlerror();
rax = [NSString stringWithUTF8String:rax];
rax = [rax retain];
rax = objc_retainAutorelease(rax);
r15 = rax;
rax = [rax UTF8String];
rsi = Error loading symbol: %s\n ;
goto loc_100002c2f;

loc_100002c2f:
fprintf(**___stderrp, rsi);
rbx = [_prunedErrorMessage() retain];
[r15 release];
if (rbx != 0x0) {
_main.cold.1(rbx, @selector(UTF8String));
}
return;

loc_100002b7f:
rax = dlerror();
rax = [NSString stringWithUTF8String:rax];
rax = [rax retain];
rax = objc_retainAutorelease(rax);
r15 = rax;
rax = [rax UTF8String];
rsi = Error loading required libraries. If there is an ongoing installation please wait for it to complete. Otherwise reinstall. (%s)\n ;
goto loc_100002c2f;
}

修改DVTMinimumSystemVersion

其实大家也发现了,xcodebuild二进制本身竟然内嵌了一段XML!我使用llvm-objdump把它直接提取了出来:

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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BuildMachineOSBuild</key>
<string>21E160</string>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleDisplayName</key>
<string>xcodebuild</string>
<key>CFBundleIdentifier</key>
<string>com.apple.dt.xcodebuild</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>xcodebuild</string>
<key>CFBundleShortVersionString</key>
<string>13.3</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
</array>
<key>CFBundleVersion</key>
<string>20008</string>
<key>DTCompiler</key>
<string>com.apple.compilers.llvm.clang.1_0</string>
<key>DTPlatformBuild</key>
<string>21E185d</string>
<key>DTPlatformName</key>
<string>macosx</string>
<key>DTPlatformVersion</key>
<string>12.3</string>
<key>DTSDKBuild</key>
<string>21E185d</string>
<key>DTSDKName</key>
<string>macosx12.3.internal</string>
<key>DTXcode</key>
<string>1330</string>
<key>DTXcodeBuild</key>
<string>13E112a</string>
<key>DVTMinimumSystemVersion</key>
<string>12.0</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
</dict>
</plist>

看到我们关心的DVTMinimumSystemVersionLSMinimumSystemVersion都在里面。其实也侧面证明了,真正的最低部署版本是macOS 11.0,而不是macOS 12.0(12.0只是苹果为了间接Push Developer去频繁更新macOS的阴谋罢了😂)

那下一步,要做的事情就是用魔改xcodebuild并重新codesign。修改的方式多种多样,你暴力使用Hex Editor也是最简单。但是我更好奇的是这个__TEXT,__info_plist的machO段和节的相关说明。

在网上搜索了一下相关资料,很容易就找到了感兴趣的资料:

  1. The Power Of Plist:解释Info.plist可以内嵌在二进制中
  2. Gimmedebugah: how to embedded a Info.plist into arbitrary binaries:对任意已有二进制注入Info.plist
  3. llvm-objcopy:拷贝修改machO结构到新machO

基本解释得很明确清晰,如果你有源码,可以直接利用ld64的参数 --sectcreate __TEXT,__info_plist path_to/Info.plist来注入你的Info.plist信息。没有源码可以手动修改machO结构并签名即可。

对于我此次跑Xcode 13.3来说,我选择最傻瓜最直观的Hex Editor修改(我用的是开源小工具HexFiend),只需要把12.0修改为11.0即可满足我的需要,并重新codesign一波。

1648210752222_811d0d800592f601f309e48b732f1194

codesign:

1
2
codesign --remove-signature /Applications/Xcode-13.3.0.app/Contents/Developer/usr/bin/xcodebuild
sudo /Applications/Xcode-13.3.0.app/Contents/Developer/usr/bin/xcodebuild -license

测试一下CLI,很正常

1648210752374_0a5d6ef92b0e270d6164a05153565572

最终替换步骤

  1. 修改Xcode-13.3.0.app/Contents/Info.plist中的LSMinimumSystemVersion的值为11.0
  2. 替换Xcode-13.3.0.app/Contents/Developer/usr/bin/xcodebuild中的DVTMinimumSystemVersion的二进制为11.0,或者使用我这个已经替换好的(建议还是手动参考上面步骤[修改DVTMinimumSystemVersion]替换,授人以渔而不是授人以鱼)

一步步带你开发macOS QuickLook Plugin

作者 DreamPiggy
2019年4月16日 23:02

QuickLook简介

QuickLook 是macOS上提供的一项快速展示文档预览的功能,只需要按下空格就可以快速查看各种文件格式的信息,包括文本,代码,图片,音频,视频等等。

由于QuickLook需要支持不断扩展的文件格式,因此macOS专门提供了一个QuickLook Plugin,能让开发者对自己的文件格式提供一个自定义的完整的UI显示,不必依赖macOS系统更新来支持缤纷复杂的格式。

之前一段时间,出于兴趣做了一个AVIF (AV1 Image File Format)的解码器封装,AV1作为现在流行的HEVC(H.265)潜在未来竞争者,有着开源,无专利限制,更高的压缩比等等优势,比起HEVC晚诞生了5年。

目前AVIF虽然发布了第一版规范,但是缺少相应的周边工具链的支持,在macOS上想要找一个简单的Image Viewer都没找到,调试起来异常困难,因此抽空顺便做了一个简单的Quick Look Plugin,来让自己能直接空格预览AVIF图像。

在做QuickLook Plugin的过程中,感觉有一些小坑需要记下来,因此这篇文章,目标就是一个简单的入门教程,讲解如何做一个QuickLook Plugin,来对自己喜爱但又不被系统支持的文件格式,提供更好的用户体验支持。

QuickLook Plugin工程

虽然苹果提供了完善的QuickLook Plugin开发文档,参考:Quick Look Programming Guide

但是文档已经稍显过时,遇到的一个坑点也没有提示,因此这里更详细直观的介绍一下QuickLook开发的流程。

  • 新建Xcode工程,选择这个Quick Look Plug-In模板

![屏幕快照 2019-04-16 上午11.45.25](https://lf3-client-infra.bytetos.com/obj/client-infra-images/lizhuoli/f7dac35688c54f2e9ac1a605b4295a39/2022-07-14/image/2019/04/16/屏幕快照 2019-04-16 上午11.45.25.png)

  • 打开你的模版,你会发现如下的结构
1
2
3
4
5
Project
- GenerateThumbnailForURL.c // 用来提供Finder缩略图的代码
- GeneratePreviewForURL.c // 用来生成Preview用的绘制代码
- main.c // 插件入口文件,不要修改它
- Info.plist // 描述插件支持的UTI类型的,后面会讲

QuickLook Plugin支持两种情形的功能展示:一个是对文件,按下空格来展示的窗口预览,在使用Option+空格进行全屏预览时候也会展示,后面都称作Preview

另一个是用来给Finder,来提供一个缩略图展示,这样一些图像格式,视频格式,在Finder中就能直接看到对应的缩略图,而不是一个僵硬的默认图标。后文都称作Thumbnail

由于QuickLook的核心,是希望对指定的文件格式,提供一个展示的UI和缩略图。那么在继续进一步写代码之前,我们必须得首先清楚自己需要的文件格式是什么,并了解UTI的概念。如果这一步骤处理的有问题,你的QuickLook Plugin是无法按预期的想法,被调用的。

绑定文件格式和UTI

在继续下一步之前,你需要对你想支持的文件格式,选择一个UTI (Uniform Type Identifiers).

QuickLook,在用户按下空格开始Preview的时候,会根据每个QuickLook Plugin注册的UTI,依次去询问,直到找到第一个返回成功的,最后来判定选择哪个Plugin进行展示。

建立好模版之后,打开Info.plist,在顶层的LSItemContentTypes项里面,添加你的Plugin所能支持的UTI,是一个数组,会按照先后顺序匹配,一般建议只写自己能准确识别的UTI,如果是一个通配的Plugin(如通用图片预览,通用代码预览),可以使用UTI继承关系的父级(public.image, public.source-code等)

1
2
3
4
5
6
7
8
9
10
11
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>QLGenerator</string>
<key>LSItemContentTypes</key>
<array>
<string>public.avif</string> <!--Here!-->
</array>
</dict>
</array>

在配置好Plugin支持的UTI之后,你还需要根据具体UTI的分配来源,来使用导入或者导出。

找出已有的UTI

你可以通过使用如下命令,查看一个文件对应的UTL

1
mdls test.avif

查看输出的kMDItemContentType,如果是以dyn开头,表明没有被注册过,而是系统分配的一个动态UTI(用于任意不支持的类型和代码兼容,参考Dynamic Type Identifiers

否则,形如public.png这种,标示是一个已有的UTI,可以导入来直接使用

1
kMDItemContentType ="dyn.ah62d4rv4ge80c7xmq2"

如果你是一个比较执着的人,想了解具体的每一个UTI,是由系统或者还是某个第三方App注册的,你可以使用如下命令,导出完整的系统UTI报表,来进行搜索。

1
/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -dump

UTI定义

一个UTI对应一段XML的定义,其中声明了它的类型(继承关系),UTI字符串,简介名称,扩展名,标准链接等等,基本的格式如下,很容易理解。这里是自己定义的一个AVIF格式的描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.image</string>
</array>
<key>UTTypeDescription</key>
<string>AVIF image</string>
<key>UTTypeIdentifier</key>
<string>public.avif</string>
<key>UTTypeReferenceURL</key>
<string>https://aomediacodec.github.io/av1-avif/</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>avif</string>
</array>
<key>public.mime-type</key>
<string>image/avif</string>
</dict>
</dict>

导入UTI

如果你想支持QuickLook的文件格式,已经有了系统分配的UTI,或者第三方App定义好的UTI,那么你要做的,就是导入一个UTI。

如果要导入UTI,你需要在Info.plist中,使用UTImportedTypeDeclarations这个项,来导入对应的UTI描述内容,值是一个数组,数组每项都是上面提到的UTI定义。

PS:对于导入UTI来说,你其实并不需要完整的把别人的声明抄过来,只要存在UTTypeIdentifier项即可,但是这样写能更清晰了解对应的格式描述。

1
2
3
4
5
6
7
8
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>public.png</string>
<!--...-->
</dict>
</array>

导出UTI

反之,如果你想支持的QuickLook的文件格式,不存在已有的UTI,那么你需要新增一个并导出。

如果要导出UTI,你需要在Info.plist中,使用UTExportedTypeDeclarations这个项,来导出对应的UTI描述内容,值是一个数组,数组每项都是上面提到的UTI定义。

1
2
3
4
5
6
7
8
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>public.avif</string>
<!--...-->
</dict>
</array>

QuickLook Plugin和导出UTI

值得注意的一个坑点,macOS系统注册UTI规则,会注册当前硬盘上所有的.app后缀的App包,里面所含有的导出UTI,而遗憾的是,作为QuickLook Plugin,最后编译得到的产物,不是以.app为后缀名的,而是一个.qlgenerator

因此,这就导致,如果你新增了一个UTI,但是你的QuickLook Plugin,没有任何宿主App来提供导出UTI,最终macOS会不认这个UTI,因此你的QuickLook Plugin不会被调用。这可能是苹果早期认为,QuickLook Plugin是和一个App绑定的(如Keynote和Keynote QuickLook插件的关系),独立存在的QuickLook Plugin并没有特别处理……

这个坑花费了一些时间,经过一番StackOverflow和GitHub搜索,最终找到了一个非常聪明(Trick)的解决方案:

构造一个临时占位的Dummy.app包,专门用于导出UTI,在打包的时候直接将这个Dummy.app拷贝到对应QuickLook Plugin的包中即可

我们可以使用macOS自带的Script Editor.app,来创建一个空壳App:

  1. 打开Script Editor,创建一个新文档
  2. 直接Save,类型选择Application,名称随便写一个Dummy.app,导出
  3. 用文本编辑器,打开Dummy.app/Contents/Info.plist
  4. 参考上文提到的UTI导出方式,添加对应的UTExportedTypeDeclarations项目
  5. 将这个Dummy.app,放到工程下,直接拖进来当作资源,添加到Copy Bundle Resource过程中

未命名3

这样一波操作以后,你最后构建得到的QuickLook Plugin,就能自带一个导出的UTI,然后被系统识别,最终被真正加载。

用于Preview的代码绘制实现

准备好上述UTI的配置后,现在再来看看代码。首先我们侧重看一下用于提供Preview的UI的代码。

对应的文件是GeneratePreviewForURL.c。如果要使用Objective-C,或者C++代码,你可以更改对应的文件名为.m或者.cpp即可,以下示例是以Objective-C代码为主

入口调用函数原型为下:

1
GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, CFURLRef url, CFStringRef contentTypeUTI, CFDictionaryRef options)

其实对于大多数QuickLook插件,我们关注的基本上只有这个url参数,他对应的是文件的File URL,可以拿到对应被选中的文件Data Buffer。

1
2
NSString *path = [(__bridge NSURL *)url path];
NSData *data = [NSData dataWithContentsOfFile:path];

下一步就是绘制和渲染我们的UI,QuickLook支持两种方式渲染:

  • 使用Core Graphics自定义绘制
  • 使用预置支持的数据格式,动态生成Data

使用Core Graphics绘制

这里假设已经了解Core Graphics绘制的基本知识,如果有不了解请提前查阅苹果的教程:Quartz 2D Programming Guide.

在拿到Data以后,该怎么绘制取决于你的QuickLook插件的功能,比如说,我想做的一个AVIF图像预览Quick Look插件,那么就希望触发解码,以拿到CGImage和Bitmap Buffer来绘制。

1
CGImageRef cgImgRef = [AVIFDecoder createAVIFImageWithData:data];

下一步,我们需要获取一个CGContext来绘制,使用QLPreviewRequestCreateContext,传入入口函数透传进来的preview,会得到一个CGContext,来作为上下文进行绘制。同时,还需要了解绘制的大小,标题等等选项,来提供合适的渲染UI。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CGFloat width = CGImageGetWidth(cgImgRef);
CGFloat height = CGImageGetHeight(cgImgRef);

// Add image dimensions to title
NSString *newTitle = [NSString stringWithFormat:@"%@ (%d x %d)", [path lastPathComponent], (int)width, (int)height];

NSDictionary *newOpt = @{(NSString *)kQLPreviewPropertyDisplayNameKey : newTitle,
(NSString *)kQLPreviewPropertyWidthKey : @(width),
(NSString *)kQLPreviewPropertyHeightKey : @(height)};

// Draw image
CGContextRef ctx = QLPreviewRequestCreateContext(preview, CGSizeMake(width, height), YES, NULL);
CGContextDrawImage(ctx, CGRectMake(0,0,width,height), cgImgRef);
QLPreviewRequestFlushContext(preview, ctx);

// Cleanup
CGImageRelease(cgImgRef);
CGContextRelease(ctx);

这样基本就完成了,我们绘制了一个完整的图像到CGContext上,QuickLook会渲染到屏幕上,大小是我们指定的图像大小。

如果你的QuickLook插件,需要有一个异步的处理和等待,同时可以实现这个取消的入口函数,来减少CPU占用,优化一下用户体验

1
void CancelPreviewGeneration(void *thisInterface, QLPreviewRequestRef preview)

比如说,对于大图像解吗,可以中断解码提前释放内存。

使用预置类型生成数据渲染

QuickLook Preview还有另一种渲染方式,就是使用QuickLook预置的文件类型支持,来提供相应的数据。对应文档:Dynamically Generating Previews

我们需要使用QLPreviewRequestSetDataRepresentation,来提供一个预置支持格式的Data Buffer给QuickLook。

支持的格式有:

  • Image: 系统Image/IO解码库支持的图像压缩格式
  • PDF:PDF数据
  • HTML:WebKit支持的HTML字符串,注意如果有本地的CSS,需要使用kQLPreviewPropertyAttachmentDataKey带上CSS的数据
  • XML:WebKit支持的XML字符串
  • RTF:macOS支持的富文本格式(NSAttributedString可以转换的到)
  • Text:纯文本字符串
  • Movie:系统CoreVideo库支持的视频压缩格式
  • Audio:系统CoreAudio库支持的音频压缩格式
1
2
3
NSImage *image;
NSData *imageData = [image TIFFRepresentation];
QLPreviewRequestSetDataRepresentation(preview, (__bridge CFDataRef)data, kUTTypeImage, NULL);

在其他App中使用Preview

值得一提的是,得益于macOS完整的软件生态,你的QuickLook Plugin的Preview UI,不仅仅会出现在Finder中空格弹出的预览,甚至于Xcode和一些第三方App内置的预览(即用到了QLPreviewPanel来展示UI的地方),都能触发你的插件,所以可以说是非常舒服。

在Xcode中缩略图如下:

![屏幕快照 2019-04-16 下午1.49.04](https://lf3-client-infra.bytetos.com/obj/client-infra-images/lizhuoli/f7dac35688c54f2e9ac1a605b4295a39/2022-07-14/image/2019/04/16/屏幕快照 2019-04-16 下午1.49.04.png)

用于Thumbnail的代码绘制实现

说完了关于Preview的实现代码,现在再来看看关于如何生成Finder用到的文件缩略图

Thumbnail也支持两种模式

  1. 使用同Preview的,基于Core Graphics绘制逻辑
  2. 更为简单的API,使用CGImage或者Image Data

第一种方式,和上文一模一样,这里就不再赘述了。我们可以看看第二种方式。我们只需要提供一个CGImage,或者一个Image/IO支持的图像格式的Image Data即可

1
2
3
4
5
6
7
8
9
10
// 如果是原生支持的格式,使用QLThumbnailRequestSetImageWithData

// 否则,自己解码器输出一个CGImage,然后传进去
CGImageRef cgImgRef;
if (cgImgRef) {
QLThumbnailRequestSetImage(thumbnail, cgImgRef, nil);
CGImageRelease(cgImgRef);
} else {
QLThumbnailRequestSetImageAtURL(thumbnail, url, nil);
}

对应在Finder中缩略图如下:

调试QuickLook插件

作为一个插件,要调试起来比起一般的App要麻烦一些。不过好在macOS提供了一个专门的QuickLook调试命令,苹果也有专门文档介绍

我们可以使用如下的命令,以public.avif的UTI,对test.avif文件,触发一次Quick Look的Preview,来查看渲染是否正确。

1
qlmanage -d2 -p test.avif -c public.avif

同时,为了能够Debug单步调试,我们使用Xcode的Debug Scheme,通过将Execulable改成/usr/bin/qlmanage,在Arguments中填写成上述的参数。

未命名

未命名2

这样,你可以给你的对应代码下上断点,当你再次点击Run来运行时,会自动触发单步调试,检查存在的问题。

总结

整体看下来,QuickLook Plugin的开发流程并没有多么复杂,其实你要做的就是用已有的Core Graphics绘制知识,并不涉及到AppKit相关概念,对于iOS开发者也能快速上手。

其中的坑,主要在于没有文档说明新增UTI,需要绑定一个App,而不是QuickLook Plugin本身能够声明的,对应也介绍了一个聪明的方式绕过这一限制。希望能帮助到有同样需求的人。

自己的AVIF QuickLook Plugin也终于完工,欢迎有兴趣的人尝试,并且给一点Star:

这里还有一些推荐和自己用到的QuickLook Plugin,也列举出来,能大大提升日常使用效率哦

❌
❌