45c72d874f
The stuck-card pattern: after a Back TR the card re-issues the parent menu
as a new SELECT ITEM (91XX -> FETCH, paused, awaiting TR), but the UI's
back/timeout branch ignored that response and showed the cached top menu;
the next item click then sent ENVELOPE(Menu Selection) while the card was
waiting for the TR. The card answers such an ENVELOPE with 9000 (not the
usual 91XX), and menu-select cleared the pending command without a TR,
leaving an unfinished proactive session until reset/equip.
- server: _finish_pending_menu() answers a paused command with a cancel TR
(0x10) before /api/menu-select sends its ENVELOPE and drains a 91XX
follow-up, so a new selection can never shadow an unanswered FETCH
- frontend: stkMenuRespond('back'|'timeout') renders the follow-up command
from the server response (SELECT ITEM / DISPLAY TEXT) and shows the
cached top menu only when the TR answer carries no command (9000)
- tests: finish-pending cancel TR + chain drain (Python); stkMenuRespond
back/timeout/cancel/ok rendering (frontend); help/AGENTS updated;
SW cache v107 -> v108.
9315 lines
444 KiB
HTML
9315 lines
444 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>OTAMan</title>
|
||
<link rel="stylesheet" href="style.css">
|
||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||
<link rel="icon" type="image/png" href="sim.png" sizes="462x462">
|
||
<link rel="manifest" href="manifest.json">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<link rel="apple-touch-icon" href="icon-192.png">
|
||
<script src="des-bundle.js"></script>
|
||
<script src="aes-bundle.js"></script>
|
||
</head>
|
||
<body class="bg-neutral-50 dark:bg-slate-900 text-gray-800 dark:text-slate-200">
|
||
|
||
<div class="max-w-7xl mx-auto px-6 py-2">
|
||
|
||
<div class="flex items-center justify-between mb-3">
|
||
<h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v2.0.0</span></h1>
|
||
<div class="flex items-center gap-4">
|
||
<span id="state-indicator" class="text-xs text-gray-400" title="">●</span>
|
||
<button id="install-btn" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700" style="display:none">INSTALL PWA [for offline use]</button>
|
||
<a href="https://github.com/anttro/otaman" target="_blank" class="text-xs text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300">github</a>
|
||
<a id="help-link" href="help.html" target="_blank" rel="noopener" class="text-xs text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300" data-l10n="help">help</a>
|
||
<button onclick="toggleLang()" id="lang-btn" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700">EN</button>
|
||
<button onclick="toggleTheme()" id="theme-btn" class="px-2 py-1 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700">🌙</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex flex-wrap gap-1 mb-4 border-b border-gray-300 dark:border-slate-600">
|
||
<button class="tab-btn active px-4 py-1.5 text-sm rounded-t bg-blue-600 dark:bg-blue-500 text-white dark:text-white" data-tab="c-apdu">Remote APDU</button>
|
||
<button class="tab-btn px-4 py-1.5 text-sm rounded-t bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-tab="scp80">SCP80</button>
|
||
<button class="tab-btn px-4 py-1.5 text-sm rounded-t bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-tab="pysim" data-l10n="Card reader">Card reader</button>
|
||
<button class="tab-btn px-4 py-1.5 text-sm rounded-t bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-tab="profiler" data-l10n="Profiler">Profiler</button>
|
||
<button class="tab-btn px-4 py-1.5 text-sm rounded-t bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-tab="phone" data-l10n="Phone simulator">Phone simulator</button>
|
||
</div>
|
||
|
||
<div id="tab-c-apdu" class="tab-content">
|
||
<div class="flex gap-4">
|
||
<div class="flex-1">
|
||
<div class="flex flex-wrap justify-center gap-2 mb-3">
|
||
<button class="c-apdu-subtab px-4 py-1.5 text-sm rounded-full bg-blue-600 text-white" data-sub="sim" onclick="cApduSwitchSubtab('sim')">SIM RFM</button>
|
||
<button class="c-apdu-subtab px-4 py-1.5 text-sm 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-sub="usim" onclick="cApduSwitchSubtab('usim')">USIM RFM</button>
|
||
<button class="c-apdu-subtab px-4 py-1.5 text-sm 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-sub="ber" onclick="cApduSwitchSubtab('ber')">Expanded Script</button>
|
||
<button class="c-apdu-subtab px-4 py-1.5 text-sm 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-sub="ram" onclick="cApduSwitchSubtab('ram')">RAM/GP</button>
|
||
<button class="c-apdu-subtab px-4 py-1.5 text-sm 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-sub="httpota" onclick="cApduSwitchSubtab('httpota')">HTTP OTA</button>
|
||
<button class="c-apdu-subtab px-4 py-1.5 text-sm 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-sub="parse" onclick="cApduSwitchSubtab('parse')" data-l10n="C-APDU Parser">C-APDU Parser</button>
|
||
<button class="c-apdu-subtab px-4 py-1.5 text-sm 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-sub="response" onclick="cApduSwitchSubtab('response')" data-l10n="Response parser">Response parser</button>
|
||
</div>
|
||
<div id="c-apdu-sub-sim">
|
||
<div id="chain-sim-rows"></div>
|
||
<div class="flex flex-wrap gap-1 mb-3">
|
||
<button onclick="chainAddRow('chain-sim','select')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ SELECT</button>
|
||
<button onclick="chainAddRow('chain-sim','read-record')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ READ RECORD</button>
|
||
<button onclick="chainAddRow('chain-sim','read-binary')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ READ BINARY</button>
|
||
<button onclick="chainAddRow('chain-sim','update-record')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ UPDATE RECORD</button>
|
||
<button onclick="chainAddRow('chain-sim','update-binary')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ UPDATE BINARY</button>
|
||
<button onclick="chainAddRow('chain-sim','erase-binary')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ ERASE BINARY</button>
|
||
<button onclick="chainAddRow('chain-sim','activate-file')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ ACTIVATE</button>
|
||
<button onclick="chainAddRow('chain-sim','deactivate-file')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ DEACTIVATE</button>
|
||
<button onclick="chainAddRow('chain-sim','verify')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ VERIFY PIN</button>
|
||
<button onclick="chainAddRow('chain-sim','change')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ CHANGE PIN</button>
|
||
<button onclick="chainAddRow('chain-sim','disable')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ DISABLE PIN</button>
|
||
<button onclick="chainAddRow('chain-sim','enable')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ ENABLE PIN</button>
|
||
<button onclick="chainAddRow('chain-sim','unblock')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ UNBLOCK PIN</button>
|
||
<button onclick="chainAddRow('chain-sim','get-response')" class="text-xs px-2 py-1 bg-emerald-600 text-white rounded hover:bg-emerald-700">+ GET RESPONSE</button>
|
||
</div>
|
||
<textarea id="chain-sim-preview" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800" placeholder="Chain preview — add commands above"></textarea>
|
||
<button onclick="packToSp('chain-sim-preview')" id="chain-sim-pack-btn" disabled class="mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Pack into Secured packet">Pack into Secured packet</button>
|
||
</div>
|
||
|
||
<div id="c-apdu-sub-usim" class="hidden">
|
||
<div id="chain-usim-rows"></div>
|
||
<div class="flex flex-wrap gap-1 mb-3">
|
||
<button onclick="chainAddRow('chain-usim','select')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ SELECT</button>
|
||
<button onclick="chainAddRow('chain-usim','read-record')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ READ RECORD</button>
|
||
<button onclick="chainAddRow('chain-usim','read-binary')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ READ BINARY</button>
|
||
<button onclick="chainAddRow('chain-usim','update-record')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ UPDATE RECORD</button>
|
||
<button onclick="chainAddRow('chain-usim','update-binary')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ UPDATE BINARY</button>
|
||
<button onclick="chainAddRow('chain-usim','activate-file')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ ACTIVATE</button>
|
||
<button onclick="chainAddRow('chain-usim','deactivate-file')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ DEACTIVATE</button>
|
||
<button onclick="chainAddRow('chain-usim','verify')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ VERIFY PIN</button>
|
||
<button onclick="chainAddRow('chain-usim','change')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ CHANGE PIN</button>
|
||
<button onclick="chainAddRow('chain-usim','disable')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ DISABLE PIN</button>
|
||
<button onclick="chainAddRow('chain-usim','enable')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ ENABLE PIN</button>
|
||
<button onclick="chainAddRow('chain-usim','unblock')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ UNBLOCK PIN</button>
|
||
<button onclick="chainAddRow('chain-usim','get-response')" class="text-xs px-2 py-1 bg-emerald-600 text-white rounded hover:bg-emerald-700">+ GET RESPONSE</button>
|
||
</div>
|
||
<textarea id="chain-usim-preview" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800" placeholder="Chain preview — add commands above"></textarea>
|
||
<button onclick="packToSp('chain-usim-preview')" id="chain-usim-pack-btn" disabled class="mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Pack into Secured packet">Pack into Secured packet</button>
|
||
</div>
|
||
|
||
<div id="c-apdu-sub-ber" class="hidden">
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Format</label>
|
||
<select id="ber-format" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 dark:text-slate-300 font-mono">
|
||
<option value="AA">Definite (AA)</option>
|
||
<option value="AE">Indefinite (AE)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="mb-3">
|
||
<div class="flex flex-wrap gap-1 mb-2 items-center">
|
||
<span class="text-sm font-medium text-gray-700 dark:text-slate-300 mr-1">Commands</span>
|
||
<button onclick="addBerRow('c-apdu')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ C-APDU</button>
|
||
<button onclick="addBerRow('immediate')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ Immediate</button>
|
||
<button onclick="addBerRow('error')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ Error</button>
|
||
<button onclick="addBerRow('chaining')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ Chaining</button>
|
||
</div>
|
||
<div id="ber-rows" class="space-y-2"></div>
|
||
</div>
|
||
|
||
<button onclick="genBerTlv()" class="mb-3 px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Generate Expanded Script APDU">Generate Expanded Script APDU</button>
|
||
<textarea id="ber-result" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800 dark:text-slate-300"></textarea>
|
||
<button onclick="packToSp('ber-result')" id="ber-pack-btn" disabled class="mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Pack into Secured packet">Pack into Secured packet</button>
|
||
</div>
|
||
|
||
<div id="c-apdu-sub-ram" class="hidden">
|
||
<div id="chain-ram-rows"></div>
|
||
<div class="flex flex-wrap gap-1 mb-3">
|
||
<button onclick="chainAddRow('chain-ram','install-load')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ INSTALL [for load]</button>
|
||
<button onclick="chainAddRow('chain-ram','install-install')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ INSTALL [for install]</button>
|
||
<button onclick="chainAddRow('chain-ram','install-make-sel')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ INSTALL [for make selectable]</button>
|
||
<button onclick="chainAddRow('chain-ram','install-reg-update')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ INSTALL [for registry update]</button>
|
||
<button onclick="chainAddRow('chain-ram','install-extradition')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ INSTALL [for extradition]</button>
|
||
<button onclick="chainAddRow('chain-ram','load')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ LOAD</button>
|
||
<button onclick="chainAddRow('chain-ram','delete')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ DELETE</button>
|
||
<button onclick="chainAddRow('chain-ram','get-status')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ GET STATUS</button>
|
||
<button onclick="chainAddRow('chain-ram','get-data')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ GET DATA</button>
|
||
<button onclick="chainAddRow('chain-ram','store-data')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ STORE DATA</button>
|
||
<button onclick="chainAddRow('chain-ram','set-status')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ SET STATUS</button>
|
||
<button onclick="chainAddRow('chain-ram','ext-auth')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ EXTERNAL AUTH</button>
|
||
<button onclick="chainAddRow('chain-ram','int-auth')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ INTERNAL AUTH</button>
|
||
<button onclick="chainAddRow('chain-ram','get-response')" class="text-xs px-2 py-1 bg-emerald-600 text-white rounded hover:bg-emerald-700">+ GET RESPONSE</button>
|
||
</div>
|
||
<textarea id="chain-ram-preview" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800" placeholder="Chain preview — add commands above"></textarea>
|
||
<button onclick="packToSp('chain-ram-preview')" id="chain-ram-pack-btn" disabled class="mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Pack into Secured packet">Pack into Secured packet</button>
|
||
</div>
|
||
<div id="c-apdu-sub-parse" class="hidden">
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="APDU hex">APDU hex</label>
|
||
<textarea id="parse-input" rows="4" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="80 E6 0C 00 ..."></textarea>
|
||
</div>
|
||
<button onclick="parseHex()" class="mb-3 px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Decode">Decode</button>
|
||
<div id="parse-output"></div>
|
||
</div>
|
||
|
||
<div id="c-apdu-sub-httpota" class="hidden">
|
||
<div class="flex gap-2 mb-3">
|
||
<button id="hota-mode-trigger" onclick="hotaSetMode('trigger')" class="px-4 py-1.5 text-sm rounded-full bg-blue-600 text-white" data-l10n="Trigger (Push SMS)">Trigger (Push SMS)</button>
|
||
<button id="hota-mode-store" onclick="hotaSetMode('store')" class="px-4 py-1.5 text-sm 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="Store (SD admin params)">Store (SD admin params)</button>
|
||
</div>
|
||
|
||
<div id="hota-store-tag-row" class="mb-3 hidden">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="SD params tag">SD params tag</label>
|
||
<select id="hota-store-tag" onchange="hotaUpdatePreview()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
<option value="85">'85' — GP v2.2 Amendment B v1.1 (Table 4-4)</option>
|
||
<option value="A5">'A5' — GP v2.2 Amendment B v1.1.3</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="border border-gray-300 dark:border-slate-600 rounded p-3 bg-gray-50 dark:bg-slate-800 mb-3">
|
||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||
<input type="checkbox" id="hota-include-conn" checked onchange="hotaUpdatePreview()" class="rounded">
|
||
<span data-l10n="Connection parameters ('84')">Connection parameters ('84')</span>
|
||
</label>
|
||
<div id="hota-conn-tlvs" class="hidden"></div>
|
||
<div class="flex flex-wrap gap-1 mt-2">
|
||
<button onclick="hotaAddTlv()" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700" data-l10n="+ TLV">+ TLV</button>
|
||
<button onclick="hotaAddPreset('devid')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700" data-l10n="+ Device Identities">+ Device Identities</button>
|
||
<button onclick="hotaAddPreset('alpha')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700" data-l10n="+ Alpha (empty)">+ Alpha (empty)</button>
|
||
<button onclick="hotaAddPreset('bearer')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700" data-l10n="+ Bearer (GPRS)">+ Bearer (GPRS)</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="border border-gray-300 dark:border-slate-600 rounded p-3 bg-gray-50 dark:bg-slate-800 mb-3">
|
||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||
<input type="checkbox" id="hota-include-sec" onchange="hotaUpdatePreview()" class="rounded">
|
||
<span data-l10n="Security parameters ('85')">Security parameters ('85')</span>
|
||
</label>
|
||
<div id="hota-sec-fields" class="hidden space-y-2">
|
||
<div class="flex gap-2">
|
||
<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="PSK Identity (text)">PSK Identity (text)</label>
|
||
<input id="hota-psk-text" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="89012345678901234567">
|
||
</div>
|
||
<div class="w-16"><label class="block text-xs text-gray-600 dark:text-slate-400">KVN</label>
|
||
<input id="hota-kvn" maxlength="2" value="01" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div class="w-16"><label class="block text-xs text-gray-600 dark:text-slate-400">KID</label>
|
||
<input id="hota-kid" maxlength="2" value="01" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="border border-gray-300 dark:border-slate-600 rounded p-3 bg-gray-50 dark:bg-slate-800 mb-3">
|
||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||
<input type="checkbox" id="hota-include-retry" onchange="hotaUpdatePreview()" class="rounded">
|
||
<span data-l10n="Retry policy ('86')">Retry policy ('86')</span>
|
||
</label>
|
||
<div id="hota-retry-fields" class="hidden space-y-2">
|
||
<div class="flex gap-2">
|
||
<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="Retry counter (B0,00=unlimited)">Retry counter (B0,00=unlimited)</label>
|
||
<input id="hota-retry-counter" maxlength="4" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="B000">
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<div class="w-16"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="Hours">Hours</label>
|
||
<input id="hota-delay-h" type="number" min="0" max="255" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="0">
|
||
</div>
|
||
<div class="w-16"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="Minutes">Minutes</label>
|
||
<input id="hota-delay-m" type="number" min="0" max="59" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="0">
|
||
</div>
|
||
<div class="w-16"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="Seconds">Seconds</label>
|
||
<input id="hota-delay-s" type="number" min="0" max="59" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="0">
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-2 items-end">
|
||
<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="Report failure (vendor-specific TLV)">Report failure (vendor-specific TLV)</label>
|
||
<input id="hota-retry-report" maxlength="40" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="0A08...">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="border border-gray-300 dark:border-slate-600 rounded p-3 bg-gray-50 dark:bg-slate-800 mb-3">
|
||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||
<input type="checkbox" id="hota-include-http" onchange="hotaUpdatePreview()" class="rounded">
|
||
<span data-l10n="HTTP POST ('89', 8A=host,8B=agent,8C=URI)">HTTP POST ('89', 8A=host,8B=agent,8C=URI)</span>
|
||
</label>
|
||
<div id="hota-http-fields" class="hidden space-y-2">
|
||
<div class="flex gap-2">
|
||
<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="Host (text→hex)">Host (text→hex)</label>
|
||
<input id="hota-host" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="example.com">
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="HTTP agent ID (text, X-Admin-From)">HTTP agent ID (text, X-Admin-From)</label>
|
||
<input id="hota-agent" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="89012345678901234567">
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400" data-l10n="URI (text→hex)">URI (text→hex)</label>
|
||
<input id="hota-uri" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="http://example.com/sdp">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="border border-gray-300 dark:border-slate-600 rounded p-3 bg-gray-50 dark:bg-slate-800 mb-3 hidden" id="hota-expanded-row">
|
||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300">
|
||
<input type="checkbox" id="hota-expanded" onchange="hotaUpdatePreview()" class="rounded">
|
||
<span data-l10n="Wrapped in Command Scripting template ('AA')">Wrapped in Command Scripting template ('AA')</span>
|
||
</label>
|
||
</div>
|
||
|
||
<textarea id="hota-preview" rows="5" readonly oninput="hotaPreviewEdited()" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800 mb-3" placeholder="RAM-over-HTTP payload will appear here..."></textarea>
|
||
<button onclick="packToSp('hota-preview')" id="hota-pack-btn" disabled class="mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Pack into Secured packet">Pack into Secured packet</button>
|
||
</div>
|
||
|
||
<div id="c-apdu-sub-response" class="hidden">
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Command">Command</label>
|
||
<select id="resp-cmd" onchange="updateRespCmd()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="" data-l10n="— Select command —">— Select command —</option>
|
||
<optgroup label="SIM / USIM">
|
||
<option value="sim-select">SELECT</option>
|
||
<option value="sim-status">STATUS</option>
|
||
<option value="sim-read-binary">READ BINARY</option>
|
||
<option value="sim-update-binary">UPDATE BINARY</option>
|
||
<option value="sim-read-record">READ RECORD</option>
|
||
<option value="sim-update-record">UPDATE RECORD</option>
|
||
<option value="sim-verify">VERIFY PIN</option>
|
||
<option value="sim-change">CHANGE PIN</option>
|
||
<option value="sim-disable">DISABLE PIN</option>
|
||
<option value="sim-enable">ENABLE PIN</option>
|
||
<option value="sim-unblock">UNBLOCK PIN</option>
|
||
<option value="sim-deactivate">DEACTIVATE FILE</option>
|
||
<option value="sim-activate">ACTIVATE FILE</option>
|
||
<option value="sim-challenge">GET CHALLENGE</option>
|
||
<option value="sim-auth">AUTHENTICATE</option>
|
||
<option value="sim-profile">TERMINAL PROFILE</option>
|
||
<option value="sim-envelope">ENVELOPE</option>
|
||
<option value="sim-fetch">FETCH</option>
|
||
<option value="sim-terminal-rsp">TERMINAL RESPONSE</option>
|
||
<option value="sim-manage-channel">MANAGE CHANNEL</option>
|
||
<option value="sim-msc">MANAGE SECURE CHANNEL</option>
|
||
<option value="sim-retrieve">RETRIEVE DATA</option>
|
||
<option value="sim-set-data">SET DATA</option>
|
||
<option value="sim-suspend">SUSPEND UICC</option>
|
||
</optgroup>
|
||
<optgroup label="RAM (GlobalPlatform)">
|
||
<option value="ram-install-load">INSTALL [for load]</option>
|
||
<option value="ram-install-install">INSTALL [for install]</option>
|
||
<option value="ram-install-make-sel">INSTALL [for make selectable]</option>
|
||
<option value="ram-install-reg-update">INSTALL [for registry update]</option>
|
||
<option value="ram-install-extradition">INSTALL [for extradition]</option>
|
||
<option value="ram-load">LOAD</option>
|
||
<option value="ram-delete">DELETE</option>
|
||
<option value="ram-get-status">GET STATUS</option>
|
||
<option value="ram-get-data">GET DATA</option>
|
||
<option value="ram-store-data">STORE DATA</option>
|
||
<option value="ram-set-status">SET STATUS</option>
|
||
<option value="ram-ext-auth">EXTERNAL AUTHENTICATE</option>
|
||
<option value="ram-int-auth">INTERNAL AUTHENTICATE</option>
|
||
<option value="ram-select">SELECT</option>
|
||
<option value="ram-init-update">INITIALIZE UPDATE</option>
|
||
<option value="ram-put-key">PUT KEY</option>
|
||
<option value="ram-manage-channel">MANAGE CHANNEL</option>
|
||
</optgroup>
|
||
</select>
|
||
</div>
|
||
<div class="flex gap-3 mb-3">
|
||
<div class="w-24">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">SW</label>
|
||
<input id="resp-sw" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" maxlength="4" placeholder="9000">
|
||
</div>
|
||
<div class="flex-1">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Context</label>
|
||
<input id="resp-ctx" readonly class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800" value="generic">
|
||
</div>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Response data (hex)">Response data (hex)</label>
|
||
<textarea id="resp-data" rows="3" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="E3 1A 4F 08 A0 00 00 00 03 00 00 00 9F 70 01 07 C5 03 80 00 00"></textarea>
|
||
</div>
|
||
<button onclick="decodeResponse()" class="mb-3 px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Decode">Decode</button>
|
||
<div id="resp-output" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800 whitespace-pre-wrap" style="min-height:100px"></div>
|
||
</div>
|
||
</div>
|
||
<div class="w-1/4">
|
||
<div class="border border-gray-300 dark:border-slate-600 rounded p-3 bg-gray-50 dark:bg-slate-800">
|
||
<select id="conv-type" onchange="updateConvFields()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 mb-2">
|
||
<option value="nibswap">Nibble swap</option>
|
||
<option value="imsi">IMSI → EF.IMSI</option>
|
||
<option value="msisdn">MSISDN → BCD</option>
|
||
<option value="iccid">ICCID → hex</option>
|
||
<option value="spn">Provider Name → SPN</option>
|
||
<option value="plmn">MCC,MNC → PLMNsel</option>
|
||
<option value="plmnwact">MCC,MNC,AcT → PLMNwAcT</option>
|
||
</select>
|
||
<div class="flex flex-col gap-2 mb-2">
|
||
<input id="conv-input" class="font-mono flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
<button onclick="genConvert()" class="px-3 py-1.5 bg-blue-600 text-white text-xs font-medium rounded hover:bg-blue-700" data-l10n="Convert">Convert</button>
|
||
</div>
|
||
<div id="conv-plmn-fields" class="" style="display:none">
|
||
<div class="flex gap-2 mb-2">
|
||
<div class="flex-1">
|
||
<input id="conv-mcc" maxlength="3" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="MCC">
|
||
</div>
|
||
<div class="flex-1">
|
||
<input id="conv-mnc" maxlength="3" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="MNC">
|
||
</div>
|
||
</div>
|
||
<div id="conv-act-row" class="" style="display:none">
|
||
<select id="conv-act" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
<option value="8000">GSM (8000)</option>
|
||
<option value="4000">UTRAN (4000)</option>
|
||
<option value="2000">E-UTRAN (2000)</option>
|
||
<option value="1000">NGRAN (1000)</option>
|
||
<option value="C000">GSM + UTRAN (C000)</option>
|
||
<option value="6000">UTRAN + E-UTRAN (6000)</option>
|
||
<option value="E000">GSM + UTRAN + E-UTRAN (E000)</option>
|
||
<option value="F000">All (F000)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<textarea id="conv-result" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 mt-2 bg-white dark:bg-slate-800"></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="tab-scp80" class="tab-content hidden">
|
||
<div class="flex flex-wrap justify-center gap-2 mb-3">
|
||
<button class="scp80-subtab px-4 py-1.5 text-sm rounded-full bg-blue-600 text-white" data-scp80-sub="sp" onclick="scp80SwitchSubtab('sp')" data-l10n="Secured packet">Secured packet</button>
|
||
<button class="scp80-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-scp80-sub="cards" onclick="scp80SwitchSubtab('cards')" data-l10n="Cards">Cards</button>
|
||
<button class="scp80-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-scp80-sub="ram" onclick="scp80SwitchSubtab('ram')" data-l10n="RAM">RAM</button>
|
||
</div>
|
||
<div id="scp80-sub-sp">
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Card presets">Card presets</label>
|
||
<select id="sp-card-sel" onchange="cardsApply(this.value)" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="" data-l10n="— Select card —">— Select card —</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="flex gap-2 mb-3">
|
||
<div class="flex-1"> <label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">SPI1 (Security Parameter Indicator)</label>
|
||
<div class="flex gap-2 items-center">
|
||
<select id="sp-spi1" onchange="updateSp()" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="00">00 — No security</option>
|
||
<option value="01">01 — RC only</option>
|
||
<option value="02">02 — CC/MAC only</option>
|
||
<option value="06" selected>06 — CC/MAC + Ciphering</option>
|
||
<option value="0A">0A — CC/MAC + Counter (available)</option>
|
||
<option value="0E">0E — CC/MAC + Ciphering + Counter (available)</option>
|
||
<option value="12">12 — CC/MAC + Counter (higher)</option>
|
||
<option value="16">16 — CC/MAC + Ciphering + Counter (higher)</option>
|
||
<option value="1A">1A — CC/MAC + Counter (+1)</option>
|
||
<option value="1E">1E — CC/MAC + Ciphering + Counter (+1)</option>
|
||
</select>
|
||
<input id="sp-spi1-hex" readonly class="w-16 font-mono border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-2 bg-gray-100 dark:bg-slate-800 text-center" value="06">
|
||
</div>
|
||
</div>
|
||
<div class="flex-1"> <label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">SPI2 (PoR settings)</label>
|
||
<div class="flex gap-2 items-center">
|
||
<select id="sp-spi2" onchange="updateSp()" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="00" selected>00 — No PoR, no security</option>
|
||
<option value="01">01 — PoR required, no security</option>
|
||
<option value="05">05 — PoR required, RC</option>
|
||
<option value="09">09 — PoR required, CC</option>
|
||
<option value="0D">0D — PoR required, DS</option>
|
||
<option value="11">11 — PoR required, no security, ciphered</option>
|
||
<option value="02">02 — PoR on error, no security</option>
|
||
<option value="06">06 — PoR on error, RC</option>
|
||
</select>
|
||
<select id="sp-spi2-sm" onchange="updateSp()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="0">DELIVER-REPORT</option>
|
||
<option value="1">SUBMIT SM</option>
|
||
</select>
|
||
<input id="sp-spi2-hex" readonly class="w-16 font-mono border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-2 bg-gray-100 dark:bg-slate-800 text-center" value="00">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex gap-2 mb-3">
|
||
<div class="flex-1"> <label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">KIc (Ciphering Key Identifier)</label>
|
||
<div class="flex gap-2 items-center">
|
||
<select id="sp-kic-idx" onchange="updateSpKic()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="0">Implicit (0)</option>
|
||
<option value="1" selected>1</option>
|
||
<option value="2">2</option>
|
||
<option value="3">3</option>
|
||
<option value="4">4</option>
|
||
<option value="5">5</option>
|
||
<option value="6">6</option>
|
||
<option value="7">7</option>
|
||
<option value="8">8</option>
|
||
<option value="9">9</option>
|
||
<option value="A">A</option>
|
||
<option value="B">B</option>
|
||
<option value="C">C</option>
|
||
<option value="D">D</option>
|
||
<option value="E">E</option>
|
||
<option value="F">F</option>
|
||
</select>
|
||
<select id="sp-kic-alg" onchange="updateSpKic()" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="01">01 — DES-CBC (8B key)</option>
|
||
<option value="02">02 — AES-CBC (16/24/32B key)</option>
|
||
<option value="05" selected>05 — 3DES 2-key outer-CBC (16B key)</option>
|
||
<option value="09">09 — 3DES 3-key outer-CBC (24B key)</option>
|
||
<option value="0D">0D — DES-ECB (8B key)</option>
|
||
</select>
|
||
<input id="sp-kic-hex" readonly class="w-12 font-mono border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-2 bg-gray-100 dark:bg-slate-800 text-center" value="15">
|
||
</div>
|
||
</div>
|
||
<div class="flex-1"> <label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">KID (MAC Key Identifier)</label>
|
||
<div class="flex gap-2 items-center">
|
||
<select id="sp-kid-idx" onchange="updateSpKid()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="0">Implicit (0)</option>
|
||
<option value="1" selected>1</option>
|
||
<option value="2">2</option>
|
||
<option value="3">3</option>
|
||
<option value="4">4</option>
|
||
<option value="5">5</option>
|
||
<option value="6">6</option>
|
||
<option value="7">7</option>
|
||
<option value="8">8</option>
|
||
<option value="9">9</option>
|
||
<option value="A">A</option>
|
||
<option value="B">B</option>
|
||
<option value="C">C</option>
|
||
<option value="D">D</option>
|
||
<option value="E">E</option>
|
||
<option value="F">F</option>
|
||
</select>
|
||
<select id="sp-kid-alg" onchange="updateSpKid()" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="01">01 — DES-CBC (8B key)</option>
|
||
<option value="02">02 — AES-CMAC (16/24/32B key)</option>
|
||
<option value="05" selected>05 — 3DES 2-key outer-CBC (16B key)</option>
|
||
<option value="09">09 — 3DES 3-key outer-CBC (24B key)</option>
|
||
</select>
|
||
<input id="sp-kid-hex" readonly class="w-12 font-mono border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-2 bg-gray-100 dark:bg-slate-800 text-center" value="15">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex gap-2 mb-3">
|
||
<div class="flex-1">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">KIc Key (hex)</label>
|
||
<textarea id="sp-kic-key" rows="2" oninput="spInvalidate()" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="16 bytes key"></textarea>
|
||
</div>
|
||
<div class="flex-1">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">KID Key (hex)</label>
|
||
<textarea id="sp-kid-key" rows="2" oninput="spInvalidate()" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="16 bytes key"></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex gap-2 mb-3">
|
||
<div class="flex-1">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">TAR (3 bytes)</label>
|
||
<input id="sp-tar" oninput="spInvalidate()" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="B00001" maxlength="6" value="B00001">
|
||
</div>
|
||
<div class="flex-1">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Counter (5 bytes)</label>
|
||
<div class="flex gap-1 items-center">
|
||
<button type="button" onclick="spCntrAdjust(-1)" class="px-2.5 py-2 border border-gray-300 dark:border-slate-600 text-sm rounded hover:bg-gray-200 dark:bg-slate-700 dark:hover:bg-slate-600">−</button>
|
||
<input id="sp-cntr" oninput="spInvalidate()" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 flex-1 dark:bg-slate-800" placeholder="0000000001" maxlength="10" value="0000000001">
|
||
<button type="button" onclick="spCntrAdjust(1)" class="px-2.5 py-2 border border-gray-300 dark:border-slate-600 text-sm rounded hover:bg-gray-200 dark:bg-slate-700 dark:hover:bg-slate-600">+</button>
|
||
</div>
|
||
</div>
|
||
<div class="flex-1"> <label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">Padding byte</label>
|
||
<select id="sp-padding" onchange="spInvalidate()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="00" selected>00 (TS 102 225 default)</option>
|
||
<option value="FF">FF</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300">APDU (hex)</label>
|
||
<textarea id="sp-apdu" rows="3" oninput="spInvalidate()" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800"></textarea>
|
||
</div>
|
||
|
||
<button onclick="genSp()" class="mb-3 px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Generate Secure Packet">Generate Secure Packet</button>
|
||
<button data-needs="server" onclick="pysimVerifySp()" id="sp-verify-btn" class="hidden mb-3 px-5 py-2.5 bg-slate-600 text-white text-sm font-medium rounded hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Verify vs pySim">Verify vs pySim</button>
|
||
<button data-needs="card" onclick="pysimSendOta()" id="sp-send-btn" class="hidden mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Send to Card">Send to Card</button>
|
||
<div id="sp-por-status" class="mt-2 text-base font-semibold font-mono hidden"></div>
|
||
<textarea id="sp-result" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800"></textarea>
|
||
<div id="sp-send-result" class="mt-2 text-xs font-mono hidden"></div>
|
||
<div id="sp-verify-result" class="mt-2 text-xs font-mono hidden"></div>
|
||
|
||
</div>
|
||
<div id="scp80-sub-cards" class="hidden">
|
||
<p class="text-xs text-gray-500 dark:text-slate-400 mb-3" data-l10n="Card presets are stored locally in your browser. JSON export/import buttons are below the card list.">Card presets are stored locally in your browser. JSON export/import buttons are below the card list.</p>
|
||
<div class="flex gap-2 mb-3">
|
||
<input id="cards-name" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="Name (e.g. Foobar SIM)">
|
||
<input id="cards-iccid" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono" placeholder="ICCID (optional)">
|
||
<button onclick="cardsAdd()" class="px-4 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 whitespace-nowrap" data-l10n="Add">Add</button>
|
||
</div>
|
||
<div class="flex gap-2 mb-3 flex-wrap">
|
||
<input id="cards-kic" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="KIc" maxlength="2">
|
||
<input id="cards-kid" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="KID" maxlength="2">
|
||
<input id="cards-spi1" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="SPI1" maxlength="2">
|
||
<input id="cards-spi2" class="w-12 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono text-center" placeholder="SPI2" maxlength="2">
|
||
<input id="cards-tar" class="w-20 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono" placeholder="ISD TAR" maxlength="6">
|
||
<input id="cards-cntr" class="w-24 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono" placeholder="Counter" maxlength="10">
|
||
</div>
|
||
<div class="flex gap-2 mb-3">
|
||
<input id="cards-kic-key" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono" placeholder="KIc key (32 hex chars)">
|
||
<input id="cards-kid-key" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono" placeholder="KID key (32 hex chars)">
|
||
</div>
|
||
<div id="cards-list" class="overflow-x-auto">
|
||
<table class="w-full text-sm">
|
||
<thead><tr>
|
||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="Name">Name</th>
|
||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="ICCID">ICCID</th>
|
||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">KIc</th>
|
||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">KID</th>
|
||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">SPI1</th>
|
||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">SPI2</th>
|
||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">TAR</th>
|
||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="Cntr">Cntr</th>
|
||
<th class="border-b border-gray-200 dark:border-slate-700"></th>
|
||
</tr></thead>
|
||
<tbody id="cards-tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
<input type="file" id="cards-file" accept=".json,application/json" class="hidden" onchange="cardsImportFile(this)">
|
||
<div class="flex flex-wrap gap-2 mt-2">
|
||
<button onclick="cardsExport()" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Export as JSON">Export as JSON</button>
|
||
<button onclick="cardsExportFile()" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Export to file">Export to file</button>
|
||
<button onclick="document.getElementById('cards-file').click()" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Import from file">Import from file</button>
|
||
<button onclick="cardsImportField()" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Paste & import">Paste & import</button>
|
||
<button onclick="cardsImport()" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Import JSON from clipboard">Import JSON from clipboard</button>
|
||
</div>
|
||
<textarea id="cards-io" rows="4" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 hidden" placeholder="JSON data"></textarea>
|
||
|
||
</div>
|
||
<div id="scp80-sub-ram" class="hidden">
|
||
<p class="text-xs text-gray-500 dark:text-slate-400 mb-3" data-l10n="RAM operations perform atomic GlobalPlatform commands over SCP80. Card keys are taken from the saved preset.">RAM operations perform atomic GlobalPlatform commands over SCP80. Card keys are taken from the saved preset.</p>
|
||
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Card preset">Card preset</label>
|
||
<select id="ram-card-sel" onchange="ramApplyCard(this.value)" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="" data-l10n="— Select card —">— Select card —</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="mb-3 flex gap-2 items-end">
|
||
<div class="flex-1">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Operation">Operation</label>
|
||
<select id="ram-op" onchange="ramOpChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="explore" data-l10n="Explore Card (all GP data)">Explore Card (all GP data)</option>
|
||
<option value="install-cap" data-l10n="Install Package (.cap file)">Install Package (.cap file)</option>
|
||
</select>
|
||
</div>
|
||
<button data-needs="card" onclick="ramExecute()" class="px-5 py-1.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Execute">Execute</button>
|
||
</div>
|
||
|
||
<div id="ram-install-params" class="hidden mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="CAP file (max 48 kB)">CAP file (max 48 kB)</label>
|
||
<input type="file" id="ram-cap-file" accept=".cap,.zip" class="w-full text-sm text-gray-600 dark:text-slate-300">
|
||
<div id="ram-cap-info" class="text-xs text-gray-500 mt-1 hidden"></div>
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300 mt-2" data-l10n="SD AID (empty = ISD)">SD AID (empty = ISD)</label>
|
||
<input id="ram-sd-aid" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="A000000003000000" maxlength="32">
|
||
<div id="rc-priv-row" class="mb-3 mt-2">
|
||
<div class="flex items-center gap-3 mb-2">
|
||
<label class="text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Privileges">Privileges</label>
|
||
<input id="rc-priv" readonly class="font-mono w-20 border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 bg-gray-100 dark:bg-slate-800" value="00">
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||
<div class="font-medium text-gray-700 dark:text-slate-300 mb-1" data-l10n="Byte 1">Byte 1</div>
|
||
<div class="font-medium text-gray-700 dark:text-slate-300 mb-1" data-l10n="Byte 2">Byte 2</div>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="80" onchange="updateRcPriv()"> Security Domain</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b2" value="80" onchange="updateRcPriv()"> Trusted Path</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="40" onchange="updateRcPriv()"> DAP Verification</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b2" value="40" onchange="updateRcPriv()"> Authorized Management</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="20" onchange="updateRcPriv()"> Delegated Management</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b2" value="20" onchange="updateRcPriv()"> Token Verification</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="10" onchange="updateRcPriv()"> Card Lock</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b2" value="10" onchange="updateRcPriv()"> Global Delete</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="08" onchange="updateRcPriv()"> Card Terminate</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b2" value="08" onchange="updateRcPriv()"> Global Lock</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="04" onchange="updateRcPriv()"> Card Reset</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b2" value="04" onchange="updateRcPriv()"> Global Registry</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="02" onchange="updateRcPriv()"> CVM Management</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b1" value="01" onchange="updateRcPriv()"> Mandated DAP Verification</label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b2" value="02" onchange="updateRcPriv()"> Final Application</label>
|
||
<div></div>
|
||
<label class="flex items-center gap-1"><input type="checkbox" class="rc-priv-b3" value="80" onchange="updateRcPriv()"> Receipt Generation</label>
|
||
</div>
|
||
</div>
|
||
<div id="rc-toolkit-row" class="mb-3">
|
||
<div class="mb-2">
|
||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300">
|
||
<input id="rc-toolkit-enable" type="checkbox" class="rounded border-gray-300 dark:border-slate-600 dark:bg-slate-800" onchange="updateRcToolkit()">
|
||
SIM/UICC Toolkit parameters
|
||
</label>
|
||
</div>
|
||
<div id="rc-toolkit-body" class="hidden">
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Toolkit mode">Toolkit mode</label>
|
||
<select id="rc-tk-mode" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
<option value="ca">SIM Toolkit (Tag CA)</option>
|
||
<option value="ea">UICC Toolkit (Tag EA)</option>
|
||
</select>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-x-3 gap-y-2 text-sm">
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Priority">Priority</label>
|
||
<input id="rc-tk-priority" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Timers (max)">Timers (max)</label>
|
||
<input id="rc-tk-timers" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Menu text length">Menu text length</label>
|
||
<input id="rc-tk-textlen" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Menu items">Menu items</label>
|
||
<input id="rc-tk-menus" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="First menu position">First menu position</label>
|
||
<input id="rc-tk-firstpos" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="First menu ID (hex)">First menu ID (hex)</label>
|
||
<input id="rc-tk-firstid" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800" placeholder="00" maxlength="2" value="00">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Last menu position">Last menu position</label>
|
||
<input id="rc-tk-lastpos" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Last menu ID (hex)">Last menu ID (hex)</label>
|
||
<input id="rc-tk-lastid" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800" placeholder="00" maxlength="2" value="00">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Channels (max)">Channels (max)</label>
|
||
<input id="rc-tk-channels" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300">MSL (SPI1, hex)</label>
|
||
<select id="rc-tk-msl" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
|
||
<option value="00">00 — no verification</option>
|
||
<option value="11">11 — RC/CC/DS</option>
|
||
<option value="12">12 — RC/DS/CC</option>
|
||
<option value="15">15 — RC/DS/CC + MAC</option>
|
||
<option value="16" selected>16 — RC/DS/CC + MAC + Cipher</option>
|
||
<option value="19">19 — RC/DS/CC + MAC + Cipher + DS</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300">TAR (3 bytes)</label>
|
||
<input id="rc-tk-tar" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800" placeholder="B00001" maxlength="6" value="B00001">
|
||
</div>
|
||
<div id="rc-tk-ad-row">
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Access domain (hex)">Access domain (hex)</label>
|
||
<input id="rc-tk-ad" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800" placeholder="00" maxlength="2" value="00">
|
||
</div>
|
||
<div id="rc-tk-services-row">
|
||
<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300" data-l10n="Max services">Max services</label>
|
||
<input id="rc-tk-services" type="number" min="0" max="255" value="0" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Install parameters (hex, optional)">Install parameters (hex, optional)</label>
|
||
<input id="ram-install-params-hex" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="C9 TLV">
|
||
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400 mt-2" data-l10n="STK parameters (hex, optional)">STK parameters (hex, optional)</label>
|
||
<input id="ram-stk-params" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="CA TLV">
|
||
<label class="flex items-center gap-2 mt-2">
|
||
<input type="checkbox" id="ram-make-sel" checked> <span class="text-sm" data-l10n="Make selectable">Make selectable</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div id="ram-progress" class="hidden mb-3">
|
||
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-slate-400">
|
||
<div class="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
|
||
<span id="ram-progress-text" data-l10n="Working...">Working...</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="ram-result" class="mt-2 text-base font-semibold font-mono hidden"></div>
|
||
<div id="ram-explorer" class="mt-2 text-xs font-mono hidden"></div>
|
||
<div id="ram-steps" class="mt-2 text-xs font-mono whitespace-pre-wrap hidden"></div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<div id="tab-pysim" class="tab-content hidden">
|
||
<div id="pysim-connect-row" class="mb-3">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Server URL">Server URL</label>
|
||
<div class="flex gap-2 items-center">
|
||
<input id="pysim-url" class="font-mono flex-1 max-w-md border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" value="http://127.0.0.1:8080">
|
||
<button onclick="pysimConnect()" id="pysim-connect-btn" class="px-4 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700" data-l10n="Connect">Connect</button>
|
||
</div>
|
||
</div>
|
||
<div id="pysim-connect-info" class="mb-3 text-xs text-gray-500 dark:text-slate-400 leading-relaxed">
|
||
<p data-l10n="To access local card reader, you need pysim-otaman-server running locally.">To access local card reader, you need pysim-otaman-server running locally.</p>
|
||
<p><span data-l10n="To connect, install">To connect, install</span> <a href="https://github.com/anttro/otaman" target="_blank" class="text-blue-600 dark:text-blue-400 underline">pysim-otaman-server</a>: <code class="font-mono bg-gray-100 dark:bg-slate-700 px-1 rounded">git clone https://github.com/anttro/otaman.git</code> <span data-l10n="and start it.">and start it.</span><br><span data-l10n="Refer to pysim-otaman-server README files for details.">Refer to pysim-otaman-server README files for details.</span> <span data-l10n="Click">Click</span> <b data-l10n="Connect">Connect</b> <span data-l10n="when pysim-otaman-server is ready">when pysim-otaman-server is ready</span></p>
|
||
<div id="pysim-connect-msg" class="text-xs"></div>
|
||
</div>
|
||
<div id="pysim-connected-row" class="mb-3 hidden">
|
||
<div class="flex gap-3 items-start">
|
||
<img id="pysim-status-img" src="nosim.svg" alt="pysim" class="w-16 h-16 shrink-0 dark:invert">
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||
<button data-needs="server" onclick="pysimEquipCmd()" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Equip card">Equip card</button>
|
||
<button data-needs="card" onclick="pysimStatusCmd()" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Check status">Check status</button>
|
||
<button data-needs="card" onclick="pysimResetCmd()" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Reset card">Reset card</button>
|
||
<button onclick="pysimDisconnect()" id="pysim-disconnect-btn" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Disconnect from server">Disconnect from server</button>
|
||
</div>
|
||
<div id="pysim-status" class="text-xs font-mono text-gray-600 dark:text-slate-400"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="pysim-body" class="hidden">
|
||
<div class="flex flex-wrap justify-center gap-1 mb-2">
|
||
<button class="pysim-subtab px-3 py-1 text-sm rounded-full bg-blue-600 text-white" data-pysim-sub="files" data-l10n="File manager">File manager</button>
|
||
<button class="pysim-subtab px-3 py-1 text-sm 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-pysim-sub="cmd" data-l10n="pySim command line">pySim command line</button>
|
||
<button class="pysim-subtab px-3 py-1 text-sm 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-pysim-sub="apdu" data-l10n="Raw APDU">Raw APDU</button>
|
||
</div>
|
||
|
||
<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 id="pysim-fs-tree"></div>
|
||
</div>
|
||
<div class="flex-1">
|
||
<div id="pysim-fs-detail" class="hidden">
|
||
<div class="text-xs font-mono mb-1"><span data-l10n="File:">File:</span> <b id="pysim-fs-filename"></b></div>
|
||
<div id="pysim-fs-content" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1.5 bg-gray-100 dark:bg-slate-800 min-h-20 overflow-y-auto"></div>
|
||
<div class="flex gap-2 mt-1 items-center">
|
||
<button data-needs="card" onclick="pysimFsRead()" id="pysim-fs-read-btn" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Read">Read</button>
|
||
<button data-needs="card" onclick="pysimFsSave()" id="pysim-fs-save-btn" class="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 hidden disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Save">Save</button>
|
||
<button onclick="pysimFsCancel()" id="pysim-fs-cancel-btn" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700 hidden" data-l10n="Cancel">Cancel</button>
|
||
<span class="text-xs text-gray-400 dark:text-slate-500">|</span>
|
||
<span class="pysim-mode-pill px-2 py-0.5 text-xs rounded cursor-pointer bg-blue-600 text-white" data-mode="raw" onclick="pysimFsSetMode('raw')" data-l10n="Raw">Raw</span>
|
||
<span class="pysim-mode-pill px-2 py-0.5 text-xs rounded cursor-pointer bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300" data-mode="dec" onclick="pysimFsSetMode('dec')" data-l10n="Decoded">Decoded</span>
|
||
<button data-needs="card" onclick="pysimFsEdit()" id="pysim-fs-edit-btn" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 ml-auto disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Edit">Edit</button>
|
||
</div>
|
||
<div id="pysim-fs-status" class="mt-1 text-xs font-mono text-gray-500 dark:text-slate-400" style="min-height:1.2em"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
|
||
<div id="pysim-sub-cmd" class="hidden">
|
||
<div class="flex gap-2 items-center mb-2">
|
||
<div class="relative flex-1">
|
||
<input id="pysim-cmd" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" placeholder="select MF" autocomplete="off" onkeydown="pysimCmdKeydown(event)" oninput="pysimCmdInput()">
|
||
<div id="pysim-cmd-suggest" class="hidden absolute bottom-full left-0 right-0 mb-1 max-h-32 overflow-y-auto border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-800 text-xs font-mono shadow-lg z-10"></div>
|
||
</div>
|
||
<button data-needs="server" onclick="pysimExec()" class="px-4 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Execute">Execute</button>
|
||
</div>
|
||
<div id="pysim-cmd-hint" class="hidden mb-2 text-xs font-mono text-gray-500 dark:text-slate-400 border border-gray-200 dark:border-slate-700 rounded p-2 bg-gray-50 dark:bg-slate-800 whitespace-pre-wrap"></div>
|
||
<textarea id="pysim-cmd-output" rows="10" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 bg-gray-100 dark:bg-slate-800"></textarea>
|
||
</div>
|
||
|
||
<div id="pysim-sub-apdu" class="hidden">
|
||
<div class="flex gap-2 items-center mb-2">
|
||
<input id="pysim-apdu" class="font-mono flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" placeholder="00A4040000" onkeydown="if(event.key==='Enter')pysimApdu()">
|
||
<button data-needs="card" onclick="pysimApdu()" class="px-4 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Send">Send</button>
|
||
</div>
|
||
<textarea id="pysim-apdu-output" rows="5" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 bg-gray-100 dark:bg-slate-800"></textarea>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
|
||
</div>
|
||
|
||
<div id="tab-profiler" class="tab-content hidden">
|
||
<div id="profiler-list">
|
||
<div class="flex flex-wrap justify-center gap-2 mb-3">
|
||
<button class="profiler-list-tab px-4 py-1.5 text-sm rounded-full bg-blue-600 dark:bg-blue-500 text-white dark:text-white" data-list-tab="profiles" onclick="profilerListSwitch('profiles')" data-l10n="Profiles">Profiles</button>
|
||
<button class="profiler-list-tab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-list-tab="snapshots" onclick="profilerListSwitch('snapshots')" data-l10n="Card snapshots">Card snapshots</button>
|
||
<button class="profiler-list-tab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300" data-list-tab="custom" onclick="profilerListSwitch('custom')" data-l10n="Custom files">Custom files</button>
|
||
</div>
|
||
<div id="profiler-list-profiles">
|
||
<div class="flex flex-wrap gap-2 mb-3">
|
||
<button onclick="profilerNew()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="New profile">New profile</button>
|
||
<button data-needs="card" onclick="profilerFromCard()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Profile from card">Profile from card</button>
|
||
<button onclick="document.getElementById('profiler-import-file').click()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Import profile">Import profile</button>
|
||
<input type="file" id="profiler-import-file" accept=".json,application/json" class="hidden" onchange="profilerImportFile(this)">
|
||
</div>
|
||
<div id="profiler-list-body"></div>
|
||
</div>
|
||
<div id="profiler-list-snapshots" class="hidden">
|
||
<div class="flex flex-wrap gap-2 mb-3">
|
||
<button data-needs="card" onclick="snapshotNew()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="New snapshot">New snapshot</button>
|
||
<button onclick="document.getElementById('snapshot-import-file').click()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Import snapshot">Import snapshot</button>
|
||
<button onclick="snapshotCompareOpen()" class="px-2.5 py-1 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700" data-l10n="Compare snapshots">Compare snapshots</button>
|
||
<input type="file" id="snapshot-import-file" accept=".json,application/json" class="hidden" onchange="snapshotImportFile(this)">
|
||
</div>
|
||
<div id="snapshot-list-body"></div>
|
||
</div>
|
||
|
||
<div id="profiler-list-custom" class="hidden">
|
||
<div class="mb-2 text-xs font-mono text-gray-600 dark:text-slate-400" data-l10n="Add known file paths and aliases to the file tree.">Add known file paths and aliases to the file tree.</div>
|
||
<div class="flex gap-2 items-center mb-2">
|
||
<input id="pysim-cf-path" class="font-mono flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" placeholder="3F00/6F46" onkeydown="if(event.key==='Enter')pysimCustomAdd()">
|
||
<input id="pysim-cf-name" class="font-mono w-48 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" placeholder="EF.SPN" onkeydown="if(event.key==='Enter')pysimCustomAdd()">
|
||
<button onclick="pysimCustomAdd()" class="px-3 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 whitespace-nowrap" data-l10n="Add">Add</button>
|
||
</div>
|
||
<div id="pysim-cf-list" class="text-xs font-mono text-gray-600 dark:text-slate-400"></div>
|
||
<input type="file" id="pysim-cf-file" accept=".json,application/json" class="hidden" onchange="pysimCustomImportFile(this)">
|
||
<div class="flex flex-wrap gap-2 mt-2">
|
||
<button onclick="pysimCustomExport()" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Export as JSON">Export as JSON</button>
|
||
<button onclick="pysimCustomExportFile()" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Export to file">Export to file</button>
|
||
<button onclick="document.getElementById('pysim-cf-file').click()" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Import from file">Import from file</button>
|
||
<button onclick="pysimCustomImportField()" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Paste & import">Paste & import</button>
|
||
<button onclick="pysimCustomImport()" class="px-2 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700" data-l10n="Import JSON from clipboard">Import JSON from clipboard</button>
|
||
</div>
|
||
<textarea id="pysim-cf-io" class="hidden mt-2 w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1.5 bg-gray-100 dark:bg-slate-800" rows="6"></textarea>
|
||
</div>
|
||
</div>
|
||
<div id="profiler-editor" class="hidden">
|
||
<div class="flex flex-wrap gap-2 items-center mb-3">
|
||
<button onclick="profilerBackToList()" class="px-2.5 py-1 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Back to list">Back to list</button>
|
||
<input id="profiler-name" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="Profile name">
|
||
<button onclick="profilerAddRule()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Add rule">Add rule</button>
|
||
<button onclick="profilerSaveProfile()" class="px-2.5 py-1 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700" data-l10n="Save">Save</button>
|
||
</div>
|
||
<div id="profiler-rules"></div>
|
||
</div>
|
||
<div id="profiler-results" class="hidden">
|
||
<div class="flex flex-wrap gap-2 items-center mb-3">
|
||
<button onclick="profilerBackToList()" class="px-2.5 py-1 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Back to list">Back to list</button>
|
||
<span id="profiler-results-title" class="text-sm font-semibold"></span>
|
||
<label class="ml-auto flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400 cursor-pointer">
|
||
<input id="profiler-mismatch-only" type="checkbox" onchange="profilerToggleMismatchOnly(this.checked)">
|
||
<span data-l10n="Only mismatches">Only mismatches</span>
|
||
</label>
|
||
</div>
|
||
<div id="profiler-progress" class="mb-2 text-base font-mono text-gray-600 dark:text-slate-400"></div>
|
||
<div id="profiler-summary" class="mb-2 text-sm"></div>
|
||
<div id="profiler-report"></div>
|
||
</div>
|
||
<div id="profiler-snapshot" class="hidden">
|
||
<div class="flex flex-wrap gap-2 items-center mb-3">
|
||
<button onclick="profilerBackToList()" class="px-2.5 py-1 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Back to list">Back to list</button>
|
||
<input id="snapshot-name" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="Snapshot name">
|
||
<span id="snapshot-iccid" class="text-xs font-mono text-gray-500 dark:text-slate-400"></span>
|
||
<button onclick="snapshotSaveName()" class="px-2.5 py-1 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700" data-l10n="Save">Save</button>
|
||
</div>
|
||
<div id="snapshot-summary" class="mb-3"></div>
|
||
<div id="snapshot-files"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="tab-phone" class="tab-content hidden">
|
||
<div class="flex flex-wrap justify-center gap-2 mb-3">
|
||
<button class="phone-subtab px-4 py-1.5 text-sm rounded-full bg-blue-600 text-white" data-phone-sub="phone" onclick="phoneSwitchSubtab('phone')" data-l10n="Phone">Phone</button>
|
||
<button class="phone-subtab px-4 py-1.5 text-sm 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-phone-sub="tr" onclick="phoneSwitchSubtab('tr')" data-l10n="TR Config">TR Config</button>
|
||
</div>
|
||
<div id="phone-sub-phone">
|
||
<div class="flex flex-wrap items-start gap-4 mb-4">
|
||
<div class="border border-gray-200 dark:border-slate-700 rounded p-3">
|
||
<div class="mb-1 text-sm text-gray-500 dark:text-slate-400" data-l10n="STK menu">STK menu</div>
|
||
<div class="flex items-center gap-2">
|
||
<button id="stk-menu-btn" data-needs="card" onclick="stkMenuOpen()" class="px-2.5 py-1 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" style="display:none">STK: <span id="stk-menu-title"></span></button>
|
||
<span id="stk-menu-none" class="text-xs text-gray-500 dark:text-slate-400" style="display:none" data-l10n="No menu set by the card">No menu set by the card</span>
|
||
</div>
|
||
</div>
|
||
<div class="border border-gray-200 dark:border-slate-700 rounded p-3">
|
||
<div class="mb-1 text-sm text-gray-500 dark:text-slate-400" data-l10n="STATUS and Polling">STATUS and Polling</div>
|
||
<div class="flex items-center gap-2">
|
||
<button id="pli-status-btn" data-needs="card" onclick="pysimStatusPoll()" class="px-2.5 py-1 text-sm rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Send STATUS">Send STATUS</button>
|
||
<span class="text-xs text-gray-500 dark:text-slate-400">|</span>
|
||
<span class="text-xs text-gray-500 dark:text-slate-400" data-l10n="Polling">Polling</span>
|
||
<button id="pli-pause-btn" data-needs="server" onclick="pysimPollToggle()" class="px-2.5 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 disabled:opacity-40 disabled:cursor-not-allowed">OFF</button>
|
||
<span id="pli-poll-stat" class="text-xs text-gray-400"></span>
|
||
</div>
|
||
</div>
|
||
<div class="flex-1 min-w-0 border border-gray-200 dark:border-slate-700 rounded p-3">
|
||
<div class="mb-1 text-sm text-gray-500 dark:text-slate-400" data-l10n="Events that the card monitors (SET UP EVENT LIST):">Events that the card monitors (SET UP EVENT LIST):</div>
|
||
<div id="pysim-event-list" class="text-sm font-mono text-gray-600 dark:text-slate-400"></div>
|
||
</div>
|
||
</div>
|
||
<div class="mb-2 text-sm text-gray-500 dark:text-slate-400" data-l10n="Fetched proactive commands:">Fetched proactive commands:</div>
|
||
<div id="pysim-proactive-log" class="text-sm font-mono text-gray-600 dark:text-slate-400"></div>
|
||
</div>
|
||
<div id="phone-sub-tr" class="hidden">
|
||
<div class="flex justify-between items-center mb-2">
|
||
<span class="text-sm text-gray-500 dark:text-slate-400" data-l10n="PROVIDE LOCAL INFORMATION response data">PROVIDE LOCAL INFORMATION response data</span>
|
||
<button id="pli-save-btn" data-needs="server" onclick="pysimPliSave()" class="px-2.5 py-1 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Save">Save</button>
|
||
</div>
|
||
<div id="pysim-pli-dict" class="text-sm font-mono text-gray-600 dark:text-slate-400 max-h-[60vh] overflow-auto">
|
||
<span class="text-gray-400" data-l10n="Loading...">Loading...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="event-send-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
|
||
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 max-w-sm w-full mx-4 max-h-[85vh] overflow-auto">
|
||
<h3 id="event-send-title" class="text-lg font-semibold mb-4 text-gray-800 dark:text-slate-200"></h3>
|
||
<div id="event-send-body" class="space-y-3"></div>
|
||
<div id="event-send-error" class="text-xs text-red-500 hidden mt-2"></div>
|
||
<div id="event-send-result" class="text-xs text-emerald-600 dark:text-emerald-400 hidden mt-2"></div>
|
||
<div class="flex gap-2 mt-4 justify-end">
|
||
<button onclick="document.getElementById('event-send-modal').classList.add('hidden')" class="px-3 py-1.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Cancel">Cancel</button>
|
||
<button id="event-send-btn" onclick="pysimEventSend()" class="px-4 py-1.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Send">Send</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="profiler-scan-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
|
||
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 max-w-sm w-full mx-4 max-h-[85vh] overflow-auto">
|
||
<h3 id="profiler-scan-title" class="text-lg font-semibold mb-4 text-gray-800 dark:text-slate-200" data-l10n="Profile from card">Profile from card</h3>
|
||
<label id="profiler-scan-name-label" class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Profile name">Profile name</label>
|
||
<input id="profiler-scan-name" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2 dark:bg-slate-800 mb-3" placeholder="Profile name" onkeydown="profilerScanNameKeydown(event)">
|
||
<div id="profiler-scan-options">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<span class="text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Ignore contents of files:">Ignore contents of files:</span>
|
||
<label class="ml-auto flex items-center gap-1 text-xs text-gray-500 dark:text-slate-400 cursor-pointer">
|
||
<input type="checkbox" id="profiler-scan-ignore-all" onchange="profilerScanToggleAll(this.checked)" class="rounded">
|
||
<span data-l10n="All">All</span>
|
||
</label>
|
||
</div>
|
||
<div id="profiler-scan-ignore" class="grid grid-cols-2 gap-x-3 gap-y-1 mb-3 max-h-56 overflow-auto"></div>
|
||
<div id="profiler-scan-mask" class="space-y-1 mb-3"></div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="FCP/FCI check">FCP/FCI check</label>
|
||
<select id="profiler-scan-fci-mode" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 mb-3">
|
||
<option value="type" data-l10n="Filetype only (FCP)">Filetype only (FCP)</option>
|
||
<option value="type_size" selected data-l10n="Filetype + size (FCP)">Filetype + size (FCP)</option>
|
||
<option value="exact" data-l10n="Exact FCI">Exact FCI</option>
|
||
</select>
|
||
</div>
|
||
<div id="profiler-scan-progress" class="text-xs text-gray-500 dark:text-slate-400 mb-2 hidden"></div>
|
||
<div id="profiler-scan-error" class="text-xs text-red-500 hidden mb-2"></div>
|
||
<div class="flex gap-2 justify-end">
|
||
<button id="profiler-scan-cancel-btn" onclick="profilerScanCancel()" class="px-3 py-1.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Cancel">Cancel</button>
|
||
<button id="profiler-scan-btn" onclick="profilerScanStart()" class="px-4 py-1.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Scan">Scan</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="snapshot-pick-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
|
||
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 max-w-sm w-full mx-4 max-h-[85vh] overflow-auto">
|
||
<h3 class="text-lg font-semibold mb-4 text-gray-800 dark:text-slate-200" data-l10n="Select card snapshot">Select card snapshot</h3>
|
||
<div id="snapshot-pick-list" class="space-y-1 mb-4"></div>
|
||
<div class="flex gap-2 justify-end">
|
||
<button onclick="document.getElementById('snapshot-pick-modal').classList.add('hidden')" class="px-3 py-1.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Cancel">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="snapshot-compare-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
|
||
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 max-w-sm w-full mx-4 max-h-[85vh] overflow-auto">
|
||
<h3 class="text-lg font-semibold mb-4 text-gray-800 dark:text-slate-200" data-l10n="Compare snapshots">Compare snapshots</h3>
|
||
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Master snapshot">Master snapshot</label>
|
||
<select id="snapshot-compare-master" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 mb-3"></select>
|
||
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Snapshot to check">Snapshot to check</label>
|
||
<select id="snapshot-compare-check" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 mb-3"></select>
|
||
<div id="snapshot-compare-mask" class="space-y-1 mb-3"></div>
|
||
<div class="flex gap-2 justify-end">
|
||
<button onclick="document.getElementById('snapshot-compare-modal').classList.add('hidden')" class="px-3 py-1.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Cancel">Cancel</button>
|
||
<button onclick="snapshotCompareRun()" class="px-4 py-1.5 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700" data-l10n="Compare">Compare</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="stk-menu-panel" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
|
||
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 max-w-xs w-full max-h-[80vh] flex flex-col">
|
||
<div class="flex justify-between items-center mb-3">
|
||
<button id="stk-back-btn" onclick="stkMenuRespond('back')" 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" style="display:none" data-l10n="← Back">← Back</button>
|
||
<span id="stk-menu-panel-title" class="font-bold text-sm flex-1 text-center"></span>
|
||
<button onclick="stkMenuExit()" class="px-3 py-1 text-xs rounded-full bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-900/50" data-l10n="Exit">Exit</button>
|
||
</div>
|
||
<div id="stk-menu-panel-content" class="flex-1 overflow-auto"></div>
|
||
<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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ===== Tab switching =====
|
||
function switchTab(name) {
|
||
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||
document.getElementById('tab-' + name).classList.remove('hidden');
|
||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||
btn.classList.remove('bg-blue-600', 'text-white', 'dark:bg-blue-500', 'dark:text-white');
|
||
btn.classList.add('bg-gray-200', 'text-gray-700', 'dark:bg-slate-700', 'dark:text-slate-300');
|
||
});
|
||
const active = document.querySelector(`.tab-btn[data-tab="${name}"]`);
|
||
active.classList.remove('bg-gray-200', 'text-gray-700', 'dark:bg-slate-700', 'dark:text-slate-300');
|
||
active.classList.add('bg-blue-600', 'text-white', 'dark:bg-blue-500', 'dark:text-white');
|
||
if (name === 'c-apdu') cApduSwitchSubtab('sim');
|
||
else if (name === 'scp80') setHelpAnchor(scp80HelpAnchor);
|
||
else if (name === 'pysim') setHelpAnchor(pysimHelpAnchor);
|
||
else if (name === 'profiler') { profilerSetView('list'); setHelpAnchor('profiler'); }
|
||
else if (name === 'phone') phoneSwitchSubtab('phone');
|
||
}
|
||
|
||
let scp80HelpAnchor = 'secured-packet';
|
||
function scp80SwitchSubtab(name) {
|
||
document.querySelectorAll('.scp80-subtab').forEach(btn => {
|
||
const isActive = btn.dataset.scp80Sub === name;
|
||
btn.classList.toggle('bg-blue-600', isActive);
|
||
btn.classList.toggle('text-white', isActive);
|
||
btn.classList.toggle('bg-gray-200', !isActive);
|
||
btn.classList.toggle('dark:bg-slate-700', !isActive);
|
||
btn.classList.toggle('text-gray-700', !isActive);
|
||
btn.classList.toggle('dark:text-slate-300', !isActive);
|
||
});
|
||
document.getElementById('scp80-sub-sp').classList.toggle('hidden', name !== 'sp');
|
||
document.getElementById('scp80-sub-cards').classList.toggle('hidden', name !== 'cards');
|
||
document.getElementById('scp80-sub-ram').classList.toggle('hidden', name !== 'ram');
|
||
if (name === 'cards') cardsRender();
|
||
if (name === 'ram') ramRender();
|
||
scp80HelpAnchor = { sp: 'secured-packet', cards: 'cards', ram: 'ram' }[name] || 'secured-packet';
|
||
setHelpAnchor(scp80HelpAnchor);
|
||
}
|
||
|
||
function cApduSwitchSubtab(name) {
|
||
document.querySelectorAll('.c-apdu-subtab').forEach(btn => {
|
||
const active = btn.dataset.sub === name;
|
||
btn.classList.toggle('bg-blue-600', active);
|
||
btn.classList.toggle('text-white', active);
|
||
btn.classList.toggle('bg-gray-200', !active);
|
||
btn.classList.toggle('dark:bg-slate-700', !active);
|
||
btn.classList.toggle('text-gray-700', !active);
|
||
btn.classList.toggle('dark:text-slate-300', !active);
|
||
});
|
||
['sim', 'usim', 'ber', 'ram', 'parse', 'httpota', 'response'].forEach(s => {
|
||
const el = document.getElementById('c-apdu-sub-' + s);
|
||
if (el) el.classList.toggle('hidden', s !== name);
|
||
});
|
||
setHelpAnchor({sim:'sim-rfm', usim:'usim-rfm', ber:'ber-tlv', ram:'ram-gp', parse:'c-apdu-parser', httpota:'http-ota', response:'response-parser'}[name] || 'c-apdu');
|
||
}
|
||
|
||
function phoneSwitchSubtab(name) {
|
||
document.querySelectorAll('.phone-subtab').forEach(btn => {
|
||
const active = btn.dataset.phoneSub === name;
|
||
btn.classList.toggle('bg-blue-600', active);
|
||
btn.classList.toggle('text-white', active);
|
||
btn.classList.toggle('bg-gray-200', !active);
|
||
btn.classList.toggle('dark:bg-slate-700', !active);
|
||
btn.classList.toggle('text-gray-700', !active);
|
||
btn.classList.toggle('dark:text-slate-300', !active);
|
||
});
|
||
['phone', 'tr'].forEach(s => {
|
||
document.getElementById('phone-sub-' + s).classList.toggle('hidden', s !== name);
|
||
});
|
||
if (name === 'tr') {
|
||
pysimPliRender();
|
||
} else {
|
||
stkCheckMenu();
|
||
pysimEventsRender();
|
||
pysimProactiveLogRender();
|
||
pysimPollStatusInit();
|
||
}
|
||
setHelpAnchor({ phone: 'stk-menu', tr: 'pli-dict' }[name] || 'stk-menu');
|
||
}
|
||
|
||
// ===== HTTP OTA constructor =====
|
||
let _hotaMode = 'trigger';
|
||
function _hotaHex(s) { return (s || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); }
|
||
function _hotaAsciiHex(s) { return (s || '').split('').map(c => c.charCodeAt(0).toString(16).padStart(2, '0')).join('').toUpperCase(); }
|
||
function _hotaBerLen(n) {
|
||
if (n < 128) return n.toString(16).padStart(2, '0').toUpperCase();
|
||
if (n < 256) return '81' + n.toString(16).padStart(2, '0').toUpperCase();
|
||
return '82' + n.toString(16).padStart(4, '0').toUpperCase();
|
||
}
|
||
function _hotaTlv(tag8, val8) { const v = _hotaHex(val8); return tag8.toUpperCase() + _hotaBerLen(v.length / 2) + v; }
|
||
|
||
function hotaTlv(tag, value) { return _hotaTlv(tag, value); }
|
||
function hotaBuildConn(tlvs) {
|
||
let content = '';
|
||
for (const t of tlvs) {
|
||
const v = _hotaHex(t.value);
|
||
if (!t.tag) continue;
|
||
content += t.tag.toUpperCase() + _hotaBerLen(v.length / 2) + v;
|
||
}
|
||
return '84' + _hotaBerLen(content.length / 2) + content;
|
||
}
|
||
function hotaBuildSec(s) {
|
||
const psk = _hotaAsciiHex(s.pskIdentity);
|
||
const kvn = _hotaHex(s.kvn).padStart(2, '0');
|
||
const kid = _hotaHex(s.kid).padStart(2, '0');
|
||
const data = (psk.length / 2).toString(16).padStart(2, '0').toUpperCase() + psk + '02' + kvn + kid;
|
||
return '85' + _hotaBerLen(data.length / 2) + data;
|
||
}
|
||
function hotaBuildRetry(r) {
|
||
const counter = _hotaHex(r.counter);
|
||
const hh = (parseInt(r.delayH) || 0).toString(16).padStart(2, '0').toUpperCase();
|
||
const mm = (parseInt(r.delayM) || 0).toString(16).padStart(2, '0').toUpperCase();
|
||
const ss = (parseInt(r.delayS) || 0).toString(16).padStart(2, '0').toUpperCase();
|
||
const timerTlv = '2503' + hh + mm + ss;
|
||
const report = _hotaHex(r.reportFailure);
|
||
const content = counter + timerTlv + (report && report.length % 2 === 0 ? report : '');
|
||
return '86' + _hotaBerLen(content.length / 2) + content;
|
||
}
|
||
function hotaBuildHttpPost(h) {
|
||
const host = _hotaAsciiHex(h.host);
|
||
const agent = _hotaAsciiHex(h.agent);
|
||
const uri = _hotaAsciiHex(h.uri);
|
||
const content = _hotaTlv('8A', host) + _hotaTlv('8B', agent) + _hotaTlv('8C', uri);
|
||
return '89' + _hotaBerLen(content.length / 2) + content;
|
||
}
|
||
function hotaBuildTrigger(conn, sec, retry, httpPost, expanded) {
|
||
let content = conn;
|
||
if (sec) content += sec;
|
||
if (retry) content += retry;
|
||
if (httpPost) content += httpPost;
|
||
const sd = '83' + _hotaBerLen(content.length / 2) + content;
|
||
const trig = '81' + _hotaBerLen(sd.length / 2) + sd;
|
||
if (expanded) return 'AA' + _hotaBerLen(trig.length / 2) + trig;
|
||
return trig;
|
||
}
|
||
function hotaBuildStore(conn, sec, retry, httpPost, storeTag) {
|
||
const inner = _hotaTlv(storeTag, conn + (sec || '') + (retry || '') + (httpPost || ''));
|
||
return '80E29000' + _hotaBerLen(inner.length / 2) + inner;
|
||
}
|
||
function hotaBuild(mode, conn, sec, retry, httpPost, storeTag, expanded) {
|
||
return mode === 'store'
|
||
? hotaBuildStore(conn, sec, retry, httpPost, storeTag)
|
||
: hotaBuildTrigger(conn, sec, retry, httpPost, expanded);
|
||
}
|
||
|
||
function hotaSetMode(mode) {
|
||
_hotaMode = mode;
|
||
document.getElementById('hota-mode-trigger').className = 'px-4 py-1.5 text-sm rounded-full ' + (mode === 'trigger' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600');
|
||
document.getElementById('hota-mode-store').className = 'px-4 py-1.5 text-sm rounded-full ' + (mode === 'store' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600');
|
||
document.getElementById('hota-store-tag-row').classList.toggle('hidden', mode !== 'store');
|
||
document.getElementById('hota-expanded-row').classList.toggle('hidden', mode !== 'trigger');
|
||
hotaUpdatePreview();
|
||
}
|
||
|
||
function hotaToggleConn() {
|
||
const on = document.getElementById('hota-include-conn').checked;
|
||
document.getElementById('hota-conn-tlvs').classList.toggle('hidden', !on);
|
||
hotaUpdatePreview();
|
||
}
|
||
document.addEventListener('change', function(e) {
|
||
if (e.target && e.target.id === 'hota-include-conn') hotaToggleConn();
|
||
else if (e.target && e.target.id === 'hota-include-sec') document.getElementById('hota-sec-fields').classList.toggle('hidden', !e.target.checked);
|
||
else if (e.target && e.target.id === 'hota-include-retry') document.getElementById('hota-retry-fields').classList.toggle('hidden', !e.target.checked);
|
||
else if (e.target && e.target.id === 'hota-include-http') document.getElementById('hota-http-fields').classList.toggle('hidden', !e.target.checked);
|
||
});
|
||
hotaToggleConn();
|
||
|
||
function hotaAddTlv() {
|
||
const container = document.getElementById('hota-conn-tlvs');
|
||
const row = document.createElement('div');
|
||
row.className = 'flex gap-1 items-center mb-1';
|
||
row.innerHTML =
|
||
'<input maxlength="2" oninput="hotaUpdatePreview()" class="font-mono w-14 border border-gray-300 dark:border-slate-600 text-sm rounded px-1.5 py-1 dark:bg-slate-800" placeholder="Tag">' +
|
||
'<input oninput="hotaUpdatePreview()" class="font-mono flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-1.5 py-1 dark:bg-slate-800" placeholder="Value">' +
|
||
'<button onclick="this.parentElement.remove();hotaUpdatePreview()" class="text-gray-400 hover:text-red-500 px-1" title="Delete">×</button>';
|
||
container.appendChild(row);
|
||
hotaUpdatePreview();
|
||
}
|
||
function hotaAddPreset(name) {
|
||
const container = document.getElementById('hota-conn-tlvs');
|
||
const row = document.createElement('div');
|
||
row.className = 'flex gap-1 items-center mb-1';
|
||
const presets = {
|
||
devid: ['02', '8182'],
|
||
alpha: ['80', ''],
|
||
bearer: ['01', '014001'],
|
||
};
|
||
const [tag, value] = presets[name] || ['', ''];
|
||
row.innerHTML =
|
||
'<input maxlength="2" value="' + tag + '" oninput="hotaUpdatePreview()" class="font-mono w-14 border border-gray-300 dark:border-slate-600 text-sm rounded px-1.5 py-1 dark:bg-slate-800" placeholder="Tag">' +
|
||
'<input value="' + value + '" oninput="hotaUpdatePreview()" class="font-mono flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-1.5 py-1 dark:bg-slate-800" placeholder="Value">' +
|
||
'<button onclick="this.parentElement.remove();hotaUpdatePreview()" class="text-gray-400 hover:text-red-500 px-1" title="Delete">×</button>';
|
||
container.appendChild(row);
|
||
hotaUpdatePreview();
|
||
}
|
||
|
||
function hotaReadConnTlvs() {
|
||
const tlvs = [];
|
||
document.querySelectorAll('#hota-conn-tlvs .flex').forEach(row => {
|
||
const inputs = row.querySelectorAll('input');
|
||
if (inputs.length >= 2) tlvs.push({ tag: _hotaHex(inputs[0].value), value: inputs[1].value });
|
||
});
|
||
return tlvs;
|
||
}
|
||
|
||
function hotaUpdatePreview() {
|
||
const connOn = document.getElementById('hota-include-conn').checked;
|
||
const secOn = document.getElementById('hota-include-sec').checked;
|
||
const retryOn = document.getElementById('hota-include-retry').checked;
|
||
const httpOn = document.getElementById('hota-include-http').checked;
|
||
const expanded = document.getElementById('hota-expanded').checked;
|
||
const storeTag = document.getElementById('hota-store-tag').value;
|
||
const conn = connOn ? hotaBuildConn(hotaReadConnTlvs()) : '';
|
||
const sec = secOn ? hotaBuildSec({
|
||
pskIdentity: document.getElementById('hota-psk-text').value,
|
||
kvn: document.getElementById('hota-kvn').value,
|
||
kid: document.getElementById('hota-kid').value,
|
||
}) : '';
|
||
const retry = retryOn ? hotaBuildRetry({
|
||
counter: document.getElementById('hota-retry-counter').value,
|
||
delayH: document.getElementById('hota-delay-h').value,
|
||
delayM: document.getElementById('hota-delay-m').value,
|
||
delayS: document.getElementById('hota-delay-s').value,
|
||
reportFailure: document.getElementById('hota-retry-report').value,
|
||
}) : '';
|
||
const http = httpOn ? hotaBuildHttpPost({
|
||
host: document.getElementById('hota-host').value,
|
||
agent: document.getElementById('hota-agent').value,
|
||
uri: document.getElementById('hota-uri').value,
|
||
}) : '';
|
||
const hex = hotaBuild(_hotaMode, conn, sec, retry, http, storeTag, expanded);
|
||
const preview = document.getElementById('hota-preview');
|
||
preview.value = hex;
|
||
document.getElementById('hota-pack-btn').disabled = !hex;
|
||
}
|
||
|
||
function hotaPreviewEdited() {
|
||
const hex = document.getElementById('hota-preview').value;
|
||
document.getElementById('hota-pack-btn').disabled = !hex;
|
||
}
|
||
document.querySelectorAll('#c-apdu-sub-httpota input, #c-apdu-sub-httpota select').forEach(el => {
|
||
if (el.type === 'checkbox') return;
|
||
el.addEventListener('input', hotaUpdatePreview);
|
||
});
|
||
|
||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
|
||
});
|
||
|
||
function updateRcPriv() {
|
||
let b1 = 0, b2 = 0, b3 = 0;
|
||
document.querySelectorAll('.rc-priv-b1:checked').forEach(cb => b1 |= parseInt(cb.value, 16));
|
||
document.querySelectorAll('.rc-priv-b2:checked').forEach(cb => b2 |= parseInt(cb.value, 16));
|
||
document.querySelectorAll('.rc-priv-b3:checked').forEach(cb => b3 |= parseInt(cb.value, 16));
|
||
let hex = b1.toString(16).padStart(2, '0').toUpperCase();
|
||
hex += b2.toString(16).padStart(2, '0').toUpperCase();
|
||
if (b3) hex += b3.toString(16).padStart(2, '0').toUpperCase();
|
||
document.getElementById('rc-priv').value = hex;
|
||
}
|
||
|
||
function updateRcToolkit() {
|
||
const checked = document.getElementById('rc-toolkit-enable').checked;
|
||
document.getElementById('rc-toolkit-body').classList.toggle('hidden', !checked);
|
||
updateRcTkMode();
|
||
updateStkParamsHex();
|
||
}
|
||
function updateRcTkMode() {
|
||
const isSim = document.getElementById('rc-tk-mode').value === 'ca';
|
||
document.getElementById('rc-tk-ad-row').style.display = isSim ? '' : 'none';
|
||
document.getElementById('rc-tk-services-row').style.display = isSim ? 'none' : '';
|
||
}
|
||
document.getElementById('rc-tk-mode').addEventListener('change', () => { updateRcTkMode(); updateStkParamsHex(); });
|
||
|
||
function buildRcToolkitParams() {
|
||
if (!document.getElementById('rc-toolkit-enable').checked) return '';
|
||
const tkMode = document.getElementById('rc-tk-mode').value;
|
||
const priority = parseInt(document.getElementById('rc-tk-priority').value) || 0;
|
||
const timers = parseInt(document.getElementById('rc-tk-timers').value) || 0;
|
||
const textLen = parseInt(document.getElementById('rc-tk-textlen').value) || 0;
|
||
const menus = parseInt(document.getElementById('rc-tk-menus').value) || 0;
|
||
const firstPos = parseInt(document.getElementById('rc-tk-firstpos').value) || 0;
|
||
const firstId = (document.getElementById('rc-tk-firstid').value || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2) || '00';
|
||
const lastPos = parseInt(document.getElementById('rc-tk-lastpos').value) || 0;
|
||
const lastId = (document.getElementById('rc-tk-lastid').value || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2) || '00';
|
||
const channels = parseInt(document.getElementById('rc-tk-channels').value) || 0;
|
||
const msl = document.getElementById('rc-tk-msl').value;
|
||
const tar = (document.getElementById('rc-tk-tar').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
const adRaw = (document.getElementById('rc-tk-ad').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
const ad = adRaw.padStart(2, '0').slice(0, 2);
|
||
const services = parseInt(document.getElementById('rc-tk-services').value) || 0;
|
||
|
||
if (tar && tar.length % 6 !== 0) return null;
|
||
if (parseInt(firstId, 16) > 0x7F || parseInt(lastId, 16) > 0x7F) return null;
|
||
|
||
let menuPairs = '';
|
||
for (let i = 1; i <= menus; i++) {
|
||
if (i === 1) menuPairs += firstPos.toString(16).padStart(2, '0') + firstId;
|
||
else if (i === menus) menuPairs += lastPos.toString(16).padStart(2, '0') + lastId;
|
||
else menuPairs += '0000';
|
||
}
|
||
|
||
const mslField = msl === '00' ? '00' : '0201' + msl;
|
||
|
||
let tkPayload = '';
|
||
if (tkMode === 'ca') {
|
||
tkPayload = (adRaw ? '01' + ad : '00') +
|
||
priority.toString(16).padStart(2, '0') +
|
||
timers.toString(16).padStart(2, '0') +
|
||
textLen.toString(16).padStart(2, '0') +
|
||
menus.toString(16).padStart(2, '0') +
|
||
menuPairs +
|
||
channels.toString(16).padStart(2, '0') +
|
||
mslField +
|
||
(tar ? (tar.length / 2).toString(16).padStart(2, '0') + tar : '00');
|
||
const caTlv = 'CA' + berLenStr(tkPayload.length / 2) + tkPayload;
|
||
return 'EF' + berLenStr(caTlv.length / 2) + caTlv;
|
||
} else {
|
||
tkPayload = priority.toString(16).padStart(2, '0') +
|
||
timers.toString(16).padStart(2, '0') +
|
||
textLen.toString(16).padStart(2, '0') +
|
||
menus.toString(16).padStart(2, '0') +
|
||
menuPairs +
|
||
channels.toString(16).padStart(2, '0') +
|
||
mslField +
|
||
(tar ? (tar.length / 2).toString(16).padStart(2, '0') + tar : '00') +
|
||
services.toString(16).padStart(2, '0');
|
||
const innerTlv = '80' + berLenStr(tkPayload.length / 2) + tkPayload;
|
||
return 'EA' + berLenStr(innerTlv.length / 2) + innerTlv;
|
||
}
|
||
}
|
||
|
||
function updateStkParamsHex() {
|
||
const tk = buildRcToolkitParams();
|
||
document.getElementById('ram-stk-params').value = tk || '';
|
||
}
|
||
|
||
function updateInstallParamsHex() {
|
||
document.getElementById('ram-install-params-hex').value = 'C900';
|
||
}
|
||
|
||
// ===== GSM 7-bit alphabet =====
|
||
const GSM7_ALPHABET = [
|
||
'\u0040','\u00a3','\u0024','\u00a5','\u00e8','\u00e9','\u00f9','\u00ec',
|
||
'\u00f2','\u00c7','\u000a','\u00d8','\u00f8','\u000d','\u00c5','\u00e5',
|
||
'\u0394','\u005f','\u03a6','\u0393','\u039b','\u03a9','\u03a0','\u03a8',
|
||
'\u03a3','\u0398','\u039e','\u001b','\u00c6','\u00e6','\u00df','\u00c9',
|
||
'\u0020','\u0021','\u0022','\u0023','\u00a4','\u0025','\u0026','\u0027',
|
||
'\u0028','\u0029','\u002a','\u002b','\u002c','\u002d','\u002e','\u002f',
|
||
'\u0030','\u0031','\u0032','\u0033','\u0034','\u0035','\u0036','\u0037',
|
||
'\u0038','\u0039','\u003a','\u003b','\u003c','\u003d','\u003e','\u003f',
|
||
'\u00a1','\u0041','\u0042','\u0043','\u0044','\u0045','\u0046','\u0047',
|
||
'\u0048','\u0049','\u004a','\u004b','\u004c','\u004d','\u004e','\u004f',
|
||
'\u0050','\u0051','\u0052','\u0053','\u0054','\u0055','\u0056','\u0057',
|
||
'\u0058','\u0059','\u005a','\u00c4','\u00d6','\u00d1','\u00dc','\u00a7',
|
||
'\u00bf','\u0061','\u0062','\u0063','\u0064','\u0065','\u0066','\u0067',
|
||
'\u0068','\u0069','\u006a','\u006b','\u006c','\u006d','\u006e','\u006f',
|
||
'\u0070','\u0071','\u0072','\u0073','\u0074','\u0075','\u0076','\u0077',
|
||
'\u0078','\u0079','\u007a','\u00e4','\u00f6','\u00f1','\u00fc','\u00e0',
|
||
];
|
||
|
||
const GSM7_EXT_MAP = {
|
||
'\n': 0x0a, '^': 0x14, '{': 0x28, '}': 0x29, '\\': 0x2f,
|
||
'[': 0x3c, '~': 0x3d, ']': 0x3e, '|': 0x40, '\u20ac': 0x65,
|
||
};
|
||
|
||
function gsm7TextToSeptets(text) {
|
||
const septets = [];
|
||
for (const c of text) {
|
||
if (c in GSM7_EXT_MAP) {
|
||
septets.push(0x1B);
|
||
septets.push(GSM7_EXT_MAP[c]);
|
||
} else {
|
||
const idx = GSM7_ALPHABET.indexOf(c);
|
||
if (idx === -1) throw new Error('Character not in GSM7 alphabet');
|
||
septets.push(idx);
|
||
}
|
||
}
|
||
return septets;
|
||
}
|
||
|
||
function gsm7Encode(septets) {
|
||
const numOctets = Math.ceil(septets.length * 7 / 8);
|
||
const packed = new Uint8Array(numOctets);
|
||
for (let i = 0; i < septets.length; i++) {
|
||
const bitPos = i * 7;
|
||
const byteIdx = bitPos >>> 3;
|
||
const bitShift = bitPos & 7;
|
||
packed[byteIdx] |= (septets[i] << bitShift) & 0xFF;
|
||
if (byteIdx + 1 < numOctets) {
|
||
packed[byteIdx + 1] |= septets[i] >>> (8 - bitShift);
|
||
}
|
||
}
|
||
return packed;
|
||
}
|
||
|
||
function encodeSpn(text) {
|
||
try {
|
||
const septets = gsm7TextToSeptets(text);
|
||
const packed = gsm7Encode(septets);
|
||
const nSeptets = septets.length;
|
||
const nOctets = packed.length;
|
||
const lastOctetDataBits = nSeptets * 7 - (nOctets - 1) * 8;
|
||
const spareBits = 8 - lastOctetDataBits;
|
||
let dcs = 0x80 | (spareBits & 0x07);
|
||
if (dcs === 0x81) dcs = 0x80;
|
||
const inner = new Uint8Array(16);
|
||
inner[0] = dcs;
|
||
inner.set(packed, 1);
|
||
for (let i = 1 + nOctets; i < 16; i++) inner[i] = 0xFF;
|
||
return '01' + Array.from(inner).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join('');
|
||
} catch (e) {
|
||
let hasNonBmp = false;
|
||
for (const c of text) {
|
||
const code = c.charCodeAt(0);
|
||
if (code >= 0xD800 && code <= 0xDBFF) { hasNonBmp = true; break; }
|
||
}
|
||
if (hasNonBmp) {
|
||
const buf = [];
|
||
for (const c of text) {
|
||
const code = c.charCodeAt(0);
|
||
buf.push(code >>> 8, code & 0xFF);
|
||
}
|
||
const inner = new Uint8Array(16);
|
||
inner[0] = 0x80;
|
||
for (let i = 0; i < buf.length && i + 1 < 16; i++) inner[i + 1] = buf[i];
|
||
for (let i = 1 + buf.length; i < 16; i++) inner[i] = 0xFF;
|
||
return '00' + Array.from(inner).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join('');
|
||
} else {
|
||
const nonGsm7 = [];
|
||
for (const c of text) {
|
||
if (GSM7_ALPHABET.indexOf(c) === -1 && !(c in GSM7_EXT_MAP)) {
|
||
nonGsm7.push(c.charCodeAt(0));
|
||
}
|
||
}
|
||
const base = nonGsm7.length ? (Math.min(...nonGsm7) & 0xFF00) : 0;
|
||
const baseHigh = base >>> 8;
|
||
const inner = new Uint8Array(16);
|
||
inner[0] = 0x81;
|
||
inner[1] = text.length;
|
||
inner[2] = baseHigh;
|
||
let pos = 3;
|
||
for (const c of text) {
|
||
if (GSM7_EXT_MAP.hasOwnProperty(c)) {
|
||
inner[pos++] = GSM7_EXT_MAP[c];
|
||
} else {
|
||
const idx = GSM7_ALPHABET.indexOf(c);
|
||
if (idx !== -1) {
|
||
inner[pos++] = idx;
|
||
} else {
|
||
const code = c.charCodeAt(0);
|
||
inner[pos++] = 0x80 | (code - base);
|
||
}
|
||
}
|
||
}
|
||
while (pos < 16) inner[pos++] = 0xFF;
|
||
return '00' + Array.from(inner).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join('');
|
||
}
|
||
}
|
||
}
|
||
|
||
function swapNibbles(s) {
|
||
let out = '';
|
||
for (let i = 0; i < s.length; i += 2) {
|
||
out += s[i + 1] || 'f';
|
||
out += s[i];
|
||
}
|
||
return out.toUpperCase();
|
||
}
|
||
|
||
function encImsi(imsi) {
|
||
const l = Math.ceil((imsi.length + 1) / 2);
|
||
const oe = imsi.length & 1;
|
||
const indicator = ((oe << 3) | 1).toString(16);
|
||
const padded = imsi.padEnd(15, 'f');
|
||
const second = swapNibbles(indicator + padded);
|
||
return l.toString(16).padStart(2, '0') + second;
|
||
}
|
||
|
||
function encMsisdn(msisdn) {
|
||
let s = msisdn.replace(/^\+/, '');
|
||
if (s.length % 2) s += 'f';
|
||
return swapNibbles(s);
|
||
}
|
||
|
||
function encIccid(iccid) {
|
||
return swapNibbles(iccid);
|
||
}
|
||
|
||
// EF.ICCID (2FE2): nibble-swapped E.118 digits, 19-20 digits, 'F' pad (TS 102 221 13.2).
|
||
function decIccid(hex) {
|
||
return swapNibbles((hex || '').replace(/[^0-9a-fA-F]/g, '')).replace(/F+$/, '');
|
||
}
|
||
|
||
function buildApdu(cla, ins, p1, p2, p3, data) {
|
||
let s = cla.toString(16).padStart(2, '0').toUpperCase() +
|
||
ins.toString(16).padStart(2, '0').toUpperCase() +
|
||
p1.toString(16).padStart(2, '0').toUpperCase() +
|
||
p2.toString(16).padStart(2, '0').toUpperCase();
|
||
if (p3 !== null && p3 !== undefined) s += p3.toString(16).padStart(2, '0').toUpperCase();
|
||
if (data) s += data.toUpperCase();
|
||
return s;
|
||
}
|
||
|
||
function buildSelect(cla, p1, p2, fid) {
|
||
const data = fid.length === 4 ? fid : '0000';
|
||
return buildApdu(cla, 0xA4, p1, p2, 0x02, data);
|
||
}
|
||
|
||
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
|
||
// ===== Chain Builder =====
|
||
// Row-based APDU chain builder for SIM RFM, USIM RFM, and RAM/GP sub-tabs.
|
||
// Each chain has a list of rows; each row = one APDU command with its own fields.
|
||
// Chain preview = concatenation of all row hexes, with Le stripping when
|
||
// a Case 4 command is followed by GET RESPONSE (ETSI TS 102 226 compact).
|
||
const _chains = {};
|
||
|
||
const CHAIN_CMDS_SIM = [
|
||
{value:'select',label:'SELECT'},{value:'read-record',label:'READ RECORD'},
|
||
{value:'read-binary',label:'READ BINARY'},{value:'update-record',label:'UPDATE RECORD'},
|
||
{value:'update-binary',label:'UPDATE BINARY'},{value:'erase-binary',label:'ERASE BINARY'},
|
||
{value:'activate-file',label:'ACTIVATE FILE'},{value:'deactivate-file',label:'DEACTIVATE FILE'},
|
||
{value:'verify',label:'VERIFY PIN'},{value:'change',label:'CHANGE PIN'},
|
||
{value:'disable',label:'DISABLE PIN'},{value:'enable',label:'ENABLE PIN'},
|
||
{value:'unblock',label:'UNBLOCK PIN'},{value:'get-response',label:'GET RESPONSE'},
|
||
];
|
||
const CHAIN_CMDS_USIM = [
|
||
{value:'select',label:'SELECT'},{value:'read-record',label:'READ RECORD'},
|
||
{value:'read-binary',label:'READ BINARY'},{value:'update-record',label:'UPDATE RECORD'},
|
||
{value:'update-binary',label:'UPDATE BINARY'},
|
||
{value:'activate-file',label:'ACTIVATE FILE'},{value:'deactivate-file',label:'DEACTIVATE FILE'},
|
||
{value:'verify',label:'VERIFY PIN'},{value:'change',label:'CHANGE PIN'},
|
||
{value:'disable',label:'DISABLE PIN'},{value:'enable',label:'ENABLE PIN'},
|
||
{value:'unblock',label:'UNBLOCK PIN'},{value:'get-response',label:'GET RESPONSE'},
|
||
];
|
||
const CHAIN_CMDS_RAM = [
|
||
{value:'install-load',label:'INSTALL [for load]'},
|
||
{value:'install-install',label:'INSTALL [for install]'},
|
||
{value:'install-make-sel',label:'INSTALL [for make selectable]'},
|
||
{value:'install-reg-update',label:'INSTALL [for registry update]'},
|
||
{value:'install-extradition',label:'INSTALL [for extradition]'},
|
||
{value:'load',label:'LOAD'},{value:'delete',label:'DELETE'},
|
||
{value:'get-status',label:'GET STATUS'},{value:'get-data',label:'GET DATA'},
|
||
{value:'store-data',label:'STORE DATA'},{value:'set-status',label:'SET STATUS'},
|
||
{value:'ext-auth',label:'EXTERNAL AUTHENTICATE'},
|
||
{value:'int-auth',label:'INTERNAL AUTHENTICATE'},
|
||
{value:'get-response',label:'GET RESPONSE'},
|
||
];
|
||
|
||
function chainInit(id) { if (!_chains[id]) _chains[id] = {rows: []}; }
|
||
function chainAddRow(id, cmd) { chainInit(id); _chains[id].rows.push({cmd: cmd, fields: {}}); chainRender(id); }
|
||
function chainDeleteRow(id, idx) { chainInit(id); _chains[id].rows.splice(idx, 1); chainRender(id); }
|
||
function chainUpdateCmd(id, idx, cmd) { chainInit(id); _chains[id].rows[idx].cmd = cmd; _chains[id].rows[idx].fields = {}; chainRender(id); }
|
||
function chainUpdateField(id, idx, field, value) {
|
||
chainInit(id);
|
||
_chains[id].rows[idx].fields[field] = value;
|
||
const h = document.getElementById(id + '-row-' + idx + '-hex');
|
||
if (h) h.value = chainBuildRowHex(id, idx);
|
||
const p = document.getElementById(id + '-preview');
|
||
if (p) p.value = chainBuildHex(id);
|
||
const b = document.getElementById(id + '-pack-btn');
|
||
if (b) b.disabled = !chainBuildHex(id);
|
||
}
|
||
|
||
function chainRender(chainId) {
|
||
chainInit(chainId);
|
||
const container = document.getElementById(chainId + '-rows');
|
||
const preview = document.getElementById(chainId + '-preview');
|
||
const packBtn = document.getElementById(chainId + '-pack-btn');
|
||
if (!container) return;
|
||
const rows = _chains[chainId].rows;
|
||
container.innerHTML = '';
|
||
rows.forEach(function(row, idx) {
|
||
const div = document.createElement('div');
|
||
div.className = 'mb-3 p-3 border border-gray-200 dark:border-slate-600 rounded';
|
||
div.innerHTML = chainRenderRow(chainId, idx, row);
|
||
container.appendChild(div);
|
||
});
|
||
const hex = chainBuildHex(chainId);
|
||
if (preview) preview.value = hex;
|
||
if (packBtn) packBtn.disabled = !hex;
|
||
}
|
||
|
||
function chainRenderRow(chainId, idx, row) {
|
||
const isRam = chainId === 'chain-ram';
|
||
const cmds = isRam ? CHAIN_CMDS_RAM : (chainId === 'chain-usim' ? CHAIN_CMDS_USIM : CHAIN_CMDS_SIM);
|
||
const cmdOpts = cmds.map(function(c) {
|
||
return '<option value="' + c.value + '"' + (c.value === row.cmd ? ' selected' : '') + '>' + c.label + '</option>';
|
||
}).join('');
|
||
const fieldsHtml = chainRowFieldsHtml(chainId, idx, row);
|
||
const hex = chainBuildRowHex(chainId, idx);
|
||
return '<div class="flex items-center gap-2 mb-2">' +
|
||
'<select onchange="chainUpdateCmd(\'' + chainId + '\',' + idx + ',this.value)" class="flex-shrink-0 w-52 border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">' + cmdOpts + '</select>' +
|
||
'<input id="' + chainId + '-row-' + idx + '-hex" readonly value="' + escHtml(hex) + '" class="flex-1 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-50 dark:bg-slate-900">' +
|
||
'<button onclick="chainDeleteRow(\'' + chainId + '\',' + idx + ')" class="text-red-500 hover:text-red-700 text-lg font-bold px-1" title="Delete">✕</button>' +
|
||
'</div>' +
|
||
'<div class="pl-4">' + fieldsHtml + '</div>';
|
||
}
|
||
|
||
function chainRowFieldsHtml(chainId, idx, row) {
|
||
var f = row.fields;
|
||
function uf(field) { return "chainUpdateField('" + chainId + "'," + idx + ",'" + field + "',this.value)"; }
|
||
if (chainId === 'chain-sim' || chainId === 'chain-usim') return chainSimFieldsHtml(chainId, idx, f, uf);
|
||
return chainRamFieldsHtml(idx, row, f, uf);
|
||
}
|
||
|
||
// ===== SIM/USIM field rendering =====
|
||
function chainSimFieldsHtml(chainId, idx, f, uf) {
|
||
var cmd = (_chains[chainId] && _chains[chainId].rows[idx]) ? _chains[chainId].rows[idx].cmd : '';
|
||
if (cmd === 'select') {
|
||
var method = f.method || 'fid';
|
||
var silent = chainId === 'chain-usim' && f.silent;
|
||
var html = '<div class="grid grid-cols-2 gap-2 mb-2"><div>' +
|
||
'<label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Method</label>' +
|
||
'<select onchange="' + uf('method') + ';chainRender(\'' + chainId + '\')" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
|
||
'<option value="fid"' + (method === 'fid' ? ' selected' : '') + '>By FID</option>' +
|
||
'<option value="path"' + (method === 'path' ? ' selected' : '') + '>By path</option>' +
|
||
'<option value="dfname"' + (method === 'dfname' ? ' selected' : '') + '>By AID</option>' +
|
||
'<option value="chain"' + (method === 'chain' ? ' selected' : '') + '>Chain</option>' +
|
||
'</select></div>';
|
||
if (chainId === 'chain-usim') {
|
||
html += '<div class="flex items-end pb-0.5"><label class="flex items-center gap-1.5 text-xs">' +
|
||
'<input type="checkbox"' + (silent ? ' checked' : '') + ' onchange="chainUpdateField(\'chain-usim\',' + idx + ',\'silent\',this.checked);chainRender(\'chain-usim\')" class="rounded">' +
|
||
'Silent (P2=0C)</label></div>';
|
||
}
|
||
html += '</div>';
|
||
if (method === 'fid') {
|
||
html += '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">FID (2 bytes)</label>' +
|
||
'<input value="' + escHtml(f.fid || '') + '" oninput="' + uf('fid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="6FC5" maxlength="4"></div>';
|
||
}
|
||
if (method === 'path') {
|
||
html += '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Path (hex)</label>' +
|
||
'<input value="' + escHtml(f.path || '') + '" oninput="' + uf('path') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="7FFF6FC5">';
|
||
if (chainId === 'chain-usim') {
|
||
html += '<div class="flex gap-3 mt-1 text-xs"><label class="flex items-center gap-1"><input type="radio" name="ch-usim-' + idx + '-base" value="mf"' + ((f.base || 'mf') === 'mf' ? ' checked' : '') + ' onchange="chainUpdateField(\'chain-usim\',' + idx + ',\'base\',\'mf\')"> from MF</label>' +
|
||
'<label class="flex items-center gap-1"><input type="radio" name="ch-usim-' + idx + '-base" value="df"' + (f.base === 'df' ? ' checked' : '') + ' onchange="chainUpdateField(\'chain-usim\',' + idx + ',\'base\',\'df\')"> from current DF</label></div>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
if (method === 'dfname') {
|
||
html += '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">AID (hex)</label>' +
|
||
'<input value="' + escHtml(f.dfname || '') + '" oninput="' + uf('dfname') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>';
|
||
}
|
||
if (method === 'chain') {
|
||
html += '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">FID chain (comma-separated)</label>' +
|
||
'<input value="' + escHtml(f.chain || '') + '" oninput="' + uf('chain') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="3F00,2FE2,C0"></div>';
|
||
}
|
||
return html;
|
||
}
|
||
if (cmd === 'read-record') {
|
||
var rm = f.recMode || '04';
|
||
return '<div class="grid grid-cols-3 gap-2">' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Record #</label>' +
|
||
'<input type="number" min="1" max="255" value="' + escHtml(f.record || '1') + '" oninput="' + uf('record') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">P2 (mode)</label>' +
|
||
'<select onchange="' + uf('recMode') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
|
||
'<option value="04"' + (rm === '04' ? ' selected' : '') + '>Absolute (04)</option>' +
|
||
'<option value="02"' + (rm === '02' ? ' selected' : '') + '>Next (02)</option>' +
|
||
'<option value="03"' + (rm === '03' ? ' selected' : '') + '>Previous (03)</option></select></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Le</label>' +
|
||
'<input value="' + escHtml(f.le || '00') + '" oninput="' + uf('le') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="00" maxlength="2"></div></div>';
|
||
}
|
||
if (cmd === 'read-binary') {
|
||
return '<div class="grid grid-cols-2 gap-2">' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Offset (hex)</label>' +
|
||
'<input value="' + escHtml(f.offset || '0000') + '" oninput="' + uf('offset') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="0000" maxlength="4"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Le</label>' +
|
||
'<input value="' + escHtml(f.le || '00') + '" oninput="' + uf('le') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="00" maxlength="2"></div></div>';
|
||
}
|
||
if (cmd === 'update-record') {
|
||
var rm2 = f.recMode || '04';
|
||
return '<div class="grid grid-cols-2 gap-2 mb-2">' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Record #</label>' +
|
||
'<input type="number" min="1" max="255" value="' + escHtml(f.record || '1') + '" oninput="' + uf('record') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">P2 (mode)</label>' +
|
||
'<select onchange="' + uf('recMode') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
|
||
'<option value="04"' + (rm2 === '04' ? ' selected' : '') + '>Absolute (04)</option>' +
|
||
'<option value="02"' + (rm2 === '02' ? ' selected' : '') + '>Next (02)</option>' +
|
||
'<option value="03"' + (rm2 === '03' ? ' selected' : '') + '>Previous (03)</option></select></div></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Data (hex)</label>' +
|
||
'<textarea oninput="' + uf('data') + '" rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">' + escHtml(f.data || '') + '</textarea></div>';
|
||
}
|
||
if (cmd === 'update-binary') {
|
||
return '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Offset (hex)</label>' +
|
||
'<input value="' + escHtml(f.offset || '0000') + '" oninput="' + uf('offset') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="0000" maxlength="4"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Data (hex)</label>' +
|
||
'<textarea oninput="' + uf('data') + '" rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">' + escHtml(f.data || '') + '</textarea></div>';
|
||
}
|
||
if (cmd === 'erase-binary') {
|
||
return '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Offset (hex)</label>' +
|
||
'<input value="' + escHtml(f.offset || '0000') + '" oninput="' + uf('offset') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="0000" maxlength="4"></div>';
|
||
}
|
||
if (cmd === 'activate-file' || cmd === 'deactivate-file') {
|
||
var target = f.target || 'current';
|
||
var html2 = '<div class="grid grid-cols-2 gap-2"><div>' +
|
||
'<label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Target</label>' +
|
||
'<select onchange="' + uf('target') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
|
||
'<option value="current"' + (target === 'current' ? ' selected' : '') + '>Current EF (case 1)</option>' +
|
||
'<option value="fid"' + (target === 'fid' ? ' selected' : '') + '>By FID</option>' +
|
||
'<option value="mfpath"' + (target === 'mfpath' ? ' selected' : '') + '>Path from MF</option>' +
|
||
'<option value="dfpath"' + (target === 'dfpath' ? ' selected' : '') + '>Path from current DF</option></select></div>';
|
||
if (target !== 'current') {
|
||
html2 += '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">File (hex)</label>' +
|
||
'<input value="' + escHtml(f.file || '') + '" oninput="' + uf('file') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="6FC5 / 7FFF6FC5"></div>';
|
||
}
|
||
html2 += '</div>';
|
||
return html2;
|
||
}
|
||
if (cmd === 'verify' || cmd === 'disable' || cmd === 'enable') {
|
||
var pin = f.pin || '01';
|
||
return '<div class="grid grid-cols-2 gap-2">' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">PIN</label>' +
|
||
'<select onchange="' + uf('pin') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
|
||
'<option value="01"' + (pin === '01' ? ' selected' : '') + '>PIN1 (01)</option>' +
|
||
'<option value="02"' + (pin === '02' ? ' selected' : '') + '>PIN2 (02)</option></select></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">PIN value</label>' +
|
||
'<input value="' + escHtml(f.pinVal || '') + '" oninput="' + uf('pinVal') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="1234" maxlength="8"></div></div>';
|
||
}
|
||
if (cmd === 'change' || cmd === 'unblock') {
|
||
var pin2 = f.pin || '01';
|
||
return '<div class="grid grid-cols-3 gap-2">' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">PIN</label>' +
|
||
'<select onchange="' + uf('pin') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
|
||
'<option value="01"' + (pin2 === '01' ? ' selected' : '') + '>PIN1 (01)</option>' +
|
||
'<option value="02"' + (pin2 === '02' ? ' selected' : '') + '>PIN2 (02)</option></select></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + (cmd === 'unblock' ? 'Unblock PIN' : 'Old PIN') + '</label>' +
|
||
'<input value="' + escHtml(f.pinOld || '') + '" oninput="' + uf('pinOld') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="1234" maxlength="8"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">New PIN</label>' +
|
||
'<input value="' + escHtml(f.pinNew || '') + '" oninput="' + uf('pinNew') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="5678" maxlength="8"></div></div>';
|
||
}
|
||
if (cmd === 'get-response') {
|
||
return '<div class="text-xs text-gray-500 dark:text-slate-400 italic">No parameters — CLA=C0 INS=C0 P1=00 P2=00</div>';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// ===== RAM/GP field rendering =====
|
||
function chainRamFieldsHtml(idx, row, f, uf) {
|
||
var cmd = row.cmd;
|
||
if (cmd === 'install-load') {
|
||
return '<div class="grid grid-cols-2 gap-2 mb-2">' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Load File AID</label>' +
|
||
'<input value="' + escHtml(f.loadFileAid || '') + '" oninput="' + uf('loadFileAid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">SD AID</label>' +
|
||
'<input value="' + escHtml(f.sdAid || '') + '" oninput="' + uf('sdAid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Load data (hex)</label>' +
|
||
'<textarea oninput="' + uf('loadData') + '" rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">' + escHtml(f.loadData || '') + '</textarea></div>';
|
||
}
|
||
if (cmd === 'install-install') {
|
||
var html = '<div class="grid grid-cols-3 gap-2 mb-2">' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Application / Instance AID</label>' +
|
||
'<input value="' + escHtml(f.aid || '') + '" oninput="' + uf('aid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Load File AID / Package AID</label>' +
|
||
'<input value="' + escHtml(f.elfAid || '') + '" oninput="' + uf('elfAid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Executable Module AID / Applet Class AID</label>' +
|
||
'<input value="' + escHtml(f.modAid || '') + '" oninput="' + uf('modAid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div></div>';
|
||
// Privileges
|
||
html += chainRamPrivHtml(idx, f, uf);
|
||
// Toolkit
|
||
html += chainRamToolkitHtml(idx, f, uf);
|
||
return html;
|
||
}
|
||
if (cmd === 'install-make-sel') {
|
||
return '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Application / Instance AID</label>' +
|
||
'<input value="' + escHtml(f.aid || '') + '" oninput="' + uf('aid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>' +
|
||
chainRamPrivHtml(idx, f, uf);
|
||
}
|
||
if (cmd === 'install-reg-update') {
|
||
return '<div class="grid grid-cols-2 gap-2 mb-2">' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">SD AID</label>' +
|
||
'<input value="' + escHtml(f.sdAid || '') + '" oninput="' + uf('sdAid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Application / Instance AID</label>' +
|
||
'<input value="' + escHtml(f.aid || '') + '" oninput="' + uf('aid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div></div>' +
|
||
chainRamPrivHtml(idx, f, uf);
|
||
}
|
||
if (cmd === 'install-extradition') {
|
||
return '<div class="grid grid-cols-2 gap-2">' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">SD AID</label>' +
|
||
'<input value="' + escHtml(f.sdAid || '') + '" oninput="' + uf('sdAid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Application / Instance AID</label>' +
|
||
'<input value="' + escHtml(f.aid || '') + '" oninput="' + uf('aid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div></div>';
|
||
}
|
||
if (cmd === 'load') {
|
||
return '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Data (hex)</label>' +
|
||
'<textarea oninput="' + uf('data') + '" rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">' + escHtml(f.data || '') + '</textarea></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Block number</label>' +
|
||
'<input type="number" min="0" value="' + escHtml(f.block || '0') + '" oninput="' + uf('block') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800"></div>';
|
||
}
|
||
if (cmd === 'delete') {
|
||
var dm = f.delMode || '00';
|
||
return '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Application / Instance AID</label>' +
|
||
'<input value="' + escHtml(f.aid || '') + '" oninput="' + uf('aid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">DELETE mode</label>' +
|
||
'<select onchange="' + uf('delMode') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
|
||
'<option value="00"' + (dm === '00' ? ' selected' : '') + '>AID only (00)</option>' +
|
||
'<option value="80"' + (dm === '80' ? ' selected' : '') + '>AID + related objects (80)</option></select></div>';
|
||
}
|
||
if (cmd === 'get-status') {
|
||
var gm = f.gsMode || '80';
|
||
var gp = f.gsP2 || '00';
|
||
return '<div class="grid grid-cols-2 gap-2">' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">GET STATUS mode</label>' +
|
||
'<select onchange="' + uf('gsMode') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
|
||
'<option value="80"' + (gm === '80' ? ' selected' : '') + '>ISD (80)</option>' +
|
||
'<option value="40"' + (gm === '40' ? ' selected' : '') + '>Applications and SSD (40)</option>' +
|
||
'<option value="20"' + (gm === '20' ? ' selected' : '') + '>Executable Load Files (20)</option>' +
|
||
'<option value="10"' + (gm === '10' ? ' selected' : '') + '>ELF and modules (10)</option></select></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">GET STATUS format</label>' +
|
||
'<select onchange="' + uf('gsP2') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
|
||
'<option value="02"' + (gp === '02' ? ' selected' : '') + '>02 — First/all, TLV format</option>' +
|
||
'<option value="03"' + (gp === '03' ? ' selected' : '') + '>03 — Next, TLV format</option>' +
|
||
'<option value="00"' + (gp === '00' ? ' selected' : '') + '>00 — First/all, raw format</option></select></div></div>';
|
||
}
|
||
if (cmd === 'get-data') {
|
||
var tag = f.tag || '5F50';
|
||
return '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">GET DATA tag</label>' +
|
||
'<select onchange="chainUpdateField(\'chain-ram\',' + idx + ',\'tag\',this.value)" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800 mb-1">' +
|
||
'<option value=""' + (!tag ? ' selected' : '') + '>— select preset —</option>' +
|
||
'<option value="42"' + (tag === '42' ? ' selected' : '') + '>42 — IIN</option>' +
|
||
'<option value="45"' + (tag === '45' ? ' selected' : '') + '>45 — CIN</option>' +
|
||
'<option value="66"' + (tag === '66' ? ' selected' : '') + '>66 — Card Data</option>' +
|
||
'<option value="67"' + (tag === '67' ? ' selected' : '') + '>67 — Card Capability</option>' +
|
||
'<option value="E0"' + (tag === 'E0' ? ' selected' : '') + '>E0 — Key Info Template</option>' +
|
||
'<option value="D3"' + (tag === 'D3' ? ' selected' : '') + '>D3 — Current Security Level</option>' +
|
||
'<option value="2F00"' + (tag === '2F00' ? ' selected' : '') + '>2F00 — List of Applications</option>' +
|
||
'<option value="FF21"' + (tag === 'FF21' ? ' selected' : '') + '>FF21 — Extended Card Resources</option>' +
|
||
'<option value="5F50"' + (tag === '5F50' ? ' selected' : '') + '>5F50 — SD Manager URL</option>' +
|
||
'<option value="C1"' + (tag === 'C1' ? ' selected' : '') + '>C1 — Sequence Counter</option>' +
|
||
'<option value="C2"' + (tag === 'C2' ? ' selected' : '') + '>C2 — Confirmation Counter</option>' +
|
||
'<option value="7F21"' + (tag === '7F21' ? ' selected' : '') + '>7F21 — Certificate</option>' +
|
||
'<option value="5031"' + (tag === '5031' ? ' selected' : '') + '>5031 — Certificate info</option>' +
|
||
'</select>' +
|
||
'<input value="' + escHtml(tag) + '" oninput="' + uf('tag') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="5F50" maxlength="4"></div>';
|
||
}
|
||
if (cmd === 'store-data') {
|
||
var enc = f.enc || '00';
|
||
return '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Data (hex)</label>' +
|
||
'<textarea oninput="' + uf('data') + '" rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">' + escHtml(f.data || '') + '</textarea></div>' +
|
||
'<div class="grid grid-cols-2 gap-2">' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Encryption</label>' +
|
||
'<select onchange="' + uf('enc') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
|
||
'<option value="00"' + (enc === '00' ? ' selected' : '') + '>No encryption, more blocks</option>' +
|
||
'<option value="40"' + (enc === '40' ? ' selected' : '') + '>App-dependent, more blocks</option>' +
|
||
'<option value="80"' + (enc === '80' ? ' selected' : '') + '>No encryption, last block</option>' +
|
||
'<option value="C0"' + (enc === 'C0' ? ' selected' : '') + '>App-dependent, last block</option>' +
|
||
'<option value="E0"' + (enc === 'E0' ? ' selected' : '') + '>With encryption, last block</option></select></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Block number</label>' +
|
||
'<input type="number" min="0" value="' + escHtml(f.block || '0') + '" oninput="' + uf('block') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800"></div></div>';
|
||
}
|
||
if (cmd === 'set-status') {
|
||
var sm = f.ssMode || '80';
|
||
var ss = f.ssState || '00';
|
||
var html3 = '<div class="grid grid-cols-2 gap-2 mb-2">' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">SET STATUS type</label>' +
|
||
'<select onchange="' + uf('ssMode') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
|
||
'<option value="80"' + (sm === '80' ? ' selected' : '') + '>ISD (80)</option>' +
|
||
'<option value="40"' + (sm === '40' ? ' selected' : '') + '>Application or SSD (40)</option>' +
|
||
'<option value="60"' + (sm === '60' ? ' selected' : '') + '>SD + associated apps (60)</option></select></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">State</label>' +
|
||
'<select onchange="' + uf('ssState') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
|
||
'<option value="00"' + (ss === '00' ? ' selected' : '') + '>00 — Unlock</option>' +
|
||
'<option value="80"' + (ss === '80' ? ' selected' : '') + '>80 — Lock (LOCKED)</option>' +
|
||
'<option value="01"' + (ss === '01' ? ' selected' : '') + '>01 — OP_READY</option>' +
|
||
'<option value="07"' + (ss === '07' ? ' selected' : '') + '>07 — INITIALIZED</option>' +
|
||
'<option value="0F"' + (ss === '0F' ? ' selected' : '') + '>0F — SECURED</option>' +
|
||
'<option value="7F"' + (ss === '7F' ? ' selected' : '') + '>7F — CARD_LOCKED</option>' +
|
||
'<option value="FF"' + (ss === 'FF' ? ' selected' : '') + '>FF — TERMINATED</option></select></div></div>';
|
||
if (sm !== '80') {
|
||
html3 += '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Application / Instance AID</label>' +
|
||
'<input value="' + escHtml(f.aid || '') + '" oninput="' + uf('aid') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="A000000151000000"></div>';
|
||
}
|
||
return html3;
|
||
}
|
||
if (cmd === 'ext-auth') {
|
||
var ap = f.authP1 || '00';
|
||
return '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Data (hex) — 16 bytes</label>' +
|
||
'<input value="' + escHtml(f.data || '') + '" oninput="' + uf('data') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="32 hex chars"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Security level (P1)</label>' +
|
||
'<select onchange="' + uf('authP1') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
|
||
'<option value="00"' + (ap === '00' ? ' selected' : '') + '>00 — No secure messaging</option>' +
|
||
'<option value="01"' + (ap === '01' ? ' selected' : '') + '>01 — C-MAC</option>' +
|
||
'<option value="03"' + (ap === '03' ? ' selected' : '') + '>03 — C-DEC + C-MAC</option></select></div>';
|
||
}
|
||
if (cmd === 'int-auth') {
|
||
return '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Data (hex) — challenge</label>' +
|
||
'<input value="' + escHtml(f.data || '') + '" oninput="' + uf('data') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="hex"></div>';
|
||
}
|
||
if (cmd === 'get-response') {
|
||
return '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Le (max bytes)</label>' +
|
||
'<input value="' + escHtml(f.le || '00') + '" oninput="' + uf('le') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="00" maxlength="2"></div>';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// ===== RAM/GP privileges sub-form (per-row) =====
|
||
function chainRamPrivHtml(idx, f, uf) {
|
||
var priv = f.priv || '00';
|
||
var b1 = parseInt(priv.substring(0, 2), 16) || 0;
|
||
var b2 = priv.length >= 4 ? parseInt(priv.substring(2, 4), 16) : 0;
|
||
var b3 = priv.length >= 6 ? parseInt(priv.substring(4, 6), 16) : 0;
|
||
var html = '<div class="mb-2"><div class="flex items-center gap-3 mb-1">' +
|
||
'<label class="text-xs font-medium text-gray-700 dark:text-slate-300">Privileges</label>' +
|
||
'<input readonly value="' + escHtml(priv) + '" class="font-mono w-16 border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-0.5 bg-gray-100 dark:bg-slate-800">' +
|
||
'</div><div class="grid grid-cols-2 gap-x-4 gap-y-0.5 text-xs">';
|
||
var CHECKS = [
|
||
['Security Domain','80','b1'],['Trusted Path','80','b2'],
|
||
['DAP Verification','40','b1'],['Authorized Management','40','b2'],
|
||
['Delegated Management','20','b1'],['Token Verification','20','b2'],
|
||
['Card Lock','10','b1'],['Global Delete','10','b2'],
|
||
['Card Terminate','08','b1'],['Global Lock','08','b2'],
|
||
['Card Reset','04','b1'],['Global Registry','04','b2'],
|
||
['CVM Management','02','b1'],['Final Application','02','b2'],
|
||
['Mandated DAP Verification','01','b1'],['',0,''],
|
||
['Receipt Generation','80','b3'],['','','']
|
||
];
|
||
CHECKS.forEach(function(c) {
|
||
if (!c[0]) return '';
|
||
var byteIdx = c[2];
|
||
var val = parseInt(c[1], 16);
|
||
var checked = (byteIdx === 'b1' && (b1 & val)) || (byteIdx === 'b2' && (b2 & val)) || (byteIdx === 'b3' && (b3 & val));
|
||
html += '<label class="flex items-center gap-1"><input type="checkbox" class="chain-priv-' + byteIdx + '-' + idx + '"' +
|
||
(checked ? ' checked' : '') + ' onchange="chainRamUpdatePriv(' + idx + ')"> ' + c[0] + '</label>';
|
||
});
|
||
html += '</div></div>';
|
||
return html;
|
||
}
|
||
|
||
function chainRamUpdatePriv(idx) {
|
||
var b1 = 0, b2 = 0, b3 = 0;
|
||
document.querySelectorAll('.chain-priv-b1-' + idx + ':checked').forEach(function(cb) { b1 |= parseInt(cb.value, 16); });
|
||
document.querySelectorAll('.chain-priv-b2-' + idx + ':checked').forEach(function(cb) { b2 |= parseInt(cb.value, 16); });
|
||
document.querySelectorAll('.chain-priv-b3-' + idx + ':checked').forEach(function(cb) { b3 |= parseInt(cb.value, 16); });
|
||
var hex = b1.toString(16).padStart(2, '0').toUpperCase();
|
||
hex += b2.toString(16).padStart(2, '0').toUpperCase();
|
||
if (b3) hex += b3.toString(16).padStart(2, '0').toUpperCase();
|
||
chainUpdateField('chain-ram', idx, 'priv', hex);
|
||
}
|
||
|
||
// ===== RAM/GP toolkit sub-form (per-row) =====
|
||
function chainRamToolkitHtml(idx, f, uf) {
|
||
var enabled = f.tkEnabled;
|
||
var html = '<div class="mb-2"><label class="flex items-center gap-2 text-xs font-medium text-gray-700 dark:text-slate-300">' +
|
||
'<input type="checkbox"' + (enabled ? ' checked' : '') + ' onchange="chainUpdateField(\'chain-ram\',' + idx + ',\'tkEnabled\',this.checked);chainRender(\'chain-ram\')" class="rounded">' +
|
||
'SIM/UICC Toolkit parameters</label></div>';
|
||
if (!enabled) return html;
|
||
var tkMode = f.tkMode || 'ea';
|
||
var priority = f.tkPriority || '0';
|
||
var timers = f.tkTimers || '0';
|
||
var textLen = f.tkTextlen || '0';
|
||
var menus = f.tkMenus || '0';
|
||
var firstPos = f.tkFirstpos || '0';
|
||
var firstId = f.tkFirstid || '00';
|
||
var lastPos = f.tkLastpos || '0';
|
||
var lastId = f.tkLastid || '00';
|
||
var channels = f.tkChannels || '0';
|
||
var msl = f.tkMsl || '16';
|
||
var tar = f.tkTar || 'B00001';
|
||
var ad = f.tkAd || '';
|
||
var services = f.tkServices || '0';
|
||
html += '<div class="p-2 bg-gray-50 dark:bg-slate-900 rounded border border-gray-200 dark:border-slate-700">' +
|
||
'<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Toolkit mode</label>' +
|
||
'<select onchange="' + uf('tkMode') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
|
||
'<option value="ca"' + (tkMode === 'ca' ? ' selected' : '') + '>SIM Toolkit (Tag CA)</option>' +
|
||
'<option value="ea"' + (tkMode === 'ea' ? ' selected' : '') + '>UICC Toolkit (Tag EA)</option></select></div>' +
|
||
'<div class="grid grid-cols-4 gap-x-2 gap-y-1.5 text-xs">' +
|
||
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Priority</label>' +
|
||
'<input type="number" min="0" max="255" value="' + escHtml(priority) + '" oninput="' + uf('tkPriority') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
|
||
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Timers</label>' +
|
||
'<input type="number" min="0" max="255" value="' + escHtml(timers) + '" oninput="' + uf('tkTimers') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
|
||
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Text length</label>' +
|
||
'<input type="number" min="0" max="255" value="' + escHtml(textLen) + '" oninput="' + uf('tkTextlen') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
|
||
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Menus</label>' +
|
||
'<input type="number" min="0" max="255" value="' + escHtml(menus) + '" oninput="' + uf('tkMenus') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
|
||
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">1st pos</label>' +
|
||
'<input type="number" min="0" max="255" value="' + escHtml(firstPos) + '" oninput="' + uf('tkFirstpos') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
|
||
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">1st ID (hex)</label>' +
|
||
'<input value="' + escHtml(firstId) + '" oninput="' + uf('tkFirstid') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800" maxlength="2"></div>' +
|
||
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Last pos</label>' +
|
||
'<input type="number" min="0" max="255" value="' + escHtml(lastPos) + '" oninput="' + uf('tkLastpos') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
|
||
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Last ID (hex)</label>' +
|
||
'<input value="' + escHtml(lastId) + '" oninput="' + uf('tkLastid') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800" maxlength="2"></div>' +
|
||
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Channels</label>' +
|
||
'<input type="number" min="0" max="255" value="' + escHtml(channels) + '" oninput="' + uf('tkChannels') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>' +
|
||
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">MSL (SPI1)</label>' +
|
||
'<select onchange="' + uf('tkMsl') + '" class="w-full border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800">' +
|
||
'<option value="00"' + (msl === '00' ? ' selected' : '') + '>00</option>' +
|
||
'<option value="11"' + (msl === '11' ? ' selected' : '') + '>11 RC/CC/DS</option>' +
|
||
'<option value="12"' + (msl === '12' ? ' selected' : '') + '>12 RC/DS/CC</option>' +
|
||
'<option value="15"' + (msl === '15' ? ' selected' : '') + '>15 RC/DS/CC+MAC</option>' +
|
||
'<option value="16"' + (msl === '16' ? ' selected' : '') + '>16 RC/DS/CC+MAC+Cipher</option>' +
|
||
'<option value="19"' + (msl === '19' ? ' selected' : '') + '>19 RC/DS/CC+MAC+Cipher+DS</option></select></div>' +
|
||
'<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">TAR (3 bytes)</label>' +
|
||
'<input value="' + escHtml(tar) + '" oninput="' + uf('tkTar') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800" maxlength="6"></div>';
|
||
if (tkMode === 'ca') {
|
||
html += '<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Access domain (hex)</label>' +
|
||
'<input value="' + escHtml(ad) + '" oninput="' + uf('tkAd') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800" maxlength="2"></div>';
|
||
} else {
|
||
html += '<div><label class="block text-gray-600 dark:text-slate-400 mb-0.5">Max services</label>' +
|
||
'<input type="number" min="0" max="255" value="' + escHtml(services) + '" oninput="' + uf('tkServices') + '" class="w-full font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800"></div>';
|
||
}
|
||
html += '</div></div>';
|
||
return html;
|
||
}
|
||
|
||
// ===== SIM/USIM hex builder =====
|
||
function chainSimBuildRowHex(chainId, idx, row) {
|
||
var f = row.fields;
|
||
var isSim = chainId === 'chain-sim';
|
||
var cla = isSim ? 0xA0 : 0x00;
|
||
|
||
if (row.cmd === 'select') {
|
||
var method = f.method || 'fid';
|
||
var silent = !isSim && f.silent;
|
||
var p2 = silent ? 0x0C : (isSim ? 0x00 : 0x04);
|
||
if (method === 'fid') {
|
||
var fid = (f.fid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (fid.length !== 4) return '';
|
||
var apdu = buildApdu(cla, 0xA4, 0x00, p2, 0x02, fid);
|
||
if (!isSim && !silent) apdu += '00';
|
||
return apdu;
|
||
}
|
||
if (method === 'path') {
|
||
var path = (f.path || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (path.length < 2 || path.length % 2 !== 0) return '';
|
||
var p1 = (!isSim && f.base === 'df') ? 0x09 : 0x08;
|
||
var apdu2 = buildApdu(cla, 0xA4, p1, p2, path.length / 2, path);
|
||
if (!isSim && !silent) apdu2 += '00';
|
||
return apdu2;
|
||
}
|
||
if (method === 'dfname') {
|
||
var aid = (f.dfname || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (aid.length < 2) return '';
|
||
var apdu3 = buildApdu(cla, 0xA4, 0x04, p2, aid.length / 2, aid);
|
||
if (!isSim && !silent) apdu3 += '00';
|
||
return apdu3;
|
||
}
|
||
if (method === 'chain') {
|
||
var tokens = (f.chain || '').split(',').map(function(t) { return t.trim(); }).filter(function(t) { return t.length > 0; });
|
||
var out = '';
|
||
for (var ti = 0; ti < tokens.length; ti++) {
|
||
var tok = tokens[ti];
|
||
var gr = /^c0(?::([0-9a-fA-F]{1,2}))?$/i.exec(tok);
|
||
if (gr) {
|
||
var leByte = parseInt(gr[1] || '00', 16);
|
||
out += buildApdu(cla, 0xC0, 0x00, 0x00, leByte, '');
|
||
continue;
|
||
}
|
||
var fid2 = tok.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (fid2.length !== 4) continue;
|
||
var s = buildSelect(cla, 0x00, p2, fid2);
|
||
if (!isSim && !silent) s += '00';
|
||
out += s;
|
||
}
|
||
return out;
|
||
}
|
||
return '';
|
||
}
|
||
if (row.cmd === 'get-response') {
|
||
return '00C0000000';
|
||
}
|
||
var OPS2 = {
|
||
'read-record': {ins: 0xB2}, 'read-binary': {ins: 0xB0},
|
||
'update-record': {ins: 0xDC}, 'update-binary': {ins: 0xD6},
|
||
'erase-binary': {ins: 0x0E}, 'activate-file': {ins: 0x44},
|
||
'deactivate-file': {ins: 0x04}, 'verify': {ins: 0x20},
|
||
'change': {ins: 0x24}, 'disable': {ins: 0x26},
|
||
'enable': {ins: 0x28}, 'unblock': {ins: 0x2C},
|
||
};
|
||
var op = OPS2[row.cmd];
|
||
if (!op) return '';
|
||
var p1, p2v, p3, data = null;
|
||
if (row.cmd === 'read-record') {
|
||
var rm = parseInt(f.recMode || '04', 16);
|
||
var rec = parseInt(f.record) || 1;
|
||
var le = parseInt(f.le || '00', 16);
|
||
p1 = (rm === 0x02 || rm === 0x03) ? 0x00 : rec;
|
||
p2v = rm; p3 = le;
|
||
} else if (row.cmd === 'read-binary') {
|
||
var off = parseInt((f.offset || '0000').replace(/[^0-9a-fA-F]/g, ''), 16) || 0;
|
||
var le2 = parseInt(f.le || '00', 16);
|
||
p1 = (off >> 8) & 0xFF; p2v = off & 0xFF; p3 = le2;
|
||
} else if (row.cmd === 'update-record') {
|
||
var rm2 = parseInt(f.recMode || '04', 16);
|
||
var rec2 = parseInt(f.record) || 1;
|
||
data = (f.data || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (!data) return '';
|
||
p1 = (rm2 === 0x02 || rm2 === 0x03) ? 0x00 : rec2;
|
||
p2v = rm2; p3 = data.length / 2;
|
||
} else if (row.cmd === 'update-binary') {
|
||
var off2 = parseInt((f.offset || '0000').replace(/[^0-9a-fA-F]/g, ''), 16) || 0;
|
||
data = (f.data || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (!data) return '';
|
||
p1 = (off2 >> 8) & 0xFF; p2v = off2 & 0xFF; p3 = data.length / 2;
|
||
} else if (row.cmd === 'erase-binary') {
|
||
var off3 = parseInt((f.offset || '0000').replace(/[^0-9a-fA-F]/g, ''), 16) || 0;
|
||
p1 = (off3 >> 8) & 0xFF; p2v = off3 & 0xFF; p3 = 0x00;
|
||
} else if (row.cmd === 'activate-file' || row.cmd === 'deactivate-file') {
|
||
var target = f.target || 'current';
|
||
if (target === 'current') { p1 = 0x00; p2v = 0x00; p3 = null; }
|
||
else {
|
||
var fileHex = (f.file || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (!fileHex || fileHex.length % 2 !== 0) return '';
|
||
p1 = target === 'fid' ? 0x00 : (target === 'mfpath' ? 0x08 : 0x09);
|
||
p2v = 0x00; p3 = fileHex.length / 2; data = fileHex;
|
||
}
|
||
} else if (row.cmd === 'verify' || row.cmd === 'disable' || row.cmd === 'enable') {
|
||
var pin = f.pin || '01';
|
||
var pinVal = f.pinVal || '';
|
||
if (!pinVal) return '';
|
||
var pinHex = Array.from(pinVal).map(function(c) { return c.charCodeAt(0).toString(16).padStart(2, '0'); }).join('').padEnd(16, 'F').slice(0, 16);
|
||
p1 = 0x00; p2v = parseInt(pin, 16); p3 = 0x08; data = pinHex;
|
||
} else if (row.cmd === 'change' || row.cmd === 'unblock') {
|
||
var pin2 = f.pin || '01';
|
||
var oldPin = f.pinOld || '';
|
||
var newPin = f.pinNew || '';
|
||
if (!oldPin || !newPin) return '';
|
||
var oldHex = Array.from(oldPin).map(function(c) { return c.charCodeAt(0).toString(16).padStart(2, '0'); }).join('').padEnd(16, 'F').slice(0, 16);
|
||
var newHex = Array.from(newPin).map(function(c) { return c.charCodeAt(0).toString(16).padStart(2, '0'); }).join('').padEnd(16, 'F').slice(0, 16);
|
||
p1 = 0x00; p2v = parseInt(pin2, 16); p3 = 0x10; data = oldHex + newHex;
|
||
} else {
|
||
return '';
|
||
}
|
||
return buildApdu(cla, op.ins, p1, p2v, p3, data);
|
||
}
|
||
|
||
// ===== RAM/GP hex builder =====
|
||
function chainRamBuildRowHex(idx, row) {
|
||
var f = row.fields;
|
||
var cmd = row.cmd;
|
||
function lv(data) { if (!data) return '00'; return berLenStr(data.length / 2) + data; }
|
||
function computePriv() {
|
||
var ph = f.priv || '00';
|
||
var b1 = parseInt(ph.substring(0, 2), 16) || 0;
|
||
var b2 = ph.length >= 4 ? parseInt(ph.substring(2, 4), 16) : 0;
|
||
var b3 = ph.length >= 6 ? parseInt(ph.substring(4, 6), 16) : 0;
|
||
if (b2 === 0 && b3 === 0) return b1.toString(16).padStart(2, '0').toUpperCase();
|
||
var r = b1.toString(16).padStart(2, '0').toUpperCase() + b2.toString(16).padStart(2, '0').toUpperCase();
|
||
if (b3) r += b3.toString(16).padStart(2, '0').toUpperCase();
|
||
return r;
|
||
}
|
||
function buildTkParams() {
|
||
if (!f.tkEnabled) return '';
|
||
var tkMode = f.tkMode || 'ea';
|
||
var priority = parseInt(f.tkPriority) || 0;
|
||
var timers = parseInt(f.tkTimers) || 0;
|
||
var textLen = parseInt(f.tkTextlen) || 0;
|
||
var menus = parseInt(f.tkMenus) || 0;
|
||
var firstPos = parseInt(f.tkFirstpos) || 0;
|
||
var firstId = (f.tkFirstid || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2) || '00';
|
||
var lastPos = parseInt(f.tkLastpos) || 0;
|
||
var lastId = (f.tkLastid || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2) || '00';
|
||
var channels = parseInt(f.tkChannels) || 0;
|
||
var msl = f.tkMsl || '00';
|
||
var tar = (f.tkTar || '').replace(/[^0-9a-fA-F]/g, '');
|
||
var ad = (f.tkAd || '').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2);
|
||
var services = parseInt(f.tkServices) || 0;
|
||
if (tar && tar.length % 6 !== 0) return null;
|
||
var menuPairs = '';
|
||
for (var i = 1; i <= menus; i++) {
|
||
if (i === 1) menuPairs += firstPos.toString(16).padStart(2, '0') + firstId;
|
||
else if (i === menus) menuPairs += lastPos.toString(16).padStart(2, '0') + lastId;
|
||
else menuPairs += '0000';
|
||
}
|
||
var mslField = msl === '00' ? '00' : '0201' + msl;
|
||
var tkPayload = '';
|
||
if (tkMode === 'ca') {
|
||
tkPayload = (f.tkAd ? '01' + ad : '00') +
|
||
priority.toString(16).padStart(2, '0') +
|
||
timers.toString(16).padStart(2, '0') +
|
||
textLen.toString(16).padStart(2, '0') +
|
||
menus.toString(16).padStart(2, '0') +
|
||
menuPairs +
|
||
channels.toString(16).padStart(2, '0') +
|
||
mslField +
|
||
(tar ? (tar.length / 2).toString(16).padStart(2, '0') + tar : '00');
|
||
var caTlv = 'CA' + berLenStr(tkPayload.length / 2) + tkPayload;
|
||
return 'EF' + berLenStr(caTlv.length / 2) + caTlv;
|
||
} else {
|
||
tkPayload = priority.toString(16).padStart(2, '0') +
|
||
timers.toString(16).padStart(2, '0') +
|
||
textLen.toString(16).padStart(2, '0') +
|
||
menus.toString(16).padStart(2, '0') +
|
||
menuPairs +
|
||
channels.toString(16).padStart(2, '0') +
|
||
mslField +
|
||
(tar ? (tar.length / 2).toString(16).padStart(2, '0') + tar : '00') +
|
||
services.toString(16).padStart(2, '0');
|
||
var innerTlv = '80' + berLenStr(tkPayload.length / 2) + tkPayload;
|
||
return 'EA' + berLenStr(innerTlv.length / 2) + innerTlv;
|
||
}
|
||
}
|
||
var INSTALL_P1 = {
|
||
'install-load': 0x02, 'install-install': 0x0C,
|
||
'install-make-sel': 0x08, 'install-reg-update': 0x40,
|
||
'install-extradition': 0x10,
|
||
};
|
||
var aid = (f.aid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
var sdAid = (f.sdAid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (cmd === 'install-load') {
|
||
var loadFileAid = (f.loadFileAid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
var loadData = (f.loadData || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (!loadFileAid) return '';
|
||
var dataField = lv(loadFileAid) + lv(sdAid) + '00' + (loadData ? lv(loadData) : '00') + '00';
|
||
var dataLen = berLenStr(dataField.length / 2);
|
||
return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dataLen, 16), dataField) + '00';
|
||
}
|
||
if (cmd === 'install-install') {
|
||
var elfAid = (f.elfAid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
var modAid = (f.modAid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (!aid) return '';
|
||
var privBytes = computePriv();
|
||
var installParams = 'C900';
|
||
var tkParams = buildTkParams();
|
||
if (tkParams === null) return '';
|
||
if (tkParams) installParams += tkParams;
|
||
var df2 = lv(elfAid) + lv(modAid) + lv(aid) + lv(privBytes) + lv(installParams) + '00';
|
||
var dl2 = berLenStr(df2.length / 2);
|
||
return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dl2, 16), df2) + '00';
|
||
}
|
||
if (cmd === 'install-make-sel') {
|
||
if (!aid) return '';
|
||
var privBytes2 = computePriv();
|
||
var df3 = '00' + '00' + lv(aid) + lv(privBytes2) + '00' + '00';
|
||
var dl3 = berLenStr(df3.length / 2);
|
||
return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dl3, 16), df3) + '00';
|
||
}
|
||
if (cmd === 'install-reg-update') {
|
||
if (!aid) return '';
|
||
var privBytes3 = computePriv();
|
||
var df4 = lv(sdAid) + '00' + lv(aid) + lv(privBytes3) + '00' + '00';
|
||
var dl4 = berLenStr(df4.length / 2);
|
||
return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dl4, 16), df4) + '00';
|
||
}
|
||
if (cmd === 'install-extradition') {
|
||
if (!sdAid || sdAid.length < 10 || sdAid.length > 20) return '';
|
||
if (!aid) return '';
|
||
var df5 = lv(sdAid) + '00' + lv(aid) + '00' + '00' + '00';
|
||
var dl5 = berLenStr(df5.length / 2);
|
||
return buildApdu(0x80, 0xE6, INSTALL_P1[cmd], 0x00, parseInt(dl5, 16), df5) + '00';
|
||
}
|
||
if (cmd === 'load') {
|
||
var ramData = (f.data || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
var blockNum = parseInt(f.block) || 0;
|
||
if (!ramData) return '';
|
||
var dl6 = berLenStr(ramData.length / 2);
|
||
return buildApdu(0x80, 0xE8, 0x80, blockNum, parseInt(dl6, 16), ramData) + '00';
|
||
}
|
||
if (cmd === 'delete') {
|
||
if (!aid) return '';
|
||
var delMode = f.delMode || '00';
|
||
var df7 = '4F' + berLenStr(aid.length / 2) + aid;
|
||
var dl7 = berLenStr(df7.length / 2);
|
||
return buildApdu(0x80, 0xE4, 0x00, parseInt(delMode, 16), parseInt(dl7, 16), df7) + '00';
|
||
}
|
||
if (cmd === 'get-status') {
|
||
var gsMode = f.gsMode || '80';
|
||
var gsP2 = f.gsP2 || '00';
|
||
return buildApdu(0x80, 0xF2, parseInt(gsMode, 16), parseInt(gsP2, 16), 0x02, '4F00') + '00';
|
||
}
|
||
if (cmd === 'get-data') {
|
||
var tagHex = (f.tag || '5F50').replace(/[^0-9a-fA-F]/g, '').padStart(4, '0').slice(0, 4);
|
||
var tag = parseInt(tagHex, 16);
|
||
if (tagHex === '2F00' || tagHex === '5031') {
|
||
return buildApdu(0x80, 0xCA, (tag >> 8) & 0xFF, tag & 0xFF, 0x02, '5C00') + '00';
|
||
}
|
||
return buildApdu(0x80, 0xCA, (tag >> 8) & 0xFF, tag & 0xFF, 0x00, null) + '00';
|
||
}
|
||
if (cmd === 'store-data') {
|
||
var ramData2 = (f.data || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
var enc = f.enc || '00';
|
||
var blockNum2 = parseInt(f.block) || 0;
|
||
if (!ramData2) return '';
|
||
var dl8 = berLenStr(ramData2.length / 2);
|
||
return buildApdu(0x80, 0xE2, parseInt(enc, 16), blockNum2, parseInt(dl8, 16), ramData2);
|
||
}
|
||
if (cmd === 'set-status') {
|
||
var ssMode = f.ssMode || '80';
|
||
var ssState = f.ssState || '00';
|
||
if (ssMode === '80') {
|
||
return buildApdu(0x80, 0xF0, 0x80, parseInt(ssState, 16), 0x00, null);
|
||
}
|
||
if (!aid) return '';
|
||
return buildApdu(0x80, 0xF0, parseInt(ssMode, 16), parseInt(ssState, 16), aid.length / 2, aid);
|
||
}
|
||
if (cmd === 'ext-auth') {
|
||
var ramData3 = (f.data || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
var authP1 = f.authP1 || '00';
|
||
if (!ramData3 || ramData3.length !== 32) return '';
|
||
return buildApdu(0x84, 0x82, parseInt(authP1, 16), 0x00, 0x10, ramData3);
|
||
}
|
||
if (cmd === 'int-auth') {
|
||
var ramData4 = (f.data || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (!ramData4) return '';
|
||
var dl9 = berLenStr(ramData4.length / 2);
|
||
return buildApdu(0x00, 0x88, 0x00, 0x00, parseInt(dl9, 16), ramData4) + '00';
|
||
}
|
||
if (cmd === 'get-response') {
|
||
var le = parseInt(f.le, 16) || 0;
|
||
return buildApdu(0x80, 0xC0, 0x00, 0x00, le, null);
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// ===== Chain hex builder (with Le stripping) =====
|
||
function chainBuildRowHex(chainId, idx) {
|
||
chainInit(chainId);
|
||
var row = _chains[chainId].rows[idx];
|
||
if (!row) return '';
|
||
if (chainId === 'chain-sim' || chainId === 'chain-usim') {
|
||
return chainSimBuildRowHex(chainId, idx, row);
|
||
}
|
||
return chainRamBuildRowHex(idx, row);
|
||
}
|
||
|
||
function chainIsCase4(cmd, chainId) {
|
||
if (cmd === 'select') return chainId === 'chain-usim';
|
||
if (cmd === 'get-status' || cmd === 'get-data' || cmd === 'store-data' || cmd === 'int-auth') return true;
|
||
return false;
|
||
}
|
||
|
||
function chainBuildHex(chainId) {
|
||
chainInit(chainId);
|
||
var rows = _chains[chainId].rows;
|
||
if (!rows.length) return '';
|
||
var parts = [];
|
||
for (var i = 0; i < rows.length; i++) {
|
||
var rowHex = chainBuildRowHex(chainId, i);
|
||
if (i < rows.length - 1 && rows[i + 1].cmd === 'get-response') {
|
||
if (chainIsCase4(rows[i].cmd, chainId) && rowHex.length >= 2) {
|
||
rowHex = rowHex.slice(0, -2);
|
||
}
|
||
}
|
||
parts.push(rowHex);
|
||
}
|
||
return parts.join('');
|
||
}
|
||
|
||
function updateConvFields() {
|
||
const type = document.getElementById('conv-type').value;
|
||
const isPlmn = type === 'plmn' || type === 'plmnwact';
|
||
document.getElementById('conv-input').style.display = isPlmn ? 'none' : '';
|
||
document.getElementById('conv-plmn-fields').style.display = isPlmn ? '' : 'none';
|
||
document.getElementById('conv-act-row').style.display = type === 'plmnwact' ? '' : 'none';
|
||
}
|
||
|
||
function encPlmn(mcc, mnc) {
|
||
mcc = mcc.padEnd(3, '0').slice(0, 3);
|
||
mnc = mnc.padEnd(3, 'F').slice(0, 3);
|
||
const d = mcc + mnc;
|
||
const b1 = d[1] + d[0];
|
||
const b2 = d[5] + d[2];
|
||
const b3 = d[4] + d[3];
|
||
return (b1 + b2 + b3).toUpperCase();
|
||
}
|
||
|
||
function genConvert() {
|
||
const type = document.getElementById('conv-type').value;
|
||
const input = document.getElementById('conv-input').value.trim();
|
||
const mcc = document.getElementById('conv-mcc').value.trim();
|
||
const mnc = document.getElementById('conv-mnc').value.trim();
|
||
const act = document.getElementById('conv-act').value;
|
||
|
||
let result = '';
|
||
try {
|
||
if (type === 'imsi') {
|
||
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 = 'Error: enter a value'; }
|
||
else result = encMsisdn(input);
|
||
} else if (type === 'iccid') {
|
||
if (!input) { result = 'Error: enter a value'; }
|
||
else result = encIccid(input);
|
||
} else if (type === 'spn') {
|
||
if (!input) { result = 'Error: enter a value'; }
|
||
else result = encodeSpn(input);
|
||
} else if (type === 'plmn') {
|
||
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 = '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 = '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 = 'Error: ' + e.message;
|
||
}
|
||
document.getElementById('conv-result').value = result;
|
||
}
|
||
|
||
// ===== Secured Packet UI =====
|
||
function packToSp(sourceId) {
|
||
const val = document.getElementById(sourceId).value;
|
||
if (!val) return;
|
||
switchTab('scp80');
|
||
scp80SwitchSubtab('sp');
|
||
document.getElementById('sp-apdu').value = val;
|
||
}
|
||
function spCntrAdjust(delta) {
|
||
const el = document.getElementById('sp-cntr');
|
||
let val = parseInt(el.value.replace(/[^0-9a-fA-F]/g, ''), 16) || 0;
|
||
val = Math.max(0, val + delta);
|
||
el.value = val.toString(16).padStart(10, '0').toUpperCase();
|
||
}
|
||
function spInvalidate() {
|
||
document.getElementById('sp-result').value = '';
|
||
}
|
||
|
||
function updateSp() {
|
||
document.getElementById('sp-spi1-hex').value = document.getElementById('sp-spi1').value;
|
||
const spi2Base = parseInt(document.getElementById('sp-spi2').value, 16);
|
||
const sm = parseInt(document.getElementById('sp-spi2-sm').value, 16);
|
||
const spi2Byte = spi2Base | (sm << 5);
|
||
document.getElementById('sp-spi2-hex').value = spi2Byte.toString(16).padStart(2, '0').toUpperCase();
|
||
spInvalidate();
|
||
}
|
||
function updateSpKic() {
|
||
const idx = parseInt(document.getElementById('sp-kic-idx').value, 16);
|
||
const alg = parseInt(document.getElementById('sp-kic-alg').value, 16);
|
||
const byte = (idx << 4) | alg;
|
||
document.getElementById('sp-kic-hex').value = byte.toString(16).padStart(2, '0').toUpperCase();
|
||
updateKeyPlaceholder('kic', alg & 0x0F);
|
||
spInvalidate();
|
||
}
|
||
function updateSpKid() {
|
||
const idx = parseInt(document.getElementById('sp-kid-idx').value, 16);
|
||
const alg = parseInt(document.getElementById('sp-kid-alg').value, 16);
|
||
const byte = (idx << 4) | alg;
|
||
document.getElementById('sp-kid-hex').value = byte.toString(16).padStart(2, '0').toUpperCase();
|
||
updateKeyPlaceholder('kid', alg & 0x0F);
|
||
spInvalidate();
|
||
}
|
||
function updateKeyPlaceholder(prefix, algNib) {
|
||
const keyLen = {1: 8, 2: 16, 5: 16, 9: 24, 0xD: 8}[algNib] || 8;
|
||
const ph = algNib === 2 ? '16/24/32 bytes key (AES)' : keyLen + ' bytes key';
|
||
document.getElementById('sp-' + prefix + '-key').placeholder = ph;
|
||
}
|
||
|
||
// ===== DES / 3DES using des.js library (bundled as des-bundle.js) =====
|
||
function hexToBytes(s) {
|
||
const len = s.length / 2;
|
||
const b = new Uint8Array(len);
|
||
for (let i = 0; i < len; i++) b[i] = parseInt(s.substr(i * 2, 2), 16);
|
||
return b;
|
||
}
|
||
|
||
function bytesToHex(b) {
|
||
return Array.from(b).map(x => x.toString(16).padStart(2, '0').toUpperCase()).join('');
|
||
}
|
||
|
||
function des3Keys(keyBytes) {
|
||
const kLen = keyBytes.length;
|
||
if (kLen === 8) return [keyBytes, keyBytes, keyBytes];
|
||
if (kLen === 16) return [keyBytes.subarray(0,8), keyBytes.subarray(8,16), keyBytes.subarray(0,8)];
|
||
if (kLen === 24) return [keyBytes.subarray(0,8), keyBytes.subarray(8,16), keyBytes.subarray(16,24)];
|
||
throw new Error('Invalid key length: ' + kLen + ' bytes (expected 8, 16, or 24)');
|
||
}
|
||
|
||
function des3EncryptBlock(block, keyBytes) {
|
||
const [k1, k2, k3] = des3Keys(keyBytes);
|
||
const c1 = des.DES.create({type: 'encrypt', key: k1});
|
||
const c2 = des.DES.create({type: 'decrypt', key: k2});
|
||
const c3 = des.DES.create({type: 'encrypt', key: k3});
|
||
const out = new Uint8Array(8);
|
||
c1._update(block, 0, out, 0);
|
||
c2._update(out, 0, out, 0);
|
||
c3._update(out, 0, out, 0);
|
||
return out;
|
||
}
|
||
|
||
function des3DecryptBlock(block, keyBytes) {
|
||
const [k1, k2, k3] = des3Keys(keyBytes);
|
||
const c1 = des.DES.create({type: 'decrypt', key: k3});
|
||
const c2 = des.DES.create({type: 'encrypt', key: k2});
|
||
const c3 = des.DES.create({type: 'decrypt', key: k1});
|
||
const out = new Uint8Array(8);
|
||
c1._update(block, 0, out, 0);
|
||
c2._update(out, 0, out, 0);
|
||
c3._update(out, 0, out, 0);
|
||
return out;
|
||
}
|
||
|
||
function desEncryptBlock(block, key) {
|
||
const c = des.DES.create({type: 'encrypt', key: key});
|
||
const out = new Uint8Array(8);
|
||
c._update(block, 0, out, 0);
|
||
return out;
|
||
}
|
||
|
||
function desDecryptBlock(block, key) {
|
||
const c = des.DES.create({type: 'decrypt', key: key});
|
||
const out = new Uint8Array(8);
|
||
c._update(block, 0, out, 0);
|
||
return out;
|
||
}
|
||
|
||
function des3CbcEncrypt(data, keyBytes, iv) {
|
||
const bs = 8;
|
||
const padded = zeroPad(data, bs);
|
||
const out = new Uint8Array(padded.length);
|
||
let prev = iv;
|
||
for (let i = 0; i < padded.length; i += bs) {
|
||
const block = padded.subarray(i, i + bs);
|
||
const xored = new Uint8Array(bs);
|
||
for (let j = 0; j < bs; j++) xored[j] = block[j] ^ prev[j];
|
||
const enc = des3EncryptBlock(xored, keyBytes);
|
||
out.set(enc, i);
|
||
prev = enc;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function xorBytes(a, b) {
|
||
const len = Math.min(a.length, b.length);
|
||
const out = new Uint8Array(len);
|
||
for (let i = 0; i < len; i++) out[i] = a[i] ^ b[i];
|
||
return out;
|
||
}
|
||
|
||
function zeroPad(data, blockSize) {
|
||
const padLen = blockSize - (data.length % blockSize);
|
||
if (padLen === blockSize) return data;
|
||
const out = new Uint8Array(data.length + padLen);
|
||
out.set(data);
|
||
for (let i = data.length; i < out.length; i++) out[i] = 0x00;
|
||
return out;
|
||
}
|
||
|
||
// TS 03.48 / TS 102 225 KID CC: DES/3DES in CBC mode, zero IV, '00' padding.
|
||
// The MAC is the last 8 octets of the CBC encryption of the input.
|
||
function cbcMac(data, keyBytes) {
|
||
const bs = 8;
|
||
const padded = zeroPad(data, bs);
|
||
let prev = new Uint8Array(bs);
|
||
for (let i = 0; i < padded.length; i += bs) {
|
||
const block = padded.subarray(i, i + bs);
|
||
const xored = xorBytes(block, prev);
|
||
prev = des3EncryptBlock(xored, keyBytes);
|
||
}
|
||
return prev;
|
||
}
|
||
|
||
// ===== AES using aes-js library (bundled as aes-bundle.js) =====
|
||
function aesCbcEncrypt(data, keyBytes, iv) {
|
||
const bs = 16;
|
||
const padded = zeroPad(data, bs);
|
||
const aesCbc = new aesjs.ModeOfOperation.cbc(keyBytes, iv);
|
||
return new Uint8Array(aesCbc.encrypt(padded));
|
||
}
|
||
|
||
function aesShiftLeft1(bytes) {
|
||
const out = new Uint8Array(bytes.length);
|
||
let carry = 0;
|
||
for (let i = bytes.length - 1; i >= 0; i--) {
|
||
out[i] = (bytes[i] << 1) | carry;
|
||
carry = (bytes[i] & 0x80) ? 1 : 0;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function aesCmacSubkeys(keyBytes) {
|
||
const ecb = new aesjs.ModeOfOperation.ecb(keyBytes);
|
||
const L = new Uint8Array(ecb.encrypt(new Uint8Array(16)));
|
||
const K1 = aesShiftLeft1(L);
|
||
if (L[0] & 0x80) K1[15] ^= 0x87;
|
||
const K2 = aesShiftLeft1(K1);
|
||
if (K1[0] & 0x80) K2[15] ^= 0x87;
|
||
return [K1, K2];
|
||
}
|
||
|
||
// AES-CMAC (NIST SP 800-38B) truncated to 8 octets, matching pySim's CMAC(mac_len=8).
|
||
// Unlike the 3DES CBC-MAC above, CMAC pads internally - do NOT pre-pad the input.
|
||
function aesCmac(data, keyBytes) {
|
||
const bs = 16;
|
||
const [K1, K2] = aesCmacSubkeys(keyBytes);
|
||
const ecb = new aesjs.ModeOfOperation.ecb(keyBytes);
|
||
const n = Math.max(1, Math.ceil(data.length / bs));
|
||
const lastLen = data.length % bs;
|
||
const complete = data.length > 0 && lastLen === 0;
|
||
let prev = new Uint8Array(bs);
|
||
for (let i = 0; i < n - 1; i++) {
|
||
const block = data.subarray(i * bs, (i + 1) * bs);
|
||
const xored = xorBytes(block, prev);
|
||
prev = new Uint8Array(ecb.encrypt(xored));
|
||
}
|
||
let lastBlock;
|
||
if (complete) {
|
||
lastBlock = xorBytes(data.subarray((n - 1) * bs, n * bs), K1);
|
||
} else {
|
||
const padded = new Uint8Array(bs);
|
||
const start = (n - 1) * bs;
|
||
if (data.length > start) padded.set(data.subarray(start));
|
||
padded[data.length - start] = 0x80;
|
||
lastBlock = xorBytes(padded, K2);
|
||
}
|
||
const xored = xorBytes(lastBlock, prev);
|
||
const mac = new Uint8Array(ecb.encrypt(xored));
|
||
return mac.subarray(0, 8);
|
||
}
|
||
|
||
// ===== Secure Packet assembly =====
|
||
// TS 102 225 Table 1/2 + TS 31.115 Table 1 (SMS-PP):
|
||
// CPL(2) CHL(1) SPI(2) KIc(1) KID(1) TAR(3) CNTR(5) PCNTR(1) [RC/CC/DS] secured-data(+padding)
|
||
// CPL = octets from CHL to end incl. padding; CHL = 13 + len_sig.
|
||
// CPI (0x70) and CHI (null) are NOT part of the SMS packet data.
|
||
function genSp() {
|
||
const apduHex = (document.getElementById('sp-apdu').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
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);
|
||
const kicHex = document.getElementById('sp-kic-hex').value;
|
||
const kidHex = document.getElementById('sp-kid-hex').value;
|
||
const tarHex = (document.getElementById('sp-tar').value || '').replace(/[^0-9a-fA-F]/g, '').padEnd(6, '0').slice(0, 6);
|
||
const cntrHex = (document.getElementById('sp-cntr').value || '').replace(/[^0-9a-fA-F]/g, '').padStart(10, '0').slice(0, 10);
|
||
const kicKeyHex = (document.getElementById('sp-kic-key').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
const kidKeyHex = (document.getElementById('sp-kid-key').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
const padByte = parseInt(document.getElementById('sp-padding').value, 16);
|
||
|
||
const kicAlg = parseInt(kicHex, 16) & 0x0F;
|
||
const kidAlg = parseInt(kidHex, 16) & 0x0F;
|
||
const kicIsAes = kicAlg === 0x02;
|
||
const kidIsAes = kidAlg === 0x02;
|
||
|
||
const ciphering = (spi1 & 0x04) !== 0;
|
||
const hasMac = (spi1 & 0x03) === 0x02;
|
||
|
||
const resultEl = document.getElementById('sp-result');
|
||
|
||
// Rel-18: AES requires a replay-protected counter (SPI1 b5b4 = 10 or 11)
|
||
if ((kicIsAes && ciphering) || (kidIsAes && hasMac)) {
|
||
const counterBits = (spi1 >> 3) & 0x03;
|
||
if (counterBits !== 2 && counterBits !== 3) {
|
||
resultEl.value = 'Error: AES requires a replay-protected counter (SPI1 counter: "higher" or "+1")';
|
||
return;
|
||
}
|
||
}
|
||
|
||
let kicKey, kidKey;
|
||
try {
|
||
if (kicKeyHex) {
|
||
kicKey = hexToBytes(kicKeyHex);
|
||
const ok = kicIsAes
|
||
? (kicKey.length === 16 || kicKey.length === 24 || kicKey.length === 32)
|
||
: (kicKey.length === 8 || kicKey.length === 16 || kicKey.length === 24);
|
||
if (!ok) {
|
||
resultEl.value = kicIsAes
|
||
? 'Error: AES KIc key must be 16, 24, or 32 bytes'
|
||
: 'Error: KIc key must be 8, 16, or 24 bytes';
|
||
return;
|
||
}
|
||
}
|
||
} catch (e) { resultEl.value = 'KIc key error: ' + e.message; return; }
|
||
|
||
try {
|
||
if (kidKeyHex) {
|
||
kidKey = hexToBytes(kidKeyHex);
|
||
const ok = kidIsAes
|
||
? (kidKey.length === 16 || kidKey.length === 24 || kidKey.length === 32)
|
||
: (kidKey.length === 8 || kidKey.length === 16 || kidKey.length === 24);
|
||
if (!ok) {
|
||
resultEl.value = kidIsAes
|
||
? 'Error: AES KID key must be 16, 24, or 32 bytes'
|
||
: 'Error: KID key must be 8, 16, or 24 bytes';
|
||
return;
|
||
}
|
||
}
|
||
} catch (e) { resultEl.value = 'KID key error: ' + e.message; 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);
|
||
|
||
const macLen = hasMac ? 8 : 0;
|
||
// padding aligns CNTR+PCNTR+RC/CC/DS+data to the cipher block size (ciphering only)
|
||
const cipherBlock = kicIsAes ? 16 : 8;
|
||
const padCnt = ciphering ? (cipherBlock - ((6 + macLen + apdu.length) % cipherBlock)) % cipherBlock : 0;
|
||
|
||
const chl = 13 + macLen;
|
||
const cpl = 14 + macLen + apdu.length + padCnt;
|
||
|
||
const packetLen = 3 + chl + apdu.length + padCnt;
|
||
const packet = new Uint8Array(packetLen);
|
||
|
||
packet[0] = (cpl >> 8) & 0xFF;
|
||
packet[1] = cpl & 0xFF;
|
||
packet[2] = chl;
|
||
packet[3] = spi1;
|
||
packet[4] = spi2;
|
||
packet[5] = parseInt(kicHex, 16);
|
||
packet[6] = parseInt(kidHex, 16);
|
||
packet[7] = parseInt(tarHex.substr(0,2), 16);
|
||
packet[8] = parseInt(tarHex.substr(2,2), 16);
|
||
packet[9] = parseInt(tarHex.substr(4,2), 16);
|
||
packet[10] = parseInt(cntrHex.substr(0,2), 16);
|
||
packet[11] = parseInt(cntrHex.substr(2,2), 16);
|
||
packet[12] = parseInt(cntrHex.substr(4,2), 16);
|
||
packet[13] = parseInt(cntrHex.substr(6,2), 16);
|
||
packet[14] = parseInt(cntrHex.substr(8,2), 16);
|
||
packet[15] = padCnt;
|
||
const macOff = 16;
|
||
const dataOff = macOff + macLen;
|
||
packet.set(apdu, dataOff);
|
||
for (let i = 0; i < padCnt; i++) packet[dataOff + apdu.length + i] = padByte;
|
||
|
||
if (hasMac && kidKey) {
|
||
// CC is computed over CPL+CHL+SPI+KIc+KID+TAR+CNTR+PCNTR+data(+padding),
|
||
// i.e. everything up to (but excluding) the RC/CC/DS field (TS 31.115 4.2).
|
||
const macInput = new Uint8Array(packet.length - macLen);
|
||
macInput.set(packet.subarray(0, macOff));
|
||
macInput.set(packet.subarray(dataOff), macOff);
|
||
const mac = kidIsAes ? aesCmac(macInput, kidKey) : cbcMac(macInput, kidKey);
|
||
packet.set(mac, macOff);
|
||
}
|
||
|
||
if (ciphering && kicKey) {
|
||
// ciphering covers CNTR+PCNTR+RC/CC/DS+secured data (TS 102 225 Table 2 note 1)
|
||
const toEncrypt = packet.subarray(10, packetLen);
|
||
const encrypted = kicIsAes
|
||
? aesCbcEncrypt(toEncrypt, kicKey, new Uint8Array(16))
|
||
: des3CbcEncrypt(toEncrypt, kicKey, new Uint8Array(8));
|
||
packet.set(encrypted, 10);
|
||
}
|
||
|
||
// pySim encode_cmd parity: the 2-byte CPL prefix is transmitted only when
|
||
// ciphering is applied; unciphered packets start at CHL (v1.9.5).
|
||
resultEl.value = bytesToHex(ciphering ? packet : packet.subarray(2));
|
||
}
|
||
|
||
// ===== BER-TLV =====
|
||
const BER_TAGS = {'c-apdu':'22','immediate':'81','error':'82','chaining':'83'};
|
||
const BER_QUAL = {
|
||
refresh: [
|
||
['00','NAA Initialization and Full File Change Notification'],
|
||
['01','File Change Notification'],
|
||
['02','NAA Initialization and File Change Notification'],
|
||
['03','NAA Initialization'],
|
||
['04','UICC Reset'],
|
||
['05','NAA Application Reset (not for 2G SIM)'],
|
||
['06','NAA Session Reset (not for 2G SIM)'],
|
||
['07','Steering of Roaming'],
|
||
['08','Steering of Roaming for I-WLAN'],
|
||
['09','eUICC Profile State Change (eUICC only)'],
|
||
['0A','Application Update (eUICC only)']
|
||
],
|
||
display: [
|
||
['00','Normal priority'],
|
||
['01','High priority'],
|
||
['80','Wait for user to clear message']
|
||
],
|
||
tone: [
|
||
['00','Use of vibrate alert up to the terminal'],
|
||
['01','Vibrate alert, if available, with the tone']
|
||
]
|
||
};
|
||
const BER_TONES = [
|
||
['01','Dial tone'],['02','Called subscriber busy'],['03','Congestion'],
|
||
['04','Radio path acknowledge'],['05','Radio path not available'],
|
||
['06','Error/Special info'],['07','Call waiting'],['08','Ringing tone'],
|
||
['10','General beep'],['11','Positive acknowledgement'],['12','Negative acknowledgement']
|
||
];
|
||
const BER_DEVICES = [['81','UICC'],['82','Terminal'],['83','Network'],['02','Display']];
|
||
|
||
function addBerRow(type) {
|
||
const c = document.getElementById('ber-rows');
|
||
const idx = c.children.length;
|
||
const row = document.createElement('div');
|
||
row.className = 'ber-row border border-gray-300 dark:border-slate-600 rounded p-2 bg-gray-50 dark:bg-slate-800';
|
||
row.dataset.berIdx = idx;
|
||
row.innerHTML = berRowHtml(idx, type);
|
||
c.appendChild(row);
|
||
onBerTypeChange(row.querySelector('.ber-type'));
|
||
}
|
||
|
||
function berRowHtml(idx, type) {
|
||
const types = ['c-apdu','immediate','error','chaining'].map(t =>
|
||
`<option value="${t}"${t===type?' selected':''}>${t==='c-apdu'?'C-APDU':t==='immediate'?'Immediate':t==='error'?'Error':'Chaining'}</option>`
|
||
).join('');
|
||
return `<div class="flex gap-2 items-start mb-1">
|
||
<span class="text-xs text-gray-500 dark:text-slate-400 w-6 pt-2">${idx+1}</span>
|
||
<select class="ber-type text-xs w-24 border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" onchange="onBerTypeChange(this)">
|
||
${types}
|
||
</select>
|
||
<div class="ber-body flex-1"></div>
|
||
<button onclick="removeBerRow(this)" class="text-red-500 hover:text-red-700 text-sm px-1 pt-2">✕</button>
|
||
</div>
|
||
<div class="ber-preview text-xs text-gray-400 dark:text-slate-500 font-mono ml-8" data-ber-preview="${idx}"></div>`;
|
||
}
|
||
|
||
function removeBerRow(btn) {
|
||
btn.closest('.ber-row').remove();
|
||
document.querySelectorAll('.ber-row').forEach((r,i)=>r.dataset.berIdx=i);
|
||
document.querySelectorAll('.ber-row .ber-preview').forEach((p,i)=>p.dataset.berPreview=i);
|
||
}
|
||
|
||
function onBerTypeChange(sel) {
|
||
const row = sel.closest('.ber-row');
|
||
const body = row.querySelector('.ber-body');
|
||
const type = sel.value;
|
||
if (type === 'c-apdu') {
|
||
body.innerHTML = `<input class="ber-hex w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" placeholder="hex" oninput="updateBerRow(this)">`;
|
||
} else if (type === 'chaining') {
|
||
body.innerHTML = buildScriptChainingRow(row.dataset.berIdx);
|
||
} else if (type === 'error') {
|
||
body.innerHTML = buildErrorActionRow();
|
||
const actionType = body.querySelector('.error-action-type');
|
||
onErrorActionTypeChange(actionType);
|
||
updateBerRow(sel);
|
||
} else {
|
||
body.innerHTML = `<div class="flex gap-1 mb-1 flex-wrap">
|
||
<select class="ber-subtype text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" onchange="onBerSubTypeChange(this)">
|
||
<option value="action">Action indicator</option>
|
||
<option value="proactive">Proactive command</option>
|
||
<option value="custom">Custom hex</option>
|
||
</select>
|
||
<span class="ber-sub-body flex-1"></span>
|
||
</div>`;
|
||
onBerSubTypeChange(body.querySelector('.ber-subtype'));
|
||
}
|
||
updateBerRow(sel);
|
||
}
|
||
|
||
function buildErrorActionRow() {
|
||
return `<div class="flex gap-2 items-center">
|
||
<span class="text-xs text-gray-600 dark:text-slate-400">Error Action:</span>
|
||
<select class="error-action-type text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-700 dark:text-slate-300" onchange="onErrorActionTypeChange(this)">
|
||
<option value="proactive" selected>Proactive command (DISPLAY TEXT / PLAY TONE)</option>
|
||
<option value="noaction">No action</option>
|
||
<option value="reference">Reference to EFRMA record</option>
|
||
<option value="custom">Custom hex</option>
|
||
</select>
|
||
</div>
|
||
<div class="error-action-body mt-2"></div>`;
|
||
}
|
||
|
||
function onErrorActionTypeChange(sel) {
|
||
const body = sel.parentElement.nextElementSibling;
|
||
if (sel.value === 'proactive') {
|
||
body.innerHTML = buildBerPcHtml(['display','tone']);
|
||
const pc = body.querySelector('.ber-pc');
|
||
berPopulateDevices(pc);
|
||
onBerPcTypeChange(pc.querySelector('.ber-pc-type'));
|
||
} else if (sel.value === 'noaction') {
|
||
body.innerHTML = '<span class="text-xs text-gray-400 dark:text-slate-500">Emits <code class="font-mono">82 00</code> (no action)</span>';
|
||
} else if (sel.value === 'reference') {
|
||
body.innerHTML = `<div class="flex gap-2 items-center">
|
||
<span class="text-xs text-gray-600 dark:text-slate-400">EFRMA record ref:</span>
|
||
<input class="error-ref font-mono text-xs w-10 border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" maxlength="2" placeholder="01" oninput="updateBerRow(this)">
|
||
<span class="text-xs text-gray-400">(01-7F)</span>
|
||
</div>`;
|
||
} else {
|
||
body.innerHTML = `<input class="ber-hex w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" placeholder="hex" oninput="updateBerRow(this)">`;
|
||
}
|
||
const row = sel.closest('.ber-row');
|
||
if (row) updateBerRow(sel);
|
||
}
|
||
|
||
function buildBerPcHtml(allowed = ['refresh','display','tone']) {
|
||
const typeOpts = {refresh:'REFRESH', display:'DISPLAY TEXT', tone:'PLAY TONE'};
|
||
return `<div class="ber-pc">
|
||
<div class="flex gap-1 mb-1 flex-wrap">
|
||
<select class="ber-pc-type text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" onchange="onBerPcTypeChange(this)">
|
||
${allowed.map(t => `<option value="${t}">${typeOpts[t]}</option>`).join('')}
|
||
</select>
|
||
<span class="text-xs pt-1">#</span>
|
||
<input class="ber-pc-num text-xs w-10 border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" type="number" min="1" max="255" value="1" onchange="updateBerRow(this)">
|
||
<span class="text-xs pt-1">Qual:</span>
|
||
<select class="ber-pc-qual text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" onchange="updateBerRow(this)"></select>
|
||
</div>
|
||
<div class="flex gap-1 mb-1 flex-wrap items-center">
|
||
<span class="text-xs">IDs:</span>
|
||
<select class="ber-pc-src text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" onchange="updateBerRow(this)"></select>
|
||
<span class="text-xs">→</span>
|
||
<select class="ber-pc-dst text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" onchange="updateBerRow(this)"></select>
|
||
</div>
|
||
<div class="ber-pc-fields text-xs"></div>
|
||
</div>`;
|
||
}
|
||
|
||
function onBerSubTypeChange(sel) {
|
||
const body = sel.closest('.ber-sub-body') || sel.parentElement.querySelector('.ber-sub-body');
|
||
const st = sel.value;
|
||
|
||
if (st === 'action') {
|
||
body.innerHTML = `<select class="ber-action text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" onchange="updateBerRow(this)">
|
||
<option value="81">81 (Session indication)</option>
|
||
<option value="82">82 (Early response)</option>
|
||
</select>`;
|
||
} else if (st === 'custom') {
|
||
body.innerHTML = `<input class="ber-hex w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" placeholder="hex" oninput="updateBerRow(this)">`;
|
||
} else {
|
||
body.innerHTML = buildBerPcHtml();
|
||
const pc = body.querySelector('.ber-pc');
|
||
berPopulateDevices(pc);
|
||
onBerPcTypeChange(pc.querySelector('.ber-pc-type'));
|
||
}
|
||
updateBerRow(sel);
|
||
}
|
||
|
||
function berPopulateDevices(pc) {
|
||
const src = pc.querySelector('.ber-pc-src');
|
||
const dst = pc.querySelector('.ber-pc-dst');
|
||
BER_DEVICES.forEach(([v,l])=>{
|
||
const o1 = document.createElement('option'); o1.value=v; o1.textContent=v+' ('+l+')';
|
||
if(v==='81') o1.selected=true; src.appendChild(o1);
|
||
const o2 = document.createElement('option'); o2.value=v; o2.textContent=v+' ('+l+')';
|
||
if(v==='82') o2.selected=true; dst.appendChild(o2);
|
||
});
|
||
}
|
||
|
||
function onBerPcTypeChange(sel) {
|
||
const pc = sel.closest('.ber-pc');
|
||
const qual = pc.querySelector('.ber-pc-qual');
|
||
const fields = pc.querySelector('.ber-pc-fields');
|
||
const type = sel.value;
|
||
qual.innerHTML = (BER_QUAL[type]||[['00','']]).map(([v,l])=>`<option value="${v}">${v} ${l}</option>`).join('');
|
||
if (type === 'display') {
|
||
fields.innerHTML = `<div class="flex gap-1 items-center">
|
||
<input class="ber-pc-text flex-1 text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" placeholder="Text" oninput="updateBerRow(this)">
|
||
<select class="ber-pc-enc text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" onchange="updateBerRow(this)">
|
||
<option value="gsm7">GSM 7-bit</option>
|
||
<option value="ucs2">UCS2</option>
|
||
</select>
|
||
</div>
|
||
<div class="flex gap-1 items-center mt-1">
|
||
<span class="text-xs">Alpha ID:</span>
|
||
<input class="ber-pc-alpha flex-1 text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" placeholder="hex (tag 85)" oninput="updateBerRow(this)">
|
||
</div>
|
||
<div class="flex gap-1 items-center mt-1">
|
||
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400"><input type="checkbox" class="ber-pc-dur-enable" checked onchange="updateBerRow(this)"> Duration:</label>
|
||
<select class="ber-pc-dur-unit text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" onchange="updateBerRow(this)">
|
||
<option value="00">minutes</option>
|
||
<option value="01" selected>seconds</option>
|
||
<option value="02">tenths of seconds</option>
|
||
</select>
|
||
<input class="ber-pc-dur-val text-xs w-12 border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" type="number" min="1" max="255" value="30" onchange="updateBerRow(this)">
|
||
</div>`;
|
||
} else if (type === 'tone') {
|
||
fields.innerHTML = `<div class="flex gap-1 items-center">
|
||
<span class="text-xs">Tone:</span>
|
||
<select class="ber-pc-tone text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" onchange="updateBerRow(this)"></select>
|
||
</div>`;
|
||
const tsel = fields.querySelector('.ber-pc-tone');
|
||
BER_TONES.forEach(([v,l])=>{const o=document.createElement('option');o.value=v;o.textContent=v+' '+l;tsel.appendChild(o);});
|
||
} else if (type === 'refresh') {
|
||
fields.innerHTML = `<div class="flex gap-1 items-center">
|
||
<span class="text-xs">File list (tag 92):</span>
|
||
<input class="ber-pc-flist flex-1 text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300 font-mono" placeholder="hex (optional)" oninput="updateBerRow(this)">
|
||
</div>`;
|
||
}
|
||
updateBerRow(sel);
|
||
}
|
||
|
||
function updateBerRow(el) {
|
||
const row = el.closest('.ber-row');
|
||
const preview = row.querySelector('.ber-preview');
|
||
const val = genBerRowValue(row);
|
||
preview.textContent = val || '(empty)';
|
||
}
|
||
|
||
function genBerRowValue(row) {
|
||
const type = row.querySelector('.ber-type').value;
|
||
const tag = BER_TAGS[type];
|
||
if (type === 'error') {
|
||
return genErrorActionValue(row, tag);
|
||
}
|
||
if (type === 'chaining') {
|
||
return genScriptChainingValue(row, tag);
|
||
}
|
||
if (type === 'c-apdu') {
|
||
const hex = (row.querySelector('.ber-hex').value||'').replace(/[^0-9a-fA-F]/g,'');
|
||
if (!hex) return '';
|
||
return tag + berLenStr(hex.length/2) + hex;
|
||
}
|
||
const st = row.querySelector('.ber-subtype').value;
|
||
if (st === 'action') {
|
||
const a = row.querySelector('.ber-action').value;
|
||
return tag + berLenStr(1) + a;
|
||
}
|
||
if (st === 'custom') {
|
||
const hex = (row.querySelector('.ber-hex').value||'').replace(/[^0-9a-fA-F]/g,'');
|
||
if (!hex) return '';
|
||
return tag + berLenStr(hex.length/2) + hex;
|
||
}
|
||
return genBerPcValue(row, tag);
|
||
}
|
||
|
||
function genErrorActionValue(row, tag) {
|
||
const actionType = row.querySelector('.error-action-type');
|
||
if (!actionType) return '';
|
||
const body = row.querySelector('.error-action-body');
|
||
if (!body) return '';
|
||
|
||
switch (actionType.value) {
|
||
case 'proactive':
|
||
return genBerPcValue(body, tag);
|
||
case 'noaction':
|
||
return tag + '00';
|
||
case 'reference': {
|
||
const ref = (body.querySelector('.error-ref').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
const refNum = parseInt(ref, 16);
|
||
if (!ref || isNaN(refNum) || refNum < 0x01 || refNum > 0x7F) return '';
|
||
return tag + '01' + refNum.toString(16).padStart(2, '0').toUpperCase();
|
||
}
|
||
default: {
|
||
const hex = (body.querySelector('.ber-hex').value || '').replace(/[^0-9a-fA-F]/g, '');
|
||
if (!hex) return '';
|
||
return tag + berLenStr(hex.length / 2) + hex;
|
||
}
|
||
}
|
||
}
|
||
|
||
function buildScriptChainingRow(rowIdx, hint = '') {
|
||
return `
|
||
<div class="space-y-2">
|
||
<div class="flex gap-4 items-center flex-wrap">
|
||
<span class="text-xs font-medium text-gray-700 dark:text-slate-300">Position:</span>
|
||
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400">
|
||
<input type="radio" name="chaining-${rowIdx}" value="first" ${hint === 'intermediary' ? '' : 'checked'} onchange="updateChainingFlags(${rowIdx})">
|
||
First Script
|
||
</label>
|
||
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400">
|
||
<input type="radio" name="chaining-${rowIdx}" value="intermediary" ${hint === 'intermediary' ? 'checked' : ''} onchange="updateChainingFlags(${rowIdx})">
|
||
Intermediary Script
|
||
</label>
|
||
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400">
|
||
<input type="checkbox" name="chaining-${rowIdx}-last" value="last" ${hint === 'last' ? 'checked' : ''} onchange="updateChainingFlags(${rowIdx})">
|
||
Also Last Script
|
||
</label>
|
||
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400">
|
||
<input type="checkbox" name="chaining-${rowIdx}-keep" value="keep" onchange="updateChainingFlags(${rowIdx})">
|
||
Keep chaining info across reset (RFM)
|
||
</label>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function updateChainingFlags(rowIdx) {
|
||
const first = document.querySelector(`input[name="chaining-${rowIdx}"][value="first"]`);
|
||
const intermediate = document.querySelector(`input[name="chaining-${rowIdx}"][value="intermediary"]`);
|
||
const lastCheckbox = document.querySelector(`input[name="chaining-${rowIdx}-last"]`);
|
||
const keepCheckbox = document.querySelector(`input[name="chaining-${rowIdx}-keep"]`);
|
||
if (first && intermediate) {
|
||
if (first.checked) intermediate.checked = false;
|
||
if (intermediate.checked) first.checked = false;
|
||
}
|
||
if (lastCheckbox) lastCheckbox.disabled = !!(first && first.checked);
|
||
if (keepCheckbox) keepCheckbox.disabled = !!(first && !first.checked);
|
||
updateBerRow(document.querySelector(`[data-ber-idx="${rowIdx}"] .ber-type`));
|
||
}
|
||
|
||
function genScriptChainingValue(row, tag) {
|
||
const rowIdx = row.dataset.berIdx;
|
||
const firstRadio = row.querySelector(`input[name="chaining-${rowIdx}"][value="first"]`);
|
||
const intermediateRadio = row.querySelector(`input[name="chaining-${rowIdx}"][value="intermediary"]`);
|
||
const lastCheckbox = row.querySelector(`input[name="chaining-${rowIdx}-last"]`);
|
||
const keepCheckbox = row.querySelector(`input[name="chaining-${rowIdx}-keep"]`);
|
||
|
||
let flags = '';
|
||
if (firstRadio && firstRadio.checked) {
|
||
flags = (keepCheckbox && keepCheckbox.checked) ? '11' : '01';
|
||
} else if (intermediateRadio && intermediateRadio.checked) {
|
||
flags = (lastCheckbox && lastCheckbox.checked) ? '03' : '02';
|
||
}
|
||
if (!flags) return '';
|
||
|
||
return tag + '01' + flags;
|
||
}
|
||
|
||
function genBerPcValue(row, tag) {
|
||
const pc = row.querySelector('.ber-pc');
|
||
const pct = pc.querySelector('.ber-pc-type').value;
|
||
const cmdNum = parseInt(pc.querySelector('.ber-pc-num').value)||1;
|
||
const qual = pc.querySelector('.ber-pc-qual').value;
|
||
const src = pc.querySelector('.ber-pc-src').value;
|
||
const dst = pc.querySelector('.ber-pc-dst').value;
|
||
|
||
let value = '8103' + cmdNum.toString(16).padStart(2,'0').toUpperCase();
|
||
const typeBytes = {refresh:'01',display:'21',tone:'20'};
|
||
value += (typeBytes[pct]||'00') + qual;
|
||
value += '8202' + src + dst;
|
||
|
||
if (pct === 'display') {
|
||
const text = pc.querySelector('.ber-pc-text').value;
|
||
const enc = pc.querySelector('.ber-pc-enc').value;
|
||
if (text) {
|
||
if (enc === 'ucs2') {
|
||
const buf = [0x08];
|
||
for (const c of text) { const cd=c.charCodeAt(0); buf.push((cd>>8)&0xFF,cd&0xFF); }
|
||
value += '8D' + berLenStr(buf.length) + buf.map(b=>b.toString(16).padStart(2,'0').toUpperCase()).join('');
|
||
} else {
|
||
try {
|
||
const septets = gsm7TextToSeptets(text);
|
||
const packed = gsm7Encode(septets);
|
||
const inner = Array.from(packed).map(b=>b.toString(16).padStart(2,'0').toUpperCase());
|
||
value += '8D' + berLenStr(inner.length + 1) + '00' + inner.join('');
|
||
} catch(e) {
|
||
value += '8D' + berLenStr(text.length + 1) + '04' + text.split('').map(c=>c.charCodeAt(0).toString(16).padStart(2,'0').toUpperCase()).join('');
|
||
}
|
||
}
|
||
}
|
||
const durEnable = pc.querySelector('.ber-pc-dur-enable');
|
||
if (durEnable && durEnable.checked) {
|
||
const durVal = parseInt(pc.querySelector('.ber-pc-dur-val').value);
|
||
if (!isNaN(durVal) && durVal >= 1 && durVal <= 255) {
|
||
const durUnit = pc.querySelector('.ber-pc-dur-unit').value;
|
||
value += '84' + berLenStr(2) + durUnit + durVal.toString(16).padStart(2,'0').toUpperCase();
|
||
}
|
||
}
|
||
const alpha = (pc.querySelector('.ber-pc-alpha').value||'').replace(/[^0-9a-fA-F]/g,'');
|
||
if (alpha) value += '85' + berLenStr(alpha.length/2) + alpha;
|
||
} else if (pct === 'tone') {
|
||
const tone = pc.querySelector('.ber-pc-tone').value;
|
||
value += '8E01' + tone;
|
||
} else if (pct === 'refresh') {
|
||
const flist = (pc.querySelector('.ber-pc-flist').value||'').replace(/[^0-9a-fA-F]/g,'');
|
||
if (flist) value += '92' + berLenStr(flist.length/2) + flist;
|
||
}
|
||
|
||
return tag + berLenStr(value.length/2) + value;
|
||
}
|
||
|
||
function berLenStr(n) {
|
||
if (n < 128) return n.toString(16).padStart(2,'0').toUpperCase();
|
||
return '81' + n.toString(16).padStart(2,'0').toUpperCase();
|
||
}
|
||
|
||
function genBerTlv() {
|
||
const fmt = document.getElementById('ber-format').value;
|
||
let content = '';
|
||
document.querySelectorAll('.ber-row').forEach(row => {
|
||
const v = genBerRowValue(row);
|
||
if (v) content += v;
|
||
});
|
||
if (!content) { document.getElementById('ber-result').value = ''; return; }
|
||
let result;
|
||
if (fmt === 'AA') {
|
||
result = 'AA' + berLenStr(content.length/2) + content;
|
||
} else {
|
||
result = 'AE80' + content + '0000';
|
||
}
|
||
document.getElementById('ber-result').value = result;
|
||
document.getElementById('ber-pack-btn').disabled = false;
|
||
}
|
||
|
||
// ===== C-APDU Parser =====
|
||
const PARSE_INS = {
|
||
'A4': {name:'SELECT', data:'lc'},
|
||
'B0': {name:'READ BINARY', data:'le'},
|
||
'B2': {name:'READ RECORD(S)', data:'le'},
|
||
'DC': {name:'UPDATE RECORD', data:'lc'},
|
||
'D6': {name:'UPDATE BINARY', data:'lc'},
|
||
'0E': {name:'ERASE BINARY', data:'none'},
|
||
'44': {name:'ACTIVATE FILE', data:'none'},
|
||
'04': {name:'DEACTIVATE FILE', data:'none'},
|
||
'20': {name:'VERIFY', data:'lc'},
|
||
'24': {name:'CHANGE', data:'lc'},
|
||
'26': {name:'DISABLE PIN', data:'lc'},
|
||
'28': {name:'ENABLE PIN', data:'lc'},
|
||
'2C': {name:'UNBLOCK PIN', data:'lc'},
|
||
'E6': {name:'INSTALL', data:'lc'},
|
||
'E8': {name:'LOAD', data:'lc'},
|
||
'D4': {name:'RESIZE FILE', data:'lc'},
|
||
'E0': {name:'CREATE FILE', data:'lc'},
|
||
'E4': {name:'DELETE', data:'lc'},
|
||
'F2': {name:'GET STATUS', data:'lc'},
|
||
'CA': {name:'GET DATA', data:'none'},
|
||
'DA': {name:'PUT DATA', data:'lc'},
|
||
'CB': {name:'RETRIEVE DATA', data:'le'},
|
||
'DB': {name:'SET DATA', data:'lc'},
|
||
'A2': {name:'SEARCH RECORD', data:'lc'},
|
||
'32': {name:'INCREASE', data:'lc'},
|
||
'84': {name:'GET CHALLENGE', data:'le'},
|
||
'88': {name:'AUTHENTICATE', data:'lc'},
|
||
'89': {name:'AUTHENTICATE (odd INS)', data:'lc'},
|
||
'E2': {name:'STORE DATA', data:'lc'},
|
||
'F0': {name:'SET STATUS', data:'lc'},
|
||
'82': {name:'EXTERNAL AUTHENTICATE', data:'lc'},
|
||
'C0': {name:'GET RESPONSE', data:'le'},
|
||
'10': {name:'TERMINAL PROFILE', data:'lc'},
|
||
'C2': {name:'ENVELOPE', data:'lc'},
|
||
'12': {name:'FETCH', data:'le'},
|
||
'14': {name:'TERMINAL RESPONSE', data:'lc'},
|
||
'76': {name:'SUSPEND UICC', data:'lc'},
|
||
'AA': {name:'TERMINAL CAPABILITY', data:'lc'},
|
||
'73': {name:'MANAGE SECURE CHANNEL', data:'lc'},
|
||
'75': {name:'TRANSACT DATA', data:'lc'},
|
||
'78': {name:'GET IDENTITY', data:'le'},
|
||
'7A': {name:'EXCHANGE CAPABILITIES', data:'lc'},
|
||
'7C': {name:'MANAGE LSI', data:'lc'},
|
||
'50': {name:'INITIALIZE UPDATE', data:'lc'},
|
||
'D8': {name:'PUT KEY', data:'lc'},
|
||
'70': {name:'MANAGE CHANNEL', data:'none'},
|
||
};
|
||
|
||
function parseBerLen(hex, i) {
|
||
const b = parseInt(hex.substr(i, 2), 16);
|
||
if (b < 128) return {len: b, consumed: 2};
|
||
const n = b & 0x7F;
|
||
return {len: parseInt(hex.substr(i + 2, n * 2), 16), consumed: 2 + n * 2};
|
||
}
|
||
|
||
function parseTlvList(hex) {
|
||
const out = [];
|
||
let i = 0;
|
||
while (i + 4 <= hex.length) {
|
||
const tag = hex.substr(i, 2);
|
||
let tagBytes = 2;
|
||
if ((parseInt(tag, 16) & 0x1F) === 0x1F) {
|
||
tagBytes = 4;
|
||
if (i + 4 > hex.length) break;
|
||
}
|
||
const r = parseBerLen(hex, i + tagBytes);
|
||
const len = r.len;
|
||
const consumed = tagBytes + r.consumed;
|
||
if (i + consumed + len * 2 > hex.length) break;
|
||
const value = hex.substr(i + consumed, len * 2);
|
||
const raw = hex.substr(i, consumed + len * 2);
|
||
out.push({tag, length: len, value, raw});
|
||
i += consumed + len * 2;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function baseCompTag(tag) {
|
||
const b = parseInt(tag, 16);
|
||
return (b >= 0x80 && b <= 0x9F) ? (b & 0x7F).toString(16).padStart(2, '0').toUpperCase() : tag;
|
||
}
|
||
|
||
function decodePrivileges(hex) {
|
||
if (hex.length < 2) return '';
|
||
const names = [
|
||
['Security Domain', 'DAP Verification', 'Delegated Management', 'Card Lock', 'Card Terminate', 'Card Reset', 'CVM Management', 'Mandated DAP Verification'],
|
||
['Trusted Path', 'Authorized Management', 'Token Verification', 'Global Delete', 'Global Lock', 'Global Registry', 'Final Application', 'Global Service'],
|
||
['Receipt Generation', 'Ciphered Load File Data Block', 'Contactless Activation', 'Contactless Self-Activation'],
|
||
];
|
||
const parts = [];
|
||
for (let i = 0; i < Math.min(names.length, hex.length / 2); i++) {
|
||
const b = parseInt(hex.substr(i * 2, 2), 16);
|
||
for (let bit = 7; bit >= 0; bit--) {
|
||
const idx = 7 - bit;
|
||
if (b & (1 << bit) && names[i][idx]) parts.push(names[i][idx]);
|
||
}
|
||
}
|
||
return parts.join(', ') || 'None';
|
||
}
|
||
|
||
function gsm7Decode(octets) {
|
||
const bits = [];
|
||
for (const b of octets) {
|
||
for (let j = 0; j < 8; j++) bits.push((b >> j) & 1);
|
||
}
|
||
let out = '';
|
||
let escape = false;
|
||
for (let i = 0; i + 7 <= bits.length; i += 7) {
|
||
let septet = 0;
|
||
for (let j = 0; j < 7; j++) septet |= bits[i + j] << j;
|
||
if (escape) {
|
||
const ext = Object.entries(GSM7_EXT_MAP).find(([,v]) => v === septet);
|
||
out += ext ? ext[0] : '?';
|
||
escape = false;
|
||
} else if (septet === 0x1B) {
|
||
escape = true;
|
||
} else {
|
||
out += septet < GSM7_ALPHABET.length ? GSM7_ALPHABET[septet] : '?';
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function tryUcs2(hex) {
|
||
if (hex.length < 4 || hex.length % 4 !== 0) return null;
|
||
let out = '';
|
||
for (let i = 0; i < hex.length; i += 4) {
|
||
const cp = parseInt(hex.substr(i, 4), 16);
|
||
if (cp < 0x20 || cp > 0x10FFFF) return null;
|
||
out += String.fromCodePoint(cp);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function uniformHighBytes(hex) {
|
||
const first = hex.substr(0, 2);
|
||
for (let i = 4; i < hex.length; i += 4) {
|
||
if (hex.substr(i, 2) !== first) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function decodeTextData(hex) {
|
||
if (!hex || hex.length < 2) return '?';
|
||
const dcs = parseInt(hex.substr(0, 2), 16);
|
||
const rest = hex.substr(2);
|
||
if (dcs === 0x08) {
|
||
const ucs2 = tryUcs2(rest);
|
||
return ucs2 !== null ? ucs2 : '?';
|
||
}
|
||
if (dcs === 0x00 || dcs === 0x04) {
|
||
const full = tryUcs2(hex);
|
||
if (full !== null && hex.length >= 8 && uniformHighBytes(hex)) return full;
|
||
try {
|
||
const bytes = new Uint8Array(rest.length / 2);
|
||
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(rest.substr(i * 2, 2), 16);
|
||
if (dcs === 0x00) {
|
||
const gsm = gsm7Decode(bytes);
|
||
if (gsm.length > 0) return gsm;
|
||
} else {
|
||
let s = '';
|
||
for (const b of bytes) { if (b < 0x20 || b > 0x7E) return '?'; s += String.fromCharCode(b); }
|
||
if (s.length > 0) return s;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
if (hex.length >= 4 && hex.length % 4 === 0) {
|
||
let allPrintable = true;
|
||
let ucs2 = '';
|
||
for (let i = 0; i < hex.length; i += 4) {
|
||
const cp = parseInt(hex.substr(i, 4), 16);
|
||
if (cp < 0x20 || cp > 0x10FFFF) { allPrintable = false; break; }
|
||
ucs2 += String.fromCodePoint(cp);
|
||
}
|
||
if (allPrintable && ucs2.length > 0) return ucs2;
|
||
}
|
||
try {
|
||
const bytes = new Uint8Array(hex.length / 2);
|
||
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||
const gsm = gsm7Decode(bytes);
|
||
if (gsm.length > 0 && !gsm.includes('\uFFFD')) return gsm;
|
||
} catch (e) {}
|
||
return '?';
|
||
}
|
||
|
||
function describeToolkit(hex) {
|
||
const children = [];
|
||
if (!hex || hex.length < 2) return children;
|
||
let isUicc = true;
|
||
if (hex.length >= 2) {
|
||
const first = parseInt(hex.substr(0, 2), 16);
|
||
if (first <= 0x0F) isUicc = false;
|
||
}
|
||
let i = 0;
|
||
if (isUicc) {
|
||
children.push({label:'Priority', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
|
||
children.push({label:'Timers', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
|
||
children.push({label:'Text length', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
|
||
const menus = parseInt(hex.substr(i,2), 16); i += 2;
|
||
children.push({label:'Menu entries', hex:hex.substr(i-2,2), desc:menus.toString()});
|
||
for (let m = 0; m < menus && i + 4 <= hex.length; m++) {
|
||
children.push({label:'Menu pair '+(m+1), hex:hex.substr(i,4), desc:'Pos ' + parseInt(hex.substr(i,2),16) + ', ID ' + hex.substr(i+2,2)});
|
||
i += 4;
|
||
}
|
||
children.push({label:'Channels', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
|
||
if (i + 2 <= hex.length) {
|
||
const mslLen = parseInt(hex.substr(i,2), 16); i += 2;
|
||
if (mslLen > 0 && i + mslLen * 2 <= hex.length) {
|
||
children.push({label:'MSL', hex:hex.substr(i-2, 2+mslLen*2), desc:'Length ' + mslLen + ' value ' + hex.substr(i, mslLen*2)});
|
||
i += mslLen * 2;
|
||
} else {
|
||
children.push({label:'MSL', hex:hex.substr(i-2,2), desc:'No MSL'});
|
||
}
|
||
}
|
||
if (i + 2 <= hex.length) {
|
||
const tarLen = parseInt(hex.substr(i,2), 16); i += 2;
|
||
if (tarLen > 0 && i + tarLen * 2 <= hex.length) {
|
||
children.push({label:'TAR', hex:hex.substr(i-2, 2+tarLen*2), desc:hex.substr(i, tarLen*2)});
|
||
i += tarLen * 2;
|
||
} else {
|
||
children.push({label:'TAR', hex:hex.substr(i-2,2), desc:'No TAR'});
|
||
}
|
||
}
|
||
if (i + 2 <= hex.length) {
|
||
children.push({label:'Max services', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()});
|
||
}
|
||
} else {
|
||
const adLen = parseInt(hex.substr(i,2), 16); i += 2;
|
||
if (adLen > 0 && i + adLen * 2 <= hex.length) {
|
||
children.push({label:'Access Domain', hex:hex.substr(i-2, 2+adLen*2), desc:hex.substr(i, adLen*2)});
|
||
i += adLen * 2;
|
||
} else {
|
||
children.push({label:'Access Domain', hex:hex.substr(i-2,2), desc:'Empty'});
|
||
}
|
||
children.push({label:'Priority', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
|
||
children.push({label:'Timers', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
|
||
children.push({label:'Text length', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
|
||
const menus = parseInt(hex.substr(i,2), 16); i += 2;
|
||
children.push({label:'Menu entries', hex:hex.substr(i-2,2), desc:menus.toString()});
|
||
for (let m = 0; m < menus && i + 4 <= hex.length; m++) {
|
||
children.push({label:'Menu pair '+(m+1), hex:hex.substr(i,4), desc:'Pos ' + parseInt(hex.substr(i,2),16) + ', ID ' + hex.substr(i+2,2)});
|
||
i += 4;
|
||
}
|
||
children.push({label:'Channels', hex:hex.substr(i,2), desc:parseInt(hex.substr(i,2),16).toString()}); i += 2;
|
||
if (i + 2 <= hex.length) {
|
||
const mslLen = parseInt(hex.substr(i,2), 16); i += 2;
|
||
if (mslLen > 0 && i + mslLen * 2 <= hex.length) {
|
||
children.push({label:'MSL', hex:hex.substr(i-2, 2+mslLen*2), desc:'Length ' + mslLen + ' value ' + hex.substr(i, mslLen*2)});
|
||
i += mslLen * 2;
|
||
} else {
|
||
children.push({label:'MSL', hex:hex.substr(i-2,2), desc:'No MSL'});
|
||
}
|
||
}
|
||
if (i + 2 <= hex.length) {
|
||
const tarLen = parseInt(hex.substr(i,2), 16); i += 2;
|
||
if (tarLen > 0 && i + tarLen * 2 <= hex.length) {
|
||
children.push({label:'TAR', hex:hex.substr(i-2, 2+tarLen*2), desc:hex.substr(i, tarLen*2)});
|
||
i += tarLen * 2;
|
||
} else {
|
||
children.push({label:'TAR', hex:hex.substr(i-2,2), desc:'No TAR'});
|
||
}
|
||
}
|
||
}
|
||
return children;
|
||
}
|
||
|
||
function describeProactiveCommand(tlvs) {
|
||
const children = [];
|
||
for (const tlv of tlvs) {
|
||
const base = baseCompTag(tlv.tag);
|
||
let desc = '';
|
||
if (base === '01') {
|
||
const cmdNum = parseInt(tlv.value.substr(0, 2), 16);
|
||
const typeByte = tlv.value.substr(2, 2);
|
||
const qual = tlv.value.substr(4, 2);
|
||
const typeKey = { '01': 'refresh', '21': 'display', '20': 'tone' }[typeByte];
|
||
const cmdName = { refresh: 'REFRESH', display: 'DISPLAY TEXT', tone: 'PLAY TONE' }[typeKey];
|
||
let typeName = 'Command #' + cmdNum;
|
||
if (typeKey && cmdName) {
|
||
const arr = BER_QUAL[typeKey];
|
||
const found = arr ? arr.find(([v]) => v === qual) : null;
|
||
if (found) typeName += ', ' + cmdName + ' qual ' + qual + ' ' + found[1];
|
||
else typeName += ', ' + cmdName + ' qual ' + qual;
|
||
} else {
|
||
typeName += ', type ' + typeByte + ' qual ' + qual;
|
||
}
|
||
desc = typeName;
|
||
} else if (base === '02') {
|
||
const src = tlv.value.substr(0, 2);
|
||
const dst = tlv.value.substr(2, 2);
|
||
const srcName = (BER_DEVICES.find(([v]) => v === src) || [])[1] || src;
|
||
const dstName = (BER_DEVICES.find(([v]) => v === dst) || [])[1] || dst;
|
||
desc = 'from ' + srcName + ' to ' + dstName;
|
||
} else if (base === '0D' || base === '8D') {
|
||
desc = decodeTextData(tlv.value);
|
||
} else if (base === '04' || base === '84') {
|
||
if (tlv.value.length >= 4) {
|
||
const unit = ['minutes','seconds','tenths'][parseInt(tlv.value.substr(0,2), 16)] || '?';
|
||
const val = parseInt(tlv.value.substr(2,2), 16);
|
||
desc = val + ' ' + unit;
|
||
}
|
||
} else if (base === '05' || base === '85') {
|
||
desc = 'Alpha ID: ' + tlv.value;
|
||
} else if (base === '0E' || base === '8E') {
|
||
const tone = tlv.value.substr(0, 2);
|
||
const toneName = (BER_TONES.find(([v]) => v === tone) || [])[1] || tone;
|
||
desc = 'Tone: ' + toneName;
|
||
} else if (base === '12' || base === '92') {
|
||
desc = 'File list: ' + tlv.value;
|
||
} else {
|
||
desc = tlv.value || '(empty)';
|
||
}
|
||
children.push({label:'TLV ' + tlv.tag + ' (' + baseCompTag(tlv.tag) + ')', hex:tlv.raw, desc});
|
||
}
|
||
return children;
|
||
}
|
||
|
||
function parseBerScript(hex) {
|
||
const rows = [];
|
||
let i = 0;
|
||
if (hex.startsWith('AA')) {
|
||
const r = parseBerLen(hex, 2);
|
||
i = 2 + r.consumed;
|
||
} else if (hex.startsWith('AE80')) {
|
||
i = 4;
|
||
} else {
|
||
return rows;
|
||
}
|
||
while (i < hex.length) {
|
||
if (hex.startsWith('0000', i)) break;
|
||
if (i + 4 > hex.length) break;
|
||
const tag = hex.substr(i, 2);
|
||
const r = parseBerLen(hex, i + 2);
|
||
const len = r.len;
|
||
const consumed = 2 + r.consumed;
|
||
if (i + consumed + len * 2 > hex.length) break;
|
||
const value = hex.substr(i + consumed, len * 2);
|
||
const raw = hex.substr(i, consumed + len * 2);
|
||
let label = 'Row ' + (rows.length + 1);
|
||
let children = [];
|
||
if (tag === '22') {
|
||
label = 'C-APDU';
|
||
children = parseCompactApdus(value);
|
||
} else if (tag === '81') {
|
||
label = 'Immediate Action';
|
||
children = parseActionRow(value, '81');
|
||
} else if (tag === '82') {
|
||
label = 'Error Action';
|
||
children = parseActionRow(value, '82');
|
||
} else if (tag === '83') {
|
||
label = 'Script Chaining';
|
||
children = [{label:'Flags', hex:value.substr(0,2), desc:parseChainingFlags(value.substr(0,2))}];
|
||
if (value.length > 2) children.push({label:'Script ID', hex:value.substr(2), desc:value.substr(2)});
|
||
} else {
|
||
label = 'Row ' + (rows.length + 1) + ' (tag ' + tag + ')';
|
||
}
|
||
rows.push({label, hex:raw, desc:'', children});
|
||
i += consumed + len * 2;
|
||
}
|
||
if (i < hex.length && !hex.startsWith('0000', i)) {
|
||
rows.push({label:'Trailing (unrecognized)', hex:hex.substr(i), desc:hex.substr(i)});
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function parseChainingFlags(hex) {
|
||
if (hex === '01') return 'First';
|
||
if (hex === '11') return 'First, keep across reset';
|
||
if (hex === '02') return 'Intermediary (more to follow)';
|
||
if (hex === '03') return 'Intermediary (last)';
|
||
return hex;
|
||
}
|
||
|
||
function parseActionRow(hex, tag) {
|
||
if (!hex || hex === '00') return [{label:'No action', hex:hex||'', desc:tag === '82' ? 'No action (82 00)' : ''}];
|
||
if (hex.length === 2) {
|
||
const val = parseInt(hex, 16);
|
||
if (val >= 0x01 && val <= 0x7F) {
|
||
return [{label:'Reference to EFRMA record', hex, desc:'Record 0x' + hex}];
|
||
}
|
||
const names = {'81':'Proactive session indication','82':'Early response'};
|
||
return [{label:'Action indicator', hex, desc:names[hex] || 'Value ' + hex}];
|
||
}
|
||
const tlvs = parseTlvList(hex);
|
||
if (tlvs.length > 0) {
|
||
const children = describeProactiveCommand(tlvs);
|
||
return [{label:'Proactive command', hex, desc:'', children}];
|
||
}
|
||
return [{label:'Raw', hex, desc:hex}];
|
||
}
|
||
|
||
function describeP1P2(ins, p1, p2) {
|
||
const v1 = parseInt(p1, 16);
|
||
const v2 = parseInt(p2, 16);
|
||
let d1 = '', d2 = '';
|
||
if (ins === 'A4') {
|
||
d1 = {'00':'by FID','01':'child DF','03':'parent DF','04':'by DF name (AID)','08':'path from MF','09':'path from current DF'}[p1] || '';
|
||
const resp = (v2 >> 2) & 0x07;
|
||
if (resp === 0x01) d2 = 'return FCP template';
|
||
else if (resp === 0x03) d2 = 'no response data';
|
||
if (v1 === 0x04) {
|
||
const occName = {'00':'first or only occurrence','01':'last occurrence','10':'next occurrence','11':'previous occurrence'}[(v2 & 0x03).toString(2).padStart(2,'0')];
|
||
if (occName) d2 += (d2 ? ', ' : '') + occName;
|
||
}
|
||
} else if (ins === 'B0' || ins === 'D6') {
|
||
if (v1 & 0x80) d1 = 'SFI mode, SFI ' + (v1 & 0x1F);
|
||
else d1 = 'offset high';
|
||
d2 = 'offset low';
|
||
} else if (ins === 'B2' || ins === 'DC') {
|
||
const modeKey = (v2 & 0x07).toString(16).padStart(2, '0');
|
||
d2 = {'02':'next record','03':'previous record','04':'absolute/current'}[modeKey] || '';
|
||
const sfi = (v2 >> 3) & 0x1F;
|
||
if (sfi) d2 += (d2 ? ', ' : '') + 'SFI ' + sfi;
|
||
if ((v2 & 0x07) === 0x02 || (v2 & 0x07) === 0x03) d1 = v1 === 0 ? 'ignored' : 'record # (ignored)';
|
||
else if (v1 > 0) d1 = 'record #' + v1;
|
||
} else if (ins === 'F2') {
|
||
d1 = {'80':'ISD','40':'Applications incl. SSDs','20':'Executable Load Files','10':'ELFs and Modules'}[p1] || '';
|
||
d2 = {'02':'first/all, TLV format','03':'next, TLV format','00':'first/all, deprecated raw','01':'next, deprecated raw'}[p2] || '';
|
||
} else if (ins === 'E6') {
|
||
const roles = [];
|
||
if (v1 & 0x02) roles.push('for load');
|
||
if (v1 & 0x04) roles.push('for install');
|
||
if (v1 & 0x08) roles.push('for make selectable');
|
||
if (v1 & 0x10) roles.push('for extradition');
|
||
if (v1 & 0x20) roles.push('for personalization');
|
||
if (v1 & 0x40) roles.push('for registry update');
|
||
if (roles.length) d1 = roles.join(' + ');
|
||
if (v1 & 0x80) d1 = 'more INSTALL commands follow; ' + d1;
|
||
} else if (ins === 'F0') {
|
||
d1 = {'80':'ISD','40':'Application or SSD','60':'SD + associated apps'}[p1] || '';
|
||
if (p1 === '80') {
|
||
d2 = {'01':'OP_READY','07':'INITIALIZED','0F':'SECURED','7F':'CARD_LOCKED','FF':'TERMINATED'}[p2] || '';
|
||
} else {
|
||
d2 = {'00':'unlock (previous state)','80':'LOCKED'}[p2] || '';
|
||
}
|
||
} else if (ins === '20' || ins === '24') {
|
||
if (v2 >= 1 && v2 <= 8) d2 = 'PIN ref ' + v2;
|
||
}
|
||
return {d1, d2};
|
||
}
|
||
|
||
const RAM_PARAM_TAGS = {
|
||
'C9': 'Application Specific Parameters',
|
||
'C6': 'Volatile memory quotas',
|
||
'C7': 'Non-volatile memory quotas',
|
||
'C8': 'Non-volatile memory quotas (persistent)',
|
||
'CB': 'Global Services parameters',
|
||
'CF': 'Implicit selection parameters',
|
||
'B6': 'Token CRT',
|
||
'EF': 'System Specific Parameters (SIM)',
|
||
'EA': 'System Specific Parameters (UICC)',
|
||
};
|
||
|
||
function readLvField(hex, i) {
|
||
const r = parseBerLen(hex, i);
|
||
const total = r.consumed + r.len * 2;
|
||
if (i + total > hex.length) return null;
|
||
return {value: hex.substr(i + r.consumed, r.len * 2), consumed: total, lenHex: hex.substr(i + 1, r.consumed - 2)};
|
||
}
|
||
|
||
function walkInstallParams(value) {
|
||
const tlvs = parseTlvList(value);
|
||
if (tlvs.length === 0) return null;
|
||
return tlvs.map(tlv => {
|
||
const name = RAM_PARAM_TAGS[tlv.tag];
|
||
let children = null;
|
||
if ((tlv.tag === 'EA' || tlv.tag === 'EF') && tlv.value.length >= 4) {
|
||
children = parseTlvList(tlv.value).map(inner => ({
|
||
label: 'TLV ' + inner.tag + (inner.tag === '80' ? ' (Toolkit parameters)' : inner.tag === 'CA' ? ' (SIM Toolkit parameters)' : ''),
|
||
hex: inner.raw, desc: inner.value || '(empty)',
|
||
}));
|
||
if (!children.length) children = [{label:'Value', hex:tlv.value, desc:tlv.value}];
|
||
}
|
||
const node = {label: name ? 'Tag ' + tlv.tag + ' — ' + name : 'TLV ' + tlv.tag, hex: tlv.raw, desc: tlv.value || '(empty)'};
|
||
if (children) node.children = children;
|
||
return node;
|
||
});
|
||
}
|
||
|
||
function describeInstallDataLv(p1val, data) {
|
||
let fields;
|
||
if (p1val & 0x40) {
|
||
fields = ['SD AID', '(reserved)', 'Application AID', 'Privileges', 'RU parameters', 'RU token'];
|
||
} else if (p1val & 0x20) {
|
||
fields = ['(reserved)', '(reserved)', 'Application AID', 'Privileges', 'Personalization parameters', 'Personalization token'];
|
||
} else if (p1val & 0x10) {
|
||
fields = ['Target SD AID', '(reserved)', 'Application/ELF AID', '(reserved)', 'Extradition parameters', 'Extradition token'];
|
||
} else if ((p1val & 0x08) && !(p1val & 0x04)) {
|
||
fields = ['(reserved)', '(reserved)', 'Application AID', 'Privileges', 'MS parameters', 'MS token'];
|
||
} else if (p1val & 0x04) {
|
||
fields = ['ELF AID', 'Module AID', 'Application AID', 'Privileges', 'Install parameters', 'Install token'];
|
||
} else if (p1val & 0x02) {
|
||
fields = ['Load File AID', 'SD AID', 'LFDB hash', 'Load parameters', 'Load token'];
|
||
} else {
|
||
return null;
|
||
}
|
||
const nodes = [];
|
||
let i = 0;
|
||
for (const name of fields) {
|
||
if (i >= data.length) return null;
|
||
const f = readLvField(data, i);
|
||
if (!f) return null;
|
||
const node = {label: name, hex: f.lenHex + f.value, desc:''};
|
||
if (name === 'Privileges') node.desc = decodePrivileges(f.value);
|
||
else if (name.endsWith('parameters')) {
|
||
const walked = walkInstallParams(f.value);
|
||
node.children = walked || [{label:'Value', hex:f.value, desc:f.value}];
|
||
node.desc = f.value ? '' : '(empty)';
|
||
} else if (f.value) {
|
||
node.desc = f.value;
|
||
} else {
|
||
node.desc = '(empty)';
|
||
}
|
||
nodes.push(node);
|
||
i += f.consumed;
|
||
}
|
||
if (i !== data.length) return null;
|
||
return nodes;
|
||
}
|
||
|
||
function parseOneApdu(hex) {
|
||
const isActivateDeactivate = hex.length >= 4 && (hex.substr(2, 2) === '44' || hex.substr(2, 2) === '04');
|
||
if (hex.length < 10 && !(isActivateDeactivate && hex.length === 8)) return null;
|
||
const cla = hex.substr(0, 2);
|
||
const ins = hex.substr(2, 2);
|
||
const p1 = hex.substr(4, 2);
|
||
const p2 = hex.substr(6, 2);
|
||
const p3 = hex.substr(8, 2);
|
||
const insInfo = PARSE_INS[ins];
|
||
const labels = describeP1P2(ins, p1, p2);
|
||
const children = [
|
||
{label:'CLA', hex:cla, desc:cla === '80' ? 'GP/RAM' : cla === 'A0' ? 'SIM' : parseInt(cla,16) >= 0x84 && parseInt(cla,16) <= 0x87 ? 'GlobalPlatform (secure messaging)' : 'UICC'},
|
||
{label:'INS', hex:ins, desc:insInfo ? insInfo.name : 'Unknown'},
|
||
{label:'P1', hex:p1, desc:labels.d1},
|
||
{label:'P2', hex:p2, desc:labels.d2},
|
||
];
|
||
if (isActivateDeactivate && hex.length === 8) {
|
||
return {label:'APDU (case 1)', hex:hex, desc:'no data, no Le', children};
|
||
}
|
||
if (ins === 'CA') {
|
||
if (hex.length === 10) {
|
||
children.push({label:'Le', hex:p3, desc:'Expected length: ' + parseInt(p3, 16)});
|
||
return {label:'APDU', hex:hex, desc:'', children};
|
||
}
|
||
const lc = parseInt(p3, 16);
|
||
children.push({label:'Lc', hex:p3, desc:'Data length: ' + lc});
|
||
const data = hex.substr(10, lc * 2);
|
||
children.push({label:'Data', hex:data, desc:data.startsWith('5C') && data.length >= 4 ? 'Tag list: ' + data : data});
|
||
const restLen = hex.length - (10 + lc * 2);
|
||
if (restLen >= 2) children.push({label:'Le', hex:hex.substr(10 + lc * 2, 2), desc:'Expected length: ' + parseInt(hex.substr(10 + lc * 2, 2), 16)});
|
||
return {label:'APDU', hex:hex, desc:'', children};
|
||
}
|
||
if (insInfo && insInfo.data === 'le') {
|
||
children.push({label:'Le', hex:p3, desc:'Expected length: ' + parseInt(p3, 16)});
|
||
} else if (isActivateDeactivate && hex.length >= 10) {
|
||
if (parseInt(p3, 16) === 0 && hex.length === 10) {
|
||
children.push({label:'P3', hex:p3, desc:'empty Lc (legacy form)'});
|
||
} else {
|
||
const alc = parseInt(p3, 16);
|
||
children.push({label:'Lc', hex:p3, desc:'Data length: ' + alc});
|
||
if (alc > 0 && hex.length >= 10 + alc * 2) {
|
||
const adata = hex.substr(10, alc * 2);
|
||
const adesc = adata.length === 4 ? 'FID ' + adata : (adata.length > 4 && adata.length % 2 === 0 ? 'Path ' + adata : adata);
|
||
children.push({label:'Data', hex:adata, desc:adesc});
|
||
}
|
||
}
|
||
} else if (insInfo && insInfo.data === 'none') {
|
||
children.push({label:'P3', hex:p3, desc:'No data'});
|
||
} else {
|
||
const lc = parseInt(p3, 16);
|
||
children.push({label:'Lc', hex:p3, desc:'Data length: ' + lc});
|
||
if (lc > 0 && hex.length >= 10 + lc * 2) {
|
||
const data = hex.substr(10, lc * 2);
|
||
let dataDesc = data;
|
||
let dataChildren = null;
|
||
if (ins === 'E6') {
|
||
const lv = describeInstallDataLv(parseInt(p1, 16), data);
|
||
if (lv) { dataDesc = ''; dataChildren = lv; }
|
||
else dataDesc = data;
|
||
} else if (ins === 'F0') {
|
||
if (p1 === '80') dataDesc = 'ignored for ISD';
|
||
else if (data.startsWith('4F') && data.length >= 4 && 2 + 2 + parseInt(data.substr(2, 2), 16) * 2 === data.length) dataDesc = 'Legacy 4F-TLV AID: ' + data.substr(4);
|
||
else dataDesc = 'AID (raw): ' + data;
|
||
}
|
||
const node = {label:'Data', hex:data, desc:dataDesc};
|
||
if (dataChildren) node.children = dataChildren;
|
||
children.push(node);
|
||
if (hex.length > 10 + lc * 2) {
|
||
const le = hex.substr(10 + lc * 2, 2);
|
||
children.push({label:'Le', hex:le, desc:'Expected response length: ' + parseInt(le, 16)});
|
||
}
|
||
}
|
||
}
|
||
const totalLen = hex.length;
|
||
return {label:'APDU', hex:hex.substr(0, totalLen), desc:'', children};
|
||
}
|
||
|
||
function parseCompactApdus(hex) {
|
||
const apdus = [];
|
||
let i = 0;
|
||
let prevCla = '';
|
||
while (i + 4 <= hex.length) {
|
||
const first = hex.substr(i, 2);
|
||
const second = hex.substr(i + 2, 2);
|
||
let fullCla = '';
|
||
let fullIns = '';
|
||
let consumed = 0;
|
||
const fb = parseInt(first, 16);
|
||
const knownCla = first === '80' || first === 'A0' || first === '00' || (fb >= 0x84 && fb <= 0x87);
|
||
if (knownCla && PARSE_INS[second]) {
|
||
fullCla = first;
|
||
fullIns = second;
|
||
consumed = 2;
|
||
} else if (PARSE_INS[first] && prevCla && i > 0) {
|
||
fullCla = prevCla;
|
||
fullIns = first;
|
||
consumed = 0;
|
||
}
|
||
if (fullCla) {
|
||
const p3Byte = parseInt(hex.substr(i + 6 + consumed, 2), 16);
|
||
const dataType = PARSE_INS[fullIns].data;
|
||
let apduLen = 8;
|
||
if (fullIns === 'CA') {
|
||
const remAvail = hex.length - (i + consumed);
|
||
if (p3Byte > 0 && remAvail >= p3Byte * 2 + 2) apduLen += p3Byte * 2 + 2;
|
||
} else if (fullIns === '44' || fullIns === '04') {
|
||
const avail = hex.length - i;
|
||
const p3Hex = avail >= consumed + 8 ? hex.substr(i + 6 + consumed, 2) : null;
|
||
if (p3Hex !== null) {
|
||
if (p3Byte > 0) apduLen += p3Byte * 2;
|
||
} else if (avail >= consumed + 6) {
|
||
apduLen -= 2;
|
||
}
|
||
} else if (dataType === 'lc') {
|
||
apduLen += p3Byte * 2;
|
||
}
|
||
if (i + consumed + apduLen <= hex.length) {
|
||
const apduHex = fullCla + hex.substr(i + consumed, apduLen);
|
||
const parsed = parseOneApdu(apduHex);
|
||
if (parsed) {
|
||
prevCla = fullCla;
|
||
parsed.hex = hex.substr(i, consumed + apduLen);
|
||
parsed.label = consumed === 0 ? 'APDU (implied CLA)' : 'APDU';
|
||
apdus.push(parsed);
|
||
i += consumed + apduLen;
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
apdus.push({label:'Unknown', hex:hex.substr(i, 2), desc:'Unrecognized'});
|
||
i += 2;
|
||
}
|
||
if (i < hex.length) {
|
||
if (apdus.length > 0 && hex.length - i === 2) {
|
||
const leHex = hex.substr(i);
|
||
apdus.push({label:'Le', hex:leHex, desc:'Expected response length: ' + parseInt(leHex, 16)});
|
||
} else {
|
||
apdus.push({label:'Trailing (unrecognized)', hex:hex.substr(i), desc:hex.substr(i)});
|
||
}
|
||
}
|
||
return apdus;
|
||
}
|
||
|
||
function parseHexTree(hex) {
|
||
const s = hex.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (s.length % 2 !== 0) return {label:'Error', hex:'', desc:'Odd hex length'};
|
||
if (s.startsWith('AA') && s.length >= 4) {
|
||
const rows = parseBerScript(s);
|
||
return {label:'Expanded Script (AA)', hex:s, desc:'', children:rows};
|
||
}
|
||
if (s.startsWith('AE80')) {
|
||
const rows = parseBerScript(s);
|
||
return {label:'Expanded Script (AE80)', hex:s, desc:'', children:rows};
|
||
}
|
||
const apdus = parseCompactApdus(s);
|
||
return {label:'Compact C-APDU chain', hex:s, desc:'', children:apdus};
|
||
}
|
||
|
||
function renderTree(node, container) {
|
||
const div = document.createElement('div');
|
||
div.className = 'ml-3 mb-1';
|
||
const header = document.createElement('div');
|
||
header.className = 'flex gap-2 items-start py-0.5 cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-700 rounded';
|
||
if (node.children && node.children.length > 0) {
|
||
const toggle = document.createElement('span');
|
||
toggle.textContent = '▾';
|
||
toggle.className = 'text-xs text-gray-400 w-4 flex-shrink-0';
|
||
header.appendChild(toggle);
|
||
header.onclick = () => {
|
||
const body = div.querySelector('.tree-body');
|
||
if (body) {
|
||
body.classList.toggle('hidden');
|
||
toggle.textContent = body.classList.contains('hidden') ? '▸' : '▾';
|
||
}
|
||
};
|
||
} else {
|
||
const dot = document.createElement('span');
|
||
dot.textContent = '·';
|
||
dot.className = 'text-xs text-gray-300 w-4 flex-shrink-0';
|
||
header.appendChild(dot);
|
||
}
|
||
const label = document.createElement('span');
|
||
label.className = 'text-sm font-medium text-gray-700 dark:text-slate-300';
|
||
label.textContent = node.label;
|
||
header.appendChild(label);
|
||
if (node.hex) {
|
||
const hex = document.createElement('span');
|
||
hex.className = 'text-sm font-mono text-indigo-600 dark:text-indigo-400 ml-2';
|
||
hex.textContent = node.hex;
|
||
header.appendChild(hex);
|
||
}
|
||
if (node.desc) {
|
||
const desc = document.createElement('span');
|
||
desc.className = 'text-sm text-gray-600 dark:text-slate-400 ml-2';
|
||
desc.textContent = node.desc;
|
||
header.appendChild(desc);
|
||
}
|
||
div.appendChild(header);
|
||
if (node.children && node.children.length > 0) {
|
||
const body = document.createElement('div');
|
||
body.className = 'tree-body';
|
||
div.appendChild(body);
|
||
for (const child of node.children) {
|
||
renderTree(child, body);
|
||
}
|
||
}
|
||
container.appendChild(div);
|
||
}
|
||
|
||
function parseHex() {
|
||
const input = document.getElementById('parse-input');
|
||
const output = document.getElementById('parse-output');
|
||
const hex = input.value.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (!hex || hex.length % 2 !== 0) {
|
||
output.innerHTML = '<div class="text-xs text-red-500">Invalid hex input</div>';
|
||
return;
|
||
}
|
||
const tree = parseHexTree(hex);
|
||
output.innerHTML = '';
|
||
renderTree(tree, output);
|
||
}
|
||
|
||
// ===== Response Parser =====
|
||
const SW_MAP = {
|
||
generic: {
|
||
'9000': 'Normal ending of command',
|
||
'61XX': 'Send GET RESPONSE (Le = SW2)',
|
||
'6CXX': 'Repeat command with Le = SW2',
|
||
// TS 51.011 §9.4 SIM-specific families
|
||
'9200': 'Command successful after internal update retry routine',
|
||
'9240': 'Memory problem',
|
||
'9400': 'No EF selected',
|
||
'9402': 'Out of range (invalid address)',
|
||
'9404': 'File ID not found / pattern not found',
|
||
'9408': 'File inconsistent with the command',
|
||
'9802': 'No CHV initialized',
|
||
'9804': 'Access condition not fulfilled / CHV verification failed, attempts left / authentication failed',
|
||
'9808': 'In contradiction with CHV status',
|
||
'9810': 'In contradiction with invalidation status',
|
||
'9840': 'CHV verification failed - no attempt left; CHV blocked; UNBLOCK blocked',
|
||
'9850': 'Increase cannot be performed, max value reached',
|
||
'9EXX': 'SIM data download error (SW2 = length)',
|
||
'9FXX': 'Response data available (SW2 = N bytes) - send GET RESPONSE',
|
||
'6200': 'Warning: No information given, NV memory unchanged',
|
||
'6281': 'Warning: Part of returned data may be corrupted',
|
||
'6282': 'Warning: End of file/record reached before reading Le bytes',
|
||
'6283': 'Warning: Selected file invalidated',
|
||
'6285': 'Warning: Selected file in termination state',
|
||
'62F1': 'Warning: More data available',
|
||
'62F2': 'Warning: More data available and proactive command pending',
|
||
'62F3': 'Warning: Response data available',
|
||
'63F1': 'Warning: More data expected',
|
||
'63F2': 'Warning: More data expected and proactive command pending',
|
||
'63CX': 'Verification failed, X retries remaining (SW2 & 0x0F = tries; X=0: no counter provided / blocked for VERIFY)',
|
||
'6400': 'No information given, state of NV memory unchanged',
|
||
'6500': 'No information given, state of NV memory changed',
|
||
'6581': 'Memory problem',
|
||
'6700': 'Wrong length in Lc',
|
||
'6800': 'Checking error: No information given (CLA functions)',
|
||
'6881': 'Logical channel not supported',
|
||
'6882': 'Secure messaging not supported',
|
||
'6900': 'No information given',
|
||
'6981': 'Command incompatible with file structure',
|
||
'6982': 'Security status not satisfied',
|
||
'6983': 'Authentication/PIN method blocked',
|
||
'6984': 'Referenced data invalidated',
|
||
'6985': 'Conditions of use not satisfied',
|
||
'6986': 'Command not allowed (no EF selected)',
|
||
'6989': 'Command not allowed — secure channel — security not satisfied',
|
||
'6A80': 'Incorrect parameters in the data field',
|
||
'6A81': 'Function not supported',
|
||
'6A82': 'File not found',
|
||
'6A83': 'Record not found',
|
||
'6A84': 'Not enough memory space',
|
||
'6A86': 'Incorrect parameters P1-P2',
|
||
'6A87': 'Lc inconsistent with P1-P2',
|
||
'6A85': 'Nc inconsistent with TLV data object',
|
||
'6A88': 'Referenced data not found',
|
||
'6A89': 'File already exists',
|
||
'6A8A': 'DF name already exists',
|
||
'6B00': 'Wrong parameter(s) P1-P2',
|
||
'6D00': 'Instruction code not supported or invalid',
|
||
'6E00': 'Class not supported',
|
||
'6F00': 'Technical problem, no precise diagnosis',
|
||
'9850': 'INCREASE cannot be performed, max value reached',
|
||
'9862': 'Authentication error, application specific',
|
||
'9863': 'Security session or association expired',
|
||
'9864': 'Minimum UICC suspension time is too long',
|
||
},
|
||
gp: {
|
||
'6310': 'More data available — issue GET STATUS [get next occurrence]',
|
||
'6581': 'Memory failure',
|
||
'6A80': 'Incorrect values in command data',
|
||
'6A81': 'Function not supported (card Life Cycle State is CARD_LOCKED)',
|
||
'6A82': 'Selected Application / file not found',
|
||
'6A84': 'Not enough memory space',
|
||
'6A88': 'Referenced data not found',
|
||
'6881': 'Logical channel not supported or is not active',
|
||
'6882': 'Secure messaging not supported',
|
||
'6883': 'The last command of the chain was expected',
|
||
'9484': 'Algorithm not supported (DEPRECATED)',
|
||
'9485': 'Invalid key check value (DEPRECATED)',
|
||
'6300': 'Authentication of host cryptogram failed',
|
||
'6200': 'Warning: Logical Channel already closed',
|
||
'6283': 'Warning: Card Life Cycle State is CARD_LOCKED',
|
||
},
|
||
uicc: {
|
||
'91XX': 'Normal ending with proactive UICC command pending (SW2 = length of response data)',
|
||
'9100': 'Normal ending with proactive UICC command pending (no response data)',
|
||
'92XX': 'Normal ending with ongoing data transfer session info',
|
||
'9300': 'SIM Application Toolkit is busy',
|
||
'62F1': 'More data available (ENVELOPE, RETRIEVE DATA, SET DATA)',
|
||
'62F2': 'More data available and proactive command pending',
|
||
'62F3': 'Response data available (ENVELOPE)',
|
||
'63F1': 'More data expected (ENVELOPE, RETRIEVE DATA, SET DATA)',
|
||
'63F2': 'More data expected and proactive command pending (RETRIEVE DATA)',
|
||
'63CX': 'Verification failed, X retries remaining (VERIFY PIN, CHANGE PIN, etc.)',
|
||
'6A80': 'Incorrect parameters in the data field',
|
||
'6A82': 'File not found',
|
||
'6A83': 'Record not found (READ RECORD, SEARCH RECORD, UPDATE RECORD)',
|
||
'6A84': 'Not enough memory space (RETRIEVE DATA, SET DATA)',
|
||
'6A87': 'Lc inconsistent with P1 to P2 (SELECT only)',
|
||
'6A88': 'Referenced data not found',
|
||
'6981': 'Command incompatible with file structure',
|
||
'6983': 'Authentication/PIN method blocked',
|
||
'6984': 'Referenced data invalidated',
|
||
'6986': 'Command not allowed (no EF selected)',
|
||
'6989': 'Command not allowed — secure channel — security not satisfied',
|
||
},
|
||
};
|
||
|
||
const SW_CMD_NAMES = {
|
||
'sim-select': 'SIM / USIM — SELECT',
|
||
'sim-status': 'SIM / USIM — STATUS',
|
||
'sim-read-binary': 'SIM / USIM — READ BINARY',
|
||
'sim-update-binary': 'SIM / USIM — UPDATE BINARY',
|
||
'sim-read-record': 'SIM / USIM — READ RECORD',
|
||
'sim-update-record': 'SIM / USIM — UPDATE RECORD',
|
||
'sim-verify': 'SIM / USIM — VERIFY PIN',
|
||
'sim-change': 'SIM / USIM — CHANGE PIN',
|
||
'sim-disable': 'SIM / USIM — DISABLE PIN',
|
||
'sim-enable': 'SIM / USIM — ENABLE PIN',
|
||
'sim-unblock': 'SIM / USIM — UNBLOCK PIN',
|
||
'sim-deactivate': 'SIM / USIM — DEACTIVATE FILE',
|
||
'sim-activate': 'SIM / USIM — ACTIVATE FILE',
|
||
'sim-challenge': 'SIM / USIM — GET CHALLENGE',
|
||
'sim-auth': 'SIM / USIM — AUTHENTICATE',
|
||
'sim-profile': 'SIM / USIM — TERMINAL PROFILE',
|
||
'sim-envelope': 'SIM / USIM — ENVELOPE',
|
||
'sim-fetch': 'SIM / USIM — FETCH',
|
||
'sim-terminal-rsp': 'SIM / USIM — TERMINAL RESPONSE',
|
||
'sim-manage-channel': 'SIM / USIM — MANAGE CHANNEL',
|
||
'sim-msc': 'SIM / USIM — MANAGE SECURE CHANNEL',
|
||
'sim-retrieve': 'SIM / USIM — RETRIEVE DATA',
|
||
'sim-set-data': 'SIM / USIM — SET DATA',
|
||
'sim-suspend': 'SIM / USIM — SUSPEND UICC',
|
||
'ram-install-load': 'RAM — INSTALL [for load]',
|
||
'ram-install-install': 'RAM — INSTALL [for install]',
|
||
'ram-install-make-sel': 'RAM — INSTALL [for make selectable]',
|
||
'ram-install-reg-update': 'RAM — INSTALL [for registry update]',
|
||
'ram-install-extradition': 'RAM — INSTALL [for extradition]',
|
||
'ram-load': 'RAM — LOAD',
|
||
'ram-delete': 'RAM — DELETE',
|
||
'ram-get-status': 'RAM — GET STATUS',
|
||
'ram-get-data': 'RAM — GET DATA',
|
||
'ram-store-data': 'RAM — STORE DATA',
|
||
'ram-set-status': 'RAM — SET STATUS',
|
||
'ram-ext-auth': 'RAM — EXTERNAL AUTHENTICATE',
|
||
'ram-int-auth': 'RAM — INTERNAL AUTHENTICATE',
|
||
'ram-select': 'RAM — SELECT',
|
||
'ram-init-update': 'RAM — INITIALIZE UPDATE',
|
||
'ram-put-key': 'RAM — PUT KEY',
|
||
'ram-manage-channel': 'RAM — MANAGE CHANNEL',
|
||
};
|
||
|
||
const LIFECYCLE_MAP = {
|
||
'01': 'OP_READY (card) / LOADED (ELF)',
|
||
'03': 'INSTALLED',
|
||
'07': 'SELECTABLE',
|
||
'0F': 'SECURED (card)',
|
||
'1F': 'PERSONALIZED (SD)',
|
||
'7F': 'CARD_LOCKED',
|
||
'FF': 'TERMINATED',
|
||
'83': 'LOCKED (SD)',
|
||
'87': 'LOCKED (SD)',
|
||
'8F': 'LOCKED (SD)',
|
||
};
|
||
|
||
const PRIVILEGE_NAMES = [
|
||
['Security Domain', 'DAP Verification', 'Delegated Management', 'Card Lock', 'Card Terminate', 'Card Reset', 'CVM Management', 'Mandated DAP Verification'],
|
||
['Trusted Path', 'Authorized Management', 'Token Verification', 'Global Delete', 'Global Lock', 'Global Registry', 'Final Application', 'Global Service'],
|
||
['Receipt Generation', 'Ciphered Load File Data Block', 'Contactless Activation', 'Contactless Self-Activation'],
|
||
];
|
||
|
||
function getSwCategory(cmd) {
|
||
if (!cmd) return 'generic';
|
||
return cmd.startsWith('ram-') ? 'gp' : 'uicc';
|
||
}
|
||
|
||
function lookupSw(sw1, sw2, cat) {
|
||
const key = sw1 + sw2;
|
||
const wild = sw1 + 'XX';
|
||
const cx = sw1 + 'CX';
|
||
const maps = [SW_MAP.generic];
|
||
if (cat === 'gp' || cat === 'uicc') maps.push(SW_MAP[cat]);
|
||
if (cat === 'uicc') maps.push(SW_MAP.gp);
|
||
for (const m of maps) {
|
||
if (m[key]) return m[key];
|
||
if (m[wild]) return m[wild];
|
||
if (m[cx] && (parseInt(sw2,16) & 0xF0) === 0xC0) {
|
||
const tries = parseInt(sw2,16) & 0x0F;
|
||
return m[cx].replace('X', tries);
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function updateRespCmd() {
|
||
const cmd = document.getElementById('resp-cmd').value;
|
||
const ctx = document.getElementById('resp-ctx');
|
||
if (!cmd) { ctx.value = 'generic'; return; }
|
||
ctx.value = getSwCategory(cmd) === 'gp' ? 'GlobalPlatform' : 'SIM / UICC';
|
||
}
|
||
|
||
function parseTLV(hex) {
|
||
const tags = [];
|
||
let i = 0;
|
||
while (i < hex.length) {
|
||
let tag = hex.substr(i, 2); i += 2;
|
||
const firstByte = parseInt(tag, 16);
|
||
// Multi-byte tags: bits 4-0 of first byte are 0x1F (ISO 7816)
|
||
if ((firstByte & 0x1F) === 0x1F) { tag += hex.substr(i, 2); i += 2; }
|
||
let lenByte = parseInt(hex.substr(i, 2), 16); i += 2;
|
||
let len;
|
||
if (lenByte < 0x80) { len = lenByte; }
|
||
else if (lenByte === 0x81) { len = parseInt(hex.substr(i, 2), 16); i += 2; }
|
||
else if (lenByte === 0x82) { len = parseInt(hex.substr(i, 4), 16); i += 4; }
|
||
else { len = lenByte; }
|
||
const value = hex.substr(i, len * 2); i += len * 2;
|
||
// Constructed if bit 5 of first tag byte is set (ISO 7816)
|
||
const isConstructed = (firstByte & 0x20) !== 0;
|
||
const children = isConstructed ? parseTLV(value) : null;
|
||
tags.push({ tag, len, value, children, isConstructed });
|
||
}
|
||
return tags;
|
||
}
|
||
|
||
function formatTlvTree(tags, indent) {
|
||
if (!indent) indent = 0;
|
||
const pad = ' '.repeat(indent);
|
||
let html = '';
|
||
for (const t of tags) {
|
||
const tagName = TLV_TAG_NAMES[t.tag] || '';
|
||
html += pad + '<span class="text-blue-600 dark:text-blue-400">' + t.tag + '</span>';
|
||
html += ' <span class="text-gray-500">len=' + t.len + '</span>';
|
||
if (tagName) html += ' <span class="text-green-600 dark:text-green-400">// ' + tagName + '</span>';
|
||
if (t.children && t.children.length) {
|
||
html += '\n' + formatTlvTree(t.children, indent + 1);
|
||
} else {
|
||
const decoded = decodeTlvValue(t);
|
||
if (decoded) html += ' <span class="text-purple-600 dark:text-purple-400">→ ' + decoded + '</span>';
|
||
html += '\n';
|
||
}
|
||
}
|
||
return html;
|
||
}
|
||
|
||
const TLV_TAG_NAMES = {
|
||
'4F': 'Application AID',
|
||
'5F50': 'SD Manager URL',
|
||
'9F70': 'Life Cycle State',
|
||
'C5': 'Privileges',
|
||
'C4': 'Application ELF AID',
|
||
'CC': 'Associated SD AID',
|
||
'CE': 'ELF Version Number',
|
||
'84': 'Executable Module AID',
|
||
'CF': 'Implicit Selection Parameter',
|
||
'E3': 'GP Registry Data',
|
||
'42': 'Issuer Identification Number',
|
||
'45': 'Card Image Number',
|
||
'66': 'Card Data',
|
||
'67': 'Card Capability Information',
|
||
'E0': 'Key Information Template',
|
||
'C0': 'Key Information Data',
|
||
'D3': 'Current Security Level',
|
||
'C1': 'Sequence Counter',
|
||
'C2': 'Confirmation Counter',
|
||
'5C': 'Tag List',
|
||
'7F21': 'Certificate',
|
||
'5031': 'Certificate Info (EF.OD)',
|
||
'6F': 'FCI Template',
|
||
'62': 'FCP Template',
|
||
'64': 'FMD Template',
|
||
'A0': 'Application Template',
|
||
'A5': 'Proprietary FCI Data',
|
||
'81': 'Immediate Action',
|
||
'82': 'Error Action',
|
||
'22': 'C-APDU',
|
||
'83': 'Script Chaining',
|
||
};
|
||
|
||
function decodeTlvValue(t) {
|
||
if (t.tag === '9F70' && t.value.length === 2) {
|
||
return LIFECYCLE_MAP[t.value] || 'Unknown state (' + t.value + ')';
|
||
}
|
||
if (t.tag === 'C5' && t.value.length >= 2) {
|
||
return decodePrivileges(t.value);
|
||
}
|
||
if (t.tag === '4F') {
|
||
return t.value.replace(/(..)/g, '$1 ').trim();
|
||
}
|
||
if (t.tag === 'C4' || t.tag === 'CC' || t.tag === '84') {
|
||
return t.value.replace(/(..)/g, '$1 ').trim();
|
||
}
|
||
if (t.tag === 'CE' && t.value.length === 4) {
|
||
return 'v' + parseInt(t.value.substr(0,2),16) + '.' + parseInt(t.value.substr(2,2),16);
|
||
}
|
||
if (t.tag === 'D3' && t.value.length === 2) {
|
||
const v = parseInt(t.value,16);
|
||
const parts = [];
|
||
if (v & 0x80) parts.push('C-MAC');
|
||
if (v & 0x40) parts.push('MAC');
|
||
if (v & 0x20) parts.push('R-MAC');
|
||
if (v & 0x10) parts.push('R-CC');
|
||
if (v & 0x08) parts.push('C-DEC');
|
||
if (v & 0x04) parts.push('D-DEC');
|
||
if (v & 0x02) parts.push('C-ENC');
|
||
if (v & 0x01) parts.push('D-ENC');
|
||
return parts.length ? parts.join(' + ') : 'No security';
|
||
}
|
||
if (t.tag === 'C1' && t.value.length >= 2) {
|
||
return 'Seq: 0x' + t.value;
|
||
}
|
||
if (t.tag === 'C2' && t.value.length >= 2) {
|
||
return 'Counter: 0x' + t.value;
|
||
}
|
||
if (t.tag === '5F50') {
|
||
try {
|
||
const bytes = [];
|
||
for (let i = 0; i < t.value.length; i += 2) bytes.push(parseInt(t.value.substr(i,2),16));
|
||
return new TextDecoder().decode(new Uint8Array(bytes));
|
||
} catch(e) { return t.value; }
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function decodePrivileges(hex) {
|
||
const bytes = [];
|
||
for (let i = 0; i < hex.length; i += 2) bytes.push(parseInt(hex.substr(i,2),16));
|
||
const names = [];
|
||
for (let b = 0; b < Math.min(bytes.length, PRIVILEGE_NAMES.length); b++) {
|
||
const byteNames = PRIVILEGE_NAMES[b] || [];
|
||
for (let bit = 0; bit < byteNames.length; bit++) {
|
||
if (bytes[b] & (0x80 >> bit)) {
|
||
if (byteNames[bit]) names.push(byteNames[bit]);
|
||
}
|
||
}
|
||
}
|
||
return names.length ? names.join(', ') : 'None';
|
||
}
|
||
|
||
function decodeResponse() {
|
||
const cmd = document.getElementById('resp-cmd').value;
|
||
const swHex = (document.getElementById('resp-sw').value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase().padStart(4, '0').slice(0, 4);
|
||
const sw1 = swHex.slice(0, 2);
|
||
const sw2 = swHex.slice(2, 4);
|
||
const rawData = (document.getElementById('resp-data').value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
|
||
const out = document.getElementById('resp-output');
|
||
let html = '';
|
||
|
||
if (!sw1 || !sw2) {
|
||
html += '<span class="text-red-500">Please enter SW1 and SW2</span>';
|
||
out.innerHTML = html;
|
||
return;
|
||
}
|
||
|
||
const cat = getSwCategory(cmd);
|
||
const swDesc = lookupSw(sw1, sw2, cat);
|
||
const isOk = sw1 === '90' && sw2 === '00';
|
||
|
||
html += '<div class="mb-2">';
|
||
if (isOk) {
|
||
html += '<span class="text-green-600 dark:text-green-400 font-bold">✓</span> ';
|
||
} else {
|
||
html += '<span class="text-red-500 dark:text-red-400 font-bold">✗</span> ';
|
||
}
|
||
html += 'SW1=' + sw1 + ' SW2=' + sw2;
|
||
if (cmd && SW_CMD_NAMES[cmd]) html += ' <span class="text-gray-500">(' + SW_CMD_NAMES[cmd] + ')</span>';
|
||
html += '<br>';
|
||
if (swDesc) {
|
||
html += '<span class="text-gray-700 dark:text-slate-300">' + swDesc + '</span>';
|
||
} else {
|
||
html += '<span class="text-yellow-600 dark:text-yellow-400">Unknown status word</span>';
|
||
}
|
||
html += '</div>';
|
||
|
||
if (rawData) {
|
||
html += '<div class="mt-2 pt-2 border-t border-gray-300 dark:border-slate-600">';
|
||
html += '<div class="text-sm font-medium text-gray-700 dark:text-slate-300 mb-1">Response Data</div>';
|
||
if (cmd === 'ram-get-status') {
|
||
const tlv = parseTLV(rawData);
|
||
if (tlv.length) {
|
||
html += '<pre class="text-xs leading-relaxed">' + formatTlvTree(tlv) + '</pre>';
|
||
} else {
|
||
html += '<span class="text-gray-500">' + rawData.replace(/(..)/g, '$1 ').trim() + '</span>';
|
||
}
|
||
} else if (cmd === 'ram-get-data') {
|
||
const tlv = parseTLV(rawData);
|
||
if (tlv.length) {
|
||
html += '<pre class="text-xs leading-relaxed">' + formatTlvTree(tlv) + '</pre>';
|
||
} else {
|
||
html += '<span class="text-gray-500">' + rawData.replace(/(..)/g, '$1 ').trim() + '</span>';
|
||
}
|
||
} else if (cmd === 'sim-select' || cmd === 'ram-select') {
|
||
const tlv = parseTLV(rawData);
|
||
if (tlv.length) {
|
||
html += '<pre class="text-xs leading-relaxed">' + formatTlvTree(tlv) + '</pre>';
|
||
} else {
|
||
html += '<span class="text-gray-500">' + rawData.replace(/(..)/g, '$1 ').trim() + '</span>';
|
||
}
|
||
} else {
|
||
html += '<span class="text-gray-500">' + rawData.replace(/(..)/g, '$1 ').trim() + '</span>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
|
||
out.innerHTML = html;
|
||
}
|
||
|
||
// ===== pysim integration =====
|
||
// Default to a local server on 127.0.0.1:8080. If this PWA is served by
|
||
// pysim-otaman-server itself (same origin), detect the co-located API once
|
||
// and switch to a relative base; otherwise keep the default and wait for the
|
||
// user to point at a server.
|
||
let pysimBase = 'http://127.0.0.1:8080';
|
||
|
||
async function pysimDetectSameOrigin() {
|
||
try {
|
||
const res = await fetch('/api/version', { method: 'GET', cache: 'no-store' });
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
if (!data || typeof data.version !== 'string') return;
|
||
pysimBase = '';
|
||
const urlEl = document.getElementById('pysim-url');
|
||
if (urlEl) { urlEl.value = ''; urlEl.placeholder = '(same origin)'; }
|
||
_pysimAutoConnectAttempted = true;
|
||
pysimConnect();
|
||
} catch (e) {
|
||
// no co-located API (file://, plain static server, SPA fallback) — keep default
|
||
}
|
||
}
|
||
pysimDetectSameOrigin();
|
||
|
||
function esc(str) {
|
||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
async function pysimFetch(path, body) {
|
||
const url = pysimBase + path;
|
||
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();
|
||
}
|
||
|
||
// ===== UI availability (server / card) =====
|
||
let _pysimServerAvailable = null; // null until probed
|
||
let _pysimCardEquipped = false;
|
||
let _pysimAvailabilityTimer = null;
|
||
|
||
function pysimAvailabilityState() {
|
||
if (!_pysimServerAvailable) return 'server-down';
|
||
return _pysimCardEquipped ? 'card' : 'no-card';
|
||
}
|
||
|
||
function pysimControlDisabled(needs, state) {
|
||
if (needs === 'server') return state === 'server-down';
|
||
if (needs === 'card') return state !== 'card';
|
||
return false;
|
||
}
|
||
|
||
function pysimNeedsHint(needs, state) {
|
||
return state === 'server-down' ? t('Connect to server') : t('Insert and equip a card');
|
||
}
|
||
|
||
function pysimUpdateStateIndicator() {
|
||
const el = document.getElementById('state-indicator');
|
||
if (!el) return;
|
||
const state = pysimAvailabilityState();
|
||
const entry = {
|
||
'server-down': ['text-red-500', t('No server connection')],
|
||
'no-card': ['text-yellow-600', t('Server connected, no card equipped')],
|
||
'card': ['text-emerald-600', t('Card equipped')],
|
||
}[state];
|
||
['text-red-500', 'text-yellow-600', 'text-emerald-600', 'text-gray-400'].forEach(c => el.classList.remove(c));
|
||
el.classList.add(entry[0]);
|
||
el.setAttribute('title', entry[1]);
|
||
}
|
||
|
||
function pysimApplyAvailability() {
|
||
const state = pysimAvailabilityState();
|
||
document.querySelectorAll('[data-needs]').forEach(el => {
|
||
const needs = el.getAttribute('data-needs');
|
||
const disabled = pysimControlDisabled(needs, state);
|
||
el.disabled = disabled;
|
||
if (disabled) el.setAttribute('title', pysimNeedsHint(needs, state));
|
||
else el.removeAttribute('title');
|
||
});
|
||
pysimUpdateStateIndicator();
|
||
}
|
||
|
||
function pysimSetServerAvailable(available) {
|
||
if (_pysimServerAvailable === available) return;
|
||
_pysimServerAvailable = available;
|
||
pysimApplyAvailability();
|
||
}
|
||
|
||
function pysimStartAvailabilityProbe() {
|
||
if (_pysimAvailabilityTimer) return;
|
||
_pysimAvailabilityTimer = setInterval(async () => {
|
||
if (_pysimServerAvailable === true && _pysimCardEquipped) return; // 2s status poll covers this
|
||
try {
|
||
const ver = await pysimFetch('/api/version');
|
||
if (ver && typeof ver.version === 'string') {
|
||
_pysimServerAvailable = true;
|
||
try { pysimCardStateUpdate(await pysimFetch('/api/status')); } catch (e) { /* status still optional here */ }
|
||
pysimApplyAvailability();
|
||
} else {
|
||
pysimSetServerAvailable(false);
|
||
}
|
||
} catch (e) {
|
||
pysimSetServerAvailable(false);
|
||
}
|
||
}, 5000);
|
||
}
|
||
|
||
function pysimTargetSpace() {
|
||
let h;
|
||
try { h = new URL(pysimBase).hostname.toLowerCase(); } catch (e) { return 'public'; }
|
||
if (h === 'localhost' || h === '[::1]' || h === '::1' || /^127\./.test(h)) return 'loopback';
|
||
if (/^10\./.test(h) || /^192\.168\./.test(h) || /^172\.(1[6-9]|2\d|3[01])\./.test(h)) return 'private';
|
||
return 'public';
|
||
}
|
||
|
||
function pysimIsLikelyBlocked() {
|
||
const pagePublic = location.protocol === 'https:'
|
||
|| !(location.hostname === 'localhost' || /^127\./.test(location.hostname));
|
||
const s = pysimTargetSpace();
|
||
return pagePublic && (s === 'loopback' || s === 'private');
|
||
}
|
||
|
||
function pysimSetConnected(state) {
|
||
const img = document.getElementById('pysim-status-img');
|
||
if (state === 'spin') {
|
||
img.src = 'sim_anim.svg';
|
||
} else {
|
||
img.src = state ? 'sim.svg' : 'nosim.svg';
|
||
}
|
||
}
|
||
|
||
function pysimShowConnected(connected) {
|
||
document.getElementById('pysim-connect-row').classList.toggle('hidden', connected);
|
||
document.getElementById('pysim-connect-info').classList.toggle('hidden', connected);
|
||
document.getElementById('pysim-connected-row').classList.toggle('hidden', !connected);
|
||
document.getElementById('pysim-body').classList.toggle('hidden', !connected);
|
||
document.getElementById('sp-verify-btn').classList.toggle('hidden', !connected);
|
||
document.getElementById('sp-send-btn').classList.toggle('hidden', !connected);
|
||
}
|
||
|
||
async function pysimRefresh() {
|
||
const statusEl = document.getElementById('pysim-status');
|
||
try {
|
||
const data = await pysimFetch('/api/status');
|
||
pysimSetConnected(true);
|
||
let html = 'Reader: <b>' + esc(data.reader || 'none') + '</b>' +
|
||
' | Card: <b>' + esc(data.card || 'none') + '</b>' +
|
||
' | Profile: <b>' + esc(data.profile || 'none') + '</b><br>' +
|
||
'ATR: <b>' + esc(data.atr || 'n/a') + '</b>' +
|
||
(data.cla_byte ? ' | CLA: <b>' + esc(data.cla_byte) + '</b>' : '') +
|
||
(data.sel_ctrl ? ' | SEL: <b>' + esc(data.sel_ctrl) + '</b>' : '') +
|
||
' | Channels: <b>' + (data.channels && data.channels.length ? data.channels.join(', ') : 'none') + '</b><br>' +
|
||
'ADM: <b>' + (data.adm_verified ? 'verified' : 'not verified') + '</b>' +
|
||
' | Selection: <b>' + (data.current_selection
|
||
? (data.current_selection.name || data.current_selection.fid || '?')
|
||
: 'none') + '</b>' +
|
||
(data.current_selection
|
||
? ' [' + (data.current_selection.type || '?') + ']' +
|
||
' FID=' + (data.current_selection.fid || '?') +
|
||
(data.current_selection.path ? ' path=' + data.current_selection.path : '') +
|
||
(data.current_selection.file_size ? ' size=' + data.current_selection.file_size : '') +
|
||
(data.current_selection.record_len ? ' rec_len=' + data.current_selection.record_len : '') +
|
||
(data.current_selection.num_of_rec ? ' rec_cnt=' + data.current_selection.num_of_rec : '')
|
||
: '');
|
||
statusEl.innerHTML = html;
|
||
return true;
|
||
} catch (e) {
|
||
pysimSetConnected(false);
|
||
statusEl.textContent = t('Connection failed:') + ' ' + e.message;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function pysimConnect() {
|
||
const urlInput = document.getElementById('pysim-url');
|
||
pysimBase = urlInput.value.replace(/\/+$/, '');
|
||
const wasAuto = _pysimAutoConnectAttempted;
|
||
_pysimAutoConnectAttempted = false;
|
||
const btn = document.getElementById('pysim-connect-btn');
|
||
btn.disabled = true;
|
||
btn.textContent = t('Connecting...');
|
||
const statusEl = document.getElementById('pysim-status');
|
||
const msgEl = document.getElementById('pysim-connect-msg');
|
||
msgEl.innerHTML = '<span class="text-gray-500">' + t('Checking card...') + '</span>';
|
||
pysimSetConnected('spin');
|
||
try {
|
||
// Version check
|
||
const ver = await pysimFetch('/api/version');
|
||
if (!ver.version) throw new Error('No version');
|
||
_pysimServerAvailable = true;
|
||
const sv = (ver.version || '').split('.').map(Number);
|
||
if (sv[0] < 1 || (sv[0] === 1 && sv[1] < 0)) {
|
||
msgEl.innerHTML = '<span class="text-red-500">Server version ' + esc(ver.version) + ' is outdated. Please update pysim-otaman-server.</span>';
|
||
btn.textContent = t('Connect');
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
if (sv[0] > 1) {
|
||
msgEl.innerHTML = '<span class="text-yellow-500">Warning: server version ' + esc(ver.version) + ' is newer than expected. Update OTAMan for full compatibility.</span>';
|
||
}
|
||
const status = await pysimFetch('/api/status');
|
||
if (!status.app_ready) {
|
||
throw new Error('Server not ready');
|
||
}
|
||
_pysimCardEquipped = !!status.connected;
|
||
const cmd = await pysimFetch('/api/command', { cmd: 'status' });
|
||
if (cmd.output && (cmd.output.includes('Exception') || cmd.output.includes('ProtocolError'))) {
|
||
pysimSetConnected(false);
|
||
statusEl.innerHTML = '<span class="text-red-500">' + t('No card detected. Insert card and click Equip card button') + '</span>';
|
||
pysimShowConnected(true);
|
||
btn.textContent = t('Connected');
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
pysimSetConnected(true);
|
||
pysimShowConnected(true);
|
||
pysimApplyAvailability();
|
||
await pysimRefresh();
|
||
pysimLoadCommands();
|
||
pysimFsRefresh();
|
||
pysimPollStatusInit();
|
||
pysimStartBackendPoll();
|
||
btn.textContent = t('Connected');
|
||
stkCheckMenu();
|
||
} catch (e) {
|
||
if (wasAuto) _pysimAutoConnectAttempted = true;
|
||
pysimSetConnected(false);
|
||
_pysimServerAvailable = false;
|
||
_pysimCardEquipped = false;
|
||
pysimApplyAvailability();
|
||
const hint = t(pysimIsLikelyBlocked() ? 'pysim-blocked-hint' : 'pysim-unreachable-hint')
|
||
.replace('{url}', esc(pysimBase));
|
||
msgEl.innerHTML =
|
||
'<span class="text-red-500">' + t('Connection failed:') + ' ' + esc(e.message) + '</span>' +
|
||
'<br><span class="text-sm text-gray-600 dark:text-slate-400">' + hint + '</span>';
|
||
btn.textContent = t('Connect');
|
||
}
|
||
btn.disabled = false;
|
||
}
|
||
|
||
function pysimDisconnect() {
|
||
if (_pysimPollTimer) { clearInterval(_pysimPollTimer); _pysimPollTimer = null; }
|
||
if (_pysimStatusTimer) { clearInterval(_pysimStatusTimer); _pysimStatusTimer = null; }
|
||
pysimShowConnected(false);
|
||
document.getElementById('pysim-status').textContent = '';
|
||
pysimSetConnected(false);
|
||
_pysimServerAvailable = null;
|
||
_pysimCardEquipped = false;
|
||
pysimApplyAvailability();
|
||
}
|
||
|
||
async function pysimRunCmd(cmd) {
|
||
const statusEl = document.getElementById('pysim-status');
|
||
try {
|
||
const result = await pysimFetch('/api/command', { cmd });
|
||
if (result.output && (result.output.includes('Exception') || result.output.includes('ProtocolError') || result.output.includes('SwMatchError'))) {
|
||
pysimSetConnected(false);
|
||
statusEl.innerHTML = '<span class="text-red-500">' + t('No card detected. Insert card and click Equip card button') + '</span>';
|
||
return null;
|
||
}
|
||
return result;
|
||
} catch (e) {
|
||
pysimSetConnected(false);
|
||
statusEl.innerHTML = '<span class="text-red-500">' + t('No card detected. Insert card and click Equip card button') + '</span>';
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function pysimStatusCmd() {
|
||
const statusEl = document.getElementById('pysim-status');
|
||
statusEl.innerHTML = '<span class="text-gray-500">' + t('Checking...') + '</span>';
|
||
pysimSetConnected('spin');
|
||
const result = await pysimRunCmd('status');
|
||
if (result) await pysimRefresh();
|
||
}
|
||
|
||
async function pysimResetCmd() {
|
||
const statusEl = document.getElementById('pysim-status');
|
||
statusEl.innerHTML = '<span class="text-gray-500">' + t('Resetting...') + '</span>';
|
||
pysimSetConnected('spin');
|
||
const result = await pysimRunCmd('reset');
|
||
if (result) {
|
||
await pysimRefresh();
|
||
pysimFsRefresh();
|
||
}
|
||
}
|
||
|
||
async function pysimEquipCmd() {
|
||
const statusEl = document.getElementById('pysim-status');
|
||
statusEl.innerHTML = '<span class="text-gray-500">' + t('Equipping...') + '</span>';
|
||
pysimSetConnected('spin');
|
||
const result = await pysimRunCmd('equip');
|
||
if (result) {
|
||
await pysimRefresh();
|
||
pysimFsRefresh();
|
||
stkCheckMenu();
|
||
}
|
||
}
|
||
|
||
async function pysimExec() {
|
||
const cmd = document.getElementById('pysim-cmd').value.trim();
|
||
if (!cmd) return;
|
||
const out = document.getElementById('pysim-cmd-output');
|
||
out.value = '> ' + cmd + '\n';
|
||
try {
|
||
const data = await pysimFetch('/api/command', { cmd });
|
||
out.value += data.output || '(no output)';
|
||
} catch (e) {
|
||
out.value += 'Error: ' + e.message;
|
||
}
|
||
pysimRefresh();
|
||
}
|
||
|
||
async function pysimApdu() {
|
||
const apdu = document.getElementById('pysim-apdu').value.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (!apdu) return;
|
||
const out = document.getElementById('pysim-apdu-output');
|
||
out.value = '> ' + apdu + '\n';
|
||
try {
|
||
const data = await pysimFetch('/api/apdu', { apdu });
|
||
if (data.error) {
|
||
out.value += 'Error: ' + data.error;
|
||
} else {
|
||
out.value += 'Response: ' + data.response + '\nSW: ' + data.sw;
|
||
}
|
||
} catch (e) {
|
||
out.value += 'Error: ' + e.message;
|
||
}
|
||
pysimRefresh();
|
||
}
|
||
|
||
function spParams() {
|
||
return {
|
||
spi1: document.getElementById('sp-spi1').value,
|
||
spi2: document.getElementById('sp-spi2-hex').value,
|
||
kic: document.getElementById('sp-kic-hex').value,
|
||
kid: document.getElementById('sp-kid-hex').value,
|
||
tar: document.getElementById('sp-tar').value,
|
||
cntr: document.getElementById('sp-cntr').value,
|
||
kicKey: document.getElementById('sp-kic-key').value,
|
||
kidKey: document.getElementById('sp-kid-key').value,
|
||
apdu: document.getElementById('sp-apdu').value,
|
||
};
|
||
}
|
||
|
||
async function pysimVerifySp() {
|
||
const sp = document.getElementById('sp-result').value.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
const resultEl = document.getElementById('sp-verify-result');
|
||
if (!sp) { alert('No secured packet to verify. Generate a secure packet first.'); return; }
|
||
resultEl.classList.remove('hidden');
|
||
resultEl.textContent = t('Verifying against pySim reference...');
|
||
try {
|
||
const data = await pysimFetch('/api/sp-verify', { sp, ...spParams() });
|
||
if (data.match) {
|
||
resultEl.textContent = 'MATCH: JS packet byte-identical to pySim reference (' + sp.length / 2 + ' bytes)';
|
||
resultEl.classList.remove('text-red-600');
|
||
resultEl.classList.add('text-green-600');
|
||
} else {
|
||
let msg = 'MISMATCH vs pySim.';
|
||
if (data.diffs) msg += ' First diff at byte ' + data.diffs[0].offset + ' (JS=' + data.diffs[0].js + ' pySim=' + data.diffs[0].ref + ')';
|
||
if (data.py_sp) msg += ' pySim reference: ' + data.py_sp;
|
||
resultEl.textContent = msg;
|
||
resultEl.classList.remove('text-green-600');
|
||
resultEl.classList.add('text-red-600');
|
||
}
|
||
} catch (e) {
|
||
resultEl.textContent = 'Error: ' + e.message;
|
||
resultEl.classList.remove('text-green-600');
|
||
resultEl.classList.add('text-red-600');
|
||
}
|
||
}
|
||
|
||
async function pysimSendOta() {
|
||
const connectedRow = document.getElementById('pysim-connected-row');
|
||
if (!connectedRow || connectedRow.classList.contains('hidden')) {
|
||
alert('Card reader server is not connected');
|
||
return;
|
||
}
|
||
const sp = document.getElementById('sp-result').value.replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (!sp) { alert('No secured packet to send. Generate a secure packet first.'); return; }
|
||
const sendResultEl = document.getElementById('sp-send-result');
|
||
const porStatusEl = document.getElementById('sp-por-status');
|
||
sendResultEl.classList.remove('hidden', 'text-red-600', 'text-green-600');
|
||
sendResultEl.classList.add('text-gray-500');
|
||
sendResultEl.textContent = t('Sending OTA...');
|
||
porStatusEl.classList.add('hidden');
|
||
try {
|
||
const data = await pysimFetch('/api/send-ota', { sp, ...spParams() });
|
||
if (data.success) {
|
||
if (data.por && data.por.decoded) {
|
||
document.getElementById('resp-sw').value = data.por.decoded.last_status_word || '';
|
||
document.getElementById('resp-data').value = data.por.decoded.last_response_data || '';
|
||
switchTab('c-apdu');
|
||
cApduSwitchSubtab('response');
|
||
}
|
||
let msg = 'OTA sent. SW: ' + data.sw;
|
||
const por = data.por;
|
||
if (por && por.response_status) {
|
||
msg += ' | PoR status: ' + por.response_status + ' (TAR ' + por.tar + ')';
|
||
if (por.cntr) msg += ' | PoR CNTR: ' + por.cntr;
|
||
if (por.decoded && por.decoded.last_status_word) msg += ' | last SW ' + por.decoded.last_status_word + ' ' + (por.decoded.last_response_data || '');
|
||
const okPor = por.response_status === 'por_ok';
|
||
porStatusEl.textContent = 'PoR: ' + por.response_status;
|
||
porStatusEl.classList.remove('hidden', okPor ? 'text-red-600' : 'text-green-600');
|
||
porStatusEl.classList.add(okPor ? 'text-green-600' : 'text-red-600');
|
||
if (okPor) {
|
||
// packet was accepted and executed - advance the counter so the
|
||
// same secured packet can never be sent twice (v1.9.6)
|
||
const cntrEl = document.getElementById('sp-cntr');
|
||
cntrEl.value = ((parseInt(cntrEl.value, 16) || 0) + 1)
|
||
.toString(16).toUpperCase().padStart(10, '0').slice(-10);
|
||
document.getElementById('sp-result').value = '';
|
||
// keep the selected card preset in sync with the new counter (v1.9.8)
|
||
const selIdx = parseInt(document.getElementById('sp-card-sel').value, 10);
|
||
if (!isNaN(selIdx) && cards[selIdx]) {
|
||
cards[selIdx].cntr = cntrEl.value;
|
||
cardsSave();
|
||
cardsRender();
|
||
}
|
||
}
|
||
}
|
||
sendResultEl.textContent = msg;
|
||
if (por && por.raw) {
|
||
const rawLine = document.createElement('div');
|
||
rawLine.className = 'break-all';
|
||
rawLine.textContent = 'raw PoR: ' + por.raw;
|
||
sendResultEl.appendChild(rawLine);
|
||
}
|
||
sendResultEl.classList.remove('hidden', 'text-red-600', 'text-gray-500');
|
||
sendResultEl.classList.add('text-green-600');
|
||
} else {
|
||
let msg = 'OTA failed';
|
||
if (data.error) msg += ': ' + data.error;
|
||
if (data.sw) msg += (data.error ? ' | ' : ': ') + 'SW ' + data.sw;
|
||
sendResultEl.textContent = msg;
|
||
sendResultEl.classList.remove('hidden', 'text-green-600', 'text-gray-500');
|
||
sendResultEl.classList.add('text-red-600');
|
||
}
|
||
} catch (e) {
|
||
sendResultEl.textContent = 'Error: ' + e.message;
|
||
sendResultEl.classList.remove('hidden', 'text-green-600', 'text-gray-500');
|
||
sendResultEl.classList.add('text-red-600');
|
||
}
|
||
}
|
||
|
||
// ===== SCP80/RAM pill =====
|
||
// Atomic GlobalPlatform Remote Application Management over SCP80.
|
||
// Simple ops (GET DATA / GET STATUS / DELETE) build the GP APDU locally and
|
||
// reuse /api/send-ota. Install Package sends the .cap hex to /api/ram-install
|
||
// which orchestrates INSTALL[for load] -> LOAD x N -> INSTALL[for install].
|
||
|
||
function ramRender() {
|
||
// populate the card preset selector from the in-memory cards[] array
|
||
const sel = document.getElementById('ram-card-sel');
|
||
if (!sel) return;
|
||
sel.innerHTML = '<option value="" data-l10n="— Select card —">— Select card —</option>';
|
||
cards.forEach((c, i) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = i;
|
||
opt.textContent = c.name || ('Card ' + i);
|
||
sel.appendChild(opt);
|
||
});
|
||
ramOpChanged();
|
||
}
|
||
|
||
function ramApplyCard(idx) {
|
||
// copy the selected card preset into the SP form fields so that
|
||
// getRamSpParams() picks up the right SPI/KIc/KID/TAR/CNTR/keys
|
||
cardsApply(idx);
|
||
}
|
||
|
||
function ramOpChanged() {
|
||
const op = document.getElementById('ram-op').value;
|
||
document.getElementById('ram-install-params').classList.toggle('hidden', op !== 'install-cap');
|
||
if (op !== 'explore') {
|
||
document.getElementById('ram-explorer').classList.add('hidden');
|
||
document.getElementById('ram-explorer').innerHTML = '';
|
||
}
|
||
}
|
||
|
||
function ramShowProgress(text) {
|
||
const el = document.getElementById('ram-progress');
|
||
el.classList.remove('hidden');
|
||
document.getElementById('ram-progress-text').textContent = text;
|
||
}
|
||
|
||
function ramHideProgress() {
|
||
document.getElementById('ram-progress').classList.add('hidden');
|
||
}
|
||
|
||
function ramClearResults() {
|
||
document.getElementById('ram-result').classList.add('hidden');
|
||
document.getElementById('ram-explorer').classList.add('hidden');
|
||
document.getElementById('ram-explorer').innerHTML = '';
|
||
document.getElementById('ram-steps').classList.add('hidden');
|
||
document.getElementById('ram-steps').textContent = '';
|
||
}
|
||
|
||
// Read a File as a hex string via FileReader (client-side, no upload)
|
||
function ramReadFileHex(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const bytes = new Uint8Array(reader.result);
|
||
let hex = '';
|
||
for (let i = 0; i < bytes.length; i++) hex += bytes[i].toString(16).padStart(2, '0');
|
||
resolve(hex.toUpperCase());
|
||
};
|
||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||
reader.readAsArrayBuffer(file);
|
||
});
|
||
}
|
||
|
||
// Collect SP params from the SP form (populated by ramApplyCard -> cardsApply)
|
||
function getRamSpParams() {
|
||
return {
|
||
spi1: document.getElementById('sp-spi1').value,
|
||
spi2: document.getElementById('sp-spi2').value,
|
||
kic: document.getElementById('sp-kic-hex').value,
|
||
kid: document.getElementById('sp-kid-hex').value,
|
||
tar: (document.getElementById('sp-tar').value || '000000').replace(/[^0-9a-fA-F]/g, ''),
|
||
cntr: (document.getElementById('sp-cntr').value || '0000000001').replace(/[^0-9a-fA-F]/g, ''),
|
||
kicKey: (document.getElementById('sp-kic-key').value || '').replace(/[^0-9a-fA-F]/g, ''),
|
||
kidKey: (document.getElementById('sp-kid-key').value || '').replace(/[^0-9a-fA-F]/g, ''),
|
||
};
|
||
}
|
||
|
||
function ramIncrementCntr(cntr) {
|
||
let v = (parseInt(cntr, 16) || 0) + 1;
|
||
return v.toString(16).toUpperCase().padStart(10, '0').slice(-10);
|
||
}
|
||
|
||
function ramSaveCntr(cntr) {
|
||
const el = document.getElementById('sp-cntr');
|
||
el.value = cntr;
|
||
const selIdx = parseInt(document.getElementById('ram-card-sel').value, 10);
|
||
if (!isNaN(selIdx) && cards[selIdx]) {
|
||
cards[selIdx].cntr = cntr;
|
||
cardsSave();
|
||
cardsRender();
|
||
ramRender();
|
||
}
|
||
}
|
||
|
||
// Send a single GP APDU wrapped in SCP80 via /api/send-ota
|
||
async function ramSendOta(apduHex, sp) {
|
||
// RAM operations: send raw GP command + SCP80 params; server handles SCP80 wrapping
|
||
// Caller controls SPI2: 0x01 = PoR via ENVELOPE response, 0x21 = PoR via SMS-SUBMIT
|
||
const body = Object.assign({}, sp, { apdu: apduHex, sp: '', includeCpi: true });
|
||
return await pysimFetch('/api/send-ota', body);
|
||
}
|
||
|
||
// ===== TLV parsers for GET STATUS responses (P2=02 format) =====
|
||
// P2=02 wraps each entry in an 'E3' GP Registry Data template containing TLV tags.
|
||
// Fallback: if the card only supports P2=00 (raw format), raw parsers are used.
|
||
function _parseE3Entry(hex) {
|
||
const children = parseTLV(hex || '');
|
||
const r = {};
|
||
for (const c of children) {
|
||
if (c.tag === '4F') r.aid = c.value.toUpperCase();
|
||
else if (c.tag === '9F70') r.lifecycle = c.value.toUpperCase();
|
||
else if (c.tag === 'C5') r.privileges = c.value.toUpperCase();
|
||
else if (c.tag === 'CE') r.version = c.value.toUpperCase();
|
||
else if (c.tag === 'CC') r.sdAid = c.value.toUpperCase();
|
||
else if (c.tag === 'C4') r.elfAid = c.value.toUpperCase();
|
||
else if (c.tag === 'CF') r.implicitSel = c.value.toUpperCase();
|
||
else if (c.tag === '84') r.moduleAids = ramParseModuleAids(c.value);
|
||
}
|
||
return r;
|
||
}
|
||
|
||
// Raw format (P2=00): ISD uses pure AID length, Apps use combined length (AID+lifecycle).
|
||
// Heuristic: length > 8 means combined (AID = length-1 bytes, lifecycle inside length).
|
||
function _parseRawAppEntry(hex, i) {
|
||
const rawLen = parseInt(hex.substr(i, 2), 16);
|
||
if (rawLen < 1 || i + 2 + rawLen * 2 + 2 > hex.length) return null;
|
||
const aidLen = rawLen > 8 ? rawLen - 1 : rawLen;
|
||
const aid = hex.substr(i + 2, aidLen * 2).toUpperCase();
|
||
let j = i + 2 + rawLen * 2;
|
||
const lifecycle = hex.substr(j, 2).toUpperCase(); j += 2;
|
||
const privileges = hex.substr(j, 2).toUpperCase(); j += 2;
|
||
return { aid, lifecycle, privileges, next: j };
|
||
}
|
||
|
||
// Raw format (P2=00): ELF header then trailing bytes with module entries.
|
||
// P1=20: <ELF_header> <00> <module_entries separated by 00>
|
||
// P1=10: <ELF_header> <version(2B)> <SD_AID> <module_entries...>
|
||
// In both, a module entry starts with 0x10 (len=16) followed by 15-byte AID + lifecycle.
|
||
// Scan for 0x10 markers, advance past each full entry to avoid AID-internal false positives.
|
||
function _parseRawElfEntry(hex, i) {
|
||
const aidLen = parseInt(hex.substr(i, 2), 16);
|
||
if (aidLen < 1 || i + 2 + aidLen * 2 + 2 > hex.length) return null;
|
||
const aid = hex.substr(i + 2, aidLen * 2).toUpperCase();
|
||
let j = i + 2 + aidLen * 2;
|
||
const lifecycle = hex.substr(j, 2).toUpperCase(); j += 2;
|
||
// Scan: when 0x10 found, validate 15-byte AID follows, then advance past full entry
|
||
const moduleAids = [];
|
||
const seen = new Set();
|
||
while (j + 32 <= hex.length) {
|
||
const tag = parseInt(hex.substr(j, 2), 16);
|
||
if (tag === 0x10) {
|
||
const modAid = hex.substr(j + 2, 30).toUpperCase();
|
||
if (!seen.has(modAid)) { seen.add(modAid); moduleAids.push(modAid); }
|
||
j += 32; // skip marker(1) + AID(15), continue past lifecycle+appCount
|
||
} else {
|
||
j += 2;
|
||
}
|
||
}
|
||
return { aid, lifecycle, moduleAids: moduleAids.length ? moduleAids : undefined, next: j };
|
||
}
|
||
|
||
function ramParseAppStatus(hex) {
|
||
const s = (hex || '').toUpperCase();
|
||
if (!s) return [];
|
||
// TLV format (P2=02): E3 templates with structured tags
|
||
if (s.length >= 4 && s.substr(0, 2) === 'E3') {
|
||
const tlvs = parseTLV(s);
|
||
return tlvs.filter(t => t.tag === 'E3').map(t => {
|
||
const r = _parseE3Entry(t.value);
|
||
r.type = 'app';
|
||
return r;
|
||
}).filter(r => r.aid);
|
||
}
|
||
// Raw format (P2=00 fallback): consecutive <aid_len><AID><lifecycle><privileges>
|
||
const out = [];
|
||
let i = 0;
|
||
while (i + 6 <= s.length) {
|
||
const r = _parseRawAppEntry(s, i);
|
||
if (!r) break;
|
||
out.push({ type: 'app', ...r });
|
||
i = r.next;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function ramParseElfStatus(hex) {
|
||
const s = (hex || '').toUpperCase();
|
||
if (!s) return [];
|
||
// TLV format (P2=02): E3 templates with structured tags
|
||
if (s.length >= 4 && s.substr(0, 2) === 'E3') {
|
||
const tlvs = parseTLV(s);
|
||
return tlvs.filter(t => t.tag === 'E3').map(t => {
|
||
const r = _parseE3Entry(t.value);
|
||
r.type = 'elf';
|
||
return r;
|
||
}).filter(r => r.aid);
|
||
}
|
||
// Raw format (P2=00 fallback): consecutive <aid_len><AID><lifecycle>
|
||
const out = [];
|
||
let i = 0;
|
||
while (i + 6 <= s.length) {
|
||
const r = _parseRawElfEntry(s, i);
|
||
if (!r) break;
|
||
out.push({ type: 'elf', ...r });
|
||
i = r.next;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Parse the '84' module-AID list (concatenated 4F<len><aid> TLVs) into an array
|
||
function ramParseModuleAids(hex) {
|
||
const out = [];
|
||
let j = 0;
|
||
const s = (hex || '').toUpperCase();
|
||
while (j + 4 <= s.length) {
|
||
const t = s.substr(j, 2);
|
||
const l = parseInt(s.substr(j + 2, 2), 16);
|
||
const v = s.substr(j + 4, l * 2);
|
||
j += 4 + l * 2;
|
||
if (t === '4F') out.push(v);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// GET DATA FF21 response: 81 app count, 82 free NV (3B), 83 free volatile (2B)
|
||
function ramParseGetMemory(hex) {
|
||
const s = (hex || '').toUpperCase();
|
||
if (!s.startsWith('FF21')) return null;
|
||
let i = 6; // Skip FF21 (4 bytes) + length byte (1 byte) = start at first tag
|
||
const out = {};
|
||
while (i + 4 <= s.length) {
|
||
const t = s.substr(i, 2);
|
||
const l = parseInt(s.substr(i + 2, 2), 16);
|
||
const v = s.substr(i + 4, l * 2);
|
||
i += 4 + l * 2;
|
||
if (t === '81') out.appCount = parseInt(v, 16);
|
||
else if (t === '82') out.freeNV = parseInt(v, 16);
|
||
else if (t === '83') out.freeV = parseInt(v, 16);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ===== Lifecycle / privilege formatting =====
|
||
const RAM_LIFECYCLE = {
|
||
'01': 'OP_READY', '07': 'SELECTABLE', '0F': 'PERSONALIZED',
|
||
'03': 'INSTALLED', '1F': 'SD_PERSONALIZED', '7F': 'LOCKED',
|
||
'FF': 'TERMINATED',
|
||
};
|
||
function ramFmtLifecycle(hex) {
|
||
return RAM_LIFECYCLE[hex] || ('0x' + hex);
|
||
}
|
||
|
||
// Decode GP Card Spec privileges TLV (tag C5) into human-readable strings
|
||
function ramFmtPrivileges(hex) {
|
||
const p = hex || '';
|
||
if (!p) return '(none)';
|
||
const bytes = p.match(/.{2}/g) || [];
|
||
const privs = [];
|
||
const b1 = parseInt(bytes[0] || '00', 16);
|
||
if (b1 & 0x01) privs.push('Card Lock');
|
||
if (b1 & 0x02) privs.push('Card Terminate');
|
||
if (b1 & 0x04) privs.push('Card Reset');
|
||
if (b1 & 0x08) privs.push('Cum Deletion Ctr');
|
||
if (b1 & 0x10) privs.push('GSM Card Binding');
|
||
if (b1 & 0x20) privs.push('Default Selected');
|
||
if (b1 & 0x40) privs.push('Global PIN');
|
||
const b2 = parseInt(bytes[1] || '00', 16);
|
||
if (b2 & 0x01) privs.push('Mandated DAP');
|
||
if (b2 & 0x02) privs.push('Security Domain');
|
||
if (b2 & 0x04) privs.push('DAP Verification');
|
||
if (b2 & 0x08) privs.push('Delegated Mgmt');
|
||
if (b2 & 0x10) privs.push('RFM');
|
||
if (b2 & 0x20) privs.push('CFM');
|
||
const b3 = parseInt(bytes[2] || '00', 16);
|
||
if (b3 & 0x01) privs.push('Receipt Gen');
|
||
if (b3 & 0x02) privs.push('Ciphered Load');
|
||
if (b3 & 0x04) privs.push('Delegated Perso');
|
||
if (b3 & 0x08) privs.push('Trusted Path');
|
||
if (b3 & 0x10) privs.push('Authorized Mgmt');
|
||
return privs.length ? privs.join(', ') : '(none)';
|
||
}
|
||
|
||
// Merge ELF-module entries (P1=10, carry module AIDs) into ELF entries (P1=20).
|
||
// With TLV format (P2=02), module AIDs come from tag '84' inside E3 templates.
|
||
// With raw format (P2=00), module AIDs are not available in the response.
|
||
function ramMergeElfData(elfs, modules) {
|
||
const byAid = {};
|
||
elfs.forEach(e => { if (e.aid) byAid[e.aid] = e; });
|
||
modules.forEach(m => {
|
||
if (m.aid && byAid[m.aid]) {
|
||
if (m.moduleAids && m.moduleAids.length) byAid[m.aid].moduleAids = m.moduleAids;
|
||
} else if (m.aid) {
|
||
elfs.push(m);
|
||
}
|
||
});
|
||
return elfs;
|
||
}
|
||
|
||
// Render the full explorer result as structured HTML
|
||
function ramRenderExploreHtml(mem, isd, apps, elfs) {
|
||
let html = '';
|
||
if (mem && (mem.appCount != null || mem.freeNV != null || mem.freeV != null)) {
|
||
html += '<div class="mb-4 p-3 bg-gray-50 dark:bg-slate-800 rounded">';
|
||
html += '<div class="font-semibold text-sm mb-1" data-l10n="Memory (GET DATA FF21)">Memory (GET DATA FF21)</div>';
|
||
html += '<div>Applications: ' + (mem.appCount != null ? mem.appCount : '?') + '</div>';
|
||
html += '<div>Free NV: ' + (mem.freeNV != null ? mem.freeNV + ' B' : '?') + '</div>';
|
||
html += '<div>Free Volatile: ' + (mem.freeV != null ? mem.freeV + ' B' : '?') + '</div>';
|
||
html += '</div>';
|
||
}
|
||
if (isd && isd.length) {
|
||
html += '<div class="mb-4">';
|
||
html += '<div class="font-semibold text-sm mb-1" data-l10n="ISD (Issuer Security Domain)">ISD (Issuer Security Domain)</div>';
|
||
isd.forEach(o => {
|
||
html += '<div class="mb-1 pl-2 border-l-2 border-blue-400">';
|
||
html += '<div>AID: ' + (o.aid || '?') + '</div>';
|
||
html += '<div>Lifecycle: ' + ramFmtLifecycle(o.lifecycle || '') + '</div>';
|
||
if (o.privileges) html += '<div>Privileges: ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')</div>';
|
||
if (o.sdAid) html += '<div>SD AID: ' + o.sdAid + '</div>';
|
||
html += '</div>';
|
||
});
|
||
html += '</div>';
|
||
}
|
||
if (apps && apps.length) {
|
||
html += '<div class="mb-4">';
|
||
html += '<div class="font-semibold text-sm mb-1" data-l10n="Applications / Applet Instances">Applications / Applet Instances</div>';
|
||
apps.forEach(o => {
|
||
html += '<div class="mb-1 pl-2 border-l-2 border-green-400">';
|
||
html += '<div>Application / Instance AID: ' + (o.aid || '?');
|
||
if (o.aid) {
|
||
html += ' <button onclick="ramDeleteFromExplorer(\'' + o.aid + '\', false)" data-needs="card" class="ml-2 px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Delete">Delete</button>';
|
||
}
|
||
html += '</div>';
|
||
html += '<div>Lifecycle: ' + ramFmtLifecycle(o.lifecycle || '') + '</div>';
|
||
if (o.privileges) html += '<div>Privileges: ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')</div>';
|
||
if (o.implicitSel) html += '<div>Implicit sel: ' + o.implicitSel + '</div>';
|
||
if (o.elfAid) html += '<div>Load File AID / Package AID: ' + o.elfAid + '</div>';
|
||
if (o.sdAid) html += '<div>SD AID: ' + o.sdAid + '</div>';
|
||
html += '</div>';
|
||
});
|
||
html += '</div>';
|
||
}
|
||
if (elfs && elfs.length) {
|
||
html += '<div class="mb-4">';
|
||
html += '<div class="font-semibold text-sm mb-1" data-l10n="Executable Load Files (ELFs) / Packages">Executable Load Files (ELFs) / Packages</div>';
|
||
elfs.forEach(o => {
|
||
html += '<div class="mb-1 pl-2 border-l-2 border-purple-400">';
|
||
html += '<div>Load File AID / Package AID: ' + (o.aid || '?');
|
||
if (o.aid) {
|
||
html += ' <button onclick="ramDeleteFromExplorer(\'' + o.aid + '\', false)" data-needs="card" class="ml-2 px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Delete">Delete</button>';
|
||
html += ' <button onclick="ramDeleteFromExplorer(\'' + o.aid + '\', true)" data-needs="card" class="ml-1 px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Delete All">Delete All</button>';
|
||
}
|
||
html += '</div>';
|
||
html += '<div>Lifecycle: ' + ramFmtLifecycle(o.lifecycle || '') + '</div>';
|
||
if (o.version) html += '<div>Version: ' + o.version + '</div>';
|
||
if (o.moduleAids && o.moduleAids.length) {
|
||
html += '<div>Executable Module AIDs / Applet Class AIDs:</div>';
|
||
o.moduleAids.forEach(m => { html += '<div class="pl-4">- ' + m + '</div>'; });
|
||
}
|
||
if (o.sdAid) html += '<div>SD AID: ' + o.sdAid + '</div>';
|
||
html += '</div>';
|
||
});
|
||
html += '</div>';
|
||
}
|
||
return html;
|
||
}
|
||
|
||
// ===== Operation handlers =====
|
||
async function ramExplore(sp) {
|
||
const resultEl = document.getElementById('ram-result');
|
||
const explorerEl = document.getElementById('ram-explorer');
|
||
const stepsEl = document.getElementById('ram-steps');
|
||
let cntr = sp.cntr;
|
||
const errors = [];
|
||
const mem = { appCount: null, freeNV: null, freeV: null };
|
||
const isd = [], apps = [], elfs = [], modules = [];
|
||
|
||
async function paginate(p1, collector, parser, label) {
|
||
// Chain GET STATUS + GET RESPONSE into a single SCP80 payload.
|
||
// The card's SCP80 layer executes both: GET STATUS returns 61XX,
|
||
// then GET RESPONSE fetches the data — the PoR captures the final
|
||
// result (9000 + response data) without the frontend handling 61XX.
|
||
// ELF queries (P1=20/10) use SPI2=0x21 (PoR via SMS-SUBMIT) because
|
||
// ELF data won't fit in the ENVELOPE response.
|
||
const isElf = (p1 === '20' || p1 === '10');
|
||
const spi2 = isElf ? '21' : '01';
|
||
// Try TLV format (P2=02) first for structured data; fall back to raw
|
||
// (P2=00) if the card doesn't support TLV GET STATUS.
|
||
for (const p2Init of ['02', '00']) {
|
||
let p2 = p2Init;
|
||
let guard = 0;
|
||
let tlvFailed = false;
|
||
while (guard++ < 32) {
|
||
// P2=02: no data field (card returns all tags). P2=00: Lc=02 TagList=4F00 (ignored by card).
|
||
const dataField = p2 === '02' ? '' : '024F0000';
|
||
const apdu = '80F2' + p1 + p2 + dataField + 'C0000000';
|
||
ramShowProgress(label + ' P1=' + p1 + ' P2=' + p2 + '...');
|
||
const res = await ramSendOta(apdu, Object.assign({}, sp, { cntr, spi2 }));
|
||
cntr = ramIncrementCntr(cntr);
|
||
if (!res.success || !res.por || res.por.response_status !== 'por_ok') {
|
||
const errorMsg = res.por ? res.por.response_status : (res.error || 'no data');
|
||
errors.push(label + ': ' + errorMsg);
|
||
tlvFailed = true;
|
||
break;
|
||
}
|
||
const data = res.por.decoded ? res.por.decoded.last_response_data : '';
|
||
const sw = (res.por.decoded ? res.por.decoded.last_status_word : '').toUpperCase();
|
||
// If first attempt with P2=02 gets an unsupported error, retry with P2=00.
|
||
if (guard === 1 && p2Init === '02' && (sw === '6A86' || sw === '6A88')) {
|
||
tlvFailed = true;
|
||
break;
|
||
}
|
||
// Defensive: 61XX means more data available (shouldn't happen with
|
||
// chained GET RESPONSE, but handle it if the card responds this way).
|
||
if (sw && sw.startsWith('61')) {
|
||
if (data) {
|
||
const parsed61 = parser(data);
|
||
if (parsed61.length) collector.push(...parsed61);
|
||
}
|
||
p2 = '01';
|
||
continue;
|
||
}
|
||
if (sw === '6F00') { tlvFailed = true; break; }
|
||
if (!data) {
|
||
if (sw !== '9000') {
|
||
errors.push(label + ': (no data) — SW ' + sw);
|
||
tlvFailed = true;
|
||
}
|
||
break;
|
||
}
|
||
const parsed = parser(data);
|
||
if (!parsed.length) break;
|
||
collector.push(...parsed);
|
||
if (sw === '9000') break;
|
||
p2 = '01';
|
||
}
|
||
if (!tlvFailed) break;
|
||
}
|
||
}
|
||
|
||
ramShowProgress('GET DATA FF21 (memory)...');
|
||
try {
|
||
const memRes = await ramSendOta('80CAFF2100', Object.assign({}, sp, { spi2: '01' }));
|
||
cntr = ramIncrementCntr(cntr);
|
||
if (memRes.success && memRes.por && memRes.por.response_status === 'por_ok') {
|
||
const data = memRes.por.decoded ? memRes.por.decoded.last_response_data : '';
|
||
if (!data) {
|
||
errors.push('Memory: (no data)');
|
||
} else {
|
||
const m = ramParseGetMemory(data);
|
||
if (m) Object.assign(mem, m);
|
||
}
|
||
} else {
|
||
const errorMsg = memRes.por ? memRes.por.response_status : (memRes.error || 'no data');
|
||
errors.push('Memory: ' + errorMsg);
|
||
}
|
||
} catch (e) {
|
||
errors.push('Memory: ' + e.message);
|
||
}
|
||
|
||
// 2-5. GET STATUS for ISD / Apps / ELFs / ELF modules
|
||
// P1 per GP Card Spec v2.3.1 table 11-33:
|
||
// 80=ISD, 40=Applications, 20=Executable Load Files (ELFs), 10=ELF+modules
|
||
await paginate('80', isd, ramParseAppStatus, 'ISD');
|
||
await paginate('40', apps, ramParseAppStatus, 'Apps');
|
||
await paginate('20', elfs, ramParseElfStatus, 'ELFs');
|
||
await paginate('10', modules, ramParseElfStatus, 'ELF Modules');
|
||
|
||
ramMergeElfData(elfs, modules);
|
||
ramSaveCntr(cntr);
|
||
ramHideProgress();
|
||
|
||
if (errors.length) {
|
||
resultEl.textContent = 'Partial — ' + errors.join('; ');
|
||
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
|
||
stepsEl.classList.remove('hidden');
|
||
stepsEl.textContent = errors.join('\n');
|
||
} else {
|
||
resultEl.textContent = 'OK — ' + isd.length + ' ISD, ' + apps.length + ' apps, ' + elfs.length + ' ELFs' + (mem.freeNV ? ', ' + mem.freeNV + ' free NV' : '');
|
||
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
|
||
}
|
||
|
||
const html = ramRenderExploreHtml(mem, isd, apps, elfs);
|
||
explorerEl.innerHTML = html || '(no data)';
|
||
explorerEl.classList.remove('hidden');
|
||
pysimApplyAvailability();
|
||
}
|
||
|
||
async function ramDeleteFromExplorer(aid, withCascade) {
|
||
const sp = getRamSpParams();
|
||
if (!sp.kicKey || !sp.kidKey) {
|
||
alert('Select a card preset with keys first (RAM subtab → Card preset)');
|
||
return;
|
||
}
|
||
const label = withCascade ? 'DELETE (cascade)' : 'DELETE';
|
||
if (!confirm(label + ' — AID: ' + aid + '?')) return;
|
||
const p2 = withCascade ? '80' : '00';
|
||
const aidLen = (aid.length / 2).toString(16).padStart(2, '0');
|
||
const apdu = '80E400' + p2 + _ber_len(2 + aid.length / 2) + '4F' + aidLen + aid;
|
||
ramShowProgress(label + ' ' + aid + '...');
|
||
const res = await ramSendOta(apdu, sp);
|
||
ramHideProgress();
|
||
const resultEl = document.getElementById('ram-result');
|
||
const stepsEl = document.getElementById('ram-steps');
|
||
if (!res.success || !res.por || res.por.response_status !== 'por_ok') {
|
||
resultEl.textContent = 'Failed: ' + (res.por ? res.por.response_status : res.error || res.sw);
|
||
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
|
||
return;
|
||
}
|
||
ramSaveCntr(ramIncrementCntr(sp.cntr));
|
||
const sw = res.por.decoded ? res.por.decoded.last_status_word : '';
|
||
stepsEl.classList.remove('hidden');
|
||
stepsEl.textContent = label + ' ' + aid + ' -> ' + sw;
|
||
resultEl.textContent = 'OK';
|
||
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
|
||
await ramExplore(sp);
|
||
}
|
||
|
||
async function ramInstallCap(sp) {
|
||
const fileInput = document.getElementById('ram-cap-file');
|
||
const file = fileInput.files[0];
|
||
if (!file) { alert('Select a .cap file'); return; }
|
||
if (file.size > 48 * 1024) { alert('CAP file exceeds 48 kB limit'); return; }
|
||
|
||
ramShowProgress('Reading CAP file...');
|
||
const capHex = await ramReadFileHex(file);
|
||
ramShowProgress('Sending to server for install...');
|
||
|
||
const body = {
|
||
cap_hex: capHex,
|
||
sd_aid: (document.getElementById('ram-sd-aid').value || '').replace(/[^0-9a-fA-F]/g, ''),
|
||
privileges: (document.getElementById('rc-priv').value || '00').replace(/[^0-9a-fA-F]/g, ''),
|
||
install_params: (document.getElementById('ram-install-params-hex').value || '').replace(/[^0-9a-fA-F]/g, ''),
|
||
stk_params: (document.getElementById('ram-stk-params').value || '').replace(/[^0-9a-fA-F]/g, ''),
|
||
make_selectable: document.getElementById('ram-make-sel').checked,
|
||
spi1: sp.spi1, spi2: '01', kic: sp.kic, kid: sp.kid,
|
||
tar: sp.tar, cntr: sp.cntr, kicKey: sp.kicKey, kidKey: sp.kidKey,
|
||
};
|
||
|
||
const data = await pysimFetch('/api/ram-install', body);
|
||
|
||
ramHideProgress();
|
||
const resultEl = document.getElementById('ram-result');
|
||
const stepsEl = document.getElementById('ram-steps');
|
||
stepsEl.classList.remove('hidden');
|
||
|
||
let txt = '';
|
||
(data.steps || []).forEach((s, idx) => {
|
||
const mark = s.por_status === 'por_ok' ? '✅' : '❌';
|
||
txt += mark + ' Step ' + (idx + 1) + ': ' + s.name + ' — ' + s.por_status + ' (SW ' + s.sw + ')\n';
|
||
});
|
||
stepsEl.textContent = txt;
|
||
|
||
if (data.success) {
|
||
ramSaveCntr(data.final_cntr);
|
||
resultEl.textContent = 'Install OK — load_file_aid=' + data.load_file_aid + ' module_aid=' + data.module_aid;
|
||
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
|
||
} else {
|
||
resultEl.textContent = 'Install FAILED at step: ' + data.failed_step + (data.error ? ' (' + data.error + ')' : '');
|
||
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
|
||
}
|
||
}
|
||
|
||
async function ramExecute() {
|
||
ramClearResults();
|
||
const op = document.getElementById('ram-op').value;
|
||
const sp = getRamSpParams();
|
||
if (!sp.kicKey || !sp.kidKey) {
|
||
alert('Select a card preset with keys first (RAM subtab → Card preset)');
|
||
return;
|
||
}
|
||
try {
|
||
if (op === 'explore') await ramExplore(sp);
|
||
else if (op === 'install-cap') await ramInstallCap(sp);
|
||
} catch (e) {
|
||
const resultEl = document.getElementById('ram-result');
|
||
resultEl.textContent = 'Error: ' + e.message;
|
||
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
|
||
ramHideProgress();
|
||
}
|
||
}
|
||
|
||
// ===== STK Menu Browser =====
|
||
let stkMenuStack = [];
|
||
|
||
async function stkCheckMenu() {
|
||
try {
|
||
const data = await pysimFetch('/api/menu');
|
||
const btn = document.getElementById('stk-menu-btn');
|
||
const none = document.getElementById('stk-menu-none');
|
||
if (data && data.title && data.items && data.items.length) {
|
||
document.getElementById('stk-menu-title').textContent = data.title;
|
||
btn.style.display = '';
|
||
none.style.display = 'none';
|
||
} else {
|
||
btn.style.display = 'none';
|
||
none.style.display = '';
|
||
}
|
||
} catch (e) {
|
||
console.error('stkCheckMenu error:', e);
|
||
}
|
||
}
|
||
|
||
function stkSetPillsDisabled(disabled) {
|
||
document.querySelectorAll('.tab-btn, .pysim-subtab').forEach(b => {
|
||
b.disabled = disabled;
|
||
b.classList.toggle('opacity-50', disabled);
|
||
});
|
||
}
|
||
|
||
function stkMenuOpen() {
|
||
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-buttons').classList.add('hidden');
|
||
document.getElementById('stk-menu-panel').classList.remove('hidden');
|
||
stkMenuRenderItems();
|
||
}
|
||
|
||
function stkMenuClose() {
|
||
document.getElementById('stk-menu-panel').classList.add('hidden');
|
||
stkSetPillsDisabled(false);
|
||
}
|
||
|
||
async function stkMenuExit() {
|
||
try {
|
||
await pysimFetch('/api/menu-respond', { result: 'cancel' });
|
||
} catch (e) {}
|
||
stkMenuClose();
|
||
}
|
||
|
||
function stkMenuRenderItems() {
|
||
const content = document.getElementById('stk-menu-panel-content');
|
||
content.innerHTML = '<span class="text-gray-500 text-sm">Loading...</span>';
|
||
pysimFetch('/api/menu').then(data => {
|
||
if (!data || !data.items) { content.innerHTML = '<span class="text-red-500 text-sm">No menu data</span>'; return; }
|
||
let html = '<div class="space-y-1">';
|
||
data.items.forEach(item => {
|
||
html += '<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-slate-700 cursor-pointer" onclick="stkMenuItemClick(' + item.id + ')"><span class="w-5 text-xs text-gray-300 dark:text-gray-600 text-right">' + item.id + '</span><span class="text-sm">' + esc(item.text) + '</span></div>';
|
||
});
|
||
html += '</div>';
|
||
content.innerHTML = html;
|
||
}).catch(() => { content.innerHTML = '<span class="text-red-500 text-sm">Error loading menu</span>'; });
|
||
}
|
||
|
||
async function stkMenuItemClick(itemId) {
|
||
stkSetPillsDisabled(true);
|
||
const content = document.getElementById('stk-menu-panel-content');
|
||
content.innerHTML = '<span class="text-gray-500 text-sm">Sending...</span>';
|
||
try {
|
||
const data = await pysimFetch('/api/menu-select', { item_id: itemId });
|
||
stkMenuHandleResponse(data);
|
||
} catch (e) {
|
||
content.innerHTML = '<span class="text-red-500 text-sm">Error: ' + esc(e.message) + '</span>';
|
||
stkSetPillsDisabled(false);
|
||
}
|
||
}
|
||
|
||
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');
|
||
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>';
|
||
backBtn.style.display = '';
|
||
document.getElementById('stk-ok-btn').style.display = '';
|
||
btns.classList.remove('hidden');
|
||
} else if (data.type === 'select_item') {
|
||
stkMenuStack.push(data.items);
|
||
content.innerHTML = '';
|
||
const items = data.items;
|
||
let html = '<div class="space-y-1">';
|
||
items.forEach(item => {
|
||
html += '<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-slate-700 cursor-pointer" onclick="stkSubItemClick(' + item.id + ')"><span class="w-5 text-xs text-gray-300 dark:text-gray-600 text-right">' + item.id + '</span><span class="text-sm">' + esc(item.text) + '</span></div>';
|
||
});
|
||
html += '</div>';
|
||
content.innerHTML = html;
|
||
backBtn.style.display = '';
|
||
document.getElementById('stk-ok-btn').style.display = 'none';
|
||
btns.classList.remove('hidden');
|
||
} else {
|
||
content.innerHTML = '<span class="text-gray-500 text-sm">SW: ' + esc(data.sw || '?') + '</span>';
|
||
stkSetPillsDisabled(false);
|
||
backBtn.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async function stkSubItemClick(itemId) {
|
||
const content = document.getElementById('stk-menu-panel-content');
|
||
content.innerHTML = '<span class="text-gray-500 text-sm">Sending...</span>';
|
||
try {
|
||
const data = await pysimFetch('/api/menu-respond', { result: 'ok', item_id: itemId });
|
||
stkMenuHandleResponse(data);
|
||
} catch (e) {
|
||
content.innerHTML = '<span class="text-red-500 text-sm">Error: ' + esc(e.message) + '</span>';
|
||
}
|
||
}
|
||
|
||
async function stkMenuRespond(result) {
|
||
try {
|
||
const data = await pysimFetch('/api/menu-respond', { result: result });
|
||
if (result === 'cancel' || result === 'back' || result === 'timeout') {
|
||
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);
|
||
} catch (e) {
|
||
document.getElementById('stk-menu-panel-content').innerHTML = '<span class="text-red-500 text-sm">Error: ' + esc(e.message) + '</span>';
|
||
}
|
||
}
|
||
|
||
// ===== Cards storage =====
|
||
let cards = [];
|
||
|
||
function cardsLoad() {
|
||
try {
|
||
const d = localStorage.getItem('otaman_cards');
|
||
cards = d ? JSON.parse(d) : [];
|
||
} catch (e) { cards = []; }
|
||
cardsRebuildSelect();
|
||
cardsRender();
|
||
}
|
||
|
||
function cardsSave() {
|
||
localStorage.setItem('otaman_cards', JSON.stringify(cards));
|
||
cardsRebuildSelect();
|
||
cardsRender();
|
||
}
|
||
|
||
function cardsAdd() {
|
||
const name = document.getElementById('cards-name').value.trim();
|
||
const iccid = document.getElementById('cards-iccid').value.trim();
|
||
const kic = document.getElementById('cards-kic').value.trim();
|
||
const kid = document.getElementById('cards-kid').value.trim();
|
||
const spi1 = document.getElementById('cards-spi1').value.trim();
|
||
const spi2 = document.getElementById('cards-spi2').value.trim();
|
||
const tar = document.getElementById('cards-tar').value.trim();
|
||
const cntr = document.getElementById('cards-cntr').value.trim();
|
||
const kicKey = document.getElementById('cards-kic-key').value.trim();
|
||
const kidKey = document.getElementById('cards-kid-key').value.trim();
|
||
if (!name) { alert('Name is required'); return; }
|
||
if (!kic || !kid) { alert('KIc and KID are required'); return; }
|
||
if (!kicKey || !kidKey) { alert('KIc key and KID key are required'); return; }
|
||
if (iccid && cards.some(c => c.iccid === iccid)) { alert('Card with this ICCID already exists'); return; }
|
||
cards.push({
|
||
name: name,
|
||
iccid: iccid,
|
||
kic: kic,
|
||
kid: kid,
|
||
spi1: spi1 || '16',
|
||
spi2: spi2 || '01',
|
||
tar: tar,
|
||
cntr: cntr || '0000000001',
|
||
kicKey: kicKey,
|
||
kidKey: kidKey,
|
||
});
|
||
cardsSave();
|
||
['name','iccid','kic','kid','spi1','spi2','tar','cntr','kic-key','kid-key'].forEach(id => {
|
||
document.getElementById('cards-' + id).value = '';
|
||
});
|
||
}
|
||
|
||
function cardsRemove(i) {
|
||
cards.splice(i, 1);
|
||
cardsSave();
|
||
cardsRender();
|
||
}
|
||
|
||
function cardsRender() {
|
||
const tbody = document.getElementById('cards-tbody');
|
||
if (!cards.length) { tbody.innerHTML = '<tr><td colspan="9" class="py-2 text-gray-400">' + t('No cards defined.') + '</td></tr>'; return; }
|
||
let html = '';
|
||
for (let i = 0; i < cards.length; i++) {
|
||
const c = cards[i];
|
||
html += '<tr class="border-b border-gray-100 dark:border-slate-700">';
|
||
html += '<td class="py-1 px-2 font-medium">' + esc(c.name) + '</td>';
|
||
html += '<td class="py-1 px-2 font-mono text-xs">' + esc(c.iccid) + '</td>';
|
||
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.kic) + '</td>';
|
||
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.kid) + '</td>';
|
||
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.spi1) + '</td>';
|
||
html += '<td class="py-1 px-2 font-mono text-xs text-center">' + esc(c.spi2) + '</td>';
|
||
html += '<td class="py-1 px-2 font-mono text-xs">' + esc(c.tar || '') + '</td>';
|
||
html += '<td class="py-1 px-2 font-mono text-xs">' + esc(c.cntr) + '</td>';
|
||
html += '<td class="py-1 px-2"><button onclick="cardsRemove(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-red-100 text-red-700 hover:bg-red-200">' + t('Remove') + '</button></td>';
|
||
html += '</tr>';
|
||
}
|
||
tbody.innerHTML = html;
|
||
}
|
||
|
||
function cardsRebuildSelect() {
|
||
const sel = document.getElementById('sp-card-sel');
|
||
if (!sel) return;
|
||
let html = '<option value="">' + t('— Select card —') + '</option>';
|
||
for (let i = 0; i < cards.length; i++) {
|
||
html += '<option value="' + i + '">' + esc(cards[i].name) + ' (' + esc(cards[i].iccid) + ')</option>';
|
||
}
|
||
sel.innerHTML = html;
|
||
}
|
||
|
||
function cardsApply(idx) {
|
||
const c = cards[parseInt(idx)];
|
||
if (!c) return;
|
||
document.getElementById('sp-spi1').value = c.spi1;
|
||
document.getElementById('sp-spi2-hex').value = c.spi2;
|
||
const kicByte = parseInt(c.kic, 16);
|
||
document.getElementById('sp-kic-idx').value = ((kicByte >> 4) & 0x0F).toString(16).toUpperCase();
|
||
document.getElementById('sp-kic-alg').value = (kicByte & 0x0F).toString(16).padStart(2, '0');
|
||
updateSpKic();
|
||
const kidByte = parseInt(c.kid, 16);
|
||
document.getElementById('sp-kid-idx').value = ((kidByte >> 4) & 0x0F).toString(16).toUpperCase();
|
||
document.getElementById('sp-kid-alg').value = (kidByte & 0x0F).toString(16).padStart(2, '0');
|
||
updateSpKid();
|
||
document.getElementById('sp-tar').value = c.tar || 'B00001';
|
||
document.getElementById('sp-cntr').value = c.cntr;
|
||
document.getElementById('sp-kic-key').value = c.kicKey;
|
||
document.getElementById('sp-kid-key').value = c.kidKey;
|
||
spInvalidate();
|
||
updateSp();
|
||
genSp();
|
||
}
|
||
|
||
function cardsExport() {
|
||
const el = document.getElementById('cards-io');
|
||
el.value = JSON.stringify(cards, null, 2);
|
||
el.classList.remove('hidden');
|
||
el.select();
|
||
}
|
||
|
||
function downloadJson(name, obj) {
|
||
const blob = new Blob([JSON.stringify(obj, null, 2)], {type: 'application/json'});
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = name;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
}
|
||
|
||
function cardsExportFile() {
|
||
downloadJson('otaman-cards.json', cards);
|
||
}
|
||
|
||
async function cardsImport(text) {
|
||
try {
|
||
if (text == null) text = await navigator.clipboard.readText();
|
||
const imported = JSON.parse(text);
|
||
if (!Array.isArray(imported)) throw new Error('Expected an array');
|
||
let added = 0;
|
||
for (const entry of imported) {
|
||
if (!entry.name) continue;
|
||
if (entry.iccid && cards.some(c => c.iccid === entry.iccid)) continue;
|
||
cards.push({
|
||
name: entry.name,
|
||
iccid: entry.iccid,
|
||
kic: entry.kic || '15',
|
||
kid: entry.kid || '15',
|
||
spi1: entry.spi1 || '16',
|
||
spi2: entry.spi2 || '01',
|
||
tar: entry.tar || '',
|
||
cntr: entry.cntr || '0000000001',
|
||
kicKey: entry.kicKey || '',
|
||
kidKey: entry.kidKey || '',
|
||
});
|
||
added++;
|
||
}
|
||
cardsSave();
|
||
cardsRender();
|
||
const el = document.getElementById('cards-io');
|
||
el.value = 'Imported ' + added + ' cards.';
|
||
setTimeout(() => { el.classList.add('hidden'); }, 2000);
|
||
} catch (e) {
|
||
alert('Import error: ' + e.message);
|
||
}
|
||
}
|
||
|
||
function cardsImportFile(input) {
|
||
const file = input.files && input.files[0];
|
||
input.value = '';
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => cardsImport(reader.result);
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
function cardsImportField() {
|
||
const el = document.getElementById('cards-io');
|
||
const val = el.value.trim();
|
||
if (!el.classList.contains('hidden') && val.startsWith('[')) { cardsImport(val); return; }
|
||
el.value = '';
|
||
el.placeholder = 'Paste JSON array here, then press Paste & import again';
|
||
el.classList.remove('hidden');
|
||
el.focus();
|
||
}
|
||
|
||
// ===== pysim sub-tabs =====
|
||
let pysimCmdHistory = [];
|
||
let pysimCmdHistIdx = -1;
|
||
let _pysimAutoConnectAttempted = false;
|
||
|
||
function pysimSwitchSubtab(name) {
|
||
document.querySelectorAll('.pysim-subtab').forEach(btn => {
|
||
const isActive = btn.dataset.pysimSub === name;
|
||
btn.classList.toggle('bg-blue-600', isActive);
|
||
btn.classList.toggle('text-white', isActive);
|
||
btn.classList.toggle('bg-gray-200', !isActive);
|
||
btn.classList.toggle('dark:bg-slate-700', !isActive);
|
||
btn.classList.toggle('text-gray-700', !isActive);
|
||
btn.classList.toggle('dark:text-slate-300', !isActive);
|
||
});
|
||
['cmd', 'apdu', 'files'].forEach(s => {
|
||
document.getElementById('pysim-sub-' + s).classList.toggle('hidden', s !== name);
|
||
});
|
||
pysimHelpAnchor = {cmd:'pysim-cmdline', apdu:'raw-apdu', files:'file-manager'}[name] || 'card-reader';
|
||
setHelpAnchor(pysimHelpAnchor);
|
||
}
|
||
|
||
function pysimCmdKeydown(e) {
|
||
if (e.key === 'Enter') {
|
||
const cmd = e.target.value.trim();
|
||
if (cmd) {
|
||
pysimCmdHistory.push(cmd);
|
||
pysimCmdHistIdx = pysimCmdHistory.length;
|
||
}
|
||
pysimExec();
|
||
document.getElementById('pysim-cmd-hint').classList.add('hidden');
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
if (pysimCmdHistIdx > 0) {
|
||
pysimCmdHistIdx--;
|
||
e.target.value = pysimCmdHistory[pysimCmdHistIdx];
|
||
}
|
||
} else if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
if (pysimCmdHistIdx < pysimCmdHistory.length - 1) {
|
||
pysimCmdHistIdx++;
|
||
e.target.value = pysimCmdHistory[pysimCmdHistIdx];
|
||
} else {
|
||
pysimCmdHistIdx = pysimCmdHistory.length;
|
||
e.target.value = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
let pysimCmdHintTimeout = null;
|
||
|
||
async function pysimCmdHint() {
|
||
clearTimeout(pysimCmdHintTimeout);
|
||
pysimCmdHintTimeout = setTimeout(async () => {
|
||
const input = document.getElementById('pysim-cmd');
|
||
const text = input.value.trim();
|
||
const cmd = text.split(/\s+/)[0];
|
||
if (!cmd || text.includes(' ')) {
|
||
document.getElementById('pysim-cmd-hint').classList.add('hidden');
|
||
return;
|
||
}
|
||
const hintEl = document.getElementById('pysim-cmd-hint');
|
||
try {
|
||
const data = await pysimFetch('/api/help', { cmd });
|
||
if (data.usage) {
|
||
hintEl.textContent = data.usage;
|
||
hintEl.classList.remove('hidden');
|
||
} else {
|
||
hintEl.classList.add('hidden');
|
||
}
|
||
} catch (e) {
|
||
hintEl.classList.add('hidden');
|
||
}
|
||
}, 300);
|
||
}
|
||
|
||
let pysimCmdList = [];
|
||
|
||
async function pysimLoadCommands() {
|
||
try {
|
||
const data = await pysimFetch('/api/commands');
|
||
if (Array.isArray(data)) pysimCmdList = data;
|
||
} catch (e) {}
|
||
}
|
||
|
||
function pysimCmdInput() {
|
||
const input = document.getElementById('pysim-cmd');
|
||
const text = input.value;
|
||
const suggestEl = document.getElementById('pysim-cmd-suggest');
|
||
const hintEl = document.getElementById('pysim-cmd-hint');
|
||
if (text.includes(' ')) {
|
||
suggestEl.classList.add('hidden');
|
||
const cmd = text.split(/\s+/)[0];
|
||
if (cmd) {
|
||
clearTimeout(pysimCmdHintTimeout);
|
||
pysimCmdHintTimeout = setTimeout(async () => {
|
||
try {
|
||
const data = await pysimFetch('/api/help', { cmd });
|
||
if (data.usage) {
|
||
hintEl.textContent = data.usage;
|
||
hintEl.classList.remove('hidden');
|
||
} else {
|
||
hintEl.classList.add('hidden');
|
||
}
|
||
} catch (e) {
|
||
hintEl.classList.add('hidden');
|
||
}
|
||
}, 300);
|
||
return;
|
||
}
|
||
hintEl.classList.add('hidden');
|
||
return;
|
||
}
|
||
hintEl.classList.add('hidden');
|
||
if (!text) { suggestEl.classList.add('hidden'); return; }
|
||
const matches = pysimCmdList.filter(c => c.startsWith(text)).slice(0, 20);
|
||
if (matches.length === 0) { suggestEl.classList.add('hidden'); return; }
|
||
suggestEl.innerHTML = matches.map(c =>
|
||
'<div class="px-2 py-1 cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-700" onclick="pysimCmdSelect(\'' + esc(c) + '\')">' + esc(c) + '</div>'
|
||
).join('');
|
||
suggestEl.classList.remove('hidden');
|
||
}
|
||
|
||
function pysimCmdSelect(cmd) {
|
||
document.getElementById('pysim-cmd').value = cmd;
|
||
document.getElementById('pysim-cmd-suggest').classList.add('hidden');
|
||
document.getElementById('pysim-cmd').focus();
|
||
}
|
||
|
||
// ===== pysim file browser =====
|
||
let pysimFsTreeRoot = null;
|
||
|
||
function getParentSel(node) {
|
||
if (!node || !node.parent) return null;
|
||
const p = node.parent;
|
||
if (p.name === 'MF') return 'MF';
|
||
if (p.name.startsWith('ADF.')) return p.name;
|
||
if (p.fid) return p.fid;
|
||
return p.name;
|
||
}
|
||
|
||
function pysimCustomInject(node) {
|
||
if (!node || !node.fid) return;
|
||
const pfid = node.fid.toLowerCase();
|
||
for (const c of pysimCustomFiles) {
|
||
if (c.parentFid.toLowerCase() !== pfid) continue;
|
||
const existing = (node.children || []).find(ch => ch.fid?.toLowerCase() === c.fid.toLowerCase());
|
||
if (existing) {
|
||
existing.name = c.name;
|
||
existing.custom = true;
|
||
} else {
|
||
node.children = node.children || [];
|
||
node.children.push({
|
||
name: c.name,
|
||
fid: c.fid,
|
||
isDir: c.name.startsWith('DF.') || c.name.startsWith('ADF.'),
|
||
children: null,
|
||
expanded: false,
|
||
exists: null,
|
||
custom: true,
|
||
parent: node,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
async function pysimFsRefresh() {
|
||
const detailEl = document.getElementById('pysim-fs-detail');
|
||
detailEl.classList.add('hidden');
|
||
const data = await pysimFetch('/api/tree', { name: 'MF' });
|
||
pysimFsTreeRoot = {
|
||
name: data.name || 'MF',
|
||
path: data.name || 'MF',
|
||
fid: data.fid || '3F00',
|
||
isDir: true,
|
||
children: null,
|
||
expanded: true,
|
||
parent: null,
|
||
};
|
||
pysimFsTreeRoot.children = (data.children || []).map(c => ({
|
||
name: c.name,
|
||
fid: c.fid,
|
||
isDir: c.isDir,
|
||
aid: c.aid,
|
||
children: null,
|
||
expanded: false,
|
||
exists: true,
|
||
parent: pysimFsTreeRoot,
|
||
}));
|
||
pysimFsTreeRoot.exists = true;
|
||
pysimCustomInject(pysimFsTreeRoot);
|
||
pysimFsRenderTree();
|
||
}
|
||
|
||
async function pysimFsLoadChildren(node) {
|
||
if (node.children) return;
|
||
try {
|
||
let body = { name: node.name, fid: node.fid };
|
||
const parentSel = getParentSel(node);
|
||
if (parentSel) body.parent_sel = parentSel;
|
||
let data = await pysimFetch('/api/tree', body);
|
||
if (!data || data.exists === false) {
|
||
body = { name: node.name, fid: node.fid };
|
||
data = await pysimFetch('/api/tree', body);
|
||
}
|
||
if (!data || data.exists === false) {
|
||
throw new Error('File not found');
|
||
}
|
||
node.children = (data.children || []).map(c => ({
|
||
name: c.name,
|
||
fid: c.fid,
|
||
isDir: c.isDir,
|
||
aid: c.aid,
|
||
children: null,
|
||
expanded: false,
|
||
exists: true,
|
||
parent: node,
|
||
}));
|
||
node.exists = true;
|
||
pysimCustomInject(node);
|
||
} catch (e) {
|
||
node.exists = false;
|
||
node.children = [];
|
||
pysimFsRenderTree();
|
||
}
|
||
}
|
||
|
||
function pysimFsRenderTree() {
|
||
const el = document.getElementById('pysim-fs-tree');
|
||
if (!pysimFsTreeRoot) { el.innerHTML = '(loading)'; return; }
|
||
el.innerHTML = pysimFsRenderNode(pysimFsTreeRoot, 0);
|
||
}
|
||
|
||
function pysimFsRenderNode(node, depth) {
|
||
if (!node) return '';
|
||
const pad = ' '.repeat(depth * 4);
|
||
const isDir = node.isDir;
|
||
const exists = node.exists !== false;
|
||
const verified = node.exists !== null;
|
||
const color = verified ? (exists ? (isDir ? 'text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-slate-300') : 'text-red-400 dark:text-red-400') : 'text-gray-500 dark:text-slate-400';
|
||
const italic = node.custom ? ' italic' : '';
|
||
const fidStr = node.fid ? ' <span class="text-gray-400 dark:text-slate-500">(' + node.fid.toUpperCase() + ')</span>' : '';
|
||
const aidStr = node.aid ? ' <span class="text-gray-400 dark:text-slate-500">[' + node.aid + ']</span>' : '';
|
||
let html = '<div class="' + color + italic + '">';
|
||
html += pad;
|
||
if (verified && !exists) {
|
||
html += '✗ ';
|
||
}
|
||
if (isDir) {
|
||
if ((verified && exists) || !verified) {
|
||
const toggle = node.children && node.expanded ? '▼' : '▶';
|
||
html += '<span class="cursor-pointer select-none' + italic + '" onclick="pysimFsToggleDir(\'' + esc(node.name) + '\')">' + toggle + '</span> ';
|
||
html += '<span class="cursor-pointer hover:underline' + italic + '" onclick="pysimFsToggleDir(\'' + esc(node.name) + '\')">' + esc(node.name) + fidStr + aidStr + '</span>';
|
||
} else {
|
||
html += esc(node.name) + fidStr + aidStr + '</div>';
|
||
return html;
|
||
}
|
||
} else {
|
||
html += '<span class="cursor-pointer hover:underline ml-4' + italic + '"' +
|
||
((verified && exists) ? ' onclick="pysimFsClickFile(\'' + esc(node.name) + '\')"' : (!verified ? ' onclick="pysimFsClickFile(\'' + esc(node.name) + '\')"' : '')) + '>' + esc(node.name) + fidStr + aidStr + '</span>';
|
||
}
|
||
html += '</div>';
|
||
if (isDir && node.children && node.expanded) {
|
||
for (const child of node.children) {
|
||
html += pysimFsRenderNode(child, depth + 1);
|
||
}
|
||
}
|
||
return html;
|
||
}
|
||
|
||
function pysimFsFindNode(name, root) {
|
||
if (!root) return null;
|
||
if (root.name === name) return root;
|
||
if (root.children) {
|
||
for (const c of root.children) {
|
||
const found = pysimFsFindNode(name, c);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function pysimFsToggleDir(name) {
|
||
const node = pysimFsFindNode(name, pysimFsTreeRoot);
|
||
if (!node || !node.isDir) return;
|
||
if (!node.children) {
|
||
await pysimFsLoadChildren(node);
|
||
}
|
||
node.expanded = !node.expanded;
|
||
pysimFsRenderTree();
|
||
if (node.expanded) pysimRefresh();
|
||
}
|
||
|
||
async function pysimFsClickFile(name) {
|
||
pysimFsResetEdit();
|
||
pysimFsSetMode('raw');
|
||
const node = pysimFsFindNode(name, pysimFsTreeRoot);
|
||
let body = { name: name, fid: node ? node.fid : null };
|
||
const parentSel = getParentSel(node);
|
||
if (parentSel) body.parent_sel = parentSel;
|
||
let sel = await pysimFetch('/api/select', body);
|
||
if (!sel || sel.exists === false) {
|
||
body = { name: name, fid: node ? node.fid : null };
|
||
sel = await pysimFetch('/api/select', body);
|
||
}
|
||
const exists = sel && sel.exists !== false;
|
||
if (node) {
|
||
node.exists = exists;
|
||
if (!exists) node.children = [];
|
||
pysimFsRenderTree();
|
||
}
|
||
const ft = exists ? sel.file_type : null;
|
||
const label = ({ transparent: 'TR', linear_fixed: 'LF', cyclic: 'CY', ber_tlv: 'BT', df: 'DF' })[ft] || '';
|
||
const prefix = exists ? '' : '✗ ';
|
||
document.getElementById('pysim-fs-filename').textContent = prefix + name + (label ? ' [' + label + ']' : '');
|
||
document.getElementById('pysim-fs-content').innerHTML = '';
|
||
document.getElementById('pysim-fs-status').textContent = exists ? '' : '✗ File not found on card';
|
||
if (exists) {
|
||
document.getElementById('pysim-fs-detail').classList.remove('hidden');
|
||
} else {
|
||
document.getElementById('pysim-fs-detail').classList.add('hidden');
|
||
}
|
||
pysimRefresh();
|
||
}
|
||
|
||
async function pysimFsRead() {
|
||
const rawName = document.getElementById('pysim-fs-filename').textContent.replace(/ \[.*?\]$/, '').replace(/^✗ /, '');
|
||
const out = document.getElementById('pysim-fs-content');
|
||
const statusEl = document.getElementById('pysim-fs-status');
|
||
out.innerHTML = '';
|
||
statusEl.textContent = 'Loading...';
|
||
try {
|
||
const node = pysimFsFindNode(rawName, pysimFsTreeRoot);
|
||
let body = { name: rawName, fid: node ? node.fid : null, mode: pysimFsDecodedMode ? 'decoded' : 'raw' };
|
||
const parentSel = getParentSel(node);
|
||
if (parentSel) body.parent_sel = parentSel;
|
||
let data = await pysimFetch('/api/read', body);
|
||
if (!data.success) {
|
||
body = { name: rawName, fid: node ? node.fid : null, mode: pysimFsDecodedMode ? 'decoded' : 'raw' };
|
||
data = await pysimFetch('/api/read', body);
|
||
}
|
||
if (!data.success) {
|
||
statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error');
|
||
return;
|
||
}
|
||
statusEl.textContent = 'SW: ' + data.sw + ' OK';
|
||
if (data.decoded) {
|
||
out.innerHTML = '<pre class="text-xs leading-relaxed">' + esc(JSON.stringify(data.decoded, null, 2)) + '</pre>';
|
||
} else if (data.records) {
|
||
let html = '';
|
||
for (const r of data.records) {
|
||
html += '<div class="flex items-center gap-1 mb-1">' +
|
||
'<input type="checkbox" class="pysim-fs-record-cb shrink-0 hidden">' +
|
||
'<span class="text-xs w-8 text-gray-500 dark:text-slate-400 shrink-0">' + r.num + '</span>' +
|
||
'<input class="flex-1 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-white dark:bg-slate-800 text-gray-800 dark:text-slate-200" style="overflow-x:auto;min-width:100px" value="' + esc(r.data) + '" readonly>' +
|
||
'</div>';
|
||
}
|
||
out.innerHTML = html || '(no records)';
|
||
} else {
|
||
out.innerHTML = '<textarea rows="8" readonly class="w-full font-mono text-xs border-0 bg-transparent resize-none">' + esc(data.data || '(no output)') + '</textarea>';
|
||
}
|
||
} catch (e) {
|
||
statusEl.textContent = 'Error: ' + e.message;
|
||
}
|
||
}
|
||
|
||
async function pysimFsSelect() {
|
||
const name = document.getElementById('pysim-fs-filename').textContent;
|
||
const node = pysimFsFindNode(name, pysimFsTreeRoot);
|
||
if (node && node.fid) {
|
||
await pysimFetch('/api/command', { cmd: 'select ' + node.fid });
|
||
} else {
|
||
await pysimFetch('/api/command', { cmd: 'select ' + name });
|
||
}
|
||
pysimFsRefresh();
|
||
pysimRefresh();
|
||
}
|
||
|
||
let pysimFsEditMode = false;
|
||
let pysimFsEditData = null;
|
||
|
||
function pysimFsEdit() {
|
||
pysimFsEditMode = true;
|
||
document.getElementById('pysim-fs-read-btn').classList.add('hidden');
|
||
document.getElementById('pysim-fs-edit-btn').classList.add('hidden');
|
||
document.getElementById('pysim-fs-save-btn').classList.remove('hidden');
|
||
document.getElementById('pysim-fs-cancel-btn').classList.remove('hidden');
|
||
const out = document.getElementById('pysim-fs-content');
|
||
out.querySelectorAll('textarea, input:not([type=checkbox])').forEach(el => el.removeAttribute('readonly'));
|
||
out.querySelectorAll('.pysim-fs-record-cb').forEach(cb => cb.classList.remove('hidden'));
|
||
out.querySelectorAll('input:not([type=checkbox])').forEach(el => {
|
||
el.addEventListener('input', function() {
|
||
const cb = this.closest('div')?.querySelector('.pysim-fs-record-cb');
|
||
if (cb) cb.checked = true;
|
||
});
|
||
});
|
||
pysimFsEditData = out.innerHTML;
|
||
}
|
||
|
||
function pysimFsCancel() {
|
||
pysimFsEditMode = false;
|
||
document.getElementById('pysim-fs-read-btn').classList.remove('hidden');
|
||
document.getElementById('pysim-fs-edit-btn').classList.remove('hidden');
|
||
document.getElementById('pysim-fs-save-btn').classList.add('hidden');
|
||
document.getElementById('pysim-fs-cancel-btn').classList.add('hidden');
|
||
const out = document.getElementById('pysim-fs-content');
|
||
if (pysimFsEditData) out.innerHTML = pysimFsEditData;
|
||
pysimFsEditData = null;
|
||
}
|
||
|
||
async function pysimFsSave() {
|
||
const rawName = document.getElementById('pysim-fs-filename').textContent.replace(/ \[.*?\]$/, '').replace(/^✗ /, '');
|
||
const statusEl = document.getElementById('pysim-fs-status');
|
||
const out = document.getElementById('pysim-fs-content');
|
||
const node = pysimFsFindNode(rawName, pysimFsTreeRoot);
|
||
statusEl.textContent = 'Saving...';
|
||
try {
|
||
const textareas = out.querySelectorAll('textarea');
|
||
if (textareas.length === 1) {
|
||
// Transparent file
|
||
const body = { name: rawName, fid: node ? node.fid : null, data: textareas[0].value.replace(/[^0-9a-fA-F]/g, '') };
|
||
const parentSel = getParentSel(node);
|
||
if (parentSel) body.parent_sel = parentSel;
|
||
const data = await pysimFetch('/api/write', body);
|
||
if (data.success) {
|
||
statusEl.textContent = 'SW: ' + data.sw + ' OK';
|
||
pysimFsCancel();
|
||
pysimFsRead();
|
||
} else {
|
||
statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error');
|
||
}
|
||
} else {
|
||
// Record file
|
||
const inputs = out.querySelectorAll('input:not([type=checkbox])');
|
||
let wroteAny = false;
|
||
for (const inp of inputs) {
|
||
const row = inp.closest('div');
|
||
const cb = row?.querySelector('.pysim-fs-record-cb');
|
||
if (cb && !cb.checked) continue;
|
||
wroteAny = true;
|
||
const num = row?.querySelector('span')?.textContent;
|
||
if (num) {
|
||
const body = { name: rawName, fid: node ? node.fid : null, data: inp.value.replace(/[^0-9a-fA-F]/g, ''), record_nr: parseInt(num) };
|
||
const parentSel = getParentSel(node);
|
||
if (parentSel) body.parent_sel = parentSel;
|
||
const data = await pysimFetch('/api/write', body);
|
||
if (!data.success) {
|
||
statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error') + ' (record ' + num + ')';
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
if (!wroteAny) {
|
||
statusEl.textContent = 'No records selected — check the ones you want to update';
|
||
return;
|
||
}
|
||
statusEl.textContent = 'SW: 9000 OK';
|
||
pysimFsCancel();
|
||
pysimFsRead();
|
||
}
|
||
} catch (e) {
|
||
statusEl.textContent = 'Error: ' + e.message;
|
||
}
|
||
}
|
||
|
||
function pysimFsSetMode(mode) {
|
||
pysimFsDecodedMode = mode === 'dec';
|
||
document.querySelectorAll('.pysim-mode-pill').forEach(el => {
|
||
const active = el.dataset.mode === mode;
|
||
el.classList.toggle('bg-blue-600', active);
|
||
el.classList.toggle('text-white', active);
|
||
el.classList.toggle('bg-gray-200', !active);
|
||
el.classList.toggle('dark:bg-slate-700', !active);
|
||
el.classList.toggle('text-gray-700', !active);
|
||
el.classList.toggle('dark:text-slate-300', !active);
|
||
});
|
||
}
|
||
|
||
function pysimFsResetEdit() {
|
||
pysimFsEditMode = false;
|
||
pysimFsEditData = null;
|
||
document.getElementById('pysim-fs-read-btn').classList.remove('hidden');
|
||
document.getElementById('pysim-fs-edit-btn').classList.remove('hidden');
|
||
document.getElementById('pysim-fs-save-btn').classList.add('hidden');
|
||
document.getElementById('pysim-fs-cancel-btn').classList.add('hidden');
|
||
}
|
||
|
||
// ===== proactive UICC events =====
|
||
const EVENT_NAMES = {
|
||
0x00: 'MT call', 0x01: 'Call connected', 0x02: 'Call disconnected',
|
||
0x03: 'Location status', 0x04: 'User activity', 0x05: 'Idle screen available',
|
||
0x06: 'Card reader status', 0x07: 'Language selection',
|
||
0x08: 'Browser termination', 0x09: 'Data available',
|
||
0x0A: 'Channel status', 0x0B: 'Access Technology Change',
|
||
0x0C: 'Display parameters changed', 0x0D: 'Local connection',
|
||
0x0E: 'Network Search Mode Change', 0x0F: 'Browsing status',
|
||
0x10: 'Frames Information Change', 0x11: 'I-WLAN Access Status',
|
||
0x12: 'Network Rejection', 0x13: 'HCI Connectivity',
|
||
0x14: 'Change of UICC Access', 0x15: 'CSG Cell Change',
|
||
0x16: 'Contactless state request', 0x17: 'Profile Container',
|
||
0x18: 'LTE D2D Discovery Monitoring', 0x19: 'LTE D2D Communication Monitoring',
|
||
0x1A: 'LTE D2D Announcement Response', 0x1B: 'LTE D2D Revocation',
|
||
0x1C: 'LTE D2D Application Port', 0x1D: 'LTE D2D Security Recovery',
|
||
0x1E: 'Off-net Emergency Call', 0x1F: 'ECall Over IMS',
|
||
0x20: 'EARFCN Update', 0x21: 'SCEF Channel Status',
|
||
};
|
||
|
||
const REJECTION_CAUSES = [
|
||
{v:2,l:'02 \u2014 IMSI unknown in HLR/HSS'},{v:3,l:'03 \u2014 Illegal UE / MS'},
|
||
{v:5,l:'05 \u2014 IMEI / PEI not accepted'},{v:6,l:'06 \u2014 Illegal ME'},
|
||
{v:7,l:'07 \u2014 Services not allowed'},{v:8,l:'08 \u2014 Services + non-services not allowed'},
|
||
{v:9,l:'09 \u2014 UE identity cannot be derived'},{v:10,l:'0A \u2014 Implicitly detached / de-registered'},
|
||
{v:11,l:'0B \u2014 PLMN not allowed'},{v:12,l:'0C \u2014 Location / Tracking Area not allowed'},
|
||
{v:13,l:'0D \u2014 Roaming not allowed in this area'},{v:14,l:'0E \u2014 Services not allowed in this PLMN'},
|
||
{v:15,l:'0F \u2014 No Suitable Cells'},{v:16,l:'10 \u2014 MSC temporarily not reachable'},
|
||
{v:17,l:'11 \u2014 Network failure'},
|
||
{v:18,l:'12 \u2014 CS domain not avail. (EMM) / MAC fail (GMM)'},
|
||
{v:19,l:'13 \u2014 ESM failure (EMM) / Synch failure (GMM)'},
|
||
{v:20,l:'14 \u2014 MAC failure (EMM) / Congestion (GMM)'},
|
||
{v:21,l:'15 \u2014 Synch failure (EMM)'},{v:22,l:'16 \u2014 Congestion / PLMN rejected perm. (LU)'},
|
||
{v:23,l:'17 \u2014 UE security capabilities mismatch'},{v:24,l:'18 \u2014 Security mode rejected, unspecified'},
|
||
{v:25,l:'19 \u2014 Not authorized for this CSG'},{v:26,l:'1A \u2014 Non-3GPP/5G auth unacceptable'},
|
||
{v:27,l:'1B \u2014 N1 mode not allowed (5GMM)'},{v:28,l:'1C \u2014 Restricted service area (5GMM)'},
|
||
{v:31,l:'1F \u2014 Redirection to EPC required (5GMM)'},
|
||
{v:32,l:'20 \u2014 Service option not supported (LU)'},
|
||
{v:33,l:'21 \u2014 Requested service option not subscribed (LU)'},
|
||
{v:34,l:'22 \u2014 Service option temporarily out of order (LU)'},
|
||
{v:35,l:'23 \u2014 Requested service option not authorized (EMM)'},
|
||
{v:38,l:'26 \u2014 Call cannot be identified (LU)'},
|
||
{v:39,l:'27 \u2014 CS service temporarily not available (EMM)'},
|
||
{v:40,l:'28 \u2014 No EPS bearer context activated (EMM)'},
|
||
{v:42,l:'2A \u2014 Severe network failure (EMM)'},{v:43,l:'2B \u2014 LADN not available (5GMM)'},
|
||
{v:48,l:'30 \u2014 Retry upon entry into new cell (LU)'},
|
||
{v:62,l:'3E \u2014 No network slices available (5GMM)'},
|
||
{v:65,l:'41 \u2014 Maximum PDU sessions reached (5GMM)'},
|
||
{v:67,l:'43 \u2014 Insufficient resources for slice/DNN (5GMM)'},
|
||
{v:69,l:'45 \u2014 Insufficient resources for slice (5GMM)'},
|
||
{v:71,l:'47 \u2014 ngKSI already in use (5GMM)'},
|
||
{v:72,l:'48 \u2014 Non-3GPP access to 5GCN not allowed (5GMM)'},
|
||
{v:73,l:'49 \u2014 Serving network not authorized (5GMM)'},
|
||
{v:74,l:'4A \u2014 Temporarily not authorized for SNPN (5GMM)'},
|
||
{v:75,l:'4B \u2014 Permanently not authorized for SNPN (5GMM)'},
|
||
{v:76,l:'4C \u2014 Not authorized for this CAG (5GMM)'},
|
||
{v:90,l:'5A \u2014 Payload was not forwarded (5GMM)'},
|
||
{v:91,l:'5B \u2014 DNN not supported/subscribed in slice (5GMM)'},
|
||
{v:92,l:'5C \u2014 Insufficient UP resources for PDU session (5GMM)'},
|
||
{v:95,l:'5F \u2014 Semantically incorrect message'},{v:96,l:'60 \u2014 Invalid mandatory information'},
|
||
{v:97,l:'61 \u2014 Message type non-existent/not implemented'},
|
||
{v:98,l:'62 \u2014 Message type not compatible with protocol state'},
|
||
{v:99,l:'63 \u2014 IE non-existent/not implemented'},
|
||
{v:100,l:'64 \u2014 Conditional IE error'},{v:101,l:'65 \u2014 Message not compatible with protocol state'},
|
||
{v:111,l:'6F \u2014 Protocol error, unspecified'},
|
||
];
|
||
|
||
const EVENT_FORMS = {
|
||
0x00: { title: 'MT call', note: 'not_yet' },
|
||
0x01: { title: 'Call connected', note: 'not_yet' },
|
||
0x02: { title: 'Call disconnected', note: 'not_yet' },
|
||
0x03: { title: 'Location status',
|
||
fields: [
|
||
{ id: 'status', label: 'Service Status', type: 'select',
|
||
opts: [{v:0,l:'Normal service'},{v:1,l:'Limited service'},{v:2,l:'No service'}] }
|
||
],
|
||
build(v) { return '9B01' + (v.status|0).toString(16).padStart(2,'0'); }
|
||
},
|
||
0x04: null,
|
||
0x05: null,
|
||
0x06: { title: 'Card reader status',
|
||
fields: [
|
||
{ id: 'status', label: 'Status', type: 'select',
|
||
opts: [{v:0,l:'Card reader removed'},{v:1,l:'Card reader inserted'}] }
|
||
],
|
||
build(v) { return '' + (v.status|0).toString(16).padStart(2,'0'); }
|
||
},
|
||
0x07: { title: 'Language selection',
|
||
fields: [
|
||
{ id: 'lang', label: 'Language code', type: 'text', placeholder: 'ISO 639, e.g. en, ru', pattern: '[a-zA-Z]{2,3}' }
|
||
],
|
||
build(v) { const b = new TextEncoder().encode(v.lang||'en'); return b.length.toString(16).padStart(2,'0') + bytesToHex(Array.from(b)); }
|
||
},
|
||
0x08: { title: 'Browser termination', note: 'not_yet' },
|
||
0x09: null,
|
||
0x0A: { title: 'Channel status', note: 'not_yet' },
|
||
0x0B: { title: 'Access Technology Change',
|
||
fields: [
|
||
{ id: 'tech', label: 'Access Technology', type: 'select',
|
||
opts: [{v:0,l:'GSM'},{v:1,l:'GSM Compact'},{v:3,l:'UTRAN'},{v:4,l:'GSM w/EGPRS'},{v:5,l:'UTRAN w/HSDPA'},{v:6,l:'UTRAN w/HSUPA'},{v:7,l:'UTRAN w/HSPA+'},{v:8,l:'E-UTRAN'},{v:9,l:'E-UTRAN (4G)'},{v:10,l:'5G NR'},{v:11,l:'5G NR (NSA)'},{v:12,l:'Satellite'}] }
|
||
],
|
||
build(v) { return 'BF01' + (v.tech|0).toString(16).padStart(2,'0'); }
|
||
},
|
||
0x0C: null,
|
||
0x0D: null,
|
||
0x0E: null,
|
||
0x0F: null,
|
||
0x10: null,
|
||
0x11: { title: 'I-WLAN Access Status', note: 'not_yet' },
|
||
0x12: { title: 'Network Rejection',
|
||
fields: [
|
||
{ id: 'reg_type', label: 'Registration type', type: 'select', onchange: 'updateNetRejectionFields()',
|
||
opts: [
|
||
{v:0x00,l:'Normal Location Updating',g:'lu'},{v:0x01,l:'Periodic Location Updating',g:'lu'},
|
||
{v:0x02,l:'IMSI Attach',g:'lu'},{v:0x03,l:'GPRS Attach',g:'gprs'},
|
||
{v:0x04,l:'Combined GPRS/IMSI Attach',g:'gprs'},{v:0x05,l:'RA Updating',g:'gprs'},
|
||
{v:0x06,l:'Combined RA/LA Updating',g:'gprs'},{v:0x07,l:'Combined RA/LA Updating w/ IMSI Attach',g:'gprs'},
|
||
{v:0x08,l:'Periodic RA Updating',g:'gprs'},{v:0x09,l:'EPS Attach (EMM)',g:'eps'},
|
||
{v:0x0A,l:'Combined EPS/IMSI Attach (EMM)',g:'eps'},{v:0x0B,l:'TA Updating (TAU)',g:'eps'},
|
||
{v:0x0C,l:'Combined TA/LA Updating (TAU)',g:'eps'},{v:0x0D,l:'Combined TA/LA Updating w/ IMSI Attach (TAU)',g:'eps'},
|
||
{v:0x0E,l:'Periodic TA Updating (TAU)',g:'eps'},{v:0x0F,l:'Initial Registration (5GS)',g:'eps'},
|
||
{v:0x10,l:'Mobility Registration Updating (5GS)',g:'eps'},{v:0x11,l:'Periodic Registration Updating (5GS)',g:'eps'},
|
||
]},
|
||
{ id: 'mcc', label: 'MCC', type: 'text', pattern: '\\d{3}', placeholder: 'e.g. 250', g: 'lu,gprs,eps' },
|
||
{ id: 'mnc', label: 'MNC', type: 'text', pattern: '\\d{2,3}', placeholder: 'e.g. 01', g: 'lu,gprs,eps' },
|
||
{ id: 'lac', label: 'LAC', type: 'text', pattern: '[0-9A-Fa-f]{4}', placeholder: 'hex, e.g. 00FF', g: 'lu,gprs' },
|
||
{ id: 'rac', label: 'RAC', type: 'text', pattern: '[0-9A-Fa-f]{2}', placeholder: 'hex, e.g. 01', g: 'gprs' },
|
||
{ id: 'tac', label: 'TAC', type: 'text', pattern: '[0-9A-Fa-f]{4,6}', placeholder: '4 (E-UTRAN) or 6 (NR) hex chars', g: 'eps' },
|
||
{ id: 'access_tech', label: 'Access Technology', type: 'select',
|
||
opts: [{v:0x00,l:'GSM'},{v:0x03,l:'UTRAN'},{v:0x08,l:'E-UTRAN'},{v:0x0A,l:'NG-RAN'},{v:0x0B,l:'Satellite NG-RAN'},{v:0x0C,l:'Satellite E-UTRAN'}], g: 'lu,gprs,eps' },
|
||
{ id: 'cause', label: 'Rejection cause code', type: 'select', opts: REJECTION_CAUSES, g: 'lu,gprs,eps' },
|
||
{ id: 'ext_cause', label: 'Extended EMM cause (optional)', type: 'number', min: '0', max: '255', placeholder: '0–255', g: 'eps' },
|
||
],
|
||
build(v) {
|
||
const rt = parseInt(v.reg_type) || 0;
|
||
const g = rt <= 0x02 ? 'lu' : rt <= 0x08 ? 'gprs' : 'eps';
|
||
let d = 'F401' + rt.toString(16).padStart(2,'0');
|
||
d += 'BF01' + (parseInt(v.access_tech)|0).toString(16).padStart(2,'0');
|
||
d += 'F501' + (parseInt(v.cause) || 2).toString(16).padStart(2,'0');
|
||
const pm = encPlmn(v.mcc||'250',v.mnc||'01');
|
||
if (g === 'lu') {
|
||
d += '9305' + pm + ((v.lac||'0000').toUpperCase());
|
||
} else if (g === 'gprs') {
|
||
d += 'F306' + pm + ((v.lac||'0000').toUpperCase()) + ((v.rac||'01').toUpperCase());
|
||
} else {
|
||
const tc = (v.tac||'0001').toUpperCase();
|
||
d += '7D0' + (3 + tc.length/2).toString(16) + pm + tc;
|
||
if (v.ext_cause !== '' && v.ext_cause !== undefined) {
|
||
d += '5701' + (parseInt(v.ext_cause)|0).toString(16).padStart(2,'0');
|
||
}
|
||
}
|
||
return d.toUpperCase();
|
||
}
|
||
},
|
||
0x13: null,
|
||
0x14: { title: 'Change of UICC Access',
|
||
fields: [{ id: 'tech', label: 'Access Technology', type: 'select',
|
||
opts: [{v:0,l:'GSM'},{v:3,l:'UTRAN'},{v:8,l:'E-UTRAN'},{v:10,l:'NG-RAN'}] }],
|
||
build(v) { return 'BF01' + (v.tech|0).toString(16).padStart(2,'0'); }
|
||
},
|
||
0x15: { title: 'CSG Cell Change', note: 'not_yet' },
|
||
0x16: null,
|
||
0x17: null,
|
||
};
|
||
|
||
const PLI_QUALIFIERS = [
|
||
{code:'00',name:'Location Info (MCC, MNC, LAC/TAC, Cell ID)'},{code:'01',name:'IMEI'},
|
||
{code:'02',name:'Network Measurement results'},{code:'03',name:'Date, time and time zone'},
|
||
{code:'04',name:'Language setting'},{code:'05',name:'Timing Advance'},
|
||
{code:'06',name:'Access Technology (single)'},{code:'08',name:'IMEISV'},
|
||
{code:'09',name:'Search Mode'},{code:'0A',name:'Battery charge state'},
|
||
{code:'0C',name:'Current WSID'},{code:'0D',name:'Broadcast Network info'},
|
||
{code:'0E',name:'Multiple Access Technologies'},{code:'0F',name:'Location Info (multi-RAT)'},
|
||
{code:'10',name:'NMR (multi-RAT)'},{code:'11',name:'CSG ID list + HNB name'},
|
||
{code:'12',name:'H(e)NB IP address'},{code:'13',name:'H(e)NB surrounding macrocells'},
|
||
{code:'14',name:'Current WLAN identifier'},{code:'15',name:'Slices information'},
|
||
{code:'16',name:'CAG information list'},{code:'17',name:'Rejected slices information'},
|
||
];
|
||
|
||
async function pysimEventsRender() {
|
||
const el = document.getElementById('pysim-event-list');
|
||
el.innerHTML = '<span class="text-gray-400">' + t('Loading...') + '</span>';
|
||
try {
|
||
const events = await pysimFetch('/api/events');
|
||
if (!events || !events.length) {
|
||
el.innerHTML = '<span class="text-gray-400">' + t('No events configured.') + '</span>';
|
||
return;
|
||
}
|
||
let html = '';
|
||
events.forEach(e => {
|
||
const name = EVENT_NAMES[e] || ('Event 0x' + e.toString(16).padStart(2, '0').toUpperCase());
|
||
html += '<div class="flex items-center gap-2 py-0.5"><span class="w-6 text-right text-gray-400">'
|
||
+ e.toString(16).padStart(2, '0').toUpperCase()
|
||
+ '</span> ' + esc(name)
|
||
+ '<button data-needs="card" onclick="pysimEventSendForm(' + e + ')" class="ml-auto px-2 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed">' + t('Send') + '</button></div>';
|
||
});
|
||
el.innerHTML = html;
|
||
pysimApplyAvailability();
|
||
} catch (e) {
|
||
el.innerHTML = '<span class="text-red-500">Error: ' + esc(e.message) + '</span>';
|
||
}
|
||
}
|
||
|
||
let _eventFormType = null;
|
||
|
||
function pysimEventSendForm(eventType) {
|
||
const modal = document.getElementById('event-send-modal');
|
||
const title = document.getElementById('event-send-title');
|
||
const body = document.getElementById('event-send-body');
|
||
const err = document.getElementById('event-send-error');
|
||
const result = document.getElementById('event-send-result');
|
||
const btn = document.getElementById('event-send-btn');
|
||
err.classList.add('hidden');
|
||
result.classList.add('hidden');
|
||
err.textContent = '';
|
||
result.textContent = '';
|
||
|
||
const name = EVENT_NAMES[eventType] || ('0x' + eventType.toString(16).padStart(2,'0'));
|
||
const cfg = EVENT_FORMS[eventType];
|
||
_eventFormType = eventType;
|
||
|
||
if (!cfg) {
|
||
title.textContent = name + ' (' + t('Send') + ')';
|
||
body.innerHTML = '<p class="text-sm text-gray-600 dark:text-slate-400">' + t('Send') + ' <span class="font-mono">' + esc(name) + '</span> ' + t('event notification to the card?') + '</p>';
|
||
btn.textContent = t('Send');
|
||
modal.classList.remove('hidden');
|
||
return;
|
||
}
|
||
if (cfg.note === 'not_yet') {
|
||
title.textContent = name;
|
||
body.innerHTML = '<p class="text-sm text-gray-500 dark:text-slate-400">' + t('This event type is not yet supported.') + '</p>';
|
||
btn.classList.add('hidden');
|
||
} else {
|
||
title.textContent = name + ' (' + t('Send') + ')';
|
||
let html = '';
|
||
cfg.fields.forEach(f => {
|
||
const rg = f.g ? f.g.split(',') : null;
|
||
html += '<div class="event-field" data-groups="' + (f.g||'') + '" data-id="' + f.id + '">';
|
||
html += '<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300">' + t(f.label) + '</label>';
|
||
if (f.type === 'select') {
|
||
html += '<select id="ev-f-' + f.id + '"' + (f.onchange ? ' onchange="' + f.onchange + '"' : '') + ' class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">';
|
||
f.opts.forEach(o => html += '<option value="' + o.v + '"' + (o.g ? ' data-gr="' + o.g + '"' : '') + '>' + esc(o.l) + '</option>');
|
||
html += '</select>';
|
||
} else {
|
||
const attrs = [];
|
||
if (f.pattern) attrs.push('pattern="' + f.pattern + '"');
|
||
if (f.placeholder) attrs.push('placeholder="' + f.placeholder + '"');
|
||
if (f.min !== undefined) attrs.push('min="' + f.min + '" max="' + f.max + '"');
|
||
html += '<input id="ev-f-' + f.id + '" type="' + f.type + '" ' + attrs.join(' ') + ' class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono">';
|
||
}
|
||
html += '</div>';
|
||
});
|
||
body.innerHTML = html;
|
||
btn.classList.remove('hidden');
|
||
if (eventType === 0x12) updateNetRejectionFields();
|
||
}
|
||
modal.classList.remove('hidden');
|
||
}
|
||
|
||
function updateNetRejectionFields() {
|
||
const sel = document.getElementById('ev-f-reg_type');
|
||
if (!sel) return;
|
||
const opt = sel.options[sel.selectedIndex];
|
||
const g = opt ? opt.getAttribute('data-gr') || 'lu' : 'lu';
|
||
document.querySelectorAll('#event-send-body .event-field').forEach(div => {
|
||
const groups = div.getAttribute('data-groups') || '';
|
||
const show = groups === '' || groups.indexOf(g) >= 0;
|
||
div.style.display = show ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
async function pysimEventSend() {
|
||
const err = document.getElementById('event-send-error');
|
||
const result = document.getElementById('event-send-result');
|
||
const btn = document.getElementById('event-send-btn');
|
||
err.classList.add('hidden');
|
||
result.classList.add('hidden');
|
||
|
||
const cfg = EVENT_FORMS[_eventFormType];
|
||
let eventData = null;
|
||
|
||
if (cfg && cfg.build) {
|
||
const values = {};
|
||
let valid = true;
|
||
cfg.fields.forEach(f => {
|
||
const el = document.getElementById('ev-f-' + f.id);
|
||
if (!el || el.closest('.event-field').style.display === 'none') return;
|
||
const v = el.value.trim();
|
||
values[f.id] = v;
|
||
if (f.pattern && v && !new RegExp('^' + f.pattern + '$').test(v)) {
|
||
err.textContent = t('Invalid') + ': ' + t(f.label);
|
||
err.classList.remove('hidden');
|
||
valid = false;
|
||
}
|
||
});
|
||
if (!valid) return;
|
||
try {
|
||
eventData = cfg.build(values);
|
||
} catch (e) {
|
||
err.textContent = 'Error: ' + e.message;
|
||
err.classList.remove('hidden');
|
||
return;
|
||
}
|
||
}
|
||
|
||
btn.disabled = true;
|
||
btn.textContent = t('Sending...');
|
||
try {
|
||
const resp = await pysimFetch('/api/event-send', { event_type: _eventFormType, event_data: eventData });
|
||
if (resp.sw === '9000') {
|
||
result.textContent = (eventData ? t('Event data sent.') : t('Event sent.')) + ' SW: ' + resp.sw;
|
||
result.classList.remove('hidden');
|
||
} else {
|
||
result.textContent = 'SW: ' + resp.sw;
|
||
result.classList.remove('hidden');
|
||
result.classList.add('text-amber-600');
|
||
}
|
||
} catch (e2) {
|
||
err.textContent = 'Error: ' + e2.message;
|
||
err.classList.remove('hidden');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = t('Send');
|
||
}
|
||
}
|
||
|
||
async function pysimStatusPoll() {
|
||
const btn = document.getElementById('pli-status-btn');
|
||
if (!btn) return;
|
||
btn.disabled = true;
|
||
btn.textContent = t('STATUS...');
|
||
try {
|
||
const resp = await pysimFetch('/api/status-poll', {});
|
||
if (resp.fetch) {
|
||
btn.textContent = 'SW: ' + resp.sw + ' + ' + resp.fetch_sw;
|
||
} else {
|
||
btn.textContent = 'SW: ' + resp.sw;
|
||
}
|
||
pysimEventsRender();
|
||
pysimProactiveLogRender();
|
||
} catch (e) {
|
||
btn.textContent = t('Error');
|
||
} finally {
|
||
setTimeout(() => { btn.textContent = t('Send STATUS'); btn.disabled = false; }, 2000);
|
||
}
|
||
}
|
||
|
||
let _pysimPollTimer = null;
|
||
let _pysimStatusTimer = null;
|
||
let _pysimCardStateKey = null;
|
||
let _pysimCardSession = null;
|
||
|
||
async function pysimPollToggle() {
|
||
const btn = document.getElementById('pli-pause-btn');
|
||
if (!btn) return;
|
||
try {
|
||
const status = await pysimFetch('/api/poll-status');
|
||
const newEnabled = !status.enabled;
|
||
const resp = await pysimFetch('/api/poll-toggle', { enabled: newEnabled });
|
||
pysimUpdatePollUI(resp.enabled, resp.interval);
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function pysimUpdatePollUI(enabled, interval) {
|
||
const btn = document.getElementById('pli-pause-btn');
|
||
const stat = document.getElementById('pli-poll-stat');
|
||
if (!btn) return;
|
||
if (enabled) {
|
||
btn.textContent = 'ON';
|
||
btn.classList.remove('bg-gray-200', 'dark:bg-slate-700', 'text-gray-700', 'dark:text-slate-300');
|
||
btn.classList.add('bg-emerald-600', 'text-white');
|
||
} else {
|
||
btn.textContent = 'OFF';
|
||
btn.classList.remove('bg-emerald-600', 'text-white');
|
||
btn.classList.add('bg-gray-200', 'dark:bg-slate-700', 'text-gray-700', 'dark:text-slate-300');
|
||
}
|
||
if (stat) stat.textContent = enabled ? '(' + interval + 's)' : '';
|
||
}
|
||
|
||
async function pysimPollStatusInit() {
|
||
try {
|
||
const status = await pysimFetch('/api/poll-status');
|
||
pysimUpdatePollUI(status.enabled, status.interval);
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
async function pysimResetCardData(refreshStatus) {
|
||
stkMenuClose();
|
||
stkMenuStack = [];
|
||
pysimFsRefresh();
|
||
stkCheckMenu();
|
||
pysimEventsRender();
|
||
pysimProactiveLogRender();
|
||
pysimPliRender();
|
||
if (refreshStatus) await pysimRefresh();
|
||
}
|
||
|
||
function pysimCardStateUpdate(status) {
|
||
if (!status || typeof status.connected !== 'boolean') return;
|
||
_pysimServerAvailable = true;
|
||
_pysimCardEquipped = !!status.connected;
|
||
pysimApplyAvailability();
|
||
const key = [status.connected, !!status.card_present, !!status.equipping, !!status.auto_equip, status.card_session].join('|');
|
||
if (key === _pysimCardStateKey) return;
|
||
_pysimCardStateKey = key;
|
||
const sessionChanged = _pysimCardSession !== null
|
||
&& status.card_session !== undefined && status.card_session !== _pysimCardSession;
|
||
if (status.card_session !== undefined) _pysimCardSession = status.card_session;
|
||
if (status.connected) {
|
||
pysimSetConnected(true);
|
||
pysimResetCardData(true);
|
||
return;
|
||
}
|
||
pysimSetConnected(false);
|
||
const statusEl = document.getElementById('pysim-status');
|
||
if (statusEl && statusEl.textContent.trim() !== '') {
|
||
if (status.equipping || (status.card_present && status.auto_equip)) {
|
||
statusEl.innerHTML = '<span class="text-gray-500">' + esc(t('Card inserted — initializing...')) + '</span>';
|
||
} else if (status.card_present) {
|
||
statusEl.innerHTML = '<span class="text-amber-600">' + esc(t('Card inserted — press Equip')) + '</span>';
|
||
} else {
|
||
statusEl.innerHTML = '<span class="text-red-500">' + esc(t('No card detected. Insert card and click Equip card button')) + '</span>';
|
||
}
|
||
}
|
||
if (sessionChanged) pysimResetCardData(false);
|
||
}
|
||
|
||
function pysimStartBackendPoll() {
|
||
if (_pysimPollTimer) clearInterval(_pysimPollTimer);
|
||
if (_pysimStatusTimer) clearInterval(_pysimStatusTimer);
|
||
_pysimCardStateKey = null;
|
||
_pysimCardSession = null;
|
||
_pysimStatusTimer = setInterval(async () => {
|
||
try {
|
||
pysimCardStateUpdate(await pysimFetch('/api/status'));
|
||
} catch (e) {
|
||
pysimSetServerAvailable(false);
|
||
}
|
||
}, 2000);
|
||
_pysimPollTimer = setInterval(async () => {
|
||
try {
|
||
const [log, stk, ps] = await Promise.all([
|
||
pysimFetch('/api/proactive-log'),
|
||
pysimFetch('/api/stk-status'),
|
||
pysimFetch('/api/poll-status'),
|
||
]);
|
||
pysimUpdatePollUI(ps.enabled, ps.interval);
|
||
} catch (e) { /* ignore */ }
|
||
}, 5000);
|
||
}
|
||
|
||
// ===== proactive command log =====
|
||
const CMD_NAMES = {
|
||
'03': 'POLL INTERVAL', '05': 'SET UP EVENT LIST',
|
||
'13': 'SEND SHORT MESSAGE', '20': 'PLAY TONE',
|
||
'21': 'DISPLAY TEXT', '22': 'GET INKEY', '23': 'GET INPUT',
|
||
'24': 'SELECT ITEM', '25': 'SET UP MENU',
|
||
'26': 'PROVIDE LOCAL INFORMATION',
|
||
'15': 'LAUNCH BROWSER', '70': 'ACTIVATE',
|
||
};
|
||
|
||
const CMD_QUALIFIER_SHORT = {
|
||
'01': {0x00:'NAA Init+FC', 0x01:'File Chg', 0x02:'NAA Init+FC', 0x03:'NAA Init',
|
||
0x04:'UICC Reset', 0x05:'App Reset', 0x06:'Sess Reset', 0x07:'SoR', 0x08:'SoR I-WLAN'},
|
||
'10': {0x00:'IfNotBusy', 0x01:'IfNotBusy+Redial', 0x02:'HoldOthers', 0x03:'HoldOthers+Redial',
|
||
0x04:'DiscOthers', 0x05:'DiscOthers+Redial'},
|
||
'15': {0x00:'New', 0x01:'Existing', 0x02:'Close+New'},
|
||
'21': {0x00:'Normal', 0x01:'HighPri', 0x80:'WaitUser'},
|
||
'22': {0x00:'Digits', 0x01:'Alpha', 0x04:'YesNo', 0x08:'ImmDigit'},
|
||
'23': {0x00:'Digits+Echo', 0x01:'Alpha+Echo', 0x04:'NoEcho'},
|
||
'24': {0x00:'Normal', 0x02:'Help', 0x04:'DefaultItem', 0x80:'Nav'},
|
||
'25': {0x00:'Normal', 0x04:'Help', 0x80:'Nav'},
|
||
'26': {0x00:'Loc', 0x01:'IMEI', 0x02:'NMR', 0x03:'Time', 0x04:'Lang', 0x05:'TA',
|
||
0x06:'AccTech', 0x08:'IMEISV', 0x09:'Search', 0x0A:'Batt', 0x0C:'WSID',
|
||
0x0D:'BCInfo', 0x0E:'MultiAT', 0x0F:'MultiLoc', 0x10:'MultiNMR',
|
||
0x11:'CSG', 0x12:'HNB-IP', 0x13:'HNB-Macro', 0x14:'WLAN', 0x15:'Slices',
|
||
0x16:'CAG', 0x17:'RejSlice'},
|
||
};
|
||
|
||
let _pysimLogExpanded = new Set();
|
||
|
||
function pysimLogToggle(id) {
|
||
const panel = document.querySelector('[data-log-panel="' + id + '"]');
|
||
const tog = document.querySelector('[data-log-tog="' + id + '"]');
|
||
if (_pysimLogExpanded.has(id)) {
|
||
_pysimLogExpanded.delete(id);
|
||
if (panel) panel.classList.add('hidden');
|
||
if (tog) tog.textContent = '▶';
|
||
} else {
|
||
_pysimLogExpanded.add(id);
|
||
if (panel) panel.classList.remove('hidden');
|
||
if (tog) tog.textContent = '▼';
|
||
}
|
||
}
|
||
|
||
function pysimLogFields(decoded) {
|
||
if (!decoded || !decoded.length) return '';
|
||
let html = '';
|
||
decoded.forEach(f => {
|
||
html += '<div class="flex gap-1"><span class="text-gray-500 dark:text-slate-400 shrink-0">'
|
||
+ esc(f.label) + ':</span><span class="break-all">' + esc(f.value) + '</span></div>';
|
||
});
|
||
return html;
|
||
}
|
||
|
||
async function pysimProactiveLogRender() {
|
||
const el = document.getElementById('pysim-proactive-log');
|
||
el.innerHTML = '<span class="text-gray-400">' + t('Loading...') + '</span>';
|
||
try {
|
||
const log = await pysimFetch('/api/proactive-log');
|
||
if (!log || !log.length) {
|
||
el.innerHTML = '<span class="text-gray-400">' + t('No commands logged yet.') + '</span>';
|
||
return;
|
||
}
|
||
let html = '';
|
||
log.forEach((e, i) => {
|
||
const name = CMD_NAMES[e.type_hex] || ('Cmd 0x' + e.type_hex.toUpperCase());
|
||
let qual = '';
|
||
if (e.qualifier) {
|
||
const qs = CMD_QUALIFIER_SHORT[e.type_hex];
|
||
if (qs) {
|
||
const qv = qs[parseInt(e.qualifier,16)];
|
||
if (qv) qual = ' [' + qv + ']';
|
||
}
|
||
}
|
||
const expanded = _pysimLogExpanded.has(e.id);
|
||
html += '<div data-log-id="' + e.id + '" class="border-b border-gray-100 dark:border-slate-700/50">'
|
||
+ '<div class="flex items-center gap-2 py-0.5 cursor-pointer select-none" onclick="pysimLogToggle(' + e.id + ')">'
|
||
+ '<span class="w-12 text-right text-gray-400">' + e.elapsed + 's</span>'
|
||
+ '<span class="font-mono text-gray-400">0x' + e.type_hex.toUpperCase() + '</span>'
|
||
+ '<span class="truncate">' + esc(name) + '</span>'
|
||
+ (qual ? '<span class="text-xs text-gray-500 dark:text-slate-400">' + esc(qual) + '</span>' : '')
|
||
+ '<span class="ml-auto text-gray-400" data-log-tog="' + e.id + '">' + (expanded ? '▼' : '▶') + '</span>'
|
||
+ '</div>'
|
||
+ '<div data-log-panel="' + e.id + '" class="' + (expanded ? '' : 'hidden') + ' ml-8 pl-2 border-l border-gray-300 dark:border-slate-600 mb-1 text-xs space-y-0.5">'
|
||
+ '<div class="text-gray-500 dark:text-slate-400">' + t('Command') + ':</div>'
|
||
+ pysimLogFields(e.cmd_decoded);
|
||
if (e.raw) html += '<input type="text" readonly value="' + esc(e.raw) + '" class="w-full max-w-md font-mono text-xs rounded border border-gray-300 dark:border-slate-600 px-2 py-1 bg-gray-100 dark:bg-slate-800 text-gray-600 dark:text-slate-400">';
|
||
html += '<div class="text-gray-500 dark:text-slate-400 mt-1">' + t('Response') + ':</div>';
|
||
if (e.tr_hex !== undefined) {
|
||
if (e.tr_result !== undefined) {
|
||
html += '<div class="flex gap-1"><span class="text-gray-500 dark:text-slate-400 shrink-0">' + t('Result') + ':</span>'
|
||
+ '<span class="break-all">' + (e.tr_result_name ? esc(e.tr_result_name) + ' (' + esc(e.tr_result) + ')' : esc(e.tr_result)) + '</span></div>';
|
||
}
|
||
if (e.tr_hex) {
|
||
html += pysimLogFields(e.tr_decoded);
|
||
html += '<input type="text" readonly value="' + esc(e.tr_hex) + '" class="w-full max-w-md font-mono text-xs rounded border border-gray-300 dark:border-slate-600 px-2 py-1 bg-gray-100 dark:bg-slate-800 text-gray-600 dark:text-slate-400">';
|
||
}
|
||
} else {
|
||
html += '<div class="text-gray-400">' + t('No response yet') + '</div>';
|
||
}
|
||
html += '</div></div>';
|
||
});
|
||
el.innerHTML = html;
|
||
} catch (err) {
|
||
el.innerHTML = '<span class="text-red-500">Error: ' + esc(err.message) + '</span>';
|
||
}
|
||
}
|
||
|
||
// ===== PLI data dictionary =====
|
||
function decPlmn(hex3) {
|
||
const h = hex3.replace(/\s/g, '').slice(0, 6);
|
||
if (h.length < 6) return ['250', '01'];
|
||
const b1 = parseInt(h[0]+h[1], 16), b2 = parseInt(h[2]+h[3], 16), b3 = parseInt(h[4]+h[5], 16);
|
||
const mcc = '' + (b1&0xF) + ((b1>>4)&0xF) + (b2&0xF);
|
||
let mnc = '' + (b3&0xF);
|
||
if ((b2>>4) !== 0xF) mnc += ((b2>>4)&0xF);
|
||
return [mcc, mnc];
|
||
}
|
||
|
||
function decImei(hex8) {
|
||
const h = hex8.replace(/\s/g, '').slice(0, 16);
|
||
if (h.length < 16) return '';
|
||
let s = '';
|
||
for (let i = 0; i < 15; i += 2) {
|
||
const b = parseInt(h[i]+h[i+1], 16);
|
||
s += '' + (b&0xF) + ((b>>4)&0xF);
|
||
}
|
||
return s.slice(0, 15);
|
||
}
|
||
|
||
function encImeiPli(digits) {
|
||
const d = digits.replace(/\D/g, '').slice(0, 15);
|
||
if (d.length < 15) return '';
|
||
let h = '';
|
||
for (let i = 0; i < 15; i += 2)
|
||
h += (parseInt(d[i+1])<<4 | parseInt(d[i])).toString(16).padStart(2, '0');
|
||
return h;
|
||
}
|
||
|
||
function encImeisvPli(digits) {
|
||
const d = digits.replace(/\D/g, '').slice(0, 16);
|
||
if (d.length < 16) return '';
|
||
let h = '';
|
||
for (let i = 0; i < 16; i += 2)
|
||
h += (parseInt(d[i+1])<<4 | parseInt(d[i])).toString(16).padStart(2, '0');
|
||
return h;
|
||
}
|
||
|
||
const ACCESSTECH = [
|
||
{v:0,l:'GSM'},{v:1,l:'GSM Compact'},{v:2,l:'TIA/EIA-533'},{v:3,l:'UTRAN'},
|
||
{v:4,l:'TETRA'},{v:5,l:'TIA/EIA-95-B'},{v:6,l:'CDMA2000 1x'},{v:7,l:'CDMA2000 HRPD'},
|
||
{v:8,l:'E-UTRAN'},{v:9,l:'eHRPD'},{v:10,l:'NG-RAN'},{v:11,l:'Satellite NG-RAN'},{v:12,l:'Satellite E-UTRAN'},
|
||
];
|
||
|
||
const PLI_CODECS = {
|
||
'00': { tlv: '9305',
|
||
decode(hex) {
|
||
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 10) return null;
|
||
const pl = decPlmn(v); return { mcc: pl[0], mnc: pl[1], lac: v.slice(6,10).toUpperCase() };
|
||
},
|
||
encode(v) { return '9305' + encPlmn(v.mcc||'250',v.mnc||'01') + ((v.lac||'0000').toUpperCase()); },
|
||
fields: [
|
||
{id:'mcc',label:'MCC',type:'text',pattern:'\\d{3}',placeholder:'e.g. 250'},
|
||
{id:'mnc',label:'MNC',type:'text',pattern:'\\d{2,3}',placeholder:'e.g. 01'},
|
||
{id:'lac',label:'LAC/TAC',type:'text',pattern:'[0-9A-Fa-f]{4}',placeholder:'hex'},
|
||
],
|
||
},
|
||
'01': { tlv: '9408',
|
||
decode(hex) {
|
||
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 16) return null;
|
||
return { imei: decImei(v) };
|
||
},
|
||
encode(v) { const imei = encImeiPli(v.imei||''); return imei ? '9408'+imei : ''; },
|
||
fields: [
|
||
{id:'imei',label:'IMEI',type:'text',pattern:'\\d{15}',placeholder:'15 digits'},
|
||
],
|
||
},
|
||
'03': { tlv: '2607',
|
||
decode(hex) {
|
||
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 14) return null;
|
||
const yr = parseInt(v[0]+v[1])<70 ? 2000+parseInt(v[0]+v[1]) : 1900+parseInt(v[0]+v[1]);
|
||
const mo = parseInt(v[2]+v[3]), dy = parseInt(v[4]+v[5]);
|
||
const hh = parseInt(v[6]+v[7]), mm = parseInt(v[8]+v[9]), ss = parseInt(v[10]+v[11]);
|
||
const tz = parseInt(v[12]+v[13], 16);
|
||
const tzSign = (tz & 0x80) ? '-' : '+';
|
||
const tzH = Math.floor(((tz & 0x3F) || 0) / 4);
|
||
const tzM = (((tz & 0x3F) || 0) % 4) * 15;
|
||
return {
|
||
dt: yr+'-'+(''+mo).padStart(2,'0')+'-'+(''+dy).padStart(2,'0'),
|
||
tm: (''+hh).padStart(2,'0')+':'+(''+mm).padStart(2,'0')+':'+(''+ss).padStart(2,'0'),
|
||
tz: tzSign+(''+tzH).padStart(2,'0')+':'+(''+tzM).padStart(2,'0'),
|
||
};
|
||
},
|
||
encode(v) {
|
||
const dt = (v.dt||'2000-01-01').split('-'), tm = (v.tm||'00:00:00').split(':');
|
||
let tzQ = 0;
|
||
const tzM = v.tz ? v.tz.match(/^([-+])(\d{2}):?(\d{2})$/) : null;
|
||
if (tzM) { const h = parseInt(tzM[2])||0, m = parseInt(tzM[3])||0; tzQ = h*4 + Math.floor(m/15); if (tzM[1]==='-') tzQ |= 0x80; }
|
||
return '2607'
|
||
+ (parseInt(dt[0])%100).toString(16).padStart(2,'0')
|
||
+ (parseInt(dt[1])||1).toString(16).padStart(2,'0')
|
||
+ (parseInt(dt[2])||1).toString(16).padStart(2,'0')
|
||
+ (parseInt(tm[0])||0).toString(16).padStart(2,'0')
|
||
+ (parseInt(tm[1])||0).toString(16).padStart(2,'0')
|
||
+ (parseInt(tm[2])||0).toString(16).padStart(2,'0')
|
||
+ tzQ.toString(16).padStart(2,'0');
|
||
},
|
||
fields: [
|
||
{id:'dt',label:'Date',type:'date'},{id:'tm',label:'Time',type:'time'},
|
||
{id:'tz',label:'TZ offset',type:'text',pattern:'[-+]\\d{2}:?\\d{2}',placeholder:'+00:00'},
|
||
],
|
||
},
|
||
'04': { tlv: 'AD02',
|
||
decode(hex) {
|
||
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 4) return null;
|
||
return { lang: String.fromCharCode(parseInt(v[0]+v[1],16), parseInt(v[2]+v[3],16)) };
|
||
},
|
||
encode(v) {
|
||
const lang = (v.lang||'en').slice(0,2);
|
||
return 'AD02' + lang.charCodeAt(0).toString(16).padStart(2,'0') + lang.charCodeAt(1).toString(16).padStart(2,'0');
|
||
},
|
||
fields: [
|
||
{id:'lang',label:'Language',type:'text',pattern:'[a-zA-Z]{2,3}',placeholder:'en'},
|
||
],
|
||
},
|
||
'05': { tlv: 'C602',
|
||
decode(hex) {
|
||
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 4) return null;
|
||
return { status: parseInt(v[0]+v[1],16).toString(), ta: parseInt(v[2]+v[3],16).toString() };
|
||
},
|
||
encode(v) { return 'C602' + (parseInt(v.status)||0).toString(16).padStart(2,'0') + (parseInt(v.ta)||0).toString(16).padStart(2,'0'); },
|
||
fields: [
|
||
{id:'status',label:'ME Status',type:'number',min:'0',max:'255',placeholder:'0'},
|
||
{id:'ta',label:'Timing Advance',type:'number',min:'0',max:'255',placeholder:'0'},
|
||
],
|
||
},
|
||
'06': { tlv: 'BF01',
|
||
decode(hex) {
|
||
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 2) return null;
|
||
return { tech: parseInt(v[0]+v[1],16).toString() };
|
||
},
|
||
encode(v) { return 'BF01' + (parseInt(v.tech)||0).toString(16).padStart(2,'0'); },
|
||
fields: [{id:'tech',label:'Access Technology',type:'select',opts:ACCESSTECH}],
|
||
},
|
||
'08': { tlv: '9408',
|
||
decode(hex) {
|
||
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 16) return null;
|
||
return { imeisv: decImei(v) + v.slice(14,16).toUpperCase() };
|
||
},
|
||
encode(v) {
|
||
const raw = (v.imeisv||'').replace(/\D/g, '').slice(0, 16);
|
||
if (raw.length < 16) return '';
|
||
let h = '';
|
||
for (let i = 0; i < 16; i += 2)
|
||
h += (parseInt(raw[i+1])<<4 | parseInt(raw[i])).toString(16).padStart(2,'0');
|
||
return '9408' + h;
|
||
},
|
||
fields: [{id:'imeisv',label:'IMEISV',type:'text',pattern:'\\d{16}',placeholder:'16 digits'}],
|
||
},
|
||
'09': { tlv: 'AD01',
|
||
decode(hex) {
|
||
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 2) return null;
|
||
return { mode: parseInt(v[0]+v[1],16).toString() };
|
||
},
|
||
encode(v) {
|
||
return 'AD01' + (parseInt(v.mode)||0).toString(16).padStart(2,'0');
|
||
},
|
||
fields: [{id:'mode',label:'Search Mode',type:'select',
|
||
opts:[{v:0,l:'Automatic'},{v:1,l:'Manual'}]}],
|
||
},
|
||
'0A': { tlv: '6301',
|
||
decode(hex) {
|
||
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 2) return null;
|
||
return { charge: parseInt(v[0]+v[1],16).toString() };
|
||
},
|
||
encode(v) { return '6301' + (parseInt(v.charge)||50).toString(16).padStart(2,'0'); },
|
||
fields: [{id:'charge',label:'Charge state (%)',type:'number',min:'0',max:'100',placeholder:'50'}],
|
||
},
|
||
'0E': { tlv: 'BF',
|
||
decode(hex) {
|
||
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 2) return null;
|
||
const techs = []; for (let i = 0; i < v.length; i += 2) techs.push(parseInt(v[i]+v[i+1],16));
|
||
return { techs: techs.join(',') };
|
||
},
|
||
encode(v) {
|
||
const techs = (v.techs||'').split(',').filter(t => t !== '').map(t => parseInt(t)&0xFF);
|
||
if (!techs.length) return '';
|
||
const len = techs.length.toString(16).padStart(2,'0');
|
||
return 'BF' + len + techs.map(t => t.toString(16).padStart(2,'0')).join('');
|
||
},
|
||
fields: [
|
||
{id:'techs',label:'Access Technologies (comma-separated)',type:'text',
|
||
placeholder:'e.g. 3,8,10 (= UTRAN + E-UTRAN + NG-RAN)'},
|
||
],
|
||
},
|
||
};
|
||
async function pysimPliRender() {
|
||
const el = document.getElementById('pysim-pli-dict');
|
||
el.innerHTML = '<span class="text-gray-400">' + t('Loading...') + '</span>';
|
||
try {
|
||
const data = await pysimFetch('/api/pli-dict');
|
||
let html = '<div class="space-y-1">';
|
||
PLI_QUALIFIERS.forEach(q => {
|
||
const val = data[q.code] || '';
|
||
const hasCodec = !!PLI_CODECS[q.code];
|
||
html += '<div data-pli-row="' + q.code + '" class="py-0.5 border-b border-gray-200 dark:border-slate-700">'
|
||
+ '<div class="flex items-baseline gap-1.5">'
|
||
+ '<span class="w-6 shrink-0 text-right text-gray-400 text-xs">' + q.code + '</span>'
|
||
+ '<span class="flex-1 min-w-0 text-[13px] leading-snug">' + esc(q.name) + '</span>'
|
||
+ '</div>'
|
||
+ '<div class="flex items-center gap-1 mt-0.5 ml-8">'
|
||
+ '<input type="text" id="pli-v-' + q.code + '" value="' + esc(val) + '" placeholder="hex"'
|
||
+ ' class="flex-1 min-w-0 border border-gray-300 dark:border-slate-600 text-xs rounded px-1.5 py-1 dark:bg-slate-800 font-mono">';
|
||
if (hasCodec) {
|
||
html += '<span onclick="pysimPliDecodeRow(\'' + q.code + '\')" id="pli-tog-' + q.code + '"'
|
||
+ ' class="cursor-pointer select-none text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">▶</span>';
|
||
}
|
||
html += '</div><div class="pli-decode-panel hidden mt-1 ml-8 pl-2 border-l border-gray-300 dark:border-slate-600" data-pli-panel="' + q.code + '"></div></div>';
|
||
});
|
||
html += '</div>';
|
||
el.innerHTML = html;
|
||
} catch (e) {
|
||
el.innerHTML = '<span class="text-red-500">Error: ' + esc(e.message) + '</span>';
|
||
}
|
||
}
|
||
|
||
function pysimPliDecodeRow(code) {
|
||
const panel = document.querySelector('[data-pli-panel="' + code + '"]');
|
||
const codec = PLI_CODECS[code];
|
||
if (!codec) return;
|
||
|
||
if (!panel.classList.contains('hidden')) {
|
||
panel.innerHTML = '';
|
||
panel.classList.add('hidden');
|
||
const tog = document.getElementById('pli-tog-' + code);
|
||
if (tog) tog.textContent = '▶';
|
||
return;
|
||
}
|
||
|
||
const input = document.getElementById('pli-v-' + code);
|
||
const hex = input.value.trim().replace(/\s/g, '');
|
||
let values = {};
|
||
if (hex) {
|
||
values = codec.decode(hex);
|
||
if (!values) {
|
||
panel.innerHTML = '<span class="text-xs text-red-500">Invalid hex</span>';
|
||
panel.classList.remove('hidden');
|
||
const tog = document.getElementById('pli-tog-' + code);
|
||
if (tog) tog.textContent = '▼';
|
||
return;
|
||
}
|
||
}
|
||
|
||
let html = '<div class="space-y-1 mb-1">';
|
||
codec.fields.forEach(f => {
|
||
html += '<div class="flex items-center gap-1">'
|
||
+ '<span class="text-xs w-12 text-gray-500 dark:text-slate-400 truncate" title="' + esc(f.label) + '">' + esc(f.label) + '</span>';
|
||
if (f.type === 'select') {
|
||
html += '<select id="pli-d-' + code + '-' + f.id + '" class="text-xs border border-gray-300 dark:border-slate-600 rounded px-1.5 py-1 dark:bg-slate-800 w-40">';
|
||
f.opts.forEach(o => html += '<option value="' + o.v + '"' + (String(values[f.id]) === String(o.v) ? ' selected' : '') + '>' + esc(o.l) + '</option>');
|
||
html += '</select>';
|
||
} else {
|
||
html += '<input id="pli-d-' + code + '-' + f.id + '" type="' + f.type + '"'
|
||
+ ' value="' + esc(String(values[f.id] || '')) + '"'
|
||
+ (f.pattern ? ' pattern="' + f.pattern + '"' : '')
|
||
+ (f.placeholder ? ' placeholder="' + f.placeholder + '"' : '')
|
||
+ (f.min !== undefined ? ' min="' + f.min + '" max="' + f.max + '"' : '')
|
||
+ ' class="text-xs border border-gray-300 dark:border-slate-600 rounded px-1.5 py-1 dark:bg-slate-800 w-40 font-mono">';
|
||
}
|
||
html += '</div>';
|
||
});
|
||
html += '</div>';
|
||
html += '<div class="flex gap-1">'
|
||
+ '<button onclick="pysimPliApplyRow(\'' + code + '\')" class="text-xs px-2 py-1 rounded bg-emerald-600 text-white hover:bg-emerald-700">Apply</button>'
|
||
+ '<button onclick="var p=document.querySelector(\'[data-pli-panel=\\\'' + code + '\\\']\');p.classList.add(\'hidden\');p.innerHTML=\'\';var t=document.getElementById(\'pli-tog-' + code + '\');if(t)t.textContent=\'▶\'" class="text-xs px-2 py-1 rounded border border-gray-300 dark:border-slate-600">Cancel</button>'
|
||
+ '</div>';
|
||
panel.innerHTML = html;
|
||
panel.classList.remove('hidden');
|
||
const tog = document.getElementById('pli-tog-' + code);
|
||
if (tog) tog.textContent = '▼';
|
||
}
|
||
|
||
function pysimPliApplyRow(code) {
|
||
const codec = PLI_CODECS[code];
|
||
if (!codec) return;
|
||
const values = {};
|
||
codec.fields.forEach(f => {
|
||
const el = document.getElementById('pli-d-' + code + '-' + f.id);
|
||
if (el) values[f.id] = el.value;
|
||
});
|
||
const hex = codec.encode(values);
|
||
if (!hex) return;
|
||
const input = document.getElementById('pli-v-' + code);
|
||
if (input) input.value = hex;
|
||
const panel = document.querySelector('[data-pli-panel="' + code + '"]');
|
||
if (panel) { panel.innerHTML = ''; panel.classList.add('hidden'); }
|
||
const tog = document.getElementById('pli-tog-' + code);
|
||
if (tog) tog.textContent = '▶';
|
||
}
|
||
|
||
async function pysimPliSave() {
|
||
const btn = document.getElementById('pli-save-btn');
|
||
btn.disabled = true;
|
||
btn.textContent = t('Saving...');
|
||
const data = {};
|
||
PLI_QUALIFIERS.forEach(q => {
|
||
const el = document.getElementById('pli-v-' + q.code);
|
||
if (el) {
|
||
const v = el.value.trim().replace(/\s/g, '');
|
||
if (v && !/^[0-9A-Fa-f]*$/.test(v)) return;
|
||
data[q.code] = v;
|
||
}
|
||
});
|
||
try {
|
||
await pysimFetch('/api/pli-dict', data);
|
||
btn.textContent = t('Saved');
|
||
btn.classList.add('bg-emerald-700');
|
||
setTimeout(() => { btn.textContent = t('Save'); btn.classList.remove('bg-emerald-700'); btn.disabled = false; }, 1500);
|
||
} catch (e) {
|
||
btn.textContent = 'Error';
|
||
btn.classList.add('bg-red-600');
|
||
setTimeout(() => { btn.textContent = t('Save'); btn.classList.remove('bg-red-600'); btn.disabled = false; }, 2000);
|
||
}
|
||
}
|
||
|
||
// ===== custom files =====
|
||
let pysimCustomFiles = [];
|
||
|
||
function pysimCustomLoad() {
|
||
try {
|
||
const d = localStorage.getItem('pysim_custom_files');
|
||
pysimCustomFiles = d ? JSON.parse(d) : [];
|
||
} catch (e) { pysimCustomFiles = []; }
|
||
}
|
||
|
||
function pysimCustomSave() {
|
||
localStorage.setItem('pysim_custom_files', JSON.stringify(pysimCustomFiles));
|
||
}
|
||
|
||
function pysimCustomAdd() {
|
||
const pathEl = document.getElementById('pysim-cf-path');
|
||
const nameEl = document.getElementById('pysim-cf-name');
|
||
const path = pathEl.value.trim().toUpperCase();
|
||
const name = nameEl.value.trim();
|
||
if (!path || !name) return;
|
||
if (!name.startsWith('EF.') && !name.startsWith('DF.')) {
|
||
alert('Alias must start with "EF." or "DF."');
|
||
return;
|
||
}
|
||
const parts = path.split('/').filter(Boolean);
|
||
if (parts.length < 2) { alert('Path must have at least 2 parts, e.g. 3F00/6F46'); return; }
|
||
const fid = parts[parts.length - 1];
|
||
const parentFid = parts[parts.length - 2];
|
||
if (pysimCustomFiles.some(c => c.path.toUpperCase() === path)) { alert('Path already exists'); return; }
|
||
pysimCustomFiles.push({ path, name, fid, parentFid });
|
||
pysimCustomSave();
|
||
pysimCustomRender();
|
||
pathEl.value = '';
|
||
nameEl.value = '';
|
||
}
|
||
|
||
function pysimCustomRemove(i) {
|
||
pysimCustomFiles.splice(i, 1);
|
||
pysimCustomSave();
|
||
pysimCustomRender();
|
||
}
|
||
|
||
function pysimCustomRender() {
|
||
const el = document.getElementById('pysim-cf-list');
|
||
if (!pysimCustomFiles.length) { el.innerHTML = '<span class="text-gray-400">No custom files defined.</span>'; return; }
|
||
let html = '';
|
||
for (let i = 0; i < pysimCustomFiles.length; i++) {
|
||
const c = pysimCustomFiles[i];
|
||
html += '<div class="flex items-center gap-2 py-1 border-b border-gray-200 dark:border-slate-700">';
|
||
html += '<span class="text-gray-500">' + esc(c.path) + '</span> <span class="text-blue-600 dark:text-blue-400">→</span> <b>' + esc(c.name) + '</b>';
|
||
html += '<span onclick="pysimCustomRemove(' + i + ')" class="ml-auto cursor-pointer text-red-400 hover:text-red-600">✕</span>';
|
||
html += '</div>';
|
||
}
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
function pysimCustomExport() {
|
||
const el = document.getElementById('pysim-cf-io');
|
||
el.value = JSON.stringify(pysimCustomFiles, null, 2);
|
||
el.classList.remove('hidden');
|
||
el.select();
|
||
}
|
||
|
||
function pysimCustomExportFile() {
|
||
downloadJson('otaman-custom-files.json', pysimCustomFiles);
|
||
}
|
||
|
||
async function pysimCustomImport(text) {
|
||
try {
|
||
if (text == null) text = await navigator.clipboard.readText();
|
||
const imported = JSON.parse(text);
|
||
if (!Array.isArray(imported)) throw new Error('Expected an array');
|
||
let added = 0;
|
||
for (const entry of imported) {
|
||
if (!entry.path || !entry.name) continue;
|
||
const path = entry.path.toUpperCase();
|
||
if (!pysimCustomFiles.some(c => c.path.toUpperCase() === path)) {
|
||
const parts = path.split('/').filter(Boolean);
|
||
pysimCustomFiles.push({
|
||
path: path,
|
||
name: entry.name.toUpperCase(),
|
||
fid: (entry.fid || parts[parts.length - 1]).toUpperCase(),
|
||
parentFid: (entry.parentFid || parts[parts.length - 2]).toUpperCase(),
|
||
});
|
||
added++;
|
||
}
|
||
}
|
||
pysimCustomSave();
|
||
pysimCustomRender();
|
||
const el = document.getElementById('pysim-cf-io');
|
||
el.value = 'Imported ' + added + ' files.';
|
||
setTimeout(() => { el.classList.add('hidden'); }, 2000);
|
||
} catch (e) {
|
||
alert('Import error: ' + e.message);
|
||
}
|
||
}
|
||
|
||
function pysimCustomImportFile(input) {
|
||
const file = input.files && input.files[0];
|
||
input.value = '';
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => pysimCustomImport(reader.result);
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
function pysimCustomImportField() {
|
||
const el = document.getElementById('pysim-cf-io');
|
||
const val = el.value.trim();
|
||
if (!el.classList.contains('hidden') && val.startsWith('[')) { pysimCustomImport(val); return; }
|
||
el.value = '';
|
||
el.placeholder = 'Paste JSON array here, then press Paste & import again';
|
||
el.classList.remove('hidden');
|
||
el.focus();
|
||
}
|
||
|
||
// Init custom files
|
||
pysimCustomLoad();
|
||
|
||
// ===== Profiler =====
|
||
let profiles = [];
|
||
let profilerView = 'list';
|
||
let profilerEditId = null;
|
||
let profilerDraft = null;
|
||
let profilerResults = null;
|
||
let profilerResultsLabels = null;
|
||
let profilerMismatchOnly = false;
|
||
let snapshots = [];
|
||
let snapshotViewId = null;
|
||
let _scanTarget = 'profile';
|
||
let profilerListTab = 'profiles';
|
||
|
||
// Files whose contents are ignored (contents check omitted) when scanning.
|
||
// Keyed by FID; display name is the pySim name.
|
||
const PROFILER_IGNORE_FILES = [
|
||
{ fid: '6F7E', name: 'EF.LOCI' },
|
||
{ fid: '6F73', name: 'EF.PSLOCI' },
|
||
{ fid: '6FE3', name: 'EF.EPSLOCI' },
|
||
{ fid: '4F01', name: 'EF.5GS3GPPLOCI' },
|
||
{ fid: '6F08', name: 'EF.Keys' },
|
||
{ fid: '6F09', name: 'EF.KeysPS' },
|
||
{ fid: '6F3C', name: 'EF.SMS' },
|
||
{ fid: '6F20', name: 'EF.Kc' },
|
||
{ fid: '6F52', name: 'EF.KcGPRS' },
|
||
{ fid: '6F53', name: 'EF.LOCIGPRS' },
|
||
{ fid: '6F48', name: 'EF.CBMID' },
|
||
{ fid: '6F43', name: 'EF.SMSS' },
|
||
{ fid: '6F78', name: 'EF.ACC' },
|
||
{ fid: '6FE4', name: 'EF.EPSNSC' },
|
||
{ fid: '6F5B', name: 'EF.START-HFN' },
|
||
{ fid: '2F06', name: 'EF.ARR', checked: false },
|
||
];
|
||
|
||
// Files where contents are captured as a mask matching only the first 4 bytes
|
||
// (the rest are '?' wildcards). Keyed by FID; display name is the pySim name.
|
||
const PROFILER_MASK_PREFIX4_FIDS = {
|
||
'6F07': 'EF.IMSI',
|
||
'2FE2': 'EF.ICCID',
|
||
};
|
||
|
||
function profilerLoad() {
|
||
try {
|
||
const d = localStorage.getItem('otaman_profiles');
|
||
profiles = d ? JSON.parse(d) : [];
|
||
} catch (e) { profiles = []; }
|
||
}
|
||
|
||
function profilerSave() {
|
||
localStorage.setItem('otaman_profiles', JSON.stringify(profiles));
|
||
}
|
||
|
||
function snapshotsLoad() {
|
||
try {
|
||
const d = localStorage.getItem('otaman_snapshots');
|
||
snapshots = d ? JSON.parse(d) : [];
|
||
} catch (e) { snapshots = []; }
|
||
}
|
||
|
||
function snapshotsSave() {
|
||
try {
|
||
localStorage.setItem('otaman_snapshots', JSON.stringify(snapshots));
|
||
} catch (e) {
|
||
alert('Snapshot save failed: ' + e.message);
|
||
}
|
||
}
|
||
|
||
function profilerNewId() {
|
||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
||
}
|
||
|
||
function profilerNormHex(s) {
|
||
return (s || '').toUpperCase().replace(/[^0-9A-F?]/g, '');
|
||
}
|
||
|
||
// Strict hex normalization for byte-exact comparisons (no '?' wildcards).
|
||
function profilerNormHexStrict(s) {
|
||
return (s || '').toUpperCase().replace(/[^0-9A-F]/g, '');
|
||
}
|
||
|
||
// Match an expected hex (may contain '?' nibble wildcards) against actual hex.
|
||
function profilerMatch(mode, expected, actual) {
|
||
const e = profilerNormHex(expected);
|
||
const a = profilerNormHex(actual);
|
||
if (!e) return false;
|
||
if (mode === 'mask') {
|
||
if (e.indexOf('?') < 0) {
|
||
return a.indexOf(e) === 0;
|
||
}
|
||
if (e.length !== a.length) return false;
|
||
for (let i = 0; i < e.length; i++) {
|
||
if (e[i] !== '?' && e[i] !== a[i]) return false;
|
||
}
|
||
return true;
|
||
}
|
||
return e === a;
|
||
}
|
||
|
||
// Like profilerMatch, but compares only the overlapping (shorter) portion.
|
||
function profilerMatchMin(mode, expected, actual) {
|
||
const e = profilerNormHex(expected);
|
||
const a = profilerNormHex(actual);
|
||
const n = Math.min(e.length, a.length);
|
||
return profilerMatch(mode, e.slice(0, n), a.slice(0, n));
|
||
}
|
||
|
||
// Keep the first 4 bytes (8 hex chars) exact and mask the rest with '?'.
|
||
function profilerMaskPrefix4(hex) {
|
||
const h = profilerNormHex(hex);
|
||
if (h.length <= 8) return h;
|
||
return h.slice(0, 8) + '?'.repeat(h.length - 8);
|
||
}
|
||
|
||
// Which size/record fields apply to a given file type.
|
||
function profilerFileFields(ft) {
|
||
if (ft === 'linear_fixed' || ft === 'cyclic') return { size: false, records: true };
|
||
if (ft === 'df') return { size: false, records: false };
|
||
if (ft === 'transparent' || ft === 'ber_tlv') return { size: true, records: false };
|
||
return { size: true, records: true };
|
||
}
|
||
|
||
// Content representation ('record' vs 'transparent') for a given file type.
|
||
function profilerContentKindForFileType(ft) {
|
||
return (ft === 'linear_fixed' || ft === 'cyclic') ? 'record' : 'transparent';
|
||
}
|
||
|
||
// A fresh record content object seeded with one empty record row.
|
||
function profilerEmptyRecordContent(mode) {
|
||
return { mode: mode, kind: 'record', records: [{ num: 1, data: '' }] };
|
||
}
|
||
|
||
function profilerValidateProfile(obj) {
|
||
if (!obj || typeof obj !== 'object') return 'Not an object';
|
||
if (!obj.name) return 'Missing name';
|
||
if (!Array.isArray(obj.rules)) return 'Missing rules array';
|
||
for (const r of obj.rules) {
|
||
if (!r || typeof r !== 'object') return 'Invalid rule';
|
||
if (r.type !== 'file') return 'Unsupported rule type: ' + r.type;
|
||
if (!r.path) return 'Rule missing path';
|
||
if (r.fciMode && !['type', 'type_size', 'exact'].includes(r.fciMode)) return 'Invalid FCP/FCI mode: ' + r.fciMode;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function profilerRenderList() {
|
||
const el = document.getElementById('profiler-list-body');
|
||
if (!profiles.length) {
|
||
el.innerHTML = '<span class="text-gray-400">' + t('No profiles defined.') + '</span>';
|
||
return;
|
||
}
|
||
let html = '';
|
||
for (let i = 0; i < profiles.length; i++) {
|
||
const p = profiles[i];
|
||
html += '<div class="flex items-center gap-2 py-1.5 border-b border-gray-200 dark:border-slate-700">';
|
||
html += '<b>' + esc(p.name) + '</b>';
|
||
html += '<span class="text-xs text-gray-400 dark:text-slate-500">' + esc(new Date(p.created).toLocaleString()) + '</span>';
|
||
html += '<span class="text-xs text-gray-400 dark:text-slate-500">(' + p.rules.length + ' ' + esc(t('rules')) + ')</span>';
|
||
html += '<span class="ml-auto flex gap-1">';
|
||
html += '<button data-needs="card" onclick="profilerCheck(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed">' + esc(t('Check card')) + ' ▶</button>';
|
||
html += '<button onclick="profilerCheckSnapshotPick(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700">' + esc(t('Check card snapshot')) + '</button>';
|
||
html += '<button onclick="profilerEdit(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700">' + esc(t('Edit')) + '</button>';
|
||
html += '<button onclick="profilerExport(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-gray-600 text-white hover:bg-gray-700">' + esc(t('Export')) + '</button>';
|
||
html += '<button onclick="profilerDelete(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + esc(t('Delete')) + '</button>';
|
||
html += '</span></div>';
|
||
}
|
||
el.innerHTML = html;
|
||
pysimApplyAvailability();
|
||
}
|
||
|
||
function profilerListSwitch(name) {
|
||
profilerListTab = name;
|
||
document.querySelectorAll('.profiler-list-tab').forEach(btn => {
|
||
const active = btn.dataset.listTab === name;
|
||
btn.classList.toggle('bg-blue-600', active);
|
||
btn.classList.toggle('dark:bg-blue-500', active);
|
||
btn.classList.toggle('text-white', active);
|
||
btn.classList.toggle('dark:text-white', active);
|
||
btn.classList.toggle('bg-gray-200', !active);
|
||
btn.classList.toggle('dark:bg-slate-700', !active);
|
||
btn.classList.toggle('text-gray-700', !active);
|
||
btn.classList.toggle('dark:text-slate-300', !active);
|
||
});
|
||
document.getElementById('profiler-list-profiles').classList.toggle('hidden', name !== 'profiles');
|
||
document.getElementById('profiler-list-snapshots').classList.toggle('hidden', name !== 'snapshots');
|
||
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');
|
||
}
|
||
|
||
function profilerSetView(view) {
|
||
profilerView = view;
|
||
document.getElementById('profiler-list').classList.toggle('hidden', view !== 'list');
|
||
document.getElementById('profiler-editor').classList.toggle('hidden', view !== 'editor');
|
||
document.getElementById('profiler-results').classList.toggle('hidden', view !== 'results');
|
||
document.getElementById('profiler-snapshot').classList.toggle('hidden', view !== 'snapshot');
|
||
if (view === 'list') {
|
||
profilerRenderList();
|
||
profilerRenderSnapshotList();
|
||
profilerListSwitch(profilerListTab);
|
||
}
|
||
}
|
||
|
||
function profilerRenderSnapshotList() {
|
||
const el = document.getElementById('snapshot-list-body');
|
||
if (!snapshots.length) {
|
||
el.innerHTML = '<span class="text-gray-400">' + t('No snapshots defined.') + '</span>';
|
||
return;
|
||
}
|
||
let html = '';
|
||
for (let i = 0; i < snapshots.length; i++) {
|
||
const s = snapshots[i];
|
||
html += '<div class="flex items-center gap-2 py-1.5 border-b border-gray-200 dark:border-slate-700">';
|
||
html += '<b>' + esc(s.name) + '</b>';
|
||
if (s.iccid) html += '<span class="text-xs font-mono text-gray-500 dark:text-slate-400">' + esc(s.iccid) + '</span>';
|
||
html += '<span class="text-xs text-gray-400 dark:text-slate-500">' + esc(new Date(s.created).toLocaleString()) + '</span>';
|
||
html += '<span class="text-xs text-gray-400 dark:text-slate-500">(' + esc(profilerSnapshotCountLabel(s)) + ')</span>';
|
||
html += '<span class="ml-auto flex gap-1">';
|
||
html += '<button onclick="snapshotOpen(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700">' + esc(t('Open')) + '</button>';
|
||
html += '<button onclick="snapshotExport(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-gray-600 text-white hover:bg-gray-700">' + esc(t('Export')) + '</button>';
|
||
html += '<button onclick="snapshotDelete(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + esc(t('Delete')) + '</button>';
|
||
html += '</span></div>';
|
||
}
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
function profilerNew() {
|
||
const name = prompt(t('Profile name') + ':', '');
|
||
if (!name) return;
|
||
profiles.push({ id: profilerNewId(), name: name, created: new Date().toISOString(), rules: [] });
|
||
profilerSave();
|
||
profilerEdit(profiles.length - 1);
|
||
}
|
||
|
||
function profilerScanToggleAll(checked) {
|
||
document.querySelectorAll('#profiler-scan-ignore input[data-ignore-fid]').forEach(cb => { cb.checked = checked; });
|
||
}
|
||
|
||
function profilerScanIgnoreAllState() {
|
||
const boxes = document.querySelectorAll('#profiler-scan-ignore input[data-ignore-fid]');
|
||
const all = document.getElementById('profiler-scan-ignore-all');
|
||
if (!all) return;
|
||
let checked = 0;
|
||
boxes.forEach(cb => { if (cb.checked) checked++; });
|
||
all.checked = boxes.length > 0 && checked === boxes.length;
|
||
all.indeterminate = checked > 0 && checked < boxes.length;
|
||
}
|
||
|
||
function profilerScanSetTarget(target) {
|
||
_scanTarget = target;
|
||
const title = document.getElementById('profiler-scan-title');
|
||
const label = document.getElementById('profiler-scan-name-label');
|
||
const titleKey = target === 'snapshot' ? 'New snapshot' : 'Profile from card';
|
||
const labelKey = target === 'snapshot' ? 'Snapshot name' : 'Profile name';
|
||
title.setAttribute('data-l10n', titleKey);
|
||
title.textContent = t(titleKey);
|
||
label.setAttribute('data-l10n', labelKey);
|
||
label.textContent = t(labelKey);
|
||
document.getElementById('profiler-scan-options').classList.toggle('hidden', target === 'snapshot');
|
||
}
|
||
|
||
// Re-translate the dynamically built mask labels in the open scan modal,
|
||
// preserving the checkbox state (ignore-list labels are plain file names).
|
||
function profilerScanRefreshOptions() {
|
||
if (_scanTarget === 'snapshot') return;
|
||
document.querySelectorAll('#profiler-scan-mask input[data-mask-fid]').forEach(cb => {
|
||
const span = cb.parentElement ? cb.parentElement.querySelector('span') : null;
|
||
if (span) span.textContent = t('Match first 4 bytes for') + ' ' + PROFILER_MASK_PREFIX4_FIDS[cb.getAttribute('data-mask-fid')];
|
||
});
|
||
}
|
||
|
||
function profilerFromCard() {
|
||
profilerScanSetTarget('profile');
|
||
document.getElementById('profiler-scan-name').value = '';
|
||
document.getElementById('profiler-scan-name').readOnly = false;
|
||
document.getElementById('profiler-scan-name').classList.remove('opacity-50');
|
||
const ignore = document.getElementById('profiler-scan-ignore');
|
||
let html = '';
|
||
for (const f of PROFILER_IGNORE_FILES) {
|
||
html += '<label class="flex items-center gap-2 text-sm">' +
|
||
'<input type="checkbox" data-ignore-fid="' + f.fid + '" data-ignore-name="' + f.name + '"' + (f.checked === false ? '' : ' checked') + ' onchange="profilerScanIgnoreAllState()">' +
|
||
'<span>' + esc(f.name) + '</span></label>';
|
||
}
|
||
ignore.innerHTML = html;
|
||
profilerScanIgnoreAllState();
|
||
const mask = document.getElementById('profiler-scan-mask');
|
||
let mhtml = '';
|
||
for (const fid in PROFILER_MASK_PREFIX4_FIDS) {
|
||
mhtml += '<label class="flex items-center gap-2 text-sm">' +
|
||
'<input type="checkbox" data-mask-fid="' + fid + '" checked>' +
|
||
'<span>' + esc(t('Match first 4 bytes for')) + ' ' + esc(PROFILER_MASK_PREFIX4_FIDS[fid]) + '</span></label>';
|
||
}
|
||
mask.innerHTML = mhtml;
|
||
document.getElementById('profiler-scan-options').classList.remove('hidden');
|
||
document.getElementById('profiler-scan-progress').classList.add('hidden');
|
||
document.getElementById('profiler-scan-error').classList.add('hidden');
|
||
document.getElementById('profiler-scan-cancel-btn').disabled = false;
|
||
document.getElementById('profiler-scan-btn').disabled = false;
|
||
document.getElementById('profiler-scan-btn').textContent = t('Scan');
|
||
document.getElementById('profiler-scan-modal').classList.remove('hidden');
|
||
document.getElementById('profiler-scan-name').focus();
|
||
}
|
||
|
||
function profilerScanCancel() {
|
||
document.getElementById('profiler-scan-modal').classList.add('hidden');
|
||
}
|
||
|
||
function snapshotNew() {
|
||
profilerScanSetTarget('snapshot');
|
||
const nameEl = document.getElementById('profiler-scan-name');
|
||
nameEl.value = '';
|
||
nameEl.readOnly = false;
|
||
nameEl.classList.remove('opacity-50');
|
||
document.getElementById('profiler-scan-progress').classList.add('hidden');
|
||
document.getElementById('profiler-scan-error').classList.add('hidden');
|
||
document.getElementById('profiler-scan-cancel-btn').disabled = false;
|
||
document.getElementById('profiler-scan-btn').disabled = false;
|
||
document.getElementById('profiler-scan-btn').textContent = t('Scan');
|
||
document.getElementById('profiler-scan-modal').classList.remove('hidden');
|
||
nameEl.focus();
|
||
}
|
||
|
||
function profilerScanNameKeydown(e) {
|
||
if (e.key !== 'Enter') return;
|
||
if (document.getElementById('profiler-scan-btn').disabled) return;
|
||
e.preventDefault();
|
||
profilerScanStart();
|
||
}
|
||
|
||
async function profilerScanStart() {
|
||
const name = document.getElementById('profiler-scan-name').value.trim();
|
||
const errEl = document.getElementById('profiler-scan-error');
|
||
if (!name) {
|
||
errEl.textContent = t(_scanTarget === 'snapshot' ? 'Please enter a snapshot name' : 'Please enter a profile name');
|
||
errEl.classList.remove('hidden');
|
||
return;
|
||
}
|
||
const ignoreFids = new Set();
|
||
const ignoreNames = new Set();
|
||
document.querySelectorAll('#profiler-scan-ignore input[data-ignore-fid]').forEach(cb => {
|
||
if (cb.checked) {
|
||
ignoreFids.add(cb.getAttribute('data-ignore-fid').toUpperCase());
|
||
ignoreNames.add((cb.getAttribute('data-ignore-name') || '').toUpperCase());
|
||
}
|
||
});
|
||
const maskFids = new Set();
|
||
document.querySelectorAll('#profiler-scan-mask input[data-mask-fid]').forEach(cb => {
|
||
if (cb.checked) maskFids.add(cb.getAttribute('data-mask-fid').toUpperCase());
|
||
});
|
||
const fciMode = document.getElementById('profiler-scan-fci-mode').value;
|
||
const btn = document.getElementById('profiler-scan-btn');
|
||
const cancelBtn = document.getElementById('profiler-scan-cancel-btn');
|
||
const nameEl = document.getElementById('profiler-scan-name');
|
||
const progEl = document.getElementById('profiler-scan-progress');
|
||
document.getElementById('profiler-scan-options').classList.add('hidden');
|
||
cancelBtn.disabled = true;
|
||
nameEl.readOnly = true;
|
||
nameEl.classList.add('opacity-50');
|
||
progEl.classList.remove('hidden');
|
||
progEl.textContent = t('Discovering...');
|
||
btn.disabled = true;
|
||
btn.textContent = t('Scanning...');
|
||
try {
|
||
const onProgress = (done, total, path) => {
|
||
progEl.textContent = done + ' / ' + total + ' ' + t('files') + (path ? ' — ' + path : '');
|
||
};
|
||
if (_scanTarget === 'snapshot') {
|
||
const timing = profilerTimingAccumulator();
|
||
const scanStart = performance.now();
|
||
const files = await profilerScanCard(new Set(), new Set(), 'exact', onProgress, new Set(), 'snapshot', timing);
|
||
const stats = timing.stats();
|
||
stats.total_ms = Math.round(performance.now() - scanStart);
|
||
const snapshot = { id: profilerNewId(), name: name, created: new Date().toISOString(), iccid: profilerSnapshotIccid(files), files: files, timing: stats };
|
||
snapshots.push(snapshot);
|
||
snapshotsSave();
|
||
profilerScanCancel();
|
||
profilerSetView('list');
|
||
profilerListSwitch('snapshots');
|
||
} else {
|
||
const rules = await profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, maskFids);
|
||
const profile = { id: profilerNewId(), name: name, created: new Date().toISOString(), rules: rules };
|
||
profiles.push(profile);
|
||
profilerSave();
|
||
profilerScanCancel();
|
||
profilerEdit(profiles.length - 1);
|
||
}
|
||
} catch (e) {
|
||
errEl.textContent = e.message;
|
||
errEl.classList.remove('hidden');
|
||
} finally {
|
||
profilerScanSetTarget(_scanTarget);
|
||
cancelBtn.disabled = false;
|
||
nameEl.readOnly = false;
|
||
nameEl.classList.remove('opacity-50');
|
||
progEl.classList.add('hidden');
|
||
btn.disabled = false;
|
||
btn.textContent = t('Scan');
|
||
}
|
||
}
|
||
|
||
// Recursively walk the card filesystem and build rules for files that exist.
|
||
// Phase 1 discovers every file (tree calls only) to know the total; phase 2
|
||
// builds a rule per file, reporting progress via the optional onProgress
|
||
// callback as onProgress(done, total, path).
|
||
async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, maskFids, mode, timing) {
|
||
const rules = [];
|
||
const files = [];
|
||
const seen = new Set();
|
||
|
||
// dir: { name, fid, parentSel (to select this dir), pathPrefix (rule-path segs for its children) }
|
||
async function walkDir(dir) {
|
||
let body = { name: dir.name, fid: dir.fid };
|
||
if (dir.parentSel) body.parent_sel = dir.parentSel;
|
||
let data;
|
||
try {
|
||
data = await pysimFetch('/api/tree', body);
|
||
} catch (e) { return; }
|
||
if (!data || data.exists === false) return;
|
||
for (const c of (data.children || [])) {
|
||
// parent selector for c, mirroring getParentSel()
|
||
const parentSel = (dir.name === 'MF') ? 'MF'
|
||
: (dir.name && dir.name.startsWith('ADF.')) ? dir.name
|
||
: (dir.fid ? dir.fid : dir.name);
|
||
if (c.isDir) {
|
||
// ADF roots use the AID, not the generic ADF fid
|
||
const childPrefix = c.aid ? [c.aid.toUpperCase()]
|
||
: dir.pathPrefix.concat(c.fid ? c.fid.toUpperCase() : c.name);
|
||
await walkDir({ name: c.name, fid: c.fid, parentSel: parentSel, pathPrefix: childPrefix });
|
||
} else {
|
||
const childPath = dir.pathPrefix.concat(c.fid ? c.fid.toUpperCase() : c.name).join('/');
|
||
if (seen.has(childPath)) continue;
|
||
seen.add(childPath);
|
||
files.push({ path: childPath, child: c });
|
||
}
|
||
}
|
||
}
|
||
|
||
// MF-rooted walk
|
||
await walkDir({ name: 'MF', fid: null, parentSel: null, pathPrefix: ['MF'] });
|
||
|
||
// Sweep custom files not already covered (map leading MF fid 3F00 -> MF)
|
||
for (const cf of pysimCustomFiles) {
|
||
const parts = cf.path.split('/').filter(Boolean);
|
||
if (!parts.length) continue;
|
||
let segs;
|
||
if (parts[0].toUpperCase() === '3F00') segs = ['MF'].concat(parts.slice(1));
|
||
else segs = parts;
|
||
const path = segs.join('/');
|
||
if (seen.has(path)) continue;
|
||
seen.add(path);
|
||
files.push({ path: path, child: { fid: cf.fid, name: cf.name } });
|
||
}
|
||
|
||
const total = files.length;
|
||
if (onProgress) onProgress(0, total, '');
|
||
for (let i = 0; i < files.length; i++) {
|
||
const entry = files[i];
|
||
if (onProgress) onProgress(i + 1, total, entry.path);
|
||
const rule = mode === 'snapshot'
|
||
? await profilerBuildSnapshotFile(entry.path, entry.child, timing)
|
||
: await profilerBuildFileRule(entry.path, entry.child, ignoreFids, ignoreNames, fciMode, maskFids);
|
||
if (rule) rules.push(rule);
|
||
}
|
||
return rules;
|
||
}
|
||
|
||
async function profilerBuildFileRule(path, c, ignoreFids, ignoreNames, fciMode, maskFids) {
|
||
let sel;
|
||
try {
|
||
sel = await pysimFetch('/api/select', { path: path });
|
||
} catch (e) { return null; }
|
||
if (!sel || sel.exists !== true) return null;
|
||
const isRecord = profilerContentKindForFileType(sel.file_type) === 'record';
|
||
const rule = {
|
||
type: 'file',
|
||
path: path,
|
||
name: c.name || sel.name || null,
|
||
fileType: sel.file_type || null,
|
||
fileSize: isRecord ? null : ((sel.file_size === null || sel.file_size === undefined) ? null : sel.file_size),
|
||
recordLen: (sel.record_len === null || sel.record_len === undefined) ? null : sel.record_len,
|
||
numRecords: (sel.num_of_rec === null || sel.num_of_rec === undefined) ? null : sel.num_of_rec,
|
||
fciMode: fciMode || 'type_size',
|
||
fciHex: sel.fci_hex || null,
|
||
content: null,
|
||
};
|
||
const fid = (c.fid || sel.fid || '').toUpperCase();
|
||
const name = (c.name || sel.name || '').toUpperCase();
|
||
if (ignoreFids.has(fid) || (ignoreNames && ignoreNames.has(name))) return rule;
|
||
try {
|
||
const rd = await pysimFetch('/api/read', { path: path, mode: 'raw' });
|
||
if (rd && rd.success) {
|
||
if (rd.records) {
|
||
rule.content = {
|
||
mode: 'exact', kind: 'record',
|
||
records: rd.records.map(r => ({ num: r.num, data: r.data })),
|
||
};
|
||
} else if (rd.data) {
|
||
const useMask = maskFids ? maskFids.has(fid) : !!PROFILER_MASK_PREFIX4_FIDS[fid];
|
||
if (useMask) {
|
||
rule.content = { mode: 'mask', kind: 'transparent', expected: profilerMaskPrefix4(rd.data) };
|
||
} else {
|
||
rule.content = { mode: 'exact', kind: 'transparent', expected: rd.data };
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
return rule;
|
||
}
|
||
|
||
// Card snapshot entry: metadata + raw FCI + exact contents for every readable
|
||
// file (no ignore list, no masking, no FCP/FCI check mode).
|
||
function profilerTimingStats(values) {
|
||
const vals = (values || []).filter(v => typeof v === 'number' && isFinite(v));
|
||
if (!vals.length) return null;
|
||
let min = vals[0], max = vals[0], sum = 0;
|
||
for (const v of vals) {
|
||
if (v < min) min = v;
|
||
if (v > max) max = v;
|
||
sum += v;
|
||
}
|
||
return { min: Math.round(min), max: Math.round(max), avg: Math.round(sum / vals.length * 10) / 10, count: vals.length };
|
||
}
|
||
|
||
function profilerTimingAccumulator() {
|
||
const buckets = { select: [], read_binary: [], read_record: [] };
|
||
return {
|
||
add(type, ms) {
|
||
if (buckets[type]) buckets[type].push(ms);
|
||
},
|
||
stats() {
|
||
const out = {};
|
||
for (const type in buckets) {
|
||
const s = profilerTimingStats(buckets[type]);
|
||
if (s) out[type] = s;
|
||
}
|
||
return out;
|
||
},
|
||
};
|
||
}
|
||
|
||
function profilerFormatMs(ms) {
|
||
if (ms === null || ms === undefined) return 'n/a';
|
||
if (ms >= 1000) return (ms / 1000).toFixed(2) + ' s';
|
||
return Math.round(ms) + ' ms';
|
||
}
|
||
|
||
function profilerSnapshotCountLabel(s) {
|
||
let label = (s.files ? s.files.length : 0) + ' ' + t('files');
|
||
if (s.timing && s.timing.total_ms !== undefined) {
|
||
label += ', ' + t('scanned in') + ' ' + (s.timing.total_ms / 1000).toFixed(2) + ' ' + t('sec');
|
||
}
|
||
return label;
|
||
}
|
||
|
||
async function profilerBuildSnapshotFile(path, c, timing) {
|
||
let sel;
|
||
try {
|
||
sel = await pysimFetch('/api/select', { path: path });
|
||
} catch (e) { return null; }
|
||
if (!sel || sel.exists !== true) return null;
|
||
const isRecord = profilerContentKindForFileType(sel.file_type) === 'record';
|
||
const file = {
|
||
path: path,
|
||
name: c.name || sel.name || null,
|
||
fileType: sel.file_type || null,
|
||
fileSize: isRecord ? null : ((sel.file_size === null || sel.file_size === undefined) ? null : sel.file_size),
|
||
recordLen: (sel.record_len === null || sel.record_len === undefined) ? null : sel.record_len,
|
||
numRecords: (sel.num_of_rec === null || sel.num_of_rec === undefined) ? null : sel.num_of_rec,
|
||
fciHex: sel.fci_hex || null,
|
||
content: null,
|
||
timing: null,
|
||
};
|
||
const selectTimes = (sel.apdu_times || []).filter(t => t.type === 'select').map(t => t.ms);
|
||
if (selectTimes.length) {
|
||
if (timing) selectTimes.forEach(ms => timing.add('select', ms));
|
||
file.timing = { select_ms: selectTimes.reduce((a, b) => a + b, 0), read_ms: null };
|
||
}
|
||
try {
|
||
const rd = await pysimFetch('/api/read', { path: path, mode: 'raw' });
|
||
if (rd && rd.success) {
|
||
const readTimes = rd.apdu_times || [];
|
||
const recTimes = readTimes.filter(t => t.type === 'read_record').map(t => t.ms);
|
||
const binTimes = readTimes.filter(t => t.type === 'read_binary').map(t => t.ms);
|
||
if (rd.records) {
|
||
file.content = { kind: 'record', records: rd.records.map((r, i) => {
|
||
const rec = { num: r.num, data: r.data };
|
||
if (recTimes[i] !== undefined) rec.ms = recTimes[i];
|
||
return rec;
|
||
}) };
|
||
if (timing) recTimes.forEach(ms => timing.add('read_record', ms));
|
||
if (recTimes.length) {
|
||
file.timing = file.timing || { select_ms: null, read_ms: null };
|
||
file.timing.read_ms = recTimes.reduce((a, b) => a + b, 0);
|
||
}
|
||
} else if (rd.data) {
|
||
file.content = { kind: 'transparent', data: rd.data };
|
||
if (timing) binTimes.forEach(ms => timing.add('read_binary', ms));
|
||
if (binTimes.length) {
|
||
file.timing = file.timing || { select_ms: null, read_ms: null };
|
||
file.timing.read_ms = binTimes.reduce((a, b) => a + b, 0);
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
return file;
|
||
}
|
||
|
||
// Decode the ICCID from the captured EF.ICCID (2FE2) entry, if readable.
|
||
function profilerSnapshotIccid(files) {
|
||
const f = (files || []).find(x => x.name === 'EF.ICCID' || (x.path || '').toUpperCase().endsWith('/2FE2'));
|
||
if (!f || !f.content || f.content.kind !== 'transparent' || !f.content.data) return null;
|
||
return decIccid(f.content.data) || null;
|
||
}
|
||
|
||
function profilerImportFile(input) {
|
||
const file = input.files && input.files[0];
|
||
input.value = '';
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
try {
|
||
const obj = JSON.parse(reader.result);
|
||
const err = profilerValidateProfile(obj);
|
||
if (err) throw new Error(err);
|
||
obj.id = profilerNewId();
|
||
obj.created = obj.created || new Date().toISOString();
|
||
profiles.push(obj);
|
||
profilerSave();
|
||
profilerRenderList();
|
||
profilerListSwitch('profiles');
|
||
} catch (e) {
|
||
alert('Import error: ' + e.message);
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
function profilerExport(i) {
|
||
const p = profiles[i];
|
||
downloadJson((p.name || 'profile').replace(/[^a-zA-Z0-9_-]+/g, '_') + '.json', p);
|
||
}
|
||
|
||
function profilerDelete(i) {
|
||
const p = profiles[i];
|
||
if (!confirm(t('Delete profile') + ' "' + p.name + '"?') ) return;
|
||
profiles.splice(i, 1);
|
||
profilerSave();
|
||
profilerRenderList();
|
||
}
|
||
|
||
function profilerEdit(i) {
|
||
const p = profiles[i];
|
||
profilerEditId = p.id;
|
||
profilerDraft = JSON.parse(JSON.stringify(p));
|
||
profilerSetView('editor');
|
||
document.getElementById('profiler-name').value = profilerDraft.name;
|
||
profilerRenderEditor();
|
||
}
|
||
|
||
function profilerBackToList() {
|
||
profilerDraft = null;
|
||
profilerEditId = null;
|
||
snapshotViewId = null;
|
||
profilerSetView('list');
|
||
}
|
||
|
||
function profilerValidateSnapshot(obj) {
|
||
if (!obj || typeof obj !== 'object') return 'Not an object';
|
||
if (!obj.name) return 'Missing name';
|
||
if (!Array.isArray(obj.files)) return 'Missing files array';
|
||
for (const f of obj.files) {
|
||
if (!f || typeof f !== 'object') return 'Invalid file entry';
|
||
if (!f.path) return 'File entry missing path';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function snapshotImportFile(input) {
|
||
const file = input.files && input.files[0];
|
||
input.value = '';
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
try {
|
||
const obj = JSON.parse(reader.result);
|
||
const err = profilerValidateSnapshot(obj);
|
||
if (err) throw new Error(err);
|
||
obj.id = profilerNewId();
|
||
obj.created = obj.created || new Date().toISOString();
|
||
obj.iccid = obj.iccid || profilerSnapshotIccid(obj.files);
|
||
snapshots.push(obj);
|
||
snapshotsSave();
|
||
profilerRenderSnapshotList();
|
||
profilerListSwitch('snapshots');
|
||
} catch (e) {
|
||
alert('Import error: ' + e.message);
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
function snapshotExport(i) {
|
||
const s = snapshots[i];
|
||
downloadJson((s.name || 'snapshot').replace(/[^a-zA-Z0-9_-]+/g, '_') + '.json', s);
|
||
}
|
||
|
||
function snapshotDelete(i) {
|
||
const s = snapshots[i];
|
||
if (!confirm(t('Delete snapshot') + ' "' + s.name + '"?') ) return;
|
||
snapshots.splice(i, 1);
|
||
snapshotsSave();
|
||
profilerRenderSnapshotList();
|
||
}
|
||
|
||
function snapshotOpen(i) {
|
||
const s = snapshots[i];
|
||
snapshotViewId = s.id;
|
||
profilerSetView('snapshot');
|
||
document.getElementById('snapshot-name').value = s.name;
|
||
profilerRenderSnapshotData();
|
||
}
|
||
|
||
// Re-render the immutable part of the snapshot view (ICCID + file cards)
|
||
// without touching the (editable) name input.
|
||
function profilerRenderSnapshotData() {
|
||
const s = snapshots.find(x => x.id === snapshotViewId);
|
||
if (!s) return;
|
||
document.getElementById('snapshot-iccid').textContent = s.iccid || '';
|
||
document.getElementById('snapshot-summary').innerHTML = profilerRenderSnapshotSummary(s);
|
||
document.getElementById('snapshot-files').innerHTML = profilerRenderSnapshotFiles(s);
|
||
}
|
||
|
||
function profilerRenderSnapshotSummary(s) {
|
||
const files = (s.files || []).length;
|
||
let records = 0;
|
||
for (const f of (s.files || [])) {
|
||
if (f.content && f.content.kind === 'record' && f.content.records) records += f.content.records.length;
|
||
}
|
||
let html = '<div class="text-xs text-gray-500 dark:text-slate-400">' +
|
||
esc(t('Files')) + ': <b>' + files + '</b> · ' + esc(t('Records')) + ': <b>' + records + '</b>';
|
||
const timing = s.timing;
|
||
if (timing && timing.total_ms !== undefined) {
|
||
html += ' · ' + esc(t('Scan time')) + ': <b>' + esc(profilerFormatMs(timing.total_ms)) + '</b>';
|
||
}
|
||
html += '</div>';
|
||
const labels = [['select', 'Select'], ['read_binary', 'Read binary'], ['read_record', 'Read record']];
|
||
let rows = '';
|
||
if (timing) {
|
||
for (const [key, label] of labels) {
|
||
const st = timing[key];
|
||
if (!st) continue;
|
||
rows += '<div>' + esc(t(label)) + ': ' + esc(t('min')) + ' ' + st.min + ' ms · ' + esc(t('avg')) + ' ' + st.avg + ' ms · ' + esc(t('max')) + ' ' + st.max + ' ms (n=' + st.count + ')</div>';
|
||
}
|
||
}
|
||
html += rows
|
||
? '<div class="text-xs font-mono text-gray-600 dark:text-slate-400 mt-0.5">' + rows + '</div>'
|
||
: '<div class="text-xs text-gray-400 dark:text-slate-500 mt-0.5">' + esc(t('No timing data')) + '</div>';
|
||
return html;
|
||
}
|
||
|
||
function snapshotSaveName() {
|
||
const s = snapshots.find(x => x.id === snapshotViewId);
|
||
if (!s) return;
|
||
const name = document.getElementById('snapshot-name').value.trim();
|
||
if (!name) return;
|
||
s.name = name;
|
||
snapshotsSave();
|
||
document.getElementById('snapshot-name').value = s.name;
|
||
}
|
||
|
||
function profilerRenderSnapshotFiles(s) {
|
||
if (!s.files.length) return '<span class="text-gray-400">' + t('No files captured.') + '</span>';
|
||
let html = '';
|
||
for (const f of s.files) {
|
||
const name = profilerCustomNameForPath(f.path) || f.name;
|
||
const title = name
|
||
? '<span>' + esc(name) + '</span> <span class="text-gray-400 dark:text-slate-500">(' + esc(f.path) + ')</span>'
|
||
: '<span>' + esc(f.path) + '</span>';
|
||
html += '<div class="mb-2 p-2 border border-gray-200 dark:border-slate-600 rounded">';
|
||
html += '<div class="font-mono text-sm">' + title + '</div>';
|
||
const attrs = [];
|
||
if (f.fileType) attrs.push(esc(t('File type')) + ': ' + esc(f.fileType));
|
||
if (f.fileSize !== null && f.fileSize !== undefined) attrs.push(esc(t('Size')) + ': ' + esc(String(f.fileSize)));
|
||
if (f.recordLen !== null && f.recordLen !== undefined) attrs.push(esc(t('Record length')) + ': ' + esc(String(f.recordLen)));
|
||
if (f.numRecords !== null && f.numRecords !== undefined) attrs.push(esc(t('Record count')) + ': ' + esc(String(f.numRecords)));
|
||
if (attrs.length) html += '<div class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">' + attrs.join(' · ') + '</div>';
|
||
if (f.timing) {
|
||
const parts = [];
|
||
if (f.timing.select_ms !== null && f.timing.select_ms !== undefined) parts.push(esc(t('Select')) + ': ' + esc(profilerFormatMs(f.timing.select_ms)));
|
||
if (f.timing.read_ms !== null && f.timing.read_ms !== undefined) {
|
||
const readKey = profilerContentKindForFileType(f.fileType) === 'record' ? 'Read record' : 'Read binary';
|
||
parts.push(esc(t(readKey)) + ': ' + esc(profilerFormatMs(f.timing.read_ms)));
|
||
}
|
||
if (parts.length) html += '<div class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">' + parts.join(' · ') + '</div>';
|
||
}
|
||
if (f.fciHex) {
|
||
html += '<div class="mt-1 flex gap-3 items-start">';
|
||
html += '<div class="flex-1 min-w-0"><div class="text-xs text-gray-500 dark:text-slate-400 mb-0.5">' + esc(t('FCI hex (raw SELECT response)')) + '</div>' +
|
||
'<textarea readonly rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800 break-all">' + escHtml(f.fciHex) + '</textarea></div>';
|
||
html += '<div class="flex-1 min-w-0"><div class="text-xs text-gray-500 dark:text-slate-400 mb-0.5">' + esc(t('Decoded FCI')) + '</div>' +
|
||
'<div class="text-xs font-mono leading-relaxed text-gray-600 dark:text-slate-400 break-all">' + profilerFciPreviewItems(f.fciHex) + '</div></div>';
|
||
html += '</div>';
|
||
}
|
||
html += '<div class="text-xs text-gray-500 dark:text-slate-400 mt-1 mb-0.5">' + esc(t('Contents')) + '</div>';
|
||
if (!f.content) {
|
||
html += '<div class="text-xs text-gray-400 dark:text-slate-500">' + esc(t('Not captured')) + '</div>';
|
||
} else if (f.content.kind === 'record') {
|
||
html += '<div class="space-y-0.5">';
|
||
for (const rec of (f.content.records || [])) {
|
||
html += '<div class="flex items-center gap-2"><span class="text-xs text-gray-500 w-8 shrink-0">' + rec.num + '</span>' +
|
||
'<input readonly value="' + escHtml(rec.data) + '" class="flex-1 min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800">' +
|
||
(rec.ms !== undefined ? '<span class="text-xs text-gray-400 w-16 text-right shrink-0">' + esc(profilerFormatMs(rec.ms)) + '</span>' : '') +
|
||
'</div>';
|
||
}
|
||
html += '</div>';
|
||
} else {
|
||
html += '<input readonly value="' + escHtml(f.content.data || '') + '" class="w-full min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800">';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
return html;
|
||
}
|
||
|
||
function profilerAddRule() {
|
||
if (!profilerDraft) return;
|
||
profilerDraft.rules.push({ type: 'file', path: '', name: null, fileType: null, fileSize: null, recordLen: null, numRecords: null, fciMode: 'type_size', fciHex: null, content: null });
|
||
profilerRenderEditor();
|
||
}
|
||
|
||
function profilerDeleteRule(i) {
|
||
if (!profilerDraft) return;
|
||
profilerDraft.rules.splice(i, 1);
|
||
profilerRenderEditor();
|
||
}
|
||
|
||
function profilerRenderEditor() {
|
||
if (!profilerDraft) return;
|
||
const el = document.getElementById('profiler-rules');
|
||
if (!profilerDraft.rules.length) {
|
||
el.innerHTML = '<span class="text-gray-400">' + t('No rules defined.') + '</span>';
|
||
return;
|
||
}
|
||
let html = '';
|
||
for (let i = 0; i < profilerDraft.rules.length; i++) {
|
||
const r = profilerDraft.rules[i];
|
||
html += profilerRenderRule(i, r);
|
||
}
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
function profilerRenderRule(i, r) {
|
||
const fts = { transparent: 'transparent', linear_fixed: 'linear_fixed', cyclic: 'cyclic', ber_tlv: 'ber_tlv', df: 'df' };
|
||
let ftOpts = '<option value=""' + (!r.fileType ? ' selected' : '') + '>—</option>';
|
||
for (const k in fts) {
|
||
ftOpts += '<option value="' + k + '"' + (r.fileType === k ? ' selected' : '') + '>' + fts[k] + '</option>';
|
||
}
|
||
const cm = r.content ? r.content.mode : 'exact';
|
||
const fciMode = r.fciMode || 'type_size';
|
||
let html = '<div class="mb-2 p-3 border border-gray-200 dark:border-slate-600 rounded">';
|
||
html += '<div class="flex items-center gap-2 mb-2">';
|
||
html += '<span class="text-xs font-semibold text-gray-500 dark:text-slate-400">#' + (i + 1) + '</span>';
|
||
html += '<button onclick="profilerDeleteRule(' + i + ')" class="ml-auto px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + esc(t('Delete')) + '</button>';
|
||
html += '</div>';
|
||
const sname = profilerCustomNameForPath(r.path) || r.name || '';
|
||
const vis = profilerFileFields(r.fileType);
|
||
const showSize = vis.size && fciMode !== 'type';
|
||
const showRecords = vis.records && fciMode !== 'type';
|
||
html += '<div class="flex flex-wrap gap-2 mb-2">';
|
||
html += '<div class="w-48 shrink-0"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('Path')) +
|
||
' <span class="profiler-rule-name font-normal text-gray-400 dark:text-slate-500">' + esc(sname) + '</span></label>' +
|
||
'<input value="' + escHtml(r.path || '') + '" oninput="profilerUpdateRulePath(' + i + ',this.value,this)" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="MF/7F10/6F3A"></div>';
|
||
html += '<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('FCP/FCI check')) + '</label>' +
|
||
'<select onchange="profilerUpdateRuleFciMode(' + i + ',this.value)" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
|
||
'<option value="type"' + (fciMode === 'type' ? ' selected' : '') + '>' + esc(t('Filetype only (FCP)')) + '</option>' +
|
||
'<option value="type_size"' + (fciMode === 'type_size' ? ' selected' : '') + '>' + esc(t('Filetype + size (FCP)')) + '</option>' +
|
||
'<option value="exact"' + (fciMode === 'exact' ? ' selected' : '') + '>' + esc(t('Exact FCI')) + '</option>' +
|
||
'</select></div>';
|
||
html += '<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('File type')) + '</label>' +
|
||
'<select onchange="profilerUpdateRuleFileType(' + i + ',this.value||null)" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' + ftOpts + '</select></div>';
|
||
if (showSize) {
|
||
html += '<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('Size')) + '</label>' +
|
||
'<input type="number" value="' + (r.fileSize === null ? '' : r.fileSize) + '" oninput="profilerUpdateRule(' + i + ',\'fileSize\',this.value===\'\'?null:parseInt(this.value))" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="—"></div>';
|
||
}
|
||
if (showRecords) {
|
||
html += '<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('Record length')) + '</label>' +
|
||
'<input type="number" value="' + (r.recordLen === null ? '' : r.recordLen) + '" oninput="profilerUpdateRule(' + i + ',\'recordLen\',this.value===\'\'?null:parseInt(this.value))" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="—"></div>';
|
||
html += '<div class="flex-1"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('Record count')) + '</label>' +
|
||
'<input type="number" value="' + (r.numRecords === null ? '' : r.numRecords) + '" oninput="profilerUpdateRule(' + i + ',\'numRecords\',this.value===\'\'?null:parseInt(this.value))" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="—"></div>';
|
||
}
|
||
html += '</div>';
|
||
if (fciMode === 'exact') {
|
||
html += '<div class="mb-2 flex gap-3 items-start">';
|
||
html += '<div class="flex-1 min-w-0"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + esc(t('FCI hex (raw SELECT response)')) + '</label>' +
|
||
'<textarea oninput="profilerFciInput(' + i + ',this.value)" rows="4" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" placeholder="62...">' + escHtml(r.fciHex || '') + '</textarea></div>';
|
||
html += '<div class="flex-1 min-w-0"><div class="text-xs text-gray-500 dark:text-slate-400 mb-0.5">' + esc(t('Decoded FCI')) + '</div>' +
|
||
'<div id="profiler-fci-preview-' + i + '" class="text-xs font-mono leading-relaxed text-gray-600 dark:text-slate-400 break-all">' + profilerFciPreviewItems(r.fciHex || '') + '</div></div>';
|
||
html += '</div>';
|
||
}
|
||
if (r.fileType !== 'df') {
|
||
html += profilerRenderContentEditor(i, r);
|
||
}
|
||
html += '</div>';
|
||
return html;
|
||
}
|
||
|
||
function profilerRenderContentEditor(i, r) {
|
||
const c = r.content;
|
||
const mode = c ? c.mode : 'exact';
|
||
let html = '<div class="flex items-center justify-between mb-1">';
|
||
html += '<span class="text-xs text-gray-600 dark:text-slate-400">' + esc(t('Check contents')) + '</span>';
|
||
html += '<div class="flex items-center gap-3">';
|
||
html += '<label class="text-xs flex items-center gap-1"><input type="radio" name="profiler-cm-' + i + '" value="none"' + (!c ? ' checked' : '') + ' onchange="profilerSetContent(' + i + ',null)"> ' + esc(t('None')) + '</label>';
|
||
html += '<label class="text-xs flex items-center gap-1"><input type="radio" name="profiler-cm-' + i + '" value="exact"' + (c && mode === 'exact' ? ' checked' : '') + ' onchange="profilerSetContent(' + i + ',\'exact\')"> ' + esc(t('Exact')) + '</label>';
|
||
html += '<label class="text-xs flex items-center gap-1"><input type="radio" name="profiler-cm-' + i + '" value="mask"' + (c && mode === 'mask' ? ' checked' : '') + ' onchange="profilerSetContent(' + i + ',\'mask\')"> ' + esc(t('Mask')) + '</label>';
|
||
html += '</div></div>';
|
||
if (c) {
|
||
if (c.kind === 'record') {
|
||
html += '<div class="space-y-1">';
|
||
for (let ri = 0; ri < c.records.length; ri++) {
|
||
const rec = c.records[ri];
|
||
html += '<div class="flex items-center gap-2"><span class="text-xs text-gray-500 w-8">' + rec.num + '</span>' +
|
||
'<input value="' + escHtml(rec.data) + '" oninput="profilerUpdateRecord(' + i + ',' + ri + ',this.value)" class="flex-1 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">';
|
||
if (c.records.length > 1) {
|
||
html += '<button onclick="profilerDeleteRecord(' + i + ',' + ri + ')" class="text-red-400 hover:text-red-600 text-xs px-1">✕</button>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
html += '</div>';
|
||
html += '<button onclick="profilerAddRecord(' + i + ')" class="mt-1 text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-slate-700 hover:bg-gray-300 dark:hover:bg-slate-600 text-gray-700 dark:text-slate-300">+ Record</button>';
|
||
} else {
|
||
html += '<textarea oninput="profilerUpdateRuleContent(' + i + ',this.value)" rows="2" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800">' + escHtml(c.expected || '') + '</textarea>';
|
||
}
|
||
}
|
||
return html;
|
||
}
|
||
|
||
function profilerUpdateRule(i, field, value) {
|
||
if (!profilerDraft) return;
|
||
profilerDraft.rules[i][field] = value;
|
||
}
|
||
|
||
// Path edits drop the scan-time symbolic name (it no longer corresponds);
|
||
// a custom-file lookup still supplies a display name for known paths.
|
||
function profilerUpdateRulePath(i, value, input) {
|
||
if (!profilerDraft) return;
|
||
const r = profilerDraft.rules[i];
|
||
r.path = value;
|
||
r.name = null;
|
||
const span = input && input.parentElement ? input.parentElement.querySelector('.profiler-rule-name') : null;
|
||
if (span) span.textContent = profilerCustomNameForPath(value) || '';
|
||
}
|
||
|
||
function profilerUpdateRuleFileType(i, value) {
|
||
if (!profilerDraft) return;
|
||
const r = profilerDraft.rules[i];
|
||
r.fileType = value;
|
||
const vis = profilerFileFields(value);
|
||
if (!vis.size) r.fileSize = null;
|
||
if (!vis.records) { r.recordLen = null; r.numRecords = null; }
|
||
if (r.content) {
|
||
const kind = profilerContentKindForFileType(value);
|
||
if (r.content.kind !== kind) {
|
||
r.content = kind === 'record'
|
||
? profilerEmptyRecordContent(r.content.mode)
|
||
: { mode: r.content.mode, kind: 'transparent', expected: '' };
|
||
}
|
||
}
|
||
profilerRenderEditor();
|
||
}
|
||
|
||
function profilerUpdateRuleFciMode(i, value) {
|
||
if (!profilerDraft) return;
|
||
profilerDraft.rules[i].fciMode = value;
|
||
profilerRenderEditor();
|
||
}
|
||
|
||
function profilerSetContent(i, mode) {
|
||
if (!profilerDraft) return;
|
||
const r = profilerDraft.rules[i];
|
||
if (!mode) {
|
||
r.content = null;
|
||
} else if (r.content) {
|
||
r.content.mode = mode;
|
||
} else {
|
||
const kind = profilerContentKindForFileType(r.fileType);
|
||
r.content = kind === 'record'
|
||
? profilerEmptyRecordContent(mode)
|
||
: { mode: mode, kind: 'transparent', expected: '' };
|
||
}
|
||
profilerRenderEditor();
|
||
}
|
||
|
||
function profilerUpdateRuleContent(i, value) {
|
||
if (!profilerDraft) return;
|
||
const c = profilerDraft.rules[i].content;
|
||
if (c) c.expected = value;
|
||
}
|
||
|
||
function profilerUpdateRecord(i, ri, value) {
|
||
if (!profilerDraft) return;
|
||
const c = profilerDraft.rules[i].content;
|
||
if (c && c.records) c.records[ri].data = value;
|
||
}
|
||
|
||
function profilerAddRecord(i) {
|
||
if (!profilerDraft) return;
|
||
const c = profilerDraft.rules[i].content;
|
||
if (!c || c.kind !== 'record') return;
|
||
const lastNum = c.records.length ? c.records[c.records.length - 1].num : 0;
|
||
c.records.push({ num: lastNum + 1, data: '' });
|
||
profilerRenderEditor();
|
||
}
|
||
|
||
function profilerDeleteRecord(i, ri) {
|
||
if (!profilerDraft) return;
|
||
const c = profilerDraft.rules[i].content;
|
||
if (!c || c.kind !== 'record') return;
|
||
if (c.records.length <= 1) return;
|
||
c.records.splice(ri, 1);
|
||
profilerRenderEditor();
|
||
}
|
||
|
||
function profilerSaveProfile() {
|
||
if (!profilerDraft) return;
|
||
profilerDraft.name = document.getElementById('profiler-name').value.trim();
|
||
const idx = profiles.findIndex(p => p.id === profilerEditId);
|
||
if (idx >= 0) profiles[idx] = profilerDraft;
|
||
profilerSave();
|
||
profilerSetView('list');
|
||
}
|
||
|
||
async function profilerCheck(i) {
|
||
const p = profiles[i];
|
||
await profilerRunProfile(p, null, p.name);
|
||
}
|
||
|
||
async function profilerRunProfile(p, source, title, extraResults, labels) {
|
||
profilerSetView('results');
|
||
document.getElementById('profiler-results-title').textContent = title;
|
||
const prog = document.getElementById('profiler-progress');
|
||
document.getElementById('profiler-summary').innerHTML = '';
|
||
document.getElementById('profiler-report').innerHTML = '';
|
||
const results = [];
|
||
for (let ri = 0; ri < p.rules.length; ri++) {
|
||
const r = p.rules[ri];
|
||
prog.textContent = t('Checking') + ' ' + (ri + 1) + '/' + p.rules.length + ' — ' + r.path;
|
||
results.push(await profilerRunRule(r, source));
|
||
}
|
||
if (extraResults && extraResults.length) results.push(...extraResults);
|
||
prog.textContent = '';
|
||
profilerResults = results;
|
||
profilerResultsLabels = labels || null;
|
||
profilerRenderResultsView();
|
||
}
|
||
|
||
function profilerCheckSnapshotPick(i) {
|
||
if (!snapshots.length) {
|
||
alert(t('No snapshots defined.'));
|
||
return;
|
||
}
|
||
let html = '';
|
||
for (let si = 0; si < snapshots.length; si++) {
|
||
const s = snapshots[si];
|
||
html += '<button onclick="profilerCheckSnapshot(' + i + ',' + si + ')" class="w-full text-left px-3 py-2 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-100 dark:hover:bg-slate-700">' +
|
||
'<span class="font-semibold text-gray-800 dark:text-slate-200">' + esc(s.name) + '</span>' +
|
||
'<span class="block text-gray-500 dark:text-slate-400">' + esc(s.iccid || '') + ' · ' + esc(new Date(s.created).toLocaleString()) + ' · ' + (s.files ? s.files.length : 0) + ' ' + t('files') + '</span></button>';
|
||
}
|
||
document.getElementById('snapshot-pick-list').innerHTML = html;
|
||
document.getElementById('snapshot-pick-modal').classList.remove('hidden');
|
||
}
|
||
|
||
async function profilerCheckSnapshot(i, si) {
|
||
document.getElementById('snapshot-pick-modal').classList.add('hidden');
|
||
const p = profiles[i];
|
||
const s = snapshots[si];
|
||
await profilerRunProfile(p, profilerSnapshotSource(s), p.name + ' — ' + s.name);
|
||
}
|
||
|
||
function profilerMaskFidForFile(f, maskFids) {
|
||
if (!maskFids) return null;
|
||
const seg = (f.path || '').toUpperCase().split('/').pop();
|
||
if (maskFids.has(seg)) return seg;
|
||
for (const fid in PROFILER_MASK_PREFIX4_FIDS) {
|
||
if (maskFids.has(fid) && f.name && PROFILER_MASK_PREFIX4_FIDS[fid] === f.name) return fid;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function profilerRulesFromSnapshot(master, maskFids) {
|
||
const rules = [];
|
||
for (const f of (master.files || [])) {
|
||
const rule = {
|
||
type: 'file',
|
||
path: f.path,
|
||
name: f.name,
|
||
fileType: f.fileType,
|
||
fileSize: f.fileSize,
|
||
recordLen: f.recordLen,
|
||
numRecords: f.numRecords,
|
||
fciMode: 'exact',
|
||
fciHex: f.fciHex,
|
||
content: null,
|
||
};
|
||
if (f.content) {
|
||
if (f.content.kind === 'record') {
|
||
rule.content = { mode: 'exact', kind: 'record', records: f.content.records };
|
||
} else if (typeof f.content.data === 'string') {
|
||
rule.content = profilerMaskFidForFile(f, maskFids)
|
||
? { mode: 'mask', kind: 'transparent', expected: profilerMaskPrefix4(f.content.data) }
|
||
: { mode: 'exact', kind: 'transparent', expected: f.content.data };
|
||
}
|
||
}
|
||
rules.push(rule);
|
||
}
|
||
return rules;
|
||
}
|
||
|
||
function snapshotCompareOpen() {
|
||
if (!snapshots.length) {
|
||
alert(t('No snapshots defined.'));
|
||
return;
|
||
}
|
||
let opts = '';
|
||
for (let i = 0; i < snapshots.length; i++) {
|
||
opts += '<option value="' + i + '">' + esc(snapshots[i].name) + '</option>';
|
||
}
|
||
const master = document.getElementById('snapshot-compare-master');
|
||
const check = document.getElementById('snapshot-compare-check');
|
||
master.innerHTML = opts;
|
||
check.innerHTML = opts;
|
||
master.value = '0';
|
||
check.value = String(snapshots.length > 1 ? 1 : 0);
|
||
let mhtml = '';
|
||
for (const fid in PROFILER_MASK_PREFIX4_FIDS) {
|
||
mhtml += '<label class="flex items-center gap-2 text-sm">' +
|
||
'<input type="checkbox" data-mask-fid="' + fid + '" checked>' +
|
||
'<span>' + esc(t('Match first 4 bytes for')) + ' ' + esc(PROFILER_MASK_PREFIX4_FIDS[fid]) + '</span></label>';
|
||
}
|
||
document.getElementById('snapshot-compare-mask').innerHTML = mhtml;
|
||
document.getElementById('snapshot-compare-modal').classList.remove('hidden');
|
||
}
|
||
|
||
function profilerExtraFileResults(master, check) {
|
||
const masterPaths = new Set((master.files || []).map(f => (f.path || '').toUpperCase()));
|
||
return (check.files || []).filter(f => !masterPaths.has((f.path || '').toUpperCase())).map(f => ({
|
||
path: f.path,
|
||
name: f.name,
|
||
status: 'fail',
|
||
checks: [{ label: 'extra file', expected: 'absent', actual: 'present', ok: false }],
|
||
}));
|
||
}
|
||
|
||
async function snapshotCompareRun() {
|
||
const master = snapshots[+document.getElementById('snapshot-compare-master').value];
|
||
const check = snapshots[+document.getElementById('snapshot-compare-check').value];
|
||
if (!master || !check) return;
|
||
const maskFids = new Set();
|
||
document.querySelectorAll('#snapshot-compare-mask input[data-mask-fid]').forEach(cb => {
|
||
if (cb.checked) maskFids.add(cb.getAttribute('data-mask-fid'));
|
||
});
|
||
document.getElementById('snapshot-compare-modal').classList.add('hidden');
|
||
const pseudo = { name: master.name + ' → ' + check.name, rules: profilerRulesFromSnapshot(master, maskFids) };
|
||
await profilerRunProfile(pseudo, profilerSnapshotSource(check), pseudo.name, profilerExtraFileResults(master, check),
|
||
{ expected: master.name, actual: check.name });
|
||
}
|
||
|
||
function profilerToggleMismatchOnly(checked) {
|
||
profilerMismatchOnly = checked;
|
||
profilerRenderResultsView();
|
||
}
|
||
|
||
function profilerVisibleResults(results, mismatchOnly) {
|
||
return mismatchOnly ? results.filter(r => r.status !== 'pass') : results;
|
||
}
|
||
|
||
function profilerRenderResultsView() {
|
||
if (!profilerResults) return;
|
||
let passed = 0, failed = 0, errors = 0;
|
||
for (const res of profilerResults) {
|
||
if (res.status === 'pass') passed++;
|
||
else if (res.status === 'fail') failed++;
|
||
else errors++;
|
||
}
|
||
document.getElementById('profiler-summary').innerHTML = '<span class="text-emerald-600 font-semibold">' + passed + ' ' + t('passed') + '</span>' +
|
||
' · <span class="text-red-600 font-semibold">' + failed + ' ' + t('failed') + '</span>' +
|
||
' · <span class="text-yellow-600 font-semibold">' + errors + ' ' + t('errors') + '</span>';
|
||
const shown = profilerVisibleResults(profilerResults, profilerMismatchOnly);
|
||
document.getElementById('profiler-report').innerHTML = shown.length
|
||
? profilerRenderReport(shown, profilerResultsLabels)
|
||
: '<div class="text-sm text-emerald-600">' + esc(t('No mismatches')) + '</div>';
|
||
}
|
||
|
||
function profilerLiveSource() {
|
||
return {
|
||
select: path => pysimFetch('/api/select', { path: path }),
|
||
read: path => pysimFetch('/api/read', { path: path, mode: 'raw' }),
|
||
};
|
||
}
|
||
|
||
function profilerSnapshotSource(snapshot) {
|
||
const find = path => (snapshot.files || []).find(f => (f.path || '').toUpperCase() === (path || '').toUpperCase());
|
||
return {
|
||
select: async path => {
|
||
const f = find(path);
|
||
if (!f) return { exists: false };
|
||
return {
|
||
name: f.name,
|
||
file_type: f.fileType,
|
||
file_size: f.fileSize,
|
||
record_len: f.recordLen,
|
||
num_of_rec: f.numRecords,
|
||
fci_hex: f.fciHex,
|
||
exists: true,
|
||
};
|
||
},
|
||
read: async path => {
|
||
const f = find(path);
|
||
if (!f) return { success: false };
|
||
if (!f.content) return { success: false, sw: 'not-captured' };
|
||
if (f.content.kind === 'record') return { success: true, records: f.content.records };
|
||
return { success: true, data: f.content.data };
|
||
},
|
||
};
|
||
}
|
||
|
||
async function profilerRunRule(r, source) {
|
||
source = source || profilerLiveSource();
|
||
const res = { path: r.path, name: null, status: 'pass', checks: [] };
|
||
let sizeMismatch = false;
|
||
let sel;
|
||
try {
|
||
sel = await source.select(r.path);
|
||
} catch (e) {
|
||
res.status = 'error';
|
||
res.error = e.message;
|
||
return res;
|
||
}
|
||
if (sel && sel.name) res.name = sel.name;
|
||
if (!sel || sel.exists !== true) {
|
||
res.status = 'fail';
|
||
res.checks.push({ label: 'exists', expected: true, actual: false, ok: false });
|
||
return res;
|
||
}
|
||
res.checks.push({ label: 'exists', expected: true, actual: true, ok: true });
|
||
if (r.fileType) {
|
||
const ok = sel.file_type === r.fileType;
|
||
if (!ok) res.status = 'fail';
|
||
res.checks.push({ label: 'fileType', expected: r.fileType, actual: sel.file_type, ok: ok });
|
||
}
|
||
const isRecord = profilerContentKindForFileType(r.fileType) === 'record';
|
||
const fciMode = r.fciMode || 'type_size';
|
||
if (fciMode !== 'type') {
|
||
if (!isRecord && r.fileSize !== null && r.fileSize !== undefined) {
|
||
const ok = sel.file_size === r.fileSize;
|
||
if (!ok) { res.status = 'fail'; sizeMismatch = true; }
|
||
res.checks.push({ label: 'fileSize', expected: r.fileSize, actual: sel.file_size, ok: ok });
|
||
}
|
||
if (r.recordLen !== null && r.recordLen !== undefined) {
|
||
const ok = sel.record_len === r.recordLen;
|
||
if (!ok) { res.status = 'fail'; sizeMismatch = true; }
|
||
res.checks.push({ label: 'recordLen', expected: r.recordLen, actual: sel.record_len, ok: ok });
|
||
}
|
||
if (r.numRecords !== null && r.numRecords !== undefined) {
|
||
const ok = sel.num_of_rec === r.numRecords;
|
||
if (!ok) { res.status = 'fail'; sizeMismatch = true; }
|
||
res.checks.push({ label: 'numRecords', expected: r.numRecords, actual: sel.num_of_rec, ok: ok });
|
||
}
|
||
}
|
||
if (fciMode === 'exact' && r.fciHex) {
|
||
const expected = profilerNormHexStrict(r.fciHex);
|
||
const actual = profilerNormHexStrict(sel.fci_hex);
|
||
const ok = !!expected && expected === actual;
|
||
if (!ok) res.status = 'fail';
|
||
res.checks.push({ label: 'fci', expected: r.fciHex, actual: sel.fci_hex || '', ok: ok });
|
||
}
|
||
if (r.content) {
|
||
let rd;
|
||
try {
|
||
rd = await source.read(r.path);
|
||
} catch (e) {
|
||
res.status = 'error';
|
||
res.error = e.message;
|
||
return res;
|
||
}
|
||
if (!rd || !rd.success) {
|
||
if (rd && rd.sw === 'not-captured') {
|
||
res.status = 'error';
|
||
res.error = t('Content not captured in snapshot');
|
||
res.checks.push({ label: 'content', expected: 'captured', actual: t('not captured in snapshot'), ok: false });
|
||
} else {
|
||
res.status = 'fail';
|
||
res.checks.push({ label: 'content', expected: 'readable', actual: 'read failed (' + (rd && rd.sw ? rd.sw : '?') + ')', ok: false });
|
||
}
|
||
} else if (r.content.kind === 'record') {
|
||
const exp = r.content.records || [];
|
||
const act = rd.records || [];
|
||
const n = Math.min(exp.length, act.length);
|
||
let recMismatch = exp.length !== act.length;
|
||
if (exp.length !== act.length) {
|
||
res.status = 'fail';
|
||
const numRec = res.checks.find(c => c.label === 'numRecords');
|
||
if (!numRec || numRec.ok) {
|
||
res.checks.push({ label: 'content.records', expected: exp.length + ' records', actual: act.length + ' records', ok: false });
|
||
}
|
||
}
|
||
const matched = [];
|
||
for (let j = 0; j < n; j++) {
|
||
const dataOk = sizeMismatch ? profilerMatchMin(r.content.mode, exp[j].data, act[j].data) : profilerMatch(r.content.mode, exp[j].data, act[j].data);
|
||
const ok = exp[j].num === act[j].num && dataOk;
|
||
if (!ok) { res.status = 'fail'; recMismatch = true; } else matched.push(exp[j].num);
|
||
res.checks.push({ label: 'content.rec' + exp[j].num, expected: exp[j].data, actual: act[j].data, ok: ok });
|
||
}
|
||
if (matched.length) res.recordsMatched = matched;
|
||
if (recMismatch) res.recordsMismatch = true;
|
||
} else {
|
||
const ok = sizeMismatch ? profilerMatchMin(r.content.mode, r.content.expected, rd.data) : profilerMatch(r.content.mode, r.content.expected, rd.data);
|
||
if (!ok) res.status = 'fail';
|
||
res.checks.push({ label: 'content', expected: r.content.expected, actual: rd.data, ok: ok });
|
||
}
|
||
}
|
||
return res;
|
||
}
|
||
|
||
function profilerCustomNameForPath(path) {
|
||
if (!path) return null;
|
||
const norm = path.toUpperCase();
|
||
for (const c of pysimCustomFiles) {
|
||
const parts = c.path.split('/').filter(Boolean);
|
||
if (!parts.length) continue;
|
||
let segs;
|
||
if (parts[0].toUpperCase() === '3F00') segs = ['MF'].concat(parts.slice(1));
|
||
else segs = parts;
|
||
if (segs.join('/').toUpperCase() === norm) return c.name;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Group a rule's result checks into display aspects, derived from the checks
|
||
// that actually ran. 'Exact FCI' subsumes filetype/size/records; 'exists' is
|
||
// implicit and omitted.
|
||
function profilerResultAspects(res) {
|
||
const groups = { filetype: [], size: [], records: [], 'Exact FCI': [], contents: [] };
|
||
for (const c of (res.checks || [])) {
|
||
const l = c.label;
|
||
if (l === 'fileType') groups.filetype.push(c);
|
||
else if (l === 'fileSize') groups.size.push(c);
|
||
else if (l === 'recordLen' || l === 'numRecords') groups.records.push(c);
|
||
else if (l === 'fci') groups['Exact FCI'].push(c);
|
||
else if (l === 'content' || l.startsWith('content.')) groups.contents.push(c);
|
||
}
|
||
const out = [];
|
||
if (groups['Exact FCI'].length) {
|
||
out.push({ key: 'Exact FCI', ok: groups['Exact FCI'].every(c => c.ok) });
|
||
} else {
|
||
if (groups.filetype.length) out.push({ key: 'filetype', ok: groups.filetype.every(c => c.ok) });
|
||
if (groups.size.length) out.push({ key: 'size', ok: groups.size.every(c => c.ok) });
|
||
if (groups.records.length) out.push({ key: 'records', ok: groups.records.every(c => c.ok) });
|
||
}
|
||
if (groups.contents.length) out.push({ key: 'contents', ok: groups.contents.every(c => c.ok) });
|
||
return out;
|
||
}
|
||
|
||
function profilerAspectSummary(aspects, marked, tr) {
|
||
tr = tr || (s => s);
|
||
if (!aspects || !aspects.length) return '';
|
||
if (marked) return aspects.map(a => tr(a.key) + ' ' + (a.ok ? '✓' : '✗')).join(', ');
|
||
const has = k => aspects.some(a => a.key === k);
|
||
let fcp = '';
|
||
if (has('Exact FCI')) fcp = tr('Exact FCI');
|
||
else if (has('filetype') && has('records')) fcp = tr('filetype and records');
|
||
else if (has('filetype') && has('size')) fcp = tr('filetype and size');
|
||
else if (has('filetype')) fcp = tr('filetype');
|
||
else if (has('records')) fcp = tr('records');
|
||
else if (has('size')) fcp = tr('size');
|
||
const parts = [];
|
||
if (fcp) parts.push(fcp);
|
||
if (has('contents')) parts.push(tr('contents'));
|
||
return parts.join(', ');
|
||
}
|
||
|
||
// Compress a list of record numbers into ranges: [1,2,3,5,7] -> "1-3, 5, 7".
|
||
function profilerNumRanges(nums) {
|
||
if (!nums || !nums.length) return '';
|
||
const sorted = nums.slice().sort((a, b) => a - b);
|
||
const parts = [];
|
||
let start = null, prev = null;
|
||
for (const n of sorted) {
|
||
if (start === null) { start = prev = n; continue; }
|
||
if (n === prev + 1) { prev = n; continue; }
|
||
parts.push(start === prev ? String(start) : start + '-' + prev);
|
||
start = prev = n;
|
||
}
|
||
if (start !== null) parts.push(start === prev ? String(start) : start + '-' + prev);
|
||
return parts.join(', ');
|
||
}
|
||
|
||
// Checks whose expected/actual are raw data (hex), rendered as aligned fields.
|
||
function profilerRawDataCheck(c) {
|
||
if (c.label === 'fci') return true;
|
||
if (/^content\.rec\d+$/.test(c.label)) return true;
|
||
if (c.label === 'content') return c.expected !== 'readable';
|
||
return false;
|
||
}
|
||
|
||
// ===== FCP/FCI decoding (ISO 7816-4 5.3.3 tables 12-14, TS 102 221 11.1.1.4) =====
|
||
|
||
function fcpInt(hex) { return hex ? parseInt(hex, 16) : 0; }
|
||
|
||
function fcpFileDescriptor(v) {
|
||
if (!v) return '';
|
||
const fd = parseInt(v.substr(0, 2), 16);
|
||
const parts = [(fd & 0x80) ? 'shareable' : 'not shareable'];
|
||
const cat = (fd >> 3) & 0x07;
|
||
const st = fd & 0x07;
|
||
if (cat === 0x07) {
|
||
parts.push(st === 0x01 ? 'BER-TLV EF' : st === 0x02 ? 'SIMPLE-TLV EF' : st === 0x00 ? 'DF/ADF' : 'proprietary file (' + v.substr(0, 2) + ')');
|
||
} else {
|
||
parts.push(cat === 0 ? 'working EF' : cat === 1 ? 'internal EF' : 'proprietary EF (' + cat + ')');
|
||
parts.push(['no info', 'transparent', 'linear fixed', 'linear fixed TLV', 'linear variable', 'linear variable TLV', 'cyclic', 'cyclic TLV'][st] || ('structure ' + st));
|
||
}
|
||
if (v.length >= 4) parts.push('data coding ' + v.substr(2, 2));
|
||
if (v.length >= 10) parts.push('record length ' + fcpInt(v.substr(4, 4)) + ', ' + fcpInt(v.substr(8, 2)) + ' records');
|
||
else if (v.length === 8) parts.push('record length ' + fcpInt(v.substr(4, 4)));
|
||
else if (v.length === 6) parts.push('record length ' + fcpInt(v.substr(4, 2)));
|
||
return parts.join(', ');
|
||
}
|
||
|
||
// ISO 7816-4 table 13 / TS 102 221 table 11.7b.
|
||
function fcpLifeCycle(v) {
|
||
if (v.length !== 2) return v;
|
||
const b = parseInt(v, 16);
|
||
if (b === 0x00) return 'no information';
|
||
if (b === 0x01) return 'creation';
|
||
if (b === 0x03) return 'initialization';
|
||
if ((b & 0x0C) === 0x0C) return 'termination';
|
||
if ((b & 0x05) === 0x05) return 'operational, activated';
|
||
if ((b & 0x05) === 0x04) return 'operational, deactivated';
|
||
return 'proprietary (' + v + ')';
|
||
}
|
||
|
||
// TS 102 221 11.1.1.4.8: SFI in b8-b4, b3-b1 zero; length 0 = no short id.
|
||
function fcpSfi(v) {
|
||
if (!v) return 'not supported';
|
||
if (v.length === 2 && (parseInt(v, 16) & 0x07) === 0) return String(parseInt(v, 16) >> 3);
|
||
return v;
|
||
}
|
||
|
||
function fcpDo(key, value) {
|
||
if (/^[0-9A-F]+$/.test(value) === false) value = value || '';
|
||
switch (key) {
|
||
case '80': return { name: 'File size', decoded: fcpInt(value) + ' bytes' };
|
||
case '81': return { name: 'Total file size', decoded: fcpInt(value) + ' bytes' };
|
||
case '82': return { name: 'File descriptor', decoded: fcpFileDescriptor(value) };
|
||
case '83': return { name: 'File identifier', decoded: value };
|
||
case '84': return { name: 'DF name (AID)', decoded: value };
|
||
case '85': return { name: 'Proprietary information', decoded: value };
|
||
case '86': return { name: 'Security attribute (proprietary)', decoded: value };
|
||
case '87': return { name: 'FCI extension EF', decoded: value };
|
||
case '88': return { name: 'Short file identifier', decoded: fcpSfi(value) };
|
||
case '8A': return { name: 'Life cycle status', decoded: fcpLifeCycle(value) };
|
||
case '8B': return { name: 'Security attributes (referenced)', decoded: value.length >= 6 ? 'EF_ARR ' + value.substr(0, 4) + ', record ' + fcpInt(value.substr(4, 2)) : value };
|
||
case '8C': return { name: 'Security attributes (compact)', decoded: value };
|
||
case '8D': return { name: 'Security env. template EF', decoded: value };
|
||
case '8E': return { name: 'Channel security attribute', decoded: value };
|
||
case 'A0': return { name: 'Security attributes (data objects)', decoded: value };
|
||
case 'A5': return { name: 'Proprietary information', decoded: null };
|
||
case 'AB': return { name: 'Security attributes (expanded)', decoded: value };
|
||
case 'C6': return { name: 'PIN status template DO', decoded: null };
|
||
case 'A5/80': {
|
||
const b = fcpInt(value);
|
||
const parts = [(b & 0x01) ? 'clock stop allowed' : 'clock stop not allowed'];
|
||
const cls = [];
|
||
if (b & 0x10) cls.push('A');
|
||
if (b & 0x20) cls.push('B');
|
||
if (b & 0x40) cls.push('C');
|
||
if (b & 0x80) cls.push('D');
|
||
if (cls.length) parts.push('class ' + cls.join('/'));
|
||
return { name: 'UICC characteristics', decoded: parts.join(', ') };
|
||
}
|
||
case 'A5/81': {
|
||
if (value.length < 6) return { name: 'Application power consumption', decoded: value };
|
||
const vc = fcpInt(value.substr(0, 2));
|
||
const cls = [];
|
||
if (vc & 0x01) cls.push('A');
|
||
if (vc & 0x02) cls.push('B');
|
||
if (vc & 0x04) cls.push('C');
|
||
if (vc & 0x08) cls.push('D');
|
||
if (vc & 0x10) cls.push('E');
|
||
const ma = fcpInt(value.substr(2, 2)) & 0x3F;
|
||
const f = fcpInt(value.substr(4, 2));
|
||
const parts = [];
|
||
if (cls.length) parts.push('class ' + cls.join('/'));
|
||
if (ma) parts.push(ma + ' mA');
|
||
if (f === 0xFF) parts.push('no reference frequency');
|
||
else if (f) parts.push((f / 10) + ' MHz');
|
||
return { name: 'Application power consumption', decoded: parts.join(', ') };
|
||
}
|
||
case 'A5/82': {
|
||
const f = fcpInt(value);
|
||
return { name: 'Minimum application clock frequency', decoded: f === 0xFF ? 'none' : (f / 10) + ' MHz' };
|
||
}
|
||
case 'A5/83': return { name: 'Available memory', decoded: fcpInt(value) + ' bytes' };
|
||
case 'A5/84': return { name: 'File details', decoded: (fcpInt(value) & 0x01) ? 'DER coding only' : 'no details' };
|
||
case 'A5/85': return { name: 'Reserved file size', decoded: fcpInt(value) + ' bytes' };
|
||
case 'A5/86': return { name: 'Maximum file size', decoded: fcpInt(value) + ' bytes' };
|
||
case 'A5/87': return { name: 'Supported system commands', decoded: (fcpInt(value) & 0x01) ? 'TERMINAL CAPABILITY' : 'none' };
|
||
case 'A5/88': {
|
||
const b = fcpInt(value);
|
||
const tc = ['standard temperature range', 'temperature class A', 'temperature class B', 'temperature class C'][b & 0x07] || 'RFU';
|
||
return { name: 'Specific UICC environmental conditions', decoded: tc + ((b & 0x08) ? ', high humidity' : '') };
|
||
}
|
||
case 'A5/89': return { name: 'Platform-to-platform CAT secured APDU', decoded: value };
|
||
case 'A5/8A': return { name: 'Test configuration state', decoded: (fcpInt(value) & 0x01) ? 'active' : 'inactive' };
|
||
case 'A5/8B': return { name: 'Test configuration criterion type', decoded: value };
|
||
case 'A5/C0': return { name: 'Special file info', decoded: value };
|
||
case 'A5/C1': return { name: 'Filling pattern', decoded: value };
|
||
case 'A5/C2': return { name: 'Repeat pattern', decoded: value };
|
||
case 'C6/90': return { name: 'PS_DO', decoded: value };
|
||
case 'C6/95': return { name: 'Usage qualifier', decoded: value };
|
||
case 'C6/83': return { name: 'Key reference', decoded: value };
|
||
default: return { name: 'Unknown TLV ' + key, decoded: value };
|
||
}
|
||
}
|
||
|
||
// Decode a SELECT response (FCI): accepts the FCP template '62', an FCI template
|
||
// '6F' wrapping FCP/FMD, or bare FCP content. Returns { ok, template, items }.
|
||
// Partial-aware BER-TLV walk: parses as many complete TLVs as possible and
|
||
// reports where/why it stopped (truncated header/length/value, trailing data).
|
||
function fcpParseTlvs(hex) {
|
||
const tlvs = [];
|
||
let i = 0;
|
||
let error = '';
|
||
while (i < hex.length) {
|
||
const remaining = hex.length - i;
|
||
if (remaining < 2) { error = 'Truncated at offset ' + (i / 2) + ': incomplete tag'; break; }
|
||
const tag = hex.substr(i, 2);
|
||
let tagBytes = 2;
|
||
if ((parseInt(tag, 16) & 0x1F) === 0x1F) {
|
||
tagBytes = 4;
|
||
if (remaining < 4) { error = 'Truncated at offset ' + (i / 2) + ': incomplete 2-byte tag'; break; }
|
||
}
|
||
if (remaining < tagBytes + 2) { error = 'Truncated at offset ' + (i / 2) + ': incomplete TLV header'; break; }
|
||
const lb = parseInt(hex.substr(i + tagBytes, 2), 16);
|
||
const nLen = lb < 128 ? 0 : (lb & 0x7F);
|
||
if (i + tagBytes + 2 + nLen * 2 > hex.length) { error = 'Truncated length field at offset ' + ((i + tagBytes) / 2); break; }
|
||
const r = parseBerLen(hex, i + tagBytes);
|
||
if (!(r.len >= 0)) { error = 'Invalid length form at offset ' + ((i + tagBytes) / 2); break; }
|
||
const consumed = tagBytes + r.consumed;
|
||
const total = consumed + r.len * 2;
|
||
if (remaining < total) {
|
||
error = 'TLV ' + tag + ' declares ' + r.len + ' bytes, only ' + Math.floor(Math.max(remaining - consumed, 0) / 2) + ' available';
|
||
break;
|
||
}
|
||
tlvs.push({ tag, length: r.len, value: hex.substr(i + consumed, r.len * 2), raw: hex.substr(i, total) });
|
||
i += total;
|
||
}
|
||
return { tlvs, error, consumed: i };
|
||
}
|
||
|
||
function fcpDecode(hex) {
|
||
const h = (hex || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (!h) return { ok: false, template: null, items: [], error: '' };
|
||
let template = null;
|
||
let body = h;
|
||
let error = '';
|
||
const first = h.substr(0, 2);
|
||
if ((first === '62' || first === '64' || first === '6F') && h.length >= 4) {
|
||
const lb = parseInt(h.substr(2, 2), 16);
|
||
const nLen = lb < 128 ? 0 : (lb & 0x7F);
|
||
if (4 + nLen * 2 > h.length) {
|
||
error = 'TLV ' + first + ' has a truncated length field';
|
||
body = '';
|
||
} else {
|
||
const r = parseBerLen(h, 2);
|
||
const consumed = 2 + r.consumed;
|
||
const avail = h.length - consumed;
|
||
template = first;
|
||
body = h.substr(consumed, Math.min(r.len * 2, Math.max(avail, 0)));
|
||
if (r.len * 2 > avail) error = 'TLV ' + first + ' declares ' + r.len + ' bytes, only ' + Math.floor(Math.max(avail, 0) / 2) + ' available';
|
||
else if (consumed + r.len * 2 < h.length) error = 'Trailing data after TLV ' + first;
|
||
}
|
||
if (template === '6F' && body) {
|
||
const inner = fcpParseTlvs(body);
|
||
const fcp = inner.tlvs.find(t => t.tag === '62');
|
||
if (fcp) {
|
||
template = '62';
|
||
body = fcp.value;
|
||
if (!error && inner.error) error = inner.error;
|
||
}
|
||
}
|
||
}
|
||
const parsed = fcpParseTlvs(body);
|
||
if (!error && parsed.error) error = parsed.error;
|
||
const items = [];
|
||
for (const t of parsed.tlvs) {
|
||
const d = fcpDo(t.tag, t.value);
|
||
items.push({ key: t.tag, tag: t.tag, name: d.name, value: t.value, decoded: d.decoded });
|
||
if (t.tag === 'A5' || t.tag === 'C6') {
|
||
const sub = fcpParseTlvs(t.value);
|
||
if (!error && sub.error) error = 'In ' + t.tag + ': ' + sub.error;
|
||
for (const s of sub.tlvs) {
|
||
const sd = fcpDo(t.tag + '/' + s.tag, s.value);
|
||
items.push({ key: t.tag + '/' + s.tag, tag: s.tag, name: sd.name, value: s.value, decoded: sd.decoded });
|
||
}
|
||
}
|
||
}
|
||
return { ok: !error && items.length > 0, template, items, error };
|
||
}
|
||
|
||
function fcpDiffHtml(expectedHex, actualHex, labels) {
|
||
const expLabel = (labels && labels.expected) ? labels.expected : t('expected');
|
||
const actLabel = (labels && labels.actual) ? labels.actual : t('actual');
|
||
const e = fcpDecode(expectedHex);
|
||
const a = fcpDecode(actualHex);
|
||
const eBad = !!e.error, aBad = !!a.error;
|
||
if (!eBad && !aBad && (!e.items.length || !a.items.length)) return '';
|
||
const eMap = {}, aMap = {};
|
||
e.items.forEach(it => { eMap[it.key] = it; });
|
||
a.items.forEach(it => { aMap[it.key] = it; });
|
||
const keys = e.items.map(it => it.key);
|
||
for (const it of a.items) if (!eMap[it.key]) keys.push(it.key);
|
||
let html = '';
|
||
if (keys.length) {
|
||
html += '<div class="mt-1 text-xs text-gray-500 dark:text-slate-400">' + esc(t('FCI parameters')) + '</div>';
|
||
html += '<table class="w-full text-xs border-collapse">';
|
||
html += '<thead><tr class="border-b border-gray-200 dark:border-slate-700">' +
|
||
'<th class="text-left py-0.5 px-1 font-medium text-gray-500 dark:text-slate-400">' + esc(t('Parameter')) + '</th>' +
|
||
'<th class="text-left py-0.5 px-1 font-medium text-gray-500 dark:text-slate-400">' + esc(expLabel) + '</th>' +
|
||
'<th class="text-left py-0.5 px-1 font-medium text-gray-500 dark:text-slate-400">' + esc(actLabel) + '</th></tr></thead><tbody>';
|
||
for (const k of keys) {
|
||
const ev = eMap[k], av = aMap[k];
|
||
const ed = ev ? (ev.decoded === null ? '' : (ev.decoded || ev.value)) : '—';
|
||
const ad = av ? (av.decoded === null ? '' : (av.decoded || av.value)) : '—';
|
||
const eq = !!ev && !!av && ev.decoded === av.decoded && ev.value === av.value;
|
||
const color = eq ? 'text-gray-500 dark:text-slate-400' : 'text-red-600 font-medium';
|
||
html += '<tr class="border-b border-gray-100 dark:border-slate-800">' +
|
||
'<td class="py-0.5 px-1 ' + color + '">' + esc((ev || av).name) + '</td>' +
|
||
'<td class="py-0.5 px-1 font-mono break-all ' + color + '">' + esc(ed) + '</td>' +
|
||
'<td class="py-0.5 px-1 font-mono break-all ' + color + '">' + esc(ad) + '</td></tr>';
|
||
}
|
||
html += '</tbody></table>';
|
||
}
|
||
if (eBad) html += '<div class="text-xs text-red-600 mt-0.5 pl-2">' + esc(t('expected')) + ': ' + esc(e.error) + '</div>';
|
||
if (aBad) html += '<div class="text-xs text-red-600 mt-0.5 pl-2">' + esc(t('actual')) + ': ' + esc(a.error) + '</div>';
|
||
return html;
|
||
}
|
||
|
||
function profilerFciPreviewItems(hex) {
|
||
const d = fcpDecode(hex);
|
||
if (!d.items.length && !d.error) return '';
|
||
let html = '';
|
||
for (const it of d.items) {
|
||
if (it.decoded === null) continue;
|
||
html += '<div>' + esc(it.name) + ': <span class="text-gray-800 dark:text-slate-200">' + esc(it.decoded || it.value) + '</span></div>';
|
||
}
|
||
if (d.error) {
|
||
html += '<div class="text-red-600">' + esc(t('Decode failed')) + ': ' + esc(d.error) + '</div>';
|
||
}
|
||
return html;
|
||
}
|
||
|
||
function profilerUpdateFciPreview(i, value) {
|
||
const el = document.getElementById('profiler-fci-preview-' + i);
|
||
if (el) el.innerHTML = profilerFciPreviewItems(value);
|
||
}
|
||
|
||
function profilerFciInput(i, value) {
|
||
profilerUpdateRule(i, 'fciHex', value);
|
||
profilerUpdateFciPreview(i, value);
|
||
}
|
||
|
||
function profilerRenderReport(results, labels) {
|
||
const expLabel = (labels && labels.expected) ? labels.expected : t('expected');
|
||
const actLabel = (labels && labels.actual) ? labels.actual : t('actual');
|
||
const labelCls = 'text-xs text-gray-500 dark:text-slate-400 text-right shrink-0 ' + (labels ? 'w-40 break-all' : 'w-24');
|
||
let html = '';
|
||
for (const r of results) {
|
||
const color = r.status === 'pass' ? 'text-emerald-600' : (r.status === 'fail' ? 'text-red-600' : 'text-yellow-600');
|
||
const icon = r.status === 'pass' ? '✓' : (r.status === 'fail' ? '✗' : '⚠');
|
||
const name = profilerCustomNameForPath(r.path) || r.name;
|
||
const title = name
|
||
? '<span>' + esc(name) + '</span> <span class="text-gray-400 dark:text-slate-500">(' + esc(r.path) + ')</span>'
|
||
: '<span>' + esc(r.path) + '</span>';
|
||
const aspects = profilerResultAspects(r);
|
||
const marked = aspects.some(a => !a.ok);
|
||
const summary = profilerAspectSummary(aspects, marked, t);
|
||
html += '<div class="mb-2 p-2 border border-gray-200 dark:border-slate-600 rounded">';
|
||
html += '<div class="flex items-center gap-2"><span class="' + color + ' font-bold">' + icon + '</span><span class="font-mono text-sm">' + title + '</span>' +
|
||
(summary ? '<span class="text-xs text-gray-500 dark:text-slate-400">: ' + esc(summary) + '</span>' : '') + '</div>';
|
||
if (r.error) html += '<div class="text-xs text-yellow-600 mt-1">' + esc(r.error) + '</div>';
|
||
for (const c of r.checks) {
|
||
if (c.ok) continue;
|
||
if (c.label === 'extra file') {
|
||
html += '<div class="text-xs text-red-600 mt-1">' + esc(t('extra file')) + '</div>';
|
||
} else if (profilerRawDataCheck(c)) {
|
||
html += '<div class="mt-1">';
|
||
html += '<div class="text-xs text-red-600 font-mono pl-2">' + esc(c.label) + '</div>';
|
||
html += '<div class="flex items-center gap-2 mt-0.5 pl-2">' +
|
||
'<span class="' + labelCls + '">' + esc(expLabel) + '</span>' +
|
||
'<input readonly title="' + escHtml(String(c.expected)) + '" value="' + escHtml(String(c.expected)) + '" class="flex-1 min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800"></div>';
|
||
html += '<div class="flex items-center gap-2 mt-0.5 pl-2">' +
|
||
'<span class="' + labelCls + '">' + esc(actLabel) + '</span>' +
|
||
'<input readonly title="' + escHtml(String(c.actual)) + '" value="' + escHtml(String(c.actual)) + '" class="flex-1 min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800"></div>';
|
||
if (c.label === 'fci') html += fcpDiffHtml(String(c.expected), String(c.actual), labels);
|
||
html += '</div>';
|
||
} else {
|
||
html += '<div class="text-xs text-red-600 mt-1">' + esc(c.label) + ': ' + esc(expLabel) + ' <b>' + esc(String(c.expected)) + '</b>, ' + esc(actLabel) + ' <b>' + esc(String(c.actual)) + '</b></div>';
|
||
}
|
||
}
|
||
const recFailed = r.recordsMismatch || (r.checks || []).some(c => /^content\.rec\d+$/.test(c.label) && !c.ok);
|
||
if (recFailed && r.recordsMatched && r.recordsMatched.length) {
|
||
html += '<div class="text-xs text-emerald-600 mt-1">' + esc(t('matching records')) + ': ' + esc(profilerNumRanges(r.recordsMatched)) + '</div>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
return html;
|
||
}
|
||
|
||
profilerLoad();
|
||
snapshotsLoad();
|
||
|
||
// Init sub-tab pills
|
||
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 Expanded Script APDU': 'Сгенерировать Expanded Script APDU',
|
||
'Generate Secure Packet': 'Сгенерировать Secure Packet',
|
||
'Conversion': 'Конвертация',
|
||
'Command': 'Команда',
|
||
'Response': 'Ответ',
|
||
'Result': 'Результат',
|
||
'No response yet': 'Ответа пока нет',
|
||
'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': 'Привилегии',
|
||
'PIN value': 'Значение PIN',
|
||
'Old PIN': 'Старый PIN',
|
||
'New PIN': 'Новый PIN',
|
||
'Application / Instance AID (hex)': 'AID приложения / экземпляра (hex)',
|
||
'Load File AID / Package AID (hex)': 'AID Load File / пакета (hex)',
|
||
'Executable Module AID / Applet Class AID (hex)': 'AID исполняемого модуля / класса апплета (hex)',
|
||
'Security level (P1)': 'Уровень безопасности (P1)',
|
||
'Target': 'Цель',
|
||
'Current EF (case 1)': 'Текущий EF (case 1)',
|
||
'By FID': 'По FID',
|
||
'Path from MF': 'Путь от MF',
|
||
'Path from current DF': 'Путь от текущего DF',
|
||
'Unblock PIN': 'Разблокировка PIN',
|
||
'Byte 1': 'Байт 1',
|
||
'Byte 2': 'Байт 2',
|
||
'SIM/UICC Toolkit parameters': 'Параметры SIM/UICC Toolkit',
|
||
'Toolkit mode': 'Режим Toolkit',
|
||
'Max services': 'Максимум сервисов',
|
||
'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',
|
||
'Response parser': 'Парсер ответов',
|
||
'C-APDU Parser': 'Разбор C-APDU',
|
||
'Cards': 'Карты',
|
||
'Card reader': 'Картридер',
|
||
'Card presets are stored locally in your browser. JSON export/import buttons are below the card list.': 'Данные карт хранятся локально в браузере. Кнопки экспорта/импорта JSON — под списком карт.',
|
||
'Add': 'Добавить',
|
||
'Export as JSON': 'Экспорт в JSON',
|
||
'Export to file': 'Экспорт в файл',
|
||
'Import from file': 'Импорт из файла',
|
||
'Paste & import': 'Вставить и импортировать',
|
||
'Import JSON from clipboard': 'Импорт JSON из буфера',
|
||
'Name': 'Имя',
|
||
'ICCID': 'ICCID',
|
||
'Cntr': 'Сч',
|
||
'Remove': 'Удалить',
|
||
'Delete': 'Удалить',
|
||
'Delete All': 'Удалить все',
|
||
'ISD (Issuer Security Domain)': 'ISD (домен безопасности эмитента)',
|
||
'Applications / Applet Instances': 'Приложения / экземпляры апплетов',
|
||
'Executable Load Files (ELFs) / Packages': 'Исполняемые файлы загрузки (ELF) / пакеты',
|
||
'Application / Instance AID:': 'AID приложения / экземпляра:',
|
||
'Load File AID / Package AID:': 'AID Load File / пакета:',
|
||
'Executable Module AIDs / Applet Class AIDs:': 'AID исполняемых модулей / классов апплетов:',
|
||
'No cards defined.': 'Карты не заданы.',
|
||
'Card presets': 'Выбор карты',
|
||
'Card preset': 'Пресет карты',
|
||
'— Select card —': '— Выберите карту —',
|
||
'Operation': 'Операция',
|
||
'Explore Card (all GP data)': 'Обзор карты (все данные GP)',
|
||
'Install Package (.cap file)': 'Установка пакета (.cap файл)',
|
||
'Server URL': 'URL сервера',
|
||
'Connect': 'Подключиться',
|
||
'To access local card reader, you need pysim-otaman-server running locally.': 'Для доступа к локальному картридеру необходим запущенный pysim-otaman-server.',
|
||
'To connect, install': 'Для подключения установите',
|
||
'and start it.': 'и запустите его.',
|
||
'Refer to pysim-otaman-server README files for details.': 'Подробности — в README файлах pysim-otaman-server.',
|
||
'Click': 'Нажмите кнопку',
|
||
'when pysim-otaman-server is ready': 'когда pysim-otaman-server будет готов',
|
||
'Equip card': 'Подключить карту',
|
||
'Check status': 'Проверить статус',
|
||
'Reset card': 'Сброс карты',
|
||
'Disconnect from server': 'Отключиться от сервера',
|
||
'File manager': 'Файловый менеджер',
|
||
'Custom files': 'Пользовательские файлы',
|
||
'pySim command line': 'Командная строка pySim',
|
||
'Raw APDU': 'Отправка APDU',
|
||
'Read': 'Прочитать',
|
||
'Save': 'Сохранить',
|
||
'Raw': 'Данные как на карте',
|
||
'Decoded': 'Декодированные данные',
|
||
'Edit': 'Редактировать',
|
||
'Execute': 'Выполнить',
|
||
'Send': 'Отправить',
|
||
'Add known file paths and aliases to the file tree.': 'Добавьте пути и псевдонимы файлов в дерево файлов.',
|
||
'File:': 'Файл:',
|
||
'Cancel': 'Отмена',
|
||
'Timeout': 'Таймаут',
|
||
'OK': 'OK',
|
||
'Exit': 'Выход',
|
||
'← Back': '← Назад',
|
||
'Connecting...': 'Подключение...',
|
||
'Checking card...': 'Проверка карты...',
|
||
'Connected': 'Подключено',
|
||
'Connection failed:': 'Ошибка подключения:',
|
||
'pysim-blocked-hint': 'Браузер, вероятно, заблокировал доступ к локальному серверу карт. Разрешите доступ к локальной сети для этого сайта (Chrome/Edge/Vivaldi: Настройки сайта → Доступ к локальной сети → Разрешить). Также проверьте, что pysim-otaman-server запущен по адресу {url}.',
|
||
'pysim-unreachable-hint': 'Не удалось подключиться к серверу карт. Проверьте, что pysim-otaman-server запущен и адрес указан верно ({url}). Если эта страница открыта по HTTPS, также разрешите доступ к локальной сети в браузере (Chrome/Edge/Vivaldi: Настройки сайта → Доступ к локальной сети → Разрешить).',
|
||
'No card detected. Insert card and click Equip card button': 'Карта не обнаружена. Вставьте карту и нажмите Подключить карту',
|
||
'Card inserted — press Equip': 'Карта вставлена — нажмите «Подключить карту»',
|
||
'Card inserted — initializing...': 'Карта вставлена — инициализация...',
|
||
'Checking...': 'Проверка...',
|
||
'Resetting...': 'Сброс...',
|
||
'Equipping...': 'Подключение...',
|
||
'Sending OTA...': 'Отправка OTA...',
|
||
'Verifying against pySim reference...': 'Сверка с эталоном pySim...',
|
||
'Verify vs pySim': 'Проверить в pySim',
|
||
'Send to Card': 'Отправить на карту',
|
||
'Phone simulator': 'Симулятор телефона',
|
||
'No server connection': 'Нет соединения с сервером',
|
||
'Server connected, no card equipped': 'Сервер подключён, карта не подключена',
|
||
'Card equipped': 'Карта подключена',
|
||
'Connect to server': 'Подключитесь к серверу',
|
||
'Insert and equip a card': 'Вставьте и подключите карту',
|
||
'Files': 'Файлов',
|
||
'Records': 'Записей',
|
||
'Scan time': 'Время сканирования',
|
||
'scanned in': 'за',
|
||
'sec': 'сек',
|
||
'No timing data': 'Нет данных о времени',
|
||
'min': 'мин',
|
||
'avg': 'сред',
|
||
'max': 'макс',
|
||
'Phone': 'Телефон',
|
||
'TR Config': 'Конфигурация TR',
|
||
'Events that the card monitors (SET UP EVENT LIST):': 'События, отслеживаемые картой (SET UP EVENT LIST):',
|
||
'No events configured.': 'Нет настроенных событий.',
|
||
'Fetched proactive commands:': 'Извлечённые проактивные команды:',
|
||
'No commands logged yet.': 'Команды пока не регистрировались.',
|
||
'Send event notification to the card?': 'Отправить уведомление о событии на карту?',
|
||
'This event type is not yet supported.': 'Этот тип события пока не поддерживается.',
|
||
'Event sent.': 'Событие отправлено.',
|
||
'Event data sent.': 'Данные события отправлены.',
|
||
'Sending...': 'Отправка...',
|
||
'Invalid': 'Некорректное значение',
|
||
'Service Status': 'Статус сервиса',
|
||
'Normal service': 'Обычный сервис',
|
||
'Limited service': 'Ограниченный сервис',
|
||
'No service': 'Нет сервиса',
|
||
'Language code': 'Код языка',
|
||
'Registration type': 'Тип регистрации',
|
||
'Access Technology': 'Технология доступа',
|
||
'Rejection cause code': 'Код причины отклонения',
|
||
'Extended EMM cause (optional)': 'Расширенная EMM причина (опционально)',
|
||
'Status': 'Статус',
|
||
'Card reader removed': 'Картридер извлечён',
|
||
'Card reader inserted': 'Картридер вставлен',
|
||
'PROVIDE LOCAL INFORMATION response data': 'Данные для PROVIDE LOCAL INFORMATION',
|
||
'Send STATUS': 'Отправить STATUS',
|
||
'help': 'справка',
|
||
'STATUS and Polling': 'STATUS и опрос',
|
||
'STK menu': 'Меню STK',
|
||
'No menu set by the card': 'Меню не задано картой',
|
||
'Polling': 'Опрос',
|
||
'STATUS...': 'STATUS...',
|
||
'Saving...': 'Сохранение...',
|
||
'Saved': 'Сохранено',
|
||
'Save': 'Сохранить',
|
||
'Profiler': 'Профайлер',
|
||
'New profile': 'Новый профиль',
|
||
'Profile from card': 'Профиль с карты',
|
||
'Import profile': 'Импорт профиля',
|
||
'Profiles': 'Профили',
|
||
'Card snapshots': 'Снимки карт',
|
||
'Compare snapshots': 'Сравнить снимки',
|
||
'Master snapshot': 'Эталонный снимок',
|
||
'Snapshot to check': 'Снимок для проверки',
|
||
'Compare': 'Сравнить',
|
||
'extra file': 'лишний файл',
|
||
'Check card snapshot': 'Проверить снимок карты',
|
||
'Select card snapshot': 'Выберите снимок карты',
|
||
'Content not captured in snapshot': 'Содержимое не захвачено в снимок',
|
||
'not captured in snapshot': 'не захвачено в снимок',
|
||
'New snapshot': 'Новый снимок',
|
||
'Import snapshot': 'Импорт снимка',
|
||
'Snapshot name': 'Имя снимка',
|
||
'Delete snapshot': 'Удалить снимок',
|
||
'No snapshots defined.': 'Снимки не заданы.',
|
||
'No files captured.': 'Файлы не захвачены.',
|
||
'Please enter a snapshot name': 'Введите имя снимка',
|
||
'Open': 'Открыть',
|
||
'Contents': 'Содержимое',
|
||
'Not captured': 'Не прочитано',
|
||
'Back to list': 'Назад к списку',
|
||
'Add rule': 'Добавить правило',
|
||
'Scan': 'Сканировать',
|
||
'Discovering...': 'Поиск файлов...',
|
||
'files': 'файлов',
|
||
'Ignore contents of files:': 'Игнорировать содержимое файлов:',
|
||
'All': 'Все',
|
||
'Match first 4 bytes for': 'Сравнивать первые 4 байта для',
|
||
'FCP/FCI check': 'Проверка FCP/FCI',
|
||
'Filetype only (FCP)': 'Только тип файла (FCP)',
|
||
'Filetype + size (FCP)': 'Тип файла + размер (FCP)',
|
||
'Exact FCI': 'Полный FCI',
|
||
'FCI hex (raw SELECT response)': 'FCI hex (сырой ответ SELECT)',
|
||
'FCI parameters': 'Параметры FCI',
|
||
'Parameter': 'Параметр',
|
||
'Decoded FCI': 'Декодированный FCI',
|
||
'Decode failed': 'Ошибка декодирования',
|
||
'No profiles defined.': 'Профили не заданы.',
|
||
'rules': 'правил',
|
||
'Check card': 'Проверить карту',
|
||
'Export': 'Экспорт',
|
||
'Profile name': 'Имя профиля',
|
||
'Please enter a profile name': 'Введите имя профиля',
|
||
'Scanning...': 'Сканирование...',
|
||
'Delete profile': 'Удалить профиль',
|
||
'No rules defined.': 'Правила не заданы.',
|
||
'Path': 'Путь',
|
||
'File type': 'Тип файла',
|
||
'Size': 'Размер',
|
||
'Record length': 'Длина записи',
|
||
'Record count': 'Кол-во записей',
|
||
'Check contents': 'Проверить содержимое',
|
||
'filetype': 'тип файла',
|
||
'size': 'размер',
|
||
'records': 'записи',
|
||
'contents': 'содержимое',
|
||
'filetype and size': 'тип файла и размер',
|
||
'filetype and records': 'тип файла и записи',
|
||
'matching records': 'совпадающие записи',
|
||
'None': 'Нет',
|
||
'Exact': 'Точное',
|
||
'Mask': 'Маска',
|
||
'Checking': 'Проверка',
|
||
'passed': 'пройдено',
|
||
'failed': 'не пройдено',
|
||
'errors': 'ошибок',
|
||
'Only mismatches': 'Только расхождения',
|
||
'No mismatches': 'Расхождений нет',
|
||
'expected': 'ожидалось',
|
||
'actual': 'фактически',
|
||
'HTTP OTA': 'HTTP OTA',
|
||
'Trigger (Push SMS)': 'Триггер (Push SMS)',
|
||
'Store (SD admin params)': 'Store (параметры SD)',
|
||
'SD params tag': 'Tag параметров SD',
|
||
"Connection parameters ('84')": "Параметры соединения ('84')",
|
||
'+ TLV': '+ TLV',
|
||
'+ Device Identities': '+ Device Identities',
|
||
'+ Alpha (empty)': '+ Alpha (пустой)',
|
||
'+ Bearer (GPRS)': '+ Bearer (GPRS)',
|
||
"Security parameters ('85')": "Параметры безопасности ('85')",
|
||
'PSK Identity (text)': 'PSK Identity (текст)',
|
||
"Retry policy ('86')": "Политика повтора ('86')",
|
||
'Retry counter (B0,00=unlimited)': 'Счётчик повторов (B0,00=бесконечно)',
|
||
'Hours': 'Часы',
|
||
'Minutes': 'Минуты',
|
||
'Seconds': 'Секунды',
|
||
"Report failure (vendor-specific TLV)": "Отчёт о сбое (специфичный TLV)",
|
||
"HTTP POST ('89', 8A=host,8B=agent,8C=URI)": "HTTP POST ('89', 8A=host,8B=agent,8C=URI)",
|
||
'Host (text→hex)': 'Хост (текст→hex)',
|
||
"HTTP agent ID (text, X-Admin-From)": "ID HTTP агента (текст, X-Admin-From)",
|
||
'URI (text→hex)': 'URI (текст→hex)',
|
||
"Wrapped in Command Scripting template ('AA')": "Обёртка в Command Scripting template ('AA')",
|
||
};
|
||
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();
|
||
refreshDynamicI18n();
|
||
updateHelpLink();
|
||
}
|
||
|
||
let currentHelpAnchor = 'sim-rfm';
|
||
let pysimHelpAnchor = 'file-manager';
|
||
|
||
function updateHelpLink() {
|
||
const link = document.getElementById('help-link');
|
||
if (link) link.href = (currentLang === 'ru' ? 'help-ru.html' : 'help.html') + '#' + currentHelpAnchor;
|
||
}
|
||
|
||
function setHelpAnchor(anchor) {
|
||
currentHelpAnchor = anchor;
|
||
updateHelpLink();
|
||
}
|
||
|
||
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');
|
||
}
|
||
|
||
function isViewVisible(id) {
|
||
const el = document.getElementById(id);
|
||
return !!el && !el.classList.contains('hidden');
|
||
}
|
||
|
||
// Dynamic views build their labels with t() at render time; re-render the
|
||
// visible ones after a language switch so they don't stay in the old language.
|
||
function refreshDynamicI18n() {
|
||
if (isViewVisible('tab-profiler')) {
|
||
if (profilerView === 'list') { profilerRenderList(); profilerRenderSnapshotList(); if (profilerListTab === 'custom') pysimCustomRender(); }
|
||
else if (profilerView === 'editor') profilerRenderEditor();
|
||
else if (profilerView === 'results') profilerRenderResultsView();
|
||
else if (profilerView === 'snapshot') profilerRenderSnapshotData();
|
||
}
|
||
if (isViewVisible('profiler-scan-modal')) profilerScanRefreshOptions();
|
||
pysimUpdateStateIndicator();
|
||
if (isViewVisible('scp80-sub-cards')) cardsRender();
|
||
if (isViewVisible('tab-phone')) {
|
||
if (isViewVisible('phone-sub-phone')) {
|
||
pysimEventsRender();
|
||
pysimProactiveLogRender();
|
||
}
|
||
if (isViewVisible('phone-sub-tr')) pysimPliRender();
|
||
}
|
||
}
|
||
|
||
// Init language
|
||
const userLang = (navigator.language || '').split('-')[0];
|
||
currentLang = localStorage.getItem('lang') || (userLang === 'ru' ? 'ru' : 'en');
|
||
document.getElementById('lang-btn').textContent = currentLang.toUpperCase();
|
||
updateHelpLink();
|
||
|
||
cardsLoad();
|
||
pysimStartAvailabilityProbe();
|
||
|
||
if (localStorage.getItem('theme') === 'dark' ||
|
||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||
document.documentElement.classList.add('dark');
|
||
}
|
||
function toggleTheme() {
|
||
const html = document.documentElement;
|
||
html.classList.toggle('dark');
|
||
localStorage.setItem('theme', html.classList.contains('dark') ? 'dark' : 'light');
|
||
document.getElementById('theme-btn').textContent = html.classList.contains('dark') ? '☀️' : '🌙';
|
||
}
|
||
document.getElementById('theme-btn').textContent = document.documentElement.classList.contains('dark') ? '☀️' : '🌙';
|
||
updateConvFields();
|
||
updateSp();
|
||
updateSpKic();
|
||
updateSpKid();
|
||
chainInit('chain-sim');
|
||
chainInit('chain-usim');
|
||
chainInit('chain-ram');
|
||
translatePage();
|
||
// PWA
|
||
let deferredPrompt;
|
||
const installBtn = document.getElementById('install-btn');
|
||
if (!window.matchMedia('(display-mode: standalone)').matches) {
|
||
window.addEventListener('beforeinstallprompt', e => {
|
||
e.preventDefault();
|
||
deferredPrompt = e;
|
||
installBtn.style.display = '';
|
||
});
|
||
installBtn.addEventListener('click', async () => {
|
||
if (!deferredPrompt) return;
|
||
deferredPrompt.prompt();
|
||
const { outcome } = await deferredPrompt.userChoice;
|
||
if (outcome === 'accepted') deferredPrompt = null;
|
||
});
|
||
window.addEventListener('appinstalled', () => { installBtn.style.display = 'none'; });
|
||
}
|
||
if ('serviceWorker' in navigator) {
|
||
navigator.serviceWorker.register('sw.js');
|
||
}
|
||
</script>
|
||
|
||
</body>
|
||
</html> |