Files
simtrace-analyser/frontend/index.html
T
catarrh efcb6c6c3f Version 2.7.11: the Windows launcher and install-doc fixes
Ships the launcher/doc commit on top of v2.7.10 as a release:
- start.bat passes --db/DB to the server (it only stored the value before) and
  sends --gsmtap-port only for gsmtap capture, like start.sh;
- both .bat launchers quote their values and keep the quoting of pass-through
  options, so paths with spaces work (--db, --web-dir, --pcap, --output);
- the READMEs describe both install paths (release artifacts or a git clone)
  and document the launchers and the Windows python-on-PATH requirement.

Version strings in version.py, pyproject.toml, PWA_VERSION and the version
test move together; SW cache simtrace-analyser-v93.
2026-10-20 23:18:57 +03:00

3901 lines
185 KiB
HTML
Raw Blame History

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