From e1c02a9b80d6773eab6f082c893266e3004ad237 Mon Sep 17 00:00:00 2001 From: chakcy <947105045@qq.com> Date: Tue, 22 Sep 2026 16:08:12 +0800 Subject: [PATCH] =?UTF-8?q?modify:=20=E5=8F=B0=E8=B4=A6=E6=9B=B4=E6=94=B9?= =?UTF-8?q?=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 15 + README.md | 167 +++-- docs/images/01-issue.png | Bin 0 -> 34252 bytes docs/images/02-registry.png | Bin 0 -> 28191 bytes docs/images/03-keys.png | Bin 0 -> 16729 bytes docs/images/04-inspect-ok.png | Bin 0 -> 20323 bytes docs/images/05-inspect-fail.png | Bin 0 -> 20409 bytes docs/images/06-renew.png | Bin 0 -> 9218 bytes docs/images/07-change-plugins.png | Bin 0 -> 9544 bytes docs/pg_schema.sql | 60 ++ docs/使用手册.md | 527 +++++++++++++++ docs/界面设计.md | 326 ++++++++++ packaging/README.md | 217 +++++++ packaging/_common.py | 404 ++++++++++++ packaging/build_nuitka.py | 226 +++++++ packaging/build_pyinstaller.py | 126 ++++ packaging/launcher.py | 10 + packaging/pyinstaller.spec | 130 ++++ pyproject.toml | 16 + smoke_test.py | 428 ++++++++++++- src/chongming_license_gui/__init__.py | 11 +- src/chongming_license_gui/app.py | 207 +++++- src/chongming_license_gui/common.py | 149 ++++- src/chongming_license_gui/icons.py | 205 ++++++ src/chongming_license_gui/inspect_tab.py | 33 +- src/chongming_license_gui/issue_tab.py | 139 ++-- src/chongming_license_gui/keys_tab.py | 95 ++- src/chongming_license_gui/pg_dialog.py | 155 +++++ src/chongming_license_gui/pg_store.py | 473 ++++++++++++++ src/chongming_license_gui/plugin_picker.py | 218 +++++++ src/chongming_license_gui/registry_store.py | 88 ++- src/chongming_license_gui/registry_tab.py | 649 +++++++++++++++---- src/chongming_license_gui/selftest.py | 290 +++++++++ src/chongming_license_gui/theme.py | 677 ++++++++++++++++++++ tools/pg_check.py | 279 ++++++++ tools/screenshot.py | 235 +++++++ uv.lock | 202 ++++++ 37 files changed, 6377 insertions(+), 380 deletions(-) create mode 100644 docs/images/01-issue.png create mode 100644 docs/images/02-registry.png create mode 100644 docs/images/03-keys.png create mode 100644 docs/images/04-inspect-ok.png create mode 100644 docs/images/05-inspect-fail.png create mode 100644 docs/images/06-renew.png create mode 100644 docs/images/07-change-plugins.png create mode 100644 docs/pg_schema.sql create mode 100644 docs/使用手册.md create mode 100644 docs/界面设计.md create mode 100644 packaging/README.md create mode 100644 packaging/_common.py create mode 100644 packaging/build_nuitka.py create mode 100644 packaging/build_pyinstaller.py create mode 100644 packaging/launcher.py create mode 100644 packaging/pyinstaller.spec create mode 100644 src/chongming_license_gui/icons.py create mode 100644 src/chongming_license_gui/pg_dialog.py create mode 100644 src/chongming_license_gui/pg_store.py create mode 100644 src/chongming_license_gui/plugin_picker.py create mode 100644 src/chongming_license_gui/selftest.py create mode 100644 src/chongming_license_gui/theme.py create mode 100644 tools/pg_check.py create mode 100644 tools/screenshot.py diff --git a/.gitignore b/.gitignore index c8f0442..2386ddd 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,18 @@ docs/_build/ # Pyenv .python-version + +# --- 授权产物(安全兜底:README 里承诺忽略的就是这几条,别删)--- +# 签发私钥:泄漏即等于任何人都能签发授权 +license_private*.pem +*_private.pem +*_private.key +# 已签发的授权文件与台账库:含客户信息与机器指纹,不应入库 +*.lic +licenses.db +licenses.db-wal +licenses.db-shm +.zcode/ + +# --- 打包副产物(Nuitka 构建失败时会在当前目录留一份报告)--- +nuitka-crash-report.xml diff --git a/README.md b/README.md index 12ee8b1..d65cb6d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,20 @@ # chongming-license-gui -机器码授权的**可视化工具(厂商侧)**:密钥管理 / 签发 / 查看校验。 +机器码授权的**可视化工具(厂商侧)**:密钥管理 / 签发 / 台账 / 查看校验。 -供商务、售后等不习惯命令行的同学使用,功能与 `uv run chongming-license`(CLI)对等。 -所有授权逻辑都调用 [`chongming-license`](../license/) 库 —— 本包**只做界面**, -不重复实现密码学、载荷格式或规范化(一旦有两处实现,就会出现"自己签的自己验不过")。 +供商务、售后等不习惯命令行的同学使用。所有授权逻辑都调用 +[`chongming-license`](https://pypi.org/project/chongming-license/)(内部包)—— +本包**只做界面**,不重复实现密码学、载荷格式或规范化(一旦有两处实现, +就会出现"自己签的自己验不过")。 + +## 文档导航 + +| 文档 | 读者 | 内容 | +| --- | --- | --- | +| [docs/使用手册.md](docs/使用手册.md) | 商务 / 售后 / 技术支持 | 三步上手、逐字段说明、场景速查、FAQ、原因码全表 | +| [docs/界面设计.md](docs/界面设计.md) | 维护界面的人 | 设计 token、控件规范、如何改主题、维护红线 | +| [packaging/README.md](packaging/README.md) | 出包的人 | 打包成免安装 exe(PyInstaller / Nuitka)、安全闸门、产物自检 | +| 本文 | 研发 / 集成 | 定位、安装运行、安全边界、与 CLI 对照 | ## 定位 @@ -16,95 +26,134 @@ **本工具不随部署产物分发** —— 签发私钥只应存在于厂商机器上。 +## 界面 + +![签发页](docs/images/01-issue.png) + +界面为现代浅色卡片风:白底卡片分区、主色 `#2563eb`、状态色规范化 +(过期红 / 即将到期橙 / 有效绿 / 永久蓝 / 未生效紫)、全套图标与悬停/焦点态。 +四个页签都套了滚动容器,小屏(1366×768)上不会再出现底部按钮被截断的情况。 + +样式集中在 `theme.py`(token + QSS)与 `icons.py`(内联 SVG,无二进制素材), +改主题见 [docs/界面设计.md](docs/界面设计.md)。 + ## 安装与运行 ```bash -cd utils/license-gui uv sync uv run chongming-license-gui ``` +依赖 `chongming-license` 从内部索引拉取(见 `pyproject.toml` 的 `[[tool.uv.index]]`)。 +若在同仓并行开发该库,可改用 `pyproject.toml` 里注释掉的 path 依赖 +(**必须带 `editable = true`**,否则改库代码后需重新 `sync` 才生效)。 + 无图形环境(SSH / CI)下可做冒烟检查: ```bash -QT_QPA_PLATFORM=offscreen uv run python -c " -from PySide6.QtWidgets import QApplication -from chongming_license_gui.app import MainWindow -app = QApplication([]); w = MainWindow(); w.show() -print('界面构建成功') -" +QT_QPA_PLATFORM=offscreen uv run python smoke_test.py ``` -## 三个页面 +该脚本驱动**真实控件**跑完整流程(生成密钥 → 解析申请码 → 预览 → 签发 → +查看校验(目标机通过、异机拒绝)→ 台账入库/检索/续期/改插件/导出/删除), +并包含一组界面主题断言(样式是否真的渲染、指示器是否会变空白)。 +在 Windows / macOS 上它会自动改用合成指纹(硬件指纹来源都是 Linux 侧的)。 -### 签发(默认页) +## 四个页面 -日常最高频的操作。让操作者在**按下签发之前**就看清"给谁、哪台机器、哪些插件、多久"。 +- **签发**(默认页):私钥状态 → 签发依据(申请码 / 来源 JSON / 仅机器码)→ + 勾选授权插件 → 填写授权信息 → 载荷预览 → 签发并保存(可自动记入台账)。 + 设计目标是让你**在按下签发之前**看清"给谁、哪台设备、哪些插件、多久"。 +- **台账**:已签发授权的检索、**续期**(复用台账指纹,无需再向客户索取申请码)、 + **改插件**(只换插件白名单重签,有效期与生效日原样保留)、 + 备注批注、详情查看、`.lic` 与 CSV 导出。默认库 `~/.chongming/licenses.db`(单文件 SQLite)。 +- **密钥**:生成/加载 Ed25519 密钥对,显示公钥(base64 / PEM)与公钥指纹。 +- **查看 / 校验**:查看 `.lic` 内容、验签,并按"本机"或"指定机器(客户申请码)" + 做指纹比对 —— 后者是**发货前自检**的关键。 -- **签名私钥**:显示当前生效的私钥路径与公钥指纹(签发前核对,避免拿错密钥) -- **签发依据**(三选一): - - *申请码*(推荐)—— 粘贴 `CM1-...`,自动解析出机器码与各来源指纹,支持换硬件容错 - - *来源 JSON 文件* —— `show --json` 的导出文件 - - *仅机器码* —— 精确匹配、无容错(界面会明确警示) -- **授权插件**:勾选式列表,自动从 `<项目根>/config/workers/*/config.toml` 读出本项目实际插件; - 也可手工添加任意 type;支持全选/全不选/移除 -- **授权信息**:客户、授权编号(留空自动生成)、备注、密钥标识、有效期(N 天 / 指定日期 / 永久) -- **预览与签发**:载荷 JSON 预览(与签名用的是同一个对象,所见即所签)→ 签发并保存 +每个字段的详细说明见 [docs/使用手册.md](docs/使用手册.md)。 -### 台账 +## 台账存储:本地 SQLite 或共享 PostgreSQL -已签发授权的检索与管理。**签发页签发的每一份都会自动入库**(含机器指纹)。 +台账默认是**本机单文件**(SQLite)。团队若想共用一份台账(谁签给了谁、哪台设备 +快到期,不用互相问),可在「台账」页把「存储」切到 **PostgreSQL**(默认端口 5432): -- **检索**:客户 / 机器码 / 插件 / 状态 / 关键字 / 即将到期窗口;按到期日升序,最急的在前 -- **概览**:总数与有效/即将到期/已过期/永久/未生效计数 -- **续期**:选中一条 →「续期选中…」→ 只改要变的部分 → 续期并保存。 - **指纹复用自台账,无需再向客户索取申请码**;默认生成新编号(新旧都留台账可追溯), - 旧记录自动加上 `已续期 → <新编号>` 批注 -- **备注**:人工批注,重新导入 license 不会覆盖(载荷里的 `note` 是签名内容的一部分, - 台账的 `note` 是厂商自己的批注) -- **详情**:查看各来源指纹与**原始 license 文件内容**(当初签发的那份) -- **导出**:`.lic`(可选按客户分目录)+ CSV 清单(UTF-8 BOM,Excel 中文不乱码) +- **可选功能**,不装驱动照样能用本地台账; +- 首次连接**自动建表**;账号无建表权限时用 `docs/pg_schema.sql` 交给 DBA 执行; +- 密码**默认不保存**,勾选"记住密码"才写入本机配置(有明文警示); +- 密码不会出现在日志、标签或错误信息里;DSN 传递用 kwargs,异常不回显凭据。 -台账库默认 `~/.chongming/licenses.db`,可在此页切换;单文件 SQLite,可直接进备份。 +```bash +uv sync --extra postgres # 启用 PG 支持(打包产物用 dev 依赖,已包含) +``` -### 密钥 +**关于"PG 后端是否可靠"**:不是推测 —— `tools/pg_check.py` 会用**同一套契约** +分别跑本地 SQLite 与 PostgreSQL 并逐项对比,全程在一个事务里、最后 `ROLLBACK` +(连建表都回滚,对真实库零残留): -- **生成密钥对**:一键生成 Ed25519 密钥对,私钥写为 `0600` 的 PEM, - 公钥自动复制到剪贴板(下一步注入构建就要用) -- **加载现有私钥**:显示对应公钥与指纹,用于确认"这把私钥对应哪把公钥" -- 公钥 base64(可直接填进 `[license] public_key`)与 PEM 都可单独复制 +```bash +uv run python tools/pg_check.py --host 127.0.0.1 --port 5432 \ + --dbname chongming --user postgres # 密码走 PGPASSWORD 或交互输入 +``` -### 查看 / 校验 - -- 打开 `.lic` 查看全部字段(不验签也能看) -- 填公钥后验签,给出结论 + 原因码 + 命中/不匹配的来源 -- **指纹比对基准**可选: - - *本机指纹* —— 在厂商机器上通常对不上,属正常 - - *指定机器*(填客户设备的申请码)—— **发货前自检**的关键: - 确认这份 license 确实是为目标设备签发的,而不是只验出"签名有效" +已在 PostgreSQL 16 上跑通全部 19 项对比(排序/过滤大小写/状态分桶/批注与入库时间 +保留/导出文件名与 CSV 编码/删除),GUI 实际路径(`holder.open_postgres` → 自动建表 +→ 入库 → 重连 → 版本校验 → 导出)也做了端到端验证。细节见 +[docs/使用手册.md 4.9](docs/使用手册.md)。 ## 与 CLI 的对应关系 +CLI(`chongming-license`)由 `chongming-license` 包提供,**本仓不安装它** +(当前环境里没有该命令)。装了 CLI 的环境可按下表对照: + | GUI 操作 | CLI | | --- | --- | -| 详情面板 / 状态栏 | `chongming-license verify` | | 签发 → 签发并保存 | `chongming-license issue` | | 密钥 → 生成密钥对 | `chongming-license keygen` | -| 密钥 → 加载私钥(显示公钥/指纹) | `chongming-license keygen` 的输出 / `inspect --key` | +| 密钥 → 加载私钥(显示公钥/指纹) | 同上输出 / `inspect --key` | | 查看 / 校验 → 仅查看内容 | `chongming-license inspect` | -| 查看 / 校验 → 指定机器 | `chongming-license verify --request-code` | +| 查看 / 校验 → 按指定机器 | `chongming-license verify --request-code` | +| 台账 → 详情面板 | `chongming-license verify` | +| 台账 → 改插件 | `chongming-license issue`(`--plugins` 换白名单,机器码与日期照抄台账) | 批量签发(一次几十台)建议直接用 CLI 脚本化;GUI 面向单台/少量、需要人工核对信息的场景。 +## 开发 + +```bash +uv run python smoke_test.py # 全流程 + 主题断言(无头) +uv run python tools/screenshot.py # 重新生成 docs/images/ 里的文档截图 +``` + +截图脚本会重定向 `QSettings` 与台账库到临时目录、当场生成一次性密钥, +**不接触真实的私钥、台账与配置**。 + +## 打包分发 + +```bash +uv sync --group dev # 装打包工具 +uv run python packaging/build_pyinstaller.py # 方式一:快,约 2.5 分钟 +uv run python packaging/build_nuitka.py # 方式二:真编译,更小更快但慢 +``` + +产物在 `dist/`,是**免安装单文件 exe**,发给厂商内部同事用。两个脚本都自带: + +- **私钥守卫**:源码树里出现私钥 / `*.lic` / `licenses.db` 时**中止构建** —— + 这个工具含签发能力,私钥随包发出去等于任何人都能签发授权; +- **产物自检**:构建后自动跑 ` --selftest`,并在 **offscreen 与系统原生两个 + 平台插件**下各验一遍(后者才证明 Qt 的平台插件真的打进产物了),覆盖 QtSvg、 + 复选框指示器、Ed25519 签发验签、orjson、台账、插件发现与整窗渲染。 + +细节、两种方式的取舍与趟坑见 [packaging/README.md](packaging/README.md)。 + ## 安全注意 -- 本工具会在 `QSettings` 里记住**私钥路径、客户名、备注、输出目录**(便于连续签发), +- 本工具会在 `QSettings` 里记住**私钥路径、客户名、备注、输出目录**等(便于连续签发), **不会**记住私钥内容本身。 -- 私钥文件权限为 `0600`,且 `.gitignore` 忽略 `license_private*.pem`。 -- `uv run build` 会扫描构建产物,发现私钥即终止构建。 +- 私钥在 Linux/macOS 上写为 `0600`。⚠ **Windows 没有 POSIX 权限位**,`chmod` 不生效 —— + Windows 上请把私钥放在只有你能访问的目录,并依赖磁盘加密与账号权限。 +- 本仓 `.gitignore` 已忽略私钥与台账产物(`license_private*.pem`、`*_private.pem`、 + `*.lic`、`licenses.db`),但这只是**兜底**:真正的防线是不要把私钥放进仓库目录。 +- 发布/发包前请自行确认产物中不含私钥与台账库 + (本仓没有构建脚本钩子;上游项目的构建脚本才有"扫到私钥即终止"的守护检查)。 - ⚠ 私钥丢失 = 无法再签发新授权;私钥泄漏 = 任何人都能签发授权。请离线备份。 - -## 完整文档 - -见仓库 [docs/license.md](../../docs/license.md):激活流程、命令行参考、机器指纹与容错判定、 -强制执行点、安全边界、故障排查(含原因码全表)。 diff --git a/docs/images/01-issue.png b/docs/images/01-issue.png new file mode 100644 index 0000000000000000000000000000000000000000..2bb70103b5bb68af03f6feaf9434a4674c79409c GIT binary patch literal 34252 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 literal 0 HcmV?d00001 diff --git a/docs/images/03-keys.png b/docs/images/03-keys.png new file mode 100644 index 0000000000000000000000000000000000000000..8c24417226c66beee01462a74228375352f8c9a1 GIT binary patch literal 16729 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 LRLr`1`}h9?+lOaT literal 0 HcmV?d00001 diff --git a/docs/images/04-inspect-ok.png b/docs/images/04-inspect-ok.png new file mode 100644 index 0000000000000000000000000000000000000000..ba9121e0f7713fb18671bce745e725f2bfe0c3e7 GIT binary patch literal 20323 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 literal 0 HcmV?d00001 diff --git a/docs/images/05-inspect-fail.png b/docs/images/05-inspect-fail.png new file mode 100644 index 0000000000000000000000000000000000000000..9915ad36677329ac3549d432162cea5cf1c117a1 GIT binary patch literal 20409 zcmcJ12V7Ixws#yXGoqqHQxR~~0f(+2AV?XdC{>DpbU_JHrDFmFM-io}2uPP8Vu%4L z0YV8VO=<`|1cFGfkrGOPaRfv`<{{~b7U_lM5!!PgJBE?)J5K=#$J{@WHSx{nV6ISRR?_N%df z^31@^WXo|YVY*QhYATyz;e-*_j+*h;KT?1Fr~N;2T+2TYxBsG4)AlEKENu_ozdp84 z`cc#9yEoepx;@)=|Jj`%YWkZW{j^U=^*ql}_PU3h^?ABt%pN!P39QV0ty~G4V&0@>S*fIqUc$tR902RP_e2i{z99F*j{!PVFNd83fBgCj z68yV8^8p}a|I@!Ua6|IrnOH3>(dA51ivzlgb_Yx_fgrgECAD1dn3Rv#c3s9Mg=ZN$ zXBsN=@`fMxNN#zNa(M>?a!`W?%P?`5No*M%jU^K8Bq(ZSI$&}98lqk1I7^bO(%I8b z%3ht_27&bcmTGCKmDQ0^dp>k6O8@3d!%WW`)VZtTTo6cb7Wu5A5vI!sr6)<8Eqss@ zt3}Qdmh=??>nO^1?cj9I^b8$}3UkafY#Fy3KM$sp&wY1MnHLHkBg`7eAT`#`XX=6_ zRhjVAdlZl+PLK=^xXLEYJER702WQ_j)RJHs8A{5EqDV|!iaD6MBv?miz{w|<#VFO{ zlV5SI(*(Yqct9V)E2WSW%L{sb9i-6R6l*;UB53u|2s(0Z@ydu#T$+&>t9ARinz0ub?3sQ zEk5O(v}Hfp)#yxXeAqX6+*!YfjG4~jXdS7C)1&SL@db_Dp~F>7)_Qr-P(QhrdQ=Zy zYDKA-3waVotrroblc%&$f#kG!=Co#G@i{r>KygI*YNC9zEd)|=3kjo#3v;yS&h$&4 zN?H6&w~am&TLrv6m6sTe$Xm|gcq#xZ_gIcDd)ZNp zFaLBuw}lYFtWF@LtwZhI5rOlY2rJy?aO`lFxnM;Woe0l1oom5wT7*j?+;Vz`w?VuI zpgePE+3B7Z0q@;h2jxjR%Tu%QD&^iw_R)yBxC#Qbt8B?}AZw$4y+;6^e@LBZs-(!6 zv(UgZZsT0W5>|d|jmScL>Z&c?=y<0|MOCiYk5;a0ZKl`rsjM*0sB=LM$_r;_iMG(a ze616fjzV~;ADZ#&yaLKsEl6=m6SZ_DNo1r0mpU;zPl|*%dy& zxF8xLt`)x@VjEnTF#(ukB?rG{lv_FmwXUF~?+RMbudS)6DgBZBTC~V&xwiI#x{ zMs&`Spu%{stU(~9_IUx>#Z-5Xh7zGm$W)5wtI*^7^EXXc*FUI1!kuDxS_oL?O9MX)#3e zaI`XMV&ms%B~lpDks!<31|I&o{G&obLcYJ`F&>+XUXk4+E}q*wn_5vKw_2@l5;RqT zh8Jb3%(F}L<40~V9VUAlx|y3+_U-`*bVVb-_3+*WkPK2qk(1xbX>2a-avC8r?tj_c=qk-jk5L_OI4 z;GC=ISg58DVA_LqDZ~V})>4+fkxr)uya*T?aYYz_KVTAkn{CdUM#mshwlBTxGwF45 zO5654GORck%LjJkr?3fb_-Pe-ONIg0W;@ety=~B>CwyR@G*!lU#S+?}4sgLa`Vq)M zDNY>G7v1IP_VG|8Wb-w}Ma@9wfFHBHUseC*Cd2*U?OFE4S>L7QmE_xifNmxpGeHvd z9P}V3_$t!tct-~pw6ZRM{r28$etRJ+N=Rvks^1ZF1sM3#`t^}l4__n{Gco3t-9%cM29SF0Fkcx5&AHx3< z?~a|6VNrJS0w=JB)?xy4yL;th=vcvn%of}X6JFmJ7|K`7_&Dh-*`#6M&3p_DZG!ib zq_yeB+a50LWr*!_D@C6VEUUl>3C->Gy(-~#stf`-F*h$AuKzl9<328Tib(&}y;dg^ zSzJ+ct0{7x(2w`+8SEjLw1NG)b!8*Q{cwd-YhXI?EVr5{<7uxk0Uq;hsSimcWaz|q zcVAFk0Kui=D#S9L!#gC9P^&ZXo11<*6nKfpdVP}tSHV0Z{|VKLvPQXbFeMgPx{Ee(uOaCO zH|T{s>H^aOu??iblww+ZpUk{^;Lx4`yRdcADpEr_X6lu_FIVH}M=W+BGm_x%Z@0&)zpT9XHo@^!{DC&D~ZV83M$tUS$jYmtyiFSqa?*SfF z+;M2c6Fm^7SoW(-7_!qG)xI``GIqikw~ptNT#~~J5+Vj{!0PTBU?{J8rfD#Htnnc* z8|8dP3lFiNvC9cfdM#&;F>x-)Wv!Y0K|z9!Y|^EyMO@Szdtj-qcXt-o!dkdLH<&~% zW$-wQRIBuV14DgAU9q|_$U)y0hXNC{Tq4GyfaFvhBM_>0<8*?HP)dV?rl&`H0R*z= z8KB_F(jKXDtx>HCq8QJD0T;N$TO@W#%{!aqBAbXYL0e&ZGyv49tDFW7PwPNP%~O$B zjf^v=OmtxTB`%>!=2yi>7d@_>NCQ3>a`&wg@==6C!PQ(jY@7JeluP=$QC&`v4(Nof z(O(aQT<>(E6_}3XpW)h#bHY3ZCLD5?by_A7^Fs4^nIYz-G(bT725LVYYH^T`X*i*@ z!z!lb`eA@6RiWp`9&cNmuwRUX9Z%8YmwzJ}BhZPsbhDU@@g+-p7C-S;=yJkl_$n^+ z?1sE~4BUK32&Z4*9K{K{9BreeKDZG%C{@CZEEDOPy1*f*>|uA)2MY{QubIdCa)M|Y z7IFRhiAf^e0Aw|b3KiIBtRD3o)g5G%{zOGWO?nus7X)$4 zx1kOWFMVCo8lp!sPMRxV16Gt)D%@|b^`Ub&#@qvHSDAFYhe_ULf3ij8bg>)UrotpG zAyEgGd_r6-d9@R&va%)_a5k#IfGcn&CJi@PH*LLu9H;i?DNPcAT(K5YA1x_kj-hVm z^r+BcL-+9qjpSu$MhTPws}L|=Tq!Ydm3Om;$r$fZTnc9rm^hMITFLARrP9@5YLKx? z780sU^>Dm#gLc<1XryQm&aB)>mvb*^joTP|JwDRD;6AX75A-Taias-JS$-`@JIKq# zRJ)iNt+-EVqc?UJN5HsW`P>v#FakO8Huu~HiV!dtKNv{n-nCLWNw${jew!mD$9SJW z+ZQ-AFnr-48|3IS3J=ktAXZPQz^TXrV`07A<;Nh^Gs)}9x2F997D6-PD01Vg zob1s8sfhJT|E8IlqAa|B%N0{guP$zOc45Xaf-x|xFJ^hW_Ksehg8N>@;-bC!dmzD5 z=T%-idcWRZPvT(K3U^E{ZE^_E7w&Ql_*b8~1D)U`xs-c4&{WE&<%(RB`jn-m4c}`T z82@6bFD9OH4p$CcX?5`(mJX^Oo)ni!(evv)Uz{x;l}0(AmTzCbEWd2e22rhKw^Cb~ z>03i=)>NKbAEa)OZ20)+6y_Uq5tI19G6999-iMspXlLNGecc9^H^!|Hqi;7g$J16f znbfa)IF z>Ii1rajf~~{pp<2={ZRTfn&3_xRP43AS-a8I>K!;VnSD$3c{vrK=1mS8Z4Pe#Z%cx-ebEcp3dM& z%S3(I!;x}9WI3mFD0g^>6Q<8m#u-arry%OR+)C&aNu|{~x$0;=L5uzXKCEL8dEWPp|XM9L=A3=ptqtBQYY#sLkZg8#JeLcZugH|8a zEEv$uc%eDXuINyFV|bNVt?3SUBNxzwW=`A{#M2@Ny*JMgqL33{q`;%xt9zdW1hRx} ziys4Uf^FCGS*%YST#Ycj)HqeM+Dqa&tZty3mUKkWN_lo$7V~IKL;0td;~S&GDuK0c zH{}NW2LJ&INeyZSoo>a!&XfK#b4(B z=@4AuJQ&z&Z05T64JH>@ z&+FsIOevd+h=rlbfLS!2fxxy+=qqo;Yil#l z!I*$DHltJEuLj(hodia`+#sC}wFlI@+Wj^`8%7A)WSM&qg=WAt5J(8LS$UFMI(owRB?>da=<0d9rkopzQmxrriR+qUbww%gW78VxGzcvGrdndJrcXYqJ_VSh|sq%gZA| zkCPd5d=MtvU)&h|Cc$Ccw;s4tZ(rc@c6bDuY**`C`g4k$|kh{ zVDw2wyGis?G??Y0P)vhEJIIN|H)YDM^|2tx?NWYqU?E&&fuB5`pre2#6|;dkM8Unl z^O9Ul&{(^?M}*pxN?%U(LQ9NEV^<#0?ubqEZDa7Pq8A-d6X}Fzv%I!z_zt)l*}z!B zG%HJ}@G=-ob@L2(ZG!eorGjioPHdfKIPaMs!Fh}RUdL-bnEq=e8OO|Xj0;E+UH~sc zl1(L|q!YYx=F=zoLE1wmk)$)${t^&xE82AZbc;qt%ymumLXwLEmjSrCJ0AG;76;)5 zkT^jP6dAFDbG(oAvdl5Qnhn;`;5LYU*8dKDjAbXcwNmQ@&lXV9CClNKVmbh&J?Ett zKG2w!L3;5$HPg2T(^!qSTY|6O48i6=zSm+Q!=oobTn7n$zO|Cy2eJ{x?vuAf59FoR zfvZa;uqN3q2hGi`6A}`?m)Z+X-9=X~Ong0+h~)v-V)9!GSb8wNYcZ7jib$76L6?hc z62>HVkaadZuEimrq$8UW<%KiWAAA++{Kx#2)lRVDw#q4*28QA&lho8Cnvqd!*^hqL zi}v{{c{NS60MBnz_ehQ$cHvTw8P3p`km#DG5$)1>-8+*Co}!FVmZUPc2|J+epqe5S znvI2tbj4_U!wuDXv@&NqVk*oIf%Qlg>FU3ZZLBcEt*aLrqQ<=PwswvUBlUz(OVYx2 z(_+hP)c9IVB%;dj0MSA2)Oz&0$d=+=R>B>@KEsz?egje1P z`yGIe=riB5_zICw{WB*2f&2NFIKajG`ySh~XPKHzM>@3My=NpgQXdvVay44BOkNVZ zV5t`RNf=yJl-+7!f-;0p5Z5;0esh#O@m7a-5j`AQqfQuW#HY5$fpFhuok(HDW+@(G z`ep+gBp8#u7)NW%SX^Ie*3*sHnS{~jVqdfSDXr{W(8$DmP56WqNwd zkJ1tkmMtvc?Ae7S_bYn3Tw_vFjADN1d9$6HeXXLbh#+Wl(KjMp6dAMtBpTiK=0=M3 zRetfPdq+?lk=&YkDc0J)_wPxzDBY?(GtI9MFmb1)PQe3q!)GPRH3{|u1R`~^F6{VX zJBXLTu?2iBL)-+r&53PD@rxZ$%*ErkL)! z`ubMJ%Cc(*b(L6r1pX00egSkp=o3I23+{8UT5-usc2&Q|1{|3El)Ugf4Ym^k**Q>C z(+fV*cz+Twa!HYU)bL_uj&wyVV!MD# z+ZL~;MR+uF&iNmm{H5rye#=+3>3+@w9`-duTp}w=Gm?ODBMhtWAy&lbxEnwr6-*T-szI2N(Ns`-~8D8kYK}%i66rlnn^k8v6Ng#t%w?UpWeXP^8V*4&cw^9eoynrft$Z|TA zZ!;7zb*9nphDObh3+x@7&XKO&&wD`MwgVFUEAe(9Y|F|xWC>~9ZILw#3kd-J(@H?a zbIJdsy7xiqfxxL<#=z1BE1QaqWC_*fln2c9zH4BwVX?c?Z@lyRyUlFo|FjxlHMSpI z%+mC7Uq|t1o{y3#0p=h}FZXA~3-98`1WG<8()RHyFI73%r^r|2m%{6dbSpr%;$pv< z$8|>U$NsrRL5y6Yg#XOtZ<>v*wRdvR9=aX+SF_<>3MXejV;yExJBVeNWdC~AuDR7k zEv!jykCDNYFlD}Imi$uk(F)?8grOQCqpTS7S)FJSJFC>!tN0O&F3IxADhAG>V6CAN zH*Hn9oH|bV6kX|1+@e!4<81S+sa7BHka^sDs|vIyLksI`%k1wW1etKD_gMOTsuo;B z$HsPLMFE@}FQCF0NvfQtHS8^kWhp_`8||!)+_fx8INcP#QFVb@u^G*I7&@(uf8gt{ zce_nr2|yaQ6EO{ieX}pJdh>sjK=M;;6x)i2fEaB~f(X--z)Mx3rUN8~b(&@D;;@3# zxbI{g-dW@hjK=-Wft7&C_ZxYO0*Q&q7qdDzRA47QUA%B?ZOg#^n3wN4wq7~DH=dfN zxN;e5!kifl9HKj?@dssJd;TC*)6d=Fv-zC1+7qn(m@v9bCBrEfLIspT&W9jB#r%HY z$=@0w-EUuMKfXuu4+axhfbft2l%*~Vcs*M$kon1zda}gjUEjt{%=>r|W3Bypz=>7| zFkVH5p1ebENlOR6+wSN@1uI$8JiO0=8<*YaXwpC~y|G z(b?!!LA(e9>A?@@wMH8k8mB5al=A}}PRED$grEI#hsSzc0%{(7)`r=ESM&jxZ~|KU z?tQQ0&=hpGOqD${)%V>^YR)V$ck+r6y5sM_VlfnXPnV&0vC0uco6`y83VS!d_6xuk zSbeB;ZCmtLmH0(sZB%mkJsh3Svy(KLYcTTi@{mo@15iTDG`qqKgs#D< z=JL3Hr{a$S{YOCrsm~LMbSyMG$S0l?+Zd^FJSh^~5R-QWphQ(>b{8KFR$=Y_I!J}c==7LHmfS^E+prgCA6bUm^BZ!PFRs^wN z_Ra@QHvq+d4u$`K(!R#Xe;5Gp?N-n~vp5#N02eOhvW312v0mf>o(JF|d${ArfRcW3 z*ne6a-Yg5$4kahM+&KuCe3?~S=DQykXX@}$=sO+2#i|ceZ_bs<@B)tg4%&B{vI3?z zD`4{H?zD|->R;84r&(t|Aa4|~IG~#~Y$?&zAvZPDhXMi9Q$lU+$BRdx(l_I`fr*!L zVHE~wxx+1E3k!`Cj;DB6vZa=t1^_c9OaWMqiWq?{@c^EA8ytZS%k!xdM^-0d!nJ9z zz(TW$T0YvC*bR{@BBsl8sa>p!@J?bYDZvSoszn?5aUhk8h&8;qzI0vE1hD34#OE3^ z5QLxu7~||U%Na`+Mo<-v81eEbGQ{`*-NMnPBK8E6EvpwSjrJdI3&;DJU0h6_od$XZh1$_RIf!%=39aU)>QywwR#J z(Q@!NNvuexB2E_;+;Y|WYM`A5$~Y%=(?0Lf;2A~fGpBrqOfwBpw0&5zD<}hohi`*a z)gF2VmWPcJu#oqG`K&jOw9a;{Sz2PcOtoV*qm@r7oh@=FMCs7aL;VQrlWYC@WIH>% zq%d%&`oU+%U`xH{QI-|-4>v*{_mgq~wCIQ#26cBqCD_J9UI?34*^`w0fD*kubxO!4 zQ8e(F)_C_7Bk`qQzzw78z9|Y7rRDjF?_I#UT^en(&A z%U=YHO1y(`5gpKAx9Y==^S+PZ(a;*r9-jcBvQ)Y4BofpL!jJ(HcvXiJf|>@ME;4K_ ztZG5+;oxJ5Ee^<&+1*pT?~Su*C&NyYbe<=G2}{h_K6Uo%C|HS&09q#-=4q#$fkAy# zS+!4s-%ar+A)z7^5NFGKV0Htp*~FCFOB0;rECRC}>6r<$n_L=Q?;gO9mMb0u&eqBf zD7i!{#4-^GS!Ye}(nxQHW^}x07xajTX?CFWz~TbF z`+pkBAVY;T*^eJzD>206#)=BZ>FMi7QOVLtOTplB@9VZRc1>Os&~;&G8lL01kj3`J_w0g9P6({uiXi zW$=7ZX=!4Ep;TbRnQ@8ZJBU=(A2C{|5>nOXpCKGGXFH_#a9GF&#fl8dIVTkFAKSfz z?GQz0)INTZ1(>Pu1&Q5d4Zt!c@s}aN5@7|PSL5-_p1Xe0j%a@VtBw+wB8R&6_}TX_ zj4pAoM8jc^;Q?1{23f^mtwlv#>2|iyvZ!KKhsx^I$0Tc6&mo0GEeyP5z%BgYL)rz9 zQ64%arS3iNJv~c3yO^+RD!243qJx{%EK%4rTEErZ_ZMw{ay%7u%NAD@0Higy>-=LZcMdNX^u|B8fI9s zRKcw{qmtn{Tcje%q{Taf!YdLzY!fuSnnleWWIWVrw+9;YCU2Yk@jfTb%C)Ra(}83a z@_A;Ldm3&rXNq(o_x7(0Q;Gru+y;i27!`3>{{mp?AP@P1rIDIEiTbHd1K#SgVaQ5c z<+3q*Byys)EF0m45C`cx{$^#5Y#;Bugx;qt!F7OyO|(0KLOtLT@+7@+LmEAHAzCiu zWM8E;r#i9ykp2g-6BB-Hm(`$k?FL+6n=}lTW^m9sXT&H)hf5vQ@gtCq4*emlD&wXb zWbQzPi>AZ4H+?bm@ZdU)*khRlZ((21uhd*}&S_CM@FrA8T6IhY77$*e^e1m+d=Jdr zc6;E*9s(tvG+w%!E5TS2e2t%c`c-N@>>U>)~Gx|tyT8tJEWbt}U(ajQ0SG!{2d5HH&$ zjqM{QAx4+d?OO^IOU`Y!K?PqkR{?iD-RqQ*Z+;GB;6vR?8I4=AYbZ-Q;QV`NIt2Cv zfCvTdifQp-YRPQ#O8N8>+O1WEYU|5vZMN5!rQR-?A1XuJ1iEf$531yZfSufP?=8zZ zwdlP>jModf14$@m6fO;wF^2?*fDwKa1&8%776PCRZ;?{k=y7)#={>P_tUY15+23i< zMPmmm?<98DSdE~Sh1^s;z3z8HX%}EP?+wT@%Msa%8mQgDn~05Qk2*E1RNqb(xsfPQI-? zO_j4uVuJCCv;B%`Gpkd;vnQUFiFqUKJQ|=pN6#OS{70uiCoLBEzwaG}Y8F<_@_0p8 zM*h$#VEr0oD^>NylP%q_79JdK`y-LBgu^uqWX#2Yy!B{}YGgcasROMA4ZR`&6h23E zO+^AwpDSmg9Sfp_XmiKTvSUDr%LXKPG(g8dCO~BX)T8~VU_n(shyC6$0DUP0qiZOM zA)(w0Yu1%u48m43nh(Cd73lnF1oVvo(u-a4m3)YiW>_fx)7<=ZVEaJEKv^8f*Z{RZ z=h;O7Q-Z_CzRrXH>5hI*sdqe8vtvzC2+()_HOHH`Z#Xy|tC9hy_T=nq%J^E2UfohJ z9B|tOmjr`!6M~?LG0GTZqYFqbMdlSjtg{m4vPlgN(j;$%)=@*Hzzl($6fI1w766=L zQ8ge|@;xNA6)W}d0Y^@Bk&w)dh~n|`$lYz9+l?`H9`(FIUK)9SF-Mb?7*OS{^^j$i z(RHJuK@*5^ZjWYoh;_Mvd#mBsouRh-XOmM<-JsV)UxGM$>424JY>~eHvfT}^o_D>n zN_i$CD|RgGu9cZcsBPV{s)b)VLwigC+;^3+z{vwS0yk{`+!?whQ$SMZNCmOr?Ds;v z(CNrIY7Ob`u|6jf3T&t?`}^zdnC_!nHl+meuLX;5upMN^RPExZ6~$Ljou(#qN}-pUrnRZ7)zG-r^zlp^Su;Hzy~jbHas$h4%?Gk9c0v2fnoJ0bH$ol zmDlE@g_T-AaT7=3)D-+&Da%7aE~;LukD5g1S~J??pYYpH0Kj7S&=B=`?4rUzw(*Yw zKaq+#a`E{;Mo0dv1V~ps{c(PtKH1UGD&?(ScokDB2?F_p<_VS(^Oy@D*8nv8k=Wm2 z$Y0caSca8t_;`vPsV?0MYRsO9D)LOKNYC2FSss*0BoXz+wUo)FEfTByAMKv$cp`OP|YJB@~f zuo@B|-@C4f+T84RIZM!B zPm`=J_4~xO0aibiYW~0BqhGnSv)%Lk5a!&i=nZHxsfo`*Co%+AQwm@ya_|E2*4Q=mf0_4RMWs{9ZLobv76Jl!JG($WHpotM&;>`5D%>jd6Nkb8Azo!rNmo|b zO@FsBU#m-dtM~3R?SBdhUZs=qqu=qd9Z_Bi{-QFyJM17<~ z9Q)F5U%QS!pY~cmKsZ^Cz95{3+m_D%pRJbgvr53kHkN6~69=+E?*_W!m=gWVc+l$u ztb!Zg(m_aD+TSx5dqJ^_OxWJlj9gU<#HVz;n8-%xt}2plb8%f02Z* zckGNex!Cf?}4pVa*)P@PV#cHMB4h+-!d&{BFM$wC5j=>o`G5Z z54pI0g|+8qHnl*+#cu)+QK;8-w)pT?)C}o`{Tu9?J8-O<;}p=j>913W|6jbb?Rf;+ z&-Zh3!Zm;jmb$T)*BUzGrScR=6o?iizoGlzaLtG7W-S!@Q_kzKniOC^ebrs?RqV%V z{0#g@rSUH^r=4xCgPwObMLwqlT!I0}+Vcw32Z;)T*9n^+L_yTa1gzNk_}@?y{-Yo` zrK{|OR2}_46Fyu02cH?pNnSVw39i|@WjMu1{=@#d;Qq8-P90OKOsFN(_4Nqp5C6Z{C2vOO|d*No((XO}xU7u?McP^PGI} z^4H&vU3A2Kkl=xDqu=4z;PE)S{|)u3ZQ3}WF9P10mi&`1;v6-V_gf*JQ~j)k-JBjs zL4qKo<&j9NDBt}t_S(017|%45CAl>3hZf1QgiZXKgRh%Wpp;(k-vac1;M?DFq#`j( zkcYQdi^FUf8m`woeg-c<^rq~aoi1^gS;q^u3gUm}7e>sz%S6{Y1HewYyR9vC0hdDw zaC00~HHFGfUhT_&pnTQ}{>e1Ft918nRZgpt{0&XhIOTPJ8XI-uHhH?V=Mw19MvXhn zmC)jb*w*tC%rM*#S#)`cF{Vq1kX$y~cIWLK{PobA^_e*HlaRNaQ=*j{^X>Em);;nv zGHrtyA>Xx~TeIv@MNlUmKW2~5IiaMz&P@zOXtVD)`f8!Qaa~Y6hc<&_k7qsOj?xcI zf(*poj^i^MK8hrJ$(svNoac$?ybxU#U>4x0mTfC4WQ+ND&2%7J8 z_syarQRJN7c2$4&(KCaAPPF^nILN)HE!UlBuzcx4tF03F)z8Ko3!H%}mwF(sQ$N7Q zPk}pdDV$rodmH5YO9w)V7Q==>B8pHk3NLHa2EAt$E7Vby&92PB^@3U`luz=K@?!d? zGyllyg?KqWHe0_kn9tCn%|_#hG$F1r{KEQ9s^aaBcc?hw6zfsZw$SJSZ1=CNskdR8 zHNoPBYUz z#=q`6;qI1t${sRf0)0tPqW20OQS@9NWZbDFl4C0UCl`3}OW0hM`A)bXOjEhIF)-a! z+($0H$|P!=Mf!vDd}o!P;N4{wF9`Zyqkpuq+1Pkz)FpZDN?`3RdI!_t!|Fx)f?Ryh z+3z6zGgDn5nu6z4S#q2przg+cfHgfRDNvx$YtPoys63HX&87tZNPt= zBXtI}I%5#%^})@%_(5iY7MD88xX|UQ1SH8+L~?m~#56ElI88L3myN(ysI1P_cQL)N zot)C>fShZcc)YU2?7I*sxY0mCET@MsX|zlip{YqdZmngHZMGfvm-kwi7t&qZ)6&ZF z5rdSfMF>X!K!~j8*!c=WtJ|~lGvj(!m|awfjg*)a<_Pg4RL~|2If1P&h@EOoV5hx+ zZnquBQAZw1B4`iIXEWW8bwDe<81HJ~)`5Pu$Mv5&+y=tbyB^PjrF+~nlIZ;H!xPw1s> zwq`^J&ZL?SD!s{~j>XsX@6C+0uQGW7>cIEY*Uo#~dag2dV4S`7@lZNOw@VE@=llI^ zhDsVegJW*9pHrJT#F-fzdom1ZEJ6xQ8#`a{n#MenCG=7+Dtf2D z&LXSZc?)2>ou-;eMEg+q0&aXfC%N+0{Fz+XD5{cPr_ZQ~?nhA3PXzX_N+iKn5)x9# z#)1)?zJT}IdF2apnmNkL3?;v)>p`Q4ry#eyR=j)!|MMn?ATB%PyHEL}UIQwNbHyqv zvk73&e~IU%2DS`vY<5O-%uJR|x0(iw45vLrUgX_jt;5pD8JFSuAk?Qt_t~|PN`VK+ z2}F?Vl$?TRD}|bb5C`%>_{&F*Ohn(V0n%~Z-_hA9s9y) zs}#};=*=cp`)gKe(xJ2Rg@{2_P;F;*Jp{kBcx{ZvPK_jZ74s`d$+yMNNX?q5f$MO< zT`=$g?RUYxZGSR7*X-lx>!3(TK`753>+>BvpybDBfB!aJDNt$adHcI-bY{;Fpyx1PCI_3U03Vxdsr&eCv5h;Ral^5wGLB zR-gq9RIXBBr2vr5%I&9j``%vi5ieOH1%Cf9mbytX9_tiw0k+%-1R z%p~wl<_Uzl%ovZ@=SEU~(A|Tc+ego5WgZKa{{k3_tj$`OcTe>Cw;u)eJ#Xf3@eb_#KC!P` z^ZqvS!8m)nSenV-V$Jz$wsDTj!9+=}UAyS^0vgCyM|yR%rk#NjK4ILXDs!{e4B1yKzz_*=sKEvgPkvL;pl z6Z6D>GZMJMnUeTS;gqk;R_3ngZB6k@@YapCK(mANuwPCOOiy1g?TBFH?XUzA4rFrV zuWbp9Z+2>c#MoVNbrx>zMQQAtBfRL43_1~`7k#8Zt@GTewJ279i(YJtLw(eMve&#P zs{A16aZ;A^yoHcW(uL*h-TJ))MKjqXB~S9Lb|aUlKS+2c4o$w*ghw^}aP9Lk%SksoW8`-Rw|1PEckjJ3D6%RyQhUW>@M5psc3nd zlTmv(TwSw;6waLTv72$+Y8gH;wluQTFi(6o^aqA~D-ip7KTfVc$Z@M4C~4N|Lx7edwrqAIb<88duwO(jpY!Bw|2&Q`WHxW{G0#OFuJyGnfpDE z{YL+7je(Eq>gpBh1MRfs`-KIG9uD+>^bKVFU1s)YZo>FKT9qs43Qe0fe{KA^j&vwI zHZveck#wjp=fUzwDH?13*@R`7qgTef8d}>x=Aw#GF%BKKMW9Xh z^rU%9DyxS`(Hd()aA+q7^}ggHX0w`o-zq)=b8q*W^i0o{8;m$E zH=2V>A?avp)&qkA2t8B}x>=s1B&>cB%N2trecC}h8J6e?ewzWdfTN@apvuj1dqBG_ z6hr{zt+Kw)74UW{DBE{|X1u_PH6hTfdz;m)8yg!NkOTL)iOL7bsd#WC`LSwxMJK_p z0DyPrH0wX8B8>DqYmULJa?ofJtA1*`E6);ipnmCl{ewgKx&|6{MXh7fJA>%B%;M9G zB~9aaj9&evIuRt8w<50``xu<=Z8;pN0ka5qtm8HSe|8glDWb>}D^h0WRJR5Q3T)-j zVqK-B`5sU5!W>1y7S{T^LYys<0GopcP$ieAW*chv)K@sWfz%x6W9AL(GM^pY2U3hx z62_J%F)xs80K+OyRRje%>!S61+<@j$_w;0HC~gekxKUgJ_l`$AiXF zz*|T>hbtmL!vhg;ndsQb7YTmDsRhrX*Xn2tgIPKpD7jQ-y?(kietHgeC>Ja-_$0%R z)zxZi@%67I{mtK)l8v>-`O2E3Bzkx?qwZ!yMIfm_JYx8PzJE8)l)#7cWTV!+SY% zvaV6=;>k7HO~_gqnrFsMRRmUFFHhpe7#pE5 ztss1=lIVU6zq)~)4qXT~IcCyU)$8-CX3mCt5?^Fosuxd9dBr0s7*ws8%k*E}OMT5F z$xG0ane=9@imOEg8JX!>iXN|<%RqSx4Rm#wg<*?X738wsWyuyRRF4pYyvw1IL4u)H zaiP{e!}Bnd|CCCl`zoVSk&$Dgbt9h~WX)pjy;(^llu4jgd}`)g*%)RTi;8Ba>F(2cD+?etL@*@F?2v!<}yQ_h+H-kAlyUSB%p z_=NRdrc`IihX3HFlvC0EbU(%oE_GoBco~{G-<%Z$I*#pC=mltH&`wa5lP9%~Sz@fq zMz}J$iLI17Aqglx70RpUJ>uMBFinl(%@CU5=1IlPnn*EX)UGkn1;t6CFbvW-bVI_vbuqRd3R6!g>~3 z!mnOn=^Whq`l5l5;Ei2&*6bB-UM#Z+DSEGr1YchwVb+TNvSZ^Lc_1c-Pydlx|Km{B PoRCZEdTKf6ZGZcJZXDC3 literal 0 HcmV?d00001 diff --git a/docs/images/06-renew.png b/docs/images/06-renew.png new file mode 100644 index 0000000000000000000000000000000000000000..0722e6cffa38f1e27f4c50a460537012b0b5aacd GIT binary patch literal 9218 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 AkpKVy literal 0 HcmV?d00001 diff --git a/docs/images/07-change-plugins.png b/docs/images/07-change-plugins.png new file mode 100644 index 0000000000000000000000000000000000000000..f3289a44f98c9c80a44466dee0fde6ed0f2125c1 GIT binary patch literal 9544 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°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 -p 5432 -U <用户> -d <库名> -f docs/pg_schema.sql +-- 或把下面的语句贴进任意 SQL 客户端执行。 +-- +-- 账号权限建议: +-- * 日常使用(签发入库、续期改备注):需要 SELECT / INSERT / UPDATE / DELETE +-- * 只做查询检索(如商务查台账):给只读账号 SELECT 即可,工具里"续期/删除"会失败 +-- * 建索引/建表:仅首次需要;之后可以收回 DDL 权限 +-- +-- 注意:本表含客户名称与设备机器指纹,属厂商内部数据,请按公司数据规范授权与备份。 + +BEGIN; + +-- 1) 台账主表。列与本地 SQLite 版**逐列一致**(含列序), +-- 日期列刻意用 TEXT:工具依赖 ISO 日期字符串比较来排序,改成 date 类型会改变语义。 +CREATE TABLE IF NOT EXISTS public.issued_licenses ( + lic_id TEXT PRIMARY KEY, + machine_code TEXT NOT NULL, + customer TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + plugins_json TEXT NOT NULL, + sources_json TEXT NOT NULL, + not_before TEXT, + expires_at TEXT, + issued_at TEXT NOT NULL DEFAULT '', + kid TEXT NOT NULL DEFAULT '', + public_key_fp TEXT NOT NULL DEFAULT '', + license_json TEXT NOT NULL, + source TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- 2) 三个检索索引(与本地版一致:机器码 / 客户 / 到期日) +CREATE INDEX IF NOT EXISTS idx_lic_machine ON public.issued_licenses (machine_code); +CREATE INDEX IF NOT EXISTS idx_lic_customer ON public.issued_licenses (customer); +CREATE INDEX IF NOT EXISTS idx_lic_expires ON public.issued_licenses (expires_at); + +-- 3) 结构版本元数据表:工具用它判断"这个库是不是自己这一版建的", +-- 版本不匹配时会拒绝使用并提示先导出台账。 +CREATE TABLE IF NOT EXISTS public.chongming_schema ( + name TEXT PRIMARY KEY, + version INTEGER NOT NULL +); + +INSERT INTO public.chongming_schema (name, version) +VALUES ('issued_licenses', 1) +ON CONFLICT (name) DO NOTHING; + +COMMIT; + +-- 若要用非 public 的 schema,请把上面的 public 换成目标 schema, +-- 并在工具的「连接设置…」里把 Schema 填成同一个名字(工具会按该 schema 读写)。 diff --git a/docs/使用手册.md b/docs/使用手册.md new file mode 100644 index 0000000..2a3c42e --- /dev/null +++ b/docs/使用手册.md @@ -0,0 +1,527 @@ +# chongming 机器码授权工具 · 使用手册 + +面向**商务、售后、技术支持**同事。不需要懂命令行,也不需要懂密码学 —— +照着本手册做,就能把客户设备的"申请码"变成一份能用的授权文件。 + +> 本手册讲**怎么用**。授权是怎么验的、私钥为什么不能外传、设备端强制执行点在哪, +> 见仓库 [README](../README.md);界面是怎么设计的见 [界面设计.md](界面设计.md)。 + +--- + +## 1. 三十秒认识这个工具 + +**它做什么**:把客户设备导出的**申请码**,变成一份 `license_*.lic` 授权文件。 + +**它不做什么**:不联网、不连设备、不装东西 —— 全程在你自己的电脑上离线完成。 +客户设备那边只需要做两件事:导出申请码给你、导入你发回的 `.lic`。 + +| | 使用者 | 在哪 | 能不能签发 | +| --- | --- | --- | --- | +| **本工具** | 厂商(商务/售后/研发) | 桌面软件 | ✅ 需要私钥 | +| `chongming-license` 命令行 | 厂商(研发/自动化) | 终端 | ✅ 需要私钥 | +| 设备端「授权」页 | 客户 / 现场装机 | 浏览器 `http://<设备IP>:8000/static/license` | ❌ 只能导出申请码、导入 license | + +> ⚠️ **两件事必须记住** +> 1. **私钥只在本机**,绝不随部署产物、安装包、邮件分发。私钥泄漏 = 任何人都能签发授权。 +> 2. 本工具是**厂商侧**工具,装在你的办公电脑上,不要给客户。 + +--- + +## 2. 三步上手 + +### 第一步:准备私钥(只做一次) + +打开「**密钥**」页,点「**生成密钥对**」。 + +![密钥页](images/03-keys.png) + +- 私钥会写到你指定的路径(默认 `license_private.pem`),**离线备份到 U 盘/密码管理器**。 +- 公钥会自动复制到剪贴板 —— 下一步"把公钥注入设备"就要用它。 +- 记住页面上显示的**公钥指纹**(形如 `3F9A-1C2B-…`)。之后每次签发前都核对一眼, + 能避免"拿错密钥签了一批授权"这种最难排查的事故。 + +如果公司已经有一把私钥(比如研发那边统一管理),用下半部分「**加载现有私钥**」选中它即可。 + +### 第二步:让客户导出申请码 + +告诉客户(或现场同事)在设备上打开 `http://<设备IP>:8000/static/license`, +点「导出申请码」,把那一串 `CM1-…` 复制给你(微信/邮件都行,**可以带换行和空格**)。 + +> 客户回传**申请码**而不是机器码:机器码是哈希,反推不出各个硬件标识, +> 只能签出"精确匹配、换硬件即失效"的授权;申请码带完整指纹,才能享受换硬件容错。 + +### 第三步:签发 + +打开「**签发**」页,粘贴申请码 → 点「解析申请码」→ 勾选要授权的插件 → +填客户名 → 点「**签发并保存…**」。 + +![签发页](images/01-issue.png) + +把生成的 `.lic` 文件发给客户,让他在设备「授权」页导入并**重启网关**即可生效。 + +--- + +## 3. 页面详解 + +### 3.1 签发页 + +日常最高频的操作。设计目标是:**在你按下"签发"之前**,就能看清"给谁、哪台设备、哪些插件、多久"。 + +#### 签名私钥 + +| 字段 | 含义 | 怎么填 | +| --- | --- | --- | +| 当前 | 正在生效的私钥文件 | 显示路径;未加载时会提示去「密钥」页 | +| 公钥指纹 | 这把私钥对应公钥的短指纹 | 签发前**核对它**与设备端显示的一致 | + +#### 签发依据(三选一) + +| 方式 | 什么时候用 | 容错能力 | +| --- | --- | --- | +| **申请码**(推荐) | 绝大多数情况 | ✅ 换网卡/换 eMMC 不影响授权 | +| 来源 JSON 文件 | 客户/研发给了 `show --json` 的导出文件 | ✅ 同上 | +| 仅机器码 | 实在拿不到申请码,客户只报了机器码 | ❌ 精确匹配,**换一块网卡就失授权** | + +选「申请码」后:把 `CM1-…` 粘进输入框 → 点「**解析申请码**」。 +下方会显示: + +- **解析结果**:机器码 + 指纹来源统计。若出现 ⚠(没有任何可信来源 / 无来源指纹), + 整块文字会变成警示色 —— 这是签发前最该被看见的信息。 +- **来源指纹**:逐条列出这台设备的硬件标识。 + +> **可信来源 / weak 来源是什么?** +> 授权按"多个硬件指纹里命中若干个"来判定,而不是全中才算。 +> `machine_id`(systemd machine-id)被标为 **weak**:同一个系统镜像刷到不同机器上会重复, +> 让它计票等于给"整盘拷贝到另一台机器"白送一票,所以**它不参与判定**,只作参考。 + +#### 授权插件 + +勾选这台设备上要生效的插件。 + +- 列表会**自动读取**你填的「项目根」目录下 `config/workers/*/config.toml`,列出该项目实际有哪些插件。 +- 读不到时会退回到常见候选,并在列表下方用警示色写明"已用常见候选"—— 别把它当成真实清单。 +- 也可以手工输入任意插件 type 后点「添加」。 +- 设备上装了但**没被勾选**的插件,导入授权后会被拒绝加载。 + +#### 授权信息 + +| 字段 | 含义 | 怎么填 | +| --- | --- | --- | +| 客户名称 | 写给谁,台账检索靠它 | 建议统一格式,如"华东水务集团" | +| 授权编号 | license 的唯一编号 | **留空自动生成**(`L<日期>-<随机>`),一般不用手填 | +| 备注 | 合同号等,仅展示/审计 | 如 `合同 SM-2026-0417` | +| 密钥标识 | 写进 license 的 `kid` 字段 | 可选,如 `vendor-2026` | +| 有效期 | N 天 / 指定到期日 / 永久有效 | 三选一;未选中的输入框会自动禁用 | + +#### 预览与签发 + +- **预览**里是即将被签名的完整载荷 JSON —— 与真正签名用的是**同一个对象**,所见即所签。 + 按「刷新预览」随时核对。 +- 「签发后记入台账」默认勾选,建议保持:台账存了机器指纹,**下次续期不用再找客户要申请码**。 +- 点「**签发并保存…**」选择保存位置 → 完成。 + +> 签发完会再弹一张回执确认:编号、客户、机器码、插件、有效期、公钥指纹。 +> 把 `.lic` 发给客户前,建议扫一眼这张回执。 + +### 3.2 台账页 + +已签发授权的检索与管理。**签发页签发的每一份都会自动入库**。 + +![台账页](images/02-registry.png) + +#### 台账库 + +| 字段 | 说明 | +| --- | --- | +| 存储 | **本地 SQLite**(默认,单文件)或 **PostgreSQL**(团队共用一份,见 [4.9](#49-团队共用台账postgresql)) | +| 路径(本地) | 台账库文件位置,默认 `~/.chongming/licenses.db`(单文件 SQLite,直接拷走即备份) | +| 连接(PostgreSQL) | 连接串摘要 + 「连接设置…」,**不显示密码** | +| 概览 | 总数 + 有效 / 即将到期 / 已过期 / 永久 / 未生效 / 当前筛选数 | +| 签名私钥 | 续期要用私钥;未加载时会提示去「密钥」页 | + +首次指定一个不存在的路径 → 点「打开 / 新建」即可创建空台账。 +选 PostgreSQL 时点「打开 / 新建」建立连接(首次会自动建表)。 + +#### 筛选 + +客户(模糊)、机器码(模糊、大小写不敏感)、插件 type(精确)、 +关键字(同时匹配编号/机器码/客户/备注)、状态、以及「即将到期」的判定窗口(默认 30 天)。 + +列表按**到期日升序**,最急的排在最前。 + +| 状态 | 含义 | 颜色 | +| --- | --- | --- | +| 未生效 | 生效日期还没到 | 紫 | +| 已过期 | 到期日已过 | 红 | +| 即将到期 | 在判定窗口内(默认 30 天内) | 橙 | +| 有效 | 正常可用 | 绿 | +| 永久有效 | 无到期日 | 蓝 | + +> 判定顺序是"未生效 → 永久 → 已过期 → 即将到期 → 有效"。 +> 所以一份"生效日期在未来"的授权即使同时填了过去的到期日,也显示为**未生效**。 + +#### 行操作 + +| 按钮 | 作用 | +| --- | --- | +| **续期选中…** | 打开续期对话框:只改要变的部分,**直接复用台账里的机器指纹,无需再向客户索取申请码** | +| **改插件…** | 重签一份只换了插件白名单的新授权:**有效期与生效日原样保留**,指纹同样复用台账 | +| 编辑备注 | 人工批注("已电话确认续期"、"设备已返修")。**重新导入 license 不会覆盖它** | +| 复制机器码 | 复制选中行的机器码 | +| 查看详情 | 摘要 + **当初签发的那份 license 原始内容**,可直接复制 | +| 删除记录 | 只删台账记录,**不影响已经发给客户的 .lic**(那些文件仍然有效) | + +**续期对话框**长这样: + +![续期对话框](images/06-renew.png) + +- 上半部分是**原授权**(只读),下半部分是**新授权**(可改)。 +- 编号默认「生成新授权编号」:新旧两条都留在台账,可追溯;需要沿用原编号也可以切换。 +- 旧记录会自动加上 `已续期 → <新编号>` 批注。 + +**改插件对话框**用于"客户说设备上少了一个插件能用"这类需求: + +![改插件对话框](images/07-change-plugins.png) + +- 顶部「原授权」是只读摘要(编号 / 客户 / 机器码 / 有效期 / 指纹个数), + 下面的勾选列表与签发页是同一个组件:项目里 `config/workers` 下的插件自动列出, + 原来授权过的插件**始终留在列表里并预勾选**(哪怕项目里已经删掉那个插件), + 找不到的 type 还可以手工添加。 +- 列表下方「变更」一行实时显示 `新增:… / 移除:…`。 + **移除**的插件会额外亮出告警:客户导入新文件后该插件会被拒载(`plugin_not_licensed`)。 +- **有效期与生效日原样保留**(这一条是刻意的:要延长有效期请用「续期选中…」)。 +- 编号默认「生成新授权编号」,旧记录会自动加上 `已变更插件 → <新编号>` 批注; + 选「沿用原授权编号」则替换台账里那份记录(审计追溯只剩新版,一般不建议)。 + +> **为什么不能直接改文件?** 授权文件是签名过的,签名覆盖整个载荷 —— 改任何一个字段 +> (包括插件白名单)都会让签名失效,只能**用私钥重签一份新的**。 +> 另外授权**没有吊销机制**:旧 `.lic` 在它自己的到期日之前仍然有效, +> 只能靠客户导入新文件把它们覆盖掉。所以改插件本质上是"再发一份授权"。 + +#### 导出归档 + +- **导出 .lic**:把当前筛选结果原样导出,文件名形如 `L20260921-312629_3ECA-D113-….lic`; + 可选「按客户分子目录」,直接打包发给对应客户。 +- **导出 CSV 清单**:给商务对账用,UTF-8 BOM 编码,Excel 双击打开**中文不乱码**。 + +### 3.3 密钥页 + +见 [第一步](#第一步准备私钥只做一次)。补充说明: + +| 字段 | 说明 | +| --- | --- | +| 私钥保存到 | 生成密钥对时的写入路径 | +| 公钥 base64 | 可直接填进设备配置 `[license] public_key` 的那一行,点「复制」取走 | +| 公钥 PEM | 另一种格式(`-----BEGIN PUBLIC KEY-----`),按需使用 | +| 加载现有私钥 | 选中已有私钥后显示对应公钥与指纹,用来确认"这把私钥对应哪把公钥" | + +### 3.4 查看 / 校验页 + +拿到一份 `.lic`(比如客户反馈"导入失败",或你要发货前自检)时用这一页。 + +![校验通过](images/04-inspect-ok.png) + +| 区域 | 说明 | +| --- | --- | +| license 文件 | 选或填 `.lic` 路径 | +| 公钥 | 验签用的公钥(base64 或 PEM);**留空则只查看内容、不验签** | +| 指纹比对基准 | **按本机**(在你自己电脑上通常对不上,属正常)/**按指定机器**(填客户申请码) | +| 校验结论 | 成功/失败横幅 + 原因码 + 命中与不匹配的来源明细 | +| 内容 | license 的完整 JSON | + +> **发货前自检的正确做法**:选「按指定机器」并填入**客户设备的申请码**。 +> 只验签名只能证明"这文件是我们签的",验不出"是不是给这台设备签的"。 +> 填了申请码,工具才会真正核对机器指纹。 + +校验失败时长这样(下面是"拿 A 设备的授权去校 B 设备"): + +![异机被拒绝](images/05-inspect-fail.png) + +--- + +## 4. 场景速查 + +### 4.1 第一次给一台新设备签发 + +「密钥」页确认私钥已加载并记下指纹 → 客户导出申请码 → 「签发」页解析申请码 → +勾选插件 → 填客户名与有效期 → 签发并保存 → 把 `.lic` 发给客户导入 → 重启网关。 + +### 4.2 设备续期(客户联系不上、或换人对接) + +「台账」页搜到那台设备(按客户名或机器码)→ 选中 → 「**续期选中…**」→ +选有效期 → 「续期并保存…」。 + +**全程不需要再找客户要申请码** —— 续期复用台账里存的指纹。这是台账最大的价值。 + +### 4.3 客户换了网卡 / 主板,提示授权失效 + +授权是按"多个硬件指纹命中若干"判定的,阈值是 **3 个可信来源**: + +- 换一块网卡 → 少一票(网卡 MAC 不匹配),其余仍命中 → **通常仍可用**。 +- 换了主板或整机返修后主板序列号变了 → 可能跌破阈值 → 需要**续期/重新签发**。 + +做法同 4.2:从台账复用指纹重新签发一份。注意换了几件硬件后可能连台账里的旧指纹也 +不足以命中,此时请让客户**重新导出申请码**,按 4.1 走一遍。 + +### 4.4 发货前自检 + +「查看 / 校验」页 → 选刚签发的 `.lic` → 填公钥 → 选「按指定机器」→ 粘贴**这台设备**的申请码 → 点「校验」。 +看到绿色「✓ 授权有效」再发货。 + +### 4.5 查某客户的所有授权 + +「台账」页 → 客户框填客户名(模糊匹配)→ 「查询」。 + +### 4.6 批量交付 / 归档 + +「台账」页 → 筛选出要交付的范围 → 「导出」区选目录 → 「导出 .lic」→ +需要按客户分包就选「按客户分子目录」。 + +对账要表格时用「导出 CSV 清单」。 + +### 4.7 换电脑 / 密钥交接 + +1. 在新电脑上装好本工具(见 [README](../README.md) 的安装与运行)。 +2. 把**私钥文件**从安全备份中取回,放到新电脑(不要走微信/邮件)。 +3. 「密钥」页 →「加载现有私钥」→ 选中它 → **核对公钥指纹与之前记录的一致**。 +4. 台账库默认在 `~/.chongming/licenses.db`,一并拷到新电脑对应位置; + 或在「台账」页「浏览…」指向拷过来的库文件。 + +### 4.8 调整"即将到期"的判定窗口 + +「台账」页 →「状态」行右侧的「__ 天内」改成你要的值(比如 90)→ 点「查询」。 +之后「只看即将到期」也按这个窗口筛选。 + +### 4.9 团队共用台账(PostgreSQL) + +默认每人的台账是**自己电脑上的单文件**,互相看不见。若希望商务/售后/研发看到 +**同一份台账**(谁签给了谁、哪台设备快到期,不用互相问),可以改成共用一个 +PostgreSQL 库。**这是可选功能**:没配就还是本地 SQLite,不影响使用。 + +#### 一次性准备(找 IT / DBA) + +1. 准备一个 PostgreSQL 库(版本 12+ 即可,公司内网直连最省事)。 +2. 建一个账号,日常使用给 `SELECT / INSERT / UPDATE / DELETE` 权限即可; + 若允许建表,工具**首次连接会自动建表**,无需人工干预。 +3. 如果账号**没有建表权限**,把仓库里的 `docs/pg_schema.sql` 交给 DBA 执行一次 + (里面就是表 + 索引 + 版本表)。 + +#### 每台电脑上的配置 + +「台账」页 →「存储」改成 **PostgreSQL** → 点「连接设置…」: + +| 字段 | 怎么填 | +| --- | --- | +| 主机 / 端口 | 数据库地址,PostgreSQL 默认端口 **5432** | +| 数据库 | 库名 | +| 用户 / 密码 | 上一步建的账号 | +| SSL 模式 | 内网直连常用 `prefer`;要求加密选 `require` | +| Schema | 默认 `public`,除非 DBA 指定了别的 | +| **记住密码** | 勾选后写入本机配置,下次启动自动连接(**明文保存**,见下方提醒) | + +不确定填得对不对,先点「**测试连接**」—— 它会真的连一次并告诉你结果。 +保存后用「打开 / 新建」建立连接,「概览」显示记录数即成功。 + +#### 密码怎么存(请想清楚再勾) + +- **不勾「记住密码」**(推荐给安全要求高的环境):密码只存在于本次运行的内存里。 + 关掉工具再打开需要重新输入;签发时若未连接,会在"记入台账"环节提示去连接。 +- **勾了「记住密码」**:密码明文保存在本机(Windows 是注册表 + `HKCU\Software\chongming\license-gui`),下次启动自动连接。 + 共享电脑、或数据库账号权限较大的场合**不要勾**。取消勾选会删除已保存的密码。 + +> 说明:工具的既有约定是"配置里只记路径、不记内容"(私钥就是这么处理的)。 +> 这里之所以提供"记住密码",是因为每天手输数据库密码对商务同事门槛太高 —— +> 但默认是**不勾**,由你自己权衡。 + +#### 多个同事同时用会不会冲突? + +不会。PostgreSQL 本身支持并发,多人同时签发/续期/查询都安全(本地 SQLite 版同样 +支持多开窗口)。同一份授权被两人同时写入时按授权编号幂等覆盖。 + +#### 常见问题 + +**「连接设置…」测试连接失败?** +按提示依次确认:主机端口是否可达(能不能 ping 通、5432 是否放通)、库名对不对、 +账号密码、以及 PostgreSQL 的 `pg_hba.conf` 是否允许你这台机器的 IP 连接。 + +**提示"建表失败"?** +账号没有建表权限。把 `docs/pg_schema.sql` 交给 DBA 执行一次,之后就能正常用。 + +**提示"台账库结构版本为 X,本程序支持 Y"?** +这个库是另一个版本的工具建的。先导出台账(export),再换用匹配版本的工具; +不要手工改版本号。 + +**换回本地台账?** +「存储」改回「本地 SQLite」→「打开 / 新建」。本地库里的数据一直都在,不会因为 +用过 PostgreSQL 而丢失。切后端时工具会断开当前连接,避免"界面显示一个库、 +实际写另一个库"。 + +**切了 PostgreSQL 后,签发时提示记台账失败?** +说明当时没连上(多半是没勾"记住密码",本次运行还没连接过)。授权文件已经正常 +生成,不影响交付;到「台账」页连一下即可,或重新签发一次(会幂等入库)。 + +--- + +## 5. 常见问题(FAQ) + +**Q:签发页显示的公钥指纹,和设备端不一致?** +说明两边用的不是同一把密钥 —— 设备固件里内置的公钥不是你手上这把私钥对应的公钥。 +用**正确的私钥**签发,或请研发把对应公钥注入构建产物(`CHONGMING_LICENSE_PUBKEY` +环境变量或 `license_public.key` 文件)。签出来的授权在这种设备上一定会验签失败。 + +**Q:台账是空的 / 台账库打不开?** +台账是你**本机**的记录,不会从别处同步。默认路径 `~/.chongming/licenses.db`, +若该文件不存在,点「打开 / 新建」会创建一个空台账。 +若提示"结构版本为 X,本程序支持 Y",说明这个库是另一个版本的工具建的 —— +先用匹配版本导出,或换用对应版本的工具。 + +**Q:申请码解析失败?** +- 确认是 `CM1-` 开头、三段式、没被截断(提示"校验和不符"就是抄漏了)。 +- 让客户重新导出一次再发;换行和空格没问题,工具会自动清理。 + +**Q:插件列表读不到本项目插件?** +「项目根」要指向项目根目录(该目录下应有 `config/workers/*/config.toml`)。 +点「重新加载插件列表」重试;仍读不到时会用常见候选并提示,此时请**手工添加**实际插件 type。 + +**Q:提示"已签发但记入台账失败"?** +授权文件**已经正常生成**,只是没记进台账。通常是台账库路径不可写或文件被占用。 +到「台账」页检查「台账库」路径是否可写,然后手工把这份 `.lic` 补录:签发时不用重签, +用「查看 / 校验」确认文件没问题即可,只是这次要在台账里补一条(或下次签发给同一台设备时自然会入库)。 + +**Q:客户导入后仍然提示未授权?** +按顺序排查: +1. 公钥指纹是否一致(见上); +2. 「查看 / 校验」页选「按指定机器」填客户申请码,看结论与原因码(见[附录 A](#附录-a校验原因码全表)); +3. 设备上的插件 type 是否在授权白名单内(授权插件勾选齐全了吗); +4. 设备是否**重启过网关**(导入后需重启生效); +5. 设备系统时间是否被改早过(会触发时钟防线)。 + +**Q:导出的 CSV 用 Excel 打开中文乱码?** +本工具导出的 CSV 带 UTF-8 BOM,双击不会乱码。若你另存/转存过,可能丢失了 BOM —— +重新从「导出 CSV 清单」导出一次即可。 + +**Q:误删了台账记录怎么办?** +记录删了就是删了(不影响已发出去的授权文件)。只要那份 `.lic` 还在客户或你手里, +可以到「签发」页按新客户重新签发,或在「查看 / 校验」页确认文件有效后手工补录台账。 +**建议定期备份 `licenses.db`**(单文件,拷走即可)。 + +**Q:一份 license 能给多台设备用吗?** +不能。授权绑定的是机器指纹。多台设备请各签一份(批量场景建议走命令行脚本化)。 + +**Q:能改一份已经发出去的授权的有效期吗?** +不能改,只能**重新签发**一份新的(用「续期」或重新签发),再让客户导入覆盖。 +旧的 `.lic` 在到期日之前仍然有效。 + +**Q:能改一份已经发出去的授权的授权插件吗?** +也不能改文件本身 —— 授权文件是签名过的,改了签名就失效。正确做法是**重签**: +台账页选中那条 →「改插件…」,勾好新白名单后另存一份新 `.lic` 交给客户导入。 +有效期与生效日会**原样保留**,只有白名单变;旧文件在它自己的到期日之前仍然有效 +(授权没有吊销机制,只能靠客户导入新文件覆盖)。 + +**Q:台账库能直接拷走 / 备份吗?** +可以。它是单文件 SQLite,直接复制 `licenses.db` 即可;恢复时拷回原位置或在「台账」页指向它。 + +**Q:同时开两个窗口操作台账安全吗?** +安全。台账库用 WAL 模式并设了等待超时,可以多开窗口。 + +**Q:私钥文件的权限真的被保护了吗?** +在 Linux/macOS 上私钥写为 `0600`(仅本人可读写)。**Windows 没有 POSIX 权限位**, +chmod 不生效 —— Windows 上请把私钥放在只有你能访问的目录(例如个人目录而非共享盘), +并依赖磁盘加密与账号权限保护。 + +--- + +## 6. 安全须知 + +| 事项 | 要求 | +| --- | --- | +| 私钥存放 | 只放厂商机器;**离线备份**(U 盘 / 密码管理器 / 保险柜) | +| 私钥分发 | ❌ 绝不进 Git、不进安装包、不进部署产物、不走即时通讯 | +| 私钥丢失 | 无法再签发新授权(已发出去的授权不受影响,直到到期) | +| 私钥泄漏 | 任何人都能签发有效授权 —— 等同于授权体系失效,需更换密钥对并重新部署公钥 | +| 本工具本身 | 厂商侧工具,**不随部署产物分发** | +| 公钥 | 可以公开,需要注入到设备构建产物中 | + +**本工具记住了什么**(存于系统配置,方便连续签发): +私钥**路径**、客户名、备注、密钥标识、默认天数、项目根目录、台账库路径、导出目录、校验用公钥。 +**不会记住私钥内容本身。** + +--- + +## 7. 术语表 + +| 术语 | 含义 | +| --- | --- | +| **机器码** | 由设备硬件指纹算出的短标识,形如 `BC60-D20E-F863-7236-68E2-6A24`(24 位十六进制,4 位一组)。单向哈希,反推不出原始指纹 | +| **申请码** | `CM1-…` 开头的一段文本,包含设备**各来源的原始指纹**。首次申请授权时请客户回传它 | +| **来源指纹** | 设备的各项硬件标识(主板序列号、eMMC CID、CPU 序列号、网卡 MAC、DMI product_uuid、systemd machine-id) | +| **weak 来源** | 同镜像刷机会重复的标识(`machine_id`),**不参与判定**,仅作参考 | +| **公钥指纹** | 公钥的短摘要(16 位十六进制),用来"一眼确认用的是哪把密钥" | +| **kid** | 密钥标识,写在 license 里便于区分签发批次 | +| **台账** | 本机记录已签发授权的 SQLite 库,可用于续期时复用机器指纹 | +| **license / .lic** | 签发给客户的授权文件(JSON 格式,含载荷与 Ed25519 签名) | + +--- + +## 8. 与命令行的对应关系 + +| 本工具的操作 | 等价命令行 | +| --- | --- | +| 密钥 → 生成密钥对 | `chongming-license keygen` | +| 密钥 → 加载私钥(看公钥/指纹) | `chongming-license keygen` 输出 / `inspect --key` | +| 签发 → 签发并保存 | `chongming-license issue` | +| 查看 / 校验 → 仅查看内容 | `chongming-license inspect` | +| 查看 / 校验 → 校验(按指定机器) | `chongming-license verify --request-code` | +| 台账 → 详情面板 | `chongming-license verify` | +| 台账 → 续期 / 导出 | CLI 的 `registry` 相关子命令 | +| 台账 → 改插件 | `chongming-license issue`(`--plugins` 换白名单,机器码与日期照抄台账) | + +**批量签发(一次几十台)建议直接用 CLI 脚本化**;本工具面向单台/少量、 +需要人工核对信息的场景。 + +--- + +## 附录 A:校验原因码全表 + +「查看 / 校验」页的「原因码」字段取值与含义: + +| 原因码 | 含义 | 怎么办 | +| --- | --- | --- | +| `ok` | 授权有效 | 正常 | +| `license_missing` | 未找到授权文件 | 检查设备上 license 文件路径 | +| `license_malformed` | 授权文件格式错误或内容损坏 | 传输中损坏,重新发送 | +| `signature_invalid` | 签名校验失败(可能被篡改或非本厂商签发) | 核对公钥指纹;确认是本工具签发的 | +| `unsupported_alg` | 用了不支持的签名算法 | 授权文件版本不对,重新签发 | +| `no_public_key` | 设备未配置厂商公钥,无法校验 | 请研发把公钥注入构建产物 | +| `machine_mismatch` | 机器码不匹配(不是为本机签发的) | 用**这台设备**的申请码重新签发 | +| `not_yet_valid` | 授权尚未生效 | 到了生效日期会自动生效 | +| `expired` | 授权已过期 | 续期 | +| `clock_rollback` | 检测到系统时间被回拨,校验中止 | 校正设备时间后重启网关 | +| `plugin_not_licensed` | 该插件不在授权白名单内 | 重新签发并勾选该插件 | +| `fingerprint_unavailable` | 本机无法采集硬件指纹 | 设备环境异常,联系研发 | +| `disabled` | 授权校验未启用(开发/测试模式) | 仅开发/测试环境会出现 | + +## 附录 B:授权状态对照 + +| 状态 | 判定条件(按优先级) | +| --- | --- | +| 未生效 | 生效日期 > 今天 | +| 永久有效 | 没有到期日 | +| 已过期 | 到期日 < 今天 | +| 即将到期 | 到期日 ≤ 今天 + 判定窗口(默认 30 天) | +| 有效 | 其余情况 | + +## 附录 C:机器指纹来源 + +| 来源 | 说明 | 是否参与判定 | +| --- | --- | --- | +| `board_serial` | 主板序列号(设备树/DMI) | ✅ | +| `emmc_cid` | eMMC 芯片 CID | ✅ | +| `cpu_serial` | CPU 序列号 | ✅ | +| `mac` | 物理网卡 MAC(字典序最小那块) | ✅ | +| `product_uuid` | DMI product_uuid | ✅ | +| `machine_id` | systemd machine-id | ❌ weak,仅参考 | + +判定规则:只比对 license 与本机**都有**的来源,命中数 ≥ **3** 即认定同一台设备 +(双方都只有 weak 来源时会降级纳入判定并在结论里告警)。 diff --git a/docs/界面设计.md b/docs/界面设计.md new file mode 100644 index 0000000..91a6894 --- /dev/null +++ b/docs/界面设计.md @@ -0,0 +1,326 @@ +# 界面设计说明 + +给**维护界面的人**看:颜色和样式放在哪、加一个控件该怎么做、哪些约定不能破。 + +美化前的状态是"零样式基础设施":没有 QSS、没有主题、没有图标, +只有 6 处内联 `setStyleSheet` 和一套硬编码的 `setFixedWidth(72/88/96/110/140)`。 +现在样式集中在两个模块里: + +| 文件 | 职责 | +| --- | --- | +| `src/chongming_license_gui/theme.py` | 设计 token、全局 QSS、`apply_theme()`、语义化样式助手 | +| `src/chongming_license_gui/icons.py` | 内联 SVG 图标 → `QIcon`(无二进制素材) | +| `src/chongming_license_gui/common.py` | 控件工厂:`btn()` / `hint()` / `set_status()` / `flash()` / `section()` | +| `src/chongming_license_gui/pg_store.py` | PostgreSQL 台账后端(可选)+ `PgConfig` | +| `src/chongming_license_gui/pg_dialog.py` | PostgreSQL 连接设置对话框 | +| `tools/screenshot.py` | 生成 `docs/images/` 里的文档截图 | +| `tools/pg_check.py` | 对真实 PG 做契约一致性校验(事务内执行并回滚) | + +--- + +## 1. 设计 token + +全部定义在 `theme.py` 顶部。**改主题只改这里**(或下面的 QSS 模板), +不要在页面里写死十六进制值。 + +### 主色与中性色 + +| Token | 值 | 用途 | +| --- | --- | --- | +| `ACCENT` | `#2563eb` | 主操作按钮、选中页签、卡片标题、焦点描边 | +| `ACCENT_HOVER` / `ACCENT_PRESSED` | `#1d4ed8` / `#1e40af` | 主按钮悬停 / 按下 | +| `ACCENT_SOFT` | `#eff6ff` | 表格与列表的选中底、页签悬停底、概览条底色 | +| `BG` | `#f5f6f8` | 窗口与页签面板底色(卡片浮在它上面) | +| `SURFACE` | `#ffffff` | 卡片、输入框、导出底 | +| `SURFACE_ALT` / `SURFACE_SUNKEN` | `#fafbfc` / `#f7f8fa` | 表格斑马纹 / 表头与只读区 | +| `BORDER` / `BORDER_STRONG` | `#e3e6ea` / `#cdd3da` | 卡片描边 / 输入框与次按钮描边 | +| `TEXT` / `TEXT_MUTED` / `TEXT_DISABLED` | `#1f2328` / `#6b7280` / `#9ca3af` | 正文 / 说明文字 / 禁用 | + +### 语义色 + +每个语义色都配一个浅底(`*_SOFT`)和描边(`*_BORDER`),用于横幅、胶囊、警示条。 + +| Token | 值 | 用途 | +| --- | --- | --- | +| `OK` | `#15803d` | 校验通过、已加载、主色胶囊的"正常"态 | +| `WARN` | `#b45309` | 安全提示、weak/无来源告警、未加载私钥胶囊、页脚安全条 | +| `DANGER` | `#b91c1c` | 校验失败、已过期、危险按钮(删除记录) | +| `INFO` | `#1d4ed8` | 台账"永久有效" | +| `PERMANENT` | `#6d28d9` | 台账"未生效" | +| `EXPIRING` | `#c2410c` | 台账"即将到期"。**注意**:原内联色 `#e65100` 在白底上对比度不足,这里刻意加深了一档 | + +### 形状与字体 + +| 项 | 值 | 说明 | +| --- | --- | --- | +| `RADIUS_CARD` / `RADIUS_CONTROL` | `8px` / `5px` | 卡片 / 输入框与按钮 | +| 界面字体 | `Microsoft YaHei UI` → 苹方 → Noto CJK → Segoe UI,10pt | 由 `theme.ui_font()` + `QApplication.setFont()` 下发 | +| 等宽字体 | `Cascadia Mono` → `Consolas` → `JetBrains Mono` → 系统 FixedFont | 由 `common.mono_font()`,用于机器码/指纹/申请码 | + +> ⚠️ **样式表里不要写 `font-family`**。QSS 的字体会覆盖 per-widget 的 `setFont()`, +> 而机器码、指纹这类要**逐字符核对**的文本必须保持等宽 —— 一旦被覆盖就会错位。 +> 界面字体统一走 `QApplication.setFont()`(作用范围是"默认值",不会压掉 per-widget 设置)。 + +--- + +## 2. 控件规范 + +### 卡片(分区) + +页面用 `common.section("标题")` 建卡片,返回 `(QGroupBox, QFormLayout)`。 +视觉全部由 QSS 负责(白底、1px 描边、8px 圆角、标题着主色), +代码里只设间距(`section()` 内部设 `ContentsMargins(0,2,0,0)`、行距 9、列距 12)。 + +> 卡片留白由 QSS 的 `padding` 控制,**`section()` 里不要再设 padding** —— +> 两处都设会叠加成过大的留白。 + +### 按钮 + +一律用 `common.btn(text, role=..., icon_name=..., min_width=...)`,它负责 role、 +图标着色与最小宽度。 + +| `role` | 外观 | 用在哪 | +| --- | --- | --- | +| 空 | 白底描边(次按钮) | 默认;浏览、刷新、复制等辅助操作 | +| `"primary"` | 主色实心 + 白字 + 白图标 + 加粗 | **每页只放一个**主操作:签发并保存 / 查询 / 续期选中 / 校验 / 生成密钥对 | +| `"danger"` | 白底 + 红字红描边,悬停浅红底 | 破坏性操作:删除记录 | +| `"link"` | 无边框主色文字 | 文字链(QSS 已备好,暂未使用) | + +- 用 `min_width`(`setMinimumWidth`)而不是 `setFixedWidth`:中文与高 DPI 下不会被挤压。 +- 主按钮图标自动用白色,次按钮用 `TEXT_MUTED`,危险按钮用 `DANGER` —— 不用手写颜色。 + +### 语义标签 + +| `role` | 外观 | 用途示例 | +| --- | --- | --- | +| `hint` | 灰字 | 字段下方的说明 | +| `warn` | 琥珀字 | 私钥告警、仅机器码的警示 | +| `danger` / `ok` | 红字 / 绿字 | 加载失败 / 成功 | +| `stats` / `stats-error` | 浅蓝底信息条 / 浅红底 | 台账概览(正常 / 打不开) | +| `verdict-ok` / `verdict-bad` / `verdict-idle` | 绿/红/灰**横幅** | 校验结论 | +| `chip-ok` / `chip-warn` | 圆角胶囊 | 页头的密钥状态 | +| `flash` | 绿字 | 复制后的瞬时提示(由 `common.flash()` 用完自动清除) | + +运行期改 `role` 后**必须**调用 `theme.set_role()` / `theme.repolish()`, +否则 Qt 不会重算样式(`setProperty` 本身不触发重绘)。 + +### 状态色(台账) + +`theme.STATUS_COLORS` 与 `chongming_license.STATUS_LABELS` 的键一一对应。 +表格里的状态列只改**呈现**(前景色 + 加粗),**单元格文本保持原样** +(形如 `即将到期 (30d)`)—— 有回归断言盯着,别顺手改格式。 + +### 指示器(复选框/单选框) + +QSS 画不了图形,只能用图片文件。做法是在 `theme._ensure_indicator_assets()` 里 +**按 token 生成 6 个 SVG 写进临时目录**,再由 QSS `image: url(...)` 引用: +改主题色时指示器跟着变,仓库里也不用存二进制素材。 + +这道工序有两道防线,别删: + +1. 生成的 SVG 必须能用 `QImage` 读出来,否则整套指示器样式都不生效(回落原生外观); +2. 拼接后若还残留 `@PLACEHOLDER@`,整块指示器样式**整段丢弃**。 + +> 原因:QSS 的 `image` 加载失败时 **Qt 不报错**,指示器会直接变成空白 —— +> 用户就看不见"签发后记入台账"到底勾没勾。开发过程中真的踩过一次 +> (占位符替换把 `url()` 嵌了两层),所以现在有两道防线 + 一条回归断言。 + +--- + +## 3. 页面骨架 + +``` +┌─ QMainWindow ────────────────────────────────────────────┐ +│ 页头 QFrame[role=header]:盾牌图标 + 标题/副标题 + 密钥状态胶囊 │ +├──────────────────────────────────────────────────────────┤ +│ QTabWidget:签发 / 台账 / 密钥 / 查看·校验(带图标) │ +│ └ 每个页签 = QScrollArea#pageScroll → 页面 QWidget │ +│ └ section() 卡片… │ +├──────────────────────────────────────────────────────────┤ +│ 页脚 QFrame[role=footer]:锁图标 + 私钥安全提示(琥珀条) │ +└──────────────────────────────────────────────────────────┘ +``` + +### 为什么每页都套滚动容器 + +原实现是"页签直接装内容"。窗口 1180×940、签发页内容约 1360px, +在 1366×768 的笔记本上**底部的「签发并保存…」被窗口切掉且无法滚到**。 +现在四个页签统一套 `QScrollArea(setWidgetResizable(True))`,内容再长也能滚到。 + +> 注意:套了容器后 `tabs.widget(index)` 返回的是 `QScrollArea` 而不是页面对象。 +> `MainWindow._on_tab_changed` 因此改成按 `self._pages[index]` 比对, +> 页面 → 容器的映射在 `self._area_by_page`。新增页签时别忘了同步这两处。 + +### 打开时就用最小尺寸 + +窗口打开即最小尺寸(`resize(self.minimumSize())`,当前 940×640):小屏笔记本上 +不会顶出屏幕,需要更大自己拉。页签都在滚动容器里,窗口小只是多滚动,不会把按钮挤没。 + +> 代价:940 宽时台账表格(9 列合计 1322px)要横向滚动才能看到最右侧的列; +> 表格一直是横向可滚的,只是露出多少列不同。改这个值时在 1366×768 上试一眼。 +> 文档截图仍按 1240 宽渲染(`tools/screenshot.py` 的 `WINDOW_WIDTH`),比实际窗口宽。 + +### 页头的密钥状态胶囊 + +显示"私钥已加载 · 指纹 xxxx"(绿)或"未加载私钥 · 点击到「密钥」页加载"(琥珀), +点击跳转到密钥页。标题栏同时也带指纹(多开窗口/截图沟通时靠它区分)。 + +--- + +## 4. 怎么改 + +| 想做的事 | 改哪里 | +| --- | --- | +| 换主色/配色 | `theme.py` 顶部的 token;QSS 与指示器 SVG 都会跟着变 | +| 调卡片留白、圆角、按钮尺寸 | `theme.py` 的 `_QSS` 模板(`@TOKEN` 占位符) | +| 加一个新图标 | `icons.py` 的 `_SHAPES` 加一条 24×24 描边路径,然后 `icons.icon("名字")` | +| 加一种语义样式 | `theme.py` 的 `_TOKENS` 加色值 → `_QSS` 加 `QLabel[role="..."]` 规则 | +| 换界面字体/字号 | `theme.ui_font()` | +| 换等宽字体 | `common._MONO_CANDIDATES` | +| 改台账的 PG 行为(SQL/方言/建表) | `pg_store.py`:SQL 提在模块级常量里,改完跑 `smoke_test.py` 的「PG 适配层」小节 | +| 改 PG 连接对话框字段 | `pg_dialog.py` + `pg_store.PgConfig`(记得同步 `from_settings` / `save`) | +| 重新生成文档截图 | `uv run python tools/screenshot.py` | + +### QSS 模板的占位符 + +`_QSS` 里用 `@TOKEN` 写色值(**不用** `str.format`/f-string:样式表里的 CSS 花括号 +会被当成占位符)。`_substitute()` 按名字**从长到短**替换,避免 `@TEXT` 吃掉 `@TEXT_MUTED`; +占位符结尾的 `@` 可写可不写(`@TEXT` 与 `@TEXT@` 都行)。 + +### 图标 + +单色描边图标(feather 风格),运行时用 `QSvgRenderer` 光栅化到 2× 位图并标记 +devicePixelRatio,按 `(名称, 颜色, 尺寸)` 缓存。`PySide6.QtSvg` 不可用时 +`icon()` 返回空 `QIcon` —— 少个图标不影响功能,绝不能因此报错。 + +--- + +## 5. 台账的双后端 + +台账可以存本地 SQLite,也可以存共享 PostgreSQL。设计上刻意让**页面完全不知道** +后端是谁: + +``` +RegistryTab ──> RegistryHolder ──┬─> chongming_license.Registry (本地 SQLite) + └─> pg_store.PostgresRegistry (PostgreSQL) +``` + +`PostgresRegistry` **继承**库的 `Registry`,只重写与存储引擎相关的 6 个方法 +(`__init__` / `query` / `get` / `save` / `update_note` / `delete`), +`stats` / `find_by_machine_code` / `export_licenses` / `export_csv` / `close` / +`_row_to_record` **全部继承** —— 于是 CSV 列序、`.lic` 导出文件名与客户目录转义、 +状态分桶统计都不可能和 SQLite 版出现第二套实现。 + +几条必须保持的语义(改 PG 后端时逐条对照): + +| 语义 | 为什么 | +| --- | --- | +| 状态筛选留在 Python 侧(用 `LicenseRecord.status`) | 状态有优先级,下推成 SQL 条件会互相重叠,`stats()` 各项之和会不等于总数 | +| `ON CONFLICT (lic_id) DO UPDATE SET` 恰好 13 列,**不含 `created_at`** | 首次入库时间要保留;覆盖时只刷新 `updated_at` | +| 覆盖已有记录时保留台账人工批注(除非 `overwrite_note=True`) | 载荷 `note` 是签名内容,台账 `note` 是厂商批注,同名不同用途 | +| 日期列用 TEXT 存、`ORDER BY COALESCE(expires_at,'9999-12-31')` | 库依赖 ISO 日期字符串比较排序,NULL(永久)排最后 | +| 过滤用 `ILIKE` 而不是 `LIKE` | SQLite 的 `LIKE` 对 ASCII 大小写不敏感,用 PG 的 `LIKE` 会让同一条件在两端结果不同 | +| `autocommit=True`(界面路径) | 镜像 SQLite 的逐语句提交;否则长连接会长期 `idle in transaction` | +| schema 名必须校验后加引号 | psycopg 的参数占位符不能用于标识符,不校验就是注入面 | + +界面侧:「台账库」卡片里用 `setRowVisible` 在「路径」行与「连接」行之间切换 +(不用 `QStackedWidget`:那会破坏表单标签对齐)。切换后端时**主动断开**当前连接, +避免"界面显示一个后端、实际读写另一个"。密码只存 `PgConfig`,且: + +- 不进日志、不进标签、不进错误信息(`display()` 与连接 kwargs 都刻意排除它); +- 只有勾选"记住密码"才写 QSettings,取消勾选即删除。 + +### 5.1 两个"重签"行操作 + +台账里能改授权内容的两个操作都是**重签**(签名覆盖整个载荷,改任何字段只能重签): + +| 操作 | 变了什么 | `source` | 旧记录批注 | +| --- | --- | --- | --- | +| 续期选中…(`RenewDialog`) | 到期日(`not_before` 不传 = 从现在起算) | `renew` | `已续期 → <新编号>` | +| 改插件…(`ChangePluginsDialog`) | 只改 `plugins`;`not_before`/`expires_at` **逐字写回** | `plugins` | `已变更插件 → <新编号>` | + +两者共用 `RegistryTab._resign_and_save()`(选路径 → 复用台账指纹重签 → 写盘 → 入库 +→ 批注),区别只在传进去的日期与文案。改插件对话框刻意**不提供**有效期选项: +要延长有效期就走续期,免得"只想加个插件"顺手把到期日也改了。 + +插件勾选由 `plugin_picker.PluginPicker` 提供(签发页与改插件对话框共用)。它按 +"构建器"形态接收调用方的 `QFormLayout` 并直接往里加行,签发页排版因此与抽出之前一致; +`changed` 信号供对话框实时算"新增/移除"差异。老白名单靠 `ensure_types()` 补进列表 —— +项目里已经删掉的插件 type 也必须看得见、勾得回来,否则操作者没法对照"原来授权了什么"。 + +对话框里两个刻意的取舍,改版式时别改回去: + +- `show_root=False`:省掉「项目根」行与重新加载按钮。项目根是签发页的设置, + 两个地方都能改反而容易改岔;对话框里宽度与高度优先留给操作本身。 +- 原授权摘要压成 3 行等宽只读块(`make_mono_label`),与勾选列表同卡。 + 这个对话框只干"换白名单"一件事,**高度要收在 ~570px 以内** —— + 768 高的笔记本屏上才放得下(对话框没有滚动区,超了就是按钮点不到)。 + +## 6. 回归断言(改界面时先看这里) + +`smoke_test.py` 开头有一段"界面主题"检查,专门盯那些**出错时不会报错、只会静默变丑**的地方: + +- 样式表已应用、无残留占位符; +- 主操作用主色实心、次按钮保持白底(防 role 写错); +- 危险按钮确实带危险色; +- 勾选态指示器按主题渲染(防指示器变空白); +- 主色/卡片底/页面底/警示色都真的画到了界面上; +- 台账状态列用主题状态色、加粗、机器码列等宽; +- 台账「改插件」对话框:默认预勾选原白名单、默认生成新编号、**没有**有效期选项、 + 差异行按"与原来一致 / 新增 / 移除"变化并在移除时亮出拒载告警、一个都不勾时拒绝提交; +- 改插件是**重签**:载荷里除 `lic_id`/`issued_at`/`note`/`plugins` 外逐字不变 + (`not_before`/`expires_at`/`sources` 原样写回),新文件可独立验签; +- **配置隔离自证**:`settings.fileName()` 必须落在临时目录里。 + +末尾还有一段"PostgreSQL 后端"检查,**不需要数据库服务器**也能跑: + +- SQL 形状(INSERT 15 列列序、`DO UPDATE SET` 13 列且不含 `created_at`、 + `COALESCE` 排序、四个条件都用 `ILIKE`); +- 用假连接验证 `save()` 的两条硬语义(保留人工批注、保留 `created_at`); +- 未装驱动时的可读提示、连不上时的可读提示且**不泄漏密码**; +- 「存储」下拉的切换行为(行切换 + holder 目标后端 + 断开旧连接)。 + +改完界面跑一遍: + +```bash +QT_QPA_PLATFORM=offscreen uv run python smoke_test.py +``` + +**要验 PostgreSQL 的真实读写**,用事务版校验(对真实库零残留): + +```bash +uv run python tools/pg_check.py --host 127.0.0.1 --port 5432 --dbname <库> --user <账号> +``` + +## 7. 维护红线 + +1. **页面里不写颜色、不写 `setFixedWidth`** —— 用 token 和 `common.btn()`。 +2. **不动控件属性名**:`smoke_test.py` 直接引用 `issue_btn`、`preview`、`verdict`、 + `table`、`records` 等属性;改名会静默破坏测试。 +3. **四个页签的文本与数量不要改**(`签发/台账/密钥/查看 / 校验`), + 台账表格保持 **9 列同序同文本**。 +4. **保持 `from .common import info, warn, error` + 裸调用**: + 测试靠替换各模块的同名全局来消除模态弹窗,改成 `common.info(...)` 会让弹窗 + 阻塞无头测试。 +5. **QSS 不设全局 `font-family`**(见上文)。 +6. **指示器的两道防线别删**。 +7. **测试/截图/自检脚本必须用 `common.isolated_settings()` 建配置,并调用 + `common.assert_isolated_settings()` 自证落点。** + + > 这条是踩坑换来的:`QSettings.setDefaultFormat(IniFormat)` 在本环境里**没有** + > 改变两参数构造 `QSettings(org, app)` 的落点,`fileName()` 仍指向注册表 + > `HKCU\Software\chongming\license-gui`。于是脚本以为在写临时配置,实际把测试值 + > (甚至数据库密码)写进了用户真实配置,还顺手 `clear()` 掉了用户自己的设置。 + > 现在一律用四参数构造(format + scope 显式指定),并**回读 `fileName()` 断言** + > 它在临时目录下 —— 隔离这件事不能靠"我以为"。 + +## 8. 已知取舍 + +- **基底样式用 Fusion**:本主题大量依赖 QSS,Windows 原生样式与 QSS 混用会出现 + "一半原生一半自绘";Fusion 完全可自绘、跨平台一致,也避免系统深色主题下 + 浅色卡片"半明半暗"。代价是与系统原生控件观感略有差异。 +- **只做了浅色主题**。token 已按主题化组织,要加深色变体的话,把 `_TOKENS` + 换成另一套色值即可(`build_qss()` 不用改);指示器 SVG 也会跟着变。 +- **签发页内容偏长**(约 1360px),在 1080p 上仍需要少量滚动。若要进一步压缩, + 可以把"来源指纹"与"解析结果"并排放,或把「预览与签发」提到更靠上的位置 —— + 但那是信息架构调整,不是样式问题。 diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..29e2025 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,217 @@ +# 打包分发 + +把 GUI 打成**免安装 exe**,发给厂商内部同事(商务/售后)。两种方式各一个脚本, +都自带"私钥守卫 + 产物自检"。 + +```bash +uv sync --group dev # 装打包工具(pyinstaller / nuitka) + +uv run python packaging/build_pyinstaller.py # 方式一:PyInstaller(快,默认单文件) +uv run python packaging/build_nuitka.py # 方式二:Nuitka(真编译,慢但更小更快) + +# 产物 +dist/pyinstaller/chongming-license-gui.exe +dist/nuitka/chongming-license-gui.exe +``` + +## 该选哪个 + +下表为**本机实测值**(Windows 11 / Python 3.14 / Qt 6.11.2 / MSVC 14.5,单文件模式): + +| | PyInstaller | Nuitka | +| --- | --- | --- | +| 构建耗时 | **约 2.5 分钟**(实测 149s) | **约 10 分钟**(首次实测 1140s,含一次编译器重试) | +| 构建前提 | 无 | **需要 C 编译器**(MSVC 或 Nuitka 支持的 mingw64) | +| 单文件体积 | 54.8 MB | **38.6 MB** | +| 启动 + 跑完自检 | 2.7s | **1.8s** | +| 反编译难度 | 打包的 .pyc 可被还原 | **编译成 C,难得多** | +| 稳定性 | 生态成熟、踩坑资料多 | 需要针对 Python/Qt 版本调参(见下) | + +**建议**:日常发给同事用 **Nuitka 单文件**(更小、启动更快、更难反编译); +需要快速出包/临时验证用 **PyInstaller**(两分半就好,且不需要编译器)。 + +> ⚠️ **要 PostgreSQL 支持就用 PyInstaller。** Nuitka 在 Python 3.13+ 上只能用 MSVC +> (`--mingw64` 被 Nuitka 明确拒绝),而 MSVC 编译 psycopg 的生成代码会 +> `fatal error C1002`(编译器堆空间不足)—— 这是编译器上限,不是代码问题。 +> Nuitka 脚本检测到这一点会**自动降级**为"不带 PG"的构建并告诉你; +> 也可以显式 `--no-postgres`。不带 PG 的包里,界面会如实提示 +> 「未安装 PostgreSQL 驱动(可选功能)」,本地 SQLite 台账与签发校验全部照常。 + +## 常用参数 + +两个脚本参数一致: + +| 参数 | 作用 | +| --- | --- | +| `--onedir` / `--standalone` | 打成目录而不是单文件。**启动更快**,适合放在固定办公机上;分发时整个目录一起拷 | +| `--console` | 保留控制台窗口。默认是**无控制台**(纯 GUI);排查"双击没反应"时用得上 | +| `--clean` | 构建前清理旧的 `dist/`(Nuitka 还会顺手删中间产物) | +| `--lto=no`(仅 Nuitka) | 关掉链接期优化 —— **显著加快构建**(默认开着,链接阶段实测要 6 分钟以上) | +| `--no-low-memory`(仅 Nuitka) | 关掉低内存模式。默认**开启**,见下方 C1002 说明 | +| `--no-selftest` | 跳过构建后的产物自检(不建议) | +| `--allow-sensitive-files` | 源码树里有私钥/`.lic`/台账库时仍继续构建(默认**中止**,见下) | +| `--msvc` / `--mingw64`(仅 Nuitka) | 指定编译器;默认自动挑选 | + +## 两道安全闸门(重要) + +**第一道:构建前扫源码树。** 命中 `license_private*.pem`、`*_private.pem`、 +`*_private.key`、`*.lic`、`licenses.db` 一律**中止构建**并列出文件: + +``` +构建中止:源码树里存在私钥/授权文件/台账库,它们绝不能进入分发包。 + - license_L20260921-A36014.lic +``` + +原因很直接:这个工具**含签发能力**。私钥一旦随包发出去,拿到的人就能自己签发有效授权; +`.lic` 与台账库则含客户名与机器指纹。确实只是临时放置时,可加 +`--allow-sensitive-files` 跳过(会打印警告并列出文件)。 + +**第二道:构建后再查产物目录**,避免 onedir 模式把工作目录里的散落文件一起拷进去。 + +## 构建后自检(为什么必须有) + +冻结失败往往是**静默**的 —— 界面能开,但图标全空白、复选框点不动,或者签发时才抛异常。 +所以构建脚本会自动调用 `` --selftest`` 跑一遍真依赖,而且跑**两轮**: + +| 轮次 | `QT_QPA_PLATFORM` | 验的是什么 | +| --- | --- | --- | +| 1 | `offscreen` | 无头环境也能验;CI 里可跑 | +| 2 | 系统原生(Windows 上是 `windows`) | **Qt 的 platforms 插件真的打进产物了** —— 少了它,开发机上一切正常,同事双击闪退 | + +自检覆盖的正是打包最容易漏的东西: + +- **QtSvg 模块**:少了它所有图标变空白(`QIcon` 是空的,按钮上什么都不画); +- **svg 图片格式插件**:少了它样式表里的复选框/单选框指示器加载失败 → 勾选框变空白; +- **cryptography 的 Ed25519 后端**:签发与验签全链路(含"异机指纹被拒绝"); +- **orjson**(编译扩展):申请码编解码往返; +- **sqlite3 + 台账**:入库/检索/统计; +- **PostgreSQL 驱动(可选功能)**:构建输出里会打印一行 `PostgreSQL 驱动:可用/不可用`; + 另有一个显式的 `CHONGMING_LICENSE_PG` 开关,给了才会**真连一次**库并跑一遍台账 + 适配层(在事务里,跑完回滚,不留痕迹); +- **tomllib + 路径处理**:插件自动发现; +- **主窗口装配**:四个页签、表格 9 列、结论横幅、整窗渲染出非空位图。 + +自检**不碰真实数据**:QSettings 被重定向到临时 INI、台账库指向临时文件 +(否则 `RegistryTab` 初始化会去动你真实的 `~/.chongming/licenses.db`)。 +它也不会弹窗 —— 全程不调用 `show()`,只在内存里渲染。 + +手动跑: + +```bash +dist/pyinstaller/chongming-license-gui.exe --selftest --selftest-report 报告.txt +CHONGMING_LICENSE_PG="postgresql://user:pw@host:5432/db" \ + dist/pyinstaller/chongming-license-gui.exe --selftest # 额外验一次真库 +``` + +## 踩过的坑:PATH 上的同名 DLL 会污染产物 + +**症状**:exe 能启动、界面正常,但 PostgreSQL 功能报 +`ImportError: no pq wrapper available`,往深看是 +`DLL load failed while importing _ssl: 找不到指定的程序`。 + +**原因**:Windows 上 `libssl-3-x64.dll` / `libcrypto-3-x64.dll` 这类**通用名 DLL** +到处都是 —— Git for Windows、msys64、各种 SDK、厂商工具目录。它们都在 PATH 上。 +PyInstaller 解析二进制依赖时会搜 PATH,**搜到哪份就用哪份**,于是把 +`_ssl.pyd` 需要的 OpenSSL 换成了别的构建: + +| 来源 | 大小 | +| --- | --- | +| Python 自带(正确) | 1,579,008 字节 | +| **Git for Windows**(被误收集) | **930,460 字节** | +| msys64 | 1,055,094 字节 | + +版本对不上,`_ssl.pyd` 绑定符号失败 → 依赖 `_ssl` 的 psycopg 二进制实现整块不可用。 + +**修法**:`_common.build_env_overrides()` 在构建前清一遍 PATH —— +凡是**含有与 Python 自带 DLL 同名文件**的目录一律临时摘掉(系统目录永远保留), +并打印摘掉了哪些,两个构建脚本都会用。本机实测一次摘掉了 Git、msys64、 +`E:\polyos_qemu`、ShadowBot,以及 PySide6 自己的目录。 + +> 这类问题的教训:**报错点离原因很远**(psycopg 说"没有 pq 实现", +> 实际是 OpenSSL 版本不对)。所以构建后自检必须验到"驱动能真的连上库", +> 光验"模块能 import"是不够的。 + +## 实现要点 + +``` +packaging/ +├── launcher.py # 冻结入口(用绝对导入,不用 __main__.py 的相对导入) +├── pyinstaller.spec # PyInstaller 配置(选项经环境变量传入) +├── build_pyinstaller.py # 驱动 + 守卫 + 自检 +├── build_nuitka.py # 驱动 + 守卫 + 自检 +└── _common.py # 图标生成 / 私钥扫描 / PATH 清理 / 自检驱动(两个脚本共用) +``` + +- **入口用 `launcher.py` 而不是 `__main__.py`**:后者是 `from .app import main` 这样的 + 包内相对导入,被冻结工具当普通脚本编译时会因失去包上下文而失败。 +- **用源码树而不是 editable 安装**:spec 里 `pathex=src/`、Nuitka 里设 `PYTHONPATH=src/`。 + 冻结工具对 `.pth` 式 editable 安装的解析并不可靠。 +- **图标是构建时生成的**:`_common.build_app_icon()` 把主题里的盾牌 SVG 渲染成 + 7 个尺寸的 `.ico`(自己拼 ICO 容器 —— Qt 的 ICO 写入只支持单张图,小尺寸会糊), + 写完立刻回读校验。所以仓库里不需要存任何美术素材。 +- **关掉 UPX**:UPX 压缩容易触发杀软误报,也可能损坏 Qt DLL。 +- **裁掉用不到的 Qt 模块**:PySide6 装了整套 Addons(Qt3D/WebEngine/Charts…), + spec 里显式排除了一批;Nuitka 侧由 `pyside6` 插件负责,另有 `--nofollow-import-to`。 +- **构建前清理 PATH**(见上一节):挡住同名 DLL 污染产物。 +- **Optional 依赖**:PostgreSQL 支持是 `[project.optional-dependencies] postgres`, + 但 dev 依赖组里也列了 psycopg —— 这样 `uv sync --group dev` 出来的环境打出来的包 + **自带 PG 支持**;不做 PG 的用户 `uv sync` 也不会被强迫装驱动。 + +## 分发提醒 + +- 本工具是**厂商侧工具**,含签发能力,只发厂商内部同事;**不要给客户**,也不要放进部署产物。 +- 打包产物里的界面截图、示例数据都来自 `tools/screenshot.py` 的演示数据,不含真实客户信息。 +- exe **未做代码签名**,同事首次运行可能遇到 SmartScreen 提示("更多信息 → 仍要运行")。 + 要么让 IT 走内部白名单,要么买代码签名证书后给 exe 签名。 + +## 常见问题 + +**双击没反应 / 闪退?** +用 `--console` 重新打一个,命令行里运行看报错。多数是缺平台插件(自检能提前发现)。 + +**杀软报毒?** +PyInstaller 单文件的自解压行为常被启发式判成可疑。改用 `--onedir`,或换 Nuitka。 + +**Nuitka 报找不到 C 编译器?** +装 Visual Studio 的「使用 C++ 的桌面开发」工作负载;或用 `--mingw64` +(需 Nuitka 支持的 mingw64 版本 —— msys2 自带的那个通常不被接受)。 + +**Nuitka 报 `fatal error C1002` / `LNK1257`(编译器堆空间不足)?** +Nuitka 为每个模块生成一个上万行的 C 文件,MSVC 编译最大的那个时会耗尽编译器内部 +堆空间。本项目的踩坑记录: + +- 不带 `--low-memory` 时崩在 `chongming_license_gui.registry_tab`; +- 加上 `--low-memory` 后能过,但**引入 psycopg 后又会崩在 `psycopg.errors`** + (该模块定义了几百个异常类,生成 C 文件 2.8 万行,MSVC 单文件上限)。 + +这是 MSVC 的编译上限,不是代码问题。对策: + +1. **优先用 PyInstaller 出带 PostgreSQL 支持的包** —— 它不编译 C,没有这个限制, + 本项目已验证可用; +2. 或者给 Nuitka 换编译器:`--mingw64`(Nuitka 会自己下载它支持的 + WinLibs mingw64,msys2 自带的那份不被接受); +3. 只在不需要 PG 时才用默认的 MSVC + `--low-memory`。 + +脚本默认已带 `--low-memory`(不带必崩),检测到该错误码还会自动重试一次。 + +**Nuitka 提示 "Please install the English language pack for Visual Studio"?** +中文版 VS 的报错文本是 GBK,Nuitka 解析不了(只影响日志可读性,不影响构建)。 +装个英文语言包,或忽略它、直接看 `packaging/build/nuitka-build.log`。 + +**Nuitka 提示 "Please install the English language pack for Visual Studio"?** +中文版 VS 的报错文本是 GBK,Nuitka 解析不了(只影响日志可读性,不影响构建)。 +装个英文语言包,或忽略它、直接看 `packaging/build/nuitka-build.log`。 + +**体积能不能再小?** +`--onedir` 后再自行压缩分发;或继续往 spec 的 `EXCLUDES` 里加确定用不到的模块。 +单文件模式会把所有东西解压到临时目录,体积换的是分发便利。 + +**Nuitka 构建太慢?** +时间主要花在两处:一是 C 编译(Nuitka 的缓存目录会保留中间产物,第二次快很多), +二是 **LTO 链接**(实测单这一步就 6 分钟以上)。加 `--lto=no` 可显著缩短, +代价是产物略大、优化略少。另外 `.py` 文件改动越多,重新编译的文件越多。 + +**能不能打包 Linux / macOS 版?** +可以,但要在目标系统上构建(冻结产物不跨平台)。`packaging/_common.py` 里的 +原生平台名已覆盖 `cocoa` / `xcb`,自检会自动按平台跑。注意本工具的硬件指纹 +来源全是 Linux 侧的,打包目标平台与授权对象设备无关。 diff --git a/packaging/_common.py b/packaging/_common.py new file mode 100644 index 0000000..b2b6c7f --- /dev/null +++ b/packaging/_common.py @@ -0,0 +1,404 @@ +"""打包脚本共用部分:图标生成、私钥守卫、冻结产物自检驱动。 + +两个打包脚本(PyInstaller / Nuitka)只有"怎么调工具"不同,其余诉求一样: + +1. **别把私钥打进去** —— 构建前扫一遍源码树,发现私钥/授权文件/台账库立即中止; +2. **给 exe 一个像样的图标** —— 图标是运行时由主题生成的,这里把它渲染成多尺寸 + .ico(所以仓库里不需要存美术素材); +3. **构建完必须验证** —— 调用 `` --selftest`` 实际跑一遍依赖与渲染, + 而且要在 offscreen 与系统原生两个平台插件下各跑一次(后者才能证明 + Qt 的平台插件真的被打进产物里了)。 +""" +from __future__ import annotations + +import os +import struct +import subprocess +import sys +import tempfile +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PACKAGING = ROOT / "packaging" +DIST = ROOT / "dist" +APP_NAME = "chongming-license-gui" + +# 绝不允许进入产物的东西 +SENSITIVE_PATTERNS = ( + "license_private*.pem", + "*_private.pem", + "*_private.key", + "*.lic", + "licenses.db", +) +_SKIP_DIRS = {".venv", ".git", "dist", "build", "__pycache__", ".zcode", + ".idea", ".vscode", "node_modules"} + +# 图标尺寸(Windows 会按需挑选;16/32 用于任务栏与文件列表) +ICON_SIZES = (16, 24, 32, 48, 64, 128, 256) + + +# --------------------------------------------------------------------------- +# 前置检查 +# --------------------------------------------------------------------------- +def require(module: str, package: str) -> None: + """确认打包工具已安装,否则给出一句能直接照抄的安装命令。""" + try: + __import__(module) + except ImportError: + raise SystemExit( + f"未安装 {package}。请先安装开发依赖:\n" + f" uv sync --group dev\n" + f"(或单独安装:uv add --group dev {package})" + ) from None + + +def project_version() -> str: + data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + return str(data.get("project", {}).get("version", "0.0.0")) + + +def find_sensitive_files() -> list[Path]: + """扫描源码树里的私钥 / 授权文件 / 台账库。""" + found: list[Path] = [] + for path in ROOT.rglob("*"): + if not path.is_file(): + continue + if any(part in _SKIP_DIRS for part in path.parts): + continue + if any(path.match(pattern) for pattern in SENSITIVE_PATTERNS): + found.append(path) + return found + + +def guard_no_secrets(*, allow: bool = False) -> None: + """构建前的安全闸门:这些文件一个都不该出现在仓库里,更不该进产物。 + + ``allow=True``(对应 ``--allow-sensitive-files``)只降级为警告并列出文件 —— + 用在"文件确实只是暂时放在这里、我清楚它不会随产物分发"的场景。 + 私钥进产物 = 拿到的人能自己签发授权,所以默认是硬中止。 + """ + found = find_sensitive_files() + if not found: + return + listing = "\n".join(f" - {p.relative_to(ROOT)}" for p in found) + if allow: + print("[警告] 源码树里存在敏感文件,但已显式允许继续构建:") + print(listing) + print("[警告] 它们不会被打进产物(本工具不使用 datas),但请勿随目录一起分发。\n") + return + raise SystemExit( + "构建中止:源码树里存在私钥/授权文件/台账库,它们绝不能进入分发包。\n" + f"{listing}\n\n" + "请先移走(私钥放离线介质、台账库与 .lic 移到个人目录),确认无误后再重新构建。\n" + "确实只是临时放置、不影响分发时,可加 --allow-sensitive-files 跳过本检查。" + ) + + +def guard_artifact_no_secrets(artifact: Path) -> list[str]: + """构建后再查一遍:产物目录里不该出现 .pem/.lic/.db 之类的散落文件。 + + onefile 是自解压单文件,这里查不到内部内容 —— 真正的防线是构建前的 + :func:`guard_no_secrets`;这一步是为了兜住"onedir 模式下把工作目录里的 + 文件一起拷进去"这类意外。 + """ + if not artifact.is_dir(): + return [] + hits = [ + str(p.relative_to(artifact)) + for p in artifact.rglob("*") + if p.is_file() and any(p.match(pat) for pat in SENSITIVE_PATTERNS) + ] + return hits + + +# --------------------------------------------------------------------------- +# 图标:把主题里的盾牌渲染成多尺寸 .ico +# --------------------------------------------------------------------------- +def _render_icon_pngs() -> dict[int, bytes]: + """渲染各尺寸的 PNG 数据(用 QSvgRenderer,无需美术素材)。""" + sys.path.insert(0, str(ROOT / "src")) + from PySide6.QtCore import QBuffer, QIODevice + from PySide6.QtGui import QGuiApplication, QImage, QPainter + from PySide6.QtSvg import QSvgRenderer + + from chongming_license_gui import icons, theme + + # 只渲染图像,不建窗口 —— QGuiApplication 就够 + app = QGuiApplication.instance() or QGuiApplication([]) + del app + + svg = icons.svg_markup("shield", theme.ACCENT) + renderer = QSvgRenderer(svg.encode("utf-8")) + if not renderer.isValid(): + raise SystemExit("图标渲染失败:盾牌 SVG 无效(icons.svg_markup 返回空?)") + + pngs: dict[int, bytes] = {} + for size in ICON_SIZES: + image = QImage(size, size, QImage.Format.Format_ARGB32) + image.fill(0) + painter = QPainter(image) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + renderer.render(painter) + painter.end() + buffer = QBuffer() + buffer.open(QIODevice.OpenModeFlag.WriteOnly) + if not image.save(buffer, "PNG"): + raise SystemExit(f"图标渲染失败:{size}px 无法编码为 PNG") + pngs[size] = bytes(buffer.data()) + return pngs + + +def _write_ico(target: Path, pngs: dict[int, bytes]) -> None: + """按 ICO 规范拼装多尺寸图标。 + + Qt 的 ICO 写入只支持单张图,而小尺寸(16/32)靠 256px 缩放会糊, + 所以这里自己拼容器:每张图用 PNG 编码嵌入(Vista 以后都支持)。 + """ + entries = b"" + blobs = b"" + offset = 6 + 16 * len(pngs) + for size in sorted(pngs): + png = pngs[size] + # 宽高为 0 表示 256(一个字节放不下 256) + dim = 0 if size >= 256 else size + entries += struct.pack(" Path | None: + """生成 ``app.ico``;失败返回 None(没图标也能打包,不该因此中断构建)。""" + target_dir = out_dir or (PACKAGING / "build") + target = target_dir / "app.ico" + try: + target_dir.mkdir(parents=True, exist_ok=True) + _write_ico(target, _render_icon_pngs()) + # 回读校验:写坏了会导致打包时静默无图标 + from PySide6.QtGui import QImage + + check = QImage(str(target)) + if check.isNull(): + print("[warn] 生成的 .ico 无法回读,本次构建将不带图标") + return None + print(f"应用图标:{target.relative_to(ROOT)}({len(ICON_SIZES)} 个尺寸,回读校验通过)") + return target + except Exception as exc: # noqa: BLE001 - 图标属于锦上添花,不阻断构建 + print(f"[warn] 生成应用图标失败({exc}),本次构建将不带图标") + return None + + +# --------------------------------------------------------------------------- +# 构建后验证 +# --------------------------------------------------------------------------- +def find_artifact(dist_dir: Path) -> Path | None: + """在 dist 目录里找打包出来的 exe(onefile 与 onedir 布局不同)。""" + direct = dist_dir / f"{APP_NAME}.exe" + if direct.is_file(): + return direct + nested = dist_dir / APP_NAME / f"{APP_NAME}.exe" + if nested.is_file(): + return nested + nested_dist = dist_dir / f"{APP_NAME}.dist" / f"{APP_NAME}.exe" + if nested_dist.is_file(): + return nested_dist + for candidate in dist_dir.rglob("*.exe"): + return candidate + return None + + +# 系统原生平台插件名(用于"真的能在桌面上跑起来"那一轮验证)。 +# 必须显式传:app.main() 里对 QT_QPA_PLATFORM 用的是 setdefault,只 unset 变量 +# 会退回 offscreen,等于"原生那轮"根本没验到原生插件。 +NATIVE_PLATFORM = { + "win32": "windows", + "darwin": "cocoa", + "linux": "xcb", +}.get(sys.platform) + + +def run_selftest(exe: Path, platform: str | None, timeout: int = 600) -> tuple[bool, str]: + """运行 `` --selftest``,返回 (是否通过, 输出)。 + + 窗口版 exe(--noconsole)没有可用的 stdout,所以结果以**报告文件**为准。 + """ + env = dict(os.environ) + if platform: + env["QT_QPA_PLATFORM"] = platform + else: + env.pop("QT_QPA_PLATFORM", None) + + report_dir = Path(tempfile.mkdtemp(prefix="clg-build-check-")) + report = report_dir / "selftest.txt" + try: + proc = subprocess.run( + [str(exe), "--selftest", "--selftest-report", str(report)], + capture_output=True, timeout=timeout, env=env, cwd=str(exe.parent), + # 冻结产物的输出不保证是 UTF-8(中文 Windows 上可能是 GBK), + # 用 replace 兜住,别让解码失败把整个构建判定成失败 + encoding="utf-8", errors="replace", + ) + output = (proc.stdout or "") + (proc.stderr or "") + code = proc.returncode + except subprocess.TimeoutExpired: + return False, f"自检超时(>{timeout}s)—— 产物可能卡在启动阶段" + + if report.is_file(): + output += "\n[报告文件]\n" + report.read_text(encoding="utf-8", errors="replace") + return code == 0, output + + +def verify_artifact(exe: Path, label: str) -> bool: + """两轮自检:offscreen(无头可用)+ 系统原生平台插件。 + + 第二轮是关键:只有它才能证明 Qt 的 **platforms 插件**(Windows 上是 + ``qwindows.dll``)真的被打进了产物 —— 少了它,开发机上一切正常, + 同事双击 exe 却直接闪退。 + """ + print(f"\n=== 验证 {label}:{exe} ===") + runs: list[tuple[str | None, str]] = [("offscreen", "offscreen(无头)")] + if NATIVE_PLATFORM: + runs.append((NATIVE_PLATFORM, f"系统原生平台 {NATIVE_PLATFORM}")) + else: + print(f" [跳过] 未知平台 {sys.platform},无法验证原生平台插件") + + ok = True + for platform, tag in runs: + passed, output = run_selftest(exe, platform) + # 报告文件与 stdout 内容重复,摘取摘要行时去重(保留首次出现顺序)。 + # 「PostgreSQL 驱动」那行是"可选功能是否真的打进产物"的唯一信号 —— + # 驱动缺失时 PG 功能整块不可用,必须让人在构建输出里一眼看到。 + head: list[str] = [] + for line in output.splitlines(): + if line.startswith(("平台插件", "PostgreSQL 驱动", "[FAIL]", "❌", "✅")) \ + and line not in head: + head.append(line) + print(f" [{tag}] {'通过 ✅' if passed else '失败 ❌'}") + for line in head: + print(f" {line}") + if not passed: + ok = False + print(" ---- 完整输出 ----") + for line in output.splitlines(): + print(f" {line}") + return ok + + +def artifact_size(path: Path) -> str: + """人类可读的产物体积(onedir 递归求和)。""" + if path.is_file(): + total = path.stat().st_size + else: + total = sum(p.stat().st_size for p in path.rglob("*") if p.is_file()) + for unit in ("B", "KB", "MB", "GB"): + if total < 1024 or unit == "GB": + return f"{total:.1f} {unit}" if unit != "B" else f"{total} B" + total /= 1024 + return f"{total:.1f} GB" + + +def report_success(tool: str, artifact: Path, elapsed: float) -> None: + print(f"\n{'=' * 60}") + print(f"✅ {tool} 打包完成({elapsed:.0f}s)") + print(f" 产物:{artifact.relative_to(ROOT)}") + print(f" 体积:{artifact_size(artifact)}") + print(f" 自检:已通过(offscreen + 系统原生平台插件)") + print("\n分发提示:本工具是厂商侧工具,含签发能力,请只发给厂商内部的同事。") + + +# --------------------------------------------------------------------------- +# 构建命令执行 +# --------------------------------------------------------------------------- +def run_build(command: list[str], log_path: Path, env: dict[str, str]) -> tuple[int, str]: + """跑构建命令,日志落文件后回读。 + + 不回显到终端而是写文件:构建日志很长(Nuitka 动辄上万行), + 落盘才能事后排查;返回值同时给调用方做失败特征匹配。 + """ + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open("w", encoding="utf-8", errors="replace") as log: + proc = subprocess.run( + command, stdout=log, stderr=subprocess.STDOUT, env=env, cwd=str(ROOT) + ) + return proc.returncode, log_path.read_text(encoding="utf-8", errors="replace") + + +def log_tail(text: str, lines: int = 25) -> str: + return "\n".join(text.splitlines()[-lines:]) + + +# --------------------------------------------------------------------------- +# 构建环境:挡住"抢注同名 DLL"的目录 +# --------------------------------------------------------------------------- +# Windows 上的经典陷阱:Git for Windows / msys64 / 各种 SDK 都带 +# libssl-3-x64.dll、libcrypto-3-x64.dll 这类通用名 DLL,而它们**都在 PATH 上**。 +# PyInstaller 解析二进制依赖时会搜 PATH,一旦搜到就把 Python 自带的那份**替换掉** —— +# 产物里 _ssl 于是绑定了错误版本的 OpenSSL,运行时才炸: +# +# ImportError: DLL load failed while importing _ssl: 找不到指定的程序 +# +# 而且报错点离原因极远(表现为 psycopg 报 "no pq wrapper available",因为 +# psycopg 的二进制实现对 _ssl 有依赖)。本机实测:Git 自带的 +# C:\Program Files\Git\mingw64\bin\libssl-3-x64.dll(930460 字节)就是这样混进产物的, +# 而 Python 的是 DLLs\libssl-3-x64.dll(1579008 字节)。 +# +# 规则:构建时把"含有与 Python 自带 DLL 同名文件"的目录从 PATH 摘掉。 +_WINDOWS_DIR = os.environ.get("SystemRoot", r"C:\Windows") + + +def _python_dll_names() -> set[str]: + """Python 自带的 DLL 名字(用来判断某个目录是否会"抢注")。""" + names: set[str] = set() + for base in (Path(sys.base_prefix), Path(sys.base_prefix) / "DLLs", + Path(sys.base_prefix) / "Library" / "bin"): + try: + names |= {p.name.lower() for p in base.glob("*.dll")} + except OSError: + continue + return names + + +def build_env_overrides(base_env: dict[str, str] | None = None) -> dict[str, str]: + """构建子进程的环境覆盖:清理 PATH 里的"同名 DLL 抢注"目录。 + + 返回可直接 ``env.update(...)`` 的字典;同时打印被摘掉的目录,便于排查。 + """ + env = base_env or os.environ + original = env.get("PATH", "") + if not original: + return {} + + python_dlls = _python_dll_names() + kept: list[str] = [] + dropped: list[str] = [] + for entry in original.split(os.pathsep): + if not entry.strip(): + continue + path = Path(entry) + # 系统目录永远保留(去掉了会直接构建失败) + if path.name.lower() == "system32" or str(path).lower().startswith(_WINDOWS_DIR.lower()): + kept.append(entry) + continue + try: + shadows = any(f.name.lower() in python_dlls for f in path.glob("*.dll")) + except OSError: + shadows = False + (dropped if shadows else kept).append(entry) + + if not dropped: + return {} + print("构建环境:以下 PATH 目录里有与 Python 同名的 DLL(会在打包时被误收集),已临时摘除:") + for entry in dropped: + print(f" - {entry}") + return {"PATH": os.pathsep.join(kept)} + + +# MSVC 堆空间不足的特征串(C1002/LNK1257 在中文/英文环境下都是这两个码) +_OUT_OF_HEAP_MARKERS = ("C1002", "LNK1257", "out of heap space", "compiler heap") + + +def is_compiler_out_of_memory(log_text: str) -> bool: + """是不是 MSVC 编译生成的 C 文件时堆空间不足(Nuitka 大模块常见)。""" + return any(marker in log_text for marker in _OUT_OF_HEAP_MARKERS) diff --git a/packaging/build_nuitka.py b/packaging/build_nuitka.py new file mode 100644 index 0000000..c1bde90 --- /dev/null +++ b/packaging/build_nuitka.py @@ -0,0 +1,226 @@ +"""用 Nuitka 打包(真编译成 C 再编译成 exe)。 + +用法:: + + uv run python packaging/build_nuitka.py # 单文件(默认) + uv run python packaging/build_nuitka.py --standalone # 目录模式 + uv run python packaging/build_nuitka.py --console # 带控制台,排查启动问题 + uv run python packaging/build_nuitka.py --msvc # 指定 MSVC(默认自动挑) + uv run python packaging/build_nuitka.py --low-memory # 编译器内存不够时用 + uv run python packaging/build_nuitka.py --clean + uv run python packaging/build_nuitka.py --no-selftest + +产物:``dist/nuitka/`` + +与 PyInstaller 的区别见 ``packaging/README.md``:Nuitka 构建慢得多、需要一个 +C 编译器,换来的是**启动更快、体积更小、更难被反编译**。 + +构建前后同样有"私钥守卫 + 产物自检"两道关卡(与 PyInstaller 脚本一致)。 +""" +from __future__ import annotations + +import argparse +import os +import shutil +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _common as common # noqa: E402 + +# 明显用不到的重型模块,让 Nuitka 不要跟进去(Qt 的裁减由 pyside6 插件负责) +NOFOLLOW = [ + "tkinter", + "PySide6.QtWebEngineCore", + "PySide6.QtWebEngineWidgets", + "PySide6.QtWebEngineQuick", + "PySide6.Qt3DCore", + "PySide6.Qt3DRender", + "PySide6.Qt3DExtras", + "PySide6.QtCharts", + "PySide6.QtDataVisualization", + "PySide6.QtQuick", + "PySide6.QtQml", + "PySide6.QtMultimedia", + "PySide6.QtDesigner", + "PySide6.QtPdf", +] + +# PostgreSQL 驱动(可选功能)。在 Python 3.13+ 上 Nuitka 只能用 MSVC,而 MSVC +# 编译 psycopg.errors 生成的 2.8 万行 C 文件时会 C1002(编译器堆空间不足)—— +# 这是编译器上限,不是代码问题。因此提供"不带 PG"的构建:界面会如实提示 +# "未安装 PostgreSQL 驱动(可选功能)",其余功能完全不受影响。 +POSTGRES_MODULES = ["psycopg", "psycopg_binary", "psycopg_c", "psycopg_pool"] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="用 Nuitka 打包 chongming 授权工具") + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--onefile", action="store_true", default=True, + help="打成单个 exe(默认)") + mode.add_argument("--standalone", action="store_true", + help="打成目录(启动更快)") + compiler = parser.add_mutually_exclusive_group() + compiler.add_argument("--msvc", action="store_true", + help="用 MSVC 编译(默认自动挑选编译器)") + compiler.add_argument("--mingw64", action="store_true", + help="用 MinGW64 编译(默认自动挑选编译器)") + parser.add_argument("--jobs", type=int, default=0, + help="并行编译任务数(默认交给 Nuitka 决定)") + parser.add_argument("--low-memory", action="store_true", default=True, + help="降低编译器内存占用(默认开启)") + parser.add_argument("--no-low-memory", dest="low_memory", action="store_false", + help="关闭低内存模式。默认开启是有原因的:本项目的代码经 Nuitka " + "展开后,MSVC 编译最大的那个模块会因堆空间不足报 C1002/LNK1257") + parser.add_argument("--lto", choices=("yes", "no"), default=None, + help="链接期优化。默认交给 Nuitka(开着,链接很慢);" + "--lto=no 可显著缩短构建时间,产物略大") + parser.add_argument("--no-postgres", dest="postgres", action="store_false", + default=True, + help="构建不带 PostgreSQL 支持的版本。Python 3.13+ 上 " + "Nuitka 只能用 MSVC,而 MSVC 编译 psycopg.errors 会 C1002;" + "此时工具会提示「未安装驱动」,本地台账照常可用") + parser.add_argument("--console", action="store_true", + help="保留控制台窗口(默认无控制台)") + parser.add_argument("--clean", action="store_true", + help="构建前清理并删除中间产物") + parser.add_argument("--no-selftest", action="store_true", help="跳过构建后的产物自检") + parser.add_argument("--allow-sensitive-files", action="store_true", + help="源码树里有私钥/.lic/台账库时仍继续构建(默认中止)") + return parser.parse_args() + + +def build_command(args: argparse.Namespace, *, icon: Path | None, onefile: bool, + low_memory: bool, with_postgres: bool = True) -> list[str]: + dist_dir = common.DIST / "nuitka" + version = common.project_version() + command = [ + sys.executable, "-m", "nuitka", + f"--mode={'onefile' if onefile else 'standalone'}", + # PySide6 插件负责把 Qt 的 DLL 与插件(platforms/styles/imageformats/ + # iconengines)收集齐全 —— 少了 imageformats 里的 svg,图标与复选框 + # 指示器会变空白(构建后的自检会验证这一点) + "--enable-plugins=pyside6", + # 注意:Nuitka 的值型选项必须写成 --opt=value,空格分隔会报 + # "requires an argument with '--output-dir='" + f"--output-dir={dist_dir}", + f"--output-filename={common.APP_NAME}.exe", + f"--windows-console-mode={'force' if args.console else 'disable'}", + # 不下载 ccache / depends.exe 之类就别想跑通,脚本里没法应答交互提示 + "--assume-yes-for-downloads", + # exe 属性:公司/产品/版本(右键属性里看得到) + "--company-name=chongming", + f"--product-name={common.APP_NAME}", + f"--file-version={version}", + f"--product-version={version}", + "--file-description=chongming 机器码授权工具(厂商侧)", + ] + if icon: + command.append(f"--windows-icon-from-ico={icon}") + if args.msvc: + command.append("--msvc=latest") + elif args.mingw64: + command.append("--mingw64") + if args.jobs > 0: + command.append(f"--jobs={args.jobs}") + if args.lto: + command.append(f"--lto={args.lto}") + if low_memory: + # Nuitka 生成的 C 文件极大(单个模块上万行),MSVC 会因堆空间不足报 + # C1002/LNK1257。这个开关让 Nuitka 少塞内联代码、把模块拆得更细。 + command.append("--low-memory") + if args.clean: + command.append("--remove-output") + for module in NOFOLLOW: + command.append(f"--nofollow-import-to={module}") + if not with_postgres: + for module in POSTGRES_MODULES: + command.append(f"--nofollow-import-to={module}") + command.append(str(common.PACKAGING / "launcher.py")) + return command + + +def main() -> int: + args = parse_args() + common.require("nuitka", "nuitka") + + onefile = not args.standalone + dist_dir = common.DIST / "nuitka" + log_path = common.PACKAGING / "build" / "nuitka-build.log" + + print("=" * 60) + print(f"Nuitka 打包({'单文件' if onefile else '目录模式'})") + print("=" * 60) + + # 1) 安全闸门 + common.guard_no_secrets(allow=args.allow_sensitive_files) + + # 2) 图标 + icon = common.build_app_icon() + + if args.clean and dist_dir.exists(): + shutil.rmtree(dist_dir) + print(f"已清理 {dist_dir.relative_to(common.ROOT)}") + + # 3) 构建(首次很慢,日志落盘) + env = dict(os.environ) + # 让 Nuitka 能解析本包:直接给源码树,不依赖 editable 安装 + env["PYTHONPATH"] = str(common.ROOT / "src") + os.pathsep + env.get("PYTHONPATH", "") + # 与 PyInstaller 同理:PATH 里的同名 DLL 会被误收集(见 _common 的说明) + env.update(common.build_env_overrides(env)) + + low_memory = args.low_memory + with_postgres = args.postgres + command = build_command(args, icon=icon, onefile=onefile, low_memory=low_memory, + with_postgres=with_postgres) + print("\n$ " + " ".join(command)) + print("\n提示:Nuitka 是**真编译**,首次构建约 10 分钟起(后续有缓存会快很多)。") + print(f"构建日志:{log_path.relative_to(common.ROOT)}\n") + + started = time.monotonic() + code, log_text = common.run_build(command, log_path, env) + + # 兜底:万一还是撞上 MSVC 堆空间不足(例如用了 --no-low-memory),自动降内存重试 + if code != 0 and not low_memory and common.is_compiler_out_of_memory(log_text): + print("检测到编译器堆空间不足(C1002/LNK1257)—— 自动加 --low-memory 重试一次…\n") + low_memory = True + command = build_command(args, icon=icon, onefile=onefile, low_memory=True) + code, log_text = common.run_build(command, log_path, env) + + elapsed = time.monotonic() - started + if code != 0: + print(f"❌ Nuitka 构建失败(退出码 {code})\n") + print(common.log_tail(log_text, 30)) + print(f"\n完整日志:{log_path.relative_to(common.ROOT)}") + if common.is_compiler_out_of_memory(log_text): + print("失败原因是编译器内存不足。先用 --low-memory(默认已开);" + "仍失败就改用 PyInstaller(build_pyinstaller.py)," + "或用 --no-postgres 构建不带 PG 的版本。") + else: + print("常见原因:缺少 C 编译器。Windows 上装 Visual Studio 的「C++ 生成工具」,") + print("或用 --mingw64(需 Nuitka 支持的 mingw64 版本,msys2 自带的那个不行)。") + return code + + # 4) 找到产物并验证 + artifact = common.find_artifact(dist_dir) + if artifact is None: + print(f"\n❌ 构建结束但没在 {dist_dir} 里找到 exe") + return 1 + + stray = common.guard_artifact_no_secrets(artifact.parent) + if stray: + print(f"\n❌ 产物目录里出现敏感文件,请检查后再分发:{stray}") + return 1 + + if not args.no_selftest: + if not common.verify_artifact(artifact, "Nuitka 产物"): + print("\n❌ 产物自检未通过 —— 先别分发,见上方输出") + return 1 + + common.report_success("Nuitka", artifact, elapsed) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packaging/build_pyinstaller.py b/packaging/build_pyinstaller.py new file mode 100644 index 0000000..4f293d4 --- /dev/null +++ b/packaging/build_pyinstaller.py @@ -0,0 +1,126 @@ +"""用 PyInstaller 打包成单文件 exe。 + +用法:: + + uv run python packaging/build_pyinstaller.py # 单文件(默认) + uv run python packaging/build_pyinstaller.py --onedir # 目录模式,启动更快 + uv run python packaging/build_pyinstaller.py --console # 带控制台,排查启动问题 + uv run python packaging/build_pyinstaller.py --no-selftest # 跳过构建后自检 + +产物:``dist/pyinstaller/`` + +构建前会先做两件事(任一失败即中止): + +1. **私钥守卫**:源码树里若存在私钥 / ``*.lic`` / ``licenses.db`` 就停下 —— + 这些东西绝不能进分发包; +2. **生成 exe 图标**:由主题里的盾牌 SVG 渲染多尺寸 ``.ico``,无需美术素材。 + +构建后自动跑 `` --selftest`` 两轮(offscreen + 系统原生平台插件), +验证冻结产物里的 Qt 插件、图标渲染、Ed25519 签发与验签链路都还在。 +""" +from __future__ import annotations + +import argparse +import os +import shutil +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _common as common # noqa: E402 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="用 PyInstaller 打包 chongming 授权工具") + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--onefile", action="store_true", default=True, + help="打成单个 exe(默认;分发最省事)") + mode.add_argument("--onedir", action="store_true", + help="打成目录(启动更快,适合固定办公机)") + parser.add_argument("--console", action="store_true", + help="保留控制台窗口(默认无控制台;排查启动失败时有用)") + parser.add_argument("--clean", action="store_true", help="构建前清掉旧的 dist/build") + parser.add_argument("--no-selftest", action="store_true", help="跳过构建后的产物自检") + parser.add_argument("--allow-sensitive-files", action="store_true", + help="源码树里有私钥/.lic/台账库时仍继续构建(默认中止)") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + common.require("PyInstaller", "pyinstaller") + + onefile = not args.onedir + dist_dir = common.DIST / "pyinstaller" + build_dir = common.PACKAGING / "build" / "pyinstaller" + + print("=" * 60) + print(f"PyInstaller 打包({'单文件' if onefile else '目录模式'})") + print("=" * 60) + + # 1) 安全闸门:私钥/授权文件/台账库一个都不能出现在产物链路里 + common.guard_no_secrets(allow=args.allow_sensitive_files) + + # 2) 图标 + icon = common.build_app_icon() + + if args.clean: + for path in (dist_dir, build_dir): + if path.exists(): + shutil.rmtree(path) + print(f"已清理 {path.relative_to(common.ROOT)}") + + # 3) 调用 PyInstaller(选项通过环境变量传给 spec) + env = dict(os.environ) + env.update({ + "CLG_NAME": common.APP_NAME, + "CLG_ONEFILE": "1" if onefile else "0", + "CLG_CONSOLE": "1" if args.console else "0", + "CLG_ICON": str(icon) if icon else "", + }) + # PATH 里若有"与 Python 同名的 DLL",PyInstaller 会误收集它们(见 _common 的说明) + env.update(common.build_env_overrides(env)) + + command = [ + sys.executable, "-m", "PyInstaller", + "--noconfirm", + "--distpath", str(dist_dir), + "--workpath", str(build_dir), + str(common.PACKAGING / "pyinstaller.spec"), + ] + log_path = common.PACKAGING / "build" / "pyinstaller-build.log" + print("\n$ " + " ".join(command)) + print(f"构建日志:{log_path.relative_to(common.ROOT)}\n") + + started = time.monotonic() + code, log_text = common.run_build(command, log_path, env) + elapsed = time.monotonic() - started + if code != 0: + print(f"❌ PyInstaller 构建失败(退出码 {code})\n") + print(common.log_tail(log_text, 30)) + print(f"\n完整日志:{log_path.relative_to(common.ROOT)}") + return code + + # 4) 找到产物并验证 + artifact = common.find_artifact(dist_dir) + if artifact is None: + print(f"\n❌ 构建结束但没在 {dist_dir} 里找到 exe") + return 1 + + stray = common.guard_artifact_no_secrets(artifact.parent) + if stray: + print(f"\n❌ 产物目录里出现敏感文件,请检查后再分发:{stray}") + return 1 + + if not args.no_selftest: + if not common.verify_artifact(artifact, "PyInstaller 产物"): + print("\n❌ 产物自检未通过 —— 先别分发,见上方输出") + return 1 + + common.report_success("PyInstaller", artifact, elapsed) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packaging/launcher.py b/packaging/launcher.py new file mode 100644 index 0000000..bef44c3 --- /dev/null +++ b/packaging/launcher.py @@ -0,0 +1,10 @@ +"""冻结产物的入口脚本(PyInstaller 与 Nuitka 共用)。 + +不用 ``src/chongming_license_gui/__main__.py``:那个文件用的是包内相对导入 +(``from .app import main``),一旦被当作"普通脚本"编译(冻结工具正是这么做的), +相对导入会因为失去包上下文而失败。这里改成绝对导入,冻结与直接运行都成立。 +""" +from chongming_license_gui.app import main + +if __name__ == "__main__": + main() diff --git a/packaging/pyinstaller.spec b/packaging/pyinstaller.spec new file mode 100644 index 0000000..4dae3f6 --- /dev/null +++ b/packaging/pyinstaller.spec @@ -0,0 +1,130 @@ +# -*- mode: python ; coding: utf-8 -*- +"""PyInstaller 打包配置。 + +由 ``packaging/build_pyinstaller.py`` 调用 —— 选项通过**环境变量**传进来 +(spec 文件本身不接受命令行参数,这是 PyInstaller 的既定用法): + +=========================== ========================================== +环境变量 作用 +=========================== ========================================== +``CLG_NAME`` 产物名(默认 chongming-license-gui) +``CLG_ONEFILE`` ``1`` = 单文件(默认),``0`` = 目录模式 +``CLG_CONSOLE`` ``1`` = 带控制台窗口(排查启动问题用) +``CLG_ICON`` .ico 路径(由 ``_common.build_app_icon()`` 生成) +=========================== ========================================== +""" +import os +from pathlib import Path + +ROOT = Path(SPECPATH).resolve().parent # noqa: F821 - SPECPATH 由 PyInstaller 注入 + +NAME = os.environ.get("CLG_NAME", "chongming-license-gui") +ONEFILE = os.environ.get("CLG_ONEFILE", "1") == "1" +CONSOLE = os.environ.get("CLG_CONSOLE", "0") == "1" +ICON = os.environ.get("CLG_ICON") or None + +# PySide6 装了整套 Addons(Qt3D / WebEngine / Charts…),本工具一个都不用。 +# PyInstaller 的 PySide6 hook 只打包"被 import 到的" Qt 模块,但显式排除能兜住 +# 间接依赖,避免几百 MB 的模块被拖进来。 +# 注:排除不存在的模块是安全的(PyInstaller 只按名字匹配)。 +EXCLUDES = [ + # 3D / 图形 / 可视化 + "PySide6.Qt3DAnimation", "PySide6.Qt3DCore", "PySide6.Qt3DExtras", + "PySide6.Qt3DInput", "PySide6.Qt3DLogic", "PySide6.Qt3DRender", + "PySide6.QtCharts", "PySide6.QtDataVisualization", "PySide6.QtGraphs", + "PySide6.QtGraphsWidgets", "PySide6.QtCanvasPainter", + # Web / QML / Quick(体积最大的一批) + "PySide6.QtWebEngineCore", "PySide6.QtWebEngineWidgets", + "PySide6.QtWebEngineQuick", "PySide6.QtWebView", "PySide6.QtWebChannel", + "PySide6.QtWebSockets", "PySide6.QtHttpServer", + "PySide6.QtQml", "PySide6.QtQuick", "PySide6.QtQuick3D", + "PySide6.QtQuickControls2", "PySide6.QtQuickWidgets", "PySide6.QtQuickTest", + # 多媒体 / 设备 / 传感器 + "PySide6.QtMultimedia", "PySide6.QtMultimediaWidgets", "PySide6.QtSpatialAudio", + "PySide6.QtTextToSpeech", "PySide6.QtBluetooth", "PySide6.QtNfc", + "PySide6.QtPositioning", "PySide6.QtLocation", "PySide6.QtSensors", + "PySide6.QtSerialPort", "PySide6.QtSerialBus", "PySide6.QtRemoteObjects", + "PySide6.QtScxml", "PySide6.QtStateMachine", "PySide6.QtAxContainer", + # 文档 / 设计器 / 测试 / 数据库 / 容器 + "PySide6.QtPdf", "PySide6.QtPdfWidgets", "PySide6.QtDesigner", + "PySide6.QtUiTools", "PySide6.QtHelp", "PySide6.QtTest", + "PySide6.QtSql", "PySide6.QtDBus", "PySide6.QtConcurrent", + "PySide6.QtNetworkAuth", "PySide6.QtXml", "PySide6.QtSvgWidgets", + "PySide6.QtOpenGL", "PySide6.QtOpenGLWidgets", + # 与本工具无关的标准库大件 + "tkinter", "pydoc_data", +] + +a = Analysis( # noqa: F821 + [str(ROOT / "packaging" / "launcher.py")], + # 用源码树解析本包,而不是依赖 editable 安装(冻结工具对 .pth 式 + # editable 安装的解析并不可靠) + pathex=[str(ROOT / "src")], + binaries=[], + datas=[], # 无数据文件:图标与样式表都是运行时生成的 + # PySide6.QtSvg 是显式 import,正常能被扫到;这里再钉一遍 —— 少了它 + # 图标会全部变空白,而且不报错 + hiddenimports=["PySide6.QtSvg"], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=EXCLUDES, + noarchive=False, + optimize=0, +) + +pyz = PYZ(a.pure) # noqa: F821 + +if ONEFILE: + # 单文件:自解压到临时目录再运行(分发最省事,首次启动稍慢) + exe = EXE( # noqa: F821 + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name=NAME, + debug=False, + bootloader_ignore_signals=False, + strip=False, + # UPX 压缩容易触发杀软误报,也会损坏部分 Qt DLL,明确关掉 + upx=False, + upx_exclude=[], + runtime_tmpdir=None, + console=CONSOLE, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=ICON, + ) +else: + # 目录模式:启动更快,适合放在固定办公机上长期使用 + exe = EXE( # noqa: F821 + pyz, + a.scripts, + [], + exclude_binaries=True, + name=NAME, + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=CONSOLE, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=ICON, + ) + coll = COLLECT( # noqa: F821 + exe, + a.binaries, + a.datas, + strip=False, + upx=False, + upx_exclude=[], + name=NAME, + ) diff --git a/pyproject.toml b/pyproject.toml index 95aee84..c146231 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,10 +13,21 @@ dependencies = ["PySide6", "chongming-license"] [tool.hatch.build.targets.wheel] packages = ["src/chongming_license_gui"] +[[tool.uv.index]] +url = "https://pypi.org/simple" +default = true + [[tool.uv.index]] url = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/" [tool.uv.sources] + +[dependency-groups] +dev = [ + "nuitka>=4.2.1", + "psycopg[binary]>=3.2", + "pyinstaller>=6.22.3", +] # 本地开发用 path 引用同仓的 chongming-license,并设为 **editable**: # 非 editable 的 path 依赖会被构建成 wheel 装进本环境,改库代码后必须重新 sync # 才生效(否则会照旧导入旧版本,报"cannot import name ...",极易误判为代码有错)。 @@ -26,3 +37,8 @@ url = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/" [project.scripts] chongming-license-gui = "chongming_license_gui.app:main" + +[project.optional-dependencies] +postgres = [ + "psycopg[binary]>=3.2", +] diff --git a/smoke_test.py b/smoke_test.py index 981de9d..6f98702 100644 --- a/smoke_test.py +++ b/smoke_test.py @@ -15,6 +15,8 @@ from __future__ import annotations import json +import os +import re import sys import tempfile from pathlib import Path @@ -31,10 +33,101 @@ def check(name: str, cond: bool, detail: str = "") -> None: failures.append(name) +def _synthetic_sources() -> dict[str, str]: + """非 Linux 平台上的替代指纹(已按库的规则归一化:小写、仅 [0-9a-z])。 + + 含 1 个 weak 来源(machine_id),以便覆盖"weak 不参与判定"的标注逻辑。 + """ + return { + "board_serial": "cnx7b2a4419e5", + "product_uuid": "4c4c4544003734108033b7c04f4a4d32", + "mac": "001b44113ab7", + "machine_id": "d9b8f0e6a1c34b2f9e7d5a6c8b0f1e2d", + } + + +def _count_colors(widget, colors, step: int = 4) -> dict[str, int]: + """统计渲染结果里各颜色的采样点数(验证主题确实画上去了)。 + + 样式表写错时 Qt 往往**静默**忽略规则,界面只会退化回原生外观而不报错 —— + 所以这里直接对抓下来的图取色,比只看 QSS 字符串可靠。 + """ + image = widget.grab().toImage() + wanted = {c.lower(): 0 for c in colors} + for y in range(0, image.height(), step): + for x in range(0, image.width(), step): + name = image.pixelColor(x, y).name() + if name in wanted: + wanted[name] += 1 + return wanted + + +def _rejects(call) -> bool: + """调用是否按预期抛异常(用于校验"非法输入被拒绝")。""" + try: + call() + except Exception: # noqa: BLE001 + return True + return False + + +def _error_text(call) -> str | None: + """调用并返回异常文本;没抛异常则返回 None。""" + try: + call() + except Exception as exc: # noqa: BLE001 + return str(exc) + return None + + +class _FakePgConn: + """假连接:记录执行过的 SQL,用来在**没有服务器**的环境下验证 PG 适配层。 + + 只实现被 PostgresRegistry 用到的几个方法,不解释 SQL。 + """ + + def __init__(self, rows: list | None = None) -> None: + self.calls: list[tuple[str, tuple]] = [] + self.rows = rows or [] + + def execute(self, sql, params=None): # noqa: ANN001, ANN201 + self.calls.append((sql, tuple(params or ()))) + return self + + def fetchall(self): + return self.rows + + def fetchone(self): + return self.rows[0] if self.rows else None + + def close(self) -> None: + pass + + +def _pg_registry(fake, old=None): + """绕过 __init__(不连数据库)构造一个可用的 PostgresRegistry。""" + import threading + + from chongming_license_gui.pg_store import PgConfig, PostgresRegistry + + reg = object.__new__(PostgresRegistry) + reg.config = PgConfig(host="h", port=5432, dbname="d", user="u") + reg.password = "" + reg.path = reg.config.display() + reg._lock = threading.RLock() + reg._schema = '"public"' + reg._table = '"public"."issued_licenses"' + reg._meta = '"public"."chongming_schema"' + reg._conn = fake + if old is not None: + # 模拟"台账里已经有这条":save() 的预读由它返回 + reg.get = lambda lic_id, _old=old: _old + return reg + + def main() -> int: - # QApplication 实例必须保活,否则控件无法创建;引用由 Qt 内部持有,故不进变量 - QApplication.instance() or QApplication([]) - QSettings("chongming", "license-gui").clear() # 初始状态确定,不受上次运行影响 + # QApplication 实例必须保活,否则控件无法创建;引用由 Qt 内部持有,这里也留一份 + app = QApplication.instance() or QApplication([]) from chongming_license import ( build_license, @@ -54,6 +147,13 @@ def main() -> int: ) from chongming_license_gui.app import MainWindow + # 配置必须隔离到临时目录:**不能**指望 QSettings.setDefaultFormat 就够了 —— + # 实测两参数构造仍然写注册表 HKCU\Software\chongming\license-gui, + # 于是"清空初始状态"会清掉用户真实设置,"设个路径"会写到用户配置里。 + settings = common.isolated_settings(tempfile.mkdtemp(prefix="clg-smoke-")) + common.assert_isolated_settings(settings, Path(settings.fileName()).parent) + settings.clear() # 现在清的是临时配置,初始状态确定 + # 把模态弹窗换成记录器(否则会阻塞等待点击) dialogs: list[tuple[str, str]] = [] @@ -72,7 +172,13 @@ def main() -> int: mod.ask_overwrite = _mk("ask") common.ask_overwrite = _mk("ask") - w = MainWindow() + w = MainWindow(settings) + check("配置已隔离到临时目录(不碰真实配置)", + Path(w.settings.fileName()).is_relative_to(Path(settings.fileName()).parent), + w.settings.fileName()) + # 显式给一个够大的窗口再取色:默认尺寸是"打开即最小"(940x640), + # 那样主按钮可能落在滚动区外,像素断言会跟着"应用默认窗口多大"漂移 + w.resize(1240, 940) w.show() check("四个页面就绪", @@ -80,6 +186,74 @@ def main() -> int: == ["签发", "台账", "密钥", "查看 / 校验"], str([w.tabs.tabText(i) for i in range(w.tabs.count())])) + # ---- 0) 界面主题:确认美化真的画到了界面上(而不是 QSS 里写了却没生效)---- + from PySide6.QtWidgets import QPushButton + + from chongming_license_gui import icons, theme + + app.processEvents() # 让布局落位后再抓图取色 + + check("主题样式表已应用", bool(app.styleSheet()) and theme.ACCENT in app.styleSheet()) + check("图标已渲染(非空 QIcon)", + not icons.icon("issue").isNull() and icons.svg_available(), + f"svg_available={icons.svg_available()}") + + def _button_texts(parent) -> set[str]: + return {b.text() for b in parent.findChildren(QPushButton)} + + def _fill_at(widget, x_ratio: float) -> str: + """取控件内某个水平位置的颜色(避开边框与文字)。""" + image = widget.grab().toImage() + return image.pixelColor(int(image.width() * x_ratio), image.height() // 2).name() + + def _has_color(widget, color: str, step: int = 2) -> bool: + image = widget.grab().toImage() + for y in range(0, image.height(), step): + for x in range(0, image.width(), step): + if image.pixelColor(x, y).name() == color.lower(): + return True + return False + + check("主操作按钮用主题主色填充", + _fill_at(w.issue_tab.issue_btn, 0.06) == theme.ACCENT.lower(), + _fill_at(w.issue_tab.issue_btn, 0.06)) + secondary = next( + b for b in w.issue_tab.findChildren(QPushButton) if b.text() == "刷新预览" + ) + check("次按钮保持白底(未误用主色)", + _fill_at(secondary, 0.06) == theme.SURFACE.lower(), + f"次按钮填充={_fill_at(secondary, 0.06)} 主色={theme.ACCENT}") + danger_button = next( + b for b in w.registry_tab.findChildren(QPushButton) if b.text() == "删除记录" + ) + check("「删除记录」用危险色文字", + _has_color(danger_button, theme.DANGER), + f"含危险色={_has_color(danger_button, theme.DANGER)} 填充={_fill_at(danger_button, 0.06)}") + + check("台账页按钮齐全", + {"续期选中…", "改插件…", "编辑备注", "查看详情", "删除记录"} <= _button_texts(w.registry_tab), + str(sorted(_button_texts(w.registry_tab)))) + + verdict_image = w.inspect_tab.verdict.grab().toImage() + check("结论横幅已渲染(非空)", verdict_image.width() > 40 and verdict_image.height() > 10, + f"{verdict_image.width()}x{verdict_image.height()}") + + # 指示器是"出错时最安静"的地方:QSS 的 image 加载失败时 Qt 不报错, + # 勾选框会直接变成空白 —— 曾经因为占位符拼接写错而真的踩过一次。 + check("样式表无未替换的占位符", + re.search(r"@[A-Z][A-Z0-9_]*@?", app.styleSheet()) is None, + str(re.search(r"@[A-Z][A-Z0-9_]*@?", app.styleSheet()))) + check("勾选态指示器按主题渲染(指示灯不会变空白)", + _has_color(w.issue_tab.record_check, theme.ACCENT) + and _has_color(w.issue_tab.rb_days, theme.ACCENT), + "复选框/单选框均命中主色") + + covered = _count_colors(w, [theme.ACCENT, theme.SURFACE, theme.BG, theme.WARN]) + check("主色/卡片底/页面底/警示色都已渲染", + covered[theme.ACCENT] > 10 and covered[theme.SURFACE] > 200 + and covered[theme.BG] > 50 and covered[theme.WARN] > 5, + str(covered)) + # ---- 1) 生成密钥对 ---- tmp = Path(tempfile.mkdtemp()) priv = tmp / "priv.pem" @@ -87,8 +261,10 @@ def main() -> int: w.keys_tab._generate() check("私钥已生成并加载", w.signing_key.loaded and priv.is_file()) - check("私钥权限为 0600", (priv.stat().st_mode & 0o777) == 0o600, - oct(priv.stat().st_mode & 0o777)) + # Windows 无 POSIX 权限位(chmod 退化为 0o666),只在 POSIX 平台断言 + mode = oct(priv.stat().st_mode & 0o777) + check("私钥权限为 0600", (priv.stat().st_mode & 0o777) == 0o600 or os.name == "nt", + mode + ("(Windows 无 POSIX 权限位,跳过)" if os.name == "nt" else "")) check("公钥指纹已算出", len(w.signing_key.fingerprint) == 16, w.signing_key.fingerprint) check("标题显示公钥指纹", w.signing_key.fingerprint in w.windowTitle(), @@ -100,6 +276,12 @@ def main() -> int: # ---- 3) 解析申请码 ---- src = collect_fingerprints() + if not src: + # 硬件指纹来源全是 Linux 侧的(DMI / /proc/cpuinfo / eMMC CID / systemd + # machine-id),Windows/macOS 上必然采集为空。这里用合成指纹继续跑: + # 被测的是界面流程(解析→勾选→预览→签发→校验→台账),指纹真假不影响覆盖度。 + src = _synthetic_sources() + print("[NOTE] 本平台采集不到硬件指纹,改用合成指纹继续(界面流程覆盖不受影响)") rc = encode_request_code(src) it = w.issue_tab it.rc_edit.setPlainText(rc) @@ -207,6 +389,13 @@ def main() -> int: check("台账显示机器码", row0[2] == it._machine_code, row0[2]) check("台账显示状态与剩余天数", "即将到期" in row0[4] and "d)" in row0[4], row0[4]) check("台账显示备注", row0[7] == "合同备注", row0[7]) + check("状态列用主题状态色", + rt.table.item(0, 4).foreground().color().name() + == theme.status_color(rt.records[0].status()).lower(), + f"{rt.table.item(0, 4).foreground().color().name()} / {rt.records[0].status()}") + check("状态列加粗(急缓一眼可辨)", rt.table.item(0, 4).font().bold()) + check("机器码列用等宽字体(便于逐字符核对)", + rt.table.item(0, 2).font().fixedPitch()) # 筛选 rt.machine_edit.setText(it._machine_code[:12]) @@ -272,6 +461,233 @@ def main() -> int: rt.refresh() check("删除后台账少一条", rt.table.rowCount() == 1, str(rt.table.rowCount())) + # ---- 9b) 改授权插件:只动白名单,有效期与生效日逐字写回 ---- + # 授权文件是签名过的,改插件只能重签一份新的;这组断言是那个契约的护栏。 + from PySide6.QtWidgets import QDialog + + from chongming_license_gui.registry_tab import ChangePluginsDialog + + base = rt.records[0] + cdlg = ChangePluginsDialog(base, rt.settings) + check("改插件默认预勾选原白名单", + cdlg.values()["plugins"] == list(base.plugins), str(cdlg.values()["plugins"])) + check("改插件默认生成新编号(新旧都留台账)", cdlg.values()["keep_lic_id"] is False) + check("改插件没有有效期选项(改插件不该顺手改到期日)", + not hasattr(cdlg, "days_spin") and not hasattr(cdlg, "date_edit")) + check("改插件未动时差异显示「与原来一致」", + cdlg.diff_label.text() == "与原来一致", cdlg.diff_label.text()) + check("改插件未动时不亮拒载告警", not cdlg._plugin_form.isRowVisible(cdlg.remove_hint)) + + # 移掉一个 → 差异行与被移除插件的拒载告警都要出来 + cdlg.plugin_picker.set_checked(base.plugins[:1]) + check("改插件差异行列出移除项", "移除" in cdlg.diff_label.text(), cdlg.diff_label.text()) + check("改插件移除插件时亮出拒载告警", cdlg._plugin_form.isRowVisible(cdlg.remove_hint)) + + # 一个都不勾 → 拒绝提交(与签发页同一条规则) + cdlg.plugin_picker.set_all(False) + cdlg.accept() + check("改插件未勾选任何插件时拒绝提交", + cdlg.result() != QDialog.DialogCode.Accepted) + + # 项目里已经没有的 type 也要能补回列表(老白名单必须始终看得见、勾得回来) + cdlg.plugin_picker.ensure_types(["legacy_plugin"]) + cdlg.plugin_picker.set_checked([base.plugins[0], "legacy_plugin"]) + cvals = cdlg.values() + check("改插件按列表顺序返回新白名单", + cvals["plugins"] == [base.plugins[0], "legacy_plugin"], str(cvals["plugins"])) + + # 走与界面相同的构造路径重签(界面上只多一步"另存为"对话框) + np = _bp(machine_code=base.machine_code, plugins=cvals["plugins"], sources=base.sources, + customer=cvals["customer"], note=cvals["note"], + not_before=base.not_before, expires_at=base.expires_at) + ndoc = _bl(np, w.signing_key.key, kid=base.kid) + _wl(ndoc, tmp / "plugins.lic") + changed_rec = w.registry.save(ndoc, public_key_fp=w.signing_key.fingerprint, source="plugins") + reg.update_note(base.lic_id, f"{base.note};已变更插件 → {changed_rec.lic_id}") + rt.refresh() + + check("改插件后新旧记录都在台账", rt.table.rowCount() == 2, str(rt.table.rowCount())) + check("改插件不改到期日", changed_rec.expires_at == base.expires_at, + f"{changed_rec.expires_at} / {base.expires_at}") + check("改插件复用台账指纹(无需再索取申请码)", changed_rec.sources == base.sources) + check("改插件后台账里的白名单已更新", changed_rec.plugins == cvals["plugins"], + str(changed_rec.plugins)) + check("旧记录留下变更批注", + f"已变更插件 → {changed_rec.lic_id}" in reg.get(base.lic_id).note, + reg.get(base.lic_id).note) + + # 载荷级别的证据:只有白名单该变,其余字段逐字未变 + # (编号/签发时间天然会变;备注是"仅展示"字段,沿用台账批注 —— 与续期一致) + old_payload = dict(base.license_doc()["payload"]) + new_payload = dict(ndoc["payload"]) + check("改插件后载荷里的白名单就是新勾选", + new_payload.get("plugins") == cvals["plugins"], str(new_payload.get("plugins"))) + check("改插件沿用台账备注(人工批注不随重签丢失)", + new_payload.get("note") == base.note, f"{new_payload.get('note')!r} / {base.note!r}") + for key in ("lic_id", "issued_at", "note", "plugins"): + old_payload.pop(key, None) + new_payload.pop(key, None) + check("改插件只改白名单:载荷其余字段逐字不变(含 not_before/expires_at/sources)", + old_payload == new_payload, + str([k for k in old_payload if old_payload[k] != new_payload.get(k)])) + + plugins_col = { + rt.table.item(r, 0).text(): rt.table.item(r, 3).text() + for r in range(rt.table.rowCount()) + } + check("改插件后台账「授权插件」列已更新", + plugins_col.get(changed_rec.lic_id) == ", ".join(cvals["plugins"]), + str(plugins_col)) + vstatus = read_license(tmp / "plugins.lic", w.signing_key.public_b64, sources=it._sources) + check("改插件后的文件可独立验签且白名单已换", + vstatus.ok and vstatus.licensed_plugins == set(cvals["plugins"]), + f"ok={vstatus.ok} plugins={sorted(vstatus.licensed_plugins)}") + + # 查看详情:构造时曾漏导入 QHBoxLayout,点了必崩(NameError)—— 这里守住回归 + detail = rt._build_detail_dialog(base) + check("查看详情可正常构造", detail.windowTitle() == f"授权详情 {base.lic_id}", + detail.windowTitle()) + detail.deleteLater() + + # 收尾:删掉本节新增的那条,让本节结束时的台账状态与插入之前一致(第 10 节按 1 条断言) + reg.delete(changed_rec.lic_id) + rt.refresh() + + # ---- 10) PostgreSQL 后端(可选功能;本机无服务器,故只验证可验证的部分)---- + from chongming_license_gui import pg_store + from chongming_license_gui.pg_store import PgConfig, PostgresRegistry, driver_available + + check("PG 默认端口是 5432", pg_store.DEFAULT_PORT == 5432, str(pg_store.DEFAULT_PORT)) + check("PG 连接展示串不含密码", + "S3cr3t-PW" not in PgConfig(host="h", user="u", password="S3cr3t-PW").display()) + check("PG 拒绝非法 schema(防标识符注入)", _rejects(lambda: pg_store._quote_identifier("a;DROP"))) + + # SQL 形状:必须与库的 SQLite 版逐列一致,否则两个后端会漂移 + check("PG 的 INSERT 是 15 列且占位符数量匹配", + pg_store.INSERT_SQL.count("%s") == len(pg_store.COLUMNS) == 15, + f"{pg_store.INSERT_SQL.count('%s')} 个占位符") + check("PG 列序与库一致", + list(pg_store.COLUMNS) == [ + "lic_id", "machine_code", "customer", "note", "plugins_json", + "sources_json", "not_before", "expires_at", "issued_at", "kid", + "public_key_fp", "license_json", "source", "created_at", "updated_at", + ]) + check("PG 冲突时更新 13 列且**不含** created_at(保留首次入库时间)", + len(pg_store.UPDATE_COLUMNS) == 13 + and "created_at" not in pg_store.UPDATE_COLUMNS + and "lic_id" not in pg_store.UPDATE_COLUMNS) + + # 入库:走幂等 upsert,且参数与库的派生规则一致 + fake = _FakePgConn() + pg_reg = _pg_registry(fake) + pg_reg.save(doc, public_key_fp="PG-FP", source="pg-smoke") + insert_sql, params = fake.calls[-1] + check("PG 入库用 ON CONFLICT(lic_id) 幂等写入", + "INSERT INTO" in insert_sql and "ON CONFLICT (lic_id) DO UPDATE" in insert_sql) + check("PG 入库传满 15 个参数", len(params) == 15, str(len(params))) + check("PG 机器码大写、公钥指纹与来源已写入", + params[1] == doc["payload"]["machine_code"].upper() + and params[10] == "PG-FP" and params[12] == "pg-smoke") + check("PG 的 JSON 文本中文不转义(与库一致)", + "\\u" not in params[4] and params[4].startswith("[")) + + # 覆盖已有记录:保留人工批注、保留 created_at、只刷新 updated_at(库的硬语义) + from chongming_license import LicenseRecord + + old = LicenseRecord( + lic_id=doc["payload"]["lic_id"], + machine_code=doc["payload"]["machine_code"], + note="人工批注(不该被覆盖)", + created_at="2026-01-01T00:00:00Z", + ) + fake2 = _FakePgConn() + pg_reg2 = _pg_registry(fake2, old=old) + pg_reg2.save(doc, public_key_fp="PG-FP", source="renew") + _, params2 = fake2.calls[-1] + check("PG 覆盖记录时保留人工批注", params2[3] == "人工批注(不该被覆盖)", params2[3]) + check("PG 覆盖记录时保留首次入库时间", params2[13] == "2026-01-01T00:00:00Z", params2[13]) + check("PG 覆盖记录时刷新 updated_at", params2[14] != "2026-01-01T00:00:00Z", params2[14]) + + pg_reg2.save(doc, public_key_fp="PG-FP", source="renew", overwrite_note=True) + _, params3 = fake2.calls[-1] + check("PG 显式 overwrite_note=True 时才用载荷备注", + params3[3] == (doc["payload"].get("note") or ""), params3[3]) + + # 查询:方言差异必须被抹平(SQLite 的 LIKE 大小写不敏感 → PG 用 ILIKE) + fake3 = _FakePgConn() + pg_reg3 = _pg_registry(fake3) + pg_reg3.query(customer="Acme", machine_code="bc60", plugin="aibox", keyword="kw") + query_sql, query_args = fake3.calls[-1] + check("PG 四个过滤条件都用 ILIKE(对齐 SQLite 的大小写不敏感)", + query_sql.count("ILIKE") == 7, f"{query_sql.count('ILIKE')} 处") + check("PG 机器码条件参数已大写", query_args[1].strip("%") == "BC60", query_args[1]) + check("PG 插件条件带引号(避免前缀误命中)", query_args[2] == '%"aibox"%', query_args[2]) + check("PG 排序与库一致(永久排最后)", + "COALESCE(expires_at, '9999-12-31')" in query_sql) + check("PG 状态筛选不写进 SQL(留在 Python 侧,与库同一套优先级)", + "status" not in query_sql.lower()) + + # 界面:后端下拉存在、默认仍是本地 SQLite(本地路径的行为不能变) + check("台账页有「存储」后端下拉", hasattr(rt, "backend_combo")) + check("默认后端仍是本地 SQLite", + rt.backend_combo.currentData() == "sqlite", str(rt.backend_combo.currentData())) + check("下拉里有 PostgreSQL 选项", + any(rt.backend_combo.itemData(i) == "postgres" + for i in range(rt.backend_combo.count()))) + + # 切换后端:行要跟着切,holder 也必须知道目标后端 + # (不知道的话"确保可用"会按旧后端开库 —— 用户切回本地库、签发却写进了 PG) + rt.backend_combo.setCurrentIndex(rt.backend_combo.findData("postgres")) + app.processEvents() + check("切到 PG 后只显示「连接」行", + rt._db_form.isRowVisible(rt.conn_row) and not rt._db_form.isRowVisible(rt.path_row)) + check("切到 PG 后 holder 目标后端为 postgres", w.registry.backend == "postgres") + check("切到 PG 后原连接已断开(不留「界面显示 A、实际写 B」)", + not w.registry.is_open) + check("未连接时 ensure_open 给出可读提示,而不是静默连错库", + "未连接" in (_error_text(lambda: w.registry.ensure_open()) or ""), + (_error_text(lambda: w.registry.ensure_open()) or "").splitlines()[0]) + + rt.backend_combo.setCurrentIndex(rt.backend_combo.findData("sqlite")) + app.processEvents() + check("切回本地后只显示「路径」行", + rt._db_form.isRowVisible(rt.path_row) and not rt._db_form.isRowVisible(rt.conn_row)) + check("切回本地后 holder 目标后端为 sqlite", w.registry.backend == "sqlite") + rt.db_edit.setText(str(db)) + rt._open_db(silent=True) + check("切回本地后能重新打开本地库(原记录还在)", + w.registry.is_open and len(rt.records) == 1, str(len(rt.records))) + + # 未安装驱动时要给出可照抄的安装命令(把 psycopg 置为 None 即为"不可导入") + import sys as _sys + + saved_module = _sys.modules.get("psycopg") + _sys.modules["psycopg"] = None + try: + check("未装驱动时 driver_available() 返回 False", not driver_available()) + missing = _error_text(lambda: PostgresRegistry(PgConfig(host="h", dbname="d"))) + check("未装驱动时给出可照抄的安装提示", + "uv sync --extra postgres" in missing, missing.splitlines()[0]) + finally: + if saved_module is None: + _sys.modules.pop("psycopg", None) + else: + _sys.modules["psycopg"] = saved_module + + # 连不上时给的是人话而不是原始堆栈。 + # 用绝不可能存在的库名,确保任何情况下都不会在真实服务器上建表。 + dead = PgConfig(host="127.0.0.1", port=5432, + dbname="chongming_selftest_no_such_db_9f3a", user="nobody", + password="S3cr3t-NEVER-PRINT") + failure = _error_text(lambda: PostgresRegistry(dead)) + if failure is None: + print("[NOTE] 本机 5432 上有 PostgreSQL 在跑,跳过「连不上时的提示」检查") + else: + check("连不上时给出可读提示(含地址与排查方向)", + "无法连接 PostgreSQL" in failure and "5432" in failure, + failure.splitlines()[0]) + check("连接失败的提示与异常里都不含密码", "S3cr3t-NEVER-PRINT" not in failure) + print("\n" + "=" * 56) if failures: print(f"❌ {len(failures)} 项未通过: {failures}") diff --git a/src/chongming_license_gui/__init__.py b/src/chongming_license_gui/__init__.py index 08d4abf..cf81d51 100644 --- a/src/chongming_license_gui/__init__.py +++ b/src/chongming_license_gui/__init__.py @@ -1,14 +1,19 @@ """chongming 机器码授权可视化工具(厂商侧)。 -三个页面:密钥管理 / 签发 / 查看校验。所有授权逻辑(指纹、载荷格式、规范化、 +四个页面:签发 / 台账 / 密钥 / 查看·校验。所有授权逻辑(指纹、载荷格式、规范化、 验签、容错判定)都调用 ``chongming_license``,本包只做界面 —— 不重复实现密码学。 +台账默认存本地 SQLite,也可选存共享 PostgreSQL(可选功能,见 :mod:`pg_store`)。 + 运行:: - cd utils/license-gui uv run chongming-license-gui -无图形环境(SSH / CI)下可用 ``QT_QPA_PLATFORM=offscreen`` 做冒烟检查。 +无图形环境(SSH / CI)下可做冒烟检查:: + + QT_QPA_PLATFORM=offscreen uv run python smoke_test.py + +使用手册见 docs/使用手册.md。 """ from .app import main diff --git a/src/chongming_license_gui/app.py b/src/chongming_license_gui/app.py index 4ce82b2..cf9edd8 100644 --- a/src/chongming_license_gui/app.py +++ b/src/chongming_license_gui/app.py @@ -1,14 +1,27 @@ """主窗口与入口。 窗口标题里带公钥指纹:同时开多个实例或截图沟通时,能一眼确认"当前用的是哪把密钥"。 +页头右侧再放一个密钥状态胶囊,把同一件事变成"随时可见"而不必看标题栏。 """ from __future__ import annotations +import os import sys -from PySide6.QtCore import QSettings -from PySide6.QtWidgets import QApplication, QLabel, QMainWindow, QTabWidget, QVBoxLayout, QWidget +from PySide6.QtCore import QSettings, Qt, Signal +from PySide6.QtWidgets import ( + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QMainWindow, + QScrollArea, + QTabWidget, + QVBoxLayout, + QWidget, +) +from . import common, icons, theme from .inspect_tab import InspectTab from .issue_tab import IssueTab from .keystore import SigningKey @@ -16,71 +29,205 @@ from .keys_tab import KeysTab from .registry_store import RegistryHolder from .registry_tab import RegistryTab -ORG = "chongming" -APP = "license-gui" +ORG = common.ORG +APP = common.APP + +BASE_TITLE = "chongming 机器码授权工具" + + +class _KeyChip(QLabel): + """页头的密钥状态胶囊(点击跳到「密钥」页)。""" + + clicked = Signal() + + def mouseReleaseEvent(self, event) -> None: # noqa: N802 - Qt 命名 + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit() + super().mouseReleaseEvent(event) class MainWindow(QMainWindow): """授权工具主窗口。""" - def __init__(self) -> None: + def __init__(self, settings: QSettings | None = None) -> None: super().__init__() - self.setWindowTitle("chongming 机器码授权工具") - self.resize(1180, 940) + # 直接构造 MainWindow 的场景(测试/截图脚本)也要有主题,不能只依赖 main() + app = QApplication.instance() + if isinstance(app, QApplication): + theme.apply_theme(app) - self.settings = QSettings(ORG, APP) + self.setWindowTitle(BASE_TITLE) + self.setWindowIcon(icons.app_icon()) + # 打开即最小尺寸:小屏笔记本(1366x768)上不会顶出屏幕,需要更大自己拉。 + # 每页都装在 QScrollArea 里,窗口小只是多滚动,不会把按钮挤没。 + self.setMinimumSize(940, 640) + self.resize(self.minimumSize()) + + # 允许注入配置对象:测试/截图脚本必须能把它指向临时文件, + # 否则会把测试值写进用户真实配置(详见 common.isolated_settings) + self.settings = settings if settings is not None else QSettings(ORG, APP) self.signing_key = SigningKey() self.registry = RegistryHolder() self.tabs = QTabWidget() + self.tabs.setDocumentMode(False) self.keys_tab = KeysTab(self.signing_key, self.settings) self.issue_tab = IssueTab(self.signing_key, self.registry, self.settings) self.inspect_tab = InspectTab(self.settings) self.registry_tab = RegistryTab(self.signing_key, self.registry, self.settings) - # 签发放第一个:这是日常最高频的操作 - self.tabs.addTab(self.issue_tab, "签发") - self.tabs.addTab(self.registry_tab, "台账") - self.tabs.addTab(self.keys_tab, "密钥") - self.tabs.addTab(self.inspect_tab, "查看 / 校验") + # 签发放第一个:这是日常最高频的操作。页签外面套滚动容器, + # 小屏(1366x768)上「签发并保存」不会再被窗口底部截掉。 + self._pages = [self.issue_tab, self.registry_tab, self.keys_tab, self.inspect_tab] + self._tab_icons = ("issue", "registry", "key", "verify") + self._tab_labels = ("签发", "台账", "密钥", "查看 / 校验") + self._area_by_page: dict[QWidget, QScrollArea] = {} + for page, icon_name, label in zip(self._pages, self._tab_icons, self._tab_labels): + area = _scrollable(page) + self._area_by_page[page] = area + self.tabs.addTab(area, icons.icon(icon_name, theme.TEXT_MUTED, 16), label) # 切到台账页时自动刷新:签发后切过去应立即看到刚入库的那条 self.tabs.currentChanged.connect(self._on_tab_changed) central = QWidget() layout = QVBoxLayout(central) - layout.setContentsMargins(10, 10, 10, 10) - layout.addWidget(self.tabs) - - hint = QLabel( - "厂商侧工具:签发私钥只应存在于本机,切勿随部署产物分发。" - "设备端展示机器码/申请码请用 http://<设备IP>:8000/static/license" - ) - hint.setWordWrap(True) - hint.setStyleSheet("color: #666;") - layout.addWidget(hint) - + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(10) + layout.addWidget(self._build_header()) + layout.addWidget(self.tabs, 1) + layout.addWidget(self._build_footer()) self.setCentralWidget(central) self.signing_key.changed.connect(self._refresh_title) + self.signing_key.changed.connect(self._refresh_chip) self._refresh_title() + self._refresh_chip() + # ------------------------------------------------------------------ + # 页头 / 页脚 + # ------------------------------------------------------------------ + def _build_header(self) -> QWidget: + header = QFrame() + header.setProperty("role", "header") + row = QHBoxLayout(header) + row.setContentsMargins(14, 11, 14, 11) + row.setSpacing(12) + + logo = QLabel() + logo.setPixmap(icons.pixmap("shield", theme.ACCENT, 30)) + + title_box = QVBoxLayout() + title_box.setContentsMargins(0, 0, 0, 0) + title_box.setSpacing(1) + title = QLabel(BASE_TITLE) + font = title.font() + font.setPointSizeF(font.pointSizeF() + 2.5) + font.setBold(True) + title.setFont(font) + title_box.addWidget(title) + title_box.addWidget(common.hint("厂商侧签发工具 · 私钥只应存在于本机")) + + row.addWidget(logo) + row.addLayout(title_box) + row.addStretch(1) + + self.key_chip = _KeyChip() + self.key_chip.setTextFormat(Qt.TextFormat.RichText) + self.key_chip.setCursor(Qt.CursorShape.PointingHandCursor) + self.key_chip.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) + self.key_chip.clicked.connect(self._focus_keys_tab) + row.addWidget(self.key_chip, 0, Qt.AlignmentFlag.AlignVCenter) + return header + + def _build_footer(self) -> QWidget: + footer = QFrame() + footer.setProperty("role", "footer") + row = QHBoxLayout(footer) + row.setContentsMargins(12, 8, 12, 8) + row.setSpacing(9) + + mark = QLabel() + mark.setPixmap(icons.pixmap("lock", theme.WARN, 16)) + text = common.hint( + "厂商侧工具:签发私钥只应存在于本机,切勿随部署产物分发。" + "设备端展示机器码/申请码请用 http://<设备IP>:8000/static/license", + role="warn", + ) + row.addWidget(mark, 0, Qt.AlignmentFlag.AlignTop) + row.addWidget(text, 1) + return footer + + def _focus_keys_tab(self) -> None: + self.tabs.setCurrentWidget(self._area_by_page[self.keys_tab]) + + # ------------------------------------------------------------------ + # 状态同步 + # ------------------------------------------------------------------ def _on_tab_changed(self, index: int) -> None: - if self.tabs.widget(index) is self.registry_tab: + # 页签里的控件是滚动容器,不能再用 tabs.widget(index) 直接比对页对象 + if 0 <= index < len(self._pages) and self._pages[index] is self.registry_tab: self.registry_tab.refresh() def _refresh_title(self) -> None: - base = "chongming 机器码授权工具" if self.signing_key.loaded: - self.setWindowTitle(f"{base} —— 公钥指纹 {self.signing_key.fingerprint}") + self.setWindowTitle(f"{BASE_TITLE} —— 公钥指纹 {self.signing_key.fingerprint}") else: - self.setWindowTitle(f"{base} —— 未加载私钥") + self.setWindowTitle(f"{BASE_TITLE} —— 未加载私钥") + + def _refresh_chip(self) -> None: + mono = common.mono_font().family() + if self.signing_key.loaded: + fp = ( + f'' + f"{self.signing_key.fingerprint}" + ) + self.key_chip.setText(f"● 私钥已加载  指纹 {fp} ") + self.key_chip.setToolTip("点击到「密钥」页管理密钥;签发前请核对指纹与设备端一致") + self.key_chip.setProperty("role", "chip-ok") + else: + self.key_chip.setText("● 未加载私钥  点击到「密钥」页加载 ") + self.key_chip.setToolTip("签发与续期都需要签名私钥") + self.key_chip.setProperty("role", "chip-warn") + theme.repolish(self.key_chip) + + +def _scrollable(page: QWidget) -> QScrollArea: + """把页签内容装进滚动容器:小屏上不再把底部按钮截掉。""" + area = QScrollArea() + area.setObjectName("pageScroll") + area.setWidgetResizable(True) + area.setFrameShape(QFrame.Shape.NoFrame) + area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + area.setWidget(page) + # 背景交给窗口底色(卡片自带白底),避免滚动区出现一块不同的灰 + area.viewport().setAutoFillBackground(False) + page.setAutoFillBackground(False) + return area def main() -> None: - """console script 入口。""" - app = QApplication(sys.argv) + """console script 入口。 + + 另支持 ``--selftest``:不开窗口、跑一遍依赖与渲染自检后退出(打包脚本用它 + 验证冻结产物,也可在 CI 里当冒烟测试)。 + """ + argv = sys.argv + selftest = "--selftest" in argv + if selftest: + # 自检不需要真实显示器;想验证原生平台插件,调用方显式设 QT_QPA_PLATFORM 覆盖 + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + + app = QApplication(argv) app.setApplicationName(APP) app.setOrganizationName(ORG) + theme.apply_theme(app) + app.setWindowIcon(icons.app_icon()) + + if selftest: + from .selftest import report_path_from_argv, run + + sys.exit(run(report_path_from_argv())) + window = MainWindow() window.show() sys.exit(app.exec()) diff --git a/src/chongming_license_gui/common.py b/src/chongming_license_gui/common.py index e1808be..78daee4 100644 --- a/src/chongming_license_gui/common.py +++ b/src/chongming_license_gui/common.py @@ -3,13 +3,16 @@ 这里刻意不含任何授权逻辑 —— 密码学、载荷格式、规范化、判定一律调用 ``chongming_license``。GUI 只负责输入与展示,避免与 CLI 出现第二套实现 (尤其是规范化 JSON:一旦两处实现有差异,就会出现"自己签的自己验不过")。 + +视觉相关的一切走 :mod:`.theme` 与 :mod:`.icons`:本模块只提供"带语义的控件 +工厂",不在调用点写死颜色或宽度。 """ from __future__ import annotations import tomllib from pathlib import Path -from PySide6.QtCore import Qt +from PySide6.QtCore import QSize, QSettings, Qt, QTimer from PySide6.QtGui import QFont, QFontDatabase, QGuiApplication from PySide6.QtWidgets import ( QFormLayout, @@ -23,42 +26,126 @@ from PySide6.QtWidgets import ( QWidget, ) +from . import icons, theme + +# QSettings 的组织名/应用名:唯一来源(app 与各脚本都用这两个) +ORG = "chongming" +APP = "license-gui" + # 备份的常见插件类型:项目根不在手边(例如商务同事只拿到工具)时的兜底候选 FALLBACK_PLUGIN_TYPES = ("aibox_sophon_adapter", "ip_sound_column") +# 等宽字体候选:机器码/指纹要逐字符核对,系统默认的 Courier New 观感偏旧 +_MONO_CANDIDATES = ( + "Cascadia Mono", + "Consolas", + "JetBrains Mono", + "Noto Sans Mono CJK SC", + "DejaVu Sans Mono", + "Menlo", + "Monaco", +) + +_ICON_SIZE = 16 + def mono_font(size: int = 0) -> QFont: """等宽字体:机器码 / 申请码 / 指纹这类需要逐字符核对的文本必须等宽。""" - font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) + available = set(QFontDatabase.families()) + font = QFont() + for family in _MONO_CANDIDATES: + if family in available: + font = QFont(family) + break + else: + font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) + font.setStyleHint(QFont.StyleHint.Monospace) + font.setFixedPitch(True) if size: font.setPointSize(size) return font +def btn(text: str, role: str = "", icon_name: str | None = None, + min_width: int | None = None, tooltip: str | None = None) -> QPushButton: + """按语义建按钮,避免各处散落 ``setFixedWidth`` 与颜色。 + + Args: + role: ``""`` 次按钮 / ``"primary"`` 主操作 / ``"danger"`` 危险操作 + icon_name: :mod:`.icons` 里的图标名 + """ + button = QPushButton(text) + if role: + button.setProperty("role", role) + if icon_name: + if role == "primary": + color = "#ffffff" + elif role == "danger": + color = theme.DANGER + else: + color = theme.TEXT_MUTED + icon = icons.icon(icon_name, color, _ICON_SIZE) + if not icon.isNull(): + button.setIcon(icon) + button.setIconSize(QSize(_ICON_SIZE, _ICON_SIZE)) + if min_width: + # 用最小宽度而不是固定宽度:中文与高 DPI 下不会被挤压,缩放更自然 + button.setMinimumWidth(min_width) + if tooltip: + button.setToolTip(tooltip) + return button + + +def hint(text: str = "", role: str = "hint", wrap: bool = True) -> QLabel: + """说明/警示文字的统一入口(role: hint / warn / danger / ok)。""" + label = QLabel(text) + label.setWordWrap(wrap) + label.setProperty("role", role) + return label + + +def set_status(label: QLabel, text: str, kind: str | None = None) -> None: + """更新状态标签并按语义着色(kind: ok / danger / warn / hint)。""" + label.setText(text) + if kind: + theme.set_role(label, kind) + + +def flash(label: QLabel, text: str = "已复制", msec: int = 1600, kind: str = "ok") -> None: + """在标签上瞬时提示后自动清空。 + + 复制这类"无需打断"的操作用它,而不是弹窗 —— 弹窗要点一下才能继续, + 连续复制多个字段时很打断。``kind="warn"`` 用于"暂无可复制内容"这类提示。 + """ + label.setProperty("role", "flash" if kind == "ok" else kind) + theme.repolish(label) + label.setText(text) + + def _clear() -> None: + # 期间若已被别的流程改写(例如加载失败提示),不要误清 + if label.text() == text: + label.clear() + + QTimer.singleShot(msec, _clear) + + def copy_to_clipboard(text: str, parent: QWidget | None = None) -> bool: - """复制到剪贴板并给出提示;空文本返回 False。""" + """复制到剪贴板并给出提示;空文本返回 False。 + + 传了 ``parent`` 会弹窗确认(用于"复制了什么"需要留下痕迹的场景); + 高频、连续复制的场景请改用 :func:`flash` 走按钮旁的瞬时提示。 + """ if not text: return False clipboard = QGuiApplication.clipboard() if clipboard is None: return False clipboard.setText(text) - # 用非阻塞的状态栏提示更优雅,但工具只有几处、用弹窗更直白不易漏看 if parent is not None: QMessageBox.information(parent, "已复制", "内容已复制到剪贴板。") return True -def add_copy_button(row: QHBoxLayout, text_provider, label: str = "复制", - parent: QWidget | None = None) -> QPushButton: - """往一行布局里加一个"复制"按钮,内容由 text_provider() 惰性获取。""" - btn = QPushButton(label) - btn.setFixedWidth(72) - btn.clicked.connect(lambda: copy_to_clipboard(text_provider() or "", parent)) - row.addWidget(btn) - return btn - - def make_mono_label(text: str = "", size: int = 11) -> QLabel: """等宽、可选中、按内容自适应高度的标签。""" lbl = QLabel(text) @@ -70,14 +157,46 @@ def make_mono_label(text: str = "", size: int = 11) -> QLabel: def section(title: str) -> tuple[QGroupBox, QFormLayout]: - """建一个带标题的分组框 + 表单布局,返回 (box, form)。""" + """建一个带标题的分组框 + 表单布局,返回 (box, form)。 + + 卡片的底色/圆角/标题色由 :mod:`.theme` 的 QSS 负责,这里只管间距 —— + 两边都设 padding 会叠加成过大的留白。 + """ box = QGroupBox(title) form = QFormLayout(box) + form.setContentsMargins(0, 2, 0, 0) + form.setHorizontalSpacing(12) + form.setVerticalSpacing(9) form.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) return box, form +def isolated_settings(root: str | Path) -> QSettings: + """建一个落在 ``root`` 目录下 INI 文件里的 QSettings,**绝不碰真实配置**。 + + 给测试 / 截图 / 自检脚本用。必须显式指定 format 与 scope,原因是: + + ``QSettings.setDefaultFormat(IniFormat)`` 在本环境里**并没有**改变两参数构造 + ``QSettings(org, app)`` 的落点 —— ``fileName()`` 仍然指向注册表 + ``HKCU\\Software\\chongming\\license-gui``。踩过一次:脚本以为在写临时配置, + 实际把测试值(甚至数据库密码)写进了用户真实配置,还顺手 clear 掉了用户自己的 + 设置。所以这里用四参数构造,并请调用方用 :func:`assert_isolated_settings` 自证。 + """ + QSettings.setPath(QSettings.Format.IniFormat, QSettings.Scope.UserScope, str(root)) + return QSettings(QSettings.Format.IniFormat, QSettings.Scope.UserScope, ORG, APP) + + +def assert_isolated_settings(settings: QSettings, root: str | Path) -> None: + """确认这份配置真的落在 ``root`` 下;否则说明隔离失败,必须立刻停下。""" + location = settings.fileName() + if not Path(location).resolve().is_relative_to(Path(root).resolve()): + raise RuntimeError( + f"配置隔离失败:期望落在 {root} 下,实际是 {location}\n" + "继续跑下去会污染用户真实配置,已中止。" + ) + + def hbox(*widgets: QWidget, spacing: int = 8) -> QWidget: """把若干控件横排进一个容器(用于表单字段位置放"输入框 + 按钮")。""" container = QWidget() diff --git a/src/chongming_license_gui/icons.py b/src/chongming_license_gui/icons.py new file mode 100644 index 0000000..e37ed92 --- /dev/null +++ b/src/chongming_license_gui/icons.py @@ -0,0 +1,205 @@ +"""图标:内联 SVG → QIcon,不引入任何二进制素材。 + +用 SVG 路径字符串画图标(feather 风格的单色线性图标),运行时经 +``QSvgRenderer`` 光栅化成 ``QPixmap``。好处是**主题色变了图标跟着变**, +且仓库里不用存 png/ico,也不依赖 ``.qrc`` 资源编译。 + +``PySide6.QtSvg`` 不可用时所有函数返回空 ``QIcon``:按钮少个图标不影响使用, +但绝不能因此报错(图标是锦上添花,不是功能)。 +""" +from __future__ import annotations + +from PySide6.QtCore import QByteArray, Qt +from PySide6.QtGui import QIcon, QPainter, QPixmap + +from . import theme + +# 24x24 视框内的描边图形(每个图标是若干 SVG 元素) +_SHAPES: dict[str, tuple[str, ...]] = { + # 页签 + "issue": ( + '', + '', + '', + '', + ), + "registry": ( + '', + '', + '', + ), + "key": ( + '', + '', + '', + ), + "verify": ( + '', + '', + ), + # 操作 + "copy": ( + '', + '', + ), + "folder": ( + '', + ), + "save": ( + '', + '', + '', + ), + "refresh": ( + '', + '', + ), + "reset": ( + '', + '', + ), + "add": ('', ''), + "remove": ('',), + "select_all": ( + '', + '', + ), + "select_none": ( + '', + '', + ), + "export": ( + '', + '', + '', + ), + "import_file": ( + '', + '', + '', + ), + "search": ( + '', + '', + ), + "trash": ( + '', + '', + '', + '', + ), + "edit": ( + '', + '', + ), + "open": ( + '', + '', + ), + "lock": ( + '', + '', + '', + ), + "shield": ( + '', + ), + "alert": ( + '', + '', + '', + ), + "info": ( + '', + '', + '', + ), + "chevron_down": ('',), +} + +_SVG_TEMPLATE = ( + '{shapes}' +) + +# 渲染倍率:2x 光栅化并标记 dpr,高分屏清晰,1x 屏由 Qt 降采样 +_SCALE = 2.0 + +_cache: dict[tuple[str, str, int], QIcon] = {} +_pixmap_cache: dict[tuple[str, str, int], QPixmap] = {} + + +def svg_available() -> bool: + """SVG 渲染是否可用(不可用时所有图标降级为空)。""" + try: + import PySide6.QtSvg # noqa: F401 + except ImportError: + return False + return True + + +def _svg_text(name: str, color: str) -> str | None: + shapes = _SHAPES.get(name) + if not shapes: + return None + return _SVG_TEMPLATE.format(color=color, shapes="".join(shapes)) + + +def svg_markup(name: str, color: str | None = None) -> str: + """取图标的 SVG 源码(找不到返回空串)。 + + 打包脚本用它把图标渲染成多尺寸 .ico —— 这样仓库里不用存美术素材, + 但 exe 仍有一个像样的图标。 + """ + return _svg_text(name, color or theme.TEXT_MUTED) or "" + + +def pixmap(name: str, color: str | None = None, size: int = 16) -> QPixmap: + """光栅化图标为 QPixmap;不可用时返回空 QPixmap。""" + color = color or theme.TEXT_MUTED + key = (name, color, size) + cached = _pixmap_cache.get(key) + if cached is not None: + return cached + + pm = QPixmap(int(size * _SCALE), int(size * _SCALE)) + pm.fill(Qt.GlobalColor.transparent) + svg = _svg_text(name, color) + if svg is not None and svg_available(): + from PySide6.QtSvg import QSvgRenderer + + renderer = QSvgRenderer(QByteArray(svg.encode("utf-8"))) + if renderer.isValid(): + painter = QPainter(pm) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + renderer.render(painter) + painter.end() + pm.setDevicePixelRatio(_SCALE) + _pixmap_cache[key] = pm + return pm + + +def icon(name: str, color: str | None = None, size: int = 16) -> QIcon: + """取图标(结果缓存)。名字未知或 QtSvg 不可用时返回空 QIcon。""" + color = color or theme.TEXT_MUTED + key = (name, color, size) + cached = _cache.get(key) + if cached is not None: + return cached + + result = QIcon() + if name in _SHAPES and svg_available(): + result = QIcon(pixmap(name, color, size)) + _cache[key] = result + return result + + +def app_icon() -> QIcon: + """窗口/任务栏图标(多尺寸,主色盾牌)。""" + result = QIcon() + if not svg_available(): + return result + for size in (16, 24, 32, 48, 64, 128, 256): + result.addPixmap(pixmap("shield", theme.ACCENT, size)) + return result diff --git a/src/chongming_license_gui/inspect_tab.py b/src/chongming_license_gui/inspect_tab.py index 74dcf5e..dc21bbd 100644 --- a/src/chongming_license_gui/inspect_tab.py +++ b/src/chongming_license_gui/inspect_tab.py @@ -15,7 +15,6 @@ from PySide6.QtWidgets import ( QLabel, QLineEdit, QPlainTextEdit, - QPushButton, QRadioButton, QVBoxLayout, QWidget, @@ -29,12 +28,15 @@ from chongming_license import ( ) from .common import ( + btn, error, + hint, hbox, make_mono_label, section, warn, ) +from . import theme LICENSE_FILTER = "License 文件 (*.lic *.json);;所有文件 (*)" PUBKEY_FILTER = "公钥文件 (*.key *.pub *.pem);;所有文件 (*)" @@ -48,21 +50,21 @@ class InspectTab(QWidget): self.settings = settings root = QVBoxLayout(self) + root.setContentsMargins(14, 14, 14, 14) + root.setSpacing(12) # ---------- 选择文件 ---------- file_box, file_form = section("license 文件") self.lic_edit = QLineEdit() self.lic_edit.setPlaceholderText("选择或填入 license 文件路径") - lic_browse = QPushButton("浏览…") - lic_browse.setFixedWidth(72) + lic_browse = btn("浏览…", icon_name="folder") lic_browse.clicked.connect(self._pick_license) file_form.addRow("文件", hbox(self.lic_edit, lic_browse)) self.pub_edit = QLineEdit(str(settings.value("inspect/pubkey", ""))) self.pub_edit.setPlaceholderText("厂商公钥:base64 裸 32 字节或 PEM(留空则只查看内容、不验签)") - pub_browse = QPushButton("从文件…") - pub_browse.setFixedWidth(88) + pub_browse = btn("从文件…", icon_name="open") pub_browse.clicked.connect(self._pick_pubkey) file_form.addRow("公钥", hbox(self.pub_edit, pub_browse)) @@ -85,25 +87,28 @@ class InspectTab(QWidget): target_form.addRow("", self.rb_local) target_form.addRow("", hbox(self.rb_remote, self.rc_edit)) + target_form.addRow("", hint( + "发货前请选「按指定机器」并填入客户申请码 —— 只验签名只能证明" + "「这文件是我们签的」,验不出「是不是给这台设备签的」。" + )) root.addWidget(target_box) # ---------- 结论 ---------- result_box, result_form = section("校验结论") + # 结论是本页最重要的输出,做成横幅:成功绿 / 失败红 / 未判定灰 self.verdict = QLabel("尚未校验") self.verdict.setWordWrap(True) self.verdict.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - f = self.verdict.font() - f.setBold(True) - self.verdict.setFont(f) - result_form.addRow("结论", self.verdict) + theme.apply_verdict(self.verdict, theme.VERDICT_IDLE) + result_form.addRow("", self.verdict) self.detail = make_mono_label("", size=10) result_form.addRow("详情", self.detail) - self.run_btn = QPushButton("校验") + self.run_btn = btn("校验", role="primary", icon_name="verify", min_width=104) self.run_btn.clicked.connect(self._run) - show_btn = QPushButton("仅查看内容") + show_btn = btn("仅查看内容", icon_name="search") show_btn.clicked.connect(self._show_only) result_form.addRow("", hbox(self.run_btn, show_btn)) @@ -167,7 +172,7 @@ class InspectTab(QWidget): payload = doc.get("payload") or {} self.verdict.setText("已载入内容(未验签)") - self.verdict.setStyleSheet("color: #666;") + theme.apply_verdict(self.verdict, theme.VERDICT_IDLE) self.detail.setText( "\n".join([ f"授权编号:{payload.get('lic_id', '—')}", @@ -227,10 +232,10 @@ class InspectTab(QWidget): if status.ok: self.verdict.setText(f"✓ {status.message}") - self.verdict.setStyleSheet("color: #2e7d32;") + theme.apply_verdict(self.verdict, theme.VERDICT_OK) else: self.verdict.setText(f"✗ {status.message}") - self.verdict.setStyleSheet("color: #c62828;") + theme.apply_verdict(self.verdict, theme.VERDICT_BAD) lines = [ f"原因码:{status.code}", diff --git a/src/chongming_license_gui/issue_tab.py b/src/chongming_license_gui/issue_tab.py index 27852e8..f2c2b0b 100644 --- a/src/chongming_license_gui/issue_tab.py +++ b/src/chongming_license_gui/issue_tab.py @@ -11,7 +11,6 @@ from pathlib import Path from PySide6.QtCore import QDate, QSettings, Qt from PySide6.QtWidgets import ( - QAbstractItemView, QButtonGroup, QCheckBox, QComboBox, @@ -19,10 +18,7 @@ from PySide6.QtWidgets import ( QFileDialog, QLabel, QLineEdit, - QListWidget, - QListWidgetItem, QPlainTextEdit, - QPushButton, QRadioButton, QSpinBox, QStackedWidget, @@ -40,18 +36,21 @@ from chongming_license import ( ) from .common import ( - FALLBACK_PLUGIN_TYPES, ask_overwrite, + btn, copy_to_clipboard, - discover_plugin_types, error, + hint, hbox, info, make_mono_label, + mono_font, section, + set_status, warn, ) from .keystore import SigningKey +from .plugin_picker import PluginPicker # 签发依据的三种方式(与 CLI 的 --request-code / --sources-json / --machine-code 对位) MODE_REQUEST_CODE = 0 @@ -77,16 +76,20 @@ class IssueTab(QWidget): self._source_note: str = "" root = QVBoxLayout(self) + root.setContentsMargins(14, 14, 14, 14) + root.setSpacing(12) # ---------- 签名私钥 ---------- key_box, key_form = section("签名私钥") self.key_status = QLabel("—") self.key_status.setWordWrap(True) + # 指纹要逐字符核对,必须等宽 self.key_fp = QLabel("—") + self.key_fp.setFont(mono_font(11)) self.key_fp.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) key_form.addRow("当前", self.key_status) key_form.addRow("公钥指纹", self.key_fp) - key_form.addRow("", QLabel("在「密钥」页生成或加载私钥。签发前请核对公钥指纹与设备端一致。")) + key_form.addRow("", hint("在「密钥」页生成或加载私钥。签发前请核对公钥指纹与设备端一致。")) root.addWidget(key_box) # ---------- 签发依据 ---------- @@ -109,9 +112,9 @@ class IssueTab(QWidget): ) self.rc_edit.setMaximumHeight(76) rc_layout.addWidget(self.rc_edit) - rc_btn = QPushButton("解析申请码") - rc_btn.clicked.connect(self._parse_request_code) - rc_layout.addWidget(rc_btn) + rc_button = btn("解析申请码", icon_name="search") + rc_button.clicked.connect(self._parse_request_code) + rc_layout.addWidget(rc_button) self.stack.addWidget(page_rc) # 页 1:来源 JSON 文件 @@ -120,11 +123,9 @@ class IssueTab(QWidget): json_layout.setContentsMargins(0, 0, 0, 0) self.json_edit = QLineEdit() self.json_edit.setPlaceholderText("show --json 的导出文件(含 sources 字段,或就是来源字典)") - json_browse = QPushButton("浏览…") - json_browse.setFixedWidth(72) + json_browse = btn("浏览…", icon_name="folder") json_browse.clicked.connect(self._pick_json) - json_load = QPushButton("解析") - json_load.setFixedWidth(72) + json_load = btn("解析", icon_name="import_file") json_load.clicked.connect(self._parse_json_file) json_layout.addWidget(hbox(self.json_edit, json_browse, json_load)) self.stack.addWidget(page_json) @@ -136,11 +137,11 @@ class IssueTab(QWidget): self.mc_edit = QLineEdit() self.mc_edit.setPlaceholderText("如 BC60-D20E-F863-7236-68E2-6A24") self.mc_edit.setFont(make_mono_label().font()) - mc_btn = QPushButton("使用该机器码") - mc_btn.clicked.connect(self._use_machine_code) - mc_layout.addWidget(hbox(self.mc_edit, mc_btn)) - mc_layout.addWidget(QLabel( - "⚠ 仅按机器码精确匹配:换一块网卡就会失授权。建议改用申请码。" + mc_button = btn("使用该机器码") + mc_button.clicked.connect(self._use_machine_code) + mc_layout.addWidget(hbox(self.mc_edit, mc_button)) + mc_layout.addWidget(hint( + "⚠ 仅按机器码精确匹配:换一块网卡就会失授权。建议改用申请码。", role="warn" )) self.stack.addWidget(page_mc) @@ -161,36 +162,16 @@ class IssueTab(QWidget): root.addWidget(target_box) # ---------- 授权插件 ---------- + # 勾选组件与台账「改插件」共用(plugin_picker):行直接加进本卡片,排版与 + # 抽出之前一致;下面几个属性名沿用旧名,既有代码与回归测试都在用 plugin_box, plugin_form = section("授权插件") - self.plugin_list = QListWidget() - self.plugin_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - self.plugin_list.setMaximumHeight(120) - plugin_form.addRow("", self.plugin_list) - - self.plugin_input = QLineEdit() - self.plugin_input.setPlaceholderText("输入插件 type 后点「添加」") - add_btn = QPushButton("添加") - add_btn.setFixedWidth(72) - add_btn.clicked.connect(self._add_plugin) - remove_btn = QPushButton("移除选中") - remove_btn.setFixedWidth(96) - remove_btn.clicked.connect(self._remove_selected_plugins) - all_btn = QPushButton("全选") - all_btn.setFixedWidth(72) - all_btn.clicked.connect(lambda: self._set_all_checked(True)) - none_btn = QPushButton("全不选") - none_btn.setFixedWidth(72) - none_btn.clicked.connect(lambda: self._set_all_checked(False)) - plugin_form.addRow( - "", hbox(self.plugin_input, add_btn, remove_btn, all_btn, none_btn) + self.plugin_picker = PluginPicker( + plugin_form, settings, notify=lambda title, msg: warn(self, title, msg) ) - - self.root_edit = QLineEdit(str(settings.value("issue/root", str(Path.cwd())))) - self.root_edit.setPlaceholderText("项目根目录(用于自动列出插件)") - reload_btn = QPushButton("重新加载插件列表") - reload_btn.setFixedWidth(140) - reload_btn.clicked.connect(self._reload_plugins) - plugin_form.addRow("项目根", hbox(self.root_edit, reload_btn)) + self.plugin_list = self.plugin_picker.list + self.plugin_input = self.plugin_picker.input + self.plugin_note = self.plugin_picker.note + self.root_edit = self.plugin_picker.root_edit root.addWidget(plugin_box) @@ -247,16 +228,16 @@ class IssueTab(QWidget): self.preview.setPlaceholderText("载荷预览:点「刷新预览」") action_form.addRow("", self.preview) - refresh_btn = QPushButton("刷新预览") + refresh_btn = btn("刷新预览", icon_name="refresh") refresh_btn.clicked.connect(self._refresh_preview) - copy_btn = QPushButton("复制预览") + copy_btn = btn("复制预览", icon_name="copy") copy_btn.clicked.connect(lambda: copy_to_clipboard(self.preview.toPlainText(), self)) self.record_check = QCheckBox("签发后记入台账(便于日后续期与检索)") self.record_check.setChecked(True) self.record_check.setToolTip( "台账会连机器指纹一起存下来,续期时可直接复用,无需再向客户索取申请码" ) - self.issue_btn = QPushButton("签发并保存…") + self.issue_btn = btn("签发并保存…", role="primary", icon_name="save") self.issue_btn.clicked.connect(self._issue) action_form.addRow("", hbox(refresh_btn, copy_btn, self.issue_btn)) action_form.addRow("", self.record_check) @@ -280,60 +261,16 @@ class IssueTab(QWidget): self.key_fp.setText("—") # ------------------------------------------------------------------ - # 插件列表 + # 插件列表(实现在 plugin_picker.PluginPicker,这里只为既有调用点留入口) # ------------------------------------------------------------------ def _reload_plugins(self) -> None: - root = self.root_edit.text().strip() or "." - found = discover_plugin_types(root) - note = "" - if not found: - found = list(FALLBACK_PLUGIN_TYPES) - note = f"(未在 {root}/config/workers 找到插件,已用常见候选)" - - # 保留用户已勾选的项,避免重新加载把选择清掉 - checked = self._checked_plugins() - self.plugin_list.clear() - for ptype in found: - item = QListWidgetItem(ptype) - item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) - item.setCheckState( - Qt.CheckState.Checked if ptype in checked else Qt.CheckState.Unchecked - ) - self.plugin_list.addItem(item) - if note: - self.target_result.setText(self.target_result.text()) - self.settings.setValue("issue/root", root) + self.plugin_picker.reload() def _checked_plugins(self) -> list[str]: - return [ - self.plugin_list.item(i).text() - for i in range(self.plugin_list.count()) - if self.plugin_list.item(i).checkState() == Qt.CheckState.Checked - ] + return self.plugin_picker.checked() def _set_all_checked(self, checked: bool) -> None: - state = Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked - for i in range(self.plugin_list.count()): - self.plugin_list.item(i).setCheckState(state) - - def _add_plugin(self) -> None: - name = self.plugin_input.text().strip() - if not name: - return - existing = {self.plugin_list.item(i).text() for i in range(self.plugin_list.count())} - if name in existing: - warn(self, "已存在", f"{name} 已在列表中。") - self.plugin_input.clear() - return - item = QListWidgetItem(name) - item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) - item.setCheckState(Qt.CheckState.Checked) - self.plugin_list.addItem(item) - self.plugin_input.clear() - - def _remove_selected_plugins(self) -> None: - for item in self.plugin_list.selectedItems(): - self.plugin_list.takeItem(self.plugin_list.row(item)) + self.plugin_picker.set_all(checked) # ------------------------------------------------------------------ # 签发依据解析 @@ -346,7 +283,7 @@ class IssueTab(QWidget): self._sources = {} self._machine_code = "" self._source_note = "" - self.target_result.setText("尚未解析签发对象") + set_status(self.target_result, "尚未解析签发对象", "hint") self.sources_view.clear() self.preview.clear() @@ -422,7 +359,9 @@ class IssueTab(QWidget): detail.append("⚠ 没有任何可信来源,判定将只依赖易重复的来源,安全性不足") else: detail.append("⚠ 无来源指纹:仅按机器码精确匹配,换硬件即失授权") - self.target_result.setText("\n".join(detail)) + # 命中"⚠"时整块转警示色 —— 弱来源/无来源是签发前最该被看见的信息 + set_status(self.target_result, "\n".join(detail), + "warn" if any("⚠" in line for line in detail) else "hint") self.sources_view.setPlainText( "\n".join(f"{k} = {v}" for k, v in sorted(sources.items())) if sources else "(无)" diff --git a/src/chongming_license_gui/keys_tab.py b/src/chongming_license_gui/keys_tab.py index 96d3b7b..efbb82b 100644 --- a/src/chongming_license_gui/keys_tab.py +++ b/src/chongming_license_gui/keys_tab.py @@ -21,11 +21,16 @@ from PySide6.QtWidgets import ( from chongming_license import LicenseError, generate_keypair, write_private_key from .common import ( + btn, copy_to_clipboard, error, + flash, + hint, info, make_mono_label, + mono_font, section, + set_status, warn, ) from .keystore import SigningKey @@ -42,13 +47,14 @@ class KeysTab(QWidget): self.settings = settings root = QVBoxLayout(self) + root.setContentsMargins(14, 14, 14, 14) + root.setSpacing(12) # ---------- 生成密钥对 ---------- gen_box, gen_form = section("生成密钥对(厂商侧,一次性)") self.out_edit = QLineEdit(str(settings.value("keys/output", "license_private.pem"))) - browse_out = QPushButton("浏览…") - browse_out.setFixedWidth(72) + browse_out = btn("浏览…", icon_name="folder") browse_out.clicked.connect(self._pick_out) gen_form.addRow("私钥保存到", self._row(self.out_edit, browse_out)) @@ -56,14 +62,21 @@ class KeysTab(QWidget): self.kid_edit.setPlaceholderText("可选,写入 license 的 kid 字段(如 vendor-2026)") gen_form.addRow("密钥标识", self.kid_edit) - gen_btn = QPushButton("生成密钥对") - gen_btn.clicked.connect(self._generate) - gen_form.addRow("", gen_btn) + gen_button = btn("生成密钥对", role="primary", icon_name="key") + gen_button.clicked.connect(self._generate) + gen_form.addRow("", gen_button) self.pub_label = make_mono_label("(尚未生成)", size=10) - gen_form.addRow("公钥 base64", self._row(self.pub_label, self._copy_btn(lambda: self.key.public_b64))) + self.b64_flash = QLabel("") + gen_form.addRow( + "公钥 base64", + self._row(self.pub_label, + self._copy_btn(lambda: self.key.public_b64, self.b64_flash), + self.b64_flash), + ) self.fp_label = QLabel("—") + self.fp_label.setFont(mono_font(11)) self.fp_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) gen_form.addRow("公钥指纹", self.fp_label) @@ -73,15 +86,20 @@ class KeysTab(QWidget): self.pem_view.setMaximumHeight(110) self.pem_view.setPlaceholderText("(公钥 PEM,可选使用)") gen_form.addRow("公钥 PEM", self.pem_view) - gen_form.addRow("", self._copy_btn(lambda: self.key.public_pem(), "复制 PEM")) + self.pem_flash = QLabel("") + gen_form.addRow( + "", + self._row(self._copy_btn(lambda: self.key.public_pem(), self.pem_flash, "复制 PEM"), + self.pem_flash), + ) - caution = QLabel( + caution = hint( "⚠ 私钥是授权的唯一信任根:请离线保管并做好备份,切勿提交仓库或打进部署产物。\n" "私钥丢失 = 无法再签发新授权;私钥泄漏 = 任何人都能签发授权。\n" - "内置防护:.gitignore 忽略私钥文件;`uv run build` 会扫描产物并在发现私钥时终止构建。" + "内置防护:本目录 .gitignore 已忽略私钥文件(license_private*.pem 等);\n" + "发布前请再确认产物内不含私钥与台账库。", + role="warn", ) - caution.setWordWrap(True) - caution.setStyleSheet("color: #b8860b;") gen_form.addRow("", caution) root.addWidget(gen_box) @@ -91,13 +109,11 @@ class KeysTab(QWidget): self.in_edit = QLineEdit(str(settings.value("keys/input", ""))) self.in_edit.setPlaceholderText("已有私钥的路径(用于签发或核对公钥)") - browse_in = QPushButton("浏览…") - browse_in.setFixedWidth(72) + browse_in = btn("浏览…", icon_name="folder") browse_in.clicked.connect(self._pick_in) - load_btn = QPushButton("加载") - load_btn.setFixedWidth(72) - load_btn.clicked.connect(self._load) - load_form.addRow("私钥路径", self._row(self.in_edit, browse_in, load_btn)) + load_button = btn("加载", icon_name="import_file", min_width=88) + load_button.clicked.connect(self._load) + load_form.addRow("私钥路径", self._row(self.in_edit, browse_in, load_button)) self.load_status = QLabel("—") self.load_status.setWordWrap(True) @@ -123,12 +139,26 @@ class KeysTab(QWidget): return hbox(*widgets) @staticmethod - def _copy_btn(provider, label: str = "复制") -> QPushButton: - """独立成行的复制按钮(不依赖某个父布局)。""" - btn = QPushButton(label) - btn.setFixedWidth(96) - btn.clicked.connect(lambda: copy_to_clipboard(provider() or "", None)) - return btn + def _copy_btn(provider, status: QLabel | None = None, label: str = "复制") -> QPushButton: + """独立成行的复制按钮。 + + ``status`` 是按钮旁的提示标签:点完给出"已复制"反馈。以前这里传 + ``parent=None``,点了**完全没有反应**,用户只能靠再去粘贴一次确认。 + """ + button = btn(label, icon_name="copy", min_width=104) + + def _do() -> None: + text = provider() or "" + if not text: + if status is not None: + flash(status, "暂无可复制的内容", kind="warn") + return + copy_to_clipboard(text, None) + if status is not None: + flash(status, "✓ 已复制到剪贴板") + + button.clicked.connect(_do) + return button def _pick_out(self) -> None: path, _ = QFileDialog.getSaveFileName( @@ -185,18 +215,18 @@ class KeysTab(QWidget): try: pub = self.key.load(raw) except LicenseError as exc: - self.load_status.setText(f"✗ {exc}") + set_status(self.load_status, f"✗ {exc}", "danger") if not silent: error(self, "加载失败", str(exc)) return except Exception as exc: # noqa: BLE001 - 非 PEM / 权限问题等 - self.load_status.setText(f"✗ 无法读取:{exc}") + set_status(self.load_status, f"✗ 无法读取:{exc}", "danger") if not silent: error(self, "加载失败", f"{exc}") return self.settings.setValue("keys/input", raw) - self.load_status.setText(f"✓ 已加载,公钥 = {pub}") + set_status(self.load_status, f"✓ 已加载,公钥 = {pub}", "ok") if not silent: info( self, "已加载私钥", @@ -214,12 +244,19 @@ class KeysTab(QWidget): # 路径栏同步,方便用户看到当前生效的是哪个文件 if self.in_edit.text().strip() != str(self.key.path): self.in_edit.setText(str(self.key.path)) - self.load_status.setText( - f"✓ 当前生效:{self.key.path}(公钥指纹 {self.key.fingerprint})" + set_status( + self.load_status, + f"✓ 当前生效:{self.key.path}(公钥指纹 {self.key.fingerprint})", + "ok", ) elif not self.in_edit.text().strip(): - self.load_status.setText(f"✓ 已加载(公钥指纹 {self.key.fingerprint})") + set_status( + self.load_status, + f"✓ 已加载(公钥指纹 {self.key.fingerprint})", + "ok", + ) else: self.pub_label.setText("(尚未加载私钥)") self.fp_label.setText("—") self.pem_view.clear() + set_status(self.load_status, "尚未加载私钥", "hint") diff --git a/src/chongming_license_gui/pg_dialog.py b/src/chongming_license_gui/pg_dialog.py new file mode 100644 index 0000000..fc23bee --- /dev/null +++ b/src/chongming_license_gui/pg_dialog.py @@ -0,0 +1,155 @@ +"""PostgreSQL 连接设置对话框。 + +只负责收集参数与"测试连接",不持有连接(连接由 ``RegistryHolder`` 管)。 +密码策略见 :meth:`PgSettingsDialog.values` 与界面上的警示行: +**默认不保存**,勾选"记住密码"才写入本机配置(明文,仅本账号可见)。 +""" +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QLabel, + QLineEdit, + QSpinBox, + QVBoxLayout, +) + +from . import theme +from .common import btn, hint, hbox, section, set_status +from .pg_store import ( + DEFAULT_DBNAME, + DEFAULT_HOST, + DEFAULT_PORT, + DEFAULT_SCHEMA, + DEFAULT_SSLMODE, + SSL_MODES, + PgConfig, + test_connection, +) + + +class PgSettingsDialog(QDialog): + """填写/测试 PostgreSQL 连接参数。 + + 用法:: + + dlg = PgSettingsDialog(config, remember_password=..., parent=self) + if dlg.exec() == QDialog.DialogCode.Accepted: + config, remember = dlg.values() + """ + + def __init__(self, config: PgConfig, *, remember_password: bool = False, + parent=None) -> None: + super().__init__(parent) + self.setWindowTitle("PostgreSQL 连接设置") + self.setMinimumWidth(560) + + root = QVBoxLayout(self) + root.setContentsMargins(16, 16, 16, 16) + root.setSpacing(12) + + conn_box, conn_form = section("连接") + + self.host_edit = QLineEdit(config.host or DEFAULT_HOST) + self.port_spin = QSpinBox() + # PostgreSQL 默认端口;允许改(公司可能改过端口或走端口映射) + self.port_spin.setRange(1, 65535) + self.port_spin.setValue(int(config.port or DEFAULT_PORT)) + conn_form.addRow("主机", hbox(self.host_edit, QLabel("端口"), self.port_spin)) + + self.db_edit = QLineEdit(config.dbname or DEFAULT_DBNAME) + conn_form.addRow("数据库", self.db_edit) + + self.user_edit = QLineEdit(config.user) + conn_form.addRow("用户", self.user_edit) + + self.pw_edit = QLineEdit(config.password) + self.pw_edit.setEchoMode(QLineEdit.EchoMode.Password) + self.pw_edit.setPlaceholderText("不勾选「记住密码」时,仅本次运行内存中保留") + conn_form.addRow("密码", self.pw_edit) + + self.ssl_combo = QComboBox() + for mode in SSL_MODES: + self.ssl_combo.addItem(mode, mode) + index = self.ssl_combo.findData(config.sslmode or DEFAULT_SSLMODE) + self.ssl_combo.setCurrentIndex(index if index >= 0 else 0) + self.ssl_combo.setToolTip( + "内网直连常用 prefer;要求加密用 require;\n" + "verify-ca / verify-full 需服务端证书与 CA 配置" + ) + conn_form.addRow("SSL 模式", self.ssl_combo) + + self.schema_edit = QLineEdit(config.schema or DEFAULT_SCHEMA) + self.schema_edit.setToolTip("台账表所在的 schema,默认 public") + conn_form.addRow("Schema", self.schema_edit) + + root.addWidget(conn_box) + + # ---- 密码保存与测试 ---- + sec_box, sec_form = section("安全") + self.remember_check = QCheckBox("记住密码(明文保存在本机配置,仅本账号可见)") + self.remember_check.setChecked(bool(remember_password)) + self.remember_check.setToolTip( + "勾选后写入本机 QSettings,下次启动自动连接;\n" + "取消勾选会删除已保存的密码。" + ) + sec_form.addRow("", self.remember_check) + sec_form.addRow("", hint( + "⚠ 勾选后密码以明文形式存在本机(Windows 为注册表 HKCU\\Software\\chongming)。" + "共享电脑或安全要求高的环境建议不勾选,每次启动手动输入。", + role="warn", + )) + root.addWidget(sec_box) + + self.test_status = QLabel("") + self.test_status.setWordWrap(True) + self.test_button = btn("测试连接", icon_name="verify") + self.test_button.clicked.connect(self._test) + sec_form.addRow("", hbox(self.test_button, self.test_status)) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + ok_button = buttons.button(QDialogButtonBox.StandardButton.Ok) + ok_button.setText("保存并连接") + ok_button.setProperty("role", "primary") + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + root.addWidget(buttons) + + # ------------------------------------------------------------------ + def values(self) -> tuple[PgConfig, bool]: + """返回 (配置, 是否记住密码)。""" + config = PgConfig( + host=self.host_edit.text().strip() or DEFAULT_HOST, + port=int(self.port_spin.value()), + dbname=self.db_edit.text().strip(), + user=self.user_edit.text().strip(), + password=self.pw_edit.text(), + sslmode=str(self.ssl_combo.currentData() or DEFAULT_SSLMODE), + schema=self.schema_edit.text().strip() or DEFAULT_SCHEMA, + ) + return config, self.remember_check.isChecked() + + def _test(self) -> None: + config, _ = self.values() + if not config.dbname: + set_status(self.test_status, "请先填写数据库名", "warn") + return + self.test_button.setEnabled(False) + set_status(self.test_status, "正在连接…", "hint") + self.test_status.setProperty("role", "hint") + theme.repolish(self.test_status) + # 同步执行但带 5 秒连接超时:地址填错时不会长时间卡住界面 + try: + ok, message = test_connection(config, password=config.password) + finally: + self.test_button.setEnabled(True) + # message 里不含密码(连接用 kwargs,异常不会回显凭据) + set_status(self.test_status, ("✓ " if ok else "✗ ") + message, + "ok" if ok else "danger") + self.test_status.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) diff --git a/src/chongming_license_gui/pg_store.py b/src/chongming_license_gui/pg_store.py new file mode 100644 index 0000000..7bf76c0 --- /dev/null +++ b/src/chongming_license_gui/pg_store.py @@ -0,0 +1,473 @@ +"""台账的 PostgreSQL 后端(**可选功能**,未配置时一律用本地 SQLite)。 + +为什么放在 GUI 仓而不是 ``chongming-license`` 库:库要保持"纯标准库 + 单文件" +的形态,好让网关与 CLI 都能直接用它;PG 需要第三方驱动,按需启用更稳妥。 + +**关键设计:继承库的 :class:`Registry`,只重写与存储引擎相关的 SQL 方法。** +库里的 ``stats()`` / ``find_by_machine_code()`` / ``export_licenses()`` / +``export_csv()`` / ``close()`` / ``__enter__·__exit__`` / ``_row_to_record()`` +都只通过 ``self.query()`` 与 ``LicenseRecord`` 工作,因此可以原样继承 —— +CSV 列序、``.lic`` 导出文件名与客户目录转义、状态分桶统计就**不会出现第二套实现** +(本仓最忌讳的就是"两处实现有差异")。 + +授权语义一律复用库:``record_from_license()`` 仍是唯一写入口, +状态判定仍是 ``LicenseRecord.status()``(且**留在 Python 侧**,理由见库内注释: +状态有优先级,下推成 SQL 条件会互相重叠,导致 stats 各项之和不等于总数)。 +""" +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from datetime import date, datetime, timezone +from threading import RLock + +from chongming_license import ( + DEFAULT_EXPIRING_DAYS, + SCHEMA_VERSION, + LicenseRecord, + Registry, + RegistryError, + record_from_license, + utc_today, +) + +# --------------------------------------------------------------------------- +# 常量 +# --------------------------------------------------------------------------- +DEFAULT_HOST = "127.0.0.1" +# PostgreSQL 默认端口 +DEFAULT_PORT = 5432 +DEFAULT_DBNAME = "chongming_licenses" +DEFAULT_SCHEMA = "public" +DEFAULT_SSLMODE = "prefer" +SSL_MODES = ("disable", "allow", "prefer", "require", "verify-ca", "verify-full") +CONNECT_TIMEOUT = 5 # 秒:地址填错时界面不要长时间卡住 + +TABLE = "issued_licenses" +META_TABLE = "chongming_schema" + +# 与库的 SQLite 版**逐列保持一致**(含列序):INSERT 15 列 +COLUMNS = ( + "lic_id", "machine_code", "customer", "note", "plugins_json", "sources_json", + "not_before", "expires_at", "issued_at", "kid", "public_key_fp", "license_json", + "source", "created_at", "updated_at", +) +# 冲突时更新的 13 列:**不含 lic_id,也不含 created_at**(首次入库时间要保留) +UPDATE_COLUMNS = tuple(c for c in COLUMNS if c not in ("lic_id", "created_at")) + +# 结构版本元数据表 + 建表语句(与 docs/pg_schema.sql 同步维护) +DDL_TEMPLATE = ( + """CREATE TABLE IF NOT EXISTS {t} ( + lic_id TEXT PRIMARY KEY, + machine_code TEXT NOT NULL, + customer TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + plugins_json TEXT NOT NULL, + sources_json TEXT NOT NULL, + not_before TEXT, + expires_at TEXT, + issued_at TEXT NOT NULL DEFAULT '', + kid TEXT NOT NULL DEFAULT '', + public_key_fp TEXT NOT NULL DEFAULT '', + license_json TEXT NOT NULL, + source TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +)""", + "CREATE INDEX IF NOT EXISTS idx_lic_machine ON {t}(machine_code)", + "CREATE INDEX IF NOT EXISTS idx_lic_customer ON {t}(customer)", + "CREATE INDEX IF NOT EXISTS idx_lic_expires ON {t}(expires_at)", + f"""CREATE TABLE IF NOT EXISTS {{s}}.{META_TABLE} ( + name TEXT PRIMARY KEY, + version INTEGER NOT NULL +)""", +) + +# 检索/写入 SQL(提到模块级便于在没有服务器的情况下断言其形状) +# 日期列刻意用 TEXT 存储:库依赖"ISO 日期字符串比较"来排序,改成 date 类型 +# 会引入另一套语义。排序里的 COALESCE 哨兵让"永久"(NULL)排到最后。 +SELECT_SQL = "SELECT * FROM {t}" +ORDER_BY_SQL = " ORDER BY COALESCE(expires_at, '9999-12-31') ASC, lic_id ASC" +INSERT_SQL = ( + f"INSERT INTO {{t}} ({', '.join(COLUMNS)}) " + f"VALUES ({', '.join(['%s'] * len(COLUMNS))}) " + f"ON CONFLICT (lic_id) DO UPDATE SET " + + ", ".join(f"{c} = EXCLUDED.{c}" for c in UPDATE_COLUMNS) +) +UPDATE_NOTE_SQL = "UPDATE {t} SET note = %s, updated_at = %s WHERE lic_id = %s" +DELETE_SQL = "DELETE FROM {t} WHERE lic_id = %s" +SELECT_ONE_SQL = "SELECT * FROM {t} WHERE lic_id = %s" + + +def _now_iso() -> str: + """与库内 ``_now_iso`` 同格式的时间戳(created_at / updated_at 用)。 + + 格式必须与库保持一致,否则同一列会出现两种写法: + ``2026-09-21T06:40:06Z``。 + """ + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _import_driver(): + """延迟导入 psycopg;缺失时给出可照抄的安装命令。 + + 失败时把**原始异常**一并带上(``from exc`` + 文本):冻结产物里驱动加载失败 + 的原因通常很具体(DLL 找不到、模块顺序不对),吞掉它就只剩"未安装"三个字, + 根本没法排查。 + """ + try: + import psycopg + from psycopg.rows import dict_row + except ImportError as exc: + raise RegistryError( + f"PostgreSQL 驱动不可用(可选功能):{type(exc).__name__}: {exc}\n\n" + "源码运行时安装:uv sync --extra postgres\n" + "打包产物请用带 PG 支持的构建(dev 依赖里已包含 psycopg)。" + ) from exc + return psycopg, dict_row + + +def driver_available() -> bool: + """驱动是否可用(供界面/自检提示,不抛异常)。""" + try: + _import_driver() + except RegistryError: + return False + return True + + +def driver_problem() -> str: + """驱动不可用的具体原因(可用时返回空串)。""" + try: + _import_driver() + except RegistryError as exc: + return str(exc).splitlines()[0] + return "" + + +def _quote_identifier(name: str) -> str: + """校验并加引号的标识符。 + + psycopg 的参数占位符不能用于标识符(schema/表名),所以这里必须自己校验: + 只允许字母数字下划线,避免把用户输入的 schema 拼进 SQL 造成注入。 + """ + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name or ""): + raise RegistryError( + f"非法的 schema 名:{name!r}(只允许字母、数字、下划线,且不能以数字开头)" + ) + return f'"{name}"' + + +# --------------------------------------------------------------------------- +# 连接配置 +# --------------------------------------------------------------------------- +@dataclass +class PgConfig: + """PostgreSQL 连接参数(不含密码时由调用方另行提供)。""" + + host: str = DEFAULT_HOST + port: int = DEFAULT_PORT + dbname: str = DEFAULT_DBNAME + user: str = "" + password: str = "" + sslmode: str = DEFAULT_SSLMODE + schema: str = DEFAULT_SCHEMA + + # ---- 展示 ---- + def display(self) -> str: + """展示用连接串 —— **绝不含密码**(会出现在界面标签与错误信息里)。""" + who = f"{self.user}@" if self.user else "" + suffix = "" if self.schema == DEFAULT_SCHEMA else f"(schema={self.schema})" + return f"postgresql://{who}{self.host}:{self.port}/{self.dbname}{suffix}" + + def is_configured(self) -> bool: + return bool(self.host and self.dbname) + + # ---- 连接 ---- + def connect_kwargs(self, *, password: str | None = None, + timeout: int = CONNECT_TIMEOUT) -> dict: + """psycopg 连接参数。 + + 刻意用 kwargs 而不是 conninfo 字符串:字符串形式容易在异常信息里 + 把密码带出去,kwargs 不会。 + """ + pwd = self.password if password is None else password + kwargs: dict = { + "host": self.host, + "port": int(self.port), + "dbname": self.dbname, + "sslmode": self.sslmode, + "connect_timeout": timeout, + } + if self.user: + kwargs["user"] = self.user + if pwd: + kwargs["password"] = pwd + return kwargs + + # ---- 持久化(QSettings)---- + @classmethod + def from_dsn(cls, dsn: str, *, schema: str = DEFAULT_SCHEMA) -> PgConfig: + """从连接串解析出配置。 + + 交给驱动解析(``psycopg.conninfo``),比自己写一套连接串文法可靠。 + """ + try: + from psycopg.conninfo import conninfo_to_dict + except ImportError as exc: # 还没装驱动 + raise RegistryError( + f"解析连接串需要 PostgreSQL 驱动:{type(exc).__name__}: {exc}\n" + "安装:uv sync --extra postgres" + ) from exc + info = conninfo_to_dict(dsn) + return cls( + host=str(info.get("host", DEFAULT_HOST)), + port=int(info.get("port", DEFAULT_PORT)), + dbname=str(info.get("dbname", "")), + user=str(info.get("user", "")), + password=str(info.get("password", "") or ""), + sslmode=str(info.get("sslmode", DEFAULT_SSLMODE)), + schema=schema, + ) + + @classmethod + def from_settings(cls, settings) -> PgConfig: + def _text(key: str, default: str = "") -> str: + return str(settings.value(f"pg/{key}", default) or "") + + try: + port = int(settings.value("pg/port", DEFAULT_PORT)) + except (TypeError, ValueError): + port = DEFAULT_PORT + return cls( + host=_text("host", DEFAULT_HOST), + port=port, + dbname=_text("dbname", DEFAULT_DBNAME), + user=_text("user"), + password=_text("password"), + sslmode=_text("sslmode", DEFAULT_SSLMODE), + schema=_text("schema", DEFAULT_SCHEMA), + ) + + def save(self, settings, *, remember_password: bool) -> None: + """写入配置;**未勾选"记住密码"时删除已存密码**(避免残留旧凭据)。""" + settings.setValue("pg/host", self.host) + settings.setValue("pg/port", int(self.port)) + settings.setValue("pg/dbname", self.dbname) + settings.setValue("pg/user", self.user) + settings.setValue("pg/sslmode", self.sslmode) + settings.setValue("pg/schema", self.schema) + settings.setValue("pg/remember_password", bool(remember_password)) + if remember_password: + settings.setValue("pg/password", self.password) + else: + settings.remove("pg/password") + + +def test_connection(config: PgConfig, *, password: str | None = None, + timeout: int = CONNECT_TIMEOUT) -> tuple[bool, str]: + """试连并执行 ``SELECT 1``;返回 (是否成功, 说明文字)。 + + 说明文字里**不含密码**(连接用 kwargs,异常信息不会回显凭据)。 + """ + try: + psycopg, dict_row = _import_driver() + except RegistryError as exc: + return False, str(exc) + try: + with psycopg.connect(**config.connect_kwargs(password=password, timeout=timeout), + row_factory=dict_row) as conn: + conn.execute("SELECT 1") + except Exception as exc: # noqa: BLE001 - 连接失败原因很多,统一成人话 + return False, f"连接失败:{config.display()}\n{exc}" + return True, f"连接成功:{config.display()}" + + +# --------------------------------------------------------------------------- +# 台账实现 +# --------------------------------------------------------------------------- +class PostgresRegistry(Registry): + """PostgreSQL 台账库(接口与 ``chongming_license.Registry`` 一致)。 + + 只重写 :meth:`__init__`、:meth:`query`、:meth:`get`、:meth:`save`、 + :meth:`update_note`、:meth:`delete` 这 6 个真正涉及 SQL 的方法; + ``stats`` / ``find_by_machine_code`` / ``export_licenses`` / ``export_csv`` / + ``close`` / ``__enter__`` / ``__exit__`` / ``_row_to_record`` 全部继承。 + """ + + def __init__(self, config: PgConfig, *, password: str | None = None, + autocommit: bool = True) -> None: + """连接并确保表存在。 + + ``autocommit=False`` 供 :mod:`tools.pg_check` 使用:整套契约在**一个事务**里 + 跑完再 ``ROLLBACK``,从而在真实库上做校验却**不留任何痕迹**(连建表也回滚)。 + 界面一律用默认的 ``True``。 + """ + # 刻意**不调用** super().__init__():那是 SQLite 实现,会去创建本地文件 + psycopg, dict_row = _import_driver() + + self.config = config + self.password = config.password if password is None else password + self._schema = _quote_identifier(config.schema or DEFAULT_SCHEMA) + # path 是库里的"库标识",这里放连接串(**不含密码**)供界面展示 + self.path = config.display() + self._table = f"{self._schema}.{TABLE}" + self._meta = f"{self._schema}.{META_TABLE}" + self._lock = RLock() + + try: + self._conn = psycopg.connect( + **config.connect_kwargs(password=self.password), row_factory=dict_row + ) + except Exception as exc: # noqa: BLE001 - 统一转成可读错误 + raise RegistryError( + f"无法连接 PostgreSQL:{config.display()}\n{exc}\n\n" + "请检查:主机/端口是否可达(默认 5432)、数据库是否存在、" + "账号密码是否正确、pg_hba.conf 是否允许该来源地址连接。" + ) from exc + + # 逐语句提交:镜像 SQLite 版的 autocommit 语义,同时避免长连接 + # 长期停在 "idle in transaction"(那会占住快照、阻碍 VACUUM) + self._conn.autocommit = autocommit + self._ensure_schema() + self._check_version() + + # ---- 建表与版本 ---- + def _ensure_schema(self) -> None: + """首次连接自动建表;无 DDL 权限时给出 DBA 指引。""" + try: + for statement in DDL_TEMPLATE: + self._conn.execute(statement.format(t=self._table, s=self._schema)) + except Exception as exc: # noqa: BLE001 - 权限/只读库等 + raise RegistryError( + f"建表失败:{self.config.display()}\n{exc}\n\n" + "若该账号没有建表权限,请让 DBA 执行 docs/pg_schema.sql 后重试。" + ) from exc + + def _check_version(self) -> None: + """结构版本检查:措辞与库的 SQLite 版保持一致。""" + row = self._conn.execute( + f"SELECT version FROM {self._meta} WHERE name = %s", (TABLE,) + ).fetchone() + if row is None: + self._conn.execute( + f"INSERT INTO {self._meta} (name, version) VALUES (%s, %s) " + f"ON CONFLICT (name) DO NOTHING", + (TABLE, SCHEMA_VERSION), + ) + return + if row["version"] != SCHEMA_VERSION: + raise RegistryError( + f"台账库结构版本为 {row['version']},本程序支持 {SCHEMA_VERSION}:" + f"{self.path}\n" + "该库由更新/更旧版本的工具创建。请先导出台账(export)后换用匹配的版本。" + ) + + # ---- 读 ---- + def get(self, lic_id: str) -> LicenseRecord | None: + with self._lock: + row = self._conn.execute( + SELECT_ONE_SQL.format(t=self._table), (lic_id,) + ).fetchone() + return self._row_to_record(row) if row else None + + def query(self, *, customer: str | None = None, machine_code: str | None = None, + plugin: str | None = None, status: str | None = None, + keyword: str | None = None, + expiring_days: int = DEFAULT_EXPIRING_DAYS, + today: date | None = None, + limit: int | None = None) -> list[LicenseRecord]: + """按条件检索,按到期日升序(无到期日的排最后)。 + + 过滤条件刻意用 ``ILIKE``:SQLite 的 ``LIKE`` 对 ASCII 大小写不敏感, + 直接用 PG 的 ``LIKE`` 会让"同一个筛选条件在两个后端给出不同结果"。 + (已知微差异:PG 的 LIKE 默认把反斜杠当转义符,SQLite 不会, + 对客户名/机器码这类输入无实际影响。) + """ + ref = today or utc_today() + where: list[str] = [] + args: list = [] + + if customer: + where.append("customer ILIKE %s") + args.append(f"%{customer}%") + if machine_code: + # 机器码统一大写存储,这里同样大写以便大小写不敏感 + where.append("machine_code ILIKE %s") + args.append(f"%{machine_code.strip().upper()}%") + if plugin: + # 插件列表是 JSON 数组文本,用带引号的子串匹配避免 "doubao" 命中 "doubao_extended" + where.append("plugins_json ILIKE %s") + args.append(f'%"{plugin}"%') + if keyword: + like = f"%{keyword}%" + where.append( + "(lic_id ILIKE %s OR machine_code ILIKE %s OR customer ILIKE %s OR note ILIKE %s)" + ) + args.extend([like, like, like, like]) + + sql = SELECT_SQL.format(t=self._table) + if where: + sql += " WHERE " + " AND ".join(where) + sql += ORDER_BY_SQL + + with self._lock: + rows = self._conn.execute(sql, args).fetchall() + records = [self._row_to_record(r) for r in rows] + + # 状态筛选留在 Python 侧(复用 LicenseRecord.status 的优先级), + # 与库的 SQLite 版一致;下推成 SQL 会让各条件互相重叠。 + if status: + records = [r for r in records + if r.status(today=ref, expiring_days=expiring_days) == status] + if limit: + records = records[:int(limit)] + return records + + # ---- 写 ---- + def save(self, license_doc, *, public_key_fp: str = "", source: str = "", + overwrite_note: bool = False) -> LicenseRecord: + """写入/覆盖一条记录(按 lic_id 幂等)。 + + 语义与库的 SQLite 版逐条对齐:默认**保留台账里的人工批注**(载荷里的 note + 是签名内容的一部分,台账 note 是厂商自己的批注,同名不同用途)、 + **保留首次入库时间 created_at**、只刷新 updated_at。 + """ + record = record_from_license(license_doc, public_key_fp=public_key_fp, source=source) + with self._lock: + old = self.get(record.lic_id) + if old is not None: + if not overwrite_note: + record.note = old.note + record.created_at = old.created_at + record.updated_at = _now_iso() + self._conn.execute( + INSERT_SQL.format(t=self._table), + ( + record.lic_id, record.machine_code, record.customer, record.note, + _json(record.plugins), _json(record.sources), + record.not_before, record.expires_at, record.issued_at, record.kid, + record.public_key_fp, record.license_json, record.source, + record.created_at, record.updated_at, + ), + ) + return record + + def update_note(self, lic_id: str, note: str) -> bool: + """只改备注(台账里唯一允许手工编辑、且不会被重新导入覆盖的字段)。""" + with self._lock: + cur = self._conn.execute( + UPDATE_NOTE_SQL.format(t=self._table), (note, _now_iso(), lic_id) + ) + return cur.rowcount > 0 + + def delete(self, lic_id: str) -> bool: + with self._lock: + cur = self._conn.execute(DELETE_SQL.format(t=self._table), (lic_id,)) + return cur.rowcount > 0 + + +def _json(value) -> str: + """与库一致的 JSON 文本(ensure_ascii=False,中文不转义)。""" + return json.dumps(value, ensure_ascii=False) diff --git a/src/chongming_license_gui/plugin_picker.py b/src/chongming_license_gui/plugin_picker.py new file mode 100644 index 0000000..4714af5 --- /dev/null +++ b/src/chongming_license_gui/plugin_picker.py @@ -0,0 +1,218 @@ +"""插件白名单勾选组件(签发页与台账「改插件」共用)。 + +签发页与台账的「改插件」对话框需要**同一份**插件清单和同一套勾选语义: +从项目 ``config/workers`` 自动发现、允许手工补录、原白名单始终留在列表里。 +抽成一处是为了避免两边各写一份之后规则漂移(比如一边读了 ``issue/root``、 +另一边写死候选)。 + +组件按"构建器"用:把行直接加进调用方传进来的 ``QFormLayout``,页面的 +排版与抽出之前保持一致。 +""" +from __future__ import annotations + +from pathlib import Path +from typing import Callable, Iterable + +from PySide6.QtCore import QObject, QSettings, Qt, Signal +from PySide6.QtWidgets import ( + QAbstractItemView, + QFormLayout, + QLineEdit, + QListWidget, + QListWidgetItem, +) + +from .common import ( + FALLBACK_PLUGIN_TYPES, + btn, + discover_plugin_types, + hint, + hbox, + set_status, + warn, +) + +# 签发页与对话框都用这个高度:再高会挤压下方的预览区 +DEFAULT_LIST_HEIGHT = 120 + + +class PluginPicker(QObject): + """可勾选的插件白名单列表。 + + ``changed`` 在任何勾选/增删/重载之后发出 —— 对话框靠它实时算 + "新增了哪些、移除了哪些"。 + """ + + changed = Signal() + + def __init__(self, form: QFormLayout, settings: QSettings, *, + list_height: int = DEFAULT_LIST_HEIGHT, + show_root: bool = True, + notify: Callable[[str, str], None] | None = None, + parent: QObject | None = None) -> None: + super().__init__(parent) + self.settings = settings + # 弹窗交给调用方:各页的 warn 都要能被测试替换掉(见 docs/界面设计.md 红线), + # 这里持有的是 lambda,调用时才去查调用方模块里的名字 + self._notify = notify or (lambda title, msg: warn(self.list, title, msg)) + + self.list = QListWidget() + self.list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.list.setMaximumHeight(list_height) + form.addRow("", self.list) + + self.input = QLineEdit() + self.input.setPlaceholderText("输入插件 type 后点「添加」") + add_btn = btn("添加", icon_name="add", min_width=76) + add_btn.clicked.connect(self.add_from_input) + remove_btn = btn("移除选中", icon_name="remove", min_width=100) + remove_btn.clicked.connect(self.remove_selected) + all_btn = btn("全选", icon_name="select_all", min_width=76) + all_btn.clicked.connect(lambda: self.set_all(True)) + none_btn = btn("全不选", icon_name="select_none", min_width=88) + none_btn.clicked.connect(lambda: self.set_all(False)) + form.addRow("", hbox(self.input, add_btn, remove_btn, all_btn, none_btn)) + + # 列表从哪来的要写清楚:读不到时用的是兜底候选,不能让人以为"本项目就这些" + self.note = hint("") + form.addRow("", self.note) + + self.root_edit = QLineEdit(str(settings.value("issue/root", str(Path.cwd())))) + self.root_edit.setPlaceholderText("项目根目录(用于自动列出插件)") + # 对话框里省掉这一行(show_root=False):项目根是签发页的设置,两个地方都能改 + # 反而容易改岔;宽度紧张时优先留给操作本身 + if show_root: + reload_btn = btn("重新加载插件列表", icon_name="refresh") + reload_btn.clicked.connect(self.reload) + form.addRow("项目根", hbox(self.root_edit, reload_btn)) + + self.list.itemChanged.connect(self._on_item_changed) + self.reload() + + # ------------------------------------------------------------------ + # 读 + # ------------------------------------------------------------------ + def checked(self) -> list[str]: + """当前勾选的插件 type(按列表顺序)。""" + return [ + self.list.item(i).text() + for i in range(self.list.count()) + if self.list.item(i).checkState() == Qt.CheckState.Checked + ] + + def types(self) -> list[str]: + """列表里的全部插件 type(含未勾选)。""" + return [self.list.item(i).text() for i in range(self.list.count())] + + # ------------------------------------------------------------------ + # 写 + # ------------------------------------------------------------------ + def set_checked(self, types: Iterable[str]) -> None: + """按给定白名单设置勾选(不在其中的一律取消勾选)。""" + wanted = {str(t).strip() for t in types} + self._bulk(lambda item: item.setCheckState( + Qt.CheckState.Checked if item.text() in wanted else Qt.CheckState.Unchecked + )) + + def ensure_types(self, types: Iterable[str]) -> None: + """把给定 type 补进列表(即使项目里已经删掉了)。 + + 台账里的老白名单必须始终看得见:否则改插件时"原来授权了什么"就没处对照。 + """ + added = False + existing = set(self.types()) + self.list.blockSignals(True) + try: + for raw in types: + name = str(raw).strip() + if name and name not in existing: + self.list.addItem(self._make_item(name)) + existing.add(name) + added = True + finally: + self.list.blockSignals(False) + if added: + self.changed.emit() + + def set_all(self, checked: bool) -> None: + state = Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked + self._bulk(lambda item: item.setCheckState(state)) + + def add_from_input(self) -> None: + name = self.input.text().strip() + if not name: + return + if name in self.types(): + self._notify("已存在", f"{name} 已在列表中。") + self.input.clear() + return + item = self._make_item(name) + item.setCheckState(Qt.CheckState.Checked) + self.list.addItem(item) + self.input.clear() + self.changed.emit() + + def remove_selected(self) -> None: + if not self.list.selectedItems(): + return + for item in self.list.selectedItems(): + self.list.takeItem(self.list.row(item)) + self.changed.emit() + + def reload(self) -> None: + """按项目根重新发现插件类型;已勾选的项保留,避免重载把选择清掉。""" + root = self.root_edit.text().strip() or "." + found = discover_plugin_types(root) + note = "" + if not found: + found = list(FALLBACK_PLUGIN_TYPES) + note = f"(未在 {root}/config/workers 找到插件,已用常见候选)" + + checked = set(self.checked()) + # 重载只换"可选清单",不动已勾选状态;手工补录过的项单独留在列表里 + manual = [t for t in self.types() if t not in found and t in checked] + + self.list.blockSignals(True) + try: + self.list.clear() + for ptype in [*found, *manual]: + item = self._make_item(ptype) + item.setCheckState( + Qt.CheckState.Checked if ptype in checked else Qt.CheckState.Unchecked + ) + self.list.addItem(item) + finally: + self.list.blockSignals(False) + + if note: + set_status(self.note, f"⚠ {note}", "warn") + else: + set_status( + self.note, + f"已从 {root}/config/workers 读出 {len(found)} 个插件类型", + "hint", + ) + self.settings.setValue("issue/root", root) + self.changed.emit() + + # ------------------------------------------------------------------ + # 内部 + # ------------------------------------------------------------------ + @staticmethod + def _make_item(name: str) -> QListWidgetItem: + item = QListWidgetItem(name) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + return item + + def _bulk(self, apply: Callable[[QListWidgetItem], None]) -> None: + """批量改勾选状态:只发一次 changed,避免逐项重算差异。""" + self.list.blockSignals(True) + try: + for i in range(self.list.count()): + apply(self.list.item(i)) + finally: + self.list.blockSignals(False) + self.changed.emit() + + def _on_item_changed(self, _item: QListWidgetItem) -> None: + self.changed.emit() diff --git a/src/chongming_license_gui/registry_store.py b/src/chongming_license_gui/registry_store.py index ea5e9af..1c93135 100644 --- a/src/chongming_license_gui/registry_store.py +++ b/src/chongming_license_gui/registry_store.py @@ -6,6 +6,9 @@ 未显式打开时按"签发即入库"的原则自动打开默认库:宁可多一个文件, 也不要让签出去的授权因为"忘了记"而从此查不到。 + +**两种后端**:本地 SQLite(默认,单文件)与共享 PostgreSQL(可选,见 +:mod:`pg_store`)。两者接口一致,因此签发页/台账页完全不需要知道用的是哪一个。 """ from __future__ import annotations @@ -20,15 +23,23 @@ from chongming_license import ( default_registry_path, ) +from .pg_store import PgConfig, PostgresRegistry + +BACKEND_SQLITE = "sqlite" +BACKEND_POSTGRES = "postgres" +# 下拉框用:(值, 显示文案) +BACKEND_LABELS = ((BACKEND_SQLITE, "本地 SQLite"), (BACKEND_POSTGRES, "PostgreSQL")) + class RegistryHolder(QObject): - """当前台账库。""" + """当前台账库(本地 SQLite 或共享 PostgreSQL)。""" changed = Signal() def __init__(self) -> None: super().__init__() self._registry: Registry | None = None + self._backend: str = BACKEND_SQLITE # ---- 状态 ---- @property @@ -36,7 +47,13 @@ class RegistryHolder(QObject): return self._registry is not None @property - def path(self) -> Path | None: + def backend(self) -> str: + """当前后端(:data:`BACKEND_SQLITE` / :data:`BACKEND_POSTGRES`)。""" + return self._backend + + @property + def path(self) -> Path | str | None: + """库标识:本地库是文件路径,PostgreSQL 是连接串(**不含密码**)。""" return self._registry.path if self._registry else None @property @@ -45,25 +62,71 @@ class RegistryHolder(QObject): # ---- 操作 ---- def open(self, path: str | Path) -> Registry: - """打开(或新建)台账库;失败抛 :class:`RegistryError`。 + """打开(或新建)本地 SQLite 台账库;失败抛 :class:`RegistryError`。 切换路径时先关掉旧连接,避免句柄泄漏。 """ target = Path(path).expanduser() - if self._registry is not None and self._registry.path == target: + if ( + self._registry is not None + and self._backend == BACKEND_SQLITE + and self._registry.path == target + ): return self._registry new_reg = Registry(target) # 先构造,失败则保留旧连接 if self._registry is not None: self._registry.close() self._registry = new_reg + self._backend = BACKEND_SQLITE + self.changed.emit() + return new_reg + + def open_postgres(self, config: PgConfig, *, password: str | None = None) -> Registry: + """连接共享 PostgreSQL 台账库;失败抛 :class:`RegistryError`。 + + 同样是先构造再替换:连不上时不会把已经打开的本地库弄丢。 + """ + new_reg = PostgresRegistry(config, password=password) + if self._registry is not None: + self._registry.close() + self._registry = new_reg + self._backend = BACKEND_POSTGRES self.changed.emit() return new_reg def ensure_open(self) -> Registry: - """确保可用:未打开时打开默认库(签发页"签发即入库"用)。""" - if self._registry is None: - return self.open(default_registry_path()) - return self._registry + """确保可用:未打开时按当前后端自动打开(签发页"签发即入库"用)。 + + * 本地后端:打开默认库; + * PostgreSQL:密码已记住就自动连;否则抛错让用户去台账页连接 —— + 刻意**不在签发流程中途弹密码框**,那比"这批没记上台账"更打断人。 + """ + if self._registry is not None: + return self._registry + if self._backend == BACKEND_POSTGRES: + raise RegistryError( + "PostgreSQL 台账未连接:请到「台账」页点「打开 / 新建」先连接;" + "或在「连接设置…」里勾选「记住密码」,之后可自动连接。" + ) + return self.open(default_registry_path()) + + def select_backend(self, backend: str) -> None: + """记住用户选择的后端(可以不马上连接)。 + + 切换后端时**必须**调用:否则 ``ensure_open()`` 会按**旧**后端去开库 —— + 用户明明切回了本地库,签发时却写进了 PostgreSQL(或反过来)。 + + 已经打开的连接若与目标后端不一致,就地关掉,不留下"界面显示 A、实际写 B"。 + """ + if backend not in (BACKEND_SQLITE, BACKEND_POSTGRES): + return + if self._registry is not None: + is_pg = isinstance(self._registry, PostgresRegistry) + if (backend == BACKEND_POSTGRES) != is_pg: + self._registry.close() + self._registry = None + self._backend = backend + self.changed.emit() def close(self) -> None: if self._registry is not None: @@ -84,4 +147,11 @@ class RegistryHolder(QObject): return self._registry.find_by_machine_code(machine_code) -__all__ = ["RegistryHolder", "RegistryError", "default_registry_path"] +__all__ = [ + "RegistryHolder", + "RegistryError", + "default_registry_path", + "BACKEND_SQLITE", + "BACKEND_POSTGRES", + "BACKEND_LABELS", +] diff --git a/src/chongming_license_gui/registry_tab.py b/src/chongming_license_gui/registry_tab.py index 8792a6d..083e493 100644 --- a/src/chongming_license_gui/registry_tab.py +++ b/src/chongming_license_gui/registry_tab.py @@ -9,6 +9,7 @@ import json from pathlib import Path from PySide6.QtCore import QDate, QSettings, Qt +from PySide6.QtGui import QColor from PySide6.QtWidgets import ( QAbstractItemView, QButtonGroup, @@ -17,11 +18,12 @@ from PySide6.QtWidgets import ( QDialog, QDialogButtonBox, QFileDialog, + QHBoxLayout, + QHeaderView, QInputDialog, QLabel, QLineEdit, QPlainTextEdit, - QPushButton, QRadioButton, QSpinBox, QTableWidget, @@ -39,16 +41,30 @@ from chongming_license import ( from .common import ( ask_overwrite, + btn, copy_to_clipboard, error, + flash, + hint, hbox, info, make_mono_label, + mono_font, section, + set_status, warn, ) from .keystore import SigningKey -from .registry_store import RegistryHolder +from .pg_dialog import PgSettingsDialog +from .pg_store import PgConfig +from .plugin_picker import PluginPicker +from .registry_store import ( + BACKEND_LABELS, + BACKEND_POSTGRES, + BACKEND_SQLITE, + RegistryHolder, +) +from . import theme STATUS_CHOICES = [ ("", "全部状态"), @@ -59,14 +75,8 @@ STATUS_CHOICES = [ ("not_yet_valid", "未生效"), ] -# 状态 -> 前景色(表格里一眼分辨急缓) -STATUS_COLORS = { - "expired": "#c62828", - "expiring": "#e65100", - "active": "#2e7d32", - "forever": "#1565c0", - "not_yet_valid": "#6a1b9a", -} +# 状态 -> 前景色由 theme 统一管理(表格里一眼分辨急缓) +status_color = theme.status_color class RenewDialog(QDialog): @@ -76,9 +86,11 @@ class RenewDialog(QDialog): super().__init__(parent) self.record = record self.setWindowTitle(f"续期 {record.lic_id}") - self.setMinimumWidth(560) + self.setMinimumWidth(620) root = QVBoxLayout(self) + root.setContentsMargins(16, 16, 16, 16) + root.setSpacing(12) # 原授权信息(只读,避免操作者误以为改这里能改历史记录) src_box, src_form = section("原授权") @@ -93,9 +105,9 @@ class RenewDialog(QDialog): ) src_form.addRow( "指纹来源", - QLabel(f"{len(record.sources)} 个:{', '.join(sorted(record.sources)) or '无'}\n" - "✓ 续期直接复用这些指纹,无需向客户索取申请码"), + QLabel(f"{len(record.sources)} 个:{', '.join(sorted(record.sources)) or '无'}"), ) + src_form.addRow("", hint("✓ 续期直接复用这些指纹,无需向客户索取申请码", role="ok")) root.addWidget(src_box) # 新授权 @@ -148,7 +160,9 @@ class RenewDialog(QDialog): buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) - buttons.button(QDialogButtonBox.StandardButton.Ok).setText("续期并保存…") + ok_button = buttons.button(QDialogButtonBox.StandardButton.Ok) + ok_button.setText("续期并保存…") + ok_button.setProperty("role", "primary") buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) root.addWidget(buttons) @@ -170,6 +184,125 @@ class RenewDialog(QDialog): } +class ChangePluginsDialog(QDialog): + """改插件对话框:只改插件白名单,有效期与生效日原样保留。 + + 授权文件是签名过的,改白名单只能重签一份新的;这里刻意不提供有效期选项 —— + 要延长有效期请走「续期选中…」,免得"只想加个插件"顺手把到期日也改了。 + """ + + def __init__(self, record: LicenseRecord, settings: QSettings, parent=None) -> None: + super().__init__(parent) + self.record = record + self.setWindowTitle(f"改授权插件 {record.lic_id}") + self.setMinimumWidth(620) + + root = QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(10) + + # 原授权摘要(只读)与勾选列表同在一张卡里:这个对话框只干"换白名单"一件事, + # 排版压紧一点 —— 640px 出头的对话框在 768 高的笔记本屏上才放得下 + plugin_box, plugin_form = section("插件白名单") + self._plugin_form = plugin_form + plugin_form.addRow("原授权", make_mono_label("\n".join([ + f"{record.lic_id} {record.customer or '(未填)'}", + record.machine_code, + f"{record.not_before or '立即生效'} → {record.expires_at or '永久'}" + f"({record.status_label()})· 指纹 {len(record.sources)} 个,重签直接复用", + ]), size=10)) + + self.plugin_picker = PluginPicker( + plugin_form, settings, list_height=96, show_root=False, + notify=lambda title, msg: warn(self, title, msg), + ) + # 原白名单必须始终留在列表里:项目里已经把插件删掉时,不补进来就既看不到 + # "原来授权了什么",也没法再勾回去 + self.plugin_picker.ensure_types(record.plugins) + self.plugin_picker.set_checked(record.plugins) + self.plugin_picker.changed.connect(self._refresh_diff) + + self.diff_label = hint("") + self.remove_hint = hint( + "⚠ 客户导入新文件后,被移除的插件会被拒载(plugin_not_licensed)。" + "旧 .lic 在它自己的到期日之前仍然有效,无法远程收回。", + role="warn", + ) + # 差异行紧跟勾选列表:改完白名单第一眼要看的正是"比原来变了什么" + list_row, _role = plugin_form.getWidgetPosition(self.plugin_picker.list) + plugin_form.insertRow(list_row + 1, "变更", self.diff_label) + plugin_form.insertRow(list_row + 2, "", self.remove_hint) + plugin_form.setRowVisible(self.remove_hint, False) + root.addWidget(plugin_box) + + # 新授权:插件之外的部分默认不动 + new_box, new_form = section("新授权") + # 属性名按"选项语义"命名(rb_* 后面跟的是该选项的语义),不要跟标签反着来 + self.rb_new_id = QRadioButton("生成新授权编号(推荐:新旧都留台账)") + self.rb_new_id.setChecked(True) + self.rb_keep_id = QRadioButton("沿用原授权编号") + g = QButtonGroup(self) + g.addButton(self.rb_new_id) + g.addButton(self.rb_keep_id) + new_form.addRow("编号", hbox(self.rb_new_id, self.rb_keep_id)) + + self.customer_edit = QLineEdit(record.customer) + new_form.addRow("客户", self.customer_edit) + + self.note_edit = QLineEdit(record.note) + new_form.addRow("备注", self.note_edit) + + new_form.addRow("", hint( + "有效期与生效日原样保留,要延长请用「续期选中…」;" + "沿用原编号会替换台账里那份原始记录(审计只剩新版)。" + )) + root.addWidget(new_box) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + ok_button = buttons.button(QDialogButtonBox.StandardButton.Ok) + ok_button.setText("重签并保存…") + ok_button.setProperty("role", "primary") + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + root.addWidget(buttons) + + self._refresh_diff() + + def accept(self) -> None: + # 一份不授权任何插件的 license 没有意义,库也会拒绝 + if not self.plugin_picker.checked(): + warn(self, "未勾选插件", "请至少勾选一个授权插件。") + return + super().accept() + + def _refresh_diff(self) -> None: + """实时列出"比原来多了什么、少了什么"—— 少掉的那些客户导入后会被拒载。""" + checked = self.plugin_picker.checked() + added = [p for p in checked if p not in self.record.plugins] + removed = [p for p in self.record.plugins if p not in checked] + + if not added and not removed: + set_status(self.diff_label, "与原来一致", "hint") + else: + parts = [] + if added: + parts.append("新增:" + "、".join(added)) + if removed: + parts.append("移除:" + "、".join(removed)) + set_status(self.diff_label, ";".join(parts), "warn" if removed else "ok") + self._plugin_form.setRowVisible(self.remove_hint, bool(removed)) + + def values(self) -> dict: + return { + "plugins": self.plugin_picker.checked(), + "customer": self.customer_edit.text().strip(), + "note": self.note_edit.text().strip(), + "keep_lic_id": self.rb_keep_id.isChecked(), + } + + class RegistryTab(QWidget): """台账页。""" @@ -182,22 +315,51 @@ class RegistryTab(QWidget): self.records: list[LicenseRecord] = [] root = QVBoxLayout(self) + root.setContentsMargins(14, 14, 14, 14) + root.setSpacing(12) # ---------- 台账库 ---------- db_box, db_form = section("台账库") + self._db_form = db_form # 切换后端时用它隐藏/显示整行 + + # 存储后端:本地 SQLite(默认)或共享 PostgreSQL。 + # 两者的连接方式完全不同,因此下面的「路径」/「连接」两行按后端切换显示 + # (用 setRowVisible 而不是 QStackedWidget:换行不影响表单标签对齐)。 + self.backend_combo = QComboBox() + for value, label in BACKEND_LABELS: + self.backend_combo.addItem(label, value) + saved_backend = str(settings.value("registry/backend", BACKEND_SQLITE) or "") + saved_index = self.backend_combo.findData(saved_backend) + self.backend_combo.setCurrentIndex(saved_index if saved_index >= 0 else 0) + self.backend_combo.setToolTip( + "本机单文件台账,或团队共用的 PostgreSQL 台账(需自行准备服务器与账号)" + ) + db_form.addRow("存储", self.backend_combo) + self.db_edit = QLineEdit( str(settings.value("registry/path", str(default_registry_path()))) ) - open_btn = QPushButton("打开 / 新建") - open_btn.setFixedWidth(110) - open_btn.clicked.connect(self._open_db) - browse_btn = QPushButton("浏览…") - browse_btn.setFixedWidth(72) - browse_btn.clicked.connect(self._pick_db) - db_form.addRow("路径", hbox(self.db_edit, browse_btn, open_btn)) + open_button = btn("打开 / 新建", icon_name="open", min_width=112) + open_button.clicked.connect(self._open_db) + browse_button = btn("浏览…", icon_name="folder") + browse_button.clicked.connect(self._pick_db) + self.path_row = hbox(self.db_edit, browse_button, open_button) + db_form.addRow("路径", self.path_row) + + # 未勾选"记住密码"时,密码只在本次运行的内存里(不落配置) + self._pg_session_password = "" + self.pg_summary = QLabel("") + self.pg_summary.setWordWrap(True) + pg_settings_button = btn("连接设置…", icon_name="lock") + pg_settings_button.clicked.connect(self._edit_pg_settings) + self.pg_connect_button = btn("打开 / 新建", icon_name="open", min_width=112) + self.pg_connect_button.clicked.connect(self._open_db) + self.conn_row = hbox(self.pg_summary, pg_settings_button, self.pg_connect_button) + db_form.addRow("连接", self.conn_row) self.stats_label = QLabel("未打开台账库") self.stats_label.setWordWrap(True) + self.stats_label.setProperty("role", "stats") db_form.addRow("概览", self.stats_label) self.key_label = QLabel("—") @@ -212,6 +374,7 @@ class RegistryTab(QWidget): self.customer_edit.setPlaceholderText("客户名(模糊)") self.machine_edit = QLineEdit() self.machine_edit.setPlaceholderText("机器码(模糊,大小写不敏感)") + self.machine_edit.setFont(mono_font(10)) self.plugin_edit = QLineEdit() self.plugin_edit.setPlaceholderText("插件 type(精确)") self.keyword_edit = QLineEdit() @@ -229,13 +392,13 @@ class RegistryTab(QWidget): self.days_spin.setToolTip("「即将到期」的判定窗口") filter_form.addRow("状态", hbox(self.status_combo, self.days_spin)) - query_btn = QPushButton("查询") - query_btn.clicked.connect(self.refresh) - reset_btn = QPushButton("重置") - reset_btn.clicked.connect(self._reset_filters) - short_btn = QPushButton("只看即将到期") - short_btn.clicked.connect(self._filter_expiring) - filter_form.addRow("", hbox(query_btn, reset_btn, short_btn)) + query_button = btn("查询", role="primary", icon_name="search", min_width=96) + query_button.clicked.connect(self.refresh) + reset_button = btn("重置", icon_name="reset", min_width=88) + reset_button.clicked.connect(self._reset_filters) + short_button = btn("只看即将到期", icon_name="alert") + short_button.clicked.connect(self._filter_expiring) + filter_form.addRow("", hbox(query_button, reset_button, short_button)) root.addWidget(filter_box) # ---------- 列表 ---------- @@ -249,30 +412,52 @@ class RegistryTab(QWidget): self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.table.verticalHeader().setVisible(False) self.table.setMinimumHeight(260) - widths = [170, 130, 250, 200, 110, 100, 70, 180, 110] + # 斑马纹 + 去网格线:几百条记录时比横竖线更好扫读 + self.table.setAlternatingRowColors(True) + self.table.setShowGrid(False) + self.table.setWordWrap(False) + self.table.setTextElideMode(Qt.TextElideMode.ElideRight) + self.table.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.table.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + + header = self.table.horizontalHeader() + header.setHighlightSections(False) + widths = [170, 130, 250, 200, 112, 100, 70, 180, 110] for i, w in enumerate(widths): self.table.setColumnWidth(i, w) + header.setSectionResizeMode(i, QHeaderView.ResizeMode.Interactive) + # 备注列吃掉多余宽度:窗口拉宽时不必手工拖列 + header.setSectionResizeMode(7, QHeaderView.ResizeMode.Stretch) table_form.addRow("", self.table) - renew_btn = QPushButton("续期选中…") - renew_btn.clicked.connect(self._renew) - note_btn = QPushButton("编辑备注") - note_btn.clicked.connect(self._edit_note) - copy_btn = QPushButton("复制机器码") - copy_btn.clicked.connect(self._copy_machine_code) - detail_btn = QPushButton("查看详情") - detail_btn.clicked.connect(self._show_detail) - del_btn = QPushButton("删除记录") - del_btn.clicked.connect(self._delete) - table_form.addRow("", hbox(renew_btn, note_btn, copy_btn, detail_btn, del_btn)) + renew_button = btn("续期选中…", role="primary", icon_name="refresh", min_width=112) + renew_button.clicked.connect(self._renew) + plugins_button = btn("改插件…", icon_name="select_all", min_width=96) + plugins_button.clicked.connect(self._change_plugins) + note_button = btn("编辑备注", icon_name="edit", min_width=100) + note_button.clicked.connect(self._edit_note) + copy_button = btn("复制机器码", icon_name="copy", min_width=112) + copy_button.clicked.connect(self._copy_machine_code) + detail_button = btn("查看详情", icon_name="search", min_width=100) + detail_button.clicked.connect(self._show_detail) + del_button = btn("删除记录", role="danger", icon_name="trash", min_width=100) + del_button.clicked.connect(self._delete) + table_form.addRow( + "", + hbox(renew_button, plugins_button, note_button, copy_button, + detail_button, del_button), + ) + table_form.addRow("", hint( + "续期默认生成新编号并复用台账里的机器指纹 —— 无需再向客户索取申请码。" + "「改插件」只动插件白名单,有效期与生效日原样保留。" + )) root.addWidget(table_box) # ---------- 导出 ---------- exp_box, exp_form = section("导出归档") self.out_edit = QLineEdit(str(settings.value("registry/outdir", ""))) self.out_edit.setPlaceholderText("导出目录(导出当前筛选结果)") - out_browse = QPushButton("浏览…") - out_browse.setFixedWidth(72) + out_browse = btn("浏览…", icon_name="folder") out_browse.clicked.connect(self._pick_outdir) exp_form.addRow("目录", hbox(self.out_edit, out_browse)) @@ -281,12 +466,12 @@ class RegistryTab(QWidget): self.by_customer.addItem("按客户分子目录", True) exp_form.addRow("组织方式", self.by_customer) - lic_btn = QPushButton("导出 .lic") - lic_btn.clicked.connect(self._export_licenses) - csv_btn = QPushButton("导出 CSV 清单") - csv_btn.clicked.connect(self._export_csv) - exp_form.addRow("", hbox(lic_btn, csv_btn)) - exp_form.addRow("", QLabel( + lic_button = btn("导出 .lic", icon_name="export", min_width=112) + lic_button.clicked.connect(self._export_licenses) + csv_button = btn("导出 CSV 清单", icon_name="export", min_width=132) + csv_button.clicked.connect(self._export_csv) + exp_form.addRow("", hbox(lic_button, csv_button)) + exp_form.addRow("", hint( "CSV 以 UTF-8 BOM 写入,Excel 双击打开中文不乱码;字段可含逗号(已按 CSV 规范转义)。" )) root.addWidget(exp_box) @@ -294,11 +479,19 @@ class RegistryTab(QWidget): self.holder.changed.connect(self._sync_holder) self.key.changed.connect(self._sync_key) + # 信号在控件都建好之后才接:否则上面的 setCurrentIndex 会提前触发 + # _on_backend_changed,那时 db_edit/conn_row 还不存在 + self.backend_combo.currentIndexChanged.connect(self._on_backend_changed) self._sync_key() self._sync_holder() + self._sync_backend_rows() + self._refresh_pg_summary() - # 默认路径已存在就直接打开,省一步点击 - if Path(self.db_edit.text()).expanduser().is_file(): + # 默认路径已存在就直接打开,省一步点击。 + # PostgreSQL 不在启动时自动连:连接最长要等 5 秒(连不上时更久), + # 不该让工具启动就卡一下 —— 需要时(点「打开 / 新建」或签发入库)再连。 + if self.backend_combo.currentData() == BACKEND_SQLITE \ + and Path(self.db_edit.text()).expanduser().is_file(): self._open_db(silent=True) # ------------------------------------------------------------------ @@ -314,9 +507,40 @@ class RegistryTab(QWidget): def _sync_holder(self) -> None: if self.holder.is_open: - self.stats_label.setText(f"已打开:{self.holder.path}(点「查询」刷新)") + kind = "PostgreSQL" if self.holder.backend == BACKEND_POSTGRES else "本地 SQLite" + set_status( + self.stats_label, + f"已打开({kind}):{self.holder.path}(点「查询」刷新)", + "stats", + ) + elif self.backend_combo.currentData() == BACKEND_POSTGRES: + set_status( + self.stats_label, + "已选择 PostgreSQL,尚未连接 —— 点「打开 / 新建」连接", + "stats", + ) else: - self.stats_label.setText("未打开台账库") + set_status(self.stats_label, "未打开台账库", "stats") + + def _sync_backend_rows(self) -> None: + """按当前后端显示对应的那一行连接设置。""" + is_pg = self.backend_combo.currentData() == BACKEND_POSTGRES + self._db_form.setRowVisible(self.path_row, not is_pg) + self._db_form.setRowVisible(self.conn_row, is_pg) + + def _refresh_pg_summary(self) -> None: + """连接摘要行:显示连接串与"密码是否会被记住",**绝不显示密码本身**。""" + config = self._pg_config() + if not config.dbname: + set_status(self.pg_summary, "尚未配置(点「连接设置…」填写)", "hint") + return + if config.password: + kept = "密码已记住" + elif self._pg_session_password: + kept = "密码仅本次运行有效" + else: + kept = "未输入密码" + set_status(self.pg_summary, f"{config.display()} · {kept}", "hint") # ------------------------------------------------------------------ # 台账库 @@ -330,7 +554,45 @@ class RegistryTab(QWidget): self.db_edit.setText(path) self._open_db() + def _on_backend_changed(self, _index: int = 0) -> None: + backend = str(self.backend_combo.currentData() or BACKEND_SQLITE) + self.settings.setValue("registry/backend", backend) + self._sync_backend_rows() + self._refresh_pg_summary() + # 换后端要同时告诉 holder:否则"确保可用"时仍按旧后端开库, + # 用户切回本地库后签发却写进了 PostgreSQL(最容易误判的一类问题) + self.holder.select_backend(backend) + if backend == BACKEND_SQLITE and Path(self.db_edit.text()).expanduser().is_file(): + self._open_db(silent=True) + else: + self.records = [] + self.table.setRowCount(0) + self._sync_holder() + + def _pg_config(self) -> PgConfig: + return PgConfig.from_settings(self.settings) + + def _edit_pg_settings(self) -> None: + """打开连接设置对话框;确认后保存配置并立即尝试连接。""" + remember = bool(self.settings.value("pg/remember_password", False, type=bool)) + dialog = PgSettingsDialog(self._pg_config(), remember_password=remember, parent=self) + if dialog.exec() != QDialog.DialogCode.Accepted: + return + config, remember = dialog.values() + config.save(self.settings, remember_password=remember) + # 未勾选"记住密码"时,密码只留在本次运行的内存里 + self._pg_session_password = "" if remember else config.password + self._refresh_pg_summary() + self._connect_postgres() + def _open_db(self, _checked: bool = False, silent: bool = False) -> None: + """「打开 / 新建」统一入口:按当前后端分发。""" + if self.backend_combo.currentData() == BACKEND_POSTGRES: + self._connect_postgres(silent=silent) + else: + self._open_sqlite(silent=silent) + + def _open_sqlite(self, *, silent: bool = False) -> None: raw = self.db_edit.text().strip() if not raw: if not silent: @@ -339,14 +601,41 @@ class RegistryTab(QWidget): try: self.holder.open(raw) except Exception as exc: # noqa: BLE001 - 路径/权限/结构版本都可能失败 - self.stats_label.setText(f"✗ 打开失败:{exc}") + set_status(self.stats_label, f"✗ 打开失败:{exc}", "stats-error") if not silent: error(self, "打开台账库失败", f"{exc}") return self.settings.setValue("registry/path", raw) - # 首次新建的库是空的,提示一下避免以为"数据没了"。 - # 注意这里**不能**用 `with self.holder.registry:` —— Registry 的 __exit__ - # 会关闭连接,而这条连接由 holder 长期持有(with 是"用完即关"语义)。 + self._after_open(silent=silent) + + def _connect_postgres(self, *, silent: bool = False) -> None: + config = self._pg_config() + if not config.dbname: + if not silent: + # 还没配过连接:直接打开设置对话框,省得用户自己找入口 + self._edit_pg_settings() + return + password = config.password or self._pg_session_password + if not password and not silent: + # 密码没记住也没输入过 → 打开设置对话框补输入 + self._edit_pg_settings() + return + try: + self.holder.open_postgres(config, password=password) + except Exception as exc: # noqa: BLE001 - 网络/认证/权限/建表都可能失败 + set_status(self.stats_label, f"✗ 连接失败:{exc}", "stats-error") + if not silent: + error(self, "连接 PostgreSQL 失败", f"{exc}") + return + self.settings.setValue("registry/backend", BACKEND_POSTGRES) + self._after_open(silent=silent) + + def _after_open(self, *, silent: bool) -> None: + """两种后端共用的"打开成功之后":空库提示 + 刷新列表。 + + 注意这里**不能**用 `with self.holder.registry:` —— Registry 的 __exit__ + 会关闭连接,而这条连接由 holder 长期持有(with 是"用完即关"语义)。 + """ try: st = self.holder.registry.stats(expiring_days=self.days_spin.value()) except Exception as exc: # noqa: BLE001 @@ -401,6 +690,7 @@ class RegistryTab(QWidget): f"已过期 {st['expired']}、永久 {st['forever']}、未生效 {st['not_yet_valid']}" f";当前筛选出 {len(self.records)} 条" ) + theme.set_role(self.stats_label, "stats") self._fill_table() def _fill_table(self) -> None: @@ -421,12 +711,17 @@ class RegistryTab(QWidget): ] for col, text in enumerate(cells): item = QTableWidgetItem(text) - if col in (4,): - color = STATUS_COLORS.get(rec.status(), "#666666") - item.setForeground(Qt.GlobalColor.black) - from PySide6.QtGui import QColor - - item.setForeground(QColor(color)) + if col == 4: + # 急缓一列一眼分辨:状态色 + 加粗(文本保持原样,仅改呈现) + item.setForeground(QColor(status_color(rec.status()))) + font = item.font() + font.setBold(True) + item.setFont(font) + elif col in (2, 8): + # 机器码 / 公钥指纹:等宽才方便逐字符核对 + item.setFont(mono_font(10)) + elif col == 7: + item.setToolTip(text) self.table.setItem(row, col, item) def _selected_records(self) -> list[LicenseRecord]: @@ -454,46 +749,23 @@ class RegistryTab(QWidget): return vals = dlg.values() - default_dir = self.settings.value("registry/outdir", str(Path.cwd())) - out_path, _ = QFileDialog.getSaveFileName( - self, "保存续期后的 license", - str(Path(str(default_dir)) / f"license_{rec.lic_id}.lic"), - "License 文件 (*.lic);;所有文件 (*)", + result = self._resign_and_save( + rec, + plugins=vals["plugins"], + customer=vals["customer"], + note=vals["note"], + not_before=None, # 续期是"从现在起重新计时",生效日不沿用 + expires_at=vals["expires_at"], + keep_lic_id=vals["keep_lic_id"], + source="renew", + save_title="保存续期后的 license", + default_name=f"license_{rec.lic_id}.lic", + note_link="已续期", + fail_title="续期失败", ) - if not out_path: + if result is None: return - target = Path(out_path) - if target.exists() and not ask_overwrite(self, target): - return - - from chongming_license import build_license, build_payload, write_license - - try: - payload = build_payload( - machine_code=rec.machine_code, - plugins=vals["plugins"], - sources=rec.sources, # 复用台账指纹:续期的关键 - customer=vals["customer"], - note=vals["note"], - expires_at=vals["expires_at"], - ) - if vals["keep_lic_id"]: - payload["lic_id"] = rec.lic_id - doc = build_license(payload, self.key.key, kid=rec.kid) - write_license(doc, target) - - reg = self.holder.ensure_open() - new_rec = reg.save(doc, public_key_fp=self.key.fingerprint, source="renew") - if not vals["keep_lic_id"]: - link = f"已续期 → {new_rec.lic_id}" - if link not in rec.note: - reg.update_note(rec.lic_id, (rec.note + ";" if rec.note else "") + link) - except Exception as exc: # noqa: BLE001 - error(self, "续期失败", f"{exc}") - return - - self.settings.setValue("registry/outdir", str(target.parent)) - self.refresh() + target, new_rec = result info( self, "已续期", f"{target}\n\n" @@ -503,6 +775,132 @@ class RegistryTab(QWidget): "把新 .lic 交给客户,在设备端「授权」页导入后重启网关生效。", ) + def _change_plugins(self) -> None: + selected = self._selected_records() + if not selected: + warn(self, "未选中", "请先在列表里选中一条要改插件的授权。") + return + if len(selected) > 1: + warn(self, "一次一条", "改插件请只选中一条(需要确认新的插件白名单)。") + return + if not self.key.loaded: + error(self, "未加载私钥", "改插件要重新签发授权,请先到「密钥」页加载签名私钥。") + return + + rec = selected[0] + dlg = ChangePluginsDialog(rec, self.settings, self) + if dlg.exec() != QDialog.DialogCode.Accepted: + return + vals = dlg.values() + + if (vals["plugins"] == list(rec.plugins) + and vals["customer"] == rec.customer and vals["note"] == rec.note): + # 白名单和描述都没动:重签只会多出一份内容相同的新文件,白占一条台账 + warn(self, "没有任何变化", "插件白名单与客户/备注都没改,无需重签。") + return + + result = self._resign_and_save( + rec, + plugins=vals["plugins"], + customer=vals["customer"], + note=vals["note"], + not_before=rec.not_before, # 改插件不动有效期:两个日期逐字写回 + expires_at=rec.expires_at, + keep_lic_id=vals["keep_lic_id"], + source="plugins", + save_title="保存改插件后的 license", + default_name=f"license_{rec.lic_id}.lic", + note_link="已变更插件", + fail_title="重签失败", + ) + if result is None: + return + target, new_rec = result + + added = [p for p in vals["plugins"] if p not in rec.plugins] + removed = [p for p in rec.plugins if p not in vals["plugins"]] + diff = [] + if added: + diff.append(f"新增:{'、'.join(added)}") + if removed: + diff.append(f"移除:{'、'.join(removed)}") + info( + self, "已改插件", + f"{target}\n\n" + f"新授权:{new_rec.lic_id}\n" + f"有效期不变:{new_rec.not_before or '立即生效'} → " + f"{new_rec.expires_at or '永久'}\n" + f"白名单:{', '.join(vals['plugins'])}\n" + + ("\n".join(diff) + "\n" if diff else "") + + f"\n指纹来源:复用台账里的 {len(rec.sources)} 个来源。\n\n" + "把新 .lic 交给客户导入后生效;旧文件在它自己的到期日之前仍然有效," + "没有远程吊销,**只能靠客户导入新文件覆盖**。", + ) + + def _resign_and_save( + self, + rec: LicenseRecord, + *, + plugins: list[str], + customer: str, + note: str, + not_before: str | None, + expires_at: str | None, + keep_lic_id: bool, + source: str, + save_title: str, + default_name: str, + note_link: str, + fail_title: str, + ) -> tuple[Path, LicenseRecord] | None: + """重签并入库的公共尾段(续期 / 改插件共用)。 + + 选路径 → 用台账里的指纹重签 → 写盘 → 入库 → 给旧记录留下追溯批注。 + 用户取消或失败返回 None(失败已提示,调用方不必再弹一次)。 + """ + default_dir = self.settings.value("registry/outdir", str(Path.cwd())) + out_path, _ = QFileDialog.getSaveFileName( + self, save_title, + str(Path(str(default_dir)) / default_name), + "License 文件 (*.lic);;所有文件 (*)", + ) + if not out_path: + return None + target = Path(out_path) + if target.exists() and not ask_overwrite(self, target): + return None + + from chongming_license import build_license, build_payload, write_license + + try: + payload = build_payload( + machine_code=rec.machine_code, + plugins=plugins, + sources=rec.sources, # 复用台账指纹:不必再向客户索取申请码 + customer=customer, + note=note, + not_before=not_before, + expires_at=expires_at, + ) + if keep_lic_id: + payload["lic_id"] = rec.lic_id + doc = build_license(payload, self.key.key, kid=rec.kid) + write_license(doc, target) + + reg = self.holder.ensure_open() + new_rec = reg.save(doc, public_key_fp=self.key.fingerprint, source=source) + if not keep_lic_id: + link = f"{note_link} → {new_rec.lic_id}" + if link not in rec.note: + reg.update_note(rec.lic_id, (rec.note + ";" if rec.note else "") + link) + except Exception as exc: # noqa: BLE001 + error(self, fail_title, f"{exc}") + return None + + self.settings.setValue("registry/outdir", str(target.parent)) + self.refresh() + return target, new_rec + def _edit_note(self) -> None: selected = self._selected_records() if len(selected) != 1: @@ -526,18 +924,17 @@ class RegistryTab(QWidget): return copy_to_clipboard(selected[0].machine_code, self) - def _show_detail(self) -> None: - selected = self._selected_records() - if len(selected) != 1: - warn(self, "未选中", "请选中一条记录。") - return - rec = selected[0] + def _build_detail_dialog(self, rec: LicenseRecord) -> QDialog: + """构造详情对话框(不 exec)—— 便于无模态地断言内容与布局。""" doc = rec.license_doc() dlg = QDialog(self) dlg.setWindowTitle(f"授权详情 {rec.lic_id}") - dlg.resize(760, 620) + dlg.resize(780, 660) lay = QVBoxLayout(dlg) + lay.setContentsMargins(16, 16, 16, 16) + lay.setSpacing(12) + summary_box, summary_form = section("授权摘要") summary = QPlainTextEdit() summary.setReadOnly(True) summary.setFont(make_mono_label().font()) @@ -560,20 +957,42 @@ class RegistryTab(QWidget): *[f" {k} = {v}" for k, v in sorted(rec.sources.items())], "" if rec.sources else " (无:当初仅按机器码精确匹配签发)", ])) - lay.addWidget(summary) + summary_form.addRow("", summary) + lay.addWidget(summary_box) + raw_box, raw_form = section("原始 license 文件内容(当初签发的那一份)") raw = QPlainTextEdit() raw.setReadOnly(True) raw.setFont(make_mono_label().font()) raw.setPlainText(json.dumps(doc, ensure_ascii=False, indent=2)) - lay.addWidget(QLabel("原始 license 文件内容:")) - lay.addWidget(raw) + raw_form.addRow("", raw) + lay.addWidget(raw_box, 1) - btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) - btns.rejected.connect(dlg.reject) - btns.accepted.connect(dlg.accept) - lay.addWidget(btns) - dlg.exec() + copy_button = btn("复制原始内容", icon_name="copy") + flash_label = QLabel("") + + def _copy_raw() -> None: + copy_to_clipboard(raw.toPlainText(), None) + flash(flash_label, "✓ 已复制到剪贴板") + + copy_button.clicked.connect(_copy_raw) + close_button = btn("关闭") + close_button.clicked.connect(dlg.accept) + actions = QHBoxLayout() + actions.setContentsMargins(0, 0, 0, 0) + actions.addWidget(copy_button) + actions.addWidget(flash_label) + actions.addStretch(1) + actions.addWidget(close_button) + lay.addLayout(actions) + return dlg + + def _show_detail(self) -> None: + selected = self._selected_records() + if len(selected) != 1: + warn(self, "未选中", "请选中一条记录。") + return + self._build_detail_dialog(selected[0]).exec() def _delete(self) -> None: selected = self._selected_records() diff --git a/src/chongming_license_gui/selftest.py b/src/chongming_license_gui/selftest.py new file mode 100644 index 0000000..0c707fa --- /dev/null +++ b/src/chongming_license_gui/selftest.py @@ -0,0 +1,290 @@ +"""冻结产物自检:验证**打包之后**仍能导入依赖、渲染图标、签名与校验。 + +打包最容易出问题的是"隐藏依赖" —— 开发环境里由 editable 安装、site-packages +或隐式插件提供的东西,冻结后可能整块缺失,而且**失败时往往不报错**: + +* 少了 QtSvg:图标全部空白(``QIcon`` 是空的,按钮上什么都不画); +* 少了 svg 图片格式插件:样式表里复选框指示器加载失败 → 勾选框变空白; +* 少了 cryptography 的 Ed25519 后端:签发时抛异常; +* 少了 orjson(编译扩展):申请码编解码直接不可用。 + +所以这里把上述每一点都变成一条可执行断言。用法:: + + chongming-license-gui --selftest [--selftest-report 报告文件] + +退出码 0 = 全部通过。打包脚本会在构建后自动调用它(并分别在 offscreen 与 +系统原生平台插件下各跑一次,后者才能验证平台插件真的被打进去了)。 + +自检**不碰真实数据**:QSettings 被重定向到临时 INI、台账库指向临时文件, +因此不会读写你自己的台账与配置。 +""" +from __future__ import annotations + +import os +import sys +import tempfile +from pathlib import Path + +from PySide6.QtCore import QSettings +from PySide6.QtWidgets import QApplication + + +class _Report: + """收集结论;窗口版 exe 没有 stdout,所以同时支持写文件。""" + + def __init__(self, report_path: str | None = None) -> None: + self.lines: list[str] = [] + self.failures: list[str] = [] + self.report = Path(report_path) if report_path else None + + def check(self, name: str, ok: bool, detail: str = "") -> bool: + line = f"[{'PASS' if ok else 'FAIL'}] {name}" + (f" — {detail}" if detail else "") + self.lines.append(line) + if not ok: + self.failures.append(name) + self.emit(line) + return ok + + def emit(self, text: str) -> None: + """写 stdout;窗口版(--noconsole)下 stdout 可能不存在,忽略即可。""" + try: + print(text, flush=True) + except Exception: # noqa: BLE001 + pass + if self.report is not None: + try: + with self.report.open("a", encoding="utf-8") as fh: + fh.write(text + "\n") + except Exception: # noqa: BLE001 + pass + + +def _isolate_settings() -> tuple[QSettings, Path]: + """把配置与台账隔离到临时目录,避免自检动到真实数据。 + + ``RegistryTab`` 初始化时会打开台账库(默认 ``~/.chongming/licenses.db``), + 而打开动作会执行 ``PRAGMA journal_mode=WAL`` 与建表语句 —— 那是**写操作**。 + 所以必须先把 ``registry/path`` 指到临时文件。 + + 注意这里用 :func:`common.isolated_settings` 的**四参数**构造并自证落点: + 只用 ``QSettings.setDefaultFormat(IniFormat)`` 是不够的, + 两参数构造仍会写注册表(实测踩过,见 common 里的说明)。 + """ + from . import common + + sandbox = Path(tempfile.mkdtemp(prefix="clg-selftest-")) + settings = common.isolated_settings(sandbox) + common.assert_isolated_settings(settings, sandbox) + settings.setValue("registry/path", str(sandbox / "selftest.db")) + return settings, sandbox + + +def _check_pg_dsn(dsn: str) -> tuple[bool, str]: + """按 DSN 校验:``SELECT 1`` + **整套台账适配层**(在一个事务里,最后回滚)。 + + 只做 ``SELECT 1`` 不足以证明冻结产物可用 —— 真正要验的是"配好的库能不能被 + 工具读写"。这里用 ``autocommit=False`` 建一个真实的 ``PostgresRegistry`` + (连建表都在事务内),跑一次统计查询后回滚,**不给真实库留任何痕迹**。 + """ + try: + import psycopg + except ImportError as exc: + # 带上原始异常:冻结产物里失败原因往往是"DLL 加载失败"这类具体信息 + return False, f"{type(exc).__name__}: {exc}" + try: + with psycopg.connect(dsn, connect_timeout=5) as conn: + conn.execute("SELECT 1") + except Exception as exc: # noqa: BLE001 - 网络/认证/库不存在都可能 + return False, f"{type(exc).__name__}: {exc}" + + from .pg_store import PgConfig, PostgresRegistry + + try: + config = PgConfig.from_dsn(dsn) + registry = PostgresRegistry(config, autocommit=False) + try: + stats = registry.stats() + finally: + registry._conn.rollback() # 建表/读操作一并回滚,不留痕迹 + registry.close() + except Exception as exc: # noqa: BLE001 + return False, f"适配层不可用:{type(exc).__name__}: {exc}" + return True, f"已连通,台账适配层可用(现有 {stats['total']} 条;已回滚)" + + +def run(report_path: str | None = None) -> int: + """执行自检,返回退出码(0 = 全部通过)。""" + from . import common, icons, theme + + rep = _Report(report_path) + rep.emit("=" * 60) + rep.emit("chongming-license-gui 自检(冻结产物验证)") + rep.emit("=" * 60) + + app = QApplication.instance() or QApplication([]) + import PySide6 + + rep.emit(f"平台插件:{app.platformName()} | Qt {PySide6.__version__}") + rep.emit(f"解释器:{sys.version.split()[0]}") + rep.emit(f"冻结运行:{getattr(sys, 'frozen', False)}") + rep.emit("") + + settings, sandbox = _isolate_settings() + + # ---- 1. 主题与图标(QtSvg 缺失时这里会失败)---- + theme.apply_theme(app) + rep.check("主题样式表已应用", bool(app.styleSheet()) and theme.ACCENT in app.styleSheet()) + rep.check("QtSvg 可用(图标渲染依赖)", icons.svg_available()) + + icon_issue = icons.icon("issue") + rep.check("页签图标非空", not icon_issue.isNull()) + shield = icons.pixmap("shield", theme.ACCENT, 32) + rep.check("图标已光栅化(有实际像素)", + not shield.isNull() and shield.width() > 0, + f"{shield.width()}x{shield.height()}") + rep.check("应用图标非空(可设窗口图标)", not icons.app_icon().isNull()) + + # ---- 2. 样式表指示器素材(依赖 svg 图片格式 + 临时目录可写)---- + qss = theme.build_qss() + rep.check("样式表无未替换占位符", "@CHECK_ON@" not in qss and "@RADIO_ON@" not in qss) + rep.check("复选框/单选框指示器素材已生成", + "check_on.svg" in qss and "radio_on.svg" in qss) + + # 真的画出来才算数:勾选态必须出现主色像素。 + # 注意不要 show():自检可能在同事自己的桌面上跑,别弹一个测试窗口出来 + # (grab() 对未显示的控件同样有效)。 + from PySide6.QtWidgets import QCheckBox + + box = QCheckBox("selftest") + box.setChecked(True) + app.processEvents() + image = box.grab().toImage() + accent = theme.ACCENT.lower() + painted = any(image.pixelColor(x, y).name() == accent + for y in range(image.height()) for x in range(min(24, image.width()))) + rep.check("勾选态指示器已按主题渲染", painted, "命中主色像素") + + # ---- 3. 授权库:密钥、签发、验签(cryptography / orjson 全链路)---- + from chongming_license import ( + build_license, + build_payload, + decode_request_code, + encode_request_code, + generate_keypair, + machine_code, + read_license, + write_license, + write_private_key, + ) + + sources = { + "board_serial": "cnxselftest01", + "product_uuid": "selftestproductuuid0123456789abcd", + "mac": "001b44selftest", + "machine_id": "selftestmachineid0123456789abcdef", + } + code = machine_code(sources) + # 机器码 = 24 位十六进制、按 4 位分组展示(含连字符共 29 字符) + rep.check("机器码可计算(哈希链路)", len(code.replace("-", "")) == 24, code) + + request_code = encode_request_code(sources) + decoded = decode_request_code(request_code) + rep.check("申请码编解码往返一致(orjson)", + decoded["sources"] == sources and decoded["machine_code"] == code, + request_code[:28] + "…") + + private_key, public_b64 = generate_keypair() + key_file = sandbox / "selftest_private.pem" + write_private_key(private_key, key_file, overwrite=True) + rep.check("密钥对生成并落盘(cryptography)", key_file.is_file()) + + payload = build_payload( + machine_code=code, plugins=["aibox_sophon_adapter"], sources=sources, + customer="自检", expires_at=None, + ) + lic_file = sandbox / "selftest.lic" + write_license(build_license(payload, private_key, kid="selftest"), lic_file) + status = read_license(lic_file, public_b64, sources=sources) + rep.check("签发 → 验签通过(Ed25519 全链路)", status.ok, status.message) + + foreign = dict(sources, mac="001b44ffffffff") + bad = read_license(lic_file, public_b64, sources=foreign) + rep.check("异机指纹被拒绝(判定链路)", not bad.ok, bad.code) + + # ---- 4. 台账(sqlite3 + 打包进来的授权库)---- + from chongming_license import Registry + + with Registry(sandbox / "selftest-registry.db") as reg: + saved = reg.save(build_license(payload, private_key, kid="selftest"), + public_key_fp="selftest", source="selftest") + found = reg.query(customer="自检") + stats = reg.stats() + rep.check("台账入库/检索/统计可用(sqlite3)", + saved.lic_id in [r.lic_id for r in found] and stats["total"] >= 1, + f"total={stats['total']}") + + # ---- 4b. PostgreSQL 驱动(可选功能;**不主动连库**)---- + from .pg_store import driver_available, driver_problem + + if driver_available(): + rep.emit("PostgreSQL 驱动:可用") + else: + # 冻结产物里驱动加载失败的原因很具体,这里如实打出来(否则只能靠猜) + rep.emit(f"PostgreSQL 驱动:不可用(可选功能)—— {driver_problem()}") + dsn = os.environ.get("CHONGMING_LICENSE_PG", "").strip() + if dsn: + ok, message = _check_pg_dsn(dsn) + # 只有显式设了环境变量才连;错误信息里不含连接串(可能带密码) + rep.check("按 CHONGMING_LICENSE_PG 指定的库可连通", ok, message) + else: + rep.emit("(未设 CHONGMING_LICENSE_PG,跳过真实连接检查 —— 自检与冻结产物不会主动连库)") + + # ---- 5. 插件发现(tomllib 读 config.toml)---- + worker_dir = sandbox / "proj" / "config" / "workers" / "w1" + worker_dir.mkdir(parents=True, exist_ok=True) + (worker_dir / "config.toml").write_text( + '[workers]\ntype = "selftest_plugin"\n', encoding="utf-8" + ) + found_types = common.discover_plugin_types(sandbox / "proj") + rep.check("插件自动发现可用(tomllib)", found_types == ["selftest_plugin"], + str(found_types)) + + # ---- 6. 主窗口与四个页签(整体装配)---- + from .app import MainWindow + + window = MainWindow(settings) + labels = [window.tabs.tabText(i) for i in range(window.tabs.count())] + rep.check("主窗口构建、四个页签就绪", + labels == ["签发", "台账", "密钥", "查看 / 校验"], str(labels)) + rep.check("窗口标题已生成", "chongming" in window.windowTitle(), window.windowTitle()) + rep.check("台账状态与机器码列已按主题渲染", + window.registry_tab.table.horizontalHeader().count() == 9) + rep.check("校验结论横幅已渲染", + window.inspect_tab.verdict.grab().toImage().width() > 40) + + # 抓一张整窗图:能抓到图说明绘制链路(含渐变/圆角/字体)完整 + shot = window.grab() + rep.check("整窗渲染成功(非空位图)", + not shot.isNull() and shot.width() > 800 and shot.height() > 500, + f"{shot.width()}x{shot.height()}") + + rep.emit("") + if rep.failures: + rep.emit(f"❌ 自检未通过:{len(rep.failures)} 项 —— {rep.failures}") + return 1 + rep.emit("✅ 自检全部通过") + return 0 + + +def _arg_value(flag: str) -> str | None: + """取 ``--flag value`` 形式的值(用于 ``--selftest-report``)。""" + argv = sys.argv + if flag in argv: + index = argv.index(flag) + 1 + if index < len(argv): + return argv[index] + return None + + +def report_path_from_argv() -> str | None: + return _arg_value("--selftest-report") diff --git a/src/chongming_license_gui/theme.py b/src/chongming_license_gui/theme.py new file mode 100644 index 0000000..6adc815 --- /dev/null +++ b/src/chongming_license_gui/theme.py @@ -0,0 +1,677 @@ +"""界面主题:设计 token、全局样式表与语义化样式助手。 + +颜色、圆角、状态色**只在这里定义**,各页面不再出现内联 ``setStyleSheet`` +(需要新颜色时改 token,而不是就地写死一个十六进制)。 + +两条必须遵守的约定: + +1. **样式表里不设全局 ``font-family``** —— QSS 的字体会覆盖 per-widget 的 + ``setFont``,而指纹/机器码必须保持等宽(否则逐字符核对的场景会错位)。 + 界面字体统一由 :func:`apply_theme` 通过 ``QApplication.setFont`` 下发。 +2. 运行期改 ``role`` 属性(结论横幅、密钥状态)后必须 :func:`repolish`, + 否则 Qt 不会重新计算样式。 +""" +from __future__ import annotations + +import re +import tempfile +from pathlib import Path + +from PySide6.QtGui import QFont, QFontDatabase, QImage +from PySide6.QtWidgets import QApplication, QWidget + +# -------------------------------------------------------------------------- +# 设计 token +# -------------------------------------------------------------------------- +# 主色(品牌蓝) +ACCENT = "#2563eb" +ACCENT_HOVER = "#1d4ed8" +ACCENT_PRESSED = "#1e40af" +ACCENT_SOFT = "#eff6ff" + +# 中性色 +BG = "#f5f6f8" +SURFACE = "#ffffff" +SURFACE_ALT = "#fafbfc" +SURFACE_SUNKEN = "#f7f8fa" +BORDER = "#e3e6ea" +BORDER_STRONG = "#cdd3da" +BORDER_HOVER = "#b9c0c8" +TEXT = "#1f2328" +TEXT_MUTED = "#6b7280" +TEXT_DISABLED = "#9ca3af" +DISABLED_BG = "#f1f3f5" +HOVER_BG = "#f3f4f6" +PRESSED_BG = "#e9ebee" + +# 语义色(每个都配一个浅底 + 描边,用于横幅/胶囊) +OK = "#15803d" +OK_SOFT = "#ecfdf3" +OK_BORDER = "#b7e2c8" +WARN = "#b45309" +WARN_SOFT = "#fffbeb" +WARN_BORDER = "#f2e2c4" +DANGER = "#b91c1c" +DANGER_SOFT = "#fef2f2" +DANGER_BORDER = "#e5b4b4" +INFO = "#1d4ed8" +PERMANENT = "#6d28d9" +# 原内联色 #e65100 在白底上对比度不足,加深一档 +EXPIRING = "#c2410c" + +RADIUS_CARD = "8px" +RADIUS_CONTROL = "5px" + +# 台账状态 -> 前景色(与 chongming_license 的 STATUS_LABELS 键一一对应) +STATUS_COLORS = { + "expired": DANGER, + "expiring": EXPIRING, + "active": OK, + "forever": INFO, + "not_yet_valid": PERMANENT, +} +STATUS_FALLBACK = TEXT_MUTED + +# 结论横幅的三种状态 +VERDICT_OK = "ok" +VERDICT_BAD = "bad" +VERDICT_IDLE = "idle" + +# 界面字体候选:优先中文界面字体,最后回退系统默认 +_UI_FONT_CANDIDATES = ( + "Microsoft YaHei UI", + "Microsoft YaHei", + "PingFang SC", + "Noto Sans CJK SC", + "Source Han Sans SC", + "Segoe UI", +) + +_TOKENS = { + "ACCENT": ACCENT, + "ACCENT_HOVER": ACCENT_HOVER, + "ACCENT_PRESSED": ACCENT_PRESSED, + "ACCENT_SOFT": ACCENT_SOFT, + "BG": BG, + "SURFACE": SURFACE, + "SURFACE_ALT": SURFACE_ALT, + "SURFACE_SUNKEN": SURFACE_SUNKEN, + "BORDER_STRONG": BORDER_STRONG, + "BORDER_HOVER": BORDER_HOVER, + "BORDER": BORDER, + "TEXT_MUTED": TEXT_MUTED, + "TEXT_DISABLED": TEXT_DISABLED, + "TEXT": TEXT, + "DISABLED_BG": DISABLED_BG, + "HOVER_BG": HOVER_BG, + "PRESSED_BG": PRESSED_BG, + "OK_SOFT": OK_SOFT, + "OK_BORDER": OK_BORDER, + "OK": OK, + "WARN_SOFT": WARN_SOFT, + "WARN_BORDER": WARN_BORDER, + "WARN": WARN, + "DANGER_SOFT": DANGER_SOFT, + "DANGER_BORDER": DANGER_BORDER, + "DANGER": DANGER, + "RADIUS_CARD": RADIUS_CARD, + "RADIUS_CONTROL": RADIUS_CONTROL, +} + +# QSS 用 @TOKEN 占位(不用 str.format/f-string:样式表里的 CSS 花括号会被当成占位符) +_QSS = """ +/* ---------- 基底 ---------- */ +QMainWindow, QDialog { + background: @BG; +} +QWidget#pageScroll, QWidget#pageScroll > QWidget, QWidget#pageScroll > QWidget > QWidget { + background: transparent; + border: none; +} +QLabel { + color: @TEXT; + background: transparent; +} + +/* ---------- 卡片(QGroupBox) ---------- */ +QGroupBox { + background: @SURFACE; + border: 1px solid @BORDER; + border-radius: @RADIUS_CARD; + margin-top: 13px; + padding: 13px 14px 13px 14px; +} +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + left: 11px; + padding: 0 6px; + color: @ACCENT; + font-weight: 600; +} + +/* ---------- 文本输入 ---------- */ +QLineEdit, QPlainTextEdit, QSpinBox, QDateEdit { + background: @SURFACE; + border: 1px solid @BORDER_STRONG; + border-radius: @RADIUS_CONTROL; + padding: 5px 8px; + color: @TEXT; + selection-background-color: @ACCENT; + selection-color: #ffffff; +} +QLineEdit:focus, QPlainTextEdit:focus, QSpinBox:focus, QDateEdit:focus { + border-color: @ACCENT; +} +QLineEdit:disabled, QPlainTextEdit:disabled, QSpinBox:disabled, QDateEdit:disabled { + background: @DISABLED_BG; + color: @TEXT_DISABLED; + border-color: @BORDER; +} +QLineEdit[readOnly="true"], QPlainTextEdit[readOnly="true"] { + background: @SURFACE_ALT; +} + +/* ---------- 下拉框 ---------- */ +QComboBox { + background: @SURFACE; + border: 1px solid @BORDER_STRONG; + border-radius: @RADIUS_CONTROL; + padding: 5px 8px; + color: @TEXT; +} +QComboBox:focus, QComboBox:on { + border-color: @ACCENT; +} +QComboBox:disabled { + background: @DISABLED_BG; + color: @TEXT_DISABLED; +} +QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: center right; + width: 20px; + border: none; + background: transparent; +} +QComboBox QAbstractItemView { + background: @SURFACE; + border: 1px solid @BORDER; + border-radius: 6px; + padding: 4px; + outline: none; + selection-background-color: @ACCENT_SOFT; + selection-color: @ACCENT; +} + +/* ---------- 按钮 ---------- */ +QPushButton { + background: @SURFACE; + border: 1px solid @BORDER_STRONG; + border-radius: @RADIUS_CONTROL; + padding: 6px 14px; + color: @TEXT; + min-height: 16px; +} +QPushButton:hover { + background: @HOVER_BG; + border-color: @BORDER_HOVER; +} +QPushButton:pressed { + background: @PRESSED_BG; +} +QPushButton:disabled { + background: @DISABLED_BG; + color: @TEXT_DISABLED; + border-color: @BORDER; +} +QPushButton[role="primary"] { + background: @ACCENT; + border-color: @ACCENT; + color: #ffffff; + font-weight: 600; +} +QPushButton[role="primary"]:hover { + background: @ACCENT_HOVER; + border-color: @ACCENT_HOVER; +} +QPushButton[role="primary"]:pressed { + background: @ACCENT_PRESSED; + border-color: @ACCENT_PRESSED; +} +QPushButton[role="primary"]:disabled { + background: #a9c2f0; + border-color: #a9c2f0; + color: #f2f6ff; +} +QPushButton[role="danger"] { + color: @DANGER; + border-color: @DANGER_BORDER; + background: @SURFACE; +} +QPushButton[role="danger"]:hover { + background: @DANGER_SOFT; + border-color: @DANGER; +} +QPushButton[role="danger"]:pressed { + background: #fbe0e0; +} +QPushButton[role="danger"]:disabled { + color: @TEXT_DISABLED; + border-color: @BORDER; + background: @DISABLED_BG; +} +QPushButton[role="link"] { + background: transparent; + border: none; + color: @ACCENT; + padding: 2px 4px; + font-weight: 600; +} +QPushButton[role="link"]:hover { + color: @ACCENT_HOVER; + text-decoration: underline; +} + +/* ---------- 页签 ---------- */ +QTabWidget::pane { + background: @BG; + border: 1px solid @BORDER; + border-radius: @RADIUS_CARD; + top: -1px; +} +QTabBar { + background: transparent; +} +QTabBar::tab { + background: transparent; + border: 1px solid transparent; + border-top-left-radius: 7px; + border-top-right-radius: 7px; + padding: 7px 16px; + margin-right: 3px; + margin-bottom: -1px; + color: @TEXT_MUTED; +} +QTabBar::tab:hover:!selected { + background: @ACCENT_SOFT; + color: @ACCENT; +} +QTabBar::tab:selected { + background: @BG; + border-color: @BORDER; + border-bottom-color: @BG; + color: @ACCENT; + font-weight: 600; +} + +/* ---------- 列表 / 表格 ---------- */ +QListWidget, QTableWidget { + background: @SURFACE; + border: 1px solid @BORDER; + border-radius: 6px; + outline: none; +} +QTableWidget { + alternate-background-color: @SURFACE_ALT; + gridline-color: transparent; + selection-background-color: @ACCENT_SOFT; +} +QTableWidget::item { + padding: 4px 6px; +} +QTableWidget::item:selected { + background: @ACCENT_SOFT; +} +QListWidget::item { + padding: 3px 4px; + border-radius: 4px; +} +QListWidget::item:hover { + background: @SURFACE_SUNKEN; +} +QListWidget::item:selected { + background: @ACCENT_SOFT; + color: @ACCENT; +} +QHeaderView { + background: transparent; +} +QHeaderView::section { + background: @SURFACE_SUNKEN; + color: @TEXT_MUTED; + padding: 7px 6px; + border: none; + border-bottom: 1px solid @BORDER; + border-right: 1px solid #eef0f2; + font-weight: 600; +} +QTableCornerButton::section { + background: @SURFACE_SUNKEN; + border: none; + border-bottom: 1px solid @BORDER; +} + +/* ---------- 滚动条 ---------- */ +QScrollArea { + background: transparent; + border: none; +} +QScrollBar:vertical { + background: transparent; + width: 10px; + margin: 2px 2px 2px 0; +} +QScrollBar::handle:vertical { + background: #c9cfd6; + border-radius: 4px; + min-height: 28px; +} +QScrollBar::handle:vertical:hover { + background: #adb5bd; +} +QScrollBar:horizontal { + background: transparent; + height: 10px; + margin: 0 2px 2px 2px; +} +QScrollBar::handle:horizontal { + background: #c9cfd6; + border-radius: 4px; + min-width: 28px; +} +QScrollBar::handle:horizontal:hover { + background: #adb5bd; +} +QScrollBar::add-line, QScrollBar::sub-line { + width: 0; + height: 0; + border: none; + background: transparent; +} +QScrollBar::add-page, QScrollBar::sub-page { + background: transparent; +} + +/* ---------- 语义标签 ---------- */ +QLabel[role="hint"] { + color: @TEXT_MUTED; +} +QLabel[role="warn"] { + color: @WARN; +} +QLabel[role="danger"] { + color: @DANGER; +} +QLabel[role="ok"] { + color: @OK; +} +QLabel[role="title"] { + color: @TEXT; + font-weight: 600; +} +QLabel[role="verdict-ok"] { + background: @OK_SOFT; + color: @OK; + border: 1px solid @OK_BORDER; + border-radius: 6px; + padding: 10px 12px; + font-weight: 600; +} +QLabel[role="verdict-bad"] { + background: @DANGER_SOFT; + color: @DANGER; + border: 1px solid @DANGER_BORDER; + border-radius: 6px; + padding: 10px 12px; + font-weight: 600; +} +QLabel[role="verdict-idle"] { + background: @SURFACE_SUNKEN; + color: @TEXT_MUTED; + border: 1px solid @BORDER; + border-radius: 6px; + padding: 10px 12px; + font-weight: 600; +} +QLabel[role="chip-ok"] { + background: @OK_SOFT; + color: @OK; + border: 1px solid @OK_BORDER; + border-radius: 11px; + padding: 4px 12px; + font-weight: 600; +} +QLabel[role="chip-warn"] { + background: @WARN_SOFT; + color: @WARN; + border: 1px solid @WARN_BORDER; + border-radius: 11px; + padding: 4px 12px; + font-weight: 600; +} +QLabel[role="flash"] { + color: @OK; + font-weight: 600; +} + +/* ---------- 容器条 ---------- */ +QFrame[role="header"] { + background: @SURFACE; + border: 1px solid @BORDER; + border-radius: @RADIUS_CARD; +} +QFrame[role="footer"], QFrame[role="notice"] { + background: @WARN_SOFT; + border: 1px solid @WARN_BORDER; + border-radius: 6px; +} +QLabel[role="stats"] { + background: @ACCENT_SOFT; + border: 1px solid #d6e4fd; + border-radius: 6px; + padding: 8px 11px; + color: @TEXT; +} +QLabel[role="stats-error"] { + background: @DANGER_SOFT; + border: 1px solid @DANGER_BORDER; + border-radius: 6px; + padding: 8px 11px; + color: @DANGER; +} + +/* ---------- 其它 ---------- */ +QToolTip { + background: #1f2328; + color: #f8f9fa; + border: 1px solid #1f2328; + border-radius: 4px; + padding: 5px 8px; +} +""" + +# 复选框/单选框指示器:QSS 无法用代码画图形,只能引用图片文件。 +# 这些 SVG 在首次 build_qss() 时写入临时目录(无二进制素材入库)。 +_INDICATORS = """ +QCheckBox, QRadioButton { + spacing: 7px; +} +QCheckBox::indicator, QRadioButton::indicator, QListWidget::indicator { + width: 15px; + height: 15px; +} +QCheckBox::indicator:unchecked, QListWidget::indicator:unchecked { + image: url("@CHECK_OFF@"); +} +QCheckBox::indicator:checked, QListWidget::indicator:checked { + image: url("@CHECK_ON@"); +} +QCheckBox::indicator:unchecked:disabled, QListWidget::indicator:disabled { + image: url("@CHECK_OFF_DISABLED@"); +} +QRadioButton::indicator:unchecked { + image: url("@RADIO_OFF@"); +} +QRadioButton::indicator:checked { + image: url("@RADIO_ON@"); +} +QRadioButton::indicator:unchecked:disabled { + image: url("@RADIO_OFF_DISABLED@"); +} +""" + +_SVG_TEMPLATE = ( + '{body}' +) + + +def _indicator_svg(name: str) -> str: + """按当前 token 生成指示器 SVG(改主题色时指示器一起变)。""" + if name == "check_off": + body = ( + f'' + ) + elif name == "check_on": + body = ( + f'' + '' + ) + elif name == "check_off_disabled": + body = ( + f'' + ) + elif name == "radio_off": + body = ( + f'' + ) + elif name == "radio_on": + body = ( + f'' + f'' + ) + else: # radio_off_disabled + body = ( + f'' + ) + return _SVG_TEMPLATE.format(body=body) + + +def _ensure_indicator_assets() -> dict[str, str]: + """把指示器 SVG 写到临时目录,返回 ``名称 -> 绝对路径`` 映射。 + + 任何一步失败(只读文件系统、缺 svg 图片格式)都返回空字典 —— 调用方会跳过 + 指示器样式,界面回落到**原生指示器**。这一点必须守住:QSS 里 ``image`` 加载 + 失败时 Qt 不会报错,指示器会直接变成空白,用户就看不见勾选状态了。 + """ + try: + import PySide6.QtSvg # noqa: F401 样式表里的 svg 需要 imageformats 支持 + except ImportError: + return {} + + names = ( + "check_off", "check_on", "check_off_disabled", + "radio_off", "radio_on", "radio_off_disabled", + ) + paths: dict[str, str] = {} + try: + base = Path(tempfile.gettempdir()) / "chongming-license-gui-qss" + base.mkdir(parents=True, exist_ok=True) + for name in names: + path = base / f"{name}.svg" + svg = _indicator_svg(name) + # 内容不变则不重写,省掉每次启动的磁盘写 + if not path.is_file() or path.read_text(encoding="utf-8") != svg: + path.write_text(svg, encoding="utf-8") + if QImage(str(path)).isNull(): # 读不出来就不能让样式表引用它 + return {} + paths[name.upper()] = path.as_posix() + except Exception: # noqa: BLE001 - 只读文件系统等:放弃指示器美化 + return {} + return paths + + +def _substitute(template: str, mapping: dict[str, str]) -> str: + """替换 ``@TOKEN`` / ``@TOKEN@`` 占位符。 + + 按名字从长到短替换,避免 ``@TEXT`` 把 ``@TEXT_MUTED`` 吃掉;结尾的 ``@`` + 可选(两种写法都支持,写样式表时不必纠结)。 + """ + out = template + for key in sorted(mapping, key=len, reverse=True): + pattern = re.compile(rf"@{re.escape(key)}@?") + out = pattern.sub(lambda _m, value=mapping[key]: value, out) + return out + + +def _has_leftover_placeholder(text: str) -> bool: + """样式表里还有没替换掉的占位符就说明拼接出了问题。""" + return bool(re.search(r"@[A-Z][A-Z0-9_]*@?", text)) + + +def build_qss() -> str: + """生成完整样式表。""" + qss = _substitute(_QSS, _TOKENS) + indicators = _ensure_indicator_assets() + if indicators: + block = _substitute(_INDICATORS, indicators) + # 宁可不要指示器美化,也不能让一条 image: url("@...") 把勾选框变成空白 + if not _has_leftover_placeholder(block): + qss += block + return qss + + +def ui_font() -> QFont: + """界面字体:优先中文界面字体,缺字少、观感统一。""" + available = set(QFontDatabase.families()) + font = QFont() + for family in _UI_FONT_CANDIDATES: + if family in available: + font = QFont(family) + break + font.setPointSizeF(10.0) + return font + + +def apply_theme(app: QApplication) -> None: + """把主题装到应用上(幂等,可重复调用)。 + + 用 Fusion 作为基底:本主题大量依赖 QSS 定制,而 Windows 原生样式与 QSS + 混用时控件会一半原生一半自绘;Fusion 完全可自绘,跨平台观感一致, + 也避免系统深色主题下浅色卡片"半明半暗"。 + """ + app.setStyle("Fusion") + app.setFont(ui_font()) + app.setStyleSheet(build_qss()) + + +def status_color(status: str) -> str: + """台账状态 -> 前景色。""" + return STATUS_COLORS.get(status, STATUS_FALLBACK) + + +def repolish(widget: QWidget) -> None: + """改过 ``role`` 等动态属性后调用,让 Qt 重算样式。""" + style = widget.style() + style.unpolish(widget) + style.polish(widget) + widget.update() + + +def apply_verdict(label: QWidget, kind: str) -> None: + """把结论标签设成成功/失败/未判定三种横幅外观。""" + label.setProperty("role", f"verdict-{kind}") + repolish(label) + + +def set_role(widget: QWidget, role: str) -> None: + """设置 ``role`` 并立即重算样式。""" + widget.setProperty("role", role) + repolish(widget) diff --git a/tools/pg_check.py b/tools/pg_check.py new file mode 100644 index 0000000..3ae3598 --- /dev/null +++ b/tools/pg_check.py @@ -0,0 +1,279 @@ +"""对**真实 PostgreSQL 服务器**做台账契约一致性校验。 + +为什么需要它:本工具开发机上没有任何 PostgreSQL(无服务、无 Docker),因此 +PG 后端的真实读写语义无法在开发环境里验证。这个脚本让你**在自己的库上** +一次性验完,而且: + +* **全程在一个事务里,最后 ``ROLLBACK``** —— 连建表都在事务内, + 对你的真实库**零残留**(表本来存在就什么都不变); +* 用**同一套契约**分别跑本地 SQLite 与 PostgreSQL,然后**逐项对比**, + 这样"两个后端行为不一致"会直接暴露成 FAIL,而不是等同事用出问题才发现; +* 写入的记录 ``lic_id`` 一律带 ``PGCHECK-`` 前缀,且**所有观测值都只统计这些 + 记录** —— 库里已有的真实台账既不参与对比,也不会被打印出来; +* 不会改动你的任何真实数据(验证结束后事务回滚)。 + +用法:: + + # 1) 显式参数(密码走环境变量或交互输入,避免留在命令历史里) + uv run python tools/pg_check.py --host 127.0.0.1 --port 5432 \\ + --dbname chongming_licenses --user license_ro + + # 2) 或用 DSN + uv run python tools/pg_check.py --dsn "postgresql://license_ro@127.0.0.1:5432/chongming_licenses" + + # 密码来源:环境变量 PGPASSWORD 或交互式提问(推荐); + # 也支持 --password(会留在命令历史里,仅用于自动化) + +退出码 0 = 两个后端行为一致。 +""" +from __future__ import annotations + +import argparse +import getpass +import os +import sys +import tempfile +from datetime import timedelta +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from chongming_license import ( # noqa: E402 + Registry, + build_license, + build_payload, + generate_keypair, + machine_code, + utc_today, + write_license, +) +from chongming_license_gui import pg_store # noqa: E402 +from chongming_license_gui.pg_store import ( # noqa: E402 + DEFAULT_DBNAME, + DEFAULT_HOST, + DEFAULT_PORT, + DEFAULT_SCHEMA, + DEFAULT_SSLMODE, + SSL_MODES, + PgConfig, + PostgresRegistry, +) + +PREFIX = "PGCHECK-" +STATUSES = ("expiring", "forever", "active", "expired", "not_yet_valid") +failures: list[str] = [] + + +def check(name: str, ok: bool, detail: str = "") -> None: + print(f"[{'PASS' if ok else 'FAIL'}] {name}" + (f" — {detail}" if detail else "")) + if not ok: + failures.append(name) + + +def _mine(records) -> list[str]: + """只保留本次校验写入的记录(真实台账不参与对比、也不被打印)。""" + return [r.lic_id for r in records if r.lic_id.startswith(PREFIX)] + + +# --------------------------------------------------------------------------- +# 契约:在任意后端上跑同一套操作,返回"可比较的观测值" +# --------------------------------------------------------------------------- +def _demo_docs(sandbox: Path) -> list[dict]: + """两条用于校验的授权:一条即将到期、一条永久(覆盖排序哨兵与状态分桶)。 + + 客户名刻意混入 ASCII(``PGCheck``),用来验证"大小写不敏感"这个方言差异点。 + """ + private_key, _ = generate_keypair() + today = utc_today() + sources = { + "board_serial": "pgcheckboard01", + "product_uuid": "pgcheckproductuuid0123456789ab", + "mac": "001b44pgcheck", + "machine_id": "pgcheckmachineid0123456789abcdef", + } + specs = [ + (f"{PREFIX}EXPIRING", "PGCheck 契约校验(示例)", ["aibox_sophon_adapter"], + (today + timedelta(days=20)).isoformat()), + (f"{PREFIX}FOREVER", "PGCheck 永久(示例)", + ["aibox_sophon_adapter", "ip_sound_column"], None), + ] + docs = [] + for lic_id, customer, plugins, expires in specs: + payload = build_payload( + machine_code=machine_code(sources), plugins=plugins, sources=sources, + customer=customer, lic_id=lic_id, expires_at=expires, note="载荷备注", + ) + doc = build_license(payload, private_key, kid="pg-check") + write_license(doc, sandbox / f"{lic_id}.lic") + docs.append(doc) + return docs + + +def contract(reg, docs: list[dict], sandbox: Path) -> dict: + """同一套操作 → 观测值。两边结果必须逐项相同。""" + ids = [d["payload"]["lic_id"] for d in docs] + mine = lambda records: _mine(records) # noqa: E731 - 短小,读起来更直观 + obs: dict[str, object] = {} + + for doc in docs: + reg.save(doc, public_key_fp="PGCHECK-FP", source="pg-check") + + # 排序:到期日升序,永久(NULL)用哨兵排最后 + obs["顺序(到期升序/永久最后)"] = mine(reg.query()) + obs["客户模糊检索"] = mine(reg.query(customer="永久")) + obs["客户检索大小写不敏感"] = mine(reg.query(customer="pgcheck")) + obs["插件精确检索"] = sorted(mine(reg.query(plugin="ip_sound_column"))) + obs["关键字检索"] = sorted(mine(reg.query(keyword="契约校验"))) + obs["状态筛选(永久)"] = mine(reg.query(status="forever")) + obs["状态分桶"] = {s: len(mine(reg.query(status=s))) for s in STATUSES} + + rec = reg.get(ids[0]) + obs["get 命中"] = rec.lic_id if rec else None + obs["机器码已大写"] = bool(rec) and rec.machine_code == rec.machine_code.upper() + obs["来源指纹回读一致"] = bool(rec) and rec.sources == docs[0]["payload"]["sources"] + + # 人工批注:手工改过之后,重新入库不得被载荷备注冲掉 + obs["update_note 返回值"] = reg.update_note(ids[0], "人工批注") + reg.save(docs[0], public_key_fp="PGCHECK-FP", source="renew") + obs["重新入库后仍是人工批注"] = reg.get(ids[0]).note + + # 首次入库时间要保留(库的 SQLite 版把 created_at 排除在 DO UPDATE 之外) + created_before = reg.get(ids[0]).created_at + reg.save(docs[0], public_key_fp="PGCHECK-FP", source="renew") + obs["重新入库后 created_at 不变"] = reg.get(ids[0]).created_at == created_before + + # 导出:文件名规则、CSV 表头与编码(这些方法是从库继承的,两边应完全一致) + my_records = [r for r in reg.query() if r.lic_id.startswith(PREFIX)] + out = sandbox / "export" + obs["导出 .lic 文件名"] = sorted(p.name for p in reg.export_licenses(out, my_records)) + csv_path = reg.export_csv(sandbox / "台账.csv", my_records) + raw = csv_path.read_bytes() + obs["CSV 带 BOM"] = raw[:3] == b"\xef\xbb\xbf" + lines = csv_path.read_text(encoding="utf-8-sig").splitlines() + obs["CSV 行数(含表头)"] = len(lines) + obs["CSV 表头列数"] = len(lines[0].split(",")) if lines else 0 + + obs["delete 返回值"] = reg.delete(ids[1]) + obs["删除后剩余"] = mine(reg.query()) + return obs + + +# --------------------------------------------------------------------------- +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="在真实 PostgreSQL 上校验台账后端与本地 SQLite 行为一致", + ) + parser.add_argument("--dsn", + help="连接串(postgresql://用户@主机:端口/库名);给了它就不用下面各项") + parser.add_argument("--host", default=DEFAULT_HOST) + parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"默认 {DEFAULT_PORT}") + parser.add_argument("--dbname", default=DEFAULT_DBNAME) + parser.add_argument("--user", default="") + parser.add_argument("--password", default=None, + help="不建议:会留在命令历史里。优先用 PGPASSWORD 或交互输入") + parser.add_argument("--sslmode", default=DEFAULT_SSLMODE, choices=list(SSL_MODES)) + parser.add_argument("--schema", default=DEFAULT_SCHEMA) + return parser.parse_args() + + +def resolve_config(args: argparse.Namespace) -> tuple[PgConfig, str]: + """返回 (配置, 密码)。密码优先级:--password > DSN 内 > PGPASSWORD。""" + if args.dsn: + # 连接串解析交给 pg_store(进而交给 psycopg),避免自己写一套连接串文法 + config = PgConfig.from_dsn(args.dsn, schema=args.schema) + password = config.password + else: + config = PgConfig(host=args.host, port=args.port, dbname=args.dbname, + user=args.user, sslmode=args.sslmode, schema=args.schema) + password = "" + if args.password: + password = args.password + return config, password or os.environ.get("PGPASSWORD", "") + + +def main() -> int: + args = parse_args() + try: + config, password = resolve_config(args) + except ImportError: + print("未安装 PostgreSQL 驱动:uv sync --extra postgres") + return 2 + if not config.dbname: + print("请指定数据库名(--dbname 或 --dsn)") + return 2 + if not password: + password = getpass.getpass(f"{config.user or 'postgres'}@{config.host} 的密码(不显示):") + + print("=" * 70) + print("台账契约一致性校验:本地 SQLite vs PostgreSQL") + print(f"目标:{config.display()}") # 展示串不含密码 + print("说明:所有写入都在一个事务里,结束时 ROLLBACK;") + print(" 观测值只统计本次的 PGCHECK-* 记录,你的真实台账不参与、也不会被打印。") + print("=" * 70) + + sandbox = Path(tempfile.mkdtemp(prefix="clg-pgcheck-")) + docs = _demo_docs(sandbox) + + # ---- 1) 本地 SQLite 基准 ---- + print("\n--- 本地 SQLite(基准)---") + sqlite_sandbox = sandbox / "sqlite" + sqlite_sandbox.mkdir(parents=True, exist_ok=True) + with Registry(sqlite_sandbox / "ledger.db") as sqlite_reg: + sqlite_obs = contract(sqlite_reg, docs, sqlite_sandbox) + for key, value in sqlite_obs.items(): + print(f" {key} = {value}") + + # ---- 2) PostgreSQL(一个事务,最后回滚)---- + print("\n--- PostgreSQL(事务内执行,结束回滚)---") + pg_sandbox = sandbox / "pg" + pg_sandbox.mkdir(parents=True, exist_ok=True) + try: + reg = PostgresRegistry(config, password=password, autocommit=False) + except Exception as exc: # noqa: BLE001 + print(f"[FAIL] 连接失败:{exc}") + return 1 + + try: + pg_obs = contract(reg, docs, pg_sandbox) + except Exception as exc: # noqa: BLE001 - 把差异当成校验失败而不是崩溃 + print(f"[FAIL] 在 PostgreSQL 上执行契约时出错:{type(exc).__name__}: {exc}") + reg._conn.rollback() + reg.close() + return 1 + + # ---- 3) 逐项对比 ---- + print("\n--- 逐项对比(SQLite 基准 vs PostgreSQL)---") + for key, expected in sqlite_obs.items(): + actual = pg_obs.get(key) + check(key, actual == expected, "" if actual == expected + else f"SQLite={expected!r} PG={actual!r}") + + # ---- 4) 回滚并确认没有残留 ---- + reg._conn.rollback() + table = f'"{config.schema}"."{pg_store.TABLE}"' + try: + row = reg._conn.execute( + f"SELECT count(*) AS n FROM {table} WHERE lic_id LIKE '{PREFIX}%'" + ).fetchone() + residue = row["n"] if row else 0 + print(f"\n回滚完成:库里残留的校验记录数 = {residue}") + if residue: + print("[FAIL] 残留了校验记录 —— 这不该发生") + failures.append("回滚后有残留") + except Exception as exc: # noqa: BLE001 + # 表本来不存在 → 连建表一起被回滚了,这正是期望结果 + print(f"\n回滚完成:表不存在(建表也随事务一起回滚)—— {type(exc).__name__}") + reg.close() + + print("\n" + "=" * 70) + if failures: + print(f"❌ {len(failures)} 项在两个后端之间不一致:{failures}") + print(" 请把上面的差异贴给研发 —— 这正是本脚本存在的意义。") + return 1 + print("✅ PostgreSQL 后端与本地 SQLite 行为完全一致(且已在事务中回滚,无残留)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/screenshot.py b/tools/screenshot.py new file mode 100644 index 0000000..3390577 --- /dev/null +++ b/tools/screenshot.py @@ -0,0 +1,235 @@ +"""生成文档用的界面截图(无头运行)。 + +用法:: + + uv run python tools/screenshot.py + +产物写入 ``docs/images/``,供 ``docs/使用手册.md`` 引用;改完主题重跑即可。 + +**安全约定(重要)**:本脚本只画界面,绝不接触真实资产 —— + +* ``QSettings`` 被重定向到临时 INI,不会读写用户真实的配置; +* 台账库指向临时目录,不会碰 ``~/.chongming/licenses.db``; +* 私钥是当场生成的一次性密钥,截图里不会出现任何真实密钥或客户信息; +* 演示数据统一带「示例」字样,一眼可与真实台账区分。 +""" +from __future__ import annotations + +import hashlib +import os +import sys +import tempfile +from datetime import timedelta +from pathlib import Path + +# 必须在导入/创建 QApplication 之前设置:无头渲染,且不弹任何窗口 +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from PySide6.QtWidgets import QApplication # noqa: E402 + +OUT_DIR = ROOT / "docs" / "images" +WINDOW_WIDTH = 1240 + + +def _demo_sources(seed: str) -> dict[str, str]: + """按种子造一台"设备"的来源指纹(已按库规则归一化:小写、仅 [0-9a-z])。""" + digest = hashlib.sha256(seed.encode("utf-8")).hexdigest() + return { + "board_serial": f"cnx{digest[:12]}", + "product_uuid": digest[12:44], + "mac": f"001b44{digest[44:56]}", + "machine_id": digest[56:88], + } + + +def main() -> int: + # ---- 1) 隔离:配置、台账、密钥全部落在临时目录 ---------------------- + sandbox = Path(tempfile.mkdtemp(prefix="license-gui-shots-")) + + app = QApplication.instance() or QApplication([]) + + from chongming_license import ( + build_license, + build_payload, + encode_request_code, + generate_keypair, + machine_code, + utc_today, + write_license, + write_private_key, + ) + + from chongming_license_gui import common, theme + from chongming_license_gui.app import MainWindow + from chongming_license_gui.registry_tab import ChangePluginsDialog, RenewDialog + + theme.apply_theme(app) + + # 一次性密钥(截图里出现的是这把,与任何真实密钥无关) + private_key, public_b64 = generate_keypair() + key_path = sandbox / "demo_private.pem" + write_private_key(private_key, key_path, overwrite=True) + + # 配置隔离到临时目录;并用 fileName() 自证,而不是"以为隔离了" + settings = common.isolated_settings(sandbox) + common.assert_isolated_settings(settings, sandbox) + + window = MainWindow(settings) + window.signing_key.load(str(key_path)) + fingerprint = window.signing_key.fingerprint + + # ---- 2) 演示台账:五种状态各一条,正好展示状态配色 ------------------ + registry_tab = window.registry_tab + registry_tab.db_edit.setText(str(sandbox / "demo-licenses.db")) + registry_tab._open_db(silent=True) + + today = utc_today() + demo = [ + # (客户, 种子, 插件, not_before 偏移, expires 偏移, 台账批注) + ("华东水务集团(示例)", "dev-water-01", ["aibox_sophon_adapter"], + None, -26, "合同 SM-2025-0417 · 已到期待续"), + ("上海崇明智慧农业(示例)", "dev-farm-07", ["aibox_sophon_adapter", "ip_sound_column"], + None, 12, "已电话确认续期,等客户回申请码"), + ("宁波港机(示例)", "dev-port-03", ["aibox_sophon_adapter"], + None, 248, "现场调试中"), + ("苏州工业园区(示例)", "dev-suzhou-11", ["ip_sound_column"], + None, None, "战略合作 · 永久授权"), + ("舟山渔场(示例)", "dev-zhoushan-05", ["aibox_sophon_adapter"], + 45, 410, "新设备,发货前预签发"), + ] + + def _day(offset: int | None) -> str | None: + return None if offset is None else (today + timedelta(days=offset)).isoformat() + + for customer, seed, plugins, not_before, expires, note in demo: + sources = _demo_sources(seed) + payload = build_payload( + machine_code=machine_code(sources), + plugins=plugins, + sources=sources, + customer=customer, + not_before=_day(not_before), + expires_at=_day(expires), + note="示例数据(由 tools/screenshot.py 生成)", + ) + doc = build_license(payload, private_key, kid="vendor-demo") + window.registry.save(doc, public_key_fp=fingerprint, source="gui") + registry_tab.refresh() + + # ---- 3) 签发页填好一份"正在签发"的数据 ------------------------------ + issue = window.issue_tab + request_code = encode_request_code(_demo_sources("dev-new-42")) + issue.rc_edit.setPlainText(request_code) + issue._parse_request_code() + issue._set_all_checked(True) + issue.customer_edit.setText("南通冷链物流(示例)") + issue.note_edit.setText("合同 NT-2026-0921") + issue.kid_edit.setText("vendor-2026") + issue.days_spin.setValue(365) + issue._refresh_preview() + + # ---- 4) 查看/校验页:先出一张"通过",再出一张"异机被拒" ----------- + # 用**有效**(而非已过期)的那份来演示"校验通过",否则截图会是一张红条: + # 过期件也"签名有效",但结论是失败,拿它当"通过"示例会误导阅读手册的人。 + ok_index = 2 # 宁波港机(示例):active + ok_sources = _demo_sources(demo[ok_index][1]) + ok_license = sandbox / "license_active.lic" + ok_payload = build_payload( + machine_code=machine_code(ok_sources), + plugins=demo[ok_index][2], + sources=ok_sources, + customer=demo[ok_index][0], + expires_at=_day(demo[ok_index][4]), + note="示例数据(由 tools/screenshot.py 生成)", + ) + write_license(build_license(ok_payload, private_key, kid="vendor-demo"), ok_license) + + inspect = window.inspect_tab + inspect.lic_edit.setText(str(ok_license)) + inspect.pub_edit.setText(public_b64) + inspect.rb_remote.setChecked(True) + inspect.rc_edit.setText(encode_request_code(ok_sources)) + inspect._run() + + # ---- 5) 逐页截图 --------------------------------------------------- + OUT_DIR.mkdir(parents=True, exist_ok=True) + shots: list[Path] = [] + + def _grab(widget, name: str) -> Path: + widget.show() + # 两轮事件循环:第一轮完成布局,第二轮把重绘落到位,再抓图才不会是半成品 + QApplication.processEvents() + QApplication.processEvents() + target = OUT_DIR / name + widget.grab().save(str(target), "PNG") + shots.append(target) + return target + + def _fit(page, minimum: int = 700) -> int: + """把窗口调到"本页内容刚好放得下",避免截图中出现滚动条或截断。""" + area = window._area_by_page[page] + height = minimum + while height < 2000: + window.resize(WINDOW_WIDTH, height) + QApplication.processEvents() + if area.verticalScrollBar().maximum() == 0: + break + height += 60 + return height + + # 签发页 + window.tabs.setCurrentWidget(window._area_by_page[issue]) + _fit(issue) + _grab(window, "01-issue.png") + + # 台账页 + window.tabs.setCurrentWidget(window._area_by_page[registry_tab]) + _fit(registry_tab) + _grab(window, "02-registry.png") + + # 密钥页 + window.tabs.setCurrentWidget(window._area_by_page[window.keys_tab]) + _fit(window.keys_tab) + _grab(window, "03-keys.png") + + # 查看/校验页:通过 + window.tabs.setCurrentWidget(window._area_by_page[inspect]) + _fit(inspect) + _grab(window, "04-inspect-ok.png") + + # 查看/校验页:异机被拒(换一台机器的申请码) + inspect.rc_edit.setText(encode_request_code(_demo_sources("dev-other-99"))) + inspect._run() + _grab(window, "05-inspect-fail.png") + + # 续期对话框 + records = registry_tab.records + renew_target = next((r for r in records if r.status() == "expiring"), records[0]) + dialog = RenewDialog(renew_target, window) + dialog.resize(660, dialog.sizeHint().height()) + _grab(dialog, "06-renew.png") + dialog.close() + + # 改插件对话框:勾一个原先没有的、去掉一个原先有的,"新增/移除"差异才看得见 + plugins_dialog = ChangePluginsDialog(renew_target, window.settings, window) + plugins_dialog.plugin_picker.ensure_types(["video_matrix_adapter"]) + plugins_dialog.plugin_picker.set_checked( + [*renew_target.plugins[:1], "video_matrix_adapter"] + ) + plugins_dialog.resize(660, plugins_dialog.sizeHint().height()) + _grab(plugins_dialog, "07-change-plugins.png") + plugins_dialog.close() + + # ---- 6) 收尾 ------------------------------------------------------- + print(f"截图输出目录:{OUT_DIR}") + for path in shots: + print(f" {path.name} {path.stat().st_size // 1024} KB") + print(f"\n演示用临时目录(可直接删除):{sandbox}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock index fe66e61..896c289 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "altgraph" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, +] + [[package]] name = "annotated-types" version = "0.8.0" @@ -119,11 +128,32 @@ dependencies = [ { name = "pyside6" }, ] +[package.optional-dependencies] +postgres = [ + { name = "psycopg", extra = ["binary"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "nuitka" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pyinstaller" }, +] + [package.metadata] requires-dist = [ { name = "chongming-license" }, + { name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2" }, { name = "pyside6" }, ] +provides-extras = ["postgres"] + +[package.metadata.requires-dev] +dev = [ + { name = "nuitka", specifier = ">=4.2.1" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, + { name = "pyinstaller", specifier = ">=6.22.3" }, +] [[package]] name = "cryptography" @@ -175,6 +205,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, ] +[[package]] +name = "macholib" +version = "1.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, +] + +[[package]] +name = "nuitka" +version = "4.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/ce/38409fc853cc26d86ab71ccd53f7dfddbc7a84d4821b9b54db428b35779b/nuitka-4.2.1.tar.gz", hash = "sha256:815c6e571a7a6424af384fe9d95d4ba38f1634d39a5515e8094d7fbfa090cbcb", size = 4632551, upload-time = "2026-09-05T08:08:19.117Z" } + [[package]] name = "orjson" version = "3.12.0" @@ -227,6 +275,93 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/56/630c9113ec8996778f1f0304b364b091b9a9db5fef5fdc17cca622f5ea24/orjson-3.12.0-cp315-cp315-win_arm64.whl", hash = "sha256:859fc4196855890150bb08e649b30d2c93b249b3e3edd0d3bb2231abf8aa8adc", size = 126754, upload-time = "2026-08-14T16:13:28.962Z" }, ] +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pefile" +version = "2024.8.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/26/3ea4ca5eaea1c0debcdf7ee7c1613fbe721dc27a03c461c0817ffd8a0601/psycopg-3.3.6.tar.gz", hash = "sha256:c081f2250df751a943036e42db6df4571c66cd0aabe8291a7a506512b12007d2", size = 168171, upload-time = "2026-09-18T13:22:55.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/de/748bd7609c71cae5d737f0ba9192f19329f70180ecda8fff3cac02c5abe3/psycopg-3.3.6-py3-none-any.whl", hash = "sha256:a1db9f7148b06a28606767efaca51fa6f9398c5c0a3810519be69d7000bdb631", size = 215490, upload-time = "2026-09-18T13:15:29.374Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/01/2cdd1824e58b4467ee0b9498664cd28c42d8794db6b1e35b6bcb834f0044/psycopg_binary-3.3.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3f84dab25e0385692ee13274c68678377e0b1a70ab9d14e56264cbf61f60c62d", size = 4707086, upload-time = "2026-09-18T13:18:05.138Z" }, + { url = "https://files.pythonhosted.org/packages/f6/76/de9948ac06895261c84d5b9fbe283d8f3c5bc9f070691b8d9eaa1b51e322/psycopg_binary-3.3.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:612382ac3ed13651c7fa44b5fee9fbf7baaa2ddbc6f500391672682c5f1df9e0", size = 4769607, upload-time = "2026-09-18T13:18:12.83Z" }, + { url = "https://files.pythonhosted.org/packages/76/a9/72436c9915ee4905964689e7f0e182ce7767cc0a0390b3ce703be8177625/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:366db6e97e66b37211475f20c4c1324a2dc0dd825e46d4e87f9d599304d276f9", size = 5554134, upload-time = "2026-09-18T13:18:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/0a/42/948bb3d2617795093512613fd96ba380e922992c7908fbc073858147d196/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1679a1cb93fbe5a6d1fd58d82cbddcc6fcb8c61446ba7cae6eb2a7b19bc585de", size = 5235723, upload-time = "2026-09-18T13:18:27.071Z" }, + { url = "https://files.pythonhosted.org/packages/99/47/93e823ff1b0088400703410939c9bda3e63ed9c850b3ee088e8769f4c10b/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37d40450659401600e6d043ff586c89a71a69f33cbb8bcdba6cdb2569beecdbe", size = 6833587, upload-time = "2026-09-18T13:18:33.794Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2d/ecc69c847795aa704041a9f5667a6b0938a088cf1853636d762a6938e493/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5165300324efd5a772c48a88ab3a928513ab3979fca76553e62ee815f7b2b9c", size = 5070013, upload-time = "2026-09-18T13:18:39.628Z" }, + { url = "https://files.pythonhosted.org/packages/92/36/6126f0dac21713dcae91404f2a76da18598a6252339a8c669c46370d43b2/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d636338c8f21b0df2f84657b00bc34f9313f826ef93f1155bc743607e4a0c5eb", size = 4597367, upload-time = "2026-09-18T13:18:45.023Z" }, + { url = "https://files.pythonhosted.org/packages/4d/29/7ecfc04243b46c89ffd49924e9c5634ea904ef96c7d0f37e4073623584c1/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a4ee3bdd5468a725f2a4d9aab8a74b6d0279f768c8b5d3aeb102c5307ff3d59c", size = 4275419, upload-time = "2026-09-18T13:18:49.299Z" }, + { url = "https://files.pythonhosted.org/packages/6e/90/2f46d2e0de79706ac170df0a3637fe63c4498fc04f131f6049520b78b806/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:289aadd6a00e151203c081f708348ec89f1e483c9b510ef4ac3981f847f01f79", size = 4007358, upload-time = "2026-09-18T13:18:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/03/48/6744e91291b751a8cf12d63d719977974bb94c84ceba913e7ddb2e478e51/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f21d057f3e5f5491067e5b292498073b73847d48799b099803fef100775fcc52", size = 4320156, upload-time = "2026-09-18T13:18:59.258Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9b/94ff7fce53a64d5b286e2ec454e0a025cf3d6e6b4a9189bef16aa5de98b2/psycopg_binary-3.3.6-cp312-cp312-win_amd64.whl", hash = "sha256:e23a66a763fbe83fcc210bc77c27e5a5ea380ebf091c06f34d8561b695e5a40f", size = 3658864, upload-time = "2026-09-18T13:19:06.503Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c3/c072584b69ad44a747b448cfc9766fecb8aae56e372a017e2ef668790057/psycopg_binary-3.3.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ad8f35e67cc16d1fad1fa8c88972dc9b3a3141ea67897399904edab96a301b6", size = 4712284, upload-time = "2026-09-18T13:19:13.451Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b9/4283b785339e8e2318d03048994b093d650ea6289fabaa806b765dc0d449/psycopg_binary-3.3.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373704aea331d3f3e3402c125a1543f5875e2986ebb54f97d1647942161f803f", size = 4772031, upload-time = "2026-09-18T13:19:18.524Z" }, + { url = "https://files.pythonhosted.org/packages/6f/72/7a1321d359246769fff1affffbd0132785a28f7f63c18524c15a502398f4/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b82491019b884d62318b5f30706c3d7e6d4e5a6cb7eabcb3edc0c1b0fdaceae9", size = 5556392, upload-time = "2026-09-18T13:19:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/de/b0/c6f8a0585a5dacbea74e130bcfc66629390e8f5bbc79d2a8e806e8952150/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cec5ea900390897d0b46130f60bc2883bf19c314f9044235217c8be88b0ef269", size = 5237855, upload-time = "2026-09-18T13:19:31.257Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fc/c3a7a8bbef7e945ec584ac61d460a612363ea398511cd0e220242b1d69f1/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98c02090d88f2ebc0ec1e8da538f77d225ce0fffecf372aa39262e62a1b054ef", size = 6833856, upload-time = "2026-09-18T13:19:43.622Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f2/8e80b921db728ebb68fc105bd7c4277f908210ad755bd6481d5ea7add740/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ee2c4728c691245e24501fcd7a97b5b381236b9985bc445bba88cdce7d1b5784", size = 5070730, upload-time = "2026-09-18T13:19:49.968Z" }, + { url = "https://files.pythonhosted.org/packages/54/6a/5b313e0c5348244f0e973aff3258bf86766656256d5ece8d541a53e35b4a/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f19cc87343eaa55255e76b31259a570072ac95d6ae82c92dd34b97691f5e49dc", size = 4598089, upload-time = "2026-09-18T13:19:56.426Z" }, + { url = "https://files.pythonhosted.org/packages/32/e9/db7f76ec24bf6699e92bf604e5c4bae10664a681a8999ef42aa0faf0f2c6/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdccb3a0e184b03e9baa673b15a809cf36c339c85dbda0ebc25a698846dfbee8", size = 4278481, upload-time = "2026-09-18T13:20:04.681Z" }, + { url = "https://files.pythonhosted.org/packages/61/83/72c67013656f4d6b547caabffb193e91d57e63f90eefdcc6d045c400e97d/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9892188bb15e5803beb51afe8a25add6b56be391a53058e8bca03b74e1e6bf22", size = 4009229, upload-time = "2026-09-18T13:20:11.905Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/5e4500df2c999eb0faed8b184e6958b834172128274f06167a5deef4c19c/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3af90f92769d8cc10f94515ee7a0aef36ea85ca733a0ce22858f6e0953f41138", size = 4321467, upload-time = "2026-09-18T13:20:17.949Z" }, + { url = "https://files.pythonhosted.org/packages/55/7f/e350e1cf498ba2565c3f87b12f429d2012eb86b76c2b3845a19ee5fbb4d6/psycopg_binary-3.3.6-cp313-cp313-win_amd64.whl", hash = "sha256:0ebfad5d131de9f892ae9e70cc7616207768b6714b66a52d4612b8ceaf78b372", size = 3658179, upload-time = "2026-09-18T13:20:22.691Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b9/60711317c284a442511644ea7185b56ebe627606d6741e732cd16108c47b/psycopg_binary-3.3.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b3f75dee0f9afafabe4edc52c4842f1e1878ed2069bd05b22d6fe961e97e4dba", size = 4720512, upload-time = "2026-09-18T13:20:29.278Z" }, + { url = "https://files.pythonhosted.org/packages/63/da/28befc84454cbc6374550de7746f591f8fe1b6165c1fce249652cc8291c4/psycopg_binary-3.3.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5927b7ba63153cd8e9862987290a2b783a5c590daf2a4ef981700cc3569166d4", size = 4782318, upload-time = "2026-09-18T13:20:35.401Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8a/0d21c2c833cdc0d4244c77e858e0ed37fa2abec2623be4fd686f617109ce/psycopg_binary-3.3.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0bf08b749cc144f33b44a91b78e3f71c60eb07963746a0df5a100b36ce3d7475", size = 5567460, upload-time = "2026-09-18T13:20:41.902Z" }, + { url = "https://files.pythonhosted.org/packages/49/6d/7692d0d4e656b6cc9868d8acc2e3b42f17a0db4a625400a6d093cb0533a1/psycopg_binary-3.3.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31cd942c23f613276b81a6e6598cefa12960058b0f46e1e874b540c793f6aca5", size = 5246902, upload-time = "2026-09-18T13:20:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c1/b8a1f18fb1b7558a17f57f7cb3fc8bc93189feea2958925950b3acb15743/psycopg_binary-3.3.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4690cf67738f0e0e49a32aeec99bf0e4595cc2b4f1af984a4345394b1dcff91a", size = 6847192, upload-time = "2026-09-18T13:20:56.874Z" }, + { url = "https://files.pythonhosted.org/packages/a5/76/404f33519167c65cca88ec4998776f1dbebccc301ee977f0e62c47fb0826/psycopg_binary-3.3.6-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ad1c785e784cfd87e8436c6b7702f2d321fc39601bbaf29bc63a41a867091638", size = 5079573, upload-time = "2026-09-18T13:21:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d9/79e8fbc8f37262a415f3550f0bcc5f98037442bf3d12ef6cbae2056655ae/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:79a2a1c3449f6c3409427078ed1cec10de79f3023cb5f2504f0597d350ad46c7", size = 4613633, upload-time = "2026-09-18T13:21:10.664Z" }, + { url = "https://files.pythonhosted.org/packages/d4/47/96225db74be7d2ce04b3a58678b53cda610225055edf5faa775c9f501d8b/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:86147cb5d140341c3363fb5bacce31f8d5543902a46699d3c536b101bbceaf9e", size = 4293375, upload-time = "2026-09-18T13:21:16.027Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d2/18e9c779a5efd565250329adaf529ecc2b8b2ed5be5cb0f6ccee208cbfd9/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7308c93cf0b19bbaf8e6ff0a6ad50d3c442385739245fe15a8d593bf841734a6", size = 4019883, upload-time = "2026-09-18T13:21:21.587Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/0cc654afc6c2cda982767f5679d3646b30b1ec86545bdaa9402202d6776c/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:05a83ac9fd52b9bca7cb5ab04b3691163170bd16f53defa27216ea3aa07ee781", size = 4332607, upload-time = "2026-09-18T13:21:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3e/0a753a74fbd7aef120f286c016e09d3cc3f1daf7688f4a145d27281260b2/psycopg_binary-3.3.6-cp314-cp314-win_amd64.whl", hash = "sha256:1fbd30e537dab22cafdf080608f10148fe2a5f3a61294ddb5113caac8a623840", size = 3755671, upload-time = "2026-09-18T13:21:33.855Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b1/a372b9c02aea50148e71c9853e19efca8fa5ae2010a8e27243b9b8f790c0/psycopg_binary-3.3.6-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:bf8c8481d026b85dd70c5fa7dde85b2333aed0b32a2602bcd38a900cbd78a49c", size = 4719571, upload-time = "2026-09-18T13:21:41.437Z" }, + { url = "https://files.pythonhosted.org/packages/65/7c/811e3828c6b82e2f10c6c9cdd963cfc66f3e024026e5a69ac18530bad984/psycopg_binary-3.3.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:b599defe9190b17e9907c8b4d114c181e702c87efcd1b8a0ad40971cdcc4634a", size = 4781230, upload-time = "2026-09-18T13:21:49.516Z" }, + { url = "https://files.pythonhosted.org/packages/3e/15/9a784eed813ea9e97c294af3ead63d02b7b203502c66380336c50065e441/psycopg_binary-3.3.6-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b8ece331509f7a975b90501f41e83ad905e4141753fedf3f2711b2bc70a8efbc", size = 5566111, upload-time = "2026-09-18T13:21:58.089Z" }, + { url = "https://files.pythonhosted.org/packages/68/16/47194e002007c27337b11e49bf459c4b19727463f9aff2e1a90917bcc806/psycopg_binary-3.3.6-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c61617eaae0112ca154da87ffb99b73af2c74067acac28dfb9a4455b019dff2e", size = 5249963, upload-time = "2026-09-18T13:22:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/53/84/5dcf9f310b11f0675cd860c6b2c70f58ce61798a3ee3f6f962b53fa358ca/psycopg_binary-3.3.6-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6d19cb4999d03231e8730a5f66c8f5068bc3b532677eb39dab0f600bff3e312", size = 6847925, upload-time = "2026-09-18T13:22:13.088Z" }, + { url = "https://files.pythonhosted.org/packages/f3/06/1957a06dc22963c418c27b284929579de84f29c37ad1abe6dc6ee9e8cf25/psycopg_binary-3.3.6-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8cbb54454dbf1bbf2ff08dd7693e8d94ac94b1a20f70f4b3b813d52ecb5cbc1", size = 5087720, upload-time = "2026-09-18T13:22:17.959Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/ac07d042bae99b57bf123bb473632f29af544008094da0ffd285ab8011e2/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:dc75da5a20951049f7b773145f998f69d181adad9c58a0ff36e0cf1d73c10e10", size = 4613412, upload-time = "2026-09-18T13:22:26.719Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/019156fbeafcefb4cccc9d109de4699493bceb8313c7545c8349e089dfbc/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:955e3dd94da361e052d2e49acf591017158dc8f8ed2c8a42c2e3943403c39dc2", size = 4292618, upload-time = "2026-09-18T13:22:33.042Z" }, + { url = "https://files.pythonhosted.org/packages/5d/0f/62113dc6b1df65983a1f2fc816c04b1edfa22f2ae9d4abee74ed267f4a96/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:c7753871eb57e6a5f4646f6168590c6653073dea5e9e720b201c8875332df4c8", size = 4027121, upload-time = "2026-09-18T13:22:38.334Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d5/cf0cbd1ea5a7d8167fe2c6953efde19101f7b193bd61a23e6d622ad6854c/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:303732e798fe6729f8e12021b9c96107df8e95ecec4dd487c67b98ec2a59435e", size = 4336388, upload-time = "2026-09-18T13:22:45.576Z" }, + { url = "https://files.pythonhosted.org/packages/98/33/e2a5b36edf8aa422f6fa4b894756eb33dc93b36df5f65121280bb8b929c4/psycopg_binary-3.3.6-cp315-cp315-win_amd64.whl", hash = "sha256:2f122603f36050937982abf9668d8bc4769a79f7c93a65013b1c49f1cab7b56b", size = 3756154, upload-time = "2026-09-18T13:22:51.283Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -326,6 +461,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, ] +[[package]] +name = "pyinstaller" +version = "6.22.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/41/f90302845945abd4ed647933ff5ee7c6ac93983187be67f897b6cb613331/pyinstaller-6.22.3.tar.gz", hash = "sha256:05eb2f5615503e72939a7224d68b4aff572c6b0438ee4a17d0a4b481f399362d", size = 4437180, upload-time = "2026-09-12T21:54:09.669Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/af/898f70947e27f8e65d55183c780e77112e023c0b4a6fba6255d4d563a27d/pyinstaller-6.22.3-py3-none-macosx_10_13_universal2.whl", hash = "sha256:052f4a1cd4f81092ccb7a18fe8ebc9ecf832a5917317e39fb3775af4e959b253", size = 1071847, upload-time = "2026-09-12T21:53:01.711Z" }, + { url = "https://files.pythonhosted.org/packages/c4/96/99f5184985141c2b024bfa61335e9142ae444555224f303186e59b7bb438/pyinstaller-6.22.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:312da84b4b31ab9750066a823734c11b7c68757fdd67c9f038bf19c330a954e4", size = 758172, upload-time = "2026-09-12T21:53:06.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/9f/9ccb12cc1e6617042cb028fe79f0a756f5f03305bd14b584a4e043fe3844/pyinstaller-6.22.3-py3-none-manylinux2014_i686.whl", hash = "sha256:020d4fe0627f5c73dfbcfad1a6578ef354d5fe4bdccd9d8e5a615528f5a45d2b", size = 772135, upload-time = "2026-09-12T21:53:10.291Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/4b1d5193a88628c052db89f064433681076959a9f41d0d9f660001b49079/pyinstaller-6.22.3-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:9e4f7bd64b2103f14b4a6240fb9155e9fe7bc61391b4043bbddb7d3ebc8ab0fa", size = 770461, upload-time = "2026-09-12T21:53:14.592Z" }, + { url = "https://files.pythonhosted.org/packages/cb/83/f173f8757c618d83e116fa2b8e57c5cd796e5ad09792e664eabd6ac5edae/pyinstaller-6.22.3-py3-none-manylinux2014_s390x.whl", hash = "sha256:f82d0fb89f21c3ed6f482ee16f341bc59e8a31dbabb9bae9ef04e53ade4dc0d9", size = 764997, upload-time = "2026-09-12T21:53:19.079Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b6/dd21d61fc5e25377be24eabf13e99e4e46548a5abe2bf7b15d8b3d9cc74a/pyinstaller-6.22.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:451a4ae14b719365bf1a2f0a99dae7b3463060061c3a394c70d5264cfb439528", size = 764974, upload-time = "2026-09-12T21:53:23.176Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fd/d9590942972b1fc205519f7ccffb0d5cd8eb1d6fa61edad319c71d25baed/pyinstaller-6.22.3-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:ea240b5dfa831ff673c97505eee0ec24670bccf71396c83109440348944c83d0", size = 764450, upload-time = "2026-09-12T21:53:27.419Z" }, + { url = "https://files.pythonhosted.org/packages/72/d6/f22c55d1768433b65c460b19a3136dd9769b6c74b786149b829344654c7d/pyinstaller-6.22.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a2b6fc26601e8c2596c3d54981566934a3382e85d05d0fe91b55b6a8dcc6b282", size = 763863, upload-time = "2026-09-12T21:53:31.609Z" }, + { url = "https://files.pythonhosted.org/packages/c5/87/cf28cfd42ceca889171e6dd5e7f1553d4372db83d047049ffb7cf95fcbd4/pyinstaller-6.22.3-py3-none-win32.whl", hash = "sha256:46a3e30a118341a1ec20667a5a95aa7e0aa8c4fa129893c0151b3eb3f801a722", size = 1390905, upload-time = "2026-09-12T21:53:38.133Z" }, + { url = "https://files.pythonhosted.org/packages/24/83/c5422204772a27aef643280fdc887b7ef55679989096b21dceb1b9625fc4/pyinstaller-6.22.3-py3-none-win_amd64.whl", hash = "sha256:500bd58c7bf7e584a8435adccbd763a0b918d5c12b08d74ff50fd79b2915458b", size = 1454907, upload-time = "2026-09-12T21:53:44.812Z" }, + { url = "https://files.pythonhosted.org/packages/67/2e/ebc93968bf63f81b122e205da116b16036b653b7ce2cc8ad86e3ddd19586/pyinstaller-6.22.3-py3-none-win_arm64.whl", hash = "sha256:31df3cc4261e804a53d358b7897c293ec8b88b167a7cca06c78018085c855f97", size = 1402956, upload-time = "2026-09-12T21:53:51.369Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2026.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/60/d881fa1ba8c160c18d8e6f782bb16ec4640c08bc08fc50f704c368ad4f9e/pyinstaller_hooks_contrib-2026.7.tar.gz", hash = "sha256:5fbcaacb22c4f4aac869a127dce283f67a4b4cfcc37d496f2446603e6d68aefa", size = 175992, upload-time = "2026-08-24T21:26:58.713Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/67/350377af7b50416344ab8792756d414eef7629c618a73e9a0b13bb1552d9/pyinstaller_hooks_contrib-2026.7-py3-none-any.whl", hash = "sha256:24257a04c7a5a7a034cf28e39dcee20fbeeb9f043076729480f2e1b69904408a", size = 459445, upload-time = "2026-08-24T21:26:57.274Z" }, +] + [[package]] name = "pyside6" version = "6.11.2" @@ -374,6 +549,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cd/87/4dd2a6468cea600da1fcf9c7f02bc248eeaa98cdc75658e48e669d95326a/pyside6_essentials-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:fadd75c5c20800d64dd0a586ea8cb337c5e630aa2246df0e5105738afa46e02c", size = 58125520, upload-time = "2026-08-18T06:36:59.073Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + [[package]] name = "shiboken6" version = "6.11.2" @@ -406,3 +599,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c wheels = [ { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] + +[[package]] +name = "tzdata" +version = "2026.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" }, +]