diff --git a/frontend/help-ru.html b/frontend/help-ru.html
index 3ea079a..222d1d6 100644
--- a/frontend/help-ru.html
+++ b/frontend/help-ru.html
@@ -352,9 +352,8 @@
6.1 Файловый менеджер
Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по FID или символьному имени (пиллы и кнопка «Проверить все файлы» закреплены над прокручиваемым деревом; выбор сохраняется в localStorage). При выборе файла над содержимым также показываются FID, тип файла, размер / структура записей и декодированный FCI.
- Прочитать — чтение файла (автоопределение transparent/record)
- Редактировать — изменение hex-данных, Сохранить для записи (или Отмена )
- Данные как на карте / Декодированные данные — переключение между hex-дампом и таблицей декодированных полей (клиентские декодеры EF: IMSI, ICCID, SPN, списки PLMN, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); серверный pySim JSON того же чтения остаётся доступен в свёрнутом блоке pySim JSON (сервер)
+ Читать сырые данные / Декодировать — чтение выбранного файла в виде hex-дампа или таблицы декодированных полей (клиентские декодеры EF: IMSI, ICCID, SPN, списки PLMN, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); серверный pySim JSON того же чтения остаётся доступен в свёрнутом блоке pySim JSON (сервер) . Подсвеченная кнопка — текущий вид; нажатие любой из них (пере)читывает файл.
+ Редактировать сырые данные — изменение hex-данных, Сохранить для записи (или Отмена ); transparent-файлы редактируются в textarea, record-файлы — по одному полю на запись с флажками выбора. Декодированный вид доступен только для чтения — «Редактировать сырые данные» сначала переключает на hex-вид и при необходимости читает файл.
Проверить все файлы — обход всего дерева (включая пользовательские файлы) с пометкой каждого элемента: есть (обычный вид) или нет (красный ✗, без стрелки разворачивания); существующие пустые DF показывают (пусто). Отображается прогресс N / всего , обход можно остановить; в конце — сводка «есть/нет». Файлы проверяются только при разворачивании или проверке — просмотр остаётся ленивым.
ADM — файлы, требующие администраторский PIN, возвращают 6982/9804; если в подходящей предустановке карты (тот же ICCID) есть ключ ADM, рядом с ошибкой появляется кнопка «Проверить ADM» , а значок в заголовке (ADM ✓/✗ ⚿) становится кликабельным. Каждый неверный ключ расходует попытку: остаток показывается, а повторная попытка требует подтверждения. Заблокированный ADM здесь не восстановить — нужен ключ разблокировки карты.
diff --git a/frontend/help.html b/frontend/help.html
index 2841a93..80f6ff2 100644
--- a/frontend/help.html
+++ b/frontend/help.html
@@ -352,9 +352,8 @@
6.1 File manager
The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by FID or symbolic Name (pills pinned above the scrolling tree together with Probe all files ; the choice is remembered in localStorage). Selecting a file also shows its FID, file type, size / record layout and the decoded FCI above the content pane.
- Read — reads the selected file (auto-detects transparent vs record files)
- Edit — modify hex data, Save to write back (or Cancel )
- Raw / Decoded — toggle between the hex dump and a decoded field table (client-side EF decoders: IMSI, ICCID, SPN, PLMN lists, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, service tables, SUME, …); the server-side pySim JSON of the same read stays available in the collapsed pySim JSON (server) disclosure
+ Read raw / Read decoded — read the selected file as a hex dump or as a decoded field table (client-side EF decoders: IMSI, ICCID, SPN, PLMN lists, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, service tables, SUME, …); the server-side pySim JSON of the same read stays available in the collapsed pySim JSON (server) disclosure. The highlighted pill is the current view; clicking either pill (re-)reads the file.
+ Edit raw — edit the raw hex data, Save to write back (or Cancel ); transparent files use a textarea, record files one input per record with selection checkboxes. The decoded view is read-only — Edit raw switches to the hex view first and reads the file if needed.
Probe all files — walks the whole tree (including custom files) and marks every entry present (normal) or absent (red ✗, no expand arrow); empty-but-present DFs show (empty). Shows progress N / total , can be stopped, and finishes with a present/absent summary. Files are only verified when expanded or probed — browsing stays lazy.
ADM — files that need the administrator PIN fail with 6982/9804; if the matching card preset (same ICCID) carries an ADM key, a Verify ADM button appears next to the error and the header badge (ADM ✓/✗ ⚿) becomes clickable. Every wrong key consumes an attempt: the remaining attempts are shown and a retry asks for confirmation. A blocked ADM cannot be recovered here — it needs the card’s unblock key.
diff --git a/frontend/index.html b/frontend/index.html
index c3f9dc3..790f040 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -861,13 +861,13 @@
-
Read
-
Save
-
Cancel
-
|
-
Raw
-
Decoded
-
Edit
+
Read raw
+
Read decoded
+
+ Save
+ Cancel
+ Edit raw
+
@@ -1413,7 +1413,7 @@
// ===== Version =====
// Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect().
-const SIMPLE_VERSION = '2.7.8';
+const SIMPLE_VERSION = '2.7.9';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -7176,6 +7176,7 @@ async function pysimFsClickFile(name) {
}
async function pysimFsRead() {
+ if (!pysimFsSelected) return false;
const rawName = pysimFsSelected;
const out = document.getElementById('pysim-fs-content');
const statusEl = document.getElementById('pysim-fs-status');
@@ -7188,7 +7189,7 @@ async function pysimFsRead() {
const data = await pysimFetch('/api/read', body);
if (!data.success) {
pysimFsShowError(statusEl, data.sw, data.error);
- return;
+ return false;
}
statusEl.textContent = 'SW: ' + data.sw + ' OK';
if (pysimFsDecodedMode) {
@@ -7206,8 +7207,10 @@ async function pysimFsRead() {
} else {
out.innerHTML = '';
}
+ return true;
} catch (e) {
statusEl.textContent = 'Error: ' + e.message;
+ return false;
}
}
@@ -7267,13 +7270,22 @@ async function pysimFsLoadPySimJson() {
let pysimFsEditMode = false;
let pysimFsEditData = null;
-function pysimFsEdit() {
+// Edit raw: transparent files become an editable textarea, record files
+// per-record inputs with selection checkboxes. The decoded view is a
+// read-only field table and a freshly selected file has no content yet, so
+// make sure the editable raw view is loaded first (the pills are read
+// actions; Edit switches back to raw and reads on demand).
+async function pysimFsEdit() {
+ const out = document.getElementById('pysim-fs-content');
+ if (pysimFsDecodedMode || !out.innerHTML.trim()) {
+ pysimFsSetMode('raw', true);
+ if (!await pysimFsRead()) return;
+ }
pysimFsEditMode = true;
- document.getElementById('pysim-fs-read-btn').classList.add('hidden');
document.getElementById('pysim-fs-edit-btn').classList.add('hidden');
document.getElementById('pysim-fs-save-btn').classList.remove('hidden');
document.getElementById('pysim-fs-cancel-btn').classList.remove('hidden');
- const out = document.getElementById('pysim-fs-content');
+ pysimFsPillsEnabled(false);
out.querySelectorAll('textarea, input:not([type=checkbox])').forEach(el => el.removeAttribute('readonly'));
out.querySelectorAll('.pysim-fs-record-cb').forEach(cb => cb.classList.remove('hidden'));
out.querySelectorAll('input:not([type=checkbox])').forEach(el => {
@@ -7287,10 +7299,10 @@ function pysimFsEdit() {
function pysimFsCancel() {
pysimFsEditMode = false;
- document.getElementById('pysim-fs-read-btn').classList.remove('hidden');
document.getElementById('pysim-fs-edit-btn').classList.remove('hidden');
document.getElementById('pysim-fs-save-btn').classList.add('hidden');
document.getElementById('pysim-fs-cancel-btn').classList.add('hidden');
+ pysimFsPillsEnabled(true);
const out = document.getElementById('pysim-fs-content');
if (pysimFsEditData) out.innerHTML = pysimFsEditData;
pysimFsEditData = null;
@@ -7350,7 +7362,12 @@ async function pysimFsSave() {
}
}
-function pysimFsSetMode(mode) {
+// The Read raw / Read decoded pills are the read actions: they switch the
+// view mode and (re-)read the selected file. `skipRead` updates only the
+// pills — used by pysimFsEdit() right before its own read. While editing
+// they are inert: re-rendering would discard unsaved changes.
+function pysimFsSetMode(mode, skipRead) {
+ if (pysimFsEditMode) return;
pysimFsDecodedMode = mode === 'dec';
document.querySelectorAll('.pysim-mode-pill').forEach(el => {
const active = el.dataset.mode === mode;
@@ -7361,18 +7378,25 @@ function pysimFsSetMode(mode) {
el.classList.toggle('text-gray-700', !active);
el.classList.toggle('dark:text-slate-300', !active);
});
- // Re-render the open file in the new mode (not while editing: that would
- // discard unsaved changes).
- if (pysimFsSelected && !pysimFsEditMode) pysimFsRead();
+ if (!skipRead && pysimFsSelected) pysimFsRead();
+}
+
+// Visual cue for the inert pills while editing (pysimFsSetMode guards the
+// click; this only dims them).
+function pysimFsPillsEnabled(enabled) {
+ document.querySelectorAll('.pysim-mode-pill').forEach(el => {
+ el.classList.toggle('opacity-50', !enabled);
+ el.classList.toggle('pointer-events-none', !enabled);
+ });
}
function pysimFsResetEdit() {
pysimFsEditMode = false;
pysimFsEditData = null;
- document.getElementById('pysim-fs-read-btn').classList.remove('hidden');
document.getElementById('pysim-fs-edit-btn').classList.remove('hidden');
document.getElementById('pysim-fs-save-btn').classList.add('hidden');
document.getElementById('pysim-fs-cancel-btn').classList.add('hidden');
+ pysimFsPillsEnabled(true);
}
// ===== proactive UICC events =====
@@ -13055,11 +13079,12 @@ const LANG_RU = {
'Custom files': 'Пользовательские файлы',
'pySim command line': 'Командная строка pySim',
'Raw APDU': 'Отправка APDU',
- 'Read': 'Прочитать',
+ 'Read raw': 'Читать сырые данные',
+ 'Read decoded': 'Декодировать',
+ 'Edit raw': 'Редактировать сырые данные',
'Refresh': 'Обновить',
'Last refresh': 'Последнее обновление',
'Save': 'Сохранить',
- 'Raw': 'Данные как на карте',
'Decoded': 'Декодированные данные',
'Edit': 'Редактировать',
'Clone': 'Клонировать',
diff --git a/frontend/sw.js b/frontend/sw.js
index 30b4c27..e96fd18 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v211';
+const CACHE = 'simple-v212';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/fs_edit.test.js b/frontend/tests/fs_edit.test.js
new file mode 100644
index 0000000..9a6c526
--- /dev/null
+++ b/frontend/tests/fs_edit.test.js
@@ -0,0 +1,188 @@
+const { test } = require('node:test');
+const assert = require('node:assert');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
+
+function extractFunc(src, name) {
+ const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
+ const m = re.exec(src);
+ if (!m) throw new Error('function ' + name + ' not found');
+ let i = m.index + m[0].length - 1;
+ let depth = 0;
+ for (; i < src.length; i++) {
+ if (src[i] === '{') depth++;
+ else if (src[i] === '}') {
+ depth--;
+ if (depth === 0) break;
+ }
+ }
+ return src.slice(m.index, i + 1);
+}
+
+let code = 'var pysimFsDecodedMode = false;\n'
+ + 'var pysimFsEditMode = false;\n'
+ + 'var pysimFsEditData = null;\n'
+ + 'var pysimFsSelected = null;\n';
+for (const fn of ['pysimFsSetMode', 'pysimFsPillsEnabled', 'pysimFsEdit',
+ 'pysimFsCancel', 'pysimFsResetEdit']) {
+ code += extractFunc(html, fn) + '\n';
+}
+eval(code);
+
+let events = [];
+let readCalls = 0;
+let readResult = true;
+let content, buttons, pills, editables, checkboxes, inputs;
+
+function fakeClassList() {
+ const set = new Set();
+ return {
+ add: (...c) => c.forEach(x => set.add(x)),
+ remove: (...c) => c.forEach(x => set.delete(x)),
+ toggle: (c, on) => { if (on) set.add(c); else set.delete(c); },
+ contains: c => set.has(c),
+ };
+}
+
+function fakeEl() {
+ return { classList: fakeClassList(), dataset: {} };
+}
+
+function setup(opts) {
+ opts = opts || {};
+ events = [];
+ readCalls = 0;
+ readResult = opts.readResult !== false;
+ editables = [{ removeAttribute: () => events.push('readonly-removed') }];
+ checkboxes = [fakeEl()];
+ inputs = [{ addEventListener: () => {} }];
+ content = fakeEl();
+ content.innerHTML = opts.contentHtml || '';
+ content.querySelectorAll = sel => {
+ if (sel === '.pysim-fs-record-cb') return checkboxes;
+ if (sel.indexOf('textarea') === 0) return editables;
+ return inputs;
+ };
+ buttons = {
+ 'pysim-fs-edit-btn': fakeEl(),
+ 'pysim-fs-save-btn': fakeEl(),
+ 'pysim-fs-cancel-btn': fakeEl(),
+ };
+ buttons['pysim-fs-save-btn'].classList.add('hidden');
+ buttons['pysim-fs-cancel-btn'].classList.add('hidden');
+ pills = [fakeEl(), fakeEl()];
+ pills[0].dataset.mode = 'raw';
+ pills[1].dataset.mode = 'dec';
+
+ pysimFsDecodedMode = !!opts.decoded;
+ pysimFsEditMode = false;
+ pysimFsEditData = null;
+ pysimFsSelected = 'EF.IMSI';
+
+ globalThis.document = {
+ getElementById: id => id === 'pysim-fs-content' ? content : (buttons[id] || null),
+ querySelectorAll: sel => sel === '.pysim-mode-pill' ? pills : [],
+ };
+ globalThis.pysimFsRead = async () => {
+ readCalls++;
+ events.push('read');
+ return readResult;
+ };
+ return {
+ content,
+ btn: id => buttons['pysim-fs-' + id + '-btn'],
+ pill: mode => pills.find(p => p.dataset.mode === mode),
+ reads: () => readCalls,
+ readonlyRemoved: () => events.filter(e => e === 'readonly-removed').length,
+ events,
+ };
+}
+
+test('Read raw / Read decoded pills switch the view and read the file', () => {
+ const h = setup();
+ pysimFsSetMode('raw', true);
+ assert.strictEqual(pysimFsDecodedMode, false);
+ assert.strictEqual(h.reads(), 0, 'skipRead updates the pills only');
+ assert.ok(h.pill('raw').classList.contains('bg-blue-600'));
+ assert.ok(!h.pill('dec').classList.contains('bg-blue-600'));
+
+ pysimFsSetMode('dec');
+ assert.strictEqual(pysimFsDecodedMode, true);
+ assert.strictEqual(h.reads(), 1, 'a pill click reads the selected file');
+ assert.ok(h.pill('dec').classList.contains('bg-blue-600'));
+ assert.ok(!h.pill('raw').classList.contains('bg-blue-600'));
+});
+
+test('pill clicks are ignored while editing', () => {
+ const h = setup();
+ pysimFsDecodedMode = true;
+ pysimFsEditMode = true;
+ pysimFsSetMode('raw');
+ assert.strictEqual(pysimFsDecodedMode, true, 'the view mode must not change mid-edit');
+ assert.strictEqual(h.reads(), 0);
+});
+
+test('Edit raw keeps a loaded raw view and enters edit mode without re-reading', async () => {
+ const h = setup({ contentHtml: '' });
+ await pysimFsEdit();
+ assert.strictEqual(h.reads(), 0);
+ assert.strictEqual(pysimFsEditMode, true);
+ assert.ok(h.btn('edit').classList.contains('hidden'));
+ assert.ok(!h.btn('save').classList.contains('hidden'));
+ assert.ok(!h.btn('cancel').classList.contains('hidden'));
+ assert.strictEqual(h.readonlyRemoved(), 1);
+ assert.ok(h.pill('raw').classList.contains('opacity-50'), 'pills are dimmed');
+ assert.ok(h.pill('raw').classList.contains('pointer-events-none'));
+});
+
+test('Edit raw switches from the decoded view to raw and reads before editing', async () => {
+ const h = setup({ decoded: true });
+ await pysimFsEdit();
+ assert.strictEqual(pysimFsDecodedMode, false);
+ assert.strictEqual(h.reads(), 1);
+ assert.deepStrictEqual(h.events, ['read', 'readonly-removed'],
+ 'the raw view must be rendered before the readonly attributes are stripped');
+ assert.ok(h.pill('raw').classList.contains('bg-blue-600'));
+ assert.strictEqual(pysimFsEditMode, true);
+});
+
+test('Edit raw reads a fresh selection and aborts if the read fails', async () => {
+ const h = setup({ readResult: false });
+ await pysimFsEdit();
+ assert.strictEqual(h.reads(), 1);
+ assert.strictEqual(pysimFsEditMode, false);
+ assert.ok(h.btn('save').classList.contains('hidden'));
+ assert.ok(!h.pill('raw').classList.contains('opacity-50'));
+});
+
+test('Cancel restores the view and re-enables the pills', async () => {
+ const h = setup({ contentHtml: '' });
+ await pysimFsEdit();
+ pysimFsCancel();
+ assert.strictEqual(pysimFsEditMode, false);
+ assert.strictEqual(h.content.innerHTML, '');
+ assert.ok(!h.pill('raw').classList.contains('opacity-50'));
+ assert.ok(!h.pill('raw').classList.contains('pointer-events-none'));
+ assert.ok(!h.btn('edit').classList.contains('hidden'));
+ assert.ok(h.btn('save').classList.contains('hidden'));
+ assert.ok(h.btn('cancel').classList.contains('hidden'));
+});
+
+test('Reset edit re-enables the pills', () => {
+ const h = setup();
+ pysimFsEditMode = true;
+ pysimFsPillsEnabled(false);
+ pysimFsResetEdit();
+ assert.strictEqual(pysimFsEditMode, false);
+ assert.ok(!h.pill('dec').classList.contains('opacity-50'));
+});
+
+test('the file manager row exposes the read pills and Edit raw', () => {
+ assert.ok(!html.includes('id="pysim-fs-read-btn"'), 'the redundant Read button is gone');
+ assert.ok(html.includes('data-l10n="Read raw">Read raw<'));
+ assert.ok(html.includes('data-l10n="Read decoded">Read decoded<'));
+ assert.ok(html.includes('data-l10n="Edit raw">Edit raw<'));
+ assert.ok(html.includes('id="pysim-fs-read-raw-btn"') && html.includes('data-needs="card"'));
+});
diff --git a/pyproject.toml b/pyproject.toml
index 4dd53a2..5d6ea9b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
-version = "2.7.8"
+version = "2.7.9"
description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py
index c365bee..17ce9f7 100644
--- a/pysim_simple_server/server.py
+++ b/pysim_simple_server/server.py
@@ -26,7 +26,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
-VERSION = '2.7.8'
+VERSION = '2.7.9'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE