2088 lines
95 KiB
HTML
2088 lines
95 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en" data-theme="dark">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>SIMtrace Analyser</title>
|
||
<link rel="manifest" href="manifest.json">
|
||
<link rel="icon" type="image/svg+xml" href="sim-analyser.svg">
|
||
<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;
|
||
}
|
||
[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;
|
||
}
|
||
|
||
* { 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: 1; 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 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); }
|
||
.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: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-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; }
|
||
.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); }
|
||
|
||
/* 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">
|
||
<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;"></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="stats" data-l10n="Stats">Stats</button>
|
||
<button class="side-tab" data-view="map" data-l10n="Map">Map</button>
|
||
</div>
|
||
<div id="type-counts">—</div>
|
||
<div id="type-map" style="display:none">—</div>
|
||
</div>
|
||
</div>
|
||
<div class="status-bar">
|
||
<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,
|
||
livePoller: null,
|
||
messages: [],
|
||
typeCounts: [],
|
||
traceMap: [],
|
||
sideView: 'stats',
|
||
pendingDeleteId: null,
|
||
serverAvailable: false,
|
||
serverName: '',
|
||
serverVersion: '',
|
||
captureMode: '',
|
||
deviceConnected: null,
|
||
};
|
||
|
||
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 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 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 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_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 (!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) rows.push(['Device IDs', cmd.device_ids]);
|
||
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.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]);
|
||
|
||
// 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]);
|
||
rows.push(['CNTR', '0x' + sec.cntr]);
|
||
rows.push(['PCNTR', sec.pcntr]);
|
||
if (sec.rc_cc_ds) rows.push(['RC/CC/DS', sec.rc_cc_ds]);
|
||
rows.push(['Secured data', sec.data]);
|
||
}
|
||
|
||
const rp = t.response_packet;
|
||
if (rp && !rp.raw) {
|
||
rows.push(['Response packet', 'Response Packet Length ' + rp.rpl + ' · Response Header Length ' + rp.rhl]);
|
||
rows.push(['TAR', '0x' + rp.tar]);
|
||
rows.push(['CNTR', '0x' + rp.cntr]);
|
||
rows.push(['PCNTR', 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', 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)">${fmt_elapsed(msg.elapsed)}</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)">${fmt_elapsed(msg.elapsed)}</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)">${fmt_elapsed(msg.elapsed)}</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 === 'fidi') {
|
||
d.innerHTML = `<div class="detail-header"><strong>Fi/Di Change</strong><span style="color:var(--text-muted)">${fmt_elapsed(msg.elapsed)}</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)">${fmt_elapsed(msg.elapsed)}</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)">${fmt_elapsed(msg.elapsed)}</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>`;
|
||
}
|
||
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' && d.summary) {
|
||
return 'ENVELOPE: ' + d.summary;
|
||
}
|
||
return stat_label(m);
|
||
}
|
||
|
||
function buildTraceMap() {
|
||
const msgs = STATE.messages;
|
||
const groups = [];
|
||
let i = 0;
|
||
while (i < msgs.length) {
|
||
const ins = _ins_hex(msgs[i]);
|
||
if (ins === 'c0') { i++; continue; } // skip GET RESPONSE
|
||
if (ins && MAP_RW_INS.has(ins)) {
|
||
// Split the run into per-file records (delimited by SELECT).
|
||
const access = [];
|
||
let cur = null;
|
||
let j = i;
|
||
while (j < msgs.length) {
|
||
const jins = _ins_hex(msgs[j]);
|
||
if (jins === 'c0') { j++; continue; } // skip GET RESPONSE
|
||
if (!jins || !MAP_RW_INS.has(jins)) break;
|
||
if (jins === 'a4') {
|
||
if (cur) access.push(cur);
|
||
cur = { file: map_file_label(msgs[j]), hasRead: false, hasWrite: false, start: j, end: j };
|
||
} else if (is_write_ins(jins)) {
|
||
if (!cur) cur = { file: map_file_label(msgs[j]), hasRead: false, hasWrite: false, start: j, end: j };
|
||
cur.hasWrite = true;
|
||
cur.end = j;
|
||
} else { // SEARCH / READ
|
||
if (!cur) cur = { file: map_file_label(msgs[j]), hasRead: false, hasWrite: false, start: j, end: j };
|
||
cur.hasRead = true;
|
||
cur.end = j;
|
||
}
|
||
j++;
|
||
}
|
||
if (cur) access.push(cur);
|
||
|
||
let k = 0;
|
||
while (k < access.length) {
|
||
const rec = access[k];
|
||
if (rec.hasRead && rec.hasWrite) {
|
||
groups.push({ label: t('Read/write:') + ' ' + (rec.file || '—'), start: rec.start, end: rec.end });
|
||
k++;
|
||
} else if (rec.hasWrite) {
|
||
const files = [];
|
||
let end = rec.end;
|
||
let m = k;
|
||
while (m < access.length && access[m].hasWrite && !access[m].hasRead) {
|
||
if (access[m].file && !files.includes(access[m].file)) files.push(access[m].file);
|
||
end = access[m].end;
|
||
m++;
|
||
}
|
||
groups.push({ label: t('Writes:') + ' ' + (files.join(', ') || '—'), start: rec.start, end });
|
||
k = m;
|
||
} else {
|
||
const files = [];
|
||
let end = rec.end;
|
||
let m = k;
|
||
while (m < access.length && !access[m].hasWrite) {
|
||
if (access[m].file && !files.includes(access[m].file)) files.push(access[m].file);
|
||
end = access[m].end;
|
||
m++;
|
||
}
|
||
groups.push({ label: t('Reads:') + ' ' + (files.join(', ') || '—'), start: rec.start, end });
|
||
k = m;
|
||
}
|
||
}
|
||
i = j;
|
||
} else if (ins === 'f2') {
|
||
let j = i;
|
||
while (j < msgs.length && _ins_hex(msgs[j]) === 'f2') j++;
|
||
groups.push({ label: 'STATUS: ' + (j - i), start: i, end: j - 1 });
|
||
i = j;
|
||
} else {
|
||
groups.push({ label: map_other_label(msgs[i]), start: i, end: i });
|
||
i++;
|
||
}
|
||
}
|
||
STATE.traceMap = groups;
|
||
}
|
||
|
||
function renderSidePanel() {
|
||
const entries = Object.entries(STATE.typeCounts).sort((a, b) => b[1] - a[1]);
|
||
el('type-counts').innerHTML = entries.map(([k, v], i) =>
|
||
`<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 =>
|
||
`<div class="map-item" data-start="${g.start}">${g.label}</div>`
|
||
).join('') || t('No commands');
|
||
|
||
const showMap = STATE.sideView === 'map';
|
||
el('type-counts').style.display = showMap ? 'none' : '';
|
||
mapEl.style.display = showMap ? '' : 'none';
|
||
document.querySelectorAll('#side-panel .side-tab').forEach(tab => {
|
||
tab.classList.toggle('active', tab.dataset.view === STATE.sideView);
|
||
});
|
||
}
|
||
|
||
function populateFilterOptions() {
|
||
const sel = el('filter-select');
|
||
const counts = {};
|
||
for (const m of STATE.messages) {
|
||
const lbl = filter_label(m);
|
||
counts[lbl] = (counts[lbl] || 0) + 1;
|
||
}
|
||
const keys = Object.keys(counts).sort((a, b) => a.localeCompare(b));
|
||
const existing = Array.from(sel.options).slice(1).map(o => o.value);
|
||
if (existing.length === keys.length && existing.every((k, i) => k === keys[i])) return;
|
||
const cur = sel.value;
|
||
sel.innerHTML = '<option value="">' + t('All commands') + '</option>' +
|
||
keys.map(k => `<option value="${escapeHtml(k).replace(/"/g, '"')}">${escapeHtml(k)}</option>`).join('');
|
||
sel.value = keys.includes(cur) ? cur : '';
|
||
}
|
||
|
||
function scrollToType(typeKey) {
|
||
const apduList = el('apdu-list');
|
||
let targetEl = null;
|
||
for (const el of apduList.querySelectorAll('.apdu-entry')) {
|
||
const msgIdx = parseInt(el.dataset.idx);
|
||
const msg = STATE.messages[msgIdx];
|
||
if (msg && stat_label(msg) === typeKey) {
|
||
targetEl = el;
|
||
break;
|
||
}
|
||
}
|
||
if (targetEl) {
|
||
targetEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||
targetEl.classList.add('selected');
|
||
setTimeout(() => targetEl.classList.remove('selected'), 1500);
|
||
}
|
||
}
|
||
|
||
function focusMessage(idx) {
|
||
const filterEl = el('filter-select');
|
||
const searchEl = el('search-input');
|
||
const needRender = filterEl.value !== '' || searchEl.value !== '';
|
||
filterEl.value = '';
|
||
searchEl.value = '';
|
||
if (needRender) renderMessages(true);
|
||
const entry = el('apdu-list').querySelector(`.apdu-entry[data-idx="${idx}"]`);
|
||
if (!entry) return;
|
||
entry.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||
entry.classList.add('selected');
|
||
setTimeout(() => entry.classList.remove('selected'), 1500);
|
||
showDetail(STATE.messages[idx], entry);
|
||
}
|
||
|
||
// ──────────────────── Render Trace ────────────────────
|
||
function isOriginalCommand(i) {
|
||
if (STATE.messages[i].type !== 'tpdu') return false;
|
||
for (let j = i + 1; j < STATE.messages.length; j++) {
|
||
if (STATE.messages[j].type !== 'tpdu') continue;
|
||
const d = STATE.messages[j].decoded;
|
||
return !!d && d.ins_name === 'GET RESPONSE' && !!d.response_for;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function matchesFilter(m, i, filterType) {
|
||
if (!filterType) return true;
|
||
if (filter_label(m) === filterType) return true;
|
||
const d = m.decoded;
|
||
const isGR = m.type === 'tpdu' && d && d.ins_name === 'GET RESPONSE';
|
||
if (isGR && d.response_for === filterType) return true;
|
||
if (filterType === 'GET RESPONSE' && isOriginalCommand(i)) return true;
|
||
return false;
|
||
}
|
||
|
||
function renderTraceEmptyState() {
|
||
if (STATE.captureActive || STATE.sessionViewId) return;
|
||
const list = el('apdu-list');
|
||
const connCls = STATE.serverAvailable ? 'connected' : 'disconnected';
|
||
const modeText = captureModeText();
|
||
const modeHtml = modeText
|
||
? `<div class="capture-mode ${STATE.captureMode}">${modeText}</div>`
|
||
: '';
|
||
const action = STATE.captureMode === 'disabled'
|
||
? `<p style="font-size:12px;">${t('Open a saved session from the Sessions tab.')}</p>`
|
||
: `<button id="trace-start-btn">${t('Start Capture')}</button>`;
|
||
list.innerHTML = `<div class="empty-state">
|
||
<div class="conn-status ${connCls}">${connectionStatusText()}</div>
|
||
${modeHtml}
|
||
${action}
|
||
</div>`;
|
||
const btn = $('#trace-start-btn', list);
|
||
if (btn) btn.addEventListener('click', startCapture);
|
||
updateStatus();
|
||
}
|
||
|
||
function renderMessages(skipScroll) {
|
||
const list = el('apdu-list');
|
||
const searchQ = el('search-input').value.toLowerCase();
|
||
const filterType = el('filter-select').value;
|
||
|
||
const filtered = [];
|
||
for (let i = 0; i < STATE.messages.length; i++) {
|
||
const m = STATE.messages[i];
|
||
if (!matchesFilter(m, i, filterType)) continue;
|
||
if (searchQ && !m.data.toLowerCase().includes(searchQ)) continue;
|
||
filtered.push({ m, idx: i });
|
||
}
|
||
|
||
buildTypeCounts();
|
||
buildTraceMap();
|
||
renderSidePanel();
|
||
populateFilterOptions();
|
||
|
||
if (!STATE.captureActive && !STATE.sessionViewId) {
|
||
renderTraceEmptyState();
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = filtered.map(({ m, idx }) => {
|
||
if (m.type === 'gap') {
|
||
return `<div class="apdu-entry apdu-gap" data-idx="${idx}">
|
||
<span class="apdu-time">${fmt_elapsed(m.elapsed)}</span>
|
||
<span class="apdu-type">⚠ CAPTURE GAP</span>
|
||
<span class="apdu-data">${cmd_description(m)}</span>
|
||
</div>`;
|
||
}
|
||
const label = ins_label(m);
|
||
const typeCls = m.type === 'atr' ? 'atr' : m.type === 'pps' ? 'pps' : '';
|
||
const errs = error_names(m);
|
||
const errBadge = errs ? `<span class="apdu-err" title="${errs.join(', ')}">⚠</span>` : '';
|
||
return `<div class="apdu-entry" data-idx="${idx}">
|
||
<span class="apdu-time">${fmt_elapsed(m.elapsed)}</span>
|
||
<span class="apdu-type ${typeCls}">${label}</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.messages = [];
|
||
_lastAfter = 0;
|
||
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>
|
||
<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);
|
||
});
|
||
});
|
||
} 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.captureActive = false;
|
||
STATE.currentSessionName = data.session.name || t('Session #') + id;
|
||
STATE.messages = data.messages;
|
||
switchTab('trace');
|
||
updateCaptureButton();
|
||
renderMessages();
|
||
el('session-name-display').style.display = 'inline';
|
||
el('session-name-display').textContent = t('Session: ') + (data.session.name || '#' + id);
|
||
} catch (e) {
|
||
alert(t('Failed to load session'));
|
||
}
|
||
}
|
||
|
||
function formatDuration(start, end) {
|
||
if (!start || !end) return '?';
|
||
const ms = new Date(end) - new Date(start);
|
||
const s = Math.floor(ms / 1000);
|
||
if (s < 60) return s + 's';
|
||
const m = Math.floor(s / 60);
|
||
if (m < 60) return m + 'm ' + (s % 60) + 's';
|
||
const h = Math.floor(m / 60);
|
||
return h + 'h ' + (m % 60) + 'm';
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
function showDeleteModal(id) {
|
||
STATE.pendingDeleteId = id;
|
||
const s = document.querySelector(`.session-card[data-id="${id}"] input`);
|
||
el('delete-session-name').textContent = s ? s.value : t('Session #') + id;
|
||
el('delete-modal').classList.add('show');
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
const id = STATE.pendingDeleteId;
|
||
if (!id) return;
|
||
try {
|
||
await API.deleteSession(id);
|
||
el('delete-modal').classList.remove('show');
|
||
STATE.pendingDeleteId = null;
|
||
loadSessions();
|
||
} catch (e) {
|
||
alert(t('Failed to delete session'));
|
||
}
|
||
}
|
||
|
||
// ──────────────────── Settings ────────────────────
|
||
let _connTimer = null;
|
||
|
||
function targetSpace() {
|
||
let h;
|
||
try { h = new URL(STATE.serverUrl).hostname.toLowerCase(); } catch (e) { return 'public'; }
|
||
if (h === 'localhost' || h === '[::1]' || h === '::1' || /^127\./.test(h)) return 'loopback';
|
||
if (/^10\./.test(h) || /^192\.168\./.test(h) || /^172\.(1[6-9]|2\d|3[01])\./.test(h)) return 'private';
|
||
return 'public';
|
||
}
|
||
|
||
function isLikelyBlocked() {
|
||
const pagePublic = location.protocol === 'https:'
|
||
|| !(location.hostname === 'localhost' || /^127\./.test(location.hostname));
|
||
const s = targetSpace();
|
||
return pagePublic && (s === 'loopback' || s === 'private');
|
||
}
|
||
|
||
function syncServerSourceUi() {
|
||
const source = el('server-source');
|
||
const urlInput = el('server-url');
|
||
const custom = STATE.serverUrl !== '';
|
||
source.value = custom ? 'custom' : 'same';
|
||
urlInput.style.display = custom ? '' : 'none';
|
||
if (custom) urlInput.value = STATE.serverUrl;
|
||
}
|
||
|
||
function updateVersionWarn(serverVersion) {
|
||
const elv = el('version-warn');
|
||
const sv = parseVersion(serverVersion);
|
||
if (sv === null) {
|
||
elv.textContent = t('Server version unknown — update simtrace2-pysniff-server.');
|
||
return;
|
||
}
|
||
const cmpMin = compareVersions(serverVersion, MIN_SERVER_VERSION);
|
||
if (cmpMin !== null && cmpMin < 0) {
|
||
elv.textContent = t('Server version {v} is outdated. Please update simtrace2-pysniff-server.').replace('{v}', serverVersion);
|
||
return;
|
||
}
|
||
if (sv[0] > parseVersion(PWA_VERSION)[0]) {
|
||
elv.textContent = t('Server version {v} is newer than expected. Update simtrace2-pysniff for full compatibility.').replace('{v}', serverVersion);
|
||
return;
|
||
}
|
||
elv.textContent = '';
|
||
}
|
||
|
||
function captureModeText() {
|
||
switch (STATE.captureMode) {
|
||
case 'gsmtap':
|
||
return t('Capture mode: GSMTAP — the server is listening for incoming UDP/GSMTAP traffic. Ensure a GSMTAP source (e.g. simtrace2-pysniff or simtrace2-sniff) is feeding the server.');
|
||
case 'direct':
|
||
return t('Capture mode: direct — the server captures from SIMtrace2 hardware. Keep the device connected.');
|
||
case 'disabled':
|
||
return t('Capture is disabled — only pre-captured traces (saved sessions / PCAP import) are available.');
|
||
default:
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function connectionStatusText() {
|
||
if (STATE.serverAvailable) {
|
||
return '⬤ ' + t('Connected') + ' — ' + STATE.serverName + ' v' + STATE.serverVersion;
|
||
}
|
||
return '⬤ ' + t('Not connected — ensure the server is running on the URL above');
|
||
}
|
||
|
||
function updateCaptureModeLine() {
|
||
const elm = el('capture-mode');
|
||
const mode = STATE.captureMode;
|
||
elm.textContent = captureModeText();
|
||
elm.className = 'capture-mode' + (mode ? ' ' + mode : '');
|
||
}
|
||
|
||
function connectServer() {
|
||
if (_connTimer) return;
|
||
_connTimer = setInterval(checkConnection, 5000);
|
||
checkConnection();
|
||
}
|
||
|
||
function disconnectServer() {
|
||
if (STATE.captureActive && !confirm(t('Capture is still running on the server — it will continue after you disconnect. Disconnect?'))) return;
|
||
if (_connTimer) {
|
||
clearInterval(_connTimer);
|
||
_connTimer = null;
|
||
}
|
||
STATE.serverAvailable = false;
|
||
STATE.serverName = '';
|
||
STATE.serverVersion = '';
|
||
STATE.captureMode = '';
|
||
el('conn-status').textContent = '⬤ ' + t('Disconnected — click Connect to resume');
|
||
el('conn-status').className = 'conn-status disconnected';
|
||
el('capture-mode').textContent = '';
|
||
el('capture-mode').className = 'capture-mode';
|
||
el('version-warn').textContent = '';
|
||
el('conn-hint').textContent = '';
|
||
el('conn-instructions').innerHTML = '';
|
||
el('status-dot').className = 'status-dot off';
|
||
el('status-text').textContent = t('Not connected');
|
||
const btn = el('connect-btn');
|
||
btn.textContent = t('Connect');
|
||
btn.classList.add('disconnected');
|
||
updateCaptureButton();
|
||
stopPolling();
|
||
renderTraceEmptyState();
|
||
}
|
||
|
||
async function checkConnection() {
|
||
try {
|
||
const data = await API.status();
|
||
STATE.serverAvailable = true;
|
||
STATE.captureMode = data.capture_mode || '';
|
||
STATE.serverName = data.server || 'simtrace-analyser-server';
|
||
STATE.serverVersion = data.version || '';
|
||
STATE.deviceConnected = data.device_connected;
|
||
el('conn-status').textContent = connectionStatusText();
|
||
el('conn-status').className = 'conn-status connected';
|
||
updateVersionWarn(data.version);
|
||
updateCaptureModeLine();
|
||
el('conn-hint').textContent = '';
|
||
el('conn-instructions').innerHTML = '';
|
||
el('connect-btn').textContent = t('Disconnect');
|
||
el('connect-btn').classList.remove('disconnected');
|
||
el('status-dot').className = 'status-dot ' + (data.capture_active ? 'live' : 'paused');
|
||
el('status-text').textContent = data.capture_active ? t('Capturing') : t('Connected');
|
||
if (data.capture_active) {
|
||
if (data.session_id && data.session_id !== STATE.currentSessionId) {
|
||
// A different capture session than the one we were viewing —
|
||
// reset the stream so we re-fetch it from the start.
|
||
STATE.currentSessionId = data.session_id;
|
||
STATE.messages = [];
|
||
_lastAfter = 0;
|
||
}
|
||
STATE.captureActive = true;
|
||
STATE.currentSessionId = data.session_id;
|
||
startPolling();
|
||
} else {
|
||
STATE.captureActive = false;
|
||
}
|
||
updateCaptureButton();
|
||
renderTraceEmptyState();
|
||
} catch (e) {
|
||
STATE.serverAvailable = false;
|
||
STATE.captureMode = '';
|
||
STATE.serverName = '';
|
||
STATE.serverVersion = '';
|
||
el('conn-status').textContent = connectionStatusText();
|
||
el('conn-status').className = 'conn-status disconnected';
|
||
updateCaptureModeLine();
|
||
el('version-warn').textContent = '';
|
||
const url = STATE.serverUrl || location.origin;
|
||
el('conn-hint').textContent = t(isLikelyBlocked() ? HINT_BLOCKED : HINT_UNREACHABLE).replace('{url}', url);
|
||
el('connect-btn').textContent = t('Connect');
|
||
el('connect-btn').classList.add('disconnected');
|
||
el('conn-instructions').innerHTML = `
|
||
<h3>${t('Setup')}</h3>
|
||
<ol>
|
||
<li>${t('Clone and install simtrace2-pysniff:')}<br>
|
||
<code>git clone https://github.com/anttro/simtrace2-pysniff.git</code><br>
|
||
<code>cd simtrace2-pysniff && pip install -e .</code></li>
|
||
<li>${t('Start the server in direct mode (recommended):')}<br>
|
||
<code>./server-start.sh --capture direct</code><br>
|
||
<span style="color:var(--text-muted);">${t('Requires SIMtrace2 hardware connected via USB; captures directly from the device.')}</span></li>
|
||
</ol>
|
||
<p style="margin-top:8px;">${t('Then set the URL above to http://127.0.0.1:8081 and return to the Trace tab.')}</p>
|
||
<h3 style="margin-top:20px;">${t('Alternative: GSMTAP mode')}</h3>
|
||
<p style="color:var(--text-muted); margin-top:4px;">${t('Use an external sniffer alongside the server. This mode is compatible with the original Osmocom simtrace2-sniff.')}</p>
|
||
<ol style="margin-top:4px;">
|
||
<li>${t('Start the server:')}<br>
|
||
<code>./server-start.sh --capture gsmtap</code></li>
|
||
<li>${t('Start a GSMTAP source in another terminal — either:')}<br>
|
||
<code>./sniff-start.sh --gsmtap 127.0.0.1</code> (simtrace2-pysniff)<br>
|
||
or<br>
|
||
<code>simtrace2-sniff --gsmtap-ip 127.0.0.1</code> ${t('(original Osmocom tool)')}</li>
|
||
</ol>`;
|
||
el('status-dot').className = 'status-dot off';
|
||
el('status-text').textContent = t('Not connected');
|
||
updateCaptureButton();
|
||
renderTraceEmptyState();
|
||
}
|
||
}
|
||
|
||
// ──────────────────── Tabs ────────────────────
|
||
function switchTab(name) {
|
||
STATE.activeTab = name;
|
||
document.querySelectorAll('.header-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
|
||
document.querySelectorAll('.panel').forEach(p => p.classList.toggle('active', p.id === 'panel-' + name));
|
||
if (name === 'sessions') loadSessions();
|
||
if (name === 'settings') checkConnection();
|
||
}
|
||
|
||
// ──────────────────── Search / Filter ────────────────────
|
||
let _searchTimer = null;
|
||
function onSearchChange() {
|
||
clearTimeout(_searchTimer);
|
||
_searchTimer = setTimeout(renderMessages, 200);
|
||
}
|
||
|
||
function onFilterChange() {
|
||
renderMessages();
|
||
}
|
||
|
||
// ──────────────────── Versioning ────────────────────
|
||
const PWA_VERSION = '1.7.0';
|
||
const MIN_SERVER_VERSION = '1.0.0';
|
||
|
||
function parseVersion(v) {
|
||
const parts = String(v || '').trim().split('.').map(n => parseInt(n, 10));
|
||
if (parts.length < 2 || parts.some(isNaN)) return null;
|
||
return parts;
|
||
}
|
||
|
||
function compareVersions(a, b) {
|
||
const pa = parseVersion(a);
|
||
const pb = parseVersion(b);
|
||
if (pa === null || pb === null) return null;
|
||
for (let i = 0; i < 3; i++) {
|
||
const da = pa[i] || 0;
|
||
const db = pb[i] || 0;
|
||
if (da !== db) return da < db ? -1 : 1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// ──────────────────── i18n ────────────────────
|
||
let currentLang = 'en';
|
||
|
||
const HINT_BLOCKED = 'The browser likely blocked access to the local server. Allow local network access for this site (Chrome/Edge/Vivaldi: Site settings → Local network access → Allow) and confirm simtrace2-pysniff-server is running at {url}.';
|
||
const HINT_UNREACHABLE = 'Couldn\'t reach the server. Check that simtrace2-pysniff-server is running and the URL is correct ({url}). If this page is served over HTTPS, also allow local network access in the browser (Site settings → Local network access → Allow).';
|
||
|
||
const LANG_RU = {
|
||
'Trace': 'Трассировка',
|
||
'Sessions': 'Сессии',
|
||
'Settings': 'Настройки',
|
||
'trace analyser': 'анализатор трейсов',
|
||
'Install PWA': 'Установить PWA',
|
||
'Search hex bytes...': 'Поиск по данным (hex)...',
|
||
'All commands': 'Все команды',
|
||
'Start Capture': 'Начать захват',
|
||
'Stop Capture': 'Остановить захват',
|
||
'Connect to server': 'Подключиться к серверу',
|
||
'Click an APDU to see details': 'Нажмите на APDU для просмотра деталей',
|
||
'Command Stats': 'Статистика команд',
|
||
'Stats': 'Статистика',
|
||
'Map': 'Обзор',
|
||
'Reads:': 'Чтение:',
|
||
'Writes:': 'Запись:',
|
||
'Read/write:': 'Чтение/запись:',
|
||
'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',
|
||
'{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');
|
||
}
|
||
|
||
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();
|
||
}
|
||
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();
|
||
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 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>
|