feat: implement C#-compatible AES-128-CBC license file parsing and integrate license management UI into AboutDialog
This commit is contained in:
@@ -1,25 +1,20 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Dialogs
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
// TODO P3.1 + P3.2: license grid, "Load License" picker, version binding
|
||||
// THINKING: C# parity requires the dgvLicense grid (Wafer SN, Mfg Date,
|
||||
// License Level, License Date) fed from AES-128-CBC .bin files under
|
||||
// <app_data>/licenses/ — CryptoHelper (backend/crypto/crypto_helper.py)
|
||||
// already has the primitives; the missing piece is the license file model
|
||||
// (P3.1 backend). "Version 0.1.0" below is hardcoded and will drift from
|
||||
// pyproject.toml — expose importlib.metadata.version("pygui") via a context
|
||||
// property instead (P3.2). See docs/pending/alpha-release-polish-plan.md §3.
|
||||
Popup {
|
||||
id: root
|
||||
modal: true
|
||||
dim: true
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
width: 420
|
||||
height: 340
|
||||
width: 480
|
||||
height: 440
|
||||
anchors.centerIn: Overlay.overlay
|
||||
|
||||
onOpened: licenseModel.refresh()
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusMd
|
||||
color: Theme.cardBackground
|
||||
@@ -27,6 +22,13 @@ Popup {
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
FileDialog {
|
||||
id: licenseFileDialog
|
||||
title: "Load License"
|
||||
nameFilters: ["License files (*.bin)"]
|
||||
onAccepted: loadFailedLabel.visible = !licenseModel.loadLicenseFile(selectedFile.toString())
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 24
|
||||
@@ -51,7 +53,7 @@ Popup {
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "Version 0.1.0"
|
||||
text: "Version " + appVersion
|
||||
font.pixelSize: Theme.fontSm
|
||||
color: Theme.bodyColor
|
||||
}
|
||||
@@ -75,7 +77,8 @@ Popup {
|
||||
// ── License grid ──
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 100
|
||||
Layout.fillHeight: true
|
||||
Layout.minimumHeight: 120
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.panelBackground
|
||||
border.color: Theme.cardBorder
|
||||
@@ -86,11 +89,24 @@ Popup {
|
||||
anchors.margins: 10
|
||||
spacing: 2
|
||||
|
||||
Label {
|
||||
text: "LICENSE"
|
||||
color: Theme.panelTitleText
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.letterSpacing: 0.5
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
Label {
|
||||
text: "LICENSE"
|
||||
color: Theme.panelTitleText
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.letterSpacing: 0.5
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Label {
|
||||
id: loadFailedLabel
|
||||
visible: false
|
||||
text: "Invalid license file"
|
||||
color: Theme.statusWarningColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
}
|
||||
}
|
||||
|
||||
// Grid header
|
||||
@@ -98,8 +114,9 @@ Popup {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Label { text: "Wafer SN"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 100 }
|
||||
Label { text: "Mfg Date"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 80 }
|
||||
Label { text: "Level"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 60 }
|
||||
Label { text: "Mfg Date"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 90 }
|
||||
Label { text: "Level"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 50 }
|
||||
Label { text: "License Date"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.fillWidth: true }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
@@ -108,7 +125,24 @@ Popup {
|
||||
color: Theme.softBorder
|
||||
}
|
||||
|
||||
ListView {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
clip: true
|
||||
visible: licenseModel.licenses.length > 0
|
||||
model: licenseModel.licenses
|
||||
delegate: RowLayout {
|
||||
width: ListView.view.width
|
||||
spacing: 4
|
||||
Label { text: modelData.serial; font.pixelSize: Theme.fontSm; color: Theme.bodyColor; Layout.preferredWidth: 100 }
|
||||
Label { text: modelData.mfgDate; font.pixelSize: Theme.fontSm; color: Theme.bodyColor; Layout.preferredWidth: 90 }
|
||||
Label { text: modelData.level; font.pixelSize: Theme.fontSm; color: Theme.bodyColor; Layout.preferredWidth: 50 }
|
||||
Label { text: modelData.licDate; font.pixelSize: Theme.fontSm; color: Theme.bodyColor; Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
visible: licenseModel.licenses.length === 0
|
||||
text: "No license loaded"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
@@ -120,25 +154,50 @@ Popup {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Close ──
|
||||
Button {
|
||||
text: "Close"
|
||||
Layout.alignment: Qt.AlignRight
|
||||
Layout.preferredWidth: 100
|
||||
Layout.preferredHeight: 32
|
||||
onClicked: root.close()
|
||||
// ── Buttons ──
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||
border.color: Theme.fieldBorder
|
||||
border.width: 1
|
||||
Button {
|
||||
text: "Load License"
|
||||
Layout.preferredWidth: 120
|
||||
Layout.preferredHeight: 32
|
||||
onClicked: licenseFileDialog.open()
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||
border.color: Theme.fieldBorder
|
||||
border.width: 1
|
||||
}
|
||||
contentItem: Label {
|
||||
text: parent.text
|
||||
color: Theme.buttonNeutralText
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
contentItem: Label {
|
||||
text: parent.text
|
||||
color: Theme.buttonNeutralText
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Button {
|
||||
text: "Close"
|
||||
Layout.preferredWidth: 100
|
||||
Layout.preferredHeight: 32
|
||||
onClicked: root.close()
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||
border.color: Theme.fieldBorder
|
||||
border.width: 1
|
||||
}
|
||||
contentItem: Label {
|
||||
text: parent.text
|
||||
color: Theme.buttonNeutralText
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import sys
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtQml import QQmlApplicationEngine
|
||||
@@ -11,6 +12,7 @@ from pygui.backend.controllers.session_controller import SessionController
|
||||
from pygui.backend.data.file_browser import FileBrowser
|
||||
from pygui.backend.data.local_settings import LocalSettings
|
||||
from pygui.backend.data.local_settings_model import LocalSettingsModel
|
||||
from pygui.backend.license.license_model import LicenseModel
|
||||
|
||||
# Importing wafer_map_item + trend_chart_item registers @QmlElement types (QML: import ISC.Wafer).
|
||||
|
||||
@@ -37,12 +39,24 @@ def main() -> int:
|
||||
engine.rootContext().setContextProperty("settingsModel", settings_model)
|
||||
engine.rootContext().setContextProperty("file_browser", select_file_dialog_model)
|
||||
|
||||
# App version from package metadata so the About dialog can't drift
|
||||
# from pyproject.toml.
|
||||
try:
|
||||
app_version = version("pygui")
|
||||
except PackageNotFoundError:
|
||||
app_version = "dev"
|
||||
engine.rootContext().setContextProperty("appVersion", app_version)
|
||||
|
||||
# ===== Device Controller (serial comm) =====
|
||||
data_dir = str(settings_model._data_dir)
|
||||
raw_settings = LocalSettings.read_settings(data_dir)
|
||||
device_controller = DeviceController(raw_settings, data_dir)
|
||||
engine.rootContext().setContextProperty("deviceController", device_controller)
|
||||
|
||||
# ===== License Model (About dialog grid, replay trial) =====
|
||||
license_model = LicenseModel(data_dir)
|
||||
engine.rootContext().setContextProperty("licenseModel", license_model)
|
||||
|
||||
# ===== Session Controller (live/review wafer dashboard) =====
|
||||
raw_settings_dict = raw_settings.__dict__.copy() if hasattr(raw_settings, '__dict__') else {}
|
||||
stream_controller = SessionController(settings=raw_settings_dict)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from pygui.backend.license.license_manager import (
|
||||
LICENSE_FILENAME_RE,
|
||||
License,
|
||||
parse_license_blob,
|
||||
read_license_files,
|
||||
)
|
||||
from pygui.backend.license.license_model import LicenseModel
|
||||
|
||||
__all__ = [
|
||||
"LICENSE_FILENAME_RE",
|
||||
"License",
|
||||
"LicenseModel",
|
||||
"parse_license_blob",
|
||||
"read_license_files",
|
||||
]
|
||||
@@ -0,0 +1,146 @@
|
||||
"""QML-facing license model: grid rows, Load License, replay trial marker."""
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from PySide6.QtCore import Property, QObject, Signal, Slot
|
||||
|
||||
from pygui.backend.license.license_manager import (
|
||||
LICENSE_FILENAME_RE,
|
||||
License,
|
||||
parse_license_blob,
|
||||
read_license_files,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
TRIAL_DAYS = 30
|
||||
|
||||
|
||||
def _to_local_path(path_or_url: str) -> Path:
|
||||
"""Accept both plain paths and file:// URLs (QML FileDialog gives URLs)."""
|
||||
if path_or_url.startswith("file:"):
|
||||
return Path(unquote(urlparse(path_or_url).path))
|
||||
return Path(path_or_url)
|
||||
|
||||
|
||||
# ===== License Model =====
|
||||
class LicenseModel(QObject):
|
||||
"""Context property backing the About dialog license grid.
|
||||
|
||||
Licenses live in ``<data_dir>/licenses`` (mirrors C#'s
|
||||
``DataDir\\licenses``). The 30-day replay trial marker is
|
||||
``<data_dir>/replay.lic``.
|
||||
"""
|
||||
|
||||
licensesChanged = Signal()
|
||||
|
||||
def __init__(self, data_dir: str, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._data_dir = Path(data_dir)
|
||||
self._licenses_dir = self._data_dir / "licenses"
|
||||
try:
|
||||
self._licenses_dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
log.warning("Cannot create licenses dir %s: %s", self._licenses_dir, exc)
|
||||
self._licenses: list[License] = []
|
||||
self.refresh()
|
||||
|
||||
# ── Grid rows ─────────────────────────────────────────────────────
|
||||
@Property("QVariantList", notify=licensesChanged)
|
||||
def licenses(self) -> list:
|
||||
"""Rows for the About grid: serial, mfg date, level, license date."""
|
||||
return [
|
||||
{
|
||||
"serial": lic.serial,
|
||||
"mfgDate": lic.mfg_date_decimal,
|
||||
"level": lic.level,
|
||||
"licDate": lic.lic_date_decimal,
|
||||
}
|
||||
for lic in self._licenses
|
||||
]
|
||||
|
||||
@Slot()
|
||||
def refresh(self) -> None:
|
||||
"""Re-scan the licenses directory."""
|
||||
self._licenses = read_license_files(self._licenses_dir)
|
||||
self.licensesChanged.emit()
|
||||
|
||||
# ── Load License ──────────────────────────────────────────────────
|
||||
@Slot(str, result=bool)
|
||||
def loadLicenseFile(self, path_or_url: str) -> bool:
|
||||
"""Validate a picked .bin and copy it into the licenses directory."""
|
||||
src = _to_local_path(path_or_url)
|
||||
if not src.is_file():
|
||||
log.warning("License file does not exist: %s", src)
|
||||
return False
|
||||
if not LICENSE_FILENAME_RE.match(src.name):
|
||||
log.warning("License filename invalid: %s", src.name)
|
||||
return False
|
||||
|
||||
try:
|
||||
blob = src.read_bytes()
|
||||
except OSError as exc:
|
||||
log.warning("Cannot read license file %s: %s", src, exc)
|
||||
return False
|
||||
|
||||
lic = parse_license_blob(blob)
|
||||
if lic is None or src.name != f"{lic.serial}-{lic.level}.bin":
|
||||
log.warning("License file failed validation: %s", src.name)
|
||||
return False
|
||||
|
||||
try:
|
||||
self._licenses_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(src, self._licenses_dir / src.name)
|
||||
except OSError as exc:
|
||||
log.warning("Cannot copy license into %s: %s", self._licenses_dir, exc)
|
||||
return False
|
||||
|
||||
self.refresh()
|
||||
return True
|
||||
|
||||
# ── Wafer detect (informational) ──────────────────────────────────
|
||||
@Slot(str, result=str)
|
||||
def levelForWafer(self, serial: str) -> str:
|
||||
"""License level for a wafer serial like "A00001"; "" if unlicensed."""
|
||||
for lic in self._licenses:
|
||||
if lic.serial == serial:
|
||||
return lic.level
|
||||
return ""
|
||||
|
||||
# ── Replay trial (30-day marker, C# parity) ───────────────────────
|
||||
@property
|
||||
def _trial_marker(self) -> Path:
|
||||
return self._data_dir / "replay.lic"
|
||||
|
||||
@Slot(result=str)
|
||||
def replayLicenseState(self) -> str:
|
||||
""""permanent" if any license level >= 1, "temporary" if trial
|
||||
marker exists, else "none" (trial not started)."""
|
||||
if any(lic.level_int >= 1 for lic in self._licenses):
|
||||
return "permanent"
|
||||
if self._trial_marker.exists():
|
||||
return "temporary"
|
||||
return "none"
|
||||
|
||||
@Slot(result=bool)
|
||||
def startReplayTrial(self) -> bool:
|
||||
"""Create the trial marker (idempotent). False on I/O failure."""
|
||||
try:
|
||||
self._trial_marker.touch(exist_ok=True)
|
||||
return True
|
||||
except OSError as exc:
|
||||
log.warning("Cannot create trial marker: %s", exc)
|
||||
return False
|
||||
|
||||
@Slot(result=bool)
|
||||
def replayTrialExpired(self) -> bool:
|
||||
"""True once the marker is older than 30 days (or missing)."""
|
||||
marker = self._trial_marker
|
||||
if not marker.exists():
|
||||
return True
|
||||
age_days = (time.time() - marker.stat().st_mtime) / 86400.0
|
||||
return age_days > TRIAL_DAYS
|
||||
@@ -0,0 +1,126 @@
|
||||
"""C#-compatible wafer license file parsing.
|
||||
|
||||
License files are AES-128-CBC encrypted `.bin` blobs named
|
||||
``X00000-00.bin`` where ``X`` is the wafer family code, ``00000`` the
|
||||
5-digit serial number, and ``00`` the 2-digit license level. The blob is
|
||||
``ciphertext || iv`` (IV is the trailing 16 bytes). The decrypted text is
|
||||
a fixed-offset record::
|
||||
|
||||
[0:6] serial e.g. "A00001"
|
||||
[7:15] mfg date 8 hex chars
|
||||
[16:18] level 2 decimal digits ("00" basic, "01"+ adds replay)
|
||||
[19:27] license date 8 hex chars
|
||||
|
||||
The key is hardcoded to match the C# application exactly
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from pygui.backend.crypto.crypto_helper import CryptoHelper
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
LICENSE_KEY = b"ThisIsA16ByteKey"
|
||||
LICENSE_FILENAME_RE = re.compile(r"^[A-Z]\d{5}-\d{2}\.bin$")
|
||||
|
||||
_MIN_TEXT_LEN = 27
|
||||
|
||||
|
||||
# ===== License Record =====
|
||||
@dataclass(frozen=True)
|
||||
class License:
|
||||
"""One parsed license file (all fields as stored in the file)."""
|
||||
|
||||
serial: str # "A00001"
|
||||
level: str # "00".."03"
|
||||
mfg_date_hex: str # 8 hex chars
|
||||
lic_date_hex: str # 8 hex chars
|
||||
|
||||
@property
|
||||
def level_int(self) -> int:
|
||||
"""Level as int; -1 if unparseable."""
|
||||
try:
|
||||
return int(self.level, 10)
|
||||
except ValueError:
|
||||
return -1
|
||||
|
||||
@property
|
||||
def mfg_date_decimal(self) -> str:
|
||||
"""Mfg date shown as decimal (C# grid does Convert.ToInt64(hex, 16))."""
|
||||
return _hex_to_decimal(self.mfg_date_hex)
|
||||
|
||||
@property
|
||||
def lic_date_decimal(self) -> str:
|
||||
"""License date shown as decimal, same conversion as the C# grid."""
|
||||
return _hex_to_decimal(self.lic_date_hex)
|
||||
|
||||
|
||||
def _hex_to_decimal(hex_str: str) -> str:
|
||||
try:
|
||||
return str(int(hex_str, 16))
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
|
||||
# ===== Blob Parsing =====
|
||||
def split_data_iv(blob: bytes) -> tuple[bytes, bytes]:
|
||||
"""Split a license blob into (ciphertext, iv) — IV is the last 16 bytes."""
|
||||
if len(blob) < 32:
|
||||
raise ValueError("License blob too short")
|
||||
return blob[:-16], blob[-16:]
|
||||
|
||||
|
||||
def parse_license_blob(blob: bytes) -> License | None:
|
||||
"""Decrypt and parse one license blob. None if malformed."""
|
||||
try:
|
||||
data, iv = split_data_iv(blob)
|
||||
message = CryptoHelper.decrypt_aes(data, LICENSE_KEY, iv)
|
||||
text = message.decode("utf-8")
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
log.warning("Undecryptable license blob: %s", exc)
|
||||
return None
|
||||
|
||||
if len(text) < _MIN_TEXT_LEN:
|
||||
log.warning("License text too short (%d chars)", len(text))
|
||||
return None
|
||||
|
||||
return License(
|
||||
serial=text[0:6],
|
||||
mfg_date_hex=text[7:15],
|
||||
level=text[16:18],
|
||||
lic_date_hex=text[19:27],
|
||||
)
|
||||
|
||||
|
||||
# ===== Directory Scan =====
|
||||
def read_license_files(licenses_dir: str | Path) -> list[License]:
|
||||
"""Parse every valid license .bin in the directory.
|
||||
|
||||
A file counts only if its name matches ``X00000-00.bin`` AND equals
|
||||
``{serial}-{level}.bin`` from its own decrypted payload — same
|
||||
tamper check as the C# app.
|
||||
"""
|
||||
directory = Path(licenses_dir)
|
||||
if not directory.is_dir():
|
||||
return []
|
||||
|
||||
licenses: list[License] = []
|
||||
for path in sorted(directory.glob("*.bin")):
|
||||
if not LICENSE_FILENAME_RE.match(path.name):
|
||||
continue
|
||||
try:
|
||||
blob = path.read_bytes()
|
||||
except OSError as exc:
|
||||
log.warning("Cannot read license file %s: %s", path, exc)
|
||||
continue
|
||||
lic = parse_license_blob(blob)
|
||||
if lic is None:
|
||||
continue
|
||||
if path.name != f"{lic.serial}-{lic.level}.bin":
|
||||
log.warning("License filename mismatch: %s", path.name)
|
||||
continue
|
||||
licenses.append(lic)
|
||||
return licenses
|
||||
Reference in New Issue
Block a user