让使用PyQt5的app在使用Packages打包后可以通过苹果的公证

根据苹果开发者网站上的消息,2020年2月3日之后,macOS Catalina在默认设置下,只能运行经过苹果公证的应用程序。碰巧公司macOS版本的客户端也因为其他原因,需要升级Python和PyQt5的版本。磕磕碰碰弄了一周,终于完成升级并通过了苹果的公证。在此记录一下过程和遇到的一些坑。

相关版本

Python: 3.6.8
PyQt5: 5.12.3(对应Qt5版本5.12.6)
PyInstaller: 3.5
Packages: 1.2.7

对APP结构的改动

目前苹果要求App/Contents/MacOS下的所有文件都具有签名。但截止到1.2.7版本,Packages对保留文件的扩展属性这个功能的支持依然存在问题。而苹果对非可执行文件的签名是存放在文件的扩展属性中的。这就导致已经被签名的非可执行文件,在Packages打包后,会丢失签名信息。
目前的解决方案是,想办法将非可执行文件移动到App/Contents/Resources目录下。

PyInstaller需要移动的文件

移动App/Contents/MacOS/base_library.zipApp/Contents/Resources下,在原路径下创建名为base_library.zip,指向App/Contents/Resources/base_library.zip的链接文件

PyQt5需要移动的文件

  1. 使用Recipe-OSX-Code-Signing-Qt中的fix_app_qt_folder_names_for_codesign.py脚本,并对脚本做一些修改。
    将开始迭代的路径从App/Contents/MacOS变为App/Contents/MacOS/PyQt5/Qt, 将需要移动的判断标准从带.的文件夹变为所有文件夹
    运行后将App/Contents/MacOS/PyQt5/Qt下的文件夹被移动App/Contents/Resources/PyQt5/Qt下,在原路径留下一个链接文件,且移动后所有可执行文件的动态库查找路径均被修复。
  2. 遍历App/Contents/Resources/PyQt5/Qt/lib目录下所有Qt*.framework目录,将在App/Contents/MacOS下创建名称为Qt*, 指向App/Contents/Resources/PyQt5/Qt/lib/Qt*.framework/Versions/5/Qt*的链接文件,替换原有的实体文件。
    例如:删除App/Contents/MacOS/QtCore,创建同名链接文件,指向App/Contents/Resources/PyQt5/Qt/lib/QtCore.framework/Versions/5/QtCore
    这么做可以显著减少安装包体积。
  3. 如果项目中使用了QtWebEngine,此时运行app,Qt会提示错误Could not find QtWebEngineProcess。因为QtWebEngineProcess在1中被移动了。需要在App启动时修改环境变量QTWEBENGINEPROCESS_PATH,指向App/Contents/Resources/PyQt5/Qt/lib/QtWebEngineCore.framework/Helpers/QtWebEngineProcess.app/Contents/Resources/QtWebEngineProcess,让Qt可以正确的找到QtWebEngineProcess。
    1
    2
    3
    4
    5
    6
    7
    8
    os.environ['QTWEBENGINEPROCESS_PATH'] = os.path.abspath(
    os.path.join(
    os.path.dirname(sys.executable),
    '..',
    'Resources/PyQt5/Qt/lib/QtWebEngineCore.framework',
    'Helpers/QtWebEngineProcess.app/Contents/Resources/QtWebEngineProcess',
    )
    )

签名

授权文件

参照notarizing_macos_software_before_distribution
Enable hardened runtime (macOS),被公证的app需要开启hardened runtime。通过Xcode维护的工程,可以按照Add a capability to a target,添加相关能力即可。但我们的工程并没有Xcode来管理,需要自己创建一个授权文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?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>com.apple.security.application-groups</key>
<array>
<string>com.my.groups</string>
</array>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-executable-page-protection</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
...
</dict>
</plist>

在文件内添加项目工程需要使用到的能力。相关能力可以在hardened_runtime_entitlements中查看
PyInstaller在运行时,会解压一部分可执行文件到临时目录,再执行这些可执行文件。如果不修改源代码,这些文件是没有签名的。运行时会报Memory Error。需要在授权文件中增加Allow Unsigned Executable Memory Entitlement的能力。

签名

签名时需要增加的选项:
--options runtime 用于标识开启Hardened runtime
--timestamp 显式的增加时间戳。否则后续公证时会报The signature does not include a secure timestamp.的错误
--entitlements entitlement_file_path 用于标识需要开启的能力

一开始我们直接使用如下命令签名

1
2
3
4
5
6
7
codesign -s cert_id \
-f \
--deep \
--options runtime \
--timestamp \
--entitlements entitlement_file_path \
app_path

但在使用时发现有几个问题:

  1. 经常因为网络原因导致获取时间戳失败,最终导致签名失败。如果重试,则会重签整个App。
  2. –deep在分析依赖时经常会出现问题,导致漏签某个文件,或者某个文件依赖的文件没有签名导致签名中断。

最终的解决方案是:

  1. 使用

    1
    2
    3
    4
    5
    6
    codesign -s cert_id \
    -f \
    --options runtime \
    --timestamp \
    --entitlements entitlement_file_path \
    file_path

    对单个文件签名

  2. 遍历App/Contents/MacOS下的文件,对主程序以外的文件进行签名

  3. 遍历App/Contents/Resources下的文件,对.dylib后缀的文件进行签名

  4. App/Contents/Resources/PyQt5/Qt/lib/Qt*.framework/Versions/5/Qt*(即上文提到的需要软链到App/Contents/MacOS的Qt动态库)进行签名

  5. 如果需要使用QtWebEngine,也需要对App/Contents/Resources/PyQt5/Qt/lib/QtWebEngineCore.framework/Helpers/QtWebEngineProcess.app/Contents/Resources/QtWebEngineProcess进行签名

打包

到这一步时,App/Contents/MacOS下只有可执行文件,且都进过了签名。App/Contents/Resources下的所有可执行文件也都经过了签名。之后就可以和平常一样,使用Packages生成pkg文件。

公证

这一步相对来说比较简单,根据customizing_the_notarization_workflow的介绍一步一步来即可。

上传

1
2
3
4
5
6
xcrun altool --notarize-app \
--primary-bundle-id "com.example.ote.zip" \
--username "USERNAME" \
--password "PASSWORD" \
--asc-provider <ProviderShortname> \
--file zip_or_pkg_path

上传成功后会输出类似于

1
RequestUUID: 12345678-abcd-1234-abcd-123456789abc

的结果。uuid在后续查询公证状态时会用到

查看公证结果

1
2
3
xcrun altool --notarization-info 12345678-abcd-1234-abcd-123456789abc \
-u "USERNAME" \
-p "PASSWORD"

输出结果的Status字段会显示本次公证的状态。LogFileURL字段则会提供指向一个json文件的网址,记录公证的文件内容和发现的问题。需要重点关注json文件中的issues字段。

将公证结果附加到安装包上

1
xcrun stapler staple pkg_path

检查附加结果是否有效

1
xcrun stapler validate pkg_path

其他

  • 在新部署的环境上执行上传app操作时,会下载和更新相关的jar包。这个过程可能会比较漫长。最好在第一次执行时,增加--verbose参数,查看jar包的下载进度。如果发现很长时间没有动静的话,去活动监视器中退出altool创建的java进程,等待
    命令行自行退出后再重试。
  • 处理公证结果中LogFileURL指出的问题时,按照resolving_common_notarization_issues中提到的进行处理。如果提示主进程The signature of the binary is invalid.,有可能是App/Contents/MacOS中的文件,在签名时被遗漏了,或是打包时被移除了签名。建议执行
    1
    codesign -vvv -deep app_path
    查看是哪个文件没有签名。