update server
This commit is contained in:
@@ -0,0 +1,656 @@
|
||||
# license_utils.py
|
||||
"""RSA 验签许可证模块 —— 嵌入 exe,验签 + 每日巡检 + 防改系统时间。
|
||||
|
||||
用法(在 main.py 或主窗口 __init__ 中调用一次即可):
|
||||
from license_utils import check_license, start_license_watchdog
|
||||
|
||||
check_license() # 启动时验签(失败则抛异常退出)
|
||||
start_license_watchdog(interval_minutes=1440) # 后台每天巡检
|
||||
"""
|
||||
|
||||
import json
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time as _time_module
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
|
||||
# ============================================================
|
||||
# 公钥(编译进 exe,可公开)—— 与 ControlPanel 使用的签名私钥配对
|
||||
# 公钥更新由受控的发布流程完成,客户端不包含任何签发能力。
|
||||
# ============================================================
|
||||
# {{LICENSE_PUBLIC_KEY_START}}
|
||||
PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
|
||||
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEArQ4l2ePnl+p9yuUWcuOK
|
||||
CQ4bzixyJKh8tPSVMwqI7u02v9qdrIPu5j3T0tl+qCNTLMP6WHd6s09M7bWuVTnU
|
||||
220ljrdBb8opIzNGxTTri1k3JqMI95ljLwdEB6vp+ISyrdFKG4o+B+hDDvxkbyGH
|
||||
2vKBG71Wrws4ujOEI1H8MDWDRMyGrHFQZZk1Sz6WkgWT+yjoZ8L0K3o0afIYw8F9
|
||||
J50aaRQ9fvNW4EB+Pa5Yy5DQrNIs1smMVDpegdYt5uUMwEnfoS6Y6l98Gz7ljZ5n
|
||||
6/WVaFb55XquAwsF/zq6oDfKBrAOBqzT2YYZklr8swlKKIJ3ExA1sd/dxhfZhidi
|
||||
pqCvye6+cYa5GTRu9knzBsVPdzzhQC5AqKUuPglVJV8dQPfH7Nb7EP5wvNSgzpLT
|
||||
9wsIoZXm9GXD0hApHvobSiZnpqY5g9InV7fQZyold2zFhHWpDieNjX0844gQafnH
|
||||
Ue6JWRU4j3Wg37WDPbwkO3tQba2jbUQsLYomLGuohfkVAgMBAAE=
|
||||
-----END PUBLIC KEY-----"""
|
||||
# {{LICENSE_PUBLIC_KEY_END}}
|
||||
|
||||
# 许可证文件相对路径
|
||||
LICENSE_FILE = "license.lic"
|
||||
|
||||
# 巡检间隔(分钟)
|
||||
DEFAULT_CHECK_INTERVAL = 5
|
||||
ONLINE_CHECK_TIMEOUT_SECONDS = 5
|
||||
DEFAULT_OFFLINE_HOURS = 72
|
||||
|
||||
# 过期后宽限期(小时),给用户保存工作的时间
|
||||
GRACE_PERIOD_HOURS = 2
|
||||
|
||||
# ============================================================
|
||||
# 内部状态
|
||||
# ============================================================
|
||||
_startup_monotonic = _time_module.monotonic() # 软件启动时刻(不受系统时间影响)
|
||||
_last_check_result = None
|
||||
_verified_license = None
|
||||
_verified_license_lock = threading.RLock()
|
||||
_watchdog_started = False
|
||||
_watchdog_lock = threading.Lock()
|
||||
_last_online_success_monotonic = None
|
||||
_on_expired_callback = None # 过期回调,可由外部设置
|
||||
_on_grace_callback = None # 缓冲期回调
|
||||
_on_log_callback = None # 日志回调,供 UI 状态栏显示
|
||||
_warning_shown_states = {
|
||||
"expiring_today": False, # 到期当天警告是否已弹窗
|
||||
"expiring_soon": False, # 即将到期(30天内)警告是否已弹窗
|
||||
"expired_grace": False, # 过期缓冲期警告是否已弹窗
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# GUI 线程安全工具
|
||||
# ============================================================
|
||||
def _invoke_on_qt_thread(func):
|
||||
"""在 Qt 主线程中安全执行 func。
|
||||
|
||||
使用 QTimer.singleShot 将回调排队到主线程事件循环。
|
||||
关键:必须传入 QApplication 作为 context(receiver),否则 QTimer 会被调度到
|
||||
当前线程(watchdog 是 daemon 线程,没有 Qt event loop),导致静默永不触发。
|
||||
"""
|
||||
try:
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QApplication
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
QTimer.singleShot(0, app, func)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 兜底:没有 QApplication 时直接调用(GUI 还未初始化)
|
||||
try:
|
||||
func()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _show_message_box(icon, title, message):
|
||||
"""线程安全地显示 QMessageBox。
|
||||
|
||||
Args:
|
||||
icon: 'critical', 'warning', 'information'
|
||||
title: 弹窗标题
|
||||
message: 弹窗内容
|
||||
"""
|
||||
def _show():
|
||||
try:
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
return
|
||||
if icon == 'critical':
|
||||
QMessageBox.critical(None, title, message)
|
||||
elif icon == 'warning':
|
||||
QMessageBox.warning(None, title, message)
|
||||
else:
|
||||
QMessageBox.information(None, title, message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_invoke_on_qt_thread(_show)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 验签核心
|
||||
# ============================================================
|
||||
def _load_public_key():
|
||||
"""从内嵌的 PEM 加载公钥"""
|
||||
return serialization.load_pem_public_key(PUBLIC_KEY_PEM.encode())
|
||||
|
||||
|
||||
def _validate_device_id(device_id):
|
||||
"""确保新许可证中的目录键只能是 ``company/line``。"""
|
||||
if not isinstance(device_id, str):
|
||||
raise ValueError("许可证 device_id 必须是字符串")
|
||||
segments = device_id.split("/")
|
||||
if len(segments) != 2 or any(
|
||||
not segment or segment in (".", "..") or "\\" in segment
|
||||
for segment in segments):
|
||||
raise ValueError("许可证 device_id 必须是 company/production-line 格式")
|
||||
|
||||
|
||||
def _validate_payload(payload):
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("许可证内容必须是对象")
|
||||
|
||||
new_fields = ("license_id", "company_id", "production_line_id", "device_id")
|
||||
has_new_format = any(field in payload for field in new_fields)
|
||||
if has_new_format:
|
||||
missing = [field for field in new_fields
|
||||
if not isinstance(payload.get(field), str) or not payload[field].strip()]
|
||||
if missing:
|
||||
raise ValueError(f"新许可证缺少标识字段: {', '.join(missing)}")
|
||||
_validate_device_id(payload["device_id"])
|
||||
else:
|
||||
_log("旧许可证不支持在线撤销")
|
||||
|
||||
if not isinstance(payload.get("customer"), str) or not payload["customer"].strip():
|
||||
raise ValueError("许可证缺少客户信息")
|
||||
return has_new_format
|
||||
|
||||
|
||||
def verify_license(lic_path=None):
|
||||
"""验证许可证签名 + 有效期。
|
||||
|
||||
Args:
|
||||
lic_path: 许可证文件路径,默认 exe 同级目录下的 license.lic
|
||||
|
||||
Returns:
|
||||
dict: 许可证 payload(customer, expiry, issued 等)
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 许可证文件不存在
|
||||
InvalidSignature: 签名不匹配(伪造/篡改)
|
||||
RuntimeError: 许可证已过期
|
||||
ValueError: 许可证格式错误
|
||||
"""
|
||||
if lic_path is None:
|
||||
# PyInstaller 打包后 sys.executable 是 exe 路径
|
||||
exe_dir = Path(sys.executable).parent if getattr(sys, 'frozen', False) else Path.cwd()
|
||||
lic_path = exe_dir / LICENSE_FILE
|
||||
|
||||
if not os.path.exists(lic_path):
|
||||
raise FileNotFoundError(f"许可证文件不存在: {lic_path}")
|
||||
|
||||
with open(lic_path, "r", encoding="utf-8") as f:
|
||||
raw = f.read().strip()
|
||||
|
||||
# 解析: payload_base64 | signature_base64
|
||||
if "|" not in raw:
|
||||
raise ValueError("许可证格式错误")
|
||||
|
||||
payload_b64, signature_b64 = raw.split("|", 1)
|
||||
signature = base64.b64decode(signature_b64)
|
||||
|
||||
# RSA-PSS SHA256 验签
|
||||
try:
|
||||
pub_key = _load_public_key()
|
||||
pub_key.verify(
|
||||
signature,
|
||||
payload_b64.encode(), # 签名的是 base64 字符串本身
|
||||
padding.PSS(
|
||||
mgf=padding.MGF1(hashes.SHA256()),
|
||||
salt_length=padding.PSS.MAX_LENGTH,
|
||||
),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
except InvalidSignature:
|
||||
raise InvalidSignature("许可证签名验证失败:文件可能被篡改")
|
||||
|
||||
# 解析载荷
|
||||
try:
|
||||
payload = json.loads(base64.b64decode(payload_b64))
|
||||
except Exception:
|
||||
raise ValueError("许可证内容解析失败")
|
||||
|
||||
_validate_payload(payload)
|
||||
|
||||
# 有效期检查(精确到小时)
|
||||
expiry_str = payload.get("expiry")
|
||||
if not expiry_str:
|
||||
raise ValueError("许可证缺少过期时间")
|
||||
|
||||
# 兼容旧格式 YYYY-MM-DD(视为当天 23:59)
|
||||
if len(expiry_str) == 10:
|
||||
expiry = datetime.datetime.strptime(expiry_str + " 23:59", "%Y-%m-%d %H:%M")
|
||||
else:
|
||||
expiry = datetime.datetime.strptime(expiry_str, "%Y-%m-%d %H:%M")
|
||||
|
||||
trusted_now = _get_trusted_now()
|
||||
|
||||
if trusted_now > expiry:
|
||||
gap_days = (trusted_now - expiry).days
|
||||
uptime_days = _get_uptime_days()
|
||||
|
||||
# 反时钟篡改:需同时满足两个条件才怀疑用户拨慢系统时间
|
||||
# ① 软件运行不到 1 天(刚启动)
|
||||
# ② 过期时间差超过 7 天(差距巨大 → 文件 mtime 暴露了真实时间)
|
||||
# 缺失任一条件 → 真过期,直接报错:
|
||||
# - gap 小(几分钟~几小时)→ 刚过期,正常报错
|
||||
# - 软件跑了很久 → 正常使用中过期,正常报错
|
||||
if uptime_days < 1 and gap_days > 7:
|
||||
issued_str = payload.get("issued", expiry_str)
|
||||
if len(issued_str) == 10:
|
||||
issued = datetime.datetime.strptime(issued_str + " 00:00", "%Y-%m-%d %H:%M")
|
||||
else:
|
||||
issued = datetime.datetime.strptime(issued_str, "%Y-%m-%d %H:%M")
|
||||
estimated_now = issued + datetime.timedelta(days=uptime_days)
|
||||
if estimated_now <= expiry:
|
||||
return payload
|
||||
|
||||
raise ExpiredError(
|
||||
f"许可证已过期 (到期: {expiry_str})",
|
||||
expiry=expiry,
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def get_verified_license():
|
||||
"""返回本进程已通过本地验证的许可证载荷,尚未验证时返回 ``None``。"""
|
||||
with _verified_license_lock:
|
||||
return dict(_verified_license) if _verified_license is not None else None
|
||||
|
||||
|
||||
def _is_new_license(payload):
|
||||
return all(payload.get(field) for field in (
|
||||
"license_id", "company_id", "production_line_id", "device_id"))
|
||||
|
||||
|
||||
def _api_url():
|
||||
base_url = os.environ.get(
|
||||
"REINLOOP_SERVER_URL", "http://ReinLoop.dominatedconvergence.com"
|
||||
).rstrip("/")
|
||||
return os.environ.get("REINLOOP_API_URL", f"{base_url}/api")
|
||||
|
||||
|
||||
def _offline_limit_seconds():
|
||||
try:
|
||||
hours = float(os.environ.get("REINLOOP_LICENSE_OFFLINE_HOURS", DEFAULT_OFFLINE_HOURS))
|
||||
except ValueError:
|
||||
hours = DEFAULT_OFFLINE_HOURS
|
||||
return max(0, hours) * 3600
|
||||
|
||||
|
||||
def validate_license_online(payload):
|
||||
"""验证可撤销许可证的在线状态。
|
||||
|
||||
网络故障只在超过离线时限后失效;服务端明确拒绝则立即返回 ExpiredError。
|
||||
"""
|
||||
global _last_online_success_monotonic
|
||||
if not _is_new_license(payload):
|
||||
return
|
||||
|
||||
try:
|
||||
response = requests.post(_api_url(), json={
|
||||
"type": "validateLicense",
|
||||
"licenseId": payload["license_id"],
|
||||
"deviceId": payload["device_id"],
|
||||
}, timeout=ONLINE_CHECK_TIMEOUT_SECONDS)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
now = _time_module.monotonic()
|
||||
if _last_online_success_monotonic is None:
|
||||
_last_online_success_monotonic = _startup_monotonic
|
||||
if now - _last_online_success_monotonic > _offline_limit_seconds():
|
||||
raise ExpiredError("许可证在线校验超过离线宽限期") from exc
|
||||
_log(f"许可证在线校验暂不可用: {exc}")
|
||||
return
|
||||
|
||||
status = result.get("status")
|
||||
if not result.get("success") or not result.get("valid") or status != "active":
|
||||
reason = status or result.get("errMsg") or "invalid"
|
||||
raise ExpiredError(f"许可证在线状态无效: {reason}")
|
||||
if result.get("licenseId") not in (None, payload["license_id"]):
|
||||
raise ExpiredError("许可证在线校验返回了不匹配的许可证")
|
||||
_last_online_success_monotonic = _time_module.monotonic()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 时间可信度
|
||||
# ============================================================
|
||||
def _get_uptime_days():
|
||||
"""软件已连续运行的天数(基于 monotonic,不受系统时间影响)"""
|
||||
return (_time_module.monotonic() - _startup_monotonic) / 86400
|
||||
|
||||
|
||||
def _get_trusted_now():
|
||||
"""多源交叉校验获取可信日期时间(精确到小时)。
|
||||
|
||||
取系统时间和文件时间的最大值,防止用户回拨系统时间。
|
||||
"""
|
||||
candidates = []
|
||||
|
||||
# 1. 系统时间
|
||||
candidates.append(datetime.datetime.now())
|
||||
|
||||
# 2. 软件启动时记录的"最早可能时间"
|
||||
# monotonic 计时推导出的启动时间
|
||||
startup_guess = datetime.datetime.now() - datetime.timedelta(
|
||||
days=_get_uptime_days()
|
||||
)
|
||||
candidates.append(startup_guess)
|
||||
|
||||
# 3. 系统文件的修改时间(不易被用户修改)
|
||||
system_files = []
|
||||
if sys.platform == "darwin":
|
||||
system_files = [
|
||||
"/System/Library/CoreServices/SystemVersion.plist",
|
||||
"/usr/bin/python3",
|
||||
]
|
||||
elif sys.platform == "win32":
|
||||
system_files = [
|
||||
r"C:\Windows\System32\ntoskrnl.exe",
|
||||
r"C:\Windows\explorer.exe",
|
||||
]
|
||||
|
||||
for sf in system_files:
|
||||
if os.path.exists(sf):
|
||||
try:
|
||||
mtime = os.path.getmtime(sf)
|
||||
candidates.append(datetime.datetime.fromtimestamp(mtime))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 取最大值(真时间 ≥ 所有候选值。用户可能回拨,但不能让其他文件"变新")
|
||||
return max(candidates)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 后台巡检
|
||||
# ============================================================
|
||||
def start_license_watchdog(interval_minutes=DEFAULT_CHECK_INTERVAL):
|
||||
"""启动后台许可证巡检线程。
|
||||
|
||||
Args:
|
||||
interval_minutes: 检查间隔(分钟),默认 1440(24小时)
|
||||
"""
|
||||
global _watchdog_started
|
||||
with _watchdog_lock:
|
||||
if _watchdog_started:
|
||||
return
|
||||
_watchdog_started = True
|
||||
t = threading.Thread(
|
||||
target=_watchdog_loop,
|
||||
args=(max(5, interval_minutes),),
|
||||
daemon=True,
|
||||
name="license-watchdog",
|
||||
)
|
||||
t.start()
|
||||
|
||||
|
||||
def _watchdog_loop(interval_minutes):
|
||||
while True:
|
||||
_time_module.sleep(interval_minutes * 60)
|
||||
|
||||
try:
|
||||
payload = verify_license()
|
||||
with _verified_license_lock:
|
||||
global _verified_license
|
||||
_verified_license = dict(payload)
|
||||
validate_license_online(payload)
|
||||
expiry_str = payload["expiry"]
|
||||
# 解析到期时间(兼容旧格式)
|
||||
if len(expiry_str) == 10:
|
||||
expiry = datetime.datetime.strptime(expiry_str + " 23:59", "%Y-%m-%d %H:%M")
|
||||
else:
|
||||
expiry = datetime.datetime.strptime(expiry_str, "%Y-%m-%d %H:%M")
|
||||
|
||||
trusted_now = _get_trusted_now()
|
||||
hours_left = (expiry - trusted_now).total_seconds() / 3600
|
||||
days_left = int(hours_left / 24)
|
||||
|
||||
if hours_left < 0:
|
||||
# 已过期(不应走到这里,verify_license 会抛 ExpiredError)
|
||||
_log(f"⚠ 许可证已过期 ({expiry_str})")
|
||||
_show_message_box('critical', "许可证已过期",
|
||||
f"您的许可证已于 {expiry_str} 到期!\n请尽快联系厂商续期。")
|
||||
|
||||
elif hours_left <= 24:
|
||||
if not _warning_shown_states["expiring_today"]:
|
||||
_warning_shown_states["expiring_today"] = True
|
||||
_log(f"⚠ 许可证将在今天到期 ({expiry_str})")
|
||||
_show_message_box('warning', "许可证即将到期",
|
||||
f"您的许可证将于今天 {expiry_str} 到期!\n"
|
||||
f"剩余约 {int(hours_left)} 小时,请及时联系厂商续期。")
|
||||
|
||||
elif days_left <= 30:
|
||||
if not _warning_shown_states["expiring_soon"]:
|
||||
_warning_shown_states["expiring_soon"] = True
|
||||
_log(f"⚠ 许可证将在 {days_left} 天后到期 ({expiry_str})")
|
||||
_show_message_box('warning', "许可证即将到期",
|
||||
f"您的许可证将在 {days_left} 天后({expiry_str})到期\n请提前联系厂商续期。")
|
||||
|
||||
except ExpiredError as e:
|
||||
_handle_expired(e)
|
||||
|
||||
except Exception as e:
|
||||
_log(f"许可证巡检异常: {e}")
|
||||
|
||||
|
||||
def _handle_expired(error):
|
||||
"""处理许可证过期"""
|
||||
_log(f"❌ {error}")
|
||||
|
||||
# 弹窗:许可证已过期
|
||||
_show_message_box('critical', "许可证已过期",
|
||||
f"{error}\n\n软件将在 {GRACE_PERIOD_HOURS} 小时缓冲期后自动退出,\n请及时保存工作并联系厂商续期。")
|
||||
|
||||
if _on_expired_callback:
|
||||
_on_expired_callback(str(error))
|
||||
|
||||
# 宽限期:给用户时间保存工作
|
||||
grace_start = _time_module.monotonic()
|
||||
grace_seconds = GRACE_PERIOD_HOURS * 3600
|
||||
|
||||
if not _warning_shown_states["expired_grace"]:
|
||||
_warning_shown_states["expired_grace"] = True
|
||||
if _on_grace_callback:
|
||||
_on_grace_callback(GRACE_PERIOD_HOURS)
|
||||
|
||||
# 半小时后提醒一次
|
||||
warned_half = False
|
||||
while _time_module.monotonic() - grace_start < grace_seconds:
|
||||
elapsed = _time_module.monotonic() - grace_start
|
||||
if not warned_half and elapsed > grace_seconds / 2:
|
||||
warned_half = True
|
||||
_show_message_box('warning', "许可证已过期",
|
||||
f"缓冲期剩余约 {GRACE_PERIOD_HOURS // 2} 小时,\n请尽快保存工作!")
|
||||
_time_module.sleep(60) # 每分钟检查一次
|
||||
|
||||
# 宽限期过,强制退出
|
||||
_log("宽限期已过,软件即将退出")
|
||||
_show_message_box('critical', "许可证已过期",
|
||||
"缓冲期已结束,软件即将退出。\n请联系厂商续期后重新启动。")
|
||||
|
||||
# 给 5 秒做最后的清理
|
||||
_time_module.sleep(5)
|
||||
os._exit(1)
|
||||
|
||||
|
||||
def set_on_expired(callback):
|
||||
"""设置过期回调: callback(message: str)"""
|
||||
global _on_expired_callback
|
||||
_on_expired_callback = callback
|
||||
|
||||
|
||||
def set_on_grace(callback):
|
||||
"""设置宽限期回调: callback(hours: int)"""
|
||||
global _on_grace_callback
|
||||
_on_grace_callback = callback
|
||||
|
||||
|
||||
def set_on_log(callback):
|
||||
"""设置日志回调: callback(message: str)
|
||||
所有许可证关键日志会同时输出到此回调,供 UI 状态栏显示。
|
||||
"""
|
||||
global _on_log_callback
|
||||
_on_log_callback = callback
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 便捷入口
|
||||
# ============================================================
|
||||
class ExpiredError(RuntimeError):
|
||||
"""许可证过期异常"""
|
||||
def __init__(self, message, expiry=None):
|
||||
super().__init__(message)
|
||||
self.expiry = expiry
|
||||
|
||||
|
||||
def _log(msg):
|
||||
"""终端日志 + UI 回调(不依赖任何 UI 层)"""
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
formatted = f"[License {timestamp}] {msg}"
|
||||
print(formatted)
|
||||
# 同步推送到 UI 状态栏(如果已注册回调)
|
||||
if _on_log_callback:
|
||||
try:
|
||||
_on_log_callback(str(msg))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def check_license(lic_path=None):
|
||||
"""启动时调用:验证许可证,通过则返回 payload。
|
||||
|
||||
在 main.py 的 main() 函数开头调用一次即可。
|
||||
内部会自动启动后台巡检线程。
|
||||
|
||||
Returns:
|
||||
dict: 许可证载荷
|
||||
|
||||
Raises:
|
||||
SystemExit: 验签失败或已过期(启动阶段直接退出)
|
||||
"""
|
||||
try:
|
||||
payload = verify_license(lic_path)
|
||||
environment_device_id = os.environ.get("REINLOOP_DEVICE_ID", "").strip()
|
||||
if (_is_new_license(payload) and environment_device_id
|
||||
and environment_device_id != payload["device_id"]):
|
||||
raise ValueError("REINLOOP_DEVICE_ID 与许可证 device_id 不一致")
|
||||
validate_license_online(payload)
|
||||
with _verified_license_lock:
|
||||
global _verified_license
|
||||
_verified_license = dict(payload)
|
||||
start_license_watchdog()
|
||||
expiry = payload.get("expiry", "未知")
|
||||
customer = payload.get("customer", "未知")
|
||||
_log(f"✅ 许可证有效 | 客户: {customer} | 到期: {expiry}")
|
||||
return payload
|
||||
|
||||
except FileNotFoundError as e:
|
||||
_log(f"❌ {e}")
|
||||
_show_error_and_exit(
|
||||
"未找到许可证文件",
|
||||
"请将 license.lic 放到软件根目录,然后重新启动程序。\n\n"
|
||||
"如有疑问,请联系厂商获取有效的许可证文件。"
|
||||
)
|
||||
|
||||
except InvalidSignature as e:
|
||||
_log(f"❌ {e}")
|
||||
_show_error_and_exit(
|
||||
"许可证验证失败",
|
||||
"许可证签名校验不通过,文件可能已被篡改。\n\n"
|
||||
"请使用原始签发的 license.lic 文件,\n"
|
||||
"或联系厂商重新签发。"
|
||||
)
|
||||
|
||||
except ExpiredError as e:
|
||||
_log(f"❌ {e}")
|
||||
_show_error_and_exit(
|
||||
"许可证已过期",
|
||||
f"您的许可证已于 {e.expiry} 到期。\n\n"
|
||||
"请联系厂商续期,获取新的许可证文件后重新启动。"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_log(f"❌ 许可证检查异常: {e}")
|
||||
_show_error_and_exit(f"许可证校验失败: {e}", str(e))
|
||||
|
||||
|
||||
def _show_error_and_exit(title, detail=""):
|
||||
"""显示错误弹窗并退出(兼容 GUI 和无 GUI 模式)。
|
||||
|
||||
Args:
|
||||
title: 弹窗标题(简短概要)
|
||||
detail: 弹窗正文(详细说明和操作建议)
|
||||
"""
|
||||
message = f"{title}\n\n{detail}" if detail else title
|
||||
|
||||
# 尝试 GUI 弹窗
|
||||
try:
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
QMessageBox.critical(None, title, detail or title)
|
||||
else:
|
||||
# 无 QApplication 实例时,尝试创建一个临时的
|
||||
try:
|
||||
app = QApplication(sys.argv[:1])
|
||||
QMessageBox.critical(None, title, detail or title)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(message)
|
||||
print(f"{'='*50}\n")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 辅助:获取许可证信息(供 UI 显示)
|
||||
# ============================================================
|
||||
def get_license_info(lic_path=None):
|
||||
"""读取许可证信息(不做过期检查),供 UI 显示。
|
||||
|
||||
Returns:
|
||||
dict | None: 许可证信息,文件不存在则返回 None
|
||||
"""
|
||||
try:
|
||||
if lic_path is None:
|
||||
exe_dir = (
|
||||
Path(sys.executable).parent
|
||||
if getattr(sys, 'frozen', False)
|
||||
else Path.cwd()
|
||||
)
|
||||
lic_path = exe_dir / LICENSE_FILE
|
||||
|
||||
if not os.path.exists(lic_path):
|
||||
return None
|
||||
|
||||
with open(lic_path, "r", encoding="utf-8") as f:
|
||||
raw = f.read().strip()
|
||||
|
||||
payload_b64 = raw.split("|")[0]
|
||||
payload = json.loads(base64.b64decode(payload_b64))
|
||||
|
||||
# 先验签保证内容可信
|
||||
verify_license(lic_path)
|
||||
|
||||
return {
|
||||
"customer": payload.get("customer", "未知"),
|
||||
"expiry": payload.get("expiry", "未知"),
|
||||
"issued": payload.get("issued", "未知"),
|
||||
"features": payload.get("features", "*"),
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
Reference in New Issue
Block a user