Compare commits
2 Commits
1fe5c6347d
...
5485d61390
| Author | SHA1 | Date | |
|---|---|---|---|
| 5485d61390 | |||
| 45c72d874f |
@@ -486,6 +486,7 @@ Connects to the bundled [`pysim-otaman-server`](pysim_otaman_server/) for live c
|
||||
|
||||
Browse the UICC filesystem in a tree view. Files are shown with names, FIDs, and AIDs (for ADFs). Click to read contents.
|
||||
|
||||
- Entries are grouped with DFs above EFs and sorted by **FID** or symbolic **Name** (pills above the tree, remembered in `localStorage`)
|
||||
- **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
|
||||
|
||||
@@ -460,6 +460,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
||||
|
||||
Дерево файлов UICC. Отображаются имена, FID и AID (для ADF). Клик для чтения содержимого.
|
||||
|
||||
- Элементы сгруппированы (DF выше EF) и отсортированы по **FID** или символьному **имени** (пиллы над деревом, выбор сохраняется в `localStorage`)
|
||||
- **Read** — чтение файла (автоопределение transparent/record)
|
||||
- **Edit** — режим редактирования, измените hex-данные и нажмите **Save** для записи
|
||||
- **Raw / Decoded** — переключение между hex-дампом и декодированным JSON
|
||||
|
||||
@@ -317,7 +317,7 @@
|
||||
<p class="mb-3">Подключение к локальному <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> для работы с картой: введите URL сервера (по умолчанию <code class="font-mono text-sm">http://127.0.0.1:8080</code>) и нажмите <strong>Подключиться</strong>. Область статуса показывает состояние ридера/карты, а <strong>Подключить карту</strong> (пере)инициализирует карту после вставки. Подвкладки: <strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong> и <strong>Отправка APDU</strong>. <strong>«Профайлер»</strong> и <strong>«Симулятор телефона»</strong> — отдельные вкладки верхнего уровня.</p>
|
||||
|
||||
<h3 id="file-manager" class="text-lg font-medium mb-2">4.1 Файловый менеджер</h3>
|
||||
<p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа.</p>
|
||||
<p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по <strong>FID</strong> или символьному <strong>имени</strong> (пиллы над деревом; выбор сохраняется в <code class="font-mono text-sm">localStorage</code>).</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Прочитать</strong> — чтение файла (автоопределение transparent/record)</li>
|
||||
<li><strong>Редактировать</strong> — изменение hex-данных, <strong>Сохранить</strong> для записи (или <strong>Отмена</strong>)</li>
|
||||
@@ -412,7 +412,7 @@
|
||||
<p class="text-sm mb-3">Работа с сессией Card Application Toolkit. Две подвкладки: <strong>«Телефон»</strong> (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд) и <strong>«Конфигурация TR»</strong> (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд).</p>
|
||||
|
||||
<h3 id="stk-menu" class="text-lg font-medium mb-2">6.1 Меню STK</h3>
|
||||
<p class="text-sm mb-3">Если карта выдала команду SET UP MENU, вверху этого представления появляется блок «Меню STK» с изумрудной кнопкой <strong>STK: <название></strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается «Меню не задано картой». Состояние меню обновляется при каждом открытии представления. Интерактивные проактивные команды всегда получают TERMINAL RESPONSE: оверлей ждёт вашего выбора, и если вы не ответили и не нажали <strong>Timeout</strong>, сервер сам отвечает результатом timeout через <code class="font-mono text-sm">--menu-timeout</code> секунд (по умолчанию 60, <code class="font-mono text-sm">0</code> отключает).</p>
|
||||
<p class="text-sm mb-3">Если карта выдала команду SET UP MENU, вверху этого представления появляется блок «Меню STK» с изумрудной кнопкой <strong>STK: <название></strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается «Меню не задано картой». Состояние меню обновляется при каждом открытии представления. Интерактивные проактивные команды всегда получают TERMINAL RESPONSE: оверлей ждёт вашего выбора, и если вы не ответили и не нажали <strong>Timeout</strong>, сервер сам отвечает результатом timeout через <code class="font-mono text-sm">--menu-timeout</code> секунд (по умолчанию 60, <code class="font-mono text-sm">0</code> отключает). <strong>Назад</strong> и <strong>Timeout</strong> продолжают диалог с картой: если карта в ответ выдаёт следующую проактивную команду (SELECT ITEM или DISPLAY TEXT), панель показывает её; кэшированное верхнее меню появляется только когда карте больше нечего выполнять.</p>
|
||||
|
||||
<h3 id="subscribed-events" class="text-lg font-medium mb-2">6.2 Подписанные события (SET UP EVENT LIST)</h3>
|
||||
<p class="text-sm mb-2">События, которые отслеживает карта. У каждого события есть кнопка <strong>Отправить</strong>, открывающая форму, специфичную для типа события:</p>
|
||||
|
||||
+2
-2
@@ -317,7 +317,7 @@
|
||||
<p class="mb-3">Connects to a local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> for live card operations: enter the server URL (default <code class="font-mono text-sm">http://127.0.0.1:8080</code>) and press <strong>Connect</strong>. The status area shows the reader/card state, and <strong>Equip card</strong> (re)initializes the card after insertion. Sub-tabs: <strong>File manager</strong>, <strong>pySim command line</strong>, and <strong>Raw APDU</strong>. The <strong>Profiler</strong> and <strong>Phone simulator</strong> are separate top-level tabs.</p>
|
||||
|
||||
<h3 id="file-manager" class="text-lg font-medium mb-2">4.1 File manager</h3>
|
||||
<p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right.</p>
|
||||
<p class="text-sm mb-2">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 <strong>FID</strong> or symbolic <strong>Name</strong> (pills above the tree; the choice is remembered in <code class="font-mono text-sm">localStorage</code>).</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Read</strong> — reads the selected file (auto-detects transparent vs record files)</li>
|
||||
<li><strong>Edit</strong> — modify hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>)</li>
|
||||
@@ -412,7 +412,7 @@
|
||||
<p class="text-sm mb-3">Interacts with the Card Application Toolkit session. The view has two pills: <strong>Phone</strong> (STK menu, STATUS and polling, subscribed events, proactive command log) and <strong>TR Config</strong> (response data injected into TERMINAL RESPONSEs for proactive commands).</p>
|
||||
|
||||
<h3 id="stk-menu" class="text-lg font-medium mb-2">6.1 STK menu</h3>
|
||||
<p class="text-sm mb-3">When the card has issued a SET UP MENU command, a “STK menu” block appears at the top of this view with an emerald <strong>STK: <title></strong> button that opens the menu overlay (same as the card’s STK menu browser). If the card has not set up a menu, the block shows “No menu set by the card” instead. The menu state is refreshed each time the view is opened. User-interactive proactive commands always get a TERMINAL RESPONSE: the overlay pauses for your choice, and if you neither answer nor press <strong>Timeout</strong>, the server answers with a timeout result after the <code class="font-mono text-sm">--menu-timeout</code> seconds (default 60, <code class="font-mono text-sm">0</code> disables).</p>
|
||||
<p class="text-sm mb-3">When the card has issued a SET UP MENU command, a “STK menu” block appears at the top of this view with an emerald <strong>STK: <title></strong> button that opens the menu overlay (same as the card’s STK menu browser). If the card has not set up a menu, the block shows “No menu set by the card” instead. The menu state is refreshed each time the view is opened. User-interactive proactive commands always get a TERMINAL RESPONSE: the overlay pauses for your choice, and if you neither answer nor press <strong>Timeout</strong>, the server answers with a timeout result after the <code class="font-mono text-sm">--menu-timeout</code> seconds (default 60, <code class="font-mono text-sm">0</code> disables). <strong>Back</strong> and <strong>Timeout</strong> keep the dialogue with the card going: when the card replies with a further proactive command (SELECT ITEM or DISPLAY TEXT) the panel shows it; the cached top menu appears only when the card has nothing more to execute.</p>
|
||||
|
||||
<h3 id="subscribed-events" class="text-lg font-medium mb-2">6.2 Subscribed events (SET UP EVENT LIST)</h3>
|
||||
<p class="text-sm mb-2">The events the card monitors. Each event has a <strong>Send</strong> button that opens a form specific to the event type:</p>
|
||||
|
||||
+54
-4
@@ -772,6 +772,11 @@
|
||||
<div id="pysim-sub-files">
|
||||
<div class="flex gap-2">
|
||||
<div class="w-1/3 border border-gray-300 dark:border-slate-600 rounded p-2 text-xs font-mono overflow-y-auto bg-gray-50 dark:bg-slate-800" style="max-height:420px">
|
||||
<div class="flex items-center gap-1 mb-1 text-gray-500 dark:text-slate-400">
|
||||
<span data-l10n="Sort:">Sort:</span>
|
||||
<span class="pysim-fs-sort-pill px-2 py-0.5 rounded cursor-pointer bg-blue-600 text-white" data-fs-sort="fid" onclick="pysimFsSetSort('fid')">FID</span>
|
||||
<span class="pysim-fs-sort-pill px-2 py-0.5 rounded cursor-pointer bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300" data-fs-sort="name" onclick="pysimFsSetSort('name')" data-l10n="Name">Name</span>
|
||||
</div>
|
||||
<div id="pysim-fs-tree"></div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
@@ -5486,9 +5491,13 @@ async function stkMenuRespond(result) {
|
||||
try {
|
||||
const data = await pysimFetch('/api/menu-respond', { result: result });
|
||||
if (result === 'cancel' || result === 'back' || result === 'timeout') {
|
||||
stkMenuRenderItems();
|
||||
document.getElementById('stk-menu-buttons').classList.add('hidden');
|
||||
document.getElementById('stk-back-btn').style.display = 'none';
|
||||
if (data && (data.type === 'select_item' || data.type === 'display_text')) {
|
||||
stkMenuHandleResponse(data);
|
||||
} else {
|
||||
stkMenuRenderItems();
|
||||
document.getElementById('stk-menu-buttons').classList.add('hidden');
|
||||
document.getElementById('stk-back-btn').style.display = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
stkMenuHandleResponse(data);
|
||||
@@ -5845,6 +5854,44 @@ function pysimCustomInject(node) {
|
||||
}
|
||||
}
|
||||
|
||||
let pysimFsSort = localStorage.getItem('otaman_fs_sort') === 'name' ? 'name' : 'fid';
|
||||
|
||||
function pysimFsSortChildren(children, mode) {
|
||||
return (children || []).slice().sort((a, b) => {
|
||||
if (!!a.isDir !== !!b.isDir) return a.isDir ? -1 : 1;
|
||||
const af = (a.fid || '').toUpperCase();
|
||||
const bf = (b.fid || '').toUpperCase();
|
||||
const an = (a.name || '').toUpperCase();
|
||||
const bn = (b.name || '').toUpperCase();
|
||||
let r;
|
||||
if (mode === 'name') {
|
||||
const ak = an || af;
|
||||
const bk = bn || bf;
|
||||
r = ak < bk ? -1 : ak > bk ? 1 : 0;
|
||||
if (r === 0) r = af < bf ? -1 : af > bf ? 1 : 0;
|
||||
} else {
|
||||
r = af < bf ? -1 : af > bf ? 1 : 0;
|
||||
if (r === 0) r = an < bn ? -1 : an > bn ? 1 : 0;
|
||||
}
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
function pysimFsSetSort(mode) {
|
||||
pysimFsSort = mode === 'name' ? 'name' : 'fid';
|
||||
localStorage.setItem('otaman_fs_sort', pysimFsSort);
|
||||
document.querySelectorAll('.pysim-fs-sort-pill').forEach(p => {
|
||||
const active = p.dataset.fsSort === pysimFsSort;
|
||||
p.classList.toggle('bg-blue-600', active);
|
||||
p.classList.toggle('text-white', active);
|
||||
p.classList.toggle('bg-gray-200', !active);
|
||||
p.classList.toggle('dark:bg-slate-700', !active);
|
||||
p.classList.toggle('text-gray-700', !active);
|
||||
p.classList.toggle('dark:text-slate-300', !active);
|
||||
});
|
||||
pysimFsRenderTree();
|
||||
}
|
||||
|
||||
async function pysimFsRefresh() {
|
||||
const detailEl = document.getElementById('pysim-fs-detail');
|
||||
detailEl.classList.add('hidden');
|
||||
@@ -5942,7 +5989,7 @@ function pysimFsRenderNode(node, depth) {
|
||||
}
|
||||
html += '</div>';
|
||||
if (isDir && node.children && node.expanded) {
|
||||
for (const child of node.children) {
|
||||
for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
|
||||
html += pysimFsRenderNode(child, depth + 1);
|
||||
}
|
||||
}
|
||||
@@ -9046,6 +9093,8 @@ const LANG_RU = {
|
||||
'Verify vs pySim': 'Проверить в pySim',
|
||||
'Send to Card': 'Отправить на карту',
|
||||
'Phone simulator': 'Симулятор телефона',
|
||||
'Sort:': 'Сортировка:',
|
||||
'Name': 'Имя',
|
||||
'No server connection': 'Нет соединения с сервером',
|
||||
'Server connected, no card equipped': 'Сервер подключён, карта не подключена',
|
||||
'Card equipped': 'Карта подключена',
|
||||
@@ -9265,6 +9314,7 @@ updateHelpLink();
|
||||
|
||||
cardsLoad();
|
||||
pysimStartAvailabilityProbe();
|
||||
pysimFsSetSort(pysimFsSort);
|
||||
|
||||
if (localStorage.getItem('theme') === 'dark' ||
|
||||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'otaman-v107';
|
||||
const CACHE = 'otaman-v109';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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('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);
|
||||
}
|
||||
|
||||
eval(extractFunc(html, 'pysimFsSortChildren'));
|
||||
|
||||
const f = (fid, name) => ({ fid, name, isDir: false });
|
||||
const d = (fid, name) => ({ fid, name, isDir: true });
|
||||
|
||||
test('DFs sort before EFs in both modes', () => {
|
||||
const children = [f('6F07', 'EF.IMSI'), d('7F20', 'DF.GSM'), f('2FE2', 'EF.ICCID'), d('7F10', 'DF.TELECOM')];
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'fid').map(x => x.fid), ['7F10', '7F20', '2FE2', '6F07']);
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.name), ['DF.GSM', 'DF.TELECOM', 'EF.ICCID', 'EF.IMSI']);
|
||||
});
|
||||
|
||||
test('FID mode sorts EFs numerically by FID string', () => {
|
||||
const children = [f('6F3A', 'EF.ADN'), f('2FE2', 'EF.ICCID'), f('6F07', 'EF.IMSI')];
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'fid').map(x => x.fid), ['2FE2', '6F07', '6F3A']);
|
||||
});
|
||||
|
||||
test('name mode sorts case-insensitively', () => {
|
||||
const children = [f('6F3A', 'EF.ADN'), f('2FE2', 'ef.iccid'), f('6F07', 'EF.IMSI')];
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.name), ['EF.ADN', 'ef.iccid', 'EF.IMSI']);
|
||||
});
|
||||
|
||||
test('missing name falls back to the FID as the sort key', () => {
|
||||
const children = [f('2FE2', null), f('6F07', 'EF.IMSI'), f('6F3A', '')];
|
||||
// keys: '2FE2', 'EF.IMSI', '6F3A' -> '2FE2' < '6F3A' < 'EF.IMSI'
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.fid), ['2FE2', '6F3A', '6F07']);
|
||||
});
|
||||
|
||||
test('custom entries use their isDir flag for the DF priority', () => {
|
||||
const children = [f('6F3A', 'EF.ADN'), d('7F20', 'DF.GSM')];
|
||||
assert.strictEqual(pysimFsSortChildren(children, 'fid')[0].name, 'DF.GSM');
|
||||
});
|
||||
|
||||
test('equal keys keep a deterministic tie-break by the other field', () => {
|
||||
const children = [f('6F07', 'EF.SAME'), f('2FE2', 'EF.SAME')];
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.fid), ['2FE2', '6F07']);
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'fid').map(x => x.fid), ['2FE2', '6F07']);
|
||||
});
|
||||
|
||||
test('does not mutate the input array', () => {
|
||||
const children = [f('6F3A', 'B'), f('2FE2', 'A')];
|
||||
pysimFsSortChildren(children, 'fid');
|
||||
assert.deepStrictEqual(children.map(x => x.fid), ['6F3A', '2FE2']);
|
||||
});
|
||||
@@ -48,3 +48,9 @@ test('header state indicator and profiler custom-files tab', () => {
|
||||
assert.ok(html.includes('data-list-tab="custom"'));
|
||||
assert.ok(!html.includes('data-pysim-sub="custom"'));
|
||||
});
|
||||
|
||||
test('file manager has FID / Name sort pills', () => {
|
||||
assert.match(html, /data-fs-sort="fid" onclick="pysimFsSetSort\('fid'\)"/);
|
||||
assert.match(html, /data-fs-sort="name" onclick="pysimFsSetSort\('name'\)"/);
|
||||
assert.ok(html.includes('pysim-fs-sort-pill'));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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, asyncFn) {
|
||||
const re = new RegExp('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 (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = extractFunc(html, 'stkMenuRespond', true) + '\n';
|
||||
code += 'globalThis.esc = s => s;\n';
|
||||
eval(code);
|
||||
|
||||
function setup(response) {
|
||||
const calls = { handled: null, rendered: 0 };
|
||||
globalThis.pysimFetch = async () => response;
|
||||
globalThis.stkMenuHandleResponse = d => { calls.handled = d; };
|
||||
globalThis.stkMenuRenderItems = () => { calls.rendered++; };
|
||||
const btns = { classList: { add: () => {} } };
|
||||
const back = { style: {} };
|
||||
globalThis.document = {
|
||||
getElementById: id => (id === 'stk-menu-buttons' ? btns : id === 'stk-back-btn' ? back : { innerHTML: '' }),
|
||||
};
|
||||
return calls;
|
||||
}
|
||||
|
||||
test('back with a fetched SELECT ITEM continues the card dialogue', async () => {
|
||||
const data = { type: 'select_item', items: [{ id: 1, text: 'Info' }] };
|
||||
const calls = setup(data);
|
||||
await stkMenuRespond('back');
|
||||
assert.strictEqual(calls.handled, data);
|
||||
assert.strictEqual(calls.rendered, 0);
|
||||
});
|
||||
|
||||
test('back with a fetched DISPLAY TEXT shows it', async () => {
|
||||
const data = { type: 'display_text', text: 'hello' };
|
||||
const calls = setup(data);
|
||||
await stkMenuRespond('back');
|
||||
assert.strictEqual(calls.handled, data);
|
||||
assert.strictEqual(calls.rendered, 0);
|
||||
});
|
||||
|
||||
test('timeout with a fetched SELECT ITEM continues the card dialogue', async () => {
|
||||
const data = { type: 'select_item', items: [] };
|
||||
const calls = setup(data);
|
||||
await stkMenuRespond('timeout');
|
||||
assert.strictEqual(calls.handled, data);
|
||||
assert.strictEqual(calls.rendered, 0);
|
||||
});
|
||||
|
||||
test('back answered with SW 9000 falls back to the cached top menu', async () => {
|
||||
const calls = setup({ type: 'done', sw: '9000' });
|
||||
await stkMenuRespond('back');
|
||||
assert.strictEqual(calls.handled, null);
|
||||
assert.strictEqual(calls.rendered, 1);
|
||||
});
|
||||
|
||||
test('cancel falls back to the cached top menu', async () => {
|
||||
const calls = setup({ sw: '9000' });
|
||||
await stkMenuRespond('cancel');
|
||||
assert.strictEqual(calls.handled, null);
|
||||
assert.strictEqual(calls.rendered, 1);
|
||||
});
|
||||
|
||||
test('ok navigates with the server response', async () => {
|
||||
const data = { type: 'select_item', items: [{ id: 1, text: 'x' }] };
|
||||
const calls = setup(data);
|
||||
await stkMenuRespond('ok');
|
||||
assert.strictEqual(calls.handled, data);
|
||||
assert.strictEqual(calls.rendered, 0);
|
||||
});
|
||||
@@ -1601,6 +1601,21 @@ def _menu_send_response(server, result, item_id=None):
|
||||
return resp, 200
|
||||
|
||||
|
||||
def _finish_pending_menu(server, scc):
|
||||
"""A new menu selection must never shadow a FETCHed command that awaits its
|
||||
TERMINAL RESPONSE: answer it with a cancel TR (0x10) first, then drain any
|
||||
follow-up proactive command so the card is ready for the new selection."""
|
||||
pd = server.stk_pending
|
||||
if not pd:
|
||||
return
|
||||
sys.stderr.write('MENU-SELECT: finishing pending cmd=%02x type=%02x with cancel TR\n'
|
||||
% (pd['cmd_num'], pd['cmd_type']))
|
||||
resp, _ = _menu_send_response(server, 'cancel', None)
|
||||
sw = (resp or {}).get('sw', '')
|
||||
if sw.startswith('91'):
|
||||
_handle_proactive_chain(scc, sw)
|
||||
|
||||
|
||||
class PysimHandler(BaseHTTPRequestHandler):
|
||||
def _send_json(self, data, status=200):
|
||||
self.send_response(status)
|
||||
@@ -2179,6 +2194,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
body = self._read_body()
|
||||
self._log_req(body)
|
||||
_finish_pending_menu(self.server, scc)
|
||||
item_id = body.get('item_id', 0)
|
||||
if not isinstance(item_id, int):
|
||||
item_id = int(item_id)
|
||||
|
||||
@@ -87,3 +87,42 @@ class TestMenuSendResponse(unittest.TestCase):
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestFinishPendingMenu(unittest.TestCase):
|
||||
def make_server(self):
|
||||
return types.SimpleNamespace(
|
||||
stk_pending={'type': 'select_item', 'cmd_num': 1, 'cmd_type': 0x24,
|
||||
'dev_src': 0x81, 'dev_dst': 0x83, 'items': []},
|
||||
menu_active=True, scc=None)
|
||||
|
||||
def make_scc(self, sent, sw='9000'):
|
||||
return types.SimpleNamespace(
|
||||
cat_cla='80',
|
||||
_tp=types.SimpleNamespace(send_apdu=lambda h: (sent.append(h) or ('', sw))))
|
||||
|
||||
def test_no_pending_is_noop(self):
|
||||
sent = []
|
||||
S._finish_pending_menu(types.SimpleNamespace(stk_pending=None), self.make_scc(sent))
|
||||
self.assertEqual(sent, [])
|
||||
|
||||
def test_pending_finished_with_cancel_tr(self):
|
||||
server = self.make_server()
|
||||
sent = []
|
||||
scc = self.make_scc(sent)
|
||||
server.scc = scc
|
||||
S._finish_pending_menu(server, scc)
|
||||
self.assertEqual(len(sent), 1)
|
||||
tr = sent[0]
|
||||
self.assertTrue(tr.startswith('801400000d'), tr)
|
||||
self.assertIn('83021000', tr) # general result 0x10 = cancel
|
||||
self.assertIsNone(server.stk_pending)
|
||||
self.assertFalse(server.menu_active)
|
||||
|
||||
def test_91xx_answer_drains_chain(self):
|
||||
server = self.make_server()
|
||||
scc = self.make_scc([], sw='9120')
|
||||
server.scc = scc
|
||||
with mock.patch.object(S, '_handle_proactive_chain') as chain:
|
||||
S._finish_pending_menu(server, scc)
|
||||
chain.assert_called_once_with(scc, '9120')
|
||||
|
||||
Reference in New Issue
Block a user