From 0c7d0fb68f601b6ebc93bb202f58141c82f9a852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Fri, 7 Aug 2026 13:26:38 +0300 Subject: [PATCH] i18n: LANG_RU map, data-l10n, language toggle, version check, compatibility --- README.md | 12 +- index.html | 373 ++++++++++++++++++++++++++++++++++------------------- style.css | 9 ++ 3 files changed, 262 insertions(+), 132 deletions(-) diff --git a/README.md b/README.md index ddb9bf8..b7b9d05 100644 --- a/README.md +++ b/README.md @@ -384,4 +384,14 @@ Dark theme is supported. The app follows the OS preference on first visit, and a ## Localisation -The UI is in Russian. English translations are in progress. Language is detected from the browser's `navigator.language` preference. +The UI is in English with Russian language support. Language is detected from the browser's `navigator.language` preference. A manual toggle button (EN/RU) in the header persists the choice in `localStorage`. + +## Version compatibility + +| PWA (OTAMan) | Server | Status | +|-------------|--------|--------| +| 1.x.x | 1.x.x | ✅ Compatible | +| 1.x.x | 0.x.x | ❌ Outdated — update server | +| 1.x.x | 2.x.x+ | ⚠️ Server newer — update PWA | + +The PWA checks the server version on connect via `GET /api/version` and warns if versions are incompatible. diff --git a/index.html b/index.html index aac7581..c56e1d0 100644 --- a/index.html +++ b/index.html @@ -16,10 +16,11 @@
-

OTAMan SIM OTA с человеческим лицом

+

OTAMan SIM OTA with a Human Face v1.0.0

github +
@@ -38,7 +39,7 @@
- + - Начать с SELECT + Start with SELECT
- +
@@ -75,7 +76,7 @@
- +
@@ -83,7 +84,7 @@
- +
@@ -91,21 +92,21 @@
- +
- +
- +
@@ -115,12 +116,12 @@
- +
- +
@@ -145,13 +146,13 @@
-

Конвертация

+

Conversion

- +
- + - +
-

Конвертация

+

Conversion

- +
- + - + - + - +
- +
- +
- +
- +
- +
- +
- +
- +
- +
- +
- + - + - + @@ -1276,14 +1277,14 @@ function genSimUsim(mode) { if (cmd === 'select') { const selectApdu = buildSelectApdu(mode, preset, selInfo); - if (!selectApdu) { resultEl.value = 'Ошибка: укажите параметры SELECT'; return; } + if (!selectApdu) { resultEl.value = 'Error: specify SELECT parameters'; return; } resultEl.value = selectApdu; document.getElementById(mode + '-pack-btn').disabled = false; return; } const op = OPS[cmd]; - if (!op) { resultEl.value = 'Ошибка: неизвестная команда'; return; } + if (!op) { resultEl.value = 'Error: unknown command'; return; } let p1, p2, p3, data = null; if (cmd === 'read-record') { @@ -1291,17 +1292,17 @@ function genSimUsim(mode) { } else if (cmd === 'read-binary') { p1 = (offset >> 8) & 0xFF; p2 = offset & 0xFF; p3 = le; } else if (cmd === 'update-record') { - if (!dataHex) { resultEl.value = 'Ошибка: укажите данные'; return; } + if (!dataHex) { resultEl.value = 'Error: specify data'; return; } p1 = record; p2 = recMode; p3 = dataHex.length / 2; data = dataHex; } else if (cmd === 'update-binary') { - if (!dataHex) { resultEl.value = 'Ошибка: укажите данные'; return; } + if (!dataHex) { resultEl.value = 'Error: specify data'; return; } p1 = (offset >> 8) & 0xFF; p2 = offset & 0xFF; p3 = dataHex.length / 2; data = dataHex; } else if (cmd === 'erase-binary') { p1 = (offset >> 8) & 0xFF; p2 = offset & 0xFF; p3 = 0x00; } else if (cmd === 'activate-file' || cmd === 'deactivate-file') { p1 = 0x00; p2 = 0x00; p3 = 0x00; } else if (cmd === 'verify' || cmd === 'change') { - if (!dataHex) { resultEl.value = 'Ошибка: укажите данные'; return; } + if (!dataHex) { resultEl.value = 'Error: specify data'; return; } p1 = 0x00; p2 = parseInt(pin, 16); p3 = dataHex.length / 2; data = dataHex; } @@ -1342,7 +1343,7 @@ function genRam() { const resultEl = document.getElementById('ram-result'); - if (cmd === 'install-load' && !sdAid) { resultEl.value = 'Ошибка: укажите SD AID'; return; } + if (cmd === 'install-load' && !sdAid) { resultEl.value = 'Error: specify SD AID'; return; } const INSTALL_P1 = { 'install-load': 0x02, @@ -1414,23 +1415,23 @@ function genRam() { const dataLen = (dataField.length / 2).toString(16).padStart(2, '0'); apdu = buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dataLen, 16), dataField); } else if (cmd === 'install-make-sel') { - if (!aid) { resultEl.value = 'Ошибка: укажите AID'; return; } + if (!aid) { resultEl.value = 'Error: specify AID'; return; } const aidLen = (aid.length / 2).toString(16).padStart(2, '0'); const dataField = '4F' + aidLen + aid; const dataLen = (dataField.length / 2).toString(16).padStart(2, '0'); apdu = buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dataLen, 16), dataField); } else if (cmd === 'install-reg-update' || cmd === 'install-extradition') { - if (!aid) { resultEl.value = 'Ошибка: укажите AID'; return; } + if (!aid) { resultEl.value = 'Error: specify AID'; return; } const aidLen = (aid.length / 2).toString(16).padStart(2, '0'); const dataField = '4F' + aidLen + aid; const dataLen = (dataField.length / 2).toString(16).padStart(2, '0'); apdu = buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dataLen, 16), dataField); } else if (cmd === 'load') { - if (!ramData) { resultEl.value = 'Ошибка: укажите данные'; return; } + if (!ramData) { resultEl.value = 'Error: specify data'; return; } const dataLen = (ramData.length / 2).toString(16).padStart(2, '0'); apdu = buildApdu(0x80, 0xE8, 0x00, blockNum, parseInt(dataLen, 16), ramData); } else if (cmd === 'delete') { - if (!aid) { resultEl.value = 'Ошибка: укажите AID'; return; } + if (!aid) { resultEl.value = 'Error: specify AID'; return; } const aidLen = (aid.length / 2).toString(16).padStart(2, '0'); const dataField = '4F' + aidLen + aid; const dataLen = (dataField.length / 2).toString(16).padStart(2, '0'); @@ -1442,26 +1443,26 @@ function genRam() { const tag = parseInt(tagHex, 16); apdu = buildApdu(0x80, 0xCA, (tag >> 8) & 0xFF, tag & 0xFF, 0x00, null); } else if (cmd === 'store-data') { - if (!ramData) { resultEl.value = 'Ошибка: укажите данные'; return; } + if (!ramData) { resultEl.value = 'Error: specify data'; return; } const dataLen = (ramData.length / 2).toString(16).padStart(2, '0'); apdu = buildApdu(0x80, 0xE2, parseInt(enc, 16), blockNum, parseInt(dataLen, 16), ramData); } else if (cmd === 'set-status') { - if (!aid) { resultEl.value = 'Ошибка: укажите AID'; return; } + if (!aid) { resultEl.value = 'Error: specify AID'; return; } const aidLen = (aid.length / 2).toString(16).padStart(2, '0'); const dataField = '4F' + aidLen + aid; const dataLen = (dataField.length / 2).toString(16).padStart(2, '0'); apdu = buildApdu(0x80, 0xF0, parseInt(ssMode, 16), parseInt(ssState, 16), parseInt(dataLen, 16), dataField); } else if (cmd === 'ext-auth') { - if (!ramData) { resultEl.value = 'Ошибка: укажите данные (host cryptogram)'; return; } + if (!ramData) { resultEl.value = 'Error: specify data (host cryptogram)'; return; } const dataLen = (ramData.length / 2).toString(16).padStart(2, '0'); apdu = buildApdu(0x80, 0x82, 0x00, 0x00, parseInt(dataLen, 16), ramData); } else if (cmd === 'int-auth') { - if (!ramData) { resultEl.value = 'Ошибка: укажите данные (challenge)'; return; } + if (!ramData) { resultEl.value = 'Error: specify data (challenge)'; return; } const dataLen = (ramData.length / 2).toString(16).padStart(2, '0'); apdu = buildApdu(0x80, 0x88, 0x00, 0x00, parseInt(dataLen, 16), ramData) + '00'; } - resultEl.value = apdu || 'Ошибка: неизвестная команда'; + resultEl.value = apdu || 'Error: unknown command'; if (apdu) document.getElementById('ram-pack-btn').disabled = false; } @@ -1493,35 +1494,35 @@ function genConvert() { let result = ''; try { if (type === 'imsi') { - if (!input) { result = 'Ошибка: введите значение'; } - else if (!/^\d{15}$/.test(input)) { result = 'Ошибка: IMSI должен быть 15 цифр'; } + if (!input) { result = 'Error: enter a value'; } + else if (!/^\d{15}$/.test(input)) { result = 'Error: IMSI must be 15 digits'; } else result = encImsi(input); } else if (type === 'msisdn') { - if (!input) { result = 'Ошибка: введите значение'; } + if (!input) { result = 'Error: enter a value'; } else result = encMsisdn(input); } else if (type === 'iccid') { - if (!input) { result = 'Ошибка: введите значение'; } + if (!input) { result = 'Error: enter a value'; } else result = encIccid(input); } else if (type === 'spn') { - if (!input) { result = 'Ошибка: введите значение'; } + if (!input) { result = 'Error: enter a value'; } else result = encodeSpn(input); } else if (type === 'plmn') { - if (!mcc || !mnc) { result = 'Ошибка: укажите MCC и MNC'; } - else if (!/^\d{3}$/.test(mcc)) { result = 'Ошибка: MCC должен быть 3 цифры'; } - else if (!/^\d{2,3}$/.test(mnc)) { result = 'Ошибка: MNC должен быть 2-3 цифры'; } + if (!mcc || !mnc) { result = 'Error: specify MCC and MNC'; } + else if (!/^\d{3}$/.test(mcc)) { result = 'Error: MCC must be 3 digits'; } + else if (!/^\d{2,3}$/.test(mnc)) { result = 'Error: MNC must be 2-3 digits'; } else result = encPlmn(mcc, mnc); } else if (type === 'plmnwact') { - if (!mcc || !mnc) { result = 'Ошибка: укажите MCC и MNC'; } - else if (!/^\d{3}$/.test(mcc)) { result = 'Ошибка: MCC должен быть 3 цифры'; } - else if (!/^\d{2,3}$/.test(mnc)) { result = 'Ошибка: MNC должен быть 2-3 цифры'; } + if (!mcc || !mnc) { result = 'Error: specify MCC and MNC'; } + else if (!/^\d{3}$/.test(mcc)) { result = 'Error: MCC must be 3 digits'; } + else if (!/^\d{2,3}$/.test(mnc)) { result = 'Error: MNC must be 2-3 digits'; } else result = encPlmn(mcc, mnc) + act.toUpperCase(); } else if (type === 'nibswap') { - if (!input) { result = 'Ошибка: введите значение'; } - else if (input.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(input)) { result = 'Ошибка: введите чётное количество hex-символов'; } + if (!input) { result = 'Error: enter a value'; } + else if (input.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(input)) { result = 'Error: enter an even number of hex characters'; } else result = swapNibbles(input); } } catch (e) { - result = 'Ошибка: ' + e.message; + result = 'Error: ' + e.message; } document.getElementById('conv-result').value = result; } @@ -1681,7 +1682,7 @@ function retailMac(data, keyBytes) { // ===== Secure Packet assembly ===== function genSp() { const apduHex = (document.getElementById('sp-apdu').value || '').replace(/[^0-9a-fA-F]/g, ''); - if (!apduHex) { document.getElementById('sp-result').value = 'Ошибка: укажите APDU'; return; } + if (!apduHex) { document.getElementById('sp-result').value = 'Error: specify APDU'; return; } const spi1 = parseInt(document.getElementById('sp-spi1').value, 16); const spi2 = parseInt(document.getElementById('sp-spi2-hex').value, 16); @@ -1703,20 +1704,20 @@ function genSp() { if (kicKeyHex) { kicKey = hexToBytes(kicKeyHex); if (kicKey.length !== 8 && kicKey.length !== 16 && kicKey.length !== 24) - { resultEl.value = 'Ошибка: KIc key must be 8, 16, or 24 bytes'; return; } + { resultEl.value = 'Error: KIc key must be 8, 16, or 24 bytes'; return; } } - } catch (e) { resultEl.value = 'Ошибка KIc key: ' + e.message; return; } + } catch (e) { resultEl.value = 'KIc key error: ' + e.message; return; } try { if (kidKeyHex) { kidKey = hexToBytes(kidKeyHex); if (kidKey.length !== 8 && kidKey.length !== 16 && kidKey.length !== 24) - { resultEl.value = 'Ошибка: KID key must be 8, 16, or 24 bytes'; return; } + { resultEl.value = 'Error: KID key must be 8, 16, or 24 bytes'; return; } } - } catch (e) { resultEl.value = 'Ошибка KID key: ' + e.message; return; } + } catch (e) { resultEl.value = 'KID key error: ' + e.message; return; } - if (ciphering && !kicKey) { resultEl.value = 'Ошибка: ciphering requires KIc key'; return; } - if (hasMac && !kidKey) { resultEl.value = 'Ошибка: MAC requires KID key'; return; } + if (ciphering && !kicKey) { resultEl.value = 'Error: ciphering requires KIc key'; return; } + if (hasMac && !kidKey) { resultEl.value = 'Error: MAC requires KID key'; return; } const apdu = hexToBytes(apduHex); @@ -2434,7 +2435,7 @@ function esc(str) { async function pysimFetch(path, body) { const url = pysimBase + path; - const opts = { method: body ? 'POST' : 'GET', headers: { 'Content-Type': 'application/json' } }; + const opts = { method: body ? 'POST' : 'GET', headers: { 'Content-Type': 'application/json', 'Accept-Language': currentLang } }; if (body) opts.body = JSON.stringify(body); const res = await fetch(url, opts); return await res.json(); @@ -2500,6 +2501,19 @@ async function pysimConnect() { statusEl.innerHTML = 'Checking card...'; pysimSetConnected('spin'); try { + // Version check + const ver = await pysimFetch('/api/version'); + if (!ver.version) throw new Error('No version'); + const sv = (ver.version || '').split('.').map(Number); + if (sv[0] < 1 || (sv[0] === 1 && sv[1] < 0)) { + statusEl.innerHTML = 'Server version ' + esc(ver.version) + ' is outdated. Please update pysim-otaman-server.'; + btn.textContent = 'Connect'; + btn.disabled = false; + return; + } + if (sv[0] > 1) { + statusEl.innerHTML = 'Warning: server version ' + esc(ver.version) + ' is newer than expected. Update OTAMan for full compatibility.'; + } const status = await pysimFetch('/api/status'); if (!status.app_ready) { throw new Error('Server not ready'); @@ -2906,6 +2920,102 @@ document.querySelectorAll('.pysim-subtab').forEach(btn => { btn.addEventListener('click', () => pysimSwitchSubtab(btn.dataset.pysimSub)); }); +// ===== i18n ===== +const LANG_RU = { + 'Generate APDU': 'Сгенерировать APDU', + 'Pack into Secured packet': 'Упаковать в Secured packet', + 'Convert': 'Конвертировать', + 'Decode': 'Декодировать', + 'Generate BER-TLV': 'Сгенерировать BER-TLV', + 'Generate Secure Packet': 'Сгенерировать Secure Packet', + 'Conversion': 'Конвертация', + 'Command': 'Команда', + 'Start with SELECT': 'Начать с SELECT', + 'SELECT method': 'Метод SELECT', + 'Path (hex)': 'Путь (hex)', + 'FID chain (comma-separated)': 'FID цепочка (через запятую)', + 'Record #': 'Запись №', + 'Selection mode (P2)': 'Режим выборки (P2)', + 'Offset (hex)': 'Смещение (hex)', + 'Data (hex)': 'Данные (hex)', + 'Record size (bytes)': 'Размер записи (байт)', + 'Allow P1/P2 editing': 'Разрешить редактирование P1/P2', + 'Privileges': 'Привилегии', + 'Byte 1': 'Байт 1', + 'Byte 2': 'Байт 2', + 'SIM/UICC Toolkit parameters': 'Параметры SIM/UICC Toolkit', + 'Toolkit mode': 'Режим Toolkit', + 'Priority': 'Приоритет', + 'Timers (max)': 'Таймеры (макс)', + 'Menu text length': 'Длина текста меню', + 'Menu items': 'Пункты меню', + 'First menu position': 'Первая позиция в меню', + 'First menu ID (hex)': 'ID первого пункта меню (hex)', + 'Last menu position': 'Позиция последнего пункта меню', + 'Last menu ID (hex)': 'ID последнего пункта меню (hex)', + 'Channels (max)': 'Каналы (макс)', + 'Access domain (hex)': 'Домен доступа (hex)', + 'Load data (hex)': 'Данные загрузки (hex)', + 'Block number': 'Номер блока', + 'Encryption': 'Шифрование', + 'DELETE mode': 'Режим DELETE', + 'GET STATUS mode': 'Режим GET STATUS', + 'GET STATUS format': 'Формат GET STATUS', + 'SET STATUS type': 'Тип SET STATUS', + 'State': 'Состояние', + 'GET DATA tag': 'Tag GET DATA', + 'Response data (hex)': 'Данные ответа (hex)', + '— Select command —': '— Выберите команду —', + '— select preset —': '— выберите предустановленный —', + 'SIM OTA with a Human Face': 'SIM OTA с человеческим лицом', + 'Error: specify SELECT parameters': 'Ошибка: укажите параметры SELECT', + 'Error: unknown command': 'Ошибка: неизвестная команда', + 'Error: specify data': 'Ошибка: укажите данные', + 'Error: specify SD AID': 'Ошибка: укажите SD AID', + 'Error: specify AID': 'Ошибка: укажите AID', + 'Error: specify data (host cryptogram)': 'Ошибка: укажите данные (host cryptogram)', + 'Error: specify data (challenge)': 'Ошибка: укажите данные (challenge)', + 'Error: enter a value': 'Ошибка: введите значение', + 'Error: IMSI must be 15 digits': 'Ошибка: IMSI должен быть 15 цифр', + 'Error: specify MCC and MNC': 'Ошибка: укажите MCC и MNC', + 'Error: MCC must be 3 digits': 'Ошибка: MCC должен быть 3 цифры', + 'Error: MNC must be 2-3 digits': 'Ошибка: MNC должен быть 2-3 цифры', + 'Error: enter an even number of hex characters': 'Ошибка: введите чётное количество hex-символов', + 'Error: specify APDU': 'Ошибка: укажите APDU', + 'KIc key error: ': 'Ошибка KIc key: ', + 'KID key error: ': 'Ошибка KID key: ', + 'Error: ciphering requires KIc key': 'Ошибка: ciphering requires KIc key', + 'Error: MAC requires KID key': 'Ошибка: MAC requires KID key', +}; +let currentLang = 'en'; + +function t(text) { + if (currentLang === 'ru' && LANG_RU[text]) return LANG_RU[text]; + return text; +} + +function toggleLang() { + currentLang = currentLang === 'en' ? 'ru' : 'en'; + localStorage.setItem('lang', currentLang); +document.getElementById('lang-btn').textContent = currentLang.toUpperCase(); +translatePage(); + translatePage(); +} + +function translatePage() { + document.querySelectorAll('[data-l10n]').forEach(el => { + const key = el.getAttribute('data-l10n'); + el.textContent = t(key); + }); + const slogan = document.getElementById('slogan'); + if (slogan) slogan.textContent = t('SIM OTA with a Human Face'); +} + +// Init language +const userLang = (navigator.language || '').split('-')[0]; +currentLang = localStorage.getItem('lang') || (userLang === 'ru' ? 'ru' : 'en'); +document.getElementById('lang-btn').textContent = currentLang.toUpperCase(); + if (localStorage.getItem('theme') === 'dark' || (!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) { document.documentElement.classList.add('dark'); @@ -2929,6 +3039,7 @@ toggleSimOverride('usim'); updateP1P2Display('sim'); updateP1P2Display('usim'); updateRamFields(); +translatePage(); // PWA let deferredPrompt; const installBtn = document.getElementById('install-btn'); diff --git a/style.css b/style.css index bc002f6..ee1dadd 100644 --- a/style.css +++ b/style.css @@ -583,6 +583,10 @@ video { margin-bottom: 1.5rem; } +.ml-1 { + margin-left: 0.25rem; +} + .ml-2 { margin-left: 0.5rem; } @@ -1022,6 +1026,11 @@ video { color: rgb(255 255 255 / var(--tw-text-opacity, 1)); } +.text-yellow-500 { + --tw-text-opacity: 1; + color: rgb(234 179 8 / var(--tw-text-opacity, 1)); +} + .text-yellow-600 { --tw-text-opacity: 1; color: rgb(202 138 4 / var(--tw-text-opacity, 1));