profiler: card snapshots (Снимки карты) list, scan, view
New read-only capture entity stored in localStorage 'otaman_snapshots': - profiler list view is now two labelled columns: Profiles and Card snapshots; each snapshot row shows name, decoded ICCID, date, file count and Open / Export / Delete buttons - New snapshot reuses the scan modal in a snapshot mode (name only, no ignore/mask/FCP options) via profilerScanCard(..., 'snapshot') -> profilerBuildSnapshotFile: metadata + raw FCI + exact contents for every readable file (no ignore list, no masking). The scan returns to the list afterwards. - ICCID is decoded from EF.ICCID (2FE2) with decIccid() (nibble-swapped E.118 digits, F pad per TS 102 221 13.2) and stored as an immutable snapshot field, shown next to the name in the list and the view. - Open shows all captured data read-only (attributes, raw FCI + decoded FCI, contents or 'Not captured'); only the name is editable/saveable. - Import/Export/Delete + profilerValidateSnapshot; quota-safe save. Tests: decIccid, profilerSnapshotIccid, validation, snapshot builder (exact contents/no mask/unreadable), snapshot-mode scan with ICCID. Docs synced (help/help-ru). SW cache v79 -> v80.
This commit is contained in:
@@ -385,7 +385,15 @@
|
||||
<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>
|
||||
</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">
|
||||
|
||||
+9
-1
@@ -385,7 +385,15 @@
|
||||
<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 shows two columns — <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>
|
||||
</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">
|
||||
|
||||
+291
-20
@@ -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="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-sm font-semibold mb-2 text-gray-700 dark:text-slate-300" data-l10n="Profiles">Profiles</div>
|
||||
<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>
|
||||
<div class="text-sm font-semibold mb-2 text-gray-700 dark:text-slate-300" data-l10n="Card snapshots">Card snapshots</div>
|
||||
<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>
|
||||
<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">
|
||||
@@ -1419,6 +1442,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 +6978,9 @@ let profilerView = 'list';
|
||||
let profilerEditId = null;
|
||||
let profilerDraft = null;
|
||||
let profilerResults = null;
|
||||
let snapshots = [];
|
||||
let snapshotViewId = null;
|
||||
let _scanTarget = 'profile';
|
||||
|
||||
// Files whose contents are ignored (contents check omitted) when scanning.
|
||||
// Keyed by FID; display name is the pySim name.
|
||||
@@ -6990,6 +7021,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);
|
||||
}
|
||||
@@ -7095,7 +7141,31 @@ function profilerSetView(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(); }
|
||||
}
|
||||
|
||||
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 +7190,21 @@ 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');
|
||||
}
|
||||
|
||||
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 +7238,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 +7286,29 @@ 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');
|
||||
} 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 +7322,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 +7376,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 +7427,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 = '';
|
||||
@@ -7364,9 +7513,118 @@ 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();
|
||||
} 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;
|
||||
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 });
|
||||
@@ -8082,6 +8340,7 @@ function profilerRenderReport(results) {
|
||||
}
|
||||
|
||||
profilerLoad();
|
||||
snapshotsLoad();
|
||||
|
||||
// Init sub-tab pills
|
||||
document.querySelectorAll('.pysim-subtab').forEach(btn => {
|
||||
@@ -8283,6 +8542,18 @@ const LANG_RU = {
|
||||
'New profile': 'Новый профиль',
|
||||
'Profile from card': 'Профиль с карты',
|
||||
'Import profile': 'Импорт профиля',
|
||||
'Profiles': 'Профили',
|
||||
'Card snapshots': 'Снимки карты',
|
||||
'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': 'Сканировать',
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'otaman-v79';
|
||||
const CACHE = 'otaman-v80';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -21,12 +21,13 @@ 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'];
|
||||
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 += html.match(/const PROFILER_MASK_PREFIX4_FIDS = \{[\s\S]*?\n\};/)[0] + '\n';
|
||||
eval(code);
|
||||
|
||||
@@ -819,3 +820,97 @@ 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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user