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
+41 -6
View File
@@ -33,6 +33,9 @@ Rectangle {
property string dataCompareFileB: ""
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) {
file_browser.refreshFiles();
if (root.mapReviewFile !== "") {
@@ -68,6 +71,9 @@ Rectangle {
if (root.selectedTabIndex === 2) root.mapReviewFile = 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
@@ -84,6 +90,24 @@ Rectangle {
property bool waferDetected: 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 {
target: deviceController
function onDetectResult(result){
@@ -124,6 +148,7 @@ Rectangle {
root.selectedTabIndex = 0
}
root.waferLicensed = root.waferDetected && deviceController.waferLicensed()
root.reviewBlocked = licenseModel.reviewAccessBlocked()
}
}
Connections {
@@ -133,9 +158,9 @@ Rectangle {
result !== undefined &&
result.success === true)
if (root.memoryRead) {
console.log("[P1.1] Memory read complete:", result.bytes, "bytes")
console.log("Memory read complete:", result.bytes, "bytes")
} else {
console.log("[P1.1] Read failed:", result.error || "unknown")
console.log("Read failed:", result.error || "unknown")
}
}
}
@@ -271,6 +296,7 @@ Rectangle {
}
}
Component.onCompleted: {
root.reviewBlocked = licenseModel.reviewAccessBlocked()
if (isFirstLaunch) {
firstLaunchWizard.open()
}
@@ -342,10 +368,13 @@ Rectangle {
id: pillBtn
checked: root.selectedTabIndex === index
// 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
// tooltip can explain why — a disabled Item receives no mouse/hover
// 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
Layout.fillWidth: true
Layout.fillHeight: true
@@ -411,9 +440,15 @@ Rectangle {
AppToolTip {
visible: pillBtn.hovered
text: (index === 2 && licenseModel.licenses.length === 0)
? "<b>MAP</b>: No license loaded — load one in About to unlock"
: "<b>" + model.label + "</b>: " + model.desc
// 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>" + model.label + "</b>: " + model.desc
}
onClicked: if (!pillBtn.locked) root.selectedTabIndex = index
@@ -303,6 +303,13 @@ class DeviceController(QObject):
self._activity_log = self._activity_log[-200:]
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_error_boundary
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):
model = LicenseModel(str(tmp_path))
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