-
+
@@ -1903,11 +1903,163 @@
// ===== 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.22.4';
+const SIMPLE_VERSION = '3.22.5';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
+// ===== view history (the browser Back/Forward buttons) =====
+// Every view switch records a small state object, so the browser Back button
+// walks the app's own view stack: the tabs, every pill set, the Cards
+// list/editor, the test-suite views and the Profiler views. Session-only -
+// the URL never changes (pushState can throw on file://, and the feature then
+// degrades to no history).
+let _activeTab = 'c-apdu';
+let _scp80Sub = 'sp';
+let _capduSub = 'sim';
+let _phoneSub = 'phone';
+let _scp81Sub = 'listener';
+let _cardsView = 'list';
+let _navState = null; // the state of the current history entry
+let _navApplying = false; // replaying: the switch functions must not record
+let _navOk = true; // false when pushState is unavailable (file://)
+let _navReplaceNext = false; // the next record replaces (in-app Cancel/Back)
+
+const NAV_FIELDS = ['tab', 'sub', 'parser', 'view', 'listtab',
+ 'preset', 'suite', 'script', 'item'];
+
+// The canonical key of a state (the equality check; pure).
+function navStateKey(st) {
+ st = st || {};
+ return JSON.stringify(NAV_FIELDS.map(k => String(st[k] == null ? '' : st[k])));
+}
+
+// The state of the view currently shown (reads the module view variables).
+function navCurrent() {
+ const st = {tab: _activeTab};
+ if (_activeTab === 'scp80') st.sub = _scp80Sub;
+ else if (_activeTab === 'c-apdu') {
+ st.sub = _capduSub;
+ if (_capduSub === 'parser') st.parser = _parserSub;
+ } else if (_activeTab === 'phone') {
+ st.sub = _phoneSub;
+ if (_phoneSub === 'test') {
+ st.view = _testView;
+ const suite = testCurrentSuite();
+ if (suite) st.suite = suite.id;
+ if (_testView === 'script' && _testScriptId) st.script = _testScriptId;
+ }
+ } else if (_activeTab === 'scp81') st.sub = _scp81Sub;
+ else if (_activeTab === 'cards') {
+ st.view = _cardsView;
+ const p = (_cardsEditIdx !== null && cards[_cardsEditIdx]) ? cards[_cardsEditIdx] : null;
+ if (_cardsView === 'edit' && p) st.preset = p.id || '';
+ } else if (_activeTab === 'profiler') {
+ st.view = profilerView;
+ if (profilerView === 'list') st.listtab = profilerListTab;
+ else if (profilerView === 'editor' && profilerEditId) st.item = profilerEditId;
+ else if (profilerView === 'snapshot' && snapshotViewId) st.item = snapshotViewId;
+ }
+ return st;
+}
+
+// Record the current view as a history entry (a no-op when replaying or when
+// the state did not change). The in-app Cancel/Back buttons call navReplace()
+// first, so their entry is replaced instead of stacked.
+function navRecord() {
+ const replace = _navReplaceNext;
+ _navReplaceNext = false;
+ if (_navApplying || !_navOk) return;
+ const st = navCurrent();
+ if (!replace && _navState && navStateKey(st) === navStateKey(_navState)) {
+ _navState = st;
+ return;
+ }
+ _navState = st;
+ try {
+ if (replace) history.replaceState(st, '');
+ else history.pushState(st, '');
+ } catch (e) {
+ _navOk = false;
+ }
+}
+
+function navReplace() { _navReplaceNext = true; }
+
+// Seed the entry the app was loaded with: the first Back then returns to the
+// initial view instead of leaving the page.
+function navSeed() {
+ _navState = navCurrent();
+ try { history.replaceState(_navState, ''); } catch (e) { _navOk = false; }
+}
+
+// Replay a history entry (popstate).
+function navApply(st) {
+ st = st || {};
+ _navApplying = true;
+ try {
+ switchTab(st.tab || 'c-apdu');
+ if (st.tab === 'scp80') {
+ scp80SwitchSubtab(st.sub || 'sp');
+ } else if (st.tab === 'c-apdu') {
+ cApduSwitchSubtab(st.sub || 'sim');
+ if ((st.sub || 'sim') === 'parser') parserSwitchSubtab(st.parser || 'parse');
+ } else if (st.tab === 'phone') {
+ phoneSwitchSubtab(st.sub || 'phone');
+ if ((st.sub || 'phone') === 'test') navApplyTest(st);
+ } else if (st.tab === 'scp81') {
+ scp81SwitchSubtab(st.sub || 'listener');
+ } else if (st.tab === 'cards') {
+ navApplyCards(st);
+ } else if (st.tab === 'profiler') {
+ navApplyProfiler(st);
+ }
+ } finally {
+ _navApplying = false;
+ }
+ _navState = navCurrent();
+}
+
+function navApplyTest(st) {
+ const suites = testSuites();
+ let idx = suites.findIndex(s => s.id === st.suite);
+ if (idx < 0) idx = suites.length ? 0 : -1;
+ _testSuiteIdx = idx;
+ _testScriptId = (st.script && testScriptById(st.script)) ? st.script : null;
+ let view = st.view || 'suites';
+ if (view === 'script' && !_testScriptId) view = 'suite';
+ if (view === 'suite' && idx < 0) view = 'suites';
+ testViewShow(view);
+}
+
+function navApplyCards(st) {
+ if ((st.view || 'list') === 'edit') {
+ const idx = st.preset ? cards.findIndex(c => c.id === st.preset) : -1;
+ if (idx >= 0) { cardsEdit(idx); return; }
+ if (!st.preset) { cardsNew(); return; }
+ }
+ cardsShowList();
+}
+
+function navApplyProfiler(st) {
+ const view = st.view || 'list';
+ if (view === 'editor' && st.item) {
+ const idx = profiles.findIndex(p => p.id === st.item);
+ if (idx >= 0) { profilerEdit(idx); return; }
+ } else if (view === 'snapshot' && st.item) {
+ const idx = snapshots.findIndex(s => s.id === st.item);
+ if (idx >= 0) { snapshotOpen(idx); return; }
+ }
+ profilerSetView(view === 'editor' || view === 'snapshot' ? 'list' : view);
+ profilerListSwitch(st.listtab || profilerListTab);
+}
+
+// The test-suite views' in-app back buttons.
+function testBackToSuites() { navReplace(); testViewShow('suites'); }
+
+window.addEventListener('popstate', e => navApply(e.state));
+
// ===== Tab switching =====
function switchTab(name) {
+ _activeTab = name;
if (_scp81Timer && name !== 'scp81') { clearInterval(_scp81Timer); _scp81Timer = null; }
if (_bipTimer && name !== 'phone') { clearInterval(_bipTimer); _bipTimer = null; }
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
@@ -1926,10 +2078,12 @@ function switchTab(name) {
else if (name === 'profiler') { profilerSetView('list'); setHelpAnchor('profiler'); }
else if (name === 'phone') phoneSwitchSubtab('phone');
else if (name === 'scp81') scp81Enter();
+ navRecord();
}
let scp80HelpAnchor = 'secured-packet';
function scp80SwitchSubtab(name) {
+ _scp80Sub = name;
document.querySelectorAll('.scp80-subtab').forEach(btn => {
const isActive = btn.dataset.scp80Sub === name;
btn.classList.toggle('bg-blue-600', isActive);
@@ -1946,6 +2100,7 @@ function scp80SwitchSubtab(name) {
if (name === 'tar') { tarRender(); tarProbeRender(); }
scp80HelpAnchor = { sp: 'secured-packet', ram: 'ram', tar: 'tar-probe' }[name] || 'secured-packet';
setHelpAnchor(scp80HelpAnchor);
+ navRecord();
}
// Top-level Remote APDU subtabs; the C-APDU and R-APDU parsers are nested
@@ -1966,6 +2121,7 @@ function parserSwitchSubtab(name) {
document.getElementById('c-apdu-sub-parse').classList.toggle('hidden', _parserSub !== 'parse');
document.getElementById('c-apdu-sub-response').classList.toggle('hidden', _parserSub !== 'response');
setHelpAnchor(PARSER_HELP_ANCHORS[_parserSub]);
+ navRecord();
}
function cApduSwitchSubtab(name) {
@@ -1976,6 +2132,7 @@ function cApduSwitchSubtab(name) {
parserSwitchSubtab(name);
return;
}
+ _capduSub = name;
document.querySelectorAll('.c-apdu-subtab').forEach(btn => {
const active = btn.dataset.sub === name;
btn.classList.toggle('bg-blue-600', active);
@@ -1991,9 +2148,11 @@ function cApduSwitchSubtab(name) {
});
setHelpAnchor({sim:'sim-rfm', usim:'usim-rfm', ber:'ber-tlv', ram:'ram-gp', httpota:'push-commands'}[name] || 'c-apdu');
if (name === 'parser') parserSwitchSubtab(_parserSub);
+ navRecord();
}
function phoneSwitchSubtab(name) {
+ _phoneSub = name;
document.querySelectorAll('.phone-subtab').forEach(btn => {
const active = btn.dataset.phoneSub === name;
btn.classList.toggle('bg-blue-600', active);
@@ -2023,6 +2182,7 @@ function phoneSwitchSubtab(name) {
tpRefresh();
}
setHelpAnchor({ phone: 'stk-menu', tr: 'pli-dict', bip: 'bip', esim: 'esim', test: 'test-script' }[name] || 'stk-menu');
+ navRecord();
}
// ===== eSIM / LPA (local ES10a/b/c operations) =====
@@ -7239,6 +7399,7 @@ function testViewShow(view) {
_testView = view;
_testSuiteTarget = null;
testRender();
+ navRecord();
}
function testRender() {
@@ -7593,7 +7754,7 @@ function testOpenScript(id, run) {
if (run) testRunStart();
}
-function testScriptBack() { testViewShow('suite'); }
+function testScriptBack() { navReplace(); testViewShow('suite'); }
function testRenderScriptHeader() {
const cur = testCurrentScript();
@@ -13402,18 +13563,22 @@ function cardsFormValues() {
// Show the preset list (the Cards tab's default view).
function cardsShowList() {
+ _cardsView = 'list';
const list = document.getElementById('cards-list-view');
const editor = document.getElementById('cards-editor-view');
if (list) list.classList.remove('hidden');
if (editor) editor.classList.add('hidden');
+ navRecord();
}
// Show the preset editor.
function cardsShowEditor() {
+ _cardsView = 'edit';
const list = document.getElementById('cards-list-view');
const editor = document.getElementById('cards-editor-view');
if (list) list.classList.add('hidden');
if (editor) editor.classList.remove('hidden');
+ navRecord();
}
function cardsSetFormMode() {
@@ -13551,6 +13716,7 @@ function cardsEdit(i) {
}
function cardsCancelEdit() {
+ navReplace();
_cardsEditIdx = null;
cardsSetFormMode();
cardsClearForm();
@@ -16626,6 +16792,7 @@ function scp81ModeChanged() {
}
function scp81SwitchSubtab(name) {
+ _scp81Sub = name;
document.querySelectorAll('.scp81-subtab').forEach(btn => {
const active = btn.dataset.scp81Sub === name;
btn.classList.toggle('bg-blue-600', active);
@@ -16640,6 +16807,7 @@ function scp81SwitchSubtab(name) {
if (name === 'scripts') { scriptsRender(); scriptsRebuildSelect(); }
else { scp81ModeChanged(); scp81StatusRefresh(); }
setHelpAnchor(name === 'scripts' ? 'scp81-scripts' : 'scp81-listener');
+ navRecord();
}
function scp81Msg(text, cls) {
@@ -18395,6 +18563,7 @@ function profilerListSwitch(name) {
document.getElementById('profiler-list-custom').classList.toggle('hidden', name !== 'custom');
if (name === 'custom') pysimCustomRender();
setHelpAnchor({ profiles: 'profiler', snapshots: 'card-snapshots', custom: 'custom-files' }[name] || 'profiler');
+ navRecord();
}
function profilerSetView(view) {
@@ -18408,6 +18577,7 @@ function profilerSetView(view) {
profilerRenderSnapshotList();
profilerListSwitch(profilerListTab);
}
+ navRecord();
}
function profilerRenderSnapshotList() {
@@ -18934,6 +19104,7 @@ function profilerEdit(i) {
}
function profilerBackToList() {
+ navReplace();
profilerDraft = null;
profilerEditId = null;
snapshotViewId = null;
@@ -19309,6 +19480,7 @@ function profilerSaveProfile() {
const idx = profiles.findIndex(p => p.id === profilerEditId);
if (idx >= 0) profiles[idx] = profilerDraft;
profilerSave();
+ navReplace();
profilerSetView('list');
}
@@ -22607,6 +22779,28 @@ const LANG_RU = {
'child file(s)?': 'дочерних файлов?',
'Dropped invalid legacy entries:': 'Отброшено некорректных старых записей:',
'No custom files defined.': 'Пользовательские файлы не заданы.',
+ // The t() keys the i18n completeness test found missing (v3.22.5).
+ 'Run': 'Запустить',
+ 'Suite': 'Набор',
+ 'scripts': 'скриптов',
+ 'Move': 'Переместить',
+ 'Copy': 'Копировать',
+ 'No suite run yet.': 'Набор ещё не запускался.',
+ 'Warning': 'Предупреждение',
+ 'Text': 'Текст',
+ 'id': 'id',
+ 'text': 'текст',
+ 'ok': 'ок',
+ 'cancel': 'отмена',
+ 'back': 'назад',
+ 'timeout': 'тайм-аут',
+ 'no response': 'нет ответа',
+ 'PoR = por_ok': 'PoR = por_ok',
+ 'event data is required': 'нужны данные события',
+ 'event data must be hex': 'данные события должны быть в hex',
+ 'No card preset matches the equipped card': 'Ни один пресет не соответствует подключённой карте',
+ 'Analysis failed': 'Анализ не удался',
+ 'C6 = non-volatile code space (the load file); C7 = volatile (RAM); C8 = non-volatile data. Values are bytes; the hex is the install parameter coding.': 'C6 — код в энергонезависимой памяти (load-файл); C7 — волатильная (RAM); C8 — энергонезависимые данные. Значения — байты; hex — кодирование в параметрах установки.',
};
let currentLang = 'en';
@@ -22755,6 +22949,7 @@ if (!window.matchMedia('(display-mode: standalone)').matches) {
});
window.addEventListener('appinstalled', () => { installBtn.style.display = 'none'; });
}
+navSeed();
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js');
}
diff --git a/frontend/sw.js b/frontend/sw.js
index 6c3daea..d320d8a 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v408';
+const CACHE = 'simple-v409';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/cards_form.test.js b/frontend/tests/cards_form.test.js
index 14a5d2e..8a1fa1b 100644
--- a/frontend/tests/cards_form.test.js
+++ b/frontend/tests/cards_form.test.js
@@ -55,7 +55,9 @@ code = extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_
+ 'var _cardsKeysetCount = 0;\nvar _cardsTarCount = 0;\n'
+ 'globalThis.spPresetWarningRender = function() {};\n'
+ 'globalThis.spMslWarningRender = function() {};\n'
- + 'globalThis.pysimApplyAvailability = function() {};\n' + code;
+ + 'globalThis.pysimApplyAvailability = function() {};\n'
+ + 'globalThis.navRecord = function() {};\n'
+ + 'globalThis.navReplace = function() {};\n' + code;
eval(code);
globalThis.t = s => s;
diff --git a/frontend/tests/html.test.js b/frontend/tests/html.test.js
index f893431..8b0abb8 100644
--- a/frontend/tests/html.test.js
+++ b/frontend/tests/html.test.js
@@ -9,6 +9,31 @@ const helpPages = ['help.html', 'help-ru.html'].map(f => ({
name: f, text: fs.readFileSync(path.join(__dirname, '..', f), 'utf8'),
}));
+// The Russian dictionary must cover every user-visible key: the t('...')
+// literals in the script and every data-l10n / data-l10n-title attribute.
+// A missing key silently falls back to English - that is how the suite
+// views' Run/Move/Copy buttons stayed English until v3.22.5.
+test('every t() key and data-l10n attribute has a Russian entry', () => {
+ const dictMatch = html.match(/const LANG_RU = \{([\s\S]*?)\n\};/);
+ assert.ok(dictMatch, 'LANG_RU not found');
+ const norm = k => k
+ .replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)))
+ .replace(/\\'/g, "'")
+ .replace(/"/g, '"').replace(/&/g, '&').replace(/'/g, "'");
+ const keys = new Set();
+ for (const m of dictMatch[1].matchAll(/^\s*(?:'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)"):/gm)) {
+ keys.add(norm(m[1] !== undefined ? m[1] : m[2]));
+ }
+ const missing = [];
+ for (const m of html.matchAll(/\bt\(\s*'((?:[^'\\]|\\.)*)'\s*\)/g)) {
+ if (!keys.has(norm(m[1]))) missing.push('t(): ' + m[1]);
+ }
+ for (const m of html.matchAll(/data-l10n(?:-title)?="([^"]+)"/g)) {
+ if (!keys.has(norm(m[1]))) missing.push('data-l10n: ' + m[1]);
+ }
+ assert.deepStrictEqual(missing, [], 'missing Russian entries: ' + missing.join(' | '));
+});
+
// Tags that never take an end tag.
const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img',
'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
diff --git a/frontend/tests/nav.test.js b/frontend/tests/nav.test.js
new file mode 100644
index 0000000..d98b6b7
--- /dev/null
+++ b/frontend/tests/nav.test.js
@@ -0,0 +1,78 @@
+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);
+}
+
+// The pure state helpers (they only read the NAV_FIELDS constant).
+eval(html.match(/const NAV_FIELDS = \[[\s\S]*?\];/)[0].replace('const ', 'var '));
+eval(extractFunc(html, 'navStateKey'));
+
+test('navStateKey canonicalises the view state', () => {
+ assert.strictEqual(navStateKey(null), navStateKey({}));
+ assert.strictEqual(navStateKey({ tab: 'cards' }),
+ navStateKey({ tab: 'cards', preset: '', view: '' }));
+ assert.notStrictEqual(navStateKey({ tab: 'cards', view: 'list' }),
+ navStateKey({ tab: 'cards', view: 'edit' }));
+ // the field order does not matter
+ assert.strictEqual(navStateKey({ tab: 'a', suite: 'x' }),
+ navStateKey({ suite: 'x', tab: 'a' }));
+ // every field participates in the identity
+ const base = { tab: 'phone', sub: 'test', view: 'script', suite: 's', script: 'c' };
+ const baseKey = navStateKey(base);
+ for (const f of ['tab', 'sub', 'parser', 'view', 'listtab', 'preset', 'suite', 'script', 'item']) {
+ const st = Object.assign({}, base);
+ st[f] = 'other';
+ assert.notStrictEqual(navStateKey(st), baseKey, f + ' must change the key');
+ }
+});
+
+test('the view switches record history, the back buttons replace it', () => {
+ // every user-facing switch records its view
+ for (const fn of ['switchTab', 'scp80SwitchSubtab', 'cApduSwitchSubtab',
+ 'parserSwitchSubtab', 'phoneSwitchSubtab', 'scp81SwitchSubtab',
+ 'cardsShowList', 'cardsShowEditor', 'testViewShow', 'profilerSetView',
+ 'profilerListSwitch']) {
+ assert.match(extractFunc(html, fn), /navRecord\(\)/, fn + ' must record');
+ }
+ // the view variables the state is built from
+ assert.match(extractFunc(html, 'switchTab'), /_activeTab = name/);
+ assert.match(extractFunc(html, 'scp80SwitchSubtab'), /_scp80Sub = name/);
+ assert.match(extractFunc(html, 'cApduSwitchSubtab'), /_capduSub = name/);
+ assert.match(extractFunc(html, 'phoneSwitchSubtab'), /_phoneSub = name/);
+ assert.match(extractFunc(html, 'scp81SwitchSubtab'), /_scp81Sub = name/);
+ assert.match(extractFunc(html, 'cardsShowList'), /_cardsView = 'list'/);
+ assert.match(extractFunc(html, 'cardsShowEditor'), /_cardsView = 'edit'/);
+ // the in-app Cancel/Back (and the profiler save) replace their entry
+ for (const fn of ['cardsCancelEdit', 'profilerBackToList', 'testScriptBack',
+ 'profilerSaveProfile']) {
+ assert.match(extractFunc(html, fn), /navReplace\(\)/, fn + ' must replace');
+ }
+ // the router core: seeded, guarded, replayed through the switch functions
+ assert.match(html, /window\.addEventListener\('popstate'/);
+ assert.match(html, /history\.pushState\(/);
+ assert.match(html, /history\.replaceState\(/);
+ assert.match(html, /navSeed\(\);/);
+ assert.match(extractFunc(html, 'navApply'), /_navApplying = true/);
+ assert.match(extractFunc(html, 'navApply'), /switchTab\(/);
+ for (const fn of ['navApplyTest', 'navApplyCards', 'navApplyProfiler', 'testBackToSuites']) {
+ assert.match(html, new RegExp('function ' + fn + '\\('), fn + ' must exist');
+ }
+});
diff --git a/frontend/tests/phone_tabs.test.js b/frontend/tests/phone_tabs.test.js
index 48b0f87..b692642 100644
--- a/frontend/tests/phone_tabs.test.js
+++ b/frontend/tests/phone_tabs.test.js
@@ -32,7 +32,8 @@ const code = extractFunc(html, 'phoneSwitchSubtab') + '\n' +
'globalThis.esimFetchAll = () => { globalThis._esim = (globalThis._esim || 0) + 1; };\n' +
'globalThis.testInit = () => { globalThis._test = (globalThis._test || 0) + 1; };\n' +
'globalThis.bipEnter = () => { globalThis._bip = (globalThis._bip || 0) + 1; };\n' +
- 'globalThis.netStateFetch = () => { globalThis._netstate = (globalThis._netstate || 0) + 1; };\n';
+ 'globalThis.netStateFetch = () => { globalThis._netstate = (globalThis._netstate || 0) + 1; };\n' +
+ 'globalThis.navRecord = function() {};\n';
eval(code);
function makeClassList() {
diff --git a/frontend/tests/profiler.test.js b/frontend/tests/profiler.test.js
index c5acd16..be08dd9 100644
--- a/frontend/tests/profiler.test.js
+++ b/frontend/tests/profiler.test.js
@@ -34,6 +34,8 @@ code += extractFunc(html, 'profilerCheck', true) + '\n';
code += extractFunc(html, 'profilerCheckSnapshot', true) + '\n';
code += extractFunc(html, 'profilerScanSnapshot', true) + '\n';
code += "var _scanTarget = 'profile';\nvar profilerResults = null;\nvar profilerResultsHeader = null;\nvar profilerMismatchOnly = false;\n";
+// the view-history hooks (the router lives in the page; this pure test stubs it)
+code += 'globalThis.navRecord = function() {};\nglobalThis.navReplace = function() {};\n';
code += html.match(/const PROFILER_MASK_PREFIX4_FIDS = \{[\s\S]*?\n\};/)[0] + '\n';
eval(code);
diff --git a/pyproject.toml b/pyproject.toml
index 5ca8074..a25f6df 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
-version = "3.22.4"
+version = "3.22.5"
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 2dafd59..fb1c0fd 100644
--- a/pysim_simple_server/server.py
+++ b/pysim_simple_server/server.py
@@ -36,7 +36,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
-VERSION = '3.22.4'
+VERSION = '3.22.5'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE