公证mac app时处理You must first sign the relevant contracts online错误

昨天修改了一些客户端代码,在发布app前例行跑一遍脚本,对app做公证。结果执行xcrun altool --notarize-app时,报了内容为You must first sign the relevant contracts online. (1048)的错误。
Google了一番之后,大概明白了这个错误是咋出来的了。
苹果更新了一些协议条款,需要开发者接受条款后才能继续使用服务。但公证脚本中对应的开发者账户,还没来得及接受条款。导致执行xcrun altool相关命令时,服务器返回了You must first sign the relevant contracts online. (1048)错误。
了解了原因,解决起来也快。让开发者账户的拥有者登录一下 https://appstoreconnect.apple.com/agreements/# ,把所有条款都确认一遍。再执行公证流程就可以了。

在Python中调用未注册的COM对象

背景

迫于集团的强制要求,Windows下的客户端需要接入一个安全组件。启动时主程序通过这个安全组件加载其他动态库,在加载动态库时则会检查文件的签名和签发人,确保加载的动态库没有被篡改。
安全组件提供了动态库、头文件及msvc调用的例子。本以为用Python的ctypes直接调用动态库导出的函数就能搞定。结果发现事情并没有那么简单。动态库只导出了根据IID获取COM对象的接口。实际的功能函数都在对应的COM对象的成员函数中。而头文件则对这一系列操作做了封装,将获取COM对象,调用相关成员函数等一系列操作,封装在一个内联函数中。
对于用C/C++开发的程序,直接调用include头文件,调用这些内联函数就行了。但我们的客户端是使用Python开发的,没法这么做。最简单的方法应该是再实现一个动态库,里面实现调用这些内联函数的函数,并把自己实现的函数导出,在Python中通过ctypes去调用。然而这么套娃,总感觉很奇怪。于是就想着,能不能在Python中直接获取并使用这些COM对象(作死开始)。

pythoncom

先尝试了如下代码:

1
2
import pythoncom
com_object = pythoncom.ObjectFromAddress(object_pointer_from_dll.value, '{class_id}')

执行后报错:

1
TypeError: There is no interface object registered that supports this IID

一通Google之后,大概找到了问题的原因。pythoncom需要COM对象继承并实现IDispatch的相关接口,否则就不知道COM对象实现的函数和返回值类型。很明显,安全组件提供的COM组件并没有继承和实现IDipatch接口。

通过虚函数表调用

仔细想想,COM对象,本质上也是一个C++对象,应该能通过获取虚表中各个函数的函数指针,直接调用成员函数。
再次Google一番之后,找到了一些相关的资料。The layout of a COM object中提到:

  • COM对象的开头为指向虚函数表的指针。
  • 虚表中存放着调用方式为__stdcall的函数指针,第一个参数为对象本身,之后为函数入参。
  • 同一个类中的函数按照声明的先后顺序排列。基类的函数指针在派生类之前。
  • 如果没有出现了菱形继承,开头只有一个虚表指针。否则,开头会出现多个虚表指针。需要根据被调用函数所在的基类,去判断需要使用的虚表。

实现的代码为

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
from ctypes import addressof, cast, c_long, c_void_p, POINTER, WINFUNCTYPE
from typing import List, Type, Union


class IUnknown:
def __init__(self, address: c_void_p):
self._address = address
self._v_table = cast(self._address, POINTER(POINTER(WINFUNCTYPE(None))))

def __del__(self):
self._release()

def _release(self):
return int(self._get_func(0, 2, [c_void_p], c_long)(self._address))

def _get_func(self, v_table_index: int, function_index: int, arg_types: List[Type], result_type: Union[Type, None]):
return WINFUNCTYPE(result_type, *arg_types).from_address(addressof(self._v_table[v_table_index][function_index]))


class ICustomObject(IUnknown):
def __init__(self, address: c_void_p):
super(ICustomObject, self).__init__(address)

def custom_function(self) -> bool:
return bool(self._get_func(0, 3, [c_void_p], c_long)(self._address))

其他

平心而论,通过这种方式使用COM对象,可维护性极差。如果可以选,类似场景下,还是建议使用背景中提到的方案。

让使用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文件,增加元素,才能正常工作