"""生成文档用的界面截图(无头运行)。 用法:: 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())