GammaRay在mac上的编译及分发

最近在网上瞎逛时,发现了一个调试Qt应用程序的好东西:GammaRay。
按照官方介绍:
GammaRay is a software introspection tool for Qt applications developed by KDAB. Leveraging the QObject introspection mechanism it allows you to observe and manipulate your application at runtime. This works both locally on your workstation and remotely on an embedded target.
使用GammaRay可以在运行时直接查看Qt应用内的对象树,修改对象属性,查看信号槽连接和发射情况,查看界面布局等等。
对于我来说,GammaRay最大的作用有两个:

  • 简单直观的查看信号和事件的触发时机
  • 运行时动态设置对象属性和界面样式

对于团队新人来说,还可以借助这个工具,快速了解应用的架构以及界面布局,加快入门速度。

这么好的工具,当然要在团队内推广开来。但目前GammaRay并不直接提供编译好的二进制文件,只开源了源代码。
让每个人自己下载源码编译一次,显然是个很蠢的做法。于是大家达成共识,编译一份团队特供版的GammaRay。而我就来负责mac版本的编译。

编译

GammaRay的源码可以从 https://github.com/KDAB/GammaRay.git 上获取。稳定起见,选择最近release版本(v2.11.3)对应的tag。
GammaRay本身使用CMake进行工程管理,在*nix系统上编译很方便。按照INSTALL.md中的步骤,添加Qt库所在路径,执行cmake && cmake install即可完成编译。
此处需要注意:

  1. 建议直接使用编译应用程序的Qt库编译GammaRay
    工程会编译GammaRay本体和Probe。编译Probe需要使用和被调试的Qt应用程序一致的Qt动态库,GammaRay加载Probe之后才能正常工作。
    编译参数中可以看到,GammaRay支持单Launcher加载多Probe的方式。但没有具体的文档说明。在我们的项目中,目前统一使用一个Qt库,没有类似的需求。这里就没有深入研究。

  2. 使用cmake install生成的二进制产物
    mac上存在rpath的概念。mac上的动态库、可执行文件本身也会记录要其依赖的其他动态库的路径。
    默认情况下,cmake build的产物,记录了依赖动态库在本地的绝对路径。cmake install时,则会更新这些内容,变为包含rpath的相对路径。
    如果直接使用cmake build的产物,最终生成的可执行程序,在分发给其他电脑之后,会因为依赖的动态库无法加载,导致程序无法运行。

分发

执行完cmake install之后,默认会在/Applications/下生成一个名为GammaRay.app的应用程序。但直接运行时大概率会无法启动,或者无法正常工作。因为此时GammaRay.app内部没有包含依赖的Qt库,启动时会尝试加载系统目录下的Qt库。
Qt官方提供了macdelpoyqt来解决这个问题,将应用依赖的Qt库复制到app内,并会修改相关动态库的rpath等。但如果只是执行

1
${QTDIR}/bin/macdelpoyqt /Applications/GammaRay.app

GammaRay.app可以正常启动,依然无法正常工作。为什么呢?

一顿尝试之后发现,macdelpoyqt只能检查应用程序的主程序直接或者间接显式依赖的Qt动态库。
举个例子,主程序显式依赖了QtWidget和三方动态库X,三方动态库X显式依赖了QtOpenglWidget,运行时会动态加载Qt3DRender。
在这种场景下,macdelpoyqt只能分析出应用程序需要QtWidget和QtOpenglWidget,以及它俩依赖的其他Qt组件,并不能分析出对Qt3DRender的依赖。运行时,就会因为缺少Qt3DRender导致的错误。
回到GammaRay这边,install完成之后,Probe会作为插件被复制到GammaRay.app/Contents/Plugins/gammaray目录下,在GammaRay运行时动态加载。macdelpoyqt只会分析GammaRay.app/Contents/MacOS下几个可执行文件的依赖,丢掉了对Probe的依赖分析。

针对这个问题,可以利用macdelpoyqt的-executable选项,将需要分析依赖的动态库文件也一并传入。
因为Probe下的动态库个数比较多,手写容易出错,我就写了个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
from os import walk
from os.path import join
from subprocess import check_call

APP_PATH = '/Applications/GammaRay.app' # GammaRay.app路径
QT_DIR = '/Users/yoooooo/Qt/5.9.9/clang_64' # Qt库路径

executable_file_list = []
for parent, _, files in walk(join(APP_PATH, 'Contents/MacOS')):
for file in files:
executable_file_list.append(join(parent, file))

for parent, _, files in walk(join(APP_PATH, 'Contents/PlugIns/gammaray')):
for file in files:
if file.endswith('.so') or file.endswith('.dylib'):
executable_file_list.append(join(parent, file))

check_call([
join(QT_DIR, 'bin/macdeployqt'),
APP_PATH,
'-verbose=3',
'-dmg', # 生成dmg文件
*['-executable=' + executable_file for executable_file in executable_file_list]
],
cwd='/Applications', # 最终生成的dmg文件所在目录
)

其他

可以看到脚本中调用macdeployqt时,除了增加-executable选项之外,还增加了-dmg选项,最后直接生成了dmg文件。
期间因为偷懒,直接生成zip包进行分发。结果因为app中存在软链接文件,而标准格式的zip是不支持软链接的。导致解压zip得到的app无法正常工作,排查了很久才意识到,踩中这个老坑了。

此外,如果编译GammaRay的macOS版本比较新时,建议在GammaRay根目录下的CMakeList.txt中增加

1
2
# 此处设置最低支持的macOS版本为10.13
set(CMAKE_OSX_DEPLOYMENT_TARGET "10.13" CACHE STRING "Minimum OS X deployment version")

确保编译出来的app可以在低版本macOS上运行。

Windows 10下CMake无法找到Visual Studio 2013工具链的问题排查

迫于生计,紧急支援了一个客户端项目。这个项目使用CMake进行工程管理,在Windows下,为了支持XP及一些特殊原因,需要先生成Visual Studio 2013工程,再进行编译。
按照README上说的,在开发机上安装完Visual Studio 2013和CMake,再运行自动化脚本,相关工程就会自动创建并编译,我就可以点个咖啡,享受C++程序员的福利时间。
然而现实是残酷的,咖啡还没下单,自动化脚本就报错了。
执行

1
cmake SOURCE_PATH -G "Visual Studio 12 2013"

时报错:
The CXX compiler identification is unknown
看着像是没找到Visual Studio 2013的工具链。

印象中之前也解决过类似的问题,删除CMakeCache.txt、升级CMake、重装Visual Studio、手动设置CMAKE_CXX_COMPILER。结果一轮操作下来,还是相同的报错:
The CXX compiler identification is unknown
一个上午过去了,连开发环境都没部署完,这就很尴尬了。难道要放重装系统的大招?

正好点的咖啡到了。喝了一杯咖啡,冷静了一些,终于决定先看一下CMake自己的报错信息。
打开CMakeError.log,发现里面有大量类似的报错

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Build started 11/30/2021 11:10:35 PM.
Project "SecretPath\CMakeFiles\3.21.1\CompilerIdC\CompilerIdC.vcxproj" on node 1 (default targets).
PrepareForBuild:
Creating directory "Debug\".
Creating directory "Debug\CompilerIdC.tlog\".
InitializeBuildStatus:
Creating "Debug\CompilerIdC.tlog\unsuccessfulbuild" because "AlwaysCreate" was specified.
ClCompile:
C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\bin\CL.exe /c /nologo /W0 /WX- /Od /Oy- /D _USING_V110_SDK71_ /D _MBCS /Gm- /EHsc /RTC1 /MDd /GS /fp:precise /Zc:wchar_t /Zc:forScope /Fo"Debug\\" /Fd"Debug\vc120.pdb" /Gd /TC /analyze- /errorReport:queue CMakeCCompilerId.c
CMakeCCompilerId.c
CMakeCCompilerId.c : fatal error C1001: An internal error has occurred in the compiler. [SecretPath\CMakeFiles\3.21.1\CompilerIdC\CompilerIdC.vcxproj]
(compiler file 'f:\dd\vctools\compiler\cxxfe\sl\p1\c\p0io.c', line 2807)
To work around this problem, try simplifying or changing the program near the locations listed above.
Please choose the Technical Support command on the Visual C++
Help menu, or open the Technical Support help file for more information
Done Building Project "SecretPath\CMakeFiles\3.21.1\CompilerIdC\CompilerIdC.vcxproj" (default targets) -- FAILED.

是因为无法编译这些测试文件,才导致CMake无法识别么?
拿着fatal error C1001f:\dd\vctools\compiler\cxxfe\sl\p1\c\p0io.c作为关键字搜了一波,还真搜到了相关的信息。
fatal error C1001: An internal error has occurred in the compiler. ‘f:\dd\vctools\compiler\cxxfe\sl\p1\c\p0io.c
在这个问题下,有人提到操作系统的locale设置会影响编译器编译p0io.c这个文件。关闭Windows 10区域设置中的Beta版: 使用Unicode UTF-8提供全球语言支持 / Beta: Use Unicode UTF-8 for worldwide language support就可以解决这个问题。(更详细的讨论)
碰巧我的开发机之前为了验证该设置对程序的影响,手动打开过。之后就再也没管过。关闭设置之后,再重新执行CMake,终于成功识别出了Visual Studio 2013的工具链。
总结一下,遇到问题先看日志,别急着套用之前的解决方案。CMake这种成熟的开源软件,给出的错误日志都很详细,直接对着日志排查/上网搜索关键字,往往比无脑重装软件更快。

CLion加载Qt工程的错误处理

今天心血来潮,想优化一下之前写的日志分析工具。打开CLion,加载工程,准备开干,结果CMake报了这样的错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- Could NOT find Qt6CoreTools (missing: Qt6CoreTools_DIR)
CMake Warning at /Users/***/Qt/6.2.1/macos/lib/cmake/Qt6/Qt6Config.cmake:176 (find_package):
Found package configuration file:

/Users/***/Qt/6.2.1/macos/lib/cmake/Qt6Core/Qt6CoreConfig.cmake

but it set Qt6Core_FOUND to FALSE so package "Qt6Core" is considered to be NOT FOUND.
Call Stack (most recent call first):
src/CMakeLists.txt:1 (find_package)

-- Could NOT find Qt6GuiTools (missing: Qt6GuiTools_DIR)
CMake Warning at /Users/***/Qt/6.2.1/macos/lib/cmake/Qt6/Qt6Config.cmake:176 (find_package):
Found package configuration file:

/Users/***/Qt/6.2.1/macos/lib/cmake/Qt6Gui/Qt6GuiConfig.cmake

but it set Qt6Gui_FOUND to FALSE so package "Qt6Gui" is considered to be NOT FOUND.
Call Stack (most recent call first):
src/CMakeLists.txt:1 (find_package)

因为很长一段时间都没有动过这个工程,期间本地的CLion还升级了几个版本,本地Qt版本也升级过好几次,一时间无法确定是什么原因导致的加载失败。好在使用Qt Creator加载工程不会出现这个问题。趁着灵感还在,在Qt Creator里把代码写了。
完事之后上网搜了一波,没有找到靠谱的答案,但是找到了jerbrains官方的Qt指导
照着指导修改了一些工程配置,发现在设置CMAKE_PREFIX_PATH后,上述报错就消失了。
有遇到相同报错的同学可以先排查一下,检查CMAKE_PREFIX_PATH是否包含${QTDIR}/lib/cmake

USN日志处理移动事件时的补充

在写完通过读取USN日志监控文件变动之后,又陆陆续续踩了一些坑,修复了一些客户反馈的bug,主要是移动相关的问题。在这做一下记录,希望能帮到其他的人(应该也没人会去踩这种坑吧。。。)

等待移动队列

通过读取USN日志监控文件变动中提到:

  1. 文件id为X的文件从A移动到B
  2. 文件id为Y的文件从C移动到A

这种场景,在部分情况下会上报

  1. file id为X,USN_REASON包含RENAME_OLD_NAME,名称为A的记录
  2. file id为Y,USN_REASON包含RENAME_OLD_NAME,名称为C的记录
  3. file id为Y,USN_REASON包含RENAME_NEW_NAME和CLOSE,名称为A的记录
  4. file id为X,USN_REASON包含RENAME_NEW_NAME和CLOSE,名称为B的记录

原本认为是使用Transactional NTFS (TxF)技术的软件造成的,但无法复现出来,具体原因仍然有待调查。
如果直接依靠USN_REASON_RENAME_NEW_NAME的到达顺序来判断文件实际的移动顺序,则就会将该场景误判为C移动到B再移动到A。
为了解决这个问题,我们实现了一个等待移动队列,里面的单个元素,包含发生移动的文件ID,以及对应的包含USN_REASON_RENAME_NEW_NAME | CLOSE的记录

  1. USN_REASON_RENAME_OLD_NAME抵达后,向队列尾部添加对应文件ID,记录为空的元素。
  2. 在USN_REASON_RENAME_NEW_NAME | CLOSE记录抵达后,根据记录中的文件ID,查找队列中该ID对应的元素位置并存储记录。并将队列起始位置开始取元素的记录,直到遇到记录为空的情况。

最终输出的事件顺序即为真实的移动顺序。

对移动后仍然保持打开状态文件的处理

在上线等待移动队列后,我们发现部分客户出现了监控事件延迟的问题。经过排查后发现,部分文件(常见于系统日志)在移动后不发送包含USN_REASON_RENAME_NEW_NAME | CLOSE的记录,或发送间隔极长,导致移动事件都积压在等待移动队列中。
为了适配这种场景,我们又在等待队列中的元素中记录了入队时间。并做了如下修改:

  1. USN_REASON_RENAME_OLD_NAME抵达后,除了添加文件ID,还会删除队列中同文件ID的元素。
  2. USN_REASON_RENAME_NEW_NAME | CLOSE记录抵达后,除了存储记录,还会将队列前记录为空但入队时间超过指定间隔的元素移到最后并重置入队时间,之后再从队列起始位置开始取元素的记录。

两种操作的目的都是为了将相关记录移动到队列尾部,确保不阻塞其他文件的移动事件。

在MySQL上以text类型字段为查找结果排序时踩到的坑

公司的同步功能后端接口,提供了一系列和文件操作相关的接口。其中一个接口的作用是,返回云端指定节点下所有子节点id的列表,且保证父节点id在列表中一定比子节点id先出现。
客户端会按顺序遍历这个列表,获取id对应的信息,插入一个树结构,就可以得到云端当前的文件结构。如果列表中的id顺序不对,或者只有子节点id没有父节点id,那么客户端就会通知云端数据出现错误,并重新调用这个接口,获取修复后的文件结构。
最近在回归测试同步功能时发现,当云端某个目录层级过深时,使用上面提到的那个接口获取id列表时,返回的id顺序会出现错乱。比较奇怪的是,数据库中这个目录及下列节点的数据均没有出现错乱的情况。使用其他接口来获取文件结构时也不会出现错误。难道是这个接口的实现有问题?
后端数据库表在设计时只记录了节点当前的直属父节点的id。后续迭代开发时,为了满足一系列的业务需求,增加了一个text类型的字段path_ids,表示从根节点到当前节点的完整路径。实际内容为以/分割的节点id。在新建、移动、删除节点时维护这个字段。看了一下这个接口的实现:

  1. 查找db中节点对应的path_ids,记为p
  2. 查找db中所有path_ids以p+/开头的节点,并以path_ids升序排序返回id

看着也没什么问题。但实际输出的列表中,层级比较深的几个目录节点,和下面的子节点,顺序始终是错的。直接连接mysql,查找db中所有path_ids以p+/开头的节点,并以path_ids升序排序返回id和path_ids,得到的结果也是错误的。难道是MySQL的错?
一顿Google之后,发现问题关键点了。按照The BLOB and TEXT Types中提到的。BLOB/TEXT类型的字段只有前max_sort_length个字节被用于排序。max_sort_length默认值为1024。在我们这个场景中,因为路径比较深,节点和下面的子节点的path_ids字段长度超过了1024。MySQL在排序时,无法正确处理这些节点的先后顺序。
找到问题原因后,解决方案就比较多了。

  1. 接口本身不对节点id做排序。客户端在拿到子节点id和对应的节点信息后,根据节点信息中的父子关系,去重新构建文件树。
  2. 业务层从MySQL中获取不排序的数据,然后在业务代码中排序。
  3. 增大max_sort_length值,继续让MySQL执行排序。

综合性能及兼容性的考虑,最终我们选择了

  1. 被线上老版客户端调用的原有接口,使用增大max_sort_length值的方案。
  2. 新开一个新版客户端调用的接口,使用方案1。

在macOS 10.15.4之后的系统上编译Qt 5.14.2 WebEngine时遇到的问题

背景

公司目前还在维护一个基于Qt WebEngine开发的V2客户端。在这个客户端上,依赖浏览器内核来实现对音视频文件的预览。为了避免许可问题,Qt官方给出的二进制文件中,没有启用对特定格式音视频解码器的支持,导致部分音视频文件无法被解码。因此每次升级Qt版本时,都需要重新编译Qt WebEngine,启用对这些音视频解码器的支持。
几个月前,公司选择接入WPS预览方案作为新的在线文档预览方案。因为方案用到了浏览器的Service Worker API,而当时V2客户端使用的Qt 5.9.3内置的WebEngine,对Service Worker API的支持存在问题。导致在V2客户端中打开预览界面时,会出现页面完全空白等问题。这些bug同时影响了Windows和macOS两个平台上的V2客户端。因为公司的下一代产品,暂时没有macOS平台的开发计划,只能将macOS平台上V2客户端使用的Qt升级到当时最新的5.14.2版本,并重新发布。因此,需要在macOS上重新编译Qt 5.14.2的WebEngine。

编译

编译本身还是很简单的。下载源代码并解压源代码,再在源代码的上级目录下新建build目录,在build目录下执行

script
1
2
3
4
5
6
7
8
9
../qt-everywhere-src-5.14.2/configure -opensource \
-confirm-license \
-verbose \
-release \
-nomake tests \
-nomake examples \
-webengine-proprietary-codecs
make
make install

这样执行,编译结果和中间文件都在build目录中生成。如果需要修改配置重新编译,直接清空build目录再重新配置生成就可以了。
因为编译过之前之前的版本,都是一次通过,以为这次也会一样顺利。周五下班前开始编译,周一上班时直接就可以用了。结果周末远程回公司电脑看编译结果,看到了一堆错误。根源是src/qtwebengine/src/3rdparty/chromium/build/mac/find_sdk.py抛了Exception: No 10.15.4+ SDK found的异常。
一顿搜索后,在Qt BugReports系统里找到了别人提的bug(QTBUG-83318)。报告人和我一样,在编译Qt 5.14.2版本的时候遇到了这个问题。开发给出的解释是10.15.4开始,xcrun命令输出的SDK版本信息格式为Major.Minor.Patch,而不是原来的Major.Minor,而原有的编译脚本没有适配这个改动。5.12.9以及5.15的正式版上会修复这个问题。而5.14版本不是LTS版本,5.14.2之后不会再有新的小版本更新,需要我们自行修复。
研究了一下5.15上修复bug的commit,基本就是修改一下src/buildtools/config/mac_osx.pri里获取mac_sdk_min的方式,适配一下新的格式。在5.14.2源代码的对应位置中修改一下,就可以修复这个报错,让编译继续。
之后又遇到了一个异常:src/qtwebengine/src/3rdparty/chromium/build/toolchain/mac/filter_libtool.py在执行IsBlacklistedLine时抛了TypeError: cannot use a string pattern on a bytes-like object异常。这个是因为我系统中的python默认为python3。filter_libtool.py中执行libtoolout.communicate()得到的_err类型为bytes,但IsBlacklistedLine期待的入参类型为str。这个需要在得到err后,执行

1
err = err.decode()

将err解码为utf8编码的字符串,再执行后面的操作。

完成上述修改后,即可正常编译。

替换

上述命令执行完成后,会在/usr/local/Qt-5.14.2/目录下生成最终编译产物。这套SDK的Qt WebEngine已经启用对上述音视频解码器的支持。使用这套SDK重新编译客户端即可。
如果你的项目和我们的V2客户端一样,使用了PyQt5,则需要复制/usr/local/Qt-5.14.2/lib下的QtWebEngineCore.framework目录,替换掉site-packages/PyQt5/Qt/lib下的同名目录。

通过读取USN日志监控文件变动

之前我曾在借助计划任务执行需要管理员权限运行的程序里提过,要用读取USN日志来实现对文件变动的监控。之后实现了初版的监控,并断断续续迭代了几个月,修了不少bug。现在功能算是稳定了,在这记录一下,也分享一下遇到的坑,希望能帮到其他人。

USN日志

简介

官方文档戳这里
简单说就是NTFS格式的磁盘上可以开启USN日志功能,记录所有文件/文件夹的文件变动,包括增加删除移动重命名、属性变化、内容变化等。
USN日志中包含一条一条的USN记录,这些记录可以通过 update sequence number (USN) 进行查找。
不同版本的USN日志中存储的USN记录结构体并不相同(USN_RECORD_V2/USN_RECORD_V3/USN_RECORD_V4)。但都有下列属性:

  • FileReferenceNumber: 文件ID
  • ParentFileReferenceNumber: 父文件ID
  • Usn: USN
  • TimeStamp: 时间戳
  • Reason: 变动原因
  • FileAttributes: 文件属性
  • FileNameLength、FileNameOffset、FileName: 文件名

基础实现

Windows提供了两种方式来读取USN日志记录

  1. FSCTL_ENUM_USN_DATA 枚举两个USN之间的所有USN记录。
  2. FSCTL_READ_USN_JOURNAL 读取指定USN之后满足条件的USN记录

可以参考官方例子walking-a-buffer-of-change-journal-records

监控实现

整体方案

  1. 使用CreateFile获得一个磁盘卷的句柄(该操作需要UAC权限)
  2. FSCTL_CREATE_USN_JOURNAL创建/更新USN日志,得到执行FSCTL_CREATE_USN_JOURNAL时刻T0,当前USN日志第一条记录的USN FirstUsn和下一次记录插入时的USN NextUsn。在已经有USN日志的磁盘卷上执行该操作,会按照传入的参数,重新剪裁日志。
  3. FSCTL_ENUM_USN_DATA枚举FirstUsnNextUsn之间的所有USN记录。这些记录对应了T0时,磁盘卷上存在的文件/文件夹。可以通过USN记录中的文件ID、父文件ID、文件名来构建文件树结构FileTree
  4. 反复执行FSCTL_READ_USN_JOURNAL 获取NextUsn之后的所有USN记录。对每一条记录,根据文件ID/父文件ID,在FileTree中获取完整路径。并根据Reason更新FileTree,并生成对应的文件变动事件。

需要注意的坑

  • 只处理Reason包含USN_REASON_CLOSE时的USN记录。

  • 单条USN记录的Reason可能同时包含USN_REASON_FILE_DELETE / USN_REASON_RENAME_NEW_NAME / USN_REASON_FILE_CREATE。目前我们的实现是优先判断USN_REASON_FILE_DELETE,然后判断USN_REASON_RENAME_NEW_NAME, 最后判断USN_REASON_FILE_CREATE。

  • 理论上可以在执行FSCTL_READ_USN_JOURNAL时带上过滤条件,只获取包含USN_REASON_CLOSE时的USN记录。
    但我们的客户反馈,部分使用Transactional NTFS (TxF)的软件(比如Word的某几个版本)在执行删除A -> B移动到A操作时,FSCTL_READ_USN_JOURNAL获取到记录的顺序为B移动到A -> 删除A。导致上层逻辑认为实际操作为B文件移动到A位置之后被删除了。而对应的Reason为USN_REASON_RENAME_OLD_NAME的记录到达顺序则是准确的。最终我们选择记录Reason为USN_REASON_RENAME_OLD_NAME的记录对应的文件ID,并在更新FileTree和生成文件变动事件时,严格按照USN_REASON_RENAME_OLD_NAME到达顺序来处理USN_REASON_RENAME_NEW_NAME记录。

  • FSCTL_ENUM_USN_DATA在文件量较多、磁盘速度较慢的情况下,需要花费较长的时间执行。这会导致:

    1. 如果每次启动监控都执行一次FSCTL_ENUM_USN_DATA,则需要等待很长的时间才能真正开始工作。

    2. FSCTL_READ_USN_JOURNAL开始读取的一部分USN记录延迟较大。

      目前我们的做法是:

    • 将文件树存入sqlite3 db中缓存起来,并记录对应的USN日志ID和最后一次执行FSCTL_READ_USN_JOURNAL时返回的NextUsn
    • 监控启动时判断USN日志ID是否和db中记录的一致。NextUsn是否大于等于db中记录的值。如果一致,则跳过执行FSCTL_ENUM_USN_DATA,以db中的NextUsn作为执行FSCTL_READ_USN_JOURNAL时传入的初始值。否则清空DB,按照正常流程执行FSCTL_ENUM_USN_DATA。
    • FSCTL_ENUM_USN_DATA执行完成后,先执行FSCTL_READ_USN_JOURNAL,只获取包含USN_REASON_CLOSE时的USN记录,并更新FileTree。直到读取不到USN记录,或者USN记录的触发时间与当前时间的间隔已经小于1秒。之后再执行正常的FSCTL_READ_USN_JOURNAL流程,开始生成文件变动事件。
  • USN_REASON_OBJECT_ID_CHANGE的解释为The object identifier of a file or directory is changed.,但似乎不能作为文件ID变化的依据。

  • NTFS的树状结构中,磁盘卷根目录并不是文件树的根节点。存在部分节点,他们的父文件ID并不在FSCTL_ENUM_USN_DATA构造的文件树中。遇到这种case,认为这些节点在磁盘卷的根目录下即可。

  • 执行FSCTL_READ_USN_JOURNAL过程中如果删除USN日志,则FSCTL_READ_USN_JOURNAL会返回错误ERROR_INVALID_PARAMETERERROR_JOURNAL_ENTRY_DELETED,需要根据对应的业务逻辑,重新执行FSCTL_CREATE_USN_JOURNAL生成新的USN日志,或者结束监控。

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