modify: 台账更改插件
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
**本工具不随部署产物分发** —— 签发私钥只应存在于厂商机器上。
|
||||
|
||||
## 界面
|
||||
|
||||

|
||||
|
||||
界面为现代浅色卡片风:白底卡片分区、主色 `#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` 时**中止构建** ——
|
||||
这个工具含签发能力,私钥随包发出去等于任何人都能签发授权;
|
||||
- **产物自检**:构建后自动跑 `<exe> --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):激活流程、命令行参考、机器指纹与容错判定、
|
||||
强制执行点、安全边界、故障排查(含原因码全表)。
|
||||
|
||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,60 @@
|
||||
-- chongming 机器码授权工具 —— PostgreSQL 台账建表脚本
|
||||
--
|
||||
-- 什么时候需要它?
|
||||
-- 工具**首次连接**会自动执行等价的 DDL(CREATE TABLE IF NOT EXISTS)。
|
||||
-- 但如果数据库账号没有建表权限,自动建表会失败并提示你执行本脚本 ——
|
||||
-- 此时请把本文件交给 DBA,在目标库上执行一次即可。
|
||||
--
|
||||
-- 执行方式(二选一):
|
||||
-- psql -h <主机> -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 读写)。
|
||||
@@ -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. 三步上手
|
||||
|
||||
### 第一步:准备私钥(只做一次)
|
||||
|
||||
打开「**密钥**」页,点「**生成密钥对**」。
|
||||
|
||||

|
||||
|
||||
- 私钥会写到你指定的路径(默认 `license_private.pem`),**离线备份到 U 盘/密码管理器**。
|
||||
- 公钥会自动复制到剪贴板 —— 下一步"把公钥注入设备"就要用它。
|
||||
- 记住页面上显示的**公钥指纹**(形如 `3F9A-1C2B-…`)。之后每次签发前都核对一眼,
|
||||
能避免"拿错密钥签了一批授权"这种最难排查的事故。
|
||||
|
||||
如果公司已经有一把私钥(比如研发那边统一管理),用下半部分「**加载现有私钥**」选中它即可。
|
||||
|
||||
### 第二步:让客户导出申请码
|
||||
|
||||
告诉客户(或现场同事)在设备上打开 `http://<设备IP>:8000/static/license`,
|
||||
点「导出申请码」,把那一串 `CM1-…` 复制给你(微信/邮件都行,**可以带换行和空格**)。
|
||||
|
||||
> 客户回传**申请码**而不是机器码:机器码是哈希,反推不出各个硬件标识,
|
||||
> 只能签出"精确匹配、换硬件即失效"的授权;申请码带完整指纹,才能享受换硬件容错。
|
||||
|
||||
### 第三步:签发
|
||||
|
||||
打开「**签发**」页,粘贴申请码 → 点「解析申请码」→ 勾选要授权的插件 →
|
||||
填客户名 → 点「**签发并保存…**」。
|
||||
|
||||

|
||||
|
||||
把生成的 `.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 台账页
|
||||
|
||||
已签发授权的检索与管理。**签发页签发的每一份都会自动入库**。
|
||||
|
||||

|
||||
|
||||
#### 台账库
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| 存储 | **本地 SQLite**(默认,单文件)或 **PostgreSQL**(团队共用一份,见 [4.9](#49-团队共用台账postgresql)) |
|
||||
| 路径(本地) | 台账库文件位置,默认 `~/.chongming/licenses.db`(单文件 SQLite,直接拷走即备份) |
|
||||
| 连接(PostgreSQL) | 连接串摘要 + 「连接设置…」,**不显示密码** |
|
||||
| 概览 | 总数 + 有效 / 即将到期 / 已过期 / 永久 / 未生效 / 当前筛选数 |
|
||||
| 签名私钥 | 续期要用私钥;未加载时会提示去「密钥」页 |
|
||||
|
||||
首次指定一个不存在的路径 → 点「打开 / 新建」即可创建空台账。
|
||||
选 PostgreSQL 时点「打开 / 新建」建立连接(首次会自动建表)。
|
||||
|
||||
#### 筛选
|
||||
|
||||
客户(模糊)、机器码(模糊、大小写不敏感)、插件 type(精确)、
|
||||
关键字(同时匹配编号/机器码/客户/备注)、状态、以及「即将到期」的判定窗口(默认 30 天)。
|
||||
|
||||
列表按**到期日升序**,最急的排在最前。
|
||||
|
||||
| 状态 | 含义 | 颜色 |
|
||||
| --- | --- | --- |
|
||||
| 未生效 | 生效日期还没到 | 紫 |
|
||||
| 已过期 | 到期日已过 | 红 |
|
||||
| 即将到期 | 在判定窗口内(默认 30 天内) | 橙 |
|
||||
| 有效 | 正常可用 | 绿 |
|
||||
| 永久有效 | 无到期日 | 蓝 |
|
||||
|
||||
> 判定顺序是"未生效 → 永久 → 已过期 → 即将到期 → 有效"。
|
||||
> 所以一份"生效日期在未来"的授权即使同时填了过去的到期日,也显示为**未生效**。
|
||||
|
||||
#### 行操作
|
||||
|
||||
| 按钮 | 作用 |
|
||||
| --- | --- |
|
||||
| **续期选中…** | 打开续期对话框:只改要变的部分,**直接复用台账里的机器指纹,无需再向客户索取申请码** |
|
||||
| **改插件…** | 重签一份只换了插件白名单的新授权:**有效期与生效日原样保留**,指纹同样复用台账 |
|
||||
| 编辑备注 | 人工批注("已电话确认续期"、"设备已返修")。**重新导入 license 不会覆盖它** |
|
||||
| 复制机器码 | 复制选中行的机器码 |
|
||||
| 查看详情 | 摘要 + **当初签发的那份 license 原始内容**,可直接复制 |
|
||||
| 删除记录 | 只删台账记录,**不影响已经发给客户的 .lic**(那些文件仍然有效) |
|
||||
|
||||
**续期对话框**长这样:
|
||||
|
||||

|
||||
|
||||
- 上半部分是**原授权**(只读),下半部分是**新授权**(可改)。
|
||||
- 编号默认「生成新授权编号」:新旧两条都留在台账,可追溯;需要沿用原编号也可以切换。
|
||||
- 旧记录会自动加上 `已续期 → <新编号>` 批注。
|
||||
|
||||
**改插件对话框**用于"客户说设备上少了一个插件能用"这类需求:
|
||||
|
||||

|
||||
|
||||
- 顶部「原授权」是只读摘要(编号 / 客户 / 机器码 / 有效期 / 指纹个数),
|
||||
下面的勾选列表与签发页是同一个组件:项目里 `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`(比如客户反馈"导入失败",或你要发货前自检)时用这一页。
|
||||
|
||||

|
||||
|
||||
| 区域 | 说明 |
|
||||
| --- | --- |
|
||||
| license 文件 | 选或填 `.lic` 路径 |
|
||||
| 公钥 | 验签用的公钥(base64 或 PEM);**留空则只查看内容、不验签** |
|
||||
| 指纹比对基准 | **按本机**(在你自己电脑上通常对不上,属正常)/**按指定机器**(填客户申请码) |
|
||||
| 校验结论 | 成功/失败横幅 + 原因码 + 命中与不匹配的来源明细 |
|
||||
| 内容 | license 的完整 JSON |
|
||||
|
||||
> **发货前自检的正确做法**:选「按指定机器」并填入**客户设备的申请码**。
|
||||
> 只验签名只能证明"这文件是我们签的",验不出"是不是给这台设备签的"。
|
||||
> 填了申请码,工具才会真正核对机器指纹。
|
||||
|
||||
校验失败时长这样(下面是"拿 A 设备的授权去校 B 设备"):
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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 来源时会降级纳入判定并在结论里告警)。
|
||||
@@ -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 上仍需要少量滚动。若要进一步压缩,
|
||||
可以把"来源指纹"与"解析结果"并排放,或把「预览与签发」提到更靠上的位置 ——
|
||||
但那是信息架构调整,不是样式问题。
|
||||
@@ -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 模式把工作目录里的散落文件一起拷进去。
|
||||
|
||||
## 构建后自检(为什么必须有)
|
||||
|
||||
冻结失败往往是**静默**的 —— 界面能开,但图标全空白、复选框点不动,或者签发时才抛异常。
|
||||
所以构建脚本会自动调用 ``<exe> --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 侧的,打包目标平台与授权对象设备无关。
|
||||
@@ -0,0 +1,404 @@
|
||||
"""打包脚本共用部分:图标生成、私钥守卫、冻结产物自检驱动。
|
||||
|
||||
两个打包脚本(PyInstaller / Nuitka)只有"怎么调工具"不同,其余诉求一样:
|
||||
|
||||
1. **别把私钥打进去** —— 构建前扫一遍源码树,发现私钥/授权文件/台账库立即中止;
|
||||
2. **给 exe 一个像样的图标** —— 图标是运行时由主题生成的,这里把它渲染成多尺寸
|
||||
.ico(所以仓库里不需要存美术素材);
|
||||
3. **构建完必须验证** —— 调用 ``<exe> --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("<BBBBHHII", dim, dim, 0, 0, 1, 32, len(png), offset)
|
||||
offset += len(png)
|
||||
blobs += png
|
||||
target.write_bytes(struct.pack("<HHH", 0, 1, len(pngs)) + entries + blobs)
|
||||
|
||||
|
||||
def build_app_icon(out_dir: Path | None = None) -> 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]:
|
||||
"""运行 ``<exe> --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)
|
||||
@@ -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())
|
||||
@@ -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``,无需美术素材。
|
||||
|
||||
构建后自动跑 ``<exe> --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())
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'<span style="font-family:\'{mono}\';">'
|
||||
f"{self.signing_key.fingerprint}</span>"
|
||||
)
|
||||
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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": (
|
||||
'<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/>',
|
||||
'<path d="M14 3v5h5"/>',
|
||||
'<path d="M9 13h6"/>',
|
||||
'<path d="M9 17h4"/>',
|
||||
),
|
||||
"registry": (
|
||||
'<ellipse cx="12" cy="6" rx="8" ry="3"/>',
|
||||
'<path d="M4 6v6c0 1.66 3.58 3 8 3s8-1.34 8-3V6"/>',
|
||||
'<path d="M4 12v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6"/>',
|
||||
),
|
||||
"key": (
|
||||
'<circle cx="7.5" cy="15.5" r="4.2"/>',
|
||||
'<path d="M10.6 12.4 20.5 2.5"/>',
|
||||
'<path d="M17.2 2h3.3v3.3"/>',
|
||||
),
|
||||
"verify": (
|
||||
'<path d="M12 3l7 3v5.5c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6z"/>',
|
||||
'<path d="M9 12l2.2 2.2L15.5 10"/>',
|
||||
),
|
||||
# 操作
|
||||
"copy": (
|
||||
'<rect x="9" y="9" width="11" height="11" rx="2"/>',
|
||||
'<path d="M6 15H5.5A1.5 1.5 0 0 1 4 13.5v-9A1.5 1.5 0 0 1 5.5 3h9A1.5 1.5 0 0 1 16 4.5V5"/>',
|
||||
),
|
||||
"folder": (
|
||||
'<path d="M3 7a2 2 0 0 1 2-2h3.5l2 2H19a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
||||
),
|
||||
"save": (
|
||||
'<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>',
|
||||
'<path d="M17 21v-8H7v8"/>',
|
||||
'<path d="M7 3v5h7"/>',
|
||||
),
|
||||
"refresh": (
|
||||
'<path d="M21 12a9 9 0 1 1-2.7-6.4"/>',
|
||||
'<path d="M21 3v6h-6"/>',
|
||||
),
|
||||
"reset": (
|
||||
'<path d="M3 12a9 9 0 1 0 2.7-6.4"/>',
|
||||
'<path d="M3 3v6h6"/>',
|
||||
),
|
||||
"add": ('<path d="M12 5v14"/>', '<path d="M5 12h14"/>'),
|
||||
"remove": ('<path d="M5 12h14"/>',),
|
||||
"select_all": (
|
||||
'<rect x="3" y="3" width="18" height="18" rx="3.5"/>',
|
||||
'<path d="M8 12.3l2.6 2.6L16 9.6"/>',
|
||||
),
|
||||
"select_none": (
|
||||
'<rect x="3" y="3" width="18" height="18" rx="3.5"/>',
|
||||
'<path d="M8.5 12h7"/>',
|
||||
),
|
||||
"export": (
|
||||
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>',
|
||||
'<path d="M7 10l5 5 5-5"/>',
|
||||
'<path d="M12 15V3"/>',
|
||||
),
|
||||
"import_file": (
|
||||
'<path d="M12 3v12"/>',
|
||||
'<path d="M7 10l5 5 5-5"/>',
|
||||
'<path d="M4 20h16"/>',
|
||||
),
|
||||
"search": (
|
||||
'<circle cx="11" cy="11" r="7"/>',
|
||||
'<path d="M20.5 20.5l-4.4-4.4"/>',
|
||||
),
|
||||
"trash": (
|
||||
'<path d="M4 7h16"/>',
|
||||
'<path d="M6.5 7l1 12.1a2 2 0 0 0 2 1.9h5a2 2 0 0 0 2-1.9L17.5 7"/>',
|
||||
'<path d="M10 11v6M14 11v6"/>',
|
||||
'<path d="M9.5 7V5.2A1.2 1.2 0 0 1 10.7 4h2.6a1.2 1.2 0 0 1 1.2 1.2V7"/>',
|
||||
),
|
||||
"edit": (
|
||||
'<path d="M12 20h9"/>',
|
||||
'<path d="M16.6 3.6a2 2 0 0 1 2.8 2.8L7.5 18.3 3.5 19.5l1.2-4z"/>',
|
||||
),
|
||||
"open": (
|
||||
'<path d="M4 19.5 6 12h15l-2 7.5z"/>',
|
||||
'<path d="M4 19.5V5.5A1.5 1.5 0 0 1 5.5 4h4L11.5 6H18a2 2 0 0 1 2 2v3"/>',
|
||||
),
|
||||
"lock": (
|
||||
'<rect x="4.5" y="10" width="15" height="10.5" rx="2.2"/>',
|
||||
'<path d="M8 10V7.2A4 4 0 0 1 16 7.2V10"/>',
|
||||
'<path d="M12 14v3"/>',
|
||||
),
|
||||
"shield": (
|
||||
'<path d="M12 3l7 3v5.5c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6z"/>',
|
||||
),
|
||||
"alert": (
|
||||
'<path d="M10.3 4.4 2.7 17.4A2 2 0 0 0 4.4 20.4h15.2a2 2 0 0 0 1.7-3L13.7 4.4a2 2 0 0 0-3.4 0z"/>',
|
||||
'<path d="M12 9.5v4"/>',
|
||||
'<path d="M12 17h.01"/>',
|
||||
),
|
||||
"info": (
|
||||
'<circle cx="12" cy="12" r="9"/>',
|
||||
'<path d="M12 11v5"/>',
|
||||
'<path d="M12 7.8h.01"/>',
|
||||
),
|
||||
"chevron_down": ('<path d="M6 9.5l6 6 6-6"/>',),
|
||||
}
|
||||
|
||||
_SVG_TEMPLATE = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" '
|
||||
'fill="none" stroke="{color}" stroke-width="1.8" stroke-linecap="round" '
|
||||
'stroke-linejoin="round">{shapes}</svg>'
|
||||
)
|
||||
|
||||
# 渲染倍率: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
|
||||
@@ -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}",
|
||||
|
||||
@@ -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 "(无)"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
@@ -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 = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" '
|
||||
'viewBox="0 0 16 16">{body}</svg>'
|
||||
)
|
||||
|
||||
|
||||
def _indicator_svg(name: str) -> str:
|
||||
"""按当前 token 生成指示器 SVG(改主题色时指示器一起变)。"""
|
||||
if name == "check_off":
|
||||
body = (
|
||||
f'<rect x="1.6" y="1.6" width="12.8" height="12.8" rx="3.6" '
|
||||
f'fill="{SURFACE}" stroke="{BORDER_STRONG}" stroke-width="1.4"/>'
|
||||
)
|
||||
elif name == "check_on":
|
||||
body = (
|
||||
f'<rect x="1.6" y="1.6" width="12.8" height="12.8" rx="3.6" fill="{ACCENT}"/>'
|
||||
'<path d="M4.7 8.3l2.2 2.2 4.4-4.8" fill="none" stroke="#ffffff" '
|
||||
'stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>'
|
||||
)
|
||||
elif name == "check_off_disabled":
|
||||
body = (
|
||||
f'<rect x="1.6" y="1.6" width="12.8" height="12.8" rx="3.6" '
|
||||
f'fill="{DISABLED_BG}" stroke="{BORDER}" stroke-width="1.4"/>'
|
||||
)
|
||||
elif name == "radio_off":
|
||||
body = (
|
||||
f'<circle cx="8" cy="8" r="6.4" fill="{SURFACE}" stroke="{BORDER_STRONG}" '
|
||||
'stroke-width="1.4"/>'
|
||||
)
|
||||
elif name == "radio_on":
|
||||
body = (
|
||||
f'<circle cx="8" cy="8" r="6.4" fill="{SURFACE}" stroke="{ACCENT}" '
|
||||
'stroke-width="1.6"/>'
|
||||
f'<circle cx="8" cy="8" r="3.2" fill="{ACCENT}"/>'
|
||||
)
|
||||
else: # radio_off_disabled
|
||||
body = (
|
||||
f'<circle cx="8" cy="8" r="6.4" fill="{DISABLED_BG}" stroke="{BORDER}" '
|
||||
'stroke-width="1.4"/>'
|
||||
)
|
||||
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)
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||