昨天修改了一些客户端代码,在发布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 | import pythoncom |
执行后报错:
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 | from ctypes import addressof, cast, c_long, c_void_p, POINTER, WINFUNCTYPE |
其他
平心而论,通过这种方式使用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.zip
到App/Contents/Resources
下,在原路径下创建名为base_library.zip
,指向App/Contents/Resources/base_library.zip
的链接文件
PyQt5需要移动的文件
- 使用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
下,在原路径留下一个链接文件,且移动后所有可执行文件的动态库查找路径均被修复。 - 遍历
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
。
这么做可以显著减少安装包体积。 - 如果项目中使用了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
8os.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 |
|
在文件内添加项目工程需要使用到的能力。相关能力可以在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 | codesign -s cert_id \ |
但在使用时发现有几个问题:
- 经常因为网络原因导致获取时间戳失败,最终导致签名失败。如果重试,则会重签整个App。
- –deep在分析依赖时经常会出现问题,导致漏签某个文件,或者某个文件依赖的文件没有签名导致签名中断。
最终的解决方案是:
使用
1
2
3
4
5
6codesign -s cert_id \
-f \
--options runtime \
--timestamp \
--entitlements entitlement_file_path \
file_path对单个文件签名
遍历
App/Contents/MacOS
下的文件,对主程序以外的文件进行签名遍历
App/Contents/Resources
下的文件,对.dylib
后缀的文件进行签名对
App/Contents/Resources/PyQt5/Qt/lib/Qt*.framework/Versions/5/Qt*
(即上文提到的需要软链到App/Contents/MacOS的Qt动态库)进行签名如果需要使用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 | xcrun altool --notarize-app \ |
上传成功后会输出类似于
1 | RequestUUID: 12345678-abcd-1234-abcd-123456789abc |
的结果。uuid在后续查询公证状态时会用到
查看公证结果
1 | xcrun altool --notarization-info 12345678-abcd-1234-abcd-123456789abc \ |
输出结果的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 | import sys |
执行计划任务
同样的,通过com组件ITaskService启动计划任务。此时就不需要管理员权限了。
1 | exist_task = root_folder.GetTask(f'{task_name}') # 如果任务不存在,此处会抛出异常 |
最终流程
启动时检查当前进程是否拥有管理员权限
- 如果没有管理员权限,则检查系统的计划任务服务中有没有注册对应的计划任务。
- 如果没有,则使用ShellExecute/ShellExecuteEx的方式,启动带管理员权限的进程(会弹出UAC对话框),并退出当前进程。
- 如果有,则启动对应的计划任务,并退出当前进程
- 如果有管理员权限,则检查系统的计划任务服务中有没有注册对应的计划任务。
如果不存在,则需要创建/修复计划任务。因为此时程序已经拥有管理员权限,可以直接创建对应的计划任务
其他
方案还需要实现一些辅助的功能,这边也一并列出对应的Python实现
检查当前进程是否有管理员权限
1 | def check_privileges() -> bool |
使用ShellExecuteEx启动带管理员权限的进程
1 | start_args = ['banana', 'kuma~'] |
Python3在Windows系统上对长路径的支持
Windows XP及之后的版本
maximum-path-length-limitation中提到,可以将路径转换为extended-length path
再传入相关API。具体转换方式为
- 将路径中的
斜杠(/)
替换为反斜杠(\)
- 在路径前增加前缀
\\?\
- 如果是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文件,增加元素,才能正常工作