Add trace map view; decode PNN/SPN and 7-bit SMS TP-UD

This commit is contained in:
catarrh committed 2026-08-17 01:08:49 +03:00
1 parent 1c2d6af9e4
commit 017c65de27
3 files changed
+258 -21

No files matched your search

+167 -3
View File
@@ -136,6 +136,11 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(-
.side-item { display: flex; justify-content: space-between; padding: 3px 8px; cursor: pointer; border-radius: 4px; font-size: 13px; }
.side-item:hover { background: var(--bg-input); }
.side-item .count { color: var(--accent); font-weight: 600; }
.side-tabs { display: flex; margin-bottom: 8px; border-bottom: 1px solid var(--bg-input); }
.side-tab { flex: 1; padding: 6px 0; background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; border-bottom: 2px solid transparent; }
.side-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.map-item { padding: 3px 8px; cursor: pointer; border-radius: 4px; font-size: 13px; color: var(--text); word-break: break-word; }
.map-item:hover { background: var(--bg-input); }
/* Status bar */
.status-bar { display: flex; padding: 8px 16px; background: var(--bg-panel); border-top: 1px solid var(--bg-input); font-size: 13px; gap: 16px; flex-shrink: 0; }
@@ -250,8 +255,12 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(-
</div>
</div>
<div class="side-panel" id="side-panel">
<h3 data-l10n="Command Stats">Command Stats</h3>
<div class="side-tabs">
<button class="side-tab active" data-view="stats" data-l10n="Stats">Stats</button>
<button class="side-tab" data-view="map" data-l10n="Map">Map</button>
</div>
<div id="type-counts">—</div>
<div id="type-map" style="display:none">—</div>
</div>
</div>
<div class="status-bar">
@@ -332,6 +341,8 @@ const STATE = {
livePoller: null,
messages: [],
typeCounts: [],
traceMap: [],
sideView: 'stats',
pendingDeleteId: null,
serverAvailable: false,
serverName: '',
@@ -695,6 +706,8 @@ function fileRows(f) {
if (f.iccid) rows.push(['ICCID', f.iccid]);
if (f.languages) rows.push(['Languages', f.languages.join(', ')]);
if (f.name !== undefined) rows.push(['Name', f.name]);
if (f.full !== undefined) rows.push(['Full name', f.full]);
if (f.short !== undefined) rows.push(['Short name', f.short]);
if (f.number) rows.push(['Number', f.number]);
if (f.text) rows.push(['Text', f.text]);
if (f.ccp !== undefined) rows.push(['CCP', '0x' + hx(f.ccp, 2)]);
@@ -916,6 +929,112 @@ function buildTypeCounts() {
STATE.typeCounts = counts;
}
function _ins_hex(m) {
if (m.type !== 'tpdu' || !m.decoded) return null;
return m.decoded.ins_hex;
}
function map_file_label(m) {
const d = m.decoded;
if (!d) return null;
if (d.ins_hex === 'a4') { // SELECT
if (d.body && d.body.note) return d.body.note;
if (d.summary) return d.summary;
return null;
}
if (d.file) return d.file.ef || d.file.fid || null;
return null;
}
const MAP_RW_INS = new Set(['a4', 'a2', 'b0', 'b1', 'b2', 'd6', 'dc']);
function is_read_ins(ins) { return ins === 'a2' || ins === 'b0' || ins === 'b1' || ins === 'b2'; }
function is_write_ins(ins) { return ins === 'd6' || ins === 'dc'; }
function map_other_label(m) {
const d = m.decoded;
if (d && d.ins_name === 'ENVELOPE' && d.summary) {
return 'ENVELOPE: ' + d.summary;
}
return stat_label(m);
}
function buildTraceMap() {
const msgs = STATE.messages;
const groups = [];
let i = 0;
while (i < msgs.length) {
const ins = _ins_hex(msgs[i]);
if (ins === 'c0') { i++; continue; } // skip GET RESPONSE
if (ins && MAP_RW_INS.has(ins)) {
// Split the run into per-file records (delimited by SELECT).
const access = [];
let cur = null;
let j = i;
while (j < msgs.length) {
const jins = _ins_hex(msgs[j]);
if (jins === 'c0') { j++; continue; } // skip GET RESPONSE
if (!jins || !MAP_RW_INS.has(jins)) break;
if (jins === 'a4') {
if (cur) access.push(cur);
cur = { file: map_file_label(msgs[j]), hasRead: false, hasWrite: false, start: j, end: j };
} else if (is_write_ins(jins)) {
if (!cur) cur = { file: map_file_label(msgs[j]), hasRead: false, hasWrite: false, start: j, end: j };
cur.hasWrite = true;
cur.end = j;
} else { // SEARCH / READ
if (!cur) cur = { file: map_file_label(msgs[j]), hasRead: false, hasWrite: false, start: j, end: j };
cur.hasRead = true;
cur.end = j;
}
j++;
}
if (cur) access.push(cur);
let k = 0;
while (k < access.length) {
const rec = access[k];
if (rec.hasRead && rec.hasWrite) {
groups.push({ label: t('Read/write:') + ' ' + (rec.file || '—'), start: rec.start, end: rec.end });
k++;
} else if (rec.hasWrite) {
const files = [];
let end = rec.end;
let m = k;
while (m < access.length && access[m].hasWrite && !access[m].hasRead) {
if (access[m].file) files.push(access[m].file);
end = access[m].end;
m++;
}
groups.push({ label: t('Writes:') + ' ' + (files.join(', ') || '—'), start: rec.start, end });
k = m;
} else {
const files = [];
let end = rec.end;
let m = k;
while (m < access.length && !access[m].hasWrite) {
if (access[m].file) files.push(access[m].file);
end = access[m].end;
m++;
}
groups.push({ label: t('Reads:') + ' ' + (files.join(', ') || '—'), start: rec.start, end });
k = m;
}
}
i = j;
} else if (ins === 'f2') {
let j = i;
while (j < msgs.length && _ins_hex(msgs[j]) === 'f2') j++;
groups.push({ label: 'STATUS: ' + (j - i), start: i, end: j - 1 });
i = j;
} else {
groups.push({ label: map_other_label(msgs[i]), start: i, end: i });
i++;
}
}
STATE.traceMap = groups;
}
function renderSidePanel() {
const entries = Object.entries(STATE.typeCounts).sort((a, b) => b[1] - a[1]);
el('type-counts').innerHTML = entries.map(([k, v], i) =>
@@ -923,6 +1042,18 @@ function renderSidePanel() {
<span>${k}</span><span class="count">${v}</span>
</div>`
).join('') || t('No commands');
const mapEl = el('type-map');
mapEl.innerHTML = STATE.traceMap.map(g =>
`<div class="map-item" data-start="${g.start}">${g.label}</div>`
).join('') || t('No commands');
const showMap = STATE.sideView === 'map';
el('type-counts').style.display = showMap ? 'none' : '';
mapEl.style.display = showMap ? '' : 'none';
document.querySelectorAll('#side-panel .side-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.view === STATE.sideView);
});
}
function populateFilterOptions() {
@@ -959,6 +1090,21 @@ function scrollToType(typeKey) {
}
}
function focusMessage(idx) {
const filterEl = el('filter-select');
const searchEl = el('search-input');
const needRender = filterEl.value !== '' || searchEl.value !== '';
filterEl.value = '';
searchEl.value = '';
if (needRender) renderMessages(true);
const entry = el('apdu-list').querySelector(`.apdu-entry[data-idx="${idx}"]`);
if (!entry) return;
entry.scrollIntoView({ block: 'center', behavior: 'smooth' });
entry.classList.add('selected');
setTimeout(() => entry.classList.remove('selected'), 1500);
showDetail(STATE.messages[idx], entry);
}
// ──────────────────── Render Trace ────────────────────
function isOriginalCommand(i) {
if (STATE.messages[i].type !== 'tpdu') return false;
@@ -1001,7 +1147,7 @@ function renderTraceEmptyState() {
updateStatus();
}
function renderMessages() {
function renderMessages(skipScroll) {
const list = el('apdu-list');
const searchQ = el('search-input').value.toLowerCase();
const filterType = el('filter-select').value;
@@ -1015,6 +1161,7 @@ function renderMessages() {
}
buildTypeCounts();
buildTraceMap();
renderSidePanel();
populateFilterOptions();
@@ -1034,7 +1181,7 @@ function renderMessages() {
</div>`;
}).join('');
list.scrollTop = list.scrollHeight;
if (!skipScroll) list.scrollTop = list.scrollHeight;
updateStatus();
}
@@ -1101,6 +1248,7 @@ async function startCapture() {
STATE.currentSessionId = data.session_id;
STATE.messages = [];
buildTypeCounts();
buildTraceMap();
renderSidePanel();
renderMessages();
updateCaptureButton();
@@ -1527,6 +1675,11 @@ const LANG_RU = {
'Connect to server': 'Подключиться к серверу',
'Click an APDU to see details': 'Нажмите на APDU для просмотра деталей',
'Command Stats': 'Статистика команд',
'Stats': 'Статистика',
'Map': 'Карта',
'Reads:': 'Чтение:',
'Writes:': 'Запись:',
'Read/write:': 'Чтение/запись:',
'Not connected': 'Не подключено',
'Live': 'Захват',
'Session': 'Сессия',
@@ -1772,6 +1925,17 @@ function init() {
$on('delete-confirm', 'click', confirmDelete);
el('side-panel').addEventListener('click', e => {
const tab = e.target.closest('.side-tab');
if (tab) {
STATE.sideView = tab.dataset.view;
renderSidePanel();
return;
}
const mapItem = e.target.closest('.map-item');
if (mapItem) {
focusMessage(parseInt(mapItem.dataset.start));
return;
}
const item = e.target.closest('.side-item');
if (!item) return;
scrollToType(item.dataset.key);