Compare commits
6 Commits
aa6a9ed1b5
...
f64f2dac7c
| Author | SHA1 | Date | |
|---|---|---|---|
| f64f2dac7c | |||
| 612f267349 | |||
| 00c84307aa | |||
| efbdb5024c | |||
| fa4064f1e8 | |||
| 71da10b637 |
+11
-2
@@ -371,7 +371,7 @@
|
||||
<li><strong>Новый профиль</strong> — создаёт пустой набор правил, запросив имя.</li>
|
||||
<li><strong>Профиль с карты</strong> — сканирует подключённую карту и создаёт по одному правилу на каждый существующий файл (см. ниже), затем открывает редактор.</li>
|
||||
<li><strong>Импорт профиля</strong> — загружает набор правил из JSON-файла (имя хранится внутри JSON).</li>
|
||||
<li>В каждой строке профиля показаны имя и время создания, а также действия <strong>Проверить карту ▶</strong>, <strong>Редактировать</strong>, <strong>Экспорт</strong> (скачать JSON) и <strong>Удалить</strong>.</li>
|
||||
<li>В каждой строке профиля показаны имя и время создания, а также действия <strong>Проверить карту ▶</strong>, <strong>Проверить снимок карты</strong>, <strong>Редактировать</strong>, <strong>Экспорт</strong> (скачать JSON) и <strong>Удалить</strong>.</li>
|
||||
</ul>
|
||||
<h4 class="font-medium mb-1">Правила файловой системы</h4>
|
||||
<p class="text-sm mb-2">Правила выполняются последовательно. Редактор показывает символьное имя файла pySim (если известно) рядом с путём правила; <strong>Добавить правило</strong> добавляет правило, <strong>Сохранить</strong> сохраняет изменения. Правило файловой системы задаётся:</p>
|
||||
@@ -385,7 +385,16 @@
|
||||
<h4 class="font-medium mb-1">Опции сканирования «Профиль с карты»</h4>
|
||||
<p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает селектор <strong>«Проверка FCP/FCI»</strong> (те же три режима, по умолчанию <strong>Тип файла + размер (FCP)</strong>), применяемый ко всем создаваемым правилам, а также список <strong>«Игнорировать содержимое файлов»</strong> (все отмечены по умолчанию, кроме <code class="font-mono text-sm">EF.ARR</code>; флажок в заголовке отмечает или снимает весь список) часто перезаписываемых файлов, содержимое которых пропускается: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Ещё две отмеченные по умолчанию опции <strong>«Сравнивать первые 4 байта для»</strong> <code class="font-mono text-sm">EF.IMSI</code> и <code class="font-mono text-sm">EF.ICCID</code> захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает <em>N / всего файлов</em> с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>«Пользовательские файлы»</strong> включаются с той же проверкой существования.</p>
|
||||
|
||||
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">5.7 Сценарии использования</h3>
|
||||
<h4 id="card-snapshots" class="font-medium mb-1">Снимки карт</h4>
|
||||
<p class="text-sm mb-2">Представление списка имеет две вкладки — <strong>«Профили»</strong> и <strong>«Снимки карт»</strong>. Снимок карты — неизменяемая фиксация файловой системы карты: для каждого существующего файла сохраняются путь, символьное имя, тип, размер (или длина/число записей), сырой FCI из ответа SELECT и содержимое, если файл читается (без списка игнорирования и без масок). ICCID декодируется из EF.ICCID и показывается рядом с именем снимка.</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Новый снимок</strong> — запрашивает имя и сканирует карту, затем возвращает к списку.</li>
|
||||
<li><strong>Импорт снимка</strong> — загружает снимок из JSON-файла.</li>
|
||||
<li>В каждой строке снимка — <strong>Открыть</strong>, <strong>Экспорт</strong> и <strong>Удалить</strong>. <strong>Открыть</strong> показывает все захваченные данные только для чтения (сырой FCI с декодированным FCI, содержимое); редактируется только имя снимка.</li>
|
||||
<li><strong>Проверить снимок карты</strong> в строке профиля выполняет правила профиля на выбранном из списка снимке, без картридера. Отчёт такой же, как при проверке карты; файлы, содержимое которых не было захвачено при сканировании, помечаются как непроверяемые ошибки.</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">5.7 Сценарии использования</h3>
|
||||
|
||||
<h4 id="scenario-a" class="font-medium mb-1">Сценарий A — Работа с файлами, не входящими в модель pySim («Пользовательские файлы»)</h4>
|
||||
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
|
||||
|
||||
+11
-2
@@ -371,7 +371,7 @@
|
||||
<li><strong>New profile</strong> — creates an empty ruleset after prompting for a name.</li>
|
||||
<li><strong>Profile from card</strong> — scans the equipped card and generates one rule per existing file (see below), then opens the editor.</li>
|
||||
<li><strong>Import profile</strong> — loads a ruleset from a JSON file (the name is stored inside the JSON).</li>
|
||||
<li>Each profile row shows its name and creation time, with <strong>Check card ▶</strong>, <strong>Edit</strong>, <strong>Export</strong> (download JSON), and <strong>Delete</strong> actions.</li>
|
||||
<li>Each profile row shows its name and creation time, with <strong>Check card ▶</strong>, <strong>Check card snapshot</strong>, <strong>Edit</strong>, <strong>Export</strong> (download JSON), and <strong>Delete</strong> actions.</li>
|
||||
</ul>
|
||||
<h4 class="font-medium mb-1">Filesystem rules</h4>
|
||||
<p class="text-sm mb-2">Rules run sequentially. The editor shows the symbolic pySim name (when known) next to each rule’s path; use <strong>Add rule</strong> to append one and <strong>Save</strong> to keep the changes. A filesystem rule is defined by:</p>
|
||||
@@ -385,7 +385,16 @@
|
||||
<h4 class="font-medium mb-1">“Profile from card” scan options</h4>
|
||||
<p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>“FCP/FCI check”</strong> selector (the same three modes above, default <strong>Filetype + size</strong>) applied to every generated rule, plus an <strong>“Ignore contents of files”</strong> checklist (all checked by default except <code class="font-mono text-sm">EF.ARR</code>; the header checkbox checks or unchecks the whole list) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Two further checked-by-default options <strong>“Match first 4 bytes for”</strong> <code class="font-mono text-sm">EF.IMSI</code> and <code class="font-mono text-sm">EF.ICCID</code> capture those files’ contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows <em>N / total files</em> with the current file path while scanning; during the scan the options are hidden and the buttons are locked. Rules are created only for files that actually exist on the card (a FCP template is returned); missing files are skipped. Custom files from the <strong>Custom files</strong> sub-tab are included under the same existence check.</p>
|
||||
|
||||
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">5.7 Usage scenarios</h3>
|
||||
<h4 id="card-snapshots" class="font-medium mb-1">Card snapshots</h4>
|
||||
<p class="text-sm mb-2">The list view has two tabs — <strong>Profiles</strong> and <strong>Card snapshots</strong>. A card snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name.</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>New snapshot</strong> — asks for a name and scans the card, then returns to the list.</li>
|
||||
<li><strong>Import snapshot</strong> — loads a snapshot from a JSON file.</li>
|
||||
<li>Each snapshot row has <strong>Open</strong>, <strong>Export</strong>, and <strong>Delete</strong>. <strong>Open</strong> shows all captured data read-only (raw FCI with the decoded FCI, contents); only the snapshot name is editable.</li>
|
||||
<li><strong>Check card snapshot</strong> on a profile row runs the profile rules against a snapshot you pick from the list, without a card reader. The report is the same as a live check; files whose contents were not captured during the scan are reported as unverifiable errors.</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">5.7 Usage scenarios</h3>
|
||||
|
||||
<h4 id="scenario-a" class="font-medium mb-1">Scenario A — Working with files not in pySim’s model (Custom files)</h4>
|
||||
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
|
||||
|
||||
+462
-38
@@ -813,13 +813,27 @@
|
||||
|
||||
<div id="pysim-sub-profiler" class="hidden">
|
||||
<div id="profiler-list">
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
<button onclick="profilerNew()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="New profile">New profile</button>
|
||||
<button onclick="profilerFromCard()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Profile from card">Profile from card</button>
|
||||
<button onclick="document.getElementById('profiler-import-file').click()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Import profile">Import profile</button>
|
||||
<input type="file" id="profiler-import-file" accept=".json,application/json" class="hidden" onchange="profilerImportFile(this)">
|
||||
<div class="flex gap-1 mb-3 border-b border-gray-300 dark:border-slate-600">
|
||||
<button class="profiler-list-tab px-4 py-1.5 text-sm rounded-t bg-blue-600 dark:bg-blue-500 text-white dark:text-white" data-list-tab="profiles" onclick="profilerListSwitch('profiles')" data-l10n="Profiles">Profiles</button>
|
||||
<button class="profiler-list-tab px-4 py-1.5 text-sm rounded-t bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-list-tab="snapshots" onclick="profilerListSwitch('snapshots')" data-l10n="Card snapshots">Card snapshots</button>
|
||||
</div>
|
||||
<div id="profiler-list-profiles">
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
<button onclick="profilerNew()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="New profile">New profile</button>
|
||||
<button onclick="profilerFromCard()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Profile from card">Profile from card</button>
|
||||
<button onclick="document.getElementById('profiler-import-file').click()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Import profile">Import profile</button>
|
||||
<input type="file" id="profiler-import-file" accept=".json,application/json" class="hidden" onchange="profilerImportFile(this)">
|
||||
</div>
|
||||
<div id="profiler-list-body"></div>
|
||||
</div>
|
||||
<div id="profiler-list-snapshots" class="hidden">
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
<button onclick="snapshotNew()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="New snapshot">New snapshot</button>
|
||||
<button onclick="document.getElementById('snapshot-import-file').click()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Import snapshot">Import snapshot</button>
|
||||
<input type="file" id="snapshot-import-file" accept=".json,application/json" class="hidden" onchange="snapshotImportFile(this)">
|
||||
</div>
|
||||
<div id="snapshot-list-body"></div>
|
||||
</div>
|
||||
<div id="profiler-list-body"></div>
|
||||
</div>
|
||||
<div id="profiler-editor" class="hidden">
|
||||
<div class="flex flex-wrap gap-2 items-center mb-3">
|
||||
@@ -839,6 +853,15 @@
|
||||
<div id="profiler-summary" class="mb-2 text-sm"></div>
|
||||
<div id="profiler-report"></div>
|
||||
</div>
|
||||
<div id="profiler-snapshot" class="hidden">
|
||||
<div class="flex flex-wrap gap-2 items-center mb-3">
|
||||
<button onclick="profilerBackToList()" class="px-2.5 py-1 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Back to list">Back to list</button>
|
||||
<input id="snapshot-name" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="Snapshot name">
|
||||
<span id="snapshot-iccid" class="text-xs font-mono text-gray-500 dark:text-slate-400"></span>
|
||||
<button onclick="snapshotSaveName()" class="px-2.5 py-1 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700" data-l10n="Save">Save</button>
|
||||
</div>
|
||||
<div id="snapshot-files"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pysim-sub-cmd" class="hidden">
|
||||
@@ -909,8 +932,8 @@
|
||||
|
||||
<div id="profiler-scan-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 max-w-sm w-full mx-4 max-h-[85vh] overflow-auto">
|
||||
<h3 class="text-lg font-semibold mb-4 text-gray-800 dark:text-slate-200" data-l10n="Profile from card">Profile from card</h3>
|
||||
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Profile name">Profile name</label>
|
||||
<h3 id="profiler-scan-title" class="text-lg font-semibold mb-4 text-gray-800 dark:text-slate-200" data-l10n="Profile from card">Profile from card</h3>
|
||||
<label id="profiler-scan-name-label" class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Profile name">Profile name</label>
|
||||
<input id="profiler-scan-name" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2 dark:bg-slate-800 mb-3" placeholder="Profile name">
|
||||
<div id="profiler-scan-options">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
@@ -938,6 +961,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="snapshot-pick-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 max-w-sm w-full mx-4 max-h-[85vh] overflow-auto">
|
||||
<h3 class="text-lg font-semibold mb-4 text-gray-800 dark:text-slate-200" data-l10n="Select card snapshot">Select card snapshot</h3>
|
||||
<div id="snapshot-pick-list" class="space-y-1 mb-4"></div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button onclick="document.getElementById('snapshot-pick-modal').classList.add('hidden')" class="px-3 py-1.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="stk-menu-panel" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 max-w-xs w-full max-h-[80vh] flex flex-col">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
@@ -1419,6 +1452,11 @@ function encIccid(iccid) {
|
||||
return swapNibbles(iccid);
|
||||
}
|
||||
|
||||
// EF.ICCID (2FE2): nibble-swapped E.118 digits, 19-20 digits, 'F' pad (TS 102 221 13.2).
|
||||
function decIccid(hex) {
|
||||
return swapNibbles((hex || '').replace(/[^0-9a-fA-F]/g, '')).replace(/F+$/, '');
|
||||
}
|
||||
|
||||
function buildApdu(cla, ins, p1, p2, p3, data) {
|
||||
let s = cla.toString(16).padStart(2, '0').toUpperCase() +
|
||||
ins.toString(16).padStart(2, '0').toUpperCase() +
|
||||
@@ -6950,6 +6988,10 @@ let profilerView = 'list';
|
||||
let profilerEditId = null;
|
||||
let profilerDraft = null;
|
||||
let profilerResults = null;
|
||||
let snapshots = [];
|
||||
let snapshotViewId = null;
|
||||
let _scanTarget = 'profile';
|
||||
let profilerListTab = 'profiles';
|
||||
|
||||
// Files whose contents are ignored (contents check omitted) when scanning.
|
||||
// Keyed by FID; display name is the pySim name.
|
||||
@@ -6990,6 +7032,21 @@ function profilerSave() {
|
||||
localStorage.setItem('otaman_profiles', JSON.stringify(profiles));
|
||||
}
|
||||
|
||||
function snapshotsLoad() {
|
||||
try {
|
||||
const d = localStorage.getItem('otaman_snapshots');
|
||||
snapshots = d ? JSON.parse(d) : [];
|
||||
} catch (e) { snapshots = []; }
|
||||
}
|
||||
|
||||
function snapshotsSave() {
|
||||
try {
|
||||
localStorage.setItem('otaman_snapshots', JSON.stringify(snapshots));
|
||||
} catch (e) {
|
||||
alert('Snapshot save failed: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function profilerNewId() {
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
@@ -7082,6 +7139,7 @@ function profilerRenderList() {
|
||||
html += '<span class="text-xs text-gray-400 dark:text-slate-500">(' + p.rules.length + ' ' + esc(t('rules')) + ')</span>';
|
||||
html += '<span class="ml-auto flex gap-1">';
|
||||
html += '<button onclick="profilerCheck(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700">' + esc(t('Check card')) + ' ▶</button>';
|
||||
html += '<button onclick="profilerCheckSnapshotPick(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-slate-600 text-white hover:bg-slate-700">' + esc(t('Check card snapshot')) + '</button>';
|
||||
html += '<button onclick="profilerEdit(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700">' + esc(t('Edit')) + '</button>';
|
||||
html += '<button onclick="profilerExport(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-gray-600 text-white hover:bg-gray-700">' + esc(t('Export')) + '</button>';
|
||||
html += '<button onclick="profilerDelete(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + esc(t('Delete')) + '</button>';
|
||||
@@ -7090,12 +7148,57 @@ function profilerRenderList() {
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function profilerListSwitch(name) {
|
||||
profilerListTab = name;
|
||||
document.querySelectorAll('.profiler-list-tab').forEach(btn => {
|
||||
const active = btn.dataset.listTab === name;
|
||||
btn.classList.toggle('bg-blue-600', active);
|
||||
btn.classList.toggle('dark:bg-blue-500', active);
|
||||
btn.classList.toggle('text-white', active);
|
||||
btn.classList.toggle('dark:text-white', active);
|
||||
btn.classList.toggle('bg-gray-200', !active);
|
||||
btn.classList.toggle('dark:bg-slate-700', !active);
|
||||
btn.classList.toggle('text-gray-700', !active);
|
||||
btn.classList.toggle('dark:text-slate-300', !active);
|
||||
});
|
||||
document.getElementById('profiler-list-profiles').classList.toggle('hidden', name !== 'profiles');
|
||||
document.getElementById('profiler-list-snapshots').classList.toggle('hidden', name !== 'snapshots');
|
||||
}
|
||||
|
||||
function profilerSetView(view) {
|
||||
profilerView = view;
|
||||
document.getElementById('profiler-list').classList.toggle('hidden', view !== 'list');
|
||||
document.getElementById('profiler-editor').classList.toggle('hidden', view !== 'editor');
|
||||
document.getElementById('profiler-results').classList.toggle('hidden', view !== 'results');
|
||||
if (view === 'list') profilerRenderList();
|
||||
document.getElementById('profiler-snapshot').classList.toggle('hidden', view !== 'snapshot');
|
||||
if (view === 'list') {
|
||||
profilerRenderList();
|
||||
profilerRenderSnapshotList();
|
||||
profilerListSwitch(profilerListTab);
|
||||
}
|
||||
}
|
||||
|
||||
function profilerRenderSnapshotList() {
|
||||
const el = document.getElementById('snapshot-list-body');
|
||||
if (!snapshots.length) {
|
||||
el.innerHTML = '<span class="text-gray-400">' + t('No snapshots defined.') + '</span>';
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
for (let i = 0; i < snapshots.length; i++) {
|
||||
const s = snapshots[i];
|
||||
html += '<div class="flex items-center gap-2 py-1.5 border-b border-gray-200 dark:border-slate-700">';
|
||||
html += '<b>' + esc(s.name) + '</b>';
|
||||
if (s.iccid) html += '<span class="text-xs font-mono text-gray-500 dark:text-slate-400">' + esc(s.iccid) + '</span>';
|
||||
html += '<span class="text-xs text-gray-400 dark:text-slate-500">' + esc(new Date(s.created).toLocaleString()) + '</span>';
|
||||
html += '<span class="text-xs text-gray-400 dark:text-slate-500">(' + s.files.length + ' ' + esc(t('files')) + ')</span>';
|
||||
html += '<span class="ml-auto flex gap-1">';
|
||||
html += '<button onclick="snapshotOpen(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700">' + esc(t('Open')) + '</button>';
|
||||
html += '<button onclick="snapshotExport(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-gray-600 text-white hover:bg-gray-700">' + esc(t('Export')) + '</button>';
|
||||
html += '<button onclick="snapshotDelete(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + esc(t('Delete')) + '</button>';
|
||||
html += '</span></div>';
|
||||
}
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function profilerNew() {
|
||||
@@ -7120,7 +7223,31 @@ function profilerScanIgnoreAllState() {
|
||||
all.indeterminate = checked > 0 && checked < boxes.length;
|
||||
}
|
||||
|
||||
function profilerScanSetTarget(target) {
|
||||
_scanTarget = target;
|
||||
const title = document.getElementById('profiler-scan-title');
|
||||
const label = document.getElementById('profiler-scan-name-label');
|
||||
const titleKey = target === 'snapshot' ? 'New snapshot' : 'Profile from card';
|
||||
const labelKey = target === 'snapshot' ? 'Snapshot name' : 'Profile name';
|
||||
title.setAttribute('data-l10n', titleKey);
|
||||
title.textContent = t(titleKey);
|
||||
label.setAttribute('data-l10n', labelKey);
|
||||
label.textContent = t(labelKey);
|
||||
document.getElementById('profiler-scan-options').classList.toggle('hidden', target === 'snapshot');
|
||||
}
|
||||
|
||||
// Re-translate the dynamically built mask labels in the open scan modal,
|
||||
// preserving the checkbox state (ignore-list labels are plain file names).
|
||||
function profilerScanRefreshOptions() {
|
||||
if (_scanTarget === 'snapshot') return;
|
||||
document.querySelectorAll('#profiler-scan-mask input[data-mask-fid]').forEach(cb => {
|
||||
const span = cb.parentElement ? cb.parentElement.querySelector('span') : null;
|
||||
if (span) span.textContent = t('Match first 4 bytes for') + ' ' + PROFILER_MASK_PREFIX4_FIDS[cb.getAttribute('data-mask-fid')];
|
||||
});
|
||||
}
|
||||
|
||||
function profilerFromCard() {
|
||||
profilerScanSetTarget('profile');
|
||||
document.getElementById('profiler-scan-name').value = '';
|
||||
document.getElementById('profiler-scan-name').readOnly = false;
|
||||
document.getElementById('profiler-scan-name').classList.remove('opacity-50');
|
||||
@@ -7154,11 +7281,25 @@ function profilerScanCancel() {
|
||||
document.getElementById('profiler-scan-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
function snapshotNew() {
|
||||
profilerScanSetTarget('snapshot');
|
||||
const nameEl = document.getElementById('profiler-scan-name');
|
||||
nameEl.value = '';
|
||||
nameEl.readOnly = false;
|
||||
nameEl.classList.remove('opacity-50');
|
||||
document.getElementById('profiler-scan-progress').classList.add('hidden');
|
||||
document.getElementById('profiler-scan-error').classList.add('hidden');
|
||||
document.getElementById('profiler-scan-cancel-btn').disabled = false;
|
||||
document.getElementById('profiler-scan-btn').disabled = false;
|
||||
document.getElementById('profiler-scan-btn').textContent = t('Scan');
|
||||
document.getElementById('profiler-scan-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function profilerScanStart() {
|
||||
const name = document.getElementById('profiler-scan-name').value.trim();
|
||||
const errEl = document.getElementById('profiler-scan-error');
|
||||
if (!name) {
|
||||
errEl.textContent = t('Please enter a profile name');
|
||||
errEl.textContent = t(_scanTarget === 'snapshot' ? 'Please enter a snapshot name' : 'Please enter a profile name');
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
@@ -7188,19 +7329,30 @@ async function profilerScanStart() {
|
||||
btn.disabled = true;
|
||||
btn.textContent = t('Scanning...');
|
||||
try {
|
||||
const rules = await profilerScanCard(ignoreFids, ignoreNames, fciMode, (done, total, path) => {
|
||||
const onProgress = (done, total, path) => {
|
||||
progEl.textContent = done + ' / ' + total + ' ' + t('files') + (path ? ' — ' + path : '');
|
||||
}, maskFids);
|
||||
const profile = { id: profilerNewId(), name: name, created: new Date().toISOString(), rules: rules };
|
||||
profiles.push(profile);
|
||||
profilerSave();
|
||||
profilerScanCancel();
|
||||
profilerEdit(profiles.length - 1);
|
||||
};
|
||||
if (_scanTarget === 'snapshot') {
|
||||
const files = await profilerScanCard(new Set(), new Set(), 'exact', onProgress, new Set(), 'snapshot');
|
||||
const snapshot = { id: profilerNewId(), name: name, created: new Date().toISOString(), iccid: profilerSnapshotIccid(files), files: files };
|
||||
snapshots.push(snapshot);
|
||||
snapshotsSave();
|
||||
profilerScanCancel();
|
||||
profilerSetView('list');
|
||||
profilerListSwitch('snapshots');
|
||||
} else {
|
||||
const rules = await profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, maskFids);
|
||||
const profile = { id: profilerNewId(), name: name, created: new Date().toISOString(), rules: rules };
|
||||
profiles.push(profile);
|
||||
profilerSave();
|
||||
profilerScanCancel();
|
||||
profilerEdit(profiles.length - 1);
|
||||
}
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.classList.remove('hidden');
|
||||
} finally {
|
||||
document.getElementById('profiler-scan-options').classList.remove('hidden');
|
||||
profilerScanSetTarget(_scanTarget);
|
||||
cancelBtn.disabled = false;
|
||||
nameEl.readOnly = false;
|
||||
nameEl.classList.remove('opacity-50');
|
||||
@@ -7214,7 +7366,7 @@ async function profilerScanStart() {
|
||||
// Phase 1 discovers every file (tree calls only) to know the total; phase 2
|
||||
// builds a rule per file, reporting progress via the optional onProgress
|
||||
// callback as onProgress(done, total, path).
|
||||
async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, maskFids) {
|
||||
async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, maskFids, mode) {
|
||||
const rules = [];
|
||||
const files = [];
|
||||
const seen = new Set();
|
||||
@@ -7268,7 +7420,9 @@ async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, ma
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const entry = files[i];
|
||||
if (onProgress) onProgress(i + 1, total, entry.path);
|
||||
const rule = await profilerBuildFileRule(entry.path, entry.child, ignoreFids, ignoreNames, fciMode, maskFids);
|
||||
const rule = mode === 'snapshot'
|
||||
? await profilerBuildSnapshotFile(entry.path, entry.child)
|
||||
: await profilerBuildFileRule(entry.path, entry.child, ignoreFids, ignoreNames, fciMode, maskFids);
|
||||
if (rule) rules.push(rule);
|
||||
}
|
||||
return rules;
|
||||
@@ -7317,6 +7471,45 @@ async function profilerBuildFileRule(path, c, ignoreFids, ignoreNames, fciMode,
|
||||
return rule;
|
||||
}
|
||||
|
||||
// Card snapshot entry: metadata + raw FCI + exact contents for every readable
|
||||
// file (no ignore list, no masking, no FCP/FCI check mode).
|
||||
async function profilerBuildSnapshotFile(path, c) {
|
||||
let sel;
|
||||
try {
|
||||
sel = await pysimFetch('/api/select', { path: path });
|
||||
} catch (e) { return null; }
|
||||
if (!sel || sel.exists !== true) return null;
|
||||
const isRecord = profilerContentKindForFileType(sel.file_type) === 'record';
|
||||
const file = {
|
||||
path: path,
|
||||
name: c.name || sel.name || null,
|
||||
fileType: sel.file_type || null,
|
||||
fileSize: isRecord ? null : ((sel.file_size === null || sel.file_size === undefined) ? null : sel.file_size),
|
||||
recordLen: (sel.record_len === null || sel.record_len === undefined) ? null : sel.record_len,
|
||||
numRecords: (sel.num_of_rec === null || sel.num_of_rec === undefined) ? null : sel.num_of_rec,
|
||||
fciHex: sel.fci_hex || null,
|
||||
content: null,
|
||||
};
|
||||
try {
|
||||
const rd = await pysimFetch('/api/read', { path: path, mode: 'raw' });
|
||||
if (rd && rd.success) {
|
||||
if (rd.records) {
|
||||
file.content = { kind: 'record', records: rd.records.map(r => ({ num: r.num, data: r.data })) };
|
||||
} else if (rd.data) {
|
||||
file.content = { kind: 'transparent', data: rd.data };
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return file;
|
||||
}
|
||||
|
||||
// Decode the ICCID from the captured EF.ICCID (2FE2) entry, if readable.
|
||||
function profilerSnapshotIccid(files) {
|
||||
const f = (files || []).find(x => x.name === 'EF.ICCID' || (x.path || '').toUpperCase().endsWith('/2FE2'));
|
||||
if (!f || !f.content || f.content.kind !== 'transparent' || !f.content.data) return null;
|
||||
return decIccid(f.content.data) || null;
|
||||
}
|
||||
|
||||
function profilerImportFile(input) {
|
||||
const file = input.files && input.files[0];
|
||||
input.value = '';
|
||||
@@ -7332,6 +7525,7 @@ function profilerImportFile(input) {
|
||||
profiles.push(obj);
|
||||
profilerSave();
|
||||
profilerRenderList();
|
||||
profilerListSwitch('profiles');
|
||||
} catch (e) {
|
||||
alert('Import error: ' + e.message);
|
||||
}
|
||||
@@ -7364,9 +7558,127 @@ function profilerEdit(i) {
|
||||
function profilerBackToList() {
|
||||
profilerDraft = null;
|
||||
profilerEditId = null;
|
||||
snapshotViewId = null;
|
||||
profilerSetView('list');
|
||||
}
|
||||
|
||||
function profilerValidateSnapshot(obj) {
|
||||
if (!obj || typeof obj !== 'object') return 'Not an object';
|
||||
if (!obj.name) return 'Missing name';
|
||||
if (!Array.isArray(obj.files)) return 'Missing files array';
|
||||
for (const f of obj.files) {
|
||||
if (!f || typeof f !== 'object') return 'Invalid file entry';
|
||||
if (!f.path) return 'File entry missing path';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function snapshotImportFile(input) {
|
||||
const file = input.files && input.files[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const obj = JSON.parse(reader.result);
|
||||
const err = profilerValidateSnapshot(obj);
|
||||
if (err) throw new Error(err);
|
||||
obj.id = profilerNewId();
|
||||
obj.created = obj.created || new Date().toISOString();
|
||||
obj.iccid = obj.iccid || profilerSnapshotIccid(obj.files);
|
||||
snapshots.push(obj);
|
||||
snapshotsSave();
|
||||
profilerRenderSnapshotList();
|
||||
profilerListSwitch('snapshots');
|
||||
} catch (e) {
|
||||
alert('Import error: ' + e.message);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
function snapshotExport(i) {
|
||||
const s = snapshots[i];
|
||||
downloadJson((s.name || 'snapshot').replace(/[^a-zA-Z0-9_-]+/g, '_') + '.json', s);
|
||||
}
|
||||
|
||||
function snapshotDelete(i) {
|
||||
const s = snapshots[i];
|
||||
if (!confirm(t('Delete snapshot') + ' "' + s.name + '"?') ) return;
|
||||
snapshots.splice(i, 1);
|
||||
snapshotsSave();
|
||||
profilerRenderSnapshotList();
|
||||
}
|
||||
|
||||
function snapshotOpen(i) {
|
||||
const s = snapshots[i];
|
||||
snapshotViewId = s.id;
|
||||
profilerSetView('snapshot');
|
||||
document.getElementById('snapshot-name').value = s.name;
|
||||
profilerRenderSnapshotData();
|
||||
}
|
||||
|
||||
// Re-render the immutable part of the snapshot view (ICCID + file cards)
|
||||
// without touching the (editable) name input.
|
||||
function profilerRenderSnapshotData() {
|
||||
const s = snapshots.find(x => x.id === snapshotViewId);
|
||||
if (!s) return;
|
||||
document.getElementById('snapshot-iccid').textContent = s.iccid || '';
|
||||
document.getElementById('snapshot-files').innerHTML = profilerRenderSnapshotFiles(s);
|
||||
}
|
||||
|
||||
function snapshotSaveName() {
|
||||
const s = snapshots.find(x => x.id === snapshotViewId);
|
||||
if (!s) return;
|
||||
const name = document.getElementById('snapshot-name').value.trim();
|
||||
if (!name) return;
|
||||
s.name = name;
|
||||
snapshotsSave();
|
||||
document.getElementById('snapshot-name').value = s.name;
|
||||
}
|
||||
|
||||
function profilerRenderSnapshotFiles(s) {
|
||||
if (!s.files.length) return '<span class="text-gray-400">' + t('No files captured.') + '</span>';
|
||||
let html = '';
|
||||
for (const f of s.files) {
|
||||
const name = profilerCustomNameForPath(f.path) || f.name;
|
||||
const title = name
|
||||
? '<span>' + esc(name) + '</span> <span class="text-gray-400 dark:text-slate-500">(' + esc(f.path) + ')</span>'
|
||||
: '<span>' + esc(f.path) + '</span>';
|
||||
html += '<div class="mb-2 p-2 border border-gray-200 dark:border-slate-600 rounded">';
|
||||
html += '<div class="font-mono text-sm">' + title + '</div>';
|
||||
const attrs = [];
|
||||
if (f.fileType) attrs.push(esc(t('File type')) + ': ' + esc(f.fileType));
|
||||
if (f.fileSize !== null && f.fileSize !== undefined) attrs.push(esc(t('Size')) + ': ' + esc(String(f.fileSize)));
|
||||
if (f.recordLen !== null && f.recordLen !== undefined) attrs.push(esc(t('Record length')) + ': ' + esc(String(f.recordLen)));
|
||||
if (f.numRecords !== null && f.numRecords !== undefined) attrs.push(esc(t('Record count')) + ': ' + esc(String(f.numRecords)));
|
||||
if (attrs.length) html += '<div class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">' + attrs.join(' · ') + '</div>';
|
||||
if (f.fciHex) {
|
||||
html += '<div class="mt-1 flex gap-3 items-start">';
|
||||
html += '<div class="flex-1 min-w-0"><div class="text-xs text-gray-500 dark:text-slate-400 mb-0.5">' + esc(t('FCI hex (raw SELECT response)')) + '</div>' +
|
||||
'<textarea readonly rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800 break-all">' + escHtml(f.fciHex) + '</textarea></div>';
|
||||
html += '<div class="flex-1 min-w-0"><div class="text-xs text-gray-500 dark:text-slate-400 mb-0.5">' + esc(t('Decoded FCI')) + '</div>' +
|
||||
'<div class="text-xs font-mono leading-relaxed text-gray-600 dark:text-slate-400 break-all">' + profilerFciPreviewItems(f.fciHex) + '</div></div>';
|
||||
html += '</div>';
|
||||
}
|
||||
html += '<div class="text-xs text-gray-500 dark:text-slate-400 mt-1 mb-0.5">' + esc(t('Contents')) + '</div>';
|
||||
if (!f.content) {
|
||||
html += '<div class="text-xs text-gray-400 dark:text-slate-500">' + esc(t('Not captured')) + '</div>';
|
||||
} else if (f.content.kind === 'record') {
|
||||
html += '<div class="space-y-0.5">';
|
||||
for (const rec of (f.content.records || [])) {
|
||||
html += '<div class="flex items-center gap-2"><span class="text-xs text-gray-500 w-8 shrink-0">' + rec.num + '</span>' +
|
||||
'<input readonly value="' + escHtml(rec.data) + '" class="flex-1 min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800"></div>';
|
||||
}
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += '<input readonly value="' + escHtml(f.content.data || '') + '" class="w-full min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800">';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function profilerAddRule() {
|
||||
if (!profilerDraft) return;
|
||||
profilerDraft.rules.push({ type: 'file', path: '', name: null, fileType: null, fileSize: null, recordLen: null, numRecords: null, fciMode: 'type_size', fciHex: null, content: null });
|
||||
@@ -7577,37 +7889,103 @@ function profilerSaveProfile() {
|
||||
|
||||
async function profilerCheck(i) {
|
||||
const p = profiles[i];
|
||||
await profilerRunProfile(p, null, p.name);
|
||||
}
|
||||
|
||||
async function profilerRunProfile(p, source, title) {
|
||||
profilerSetView('results');
|
||||
document.getElementById('profiler-results-title').textContent = p.name;
|
||||
document.getElementById('profiler-results-title').textContent = title;
|
||||
const prog = document.getElementById('profiler-progress');
|
||||
const summary = document.getElementById('profiler-summary');
|
||||
const report = document.getElementById('profiler-report');
|
||||
report.innerHTML = '';
|
||||
summary.innerHTML = '';
|
||||
document.getElementById('profiler-summary').innerHTML = '';
|
||||
document.getElementById('profiler-report').innerHTML = '';
|
||||
const results = [];
|
||||
let passed = 0, failed = 0, errors = 0;
|
||||
for (let ri = 0; ri < p.rules.length; ri++) {
|
||||
const r = p.rules[ri];
|
||||
prog.textContent = t('Checking') + ' ' + (ri + 1) + '/' + p.rules.length + ' — ' + r.path;
|
||||
const res = await profilerRunRule(r);
|
||||
results.push(res);
|
||||
results.push(await profilerRunRule(r, source));
|
||||
}
|
||||
prog.textContent = '';
|
||||
profilerResults = results;
|
||||
profilerRenderResultsView();
|
||||
}
|
||||
|
||||
function profilerCheckSnapshotPick(i) {
|
||||
if (!snapshots.length) {
|
||||
alert(t('No snapshots defined.'));
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
for (let si = 0; si < snapshots.length; si++) {
|
||||
const s = snapshots[si];
|
||||
html += '<button onclick="profilerCheckSnapshot(' + i + ',' + si + ')" class="w-full text-left px-3 py-2 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-100 dark:hover:bg-slate-700">' +
|
||||
'<span class="font-semibold text-gray-800 dark:text-slate-200">' + esc(s.name) + '</span>' +
|
||||
'<span class="block text-gray-500 dark:text-slate-400">' + esc(s.iccid || '') + ' · ' + esc(new Date(s.created).toLocaleString()) + ' · ' + (s.files ? s.files.length : 0) + ' ' + t('files') + '</span></button>';
|
||||
}
|
||||
document.getElementById('snapshot-pick-list').innerHTML = html;
|
||||
document.getElementById('snapshot-pick-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function profilerCheckSnapshot(i, si) {
|
||||
document.getElementById('snapshot-pick-modal').classList.add('hidden');
|
||||
const p = profiles[i];
|
||||
const s = snapshots[si];
|
||||
await profilerRunProfile(p, profilerSnapshotSource(s), p.name + ' — ' + s.name);
|
||||
}
|
||||
|
||||
function profilerRenderResultsView() {
|
||||
if (!profilerResults) return;
|
||||
let passed = 0, failed = 0, errors = 0;
|
||||
for (const res of profilerResults) {
|
||||
if (res.status === 'pass') passed++;
|
||||
else if (res.status === 'fail') failed++;
|
||||
else errors++;
|
||||
}
|
||||
prog.textContent = '';
|
||||
summary.innerHTML = '<span class="text-emerald-600 font-semibold">' + passed + ' ' + t('passed') + '</span>' +
|
||||
document.getElementById('profiler-summary').innerHTML = '<span class="text-emerald-600 font-semibold">' + passed + ' ' + t('passed') + '</span>' +
|
||||
' · <span class="text-red-600 font-semibold">' + failed + ' ' + t('failed') + '</span>' +
|
||||
' · <span class="text-yellow-600 font-semibold">' + errors + ' ' + t('errors') + '</span>';
|
||||
report.innerHTML = profilerRenderReport(results);
|
||||
document.getElementById('profiler-report').innerHTML = profilerRenderReport(profilerResults);
|
||||
}
|
||||
|
||||
async function profilerRunRule(r) {
|
||||
function profilerLiveSource() {
|
||||
return {
|
||||
select: path => pysimFetch('/api/select', { path: path }),
|
||||
read: path => pysimFetch('/api/read', { path: path, mode: 'raw' }),
|
||||
};
|
||||
}
|
||||
|
||||
function profilerSnapshotSource(snapshot) {
|
||||
const find = path => (snapshot.files || []).find(f => (f.path || '').toUpperCase() === (path || '').toUpperCase());
|
||||
return {
|
||||
select: async path => {
|
||||
const f = find(path);
|
||||
if (!f) return { exists: false };
|
||||
return {
|
||||
name: f.name,
|
||||
file_type: f.fileType,
|
||||
file_size: f.fileSize,
|
||||
record_len: f.recordLen,
|
||||
num_of_rec: f.numRecords,
|
||||
fci_hex: f.fciHex,
|
||||
exists: true,
|
||||
};
|
||||
},
|
||||
read: async path => {
|
||||
const f = find(path);
|
||||
if (!f) return { success: false };
|
||||
if (!f.content) return { success: false, sw: 'not-captured' };
|
||||
if (f.content.kind === 'record') return { success: true, records: f.content.records };
|
||||
return { success: true, data: f.content.data };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function profilerRunRule(r, source) {
|
||||
source = source || profilerLiveSource();
|
||||
const res = { path: r.path, name: null, status: 'pass', checks: [] };
|
||||
let sizeMismatch = false;
|
||||
let sel;
|
||||
try {
|
||||
sel = await pysimFetch('/api/select', { path: r.path });
|
||||
sel = await source.select(r.path);
|
||||
} catch (e) {
|
||||
res.status = 'error';
|
||||
res.error = e.message;
|
||||
@@ -7654,15 +8032,21 @@ async function profilerRunRule(r) {
|
||||
if (r.content) {
|
||||
let rd;
|
||||
try {
|
||||
rd = await pysimFetch('/api/read', { path: r.path, mode: 'raw' });
|
||||
rd = await source.read(r.path);
|
||||
} catch (e) {
|
||||
res.status = 'error';
|
||||
res.error = e.message;
|
||||
return res;
|
||||
}
|
||||
if (!rd || !rd.success) {
|
||||
res.status = 'fail';
|
||||
res.checks.push({ label: 'content', expected: 'readable', actual: 'read failed (' + (rd && rd.sw ? rd.sw : '?') + ')', ok: false });
|
||||
if (rd && rd.sw === 'not-captured') {
|
||||
res.status = 'error';
|
||||
res.error = t('Content not captured in snapshot');
|
||||
res.checks.push({ label: 'content', expected: 'captured', actual: t('not captured in snapshot'), ok: false });
|
||||
} else {
|
||||
res.status = 'fail';
|
||||
res.checks.push({ label: 'content', expected: 'readable', actual: 'read failed (' + (rd && rd.sw ? rd.sw : '?') + ')', ok: false });
|
||||
}
|
||||
} else if (r.content.kind === 'record') {
|
||||
const exp = r.content.records || [];
|
||||
const act = rd.records || [];
|
||||
@@ -8082,6 +8466,7 @@ function profilerRenderReport(results) {
|
||||
}
|
||||
|
||||
profilerLoad();
|
||||
snapshotsLoad();
|
||||
|
||||
// Init sub-tab pills
|
||||
document.querySelectorAll('.pysim-subtab').forEach(btn => {
|
||||
@@ -8283,6 +8668,22 @@ const LANG_RU = {
|
||||
'New profile': 'Новый профиль',
|
||||
'Profile from card': 'Профиль с карты',
|
||||
'Import profile': 'Импорт профиля',
|
||||
'Profiles': 'Профили',
|
||||
'Card snapshots': 'Снимки карт',
|
||||
'Check card snapshot': 'Проверить снимок карты',
|
||||
'Select card snapshot': 'Выберите снимок карты',
|
||||
'Content not captured in snapshot': 'Содержимое не захвачено в снимок',
|
||||
'not captured in snapshot': 'не захвачено в снимок',
|
||||
'New snapshot': 'Новый снимок',
|
||||
'Import snapshot': 'Импорт снимка',
|
||||
'Snapshot name': 'Имя снимка',
|
||||
'Delete snapshot': 'Удалить снимок',
|
||||
'No snapshots defined.': 'Снимки не заданы.',
|
||||
'No files captured.': 'Файлы не захвачены.',
|
||||
'Please enter a snapshot name': 'Введите имя снимка',
|
||||
'Open': 'Открыть',
|
||||
'Contents': 'Содержимое',
|
||||
'Not captured': 'Не прочитано',
|
||||
'Back to list': 'Назад к списку',
|
||||
'Add rule': 'Добавить правило',
|
||||
'Scan': 'Сканировать',
|
||||
@@ -8364,9 +8765,9 @@ function t(text) {
|
||||
function toggleLang() {
|
||||
currentLang = currentLang === 'en' ? 'ru' : 'en';
|
||||
localStorage.setItem('lang', currentLang);
|
||||
document.getElementById('lang-btn').textContent = currentLang.toUpperCase();
|
||||
translatePage();
|
||||
document.getElementById('lang-btn').textContent = currentLang.toUpperCase();
|
||||
translatePage();
|
||||
refreshDynamicI18n();
|
||||
updateHelpLink();
|
||||
}
|
||||
|
||||
@@ -8392,6 +8793,29 @@ function translatePage() {
|
||||
if (slogan) slogan.textContent = t('SIM OTA with a Human Face');
|
||||
}
|
||||
|
||||
function isViewVisible(id) {
|
||||
const el = document.getElementById(id);
|
||||
return !!el && !el.classList.contains('hidden');
|
||||
}
|
||||
|
||||
// Dynamic views build their labels with t() at render time; re-render the
|
||||
// visible ones after a language switch so they don't stay in the old language.
|
||||
function refreshDynamicI18n() {
|
||||
if (isViewVisible('pysim-sub-profiler')) {
|
||||
if (profilerView === 'list') { profilerRenderList(); profilerRenderSnapshotList(); }
|
||||
else if (profilerView === 'editor') profilerRenderEditor();
|
||||
else if (profilerView === 'results') profilerRenderResultsView();
|
||||
else if (profilerView === 'snapshot') profilerRenderSnapshotData();
|
||||
}
|
||||
if (isViewVisible('profiler-scan-modal')) profilerScanRefreshOptions();
|
||||
if (isViewVisible('scp80-sub-cards')) cardsRender();
|
||||
if (isViewVisible('pysim-sub-proactive')) {
|
||||
pysimEventsRender();
|
||||
pysimProactiveLogRender();
|
||||
pysimPliRender();
|
||||
}
|
||||
}
|
||||
|
||||
// Init language
|
||||
const userLang = (navigator.language || '').split('-')[0];
|
||||
currentLang = localStorage.getItem('lang') || (userLang === 'ru' ? 'ru' : 'en');
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'otaman-v79';
|
||||
const CACHE = 'otaman-v85';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -21,12 +21,14 @@ function extractFunc(src, name, asyncFn) {
|
||||
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState'];
|
||||
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource'];
|
||||
let code = '';
|
||||
for (const f of FNS) code += extractFunc(html, f) + '\n';
|
||||
code += extractFunc(html, 'profilerBuildFileRule', true) + '\n';
|
||||
code += extractFunc(html, 'profilerRunRule', true) + '\n';
|
||||
code += extractFunc(html, 'profilerScanCard', true) + '\n';
|
||||
code += extractFunc(html, 'profilerBuildSnapshotFile', true) + '\n';
|
||||
code += "var _scanTarget = 'profile';\n";
|
||||
code += html.match(/const PROFILER_MASK_PREFIX4_FIDS = \{[\s\S]*?\n\};/)[0] + '\n';
|
||||
eval(code);
|
||||
|
||||
@@ -819,3 +821,211 @@ test('profilerScanToggleAll / profilerScanIgnoreAllState manage the ignore check
|
||||
|
||||
delete global.document;
|
||||
});
|
||||
|
||||
// --- card snapshots ---
|
||||
|
||||
test('decIccid decodes nibble-swapped EF.ICCID digits and strips F padding', () => {
|
||||
assert.strictEqual(decIccid('980711090000640090F8'), '8970119000004600098');
|
||||
assert.strictEqual(decIccid('98103254769810325476'), '89012345678901234567');
|
||||
assert.strictEqual(decIccid('98 07 11 09 00 00 64 00 90 f8'), '8970119000004600098');
|
||||
assert.strictEqual(decIccid(''), '');
|
||||
});
|
||||
|
||||
test('profilerSnapshotIccid finds EF.ICCID by name or FID and handles null cases', () => {
|
||||
const hex = '980711090000640090F8';
|
||||
assert.strictEqual(
|
||||
profilerSnapshotIccid([{ path: 'MF/2FE2', name: 'EF.ICCID', content: { kind: 'transparent', data: hex } }]),
|
||||
'8970119000004600098');
|
||||
assert.strictEqual(
|
||||
profilerSnapshotIccid([{ path: 'MF/6F07', name: 'x', content: { kind: 'transparent', data: hex } }]),
|
||||
null);
|
||||
assert.strictEqual(
|
||||
profilerSnapshotIccid([{ path: 'MF/2FE2', name: 'EF.ICCID', content: null }]),
|
||||
null);
|
||||
assert.strictEqual(profilerSnapshotIccid([]), null);
|
||||
});
|
||||
|
||||
test('profilerValidateSnapshot checks name and files array', () => {
|
||||
assert.strictEqual(profilerValidateSnapshot(null), 'Not an object');
|
||||
assert.strictEqual(profilerValidateSnapshot({ name: 'x' }), 'Missing files array');
|
||||
assert.strictEqual(profilerValidateSnapshot({ name: 'x', files: 'nope' }), 'Missing files array');
|
||||
assert.strictEqual(profilerValidateSnapshot({ files: [] }), 'Missing name');
|
||||
assert.strictEqual(profilerValidateSnapshot({ name: 'x', files: [null] }), 'Invalid file entry');
|
||||
assert.strictEqual(profilerValidateSnapshot({ name: 'x', files: [{}] }), 'File entry missing path');
|
||||
assert.strictEqual(profilerValidateSnapshot({ name: 'x', files: [{ path: 'MF/6F07' }] }), null);
|
||||
});
|
||||
|
||||
test('profilerBuildSnapshotFile captures exact contents for everything readable', async () => {
|
||||
// transparent file (IMSI) is captured exactly, no mask
|
||||
mockFetch({
|
||||
'/api/select': () => ({ name: 'EF.IMSI', fid: '6F07', file_type: 'transparent', file_size: 9, record_len: null, num_of_rec: null, fci_hex: '6212', exists: true }),
|
||||
'/api/read': () => ({ success: true, data: '082905911234567890' }),
|
||||
});
|
||||
const f = await profilerBuildSnapshotFile('MF/6F07', { fid: '6f07', name: 'EF.IMSI' });
|
||||
assert.strictEqual(f.name, 'EF.IMSI');
|
||||
assert.strictEqual(f.fileType, 'transparent');
|
||||
assert.strictEqual(f.fileSize, 9);
|
||||
assert.strictEqual(f.fciHex, '6212');
|
||||
assert.deepStrictEqual(f.content, { kind: 'transparent', data: '082905911234567890' });
|
||||
|
||||
// record file
|
||||
mockFetch({
|
||||
'/api/select': () => ({ name: 'EF.ADN', fid: '6F3A', file_type: 'linear_fixed', file_size: null, record_len: 2, num_of_rec: 2, fci_hex: '620E', exists: true }),
|
||||
'/api/read': () => ({ success: true, records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }] }),
|
||||
});
|
||||
const r = await profilerBuildSnapshotFile('MF/7F20/6F3A', { fid: '6F3A', name: 'EF.ADN' });
|
||||
assert.strictEqual(r.recordLen, 2);
|
||||
assert.strictEqual(r.numRecords, 2);
|
||||
assert.deepStrictEqual(r.content, { kind: 'record', records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }] });
|
||||
|
||||
// unreadable -> content null, metadata kept
|
||||
mockFetch({
|
||||
'/api/select': () => ({ name: 'EF.Kc', fid: '6F20', file_type: 'transparent', file_size: 9, record_len: null, num_of_rec: null, fci_hex: '620A', exists: true }),
|
||||
'/api/read': () => ({ success: false, sw: '6982' }),
|
||||
});
|
||||
const u = await profilerBuildSnapshotFile('MF/7F20/6F20', { fid: '6F20', name: 'EF.Kc' });
|
||||
assert.strictEqual(u.content, null);
|
||||
assert.strictEqual(u.fileSize, 9);
|
||||
});
|
||||
|
||||
test('profilerScanCard snapshot mode builds snapshot entries with ICCID', async () => {
|
||||
global.pysimCustomFiles = [];
|
||||
global.pysimFetch = async (path, body) => {
|
||||
if (path === '/api/tree') return { exists: true, name: 'MF', children: [
|
||||
{ name: 'EF.ICCID', fid: '2fe2', isDir: false },
|
||||
{ name: 'EF.IMSI', fid: '6f07', isDir: false },
|
||||
] };
|
||||
if (path === '/api/select') {
|
||||
const fid = body.path.split('/').pop();
|
||||
if (fid === '2FE2') return { name: 'EF.ICCID', fid: '2FE2', file_type: 'transparent', file_size: 10, record_len: null, num_of_rec: null, fci_hex: '620E', exists: true };
|
||||
return { name: 'EF.IMSI', fid: '6F07', file_type: 'transparent', file_size: 9, record_len: null, num_of_rec: null, fci_hex: '6212', exists: true };
|
||||
}
|
||||
if (path === '/api/read') {
|
||||
return body.path.endsWith('2FE2')
|
||||
? { success: true, data: '980711090000640090F8' }
|
||||
: { success: true, data: '082905911234567890' };
|
||||
}
|
||||
throw new Error('unexpected ' + path);
|
||||
};
|
||||
const files = await profilerScanCard(new Set(), new Set(), 'exact', undefined, new Set(), 'snapshot');
|
||||
assert.strictEqual(files.length, 2);
|
||||
assert.ok(!files[0].fciMode, 'snapshot entries carry no FCI mode');
|
||||
assert.strictEqual(profilerSnapshotIccid(files), '8970119000004600098');
|
||||
// IMSI captured exactly (no mask)
|
||||
assert.strictEqual(files.find(f => f.name === 'EF.IMSI').content.data, '082905911234567890');
|
||||
delete global.pysimCustomFiles;
|
||||
});
|
||||
|
||||
test('profilerListSwitch toggles the profiles/snapshots tabs', () => {
|
||||
const mkBtn = tab => ({
|
||||
dataset: { listTab: tab },
|
||||
classList: { _c: new Set(), toggle(c, on) { if (on) this._c.add(c); else this._c.delete(c); } },
|
||||
});
|
||||
const btns = [mkBtn('profiles'), mkBtn('snapshots')];
|
||||
const profilesEl = { hidden: false, classList: { toggle(cls, on) { profilesEl.hidden = on; } } };
|
||||
const snapsEl = { hidden: true, classList: { toggle(cls, on) { snapsEl.hidden = on; } } };
|
||||
global.document = {
|
||||
querySelectorAll: sel => (sel === '.profiler-list-tab' ? btns : []),
|
||||
getElementById: id => (id === 'profiler-list-profiles' ? profilesEl : id === 'profiler-list-snapshots' ? snapsEl : null),
|
||||
};
|
||||
|
||||
profilerListSwitch('snapshots');
|
||||
assert.strictEqual(profilesEl.hidden, true);
|
||||
assert.strictEqual(snapsEl.hidden, false);
|
||||
assert.ok(btns[0].classList._c.has('bg-gray-200'));
|
||||
assert.ok(btns[1].classList._c.has('bg-blue-600'));
|
||||
|
||||
profilerListSwitch('profiles');
|
||||
assert.strictEqual(profilesEl.hidden, false);
|
||||
assert.strictEqual(snapsEl.hidden, true);
|
||||
assert.ok(btns[0].classList._c.has('bg-blue-600'));
|
||||
assert.ok(btns[1].classList._c.has('bg-gray-200'));
|
||||
|
||||
delete global.document;
|
||||
});
|
||||
|
||||
test('profilerScanRefreshOptions re-translates mask labels without touching checkbox state', () => {
|
||||
global.t = s => (s === 'Match first 4 bytes for' ? 'FIRST4' : s);
|
||||
const span = { textContent: '' };
|
||||
const cb = {
|
||||
checked: true,
|
||||
parentElement: { querySelector: sel => (sel === 'span' ? span : null) },
|
||||
getAttribute: () => '6F07',
|
||||
};
|
||||
global.document = {
|
||||
querySelectorAll: sel => (sel === '#profiler-scan-mask input[data-mask-fid]' ? [cb] : []),
|
||||
};
|
||||
profilerScanRefreshOptions();
|
||||
assert.strictEqual(span.textContent, 'FIRST4 EF.IMSI');
|
||||
assert.strictEqual(cb.checked, true);
|
||||
delete global.document;
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
|
||||
function snapFile(path, extra) {
|
||||
return {
|
||||
path: path, name: 'EF.X', fileType: 'transparent', fileSize: 4,
|
||||
recordLen: null, numRecords: null, fciHex: '621082024021',
|
||||
content: { kind: 'transparent', data: 'AABB' }, ...(extra || {}),
|
||||
};
|
||||
}
|
||||
|
||||
test('profilerSnapshotSource selects file metadata by path (case-insensitive)', async () => {
|
||||
const src = profilerSnapshotSource({ files: [snapFile('MF/7F20/6F3A')] });
|
||||
const sel = await src.select('mf/7f20/6f3a');
|
||||
assert.strictEqual(sel.exists, true);
|
||||
assert.strictEqual(sel.file_type, 'transparent');
|
||||
assert.strictEqual(sel.file_size, 4);
|
||||
assert.strictEqual(sel.fci_hex, '621082024021');
|
||||
});
|
||||
|
||||
test('profilerSnapshotSource reports a missing file as not existing', async () => {
|
||||
const src = profilerSnapshotSource({ files: [snapFile('MF/7F20/6F3A')] });
|
||||
assert.deepStrictEqual(await src.select('MF/6F07'), { exists: false });
|
||||
});
|
||||
|
||||
test('profilerSnapshotSource read returns captured transparent data and records', async () => {
|
||||
const src = profilerSnapshotSource({ files: [
|
||||
snapFile('MF/6F3A'),
|
||||
snapFile('MF/6F3B', { fileType: 'linear_fixed', fileSize: null, recordLen: 2, numRecords: 1, content: { kind: 'record', records: [{ num: 1, data: 'AABB' }] } }),
|
||||
] });
|
||||
assert.strictEqual((await src.read('MF/6F3A')).data, 'AABB');
|
||||
assert.deepStrictEqual((await src.read('MF/6F3B')).records, [{ num: 1, data: 'AABB' }]);
|
||||
});
|
||||
|
||||
test('profilerSnapshotSource flags uncaptured contents', async () => {
|
||||
const src = profilerSnapshotSource({ files: [snapFile('MF/6F3A', { content: null })] });
|
||||
assert.deepStrictEqual(await src.read('MF/6F3A'), { success: false, sw: 'not-captured' });
|
||||
});
|
||||
|
||||
test('profilerRunRule against a snapshot passes on matching metadata and content', async () => {
|
||||
const src = profilerSnapshotSource({ files: [snapFile('MF/6F3A')] });
|
||||
const res = await profilerRunRule({ path: 'MF/6F3A', fileType: 'transparent', fileSize: 4, fciMode: 'type_size', content: { mode: 'exact', kind: 'transparent', expected: 'AABB' } }, src);
|
||||
assert.strictEqual(res.status, 'pass');
|
||||
});
|
||||
|
||||
test('profilerRunRule against a snapshot fails on a missing file', async () => {
|
||||
const src = profilerSnapshotSource({ files: [] });
|
||||
const res = await profilerRunRule({ path: 'MF/6F3A' }, src);
|
||||
assert.strictEqual(res.status, 'fail');
|
||||
assert.ok(res.checks.some(c => c.label === 'exists' && c.ok === false));
|
||||
});
|
||||
|
||||
test('profilerRunRule against a snapshot fails on metadata mismatch', async () => {
|
||||
const src = profilerSnapshotSource({ files: [snapFile('MF/6F3A', { fileSize: 9, fciHex: '621082024022' })] });
|
||||
const res = await profilerRunRule({ path: 'MF/6F3A', fileType: 'transparent', fileSize: 4, fciMode: 'exact', fciHex: '621082024021' }, src);
|
||||
assert.strictEqual(res.status, 'fail');
|
||||
assert.ok(res.checks.some(c => c.label === 'fileSize' && c.ok === false));
|
||||
assert.ok(res.checks.some(c => c.label === 'fci' && c.ok === false));
|
||||
});
|
||||
|
||||
test('profilerRunRule against a snapshot reports uncaptured content as an error', async () => {
|
||||
global.t = s => s;
|
||||
const src = profilerSnapshotSource({ files: [snapFile('MF/6F3A', { content: null })] });
|
||||
const res = await profilerRunRule({ path: 'MF/6F3A', fileType: 'transparent', fileSize: 4, content: { mode: 'exact', kind: 'transparent', expected: 'AABB' } }, src);
|
||||
assert.strictEqual(res.status, 'error');
|
||||
assert.strictEqual(res.error, 'Content not captured in snapshot');
|
||||
assert.ok(res.checks.some(c => c.label === 'content' && c.ok === false));
|
||||
delete global.t;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user