- the P3 row read "0x02 — 2 bytes" for both kinds of P3; the decode now marks it (p3_kind): a command that carries data has Lc (the command-data length), a case-2 command (APDU_SPEC 'le', e.g. READ BINARY/RECORD, GET RESPONSE, SEARCH…) has Le (the expected response length). The detail row appends "· Lc (command data length)" / "· Le (expected response length)" («Lc (длина данных команды)» / «Le (ожидаемая длина ответа)») - a SEARCH RECORD without an SFI searched the current EF (TS 102 221 §11.1.7.1) but showed no file at all; it now resolves like READ/UPDATE: the SFI's declared file when SFI-addressed (an unresolved SFI stays marked "SFI n"), otherwise the current selection — with the stale-selection note when that EF is not a record file. Verified on session 193: the 5.371 s search (P2=0x04, no SFI) now shows 4FA1, the 5.434 s one (SFI 2) shows 4F3A - tests: p3_kind cases (Le/Lc/none) and the SEARCH target cases (current EF, SFI-resolved, unknown SFI, transparent selection); a Node pin for the P3 row and the two new Russian strings; 540 Python + 77 Node tests - PWA_VERSION/pyproject/version.py 2.7.9, SW cache simtrace-analyser-v89
3898 lines
185 KiB
HTML
3898 lines
185 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en" data-theme="dark">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>SIMtrace Analyser</title>
|
||
<link rel="manifest" href="manifest.json">
|
||
<link rel="icon" type="image/svg+xml" href="sim-analyser.svg" id="favicon-svg">
|
||
<link rel="icon" type="image/png" href="favicon.png">
|
||
<meta name="theme-color" content="#0f172a">
|
||
<style>
|
||
:root {
|
||
/* Layout sizes (theme-independent) */
|
||
--side-panel-w: 260px;
|
||
}
|
||
|
||
:root, [data-theme="dark"] {
|
||
--bg: #0f172a;
|
||
--bg-panel: #1e293b;
|
||
--bg-input: #334155;
|
||
--border: #475569;
|
||
--text: #e2e8f0;
|
||
--text-muted: #94a3b8;
|
||
--text-dim: #cbd5e1;
|
||
--text-faint: #cbd5e1;
|
||
--accent: #3b82f6;
|
||
--accent-hover: #2563eb;
|
||
--accent-bg: #14532d;
|
||
--warn-bg: #7f1d1d;
|
||
--warn: #facc15;
|
||
--warn-hover: #eab308;
|
||
--warning-bg: #78350f;
|
||
--error: #f87171;
|
||
--danger: #b91c1c;
|
||
--danger-hover: #dc2626;
|
||
--code-bg: #1e293b;
|
||
--scrollbar-track: #0f172a;
|
||
--scrollbar-thumb: #334155;
|
||
--modal-bg: rgba(0,0,0,0.6);
|
||
--green: #22c55e;
|
||
--yellow: #facc15;
|
||
--link: #94a3b8;
|
||
--link-hover: #e2e8f0;
|
||
--proactive: #a78bfa;
|
||
--tr: #22d3ee;
|
||
--envelope: #fbbf24;
|
||
--auth: #f87171;
|
||
--pair-row: rgba(148, 163, 184, 0.12);
|
||
}
|
||
[data-theme="light"] {
|
||
--bg: #fafafa;
|
||
--bg-panel: #e5e7eb;
|
||
--bg-input: #ffffff;
|
||
--border: #d1d5db;
|
||
--text: #1f2937;
|
||
--text-muted: #6b7280;
|
||
--text-dim: #4b5563;
|
||
--text-faint: #374151;
|
||
--accent: #2563eb;
|
||
--accent-hover: #1d4ed8;
|
||
--accent-bg: #dcfce7;
|
||
--warn-bg: #fee2e2;
|
||
--warn: #f59e0b;
|
||
--warn-hover: #d97706;
|
||
--warning-bg: #fef3c7;
|
||
--error: #dc2626;
|
||
--danger: #dc2626;
|
||
--danger-hover: #b91c1c;
|
||
--code-bg: #f3f4f6;
|
||
--scrollbar-track: #fafafa;
|
||
--scrollbar-thumb: #d1d5db;
|
||
--modal-bg: rgba(0,0,0,0.4);
|
||
--green: #16a34a;
|
||
--yellow: #ca8a04;
|
||
--link: #6b7280;
|
||
--link-hover: #1f2937;
|
||
--proactive: #6d28d9;
|
||
--tr: #0e7490;
|
||
--envelope: #b45309;
|
||
--auth: #dc2626;
|
||
--pair-row: rgba(100, 116, 139, 0.15);
|
||
}
|
||
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(--text); display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
|
||
|
||
/* Header */
|
||
.header { display: flex; background: var(--bg-panel); border-bottom: 1px solid var(--border); }
|
||
.header-tabs { display: flex; gap: 4px; padding: 8px 16px 0 16px; align-items: flex-end; }
|
||
.header-tab { padding: 6px 16px; cursor: pointer; color: var(--text-muted); background: var(--bg-input); border-radius: 6px 6px 0 0; font-size: 14px; }
|
||
.header-tab:hover { background: var(--border); }
|
||
.header-tab.active { background: var(--accent); color: #fff; }
|
||
.header-title { margin: 0 auto; display: flex; align-items: center; gap: 6px; color: var(--text); font-weight: 600; font-size: 14px; white-space: nowrap; }
|
||
.header-title .pwa-version { color: var(--text-muted); font-size: 11px; font-weight: 400; }
|
||
.header-title a { color: var(--accent); text-decoration: none; }
|
||
.header-title a:hover { text-decoration: underline; }
|
||
.header-right { margin-left: auto; display: flex; align-items: center; gap: 12px; padding: 8px 16px; }
|
||
.capture-chip { font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 12px; white-space: nowrap; }
|
||
.capture-chip.live { color: var(--green); background: var(--accent-bg); }
|
||
.capture-chip.warn { color: var(--warn); background: var(--warning-bg); }
|
||
.header-right a { color: var(--link); text-decoration: none; font-size: 13px; }
|
||
.header-right a:hover { color: var(--link-hover); }
|
||
.header-right button { padding: 4px 10px; font-size: 14px; cursor: pointer; background: var(--bg-input); border: 1px solid var(--border); border-radius: 4px; color: var(--text); }
|
||
.header-right button:hover { background: var(--accent); color: #fff; }
|
||
|
||
/* Panels */
|
||
.panel { flex: 1; display: none; flex-direction: column; overflow: hidden; }
|
||
.panel.active { display: flex; }
|
||
|
||
/* Trace view */
|
||
.trace-topbar { display: flex; gap: 12px; padding: 12px 16px; background: var(--bg-panel); align-items: center; flex-shrink: 0; }
|
||
.trace-topbar input { flex: 0 1 16.667%; padding: 8px 12px; background: var(--bg-input); border: 1px solid var(--border); border-radius: 4px; color: var(--text); }
|
||
.trace-topbar input::placeholder { color: var(--text-muted); }
|
||
.trace-topbar select { padding: 8px 12px; background: var(--bg-input); border: 1px solid var(--border); border-radius: 4px; color: var(--text); }
|
||
.trace-topbar .filter-label { color: var(--text-muted); font-size: 13px; white-space: nowrap; }
|
||
.trace-topbar #session-name-display { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.trace-topbar button { padding: 8px 16px; background: var(--accent); color: #fff; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; }
|
||
.trace-topbar button:hover { background: var(--accent-hover); }
|
||
.trace-topbar button.stop { background: var(--danger); color: #fff; }
|
||
.trace-topbar button.stop:hover { background: var(--danger-hover); }
|
||
.trace-topbar button.connect { background: var(--warn); color: #0f172a; }
|
||
.trace-topbar button.connect:hover { background: var(--warn-hover); }
|
||
.trace-topbar button.disabled { background: var(--bg-input); color: var(--text-muted); cursor: not-allowed; opacity: 0.6; }
|
||
|
||
/* Command filter: a button + a checkbox dropdown (presets and commands) */
|
||
.filter-dropdown { position: relative; }
|
||
#filter-btn { background: var(--bg-input); color: var(--text); border: 1px solid var(--border);
|
||
border-radius: 4px; padding: 8px 12px; cursor: pointer; font-weight: 400;
|
||
max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
#filter-btn:hover { background: var(--border); }
|
||
.filter-menu { position: absolute; top: calc(100% + 6px); left: 0; z-index: 60; min-width: 260px;
|
||
max-height: 60vh; overflow-y: auto; padding: 6px; background: var(--bg-panel);
|
||
border: 1px solid var(--border); border-radius: 4px; box-shadow: 0 8px 24px var(--modal-bg); }
|
||
.filter-item { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-radius: 4px;
|
||
cursor: pointer; font-size: 13px; white-space: nowrap; }
|
||
.filter-item:hover { background: var(--bg-input); }
|
||
.filter-item input { margin: 0; cursor: pointer; }
|
||
.filter-item .filter-count { margin-left: auto; color: var(--text-muted); font-size: 12px; }
|
||
.filter-menu .filter-heading { margin: 6px 8px 2px; color: var(--text-muted); font-size: 11px;
|
||
text-transform: uppercase; letter-spacing: 1px; }
|
||
.filter-sep { height: 1px; margin: 6px 4px; background: var(--border); }
|
||
.trace-main { display: flex; flex: 1; overflow: hidden; }
|
||
.trace-left { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||
|
||
/* APDU list */
|
||
.apdu-list { flex: 1 1 0; min-height: 0; overflow-y: auto; padding: 8px; }
|
||
.apdu-entry { display: flex; gap: 12px; padding: 3px 12px; border-bottom: 1px solid var(--bg-panel); cursor: pointer; align-items: baseline; }
|
||
.apdu-entry.apdu-pair { background: var(--pair-row); }
|
||
.apdu-entry:hover { background: var(--bg-panel); }
|
||
.apdu-entry.selected { background: var(--bg-input); }
|
||
[data-theme="light"] .apdu-entry.selected { background: #dbeafe; }
|
||
.apdu-time { color: var(--text-muted); font-family: monospace; white-space: nowrap; min-width: 80px; }
|
||
.apdu-type { color: var(--text); font-weight: 600; min-width: 150px; }
|
||
.apdu-type.atr { color: var(--green); }
|
||
.apdu-type.pps { color: var(--yellow); }
|
||
.apdu-type.rst { color: var(--warn); }
|
||
.apdu-type.vcc { color: var(--accent); }
|
||
.apdu-type.cat-proactive { font-weight: 700; color: var(--proactive); }
|
||
.apdu-type.cat-tr { font-weight: 700; color: var(--tr); }
|
||
.apdu-type.cat-envelope { color: var(--envelope); }
|
||
.apdu-type.cat-auth { color: var(--auth); }
|
||
.apdu-type.cat-gr { color: var(--text-muted); font-weight: 600; }
|
||
.apdu-data { font-family: monospace; color: var(--text-dim); word-break: break-all; flex: 1; }
|
||
.apdu-sw { font-family: monospace; color: var(--text-muted); white-space: nowrap; min-width: 52px; }
|
||
.apdu-sw.sw-ok { color: var(--green); }
|
||
.apdu-sw.sw-pending { color: var(--accent); }
|
||
.apdu-sw.sw-warn { color: var(--warn); }
|
||
.apdu-sw.sw-error { color: var(--error); }
|
||
.apdu-err { color: var(--error); white-space: nowrap; }
|
||
.apdu-gap { background: var(--warning-bg); border-bottom: 1px solid var(--warn); }
|
||
.apdu-gap .apdu-type { color: var(--warn); }
|
||
.apdu-gap .apdu-data { color: var(--warn); }
|
||
.device-warn { padding: 8px 12px; font-size: 12px; color: var(--warn); background: var(--warning-bg); border-bottom: 1px solid var(--warn); }
|
||
|
||
/* APDU detail panel */
|
||
.apdu-detail { display: flex; flex-direction: column; flex: 0 0 var(--detail-height, 30%); overflow-y: auto; background: var(--bg-panel); }
|
||
/* Splitter bar */
|
||
.splitter { height: 6px; cursor: row-resize; background: var(--border); flex-shrink: 0; }
|
||
.splitter::after { content: ''; display: block; margin: 0 auto; width: 30px; height: 100%; background: radial-gradient(circle, var(--text-muted) 1px, transparent 1px); background-size: 6px 6px; }
|
||
.splitter:hover::after, .splitter.active::after { background: radial-gradient(circle, var(--text) 1px, transparent 1px); background-size: 6px 6px; }
|
||
.splitter:hover, .splitter.active { background: var(--accent); }
|
||
.apdu-detail .detail-empty { padding: 24px; color: var(--text-muted); text-align: center; }
|
||
.detail-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: var(--bg-input); border-bottom: 1px solid var(--border); }
|
||
[data-theme="light"] .detail-header { background: #dbeafe; }
|
||
.detail-header strong { font-size: 13px; }
|
||
.decrypt-strip { padding: 6px 12px; border-top: 1px solid var(--border); background: var(--bg-panel); font-size: 12px; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||
.decrypt-strip input { background: var(--bg-input); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 3px 8px; font-family: monospace; width: 260px; }
|
||
.decrypt-strip button { background: var(--accent); color: #fff; border: none; border-radius: 4px; padding: 4px 10px; cursor: pointer; font-weight: 600; }
|
||
.detail-body { padding: 8px 12px; font-family: monospace; font-size: 12px; line-height: 1.6; }
|
||
.detail-body table { width: 100%; border-collapse: collapse; }
|
||
.detail-body td { padding: 1px 8px; vertical-align: top; }
|
||
.detail-body td.label { color: var(--text-muted); white-space: nowrap; width: 80px; }
|
||
.detail-body td.value { color: var(--text); }
|
||
.detail-body td.hex { color: var(--text-dim); word-break: break-all; }
|
||
.detail-body td.name { color: var(--accent); font-weight: 600; }
|
||
|
||
/* Side panel */
|
||
.side-panel { width: var(--side-panel-w); background: var(--bg-panel); padding: 12px; overflow-y: auto; flex-shrink: 0; border-left: 1px solid var(--bg-input); }
|
||
.side-panel h3 { color: var(--accent); margin-bottom: 8px; font-size: 13px; text-transform: uppercase; letter-spacing: 1px; }
|
||
.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); }
|
||
/* Side-panel view selector: it lives in the trace topbar, right-aligned over
|
||
the side panel with the panel's width, so it stays visible while the panel
|
||
scrolls. The negative right margin cancels the topbar's 16px padding, so
|
||
its right edge lines up with the panel's. */
|
||
.trace-topbar .side-view-select { width: var(--side-panel-w); flex: 0 0 auto; margin-left: auto; margin-right: -16px; }
|
||
.file-op { display: flex; align-items: baseline; gap: 6px; padding: 3px 8px; cursor: pointer; border-radius: 4px; font-size: 13px; word-break: break-word; }
|
||
.file-op:hover { background: var(--bg-input); }
|
||
.file-op-read { color: var(--text-muted); }
|
||
.file-op-update { color: var(--accent); }
|
||
.file-op-card { color: var(--warn); }
|
||
.file-op .file-op-diff { margin-left: auto; background: none; border: none; color: var(--accent); cursor: pointer; font-size: 12px; padding: 0 2px; flex: 0 0 auto; text-decoration: underline; }
|
||
.file-op .file-op-diff:hover { color: var(--warn); }
|
||
.file-diff-modal { max-width: 1100px; }
|
||
.file-diff-body { max-height: 60vh; overflow-y: auto; margin-bottom: 16px; }
|
||
.hex-legend { display: flex; align-items: center; gap: 14px; font-size: 11px; color: var(--text-muted); margin-top: 10px; flex-wrap: nowrap; overflow-x: auto; }
|
||
.hex-legend-item { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; flex: 0 0 auto; }
|
||
.hex-summary { font-size: 12px; color: var(--text-muted); margin-bottom: 10px; }
|
||
.hex-pair { display: flex; gap: 16px; flex-wrap: wrap; }
|
||
.hex-pair > div { flex: 1 1 320px; min-width: 0; }
|
||
.hex-pair h4 { font-size: 12px; text-transform: uppercase; letter-spacing: 1px; color: var(--accent); margin-bottom: 4px; }
|
||
.hex-view { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.45; white-space: pre; overflow-x: auto; }
|
||
.hex-row { display: flex; align-items: baseline; }
|
||
.hex-addr { color: var(--text-muted); margin-right: 8px; }
|
||
.hex-cell { padding: 0 1px; border-radius: 2px; }
|
||
.hex-cell.hex-changed { background: var(--warn); color: #1a1a1a; font-weight: 600; }
|
||
.hex-cell.hex-new { background: var(--accent); color: #fff; font-weight: 600; }
|
||
.hex-cell.hex-unknown { color: var(--text-muted); }
|
||
.hex-note { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
|
||
.hex-error { font-size: 12px; color: var(--error); }
|
||
.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); }
|
||
.map-files-toggle { color: var(--accent); cursor: pointer; }
|
||
.map-files-toggle .map-arrow { color: var(--text-muted); }
|
||
.map-item.map-proactive { font-weight: 700; color: var(--proactive); }
|
||
.map-item.map-tr { font-weight: 700; color: var(--tr); }
|
||
.map-item.map-envelope { font-weight: 600; color: var(--envelope); }
|
||
.map-item.map-auth { font-weight: 600; color: var(--auth); }
|
||
|
||
/* 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; }
|
||
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
|
||
.status-dot.live { background: var(--green); }
|
||
.status-dot.paused { background: var(--yellow); }
|
||
.status-dot.off { background: var(--text-muted); }
|
||
|
||
/* Sessions */
|
||
.sessions-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; padding: 16px; overflow-y: auto; }
|
||
.sessions-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px 0; gap: 12px; }
|
||
.sessions-header .sessions-stats { color: var(--text-muted); font-size: 13px; }
|
||
.sessions-header .import-msg { margin-left: auto; font-size: 13px; color: var(--text-muted); }
|
||
.sessions-header .import-msg.error { color: var(--error); }
|
||
.sessions-header button { padding: 8px 16px; background: var(--accent); color: #fff; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; }
|
||
.sessions-header button:hover { background: var(--accent-hover); }
|
||
.session-card { background: var(--bg-panel); border-radius: 8px; padding: 16px; cursor: pointer; border: 1px solid var(--bg-input); }
|
||
.session-card:hover { border-color: var(--accent); }
|
||
.session-name { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||
.session-name input { flex: 1; padding: 4px 8px; background: var(--bg-input); border: 1px solid var(--border); color: var(--text); border-radius: 4px; font-size: 14px; }
|
||
.session-name input:focus { border-color: var(--accent); outline: none; }
|
||
.session-name .edit-hint { color: var(--text-muted); font-size: 12px; }
|
||
.session-meta { display: flex; gap: 16px; color: var(--text-muted); font-size: 12px; }
|
||
.session-meta span { display: flex; align-items: center; gap: 4px; }
|
||
.session-actions { display: flex; gap: 8px; margin-top: 12px; align-items: center; }
|
||
.session-download { padding: 4px 12px; background: none; border: 1px solid var(--text-muted); color: var(--text-muted); border-radius: 4px; cursor: pointer; text-decoration: none; font-size: 13px; display: inline-block; }
|
||
.session-download:hover { border-color: var(--accent); color: var(--accent); }
|
||
.session-copy { padding: 4px 12px; background: none; border: 1px solid var(--text-muted); color: var(--text-muted); border-radius: 4px; cursor: pointer; font-size: 13px; }
|
||
.session-copy:hover { border-color: var(--accent); color: var(--accent); }
|
||
.session-delete { padding: 4px 12px; background: none; border: 1px solid var(--danger); color: var(--danger); border-radius: 4px; cursor: pointer; font-size: 13px; }
|
||
.session-delete:hover { background: var(--danger); color: #fff; }
|
||
.session-view-toggle { display: flex; gap: 2px; }
|
||
.session-view-toggle button { padding: 4px 10px; background: var(--bg-input); border: 1px solid var(--border); color: var(--text-muted); cursor: pointer; font-size: 13px; }
|
||
.session-view-toggle button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||
.sessions-list { padding: 0 16px 16px; overflow-y: auto; }
|
||
.sessions-list table { width: 100%; border-collapse: collapse; }
|
||
.sessions-list th { text-align: left; padding: 4px 12px; color: var(--text-muted); font-size: 12px; border-bottom: 1px solid var(--border); }
|
||
.sessions-list td { padding: 4px 12px; border-bottom: 1px solid var(--border); font-size: 13px; vertical-align: middle; }
|
||
.sessions-list th:first-child { min-width: 200px; }
|
||
.sessions-list input[type="text"] { width: 100%; padding: 4px 8px; background: var(--bg-input); border: 1px solid var(--border); color: var(--text); border-radius: 4px; font-size: 13px; box-sizing: border-box; }
|
||
.sessions-list input[type="text"]:focus { border-color: var(--accent); outline: none; }
|
||
.sessions-list tr[data-id] { cursor: pointer; }
|
||
.sessions-list tr[data-id]:hover { background: var(--bg-panel); }
|
||
.sessions-list td .session-actions { display: flex; gap: 8px; align-items: center; margin-top: 0; }
|
||
|
||
/* Settings */
|
||
.settings-view { padding: 24px; max-width: 600px; }
|
||
.settings-view label { display: block; margin-bottom: 6px; color: var(--text-muted); }
|
||
.settings-view input { width: 100%; padding: 8px 12px; background: var(--bg-input); border: 1px solid var(--border); color: var(--text); border-radius: 4px; }
|
||
.settings-view input::placeholder { color: var(--text-muted); }
|
||
.settings-url-row { display: flex; gap: 8px; margin-bottom: 16px; }
|
||
.settings-url-row input { flex: 1; margin-bottom: 0; }
|
||
.settings-url-row select { flex: 0 0 auto; padding: 8px 12px; background: var(--bg-input); border: 1px solid var(--border); color: var(--text); border-radius: 4px; }
|
||
.settings-url-row button { padding: 8px 16px; background: var(--accent); color: #fff; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; white-space: nowrap; }
|
||
.settings-url-row button:hover { background: var(--accent-hover); }
|
||
.settings-url-row button.disconnected { background: var(--bg-input); }
|
||
.conn-status { padding: 12px; border-radius: 4px; margin-bottom: 12px; }
|
||
.conn-status.connected { background: var(--accent-bg); color: var(--green); }
|
||
.conn-status.disconnected { background: var(--warn-bg); color: var(--error); }
|
||
.conn-hint { font-size: 12px; color: var(--text-muted); margin: 8px 0 0; line-height: 1.5; }
|
||
.capture-mode { padding: 12px; border-radius: 4px; margin-bottom: 12px; font-size: 13px; line-height: 1.5; }
|
||
.capture-mode:empty { display: none; }
|
||
.capture-mode.gsmtap { background: var(--warning-bg); color: var(--warn); }
|
||
.capture-mode.direct { background: var(--accent-bg); color: var(--green); }
|
||
.capture-mode.disabled { background: var(--warning-bg); color: var(--warn); }
|
||
.version-warn { font-size: 12px; color: var(--warn); margin: 8px 0 0; line-height: 1.5; }
|
||
.version-warn:empty { display: none; }
|
||
.conn-instructions { margin-top: 16px; font-size: 13px; color: var(--text-faint); line-height: 1.7; }
|
||
.conn-instructions h3 { color: var(--accent); margin-bottom: 8px; font-size: 14px; }
|
||
.conn-instructions ol { padding-left: 20px; }
|
||
.conn-instructions li { margin-bottom: 8px; }
|
||
.conn-instructions code { background: var(--code-bg); padding: 2px 6px; border-radius: 3px; font-size: 12px; }
|
||
.conn-instructions p { margin-top: 12px; }
|
||
|
||
/* Empty state */
|
||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-muted); gap: 16px; }
|
||
.empty-state button { padding: 12px 24px; background: var(--accent); color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; }
|
||
.empty-state .conn-status, .empty-state .capture-mode { width: 100%; max-width: 560px; text-align: left; }
|
||
|
||
/* Modal */
|
||
.modal-overlay { display: none; position: fixed; inset: 0; background: var(--modal-bg); z-index: 100; align-items: center; justify-content: center; }
|
||
.modal-overlay.show { display: flex; }
|
||
.modal { background: var(--bg-panel); border-radius: 8px; padding: 24px; max-width: 400px; width: 90%; }
|
||
.modal h3 { margin-bottom: 12px; color: var(--accent); }
|
||
.modal p { color: var(--text-faint); margin-bottom: 16px; }
|
||
.modal-buttons { display: flex; gap: 8px; justify-content: flex-end; }
|
||
.modal-buttons button { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; }
|
||
.modal-buttons .confirm { background: var(--accent); color: #fff; }
|
||
.modal-buttons .cancel { background: var(--bg-input); color: var(--text-faint); }
|
||
.modal-buttons button:disabled { opacity: 0.5; cursor: default; }
|
||
|
||
/* COMPRION import dialog */
|
||
.comprion-modal { max-width: 560px; }
|
||
.comprion-slot { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }
|
||
.comprion-slot label { flex: 1 1 auto; min-width: 0; font-size: 13px; color: var(--text-muted); }
|
||
.comprion-slot button { padding: 6px 10px; border: none; border-radius: 4px; cursor: pointer; background: var(--bg-input); color: var(--text); flex: 0 0 auto; }
|
||
.comprion-slot button:disabled { opacity: 0.5; cursor: default; }
|
||
.comprion-slot .file-name { flex: 1 1 100%; min-width: 0; font-size: 12px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.comprion-hint { font-size: 12px; line-height: 1.45; margin: 10px 0 0; }
|
||
.comprion-error { font-size: 12px; line-height: 1.45; color: var(--error); margin-top: 8px; }
|
||
.comprion-error:empty { display: none; }
|
||
|
||
/* Scrollbar — one style for every pane (APDU list, side panel, APDU detail,
|
||
dialogs, session lists, the hex views' horizontal bars) */
|
||
::-webkit-scrollbar { width: 12px; height: 12px; }
|
||
::-webkit-scrollbar-track { background: var(--scrollbar-track); }
|
||
::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 6px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="header">
|
||
<div class="header-tabs">
|
||
<div class="header-tab active" data-tab="trace" data-l10n="Trace">Trace</div>
|
||
<div class="header-tab" data-tab="sessions" data-l10n="Sessions">Sessions</div>
|
||
<div class="header-tab" data-tab="settings" data-l10n="Settings">Settings</div>
|
||
</div>
|
||
<div class="header-title"><a href="https://osmocom.org/projects/baseband/wiki/GSMTAP" target="_blank" rel="noopener">GSMTAP</a> <span data-l10n="trace analyser">trace analyser</span> <span class="pwa-version" id="pwa-version"></span></div>
|
||
<div class="header-right">
|
||
<span class="capture-chip" id="capture-chip" style="display:none"></span>
|
||
<button id="install-btn" data-l10n="Install PWA">Install PWA</button>
|
||
<a href="https://github.com/anttro/simtrace-analyser" target="_blank" rel="noopener">GitHub</a>
|
||
<button id="lang-btn" onclick="toggleLang()" title="Language / Язык">EN</button>
|
||
<button id="theme-btn" onclick="toggleTheme()">☀️</button>
|
||
</div>
|
||
</div><!-- Trace Panel -->
|
||
<div class="panel active" id="panel-trace">
|
||
<div class="trace-topbar">
|
||
<button id="capture-btn" data-l10n="Start Capture">Start Capture</button>
|
||
<input type="text" id="search-input" data-l10n="Search hex bytes..." placeholder="Search hex bytes..." autocomplete="off">
|
||
<span class="filter-label" data-l10n="Filter">Filter</span>
|
||
<div class="filter-dropdown" id="filter-dropdown">
|
||
<button type="button" id="filter-btn" aria-haspopup="true" aria-expanded="false">All commands</button>
|
||
<div class="filter-menu" id="filter-menu" role="group" aria-label="Filter" style="display:none"></div>
|
||
</div>
|
||
<span id="session-name-display" style="display:none; color:var(--text-muted); font-size:15px; font-weight:700;"></span>
|
||
<select class="side-view-select" id="side-view-select" aria-label="Side panel view">
|
||
<option value="map" data-l10n="Map">Map</option>
|
||
<option value="stats" data-l10n="Stats">Stats</option>
|
||
<option value="files" data-l10n="Files">Files</option>
|
||
</select>
|
||
</div>
|
||
<div class="trace-main">
|
||
<div class="trace-left">
|
||
<div class="apdu-list" id="apdu-list"></div>
|
||
<div class="splitter" id="splitter"></div>
|
||
<div class="apdu-detail" id="apdu-detail">
|
||
<div class="detail-empty" data-l10n="Click an APDU to see details">Click an APDU to see details</div>
|
||
</div>
|
||
</div>
|
||
<div class="side-panel" id="side-panel">
|
||
<div id="type-counts">—</div>
|
||
<div id="type-map" style="display:none">—</div>
|
||
<div id="file-view" style="display:none">—</div>
|
||
</div>
|
||
</div>
|
||
<div class="status-bar">
|
||
<span><span class="status-dot off" id="status-dot"></span><span id="status-text" data-l10n="Not connected">Not connected</span></span>
|
||
<span id="msg-count" style="color:var(--text-muted);">0 messages</span>
|
||
</div>
|
||
<div class="device-warn" id="device-warn" style="display:none">⚠ <span id="device-warn-text"></span></div>
|
||
</div>
|
||
|
||
<!-- Sessions Panel -->
|
||
<div class="panel" id="panel-sessions">
|
||
<div class="sessions-header">
|
||
<span class="sessions-stats" id="sessions-stats"></span>
|
||
<span class="import-msg" id="import-msg"></span>
|
||
<button id="import-pcap-btn" data-l10n="Import PCAP">Import PCAP</button>
|
||
<button id="import-comprion-btn" data-l10n="Import COMPRION trace" data-l10n-title="Import the 'ISO Protocol Layer' export, optionally with the matching 'ISO Application Layer' export for exact timestamps.">Import COMPRION trace</button>
|
||
<button id="new-capture-btn" data-l10n="New capture">New capture</button>
|
||
<input type="file" id="import-pcap-file" accept=".pcap,.pcapng,.cap" style="display:none;">
|
||
<div class="session-view-toggle">
|
||
<button id="view-grid-btn" class="active" data-l10n="Tiles">Tiles</button>
|
||
<button id="view-list-btn" data-l10n="List">List</button>
|
||
</div>
|
||
</div>
|
||
<div class="sessions-grid" id="sessions-grid">
|
||
<div class="empty-state" style="height:auto; padding:40px;">
|
||
<p data-l10n="No saved sessions">No saved sessions</p>
|
||
<p style="font-size:12px;" data-l10n="Start a capture from the Trace tab to create sessions.">Start a capture from the Trace tab to create sessions.</p>
|
||
</div>
|
||
</div>
|
||
<div class="sessions-list" id="sessions-list" style="display:none">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th data-l10n="Name">Name</th>
|
||
<th data-l10n="Date">Date</th>
|
||
<th data-l10n="Duration">Duration</th>
|
||
<th data-l10n="Messages">Messages</th>
|
||
<th data-l10n="Mode">Mode</th>
|
||
<th data-l10n="Actions">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Settings Panel -->
|
||
<div class="panel" id="panel-settings">
|
||
<div class="settings-view">
|
||
<label data-l10n="Server">Server</label>
|
||
<div class="settings-url-row">
|
||
<select id="server-source">
|
||
<option value="same" data-l10n="Same origin">Same origin</option>
|
||
<option value="custom" data-l10n="Custom URL">Custom URL</option>
|
||
</select>
|
||
<input type="text" id="server-url" placeholder="http://127.0.0.1:8081" style="display:none;">
|
||
<button id="connect-btn" data-l10n="Connect">Connect</button>
|
||
</div>
|
||
<div class="conn-status disconnected" id="conn-status">
|
||
⬤ Not connected — ensure the server is running on the URL above
|
||
</div>
|
||
<div class="capture-mode" id="capture-mode"></div>
|
||
<div class="version-warn" id="version-warn"></div>
|
||
<div class="conn-hint" id="conn-hint"></div>
|
||
<div class="conn-instructions" id="conn-instructions"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Delete confirmation modal -->
|
||
<div class="modal-overlay" id="delete-modal">
|
||
<div class="modal">
|
||
<h3 data-l10n="Delete Session">Delete Session</h3>
|
||
<p><span data-l10n="Delete">Delete</span> <strong id="delete-session-name"></strong> <span data-l10n="and all its APDU data? This cannot be undone.">and all its APDU data? This cannot be undone.</span></p>
|
||
<div class="modal-buttons">
|
||
<button class="cancel" id="delete-cancel" data-l10n="Cancel">Cancel</button>
|
||
<button class="confirm" id="delete-confirm" data-l10n="Delete">Delete</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Card-side change detail (Files view) -->
|
||
<div class="modal-overlay" id="file-diff-modal">
|
||
<div class="modal file-diff-modal">
|
||
<h3 data-l10n="Change details">Change details</h3>
|
||
<p id="file-diff-sub"></p>
|
||
<div class="file-diff-body" id="file-diff-body"></div>
|
||
<div class="modal-buttons">
|
||
<button class="cancel" id="file-diff-close" data-l10n="Close">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- COMPRION trace import dialog --><div class="modal-overlay" id="comprion-modal">
|
||
<div class="modal comprion-modal">
|
||
<h3 data-l10n="Import COMPRION trace">Import COMPRION trace</h3>
|
||
<p data-l10n="Select the ISO Protocol Layer export and, if available, the matching ISO Application Layer export of the same session.">Select the ISO Protocol Layer export and, if available, the matching ISO Application Layer export of the same session.</p>
|
||
<div class="comprion-slot">
|
||
<label data-l10n="ISO Protocol Layer export (required)">ISO Protocol Layer export (required)</label>
|
||
<button id="comprion-protocol-btn" data-l10n="Choose file…">Choose file…</button>
|
||
<span class="file-name" id="comprion-protocol-name"></span>
|
||
</div>
|
||
<div class="comprion-slot">
|
||
<label data-l10n="ISO Application Layer export (optional)">ISO Application Layer export (optional)</label>
|
||
<button id="comprion-application-btn" data-l10n="Choose file…">Choose file…</button>
|
||
<span class="file-name" id="comprion-application-name"></span>
|
||
</div>
|
||
<input type="file" id="comprion-protocol-file" accept=".txt,.tsv" style="display:none;">
|
||
<input type="file" id="comprion-application-file" accept=".txt,.tsv" style="display:none;">
|
||
<p class="comprion-hint" id="comprion-hint"></p>
|
||
<div class="comprion-error" id="comprion-error"></div>
|
||
<div class="modal-buttons">
|
||
<button class="cancel" id="comprion-cancel" data-l10n="Cancel">Cancel</button>
|
||
<button class="confirm" id="comprion-import" data-l10n="Import" disabled>Import</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ──────────────────── Service worker ────────────────────
|
||
if ('serviceWorker' in navigator) {
|
||
navigator.serviceWorker.register('sw.js').then(reg => {
|
||
console.log('SW registered, scope:', reg.scope);
|
||
}).catch(err => {
|
||
console.error('SW registration failed:', err);
|
||
});
|
||
}
|
||
|
||
// ──────────────────── State ────────────────────
|
||
const STATE = {
|
||
serverUrl: localStorage.getItem('serverUrl') || '',
|
||
activeTab: 'trace',
|
||
captureActive: false,
|
||
currentSessionId: null,
|
||
currentSessionName: null,
|
||
currentSessionRawName: null,
|
||
sessionViewId: null,
|
||
sessionStarted: null,
|
||
livePoller: null,
|
||
messages: [],
|
||
selectedIdx: null, // the focused APDU entry (persists across renders)
|
||
filter: new Set(), // command-filter entries (empty = all commands)
|
||
typeCounts: [],
|
||
traceMap: [],
|
||
sideView: 'map',
|
||
filesData: null,
|
||
filesSessionId: null,
|
||
filesImages: {},
|
||
pendingDeleteId: null,
|
||
serverAvailable: false,
|
||
serverName: '',
|
||
serverVersion: '',
|
||
captureMode: '',
|
||
deviceConnected: null,
|
||
mapExpanded: new Set(),
|
||
deepLinkHandled: false,
|
||
};
|
||
|
||
const API_BASE = () => STATE.serverUrl;
|
||
|
||
const INS_NAMES = {
|
||
'a4': 'SELECT', 'a0': 'SELECT', '70': 'MANAGE CHANNEL',
|
||
'b0': 'READ BINARY', 'b1': 'READ RECORD',
|
||
'd6': 'UPDATE BINARY', 'dc': 'UPDATE RECORD',
|
||
'c0': 'GET RESPONSE', 'c2': 'ENVELOPE',
|
||
'20': 'VERIFY', '21': 'VERIFY',
|
||
'88': 'AUTHENTICATE', '89': 'AUTHENTICATE',
|
||
'82': 'EXTERNAL AUTHENTICATE',
|
||
'84': 'GET CHALLENGE',
|
||
'f2': 'STATUS',
|
||
'14': 'TERMINAL RESPONSE',
|
||
'12': 'FETCH',
|
||
'e2': 'APPEND RECORD',
|
||
'32': 'INCREASE',
|
||
'04': 'DEACTIVATE FILE',
|
||
'44': 'ACTIVATE FILE',
|
||
};
|
||
|
||
// ──────────────────── UI Helpers ────────────────────
|
||
function line_event_label(msg) {
|
||
const d = msg.decoded;
|
||
if (msg.type === 'rst') return d && d.label ? d.label : 'RESET';
|
||
if (msg.type === 'vcc') return d && d.label ? d.label : 'VCC';
|
||
return null;
|
||
}
|
||
|
||
function ins_label(msg) {
|
||
if (msg.decoded && msg.decoded.cat_command) return msg.decoded.cat_command;
|
||
if (msg.decoded && msg.decoded.ins_name) return msg.decoded.ins_name;
|
||
if (msg.type === 'atr') return 'ATR';
|
||
if (msg.type === 'pps') return 'PPS';
|
||
if (msg.type === 'change') return 'STATE CHANGE';
|
||
if (msg.type === 'fidi') return 'FIDI';
|
||
if (msg.type === 'gap') return 'CAPTURE GAP';
|
||
const le = line_event_label(msg);
|
||
if (le) return le;
|
||
const d = msg.data;
|
||
if (d && d.length >= 2) {
|
||
const ins = d.slice(2, 4);
|
||
return INS_NAMES[ins.toLowerCase()] || '0x' + ins.toUpperCase();
|
||
}
|
||
return 'TPDU';
|
||
}
|
||
|
||
function _fallback_label(msg) {
|
||
if (msg.type === 'atr') return 'ATR';
|
||
if (msg.type === 'pps') return 'PPS';
|
||
if (msg.type === 'change') return 'STATE CHANGE';
|
||
if (msg.type === 'fidi') return 'FIDI';
|
||
if (msg.type === 'gap') return 'CAPTURE GAP';
|
||
const le = line_event_label(msg);
|
||
if (le) return le;
|
||
const data = msg.data;
|
||
if (data && data.length >= 2) {
|
||
const ins = data.slice(2, 4);
|
||
return INS_NAMES[ins.toLowerCase()] || '0x' + ins.toUpperCase();
|
||
}
|
||
return 'TPDU';
|
||
}
|
||
|
||
function stat_label(msg) {
|
||
const d = msg.decoded;
|
||
if (d) {
|
||
if (d.ins_name === 'ENVELOPE') return 'ENVELOPE';
|
||
if (d.cat_command) {
|
||
if (d.cat_command.startsWith('PROVIDE LOCAL INFORMATION')) return 'PROVIDE LOCAL INFORMATION';
|
||
return d.cat_command;
|
||
}
|
||
if (d.ins_name) return d.ins_name;
|
||
}
|
||
return _fallback_label(msg);
|
||
}
|
||
|
||
function filter_label(msg) {
|
||
const d = msg.decoded;
|
||
if (d) {
|
||
if (d.ins_name === 'ENVELOPE') return 'ENVELOPE';
|
||
// Every command keeps its own label (the filter is multi-select, so
|
||
// FETCH and TERMINAL RESPONSE are picked individually — or through
|
||
// the proactive preset's SW 91xx rule).
|
||
if (d.ins_name) return d.ins_name;
|
||
}
|
||
return _fallback_label(msg);
|
||
}
|
||
|
||
function fmt_elapsed(e) {
|
||
const s = Math.floor(e);
|
||
const ms = Math.round((e - s) * 1000);
|
||
return `${String(s).padStart(4, '0')}.${String(ms).padStart(3, '0')}`;
|
||
}
|
||
|
||
function fmt_absolute(e) {
|
||
// Absolute wall-clock time of a message: session/capture start
|
||
// (sessions.started, local time) + message elapsed offset.
|
||
if (!STATE.sessionStarted) return null;
|
||
const t = new Date(STATE.sessionStarted);
|
||
if (isNaN(t)) return null;
|
||
t.setMilliseconds(t.getMilliseconds() + Math.round(e * 1000));
|
||
const p = (n, w) => String(n).padStart(w || 2, '0');
|
||
return `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())} ` +
|
||
`${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}.${p(t.getMilliseconds(), 3)}`;
|
||
}
|
||
|
||
function detail_time_html(msg) {
|
||
const abs = fmt_absolute(msg.elapsed);
|
||
return abs ? `${fmt_elapsed(msg.elapsed)} · ${abs}` : fmt_elapsed(msg.elapsed);
|
||
}
|
||
|
||
// ──────────────────── SCP80 decryption (client-side only) ────────────────────
|
||
// Keys are entered per packet, memoized by key-set for the session, held
|
||
// in-memory only and never sent to the server.
|
||
const OTA_KEYS = {}; // key-set number -> key hex
|
||
const DECRYPT_CACHE = {}; // cipher_block hex -> {cntr,pcntr,rc,data,pad} | {error}
|
||
|
||
function hexToBytes(h) {
|
||
const o = [];
|
||
for (let i = 0; i + 1 < h.length; i += 2) o.push(parseInt(h.substr(i, 2), 16));
|
||
return o;
|
||
}
|
||
function bytesToHex(b) {
|
||
return (b || []).map(x => x.toString(16).padStart(2, '0')).join('').toUpperCase();
|
||
}
|
||
|
||
// ── DES / 3DES core (FIPS 46-3 tables, bit-array implementation) ──
|
||
const _PC1 = [57,49,41,33,25,17,9,1,58,50,42,34,26,18,10,2,59,51,43,35,27,19,11,3,60,52,44,36,63,55,47,39,31,23,15,7,62,54,46,38,30,22,14,6,61,53,45,37,29,21,13,5,28,20,12,4];
|
||
const _PC2 = [14,17,11,24,1,5,3,28,15,6,21,10,23,19,12,4,26,8,16,7,27,20,13,2,41,52,31,37,47,55,30,40,51,45,33,48,44,49,39,56,34,53,46,42,50,36,29,32];
|
||
const _SHIFTS = [1,1,2,2,2,2,2,2,1,2,2,2,2,2,2,1];
|
||
const _IP = [58,50,42,34,26,18,10,2,60,52,44,36,28,20,12,4,62,54,46,38,30,22,14,6,64,56,48,40,32,24,16,8,57,49,41,33,25,17,9,1,59,51,43,35,27,19,11,3,61,53,45,37,29,21,13,5,63,55,47,39,31,23,15,7];
|
||
const _FP = [40,8,48,16,56,24,64,32,39,7,47,15,55,23,63,31,38,6,46,14,54,22,62,30,37,5,45,13,53,21,61,29,36,4,44,12,52,20,60,28,35,3,43,11,51,19,59,27,34,2,42,10,50,18,58,26,33,1,41,9,49,17,57,25];
|
||
const _EXP = [32,1,2,3,4,5,4,5,6,7,8,9,8,9,10,11,12,13,12,13,14,15,16,17,16,17,18,19,20,21,20,21,22,23,24,25,24,25,26,27,28,29,28,29,30,31,32,1];
|
||
const _PP = [16,7,20,21,29,12,28,17,1,15,23,26,5,18,31,10,2,8,24,14,32,27,3,9,19,13,30,6,22,11,4,25];
|
||
const _SB = [
|
||
[14,4,13,1,2,15,11,8,3,10,6,12,5,9,0,7,0,15,7,4,14,2,13,1,10,6,12,11,9,5,3,8,4,1,14,8,13,6,2,11,15,12,9,7,3,10,5,0,15,12,8,2,4,9,1,7,5,11,3,14,10,0,6,13],
|
||
[15,1,8,14,6,11,3,4,9,7,2,13,12,0,5,10,3,13,4,7,15,2,8,14,12,0,1,10,6,9,11,5,0,14,7,11,10,4,13,1,5,8,12,6,9,3,2,15,13,8,10,1,3,15,4,2,11,6,7,12,0,5,14,9],
|
||
[10,0,9,14,6,3,15,5,1,13,12,7,11,4,2,8,13,7,0,9,3,4,6,10,2,8,5,14,12,11,15,1,13,6,4,9,8,15,3,0,11,1,2,12,5,10,14,7,1,10,13,0,6,9,8,7,4,15,14,3,11,5,2,12],
|
||
[7,13,14,3,0,6,9,10,1,2,8,5,11,12,4,15,13,8,11,5,6,15,0,3,4,7,2,12,1,10,14,9,10,6,9,0,12,11,7,13,15,1,3,14,5,2,8,4,3,15,0,6,10,1,13,8,9,4,5,11,12,7,2,14],
|
||
[2,12,4,1,7,10,11,6,8,5,3,15,13,0,14,9,14,11,2,12,4,7,13,1,5,0,15,10,3,9,8,6,4,2,1,11,10,13,7,8,15,9,12,5,6,3,0,14,11,8,12,7,1,14,2,13,6,15,0,9,10,4,5,3],
|
||
[12,1,10,15,9,2,6,8,0,13,3,4,14,7,5,11,10,15,4,2,7,12,9,5,6,1,13,14,0,11,3,8,9,14,15,5,2,8,12,3,7,0,4,10,1,13,11,6,4,3,2,12,9,5,15,10,11,14,1,7,6,0,8,13],
|
||
[4,11,2,14,15,0,8,13,3,12,9,7,5,10,6,1,13,0,11,7,4,9,1,10,14,3,5,12,2,15,8,6,1,4,11,13,12,3,7,14,10,15,6,8,0,5,9,2,6,11,13,8,1,4,10,7,9,5,0,15,14,2,3,12],
|
||
[13,2,8,4,6,15,11,1,10,9,3,14,5,0,12,7,1,15,13,8,10,3,7,4,12,5,6,11,0,14,9,2,7,11,4,1,9,12,14,2,0,6,10,13,15,3,5,8,2,1,14,7,4,10,8,13,15,12,9,0,3,5,6,11],
|
||
];
|
||
|
||
function _permute(tab, bits) {
|
||
const out = new Array(tab.length);
|
||
for (let i = 0; i < tab.length; i++) out[i] = bits[tab[i] - 1];
|
||
return out;
|
||
}
|
||
function _xorArr(a, b) {
|
||
const o = new Array(a.length);
|
||
for (let i = 0; i < a.length; i++) o[i] = a[i] ^ b[i];
|
||
return o;
|
||
}
|
||
function _rol28(bits, n) {
|
||
const o = new Array(28);
|
||
for (let i = 0; i < 28; i++) o[i] = bits[(i + n) % 28];
|
||
return o;
|
||
}
|
||
function _bytesToBits(bytes) {
|
||
const bits = [];
|
||
for (const b of bytes) for (let j = 7; j >= 0; j--) bits.push((b >> j) & 1);
|
||
return bits;
|
||
}
|
||
function _bitsToBytes(bits) {
|
||
const out = [];
|
||
for (let i = 0; i + 8 <= bits.length; i += 8) {
|
||
let b = 0;
|
||
for (let j = 0; j < 8; j++) b = (b << 1) | bits[i + j];
|
||
out.push(b);
|
||
}
|
||
return out;
|
||
}
|
||
function _desKeySchedule(keyBytes) {
|
||
const k = _permute(_PC1, _bytesToBits(keyBytes));
|
||
let c = k.slice(0, 28), d = k.slice(28);
|
||
const subkeys = [];
|
||
for (let r = 0; r < 16; r++) {
|
||
c = _rol28(c, _SHIFTS[r]);
|
||
d = _rol28(d, _SHIFTS[r]);
|
||
subkeys.push(_permute(_PC2, c.concat(d)));
|
||
}
|
||
return subkeys;
|
||
}
|
||
function _desF(R, sk) {
|
||
const x = _xorArr(_permute(_EXP, R), sk);
|
||
const sOut = [];
|
||
for (let i = 0; i < 8; i++) {
|
||
const b = x.slice(i * 6, i * 6 + 6);
|
||
const row = (b[0] << 1) | b[5];
|
||
const col = (b[1] << 3) | (b[2] << 2) | (b[3] << 1) | b[4];
|
||
const v = _SB[i][row * 16 + col];
|
||
sOut.push((v >> 3) & 1, (v >> 2) & 1, (v >> 1) & 1, v & 1);
|
||
}
|
||
return _permute(_PP, sOut);
|
||
}
|
||
function _desBlock(bits, subkeys, decrypt) {
|
||
const b = _permute(_IP, bits);
|
||
let L = b.slice(0, 32), R = b.slice(32);
|
||
for (let r = 0; r < 16; r++) {
|
||
const sk = decrypt ? subkeys[15 - r] : subkeys[r];
|
||
const newR = _xorArr(L, _desF(R, sk));
|
||
L = R;
|
||
R = newR;
|
||
}
|
||
return _permute(_FP, R.concat(L));
|
||
}
|
||
// 3DES-CBC decrypt — keyBytes: 16 (K3=K1) or 24 octets; ivBytes: 8 zero octets.
|
||
function des3CbcDecrypt(keyBytes, ivBytes, ctBytes) {
|
||
const s1 = _desKeySchedule(keyBytes.slice(0, 8));
|
||
const s2 = _desKeySchedule(keyBytes.slice(8, 16));
|
||
const s3 = _desKeySchedule(keyBytes.length >= 24 ? keyBytes.slice(16, 24) : keyBytes.slice(0, 8));
|
||
let prev = ivBytes.slice();
|
||
const out = [];
|
||
for (let i = 0; i + 8 <= ctBytes.length; i += 8) {
|
||
const block = _bytesToBits(ctBytes.slice(i, i + 8));
|
||
let b = _desBlock(block, s3, true); // D_K3
|
||
b = _desBlock(b, s2, false); // E_K2
|
||
b = _desBlock(b, s1, true); // D_K1
|
||
out.push(..._bitsToBytes(_xorArr(b, _bytesToBits(prev))));
|
||
prev = ctBytes.slice(i, i + 8);
|
||
}
|
||
return out;
|
||
}
|
||
// Single DES-CBC decrypt (deprecated KIc coding '01').
|
||
function desCbcDecrypt(keyBytes, ivBytes, ctBytes) {
|
||
const s = _desKeySchedule(keyBytes.slice(0, 8));
|
||
let prev = ivBytes.slice();
|
||
const out = [];
|
||
for (let i = 0; i + 8 <= ctBytes.length; i += 8) {
|
||
const b = _desBlock(_bytesToBits(ctBytes.slice(i, i + 8)), s, true);
|
||
out.push(..._bitsToBytes(_xorArr(b, _bytesToBits(prev))));
|
||
prev = ctBytes.slice(i, i + 8);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ──────────────────── C-APDU parsing (vendored from SIMple) ────────────────────
|
||
// Parses C-APDU hex streams (e.g. SCP80 secured-packet application messages)
|
||
// into {label, hex, desc, children} trees.
|
||
const PARSE_INS = {
|
||
'A4': {name:'SELECT', data:'lc'},
|
||
'B0': {name:'READ BINARY', data:'le'},
|
||
'B2': {name:'READ RECORD(S)', data:'le'},
|
||
'DC': {name:'UPDATE RECORD', data:'lc'},
|
||
'D6': {name:'UPDATE BINARY', data:'lc'},
|
||
'0E': {name:'ERASE BINARY', data:'none'},
|
||
'44': {name:'ACTIVATE FILE', data:'none'},
|
||
'04': {name:'DEACTIVATE FILE', data:'none'},
|
||
'20': {name:'VERIFY', data:'lc'},
|
||
'24': {name:'CHANGE', data:'lc'},
|
||
'26': {name:'DISABLE PIN', data:'lc'},
|
||
'28': {name:'ENABLE PIN', data:'lc'},
|
||
'2C': {name:'UNBLOCK PIN', data:'lc'},
|
||
'E6': {name:'INSTALL', data:'lc'},
|
||
'E8': {name:'LOAD', data:'lc'},
|
||
'D4': {name:'RESIZE FILE', data:'lc'},
|
||
'E0': {name:'CREATE FILE', data:'lc'},
|
||
'E4': {name:'DELETE', data:'lc'},
|
||
'F2': {name:'GET STATUS', data:'lc'},
|
||
'CA': {name:'GET DATA', data:'none'},
|
||
'DA': {name:'PUT DATA', data:'lc'},
|
||
'CB': {name:'RETRIEVE DATA', data:'le'},
|
||
'DB': {name:'SET DATA', data:'lc'},
|
||
'A2': {name:'SEARCH RECORD', data:'lc'},
|
||
'32': {name:'INCREASE', data:'lc'},
|
||
'84': {name:'GET CHALLENGE', data:'le'},
|
||
'88': {name:'AUTHENTICATE', data:'lc'},
|
||
'89': {name:'AUTHENTICATE (odd INS)', data:'lc'},
|
||
'E2': {name:'STORE DATA', data:'lc'},
|
||
'F0': {name:'SET STATUS', data:'lc'},
|
||
'82': {name:'EXTERNAL AUTHENTICATE', data:'lc'},
|
||
'C0': {name:'GET RESPONSE', data:'le'},
|
||
'10': {name:'TERMINAL PROFILE', data:'lc'},
|
||
'C2': {name:'ENVELOPE', data:'lc'},
|
||
'12': {name:'FETCH', data:'le'},
|
||
'14': {name:'TERMINAL RESPONSE', data:'lc'},
|
||
'76': {name:'SUSPEND UICC', data:'lc'},
|
||
'AA': {name:'TERMINAL CAPABILITY', data:'lc'},
|
||
'73': {name:'MANAGE SECURE CHANNEL', data:'lc'},
|
||
'75': {name:'TRANSACT DATA', data:'lc'},
|
||
'78': {name:'GET IDENTITY', data:'le'},
|
||
'7A': {name:'EXCHANGE CAPABILITIES', data:'lc'},
|
||
'7C': {name:'MANAGE LSI', data:'lc'},
|
||
'50': {name:'INITIALIZE UPDATE', data:'lc'},
|
||
'D8': {name:'PUT KEY', data:'lc'},
|
||
'70': {name:'MANAGE CHANNEL', data:'none'},
|
||
};
|
||
|
||
function describeP1P2(ins, p1, p2) {
|
||
const v1 = parseInt(p1, 16);
|
||
const v2 = parseInt(p2, 16);
|
||
let d1 = '', d2 = '';
|
||
if (ins === 'A4') {
|
||
d1 = {'00':'by FID','01':'child DF','03':'parent DF','04':'by DF name (AID)','08':'path from MF','09':'path from current DF'}[p1] || '';
|
||
const resp = (v2 >> 2) & 0x07;
|
||
if (resp === 0x01) d2 = 'return FCP template';
|
||
else if (resp === 0x03) d2 = 'no response data';
|
||
if (v1 === 0x04) {
|
||
const occName = {'00':'first or only occurrence','01':'last occurrence','10':'next occurrence','11':'previous occurrence'}[(v2 & 0x03).toString(2).padStart(2,'0')];
|
||
if (occName) d2 += (d2 ? ', ' : '') + occName;
|
||
}
|
||
} else if (ins === 'B0' || ins === 'D6') {
|
||
if (v1 & 0x80) d1 = 'SFI mode, SFI ' + (v1 & 0x1F);
|
||
else d1 = 'offset high';
|
||
d2 = 'offset low';
|
||
} else if (ins === 'B2' || ins === 'DC') {
|
||
const modeKey = (v2 & 0x07).toString(16).padStart(2, '0');
|
||
d2 = {'02':'next record','03':'previous record','04':'absolute/current'}[modeKey] || '';
|
||
const sfi = (v2 >> 3) & 0x1F;
|
||
if (sfi) d2 += (d2 ? ', ' : '') + 'SFI ' + sfi;
|
||
if ((v2 & 0x07) === 0x02 || (v2 & 0x07) === 0x03) d1 = v1 === 0 ? 'ignored' : 'record # (ignored)';
|
||
else if (v1 > 0) d1 = 'record #' + v1;
|
||
} else if (ins === 'F2') {
|
||
d1 = {'80':'ISD','40':'Applications incl. SSDs','20':'Executable Load Files','10':'ELFs and Modules'}[p1] || '';
|
||
d2 = {'02':'first/all, TLV format','03':'next, TLV format','00':'first/all, deprecated raw','01':'next, deprecated raw'}[p2] || '';
|
||
} else if (ins === 'E6') {
|
||
const roles = [];
|
||
if (v1 & 0x02) roles.push('for load');
|
||
if (v1 & 0x04) roles.push('for install');
|
||
if (v1 & 0x08) roles.push('for make selectable');
|
||
if (v1 & 0x10) roles.push('for extradition');
|
||
if (v1 & 0x20) roles.push('for personalization');
|
||
if (v1 & 0x40) roles.push('for registry update');
|
||
if (roles.length) d1 = roles.join(' + ');
|
||
if (v1 & 0x80) d1 = 'more INSTALL commands follow; ' + d1;
|
||
} else if (ins === 'F0') {
|
||
d1 = {'80':'ISD','40':'Application or SSD','60':'SD + associated apps'}[p1] || '';
|
||
if (p1 === '80') {
|
||
d2 = {'01':'OP_READY','07':'INITIALIZED','0F':'SECURED','7F':'CARD_LOCKED','FF':'TERMINATED'}[p2] || '';
|
||
} else {
|
||
d2 = {'00':'unlock (previous state)','80':'LOCKED'}[p2] || '';
|
||
}
|
||
} else if (ins === '20' || ins === '24') {
|
||
if (v2 >= 1 && v2 <= 8) d2 = 'PIN ref ' + v2;
|
||
}
|
||
return {d1, d2};
|
||
}
|
||
|
||
function describeInstallDataLv(p1val, data) {
|
||
let fields;
|
||
if (p1val & 0x40) {
|
||
fields = ['SD AID', '(reserved)', 'Application AID', 'Privileges', 'RU parameters', 'RU token'];
|
||
} else if (p1val & 0x20) {
|
||
fields = ['(reserved)', '(reserved)', 'Application AID', 'Privileges', 'Personalization parameters', 'Personalization token'];
|
||
} else if (p1val & 0x10) {
|
||
fields = ['Target SD AID', '(reserved)', 'Application/ELF AID', '(reserved)', 'Extradition parameters', 'Extradition token'];
|
||
} else if ((p1val & 0x08) && !(p1val & 0x04)) {
|
||
fields = ['(reserved)', '(reserved)', 'Application AID', 'Privileges', 'MS parameters', 'MS token'];
|
||
} else if (p1val & 0x04) {
|
||
fields = ['ELF AID', 'Module AID', 'Application AID', 'Privileges', 'Install parameters', 'Install token'];
|
||
} else if (p1val & 0x02) {
|
||
fields = ['Load File AID', 'SD AID', 'LFDB hash', 'Load parameters', 'Load token'];
|
||
} else {
|
||
return null;
|
||
}
|
||
const nodes = [];
|
||
let i = 0;
|
||
for (const name of fields) {
|
||
if (i >= data.length) return null;
|
||
const f = readLvField(data, i);
|
||
if (!f) return null;
|
||
const node = {label: name, hex: f.lenHex + f.value, desc:''};
|
||
if (name === 'Privileges') node.desc = decodePrivileges(f.value);
|
||
else if (name.endsWith('parameters')) {
|
||
const walked = walkInstallParams(f.value);
|
||
node.children = walked || [{label:'Value', hex:f.value, desc:f.value}];
|
||
node.desc = f.value ? '' : '(empty)';
|
||
} else if (f.value) {
|
||
node.desc = f.value;
|
||
} else {
|
||
node.desc = '(empty)';
|
||
}
|
||
nodes.push(node);
|
||
i += f.consumed;
|
||
}
|
||
if (i !== data.length) return null;
|
||
return nodes;
|
||
}
|
||
|
||
function parseOneApdu(hex) {
|
||
hex = (hex || '').toUpperCase();
|
||
const isActivateDeactivate = hex.length >= 4 && (hex.substr(2, 2) === '44' || hex.substr(2, 2) === '04');
|
||
if (hex.length < 10 && !(isActivateDeactivate && hex.length === 8)) return null;
|
||
const cla = hex.substr(0, 2);
|
||
const ins = hex.substr(2, 2);
|
||
const p1 = hex.substr(4, 2);
|
||
const p2 = hex.substr(6, 2);
|
||
const p3 = hex.substr(8, 2);
|
||
const insInfo = PARSE_INS[ins];
|
||
const labels = describeP1P2(ins, p1, p2);
|
||
const children = [
|
||
{label:'CLA', hex:cla, desc:cla === '80' ? 'GP/RAM' : cla === 'A0' ? 'SIM' : parseInt(cla,16) >= 0x84 && parseInt(cla,16) <= 0x87 ? 'GlobalPlatform (secure messaging)' : 'UICC'},
|
||
{label:'INS', hex:ins, desc:insInfo ? insInfo.name : 'Unknown'},
|
||
{label:'P1', hex:p1, desc:labels.d1},
|
||
{label:'P2', hex:p2, desc:labels.d2},
|
||
];
|
||
if (isActivateDeactivate && hex.length === 8) {
|
||
return {label:'APDU (case 1)', hex:hex, desc:'no data, no Le', children};
|
||
}
|
||
if (ins === 'CA') {
|
||
if (hex.length === 10) {
|
||
children.push({label:'Le', hex:p3, desc:'Expected length: ' + parseInt(p3, 16)});
|
||
return {label:'APDU', hex:hex, desc:'', children};
|
||
}
|
||
const lc = parseInt(p3, 16);
|
||
children.push({label:'Lc', hex:p3, desc:'Data length: ' + lc});
|
||
const data = hex.substr(10, lc * 2);
|
||
children.push({label:'Data', hex:data, desc:data.startsWith('5C') && data.length >= 4 ? 'Tag list: ' + data : data});
|
||
const restLen = hex.length - (10 + lc * 2);
|
||
if (restLen >= 2) children.push({label:'Le', hex:hex.substr(10 + lc * 2, 2), desc:'Expected length: ' + parseInt(hex.substr(10 + lc * 2, 2), 16)});
|
||
return {label:'APDU', hex:hex, desc:'', children};
|
||
}
|
||
if (insInfo && insInfo.data === 'le') {
|
||
children.push({label:'Le', hex:p3, desc:'Expected length: ' + parseInt(p3, 16)});
|
||
} else if (isActivateDeactivate && hex.length >= 10) {
|
||
if (parseInt(p3, 16) === 0 && hex.length === 10) {
|
||
children.push({label:'P3', hex:p3, desc:'empty Lc (legacy form)'});
|
||
} else {
|
||
const alc = parseInt(p3, 16);
|
||
children.push({label:'Lc', hex:p3, desc:'Data length: ' + alc});
|
||
if (alc > 0 && hex.length >= 10 + alc * 2) {
|
||
const adata = hex.substr(10, alc * 2);
|
||
const adesc = adata.length === 4 ? 'FID ' + adata : (adata.length > 4 && adata.length % 2 === 0 ? 'Path ' + adata : adata);
|
||
children.push({label:'Data', hex:adata, desc:adesc});
|
||
}
|
||
}
|
||
} else if (insInfo && insInfo.data === 'none') {
|
||
children.push({label:'P3', hex:p3, desc:'No data'});
|
||
} else {
|
||
const lc = parseInt(p3, 16);
|
||
children.push({label:'Lc', hex:p3, desc:'Data length: ' + lc});
|
||
if (lc > 0 && hex.length >= 10 + lc * 2) {
|
||
const data = hex.substr(10, lc * 2);
|
||
let dataDesc = data;
|
||
let dataChildren = null;
|
||
if (ins === 'E6') {
|
||
const lv = describeInstallDataLv(parseInt(p1, 16), data);
|
||
if (lv) { dataDesc = ''; dataChildren = lv; }
|
||
else dataDesc = data;
|
||
} else if (ins === 'F0') {
|
||
if (p1 === '80') dataDesc = 'ignored for ISD';
|
||
else if (data.startsWith('4F') && data.length >= 4 && 2 + 2 + parseInt(data.substr(2, 2), 16) * 2 === data.length) dataDesc = 'Legacy 4F-TLV AID: ' + data.substr(4);
|
||
else dataDesc = 'AID (raw): ' + data;
|
||
}
|
||
const node = {label:'Data', hex:data, desc:dataDesc};
|
||
if (dataChildren) node.children = dataChildren;
|
||
children.push(node);
|
||
if (hex.length > 10 + lc * 2) {
|
||
const le = hex.substr(10 + lc * 2, 2);
|
||
children.push({label:'Le', hex:le, desc:'Expected response length: ' + parseInt(le, 16)});
|
||
}
|
||
}
|
||
}
|
||
const totalLen = hex.length;
|
||
return {label:'APDU', hex:hex.substr(0, totalLen), desc:'', children};
|
||
}
|
||
|
||
function parseCompactApdus(hex) {
|
||
hex = (hex || '').toUpperCase();
|
||
const apdus = [];
|
||
let i = 0;
|
||
let prevCla = '';
|
||
while (i + 4 <= hex.length) {
|
||
const first = hex.substr(i, 2);
|
||
const second = hex.substr(i + 2, 2);
|
||
let fullCla = '';
|
||
let fullIns = '';
|
||
let consumed = 0;
|
||
const fb = parseInt(first, 16);
|
||
const knownCla = first === '80' || first === 'A0' || first === '00' || (fb >= 0x84 && fb <= 0x87);
|
||
if (knownCla && PARSE_INS[second]) {
|
||
fullCla = first;
|
||
fullIns = second;
|
||
consumed = 2;
|
||
} else if (PARSE_INS[first] && prevCla && i > 0) {
|
||
fullCla = prevCla;
|
||
fullIns = first;
|
||
consumed = 0;
|
||
}
|
||
if (fullCla) {
|
||
const p3Byte = parseInt(hex.substr(i + 6 + consumed, 2), 16);
|
||
const dataType = PARSE_INS[fullIns].data;
|
||
let apduLen = 8;
|
||
if (fullIns === 'CA') {
|
||
const remAvail = hex.length - (i + consumed);
|
||
if (p3Byte > 0 && remAvail >= p3Byte * 2 + 2) apduLen += p3Byte * 2 + 2;
|
||
} else if (fullIns === '44' || fullIns === '04') {
|
||
const avail = hex.length - i;
|
||
const p3Hex = avail >= consumed + 8 ? hex.substr(i + 6 + consumed, 2) : null;
|
||
if (p3Hex !== null) {
|
||
if (p3Byte > 0) apduLen += p3Byte * 2;
|
||
} else if (avail >= consumed + 6) {
|
||
apduLen -= 2;
|
||
}
|
||
} else if (dataType === 'lc') {
|
||
apduLen += p3Byte * 2;
|
||
}
|
||
if (i + consumed + apduLen <= hex.length) {
|
||
const apduHex = fullCla + hex.substr(i + consumed, apduLen);
|
||
const parsed = parseOneApdu(apduHex);
|
||
if (parsed) {
|
||
prevCla = fullCla;
|
||
parsed.hex = hex.substr(i, consumed + apduLen);
|
||
parsed.label = consumed === 0 ? 'APDU (implied CLA)' : 'APDU';
|
||
apdus.push(parsed);
|
||
i += consumed + apduLen;
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
apdus.push({label:'Unknown', hex:hex.substr(i, 2), desc:'Unrecognized'});
|
||
i += 2;
|
||
}
|
||
if (i < hex.length) {
|
||
if (apdus.length > 0 && hex.length - i === 2) {
|
||
const leHex = hex.substr(i);
|
||
apdus.push({label:'Le', hex:leHex, desc:'Expected response length: ' + parseInt(leHex, 16)});
|
||
} else {
|
||
apdus.push({label:'Trailing (unrecognized)', hex:hex.substr(i), desc:hex.substr(i)});
|
||
}
|
||
}
|
||
return apdus;
|
||
}
|
||
|
||
// Decrypt a ciphered secured packet block with the user-supplied key hex.
|
||
// Returns {cntr, pcntr, rc, data, pad} byte arrays; throws on bad key/size.
|
||
async function decryptSecured(sec, keyHex) {
|
||
const nibble = (sec.kic_raw !== undefined ? sec.kic_raw : 0) & 0x0F;
|
||
const ct = hexToBytes(sec.cipher_block);
|
||
const iv = new Array(8).fill(0); // TS 102 225: CBC initial chaining value = zero
|
||
let pt;
|
||
if (nibble === 0x02) { // AES-CBC
|
||
const key = hexToBytes(keyHex);
|
||
if (![16, 24, 32].includes(key.length)) throw new Error('AES key must be 16/24/32 bytes');
|
||
if (ct.length % 16) throw new Error('cipher block not AES-aligned');
|
||
// WebCrypto CBC decrypt always validates PKCS#7, which SCP80
|
||
// padding (PCNTR) violates. Trick: append a synthetic block
|
||
// C* = E(C_last XOR padBlock) — decrypting it yields a valid
|
||
// full-block PKCS padding (16 × 0x10), so validation passes and
|
||
// the real plaintext is recovered; the synthetic block is discarded.
|
||
const k = await crypto.subtle.importKey('raw', new Uint8Array(key), 'AES-CBC', false, ['encrypt', 'decrypt']);
|
||
const padBlock = new Array(16).fill(0x10);
|
||
const last = ct.slice(ct.length - 16);
|
||
const target = _xorArr(last, padBlock);
|
||
const eRaw = await crypto.subtle.encrypt(
|
||
{name: 'AES-CBC', iv: new Uint8Array(16)}, k, new Uint8Array(target));
|
||
const cStar = Array.from(new Uint8Array(eRaw)).slice(0, 16);
|
||
const buf = await crypto.subtle.decrypt(
|
||
{name: 'AES-CBC', iv: new Uint8Array(16)}, k,
|
||
new Uint8Array(ct.concat(cStar)));
|
||
pt = Array.from(new Uint8Array(buf)).slice(0, ct.length);
|
||
} else if (nibble === 0x05 || nibble === 0x09) { // 3DES-CBC 2/3 keys
|
||
const key = hexToBytes(keyHex);
|
||
if (nibble === 0x05 && key.length !== 16 && key.length !== 24)
|
||
throw new Error('3DES (2 keys) key must be 16 bytes (or 24 with K3=K1)');
|
||
if (nibble === 0x09 && key.length !== 24)
|
||
throw new Error('3DES (3 keys) key must be 24 bytes');
|
||
pt = des3CbcDecrypt(key, iv, ct);
|
||
} else if (nibble === 0x01) { // single DES (deprecated)
|
||
const key = hexToBytes(keyHex);
|
||
if (key.length !== 8) throw new Error('DES key must be 8 bytes');
|
||
pt = desCbcDecrypt(key, iv, ct);
|
||
} else {
|
||
throw new Error('cipher algorithm not supported for decryption');
|
||
}
|
||
const rcLen = sec.rc_cc_ds ? sec.rc_cc_ds.length / 2 : 0;
|
||
if (pt.length < 6 + rcLen) throw new Error('decrypted block too short');
|
||
const cntr = pt.slice(0, 5);
|
||
const pcntr = pt[5];
|
||
const rc = pt.slice(6, 6 + rcLen);
|
||
const dataAll = pt.slice(6 + rcLen);
|
||
if (pcntr > dataAll.length) throw new Error('invalid padding count — wrong key?');
|
||
const data = dataAll.slice(0, dataAll.length - pcntr);
|
||
return {cntr, pcntr, rc, data, pad: pcntr};
|
||
}
|
||
|
||
function fmt_data(hex, limit) {
|
||
limit = limit || 32;
|
||
const pairs = (hex || '').match(/.{1,2}/g) || [];
|
||
const shown = pairs.slice(0, limit);
|
||
let s = shown.join(' ');
|
||
if (pairs.length > limit) s += ' ...';
|
||
return s.toUpperCase();
|
||
}
|
||
|
||
function hx(n, len) {
|
||
len = len || 2;
|
||
return n.toString(16).toUpperCase().padStart(len, '0');
|
||
}
|
||
|
||
function fmtSize(bytes) {
|
||
if (bytes == null) return '?';
|
||
if (bytes < 1024) return bytes + ' B';
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||
return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GB';
|
||
}
|
||
|
||
function data_text(m) {
|
||
if (m.type === 'change' && m.decoded && Array.isArray(m.decoded.flags)) {
|
||
return m.decoded.flags.join(', ');
|
||
}
|
||
return fmt_data(m.data);
|
||
}
|
||
|
||
function cmd_description(m) {
|
||
if (m.type === 'gap') return t('device disconnected — messages may be missing');
|
||
const d = m.decoded;
|
||
// No raw-hex fallback for these: STATUS's decoded summary is the only
|
||
// useful list text (it has no command body), and ATR/PPS/RST/VCC get a
|
||
// decoded summary (or nothing) instead of opaque bytes like "010100".
|
||
if (d && (d.ins_hex === 'f2' || d.type === 'atr' || d.type === 'pps'
|
||
|| d.type === 'rst' || d.type === 'vcc')) {
|
||
return d.summary || '';
|
||
}
|
||
if (d && d.summary) return d.summary;
|
||
return data_text(m);
|
||
}
|
||
|
||
function error_names(m) {
|
||
const d = m.decoded;
|
||
if (!d) return null;
|
||
const errs = [];
|
||
if (Array.isArray(d.errors)) errs.push(...d.errors);
|
||
if (d.length_mismatch) errs.push('Data length mismatch');
|
||
return errs.length ? errs : null;
|
||
}
|
||
|
||
function sw_text(m) {
|
||
const d = m.decoded;
|
||
if (!d || !d.sw) return '';
|
||
return (d.sw.sw1 + ' ' + d.sw.sw2).toUpperCase();
|
||
}
|
||
|
||
function sw_class(m) {
|
||
const d = m.decoded;
|
||
if (!d || !d.sw) return '';
|
||
const s = d.sw.sw1;
|
||
if (s === '90' || (s >= '92' && s <= '9f')) return 'sw-ok';
|
||
if (s === '61' || s === '91' || s === '6c') return 'sw-pending';
|
||
if (s === '62' || s === '63') return 'sw-warn';
|
||
if (s >= '64' && s <= '6f') return 'sw-error';
|
||
return '';
|
||
}
|
||
|
||
// ──────────────────── API ────────────────────
|
||
const API = {
|
||
async _fetch(method, path, body) {
|
||
const opts = { method, headers: {} };
|
||
if (body) {
|
||
opts.headers['Content-Type'] = 'application/json';
|
||
opts.body = JSON.stringify(body);
|
||
}
|
||
let res;
|
||
try {
|
||
res = await fetch(API_BASE() + path, opts);
|
||
} catch (e) {
|
||
// The server is unreachable (network error) — that *is* a
|
||
// disconnected state.
|
||
STATE.serverAvailable = false;
|
||
updateCaptureButton();
|
||
throw e;
|
||
}
|
||
let data = {};
|
||
try {
|
||
data = await res.json();
|
||
} catch (e) { /* non-JSON body */ }
|
||
if (!res.ok) {
|
||
// The server answers errors as {"error": …}; surface them instead
|
||
// of letting callers treat the error body as data.
|
||
throw Object.assign(
|
||
new Error((data && data.error) || `HTTP ${res.status}`),
|
||
{ status: res.status, code: data && data.code });
|
||
}
|
||
return data;
|
||
},
|
||
status() { return this._fetch('GET', '/api/status'); },
|
||
listSessions() { return this._fetch('GET', '/api/sessions'); },
|
||
getSession(id, offset) { return this._fetch('GET', `/api/sessions/${id}?offset=${offset || 0}&limit=500`); },
|
||
renameSession(id, name) { return this._fetch('PATCH', `/api/sessions/${id}/name`, { name }); },
|
||
deleteSession(id) { return this._fetch('DELETE', `/api/sessions/${id}`); },
|
||
startCapture() { return this._fetch('POST', '/api/capture/start'); },
|
||
stopCapture() { return this._fetch('POST', '/api/capture/stop'); },
|
||
getLatest(after) { return this._fetch('GET', `/api/capture/latest?after=${after}`); },
|
||
getSessionFiles(id) { return this._fetch('GET', `/api/sessions/${id}/files`); },
|
||
getSessionFileImage(id, key, change) {
|
||
return this._fetch('GET', `/api/sessions/${id}/files/image?key=` +
|
||
encodeURIComponent(key) + `&change=${change}`);
|
||
},
|
||
searchMessages(sessionId, query) { return this._fetch('GET', `/api/apdu/search?session_id=${sessionId}&q=${encodeURIComponent(query)}`); },
|
||
filterMessages(sessionId, type) { return this._fetch('GET', `/api/apdu/filter?session_id=${sessionId}&type=${type}`); },
|
||
async _importPost(params, body, contentType) {
|
||
const res = await fetch(API_BASE() + '/api/sessions/import?' + params, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': contentType },
|
||
body: body,
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) throw Object.assign(new Error(data.error || 'Import failed'),
|
||
{ status: res.status, code: data.code });
|
||
return data;
|
||
},
|
||
async importSession(name, bytes, format) {
|
||
const params = 'name=' + encodeURIComponent(name) +
|
||
(format ? '&format=' + encodeURIComponent(format) : '');
|
||
return this._importPost(params, bytes, 'application/octet-stream');
|
||
},
|
||
async importComprionBundle(name, text, timesText) {
|
||
const params = 'name=' + encodeURIComponent(name) + '&format=comprion';
|
||
return this._importPost(params,
|
||
JSON.stringify({ text: text, times_text: timesText }),
|
||
'application/json');
|
||
},
|
||
};
|
||
|
||
// ──────────────────── UI Helpers ────────────────────
|
||
function el(id) { return document.getElementById(id); }
|
||
function $on(id, ev, fn) { el(id).addEventListener(ev, fn); }
|
||
|
||
function tpRows(tp) {
|
||
if (!tp) return '';
|
||
const rows = [];
|
||
if (tp.device_class === 'modem') {
|
||
rows.push(['Device class', 'headless/modem — ' + (tp.class_reason || '')]);
|
||
} else if (tp.device_class === 'handset') {
|
||
rows.push(['Device class', 'handset — ' + (tp.class_reason || '')]);
|
||
} else {
|
||
rows.push(['Device class', 'not determined']);
|
||
}
|
||
const gen = [];
|
||
if (tp.hsdpa) gen.push('HSDPA (3G)');
|
||
if (tp.eutran) gen.push('E-UTRAN (4G)');
|
||
rows.push(['Radio', gen.length
|
||
? 'declared: ' + gen.join(', ')
|
||
: 'no 3G/4G capability declared (under-declaration is common)']);
|
||
if (tp.utran_ps) rows.push(['UTRAN PS ext.', 'declared']);
|
||
if (tp.bip === true) {
|
||
let v = 'capable';
|
||
if (tp.bip_channels != null) {
|
||
v += ' — ' + tp.bip_channels + ' channel' + (tp.bip_channels === 1 ? '' : 's');
|
||
}
|
||
if (tp.bip_bearers && tp.bip_bearers.length) v += ' · ' + tp.bip_bearers.join(', ');
|
||
rows.push(['BIP', v]);
|
||
if (tp.bip_transports && tp.bip_transports.length) {
|
||
rows.push(['BIP transports', tp.bip_transports.join(', ')]);
|
||
}
|
||
} else if (tp.bip === false) {
|
||
rows.push(['BIP', 'not advertised (no OPEN CHANNEL)']);
|
||
}
|
||
if (tp.standards) rows.push(['Standards', tp.standards]);
|
||
const ui = [];
|
||
if (tp.soft_keys != null) ui.push('soft keys ' + tp.soft_keys);
|
||
if (tp.screen_h != null || tp.screen_w != null) {
|
||
ui.push('screen ' + (tp.screen_h || 0) + '×' + (tp.screen_w || 0) + ' chars');
|
||
}
|
||
if (tp.markup && tp.markup.length) ui.push('markup ' + tp.markup.join('/'));
|
||
if (ui.length) rows.push(['UI', ui.join(' · ')]);
|
||
rows.push(['Profile', tp.length + ' bytes — ' + (tp.era || '')]);
|
||
return `<tr><td class="label" colspan="2" style="padding-top:6px; border-top:1px solid var(--border);">Terminal profile</td></tr>` +
|
||
rows.map(([l, v]) => `<tr><td class="label">${l}</td><td class="value">${v}</td></tr>`).join('');
|
||
}
|
||
|
||
function responseRows(resp) {
|
||
if (!resp) return '';
|
||
const rows = [];
|
||
if (resp.qualifier) rows.push(['Qualifier', resp.qualifier]);
|
||
const fd = resp.file_descriptor;
|
||
if (fd) {
|
||
rows.push(['File type', fd.file_type]);
|
||
if (fd.structure) rows.push(['Structure', fd.structure]);
|
||
rows.push(['Shareable', fd.shareable]);
|
||
if (fd.data_coding) rows.push(['Data coding', fd.data_coding]);
|
||
if (fd.record_length != null) rows.push(['Record length', fd.record_length]);
|
||
if (fd.num_records != null) rows.push(['Records', fd.num_records]);
|
||
}
|
||
if (resp.file_id) rows.push(['File ID', resp.file_id + (resp.file_id_name ? ' (' + resp.file_id_name + ')' : '')]);
|
||
if (resp.df_name) rows.push(['DF name', resp.df_name]);
|
||
if (resp.sfi) rows.push(['SFI', resp.sfi]);
|
||
if (resp.life_cycle) rows.push(['Life cycle', resp.life_cycle]);
|
||
if (resp.file_size != null) rows.push(['File Size', resp.file_size + ' B']);
|
||
if (resp.short_ef_id) rows.push(['Short EF ID', resp.short_ef_id]);
|
||
if (resp.channel != null) rows.push(['Assigned channel', resp.channel]);
|
||
|
||
if (resp.type === 'GSM' || resp.type === '3G/EPS/5G') {
|
||
rows.push(['Auth', resp.type + (resp.status ? ' (' + resp.status + ')' : '')]);
|
||
if (resp.sres) rows.push(['SRES', resp.sres]);
|
||
if (resp.res) rows.push(['RES', resp.res]);
|
||
if (resp.ck) rows.push(['CK', resp.ck]);
|
||
if (resp.ik) rows.push(['IK', resp.ik]);
|
||
if (resp.kc) rows.push(['Kc', resp.kc]);
|
||
if (resp.auts) {
|
||
rows.push(['AUTS', resp.auts]);
|
||
if (resp.sqn_ak) rows.push(['SQN⊕AK', resp.sqn_ak + ' (concealed)']);
|
||
if (resp.mac_s) rows.push(['MAC-S', resp.mac_s]);
|
||
}
|
||
}
|
||
|
||
if (resp.code !== undefined) {
|
||
rows.push(['Result', resp.code + (resp.name ? ' — ' + resp.name : '')]);
|
||
}
|
||
if (resp.duration) {
|
||
rows.push(['Duration', resp.duration.value + ' ' + resp.duration.unit]);
|
||
}
|
||
if (resp.item_identifier !== undefined) {
|
||
rows.push(['Item', resp.item_identifier]);
|
||
}
|
||
if (resp.local_info) {
|
||
for (const [k, v] of Object.entries(resp.local_info)) {
|
||
rows.push([k, v]);
|
||
}
|
||
}
|
||
if (resp.rpl !== undefined) {
|
||
const renc = resp.ciphered ? ' (encrypted)' : '';
|
||
rows.push(['Response packet', 'Response Packet Length ' + resp.rpl + ' · Response Header Length ' + resp.rhl]);
|
||
rows.push(['TAR', '0x' + resp.tar]);
|
||
rows.push(['CNTR' + renc, '0x' + resp.cntr]);
|
||
rows.push(['PCNTR' + renc, resp.pcntr]);
|
||
const st = resp.status || {};
|
||
rows.push(['Status', '0x' + st.code + (st.name ? ' — ' + st.name : '')]);
|
||
if (resp.rc_cc_ds) rows.push(['RC/CC/DS' + renc, resp.rc_cc_ds]);
|
||
if (resp.data) rows.push(['Response data', resp.data]);
|
||
}
|
||
|
||
if (!rows.length) return '';
|
||
return `<tr><td class="label" colspan="2" style="padding-top:6px; border-top:1px solid var(--border);">Response</td></tr>` +
|
||
rows.map(([l, v]) => `<tr><td class="label">${l}</td><td class="value">${v}</td></tr>`).join('');
|
||
}
|
||
|
||
function cmdRows(cmd) {
|
||
if (!cmd) return '';
|
||
const rows = [];
|
||
|
||
// AUTHENTICATE command body
|
||
if (cmd.rand !== undefined || cmd.autn !== undefined || cmd.specific_key) {
|
||
if (cmd.specific_key) rows.push(['Key', 'specific reference data']);
|
||
if (cmd.rand) rows.push(['RAND', cmd.rand]);
|
||
if (cmd.autn) {
|
||
rows.push(['AUTN', cmd.autn]);
|
||
if (cmd.sqn_ak) rows.push(['SQN⊕AK', cmd.sqn_ak + ' (concealed)']);
|
||
if (cmd.amf) rows.push(['AMF', cmd.amf]);
|
||
if (cmd.mac) rows.push(['MAC', cmd.mac]);
|
||
}
|
||
}
|
||
|
||
// ENVELOPE fields
|
||
if (cmd.events) rows.push(['Event', cmd.events.join(', ')]);
|
||
if (cmd.location_status) rows.push(['Location status', cmd.location_status]);
|
||
if (cmd.location_info) rows.push(['Location info', cmd.location_info]);
|
||
if (cmd.caller) rows.push(['Caller', cmd.caller]);
|
||
if (cmd.transaction_id !== undefined) rows.push(['Transaction', cmd.transaction_id]);
|
||
if (cmd.subaddress) rows.push(['Subaddress', cmd.subaddress]);
|
||
if (cmd.smsc) rows.push(['SMSC', cmd.smsc]);
|
||
if (cmd.tp_da) rows.push(['TP-DA', cmd.tp_da]);
|
||
if (cmd.device_ids) {
|
||
const di = cmd.device_ids;
|
||
rows.push(['Device IDs', typeof di === 'string' ? di
|
||
: (di.src + ' → ' + di.dst)]);
|
||
}
|
||
if (cmd.cb_page) {
|
||
const cb = cmd.cb_page;
|
||
if (cb.serial) rows.push(['CB serial', cb.serial]);
|
||
if (cb.message_id) rows.push(['CB message', cb.message_id]);
|
||
if (cb.dcs) rows.push(['CB DCS', cb.dcs]);
|
||
if (cb.page) rows.push(['CB page', cb.page]);
|
||
if (cb.content) rows.push(['CB content', cb.content]);
|
||
}
|
||
|
||
// Proactive command fields (FETCH)
|
||
if (cmd.qualifier) rows.push(['Qualifier', cmd.qualifier]);
|
||
if (cmd.file_list) rows.push(['File list', cmd.file_list.join(', ')]);
|
||
if (cmd.aid) rows.push(['AID', cmd.aid]);
|
||
if (cmd.title) rows.push(['Title', cmd.title]);
|
||
if (cmd.text !== undefined) rows.push(['Text', cmd.text]);
|
||
if (cmd.icon_id !== undefined) rows.push(['Icon', cmd.icon_id ? 'record #' + cmd.icon_id : 'none']);
|
||
if (cmd.items) {
|
||
for (const it of cmd.items) {
|
||
const next = it.next_action ? ' \u2192 ' + it.next_action : '';
|
||
rows.push(['Item ' + (it.id !== undefined ? '0x' + hx(it.id, 2) : '?'), it.text + next]);
|
||
}
|
||
}
|
||
if (cmd.duration) rows.push(['Duration', cmd.duration.value + ' ' + cmd.duration.unit]);
|
||
if (cmd.address) rows.push(['Address', cmd.address]);
|
||
if (cmd.response_length) rows.push(['Response length', cmd.response_length.min + '…' + cmd.response_length.max]);
|
||
if (cmd.item_id !== undefined) rows.push(['Item id', '0x' + hx(cmd.item_id, 2)]);
|
||
if (cmd.help) rows.push(['Help', 'requested']);
|
||
if (cmd.timer_id !== undefined) rows.push(['Timer id',
|
||
typeof cmd.timer_id === 'number' ? '0x' + hx(cmd.timer_id, 2) : cmd.timer_id]);
|
||
if (cmd.timer_value) rows.push(['Timer value', cmd.timer_value]);
|
||
if (cmd.ussd) rows.push(['USSD string', cmd.ussd]);
|
||
if (cmd.encapsulated && cmd.encapsulated.type) {
|
||
rows.push(['Encapsulated', cmd.encapsulated.type]);
|
||
}
|
||
if (cmd.text_note) rows.push(['Text note', cmd.text_note]);
|
||
|
||
// TERMINAL CAPABILITY (TS 102 221 §11.1.19)
|
||
if (cmd.power_supply) {
|
||
const ps = cmd.power_supply;
|
||
const bits = [];
|
||
bits.push(ps.classes && ps.classes.length
|
||
? 'class ' + ps.classes.join('+')
|
||
: 'class 0x' + hx(ps.class_raw || 0, 2));
|
||
if (ps.max_current_ma !== undefined) bits.push(ps.max_current_ma + ' mA');
|
||
if (ps.clock_mhz !== undefined && ps.clock_mhz !== null) bits.push(ps.clock_mhz + ' MHz clock');
|
||
else if (ps.clock_raw !== undefined) bits.push('clock not indicated');
|
||
rows.push(['Power supply', bits.join(', ')]);
|
||
}
|
||
if (cmd.extended_channels) rows.push(['Logical channels', 'extended support']);
|
||
if (cmd.extended_channels_raw) rows.push(['Logical channels raw', cmd.extended_channels_raw]);
|
||
if (cmd.interfaces) rows.push(['Additional interfaces',
|
||
cmd.interfaces.length ? cmd.interfaces.join(', ') : 'none']);
|
||
if (cmd.interfaces_raw) rows.push(['Interfaces raw', cmd.interfaces_raw]);
|
||
if (cmd.euicc_sgp22) {
|
||
const e = cmd.euicc_sgp22;
|
||
const names = (e.functions || []).join(', ');
|
||
rows.push(['eUICC (SGP.22)', names ? names + ' \u00b7 0x' + e.raw : '0x' + e.raw]);
|
||
}
|
||
if (cmd.euicc_sgp32) rows.push(['eUICC (SGP.32)', '0x' + (cmd.euicc_sgp32.raw || '')]);
|
||
|
||
if (cmd.raw_tlv) {
|
||
for (const t of cmd.raw_tlv) rows.push(['TLV 0x' + t.tag, t.value || '(empty)']);
|
||
}
|
||
|
||
// SM TPDU (nested in SMS-PP download / SEND SHORT MESSAGE)
|
||
const t = cmd.tpdu;
|
||
if (t) {
|
||
rows.push(['SMS TPDU', t.mti]);
|
||
if (t.oa) rows.push(['TP-OA', t.oa]);
|
||
if (t.da) rows.push(['TP-DA', t.da]);
|
||
if (t.mr !== undefined) rows.push(['TP-MR', '0x' + hx(t.mr, 2)]);
|
||
if (t.scts) rows.push(['TP-SCTS', t.scts]);
|
||
if (t.pid !== undefined) rows.push(['TP-PID', '0x' + hx(t.pid, 2) + (t.pid_name ? ' — ' + t.pid_name : '')]);
|
||
if (t.dcs !== undefined) {
|
||
const di = t.dcs_info || {};
|
||
let dcsTxt = '0x' + hx(t.dcs, 2) + ' — ' + t.encoding;
|
||
if (di.group) dcsTxt += ' · ' + di.group;
|
||
if (di.compressed) dcsTxt += ' · compressed';
|
||
if (di.indication) dcsTxt += ' · ' + di.indication + ' (' + di.sense + ')';
|
||
else if (t.msg_class !== undefined && di.has_class) dcsTxt += ' · class ' + t.msg_class;
|
||
rows.push(['TP-DCS', dcsTxt]);
|
||
}
|
||
if (t.vp) rows.push(['TP-VP', t.vp]);
|
||
if (Array.isArray(t.udh)) {
|
||
for (const ie of t.udh) {
|
||
let v = ie.name || ('IEI ' + ie.iei);
|
||
const d = ie.data;
|
||
if (d) {
|
||
if (d.seq !== undefined && d.max !== undefined)
|
||
v += ` — ref ${d.reference}, part ${d.seq}/${d.max}`;
|
||
else if (d.dest_port !== undefined)
|
||
v += ` — dst ${d.dest_port}, src ${d.orig_port}`;
|
||
else if (d.count !== undefined)
|
||
v += ` — ${d.indication || 'messages'}: ${d.count}` + (d.store ? ' (store)' : '');
|
||
}
|
||
if (ie.hex) v += ' · ' + ie.hex;
|
||
rows.push(['UDH', v]);
|
||
}
|
||
}
|
||
if (t.text !== undefined) rows.push(['TP-UD', t.text]);
|
||
if (t.payload) rows.push(['TP-UD (payload)', t.payload]);
|
||
|
||
const sec = t.secured;
|
||
if (sec && !sec.raw) {
|
||
rows.push(['Secured packet', 'Command Packet Length ' + sec.cpl + ' · Command Header Length ' + sec.chl]);
|
||
const s = sec.spi || {};
|
||
rows.push(['SPI', '0x' + (s.hex || '') + ' — ' + (s.counter || '') +
|
||
(s.ciphering ? ', ciphering' : '') + ', ' + (s.rc_cc_ds || '') + ', ' + (s.por || '')]);
|
||
rows.push(['KIc', 'key ' + sec.kic.key + ' — ' + sec.kic.algo]);
|
||
rows.push(['KID', 'key ' + sec.kid.key + ' — ' + sec.kid.algo]);
|
||
rows.push(['TAR', '0x' + sec.tar]);
|
||
// When the packet is ciphered, CNTR/PCNTR/RC-CC-DS on the wire
|
||
// are ciphertext (TS 102 225 Table 2 note 1) — not real values.
|
||
const enc = sec.ciphered ? ' (encrypted)' : '';
|
||
rows.push(['CNTR' + enc, '0x' + sec.cntr]);
|
||
rows.push(['PCNTR' + enc, sec.pcntr]);
|
||
if (sec.rc_cc_ds) rows.push(['RC/CC/DS' + enc, sec.rc_cc_ds]);
|
||
rows.push(['Secured data', sec.data]);
|
||
// Decrypted values (client-side, key supplied by the user).
|
||
const dec = sec.cipher_block ? DECRYPT_CACHE[sec.cipher_block] : null;
|
||
if (dec) {
|
||
if (dec.error) {
|
||
rows.push(['Decrypt error', dec.error]);
|
||
} else {
|
||
const cntrHex = bytesToHex(dec.cntr);
|
||
const cntrDec = BigInt('0x' + cntrHex).toString();
|
||
rows.push(['CNTR (decrypted)', '0x' + cntrHex + ' (' + cntrDec + ')']);
|
||
rows.push(['PCNTR (decrypted)', dec.pcntr]);
|
||
if (dec.rc.length) rows.push(['RC/CC/DS (decrypted)', bytesToHex(dec.rc)]);
|
||
rows.push(['Secured data (decrypted)', bytesToHex(dec.data)]);
|
||
}
|
||
}
|
||
// Application message: the secured data is a C-APDU stream —
|
||
// plaintext when unciphered, decrypted content otherwise.
|
||
const appSrc = !sec.ciphered && sec.data ? {hex: sec.data, src: 'plaintext'}
|
||
: (sec.cipher_block && dec && !dec.error) ? {hex: bytesToHex(dec.data), src: 'decrypted'}
|
||
: null;
|
||
if (appSrc && appSrc.hex) {
|
||
const apdus = parseCompactApdus(appSrc.hex)
|
||
.filter(a => a.label !== 'Unknown');
|
||
if (apdus.length) {
|
||
rows.push(['Application message (' + appSrc.src + ')',
|
||
apdus.length + ' C-APDU' + (apdus.length > 1 ? 's' : '')]);
|
||
let n = 0;
|
||
for (const apdu of apdus) {
|
||
if (apdu.label === 'Le') {
|
||
rows.push(['· Le (trailing)',
|
||
(apdu.hex || '') + (apdu.desc ? ' — ' + apdu.desc : '')]);
|
||
continue;
|
||
}
|
||
n++;
|
||
const kids = apdu.children || [];
|
||
const insKid = kids.find(k => k.label === 'INS');
|
||
const name = insKid ? (insKid.desc || '') : apdu.label;
|
||
rows.push(['C-APDU #' + n + ' — ' + (name || apdu.label) +
|
||
(apdu.label.includes('implied CLA') ? ' (implied CLA)' : ''),
|
||
apdu.hex || '']);
|
||
// Compact layout: pair CLA+INS and P1+P2; skip a
|
||
// desc that merely repeats the hex value.
|
||
const fmtKid = k => {
|
||
let v = k.hex || '';
|
||
if (k.desc && k.desc !== k.hex) v += ' — ' + k.desc;
|
||
return v;
|
||
};
|
||
const byLabel = {};
|
||
for (const k of kids) byLabel[k.label] = k;
|
||
const pairs = [['CLA', 'INS'], ['P1', 'P2']];
|
||
const done = new Set();
|
||
for (const [a, b] of pairs) {
|
||
if (byLabel[a] && byLabel[b]) {
|
||
rows.push(['· ' + a + ', ' + b,
|
||
fmtKid(byLabel[a]) + ', ' + fmtKid(byLabel[b])]);
|
||
done.add(a);
|
||
done.add(b);
|
||
}
|
||
}
|
||
for (const k of kids) {
|
||
if (done.has(k.label)) continue;
|
||
rows.push(['· ' + k.label, fmtKid(k)]);
|
||
if (k.children) for (const c of k.children) {
|
||
rows.push(['· · ' + c.label, fmtKid(c)]);
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
rows.push(['Application message (' + appSrc.src + ')', appSrc.hex]);
|
||
}
|
||
}
|
||
}
|
||
|
||
const rp = t.response_packet;
|
||
if (rp && !rp.raw) {
|
||
const renc = rp.ciphered ? ' (encrypted)' : '';
|
||
rows.push(['Response packet', 'Response Packet Length ' + rp.rpl + ' · Response Header Length ' + rp.rhl]);
|
||
rows.push(['TAR', '0x' + rp.tar]);
|
||
rows.push(['CNTR' + renc, '0x' + rp.cntr]);
|
||
rows.push(['PCNTR' + renc, rp.pcntr]);
|
||
const st = rp.status || {};
|
||
rows.push(['Status', '0x' + st.code + (st.name ? ' — ' + st.name : '')]);
|
||
if (rp.rc_cc_ds) rows.push(['RC/CC/DS' + renc, rp.rc_cc_ds]);
|
||
rows.push(['Response data', rp.data]);
|
||
}
|
||
}
|
||
|
||
if (!rows.length) return '';
|
||
return `<tr><td class="label" colspan="2" style="padding-top:6px; border-top:1px solid var(--border);">Command</td></tr>` +
|
||
rows.map(([l, v]) => `<tr><td class="label">${l}</td><td class="value">${v}</td></tr>`).join('');
|
||
}
|
||
|
||
function fileRows(f) {
|
||
if (!f) return '';
|
||
const title = f.ef ? `${f.ef} (0x${(f.fid || '').toUpperCase()})` : (f.fid || '').toUpperCase();
|
||
const rows = [];
|
||
if (f.unknown) {
|
||
rows.push(['File', `${f.ef || ('SFI ' + f.sfi)} — unresolved SFI reference`]);
|
||
} else if (f.stale) {
|
||
rows.push(['File', title + ' — selection stale (record op on non-record file)']);
|
||
} else if (f.empty) {
|
||
rows.push(['File', title + ' — empty']);
|
||
} else {
|
||
rows.push(['File', title]);
|
||
if (f.imsi) rows.push(['IMSI', f.imsi]);
|
||
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)]);
|
||
if (f.ext1 !== undefined) rows.push(['Ext1', '0x' + hx(f.ext1, 2)]);
|
||
if (f.plmns) rows.push(['PLMNs', f.plmns.map(p => p.mcc + '/' + p.mnc + (p.access_tech ? ' (' + p.access_tech + ')' : '')).join(', ')]);
|
||
if (f.services) {
|
||
for (const s of f.services) rows.push(['Service ' + s.n, s.name || '']);
|
||
}
|
||
if (f.applications) {
|
||
for (const a of f.applications) {
|
||
rows.push(['App', (a.label ? a.label + ' · ' : '') + (a.aid || '')]);
|
||
if (a.eap) {
|
||
const types = (a.eap.eap_types || []).join(', ');
|
||
const dfs = (a.eap.dfs || []).map(d => d.toUpperCase()).join(', ');
|
||
rows.push(['EAP', `${a.eap.label || 'EAP'} (${types}) in ${dfs}`]);
|
||
}
|
||
}
|
||
}
|
||
if (f.direction) rows.push(['SMS', f.direction + ' — ' + f.status]);
|
||
if (f.message_ids) rows.push(['Message IDs', f.message_ids.join(', ')]);
|
||
if (f.record_numbers) rows.push(['Records found', f.record_numbers.join(', ')]);
|
||
if (f.range) rows.push(['Range', f.range[0] + '…' + f.range[1]]);
|
||
if (f.access_classes) rows.push(['Access classes', f.access_classes.join(', ')]);
|
||
if (f.phase) rows.push(['Phase', f.phase]);
|
||
if (f.display_condition) rows.push(['Display condition', f.display_condition]);
|
||
if (f.mcc) {
|
||
const parts = ['MCC ' + f.mcc + ' MNC ' + f.mnc];
|
||
if (f.lac) parts.push('LAC ' + f.lac);
|
||
if (f.tac) parts.push('TAC ' + f.tac);
|
||
if (f.rac) parts.push('RAC ' + f.rac);
|
||
rows.push(['Location', parts.join(' · ')]);
|
||
}
|
||
if (f.tmsi) rows.push(['TMSI', f.tmsi]);
|
||
if (f.tmsi_time) rows.push(['TMSI time', f.tmsi_time]);
|
||
if (f.location_update_status) rows.push(['Update status', f.location_update_status]);
|
||
if (f.guti) rows.push(['GUTI', f.guti]);
|
||
if (f.eps_update_status) rows.push(['EPS update status', f.eps_update_status]);
|
||
if (f.p_tmsi) rows.push(['P-TMSI', f.p_tmsi]);
|
||
if (f.p_tmsi_signature) rows.push(['P-TMSI signature', f.p_tmsi_signature]);
|
||
if (f.update_status) rows.push(['Update status', f.update_status]);
|
||
if (f.ksi_asme) rows.push(['KSI (ASME)', f.ksi_asme]);
|
||
if (f.k_asme) rows.push(['K_ASME', f.k_asme]);
|
||
if (f.uplink_nas_count !== undefined) rows.push(['UpLink NAS count', f.uplink_nas_count]);
|
||
if (f.downlink_nas_count !== undefined) rows.push(['DownLink NAS count', f.downlink_nas_count]);
|
||
if (f.nas_algorithms) rows.push(['NAS algorithms', f.nas_algorithms]);
|
||
if (f.rules) rows.push(['Rules', f.rules.map(r => r.tag + ' ' + r.hex).join(', ')]);
|
||
if (f.raw) rows.push(['Data', f.raw]);
|
||
if (f.tpdu) {
|
||
const t = f.tpdu;
|
||
rows.push(['SMS TPDU', t.mti]);
|
||
if (t.oa) rows.push(['TP-OA', t.oa]);
|
||
if (t.da) rows.push(['TP-DA', t.da]);
|
||
if (t.text !== undefined) rows.push(['TP-UD', t.text]);
|
||
if (t.payload) rows.push(['TP-UD (payload)', t.payload]);
|
||
}
|
||
}
|
||
|
||
if (!rows.length) return '';
|
||
return `<tr><td class="label" colspan="2" style="padding-top:6px; border-top:1px solid var(--border);">File data</td></tr>` +
|
||
rows.map(([l, v]) => `<tr><td class="label">${l}</td><td class="value">${v}</td></tr>`).join('');
|
||
}
|
||
|
||
function ifaceValue(ifc) {
|
||
const v = [];
|
||
if (ifc.f !== undefined) v.push('F=' + ifc.f);
|
||
if (ifc.d !== undefined) v.push('D=' + ifc.d);
|
||
if (ifc.f_div_d !== undefined) v.push('F/D=' + ifc.f_div_d);
|
||
if (ifc.f_max !== undefined) v.push('f(max)=' + ifc.f_max + ' MHz');
|
||
if (ifc.programming !== undefined) v.push(ifc.programming);
|
||
if (ifc.extra_guard_time !== undefined) v.push('N=' + ifc.extra_guard_time + ' etu');
|
||
if (ifc.work_waiting_time !== undefined) v.push('WI=' + ifc.work_waiting_time);
|
||
if (ifc.specific !== undefined) v.push(ifc.specific ? 'specific mode' : 'negotiable mode');
|
||
if (ifc.protocol !== undefined) v.push(ifc.protocol);
|
||
if (ifc.ifsc !== undefined) v.push('IFSC=' + ifc.ifsc);
|
||
if (ifc.bwi !== undefined) v.push('BWI=' + ifc.bwi + ', CWI=' + ifc.cwi);
|
||
if (ifc.clock_stop) v.push(ifc.clock_stop);
|
||
if (ifc.classes && ifc.classes.length) v.push('classes ' + ifc.classes.join(', '));
|
||
if (ifc.spu !== undefined) v.push('SPU ' + ifc.spu);
|
||
return (v.length ? v.join(' · ') : '') + (ifc.hex ? ' (0x' + ifc.hex + ')' : '');
|
||
}
|
||
|
||
function atrPpsRows(decoded) {
|
||
const rows = [];
|
||
if (decoded.type === 'atr') {
|
||
rows.push(['Convention', decoded.convention || 'unknown']);
|
||
if (decoded.ts) rows.push(['TS', '0x' + decoded.ts]);
|
||
if (decoded.t0) rows.push(['T0', '0x' + decoded.t0 + ' — ' + decoded.historical_len + ' historical bytes']);
|
||
if (decoded.protocols && decoded.protocols.length) {
|
||
rows.push([decoded.protocols.length > 1 ? 'Protocols' : 'Protocol', decoded.protocols.join(', ')]);
|
||
}
|
||
if (decoded.pps) rows.push(['PPS', 'supported']);
|
||
if (decoded.interface) {
|
||
for (const ifc of decoded.interface) {
|
||
rows.push([ifc.name, ifaceValue(ifc)]);
|
||
}
|
||
}
|
||
if (decoded.clk_hz) rows.push(['CLK', (decoded.clk_hz / 1e6).toFixed(3) + ' MHz']);
|
||
if (decoded.data_rate) rows.push(['Data rate', decoded.data_rate + ' bit/s']);
|
||
if (decoded.etu_us) rows.push(['ETU', decoded.etu_us + ' µs']);
|
||
if (decoded.historical) {
|
||
const h = decoded.historical;
|
||
let v = h.category || '';
|
||
if (h.life_cycle) v += ' — ' + h.life_cycle;
|
||
if (h.sw) v += ' (SW ' + h.sw + ')';
|
||
rows.push(['Historical', v + ' · ' + h.raw]);
|
||
if (h.card_capabilities) {
|
||
const caps = h.card_capabilities;
|
||
const cv = [];
|
||
if (caps.max_channels != null) {
|
||
cv.push('logical channels: ' + (typeof caps.max_channels === 'number'
|
||
? 'up to ' + caps.max_channels : caps.max_channels));
|
||
}
|
||
if (caps.channel_assignment && caps.channel_assignment.length) {
|
||
cv.push('assigned ' + caps.channel_assignment.join(', '));
|
||
}
|
||
if (caps.command_chaining) cv.push('command chaining');
|
||
if (caps.extended_lc_le) cv.push('extended Lc/Le');
|
||
rows.push(['Card capabilities', cv.join(' · ') + (cv.length ? ' · ' : '') + caps.raw]);
|
||
}
|
||
if (h.objects && h.objects.length) {
|
||
const names = h.objects.map(o => o.name + (o.value ? ' (' + o.value + ')' : ''));
|
||
rows.push(['Historical objects', names.join(', ')]);
|
||
}
|
||
}
|
||
if (decoded.tck) {
|
||
rows.push(['TCK', '0x' + decoded.tck + (decoded.tck_valid ? ' (valid)' : ' (mismatch)')]);
|
||
}
|
||
} else {
|
||
if (decoded.ppss) rows.push(['PPSS', '0x' + decoded.ppss]);
|
||
if (decoded.pps0) rows.push(['PPS0', '0x' + decoded.pps0]);
|
||
if (decoded.protocol) rows.push(['Protocol', decoded.protocol]);
|
||
if (decoded.fi_di) {
|
||
const fd = decoded.fi_di;
|
||
const v = [];
|
||
if (fd.f !== undefined) v.push('F=' + fd.f);
|
||
if (fd.d !== undefined) v.push('D=' + fd.d);
|
||
if (fd.f_div_d !== undefined) v.push('F/D=' + fd.f_div_d);
|
||
rows.push(['PPS1', v.join(' · ') + ' (0x' + fd.pps1 + ')']);
|
||
}
|
||
if (decoded.pps2 !== undefined) rows.push(['PPS2', '0x' + decoded.pps2 + (decoded.spu ? ' — ' + decoded.spu : '')]);
|
||
if (decoded.pck) rows.push(['PCK', '0x' + decoded.pck + (decoded.pck_valid ? ' (valid)' : ' (mismatch)')]);
|
||
if (decoded.note) rows.push(['Note', decoded.note]);
|
||
}
|
||
rows.push(['Raw', (decoded.raw || '')]);
|
||
if (decoded.errors) rows.push(['Errors', decoded.errors.join(', ')]);
|
||
return rows;
|
||
}
|
||
|
||
function showDetail(msg, entry) {
|
||
// Highlight the focused entry; the state keeps it marked across renders.
|
||
document.querySelectorAll('.apdu-entry.selected').forEach(e => e.classList.remove('selected'));
|
||
if (entry) {
|
||
entry.classList.add('selected');
|
||
const idx = Number(entry.dataset.idx);
|
||
if (!Number.isNaN(idx)) STATE.selectedIdx = idx;
|
||
}
|
||
|
||
const d = el('apdu-detail');
|
||
const decoded = msg.decoded;
|
||
|
||
if (msg.type === 'gap') {
|
||
d.innerHTML = `<div class="detail-header"><strong>Capture Gap</strong><span style="color:var(--text-muted)">${detail_time_html(msg)}</span></div>
|
||
<div class="detail-body" style="color:var(--warn);">⚠ ${t('The device disconnected and reconnected — some APDUs may be missing from this trace.')}</div>`;
|
||
return;
|
||
}
|
||
|
||
if (!decoded) {
|
||
d.innerHTML = `<div class="detail-header"><strong>${ins_label(msg)}</strong><span style="color:var(--text-muted)">${detail_time_html(msg)}</span></div>
|
||
<div class="detail-body" style="color:var(--text-muted);">No decoded data available for this message type.</div>`;
|
||
return;
|
||
}
|
||
|
||
if (decoded.type === 'change') {
|
||
d.innerHTML = `<div class="detail-header"><strong>State Change</strong><span style="color:var(--text-muted)">${detail_time_html(msg)}</span></div>
|
||
<div class="detail-body">
|
||
<table>
|
||
<tr><td class="label">Flags</td><td class="value">${decoded.flags.join(', ') || 'no changes'}</td></tr>
|
||
</table>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
if (decoded.type === 'rst' || decoded.type === 'vcc') {
|
||
const evName = decoded.label || decoded.event || (decoded.type === 'rst' ? 'reset event' : 'power event');
|
||
d.innerHTML = `<div class="detail-header"><strong>${ins_label(msg)}</strong><span style="color:var(--text-muted)">${detail_time_html(msg)}</span></div>
|
||
<div class="detail-body">
|
||
<table>
|
||
<tr><td class="label">Event</td><td class="value">${evName}</td></tr>
|
||
<tr><td class="label">Level</td><td class="value">${decoded.level || '?'}</td></tr>
|
||
${decoded.clk_hz ? `<tr><td class="label">CLK</td><td class="value">${(decoded.clk_hz / 1e6).toFixed(3)} MHz</td></tr>` : ''}
|
||
${decoded.raw ? `<tr><td class="label">Raw</td><td class="hex">${decoded.raw}</td></tr>` : ''}
|
||
</table>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
if (decoded.type === 'fidi') {
|
||
d.innerHTML = `<div class="detail-header"><strong>Fi/Di Change</strong><span style="color:var(--text-muted)">${detail_time_html(msg)}</span></div>
|
||
<div class="detail-body">
|
||
<table>
|
||
<tr><td class="label">Fi</td><td class="value">${decoded.fi_val} (index ${decoded.fi})</td></tr>
|
||
<tr><td class="label">Di</td><td class="value">${decoded.di_val} (index ${decoded.di})</td></tr>
|
||
<tr><td class="label">Raw</td><td class="hex">0x${(decoded.fidi || '').toUpperCase()}</td></tr>
|
||
</table>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
if (decoded.type === 'atr' || decoded.type === 'pps') {
|
||
const rows = atrPpsRows(decoded);
|
||
d.innerHTML = `<div class="detail-header"><strong>${decoded.type.toUpperCase()}</strong><span style="color:var(--text-muted)">${detail_time_html(msg)}</span></div>
|
||
<div class="detail-body">
|
||
<table>
|
||
${rows.map(([l, v]) => `<tr><td class="label">${l}</td><td class="hex">${v}</td></tr>`).join('')}
|
||
</table>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
let body = '';
|
||
if (decoded.body) {
|
||
const note = decoded.body.note ? ` <span style="color:var(--accent);">${decoded.body.note}</span>` : '';
|
||
body = `<tr><td class="label">${decoded.body.label || 'Body'}</td><td class="hex">${(decoded.body.hex || '').toUpperCase()}${note}</td></tr>`;
|
||
}
|
||
|
||
let p1p2 = '';
|
||
if (decoded.p1p2) {
|
||
if (decoded.p1p2.unused) {
|
||
p1p2 = `<tr><td class="label">P1, P2</td><td class="value">0x${hx(decoded.p1p2.value, 4)} (RFU)</td></tr>`;
|
||
} else if (decoded.p1p2.sfi !== undefined) {
|
||
p1p2 = `<tr><td class="label">P1-P2</td><td class="value">0x${hx(decoded.p1p2.value, 4)} — SFI ${decoded.p1p2.sfi}, offset ${decoded.p1p2.offset}</td></tr>`;
|
||
} else if (decoded.p1p2.offset !== undefined) {
|
||
p1p2 = `<tr><td class="label">P1-P2</td><td class="value">0x${hx(decoded.p1p2.value, 4)} — Offset: ${decoded.p1p2.offset}</td></tr>`;
|
||
} else {
|
||
p1p2 = `<tr><td class="label">P1-P2</td><td class="value">0x${hx(decoded.p1p2.value, 4)} — ${decoded.p1p2.label}: ${decoded.p1p2.value}</td></tr>`;
|
||
}
|
||
}
|
||
let p1 = '';
|
||
if (decoded.p1) {
|
||
const n = decoded.p1.name || (decoded.p1.label && decoded.p1.value != null ? `${decoded.p1.label}: ${decoded.p1.value}` : '');
|
||
p1 = `<tr><td class="label">P1</td><td class="value">0x${(decoded.p1.raw || '').toUpperCase()}${n ? ` — <span class="name">${n}</span>` : ''}</td></tr>`;
|
||
}
|
||
let p2 = '';
|
||
if (decoded.p2) {
|
||
const parts = [];
|
||
if (decoded.p2.sfi) parts.push('SFI ' + decoded.p2.sfi);
|
||
const m = decoded.p2.name || (decoded.p2.bits ? decoded.p2.bits.join(', ') : '');
|
||
if (m) parts.push(m);
|
||
p2 = `<tr><td class="label">P2</td><td class="value">0x${(decoded.p2.raw || '').toUpperCase()}${parts.length ? ` — <span class="name">${parts.join(', ')}</span>` : ''}</td></tr>`;
|
||
}
|
||
|
||
let sw = '';
|
||
if (decoded.sw) {
|
||
const sn = decoded.sw.name || '';
|
||
sw = `<tr><td class="label">SW</td><td class="value">${(decoded.sw.sw1 || '').toUpperCase()} ${(decoded.sw.sw2 || '').toUpperCase()} <span style="color:${sn ? 'var(--accent)' : 'var(--text-muted)'}">${sn}</span></td></tr>`;
|
||
}
|
||
|
||
const cla = decoded.cla;
|
||
const claInfo = `${cla.interclass}, ch${cla.channel}, SM: ${cla.secure_messaging}, chain: ${cla.chain}`;
|
||
|
||
const catRow = decoded.cat_command
|
||
? `<tr><td class="label">CAT</td><td class="name">${decoded.cat_command}</td></tr>`
|
||
: '';
|
||
|
||
const respRow = decoded.response_for !== undefined
|
||
? `<tr><td class="label">Response for</td><td class="name">${decoded.response_for || 'orphaned'}</td></tr>`
|
||
: '';
|
||
|
||
const trRow = decoded.response_to
|
||
? `<tr><td class="label">Responding to</td><td class="name">${decoded.response_to}</td></tr>`
|
||
: '';
|
||
|
||
const lm = decoded.length_mismatch;
|
||
const lmNote = lm
|
||
? ` <span style="color:var(--error)">— ${lm.kind} (${lm.actual}/${lm.expected} bytes)</span>`
|
||
: '';
|
||
|
||
d.innerHTML = `<div class="detail-header">
|
||
<strong>${decoded.cat_command || decoded.ins_name}</strong>
|
||
<span style="color:var(--text-muted)">${detail_time_html(msg)}</span>
|
||
</div>
|
||
<div class="detail-body">
|
||
<table>
|
||
<tr><td class="label">Raw APDU</td><td class="hex">${fmt_data(msg.data, 9999)}</td></tr>
|
||
<tr><td class="label">CLA</td><td class="value">0x${(cla.hex || '').toUpperCase()} — ${claInfo}</td></tr>
|
||
<tr><td class="label">INS</td><td class="name">0x${(decoded.ins_hex || '').toUpperCase()} — ${decoded.ins_name}</td></tr>
|
||
${catRow}${respRow}${trRow}
|
||
${p1p2}${p1}${p2}
|
||
<tr><td class="label">P3</td><td class="value">0x${hx(decoded.p3, 2)} — ${decoded.p3} bytes${decoded.p3_kind === 'le' ? ' · ' + t('Le (expected response length)') : decoded.p3_kind === 'lc' ? ' · ' + t('Lc (command data length)') : ''}${lmNote}</td></tr>
|
||
${body}
|
||
${cmdRows(decoded.cmd)}
|
||
${responseRows(decoded.response)}
|
||
${tpRows(decoded.tp)}
|
||
${fileRows(decoded.file)}
|
||
${sw}
|
||
${decoded.errors ? `<tr><td class="label">Errors</td><td class="value" style="color:var(--error)">${decoded.errors.join(', ')}</td></tr>` : ''}
|
||
</table>
|
||
</div>`;
|
||
|
||
// Ciphered SCP80 command packet → client-side decrypt strip (the key
|
||
// never leaves the browser; memoized per key-set for the session).
|
||
const secured = ((decoded.cmd || {}).tpdu || {}).secured;
|
||
if (secured && secured.ciphered && secured.cipher_block) {
|
||
const strip = document.createElement('div');
|
||
strip.className = 'decrypt-strip';
|
||
const keySet = secured.kic ? secured.kic.key : 0;
|
||
strip.innerHTML = `<span style="color:var(--text-muted)">Ciphered packet — 3DES/AES key (hex) for key set ${keySet}:</span> ` +
|
||
`<input type="text" class="decrypt-key" placeholder="key hex" spellcheck="false"> ` +
|
||
`<button class="decrypt-btn">Decrypt</button> ` +
|
||
`<span class="decrypt-status" style="color:var(--text-muted)"></span>`;
|
||
d.appendChild(strip);
|
||
const input = strip.querySelector('.decrypt-key');
|
||
const status = strip.querySelector('.decrypt-status');
|
||
const btn = strip.querySelector('.decrypt-btn');
|
||
const run = async () => {
|
||
const keyHex = input.value.trim().toLowerCase();
|
||
if (!keyHex) return;
|
||
try {
|
||
const parsed = await decryptSecured(secured, keyHex);
|
||
OTA_KEYS[keySet] = keyHex;
|
||
DECRYPT_CACHE[secured.cipher_block] = parsed;
|
||
status.textContent = '✓ decrypted';
|
||
status.style.color = 'var(--green)';
|
||
showDetail(msg, entry); // re-render with decrypted rows
|
||
} catch (e) {
|
||
status.textContent = '✗ ' + e.message;
|
||
status.style.color = 'var(--error)';
|
||
}
|
||
};
|
||
btn.addEventListener('click', run);
|
||
input.addEventListener('keydown', ev => { if (ev.key === 'Enter') run(); });
|
||
if (OTA_KEYS[keySet] && !DECRYPT_CACHE[secured.cipher_block]) {
|
||
input.value = OTA_KEYS[keySet];
|
||
run();
|
||
}
|
||
}
|
||
}
|
||
function buildTypeCounts() {
|
||
const counts = {};
|
||
for (const m of STATE.messages) {
|
||
const lbl = stat_label(m);
|
||
counts[lbl] = (counts[lbl] || 0) + 1;
|
||
}
|
||
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.replace('current ADF', 'ADF');
|
||
if (d.summary) return d.summary.replace('current ADF', 'ADF');
|
||
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') {
|
||
return d.cat_command || 'ENVELOPE';
|
||
}
|
||
return stat_label(m);
|
||
}
|
||
|
||
// Styling category for a command (shared by map view and APDU list).
|
||
function command_cat(ins) {
|
||
if (ins === '12') return 'proactive'; // FETCH
|
||
if (ins === '14' || ins === '10' || ins === 'aa') return 'tr'; // TERMINAL RESPONSE / PROFILE / CAPABILITY
|
||
if (ins === 'c2') return 'envelope'; // ENVELOPE
|
||
if (ins === '88' || ins === '89') return 'auth'; // AUTHENTICATE
|
||
if (ins === 'c0') return 'gr'; // GET RESPONSE
|
||
return null;
|
||
}
|
||
|
||
function session_link(id) {
|
||
return location.href.split('#')[0] + '#session=' + id;
|
||
}
|
||
|
||
function is_loopback() {
|
||
return ['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0'].includes(location.hostname);
|
||
}
|
||
|
||
function copySessionLink(id) {
|
||
const url = session_link(id);
|
||
const prompt_ = () => prompt(t('Share link'), url);
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(url).catch(prompt_);
|
||
} else {
|
||
prompt_();
|
||
}
|
||
}
|
||
|
||
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).
|
||
// Each READ/UPDATE op contributes its own resolved file, so
|
||
// SFI-addressed files (never SELECTed) are attributed correctly.
|
||
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 = { files: [], selNote: map_file_label(msgs[j]), hasRead: false, hasWrite: false, start: j, end: j };
|
||
} else {
|
||
if (!cur) cur = { files: [], selNote: null, hasRead: false, hasWrite: false, start: j, end: j };
|
||
const f = map_file_label(msgs[j]);
|
||
if (f && !cur.files.includes(f)) cur.files.push(f);
|
||
if (is_write_ins(jins)) cur.hasWrite = true; else cur.hasRead = true;
|
||
cur.end = j;
|
||
}
|
||
j++;
|
||
}
|
||
if (cur) access.push(cur);
|
||
// Records with no read/write ops (pure catalog SELECT) fall back to
|
||
// the selected file's note.
|
||
for (const rec of access) {
|
||
if (rec.files.length === 0 && rec.selNote) rec.files = [rec.selNote];
|
||
}
|
||
|
||
let k = 0;
|
||
while (k < access.length) {
|
||
const rec = access[k];
|
||
if (rec.hasRead && rec.hasWrite) {
|
||
groups.push({ kind: 'rw', cat: 'file', files: rec.files, 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) {
|
||
for (const f of access[m].files) if (f && !files.includes(f)) files.push(f);
|
||
end = access[m].end;
|
||
m++;
|
||
}
|
||
groups.push({ kind: 'write', cat: 'file', files, start: rec.start, end });
|
||
k = m;
|
||
} else {
|
||
const files = [];
|
||
let end = rec.end;
|
||
let m = k;
|
||
while (m < access.length && !access[m].hasWrite) {
|
||
for (const f of access[m].files) if (f && !files.includes(f)) files.push(f);
|
||
end = access[m].end;
|
||
m++;
|
||
}
|
||
groups.push({ kind: 'read', cat: 'file', files, 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), cat: 'status', start: i, end: j - 1 });
|
||
i = j;
|
||
} else {
|
||
groups.push({ label: map_other_label(msgs[i]), cat: command_cat(ins), 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) =>
|
||
`<div class="side-item" data-idx="${i}" data-key="${k}">
|
||
<span>${k}</span><span class="count">${v}</span>
|
||
</div>`
|
||
).join('') || t('No commands');
|
||
|
||
const mapEl = el('type-map');
|
||
mapEl.innerHTML = STATE.traceMap.map(g => {
|
||
const cls = 'map-item' + (g.cat ? ' map-' + g.cat : '');
|
||
if (g.kind) {
|
||
const kind = g.kind === 'rw' ? t('Read/write:') : g.kind === 'write' ? t('Writes:') : t('Reads:');
|
||
return `<div class="${cls}" data-start="${g.start}">${kind} ${mapFilesHtml(g)}</div>`;
|
||
}
|
||
return `<div class="${cls}" data-start="${g.start}">${g.label}</div>`;
|
||
}).join('') || t('No commands');
|
||
|
||
const showMap = STATE.sideView === 'map';
|
||
el('type-counts').style.display = STATE.sideView === 'stats' ? '' : 'none';
|
||
mapEl.style.display = showMap ? '' : 'none';
|
||
el('file-view').style.display = STATE.sideView === 'files' ? '' : 'none';
|
||
el('side-view-select').value = STATE.sideView;
|
||
if (STATE.sideView === 'files') renderFileView();
|
||
}
|
||
|
||
// ──────────────────── Files view (per-file operation timeline) ────────────────────
|
||
|
||
function fileOpClass(kind) {
|
||
if (kind === 'update') return 'file-op-update';
|
||
if (kind === 'card_change') return 'file-op-card';
|
||
return 'file-op-read';
|
||
}
|
||
|
||
function fileOpVerb(kind) {
|
||
if (kind === 'update') return t('Update');
|
||
// A card-side change is discovered by a read operation, so the row reads
|
||
// as one ("Read EF_IMSI") with the change offered as a trailing link.
|
||
return t('Read');
|
||
}
|
||
|
||
function fileOpLabel(op) {
|
||
const name = (op.file && op.file.name) || '?';
|
||
return `${fileOpVerb(op.kind)} ${name}`;
|
||
}
|
||
|
||
// Hex view of an image: 16 bytes per row across the observed segments, with
|
||
// gaps drawn as unknown cells. *ranges* (bytes that changed) and *observed*
|
||
// (bytes first observed by this change) mark the cells.
|
||
function hexImageRows(segments, ranges, observed, limit, size) {
|
||
const maxBytes = limit || 2048;
|
||
const bytes = new Map();
|
||
let end = 0;
|
||
for (const seg of segments || []) {
|
||
const hex = (seg.hex || '').toLowerCase();
|
||
for (let i = 0; i < hex.length / 2; i++) {
|
||
bytes.set(seg.offset + i, hex.substr(i * 2, 2));
|
||
}
|
||
end = Math.max(end, seg.offset + Math.floor(hex.length / 2));
|
||
}
|
||
const covered = (list, pos) => (list || []).some(([p, n]) => pos >= p && pos < p + n);
|
||
// The filler stops at the known file/record length (a short file must not
|
||
// look like it has missing data) — and never hides observed bytes: when
|
||
// the FCP understates the layout (a 2-byte record_len for a 44-byte
|
||
// record read) the data's own end is the limit. Without a known length
|
||
// the row also ends at the data instead of inventing bytes.
|
||
const stopAt = size && size >= end ? size : end;
|
||
const rows = [];
|
||
const used = {changed: false, fresh: false, unknown: false};
|
||
let truncated = false;
|
||
for (let off = 0; off < end; off += 16) {
|
||
if (off >= maxBytes) {
|
||
truncated = true;
|
||
break;
|
||
}
|
||
const cells = [];
|
||
for (let i = 0; i < 16; i++) {
|
||
const pos = off + i;
|
||
// Never pad past the known file/record length: the filler would
|
||
// read as missing data when the file is simply short. '--'
|
||
// still marks bytes inside the file that were never observed.
|
||
if (stopAt && pos >= stopAt) break;
|
||
const hex = bytes.get(pos);
|
||
if (hex === undefined) {
|
||
cells.push({hex: '--', cls: 'hex-unknown'});
|
||
used.unknown = true;
|
||
continue;
|
||
}
|
||
const cls = covered(ranges, pos) ? 'hex-changed'
|
||
: covered(observed, pos) ? 'hex-new' : '';
|
||
if (cls === 'hex-changed') used.changed = true;
|
||
else if (cls === 'hex-new') used.fresh = true;
|
||
cells.push({hex: hex, cls: cls});
|
||
}
|
||
rows.push({offset: off, cells: cells});
|
||
}
|
||
return {rows: rows, truncated: truncated, end: end, used: used};
|
||
}
|
||
|
||
// One side of the change view: the hex rows plus the marks actually used, so
|
||
// the caller can show only the legend entries that apply.
|
||
function hexImageHtml(image, key) {
|
||
const segments = image[key] || [];
|
||
const asRecord = image.record !== null && image.record !== undefined;
|
||
const size = asRecord ? image.record_len : image.size;
|
||
const view = hexImageRows(segments, image.ranges, image.observed_ranges,
|
||
undefined, size);
|
||
// The offset column only helps when the view spans more than one row.
|
||
const addr = view.rows.length > 1;
|
||
const used = view.used;
|
||
let body = view.rows.map(r =>
|
||
`<div class="hex-row">` +
|
||
(addr ? `<span class="hex-addr">${r.offset.toString(16).toUpperCase().padStart(4, '0')}</span>` : '') +
|
||
r.cells.map(c => `<span class="hex-cell ${c.cls}">${c.hex}</span>`).join('') +
|
||
'</div>'
|
||
).join('');
|
||
if (!view.rows.length) {
|
||
body = `<div class="hex-row hex-unknown">${t('never observed')}</div>`;
|
||
used.unknown = true;
|
||
}
|
||
const html = `<div class="hex-view">${body}</div>` +
|
||
(view.truncated ? `<div class="hex-note">${t('Showing the first {n} bytes of the image').replace('{n}', 2048)}</div>` : '');
|
||
return {html: html, used: used};
|
||
}
|
||
|
||
async function renderFileView() {
|
||
const view = el('file-view');
|
||
if (STATE.sessionViewId === null) {
|
||
view.innerHTML = `<div class="empty-state" style="height:auto;padding:24px;">${t('Open a saved session from the Sessions tab.')}</div>`;
|
||
return;
|
||
}
|
||
const sid = STATE.sessionViewId;
|
||
let data = STATE.filesData;
|
||
// A failure is never cached: only a well-formed payload is remembered, so
|
||
// the next visit retries instead of showing a stale error.
|
||
if (data === null || STATE.filesSessionId !== sid ||
|
||
!Array.isArray(data.timeline)) {
|
||
view.innerHTML = `<div class="empty-state" style="height:auto;padding:24px;">${t('Loading…')}</div>`;
|
||
try {
|
||
const payload = await API.getSessionFiles(sid);
|
||
if (STATE.sessionViewId !== sid) return; // the view moved on
|
||
if (!payload || !Array.isArray(payload.timeline)) {
|
||
throw new Error((payload && payload.error) || t('Failed to load files'));
|
||
}
|
||
data = payload;
|
||
STATE.filesData = payload;
|
||
STATE.filesSessionId = sid;
|
||
} catch (e) {
|
||
if (STATE.sessionViewId !== sid) return;
|
||
STATE.filesData = null;
|
||
STATE.filesSessionId = null;
|
||
view.innerHTML = `<div class="empty-state" style="height:auto;padding:24px;">${t('Failed to load files')}: ${escapeHtml((e && e.message) || '')}</div>`;
|
||
return;
|
||
}
|
||
}
|
||
const timeline = data.timeline;
|
||
if (!timeline.length) {
|
||
view.innerHTML = `<div class="empty-state" style="height:auto;padding:24px;">${t('No file operations recorded')}</div>`;
|
||
return;
|
||
}
|
||
view.innerHTML = timeline.map(op => {
|
||
const diff = op.change_ref
|
||
? `<button class="file-op-diff" data-diff="${escapeHtml(op.change_ref.file)}|${op.change_ref.idx}" title="${t('Show the change')}">${op.kind === 'card_change' ? t('Changed on card!') : t('Show diff')}</button>`
|
||
: '';
|
||
return `<div class="file-op ${fileOpClass(op.kind)}" data-idx="${op.index}">
|
||
<span>${escapeHtml(fileOpLabel(op))}</span>${diff}
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function showFileDiff(fileKey, idx) {
|
||
const data = STATE.filesData;
|
||
const entry = data && data.files ? data.files[fileKey] : null;
|
||
if (!entry) return;
|
||
el('file-diff-sub').textContent = entry.name;
|
||
el('file-diff-body').innerHTML = `<p class="hex-note">${t('Loading…')}</p>`;
|
||
el('file-diff-modal').classList.add('show');
|
||
const cacheKey = `${STATE.sessionViewId}|${fileKey}|${idx}`;
|
||
let image = STATE.filesImages[cacheKey];
|
||
try {
|
||
if (!image) {
|
||
image = await API.getSessionFileImage(STATE.sessionViewId, fileKey, idx);
|
||
if (!image || !image.before) {
|
||
throw new Error((image && image.error) || t('Failed to load files'));
|
||
}
|
||
STATE.filesImages[cacheKey] = image;
|
||
}
|
||
} catch (e) {
|
||
el('file-diff-body').innerHTML =
|
||
`<p class="hex-error">${t('Failed to load files')}: ${escapeHtml((e && e.message) || '')}</p>`;
|
||
return;
|
||
}
|
||
renderFileDiff(image);
|
||
}
|
||
|
||
function renderFileDiff(image) {
|
||
const changed = (image.ranges || []).reduce((n, r) => n + r[1], 0);
|
||
const fresh = (image.observed_ranges || []).reduce((n, r) => n + r[1], 0);
|
||
const meta = [];
|
||
if (image.op) meta.push(image.op);
|
||
if (image.record !== null && image.record !== undefined) {
|
||
meta.push(`${t('Record')} ${image.record}` +
|
||
(image.n_records ? ` / ${image.n_records}` : ''));
|
||
}
|
||
if (image.index !== null && image.index !== undefined) {
|
||
meta.push(`${t('APDU')} #${image.index}`);
|
||
}
|
||
if (image.elapsed !== null && image.elapsed !== undefined) {
|
||
meta.push(`${fmt_elapsed(image.elapsed)} ${t('sec')}`);
|
||
}
|
||
el('file-diff-sub').textContent = `${image.path || image.name || ''} — ${meta.join(' · ')}`;
|
||
const before = hexImageHtml(image, 'before');
|
||
const after = hexImageHtml(image, 'after');
|
||
const used = {
|
||
changed: before.used.changed || after.used.changed,
|
||
fresh: before.used.fresh || after.used.fresh,
|
||
unknown: before.used.unknown || after.used.unknown,
|
||
};
|
||
// Only the marks the views actually show get a legend entry (a short
|
||
// file has no '--' filler, so 'never observed' would be misleading).
|
||
const legend = [
|
||
used.changed ? `<span class="hex-legend-item"><span class="hex-cell hex-changed">aa</span> ${t('changed')}</span>` : '',
|
||
used.fresh ? `<span class="hex-legend-item"><span class="hex-cell hex-new">aa</span> ${t('newly observed')}</span>` : '',
|
||
used.unknown ? `<span class="hex-legend-item"><span class="hex-cell hex-unknown">--</span> ${t('never observed')}</span>` : '',
|
||
].join('');
|
||
// Mention only what happened: "0 bytes newly observed" is noise.
|
||
const counts = [];
|
||
if (changed) counts.push(t('{c} bytes changed').replace('{c}', changed));
|
||
if (fresh) counts.push(t('{n} bytes newly observed').replace('{n}', fresh));
|
||
el('file-diff-body').innerHTML =
|
||
`<div class="hex-summary">${counts.length ? counts.join(' · ') : t('No changes')}</div>
|
||
<div class="hex-pair">
|
||
<div><h4>${t('Before')}</h4>${before.html}</div>
|
||
<div><h4>${t('After')}</h4>${after.html}</div>
|
||
</div>` +
|
||
(legend ? `<div class="hex-legend">${legend}</div>` : '') +
|
||
(image.image_truncated ? `<p class="hex-note">${t('The image is frozen (size cap reached) — later changes are not tracked.')}</p>` : '');
|
||
}
|
||
|
||
// Load the pages up to *idx* (the message list is fetched in pages) and focus
|
||
// the message — used by the Files timeline rows.
|
||
async function jumpToMessage(idx) {
|
||
if (STATE.sessionViewId === null || idx === null || idx === undefined) return;
|
||
try {
|
||
while (STATE.messages.length <= idx) {
|
||
const data = await API.getSession(STATE.sessionViewId, STATE.messages.length);
|
||
if (!data.messages || !data.messages.length) break;
|
||
STATE.messages.push(...data.messages);
|
||
}
|
||
// Render without the stick-to-bottom: focusMessage() scrolls to the
|
||
// record (the render only materializes its element).
|
||
renderMessages(true);
|
||
focusMessage(idx);
|
||
} catch (e) { /* keep the current view */ }
|
||
}
|
||
|
||
function mapFilesHtml(g) {
|
||
const files = g.files || [];
|
||
if (files.length === 0) return '—';
|
||
if (files.length <= 3) return files.join(', ');
|
||
const expanded = STATE.mapExpanded.has(g.start);
|
||
const label = expanded ? files.join(', ') : t('{n} files').replace('{n}', files.length);
|
||
return `<span class="map-files-toggle" data-start="${g.start}">${label} <span class="map-arrow">${expanded ? '▾' : '▸'}</span></span>`;
|
||
}
|
||
|
||
// ──────────────────── Command filter ────────────────────
|
||
// The filter holds a set of entries — a conversation preset or a single
|
||
// command label ("cmd:<label>") — and an empty set means "all commands".
|
||
const FILTER_PRESETS = [
|
||
{
|
||
id: 'preset:proactive', name: 'Proactive sessions',
|
||
// FETCH / TERMINAL PROFILE plus every proactive-session initiator:
|
||
// a command the card answers with SW 91xx.
|
||
labels: ['FETCH', 'TERMINAL PROFILE'],
|
||
test: m => _sw1(m) === 0x91,
|
||
},
|
||
{
|
||
id: 'preset:files', name: 'File operations',
|
||
labels: ['SELECT', 'READ BINARY', 'READ RECORD', 'UPDATE BINARY',
|
||
'UPDATE RECORD'],
|
||
test: m => ['a4', 'b0', 'b2', 'd6', 'dc'].includes(_ins_hex(m)),
|
||
},
|
||
];
|
||
|
||
function _sw1(m) {
|
||
const sw = (m.decoded || {}).sw;
|
||
return sw && sw.sw1 ? parseInt(sw.sw1, 16) : null;
|
||
}
|
||
|
||
function filterPreset(id) {
|
||
return FILTER_PRESETS.find(p => p.id === id) || null;
|
||
}
|
||
|
||
function filterEntryName(entry) {
|
||
const p = filterPreset(entry);
|
||
return p ? t(p.name) : entry.slice(4);
|
||
}
|
||
|
||
function filterSummary() {
|
||
if (!STATE.filter.size) return t('All commands');
|
||
if (STATE.filter.size === 1) return filterEntryName([...STATE.filter][0]);
|
||
return t('Selected: {n}').replace('{n}', STATE.filter.size);
|
||
}
|
||
|
||
let _filterMenuSig = null;
|
||
|
||
// Rebuild the dropdown's rows (presets, then every command seen in the
|
||
// session with its count). The DOM is only rewritten when the command set
|
||
// changed or the menu is open; the summary button always follows the state.
|
||
function renderFilterMenu(force) {
|
||
const menu = el('filter-menu');
|
||
if (!menu) return;
|
||
const counts = {};
|
||
for (const m of STATE.messages) {
|
||
const lbl = filter_label(m);
|
||
counts[lbl] = (counts[lbl] || 0) + 1;
|
||
}
|
||
const keys = Object.keys(counts).sort((a, b) => a.localeCompare(b));
|
||
const sig = keys.join('\n');
|
||
if (!force && menu.style.display === 'none' && sig === _filterMenuSig) {
|
||
el('filter-btn').textContent = filterSummary();
|
||
return;
|
||
}
|
||
_filterMenuSig = sig;
|
||
const item = (value, label, checked, count) =>
|
||
`<label class="filter-item"><input type="checkbox" data-filter="${escapeHtml(value)}"` +
|
||
`${checked ? ' checked' : ''}> ${escapeHtml(label)}` +
|
||
`${count === undefined ? '' : `<span class="filter-count">${count}</span>`}</label>`;
|
||
menu.innerHTML = [
|
||
item('all', t('All commands'), !STATE.filter.size),
|
||
`<div class="filter-heading">${t('Presets')}</div>`,
|
||
...FILTER_PRESETS.map(p => item(p.id, t(p.name), STATE.filter.has(p.id))),
|
||
'<div class="filter-sep"></div>',
|
||
`<div class="filter-heading">${t('Commands')}</div>`,
|
||
...keys.map(k => item('cmd:' + k, k, STATE.filter.has('cmd:' + k),
|
||
counts[k])),
|
||
].join('');
|
||
el('filter-btn').textContent = filterSummary();
|
||
}
|
||
|
||
function toggleFilterMenu(force) {
|
||
const menu = el('filter-menu');
|
||
if (!menu) return;
|
||
const open = force === undefined ? menu.style.display === 'none' : !!force;
|
||
if (open) renderFilterMenu(true);
|
||
menu.style.display = open ? '' : 'none';
|
||
el('filter-btn').setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
}
|
||
|
||
function setFilterEntry(entry, on) {
|
||
if (entry === 'all') STATE.filter.clear();
|
||
else if (on) STATE.filter.add(entry);
|
||
else STATE.filter.delete(entry);
|
||
renderFilterMenu(true);
|
||
renderMessages();
|
||
}
|
||
|
||
function clearFilter() {
|
||
if (!STATE.filter.size) return;
|
||
STATE.filter.clear();
|
||
renderFilterMenu(true);
|
||
}
|
||
|
||
function scrollToType(typeKey) {
|
||
const apduList = el('apdu-list');
|
||
let targetEl = null;
|
||
for (const el of apduList.querySelectorAll('.apdu-entry')) {
|
||
const msgIdx = parseInt(el.dataset.idx);
|
||
const msg = STATE.messages[msgIdx];
|
||
if (msg && stat_label(msg) === typeKey) {
|
||
targetEl = el;
|
||
break;
|
||
}
|
||
}
|
||
if (targetEl) {
|
||
STATE.selectedIdx = parseInt(targetEl.dataset.idx);
|
||
targetEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||
document.querySelectorAll('.apdu-entry.selected').forEach(e => e.classList.remove('selected'));
|
||
targetEl.classList.add('selected');
|
||
}
|
||
}
|
||
|
||
function focusMessage(idx) {
|
||
const searchEl = el('search-input');
|
||
const needRender = STATE.filter.size > 0 || searchEl.value !== '';
|
||
clearFilter();
|
||
searchEl.value = '';
|
||
// The focused entry stays selected until another one is focused; the
|
||
// state lets a re-render (the live poll included) keep it marked.
|
||
STATE.selectedIdx = idx;
|
||
if (needRender) renderMessages(true);
|
||
const entry = el('apdu-list').querySelector(`.apdu-entry[data-idx="${idx}"]`);
|
||
if (!entry) return;
|
||
entry.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||
showDetail(STATE.messages[idx], entry);
|
||
}
|
||
|
||
// ──────────────────── Render Trace ────────────────────
|
||
function isOriginalCommand(i) {
|
||
if (STATE.messages[i].type !== 'tpdu') return false;
|
||
for (let j = i + 1; j < STATE.messages.length; j++) {
|
||
if (STATE.messages[j].type !== 'tpdu') continue;
|
||
const d = STATE.messages[j].decoded;
|
||
return !!d && d.ins_name === 'GET RESPONSE' && !!d.response_for;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function matchesFilter(m, i, filterSet) {
|
||
if (!filterSet || !filterSet.size) return true;
|
||
const label = filter_label(m);
|
||
const d = m.decoded;
|
||
const isGR = m.type === 'tpdu' && d && d.ins_name === 'GET RESPONSE';
|
||
const origin = isGR ? d.response_for : null;
|
||
for (const entry of filterSet) {
|
||
const preset = filterPreset(entry);
|
||
if (preset) {
|
||
// A preset covers the commands it names and the rows its rule
|
||
// matches (SW 91xx / the file-operation INS set); the GET
|
||
// RESPONSE that carries a selected command's data comes along.
|
||
if (preset.test(m) || preset.labels.includes(label) ||
|
||
(origin && preset.labels.includes(origin))) return true;
|
||
continue;
|
||
}
|
||
const want = entry.slice(4);
|
||
if (want === label) return true;
|
||
// A GET RESPONSE belongs to the command that fetched it; selecting
|
||
// GET RESPONSE also shows those commands.
|
||
if (origin && want === origin) return true;
|
||
if (want === 'GET RESPONSE' && isOriginalCommand(i)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function renderTraceEmptyState() {
|
||
if (STATE.captureActive || STATE.sessionViewId) return;
|
||
const list = el('apdu-list');
|
||
const connCls = STATE.serverAvailable ? 'connected' : 'disconnected';
|
||
const modeText = captureModeText();
|
||
const modeHtml = modeText
|
||
? `<div class="capture-mode ${STATE.captureMode}">${modeText}</div>`
|
||
: '';
|
||
const action = STATE.captureMode === 'disabled'
|
||
? `<p style="font-size:12px;">${t('Open a saved session from the Sessions tab.')}</p>`
|
||
: `<button id="trace-start-btn">${t('Start Capture')}</button>`;
|
||
list.innerHTML = `<div class="empty-state">
|
||
<div class="conn-status ${connCls}">${connectionStatusText()}</div>
|
||
${modeHtml}
|
||
${action}
|
||
</div>`;
|
||
const btn = $('#trace-start-btn', list);
|
||
if (btn) btn.addEventListener('click', startCapture);
|
||
updateStatus();
|
||
}
|
||
|
||
// Content-follow decision for the APDU list: pin the view to the newest
|
||
// messages only when the reader is already at the bottom. An explicit jump
|
||
// (*skipScroll*) never sticks, and a freshly loaded session/capture
|
||
// (*forceStick*) always starts at the bottom.
|
||
function shouldStickToBottom(list, skipScroll, forceStick) {
|
||
if (forceStick) return true;
|
||
if (skipScroll) return false;
|
||
return list.scrollTop + list.clientHeight >= list.scrollHeight - 40;
|
||
}
|
||
|
||
function renderMessages(skipScroll, forceStick) {
|
||
const list = el('apdu-list');
|
||
// Read the geometry before the DOM is touched: adding messages grows
|
||
// scrollHeight, which would hide the fact that the reader was at the end.
|
||
const stick = shouldStickToBottom(list, skipScroll, forceStick);
|
||
const searchQ = el('search-input').value.toLowerCase();
|
||
const filterSet = STATE.filter;
|
||
|
||
const filtered = [];
|
||
for (let i = 0; i < STATE.messages.length; i++) {
|
||
const m = STATE.messages[i];
|
||
if (!matchesFilter(m, i, filterSet)) continue;
|
||
if (searchQ && !m.data.toLowerCase().includes(searchQ)) continue;
|
||
filtered.push({ m, idx: i });
|
||
}
|
||
|
||
buildTypeCounts();
|
||
buildTraceMap();
|
||
renderSidePanel();
|
||
renderFilterMenu();
|
||
|
||
if (!STATE.captureActive && !STATE.sessionViewId) {
|
||
renderTraceEmptyState();
|
||
return;
|
||
}
|
||
|
||
const selectedIdx = STATE.selectedIdx;
|
||
list.innerHTML = filtered.map(({ m, idx }) => {
|
||
const selCls = idx === selectedIdx ? ' selected' : '';
|
||
if (m.type === 'gap') {
|
||
return `<div class="apdu-entry apdu-gap${selCls}" data-idx="${idx}">
|
||
<span class="apdu-time">${fmt_elapsed(m.elapsed)}</span>
|
||
<span class="apdu-type">⚠ CAPTURE GAP</span>
|
||
<span class="apdu-data">${cmd_description(m)}</span>
|
||
</div>`;
|
||
}
|
||
const label = ins_label(m);
|
||
const cat = command_cat(_ins_hex(m));
|
||
const typeCls = (m.type === 'atr' ? 'atr' : m.type === 'pps' ? 'pps'
|
||
: m.type === 'rst' ? 'rst' : m.type === 'vcc' ? 'vcc' : '') +
|
||
(cat ? ' cat-' + cat : '');
|
||
const errs = error_names(m);
|
||
const errBadge = errs ? `<span class="apdu-err" title="${errs.join(', ')}">⚠</span>` : '';
|
||
// Command + GET RESPONSE pairs: subtle tint on both members,
|
||
// ↳ prefix marks the response as a continuation of the previous
|
||
// command (server sets response_for when prior SW1 was '61').
|
||
const isGR = m.type === 'tpdu' && m.decoded &&
|
||
m.decoded.ins_name === 'GET RESPONSE' && m.decoded.response_for;
|
||
const pairCls = (isGR || isOriginalCommand(idx)) ? ' apdu-pair' : '';
|
||
const grLabel = isGR ? '↳ ' + label : label;
|
||
return `<div class="apdu-entry${pairCls}${selCls}" data-idx="${idx}">
|
||
<span class="apdu-time">${fmt_elapsed(m.elapsed)}</span>
|
||
<span class="apdu-type ${typeCls}">${grLabel}</span>
|
||
<span class="apdu-sw ${sw_class(m)}">${sw_text(m)}</span>
|
||
<span class="apdu-data">${cmd_description(m)}</span>
|
||
${errBadge}
|
||
</div>`;
|
||
}).join('');
|
||
|
||
if (stick) list.scrollTop = list.scrollHeight;
|
||
updateStatus();
|
||
}
|
||
|
||
function $(selector, root) { return (root || document).querySelector(selector); }
|
||
|
||
function updateStatus() {
|
||
const dot = el('status-dot');
|
||
const text = el('status-text');
|
||
const count = el('msg-count');
|
||
|
||
dot.className = 'status-dot';
|
||
if (STATE.captureActive) {
|
||
dot.classList.add('live');
|
||
text.textContent = t('Live');
|
||
} else if (STATE.sessionViewId || STATE.currentSessionId) {
|
||
dot.classList.add('paused');
|
||
text.textContent = STATE.currentSessionName || t('Session');
|
||
} else {
|
||
dot.classList.add('off');
|
||
text.textContent = t('Not connected');
|
||
}
|
||
count.textContent = t('{n} messages').replace('{n}', STATE.messages.length);
|
||
updateDeviceWarn();
|
||
updateCaptureChip();
|
||
}
|
||
|
||
function updateCaptureChip() {
|
||
const chip = el('capture-chip');
|
||
if (!chip) return;
|
||
const disconnected = STATE.captureMode === 'direct' && STATE.captureActive && STATE.deviceConnected === false;
|
||
if (disconnected) {
|
||
chip.style.display = '';
|
||
chip.className = 'capture-chip warn';
|
||
chip.textContent = '⚠ ' + t('device disconnected — reconnecting…');
|
||
} else if (STATE.captureActive) {
|
||
chip.style.display = '';
|
||
chip.className = 'capture-chip live';
|
||
chip.textContent = '● ' + t('LIVE') + ' · ' + t('{n} messages').replace('{n}', STATE.messages.length);
|
||
} else {
|
||
chip.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function updateDeviceWarn() {
|
||
const warn = el('device-warn');
|
||
if (!warn) return;
|
||
const disconnected = STATE.captureMode === 'direct' && STATE.captureActive && STATE.deviceConnected === false;
|
||
if (disconnected) {
|
||
warn.style.display = '';
|
||
el('device-warn-text').textContent = t('device disconnected — reconnecting…');
|
||
} else {
|
||
warn.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// ──────────────────── Live Polling ────────────────────
|
||
let _lastAfter = 0;
|
||
|
||
function startPolling() {
|
||
if (STATE.livePoller) return;
|
||
STATE.livePoller = setInterval(async () => {
|
||
try {
|
||
const data = await API.getLatest(_lastAfter);
|
||
STATE.deviceConnected = data.device_connected;
|
||
if (data.active) {
|
||
STATE.captureActive = true;
|
||
STATE.currentSessionId = data.session_id;
|
||
}
|
||
if (data.messages && data.messages.length > 0) {
|
||
STATE.messages.push(...data.messages);
|
||
_lastAfter = data.next_after;
|
||
renderMessages();
|
||
}
|
||
if (!data.active && STATE.captureActive) {
|
||
STATE.captureActive = false;
|
||
renderMessages();
|
||
}
|
||
} catch (e) {}
|
||
}, 500);
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (STATE.livePoller) {
|
||
clearInterval(STATE.livePoller);
|
||
STATE.livePoller = null;
|
||
}
|
||
}
|
||
|
||
// ──────────────────── Capture ────────────────────
|
||
async function startCapture() {
|
||
if (STATE.captureMode === 'disabled') return;
|
||
try {
|
||
const data = await API.startCapture();
|
||
STATE.captureActive = true;
|
||
STATE.currentSessionId = data.session_id;
|
||
STATE.sessionStarted = data.started;
|
||
STATE.messages = [];
|
||
STATE.selectedIdx = null;
|
||
_lastAfter = 0;
|
||
STATE.mapExpanded.clear();
|
||
buildTypeCounts();
|
||
buildTraceMap();
|
||
renderSidePanel();
|
||
renderMessages(false, true);
|
||
el('apdu-detail').innerHTML = `<div class="detail-empty" data-l10n="Click an APDU to see details">${t('Click an APDU to see details')}</div>`;
|
||
updateCaptureButton();
|
||
startPolling();
|
||
} catch (e) {
|
||
alert(t('Failed to start capture: ') + e.message);
|
||
}
|
||
}
|
||
|
||
// Stopping when nothing is capturing answers 400 ('No active capture'), and
|
||
// 403 when capture is disabled — both mean "there is no capture to stop",
|
||
// not a failure.
|
||
async function stopCaptureIfRunning() {
|
||
try {
|
||
await API.stopCapture();
|
||
} catch (e) { /* nothing to stop */ }
|
||
}
|
||
|
||
async function stopCapture() {
|
||
await stopCaptureIfRunning();
|
||
STATE.captureActive = false;
|
||
STATE.currentSessionId = null;
|
||
STATE.sessionViewId = null;
|
||
updateCaptureButton();
|
||
updateStatus();
|
||
}
|
||
|
||
function updateCaptureButton() {
|
||
const btn = el('capture-btn');
|
||
const newCapture = el('new-capture-btn');
|
||
btn.classList.remove('stop', 'connect', 'disabled');
|
||
const captureOff = STATE.captureMode === 'disabled' && STATE.serverAvailable;
|
||
newCapture.style.display = captureOff ? 'none' : '';
|
||
btn.style.display = '';
|
||
if (!STATE.serverAvailable) {
|
||
btn.textContent = t('Connect to server');
|
||
btn.classList.add('connect');
|
||
btn.disabled = false;
|
||
btn.title = '';
|
||
} else if (captureOff) {
|
||
btn.textContent = t('Start Capture');
|
||
btn.classList.add('disabled');
|
||
btn.disabled = true;
|
||
btn.title = t('Capture is disabled — only pre-captured traces (saved sessions / PCAP import) are available.');
|
||
} else if (STATE.captureActive) {
|
||
btn.textContent = t('Stop Capture');
|
||
btn.classList.add('stop');
|
||
btn.disabled = false;
|
||
btn.title = '';
|
||
} else {
|
||
btn.textContent = t('Start Capture');
|
||
btn.disabled = false;
|
||
btn.title = '';
|
||
}
|
||
}
|
||
|
||
// ──────────────────── Sessions ────────────────────
|
||
let SESSION_VIEW = localStorage.getItem('session-view') || 'grid';
|
||
|
||
function toggleSessionView(mode) {
|
||
SESSION_VIEW = mode;
|
||
localStorage.setItem('session-view', mode);
|
||
el('view-grid-btn').classList.toggle('active', mode === 'grid');
|
||
el('view-list-btn').classList.toggle('active', mode === 'list');
|
||
el('sessions-grid').style.display = mode === 'grid' ? '' : 'none';
|
||
el('sessions-list').style.display = mode === 'list' ? '' : 'none';
|
||
loadSessions();
|
||
}
|
||
|
||
function buildGridHTML(sessions) {
|
||
return sessions.map(s => {
|
||
const name = s.name || t('Session #') + s.id;
|
||
const dStart = s.started ? s.started.replace('T', ' ') : '?';
|
||
const duration = s.ended ? formatDuration(s.started, s.ended) : t('active');
|
||
return `<div class="session-card" data-id="${s.id}">
|
||
<div class="session-name">
|
||
<input type="text" value="${escapeHtml(name)}" data-sid="${s.id}" placeholder="${t('Session name...')}">
|
||
</div>
|
||
<div class="session-meta">
|
||
<span>${dStart}</span>
|
||
<span>${duration}</span>
|
||
<span>${s.message_count} ${t('msgs')}</span>
|
||
<span>${s.mode}</span>
|
||
</div>
|
||
<div class="session-actions">
|
||
<a class="session-download" href="${API_BASE()}/api/sessions/${s.id}/pcap">${t('Download PCAP')}</a>
|
||
${is_loopback() ? '' : `<button class="session-copy" data-sid="${s.id}">${t('Copy link')}</button>`}
|
||
<button class="session-delete" data-sid="${s.id}">${t('Delete')}</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function buildListHTML(sessions) {
|
||
return sessions.map(s => {
|
||
const name = s.name || t('Session #') + s.id;
|
||
const dStart = s.started ? s.started.replace('T', ' ') : '?';
|
||
const duration = s.ended ? formatDuration(s.started, s.ended) : t('active');
|
||
return `<tr data-id="${s.id}">
|
||
<td><input type="text" value="${escapeHtml(name)}" data-sid="${s.id}" placeholder="${t('Session name...')}"></td>
|
||
<td>${dStart}</td>
|
||
<td>${duration}</td>
|
||
<td>${s.message_count} ${t('msgs')}</td>
|
||
<td>${s.mode}</td>
|
||
<td class="session-actions">
|
||
<a class="session-download" href="${API_BASE()}/api/sessions/${s.id}/pcap">${t('Download PCAP')}</a>
|
||
${is_loopback() ? '' : `<button class="session-copy" data-sid="${s.id}">${t('Copy link')}</button>`}
|
||
<button class="session-delete" data-sid="${s.id}">${t('Delete')}</button>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
function attachSessionListeners(container) {
|
||
container.querySelectorAll('[data-id]').forEach(el => {
|
||
el.addEventListener('click', e => {
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON' || e.target.tagName === 'A') return;
|
||
loadSession(parseInt(el.dataset.id));
|
||
});
|
||
});
|
||
container.querySelectorAll('input[data-sid]').forEach(input => {
|
||
const originalName = input.value;
|
||
input.addEventListener('blur', async () => {
|
||
const sid = parseInt(input.dataset.sid);
|
||
const name = input.value.trim();
|
||
try {
|
||
await API.renameSession(sid, name);
|
||
} catch (e) {
|
||
input.value = originalName;
|
||
}
|
||
});
|
||
input.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') input.blur();
|
||
});
|
||
});
|
||
container.querySelectorAll('.session-delete').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
showDeleteModal(parseInt(btn.dataset.sid));
|
||
});
|
||
});
|
||
container.querySelectorAll('.session-copy').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
copySessionLink(parseInt(btn.dataset.sid));
|
||
});
|
||
});
|
||
}
|
||
|
||
async function loadSessions() {
|
||
try {
|
||
const data = await API.listSessions();
|
||
const grid = el('sessions-grid');
|
||
const list = el('sessions-list');
|
||
|
||
const total = data.total_sessions != null ? data.total_sessions : data.sessions.length;
|
||
const dbSize = data.db_size != null ? fmtSize(data.db_size) : '?';
|
||
const sessionsLabel = total === 1
|
||
? t('{n} session · {size}').replace('{n}', total).replace('{size}', dbSize)
|
||
: t('{n} session(s) · {size}').replace('{n}', total).replace('{size}', dbSize);
|
||
el('sessions-stats').textContent = sessionsLabel;
|
||
|
||
grid.style.display = SESSION_VIEW === 'grid' ? '' : 'none';
|
||
list.style.display = SESSION_VIEW === 'list' ? '' : 'none';
|
||
el('view-grid-btn').classList.toggle('active', SESSION_VIEW === 'grid');
|
||
el('view-list-btn').classList.toggle('active', SESSION_VIEW === 'list');
|
||
|
||
if (!data.sessions.length) {
|
||
const emptyHTML = `<div class="empty-state" style="height:auto; padding:40px;">
|
||
<p>${t('No saved sessions')}</p>
|
||
</div>`;
|
||
grid.innerHTML = emptyHTML;
|
||
list.innerHTML = emptyHTML;
|
||
return;
|
||
}
|
||
|
||
if (SESSION_VIEW === 'grid') {
|
||
grid.innerHTML = buildGridHTML(data.sessions);
|
||
} else {
|
||
list.querySelector('tbody').innerHTML = buildListHTML(data.sessions);
|
||
}
|
||
|
||
attachSessionListeners(SESSION_VIEW === 'grid' ? grid : list);
|
||
} catch (e) {}
|
||
}
|
||
|
||
// Import failure codes -> user-facing strings (the server answers the same
|
||
// text; the map is what gets translated).
|
||
const IMPORT_ERROR_KEYS = {
|
||
app_layer: "This is the 'ISO Application Layer' export — select the 'ISO Protocol Layer' file (it contains the raw APDU bytes).",
|
||
not_comprion: "Not a COMPRION export — expected the 'ISO Protocol Layer' text file.",
|
||
unsupported_layout: "Unsupported COMPRION export layout (expected the 'ISO Protocol Layer' table).",
|
||
no_data: 'No APDU exchanges found in the COMPRION export.',
|
||
times_unparsable: 'No usable Time entries found in the ISO Application Layer export.',
|
||
session_mismatch: 'The two files belong to different sessions — select the ISO Application Layer export of the same session.',
|
||
time_base_unverified: 'Cannot verify that the two files belong to the same session (no Session Name in either file).',
|
||
time_base_mismatch: 'The ISO Application Layer file does not match this trace (different exchange count).',
|
||
protocol_slot: "This is the ISO Protocol Layer export — select it in the first slot.",
|
||
identical: 'The two files are identical.',
|
||
comprion_export: "This looks like a COMPRION export — use 'Import COMPRION trace'.",
|
||
};
|
||
|
||
// Cheap pre-upload check of a COMPRION export's layer title: 'app_layer' /
|
||
// 'not_comprion' when the file is clearly the wrong one, '' to upload (the
|
||
// server performs the full layout validation).
|
||
function comprionFileCheck(text) {
|
||
const heads = [];
|
||
for (const line of text.split(/\r?\n/).slice(0, 12)) {
|
||
const cells = line.split('\t').map(c => c.trim());
|
||
heads.push(cells[0] || '');
|
||
}
|
||
if (heads.includes('ISO Application Layer')) return 'app_layer';
|
||
if (heads.includes('ISO Protocol Layer')) return '';
|
||
return 'not_comprion';
|
||
}
|
||
|
||
function importErrorText(e) {
|
||
const key = e && e.code && IMPORT_ERROR_KEYS[e.code];
|
||
return t(key || (e && e.message) || 'Import failed');
|
||
}
|
||
|
||
// Which slot accepts which export? *slot* is 'protocol' | 'application',
|
||
// *kind* is a comprionFileCheck() result. Returns '' when acceptable, or the
|
||
// error code to show.
|
||
function comprionSlotCheck(slot, kind) {
|
||
if (kind === 'not_comprion') return 'not_comprion';
|
||
if (slot === 'protocol' && kind === 'app_layer') return 'app_layer';
|
||
if (slot === 'application' && kind === '') return 'protocol_slot';
|
||
return '';
|
||
}
|
||
|
||
function importMsg(text, error) {
|
||
const msg = el('import-msg');
|
||
msg.classList.toggle('error', !!error);
|
||
msg.textContent = text;
|
||
}
|
||
|
||
function importBaseName(file) {
|
||
return file.name.replace(/\.[^.]+$/, '') || file.name;
|
||
}
|
||
|
||
function importSuccessText(data) {
|
||
let text = t('Imported \'{name}\' — {n} messages')
|
||
.replace('{name}', data.name).replace('{n}', data.message_count);
|
||
if (data.time_base === 'application-layer') {
|
||
text += ' · ' + t('exact timestamps (Application Layer)');
|
||
}
|
||
if (data.skipped) {
|
||
text += ' · ' + t('{n} packets skipped (not APDU-shaped)')
|
||
.replace('{n}', data.skipped);
|
||
}
|
||
return text;
|
||
}
|
||
|
||
async function finishImport(run) {
|
||
importMsg(t('Importing…'));
|
||
try {
|
||
const data = await run();
|
||
importMsg(importSuccessText(data));
|
||
loadSessions();
|
||
} catch (e) {
|
||
importMsg(importErrorText(e), true);
|
||
}
|
||
}
|
||
|
||
async function importPcapFile(file) {
|
||
await finishImport(async () =>
|
||
API.importSession(importBaseName(file), await file.arrayBuffer(), ''));
|
||
}
|
||
|
||
// ──────────────────── COMPRION import dialog ────────────────────
|
||
|
||
const COMPRION_DLG = { protocol: null, application: null, busy: false, error: '' };
|
||
|
||
function comprionSameFile(a, b) {
|
||
return !!a && !!b && a.name === b.name && a.size === b.size;
|
||
}
|
||
|
||
function renderComprionDialog() {
|
||
el('comprion-protocol-name').textContent =
|
||
COMPRION_DLG.protocol ? COMPRION_DLG.protocol.name : t('no file selected');
|
||
el('comprion-application-name').textContent =
|
||
COMPRION_DLG.application ? COMPRION_DLG.application.name : t('no file selected');
|
||
el('comprion-hint').textContent = COMPRION_DLG.application
|
||
? t('The ISO Application Layer export adds exact per-APDU timestamps (ms precision) — the session window and the gaps between APDUs become exact. Both files are verified to belong to the same session before importing.')
|
||
: t("Without the ISO Application Layer export the timeline is card/line activity time from the Protocol Layer's Delay column (the unit assumes 3.8 MHz) — the order is right, but host pauses are missing and the absolute times are not exact.");
|
||
el('comprion-error').textContent = COMPRION_DLG.error
|
||
? t(IMPORT_ERROR_KEYS[COMPRION_DLG.error] || COMPRION_DLG.error) : '';
|
||
const busy = COMPRION_DLG.busy;
|
||
el('comprion-protocol-btn').disabled = busy;
|
||
el('comprion-application-btn').disabled = busy;
|
||
el('comprion-cancel').disabled = busy;
|
||
el('comprion-import').disabled = busy || !COMPRION_DLG.protocol;
|
||
el('comprion-import').textContent = busy ? t('Importing…') : t('Import');
|
||
}
|
||
|
||
function openComprionDialog() {
|
||
COMPRION_DLG.protocol = null;
|
||
COMPRION_DLG.application = null;
|
||
COMPRION_DLG.busy = false;
|
||
COMPRION_DLG.error = '';
|
||
el('comprion-protocol-file').value = '';
|
||
el('comprion-application-file').value = '';
|
||
renderComprionDialog();
|
||
el('comprion-modal').classList.add('show');
|
||
}
|
||
|
||
function closeComprionDialog() {
|
||
if (COMPRION_DLG.busy) return;
|
||
el('comprion-modal').classList.remove('show');
|
||
}
|
||
|
||
async function comprionPick(slot, file) {
|
||
if (!file) return;
|
||
let kind = 'not_comprion';
|
||
try {
|
||
kind = comprionFileCheck(await file.slice(0, 8192).text());
|
||
} catch (e) { /* unreadable: treat as unrelated */ }
|
||
const other = slot === 'protocol' ? COMPRION_DLG.application : COMPRION_DLG.protocol;
|
||
if (comprionSameFile(file, other)) {
|
||
// The same export in both slots beats the generic slot mismatch.
|
||
COMPRION_DLG[slot] = null;
|
||
COMPRION_DLG.error = 'identical';
|
||
} else {
|
||
const error = comprionSlotCheck(slot, kind);
|
||
if (error) {
|
||
COMPRION_DLG[slot] = null;
|
||
COMPRION_DLG.error = error;
|
||
} else {
|
||
COMPRION_DLG[slot] = file;
|
||
COMPRION_DLG.error = '';
|
||
}
|
||
}
|
||
renderComprionDialog();
|
||
}
|
||
|
||
async function confirmComprionImport() {
|
||
if (COMPRION_DLG.busy || !COMPRION_DLG.protocol) return;
|
||
COMPRION_DLG.busy = true;
|
||
COMPRION_DLG.error = '';
|
||
renderComprionDialog();
|
||
const file = COMPRION_DLG.protocol;
|
||
const app = COMPRION_DLG.application;
|
||
try {
|
||
const data = app
|
||
? await API.importComprionBundle(importBaseName(file),
|
||
await file.text(), await app.text())
|
||
: await API.importSession(importBaseName(file),
|
||
await file.arrayBuffer(), 'comprion');
|
||
el('comprion-modal').classList.remove('show');
|
||
importMsg(importSuccessText(data));
|
||
loadSessions();
|
||
} catch (e) {
|
||
// Keep the dialog (and both selections) so the user can fix them.
|
||
COMPRION_DLG.error = (e && e.code && IMPORT_ERROR_KEYS[e.code])
|
||
? e.code : ((e && e.message) || 'Import failed');
|
||
} finally {
|
||
COMPRION_DLG.busy = false;
|
||
renderComprionDialog();
|
||
}
|
||
}
|
||
|
||
async function loadSession(id) {
|
||
if (STATE.captureActive && !confirm(t('Opening a session stops the active capture. Continue?'))) return;
|
||
try {
|
||
// Stop any active capture so it can't write into the session being
|
||
// viewed (a 400/403 means there is none — not an error).
|
||
await stopCaptureIfRunning();
|
||
STATE.captureActive = false;
|
||
STATE.currentSessionId = null;
|
||
stopPolling();
|
||
|
||
const data = await API.getSession(id, 0);
|
||
STATE.sessionViewId = id;
|
||
STATE.filesData = null;
|
||
STATE.filesSessionId = null;
|
||
STATE.filesImages = {};
|
||
STATE.selectedIdx = null;
|
||
STATE.sessionStarted = data.session.started;
|
||
STATE.captureActive = false;
|
||
STATE.currentSessionName = data.session.name || t('Session #') + id;
|
||
STATE.currentSessionRawName = data.session.name || null;
|
||
STATE.messages = data.messages;
|
||
STATE.mapExpanded.clear();
|
||
switchTab('trace');
|
||
updateCaptureButton();
|
||
renderMessages(false, true);
|
||
el('apdu-detail').innerHTML = `<div class="detail-empty" data-l10n="Click an APDU to see details">${t('Click an APDU to see details')}</div>`;
|
||
el('session-name-display').style.display = 'inline';
|
||
el('session-name-display').textContent = t('Session: ') + (data.session.name || '#' + id);
|
||
history.replaceState(null, '', '#session=' + id);
|
||
} catch (e) {
|
||
alert(t('Failed to load session'));
|
||
}
|
||
}
|
||
|
||
function formatDuration(start, end) {
|
||
if (!start || !end) return '?';
|
||
const ms = new Date(end) - new Date(start);
|
||
const s = Math.floor(ms / 1000);
|
||
if (s < 60) return s + 's';
|
||
const m = Math.floor(s / 60);
|
||
if (m < 60) return m + 'm ' + (s % 60) + 's';
|
||
const h = Math.floor(m / 60);
|
||
return h + 'h ' + (m % 60) + 'm';
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
function showDeleteModal(id) {
|
||
STATE.pendingDeleteId = id;
|
||
const s = document.querySelector(`.session-card[data-id="${id}"] input`);
|
||
el('delete-session-name').textContent = s ? s.value : t('Session #') + id;
|
||
el('delete-modal').classList.add('show');
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
const id = STATE.pendingDeleteId;
|
||
if (!id) return;
|
||
try {
|
||
await API.deleteSession(id);
|
||
el('delete-modal').classList.remove('show');
|
||
STATE.pendingDeleteId = null;
|
||
loadSessions();
|
||
} catch (e) {
|
||
alert(t('Failed to delete session'));
|
||
}
|
||
}
|
||
|
||
// ──────────────────── Settings ────────────────────
|
||
let _connTimer = null;
|
||
|
||
function targetSpace() {
|
||
let h;
|
||
try { h = new URL(STATE.serverUrl).hostname.toLowerCase(); } catch (e) { return 'public'; }
|
||
if (h === 'localhost' || h === '[::1]' || h === '::1' || /^127\./.test(h)) return 'loopback';
|
||
if (/^10\./.test(h) || /^192\.168\./.test(h) || /^172\.(1[6-9]|2\d|3[01])\./.test(h)) return 'private';
|
||
return 'public';
|
||
}
|
||
|
||
function isLikelyBlocked() {
|
||
const pagePublic = location.protocol === 'https:'
|
||
|| !(location.hostname === 'localhost' || /^127\./.test(location.hostname));
|
||
const s = targetSpace();
|
||
return pagePublic && (s === 'loopback' || s === 'private');
|
||
}
|
||
|
||
function syncServerSourceUi() {
|
||
const source = el('server-source');
|
||
const urlInput = el('server-url');
|
||
const custom = STATE.serverUrl !== '';
|
||
source.value = custom ? 'custom' : 'same';
|
||
urlInput.style.display = custom ? '' : 'none';
|
||
if (custom) urlInput.value = STATE.serverUrl;
|
||
}
|
||
|
||
function updateVersionWarn(serverVersion) {
|
||
const elv = el('version-warn');
|
||
const sv = parseVersion(serverVersion);
|
||
if (sv === null) {
|
||
elv.textContent = t('Server version unknown — update simtrace-analyser-server.');
|
||
return;
|
||
}
|
||
const cmpMin = compareVersions(serverVersion, MIN_SERVER_VERSION);
|
||
if (cmpMin !== null && cmpMin < 0) {
|
||
elv.textContent = t('Server version {v} is outdated. Please update simtrace-analyser-server.').replace('{v}', serverVersion);
|
||
return;
|
||
}
|
||
if (sv[0] > parseVersion(PWA_VERSION)[0]) {
|
||
elv.textContent = t('Server version {v} is newer than expected. Update SIMtrace Analyser for full compatibility.').replace('{v}', serverVersion);
|
||
return;
|
||
}
|
||
elv.textContent = '';
|
||
}
|
||
|
||
function captureModeText() {
|
||
switch (STATE.captureMode) {
|
||
case 'gsmtap':
|
||
return t('Capture mode: GSMTAP — the server is listening for incoming UDP/GSMTAP traffic. Ensure a GSMTAP source (e.g. simtrace2-pysniff or simtrace2-sniff) is feeding the server.');
|
||
case 'direct':
|
||
return t('Capture mode: direct — the server captures from SIMtrace2 hardware. Keep the device connected.');
|
||
case 'disabled':
|
||
return t('Capture is disabled — only pre-captured traces (saved sessions / PCAP import) are available.');
|
||
default:
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function connectionStatusText() {
|
||
if (STATE.serverAvailable) {
|
||
return '⬤ ' + t('Connected') + ' — ' + STATE.serverName + ' v' + STATE.serverVersion;
|
||
}
|
||
return '⬤ ' + t('Not connected — ensure the server is running on the URL above');
|
||
}
|
||
|
||
function updateCaptureModeLine() {
|
||
const elm = el('capture-mode');
|
||
const mode = STATE.captureMode;
|
||
elm.textContent = captureModeText();
|
||
elm.className = 'capture-mode' + (mode ? ' ' + mode : '');
|
||
}
|
||
|
||
function connectServer() {
|
||
if (_connTimer) return;
|
||
_connTimer = setInterval(checkConnection, 5000);
|
||
checkConnection();
|
||
}
|
||
|
||
function disconnectServer() {
|
||
if (STATE.captureActive && !confirm(t('Capture is still running on the server — it will continue after you disconnect. Disconnect?'))) return;
|
||
if (_connTimer) {
|
||
clearInterval(_connTimer);
|
||
_connTimer = null;
|
||
}
|
||
STATE.serverAvailable = false;
|
||
STATE.serverName = '';
|
||
STATE.serverVersion = '';
|
||
STATE.captureMode = '';
|
||
el('conn-status').textContent = '⬤ ' + t('Disconnected — click Connect to resume');
|
||
el('conn-status').className = 'conn-status disconnected';
|
||
el('capture-mode').textContent = '';
|
||
el('capture-mode').className = 'capture-mode';
|
||
el('version-warn').textContent = '';
|
||
el('conn-hint').textContent = '';
|
||
el('conn-instructions').innerHTML = '';
|
||
el('status-dot').className = 'status-dot off';
|
||
el('status-text').textContent = t('Not connected');
|
||
const btn = el('connect-btn');
|
||
btn.textContent = t('Connect');
|
||
btn.classList.add('disconnected');
|
||
updateCaptureButton();
|
||
stopPolling();
|
||
renderTraceEmptyState();
|
||
}
|
||
|
||
async function checkConnection() {
|
||
try {
|
||
const data = await API.status();
|
||
STATE.serverAvailable = true;
|
||
STATE.captureMode = data.capture_mode || '';
|
||
STATE.serverName = data.server || 'simtrace-analyser-server';
|
||
STATE.serverVersion = data.version || '';
|
||
STATE.deviceConnected = data.device_connected;
|
||
el('conn-status').textContent = connectionStatusText();
|
||
el('conn-status').className = 'conn-status connected';
|
||
updateVersionWarn(data.version);
|
||
updateCaptureModeLine();
|
||
el('conn-hint').textContent = '';
|
||
el('conn-instructions').innerHTML = '';
|
||
el('connect-btn').textContent = t('Disconnect');
|
||
el('connect-btn').classList.remove('disconnected');
|
||
el('status-dot').className = 'status-dot ' + (data.capture_active ? 'live' : 'paused');
|
||
el('status-text').textContent = data.capture_active ? t('Capturing') : t('Connected');
|
||
if (data.capture_active) {
|
||
if (data.session_id && data.session_id !== STATE.currentSessionId) {
|
||
// A different capture session than the one we were viewing —
|
||
// reset the stream so we re-fetch it from the start.
|
||
STATE.currentSessionId = data.session_id;
|
||
STATE.messages = [];
|
||
_lastAfter = 0;
|
||
STATE.mapExpanded.clear();
|
||
}
|
||
STATE.captureActive = true;
|
||
STATE.currentSessionId = data.session_id;
|
||
startPolling();
|
||
} else {
|
||
STATE.captureActive = false;
|
||
}
|
||
updateCaptureButton();
|
||
renderTraceEmptyState();
|
||
if (!STATE.deepLinkHandled) {
|
||
STATE.deepLinkHandled = true;
|
||
const m = location.hash.match(/^#session=(\d+)$/);
|
||
if (m) loadSession(parseInt(m[1]));
|
||
}
|
||
} catch (e) {
|
||
STATE.serverAvailable = false;
|
||
STATE.captureMode = '';
|
||
STATE.serverName = '';
|
||
STATE.serverVersion = '';
|
||
el('conn-status').textContent = connectionStatusText();
|
||
el('conn-status').className = 'conn-status disconnected';
|
||
updateCaptureModeLine();
|
||
el('version-warn').textContent = '';
|
||
const url = STATE.serverUrl || location.origin;
|
||
el('conn-hint').textContent = t(isLikelyBlocked() ? HINT_BLOCKED : HINT_UNREACHABLE).replace('{url}', url);
|
||
el('connect-btn').textContent = t('Connect');
|
||
el('connect-btn').classList.add('disconnected');
|
||
el('conn-instructions').innerHTML = `
|
||
<h3>${t('Setup')}</h3>
|
||
<ol>
|
||
<li>${t('Clone and install SIMtrace Analyser:')}<br>
|
||
<code>git clone https://github.com/anttro/simtrace-analyser.git</code><br>
|
||
<code>cd simtrace-analyser && pip install -e .</code></li>
|
||
<li>${t('Start the server in direct mode (recommended):')}<br>
|
||
<code>./start.sh --capture direct</code><br>
|
||
<span style="color:var(--text-muted);">${t('Requires SIMtrace2 hardware connected via USB; captures directly from the device.')}</span></li>
|
||
</ol>
|
||
<p style="margin-top:8px;">${t('Then set the URL above to http://127.0.0.1:8081 and return to the Trace tab.')}</p>
|
||
<h3 style="margin-top:20px;">${t('Alternative: GSMTAP mode')}</h3>
|
||
<p style="color:var(--text-muted); margin-top:4px;">${t('Use an external sniffer alongside the server. This mode is compatible with the original Osmocom simtrace2-sniff.')}</p>
|
||
<ol style="margin-top:4px;">
|
||
<li>${t('Start the server:')}<br>
|
||
<code>./start.sh --capture gsmtap</code></li>
|
||
<li>${t('Start a GSMTAP source in another terminal — either:')}<br>
|
||
<code>./sniff-start.sh --gsmtap 127.0.0.1</code> (simtrace2-pysniff)<br>
|
||
or<br>
|
||
<code>simtrace2-sniff --gsmtap-ip 127.0.0.1</code> ${t('(original Osmocom tool)')}</li>
|
||
</ol>`;
|
||
el('status-dot').className = 'status-dot off';
|
||
el('status-text').textContent = t('Not connected');
|
||
updateCaptureButton();
|
||
renderTraceEmptyState();
|
||
}
|
||
}
|
||
|
||
// ──────────────────── Tabs ────────────────────
|
||
function switchTab(name) {
|
||
STATE.activeTab = name;
|
||
document.querySelectorAll('.header-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
|
||
document.querySelectorAll('.panel').forEach(p => p.classList.toggle('active', p.id === 'panel-' + name));
|
||
if (name === 'sessions') loadSessions();
|
||
if (name === 'settings') checkConnection();
|
||
}
|
||
|
||
// ──────────────────── Search / Filter ────────────────────
|
||
let _searchTimer = null;
|
||
function onSearchChange() {
|
||
clearTimeout(_searchTimer);
|
||
_searchTimer = setTimeout(renderMessages, 200);
|
||
}
|
||
|
||
// ──────────────────── Versioning ────────────────────
|
||
const PWA_VERSION = '2.7.9';
|
||
const MIN_SERVER_VERSION = '2.3.0';
|
||
|
||
function parseVersion(v) {
|
||
const parts = String(v || '').trim().split('.').map(n => parseInt(n, 10));
|
||
if (parts.length < 2 || parts.some(isNaN)) return null;
|
||
return parts;
|
||
}
|
||
|
||
function compareVersions(a, b) {
|
||
const pa = parseVersion(a);
|
||
const pb = parseVersion(b);
|
||
if (pa === null || pb === null) return null;
|
||
for (let i = 0; i < 3; i++) {
|
||
const da = pa[i] || 0;
|
||
const db = pb[i] || 0;
|
||
if (da !== db) return da < db ? -1 : 1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// ──────────────────── i18n ────────────────────
|
||
let currentLang = 'en';
|
||
|
||
const HINT_BLOCKED = 'The browser likely blocked access to the local server. Allow local network access for this site (Chrome/Edge/Vivaldi: Site settings → Local network access → Allow) and confirm simtrace-analyser-server is running at {url}.';
|
||
const HINT_UNREACHABLE = 'Couldn\'t reach the server. Check that simtrace-analyser-server is running and the URL is correct ({url}). If this page is served over HTTPS, also allow local network access in the browser (Site settings → Local network access → Allow).';
|
||
|
||
const LANG_RU = {
|
||
'Trace': 'Трассировка',
|
||
'Sessions': 'Сессии',
|
||
'Settings': 'Настройки',
|
||
'trace analyser': 'анализатор трейсов',
|
||
'Install PWA': 'Установить PWA',
|
||
'Search hex bytes...': 'Поиск по данным (hex)...',
|
||
'All commands': 'Все команды',
|
||
'Presets': 'Пресеты',
|
||
'Commands': 'Команды',
|
||
'Proactive sessions': 'Проактивные сессии',
|
||
'File operations': 'Файловые операции',
|
||
'Selected: {n}': 'Выбрано: {n}',
|
||
'Filter': 'Фильтр',
|
||
'Start Capture': 'Начать захват',
|
||
'Stop Capture': 'Остановить захват',
|
||
'Connect to server': 'Подключиться к серверу',
|
||
'Click an APDU to see details': 'Нажмите на APDU для просмотра деталей',
|
||
'Command Stats': 'Статистика команд',
|
||
'Stats': 'Статистика',
|
||
'Map': 'Обзор',
|
||
'Files': 'Файлы',
|
||
'Read': 'Чтение',
|
||
'Update': 'Запись',
|
||
'Changed on card!': 'Изменено на карте!',
|
||
'Show diff': 'Изменения',
|
||
'Change details': 'Подробности изменения',
|
||
'changed': 'изменено',
|
||
'newly observed': 'впервые наблюдается',
|
||
'never observed': 'не наблюдалось',
|
||
'{c} bytes changed': 'Изменено байт: {c}',
|
||
'{n} bytes newly observed': 'Впервые наблюдается байт: {n}',
|
||
'No changes': 'Изменений нет',
|
||
'APDU': 'APDU',
|
||
'sec': 'с',
|
||
'Showing the first {n} bytes of the image': 'Показаны первые {n} байт образа',
|
||
'The image is frozen (size cap reached) — later changes are not tracked.': 'Образ заморожен (достигнут предел размера) — последующие изменения не отслеживаются.',
|
||
'Close': 'Закрыть',
|
||
'Offset': 'Смещение',
|
||
'Record': 'Запись №',
|
||
'Lc (command data length)': 'Lc (длина данных команды)',
|
||
'Le (expected response length)': 'Le (ожидаемая длина ответа)',
|
||
'Before': 'Было',
|
||
'After': 'Стало',
|
||
'Show the change': 'Показать изменение',
|
||
'No file operations recorded': 'Операции с файлами не зафиксированы',
|
||
'Failed to load files': 'Не удалось загрузить список файлов',
|
||
'Loading…': 'Загрузка…',
|
||
'Reads:': 'Чтение:',
|
||
'Writes:': 'Запись:',
|
||
'Read/write:': 'Чтение/запись:',
|
||
'{n} files': '{n} файлов',
|
||
'Not connected': 'Не подключено',
|
||
'Live': 'Захват',
|
||
'Session': 'Сессия',
|
||
'Connected': 'Подключено',
|
||
'Capturing': 'Захват',
|
||
'No capture active': 'Нет активного захвата',
|
||
'No commands': 'Нет команд',
|
||
'No saved sessions': 'Нет сохранённых сессий',
|
||
'Start a capture from the Trace tab to create sessions.': 'Начните захват на вкладке «Трассировка», чтобы создать сессии.',
|
||
'Import PCAP': 'Импорт PCAP',
|
||
'Import COMPRION trace': 'Импорт трейсов COMPRION',
|
||
'Import the \'ISO Protocol Layer\' export, optionally with the matching \'ISO Application Layer\' export for exact timestamps.':
|
||
'Импорт экспорта «ISO Protocol Layer», при наличии — с соответствующим экспортом «ISO Application Layer» для точных меток времени.',
|
||
'Select the ISO Protocol Layer export and, if available, the matching ISO Application Layer export of the same session.':
|
||
'Выберите экспорт «ISO Protocol Layer» и, если есть, соответствующий ему экспорт «ISO Application Layer» той же сессии.',
|
||
'ISO Protocol Layer export (required)': 'Экспорт «ISO Protocol Layer» (обязательно)',
|
||
'ISO Application Layer export (optional)': 'Экспорт «ISO Application Layer» (необязательно)',
|
||
'Choose file…': 'Выбрать файл…',
|
||
'no file selected': 'файл не выбран',
|
||
'Import': 'Импортировать',
|
||
'Without the ISO Application Layer export the timeline is card/line activity time from the Protocol Layer\'s Delay column (the unit assumes 3.8 MHz) — the order is right, but host pauses are missing and the absolute times are not exact.':
|
||
'Без экспорта «ISO Application Layer» таймлайн — это время активности карты/линии по столбцу Delay из Protocol Layer (единица измерения предполагает 3,8 МГц) — порядок верен, но паузы хоста отсутствуют, а абсолютное время неточно.',
|
||
'The ISO Application Layer export adds exact per-APDU timestamps (ms precision) — the session window and the gaps between APDUs become exact. Both files are verified to belong to the same session before importing.':
|
||
'Экспорт «ISO Application Layer» добавляет точные метки времени по APDU (точность до мс) — окно сессии и интервалы между APDU становятся точными. Перед импортом проверяется, что файлы относятся к одной сессии.',
|
||
'This is the ISO Protocol Layer export — select it in the first slot.':
|
||
'Это экспорт «ISO Protocol Layer» — выберите его в первом поле.',
|
||
'The two files are identical.': 'Это один и тот же файл.',
|
||
'This is the \'ISO Application Layer\' export — select the \'ISO Protocol Layer\' file (it contains the raw APDU bytes).':
|
||
'Это экспорт «ISO Application Layer» — выберите файл «ISO Protocol Layer» (в нём есть необработанные APDU-байты).',
|
||
'Not a COMPRION export — expected the \'ISO Protocol Layer\' text file.':
|
||
'Это не экспорт COMPRION — ожидается текстовый файл «ISO Protocol Layer».',
|
||
'Unsupported COMPRION export layout (expected the \'ISO Protocol Layer\' table).':
|
||
'Неподдерживаемый формат экспорта COMPRION (ожидается таблица «ISO Protocol Layer»).',
|
||
'No APDU exchanges found in the COMPRION export.':
|
||
'В экспорте COMPRION не найдено ни одного APDU-обмена.',
|
||
'No usable Time entries found in the ISO Application Layer export.':
|
||
'В экспорте «ISO Application Layer» не найдено пригодных записей Time.',
|
||
'The two files belong to different sessions — select the ISO Application Layer export of the same session.':
|
||
'Файлы относятся к разным сессиям — выберите экспорт «ISO Application Layer» той же сессии.',
|
||
'Cannot verify that the two files belong to the same session (no Session Name in either file).':
|
||
'Не удалось убедиться, что файлы относятся к одной сессии (ни в одном нет Session Name).',
|
||
'The ISO Application Layer file does not match this trace (different exchange count).':
|
||
'Файл «ISO Application Layer» не соответствует этой трассировке (другое число обменов).',
|
||
'This looks like a COMPRION export — use \'Import COMPRION trace\'.':
|
||
'Похоже на экспорт COMPRION — используйте «Импорт трейсов COMPRION».',
|
||
'exact timestamps (Application Layer)': 'точные метки времени (ISO Application Layer)',
|
||
'{n} packets skipped (not APDU-shaped)': 'пропущено пакетов (не APDU): {n}',
|
||
'New capture': 'Новый захват',
|
||
'Tiles': 'Плитки',
|
||
'List': 'Список',
|
||
'Name': 'Имя',
|
||
'Date': 'Дата',
|
||
'Duration': 'Длительность',
|
||
'Messages': 'Сообщения',
|
||
'Mode': 'Режим',
|
||
'Actions': 'Действия',
|
||
'Server': 'Сервер',
|
||
'device disconnected — reconnecting…': 'устройство отключено — переподключение…',
|
||
'device disconnected — messages may be missing': 'устройство отключалось — сообщения могли быть потеряны',
|
||
'The device disconnected and reconnected — some APDUs may be missing from this trace.': 'Устройство отключалось и переподключалось — часть APDU может отсутствовать в трейсе.',
|
||
'LIVE': 'В ЭФИРЕ',
|
||
'Opening a session stops the active capture. Continue?': 'Открытие сессии остановит активный захват. Продолжить?',
|
||
'Capture is still running on the server — it will continue after you disconnect. Disconnect?': 'Захват всё ещё выполняется на сервере — он продолжится после отключения. Отключиться?',
|
||
'Same origin': 'Тот же источник',
|
||
'Custom URL': 'Свой URL',
|
||
'Connect': 'Подключить',
|
||
'Disconnect': 'Отключить',
|
||
'Disconnected — click Connect to resume': 'Отключено — нажмите «Подключить», чтобы возобновить',
|
||
'Not connected — ensure the server is running on the URL above': 'Не подключено — убедитесь, что сервер запущен по указанному URL',
|
||
'Delete Session': 'Удалить сессию',
|
||
'Delete': 'Удалить',
|
||
'and all its APDU data? This cannot be undone.': 'и все данные APDU? Это действие необратимо.',
|
||
'Cancel': 'Отмена',
|
||
'Session #': 'Сессия #',
|
||
'Session: ': 'Сессия: ',
|
||
'Session name...': 'Имя сессии...',
|
||
'msgs': 'сообщ.',
|
||
'active': 'активна',
|
||
'Download PCAP': 'Скачать PCAP',
|
||
'Copy link': 'Копировать ссылку',
|
||
'Share link': 'Ссылка для общего доступа',
|
||
'{n} messages': '{n} сообщений',
|
||
'{n} session(s) · {size}': '{n} сессий · {size}',
|
||
'{n} session · {size}': '{n} сессия · {size}',
|
||
'Importing…': 'Импорт…',
|
||
'Import failed': 'Ошибка импорта',
|
||
'Imported \'{name}\' — {n} messages': 'Импортировано «{name}» — {n} сообщений',
|
||
'Failed to start capture: ': 'Не удалось начать захват: ',
|
||
'Failed to load session': 'Не удалось загрузить сессию',
|
||
'Failed to delete session': 'Не удалось удалить сессию',
|
||
'No GSMTAP data found in trace': 'В трассировке нет данных GSMTAP',
|
||
'Empty file': 'Пустой файл',
|
||
'Install from the address bar:\nclick the install icon (⊕ / monitor icon) at the right\nend of Chrome\'s address bar, then "Install SIMtrace Analyser".':
|
||
'Установка из адресной строки:\nнажмите значок установки (⊕ / значок монитора) в правом\nконце адресной строки Chrome, затем «Установить SIMtrace Analyser».',
|
||
[HINT_BLOCKED]: 'Браузер, вероятно, заблокировал доступ к локальному серверу. Разрешите доступ к локальной сети для этого сайта (Chrome/Edge/Vivaldi: Настройки сайта → Доступ к локальной сети → Разрешить) и убедитесь, что simtrace-analyser-server запущен по адресу {url}.',
|
||
[HINT_UNREACHABLE]: 'Не удалось связаться с сервером. Проверьте, что simtrace-analyser-server запущен и URL указан верно ({url}). Если эта страница открыта по HTTPS, также разрешите доступ к локальной сети в браузере (Настройки сайта → Доступ к локальной сети → Разрешить).',
|
||
'Setup': 'Настройка',
|
||
'Clone and install SIMtrace Analyser:': 'Клонируйте и установите SIMtrace Analyser:',
|
||
'Start the server in direct mode (recommended):': 'Запустите сервер в режиме direct (рекомендуется):',
|
||
'Requires SIMtrace2 hardware connected via USB; captures directly from the device.': 'Требуется оборудование SIMtrace2, подключённое по USB; захват выполняется напрямую с устройства.',
|
||
'Then set the URL above to http://127.0.0.1:8081 and return to the Trace tab.': 'Затем укажите URL выше (http://127.0.0.1:8081) и вернитесь на вкладку «Трассировка».',
|
||
'Alternative: GSMTAP mode': 'Альтернатива: режим GSMTAP',
|
||
'Use an external sniffer alongside the server. This mode is compatible with the original Osmocom simtrace2-sniff.': 'Используйте внешний сниффер вместе с сервером. Этот режим совместим с оригинальным инструментом Osmocom simtrace2-sniff.',
|
||
'Start the server:': 'Запустите сервер:',
|
||
'Start a GSMTAP source in another terminal — either:': 'Запустите источник GSMTAP в другом терминале — либо:',
|
||
'(original Osmocom tool)': '(оригинальный инструмент Osmocom)',
|
||
'Server version unknown — update simtrace-analyser-server.': 'Версия сервера неизвестна — обновите simtrace-analyser-server.',
|
||
'Server version {v} is outdated. Please update simtrace-analyser-server.': 'Версия сервера {v} устарела. Обновите simtrace-analyser-server.',
|
||
'Server version {v} is newer than expected. Update SIMtrace Analyser for full compatibility.': 'Версия сервера {v} новее ожидаемой. Обновите SIMtrace Analyser для полной совместимости.',
|
||
'Capture mode: GSMTAP — the server is listening for incoming UDP/GSMTAP traffic. Ensure a GSMTAP source (e.g. simtrace2-pysniff or simtrace2-sniff) is feeding the server.': 'Режим захвата: GSMTAP — сервер ожидает входящий UDP/GSMTAP-трафик. Убедитесь, что источник GSMTAP (например, simtrace2-pysniff или simtrace2-sniff) передаёт данные серверу.',
|
||
'Capture mode: direct — the server captures from SIMtrace2 hardware. Keep the device connected.': 'Режим захвата: direct — сервер захватывает данные с устройства SIMtrace2. Не отключайте устройство.',
|
||
'Capture is disabled — only pre-captured traces (saved sessions / PCAP import) are available.': 'Захват отключён — доступны только ранее записанные трассировки (сохранённые сессии / импорт PCAP).',
|
||
'Capture unavailable': 'Захват недоступен',
|
||
'Open a saved session from the Sessions tab.': 'Откройте сохранённую сессию на вкладке «Сессии».',
|
||
'Capture disabled': 'Захват отключён',
|
||
};
|
||
|
||
function t(text) {
|
||
if (currentLang === 'ru' && LANG_RU[text]) return LANG_RU[text];
|
||
return text;
|
||
}
|
||
|
||
function translatePage() {
|
||
document.querySelectorAll('[data-l10n]').forEach(el => {
|
||
const key = el.getAttribute('data-l10n');
|
||
if (el.tagName === 'INPUT') {
|
||
el.placeholder = t(key);
|
||
} else {
|
||
el.textContent = t(key);
|
||
}
|
||
});
|
||
document.querySelectorAll('[data-l10n-title]').forEach(el => {
|
||
el.setAttribute('title', t(el.getAttribute('data-l10n-title')));
|
||
});
|
||
}
|
||
|
||
function applyLang() {
|
||
document.getElementById('lang-btn').textContent = currentLang.toUpperCase();
|
||
}
|
||
|
||
function toggleLang() {
|
||
currentLang = currentLang === 'en' ? 'ru' : 'en';
|
||
localStorage.setItem('lang', currentLang);
|
||
applyLang();
|
||
translatePage();
|
||
if (STATE.sessionViewId) {
|
||
el('session-name-display').textContent = t('Session: ') + (STATE.currentSessionRawName || '#' + STATE.sessionViewId);
|
||
}
|
||
updateStatus();
|
||
updateCaptureButton();
|
||
renderMessages();
|
||
loadSessions();
|
||
checkConnection();
|
||
if (el('comprion-modal').classList.contains('show')) renderComprionDialog();
|
||
}
|
||
|
||
// ──────────────────── Theme ────────────────────
|
||
function applyTheme(theme) {
|
||
document.documentElement.setAttribute('data-theme', theme);
|
||
localStorage.setItem('theme', theme);
|
||
document.getElementById('theme-btn').textContent = theme === 'dark' ? '☀️' : '🌙';
|
||
document.querySelector('meta[name="theme-color"]').setAttribute(
|
||
'content', theme === 'dark' ? '#0f172a' : '#fafafa');
|
||
const fav = document.getElementById('favicon-svg');
|
||
if (fav) fav.setAttribute('href',
|
||
theme === 'dark' ? 'sim-analyser.svg' : 'sim-analyser-light.svg');
|
||
}
|
||
|
||
function toggleTheme() {
|
||
const current = document.documentElement.getAttribute('data-theme');
|
||
applyTheme(current === 'dark' ? 'light' : 'dark');
|
||
}
|
||
|
||
// ──────────────────── PWA Install ────────────────────
|
||
let _deferredInstallPrompt = null;
|
||
|
||
function hideInstallBtn() {
|
||
const btn = document.getElementById('install-btn');
|
||
if (btn) btn.style.display = 'none';
|
||
}
|
||
|
||
window.addEventListener('beforeinstallprompt', e => {
|
||
e.preventDefault();
|
||
_deferredInstallPrompt = e;
|
||
console.log('beforeinstallprompt fired — install prompt available');
|
||
});
|
||
|
||
window.addEventListener('appinstalled', () => {
|
||
console.log('appinstalled fired');
|
||
_deferredInstallPrompt = null;
|
||
hideInstallBtn();
|
||
});
|
||
|
||
async function promptInstall() {
|
||
if (_deferredInstallPrompt) {
|
||
_deferredInstallPrompt.prompt();
|
||
const choice = await _deferredInstallPrompt.userChoice;
|
||
console.log('install userChoice:', choice.outcome);
|
||
if (choice.outcome === 'accepted') {
|
||
_deferredInstallPrompt = null;
|
||
hideInstallBtn();
|
||
}
|
||
return;
|
||
}
|
||
alert(t('Install from the address bar:\nclick the install icon (⊕ / monitor icon) at the right\nend of Chrome\'s address bar, then "Install SIMtrace Analyser".'));
|
||
}
|
||
|
||
// ──────────────────── Init ────────────────────
|
||
function init() {
|
||
const saved = localStorage.getItem('theme');
|
||
if (saved) {
|
||
applyTheme(saved);
|
||
} else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
|
||
applyTheme('light');
|
||
}
|
||
|
||
const userLang = (navigator.language || '').split('-')[0];
|
||
currentLang = localStorage.getItem('lang') || (userLang === 'ru' ? 'ru' : 'en');
|
||
applyLang();
|
||
translatePage();
|
||
|
||
el('pwa-version').textContent = 'v' + PWA_VERSION;
|
||
syncServerSourceUi();
|
||
renderTraceEmptyState();
|
||
|
||
document.querySelectorAll('.header-tab').forEach(tab => {
|
||
tab.addEventListener('click', () => switchTab(tab.dataset.tab));
|
||
});
|
||
|
||
$on('capture-btn', 'click', () => {
|
||
if (!STATE.serverAvailable) {
|
||
switchTab('settings');
|
||
return;
|
||
}
|
||
if (STATE.captureActive) return stopCapture();
|
||
if (STATE.sessionViewId) {
|
||
el('session-name-display').style.display = 'none';
|
||
STATE.sessionViewId = null;
|
||
STATE.currentSessionName = null;
|
||
updateCaptureButton();
|
||
history.replaceState(null, '', location.pathname);
|
||
}
|
||
startCapture();
|
||
});
|
||
|
||
$on('new-capture-btn', 'click', () => {
|
||
if (!STATE.serverAvailable) {
|
||
switchTab('settings');
|
||
return;
|
||
}
|
||
if (STATE.captureActive) return stopCapture();
|
||
el('session-name-display').style.display = 'none';
|
||
STATE.sessionViewId = null;
|
||
STATE.currentSessionName = null;
|
||
updateCaptureButton();
|
||
history.replaceState(null, '', location.pathname);
|
||
switchTab('trace');
|
||
startCapture();
|
||
});
|
||
|
||
$on('import-pcap-btn', 'click', () => el('import-pcap-file').click());
|
||
$on('import-pcap-file', 'change', () => {
|
||
const file = el('import-pcap-file').files[0];
|
||
if (file) importPcapFile(file);
|
||
el('import-pcap-file').value = '';
|
||
});
|
||
$on('import-comprion-btn', 'click', openComprionDialog);
|
||
$on('comprion-protocol-btn', 'click', () => el('comprion-protocol-file').click());
|
||
$on('comprion-application-btn', 'click', () => el('comprion-application-file').click());
|
||
$on('comprion-protocol-file', 'change', async () => {
|
||
await comprionPick('protocol', el('comprion-protocol-file').files[0]);
|
||
el('comprion-protocol-file').value = '';
|
||
});
|
||
$on('comprion-application-file', 'change', async () => {
|
||
await comprionPick('application', el('comprion-application-file').files[0]);
|
||
el('comprion-application-file').value = '';
|
||
});
|
||
$on('comprion-cancel', 'click', closeComprionDialog);
|
||
$on('comprion-import', 'click', confirmComprionImport);
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key !== 'Escape') return;
|
||
if (el('comprion-modal').classList.contains('show')) {
|
||
closeComprionDialog();
|
||
} else if (el('file-diff-modal').classList.contains('show')) {
|
||
el('file-diff-modal').classList.remove('show');
|
||
}
|
||
});
|
||
|
||
$on('view-grid-btn', 'click', () => toggleSessionView('grid'));
|
||
$on('view-list-btn', 'click', () => toggleSessionView('list'));
|
||
|
||
$on('search-input', 'input', onSearchChange);
|
||
$on('filter-btn', 'click', () => toggleFilterMenu());
|
||
$on('filter-menu', 'change', e => {
|
||
const box = e.target.closest('input[data-filter]');
|
||
if (box) setFilterEntry(box.dataset.filter, box.checked);
|
||
});
|
||
document.addEventListener('click', e => {
|
||
if (!e.target.closest('#filter-dropdown')) toggleFilterMenu(false);
|
||
});
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') toggleFilterMenu(false);
|
||
});
|
||
|
||
$on('server-source', 'change', () => {
|
||
if (el('server-source').value === 'same') {
|
||
STATE.serverUrl = '';
|
||
localStorage.setItem('serverUrl', '');
|
||
syncServerSourceUi();
|
||
checkConnection();
|
||
} else {
|
||
if (!el('server-url').value.trim()) {
|
||
el('server-url').value = 'http://127.0.0.1:8081';
|
||
}
|
||
STATE.serverUrl = el('server-url').value.trim();
|
||
localStorage.setItem('serverUrl', STATE.serverUrl);
|
||
syncServerSourceUi();
|
||
checkConnection();
|
||
}
|
||
});
|
||
|
||
$on('server-url', 'change', () => {
|
||
STATE.serverUrl = el('server-url').value.trim();
|
||
localStorage.setItem('serverUrl', STATE.serverUrl);
|
||
checkConnection();
|
||
});
|
||
|
||
$on('connect-btn', 'click', () => {
|
||
if (STATE.serverAvailable) {
|
||
disconnectServer();
|
||
} else {
|
||
connectServer();
|
||
}
|
||
});
|
||
|
||
$on('install-btn', 'click', promptInstall);
|
||
|
||
$on('side-view-select', 'change', () => {
|
||
STATE.sideView = el('side-view-select').value;
|
||
renderSidePanel();
|
||
});
|
||
$on('file-view', 'click', e => {
|
||
const diff = e.target.closest('.file-op-diff');
|
||
if (diff) {
|
||
const parts = diff.dataset.diff.split('|');
|
||
showFileDiff(parts[0], Number(parts[1]));
|
||
return;
|
||
}
|
||
const row = e.target.closest('.file-op');
|
||
if (row) jumpToMessage(Number(row.dataset.idx));
|
||
});
|
||
$on('file-diff-close', 'click', () => el('file-diff-modal').classList.remove('show'));
|
||
|
||
$on('delete-cancel', 'click', () => {
|
||
el('delete-modal').classList.remove('show');
|
||
STATE.pendingDeleteId = null;
|
||
});
|
||
$on('delete-confirm', 'click', confirmDelete);
|
||
|
||
el('side-panel').addEventListener('click', e => {
|
||
const toggle = e.target.closest('.map-files-toggle');
|
||
if (toggle) {
|
||
e.stopPropagation();
|
||
const start = parseInt(toggle.dataset.start);
|
||
if (STATE.mapExpanded.has(start)) STATE.mapExpanded.delete(start);
|
||
else STATE.mapExpanded.add(start);
|
||
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);
|
||
});
|
||
|
||
connectServer();
|
||
|
||
el('apdu-list').addEventListener('click', e => {
|
||
const entry = e.target.closest('.apdu-entry');
|
||
if (!entry) return;
|
||
const idx = parseInt(entry.dataset.idx);
|
||
const msg = STATE.messages[idx];
|
||
if (!msg) return;
|
||
showDetail(msg, entry);
|
||
});
|
||
|
||
updateCaptureButton();
|
||
}
|
||
|
||
// ──────────────────── Splitter ────────────────────
|
||
(function initSplitter() {
|
||
const splitter = el('splitter');
|
||
const detail = el('apdu-detail');
|
||
const left = splitter.parentElement;
|
||
const saved = parseFloat(localStorage.getItem('splitter-ratio'));
|
||
if (saved > 0.1 && saved < 0.8) {
|
||
left.style.setProperty('--detail-height', (saved * 100) + '%');
|
||
}
|
||
let startY, startH;
|
||
function onMove(e) {
|
||
const dy = startY - e.clientY;
|
||
const total = left.clientHeight;
|
||
const h = Math.min(Math.max(startH + dy, total * 0.1), total * 0.8);
|
||
const ratio = h / total;
|
||
left.style.setProperty('--detail-height', (ratio * 100) + '%');
|
||
localStorage.setItem('splitter-ratio', ratio);
|
||
}
|
||
function onUp() {
|
||
splitter.classList.remove('active');
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
}
|
||
splitter.addEventListener('mousedown', e => {
|
||
e.preventDefault();
|
||
startY = e.clientY;
|
||
startH = detail.offsetHeight;
|
||
splitter.classList.add('active');
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
});
|
||
})();
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|