diff --git a/.gitignore b/.gitignore index baa7ae1..a7a729c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,9 @@ dist/ build/ *.egg .pytest_cache/ +.test-tmp*/ .ruff_cache/ +.venv-codex/ .mypy_cache/ .coverage coverage.xml @@ -14,7 +16,11 @@ htmlcov/ # 签名产物(证书、密钥、Profile、签名后的 hap) signing_files/ +signed_haps/ +logs/ +hapsign-config.json output/ +/*.hap # 反编译参考源码(不参与运行,体积过大) reverse/ @@ -25,6 +31,9 @@ reverse/ *.swp *.swo +# 本地运行脚本(含个人环境变量,不提交) +run_sign_install.bat + # OS Thumbs.db Desktop.ini diff --git a/CHANGELOG.md b/CHANGELOG.md index a9b5a04..2a251ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,64 @@ - Ruff、pytest、覆盖率、pre-commit 和 Windows CI 配置。 - 贡献指南、安全策略、行为准则和统一编辑器配置。 - CLI、缓存、HTTP 响应和权限提取的单元测试。 +- 自动检测已签名 HAP:存在 Hap Signing Block 时跳过登录/申请/签名,直接安装。 +- 签名块检测对齐 `developtools_hapsigner`(两阶段 EOCD、ZIP64 Locator、blockCount/尺寸边界)。 +- PySide6 桌面界面,支持文件选择、HAP 拖放、后台执行和运行记录。 +- 桌面版增加主动设备检测,并在签名安装前强制确认 HDC 设备可用。 +- 运行记录使用统一的轻量滚动条样式,文件卡片支持移除误选 HAP。 +- Windows 便携版声明 Per-Monitor V2 DPI 感知,并使用系统字体和小数缩放。 +- 桌面版签名材料改为保存在程序目录的 `signing_files/`,便于随目录迁移。 +- Windows 下 Java、keytool 和 HDC 使用无控制台窗口方式启动,消除闪窗。 +- HDC server 采用“本次启动、本次关闭”策略,避免任务结束后遗留后台进程, + 同时不终止 DevEco 等工具已有的 HDC 服务。 +- PyInstaller 便携版构建配置,以及跨平台用户数据和工具链发现。 +- 恢复 Playwright 受控浏览器作为默认登录环境,系统默认浏览器保留为备用模式。 +- 新增 Playwright 受控系统 Edge/Chrome 模式并作为精简包默认值;兼容包仍可 + 携带内置 Chromium,普通系统默认浏览器只作为非受控备用。 +- 桌面任务使用按实际阶段推进的百分比进度条,并支持主动取消。 +- 执行中的登录、网络请求、Java/keytool、签名工具和 HDC 均响应取消信号; + 关闭窗口时可确认中断,清理完成后自动退出。 +- 新增程序目录 JSON 配置、滚动文件日志、日志级别和敏感诊断开关。 +- 桌面设置可在程序目录、用户 AppData Local 和自定义签名目录之间切换,并可 + 一键打开签名目录或日志目录。 +- 可选择是否保留签名后的 HAP;默认在程序目录 `signed_haps/` 中仅保留最新 + 一个,新文件发布成功后才删除旧文件,关闭后使用并清理任务临时文件。 +- 便携构建采用带回退开关的保守精简:只排除已知无关的 JCEF/录像组件,并在 + 裁剪后强制执行 Chromium、Java、keytool 与 hap-sign-tool 自检。 +- 设置页改为分区卡片布局,统一重绘下拉框、弹出菜单、复选框及操作按钮。 +- Windows 字体改用整数逻辑像素、Microsoft YaHei UI 和完整 hinting,降低 + 150%/200% 高 DPI 下由分数物理像素造成的笔画发虚。 +- 补充隐私说明、第三方组件声明和开源发布门禁;便携包构建会复制项目许可、 + 冻结依赖随附许可,并为本机复制的工具链生成 SHA-256 来源清单。 +- 明确区分 MIT 源码发布与第三方二进制再分发:未确认具体 DevEco/SDK 版本许可前, + 完整便携 ZIP 只用于本地构建验证。 +- 正式 Windows 便携构建改用 `toolchain.lock.json` 锁定并校验的 OpenHarmony + 6.1 公共 SDK 与 Eclipse Temurin 21;构建时仅提取 HDC、hap-sign-tool、 + libusb 和 NOTICE,并以 `jlink` 生成精简 Java 运行时,同时随包保留来源、 + 哈希、许可材料及 libusb 对应源码。DevEco 工具链只保留为显式排障回退。 +- 便携构建在 ZIP 旁自动生成标准 `.sha256` 校验文件,降低发布时手工抄录哈希出错的风险。 + +### Fixed + +- HTTP 客户端正确发送 `User-Agent` / `Accept-Language` 请求头。 +- Token 缓存缺少 `jwt_token` 时不再复用,避免后续刷新失败。 +- 设备注册将业务层重复错误码视为成功,并保留 HTTP 错误信息中的兼容判定。 +- 设备注册兼容服务端新增的 `205389858 (UDID is repeat)`,复用已注册设备, + 不再把“设备已存在”当作安装失败。 +- 签名工具默认密码统一引用 `HAPSIGN_KEYSTORE_PASSWORD` / 配置默认值。 +- 登录回调兼容根路径、`/callback` 及其他本地路径上的 GET/POST 回调, + 并支持普通表单、multipart 表单、JSON、查询参数和 CORS 预检,修复授权后 + 一直等待的问题;用户拒绝授权时也会立即结束等待。 +- 登录回调支持 Chromium Private Network Access 预检、缺失 multipart 类型推断 + 及嵌套 JSON;运行记录会显示不含 token 的回调方法、类型和字段诊断信息。 +- 已选 HAP 卡片固定显示在拖放区右侧,避免窗口布局变化时覆盖拖放区。 +- 主动取消任务后将进度条重置为 0%。 +- 登录成功响应恢复为已验证实现使用的 DevEco 成功页跳转,并为受控 Chromium + 预授予本地网络访问权限,避免授权完成后回调请求被浏览器策略拦截。 ### Security - 登录回调服务仅监听 loopback 地址。 -- 登录日志不再包含 token、请求体、CSRF code、用户 ID、设备 UDID 或完整登录 URL。 +- 日志默认不包含 token、完整请求体、CSRF code 或完整登录 URL;只有用户主动开启 + “敏感诊断”且使用 DEBUG 级别时才记录完整网络载荷,密钥库密码始终排除。 - 限制登录回调请求体大小,并尽力收紧 token 缓存文件权限。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 828c89e..902f098 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,6 +28,12 @@ python -m pytest --cov 也可以安装 `pre-commit` 后运行 `pre-commit install`。仓库中的本地 hooks 使用当前 Python 环境已安装的 Ruff,不会在执行时下载额外工具。 +## 桌面版与打包 + +修改 GUI、运行时发现或打包配置后,除单元测试外还应构建一次当前平台的 +PyInstaller 便携版,并运行打包后的 `--smoke-test`。完整步骤、工具链要求和 +发布检查清单见 [docs/PACKAGING.md](docs/PACKAGING.md)。 + ## Pull request - 每个 PR 聚焦一个问题,并说明行为变化和验证方式。 @@ -35,3 +41,6 @@ Python 环境已安装的 Ruff,不会在执行时下载额外工具。 - 不在测试中调用真实华为服务;使用 pytest 的 monkeypatch 或 mock。 - 若改动用户可见行为,在 `CHANGELOG.md` 的 Unreleased 小节记录。 - API 来自非公开实现时,应在说明中标注兼容性风险,不提交第三方反编译产物。 +- 新增运行时依赖或便携包二进制时,同步更新 `THIRD_PARTY_NOTICES.md`,并提供 + 可审计的来源、版本、哈希和再分发许可。 +- 完整便携包的公开发布还必须通过 `docs/OPEN_SOURCE_RELEASE.md` 的许可与隐私门禁。 diff --git a/PORTABLE.md b/PORTABLE.md new file mode 100644 index 0000000..da58fa9 --- /dev/null +++ b/PORTABLE.md @@ -0,0 +1,93 @@ +# HapSign 便携版 + +解压 ZIP 后双击 `HapSign.exe`(macOS/Linux 使用对应平台可执行文件)。 + +## 使用方法 + +1. 用 USB 连接 HarmonyOS 设备,并确认设备已允许调试。 +2. 可点击“检测设备”确认设备已连接并授权;也可以直接开始,程序会自动检测。 +3. 将 `.hap` 文件拖入窗口,或点击选择文件;误选时点击文件右侧的“×”移除。 +4. 点击“开始签名并安装”。 +5. 如果 HAP 尚未签名,程序会控制系统 Edge(其次 Chrome)打开华为登录页; + 完成登录后程序会继续。 + +进度条按当前实际流程阶段推进。任务执行期间可以点击“取消”;如果直接关闭窗口, +程序会询问是否中断当前任务。确认后会先结束登录等待或外部工具、清理本次启动的 +HDC 服务,再退出。取消不会复用未完成任务的运行状态,可直接重新开始。 + +签名后的 HAP、证书、Profile、密钥库和登录令牌默认保存在程序目录下: + +```text +HapSign/ +├── HapSign.exe +├── signing_files/ + ├── .token_cache.json + └── / +└── signed_haps/ # 最新一个签名 HAP +``` + +请把便携版解压到当前用户可写的目录,不要放进 `Program Files` 等受保护位置。 +移动整个 `HapSign` 目录时,签名材料和缓存会一起移动。 + +标题栏的“设置”可把签名目录改为当前用户的 `AppData Local` 或自定义目录,也可 +直接打开签名目录和日志目录。配置文件是程序目录下的 `hapsign-config.json`; +日志默认写入 `logs/hapsign.log`,无法写入程序目录时会回退到用户本地数据目录。 +敏感日志默认关闭;只有主动开启且日志级别为 DEBUG 时才记录 token、用户标识及 +完整 API 请求/响应。签名库密码始终不会写入日志。 + +“保留最新一个签名后的 HAP”默认开启。程序会在新 HAP 完整签名成功后写入 +`signed_haps/`,只删除 HapSign 清单记录的旧产物,因此不会误删目录中的用户 HAP, +当前输入文件也不会被清理,签名失败也不会破坏上一份。关闭该开关后,签名 HAP 只 +作为安装临时文件,任务结束后自动清理。 + +输入已签名 HAP 时会直接安装,不产生新的签名材料。 +Java、keytool 和 HDC 等外部命令会在后台执行,不会弹出命令行窗口。 +任务结束时只关闭由本次任务启动的 HDC server;原本由 DevEco 等工具启动的 +既有 HDC server 不会被终止。 + +## 构建 + +构建机需要 Python 3.11+。目标电脑不需要安装 Python 或 DevEco Studio;默认 +精简包要求目标 Windows 已安装 Edge 或 Chrome。正式 Windows 构建先准备锁定的 +OpenHarmony/Temurin 工具链: + +```bash +python -m pip install -e ".[gui,bundle]" +python scripts/prepare_toolchain.py +python scripts/build_portable.py +``` + +要生成不依赖系统浏览器二进制的兼容包: + +```powershell +$env:PLAYWRIGHT_BROWSERS_PATH = "0" +python -m playwright install --no-shell chromium +python scripts/build_portable.py --keep-bundled-browser +``` + +构建结果位于 `dist/HapSign-portable-.zip`,同目录会生成可用于发布校验的 +`.zip.sha256` 文件。 +兼容包位于 `dist/HapSign-portable--compat.zip`。 +准备脚本使用 `jlink` 生成精简 Temurin 运行时,构建脚本会自动执行 Java、 +keytool、hap-sign-tool、HDC 和冻结程序自检。 + +仅调试 GUI、不复制外部工具链时可以运行: + +```bash +python scripts/build_portable.py --skip-toolchain +``` + +该 GUI-only 包不能在没有外部工具链的电脑上完成签名和安装。完整的构建环境、 +资源发现顺序、目录结构、验证方法和发布清单见 `docs/PACKAGING.md`;生成的便携 +目录中也会包含一份 `BUILDING.md`。 + +PyInstaller 产物与当前操作系统绑定,因此 Windows、macOS、Linux 需要分别构建。 +锁文件会记录公共 SDK、Temurin 和核心文件哈希;发布包也包含生成时的 +`PROVENANCE.txt`、完整 OpenHarmony NOTICE、Temurin legal 目录,以及 +`libusb_shared.dll` 对应的 OpenHarmony 源码快照。若使用 +`--allow-deveco-toolchain` 回退,本次产物只用于本机排障,不得公开发布。 + +发布包根目录会包含 HapSign 的 `LICENSE`、`PRIVACY.md`、 +`THIRD_PARTY_NOTICES.md` 和 `BUILDING.md`,冻结依赖随附的许可文件位于 +`licenses/python/`。Temurin legal、OpenHarmony NOTICE 和 libusb 对应源码也必须 +保留。 diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..f11aa97 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,48 @@ +# Privacy and local data + +HapSign 是本地运行的开源工具,项目维护者没有自建后端、遥测、广告或崩溃上报。 +但签名流程需要用户主动登录华为开发者服务,并直接与华为域名通信。使用这些服务时, +数据处理还受华为账号、开发者服务及所在地区适用条款约束。 + +## 网络通信 + +程序只在用户启动签名流程后访问: + +- `https://devecostudio.huawei.com`:登录、临时令牌和访问令牌交换; +- `https://connect-api.cloud.huawei.com`:团队、证书、设备、应用和 Provision + Profile 管理; +- 上述服务返回的短期证书/Profile 下载地址; +- `127.0.0.1` 的临时回调端口:浏览器把登录结果交还给本机 HapSign,不对局域网 + 或公网监听。 + +发送给华为服务的数据可能包括账号令牌、用户和团队标识、CSR 公钥请求、证书名称、 +HAP 包名、HAP 声明的待预授权权限、设备 UDID/类型/自动生成的设备名、证书 ID、 +设备 ID、应用 ID 以及 Profile 名称。HAP 文件正文、私钥和密钥库密码不会上传; +HAP 签名在本机完成。 + +程序会查询、创建和在必要时删除当前账号下的调试证书、设备记录及 Provision +Profile。重复 UDID 会复用已有设备记录。用户应确认自己有权对相应账号、应用和设备 +执行这些操作。 + +## 本地保存 + +根据设置,以下文件保存在程序目录、用户 Local AppData 或用户选择的目录: + +- `signing_files/.token_cache.json`:访问令牌、刷新令牌、JWT 和账号基本字段; +- `signing_files//`:私钥密钥库、CSR、证书、Profile 和缓存元数据; +- `signed_haps/`:可选保留的最后一个已签名 HAP; +- `logs/hapsign.log*`:诊断日志; +- `hapsign-config.json`:日志级别、保存位置和功能开关。 + +令牌和签名材料按日期复用,但不会由项目维护者远程删除。用户可关闭应用后删除上述 +目录;分享、卸载或移动便携目录前也应主动检查。签名 HAP 可能包含用户自有代码,不应 +随公开问题报告或发布包上传。 + +## 日志 + +默认日志不会记录 token、完整登录 URL、完整回调正文或完整 API 请求/响应。只有用户 +同时选择 DEBUG 级别并开启“记录敏感诊断信息”后,日志才可能包含这些内容;密钥库密码 +始终不记录。敏感排障完成后应关闭开关并删除已有日志与令牌缓存。 + +更多安全建议见 [SECURITY.md](SECURITY.md)。源代码可以审计全部网络端点;如果不接受 +上述数据流,请不要启动登录和签名流程。 diff --git a/README.md b/README.md index 0cd8808..ec6ac1c 100644 --- a/README.md +++ b/README.md @@ -4,28 +4,56 @@ > [!IMPORTANT] > 本项目是非官方工具,与华为无隶属或背书关系。它依赖可能变化的在线接口,仅用于合法的 -> 本机开发和调试。使用者应自行确认账号权限、数据安全以及相关服务条款。 +> 本机开发和调试。使用者应自行确认账号权限、数据安全以及相关服务条款。程序的数据流 +> 和本地保存行为见 [PRIVACY.md](PRIVACY.md),第三方许可边界见 +> [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。 ## 工作原理 ``` -Playwright 打开华为登录页(用户手动登录) - → 拿 tempToken → 换 accessToken - → 调华为云签名 API 生成 .cer / .p7b - → hap-sign-tool 签名 hap - → hdc install 安装到设备 +检测 HAP 是否已签名 + ├─ 已签名 → hdc install 直接安装 + └─ 未签名 → + Playwright 控制系统 Edge/Chrome 打开登录页(用户手动登录) + → 拿 tempToken → 换 accessToken + → 调华为云签名 API 生成 .cer / .p7b + → hap-sign-tool 签名 hap + → hdc install 安装到设备 ``` ## 前置条件 -1. **DevEco Studio** 已安装(需要其中的 JBR Java、hap-sign-tool.jar、hdc) +1. **签名/设备工具链**:便携版已内置;源码运行可使用已准备的公开工具链或 + DevEco Studio 2. **Python 3.11+**(推荐使用 conda 或 venv 隔离环境) 3. **HarmonyOS 设备**已通过 USB 连接并开启 USB 调试模式 4. **华为开发者账号**(需要已完成实名认证) -支持 **Windows** 与 **macOS**(命令行)。 +命令行支持 **Windows** 与 **macOS**;当前正式便携版为 Windows。 -## 安装 +## 直接使用 Windows 便携版(推荐) + +普通使用者不需要安装 Python、DevEco Studio、Java 或 HDC。到 GitHub Releases +下载 `HapSign-portable-windows.zip` 及旁边的 `.zip.sha256` 校验文件,先在 +PowerShell 中核对下载完整性: + +```powershell +Get-FileHash .\HapSign-portable-windows.zip -Algorithm SHA256 +Get-Content .\HapSign-portable-windows.zip.sha256 +``` + +确认哈希一致后,将 ZIP 解压到当前用户可写的目录(不要放入 `Program Files`), +双击 `HapSign.exe` 即可。精简包会复用系统 Edge/Chrome;如果发布页同时提供 +`HapSign-portable-windows-compat.zip`,它包含内置 Chromium,适合没有可用系统 +浏览器的电脑。 + +首次使用时连接已开启 USB 调试的 HarmonyOS 设备,并在设备上确认调试授权;在窗口中 +点击“检测设备”,然后拖入或选择 `.hap`,点击“开始签名并安装”。未签名 HAP 会打开 +受控浏览器完成华为账号登录和验证码/二次验证,之后自动申请材料、签名并安装;已签名 +HAP 会跳过登录和签名直接安装。签名材料、日志和可选的签名后 HAP 的位置见 +[便携版说明](PORTABLE.md)。 + +## 从源码安装 ```bash # 克隆仓库 @@ -35,13 +63,28 @@ cd HapSign # 安装项目(提供 hapsign 命令) python -m pip install . -# 安装 Playwright 浏览器(首次必须) -playwright install chromium ``` -### 配置 DevEco Studio 路径 +源码桌面版默认由 Playwright 控制本机 Edge(其次 Chrome),仍会预授予登录页访问 +本地回调服务的权限,因此不同于直接调用系统默认浏览器的旧方案。Windows 10/11 +通常已经包含 Edge,不需要额外下载 Chromium。 + +如需使用内置 Chromium 兼容模式: + +```powershell +$env:PLAYWRIGHT_BROWSERS_PATH = "0" +python -m playwright install --no-shell chromium +``` + +桌面设置提供“受控系统浏览器”“内置 Chromium”和“非受控系统默认浏览器”三种 +模式;环境变量 `HAPSIGN_BROWSER` 可使用 `system_controlled`、`playwright` +或 `system` 覆盖代码默认值。 + +### 可选:配置 DevEco Studio 回退路径 -默认查找路径: +正式便携版不需要 DevEco Studio。源码运行时程序会优先使用已准备的公开工具链,只有 +在排查特定 DevEco 版本兼容性时才需要本机 DevEco 回退。程序会查找系统常见安装目录 +和 `D:\Program Files\Huawei\DevEco Studio`;如果安装在其他位置,设置环境变量: - Windows: `D:\Program Files\Huawei\DevEco Studio` - macOS: `/Applications/DevEco-Studio.app/Contents` @@ -79,18 +122,87 @@ $env:HAPSIGN_PYTHON = "C:\path\to\your\python.exe" ## 使用 -### 方式一:命令行(Windows / macOS) +### 方式一:桌面应用(推荐) + +安装 GUI 依赖后运行: + +```bash +python -m pip install -e ".[gui]" +hapsign-app +``` + +桌面版支持点击选择或拖入 `.hap` 文件,也可以点击右侧文件卡片的“×”移除误选 +文件。可随时点击“检测设备”确认 HDC 连接状态,并在后台完成登录、签名和安装。 +进度条会按设备检测、账号授权、证书申请、签名和安装等实际阶段推进。执行期间 +可以点击“取消”;关闭窗口时会询问是否中断,完成子进程和 HDC 清理后再退出, +取消后的同一窗口可以直接重新开始完整流程。 +开始流程时还会自动执行一次设备可用性检查,未连接、未授权或同时连接多台设备 +时不会继续。运行记录和错误会直接显示在窗口中。 + +标题栏的“设置”可选择登录浏览器、签名文件保存位置和日志级别,并能直接打开 +签名目录或日志目录。诊断日志默认写到程序目录的 `logs/hapsign.log`,单个文件 +最多 4 MiB,保留 3 份轮转备份。敏感诊断默认关闭;主动开启并选择 DEBUG 后, +日志可能包含 token、用户标识和完整 API 请求/响应,但始终不会记录密钥库密码。 +“保留最新一个签名后的 HAP”默认开启:最终 HAP 固定写到程序目录的 +`signed_haps/`,新文件成功生成后只清理 HapSign 清单记录的旧产物,不会删除 +目录中未记录的用户 HAP;当前输入文件也会受到保护。关闭后程序使用临时文件安装, +任务结束即删除。 + +如果 HDC server 原本未运行,程序会在本次任务结束时关闭自己拉起的后台服务; +如果 DevEco Studio 或其他工具已经启动 HDC server,则会保留该既有服务。 + +### 方式二:bat 拖拽 + +将 `.hap` 文件直接拖到 `sign_install.bat` 上,自动完成签名+安装。 + +### 方式三:命令行(Windows / macOS) ```bash hapsign --hap path/to/app-unsigned.hap +hapsign --hap path/to/app-signed.hap # 已签名则跳过签名,直接安装 ``` 包名会自动从 hap 内的 `module.json` 提取,无需手动指定。 +若 HAP 已包含签名块(Hap Signing Block),会跳过登录与签名,直接安装原文件。 源码目录中仍可使用 `python main.py --hap ...`。 -### 方式二:拖拽(仅 Windows) +### 构建便携版 -将 `.hap` 文件直接拖到 `sign_install.bat` 上,自动完成签名+安装。 +便携版是一个可直接解压运行的目录,不要求目标电脑安装 Python 或 DevEco Studio。 +Windows 正式包使用锁定并校验的 OpenHarmony 6.1 公共工具链和 Eclipse Temurin +21。首次构建先准备工具链,再打包: + +```bash +python -m pip install -e ".[gui,bundle]" +python scripts/prepare_toolchain.py +python scripts/build_portable.py +``` + +`prepare_toolchain.py` 会校验 `toolchain.lock.json` 中的大小和 SHA-256,只从 +OpenHarmony 公共 SDK 提取 HDC、libusb、hap-sign-tool 和 NOTICE,再用 Temurin +JDK 的 `jlink` 生成精简 Java 运行时。公共 SDK 下载约 2.5 GB,但只在构建缓存中 +保留;最终工具链约 66 MiB。已有下载可用 `--sdk-archive` 和 `--jdk-archive` +传入,仍会执行相同校验。 + +默认产物复用系统 Edge/Chrome,不包含 Chromium。要生成包含内置 Chromium 的兼容 +包,PowerShell 中执行: + +```powershell +$env:PLAYWRIGHT_BROWSERS_PATH = "0" +python -m playwright install --no-shell chromium +python scripts/build_portable.py --keep-bundled-browser +``` + +输出文件为 `dist/HapSign-portable-.zip`。Windows、macOS 和 Linux +产物需要在各自平台分别构建。便携版使用说明见 [PORTABLE.md](PORTABLE.md), +完整构建步骤见 [docs/PACKAGING.md](docs/PACKAGING.md)。 + +> [!NOTE] +> 当前锁定的 OpenHarmony 公共工具链是 `6.1.0.31 / API 23`(HDC 3.2.0c), +> 并非 DevEco 6.1.1.125 中较新的 API 24 版本。现有签名产物验证、实际重签名和 +> 设备识别已经通过;正式发布仍应按 +> [开源发布门禁](docs/OPEN_SOURCE_RELEASE.md) 完成真实设备安装回归。 +> `--allow-deveco-toolchain` 只用于排障回退,其产物不得冒充锁定的公开构建。 ### 完整参数 @@ -98,7 +210,7 @@ hapsign --hap path/to/app-unsigned.hap hapsign --hap [选项] 选项: - --hap 未签名的 hap 文件路径(必填) + --hap hap 文件路径(必填;已签名则直接安装) --bundle-name 应用包名(不传则从 hap 内自动提取) --country 国家码,默认 CN --device-type 设备类型码,默认 4 @@ -133,12 +245,28 @@ hapsign --hap app.hap --enable-capability 此模式通过 `add.real.provision` API 创建 Real Profile(provisionType=1),对应 DevEco Studio 6.1+ 的 `enableCapability` 路径。需要应用已在 AGC(AppGallery Connect)注册且当前账号有访问权限,否则自动回退到 Test Profile。 -### 签名后的文件 +### 签名文件和缓存位置 -签名后的 hap 和签名材料保存在 `signing_files/{bundle_name}/` 下: +桌面版和便携版默认把签名材料保存在程序目录旁,解压目录可以整体移动: +```text +HapSign/ +├── HapSign.exe +├── signing_files/ + ├── .token_cache.json + └── / +└── signed_haps/ # 最新一个签名 HAP(可在设置中关闭) ``` -signing_files/com.example.myapp/ + +源码 CLI 默认保存在启动命令时所在目录的 +`signing_files//`;传入 `--work-dir` 可以指定其他目录。 +程序目录必须可写,不建议把便携版解压到 `Program Files` 等受保护目录。 +桌面版“设置”中还可以改为用户 `AppData Local` 或任意自定义目录。 + +成功完成一次未签名 HAP 的签名后,目录内容如下: + +``` +<签名目录>/com.example.myapp/ ├── auto_debug_com.example.myapp.p12 # 密钥库 ├── auto_debug_com.example.myapp.csr # CSR ├── auto_debug_com.example.myapp.cer # 调试证书 @@ -147,6 +275,8 @@ signing_files/com.example.myapp/ └── entry-default-unsigned_signed.hap # 签名后的 hap ``` +如果输入 HAP 本身已经签名,程序会直接安装原文件,不会生成上述签名材料。 + ## 缓存策略 同一天内不会重复登录或重复申请签名文件: @@ -163,6 +293,8 @@ signing_files/com.example.myapp/ - 登录验证码 / 二次验证需要用户在浏览器中手动处理 - 拖拽安装脚本仅支持 Windows(`sign_install.bat`);macOS 请使用 `hapsign` 命令行 +- 当前提供并完整验证的是 Windows 便携版;macOS/Linux 已预留运行时路径, + 仍需分别增加锁定的公开工具包并在对应系统构建、实机验证 - 签名流程依赖华为云 API,需要有网络连接和华为开发者账号 ## 开发与贡献 @@ -183,10 +315,12 @@ python -m pytest --cov hapsign/ ├── cli.py # 命令行参数和入口 ├── config.py # 配置常量(域名、SDK 路径、API 端点、密钥参数) +├── gui.py # PySide6 桌面界面 ├── models.py # 数据模型 ├── pipeline.py # 全流程编排(缓存、登录、签名、安装) +├── runtime.py # 用户数据目录与跨平台工具链发现 ├── login/ -│ └── browser_login.py # Playwright 浏览器登录 +│ └── browser_login.py # 受控系统浏览器、内置 Chromium 与普通浏览器备用后端 ├── token/ │ └── token_exchange.py # tempToken → JWT → accessToken ├── api/ @@ -197,12 +331,17 @@ hapsign/ │ └── capability_api.py # 应用信息 API (app brief info) └── signing/ ├── keytool_util.py # keytool 生成 EC 密钥对 + CSR + ├── hap_inspect.py # 检测 HAP 是否已签名 ├── hap_signer.py # hap-sign-tool 签名 hap └── installer.py # hdc install / 获取 UDID ``` ## License -本项目使用 [MIT License](LICENSE)。参与项目需遵守 [Code of Conduct](CODE_OF_CONDUCT.md)。 +HapSign 自身源代码使用 [MIT License](LICENSE)。第三方依赖和便携包工具链保持各自 +许可,不因本项目采用 MIT 而改变,详见 +[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。隐私与本地数据说明见 +[PRIVACY.md](PRIVACY.md),参与项目需遵守 +[Code of Conduct](CODE_OF_CONDUCT.md)。 Powered by [BitFun](https://github.com/GCWing/BitFun) diff --git a/SECURITY.md b/SECURITY.md index c3ddb86..9bbf664 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,14 +3,22 @@ ## 报告安全问题 请不要在公开 Issue 中提交 token、证书、私钥、设备 UDID、登录回调内容或可复现的 -账号信息。请通过 GitHub 仓库维护者提供的私密联系方式报告漏洞;若暂无私密渠道, -先提交一个不包含敏感细节的 Issue,请求维护者建立私密沟通。 +账号信息。维护者应在仓库 Settings → Code security 中启用 GitHub Private +vulnerability reporting;启用后请使用仓库 Security 页的“Report a vulnerability” +私密提交。若该入口尚不可用,只提交一个不包含敏感细节的 Issue,请求维护者建立 +私密沟通,未经确认不要公开漏洞细节。 ## 本地敏感数据 -hapsign 会在 `signing_files/` 中保存当日 token 缓存、调试证书、Profile 和 `.p12` -密钥库,以避免重复登录和申请。这些文件已被 `.gitignore` 排除,但仍是本机明文敏感 -数据。请勿上传、分享或放入云同步目录;在共享电脑上使用后应删除该目录。 +hapsign 默认会在程序目录的 `signing_files/` 中保存当日 token 缓存、调试证书、 +Profile 和 `.p12` 密钥库,以避免重复登录和申请。这些文件已被 `.gitignore` +排除,但仍是本机敏感数据。Windows 上 token 缓存通过当前用户作用域的 DPAPI +(CryptProtectData)静态加密后落盘,其他平台退化为受限权限(仅当前用户可读) +的明文存储,并在首次保存时打印告警;请勿把缓存目录放入云同步目录,在共享电脑 +上使用后应删除该目录。移动或分享便携目录前应先移除 `signing_files/`;桌面设置 +可改为用户 AppData Local 或自定义目录;同样应按敏感数据目录保护。 +程序目录的 `signed_haps/` 可能包含用户应用代码,移动或分享便携目录前也应检查; +可在设置中关闭保留签名 HAP。 默认密钥库密码只用于本机调试材料,不应被视为安全密码。可以在运行前设置 `HAPSIGN_KEYSTORE_PASSWORD` 环境变量覆盖它: @@ -19,11 +27,15 @@ hapsign 会在 `signing_files/` 中保存当日 token 缓存、调试证书、Pr $env:HAPSIGN_KEYSTORE_PASSWORD = "使用你自己的强密码" ``` -开启详细日志前请确认使用的是最新版本。程序不会记录 token、回调请求体、CSRF code、 -用户 ID、设备 UDID 或完整登录 URL。若发现日志中出现凭据,请立即停止分享日志、清除 -token 缓存并重新登录。 +敏感诊断开关默认关闭,此时程序不会记录 token、完整回调请求、CSRF code 或完整 +登录 URL。只有用户主动开启该开关并选择 DEBUG 级别后,才会记录 token、用户标识及 +完整 API 请求/响应;密钥库密码无论如何都不会记录。分享 `logs/hapsign.log*` 前请 +确认开关状态并检查内容。若日志已经包含凭据,请停止分享、删除日志、清除 token +缓存并重新登录。 ## 支持范围 安全修复只保证在最新发布版本和当前主分支提供。项目依赖第三方在线接口,接口变化、 账号策略和服务条款不在项目的安全支持范围内。 + +程序访问的域名、发送的数据和本地保留策略见 [PRIVACY.md](PRIVACY.md)。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..83a58d1 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,78 @@ +# Third-party notices + +HapSign 自身的源代码使用 [MIT License](LICENSE)。本文件说明源码依赖及便携版 +可能包含的第三方组件;第三方组件不因被 HapSign 使用或打包而改用 MIT License。 + +## Python 与桌面运行时 + +| 组件 | 用途 | 上游许可 | +| --- | --- | --- | +| Python | 应用运行时 | Python Software Foundation License | +| PySide6 Essentials / Shiboken6 / Qt | 桌面界面 | LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only,或 Qt 商业许可 | +| Playwright for Python / Playwright driver | 控制登录浏览器 | Apache-2.0 | +| greenlet | Playwright 运行时传递依赖 | MIT | +| pyee | Playwright 运行时传递依赖 | MIT | +| Requests | HTTPS 客户端 | Apache-2.0 | +| urllib3 | HTTPS 连接 | MIT | +| certifi | CA 证书集合 | MPL-2.0 | +| charset-normalizer | 文本编码检测 | MIT | +| idna | 国际化域名处理 | BSD-3-Clause | +| PyInstaller bootloader | 生成可执行文件 | GPL-2.0-or-later,带允许分发生成程序的特殊例外 | + +Windows 便携版以独立 DLL 的方式携带未修改的 Qt/PySide6 运行库,选择 LGPL-3.0 +路径进行分发。接收者可以用 ABI 兼容的修改版动态库替换 +`_internal/PySide6/` 中相应文件;HapSign 不对调试这种修改施加额外限制。Qt 的 +版权、商标和许可仍归各权利人所有。 + +构建脚本会把当前构建环境中上述 Python distribution 随附的 LICENSE、COPYING +和 NOTICE 文件复制到便携包的 `licenses/python/`。Playwright 自带的 driver +许可及第三方声明也保留在 `_internal/playwright/driver/`。兼容包如果包含 +Chromium,还必须保留该目录内 Playwright/Chromium 的第三方声明。 + +## 便携工具链 + +Windows 正式便携版从 `toolchain.lock.json` 锁定的公开上游准备: + +| 组件 | 当前版本/来源 | 许可 | +| --- | --- | --- | +| Java、keytool | Eclipse Temurin 21,经 `jlink` 生成 | GPL-2.0-only WITH Classpath-exception-2.0;各模块可能另有随附许可 | +| HDC | OpenHarmony 公共 SDK 6.1.0.31 | 以 SDK NOTICE 和上游仓库为准,主体为 Apache-2.0 | +| hap-sign-tool | OpenHarmony 公共 SDK 6.1.0.31 | Apache-2.0 及 JAR 内随附第三方许可 | +| libusb_shared | OpenHarmony 公共 SDK 内的 libusb 1.0.28 | LGPL-2.1-or-later | + +构建脚本会保留: + +- `resources/toolchain//runtime/legal/`; +- OpenHarmony 公共 SDK 的完整 `NOTICE.txt`; +- 下载地址、版本及 SHA-256 的 `PROVENANCE.txt` 和 `toolchain.lock.json`; +- `licenses/libusb-source/` 中与 DLL 对应的 OpenHarmony libusb 完整源码快照、 + 补丁、构建配置和 LGPL 文本。 + +HapSign 的 MIT 许可只覆盖本项目自身代码,不覆盖这些独立组件。接收者可以按各组件 +许可证替换或重新构建它们。源码快照的固定来源和哈希见 +`third_party/libusb/README.md`。 + +`--allow-deveco-toolchain` 仍可为兼容排障从本机 DevEco 复制工具;此模式的 +`PROVENANCE.txt` 会标识本机来源,不属于上述可审计正式构建,不应直接上传公开 +Release。`--skip-toolchain` 则完全不包含这些工具。 + +详细发布门禁见 [docs/OPEN_SOURCE_RELEASE.md](docs/OPEN_SOURCE_RELEASE.md)。 + +## 参考实现 + +HAP 签名块格式、HDC 行为和 hap-sign-tool 调用方式参考或对齐了 OpenHarmony 的 +`developtools_hapsigner` 与 `developtools_hdc` 项目。上游项目以各自仓库中的 +LICENSE 为准,主要采用 Apache-2.0: + +- +- + +如果后续从这些或其他开源仓库复制、移植了具体代码,而不只是依据公开格式重新实现, +必须在提交时记录来源文件、commit、修改内容和许可证。不得把不兼容许可证覆盖的代码 +直接标成 HapSign 的 MIT 代码。 + +## 商标与非隶属关系 + +HarmonyOS、OpenHarmony、HUAWEI、DevEco Studio、Qt、Python、Chromium、 +Microsoft Edge 和 Google Chrome 是各自权利人的商标或名称。本项目对这些名称的 +使用仅用于描述兼容性和依赖关系,不表示隶属、授权、认可或背书。 diff --git a/bundle/hapsign.manifest b/bundle/hapsign.manifest new file mode 100644 index 0000000..2c54a80 --- /dev/null +++ b/bundle/hapsign.manifest @@ -0,0 +1,23 @@ + + + + + + + + + + + + + true/pm + + + PerMonitorV2,PerMonitor + + + true + + + + diff --git a/bundle/hapsign.spec b/bundle/hapsign.spec new file mode 100644 index 0000000..a736778 --- /dev/null +++ b/bundle/hapsign.spec @@ -0,0 +1,71 @@ +# -*- mode: python ; coding: utf-8 -*- + +from pathlib import Path +import os +import sys +from PyInstaller.utils.hooks import collect_all + +project_root = Path(SPECPATH).parent +app_manifest = ( + str(project_root / "bundle" / "hapsign.manifest") + if sys.platform == "win32" + else None +) +playwright_datas, playwright_binaries, playwright_hiddenimports = collect_all( + "playwright" +) +if os.environ.get("HAPSIGN_BUNDLE_CHROMIUM", "0") != "1": + playwright_datas = [ + item for item in playwright_datas if ".local-browsers" not in item[0] + ] + playwright_binaries = [ + item for item in playwright_binaries if ".local-browsers" not in item[0] + ] + +analysis = Analysis( + [str(project_root / "hapsign" / "gui.py")], + pathex=[str(project_root)], + binaries=playwright_binaries, + datas=playwright_datas, + hiddenimports=playwright_hiddenimports, + hookspath=[str(project_root / "bundle" / "hooks")], + hooksconfig={}, + runtime_hooks=[], + excludes=[ + "OpenSSL", + "cryptography", + "pytest", + "pytest_cov", + "ruff", + "setuptools", + "wheel", + ], + noarchive=False, + optimize=1, +) +pyz = PYZ(analysis.pure) + +exe = EXE( + pyz, + analysis.scripts, + [], + exclude_binaries=True, + name="HapSign", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + disable_windowed_traceback=False, + manifest=app_manifest, +) + +collect = COLLECT( + exe, + analysis.binaries, + analysis.datas, + strip=False, + upx=False, + upx_exclude=[], + name="HapSign", +) diff --git a/bundle/hooks/hook-playwright.async_api.py b/bundle/hooks/hook-playwright.async_api.py new file mode 100644 index 0000000..a085b1c --- /dev/null +++ b/bundle/hooks/hook-playwright.async_api.py @@ -0,0 +1,9 @@ +"""按便携构建模式收集 Playwright 数据。""" + +import os + +from PyInstaller.utils.hooks import collect_data_files + +datas = collect_data_files("playwright") +if os.environ.get("HAPSIGN_BUNDLE_CHROMIUM", "0") != "1": + datas = [item for item in datas if ".local-browsers" not in item[0]] diff --git a/bundle/hooks/hook-playwright.sync_api.py b/bundle/hooks/hook-playwright.sync_api.py new file mode 100644 index 0000000..a085b1c --- /dev/null +++ b/bundle/hooks/hook-playwright.sync_api.py @@ -0,0 +1,9 @@ +"""按便携构建模式收集 Playwright 数据。""" + +import os + +from PyInstaller.utils.hooks import collect_data_files + +datas = collect_data_files("playwright") +if os.environ.get("HAPSIGN_BUNDLE_CHROMIUM", "0") != "1": + datas = [item for item in datas if ".local-browsers" not in item[0]] diff --git a/docs/OPEN_SOURCE_RELEASE.md b/docs/OPEN_SOURCE_RELEASE.md new file mode 100644 index 0000000..3b4b854 --- /dev/null +++ b/docs/OPEN_SOURCE_RELEASE.md @@ -0,0 +1,57 @@ +# 开源发布门禁 + +本文区分“公开源代码”和“公开预编译便携包”。仓库采用 MIT License,并不自动赋予 +发布者重新分发第三方二进制的权利。 + +## 源码仓库 + +公开前应确认: + +- `LICENSE`、`README.md`、`THIRD_PARTY_NOTICES.md`、`PRIVACY.md`、 + `SECURITY.md`、`CONTRIBUTING.md` 和 `CODE_OF_CONDUCT.md` 存在; +- README 明确非官方性质、在线接口兼容性风险、数据流和第三方许可边界; +- 对所有“参考/移植自开源实现”的代码完成来源审计,记录上游仓库、commit 和许可; + 如果存在 GPL 或其他与整体 MIT 声明不兼容的复制代码,先解决许可和标注,不能仅在 + README 写一句致谢; +- Git 历史及当前工作区不含 HAP、token、UDID、证书、Profile、私钥、日志、 + 本机配置或反编译产物; +- CI 能在无账号、无设备、无 DevEco 环境下完成 lint、format 和单元测试; +- 发布版本有 changelog、版本号、tag 和可复现的构建命令; +- 仓库设置已启用 GitHub Private vulnerability reporting,或 SECURITY 文件提供 + 一个真实可用的私密安全联系方式。 + +## 预编译包 + +每个公开二进制包还必须满足: + +- 根目录包含 HapSign `LICENSE`、隐私说明、第三方声明和构建说明; +- `licenses/python/` 包含冻结依赖随附的许可和 NOTICE; +- Temurin runtime 的 `legal/` 未被裁掉,OpenHarmony SDK 的 `NOTICE.txt` 已保留; +- `licenses/libusb-source/` 包含与 `libusb_shared` 对应的完整源码快照; +- 记录 Python、Qt、Playwright、Temurin、HDC、hap-sign-tool 和可选 Chromium 的准确 + 版本、来源及 SHA-256; +- 工具链来自 `toolchain.lock.json` 锁定的公共上游,实际产物哈希与 + `PROVENANCE.txt` 一致; +- Qt 以可替换的共享库形式分发,并按选择的 LGPL-3.0 路径提供相应通知和许可文本; +- 兼容包保留 Chromium/Playwright 的第三方许可文件; +- 解压目录不含 `hapsign-config.json`、`logs/`、`signing_files/`、`signed_haps/` + 或任何构建者/测试者数据; +- 对最终 ZIP 运行恶意软件扫描、签名(若有代码签名证书)和 SHA-256 校验。 + +Windows 正式构建必须先运行 `python scripts/prepare_toolchain.py`,再运行 +`python scripts/build_portable.py`。准备脚本锁定并校验 OpenHarmony 公共 SDK 与 +Eclipse Temurin,只提取运行所需文件,并附带 libusb 对应源代码。 + +使用 `--allow-deveco-toolchain` 生成的是本机兼容/排障包,不通过公开发布门禁; +使用 `--skip-toolchain` 生成的是 GUI-only 包,也不能作为完整便携版发布。 + +## GitHub 发布建议 + +1. 整理并审查未提交改动。 +2. 执行 `python -m ruff format --check .`、`python -m ruff check .` 和 + `python -m pytest --cov`。 +3. 运行敏感文件扫描和 `git diff --check`。 +4. 更新 `hapsign.__version__` 与 `CHANGELOG.md`。 +5. 创建签名 tag。 +6. 只上传已通过本页许可门禁的产物,并在 Release notes 中列出 SHA-256、支持平台、 + 已知限制和第三方工具链来源。 diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md new file mode 100644 index 0000000..ae06a54 --- /dev/null +++ b/docs/PACKAGING.md @@ -0,0 +1,282 @@ +# HapSign 便携版打包指南 + +HapSign 使用 PyInstaller 的 `onedir` 模式生成桌面程序目录,再把当前平台的 +Java、keytool、`hap-sign-tool.jar` 和 HDC 一并复制后压缩成 ZIP。目标电脑解压 +即可运行,不要求预装 Python 或 DevEco Studio。 + +## 1. 构建模型 + +- Windows、macOS、Linux 产物必须分别在对应操作系统构建,不能交叉生成。 +- 当前经过完整构建和启动验证的是 64 位 Windows 版本。 +- 仓库不保存大型 SDK/JDK 二进制;`toolchain.lock.json` 固定公共上游地址、版本、 + 大小和 SHA-256,准备结果放在被 Git 忽略的 `build/`。 +- 默认由 Playwright 控制系统 Edge/Chrome,因此继续拥有本地网络权限和回调控制, + 但不携带 Chromium;可另外生成包含内置 Chromium 的兼容包。 +- PyInstaller 使用 `onedir` 而不是单文件模式,启动更快,也便于检查和替换 + Java/HDC 等资源。 + +## 2. 构建机要求 + +完整便携包需要: + +1. Python 3.11 或更高版本。 +2. Windows 正式构建需要网络下载锁定的 OpenHarmony 公共 SDK(约 2.5 GB)和 + Temurin JDK(约 205 MB),或由构建者提供已下载的相同文件。 +3. 首次准备建议至少 6 GB 可用磁盘空间;工具链生成后可删除研究/下载副本, + `build/toolchain-prepared/windows` 约 66 MiB。 + +DevEco Studio 不是正式便携构建的依赖。它仍可用于源码运行和兼容排障;只有显式 +传入 `--allow-deveco-toolchain` 时,构建脚本才会从本机发现并复制它的工具。 + +## 3. 安装构建依赖 + +建议使用独立虚拟环境: + +```powershell +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install --upgrade pip +python -m pip install -e ".[gui,bundle]" +``` + +开发检查还需要: + +```powershell +python -m pip install -e ".[dev]" +``` + +## 4. 准备公开工具链 + +在仓库根目录执行: + +```powershell +python scripts/prepare_toolchain.py +``` + +准备脚本会: + +1. 校验 `toolchain.lock.json`; +2. 下载并校验 OpenHarmony 6.1 公共 SDK 和 Eclipse Temurin 21; +3. 只提取 Windows HDC、配套 libusb、hap-sign-tool 及完整 NOTICE; +4. 用 `jlink` 生成只含签名器和 keytool 所需模块的 Java runtime; +5. 附带 libusb 1.0.28 对应的 OpenHarmony 源码快照; +6. 实际运行 Java、EC keytool、hap-sign-tool 和 HDC 版本自检。 + +已有官方归档时可以避免重复下载;文件仍会严格按锁文件验证: + +```powershell +python scripts/prepare_toolchain.py ` + --sdk-archive D:\cache\ohos-sdk-windows_linux-public.tar.gz ` + --jdk-archive D:\cache\OpenJDK21U-jdk_x64_windows_hotspot_21.0.12_8.zip +``` + +默认输出是 `build/toolchain-prepared/windows/`。要重建现有输出,显式添加 +`--force`。 + +## 5. 生成完整便携包 + +在仓库根目录执行: + +```powershell +python scripts/build_portable.py +``` + +脚本会依次: + +1. 检查构建机系统 Edge/Chrome 可由 Playwright 正常控制。 +2. 使用 `bundle/hapsign.spec` 构建 `dist/HapSign/`;默认不收集 Chromium。 +3. 复制已校验的公开工具链及其许可、来源和对应源码。 +4. 复制便携版使用说明和本打包指南。 +5. 运行冻结应用自检,并清除自检生成的日志、配置和运行数据。 +6. 生成 `dist/HapSign-portable-.zip`。 + +Windows 正式构建使用 Temurin `jlink` runtime 并复用系统 Edge/Chrome。每次构建 +都会实际启动冻结版受控系统浏览器,并运行 Java、hap-sign-tool 和 keytool 密钥 +生成自检;任一失败便终止构建。 + +仅在排查新版 DevEco 兼容性时可使用本机回退: + +```powershell +python scripts/build_portable.py --allow-deveco-toolchain +python scripts/build_portable.py --allow-deveco-toolchain --keep-full-jbr +``` + +这两种产物都不是锁定的公开构建,不得直接上传 Release。 + +生成包含内置 Chromium 的兼容包: + +```powershell +$env:PLAYWRIGHT_BROWSERS_PATH = "0" +python -m playwright install --no-shell chromium +python scripts/build_portable.py --keep-bundled-browser +``` + +Windows 的主要输出结构: + +```text +dist/ +├── HapSign/ +│ ├── HapSign.exe +│ ├── _internal/ # Python、Qt 和应用依赖 +│ │ └── playwright/ # 浏览器控制驱动 +│ ├── resources/toolchain/windows/ +│ │ ├── runtime/ # Temurin jlink runtime +│ │ ├── lib/hap-sign-tool.jar +│ │ ├── bin/hdc.exe +│ │ ├── NOTICE.txt +│ │ ├── PROVENANCE.txt +│ │ ├── toolchain.lock.json +│ │ └── licenses/libusb-source/ +│ ├── README.md +│ └── BUILDING.md +└── HapSign-portable-windows.zip +``` + +## 6. GUI-only 快速构建 + +仅验证界面和 PyInstaller 配置时可以跳过约数百 MiB 的工具链复制: + +```powershell +python scripts/build_portable.py --skip-toolchain +``` + +GUI-only 包仍能启动,但目标电脑若没有可发现的外部工具链,就不能检测设备、 +签名或安装。不要把它当作正式便携版发布。 + +## 7. 构建后验证 + +先运行自动检查: + +```powershell +python -m ruff check hapsign tests scripts +python -m pytest --cov +``` + +验证窗口程序能够启动并自动退出: + +```powershell +.\dist\HapSign\HapSign.exe --smoke-test +.\dist\HapSign\HapSign.exe --system-browser-smoke-test +``` + +兼容包还应执行 `HapSign.exe --browser-smoke-test`。 + +验证内置工具: + +```powershell +.\dist\HapSign\resources\toolchain\windows\runtime\bin\java.exe -version +.\dist\HapSign\resources\toolchain\windows\runtime\bin\keytool.exe -help +.\dist\HapSign\resources\toolchain\windows\bin\hdc.exe -v +``` + +构建脚本会在 ZIP 旁自动生成标准 SHA-256 sidecar,例如 +`HapSign-portable-windows.zip.sha256`。仍可手动复核: + +```powershell +Get-FileHash -Algorithm SHA256 .\dist\HapSign-portable-windows.zip +``` + +GUI 改动还应至少人工检查一次: + +- 100%、150%、200% 缩放下文字和控件没有位图模糊。 +- 拖入、重新选择和移除 HAP 正常。 +- 选择 HAP 后文件卡片始终位于拖放区右侧,不会覆盖拖放区。 +- 受控系统 Edge/Chrome 能打开登录页,授权回调后程序继续并跳转成功页。 +- 进度条按流程阶段递增,不显示无限循环动画。 +- 登录等待、签名或安装过程中点击“取消”能回到可重试状态。 +- 任务运行时关闭窗口会询问是否中断;确认后清理并正常退出。 +- 日志滚动条、长文件名和错误提示显示正常。 +- “检测设备”及安装前自动检查都能识别未授权、无设备和正常设备。 +- Java、keytool、HDC 执行期间没有命令行窗口闪现。 +- 无预先运行的 HDC server 时,任务结束后不残留 `hdc` 进程。 +- DevEco 已经运行 HDC server 时,任务结束后该既有进程仍然保留。 + +## 8. 签名文件位置 + +桌面/便携版默认把运行数据放在可执行文件旁: + +```text +HapSign/ +├── HapSign.exe +├── signing_files/ + ├── .token_cache.json + └── / +└── signed_haps/ # 最新一个签名 HAP,与材料目录设置无关 +``` + +因此便携目录必须可写。源码 GUI 默认使用项目根目录,源码 CLI 默认使用当前工作 +目录下的 `signing_files//`。可以用 `HAPSIGN_DATA_DIR` 覆盖桌面版 +数据根目录,CLI 则可使用 `--work-dir`。已签名 HAP 会跳过签名流程,因此不会 +产生新的 `.p12`、`.cer`、`.p7b` 或签名后 HAP。 + +GUI 设置也可选择用户 `AppData Local` 或自定义签名目录。程序目录下的 +`hapsign-config.json` 保存这些设置,`logs/hapsign.log` 保存滚动诊断日志; +程序目录不可写时日志回退到用户本地数据目录。敏感诊断默认关闭,开启后配合 +DEBUG 级别可记录 token、用户标识和完整 API 请求/响应,但密钥库密码永不记录。 +最终签名 HAP 固定使用程序目录的 `signed_haps/`,默认只保留最新一个 HapSign +清单记录的产物,不会清理未记录的用户 HAP,当前输入文件也会受到保护;设置关闭 +保留后,任务使用系统临时目录并在结束时清理。 + +## 9. 跨平台注意事项 + +- `runtime.py` 已集中处理平台目录和可执行文件名;`toolchain.lock.json` 当前只 + 锁定并实测 Windows x64。 +- 新平台应增加独立锁定项、对应平台 Temurin 归档、OpenHarmony 工具包与真实设备 + 测试,不能复用 Windows 二进制。 +- macOS 发布通常还需要应用包、代码签名和 notarization;当前脚本只生成目录 + 和 ZIP。 +- Linux 需要在目标发行版或兼容的较旧发行版构建,并验证 Qt、USB 权限和 HDC。 +- 不要把一个平台的 Java runtime 或 HDC 复制进另一个平台的产物。 + +## 10. 发布前清单 + +- 工作区不包含 token、`.p12`、`.cer`、`.p7b`、UDID 或用户 HAP。 +- 发布 ZIP 不包含构建自检生成的 `logs/`、配置或运行数据目录。 +- 包根目录包含 `LICENSE`、`PRIVACY.md`、`THIRD_PARTY_NOTICES.md` 和 + `BUILDING.md`,`licenses/python/` 中存在冻结依赖的随附许可。 +- 工具链目录包含 `PROVENANCE.txt`、锁文件、OpenHarmony NOTICE、Temurin legal + 和 `licenses/libusb-source/`。 +- 全部测试和 Ruff 检查通过。 +- 正式包不是 `--skip-toolchain` 产物。 +- EXE 冒烟、Java、keytool 和 HDC 版本命令通过。 +- 至少用一台真实设备验证检测、签名和安装。 +- 确认 Temurin、HDC、签名工具和 libusb 的再分发条件。 +- 按 `docs/OPEN_SOURCE_RELEASE.md` 记录第三方组件的准确版本、来源和 SHA-256; +- 正式包不是 `--allow-deveco-toolchain` 回退产物。 +- 记录 ZIP 大小和 SHA-256。 + +## 11. 常见问题 + +### 提示工具链缺失 + +先运行 `python scripts/prepare_toolchain.py`。若已有下载,使用 +`--sdk-archive`/`--jdk-archive` 指向文件;校验失败时不要绕过锁文件。 + +### 包很大 + +主要空间来自 Playwright 浏览器控制驱动、Qt 和 Python 冻结运行时。不要直接删除 +DLL;应使用 +经过验证的精简方案,并重新执行完整登录、签名和安装测试。 + +替换前的 Windows 精简构建未压缩目录约 390 MiB、ZIP 约 187 MiB。公开工具链 +将 Java 从约 170 MiB JBR 换成约 48 MiB Temurin runtime,完整工具链约 66 MiB; +最终 ZIP 大小以本次构建结果为准。替换后的主要组成大致为: + +- Playwright 浏览器控制驱动:约 103 MiB +- PySide6/Qt:约 73 MiB +- Temurin `jlink` runtime:约 48 MiB +- hap-sign-tool:约 12 MiB +- HDC 及配套文件:约 5.5 MiB +- OpenHarmony NOTICE、libusb 对应源码及来源记录:约 1 MiB +- Python 运行时、requests 和应用代码等其余部分:约 28 MiB + +这些数字会随 Playwright、Qt、Temurin 和 OpenHarmony 版本变化。精简包通过复用 +系统 Edge/Chrome,兼容包则额外携带 Chromium。两种模式都由 +Playwright 控制浏览器并授予本地回调权限,非受控系统默认浏览器仅作为备用。 + +### 4K 屏幕字体模糊 + +Windows 包包含 Per-Monitor V2 manifest,GUI 也在创建 `QApplication` 前启用 +Qt 高 DPI 小数缩放。界面字体使用整数逻辑像素、Microsoft YaHei UI 和完整 +hinting,避免 200% 缩放下出现分数物理像素。若仍模糊,检查 `HapSign.exe` +兼容性设置中是否被手动启用了“替代高 DPI 缩放行为”,并关闭该覆盖。 diff --git a/hapsign/api/cert_api.py b/hapsign/api/cert_api.py index e402977..ba70019 100644 --- a/hapsign/api/cert_api.py +++ b/hapsign/api/cert_api.py @@ -21,7 +21,6 @@ API_CERT_LIST, API_REAPPLY_URL, API_USER_TEAM, - BASE_URL, HEADER_ACCESS_TOKEN, ) @@ -60,16 +59,17 @@ def get_team_id(self) -> str: def sign_agreement(self) -> bool: """签署未实名用户的开发者协议。 - POST {API_AGREEMENT},header 中携带 accessToken。 - 使用登录域名(BASE_URL),不是签名 API 域名。 + POST {API_AGREEMENT},header 中携带 accessToken(只发这一个认证头)。 + 走统一客户端封装(_build_url 的 /authrouter/ 路由 → 登录域名 BASE_URL, + 非签名 API 域名),从而获得取消检查、超时、脱敏诊断、HTTP 错误和 + token 失效(HTTP 401 / code=4000 → TokenExpiredError)处理。 Returns: 签署成功返回 True。 """ - url = f"{BASE_URL}{API_AGREEMENT}" headers = {HEADER_ACCESS_TOKEN: self._client.access_token} - resp = requests.post(url, headers=headers, timeout=30) - resp.raise_for_status() + text = self._client._do_post_form_text(API_AGREEMENT, headers) + logger.debug("agreement response: %s", text[:200]) return True def add_certificate( @@ -219,7 +219,9 @@ def download_file( raise ValueError(f"reapply URL 中未找到 newUrl: {urls_info}") # 第二步:直接下载(不需要认证 header) - resp = requests.get(new_url, timeout=60) + self._client._check_cancelled() + resp = requests.get(new_url, timeout=(5, 20)) + self._client._check_cancelled() resp.raise_for_status() with open(save_path, "wb") as f: diff --git a/hapsign/api/client.py b/hapsign/api/client.py index fae70ba..ccac48e 100644 --- a/hapsign/api/client.py +++ b/hapsign/api/client.py @@ -7,20 +7,29 @@ - code=4000 或 HTTP 401 表示 token 失效 """ +import logging +import threading from typing import Any +from urllib.parse import urlparse import requests +from hapsign.cancellation import raise_if_cancelled from hapsign.config import ( + ACCEPT_LANGUAGE, BASE_URL, CLOUD_BASE_URL, ERR_TOKEN_INVALID_CODE, - HEADER_ACCEPT_LANG, + HEADER_ACCEPT_LANGUAGE, HEADER_OAUTH2_TOKEN, HEADER_TEAM_ID, HEADER_UID, HEADER_USER_AGENT, + USER_AGENT, ) +from hapsign.diagnostics import sensitive_logging_enabled + +logger = logging.getLogger(__name__) class TokenExpiredError(Exception): @@ -38,6 +47,7 @@ def __init__( access_token: str, uid: str, base_url: str = CLOUD_BASE_URL, + cancel_event: threading.Event | None = None, ): """初始化客户端。 @@ -49,6 +59,10 @@ def __init__( self.access_token = access_token self.uid = uid self.base_url = base_url.rstrip("/") + self.cancel_event = cancel_event + + def _check_cancelled(self) -> None: + raise_if_cancelled(self.cancel_event) def _get_headers(self, team_id: str | None = None) -> dict[str, str]: """构造请求头。 @@ -62,8 +76,8 @@ def _get_headers(self, team_id: str | None = None) -> dict[str, str]: headers = { HEADER_OAUTH2_TOKEN: self.access_token, HEADER_UID: self.uid, - HEADER_USER_AGENT: "Chrome/49.0.2623.75", - HEADER_ACCEPT_LANG: "zh-CN", + HEADER_USER_AGENT: USER_AGENT, + HEADER_ACCEPT_LANGUAGE: ACCEPT_LANGUAGE, } if team_id is not None: headers[HEADER_TEAM_ID] = team_id @@ -110,6 +124,52 @@ def _check_response(self, resp: requests.Response) -> None: except (ValueError, AttributeError): pass + def _log_http( + self, + method: str, + url: str, + response: requests.Response, + *, + headers: dict[str, str], + payload: object = None, + ) -> None: + """记录足以定位接口变更的元数据;完整载荷由敏感开关控制。""" + parsed = urlparse(url) + response_keys: list[str] = [] + business_code: object = None + try: + decoded = response.json() + if isinstance(decoded, dict): + response_keys = sorted(str(key) for key in decoded) + ret = decoded.get("ret") + business_code = ( + ret.get("code") if isinstance(ret, dict) else decoded.get("code") + ) + except (ValueError, AttributeError): + pass + content = getattr(response, "content", b"") + response_size = len(content) if isinstance(content, (bytes, str)) else -1 + logger.debug( + "[api] %s %s://%s%s status=%d bytes=%d keys=%s code=%r", + method, + parsed.scheme, + parsed.netloc, + parsed.path, + response.status_code, + response_size, + response_keys, + business_code, + ) + if sensitive_logging_enabled(): + logger.debug( + "[api] sensitive %s %s headers=%r payload=%r response=%r", + method, + url, + headers, + payload, + response.text, + ) + def _do_get( self, url: str, @@ -118,7 +178,10 @@ def _do_get( ) -> dict[str, Any]: """执行 GET 请求并返回解析后的 JSON。""" full_url = self._build_url(url) - resp = requests.get(full_url, headers=headers, params=params, timeout=30) + self._check_cancelled() + resp = requests.get(full_url, headers=headers, params=params, timeout=(5, 15)) + self._check_cancelled() + self._log_http("GET", full_url, resp, headers=headers, payload=params) self._check_response(resp) resp.raise_for_status() return resp.json() @@ -131,7 +194,10 @@ def _do_post_form( ) -> dict[str, Any]: """执行 POST form-encoded 请求并返回解析后的 JSON。""" full_url = self._build_url(url) - resp = requests.post(full_url, headers=headers, data=params, timeout=30) + self._check_cancelled() + resp = requests.post(full_url, headers=headers, data=params, timeout=(5, 15)) + self._check_cancelled() + self._log_http("POST", full_url, resp, headers=headers, payload=params) self._check_response(resp) resp.raise_for_status() return resp.json() @@ -144,7 +210,10 @@ def _do_post_json( ) -> dict[str, Any]: """执行 POST JSON 请求并返回解析后的 JSON。""" full_url = self._build_url(url) - resp = requests.post(full_url, headers=headers, json=data, timeout=30) + self._check_cancelled() + resp = requests.post(full_url, headers=headers, json=data, timeout=(5, 15)) + self._check_cancelled() + self._log_http("POST", full_url, resp, headers=headers, payload=data) self._check_response(resp) resp.raise_for_status() return resp.json() @@ -157,7 +226,10 @@ def _do_delete( ) -> dict[str, Any]: """执行 DELETE 请求(带 JSON body)并返回解析后的 JSON。""" full_url = self._build_url(url) - resp = requests.delete(full_url, headers=headers, json=data, timeout=30) + self._check_cancelled() + resp = requests.delete(full_url, headers=headers, json=data, timeout=(5, 15)) + self._check_cancelled() + self._log_http("DELETE", full_url, resp, headers=headers, payload=data) self._check_response(resp) resp.raise_for_status() return resp.json() @@ -174,7 +246,10 @@ def _do_post_form_text( Java 通过 responseContent.contains('"code":0') 检查成功。 """ full_url = self._build_url(url) - resp = requests.post(full_url, headers=headers, data=params, timeout=30) + self._check_cancelled() + resp = requests.post(full_url, headers=headers, data=params, timeout=(5, 15)) + self._check_cancelled() + self._log_http("POST", full_url, resp, headers=headers, payload=params) self._check_response(resp) resp.raise_for_status() return resp.text @@ -187,7 +262,10 @@ def _do_delete_text( ) -> str: """执行 DELETE 请求(带 JSON body)并返回原始响应文本。""" full_url = self._build_url(url) - resp = requests.delete(full_url, headers=headers, json=data, timeout=30) + self._check_cancelled() + resp = requests.delete(full_url, headers=headers, json=data, timeout=(5, 15)) + self._check_cancelled() + self._log_http("DELETE", full_url, resp, headers=headers, payload=data) self._check_response(resp) resp.raise_for_status() return resp.text @@ -204,7 +282,10 @@ def _do_post_json_text( Java 通过 responseContent.contains('"code":0') 检查成功。 """ full_url = self._build_url(url) - resp = requests.post(full_url, headers=headers, json=data, timeout=30) + self._check_cancelled() + resp = requests.post(full_url, headers=headers, json=data, timeout=(5, 15)) + self._check_cancelled() + self._log_http("POST", full_url, resp, headers=headers, payload=data) self._check_response(resp) resp.raise_for_status() return resp.text diff --git a/hapsign/api/device_api.py b/hapsign/api/device_api.py index 7c0ec18..aa12a93 100644 --- a/hapsign/api/device_api.py +++ b/hapsign/api/device_api.py @@ -8,7 +8,32 @@ from typing import Any from hapsign.api.client import HuaweiSignClient -from hapsign.config import API_DEVICE_ADD, API_DEVICE_LIST, ERR_DEVICE_DUPLICATE +from hapsign.config import ( + API_DEVICE_ADD, + API_DEVICE_LIST, + ERR_DEVICE_DUPLICATE, + ERR_DEVICE_UDID_DUPLICATE, +) + +_DEVICE_ALREADY_EXISTS_CODES = { + str(ERR_DEVICE_DUPLICATE), + str(ERR_DEVICE_UDID_DUPLICATE), +} + + +def _is_device_already_exists(value: Any) -> bool: + text = str(value) + return any(code in text for code in _DEVICE_ALREADY_EXISTS_CODES) + + +def _extract_response_code(data: Any) -> Any: + """从签名 API 响应提取业务 code(顶层或 ret 嵌套)。""" + if not isinstance(data, dict): + return None + ret = data.get("ret") + if isinstance(ret, dict) and "code" in ret: + return ret.get("code") + return data.get("code") class DeviceAPI: @@ -34,7 +59,8 @@ def add_device( POST form {API_DEVICE_ADD} 参数:deviceName(自动生成)、udid(设备唯一标识)、deviceType(设备类型码)。 - 重复设备(错误码 205389857)视为成功。 + 设备名称重复(205389857)或 UDID 已注册(205389858)视为成功, + 后续从设备列表复用已有记录。 Args: udid: 设备 UDID(64 位十六进制字符串)。 @@ -61,14 +87,20 @@ def add_device( "deviceType": device_type, } try: - self._client._do_post_form(API_DEVICE_ADD, headers, params) + data = self._client._do_post_form(API_DEVICE_ADD, headers, params) except Exception as exc: - err_msg = str(exc) - if str(ERR_DEVICE_DUPLICATE) in err_msg: + # HTTP 层失败时仍兼容逆向场景:错误信息里可能直接带业务码 + if _is_device_already_exists(exc): return True raise - return True + # 200 响应也可能返回业务错误码(与 DevEco 行为一致) + code = _extract_response_code(data) + if code in (None, 0, "0"): + return True + if _is_device_already_exists(code): + return True + raise RuntimeError(f"add_device failed: {data}") def get_device_list(self, team_id: str) -> list[dict[str, Any]]: """查询已注册的设备列表。 diff --git a/hapsign/cancellation.py b/hapsign/cancellation.py new file mode 100644 index 0000000..671da78 --- /dev/null +++ b/hapsign/cancellation.py @@ -0,0 +1,15 @@ +"""跨线程任务取消原语。""" + +from __future__ import annotations + +import threading + + +class OperationCancelled(Exception): + """用户主动取消当前操作。""" + + +def raise_if_cancelled(cancel_event: threading.Event | None) -> None: + """取消信号已设置时立即中断当前流程。""" + if cancel_event is not None and cancel_event.is_set(): + raise OperationCancelled("操作已取消") diff --git a/hapsign/cli.py b/hapsign/cli.py index 482e75c..e97f794 100644 --- a/hapsign/cli.py +++ b/hapsign/cli.py @@ -42,7 +42,11 @@ def build_parser() -> argparse.ArgumentParser: hapsign --hap app.hap --bundle-name com.example.myapp """, ) - parser.add_argument("--hap", required=True, help="未签名的 HAP 文件路径") + parser.add_argument( + "--hap", + required=True, + help="HAP 文件路径(未签名则自动签名;已签名则直接安装)", + ) parser.add_argument( "--bundle-name", default=None, diff --git a/hapsign/config.py b/hapsign/config.py index 6fa5ace..8081068 100644 --- a/hapsign/config.py +++ b/hapsign/config.py @@ -4,6 +4,8 @@ import os import sys +from hapsign.runtime import discover_toolchain + # ── 域名 ────────────────────────────────────────────────────── # 登录/认证域名(从 IdeSystem.properties 逆向获得) BASE_URL = "https://devecostudio.huawei.com" @@ -39,6 +41,11 @@ # ── 登录参数 ────────────────────────────────────────────────── APP_ID = "1007" +# 登录协议兼容版本:temptoken/check 请求里的 version 参数。 +# 逆向自 DevEco Studio 5.0.5 的请求,服务端可能校验该字段;在没有真实接口 +# 回归证据前不要直接改成 hapsign.__version__(升级策略见 todo P1-9)。 +LOGIN_PROTOCOL_VERSION = "5.0.5" + # 国家码映射(从 HiAiLoginService 逆向获得) # siteId → countryCode: 1→CN, 5→SG, 7→DE, 8→RU SITE_ID_MAP = {"CN": "1", "SG": "5", "DE": "7", "RU": "8"} @@ -69,13 +76,14 @@ ERR_CERT_EXCEED_LIMIT = 205389872 ERR_DEVICE_EXCEED_LIMIT = 205389859 ERR_DEVICE_DUPLICATE = 205389857 +ERR_DEVICE_UDID_DUPLICATE = 205389858 ERR_PROVISION_EXCEED_LIMIT = 205389938 ERR_PROVISION_EXCEED_LIMIT_2 = 205389845 ERR_APP_ID_INVALID = 205389959 ERR_TOKEN_INVALID_CODE = 4000 # 响应 code=4000 表示 token 失效 -# ── 本机 SDK 路径(可通过环境变量覆盖)────────────────────── +# ── 本机 SDK 路径兼容解析(可通过环境变量覆盖)────────────── def default_deveco_home(platform: str | None = None) -> str: """返回当前平台的 DevEco Studio 默认安装根目录。""" plat = sys.platform if platform is None else platform @@ -120,7 +128,12 @@ def resolve_sdk_paths( _DEVECO_HOME = os.environ.get("DEVECO_HOME") or default_deveco_home() -DEVECO_JBR, HAP_SIGN_TOOL, HDC_PATH, KEYTOOL_PATH = resolve_sdk_paths(_DEVECO_HOME) +# ── 本机 / 便携版工具链路径(可通过环境变量覆盖)──────────── +_TOOLCHAIN = discover_toolchain() +DEVECO_JBR = str(_TOOLCHAIN.java) +KEYTOOL_PATH = str(_TOOLCHAIN.keytool) +HAP_SIGN_TOOL = str(_TOOLCHAIN.hap_sign_tool) +HDC_PATH = str(_TOOLCHAIN.hdc) # ── HTTP header 常量 ────────────────────────────────────────── HEADER_OAUTH2_TOKEN = "oauth2Token" @@ -129,8 +142,11 @@ def resolve_sdk_paths( HEADER_ACCESS_TOKEN = "accessToken" HEADER_JWT_TOKEN = "jwtToken" HEADER_REFRESH = "refresh" -HEADER_USER_AGENT = "Chrome/49.0.2623.75" -HEADER_ACCEPT_LANG = "zh-CN" +HEADER_USER_AGENT = "User-Agent" +HEADER_ACCEPT_LANGUAGE = "Accept-Language" +# DevEco 逆向请求里使用的 UA / Accept-Language 值 +USER_AGENT = "Chrome/49.0.2623.75" +ACCEPT_LANGUAGE = "zh-CN" # ── ACL 权限白名单(从 DevEco 6.1 生成的 p7b allowed-acls 提取)──── # 仅这些权限可通过 aclPermissionList 预授权,其余权限会被服务端拒绝 diff --git a/hapsign/diagnostics.py b/hapsign/diagnostics.py new file mode 100644 index 0000000..58cdfb2 --- /dev/null +++ b/hapsign/diagnostics.py @@ -0,0 +1,125 @@ +"""持久化诊断日志。""" + +from __future__ import annotations + +import logging +import re +from logging.handlers import RotatingFileHandler +from pathlib import Path + +from hapsign import __version__ +from hapsign.runtime import platform_tag +from hapsign.settings import AppSettings, log_directory, user_local_data_dir + +_HANDLER_MARKER = "_hapsign_file_handler" +_sensitive_logging_enabled = False + +_SENSITIVE_ASSIGNMENT_RE = re.compile( + r"(?i)(?P\b(?:temp[_-]?token|access[_-]?token|refresh[_-]?token|" + r"jwt[_-]?token|oauth2[_-]?token|keystore[_-]?password|password|passwd|" + r"secret|token|code)\b)" + r"(?P\s*['\"]?\s*[:=]\s*['\"]?)" + r"(?P[^&,\s}'\"\]\)]+)" +) +_AUTHORIZATION_RE = re.compile( + r"(?i)(?P\bauthorization\b)" + r"(?P\s*['\"]?\s*[:=]\s*['\"]?)" + r"(?PBearer\s+)?" + r"(?P[^&,\s}'\"\]\)]+)" +) +_BEARER_TOKEN_RE = re.compile(r"(?i)(?P\bBearer\s+)(?P[^\s,}'\"\]\)]+)") + + +def set_sensitive_logging(enabled: bool) -> None: + """控制是否允许诊断日志包含 token 及完整网络载荷。""" + global _sensitive_logging_enabled + _sensitive_logging_enabled = bool(enabled) + + +def sensitive_logging_enabled() -> bool: + """返回敏感诊断开关状态。""" + return _sensitive_logging_enabled + + +def redact_sensitive_text(value: object) -> str: + """脱敏异常文本中的令牌、密码和授权参数。 + + 敏感诊断开关是显式选择,开启后保留原始文本以便定位协议问题;默认日志 + 必须避免把认证信息写入控制台、文件或 GUI 日志窗口。 + """ + text = str(value) + if sensitive_logging_enabled(): + return text + + text = _AUTHORIZATION_RE.sub( + lambda match: ( + f"{match.group('key')}{match.group('separator')}" + f"{match.group('scheme') or ''}" + ), + text, + ) + text = _SENSITIVE_ASSIGNMENT_RE.sub( + lambda match: f"{match.group('key')}{match.group('separator')}", + text, + ) + return _BEARER_TOKEN_RE.sub( + lambda match: f"{match.group('prefix')}", + text, + ) + + +def configure_file_logging(settings: AppSettings) -> Path: + """配置滚动日志;程序目录不可写时回退到用户本地目录。""" + set_sensitive_logging(settings.log_sensitive_data) + root_logger = logging.getLogger() + for handler in list(root_logger.handlers): + if getattr(handler, _HANDLER_MARKER, False): + root_logger.removeHandler(handler) + handler.close() + + preferred = log_directory() + try: + preferred.mkdir(parents=True, exist_ok=True) + log_path = preferred / "hapsign.log" + handler = RotatingFileHandler( + log_path, + maxBytes=4 * 1024 * 1024, + backupCount=3, + encoding="utf-8", + ) + except OSError: + fallback = user_local_data_dir() / "logs" + fallback.mkdir(parents=True, exist_ok=True) + log_path = fallback / "hapsign.log" + handler = RotatingFileHandler( + log_path, + maxBytes=4 * 1024 * 1024, + backupCount=3, + encoding="utf-8", + ) + + setattr(handler, _HANDLER_MARKER, True) + handler.setLevel(getattr(logging, settings.log_level)) + handler.setFormatter( + logging.Formatter( + "%(asctime)s.%(msecs)03d %(levelname)-7s " + "[%(threadName)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + root_logger.addHandler(handler) + # 由各 handler 决定输出级别;根 logger 保留 DEBUG 才能让文件级别即时生效。 + root_logger.setLevel(logging.DEBUG) + logging.captureWarnings(True) + logging.getLogger(__name__).info( + "HapSign 启动:version=%s platform=%s log_level=%s sensitive=%s", + __version__, + platform_tag(), + settings.log_level, + settings.log_sensitive_data, + ) + if settings.log_sensitive_data: + logging.getLogger(__name__).warning( + "敏感诊断已开启:日志可能包含 token、用户标识及完整 API 请求/响应" + ) + return log_path diff --git a/hapsign/gui.py b/hapsign/gui.py new file mode 100644 index 0000000..ef4bcfe --- /dev/null +++ b/hapsign/gui.py @@ -0,0 +1,1474 @@ +"""HapSign 桌面应用。 + +桌面层只负责交互与后台调度,签名逻辑继续复用 :class:`SignPipeline`。 +""" + +from __future__ import annotations + +import logging +import os +import sys +import threading +from pathlib import Path + +# Qt 6 默认支持高 DPI;在导入 Qt 前显式启用并保留小数缩放比例, +# 避免 Windows 在 125%/150%/4K 屏幕上退回位图拉伸。 +os.environ.setdefault("QT_ENABLE_HIGHDPI_SCALING", "1") +os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") + +from PySide6.QtCore import ( + QObject, + QPointF, + QRectF, + QSize, + Qt, + QThread, + QTimer, + QUrl, + Signal, + Slot, +) +from PySide6.QtGui import ( + QColor, + QDesktopServices, + QDragEnterEvent, + QDropEvent, + QFont, + QFontDatabase, + QIcon, + QLinearGradient, + QMouseEvent, + QPainter, + QPainterPath, + QPen, + QPixmap, +) +from PySide6.QtWidgets import ( + QApplication, + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QFileDialog, + QFormLayout, + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QMainWindow, + QMessageBox, + QPlainTextEdit, + QProgressBar, + QProxyStyle, + QPushButton, + QSizePolicy, + QSpacerItem, + QStyle, + QStyledItemDelegate, + QStyleOptionViewItem, + QVBoxLayout, + QWidget, +) + +from hapsign import __version__ +from hapsign.cancellation import OperationCancelled +from hapsign.cli import detect_bundle_name +from hapsign.diagnostics import configure_file_logging +from hapsign.login.browser_login import playwright_browser_smoke_test +from hapsign.pipeline import SignPipeline +from hapsign.runtime import ( + discover_toolchain, + platform_tag, +) +from hapsign.settings import ( + AppSettings, + config_file_path, + load_settings, + save_settings, + signed_haps_dir, + signing_files_dir, +) +from hapsign.signing.hap_inspect import is_hap_signed +from hapsign.signing.installer import Installer + +LOGGER = logging.getLogger(__name__) + +WINDOW_STYLE = """ +QWidget { + color: #18212f; + font-size: 14px; +} +QWidget#root { + background: #f4f6fa; +} +QFrame#card { + background: #ffffff; + border: 1px solid #e6eaf0; + border-radius: 18px; +} +QFrame#dropZone { + background: #fafbfe; + border: 2px dashed #cbd3df; + border-radius: 16px; +} +QFrame#dropZone:hover, QFrame#dropZone[active="true"] { + background: #f3f6ff; + border-color: #5a6ff0; +} +QLabel#title { + color: #111827; + font-size: 28px; + font-weight: 700; +} +QLabel#subtitle { + color: #657084; + font-size: 14px; +} +QLabel#dropTitle { + color: #18212f; + font-size: 18px; + font-weight: 650; +} +QLabel#muted, QLabel#fileMeta, QLabel#logTitle { + color: #778195; + font-size: 13px; +} +QLabel#fileName { + color: #172033; + font-size: 16px; + font-weight: 650; +} +QLabel#status { + color: #536078; + font-size: 14px; +} +QLabel#chip { + color: #526075; + background: #e9edf5; + border-radius: 11px; + padding: 3px 9px; + font-size: 12px; + font-weight: 600; +} +QLabel#signedChip { + color: #237353; + background: #e6f6ee; + border-radius: 11px; + padding: 3px 9px; + font-size: 12px; + font-weight: 650; +} +QLabel#unsignedChip { + color: #8a5b14; + background: #fff2d9; + border-radius: 11px; + padding: 3px 9px; + font-size: 12px; + font-weight: 650; +} +QPushButton { + min-height: 42px; + border-radius: 11px; + padding: 0 18px; + font-weight: 650; +} +QPushButton#primaryButton { + color: #ffffff; + background: #4459dc; + border: 1px solid #4459dc; +} +QPushButton#primaryButton:hover { + background: #384cc9; + border-color: #384cc9; +} +QPushButton#primaryButton:pressed { + background: #3043b7; +} +QPushButton#primaryButton:disabled { + color: #a8afbc; + background: #e6e9ef; + border-color: #e6e9ef; +} +QPushButton#secondaryButton { + color: #334155; + background: #ffffff; + border: 1px solid #d9dee8; +} +QPushButton#secondaryButton:hover { + background: #f7f8fb; + border-color: #c8cfdb; +} +QPushButton#cancelButton { + color: #a33c48; + background: #fff7f8; + border: 1px solid #efcbd0; +} +QPushButton#cancelButton:hover { + color: #8f2f3b; + background: #fdecef; + border-color: #e9aeb7; +} +QPushButton#removeButton { + color: #7b8495; + background: transparent; + border: none; + border-radius: 16px; + min-width: 32px; + max-width: 32px; + min-height: 32px; + max-height: 32px; + padding: 0; + font-size: 24px; + font-weight: 400; +} +QPushButton#removeButton:hover { + color: #b33f4a; + background: #fdecef; +} +QPushButton#removeButton:pressed { + background: #f9dce1; +} +QProgressBar { + min-height: 18px; + max-height: 18px; + border: none; + border-radius: 9px; + background: #e8ebf2; + color: #ffffff; + font-size: 12px; + font-weight: 650; + text-align: center; +} +QProgressBar::chunk { + border-radius: 9px; + background: #5367e8; +} +QPlainTextEdit { + color: #445066; + background: #f7f8fb; + border: 1px solid #e7eaf0; + border-radius: 11px; + padding: 10px; + font-size: 13px; + selection-background-color: #cdd5ff; +} +QDialog { + background: #f7f8fb; +} +QDialog QLabel#settingsTitle { + color: #111827; + font-size: 22px; + font-weight: 700; +} +QDialog QLabel#settingsSubtitle { + color: #778195; + font-size: 13px; +} +QFrame#settingsSection { + background: #ffffff; + border: 1px solid #e5e9f0; + border-radius: 14px; +} +QLabel#sectionTitle { + color: #263247; + font-size: 14px; + font-weight: 700; +} +QLabel#warning { + color: #966114; + background: #fff8e8; + border: 1px solid #f1dfb4; + border-radius: 9px; + padding: 7px 10px; + font-size: 13px; +} +QComboBox, QLineEdit { + min-height: 40px; + color: #243047; + background: #ffffff; + border: 1px solid #d9dee8; + border-radius: 10px; + padding: 0 13px; + selection-background-color: #dfe4ff; +} +QComboBox:hover, QLineEdit:hover { + border-color: #aeb9ca; +} +QComboBox:focus, QLineEdit:focus { + border: 1px solid #6072e8; +} +QComboBox { + padding-right: 48px; +} +QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 42px; + background: #f7f8fc; + border: none; + border-left: 1px solid #e2e6ee; + border-top-right-radius: 10px; + border-bottom-right-radius: 10px; +} +QComboBox::drop-down:hover { + background: #eef1fb; +} +QComboBox::down-arrow { + image: none; + width: 16px; + height: 16px; +} +QComboBox QAbstractItemView { + color: #243047; + background: #ffffff; + border: 1px solid #d8dee9; + border-radius: 10px; + padding: 6px; + outline: none; + selection-color: #263baf; + selection-background-color: #edf0ff; +} +QComboBox QAbstractItemView::item { + min-height: 38px; + padding: 0 12px; + border-radius: 7px; +} +QComboBox QAbstractItemView::item:selected { + color: #263baf; + background: #edf0ff; + border: none; +} +QLineEdit:disabled { + color: #98a2b3; + background: #f3f5f8; + border-color: #e4e8ef; +} +QCheckBox { + min-height: 24px; + spacing: 10px; +} +QCheckBox::indicator { + width: 18px; + height: 18px; +} +QDialogButtonBox QPushButton { + min-width: 92px; +} +QScrollBar:vertical { + background: transparent; + width: 12px; + margin: 5px 2px; +} +QScrollBar::handle:vertical { + background: #c7cedb; + min-height: 32px; + border-radius: 4px; +} +QScrollBar::handle:vertical:hover { + background: #9da8bb; +} +QScrollBar::handle:vertical:pressed { + background: #7e8aa0; +} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + height: 0; + background: transparent; +} +QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { + background: transparent; +} +QScrollBar:horizontal { + background: transparent; + height: 12px; + margin: 2px 5px; +} +QScrollBar::handle:horizontal { + background: #c7cedb; + min-width: 32px; + border-radius: 4px; +} +QScrollBar::handle:horizontal:hover { + background: #9da8bb; +} +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { + width: 0; + background: transparent; +} +QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { + background: transparent; +} +""" + + +def _human_size(size: int) -> str: + value = float(size) + for unit in ("B", "KB", "MB", "GB"): + if value < 1024 or unit == "GB": + if unit == "B": + return f"{int(value)} {unit}" + return f"{value:.1f} {unit}" + value /= 1024 + return f"{size} B" + + +def _make_app_icon(size: int = 128) -> QIcon: + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = QRectF(8, 8, size - 16, size - 16) + gradient = QLinearGradient(rect.topLeft(), rect.bottomRight()) + gradient.setColorAt(0.0, QColor("#6477f2")) + gradient.setColorAt(1.0, QColor("#3548c7")) + painter.setBrush(gradient) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawRoundedRect(rect, 28, 28) + + path = QPainterPath() + path.moveTo(size * 0.31, size * 0.36) + path.lineTo(size * 0.50, size * 0.25) + path.lineTo(size * 0.69, size * 0.36) + path.lineTo(size * 0.69, size * 0.64) + path.lineTo(size * 0.50, size * 0.75) + path.lineTo(size * 0.31, size * 0.64) + path.closeSubpath() + painter.setBrush(QColor("#ffffff")) + painter.drawPath(path) + + painter.setPen(QPen(QColor("#5367e8"), max(2, size // 32))) + painter.drawLine( + QPointF(size * 0.39, size * 0.47), + QPointF(size * 0.61, size * 0.47), + ) + painter.drawLine( + QPointF(size * 0.39, size * 0.56), + QPointF(size * 0.56, size * 0.56), + ) + painter.end() + return QIcon(pixmap) + + +class HapSignStyle(QProxyStyle): + """在 Fusion 基础上绘制与界面一致的箭头和复选框。""" + + def drawPrimitive(self, element, option, painter, widget=None) -> None: + if element == QStyle.PrimitiveElement.PE_IndicatorArrowDown: + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + center = option.rect.center() + pen = QPen(QColor("#667085"), 1.8) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) + painter.setPen(pen) + path = QPainterPath() + path.moveTo(center.x() - 4.5, center.y() - 2) + path.lineTo(center.x(), center.y() + 2.5) + path.lineTo(center.x() + 4.5, center.y() - 2) + painter.drawPath(path) + painter.restore() + return + + if element == QStyle.PrimitiveElement.PE_IndicatorCheckBox: + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = QRectF(option.rect).adjusted(1, 1, -1, -1) + enabled = bool(option.state & QStyle.StateFlag.State_Enabled) + checked = bool(option.state & QStyle.StateFlag.State_On) + hovered = bool(option.state & QStyle.StateFlag.State_MouseOver) + if checked: + fill = QColor("#4459dc" if enabled else "#aab3dd") + border = QColor("#4459dc" if enabled else "#aab3dd") + else: + fill = QColor("#f8f9fc" if enabled else "#f0f2f5") + border = QColor("#aeb8c8" if hovered else "#c7ced9") + painter.setBrush(fill) + painter.setPen(QPen(border, 1.2)) + painter.drawRoundedRect(rect, 4, 4) + if checked: + pen = QPen(QColor("#ffffff"), 1.8) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) + painter.setPen(pen) + check = QPainterPath() + check.moveTo(rect.left() + 4.0, rect.center().y()) + check.lineTo(rect.left() + 7.2, rect.bottom() - 4.2) + check.lineTo(rect.right() - 3.5, rect.top() + 4.1) + painter.drawPath(check) + painter.restore() + return + + super().drawPrimitive(element, option, painter, widget) + + +def _application_font() -> QFont: + """选择高质量中文 UI 字体,并让笔画尽量贴合物理像素网格。""" + available = set(QFontDatabase.families()) + preferred = ( + ("Microsoft YaHei UI", "Microsoft YaHei", "Segoe UI") + if platform_tag() == "windows" + else ("PingFang SC", "Noto Sans CJK SC", "Noto Sans") + ) + family = next( + (candidate for candidate in preferred if candidate in available), + QFontDatabase.systemFont(QFontDatabase.SystemFont.GeneralFont).family(), + ) + font = QFont(family) + # 整数逻辑像素避免 10pt -> 13.333px 在 200% DPI 下形成分数物理像素。 + font.setPixelSize(14) + font.setHintingPreference(QFont.HintingPreference.PreferFullHinting) + font.setStyleStrategy( + QFont.StyleStrategy.PreferAntialias | QFont.StyleStrategy.PreferQuality + ) + return font + + +def _fixed_width_font() -> QFont: + available = set(QFontDatabase.families()) + family = next( + ( + candidate + for candidate in ("Cascadia Mono", "Consolas", "Microsoft YaHei UI") + if candidate in available + ), + QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont).family(), + ) + font = QFont(family) + font.setPixelSize(13) + font.setHintingPreference(QFont.HintingPreference.PreferFullHinting) + font.setStyleStrategy( + QFont.StyleStrategy.PreferAntialias | QFont.StyleStrategy.PreferQuality + ) + return font + + +class ComboItemDelegate(QStyledItemDelegate): + def paint(self, painter, option, index) -> None: + clean_option = QStyleOptionViewItem(option) + clean_option.state &= ~QStyle.StateFlag.State_HasFocus + super().paint(painter, clean_option, index) + + +class StyledComboBox(QComboBox): + """使用程序自己的抗锯齿箭头,避免平台原生方框样式混入。""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setItemDelegate(ComboItemDelegate(self)) + + def paintEvent(self, event) -> None: + super().paintEvent(event) + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + center = QPointF(self.width() - 21.0, self.height() / 2.0) + pen = QPen(QColor("#667085"), 1.8) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) + painter.setPen(pen) + path = QPainterPath() + path.moveTo(center.x() - 4.5, center.y() - 2.0) + path.lineTo(center.x(), center.y() + 2.5) + path.lineTo(center.x() + 4.5, center.y() - 2.0) + painter.drawPath(path) + + +class DropZone(QFrame): + """支持点击与 HAP 文件拖放的选择区域。""" + + choose_requested = Signal() + file_dropped = Signal(str) + + def __init__(self) -> None: + super().__init__() + self.setObjectName("dropZone") + self.setAcceptDrops(True) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setMinimumHeight(190) + self.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Fixed, + ) + + layout = QVBoxLayout(self) + layout.setContentsMargins(28, 25, 28, 25) + layout.setSpacing(8) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + + badge = QLabel("HAP") + badge.setAlignment(Qt.AlignmentFlag.AlignCenter) + badge.setFixedSize(58, 58) + badge.setStyleSheet( + "color: #4459dc; background: #e9edff; border-radius: 16px;" + "font-size: 14px; font-weight: 750;" + ) + title = QLabel("拖入 HAP 文件") + title.setObjectName("dropTitle") + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + hint = QLabel("或点击这里从电脑中选择") + hint.setObjectName("muted") + hint.setAlignment(Qt.AlignmentFlag.AlignCenter) + + layout.addWidget(badge, 0, Qt.AlignmentFlag.AlignCenter) + layout.addWidget(title) + layout.addWidget(hint) + + def _set_active(self, active: bool) -> None: + self.setProperty("active", active) + self.style().unpolish(self) + self.style().polish(self) + + def mousePressEvent(self, event: QMouseEvent) -> None: + if event.button() == Qt.MouseButton.LeftButton: + self.choose_requested.emit() + super().mousePressEvent(event) + + def dragEnterEvent(self, event: QDragEnterEvent) -> None: + urls = event.mimeData().urls() + if any(url.toLocalFile().lower().endswith(".hap") for url in urls): + event.acceptProposedAction() + self._set_active(True) + return + event.ignore() + + def dragLeaveEvent(self, event) -> None: + self._set_active(False) + super().dragLeaveEvent(event) + + def dropEvent(self, event: QDropEvent) -> None: + self._set_active(False) + for url in event.mimeData().urls(): + path = url.toLocalFile() + if path.lower().endswith(".hap"): + self.file_dropped.emit(path) + event.acceptProposedAction() + return + event.ignore() + + +class PipelineLogHandler(logging.Handler): + """将后台线程日志转发到 Qt 信号。""" + + def __init__(self, callback) -> None: + super().__init__(logging.INFO) + self.callback = callback + self.last_error = "" + + def emit(self, record: logging.LogRecord) -> None: + message = record.getMessage() + if record.levelno >= logging.ERROR: + self.last_error = message + self.callback(message) + + +class PipelineWorker(QObject): + """在后台线程运行签名安装流程。""" + + log_message = Signal(str) + progress_changed = Signal(int, str) + finished = Signal(bool, bool, str) + + def __init__( + self, + hap_path: str, + state_dir: Path, + browser_mode: str, + keep_signed_hap: bool, + ) -> None: + super().__init__() + self.hap_path = hap_path + self.state_dir = state_dir + self.browser_mode = browser_mode + self.keep_signed_hap = keep_signed_hap + self.cancel_event = threading.Event() + + def request_cancel(self) -> None: + """可从 GUI 线程直接调用;Event 是线程安全的。""" + self.cancel_event.set() + + @Slot() + def run(self) -> None: + root_logger = logging.getLogger() + handler = PipelineLogHandler(self.log_message.emit) + root_logger.addHandler(handler) + + try: + bundle_name = detect_bundle_name(self.hap_path) + signed = is_hap_signed(self.hap_path) + toolchain = discover_toolchain() + missing = toolchain.missing(require_signing=not signed) + if missing: + details = "\n".join(f"• {item}" for item in missing) + raise RuntimeError(f"便携工具链不完整:\n{details}") + + self.log_message.emit(f"应用包:{bundle_name}") + self.log_message.emit(f"工具来源:{toolchain.source}") + LOGGER.info( + "开始桌面流程:hap=%s state_dir=%s browser=%s", + self.hap_path, + self.state_dir, + self.browser_mode, + ) + pipeline = SignPipeline( + hap_path=self.hap_path, + bundle_name=bundle_name, + state_dir=str(self.state_dir), + browser_mode=self.browser_mode, + signed_output_dir=str(signed_haps_dir()), + keep_signed_hap=self.keep_signed_hap, + cancel_event=self.cancel_event, + progress_callback=self.progress_changed.emit, + ) + if pipeline.run(): + self.finished.emit(True, False, "HAP 已成功安装到设备") + elif self.cancel_event.is_set(): + self.finished.emit(False, True, "任务已取消") + else: + message = handler.last_error or "签名或安装没有完成,请查看运行记录" + self.finished.emit(False, False, message) + except OperationCancelled: + self.log_message.emit("任务已取消,清理工作已完成") + self.finished.emit(False, True, "任务已取消") + except Exception as exc: + if self.cancel_event.is_set(): + self.finished.emit(False, True, "任务已取消") + else: + LOGGER.exception("桌面流程失败") + self.finished.emit(False, False, str(exc)) + finally: + root_logger.removeHandler(handler) + + +class DeviceCheckWorker(QObject): + """在后台确认 HDC 工具和设备连接状态。""" + + progress_changed = Signal(int, str) + finished = Signal(bool, bool, str) + + def __init__(self) -> None: + super().__init__() + self.cancel_event = threading.Event() + + def request_cancel(self) -> None: + self.cancel_event.set() + + @Slot() + def run(self) -> None: + try: + self.progress_changed.emit(15, "正在检查 HDC 工具") + toolchain = discover_toolchain() + missing = toolchain.missing(require_signing=False) + if missing: + details = "\n".join(f"• {item}" for item in missing) + raise RuntimeError(f"HDC 工具不可用:\n{details}") + self.progress_changed.emit(55, "正在查询连接设备") + with Installer(cancel_event=self.cancel_event) as installer: + udid = installer.get_udid() + self.finished.emit( + True, + False, + f"设备连接正常(UDID 尾号 {udid[-6:]})", + ) + except OperationCancelled: + self.finished.emit(False, True, "设备检测已取消") + except Exception as exc: + if self.cancel_event.is_set(): + self.finished.emit(False, True, "设备检测已取消") + else: + self.finished.emit(False, False, str(exc)) + + +def _open_local_directory(parent: QWidget, path: Path, title: str) -> None: + try: + path.mkdir(parents=True, exist_ok=True) + except OSError as exc: + QMessageBox.warning(parent, f"无法打开{title}", str(exc)) + return + if not QDesktopServices.openUrl(QUrl.fromLocalFile(str(path.resolve()))): + QMessageBox.warning(parent, f"无法打开{title}", f"目录位置:\n{path}") + + +class SettingsDialog(QDialog): + """桌面版持久化设置。""" + + def __init__( + self, + settings: AppSettings, + log_path: Path, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self._settings = settings + self._log_path = log_path + self.setWindowTitle("HapSign 设置") + self.setMinimumWidth(610) + + layout = QVBoxLayout(self) + layout.setContentsMargins(24, 20, 24, 18) + layout.setSpacing(11) + + title = QLabel("设置") + title.setObjectName("settingsTitle") + layout.addWidget(title) + basic_section = QFrame() + basic_section.setObjectName("settingsSection") + basic_layout = QVBoxLayout(basic_section) + basic_layout.setContentsMargins(16, 13, 16, 15) + basic_layout.setSpacing(9) + basic_title = QLabel("登录与签名材料") + basic_title.setObjectName("sectionTitle") + basic_layout.addWidget(basic_title) + form = QFormLayout() + form.setHorizontalSpacing(18) + form.setVerticalSpacing(9) + form.setLabelAlignment( + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter + ) + form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + + self.browser_combo = StyledComboBox() + self.browser_combo.addItem( + "系统 Edge / Chrome(受控,推荐)", + "system_controlled", + ) + self.browser_combo.addItem("内置 Chromium(兼容模式)", "playwright") + self.browser_combo.addItem("系统默认浏览器(非受控备用)", "system") + self.browser_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Fixed, + ) + self._select_data(self.browser_combo, settings.browser_mode) + form.addRow("登录浏览器", self.browser_combo) + + self.storage_combo = StyledComboBox() + self.storage_combo.addItem("程序目录(便携)", "program") + self.storage_combo.addItem("用户 AppData Local", "appdata") + self.storage_combo.addItem("自定义目录", "custom") + self.storage_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Fixed, + ) + self._select_data(self.storage_combo, settings.signing_storage) + self.storage_combo.currentIndexChanged.connect(self._update_custom_state) + form.addRow("签名文件位置", self.storage_combo) + + custom_row = QWidget() + custom_layout = QHBoxLayout(custom_row) + custom_layout.setContentsMargins(0, 0, 0, 0) + custom_layout.setSpacing(8) + self.custom_path = QLineEdit(settings.custom_signing_dir) + self.custom_path.setPlaceholderText("选择一个用于保存签名材料的目录") + self.custom_browse_button = QPushButton("浏览") + self.custom_browse_button.setObjectName("secondaryButton") + self.custom_browse_button.clicked.connect(self._choose_custom_directory) + custom_layout.addWidget(self.custom_path, 1) + custom_layout.addWidget(self.custom_browse_button) + form.addRow("自定义目录", custom_row) + basic_layout.addLayout(form) + layout.addWidget(basic_section) + + diagnostic_section = QFrame() + diagnostic_section.setObjectName("settingsSection") + diagnostic_layout = QVBoxLayout(diagnostic_section) + diagnostic_layout.setContentsMargins(16, 13, 16, 14) + diagnostic_layout.setSpacing(8) + diagnostic_title = QLabel("日志与签名产物") + diagnostic_title.setObjectName("sectionTitle") + diagnostic_layout.addWidget(diagnostic_title) + log_row = QHBoxLayout() + log_row.setSpacing(18) + log_label = QLabel("日志级别") + log_label.setMinimumWidth(104) + self.log_level_combo = StyledComboBox() + for level in ("DEBUG", "INFO", "WARNING", "ERROR"): + self.log_level_combo.addItem(level, level) + self._select_data(self.log_level_combo, settings.log_level) + log_row.addWidget(log_label) + log_row.addWidget(self.log_level_combo, 1) + diagnostic_layout.addLayout(log_row) + + self.sensitive_check = QCheckBox( + "记录敏感诊断信息(可能包含 token、用户标识及完整 API 请求/响应)" + ) + self.sensitive_check.setChecked(settings.log_sensitive_data) + diagnostic_layout.addWidget(self.sensitive_check) + warning = QLabel("仅 DEBUG 记录敏感内容;签名库密码永不记录,排障后请关闭。") + warning.setObjectName("warning") + warning.setWordWrap(True) + diagnostic_layout.addWidget(warning) + self.keep_signed_check = QCheckBox("保留最新一个签名后的 HAP") + self.keep_signed_check.setChecked(settings.keep_signed_hap) + diagnostic_layout.addWidget(self.keep_signed_check) + signed_hint = QLabel( + "目录:程序目录 / signed_haps;新文件成功后自动清理旧 HAP。" + ) + signed_hint.setObjectName("muted") + signed_hint.setWordWrap(True) + signed_hint.setToolTip(str(signed_haps_dir())) + diagnostic_layout.addWidget(signed_hint) + layout.addWidget(diagnostic_section) + + data_section = QFrame() + data_section.setObjectName("settingsSection") + data_layout = QVBoxLayout(data_section) + data_layout.setContentsMargins(16, 13, 16, 14) + data_layout.setSpacing(8) + data_title = QLabel("数据位置") + data_title.setObjectName("sectionTitle") + data_layout.addWidget(data_title) + directory_actions = QHBoxLayout() + directory_actions.setSpacing(9) + open_signing = QPushButton("打开签名目录") + open_signing.setObjectName("secondaryButton") + open_signing.setToolTip(str(signing_files_dir(settings))) + open_signing.clicked.connect(self._open_selected_signing_directory) + open_logs = QPushButton("打开日志目录") + open_logs.setObjectName("secondaryButton") + open_logs.setToolTip(str(self._log_path.parent)) + open_logs.clicked.connect( + lambda: _open_local_directory(self, self._log_path.parent, "日志目录") + ) + open_signed = QPushButton("打开签名 HAP 目录") + open_signed.setObjectName("secondaryButton") + open_signed.setToolTip(str(signed_haps_dir())) + open_signed.clicked.connect( + lambda: _open_local_directory( + self, + signed_haps_dir(), + "签名 HAP 目录", + ) + ) + directory_actions.addWidget(open_signing) + directory_actions.addWidget(open_signed) + directory_actions.addWidget(open_logs) + data_layout.addLayout(directory_actions) + data_section.setToolTip(f"配置文件:{config_file_path()}") + layout.addWidget(data_section) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Save + | QDialogButtonBox.StandardButton.Cancel + ) + save_button = buttons.button(QDialogButtonBox.StandardButton.Save) + save_button.setText("保存") + save_button.setObjectName("primaryButton") + cancel_button = buttons.button(QDialogButtonBox.StandardButton.Cancel) + cancel_button.setText("取消") + cancel_button.setObjectName("secondaryButton") + buttons.accepted.connect(self._validate_and_accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + self._update_custom_state() + + @staticmethod + def _select_data(combo: QComboBox, value: str) -> None: + index = combo.findData(value) + combo.setCurrentIndex(max(0, index)) + + @Slot() + def _update_custom_state(self) -> None: + enabled = self.storage_combo.currentData() == "custom" + self.custom_path.setEnabled(enabled) + self.custom_browse_button.setEnabled(enabled) + + @Slot() + def _choose_custom_directory(self) -> None: + initial = self.custom_path.text().strip() or str(Path.home()) + selected = QFileDialog.getExistingDirectory( + self, + "选择签名文件目录", + initial, + ) + if selected: + self.custom_path.setText(selected) + + def result_settings(self) -> AppSettings: + return AppSettings( + log_level=str(self.log_level_combo.currentData()), + signing_storage=str(self.storage_combo.currentData()), + custom_signing_dir=self.custom_path.text().strip(), + browser_mode=str(self.browser_combo.currentData()), + log_sensitive_data=self.sensitive_check.isChecked(), + keep_signed_hap=self.keep_signed_check.isChecked(), + ) + + @Slot() + def _open_selected_signing_directory(self) -> None: + settings = self.result_settings() + if settings.signing_storage == "custom" and not settings.custom_signing_dir: + QMessageBox.warning(self, "尚未选择目录", "请先选择一个自定义目录。") + return + _open_local_directory( + self, + signing_files_dir(settings), + "签名目录", + ) + + @Slot() + def _validate_and_accept(self) -> None: + if ( + self.storage_combo.currentData() == "custom" + and not self.custom_path.text().strip() + ): + QMessageBox.warning(self, "无法保存", "自定义签名目录不能为空。") + return + self.accept() + + +class MainWindow(QMainWindow): + """HapSign 主窗口。""" + + def __init__( + self, + settings: AppSettings | None = None, + log_path: Path | None = None, + ) -> None: + super().__init__() + self.settings = settings or load_settings() + self.log_path = log_path or configure_file_logging(self.settings) + self.selected_path: Path | None = None + self.worker_thread: QThread | None = None + self.worker: QObject | None = None + self._close_after_cancel = False + self._cancel_requested = False + + self.setWindowTitle("HapSign") + self.setWindowIcon(_make_app_icon()) + self.resize(820, 690) + self.setMinimumSize(QSize(720, 620)) + + root = QWidget() + root.setObjectName("root") + self.setCentralWidget(root) + outer = QVBoxLayout(root) + outer.setContentsMargins(42, 34, 42, 34) + outer.setSpacing(22) + outer.addLayout(self._build_header()) + + card = QFrame() + card.setObjectName("card") + card_layout = QVBoxLayout(card) + card_layout.setContentsMargins(26, 26, 26, 24) + card_layout.setSpacing(18) + + file_layout = QHBoxLayout() + file_layout.setSpacing(14) + self.drop_zone = DropZone() + self.drop_zone.choose_requested.connect(self._choose_file) + self.drop_zone.file_dropped.connect(self._load_file) + file_layout.addWidget(self.drop_zone, 1) + + self.file_panel = self._build_file_panel() + self.file_panel.hide() + file_layout.addWidget(self.file_panel) + card_layout.addLayout(file_layout) + + action_layout = QHBoxLayout() + action_layout.setSpacing(10) + self.choose_button = QPushButton("选择 HAP") + self.choose_button.setObjectName("secondaryButton") + self.choose_button.clicked.connect(self._choose_file) + self.check_button = QPushButton("检测设备") + self.check_button.setObjectName("secondaryButton") + self.check_button.clicked.connect(self._check_device) + self.start_button = QPushButton("开始签名并安装") + self.start_button.setObjectName("primaryButton") + self.start_button.setEnabled(False) + self.start_button.clicked.connect(self._start) + self.cancel_button = QPushButton("取消") + self.cancel_button.setObjectName("cancelButton") + self.cancel_button.clicked.connect(self._cancel_active_task) + self.cancel_button.hide() + action_layout.addWidget(self.choose_button) + action_layout.addWidget(self.check_button) + action_layout.addWidget(self.start_button, 1) + action_layout.addWidget(self.cancel_button) + card_layout.addLayout(action_layout) + + self.progress_bar = QProgressBar() + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(0) + self.progress_bar.setFormat("%p%") + self.progress_bar.setTextVisible(True) + self.progress_bar.hide() + card_layout.addWidget(self.progress_bar) + + self.status_label = QLabel("选择一个 HAP 文件即可开始") + self.status_label.setObjectName("status") + card_layout.addWidget(self.status_label) + + self.log_title = QLabel("运行记录") + self.log_title.setObjectName("logTitle") + self.log_title.hide() + card_layout.addWidget(self.log_title) + self.log_view = QPlainTextEdit() + self.log_view.setReadOnly(True) + self.log_view.setMaximumBlockCount(300) + self.log_view.setMinimumHeight(120) + self.log_view.setFont(_fixed_width_font()) + self.log_view.hide() + card_layout.addWidget(self.log_view) + outer.addWidget(card) + outer.addItem( + QSpacerItem( + 0, + 0, + QSizePolicy.Policy.Minimum, + QSizePolicy.Policy.Expanding, + ) + ) + + def _build_header(self) -> QHBoxLayout: + header = QHBoxLayout() + header.setSpacing(15) + + icon_label = QLabel() + icon_label.setPixmap(_make_app_icon(92).pixmap(56, 56)) + icon_label.setFixedSize(56, 56) + header.addWidget(icon_label) + + text_layout = QVBoxLayout() + text_layout.setSpacing(2) + title = QLabel("HapSign") + title.setObjectName("title") + subtitle = QLabel("签名并安装 HarmonyOS 应用") + subtitle.setObjectName("subtitle") + text_layout.addWidget(title) + text_layout.addWidget(subtitle) + header.addLayout(text_layout) + header.addStretch(1) + + self.settings_button = QPushButton("设置") + self.settings_button.setObjectName("secondaryButton") + self.settings_button.clicked.connect(self._open_settings) + header.addWidget(self.settings_button) + + platform_chip = QLabel(f"{platform_tag().title()} · v{__version__}") + platform_chip.setObjectName("chip") + platform_chip.setAlignment(Qt.AlignmentFlag.AlignCenter) + header.addWidget(platform_chip) + return header + + def _build_file_panel(self) -> QFrame: + panel = QFrame() + panel.setStyleSheet("QFrame { background: #f7f8fb; border-radius: 12px; }") + panel.setFixedWidth(245) + panel.setMinimumHeight(190) + layout = QVBoxLayout(panel) + layout.setContentsMargins(15, 12, 15, 12) + layout.setSpacing(8) + + top_layout = QHBoxLayout() + file_badge = QLabel("HAP") + file_badge.setAlignment(Qt.AlignmentFlag.AlignCenter) + file_badge.setFixedSize(42, 42) + file_badge.setStyleSheet( + "color: #4459dc; background: #e7ebff; border-radius: 10px;" + "font-size: 12px; font-weight: 750;" + ) + top_layout.addWidget(file_badge) + top_layout.addStretch(1) + self.remove_button = QPushButton("×") + self.remove_button.setObjectName("removeButton") + self.remove_button.setToolTip("移除当前 HAP") + self.remove_button.setAccessibleName("移除当前 HAP") + self.remove_button.clicked.connect(self._clear_file) + top_layout.addWidget(self.remove_button) + layout.addLayout(top_layout) + + self.file_name = QLabel() + self.file_name.setObjectName("fileName") + self.file_name.setWordWrap(True) + self.file_meta = QLabel() + self.file_meta.setObjectName("fileMeta") + layout.addWidget(self.file_name) + layout.addWidget(self.file_meta) + layout.addStretch(1) + + self.signature_chip = QLabel() + self.signature_chip.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.signature_chip, 0, Qt.AlignmentFlag.AlignLeft) + return panel + + @Slot() + def _open_settings(self) -> None: + if self.worker_thread is not None: + return + dialog = SettingsDialog(self.settings, self.log_path, self) + if dialog.exec() != QDialog.DialogCode.Accepted: + return + updated = dialog.result_settings() + try: + save_settings(updated) + self.log_path = configure_file_logging(updated) + except OSError as exc: + QMessageBox.warning(self, "无法保存设置", str(exc)) + return + self.settings = updated + self.status_label.setStyleSheet("color: #237353; font-weight: 650;") + self.status_label.setText("设置已保存,将用于下一次任务") + + @Slot() + def _choose_file(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, + "选择 HAP 文件", + str(Path.home()), + "HarmonyOS HAP (*.hap);;所有文件 (*)", + ) + if path: + self._load_file(path) + + @Slot(str) + def _load_file(self, raw_path: str) -> None: + path = Path(raw_path).expanduser().resolve() + try: + if path.suffix.lower() != ".hap": + raise ValueError("请选择扩展名为 .hap 的文件") + if not path.is_file(): + raise ValueError("文件不存在或无法读取") + bundle_name = detect_bundle_name(str(path)) + signed = is_hap_signed(path) + except Exception as exc: + QMessageBox.warning(self, "无法载入 HAP", str(exc)) + return + + self.selected_path = path + self.status_label.setStyleSheet("") + self.start_button.setText("开始签名并安装") + self.file_name.setText(path.name) + self.file_name.setToolTip(str(path)) + self.file_meta.setText(f"{bundle_name} · {_human_size(path.stat().st_size)}") + self.signature_chip.setText("已签名" if signed else "待签名") + self.signature_chip.setObjectName("signedChip" if signed else "unsignedChip") + self.signature_chip.style().unpolish(self.signature_chip) + self.signature_chip.style().polish(self.signature_chip) + self.file_panel.show() + self.start_button.setEnabled(True) + self.status_label.setText( + "已签名,将直接安装" if signed else "未签名,将自动完成签名后安装" + ) + + @Slot() + def _clear_file(self) -> None: + if self.worker_thread is not None: + return + self.selected_path = None + self.file_name.clear() + self.file_name.setToolTip("") + self.file_meta.clear() + self.signature_chip.clear() + self.file_panel.hide() + self.start_button.setText("开始签名并安装") + self.start_button.setEnabled(False) + self.progress_bar.hide() + self.log_title.hide() + self.log_view.clear() + self.log_view.hide() + self.status_label.setStyleSheet("") + self.status_label.setText("HAP 已移除,可重新选择或拖入文件") + + @Slot() + def _check_device(self) -> None: + if self.worker_thread is not None: + return + + self.status_label.setStyleSheet("") + self.status_label.setText("正在检测设备连接…") + self.choose_button.setEnabled(False) + self.check_button.setEnabled(False) + self.start_button.setEnabled(False) + self.remove_button.setEnabled(False) + self.drop_zone.setEnabled(False) + self.settings_button.setEnabled(False) + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(5) + self.progress_bar.show() + self.cancel_button.show() + self._cancel_requested = False + + thread = QThread(self) + worker = DeviceCheckWorker() + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.progress_changed.connect(self._set_progress) + worker.finished.connect(self._finish_device_check) + worker.finished.connect(thread.quit) + worker.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + thread.finished.connect(self._thread_finished) + self.worker_thread = thread + self.worker = worker + thread.start() + + @Slot(bool, bool, str) + def _finish_device_check( + self, success: bool, cancelled: bool, message: str + ) -> None: + self.progress_bar.setValue(100 if success else self.progress_bar.value()) + self.status_label.setText(message) + if success: + self.status_label.setStyleSheet("color: #237353; font-weight: 650;") + elif cancelled: + self.progress_bar.setValue(0) + self.status_label.setStyleSheet("color: #657084; font-weight: 650;") + else: + self.status_label.setStyleSheet("color: #b54646; font-weight: 650;") + QMessageBox.warning(self, "未检测到可用设备", message) + + @Slot() + def _start(self) -> None: + if self.selected_path is None or self.worker_thread is not None: + return + + self.status_label.setStyleSheet("") + self.start_button.setEnabled(False) + self.choose_button.setEnabled(False) + self.check_button.setEnabled(False) + self.remove_button.setEnabled(False) + self.drop_zone.setEnabled(False) + self.settings_button.setEnabled(False) + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(0) + self.progress_bar.show() + self.cancel_button.show() + self._cancel_requested = False + self.log_title.show() + self.log_view.clear() + self.log_view.show() + self.status_label.setText("正在准备,请保持设备连接…") + + thread = QThread(self) + worker = PipelineWorker( + str(self.selected_path), + signing_files_dir(self.settings), + self.settings.browser_mode, + self.settings.keep_signed_hap, + ) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.log_message.connect(self._append_log) + worker.progress_changed.connect(self._set_progress) + worker.finished.connect(self._finish) + worker.finished.connect(thread.quit) + worker.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + thread.finished.connect(self._thread_finished) + self.worker_thread = thread + self.worker = worker + thread.start() + + @Slot(str) + def _append_log(self, message: str) -> None: + self.log_view.appendPlainText(message) + self.status_label.setText(message) + + @Slot(int, str) + def _set_progress(self, value: int, message: str) -> None: + self.progress_bar.setValue(max(0, min(100, value))) + if message: + self.status_label.setText(message) + + @Slot() + def _cancel_active_task(self) -> None: + if self.worker is None or self._cancel_requested: + return + self._cancel_requested = True + self.cancel_button.setEnabled(False) + self.status_label.setStyleSheet("color: #657084; font-weight: 650;") + self.status_label.setText("正在取消并清理,请稍候…") + request_cancel = getattr(self.worker, "request_cancel", None) + if request_cancel is not None: + request_cancel() + + @Slot(bool, bool, str) + def _finish(self, success: bool, cancelled: bool, message: str) -> None: + self.progress_bar.setValue(100 if success else self.progress_bar.value()) + self.status_label.setText(message) + if success: + self.status_label.setStyleSheet("color: #237353; font-weight: 650;") + self.start_button.setText("再次安装") + elif cancelled: + self.progress_bar.setValue(0) + self.status_label.setStyleSheet("color: #657084; font-weight: 650;") + self.start_button.setText("重新开始") + else: + self.status_label.setStyleSheet("color: #b54646; font-weight: 650;") + self.start_button.setText("重试") + QMessageBox.critical(self, "未能完成安装", message) + + @Slot() + def _thread_finished(self) -> None: + self.worker_thread = None + self.worker = None + self.cancel_button.hide() + self.cancel_button.setEnabled(True) + self._cancel_requested = False + self.start_button.setEnabled(self.selected_path is not None) + self.choose_button.setEnabled(True) + self.check_button.setEnabled(True) + self.remove_button.setEnabled(self.selected_path is not None) + self.drop_zone.setEnabled(True) + self.settings_button.setEnabled(True) + if self._close_after_cancel: + self._close_after_cancel = False + QTimer.singleShot(0, self.close) + + def closeEvent(self, event) -> None: + if self.worker_thread is not None: + if self._close_after_cancel: + event.ignore() + return + answer = QMessageBox.question( + self, + "中断并退出?", + "任务仍在执行。是否中断当前任务、完成清理后退出程序?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if answer == QMessageBox.StandardButton.Yes: + self._close_after_cancel = True + self._cancel_active_task() + event.ignore() + return + event.accept() + + +def main() -> int: + """启动桌面应用。""" + settings = load_settings() + log_path = configure_file_logging(settings) + browser_smoke_mode = None + if "--browser-smoke-test" in sys.argv: + browser_smoke_mode = "playwright" + elif "--system-browser-smoke-test" in sys.argv: + browser_smoke_mode = "system_controlled" + if browser_smoke_mode is not None: + try: + playwright_browser_smoke_test(browser_smoke_mode) + except Exception: + LOGGER.exception("受控浏览器自检失败:%s", browser_smoke_mode) + return 1 + LOGGER.info("受控浏览器自检成功:%s", browser_smoke_mode) + return 0 + + QApplication.setHighDpiScaleFactorRoundingPolicy( + Qt.HighDpiScaleFactorRoundingPolicy.PassThrough + ) + app = QApplication(sys.argv) + app.setApplicationName("HapSign") + app.setOrganizationName("HapSign") + app.setWindowIcon(_make_app_icon()) + app.setStyle(HapSignStyle("Fusion")) + app.setStyleSheet(WINDOW_STYLE) + + app.setFont(_application_font()) + + window = MainWindow(settings, log_path) + window.show() + if "--smoke-test" in sys.argv: + QTimer.singleShot(250, app.quit) + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hapsign/login/browser_login.py b/hapsign/login/browser_login.py index b40016a..133c4c7 100644 --- a/hapsign/login/browser_login.py +++ b/hapsign/login/browser_login.py @@ -4,22 +4,30 @@ 通过本地 HTTP 服务拦截回调拿到 tempToken。 流程(逆向自 DevEco Studio HiAiLoginService): - 1. 找空闲端口,生成 CSRF code (UUID 去横线) - 2. 启动本地 HTTP 服务等待 POST /callback - 3. Playwright 浏览器打开登录页 ?port={port}&appid=1007&code={uuid} + 1. 生成 CSRF code (UUID 去横线),在 loopback 上绑定临时端口 + 2. 启动本地 HTTP 服务等待授权回调 + 3. 系统浏览器打开登录页 ?port={port}&appid=1007&code={uuid} 4. 用户在浏览器中手动输入账号密码并登录 5. 华为 OAuth 服务端通过浏览器回调本地 HTTP 服务,携带 tempToken, siteId, code 6. 校验 code(CSRF),返回 tempToken """ +import json import logging -import socket +import os import threading +import time import uuid +import webbrowser +from email import policy +from email.parser import BytesParser from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path from urllib.parse import parse_qs, urlparse -from hapsign.config import APP_ID, BASE_URL, LOGIN_AUTH_PATH +from hapsign.cancellation import OperationCancelled, raise_if_cancelled +from hapsign.config import APP_ID, BASE_URL, LOGIN_AUTH_PATH, LOGIN_SUCCESS_PATH +from hapsign.diagnostics import sensitive_logging_enabled logger = logging.getLogger(__name__) @@ -27,16 +35,69 @@ _CALLBACK_TIMEOUT = 300 _CALLBACK_HOST = "127.0.0.1" _MAX_CALLBACK_BODY_SIZE = 64 * 1024 +_CONTROLLED_SYSTEM_CHANNELS = ("msedge", "chrome") + + +def _parse_multipart(body: bytes, content_type: str) -> dict[str, str]: + """解析浏览器回调的 multipart 表单,兼容 DevEco 的 Netty 解码行为。""" + message = BytesParser(policy=policy.default).parsebytes( + b"Content-Type: " + + content_type.encode("ascii", errors="ignore") + + b"\r\nMIME-Version: 1.0\r\n\r\n" + + body + ) + if not message.is_multipart(): + return {} + params: dict[str, str] = {} + for part in message.iter_parts(): + name = part.get_param("name", header="content-disposition") + if not name: + continue + value = part.get_payload(decode=True) or b"" + charset = part.get_content_charset() or "utf-8" + params[name] = value.decode(charset, errors="replace") + return params + + +def _flatten_params(params: dict) -> dict[str, str]: + return { + str(key): str(value[-1] if isinstance(value, list) else value) + for key, value in params.items() + } + + +def _decode_callback_params(body: bytes, content_type: str) -> dict[str, str]: + """按 Content-Type 解码,并对浏览器省略/误报类型的情况进行安全回退。""" + media_type = content_type.lower() + params: dict = {} + + if "application/json" in media_type: + try: + decoded = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError, TypeError): + decoded = {} + if isinstance(decoded, dict): + nested = decoded.get("data") + params = nested if isinstance(nested, dict) else decoded + elif "multipart/form-data" in media_type: + params = _parse_multipart(body, content_type) + else: + try: + params = parse_qs(body.decode("utf-8")) + except UnicodeDecodeError: + params = {} + + # 某些授权页会漏掉 multipart Content-Type;从首行恢复 boundary 后重试。 + if not {"tempToken", "code"}.intersection(params) and body.startswith(b"--"): + first_line = body.splitlines()[0].strip() + if first_line.startswith(b"--") and len(first_line) > 2: + boundary = first_line[2:].decode("ascii", errors="ignore") + params = _parse_multipart( + body, + f'multipart/form-data; boundary="{boundary}"', + ) -# 登录成功重定向路径 -_LOGIN_SUCCESS_PATH = "console/DevEcoIDE/loginSuccess" - - -def _find_free_port() -> int: - """找本机空闲 TCP 端口。""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind((_CALLBACK_HOST, 0)) - return s.getsockname()[1] + return _flatten_params(params) def _make_callback_handler( @@ -44,66 +105,150 @@ def _make_callback_handler( ): """创建回调 HTTP 请求处理器类(闭包注入共享状态)。 - 华为 OAuth 服务端在用户登录成功后,通过浏览器向本地回调服务器 - POST 表单数据:tempToken, siteId, code。 + 华为 OAuth 服务端在用户登录成功后,通过浏览器向本地回调服务器发送 + tempToken、siteId 和 code。兼容 DevEco 使用的 multipart 表单及其他版本。 """ class _CallbackHandler(BaseHTTPRequestHandler): + def _send_headers(self, status: int, content_type: str = "text/plain") -> None: + self.send_response(status) + self.send_header("Content-Type", f"{content_type}; charset=utf-8") + origin = self.headers.get("Origin", "") + self.send_header("Access-Control-Allow-Origin", origin or "*") + if origin: + self.send_header("Access-Control-Allow-Credentials", "true") + self.send_header("Vary", "Origin") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + requested_headers = self.headers.get( + "Access-Control-Request-Headers", "Content-Type" + ) + self.send_header("Access-Control-Allow-Headers", requested_headers) + # Chromium 对 HTTPS 页面访问 loopback 可能发起 Private Network + # Access 预检;允许后才会真正发送带 token 的 POST。 + self.send_header("Access-Control-Allow-Private-Network", "true") + self.send_header("Cache-Control", "no-store") + self.end_headers() + + def _send_text( + self, status: int, message: str, content_type: str = "text/plain" + ) -> None: + self._send_headers(status, content_type) + try: + self.wfile.write(message.encode("utf-8")) + except (BrokenPipeError, ConnectionResetError): + logger.debug("[callback] client closed before reading response") + + def _send_success_redirect(self) -> None: + self.send_response(302) + self.send_header("Location", f"{BASE_URL}/{LOGIN_SUCCESS_PATH}") + self.send_header("Cache-Control", "no-store") + self.end_headers() + def _process_params(self, params: dict) -> None: """处理回调参数,校验 CSRF code 并保存 tempToken。""" # 回调中含有 tempToken。日志只记录字段名,避免用户分享日志时泄漏凭据。 logger.debug("[callback] fields received: %s", sorted(params)) + if sensitive_logging_enabled(): + logger.debug("[callback] sensitive params=%r", params) received_code = params.get("code", "") if received_code != expected_code: logger.warning("[callback] CSRF code mismatch") - self.send_response(400) - self.end_headers() - self.wfile.write(b"invalid csrf code") + self._send_text(400, "invalid csrf code") + return + + temp_token = params.get("tempToken", "") + if not temp_token: + denied = params.get("quit", "") + callback_data["error"] = ( + "用户取消了华为账号授权" + if denied in {"quit", "access_denied"} + else "登录回调缺少 tempToken" + ) + callback_event.set() + logger.warning("[callback] authorization did not return tempToken") + self._send_text(400, callback_data["error"], "text/html") return - callback_data["tempToken"] = params.get("tempToken", "") + callback_data["tempToken"] = temp_token callback_data["siteId"] = params.get("siteId", "") callback_data["code"] = params.get("code", "") callback_event.set() - - redirect_url = f"{BASE_URL}/{_LOGIN_SUCCESS_PATH}" - self.send_response(302) - self.send_header("Location", redirect_url) - self.end_headers() + logger.info("[callback] 授权回调校验成功") + self._send_success_redirect() def do_POST(self): - content_length = int(self.headers.get("Content-Length", 0)) + try: + content_length = int(self.headers.get("Content-Length", 0)) + except ValueError: + self._send_text(400, "invalid content length") + return + if content_length < 0: + self._send_text(400, "invalid content length") + return if content_length > _MAX_CALLBACK_BODY_SIZE: - self.send_response(413) - self.end_headers() + self._send_text(413, "request body too large") return - body = self.rfile.read(content_length).decode("utf-8") - logger.debug("[callback] POST path=%s", self.path) - params = parse_qs(body) - params = {k: v[0] if isinstance(v, list) else v for k, v in params.items()} + body = self.rfile.read(content_length) + parsed_path = urlparse(self.path) + content_type = self.headers.get("Content-Type", "") + params = _decode_callback_params(body, content_type) + query_params = parse_qs(urlparse(self.path).query) + for key, value in _flatten_params(query_params).items(): + params.setdefault(key, value) + logger.info( + "[callback] 收到 POST:path=%s, type=%s, bytes=%d, fields=%s", + parsed_path.path, + content_type.split(";", 1)[0] or "(missing)", + len(body), + sorted(params), + ) + if sensitive_logging_enabled(): + logger.debug( + "[callback] sensitive headers=%r body=%r", + dict(self.headers.items()), + body.decode("utf-8", errors="replace"), + ) self._process_params(params) def do_GET(self): parsed = urlparse(self.path) - logger.debug("[callback] GET path=%s", parsed.path) - if parsed.path != "/callback": - self.send_response(200) - self.end_headers() - self.wfile.write(b"ok") + params = _flatten_params(parse_qs(parsed.query)) + logger.info( + "[callback] 收到 GET:path=%s, fields=%s", + parsed.path, + sorted(params), + ) + if sensitive_logging_enabled(): + logger.debug( + "[callback] sensitive headers=%r query=%r", + dict(self.headers.items()), + params, + ) + # DevEco/Huawei 的不同版本可能回调 /、/callback 或其他本地路径。 + # 只要携带了授权字段,就按回调处理,避免授权成功后永远等待。 + if "tempToken" not in params and "code" not in params: + self._send_text(200, "HapSign callback server is ready") return - params = parse_qs(parsed.query) - params = {k: v[0] if isinstance(v, list) else v for k, v in params.items()} self._process_params(params) + def do_OPTIONS(self): + logger.info( + "[callback] 收到浏览器预检:origin=%s, private-network=%s", + self.headers.get("Origin", "(missing)"), + self.headers.get("Access-Control-Request-Private-Network", "false"), + ) + self._send_headers(204) + def log_message(self, fmt, *args): - logger.debug("[http] %s - %s", self.client_address[0], fmt % args) + # BaseHTTPRequestHandler 的默认请求行可能包含 tempToken,不记录。 + logger.debug("[callback] loopback request handled") return _CallbackHandler class BrowserLogin: - """Playwright 浏览器登录。 + """系统浏览器或 Playwright 浏览器登录。 使用示例:: @@ -111,6 +256,16 @@ class BrowserLogin: temp_token = login.login("CN") """ + def __init__( + self, + browser_mode: str | None = None, + cancel_event: threading.Event | None = None, + ): + self.browser_mode = ( + browser_mode or os.environ.get("HAPSIGN_BROWSER", "system_controlled") + ).lower() + self.cancel_event = cancel_event + def login(self, country: str = "CN") -> str: """打开华为登录页,用户手动登录,拦截回调拿 tempToken。 @@ -123,40 +278,43 @@ def login(self, country: str = "CN") -> str: Raises: RuntimeError: 任意步骤失败时抛出 """ - # ── 1. 找空闲端口 ── - port = _find_free_port() - - # ── 2. 生成 CSRF code(UUID 去横线,与原始插件一致) ── + # ── 1. 生成 CSRF code(UUID 去横线,与原始插件一致) ── csrf_code = uuid.uuid4().hex - - # ── 3. 构建登录 URL ── - login_url = ( - f"{BASE_URL}/{LOGIN_AUTH_PATH}?port={port}&appid={APP_ID}&code={csrf_code}" - ) - - logger.info("[LOGIN] callback port=%d", port) - - # ── 4. 启动本地 HTTP 回调服务 ── callback_data: dict = {} callback_event = threading.Event() - handler_class = _make_callback_handler(csrf_code, callback_data, callback_event) - server = ThreadingHTTPServer((_CALLBACK_HOST, port), handler_class) + # 直接让 HTTPServer 绑定临时端口,消除“先探测、后绑定”的端口竞争窗口。 + server = ThreadingHTTPServer((_CALLBACK_HOST, 0), handler_class) + server.daemon_threads = True + server.block_on_close = False + port = server.server_address[1] + login_url = ( + f"{BASE_URL}/{LOGIN_AUTH_PATH}?port={port}&appid={APP_ID}&code={csrf_code}" + ) server_thread = threading.Thread(target=server.serve_forever, daemon=True) server_thread.start() + logger.info("[LOGIN] callback port=%d", port) logger.info("[LOGIN] callback server listening on %s:%d", _CALLBACK_HOST, port) + logger.info("[LOGIN] browser mode=%s", self.browser_mode) + if sensitive_logging_enabled(): + logger.debug("[LOGIN] sensitive login_url=%s", login_url) try: - # ── 5. 启动 Playwright 浏览器,等待回调 ── + # ── 5. 启动浏览器,等待回调 ── self._browser_login_and_wait(login_url, callback_event) finally: # ── 6. 关闭 HTTP 服务 ── server.shutdown() + server.server_close() + server_thread.join(timeout=2) # ── 7. 校验并返回 tempToken ── temp_token = callback_data.get("tempToken", "") if not temp_token: + callback_error = callback_data.get("error") + if callback_error: + raise RuntimeError(callback_error) raise RuntimeError( "Callback did not contain tempToken. Login may have been cancelled." ) @@ -167,6 +325,54 @@ def _browser_login_and_wait( self, login_url: str, callback_event: threading.Event, + ) -> None: + """按配置启动系统浏览器或 Playwright。""" + if self.browser_mode == "system": + self._system_browser_login_and_wait(login_url, callback_event) + return + if self.browser_mode in {"playwright", "system_controlled"}: + self._playwright_login_and_wait(login_url, callback_event) + return + raise RuntimeError( + f"Unsupported browser mode: {self.browser_mode}. " + "Use 'system_controlled', 'playwright' or 'system'." + ) + + def _wait_for_callback(self, callback_event: threading.Event) -> None: + deadline = time.monotonic() + _CALLBACK_TIMEOUT + while not callback_event.wait(timeout=0.1): + raise_if_cancelled(self.cancel_event) + if time.monotonic() >= deadline: + raise RuntimeError( + "Login timed out: no callback received within " + f"{_CALLBACK_TIMEOUT}s. " + "Please check your network and complete login in the browser." + ) + + def _system_browser_login_and_wait( + self, + login_url: str, + callback_event: threading.Event, + ) -> None: + """使用系统默认浏览器完成登录,不引入 Chromium 运行时。""" + raise_if_cancelled(self.cancel_event) + if not webbrowser.open(login_url, new=1, autoraise=True): + raise RuntimeError( + "Unable to open the system browser. " + "Set HAPSIGN_BROWSER=playwright to use the optional backend." + ) + logger.info("[login] 登录页面已在系统浏览器中打开") + logger.info( + "[login] 正在等待登录回调(最多 %s 秒,可处理验证码或二次验证)", + _CALLBACK_TIMEOUT, + ) + raise_if_cancelled(self.cancel_event) + self._wait_for_callback(callback_event) + + def _playwright_login_and_wait( + self, + login_url: str, + callback_event: threading.Event, ) -> None: """启动 Playwright 浏览器,导航到登录页,等待回调。 @@ -179,6 +385,8 @@ def _browser_login_and_wait( Raises: RuntimeError: Playwright 未安装、浏览器启动失败或回调超时 """ + # 打包时浏览器放在 playwright 包内,源码运行也遵循同一路径。 + os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", "0") try: from playwright.sync_api import TimeoutError as PwTimeout from playwright.sync_api import sync_playwright @@ -191,10 +399,12 @@ def _browser_login_and_wait( try: with sync_playwright() as pw: - browser = pw.chromium.launch( - headless=False, - args=["--disable-blink-features=AutomationControlled"], + browser, runtime_name = _launch_controlled_browser( + pw, + self.browser_mode, + allow_fallback=True, ) + logger.info("[login] 受控浏览器已启动:%s", runtime_name) context = browser.new_context( viewport={"width": 1280, "height": 800}, locale="zh-CN", @@ -204,31 +414,126 @@ def _browser_login_and_wait( "Chrome/120.0.0.0 Safari/537.36" ), ) + try: + context.grant_permissions( + ["local-network-access"], + origin=BASE_URL, + ) + logger.info("[login] 已授予登录页访问本地回调服务的权限") + except Exception as exc: + # 旧版 Chromium 不认识该权限;回调服务器仍提供 PNA 预检响应。 + logger.debug("[login] 无法预授予本地网络权限:%s", exc) page = context.new_page() + def log_request_failure(request) -> None: + parsed = urlparse(request.url) + safe_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}" + detail = request.url if sensitive_logging_enabled() else safe_url + logger.warning( + "[login] 浏览器请求失败:%s %s (%s)", + request.method, + detail, + request.failure, + ) + + page.on("requestfailed", log_request_failure) + page.on( + "pageerror", + lambda error: logger.warning("[login] 页面脚本错误:%s", error), + ) + # 使用 domcontentloaded,避免持续网络请求让 networkidle 超时。 try: page.goto(login_url, wait_until="domcontentloaded", timeout=60000) except PwTimeout as exc: raise RuntimeError("Login page load timed out") from exc - print("[LOGIN] Page loaded") - print("[LOGIN] Please login in the browser window...") - - print(f"[LOGIN] Waiting for callback (timeout={_CALLBACK_TIMEOUT}s)...") - print("[LOGIN] (handle captcha/2FA in browser if needed)") - - # 等待回调(浏览器保持打开,让用户手动登录) - if not callback_event.wait(timeout=_CALLBACK_TIMEOUT): - raise RuntimeError( - "Login timed out: no callback received within " - f"{_CALLBACK_TIMEOUT}s. " - f"Please check your network and complete login in the browser." - ) + logger.info("[login] 登录页面已打开,请在浏览器中完成登录") + logger.info( + "[login] 正在等待登录回调(最多 %s 秒,可处理验证码或二次验证)", + _CALLBACK_TIMEOUT, + ) + self._wait_for_callback(callback_event) browser.close() - except RuntimeError: + except (RuntimeError, OperationCancelled): raise except Exception as e: raise RuntimeError(f"Browser operation failed: {e}") from e + + +def _launch_controlled_browser( + playwright, + preferred_mode: str, + *, + allow_fallback: bool, + smoke_test: bool = False, +): + """启动受控浏览器;系统 Edge/Chrome 与内置 Chromium 可互相回退。""" + system_candidates = [ + (channel, {"channel": channel}) for channel in _CONTROLLED_SYSTEM_CHANNELS + ] + bundled_candidate = [("bundled-chromium", {})] + if preferred_mode == "system_controlled": + candidates = system_candidates + if allow_fallback: + candidates += bundled_candidate + elif preferred_mode == "playwright": + candidates = bundled_candidate + if allow_fallback: + candidates += system_candidates + else: + raise RuntimeError(f"Unsupported controlled browser mode: {preferred_mode}") + + failures: list[str] = [] + for runtime_name, launch_options in candidates: + if runtime_name == "bundled-chromium": + executable = Path(playwright.chromium.executable_path) + if not executable.is_file(): + failures.append("bundled-chromium: runtime not bundled") + continue + args = ["--disable-blink-features=AutomationControlled"] + if smoke_test: + args.append("--headless=new") + try: + browser = playwright.chromium.launch( + headless=False, + args=args, + **launch_options, + ) + if runtime_name != ( + "bundled-chromium" + if preferred_mode == "playwright" + else _CONTROLLED_SYSTEM_CHANNELS[0] + ): + logger.warning( + "[login] 首选浏览器不可用,已回退到 %s", + runtime_name, + ) + return browser, runtime_name + except Exception as exc: + failures.append(f"{runtime_name}: {exc}") + + raise RuntimeError( + "无法启动受控浏览器。请安装 Microsoft Edge/Google Chrome," + "或使用包含内置 Chromium 的兼容包。详情:" + " | ".join(failures) + ) + + +def playwright_browser_smoke_test(browser_mode: str = "playwright") -> None: + """严格启动指定受控浏览器,用于验证便携包运行时。""" + os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", "0") + try: + from playwright.sync_api import sync_playwright + except ImportError as exc: + raise RuntimeError("Playwright 未安装") from exc + + with sync_playwright() as pw: + browser, _ = _launch_controlled_browser( + pw, + browser_mode, + allow_fallback=False, + smoke_test=True, + ) + browser.close() diff --git a/hapsign/pipeline.py b/hapsign/pipeline.py index f423d0e..40738b8 100644 --- a/hapsign/pipeline.py +++ b/hapsign/pipeline.py @@ -15,7 +15,11 @@ import json import logging import os +import tempfile +import threading +import traceback import zipfile +from collections.abc import Callable from datetime import date from hapsign.api.capability_api import CapabilityAPI @@ -23,23 +27,28 @@ from hapsign.api.client import HuaweiSignClient, TokenExpiredError from hapsign.api.device_api import DeviceAPI from hapsign.api.provision_api import ProvisionAPI +from hapsign.cancellation import OperationCancelled, raise_if_cancelled from hapsign.config import ( ACL_PERMISSION_WHITELIST, DEVICE_TYPE_PHONE, KEY_ALIAS, KEYSTORE_PASSWORD, ) +from hapsign.diagnostics import redact_sensitive_text from hapsign.login.browser_login import BrowserLogin from hapsign.models import AppBriefInfo, CertResult, ProvisionResult, TokenInfo +from hapsign.signing.hap_inspect import is_hap_signed from hapsign.signing.hap_signer import HapSigner from hapsign.signing.installer import Installer from hapsign.signing.keytool_util import KeytoolUtil +from hapsign.token import secure_token_cache from hapsign.token.token_exchange import TokenExchange logger = logging.getLogger(__name__) # 签名文件根目录(相对于项目根) SIGNING_FILES_DIR = "signing_files" +SIGNED_HAP_MANIFEST = ".hapsign-signed-haps.json" class SignPipeline: @@ -52,6 +61,10 @@ class SignPipeline: bundle_name="com.example.myapp", ) pipeline.run() + + 生命周期约定:SignPipeline 是一次性对象,一个实例只应调用一次 ``run()``; + 再次执行请新建实例。运行期状态(_team_id、_app_info、_cert_result、 + 签名材料路径等)在构造函数中显式初始化。 """ def __init__( @@ -61,9 +74,15 @@ def __init__( country: str = "CN", device_type: str = DEVICE_TYPE_PHONE, work_dir: str = "", + state_dir: str = "", enable_capability: bool = False, force_refresh_token: bool = False, force_refresh_signing: bool = False, + browser_mode: str = "system", + signed_output_dir: str = "", + keep_signed_hap: bool = True, + cancel_event: threading.Event | None = None, + progress_callback: Callable[[int, str], None] | None = None, ): self.hap_path = hap_path self.bundle_name = bundle_name @@ -72,16 +91,23 @@ def __init__( self.enable_capability = enable_capability self.force_refresh_token = force_refresh_token self.force_refresh_signing = force_refresh_signing + self.browser_mode = browser_mode + self.keep_signed_hap = keep_signed_hap + self._temporary_signed_dir: tempfile.TemporaryDirectory | None = None + self.cancel_event = cancel_event + self.progress_callback = progress_callback + self.state_dir = state_dir or SIGNING_FILES_DIR if work_dir: self.work_dir = work_dir else: - self.work_dir = os.path.join(SIGNING_FILES_DIR, bundle_name) + self.work_dir = os.path.join(self.state_dir, bundle_name) + self.signed_output_dir = signed_output_dir or self.work_dir os.makedirs(self.work_dir, exist_ok=True) self.keystore_password = KEYSTORE_PASSWORD self._metadata_path = os.path.join(self.work_dir, "metadata.json") - self._token_cache_path = os.path.join(SIGNING_FILES_DIR, ".token_cache.json") + self._token_cache_path = os.path.join(self.state_dir, ".token_cache.json") - self._token_exchange = TokenExchange() + self._token_exchange = TokenExchange(cancel_event=cancel_event) self._token_info: TokenInfo | None = None self._client: HuaweiSignClient | None = None self._cert_api: CertAPI | None = None @@ -89,27 +115,82 @@ def __init__( self._provision_api: ProvisionAPI | None = None self._capability_api: CapabilityAPI | None = None self._token_from_cache = False + self._udid = "" + self._installer: Installer | None = None + + # 运行期状态:全部显式初始化,避免 hasattr 探测和未初始化属性。 + self._team_id = "" + self._app_info: AppBriefInfo | None = None + self._cert_result: CertResult | None = None + self._p12_path = "" + self._cer_path = "" + self._p7b_path = "" + self._csr_path = "" + self._csr_content = "" + self._signed_hap_path = "" + + def _check_cancelled(self) -> None: + raise_if_cancelled(self.cancel_event) + + def _emit_progress(self, value: int, label: str) -> None: + if self.progress_callback is not None: + self.progress_callback(value, label) # ── Token 缓存 ────────────────────────────────────────────── def _load_token_cache(self) -> dict | None: """加载当天的 token 缓存。 - 条件:缓存存在、creation_date 是今天。 + 条件:缓存存在、creation_date 是今天;缓存可能是 DPAPI 加密格式或旧版 + 明文 JSON。明文缓存首次读取时安全迁移为加密格式;解密失败视为无缓存, + 回退重新登录。 """ if not os.path.exists(self._token_cache_path): return None try: - with open(self._token_cache_path, encoding="utf-8") as f: - cache = json.load(f) - except (json.JSONDecodeError, OSError): + with open(self._token_cache_path, "rb") as f: + raw = f.read() + except OSError: + return None + + if secure_token_cache.is_encrypted(raw): + try: + payload = secure_token_cache.decrypt(raw) + except secure_token_cache.DecryptError as exc: + logger.warning( + "[cache] token 缓存解密失败,将重新登录: %s", + redact_sensitive_text(exc), + ) + return None + try: + cache = json.loads(payload) + except (json.JSONDecodeError, UnicodeDecodeError, TypeError): + logger.warning("[cache] token 缓存内容损坏,将重新登录") + return None + legacy = False + else: + try: + cache = json.loads(raw) + except (json.JSONDecodeError, UnicodeDecodeError, TypeError): + return None + legacy = True + + if not isinstance(cache, dict): + logger.warning("[cache] token 缓存格式无效,将重新登录") return None if cache.get("creation_date") != date.today().isoformat(): logger.info("[cache] token 缓存非今日,跳过") return None - if not cache.get("access_token") or not cache.get("user_id"): + if ( + not cache.get("access_token") + or not cache.get("user_id") + or not cache.get("jwt_token") + ): return None + if legacy: + # 旧版明文缓存:安全迁移为加密格式,失败不影响本次使用。 + self._write_token_cache(cache) return cache def _save_token_cache(self) -> None: @@ -126,13 +207,36 @@ def _save_token_cache(self) -> None: "real_name": self._token_info.real_name, "jwt_token": self._token_info.jwt_token, } - os.makedirs(SIGNING_FILES_DIR, exist_ok=True) - with open(self._token_cache_path, "w", encoding="utf-8") as f: - json.dump(cache, f, indent=2, ensure_ascii=False) + self._write_token_cache(cache) + + def _write_token_cache(self, cache: dict) -> None: + """把 token 缓存写入磁盘(DPAPI 加密;原子替换,失败保留原缓存)。""" + os.makedirs(self.state_dir, exist_ok=True) + tmp_path = self._token_cache_path + ".tmp" try: - os.chmod(self._token_cache_path, 0o600) + payload = json.dumps(cache, ensure_ascii=False).encode("utf-8") + data = secure_token_cache.protect(payload) + with open(tmp_path, "wb") as f: + f.write(data) + try: + os.chmod(tmp_path, 0o600) + except OSError as exc: + logger.debug( + "无法限制 token 缓存文件权限: %s", + redact_sensitive_text(exc), + ) + os.replace(tmp_path, self._token_cache_path) except OSError as exc: - logger.debug("无法限制 token 缓存文件权限: %s", exc) + # 写入或替换失败时保留原缓存,不留下会被当成正式缓存读取的半截文件。 + try: + os.remove(tmp_path) + except OSError: + pass + logger.warning( + "保存 token 缓存失败,保留原缓存: %s", + redact_sensitive_text(exc), + ) + return logger.info("[cache] token 缓存已保存: %s", self._token_cache_path) def _init_client_from_cache(self, cache: dict) -> None: @@ -149,6 +253,7 @@ def _init_client_from_cache(self, cache: dict) -> None: self._client = HuaweiSignClient( access_token=self._token_info.access_token, uid=self._token_info.user_id, + cancel_event=self.cancel_event, ) self._cert_api = CertAPI(self._client) self._device_api = DeviceAPI(self._client) @@ -172,7 +277,11 @@ def _load_cached_metadata(self) -> dict | None: try: with open(self._metadata_path, encoding="utf-8") as f: meta = json.load(f) - except (json.JSONDecodeError, OSError): + except (json.JSONDecodeError, UnicodeDecodeError, OSError): + return None + + if not isinstance(meta, dict): + logger.warning("[cache] 签名文件元数据格式无效,将重新申请") return None if meta.get("creation_date") != date.today().isoformat(): @@ -214,9 +323,22 @@ def _save_metadata( # ── 主流程 ────────────────────────────────────────────────── def run(self) -> bool: + """执行流程,并清理由本次任务启动的 HDC server。""" + try: + self._check_cancelled() + self._emit_progress(2, "正在准备") + result = self._run_pipeline() + if result: + self._emit_progress(100, "安装完成") + return result + finally: + self._close_installer() + self._cleanup_temporary_signed_hap() + + def _run_pipeline(self) -> bool: """执行签名安装流程,优先使用缓存。 - 强制刷新逻辑: + 强制刷新逻辑(全部是本次运行的局部决策,不改写构造参数): - force_refresh_token: 清除 token 缓存,强制重新登录 - force_refresh_signing: 清除签名文件缓存,强制重新申请 - 刷新 token 时自动连带刷新签名文件(否则只刷新 token 无意义) @@ -224,14 +346,26 @@ def run(self) -> bool: Returns: 全部步骤成功返回 True,任一步骤失败返回 False。 """ - # 强制刷新 token 时连带刷新签名文件 + # 所有路径都先确认设备可用,避免登录、申请证书或签名完成后才发现 + # HDC 无法安装。检测得到的 UDID 也会在注册设备时直接复用。 + if not self._run_steps([("检测设备连接", self._step_check_device)]): + return False + + # 已签名包:跳过登录 / 申请 / 签名,直接安装原文件 + if is_hap_signed(self.hap_path): + logger.info("[sign] 检测到已签名 HAP,跳过签名流程,直接安装") + self._signed_hap_path = self.hap_path + steps = [("安装 hap 到设备", self._step_install)] + return self._run_steps(steps) + + # 强制刷新 token 时连带刷新签名文件(仅本次运行,不改写实例属性) + refresh_signing = self.force_refresh_signing if self.force_refresh_token: self._clear_token_cache() logger.info("[cache] 强制刷新 token 缓存") - # token 刷新后签名文件也必须重新申请 - self.force_refresh_signing = True + refresh_signing = True - if not self.force_refresh_signing: + if not refresh_signing: signing_cached = self._load_cached_metadata() else: signing_cached = None @@ -258,10 +392,18 @@ def run(self) -> bool: else: # 没有缓存 token,需要登录 try: + self._emit_progress(18, "等待华为账号授权") self._step_login() + self._emit_progress(30, "正在完成登录") self._step_exchange_token() + except OperationCancelled: + raise except Exception as e: - logger.error("x 登录失败: %s", e) + logger.error("x 登录失败: %s", redact_sensitive_text(e)) + logger.debug( + "登录失败调用栈:\n%s", + redact_sensitive_text(traceback.format_exc()), + ) return False steps = [ @@ -275,28 +417,65 @@ def run(self) -> bool: if self.enable_capability: steps.insert(0, ("查询应用信息", self._step_get_app_info)) + return self._run_steps(steps) + + def _run_steps(self, steps: list) -> bool: + """按顺序执行步骤,处理 token 失效重试。""" + progress_by_step = { + "检测设备连接": 7, + "查询应用信息": 38, + "生成密钥对和 CSR": 40, + "申请证书": 52, + "注册调试设备": 64, + "创建调试 Profile": 75, + "签名 hap": 86, + "安装 hap 到设备": 95, + } for name, step in steps: + self._check_cancelled() + self._emit_progress(progress_by_step.get(name, 10), name) logger.info("> %s ...", name) try: step() + self._check_cancelled() except TokenExpiredError as e: # 只有 token 真正失效才回退到重新登录 logger.warning( "x %s failed: token 已失效 (%s),回退到重新登录", name, - e, + redact_sensitive_text(e), ) self._token_from_cache = False self._clear_token_cache() try: + self._emit_progress(18, "登录已失效,等待重新授权") self._step_login() + self._emit_progress(30, "正在刷新登录") self._step_exchange_token() step() + except OperationCancelled: + raise except Exception as retry_err: - logger.error("x %s failed after retry: %s", name, retry_err) + logger.error( + "x %s failed after retry: %s", + name, + redact_sensitive_text(retry_err), + ) + logger.debug( + "%s 重试失败调用栈:\n%s", + name, + redact_sensitive_text(traceback.format_exc()), + ) return False + except OperationCancelled: + raise except Exception as e: - logger.error("x %s failed: %s", name, e) + logger.error("x %s failed: %s", name, redact_sensitive_text(e)) + logger.debug( + "%s 失败调用栈:\n%s", + name, + redact_sensitive_text(traceback.format_exc()), + ) return False logger.info("+ %s done", name) return True @@ -305,7 +484,10 @@ def run(self) -> bool: def _step_login(self) -> None: """Playwright 浏览器登录,用户手动输入账号密码,获取 tempToken。""" - login = BrowserLogin() + login = BrowserLogin( + browser_mode=self.browser_mode, + cancel_event=self.cancel_event, + ) self._temp_token = login.login(self.country) def _step_exchange_token(self) -> None: @@ -320,6 +502,7 @@ def _step_exchange_token(self) -> None: self._client = HuaweiSignClient( access_token=token_info.access_token, uid=token_info.user_id, + cancel_event=self.cancel_event, ) self._cert_api = CertAPI(self._client) self._device_api = DeviceAPI(self._client) @@ -342,7 +525,7 @@ def _step_get_app_info(self) -> None: self._team_id = team_id logger.info("Team ID: %s", team_id) - self._app_info: AppBriefInfo | None = self._with_refresh( + self._app_info = self._with_refresh( self._capability_api.get_app_brief_info, team_id, self.bundle_name ) if self._app_info is None: @@ -376,7 +559,7 @@ def _step_generate_keypair(self) -> None: self.work_dir, f"auto_debug_{self.bundle_name}.csr" ) - keytool = KeytoolUtil() + keytool = KeytoolUtil(cancel_event=self.cancel_event) keytool.generate_keypair(self._p12_path, KEY_ALIAS, self.keystore_password) self._csr_content = keytool.generate_csr( self._p12_path, KEY_ALIAS, self.keystore_password, self._csr_path @@ -393,13 +576,15 @@ def _step_add_certificate(self) -> None: 5. 下载 .cer 文件 """ assert self._cert_api is not None - if not hasattr(self, "_team_id") or self._team_id is None: + if not self._team_id: self._team_id = self._with_refresh(self._cert_api.get_team_id) team_id = self._team_id logger.info("Team ID: %s", team_id) try: self._with_refresh(self._cert_api.sign_agreement) + except OperationCancelled: + raise except Exception: logger.debug("Agreement signing skipped (may already be signed)") @@ -414,6 +599,8 @@ def _step_add_certificate(self) -> None: logger.info("Deleted old certificate (id=%s)", old_id) except ValueError: logger.debug("No existing certificate to delete") + except OperationCancelled: + raise except Exception: logger.debug("Delete old certificate failed, continuing") @@ -447,9 +634,8 @@ def _step_add_certificate(self) -> None: def _step_register_device(self) -> None: """获取设备 UDID 并注册到华为平台,然后查询设备 ID。""" assert self._device_api is not None - installer = Installer() - self._udid = installer.get_udid() - logger.info("已读取设备 UDID") + if not self._udid: + self._udid = self._get_installer().get_udid() self._with_refresh( self._device_api.add_device, @@ -525,8 +711,13 @@ def _step_create_provision(self) -> None: result.provision_id, ) logger.info("已删除远端 Profile (id=%s)", result.provision_id) + except OperationCancelled: + raise except Exception as e: - logger.debug("删除远端 Profile 失败(不影响流程): %s", e) + logger.debug( + "删除远端 Profile 失败(不影响流程): %s", + redact_sensitive_text(e), + ) self._save_metadata( p12_path=self._p12_path, @@ -540,29 +731,149 @@ def _step_create_provision(self) -> None: def _step_sign_hap(self) -> None: """用 hap-sign-tool 对 hap 包签名。""" hap_basename = os.path.splitext(os.path.basename(self.hap_path))[0] - self._signed_hap_path = os.path.join( - self.work_dir, f"{hap_basename}_signed.hap" - ) + if self.keep_signed_hap: + os.makedirs(self.signed_output_dir, exist_ok=True) + output_path = os.path.join( + self.signed_output_dir, + f".{hap_basename}.signing.tmp.hap", + ) + else: + self._temporary_signed_dir = tempfile.TemporaryDirectory( + prefix="hapsign-signed-" + ) + output_path = os.path.join( + self._temporary_signed_dir.name, + f"{hap_basename}_signed.hap", + ) - signer = HapSigner() - signer.sign_hap( - self.hap_path, - self._cer_path, - self._p7b_path, - self._p12_path, - KEY_ALIAS, - self.keystore_password, - self._signed_hap_path, - ) + signer = HapSigner(cancel_event=self.cancel_event) + try: + signer.sign_hap( + self.hap_path, + self._cer_path, + self._p7b_path, + self._p12_path, + KEY_ALIAS, + self.keystore_password, + output_path, + ) + except Exception: + if self.keep_signed_hap: + try: + os.remove(output_path) + except FileNotFoundError: + pass + raise + if self.keep_signed_hap: + final_path = os.path.join( + self.signed_output_dir, + f"{hap_basename}_signed.hap", + ) + # 原子发布新文件;签名失败不会破坏上一份同名产物或其他 HAP。 + os.replace(output_path, final_path) + self._cleanup_previous_signed_haps(final_path) + self._save_signed_hap_manifest(final_path) + self._signed_hap_path = final_path + else: + self._signed_hap_path = output_path logger.info("签名后 hap: %s", self._signed_hap_path) + def _cleanup_previous_signed_haps(self, final_path: str) -> None: + """只清理 manifest 记录的旧产物,绝不按扩展名删除用户文件。""" + manifest_path = os.path.join(self.signed_output_dir, SIGNED_HAP_MANIFEST) + try: + with open(manifest_path, encoding="utf-8") as manifest_file: + manifest = json.load(manifest_file) + except (json.JSONDecodeError, UnicodeDecodeError, OSError): + return + + if not isinstance(manifest, dict): + return + generated_haps = manifest.get("generated_haps") + if not isinstance(generated_haps, list): + return + + final_resolved = os.path.normcase(os.path.realpath(os.path.abspath(final_path))) + input_resolved = os.path.normcase( + os.path.realpath(os.path.abspath(self.hap_path)) + ) + for name in generated_haps: + if ( + not isinstance(name, str) + or os.path.basename(name) != name + or os.path.splitext(name)[1].lower() != ".hap" + ): + continue + candidate = os.path.join(self.signed_output_dir, name) + candidate_resolved = os.path.normcase( + os.path.realpath(os.path.abspath(candidate)) + ) + if candidate_resolved in {final_resolved, input_resolved}: + continue + if not os.path.isfile(candidate): + continue + try: + os.remove(candidate) + except OSError as exc: + logger.warning( + "无法删除旧的签名 HAP %s:%s", + candidate, + redact_sensitive_text(exc), + ) + + def _save_signed_hap_manifest(self, final_path: str) -> None: + """原子保存本次生成的签名 HAP 清单。""" + manifest_path = os.path.join(self.signed_output_dir, SIGNED_HAP_MANIFEST) + temporary_path = f"{manifest_path}.tmp" + manifest = { + "version": 1, + "generated_haps": [os.path.basename(final_path)], + } + try: + with open(temporary_path, "w", encoding="utf-8") as manifest_file: + json.dump(manifest, manifest_file, indent=2, ensure_ascii=False) + os.replace(temporary_path, manifest_path) + except OSError as exc: + try: + os.remove(temporary_path) + except OSError: + pass + logger.warning( + "无法保存签名 HAP 清单 %s:%s", + manifest_path, + redact_sensitive_text(exc), + ) + def _step_install(self) -> None: """hdc install 安装签名后的 hap 到设备。""" - installer = Installer() - installer.install(self._signed_hap_path) + self._get_installer().install(self._signed_hap_path) + + def _step_check_device(self) -> None: + """确认有且仅有一台已授权、可通过 HDC 访问的设备。""" + self._udid = self._get_installer().get_udid() + logger.info("已检测到可用设备(UDID 尾号 %s)", self._udid[-6:]) # ── 工具方法 ──────────────────────────────────────────────── + def _get_installer(self) -> Installer: + if self._installer is None: + self._installer = Installer(cancel_event=self.cancel_event) + return self._installer + + def _cleanup_temporary_signed_hap(self) -> None: + if self._temporary_signed_dir is None: + return + try: + self._temporary_signed_dir.cleanup() + finally: + self._temporary_signed_dir = None + + def _close_installer(self) -> None: + if self._installer is None: + return + self._installer.close() + self._installer = None + def _extract_permissions(self) -> list[str]: """从 hap 的 module.json 提取 requestPermissions,按 ACL 白名单过滤。 @@ -589,14 +900,19 @@ def _extract_permissions(self) -> list[str]: "非 ACL 权限(普通权限,由系统授予): %s", skipped ) return filtered + except OperationCancelled: + raise except Exception as e: - logger.debug("提取权限列表失败: %s", e) + logger.debug("提取权限列表失败: %s", redact_sensitive_text(e)) return [] def _with_refresh(self, func, *args, **kwargs): """执行 API 调用,token 失效时自动刷新并重试一次。""" + self._check_cancelled() try: - return func(*args, **kwargs) + result = func(*args, **kwargs) + self._check_cancelled() + return result except TokenExpiredError: logger.warning("Token 失效,尝试刷新...") new_token = self._token_exchange.refresh_access_token( @@ -605,4 +921,7 @@ def _with_refresh(self, func, *args, **kwargs): self._client.access_token = new_token self._token_info.access_token = new_token self._save_token_cache() - return func(*args, **kwargs) + self._check_cancelled() + result = func(*args, **kwargs) + self._check_cancelled() + return result diff --git a/hapsign/runtime.py b/hapsign/runtime.py new file mode 100644 index 0000000..bed98e6 --- /dev/null +++ b/hapsign/runtime.py @@ -0,0 +1,184 @@ +"""运行目录、用户数据目录与外部工具链发现。 + +该模块不依赖 GUI,CLI、桌面版和 PyInstaller 便携版共用同一套规则。 +所有平台差异集中在这里,避免业务流程散落 Windows 专用路径。 +""" + +from __future__ import annotations + +import os +import platform +import sys +from dataclasses import dataclass +from pathlib import Path + +APP_NAME = "HapSign" + + +def platform_tag() -> str: + """返回便携资源目录使用的平台标识。""" + system = platform.system().lower() + if system == "darwin": + return "macos" + if system == "windows": + return "windows" + return "linux" + + +def application_dir() -> Path: + """返回应用所在目录;冻结后为可执行文件目录。""" + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return Path(__file__).resolve().parent.parent + + +def resource_dir() -> Path: + """返回便携版资源根目录,可通过环境变量覆盖。""" + override = os.environ.get("HAPSIGN_RESOURCE_DIR") + if override: + return Path(override).expanduser().resolve() + return application_dir() / "resources" + + +def app_data_dir() -> Path: + """返回应用数据根目录;默认与程序可执行文件位于同一目录。""" + override = os.environ.get("HAPSIGN_DATA_DIR") + if override: + return Path(override).expanduser().resolve() + return application_dir() + + +@dataclass(frozen=True) +class ToolchainPaths: + """签名与安装所需外部工具路径。""" + + java: Path + keytool: Path + hap_sign_tool: Path + hdc: Path + source: str + + def missing(self, *, require_signing: bool = True) -> list[str]: + """返回缺少的工具;已签名 HAP 只要求 HDC。""" + required = [("HDC", self.hdc)] + if require_signing: + required.extend( + [ + ("Java", self.java), + ("keytool", self.keytool), + ("hap-sign-tool.jar", self.hap_sign_tool), + ] + ) + return [f"{name}: {path}" for name, path in required if not path.is_file()] + + +def _executable_name(name: str) -> str: + return f"{name}.exe" if platform.system() == "Windows" else name + + +def _from_portable_resources() -> ToolchainPaths: + root = resource_dir() / "toolchain" / platform_tag() + # 新版公开工具链使用中性的 runtime/;旧便携包仍兼容 jbr/。 + runtime_root = root / "runtime" + if not runtime_root.is_dir(): + runtime_root = root / "jbr" + return ToolchainPaths( + java=runtime_root / "bin" / _executable_name("java"), + keytool=runtime_root / "bin" / _executable_name("keytool"), + hap_sign_tool=root / "lib" / "hap-sign-tool.jar", + hdc=root / "bin" / _executable_name("hdc"), + source="portable", + ) + + +def _from_deveco_home(home: Path, source: str) -> ToolchainPaths: + toolchains = home / "sdk" / "default" / "openharmony" / "toolchains" + # DevEco Studio 的 macOS 应用包把 JBR 放在 Contents/Home 下;Windows + # 和 Linux 的发行版则直接使用 jbr/bin。 + runtime_root = home / "jbr" + if platform.system() == "Darwin": + runtime_root = runtime_root / "Contents" / "Home" + return ToolchainPaths( + java=runtime_root / "bin" / _executable_name("java"), + keytool=runtime_root / "bin" / _executable_name("keytool"), + hap_sign_tool=toolchains / "lib" / "hap-sign-tool.jar", + hdc=toolchains / _executable_name("hdc"), + source=source, + ) + + +def _deveco_home_candidates() -> list[tuple[Path, str]]: + candidates: list[tuple[Path, str]] = [] + configured = os.environ.get("DEVECO_HOME") + if configured: + candidates.append((Path(configured).expanduser(), "DEVECO_HOME")) + + system = platform.system() + if system == "Windows": + program_files = Path(os.environ.get("ProgramFiles", r"C:\Program Files")) + candidates.extend( + [ + (program_files / "Huawei" / "DevEco Studio", "DevEco Studio"), + ( + Path(r"D:\Program Files\Huawei\DevEco Studio"), + "DevEco Studio", + ), + ] + ) + elif system == "Darwin": + candidates.extend( + [ + ( + Path("/Applications/DevEco-Studio.app/Contents"), + "DevEco Studio", + ), + ( + Path("/Applications/DevEco Studio.app/Contents"), + "DevEco Studio", + ), + ] + ) + else: + candidates.extend( + [ + (Path("/opt/DevEco-Studio"), "DevEco Studio"), + (Path.home() / "DevEco-Studio", "DevEco Studio"), + ] + ) + return candidates + + +def _with_direct_overrides(paths: ToolchainPaths) -> ToolchainPaths: + def override(name: str, current: Path) -> Path: + value = os.environ.get(name) + return Path(value).expanduser() if value else current + + return ToolchainPaths( + java=override("HAPSIGN_JAVA", paths.java), + keytool=override("HAPSIGN_KEYTOOL", paths.keytool), + hap_sign_tool=override("HAPSIGN_HAP_SIGN_TOOL", paths.hap_sign_tool), + hdc=override("HAPSIGN_HDC", paths.hdc), + source=paths.source, + ) + + +def discover_toolchain() -> ToolchainPaths: + """优先发现便携资源,其次查找本机 DevEco Studio。""" + candidates = [_from_portable_resources()] + candidates.extend( + _from_deveco_home(home, source) for home, source in _deveco_home_candidates() + ) + + overridden = [_with_direct_overrides(item) for item in candidates] + complete = next((item for item in overridden if not item.missing()), None) + if complete is not None: + return complete + + # 未完整安装时返回现存文件最多的一组,让错误信息指向最可能的安装位置。 + return max( + overridden, + key=lambda item: sum( + path.is_file() + for path in (item.java, item.keytool, item.hap_sign_tool, item.hdc) + ), + ) diff --git a/hapsign/settings.py b/hapsign/settings.py new file mode 100644 index 0000000..e8b9b11 --- /dev/null +++ b/hapsign/settings.py @@ -0,0 +1,115 @@ +"""HapSign 桌面配置与数据目录解析。""" + +from __future__ import annotations + +import json +import os +import platform +from dataclasses import asdict, dataclass +from pathlib import Path + +from hapsign.runtime import APP_NAME, application_dir + +LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR"} +STORAGE_MODES = {"program", "appdata", "custom"} +BROWSER_MODES = {"system_controlled", "playwright", "system"} + + +@dataclass(frozen=True) +class AppSettings: + """可持久化的桌面设置。""" + + log_level: str = "INFO" + signing_storage: str = "program" + custom_signing_dir: str = "" + browser_mode: str = "system_controlled" + log_sensitive_data: bool = False + keep_signed_hap: bool = True + + +def config_file_path() -> Path: + """配置文件默认与可执行文件放在一起,保持便携语义。""" + override = os.environ.get("HAPSIGN_CONFIG_FILE") + if override: + return Path(override).expanduser().resolve() + return application_dir() / "hapsign-config.json" + + +def user_local_data_dir() -> Path: + """返回当前平台的用户本地应用数据目录。""" + system = platform.system() + if system == "Windows": + root = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) + return root / APP_NAME + if system == "Darwin": + return Path.home() / "Library" / "Application Support" / APP_NAME + root = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) + return root / APP_NAME + + +def signing_files_dir(settings: AppSettings) -> Path: + """按设置返回签名材料、缓存和签名后 HAP 的目录。""" + override = os.environ.get("HAPSIGN_DATA_DIR") + if override: + return Path(override).expanduser().resolve() / "signing_files" + if settings.signing_storage == "appdata": + return user_local_data_dir() / "signing_files" + if settings.signing_storage == "custom" and settings.custom_signing_dir: + return Path(settings.custom_signing_dir).expanduser().resolve() + return application_dir() / "signing_files" + + +def log_directory() -> Path: + """日志固定优先写入程序目录,便于便携迁移和问题定位。""" + return application_dir() / "logs" + + +def signed_haps_dir() -> Path: + """最终签名 HAP 始终位于程序目录,不随签名材料设置变化。""" + return application_dir() / "signed_haps" + + +def _validated(data: dict) -> AppSettings: + log_level = str(data.get("log_level", "INFO")).upper() + if log_level not in LOG_LEVELS: + log_level = "INFO" + storage = str(data.get("signing_storage", "program")).lower() + if storage not in STORAGE_MODES: + storage = "program" + browser_mode = str(data.get("browser_mode", "system_controlled")).lower() + if browser_mode not in BROWSER_MODES: + browser_mode = "system_controlled" + keep_signed_hap = data.get("keep_signed_hap", True) + if not isinstance(keep_signed_hap, bool): + keep_signed_hap = True + return AppSettings( + log_level=log_level, + signing_storage=storage, + custom_signing_dir=str(data.get("custom_signing_dir", "")), + browser_mode=browser_mode, + log_sensitive_data=data.get("log_sensitive_data", False) is True, + keep_signed_hap=keep_signed_hap, + ) + + +def load_settings() -> AppSettings: + """读取 JSON 设置;缺失或损坏时使用安全默认值。""" + path = config_file_path() + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return AppSettings() + return _validated(data) if isinstance(data, dict) else AppSettings() + + +def save_settings(settings: AppSettings) -> Path: + """原子保存 JSON 设置并返回文件路径。""" + path = config_file_path() + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text( + json.dumps(asdict(settings), ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + os.replace(temporary, path) + return path diff --git a/hapsign/signing/__init__.py b/hapsign/signing/__init__.py index 9d73cde..7872dcf 100644 --- a/hapsign/signing/__init__.py +++ b/hapsign/signing/__init__.py @@ -1,7 +1,8 @@ """Signing 模块 —— 密钥生成、HAP 签名与安装。""" +from .hap_inspect import is_hap_signed from .hap_signer import HapSigner from .installer import Installer from .keytool_util import KeytoolUtil -__all__ = ["KeytoolUtil", "HapSigner", "Installer"] +__all__ = ["KeytoolUtil", "HapSigner", "Installer", "is_hap_signed"] diff --git a/hapsign/signing/hap_inspect.py b/hapsign/signing/hap_inspect.py new file mode 100644 index 0000000..07eef2e --- /dev/null +++ b/hapsign/signing/hap_inspect.py @@ -0,0 +1,188 @@ +"""检测 HAP 是否已包含签名块。 + +对齐 OpenHarmony ``developtools_hapsigner``: +- Java ``ZipUtils.findEocdInHap`` / ``HapUtils.findHapSigningBlock`` +- C++ ``HapSigningBlockUtils::FindHapSigningBlock`` / ``CheckSignBlockHead`` + +仅做 presence 检测(结构上是否存在 Signing Block),不做 CMS / 证书链验签。 +完整验签请使用 ``hap-sign-tool verify-app``,不适合 pipeline 门禁。 +""" + +from __future__ import annotations + +import struct +from pathlib import Path +from typing import BinaryIO + +# HapUtils / HapSignerBlockUtils 常量 +HAP_SIGN_SCHEME_V3_BLOCK_VERSION = 3 +HAP_SIG_BLOCK_MAGIC_LO_V2 = 0x2067695320504148 +HAP_SIG_BLOCK_MAGIC_HI_V2 = 0x3234206B636F6C42 +HAP_SIG_BLOCK_MAGIC_LO_V3 = 0x676973207061683C +HAP_SIG_BLOCK_MAGIC_HI_V3 = 0x3E6B636F6C62206E +HAP_SIG_BLOCK_HEADER_SIZE = 32 +OPTIONAL_SUB_BLOCK_HEADER_SIZE = 12 +# C++ HapSigningBlockUtils +MAX_BLOCK_COUNT = 10 +MAX_HAP_SIGN_BLOCK_SIZE = 1024 * 1024 * 1024 +# Java HapUtils.verifySignBlock: size <= Integer.MAX_VALUE - 8 +_JAVA_MAX_SIG_BLOCK_SIZE = 0x7FFFFFFF - 8 + +# ZipUtils +ZIP_EOCD_SEGMENT_MIN_SIZE = 22 +ZIP_EOCD_COMMENT_LENGTH_OFFSET = 20 +ZIP_CD_SIZE_OFFSET_IN_EOCD = 12 +ZIP_CD_OFFSET_IN_EOCD = 16 +ZIP_EOCD_MAX_COMMENT_SIZE = 0xFFFF +ZIP64_EOCD_LOCATOR_SIZE = 20 +ZIP64_EOCD_LOCATOR_SIG = 0x07064B50 +ZIP64_UINT32_SENTINEL = 0xFFFFFFFF +_EOCD_SIGNATURE = b"PK\x05\x06" + + +def _find_eocd_in_window(window: bytes) -> int | None: + """在搜索窗口内定位 EOCD,用 comment length 自洽性排除伪签名。""" + search_size = len(window) + if search_size < ZIP_EOCD_SEGMENT_MIN_SIZE: + return None + relative = window.rfind(_EOCD_SIGNATURE) + while relative >= 0: + if relative + ZIP_EOCD_SEGMENT_MIN_SIZE <= search_size: + comment_size = struct.unpack_from( + " int | None: + """按给定最大 comment 长度从文件末尾搜索 EOCD。""" + if file_size < ZIP_EOCD_SEGMENT_MIN_SIZE: + return None + if max_comment_size < 0 or max_comment_size > ZIP_EOCD_MAX_COMMENT_SIZE: + raise ValueError(f"max_comment_size out of range: {max_comment_size}") + + final_max_comment = min(max_comment_size, file_size - ZIP_EOCD_SEGMENT_MIN_SIZE) + search_size = final_max_comment + ZIP_EOCD_SEGMENT_MIN_SIZE + search_start = file_size - search_size + hap_file.seek(search_start) + window = hap_file.read(search_size) + if len(window) != search_size: + return None + relative = _find_eocd_in_window(window) + if relative is None: + return None + return search_start + relative + + +def _find_eocd_offset_two_phase(hap_file: BinaryIO, file_size: int) -> int | None: + """对齐 ZipUtils.findEocdInHap:先假设无 comment,再扩大到 65535。""" + eocd = _find_eocd_offset(hap_file, file_size, 0) + if eocd is not None: + return eocd + return _find_eocd_offset(hap_file, file_size, ZIP_EOCD_MAX_COMMENT_SIZE) + + +def _has_zip64_eocd_locator(hap_file: BinaryIO, eocd_offset: int) -> bool: + """对齐 ZipUtils.checkZip64EoCDLocatorIsPresent。""" + locator_pos = eocd_offset - ZIP64_EOCD_LOCATOR_SIZE + if locator_pos < 0: + return False + hap_file.seek(locator_pos) + sig = hap_file.read(4) + if len(sig) != 4: + return False + return struct.unpack(" bool: + """校验 32 字节签名块 footer:magic / version / size / blockCount。""" + if len(header) != HAP_SIG_BLOCK_HEADER_SIZE: + return False + block_count, block_size, magic_lo, magic_hi, version = struct.unpack( + "= HAP_SIGN_SCHEME_V3_BLOCK_VERSION: + magic_ok = ( + magic_lo == HAP_SIG_BLOCK_MAGIC_LO_V3 + and magic_hi == HAP_SIG_BLOCK_MAGIC_HI_V3 + ) + else: + magic_ok = ( + magic_lo == HAP_SIG_BLOCK_MAGIC_LO_V2 + and magic_hi == HAP_SIG_BLOCK_MAGIC_HI_V2 + ) + if not magic_ok: + return False + + # 真实签名包至少有一个 sub-block;0/负数视为非法 + if block_count <= 0 or block_count > MAX_BLOCK_COUNT: + return False + + max_size = min( + central_directory_offset, + MAX_HAP_SIGN_BLOCK_SIZE, + _JAVA_MAX_SIG_BLOCK_SIZE, + ) + if not HAP_SIG_BLOCK_HEADER_SIZE <= block_size <= max_size: + return False + + # sub-block header 数组必须能放进签名块主体(footer 之外) + block_array_size = block_size - HAP_SIG_BLOCK_HEADER_SIZE + if block_count * OPTIONAL_SUB_BLOCK_HEADER_SIZE > block_array_size: + return False + return True + + +def is_hap_signed(hap_path: str | Path) -> bool: + """判断 HAP 是否已包含 Hap Signing Block。 + + 仅检测签名块是否存在,不做证书链或 Profile 校验。 + 文件不存在、ZIP64、非 ZIP、或结构非法时返回 False(宁可漏判也不误判)。 + """ + path = Path(hap_path) + try: + with path.open("rb") as hap_file: + hap_file.seek(0, 2) + file_size = hap_file.tell() + eocd_offset = _find_eocd_offset_two_phase(hap_file, file_size) + if eocd_offset is None: + return False + + # ZIP64:上游 verify 路径直接拒绝;我们同样不做深入解析 + if _has_zip64_eocd_locator(hap_file, eocd_offset): + return False + + hap_file.seek(eocd_offset) + eocd = hap_file.read(ZIP_EOCD_SEGMENT_MIN_SIZE) + if len(eocd) != ZIP_EOCD_SEGMENT_MIN_SIZE: + return False + + central_directory_size = struct.unpack_from( + " None: + self.cancel_event = cancel_event + def sign_hap( self, hap_path: str, @@ -15,7 +19,7 @@ def sign_hap( p7b_path: str, p12_path: str, alias: str = config.KEY_ALIAS, - password: str = "123456", + password: str = config.KEYSTORE_PASSWORD, output_path: str = "", ) -> bool: """使用 hap-sign-tool sign-app 命令签名 hap。 @@ -67,7 +71,12 @@ def sign_hap( "-signCode", "1", ] - result = subprocess.run(cmd, capture_output=True, text=True) + result = run_process( + cmd, + capture_output=True, + text=True, + cancel_event=self.cancel_event, + ) if result.returncode != 0: raise RuntimeError( f"hap-sign-tool sign-app 失败 (code={result.returncode}): " diff --git a/hapsign/signing/installer.py b/hapsign/signing/installer.py index 4342094..cc5308e 100644 --- a/hapsign/signing/installer.py +++ b/hapsign/signing/installer.py @@ -1,14 +1,282 @@ """HDC 工具封装 —— 获取设备 UDID 和安装 hap。""" +from __future__ import annotations + +import datetime +import logging +import os import re import subprocess +import threading +import time from hapsign import config +from hapsign.subprocess_utils import no_window_kwargs, run_process + +logger = logging.getLogger(__name__) + +_HDC_SERVER_HOST = "127.0.0.1" +_HDC_SERVER_PORT = 8710 + +# hdc start 调用时刻与监听进程创建时刻比较时允许的时钟偏差(秒) +_CLOCK_SKEW_SECONDS = 2.0 + +# hdc start 返回后确认监听进程出现的轮询参数(HDC 启动可能略慢) +_LISTENER_POLL_ATTEMPTS = 8 +_LISTENER_POLL_INTERVAL = 0.25 + +# hdc install 的失败标记:非零退出码之外,仅当输出含这些真实失败指示才判定失败。 +# - [Fail] 状态标记、INSTALL_FAILED_ 前缀可在任意位置出现(HDC 的错误码前缀); +# - error: 只匹配状态行行首,避免普通输出里的类似字符串误判。 +_FATAL_INSTALL_MARKERS = re.compile( + r"(?:\[fail\]|install_failed_|^error:)", re.IGNORECASE | re.MULTILINE +) + + +def _listener_pid() -> int | None: + """返回监听 ``127.0.0.1:8710`` 的进程 PID;无监听或无法识别时返回 None。""" + if os.name == "nt": + return _listener_pid_windows() + return _listener_pid_posix() + + +def _listener_pid_windows() -> int | None: + """解析 netstat 输出,取本地监听 8710 的 PID。""" + try: + result = subprocess.run( + ["netstat", "-ano", "-p", "TCP"], + capture_output=True, + text=True, + timeout=10, + **no_window_kwargs(), + ) + except (OSError, subprocess.TimeoutExpired): + return None + # 监听套接字的远端地址恒为 0.0.0.0:0,状态列文本在不同语言环境可能不同 + pattern = re.compile(r"TCP\s+127\.0\.0\.1:8710\s+0\.0\.0\.0:0\s+\S+\s+(\d+)\s*$") + for line in result.stdout.splitlines(): + match = pattern.search(line) + if match: + return int(match.group(1)) + return None + + +def _listener_pid_posix() -> int | None: + """POSIX 平台回退使用 lsof 定位监听 8710 的 PID。""" + try: + result = subprocess.run( + ["lsof", "-nP", f"-iTCP:{_HDC_SERVER_PORT}", "-sTCP:LISTEN"], + capture_output=True, + text=True, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + return None + for line in result.stdout.splitlines()[1:]: # 跳过表头 + parts = line.split() + if len(parts) >= 2: + try: + return int(parts[1]) + except ValueError: + continue + return None + + +def _process_start_time(pid: int) -> float | None: + """返回进程启动时刻的 epoch 秒;无法读取时返回 None。""" + if os.name == "nt": + return _process_start_time_windows(pid) + return _process_start_time_posix(pid) + + +def _process_start_time_windows(pid: int) -> float | None: + """用 GetProcessTimes 读取进程创建时间(epoch 秒)。""" + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.OpenProcess.restype = wintypes.HANDLE + kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + kernel32.GetProcessTimes.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(wintypes.FILETIME), + ctypes.POINTER(wintypes.FILETIME), + ctypes.POINTER(wintypes.FILETIME), + ctypes.POINTER(wintypes.FILETIME), + ] + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + + process = kernel32.OpenProcess( + 0x1000, + False, + pid, # PROCESS_QUERY_LIMITED_INFORMATION + ) + if not process: + return None + try: + creation = wintypes.FILETIME() + exit_time = wintypes.FILETIME() + kernel_time = wintypes.FILETIME() + user_time = wintypes.FILETIME() + if not kernel32.GetProcessTimes( + process, + ctypes.byref(creation), + ctypes.byref(exit_time), + ctypes.byref(kernel_time), + ctypes.byref(user_time), + ): + return None + raw = (creation.dwHighDateTime << 32) | creation.dwLowDateTime + # FILETIME 从 1601-01-01 UTC 起以 100ns 计,换算为 Unix epoch 秒 + return (raw - 116444736000000000) / 10_000_000.0 + finally: + kernel32.CloseHandle(process) + + +def _process_start_time_posix(pid: int) -> float | None: + try: + result = subprocess.run( + ["ps", "-o", "lstart=", "-p", str(pid)], + capture_output=True, + text=True, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + return None + text = result.stdout.strip() + if not text: + return None + try: + parsed = datetime.datetime.strptime(text, "%a %b %d %H:%M:%S %Y") + except ValueError: + return None + return time.mktime(parsed.timetuple()) class Installer: """使用 hdc 获取设备 UDID 并安装 hap 包。""" + def __init__(self, cancel_event: threading.Event | None = None) -> None: + self._hdc = config.HDC_PATH + self.cancel_event = cancel_event + # 本任务确认创建、close 时应清理的 HDC server 监听 PID;None 表示不归属 + self._owned_server_pid: int | None = None + self._server_checked = False + self._closed = False + + def __enter__(self) -> Installer: + return self + + def __exit__(self, *_exc_info) -> None: + self.close() + + def _ensure_server(self) -> None: + """确保 HDC server 可用,并确认本任务是否创建了它。仅首次使用时执行。 + + - 已存在监听进程(既有 HDC 或非 HDC 占用):不启动、不接管;端口被非 + HDC 占用的情况由后续 hdc 命令失败自然暴露。 + - 无监听进程:显式 ``hdc start``;随后短轮询等待监听进程出现(HDC 启动 + 可能略慢),仅当出现的监听进程“创建时刻不早于本次调用”才确认归属并 + 记录 PID。 + - 启动失败或归属无法确认:记录警告,``close`` 不做清理。 + """ + if self._server_checked: + return + self._server_checked = True + + pre = _listener_pid() + if pre is not None: + logger.info("检测到既有 HDC server (PID %s),本次任务不接管", pre) + return + + started_at = time.time() + try: + result = subprocess.run( + [self._hdc, "start"], + capture_output=True, + text=True, + timeout=10, + **no_window_kwargs(), + ) + except (OSError, subprocess.TimeoutExpired) as exc: + logger.warning("启动 HDC 后台服务失败: %s", exc) + return + if result.returncode != 0: + output = (result.stderr or result.stdout or "").strip() + logger.warning( + "启动 HDC 后台服务失败 (code=%s): %s", + result.returncode, + output, + ) + return + + post = None + for attempt in range(_LISTENER_POLL_ATTEMPTS): + post = _listener_pid() + if post is not None: + break + if attempt + 1 < _LISTENER_POLL_ATTEMPTS: + time.sleep(_LISTENER_POLL_INTERVAL) + if post is None: + logger.warning("hdc start 未发现监听进程,归属不明,close 时不清理") + return + created = _process_start_time(post) + if created is None or created < started_at - _CLOCK_SKEW_SECONDS: + # 监听进程早于本次 hdc start 创建 → 外部 HDC 抢先启动,不接管 + logger.warning( + "监听进程 PID %s 非本次启动(可能外部抢先),不接管清理", post + ) + return + self._owned_server_pid = post + logger.info("本任务已启动 HDC 后台服务 (PID %s)", post) + + def close(self) -> None: + """仅关闭本实例确认启动的 HDC server。 + + 先复核监听 PID 未易主,再调用全局 ``hdc kill`` 优雅关闭(官方命令, + 由守护进程自身结束其进程树);归属不明或已易主时不清理。 + """ + if self._closed: + return + self._closed = True + if self._owned_server_pid is None: + return + + current = _listener_pid() + if current != self._owned_server_pid: + if current is None: + logger.info("HDC server 已不在监听,跳过清理") + else: + logger.warning( + "HDC server 监听 PID 已从 %s 变为 %s,视为外部接管,不执行清理", + self._owned_server_pid, + current, + ) + return + + try: + result = subprocess.run( + [self._hdc, "kill"], + capture_output=True, + text=True, + timeout=5, + **no_window_kwargs(), + ) + except (OSError, subprocess.TimeoutExpired) as exc: + logger.warning("关闭 HDC 后台服务失败: %s", exc) + return + if result.returncode == 0: + logger.info( + "已关闭本次启动的 HDC 后台服务 (PID %s)", self._owned_server_pid + ) + else: + output = (result.stderr or result.stdout or "").strip() + logger.warning( + "关闭 HDC 后台服务失败 (code=%s): %s", + result.returncode, + output, + ) + def get_udid(self) -> str: """获取已连接设备的 UDID。 @@ -26,14 +294,20 @@ def get_udid(self) -> str: Raises: RuntimeError: 没有可用设备或无法获取 UDID 时抛出。 """ - hdc = config.HDC_PATH + self._ensure_server() commands = [ - [hdc, "shell", "bm", "get", "-u"], - [hdc, "shell", "param", "get", "const.product.udid"], + [self._hdc, "shell", "bm", "get", "-u"], + [self._hdc, "shell", "param", "get", "const.product.udid"], ] for cmd in commands: try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=15) + result = run_process( + cmd, + capture_output=True, + text=True, + timeout=15, + cancel_event=self.cancel_event, + ) except subprocess.TimeoutExpired: continue if result.returncode == 0: @@ -41,7 +315,9 @@ def get_udid(self) -> str: match = re.search(r"\b([0-9A-Fa-f]{64})\b", result.stdout) if match: return match.group(1) - raise RuntimeError("无法获取设备 UDID: 请确认已连接设备且 hdc 可用") + raise RuntimeError( + "未检测到可用设备:请确认设备已连接、已授权 USB 调试,且当前只连接一台设备" + ) def install(self, hap_path: str) -> bool: """使用 hdc install 安装 hap 包到已连接设备。 @@ -55,13 +331,20 @@ def install(self, hap_path: str) -> bool: Raises: RuntimeError: hdc install 执行失败时抛出。 """ - hdc = config.HDC_PATH - cmd = [hdc, "install", hap_path] - result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + self._ensure_server() + cmd = [self._hdc, "install", hap_path] + result = run_process( + cmd, + capture_output=True, + text=True, + timeout=60, + cancel_event=self.cancel_event, + ) output = (result.stdout or "") + (result.stderr or "") - # hdc install 即使失败也可能返回 0,需要检查输出内容 - failed = "error:" in output.lower() or "failed" in output.lower() - if result.returncode != 0 or failed: + # hdc install 即使失败也可能返回 0,仅按真实失败标记识别: + # 非零退出码、[Fail] 状态行、INSTALL_FAILED_ 前缀或明确的 error: 行。 + failed = result.returncode != 0 or bool(_FATAL_INSTALL_MARKERS.search(output)) + if failed: raise RuntimeError( f"hdc install 失败 (code={result.returncode}): {output.strip()}" ) diff --git a/hapsign/signing/keytool_util.py b/hapsign/signing/keytool_util.py index 1498768..47f23d9 100644 --- a/hapsign/signing/keytool_util.py +++ b/hapsign/signing/keytool_util.py @@ -1,24 +1,28 @@ """keytool 工具封装 —— 生成密钥对和 CSR。""" import os -import subprocess +import threading from hapsign import config +from hapsign.subprocess_utils import run_process class KeytoolUtil: """封装 keytool 命令,用于生成 EC 密钥对和 CSR。""" + def __init__(self, cancel_event: threading.Event | None = None) -> None: + self.cancel_event = cancel_event + @staticmethod def _get_keytool_path() -> str: - """返回与当前平台匹配的 keytool 路径。""" + """返回运行时发现的 keytool 路径。""" return config.KEYTOOL_PATH def generate_keypair( self, keystore_path: str, alias: str = config.KEY_ALIAS, - password: str = "123456", + password: str = config.KEYSTORE_PASSWORD, ) -> bool: """使用 keytool -genkeypair 生成 EC 256 密钥对。 @@ -58,7 +62,12 @@ def generate_keypair( "-dname", config.KEY_DNAME, ] - result = subprocess.run(cmd, capture_output=True, text=True) + result = run_process( + cmd, + capture_output=True, + text=True, + cancel_event=self.cancel_event, + ) if result.returncode != 0: raise RuntimeError( f"keytool -genkeypair 失败 (code={result.returncode}): " @@ -70,7 +79,7 @@ def generate_csr( self, keystore_path: str, alias: str = config.KEY_ALIAS, - password: str = "123456", + password: str = config.KEYSTORE_PASSWORD, csr_path: str = "", ) -> str: """使用 keytool -certreq 生成 CSR 并返回其内容。 @@ -102,7 +111,12 @@ def generate_csr( "-sigalg", config.SIGN_ALG, ] - result = subprocess.run(cmd, capture_output=True, text=True) + result = run_process( + cmd, + capture_output=True, + text=True, + cancel_event=self.cancel_event, + ) if result.returncode != 0: raise RuntimeError( f"keytool -certreq 失败 (code={result.returncode}): " diff --git a/hapsign/subprocess_utils.py b/hapsign/subprocess_utils.py new file mode 100644 index 0000000..4f686d0 --- /dev/null +++ b/hapsign/subprocess_utils.py @@ -0,0 +1,259 @@ +"""跨平台外部进程启动选项。""" + +from __future__ import annotations + +import os +import platform +import signal +import subprocess +import threading +import time +from typing import Any + +from hapsign.cancellation import OperationCancelled, raise_if_cancelled + +_CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) + + +def no_window_kwargs() -> dict[str, int]: + """Windows 下禁止控制台工具创建一闪而过的命令行窗口。""" + if platform.system() == "Windows": + return {"creationflags": _CREATE_NO_WINDOW} + return {} + + +# ── Windows Job Object(进程树终止) ───────────────────────────────── + +_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000 +_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS = 9 + + +def _create_job_object(process: subprocess.Popen) -> Any | None: + """为 Windows 子进程创建带 KILL_ON_JOB_CLOSE 的 Job Object。 + + 创建或绑定失败(例如已处于禁止嵌套的 Job 中)时返回 None,调用方回退为 + 只终止直接子进程。 + """ + try: + import ctypes + from ctypes import wintypes + + class _BasicLimit(ctypes.Structure): + _fields_ = [ + ("PerProcessUserTimeLimit", wintypes.LARGE_INTEGER), + ("PerJobUserTimeLimit", wintypes.LARGE_INTEGER), + ("LimitFlags", wintypes.DWORD), + ("MinimumWorkingSetSize", ctypes.c_size_t), + ("MaximumWorkingSetSize", ctypes.c_size_t), + ("ActiveProcessLimit", wintypes.DWORD), + ("Affinity", ctypes.c_size_t), + ("PriorityClass", wintypes.DWORD), + ("SchedulingClass", wintypes.DWORD), + ] + + class _IOCounters(ctypes.Structure): + _fields_ = [ + ("ReadOperationCount", ctypes.c_ulonglong), + ("WriteOperationCount", ctypes.c_ulonglong), + ("OtherOperationCount", ctypes.c_ulonglong), + ("ReadTransferCount", ctypes.c_ulonglong), + ("WriteTransferCount", ctypes.c_ulonglong), + ("OtherTransferCount", ctypes.c_ulonglong), + ] + + class _ExtendedLimit(ctypes.Structure): + _fields_ = [ + ("BasicLimitInformation", _BasicLimit), + ("IoInfo", _IOCounters), + ("ProcessMemoryLimit", ctypes.c_size_t), + ("JobMemoryLimit", ctypes.c_size_t), + ("PeakProcessMemoryUsed", ctypes.c_size_t), + ("PeakJobMemoryUsed", ctypes.c_size_t), + ] + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.CreateJobObjectW.restype = wintypes.HANDLE + kernel32.CreateJobObjectW.argtypes = [wintypes.LPVOID, wintypes.LPCWSTR] + kernel32.SetInformationJobObject.argtypes = [ + wintypes.HANDLE, + ctypes.c_int, + wintypes.LPVOID, + wintypes.DWORD, + ] + kernel32.AssignProcessToJobObject.argtypes = [ + wintypes.HANDLE, + wintypes.HANDLE, + ] + + job = kernel32.CreateJobObjectW(None, None) + if not job: + return None + info = _ExtendedLimit() + info.BasicLimitInformation.LimitFlags = _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if not kernel32.SetInformationJobObject( + job, + _JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS, + ctypes.byref(info), + ctypes.sizeof(info), + ): + kernel32.CloseHandle(job) + return None + if not kernel32.AssignProcessToJobObject(job, process._handle): + kernel32.CloseHandle(job) + return None + return job + except (OSError, AttributeError): + return None + + +def _terminate_job(job: Any) -> None: + try: + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT] + kernel32.TerminateJobObject(job, 1) + except OSError: + pass + + +def _close_job(job: Any) -> None: + try: + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle(job) + except OSError: + pass + + +# ── 进程树终止 ───────────────────────────────────────────────────── + + +def _terminate_process_group(process: subprocess.Popen) -> None: + """POSIX:向子进程所在进程组发送 SIGTERM(进程以新会话/组启动)。""" + try: + os.killpg(os.getpgid(process.pid), signal.SIGTERM) + except (ProcessLookupError, PermissionError, OSError): + pass + + +def _terminate_windows_tree(pid: int) -> None: + """Windows 兜底:taskkill /T 终止整棵进程树。 + + Job Object 只能覆盖绑定时刻之后创建的进程,对绑定前已存在的后代进程 + 无能为力;taskkill /T 在终止时重新遍历进程树,可补齐这部分进程。 + """ + try: + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(pid)], + capture_output=True, + text=True, + **no_window_kwargs(), + ) + except (OSError, ValueError): + pass + + +def _stop_process( + process: subprocess.Popen, + job: Any | None = None, +) -> None: + """终止子进程及其后代进程树。 + + Windows 依次使用 Job Object 与 taskkill /T 兜底;POSIX 使用进程组; + 最后等待最多 2 秒,仍存活则强制 kill。 + """ + if process.poll() is not None: + return + if job is not None: + _terminate_job(job) + if os.name == "nt": + _terminate_windows_tree(process.pid) + elif job is None: + _terminate_process_group(process) + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=2) + + +def _popen_process_tree_options() -> dict[str, Any]: + """Popen 需要额外传入的进程树参数:POSIX 下独立进程组。""" + if os.name == "nt": + return {} + return {"start_new_session": True} + + +def run_process( + command: list[str], + *, + cancel_event: threading.Event | None = None, + timeout: float | None = None, + capture_output: bool = False, + text: bool = False, + **kwargs: Any, +) -> subprocess.CompletedProcess: + """运行外部命令;有取消信号或超时要求时,终止子进程及整棵进程树。 + + 仅当既无取消信号也无超时时走 subprocess.run 快路径;否则使用 Popen + + Job Object / 进程组,确保取消或超时能终止整棵进程树。 + """ + options = no_window_kwargs() + options.update(kwargs) + if cancel_event is None and timeout is None: + return subprocess.run( + command, + capture_output=capture_output, + text=text, + **options, + ) + + raise_if_cancelled(cancel_event) + popen_options = dict(options) + popen_options.update(_popen_process_tree_options()) + process = subprocess.Popen( + command, + stdout=subprocess.PIPE if capture_output else None, + stderr=subprocess.PIPE if capture_output else None, + text=text, + **popen_options, + ) + job = _create_job_object(process) if os.name == "nt" else None + try: + deadline = None if timeout is None else time.monotonic() + timeout + while True: + if cancel_event is not None and cancel_event.is_set(): + _stop_process(process, job) + process.communicate() + raise OperationCancelled("操作已取消") + wait_timeout = 0.1 + if deadline is not None: + remaining = deadline - time.monotonic() + if remaining <= 0: + _stop_process(process, job) + stdout, stderr = process.communicate() + raise subprocess.TimeoutExpired( + command, + timeout, + output=stdout, + stderr=stderr, + ) + wait_timeout = min(wait_timeout, remaining) + try: + stdout, stderr = process.communicate(timeout=wait_timeout) + except subprocess.TimeoutExpired: + continue + return subprocess.CompletedProcess( + command, + process.returncode, + stdout, + stderr, + ) + finally: + if job is not None: + _close_job(job) diff --git a/hapsign/token/secure_token_cache.py b/hapsign/token/secure_token_cache.py new file mode 100644 index 0000000..fbcc82f --- /dev/null +++ b/hapsign/token/secure_token_cache.py @@ -0,0 +1,157 @@ +"""Token 缓存静态加密。 + +Windows 使用当前用户作用域的 DPAPI(CryptProtectData / CryptUnprotectData), +无第三方依赖;其他平台退化为受限权限(0o600)的明文 JSON,并在日志与文档中 +明确告警。缓存文件单行存储: + +- 加密格式:``hapsign-token-v1:`` +- 旧版明文 JSON 首次读取时自动安全迁移为加密格式。 + +任何路径都不得把加密前的 token、密钥或完整缓存内容写入日志。 +""" + +from __future__ import annotations + +import base64 +import logging +import os + +logger = logging.getLogger(__name__) + +_HEADER = "hapsign-token-v1:" +_HEADER_BYTES = _HEADER.encode("ascii") + +# CRYPTPROTECT_UI_FORBIDDEN:禁止弹出任何 UI,失败即抛错。 +_CRYPTPROTECT_UI_FORBIDDEN = 0x1 + + +class DecryptError(Exception): + """缓存无法解密(密钥不可用、内容损坏或非加密格式)。""" + + +def is_encrypted(data: bytes) -> bool: + """判断缓存字节是否为加密格式。""" + return data.startswith(_HEADER_BYTES) + + +def protect(payload: bytes) -> bytes: + """加密缓存载荷,返回可直接写入磁盘的字节。 + + 非 Windows 平台无 DPAPI,按约定退化为原样明文(由调用方限制文件权限), + 并记录明确告警。Windows 上 CryptProtectData 失败时抛出 OSError,由调用方 + 决定降级策略(当前策略:放弃保存并告警,绝不把 token 明文落盘)。 + """ + if os.name != "nt": + logger.warning( + "当前平台没有 DPAPI,token 缓存将以受限权限的明文存储;" + "请勿在共享电脑或云同步目录中使用本工具" + ) + return payload + return _HEADER_BYTES + base64.b64encode(_dpapi_protect(payload)) + + +def decrypt(data: bytes) -> bytes: + """解密缓存字节,返回原始 JSON 载荷。 + + Raises: + DecryptError: 非加密格式、非 Windows 平台、base64 无效或 DPAPI 解密失败。 + """ + if not is_encrypted(data): + raise DecryptError("缓存不是加密格式") + if os.name != "nt": + # 加密缓存由 Windows DPAPI 生成,跨平台无法解密。显式报错而不是 + # 去调用仅 Windows 可用的 crypt32,避免 AttributeError 泄漏。 + raise DecryptError( + "缓存文件是 Windows DPAPI 加密格式,当前平台无法解密;" + "请删除缓存文件后重新登录" + ) + try: + blob = base64.b64decode(data[len(_HEADER_BYTES) :]) + except ValueError as exc: + raise DecryptError("缓存头有效但 base64 编码无效") from exc + try: + return _dpapi_unprotect(blob) + except OSError as exc: + raise DecryptError(f"DPAPI 解密失败: {exc}") from exc + + +def _dpapi_protect(data: bytes) -> bytes: + """调用 CryptProtectData(当前用户作用域)加密字节串。""" + import ctypes + from ctypes import wintypes + + class DATA_BLOB(ctypes.Structure): + _fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.c_void_p)] + + crypt32 = ctypes.WinDLL("crypt32", use_last_error=True) + crypt32.CryptProtectData.restype = wintypes.BOOL + crypt32.CryptProtectData.argtypes = [ + ctypes.POINTER(DATA_BLOB), + wintypes.LPCWSTR, + ctypes.POINTER(DATA_BLOB), + ctypes.c_void_p, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(DATA_BLOB), + ] + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.LocalFree.argtypes = [wintypes.HANDLE] + + buffer = ctypes.create_string_buffer(data) + blob_in = DATA_BLOB(len(data), ctypes.cast(buffer, ctypes.c_void_p)) + blob_out = DATA_BLOB() + if not crypt32.CryptProtectData( + ctypes.byref(blob_in), + None, + None, + None, + None, + _CRYPTPROTECT_UI_FORBIDDEN, + ctypes.byref(blob_out), + ): + raise OSError(f"CryptProtectData 失败: {ctypes.get_last_error()}") + try: + return ctypes.string_at(blob_out.pbData, blob_out.cbData) + finally: + kernel32.LocalFree(blob_out.pbData) + + +def _dpapi_unprotect(data: bytes) -> bytes: + """调用 CryptUnprotectData(当前用户作用域)解密字节串。""" + import ctypes + from ctypes import wintypes + + class DATA_BLOB(ctypes.Structure): + _fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.c_void_p)] + + crypt32 = ctypes.WinDLL("crypt32", use_last_error=True) + crypt32.CryptUnprotectData.restype = wintypes.BOOL + crypt32.CryptUnprotectData.argtypes = [ + ctypes.POINTER(DATA_BLOB), + ctypes.POINTER(wintypes.LPWSTR), + ctypes.POINTER(DATA_BLOB), + ctypes.c_void_p, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(DATA_BLOB), + ] + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.LocalFree.argtypes = [wintypes.HANDLE] + + buffer = ctypes.create_string_buffer(data) + blob_in = DATA_BLOB(len(data), ctypes.cast(buffer, ctypes.c_void_p)) + blob_out = DATA_BLOB() + if not crypt32.CryptUnprotectData( + ctypes.byref(blob_in), + None, + None, + None, + None, + _CRYPTPROTECT_UI_FORBIDDEN, + ctypes.byref(blob_out), + ): + raise OSError(f"CryptUnprotectData 失败: {ctypes.get_last_error()}") + try: + return ctypes.string_at(blob_out.pbData, blob_out.cbData) + finally: + kernel32.LocalFree(blob_out.pbData) diff --git a/hapsign/token/token_exchange.py b/hapsign/token/token_exchange.py index 150852c..d71c116 100644 --- a/hapsign/token/token_exchange.py +++ b/hapsign/token/token_exchange.py @@ -9,35 +9,77 @@ import base64 import json +import logging +import threading +from urllib.parse import urlparse import requests +from hapsign.cancellation import raise_if_cancelled from hapsign.config import ( APP_ID, BASE_URL, HEADER_JWT_TOKEN, HEADER_REFRESH, JWT_TOKEN_CHECK_PATH, + LOGIN_PROTOCOL_VERSION, TEMP_TOKEN_CHECK_PATH, ) +from hapsign.diagnostics import sensitive_logging_enabled from hapsign.models import TokenInfo +logger = logging.getLogger(__name__) + + +def _log_response( + operation: str, + url: str, + response: requests.Response, + *, + request_data: object, +) -> None: + parsed = urlparse(url) + content = getattr(response, "content", b"") + response_size = len(content) if isinstance(content, (bytes, str)) else -1 + logger.debug( + "[token] %s response=%s://%s%s status=%d bytes=%d", + operation, + parsed.scheme, + parsed.netloc, + parsed.path, + response.status_code, + response_size, + ) + if sensitive_logging_enabled(): + logger.debug( + "[token] sensitive %s request=%r response=%r", + operation, + request_data, + response.text, + ) + class TokenExchange: """Token 交换工具类。""" + def __init__(self, cancel_event: threading.Event | None = None) -> None: + self.cancel_event = cancel_event + + def _check_cancelled(self) -> None: + raise_if_cancelled(self.cancel_event) + def exchange_temp_token( self, temp_token: str, site: str = "CN", - version: str = "5.0.5", + version: str = LOGIN_PROTOCOL_VERSION, ) -> str: """用 tempToken 换取 jwtToken。 Args: temp_token: 浏览器回调获得的临时令牌。 site: 站点代码(CN / SG / DE / RU)。 - version: DevEco Studio 版本号。 + version: 登录协议版本(默认取 config.LOGIN_PROTOCOL_VERSION)。 Returns: jwtToken 字符串。 @@ -52,7 +94,10 @@ def exchange_temp_token( "version": version, "appid": APP_ID, } - resp = requests.get(url, params=params, timeout=30) + self._check_cancelled() + resp = requests.get(url, params=params, timeout=(5, 15)) + self._check_cancelled() + _log_response("temp-token-check", url, resp, request_data=params) resp.raise_for_status() # 响应正文即为 jwtToken 字符串(非 JSON) return resp.text @@ -72,7 +117,10 @@ def get_access_token(self, jwt_token: str) -> TokenInfo: """ url = f"{BASE_URL}/{JWT_TOKEN_CHECK_PATH}" headers = {HEADER_JWT_TOKEN: jwt_token, HEADER_REFRESH: "false"} - resp = requests.get(url, headers=headers, timeout=30) + self._check_cancelled() + resp = requests.get(url, headers=headers, timeout=(5, 15)) + self._check_cancelled() + _log_response("jwt-token-check", url, resp, request_data=headers) resp.raise_for_status() data = resp.json() @@ -126,7 +174,10 @@ def refresh_access_token(self, jwt_token: str) -> str: """ url = f"{BASE_URL}/{JWT_TOKEN_CHECK_PATH}" headers = {HEADER_JWT_TOKEN: jwt_token, HEADER_REFRESH: "true"} - resp = requests.get(url, headers=headers, timeout=30) + self._check_cancelled() + resp = requests.get(url, headers=headers, timeout=(5, 15)) + self._check_cancelled() + _log_response("refresh-token", url, resp, request_data=headers) resp.raise_for_status() data = resp.json() diff --git a/pyproject.toml b/pyproject.toml index a42e949..88f09b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,11 +23,20 @@ classifiers = [ "Topic :: Software Development :: Build Tools", ] dependencies = [ - "playwright>=1.40,<2", "requests>=2.31,<3", ] [project.optional-dependencies] +playwright = [ + "playwright>=1.40,<2", +] +gui = [ + "PySide6-Essentials>=6.8,<7", + "playwright>=1.40,<2", +] +bundle = [ + "PyInstaller>=6.14,<7", +] dev = [ "pre-commit>=4,<5", "pytest>=8,<9", @@ -38,9 +47,15 @@ dev = [ [project.scripts] hapsign = "hapsign.cli:main" +[project.gui-scripts] +hapsign-app = "hapsign.gui:main" + [project.urls] Homepage = "https://github.com/guantw/HapSign" +Source = "https://github.com/guantw/HapSign" Issues = "https://github.com/guantw/HapSign/issues" +Changelog = "https://github.com/guantw/HapSign/blob/master/CHANGELOG.md" +Security = "https://github.com/guantw/HapSign/security" [tool.setuptools.dynamic] version = {attr = "hapsign.__version__"} @@ -55,6 +70,7 @@ testpaths = ["tests"] [tool.coverage.run] branch = true source = ["hapsign"] +omit = ["hapsign/gui.py"] [tool.coverage.report] fail_under = 58 diff --git a/scripts/build_portable.py b/scripts/build_portable.py new file mode 100644 index 0000000..ac01dd7 --- /dev/null +++ b/scripts/build_portable.py @@ -0,0 +1,631 @@ +"""构建当前平台的 HapSign 便携版目录和 ZIP。 + +构建机需要 Python、项目 bundle 依赖以及 prepare_toolchain 生成的公开工具链。 +DevEco 仅作为显式启用的本机兼容回退。 +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import os +import re +import shutil +import subprocess +import sys +import tempfile +from importlib import metadata +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +DIST_DIR = PROJECT_ROOT / "dist" +PREPARED_TOOLCHAIN_ROOT = PROJECT_ROOT / "build" / "toolchain-prepared" +# 实际冻结进便携包、需要随包附许可的 Python 运行时依赖。 +# 门禁 _validate_runtime_license_coverage 会校验:本清单必须 ⊇ 实际冻结闭包。 +PYTHON_RUNTIME_DISTRIBUTIONS = ( + "PySide6-Essentials", + "shiboken6", + "playwright", + "greenlet", # playwright 的运行时传递依赖 + "pyee", # playwright 的运行时传递依赖 + "requests", + "urllib3", + "certifi", + "charset-normalizer", + "idna", + "PyInstaller", +) +# 仅构建期使用的工具:其 bootloader 嵌入冻结产物故保留许可记录,但自身与其传递 +# 依赖并不随便携包分发,因此不计入门禁的“实际冻结闭包”。 +BUILD_TIME_ONLY_DISTRIBUTIONS = ("PyInstaller",) + + +def _write_sha256_file(archive_path: Path) -> Path: + """为发布归档生成可被常见校验工具读取的 SHA-256 sidecar。""" + + digest = hashlib.sha256() + with archive_path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + checksum_path = archive_path.with_suffix(archive_path.suffix + ".sha256") + checksum_path.write_text( + f"{digest.hexdigest()} {archive_path.name}\n", + encoding="ascii", + ) + return checksum_path + + +# 以下排除项只用于显式启用的 DevEco JBR 回退;正式构建使用 jlink runtime。 +# 这里仅排除能够由构建后工具链自检证明无关的 JCEF 原生资源;Java modules、 +# 字体、安全配置和其他 DLL 全部保留,后续精简需单独增加测试后再进行。 +WINDOWS_JCEF_BIN_FILES = { + "chrome_elf.dll", + "d3dcompiler_47.dll", + "dxcompiler.dll", + "dxil.dll", + "icudtl.dat", + "jcef.dll", + "jcef_helper.dll", + "jcef_helper.exe", + "jogl_desktop.dll", + "jogl_mobile.dll", + "libcef.dll", + "libegl.dll", + "libglesv2.dll", + "snapshot_blob.bin", + "v8_context_snapshot.bin", + "vk_swiftshader.dll", + "vulkan-1.dll", +} +WINDOWS_JCEF_LIB_FILES = { + "chrome_100_percent.pak", + "chrome_200_percent.pak", + "resources.pak", + "vk_swiftshader_icd.json", +} +WINDOWS_JCEF_LIB_DIRS = {"locales"} + + +def _require_build_modules() -> None: + missing = [ + name + for name in ("PyInstaller", "PySide6", "playwright") + if importlib.util.find_spec(name) is None + ] + if missing: + packages = " ".join(missing) + raise RuntimeError( + "缺少构建依赖:" + f"{', '.join(missing)}\n" + f"请先执行:{sys.executable} -m pip install -e .[gui,bundle]\n" + f"检测名称:{packages}" + ) + + +def _require_playwright_browser() -> None: + from playwright.sync_api import sync_playwright + + with sync_playwright() as pw: + executable = Path(pw.chromium.executable_path) + if executable.is_file(): + return + raise RuntimeError( + "缺少 Playwright Chromium 浏览器。\n" + "请在 PowerShell 中执行:\n" + '$env:PLAYWRIGHT_BROWSERS_PATH="0"\n' + f"{sys.executable} -m playwright install --no-shell chromium" + ) + + +def _run(command: list[str], *, env: dict[str, str]) -> None: + print("+", subprocess.list2cmdline(command)) + subprocess.run(command, cwd=PROJECT_ROOT, env=env, check=True) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest().upper() + + +def _runtime_requirement_names(distribution_name: str) -> set[str]: + """返回 distribution 在当前环境生效的核心运行时依赖名(排除 extras 条件依赖)。 + + `metadata.requires` 返回的字符串形如 `greenlet>=3.1.1 ; python_version >= '3.9'` + 或 `pyee (>=12.0.0) ; extra == 'gui'`;这里只解析名字部分,并丢弃带 + `extra ==` 标记的条件依赖。 + """ + names: set[str] = set() + try: + raw_requirements = metadata.requires(distribution_name) + except metadata.PackageNotFoundError: + return names + for raw in raw_requirements or (): + if "extra ==" in raw: + continue + name_part = raw.split(";", 1)[0].strip() + if not name_part: + continue + # 只取名字部分:截断到版本比较符、逗号或空白。 + # 例如 "greenlet>=3.1.1" → "greenlet";"pyee (>=12.0.0)" → "pyee"。 + name = re.split(r"[<>=!~,\s]+", name_part, maxsplit=1)[0].strip() + if name: + names.add(name) + return names + + +def _normalize_distribution_name(name: str) -> str: + """PEP 503 规范化:小写并把 `-`/`_`/`.` 合并为 `-`。 + + 等价于 importlib.metadata.normalize_name(Python 3.13+ 才公开), + 3.11/3.12 构建机这里自行实现。 + """ + return re.sub(r"[-_.]+", "-", name).lower() + + +def _frozen_runtime_closure(roots: tuple[str, ...]) -> set[str]: + """从实际冻结的根依赖出发,解析传递的运行时依赖闭包(规范化名称)。 + + 只跟随当前环境实际生效的核心运行时依赖;构建期工具不在 roots 中, + 其依赖也不会被误收。 + """ + closure: set[str] = set() + pending = [_normalize_distribution_name(root) for root in roots] + while pending: + current = pending.pop() + if current in closure: + continue + closure.add(current) + for dep in _runtime_requirement_names(current): + normalized = _normalize_distribution_name(dep) + if normalized not in closure: + pending.append(normalized) + return closure + + +def _validate_runtime_license_coverage() -> None: + """门禁:许可清单必须覆盖实际冻结闭包,缺项直接让构建失败。 + + 断言方向为「清单 ⊇ 闭包」:只允许闭包比清单多而报错,不允许按清单反向 + 裁剪实际冻结内容,否则构建机与发布环境不一致时差距会被悄悄掩盖。 + """ + roots = tuple( + name + for name in PYTHON_RUNTIME_DISTRIBUTIONS + if name not in BUILD_TIME_ONLY_DISTRIBUTIONS + ) + listed = { + _normalize_distribution_name(name) for name in PYTHON_RUNTIME_DISTRIBUTIONS + } + closure = _frozen_runtime_closure(roots) + missing = sorted(name for name in closure if name not in listed) + if missing: + raise RuntimeError( + "第三方许可清单缺少实际冻结的运行时依赖:" + f"{', '.join(missing)}\n" + "请在 PYTHON_RUNTIME_DISTRIBUTIONS 中补全后再构建。" + ) + + +def _copy_python_license_files(portable_root: Path) -> None: + """复制冻结依赖随 wheel 安装的许可,并记录精确构建版本。""" + license_root = portable_root / "licenses" / "python" + license_root.mkdir(parents=True, exist_ok=True) + manifest = [ + "Python distributions frozen into this build", + "===========================================", + "", + "Generated from the active build environment.", + "", + ] + + for distribution_name in PYTHON_RUNTIME_DISTRIBUTIONS: + try: + distribution = metadata.distribution(distribution_name) + except metadata.PackageNotFoundError as exc: + raise RuntimeError( + f"无法生成第三方许可清单,缺少 distribution: {distribution_name}" + ) from exc + + canonical_name = distribution.metadata.get("Name", distribution_name) + version = distribution.version + license_expression = ( + distribution.metadata.get("License-Expression") + or distribution.metadata.get("License") + or "(not declared in package metadata)" + ) + manifest.append(f"- {canonical_name} {version}: {license_expression}") + + destination = license_root / f"{canonical_name}-{version}" + copied = 0 + for entry in distribution.files or (): + normalized = str(entry).replace("\\", "/") + lowered = normalized.lower() + filename = Path(normalized).name.lower() + is_license_name = filename.startswith( + ("license", "copying", "notice", "thirdpartynotice") + ) + if ".dist-info/licenses/" not in lowered and not ( + ".dist-info/" in lowered and is_license_name + ): + continue + source = Path(distribution.locate_file(entry)) + if not source.is_file(): + continue + target = destination / Path(normalized).name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + copied += 1 + + if copied == 0: + manifest.append( + f" No standalone license file was present in the installed " + f"{canonical_name} distribution; see THIRD_PARTY_NOTICES.md." + ) + + python_license = Path(sys.base_prefix) / "LICENSE.txt" + if not python_license.is_file(): + raise RuntimeError(f"无法找到 Python 运行时许可文件: {python_license}") + shutil.copy2(python_license, license_root / "PYTHON-LICENSE.txt") + + (license_root / "DISTRIBUTIONS.txt").write_text( + "\n".join(manifest) + "\n", + encoding="utf-8", + ) + + +def _copy_release_documents(portable_root: Path) -> None: + """把源码许可、隐私和构建说明放到可执行包根目录。""" + documents = { + "PORTABLE.md": "README.md", + "LICENSE": "LICENSE", + "PRIVACY.md": "PRIVACY.md", + "THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md", + "docs/PACKAGING.md": "BUILDING.md", + "docs/OPEN_SOURCE_RELEASE.md": "OPEN_SOURCE_RELEASE.md", + } + for source_name, target_name in documents.items(): + shutil.copy2(PROJECT_ROOT / source_name, portable_root / target_name) + _copy_python_license_files(portable_root) + + +def _jbr_ignore(source_root: Path): + bin_dir = (source_root / "bin").resolve() + lib_dir = (source_root / "lib").resolve() + + def ignore(directory: str, names: list[str]) -> set[str]: + current = Path(directory).resolve() + lowered = {name.lower(): name for name in names} + if current == bin_dir: + return {lowered[name] for name in WINDOWS_JCEF_BIN_FILES if name in lowered} + if current == lib_dir: + excluded = WINDOWS_JCEF_LIB_FILES | WINDOWS_JCEF_LIB_DIRS + return {lowered[name] for name in excluded if name in lowered} + return set() + + return ignore + + +def _smoke_test_toolchain(portable_root: Path) -> None: + from hapsign.runtime import platform_tag + + root = portable_root / "resources" / "toolchain" / platform_tag() + suffix = ".exe" if sys.platform == "win32" else "" + runtime = root / "runtime" + if not runtime.is_dir(): + runtime = root / "jbr" + java = runtime / "bin" / f"java{suffix}" + keytool = runtime / "bin" / f"keytool{suffix}" + signer = root / "lib" / "hap-sign-tool.jar" + + checks = [ + [str(java), "-version"], + [str(java), "-jar", str(signer)], + ] + with tempfile.TemporaryDirectory(prefix="hapsign-toolchain-smoke-") as temp: + keystore = Path(temp) / "smoke.p12" + checks.append( + [ + str(keytool), + "-genkeypair", + "-alias", + "hapsign-smoke", + "-keyalg", + "EC", + "-groupname", + "secp256r1", + "-keystore", + str(keystore), + "-storetype", + "PKCS12", + "-storepass", + "hapsign-smoke-password", + "-keypass", + "hapsign-smoke-password", + "-dname", + "CN=HapSign Build Smoke", + "-validity", + "1", + "-noprompt", + ] + ) + for command in checks: + result = subprocess.run( + command, + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if result.returncode != 0: + details = result.stderr.strip() or result.stdout.strip() + raise RuntimeError( + "精简后的便携 Java 工具链自检失败:" + f"{subprocess.list2cmdline(command)}\n{details}" + ) + + +def _copy_local_toolchain(portable_root: Path, *, keep_full_jbr: bool) -> None: + """从本机安装复制工具,仅供兼容和排障,不用于公开 Release。""" + sys.path.insert(0, str(PROJECT_ROOT)) + from hapsign.runtime import discover_toolchain, platform_tag + + toolchain = discover_toolchain() + missing = toolchain.missing() + if missing: + details = "\n".join(f"- {item}" for item in missing) + raise RuntimeError(f"无法生成完整便携版,工具链缺失:\n{details}") + + jbr_root = toolchain.java.parent.parent + target = portable_root / "resources" / "toolchain" / platform_tag() + target.mkdir(parents=True, exist_ok=True) + copy_options = {"dirs_exist_ok": True} + if sys.platform == "win32" and not keep_full_jbr: + copy_options["ignore"] = _jbr_ignore(jbr_root) + shutil.copytree(jbr_root, target / "jbr", **copy_options) + + lib_dir = target / "lib" + bin_dir = target / "bin" + lib_dir.mkdir(exist_ok=True) + bin_dir.mkdir(exist_ok=True) + shutil.copy2(toolchain.hap_sign_tool, lib_dir / "hap-sign-tool.jar") + shutil.copy2(toolchain.hdc, bin_dir / toolchain.hdc.name) + + # Windows HDC 与同目录的 libusb_shared.dll 配套分发。 + libusb = toolchain.hdc.parent / "libusb_shared.dll" + if libusb.is_file(): + shutil.copy2(libusb, bin_dir / libusb.name) + notice = toolchain.hdc.parent / "NOTICE.txt" + if notice.is_file(): + shutil.copy2(notice, target / "NOTICE.txt") + + # DevEco 自身的综合 NOTICE 可补充 JBR/SDK 安装包中的许可文本。它不能代替 + # 发布者核对具体版本的再分发权,但公开二进制不得把随附声明裁掉。 + deveco_notice = jbr_root.parent / "license" / "NOTICE.txt" + if deveco_notice.is_file(): + notice_target = portable_root / "licenses" / "deveco-studio" + notice_target.mkdir(parents=True, exist_ok=True) + shutil.copy2(deveco_notice, notice_target / "NOTICE.txt") + + manifest_files = { + "java": target / "jbr" / "bin" / toolchain.java.name, + "keytool": target / "jbr" / "bin" / toolchain.keytool.name, + "hap-sign-tool": target / "lib" / "hap-sign-tool.jar", + "hdc": target / "bin" / toolchain.hdc.name, + } + manifest_lines = [ + "Bundled toolchain provenance", + "============================", + "", + f"Detected source type: {toolchain.source}", + "The source installation path is intentionally omitted for privacy.", + "A local build does not by itself grant public redistribution rights.", + "", + ] + for name, path in manifest_files.items(): + manifest_lines.append( + f"{name}: {path.relative_to(portable_root).as_posix()} " + f"sha256={_sha256(path)}" + ) + (target / "PROVENANCE.txt").write_text( + "\n".join(manifest_lines) + "\n", + encoding="utf-8", + ) + _smoke_test_toolchain(portable_root) + + +def _copy_toolchain( + portable_root: Path, + *, + keep_full_jbr: bool, + allow_local_toolchain: bool, +) -> None: + """优先复制已锁定的公开工具链;本机 DevEco 回退必须显式启用。""" + sys.path.insert(0, str(PROJECT_ROOT)) + from hapsign.runtime import platform_tag + + prepared = PREPARED_TOOLCHAIN_ROOT / platform_tag() + target = portable_root / "resources" / "toolchain" / platform_tag() + required = ( + prepared / "PROVENANCE.txt", + prepared / "toolchain.lock.json", + prepared / "NOTICE.txt", + ) + if prepared.is_dir() and all(path.is_file() for path in required): + shutil.copytree(prepared, target, dirs_exist_ok=True) + _smoke_test_toolchain(portable_root) + return + + if not allow_local_toolchain: + raise RuntimeError( + "尚未准备可公开分发的锁定工具链。\n" + f"请先执行:{sys.executable} scripts/prepare_toolchain.py\n" + "仅为本机排障时可显式传入 --allow-deveco-toolchain;" + "该回退产物不应上传公开 Release。" + ) + _copy_local_toolchain(portable_root, keep_full_jbr=keep_full_jbr) + + +def _prune_playwright_extras( + portable_root: Path, + *, + keep_bundled_browser: bool, +) -> None: + """按构建模式清理不需要的 Playwright 浏览器资源。""" + internal = portable_root / "_internal" + browsers = internal.glob("playwright/**/.local-browsers/*") + for browser in browsers: + if not browser.is_dir(): + continue + if browser.name.startswith("ffmpeg-") or ( + browser.name.startswith("chromium-") and not keep_bundled_browser + ): + shutil.rmtree(browser) + + +def _smoke_test_frozen_app( + portable_root: Path, + *, + keep_bundled_browser: bool, +) -> None: + executable_name = "HapSign.exe" if sys.platform == "win32" else "HapSign" + executable = portable_root / executable_name + checks = ["--system-browser-smoke-test", "--smoke-test"] + if keep_bundled_browser: + checks.insert(1, "--browser-smoke-test") + for argument in checks: + result = subprocess.run( + [str(executable), argument], + cwd=portable_root, + timeout=30, + check=False, + ) + if result.returncode != 0: + raise RuntimeError( + f"冻结应用自检失败:{executable.name} {argument} " + f"(code={result.returncode})" + ) + + +def _remove_smoke_artifacts(portable_root: Path) -> None: + """避免把构建自检生成的本地日志或运行数据收入发布包。""" + config = portable_root / "hapsign-config.json" + if config.is_file(): + config.unlink() + for name in ("logs", "signing_files", "signed_haps"): + directory = portable_root / name + if directory.is_dir(): + shutil.rmtree(directory) + + +def build( + *, + skip_toolchain: bool, + keep_full_jbr: bool, + keep_bundled_browser: bool, + allow_local_toolchain: bool, +) -> Path: + _require_build_modules() + _validate_runtime_license_coverage() + os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", "0") + if keep_bundled_browser: + _require_playwright_browser() + env = os.environ.copy() + env["HAPSIGN_BUNDLE_CHROMIUM"] = "1" if keep_bundled_browser else "0" + + _run( + [ + sys.executable, + "-m", + "PyInstaller", + "--noconfirm", + "--clean", + "--distpath", + str(DIST_DIR), + "--workpath", + str(PROJECT_ROOT / "build" / "pyinstaller"), + str(PROJECT_ROOT / "bundle" / "hapsign.spec"), + ], + env=env, + ) + + portable_root = DIST_DIR / "HapSign" + _prune_playwright_extras( + portable_root, + keep_bundled_browser=keep_bundled_browser, + ) + if not skip_toolchain: + _copy_toolchain( + portable_root, + keep_full_jbr=keep_full_jbr, + allow_local_toolchain=allow_local_toolchain, + ) + _copy_release_documents(portable_root) + _smoke_test_frozen_app( + portable_root, + keep_bundled_browser=keep_bundled_browser, + ) + _remove_smoke_artifacts(portable_root) + + sys.path.insert(0, str(PROJECT_ROOT)) + from hapsign.runtime import platform_tag + + archive_variant = "-compat" if keep_bundled_browser else "" + archive_base = DIST_DIR / (f"HapSign-portable-{platform_tag()}{archive_variant}") + archive_path = archive_base.with_suffix(".zip") + if archive_path.is_file(): + archive_path.unlink() + shutil.make_archive( + str(archive_base), + "zip", + root_dir=portable_root.parent, + base_dir=portable_root.name, + ) + _write_sha256_file(archive_path) + return archive_path + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--skip-toolchain", + action="store_true", + help="只构建 GUI,不复制签名与设备工具链", + ) + parser.add_argument( + "--keep-full-jbr", + action="store_true", + help="使用 DevEco 回退时保留完整 JBR 的 JCEF 资源", + ) + parser.add_argument( + "--allow-deveco-toolchain", + action="store_true", + help="公开工具链未准备好时允许复制本机 DevEco 工具;产物不可直接公开发布", + ) + parser.add_argument( + "--keep-bundled-browser", + action="store_true", + help="额外包含 Playwright Chromium;默认复用系统 Edge/Chrome 以缩小体积", + ) + args = parser.parse_args() + + try: + archive = build( + skip_toolchain=args.skip_toolchain, + keep_full_jbr=args.keep_full_jbr, + keep_bundled_browser=args.keep_bundled_browser, + allow_local_toolchain=args.allow_deveco_toolchain, + ) + except (RuntimeError, subprocess.CalledProcessError) as exc: + print(f"构建失败:{exc}", file=sys.stderr) + return 1 + print(f"便携版已生成:{archive}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prepare_toolchain.py b/scripts/prepare_toolchain.py new file mode 100644 index 0000000..a3c6d27 --- /dev/null +++ b/scripts/prepare_toolchain.py @@ -0,0 +1,517 @@ +"""从可审计的公开上游准备 HapSign 便携工具链。 + +下载内容、大小和 SHA-256 固定在 ``toolchain.lock.json``。Windows 当前使用: + +* OpenHarmony 6.1 公共 SDK 中的 HDC、libusb 和 hap-sign-tool; +* Eclipse Temurin 21 JDK,经 jlink 缩成仅供 HapSign 使用的运行时。 + +缓存和输出默认都位于被 Git 忽略的 ``build/``,不会把大型 SDK 加进源码仓库。 +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import platform +import shutil +import subprocess +import sys +import tarfile +import tempfile +import time +import urllib.parse +import urllib.request +import zipfile +from pathlib import Path, PurePosixPath +from typing import Any + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +LOCK_PATH = PROJECT_ROOT / "toolchain.lock.json" +DEFAULT_CACHE_DIR = PROJECT_ROOT / "build" / "toolchain-cache" +DEFAULT_OUTPUT_ROOT = PROJECT_ROOT / "build" / "toolchain-prepared" +DOWNLOAD_CHUNK_SIZE = 4 * 1024 * 1024 + + +def _platform_tag() -> str: + system = platform.system().lower() + if system == "darwin": + return "macos" + if system == "windows": + return "windows" + return "linux" + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(DOWNLOAD_CHUNK_SIZE), b""): + digest.update(chunk) + return digest.hexdigest().lower() + + +def _verify_file(path: Path, metadata: dict[str, Any], *, label: str) -> None: + expected_size = metadata.get("size") + if expected_size is not None and path.stat().st_size != expected_size: + raise RuntimeError( + f"{label} 大小校验失败:期望 {expected_size},实际 {path.stat().st_size}," + f"文件:{path}" + ) + expected_hash = metadata.get("sha256") + if expected_hash is not None: + actual_hash = _sha256(path) + if actual_hash != expected_hash.lower(): + raise RuntimeError( + f"{label} SHA-256 校验失败:\n" + f"期望 {expected_hash.lower()}\n实际 {actual_hash}\n文件:{path}" + ) + + +def _download( + metadata: dict[str, Any], + destination: Path, + *, + label: str, +) -> Path: + """下载并校验锁定文件;使用 .part 支持可靠的断点续传。""" + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.is_file(): + _verify_file(destination, metadata, label=label) + print(f"使用已校验缓存:{destination}") + return destination + + partial = destination.with_name(f"{destination.name}.part") + expected_size = int(metadata["size"]) + offset = partial.stat().st_size if partial.is_file() else 0 + if offset > expected_size: + raise RuntimeError( + f"下载缓存大于锁定文件,请删除后重试:{partial} " + f"({offset} > {expected_size})" + ) + + headers = {"User-Agent": "HapSign-toolchain-preparer/1"} + if offset: + headers["Range"] = f"bytes={offset}-" + request = urllib.request.Request(metadata["url"], headers=headers) + print(f"下载 {label}:{metadata['url']}") + if offset: + print(f"从 {offset} 字节继续") + + with urllib.request.urlopen(request, timeout=60) as response: + status = getattr(response, "status", response.getcode()) + mode = "ab" + if offset and status != 206: + print("服务器未接受断点续传,改为从头下载") + offset = 0 + mode = "wb" + elif not offset: + mode = "wb" + + downloaded = offset + last_report = time.monotonic() + with partial.open(mode) as target: + while True: + chunk = response.read(DOWNLOAD_CHUNK_SIZE) + if not chunk: + break + target.write(chunk) + downloaded += len(chunk) + now = time.monotonic() + if now - last_report >= 5: + percent = downloaded / expected_size * 100 + print( + f"\r {downloaded / 1024**2:.1f} MiB / " + f"{expected_size / 1024**2:.1f} MiB ({percent:.1f}%)", + end="", + flush=True, + ) + last_report = now + print() + _verify_file(partial, metadata, label=label) + os.replace(partial, destination) + return destination + + +def _archive_name(url: str) -> str: + name = PurePosixPath(urllib.parse.urlparse(url).path).name + if not name: + raise RuntimeError(f"下载地址没有文件名:{url}") + return name + + +def _extract_nested_toolchains( + sdk_archive: Path, + *, + expected_name: str, + destination: Path, +) -> Path: + """从公共 SDK tar.gz 中只提取目标平台 toolchains ZIP。""" + destination.parent.mkdir(parents=True, exist_ok=True) + with tarfile.open(sdk_archive, mode="r:gz") as archive: + matches = [ + member + for member in archive.getmembers() + if member.isfile() and PurePosixPath(member.name).name == expected_name + ] + if len(matches) != 1: + names = ", ".join(item.name for item in matches) or "(未找到)" + raise RuntimeError( + f"公共 SDK 中应当恰好有一个 {expected_name},实际:{names}" + ) + source = archive.extractfile(matches[0]) + if source is None: + raise RuntimeError(f"无法读取公共 SDK 成员:{matches[0].name}") + with source, destination.open("wb") as target: + shutil.copyfileobj(source, target, length=DOWNLOAD_CHUNK_SIZE) + return destination + + +def _normalized_zip_name(name: str) -> str: + return str(PurePosixPath(name.replace("\\", "/"))) + + +def _find_zip_member(archive: zipfile.ZipFile, suffix: str) -> zipfile.ZipInfo: + normalized_suffix = _normalized_zip_name(suffix).lower() + matches = [ + item + for item in archive.infolist() + if not item.is_dir() + and _normalized_zip_name(item.filename).lower().endswith(normalized_suffix) + ] + if len(matches) != 1: + names = ", ".join(item.filename for item in matches) or "(未找到)" + raise RuntimeError(f"toolchains ZIP 中应当恰好有一个 *{suffix},实际:{names}") + return matches[0] + + +def _extract_openharmony_files( + toolchains_zip: Path, + output: Path, + files: dict[str, dict[str, Any]], +) -> None: + with zipfile.ZipFile(toolchains_zip) as archive: + for target_name, metadata in files.items(): + member = _find_zip_member(archive, metadata["archive_suffix"]) + target = output / Path(target_name) + target.parent.mkdir(parents=True, exist_ok=True) + with archive.open(member) as source, target.open("wb") as destination: + shutil.copyfileobj(source, destination, length=DOWNLOAD_CHUNK_SIZE) + _verify_file(target, metadata, label=target_name) + + +def _safe_extract_zip(archive_path: Path, destination: Path) -> None: + destination_resolved = destination.resolve() + with zipfile.ZipFile(archive_path) as archive: + for member in archive.infolist(): + target = (destination / _normalized_zip_name(member.filename)).resolve() + if ( + target != destination_resolved + and destination_resolved not in target.parents + ): + raise RuntimeError(f"ZIP 包含越界路径:{member.filename}") + archive.extractall(destination) + + +def _find_jdk_root(extracted: Path) -> Path: + executable = "jlink.exe" if os.name == "nt" else "jlink" + matches = list(extracted.glob(f"*/bin/{executable}")) + if len(matches) != 1: + names = ", ".join(str(item) for item in matches) or "(未找到)" + raise RuntimeError(f"Temurin ZIP 中无法唯一定位 {executable}:{names}") + return matches[0].parent.parent + + +def _create_java_runtime( + jdk_archive: Path, + output: Path, + *, + modules: list[str], +) -> None: + with tempfile.TemporaryDirectory(prefix="hapsign-temurin-") as temporary: + extracted = Path(temporary) + _safe_extract_zip(jdk_archive, extracted) + jdk_root = _find_jdk_root(extracted) + executable = "jlink.exe" if os.name == "nt" else "jlink" + command = [ + str(jdk_root / "bin" / executable), + "--add-modules", + ",".join(modules), + "--strip-debug", + "--no-man-pages", + "--no-header-files", + "--compress=zip-6", + "--output", + str(output / "runtime"), + ] + print("+", subprocess.list2cmdline(command)) + subprocess.run(command, check=True) + + license_dir = output / "licenses" / "temurin" + license_dir.mkdir(parents=True, exist_ok=True) + for name in ( + "NOTICE", + "LICENSE", + "ADDITIONAL_LICENSE_INFO", + "ASSEMBLY_EXCEPTION", + ): + source = jdk_root / name + if source.is_file(): + shutil.copy2(source, license_dir / name) + + +def _copy_libusb_source(output: Path, metadata: dict[str, Any]) -> None: + source = PROJECT_ROOT / metadata["path"] + _verify_file(source, metadata, label="libusb 对应源代码") + destination = output / "licenses" / "libusb-source" + destination.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination / source.name) + readme = source.parent / "README.md" + if readme.is_file(): + shutil.copy2(readme, destination / "README.md") + + +def _smoke_test(output: Path) -> None: + suffix = ".exe" if os.name == "nt" else "" + java = output / "runtime" / "bin" / f"java{suffix}" + keytool = output / "runtime" / "bin" / f"keytool{suffix}" + signer = output / "lib" / "hap-sign-tool.jar" + hdc = output / "bin" / f"hdc{suffix}" + + commands = [ + [str(java), "-version"], + [str(java), "-jar", str(signer)], + [str(hdc), "-v"], + ] + with tempfile.TemporaryDirectory(prefix="hapsign-toolchain-smoke-") as temporary: + keystore = Path(temporary) / "smoke.p12" + commands.append( + [ + str(keytool), + "-genkeypair", + "-alias", + "hapsign-smoke", + "-keyalg", + "EC", + "-groupname", + "secp256r1", + "-keystore", + str(keystore), + "-storetype", + "PKCS12", + "-storepass", + "hapsign-smoke-password", + "-keypass", + "hapsign-smoke-password", + "-dname", + "CN=HapSign Toolchain Smoke", + "-validity", + "1", + "-noprompt", + ] + ) + for command in commands: + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + if result.returncode != 0: + details = result.stderr.strip() or result.stdout.strip() + raise RuntimeError( + f"工具链自检失败:{subprocess.list2cmdline(command)}\n{details}" + ) + + +def _write_provenance( + output: Path, + *, + platform_config: dict[str, Any], +) -> None: + openharmony = platform_config["openharmony"] + java = platform_config["java"] + files = { + "java": output + / "runtime" + / "bin" + / ("java.exe" if os.name == "nt" else "java"), + "keytool": output + / "runtime" + / "bin" + / ("keytool.exe" if os.name == "nt" else "keytool"), + "hap-sign-tool": output / "lib" / "hap-sign-tool.jar", + "hdc": output / "bin" / ("hdc.exe" if os.name == "nt" else "hdc"), + } + lines = [ + "HapSign verified public toolchain", + "=================================", + "", + f"OpenHarmony SDK: {openharmony['release']} / {openharmony['version']}", + f"OpenHarmony archive: {openharmony['archive']['url']}", + f"OpenHarmony archive SHA-256: {openharmony['archive']['sha256']}", + f"Java: {java['distribution']} {java['version']}", + f"Java archive: {java['archive']['url']}", + f"Java archive SHA-256: {java['archive']['sha256']}", + "Java runtime: generated with jlink from the locked JDK archive", + "", + "Bundled files:", + ] + for name, path in files.items(): + lines.append( + f"- {name}: {path.relative_to(output).as_posix()} sha256={_sha256(path)}" + ) + (output / "PROVENANCE.txt").write_text( + "\n".join(lines) + "\n", + encoding="utf-8", + ) + shutil.copy2(LOCK_PATH, output / "toolchain.lock.json") + + +def prepare( + *, + target_platform: str, + cache_dir: Path, + output: Path, + sdk_archive_override: Path | None, + jdk_archive_override: Path | None, + force: bool, +) -> Path: + lock = json.loads(LOCK_PATH.read_text(encoding="utf-8")) + if lock.get("schema") != 1: + raise RuntimeError(f"不支持的工具链锁文件版本:{lock.get('schema')}") + try: + platform_config = lock["platforms"][target_platform] + except KeyError as exc: + supported = ", ".join(sorted(lock["platforms"])) + raise RuntimeError( + f"尚未锁定 {target_platform} 工具链;当前支持:{supported}" + ) from exc + if target_platform != _platform_tag(): + raise RuntimeError( + f"jlink 不能跨平台生成运行时:目标 {target_platform}," + f"当前主机 {_platform_tag()}" + ) + + if output.exists() and not force: + raise RuntimeError(f"输出目录已存在;确认后使用 --force 重建:{output}") + output.parent.mkdir(parents=True, exist_ok=True) + + openharmony = platform_config["openharmony"] + java = platform_config["java"] + sdk_archive = sdk_archive_override + if sdk_archive is None: + sdk_archive = cache_dir / _archive_name(openharmony["archive"]["url"]) + sdk_archive = _download( + openharmony["archive"], + sdk_archive, + label="OpenHarmony 公共 SDK", + ) + else: + sdk_archive = sdk_archive.resolve() + _verify_file(sdk_archive, openharmony["archive"], label="OpenHarmony 公共 SDK") + + jdk_archive = jdk_archive_override + if jdk_archive is None: + jdk_archive = cache_dir / _archive_name(java["archive"]["url"]) + jdk_archive = _download( + java["archive"], jdk_archive, label="Eclipse Temurin JDK" + ) + else: + jdk_archive = jdk_archive.resolve() + _verify_file(jdk_archive, java["archive"], label="Eclipse Temurin JDK") + + cache_dir.mkdir(parents=True, exist_ok=True) + toolchains_zip = cache_dir / openharmony["toolchains_member"] + if not toolchains_zip.is_file(): + print(f"从公共 SDK 提取:{openharmony['toolchains_member']}") + _extract_nested_toolchains( + sdk_archive, + expected_name=openharmony["toolchains_member"], + destination=toolchains_zip, + ) + _verify_file( + toolchains_zip, + {"sha256": openharmony["toolchains_sha256"]}, + label="OpenHarmony Windows toolchains", + ) + + staging = Path(tempfile.mkdtemp(prefix=f".{output.name}-", dir=str(output.parent))) + try: + _extract_openharmony_files( + toolchains_zip, + staging, + openharmony["files"], + ) + _create_java_runtime( + jdk_archive, + staging, + modules=java["modules"], + ) + _copy_libusb_source(staging, openharmony["libusb_source"]) + _write_provenance(staging, platform_config=platform_config) + _smoke_test(staging) + if output.exists(): + shutil.rmtree(output) + staging.replace(output) + except BaseException: + shutil.rmtree(staging, ignore_errors=True) + raise + print(f"已生成并验证公开工具链:{output}") + return output + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--platform", + default=_platform_tag(), + choices=("windows", "macos", "linux"), + help="目标平台;jlink 要求与当前主机一致", + ) + parser.add_argument( + "--cache-dir", + type=Path, + default=DEFAULT_CACHE_DIR, + help="下载缓存目录", + ) + parser.add_argument( + "--output-dir", + type=Path, + help="输出目录;默认 build/toolchain-prepared/", + ) + parser.add_argument( + "--sdk-archive", + type=Path, + help="使用已下载的 OpenHarmony 公共 SDK,同时仍执行锁定校验", + ) + parser.add_argument( + "--jdk-archive", + type=Path, + help="使用已下载的 Temurin JDK ZIP,同时仍执行锁定校验", + ) + parser.add_argument( + "--force", + action="store_true", + help="删除并重建已存在的目标工具链目录", + ) + args = parser.parse_args() + output = args.output_dir or DEFAULT_OUTPUT_ROOT / args.platform + try: + prepare( + target_platform=args.platform, + cache_dir=args.cache_dir.resolve(), + output=output.resolve(), + sdk_archive_override=args.sdk_archive, + jdk_archive_override=args.jdk_archive, + force=args.force, + ) + except (OSError, RuntimeError, subprocess.SubprocessError, tarfile.TarError) as exc: + print(f"准备工具链失败:{exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_api.py b/tests/test_api.py index 7c18388..75bad76 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -4,17 +4,26 @@ from unittest.mock import Mock import pytest +import requests from hapsign.api.cert_api import CertAPI +from hapsign.api.client import TokenExpiredError from hapsign.api.device_api import DeviceAPI from hapsign.api.provision_api import ProvisionAPI -from hapsign.config import API_REAL_PROVISION_ADD, API_TEST_PROVISION_ADD +from hapsign.cancellation import OperationCancelled +from hapsign.config import ( + API_AGREEMENT, + API_REAL_PROVISION_ADD, + API_TEST_PROVISION_ADD, + HEADER_ACCESS_TOKEN, +) @pytest.fixture def client() -> Mock: value = Mock() value._get_headers.return_value = {"teamId": "team"} + value.access_token = "tok" return value @@ -61,6 +70,29 @@ def test_provision_rejects_error_response(client) -> None: ProvisionAPI(client).add_test_provision("team", "bundle", [], []) +@pytest.mark.parametrize("duplicate_code", [205389857, 205389858, "205389858"]) +def test_add_device_treats_duplicate_code_as_success(client, duplicate_code) -> None: + client._do_post_form.return_value = {"ret": {"code": duplicate_code}} + + assert DeviceAPI(client).add_device("udid", "4", "team", "name") is True + + +@pytest.mark.parametrize("duplicate_code", [205389857, 205389858]) +def test_add_device_treats_duplicate_in_http_error_as_success( + client, duplicate_code +) -> None: + client._do_post_form.side_effect = RuntimeError(f"request failed: {duplicate_code}") + + assert DeviceAPI(client).add_device("udid", "4", "team", "name") is True + + +def test_add_device_raises_on_other_business_error(client) -> None: + client._do_post_form.return_value = {"ret": {"code": 1, "msg": "failed"}} + + with pytest.raises(RuntimeError, match="add_device failed"): + DeviceAPI(client).add_device("udid", "4", "team", "name") + + def test_find_device_id(client) -> None: client._do_get.return_value = {"list": [{"id": 7, "udid": "wanted"}]} @@ -82,3 +114,35 @@ def test_find_certificate(client) -> None: result = CertAPI(client).find_cert("team", "debug.cer") assert result["certObjectId"] == "object" + + +def test_sign_agreement_uses_unified_client_with_only_access_token(client) -> None: + client._do_post_form_text.return_value = "ok" + + assert CertAPI(client).sign_agreement() is True + + url, headers = client._do_post_form_text.call_args.args + assert url == API_AGREEMENT + # 只保留该接口特殊的 accessToken 头,不误加签名 API 的其他认证头。 + assert headers == {HEADER_ACCESS_TOKEN: "tok"} + + +def test_sign_agreement_propagates_http_error(client) -> None: + client._do_post_form_text.side_effect = requests.HTTPError("500 Server Error") + + with pytest.raises(requests.HTTPError): + CertAPI(client).sign_agreement() + + +def test_sign_agreement_propagates_token_expiry(client) -> None: + client._do_post_form_text.side_effect = TokenExpiredError("Token 失效") + + with pytest.raises(TokenExpiredError): + CertAPI(client).sign_agreement() + + +def test_sign_agreement_propagates_cancellation(client) -> None: + client._do_post_form_text.side_effect = OperationCancelled("cancelled") + + with pytest.raises(OperationCancelled): + CertAPI(client).sign_agreement() diff --git a/tests/test_browser_login.py b/tests/test_browser_login.py index 75cd760..3775759 100644 --- a/tests/test_browser_login.py +++ b/tests/test_browser_login.py @@ -4,12 +4,16 @@ import logging import threading from http.server import ThreadingHTTPServer +from unittest.mock import Mock from urllib.parse import urlencode +from hapsign.cancellation import OperationCancelled +from hapsign.diagnostics import set_sensitive_logging from hapsign.login import browser_login def test_callback_accepts_token_without_logging_it(caplog) -> None: + set_sensitive_logging(False) callback_data = {} callback_event = threading.Event() handler = browser_login._make_callback_handler( @@ -45,3 +49,342 @@ def test_callback_accepts_token_without_logging_it(caplog) -> None: def test_callback_host_is_loopback() -> None: assert browser_login._CALLBACK_HOST == "127.0.0.1" + + +def test_callback_accepts_get_on_root_path() -> None: + callback_data = {} + callback_event = threading.Event() + handler = browser_login._make_callback_handler( + "expected-code", callback_data, callback_event + ) + server = ThreadingHTTPServer((browser_login._CALLBACK_HOST, 0), handler) + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + + try: + connection = http.client.HTTPConnection(*server.server_address, timeout=5) + query = urlencode( + {"tempToken": "root-token", "siteId": "CN", "code": "expected-code"} + ) + connection.request("GET", f"/?{query}") + response = connection.getresponse() + response.read() + connection.close() + + assert response.status == 302 + assert response.getheader("Location").endswith( + "/console/DevEcoIDE/loginSuccess" + ) + assert callback_event.wait(timeout=2) + assert callback_data["tempToken"] == "root-token" + finally: + server.shutdown() + server.server_close() + + +def test_callback_accepts_multipart_form() -> None: + callback_data = {} + callback_event = threading.Event() + handler = browser_login._make_callback_handler( + "expected-code", callback_data, callback_event + ) + server = ThreadingHTTPServer((browser_login._CALLBACK_HOST, 0), handler) + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + boundary = "----HapSignBoundary" + fields = { + "tempToken": "multipart-token", + "siteId": "1", + "code": "expected-code", + } + body = "".join( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{name}"\r\n\r\n' + f"{value}\r\n" + for name, value in fields.items() + ) + body += f"--{boundary}--\r\n" + + try: + connection = http.client.HTTPConnection(*server.server_address, timeout=5) + connection.request( + "POST", + "/callback", + body.encode("utf-8"), + {"Content-Type": f"multipart/form-data; boundary={boundary}"}, + ) + response = connection.getresponse() + response.read() + connection.close() + + assert response.status == 302 + assert callback_event.wait(timeout=2) + assert callback_data["tempToken"] == "multipart-token" + assert callback_data["siteId"] == "1" + finally: + server.shutdown() + server.server_close() + + +def test_callback_infers_multipart_when_content_type_is_missing() -> None: + boundary = "----MissingHeaderBoundary" + body = ( + f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="tempToken"\r\n\r\n' + "inferred-token\r\n" + f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="code"\r\n\r\n' + "expected-code\r\n" + f"--{boundary}--\r\n" + ).encode() + + assert browser_login._decode_callback_params(body, "") == { + "tempToken": "inferred-token", + "code": "expected-code", + } + + +def test_callback_json_accepts_nested_data() -> None: + body = b'{"data":{"tempToken":"json-token","code":"expected-code"}}' + + assert browser_login._decode_callback_params(body, "application/json") == { + "tempToken": "json-token", + "code": "expected-code", + } + + +def test_denied_authorization_finishes_callback_wait() -> None: + callback_data = {} + callback_event = threading.Event() + handler = browser_login._make_callback_handler( + "expected-code", callback_data, callback_event + ) + server = ThreadingHTTPServer((browser_login._CALLBACK_HOST, 0), handler) + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + + try: + connection = http.client.HTTPConnection(*server.server_address, timeout=5) + body = urlencode({"quit": "access_denied", "code": "expected-code"}) + connection.request( + "POST", + "/callback", + body, + {"Content-Type": "application/x-www-form-urlencoded"}, + ) + response = connection.getresponse() + response.read() + connection.close() + + assert response.status == 400 + assert callback_event.wait(timeout=2) + assert "取消" in callback_data["error"] + finally: + server.shutdown() + server.server_close() + + +def test_post_body_takes_precedence_over_query_params() -> None: + callback_data = {} + callback_event = threading.Event() + handler = browser_login._make_callback_handler( + "expected-code", callback_data, callback_event + ) + server = ThreadingHTTPServer((browser_login._CALLBACK_HOST, 0), handler) + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + + try: + connection = http.client.HTTPConnection(*server.server_address, timeout=5) + body = urlencode({"tempToken": "body-token", "code": "expected-code"}) + connection.request( + "POST", + "/callback?code=wrong-query-code", + body, + {"Content-Type": "application/x-www-form-urlencoded"}, + ) + response = connection.getresponse() + response.read() + connection.close() + + assert response.status == 302 + assert callback_event.wait(timeout=2) + assert callback_data["tempToken"] == "body-token" + finally: + server.shutdown() + server.server_close() + + +def test_private_network_preflight_is_allowed() -> None: + handler = browser_login._make_callback_handler( + "expected-code", {}, threading.Event() + ) + server = ThreadingHTTPServer((browser_login._CALLBACK_HOST, 0), handler) + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + + try: + connection = http.client.HTTPConnection(*server.server_address, timeout=5) + connection.request( + "OPTIONS", + "/callback", + headers={ + "Origin": "https://devecostudio.huawei.com", + "Access-Control-Request-Headers": "content-type, x-requested-with", + "Access-Control-Request-Private-Network": "true", + }, + ) + response = connection.getresponse() + response.read() + connection.close() + + assert response.status == 204 + assert ( + response.getheader("Access-Control-Allow-Origin") + == "https://devecostudio.huawei.com" + ) + assert response.getheader("Access-Control-Allow-Private-Network") == "true" + assert "x-requested-with" in response.getheader("Access-Control-Allow-Headers") + finally: + server.shutdown() + server.server_close() + + +def test_system_browser_waits_for_callback(monkeypatch) -> None: + opened = Mock(return_value=True) + monkeypatch.setattr(browser_login.webbrowser, "open", opened) + callback_event = threading.Event() + callback_event.set() + + login = browser_login.BrowserLogin(browser_mode="system") + login._browser_login_and_wait("https://example.invalid/login", callback_event) + + opened.assert_called_once_with( + "https://example.invalid/login", + new=1, + autoraise=True, + ) + + +def test_default_browser_mode_is_controlled_system_browser(monkeypatch) -> None: + monkeypatch.delenv("HAPSIGN_BROWSER", raising=False) + assert browser_login.BrowserLogin().browser_mode == "system_controlled" + + +def test_controlled_system_browser_prefers_edge() -> None: + browser = Mock() + chromium = Mock() + chromium.launch.return_value = browser + playwright = Mock(chromium=chromium) + + result, runtime = browser_login._launch_controlled_browser( + playwright, + "system_controlled", + allow_fallback=False, + smoke_test=True, + ) + + assert result is browser + assert runtime == "msedge" + chromium.launch.assert_called_once() + assert chromium.launch.call_args.kwargs["channel"] == "msedge" + assert "--headless=new" in chromium.launch.call_args.kwargs["args"] + + +def test_controlled_system_browser_falls_back_to_chrome() -> None: + browser = Mock() + chromium = Mock() + chromium.launch.side_effect = [RuntimeError("edge missing"), browser] + playwright = Mock(chromium=chromium) + + result, runtime = browser_login._launch_controlled_browser( + playwright, + "system_controlled", + allow_fallback=False, + ) + + assert result is browser + assert runtime == "chrome" + assert [call.kwargs["channel"] for call in chromium.launch.call_args_list] == [ + "msedge", + "chrome", + ] + + +def test_missing_bundled_chromium_falls_back_for_existing_settings( + tmp_path, +) -> None: + browser = Mock() + chromium = Mock() + chromium.executable_path = str(tmp_path / "missing-chromium.exe") + chromium.launch.return_value = browser + playwright = Mock(chromium=chromium) + + result, runtime = browser_login._launch_controlled_browser( + playwright, + "playwright", + allow_fallback=True, + ) + + assert result is browser + assert runtime == "msedge" + chromium.launch.assert_called_once() + assert chromium.launch.call_args.kwargs["channel"] == "msedge" + + +def test_sensitive_callback_logging_requires_explicit_switch(caplog) -> None: + callback_data = {} + callback_event = threading.Event() + handler = browser_login._make_callback_handler( + "expected-code", callback_data, callback_event + ) + server = ThreadingHTTPServer((browser_login._CALLBACK_HOST, 0), handler) + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + secret = "explicit-sensitive-token" + + try: + set_sensitive_logging(True) + caplog.set_level(logging.DEBUG) + connection = http.client.HTTPConnection(*server.server_address, timeout=5) + body = urlencode({"tempToken": secret, "code": "expected-code"}) + connection.request( + "POST", + "/callback", + body, + {"Content-Type": "application/x-www-form-urlencoded"}, + ) + response = connection.getresponse() + response.read() + connection.close() + + assert response.status == 302 + assert secret in caplog.text + finally: + set_sensitive_logging(False) + server.shutdown() + server.server_close() + + +def test_unknown_browser_mode_is_rejected() -> None: + login = browser_login.BrowserLogin(browser_mode="unknown") + + try: + login._browser_login_and_wait("https://example.invalid", threading.Event()) + except RuntimeError as exc: + assert "Unsupported browser mode" in str(exc) + else: + raise AssertionError("unsupported browser mode should fail") + + +def test_callback_wait_can_be_cancelled() -> None: + cancel_event = threading.Event() + cancel_event.set() + login = browser_login.BrowserLogin(browser_mode="system", cancel_event=cancel_event) + + try: + login._wait_for_callback(threading.Event()) + except OperationCancelled: + pass + else: + raise AssertionError("cancelled callback wait should stop") diff --git a/tests/test_build_portable.py b/tests/test_build_portable.py new file mode 100644 index 0000000..67c2f7a --- /dev/null +++ b/tests/test_build_portable.py @@ -0,0 +1,372 @@ +"""便携版保守精简规则测试。""" + +import hashlib + +import pytest + +from scripts import build_portable + + +def test_write_sha256_file_uses_standard_sidecar_format(tmp_path) -> None: + archive = tmp_path / "HapSign-portable-windows.zip" + archive.write_bytes(b"portable archive") + + checksum = build_portable._write_sha256_file(archive) + + expected = hashlib.sha256(b"portable archive").hexdigest() + assert checksum.name == "HapSign-portable-windows.zip.sha256" + assert checksum.read_text(encoding="ascii") == f"{expected} {archive.name}\n" + + +def test_jbr_pruning_only_targets_known_jcef_resources(tmp_path) -> None: + jbr = tmp_path / "jbr" + bin_dir = jbr / "bin" + lib_dir = jbr / "lib" + bin_dir.mkdir(parents=True) + lib_dir.mkdir() + ignore = build_portable._jbr_ignore(jbr) + + ignored_bin = ignore( + str(bin_dir), + ["java.exe", "keytool.exe", "libcef.dll", "awt.dll"], + ) + ignored_lib = ignore( + str(lib_dir), + ["modules", "security", "locales", "resources.pak"], + ) + + assert ignored_bin == {"libcef.dll"} + assert ignored_lib == {"locales", "resources.pak"} + assert ignore(str(jbr / "conf"), ["security"]) == set() + + +def _playwright_browser_fixture(tmp_path): + portable = tmp_path / "HapSign" + browsers = ( + portable / "_internal" / "playwright" / "driver" / "package" / ".local-browsers" + ) + chromium = browsers / "chromium-1" + ffmpeg = browsers / "ffmpeg-1" + chromium.mkdir(parents=True) + ffmpeg.mkdir() + (chromium / "chrome.exe").write_bytes(b"chrome") + (ffmpeg / "ffmpeg.exe").write_bytes(b"ffmpeg") + return portable, chromium, ffmpeg + + +def test_compat_build_keeps_chromium_but_removes_ffmpeg(tmp_path) -> None: + portable, chromium, ffmpeg = _playwright_browser_fixture(tmp_path) + + build_portable._prune_playwright_extras( + portable, + keep_bundled_browser=True, + ) + + assert chromium.is_dir() + assert not ffmpeg.exists() + + +def test_slim_build_removes_all_bundled_browser_resources(tmp_path) -> None: + portable, chromium, ffmpeg = _playwright_browser_fixture(tmp_path) + + build_portable._prune_playwright_extras( + portable, + keep_bundled_browser=False, + ) + + assert not chromium.exists() + assert not ffmpeg.exists() + + +def test_smoke_artifact_cleanup_preserves_application_files(tmp_path) -> None: + portable = tmp_path / "HapSign" + portable.mkdir() + executable = portable / "HapSign.exe" + executable.write_bytes(b"app") + (portable / "hapsign-config.json").write_text("{}", encoding="utf-8") + for name in ("logs", "signing_files", "signed_haps"): + directory = portable / name + directory.mkdir() + (directory / "private-data").write_text("secret", encoding="utf-8") + + build_portable._remove_smoke_artifacts(portable) + + assert executable.is_file() + assert not (portable / "hapsign-config.json").exists() + assert not (portable / "logs").exists() + assert not (portable / "signing_files").exists() + assert not (portable / "signed_haps").exists() + + +def test_python_dependency_licenses_are_copied(tmp_path, monkeypatch) -> None: + portable = tmp_path / "HapSign" + portable.mkdir() + python_root = tmp_path / "python" + python_root.mkdir() + (python_root / "LICENSE.txt").write_text("Python license\n", encoding="utf-8") + # 不依赖各平台 Python 发行版是否把 LICENSE.txt 放在 base_prefix 根目录。 + monkeypatch.setattr(build_portable.sys, "base_prefix", str(python_root)) + monkeypatch.setattr( + build_portable, + "PYTHON_RUNTIME_DISTRIBUTIONS", + ("requests",), + ) + + build_portable._copy_python_license_files(portable) + + license_root = portable / "licenses" / "python" + assert (license_root / "PYTHON-LICENSE.txt").is_file() + assert (license_root / "DISTRIBUTIONS.txt").is_file() + assert ( + "requests" + in (license_root / "DISTRIBUTIONS.txt").read_text(encoding="utf-8").lower() + ) + assert any( + path.name.lower().startswith(("license", "notice")) + for path in license_root.rglob("*") + if path.is_file() + ) + + +def test_runtime_license_list_covers_playwright_core_dependencies() -> None: + listed = { + build_portable._normalize_distribution_name(name) + for name in build_portable.PYTHON_RUNTIME_DISTRIBUTIONS + } + for name in ("greenlet", "pyee"): + assert build_portable._normalize_distribution_name(name) in listed + + +def _fake_requirement_map() -> dict[str, set[str]]: + return { + "playwright": {"greenlet", "pyee"}, + "greenlet": set(), + "pyee": set(), + "requests": {"urllib3", "certifi"}, + "urllib3": set(), + "certifi": set(), + } + + +def test_runtime_requirement_names_parses_versions_and_markers(monkeypatch) -> None: + # 不模拟 _runtime_requirement_names,走真实解析:版本比较符/括号/条件标记必须剥离。 + raw = { + "playwright": [ + "greenlet>=3.1.1 ; python_version >= '3.9'", + "pyee (>=12.0.0) ; python_version >= '3.9'", + "requests ; extra == 'test'", + ], + } + monkeypatch.setattr( + build_portable, + "metadata", + type( + "FakeMetadata", + (), + {"requires": lambda name: raw.get(name)}, + ), + ) + + assert build_portable._runtime_requirement_names("playwright") == { + "greenlet", + "pyee", + } + + +def test_frozen_runtime_closure_is_transitive_and_skips_extras(monkeypatch) -> None: + deps = _fake_requirement_map() + # 模拟带 extra 条件依赖的原始字符串,验证解析层会排除它们。 + raw = { + "playwright": [ + "greenlet>=3.1.1 ; python_version >= '3.9'", + "pyee (>=12.0.0) ; python_version >= '3.9'", + "requests ; extra == 'test'", + ], + } + monkeypatch.setattr( + build_portable, + "metadata", + type( + "FakeMetadata", + (), + {"requires": lambda name: raw.get(name)}, + ), + ) + monkeypatch.setattr( + build_portable, + "_runtime_requirement_names", + lambda name: deps.get(name, set()), + ) + + closure = build_portable._frozen_runtime_closure(("playwright", "requests")) + + assert closure == { + build_portable._normalize_distribution_name(name) + for name in ("playwright", "greenlet", "pyee", "requests", "urllib3", "certifi") + } + + +def test_normalize_distribution_name_follows_pep503() -> None: + assert ( + build_portable._normalize_distribution_name("PySide6-Essentials") + == "pyside6-essentials" + ) + assert build_portable._normalize_distribution_name("charset_normalizer") == ( + "charset-normalizer" + ) + + +def test_license_gate_passes_when_closure_is_covered(monkeypatch) -> None: + deps = _fake_requirement_map() + monkeypatch.setattr( + build_portable, + "_runtime_requirement_names", + lambda name: deps.get(name, set()), + ) + monkeypatch.setattr( + build_portable, + "PYTHON_RUNTIME_DISTRIBUTIONS", + ("playwright", "greenlet", "pyee", "requests", "urllib3", "certifi"), + ) + monkeypatch.setattr( + build_portable, "BUILD_TIME_ONLY_DISTRIBUTIONS", ("PyInstaller",) + ) + + build_portable._validate_runtime_license_coverage() + + +def test_license_gate_fails_when_closure_misses_manifest(monkeypatch) -> None: + deps = _fake_requirement_map() + monkeypatch.setattr( + build_portable, + "_runtime_requirement_names", + lambda name: deps.get(name, set()), + ) + # playwright 的传递依赖 greenlet/pyee 不在清单中 → 门禁必须失败。 + monkeypatch.setattr( + build_portable, + "PYTHON_RUNTIME_DISTRIBUTIONS", + ("playwright",), + ) + monkeypatch.setattr(build_portable, "BUILD_TIME_ONLY_DISTRIBUTIONS", ()) + + with pytest.raises(RuntimeError, match="greenlet"): + build_portable._validate_runtime_license_coverage() + + +def test_license_gate_ignores_build_time_only_distributions(monkeypatch) -> None: + deps = _fake_requirement_map() + # PyInstaller 及其传递依赖不会被冻结,不要求进入清单。 + monkeypatch.setattr( + build_portable, + "_runtime_requirement_names", + lambda name: deps.get(name, set()), + ) + monkeypatch.setattr( + build_portable, + "PYTHON_RUNTIME_DISTRIBUTIONS", + ("playwright", "greenlet", "pyee", "PyInstaller"), + ) + monkeypatch.setattr( + build_portable, + "BUILD_TIME_ONLY_DISTRIBUTIONS", + ("PyInstaller",), + ) + + build_portable._validate_runtime_license_coverage() + + +def test_release_documents_include_open_source_notices( + tmp_path, + monkeypatch, +) -> None: + project = tmp_path / "project" + portable = tmp_path / "HapSign" + (project / "docs").mkdir(parents=True) + portable.mkdir() + sources = { + "PORTABLE.md": "portable", + "LICENSE": "MIT", + "PRIVACY.md": "privacy", + "THIRD_PARTY_NOTICES.md": "notices", + "docs/PACKAGING.md": "building", + "docs/OPEN_SOURCE_RELEASE.md": "release", + } + for name, content in sources.items(): + path = project / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + monkeypatch.setattr(build_portable, "PROJECT_ROOT", project) + monkeypatch.setattr( + build_portable, + "_copy_python_license_files", + lambda root: None, + ) + + build_portable._copy_release_documents(portable) + + for name in ( + "README.md", + "LICENSE", + "PRIVACY.md", + "THIRD_PARTY_NOTICES.md", + "BUILDING.md", + "OPEN_SOURCE_RELEASE.md", + ): + assert (portable / name).is_file() + + +def test_copy_toolchain_prefers_verified_prepared_directory( + tmp_path, + monkeypatch, +) -> None: + project = tmp_path / "project" + prepared_root = project / "build" / "toolchain-prepared" + prepared = prepared_root / "windows" + portable = tmp_path / "HapSign" + for path in ( + prepared / "runtime" / "bin" / "java.exe", + prepared / "runtime" / "bin" / "keytool.exe", + prepared / "lib" / "hap-sign-tool.jar", + prepared / "bin" / "hdc.exe", + prepared / "PROVENANCE.txt", + prepared / "toolchain.lock.json", + prepared / "NOTICE.txt", + ): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"verified") + + monkeypatch.setattr(build_portable, "PROJECT_ROOT", project) + monkeypatch.setattr(build_portable, "PREPARED_TOOLCHAIN_ROOT", prepared_root) + monkeypatch.setattr("hapsign.runtime.platform_tag", lambda: "windows") + monkeypatch.setattr(build_portable, "_smoke_test_toolchain", lambda root: None) + + build_portable._copy_toolchain( + portable, + keep_full_jbr=False, + allow_local_toolchain=False, + ) + + target = portable / "resources" / "toolchain" / "windows" + assert (target / "runtime" / "bin" / "java.exe").read_bytes() == b"verified" + assert (target / "PROVENANCE.txt").is_file() + + +def test_copy_toolchain_requires_explicit_deveco_fallback( + tmp_path, + monkeypatch, +) -> None: + monkeypatch.setattr( + build_portable, + "PREPARED_TOOLCHAIN_ROOT", + tmp_path / "missing", + ) + monkeypatch.setattr("hapsign.runtime.platform_tag", lambda: "windows") + + with pytest.raises(RuntimeError, match="prepare_toolchain.py"): + build_portable._copy_toolchain( + tmp_path / "HapSign", + keep_full_jbr=False, + allow_local_toolchain=False, + ) diff --git a/tests/test_client.py b/tests/test_client.py index 0277b54..616db43 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,12 +1,22 @@ """HTTP 客户端响应处理测试。""" +import logging from unittest.mock import Mock import pytest from hapsign.api import client as client_module from hapsign.api.client import HuaweiSignClient, TokenExpiredError -from hapsign.config import HEADER_OAUTH2_TOKEN, HEADER_TEAM_ID, HEADER_UID +from hapsign.config import ( + ACCEPT_LANGUAGE, + HEADER_ACCEPT_LANGUAGE, + HEADER_OAUTH2_TOKEN, + HEADER_TEAM_ID, + HEADER_UID, + HEADER_USER_AGENT, + USER_AGENT, +) +from hapsign.diagnostics import set_sensitive_logging def test_headers_include_credentials_and_optional_team() -> None: @@ -17,6 +27,12 @@ def test_headers_include_credentials_and_optional_team() -> None: assert headers[HEADER_OAUTH2_TOKEN] == "access-token" assert headers[HEADER_UID] == "user-id" assert headers[HEADER_TEAM_ID] == "team-id" + assert headers[HEADER_USER_AGENT] == USER_AGENT + assert headers[HEADER_ACCEPT_LANGUAGE] == ACCEPT_LANGUAGE + assert HEADER_USER_AGENT == "User-Agent" + assert HEADER_ACCEPT_LANGUAGE == "Accept-Language" + assert "Chrome/" in headers[HEADER_USER_AGENT] + assert USER_AGENT not in headers # 值不能被误当成 header 名 @pytest.mark.parametrize( @@ -80,3 +96,44 @@ def test_request_helpers(monkeypatch, method_name, request_name, expected) -> No assert result == expected response.raise_for_status.assert_called_once() + + +def test_http_diagnostics_require_sensitive_switch(caplog) -> None: + response = Mock( + status_code=200, + content=b'{"secret":"response-secret"}', + text='{"secret":"response-secret"}', + ) + response.json.return_value = {"ret": {"code": 0}, "secret": "response-secret"} + client = HuaweiSignClient("access-token", "user-id") + caplog.set_level(logging.DEBUG) + + try: + set_sensitive_logging(False) + client._log_http( + "POST", + "https://example.invalid/path?secret=query-secret", + response, + headers={"oauth2Token": "header-secret"}, + payload={"token": "payload-secret"}, + ) + assert "header-secret" not in caplog.text + assert "payload-secret" not in caplog.text + assert "response-secret" not in caplog.text + assert "query-secret" not in caplog.text + + caplog.clear() + set_sensitive_logging(True) + client._log_http( + "POST", + "https://example.invalid/path?secret=query-secret", + response, + headers={"oauth2Token": "header-secret"}, + payload={"token": "payload-secret"}, + ) + assert "header-secret" in caplog.text + assert "payload-secret" in caplog.text + assert "response-secret" in caplog.text + assert "query-secret" in caplog.text + finally: + set_sensitive_logging(False) diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..3a4beed --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,66 @@ +"""持久诊断日志配置测试。""" + +import logging + +from hapsign import diagnostics +from hapsign.settings import AppSettings + + +def test_file_logging_reconfigures_single_handler_and_sensitive_switch( + tmp_path, monkeypatch +) -> None: + monkeypatch.setattr(diagnostics, "log_directory", lambda: tmp_path / "logs") + root = logging.getLogger() + previous_level = root.level + + try: + first_path = diagnostics.configure_file_logging( + AppSettings(log_level="DEBUG", log_sensitive_data=False) + ) + logging.getLogger("hapsign.test").warning("diagnostic marker") + handlers = [ + handler + for handler in root.handlers + if getattr(handler, diagnostics._HANDLER_MARKER, False) + ] + for handler in handlers: + handler.flush() + + assert first_path == tmp_path / "logs" / "hapsign.log" + assert "diagnostic marker" in first_path.read_text(encoding="utf-8") + assert len(handlers) == 1 + assert diagnostics.sensitive_logging_enabled() is False + + diagnostics.configure_file_logging( + AppSettings(log_level="ERROR", log_sensitive_data=True) + ) + handlers = [ + handler + for handler in root.handlers + if getattr(handler, diagnostics._HANDLER_MARKER, False) + ] + assert len(handlers) == 1 + assert handlers[0].level == logging.ERROR + assert diagnostics.sensitive_logging_enabled() is True + finally: + for handler in list(root.handlers): + if getattr(handler, diagnostics._HANDLER_MARKER, False): + root.removeHandler(handler) + handler.close() + root.setLevel(previous_level) + diagnostics.set_sensitive_logging(False) + + +def test_redact_sensitive_text_hides_tokens_and_bearer_headers() -> None: + diagnostics.set_sensitive_logging(False) + + text = diagnostics.redact_sensitive_text( + "tempToken=TEMP accessToken=ACCESS Authorization: Bearer BEARER" + ) + + assert "TEMP" not in text + assert "ACCESS" not in text + assert "BEARER" not in text + assert "tempToken=" in text + assert "accessToken=" in text + assert "Authorization: Bearer " in text diff --git a/tests/test_gui_style.py b/tests/test_gui_style.py new file mode 100644 index 0000000..839d78b --- /dev/null +++ b/tests/test_gui_style.py @@ -0,0 +1,50 @@ +"""桌面样式的高 DPI 与设置页回归测试。""" + +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +pytest.importorskip("PySide6") + +from PySide6.QtWidgets import QApplication, QDialogButtonBox + +from hapsign import gui +from hapsign.settings import AppSettings + + +@pytest.fixture(scope="module") +def qt_app(): + app = QApplication.instance() or QApplication([]) + app.setStyle(gui.HapSignStyle("Fusion")) + app.setStyleSheet(gui.WINDOW_STYLE) + app.setFont(gui._application_font()) + return app + + +def test_styles_use_integer_logical_pixels(qt_app) -> None: + assert "font-size:" in gui.WINDOW_STYLE + assert "pt;" not in gui.WINDOW_STYLE + assert gui._application_font().pixelSize() == 14 + assert gui._fixed_width_font().pixelSize() == 13 + + +def test_settings_controls_have_consistent_custom_style(qt_app, tmp_path) -> None: + dialog = gui.SettingsDialog( + AppSettings(), + tmp_path / "logs" / "hapsign.log", + ) + dialog.ensurePolished() + + assert dialog.browser_combo.currentData() == "system_controlled" + assert dialog.browser_combo.sizeHint().height() >= 42 + assert dialog.storage_combo.sizeHint().height() >= 42 + assert dialog.log_level_combo.sizeHint().height() >= 42 + assert dialog.sensitive_check.sizeHint().height() >= 24 + + buttons = dialog.findChild(QDialogButtonBox) + save = buttons.button(QDialogButtonBox.StandardButton.Save) + cancel = buttons.button(QDialogButtonBox.StandardButton.Cancel) + assert save.objectName() == "primaryButton" + assert cancel.objectName() == "secondaryButton" diff --git a/tests/test_hap_inspect.py b/tests/test_hap_inspect.py new file mode 100644 index 0000000..2947c60 --- /dev/null +++ b/tests/test_hap_inspect.py @@ -0,0 +1,227 @@ +"""HAP 签名块检测测试。""" + +import struct +import zipfile +from pathlib import Path + +from hapsign.signing.hap_inspect import ( + HAP_SIG_BLOCK_HEADER_SIZE, + HAP_SIG_BLOCK_MAGIC_HI_V2, + HAP_SIG_BLOCK_MAGIC_HI_V3, + HAP_SIG_BLOCK_MAGIC_LO_V2, + HAP_SIG_BLOCK_MAGIC_LO_V3, + HAP_SIGN_SCHEME_V3_BLOCK_VERSION, + MAX_BLOCK_COUNT, + ZIP64_EOCD_LOCATOR_SIG, + ZIP64_EOCD_LOCATOR_SIZE, + is_hap_signed, +) + + +def _write_unsigned_hap(path: Path, *, comment: bytes = b"") -> None: + with zipfile.ZipFile(path, "w") as archive: + archive.writestr( + "module.json", + '{"app":{"bundleName":"com.example.app"}}', + ) + archive.comment = comment + + +def _find_eocd_offset(data: bytes) -> int: + """定位测试包的真实 EOCD,忽略 comment 内的伪签名。""" + relative = data.rfind(b"PK\x05\x06") + while relative >= 0: + if relative + 22 <= len(data): + comment_size = struct.unpack_from(" None: + """在 ZIP Central Directory 前插入伪造的 HAP Signing Block header。""" + data = bytearray(path.read_bytes()) + eocd_offset = _find_eocd_offset(data) + cd_offset = struct.unpack_from(" None: + hap = tmp_path / "app.hap" + _write_unsigned_hap(hap) + + assert is_hap_signed(str(hap)) is False + + +def test_v3_signing_block_is_detected(tmp_path: Path) -> None: + hap = tmp_path / "app.hap" + _write_unsigned_hap(hap) + _inject_signing_block( + hap, + magic_lo=HAP_SIG_BLOCK_MAGIC_LO_V3, + magic_hi=HAP_SIG_BLOCK_MAGIC_HI_V3, + version=HAP_SIGN_SCHEME_V3_BLOCK_VERSION, + block_size=64, + ) + + assert is_hap_signed(str(hap)) is True + + +def test_v2_signing_block_is_detected(tmp_path: Path) -> None: + hap = tmp_path / "app.hap" + _write_unsigned_hap(hap) + _inject_signing_block( + hap, + magic_lo=HAP_SIG_BLOCK_MAGIC_LO_V2, + magic_hi=HAP_SIG_BLOCK_MAGIC_HI_V2, + version=2, + block_size=48, + ) + + assert is_hap_signed(str(hap)) is True + + +def test_invalid_magic_is_not_signed(tmp_path: Path) -> None: + hap = tmp_path / "app.hap" + _write_unsigned_hap(hap) + _inject_signing_block( + hap, + magic_lo=0, + magic_hi=0, + version=HAP_SIGN_SCHEME_V3_BLOCK_VERSION, + block_size=64, + ) + + assert is_hap_signed(str(hap)) is False + + +def test_eocd_signature_inside_comment_is_ignored(tmp_path: Path) -> None: + hap = tmp_path / "comment.hap" + _write_unsigned_hap(hap, comment=b"contains-PK\x05\x06-marker") + _inject_signing_block( + hap, + magic_lo=HAP_SIG_BLOCK_MAGIC_LO_V3, + magic_hi=HAP_SIG_BLOCK_MAGIC_HI_V3, + version=HAP_SIGN_SCHEME_V3_BLOCK_VERSION, + block_size=64, + ) + + assert is_hap_signed(hap) is True + + +def test_invalid_central_directory_layout_is_not_signed(tmp_path: Path) -> None: + hap = tmp_path / "invalid-directory.hap" + _write_unsigned_hap(hap) + _inject_signing_block( + hap, + magic_lo=HAP_SIG_BLOCK_MAGIC_LO_V3, + magic_hi=HAP_SIG_BLOCK_MAGIC_HI_V3, + version=HAP_SIGN_SCHEME_V3_BLOCK_VERSION, + block_size=64, + ) + data = bytearray(hap.read_bytes()) + eocd_offset = _find_eocd_offset(data) + struct.pack_into(" None: + hap = tmp_path / "zero-blocks.hap" + _write_unsigned_hap(hap) + _inject_signing_block( + hap, + magic_lo=HAP_SIG_BLOCK_MAGIC_LO_V3, + magic_hi=HAP_SIG_BLOCK_MAGIC_HI_V3, + version=HAP_SIGN_SCHEME_V3_BLOCK_VERSION, + block_size=64, + block_count=0, + ) + + assert is_hap_signed(hap) is False + + +def test_block_count_must_fit_inside_signing_block(tmp_path: Path) -> None: + hap = tmp_path / "too-many-blocks.hap" + _write_unsigned_hap(hap) + _inject_signing_block( + hap, + magic_lo=HAP_SIG_BLOCK_MAGIC_LO_V3, + magic_hi=HAP_SIG_BLOCK_MAGIC_HI_V3, + version=HAP_SIGN_SCHEME_V3_BLOCK_VERSION, + block_size=64, + block_count=3, + ) + + assert is_hap_signed(hap) is False + + +def test_block_count_above_max_is_not_signed(tmp_path: Path) -> None: + hap = tmp_path / "max-blocks.hap" + _write_unsigned_hap(hap) + block_count = MAX_BLOCK_COUNT + 1 + block_size = HAP_SIG_BLOCK_HEADER_SIZE + block_count * 12 + 64 + _inject_signing_block( + hap, + magic_lo=HAP_SIG_BLOCK_MAGIC_LO_V3, + magic_hi=HAP_SIG_BLOCK_MAGIC_HI_V3, + version=HAP_SIGN_SCHEME_V3_BLOCK_VERSION, + block_size=block_size, + block_count=block_count, + ) + + assert is_hap_signed(hap) is False + + +def test_zip64_locator_bytes_before_eocd_are_rejected(tmp_path: Path) -> None: + """对齐 VerifyHap:EOCD 前 20 字节若为 ZIP64 Locator 则不支持。""" + hap = tmp_path / "zip64-locator.hap" + _write_unsigned_hap(hap) + _inject_signing_block( + hap, + magic_lo=HAP_SIG_BLOCK_MAGIC_LO_V3, + magic_hi=HAP_SIG_BLOCK_MAGIC_HI_V3, + version=HAP_SIGN_SCHEME_V3_BLOCK_VERSION, + block_size=64, + ) + data = bytearray(hap.read_bytes()) + eocd_offset = _find_eocd_offset(data) + locator_pos = eocd_offset - ZIP64_EOCD_LOCATOR_SIZE + assert locator_pos >= 0 + struct.pack_into(" None: + assert is_hap_signed(str(tmp_path / "missing.hap")) is False diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 05dd312..68e3cb8 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -1,16 +1,24 @@ """签名流程中的本地、无网络逻辑测试。""" +import base64 import json +import logging +import os +import threading import zipfile from datetime import date, timedelta +from types import SimpleNamespace from unittest.mock import Mock import pytest from hapsign.api.client import TokenExpiredError +from hapsign.cancellation import OperationCancelled from hapsign.config import ACL_PERMISSION_WHITELIST +from hapsign.diagnostics import set_sensitive_logging from hapsign.models import TokenInfo from hapsign.pipeline import SignPipeline +from hapsign.token import secure_token_cache def _pipeline(tmp_path, monkeypatch) -> SignPipeline: @@ -22,7 +30,29 @@ def _pipeline(tmp_path, monkeypatch) -> SignPipeline: ) +def _fake_dpapi(monkeypatch) -> None: + """用可逆的确定性伪加密替代真实 DPAPI。 + + 使加密格式相关测试不依赖系统用户配置(沙箱/服务账户可能没有可用的 + 用户配置,CryptProtectData 会失败),任何平台都能稳定运行。 + """ + monkeypatch.setattr( + secure_token_cache, "_dpapi_protect", lambda data: base64.b64encode(data) + ) + + def _unprotect(data: bytes) -> bytes: + try: + return base64.b64decode(data) + except ValueError as exc: + raise OSError(f"DPAPI 解密失败(测试伪实现): {exc}") from None + + monkeypatch.setattr(secure_token_cache, "_dpapi_unprotect", _unprotect) + # 让 protect/decrypt 的跨平台守卫走 DPAPI 分支;测试结束后由 monkeypatch 恢复。 + monkeypatch.setattr(secure_token_cache.os, "name", "nt") + + def test_token_cache_round_trip(tmp_path, monkeypatch) -> None: + _fake_dpapi(monkeypatch) pipeline = _pipeline(tmp_path, monkeypatch) pipeline._token_info = TokenInfo( access_token="access", @@ -34,6 +64,96 @@ def test_token_cache_round_trip(tmp_path, monkeypatch) -> None: pipeline._save_token_cache() assert pipeline._load_token_cache()["access_token"] == "access" + assert not (tmp_path / "signing_files" / ".token_cache.json.tmp").exists() + + +def test_token_cache_write_failure_keeps_old_file(tmp_path, monkeypatch) -> None: + pipeline = _pipeline(tmp_path, monkeypatch) + cache_path = tmp_path / "signing_files" / ".token_cache.json" + cache_path.parent.mkdir() + cache_path.write_text('{"old": true}', encoding="utf-8") + pipeline._token_info = TokenInfo( + access_token="access", + user_id="user", + jwt_token="jwt", + ) + + def _broken_dumps(*args, **kwargs): + raise OSError("disk full") + + monkeypatch.setattr("hapsign.pipeline.json.dumps", _broken_dumps) + + pipeline._save_token_cache() + + assert cache_path.read_text(encoding="utf-8") == '{"old": true}' + assert not (tmp_path / "signing_files" / ".token_cache.json.tmp").exists() + + +def test_token_cache_replace_failure_keeps_old_file(tmp_path, monkeypatch) -> None: + pipeline = _pipeline(tmp_path, monkeypatch) + cache_path = tmp_path / "signing_files" / ".token_cache.json" + cache_path.parent.mkdir() + cache_path.write_text('{"old": true}', encoding="utf-8") + pipeline._token_info = TokenInfo( + access_token="access", + user_id="user", + jwt_token="jwt", + ) + + def _broken_replace(src, dst): + raise OSError("replace failed") + + monkeypatch.setattr("hapsign.pipeline.os.replace", _broken_replace) + + pipeline._save_token_cache() + + assert cache_path.read_text(encoding="utf-8") == '{"old": true}' + assert not (tmp_path / "signing_files" / ".token_cache.json.tmp").exists() + + +def test_token_cache_stale_tmp_does_not_break_load(tmp_path, monkeypatch) -> None: + _fake_dpapi(monkeypatch) + pipeline = _pipeline(tmp_path, monkeypatch) + cache_path = tmp_path / "signing_files" / ".token_cache.json" + cache_path.parent.mkdir() + # 残留的半截 tmp 文件不应被当成正式缓存。 + cache_path.with_suffix(cache_path.suffix + ".tmp").write_text( + '{"access_tok', encoding="utf-8" + ) + + assert pipeline._load_token_cache() is None + + pipeline._token_info = TokenInfo( + access_token="access", + user_id="user", + jwt_token="jwt", + ) + pipeline._save_token_cache() + + assert pipeline._load_token_cache()["access_token"] == "access" + assert not cache_path.with_suffix(cache_path.suffix + ".tmp").exists() + + +def test_custom_state_dir_owns_token_cache(tmp_path, monkeypatch) -> None: + _fake_dpapi(monkeypatch) + monkeypatch.chdir(tmp_path) + state_dir = tmp_path / "app-data" + pipeline = SignPipeline( + hap_path="app.hap", + bundle_name="com.example.app", + work_dir=str(tmp_path / "work"), + state_dir=str(state_dir), + ) + pipeline._token_info = TokenInfo( + access_token="access", + user_id="user", + jwt_token="jwt", + ) + + pipeline._save_token_cache() + + assert (state_dir / ".token_cache.json").is_file() + assert not (tmp_path / "signing_files" / ".token_cache.json").exists() def test_expired_token_cache_is_ignored(tmp_path, monkeypatch) -> None: @@ -46,6 +166,7 @@ def test_expired_token_cache_is_ignored(tmp_path, monkeypatch) -> None: "creation_date": (date.today() - timedelta(days=1)).isoformat(), "access_token": "access", "user_id": "user", + "jwt_token": "jwt", } ), encoding="utf-8", @@ -54,6 +175,181 @@ def test_expired_token_cache_is_ignored(tmp_path, monkeypatch) -> None: assert pipeline._load_token_cache() is None +def test_token_cache_without_jwt_is_ignored(tmp_path, monkeypatch) -> None: + pipeline = _pipeline(tmp_path, monkeypatch) + cache_path = tmp_path / "signing_files" / ".token_cache.json" + cache_path.parent.mkdir() + cache_path.write_text( + json.dumps( + { + "creation_date": date.today().isoformat(), + "access_token": "access", + "user_id": "user", + } + ), + encoding="utf-8", + ) + + assert pipeline._load_token_cache() is None + + +def test_token_cache_encrypted_on_disk(tmp_path, monkeypatch) -> None: + _fake_dpapi(monkeypatch) + pipeline = _pipeline(tmp_path, monkeypatch) + pipeline._token_info = TokenInfo( + access_token="access", + refresh_token="refresh", + user_id="user", + jwt_token="jwt", + ) + + pipeline._save_token_cache() + + raw = (tmp_path / "signing_files" / ".token_cache.json").read_bytes() + assert raw.startswith(secure_token_cache._HEADER.encode("ascii")) + assert b"access" not in raw + + +def test_token_cache_migrates_legacy_plaintext(tmp_path, monkeypatch) -> None: + _fake_dpapi(monkeypatch) + pipeline = _pipeline(tmp_path, monkeypatch) + cache_path = tmp_path / "signing_files" / ".token_cache.json" + cache_path.parent.mkdir() + cache_path.write_text( + json.dumps( + { + "creation_date": date.today().isoformat(), + "access_token": "access", + "refresh_token": "refresh", + "user_id": "user", + "jwt_token": "jwt", + } + ), + encoding="utf-8", + ) + + cache = pipeline._load_token_cache() + + assert cache is not None + assert cache["access_token"] == "access" + # 迁移后磁盘上应为加密格式。 + raw = cache_path.read_bytes() + assert raw.startswith(secure_token_cache._HEADER.encode("ascii")) + assert b"access" not in raw + assert pipeline._load_token_cache()["access_token"] == "access" + + +def test_token_cache_bad_base64_falls_back_to_relogin(tmp_path, monkeypatch) -> None: + pipeline = _pipeline(tmp_path, monkeypatch) + cache_path = tmp_path / "signing_files" / ".token_cache.json" + cache_path.parent.mkdir() + cache_path.write_bytes(b"hapsign-token-v1:!!!not-base64!!!") + + assert pipeline._load_token_cache() is None + + +def test_token_cache_corrupt_payload_falls_back_to_relogin( + tmp_path, monkeypatch +) -> None: + _fake_dpapi(monkeypatch) + pipeline = _pipeline(tmp_path, monkeypatch) + cache_path = tmp_path / "signing_files" / ".token_cache.json" + cache_path.parent.mkdir() + # 头 + 合法 base64 但非 DPAPI blob:解密必须失败而不是崩溃。 + cache_path.write_bytes(b"hapsign-token-v1:" + base64.b64encode(b"not a dpapi blob")) + + assert pipeline._load_token_cache() is None + + +@pytest.mark.parametrize("payload", ["[]", "null", '"not-an-object"']) +def test_token_cache_non_object_falls_back_to_relogin( + tmp_path, monkeypatch, payload +) -> None: + pipeline = _pipeline(tmp_path, monkeypatch) + cache_path = tmp_path / "signing_files" / ".token_cache.json" + cache_path.parent.mkdir() + cache_path.write_text(payload, encoding="utf-8") + + assert pipeline._load_token_cache() is None + + +@pytest.mark.parametrize("payload", ["[]", "null", '"not-an-object"']) +def test_metadata_non_object_is_ignored(tmp_path, monkeypatch, payload) -> None: + pipeline = _pipeline(tmp_path, monkeypatch) + pipeline._metadata_path = str(tmp_path / "signing" / "metadata.json") + with open(pipeline._metadata_path, "w", encoding="utf-8") as metadata: + metadata.write(payload) + + assert pipeline._load_cached_metadata() is None + + +def test_pipeline_failure_logs_redacted_exception( + caplog, tmp_path, monkeypatch +) -> None: + set_sensitive_logging(False) + pipeline = _pipeline(tmp_path, monkeypatch) + secret = "secret-temp-token" + caplog.set_level(logging.DEBUG, logger="hapsign.pipeline") + + result = pipeline._run_steps( + [ + ( + "登录", + Mock( + side_effect=RuntimeError( + "request failed: " + f"/authrouter/auth/api/temptoken/check?tempToken={secret}" + ) + ), + ) + ] + ) + + assert result is False + assert secret not in caplog.text + assert "tempToken=" in caplog.text + + +def test_token_cache_protect_failure_keeps_old_file(tmp_path, monkeypatch) -> None: + _fake_dpapi(monkeypatch) + monkeypatch.setattr( + secure_token_cache, + "_dpapi_protect", + Mock(side_effect=OSError("CryptProtectData 失败: 无法加载用户配置")), + ) + pipeline = _pipeline(tmp_path, monkeypatch) + cache_path = tmp_path / "signing_files" / ".token_cache.json" + cache_path.parent.mkdir() + cache_path.write_bytes(b"hapsign-token-v1:" + base64.b64encode(b"old-cache")) + pipeline._token_info = TokenInfo( + access_token="new", + user_id="user", + jwt_token="jwt", + ) + + pipeline._save_token_cache() + + # 加密失败:保留原缓存,绝不落明文。 + raw = cache_path.read_bytes() + assert raw.startswith(secure_token_cache._HEADER.encode("ascii")) + assert b"new" not in raw + assert not (tmp_path / "signing_files" / ".token_cache.json.tmp").exists() + + +def test_token_cache_encrypted_cache_on_non_windows_ignored( + tmp_path, monkeypatch +) -> None: + _fake_dpapi(monkeypatch) + # 模拟加密缓存被带到非 Windows 平台:decrypt 必须显式失败而不是崩溃。 + monkeypatch.setattr(secure_token_cache.os, "name", "posix") + pipeline = _pipeline(tmp_path, monkeypatch) + cache_path = tmp_path / "signing_files" / ".token_cache.json" + cache_path.parent.mkdir() + cache_path.write_bytes(b"hapsign-token-v1:" + base64.b64encode(b"whatever")) + + assert pipeline._load_token_cache() is None + + def test_metadata_does_not_store_keystore_password(tmp_path, monkeypatch) -> None: pipeline = _pipeline(tmp_path, monkeypatch) @@ -112,6 +408,7 @@ def test_with_refresh_skips_refresh_on_success(tmp_path, monkeypatch) -> None: def test_with_refresh_refreshes_and_retries_on_token_expired( tmp_path, monkeypatch ) -> None: + _fake_dpapi(monkeypatch) pipeline = _pipeline_with_token(tmp_path, monkeypatch) func = Mock(side_effect=[TokenExpiredError("expired"), "ok"]) @@ -138,3 +435,327 @@ def test_with_refresh_propagates_persistent_token_expired( # 仍只刷新一次,重试时再次抛出不再二次刷新 pipeline._token_exchange.refresh_access_token.assert_called_once_with("jwt") + + +def test_run_installs_already_signed_hap_directly(tmp_path, monkeypatch) -> None: + from hapsign import pipeline as pipeline_module + + hap = tmp_path / "signed.hap" + hap.write_bytes(b"placeholder") + pipeline = SignPipeline( + hap_path=str(hap), + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing"), + ) + monkeypatch.setattr(pipeline_module, "is_hap_signed", lambda _path: True) + install = Mock() + monkeypatch.setattr( + pipeline_module, + "Installer", + lambda **_kwargs: SimpleNamespace( + get_udid=lambda: "A" * 64, + install=install, + close=Mock(), + ), + ) + login = Mock(side_effect=AssertionError("login should be skipped")) + monkeypatch.setattr(pipeline, "_step_login", login) + monkeypatch.setattr(pipeline, "_step_sign_hap", Mock()) + + assert pipeline.run() is True + install.assert_called_once_with(str(hap)) + login.assert_not_called() + pipeline._step_sign_hap.assert_not_called() + + +def test_run_unsigned_hap_does_not_take_signed_shortcut(tmp_path, monkeypatch) -> None: + from hapsign import pipeline as pipeline_module + + hap = tmp_path / "unsigned.hap" + hap.write_bytes(b"placeholder") + pipeline = SignPipeline( + hap_path=str(hap), + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing"), + ) + monkeypatch.setattr(pipeline_module, "is_hap_signed", lambda _path: False) + monkeypatch.setattr(pipeline, "_step_check_device", Mock()) + monkeypatch.setattr(pipeline, "_load_cached_metadata", lambda: None) + monkeypatch.setattr(pipeline, "_load_token_cache", lambda: None) + monkeypatch.setattr( + pipeline, + "_step_login", + Mock(side_effect=RuntimeError("stop before network")), + ) + + assert pipeline.run() is False + pipeline._step_check_device.assert_called_once() + pipeline._step_login.assert_called_once() + + +def test_run_stops_before_signing_when_device_is_unavailable( + tmp_path, monkeypatch +) -> None: + from hapsign import pipeline as pipeline_module + + pipeline = _pipeline(tmp_path, monkeypatch) + monkeypatch.setattr(pipeline_module, "is_hap_signed", Mock()) + monkeypatch.setattr( + pipeline, + "_step_check_device", + Mock(side_effect=RuntimeError("未检测到可用设备")), + ) + login = Mock() + monkeypatch.setattr(pipeline, "_step_login", login) + + assert pipeline.run() is False + pipeline_module.is_hap_signed.assert_not_called() + login.assert_not_called() + + +def test_run_always_closes_hdc_installer(tmp_path, monkeypatch) -> None: + pipeline = _pipeline(tmp_path, monkeypatch) + close = Mock() + pipeline._installer = SimpleNamespace(close=close) + monkeypatch.setattr( + pipeline, + "_run_pipeline", + Mock(side_effect=RuntimeError("pipeline failed")), + ) + + with pytest.raises(RuntimeError, match="pipeline failed"): + pipeline.run() + + close.assert_called_once() + assert pipeline._installer is None + + +def test_register_device_reuses_preflight_udid(tmp_path, monkeypatch) -> None: + pipeline = _pipeline(tmp_path, monkeypatch) + pipeline._udid = "B" * 64 + pipeline._team_id = "team" + pipeline._device_api = SimpleNamespace( + add_device=Mock(return_value=True), + find_device_id=Mock(return_value="device-id"), + ) + get_udid = Mock(side_effect=AssertionError("UDID should be reused")) + from hapsign import pipeline as pipeline_module + + monkeypatch.setattr( + pipeline_module, + "Installer", + lambda **_kwargs: SimpleNamespace(get_udid=get_udid), + ) + + pipeline._step_register_device() + + get_udid.assert_not_called() + pipeline._device_api.add_device.assert_called_once() + assert pipeline._device_id == "device-id" + + +def test_pipeline_runtime_state_initialized(tmp_path, monkeypatch) -> None: + pipeline = _pipeline(tmp_path, monkeypatch) + + assert pipeline._team_id == "" + assert pipeline._app_info is None + assert pipeline._cert_result is None + assert pipeline._p12_path == "" + assert pipeline._cer_path == "" + assert pipeline._p7b_path == "" + assert pipeline._csr_path == "" + assert pipeline._csr_content == "" + assert pipeline._signed_hap_path == "" + + +def test_force_refresh_signing_decision_does_not_leak(tmp_path, monkeypatch) -> None: + from hapsign import pipeline as pipeline_module + + pipeline = _pipeline(tmp_path, monkeypatch) + pipeline.force_refresh_token = True + pipeline.force_refresh_signing = False + monkeypatch.setattr(pipeline_module, "is_hap_signed", lambda _path: False) + monkeypatch.setattr(pipeline, "_step_check_device", Mock()) + clear_cache = Mock() + monkeypatch.setattr(pipeline, "_clear_token_cache", clear_cache) + load_token_cache = Mock() + monkeypatch.setattr(pipeline, "_load_token_cache", load_token_cache) + login = Mock() + monkeypatch.setattr(pipeline, "_step_login", login) + exchange = Mock() + monkeypatch.setattr(pipeline, "_step_exchange_token", exchange) + monkeypatch.setattr(pipeline, "_run_steps", Mock(return_value=True)) + + assert pipeline._run_pipeline() is True + + # 强制刷新 token 走重新登录路径(不再读取 token 缓存) + clear_cache.assert_called_once() + login.assert_called_once() + exchange.assert_called_once() + load_token_cache.assert_not_called() + # 局部决策不得泄漏到实例属性:复用实例再次运行不应永久强制刷新签名 + assert pipeline.force_refresh_signing is False + assert pipeline.force_refresh_token is True + + +def test_cancelled_pipeline_closes_installer_and_propagates( + tmp_path, monkeypatch +) -> None: + cancel_event = threading.Event() + pipeline = SignPipeline( + hap_path="app.hap", + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing"), + cancel_event=cancel_event, + ) + close = Mock() + pipeline._installer = SimpleNamespace(close=close) + cancel_event.set() + + with pytest.raises(OperationCancelled): + pipeline.run() + + close.assert_called_once() + assert pipeline._installer is None + + +def test_pipeline_reports_stage_progress(tmp_path, monkeypatch) -> None: + values = [] + pipeline = SignPipeline( + hap_path="app.hap", + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing"), + progress_callback=lambda value, label: values.append((value, label)), + ) + monkeypatch.setattr(pipeline, "_run_pipeline", Mock(return_value=True)) + + assert pipeline.run() is True + assert values[0][0] == 2 + assert values[-1] == (100, "安装完成") + + +def _prepare_sign_step(pipeline: SignPipeline) -> None: + pipeline._cer_path = "certificate.cer" + pipeline._p7b_path = "profile.p7b" + pipeline._p12_path = "keystore.p12" + + +def test_kept_signed_hap_preserves_unrelated_hap(tmp_path, monkeypatch) -> None: + from hapsign import pipeline as pipeline_module + + output_dir = tmp_path / "signed_haps" + output_dir.mkdir() + source = output_dir / "new-app.hap" + source.write_bytes(b"source") + unrelated = output_dir / "previous_signed.hap" + unrelated.write_bytes(b"old") + generated = output_dir / "old-app_signed.hap" + generated.write_bytes(b"generated-old") + (output_dir / ".hapsign-signed-haps.json").write_text( + json.dumps({"version": 1, "generated_haps": [generated.name, source.name]}), + encoding="utf-8", + ) + pipeline = SignPipeline( + hap_path=str(source), + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing"), + signed_output_dir=str(output_dir), + keep_signed_hap=True, + ) + _prepare_sign_step(pipeline) + + class FakeSigner: + def __init__(self, **_kwargs) -> None: + pass + + def sign_hap(self, *_args) -> bool: + output_path = _args[-1] + with open(output_path, "wb") as output: + output.write(b"new") + return True + + monkeypatch.setattr(pipeline_module, "HapSigner", FakeSigner) + + pipeline._step_sign_hap() + + kept = list(output_dir.glob("*.hap")) + assert sorted(kept) == sorted( + [source, unrelated, output_dir / "new-app_signed.hap"] + ) + assert source.read_bytes() == b"source" + assert unrelated.read_bytes() == b"old" + signed_path = output_dir / "new-app_signed.hap" + assert signed_path.read_bytes() == b"new" + assert pipeline._signed_hap_path == str(signed_path) + assert json.loads( + (output_dir / ".hapsign-signed-haps.json").read_text(encoding="utf-8") + ) == {"version": 1, "generated_haps": [signed_path.name]} + + +def test_failed_signing_preserves_previous_output(tmp_path, monkeypatch) -> None: + from hapsign import pipeline as pipeline_module + + output_dir = tmp_path / "signed_haps" + output_dir.mkdir() + old = output_dir / "previous_signed.hap" + old.write_bytes(b"old") + pipeline = SignPipeline( + hap_path=str(tmp_path / "new-app.hap"), + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing"), + signed_output_dir=str(output_dir), + keep_signed_hap=True, + ) + _prepare_sign_step(pipeline) + + class FailingSigner: + def __init__(self, **_kwargs) -> None: + pass + + def sign_hap(self, *_args) -> bool: + with open(_args[-1], "wb") as output: + output.write(b"partial") + raise RuntimeError("signing failed") + + monkeypatch.setattr(pipeline_module, "HapSigner", FailingSigner) + + with pytest.raises(RuntimeError, match="signing failed"): + pipeline._step_sign_hap() + + assert list(output_dir.glob("*.hap")) == [old] + assert old.read_bytes() == b"old" + + +def test_disabled_signed_hap_retention_uses_temporary_file( + tmp_path, monkeypatch +) -> None: + from hapsign import pipeline as pipeline_module + + output_dir = tmp_path / "signed_haps" + pipeline = SignPipeline( + hap_path=str(tmp_path / "new-app.hap"), + bundle_name="com.example.app", + work_dir=str(tmp_path / "signing"), + signed_output_dir=str(output_dir), + keep_signed_hap=False, + ) + _prepare_sign_step(pipeline) + + class FakeSigner: + def __init__(self, **_kwargs) -> None: + pass + + def sign_hap(self, *_args) -> bool: + with open(_args[-1], "wb") as output: + output.write(b"temporary") + return True + + monkeypatch.setattr(pipeline_module, "HapSigner", FakeSigner) + + pipeline._step_sign_hap() + temporary_path = pipeline._signed_hap_path + assert os.path.isfile(temporary_path) + assert not output_dir.exists() + + pipeline._cleanup_temporary_signed_hap() + assert not os.path.exists(temporary_path) diff --git a/tests/test_prepare_toolchain.py b/tests/test_prepare_toolchain.py new file mode 100644 index 0000000..f7c87fc --- /dev/null +++ b/tests/test_prepare_toolchain.py @@ -0,0 +1,90 @@ +"""公开工具链下载、校验和安全提取测试。""" + +from __future__ import annotations + +import hashlib +import io +import tarfile +import zipfile + +import pytest + +from scripts import prepare_toolchain + + +def test_verify_file_rejects_wrong_size_and_hash(tmp_path) -> None: + artifact = tmp_path / "artifact.bin" + artifact.write_bytes(b"locked") + + with pytest.raises(RuntimeError, match="大小校验失败"): + prepare_toolchain._verify_file( + artifact, + {"size": 5}, + label="test", + ) + + with pytest.raises(RuntimeError, match="SHA-256 校验失败"): + prepare_toolchain._verify_file( + artifact, + {"size": 6, "sha256": "0" * 64}, + label="test", + ) + + +def test_extract_nested_toolchains_selects_exact_basename(tmp_path) -> None: + sdk = tmp_path / "sdk.tar.gz" + expected = "toolchains-windows-x64-test.zip" + payload = b"toolchains" + with tarfile.open(sdk, mode="w:gz") as archive: + ignored = tarfile.TarInfo("sdk/linux/toolchains-linux.zip") + ignored.size = 5 + archive.addfile(ignored, io.BytesIO(b"linux")) + selected = tarfile.TarInfo(f"sdk/windows/{expected}") + selected.size = len(payload) + archive.addfile(selected, io.BytesIO(payload)) + + output = tmp_path / expected + prepare_toolchain._extract_nested_toolchains( + sdk, + expected_name=expected, + destination=output, + ) + + assert output.read_bytes() == payload + + +def test_extract_openharmony_files_checks_locked_hash(tmp_path) -> None: + archive_path = tmp_path / "toolchains.zip" + with zipfile.ZipFile(archive_path, mode="w") as archive: + archive.writestr("sdk/toolchains/hdc.exe", b"hdc") + archive.writestr("sdk/toolchains/lib/hap-sign-tool.jar", b"signer") + + output = tmp_path / "prepared" + prepare_toolchain._extract_openharmony_files( + archive_path, + output, + { + "bin/hdc.exe": { + "archive_suffix": "hdc.exe", + "sha256": hashlib.sha256(b"hdc").hexdigest(), + }, + "lib/hap-sign-tool.jar": { + "archive_suffix": "lib/hap-sign-tool.jar", + "sha256": hashlib.sha256(b"signer").hexdigest(), + }, + }, + ) + + assert (output / "bin" / "hdc.exe").read_bytes() == b"hdc" + assert (output / "lib" / "hap-sign-tool.jar").read_bytes() == b"signer" + + +def test_safe_extract_zip_rejects_parent_traversal(tmp_path) -> None: + archive_path = tmp_path / "unsafe.zip" + with zipfile.ZipFile(archive_path, mode="w") as archive: + archive.writestr("../escaped.txt", "unsafe") + + with pytest.raises(RuntimeError, match="越界路径"): + prepare_toolchain._safe_extract_zip(archive_path, tmp_path / "target") + + assert not (tmp_path / "escaped.txt").exists() diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..727594e --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,86 @@ +"""跨平台运行目录与便携工具链发现测试。""" + +from hapsign import runtime + + +def test_app_data_dir_honors_override(tmp_path, monkeypatch) -> None: + target = tmp_path / "state" + monkeypatch.setenv("HAPSIGN_DATA_DIR", str(target)) + + assert runtime.app_data_dir() == target.resolve() + + +def test_app_data_dir_defaults_to_application_dir(tmp_path, monkeypatch) -> None: + monkeypatch.delenv("HAPSIGN_DATA_DIR", raising=False) + monkeypatch.setattr(runtime, "application_dir", lambda: tmp_path) + + assert runtime.app_data_dir() == tmp_path + + +def test_portable_toolchain_takes_precedence(tmp_path, monkeypatch) -> None: + resources = tmp_path / "resources" + monkeypatch.setenv("HAPSIGN_RESOURCE_DIR", str(resources)) + monkeypatch.setattr(runtime.platform, "system", lambda: "Windows") + root = resources / "toolchain" / "windows" + paths = [ + root / "jbr" / "bin" / "java.exe", + root / "jbr" / "bin" / "keytool.exe", + root / "lib" / "hap-sign-tool.jar", + root / "bin" / "hdc.exe", + ] + for path in paths: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"tool") + + toolchain = runtime.discover_toolchain() + + assert toolchain.source == "portable" + assert toolchain.missing() == [] + assert toolchain.hdc == paths[-1] + + +def test_portable_public_runtime_takes_precedence_over_legacy_jbr( + tmp_path, + monkeypatch, +) -> None: + resources = tmp_path / "resources" + monkeypatch.setenv("HAPSIGN_RESOURCE_DIR", str(resources)) + monkeypatch.setattr(runtime.platform, "system", lambda: "Windows") + root = resources / "toolchain" / "windows" + for name in ("java.exe", "keytool.exe"): + for runtime_name in ("runtime", "jbr"): + path = root / runtime_name / "bin" / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(runtime_name.encode()) + for path in ( + root / "lib" / "hap-sign-tool.jar", + root / "bin" / "hdc.exe", + ): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"tool") + + toolchain = runtime.discover_toolchain() + + assert toolchain.java == root / "runtime" / "bin" / "java.exe" + assert toolchain.keytool == root / "runtime" / "bin" / "keytool.exe" + + +def test_signed_hap_only_requires_hdc(tmp_path) -> None: + hdc = tmp_path / "hdc" + hdc.write_bytes(b"tool") + toolchain = runtime.ToolchainPaths( + java=tmp_path / "missing-java", + keytool=tmp_path / "missing-keytool", + hap_sign_tool=tmp_path / "missing.jar", + hdc=hdc, + source="test", + ) + + assert toolchain.missing(require_signing=False) == [] + assert len(toolchain.missing(require_signing=True)) == 3 + + +def test_platform_tag_normalizes_darwin(monkeypatch) -> None: + monkeypatch.setattr(runtime.platform, "system", lambda: "Darwin") + + assert runtime.platform_tag() == "macos" diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..7537dff --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,96 @@ +"""桌面版设置与目录规则测试。""" + +import json + +from hapsign import settings + + +def test_load_missing_config_uses_safe_defaults(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("HAPSIGN_CONFIG_FILE", str(tmp_path / "missing.json")) + + loaded = settings.load_settings() + + assert loaded == settings.AppSettings() + assert loaded.browser_mode == "system_controlled" + assert loaded.signing_storage == "program" + assert loaded.log_sensitive_data is False + assert loaded.keep_signed_hap is True + + +def test_settings_round_trip(tmp_path, monkeypatch) -> None: + config = tmp_path / "config" / "hapsign.json" + monkeypatch.setenv("HAPSIGN_CONFIG_FILE", str(config)) + expected = settings.AppSettings( + log_level="DEBUG", + signing_storage="custom", + custom_signing_dir=str(tmp_path / "keys"), + browser_mode="system", + log_sensitive_data=True, + keep_signed_hap=False, + ) + + assert settings.save_settings(expected) == config + assert settings.load_settings() == expected + assert not config.with_suffix(".json.tmp").exists() + + +def test_invalid_values_fall_back_and_sensitive_requires_boolean( + tmp_path, monkeypatch +) -> None: + config = tmp_path / "hapsign.json" + monkeypatch.setenv("HAPSIGN_CONFIG_FILE", str(config)) + config.write_text( + json.dumps( + { + "log_level": "verbose", + "signing_storage": "unknown", + "browser_mode": "other", + "log_sensitive_data": "true", + "keep_signed_hap": "false", + } + ), + encoding="utf-8", + ) + + loaded = settings.load_settings() + + assert loaded.log_level == "INFO" + assert loaded.signing_storage == "program" + assert loaded.browser_mode == "system_controlled" + assert loaded.log_sensitive_data is False + assert loaded.keep_signed_hap is True + + +def test_signing_directory_modes(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(settings, "application_dir", lambda: tmp_path / "app") + monkeypatch.setattr(settings, "user_local_data_dir", lambda: tmp_path / "local") + + assert settings.signing_files_dir(settings.AppSettings()) == ( + tmp_path / "app" / "signing_files" + ) + assert settings.signing_files_dir( + settings.AppSettings(signing_storage="appdata") + ) == (tmp_path / "local" / "signing_files") + assert settings.signing_files_dir( + settings.AppSettings( + signing_storage="custom", + custom_signing_dir=str(tmp_path / "custom"), + ) + ) == (tmp_path / "custom") + + +def test_data_directory_environment_override_wins(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("HAPSIGN_DATA_DIR", str(tmp_path / "override")) + + assert settings.signing_files_dir( + settings.AppSettings(signing_storage="appdata") + ) == (tmp_path / "override" / "signing_files") + + +def test_signed_hap_directory_is_always_in_program_directory( + tmp_path, monkeypatch +) -> None: + monkeypatch.setattr(settings, "application_dir", lambda: tmp_path / "app") + monkeypatch.setenv("HAPSIGN_DATA_DIR", str(tmp_path / "elsewhere")) + + assert settings.signed_haps_dir() == tmp_path / "app" / "signed_haps" diff --git a/tests/test_signing.py b/tests/test_signing.py index ba94c5f..f18caca 100644 --- a/tests/test_signing.py +++ b/tests/test_signing.py @@ -1,5 +1,6 @@ """外部签名与安装命令的无设备测试。""" +import time from types import SimpleNamespace from unittest.mock import Mock @@ -10,7 +11,7 @@ def test_hap_signer_builds_subprocess_command(monkeypatch) -> None: run = Mock(return_value=SimpleNamespace(returncode=0, stdout="", stderr="")) - monkeypatch.setattr(hap_signer.subprocess, "run", run) + monkeypatch.setattr(hap_signer, "run_process", run) result = hap_signer.HapSigner().sign_hap( "in.hap", "cert.cer", "profile.p7b", "key.p12", output_path="out.hap" @@ -20,11 +21,33 @@ def test_hap_signer_builds_subprocess_command(monkeypatch) -> None: assert result is True assert command[command.index("-inFile") + 1] == "in.hap" assert command[command.index("-outFile") + 1] == "out.hap" + assert ( + command[command.index("-keystorePwd") + 1] + == hap_signer.config.KEYSTORE_PASSWORD + ) + + +def test_keytool_defaults_use_config_keystore_password(monkeypatch, tmp_path) -> None: + run = Mock(return_value=SimpleNamespace(returncode=0, stdout="", stderr="")) + monkeypatch.setattr(keytool_util, "run_process", run) + monkeypatch.setattr( + keytool_util.KeytoolUtil, + "_get_keytool_path", + staticmethod(lambda: "keytool"), + ) + keystore = tmp_path / "debug.p12" + + keytool_util.KeytoolUtil().generate_keypair(str(keystore)) + + command = run.call_args.args[0] + assert command[command.index("-storepass") + 1] == ( + keytool_util.config.KEYSTORE_PASSWORD + ) def test_hap_signer_reports_tool_failure(monkeypatch) -> None: result = SimpleNamespace(returncode=2, stdout="", stderr="sign failed") - monkeypatch.setattr(hap_signer.subprocess, "run", Mock(return_value=result)) + monkeypatch.setattr(hap_signer, "run_process", Mock(return_value=result)) with pytest.raises(RuntimeError, match="sign failed"): hap_signer.HapSigner().sign_hap("in.hap", "cert.cer", "profile.p7b", "key.p12") @@ -33,22 +56,246 @@ def test_hap_signer_reports_tool_failure(monkeypatch) -> None: def test_installer_extracts_udid(monkeypatch) -> None: udid = "A" * 64 run = Mock(return_value=SimpleNamespace(returncode=0, stdout=udid, stderr="")) - monkeypatch.setattr(installer.subprocess, "run", run) + monkeypatch.setattr(installer, "run_process", run) + monkeypatch.setattr(installer.Installer, "_ensure_server", lambda self: None) assert installer.Installer().get_udid() == udid -def test_installer_detects_failure_text(monkeypatch) -> None: - result = SimpleNamespace(returncode=0, stdout="Failure: failed", stderr="") - monkeypatch.setattr(installer.subprocess, "run", Mock(return_value=result)) +def test_installer_raises_on_fail_marker(monkeypatch) -> None: + # rc=0 但输出含 [Fail] 状态行 → 判定失败 + result = SimpleNamespace( + returncode=0, stdout="[Fail]install bundle failed", stderr="" + ) + monkeypatch.setattr(installer, "run_process", Mock(return_value=result)) + monkeypatch.setattr(installer.Installer, "_ensure_server", lambda self: None) + + with pytest.raises(RuntimeError, match="hdc install"): + installer.Installer().install("app.hap") + + +def test_installer_raises_on_install_failed_prefix(monkeypatch) -> None: + result = SimpleNamespace( + returncode=0, + stdout="INSTALL_FAILED_MSG_BUFFER_ERROR: install failed", + stderr="", + ) + monkeypatch.setattr(installer, "run_process", Mock(return_value=result)) + monkeypatch.setattr(installer.Installer, "_ensure_server", lambda self: None) with pytest.raises(RuntimeError, match="hdc install"): installer.Installer().install("app.hap") +def test_installer_raises_on_error_line(monkeypatch) -> None: + result = SimpleNamespace(returncode=0, stdout="Error: signature invalid", stderr="") + monkeypatch.setattr(installer, "run_process", Mock(return_value=result)) + monkeypatch.setattr(installer.Installer, "_ensure_server", lambda self: None) + + with pytest.raises(RuntimeError, match="hdc install"): + installer.Installer().install("app.hap") + + +def test_installer_accepts_error_text_not_at_line_start(monkeypatch) -> None: + # 普通输出里含 "error:" 但不在状态行行首 → 不得误判为失败 + result = SimpleNamespace( + returncode=0, + stdout="[Info]connect success: 127.0.0.1:8710 error: nothing to worry\n" + "[Info]install bundle successfully", + stderr="", + ) + monkeypatch.setattr(installer, "run_process", Mock(return_value=result)) + monkeypatch.setattr(installer.Installer, "_ensure_server", lambda self: None) + + assert installer.Installer().install("app.hap") is True + + +def test_installer_raises_on_nonzero_exit(monkeypatch) -> None: + result = SimpleNamespace(returncode=1, stdout="", stderr="") + monkeypatch.setattr(installer, "run_process", Mock(return_value=result)) + monkeypatch.setattr(installer.Installer, "_ensure_server", lambda self: None) + + with pytest.raises(RuntimeError, match="hdc install"): + installer.Installer().install("app.hap") + + +def test_installer_accepts_success_with_error_in_path(monkeypatch) -> None: + # 路径/包名含 error/failed 但安装成功 → 不得误判 + result = SimpleNamespace( + returncode=0, + stdout="[Info]install bundle successfully", + stderr="", + ) + monkeypatch.setattr(installer, "run_process", Mock(return_value=result)) + monkeypatch.setattr(installer.Installer, "_ensure_server", lambda self: None) + + hdc = installer.Installer() + assert hdc.install(r"C:\error\failed-app.hap") is True + assert hdc.install(r"C:\tmp\debug\failed_error.apk.hap") is True + + +def test_installer_closes_server_started_by_current_task(monkeypatch) -> None: + # 无既有 server → hdc start 启动 PID 47024 → close 只 kill 该 PID + listener_pids = [None, 47024, 47024] + monkeypatch.setattr(installer, "_listener_pid", lambda: listener_pids.pop(0)) + monkeypatch.setattr(installer, "_process_start_time", lambda pid: time.time()) + run = Mock(return_value=SimpleNamespace(returncode=0, stdout="", stderr="")) + monkeypatch.setattr(installer.subprocess, "run", run) + + hdc = installer.Installer() + hdc._ensure_server() + hdc.close() + hdc.close() + + commands = [call.args[0] for call in run.call_args_list] + assert commands == [[hdc._hdc, "start"], [hdc._hdc, "kill"]] + + +def test_installer_preserves_preexisting_server(monkeypatch) -> None: + monkeypatch.setattr(installer, "_listener_pid", lambda: 47024) + run = Mock() + monkeypatch.setattr(installer.subprocess, "run", run) + + with installer.Installer() as hdc: + hdc._ensure_server() + + run.assert_not_called() + + +def test_installer_does_not_take_over_non_hdc_listener(monkeypatch) -> None: + # 端口被非 HDC 进程占用:不启动、不接管、close 不清理 + monkeypatch.setattr(installer, "_listener_pid", lambda: 12345) + run = Mock() + monkeypatch.setattr(installer.subprocess, "run", run) + + with installer.Installer() as hdc: + hdc._ensure_server() + + run.assert_not_called() + + +def test_installer_refuses_external_server_taken_over_before_start( + monkeypatch, +) -> None: + # 探测后外部 HDC 抢先启动:监听进程创建早于本次 hdc start,不接管、不清理 + listener_pids = [None, 55555] + monkeypatch.setattr(installer, "_listener_pid", lambda: listener_pids.pop(0)) + monkeypatch.setattr(installer, "_process_start_time", lambda pid: time.time() - 30) + run = Mock(return_value=SimpleNamespace(returncode=0, stdout="", stderr="")) + monkeypatch.setattr(installer.subprocess, "run", run) + + hdc = installer.Installer() + hdc._ensure_server() + hdc.close() + + commands = [call.args[0] for call in run.call_args_list] + assert commands == [[hdc._hdc, "start"]] + + +def test_installer_start_failure_does_not_kill(monkeypatch) -> None: + monkeypatch.setattr(installer, "_listener_pid", lambda: None) + monkeypatch.setattr(installer, "_LISTENER_POLL_ATTEMPTS", 1) + run = Mock( + return_value=SimpleNamespace(returncode=1, stdout="", stderr="server error") + ) + monkeypatch.setattr(installer.subprocess, "run", run) + + hdc = installer.Installer() + hdc._ensure_server() + hdc.close() + + commands = [call.args[0] for call in run.call_args_list] + assert commands == [[hdc._hdc, "start"]] + + +def test_installer_skips_kill_after_server_pid_changes(monkeypatch) -> None: + # 启动成功后监听 PID 被外部接管:close 只核对不 kill + listener_pids = [None, 47024, 99999] + monkeypatch.setattr(installer, "_listener_pid", lambda: listener_pids.pop(0)) + monkeypatch.setattr(installer, "_process_start_time", lambda pid: time.time()) + run = Mock(return_value=SimpleNamespace(returncode=0, stdout="", stderr="")) + monkeypatch.setattr(installer.subprocess, "run", run) + + hdc = installer.Installer() + hdc._ensure_server() + hdc.close() + + commands = [call.args[0] for call in run.call_args_list] + assert commands == [[hdc._hdc, "start"]] + + +def test_installer_start_failure_does_not_own(monkeypatch) -> None: + # 本任务启动失败:hdc start 后无监听进程,不归属,close 不清理 + listener_pids = [None, None] + monkeypatch.setattr( + installer, + "_listener_pid", + lambda: listener_pids.pop(0) if listener_pids else None, + ) + monkeypatch.setattr(installer, "_LISTENER_POLL_ATTEMPTS", 1) + run = Mock(return_value=SimpleNamespace(returncode=0, stdout="", stderr="")) + monkeypatch.setattr(installer.subprocess, "run", run) + + hdc = installer.Installer() + hdc._ensure_server() + hdc.close() + + commands = [call.args[0] for call in run.call_args_list] + assert commands == [[hdc._hdc, "start"]] + + +def test_installer_polls_until_listener_appears(monkeypatch) -> None: + # hdc start 后监听进程稍晚出现:轮询直到确认 PID 再归属 + listener_pids = [None, None, None, 47024, 47024] + monkeypatch.setattr( + installer, + "_listener_pid", + lambda: listener_pids.pop(0) if listener_pids else None, + ) + monkeypatch.setattr(installer, "_LISTENER_POLL_INTERVAL", 0) + monkeypatch.setattr(installer, "_process_start_time", lambda pid: time.time()) + run = Mock(return_value=SimpleNamespace(returncode=0, stdout="", stderr="")) + monkeypatch.setattr(installer.subprocess, "run", run) + + hdc = installer.Installer() + hdc._ensure_server() + hdc.close() + + commands = [call.args[0] for call in run.call_args_list] + assert commands == [[hdc._hdc, "start"], [hdc._hdc, "kill"]] + + +def test_listener_pid_parses_netstat_output(monkeypatch) -> None: + output = ( + " TCP 127.0.0.1:8710 0.0.0.0:0 LISTENING 47024\n" + " TCP 127.0.0.1:8710 127.0.0.1:65140 TIME_WAIT 0\n" + " TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 1234\n" + ) + monkeypatch.setattr(installer.os, "name", "nt") + monkeypatch.setattr( + installer.subprocess, + "run", + Mock(return_value=SimpleNamespace(returncode=0, stdout=output, stderr="")), + ) + assert installer._listener_pid() == 47024 + + +def test_listener_pid_returns_none_when_port_free(monkeypatch) -> None: + output = ( + " TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 1234\n" + ) + monkeypatch.setattr(installer.os, "name", "nt") + monkeypatch.setattr( + installer.subprocess, + "run", + Mock(return_value=SimpleNamespace(returncode=0, stdout=output, stderr="")), + ) + assert installer._listener_pid() is None + + def test_generate_keypair_builds_command(monkeypatch, tmp_path) -> None: run = Mock(return_value=SimpleNamespace(returncode=0, stdout="", stderr="")) - monkeypatch.setattr(keytool_util.subprocess, "run", run) + monkeypatch.setattr(keytool_util, "run_process", run) keystore = tmp_path / "key.p12" result = keytool_util.KeytoolUtil().generate_keypair( @@ -69,8 +316,8 @@ def test_generate_keypair_removes_existing_keystore(monkeypatch, tmp_path) -> No removed = [] monkeypatch.setattr(keytool_util.os, "remove", removed.append) monkeypatch.setattr( - keytool_util.subprocess, - "run", + keytool_util, + "run_process", Mock(return_value=SimpleNamespace(returncode=0, stdout="", stderr="")), ) @@ -81,7 +328,7 @@ def test_generate_keypair_removes_existing_keystore(monkeypatch, tmp_path) -> No def test_generate_keypair_reports_failure(monkeypatch, tmp_path) -> None: result = SimpleNamespace(returncode=1, stdout="", stderr="genkey failed") - monkeypatch.setattr(keytool_util.subprocess, "run", Mock(return_value=result)) + monkeypatch.setattr(keytool_util, "run_process", Mock(return_value=result)) with pytest.raises(RuntimeError, match="genkeypair"): keytool_util.KeytoolUtil().generate_keypair(str(tmp_path / "key.p12")) @@ -97,7 +344,7 @@ def create_csr(command, **_kwargs): return SimpleNamespace(returncode=0, stdout="", stderr="") run = Mock(side_effect=create_csr) - monkeypatch.setattr(keytool_util.subprocess, "run", run) + monkeypatch.setattr(keytool_util, "run_process", run) content = keytool_util.KeytoolUtil().generate_csr( str(tmp_path / "key.p12"), "debugKey", "pw", str(csr) @@ -110,7 +357,7 @@ def create_csr(command, **_kwargs): def test_generate_csr_reports_failure(monkeypatch, tmp_path) -> None: result = SimpleNamespace(returncode=1, stdout="", stderr="certreq failed") - monkeypatch.setattr(keytool_util.subprocess, "run", Mock(return_value=result)) + monkeypatch.setattr(keytool_util, "run_process", Mock(return_value=result)) with pytest.raises(RuntimeError, match="certreq"): keytool_util.KeytoolUtil().generate_csr( @@ -120,8 +367,8 @@ def test_generate_csr_reports_failure(monkeypatch, tmp_path) -> None: def test_generate_csr_reports_read_failure(monkeypatch, tmp_path) -> None: monkeypatch.setattr( - keytool_util.subprocess, - "run", + keytool_util, + "run_process", Mock(return_value=SimpleNamespace(returncode=0, stdout="", stderr="")), ) diff --git a/tests/test_subprocess_utils.py b/tests/test_subprocess_utils.py new file mode 100644 index 0000000..71c1385 --- /dev/null +++ b/tests/test_subprocess_utils.py @@ -0,0 +1,184 @@ +"""外部进程窗口策略与进程树终止测试。""" + +import os +import subprocess +import sys +import threading +import time +from unittest.mock import Mock + +import pytest + +from hapsign import subprocess_utils +from hapsign.cancellation import OperationCancelled + + +def test_windows_commands_do_not_create_console_window(monkeypatch) -> None: + monkeypatch.setattr(subprocess_utils.platform, "system", lambda: "Windows") + + assert subprocess_utils.no_window_kwargs() == { + "creationflags": subprocess_utils._CREATE_NO_WINDOW + } + + +def test_other_platforms_do_not_receive_windows_flags(monkeypatch) -> None: + monkeypatch.setattr(subprocess_utils.platform, "system", lambda: "Linux") + + assert subprocess_utils.no_window_kwargs() == {} + + +def test_cancelled_command_does_not_start_process(monkeypatch) -> None: + cancel_event = threading.Event() + cancel_event.set() + popen = Mock() + monkeypatch.setattr(subprocess_utils, "no_window_kwargs", lambda: {}) + monkeypatch.setattr(subprocess_utils.subprocess, "Popen", popen) + + with pytest.raises(OperationCancelled): + subprocess_utils.run_process(["tool"], cancel_event=cancel_event) + + popen.assert_not_called() + + +def _spawning_child_code(out_file: str) -> str: + """生成子进程代码:拉起一个挂起 300s 的孙进程,并把其 PID 写入文件。 + + 孙进程 stdout/stderr 重定向到 DEVNULL,避免持有子进程的管道句柄导致 + 取消后 communicate 无法读到 EOF(若进程树终止失效,测试会因此卡住而非误报)。 + """ + grandchild = "import time; time.sleep(300)" + return ( + "import pathlib, subprocess, sys, time;" + f"g = subprocess.Popen([sys.executable, '-c', {grandchild!r}]," + " stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL);" + f"pathlib.Path({out_file!r}).write_text(str(g.pid));" + "time.sleep(300)" + ) + + +def _process_exists(pid: int) -> bool: + """判断进程是否存活。Windows 上用 OpenProcess(os.kill(pid,0) 在进程不存在 + 时会抛 SystemError),POSIX 上用 kill(pid, 0)。""" + if os.name == "nt": + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.OpenProcess.restype = wintypes.HANDLE + kernel32.OpenProcess.argtypes = [ + wintypes.DWORD, + wintypes.BOOL, + wintypes.DWORD, + ] + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + handle = kernel32.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION + if not handle: + return False + kernel32.CloseHandle(handle) + return True + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +def _wait_pid_exit(pid: int, timeout: float = 5.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not _process_exists(pid): + return True + time.sleep(0.05) + return False + + +def test_cancel_terminates_whole_process_tree(tmp_path) -> None: + out = tmp_path / "grandchild.pid" + cancel_event = threading.Event() + + def cancel_after_grandchild_spawns() -> None: + deadline = time.monotonic() + 10 + while not out.exists() and time.monotonic() < deadline: + time.sleep(0.05) + cancel_event.set() + + threading.Thread(target=cancel_after_grandchild_spawns, daemon=True).start() + with pytest.raises(OperationCancelled): + subprocess_utils.run_process( + [sys.executable, "-c", _spawning_child_code(str(out))], + capture_output=True, + text=True, + cancel_event=cancel_event, + ) + + assert out.exists(), "子进程未启动孙进程" + grandchild_pid = int(out.read_text()) + assert _wait_pid_exit(grandchild_pid), "取消后孙进程仍存活" + + # 取消后下一次命令仍可正常运行 + result = subprocess_utils.run_process( + [sys.executable, "-c", "print('ok')"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + + +def test_timeout_terminates_whole_process_tree(tmp_path) -> None: + out = tmp_path / "grandchild.pid" + + with pytest.raises(subprocess.TimeoutExpired): + subprocess_utils.run_process( + [sys.executable, "-c", _spawning_child_code(str(out))], + capture_output=True, + text=True, + timeout=3.0, + ) + + assert out.exists(), "子进程未在超时前启动孙进程" + grandchild_pid = int(out.read_text()) + assert _wait_pid_exit(grandchild_pid), "超时后孙进程仍存活" + + +def test_stop_process_terminates_tree_via_job(monkeypatch) -> None: + process = Mock() + process.poll.return_value = None + terminated = [] + tree_killed = [] + # 该用例覆盖 Windows 的 Job Object + taskkill 路径,不依赖 CI 主机系统。 + monkeypatch.setattr(subprocess_utils.os, "name", "nt") + monkeypatch.setattr(subprocess_utils, "_terminate_job", terminated.append) + monkeypatch.setattr(subprocess_utils, "_terminate_windows_tree", tree_killed.append) + + subprocess_utils._stop_process(process, job="job") + + assert terminated == ["job"] + # 无论 job 是否命中,Windows 都会补一发 taskkill /T 兜底 + assert tree_killed == [process.pid] + process.wait.assert_called_once_with(timeout=2) + + +def test_stop_process_falls_back_to_process_group(monkeypatch) -> None: + process = Mock() + process.poll.return_value = None + killed = [] + monkeypatch.setattr(subprocess_utils, "_terminate_process_group", killed.append) + monkeypatch.setattr(subprocess_utils.os, "name", "posix") + + subprocess_utils._stop_process(process) + + assert killed == [process] + process.wait.assert_called_once_with(timeout=2) + + +def test_stop_process_falls_back_to_windows_tree_kill(monkeypatch) -> None: + process = Mock() + process.poll.return_value = None + tree_killed = [] + monkeypatch.setattr(subprocess_utils, "_terminate_windows_tree", tree_killed.append) + monkeypatch.setattr(subprocess_utils.os, "name", "nt") + + subprocess_utils._stop_process(process) + + assert tree_killed == [process.pid] + process.wait.assert_called_once_with(timeout=2) diff --git a/tests/test_token_exchange.py b/tests/test_token_exchange.py index f7cf5e3..added47 100644 --- a/tests/test_token_exchange.py +++ b/tests/test_token_exchange.py @@ -18,12 +18,28 @@ def _jwt(payload: dict) -> str: def test_exchange_temp_token(monkeypatch) -> None: response = Mock(text="jwt-token") - monkeypatch.setattr(token_exchange.requests, "get", Mock(return_value=response)) + get = Mock(return_value=response) + monkeypatch.setattr(token_exchange.requests, "get", get) result = token_exchange.TokenExchange().exchange_temp_token("temp", "CN", "1.0") assert result == "jwt-token" response.raise_for_status.assert_called_once() + assert get.call_args.kwargs["params"]["version"] == "1.0" + + +def test_exchange_temp_token_default_version(monkeypatch) -> None: + response = Mock(text="jwt-token") + get = Mock(return_value=response) + monkeypatch.setattr(token_exchange.requests, "get", get) + + token_exchange.TokenExchange().exchange_temp_token("temp") + + assert ( + get.call_args.kwargs["params"]["version"] + == token_exchange.LOGIN_PROTOCOL_VERSION + ) + assert token_exchange.LOGIN_PROTOCOL_VERSION == "5.0.5" def test_get_access_token_decodes_user(monkeypatch) -> None: diff --git a/third_party/libusb/README.md b/third_party/libusb/README.md new file mode 100644 index 0000000..242c297 --- /dev/null +++ b/third_party/libusb/README.md @@ -0,0 +1,15 @@ +# libusb 对应源代码 + +Windows 便携包中的 `libusb_shared.dll` 来自 OpenHarmony 公共 SDK,其 +`NOTICE.txt` 标识为 libusb 1.0.28、LGPL-2.1-or-later。为让接收者无需依赖仍然 +在线的第三方服务即可取得对应源代码,本目录保存 OpenHarmony 官方镜像的完整源码 +快照: + +- 上游: +- commit:`b982552012b9faf15db3bd5c714594da91df33b0` +- 归档:`openharmony-third_party_libusb-b982552.tar.gz` +- SHA-256: + `d6b7e36319a9aab07854e8bc89c458203697e7442ad66dd000c240df2a39a90c` + +该快照包含上游 libusb 1.0.28 源码压缩包、OpenHarmony 补丁、构建配置和 +LGPL-2.1 许可文本。源码仍按其自身许可证分发,不采用 HapSign 的 MIT License。 diff --git a/third_party/libusb/openharmony-third_party_libusb-b982552.tar.gz b/third_party/libusb/openharmony-third_party_libusb-b982552.tar.gz new file mode 100644 index 0000000..f49dc69 Binary files /dev/null and b/third_party/libusb/openharmony-third_party_libusb-b982552.tar.gz differ diff --git a/toolchain.lock.json b/toolchain.lock.json new file mode 100644 index 0000000..689711d --- /dev/null +++ b/toolchain.lock.json @@ -0,0 +1,73 @@ +{ + "schema": 1, + "platforms": { + "windows": { + "architecture": "x64", + "openharmony": { + "version": "6.1.0.31", + "api_version": "23", + "release": "6.1-Release", + "archive": { + "url": "https://repo.huaweicloud.com/openharmony/os/6.1-Release/ohos-sdk-windows_linux-public.tar.gz", + "sha256": "b833b75a64ee46bbd7880921abbb49b733ec5c8171b6684c9b524d57f624cee0", + "size": 2499936800 + }, + "toolchains_member": "toolchains-windows-x64-6.1.0.31-Release.zip", + "toolchains_sha256": "ee7ad339154504ddde74d07325ae378ead95a0bf357b62edf0c08d6b6d150688", + "files": { + "bin/hdc.exe": { + "archive_suffix": "hdc.exe", + "sha256": "0083c458f698c1f7e018fe845177f16716c146aadcb442b60ad996eb5b2f5560" + }, + "bin/libusb_shared.dll": { + "archive_suffix": "libusb_shared.dll", + "sha256": "cbba1afa0c78fa39eb44c51aabb9ae93a146a9d515428b33dc2307373523e92a" + }, + "lib/hap-sign-tool.jar": { + "archive_suffix": "lib/hap-sign-tool.jar", + "sha256": "91cea67369a8389e0cec60ebac6f2382d157515b9d96b0ef36b60b86ca528e9b" + }, + "NOTICE.txt": { + "archive_suffix": "NOTICE.txt", + "sha256": "45664d1a732d46d69a576887ce2483bb8174f46959a9afd453ad3c28381cc6f4" + }, + "oh-uni-package.json": { + "archive_suffix": "oh-uni-package.json", + "sha256": "0bdb26d146442042f26390c1aa4c61a0d0658122d3c4c70c79f75d3f890aec4d" + } + }, + "libusb_source": { + "repository": "https://gitee.com/openharmony/third_party_libusb", + "commit": "b982552012b9faf15db3bd5c714594da91df33b0", + "path": "third_party/libusb/openharmony-third_party_libusb-b982552.tar.gz", + "sha256": "d6b7e36319a9aab07854e8bc89c458203697e7442ad66dd000c240df2a39a90c" + }, + "source_repositories": { + "hap-sign-tool": "https://gitee.com/openharmony/developtools_hapsigner", + "hdc": "https://gitee.com/openharmony/developtools_hdc" + } + }, + "java": { + "distribution": "Eclipse Temurin", + "version": "21.0.12+8", + "archive": { + "url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12%2B8/OpenJDK21U-jdk_x64_windows_hotspot_21.0.12_8.zip", + "sha256": "9ba963ee2371874a74185d18bc7bb2ab9407df7683300855ed7606e0662321d0", + "size": 205069442 + }, + "modules": [ + "java.base", + "java.compiler", + "java.desktop", + "java.management", + "java.naming", + "java.rmi", + "java.scripting", + "java.sql", + "jdk.crypto.ec", + "jdk.unsupported" + ] + } + } + } +}