feat: answer GET INKEY / GET INPUT interactively (v3.6.38)

The menu chain paused only for DISPLAY TEXT / SELECT ITEM / SET UP MENU,
so a GET INPUT was auto-answered with a bare success TR and no Text
string - the live RemMobileID PIN prompt got an empty answer.

Server: _parse_get_inkey/_parse_get_input read the prompt, qualifier
flags (digits-only, UCS2, Yes/No, immediate, hidden, packed, help),
Response length 91 and Default Text 9D; the fetch handler pauses with
them; _menu_send_response(..., text=) validates the answer (digits set,
min/max, one char for GET INKEY) and builds the Text string TLV 8D in the
requested coding (SMS unpacked 04 / UCS2 08 / packed 00 via _pack_gsm7),
Yes/No as 01/00, empty input as the null text string, help as result 13
and the variable-timeout Duration echoed (6.8.4).  /api/menu-respond
takes text; /api/stk-status carries pending_data so a card-initiated
prompt opens the panel; _decode_cmd decodes both commands.

PWA: stkInputHtml/stkInputHint/stkInputValidate render the constrained
panel (password field for hidden entry, numeric keyboard, default text,
Yes/No buttons, help, immediate-digit auto-submit); OK submits through
stkMenuOk; the poll opens the panel for card-initiated prompts.

sw cache simple-v311; tests: 517 Python / 674 Node.
This commit is contained in:
2026-09-29 01:30:40 +03:00
parent ddf5cbb8e7
commit 0027c405c1
8 changed files with 571 additions and 14 deletions
+1 -1
View File
@@ -488,7 +488,7 @@
<p class="text-sm mb-3">Работа с сессией Card Application Toolkit. Четыре подвкладки: <strong>&laquo;Телефон&raquo;</strong> (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд), <strong>&laquo;Конфигурация TR&raquo;</strong> (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд), <strong>&laquo;eSIM LPA&raquo;</strong> (локальные операции с eUICC) и <strong>&laquo;Тест-скрипт&raquo;</strong> (сценарные тест-скрипты карты с ожиданием проактивных команд, см. <a href="#test-script" class="text-blue-600 dark:text-blue-400 hover:underline">&sect;8.9</a>).</p>
<h3 id="stk-menu" class="text-lg font-medium mb-2">8.1 Меню STK</h3>
<p class="text-sm mb-3">Если карта выдала команду SET UP MENU, вверху этого представления появляется блок &laquo;Меню STK&raquo; с изумрудной кнопкой <strong>STK: &lt;название&gt;</strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается &laquo;Меню не задано картой&raquo;. Состояние меню обновляется при каждом открытии представления. Пункты меню, для которых карта передала <strong>Next Action Indicator</strong> (TS 102 223 &sect;8.24), показывают его серым суффиксом (например, <code class="font-mono text-sm">&rarr; SET UP MENU</code>) &mdash; что карта выполнит при выборе пункта. Интерактивные проактивные команды всегда получают 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>
<p class="text-sm mb-3">Если карта выдала команду SET UP MENU, вверху этого представления появляется блок &laquo;Меню STK&raquo; с изумрудной кнопкой <strong>STK: &lt;название&gt;</strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается &laquo;Меню не задано картой&raquo;. Состояние меню обновляется при каждом открытии представления. Пункты меню, для которых карта передала <strong>Next Action Indicator</strong> (TS 102 223 &sect;8.24), показывают его серым суффиксом (например, <code class="font-mono text-sm">&rarr; SET UP MENU</code>) &mdash; что карта выполнит при выборе пункта. Интерактивные проактивные команды всегда получают 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), панель показывает её; кэшированное верхнее меню появляется только когда карте больше нечего выполнять. Оверлей также отвечает на запросы карты <strong>GET INKEY</strong> и <strong>GET INPUT</strong> (TS 102 223 &sect;6.4.2/&sect;6.4.3): панель показывает текст карты и поле ввода, ограничения которого заданы командой (длина ответа min&ndash;max, цифровая клавиатура, скрытый ввод, подставленный текст по умолчанию, кнопки Да/Нет для GET INKEY, справка, если карта её объявила), а ответ возвращается в Text string внутри TERMINAL RESPONSE в запрошенной кодировке (SMS-алфавит unpacked, UCS2 или packed).</p>
<h3 id="subscribed-events" class="text-lg font-medium mb-2">8.2 Подписанные события (SET UP EVENT LIST)</h3>
<p class="text-sm mb-2">События, которые отслеживает карта. У каждого события есть кнопка <strong>Отправить</strong>, открывающая форму, специфичную для типа события:</p>
+1 -1
View File
@@ -487,7 +487,7 @@
<p class="text-sm mb-3">Interacts with the Card Application Toolkit session. The view has four pills: <strong>Phone</strong> (STK menu, STATUS and polling, subscribed events, proactive command log), <strong>TR Config</strong> (response data injected into TERMINAL RESPONSEs for proactive commands), <strong>eSIM LPA</strong> (local eUICC operations) and <strong>Test script</strong> (scripted card test scripts with proactive-command expectations, see <a href="#test-script" class="text-blue-600 dark:text-blue-400 hover:underline">&sect;8.9</a>).</p>
<h3 id="stk-menu" class="text-lg font-medium mb-2">8.1 STK menu</h3>
<p class="text-sm mb-3">When the card has issued a SET UP MENU command, a &ldquo;STK menu&rdquo; block appears at the top of this view with an emerald <strong>STK: &lt;title&gt;</strong> button that opens the menu overlay (same as the card&rsquo;s STK menu browser). If the card has not set up a menu, the block shows &ldquo;No menu set by the card&rdquo; instead. The menu state is refreshed each time the view is opened. Menu items whose card-provided <strong>Next Action Indicator</strong> (TS 102 223 &sect;8.24) is present show it as a small gray suffix (e.g. <code class="font-mono text-sm">&rarr; SET UP MENU</code>) &mdash; what the card would do when the item is selected. 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>
<p class="text-sm mb-3">When the card has issued a SET UP MENU command, a &ldquo;STK menu&rdquo; block appears at the top of this view with an emerald <strong>STK: &lt;title&gt;</strong> button that opens the menu overlay (same as the card&rsquo;s STK menu browser). If the card has not set up a menu, the block shows &ldquo;No menu set by the card&rdquo; instead. The menu state is refreshed each time the view is opened. Menu items whose card-provided <strong>Next Action Indicator</strong> (TS 102 223 &sect;8.24) is present show it as a small gray suffix (e.g. <code class="font-mono text-sm">&rarr; SET UP MENU</code>) &mdash; what the card would do when the item is selected. 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. The overlay also answers the card&rsquo;s <strong>GET INKEY</strong> and <strong>GET INPUT</strong> prompts (TS 102 223 &sect;6.4.2/&sect;6.4.3): the panel shows the card&rsquo;s text with an input field whose constraints come from the command (Response length min&ndash;max, digits-only keyboard, hidden entry, default text prefilled, Yes/No buttons for GET INKEY, help when the card advertises it), and the answer is returned in the TERMINAL RESPONSE&rsquo;s Text string in the requested coding (SMS default alphabet unpacked, UCS2 or packed).</p>
<h3 id="subscribed-events" class="text-lg font-medium mb-2">8.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>
+155 -2
View File
@@ -1705,7 +1705,7 @@
<div id="stk-menu-buttons" class="flex justify-between mt-3 pt-3 border-t border-gray-200 dark:border-slate-700 hidden">
<button id="stk-timeout-btn" onclick="stkMenuRespond('timeout')" class="px-3 py-1 text-xs rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-l10n="Timeout">Timeout</button>
<button id="stk-cancel-btn" onclick="stkMenuRespond('cancel')" class="px-3 py-1 text-xs rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-l10n="Cancel">Cancel</button>
<button id="stk-ok-btn" onclick="stkMenuRespond('ok')" class="px-4 py-1 text-xs rounded-full bg-blue-600 text-white hover:bg-blue-700" data-l10n="OK">OK</button>
<button id="stk-ok-btn" onclick="stkMenuOk()" class="px-4 py-1 text-xs rounded-full bg-blue-600 text-white hover:bg-blue-700" data-l10n="OK">OK</button>
</div>
</div>
</div>
@@ -1732,7 +1732,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 = '3.6.37';
const SIMPLE_VERSION = '3.6.38';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -9688,10 +9688,126 @@ async function stkMenuItemClick(itemId) {
}
}
// User-input prompt for a pending GET INKEY / GET INPUT (pure). The card's
// constraints drive the field: hidden entry masks the value (TS 102 223
// 6.4.3), digits-only switches the keyboard, Response length bounds the text.
function stkInputHtml(pd) {
pd = pd || {};
let html = '<div class="text-sm whitespace-pre-wrap p-2 bg-gray-50 dark:bg-slate-700 rounded mb-2">' + esc(pd.text || '') + '</div>';
if (pd.yes_no) {
html += '<div class="flex gap-2 justify-center">' +
'<button onclick="stkInputSubmit(\'01\')" class="px-4 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700">' + esc(t('Yes')) + '</button>' +
'<button onclick="stkInputSubmit(\'00\')" class="px-4 py-1 text-sm rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600">' + esc(t('No')) + '</button>' +
'</div>';
return html;
}
const max = Number(pd.max) || 0;
const attrs = [
'id="stk-input-field"',
'class="w-full px-2 py-1 text-sm rounded border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 font-mono"',
'type="' + (pd.hidden ? 'password' : 'text') + '"',
pd.digits_only ? 'inputmode="numeric"' : '',
(max && max !== 0xFF) ? 'maxlength="' + max + '"' : '',
pd.immediate ? 'oninput="stkInputImmediate()"' : '',
'value="' + esc(pd.default || '') + '"',
].filter(Boolean).join(' ');
html += '<input ' + attrs + '>';
const hint = stkInputHint(pd);
if (hint) html += '<div class="text-xs text-gray-400 dark:text-slate-500 mt-1">' + esc(hint) + '</div>';
if (pd.help) {
html += '<div class="mt-2 text-center"><button onclick="stkMenuRespond(\'help\')" class="text-xs underline text-gray-500 dark:text-slate-400">' + esc(t('Help')) + '</button></div>';
}
return html;
}
// '4–16 characters · digits 0-9 * # + · hidden entry' (pure).
function stkInputHint(pd) {
pd = pd || {};
const parts = [];
const lo = Number(pd.min) || 0;
const hi = Number(pd.max) || 0;
const hasHi = hi && hi !== 0xFF;
if (pd.type === 'get_inkey') {
parts.push(t('one character'));
} else if (lo && hasHi) {
parts.push(lo + '\u2013' + hi + ' ' + t('characters'));
} else if (hasHi) {
parts.push(t('up to') + ' ' + hi + ' ' + t('characters'));
} else if (lo) {
parts.push(t('At least') + ' ' + lo + ' ' + t('characters'));
}
if (pd.digits_only || pd.hidden) parts.push(t('digits 0-9 * # +'));
if (pd.hidden) parts.push(t('hidden entry'));
return parts.join(' \u00b7 ');
}
// Validate the typed answer against the card's request (pure; mirrors the
// server rules so the panel can explain before sending).
function stkInputValidate(pd, value) {
pd = pd || {};
const v = value == null ? '' : String(value);
const digits = /^[0-9*#+]*$/;
if (pd.type === 'get_inkey') {
if (v.length !== 1) return { ok: false, error: t('Enter exactly one character') };
if (pd.digits_only && !digits.test(v)) return { ok: false, error: t('Digits only (0-9, *, #, +)') };
return { ok: true };
}
const lo = Number(pd.min) || 0;
const hi = Number(pd.max) || 0;
if (v.length < lo) return { ok: false, error: t('At least') + ' ' + lo + ' ' + t('characters') };
if (hi && hi !== 0xFF && v.length > hi) return { ok: false, error: t('At most') + ' ' + hi + ' ' + t('characters') };
if ((pd.digits_only || pd.hidden) && !digits.test(v)) return { ok: false, error: t('Digits only (0-9, *, #, +)') };
return { ok: true };
}
let _stkInputPending = null;
// Send the typed (or Yes/No) answer; the server validates again and builds
// the Text string TERMINAL RESPONSE.
async function stkInputSubmit(text) {
const pd = _stkInputPending || {};
if (text !== '01' && text !== '00') {
const field = document.getElementById('stk-input-field');
const value = field ? field.value : '';
const v = stkInputValidate(pd, value);
if (!v.ok) {
document.getElementById('stk-menu-panel-content').insertAdjacentHTML('beforeend',
'<div class="text-xs text-red-500 mt-1">' + esc(v.error) + '</div>');
return;
}
text = value;
}
const content = document.getElementById('stk-menu-panel-content');
content.innerHTML = '<span class="text-gray-500 text-sm">' + esc(t('Sending...')) + '</span>';
try {
const data = await pysimFetch('/api/menu-respond', { result: 'ok', text: text });
stkMenuHandleResponse(data);
} catch (e) {
content.innerHTML = '<span class="text-red-500 text-sm">Error: ' + esc(e.message) + '</span>';
}
}
// The OK button: submit the pending input, otherwise the plain 'ok' answer.
function stkMenuOk() {
if (_stkInputPending && (_stkInputPending.type === 'get_input' || _stkInputPending.type === 'get_inkey')) {
stkInputSubmit(null);
return;
}
stkMenuRespond('ok');
}
// Immediate digit response (GET INKEY qualifier bit 4): pass the first
// character on without asking for confirmation.
function stkInputImmediate() {
const field = document.getElementById('stk-input-field');
if (field && field.value.length >= 1) stkInputSubmit(null);
}
function stkMenuHandleResponse(data) {
const content = document.getElementById('stk-menu-panel-content');
const backBtn = document.getElementById('stk-back-btn');
const btns = document.getElementById('stk-menu-buttons');
_stkInputPending = null;
btns.classList.add('hidden');
if (data.type === 'display_text') {
content.innerHTML = '<div class="text-sm whitespace-pre-wrap p-2 bg-gray-50 dark:bg-slate-700 rounded">' + esc(data.text) + '</div>';
@@ -9704,6 +9820,12 @@ function stkMenuHandleResponse(data) {
backBtn.style.display = '';
document.getElementById('stk-ok-btn').style.display = 'none';
btns.classList.remove('hidden');
} else if (data.type === 'get_input' || data.type === 'get_inkey') {
_stkInputPending = data;
content.innerHTML = stkInputHtml(data);
backBtn.style.display = '';
document.getElementById('stk-ok-btn').style.display = data.yes_no ? 'none' : '';
btns.classList.remove('hidden');
} else {
content.innerHTML = '<span class="text-gray-500 text-sm">SW: ' + esc(data.sw || '?') + '</span>';
stkSetPillsDisabled(false);
@@ -9726,6 +9848,7 @@ async function stkSubItemClick(itemId) {
// itself (the menu watchdog's timeout TERMINAL RESPONSE, or a new selection
// that shadowed it): the dialog can no longer be answered (v3.6.35).
function stkMenuPanelReset() {
_stkInputPending = null;
const panel = document.getElementById('stk-menu-panel');
if (panel) panel.classList.add('hidden');
const btns = document.getElementById('stk-menu-buttons');
@@ -9734,6 +9857,18 @@ function stkMenuPanelReset() {
if (back) back.style.display = 'none';
}
// Open the panel for a pending command the server paused on by itself (a
// card-initiated GET INPUT / SELECT ITEM): the reply to our own click renders
// through stkMenuHandleResponse instead.
function stkMenuShowPending(pd) {
document.getElementById('stk-menu-panel-title').textContent = document.getElementById('stk-menu-title').textContent;
stkSetPillsDisabled(true);
stkMenuStack = [];
document.getElementById('stk-back-btn').style.display = 'none';
document.getElementById('stk-menu-panel').classList.remove('hidden');
stkMenuHandleResponse(pd);
}
// The /api/menu-respond reply for a command the server already answered.
function stkNoPendingError(data) {
return !!(data && typeof data.error === 'string'
@@ -11921,6 +12056,12 @@ function pysimStartBackendPoll() {
// A command the server answered by itself must not leave a stale
// dialog behind (the panel is not refreshed when the tab is hidden)
if (stkChanged && !stk.pending) stkMenuPanelReset();
// A command the card sent on its own (not the reply to our click)
// opens the panel with its prompt (v3.6.38)
if (stkChanged && stk.pending && stk.pending_data && isViewVisible('tab-phone')
&& document.getElementById('stk-menu-panel').classList.contains('hidden')) {
stkMenuShowPending(stk.pending_data);
}
if (ts) {
const wasRunning = testRunActive();
_testRunState = ts;
@@ -17497,6 +17638,18 @@ const LANG_RU = {
'Event sent.': 'Событие отправлено.',
'Event data sent.': 'Данные события отправлены.',
'Sending...': 'Отправка...',
'Yes': 'Да',
'No': 'Нет',
'Help': 'Справка',
'one character': 'один символ',
'characters': 'символов',
'up to': 'до',
'At least': 'Не менее',
'At most': 'Не более',
'digits 0-9 * # +': 'цифры 0-9 * # +',
'hidden entry': 'скрытый ввод',
'Enter exactly one character': 'Введите ровно один символ',
'Digits only (0-9, *, #, +)': 'Только цифры (0-9, *, #, +)',
'Invalid': 'Некорректное значение',
'Service Status': 'Статус сервиса',
'Normal service': 'Обычный сервис',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v310';
const CACHE = 'simple-v311';
const URLS = [
'index.html',
'help.html',
+49
View File
@@ -26,6 +26,9 @@ code += extractFunc(html, 'stkNoPendingError') + '\n';
code += extractFunc(html, 'stkMenuPanelReset') + '\n';
code += extractFunc(html, 'stkMenuNaiSuffix') + '\n';
code += extractFunc(html, 'stkMenuItemsHtml') + '\n';
code += extractFunc(html, 'stkInputHtml') + '\n';
code += extractFunc(html, 'stkInputHint') + '\n';
code += extractFunc(html, 'stkInputValidate') + '\n';
code += 'globalThis.esc = s => s;\n';
eval(code);
@@ -132,3 +135,49 @@ test('an already-answered respond does not reach the dialog handler', async () =
assert.strictEqual(calls.handled, null);
assert.strictEqual(calls.rendered, 0);
});
test('stkInputHtml builds the card-driven input field', () => {
globalThis.t = s => s;
const out = stkInputHtml({ type: 'get_input', text: 'PIN', min: 4, max: 16,
digits_only: true, hidden: true });
assert.ok(out.includes('PIN'), out);
assert.ok(out.includes('type="password"'), out); // hidden entry
assert.ok(out.includes('inputmode="numeric"'), out); // digits only
assert.ok(out.includes('maxlength="16"'), out);
assert.ok(out.includes('4\u201316 characters'), out);
assert.ok(out.includes('hidden entry'), out);
// UCS2 alphabet entry: a plain text field without the numeric keyboard
const ucs2 = stkInputHtml({ type: 'get_input', text: 'Name', min: 0, max: 10, ucs2: true });
assert.ok(ucs2.includes('type="text"'), ucs2);
assert.ok(!ucs2.includes('inputmode'), ucs2);
assert.ok(ucs2.includes('up to 10 characters'), ucs2);
// 0xFF = no maximum (TS 102 223 8.11): no maxlength and no range hint
const noMax = stkInputHtml({ type: 'get_input', text: 'x', min: 0, max: 255 });
assert.ok(!noMax.includes('maxlength'), noMax);
assert.ok(!noMax.includes('characters'), noMax);
// Yes/No: buttons instead of a field
const yesNo = stkInputHtml({ type: 'get_inkey', text: 'Continue?', yes_no: true });
assert.ok(yesNo.includes("stkInputSubmit('01')"), yesNo);
assert.ok(yesNo.includes("stkInputSubmit('00')"), yesNo);
assert.ok(!yesNo.includes('stk-input-field'), yesNo);
// the card's default text is prefilled; help adds the help action
const def = stkInputHtml({ type: 'get_input', text: 'x', default: '1234', help: true });
assert.ok(def.includes('value="1234"'), def);
assert.ok(def.includes("stkMenuRespond('help')"), def);
});
test('stkInputValidate mirrors the server input rules', () => {
globalThis.t = s => s;
const pd = { type: 'get_input', min: 4, max: 16, digits_only: true, hidden: true };
assert.strictEqual(stkInputValidate(pd, '1234').ok, true);
assert.strictEqual(stkInputValidate(pd, '12').ok, false);
assert.strictEqual(stkInputValidate(pd, '12345678901234567').ok, false);
assert.strictEqual(stkInputValidate(pd, 'abcd').ok, false);
// hidden entry allows only the digits set even without digits_only
assert.strictEqual(stkInputValidate({ type: 'get_input', hidden: true }, 'ab').ok, false);
// GET INKEY: exactly one character, digits-only honours * # +
assert.strictEqual(stkInputValidate({ type: 'get_inkey' }, 'a').ok, true);
assert.strictEqual(stkInputValidate({ type: 'get_inkey' }, 'ab').ok, false);
assert.strictEqual(stkInputValidate({ type: 'get_inkey', digits_only: true }, '*').ok, true);
assert.strictEqual(stkInputValidate({ type: 'get_inkey', digits_only: true }, 'a').ok, false);
});