478 lines
20 KiB
Python
478 lines
20 KiB
Python
"""签发页:把"申请码 → 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,
|
||
btn,
|
||
copy_to_clipboard,
|
||
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
|
||
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)
|
||
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("", hint("在「密钥」页生成或加载私钥。签发前请核对公钥指纹与设备端一致。"))
|
||
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)
|
||
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 文件
|
||
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 字段,或就是来源字典)")
|
||
json_browse = btn("浏览…", icon_name="folder")
|
||
json_browse.clicked.connect(self._pick_json)
|
||
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)
|
||
|
||
# 页 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())
|
||
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)
|
||
|
||
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)
|
||
|
||
# ---------- 授权插件 ----------
|
||
# 勾选组件与台账「改插件」共用(plugin_picker):行直接加进本卡片,排版与
|
||
# 抽出之前一致;下面几个属性名沿用旧名,既有代码与回归测试都在用
|
||
plugin_box, plugin_form = section("授权插件")
|
||
self.plugin_picker = PluginPicker(
|
||
plugin_form, settings, notify=lambda title, msg: warn(self, title, msg)
|
||
)
|
||
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)
|
||
|
||
# ---------- 授权信息 ----------
|
||
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)
|
||
|
||
refresh_btn = btn("刷新预览", icon_name="refresh")
|
||
refresh_btn.clicked.connect(self._refresh_preview)
|
||
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 = 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)
|
||
|
||
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("—")
|
||
|
||
# ------------------------------------------------------------------
|
||
# 插件列表(实现在 plugin_picker.PluginPicker,这里只为既有调用点留入口)
|
||
# ------------------------------------------------------------------
|
||
def _reload_plugins(self) -> None:
|
||
self.plugin_picker.reload()
|
||
|
||
def _checked_plugins(self) -> list[str]:
|
||
return self.plugin_picker.checked()
|
||
|
||
def _set_all_checked(self, checked: bool) -> None:
|
||
self.plugin_picker.set_all(checked)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 签发依据解析
|
||
# ------------------------------------------------------------------
|
||
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 = ""
|
||
set_status(self.target_result, "尚未解析签发对象", "hint")
|
||
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("⚠ 无来源指纹:仅按机器码精确匹配,换硬件即失授权")
|
||
# 命中"⚠"时整块转警示色 —— 弱来源/无来源是签发前最该被看见的信息
|
||
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 "(无)"
|
||
)
|
||
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 "")
|
||
+ "把该文件交给客户,在设备端「授权」页导入后重启网关生效。",
|
||
)
|