让使用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
    查看是哪个文件没有签名。

借助计划任务执行需要管理员权限运行的程序

在多次挣扎之后,我们的新项目最终决定放弃在watchdog上缝缝补补,在Windows平台上改回成用USN日志的方式监控文件变动。随之带来一个问题:拥有管理员权限的进程才能读取USN日志。
旧项目的方案是额外注册一个系统服务,读取usn日志,并通过管道与主进程通讯。但上线之后发现,系统服务很容易被各种电脑管家误删除,导致程序工作异常。新项目打算在主进程中直接读取usn日志。这就要求使用管理员权限运行主进程。而我们又不想让用户每次启动程序时都弹出一个UAC提示框。最后决定借助计划任务来实现我们的需求。

原理

运行等级(Principal.RunLevel)为TASK_RUNLEVEL_HIGHEST的计划任务,在执行时,被调用的进程会以管理员权限运行。
这种计划任务仅在添加时需要管理员权限。在被触发时,不需要管理员权限,也就不会弹出UAC提示。

实现

创建计划任务

通过com组件ITaskService添加计划任务
重点是把Principal.RunLevel设为TASK_RUNLEVEL_HIGHEST
下列代码展示了如何用Python创建最高权限的计划任务。
注意:运行下列代码时需要管理员权限

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
import sys
import os

import win32com.client

NORMAL_PRIORITY_CLASS = 4
TASK_ACTION_EXEC = 0
TASK_CREATE_OR_UPDATE = 6
TASK_INSTANCES_PARALLEL = 0
TASK_LOGON_INTERACTIVE_TOKEN = 3
TASK_RUNLEVEL_HIGHEST = 1

schedule_service = win32com.client.Dispatch('Schedule.Service')
schedule_service.Connect()

task_definition = schedule_service.NewTask(0)
task_definition.Principal.LogonType = TASK_LOGON_INTERACTIVE_TOKEN
task_definition.Principal.RunLevel = TASK_RUNLEVEL_HIGHEST
task_definition.RegistrationInfo.Author = 'Daniel'
task_definition.Settings.DisallowStartIfOnBatteries = False
task_definition.Settings.DisallowStartOnRemoteAppSession = False
task_definition.Settings.Enabled = True
task_definition.Settings.ExecutionTimeLimit = 'PT0S'
task_definition.Settings.MultipleInstances = TASK_INSTANCES_PARALLEL
task_definition.Settings.Priority = NORMAL_PRIORITY_CLASS
task_definition.Settings.StopIfGoingOnBatteries = False
task_definition.Settings.UseUnifiedSchedulingEngine = True

exec_action = task_definition.Actions.Create(TASK_ACTION_EXEC)
exec_action.Path = sys.executable
exec_action.WorkingDirectory = os.getcwd()
exec_action.Arguments = '$(Arg0)' # $(Arg0)会在任务执行时被替换

task_name = f'{os.path.splitext(os.path.basename(sys.executable))[0]}_skip_uac'
root_folder = schedule_service.GetFolder('\\')
root_folder.RegisterTaskDefinition(
task_name,
task_definition,
TASK_CREATE_OR_UPDATE,
None,
None,
TASK_LOGON_INTERACTIVE_TOKEN,
)

执行计划任务

同样的,通过com组件ITaskService启动计划任务。此时就不需要管理员权限了。

1
2
3
exist_task = root_folder.GetTask(f'{task_name}')  # 如果任务不存在,此处会抛出异常
start_args = ['banana', 'kuma~']
exist_task.Run(' '.join(f'"{arg}"' for arg in start_args)) # 参数会在计划任务启动时作为argv传入

最终流程

启动时检查当前进程是否拥有管理员权限

  • 如果没有管理员权限,则检查系统的计划任务服务中有没有注册对应的计划任务。
    • 如果没有,则使用ShellExecute/ShellExecuteEx的方式,启动带管理员权限的进程(会弹出UAC对话框),并退出当前进程。
    • 如果有,则启动对应的计划任务,并退出当前进程
  • 如果有管理员权限,则检查系统的计划任务服务中有没有注册对应的计划任务。
    如果不存在,则需要创建/修复计划任务。因为此时程序已经拥有管理员权限,可以直接创建对应的计划任务

其他

方案还需要实现一些辅助的功能,这边也一并列出对应的Python实现

检查当前进程是否有管理员权限

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
def check_privileges() -> bool
class SID_IDENTIFIER_AUTHORITY(ctypes.Structure):
_fields_ = [
("byte0", ctypes.c_byte),
("byte1", ctypes.c_byte),
("byte2", ctypes.c_byte),
("byte3", ctypes.c_byte),
("byte4", ctypes.c_byte),
("byte5", ctypes.c_byte),
]

nt_authority = SID_IDENTIFIER_AUTHORITY()
nt_authority.byte5 = SECURITY_NT_AUTHORITY

administrators_group = ctypes.c_void_p()
if ctypes.windll.advapi32.AllocateAndInitializeSid(
ctypes.byref(nt_authority),
2,
SECURITY_BUILTIN_DOMAIN_RID,
DOMAIN_ALIAS_RID_ADMINS,
0,
0,
0,
0,
0,
0,
ctypes.byref(administrators_group),
):
is_admin = BOOL()
if ctypes.windll.advapi32.CheckTokenMembership(
None,
administrators_group,
ctypes.byref(is_admin),
):
result = is_admin.value != 0
else:
# CheckTokenMembership错误,视为无管理员权限
result = False
ctypes.windll.advapi32.FreeSid(administrators_group)
else:
# AllocateAndInitializeSid错误,视为无管理员权限
result = False
return result

使用ShellExecuteEx启动带管理员权限的进程

1
2
3
4
5
6
7
8
9
10
11
12
13
start_args = ['banana', 'kuma~']
ShellExecuteEx(
# 进程启动后的表现
nShow=SW_HIDE,
# 以管理员权限启动
lpVerb='runas',
# 可执行程序的路径
lpFile=sys.executable,
# 传入的参数,注意所有的参数都需要以双引号包起来
lpParameters=' '.join(f'"{arg}"' for arg in start_args),
# 工作目录
lpDirectory=os.getcwd(),
)

Python3在Windows系统上对长路径的支持

Windows XP及之后的版本

maximum-path-length-limitation中提到,可以将路径转换为extended-length path再传入相关API。具体转换方式为

  1. 将路径中的斜杠(/)替换为反斜杠(\)
  2. 在路径前增加前缀\\?\
  3. 如果是UNC路径,需要删除开头的\\,并增加\\?\UNC\前缀。如\\server\share需要转换为\\?\UNC\server\share
    该方法最长可支持32767左右的路径长度。
    可以将extended-length path作为路径直接传入open函数来打开文件。但os.path.walk、os.path.join等路径相关的函数在处理extended-length path时会和预期的效果不太一样。建议在代码中始终传递原始路径,自己封装open函数,将filename参数转换为extended-length path并传入真正的open函数中。

Windows 10 1607及之后的版本

enable-long-paths-in-windows-10-version-1607-and-later中提到,Windows 10 1607及之后的版本, 在满足注册表或组策略中启用对长路径的支持,且应用程序本身的Mainfset中标识支持长路径的前提下,应用程序可直接打开拥有长路径的文件。
Python3.6(changlist)开始,可以用这种方式支持长路径。代码中可以直接通过open(long_path)的方式读写拥有长路径的文件。

PyInstaller相关

使用extended-length path方案的代码,在使用PyInstaller生成可执行程序后依然可以正常工作
而使用第二种方案的代码,在使用PyInstaller(截止到v3.5)生成可执行程序之后,需要修改Mainfset才能正常工作。
Mainfset本质上是一个xml描述文件。描述可执行程序或动态库的元数据。包括依赖的动态库版本、支持的特性等。可以作为资源文件内嵌,也可以是独立的文件。
使用方案二支持长路径,要求应用程序的Mainfset中包含 longPathAware 元素
Python3.6及之后的python.exe,内嵌Mainfset已经包含longPathAware元素。而PyInstaller在Windows上生成的程序,Mainfset在独立的.mainfset文件中,且内部没有包含longPathAware元素。需要修改.mainfset文件,增加元素,才能正常工作