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