Files
chongming-license-gui/tools/screenshot.py
T
2026-09-22 16:08:12 +08:00

236 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""生成文档用的界面截图(无头运行)。
用法::
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())