feat(license): runtime trial gating UI + appendActivityLog entry point

This commit is contained in:
jack
2026-07-13 11:52:06 -07:00
parent 0e6e819851
commit c76cddc5fd
3 changed files with 78 additions and 6 deletions
+39 -4
View File
@@ -33,6 +33,9 @@ Rectangle {
property string dataCompareFileB: "" property string dataCompareFileB: ""
onSelectedTabIndexChanged: { onSelectedTabIndexChanged: {
// Time-based, so re-check on every switch rather than trusting a
// value computed at some earlier point in a possibly long-running session.
root.reviewBlocked = licenseModel.reviewAccessBlocked()
if (selectedTabIndex === 2) { if (selectedTabIndex === 2) {
file_browser.refreshFiles(); file_browser.refreshFiles();
if (root.mapReviewFile !== "") { if (root.mapReviewFile !== "") {
@@ -68,6 +71,9 @@ Rectangle {
if (root.selectedTabIndex === 2) root.mapReviewFile = streamController.loadedFile if (root.selectedTabIndex === 2) root.mapReviewFile = streamController.loadedFile
else if (root.selectedTabIndex === 3) root.graphReviewFile = streamController.loadedFile else if (root.selectedTabIndex === 3) root.graphReviewFile = streamController.loadedFile
} }
function onLiveError(message) {
deviceController.appendActivityLog("Live stream error — stream stopped: " + message)
}
} }
// Data tab's Run A/B picks survive it being destroyed/recreated by its // Data tab's Run A/B picks survive it being destroyed/recreated by its
@@ -84,6 +90,24 @@ Rectangle {
property bool waferDetected: false property bool waferDetected: false
property bool waferLicensed: false property bool waferLicensed: false
// Data/Graph/Map-review are gated on the 30-day trial once it's actually
// expired (ADR-0005 "Deferred" item — the trigger existed, nothing ever
// checked it). Status stays free: hardware actions are gated separately,
// per-wafer-serial, via waferLicensed above.
property bool reviewBlocked: false
onReviewBlockedChanged: {
// Property change signals only fire on an actual value change, so
// this logs once per none/temporary → expired transition, not on
// every recompute in onSelectedTabIndexChanged/onLicensesChanged.
if (reviewBlocked) {
deviceController.appendActivityLog(
"Review trial expired — load a wafer license to continue using Data/Map/Graph (About dialog)")
if (root.selectedTabIndex !== 0) {
root.selectedTabIndex = 0
}
}
}
Connections { Connections {
target: deviceController target: deviceController
function onDetectResult(result){ function onDetectResult(result){
@@ -124,6 +148,7 @@ Rectangle {
root.selectedTabIndex = 0 root.selectedTabIndex = 0
} }
root.waferLicensed = root.waferDetected && deviceController.waferLicensed() root.waferLicensed = root.waferDetected && deviceController.waferLicensed()
root.reviewBlocked = licenseModel.reviewAccessBlocked()
} }
} }
Connections { Connections {
@@ -133,9 +158,9 @@ Rectangle {
result !== undefined && result !== undefined &&
result.success === true) result.success === true)
if (root.memoryRead) { if (root.memoryRead) {
console.log("[P1.1] Memory read complete:", result.bytes, "bytes") console.log("Memory read complete:", result.bytes, "bytes")
} else { } else {
console.log("[P1.1] Read failed:", result.error || "unknown") console.log("Read failed:", result.error || "unknown")
} }
} }
} }
@@ -271,6 +296,7 @@ Rectangle {
} }
} }
Component.onCompleted: { Component.onCompleted: {
root.reviewBlocked = licenseModel.reviewAccessBlocked()
if (isFirstLaunch) { if (isFirstLaunch) {
firstLaunchWizard.open() firstLaunchWizard.open()
} }
@@ -342,10 +368,13 @@ Rectangle {
id: pillBtn id: pillBtn
checked: root.selectedTabIndex === index checked: root.selectedTabIndex === index
// MAP (index 2) is locked until a valid license .bin is installed. // MAP (index 2) is locked until a valid license .bin is installed.
// DATA/MAP/GRAPH (index !== 0) also lock once the 30-day review
// trial has actually expired with no permanent license (ADR-0005).
// Stays enabled (not `enabled: false`) so hover still fires and the // Stays enabled (not `enabled: false`) so hover still fires and the
// tooltip can explain why — a disabled Item receives no mouse/hover // tooltip can explain why — a disabled Item receives no mouse/hover
// events at all, which would silently kill the tooltip below. // events at all, which would silently kill the tooltip below.
property bool locked: index === 2 && licenseModel.licenses.length === 0 property bool locked: (index === 2 && licenseModel.licenses.length === 0)
|| (index !== 0 && root.reviewBlocked)
flat: true flat: true
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
@@ -411,7 +440,13 @@ Rectangle {
AppToolTip { AppToolTip {
visible: pillBtn.hovered visible: pillBtn.hovered
text: (index === 2 && licenseModel.licenses.length === 0) // Trial-expired is checked first: it's the broader reason and
// applies identically to Data/Map/Graph, so when it's true it
// wins even for MAP — otherwise MAP's narrower "no license at
// all" case (trial not yet expired, still just unlicensed) shows.
text: (index !== 0 && root.reviewBlocked)
? "<b>" + model.label + "</b>: Review trial expired — load a license in About to unlock"
: (index === 2 && licenseModel.licenses.length === 0)
? "<b>MAP</b>: No license loaded — load one in About to unlock" ? "<b>MAP</b>: No license loaded — load one in About to unlock"
: "<b>" + model.label + "</b>: " + model.desc : "<b>" + model.label + "</b>: " + model.desc
} }
@@ -303,6 +303,13 @@ class DeviceController(QObject):
self._activity_log = self._activity_log[-200:] self._activity_log = self._activity_log[-200:]
self.activityLogUpdated.emit("\n".join(self._activity_log)) self.activityLogUpdated.emit("\n".join(self._activity_log))
@Slot(str)
@slot_error_boundary
def appendActivityLog(self, message: str) -> None:
"""Public entry point for QML-side conditions (e.g. license/trial
state) to log into the same Activity Log as backend-triggered lines."""
self._append_log(message)
@Slot() @Slot()
@slot_error_boundary @slot_error_boundary
def clearActivityLog(self) -> None: def clearActivityLog(self) -> None:
+30
View File
@@ -267,3 +267,33 @@ def test_replay_trial_expiry(tmp_path, qapp):
def test_replay_trial_expired_without_marker(tmp_path, qapp): def test_replay_trial_expired_without_marker(tmp_path, qapp):
model = LicenseModel(str(tmp_path)) model = LicenseModel(str(tmp_path))
assert model.replayTrialExpired() is True assert model.replayTrialExpired() is True
# ===== Review-access gate (ADR-0005 "Deferred" item) =====
def test_review_access_not_blocked_before_trial_starts(tmp_path, qapp):
model = LicenseModel(str(tmp_path))
assert model.reviewAccessBlocked() is False
def test_review_access_not_blocked_during_fresh_trial(tmp_path, qapp):
model = LicenseModel(str(tmp_path))
model.startReplayTrial()
assert model.reviewAccessBlocked() is False
def test_review_access_blocked_once_trial_expired(tmp_path, qapp):
model = LicenseModel(str(tmp_path))
model.startReplayTrial()
old = time.time() - 31 * 86400
os.utime(model._trial_marker, (old, old))
assert model.reviewAccessBlocked() is True
def test_review_access_not_blocked_with_permanent_license_even_if_trial_expired(tmp_path, qapp):
(tmp_path / "licenses").mkdir()
write_license(tmp_path / "licenses", level="01")
model = LicenseModel(str(tmp_path))
model.startReplayTrial()
old = time.time() - 31 * 86400
os.utime(model._trial_marker, (old, old))
assert model.reviewAccessBlocked() is False