v3.23.3: the low-counter notice polish

The two buttons under a cntr_low verdict shared the red warning style, and
the notice only ever rendered in the RAM pill: an SCP80 send wrote it into
the hidden RAM container, so the plain-send view showed the verdict text
but offered no way to act on it.

- The counter probe button is amber and the preset jump green (the warning
  text stays red).
- The probe renders the inline throbber the RAM operations use
  (animate-spin, no modal) while it runs; its result replaces it in the
  same box.
- Each pill carries its own notice box (#ram-cntr-low / #sp-cntr-low):
  ramShowCntrLow/ramSyncCounter target the originating view's box and
  clear the other, the SCP80 send passes 'sp' explicitly (it used to rely
  on the keyset-view heuristic), and a new send attempt clears the stale
  verdict (ramClearResults clears both boxes).
- The prebuilt Tailwind output was regenerated (npm run build):
  border-amber-300/-700 and border-emerald-300/-700 were absent from
  style.css (the stale-style trap).

Tests: the button classes, the spinner and the box targeting pinned in
ram.test.js.  846 frontend + 830 Python; sw cache simple-v416.
This commit is contained in:
2026-10-07 10:28:42 +03:00
parent 9ad22f2e26
commit 17863001f3
7 changed files with 97 additions and 24 deletions
+43 -18
View File
@@ -684,6 +684,7 @@
<textarea id="sp-result" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800"></textarea>
<div id="sp-size-info" class="text-xs mt-1 text-gray-500 dark:text-slate-400"></div>
<div id="sp-send-result" class="mt-2 text-xs font-mono hidden"></div>
<div id="sp-cntr-low" class="mt-2 hidden"></div>
<div id="sp-verify-result" class="mt-2 text-xs font-mono hidden"></div>
</div>
@@ -1904,7 +1905,7 @@
// ===== Version =====
// Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect().
const SIMPLE_VERSION = '3.23.2';
const SIMPLE_VERSION = '3.23.3';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== view history (the browser Back/Forward buttons) =====
@@ -9639,6 +9640,9 @@ async function pysimSendOta() {
sendResultEl.classList.add('text-gray-500');
sendResultEl.textContent = t('Sending OTA...');
porStatusEl.classList.add('hidden');
// a new attempt invalidates the previous low-counter verdict (the notice
// stays in the pill until then; v3.23.3)
ramCntrLowClear();
try {
const data = await pysimFetch('/api/send-ota', { sp, ...spParams() });
// low counter: not recoverable by retrying (v3.6.58)
@@ -9646,7 +9650,7 @@ async function pysimSendOta() {
sendResultEl.textContent = t('Low counter') + ' — ' + t('the card rejected the packet');
sendResultEl.classList.remove('text-gray-500', 'text-green-600');
sendResultEl.classList.add('text-red-600');
ramShowCntrLow();
ramShowCntrLow(undefined, 'sp');
return;
}
if (data.success) {
@@ -9898,8 +9902,7 @@ function ramClearResults() {
document.getElementById('ram-explorer').innerHTML = '';
document.getElementById('ram-steps').classList.add('hidden');
document.getElementById('ram-steps').textContent = '';
const cntrLowEl = document.getElementById('ram-cntr-low');
if (cntrLowEl) { cntrLowEl.innerHTML = ''; cntrLowEl.classList.add('hidden'); }
ramCntrLowClear();
}
// Read a File as a hex string via FileReader (client-side, no upload)
@@ -11576,7 +11579,7 @@ async function ramExplore(sp) {
// the run was stopped: state the verdict, do not retry
resultEl.textContent = t('Low counter') + ' — ' + t('the operation was stopped');
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
ramShowCntrLow();
ramShowCntrLow(undefined, 'ram');
} else if (errors.length) {
resultEl.textContent = t('Partial') + ' — ' + errors.join('; ');
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
@@ -11685,7 +11688,7 @@ async function ramDeleteFromExplorer(aid, withCascade) {
const el = document.getElementById('ram-result');
el.textContent = t('Low counter') + ' — ' + t('the operation was stopped');
el.classList.remove('hidden', 'text-green-600'); el.classList.add('text-red-600');
ramShowCntrLow();
ramShowCntrLow(undefined, 'ram');
return;
}
// The card consumes the counter as soon as it accepts the packet, even
@@ -12119,7 +12122,7 @@ async function ramCompatTest(sp) {
if (spCntrLow(data)) {
const v = ramCntrLowView();
modal += ramCntrLowHtml(ramCntrLowPresetIdx(v), v);
ramShowCntrLow(undefined, v);
ramShowCntrLow(undefined, 'ram');
}
ramOpModalFinish(verdict, !!data.imports_ok, modal, true);
}
@@ -12510,23 +12513,44 @@ function ramCntrLowHtml(presetIdx, view) {
const idx = Number.isInteger(presetIdx) && presetIdx >= 0 ? presetIdx : -1;
const v = view === 'sp' ? 'sp' : 'ram';
const text = t('Low counter') + ' \u2014 ' + t('the card rejected the packet');
const btnCls = 'px-2 py-1 text-xs rounded border border-red-300 dark:border-red-700 ' +
'hover:bg-red-100 dark:hover:bg-red-900/40 whitespace-nowrap';
// the probe is amber (a corrective action), the preset jump green (the
// manual fix) - v3.23.3, they used to share the red warning style
const base = 'px-2 py-1 text-xs rounded border whitespace-nowrap ';
const syncCls = base + 'border-amber-300 dark:border-amber-700 text-amber-700 ' +
'dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/40';
const goCls = base + 'border-emerald-300 dark:border-emerald-700 text-emerald-700 ' +
'dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-900/40';
return '<div class="flex items-center gap-3 text-sm text-red-600 dark:text-red-400">' +
'<span>' + text + '</span>' +
'<button type="button" onclick="ramSyncCounter(' + idx + ', \'' + v + '\')" class="' + btnCls + '">' +
'<button type="button" onclick="ramSyncCounter(' + idx + ', \'' + v + '\')" class="' + syncCls + '">' +
esc(t('Sync counter')) + '</button>' +
'<button type="button" onclick="ramGoToPreset(' + idx + ')" class="' + btnCls + '">' +
'<button type="button" onclick="ramGoToPreset(' + idx + ')" class="' + goCls + '">' +
esc(t('Go to preset')) + '</button></div>';
}
// Show the low-counter notice in the RAM result area (and, when the operation
// modal is still open, in its detail block).
// The low-counter notice box of a view: the RAM and Secured packet pills
// each carry one, so the notice (and the counter probe's progress) is visible
// in the view the failed operation ran in (v3.23.3).
function ramCntrLowBox(view) {
return document.getElementById(view === 'sp' ? 'sp-cntr-low' : 'ram-cntr-low');
}
function ramCntrLowClear() {
['ram-cntr-low', 'sp-cntr-low'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.innerHTML = ''; el.classList.add('hidden'); }
});
}
// Show the low-counter notice in the originating view's box (the other one is
// cleared: only the latest verdict stays on screen) and, when the operation
// modal is still open, in its detail block.
function ramShowCntrLow(presetIdx, view) {
const v = view === undefined ? ramCntrLowView() : view;
const idx = presetIdx === undefined ? ramCntrLowPresetIdx(v) : presetIdx;
const html = ramCntrLowHtml(idx, v);
const el = document.getElementById('ram-cntr-low');
ramCntrLowClear();
const el = ramCntrLowBox(v);
if (el && el.classList) {
el.innerHTML = html;
el.classList.remove('hidden');
@@ -12545,7 +12569,7 @@ function ramShowCntrLow(presetIdx, view) {
async function ramSyncCounter(presetIdx, view) {
const i = Number(presetIdx);
const preset = (Number.isInteger(i) && i >= 0 && cards[i]) ? cards[i] : null;
const box = document.getElementById('ram-cntr-low');
const box = ramCntrLowBox(view === 'sp' ? 'sp' : 'ram');
if (!preset) { alert(t('No card preset selected')); return; }
// The keyset the failed operation used: the originating view's selector
// first, the other view's as the fallback (both hold the keyset number as
@@ -12572,8 +12596,9 @@ async function ramSyncCounter(presetIdx, view) {
else if (val('sp-spi2-hex')) body.spi2 = val('sp-spi2-hex');
const cntr = val('sp-cntr').replace(/[^0-9a-fA-F]/g, '');
if (cntr) body.cntr = cntr;
if (box) box.innerHTML = '<div class="text-sm text-gray-500 dark:text-slate-400">' +
esc(t('Syncing counter...')) + '</div>';
if (box) box.innerHTML = '<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-slate-400">' +
'<div class="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>' +
'<span>' + esc(t('Syncing counter...')) + '</span></div>';
try {
const data = await pysimFetch('/api/counter-probe', body);
if (data.success || data.counter_saved) {
@@ -12677,7 +12702,7 @@ function ramShowInstallResult(data, okText, capMem) {
if (spCntrLow(data)) {
const v = ramCntrLowView();
modal += ramCntrLowHtml(ramCntrLowPresetIdx(v), v);
ramShowCntrLow(undefined, v);
ramShowCntrLow(undefined, 'ram');
}
ramOpModalFinish(resultEl.textContent, !!data.success, modal, true);
}
+35 -2
View File
@@ -1307,6 +1307,11 @@ video {
border-top-width: 1px;
}
.border-amber-300 {
--tw-border-opacity: 1;
border-color: rgb(252 211 77 / var(--tw-border-opacity, 1));
}
.border-amber-400 {
--tw-border-opacity: 1;
border-color: rgb(251 191 36 / var(--tw-border-opacity, 1));
@@ -1327,6 +1332,11 @@ video {
border-color: rgb(59 130 246 / var(--tw-border-opacity, 1));
}
.border-emerald-300 {
--tw-border-opacity: 1;
border-color: rgb(110 231 183 / var(--tw-border-opacity, 1));
}
.border-gray-100 {
--tw-border-opacity: 1;
border-color: rgb(243 244 246 / var(--tw-border-opacity, 1));
@@ -1887,6 +1897,11 @@ video {
color: #f1f5f9;
}
.hover\:bg-amber-100:hover {
--tw-bg-opacity: 1;
background-color: rgb(254 243 199 / var(--tw-bg-opacity, 1));
}
.hover\:bg-amber-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(180 83 9 / var(--tw-bg-opacity, 1));
@@ -1897,6 +1912,11 @@ video {
background-color: rgb(29 78 216 / var(--tw-bg-opacity, 1));
}
.hover\:bg-emerald-100:hover {
--tw-bg-opacity: 1;
background-color: rgb(209 250 229 / var(--tw-bg-opacity, 1));
}
.hover\:bg-emerald-200:hover {
--tw-bg-opacity: 1;
background-color: rgb(167 243 208 / var(--tw-bg-opacity, 1));
@@ -2017,14 +2037,19 @@ video {
border-color: rgb(217 119 6 / var(--tw-border-opacity, 1));
}
.dark\:border-amber-700:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(180 83 9 / var(--tw-border-opacity, 1));
}
.dark\:border-blue-800:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(30 64 175 / var(--tw-border-opacity, 1));
}
.dark\:border-red-700:is(.dark *) {
.dark\:border-emerald-700:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(185 28 28 / var(--tw-border-opacity, 1));
border-color: rgb(4 120 87 / var(--tw-border-opacity, 1));
}
.dark\:border-red-800:is(.dark *) {
@@ -2225,6 +2250,14 @@ video {
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
.dark\:hover\:bg-amber-900\/40:hover:is(.dark *) {
background-color: rgb(120 53 15 / 0.4);
}
.dark\:hover\:bg-emerald-900\/40:hover:is(.dark *) {
background-color: rgb(6 78 59 / 0.4);
}
.dark\:hover\:bg-red-900\/40:hover:is(.dark *) {
background-color: rgb(127 29 29 / 0.4);
}
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v415';
const CACHE = 'simple-v416';
const URLS = [
'index.html',
'help.html',
+15 -1
View File
@@ -28,6 +28,7 @@ const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamB
'_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'ramParseModuleAids', 'parseTLV', '_parseE3Entry', '_parseMenuEntries',
'ramExpandedReport', 'ramExpandedMore', 'ramPageFormats', 'ramAppsMissingDetails', 'ramExpandedTags', 'ramExpandedGroups', 'ramExpandedElfForm', 'ramElfVersionHint', 'cardEtsiRelease', 'ramChainGetResponse', 'ramDeriveElfVersions', 'ramElfAppletCandidate', 'ramElfAppMatch',
'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramCntrLowView', 'ramShowCntrLow',
'ramCntrLowBox', 'ramCntrLowClear',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'spPresetIdx', 'tarPresetIdx',
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply', 'spKeysetChanged',
@@ -913,6 +914,18 @@ test('ramCntrLowHtml states the facts without a fabricated counter', () => {
assert.ok(noPreset.includes('onclick="ramGoToPreset(-1)"'), noPreset);
assert.ok(noPreset.includes('onclick="ramSyncCounter(-1, \'ram\')"'), noPreset);
assert.ok(spView.includes('onclick="ramSyncCounter(1, \'sp\')"'), spView);
// v3.23.3: the probe button is amber, the preset jump green
const syncBtn = out.match(/<button[^>]*ramSyncCounter[^>]*>/)[0];
assert.ok(syncBtn.includes('border-amber-300'), syncBtn);
assert.ok(!syncBtn.includes('border-red-300'), syncBtn);
const goBtn = out.match(/<button[^>]*ramGoToPreset[^>]*>/)[0];
assert.ok(goBtn.includes('border-emerald-300'), goBtn);
// the notice targets the originating view's box; the probe renders the
// inline throbber (the RAM inline-progress spinner, no modal)
assert.match(extractFunc(html, 'ramShowCntrLow'), /ramCntrLowBox\(v\)/);
assert.match(extractFunc(html, 'ramSyncCounter'), /animate-spin/);
assert.match(extractFunc(html, 'ramSyncCounter'), /ramCntrLowBox\(/);
assert.match(html, /id="sp-cntr-low"/);
});
test('the explorer shows versions on app and ISD rows too', () => {
@@ -963,7 +976,8 @@ test('every SCP80/RAM flow checks the low counter and stops', () => {
const explore = extractFunc(html, 'ramExplore');
assert.ok(explore.includes('spCntrLow(res)'), 'the Explore must check every send');
assert.ok(explore.includes('if (cntrLow) break;'), 'the loops must stop');
assert.ok(explore.includes('ramShowCntrLow()'), 'the verdict must be shown');
assert.ok(explore.includes("ramShowCntrLow(undefined, 'ram')"),
'the verdict must be shown in the RAM view');
// the delete flow and the plain send check it too
assert.ok(extractFunc(html, 'ramDeleteFromExplorer').includes('spCntrLow(res)'),
'the delete flow must stop');
+1
View File
@@ -32,6 +32,7 @@ for (const fn of ['berLenStr', 'ramDeleteApdu', 'ramIncrementCntr', 'ramSaveCntr
'spPresetIdx', 'spPresetCounterValue', 'spCntrPlaceholder', 'spCntrAhead',
'ramHideProgress', 'ramDeleteFromExplorer',
'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramCntrLowView', 'ramShowCntrLow',
'ramCntrLowBox', 'ramCntrLowClear',
'escHtml', 'esc',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
version = "3.23.2"
version = "3.23.3"
description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+1 -1
View File
@@ -36,7 +36,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.23.2'
VERSION = '3.23.3'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE