Files
simtrace-analyser/frontend/index.html
T
catarrh 674a979645 Enrich per-event RST/VCC labels (ATR STARTS / power-up) + decode server-side
- decode_line_event adds human label: RST de-asserted->ATR STARTS,
  VCC applied->VCC ON (power-up); keep canonical event string
- Enable rst/vcc in DB decode dispatch so PWA receives decoded
  (fixes bare RESET/VCC fallback and a latent showDetail crash)
- PWA line_event_label/showDetail render decoded.label
- Bump 1.22.1, SW cache v32->v33; +1 test
2026-08-27 00:07:35 +03:00

2862 lines
131 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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.label ? d.label : 'RESET';
if (msg.type === 'vcc') return d && d.label ? d.label : 'VCC';
return null;
}
function ins_label(msg) {
if (msg.decoded && msg.decoded.cat_command) return msg.decoded.cat_command;
if (msg.decoded && msg.decoded.ins_name) return msg.decoded.ins_name;
if (msg.type === 'atr') return 'ATR';
if (msg.type === 'pps') return 'PPS';
if (msg.type === 'change') return 'STATE CHANGE';
if (msg.type === 'fidi') return 'FIDI';
if (msg.type === 'gap') return 'CAPTURE GAP';
const le = line_event_label(msg);
if (le) return le;
const d = msg.data;
if (d && d.length >= 2) {
const ins = d.slice(2, 4);
return INS_NAMES[ins.toLowerCase()] || '0x' + ins.toUpperCase();
}
return 'TPDU';
}
function _fallback_label(msg) {
if (msg.type === 'atr') return 'ATR';
if (msg.type === 'pps') return 'PPS';
if (msg.type === 'change') return 'STATE CHANGE';
if (msg.type === 'fidi') return 'FIDI';
if (msg.type === 'gap') return 'CAPTURE GAP';
const le = line_event_label(msg);
if (le) return le;
const data = msg.data;
if (data && data.length >= 2) {
const ins = data.slice(2, 4);
return INS_NAMES[ins.toLowerCase()] || '0x' + ins.toUpperCase();
}
return 'TPDU';
}
function stat_label(msg) {
const d = msg.decoded;
if (d) {
if (d.ins_name === 'ENVELOPE') return 'ENVELOPE';
if (d.cat_command) {
if (d.cat_command.startsWith('PROVIDE LOCAL INFORMATION')) return 'PROVIDE LOCAL INFORMATION';
return d.cat_command;
}
if (d.ins_name) return d.ins_name;
}
return _fallback_label(msg);
}
function filter_label(msg) {
const d = msg.decoded;
if (d) {
if (d.ins_name === 'ENVELOPE') return 'ENVELOPE';
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.label || decoded.event || (decoded.type === 'rst' ? 'reset event' : 'power event');
d.innerHTML = `<div class="detail-header"><strong>${ins_label(msg)}</strong><span style="color:var(--text-muted)">${detail_time_html(msg)}</span></div>
<div class="detail-body">
<table>
<tr><td class="label">Event</td><td class="value">${evName}</td></tr>
<tr><td class="label">Level</td><td class="value">${decoded.level || '?'}</td></tr>
${decoded.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, '&quot;')}">${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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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> &nbsp;(simtrace2-pysniff)<br>
&nbsp;or<br>
<code>simtrace2-sniff --gsmtap-ip 127.0.0.1</code> &nbsp;${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.22.1';
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>