Support the sigrok-iso7816-stream decoder (FX2 logic-analyzer passive SIM sniffer, github.com/anttro/sigrok_iso7816_stream) which streams GSMTAP-SIM on UDP 4729 and adds two custom sub-types for line events. - gsmtap.py: GSMTAP_SIM_RST_EVENT (0x10) / GSMTAP_SIM_VCC_EVENT (0x11). - capture.py: gsmtap_msg_type() maps 0x10→'rst', 0x11→'vcc' (shared by the listener and tests); pcap.py _SUB_TYPE_NAMES gains the same entries so PCAP re-imports of these captures preserve the events. - decode.py: decode_line_event() decodes [direction, level, reserved] into reset asserted/de-asserted or power applied/removed + level; routed through decode_sniff_msg. - database.py: reset-assert and power-removal invalidate the per-channel file selection (card-level transition); de-assert / power-on are markers only. - frontend: RST/VCC labels (RESET ASSERTED / VCC OFF, …), a detail-view branch (Event + Level + raw), and list styling (.apdu-type.rst/.vcc). README notes the custom sub-type compatibility. Tests: +8 (line-event vectors, sniff routing, subtype + PCAP round-trip mappings) — 359 total. Version 1.21.0, SW cache simtrace-analyser-v31.
2862 lines
132 KiB
HTML
2862 lines
132 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, [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;
|
||
--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;
|
||
--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 33.333%; 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 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(--bg-input); color: var(--text); }
|
||
.trace-topbar button.stop:hover { background: var(--bg-panel); }
|
||
.trace-topbar button.connect { background: var(--warn); color: #0f172a; }
|
||
.trace-topbar button.connect:hover { background: var(--warn-hover); }
|
||
.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; 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; max-height: 40%; overflow-y: auto; border-top: 2px solid var(--accent); background: var(--bg-panel); flex-shrink: 0; }
|
||
.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: 260px; 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); }
|
||
.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-delete { padding: 4px 12px; background: none; border: 1px solid var(--accent); color: var(--accent); border-radius: 4px; cursor: pointer; font-size: 13px; }
|
||
.session-delete:hover { background: var(--accent); color: #fff; }
|
||
|
||
/* 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); }
|
||
|
||
/* Scrollbar */
|
||
::-webkit-scrollbar { width: 6px; }
|
||
::-webkit-scrollbar-track { background: var(--scrollbar-track); }
|
||
::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; }
|
||
</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">SIM-ME <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/simtrace2-pysniff" 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">
|
||
<input type="text" id="search-input" data-l10n="Search hex bytes..." placeholder="Search hex bytes..." autocomplete="off">
|
||
<label for="filter-select" class="filter-label" data-l10n="Filter">Filter</label>
|
||
<select id="filter-select">
|
||
<option value="" data-l10n="All commands">All commands</option>
|
||
</select>
|
||
<button id="capture-btn" data-l10n="Start Capture">Start Capture</button>
|
||
<span id="session-name-display" style="display:none; color:var(--text-muted); font-size:15px; font-weight:700;"></span>
|
||
</div>
|
||
<div class="trace-main">
|
||
<div class="trace-left">
|
||
<div class="apdu-list" id="apdu-list"></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 class="side-tabs">
|
||
<button class="side-tab active" data-view="map" data-l10n="Map">Map</button>
|
||
<button class="side-tab" data-view="stats" data-l10n="Stats">Stats</button>
|
||
</div>
|
||
<div id="type-counts">—</div>
|
||
<div id="type-map" 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="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>
|
||
<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>
|
||
|
||
<!-- 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>
|
||
|
||
<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,
|
||
sessionViewId: null,
|
||
sessionStarted: null,
|
||
livePoller: null,
|
||
messages: [],
|
||
typeCounts: [],
|
||
traceMap: [],
|
||
sideView: 'map',
|
||
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.event ? 'RESET ' + (d.event === 'reset asserted' ? 'ASSERTED' : 'DE-ASSERTED') : 'RESET';
|
||
if (msg.type === 'vcc') return d && d.event ? 'VCC ' + (d.event === 'power applied' ? 'ON' : 'OFF') : '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';
|
||
if (d.ins_name === 'FETCH' || d.ins_name === 'TERMINAL RESPONSE') return 'FETCH: ANY PROACTIVE';
|
||
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 otaman) ────────────────────
|
||
// 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');
|
||
if (m.decoded && m.decoded.summary) return m.decoded.summary;
|
||
return data_text(m);
|
||
}
|
||
|
||
function error_names(m) {
|
||
const d = m.decoded;
|
||
return (d && Array.isArray(d.errors) && d.errors.length) ? d.errors : 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);
|
||
}
|
||
try {
|
||
const res = await fetch(API_BASE() + path, opts);
|
||
return await res.json();
|
||
} catch (e) {
|
||
STATE.serverAvailable = false;
|
||
updateCaptureButton();
|
||
throw e;
|
||
}
|
||
},
|
||
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}`); },
|
||
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 importPcap(name, bytes) {
|
||
const res = await fetch(API_BASE() + '/api/sessions/import?name=' + encodeURIComponent(name), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/octet-stream' },
|
||
body: bytes,
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) throw Object.assign(new Error(data.error || 'Import failed'), { status: res.status });
|
||
return data;
|
||
},
|
||
};
|
||
|
||
// ──────────────────── UI Helpers ────────────────────
|
||
function el(id) { return document.getElementById(id); }
|
||
function $on(id, ev, fn) { el(id).addEventListener(ev, fn); }
|
||
|
||
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.short_ef_id) rows.push(['Short EF ID', resp.short_ef_id]);
|
||
|
||
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) {
|
||
rows.push(['Item ' + (it.id !== undefined ? '0x' + hx(it.id, 2) : '?'), it.text]);
|
||
}
|
||
}
|
||
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]);
|
||
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) rows.push(['Protocols', decoded.protocols.join(', ')]);
|
||
if (decoded.interface) {
|
||
for (const ifc of decoded.interface) {
|
||
rows.push([ifc.name, ifaceValue(ifc)]);
|
||
}
|
||
}
|
||
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 (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 clicked entry
|
||
document.querySelectorAll('.apdu-entry.selected').forEach(e => e.classList.remove('selected'));
|
||
if (entry) entry.classList.add('selected');
|
||
|
||
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.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.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${lmNote}</td></tr>
|
||
${body}
|
||
${cmdRows(decoded.cmd)}
|
||
${responseRows(decoded.response)}
|
||
${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 = showMap ? 'none' : '';
|
||
mapEl.style.display = showMap ? '' : 'none';
|
||
document.querySelectorAll('#side-panel .side-tab').forEach(tab => {
|
||
tab.classList.toggle('active', tab.dataset.view === STATE.sideView);
|
||
});
|
||
}
|
||
|
||
function 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>`;
|
||
}
|
||
|
||
function populateFilterOptions() {
|
||
const sel = el('filter-select');
|
||
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 existing = Array.from(sel.options).slice(1).map(o => o.value);
|
||
if (existing.length === keys.length && existing.every((k, i) => k === keys[i])) return;
|
||
const cur = sel.value;
|
||
sel.innerHTML = '<option value="">' + t('All commands') + '</option>' +
|
||
keys.map(k => `<option value="${escapeHtml(k).replace(/"/g, '"')}">${escapeHtml(k)}</option>`).join('');
|
||
sel.value = keys.includes(cur) ? cur : '';
|
||
}
|
||
|
||
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) {
|
||
targetEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||
targetEl.classList.add('selected');
|
||
setTimeout(() => targetEl.classList.remove('selected'), 1500);
|
||
}
|
||
}
|
||
|
||
function focusMessage(idx) {
|
||
const filterEl = el('filter-select');
|
||
const searchEl = el('search-input');
|
||
const needRender = filterEl.value !== '' || searchEl.value !== '';
|
||
filterEl.value = '';
|
||
searchEl.value = '';
|
||
if (needRender) renderMessages(true);
|
||
const entry = el('apdu-list').querySelector(`.apdu-entry[data-idx="${idx}"]`);
|
||
if (!entry) return;
|
||
entry.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||
entry.classList.add('selected');
|
||
setTimeout(() => entry.classList.remove('selected'), 1500);
|
||
showDetail(STATE.messages[idx], entry);
|
||
}
|
||
|
||
// ──────────────────── Render Trace ────────────────────
|
||
function isOriginalCommand(i) {
|
||
if (STATE.messages[i].type !== 'tpdu') return false;
|
||
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, filterType) {
|
||
if (!filterType) return true;
|
||
if (filter_label(m) === filterType) return true;
|
||
const d = m.decoded;
|
||
const isGR = m.type === 'tpdu' && d && d.ins_name === 'GET RESPONSE';
|
||
if (isGR && d.response_for === filterType) return true;
|
||
if (filterType === '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();
|
||
}
|
||
|
||
function renderMessages(skipScroll) {
|
||
const list = el('apdu-list');
|
||
const searchQ = el('search-input').value.toLowerCase();
|
||
const filterType = el('filter-select').value;
|
||
|
||
const filtered = [];
|
||
for (let i = 0; i < STATE.messages.length; i++) {
|
||
const m = STATE.messages[i];
|
||
if (!matchesFilter(m, i, filterType)) continue;
|
||
if (searchQ && !m.data.toLowerCase().includes(searchQ)) continue;
|
||
filtered.push({ m, idx: i });
|
||
}
|
||
|
||
buildTypeCounts();
|
||
buildTraceMap();
|
||
renderSidePanel();
|
||
populateFilterOptions();
|
||
|
||
if (!STATE.captureActive && !STATE.sessionViewId) {
|
||
renderTraceEmptyState();
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = filtered.map(({ m, idx }) => {
|
||
if (m.type === 'gap') {
|
||
return `<div class="apdu-entry apdu-gap" 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}" 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 (!skipScroll) 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 = [];
|
||
_lastAfter = 0;
|
||
STATE.mapExpanded.clear();
|
||
buildTypeCounts();
|
||
buildTraceMap();
|
||
renderSidePanel();
|
||
renderMessages();
|
||
updateCaptureButton();
|
||
startPolling();
|
||
} catch (e) {
|
||
alert(t('Failed to start capture: ') + e.message);
|
||
}
|
||
}
|
||
|
||
async function stopCapture() {
|
||
try {
|
||
await API.stopCapture();
|
||
STATE.captureActive = false;
|
||
STATE.currentSessionId = null;
|
||
STATE.sessionViewId = null;
|
||
updateCaptureButton();
|
||
updateStatus();
|
||
} catch (e) {}
|
||
}
|
||
|
||
function updateCaptureButton() {
|
||
const btn = el('capture-btn');
|
||
const newCapture = el('new-capture-btn');
|
||
btn.classList.remove('stop', 'connect');
|
||
const captureOff = STATE.captureMode === 'disabled' && STATE.serverAvailable;
|
||
newCapture.style.display = captureOff ? 'none' : '';
|
||
if (captureOff) {
|
||
btn.style.display = 'none';
|
||
return;
|
||
}
|
||
if (STATE.sessionViewId && !STATE.captureActive) {
|
||
btn.style.display = 'none';
|
||
return;
|
||
}
|
||
btn.style.display = '';
|
||
if (!STATE.serverAvailable) {
|
||
btn.textContent = t('Connect to server');
|
||
btn.classList.add('connect');
|
||
} else if (STATE.captureActive) {
|
||
btn.textContent = t('Stop Capture');
|
||
btn.classList.add('stop');
|
||
} else {
|
||
btn.textContent = t('Start Capture');
|
||
}
|
||
}
|
||
|
||
// ──────────────────── Sessions ────────────────────
|
||
async function loadSessions() {
|
||
try {
|
||
const data = await API.listSessions();
|
||
const grid = el('sessions-grid');
|
||
|
||
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;
|
||
|
||
if (!data.sessions.length) {
|
||
grid.innerHTML = `<div class="empty-state" style="height:auto; padding:40px;">
|
||
<p>${t('No saved sessions')}</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
grid.innerHTML = data.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('');
|
||
|
||
grid.querySelectorAll('.session-card').forEach(card => {
|
||
card.addEventListener('click', e => {
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON' || e.target.tagName === 'A') return;
|
||
loadSession(parseInt(card.dataset.id));
|
||
});
|
||
});
|
||
grid.querySelectorAll('.session-name input').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();
|
||
});
|
||
});
|
||
grid.querySelectorAll('.session-delete').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
const sid = parseInt(btn.dataset.sid);
|
||
showDeleteModal(sid);
|
||
});
|
||
});
|
||
grid.querySelectorAll('.session-copy').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
copySessionLink(parseInt(btn.dataset.sid));
|
||
});
|
||
});
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function importPcapFile(file) {
|
||
const msg = el('import-msg');
|
||
msg.classList.remove('error');
|
||
msg.textContent = t('Importing…');
|
||
try {
|
||
const buf = await file.arrayBuffer();
|
||
const baseName = file.name.replace(/\.[^.]+$/, '') || file.name;
|
||
const data = await API.importPcap(baseName, buf);
|
||
msg.textContent = t('Imported \'{name}\' — {n} messages')
|
||
.replace('{name}', data.name).replace('{n}', data.message_count);
|
||
loadSessions();
|
||
} catch (e) {
|
||
msg.classList.add('error');
|
||
msg.textContent = t((e && e.message) || 'Import failed');
|
||
}
|
||
}
|
||
|
||
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.
|
||
await API.stopCapture();
|
||
STATE.captureActive = false;
|
||
STATE.currentSessionId = null;
|
||
stopPolling();
|
||
|
||
const data = await API.getSession(id, 0);
|
||
STATE.sessionViewId = id;
|
||
STATE.sessionStarted = data.session.started;
|
||
STATE.captureActive = false;
|
||
STATE.currentSessionName = data.session.name || t('Session #') + id;
|
||
STATE.messages = data.messages;
|
||
STATE.mapExpanded.clear();
|
||
switchTab('trace');
|
||
updateCaptureButton();
|
||
renderMessages();
|
||
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, '>');
|
||
}
|
||
|
||
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 simtrace2-pysniff-server.');
|
||
return;
|
||
}
|
||
const cmpMin = compareVersions(serverVersion, MIN_SERVER_VERSION);
|
||
if (cmpMin !== null && cmpMin < 0) {
|
||
elv.textContent = t('Server version {v} is outdated. Please update simtrace2-pysniff-server.').replace('{v}', serverVersion);
|
||
return;
|
||
}
|
||
if (sv[0] > parseVersion(PWA_VERSION)[0]) {
|
||
elv.textContent = t('Server version {v} is newer than expected. Update simtrace2-pysniff 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 simtrace2-pysniff:')}<br>
|
||
<code>git clone https://github.com/anttro/simtrace2-pysniff.git</code><br>
|
||
<code>cd simtrace2-pysniff && pip install -e .</code></li>
|
||
<li>${t('Start the server in direct mode (recommended):')}<br>
|
||
<code>./server-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>./server-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);
|
||
}
|
||
|
||
function onFilterChange() {
|
||
renderMessages();
|
||
}
|
||
|
||
// ──────────────────── Versioning ────────────────────
|
||
const PWA_VERSION = '1.21.0';
|
||
const MIN_SERVER_VERSION = '1.0.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 simtrace2-pysniff-server is running at {url}.';
|
||
const HINT_UNREACHABLE = 'Couldn\'t reach the server. Check that simtrace2-pysniff-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': 'Все команды',
|
||
'Filter': 'Фильтр',
|
||
'Start Capture': 'Начать захват',
|
||
'Stop Capture': 'Остановить захват',
|
||
'Connect to server': 'Подключиться к серверу',
|
||
'Click an APDU to see details': 'Нажмите на APDU для просмотра деталей',
|
||
'Command Stats': 'Статистика команд',
|
||
'Stats': 'Статистика',
|
||
'Map': 'Обзор',
|
||
'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',
|
||
'New capture': 'Новый захват',
|
||
'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: Настройки сайта → Доступ к локальной сети → Разрешить) и убедитесь, что simtrace2-pysniff-server запущен по адресу {url}.',
|
||
[HINT_UNREACHABLE]: 'Не удалось связаться с сервером. Проверьте, что simtrace2-pysniff-server запущен и URL указан верно ({url}). Если эта страница открыта по HTTPS, также разрешите доступ к локальной сети в браузере (Настройки сайта → Доступ к локальной сети → Разрешить).',
|
||
'Setup': 'Настройка',
|
||
'Clone and install simtrace2-pysniff:': 'Клонируйте и установите simtrace2-pysniff:',
|
||
'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 simtrace2-pysniff-server.': 'Версия сервера неизвестна — обновите simtrace2-pysniff-server.',
|
||
'Server version {v} is outdated. Please update simtrace2-pysniff-server.': 'Версия сервера {v} устарела. Обновите simtrace2-pysniff-server.',
|
||
'Server version {v} is newer than expected. Update simtrace2-pysniff for full compatibility.': 'Версия сервера {v} новее ожидаемой. Обновите simtrace2-pysniff для полной совместимости.',
|
||
'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);
|
||
}
|
||
});
|
||
}
|
||
|
||
function applyLang() {
|
||
document.getElementById('lang-btn').textContent = currentLang.toUpperCase();
|
||
}
|
||
|
||
function toggleLang() {
|
||
currentLang = currentLang === 'en' ? 'ru' : 'en';
|
||
localStorage.setItem('lang', currentLang);
|
||
applyLang();
|
||
translatePage();
|
||
updateStatus();
|
||
updateCaptureButton();
|
||
renderMessages();
|
||
loadSessions();
|
||
checkConnection();
|
||
}
|
||
|
||
// ──────────────────── 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('search-input', 'input', onSearchChange);
|
||
$on('filter-select', 'change', onFilterChange);
|
||
|
||
$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('delete-cancel', 'click', () => {
|
||
el('delete-modal').classList.remove('show');
|
||
STATE.pendingDeleteId = null;
|
||
});
|
||
$on('delete-confirm', 'click', confirmDelete);
|
||
|
||
el('side-panel').addEventListener('click', e => {
|
||
const tab = e.target.closest('.side-tab');
|
||
if (tab) {
|
||
STATE.sideView = tab.dataset.view;
|
||
renderSidePanel();
|
||
return;
|
||
}
|
||
const 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();
|
||
}
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|