Files
chongming-license-gui/src/chongming_license_gui/issue_tab.py
T

478 lines
20 KiB
Python
Raw Normal View History

2026-09-21 14:06:55 +08:00
"""签发页:把"申请码 → license 文件"的过程可视化。
载荷与签名一律调用 ``chongming_license.issue``(与 CLI 共用),本页只负责
收集输入、展示解析结果、给出签发前预览 —— 让操作者**在按下签发之前**就能看清
"给谁、哪台机器、哪些插件、多久",而不是签完才发现勾错了插件。
"""
from __future__ import annotations
import json
from pathlib import Path
from PySide6.QtCore import QDate, QSettings, Qt
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QComboBox,
QDateEdit,
QFileDialog,
QLabel,
QLineEdit,
QPlainTextEdit,
QRadioButton,
QSpinBox,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from chongming_license import (
RequestCodeError,
build_license,
build_payload,
decode_request_code,
machine_code,
write_license,
)
from .common import (
ask_overwrite,
2026-09-22 16:08:12 +08:00
btn,
2026-09-21 14:06:55 +08:00
copy_to_clipboard,
error,
2026-09-22 16:08:12 +08:00
hint,
2026-09-21 14:06:55 +08:00
hbox,
info,
make_mono_label,
2026-09-22 16:08:12 +08:00
mono_font,
2026-09-21 14:06:55 +08:00
section,
2026-09-22 16:08:12 +08:00
set_status,
2026-09-21 14:06:55 +08:00
warn,
)
from .keystore import SigningKey
2026-09-22 16:08:12 +08:00
from .plugin_picker import PluginPicker
2026-09-21 14:06:55 +08:00
# 签发依据的三种方式(与 CLI 的 --request-code / --sources-json / --machine-code 对位)
MODE_REQUEST_CODE = 0
MODE_SOURCES_JSON = 1
MODE_MACHINE_CODE = 2
MODE_LABELS = ("申请码(推荐,支持换硬件容错)", "来源 JSON 文件", "仅机器码(精确匹配,无容错)")
class IssueTab(QWidget):
"""签发页。"""
def __init__(self, signing_key: SigningKey, registry, settings: QSettings,
parent=None) -> None:
super().__init__(parent)
self.key = signing_key
self.registry = registry
self.settings = settings
# 当前解析出的签发对象
self._sources: dict[str, str] = {}
self._machine_code: str = ""
self._source_note: str = ""
root = QVBoxLayout(self)
2026-09-22 16:08:12 +08:00
root.setContentsMargins(14, 14, 14, 14)
root.setSpacing(12)
2026-09-21 14:06:55 +08:00
# ---------- 签名私钥 ----------
key_box, key_form = section("签名私钥")
self.key_status = QLabel("—")
self.key_status.setWordWrap(True)
2026-09-22 16:08:12 +08:00
# 指纹要逐字符核对,必须等宽
2026-09-21 14:06:55 +08:00
self.key_fp = QLabel("—")
2026-09-22 16:08:12 +08:00
self.key_fp.setFont(mono_font(11))
2026-09-21 14:06:55 +08:00
self.key_fp.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
key_form.addRow("当前", self.key_status)
key_form.addRow("公钥指纹", self.key_fp)
2026-09-22 16:08:12 +08:00
key_form.addRow("", hint("在「密钥」页生成或加载私钥。签发前请核对公钥指纹与设备端一致。"))
2026-09-21 14:06:55 +08:00
root.addWidget(key_box)
# ---------- 签发依据 ----------
target_box, target_form = section("签发依据")
self.mode_combo = QComboBox()
self.mode_combo.addItems(MODE_LABELS)
self.mode_combo.currentIndexChanged.connect(self._on_mode_changed)
target_form.addRow("方式", self.mode_combo)
self.stack = QStackedWidget()
# 页 0:申请码
page_rc = QWidget()
rc_layout = QVBoxLayout(page_rc)
rc_layout.setContentsMargins(0, 0, 0, 0)
self.rc_edit = QPlainTextEdit()
self.rc_edit.setPlaceholderText(
"粘贴客户发来的申请码(CM1-... ,可带换行/空格,会自动清理)"
)
self.rc_edit.setMaximumHeight(76)
rc_layout.addWidget(self.rc_edit)
2026-09-22 16:08:12 +08:00
rc_button = btn("解析申请码", icon_name="search")
rc_button.clicked.connect(self._parse_request_code)
rc_layout.addWidget(rc_button)
2026-09-21 14:06:55 +08:00
self.stack.addWidget(page_rc)
# 页 1:来源 JSON 文件
page_json = QWidget()
json_layout = QVBoxLayout(page_json)
json_layout.setContentsMargins(0, 0, 0, 0)
self.json_edit = QLineEdit()
self.json_edit.setPlaceholderText("show --json 的导出文件(含 sources 字段,或就是来源字典)")
2026-09-22 16:08:12 +08:00
json_browse = btn("浏览…", icon_name="folder")
2026-09-21 14:06:55 +08:00
json_browse.clicked.connect(self._pick_json)
2026-09-22 16:08:12 +08:00
json_load = btn("解析", icon_name="import_file")
2026-09-21 14:06:55 +08:00
json_load.clicked.connect(self._parse_json_file)
json_layout.addWidget(hbox(self.json_edit, json_browse, json_load))
self.stack.addWidget(page_json)
# 页 2:仅机器码
page_mc = QWidget()
mc_layout = QVBoxLayout(page_mc)
mc_layout.setContentsMargins(0, 0, 0, 0)
self.mc_edit = QLineEdit()
self.mc_edit.setPlaceholderText("如 BC60-D20E-F863-7236-68E2-6A24")
self.mc_edit.setFont(make_mono_label().font())
2026-09-22 16:08:12 +08:00
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"
2026-09-21 14:06:55 +08:00
))
self.stack.addWidget(page_mc)
target_form.addRow("", self.stack)
self.target_result = QLabel("尚未解析签发对象")
self.target_result.setWordWrap(True)
self.target_result.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
target_form.addRow("解析结果", self.target_result)
self.sources_view = QPlainTextEdit()
self.sources_view.setReadOnly(True)
self.sources_view.setFont(make_mono_label().font())
self.sources_view.setMaximumHeight(96)
self.sources_view.setPlaceholderText("(各硬件来源指纹,解析成功后显示)")
target_form.addRow("来源指纹", self.sources_view)
root.addWidget(target_box)
# ---------- 授权插件 ----------
2026-09-22 16:08:12 +08:00
# 勾选组件与台账「改插件」共用(plugin_picker):行直接加进本卡片,排版与
# 抽出之前一致;下面几个属性名沿用旧名,既有代码与回归测试都在用
2026-09-21 14:06:55 +08:00
plugin_box, plugin_form = section("授权插件")
2026-09-22 16:08:12 +08:00
self.plugin_picker = PluginPicker(
plugin_form, settings, notify=lambda title, msg: warn(self, title, msg)
2026-09-21 14:06:55 +08:00
)
2026-09-22 16:08:12 +08:00
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
2026-09-21 14:06:55 +08:00
root.addWidget(plugin_box)
# ---------- 授权信息 ----------
info_box, info_form = section("授权信息")
self.customer_edit = QLineEdit(str(settings.value("issue/customer", "")))
info_form.addRow("客户名称", self.customer_edit)
self.lic_id_edit = QLineEdit("")
self.lic_id_edit.setPlaceholderText("留空则自动生成(L<日期>-<随机>)")
info_form.addRow("授权编号", self.lic_id_edit)
self.note_edit = QLineEdit(str(settings.value("issue/note", "")))
self.note_edit.setPlaceholderText("合同号等(仅展示/审计)")
info_form.addRow("备注", self.note_edit)
self.kid_edit = QLineEdit(str(settings.value("issue/kid", "")))
self.kid_edit.setPlaceholderText("可选,写入 license 的 kid 字段")
info_form.addRow("密钥标识", self.kid_edit)
# 有效期三选一
self.expiry_group = QButtonGroup(self)
self.rb_days = QRadioButton("N 天")
self.rb_days.setChecked(True)
self.days_spin = QSpinBox()
self.days_spin.setRange(1, 36500)
self.days_spin.setValue(int(settings.value("issue/days", 365)))
self.days_spin.setSuffix(" 天")
self.rb_date = QRadioButton("指定到期日")
self.date_edit = QDateEdit()
self.date_edit.setCalendarPopup(True)
self.date_edit.setDisplayFormat("yyyy-MM-dd")
self.date_edit.setDate(QDate.currentDate().addYears(1))
self.rb_forever = QRadioButton("永久有效")
for rb in (self.rb_days, self.rb_date, self.rb_forever):
self.expiry_group.addButton(rb)
# 启停联动的输入控件,避免"选了永久却还能改天数"的歧义
self.rb_days.toggled.connect(lambda on: self.days_spin.setEnabled(on))
self.rb_date.toggled.connect(lambda on: self.date_edit.setEnabled(on))
self.days_spin.setEnabled(True)
self.date_edit.setEnabled(False)
info_form.addRow("有效期", hbox(self.rb_days, self.days_spin, self.rb_date, self.date_edit))
info_form.addRow("", self.rb_forever)
root.addWidget(info_box)
# ---------- 预览与签发 ----------
action_box, action_form = section("预览与签发")
self.preview = QPlainTextEdit()
self.preview.setReadOnly(True)
self.preview.setFont(make_mono_label().font())
self.preview.setMinimumHeight(150)
self.preview.setPlaceholderText("载荷预览:点「刷新预览」")
action_form.addRow("", self.preview)
2026-09-22 16:08:12 +08:00
refresh_btn = btn("刷新预览", icon_name="refresh")
2026-09-21 14:06:55 +08:00
refresh_btn.clicked.connect(self._refresh_preview)
2026-09-22 16:08:12 +08:00
copy_btn = btn("复制预览", icon_name="copy")
2026-09-21 14:06:55 +08:00
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(
"台账会连机器指纹一起存下来,续期时可直接复用,无需再向客户索取申请码"
)
2026-09-22 16:08:12 +08:00
self.issue_btn = btn("签发并保存…", role="primary", icon_name="save")
2026-09-21 14:06:55 +08:00
self.issue_btn.clicked.connect(self._issue)
action_form.addRow("", hbox(refresh_btn, copy_btn, self.issue_btn))
action_form.addRow("", self.record_check)
root.addWidget(action_box)
root.addStretch(1)
self.key.changed.connect(self._sync_key)
self._sync_key()
self._reload_plugins()
# ------------------------------------------------------------------
# 私钥状态
# ------------------------------------------------------------------
def _sync_key(self) -> None:
if self.key.loaded:
self.key_status.setText(self.key.describe())
self.key_fp.setText(self.key.fingerprint)
else:
self.key_status.setText("未加载私钥 —— 请到「密钥」页生成或加载")
self.key_fp.setText("—")
# ------------------------------------------------------------------
2026-09-22 16:08:12 +08:00
# 插件列表(实现在 plugin_picker.PluginPicker,这里只为既有调用点留入口)
2026-09-21 14:06:55 +08:00
# ------------------------------------------------------------------
def _reload_plugins(self) -> None:
2026-09-22 16:08:12 +08:00
self.plugin_picker.reload()
2026-09-21 14:06:55 +08:00
def _checked_plugins(self) -> list[str]:
2026-09-22 16:08:12 +08:00
return self.plugin_picker.checked()
2026-09-21 14:06:55 +08:00
def _set_all_checked(self, checked: bool) -> None:
2026-09-22 16:08:12 +08:00
self.plugin_picker.set_all(checked)
2026-09-21 14:06:55 +08:00
# ------------------------------------------------------------------
# 签发依据解析
# ------------------------------------------------------------------
def _on_mode_changed(self, index: int) -> None:
self.stack.setCurrentIndex(index)
self._clear_target()
def _clear_target(self) -> None:
self._sources = {}
self._machine_code = ""
self._source_note = ""
2026-09-22 16:08:12 +08:00
set_status(self.target_result, "尚未解析签发对象", "hint")
2026-09-21 14:06:55 +08:00
self.sources_view.clear()
self.preview.clear()
def _pick_json(self) -> None:
path, _ = QFileDialog.getOpenFileName(
self, "选择来源 JSON 文件", self.json_edit.text() or "", "JSON 文件 (*.json);;所有文件 (*)"
)
if path:
self.json_edit.setText(path)
self._parse_json_file()
def _parse_request_code(self) -> None:
raw = self.rc_edit.toPlainText()
if not raw.strip():
warn(self, "未填写", "请粘贴客户发来的申请码。")
return
try:
data = decode_request_code(raw)
except RequestCodeError as exc:
error(self, "申请码无法解析", f"{exc}")
return
self._set_target(
data["sources"], data["machine_code"],
note=f"来自申请码({len(data['sources'])} 个来源)",
)
def _parse_json_file(self) -> None:
path = self.json_edit.text().strip()
if not path:
warn(self, "未选择文件", "请选择来源 JSON 文件。")
return
try:
raw = json.loads(Path(path).read_text(encoding="utf-8"))
except Exception as exc: # noqa: BLE001 - 读盘/解析失败统一提示
error(self, "无法读取", f"{exc}")
return
sources = raw.get("sources", raw) if isinstance(raw, dict) else {}
if not isinstance(sources, dict) or not sources:
error(self, "内容不含来源", f"{path} 中未找到 sources 字段,且本身不是来源字典。")
return
sources = {str(k): str(v) for k, v in sources.items()}
try:
code = raw.get("machine_code") or machine_code(sources)
except Exception as exc: # noqa: BLE001
error(self, "无法推导机器码", f"{exc}")
return
self._set_target(sources, str(code), note=f"来自文件 {Path(path).name}")
def _use_machine_code(self) -> None:
code = self.mc_edit.text().strip().upper()
if not code:
warn(self, "未填写", "请输入机器码。")
return
self._set_target({}, code, note="仅机器码(精确匹配,无换硬件容错)")
def _set_target(self, sources: dict[str, str], code: str, note: str) -> None:
self._sources = dict(sources)
self._machine_code = str(code).strip().upper()
self._source_note = note
detail = [f"机器码:{self._machine_code}", note]
if sources:
# weak 来源不参与判定阈值,这里如实标注,避免操作者误以为来源越多越保险
from chongming_license import SOURCES as _SOURCES
weak = {s.name for s in _SOURCES if s.weak}
strong = sorted(k for k in sources if k not in weak)
weak_found = sorted(k for k in sources if k in weak)
detail.append(f"可信来源 {len(strong)} 个:{', '.join(strong) or '无'}")
if weak_found:
detail.append(f"不参与判定(weak):{', '.join(weak_found)}")
if not strong:
detail.append("⚠ 没有任何可信来源,判定将只依赖易重复的来源,安全性不足")
else:
detail.append("⚠ 无来源指纹:仅按机器码精确匹配,换硬件即失授权")
2026-09-22 16:08:12 +08:00
# 命中"⚠"时整块转警示色 —— 弱来源/无来源是签发前最该被看见的信息
set_status(self.target_result, "\n".join(detail),
"warn" if any("⚠" in line for line in detail) else "hint")
2026-09-21 14:06:55 +08:00
self.sources_view.setPlainText(
"\n".join(f"{k} = {v}" for k, v in sorted(sources.items())) if sources else "(无)"
)
self._refresh_preview()
# ------------------------------------------------------------------
# 预览与签发
# ------------------------------------------------------------------
def _expiry_value(self) -> tuple[str | None, int | None]:
"""返回 (expires_at, days) —— 二者最多一个非空,供 build_payload/resolve_expiry 使用。"""
if self.rb_forever.isChecked():
return None, None
if self.rb_days.isChecked():
return None, self.days_spin.value()
return self.date_edit.date().toString("yyyy-MM-dd"), None
def _build_payload(self) -> dict:
"""收集界面输入并构造载荷(未签名)。"""
if not self._machine_code:
raise ValueError("请先解析签发对象(申请码 / 来源文件 / 机器码)")
plugins = self._checked_plugins()
if not plugins:
raise ValueError("请至少勾选一个授权插件")
expires_at, days = self._expiry_value()
from chongming_license import resolve_expiry
return build_payload(
machine_code=self._machine_code,
plugins=plugins,
sources=self._sources,
customer=self.customer_edit.text().strip(),
lic_id=self.lic_id_edit.text().strip(),
expires_at=resolve_expiry(days=days, expires=expires_at),
note=self.note_edit.text().strip(),
)
def _refresh_preview(self) -> None:
try:
payload = self._build_payload()
except ValueError as exc:
self.preview.setPlainText(f"(无法生成预览:{exc})")
return
# 预览用与签名完全相同的载荷对象,所见即所签
self.preview.setPlainText(json.dumps(payload, ensure_ascii=False, indent=2))
def _issue(self) -> None:
if not self.key.loaded:
error(self, "未加载私钥", "请先到「密钥」页生成或加载签名私钥。")
return
try:
payload = self._build_payload()
except ValueError as exc:
error(self, "无法签发", str(exc))
return
default_dir = self.settings.value("issue/outdir", str(Path.cwd()))
default_name = f"license_{payload['lic_id']}.lic"
path, _ = QFileDialog.getSaveFileName(
self, "保存 license 文件", str(Path(str(default_dir)) / default_name),
"License 文件 (*.lic);;所有文件 (*)",
)
if not path:
return
target = Path(path)
if target.exists() and not ask_overwrite(self, target):
return
try:
doc = build_license(payload, self.key.key, kid=self.kid_edit.text().strip())
write_license(doc, target)
except Exception as exc: # noqa: BLE001 - 签名/写盘失败统一提示
error(self, "签发失败", f"{exc}")
return
# 记住这次的选择,便于连续签发给多个客户
self.settings.setValue("issue/outdir", str(target.parent))
self.settings.setValue("issue/customer", self.customer_edit.text().strip())
self.settings.setValue("issue/note", self.note_edit.text().strip())
self.settings.setValue("issue/kid", self.kid_edit.text().strip())
self.settings.setValue("issue/days", self.days_spin.value())
# 授权编号是自动生成的,签完清空以便下一单重新生成
self.lic_id_edit.clear()
# 签发即入库:避免授权文件散落在邮件/下载目录里,日后既不知道给哪台机器
# 签过,也拿不到那台机器的指纹(续期只能再找客户要申请码)
recorded = ""
if self.record_check.isChecked():
try:
self.registry.save(
doc, public_key_fp=self.key.fingerprint, source="gui"
)
recorded = str(self.registry.path)
except Exception as exc: # noqa: BLE001 - 入库失败不应让"已签发"变成失败
warn(self, "已签发但记入台账失败",
f"{exc}\n\n授权文件已正常生成,只是台账未记录。"
"请到「台账」页检查台账库路径是否可写。")
info(
self, "已签发",
f"{target}\n\n"
f"授权编号:{payload['lic_id']}\n"
f"客户:{payload['customer'] or '(未填)'}\n"
f"机器码:{payload['machine_code']}\n"
f"授权插件:{', '.join(payload['plugins'])}\n"
f"有效期:{payload['not_before'] or '立即生效'} → {payload['expires_at'] or '永久'}\n"
f"公钥指纹:{self.key.fingerprint}\n\n"
+ (f"已记入台账:{recorded}\n"
f"续期可用「台账」页,无需客户再提供申请码。\n\n" if recorded else "")
+ "把该文件交给客户,在设备端「授权」页导入后重启网关生效。",
)