diff --git a/README.md b/README.md
index be133e0..eecc032 100644
--- a/README.md
+++ b/README.md
@@ -490,6 +490,8 @@ Browse the UICC filesystem in a tree view. Files are shown with names, FIDs, and
- **Read** — reads the selected file (auto-detects transparent vs record files)
- **Edit** — switch to edit mode, modify hex data, click **Save** to write back
- **Raw / Decoded** — toggle between hex dump and pysim-decoded JSON view
+- Missing files are shown in red (✗); a present but empty DF shows `(empty)`
+- **Probe all files** — walks the whole tree (incl. custom files), marks every entry present/absent with *N / total* progress, stoppable, and ends with a summary; browsing itself stays lazy
### Command Hints
diff --git a/README_RUS.md b/README_RUS.md
index 7dcf4f0..45acbf4 100644
--- a/README_RUS.md
+++ b/README_RUS.md
@@ -464,6 +464,8 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
- **Read** — чтение файла (автоопределение transparent/record)
- **Edit** — режим редактирования, измените hex-данные и нажмите **Save** для записи
- **Raw / Decoded** — переключение между hex-дампом и декодированным JSON
+- Отсутствующие файлы показаны красным (✗); существующий пустой DF — `(пусто)`
+- **Проверить все файлы** — обход всего дерева (включая пользовательские) с пометкой «есть/нет», прогрессом *N / всего*, возможностью остановки и сводкой в конце; сам просмотр остаётся ленивым
### Подсказки команд
diff --git a/frontend/help-ru.html b/frontend/help-ru.html
index d2ca5b9..e3af178 100644
--- a/frontend/help-ru.html
+++ b/frontend/help-ru.html
@@ -322,6 +322,7 @@
Прочитать — чтение файла (автоопределение transparent/record)
Редактировать — изменение hex-данных, Сохранить для записи (или Отмена )
Данные как на карте / Декодированные данные — переключение между hex-дампом и декодированным JSON
+ Проверить все файлы — обход всего дерева (включая пользовательские файлы) с пометкой каждого элемента: есть (обычный вид) или нет (красный ✗, без стрелки разворачивания); существующие пустые DF показывают (пусто). Отображается прогресс N / всего , обход можно остановить; в конце — сводка «есть/нет». Файлы проверяются только при разворачивании или проверке — просмотр остаётся ленивым.
4.2 Командная строка pySim
diff --git a/frontend/help.html b/frontend/help.html
index 0b870f7..c0a12c2 100644
--- a/frontend/help.html
+++ b/frontend/help.html
@@ -322,6 +322,7 @@
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 hex dump and pySim-decoded JSON
+ 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.
4.2 pySim command line
diff --git a/frontend/index.html b/frontend/index.html
index f2f46e4..9755398 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -860,6 +860,8 @@
FID
Name
+ Probe all files
+
@@ -5946,8 +5948,8 @@ async function pysimFsLoadChildren(node) {
body = { name: node.name, fid: node.fid };
data = await pysimFetch('/api/tree', body);
}
- if (!data || data.exists === false) {
- throw new Error('File not found');
+ if (!data || data.exists === false || data.success === false || data.error) {
+ throw new Error((data && data.error) || 'File not found');
}
node.children = (data.children || []).map(c => ({
name: c.name,
@@ -5968,6 +5970,86 @@ async function pysimFsLoadChildren(node) {
}
}
+let _pysimFsProbe = null;
+
+function pysimFsSelectBody(node) {
+ const body = { name: node.name, fid: node.fid };
+ const parentSel = getParentSel(node);
+ if (parentSel) body.parent_sel = parentSel;
+ return body;
+}
+
+function pysimFsProbeUi(text, stopLabel) {
+ const status = document.getElementById('pysim-fs-probe-status');
+ if (status) {
+ status.textContent = text;
+ status.classList.toggle('hidden', !text);
+ }
+ const btn = document.getElementById('pysim-fs-probe-btn');
+ if (btn) {
+ btn.textContent = stopLabel ? t('Stop') : t('Probe all files');
+ btn.setAttribute('data-l10n', stopLabel ? 'Stop' : 'Probe all files');
+ }
+}
+
+async function pysimFsProbeAll() {
+ if (_pysimFsProbe) { _pysimFsProbe.stop = true; return; }
+ if (!pysimFsTreeRoot) return;
+ const probe = { stop: false, dirs: 0, done: 0, total: 0, present: 0, absent: 0 };
+ _pysimFsProbe = probe;
+ const started = Date.now();
+ let error = null;
+ const count = exists => { if (exists) probe.present++; else probe.absent++; };
+ try {
+ if (!pysimFsTreeRoot.children) await pysimFsLoadChildren(pysimFsTreeRoot);
+ const discover = async node => {
+ for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
+ if (probe.stop) return;
+ if (!child.isDir) continue;
+ await pysimFsLoadChildren(child);
+ const exists = child.exists !== false;
+ probe.dirs++;
+ count(exists);
+ pysimFsRenderTree();
+ pysimFsProbeUi(t('Probing:') + ' ' + t('discovering') + ' (' + probe.dirs + ')', true);
+ if (exists) await discover(child);
+ }
+ };
+ await discover(pysimFsTreeRoot);
+ const files = [];
+ const collect = node => {
+ for (const child of (node.children || [])) {
+ if (child.isDir) { if (child.exists !== false) collect(child); }
+ else files.push(child);
+ }
+ };
+ collect(pysimFsTreeRoot);
+ probe.total = files.length + probe.dirs;
+ probe.done = probe.dirs;
+ for (const file of files) {
+ if (probe.stop) break;
+ const data = await pysimFetch('/api/select', pysimFsSelectBody(file));
+ const exists = !!(data && data.exists !== false && !data.error);
+ file.exists = exists;
+ if (!exists) file.children = [];
+ count(exists);
+ probe.done++;
+ pysimFsProbeUi(t('Probing:') + ' ' + probe.done + '/' + probe.total, true);
+ if (probe.done % 10 === 0 || probe.done === probe.total) pysimFsRenderTree();
+ }
+ } catch (e) {
+ error = (e && e.message) ? e.message : String(e);
+ }
+ pysimFsRenderTree();
+ const secs = ((Date.now() - started) / 1000).toFixed(1);
+ let summary = t('Probed:') + ' ' + probe.done + '/' + probe.total + ' ' + t('files') + ' — '
+ + probe.present + ' ' + t('present') + ', ' + probe.absent + ' ' + t('absent') + ' (' + secs + 's)';
+ if (error) summary = t('Error') + ': ' + error + ' — ' + summary;
+ else if (probe.stop) summary = t('Stopped') + ' — ' + summary;
+ _pysimFsProbe = null;
+ pysimFsProbeUi(summary, false);
+}
+
function pysimFsRenderTree() {
const el = document.getElementById('pysim-fs-tree');
if (!pysimFsTreeRoot) { el.innerHTML = '(loading)'; return; }
@@ -6004,8 +6086,12 @@ function pysimFsRenderNode(node, depth) {
}
html += '
';
if (isDir && node.children && node.expanded) {
- for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
- html += pysimFsRenderNode(child, depth + 1);
+ if (!node.children.length) {
+ html += '' + pad + ' ' + esc(t('(empty)')) + '
';
+ } else {
+ for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
+ html += pysimFsRenderNode(child, depth + 1);
+ }
}
}
return html;
@@ -9147,6 +9233,17 @@ const LANG_RU = {
'Phone simulator': 'Симулятор телефона',
'Sort:': 'Сортировка:',
'Name': 'Имя',
+ 'Probe all files': 'Проверить все файлы',
+ 'Stop': 'Остановить',
+ 'Probing:': 'Проверка:',
+ 'discovering': 'поиск файлов',
+ 'files': 'файлов',
+ 'present': 'есть',
+ 'absent': 'нет',
+ 'Probed:': 'Проверено:',
+ 'Stopped': 'Остановлено',
+ '(empty)': '(пусто)',
+ 'Error': 'Ошибка',
'No server connection': 'Нет соединения с сервером',
'Server connected, no card equipped': 'Сервер подключён, карта не подключена',
'Card equipped': 'Карта подключена',
diff --git a/frontend/sw.js b/frontend/sw.js
index a005d2a..aa2a56f 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'otaman-v115';
+const CACHE = 'otaman-v116';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/fs_load.test.js b/frontend/tests/fs_load.test.js
new file mode 100644
index 0000000..408f5a1
--- /dev/null
+++ b/frontend/tests/fs_load.test.js
@@ -0,0 +1,96 @@
+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 = '';
+code += extractFunc(html, 'getParentSel') + '\n';
+code += extractFunc(html, 'pysimFsLoadChildren') + '\n';
+code += 'globalThis.pysimCustomInject = () => {};\n';
+eval(code);
+
+let calls = [];
+let responses = [];
+let renders = 0;
+
+function setup() {
+ calls = [];
+ responses = [];
+ renders = 0;
+ globalThis.pysimFetch = async (p, body) => {
+ calls.push({ path: p, body: JSON.parse(JSON.stringify(body)) });
+ const r = responses.shift();
+ if (r instanceof Error) throw r;
+ return JSON.parse(JSON.stringify(r));
+ };
+ globalThis.pysimFsRenderTree = () => { renders++; };
+ const node = { name: 'DF.USIM', fid: '7fff', isDir: true, children: null, exists: null, parent: { name: 'MF', fid: '3f00' } };
+ return node;
+}
+
+test('tree error payload marks the directory as absent', async () => {
+ const node = setup();
+ responses = [
+ { success: false, error: 'SW ... 6a82', exists: false },
+ { success: false, error: 'SW ... 6a82', exists: false },
+ ];
+ await pysimFsLoadChildren(node);
+ assert.strictEqual(node.exists, false);
+ assert.deepStrictEqual(node.children, []);
+ assert.strictEqual(renders, 1);
+ assert.strictEqual(calls.length, 2);
+ assert.strictEqual(calls[0].body.parent_sel, 'MF');
+});
+
+test('error payload without exists is not treated as an empty listing', async () => {
+ const node = setup();
+ responses = [
+ { success: false, error: 'boom' },
+ { success: false, error: 'boom' },
+ ];
+ await pysimFsLoadChildren(node);
+ assert.strictEqual(node.exists, false);
+ assert.deepStrictEqual(node.children, []);
+ assert.strictEqual(renders, 1);
+ assert.strictEqual(calls.length, 1);
+});
+
+test('retry without parent_sel succeeds and maps children', async () => {
+ const node = setup();
+ responses = [
+ { exists: false },
+ { exists: true, children: [{ name: 'EF.IMSI', fid: '6f07', isDir: false }] },
+ ];
+ await pysimFsLoadChildren(node);
+ assert.strictEqual(node.exists, true);
+ assert.strictEqual(node.children.length, 1);
+ assert.strictEqual(node.children[0].parent, node);
+ assert.strictEqual(node.children[0].exists, true);
+ assert.strictEqual(calls[1].body.parent_sel, undefined);
+});
+
+test('empty successful listing keeps the directory present', async () => {
+ const node = setup();
+ responses = [{ exists: true, children: [] }];
+ await pysimFsLoadChildren(node);
+ assert.strictEqual(node.exists, true);
+ assert.deepStrictEqual(node.children, []);
+});
diff --git a/frontend/tests/fs_probe.test.js b/frontend/tests/fs_probe.test.js
new file mode 100644
index 0000000..a44ff86
--- /dev/null
+++ b/frontend/tests/fs_probe.test.js
@@ -0,0 +1,124 @@
+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 pysimFsTreeRoot = null;\nvar _pysimFsProbe = null;\nvar pysimFsSort = "fid";\n';
+for (const fn of ['getParentSel', 'pysimFsSortChildren', 'pysimFsLoadChildren', 'pysimFsSelectBody', 'pysimFsProbeUi', 'pysimFsProbeAll']) {
+ code += extractFunc(html, fn) + '\n';
+}
+code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\nglobalThis.pysimCustomInject = () => {};\n';
+eval(code);
+
+function fakeEl() {
+ const classes = new Set();
+ return {
+ textContent: '',
+ attrs: {},
+ classList: {
+ add: (...cs) => cs.forEach(c => classes.add(c)),
+ remove: (...cs) => cs.forEach(c => classes.delete(c)),
+ contains: c => classes.has(c),
+ toggle: (c, on) => { if (on === undefined ? !classes.has(c) : on) classes.add(c); else classes.delete(c); },
+ },
+ setAttribute(k, v) { this.attrs[k] = v; },
+ };
+}
+
+let els = {};
+let calls = [];
+
+function setup(routes) {
+ els = { 'pysim-fs-probe-status': fakeEl(), 'pysim-fs-probe-btn': fakeEl() };
+ calls = [];
+ globalThis.document = { getElementById: id => els[id] || null };
+ globalThis.pysimFetch = async (p, body) => {
+ calls.push({ path: p, body: body || {} });
+ for (const r of routes) {
+ if (r.path !== p) continue;
+ if (r.name && (!body || body.name !== r.name)) continue;
+ return typeof r.reply === 'function' ? r.reply(body, calls) : JSON.parse(JSON.stringify(r.reply));
+ }
+ throw new Error('unexpected fetch ' + p + ' ' + JSON.stringify(body));
+ };
+ globalThis.pysimFsRenderTree = () => {};
+}
+
+function root(children) {
+ pysimFsTreeRoot = { name: 'MF', fid: '3f00', isDir: true, expanded: true, exists: true, children: null, parent: null };
+ pysimFsTreeRoot.children = children.map(c => Object.assign({ children: null, expanded: false, exists: true, parent: pysimFsTreeRoot }, c));
+ return pysimFsTreeRoot;
+}
+
+const df = (name, fid) => ({ name, fid, isDir: true });
+const ef = (name, fid) => ({ name, fid, isDir: false });
+const selectNames = () => calls.filter(c => c.path === '/api/select').map(c => c.body.name);
+
+test('probes dirs and files, including custom entries, and reports counts', async () => {
+ root([df('DF.A', '5f01'), ef('EF.ROOT', '2f01'), Object.assign(ef('EF.CUSTOM', '6fcc'), { custom: true })]);
+ let sawStop = null;
+ setup([
+ { path: '/api/tree', name: 'DF.A', reply: { exists: true, children: [{ name: 'EF.1', fid: '6f01', isDir: false }] } },
+ { path: '/api/select', name: 'EF.1', reply: () => { sawStop = els['pysim-fs-probe-btn'].textContent; return { exists: true }; } },
+ { path: '/api/select', name: 'EF.ROOT', reply: { error: 'SW 6a82', exists: false } },
+ { path: '/api/select', name: 'EF.CUSTOM', reply: { exists: true } },
+ ]);
+ await pysimFsProbeAll();
+ assert.strictEqual(sawStop, 'Stop');
+ assert.deepStrictEqual(selectNames(), ['EF.1', 'EF.ROOT', 'EF.CUSTOM']);
+ assert.strictEqual(pysimFsTreeRoot.children[0].children[0].exists, true);
+ assert.strictEqual(pysimFsTreeRoot.children[1].exists, false);
+ assert.strictEqual(pysimFsTreeRoot.children[2].exists, true);
+ const status = els['pysim-fs-probe-status'].textContent;
+ assert.match(status, /4\/4 files/);
+ assert.match(status, /3 present/);
+ assert.match(status, /1 absent/);
+ assert.strictEqual(els['pysim-fs-probe-btn'].textContent, 'Probe all files');
+ assert.strictEqual(els['pysim-fs-probe-btn'].attrs['data-l10n'], 'Probe all files');
+});
+
+test('an absent directory is marked and its subtree is never fetched', async () => {
+ root([df('DF.B', '5f02'), ef('EF.ROOT', '2f01')]);
+ setup([
+ { path: '/api/tree', name: 'DF.B', reply: { success: false, error: 'SW 6a82', exists: false } },
+ { path: '/api/select', name: 'EF.ROOT', reply: { error: 'SW 6a82', exists: false } },
+ ]);
+ await pysimFsProbeAll();
+ assert.strictEqual(pysimFsTreeRoot.children[0].exists, false);
+ assert.deepStrictEqual(selectNames(), ['EF.ROOT']);
+ assert.strictEqual(calls.filter(c => c.path === '/api/tree' && c.body.name === 'DF.B').length, 2);
+ const status = els['pysim-fs-probe-status'].textContent;
+ assert.match(status, /2\/2 files/);
+ assert.match(status, /0 present/);
+ assert.match(status, /2 absent/);
+});
+
+test('stop halts the walk and still reports a summary', async () => {
+ root([ef('EF.X', '6f01'), ef('EF.Y', '6f02')]);
+ setup([
+ { path: '/api/select', name: 'EF.X', reply: () => { _pysimFsProbe.stop = true; return { exists: true }; } },
+ ]);
+ await pysimFsProbeAll();
+ assert.deepStrictEqual(selectNames(), ['EF.X']);
+ const status = els['pysim-fs-probe-status'].textContent;
+ assert.ok(status.startsWith('Stopped —'), status);
+ assert.strictEqual(els['pysim-fs-probe-btn'].textContent, 'Probe all files');
+});
diff --git a/frontend/tests/fs_render.test.js b/frontend/tests/fs_render.test.js
new file mode 100644
index 0000000..e938c23
--- /dev/null
+++ b/frontend/tests/fs_render.test.js
@@ -0,0 +1,58 @@
+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 pysimFsSort = "fid";\n';
+code += extractFunc(html, 'pysimFsSortChildren') + '\n';
+code += extractFunc(html, 'pysimFsRenderNode') + '\n';
+code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
+eval(code);
+
+const ef = (name, fid) => ({ name, fid, isDir: false, exists: true, children: null, expanded: false });
+const df = (name, fid, extra) => Object.assign({ name, fid, isDir: true, exists: true, children: null, expanded: false }, extra || {});
+
+test('absent directory renders a cross and no expand toggle', () => {
+ const node = df('DF.WLAN', '5f40', { exists: false, children: [] });
+ const out = pysimFsRenderNode(node, 0);
+ assert.ok(out.includes('✗'), out);
+ assert.ok(!out.includes('pysimFsToggleDir'), out);
+ assert.ok(!out.includes('▶'), out);
+});
+
+test('expanded empty directory shows the (empty) placeholder', () => {
+ const node = df('DF.EMPTY', '5f00', { children: [], expanded: true });
+ const out = pysimFsRenderNode(node, 0);
+ assert.ok(out.includes('(empty)'), out);
+});
+
+test('expanded directory with children renders no placeholder', () => {
+ const node = df('DF.GSM', '7f20', { children: [ef('EF.IMSI', '6f07')], expanded: true });
+ const out = pysimFsRenderNode(node, 0);
+ assert.ok(out.includes('EF.IMSI'), out);
+ assert.ok(!out.includes('(empty)'), out);
+});
+
+test('collapsed directory hides its children', () => {
+ const node = df('DF.GSM', '7f20', { children: [ef('EF.IMSI', '6f07')], expanded: false });
+ const out = pysimFsRenderNode(node, 0);
+ assert.ok(!out.includes('EF.IMSI'), out);
+});
diff --git a/frontend/tests/html.test.js b/frontend/tests/html.test.js
index b640a21..4214c34 100644
--- a/frontend/tests/html.test.js
+++ b/frontend/tests/html.test.js
@@ -61,3 +61,10 @@ test('custom files form has add/save and cancel controls', () => {
assert.ok(html.includes("event.key==='Enter')pysimCustomSubmit()"));
assert.ok(!html.includes('pysimCustomAdd'));
});
+
+test('file manager has a probe-all-files button and status line', () => {
+ assert.match(html, /id="pysim-fs-probe-btn"[^>]*data-needs="card"/);
+ assert.match(html, /id="pysim-fs-probe-btn"[^>]*data-l10n="Probe all files"/);
+ assert.ok(html.includes('onclick="pysimFsProbeAll()"'));
+ assert.ok(html.includes('id="pysim-fs-probe-status"'));
+});
diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py
index 195b1cd..5ee5599 100644
--- a/pysim_otaman_server/server.py
+++ b/pysim_otaman_server/server.py
@@ -2184,7 +2184,7 @@ class PysimHandler(BaseHTTPRequestHandler):
sys.stderr.write('Handler error: %s\n' % e)
if 'Card' in str(e) or 'Transaction' in str(e) or 'Transmit' in str(e):
_handle_card_disconnect()
- err = {'success': False, 'error': str(e)}
+ err = {'success': False, 'error': str(e), 'exists': False}
self._send_json(err, 500)
self._log_resp(err)
elif self.path == '/api/menu-select':