6e4eb5932c
The TCA loader's Explore traces settle the format question: it sends the same P2=00 listing queries wrapped and unchained (`AA0A2208 80F24000024F0000`, pages `...01024F0000`) - no GET RESPONSE anywhere - while the live card needs the chain and answers 6700 to the wrapped unchained form; and the live ISD refuses the compact P2=02 registry query (6700) while serving it wrapped (`AA11220F 80F2400209 4F005C054F9F70C5CC00` -> 6310 + entries with CE versions). Both the listing and the registry queries now follow the operation's command format and fall back across formats. - ramFormatIsExpanded(fmt) + ramFmtLadder(runFmt, working) (pure): the run's format first, then expanded, expanded-ae and compact as the last resort; a format that served data is tried first and never repeated. - ramGetStatusApdu(p1, p2, fmt): the chain exists in compact only (TS 102 226 5.2.1.1) - an expanded run sends the bare query. - paginate: the P2=00 listing follows the run's format and pins it per send, so a GET RESPONSE can never ride inside an expanded packet. - expandedPass: each registry form goes through the format ladder; the chained 61xx retry is compact-only (it was guarded for 'expanded' but not 'expanded-ae'); the serving format is remembered for the remaining listings and named in the report ([AA]/[AE 80]); ramExpandedGroups carries it. - Tests: the helper + ladder vectors, the unchained expanded listing builder (both codings), the fallback/chain source guards, the report marker. - Help EN/RU, the repo AGENTS.md; sw cache simple-v353.
20723 lines
1.0 MiB
Plaintext
20723 lines
1.0 MiB
Plaintext
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>SIMple</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">SIMple <span id="app-version" class="text-xs text-gray-400 dark:text-slate-500 ml-1"></span></h1>
|
||
<div class="flex items-center gap-4">
|
||
<span id="state-indicator" class="flex items-center select-none" style="cursor:default" title="Connecting...">
|
||
<span id="state-indicator-dot" class="text-xs text-gray-400" title="Connecting...">●</span>
|
||
<img id="state-indicator-img" class="hidden dark:invert" style="width:30px;height:30px" alt="" src="nosim.svg">
|
||
<span id="state-indicator-iccid" class="hidden ml-0.5 text-xs font-mono text-gray-500 dark:text-slate-400 whitespace-nowrap"></span>
|
||
<span id="state-indicator-adm" class="hidden ml-2 text-xs font-mono border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5" title=""></span>
|
||
<span id="state-indicator-scp80" class="hidden ml-1 text-xs font-mono text-gray-500 dark:text-slate-400 border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5" title=""></span>
|
||
<span id="state-indicator-scp81" class="hidden ml-1 text-xs font-mono text-gray-500 dark:text-slate-400 border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5" title=""></span>
|
||
</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/simple" 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="scp81" data-l10n="SCP81">SCP81</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="cards" data-l10n="Cards">Cards</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="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="phone" data-l10n="Simulator">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="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="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="httpota" onclick="cApduSwitchSubtab('httpota')" data-l10n="Push commands">Push commands</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="parser" onclick="cApduSwitchSubtab('parser')" data-l10n="Parser">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','search-record')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ SEARCH RECORD</button>
|
||
<button onclick="chainAddRow('chain-sim','increase')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ INCREASE</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>
|
||
<button onclick="loadExpandedScript('chain-sim')" id="chain-sim-expanded-btn" class="mb-3 px-5 py-2.5 bg-violet-600 text-white text-sm font-medium rounded hover:bg-violet-700" data-l10n="→ Expanded Script">→ Expanded Script</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','search-record')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ SEARCH RECORD</button>
|
||
<button onclick="chainAddRow('chain-usim','increase')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ INCREASE</button>
|
||
<button onclick="chainAddRow('chain-usim','create-file')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ CREATE FILE</button>
|
||
<button onclick="chainAddRow('chain-usim','delete-file')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ DELETE FILE</button>
|
||
<button onclick="chainAddRow('chain-usim','resize-file')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ RESIZE FILE</button>
|
||
<button onclick="chainAddRow('chain-usim','set-data')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ SET DATA</button>
|
||
<button onclick="chainAddRow('chain-usim','retrieve-data')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ RETRIEVE DATA</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>
|
||
<button onclick="loadExpandedScript('chain-usim')" id="chain-usim-expanded-btn" class="mb-3 px-5 py-2.5 bg-violet-600 text-white text-sm font-medium rounded hover:bg-violet-700" data-l10n="→ Expanded Script">→ Expanded Script</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','put-key')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">+ PUT KEY</button>
|
||
<button onclick="chainAddRow('chain-ram','push')" class="text-xs px-2 py-1 bg-violet-600 text-white rounded hover:bg-violet-700">+ PUSH</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>
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<label class="text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Expanded format">Expanded format</label>
|
||
<select id="chain-ram-expand-fmt" onchange="ramBuildExpanded()" class="border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">
|
||
<option value="AE80">AE80 (indefinite)</option>
|
||
<option value="AA">AA (definite)</option>
|
||
</select>
|
||
<button onclick="ramBuildExpanded()" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700" data-l10n="To expanded">To expanded</button>
|
||
<button onclick="loadExpandedScript('chain-ram')" class="text-xs px-2 py-1 bg-violet-600 text-white rounded hover:bg-violet-700" data-l10n="→ Expanded Script">→ Expanded Script</button>
|
||
<button data-needs="server" onclick="ramQueueScp81()" class="text-xs px-2 py-1 bg-violet-600 text-white rounded hover:bg-violet-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Queue in SCP81">Queue in SCP81</button>
|
||
<span id="chain-ram-queue-msg" class="text-xs"></span>
|
||
</div>
|
||
<textarea id="chain-ram-expanded" 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 mb-2" placeholder="Expanded script forms (AA / AE80)"></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-httpota" class="hidden">
|
||
<div class="flex flex-wrap gap-2 mb-3">
|
||
<button id="hota-form-btn-trigger" onclick="hotaSetForm('trigger')" class="px-4 py-1.5 text-sm rounded-full bg-blue-600 text-white" data-l10n="HTTP OTA Trigger (Push SMS)">HTTP OTA Trigger (Push SMS)</button>
|
||
<button id="hota-form-btn-store" onclick="hotaSetForm('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="HTTP OTA Store (SD admin params)">HTTP OTA Store (SD admin params)</button>
|
||
<button id="hota-form-btn-link" onclick="hotaSetForm('link')" 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="Channel / link trigger">Channel / link trigger</button>
|
||
</div>
|
||
|
||
<div id="hota-form-gp">
|
||
<h3 id="hota-heading-trigger" class="text-base font-medium mb-3" data-l10n="Administrative session trigger (Push SMS)">Administrative session trigger (Push SMS)</h3>
|
||
<h3 id="hota-heading-store" class="text-base font-medium mb-3 hidden" data-l10n="Administrative session store data (SD admin params)">Administrative session store data (SD admin params)</h3>
|
||
|
||
<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' — not in the pinned v1.1 spec (unverified)</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('cmddet')" class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700" data-l10n="+ Command details">+ Command details</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 (default)">+ Bearer (default)</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 (2 bytes, hex)">Retry counter (2 bytes, hex)</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="99" 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="push-form-link" class="hidden">
|
||
<h3 class="text-base font-medium mb-2" data-l10n="Channel / link establishment (TS 102 226 §9)">Channel / link establishment (TS 102 226 §9)</h3>
|
||
<p class="text-xs text-gray-500 dark:text-slate-400 mb-2" data-l10n="Not the administration session — a C-APDU for an app that supports BIP/CAT_TP">Not the administration session — a C-APDU for an app that supports BIP/CAT_TP</p>
|
||
<div class="border border-gray-300 dark:border-slate-600 rounded p-3 bg-gray-50 dark:bg-slate-800 mb-3">
|
||
<div class="mb-2">
|
||
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Request">Request</label>
|
||
<select id="push-link-request" onchange="pushUpdate('link')" 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">
|
||
<option value="01" data-l10n="BIP channel opening (OPEN CHANNEL)">BIP channel opening (OPEN CHANNEL)</option>
|
||
<option value="02" data-l10n="CAT_TP link establishment">CAT_TP link establishment</option>
|
||
<option value="03" data-l10n="TCP connection">TCP connection</option>
|
||
</select>
|
||
</div>
|
||
<div data-push-link="01">
|
||
<div class="mb-2">
|
||
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Mode">Mode</label>
|
||
<select id="push-link-oc-mode" onchange="pushUpdate('link')" 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">
|
||
<option value="fields" data-l10n="Fields">Fields</option>
|
||
<option value="hex" data-l10n="Hex">Hex</option>
|
||
</select>
|
||
</div>
|
||
<div id="push-link-oc-fields">
|
||
<div class="grid grid-cols-2 gap-2 mb-2">
|
||
<div><label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Bearer description (hex, default 350103)">Bearer description (hex, default 350103)</label>
|
||
<input id="push-link-oc-bearer" oninput="pushUpdate('link')" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="350103"></div>
|
||
<div><label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Buffer size (hex, 2 bytes)">Buffer size (hex, 2 bytes)</label>
|
||
<input id="push-link-oc-buffer" oninput="pushUpdate('link')" maxlength="4" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="0200"></div>
|
||
<div><label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Transport protocol">Transport protocol</label>
|
||
<select id="push-link-oc-proto" onchange="pushUpdate('link')" 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">
|
||
<option value="01" data-l10n="UDP, UICC client, remote">UDP, UICC client, remote</option>
|
||
<option value="02" data-l10n="TCP, UICC client, remote">TCP, UICC client, remote</option>
|
||
</select></div>
|
||
<div><label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Destination port (hex)">Destination port (hex)</label>
|
||
<input id="push-link-oc-port" oninput="pushUpdate('link')" maxlength="4" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="1F90"></div>
|
||
<div><label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Destination address value (hex: 21 IPv4 / 57 IPv6 / F0 FQDN)">Destination address value (hex: 21 IPv4 / 57 IPv6 / F0 FQDN)</label>
|
||
<input id="push-link-oc-addr" oninput="pushUpdate('link')" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="210A000001"></div>
|
||
<div><label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="NAA / APN (text)">NAA / APN (text)</label>
|
||
<input id="push-link-oc-apn" oninput="pushUpdate('link')" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="internet"></div>
|
||
</div>
|
||
<div class="mb-2">
|
||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-400">
|
||
<input type="checkbox" id="push-link-oc-alpha-null" onchange="pushUpdate('link')" class="rounded">
|
||
<span data-l10n="Alpha (transparent — no user confirmation)">Alpha (transparent — no user confirmation)</span>
|
||
</label>
|
||
<div class="mt-1"><label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Alpha identifier (hex, optional)">Alpha identifier (hex, optional)</label>
|
||
<input id="push-link-oc-alpha" oninput="pushUpdate('link')" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800"></div>
|
||
</div>
|
||
<div class="mb-2">
|
||
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Extra TLVs (hex, optional)">Extra TLVs (hex, optional)</label>
|
||
<input id="push-link-oc-extra" oninput="pushUpdate('link')" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
</div>
|
||
<div id="push-link-oc-hex">
|
||
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="OPEN CHANNEL COMPREHENSION-TLVs (hex)">OPEN CHANNEL COMPREHENSION-TLVs (hex)</label>
|
||
<textarea id="push-link-data" rows="2" oninput="pushUpdate('link')" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 mb-2" placeholder="82102081..."></textarea>
|
||
</div>
|
||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||
<input type="checkbox" id="push-link-cat" onchange="pushUpdate('link')" class="rounded">
|
||
<span data-l10n="Also request the CAT_TP link establishment (02) in the same message">Also request the CAT_TP link establishment (02) in the same message</span>
|
||
</label>
|
||
</div>
|
||
<div data-push-link="02" id="push-link-cat-fields-1" class="grid grid-cols-2 gap-2 mb-2">
|
||
<div><label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Max SDU (hex, optional)">Max SDU (hex, optional)</label>
|
||
<input id="push-link-sdu" oninput="pushUpdate('link')" maxlength="4" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800"></div>
|
||
<div><label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Identification data (hex, optional)">Identification data (hex, optional)</label>
|
||
<input id="push-link-ident" oninput="pushUpdate('link')" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800"></div>
|
||
</div>
|
||
<div data-push-link="02,03" id="push-link-cat-fields-2" class="grid grid-cols-2 gap-2 mb-2">
|
||
<div><label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Destination port (hex)">Destination port (hex)</label>
|
||
<input id="push-link-port" oninput="pushUpdate('link')" maxlength="4" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="1F90"></div>
|
||
<div data-push-link="03"><label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Bearer description (hex, empty = 350103)">Bearer description (hex, empty = 350103)</label>
|
||
<input id="push-link-bearer" oninput="pushUpdate('link')" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="350103"></div>
|
||
<div data-push-link="03"><label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Destination address value (hex: 21 IPv4 / 57 IPv6 / F0 FQDN)">Destination address value (hex: 21 IPv4 / 57 IPv6 / F0 FQDN)</label>
|
||
<input id="push-link-addr" oninput="pushUpdate('link')" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="210A000001"></div>
|
||
<div data-push-link="03"><label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="NAA / APN (text)">NAA / APN (text)</label>
|
||
<input id="push-link-apn" oninput="pushUpdate('link')" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800" placeholder="internet"></div>
|
||
</div>
|
||
<div data-push-link="02,03" id="push-link-cat-fields-3" class="mb-2">
|
||
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Extra TLVs (hex, optional)">Extra TLVs (hex, optional)</label>
|
||
<input id="push-link-extra" oninput="pushUpdate('link')" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div data-push-link="03">
|
||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||
<input type="checkbox" id="push-link-idp" onchange="pushUpdate('link')" class="rounded">
|
||
<span data-l10n="Also send the identification packet (04) in the same message">Also send the identification packet (04) in the same message</span>
|
||
</label>
|
||
<div id="push-link-idp-block" class="hidden">
|
||
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Identification data (hex, optional; ICCID when empty)">Identification data (hex, optional; ICCID when empty)</label>
|
||
<input id="push-link-idp-ident" oninput="pushUpdate('link')" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
<div class="text-xs text-gray-500 dark:text-slate-400 mt-1" data-l10n="The identification packet follows the TCP request in the same message (ICCID when empty).">The identification packet follows the TCP request in the same message (ICCID when empty).</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<textarea id="push-link-preview" rows="2" 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 mb-2" placeholder="C-APDU (TS 102 226 §9)"></textarea>
|
||
<button onclick="packToSp('push-link-preview')" id="push-link-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>
|
||
<button onclick="pushToExpanded('push-link-preview')" id="push-link-exp-btn" disabled class="mb-3 px-5 py-2.5 bg-violet-600 text-white text-sm font-medium rounded hover:bg-violet-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="→ Expanded Script">→ Expanded Script</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="c-apdu-sub-parser" class="hidden">
|
||
<div class="flex flex-wrap gap-2 mb-3">
|
||
<button id="parser-btn-parse" onclick="parserSwitchSubtab('parse')" class="px-4 py-1.5 text-sm rounded-full bg-blue-600 text-white" data-l10n="C-APDU Parser">C-APDU Parser</button>
|
||
<button id="parser-btn-response" onclick="parserSwitchSubtab('response')" 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="R-APDU parser">R-APDU parser</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-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>
|
||
<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="ram" onclick="scp80SwitchSubtab('ram')" data-l10n="RAM">RAM</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="tar" onclick="scp80SwitchSubtab('tar')" data-l10n="TAR probe">TAR probe</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>
|
||
<label class="block mt-2 mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Keyset">Keyset</label>
|
||
<select id="sp-keyset-sel" onchange="spKeysetChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed"></select>
|
||
</div>
|
||
<div id="sp-preset-warning" class="hidden mb-3 px-3 py-2 rounded border border-amber-400 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 text-sm text-amber-700 dark:text-amber-300"></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="15">15 — PoR required, RC, ciphered</option>
|
||
<option value="19">19 — PoR required, CC, ciphered</option>
|
||
<option value="1D">1D — PoR required, DS, ciphered</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()" onchange="spCntrSyncPreset()" 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" data-needs-keyset="sp" 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-size-info" class="text-xs mt-1 text-gray-500 dark:text-slate-400"></div>
|
||
<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-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 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="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="flex-1">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Keyset">Keyset</label>
|
||
<select id="ram-keyset-sel" onchange="ramKeysetChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed"></select>
|
||
</div>
|
||
<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>
|
||
<option value="compat" data-l10n="CAP compatibility test">CAP compatibility test</option>
|
||
<option value="install-app" data-l10n="INSTALL [for install] (loaded package)">INSTALL [for install] (loaded package)</option>
|
||
<option value="make-selectable" data-l10n="INSTALL [for make selectable]">INSTALL [for make selectable]</option>
|
||
</select>
|
||
</div>
|
||
<div class="flex-1">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Format">Format</label>
|
||
<select id="ram-format" 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="auto" data-l10n="Auto (detect)">Auto (detect)</option>
|
||
<option value="compact" data-l10n="Compact">Compact</option>
|
||
<option value="expanded" data-l10n="Expanded (AA, definite length)">Expanded (AA, definite length)</option>
|
||
<option value="expanded-ae" data-l10n="Expanded (AE 80, indefinite length)">Expanded (AE 80, indefinite length)</option>
|
||
</select>
|
||
</div>
|
||
<button data-needs="card" data-needs-keyset="ram" data-cap-gate="ram" 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-preset-warning" class="hidden mb-3 px-3 py-2 rounded border border-amber-400 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 text-sm text-amber-700 dark:text-amber-300"></div>
|
||
|
||
<label class="flex items-center gap-1 mb-3 text-sm text-gray-700 dark:text-slate-300" data-l10n-title="Runs one extra expanded query per listing (P2=02; on by default). The compact listing carries AID, life cycle and privileges only - it has no version; the expanded pass adds the Executable Load File version, the Load File AID, the Security Domain AID, the module lists and the applet STK menu entries (SCP Registry Data). Cards differ in what they accept, so three query forms are tried (tag list with EA, tag list without EA, no tag list - the GPPro form); the explorer reports which form worked and where the run fell back to the compact listing (package versions are then unavailable). It costs a few extra secured packets per Explore; untick to skip it."><input type="checkbox" id="ram-expanded-details" class="rounded border-gray-300 dark:border-slate-600" onchange="ramExpandedDetailsChanged()"> <span data-l10n="Fetch expanded registry details (load-file / SD associations)">Fetch expanded registry details (load-file / SD associations)</span></label>
|
||
|
||
<div id="ram-cap-rows" 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" onchange="ramCapFileChanged()" class="w-full text-sm text-gray-600 dark:text-slate-300">
|
||
<fieldset id="ram-cap-info" class="hidden mt-2 border border-gray-200 dark:border-slate-700 rounded p-3 text-xs text-gray-500 dark:text-slate-400">
|
||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="CAP details">CAP details</legend>
|
||
<div id="ram-cap-body"></div>
|
||
</fieldset>
|
||
</div>
|
||
<div id="ram-compat-rows" class="hidden mb-3">
|
||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3" data-l10n-title="Runs the load only as far as the block that completes the CAP's Import component: the card checks the import list there, so its answer says whether the card can satisfy it. Nothing is installed and nothing is committed - the link gate is the real install's business. Lines are AID=version (rewrites an import) or +AID=version (appends a synthetic import, to query the card for a package the CAP does not use).">
|
||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Import versions to test">Import versions to test</legend>
|
||
<textarea id="ram-probe-versions" rows="4" spellcheck="false" oninput="this.dataset.manual='1'" 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="AID=version per line (+AID=version appends)"></textarea>
|
||
<div class="flex items-center gap-2 mt-1">
|
||
<button type="button" onclick="ramProbeAllZero()" data-l10n-title="Sets every AID=version line to 0.0; +AID=version lines keep their version." class="px-2 py-0.5 text-xs rounded 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="Set all 0.0">Set all 0.0</button>
|
||
</div>
|
||
<div class="text-xs text-gray-400 dark:text-slate-500 mt-1" data-l10n="The card checks the CAP's import list while it loads, so a rejection at the Import component usually means it cannot satisfy that list.">The card checks the CAP's import list while it loads, so a rejection at the Import component usually means it cannot satisfy that list.</div>
|
||
<div class="text-xs text-gray-400 dark:text-slate-500" data-l10n="Press 'Set all 0.0' first: if the test now passes, the list was the cause; then restore one line at a time to find the package and version the card lacks. A +AID=version line asks the card about a package the CAP does not use.">Press 'Set all 0.0' first: if the test now passes, the list was the cause; then restore one line at a time to find the package and version the card lacks. A +AID=version line asks the card about a package the CAP does not use.</div>
|
||
</fieldset>
|
||
<label class="block mb-1 mt-2 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="LOAD block size (1..240, default 240)">LOAD block size (1..240, default 240)</label>
|
||
<input id="ram-compat-block" type="number" min="1" max="240" class="w-32 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="240">
|
||
</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="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="ram-app-rows" class="hidden">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Load File AID / Package AID (hex)">Load File AID / Package AID (hex)</label>
|
||
<input id="ram-app-loadfile" 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="F0414C46416101" maxlength="32">
|
||
<label class="block mb-1 mt-2 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Executable Module AID / Applet Class AID (hex)">Executable Module AID / Applet Class AID (hex)</label>
|
||
<input id="ram-app-module" 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="F0414C4641610101" maxlength="32">
|
||
<label class="block mb-1 mt-2 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Instance AID (hex, empty = module AID)">Instance AID (hex, empty = module AID)</label>
|
||
<input id="ram-app-instance" 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="(module AID)" maxlength="32">
|
||
</div>
|
||
<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>
|
||
<fieldset id="rc-toolkit-row" class="mb-3 mt-2 rounded border border-blue-300 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-900/10 p-3">
|
||
<legend class="px-1 text-sm font-medium text-blue-800 dark:text-blue-300">
|
||
<label class="flex items-center gap-2">
|
||
<input id="rc-toolkit-enable" type="checkbox" class="rounded border-gray-300 dark:border-slate-600 dark:bg-slate-800" onchange="updateRcToolkit()">
|
||
<span data-l10n="SIM/UICC Toolkit parameters">SIM/UICC Toolkit parameters</span>
|
||
</label>
|
||
</legend>
|
||
<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" onchange="updateRcTkMode();updateStkParamsHex()" 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 id="rc-tk-mode-hint" class="hidden mt-1 text-xs text-blue-700 dark:text-blue-300"></div>
|
||
</div>
|
||
<div class="grid grid-cols-3 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 (0 = RFU)">Priority (0 = RFU)</label>
|
||
<input id="rc-tk-priority" oninput="updateStkParamsHex()" type="number" min="0" max="255" value="255" 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" oninput="updateStkParamsHex()" 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" oninput="updateStkParamsHex()" 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" oninput="updateStkParamsHex()" 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" oninput="updateStkParamsHex()" 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" oninput="updateStkParamsHex()" 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" oninput="updateStkParamsHex()" 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" oninput="updateStkParamsHex()" 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" oninput="updateStkParamsHex()" 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" onchange="updateStkParamsHex()" 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" oninput="updateStkParamsHex()" 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" maxlength="6">
|
||
</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" oninput="updateStkParamsHex()" 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" oninput="updateStkParamsHex()" 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 id="rc-tk-access-row" class="col-span-3 flex items-center gap-4">
|
||
<div id="rc-tk-fsaccess-row" class="flex items-center">
|
||
<label class="flex items-center gap-2 text-xs font-medium text-gray-700 dark:text-slate-300">
|
||
<input id="rc-tk-fsaccess" type="checkbox" onchange="updateStkParamsHex()" class="rounded border-gray-300 dark:border-slate-600 dark:bg-slate-800">
|
||
<span data-l10n="File system access (full)">File system access (full)</span>
|
||
</label>
|
||
</div>
|
||
<div id="rc-tk-adfaccess-row" class="flex items-center">
|
||
<label class="flex items-center gap-2 text-xs font-medium text-gray-700 dark:text-slate-300">
|
||
<input id="rc-tk-adfaccess" type="checkbox" onchange="updateStkParamsHex()" class="rounded border-gray-300 dark:border-slate-600 dark:bg-slate-800">
|
||
<span data-l10n="ADF.USIM access (full)">ADF.USIM access (full)</span>
|
||
</label>
|
||
</div>
|
||
<div id="rc-tk-adfaid-row" class="flex-1 min-w-0">
|
||
<label class="block mb-1 text-xs text-gray-500 dark:text-slate-400" data-l10n="ADF AID (hex)">ADF AID (hex)</label>
|
||
<div class="flex items-center gap-2">
|
||
<input id="rc-tk-adfaid" oninput="updateStkParamsHex()" value="A0000000871002" maxlength="32" class="font-mono flex-1 border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">
|
||
<button type="button" id="rc-tk-adfaid-from-card" data-needs="card" onclick="ramAdfAidFromCard()" 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 whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="From card">From card</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</fieldset>
|
||
<div class="grid grid-cols-2 gap-x-3 gap-y-2 text-sm mt-2">
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Applet-specific parameters (C9, hex, optional)">Applet-specific parameters (C9, hex, optional)</label>
|
||
<input id="rc-c9" oninput="updateInstallParamsHex()" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800" placeholder="empty = C9 00">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="C7 / C8 memory quotas (bytes, optional)">C7 / C8 memory quotas (bytes, optional)</label>
|
||
<div class="flex items-center gap-2">
|
||
<input id="rc-quota-c7" oninput="updateInstallParamsHex()" type="number" min="0" placeholder="C7" class="font-mono w-24 border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">
|
||
<input id="rc-quota-c8" oninput="updateInstallParamsHex()" type="number" min="0" placeholder="C8" class="font-mono w-24 border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<label class="block mb-1 mt-2 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Extra install parameters (hex, optional)">Extra 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="extra TLVs (optional)">
|
||
<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" oninput="this.dataset.manual='1'" 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="EA / CA TLV">
|
||
<div id="ram-stk-hint" class="hidden text-xs text-red-600 dark:text-red-400 mt-1"></div>
|
||
<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>
|
||
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400 mt-2" data-l10n="LOAD block size (bytes, empty = 240)">LOAD block size (bytes, empty = 240)</label>
|
||
<input id="ram-load-size" type="number" min="1" max="240" class="w-32 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="240">
|
||
<div class="text-xs text-gray-500 dark:text-slate-400 mt-1" data-l10n="Splits the load file into LOAD APDUs (1-240 bytes of payload). A secured packet larger than one SMS is sent as a concatenated SMS-PP download (up to 5 SMs).">Splits the load file into LOAD APDUs (1-240 bytes of payload). A secured packet larger than one SMS is sent as a concatenated SMS-PP download (up to 5 SMs).</div>
|
||
</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-cntr-low" class="mt-2 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 id="scp80-sub-tar" class="hidden">
|
||
<p class="text-xs text-gray-500 dark:text-slate-400 mb-3" data-l10n="Sends one secured packet per selected TAR (TS 101 220 Annex D, Table D.1) carrying a harmless command, and classifies the card's answer - which OTA applications it has registered. Every packet the card accepts consumes a counter, which is saved to the preset.">Sends one secured packet per selected TAR (TS 101 220 Annex D, Table D.1) carrying a harmless command, and classifies the card's answer - which OTA applications it has registered. Every packet the card accepts consumes a counter, which is saved to the preset.</p>
|
||
|
||
<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="Card preset">Card preset</label>
|
||
<select id="tar-card-sel" onchange="tarApplyCard(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-1">
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Keyset">Keyset</label>
|
||
<select id="tar-keyset-sel" onchange="tarKeysetChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed"></select>
|
||
</div>
|
||
</div>
|
||
<div id="tar-preset-warning" class="hidden mb-3 px-3 py-2 rounded border border-amber-400 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 text-sm text-amber-700 dark:text-amber-300"></div>
|
||
|
||
<div class="mb-1 flex items-center justify-between">
|
||
<span class="text-xs text-gray-600 dark:text-slate-400" data-l10n="TARs to probe — TS 101 220 V18.3.0 Annex D, Table D.1">TARs to probe — TS 101 220 V18.3.0 Annex D, Table D.1</span>
|
||
<span class="flex gap-1">
|
||
<button type="button" onclick="tarProbeSelectAll(true)" class="px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700" data-l10n="Select all">Select all</button>
|
||
<button type="button" onclick="tarProbeSelectAll(false)" class="px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700" data-l10n="Select none">Select none</button>
|
||
</span>
|
||
</div>
|
||
<div id="tar-list" class="mb-2 border border-gray-200 dark:border-slate-700 rounded p-2 max-h-64 overflow-y-auto"></div>
|
||
<div class="flex gap-2 items-center mb-3">
|
||
<input id="tar-custom" 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 text-center" maxlength="6" placeholder="AABBCC">
|
||
<input id="tar-custom-note" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="description (optional)">
|
||
<button type="button" onclick="tarProbeAddCustom()" class="px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 whitespace-nowrap" data-l10n="Add TAR">Add TAR</button>
|
||
</div>
|
||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Probe APDU (hex)">Probe APDU (hex)</label>
|
||
<input id="tar-apdu" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono mb-3" value="00A40000023F00">
|
||
<button data-needs="card" data-needs-keyset="tar" onclick="tarExecute()" 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 id="tar-result" class="mt-2 text-base font-semibold font-mono hidden"></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 id="profiler-from-card-btn" data-needs="card-iccid" 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"><span data-l10n="Profile from card">Profile from card</span><span class="profiler-iccid-label"></span></button>
|
||
<button onclick="profilerFromSnapshot()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Profile from snapshot">Profile from snapshot</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 id="snapshot-new-btn" data-needs="card-iccid" 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"><span data-l10n="New snapshot">New snapshot</span><span class="profiler-iccid-label" data-iccid-sep=": "></span></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="Custom files are added in file manager tree and included in card scan.">Custom files are added in file manager tree and included in card scan.</div>
|
||
<div class="flex flex-wrap gap-2 items-end mb-2">
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Root (MF / ADF)">Root (MF / ADF)</label>
|
||
<select id="pysim-cf-root" onchange="pysimCustomRootChanged()" class="font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-2 dark:bg-slate-800"></select>
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Parent DF">Parent DF</label>
|
||
<input id="pysim-cf-parent" list="pysim-cf-parent-list" oninput="pysimCustomParentInput()" onkeydown="if(event.key==='Enter')pysimCustomSubmit()" class="font-mono w-72 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2 dark:bg-slate-800" placeholder="MF/7F20 or ADF.USIM/5F3A">
|
||
<datalist id="pysim-cf-parent-list"></datalist>
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="FID (4 hex)">FID (4 hex)</label>
|
||
<input id="pysim-cf-fid" maxlength="4" class="font-mono w-24 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2 dark:bg-slate-800" placeholder="6F46" onkeydown="if(event.key==='Enter')pysimCustomSubmit()">
|
||
</div>
|
||
<div class="flex-1 min-w-[180px]">
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Alias (EF. / DF.)">Alias (EF. / DF.)</label>
|
||
<input id="pysim-cf-name" class="font-mono w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2 dark:bg-slate-800" placeholder="EF.SPN" onkeydown="if(event.key==='Enter')pysimCustomSubmit()">
|
||
</div>
|
||
<button id="pysim-cf-add-btn" onclick="pysimCustomSubmit()" class="px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 whitespace-nowrap" data-l10n="Add">Add</button>
|
||
<button id="pysim-cf-cancel-btn" onclick="pysimCustomEditCancel()" class="hidden px-3 py-2 bg-gray-600 text-white text-sm font-medium rounded hover:bg-gray-700 whitespace-nowrap" data-l10n="Cancel">Cancel</button>
|
||
</div>
|
||
<div class="text-xs text-gray-500 dark:text-slate-400 mb-2" data-l10n="The parent may be any DF path — a standard DF from the tree or a custom DF, any depth; parents not yet seen in the tree are marked ⚠.">The parent may be any DF path — a standard DF from the tree or a custom DF, any depth; parents not yet seen in the tree are marked ⚠.</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="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>
|
||
</div>
|
||
<div id="pysim-cf-io" class="hidden mt-2 text-xs text-gray-500 dark:text-slate-400"></div>
|
||
</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-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-simple-server running locally.">To access local card reader, you need pysim-simple-server running locally.</p>
|
||
<p><span data-l10n="To connect, install">To connect, install</span> <a href="https://github.com/anttro/simple" target="_blank" class="text-blue-600 dark:text-blue-400 underline">pysim-simple-server</a>: <code class="font-mono bg-gray-100 dark:bg-slate-700 px-1 rounded">git clone https://github.com/anttro/simple.git</code> <span data-l10n="and start it.">and start it.</span><br><span data-l10n="Refer to pysim-simple-server README files for details.">Refer to pysim-simple-server README files for details.</span> <span data-l10n="Click">Click</span> <b data-l10n="Connect">Connect</b> <span data-l10n="when pysim-simple-server is ready">when pysim-simple-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 flex flex-col">
|
||
<div class="shrink-0 text-xs font-mono">
|
||
<div class="flex items-center gap-1 mb-1 text-gray-500 dark:text-slate-400">
|
||
<span data-l10n="Sort:">Sort:</span>
|
||
<span class="pysim-fs-sort-pill px-2 py-0.5 rounded cursor-pointer bg-blue-600 text-white" data-fs-sort="fid" onclick="pysimFsSetSort('fid')">FID</span>
|
||
<span class="pysim-fs-sort-pill px-2 py-0.5 rounded cursor-pointer bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300" data-fs-sort="name" onclick="pysimFsSetSort('name')" data-l10n="Name">Name</span>
|
||
</div>
|
||
<button id="pysim-fs-probe-btn" data-needs="card" onclick="pysimFsProbeAll()" class="w-full mb-1 px-2 py-0.5 rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Probe all files">Probe all files</button>
|
||
<div id="pysim-fs-probe-status" class="hidden mb-1 text-gray-500 dark:text-slate-400"></div>
|
||
</div>
|
||
<div class="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:65vh">
|
||
<div id="pysim-fs-tree"></div>
|
||
</div>
|
||
</div>
|
||
<div class="flex-1">
|
||
<div id="pysim-fs-detail" class="hidden">
|
||
<div id="pysim-fs-info" class="mb-1"></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 hidden"></div>
|
||
<div class="flex gap-2 mt-1 items-center">
|
||
<button type="button" data-needs="card" onclick="pysimFsSetMode('raw')" id="pysim-fs-read-raw-btn" class="pysim-mode-pill px-2 py-0.5 text-xs rounded cursor-pointer bg-blue-600 text-white disabled:opacity-40 disabled:cursor-not-allowed" data-mode="raw" data-l10n="Read raw">Read raw</button>
|
||
<button type="button" data-needs="card" data-needs-decoder onclick="pysimFsSetMode('dec')" id="pysim-fs-read-dec-btn" 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 disabled:opacity-40 disabled:cursor-not-allowed" data-mode="dec" data-l10n="Read decoded">Read decoded</button>
|
||
<div class="ml-auto flex gap-2">
|
||
<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>
|
||
<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 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Edit raw">Edit raw</button>
|
||
</div>
|
||
</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-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>
|
||
<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="bip" onclick="phoneSwitchSubtab('bip')" data-l10n="BIP">BIP</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="esim" onclick="phoneSwitchSubtab('esim')" data-l10n="eSIM LPA">eSIM LPA</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="test" onclick="phoneSwitchSubtab('test')" data-l10n="Test script">Test script</button>
|
||
</div>
|
||
<div id="phone-sub-phone">
|
||
<div class="flex flex-wrap items-start gap-4 mb-4">
|
||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3">
|
||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="STK menu">STK menu</legend>
|
||
<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"><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>
|
||
</fieldset>
|
||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3">
|
||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="STATUS and Polling">STATUS and Polling</legend>
|
||
<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-emerald-600 text-white hover:bg-emerald-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>
|
||
</fieldset>
|
||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3">
|
||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="TERMINAL PROFILE">TERMINAL PROFILE</legend>
|
||
<div class="flex items-center gap-2">
|
||
<button id="tp-send-btn" data-needs="card" onclick="tpSend()" 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="Send">Send</button>
|
||
<button id="tp-configure-btn" data-needs="server" onclick="tpOpen()" 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="Configure">Configure</button>
|
||
</div>
|
||
<div id="tp-status" class="text-xs mt-1 text-gray-400 dark:text-slate-500"></div>
|
||
</fieldset>
|
||
<fieldset class="flex-1 min-w-0 border border-gray-200 dark:border-slate-700 rounded p-3">
|
||
<legend class="px-1 text-xs font-medium 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):</legend>
|
||
<div id="pysim-event-list" class="text-sm font-mono text-gray-600 dark:text-slate-400"></div>
|
||
</fieldset>
|
||
</div>
|
||
<div class="flex flex-wrap items-start gap-4 mb-4">
|
||
<fieldset class="flex-1 border border-gray-200 dark:border-slate-700 rounded p-3" style="min-width:30rem">
|
||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Network simulation">Network simulation</legend>
|
||
<div class="text-xs text-gray-500 dark:text-slate-400 mb-2" data-l10n="Replays the write patterns a real phone performs on network-condition changes (EPS attach, service loss, roaming denial, 2G fallback, SMS delivery, cell-broadcast reconfiguration, AUTHENTICATE). Only UPDATE BINARY/RECORD, ENVELOPE and AUTHENTICATE are sent; FPLMN is appended only by the permanent "PLMN not allowed" rejection (TS 31.102 4.2.16, never duplicated), an attach to a listed PLMN clears its entry first (successful manual selection, TS 23.122), and the 5GS location files are never touched.">Replays the write patterns a real phone performs on network-condition changes (EPS attach, service loss, roaming denial, 2G fallback, SMS delivery, cell-broadcast reconfiguration, AUTHENTICATE). Only UPDATE BINARY/RECORD, ENVELOPE and AUTHENTICATE are sent; FPLMN is appended only by the permanent "PLMN not allowed" rejection (TS 31.102 4.2.16, never duplicated), an attach to a listed PLMN clears its entry first (successful manual selection, TS 23.122), and the 5GS location files are never touched.</div>
|
||
<div class="flex flex-wrap gap-1.5 mb-2">
|
||
<button data-needs="card" onclick="netSimRun('cold_boot')" class="px-2.5 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Cold boot">Cold boot</button>
|
||
<button data-needs="card" onclick="netSimRun('attach_eps')" class="px-2.5 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="EPS attach">EPS attach</button>
|
||
<button data-needs="card" onclick="netSimRun('attach_2g')" class="px-2.5 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="2G attach">2G attach</button>
|
||
<button data-needs="card" onclick="netSimRun('service_lost')" class="px-2.5 py-1 text-xs rounded bg-red-600 text-white hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Service lost">Service lost</button>
|
||
<button data-needs="card" onclick="netSimRun('limited_service')" class="px-2.5 py-1 text-xs rounded bg-amber-600 text-white hover:bg-amber-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Limited service">Limited service</button>
|
||
<button data-needs="card" onclick="netSimRun('roaming_denied')" class="px-2.5 py-1 text-xs rounded bg-amber-600 text-white hover:bg-amber-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Roaming denied">Roaming denied</button>
|
||
<button data-needs="card" onclick="netSimRun('churn')" class="px-2.5 py-1 text-xs rounded bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Churn">Churn</button>
|
||
<button data-needs="card" onclick="netSimRun('sms_received')" class="px-2.5 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="SMS received">SMS received</button>
|
||
<button data-needs="card" onclick="netSimRun('cb_reconfig')" class="px-2.5 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="CB reconfig">CB reconfig</button>
|
||
<button data-needs="card" onclick="netSimRun('authenticate')" class="px-2.5 py-1 text-xs rounded bg-slate-600 text-white hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="AUTHENTICATE">AUTHENTICATE</button>
|
||
</div>
|
||
<details id="netsim-params" class="mb-2">
|
||
<summary class="cursor-pointer select-none text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Parameters">Parameters</summary>
|
||
<div class="mt-2 space-y-2">
|
||
<div class="flex flex-wrap items-end gap-2">
|
||
<div class="flex-1 min-w-[14rem]">
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Operator search (country / brand / MCC-MNC)">Operator search (country / brand / MCC-MNC)</label>
|
||
<input id="netsim-op-search" oninput="netSimSearchInput()" placeholder="Telekom / Germany / 26201" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="MCC">MCC</label>
|
||
<input id="netsim-mcc" value="001" maxlength="3" class="w-16 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="MNC">MNC</label>
|
||
<input id="netsim-mnc" value="01" maxlength="3" class="w-16 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
|
||
</div>
|
||
<button data-needs="server" onclick="netSimRandomOperator()" class="px-2 py-1 text-xs 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" data-l10n="Random roaming operator">Random roaming operator</button>
|
||
<button data-needs="card" onclick="netSimHomeOperator()" class="px-2 py-1 text-xs 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" data-l10n="Home network">Home network</button>
|
||
</div>
|
||
<div class="text-xs text-gray-400 dark:text-slate-500" data-l10n="Sets the PLMN written to LOCI/PSLOCI/EPSLOCI. Used by EPS attach, 2G attach and SMS received (real LAI/RAI/TAI; SMS only when its location rewrite is enabled), and by Cold boot, Service lost, Limited service and Roaming denied (the dummy LOCI/PSLOCI keep this PLMN, the EPSLOCI dummy wipes the TAI PLMN). Churn, CB reconfig and AUTHENTICATE don't use it.">Sets the PLMN written to LOCI/PSLOCI/EPSLOCI. Used by EPS attach, 2G attach and SMS received (real LAI/RAI/TAI; SMS only when its location rewrite is enabled), and by Cold boot, Service lost, Limited service and Roaming denied (the dummy LOCI/PSLOCI keep this PLMN, the EPSLOCI dummy wipes the TAI PLMN). Churn, CB reconfig and AUTHENTICATE don't use it.</div>
|
||
<div id="netsim-op-results" class="text-xs font-mono max-h-32 overflow-auto"></div>
|
||
<div class="flex flex-wrap items-end gap-2">
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="LAC">LAC</label>
|
||
<input id="netsim-lac" maxlength="4" placeholder="random" class="w-20 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
|
||
</div>
|
||
<button onclick="netSimDice('netsim-lac', 2)" class="px-1.5 py-1 text-xs rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" title="random">🎲</button>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Cell ID">Cell ID</label>
|
||
<input id="netsim-cell" maxlength="4" placeholder="random" class="w-20 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
|
||
</div>
|
||
<button onclick="netSimDice('netsim-cell', 2)" class="px-1.5 py-1 text-xs rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" title="random">🎲</button>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="TAC">TAC</label>
|
||
<input id="netsim-tac" maxlength="4" placeholder="= LAC" class="w-20 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
|
||
</div>
|
||
<button onclick="netSimDice('netsim-tac', 2)" class="px-1.5 py-1 text-xs rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" title="random">🎲</button>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="RAC">RAC</label>
|
||
<input id="netsim-rac" maxlength="2" placeholder="random" class="w-14 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
|
||
</div>
|
||
<button onclick="netSimDice('netsim-rac', 1)" class="px-1.5 py-1 text-xs rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" title="random">🎲</button>
|
||
</div>
|
||
<details>
|
||
<summary class="cursor-pointer select-none text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Advanced identity (empty = random)">Advanced identity (empty = random)</summary>
|
||
<div class="mt-2 flex flex-wrap items-end gap-2">
|
||
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">TMSI</label><input id="netsim-tmsi" maxlength="8" class="w-24 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
|
||
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">GUTI</label><input id="netsim-guti" maxlength="24" class="w-56 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
|
||
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">KSI</label><input id="netsim-ksi" maxlength="2" class="w-12 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
|
||
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">KASME</label><input id="netsim-kasme" maxlength="64" class="w-72 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
|
||
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">Kc</label><input id="netsim-kc" maxlength="16" class="w-32 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
|
||
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">UL</label><input id="netsim-ul" maxlength="8" class="w-20 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
|
||
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">DL</label><input id="netsim-dl" maxlength="8" class="w-20 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
|
||
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Algorithm">Algorithm</label><input id="netsim-algo" maxlength="2" class="w-12 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
|
||
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">RAND</label><input id="netsim-rand" maxlength="32" class="w-64 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
|
||
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">AUTN</label><input id="netsim-autn" maxlength="32" class="w-64 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
|
||
</div>
|
||
</details>
|
||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600 dark:text-slate-300">
|
||
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-send-event" checked class="rounded"> <span data-l10n="Send Location status event (if subscribed)">Send Location status event (if subscribed)</span></label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-dummy-locations" checked class="rounded"> <span data-l10n="Dummy location files">Dummy location files</span></label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-invalidate-epsnsc" checked class="rounded"> <span data-l10n="Invalidate EPSNSC">Invalidate EPSNSC</span></label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-keep-kasme" checked class="rounded"> <span data-l10n="Keep KASME (KSI 07 only)">Keep KASME (KSI 07 only)</span></label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-write-kc" checked class="rounded"> <span data-l10n="Write/invalidate Kc">Write/invalidate Kc</span></label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-write-fplmn" checked class="rounded"> <span data-l10n="Rejection: write FPLMN">Rejection: write FPLMN</span></label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-sms-location" checked class="rounded"> <span data-l10n="SMS: rewrite location too">SMS: rewrite location too</span></label>
|
||
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-cb-clear" class="rounded"> <span data-l10n="CB: clear lists instead of writing IDs">CB: clear lists instead of writing IDs</span></label>
|
||
<label class="flex items-center gap-1"><span data-l10n="Churn count">Churn count</span><input id="netsim-churn-count" type="number" value="3" min="1" max="20" class="w-14 border border-gray-300 dark:border-slate-600 text-xs rounded px-1 py-0.5 dark:bg-slate-800"></label>
|
||
<label class="flex items-center gap-1"><span data-l10n="Delay ms">Delay ms</span><input id="netsim-churn-delay" type="number" value="150" min="0" max="5000" class="w-20 border border-gray-300 dark:border-slate-600 text-xs rounded px-1 py-0.5 dark:bg-slate-800"></label>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
<div id="netsim-status" class="text-xs text-gray-400 dark:text-slate-500 mb-1"></div>
|
||
<div id="netsim-log" class="text-xs font-mono text-gray-600 dark:text-slate-400 max-h-[30vh] overflow-auto"></div>
|
||
</fieldset>
|
||
<fieldset class="w-72 shrink-0 border border-gray-200 dark:border-slate-700 rounded p-3">
|
||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Network state">Network state</legend>
|
||
<div class="flex items-center justify-between gap-2 mb-1">
|
||
<span id="netstate-badge" class="text-sm font-semibold text-gray-400 dark:text-slate-500"></span>
|
||
<button id="netstate-refresh-btn" data-needs="card" onclick="netStateRefresh()" class="px-2 py-0.5 text-xs 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" data-l10n="Refresh">Refresh</button>
|
||
</div>
|
||
<div id="netstate-location" class="text-xs font-mono text-gray-600 dark:text-slate-400"></div>
|
||
<div class="text-xs text-gray-400 dark:text-slate-500 mt-1"><span data-l10n="Last refresh">Last refresh</span>: <span id="netstate-read"></span></div>
|
||
<div id="netstate-body" class="mt-2 text-xs font-mono text-gray-600 dark:text-slate-400 max-h-[45vh] overflow-auto"></div>
|
||
</fieldset>
|
||
</div>
|
||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 mt-4">
|
||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Fetched proactive commands:">Fetched proactive commands:</legend>
|
||
<div id="pysim-proactive-log" class="text-sm font-mono text-gray-600 dark:text-slate-400"></div>
|
||
</fieldset>
|
||
</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 class="text-xs text-gray-400 dark:text-slate-500 mb-1" data-l10n="The date/time qualifier is answered from the host clock while its entry is empty.">The date/time qualifier is answered from the host clock while its entry is empty.</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 id="phone-sub-bip" class="hidden">
|
||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3">
|
||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400"><span data-l10n="BIP terminal">BIP terminal</span> <span id="bip-state" class="text-xs text-gray-400"></span></legend>
|
||
<div class="flex flex-wrap items-end gap-3">
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Mode">Mode</label>
|
||
<select id="bip-mode" onchange="bipModeChanged()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
<option value="sink" data-l10n="Local sink (log only)" selected>Local sink (log only)</option>
|
||
<option value="passthru" data-l10n="Card's destination (OPEN CHANNEL)">Card's destination (OPEN CHANNEL)</option>
|
||
<option value="redirect" data-l10n="Fixed target">Fixed target</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Host">Host</label>
|
||
<input id="bip-host" value="127.0.0.1" class="w-36 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Port">Port</label>
|
||
<input id="bip-port" placeholder="ephemeral" class="w-24 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<button id="bip-start-btn" data-needs="server" onclick="bipStart()" class="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Start">Start</button>
|
||
<button id="bip-stop-btn" data-needs="server" onclick="bipStop()" class="px-3 py-1.5 text-sm rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Stop">Stop</button>
|
||
</div>
|
||
<div id="bip-msg" class="text-xs mt-2 hidden"></div>
|
||
<div id="bip-sink-note" class="mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="Local sink: accepts the card's BIP channels on this Host and Port, logs everything received and never sends anything back. Leave the port empty for an ephemeral port (the bound one appears in the status). Use it for cards that open a TCP connection just to upload data.">Local sink: accepts the card's BIP channels on this Host and Port, logs everything received and never sends anything back. Leave the port empty for an ephemeral port (the bound one appears in the status). Use it for cards that open a TCP connection just to upload data.</div>
|
||
<div id="bip-passthru-note" class="hidden mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="Card's destination: each BIP channel is connected to the destination the card requests in OPEN CHANNEL (Other address + transport port, TCP client only); Host and Port are not used and the server's network is used (lab only).">Card's destination: each BIP channel is connected to the destination the card requests in OPEN CHANNEL (Other address + transport port, TCP client only); Host and Port are not used and the server's network is used (lab only).</div>
|
||
<div id="bip-redirect-note" class="hidden mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="Fixed target: every BIP channel is connected to this Host:Port; the address the card requests is only logged.">Fixed target: every BIP channel is connected to this Host:Port; the address the card requests is only logged.</div>
|
||
<div class="mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="One BIP session at a time: starting here replaces a running SCP81 listener, and starting the SCP81 listener replaces this session.">One BIP session at a time: starting here replaces a running SCP81 listener, and starting the SCP81 listener replaces this session.</div>
|
||
</fieldset>
|
||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3">
|
||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Channels">Channels</legend>
|
||
<div id="bip-channels" class="text-xs font-mono text-gray-600 dark:text-slate-400"></div>
|
||
</fieldset>
|
||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3">
|
||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="BIP log">BIP log</legend>
|
||
<div class="flex justify-end mb-1">
|
||
<button data-needs="server" onclick="bipLogClear()" class="px-2.5 py-1 text-xs 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" data-l10n="Clear">Clear</button>
|
||
</div>
|
||
<div id="bip-log" class="text-sm font-mono text-gray-600 dark:text-slate-400 max-h-[55vh] overflow-auto"></div>
|
||
</fieldset>
|
||
</div>
|
||
<div id="phone-sub-esim" class="hidden">
|
||
<div id="esim-not-euicc" class="text-sm text-gray-500 dark:text-slate-400 hidden" data-l10n="The equipped card is not an eUICC">The equipped card is not an eUICC</div>
|
||
<div id="esim-body" class="space-y-4">
|
||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3">
|
||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Chip">Chip</legend>
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<button id="esim-refresh-btn" data-needs="card" onclick="esimFetchAll()" 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" data-l10n="Refresh">Refresh</button>
|
||
<span id="esim-status" class="text-xs text-gray-500 dark:text-slate-400"></span>
|
||
</div>
|
||
<div id="esim-chip" class="text-xs font-mono text-gray-600 dark:text-slate-400"></div>
|
||
</fieldset>
|
||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3">
|
||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Profiles">Profiles</legend>
|
||
<div id="esim-profiles" class="text-xs"></div>
|
||
</fieldset>
|
||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3">
|
||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Notifications">Notifications</legend>
|
||
<div id="esim-notifications" class="text-xs"></div>
|
||
</fieldset>
|
||
</div>
|
||
</div>
|
||
<div id="phone-sub-test" class="hidden">
|
||
<div id="test-run-banner" class="hidden mb-3 px-3 py-2 rounded border border-amber-400 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 text-sm text-amber-700 dark:text-amber-300"></div>
|
||
<div class="flex flex-wrap items-end gap-2 mb-3">
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Test script">Test script</label>
|
||
<select id="test-script-sel" onchange="testSelectChanged()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800"></select>
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Template">Template</label>
|
||
<select id="test-template" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
<option value="empty" data-l10n="Empty">Empty</option>
|
||
<option value="menu" data-l10n="STK menu browsing">STK menu browsing</option>
|
||
<option value="scp80" data-l10n="Applet via SCP80">Applet via SCP80</option>
|
||
</select>
|
||
</div>
|
||
<button onclick="testNew()" class="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="New">New</button>
|
||
<button onclick="testClone()" class="px-3 py-1.5 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" data-l10n="Clone">Clone</button>
|
||
<button onclick="testDelete()" class="px-3 py-1.5 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" data-l10n="Delete">Delete</button>
|
||
<button onclick="testExport()" class="px-3 py-1.5 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" data-l10n="Export">Export</button>
|
||
<button onclick="testImportOpen()" class="px-3 py-1.5 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" data-l10n="Import">Import</button>
|
||
<input type="file" id="test-import-file" accept=".json,application/json" class="hidden" onchange="testImportFile(this)">
|
||
<span id="test-io-status" class="text-xs text-gray-500 dark:text-slate-400 hidden"></span>
|
||
</div>
|
||
<div class="flex flex-wrap items-end gap-2 mb-3">
|
||
<div class="flex-1 min-w-[16rem]">
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Name">Name</label>
|
||
<input id="test-name" oninput="testNameChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<button id="test-run-btn" data-needs="card" onclick="testRunStart()" class="px-4 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Start">Start</button>
|
||
<button id="test-stop-btn" onclick="testStop()" class="hidden px-4 py-1.5 text-sm rounded bg-red-600 text-white hover:bg-red-700" data-l10n="Stop">Stop</button>
|
||
<button onclick="testClearReport()" class="px-3 py-1.5 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" data-l10n="Clear report">Clear report</button>
|
||
</div>
|
||
<div id="test-steps" class="mb-2 text-sm"></div>
|
||
<div class="flex gap-2 mb-3">
|
||
<button onclick="testAddStep('action')" class="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Add action">Add action</button>
|
||
<button onclick="testAddStep('expect')" class="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Add expectation">Add expectation</button>
|
||
</div>
|
||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3">
|
||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Run report">Run report</legend>
|
||
<div id="test-run-status" class="text-xs text-gray-500 dark:text-slate-400 mb-2" data-l10n="No run yet.">No run yet.</div>
|
||
<div id="test-run-steps" class="text-sm font-mono text-gray-600 dark:text-slate-400 max-h-[45vh] overflow-auto"></div>
|
||
</fieldset>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="tab-scp81" class="tab-content hidden">
|
||
<div class="flex flex-wrap justify-center gap-2 mb-3">
|
||
<button class="scp81-subtab px-4 py-1.5 text-sm rounded-full bg-blue-600 text-white" data-scp81-sub="listener" onclick="scp81SwitchSubtab('listener')" data-l10n="Listener">Listener</button>
|
||
<button class="scp81-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-scp81-sub="scripts" onclick="scp81SwitchSubtab('scripts')" data-l10n="Scripts">Scripts</button>
|
||
</div>
|
||
<div id="scp81-sub-listener">
|
||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3">
|
||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400"><span data-l10n="HTTP OTA listener">HTTP OTA listener</span> <span id="scp81-state" class="text-xs text-gray-400"></span></legend>
|
||
<div class="flex flex-wrap items-end gap-3">
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Mode">Mode</label>
|
||
<select id="scp81-mode" onchange="scp81ModeChanged()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
<option value="tls" data-l10n="PSK TLS server" selected>PSK TLS server</option>
|
||
<option value="redirect" data-l10n="Redirect to external server">Redirect to external server</option>
|
||
<option value="passthru" data-l10n="Pass-through (card destination)">Pass-through (card destination)</option>
|
||
<option value="dump" data-l10n="Capture (dump)">Capture (dump)</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Host">Host</label>
|
||
<input id="scp81-host" value="127.0.0.1" class="w-36 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Port">Port</label>
|
||
<input id="scp81-port" value="8443" class="w-24 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div id="scp81-script-row">
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Script">Script</label>
|
||
<select id="scp81-script" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800"></select>
|
||
</div>
|
||
<button id="scp81-start-btn" data-needs="server" onclick="scp81Start()" class="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Start">Start</button>
|
||
<button id="scp81-stop-btn" data-needs="server" onclick="scp81Stop()" class="px-3 py-1.5 text-sm rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Stop">Stop</button>
|
||
<button id="scp81-restart-btn" data-needs="server" onclick="scp81RestartScript()" class="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Restart script">Restart script</button>
|
||
</div>
|
||
<div id="scp81-msg" class="text-xs mt-2 hidden"></div>
|
||
<div id="scp81-psk-note" class="mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="PSK keys come from the card presets (Cards tab): the listener picks the key by the identity the card sends in the TLS handshake.">PSK keys come from the card presets (Cards tab): the listener picks the key by the identity the card sends in the TLS handshake.</div>
|
||
<div id="scp81-redirect-note" class="hidden mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="Redirect: every BIP channel the card opens is connected to this Host:Port (the external HTTP OTA platform); TLS is terminated there, and the address the card requests is only logged.">Redirect: every BIP channel the card opens is connected to this Host:Port (the external HTTP OTA platform); TLS is terminated there, and the address the card requests is only logged.</div>
|
||
<div id="scp81-passthru-note" class="hidden mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="Pass-through: the terminal connects each BIP channel to the destination the card requests in OPEN CHANNEL (Other address + transport port, TCP client only); Host and Port above are not used, TLS is terminated by that platform, and the server's network is used (lab only).">Pass-through: the terminal connects each BIP channel to the destination the card requests in OPEN CHANNEL (Other address + transport port, TCP client only); Host and Port above are not used, TLS is terminated by that platform, and the server's network is used (lab only).</div>
|
||
</fieldset>
|
||
<details id="scp81-opts" class="border border-gray-200 dark:border-slate-700 rounded mb-3">
|
||
<summary class="w-fit list-inside relative -top-2 -mb-1 mx-3 px-1 cursor-pointer select-none text-xs font-medium text-gray-500 dark:text-slate-400 bg-neutral-50 dark:bg-slate-900">
|
||
<span data-l10n="Options (applied at Start)">Options (applied at Start)</span>
|
||
<span id="scp81-opts-badge" class="ml-1 text-xs text-amber-500 dark:text-amber-400"></span>
|
||
</summary>
|
||
<div class="px-3 pb-3">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<button onclick="scp81OptionsReset()" class="ml-auto px-2 py-0.5 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="Reset to defaults">Reset to defaults</button>
|
||
</div>
|
||
<div id="scp81-opts-http" class="space-y-2">
|
||
<div class="text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="HTTP framing">HTTP framing</div>
|
||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer"><input type="checkbox" id="opt-chunked" onchange="scp81OptionsPersist()" class="rounded"> <span data-l10n="Chunked body (Transfer-Encoding: chunked)">Chunked body (Transfer-Encoding: chunked)</span></label>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<label class="text-xs text-gray-600 dark:text-slate-400" data-l10n="Chunk size (bytes, 0 = one TLS record)">Chunk size (bytes, 0 = one TLS record)</label>
|
||
<input id="opt-chunk-size" type="number" min="0" max="4096" onchange="scp81OptionsPersist()" class="w-24 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<label class="text-xs text-gray-600 dark:text-slate-400" data-l10n="Connection header">Connection header</label>
|
||
<select id="opt-conn-header" onchange="scp81OptionsPersist()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
|
||
<option value="none" data-l10n="omit (implicit keep-alive)">omit (implicit keep-alive)</option>
|
||
<option value="keep-alive">keep-alive</option>
|
||
</select>
|
||
</div>
|
||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer"><input type="checkbox" id="opt-compact" onchange="scp81OptionsPersist()" class="rounded"> <span data-l10n="Compact headers (omit the optional space after ':')">Compact headers (omit the optional space after ':')</span></label>
|
||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer"><input type="checkbox" id="opt-next-uri" onchange="scp81NextUriToggle()" class="rounded"> <span data-l10n="Send X-Admin-Next-URI (required to continue the session)">Send X-Admin-Next-URI (required to continue the session)</span></label>
|
||
<input id="opt-next-uri-value" onchange="scp81OptionsPersist()" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800" placeholder="/api/scp81?req=%d">
|
||
<div class="text-xs text-gray-400 dark:text-slate-500" data-l10n="Unchecked = one-shot: per GP Am. B §4.4.2 the card executes the command, returns no response string and closes the session.">Unchecked = one-shot: per GP Am. B §4.4.2 the card executes the command, returns no response string and closes the session.</div>
|
||
</div>
|
||
<div id="scp81-opts-script" class="space-y-2 mt-3">
|
||
<div class="text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Script framing">Script framing</div>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<select id="opt-script-template" onchange="scp81OptionsPersist()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
|
||
<option value="indefinite" data-l10n="Indefinite (AE 80 … 00 00)">Indefinite (AE 80 … 00 00)</option>
|
||
<option value="definite" data-l10n="Definite (AA)">Definite (AA)</option>
|
||
</select>
|
||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer"><input type="checkbox" id="opt-cr-tag" onchange="scp81OptionsPersist()" class="rounded"> <span data-l10n="Comprehension-required tags">Comprehension-required tags</span></label>
|
||
</div>
|
||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer"><input type="checkbox" id="opt-targeted-app-on" onchange="scp81TargetedAppToggle()" class="rounded"> <span data-l10n="Send X-Admin-Targeted-Application">Send X-Admin-Targeted-Application</span></label>
|
||
<input id="opt-targeted-app" onchange="scp81OptionsPersist()" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800" placeholder="//aid/A000000151000000">
|
||
</div>
|
||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer mt-3"><input type="checkbox" id="opt-link-events" onchange="scp81OptionsPersist()" class="rounded"> <span data-l10n="Show link events (Channel status ENVELOPEs, TS 102 223 7.5.11)">Show link events (Channel status ENVELOPEs, TS 102 223 7.5.11)</span></label>
|
||
</div>
|
||
</details>
|
||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3">
|
||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400"><span data-l10n="Script results (R-APDUs)">Script results (R-APDUs)</span> <span id="scp81-results-state" class="text-xs text-gray-400"></span></legend>
|
||
<div id="scp81-results" class="text-sm font-mono text-gray-600 dark:text-slate-400 max-h-[35vh] overflow-auto"></div>
|
||
</fieldset>
|
||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3">
|
||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="HTTP OTA log">HTTP OTA log</legend>
|
||
<div class="flex justify-end mb-1">
|
||
<button data-needs="server" onclick="scp81LogClear()" class="px-2.5 py-1 text-xs 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" data-l10n="Clear">Clear</button>
|
||
</div>
|
||
<div id="scp81-log" class="text-sm font-mono text-gray-600 dark:text-slate-400 max-h-[55vh] overflow-auto"></div>
|
||
</fieldset>
|
||
</div>
|
||
<div id="scp81-sub-scripts" class="hidden">
|
||
<div class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3">
|
||
<div class="flex flex-wrap items-end gap-3">
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="New script from">New script from</label>
|
||
<select id="scripts-template" onchange="scriptsTemplateChanged()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
<option value="empty" data-l10n="Empty">Empty</option>
|
||
<option value="explore" data-l10n="Explore ISD">Explore ISD</option>
|
||
<option value="install" data-l10n="Install from .cap">Install from .cap</option>
|
||
<option value="delete" data-l10n="Delete AID">Delete AID</option>
|
||
</select>
|
||
</div>
|
||
<button id="scripts-new-btn" onclick="scriptsNew()" class="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="New">New</button>
|
||
<span class="text-xs text-gray-500 dark:text-slate-400" data-l10n="Scripts are stored locally in your browser and sent to the server when a listener starts.">Scripts are stored locally in your browser and sent to the server when a listener starts.</span>
|
||
</div>
|
||
<div id="scripts-install-params" class="hidden mt-3 border-t border-gray-200 dark:border-slate-700 pt-3">
|
||
<div class="flex flex-wrap items-end gap-3">
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="CAP file">CAP file</label>
|
||
<input type="file" id="scripts-cap-file" accept=".cap,.zip" onchange="scriptsCapFileChanged()" class="text-xs text-gray-600 dark:text-slate-300">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="SD AID (empty = ISD)">SD AID (empty = ISD)</label>
|
||
<input id="scripts-cap-sd" placeholder="A000000003000000" maxlength="32" class="w-56 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Install parameters (hex, optional)">Install parameters (hex, optional)</label>
|
||
<input id="scripts-cap-params" placeholder="C900" class="w-40 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="STK parameters (hex, optional)">STK parameters (hex, optional)</label>
|
||
<input id="scripts-cap-stk" placeholder="CA TLV" class="w-40 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
</div>
|
||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-400"><input type="checkbox" id="scripts-cap-makesel" checked> <span data-l10n="Make selectable">Make selectable</span></label>
|
||
<button data-needs="server" data-cap-gate="scripts" onclick="scriptsGenerateInstall()" class="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Generate">Generate</button>
|
||
</div>
|
||
<fieldset id="scripts-cap-info" class="hidden mt-2 border border-gray-200 dark:border-slate-700 rounded p-3 text-xs text-gray-500 dark:text-slate-400">
|
||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="CAP details">CAP details</legend>
|
||
<div id="scripts-cap-body"></div>
|
||
</fieldset>
|
||
<div class="text-xs text-gray-500 dark:text-slate-400 mt-2" data-l10n="The .cap is only used to generate the APDUs: the script stores the APDU list, not the file.">The .cap is only used to generate the APDUs: the script stores the APDU list, not the file.</div>
|
||
</div>
|
||
<div id="scripts-delete-params" class="hidden mt-3 border-t border-gray-200 dark:border-slate-700 pt-3">
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="AIDs to delete (one per line)">AIDs to delete (one per line)</label>
|
||
<textarea id="scripts-delete-aids" 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="A000000003000000"></textarea>
|
||
<div class="flex flex-wrap items-end gap-3 mt-2">
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Delete parameters (P2)">Delete parameters (P2)</label>
|
||
<select id="scripts-delete-p2" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||
<option value="00" data-l10n="Object only">Object only</option>
|
||
<option value="80" data-l10n="Object and related objects">Object and related objects</option>
|
||
</select>
|
||
</div>
|
||
<button onclick="scriptsGenerateDelete()" class="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700" data-l10n="Generate">Generate</button>
|
||
</div>
|
||
</div>
|
||
<div id="scripts-msg" class="text-xs mt-2 hidden"></div>
|
||
</div>
|
||
<div id="scripts-editor" class="hidden border border-gray-200 dark:border-slate-700 rounded p-3 mb-3">
|
||
<div class="flex gap-2 mb-2">
|
||
<input id="scripts-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="Script name">
|
||
<button onclick="scriptsSave()" class="px-4 py-1.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 whitespace-nowrap" data-l10n="Save">Save</button>
|
||
<button onclick="scriptsCancel()" class="px-4 py-1.5 bg-gray-500 text-white text-sm font-medium rounded hover:bg-gray-600 whitespace-nowrap" data-l10n="Cancel">Cancel</button>
|
||
</div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="APDUs (one hex C-APDU per line)">APDUs (one hex C-APDU per line)</label>
|
||
<textarea id="scripts-apdus" rows="10" 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="80CAFF2100"></textarea>
|
||
</div>
|
||
<div class="border border-gray-200 dark:border-slate-700 rounded p-3">
|
||
<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="Kind">Kind</th>
|
||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="APDUs">APDUs</th>
|
||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="Created">Created</th>
|
||
<th class="border-b border-gray-200 dark:border-slate-700"></th>
|
||
</tr></thead>
|
||
<tbody id="scripts-tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<div id="tab-cards" class="tab-content hidden">
|
||
<p class="text-xs text-gray-500 dark:text-slate-400 mb-3" data-l10n="Card presets are stored on the server. JSON export/import buttons are below the card list; a preset carries several keysets, each with its own number and counter.">Card presets are stored on the server. JSON export/import buttons are below the card list; a preset carries several keysets, each with its own number and counter.</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 id="cards-iccid-from-card" data-needs="card-iccid" onclick="cardsIccidFromCard()" class="px-3 py-1.5 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 whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="From card">From card</button>
|
||
<input id="cards-adm" class="w-40 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono" placeholder="ADM (optional)">
|
||
<button id="cards-add-btn" 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>
|
||
<button id="cards-cancel-btn" onclick="cardsCancelEdit()" class="hidden px-4 py-2.5 bg-gray-500 text-white text-sm font-medium rounded hover:bg-gray-600 whitespace-nowrap" data-l10n="Cancel">Cancel</button>
|
||
</div>
|
||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3">
|
||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="SCP80 (GSM 03.48, ETSI TS 102 225)">SCP80 (GSM 03.48, ETSI TS 102 225)</legend>
|
||
<div class="flex gap-2 mb-2 flex-wrap items-end">
|
||
<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">
|
||
<div class="flex gap-2">
|
||
<div>
|
||
<label for="cards-tar" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400">ISD TAR</label>
|
||
<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" maxlength="6" value="000000">
|
||
</div>
|
||
<div>
|
||
<label for="cards-uicc-tar" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400">UICC RFM TAR</label>
|
||
<input id="cards-uicc-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" maxlength="6" value="B00000">
|
||
</div>
|
||
<div>
|
||
<label for="cards-usim-tar" class="block mb-0.5 text-xs text-gray-600 dark:text-slate-400">ADF RFM TAR</label>
|
||
<input id="cards-usim-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" maxlength="6" value="B00001">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mb-1 flex items-center justify-between">
|
||
<span class="text-xs text-gray-600 dark:text-slate-400" data-l10n="OTA keysets — each with its own number and counter">OTA keysets — each with its own number and counter</span>
|
||
<button type="button" onclick="cardsKeysetAdd()" class="px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700" data-l10n="Add keyset">Add keyset</button>
|
||
</div>
|
||
<div id="cards-keysets"></div>
|
||
<p class="text-xs text-gray-500 dark:text-slate-400" data-l10n="KIc and KID must carry the same keyset number (their high nibble) in the range 01-0F — TS 102 225 A.2; each keyset has its own counter on the card (Annex A.1).">KIc and KID must carry the same keyset number (their high nibble) in the range 01-0F — TS 102 225 A.2; each keyset has its own counter on the card (Annex A.1).</p>
|
||
</fieldset>
|
||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3">
|
||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="SCP81 (HTTP OTA)">SCP81 (HTTP OTA)</legend>
|
||
<div class="flex gap-2 mb-2">
|
||
<input id="cards-psk-id" 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="PSK identity, SCP81 HTTP OTA (e.g. 89012345678901234567)">
|
||
<input id="cards-psk-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="PSK key (hex, 32 chars), SCP81 HTTP OTA">
|
||
</div>
|
||
<p class="text-xs text-gray-500 dark:text-slate-400" data-l10n="SCP81 HTTP OTA: the listener picks the key by the PSK identity the card sends in the TLS handshake. Cards without a PSK are ignored by the listener.">SCP81 HTTP OTA: the listener picks the key by the PSK identity the card sends in the TLS handshake. Cards without a PSK are ignored by the listener.</p>
|
||
</fieldset>
|
||
<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" data-l10n="Keysets (number: KIc/KID — counter)">Keysets (number: KIc/KID \u2014 counter)</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 (ISD/UICC RFM/ADF RFM)</th>
|
||
<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700" data-l10n="SCP81">SCP81</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="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>
|
||
</div>
|
||
<div id="cards-io" class="hidden mt-2 text-xs text-gray-500 dark:text-slate-400"></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="test-step-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-2xl w-full mx-4 max-h-[85vh] overflow-auto">
|
||
<h3 id="test-step-title" class="text-lg font-semibold mb-4 text-gray-800 dark:text-slate-200"></h3>
|
||
<div id="test-step-body" class="space-y-3"></div>
|
||
<div id="test-step-error" class="text-xs text-red-500 hidden mt-2"></div>
|
||
<div class="flex gap-2 mt-4 justify-end">
|
||
<button onclick="testStepCancel()" 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="testStepSave()" class="px-4 py-1.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Save">Save</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="tp-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-3xl 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="TERMINAL PROFILE">TERMINAL PROFILE</h3>
|
||
<div class="flex gap-3 items-start mb-3">
|
||
<div class="flex-1 min-w-0">
|
||
<div class="mb-3">
|
||
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Preset (device model)">Preset (device model)</label>
|
||
<select id="tp-preset" onchange="tpPresetChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800"></select>
|
||
</div>
|
||
<div>
|
||
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Profile (hex)">Profile (hex)</label>
|
||
<input id="tp-hex" oninput="tpHexInput()" spellcheck="false" 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">
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-2 shrink-0 mt-5">
|
||
<button onclick="tpClose()" class="px-3 py-1.5 text-sm 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="tp-apply-btn" onclick="tpApply()" class="px-4 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700" data-l10n="Apply">Apply</button>
|
||
</div>
|
||
</div>
|
||
<div id="tp-error" class="hidden text-xs text-red-500 mb-2"></div>
|
||
<div class="text-xs text-gray-500 dark:text-slate-400 mb-2" data-l10n="Bits are decoded per TS 102 223 5.2; the hex field is authoritative and bytes/bits outside the table are preserved as-is.">Bits are decoded per TS 102 223 5.2; the hex field is authoritative and bytes/bits outside the table are preserved as-is.</div>
|
||
<div id="tp-form" class="max-h-[45vh] overflow-auto border border-gray-200 dark:border-slate-700 rounded p-2 text-xs font-mono text-gray-600 dark:text-slate-400"></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 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="stkMenuOk()" class="px-4 py-1 text-xs rounded-full bg-blue-600 text-white hover:bg-blue-700" data-l10n="OK">OK</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="ram-op-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-4 max-w-3xl w-full max-h-[85vh] flex flex-col">
|
||
<div class="flex justify-between items-center mb-2 gap-3">
|
||
<span id="ram-op-title" class="font-bold text-sm flex-1"></span>
|
||
<button id="ram-op-close" onclick="ramOpModalClose()" disabled 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 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Close">Close</button>
|
||
</div>
|
||
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-slate-400">
|
||
<div id="ram-op-spin" class="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
|
||
<span id="ram-op-status" data-l10n="Working...">Working...</span>
|
||
</div>
|
||
<div class="mt-2 w-full bg-gray-200 dark:bg-slate-700 rounded h-1.5">
|
||
<div id="ram-op-bar" class="bg-blue-600 dark:bg-blue-500 h-1.5 rounded" style="width:0%"></div>
|
||
</div>
|
||
<div id="ram-op-steps" class="mt-2 text-xs font-mono whitespace-pre-wrap overflow-auto text-gray-700 dark:text-slate-300"></div>
|
||
<div class="mt-2 text-xs text-amber-600 dark:text-amber-400" id="ram-op-warn" data-l10n="Do not close or reload this window while the operation is running.">Do not close or reload this window while the operation is running.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ===== Version =====
|
||
// Single source of truth for the PWA version: shown in the header and used
|
||
// by the server version check in pysimConnect().
|
||
const SIMPLE_VERSION = '3.9.0';
|
||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||
|
||
// ===== Tab switching =====
|
||
function switchTab(name) {
|
||
if (_scp81Timer && name !== 'scp81') { clearInterval(_scp81Timer); _scp81Timer = null; }
|
||
if (_bipTimer && name !== 'phone') { clearInterval(_bipTimer); _bipTimer = null; }
|
||
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||
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 === 'cards') { cardsRender(); cardsFetch(); setHelpAnchor('cards'); }
|
||
else if (name === 'pysim') { setHelpAnchor(pysimHelpAnchor); pysimFsFitTree(); }
|
||
else if (name === 'profiler') { profilerSetView('list'); setHelpAnchor('profiler'); }
|
||
else if (name === 'phone') phoneSwitchSubtab('phone');
|
||
else if (name === 'scp81') scp81Enter();
|
||
}
|
||
|
||
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-ram').classList.toggle('hidden', name !== 'ram');
|
||
document.getElementById('scp80-sub-tar').classList.toggle('hidden', name !== 'tar');
|
||
if (name === 'ram') ramRender();
|
||
if (name === 'tar') { tarRender(); tarProbeRender(); }
|
||
scp80HelpAnchor = { sp: 'secured-packet', ram: 'ram', tar: 'tar-probe' }[name] || 'secured-packet';
|
||
setHelpAnchor(scp80HelpAnchor);
|
||
}
|
||
|
||
// Top-level Remote APDU subtabs; the C-APDU and R-APDU parsers are nested
|
||
// panels behind the Parser pill (parserSwitchSubtab).
|
||
const C_APDU_SUBTABS = ['sim', 'usim', 'ram', 'ber', 'httpota', 'parser'];
|
||
const PARSER_HELP_ANCHORS = { parse: 'c-apdu-parser', response: 'response-parser' };
|
||
let _parserSub = 'parse';
|
||
|
||
function parserSwitchSubtab(name) {
|
||
_parserSub = (name === 'response') ? 'response' : 'parse';
|
||
[['parse', 'parser-btn-parse'], ['response', 'parser-btn-response']].forEach(pair => {
|
||
const btn = document.getElementById(pair[1]);
|
||
if (!btn) return;
|
||
btn.className = 'px-4 py-1.5 text-sm rounded-full ' + (pair[0] === _parserSub
|
||
? '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('c-apdu-sub-parse').classList.toggle('hidden', _parserSub !== 'parse');
|
||
document.getElementById('c-apdu-sub-response').classList.toggle('hidden', _parserSub !== 'response');
|
||
setHelpAnchor(PARSER_HELP_ANCHORS[_parserSub]);
|
||
}
|
||
|
||
function cApduSwitchSubtab(name) {
|
||
// Nested names select the Parser pill first, so callers can keep
|
||
// addressing the panels directly (e.g. after a successful send).
|
||
if (name === 'parse' || name === 'response') {
|
||
cApduSwitchSubtab('parser');
|
||
parserSwitchSubtab(name);
|
||
return;
|
||
}
|
||
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);
|
||
});
|
||
C_APDU_SUBTABS.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', httpota:'push-commands'}[name] || 'c-apdu');
|
||
if (name === 'parser') parserSwitchSubtab(_parserSub);
|
||
}
|
||
|
||
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', 'bip', 'esim', 'test'].forEach(s => {
|
||
document.getElementById('phone-sub-' + s).classList.toggle('hidden', s !== name);
|
||
});
|
||
if (name === 'tr') {
|
||
pysimPliRender();
|
||
} else if (name === 'bip') {
|
||
bipEnter();
|
||
} else if (name === 'esim') {
|
||
esimFetchAll();
|
||
} else if (name === 'test') {
|
||
testInit();
|
||
} else {
|
||
stkCheckMenu();
|
||
pysimEventsRender();
|
||
pysimProactiveLogRender();
|
||
netStateFetch();
|
||
pysimPollStatusInit();
|
||
tpRefresh();
|
||
}
|
||
setHelpAnchor({ phone: 'stk-menu', tr: 'pli-dict', bip: 'bip', esim: 'esim', test: 'test-script' }[name] || 'stk-menu');
|
||
}
|
||
|
||
// ===== eSIM / LPA (local ES10a/b/c operations) =====
|
||
let _esimBusy = false;
|
||
let _esimChip = null;
|
||
let _esimProfiles = [];
|
||
let _esimNotifications = [];
|
||
|
||
// Chip-info keys -> display labels (unknown keys fall back to a prettified name).
|
||
const ESIM_CHIP_LABELS = {
|
||
eid: 'EID', svn: 'SVN', profile_version: 'Profile version',
|
||
euicc_firmware_ver: 'Firmware version', ext_card_resource: 'Card resource',
|
||
installed_application: 'Installed applications',
|
||
free_non_volatile_memory: 'Free non-volatile memory',
|
||
free_volatile_memory: 'Free volatile memory',
|
||
uicc_capability: 'UICC capability', ts102241_version: 'TS 102 241 version',
|
||
globalplatform_version: 'GlobalPlatform version', rsp_capability: 'RSP capability',
|
||
euicc_category: 'Category', forbidden_profile_policy_rules: 'Forbidden PPRs',
|
||
pp_version: 'PP version', ss_acreditation_number: 'SS accreditation number',
|
||
certification_data_object: 'Certification data',
|
||
platform_label: 'Platform label', discovery_base_url: 'Discovery base URL',
|
||
tre_properties: 'TRE properties', tre_product_reference: 'TRE product reference',
|
||
additional_euicc_profile_package_versions: 'Additional profile package versions',
|
||
raw_tlvs: 'Raw TLVs',
|
||
default_dp_address: 'Default SM-DP+ address', root_ds_address: 'Root DS address',
|
||
euicc_ci_pki_list_for_verification: 'CI PKI (verification)',
|
||
euicc_ci_pki_list_for_signing: 'CI PKI (signing)',
|
||
subject_key_identifier: 'Subject key identifier',
|
||
rat: 'Rules authorisation table', ppr_ids: 'PPR IDs',
|
||
allowed_operators: 'Allowed operators', ppr_flags: 'PPR flags',
|
||
plmn: 'PLMN', gid1: 'GID1', gid2: 'GID2',
|
||
iot_specific_info: 'IoT specific info',
|
||
};
|
||
|
||
const ESIM_RESULT_KEYS = {
|
||
iccidOrAidNotFound: 'profile not found',
|
||
profileNotInDisabledState: 'profile is not disabled',
|
||
profileNotInEnabledState: 'profile is not enabled',
|
||
disallowedByPolicy: 'disallowed by policy',
|
||
wrongProfileReenabling: 'wrong profile re-enabling',
|
||
catBusy: 'card is busy with a CAT session',
|
||
undefinedError: 'undefined error',
|
||
};
|
||
|
||
function esimLabel(key) {
|
||
if (ESIM_CHIP_LABELS[key]) return ESIM_CHIP_LABELS[key];
|
||
return String(key).replace(/_/g, ' ').replace(/^./, c => c.toUpperCase());
|
||
}
|
||
|
||
function esimGroupEid(eid) {
|
||
const s = String(eid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
return s.replace(/(.{4})(?=.)/g, '$1 ');
|
||
}
|
||
|
||
// Chip-info value -> [label, value] rows; nested dicts recurse, arrays of
|
||
// scalars are joined, arrays of objects recurse with an index label, empty
|
||
// values skipped. Labels are mapped and translated per path component.
|
||
function esimFieldRows(obj, prefix) {
|
||
const rows = [];
|
||
if (obj === null || obj === undefined) return rows;
|
||
if (typeof obj !== 'object') {
|
||
rows.push([prefix || '', String(obj)]);
|
||
return rows;
|
||
}
|
||
for (const k of Object.keys(obj)) {
|
||
const v = obj[k];
|
||
if (v === null || v === undefined || v === '') continue;
|
||
const label = prefix ? prefix + ' / ' + t(esimLabel(k)) : t(esimLabel(k));
|
||
if (Array.isArray(v)) {
|
||
if (v.length && typeof v[0] === 'object') {
|
||
v.forEach((item, i) => {
|
||
rows.push(...esimFieldRows(item, label + ' ' + (i + 1)));
|
||
});
|
||
} else {
|
||
rows.push([label, v.map(x => String(x)).join(', ')]);
|
||
}
|
||
} else if (typeof v === 'object') {
|
||
rows.push(...esimFieldRows(v, label));
|
||
} else {
|
||
rows.push([label, String(v)]);
|
||
}
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function esimResultText(res) {
|
||
if (!res) return '';
|
||
if (res.error && !res.result) return String(res.error);
|
||
return t(ESIM_RESULT_KEYS[res.result] || res.message || res.result || 'Error');
|
||
}
|
||
|
||
// Status text after an Enable/Disable switch: the card result, the re-init and
|
||
// the verified resulting profile state.
|
||
function esimSwitchStatus(res) {
|
||
if (!res || !res.ok) return t('Profile switch failed') + ': ' + esimResultText(res);
|
||
let msg = t('Profile switched');
|
||
if (res.reinitialized) msg += ' — ' + t('card re-initialized');
|
||
if (res.refresh_seen) msg += ' (' + t('REFRESH received') + ')';
|
||
if (res.verified === false) {
|
||
msg += ' — ' + t('not confirmed');
|
||
if (res.state_after) msg += ' (' + esimStateLabel(res.state_after) + ')';
|
||
}
|
||
return msg;
|
||
}
|
||
|
||
function esimStateLabel(state) {
|
||
if (state === 'enabled') return t('Enabled');
|
||
if (state === 'disabled') return t('Disabled');
|
||
return state || '?';
|
||
}
|
||
|
||
function esimOperationsText(ops) {
|
||
return (ops || []).map(o => t(o)).join(', ');
|
||
}
|
||
|
||
function esimProfileRows(p) {
|
||
const rows = [];
|
||
if (p.iccid) rows.push([t('ICCID'), p.iccid]);
|
||
if (p.isdp_aid) rows.push([t('ISD-P AID'), p.isdp_aid]);
|
||
if (p.provider) rows.push([t('Provider'), p.provider]);
|
||
if (p.name) rows.push([t('Profile name'), p.name]);
|
||
if (p.class) rows.push([t('Class'), p.class]);
|
||
if (p.owner) rows.push([t('Owner'), p.owner]);
|
||
if (p.icon_type) {
|
||
let icon = p.icon_type;
|
||
if (p.icon_size) icon += ' · ' + p.icon_size + ' B';
|
||
rows.push([t('Icon'), icon]);
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
// Profile icon -> data URL at its natural size ('' when the card sent no
|
||
// image). The icon type is the SGP.22 iconType: png or jpg.
|
||
function esimIconDataUrl(p) {
|
||
if (!p || !p.icon || !p.icon_type) return '';
|
||
const hex = String(p.icon).replace(/[^0-9a-fA-F]/g, '');
|
||
let bin = '';
|
||
for (let i = 0; i + 1 < hex.length; i += 2) {
|
||
bin += String.fromCharCode(parseInt(hex.substr(i, 2), 16));
|
||
}
|
||
const mime = p.icon_type === 'jpg' ? 'image/jpeg' : 'image/' + p.icon_type;
|
||
return 'data:' + mime + ';base64,' + btoa(bin);
|
||
}
|
||
|
||
// One chip-info section -> rows (the RAT is a list of rules).
|
||
function esimChipSectionRows(data) {
|
||
if (!data) return [];
|
||
if (Array.isArray(data)) {
|
||
const rows = [];
|
||
data.forEach((item, i) => {
|
||
rows.push(...esimFieldRows(item, t('Rule') + ' ' + (i + 1)));
|
||
});
|
||
return rows;
|
||
}
|
||
return esimFieldRows(data, '');
|
||
}
|
||
|
||
// A chip-info row needs the full box width when its value is a list or a
|
||
// long token (hex identifiers, capability lists); short scalars share a row.
|
||
function esimChipValueWide(label, value) {
|
||
const v = String(value);
|
||
return v.length > 24 || v.indexOf(', ') >= 0;
|
||
}
|
||
|
||
// One bordered chip-info box; no rows -> no box. With `twoColumns` the short
|
||
// fields are laid out in a 2-column grid (single column on narrow screens)
|
||
// and long values span the full width, which keeps the big EUICCInfo2 box
|
||
// compact.
|
||
function esimChipBox(title, rows, twoColumns) {
|
||
if (!rows || !rows.length) return '';
|
||
let html = '<div class="border border-gray-200 dark:border-slate-700 rounded p-2 min-w-0">';
|
||
html += '<div class="mb-1 text-xs font-semibold text-gray-600 dark:text-slate-300">' + esc(title) + '</div>';
|
||
if (twoColumns) html += '<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">';
|
||
for (const [k, v] of rows) {
|
||
const cls = twoColumns ? 'flex gap-2 min-w-0' : 'flex gap-2 mb-0.5';
|
||
html += '<div class="' + cls +
|
||
(twoColumns && esimChipValueWide(k, v) ? ' sm:col-span-2' : '') + '">';
|
||
if (k) html += '<span class="w-36 shrink-0 text-right text-gray-500 dark:text-slate-400 break-words">' + esc(k) + '</span>';
|
||
html += '<span class="font-mono break-words min-w-0">' + esc(v) + '</span></div>';
|
||
}
|
||
if (twoColumns) html += '</div>';
|
||
return html + '</div>';
|
||
}
|
||
|
||
function esimRenderChip() {
|
||
const el = document.getElementById('esim-chip');
|
||
if (!el) return;
|
||
const c = _esimChip || {};
|
||
const left = [
|
||
c.eid ? esimChipBox('EID', [['', esimGroupEid(c.eid)]]) : '',
|
||
esimChipBox('EUICCInfo1', esimChipSectionRows(c.info1)),
|
||
esimChipBox(t('Addresses'), esimChipSectionRows(c.addresses)),
|
||
esimChipBox(t('Rules authorisation table'), esimChipSectionRows(c.rat)),
|
||
].filter(Boolean);
|
||
const right = esimChipBox('EUICCInfo2', esimChipSectionRows(c.info2), true);
|
||
if (!left.length && !right) {
|
||
el.innerHTML = '<span class="text-gray-400">' + esc(t('No data')) + '</span>';
|
||
return;
|
||
}
|
||
let html = '<div class="grid grid-cols-1 lg:grid-cols-3 gap-3 items-start">';
|
||
html += '<div class="' + (right ? 'lg:col-span-1' : 'lg:col-span-3') + ' space-y-3 min-w-0">' +
|
||
left.join('') + '</div>';
|
||
if (right) html += '<div class="lg:col-span-2 min-w-0">' + right + '</div>';
|
||
html += '</div>';
|
||
const errs = Object.keys(c.errors || {});
|
||
if (errs.length) {
|
||
html += '<div class="text-xs text-yellow-600 dark:text-yellow-500 mt-1">' + esc(t('Not available')) + ': ' + esc(errs.join(', ')) + '</div>';
|
||
}
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
// Profile card title: the provider first (when known), then the nickname or
|
||
// the profile name; falls back to the ICCID.
|
||
function esimProfileTitle(p) {
|
||
const label = p.nickname || p.name;
|
||
const title = [p.provider, label].filter(Boolean).join(' ');
|
||
return title || p.iccid || '?';
|
||
}
|
||
|
||
function esimRenderProfiles() {
|
||
const el = document.getElementById('esim-profiles');
|
||
if (!el) return;
|
||
if (!_esimProfiles.length) {
|
||
el.innerHTML = '<span class="text-gray-400">' + esc(t('No profiles')) + '</span>';
|
||
return;
|
||
}
|
||
let html = '';
|
||
for (const p of _esimProfiles) {
|
||
const enabled = p.state === 'enabled';
|
||
const title = esimProfileTitle(p);
|
||
html += '<div class="mb-2 p-2 border border-gray-200 dark:border-slate-600 rounded">';
|
||
html += '<div class="flex items-center gap-2 mb-1">' +
|
||
'<span class="' + (enabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-gray-400') + ' font-bold">' + (enabled ? '●' : '○') + '</span>' +
|
||
'<span class="font-medium">' + esc(title) + '</span>' +
|
||
'<span class="text-xs text-gray-500 dark:text-slate-400">' + esc(esimStateLabel(p.state)) + '</span>' +
|
||
'<button data-needs="card" onclick="esimSetProfile(\'' + esc(p.iccid || '') + '\', \'' + esc(p.isdp_aid || '') + '\', \'' + (enabled ? 'disable' : 'enable') + '\')" ' +
|
||
'class="ml-auto px-2 py-0.5 text-xs rounded text-white disabled:opacity-40 disabled:cursor-not-allowed ' +
|
||
(enabled ? 'bg-gray-600 hover:bg-gray-700' : 'bg-emerald-600 hover:bg-emerald-700') + '">' +
|
||
esc(t(enabled ? 'Disable' : 'Enable')) + '</button></div>';
|
||
let rowsHtml = '';
|
||
for (const [k, v] of esimProfileRows(p)) {
|
||
rowsHtml += '<div class="flex gap-2"><span class="w-40 shrink-0 text-right text-gray-500 dark:text-slate-400 break-words">' + esc(k) + '</span>' +
|
||
'<span class="font-mono break-words min-w-0">' + esc(v) + '</span></div>';
|
||
}
|
||
const iconUrl = esimIconDataUrl(p);
|
||
html += '<div class="flex gap-3 items-start">';
|
||
if (iconUrl) {
|
||
html += '<img src="' + esc(iconUrl) + '" alt="" class="shrink-0">';
|
||
}
|
||
html += '<div class="min-w-0 flex-1">' + rowsHtml + '</div></div>';
|
||
html += '</div>';
|
||
}
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
function esimRenderNotifications() {
|
||
const el = document.getElementById('esim-notifications');
|
||
if (!el) return;
|
||
if (!_esimNotifications.length) {
|
||
el.innerHTML = '<span class="text-gray-400">' + esc(t('No notifications')) + '</span>';
|
||
return;
|
||
}
|
||
let html = '';
|
||
for (const n of _esimNotifications) {
|
||
html += '<div class="mb-1"><span class="text-gray-500 dark:text-slate-400">#' + esc(String(n.seq_number)) + '</span> ' +
|
||
esc(esimOperationsText(n.operations)) +
|
||
(n.address ? ' · ' + esc(n.address) : '') +
|
||
(n.iccid ? ' · <span class="font-mono">' + esc(n.iccid) + '</span>' : '') + '</div>';
|
||
}
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
async function esimFetchAll() {
|
||
const statusEl = document.getElementById('esim-status');
|
||
if (statusEl) statusEl.textContent = t('Loading...');
|
||
try {
|
||
const st = await pysimFetch('/api/status');
|
||
const euicc = !!(st && st.euicc);
|
||
document.getElementById('esim-not-euicc').classList.toggle('hidden', euicc);
|
||
document.getElementById('esim-body').classList.toggle('hidden', !euicc);
|
||
if (!euicc) {
|
||
if (statusEl) statusEl.textContent = '';
|
||
return;
|
||
}
|
||
const chip = await pysimFetch('/api/esim/chip');
|
||
const prof = await pysimFetch('/api/esim/profiles');
|
||
const notif = await pysimFetch('/api/esim/notifications');
|
||
_esimChip = chip && !chip.error ? chip : null;
|
||
_esimProfiles = (prof && prof.profiles) || [];
|
||
_esimNotifications = (notif && notif.notifications) || [];
|
||
esimRenderChip();
|
||
esimRenderProfiles();
|
||
esimRenderNotifications();
|
||
const err = (chip && chip.error) || (prof && prof.error) || (notif && notif.error);
|
||
if (statusEl) statusEl.textContent = err ? t('Error') + ': ' + err : '';
|
||
} catch (e) {
|
||
if (statusEl) statusEl.textContent = t('Error') + ': ' + e.message;
|
||
}
|
||
}
|
||
|
||
async function esimSetProfile(iccid, isdpAid, action) {
|
||
if (_esimBusy) return;
|
||
const statusEl = document.getElementById('esim-status');
|
||
const what = action === 'enable' ? t('Enable') : t('Disable');
|
||
if (!confirm(what + ' — ' + t('the card will be re-initialized. Continue?'))) return;
|
||
_esimBusy = true;
|
||
if (statusEl) statusEl.textContent = t('Switching profile — card re-initializing...');
|
||
try {
|
||
const body = { action: action, refresh: true };
|
||
if (iccid) body.iccid = iccid;
|
||
if (isdpAid) body.isdp_aid = isdpAid;
|
||
const res = await pysimFetch('/api/esim/profile', body);
|
||
if (statusEl) statusEl.textContent = esimSwitchStatus(res);
|
||
await esimFetchAll();
|
||
} catch (e) {
|
||
if (statusEl) statusEl.textContent = t('Error') + ': ' + e.message;
|
||
} finally {
|
||
_esimBusy = false;
|
||
}
|
||
}
|
||
|
||
// ===== HTTP OTA constructor =====
|
||
let _hotaMode = 'trigger';
|
||
let _hotaForm = '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();
|
||
}
|
||
// Hex normalisation for byte strings: an odd digit count is padded on the
|
||
// left so a half byte can never corrupt the TLV length field.
|
||
function _hotaPad(s) { const v = _hotaHex(s); return v.length % 2 ? '0' + v : v; }
|
||
function _hotaTlv(tag8, val8) { const v = _hotaPad(val8); return _hotaHex(tag8) + _hotaBerLen(v.length / 2) + v; }
|
||
|
||
function hotaTlv(tag, value) { return _hotaTlv(tag, value); }
|
||
function hotaBuildConn(tlvs) {
|
||
let content = '';
|
||
for (const t of tlvs) {
|
||
if (!t.tag) continue;
|
||
const v = _hotaPad(t.value);
|
||
content += _hotaHex(t.tag) + _hotaBerLen(v.length / 2) + v;
|
||
}
|
||
// '84' is 1-n per Table 4-5: an empty container must be left out entirely
|
||
return content ? '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).padStart(4, '0').slice(-4);
|
||
// Retry waiting delay = the TS 102 223 '25' timer value; each field uses
|
||
// the TP-SCTS semi-octet order of TS 23.040 9.1.2.3 (low nibble = most
|
||
// significant digit), i.e. 1 min = '10', 20 s = '02', 10 min = '01'.
|
||
const clamp = (v, max) => Math.min(max, Math.max(0, parseInt(v, 10) || 0));
|
||
const semi = v => (((v % 10) << 4) | Math.floor(v / 10)).toString(16).padStart(2, '0').toUpperCase();
|
||
const timerTlv = '2503' + semi(clamp(r.delayH, 99)) + semi(clamp(r.delayM, 59)) + semi(clamp(r.delayS, 59));
|
||
const report = _hotaHex(r.reportFailure);
|
||
const content = counter + timerTlv + (report.length % 2 === 0 ? report : '');
|
||
return '86' + _hotaBerLen(content.length / 2) + content;
|
||
}
|
||
function hotaBuildHttpPost(h) {
|
||
// '89' is 1-n per Table 4-8/9/10: only non-empty parameters are emitted
|
||
let content = '';
|
||
if (_hotaAsciiHex(h.host)) content += _hotaTlv('8A', _hotaAsciiHex(h.host));
|
||
if (_hotaAsciiHex(h.agent)) content += _hotaTlv('8B', _hotaAsciiHex(h.agent));
|
||
if (_hotaAsciiHex(h.uri)) content += _hotaTlv('8C', _hotaAsciiHex(h.uri));
|
||
return content ? '89' + _hotaBerLen(content.length / 2) + content : '';
|
||
}
|
||
function hotaBuildTrigger(conn, sec, retry, httpPost, expanded) {
|
||
const content = (conn || '') + (sec || '') + (retry || '') + (httpPost || '');
|
||
// '81' is mandatory but 0-n; '83' is 1-n, so an empty '83' is not emitted
|
||
let trig = '8100';
|
||
if (content) {
|
||
const sd = '83' + _hotaBerLen(content.length / 2) + content;
|
||
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 content = (conn || '') + (sec || '') + (retry || '') + (httpPost || '');
|
||
if (!content) return '';
|
||
const inner = _hotaTlv(storeTag, content);
|
||
const bytes = inner.length / 2;
|
||
if (bytes <= 127) return '80E29000' + _hotaBerLen(bytes) + inner;
|
||
// Parameters longer than a short APDU are chained as several STORE DATA
|
||
// commands: P1 b8=0 for all but the last block, P2 = block number,
|
||
// P1 b5-b4=10 (BER-TLV) throughout (GP Card Spec 11.11).
|
||
const chunks = [];
|
||
for (let i = 0; i < inner.length; i += 127 * 2) chunks.push(inner.slice(i, i + 127 * 2));
|
||
return chunks.map((c, i) => {
|
||
const last = i === chunks.length - 1;
|
||
const p2 = i.toString(16).padStart(2, '0').toUpperCase();
|
||
return '80E2' + (last ? '90' : '10') + p2 + _hotaBerLen(c.length / 2) + c;
|
||
}).join('');
|
||
}
|
||
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-heading-trigger').classList.toggle('hidden', mode !== 'trigger');
|
||
document.getElementById('hota-heading-store').classList.toggle('hidden', mode !== 'store');
|
||
document.getElementById('hota-store-tag-row').classList.toggle('hidden', mode !== 'store');
|
||
document.getElementById('hota-expanded-row').classList.toggle('hidden', mode !== 'trigger');
|
||
hotaUpdatePreview();
|
||
}
|
||
|
||
// The Push commands pill shows one form at a time: Trigger and Store share the
|
||
// GP administration form (different payload mode), Channel / link trigger has
|
||
// its own TS 102 226 §9 form.
|
||
function hotaSetForm(form) {
|
||
_hotaForm = form;
|
||
['trigger', 'store', 'link'].forEach(f => {
|
||
const btn = document.getElementById('hota-form-btn-' + f);
|
||
if (!btn) return;
|
||
btn.className = 'px-4 py-1.5 text-sm rounded-full ' + (f === form
|
||
? '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');
|
||
});
|
||
const visible = form === 'link' ? 'push-form-link' : 'hota-form-gp';
|
||
['hota-form-gp', 'push-form-link'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.classList.toggle('hidden', id !== visible);
|
||
});
|
||
if (form === 'trigger' || form === 'store') hotaSetMode(form);
|
||
else pushUpdate(form);
|
||
}
|
||
|
||
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: ['82', '8182'],
|
||
alpha: ['05', ''],
|
||
cmddet: ['81', '014001'],
|
||
bearer: ['35', '03'],
|
||
};
|
||
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;
|
||
}
|
||
|
||
// ===== Push command sections (TS 102 226 9) =====
|
||
// The channel/link section feeds the same encoder as the RAM/GP chain's PUSH
|
||
// command (chainPushData, vector-tested in ts102226.test.js) and previews the
|
||
// resulting C-APDU(s). A §9 PUSH is addressed to the application's TAR, so
|
||
// packing does not auto-select a TAR (unlike the RFM/RAM chain previews).
|
||
const _pushApdus = {};
|
||
|
||
function pushReadFields(section) {
|
||
const v = id => { const el = document.getElementById(id); return el ? el.value : ''; };
|
||
const idp = document.getElementById('push-' + section + '-idp');
|
||
const cat = document.getElementById('push-' + section + '-cat');
|
||
return {
|
||
request: v('push-' + section + '-request') || '01',
|
||
withIdp: !!(idp && idp.checked),
|
||
withCat: !!(cat && cat.checked),
|
||
pushData: v('push-' + section + '-data'),
|
||
pushPort: v('push-' + section + '-port'),
|
||
pushSdu: v('push-' + section + '-sdu'),
|
||
pushIdent: v('push-' + section + '-ident'),
|
||
pushBearer: v('push-' + section + '-bearer'),
|
||
pushAddr: v('push-' + section + '-addr'),
|
||
pushApn: v('push-' + section + '-apn'),
|
||
pushExtra: v('push-' + section + '-extra'),
|
||
idpIdent: v('push-' + section + '-idp-ident'),
|
||
ocMode: v('push-' + section + '-oc-mode') || 'fields',
|
||
ocBearer: v('push-' + section + '-oc-bearer'),
|
||
ocBuffer: v('push-' + section + '-oc-buffer'),
|
||
ocProto: v('push-' + section + '-oc-proto') || '01',
|
||
ocPort: v('push-' + section + '-oc-port'),
|
||
ocAddr: v('push-' + section + '-oc-addr'),
|
||
ocApn: v('push-' + section + '-oc-apn'),
|
||
ocAlphaNull: !!(document.getElementById('push-' + section + '-oc-alpha-null') || {}).checked,
|
||
ocAlpha: v('push-' + section + '-oc-alpha'),
|
||
ocExtra: v('push-' + section + '-oc-extra'),
|
||
};
|
||
}
|
||
|
||
// OPEN CHANNEL COMPREHENSION-TLVs from the guided "Fields" mode of the BIP
|
||
// channel opening request (the most used parameters per SIMalliance Stepping
|
||
// Stones R7 18.6.1). The local address is never emitted (TS 102 226 9.2.1
|
||
// forbids it and the guide recommends against it); login/password and anything
|
||
// else stay in the extra TLVs field. Bearer, buffer size and the destination
|
||
// address keep the defaults/behaviour of the OPEN CHANNEL command: the bearer
|
||
// defaults to the default bearer (35 01 03), the transport level is only
|
||
// emitted with a port and is limited to UDP/TCP client remote (TS 102 223
|
||
// 6.6.27.4).
|
||
function pushOpenChannelTlvs(f) {
|
||
const hx = s => (s || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
const four = s => hx(s).padStart(4, '0').slice(-4);
|
||
let out = hx(f.ocBearer) || '350103';
|
||
const port = hx(f.ocPort);
|
||
if (port) out += '3C03' + (f.ocProto === '02' ? '02' : '01') + four(port);
|
||
const addr = hx(f.ocAddr);
|
||
if (addr) out += '3E' + berLenStr(addr.length / 2) + addr;
|
||
const apn = _hotaAsciiHex((f.ocApn || '').trim());
|
||
if (apn) out += '47' + berLenStr(apn.length / 2) + apn;
|
||
const buffer = hx(f.ocBuffer);
|
||
if (buffer) out += '3902' + four(buffer);
|
||
if (f.ocAlphaNull) out += '0500';
|
||
else {
|
||
const alpha = hx(f.ocAlpha);
|
||
if (alpha) out += '05' + berLenStr(alpha.length / 2) + alpha;
|
||
}
|
||
return out + hx(f.ocExtra);
|
||
}
|
||
|
||
// C-APDUs for a §9 push from the form's field values; empty when a mandatory
|
||
// parameter is missing (TS 102 226 9.2.2/9.2.3: CAT_TP and TCP need the
|
||
// destination port, TCP also the destination address). P2=01/04 may carry no
|
||
// data (the application defaults / ICCID are used). The RAM/GP chain row
|
||
// stays lenient so scripts can be composed freely.
|
||
//
|
||
// The identification packet (9.1.5) presupposes an open TCP channel and is
|
||
// only meaningful as a follow-up in the same message, so it is appended right
|
||
// after the TCP request when requested.
|
||
function pushSectionApdus(section, f) {
|
||
const request = f.request || '01';
|
||
const hx = s => (s || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (request === '02' && hx(f.pushPort).length !== 4) return [];
|
||
if (request === '03' && (hx(f.pushPort).length !== 4 || !hx(f.pushAddr))) return [];
|
||
const p2 = parseInt(request, 16);
|
||
const ocTlvs = (request === '01' && f.ocMode !== 'hex') ? pushOpenChannelTlvs(f) : f.pushData;
|
||
const data = chainPushData(Object.assign({}, f, { pushType: request, pushData: ocTlvs }));
|
||
const apdus = [data
|
||
? buildApdu(0x80, 0xEC, 0x01, p2, data.length / 2, data)
|
||
: buildApdu(0x80, 0xEC, 0x01, p2, null, null)];
|
||
if (request === '03' && f.withIdp) {
|
||
const idp = chainPushData({ pushType: '04', pushIdent: f.idpIdent });
|
||
apdus.push(idp
|
||
? buildApdu(0x80, 0xEC, 0x01, 0x04, idp.length / 2, idp)
|
||
: buildApdu(0x80, 0xEC, 0x01, 0x04, null, null));
|
||
}
|
||
// SIMalliance Stepping Stones R7 18.6 recommends sending the BIP channel
|
||
// opening together with the CAT_TP link request in the same message: the
|
||
// channel parameters ride in 01, the CAT_TP port in 02.
|
||
if (request === '01' && f.withCat) {
|
||
if (hx(f.pushPort).length !== 4) return []; // CAT_TP port is mandatory (9.2.2)
|
||
const cat = chainPushData({ pushType: '02', pushPort: f.pushPort,
|
||
pushSdu: f.pushSdu, pushIdent: f.pushIdent, pushExtra: f.pushExtra });
|
||
apdus.push(cat
|
||
? buildApdu(0x80, 0xEC, 0x01, 0x02, cat.length / 2, cat)
|
||
: buildApdu(0x80, 0xEC, 0x01, 0x02, null, null));
|
||
}
|
||
return apdus;
|
||
}
|
||
|
||
function pushToggleFields(section) {
|
||
const sel = document.getElementById('push-' + section + '-request');
|
||
const request = sel ? sel.value : '';
|
||
document.querySelectorAll('[data-push-' + section + ']').forEach(el => {
|
||
const when = (el.getAttribute('data-push-' + section) || '').split(',');
|
||
el.classList.toggle('hidden', when.indexOf(request) < 0);
|
||
});
|
||
}
|
||
|
||
function pushUpdate(section) {
|
||
pushToggleFields(section);
|
||
const f = pushReadFields(section);
|
||
const apdus = pushSectionApdus(section, f);
|
||
_pushApdus[section] = apdus;
|
||
const preview = document.getElementById('push-' + section + '-preview');
|
||
if (preview) preview.value = apdus.join('');
|
||
['pack', 'exp'].forEach(kind => {
|
||
const btn = document.getElementById('push-' + section + '-' + kind + '-btn');
|
||
if (btn) btn.disabled = !apdus.length;
|
||
});
|
||
const ocFields = document.getElementById('push-' + section + '-oc-fields');
|
||
const ocHex = document.getElementById('push-' + section + '-oc-hex');
|
||
const ocHexMode = f.ocMode === 'hex';
|
||
if (ocFields) ocFields.classList.toggle('hidden', ocHexMode);
|
||
if (ocHex) ocHex.classList.toggle('hidden', !ocHexMode);
|
||
const idpBlock = document.getElementById('push-' + section + '-idp-block');
|
||
if (idpBlock) idpBlock.classList.toggle('hidden', !(f.request === '03' && f.withIdp));
|
||
// On request 01 the shared CAT_TP field blocks (port, max SDU, identification
|
||
// data, extra TLVs) follow the "also request the CAT_TP link" checkbox; for
|
||
// the other requests their own data-push rules already showed/hid them.
|
||
if (f.request === '01') {
|
||
['-cat-fields-1', '-cat-fields-2', '-cat-fields-3'].forEach(suffix => {
|
||
const el = document.getElementById('push-' + section + suffix);
|
||
if (el) el.classList.toggle('hidden', !f.withCat);
|
||
});
|
||
}
|
||
}
|
||
|
||
// Import the §9 C-APDU(s) into the Expanded Script builder as 22 command TLVs
|
||
// (a sequence - e.g. TCP request plus identification packet - becomes one row
|
||
// per command, all inside the same script).
|
||
function pushToExpanded(sourceId) {
|
||
const section = (sourceId || '').replace(/^push-/, '').replace(/-preview$/, '');
|
||
const apdus = _pushApdus[section] || [];
|
||
if (!apdus.length) return;
|
||
cApduSwitchSubtab('ber');
|
||
apdus.forEach(berAppendApdu);
|
||
genBerTlv();
|
||
}
|
||
|
||
function pushInit() {
|
||
pushUpdate('link');
|
||
hotaSetForm('trigger');
|
||
}
|
||
pushInit();
|
||
|
||
document.querySelectorAll('#c-apdu-sub-httpota input, #c-apdu-sub-httpota select').forEach(el => {
|
||
if (el.type === 'checkbox') return;
|
||
if ((el.id || '').indexOf('push-') === 0) return; // §9 sections update themselves
|
||
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));
|
||
// GP Card Spec Table 11-43: the privileges length is 1 or 3 bytes - a
|
||
// 2-byte form is invalid, so the third byte is padded when either of the
|
||
// upper bytes is set.
|
||
let hex = b1.toString(16).padStart(2, '0').toUpperCase();
|
||
if (b2 || b3) {
|
||
hex += b2.toString(16).padStart(2, '0').toUpperCase();
|
||
hex += b3.toString(16).padStart(2, '0').toUpperCase();
|
||
}
|
||
document.getElementById('rc-priv').value = hex;
|
||
}
|
||
|
||
// The install-parameter family a CAP belongs to, from its Import component:
|
||
// sim.* (sim.toolkit / sim.access, AID tree A0000000090003FFFFFFFF89...) uses
|
||
// the CA field (SIM Toolkit); uicc.* / uicc.usim.* (uicc.toolkit /
|
||
// uicc.access / ..., AID trees A0000000090005FFFFFFFF89... and
|
||
// A0000000871005FFFFFFFF89...) uses the EA field (UICC Toolkit). Returns
|
||
// 'ca', 'ea' or null when the CAP links against neither.
|
||
function ramCapToolkitMode(imports) {
|
||
const aids = (imports || []).map(im => (im.aid || '').toUpperCase());
|
||
if (aids.some(a => a.startsWith('A0000000090005FFFFFFFF89') ||
|
||
a.startsWith('A0000000871005FFFFFFFF89'))) return 'ea';
|
||
if (aids.some(a => a.startsWith('A0000000090003FFFFFFFF89'))) return 'ca';
|
||
return null;
|
||
}
|
||
|
||
// Pick the toolkit mode of the analysed RAM CAP (when its libraries decide it)
|
||
// and, when the block is in use, apply it: never make the user guess CA vs EA.
|
||
function ramApplyCapToolkitMode() {
|
||
const st = _capAnalysis.ram;
|
||
const mode = ramCapToolkitMode((st.memory && st.memory.imports) || []);
|
||
st.tk_mode = mode;
|
||
const sel = document.getElementById('rc-tk-mode');
|
||
const hint = document.getElementById('rc-tk-mode-hint');
|
||
if (hint) {
|
||
if (mode) {
|
||
hint.textContent = t('detected from the CAP') + ': ' + sel.options[sel.selectedIndex].text;
|
||
hint.classList.remove('hidden');
|
||
} else {
|
||
hint.classList.add('hidden');
|
||
}
|
||
}
|
||
const enable = document.getElementById('rc-toolkit-enable');
|
||
if (mode && enable && enable.checked) {
|
||
sel.value = mode;
|
||
if (hint) hint.textContent = t('detected from the CAP') + ': ' +
|
||
(mode === 'ea' ? 'UICC Toolkit (Tag EA)' : 'SIM Toolkit (Tag CA)');
|
||
updateRcTkMode();
|
||
updateStkParamsHex();
|
||
}
|
||
}
|
||
|
||
function updateRcToolkit() {
|
||
const checked = document.getElementById('rc-toolkit-enable').checked;
|
||
document.getElementById('rc-toolkit-body').classList.toggle('hidden', !checked);
|
||
// A CAP linked against sim.* / uicc.* decides the toolkit mode
|
||
const st = _capAnalysis.ram;
|
||
if (checked && st && st.tk_mode) document.getElementById('rc-tk-mode').value = st.tk_mode;
|
||
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-access-row').style.display = isSim ? 'none' : '';
|
||
}
|
||
|
||
// The install form's checkbox grants (privileges, toolkit enable, file
|
||
// access) are per-session choices: clear anything the browser restored on
|
||
// reload and refresh the aggregates, so a stale grant can never silently
|
||
// apply (live 2026-09-28: an inherited Receipt Generation bit produced 6985).
|
||
function ramResetGrants() {
|
||
document.querySelectorAll('.rc-priv-b1, .rc-priv-b2, .rc-priv-b3')
|
||
.forEach(cb => { cb.checked = false; });
|
||
document.getElementById('rc-toolkit-enable').checked = false;
|
||
document.getElementById('rc-tk-fsaccess').checked = false;
|
||
document.getElementById('rc-tk-adfaccess').checked = false;
|
||
updateRcToolkit(); // hides the toolkit block and refreshes the STK hex
|
||
updateRcPriv(); // privileges aggregate back to 00
|
||
}
|
||
|
||
// Pure SIM/UICC toolkit install-parameter builder shared by the RAM install
|
||
// form (buildRcToolkitParams) and the RAM/GP chain rows (buildTkParams).
|
||
// `v` carries the field values; returns the CA/EA TLV hex, or null when a
|
||
// value cannot be coded: each TAR is 3 bytes (6 hex digits), and menu item
|
||
// identifiers 128..255 are reserved for the toolkit framework, so only
|
||
// 01..7F may be requested (TS 102 226 8.2.1.3.2.3). In EA mode `v.fsAccess`
|
||
// adds the UICC file-access parameters (tag '81') and `v.adfAccess` extends
|
||
// them with an ADF entry (AID from `v.adfAid`, default ADF.USIM); the SIM path
|
||
// grants access via the CA Access Domain field instead.
|
||
function stkParamsBuild(v) {
|
||
// Priority '00' is RFU (TS 102 226 8.2.1.3.2.6): an unset field defaults
|
||
// to 255 (0xFF, lowest priority) instead of the invalid zero.
|
||
const priority = (v.priority === '' || v.priority === undefined)
|
||
? 0xFF : (parseInt(v.priority, 10) || 0);
|
||
const timers = parseInt(v.timers, 10) || 0;
|
||
const textLen = parseInt(v.textLen, 10) || 0;
|
||
const menus = parseInt(v.menus, 10) || 0;
|
||
const firstPos = parseInt(v.firstPos, 10) || 0;
|
||
const firstId = (v.firstId || '00').replace(/[^0-9a-fA-F]/g, '').toUpperCase().padStart(2, '0').slice(0, 2) || '00';
|
||
const lastPos = parseInt(v.lastPos, 10) || 0;
|
||
const lastId = (v.lastId || '00').replace(/[^0-9a-fA-F]/g, '').toUpperCase().padStart(2, '0').slice(0, 2) || '00';
|
||
const channels = parseInt(v.channels, 10) || 0;
|
||
const msl = (v.msl || '00').toUpperCase();
|
||
const tar = (v.tar || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
const adRaw = (v.ad || '').replace(/[^0-9a-fA-F]/g, '');
|
||
const ad = adRaw.padStart(2, '0').slice(0, 2);
|
||
const services = parseInt(v.services, 10) || 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').toUpperCase() + firstId;
|
||
else if (i === menus) menuPairs += lastPos.toString(16).padStart(2, '0').toUpperCase() + lastId;
|
||
else menuPairs += '0000';
|
||
}
|
||
const mslField = msl === '00' ? '00' : '0201' + msl;
|
||
let tkPayload = '';
|
||
if (v.mode === 'ca') {
|
||
tkPayload = (adRaw ? '01' + ad : '00') +
|
||
priority.toString(16).padStart(2, '0').toUpperCase() +
|
||
timers.toString(16).padStart(2, '0').toUpperCase() +
|
||
textLen.toString(16).padStart(2, '0').toUpperCase() +
|
||
menus.toString(16).padStart(2, '0').toUpperCase() +
|
||
menuPairs +
|
||
channels.toString(16).padStart(2, '0').toUpperCase() +
|
||
mslField +
|
||
(tar ? (tar.length / 2).toString(16).padStart(2, '0').toUpperCase() + tar : '00');
|
||
const caTlv = 'CA' + berLenStr(tkPayload.length / 2) + tkPayload;
|
||
return 'EF' + berLenStr(caTlv.length / 2) + caTlv;
|
||
}
|
||
tkPayload = priority.toString(16).padStart(2, '0').toUpperCase() +
|
||
timers.toString(16).padStart(2, '0').toUpperCase() +
|
||
textLen.toString(16).padStart(2, '0').toUpperCase() +
|
||
menus.toString(16).padStart(2, '0').toUpperCase() +
|
||
menuPairs +
|
||
channels.toString(16).padStart(2, '0').toUpperCase() +
|
||
mslField +
|
||
(tar ? (tar.length / 2).toString(16).padStart(2, '0').toUpperCase() + tar : '00') +
|
||
services.toString(16).padStart(2, '0');
|
||
let eaValue = '80' + berLenStr(tkPayload.length / 2) + tkPayload;
|
||
if (v.fsAccess) {
|
||
// UICC Access Application specific parameters (tag '81', TS 102 226
|
||
// 8.2.1.3.2.2/8.2.1.3.2.2.2; '82' is the *Administrative* Access
|
||
// field): every entry ends with the "Length of Access Domain DAP"
|
||
// byte (00 = no DAP):
|
||
// [file system AID length 00 = shared FS][AD length 01]
|
||
// [ADP 00 = full access][DAP length 00]
|
||
// [ADF AID length][ADF AID][AD length 01][ADP 00][DAP length 00]
|
||
// (the ADF AID must be 5..16 bytes; under '82' the card rejected the
|
||
// ADF entry with 6A80 - the applet never got its FileView rights.)
|
||
let acc = '000100' + '00';
|
||
if (v.adfAccess) {
|
||
const adf = (v.adfAid || 'A0000000871002').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (adf.length < 10 || adf.length > 32) return null;
|
||
acc += (adf.length / 2).toString(16).padStart(2, '0').toUpperCase() + adf + '0100' + '00';
|
||
}
|
||
eaValue += '81' + berLenStr(acc.length / 2) + acc;
|
||
}
|
||
return 'EA' + berLenStr(eaValue.length / 2) + eaValue;
|
||
}
|
||
|
||
// The RAM install form's toolkit fields -> the install parameters hex.
|
||
function buildRcToolkitParams() {
|
||
if (!document.getElementById('rc-toolkit-enable').checked) return '';
|
||
const g = id => document.getElementById(id).value;
|
||
return stkParamsBuild({
|
||
mode: g('rc-tk-mode'),
|
||
priority: g('rc-tk-priority'), timers: g('rc-tk-timers'),
|
||
textLen: g('rc-tk-textlen'), menus: g('rc-tk-menus'),
|
||
firstPos: g('rc-tk-firstpos'), firstId: g('rc-tk-firstid'),
|
||
lastPos: g('rc-tk-lastpos'), lastId: g('rc-tk-lastid'),
|
||
channels: g('rc-tk-channels'), msl: g('rc-tk-msl'),
|
||
tar: g('rc-tk-tar'), ad: g('rc-tk-ad'), services: g('rc-tk-services'),
|
||
fsAccess: document.getElementById('rc-tk-fsaccess').checked,
|
||
adfAccess: document.getElementById('rc-tk-adfaccess').checked,
|
||
adfAid: g('rc-tk-adfaid'),
|
||
});
|
||
}
|
||
|
||
function updateStkParamsHex() {
|
||
const tk = buildRcToolkitParams();
|
||
const el = document.getElementById('ram-stk-params');
|
||
el.value = tk || '';
|
||
// A hand-edited hex is an explicit override: keep it until a toolkit
|
||
// field is edited again (this clears the flag).
|
||
if (el.dataset) delete el.dataset.manual;
|
||
// A rejected value must not vanish silently: say why the field is empty.
|
||
const hint = document.getElementById('ram-stk-hint');
|
||
if (!hint) return;
|
||
if (tk === null) {
|
||
hint.textContent = t('STK parameters not generated: check the TAR (multiple of 3 bytes), menu IDs (<= 7F) and the ADF AID (5-16 bytes)');
|
||
hint.classList.remove('hidden');
|
||
} else {
|
||
hint.textContent = '';
|
||
hint.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
// The ADF.USIM AID is card-specific: the FCI's DF name (tag 84) is the full
|
||
// 5..16-byte AID the access parameters need - the base A0000000871002 is only
|
||
// a prefix, and a shorter AID makes the card reject the entry with 6A80
|
||
// (live 2026-09-28: the card's DF name is A0000000871002FF49FF0589).
|
||
function adfAidFromFci(fciHex) {
|
||
const s = (fciHex || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
let i = 0;
|
||
if (s.length >= 4 && (s.startsWith('62') || s.startsWith('6F'))) {
|
||
const n = parseInt(s.substr(2, 2), 16);
|
||
i = 4 + (n > 0x80 ? (n & 0x7F) * 2 : 0); // skip the FCP/FCI template head
|
||
}
|
||
while (i + 4 <= s.length) {
|
||
const tag = s.substr(i, 2);
|
||
let n = parseInt(s.substr(i + 2, 2), 16);
|
||
let j = i + 4;
|
||
if (n > 0x80) {
|
||
const nb = n & 0x7F;
|
||
n = parseInt(s.substr(i + 4, nb * 2), 16);
|
||
j = i + 4 + nb * 2;
|
||
}
|
||
if (tag === '84') return s.substr(j, n * 2).toUpperCase();
|
||
i = j + n * 2;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// Fill the ADF AID from the card's ADF.USIM FCI (one click; the AID cannot be
|
||
// known in advance - it carries a card-specific PIX).
|
||
async function ramAdfAidFromCard() {
|
||
const el = document.getElementById('rc-tk-adfaid');
|
||
const btn = document.getElementById('rc-tk-adfaid-from-card');
|
||
if (!el || !btn) return;
|
||
const label = btn.textContent;
|
||
btn.disabled = true;
|
||
try {
|
||
const data = await pysimFetch('/api/select', { name: 'ADF.USIM' });
|
||
const aid = adfAidFromFci(data && data.fci_hex);
|
||
if (!aid) throw new Error('no DF name');
|
||
el.value = aid;
|
||
updateStkParamsHex();
|
||
btn.textContent = t('OK');
|
||
} catch (e) {
|
||
btn.textContent = t('Failed');
|
||
} finally {
|
||
setTimeout(() => { btn.textContent = label; btn.disabled = false; }, 1500);
|
||
}
|
||
}
|
||
|
||
// GP memory-quota values (TS 102 226 8.2.1.3.2.2.1 / GP 9.7): a 2-byte
|
||
// integer up to 32767, a 4-byte integer above.
|
||
function ramQuotaValue(n) {
|
||
const v = Math.max(0, Math.min(0x7FFFFFFF, parseInt(n, 10) || 0));
|
||
return v <= 0x7FFF
|
||
? '02' + v.toString(16).padStart(4, '0').toUpperCase()
|
||
: '04' + v.toString(16).padStart(8, '0').toUpperCase();
|
||
}
|
||
|
||
// The applet-specific prefix of the install parameters, in the vendor's
|
||
// order: `C9 <applet-specific parameters>` + `EF <C7/C8 memory quotas>`.
|
||
// The server appends the STK (`EA`/`EF+CA`) part after it. C9 is mandatory
|
||
// in the Install Parameters (GP Table 11-49), so an empty field is sent as
|
||
// `C9 00` - like the reference tools; a prefix without C9 makes the card
|
||
// reject the data field with 6A80 (live 2026-09-28).
|
||
function ramParamsPrefix(c9Hex, c7, c8) {
|
||
const c9 = (c9Hex || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
let out = 'C9' + berLenStr(c9.length / 2) + c9;
|
||
let ef = '';
|
||
if (String(c7 ?? '') !== '' && !isNaN(parseInt(c7, 10))) ef += 'C7' + ramQuotaValue(c7);
|
||
if (String(c8 ?? '') !== '' && !isNaN(parseInt(c8, 10))) ef += 'C8' + ramQuotaValue(c8);
|
||
if (ef) out += 'EF' + berLenStr(ef.length / 2) + ef;
|
||
return out;
|
||
}
|
||
|
||
function ramInstallParamsPrefix() {
|
||
return ramParamsPrefix(
|
||
document.getElementById('rc-c9').value,
|
||
document.getElementById('rc-quota-c7').value,
|
||
document.getElementById('rc-quota-c8').value);
|
||
}
|
||
|
||
function updateInstallParamsHex() {
|
||
const prefix = ramInstallParamsPrefix();
|
||
const el = document.getElementById('ram-install-params-hex');
|
||
// The field carries extra raw TLVs; the generated prefix is shown in the
|
||
// placeholder so the composed parameters stay visible.
|
||
el.placeholder = prefix ? prefix + ' …' : 'extra TLVs (optional)';
|
||
}
|
||
|
||
// ===== 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 = {};
|
||
// Per-chain refresh callbacks (used by the Expanded Script C-APDU rows, whose
|
||
// editor is a virtual one-row chain without the stock preview elements).
|
||
const CHAIN_LISTENERS = {};
|
||
|
||
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:'search-record',label:'SEARCH RECORD (SEEK)'},{value:'increase',label:'INCREASE'},
|
||
{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:'search-record',label:'SEARCH RECORD'},{value:'increase',label:'INCREASE'},
|
||
{value:'create-file',label:'CREATE FILE'},{value:'delete-file',label:'DELETE FILE'},
|
||
{value:'resize-file',label:'RESIZE FILE'},{value:'set-data',label:'SET DATA'},
|
||
{value:'retrieve-data',label:'RETRIEVE DATA'},
|
||
{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:'put-key',label:'PUT KEY'},{value:'push',label:'PUSH (TS 102 226 §9)'},
|
||
{value:'ext-auth',label:'EXTERNAL AUTHENTICATE'},
|
||
{value:'int-auth',label:'INTERNAL AUTHENTICATE'},
|
||
{value:'get-response',label:'GET RESPONSE'},
|
||
];
|
||
|
||
// ===== Chain container kinds =====
|
||
// The SIM / USIM / RAM builders plus the one-row "virtual" chains embedded in
|
||
// the Expanded Script C-APDU rows (id ber-<kind>-<row>); the kind selects the
|
||
// command catalog and the row builders.
|
||
function chainKind(chainId) {
|
||
if (chainId === 'chain-sim') return 'sim';
|
||
if (chainId === 'chain-usim') return 'usim';
|
||
const m = /^ber-(sim|usim|ram)-\d+$/.exec(chainId || '');
|
||
return m ? m[1] : 'ram';
|
||
}
|
||
function chainIsEmbedded(chainId) { return /^ber-/.test(chainId || ''); }
|
||
function chainCommands(chainId) {
|
||
const kind = chainKind(chainId);
|
||
const cmds = kind === 'ram' ? CHAIN_CMDS_RAM : (kind === 'usim' ? CHAIN_CMDS_USIM : CHAIN_CMDS_SIM);
|
||
// GET RESPONSE is not used in the Expanded Remote Application data format
|
||
// (TS 102 226 5.2.1.1), so the embedded command pickers omit it.
|
||
return chainIsEmbedded(chainId) ? cmds.filter(function(c) { return c.value !== 'get-response'; }) : cmds;
|
||
}
|
||
function chainListen(chainId, fn) { CHAIN_LISTENERS[chainId] = fn; }
|
||
function chainNotify(chainId) { const fn = CHAIN_LISTENERS[chainId]; if (fn) fn(); }
|
||
|
||
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);
|
||
chainNotify(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) { chainNotify(chainId); return; }
|
||
const embedded = chainIsEmbedded(chainId);
|
||
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, embedded);
|
||
container.appendChild(div);
|
||
});
|
||
const hex = chainBuildHex(chainId);
|
||
if (preview) preview.value = hex;
|
||
if (packBtn) packBtn.disabled = !hex;
|
||
chainNotify(chainId);
|
||
}
|
||
|
||
function chainRenderRow(chainId, idx, row, embedded) {
|
||
const cmds = chainCommands(chainId);
|
||
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">' +
|
||
(embedded ? '' : '<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)"; }
|
||
const kind = chainKind(chainId);
|
||
if (kind === 'sim' || kind === 'usim') return chainSimFieldsHtml(chainId, idx, f, uf);
|
||
return chainRamFieldsHtml(chainId, 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 : '';
|
||
var usim = chainKind(chainId) === 'usim';
|
||
if (cmd === 'select') {
|
||
var method = f.method || 'fid';
|
||
var silent = 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') + ';rfmNoticeClear(\'' + chainId + '\',' + idx + ');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 (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(\'' + chainId + '\',' + idx + ',\'silent\',this.checked);chainRender(\'' + chainId + '\')" 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 (usim) {
|
||
html += '<div class="flex gap-3 mt-1 text-xs"><label class="flex items-center gap-1"><input type="radio" name="ch-' + chainId + '-' + idx + '-base" value="mf"' + ((f.base || 'mf') === 'mf' ? ' checked' : '') + ' onchange="chainUpdateField(\'' + chainId + '\',' + idx + ',\'base\',\'mf\')"> from MF</label>' +
|
||
'<label class="flex items-center gap-1"><input type="radio" name="ch-' + chainId + '-' + idx + '-base" value="df"' + (f.base === 'df' ? ' checked' : '') + ' onchange="chainUpdateField(\'' + chainId + '\',' + idx + ',\'base\',\'df\')"> from current DF (ADF.USIM)</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>';
|
||
}
|
||
if (method !== 'dfname') html += rfmPickerHtml(chainId, idx, method);
|
||
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 === 'search-record') {
|
||
var srMode = f.srMode || (usim ? '10' : '02');
|
||
if (usim) {
|
||
var htmlSr = '<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">Mode (P2)</label>' +
|
||
'<select onchange="' + uf('srMode') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
|
||
'<option value="10"' + (srMode === '10' ? ' selected' : '') + '>10 — forward from P1</option>' +
|
||
'<option value="12"' + (srMode === '12' ? ' selected' : '') + '>12 — backward from P1</option>' +
|
||
'<option value="18"' + (srMode === '18' ? ' selected' : '') + '>18 — enhanced search</option>' +
|
||
'<option value="1C"' + (srMode === '1C' ? ' selected' : '') + '>1C — proprietary search</option></select></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Record (P1, 00 = current)</label>' +
|
||
'<input value="' + escHtml(f.srRec || '00') + '" oninput="' + uf('srRec') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" maxlength="2"></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" maxlength="2" placeholder="00"></div></div>';
|
||
if (srMode === '18') {
|
||
htmlSr += '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Search indication (2 bytes, table 11.13)</label>' +
|
||
'<input value="' + escHtml(f.srInd || '1800') + '" oninput="' + uf('srInd') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" maxlength="4"></div>';
|
||
}
|
||
htmlSr += '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Search string (hex)</label>' +
|
||
'<input value="' + escHtml(f.srPattern || '') + '" oninput="' + uf('srPattern') + '" 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>';
|
||
return htmlSr;
|
||
}
|
||
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">Type/mode (P2)</label>' +
|
||
'<select onchange="' + uf('srMode') + '" 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"' + (srMode === '00' ? ' selected' : '') + '>00 — type 1, from start</option>' +
|
||
'<option value="01"' + (srMode === '01' ? ' selected' : '') + '>01 — type 1, backward</option>' +
|
||
'<option value="02"' + (srMode === '02' ? ' selected' : '') + '>02 — type 1, next forward</option>' +
|
||
'<option value="03"' + (srMode === '03' ? ' selected' : '') + '>03 — type 1, previous</option>' +
|
||
'<option value="10"' + (srMode === '10' ? ' selected' : '') + '>10 — type 2, from start</option>' +
|
||
'<option value="11"' + (srMode === '11' ? ' selected' : '') + '>11 — type 2, backward</option>' +
|
||
'<option value="12"' + (srMode === '12' ? ' selected' : '') + '>12 — type 2, next forward</option>' +
|
||
'<option value="13"' + (srMode === '13' ? ' selected' : '') + '>13 — type 2, previous</option></select></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Pattern (1-16 bytes hex)</label>' +
|
||
'<input value="' + escHtml(f.srPattern || '') + '" oninput="' + uf('srPattern') + '" 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">Le (empty for type 1)</label>' +
|
||
'<input value="' + escHtml(f.le || '') + '" 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" maxlength="2"></div></div>';
|
||
}
|
||
if (cmd === 'increase') {
|
||
var inc = '<div class="grid grid-cols-3 gap-2">';
|
||
if (usim) {
|
||
inc += '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">SFI (00 = current EF)</label>' +
|
||
'<input value="' + escHtml(f.sfi || '00') + '" oninput="' + uf('sfi') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" maxlength="2"></div>';
|
||
}
|
||
inc += '<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Value to add (hex)</label>' +
|
||
'<input value="' + escHtml(f.value || (usim ? '' : '000001')) + '" oninput="' + uf('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"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Le' + (usim ? '' : ' (empty = case 3)') + '</label>' +
|
||
'<input value="' + escHtml(f.le || (usim ? '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" maxlength="2"></div></div>';
|
||
return inc;
|
||
}
|
||
if (cmd === 'create-file' || cmd === 'resize-file') {
|
||
var isCreate = cmd === 'create-file';
|
||
var st = f.structure || 'transparent';
|
||
var htmlFcp = '<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">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" maxlength="4" placeholder="6F00"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">File size (hex bytes)</label>' +
|
||
'<input value="' + escHtml(f.fileSize || '') + '" oninput="' + uf('fileSize') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" maxlength="4" placeholder="0010"></div>' +
|
||
(isCreate ?
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Structure</label>' +
|
||
'<select onchange="' + uf('structure') + '" class="w-full border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1 dark:bg-slate-800">' +
|
||
'<option value="transparent"' + (st === 'transparent' ? ' selected' : '') + '>Transparent EF</option>' +
|
||
'<option value="linear_fixed"' + (st === 'linear_fixed' ? ' selected' : '') + '>Linear fixed EF</option>' +
|
||
'<option value="cyclic"' + (st === 'cyclic' ? ' selected' : '') + '>Cyclic EF</option>' +
|
||
'<option value="ber_tlv"' + (st === 'ber_tlv' ? ' selected' : '') + '>BER-TLV EF</option></select></div>'
|
||
:
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Mode (P1)</label>' +
|
||
'<select onchange="' + uf('mode') + '" 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"' + ((f.mode || '00') === '00' ? ' selected' : '') + '>00 — mode 0</option>' +
|
||
'<option value="01"' + (f.mode === '01' ? ' selected' : '') + '>01 — mode 1</option></select></div>') +
|
||
'</div>';
|
||
if (isCreate) {
|
||
htmlFcp += '<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">Record length (hex, record files)</label>' +
|
||
'<input value="' + escHtml(f.recLen || '') + '" oninput="' + uf('recLen') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" maxlength="4" placeholder="006E"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">LCSI</label>' +
|
||
'<input value="' + escHtml(f.lcsi || '05') + '" oninput="' + uf('lcsi') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" maxlength="2"></div>' +
|
||
'<div class="flex items-end pb-1"><label class="flex items-center gap-1 text-xs"><input type="checkbox"' + (f.shareable ? ' checked' : '') +
|
||
' onchange="chainUpdateField(\'' + chainId + '\',' + idx + ',\'shareable\',this.checked)" class="rounded"> Shareable</label></div></div>';
|
||
}
|
||
htmlFcp += '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">FCP template (hex)</label>' +
|
||
'<textarea oninput="' + uf('fcp') + '" 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.fcp || '') + '</textarea></div>' +
|
||
'<button type="button" onclick="chainBuildFcp(\'' + chainId + '\',' + idx + ',\'' + (isCreate ? 'create' : 'resize') + '\')" class="text-xs px-2 py-1 bg-slate-600 text-white rounded hover:bg-slate-700">Build template</button>';
|
||
return htmlFcp;
|
||
}
|
||
if (cmd === 'delete-file') {
|
||
return '<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" maxlength="4" placeholder="6F00"></div>';
|
||
}
|
||
if (cmd === 'set-data' || cmd === 'retrieve-data') {
|
||
var sdMode = f.sdMode || '80';
|
||
var htmlSd = '<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">P2 (block)</label>' +
|
||
'<select onchange="' + uf('sdMode') + '" 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"' + (sdMode === '80' ? ' selected' : '') + '>80 — first block</option>' +
|
||
'<option value="00"' + (sdMode === '00' ? ' selected' : '') + '>00 — next block</option>' +
|
||
'<option value="40"' + (sdMode === '40' ? ' selected' : '') + '>40 — retransmit previous</option></select></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">SFI (00 = current EF)</label>' +
|
||
'<input value="' + escHtml(f.sfi || '00') + '" oninput="' + uf('sfi') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" maxlength="2"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' + (cmd === 'set-data' ? 'Data (BER-TLV hex)' : 'Tag (hex, 5C = tag list)') + '</label>' +
|
||
'<input value="' + escHtml(cmd === 'set-data' ? (f.value || '') : (f.tag || '')) + '" oninput="' + uf(cmd === 'set-data' ? 'value' : '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"></div></div>';
|
||
if (cmd === 'retrieve-data') {
|
||
htmlSd += '<div class="mb-2"><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" maxlength="2"></div>';
|
||
}
|
||
return htmlSd;
|
||
}
|
||
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(chainId, 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(chainId, idx, f, uf);
|
||
// Toolkit
|
||
html += chainRamToolkitHtml(chainId, 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(chainId, 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(chainId, 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(\'' + chainId + '\',' + 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 === 'put-key') {
|
||
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">Key version (P1, hex)</label>' +
|
||
'<input value="' + escHtml(f.keyVer || '01') + '" oninput="' + uf('keyVer') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" maxlength="2"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Key identifier (P2, hex)</label>' +
|
||
'<input value="' + escHtml(f.keyId || '01') + '" oninput="' + uf('keyId') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" maxlength="2"></div></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Key data (hex, GP Card Spec 11.8 — new key under the KEK / target key, optional KCV)</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 === 'push') {
|
||
var pt = f.pushType || '01';
|
||
var htmlPush = '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Request (P2)</label>' +
|
||
'<select onchange="' + uf('pushType') + ';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="01"' + (pt === '01' ? ' selected' : '') + '>01 — BIP channel opening (TS 102 226 9.2.1)</option>' +
|
||
'<option value="02"' + (pt === '02' ? ' selected' : '') + '>02 — CAT_TP link establishment (9.2.2)</option>' +
|
||
'<option value="03"' + (pt === '03' ? ' selected' : '') + '>03 — TCP connection (9.2.3)</option>' +
|
||
'<option value="04"' + (pt === '04' ? ' selected' : '') + '>04 — identification packet (9.2.4)</option></select></div>';
|
||
if (pt === '02') {
|
||
htmlPush += '<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">Destination port (hex)</label>' +
|
||
'<input value="' + escHtml(f.pushPort || '') + '" oninput="' + uf('pushPort') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" maxlength="4" placeholder="1F90"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Max SDU (hex, optional)</label>' +
|
||
'<input value="' + escHtml(f.pushSdu || '') + '" oninput="' + uf('pushSdu') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" maxlength="4"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Identification data (hex, optional)</label>' +
|
||
'<input value="' + escHtml(f.pushIdent || '') + '" oninput="' + uf('pushIdent') + '" 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>';
|
||
} else if (pt === '03') {
|
||
htmlPush += '<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">Destination port (hex)</label>' +
|
||
'<input value="' + escHtml(f.pushPort || '') + '" oninput="' + uf('pushPort') + '" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 dark:bg-slate-800" maxlength="4" placeholder="0050"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Bearer description (hex, empty = 350103 default bearer)</label>' +
|
||
'<input value="' + escHtml(f.pushBearer || '') + '" oninput="' + uf('pushBearer') + '" 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="350103"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Destination address value (hex: 21+IPv4 / 57+IPv6 / F0+FQDN)</label>' +
|
||
'<input value="' + escHtml(f.pushAddr || '') + '" oninput="' + uf('pushAddr') + '" 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="210A000001"></div>' +
|
||
'<div><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Network access name / APN (text)</label>' +
|
||
'<input value="' + escHtml(f.pushApn || '') + '" oninput="' + uf('pushApn') + '" 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>';
|
||
} else if (pt === '04') {
|
||
htmlPush += '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">Identification data (hex, optional — ICCID is used when absent)</label>' +
|
||
'<input value="' + escHtml(f.pushIdent || '') + '" oninput="' + uf('pushIdent') + '" 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>';
|
||
}
|
||
var extraField = pt === '01' ? 'pushData' : 'pushExtra';
|
||
htmlPush += '<div class="mb-2"><label class="block text-xs text-gray-600 dark:text-slate-400 mb-0.5">' +
|
||
(pt === '01' ? 'OPEN CHANNEL COMPREHENSION-TLVs (hex, optional)' : 'Extra TLVs (hex, optional)') + '</label>' +
|
||
'<textarea oninput="' + uf(extraField) + '" 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[extraField] || '') + '</textarea></div>';
|
||
return htmlPush;
|
||
}
|
||
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(chainId, 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-' + chainId + '-' + byteIdx + '-' + idx + '"' +
|
||
(checked ? ' checked' : '') + ' onchange="chainRamUpdatePriv(\'' + chainId + '\',' + idx + ')"> ' + c[0] + '</label>';
|
||
});
|
||
html += '</div></div>';
|
||
return html;
|
||
}
|
||
|
||
function chainRamUpdatePriv(chainId, idx) {
|
||
var b1 = 0, b2 = 0, b3 = 0;
|
||
document.querySelectorAll('.chain-priv-' + chainId + '-b1-' + idx + ':checked').forEach(function(cb) { b1 |= parseInt(cb.value, 16); });
|
||
document.querySelectorAll('.chain-priv-' + chainId + '-b2-' + idx + ':checked').forEach(function(cb) { b2 |= parseInt(cb.value, 16); });
|
||
document.querySelectorAll('.chain-priv-' + chainId + '-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(chainId, idx, 'priv', hex);
|
||
}
|
||
|
||
// ===== RAM/GP toolkit sub-form (per-row) =====
|
||
function chainRamToolkitHtml(chainId, 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(\'' + chainId + '\',' + idx + ',\'tkEnabled\',this.checked);chainRender(\'' + chainId + '\')" class="rounded">' +
|
||
'SIM/UICC Toolkit parameters</label></div>';
|
||
if (!enabled) return html;
|
||
var tkMode = f.tkMode || 'ea';
|
||
var priority = f.tkPriority || '255';
|
||
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 || '';
|
||
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 (0 = RFU)</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 class="col-span-4"><label class="flex items-center gap-1 text-gray-600 dark:text-slate-400">' +
|
||
'<input type="checkbox"' + (f.tkFsAccess ? ' checked' : '') + ' onchange="chainUpdateField(\'' + chainId + '\',' + idx + ',\'tkFsAccess\',this.checked);chainRender(\'' + chainId + '\')" class="rounded">' +
|
||
'File system access (full)</label></div>';
|
||
html += '<div class="col-span-4"><label class="flex items-center gap-1 text-gray-600 dark:text-slate-400">' +
|
||
'<input type="checkbox"' + (f.tkAdfAccess ? ' checked' : '') + ' onchange="chainUpdateField(\'' + chainId + '\',' + idx + ',\'tkAdfAccess\',this.checked);chainRender(\'' + chainId + '\')" class="rounded">' +
|
||
'ADF.USIM access (full)</label></div>';
|
||
html += '<div class="col-span-4"><label class="block text-gray-600 dark:text-slate-400 mb-0.5">ADF AID (hex)</label>' +
|
||
'<input value="' + escHtml(f.tkAdfAid || 'A0000000871002') + '" oninput="' + uf('tkAdfAid') + '" 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="32"></div>';
|
||
}
|
||
html += '</div></div>';
|
||
return html;
|
||
}
|
||
|
||
// ===== SIM/USIM hex builder =====
|
||
function chainSimBuildRowHex(chainId, idx, row) {
|
||
var f = row.fields;
|
||
var isSim = chainKind(chainId) === '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';
|
||
}
|
||
function sdP2(f) {
|
||
var base = parseInt((f.sdMode || '80').replace(/[^0-9a-fA-F]/g, '') || '80', 16);
|
||
var sfi = parseInt((f.sfi || '00').replace(/[^0-9a-fA-F]/g, '') || '00', 16);
|
||
return (base | (sfi & 0x1F)) & 0xFF;
|
||
}
|
||
if (row.cmd === 'search-record') {
|
||
var pattern = (f.srPattern || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (!pattern) return '';
|
||
var srm = parseInt(f.srMode || (isSim ? '02' : '10'), 16);
|
||
if (isSim) {
|
||
// TS 51 011 9.2.7 SEEK: type/mode in P2, pattern in the data field.
|
||
var seekLe = (f.le || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
return buildApdu(0xA0, 0xA2, 0x00, srm, pattern.length / 2, pattern) + seekLe;
|
||
}
|
||
var ind = '';
|
||
if (srm === 0x18) {
|
||
// Enhanced search: 2-byte indication (TS 102 221 table 11.13) first.
|
||
ind = (f.srInd || '1800').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (ind.length !== 4) return '';
|
||
}
|
||
var srData = ind + pattern;
|
||
var rec = parseInt((f.srRec || '00').replace(/[^0-9a-fA-F]/g, '') || '00', 16);
|
||
var srLe = (f.le || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
return buildApdu(cla, 0xA2, rec, srm, srData.length / 2, srData) + srLe;
|
||
}
|
||
if (row.cmd === 'increase') {
|
||
var add = (f.value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (!add || add.length % 2) return '';
|
||
var incLe = (f.le || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (isSim) {
|
||
// TS 51 011 9.2.8: class A0, P1/P2 = 00, P3 = 03 (3-byte value).
|
||
return buildApdu(0xA0, 0x32, 0x00, 0x00, add.length / 2, add) + incLe;
|
||
}
|
||
var incSfi = parseInt((f.sfi || '00').replace(/[^0-9a-fA-F]/g, '') || '00', 16);
|
||
return buildApdu(cla, 0x32, incSfi ? (0x80 | (incSfi & 0x1F)) : 0x00, 0x00, add.length / 2, add) + incLe;
|
||
}
|
||
if (row.cmd === 'create-file') {
|
||
var fcp = (f.fcp || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (fcp.length < 4 || fcp.substr(0, 2) !== '62') return '';
|
||
return buildApdu(cla, 0xE0, 0x00, 0x00, fcp.length / 2, fcp);
|
||
}
|
||
if (row.cmd === 'delete-file') {
|
||
var dfid = (f.fid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (dfid.length !== 4) return '';
|
||
return buildApdu(cla, 0xE4, 0x00, 0x00, 0x02, dfid);
|
||
}
|
||
if (row.cmd === 'resize-file') {
|
||
var rfcp = (f.fcp || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (rfcp.length < 4 || rfcp.substr(0, 2) !== '62') return '';
|
||
// TS 102 222 table 1: RESIZE FILE uses CLA '8X'/'CX' (80 without SM).
|
||
return buildApdu(0x80, 0xD4, parseInt(f.mode || '00', 16), 0x00, rfcp.length / 2, rfcp);
|
||
}
|
||
if (row.cmd === 'set-data') {
|
||
var sdData = (f.value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (!sdData) return '';
|
||
return buildApdu(cla, 0xDB, 0x00, sdP2(f), sdData.length / 2, sdData);
|
||
}
|
||
if (row.cmd === 'retrieve-data') {
|
||
var rdTag = (f.tag || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
var rdLe = (f.le || '00').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
var rdP2 = sdP2(f);
|
||
if (rdTag) return buildApdu(cla, 0xCB, 0x00, rdP2, rdTag.length / 2, rdTag) + rdLe;
|
||
return buildApdu(cla, 0xCB, 0x00, rdP2, null, null) + rdLe;
|
||
}
|
||
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);
|
||
}
|
||
|
||
// Compose the FCP template for the CREATE FILE / RESIZE FILE builders
|
||
// (TS 102 222 tables 4 and 17). The access rule uses the compact format with
|
||
// AM=7F (all EF commands) and SC=00 (no condition) - review it for the card.
|
||
function chainBuildFcp(chainId, idx, kind) {
|
||
var row = _chains[chainId] && _chains[chainId].rows[idx];
|
||
if (!row) return;
|
||
var f = row.fields;
|
||
var fid = (f.fid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (fid.length !== 4) return;
|
||
var size = (f.fileSize || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (size.length % 2) size = '0' + size;
|
||
if (!size) size = '0000';
|
||
var inner;
|
||
if (kind === 'create') {
|
||
var st = f.structure || 'transparent';
|
||
var record = st === 'linear_fixed' || st === 'cyclic';
|
||
var fd = ({ transparent: 0x01, linear_fixed: 0x02, cyclic: 0x06, ber_tlv: 0x39 })[st] || 0x01;
|
||
if (f.shareable) fd |= 0x40;
|
||
var recLen = (f.recLen || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (record) {
|
||
if (recLen.length % 2) recLen = '0' + recLen;
|
||
if (recLen.length !== 4) return;
|
||
if (size === '0000') size = recLen;
|
||
}
|
||
inner = '82' + (record ? '04' : '02') + fd.toString(16).padStart(2, '0').toUpperCase() + '21' + (record ? recLen : '');
|
||
inner += '83' + '02' + fid;
|
||
inner += '8A' + '01' + (f.lcsi || '05').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2).toUpperCase();
|
||
inner += '8C' + '02' + '7F00';
|
||
inner += '80' + '02' + size.padStart(4, '0').slice(-4);
|
||
if (st === 'ber_tlv') inner += 'A5' + '00';
|
||
} else {
|
||
inner = '83' + '02' + fid + '80' + '02' + size.padStart(4, '0').slice(-4);
|
||
}
|
||
f.fcp = '62' + berLenStr(inner.length / 2) + inner;
|
||
chainRender(chainId);
|
||
}
|
||
|
||
// ===== RFM SELECT file picker =====
|
||
// The picker merges three file sources, deduped by the canonical FID path:
|
||
// the loaded file-manager tree (authoritative names and probed presence), the
|
||
// custom-file list, and the standard list shipped as frontend/uicc_files.json
|
||
// (built from pySim by pysim_simple_server/uicc_files.py and precached by the
|
||
// service worker, so the builders work offline). Probed-absent files
|
||
// (exists === false) are dropped; without a card the list is the specs default
|
||
// until "Probe all files" has run in the file manager.
|
||
let _uiccStdFiles = null;
|
||
let _uiccStdLoad = null;
|
||
let _rfmShowAll = false;
|
||
const _rfmFilter = {};
|
||
const _rfmNotice = {};
|
||
const _rfmStartDf = {};
|
||
|
||
function rfmRowKey(chainId, idx) { return chainId + '#' + idx; }
|
||
|
||
function uiccStdFilesLoad() {
|
||
if (_uiccStdFiles) return Promise.resolve(_uiccStdFiles);
|
||
if (!_uiccStdLoad) {
|
||
_uiccStdLoad = fetch('uicc_files.json')
|
||
.then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)))
|
||
.then(doc => { _uiccStdFiles = (doc && Array.isArray(doc.files)) ? doc.files : []; })
|
||
.catch(() => { _uiccStdFiles = []; })
|
||
.then(() => {
|
||
// Redraw pickers that were drawn before the list arrived.
|
||
Object.keys(_chains).forEach(id => {
|
||
if (document.getElementById(id + '-rows')) chainRender(id);
|
||
});
|
||
return _uiccStdFiles;
|
||
});
|
||
}
|
||
return _uiccStdLoad;
|
||
}
|
||
|
||
function rfmParentPath(path) { return (path || '').split('/').slice(0, -1).join('/'); }
|
||
function rfmEntryByPath(list, path) {
|
||
const k = (path || '').toUpperCase();
|
||
for (const e of list) if (e.path === k) return e;
|
||
return null;
|
||
}
|
||
function rfmRoots(list) {
|
||
const set = new Set();
|
||
for (const e of list) set.add(e.root);
|
||
const roots = Array.from(set);
|
||
roots.sort((a, b) => (a === 'MF' ? -1 : b === 'MF' ? 1 : a.localeCompare(b)));
|
||
return roots;
|
||
}
|
||
|
||
// Merge the file sources; the first source wins per canonical path.
|
||
function rfmFileEntries() {
|
||
const out = [];
|
||
const seen = new Set();
|
||
const nodes = {};
|
||
const add = (path, name, fid, kind, source, exists, aid) => {
|
||
const key = (path || '').toUpperCase();
|
||
if (!key || key.split('/').length < 2) return; // roots are group headers
|
||
if (exists === false) return; // probed absent
|
||
if (seen.has(key)) return;
|
||
seen.add(key);
|
||
out.push({
|
||
path: key, name: name, fid: (fid || '').toUpperCase(), kind: kind,
|
||
source: source, exists: exists, aid: (aid || '').toUpperCase(),
|
||
root: key.split('/')[0],
|
||
});
|
||
};
|
||
if (pysimFsTreeRoot) {
|
||
const walk = node => {
|
||
for (const ch of (node.children || [])) {
|
||
const p = pysimFsNodePath(ch);
|
||
if (p) nodes[p.toUpperCase()] = ch;
|
||
add(p, ch.name, ch.fid,
|
||
ch.isDir ? ((ch.name || '').startsWith('ADF.') ? 'adf' : 'df') : 'ef',
|
||
ch.custom ? 'custom' : 'card', ch.exists);
|
||
if (ch.isDir) walk(ch);
|
||
}
|
||
};
|
||
walk(pysimFsTreeRoot);
|
||
}
|
||
for (const c of pysimCustomFiles) {
|
||
const node = nodes[(c.path || '').toUpperCase()];
|
||
add(c.path, c.name, pysimCustomFid(c), c.kind === 'df' ? 'df' : 'ef',
|
||
'custom', node ? node.exists : null);
|
||
}
|
||
for (const e of (_uiccStdFiles || [])) {
|
||
const node = nodes[(e.path || '').toUpperCase()];
|
||
add(e.path, e.name, e.fid, e.kind, 'standard', node ? node.exists : null, e.aid);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// --- session context (the implicit "current DF") ---
|
||
// TS 102 226 7.2/7.3: the UICC Shared File System RFM application starts in
|
||
// MF, an ADF RFM application in its ADF - it depends on the TAR the secured
|
||
// packet is sent to, not on the builder, so the picker lets it be changed.
|
||
function rfmStartDf(chainId) {
|
||
if (!_rfmStartDf[chainId]) {
|
||
// MF for both builders: the real implicit DF follows the TAR the
|
||
// secured packet is sent to (TS 102 226 7.2/7.3), so the picker
|
||
// starts neutral and the per-row selector covers ADF sessions.
|
||
_rfmStartDf[chainId] = 'MF';
|
||
}
|
||
return _rfmStartDf[chainId];
|
||
}
|
||
function rfmSetStartDf(chainId, value) {
|
||
_rfmStartDf[chainId] = (value || 'MF').toUpperCase();
|
||
chainRender(chainId);
|
||
}
|
||
function rfmNoticeClear(chainId, idx) { delete _rfmNotice[rfmRowKey(chainId, idx)]; }
|
||
|
||
function rfmApplySelect(chainId, cur, f, list) {
|
||
const method = f.method || 'fid';
|
||
if (method === 'fid') {
|
||
const fid = (f.fid || '').toUpperCase();
|
||
const hit = list.find(e => rfmParentPath(e.path) === cur && e.fid === fid);
|
||
return hit ? (hit.kind === 'ef' ? cur : hit.path) : cur;
|
||
}
|
||
if (method === 'path') {
|
||
const hex = (f.path || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (hex.length < 4 || hex.length % 4 !== 0) return cur;
|
||
let p = (chainKind(chainId) === 'usim' && f.base === 'df') ? cur : 'MF';
|
||
for (const fid of (hex.match(/.{4}/g) || [])) {
|
||
const hit = list.find(e => rfmParentPath(e.path) === p && e.fid === fid);
|
||
if (!hit) return cur;
|
||
p = hit.path;
|
||
}
|
||
const hit = rfmEntryByPath(list, p);
|
||
return hit ? (hit.kind === 'ef' ? rfmParentPath(hit.path) : hit.path) : cur;
|
||
}
|
||
if (method === 'chain') {
|
||
let c = cur;
|
||
for (const tok of (f.chain || '').split(',')) {
|
||
const t = tok.trim().toUpperCase();
|
||
if (!t || /^C0/.test(t)) continue;
|
||
c = rfmApplySelect(chainId, c, { method: 'fid', fid: t }, list);
|
||
}
|
||
return c;
|
||
}
|
||
if (method === 'dfname') {
|
||
const aid = (f.dfname || '').toUpperCase();
|
||
const hit = list.find(e => e.kind === 'adf' && e.aid === aid);
|
||
return hit ? hit.path : cur;
|
||
}
|
||
return cur;
|
||
}
|
||
|
||
function rfmCurrentDf(chainId, idx, list) {
|
||
let cur = rfmStartDf(chainId).toUpperCase();
|
||
const rows = (_chains[chainId] || { rows: [] }).rows || [];
|
||
for (let i = 0; i < idx; i++) {
|
||
const r = rows[i];
|
||
if (r.cmd !== 'select') continue;
|
||
cur = rfmApplySelect(chainId, cur, r.fields, list) || cur;
|
||
}
|
||
return cur;
|
||
}
|
||
|
||
// FID sequence from `base` down to `target` (same root), e.g. '7F106F3A'.
|
||
function rfmFidsFrom(list, base, target) {
|
||
let p = (base || '').toUpperCase();
|
||
const t = (target || '').toUpperCase();
|
||
if (!t.startsWith(p + '/')) return '';
|
||
const fids = [];
|
||
for (const seg of t.slice(p.length + 1).split('/')) {
|
||
p = p + '/' + seg;
|
||
const e = rfmEntryByPath(list, p);
|
||
if (!e || !e.fid) return '';
|
||
fids.push(e.fid);
|
||
}
|
||
return fids.join('');
|
||
}
|
||
|
||
// Relative FID chain from cur to target, using the FID search order of
|
||
// TS 102 221 11.1.1.2 (children -> parent -> children of the parent): the
|
||
// sequence never needs a hop to the common ancestor itself.
|
||
function rfmRelativeChain(list, cur, target) {
|
||
const c = (cur || '').toUpperCase().split('/');
|
||
const t = (target || '').toUpperCase().split('/');
|
||
if (c[0] !== t[0]) return ''; // different roots: FID cannot cross
|
||
let i = 0;
|
||
while (i < c.length && i < t.length && c[i] === t[i]) i++;
|
||
const fids = [];
|
||
for (let n = c.length - 1; n >= i + 1; n--) {
|
||
const e = rfmEntryByPath(list, c.slice(0, n).join('/'));
|
||
if (!e || !e.fid) return '';
|
||
fids.push(e.fid);
|
||
}
|
||
for (let n = i + 1; n <= t.length; n++) {
|
||
const e = rfmEntryByPath(list, t.slice(0, n).join('/'));
|
||
if (!e || !e.fid) return '';
|
||
fids.push(e.fid);
|
||
}
|
||
return fids.join(',');
|
||
}
|
||
|
||
// Which method can express a picked file at this row, and with what values.
|
||
// When the row's method cannot express it the fill switches - preferring path
|
||
// (one SELECT) over chain, per the agreed behaviour - and the picker says so.
|
||
function rfmSelectFill(chainId, idx, entry, list) {
|
||
if (entry.kind === 'adf') return null; // ADF roots are selected by AID (manual)
|
||
const kind = chainKind(chainId);
|
||
const cur = rfmCurrentDf(chainId, idx, list);
|
||
const row = (_chains[chainId] || { rows: [] }).rows[idx] || { fields: {} };
|
||
const method = row.fields.method || 'fid';
|
||
const rowBase = row.fields.base || 'mf';
|
||
const cands = [];
|
||
if (rfmParentPath(entry.path) === cur) {
|
||
cands.push({ method: 'fid', fields: { fid: entry.fid } });
|
||
}
|
||
const pathDf = rfmFidsFrom(list, cur, entry.path);
|
||
if (kind === 'usim' && pathDf) cands.push({ method: 'path', fields: { path: pathDf, base: 'df' } });
|
||
const pathMf = rfmFidsFrom(list, 'MF', entry.path);
|
||
if (pathMf) cands.push({ method: 'path', fields: { path: pathMf, base: 'mf' } });
|
||
const chain = rfmRelativeChain(list, cur, entry.path);
|
||
if (chain) cands.push({ method: 'chain', fields: { chain: chain } });
|
||
if (!cands.length) return null;
|
||
const same = cands.find(c => c.method === method &&
|
||
(c.fields.base === undefined || c.fields.base === rowBase));
|
||
if (same) return { method: method, fields: same.fields, switched: false };
|
||
for (const m of ['path', 'chain', 'fid']) {
|
||
const matches = cands.filter(x => x.method === m);
|
||
if (!matches.length) continue;
|
||
const sameBase = matches.find(x => x.fields.base === undefined || x.fields.base === rowBase);
|
||
return { method: m, fields: (sameBase || matches[0]).fields, switched: true };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function rfmFileOptionsHtml(chainId, idx, list) {
|
||
const key = rfmRowKey(chainId, idx);
|
||
const filter = (_rfmFilter[key] || '').toUpperCase();
|
||
const groups = new Map();
|
||
let hidden = 0;
|
||
for (const e of list) {
|
||
if (e.kind === 'adf') continue;
|
||
if (filter) {
|
||
const hay = (e.name + ' ' + e.path + ' ' + (e.sym || '')).toUpperCase();
|
||
if (hay.indexOf(filter) < 0) continue;
|
||
}
|
||
const fill = rfmSelectFill(chainId, idx, e, list);
|
||
// Default: only what the current method can express. "all files"
|
||
// reveals the rest; picking one of those switches the method (the
|
||
// option is marked with the method it would switch to).
|
||
if (!fill || (!_rfmShowAll && fill.switched)) { hidden++; continue; }
|
||
if (!groups.has(e.root)) groups.set(e.root, []);
|
||
groups.get(e.root).push({ e: e, fill: fill });
|
||
}
|
||
let html = '<option value="">— pick a file —</option>';
|
||
for (const [root, items] of groups) {
|
||
html += '<optgroup label="' + escHtml(root) + '">';
|
||
for (const it of items) {
|
||
// Path within the root, FIDs in order (the optgroup carries the
|
||
// root) - the same convention as the path field, with slashes.
|
||
const inRoot = it.e.path.slice(it.e.root.length + 1);
|
||
html += '<option value="' + escHtml(it.e.path) + '">' +
|
||
escHtml(it.e.name + ' — ' + inRoot +
|
||
(it.fill && it.fill.switched ? ' → ' + it.fill.method : '')) +
|
||
'</option>';
|
||
}
|
||
html += '</optgroup>';
|
||
}
|
||
if (!groups.size) {
|
||
html += '<option value="" disabled>' +
|
||
(hidden ? 'no file matches the current method — check "all files"' : 'no matching files') +
|
||
'</option>';
|
||
}
|
||
return html;
|
||
}
|
||
|
||
function rfmPickerHtml(chainId, idx, method) {
|
||
const list = rfmFileEntries();
|
||
const key = rfmRowKey(chainId, idx);
|
||
const cur = rfmCurrentDf(chainId, idx, list);
|
||
const filter = _rfmFilter[key] || '';
|
||
const notice = _rfmNotice[key];
|
||
const rootOpts = rfmRoots(list).map(r =>
|
||
'<option value="' + escHtml(r) + '"' + (r === rfmStartDf(chainId) ? ' selected' : '') + '>' + escHtml(r) + '</option>'
|
||
).join('');
|
||
return '<div class="mt-2 p-2 border border-gray-200 dark:border-slate-700 rounded rfm-file-picker">' +
|
||
'<div class="flex gap-1 items-center flex-wrap mb-1">' +
|
||
'<input class="rfm-file-filter font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-1.5 py-0.5 dark:bg-slate-800 w-40" placeholder="filter" value="' + escHtml(filter) + '" oninput="rfmFileFilterInput(this,\'' + chainId + '\',' + idx + ')">' +
|
||
'<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-slate-400"><input type="checkbox" class="rounded"' + (_rfmShowAll ? ' checked' : '') + ' onchange="rfmFileShowAll(this)"> all files</label>' +
|
||
'<span class="text-xs text-gray-400 dark:text-slate-500">starts in</span>' +
|
||
'<select class="rfm-file-start text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-0.5 dark:bg-slate-800" onchange="rfmSetStartDf(\'' + chainId + '\', this.value)">' + rootOpts + '</select>' +
|
||
'<span class="text-xs text-gray-400 dark:text-slate-500">DF: ' + escHtml(cur) + '</span>' +
|
||
'</div>' +
|
||
'<select class="rfm-file-select w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-800" onchange="rfmFilePick(this,\'' + chainId + '\',' + idx + ')">' + rfmFileOptionsHtml(chainId, idx, list) + '</select>' +
|
||
'<div class="rfm-file-note text-xs mt-0.5 ' + (notice ? notice.cls : 'text-gray-400 dark:text-slate-500') + '">' + escHtml(notice ? notice.text : '') + '</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
function rfmFileFilterInput(input, chainId, idx) {
|
||
_rfmFilter[rfmRowKey(chainId, idx)] = input.value;
|
||
const sel = input.closest('.rfm-file-picker').querySelector('.rfm-file-select');
|
||
if (sel) sel.innerHTML = rfmFileOptionsHtml(chainId, idx, rfmFileEntries());
|
||
}
|
||
|
||
function rfmFileShowAll(cb) {
|
||
_rfmShowAll = cb.checked;
|
||
Object.keys(_chains).forEach(id => {
|
||
if (document.getElementById(id + '-rows')) chainRender(id);
|
||
});
|
||
}
|
||
|
||
function rfmFilePick(sel, chainId, idx) {
|
||
const key = rfmRowKey(chainId, idx);
|
||
const path = sel.value;
|
||
if (!path) return;
|
||
const list = rfmFileEntries();
|
||
const entry = list.find(e => e.path === path);
|
||
if (!entry) return;
|
||
const fill = rfmSelectFill(chainId, idx, entry, list);
|
||
if (!fill) {
|
||
_rfmNotice[key] = {
|
||
text: entry.name + ': not reachable from ' + rfmCurrentDf(chainId, idx, list) +
|
||
' with FID/path/chain — an ADF is selected by AID (kept manual).',
|
||
cls: 'text-amber-600 dark:text-amber-400',
|
||
};
|
||
chainRender(chainId);
|
||
return;
|
||
}
|
||
const row = _chains[chainId].rows[idx];
|
||
row.fields = Object.assign({}, row.fields, fill.fields, { method: fill.method });
|
||
_rfmNotice[key] = fill.switched
|
||
? { text: 'switched to ' + fill.method + ' for ' + entry.name +
|
||
' (' + (fill.fields.path || fill.fields.chain || fill.fields.fid) + ')',
|
||
cls: 'text-gray-500 dark:text-slate-400' }
|
||
: { text: '', cls: 'text-gray-400 dark:text-slate-500' };
|
||
chainRender(chainId);
|
||
}
|
||
|
||
// ===== RAM/GP hex builder =====
|
||
// PUSH command data field (TS 102 226 9.2.1-9.2.4):
|
||
// 01 BIP channel opening - OPEN CHANNEL COMPREHENSION-TLVs (optional)
|
||
// 02 CAT_TP link - transport level 3C (protocol type 00) plus
|
||
// optional buffer size 39 / channel data 36
|
||
// 03 TCP connection - bearer 35, transport level 3C (protocol type 02,
|
||
// TCP client remote), destination 3E, NAA 47
|
||
// 04 identification packet - channel data 36 (optional)
|
||
function chainPushData(f) {
|
||
function hx(s) { return (s || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); }
|
||
function port(v) { var h = hx(v); return h.length === 4 ? h : ''; }
|
||
var pt = f.pushType || '01';
|
||
if (pt === '01') return hx(f.pushData);
|
||
if (pt === '04') {
|
||
var id4 = hx(f.pushIdent);
|
||
return id4 ? '36' + berLenStr(id4.length / 2) + id4 : '';
|
||
}
|
||
if (pt === '02') {
|
||
var catPort = port(f.pushPort);
|
||
if (!catPort) return '';
|
||
var out2 = '3C' + '03' + '00' + catPort;
|
||
var sdu = port(f.pushSdu);
|
||
if (sdu) out2 += '39' + '02' + sdu;
|
||
var id2 = hx(f.pushIdent);
|
||
if (id2) out2 += '36' + berLenStr(id2.length / 2) + id2;
|
||
return out2 + hx(f.pushExtra);
|
||
}
|
||
var out3 = hx(f.pushBearer) || '350103';
|
||
out3 += '3C' + '03' + '02' + (port(f.pushPort) || '0000');
|
||
var addr = hx(f.pushAddr);
|
||
if (addr) out3 += '3E' + berLenStr(addr.length / 2) + addr;
|
||
var apn = _hotaAsciiHex((f.pushApn || '').trim());
|
||
if (apn) out3 += '47' + berLenStr(apn.length / 2) + apn;
|
||
return out3 + hx(f.pushExtra);
|
||
}
|
||
|
||
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;
|
||
// Table 11-43: privileges are 1 or 3 bytes (never 2).
|
||
if (b2 === 0 && b3 === 0) return b1.toString(16).padStart(2, '0').toUpperCase();
|
||
return b1.toString(16).padStart(2, '0').toUpperCase() +
|
||
b2.toString(16).padStart(2, '0').toUpperCase() +
|
||
b3.toString(16).padStart(2, '0').toUpperCase();
|
||
}
|
||
// The chain row's toolkit fields -> the install parameters hex.
|
||
function buildTkParams() {
|
||
if (!f.tkEnabled) return '';
|
||
return stkParamsBuild({
|
||
mode: f.tkMode || 'ea',
|
||
priority: f.tkPriority, timers: f.tkTimers, textLen: f.tkTextlen,
|
||
menus: f.tkMenus, firstPos: f.tkFirstpos, firstId: f.tkFirstid,
|
||
lastPos: f.tkLastpos, lastId: f.tkLastid, channels: f.tkChannels,
|
||
msl: f.tkMsl || '16', tar: f.tkTar, ad: f.tkAd,
|
||
services: f.tkServices,
|
||
fsAccess: f.tkFsAccess, adfAccess: f.tkAdfAccess, adfAid: f.tkAdfAid,
|
||
});
|
||
}
|
||
// INSTALL/LOAD are case-3 commands (no trailing Le), matching the
|
||
// reference terminal form and the server's _cap_apdu_sequence: a trailing
|
||
// Le made the card execute a phantom second command whose status word
|
||
// masked the real result (v3.6.8). DELETE/GET STATUS/GET DATA keep Le.
|
||
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);
|
||
}
|
||
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);
|
||
}
|
||
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);
|
||
}
|
||
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);
|
||
}
|
||
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);
|
||
}
|
||
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);
|
||
}
|
||
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);
|
||
// case 3 (no Le), like the server's ramDeleteApdu: a trailing Le byte
|
||
// becomes a phantom command on the card's SCP80 layer (SW 6700).
|
||
return buildApdu(0x80, 0xE4, 0x00, parseInt(delMode, 16), parseInt(dl7, 16), df7);
|
||
}
|
||
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 === 'put-key') {
|
||
var pkData = (f.data || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (!pkData) return '';
|
||
return buildApdu(0x80, 0xD0, parseInt(f.keyVer || '01', 16), parseInt(f.keyId || '01', 16), pkData.length / 2, pkData);
|
||
}
|
||
if (cmd === 'push') {
|
||
var pushData = chainPushData(f);
|
||
var pushP2 = parseInt(f.pushType || '01', 16);
|
||
if (!pushData) return buildApdu(0x80, 0xEC, 0x01, pushP2, null, null);
|
||
return buildApdu(0x80, 0xEC, 0x01, pushP2, pushData.length / 2, pushData);
|
||
}
|
||
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 '';
|
||
const kind = chainKind(chainId);
|
||
if (kind === 'sim' || kind === '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 =====
|
||
// One APDU per built command, for consumers outside the compact chain: a row
|
||
// that expands to several APDUs (SELECT by FID chain) is split, and GET
|
||
// RESPONSE rows are dropped because the expanded format does not use them
|
||
// (TS 102 226 5.2.1.1).
|
||
function chainApduList(chainId) {
|
||
const list = [];
|
||
chainInit(chainId);
|
||
const rows = _chains[chainId].rows;
|
||
for (let i = 0; i < rows.length; i++) {
|
||
if (rows[i].cmd === 'get-response') continue;
|
||
const h = chainBuildRowHex(chainId, i);
|
||
if (!h) continue;
|
||
if (rows[i].cmd === 'select' && (rows[i].fields.method || 'fid') === 'chain') {
|
||
parseCompactApdus(h).forEach(function(n) {
|
||
if (n.hex && /^APDU/.test(n.label)) list.push(n.hex);
|
||
});
|
||
} else {
|
||
list.push(h);
|
||
}
|
||
}
|
||
return list;
|
||
}
|
||
|
||
function chainRamApduList() {
|
||
return chainApduList('chain-ram');
|
||
}
|
||
|
||
// Append one raw APDU as a C-APDU row of the Expanded Script builder.
|
||
function berAppendApdu(hex) {
|
||
const row = addBerRow('c-apdu');
|
||
const input = row.querySelector('.ber-hex');
|
||
if (input) input.value = hex;
|
||
updateBerRow(row.querySelector('.ber-type'));
|
||
}
|
||
|
||
// Import a built chain into the Expanded Script builder as C-APDU rows.
|
||
function loadExpandedScript(chainId) {
|
||
const apdus = chainApduList(chainId);
|
||
if (!apdus.length) return;
|
||
cApduSwitchSubtab('ber');
|
||
apdus.forEach(berAppendApdu);
|
||
genBerTlv();
|
||
}
|
||
|
||
function ramBuildExpanded() {
|
||
// Show each built command in the TS 102 226 Command Scripting (expanded)
|
||
// form: AA <len> 22 <len> <apdu> or AE80 22 <len> <apdu> 0000.
|
||
const fmt = (document.getElementById('chain-ram-expand-fmt').value || 'AE80');
|
||
const out = chainRamApduList().map(apdu => {
|
||
if (fmt === 'AA') {
|
||
const tlv = '22' + berLenStr(apdu.length / 2) + apdu;
|
||
return 'AA' + berLenStr(tlv.length / 2) + tlv;
|
||
}
|
||
return 'AE8022' + berLenStr(apdu.length / 2) + apdu + '0000';
|
||
});
|
||
document.getElementById('chain-ram-expanded').value = out.join('\n');
|
||
}
|
||
|
||
async function ramQueueScp81() {
|
||
const msg = document.getElementById('chain-ram-queue-msg');
|
||
const apdus = chainRamApduList();
|
||
if (!apdus.length) { msg.textContent = t('No commands'); msg.className = 'text-xs text-red-500'; return; }
|
||
msg.textContent = '';
|
||
try {
|
||
const resp = await pysimFetch('/api/scp81/queue', { apdus: apdus, kind: 'custom' });
|
||
if (resp.ok) {
|
||
msg.textContent = t('Queued') + ': ' + resp.apdus + ' ' + t('APDUs') + ' - ' + t('send a push to run it');
|
||
msg.className = 'text-xs text-emerald-600 dark:text-emerald-400';
|
||
} else {
|
||
msg.textContent = resp.error || t('Error');
|
||
msg.className = 'text-xs text-red-500';
|
||
}
|
||
} catch (err) {
|
||
msg.textContent = String(err.message || err);
|
||
msg.className = 'text-xs text-red-500';
|
||
}
|
||
}
|
||
|
||
// Which TAR of the selected preset feeds the SCP80 form: RAM/GP -> ISD,
|
||
// SIM RFM -> UICC, USIM RFM -> USIM (spec defaults when the value is empty).
|
||
let _spTarKey = 'tar';
|
||
// RAM/GP chains packed into the SCP80 form opt in to the per-operation
|
||
// command-format detection ('auto'); other packs (HOTA, §9 push, expanded
|
||
// script) keep their payload byte-exact.
|
||
let _spRamFormat = null;
|
||
|
||
function spTarKeyForPack(sourceId) {
|
||
return {
|
||
'chain-ram-preview': 'tar',
|
||
'chain-sim-preview': 'uiccTar',
|
||
'chain-usim-preview': 'usimTar',
|
||
}[sourceId] || null;
|
||
}
|
||
|
||
function spPresetTar(key) {
|
||
const sel = document.getElementById('sp-card-sel');
|
||
const idx = sel ? parseInt(sel.value, 10) : NaN;
|
||
const c = (!isNaN(idx) && cards[idx]) ? cards[idx] : null;
|
||
return cardsTarValue(key, c ? c[key] : '');
|
||
}
|
||
|
||
function packToSp(sourceId) {
|
||
const val = document.getElementById(sourceId).value;
|
||
if (!val) return;
|
||
switchTab('scp80');
|
||
scp80SwitchSubtab('sp');
|
||
document.getElementById('sp-apdu').value = val;
|
||
const key = spTarKeyForPack(sourceId);
|
||
if (key) {
|
||
_spTarKey = key;
|
||
document.getElementById('sp-tar').value = spPresetTar(key);
|
||
}
|
||
_spRamFormat = sourceId === 'chain-ram-preview' ? 'auto' : null;
|
||
}
|
||
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) % 0x10000000000; // wrap the 5-byte counter
|
||
el.value = val.toString(16).padStart(10, '0').toUpperCase();
|
||
}
|
||
function spInvalidate() {
|
||
document.getElementById('sp-result').value = '';
|
||
spShowSizeInfo();
|
||
}
|
||
|
||
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();
|
||
spKeysetSync();
|
||
}
|
||
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();
|
||
spKeysetSync();
|
||
}
|
||
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);
|
||
}
|
||
|
||
// Redundancy Check (RC) = CRC-32 (TS 102 225 5.1.3.2): reflected polynomial
|
||
// 0xEDB88320, init and final XOR 0xFFFFFFFF, result big-endian - byte-identical
|
||
// to pySim's zlib.crc32() usage.
|
||
function crc32Bytes(data) {
|
||
let crc = 0xFFFFFFFF;
|
||
for (let i = 0; i < data.length; i++) {
|
||
crc ^= data[i];
|
||
for (let k = 0; k < 8; k++) crc = (crc >>> 1) ^ (0xEDB88320 & -(crc & 1));
|
||
}
|
||
crc = (crc ^ 0xFFFFFFFF) >>> 0;
|
||
return new Uint8Array([(crc >>> 24) & 0xFF, (crc >>> 16) & 0xFF, (crc >>> 8) & 0xFF, crc & 0xFF]);
|
||
}
|
||
|
||
// ===== 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 (CC 8, RC 4).
|
||
// CPI (0x70) and CHI (null) are NOT part of the SMS packet data.
|
||
function genSp() {
|
||
// The packet's keyset number must be defined in the selected preset
|
||
// (TS 102 225 A.2 / the "keyset must exist" rule).
|
||
const ksErr = spKeysetGuard();
|
||
if (ksErr) { alert(ksErr); return; }
|
||
_genSpBuild();
|
||
spShowSizeInfo();
|
||
}
|
||
|
||
// ===== SCP80 SMS segmentation (TS 31.115 4.2/4.3) =====
|
||
// Capacities mirror the server's SCP80_*_BYTES constants (pysim_simple_server/
|
||
// server.py): 140 octets of SMS user data minus the UDH, which carries the
|
||
// concatenation IE (5 octets) in every SM and the CPI IE (2 octets) in the
|
||
// first one of a concatenated command.
|
||
const SCP80_MAX_SMS = 5;
|
||
const SCP80_SINGLE_BYTES = 137;
|
||
const SCP80_FIRST_BYTES = 132;
|
||
const SCP80_NEXT_BYTES = 134;
|
||
|
||
// Secured packet hex -> {bytes, segments} of the SMS-PP download.
|
||
function scp80SegmentInfo(hex) {
|
||
const bytes = Math.floor((hex || '').replace(/[^0-9a-fA-F]/g, '').length / 2);
|
||
if (!bytes) return { bytes: 0, segments: 0 };
|
||
if (bytes <= SCP80_SINGLE_BYTES) return { bytes: bytes, segments: 1 };
|
||
let segments = 1;
|
||
let rest = bytes - SCP80_FIRST_BYTES;
|
||
while (rest > 0) { segments += 1; rest -= SCP80_NEXT_BYTES; }
|
||
return { bytes: bytes, segments: segments };
|
||
}
|
||
|
||
function spSizeInfoText(hex) {
|
||
const info = scp80SegmentInfo(hex);
|
||
if (!info.segments) return '';
|
||
let s = info.bytes + ' ' + t('bytes') + ' · ' + info.segments + ' SMS';
|
||
if (info.segments > 1) s += ' (' + t('concatenated') + ')';
|
||
if (info.segments > SCP80_MAX_SMS) {
|
||
s += ' — ' + t('too large for the card concatenation buffer') + ' (' + SCP80_MAX_SMS + ' SMS)';
|
||
}
|
||
return s;
|
||
}
|
||
|
||
// Refresh the packet size / SMS count line under the packet field.
|
||
function spShowSizeInfo() {
|
||
const el = document.getElementById('sp-size-info');
|
||
if (!el) return;
|
||
const value = (document.getElementById('sp-result') || {}).value || '';
|
||
const isPacket = /^\s*[0-9a-fA-F\s]+$/.test(value);
|
||
const info = isPacket ? scp80SegmentInfo(value) : { bytes: 0, segments: 0 };
|
||
el.textContent = info.segments ? spSizeInfoText(value) : '';
|
||
el.className = 'text-xs mt-1 ' + (info.segments > SCP80_MAX_SMS
|
||
? 'text-red-600 dark:text-red-400'
|
||
: 'text-gray-500 dark:text-slate-400');
|
||
}
|
||
|
||
function _genSpBuild() {
|
||
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, '').toUpperCase().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 rcCcDs = spi1 & 0x03; // 00 none / 01 RC / 10 CC / 11 DS (not offered)
|
||
const hasRc = rcCcDs === 0x01;
|
||
const hasMac = rcCcDs === 0x02;
|
||
|
||
const resultEl = document.getElementById('sp-result');
|
||
|
||
// Digital Signature is not implemented (pySim has no DS dialect either):
|
||
// refuse rather than build a packet whose SPI claims a signature that is
|
||
// not present. The selector does not offer '11'; this guards the field.
|
||
if (rcCcDs === 0x03) {
|
||
resultEl.value = 'Error: SPI1 RC/CC/DS = 11 (Digital Signature) is not supported';
|
||
return;
|
||
}
|
||
|
||
// 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 sigLen = hasMac ? 8 : (hasRc ? 4 : 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 + sigLen + apdu.length) % cipherBlock)) % cipherBlock : 0;
|
||
|
||
const chl = 13 + sigLen;
|
||
const cpl = 14 + sigLen + 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 + sigLen;
|
||
packet.set(apdu, dataOff);
|
||
for (let i = 0; i < padCnt; i++) packet[dataOff + apdu.length + i] = padByte;
|
||
|
||
if (sigLen) {
|
||
// RC/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 sigInput = new Uint8Array(packet.length - sigLen);
|
||
sigInput.set(packet.subarray(0, macOff));
|
||
sigInput.set(packet.subarray(dataOff), macOff);
|
||
if (hasRc) packet.set(crc32Bytes(sigInput), macOff);
|
||
else if (kidKey) packet.set(kidIsAes ? aesCmac(sigInput, kidKey) : cbcMac(sigInput, kidKey), 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);
|
||
}
|
||
|
||
// CPL transmission (TS 31.115 Table 1 NOTE + 4.3): it is transmitted when
|
||
// the packet is ciphered or carries RC/CC/DS - it is part of their input -
|
||
// and it is required once the packet needs SMS concatenation. A single
|
||
// unprotected SM keeps pySim's CHL-first form ("not absolutely necessary").
|
||
const withCpl = ciphering || rcCcDs !== 0 || (packet.length - 2) > SCP80_SINGLE_BYTES;
|
||
resultEl.value = bytesToHex(withCpl ? 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']];
|
||
|
||
// Stable per-row id for the embedded command chains (row indices are
|
||
// renumbered when a row is deleted, the uid is not).
|
||
let _berUidSeq = 0;
|
||
|
||
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.dataset.berUid = String(++_berUidSeq);
|
||
row.innerHTML = berRowHtml(idx, type);
|
||
c.appendChild(row);
|
||
onBerTypeChange(row.querySelector('.ber-type'));
|
||
return row;
|
||
}
|
||
|
||
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 = `<div class="flex gap-1 mb-1 flex-wrap items-center">
|
||
<select class="ber-cmd-mode text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-1 dark:bg-slate-700 dark:text-slate-300" onchange="onBerCmdModeChange(this)">
|
||
<option value="hex">Hex</option>
|
||
<option value="sim">SIM RFM</option>
|
||
<option value="usim">USIM RFM</option>
|
||
<option value="ram">RAM/GP</option>
|
||
</select>
|
||
<span class="ber-cmd-apdu text-xs font-mono text-gray-500 dark:text-slate-400"></span>
|
||
</div>
|
||
<div class="ber-cmd-editor"></div>`;
|
||
onBerCmdModeChange(body.querySelector('.ber-cmd-mode'));
|
||
} 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);
|
||
}
|
||
|
||
// ===== Expanded Script C-APDU picker (TS 102 226 5.2.1.0/5.2.1.1) =====
|
||
// A C-APDU TLV may hold any remote management command; instead of pasting
|
||
// hex, the row can host a one-row "virtual" chain built with the SIM RFM /
|
||
// USIM RFM / RAM-GP command editors (GET RESPONSE is excluded - the expanded
|
||
// format does not use it).
|
||
function berCmdChainId(uid, kind) { return 'ber-' + kind + '-' + uid; }
|
||
|
||
function onBerCmdModeChange(sel) {
|
||
const row = sel.closest('.ber-row');
|
||
const editor = row.querySelector('.ber-cmd-editor');
|
||
const mode = sel.value;
|
||
if (mode === 'hex') {
|
||
editor.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)">';
|
||
updateBerRow(sel);
|
||
return;
|
||
}
|
||
const id = berCmdChainId(row.dataset.berUid, mode);
|
||
editor.innerHTML = '<div id="' + id + '-rows"></div>';
|
||
const cmds = chainCommands(id);
|
||
_chains[id] = {rows: [{cmd: cmds[0].value, fields: {}}]};
|
||
chainListen(id, function() { berRefreshCmdRow(row, id); });
|
||
chainRender(id);
|
||
updateBerRow(sel);
|
||
}
|
||
|
||
// Echo the embedded command's APDU next to the picker and refresh the TLV
|
||
// preview whenever the one-row chain changes.
|
||
function berRefreshCmdRow(row, chainId) {
|
||
const echo = row.querySelector('.ber-cmd-apdu');
|
||
const hex = chainBuildRowHex(chainId, 0);
|
||
if (echo) echo.textContent = hex ? hex.match(/.{2}/g).join(' ') : '';
|
||
const type = row.querySelector('.ber-type');
|
||
if (type) updateBerRow(type);
|
||
}
|
||
|
||
// The C-APDU a row contributes: pasted hex or the embedded command chain.
|
||
function genBerCmdApdu(row) {
|
||
const modeSel = row.querySelector('.ber-cmd-mode');
|
||
const mode = modeSel ? modeSel.value : 'hex';
|
||
if (mode === 'hex') {
|
||
const input = row.querySelector('.ber-hex');
|
||
return input ? (input.value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase() : '';
|
||
}
|
||
return chainBuildRowHex(berCmdChainId(row.dataset.berUid, mode), 0);
|
||
}
|
||
|
||
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 = genBerCmdApdu(row);
|
||
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;
|
||
}
|
||
|
||
// GP Card Spec v2.3.1 11.1.2 privilege coding (Tables 11-7/8/9). Table 11-8
|
||
// prints "Token Management" while the rest of the spec (and the tooling)
|
||
// says "Token Verification" - the latter is used throughout. The table lives
|
||
// in the C-APDU parser section because its test evaluates that slice alone.
|
||
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'],
|
||
];
|
||
|
||
// Byte 1's b7 flags "DAP Verification" and b1 turns it into "Mandated DAP
|
||
// Verification" - the pair is decoded together so the flag bit cannot stand
|
||
// alone (v3.6.28).
|
||
function decodePrivileges(hex) {
|
||
const raw = (hex || '').replace(/[^0-9a-fA-F]/g, '');
|
||
const bytes = [];
|
||
for (let i = 0; i + 1 < raw.length; i += 2) bytes.push(parseInt(raw.substr(i, 2), 16));
|
||
const parts = [];
|
||
for (let b = 0; b < Math.min(PRIVILEGE_NAMES.length, bytes.length); b++) {
|
||
for (let bit = 0; bit < PRIVILEGE_NAMES[b].length; bit++) {
|
||
if (b === 0 && (bit === 1 || bit === 7)) continue;
|
||
if (bytes[b] & (0x80 >> bit) && PRIVILEGE_NAMES[b][bit]) parts.push(PRIVILEGE_NAMES[b][bit]);
|
||
}
|
||
if (b === 0 && (bytes[0] & 0xC0) === 0xC0) {
|
||
parts.splice(1, 0, (bytes[0] & 0x01)
|
||
? 'Mandated DAP Verification' : 'DAP Verification');
|
||
}
|
||
}
|
||
return parts.join(', ') || 'None';
|
||
}
|
||
|
||
// GSM 03.38 7-bit unpacking. `septets` (optional) is the exact character
|
||
// count — the Network Name coding carries the number of spare bits, so the
|
||
// zero padding must not decode as a trailing '@'. A final <CR> used as
|
||
// padding (TS 23.038 6.1.2.1.1) is removed when the count is known.
|
||
function gsm7Decode(octets, septets) {
|
||
const bits = [];
|
||
for (const b of octets) {
|
||
for (let j = 0; j < 8; j++) bits.push((b >> j) & 1);
|
||
}
|
||
const max = Math.floor(bits.length / 7);
|
||
const count = septets === undefined ? max : Math.min(septets, max);
|
||
let out = '';
|
||
let escape = false;
|
||
for (let i = 0; i < count; i++) {
|
||
let septet = 0;
|
||
for (let j = 0; j < 7; j++) septet |= bits[i * 7 + 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] : '?';
|
||
}
|
||
}
|
||
if (septets !== undefined && out.endsWith('\r')) out = out.slice(0, -1);
|
||
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/ISD) / LOADED (ELF)',
|
||
'03': 'INSTALLED (app/SD)',
|
||
'07': 'SELECTABLE (app/SD) / INITIALIZED (card/ISD)',
|
||
'0F': 'SECURED (card/ISD) / PERSONALIZED (SD)',
|
||
'1F': 'App-specific (app)',
|
||
'7F': 'CARD_LOCKED',
|
||
'FF': 'TERMINATED',
|
||
'83': 'LOCKED',
|
||
'87': 'LOCKED',
|
||
'8F': 'LOCKED',
|
||
};
|
||
|
||
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' || t.tag === 'C4' || t.tag === 'CC' || t.tag === '84') {
|
||
// AID-carrying tags: spaced hex plus the standard package name when known.
|
||
const spaced = t.value.replace(/(..)/g, '$1 ').trim();
|
||
const name = jcAidName(t.value);
|
||
return name ? spaced + ' (' + name + ')' : spaced;
|
||
}
|
||
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 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;
|
||
}
|
||
|
||
// ===== Test scripts (Simulator pill) =====
|
||
// Scripts are stored locally (simple_tests) and executed server-side
|
||
// (/api/test/run): the server owns the card while a run is active (other
|
||
// card endpoints answer 409) and reports the steps through
|
||
// /api/test/status. This UI edits the script and monitors the run; the
|
||
// SCP80 steps use the card preset matched by the equipped card's ICCID, and
|
||
// the final SCP80 counter is written back to it.
|
||
const TEST_SCRIPT_STORAGE = 'simple_tests';
|
||
const TEST_ACTION_KINDS = ['envelope', 'menu-select', 'file-write', 'file-read', 'apdu', 'scp80', 'status'];
|
||
let _testScripts = null;
|
||
let _testCurrentIdx = -1;
|
||
let _testRunState = null;
|
||
let _testRunTimer = null;
|
||
let _testEditStep = null;
|
||
let _testEditChecks = [];
|
||
let _testLastPresetIdx = -1;
|
||
|
||
function testTemplate(kind) {
|
||
if (kind === 'menu') {
|
||
return {name: t('STK menu browsing'), steps: [
|
||
{type: 'action', kind: 'menu-select', params: {item_id: 1},
|
||
check: {sw: {mode: 'mask', value: '91??'}}},
|
||
{type: 'expect', command: 'SELECT ITEM',
|
||
checks: [{kind: 'item', id: 1, text: 'test'}],
|
||
respond: {result: 'ok', item_id: 1}},
|
||
{type: 'expect', command: 'DISPLAY TEXT',
|
||
checks: [{kind: 'text', mode: 'contains', value: 'hello'}],
|
||
respond: {result: 'ok'}},
|
||
]};
|
||
}
|
||
if (kind === 'scp80') {
|
||
return {name: t('Applet via SCP80'), steps: [
|
||
{type: 'action', kind: 'scp80', params: {apdu: '80E2900000', tar: 'B00000'},
|
||
check: {sw: {mode: 'mask', value: '91??'}, por: 'none'}},
|
||
{type: 'expect', command: 'DISPLAY TEXT',
|
||
checks: [{kind: 'text', mode: 'contains', value: 'hello'}],
|
||
respond: {result: 'ok'}},
|
||
]};
|
||
}
|
||
return {name: t('New test script'), steps: []};
|
||
}
|
||
|
||
function testScriptsLoad() {
|
||
if (_testScripts) return _testScripts;
|
||
try {
|
||
const raw = JSON.parse(localStorage.getItem(TEST_SCRIPT_STORAGE) || '[]');
|
||
_testScripts = Array.isArray(raw) ? raw.filter(s => s && typeof s === 'object') : [];
|
||
} catch (e) {
|
||
_testScripts = [];
|
||
}
|
||
if (!_testScripts.length) _testScripts.push(testTemplate('empty'));
|
||
if (_testCurrentIdx < 0 || _testCurrentIdx >= _testScripts.length) _testCurrentIdx = 0;
|
||
return _testScripts;
|
||
}
|
||
|
||
function testScriptsSave() {
|
||
localStorage.setItem(TEST_SCRIPT_STORAGE, JSON.stringify(_testScripts || []));
|
||
}
|
||
|
||
function testCurrent() {
|
||
const scripts = testScriptsLoad();
|
||
return scripts[_testCurrentIdx] || null;
|
||
}
|
||
|
||
// Local sanity check (the server validates again); '' when the script looks good.
|
||
function testScriptProblem(script) {
|
||
if (!script || typeof script !== 'object') return t('Script must be an object');
|
||
if (!Array.isArray(script.steps)) return t('Script steps must be a list');
|
||
if (!script.steps.length) return t('Script has no steps');
|
||
for (let i = 0; i < script.steps.length; i++) {
|
||
const s = script.steps[i];
|
||
const at = t('Step') + ' ' + (i + 1) + ': ';
|
||
if (!s || typeof s !== 'object') return at + t('step must be an object');
|
||
if (s.type === 'action') {
|
||
if (TEST_ACTION_KINDS.indexOf(s.kind) < 0) return at + t('unknown action kind');
|
||
const p = s.params || {};
|
||
if (s.kind === 'apdu' && !String(p.apdu || '').trim()) return at + t('APDU is empty');
|
||
if (s.kind === 'scp80') {
|
||
const src = p.source || (p.sp ? 'sp' : 'apdu');
|
||
const value = src === 'sp' ? p.sp : p.apdu;
|
||
if (!String(value || '').trim()) return at + t('SCP80 needs an APDU or a secured packet');
|
||
}
|
||
if (s.kind === 'menu-select' && !(p.item_id >= 1 && p.item_id <= 255)) return at + t('item id must be 1..255');
|
||
if ((s.kind === 'file-write' || s.kind === 'file-read') && !String(p.path || '').trim()) return at + t('file path is empty');
|
||
} else if (s.type === 'expect') {
|
||
if (!String(s.command || '').trim()) return at + t('command is required');
|
||
} else {
|
||
return at + t('step must be an action or an expectation');
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function testCheckSummary(c) {
|
||
if (!c || !c.kind) return '';
|
||
if (c.kind === 'text') return 'text ' + (c.mode === 'exact' ? '=' : '~') + JSON.stringify(c.value || '');
|
||
if (c.kind === 'item') {
|
||
let s = 'item';
|
||
if (c.id) s += ' #' + c.id;
|
||
if (c.text) s += ' ' + (c.mode === 'exact' ? '=' : '~') + JSON.stringify(c.text);
|
||
return s;
|
||
}
|
||
if (c.kind === 'raw') return 'raw ' + (c.mode === 'mask' ? '~' : '=') + (c.value || '');
|
||
return '';
|
||
}
|
||
|
||
function testStepSummary(step) {
|
||
if (!step) return '';
|
||
if (step.type === 'expect') {
|
||
const parts = ['EXPECT ' + (step.command || '?')];
|
||
const checks = (step.checks || []).map(testCheckSummary).filter(Boolean);
|
||
if (checks.length) parts.push(checks.join(', '));
|
||
const tr = [];
|
||
if (step.respond && step.respond.result && step.respond.result !== 'ok' && step.respond.result !== 0) {
|
||
tr.push(String(step.respond.result));
|
||
}
|
||
if (step.respond && step.respond.item_id) tr.push('item=' + step.respond.item_id);
|
||
if (step.respond && step.respond.text !== undefined && step.respond.text !== '') tr.push('text=' + JSON.stringify(step.respond.text));
|
||
parts.push('TR ' + (tr.join(' ') || 'ok'));
|
||
if (step.on_fail === 'warning') parts.push('⚠');
|
||
return parts.join(' · ');
|
||
}
|
||
const p = step.params || {};
|
||
let s = '';
|
||
if (step.kind === 'envelope') s = 'ENVELOPE event=0x' + Number(p.event || 0).toString(16).toUpperCase().padStart(2, '0') + (p.data ? ' data=' + p.data : '');
|
||
else if (step.kind === 'menu-select') s = 'ENVELOPE(Menu Selection) item=' + p.item_id;
|
||
else if (step.kind === 'apdu') s = 'APDU ' + (p.apdu || '?');
|
||
else if (step.kind === 'scp80') s = 'SCP80 ' + ((p.source || (p.sp ? 'sp' : 'apdu')) === 'sp' ? (p.sp || '(secured packet)') : (p.apdu || '?')) + (p.tar ? ' TAR=' + p.tar : '') + (p.spi1 ? ' SPI1=' + p.spi1 : '') + (p.spi2 ? ' SPI2=' + p.spi2 : '');
|
||
else if (step.kind === 'file-write') s = 'UPDATE ' + (p.path || '?') + (p.mode && p.mode !== 'auto' ? ' (' + p.mode + (p.record ? ' ' + p.record : '') + ')' : '') + ' = ' + (p.data || '');
|
||
else if (step.kind === 'file-read') s = 'READ ' + (p.path || '?') + (p.mode && p.mode !== 'auto' ? ' (' + p.mode + (p.record ? ' ' + p.record : '') + ')' : '');
|
||
else if (step.kind === 'status') s = 'STATUS' + ((p.attempts || 1) > 1 ? ' x' + p.attempts + ' (poll)' : '');
|
||
else s = String(step.kind || '?');
|
||
const c = step.check || {};
|
||
const ck = [];
|
||
if (c.sw && c.sw.value && !(c.sw.mode !== 'mask' && c.sw.value === '9000')) ck.push('SW ' + (c.sw.mode === 'mask' ? '~' : '') + c.sw.value);
|
||
if (c.data && c.data.value) ck.push('data ' + (c.data.mode === 'mask' ? '~' : '') + c.data.value);
|
||
if (c.por && c.por !== 'any') ck.push('PoR ' + c.por);
|
||
if (step.on_fail === 'warning') ck.push('⚠');
|
||
return s + (ck.length ? ' · ' + ck.join(', ') : '');
|
||
}
|
||
|
||
function testRender() {
|
||
const sel = document.getElementById('test-script-sel');
|
||
if (!sel) return;
|
||
const scripts = testScriptsLoad();
|
||
sel.innerHTML = scripts.map((s, i) =>
|
||
'<option value="' + i + '"' + (i === _testCurrentIdx ? ' selected' : '') + '>' +
|
||
esc(s.name || (t('Script') + ' ' + (i + 1))) + '</option>').join('');
|
||
const cur = testCurrent();
|
||
const nameEl = document.getElementById('test-name');
|
||
if (nameEl && cur) nameEl.value = cur.name || '';
|
||
testRenderSteps();
|
||
testRenderRun(_testRunState);
|
||
}
|
||
|
||
function testRenderSteps() {
|
||
const el = document.getElementById('test-steps');
|
||
if (!el) return;
|
||
const cur = testCurrent();
|
||
if (!cur || !cur.steps || !cur.steps.length) {
|
||
el.innerHTML = '<span class="text-gray-400">' + esc(t('No steps yet - add an action or an expectation.')) + '</span>';
|
||
return;
|
||
}
|
||
let html = '<div class="border border-gray-200 dark:border-slate-700 rounded divide-y divide-gray-100 dark:divide-slate-700">';
|
||
cur.steps.forEach((s, i) => {
|
||
const kindCls = s.type === 'action'
|
||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
|
||
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300';
|
||
html += '<div class="flex items-center gap-2 px-2 py-1">'
|
||
+ '<span class="w-5 text-right text-xs text-gray-400">' + (i + 1) + '</span>'
|
||
+ '<span class="text-xs px-1.5 py-0.5 rounded ' + kindCls + '">' + esc(s.type === 'action' ? t('Action') : t('Expectation')) + '</span>'
|
||
+ '<span class="flex-1 truncate font-mono text-xs" title="' + esc(testStepSummary(s)) + '">' + esc(testStepSummary(s)) + '</span>'
|
||
+ '<button onclick="testMoveStep(' + i + ',-1)" class="px-1.5 text-xs text-gray-500 hover:text-gray-800 disabled:opacity-30" ' + (i === 0 ? 'disabled' : '') + '>↑</button>'
|
||
+ '<button onclick="testMoveStep(' + i + ',1)" class="px-1.5 text-xs text-gray-500 hover:text-gray-800 disabled:opacity-30" ' + (i === cur.steps.length - 1 ? 'disabled' : '') + '>↓</button>'
|
||
+ '<button onclick="testEditStep(' + i + ')" class="px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600 text-gray-600 dark:text-slate-300">' + esc(t('Edit')) + '</button>'
|
||
+ '<button onclick="testDeleteStep(' + i + ')" class="px-2 py-0.5 text-xs rounded border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400">' + esc(t('Delete')) + '</button>'
|
||
+ '</div>';
|
||
});
|
||
el.innerHTML = html + '</div>';
|
||
}
|
||
|
||
function testSelectChanged() {
|
||
const sel = document.getElementById('test-script-sel');
|
||
_testCurrentIdx = parseInt(sel.value, 10) || 0;
|
||
testRender();
|
||
}
|
||
|
||
function testNameChanged() {
|
||
const cur = testCurrent();
|
||
const el = document.getElementById('test-name');
|
||
if (!cur || !el) return;
|
||
cur.name = el.value;
|
||
testScriptsSave();
|
||
const sel = document.getElementById('test-script-sel');
|
||
if (sel && sel.options[_testCurrentIdx]) sel.options[_testCurrentIdx].textContent = cur.name || (t('Script') + ' ' + (_testCurrentIdx + 1));
|
||
}
|
||
|
||
function testNew() {
|
||
const kind = (document.getElementById('test-template') || {}).value || 'empty';
|
||
_testScripts.push(testTemplate(kind));
|
||
_testCurrentIdx = _testScripts.length - 1;
|
||
testScriptsSave();
|
||
testRender();
|
||
}
|
||
|
||
function testClone() {
|
||
const cur = testCurrent();
|
||
if (!cur) return;
|
||
const copy = JSON.parse(JSON.stringify(cur));
|
||
copy.name = (copy.name || t('Script')) + ' (2)';
|
||
_testScripts.push(copy);
|
||
_testCurrentIdx = _testScripts.length - 1;
|
||
testScriptsSave();
|
||
testRender();
|
||
}
|
||
|
||
function testDelete() {
|
||
const cur = testCurrent();
|
||
if (!cur) return;
|
||
if (!confirm(t('Delete this test script?'))) return;
|
||
_testScripts.splice(_testCurrentIdx, 1);
|
||
if (!_testScripts.length) _testScripts.push(testTemplate('empty'));
|
||
_testCurrentIdx = Math.max(0, _testCurrentIdx - 1);
|
||
testScriptsSave();
|
||
testRender();
|
||
}
|
||
|
||
function testExport() {
|
||
const cur = testCurrent();
|
||
if (!cur) return;
|
||
downloadJson((cur.name || 'test-script').replace(/[^\w.-]+/g, '_') + '.json', cur);
|
||
}
|
||
|
||
function testImportOpen() {
|
||
const el = document.getElementById('test-import-file');
|
||
if (el) el.click();
|
||
}
|
||
|
||
function testImportFile(input) {
|
||
const file = input && input.files ? input.files[0] : null;
|
||
if (input) input.value = '';
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
let obj = null;
|
||
try {
|
||
obj = JSON.parse(String(reader.result || ''));
|
||
} catch (e) {
|
||
ioStatus('test-io-status', t('Import failed:') + ' ' + e.message);
|
||
return;
|
||
}
|
||
const problem = testScriptProblem(obj);
|
||
if (problem) {
|
||
ioStatus('test-io-status', t('Import failed:') + ' ' + problem);
|
||
return;
|
||
}
|
||
obj.name = obj.name || file.name.replace(/\.json$/i, '');
|
||
_testScripts.push(obj);
|
||
_testCurrentIdx = _testScripts.length - 1;
|
||
testScriptsSave();
|
||
testRender();
|
||
ioStatus('test-io-status', t('Imported:') + ' ' + obj.name);
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
function testAddStep(type) {
|
||
const step = type === 'expect'
|
||
? {type: 'expect', command: 'DISPLAY TEXT', checks: [{kind: 'text', mode: 'contains', value: ''}], respond: {result: 'ok'}}
|
||
: {type: 'action', kind: 'status', params: {attempts: 1, interval_ms: 200}};
|
||
testEditStep(-1, step);
|
||
}
|
||
|
||
function testEditStep(index, presetStep) {
|
||
const cur = testCurrent();
|
||
if (!cur) return;
|
||
const step = presetStep ? JSON.parse(JSON.stringify(presetStep)) : JSON.parse(JSON.stringify(cur.steps[index]));
|
||
_testEditStep = step;
|
||
_testEditChecks = (step.checks || []).map(c => JSON.parse(JSON.stringify(c)));
|
||
_testEditStepIndex = index;
|
||
testStepRender();
|
||
document.getElementById('test-step-modal').classList.remove('hidden');
|
||
}
|
||
let _testEditStepIndex = -1;
|
||
|
||
function testStepCancel() {
|
||
document.getElementById('test-step-modal').classList.add('hidden');
|
||
_testEditStep = null;
|
||
_testEditChecks = [];
|
||
}
|
||
|
||
function testStepSave() {
|
||
const err = testStepFormError();
|
||
if (err) {
|
||
const el = document.getElementById('test-step-error');
|
||
el.textContent = err;
|
||
el.classList.remove('hidden');
|
||
return;
|
||
}
|
||
const cur = testCurrent();
|
||
const step = _testEditStep;
|
||
if (_testEditStepIndex >= 0) cur.steps[_testEditStepIndex] = step;
|
||
else cur.steps.push(step);
|
||
testScriptsSave();
|
||
document.getElementById('test-step-modal').classList.add('hidden');
|
||
_testEditStep = null;
|
||
_testEditChecks = [];
|
||
testRenderSteps();
|
||
}
|
||
|
||
function testMoveStep(i, delta) {
|
||
const cur = testCurrent();
|
||
const j = i + delta;
|
||
if (!cur || j < 0 || j >= cur.steps.length) return;
|
||
const tmp = cur.steps[i];
|
||
cur.steps[i] = cur.steps[j];
|
||
cur.steps[j] = tmp;
|
||
testScriptsSave();
|
||
testRenderSteps();
|
||
}
|
||
|
||
function testDeleteStep(i) {
|
||
const cur = testCurrent();
|
||
if (!cur) return;
|
||
cur.steps.splice(i, 1);
|
||
testScriptsSave();
|
||
testRenderSteps();
|
||
}
|
||
|
||
function testFormRow(label, inner) {
|
||
return '<div><label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300">' + esc(t(label)) + '</label>' + inner + '</div>';
|
||
}
|
||
|
||
function testFormInput(id, value, placeholder, extra) {
|
||
return '<input id="' + id + '" value="' + esc(value === undefined || value === null ? '' : String(value)) + '" placeholder="' + esc(placeholder || '') + '" ' + (extra || '') + ' 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">';
|
||
}
|
||
|
||
function testFormSelect(id, value, options, onchange) {
|
||
let html = '<select id="' + id + '"' + (onchange ? ' onchange="' + onchange + '"' : '') + ' class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">';
|
||
options.forEach(o => {
|
||
html += '<option value="' + esc(String(o.v)) + '"' + (String(o.v) === String(value) ? ' selected' : '') + '>' + esc(o.l) + '</option>';
|
||
});
|
||
return html + '</select>';
|
||
}
|
||
|
||
function testStepRender() {
|
||
const modal = document.getElementById('test-step-modal');
|
||
const title = document.getElementById('test-step-title');
|
||
const body = document.getElementById('test-step-body');
|
||
const err = document.getElementById('test-step-error');
|
||
const step = _testEditStep;
|
||
if (!step) return;
|
||
err.classList.add('hidden');
|
||
const isAction = step.type === 'action';
|
||
title.textContent = (isAction ? t('Action') : t('Expectation')) + ' · ' + t('Step') + ' ' + (_testEditStepIndex >= 0 ? (_testEditStepIndex + 1) : (_testScriptSteps() + 1));
|
||
let html = '';
|
||
if (isAction) {
|
||
const kinds = TEST_ACTION_KINDS.map(k => ({v: k, l: k}));
|
||
html += testFormRow('Action kind', testFormSelect('test-step-kind', step.kind, kinds, 'testStepKindChanged()'));
|
||
const p = step.params || {};
|
||
if (step.kind === 'envelope') {
|
||
html += testFormRow('Event type', testFormInput('test-f-event', p.event === undefined ? 3 : p.event, '0..255'));
|
||
html += testFormRow('Event data (hex, optional)', testFormInput('test-f-data', p.data, '9B0100'));
|
||
} else if (step.kind === 'menu-select') {
|
||
html += testFormRow('Item id (1-255)', testFormInput('test-f-item', p.item_id === undefined ? 1 : p.item_id, '1..255'));
|
||
} else if (step.kind === 'file-write' || step.kind === 'file-read') {
|
||
html += testFormRow('File path', testFormInput('test-f-path', p.path, 'ADF.USIM/EF.EST or MF/2FE2'));
|
||
html += testFormRow('Mode', testFormSelect('test-f-mode', p.mode || 'auto', [
|
||
{v: 'auto', l: t('Auto (by file type)')}, {v: 'binary', l: t('Transparent (binary)')}, {v: 'record', l: t('Record')}]));
|
||
html += testFormRow('Record number (record mode)', testFormInput('test-f-record', p.record, '1..255'));
|
||
if (step.kind === 'file-write') html += testFormRow('Data (hex)', testFormInput('test-f-fdata', p.data, 'FF...'));
|
||
} else if (step.kind === 'apdu') {
|
||
html += testFormRow('APDU (hex)', testFormInput('test-f-apdu', p.apdu, '00A4040008A000000151000000'));
|
||
} else if (step.kind === 'scp80') {
|
||
const src = p.source || (p.sp ? 'sp' : 'apdu');
|
||
html += testFormRow('Source', testFormSelect('test-f-src', src, [
|
||
{v: 'apdu', l: t('C-APDU (built with the preset)')}, {v: 'sp', l: t('Pre-built secured packet')}], 'testStepKindChanged()'));
|
||
if (src === 'sp') html += testFormRow('Secured packet (hex)', testFormInput('test-f-sp', p.sp, '01 00 ...'));
|
||
else html += testFormRow('C-APDU (hex)', testFormInput('test-f-apdu', p.apdu, '80E2900000'));
|
||
html += testFormRow('Keyset number (optional)',
|
||
testFormInput('test-f-kvn', p.kvn === undefined ? '' : p.kvn, t('empty = the preset\'s first keyset')));
|
||
html += testFormRow('TAR override (hex, optional)', testFormInput('test-f-tar', p.tar, 'B00000'));
|
||
html += testFormRow('SPI1 override (hex, optional)', testFormInput('test-f-spi1', p.spi1, '16'));
|
||
html += testFormRow('SPI2 override (hex, optional)', testFormInput('test-f-spi2', p.spi2, '01'));
|
||
} else if (step.kind === 'status') {
|
||
html += testFormRow('Attempts (1 = single STATUS)', testFormInput('test-f-attempts', p.attempts === undefined ? 1 : p.attempts, '1..1000'));
|
||
html += testFormRow('Interval (ms, when polling)', testFormInput('test-f-interval', p.interval_ms === undefined ? 200 : p.interval_ms, '0..10000'));
|
||
}
|
||
const c = step.check || {};
|
||
const sw = c.sw || {};
|
||
html += testFormRow('Expected SW', '<div class="flex gap-2">'
|
||
+ testFormSelect('test-f-sw-mode', sw.mode || 'exact', [{v: 'exact', l: t('Exact')}, {v: 'mask', l: t('Mask ("?" = wildcard)')}])
|
||
+ testFormInput('test-f-sw', sw.value === undefined ? '' : sw.value, t('default: 9000 (91?? when polling)')) + '</div>');
|
||
const cd = c.data || {};
|
||
html += testFormRow('Expected data (hex, optional)', '<div class="flex gap-2">'
|
||
+ testFormSelect('test-f-cdata-mode', cd.mode || 'exact', [{v: 'exact', l: t('Exact')}, {v: 'mask', l: t('Mask ("?" = wildcard)')}])
|
||
+ testFormInput('test-f-cdata', cd.value, 'AA??') + '</div>');
|
||
if (step.kind === 'scp80') {
|
||
html += testFormRow('PoR check', testFormSelect('test-f-por', c.por || 'any', [
|
||
{v: 'any', l: t('Any')}, {v: 'none', l: t('No PoR expected')}, {v: 'ok', l: t('PoR = por_ok')}]));
|
||
}
|
||
html += testFormRow('On mismatch', testFormSelect('test-f-fail', step.on_fail || 'error', [
|
||
{v: 'error', l: t('Error (stop the script)')}, {v: 'warning', l: t('Warning (continue)')}]));
|
||
html += testFormRow('Message / label (optional)', testFormInput('test-f-label', step.label, ''));
|
||
} else {
|
||
html += testFormRow('Command', testFormSelect('test-f-command', step.command || 'ANY', testCommandOptions(step.command), 'testStepKindChanged()'));
|
||
const q = step.qualifier || {};
|
||
html += testFormRow('Qualifier (optional)', '<div class="flex gap-2">'
|
||
+ testFormSelect('test-f-qual-mode', q.mode || 'exact', [{v: 'exact', l: t('Exact')}, {v: 'mask', l: t('Mask ("?" = wildcard)')}])
|
||
+ testFormInput('test-f-qual', q.value, '00 or ?0') + '</div>');
|
||
html += '<div><div class="flex items-center gap-2 mb-1"><label class="text-xs font-medium text-gray-700 dark:text-slate-300">' + esc(t('Content checks')) + '</label>'
|
||
+ '<button onclick="testAddCheck()" class="px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600">' + esc(t('Add check')) + '</button></div>'
|
||
+ '<div id="test-checks"></div></div>';
|
||
const r = step.respond || {};
|
||
html += testFormRow('TERMINAL RESPONSE result', testFormSelect('test-f-result', r.result === undefined ? 'ok' : String(r.result), [
|
||
{v: 'ok', l: t('ok')}, {v: 'cancel', l: t('cancel')}, {v: 'back', l: t('back')},
|
||
{v: 'timeout', l: t('timeout')}, {v: 'no_response', l: t('no response')}]));
|
||
html += testFormRow('Item identifier (SELECT ITEM / SET UP MENU)', testFormInput('test-f-ritem', r.item_id, '1..255'));
|
||
html += testFormRow('Text (GET INKEY / GET INPUT)', '<div class="flex gap-2">'
|
||
+ testFormInput('test-f-rtext', r.text, 'text')
|
||
+ testFormInput('test-f-rdcs', r.dcs, 'DCS') + '</div>');
|
||
html += testFormRow('Extra TLVs (hex, optional)', testFormInput('test-f-rraw', r.raw, ''));
|
||
html += testFormRow('On mismatch', testFormSelect('test-f-fail', step.on_fail || 'error', [
|
||
{v: 'error', l: t('Error (stop the script)')}, {v: 'warning', l: t('Warning (continue)')}]));
|
||
}
|
||
body.innerHTML = html;
|
||
if (!isAction) testRenderChecks();
|
||
}
|
||
|
||
function _testScriptSteps() {
|
||
const cur = testCurrent();
|
||
return cur && cur.steps ? cur.steps.length : 0;
|
||
}
|
||
|
||
function testCommandOptions(selected) {
|
||
const seen = {};
|
||
const names = [];
|
||
for (const k in CMD_NAMES) {
|
||
const v = CMD_NAMES[k];
|
||
if (v && !seen[v]) { seen[v] = 1; names.push(v); }
|
||
}
|
||
names.sort();
|
||
let out = [{v: 'ANY', l: 'ANY'}].concat(names.map(n => ({v: n, l: n})));
|
||
if (selected && !out.some(o => o.v === selected)) out.push({v: selected, l: selected});
|
||
return out;
|
||
}
|
||
|
||
function testStepKindChanged() {
|
||
if (!_testEditStep) return;
|
||
testStepCollect();
|
||
if (_testEditStep.type === 'expect') _testEditChecks = (_testEditStep.checks || []).slice();
|
||
testStepRender();
|
||
}
|
||
|
||
function testRenderChecks() {
|
||
const el = document.getElementById('test-checks');
|
||
if (!el) return;
|
||
if (!_testEditChecks.length) {
|
||
el.innerHTML = '<span class="text-xs text-gray-400">' + esc(t('No content checks.')) + '</span>';
|
||
return;
|
||
}
|
||
let html = '';
|
||
_testEditChecks.forEach((c, i) => {
|
||
html += '<div class="flex items-start gap-2 mb-1">'
|
||
+ testFormSelect('test-check-kind-' + i, c.kind || 'text', [
|
||
{v: 'text', l: t('Text')}, {v: 'item', l: t('Item')}, {v: 'raw', l: t('Raw')}], 'testCheckKindChanged()');
|
||
if (c.kind === 'item') {
|
||
html += testFormInput('test-check-id-' + i, c.id, t('id'));
|
||
html += testFormInput('test-check-text-' + i, c.text, t('text'));
|
||
html += testFormSelect('test-check-mode-' + i, c.mode || 'contains',
|
||
[{v: 'contains', l: t('Contains')}, {v: 'exact', l: t('Exact')}]);
|
||
} else if (c.kind === 'raw') {
|
||
html += testFormSelect('test-check-mode-' + i, c.mode || 'mask', [{v: 'exact', l: t('Exact')}, {v: 'mask', l: t('Mask ("?" = wildcard)')}]);
|
||
html += testFormInput('test-check-value-' + i, c.value, 'AA??');
|
||
} else {
|
||
html += testFormSelect('test-check-mode-' + i, c.mode || 'contains', [{v: 'contains', l: t('Contains')}, {v: 'exact', l: t('Exact')}]);
|
||
html += testFormInput('test-check-value-' + i, c.value, t('text'));
|
||
}
|
||
html += testFormSelect('test-check-fail-' + i, c.on_fail || 'error', [
|
||
{v: 'error', l: t('Error')}, {v: 'warning', l: t('Warning')}]);
|
||
html += '<button onclick="testRemoveCheck(' + i + ')" class="px-2 py-1 text-xs rounded border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400">×</button>'
|
||
+ '</div>';
|
||
});
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
function testCheckKindChanged() {
|
||
testChecksCollect();
|
||
testRenderChecks();
|
||
}
|
||
|
||
function testAddCheck() {
|
||
testChecksCollect();
|
||
_testEditChecks.push({kind: 'text', mode: 'contains', value: '', on_fail: 'error'});
|
||
testRenderChecks();
|
||
}
|
||
|
||
function testRemoveCheck(i) {
|
||
testChecksCollect();
|
||
_testEditChecks.splice(i, 1);
|
||
testRenderChecks();
|
||
}
|
||
|
||
function testChecksCollect() {
|
||
const out = [];
|
||
_testEditChecks.forEach((c, i) => {
|
||
const val = id => { const el = document.getElementById(id); return el ? el.value.trim() : ''; };
|
||
const kind = val('test-check-kind-' + i) || c.kind || 'text';
|
||
const row = {kind: kind, on_fail: val('test-check-fail-' + i) || 'error'};
|
||
if (kind === 'item') {
|
||
const id = parseInt(val('test-check-id-' + i), 10);
|
||
const text = val('test-check-text-' + i);
|
||
if (!isNaN(id)) row.id = id;
|
||
if (text) row.text = text;
|
||
row.mode = val('test-check-mode-' + i) || 'contains';
|
||
} else if (kind === 'raw') {
|
||
row.mode = val('test-check-mode-' + i) || 'mask';
|
||
row.value = val('test-check-value-' + i).replace(/[^0-9a-fA-F?]/g, '').toUpperCase();
|
||
} else {
|
||
row.mode = val('test-check-mode-' + i) || 'contains';
|
||
row.value = val('test-check-value-' + i);
|
||
}
|
||
out.push(row);
|
||
});
|
||
_testEditChecks = out;
|
||
}
|
||
|
||
function testStepCollect() {
|
||
const step = _testEditStep;
|
||
if (!step) return;
|
||
const val = id => { const el = document.getElementById(id); return el ? el.value.trim() : ''; };
|
||
const hex = id => val(id).replace(/[^0-9a-fA-F?]/g, '').toUpperCase();
|
||
const hexNoMask = id => val(id).replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
const num = (id, dflt) => { const v = parseInt(val(id), 10); return isNaN(v) ? dflt : v; };
|
||
if (step.type === 'action') {
|
||
const kind = val('test-step-kind') || step.kind;
|
||
step.kind = kind;
|
||
const p = {};
|
||
if (kind === 'envelope') { p.event = num('test-f-event', 3); p.data = hexNoMask('test-f-data'); }
|
||
else if (kind === 'menu-select') { p.item_id = num('test-f-item', 1); }
|
||
else if (kind === 'file-write' || kind === 'file-read') {
|
||
p.path = val('test-f-path');
|
||
p.mode = val('test-f-mode') || 'auto';
|
||
const rec = num('test-f-record', 0);
|
||
if (rec) p.record = rec;
|
||
if (kind === 'file-write') p.data = hexNoMask('test-f-fdata');
|
||
}
|
||
else if (kind === 'apdu') { p.apdu = hexNoMask('test-f-apdu'); }
|
||
else if (kind === 'scp80') {
|
||
// the source is stored explicitly: switching it must not drop the
|
||
// value of the other source (and must stick when the field is empty)
|
||
const prev = step.params || {};
|
||
const src = val('test-f-src') || prev.source || (prev.sp ? 'sp' : 'apdu');
|
||
// keep both values: the field of the inactive source is not rendered
|
||
if (document.getElementById('test-f-sp')) p.sp = hexNoMask('test-f-sp');
|
||
else p.sp = prev.sp || '';
|
||
if (document.getElementById('test-f-apdu')) p.apdu = hexNoMask('test-f-apdu');
|
||
else p.apdu = prev.apdu || '';
|
||
p.source = src;
|
||
['tar', 'spi1', 'spi2'].forEach(k => { const v = hexNoMask('test-f-' + k); if (v) p[k] = v; });
|
||
const kvn = (document.getElementById('test-f-kvn') || {}).value;
|
||
if (kvn !== undefined && String(kvn).trim() !== '') p.kvn = parseInt(String(kvn).trim(), 10);
|
||
}
|
||
else if (kind === 'status') { p.attempts = num('test-f-attempts', 1); p.interval_ms = num('test-f-interval', 200); }
|
||
step.params = p;
|
||
const check = {};
|
||
const swVal = hex('test-f-sw');
|
||
if (swVal) check.sw = {mode: val('test-f-sw-mode') || 'exact', value: swVal};
|
||
const dataVal = hex('test-f-cdata');
|
||
if (dataVal) check.data = {mode: val('test-f-cdata-mode') || 'exact', value: dataVal};
|
||
const por = val('test-f-por');
|
||
if (kind === 'scp80' && por && por !== 'any') check.por = por;
|
||
step.check = check;
|
||
step.on_fail = val('test-f-fail') || 'error';
|
||
const label = val('test-f-label');
|
||
if (label) step.label = label; else delete step.label;
|
||
} else {
|
||
step.command = val('test-f-command') || step.command || 'ANY';
|
||
const qVal = hex('test-f-qual');
|
||
if (qVal) step.qualifier = {mode: val('test-f-qual-mode') || 'exact', value: qVal};
|
||
else delete step.qualifier;
|
||
testChecksCollect();
|
||
step.checks = JSON.parse(JSON.stringify(_testEditChecks)).filter(c => {
|
||
if (c.kind === 'text') return !!(c.value || '').trim();
|
||
if (c.kind === 'item') return c.id || (c.text || '').trim();
|
||
return !!(c.value || '').trim();
|
||
});
|
||
const r = {result: val('test-f-result') || 'ok'};
|
||
const ri = num('test-f-ritem', 0);
|
||
if (ri) r.item_id = ri;
|
||
const text = val('test-f-rtext');
|
||
if (text) { r.text = text; r.dcs = hexNoMask('test-f-rdcs') || '00'; }
|
||
const raw = hexNoMask('test-f-rraw');
|
||
if (raw) r.raw = raw;
|
||
step.respond = r;
|
||
step.on_fail = val('test-f-fail') || 'error';
|
||
}
|
||
}
|
||
|
||
function testStepFormError() {
|
||
testStepCollect();
|
||
const step = _testEditStep;
|
||
if (step.type === 'action') {
|
||
const p = step.params || {};
|
||
if (step.kind === 'apdu' && !p.apdu) return t('APDU is empty');
|
||
if (step.kind === 'scp80') {
|
||
const src = p.source || (p.sp ? 'sp' : 'apdu');
|
||
if (src === 'sp' ? !p.sp : !p.apdu) return t('SCP80 needs an APDU or a secured packet');
|
||
}
|
||
if (step.kind === 'menu-select' && !(p.item_id >= 1 && p.item_id <= 255)) return t('item id must be 1..255');
|
||
if ((step.kind === 'file-write' || step.kind === 'file-read') && !p.path) return t('file path is empty');
|
||
if (step.kind === 'status' && !(p.attempts >= 1)) return t('attempts must be at least 1');
|
||
} else if (!step.command) {
|
||
return t('command is required');
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// ── run panel ──
|
||
|
||
function testRunActive() {
|
||
return !!(_testRunState && _testRunState.running);
|
||
}
|
||
|
||
function testRunMessage(text, kind) {
|
||
const el = document.getElementById('test-run-status');
|
||
if (!el) return;
|
||
el.textContent = text;
|
||
el.className = 'text-xs mb-2 ' + (kind === 'error' ? 'text-red-500' : 'text-emerald-600 dark:text-emerald-400');
|
||
}
|
||
|
||
function testRenderRun(snapshot) {
|
||
_testRunState = snapshot || null;
|
||
const running = testRunActive();
|
||
const banner = document.getElementById('test-run-banner');
|
||
if (banner) {
|
||
if (running) {
|
||
banner.textContent = '\u26a0 ' + t('Test script running:') + ' ' + (snapshot.name || '') + ' (' + (snapshot.index + 1) + '/' + snapshot.total + ')';
|
||
banner.classList.remove('hidden');
|
||
} else {
|
||
banner.classList.add('hidden');
|
||
}
|
||
}
|
||
const runBtn = document.getElementById('test-run-btn');
|
||
const stopBtn = document.getElementById('test-stop-btn');
|
||
if (runBtn) runBtn.classList.toggle('hidden', running);
|
||
if (stopBtn) stopBtn.classList.toggle('hidden', !running);
|
||
const pill = document.querySelector('[data-phone-sub="test"]');
|
||
if (pill) pill.textContent = running ? t('Test script') + ' \u2022' : t('Test script');
|
||
const statusEl = document.getElementById('test-run-status');
|
||
const stepsEl = document.getElementById('test-run-steps');
|
||
if (!statusEl || !stepsEl) return;
|
||
if (!snapshot || (!running && !snapshot.status && !(snapshot.steps || []).length)) {
|
||
statusEl.textContent = t('No run yet.');
|
||
statusEl.className = 'text-xs text-gray-500 dark:text-slate-400 mb-2';
|
||
stepsEl.innerHTML = '';
|
||
return;
|
||
}
|
||
let head = esc(snapshot.name || '');
|
||
if (snapshot.status) head += ' · ' + testStatusBadge(snapshot.status);
|
||
else if (running) head += ' · ' + esc(t('running'));
|
||
if (snapshot.preset) head += ' · ' + esc(t('Preset')) + ': ' + esc(snapshot.preset);
|
||
if (snapshot.scp80_counter) head += ' · counter ' + esc(snapshot.scp80_counter);
|
||
if (snapshot.error) head += ' · ' + esc(snapshot.error);
|
||
statusEl.innerHTML = head;
|
||
statusEl.className = 'text-xs mb-2 text-gray-600 dark:text-slate-300';
|
||
stepsEl.innerHTML = (snapshot.steps || []).map(testRunStepHtml).join('');
|
||
}
|
||
|
||
function testStatusBadge(status) {
|
||
const cls = status === 'ok' ? 'text-emerald-600 dark:text-emerald-400'
|
||
: status === 'warning' ? 'text-amber-600 dark:text-amber-400'
|
||
: status === 'error' ? 'text-red-500'
|
||
: 'text-gray-500';
|
||
return '<span class="' + cls + '">' + esc(status) + '</span>';
|
||
}
|
||
|
||
function testRunStepHtml(step) {
|
||
let html = '<div class="border-b border-gray-100 dark:border-slate-700/50 py-1">'
|
||
+ '<div class="flex items-center gap-2">'
|
||
+ '<span class="w-5 text-right text-gray-400">' + (step.index + 1) + '</span>'
|
||
+ '<span class="text-xs text-gray-400">' + esc(step.type === 'expect' ? t('Expectation') : t('Action')) + '</span>'
|
||
+ '<span class="flex-1 truncate">' + esc(step.label || '') + '</span>'
|
||
+ (step.ms !== null && step.ms !== undefined ? '<span class="text-xs text-gray-400">' + step.ms + ' ms</span>' : '')
|
||
+ testStatusBadge(step.status)
|
||
+ '</div>';
|
||
if (step.note) html += '<div class="ml-7 text-xs text-red-500">' + esc(step.note) + '</div>';
|
||
if (step.sent) html += '<div class="ml-7 text-xs break-all text-gray-500 dark:text-slate-400">' + esc(t('Sent')) + ': ' + esc(step.sent) + '</div>';
|
||
if (step.sw) html += '<div class="ml-7 text-xs text-gray-500 dark:text-slate-400">SW: ' + esc(step.sw) + (step.counter ? ' · ' + esc(t('Counter')) + ': ' + esc(step.counter) : '') + '</div>';
|
||
if (step.data) html += '<div class="ml-7 text-xs break-all text-gray-500 dark:text-slate-400">' + esc(t('Received')) + ': ' + esc(step.data) + '</div>';
|
||
(step.checks || []).forEach(c => {
|
||
html += '<div class="ml-7 text-xs ' + (c.ok ? 'text-gray-500 dark:text-slate-400' : (c.level === 'warning' ? 'text-amber-600 dark:text-amber-400' : 'text-red-500')) + '">'
|
||
+ (c.ok ? '\u2713' : (c.level === 'warning' ? '\u26a0' : '\u2717')) + ' ' + esc(c.label) + ': '
|
||
+ esc(t('expected')) + ' ' + esc(c.expected) + ', ' + esc(t('actual')) + ' ' + esc(c.actual)
|
||
+ (c.detail ? ' (' + esc(c.detail) + ')' : '') + '</div>';
|
||
});
|
||
return html + '</div>';
|
||
}
|
||
|
||
function testRunPreset() {
|
||
const preset = cardsMatchedPreset();
|
||
if (!preset) return {error: t('No card preset matches the equipped card - SCP80 steps need one')};
|
||
if (!cardsScp80Complete(preset)) return {error: t('The matched card preset has incomplete SCP80 data (KIc/KID, SPI1/SPI2, counter, keys)')};
|
||
_testLastPresetIdx = cards.indexOf(preset);
|
||
return {preset: {
|
||
name: preset.name || '',
|
||
iccid: preset.iccid || '',
|
||
kic: String(preset.kic || '').toUpperCase(),
|
||
kid: String(preset.kid || '').toUpperCase(),
|
||
kicKey: String(preset.kicKey || '').replace(/\s/g, '').toUpperCase(),
|
||
kidKey: String(preset.kidKey || '').replace(/\s/g, '').toUpperCase(),
|
||
counter: String(preset.cntr || '').replace(/\s/g, '').toUpperCase(),
|
||
tar: String(preset.tar || '').replace(/\s/g, '').toUpperCase(),
|
||
spi1: String(preset.spi1 || '').replace(/\s/g, '').toUpperCase(),
|
||
spi2: String(preset.spi2 || '').replace(/\s/g, '').toUpperCase(),
|
||
}};
|
||
}
|
||
|
||
async function testRunStart() {
|
||
const cur = testCurrent();
|
||
const problem = testScriptProblem(cur);
|
||
if (problem) { testRunMessage(problem, 'error'); return; }
|
||
let preset = null;
|
||
if (cur.steps.some(s => s.type === 'action' && s.kind === 'scp80')) {
|
||
const found = testRunPreset();
|
||
if (found.error) { testRunMessage(found.error, 'error'); return; }
|
||
preset = found.preset;
|
||
}
|
||
try {
|
||
const resp = await pysimFetch('/api/test/run', {script: cur, preset: preset});
|
||
if (resp.error) { testRunMessage(resp.error, 'error'); return; }
|
||
testRenderRun(resp);
|
||
testStartRunTimer();
|
||
pysimApplyAvailability();
|
||
} catch (e) {
|
||
testRunMessage(String(e.message || e), 'error');
|
||
}
|
||
}
|
||
|
||
async function testStop() {
|
||
try {
|
||
const resp = await pysimFetch('/api/test/stop', {});
|
||
if (resp && !resp.error) testRenderRun(resp);
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
async function testClearReport() {
|
||
try {
|
||
const resp = await pysimFetch('/api/test/clear', {});
|
||
if (resp && !resp.error) { testRenderRun(resp); testStopRunTimer(); }
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function testStartRunTimer() {
|
||
if (_testRunTimer) return;
|
||
_testRunTimer = setInterval(testPollRun, 500);
|
||
}
|
||
|
||
function testStopRunTimer() {
|
||
if (_testRunTimer) { clearInterval(_testRunTimer); _testRunTimer = null; }
|
||
}
|
||
|
||
async function testPollRun() {
|
||
try {
|
||
const st = await pysimFetch('/api/test/status');
|
||
const wasRunning = testRunActive();
|
||
const visible = isViewVisible('phone-sub-test');
|
||
_testRunState = st;
|
||
if (visible) testRenderRun(st);
|
||
if (!st.running) {
|
||
testStopRunTimer();
|
||
if (wasRunning) testWriteBackCounter();
|
||
pysimApplyAvailability();
|
||
}
|
||
} catch (e) { /* keep polling */ }
|
||
}
|
||
|
||
function testWriteBackCounter() {
|
||
const st = _testRunState;
|
||
if (!st || st.running) return;
|
||
// The server persists every counter the run consumed (one per keyset number);
|
||
// this only keeps the local copies in step with what it reports.
|
||
const counters = (st.scp80_counters && typeof st.scp80_counters === 'object')
|
||
? st.scp80_counters : null;
|
||
const next = st.scp80_counter ? String(st.scp80_counter).toUpperCase() : '';
|
||
if (!counters && !next) return;
|
||
let idx = _testLastPresetIdx;
|
||
if (idx < 0 && st.preset) {
|
||
// A run started elsewhere (or observed after a page reload): the run
|
||
// snapshot prefers the preset name, so look that up first and only
|
||
// fall back to an ICCID-looking value.
|
||
idx = cards.findIndex(c => (c.name || '') === st.preset);
|
||
if (idx < 0) {
|
||
const norm = cardsNormIccid(st.preset);
|
||
if (norm && norm.length >= 5) idx = cardsFindByIccid(norm);
|
||
}
|
||
}
|
||
if (idx < 0) return;
|
||
const preset = cards[idx];
|
||
if (!preset) return;
|
||
_testLastPresetIdx = idx;
|
||
if (counters) {
|
||
let changed = false;
|
||
Object.keys(counters).forEach(kvn => {
|
||
const ks = spKeysetFor(preset, parseInt(kvn, 10));
|
||
const value = String(counters[kvn] || '').toUpperCase();
|
||
if (ks && value && String(ks.cntr || '').toUpperCase() !== value) {
|
||
ks.cntr = value;
|
||
changed = true;
|
||
}
|
||
});
|
||
if (!changed) return;
|
||
cardsRender();
|
||
ioStatus('test-io-status', t('SCP80 counters saved to the preset.'));
|
||
return;
|
||
}
|
||
if (String(preset.cntr || '').toUpperCase() === next) return;
|
||
preset.cntr = next; // the server persisted it with the run (v3.8.0)
|
||
cardsRender();
|
||
ioStatus('test-io-status', t('SCP80 counter saved to the preset:') + ' ' + next);
|
||
}
|
||
|
||
async function testInit() {
|
||
testScriptsLoad();
|
||
testRender();
|
||
try {
|
||
const st = await pysimFetch('/api/test/status');
|
||
testRenderRun(st);
|
||
if (st && st.running) testStartRunTimer();
|
||
else testWriteBackCounter();
|
||
} catch (e) { /* server optional */ }
|
||
}
|
||
|
||
// ===== pysim integration =====
|
||
// Default to a local server on 127.0.0.1:8080. If this PWA is served by
|
||
// pysim-simple-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();
|
||
}
|
||
|
||
// The same call, but a response carrying `error` (the server's 400/404/503
|
||
// bodies) throws - the caller shows the message instead of silently using it.
|
||
async function pysimFetchOk(path, body) {
|
||
const data = await pysimFetch(path, body);
|
||
if (data && data.error) throw new Error(data.error);
|
||
return data;
|
||
}
|
||
|
||
// ===== UI availability (server / card) =====
|
||
let _pysimServerAvailable = null; // null until probed
|
||
let _pysimCardEquipped = false;
|
||
let _pysimEquipping = false;
|
||
let _pysimAdmVerified = null; // null = no card session
|
||
let _pysimAdmKey = null; // ADM key present in the matching preset
|
||
let _pysimAdmCanVerify = false; // indicator clickable (card + preset key, not blocked)
|
||
let _pysimAdmAttemptsLeft = null; // last 63Cx result for this card session
|
||
let _pysimAdmBlocked = false; // 6983/9804 — no further tries until the session changes
|
||
let _pysimAdmVerifying = false; // verify request in flight (double-click guard)
|
||
let _pysimAdmStateKey = null; // last rendered indicator state
|
||
let _pysimLastStatus = null; // last /api/status payload (for local repaints)
|
||
let _pysimCardIccid = null; // EF.ICCID digits of the equipped card (null = unknown)
|
||
let _pysimHeaderIccid = undefined; // last value rendered in the header indicator
|
||
let _pysimHeaderScp80 = undefined; // last SCP80/SCP81 marker state
|
||
let _pysimHeaderScp81 = undefined;
|
||
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) {
|
||
if (state === 'server-down') return t('Connect to server');
|
||
if (needs === 'card-iccid' && state === 'card') return t('Card equipped but its ICCID is not readable');
|
||
return t('Insert and equip a card');
|
||
}
|
||
|
||
function pysimUpdateStateIndicator() {
|
||
const el = document.getElementById('state-indicator');
|
||
const dot = document.getElementById('state-indicator-dot');
|
||
const img = document.getElementById('state-indicator-img');
|
||
if (!el || !dot || !img) return;
|
||
if (_pysimServerAvailable === true) {
|
||
const state = pysimAvailabilityState();
|
||
dot.classList.add('hidden');
|
||
dot.removeAttribute('title');
|
||
img.classList.remove('hidden');
|
||
img.src = _pysimEquipping ? 'sim_anim.svg' : (state === 'card' ? 'sim.svg' : 'nosim.svg');
|
||
el.setAttribute('title', _pysimEquipping ? t('Card inserted — initializing...')
|
||
: (state === 'card' ? t('Card equipped') : t('Server connected, no card equipped')));
|
||
} else {
|
||
const serverState = _pysimServerAvailable === false ? t('No server connection') : t('Connecting...');
|
||
img.classList.add('hidden');
|
||
dot.classList.remove('hidden');
|
||
dot.classList.remove('text-red-500', 'text-gray-400');
|
||
dot.classList.add(_pysimServerAvailable === false ? 'text-red-500' : 'text-gray-400');
|
||
dot.setAttribute('title', serverState);
|
||
el.setAttribute('title', serverState);
|
||
}
|
||
}
|
||
|
||
// Card-dependent action labels carry the equipped card's ICCID; the buttons
|
||
// themselves are disabled without a readable ICCID (data-needs="card-iccid").
|
||
function pysimUpdateCardActionLabels() {
|
||
document.querySelectorAll('.profiler-iccid-label').forEach(el => {
|
||
const sep = el.getAttribute('data-iccid-sep') || ' ';
|
||
el.textContent = _pysimCardIccid ? sep + _pysimCardIccid : '';
|
||
});
|
||
}
|
||
|
||
function pysimApplyAvailability() {
|
||
const state = pysimAvailabilityState();
|
||
document.querySelectorAll('[data-needs]').forEach(el => {
|
||
const needs = el.getAttribute('data-needs');
|
||
let disabled = pysimControlDisabled(needs, state);
|
||
if (needs === 'card-iccid') {
|
||
// From-card buttons need a readable ICCID, not just a session.
|
||
disabled = pysimControlDisabled('card', state) || !_pysimCardIccid;
|
||
}
|
||
const ksView = el.getAttribute('data-needs-keyset');
|
||
const ksBlocked = ksView && !spKeysetStartReady(ksView);
|
||
if (ksBlocked) disabled = true;
|
||
let hint = disabled ? pysimNeedsHint(needs, state) : null;
|
||
// The generic hint is "Insert and equip a card": with the card fine,
|
||
// the missing preset/keyset is the real blocker.
|
||
if (ksBlocked && state === 'card') hint = t('Select a card preset and its keyset');
|
||
if (el.hasAttribute('data-needs-decoder') && !pysimFsHasDecoder()) {
|
||
// Client-side decoder required (Read decoded) — the card hint wins
|
||
// when the card itself is the blocker.
|
||
disabled = true;
|
||
if (!hint) hint = t('No decoder for this file');
|
||
}
|
||
const capGate = el.getAttribute('data-cap-gate');
|
||
if (capGate && !capGateOk(capGate)) {
|
||
// A .cap must be analyzed successfully before its action runs.
|
||
disabled = true;
|
||
if (!hint) hint = t('Analyze the CAP file first');
|
||
}
|
||
if (testRunActive() && el.closest && !el.closest('#phone-sub-test')) {
|
||
// A running test script owns the card: other controls wait.
|
||
disabled = true;
|
||
if (!hint) hint = t('Test script running');
|
||
}
|
||
el.disabled = disabled;
|
||
if (hint) el.setAttribute('title', hint);
|
||
else el.removeAttribute('title');
|
||
});
|
||
pysimUpdateCardActionLabels();
|
||
pysimUpdateStateIndicator();
|
||
}
|
||
|
||
// Compact ADM state next to the header's card indicator: "ADM ✓" when the
|
||
// administrator PIN was verified (pySim rs.adm_verified), "ADM ✗" otherwise;
|
||
// a trailing key glyph (⚿) marks that the matching card preset carries an ADM
|
||
// key. With a key present the badge is clickable and verifies it on demand;
|
||
// hidden without a card session. Only rewrites the DOM when the state changes
|
||
// (the 2s /api/status poll calls this on every update).
|
||
function pysimUpdateAdmIndicator(status) {
|
||
const el = document.getElementById('state-indicator-adm');
|
||
if (!el) return;
|
||
const verified = (status && status.connected) ? !!status.adm_verified : null;
|
||
const key = verified === null ? null : cardsAdmPresent(cardsMatchedPreset());
|
||
const canVerify = key === true && !_pysimAdmBlocked && !_pysimAdmVerifying;
|
||
const stateKey = [verified, key, canVerify, _pysimAdmAttemptsLeft, _pysimAdmBlocked].join('|');
|
||
if (stateKey === _pysimAdmStateKey) return;
|
||
_pysimAdmStateKey = stateKey;
|
||
_pysimAdmVerified = verified;
|
||
_pysimAdmKey = key;
|
||
_pysimAdmCanVerify = canVerify;
|
||
el.classList.remove('text-emerald-600', 'dark:text-emerald-400', 'text-red-500', 'cursor-pointer');
|
||
el.removeAttribute('onclick');
|
||
el.removeAttribute('role');
|
||
if (verified === null) {
|
||
el.classList.add('hidden');
|
||
el.removeAttribute('title');
|
||
return;
|
||
}
|
||
el.textContent = 'ADM ' + (verified ? '✓' : '✗') + (key ? ' ⚿' : '');
|
||
el.classList.remove('hidden');
|
||
el.classList.add(verified ? 'text-emerald-600' : 'text-red-500');
|
||
if (verified) el.classList.add('dark:text-emerald-400');
|
||
let tip;
|
||
if (key && _pysimAdmBlocked) {
|
||
tip = t('ADM is blocked — unblock the card to try again');
|
||
} else if (key) {
|
||
tip = t('ADM key in the card preset — click to verify');
|
||
if (_pysimAdmAttemptsLeft !== null) tip += ' — ' + _pysimAdmAttemptsLeft + ' ' + t('attempt(s) left');
|
||
} else {
|
||
tip = t(verified ? 'Verified — no ADM key in the card preset' : 'No ADM key in the card preset — not verified');
|
||
}
|
||
el.setAttribute('title', tip);
|
||
if (canVerify) {
|
||
el.classList.add('cursor-pointer');
|
||
el.setAttribute('role', 'button');
|
||
el.setAttribute('onclick', 'pysimVerifyAdm()');
|
||
}
|
||
}
|
||
|
||
// Confirmation text before a repeated ADM attempt (null on the first try).
|
||
// Every wrong key consumes an attempt; a blocked ADM is unrecoverable here.
|
||
function pysimAdmRetryPrompt(attemptsLeft) {
|
||
if (attemptsLeft === null || attemptsLeft === undefined) return null;
|
||
if (attemptsLeft <= 1) return t('This is the last attempt before the ADM is blocked. Try again?');
|
||
return t('ADM verification already failed') + ' — ' + attemptsLeft + ' ' + t('attempt(s) left')
|
||
+ '. ' + t('A wrong key can block the ADM permanently. Try again?');
|
||
}
|
||
|
||
// Verify the ADM PIN from the matched card preset (top-bar indicator or the
|
||
// file manager's security-error hint). `statusEl` receives the result inline
|
||
// when given; otherwise the card status line is used. Never retries by
|
||
// itself: every attempt after a failure is confirmed first.
|
||
async function pysimVerifyAdm(statusEl) {
|
||
if (_pysimAdmVerifying) return;
|
||
const preset = cardsMatchedPreset();
|
||
if (!cardsAdmPresent(preset)) return;
|
||
const el = statusEl || document.getElementById('pysim-status');
|
||
if (_pysimAdmBlocked) {
|
||
if (el) el.textContent = t('ADM is blocked — unblock the card to try again');
|
||
return;
|
||
}
|
||
const prompt = pysimAdmRetryPrompt(_pysimAdmAttemptsLeft);
|
||
if (prompt && !confirm(prompt)) return;
|
||
_pysimAdmVerifying = true;
|
||
if (el) el.textContent = t('Verifying ADM...');
|
||
let verifiedOverride;
|
||
try {
|
||
const data = await pysimFetch('/api/verify-adm', { adm: preset.adm });
|
||
if (data.ok) {
|
||
_pysimAdmAttemptsLeft = null;
|
||
_pysimAdmBlocked = false;
|
||
verifiedOverride = true;
|
||
if (el) el.textContent = t('ADM verified');
|
||
} else if (data.blocked) {
|
||
_pysimAdmBlocked = true;
|
||
if (el) el.textContent = t('ADM is blocked');
|
||
} else if (typeof data.attempts_left === 'number') {
|
||
_pysimAdmAttemptsLeft = data.attempts_left;
|
||
if (el) el.textContent = t('ADM verification failed') + ' — ' + data.attempts_left + ' ' + t('attempt(s) left');
|
||
} else if (el) {
|
||
el.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error');
|
||
}
|
||
} catch (e) {
|
||
if (el) el.textContent = t('Error') + ': ' + e.message;
|
||
} finally {
|
||
_pysimAdmVerifying = false;
|
||
const base = _pysimLastStatus || { connected: true };
|
||
pysimUpdateAdmIndicator(Object.assign({}, base,
|
||
verifiedOverride === undefined ? {} : { adm_verified: verifiedOverride }));
|
||
}
|
||
}
|
||
|
||
// SW codes that mean "verify the ADM PIN first" (access condition / security).
|
||
function pysimAdmSecuritySw(sw) {
|
||
return sw === '6982' || sw === '9804';
|
||
}
|
||
|
||
// A new card session (equip/removal) forgets the attempt/blocked state: the
|
||
// counter belongs to the physical card that was just replaced or reset.
|
||
function pysimAdmResetAttempts() {
|
||
_pysimAdmAttemptsLeft = null;
|
||
_pysimAdmBlocked = false;
|
||
_pysimAdmStateKey = null;
|
||
}
|
||
|
||
// Render a failed file-manager operation; when the SW is a security error and
|
||
// the matched preset carries an ADM, offer verification inline (the key is
|
||
// only ever sent on an explicit click).
|
||
function pysimFsShowError(statusEl, sw, error) {
|
||
statusEl.textContent = 'SW: ' + (sw || '?') + ' — ' + (error || 'Error');
|
||
if (!pysimAdmSecuritySw(sw) || !cardsAdmPresent(cardsMatchedPreset()) || _pysimAdmBlocked) return;
|
||
const btn = document.createElement('button');
|
||
btn.textContent = t('Verify ADM');
|
||
btn.className = 'ml-2 px-2 py-0.5 text-xs rounded bg-amber-600 text-white hover:bg-amber-700';
|
||
btn.onclick = () => pysimVerifyAdm(statusEl);
|
||
statusEl.appendChild(document.createTextNode(' '));
|
||
statusEl.appendChild(btn);
|
||
}
|
||
|
||
// EF.ICCID digits of the equipped card, printed next to the card image
|
||
// (hidden while there is no card session or the ICCID is unreadable).
|
||
function pysimUpdateIccidIndicator(iccid) {
|
||
const el = document.getElementById('state-indicator-iccid');
|
||
if (!el) return;
|
||
const value = iccid || null;
|
||
if (value === _pysimHeaderIccid) return;
|
||
_pysimHeaderIccid = value;
|
||
if (!value) {
|
||
el.classList.add('hidden');
|
||
el.textContent = '';
|
||
return;
|
||
}
|
||
el.textContent = value;
|
||
el.classList.remove('hidden');
|
||
}
|
||
|
||
// Gray SCP80/SCP81 markers: shown only while the equipped card's ICCID matches
|
||
// a preset with complete settings for that view; never color-coded.
|
||
function pysimUpdatePresetIndicator(id, label, tip, show, prev) {
|
||
const el = document.getElementById(id);
|
||
if (!el) return prev;
|
||
if (show === prev) return prev;
|
||
if (show) {
|
||
el.textContent = label;
|
||
el.classList.remove('hidden');
|
||
el.setAttribute('title', t(tip));
|
||
} else {
|
||
el.textContent = '';
|
||
el.classList.add('hidden');
|
||
el.removeAttribute('title');
|
||
}
|
||
return show;
|
||
}
|
||
|
||
function pysimUpdatePresetIndicators(iccid) {
|
||
const c = cardsMatchedPreset(iccid);
|
||
_pysimHeaderScp80 = pysimUpdatePresetIndicator('state-indicator-scp80', 'SCP80', 'SCP80 preset complete', cardsScp80Complete(c), _pysimHeaderScp80);
|
||
_pysimHeaderScp81 = pysimUpdatePresetIndicator('state-indicator-scp81', 'SCP81', 'SCP81 preset complete', cardsScp81Complete(c), _pysimHeaderScp81);
|
||
}
|
||
|
||
function pysimSetServerAvailable(available) {
|
||
if (_pysimServerAvailable === available) return;
|
||
_pysimServerAvailable = available;
|
||
if (available !== true) {
|
||
pysimUpdateAdmIndicator(null);
|
||
pysimUpdateIccidIndicator(null);
|
||
pysimUpdatePresetIndicators(null);
|
||
netStateRender(null);
|
||
}
|
||
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');
|
||
// Server reachable is not the same as card equipped: keep the icon in
|
||
// step with the card state (animated while it is being initialized).
|
||
const refreshingInit = !!data.equipping || (!!data.card_present && !!data.auto_equip);
|
||
pysimSetConnected(data.connected ? true : (refreshingInit ? 'spin' : false));
|
||
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>' +
|
||
(data.iccid ? ' | ICCID: <b>' + esc(data.iccid) + '</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);
|
||
const pv = SIMPLE_VERSION.split('.').map(Number);
|
||
if (sv[0] < pv[0]) {
|
||
msgEl.innerHTML = '<span class="text-red-500">Server version ' + esc(ver.version) + ' is outdated. Please update pysim-simple-server.</span>';
|
||
btn.textContent = t('Connect');
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
if (sv[0] > pv[0]) {
|
||
msgEl.innerHTML = '<span class="text-yellow-500">Warning: server version ' + esc(ver.version) + ' is newer than expected. Update SIMple 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();
|
||
cardsFetch(); // the server store holds the card presets (v3.8.0)
|
||
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 spNextCntr(value) {
|
||
// The OTA counter must strictly increase for every secured packet sent
|
||
// (replay protection): increment the 5-byte counter, carries included.
|
||
// 5 bytes = 40 bits - a 32-bit `>>> 0` dropped the top byte of high
|
||
// counters (`10000AAAC8` -> `00000AAAC9`, v3.6.23).
|
||
const v = (parseInt((value || '').replace(/[^0-9a-fA-F]/g, ''), 16) || 0) % 0x10000000000;
|
||
return ((v + 1) % 0x10000000000).toString(16).toUpperCase().padStart(10, '0');
|
||
}
|
||
|
||
// The index of the preset selected in the SCP80 (Secured packet) view.
|
||
function spPresetIdx() {
|
||
const sel = document.getElementById('sp-card-sel');
|
||
const idx = sel ? parseInt(sel.value, 10) : NaN;
|
||
return (!isNaN(idx) && cards[idx]) ? idx : -1;
|
||
}
|
||
|
||
// The SCP80 form's counter input: a deliberate human edit, written to the
|
||
// server-side preset (v3.8.0). The counter an *operation* accepted is
|
||
// persisted by the server itself - the send/RAM flows only sync the local copy
|
||
// through spCntrLocalSync().
|
||
function spCntrSyncPreset() {
|
||
const idx = spPresetIdx();
|
||
if (idx < 0) return false;
|
||
const preset = cards[idx];
|
||
const value = (document.getElementById('sp-cntr').value || '').trim().toUpperCase();
|
||
const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) ||
|
||
spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0;
|
||
const ks = spKeysetFor(preset, kvn);
|
||
if (ks) ks.cntr = value;
|
||
cardsRender();
|
||
if (preset.id && ks) {
|
||
pysimFetch('/api/presets/update', { id: preset.id, fields: { keysets: spKeysetList(preset) } })
|
||
.then(res => {
|
||
if (res && res.error) throw new Error(res.error);
|
||
// the store normalises the counter (fixed 10-hex form)
|
||
if (res && res.preset) { cards[idx] = res.preset; cardsRender(); spKeysetSync(); }
|
||
})
|
||
.catch(e => ioStatus('cards-io', t('Counter not saved:') + ' ' + e.message));
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Keep the selected preset in step with a counter the server already persisted
|
||
// (the operations report it as final_cntr): display only, no write.
|
||
function spCntrLocalSync(value) {
|
||
const idx = spPresetIdx();
|
||
if (idx < 0) return;
|
||
const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) ||
|
||
spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0;
|
||
const ks = spKeysetFor(cards[idx], kvn);
|
||
if (ks) ks.cntr = value;
|
||
cardsRender();
|
||
}
|
||
|
||
function spParams() {
|
||
const idx = spPresetIdx();
|
||
const p = {
|
||
preset_id: idx >= 0 ? (cards[idx].id || '') : '',
|
||
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 || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase().padEnd(6, '0').slice(0, 6),
|
||
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,
|
||
};
|
||
if (_spRamFormat) p.ram_format = _spRamFormat;
|
||
return p;
|
||
}
|
||
|
||
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 = t('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;
|
||
}
|
||
// Re-read the preset (counter, keys, SPI) before sending: the Cards tab
|
||
// may have changed it after the form was filled, and a stale counter is
|
||
// rejected by the card (cntr_low) and then written back over the preset.
|
||
// The TAR is not re-applied: a packet may target a manually entered TAR
|
||
// (push/link triggers, applet OTA) - the preset sets it only when a card
|
||
// is picked or a chain is packed (v3.6.21).
|
||
spRefreshFromPreset('sp-card-sel', true);
|
||
const ksErr = spKeysetGuard();
|
||
if (ksErr) { alert(ksErr); 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() });
|
||
// low counter: not recoverable by retrying (v3.6.58)
|
||
if (spCntrLow(data)) {
|
||
sendResultEl.textContent = t('Low counter') + ' — ' + t('the card rejected the packet');
|
||
sendResultEl.classList.remove('text-gray-500', 'text-green-600');
|
||
sendResultEl.classList.add('text-red-600');
|
||
ramShowCntrLow();
|
||
return;
|
||
}
|
||
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;
|
||
if (data.bytes && data.segments) {
|
||
msg += ' | ' + data.bytes + ' ' + t('bytes') + ' · ' + data.segments + ' SMS';
|
||
}
|
||
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');
|
||
}
|
||
// The counter advances only for a packet the card accepted: a
|
||
// rejected send (cntr_low, PoR error) must leave the preset value
|
||
// untouched - the card did not consume the packet (v3.6.3; the old
|
||
// "advance always" rule silently reverted preset edits).
|
||
if (spPorAccepted(por)) {
|
||
const cntrEl = document.getElementById('sp-cntr');
|
||
const spi1Now = (document.getElementById('sp-spi1') || {}).value;
|
||
if (data.final_cntr) {
|
||
// the server persisted the accepted counter; sync the display
|
||
cntrEl.value = data.final_cntr;
|
||
msg += ' | CNTR -> ' + cntrEl.value;
|
||
spCntrLocalSync(cntrEl.value);
|
||
} else if (spCounterTracked(spi1Now)) {
|
||
// an older server: advance the local working copy
|
||
cntrEl.value = spNextCntr(cntrEl.value);
|
||
msg += ' | CNTR -> ' + cntrEl.value;
|
||
spCntrLocalSync(cntrEl.value);
|
||
} else {
|
||
// SPI1 without a counter check: the card keeps no replay
|
||
// value, so nothing is advanced (keyless SPI1 00 packets)
|
||
msg += ' | ' + t('counter not tracked (SPI1 has no counter check)');
|
||
}
|
||
if (data.ram_format) _spRamFormat = data.ram_format;
|
||
document.getElementById('sp-result').value = '';
|
||
spShowSizeInfo();
|
||
} else {
|
||
msg += ' | CNTR unchanged (' + ((por && por.response_status) || 'PoR error') + ')';
|
||
}
|
||
if (data.ram_format) msg += ' | ' + t('RAM command format:') + ' ' + data.ram_format;
|
||
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].
|
||
|
||
let _ramCardIdx = null;
|
||
let _ramOpLast = null;
|
||
|
||
function ramRender() {
|
||
// populate the card preset selector from the in-memory cards[] array,
|
||
// keeping the current/remembered selection across rebuilds
|
||
const sel = document.getElementById('ram-card-sel');
|
||
if (!sel) return;
|
||
const prev = parseInt(sel.value, 10);
|
||
const keep = (!isNaN(prev) && cards[prev]) ? prev : _ramCardIdx;
|
||
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);
|
||
});
|
||
if (keep !== null && keep !== undefined && cards[keep]) sel.value = String(keep);
|
||
// the keyset selector is built from this view's preset: rebuild it now that
|
||
// the preset is known (the auto-selection runs before the select has its
|
||
// options, which used to leave a stale "not defined in the preset" entry)
|
||
spKeysetSync();
|
||
ramOpChanged();
|
||
ramExpandedDetailsInit();
|
||
}
|
||
|
||
// The "Fetch expanded registry details" checkbox is a sticky preference: the
|
||
// expanded query costs extra secured packets and not every card accepts it,
|
||
// so it stays off unless the user asked for it (stored per browser).
|
||
function ramExpandedDetailsInit() {
|
||
const el = document.getElementById('ram-expanded-details');
|
||
if (!el) return;
|
||
let v = null;
|
||
try { v = localStorage.getItem('simple_ram_expanded'); } catch (e) { /* private mode */ }
|
||
// Default ON: the expanded pass carries the package versions, the SD /
|
||
// load-file associations and the applet menu entries (it costs a few
|
||
// extra secured packets per Explore); an explicit opt-out is honoured.
|
||
el.checked = v === null ? true : v === '1';
|
||
}
|
||
|
||
function ramExpandedDetailsChanged() {
|
||
const el = document.getElementById('ram-expanded-details');
|
||
try { localStorage.setItem('simple_ram_expanded', el && el.checked ? '1' : '0'); } catch (e) { /* ignore */ }
|
||
}
|
||
|
||
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
|
||
const i = parseInt(idx, 10);
|
||
if (!isNaN(i) && cards[i]) _ramCardIdx = i;
|
||
_spTarKey = 'tar'; // RAM/GP operations target the ISD
|
||
cardsApply(idx);
|
||
spPresetWarningRender();
|
||
}
|
||
|
||
function ramOpChanged() {
|
||
const op = document.getElementById('ram-op').value;
|
||
if (_ramOpLast !== null && op !== _ramOpLast) ramClearResults();
|
||
_ramOpLast = op;
|
||
const isCap = op === 'install-cap';
|
||
const isCompat = op === 'compat';
|
||
const isApp = op === 'install-app' || op === 'make-selectable';
|
||
const setHidden = (id, hidden) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.classList.toggle('hidden', hidden);
|
||
};
|
||
setHidden('ram-install-params', !(isCap || isApp));
|
||
setHidden('ram-cap-rows', !(isCap || isCompat));
|
||
setHidden('ram-compat-rows', !isCompat);
|
||
setHidden('ram-app-rows', !isApp);
|
||
// The Execute button's .cap gate applies to the install-cap and compat ops.
|
||
if (typeof pysimApplyAvailability === 'function') pysimApplyAvailability();
|
||
}
|
||
|
||
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');
|
||
}
|
||
|
||
// ===== RAM operation modal (v3.6.34) =====
|
||
// Long RAM operations (install chains, Explore) run behind a modal that blocks
|
||
// everything else - an interrupted install can leave a half-loaded package.
|
||
// The step headline comes from /api/status.ram_progress (polled every 2 s),
|
||
// so it works while the install request holds the card lock.
|
||
let _ramOpActive = false;
|
||
|
||
function ramOpBeforeUnload(e) {
|
||
e.preventDefault();
|
||
e.returnValue = '';
|
||
}
|
||
|
||
function ramOpModalBegin(title) {
|
||
_ramOpActive = true;
|
||
document.getElementById('ram-op-title').textContent = title || '';
|
||
document.getElementById('ram-op-status').textContent = t('Working...');
|
||
document.getElementById('ram-op-status').className = 'text-sm text-gray-600 dark:text-slate-400';
|
||
document.getElementById('ram-op-bar').style.width = '0%';
|
||
document.getElementById('ram-op-steps').textContent = '';
|
||
document.getElementById('ram-op-close').disabled = true;
|
||
document.getElementById('ram-op-spin').classList.remove('hidden');
|
||
document.getElementById('ram-op-warn').classList.remove('hidden');
|
||
document.getElementById('ram-op-modal').classList.remove('hidden');
|
||
window.addEventListener('beforeunload', ramOpBeforeUnload);
|
||
}
|
||
|
||
function ramOpModalStatus(text) {
|
||
if (!_ramOpActive) return;
|
||
document.getElementById('ram-op-status').textContent = text || '';
|
||
}
|
||
|
||
// Headline of the modal from a /api/status.ram_progress payload (pure).
|
||
function ramOpProgressText(p) {
|
||
if (!p) return '';
|
||
const parts = [];
|
||
parts.push(p.total ? t('Step') + ' ' + p.step + '/' + p.total : t('Working...'));
|
||
if (p.name) parts.push(p.name);
|
||
if (p.elapsed) parts.push(p.elapsed + ' s');
|
||
return parts.join(' \u2014 ');
|
||
}
|
||
|
||
function ramOpProgress(p) {
|
||
if (!_ramOpActive || !p || !p.active) return;
|
||
document.getElementById('ram-op-status').textContent = ramOpProgressText(p);
|
||
document.getElementById('ram-op-bar').style.width =
|
||
(p.total ? Math.min(100, Math.round(p.step * 100 / p.total)) : 10) + '%';
|
||
}
|
||
|
||
function ramOpModalFinish(summary, ok, content, asHtml) {
|
||
_ramOpActive = false;
|
||
window.removeEventListener('beforeunload', ramOpBeforeUnload);
|
||
document.getElementById('ram-op-spin').classList.add('hidden');
|
||
document.getElementById('ram-op-bar').style.width = '100%';
|
||
document.getElementById('ram-op-status').textContent = summary || '';
|
||
document.getElementById('ram-op-status').className = 'text-sm font-semibold ' +
|
||
(ok ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400');
|
||
if (content !== undefined) {
|
||
// asHtml is only used by views that build escaped HTML (the
|
||
// compatibility-test detail); everything else stays plain text.
|
||
const el = document.getElementById('ram-op-steps');
|
||
if (asHtml) el.innerHTML = content || '';
|
||
else el.textContent = content || '';
|
||
}
|
||
document.getElementById('ram-op-warn').classList.add('hidden');
|
||
document.getElementById('ram-op-close').disabled = false;
|
||
}
|
||
|
||
function ramOpModalClose() {
|
||
if (_ramOpActive) return;
|
||
document.getElementById('ram-op-modal').classList.add('hidden');
|
||
}
|
||
|
||
function ramClearResults() {
|
||
_ramExplorerData = null;
|
||
ramHideProgress();
|
||
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 = '';
|
||
const cntrLowEl = document.getElementById('ram-cntr-low');
|
||
if (cntrLowEl) { cntrLowEl.innerHTML = ''; cntrLowEl.classList.add('hidden'); }
|
||
}
|
||
|
||
// 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);
|
||
});
|
||
}
|
||
|
||
// ===== CAP file analysis (memory estimate) =====
|
||
// A .cap selected in the RAM installer or in the SCP81 "Install from .cap"
|
||
// template is analyzed server-side before any APDU is built: /api/cap-info
|
||
// validates the archive (a corrupt/wrong file fails here) and estimates the
|
||
// code size, persistent (NVRAM) and volatile (RAM) requirements. A
|
||
// successful analysis is required before the form's action button
|
||
// (data-cap-gate, checked in pysimApplyAvailability); the estimate itself is
|
||
// informational and is never used for installation.
|
||
const _capAnalysis = {
|
||
ram: { key: null, status: 'idle', memory: null, error: null, token: 0 },
|
||
scripts: { key: null, status: 'idle', memory: null, error: null, token: 0 },
|
||
};
|
||
|
||
function capFileKey(file) {
|
||
return file ? (file.name + ':' + file.size + ':' + file.lastModified) : '';
|
||
}
|
||
|
||
function capGateOk(kind) {
|
||
if (kind === 'ram') {
|
||
const op = document.getElementById('ram-op');
|
||
// the install and the compatibility test both need the analysed CAP
|
||
if (op && op.value !== 'install-cap' && op.value !== 'compat') return true;
|
||
}
|
||
const st = _capAnalysis[kind];
|
||
return !!(st && st.status === 'ok');
|
||
}
|
||
|
||
function capMemBytes(n) {
|
||
const v = Number(n) || 0;
|
||
if (v < 1024) return v + ' B';
|
||
return (v / 1024).toFixed(1) + ' kB';
|
||
}
|
||
|
||
// Estimate box body; pure apart from esc()/t(). `mem` is the server's
|
||
// memory object (pysim_simple_server.capmem.memory_json).
|
||
// Suggested C6/C7/C8 quotas as one readable line: decimal bytes (what the
|
||
// install form takes and what GP 9.7 encodes) with the hex equivalent in
|
||
// parentheses (how the C7/C8 install parameters are coded). C6 = non-volatile
|
||
// code space (the load file), C7 = volatile (RAM), C8 = non-volatile data.
|
||
function capQuotaText(sug) {
|
||
sug = sug || {};
|
||
const dec = v => String(Number(v) || 0);
|
||
const hex = v => '0x' + (Number(v) || 0).toString(16).toUpperCase().padStart(4, '0');
|
||
return 'C6=' + dec(sug.c6) + ' B (' + hex(sug.c6) + ') \u00b7 C7=' + dec(sug.c7)
|
||
+ ' B (' + hex(sug.c7) + ') \u00b7 C8=' + dec(sug.c8) + ' B (' + hex(sug.c8) + ')';
|
||
}
|
||
|
||
function capMemHtml(mem) {
|
||
if (!mem) return '';
|
||
const nv = mem.nvram || {}, ram = mem.ram || {}, code = mem.code || {}, sug = mem.suggested || {};
|
||
const methodBytes = Number(code.method_component) || 0;
|
||
// older server responses carry only the bytecode size: fall back to it
|
||
const loadFile = Number(code.load_file) || methodBytes;
|
||
const nvramData = Number(nv.total) || 0;
|
||
const requirement = Number(nv.requirement) || (loadFile + nvramData);
|
||
const otherComponents = Math.max(0, loadFile - methodBytes);
|
||
const imports = mem.imports || [];
|
||
const applets = mem.applets || [];
|
||
const comps = mem.components || [];
|
||
const bytes = v => esc(capMemBytes(v));
|
||
// the report is a set of one-heading tables: labels and column heads are
|
||
// muted, numbers line up right-aligned in a monospace column
|
||
const cell = (content, cls, span) => '<td' + (span ? ' colspan="' + span + '"' : '') +
|
||
' class="py-0.5' + (cls ? ' ' + cls : '') + '">' + content + '</td>';
|
||
const head = (content, cls) => '<td class="pb-0.5 border-b border-gray-200 dark:border-slate-700' + (cls ? ' ' + cls : '') + '">' + content + '</td>';
|
||
const table = rows => '<table class="w-full border-collapse">' + rows + '</table>';
|
||
const row = cells => '<tr>' + cells + '</tr>';
|
||
const label = (content, cls) => cell(content, 'whitespace-nowrap' + (cls ? ' ' + cls : ''));
|
||
const num = content => cell(content, 'pl-4 text-right whitespace-nowrap font-mono');
|
||
const part = content => cell(content, 'pl-4 text-gray-400 dark:text-slate-500');
|
||
const section = content => '<div class="mt-2 font-medium text-gray-500 dark:text-slate-400" style="text-transform:uppercase;letter-spacing:.06em">' + esc(content) + '</div>';
|
||
const disclosure = content => '<details class="mt-1"><summary class="cursor-pointer mt-2 font-medium text-gray-500 dark:text-slate-400" style="text-transform:uppercase;letter-spacing:.06em">' + esc(content) + '</summary>';
|
||
let html = '';
|
||
|
||
// ── package identity: AIDs, header flags (Table 6-4), applets and the
|
||
// compiled-against hint (javacard.framework version + CAP format) ──
|
||
let pkgRows = '';
|
||
if (mem.package_aid) {
|
||
let val = esc(mem.package_aid);
|
||
if (mem.package_name) val += '<div class="text-gray-400 break-all">' + esc(mem.package_name) + '</div>';
|
||
pkgRows += row(label(esc(t('Package'))) + cell(val, 'pl-4 font-mono break-all'));
|
||
}
|
||
if (mem.package_version) {
|
||
pkgRows += row(label(esc(t('Version'))) + cell(esc('v' + mem.package_version), 'pl-4 font-mono'));
|
||
}
|
||
const fl = mem.flags || {};
|
||
const fls = [];
|
||
if (fl.applet) fls.push(t('applet package'));
|
||
if (fl.export) fls.push(t('exports an API'));
|
||
if (fl.int) fls.push(t('uses int'));
|
||
if (fls.length) pkgRows += row(label(esc(t('Type'))) + cell(esc(fls.join(' \u00b7 ')), 'pl-4'));
|
||
if (applets.length) {
|
||
pkgRows += row(label(esc(t('Applets'))) + cell(applets.map(a => {
|
||
const n = jcAidName(a);
|
||
return esc(a) + (n ? ' <span class="text-gray-400">(' + esc(n) + ')</span>' : '');
|
||
}).join('<br>'), 'pl-4 font-mono break-all'));
|
||
}
|
||
// ── minimum platform: the card must provide every import version (JC VM
|
||
// spec 4.5.2 - same major, minor >= the export version) and the JCRE
|
||
// checks that while the load file is verified, so the framework import
|
||
// names the minimum Java Card level; the header int flag needs int
|
||
// support (Java Card 2.2+). capmem computes the level
|
||
// (requires_java_card); the local table is the fallback for an older
|
||
// server response.
|
||
const fw = imports.find(im => (im.aid || '').toUpperCase() === 'A0000000620101');
|
||
const sdk = mem.requires_java_card ||
|
||
(fw ? JC_FRAMEWORK_SDK[(Number(fw.major) || 0) + '.' + (Number(fw.minor) || 0)] : '');
|
||
let against = sdk ? 'Java Card \u2265 ' + sdk
|
||
: (fw ? 'javacard.framework \u2265 ' + (Number(fw.major) || 0) + '.' + (Number(fw.minor) || 0) : '');
|
||
// the ETSI release the UICC API imports come from (capmem: uicc.toolkit
|
||
// version -> REL-N per the workspace sim-tools corpus), basis in brackets
|
||
if (mem.requires_etsi_release) {
|
||
against += (against ? ' \u00b7 ' : '') + 'ETSI ' + mem.requires_etsi_release +
|
||
(mem.requires_etsi_basis ? ' (' + mem.requires_etsi_basis + ')' : '');
|
||
}
|
||
if (mem.cap_version) against += (against ? ' \u00b7 ' : '') + t('CAP format') + ' ' + mem.cap_version;
|
||
if (mem.needs_int) against += (against ? ' \u00b7 ' : '') + t('int support');
|
||
if (against) pkgRows += row(label(esc(t('Platform'))) + cell(esc(against), 'pl-4'));
|
||
if (pkgRows) html += section(t('Package')) + table(pkgRows);
|
||
|
||
// ── the libraries the CAP is linked against (JC VM spec 6.6), shown as
|
||
// ">=" requirements: same major, minor >= the export version (4.5.2);
|
||
// refs = distinct constant-pool references to the package (6.7) ──
|
||
if (imports.length) {
|
||
let reqRows = row(head('', 'text-left') + head(esc(t('AID')), 'pl-4 text-left') +
|
||
head(esc(t('Version')), 'pl-4 text-right whitespace-nowrap') +
|
||
head(esc(t('Family')), 'pl-4 text-left') + head(esc(t('Refs')), 'pl-4 text-right whitespace-nowrap'));
|
||
imports.forEach(im => {
|
||
const name = jcAidName(im.aid);
|
||
const fam = jcAidFamily(im.aid);
|
||
// unresolved names show a dash: the AID column carries the identifier
|
||
reqRows += row(cell(name ? esc(name) : '\u2014', 'whitespace-nowrap') +
|
||
cell(esc(im.aid), 'pl-4 font-mono break-all') +
|
||
num('\u2265 ' + (Number(im.major) || 0) + '.' + (Number(im.minor) || 0)) +
|
||
part(esc(fam)) + num(String(Number(im.refs) || 0)));
|
||
});
|
||
html += section(t('Requires') + ' (' + imports.length + ')') + table(reqRows);
|
||
}
|
||
|
||
// ── memory: the NVRAM requirement is the C6+C8-style total (GP Card
|
||
// Spec v2.3.1 Table 11-48) of the code image (load file) and the
|
||
// persistent data; children are indented under their total ──
|
||
const mrows = [];
|
||
mrows.push(row(label(esc(t('NVRAM requirement'))) + num(bytes(requirement)) +
|
||
part(esc(t('code image') + ' + ' + t('data')))));
|
||
mrows.push(row(label(esc(t('code image (load file)')), 'pl-4') + num(bytes(loadFile)) +
|
||
part(esc('Method.cap ' + capMemBytes(methodBytes) + ' \u00b7 ' + t('other components') + ' ' + capMemBytes(otherComponents)))));
|
||
mrows.push(row(label(esc(t('persistent data')), 'pl-4') + num(bytes(nvramData)) + part(esc([
|
||
t('static image') + ' ' + capMemBytes(nv.static_image),
|
||
t('static array init') + ' ' + capMemBytes(nv.array_init),
|
||
t('install-time objects') + ' ' + capMemBytes(nv.install_objects),
|
||
t('object headers') + ' ' + capMemBytes(nv.header_overhead),
|
||
t('reference storage') + ' ' + capMemBytes(nv.ref_storage),
|
||
].join(' \u00b7 ')))));
|
||
mrows.push(row(label(esc(t('RAM (volatile)'))) + num(bytes(ram.total)) + part(esc([
|
||
t('transient arrays') + ' ' + capMemBytes(ram.transient_arrays),
|
||
t('runtime transient') + ' ' + capMemBytes(ram.runtime_transient),
|
||
t('peak method frame') + ' ' + capMemBytes(ram.peak_frame),
|
||
].join(' \u00b7 ')))));
|
||
mrows.push(row(label(esc(t('Suggested quotas (informational)'))) + cell(
|
||
'<span title="' + esc(t('C6 = non-volatile code space (the load file); C7 = volatile (RAM); C8 = non-volatile data. Values are bytes; the hex is the install parameter coding.')) + '">'
|
||
+ esc(capQuotaText(sug)) + '</span>',
|
||
'pl-4 font-mono', 2)));
|
||
html += section(t('Memory')) + table(mrows.join(''));
|
||
|
||
// ── component sizes in load-file order (incl. the Directory/Export
|
||
// entries capmem does not parse) with each entry's share of the load file
|
||
if (comps.length) {
|
||
const ctotal = loadFile || comps.reduce((sum, c) => sum + (Number(c.size) || 0), 0);
|
||
const tcls = 'border-t border-gray-200 dark:border-slate-700';
|
||
let crows = row(head(esc(t('Component')), 'text-left') + head(esc(t('Size')), 'pl-4 text-right whitespace-nowrap') +
|
||
head(esc(t('Share')), 'pl-4 text-right whitespace-nowrap'));
|
||
comps.forEach(c => {
|
||
const sz = Number(c.size) || 0;
|
||
crows += row(label(esc(c.name)) + num(bytes(sz)) +
|
||
num(ctotal ? Math.round(sz * 100 / ctotal) + '%' : '\u2014'));
|
||
});
|
||
crows += row(cell(esc(t('Load file (total)')), tcls) +
|
||
cell(bytes(loadFile), 'pl-4 text-right whitespace-nowrap font-mono ' + tcls) +
|
||
cell('100%', 'pl-4 text-right whitespace-nowrap font-mono ' + tcls));
|
||
html += disclosure(t('Components') + ' (' + comps.length + ')') + table(crows) + '</details>';
|
||
}
|
||
|
||
// Scan warnings only - the NVRAM definition and the "estimate only" note
|
||
// live in the help; the CAP details box stays compact (v3.6.33).
|
||
(mem.warnings || []).forEach(w => {
|
||
html += '<div class="text-amber-600 dark:text-amber-400">\u26a0 ' + esc(w) + '</div>';
|
||
});
|
||
return html;
|
||
}
|
||
|
||
function capRenderAnalysis(kind) {
|
||
const box = document.getElementById(kind === 'ram' ? 'ram-cap-info' : 'scripts-cap-info');
|
||
const body = document.getElementById(kind === 'ram' ? 'ram-cap-body' : 'scripts-cap-body');
|
||
if (!box || !body) return;
|
||
const st = _capAnalysis[kind];
|
||
if (!st || st.status === 'idle') {
|
||
box.classList.add('hidden');
|
||
body.innerHTML = '';
|
||
return;
|
||
}
|
||
box.classList.remove('hidden');
|
||
if (st.status === 'analyzing') {
|
||
body.innerHTML = '<span class="text-gray-400">' + esc(t('Analyzing CAP file...')) + '</span>';
|
||
} else if (st.status === 'error') {
|
||
body.innerHTML = '<span class="text-red-500">' + esc(t('Analysis failed:')) + ' ' + esc(st.error || '') + '</span>' +
|
||
' <button class="underline text-gray-500 hover:text-gray-700 dark:text-slate-400" onclick="' +
|
||
(kind === 'ram' ? 'ramCapFileChanged()' : 'scriptsCapFileChanged()') + '">' + esc(t('Retry')) + '</button>';
|
||
} else {
|
||
body.innerHTML = capMemHtml(st.memory);
|
||
}
|
||
}
|
||
|
||
// Read the selected file, validate + estimate it on the server and update the
|
||
// form. The token guards against out-of-order responses on re-selection.
|
||
async function capAnalyzeFile(file, kind) {
|
||
const st = _capAnalysis[kind];
|
||
st.token++;
|
||
const token = st.token;
|
||
st.key = capFileKey(file);
|
||
st.memory = null;
|
||
st.error = null;
|
||
st.status = file ? 'analyzing' : 'idle';
|
||
capRenderAnalysis(kind);
|
||
pysimApplyAvailability();
|
||
if (!file) return;
|
||
try {
|
||
const hex = await ramReadFileHex(file);
|
||
const resp = await pysimFetch('/api/cap-info', { cap_hex: hex });
|
||
if (token !== st.token) return;
|
||
if (resp && resp.ok) {
|
||
st.status = 'ok';
|
||
st.memory = resp.memory || {};
|
||
if (kind === 'ram') { ramApplyCapToolkitMode(); ramProbeFill(st.memory); }
|
||
} else {
|
||
st.status = 'error';
|
||
st.error = (resp && resp.error) || t('Analysis failed');
|
||
}
|
||
} catch (e) {
|
||
if (token !== st.token) return;
|
||
st.status = 'error';
|
||
st.error = String(e && e.message ? e.message : e);
|
||
}
|
||
capRenderAnalysis(kind);
|
||
pysimApplyAvailability();
|
||
}
|
||
|
||
function ramCapFileChanged() {
|
||
const input = document.getElementById('ram-cap-file');
|
||
capAnalyzeFile(input && input.files ? input.files[0] : null, 'ram');
|
||
}
|
||
|
||
function scriptsCapFileChanged() {
|
||
const input = document.getElementById('scripts-cap-file');
|
||
capAnalyzeFile(input && input.files ? input.files[0] : null, 'scripts');
|
||
}
|
||
|
||
// Collect SP params from the SP form (populated by ramApplyCard -> cardsApply)
|
||
function getRamSpParams(selId) {
|
||
// `preset_id` names the server-side preset the operation belongs to: the
|
||
// server persists the counter it consumes there (v3.8.0). `selId` picks
|
||
// the view's preset selector (RAM by default, the TAR probe its own).
|
||
const sel = document.getElementById(selId || 'ram-card-sel');
|
||
const idx = sel ? parseInt(sel.value, 10) : NaN;
|
||
const preset = (!isNaN(idx) && cards[idx]) ? cards[idx] : null;
|
||
return {
|
||
preset_id: (preset && preset.id) || '',
|
||
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 || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase().padEnd(6, '0').slice(0, 6),
|
||
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, ''),
|
||
ram_format: (document.getElementById('ram-format') || {}).value || 'auto',
|
||
};
|
||
}
|
||
|
||
function ramIncrementCntr(cntr) {
|
||
let v = (parseInt(cntr, 16) || 0) + 1;
|
||
return v.toString(16).toUpperCase().padStart(10, '0').slice(-10);
|
||
}
|
||
|
||
function ramSaveCntr(cntr) {
|
||
// Keep the local copy in step with the counter the server persisted for the
|
||
// operation (v3.8.0): display only - the store already holds it.
|
||
const el = document.getElementById('sp-cntr');
|
||
if (el) el.value = cntr;
|
||
const selIdx = parseInt((document.getElementById('ram-card-sel') || {}).value, 10);
|
||
if (!isNaN(selIdx) && cards[selIdx]) {
|
||
const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) ||
|
||
spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0;
|
||
const ks = spKeysetFor(cards[selIdx], kvn);
|
||
if (ks) ks.cntr = cntr;
|
||
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. `ram_format` opts in to the per-operation command-format
|
||
// detection: 'auto' on the first packet, the detected value afterwards.
|
||
// Caller controls SPI2: 0x01 = PoR via ENVELOPE response, 0x21 = SMS-SUBMIT
|
||
const body = Object.assign({}, sp, { apdu: apduHex, sp: '', includeCpi: true,
|
||
ram_format: sp.ram_format || 'auto' });
|
||
return await pysimFetch('/api/send-ota', body);
|
||
}
|
||
|
||
// ===== Standard package AIDs (JavaCard / ETSI / 3GPP / GlobalPlatform) =====
|
||
// Package AIDs of the standard libraries applets can link against, mapped to
|
||
// the library package name. Source: the workspace research note
|
||
// `docs/JAVACARD.md` (2026-09-26 - parsed from the JavaCard export files in
|
||
// the project corpus: 59 unique package AIDs; its standard-library subset plus
|
||
// the recognisable non-library entries of its section 5), and GP Card Spec
|
||
// v2.3.1 H.1.3 for the default Issuer Security Domain AID. Matching is exact;
|
||
// JC_AID_RIDS only gives a weak owner hint for otherwise unknown AIDs
|
||
// (JAVACARD.md section 6). Hand-maintained - update from the note, never from
|
||
// a card registry (vendor/applet AIDs stay unnamed).
|
||
const JC_AID_NAMES = {
|
||
// Oracle / Sun JavaCard (RID A000000062)
|
||
'A0000000620001': 'java.lang',
|
||
'A0000000620002': 'java.io',
|
||
'A0000000620003': 'java.rmi',
|
||
'A0000000620101': 'javacard.framework',
|
||
'A000000062010101': 'javacard.framework.service',
|
||
'A0000000620102': 'javacard.security',
|
||
'A0000000620201': 'javacardx.crypto',
|
||
'A0000000620202': 'javacardx.biometry',
|
||
'A0000000620203': 'javacardx.external',
|
||
'A0000000620204': 'javacardx.biometry1toN',
|
||
'A0000000620205': 'javacardx.security',
|
||
'A000000062020501': 'javacardx.security.cert',
|
||
'A000000062020502': 'javacardx.security.derivation',
|
||
'A000000062020503': 'javacardx.security.util',
|
||
'A000000062020801': 'javacardx.framework.util',
|
||
'A00000006202080101': 'javacardx.framework.util.intx',
|
||
'A000000062020802': 'javacardx.framework.math',
|
||
'A000000062020803': 'javacardx.framework.tlv',
|
||
'A000000062020804': 'javacardx.framework.string',
|
||
'A000000062020805': 'javacardx.framework.event',
|
||
'A000000062020806': 'javacardx.framework.nio',
|
||
'A000000062020807': 'javacardx.framework.time',
|
||
'A0000000620209': 'javacardx.apdu',
|
||
'A000000062020901': 'javacardx.apdu.util',
|
||
// JavaCard runtime internals (recognisable, not linkable libraries)
|
||
'A0000000620301': 'com.sun.javacard.impl',
|
||
'A000000062030108': 'com.sun.javacard.installer',
|
||
'A00000006203010E': 'com.sun.javacard.implBiometry',
|
||
'A00000006203010F': 'com.sun.javacard.crypto',
|
||
// ETSI SIM / UICC (RID A000000009)
|
||
'A0000000090003FFFFFFFF8910710001': 'sim.access',
|
||
'A0000000090003FFFFFFFF8910710002': 'sim.toolkit',
|
||
'A0000000090005FFFFFFFF8911000000': 'uicc.access',
|
||
'A0000000090005FFFFFFFF8911010000': 'uicc.access.fileadministration',
|
||
'A0000000090005FFFFFFFF8911020000': 'uicc.access.bertlvfile',
|
||
'A0000000090005FFFFFFFF8912000000': 'uicc.toolkit',
|
||
'A0000000090005FFFFFFFF8913000000': 'uicc.system',
|
||
'A0000000090005FFFFFFFF8917000000': 'uicc.suspendresume',
|
||
'A0000000090005FFFFFFFF8918010000': 'uicc.services.highupdatearray',
|
||
// 3GPP USIM / ISIM (RID A000000087)
|
||
'A0000000871005FFFFFFFF8913100000': 'uicc.usim.access',
|
||
'A0000000871005FFFFFFFF8913200000': 'uicc.usim.toolkit',
|
||
'A0000000871005FFFFFFFF8913300000': 'uicc.usim.geolocation',
|
||
'A0000000871005FFFFFFFF8913400000': 'uicc.usim.suci',
|
||
'A0000000871005FFFFFFFF8914100000': 'uicc.isim.access',
|
||
// GlobalPlatform
|
||
'A00000015100': 'org.globalplatform',
|
||
'A000000151000000': 'GlobalPlatform Issuer Security Domain', // default ISD AID, GPC v2.3.1 H.1.3
|
||
// Legacy / test packages (recognisable, not linkable libraries)
|
||
'A0000000030000': 'visa/openplatform',
|
||
'123456654011': 'com.denis',
|
||
};
|
||
const JC_AID_RIDS = {
|
||
'A000000062': 'Oracle JavaCard RID',
|
||
'A000000009': 'ETSI RID',
|
||
'A000000087': '3GPP RID',
|
||
'A000000151': 'GlobalPlatform RID',
|
||
};
|
||
|
||
// Normalise an AID (strip spacing, uppercase) and reject malformed input;
|
||
// '' when it cannot be an AID (5-16 bytes).
|
||
function jcAidNorm(aid) {
|
||
const s = (aid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
return (s.length >= 10 && s.length <= 32 && s.length % 2 === 0) ? s : '';
|
||
}
|
||
|
||
// Resolve an AID to a standard package name, a well-known AID name or a RID
|
||
// owner hint; '' when nothing is known.
|
||
function jcAidName(aid) {
|
||
const s = jcAidNorm(aid);
|
||
if (!s) return '';
|
||
if (JC_AID_NAMES[s]) return JC_AID_NAMES[s];
|
||
for (const rid in JC_AID_RIDS) {
|
||
if (s.startsWith(rid)) return JC_AID_RIDS[rid];
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// Library family of a standard package AID (JAVACARD.md section 6); '' for
|
||
// applet/vendor AIDs so the UI never guesses.
|
||
function jcAidFamily(aid) {
|
||
const s = jcAidNorm(aid);
|
||
if (!s) return '';
|
||
if (s.startsWith('A000000062')) return 'Oracle JavaCard API';
|
||
if (s.startsWith('A000000009')) {
|
||
return s.indexOf('0003FFFFFFFF') >= 0 ? 'ETSI SIM (2G) API' : 'ETSI UICC API';
|
||
}
|
||
if (s.startsWith('A000000087')) return '3GPP USIM/ISIM API';
|
||
if (s.startsWith('A000000151')) return 'GlobalPlatform API';
|
||
return '';
|
||
}
|
||
|
||
// javacard.framework version -> Oracle SDK release family. Derived from the
|
||
// local Oracle SDK kit corpus (jc211_kit .. jc305u4_kit exports, 2026-09-27);
|
||
// versions not present there get no hint rather than a guess.
|
||
const JC_FRAMEWORK_SDK = {
|
||
'1.0': '2.1.1/2.1.2', '1.2': '2.2.1', '1.3': '2.2.2',
|
||
'1.4': '3.0.3', '1.5': '3.0.4', '1.6': '3.0.5',
|
||
};
|
||
|
||
// ' (name)' for plain-text listings; '' when the AID is unknown.
|
||
function jcAidSuffix(aid) {
|
||
const name = jcAidName(aid);
|
||
return name ? ' (' + name + ')' : '';
|
||
}
|
||
|
||
// Escaped AID plus a muted parenthesised name, for HTML listings.
|
||
function jcAidHtml(aid) {
|
||
const name = jcAidName(aid);
|
||
return esc(aid) + (name ? ' <span class="text-gray-400 dark:text-slate-500">(' + esc(name) + ')</span>' : '');
|
||
}
|
||
|
||
// ===== 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.
|
||
// Known tags per GP Card Spec v2.3.1 Tables 11-36/11-37 and ETSI TS 102 226
|
||
// 8.2.1.6 (EA SCP Registry Data -> 80 Menu Parameters); unknown registry data
|
||
// objects are preserved in rawTlvs instead of being dropped.
|
||
function _parseE3Entry(hex) {
|
||
const children = parseTLV(hex || '');
|
||
const r = {};
|
||
const raw = [];
|
||
for (const c of children) {
|
||
const v = c.value.toUpperCase();
|
||
if (c.tag === '4F') r.aid = v;
|
||
else if (c.tag === '9F70') r.lifecycle = v;
|
||
else if (c.tag === 'C5') r.privileges = v;
|
||
else if (c.tag === 'CE') r.version = v;
|
||
else if (c.tag === 'CC') r.sdAid = v;
|
||
else if (c.tag === 'C4') r.elfAid = v;
|
||
else if (c.tag === 'CF') r.implicitSel = v;
|
||
else if (c.tag === '84') {
|
||
// GP Table 11-37: one Executable Module AID per '84' TLV, repeated
|
||
// per module. Some cards concatenate 4F<len><aid> TLVs inside one
|
||
// '84' - accept both codings.
|
||
const aids = ramParseModuleAids(v);
|
||
const list = aids.length ? aids : (v.length >= 10 && v.length <= 32 ? [v] : []);
|
||
r.moduleAids = (r.moduleAids || []).concat(list);
|
||
}
|
||
else if (c.tag === 'EA') {
|
||
// SCP Registry Data (TS 102 226 8.2.1.6 Table 8.2): a Menu
|
||
// Parameters TLV ('80') with (position, identifier, state)
|
||
// triples (Table 8.3). An empty EA is valid (no menu entries).
|
||
const entries = _parseMenuEntries(v);
|
||
if (entries.length) r.menuEntries = entries;
|
||
else if (v) raw.push({ tag: 'EA', value: v });
|
||
}
|
||
else raw.push({ tag: c.tag, value: v });
|
||
}
|
||
if (raw.length) r.rawTlvs = raw;
|
||
return r;
|
||
}
|
||
|
||
// EA SCP Registry Data -> Menu Parameters ('80'): the value is a list of
|
||
// 3-byte menu entry triples (position, identifier, state; '00' disabled /
|
||
// '01' enabled, TS 102 226 Table 8.3).
|
||
function _parseMenuEntries(hex) {
|
||
const out = [];
|
||
for (const c of parseTLV(hex || '')) {
|
||
if (c.tag !== '80') continue;
|
||
const v = c.value.toUpperCase();
|
||
for (let i = 0; i + 6 <= v.length; i += 6) {
|
||
out.push({ pos: v.substr(i, 2), id: v.substr(i + 2, 2), state: v.substr(i + 4, 2) });
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// 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).
|
||
// Raw format (P2=00): consecutive <aid_len><AID><lifecycle><privileges>.
|
||
// The length byte is the AID length (16-byte A113/D276 AIDs included - the old
|
||
// "rawLen-1" guess truncated exactly those).
|
||
function _parseRawAppEntry(hex, i) {
|
||
const aidLen = parseInt(hex.substr(i, 2), 16);
|
||
if (aidLen < 5 || aidLen > 16 || i + 2 + aidLen * 2 + 4 > 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;
|
||
const privileges = hex.substr(j, 2).toUpperCase(); j += 2;
|
||
return { aid, lifecycle, privileges, next: j };
|
||
}
|
||
|
||
// Raw format (P2=00) ELF entry: <aid_len><AID><lifecycle><privileges>. The
|
||
// 4th byte is the privileges byte - the legacy GET STATUS response shape,
|
||
// cross-checked against GlobalPlatformPro's populate_legacy (for load files it
|
||
// must be 0x00); there is no version in the compact listing, it comes from the
|
||
// expanded 'CE' tag. The P1=10 (ELF + modules) listing appends
|
||
// <module_count> (<module_len><module_AID>)*.
|
||
// The walk is deterministic on the AID length - the old "scan for 0x10"
|
||
// heuristic derailed whenever a length byte or an AID byte happened to be
|
||
// 0x10 (which silently dropped entries like F0414C46416101).
|
||
function _parseRawElfEntry(hex, i, withModules) {
|
||
const aidLen = parseInt(hex.substr(i, 2), 16);
|
||
if (aidLen < 5 || aidLen > 16 || i + 2 + aidLen * 2 + 4 > 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;
|
||
const privileges = hex.substr(j, 2).toUpperCase(); j += 2;
|
||
const out = { aid, lifecycle, privileges, next: j };
|
||
if (!withModules) return out;
|
||
// P1=10 (ELF + modules) entry: ...<lifecycle><privileges><module_count>
|
||
// followed by <module_len><module_AID> entries. Verified against two
|
||
// live traces (a F0414C46416101 ELF with its F0414C4641610101 module).
|
||
if (j + 2 > hex.length) return out;
|
||
const modCount = parseInt(hex.substr(j, 2), 16); j += 2;
|
||
const moduleAids = [];
|
||
for (let k = 0; k < modCount && j + 4 <= hex.length; k++) {
|
||
const len = parseInt(hex.substr(j, 2), 16);
|
||
if (len < 5 || len > 16 || j + 2 + len * 2 > hex.length) break;
|
||
moduleAids.push(hex.substr(j + 2, len * 2).toUpperCase());
|
||
j += 2 + len * 2;
|
||
}
|
||
if (moduleAids.length) out.moduleAids = moduleAids;
|
||
out.next = j;
|
||
return out;
|
||
}
|
||
|
||
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, withModules) {
|
||
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><version>
|
||
// entries; P1=10 pages carry the module list (withModules). Resync one byte
|
||
// at a time instead of stopping at the first unparsable byte.
|
||
const out = [];
|
||
let i = 0;
|
||
while (i + 4 <= s.length) {
|
||
const r = _parseRawElfEntry(s, i, withModules);
|
||
if (r) { out.push({ type: 'elf', ...r }); i = r.next; continue; }
|
||
i += 2;
|
||
}
|
||
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 =====
|
||
// GP Card Spec v2.3.1 11.1.1: the life cycle coding depends on the registry
|
||
// object kind (Tables 11-3..11-6; the ISD inherits the card life cycle).
|
||
// `kind`: 'isd' (card life cycle) | 'app' | 'sd' | 'elf'. Cards may return
|
||
// extra bytes after the state (e.g. 9F70 02 0700): decode the first byte and
|
||
// surface non-zero extras instead of guessing what they are.
|
||
function ramFmtLifecycle(hex, kind) {
|
||
const s = (hex || '').toUpperCase();
|
||
if (!s) return '';
|
||
const b = parseInt(s.substr(0, 2), 16);
|
||
const extra = s.length > 2 && !/^0+$/.test(s.substr(2)) ? ' (0x' + s.substr(2) + ')' : '';
|
||
let name = '';
|
||
if (kind === 'elf') {
|
||
// GP Table 11-3: LOADED only.
|
||
if (b === 0x01) name = 'LOADED';
|
||
} else if (kind === 'isd') {
|
||
// GP Table 11-6: OP_READY / INITIALIZED / SECURED / CARD_LOCKED /
|
||
// TERMINATED (the ISD inherits the card life cycle).
|
||
name = { 1: 'OP_READY', 7: 'INITIALIZED', 15: 'SECURED', 127: 'CARD_LOCKED', 255: 'TERMINATED' }[b] || '';
|
||
} else if (kind === 'sd') {
|
||
// GP Table 11-5: INSTALLED / SELECTABLE / PERSONALIZED / LOCKED.
|
||
name = { 3: 'INSTALLED', 7: 'SELECTABLE', 15: 'PERSONALIZED' }[b] || '';
|
||
if (!name && (b & 0x83) === 0x83) name = 'LOCKED';
|
||
} else {
|
||
// GP Table 11-4 (default: an application): INSTALLED / SELECTABLE /
|
||
// application-specific bits b4-b7 (still selectable) / LOCKED.
|
||
if (b === 0x03) name = 'INSTALLED';
|
||
else if (b === 0x07) name = 'SELECTABLE';
|
||
else if ((b & 0x87) === 0x07) name = 'SELECTABLE (0x' + s.substr(0, 2) + ')';
|
||
else if ((b & 0x83) === 0x83) name = 'LOCKED';
|
||
}
|
||
return name ? name + extra : '0x' + s + extra;
|
||
}
|
||
|
||
// GP Table 11-37 Note 1: on a Java Card the Executable Load File Version
|
||
// Number is a 2-byte major/minor pair (in this order); other lengths stay raw.
|
||
function ramFmtVersion(hex) {
|
||
const s = (hex || '').toUpperCase();
|
||
if (/^[0-9A-F]{4}$/.test(s)) return parseInt(s.substr(0, 2), 16) + '.' + parseInt(s.substr(2, 2), 16);
|
||
return s;
|
||
}
|
||
|
||
// TS 102 226 Table 8.3 menu entry state: '00' disabled, '01' enabled.
|
||
function ramMenuState(state) {
|
||
const s = (state || '').toUpperCase();
|
||
if (s === '00') return t('Disabled');
|
||
if (s === '01') return t('Enabled');
|
||
return '0x' + s;
|
||
}
|
||
|
||
// Life cycle coding kind of an explorer entry (GP 11.1.1): an application
|
||
// carrying the Security Domain privilege (GP Table 11-7 byte 1 b8) is decoded
|
||
// as a Security Domain; the ISD section and the ELF section pass their kind
|
||
// explicitly.
|
||
function ramEntryKind(o) {
|
||
if (!o) return 'app';
|
||
if (o.type === 'elf') return 'elf';
|
||
const b1 = parseInt(((o.privileges || '').substr(0, 2)) || '0', 16);
|
||
return (b1 & 0x80) ? 'sd' : 'app';
|
||
}
|
||
|
||
// Decode the GP privileges value (tag C5 / compact listing) into
|
||
// human-readable strings. Delegates to the shared GP decoder so the RAM view
|
||
// and the C-APDU parser can never drift apart: the previous private table had
|
||
// wrong bits and invented names like "Cum Deletion Ctr" (v3.6.28).
|
||
function ramFmtPrivileges(hex) {
|
||
const p = (hex || '').replace(/[^0-9a-fA-F]/g, '');
|
||
if (!p) return t('(none)');
|
||
const decoded = decodePrivileges(p);
|
||
return decoded === 'None' ? t('(none)') : decoded;
|
||
}
|
||
|
||
// One explorer action button. All buttons live in a fixed-width action
|
||
// column so Delete / Delete All / Make selectable / Install line up
|
||
// table-like across the entry and module rows (v3.6.29).
|
||
function ramActionBtn(label, action, tone) {
|
||
const tones = {
|
||
danger: 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400',
|
||
ok: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400',
|
||
};
|
||
return '<button onclick="' + action + '" data-needs="card" class="w-full px-2 py-0.5 text-xs text-center whitespace-nowrap rounded '
|
||
+ (tones[tone] || tones.ok)
|
||
+ ' disabled:opacity-40 disabled:cursor-not-allowed">' + esc(label) + '</button>';
|
||
}
|
||
|
||
// One line per STK menu entry the card reports for an applet (EA SCP Registry
|
||
// Data, TS 102 226 8.2.1.6): identifier, position and state.
|
||
function ramMenuHtml(entries) {
|
||
let html = '';
|
||
(entries || []).forEach(m => {
|
||
html += '<div>' + esc(t('STK menu entry:')) + ' 0x' + esc(m.id) +
|
||
' (' + esc(t('position')) + ' ' + parseInt(m.pos, 16) + ', ' + esc(ramMenuState(m.state)) + ')</div>';
|
||
});
|
||
return html;
|
||
}
|
||
|
||
// Registry data objects the card returned but the Explore does not decode
|
||
// (GP Tables 11-36/11-37 and TS 102 226 extensions): preserved and shown, so
|
||
// nothing from the card's answer is silently dropped.
|
||
function ramRawTlvsHtml(rawTlvs) {
|
||
if (!rawTlvs || !rawTlvs.length) return '';
|
||
return '<div class="text-gray-500 dark:text-slate-400">' + esc(t('Other registry data:')) + ' ' +
|
||
rawTlvs.map(x => esc(x.tag) + '=' + esc(x.value)).join(' \u00b7 ') + '</div>';
|
||
}
|
||
|
||
// Merge the expanded (E3) pass into the compact entries: fill only the fields
|
||
// the compact listing does not carry (it has AID, life cycle and privileges,
|
||
// plus the module list for P1=10; the version, Load File AID, SD AID, menu
|
||
// entries and unknown registry data come from the expanded pass). A field
|
||
// already present in the compact entry is never overwritten.
|
||
function ramMergeExpanded(collector, parsed) {
|
||
const byAid = {};
|
||
collector.forEach(e => { if (e.aid) byAid[e.aid.toUpperCase()] = e; });
|
||
parsed.forEach(p => {
|
||
const cur = byAid[(p.aid || '').toUpperCase()];
|
||
if (!cur) return;
|
||
['privileges', 'lifecycle', 'version', 'elfAid', 'sdAid', 'implicitSel', 'menuEntries', 'rawTlvs'].forEach(k => {
|
||
if (p[k] && !cur[k]) cur[k] = p[k];
|
||
});
|
||
if (p.moduleAids && p.moduleAids.length) cur.moduleAids = p.moduleAids;
|
||
});
|
||
}
|
||
|
||
// Group the per-listing expanded outcomes (pure): one group per label, best
|
||
// outcome wins (a pass that returned entries > an accepted-but-empty pass >
|
||
// a refusal), so a listing queried twice (P1=10 and then the P1=20 fallback)
|
||
// never appears twice in the report and a form that produced data is never
|
||
// reported as a refusal.
|
||
function ramExpandedGroups(report) {
|
||
const groups = [];
|
||
(report || []).forEach(r => {
|
||
if (!r || !r.label) return;
|
||
let g = groups.find(x => x.label === r.label);
|
||
if (!g) {
|
||
g = { p1: r.p1 || '', label: r.label, form: null, entries: 0, score: 0,
|
||
sw: '', compact: false, refused: false, fmt: null };
|
||
groups.push(g);
|
||
}
|
||
g.compact = g.compact || !!r.compact;
|
||
g.transport = g.transport || !!r.transport;
|
||
g.more = g.more || !!r.more;
|
||
if (r.form) {
|
||
const score = ((r.entries || 0) > 0 ? 100 : 0) +
|
||
(RAM_EXPANDED_FORMS.length - RAM_EXPANDED_FORMS.indexOf(r.form));
|
||
if (score > g.score) {
|
||
g.form = r.form;
|
||
g.entries = r.entries || 0;
|
||
g.score = score;
|
||
g.fmt = r.fmt || null;
|
||
}
|
||
} else {
|
||
g.refused = true;
|
||
if (r.sw) g.sw = r.sw;
|
||
}
|
||
});
|
||
return groups;
|
||
}
|
||
|
||
// The "more occurrences" flag of one expanded-registry page pass (pure): a
|
||
// page that promised more data ('6310', or an accepted chained retry) is
|
||
// fulfilled by the next accepted page; it stays set when the follow-up was
|
||
// refused/failed or the page cap stopped the loop - only then is the listing
|
||
// "first page only".
|
||
function ramExpandedMore(promised, sw, page) {
|
||
if (!promised) return false;
|
||
if (page >= 8) return true;
|
||
return !ramRemoteSwOk(sw);
|
||
}
|
||
|
||
// Summary of the expanded-registry pass (pure): which query form the card
|
||
// accepted per listing and where the run fell back to the compact listing.
|
||
// Shown in the explorer so a fallback (and the missing package versions) is
|
||
// never silent.
|
||
function ramExpandedReport(report) {
|
||
const groups = ramExpandedGroups(report);
|
||
if (!groups.length) return '';
|
||
const formName = f => f === 'tags' ? t('tag list + EA')
|
||
: (f === 'noea' ? t('tag list') : (f === 'lean' ? t('lean tag list') : t('no tag list')));
|
||
// The serving command format is named when it is one of the expanded
|
||
// codings: some cards refuse the compact P2=02 query and serve it only
|
||
// inside the TS 102 226 scripting template (5.2.1)
|
||
const fmtName = f => f === 'expanded' ? 'AA' : (f === 'expanded-ae' ? 'AE 80' : '');
|
||
const parts = [];
|
||
RAM_EXPANDED_FORMS.forEach(f => {
|
||
const labels = groups.filter(g => g.form === f)
|
||
.map(g => g.label + (g.more ? ' (' + t('first page only') + ')' : '') +
|
||
(ramFormatIsExpanded(g.fmt) ? ' [' + fmtName(g.fmt) + ']' : ''));
|
||
if (labels.length) parts.push(formName(f) + ' (' + labels.join(', ') + ')');
|
||
});
|
||
groups.forEach(g => {
|
||
if (g.form) return;
|
||
const why = g.sw ? ' (' + g.sw + ')' : '';
|
||
if (!g.compact) {
|
||
parts.push(g.label + ': ' + (g.transport && g.sw
|
||
? t('request error') + ' (' + g.sw + ')' : (g.sw || t('no data'))));
|
||
} else if (g.transport) {
|
||
parts.push(g.label + ': ' + t('request error') + why + ' \u2014 ' + t('compact listing used'));
|
||
} else if (g.p1 === '80') {
|
||
// cards commonly report nothing for the ISD in the expanded form
|
||
// (GPPro treats its 6A88/6A86 as 'no data to report')
|
||
parts.push(g.label + ': ' + t('no expanded data') + why + ' \u2014 ' + t('compact listing used'));
|
||
} else {
|
||
parts.push(g.label + ': ' + t('refused') + why + ' \u2014 ' + t('compact listing used'));
|
||
}
|
||
});
|
||
const anyForm = groups.some(g => g.form);
|
||
const head = anyForm
|
||
? t('Expanded registry:')
|
||
: t('Expanded registry refused by the card') + ' \u2014 ' +
|
||
t('compact listing only (package versions unavailable)');
|
||
return head + ' ' + parts.join(' \u00b7 ');
|
||
}
|
||
|
||
// The form the ELF listing answered with ('' when it never produced entries):
|
||
// the version hint is worded from it.
|
||
function ramExpandedElfForm(report) {
|
||
const g = ramExpandedGroups(report).find(x => x.label === t('ELFs'));
|
||
return g && g.form && (g.entries || 0) > 0 ? g.form : '';
|
||
}
|
||
|
||
// Why the ELF rows show no version (pure). The version exists only in the
|
||
// tag-list forms of the expanded query: without a tag list the card builds the
|
||
// default E3 (AID + life cycle + modules, GP 11.4.3.1).
|
||
function ramElfVersionHint(elfForm) {
|
||
if (elfForm === 'notags') {
|
||
return t('Package versions are only in the tag-list forms of the expanded registry query \u2014 this card accepted only the no-tag-list form.');
|
||
}
|
||
return t('Package versions come from the expanded registry query \u2014 this card did not return them.');
|
||
}
|
||
|
||
// The applet instance that belongs to a package (pure). The package's module
|
||
// AIDs are the applet instance AIDs - they can differ from the package AID in
|
||
// the last byte (live 2026-09-30: package A113...0200 with modules ...0208 /
|
||
// ...494D4508, all present as applet instances); a shared AID prefix is the
|
||
// fallback rule. Applets that already carry a version are skipped.
|
||
function ramElfAppletCandidate(elf, apps) {
|
||
if (!elf || !elf.aid) return null;
|
||
const cands = (elf.moduleAids || []).concat([elf.aid])
|
||
.map(c => String(c).toUpperCase());
|
||
const list = (apps || []).filter(a => a && a.aid && !a.version);
|
||
const exact = list.find(a => cands.indexOf(String(a.aid).toUpperCase()) >= 0);
|
||
if (exact) return exact;
|
||
const pkg = String(elf.aid).toUpperCase();
|
||
return list.find(a => String(a.aid).toUpperCase().indexOf(pkg) === 0) || null;
|
||
}
|
||
|
||
// Fill a missing ELF version from an application entry: cards commonly return
|
||
// the load file version (CE) on the app entry together with its package AID
|
||
// (C4), so the package's version can be read from its applet instance (live
|
||
// 2026-09-30: the ELF listing refused every tag list while the apps listing
|
||
// carried CE). The row is annotated (`versionFrom`) so the inference stays
|
||
// visible. Packages without an applet instance get no version this way.
|
||
function ramDeriveElfVersions(elfs, apps) {
|
||
(elfs || []).forEach(e => {
|
||
if (e.version || !e.aid) return;
|
||
const app = (apps || []).find(a => a.version && a.elfAid &&
|
||
String(a.elfAid).toUpperCase() === String(e.aid).toUpperCase());
|
||
if (app) {
|
||
e.version = app.version;
|
||
e.versionFrom = 'app';
|
||
}
|
||
});
|
||
}
|
||
|
||
// Render the full explorer result as structured HTML
|
||
function ramRenderExploreHtml(mem, isd, apps, elfs, fmt, expanded, elfForm) {
|
||
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" title="' + esc(t('Extended Card Resources (GP tag FF21, ETSI TS 102 226 8.2.1.7.2): installed applications, free non-volatile and free volatile memory')) + '">' + esc(t('Card resources (GET DATA FF21)')) + '</div>';
|
||
html += '<div>' + esc(t('Applications:')) + ' ' + (mem.appCount != null ? mem.appCount : '?') + '</div>';
|
||
html += '<div>' + esc(t('Free NV:')) + ' ' + (mem.freeNV != null ? mem.freeNV + ' B' : '?') + '</div>';
|
||
html += '<div>' + esc(t('Free Volatile:')) + ' ' + (mem.freeV != null ? mem.freeV + ' B' : '?') + '</div>';
|
||
if (fmt) {
|
||
html += '<div title="' + esc(t('compact = the TS 102 226 5.2.1 command string (one C-APDU per secured packet, e.g. GET STATUS + GET RESPONSE); expanded = the AA/22 Command Scripting template (5.2.1.0). Detected per operation with a read-only GET STATUS probe; a card that rejects the deprecated compact P2.b2=0 form answers the expanded one.')) + '">' +
|
||
esc(t('RAM command format:')) + ' ' + esc(fmt) + '</div>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
if (expanded) {
|
||
html += '<div class="mb-4 text-xs text-gray-600 dark:text-slate-400">' + esc(expanded) + '</div>';
|
||
}
|
||
if (isd && isd.length) {
|
||
html += '<div class="mb-4">';
|
||
html += '<div class="font-semibold text-sm mb-1">' + esc(t('ISD (Issuer Security Domain)')) + '</div>';
|
||
isd.forEach(o => {
|
||
html += '<div class="mb-1 pl-2 pr-2 py-1 rounded bg-gray-50 dark:bg-slate-800/60 hover:bg-gray-100 dark:hover:bg-slate-700/50 border-l-2 border-blue-400">';
|
||
html += '<div>' + esc(t('AID:')) + ' ' + (o.aid ? jcAidHtml(o.aid) : '?') + '</div>';
|
||
html += '<div>' + esc(t('Lifecycle:')) + ' ' + ramFmtLifecycle(o.lifecycle || '', 'isd') + '</div>';
|
||
if (o.version) html += '<div>' + esc(t('Version:')) + ' ' + esc(ramFmtVersion(o.version)) + '</div>';
|
||
if (o.privileges) html += '<div>' + esc(t('Privileges:')) + ' ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')</div>';
|
||
if (o.elfAid) html += '<div>' + esc(t('Load File AID / Package AID:')) + ' ' + jcAidHtml(o.elfAid) + '</div>';
|
||
if (o.sdAid) html += '<div>' + esc(t('SD AID:')) + ' ' + jcAidHtml(o.sdAid) + '</div>';
|
||
html += ramMenuHtml(o.menuEntries);
|
||
html += ramRawTlvsHtml(o.rawTlvs);
|
||
html += '</div>';
|
||
});
|
||
html += '</div>';
|
||
}
|
||
if (apps && apps.length) {
|
||
html += '<div class="mb-4">';
|
||
html += '<div class="font-semibold text-sm mb-1">' + esc(t('Applications / Applet Instances')) + ' (' + apps.length + ')</div>';
|
||
apps.forEach(o => {
|
||
html += '<div class="mb-1 pl-2 pr-2 py-1 rounded bg-gray-50 dark:bg-slate-800/60 hover:bg-gray-100 dark:hover:bg-slate-700/50 border-l-2 border-green-400 flex items-start gap-3">';
|
||
html += '<div class="flex-1 min-w-0">';
|
||
html += '<div>' + esc(t('Application / Instance AID:')) + ' ' + (o.aid ? jcAidHtml(o.aid) : '?') + '</div>';
|
||
html += '<div>' + esc(t('Lifecycle:')) + ' ' + ramFmtLifecycle(o.lifecycle || '', ramEntryKind(o)) + '</div>';
|
||
if (o.version) html += '<div>' + esc(t('Version:')) + ' ' + esc(ramFmtVersion(o.version)) + '</div>';
|
||
if (o.privileges) html += '<div>' + esc(t('Privileges:')) + ' ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')</div>';
|
||
if (o.implicitSel) html += '<div>' + esc(t('Implicit sel:')) + ' ' + o.implicitSel + '</div>';
|
||
if (o.elfAid) html += '<div>' + esc(t('Load File AID / Package AID:')) + ' ' + jcAidHtml(o.elfAid) + '</div>';
|
||
if (o.sdAid) html += '<div>' + esc(t('SD AID:')) + ' ' + jcAidHtml(o.sdAid) + '</div>';
|
||
html += ramMenuHtml(o.menuEntries);
|
||
html += ramRawTlvsHtml(o.rawTlvs);
|
||
html += '</div>';
|
||
html += '<div class="w-28 shrink-0 flex flex-col gap-1">';
|
||
if (o.aid) {
|
||
html += ramActionBtn(t('Delete'), "ramDeleteFromExplorer('" + o.aid + "', false)", 'danger');
|
||
if ((o.lifecycle || '').toUpperCase() === '03') {
|
||
html += ramActionBtn(t('Make selectable'), "ramMakeSelectableFromExplorer('" + o.aid + "')", 'ok');
|
||
}
|
||
}
|
||
html += '</div></div>';
|
||
});
|
||
html += '</div>';
|
||
}
|
||
if (elfs && elfs.length) {
|
||
html += '<div class="mb-4">';
|
||
html += '<div class="font-semibold text-sm mb-1">' + esc(t('Executable Load Files (ELFs) / Packages')) + ' (' + elfs.length + ')</div>';
|
||
if (elfs.some(o => !o.version)) {
|
||
html += '<div class="mb-1 text-xs text-gray-500 dark:text-slate-400">' + esc(ramElfVersionHint(elfForm)) + '</div>';
|
||
}
|
||
elfs.forEach(o => {
|
||
html += '<div class="mb-1 pl-2 pr-2 py-1 rounded bg-gray-50 dark:bg-slate-800/60 hover:bg-gray-100 dark:hover:bg-slate-700/50 border-l-2 border-purple-400 flex items-start gap-3">';
|
||
html += '<div class="flex-1 min-w-0">';
|
||
html += '<div>' + esc(t('Load File AID / Package AID:')) + ' ' + (o.aid ? jcAidHtml(o.aid) : '?') + '</div>';
|
||
html += '<div>' + esc(t('Lifecycle:')) + ' ' + ramFmtLifecycle(o.lifecycle || '', 'elf') + '</div>';
|
||
if (o.version) {
|
||
html += '<div>' + esc(t('Version:')) + ' ' + esc(ramFmtVersion(o.version)) +
|
||
(o.versionFrom === 'app' ? ' <span class="text-gray-500 dark:text-slate-400">(' + esc(t('from the app instance')) + ')</span>' : '') +
|
||
'</div>';
|
||
}
|
||
if (o.privileges && o.privileges !== '00') html += '<div>' + esc(t('Privileges:')) + ' ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')</div>';
|
||
if (o.moduleAids && o.moduleAids.length) {
|
||
html += '<div>' + esc(t('Executable Module AIDs / Applet Class AIDs:')) + '</div>';
|
||
}
|
||
if (o.sdAid) html += '<div>' + esc(t('SD AID:')) + ' ' + jcAidHtml(o.sdAid) + '</div>';
|
||
html += ramRawTlvsHtml(o.rawTlvs);
|
||
html += '</div>';
|
||
html += '<div class="w-28 shrink-0 flex flex-col gap-1">';
|
||
if (o.aid) {
|
||
html += ramActionBtn(t('Delete'), "ramDeleteFromExplorer('" + o.aid + "', false)", 'danger');
|
||
html += ramActionBtn(t('Delete All'), "ramDeleteFromExplorer('" + o.aid + "', true)", 'danger');
|
||
}
|
||
html += '</div></div>';
|
||
// Module rows: emitted as sibling rows so their Install buttons share
|
||
// the same action column as the entry buttons.
|
||
(o.moduleAids || []).forEach(m => {
|
||
html += '<div class="mb-1 pl-6 pr-2 py-1 rounded bg-gray-100/60 dark:bg-slate-800/40 hover:bg-gray-200/60 dark:hover:bg-slate-700/40 flex items-start gap-3">';
|
||
html += '<div class="flex-1 min-w-0">- ' + jcAidHtml(m) + '</div>';
|
||
html += '<div class="w-28 shrink-0 flex flex-col gap-1">';
|
||
// A loaded applet class with no instance yet: one click to the
|
||
// single-APDU INSTALL [for install].
|
||
if (!ramHasInstance(apps, m)) {
|
||
html += ramActionBtn(t('Install'), "ramInstallFromExplorer('" + o.aid + "','" + m + "')", 'ok');
|
||
}
|
||
html += '</div></div>';
|
||
});
|
||
});
|
||
html += '</div>';
|
||
}
|
||
return html;
|
||
}
|
||
|
||
let _ramExplorerData = null;
|
||
|
||
function ramRenderExplorer() {
|
||
const explorerEl = document.getElementById('ram-explorer');
|
||
if (!explorerEl) return;
|
||
if (!_ramExplorerData) {
|
||
explorerEl.classList.add('hidden');
|
||
explorerEl.innerHTML = '';
|
||
return;
|
||
}
|
||
const html = ramRenderExploreHtml(_ramExplorerData.mem, _ramExplorerData.isd, _ramExplorerData.apps, _ramExplorerData.elfs, _ramExplorerData.fmt, _ramExplorerData.expanded, _ramExplorerData.elfForm);
|
||
explorerEl.innerHTML = html || esc(t('(no data)'));
|
||
explorerEl.classList.remove('hidden');
|
||
pysimApplyAvailability();
|
||
}
|
||
|
||
// ===== Operation handlers =====
|
||
async function ramExplore(sp) {
|
||
const resultEl = document.getElementById('ram-result');
|
||
const stepsEl = document.getElementById('ram-steps');
|
||
let cntr = sp.cntr;
|
||
const errors = [];
|
||
const mem = { appCount: null, freeNV: null, freeV: null };
|
||
const isd = [], apps = [], elfs = [];
|
||
// Expanded-registry state for this run: the query form the card accepted
|
||
// (remembered and tried first for the later listings), the command format
|
||
// the registry queries needed (null = the run's format; the live ISD
|
||
// answers the compact P2=02 query with 6700 and serves it wrapped) and the
|
||
// per-listing outcome report shown in the explorer.
|
||
let expandedForm = null;
|
||
let expandedFmt = null;
|
||
const expandedReport = [];
|
||
// Low counter: the card rejected a packet whose counter is not above its
|
||
// own - a retry cannot help, so the run stops immediately and the user is
|
||
// told to raise the preset counter (v3.6.58). Every send of the run goes
|
||
// through sendOta() so the verdict is caught wherever it appears.
|
||
let cntrLow = false;
|
||
async function sendOta(apdu, params) {
|
||
const res = await ramSendOta(apdu, params);
|
||
if (!cntrLow && spCntrLow(res)) cntrLow = true;
|
||
return res;
|
||
}
|
||
|
||
// One expanded query with the form ladder (see RAM_EXPANDED_FORMS): try
|
||
// the remembered form first, then the others. The ISD gets a single
|
||
// attempt - cards commonly answer 6A88/6A86 there ("no data to report",
|
||
// GPPro), which must not poison the detection for the apps/ELF queries.
|
||
// Each form is tried in the run's command format first, then in the two
|
||
// expanded codings and compact as the last resort: the live ISD refuses
|
||
// the compact P2=02 query with 6700 and serves it wrapped, while other
|
||
// cards do the opposite - the format that works is remembered for the
|
||
// remaining listings. A '61xx' answer means the card holds the response
|
||
// data - only the compact command string may chain a GET RESPONSE, the
|
||
// expanded format must not (TS 102 226 5.2.1.1); a '6310' means more
|
||
// occurrences (re-issued as a next-occurrence page, P2.b1=1). Returns
|
||
// {form, parsed, sw, entries, fmt}; form is null when no form produced
|
||
// data.
|
||
async function expandedPass(p1, label, parser, spi2) {
|
||
let forms = expandedForm
|
||
? [expandedForm].concat(RAM_EXPANDED_FORMS.filter(f => f !== expandedForm))
|
||
: RAM_EXPANDED_FORMS.slice();
|
||
if (p1 === '80') forms = [expandedForm || 'tags'];
|
||
const runFmt = sp.ram_format || 'auto';
|
||
let lastSw = '';
|
||
let lastTransport = false;
|
||
let stopPass = false;
|
||
for (const form of forms) {
|
||
if (cntrLow || stopPass) break;
|
||
const fmts = ramFmtLadder(runFmt, expandedFmt);
|
||
for (const fmt of fmts) {
|
||
if (cntrLow) break;
|
||
let data = '';
|
||
let sw = '';
|
||
let accepted = true;
|
||
let transportError = false;
|
||
let usedFmt = fmt || runFmt;
|
||
const sendOne = async (apdu, what) => {
|
||
ramShowProgress(label + ' P1=' + p1 + ' ' + what + '...');
|
||
const params = Object.assign({}, sp, { cntr, spi2 });
|
||
if (fmt) params.ram_format = fmt;
|
||
const res = await ramSendOta(apdu, params);
|
||
usedFmt = res.ram_format || fmt || sp.ram_format || usedFmt;
|
||
// an explicit fallback format is not adopted as the run's
|
||
// format - the selected/pinned format keeps applying to the
|
||
// other passes
|
||
if (!fmt && res.ram_format) sp.ram_format = res.ram_format;
|
||
if (res.final_cntr) cntr = res.final_cntr;
|
||
else if (res.success && spPorAccepted(res.por)) cntr = ramIncrementCntr(cntr);
|
||
if (!res.success || !res.por || !spPorAccepted(res.por)) {
|
||
accepted = false;
|
||
// a request/transport failure is not a remote status word
|
||
transportError = !res.por;
|
||
sw = res.por ? res.por.response_status : (res.error || t('no data'));
|
||
return '';
|
||
}
|
||
sw = ((res.por.decoded && res.por.decoded.last_status_word) || '').toUpperCase();
|
||
return res.por.decoded ? (res.por.decoded.last_response_data || '') : '';
|
||
};
|
||
let page = 0;
|
||
let promised = false;
|
||
while (accepted && page++ < 8) {
|
||
const apdu = ramExpandedQueryApdu(p1, form, page > 1);
|
||
const what = (page > 1 ? 'P2=03 (' : 'P2=02 (') + form +
|
||
(fmt ? ', ' + fmt : '') + ')';
|
||
data += await sendOne(apdu, what);
|
||
if (cntrLow || !accepted) break;
|
||
// '61xx': the card holds the response data - the GET RESPONSE
|
||
// must ride in the same compact command string (a standalone
|
||
// one answers 6700 on the live card); the expanded format
|
||
// has no command string, so there is no retry there.
|
||
const pending = /^61[0-9A-F]{2}$/.test(sw);
|
||
if (pending && !ramFormatIsExpanded(usedFmt)) {
|
||
data += await sendOne(ramChainGetResponse(apdu), what + ' + GET RESPONSE');
|
||
if (cntrLow || !accepted) break;
|
||
}
|
||
// the ISD is a single occurrence (GP 11.4.2.2); '6310' means
|
||
// more occurrences, and a '61xx' page may mask that signal
|
||
// (the GET RESPONSE's own SW replaces it) - try one more page
|
||
if (p1 === '80') break;
|
||
// continue only when the answer really says 'more occurrences'
|
||
// or the chained retry was accepted (a '61xx' page masks the
|
||
// 6310 signal with the GET RESPONSE's own SW); a refused retry
|
||
// must not cost another packet (v3.7.0)
|
||
if (sw !== '6310' && !(pending && ramRemoteSwOk(sw))) break;
|
||
promised = true;
|
||
}
|
||
// An unfulfilled 6310 only: a follow-up page that was accepted and
|
||
// ended the listing fulfils the promise.
|
||
const more = ramExpandedMore(promised, sw, page);
|
||
const parsed = data ? parser(data) : [];
|
||
if (parsed.length) {
|
||
expandedForm = form;
|
||
if (fmt) expandedFmt = fmt;
|
||
return { form: form, parsed: parsed, sw: sw, entries: parsed.length,
|
||
transport: transportError, more: more, fmt: usedFmt };
|
||
}
|
||
lastSw = sw;
|
||
lastTransport = transportError;
|
||
if (!accepted) {
|
||
stopPass = true;
|
||
break;
|
||
}
|
||
if (ramRemoteSwOk(sw)) {
|
||
// accepted but empty: nothing to gain from another form
|
||
return { form: form, parsed: [], sw: sw, entries: 0,
|
||
transport: transportError, more: more, fmt: usedFmt };
|
||
}
|
||
// refused: the same form in the next command format
|
||
}
|
||
}
|
||
return { form: null, parsed: [], sw: lastSw, entries: 0,
|
||
transport: lastTransport, more: false, fmt: null };
|
||
}
|
||
|
||
async function paginate(p1, collector, parser, label, quiet) {
|
||
if (cntrLow) return; // the run was stopped by a low counter
|
||
// Compact listing format (GP 11.4.2.2: P2.b2=0) with the chained GET
|
||
// RESPONSE - see ramGetStatusApdu(). The card's SCP80 layer runs both
|
||
// commands, so the PoR carries the listing; ramListingSpi2() picks the
|
||
// PoR transport per query.
|
||
const spi2 = ramListingSpi2(p1);
|
||
const before = collector.length;
|
||
let compactError = null;
|
||
let p2 = '00';
|
||
let guard = 0;
|
||
// The P2=00 listing follows the run's command format: compact with the
|
||
// chained GET RESPONSE (the live card) or wrapped unchained (the TCA
|
||
// loader's form). The format is pinned per send, so a chain can never
|
||
// ride inside an expanded packet.
|
||
const listingFmt = ramFormatIsExpanded(sp.ram_format) ? sp.ram_format : 'compact';
|
||
while (guard++ < 32) {
|
||
const apdu = ramGetStatusApdu(p1, p2, listingFmt);
|
||
ramShowProgress(label + ' P1=' + p1 + ' P2=' + p2 + '...');
|
||
const res = await sendOta(apdu, Object.assign({}, sp, { cntr, spi2, ram_format: listingFmt }));
|
||
if (cntrLow) break;
|
||
// Reuse the format detected for the first packet of this run
|
||
if (res.ram_format) sp.ram_format = res.ram_format;
|
||
// A packet the card accepted advances the counter even when the
|
||
// page itself is incomplete: the card consumed it, and a retry with
|
||
// the same counter is rejected (cntr_low). The server reports the
|
||
// counter to use next (the format-check packets are included).
|
||
if (res.final_cntr) cntr = res.final_cntr;
|
||
else if (res.success && spPorAccepted(res.por)) cntr = ramIncrementCntr(cntr);
|
||
if (!res.success || !res.por || !spPorAccepted(res.por)) {
|
||
compactError = label + ': ' + (res.por ? res.por.response_status
|
||
: (res.error || t('no data')));
|
||
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 (!data && res.por.response_status === 'actual_response_sms_submit') {
|
||
// Accepted, but the actual-response SMS was not captured: there
|
||
// is nothing to parse for this page (not a card error).
|
||
compactError = label + ': ' + t('response via SMS not captured');
|
||
break;
|
||
}
|
||
// Defensive: a remote 61XX means the data is still pending (the
|
||
// chained GET RESPONSE normally prevents this; the expanded format
|
||
// has no chain, so such a page yields nothing and the expanded TLV
|
||
// pass below provides the listing).
|
||
if (sw && sw.startsWith('61')) {
|
||
if (data) {
|
||
const parsed61 = parser(data);
|
||
if (parsed61.length) collector.push(...parsed61);
|
||
}
|
||
if (p1 === '80') break; // the ISD is a single occurrence (11.4.2.2)
|
||
p2 = '01';
|
||
continue;
|
||
}
|
||
if (sw === '6F00') break;
|
||
if (!data) {
|
||
if (sw !== '9000') compactError = label + ': ' + t('(no data)') + ' — SW ' + sw;
|
||
break;
|
||
}
|
||
const parsed = parser(data);
|
||
if (!parsed.length) break;
|
||
collector.push(...parsed);
|
||
if (sw === '9000') break;
|
||
// next occurrence (P2.b1=1) - never for the ISD-only query: the
|
||
// card shall reject it (GP 11.4.2.2)
|
||
if (p1 === '80') break;
|
||
p2 = '01';
|
||
}
|
||
// Expanded pass: the fallback when the compact listing produced
|
||
// nothing (a card may reject P2.b2=0 - it is deprecated) and the
|
||
// details source (package versions, SD associations, menu entries)
|
||
// behind the "Fetch expanded registry details" checkbox (on by
|
||
// default). The form ladder and the per-listing report live in
|
||
// expandedPass()/expandedReport.
|
||
const wantDetails = !!(document.getElementById('ram-expanded-details') || {}).checked;
|
||
if (collector.length === before || wantDetails) {
|
||
const out = await expandedPass(p1, label, parser, spi2);
|
||
expandedReport.push({ p1: p1, label: label, compact: collector.length > before,
|
||
form: out.form, sw: out.sw, entries: out.entries,
|
||
transport: out.transport, more: out.more, fmt: out.fmt });
|
||
if (collector.length === before) {
|
||
// Nothing from the compact pass: the expanded result is the listing
|
||
collector.push(...out.parsed);
|
||
} else if (out.parsed.length) {
|
||
// Keep the compact entries, add the expanded-only fields
|
||
ramMergeExpanded(collector, out.parsed);
|
||
}
|
||
}
|
||
// The compact error only surfaces when the expanded pass did not rescue it
|
||
// (`quiet` lets a fallback query decide whether to report it).
|
||
if (collector.length === before && compactError && !quiet) errors.push(compactError);
|
||
}
|
||
|
||
ramShowProgress(t('Card resources (GET DATA FF21)') + '...');
|
||
try {
|
||
if (cntrLow) throw new Error(t('Low counter'));
|
||
const memRes = await sendOta('80CAFF2100', Object.assign({}, sp, { spi2: '01' }));
|
||
if (memRes.ram_format) sp.ram_format = memRes.ram_format;
|
||
if (memRes.success && memRes.por && memRes.por.response_status === 'por_ok') {
|
||
cntr = memRes.final_cntr || ramIncrementCntr(cntr);
|
||
const data = memRes.por.decoded ? memRes.por.decoded.last_response_data : '';
|
||
if (!data) {
|
||
errors.push(t('Memory') + ': ' + t('(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(t('Memory') + ': ' + errorMsg);
|
||
}
|
||
} catch (e) {
|
||
errors.push(t('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, t('ISD'));
|
||
await paginate('40', apps, ramParseAppStatus, t('Apps')); // GP 11.4.2.1: '10' returns the ELFs *with* their modules - the superset of
|
||
// '20' (ELFs only). All four P1 values shall be supported, but cards
|
||
// deviate: the live card answered 6A86 (incorrect P1-P2) to '20' while
|
||
// '10' works (2026-09-28). Query '10' first; fall back to '20' (no
|
||
// module lists) only when it yields nothing, and report the primary
|
||
// failure only when both fail.
|
||
const elfErrorsBefore = errors.length;
|
||
await paginate('10', elfs, (hex) => ramParseElfStatus(hex, true), t('ELFs'));
|
||
if (!elfs.length) {
|
||
const elfFailure = errors.splice(elfErrorsBefore);
|
||
await paginate('20', elfs, ramParseElfStatus, t('ELFs'), true);
|
||
if (!elfs.length) errors.push(...elfFailure);
|
||
}
|
||
// The ELF tag-list forms may be refused for P1=10 while P1=20 accepts
|
||
// them (a card's P1=10 implementation can be stricter): when the ELF rows
|
||
// have no version and the details pass never got a tag-list form, walk the
|
||
// ladder once more with P1=20 and merge by AID (v3.7.0).
|
||
async function elfTagListRetry() {
|
||
if (cntrLow || !elfs.length || elfs.some(o => o.version)) return;
|
||
const g = ramExpandedGroups(expandedReport).find(x => x.label === t('ELFs'));
|
||
if (g && ['tags', 'noea', 'lean'].indexOf(g.form) >= 0) return;
|
||
const saved = expandedForm;
|
||
expandedForm = null; // start the ladder at the richest form
|
||
try {
|
||
const out = await expandedPass('20', t('ELFs'), ramParseElfStatus, ramListingSpi2('20'));
|
||
expandedReport.push({ p1: '20', label: t('ELFs'), compact: true,
|
||
form: out.form, sw: out.sw, entries: out.entries,
|
||
transport: out.transport, more: out.more });
|
||
if (out.parsed.length) ramMergeExpanded(elfs, out.parsed);
|
||
} finally {
|
||
if (expandedForm === null) expandedForm = saved;
|
||
}
|
||
}
|
||
await elfTagListRetry();
|
||
|
||
// A package whose own listing carries no version may still be known from
|
||
// its applet instance (the app entry carries the load file version and the
|
||
// package AID); the rows are annotated when this inference is used.
|
||
ramDeriveElfVersions(elfs, apps);
|
||
|
||
// Still missing versions: query the apps listing once per applet AID (the
|
||
// AID search qualifier selects one entity). The apps tag list works even
|
||
// where the ELF one is refused, and an applet's CE is its package's
|
||
// version - the applet instance AIDs are the package's module AIDs
|
||
// (ramElfAppletCandidate). The single-AID form answers 61xx (the card
|
||
// holds the data), so it is retried with the GET RESPONSE chained into the
|
||
// compact command string, like the ladder (v3.7.1).
|
||
async function elfPerAidVersions() {
|
||
const missing = elfs.filter(e => !e.version && e.aid);
|
||
for (const e of missing.slice(0, 6)) {
|
||
if (cntrLow) break;
|
||
const app = ramElfAppletCandidate(e, apps);
|
||
if (!app) continue;
|
||
ramShowProgress(t('Version') + ' ' + e.aid + '...');
|
||
const apdu = ramExpandedQueryApdu('40', 'tags', false, app.aid);
|
||
const send = async a => {
|
||
const res = await sendOta(a, Object.assign({}, sp, { cntr, spi2: ramListingSpi2('40') }));
|
||
if (res.ram_format) sp.ram_format = res.ram_format;
|
||
if (res.final_cntr) cntr = res.final_cntr;
|
||
else if (res.success && spPorAccepted(res.por)) cntr = ramIncrementCntr(cntr);
|
||
return res;
|
||
};
|
||
let res = await send(apdu);
|
||
if (!res.success || !res.por || !spPorAccepted(res.por)) continue;
|
||
let sw = ((res.por.decoded && res.por.decoded.last_status_word) || '').toUpperCase();
|
||
if (/^61[0-9A-F]{2}$/.test(sw) && sp.ram_format !== 'expanded') {
|
||
// the card holds the response data: the GET RESPONSE must ride
|
||
// in the same compact command string (a standalone one answers
|
||
// 6700 on the live card)
|
||
res = await send(ramChainGetResponse(apdu));
|
||
if (!res.success || !res.por || !spPorAccepted(res.por)) continue;
|
||
}
|
||
const data = res.por.decoded ? (res.por.decoded.last_response_data || '') : '';
|
||
const parsed = data ? ramParseAppStatus(data) : [];
|
||
const hit = parsed.find(x => x.version);
|
||
if (hit) {
|
||
e.version = hit.version;
|
||
e.versionFrom = 'app';
|
||
}
|
||
}
|
||
}
|
||
await elfPerAidVersions();
|
||
// Only write the counter when a packet was actually accepted: a run whose
|
||
// start value was stale must never lower the preset counter.
|
||
if (cntr !== sp.cntr) ramSaveCntr(cntr);
|
||
ramHideProgress();
|
||
|
||
if (cntrLow) {
|
||
// the run was stopped: state the verdict, do not retry
|
||
resultEl.textContent = t('Low counter') + ' — ' + t('the operation was stopped');
|
||
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
|
||
ramShowCntrLow();
|
||
} else if (errors.length) {
|
||
resultEl.textContent = t('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 = t('OK');
|
||
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
|
||
}
|
||
|
||
_ramExplorerData = { mem: mem, isd: isd, apps: apps, elfs: elfs, fmt: sp.ram_format || '',
|
||
expanded: ramExpandedReport(expandedReport),
|
||
elfForm: ramExpandedElfForm(expandedReport) };
|
||
ramRenderExplorer();
|
||
}
|
||
|
||
// GP DELETE (Card Spec v2.3.1 11.2, Tables 11-20/11-23): P1=00 (last/only
|
||
// command), P2.b8 selects "object" ('00') or "object and related objects"
|
||
// ('80'), the data field carries the mandatory '4F' AID TLV. The APDU is
|
||
// case 3 (no Le): a trailing Le byte makes the card's SCP80 layer count a
|
||
// phantom second command whose SW 6700 masks the real result (live
|
||
// 2026-09-28) - same as INSTALL/LOAD since v3.6.8. The old inline builder
|
||
// called an undefined length helper (`_ber_len`): a ReferenceError after the
|
||
// confirm - the Delete buttons never sent anything.
|
||
function ramDeleteApdu(aid, withCascade) {
|
||
const a = (aid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (!a) return '';
|
||
const data = '4F' + berLenStr(a.length / 2) + a;
|
||
return '80E400' + (withCascade ? '80' : '00') + berLenStr(data.length / 2) + data;
|
||
}
|
||
|
||
// A loaded applet class with no instance yet (or an INSTALLED, non-selectable
|
||
// instance): jump to the matching single-APDU operation with the AIDs filled.
|
||
function ramHasInstance(apps, aid) {
|
||
const a = (aid || '').toUpperCase();
|
||
return (apps || []).some(x => (x.aid || '').toUpperCase().startsWith(a));
|
||
}
|
||
|
||
function ramInstallFromExplorer(loadfileAid, moduleAid) {
|
||
document.getElementById('ram-op').value = 'install-app';
|
||
ramOpChanged();
|
||
document.getElementById('ram-app-loadfile').value = loadfileAid || '';
|
||
document.getElementById('ram-app-module').value = moduleAid || '';
|
||
document.getElementById('ram-app-instance').value = '';
|
||
const el = document.getElementById('ram-op');
|
||
if (el.scrollIntoView) el.scrollIntoView({block: 'center'});
|
||
}
|
||
|
||
function ramMakeSelectableFromExplorer(aid) {
|
||
document.getElementById('ram-op').value = 'make-selectable';
|
||
ramOpChanged();
|
||
document.getElementById('ram-app-instance').value = aid || '';
|
||
const el = document.getElementById('ram-op');
|
||
if (el.scrollIntoView) el.scrollIntoView({block: 'center'});
|
||
}
|
||
|
||
// A successful delete drops the object from the Explore result locally (the
|
||
// re-explore is not re-run). The registry has no package-to-applet link, so
|
||
// a cascade package delete also drops the applets whose AID starts with the
|
||
// package AID.
|
||
function ramRemoveFromExplorer(aid, cascade) {
|
||
const d = _ramExplorerData;
|
||
if (!d) return false;
|
||
const a = (aid || '').toUpperCase();
|
||
const elfs = d.elfs || [];
|
||
const apps = d.apps || [];
|
||
const isElf = elfs.some(e => (e.aid || '').toUpperCase() === a);
|
||
const isApp = apps.some(x => (x.aid || '').toUpperCase() === a);
|
||
if (!isElf && !isApp) return false;
|
||
if (isElf) {
|
||
d.elfs = elfs.filter(e => (e.aid || '').toUpperCase() !== a);
|
||
if (cascade) {
|
||
d.apps = apps.filter(x => {
|
||
const xa = (x.aid || '').toUpperCase();
|
||
return xa !== a && !xa.startsWith(a);
|
||
});
|
||
}
|
||
} else {
|
||
d.apps = apps.filter(x => (x.aid || '').toUpperCase() !== a);
|
||
}
|
||
ramRenderExplorer();
|
||
return true;
|
||
}
|
||
|
||
async function ramDeleteFromExplorer(aid, withCascade) {
|
||
// The preset is the source of truth (see ramExecute): re-read it before
|
||
// the operation so a stale form copy cannot send an already-consumed
|
||
// counter.
|
||
spRefreshFromPreset('ram-card-sel');
|
||
const ksErr = ramKeysetGuard();
|
||
if (ksErr) { alert(ksErr); return; }
|
||
const sp = getRamSpParams();
|
||
if (!sp.kicKey || !sp.kidKey) {
|
||
alert(t('Select a card preset with keys first (RAM subtab → Card preset)'));
|
||
return;
|
||
}
|
||
const label = withCascade ? t('Delete') + ' (' + t('cascade') + ')' : t('Delete');
|
||
if (!confirm(label + ' — ' + t('AID:') + ' ' + aid + '?')) return;
|
||
const apdu = ramDeleteApdu(aid, withCascade);
|
||
if (!apdu) return;
|
||
ramShowProgress(label + ' ' + aid + '...');
|
||
const res = await ramSendOta(apdu, sp);
|
||
ramHideProgress();
|
||
// low counter: stop - retrying cannot help (v3.6.58)
|
||
if (spCntrLow(res)) {
|
||
const el = document.getElementById('ram-result');
|
||
el.textContent = t('Low counter') + ' — ' + t('the operation was stopped');
|
||
el.classList.remove('hidden', 'text-green-600'); el.classList.add('text-red-600');
|
||
ramShowCntrLow();
|
||
return;
|
||
}
|
||
// The card consumes the counter as soon as it accepts the packet, even
|
||
// when the remote command fails; a rejected packet (cntr_low, PoR error)
|
||
// leaves it untouched.
|
||
if (res.success && spPorAccepted(res.por)) {
|
||
sp.cntr = res.final_cntr || ramIncrementCntr(sp.cntr);
|
||
ramSaveCntr(sp.cntr);
|
||
}
|
||
const resultEl = document.getElementById('ram-result');
|
||
const stepsEl = document.getElementById('ram-steps');
|
||
const sw = res.por && res.por.decoded ? res.por.decoded.last_status_word : '';
|
||
if (!res.success || !spPorAccepted(res.por) ||
|
||
(sw && !ramRemoteSwOk(sw))) {
|
||
resultEl.textContent = t('Failed') + ': ' +
|
||
(res.por ? res.por.response_status : (res.error || res.sw)) +
|
||
(sw ? ' · ' + t('remote SW') + ' ' + sw : '');
|
||
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
|
||
return;
|
||
}
|
||
const removed = ramRemoveFromExplorer(aid, withCascade);
|
||
stepsEl.classList.remove('hidden');
|
||
stepsEl.textContent = label + ' ' + aid + ' -> ' + (sw || t('no PoR'));
|
||
resultEl.textContent = t('OK') + (removed ? '' : ' (' + t('not in the list') + ')')
|
||
+ (res.ram_format ? ' — ' + t('RAM command format:') + ' ' + res.ram_format : '');
|
||
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
|
||
// The record is dropped locally (no re-explore); the counter the card
|
||
// consumed was saved above.
|
||
}
|
||
|
||
// Which CAP components a LOAD step's payload covers (pure, diagnostic). The
|
||
// server sends the component list in load-file order with sizes that include
|
||
// each component's 3-byte header (capmem), so the ranges accumulate directly.
|
||
// `completed` lists the components whose last byte falls inside the block -
|
||
// the card has just seen them whole, which is where a verifier rejection
|
||
// lands (a CAP whose Import list the card cannot satisfy is refused exactly
|
||
// in the block completing Import).
|
||
function ramStepComponents(name, components, blockSize) {
|
||
const m = /^LOAD \((\d+)\/(\d+)\)$/.exec(String(name || '').trim());
|
||
const comps = components || [];
|
||
const size = Number(blockSize) || 0;
|
||
if (!m || !comps.length || size <= 0) return null;
|
||
const start = (Number(m[1]) - 1) * size;
|
||
const end = Number(m[1]) * size;
|
||
let off = 0;
|
||
const names = [];
|
||
const completed = [];
|
||
for (const c of comps) {
|
||
const cstart = off, cend = off + (Number(c.size) || 0);
|
||
off = cend;
|
||
if (cend <= start) continue; // entirely before this block
|
||
if (cstart >= end) break; // entirely after it
|
||
names.push(c.name);
|
||
if (cend > start && cend <= end) completed.push(c.name);
|
||
}
|
||
if (!names.length) return null;
|
||
return { names: names, completed: completed };
|
||
}
|
||
|
||
// One RAM install step line: the PoR verdict plus the remote command's
|
||
// status word (9000/61xx/62xx/63xx/CAFE are success; anything else is the
|
||
// card's error) - the details that used to be lost behind a bare
|
||
// "por_error_" label. The envelope/GET RESPONSE SW is only shown when it is
|
||
// not a plain 9000. With the CAP analysis at hand the line also names the
|
||
// component the block completed (see ramStepComponents).
|
||
function ramStepLine(s, idx, comps, blockSize) {
|
||
const ok = !s.por_error && (s.por_status === 'por_ok' || s.por_status === 'no_por');
|
||
let line = (ok ? '\u2705' : '\u274c') + ' ' + t('Step') + ' ' + (idx + 1) + ': ' +
|
||
s.name + ' \u2014 ';
|
||
if (ok) {
|
||
// successful step: the verdict symbol carries the meaning - keep the
|
||
// remote status word (without its verbose name), the size and the SMS
|
||
// count only when the packet was concatenated
|
||
if (s.por_sw) line += s.por_sw;
|
||
else if (s.por_status === 'no_por') line += t('no PoR');
|
||
else line += t('OK');
|
||
if (s.bytes) line += ' \u00b7 ' + s.bytes + ' B';
|
||
if (s.segments > 1) line += ' / ' + s.segments + ' SMS';
|
||
} else {
|
||
if (s.por_status === 'por_ok') line += t('PoR ok');
|
||
else if (s.por_status === 'no_por') line += t('no PoR');
|
||
else if (s.por_status && s.por_status.indexOf('por_error') === 0) {
|
||
const legacy = s.por_status.replace(/^por_error_?/, '');
|
||
line += t('PoR error') + (legacy ? ' ' + legacy : '');
|
||
} else if (s.por_status) line += t('PoR error') + ' ' + s.por_status;
|
||
else line += t('PoR unknown');
|
||
if (s.por_sw) {
|
||
const name = lookupSw(s.por_sw.substr(0, 2), s.por_sw.substr(2, 2), 'gp');
|
||
line += ' \u00b7 ' + t('remote SW') + ' ' + s.por_sw + (name ? ' (' + name + ')' : '');
|
||
}
|
||
if (s.sw && s.sw !== '9000') line += ' \u00b7 ' + t('transport SW') + ' ' + s.sw;
|
||
if (s.bytes && s.segments) {
|
||
line += ' \u00b7 ' + s.bytes + ' ' + t('bytes') + ' / ' + s.segments + ' SMS';
|
||
}
|
||
}
|
||
const sc = ramStepComponents(s.name, comps, blockSize);
|
||
if (sc) {
|
||
if (sc.completed.length) line += ' \u00b7 ' + t('completes') + ' ' + sc.completed[sc.completed.length - 1];
|
||
else if (sc.names.length === 1) line += ' \u00b7 ' + t('inside') + ' ' + sc.names[0];
|
||
else line += ' \u00b7 ' + sc.names[0] + '\u2026' + sc.names[sc.names.length - 1];
|
||
}
|
||
return line;
|
||
}
|
||
|
||
// Hint after a failed RAM install (pure). A rejected LOAD / INSTALL [for
|
||
// load] step has two known causes: the card's platform is older than the
|
||
// CAP requires (the JCRE checks every import while it verifies the load file,
|
||
// JC VM spec 4.5.2, and refuses the step as soon as the Import component is
|
||
// complete - observed remote SW 6438/6985 exactly at that block boundary),
|
||
// or the LOAD block exceeds the card's buffer. `mem` is the CAP analysis
|
||
// (capmem memory_json) when the install came from a .cap.
|
||
function ramInstallFailHint(data, mem) {
|
||
if (!data || data.success) return '';
|
||
const steps = data.steps || [];
|
||
const step = steps[(Number(data.failed_step) || 1) - 1] || null;
|
||
const name = step ? String(step.name || '') : '';
|
||
if (!/^(LOAD |INSTALL \[for load\])/.test(name)) return '';
|
||
let hint = '';
|
||
const sc = ramStepComponents(name, mem && mem.components, data.load_block_size);
|
||
if (sc && sc.completed.length) hint += t('the failed step completes') + ' ' + sc.completed[sc.completed.length - 1] + '. ';
|
||
hint += t('a LOAD rejected here means the card refused the CAP: every import version under Requires must be present on the card');
|
||
if (mem && mem.requires_java_card) hint += ' (Java Card \u2265 ' + mem.requires_java_card + ')';
|
||
hint += t('. If the CAP needs only versions this card has, retry with a smaller LOAD block (e.g. 64 B)');
|
||
return hint;
|
||
}
|
||
|
||
// ===== Import probe (diagnostic, v3.6.42) =====
|
||
// The card checks the CAP's import list while it verifies the load file, so
|
||
// rewriting the requested versions shows whether that list is what it refuses
|
||
// - and, by lowering one entry at a time, which package and version it lacks.
|
||
|
||
// 'AID=version' per line ('all=x.y' allowed) rewrites an import; a leading
|
||
// '+' appends a synthetic import (a card-capability query); blank/comment
|
||
// lines are ignored. Pure: returns {map, additions} or {error}.
|
||
function ramProbeParse(text) {
|
||
const map = {};
|
||
const additions = [];
|
||
for (const rawLine of String(text || '').split('\n')) {
|
||
const line = rawLine.split('#')[0].trim();
|
||
if (!line) continue;
|
||
const m = /^(\+?)([0-9a-fA-F]{2,32}|all)\s*=\s*(\d{1,3})\.(\d{1,3})$/.exec(line);
|
||
if (!m) return { error: t('Invalid line:') + ' ' + line };
|
||
const ver = m[3] + '.' + m[4];
|
||
if (m[1]) {
|
||
if (m[2].toLowerCase() === 'all') return { error: t('Invalid line:') + ' ' + line };
|
||
additions.push({ aid: m[2].toUpperCase(), version: ver });
|
||
} else {
|
||
map[m[2].toLowerCase() === 'all' ? 'all' : m[2].toUpperCase()] = ver;
|
||
}
|
||
}
|
||
// an empty list is valid: the compatibility test then runs with the CAP's
|
||
// own versions (the basic "is this CAP compatible with this card?" question)
|
||
return { map: map, additions: additions };
|
||
}
|
||
|
||
// Prefill from the analysed CAP's import list unless the user edited it.
|
||
function ramProbeFill(mem) {
|
||
const el = document.getElementById('ram-probe-versions');
|
||
if (!el || (el.dataset && el.dataset.manual)) return;
|
||
const imports = (mem && mem.imports) || [];
|
||
el.value = imports.map(im => im.aid + '=' + (Number(im.major) || 0) + '.' + (Number(im.minor) || 0)).join('\n');
|
||
}
|
||
|
||
function ramProbeAllZero() {
|
||
const el = document.getElementById('ram-probe-versions');
|
||
if (!el) return;
|
||
el.value = String(el.value || '').split('\n').map(line => {
|
||
const m = /^([0-9a-fA-F]{2,32}|all)\s*=/.exec(line.trim());
|
||
return m ? m[1] + '=0.0' : line; // '+AID=' lines keep their version
|
||
}).join('\n');
|
||
el.dataset.manual = '1';
|
||
}
|
||
|
||
// Verdict line for the CAP compatibility test (pure). The test covers the
|
||
// LOAD/import gate only - the card checks the import list while it verifies
|
||
// the load file; the link gate is what a real install checks later. The
|
||
// headline distinguishes where the run actually failed: a request error, the
|
||
// format probe (the card not answering), the INSTALL [for load] request, or
|
||
// the import list itself - and names the remote status word.
|
||
function ramCompatVerdict(data) {
|
||
if (!data) return '';
|
||
const stepOk = s => !s.por_error && (s.por_status === 'por_ok' || s.por_status === 'no_por');
|
||
if (!data.steps || !data.steps.length) {
|
||
return t('Error') + ': ' + (data.error || t('no data'));
|
||
}
|
||
const formats = data.steps.filter(s => /^FORMAT CHECK/.test(String(s.name || '')));
|
||
if (formats.length && formats.every(s => !stepOk(s))) {
|
||
return t('The card did not answer the format probe') +
|
||
(data.error ? ' \u00b7 ' + data.error : '');
|
||
}
|
||
const scope = t('tested through LOAD') + ' ' + (data.boundary_block || '?') +
|
||
'/' + (data.total_blocks || '?');
|
||
const failed = data.steps.find(s => !stepOk(s)) || {};
|
||
const sw = String(failed.por_sw || '');
|
||
const swName = sw ? lookupSw(sw.substr(0, 2), sw.substr(2, 2), 'gp') : '';
|
||
const swText = sw ? t('remote SW') + ' ' + sw + (swName ? ' (' + swName + ')' : '')
|
||
: (data.error || t('no data'));
|
||
if (data.imports_ok) return t('Import list accepted') + ' \u2014 ' + scope;
|
||
if (String(failed.name || '') === 'INSTALL [for load]') {
|
||
return t('Load request rejected') + ' \u2014 ' + swText;
|
||
}
|
||
return t('Import list rejected') + ' \u2014 ' + scope + ' \u00b7 ' + swText;
|
||
}
|
||
|
||
// The applied probe changes of a compatibility test (pure): version rewrites
|
||
// and appended synthetic imports. The unmatched override AIDs are a list of
|
||
// their own in the modal detail (ramCompatDetailHtml).
|
||
function ramCompatProbeNote(data) {
|
||
const pa = (data && data.probe_imports) || [];
|
||
const added = pa.filter(p => p.from === null || p.from === undefined).length;
|
||
const changed = pa.filter(p => p.from !== null && p.from !== undefined);
|
||
const parts = [];
|
||
if (changed.length) {
|
||
const targets = Array.from(new Set(changed.map(p => p.to)));
|
||
parts.push(changed.length + ' ' + t('imports') + ' \u2192 ' +
|
||
(targets.length === 1 ? targets[0] : t('mixed')));
|
||
}
|
||
if (added) parts.push('+' + added + ' ' + t('appended'));
|
||
return parts.length ? t('probe') + ': ' + parts.join(' \u00b7 ') : '';
|
||
}
|
||
|
||
// Import-list rows of a compatibility test (pure): one row per AID the run
|
||
// touches - the CAP's imports (with the version the card was asked for, and
|
||
// the CAP's own version when the probe lowered it), the appended synthetic
|
||
// imports, and the probe override AIDs that matched nothing.
|
||
// state: 'cap' | 'lowered' | 'appended' | 'unmatched'.
|
||
function ramCompatImportRows(data, mem) {
|
||
const applied = (data && data.probe_imports) || [];
|
||
const unmatched = (data && data.probe_unmatched) || [];
|
||
const byAid = {};
|
||
applied.forEach(p => { const a = String(p.aid || '').toUpperCase(); if (a) byAid[a] = p; });
|
||
const rows = [];
|
||
const seen = {};
|
||
const push = r => { if (!r.aid || seen[r.aid]) return; seen[r.aid] = 1; rows.push(r); };
|
||
((mem && mem.imports) || []).forEach(im => {
|
||
const aid = String(im.aid || '').toUpperCase();
|
||
const capVer = (Number(im.major) || 0) + '.' + (Number(im.minor) || 0);
|
||
const ap = byAid[aid];
|
||
push({ aid: aid, capVersion: capVer, testedVersion: ap ? ap.to : capVer,
|
||
state: ap ? 'lowered' : 'cap' });
|
||
});
|
||
applied.forEach(p => {
|
||
const aid = String(p.aid || '').toUpperCase();
|
||
const appended = (p.from === null || p.from === undefined);
|
||
push({ aid: aid, capVersion: appended ? null : p.from, testedVersion: p.to,
|
||
state: appended ? 'appended' : 'lowered' });
|
||
});
|
||
unmatched.forEach(u => {
|
||
const aid = String((u && u.aid) || u || '').toUpperCase();
|
||
push({ aid: aid, capVersion: null, testedVersion: (u && u.version) || '',
|
||
state: 'unmatched' });
|
||
});
|
||
return rows;
|
||
}
|
||
|
||
// One row per import: AID (resolved library name) - tested version, with the
|
||
// probe state as a muted note.
|
||
function ramCompatRowsHtml(rows) {
|
||
if (!rows || !rows.length) return '';
|
||
return '<div class="mt-1">' + rows.map(r => {
|
||
let ver = r.testedVersion ? esc(r.testedVersion) : '';
|
||
if (r.state === 'lowered' && r.capVersion) {
|
||
ver = esc(r.capVersion) + ' \u2192 ' + esc(r.testedVersion || '?');
|
||
}
|
||
let note = '';
|
||
if (r.state === 'lowered') note = t('probe');
|
||
else if (r.state === 'appended') note = t('appended') + ' \u2014 ' + t('not in the CAP');
|
||
else if (r.state === 'unmatched') note = t('not in the CAP\'s import list');
|
||
return '<div>' + jcAidHtml(r.aid) + (ver ? ' \u2014 ' + ver : '') +
|
||
(note ? ' <span class="text-gray-500 dark:text-slate-400">(' + esc(note) + ')</span>' : '') +
|
||
'</div>';
|
||
}).join('') + '</div>';
|
||
}
|
||
|
||
// Failure analysis of a rejected LOAD (pure HTML): which CAP section the
|
||
// failing block covers and what it completes, what the card checks at that
|
||
// point (JC VM spec 4.5.2), the remote status word, the import list as tested
|
||
// and what the run's probe state implies. Used by the compatibility test
|
||
// modal (the install side reuses it in the next step).
|
||
function ramCompatFailureHtml(data, mem) {
|
||
if (!data || data.success || data.imports_ok) return '';
|
||
const steps = data.steps || [];
|
||
const step = steps[(Number(data.failed_step) || 1) - 1] || null;
|
||
const name = step ? String(step.name || '') : '';
|
||
if (!/^(LOAD |INSTALL \[for load\])/.test(name)) return '';
|
||
const bs = Number(data.load_block_size) || 0;
|
||
const sc = ramStepComponents(name, mem && mem.components, bs);
|
||
const loadSize = (mem && mem.code) ? (Number(mem.code.load_file) || 0) : 0;
|
||
let html = '<div class="mt-2 pt-2 border-t border-gray-200 dark:border-slate-700">';
|
||
// where it failed: the block, its load-file byte range and CAP components
|
||
let where = t('Failed in:') + ' ' + name;
|
||
const m = /^LOAD \((\d+)\/(\d+)\)$/.exec(name);
|
||
if (m && bs) {
|
||
const start = (Number(m[1]) - 1) * bs;
|
||
const end = loadSize ? Math.min(loadSize, start + bs) - 1 : start + bs - 1;
|
||
where += ' \u2014 ' + t('load-file bytes') + ' ' + start + '\u2013' + end +
|
||
(loadSize ? ' / ' + loadSize : '');
|
||
}
|
||
if (sc) {
|
||
where += ' \u00b7 ' + t('covers') + ' ' + sc.names.join(', ');
|
||
if (sc.completed.length) where += ' \u00b7 ' + t('completes') + ' ' + sc.completed[sc.completed.length - 1];
|
||
}
|
||
html += '<div>' + esc(where) + '</div>';
|
||
// what the card checks at that point (JC VM spec 4.5.2)
|
||
const completesImport = !!(sc && sc.completed.indexOf('Import') >= 0);
|
||
let importDone = '';
|
||
steps.forEach(s => {
|
||
const c = ramStepComponents(String(s.name || ''), mem && mem.components, bs);
|
||
if (c && c.completed.indexOf('Import') >= 0) importDone = String(s.name || '');
|
||
});
|
||
if (completesImport) {
|
||
html += '<div>' + esc(t('The card verifies the load file\'s import list when the Import component is complete (JC VM spec 4.5.2).')) + '</div>';
|
||
} else if (importDone) {
|
||
html += '<div>' + esc(t('The import list already passed \u2014 the Import component completed at') + ' ' + importDone + '.') + '</div>';
|
||
}
|
||
// the status word
|
||
const sw = step && step.por_sw ? String(step.por_sw) : '';
|
||
if (sw) {
|
||
const swName = lookupSw(sw.substr(0, 2), sw.substr(2, 2), 'gp');
|
||
html += '<div>' + esc(t('remote SW') + ' ' + sw + (swName ? ' \u2014 ' + swName : '')) + '</div>';
|
||
}
|
||
// the import list as tested + what the probe state implies
|
||
const rows = ramCompatImportRows(data, mem);
|
||
if (rows.length) {
|
||
html += '<div class="mt-1">' + esc(t('Import list under test') + ' (' + rows.length + '):') + '</div>';
|
||
html += ramCompatRowsHtml(rows);
|
||
const capRows = rows.filter(r => r.state === 'cap' || r.state === 'lowered');
|
||
const atCap = capRows.filter(r => r.state === 'cap').length;
|
||
const hasProbe = !!((data.probe_imports && data.probe_imports.length) ||
|
||
(data.probe_unmatched && data.probe_unmatched.length));
|
||
if (capRows.length && !atCap) {
|
||
html += '<div>' + esc(t('Every import was tested at 0.0 \u2014 a package the card does not have (or another load-file check, e.g. memory) is the cause.')) + '</div>';
|
||
} else if (capRows.length && hasProbe) {
|
||
html += '<div>' + esc(t('The card checks the whole list at that point \u2014 lower the versions (Set all 0.0) to test whether the list is the cause, then restore one line at a time to find the culprit.')) + '</div>';
|
||
} else if (capRows.length) {
|
||
// a real install runs the CAP's own versions: point at the test
|
||
html += '<div>' + esc(t('The card checks the whole list at that point \u2014 the CAP compatibility test can confirm it: Set all 0.0 first, then restore one line at a time to find the package and version the card lacks.')) + '</div>';
|
||
}
|
||
}
|
||
html += '</div>';
|
||
return html;
|
||
}
|
||
|
||
// Modal detail of a compatibility test (pure HTML): the failure analysis when
|
||
// the card refused, or the import list that passed.
|
||
function ramCompatDetailHtml(data, mem) {
|
||
if (!data) return '';
|
||
if (data.imports_ok) {
|
||
const rows = ramCompatImportRows(data, mem);
|
||
if (!rows.length) return '';
|
||
return '<div class="mt-2 pt-2 border-t border-gray-200 dark:border-slate-700">' +
|
||
'<div>' + esc(t('Import list under test') + ' (' + rows.length + '):') + '</div>' +
|
||
ramCompatRowsHtml(rows) + '</div>';
|
||
}
|
||
return ramCompatFailureHtml(data, mem);
|
||
}
|
||
|
||
// CAP compatibility test: run the load only as far as the Import component's
|
||
// end and report whether the card accepted the import list. Nothing is
|
||
// installed or committed (the install operation keeps the CAP's own versions).
|
||
async function ramCompatTest(sp) {
|
||
const fileInput = document.getElementById('ram-cap-file');
|
||
const file = fileInput.files[0];
|
||
if (!file) { alert(t('Select a .cap file')); return; }
|
||
if (file.size > 48 * 1024) { alert(t('CAP file exceeds 48 kB limit')); return; }
|
||
const parsed = ramProbeParse(document.getElementById('ram-probe-versions').value);
|
||
if (parsed.error) { alert(parsed.error); return; }
|
||
const bsRaw = (document.getElementById('ram-compat-block').value || '').trim();
|
||
let blockSize = null;
|
||
if (bsRaw !== '') {
|
||
const bs = parseInt(bsRaw, 10);
|
||
if (!(bs >= 1 && bs <= 240)) { alert(t('LOAD block size must be 1..240')); return; }
|
||
blockSize = bs;
|
||
}
|
||
|
||
const mem = (_capAnalysis.ram && _capAnalysis.ram.memory) || null;
|
||
const capPkg = (mem && mem.package_aid) || '';
|
||
ramClearResults();
|
||
// The run holds the card lock; the modal carries the live progress (the
|
||
// server reports ram_progress for cap-compat) and the failure analysis.
|
||
ramOpModalBegin(t('CAP compatibility test') + (capPkg ? ' \u2014 ' + capPkg : ''));
|
||
ramOpModalStatus(t('Reading CAP file...'));
|
||
const body = {
|
||
cap_hex: await ramReadFileHex(file),
|
||
probe_imports: parsed.map, probe_additions: parsed.additions,
|
||
preset_id: sp.preset_id,
|
||
spi1: sp.spi1, spi2: '01', kic: sp.kic, kid: sp.kid,
|
||
tar: sp.tar, cntr: sp.cntr, kicKey: sp.kicKey, kidKey: sp.kidKey,
|
||
};
|
||
if (blockSize !== null) body.load_block_size = blockSize;
|
||
ramOpModalStatus(t('Sending to server...'));
|
||
|
||
let data;
|
||
try {
|
||
data = await pysimFetch('/api/cap-compat', body);
|
||
} catch (e) {
|
||
const msg = t('Error') + ': ' + e.message;
|
||
const el = document.getElementById('ram-result');
|
||
el.textContent = msg;
|
||
el.classList.remove('hidden', 'text-green-600');
|
||
el.classList.add('text-red-600');
|
||
ramOpModalFinish(msg, false, '');
|
||
return;
|
||
}
|
||
if (data.final_cntr) ramSaveCntr(data.final_cntr);
|
||
// Inline at the bottom of the form: the verdict, the short probe note and
|
||
// the step lines; the per-AID detail lives in the modal.
|
||
const comps = mem && mem.components;
|
||
let txt = '';
|
||
(data.steps || []).forEach((s, idx) => { txt += ramStepLine(s, idx, comps, data.load_block_size) + '\n'; });
|
||
const stepsEl = document.getElementById('ram-steps');
|
||
stepsEl.classList.remove('hidden');
|
||
stepsEl.textContent = txt;
|
||
const verdict = ramCompatVerdict(data);
|
||
const probeNote = ramCompatProbeNote(data);
|
||
const resultEl = document.getElementById('ram-result');
|
||
resultEl.textContent = verdict + (probeNote ? ' \u2014 ' + probeNote : '');
|
||
resultEl.classList.remove('hidden', 'text-green-600', 'text-red-600');
|
||
resultEl.classList.add(data.imports_ok ? 'text-green-600' : 'text-red-600');
|
||
// Modal: the step lines (escaped) plus the probe summary and the per-AID
|
||
// detail (the failure analysis when the card refused).
|
||
let modal = esc(txt);
|
||
if (probeNote) modal += '<div class="mt-2 text-gray-500 dark:text-slate-400">' + esc(probeNote) + '</div>';
|
||
modal += ramCompatDetailHtml(data, mem);
|
||
if (spCntrLow(data)) {
|
||
modal += ramCntrLowHtml(ramCntrLowPresetIdx());
|
||
ramShowCntrLow();
|
||
}
|
||
ramOpModalFinish(verdict, !!data.imports_ok, modal, true);
|
||
}
|
||
|
||
async function ramInstallCap(sp) {
|
||
const fileInput = document.getElementById('ram-cap-file');
|
||
const file = fileInput.files[0];
|
||
if (!file) { alert(t('Select a .cap file')); return; }
|
||
if (file.size > 48 * 1024) { alert(t('CAP file exceeds 48 kB limit')); return; }
|
||
|
||
const capPkg = (_capAnalysis.ram.memory && _capAnalysis.ram.memory.package_aid) || '';
|
||
ramOpModalBegin(t('Installing package') + (capPkg ? ' \u2014 ' + capPkg : ''));
|
||
ramOpModalStatus(t('Reading CAP file...'));
|
||
const capHex = await ramReadFileHex(file);
|
||
ramOpModalStatus(t('Sending to server for install...'));
|
||
|
||
// The STK hex is regenerated on every toolkit field edit; recompute it
|
||
// once more at send time unless the user pasted a custom hex directly
|
||
// (dataset.manual), so the sent parameters always match the fields.
|
||
const stkEl = document.getElementById('ram-stk-params');
|
||
if (document.getElementById('rc-toolkit-enable').checked && !(stkEl.dataset && stkEl.dataset.manual)) {
|
||
updateStkParamsHex();
|
||
}
|
||
// The privileges are an aggregate of the checkboxes: derive them now, so a
|
||
// value restored by the browser or left from an earlier experiment can
|
||
// never be sent silently.
|
||
updateRcPriv();
|
||
|
||
const body = {
|
||
cap_hex: capHex,
|
||
preset_id: sp.preset_id,
|
||
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: (ramInstallParamsPrefix() +
|
||
(document.getElementById('ram-install-params-hex').value || '').replace(/[^0-9a-fA-F]/g, '')).toUpperCase(),
|
||
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 loadSize = (document.getElementById('ram-load-size').value || '').trim();
|
||
if (loadSize !== '') {
|
||
const n = parseInt(loadSize, 10);
|
||
if (!(n >= 1 && n <= 240)) { alert(t('LOAD block size must be 1..240')); return; }
|
||
body.load_block_size = n;
|
||
}
|
||
|
||
const data = await pysimFetch('/api/ram-install', body);
|
||
|
||
ramHideProgress();
|
||
let sizeInfo = '';
|
||
if (data.load_block_size) {
|
||
sizeInfo = ' — ' + t('LOAD blocks') + ': ' + data.load_block_size + ' B';
|
||
if (data.load_block_size_auto) sizeInfo += ' (' + t('default') + ')';
|
||
}
|
||
ramShowInstallResult(data, t('Install OK') + ' — load_file_aid=' + data.load_file_aid
|
||
+ ' module_aid=' + data.module_aid + sizeInfo, _capAnalysis.ram.memory);
|
||
}
|
||
|
||
// INSTALL [for install] / [for make selectable] for an already loaded
|
||
// package: one APDU, so the parameters can be iterated without deleting and
|
||
// re-loading the .cap every time.
|
||
async function ramInstallApp(sp, op) {
|
||
const clean = id => (document.getElementById(id).value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
const mode = op === 'make-selectable' ? 'make_selectable' : 'install';
|
||
const loadfile = clean('ram-app-loadfile');
|
||
const module = clean('ram-app-module');
|
||
const instance = clean('ram-app-instance');
|
||
if (mode === 'make_selectable') {
|
||
if (!instance) { alert(t('Instance AID required')); return; }
|
||
} else if (!loadfile || !module) {
|
||
alert(t('Load File AID and Module AID required'));
|
||
return;
|
||
}
|
||
// The privileges and the STK hex are derived from the form, never sent
|
||
// stale (see ramInstallCap).
|
||
updateRcPriv();
|
||
const stkEl = document.getElementById('ram-stk-params');
|
||
if (document.getElementById('rc-toolkit-enable').checked && !(stkEl.dataset && stkEl.dataset.manual)) {
|
||
updateStkParamsHex();
|
||
}
|
||
const body = {
|
||
mode: mode,
|
||
preset_id: sp.preset_id,
|
||
loadfile_aid: loadfile,
|
||
module_aid: module,
|
||
instance_aid: instance,
|
||
privileges: (document.getElementById('rc-priv').value || '00').replace(/[^0-9a-fA-F]/g, ''),
|
||
install_params: (ramInstallParamsPrefix() +
|
||
(document.getElementById('ram-install-params-hex').value || '').replace(/[^0-9a-fA-F]/g, '')).toUpperCase(),
|
||
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,
|
||
};
|
||
ramOpModalBegin(t(mode === 'make_selectable' ? 'INSTALL [for make selectable]' : 'INSTALL [for install]')
|
||
+ ((instance || module) ? ' \u2014 ' + (instance || module) : ''));
|
||
ramOpModalStatus(t('Sending to card...'));
|
||
const data = await pysimFetch('/api/ram-install-app', body);
|
||
ramHideProgress();
|
||
ramShowInstallResult(data, t('OK'));
|
||
}
|
||
|
||
// ===== TAR probe (v3.8.0) =====
|
||
// One secured packet per TAR from the preset's keyset, classified by the
|
||
// card's ENVELOPE verdict and the application's PoR. The standard TAR list
|
||
// lives on the server (TS 101 220 V18.3.0 Annex D, Table D.1) and is used when
|
||
// the list field is empty. Each packet the card accepts consumes a counter,
|
||
// which the server persists into the keyset (one per accepted TAR).
|
||
|
||
// TAR values allocated by ETSI (TS 101 220 V18.3.0 Annex D, Table D.1) with
|
||
// the document each allocation comes from. Kept in sync with the server's
|
||
// TAR_PROBE_TARS by a test; custom TARs live in localStorage (best effort).
|
||
const TAR_PROBE_TARS = [
|
||
{ tar: '000000', label: 'Issuer Security Domain (compact)', doc: 'TS 102 226' },
|
||
{ tar: 'B20100', label: 'Issuer Security Domain (expanded)', doc: 'TS 102 226' },
|
||
{ tar: 'B00000', label: 'UICC shared file system RFM (compact)', doc: 'TS 102 226' },
|
||
{ tar: 'B00001', label: 'ADF RFM (compact)', doc: 'TS 131 116' },
|
||
{ tar: 'B00010', label: 'SIM file system RFM (compact)', doc: 'TS 131 116' },
|
||
{ tar: 'B00120', label: 'UICC shared file system RFM (expanded)', doc: 'TS 102 226' },
|
||
{ tar: 'B00130', label: 'SIM file system RFM (expanded)', doc: 'TS 131 116' },
|
||
{ tar: 'B00140', label: 'ADF RFM (expanded)', doc: 'TS 131 116' },
|
||
{ tar: 'B20000', label: 'USAT interpreter', doc: 'TS 131 114' },
|
||
{ tar: 'B20200', label: 'Multiplexing application', doc: 'TS 102 225' },
|
||
{ tar: 'B20201', label: 'Controlling Authority Security Domain', doc: 'GP Amendment A' },
|
||
{ tar: 'B20202', label: 'OMA BCAST audience measurement', doc: 'OMA' },
|
||
{ tar: 'B20203', label: 'OMA DM LWM2M application', doc: 'OMA' },
|
||
{ tar: 'B00200', label: 'RFU (control)', doc: '' },
|
||
];
|
||
const TAR_PROBE_STORAGE = 'simple_tar_probe';
|
||
|
||
// The custom TARs added by the user (best effort: losing them is harmless).
|
||
function tarProbeCustomLoad() {
|
||
try {
|
||
const d = JSON.parse(localStorage.getItem(TAR_PROBE_STORAGE) || '[]');
|
||
if (!Array.isArray(d)) return [];
|
||
return d.filter(e => e && /^[0-9A-F]{6}$/.test(String(e.tar || '').toUpperCase()))
|
||
.map(e => ({ tar: String(e.tar).toUpperCase(), label: String(e.label || ''), custom: true }));
|
||
} catch (e) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function tarProbeCustomSave(list) {
|
||
try {
|
||
localStorage.setItem(TAR_PROBE_STORAGE, JSON.stringify(
|
||
(list || []).map(e => ({ tar: e.tar, label: e.label || '' }))));
|
||
} catch (e) { /* best effort */ }
|
||
}
|
||
|
||
// The standard rows plus the custom ones.
|
||
function tarProbeAllRows() {
|
||
return TAR_PROBE_TARS.concat(tarProbeCustomLoad());
|
||
}
|
||
|
||
// The checklist rows (pure): a checkbox per TAR, checked by default, the spec
|
||
// document in the tooltip and a remove button on custom rows.
|
||
function tarProbeRowsHtml(rows) {
|
||
return (rows || []).map(r => {
|
||
const title = r.doc ? ' title="' + esc(r.doc) + '"' : '';
|
||
return '<label class="flex items-center gap-2 py-0.5"' + title + '>' +
|
||
'<input type="checkbox" class="rounded border-gray-300 dark:border-slate-600" value="' + esc(r.tar) + '" checked>' +
|
||
'<span class="font-mono text-xs">' + esc(r.tar) + '</span>' +
|
||
'<span class="text-xs text-gray-500 dark:text-slate-400">' + esc(r.label || '') + '</span>' +
|
||
(r.custom
|
||
? '<button type="button" onclick="tarProbeRemoveCustom(\'' + esc(r.tar) + '\')" class="ml-auto px-1.5 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-red-100 dark:hover:bg-red-900/40" title="' + esc(t('Remove TAR')) + '">\u2715</button>'
|
||
: '') +
|
||
'</label>';
|
||
}).join('');
|
||
}
|
||
|
||
// The selected TARs, in list order (pure read of the DOM).
|
||
function tarProbeChecked() {
|
||
return Array.from(document.querySelectorAll('#tar-list input[type="checkbox"]'))
|
||
.filter(cb => cb.checked)
|
||
.map(cb => String(cb.value).toUpperCase());
|
||
}
|
||
|
||
function tarProbeRender() {
|
||
const box = document.getElementById('tar-list');
|
||
if (box) box.innerHTML = tarProbeRowsHtml(tarProbeAllRows());
|
||
}
|
||
|
||
function tarProbeSelectAll(on) {
|
||
document.querySelectorAll('#tar-list input[type="checkbox"]').forEach(cb => { cb.checked = !!on; });
|
||
}
|
||
|
||
function tarProbeAddCustom() {
|
||
const el = document.getElementById('tar-custom');
|
||
const noteEl = document.getElementById('tar-custom-note');
|
||
const tar = (el && el.value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (!/^[0-9A-F]{6}$/.test(tar)) { alert(t('A TAR is three hex bytes, e.g. B00001')); return; }
|
||
const custom = tarProbeCustomLoad();
|
||
if (!custom.some(e => e.tar === tar)) {
|
||
custom.push({ tar: tar, label: (noteEl && noteEl.value || '').trim(), custom: true });
|
||
tarProbeCustomSave(custom);
|
||
}
|
||
if (el) el.value = '';
|
||
if (noteEl) noteEl.value = '';
|
||
tarProbeRender();
|
||
}
|
||
|
||
function tarProbeRemoveCustom(tar) {
|
||
const want = String(tar || '').toUpperCase();
|
||
tarProbeCustomSave(tarProbeCustomLoad().filter(e => e.tar !== want));
|
||
tarProbeRender();
|
||
}
|
||
|
||
function tarProbeVerdictLabel(verdict) {
|
||
return { registered: 'registered', no_answer: 'accepted, no answer',
|
||
no_por: 'accepted, no PoR', refused: 'refused' }[verdict] || verdict;
|
||
}
|
||
|
||
// The probe result table (pure): TAR, application, ENVELOPE SW, verdict, the
|
||
// application's status word and data.
|
||
function tarProbeHtml(data) {
|
||
if (!data || !Array.isArray(data.results)) return '';
|
||
let html = '<div class="mb-2 text-sm">' + esc(t('Registered applications:')) + ' <b>' +
|
||
(data.registered || 0) + ' / ' + (data.total || 0) + '</b>' +
|
||
(data.final_cntr ? ' \u00b7 ' + esc(t('counter')) + ' ' + esc(data.final_cntr) : '') + '</div>';
|
||
html += '<table class="w-full text-xs"><thead><tr>';
|
||
['TAR', 'Application', 'ENVELOPE', 'Result', 'App SW', 'Data'].forEach(h => {
|
||
html += '<th class="text-left py-1 px-2 border-b border-gray-200 dark:border-slate-700">' + esc(t(h)) + '</th>';
|
||
});
|
||
html += '</tr></thead><tbody>';
|
||
(data.results || []).forEach(r => {
|
||
const tone = r.verdict === 'registered' ? 'text-emerald-700 dark:text-emerald-400'
|
||
: (r.verdict === 'refused' ? 'text-red-600 dark:text-red-400'
|
||
: 'text-amber-600 dark:text-amber-400');
|
||
html += '<tr class="border-b border-gray-100 dark:border-slate-700">' +
|
||
'<td class="py-1 px-2 font-mono">' + esc(r.tar) + '</td>' +
|
||
'<td class="py-1 px-2">' + esc(r.label || '') + '</td>' +
|
||
'<td class="py-1 px-2 font-mono">' + esc(r.sw || '-') + '</td>' +
|
||
'<td class="py-1 px-2 ' + tone + '">' + esc(t(tarProbeVerdictLabel(r.verdict))) + '</td>' +
|
||
'<td class="py-1 px-2 font-mono">' + esc(r.por_sw || '-') + '</td>' +
|
||
'<td class="py-1 px-2 font-mono">' + esc(r.por_data || '-') + '</td></tr>';
|
||
});
|
||
html += '</tbody></table>';
|
||
return html;
|
||
}
|
||
|
||
// The probe result line (shared by the success and failure paths).
|
||
function tarProbeResult(text, ok) {
|
||
const el = document.getElementById('tar-result');
|
||
el.textContent = text;
|
||
el.classList.remove('hidden', 'text-green-600', 'text-red-600');
|
||
el.classList.add(ok ? 'text-green-600' : 'text-red-600');
|
||
return text;
|
||
}
|
||
|
||
async function tarProbe(sp, tars) {
|
||
const apdu = (document.getElementById('tar-apdu').value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (!apdu) { alert(t('Specify the probe APDU')); return; }
|
||
if (!tars || !tars.length) { alert(t('Select at least one TAR')); return; }
|
||
const body = {
|
||
preset_id: sp.preset_id,
|
||
spi1: sp.spi1, spi2: '01',
|
||
kic: sp.kic, kid: sp.kid, kicKey: sp.kicKey, kidKey: sp.kidKey,
|
||
cntr: sp.cntr, apdu: apdu, tars: tars,
|
||
};
|
||
ramOpModalBegin(t('TAR probe'));
|
||
ramOpModalStatus(t('Sending to card...'));
|
||
let data;
|
||
try {
|
||
data = await pysimFetch('/api/tar-probe', body);
|
||
} catch (e) {
|
||
ramOpModalFinish(tarProbeResult(t('Error') + ': ' + e.message, false), false, '');
|
||
return;
|
||
}
|
||
if (data.final_cntr) ramSaveCntr(data.final_cntr);
|
||
if (!data.success) {
|
||
ramOpModalFinish(tarProbeResult(t('Error') + ': ' + (data.error || '?'), false), false, '');
|
||
return;
|
||
}
|
||
const text = tarProbeResult(t('Registered applications:') + ' ' + data.registered + ' / ' + data.total, true);
|
||
ramOpModalFinish(text, true, tarProbeHtml(data), true);
|
||
}
|
||
|
||
// The TAR-probe view's preset selector (populated from cards[], like the RAM one).
|
||
function tarRender() {
|
||
const sel = document.getElementById('tar-card-sel');
|
||
if (!sel) return;
|
||
const prev = parseInt(sel.value, 10);
|
||
// keep the current/remembered selection across rebuilds (the auto-selection
|
||
// sets _ramCardIdx before this select has its options)
|
||
const keep = (!isNaN(prev) && cards[prev]) ? prev : _ramCardIdx;
|
||
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);
|
||
});
|
||
if (keep !== null && keep !== undefined && cards[keep]) sel.value = String(keep);
|
||
spKeysetSync();
|
||
}
|
||
|
||
function tarPresetIdx() {
|
||
const sel = document.getElementById('tar-card-sel');
|
||
const idx = sel ? parseInt(sel.value, 10) : NaN;
|
||
return (!isNaN(idx) && cards[idx]) ? idx : -1;
|
||
}
|
||
|
||
function tarApplyCard(idx) {
|
||
// copy the selected preset into the shared SP form fields (the probe reads
|
||
// kic/kid/keys/counter from there, like the RAM view does)
|
||
const i = parseInt(idx, 10);
|
||
if (!isNaN(i) && cards[i]) _ramCardIdx = i;
|
||
_spTarKey = 'tar';
|
||
cardsApply(idx);
|
||
spPresetWarningRender();
|
||
}
|
||
|
||
function tarKeysetChanged() {
|
||
const sel = document.getElementById('tar-keyset-sel');
|
||
if (!sel) return;
|
||
const kvn = parseInt(sel.value, 10);
|
||
const idx = tarPresetIdx();
|
||
const ks = (idx >= 0 && !isNaN(kvn)) ? spKeysetFor(cards[idx], kvn) : null;
|
||
if (ks) spKeysetApply(ks);
|
||
spKeysetSync();
|
||
}
|
||
|
||
function tarKeysetGuard() {
|
||
const idx = tarPresetIdx();
|
||
const kic = (document.getElementById('sp-kic-hex') || {}).value;
|
||
const kid = (document.getElementById('sp-kid-hex') || {}).value;
|
||
return spKeysetCheck(idx >= 0 ? cards[idx] : null, kic, kid);
|
||
}
|
||
|
||
async function tarExecute() {
|
||
const tars = tarProbeChecked();
|
||
if (!tars.length) { alert(t('Select at least one TAR')); return; }
|
||
const cardIdx = parseInt(document.getElementById('tar-card-sel').value, 10);
|
||
if (!isNaN(cardIdx) && cards[cardIdx]) _ramCardIdx = cardIdx;
|
||
spRefreshFromPreset('tar-card-sel');
|
||
const ksErr = tarKeysetGuard();
|
||
if (ksErr) { alert(ksErr); return; }
|
||
const sp = getRamSpParams('tar-card-sel');
|
||
if (!sp.kicKey || !sp.kidKey) {
|
||
alert(t('Select a card preset with keys first (RAM subtab → Card preset)'));
|
||
return;
|
||
}
|
||
await tarProbe(sp, tars);
|
||
}
|
||
|
||
// Measured NV footprint of an install operation: the server reads GET DATA
|
||
// FF21 (free non-volatile memory) before and after the chain; the delta is
|
||
// what the operation consumed (negative = memory was freed). Cards without
|
||
// FF21 have no values and the line disappears.
|
||
function ramNvLine(data) {
|
||
if (!data || data.nv_before == null || data.nv_after == null) return '';
|
||
const used = data.nv_before - data.nv_after;
|
||
const label = used < 0 ? t('NV freed:') : t('NV used:');
|
||
return label + ' ~' + Math.abs(used) + ' B (' + t('free') + ' ' + data.nv_before + ' \u2192 ' + data.nv_after + ')';
|
||
}
|
||
|
||
// The preset the failed operation used (the RAM form's selection, else the
|
||
// SCP80 form's) - the "Go to preset" target.
|
||
function ramCntrLowPresetIdx() {
|
||
const pick = id => {
|
||
const el = document.getElementById(id);
|
||
const i = el && el.value !== '' ? parseInt(el.value, 10) : NaN;
|
||
return (!isNaN(i) && cards[i]) ? i : -1;
|
||
};
|
||
const ram = pick('ram-card-sel');
|
||
return ram >= 0 ? ram : pick('sp-card-sel');
|
||
}
|
||
|
||
// Low-counter notice (pure HTML): the facts plus two actions - a bounded
|
||
// counter sync probe and a button that opens the failed operation's preset in
|
||
// the Cards tab, where the counter can be raised by hand.
|
||
function ramCntrLowHtml(presetIdx) {
|
||
const idx = Number.isInteger(presetIdx) && presetIdx >= 0 ? presetIdx : -1;
|
||
const text = t('Low counter') + ' \u2014 ' + t('the card rejected the packet');
|
||
const btnCls = 'px-2 py-1 text-xs rounded border border-red-300 dark:border-red-700 ' +
|
||
'hover:bg-red-100 dark:hover:bg-red-900/40 whitespace-nowrap';
|
||
return '<div class="flex items-center gap-3 text-sm text-red-600 dark:text-red-400">' +
|
||
'<span>' + text + '</span>' +
|
||
'<button type="button" onclick="ramSyncCounter(' + idx + ')" class="' + btnCls + '">' +
|
||
esc(t('Sync counter')) + '</button>' +
|
||
'<button type="button" onclick="ramGoToPreset(' + idx + ')" class="' + btnCls + '">' +
|
||
esc(t('Go to preset')) + '</button></div>';
|
||
}
|
||
|
||
// Show the low-counter notice in the RAM result area (and, when the operation
|
||
// modal is still open, in its detail block).
|
||
function ramShowCntrLow(presetIdx) {
|
||
const idx = presetIdx === undefined ? ramCntrLowPresetIdx() : presetIdx;
|
||
const html = ramCntrLowHtml(idx);
|
||
const el = document.getElementById('ram-cntr-low');
|
||
if (el && el.classList) {
|
||
el.innerHTML = html;
|
||
el.classList.remove('hidden');
|
||
}
|
||
if (typeof _ramOpActive !== 'undefined' && _ramOpActive) {
|
||
const steps = document.getElementById('ram-op-steps');
|
||
if (steps) steps.innerHTML += html;
|
||
}
|
||
}
|
||
|
||
// Sync the counter of the failed operation's preset with the card: the server
|
||
// probes with a read-only command and increasing counter values (start+1, +2,
|
||
// +4, ...) until the card accepts one, stops at a 32-bit ceiling and persists
|
||
// accepted+1. A rejected attempt changes nothing on the card, so only the
|
||
// accepted packet moves its counter (the card never reports its value).
|
||
async function ramSyncCounter(presetIdx) {
|
||
const i = Number(presetIdx);
|
||
const preset = (Number.isInteger(i) && i >= 0 && cards[i]) ? cards[i] : null;
|
||
const box = document.getElementById('ram-cntr-low');
|
||
if (!preset) { alert(t('No card preset selected')); return; }
|
||
// The keyset the failed operation used: the RAM form's selector, else the
|
||
// SCP80 form's (both hold the keyset number as their option value).
|
||
const kvnOf = id => {
|
||
const sel = document.getElementById(id);
|
||
const v = sel && sel.value !== '' ? parseInt(sel.value, 10) : NaN;
|
||
return Number.isInteger(v) && v > 0 ? v : null;
|
||
};
|
||
const val = id => (document.getElementById(id) || {}).value || '';
|
||
const body = { preset_id: preset.id };
|
||
const kvn = kvnOf('ram-keyset-sel') || kvnOf('sp-keyset-sel');
|
||
if (kvn) body.kvn = kvn;
|
||
const tar = val('sp-tar').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
if (tar) body.tar = tar;
|
||
if (val('sp-spi1')) body.spi1 = val('sp-spi1');
|
||
if (val('sp-spi2-hex')) body.spi2 = val('sp-spi2-hex');
|
||
const cntr = val('sp-cntr').replace(/[^0-9a-fA-F]/g, '');
|
||
if (cntr) body.cntr = cntr;
|
||
if (box) box.innerHTML = '<div class="text-sm text-gray-500 dark:text-slate-400">' +
|
||
esc(t('Syncing counter...')) + '</div>';
|
||
try {
|
||
const data = await pysimFetch('/api/counter-probe', body);
|
||
if (data.success) {
|
||
await cardsFetch();
|
||
const cntrEl = document.getElementById('sp-cntr');
|
||
if (cntrEl && data.stored_cntr) cntrEl.value = data.stored_cntr;
|
||
const msg = t('Counter synced') + ': ' + esc(data.accepted_cntr) + ' \u2192 ' +
|
||
esc(data.stored_cntr) + '. ' + t('Retry the operation.');
|
||
if (box) box.innerHTML = '<div class="text-sm text-emerald-600 dark:text-emerald-400">' + msg + '</div>';
|
||
} else {
|
||
const msg = t('Counter sync failed') + ': ' + esc(data.error || '');
|
||
if (box) box.innerHTML = '<div class="text-sm text-red-600 dark:text-red-400">' + msg + '</div>';
|
||
}
|
||
} catch (e) {
|
||
if (box) box.innerHTML = '<div class="text-sm text-red-600 dark:text-red-400">' +
|
||
esc(String(e.message || e)) + '</div>';
|
||
}
|
||
}
|
||
|
||
// Open a preset in the Cards tab for editing (the user fixes the counter):
|
||
// select it, scroll to the counter field and focus it.
|
||
function ramGoToPreset(idx) {
|
||
switchTab('cards');
|
||
const i = Number(idx);
|
||
if (Number.isInteger(i) && i >= 0 && cards[i]) cardsEdit(i);
|
||
// Focus the counter of the keyset number the failed operation used (the
|
||
// keyset rows are keyed by their version; the first row otherwise).
|
||
const kvn = spKeysetKvnOf((document.getElementById('sp-kic-hex') || {}).value) ||
|
||
spKeysetKvnOf((document.getElementById('sp-kid-hex') || {}).value) || 0;
|
||
let row = 0;
|
||
if (Number.isInteger(i) && i >= 0 && cards[i] && kvn) {
|
||
const at = spKeysetList(cards[i]).findIndex(ks => spKeysetKvnOf(ks.kic) === kvn);
|
||
if (at >= 0) row = at;
|
||
}
|
||
const el = document.getElementById('cards-ks-' + row + '-cntr');
|
||
if (el) {
|
||
if (el.scrollIntoView) el.scrollIntoView({ block: 'center' });
|
||
el.focus();
|
||
if (el.select) el.select();
|
||
}
|
||
}
|
||
|
||
// Shared result rendering for /api/ram-install and /api/ram-install-app: the
|
||
// per-step lines, the counter the card consumed, and the verdict. A failure
|
||
// at a LOAD / INSTALL [for load] step also gets the same block analysis as
|
||
// the compatibility test (which CAP section the block covers, what the card
|
||
// checks there, the import list and the status word) in the modal.
|
||
function ramShowInstallResult(data, okText, capMem) {
|
||
const resultEl = document.getElementById('ram-result');
|
||
const stepsEl = document.getElementById('ram-steps');
|
||
stepsEl.classList.remove('hidden');
|
||
const comps = capMem && capMem.components;
|
||
let txt = '';
|
||
(data.steps || []).forEach((s, idx) => { txt += ramStepLine(s, idx, comps, data.load_block_size) + '\n'; });
|
||
const nv = ramNvLine(data);
|
||
if (nv) txt += nv + '\n';
|
||
stepsEl.textContent = txt;
|
||
// Persist the counter the card actually consumed (accepted packets only):
|
||
// even a failed install leaves the accepted steps behind, and replaying
|
||
// their counter would make the card reject the next attempt.
|
||
if (data.final_cntr) ramSaveCntr(data.final_cntr);
|
||
// The import probe's applied changes (diagnostic runs only)
|
||
let probeNote = '';
|
||
const pa = data.probe_imports;
|
||
if (pa && pa.length) {
|
||
const targets = Array.from(new Set(pa.map(p => p.to)));
|
||
probeNote = ' \u2014 ' + t('probe') + ': ' + pa.length + ' ' + t('imports') +
|
||
' \u2192 ' + (targets.length === 1 ? targets[0] : t('mixed'));
|
||
}
|
||
if (data.success) {
|
||
resultEl.textContent = (okText || t('OK')) + probeNote;
|
||
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
|
||
} else {
|
||
const hint = ramInstallFailHint(data, capMem || null);
|
||
resultEl.textContent = t('Install FAILED at step:') + ' ' + data.failed_step + (data.error ? ' (' + data.error + ')' : '') +
|
||
probeNote + (hint ? ' \u2014 ' + t('Hint:') + ' ' + hint : '');
|
||
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
|
||
}
|
||
// The modal carries the step lines (escaped) plus the LOAD-failure
|
||
// analysis when there is one (ramCompatDetailHtml returns '' for a
|
||
// successful run and for failures that are not LOAD/INSTALL [for load]).
|
||
let modal = esc(txt);
|
||
modal += ramCompatDetailHtml(data, capMem || null);
|
||
// low counter: not recoverable by retrying - state it and offer the
|
||
// bounded sync probe or the preset jump (v3.6.58)
|
||
if (spCntrLow(data)) {
|
||
modal += ramCntrLowHtml(ramCntrLowPresetIdx());
|
||
ramShowCntrLow();
|
||
}
|
||
ramOpModalFinish(resultEl.textContent, !!data.success, modal, true);
|
||
}
|
||
|
||
async function ramExecute() {
|
||
const op = document.getElementById('ram-op').value;
|
||
if (op === 'install-cap' && !capGateOk('ram')) {
|
||
alert(t('Analyze the CAP file first'));
|
||
return;
|
||
}
|
||
ramClearResults();
|
||
const cardIdx = parseInt(document.getElementById('ram-card-sel').value, 10);
|
||
if (!isNaN(cardIdx) && cards[cardIdx]) _ramCardIdx = cardIdx;
|
||
// Re-read the preset before the operation: its counter/keys may have
|
||
// changed in the Cards tab after the RAM form was filled.
|
||
spRefreshFromPreset('ram-card-sel');
|
||
const sp = getRamSpParams();
|
||
if (!sp.kicKey || !sp.kidKey) {
|
||
alert(t('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);
|
||
else if (op === 'compat') await ramCompatTest(sp);
|
||
else if (op === 'install-app' || op === 'make-selectable') await ramInstallApp(sp, op);
|
||
} catch (e) {
|
||
const resultEl = document.getElementById('ram-result');
|
||
resultEl.textContent = t('Error') + ': ' + e.message;
|
||
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
|
||
ramHideProgress();
|
||
if (_ramOpActive) ramOpModalFinish(resultEl.textContent, false, '');
|
||
}
|
||
}
|
||
|
||
// ===== STK Menu Browser =====
|
||
let stkMenuStack = [];
|
||
|
||
// The STK button label from /api/menu (pure): the card's title, or a generic
|
||
// label when the SET UP MENU carried none (the alpha identifier is optional,
|
||
// TS 102 223 6.6.7 - a title-less menu must not read as "No menu set by the
|
||
// card"). Empty string = no menu.
|
||
function stkMenuLabel(data) {
|
||
if (!data || !data.items || !data.items.length) return '';
|
||
return data.title || t('Menu');
|
||
}
|
||
|
||
async function stkCheckMenu() {
|
||
try {
|
||
const data = await pysimFetch('/api/menu');
|
||
const btn = document.getElementById('stk-menu-btn');
|
||
const none = document.getElementById('stk-menu-none');
|
||
const label = stkMenuLabel(data);
|
||
if (label) {
|
||
document.getElementById('stk-menu-title').textContent = label;
|
||
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();
|
||
}
|
||
|
||
// TS 102 223 8.24: the item's next action (coded like the Type of Command);
|
||
// the server only attaches nai_name for values the table marks for the NAI.
|
||
function stkMenuNaiSuffix(item) {
|
||
return (item && item.nai_name) ? '\u25b8 ' + item.nai_name : '';
|
||
}
|
||
|
||
// The overlay's item list, shared by the cached top menu and the pending
|
||
// SELECT ITEM items the card returns mid-dialogue: one row per item, each
|
||
// with the card-provided next action suffix. `handler` is the click callback
|
||
// (stkMenuItemClick / stkSubItemClick).
|
||
function stkMenuItemsHtml(items, handler) {
|
||
let html = '<div class="space-y-1">';
|
||
(items || []).forEach(item => {
|
||
const nai = stkMenuNaiSuffix(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="' + handler + '(' + 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>' +
|
||
(nai ? '<span class="text-xs text-gray-400 dark:text-slate-500">' + esc(nai) + '</span>' : '') + '</div>';
|
||
});
|
||
return html + '</div>';
|
||
}
|
||
|
||
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; }
|
||
content.innerHTML = stkMenuItemsHtml(data.items, 'stkMenuItemClick');
|
||
}).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);
|
||
}
|
||
}
|
||
|
||
// User-input prompt for a pending GET INKEY / GET INPUT (pure). The card's
|
||
// constraints drive the field: hidden entry masks the value (TS 102 223
|
||
// 6.4.3), digits-only switches the keyboard, Response length bounds the text.
|
||
function stkInputHtml(pd) {
|
||
pd = pd || {};
|
||
let html = '<div class="text-sm whitespace-pre-wrap p-2 bg-gray-50 dark:bg-slate-700 rounded mb-2">' + esc(pd.text || '') + '</div>';
|
||
if (pd.yes_no) {
|
||
html += '<div class="flex gap-2 justify-center">' +
|
||
'<button onclick="stkInputSubmit(\'01\')" class="px-4 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700">' + esc(t('Yes')) + '</button>' +
|
||
'<button onclick="stkInputSubmit(\'00\')" class="px-4 py-1 text-sm rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600">' + esc(t('No')) + '</button>' +
|
||
'</div>';
|
||
return html;
|
||
}
|
||
const max = Number(pd.max) || 0;
|
||
const attrs = [
|
||
'id="stk-input-field"',
|
||
'class="w-full px-2 py-1 text-sm rounded border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 font-mono"',
|
||
'type="' + (pd.hidden ? 'password' : 'text') + '"',
|
||
pd.digits_only ? 'inputmode="numeric"' : '',
|
||
(max && max !== 0xFF) ? 'maxlength="' + max + '"' : '',
|
||
pd.immediate ? 'oninput="stkInputImmediate()"' : '',
|
||
'value="' + esc(pd.default || '') + '"',
|
||
].filter(Boolean).join(' ');
|
||
html += '<input ' + attrs + '>';
|
||
const hint = stkInputHint(pd);
|
||
if (hint) html += '<div class="text-xs text-gray-400 dark:text-slate-500 mt-1">' + esc(hint) + '</div>';
|
||
if (pd.help) {
|
||
html += '<div class="mt-2 text-center"><button onclick="stkMenuRespond(\'help\')" class="text-xs underline text-gray-500 dark:text-slate-400">' + esc(t('Help')) + '</button></div>';
|
||
}
|
||
return html;
|
||
}
|
||
|
||
// '4–16 characters · digits 0-9 * # + · hidden entry' (pure).
|
||
function stkInputHint(pd) {
|
||
pd = pd || {};
|
||
const parts = [];
|
||
const lo = Number(pd.min) || 0;
|
||
const hi = Number(pd.max) || 0;
|
||
const hasHi = hi && hi !== 0xFF;
|
||
if (pd.type === 'get_inkey') {
|
||
parts.push(t('one character'));
|
||
} else if (lo && hasHi) {
|
||
parts.push(lo + '\u2013' + hi + ' ' + t('characters'));
|
||
} else if (hasHi) {
|
||
parts.push(t('up to') + ' ' + hi + ' ' + t('characters'));
|
||
} else if (lo) {
|
||
parts.push(t('At least') + ' ' + lo + ' ' + t('characters'));
|
||
}
|
||
if (pd.digits_only || pd.hidden) parts.push(t('digits 0-9 * # +'));
|
||
if (pd.hidden) parts.push(t('hidden entry'));
|
||
return parts.join(' \u00b7 ');
|
||
}
|
||
|
||
// Validate the typed answer against the card's request (pure; mirrors the
|
||
// server rules so the panel can explain before sending).
|
||
function stkInputValidate(pd, value) {
|
||
pd = pd || {};
|
||
const v = value == null ? '' : String(value);
|
||
const digits = /^[0-9*#+]*$/;
|
||
if (pd.type === 'get_inkey') {
|
||
if (v.length !== 1) return { ok: false, error: t('Enter exactly one character') };
|
||
if (pd.digits_only && !digits.test(v)) return { ok: false, error: t('Digits only (0-9, *, #, +)') };
|
||
return { ok: true };
|
||
}
|
||
const lo = Number(pd.min) || 0;
|
||
const hi = Number(pd.max) || 0;
|
||
if (v.length < lo) return { ok: false, error: t('At least') + ' ' + lo + ' ' + t('characters') };
|
||
if (hi && hi !== 0xFF && v.length > hi) return { ok: false, error: t('At most') + ' ' + hi + ' ' + t('characters') };
|
||
if ((pd.digits_only || pd.hidden) && !digits.test(v)) return { ok: false, error: t('Digits only (0-9, *, #, +)') };
|
||
return { ok: true };
|
||
}
|
||
|
||
let _stkInputPending = null;
|
||
|
||
// Send the typed (or Yes/No) answer; the server validates again and builds
|
||
// the Text string TERMINAL RESPONSE.
|
||
async function stkInputSubmit(text) {
|
||
const pd = _stkInputPending || {};
|
||
if (text !== '01' && text !== '00') {
|
||
const field = document.getElementById('stk-input-field');
|
||
const value = field ? field.value : '';
|
||
const v = stkInputValidate(pd, value);
|
||
if (!v.ok) {
|
||
document.getElementById('stk-menu-panel-content').insertAdjacentHTML('beforeend',
|
||
'<div class="text-xs text-red-500 mt-1">' + esc(v.error) + '</div>');
|
||
return;
|
||
}
|
||
text = value;
|
||
}
|
||
const content = document.getElementById('stk-menu-panel-content');
|
||
content.innerHTML = '<span class="text-gray-500 text-sm">' + esc(t('Sending...')) + '</span>';
|
||
try {
|
||
const data = await pysimFetch('/api/menu-respond', { result: 'ok', text: text });
|
||
stkMenuHandleResponse(data);
|
||
} catch (e) {
|
||
content.innerHTML = '<span class="text-red-500 text-sm">Error: ' + esc(e.message) + '</span>';
|
||
}
|
||
}
|
||
|
||
// The OK button: submit the pending input, otherwise the plain 'ok' answer.
|
||
function stkMenuOk() {
|
||
if (_stkInputPending && (_stkInputPending.type === 'get_input' || _stkInputPending.type === 'get_inkey')) {
|
||
stkInputSubmit(null);
|
||
return;
|
||
}
|
||
stkMenuRespond('ok');
|
||
}
|
||
|
||
// Immediate digit response (GET INKEY qualifier bit 4): pass the first
|
||
// character on without asking for confirmation.
|
||
function stkInputImmediate() {
|
||
const field = document.getElementById('stk-input-field');
|
||
if (field && field.value.length >= 1) stkInputSubmit(null);
|
||
}
|
||
|
||
function stkMenuHandleResponse(data) {
|
||
const content = document.getElementById('stk-menu-panel-content');
|
||
const backBtn = document.getElementById('stk-back-btn');
|
||
const btns = document.getElementById('stk-menu-buttons');
|
||
_stkInputPending = null;
|
||
btns.classList.add('hidden');
|
||
if (data.type === 'display_text') {
|
||
content.innerHTML = '<div class="text-sm whitespace-pre-wrap p-2 bg-gray-50 dark:bg-slate-700 rounded">' + esc(data.text) + '</div>';
|
||
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 = stkMenuItemsHtml(data.items, 'stkSubItemClick');
|
||
backBtn.style.display = '';
|
||
document.getElementById('stk-ok-btn').style.display = 'none';
|
||
btns.classList.remove('hidden');
|
||
} else if (data.type === 'get_input' || data.type === 'get_inkey') {
|
||
_stkInputPending = data;
|
||
content.innerHTML = stkInputHtml(data);
|
||
backBtn.style.display = '';
|
||
document.getElementById('stk-ok-btn').style.display = data.yes_no ? 'none' : '';
|
||
btns.classList.remove('hidden');
|
||
} else {
|
||
content.innerHTML = '<span class="text-gray-500 text-sm">SW: ' + esc(data.sw || '?') + '</span>';
|
||
stkSetPillsDisabled(false);
|
||
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>';
|
||
}
|
||
}
|
||
|
||
// Close the pending-command panel when the server answered the command by
|
||
// itself (the menu watchdog's timeout TERMINAL RESPONSE, or a new selection
|
||
// that shadowed it): the dialog can no longer be answered (v3.6.35).
|
||
function stkMenuPanelReset() {
|
||
_stkInputPending = null;
|
||
const panel = document.getElementById('stk-menu-panel');
|
||
if (panel) panel.classList.add('hidden');
|
||
const btns = document.getElementById('stk-menu-buttons');
|
||
if (btns) btns.classList.add('hidden');
|
||
const back = document.getElementById('stk-back-btn');
|
||
if (back) back.style.display = 'none';
|
||
}
|
||
|
||
// Open the panel for a pending command the server paused on by itself (a
|
||
// card-initiated GET INPUT / SELECT ITEM): the reply to our own click renders
|
||
// through stkMenuHandleResponse instead.
|
||
function stkMenuShowPending(pd) {
|
||
document.getElementById('stk-menu-panel-title').textContent = document.getElementById('stk-menu-title').textContent;
|
||
stkSetPillsDisabled(true);
|
||
stkMenuStack = [];
|
||
document.getElementById('stk-back-btn').style.display = 'none';
|
||
document.getElementById('stk-menu-panel').classList.remove('hidden');
|
||
stkMenuHandleResponse(pd);
|
||
}
|
||
|
||
// The /api/menu-respond reply for a command the server already answered.
|
||
function stkNoPendingError(data) {
|
||
return !!(data && typeof data.error === 'string'
|
||
&& data.error.indexOf('no pending command') >= 0);
|
||
}
|
||
|
||
async function stkMenuRespond(result) {
|
||
try {
|
||
const data = await pysimFetch('/api/menu-respond', { result: result });
|
||
if (stkNoPendingError(data)) {
|
||
// Already answered (timeout / shadowed): close the stale panel
|
||
stkMenuPanelReset();
|
||
stkCheckMenu();
|
||
return;
|
||
}
|
||
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 = [];
|
||
let _cardsEditIdx = null;
|
||
|
||
function cardsLoad() {
|
||
// Presets live on the server now (v3.8.0): /api/presets fills this array
|
||
// on connect. localStorage is deliberately ignored - the Cards tab's
|
||
// export/import (the same JSON shape) moves old browser presets over.
|
||
cards = [];
|
||
cardsRebuildSelect();
|
||
cardsRender();
|
||
}
|
||
|
||
// Load the presets from the server store. Called on connect, after every
|
||
// Cards-tab mutation and when the tab is opened; operations keep the local
|
||
// copy in step through the counter they report back.
|
||
async function cardsFetch() {
|
||
try {
|
||
const data = await pysimFetchOk('/api/presets');
|
||
cards = Array.isArray(data.presets) ? data.presets : [];
|
||
} catch (e) {
|
||
return false; // server optional at this point; the tab fills on connect
|
||
}
|
||
cardsRebuildSelect();
|
||
cardsRender();
|
||
tarRender();
|
||
scp81PushPskMap();
|
||
spPresetWarningRender();
|
||
return true;
|
||
}
|
||
|
||
function cardsSave() {
|
||
// Nothing to persist here: the server store is the source of truth (the
|
||
// Cards tab writes through /api/presets*, operations persist their own
|
||
// accepted counters). Kept as the "the list changed" hook for the
|
||
// selects, the table and the SCP81 PSK map.
|
||
cardsRebuildSelect();
|
||
cardsRender();
|
||
tarRender();
|
||
scp81PushPskMap();
|
||
}
|
||
|
||
// The card form's PSK fields feed the SCP81 PSK map: the listener selects the
|
||
// key by the identity the card presents in the TLS handshake.
|
||
function cardsPskMap() {
|
||
const out = [];
|
||
for (const c of cards) {
|
||
const id = (c.pskIdentity || '').trim();
|
||
const key = (c.pskKey || '').replace(/\s/g, '');
|
||
if (id && /^[0-9a-fA-F]+$/.test(key) && key.length % 2 === 0) {
|
||
out.push({identity: id, psk_hex: key});
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function cardsPskName(identity) {
|
||
const c = cards.find(x => (x.pskIdentity || '') === identity);
|
||
return c ? c.name : '';
|
||
}
|
||
|
||
// Spec-default TARs (GP ISD 000000, TS 31.116 UICC B00000, TS 31.115 USIM
|
||
// B00001): an empty preset value means the default for that target.
|
||
function cardsTarValue(key, value) {
|
||
const defaults = { tar: '000000', uiccTar: 'B00000', usimTar: 'B00001' };
|
||
return value || defaults[key] || '';
|
||
}
|
||
|
||
// ===== OTA keysets (one per keyset number) =====
|
||
// A preset carries several keysets: a keyset's number is the b8..b5 nibble of KIc/KID
|
||
// (TS 102 225 5.1.2/A.2). The version is implicit in the bytes - the "key
|
||
// index" - and each keyset has its own counter on the card ("a dedicated
|
||
// counter shall be associated to each key version", Annex A.1). A packet may
|
||
// use any keyset the preset defines; '00'/'00' means no security (A.2)
|
||
// and needs no keyset.
|
||
|
||
// The keyset number of a KIc/KID byte, or null when it is not a hex byte.
|
||
function spKeysetKvnOf(hex) {
|
||
const s = String(hex || '').trim().toUpperCase();
|
||
if (!/^[0-9A-F]{2}$/.test(s)) return null;
|
||
return (parseInt(s, 16) >> 4) & 0x0F;
|
||
}
|
||
|
||
// True when the packet asks the card to check the counter (TS 102 225 5.1.1,
|
||
// SPI1.b5b4 = 01/10/11). With b5b4 = 00 the field is "present, ignored,
|
||
// never updated": a keyless SPI1 0x00 packet leaves every counter untouched,
|
||
// so the form counter must not advance either.
|
||
function spCounterTracked(spi1Hex) {
|
||
const v = parseInt(String(spi1Hex || ''), 16);
|
||
if (isNaN(v)) return false;
|
||
return ((v >> 3) & 0x03) !== 0;
|
||
}
|
||
|
||
// The keysets of a preset (a v3.8.0 flat preset converts to a single one).
|
||
function spKeysetList(preset) {
|
||
if (!preset) return [];
|
||
if (Array.isArray(preset.keysets) && preset.keysets.length) return preset.keysets;
|
||
if (preset.kic || preset.kid) {
|
||
return [{ kic: preset.kic, kid: preset.kid, kicKey: preset.kicKey,
|
||
kidKey: preset.kidKey, cntr: preset.cntr }];
|
||
}
|
||
return [];
|
||
}
|
||
|
||
// The keyset of a preset with the given number, or null.
|
||
function spKeysetFor(preset, kvn) {
|
||
return spKeysetList(preset).find(ks => spKeysetKvnOf(ks.kic) === kvn) || null;
|
||
}
|
||
|
||
// The packet's key-version rules (pure): '' when the pair is usable, else the
|
||
// reason. With a preset, the (non-zero) keyset number must be defined in it -
|
||
// a packet may use any keyset, but only one the preset defines.
|
||
function spKeysetCheck(preset, kicHex, kidHex) {
|
||
const kic = String(kicHex || '').trim().toUpperCase();
|
||
const kid = String(kidHex || '').trim().toUpperCase();
|
||
for (const pair of [['KIc', kic], ['KID', kid]]) {
|
||
if (pair[1] && !/^[0-9A-F]{2}$/.test(pair[1])) {
|
||
return t(pair[0]) + ' ' + t('must be one hex byte') + ': ' + pair[1];
|
||
}
|
||
}
|
||
const kicV = spKeysetKvnOf(kic), kidV = spKeysetKvnOf(kid);
|
||
if (kicV && kidV && kicV !== kidV) {
|
||
return t('KIc and KID must carry the same keyset number (TS 102 225 A.2)') + ': ' + kic + ' / ' + kid;
|
||
}
|
||
const kvn = kicV || kidV || 0;
|
||
if (!kvn) return ''; // '00'/'00': no security
|
||
if (preset && !spKeysetFor(preset, kvn)) {
|
||
return t('Keyset') + ' ' + kvn + ' ' + t('is not defined in the preset') +
|
||
(preset.name ? ' \u2014 ' + preset.name : '');
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// The <option> list of a keyset selector: the preset's keysets, plus the key
|
||
// version currently in the form when the preset does not define it.
|
||
function spKeysetOptionsHtml(preset, kicHex, kidHex) {
|
||
const kvn = spKeysetKvnOf(kicHex) || spKeysetKvnOf(kidHex) || 0;
|
||
let html = '';
|
||
let found = false;
|
||
spKeysetList(preset).forEach(ks => {
|
||
const v = spKeysetKvnOf(ks.kic);
|
||
if (v === null) return;
|
||
if (v === kvn) found = true;
|
||
html += '<option value="' + v + '"' + (v === kvn ? ' selected' : '') + '>' +
|
||
'Keyset ' + v + ' \u2014 ' + esc(ks.kic || '??') + '/' + esc(ks.kid || '??') +
|
||
' \u2014 ' + esc(t('counter')) + ' ' + esc(ks.cntr || '') + '</option>';
|
||
});
|
||
if (kvn && !found) {
|
||
html += '<option value="' + kvn + '" selected>Keyset ' + kvn + ' \u2014 ' +
|
||
esc(t('not defined in the preset')) + '</option>';
|
||
}
|
||
if (!html) html = '<option value="">' + esc(t('\u2014 no keyset \u2014')) + '</option>';
|
||
return html;
|
||
}
|
||
|
||
// ===== Keyset editor (Cards form) =====
|
||
let _cardsKeysetCount = 0;
|
||
|
||
function cardsKeysetRowHtml(ks, i) {
|
||
const cls = 'border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono';
|
||
const kvn = spKeysetKvnOf(ks.kic);
|
||
return '<div class="flex gap-2 items-center mb-1">' +
|
||
'<span id="cards-ks-' + i + '-kvn" class="w-10 text-xs font-mono text-center text-gray-500 dark:text-slate-400" title="' + esc(t('Keyset number (b8..b5 of KIc/KID)')) + '">' + (kvn === null ? '?' : kvn) + '</span>' +
|
||
'<input id="cards-ks-' + i + '-kic" class="w-12 ' + cls + ' text-center" maxlength="2" placeholder="KIc" value="' + esc(ks.kic || '') + '" oninput="cardsKeysetKvnUpdate(' + i + ')">' +
|
||
'<input id="cards-ks-' + i + '-kid" class="w-12 ' + cls + ' text-center" maxlength="2" placeholder="KID" value="' + esc(ks.kid || '') + '" oninput="cardsKeysetKvnUpdate(' + i + ')">' +
|
||
'<input id="cards-ks-' + i + '-kic-key" class="flex-1 ' + cls + '" placeholder="KIc key (hex)" value="' + esc(ks.kicKey || '') + '">' +
|
||
'<input id="cards-ks-' + i + '-kid-key" class="flex-1 ' + cls + '" placeholder="KID key (hex)" value="' + esc(ks.kidKey || '') + '">' +
|
||
'<input id="cards-ks-' + i + '-cntr" class="w-24 ' + cls + '" maxlength="10" placeholder="Counter" value="' + esc(ks.cntr || '') + '">' +
|
||
'<button type="button" onclick="cardsKeysetRemove(' + i + ')" class="px-2 py-1 text-xs rounded bg-red-600 text-white hover:bg-red-700" title="' + esc(t('Remove keyset')) + '">\u2715</button>' +
|
||
'</div>';
|
||
}
|
||
|
||
function cardsKeysetRowsRender(keysets) {
|
||
const box = document.getElementById('cards-keysets');
|
||
if (!box) return;
|
||
const list = (keysets && keysets.length)
|
||
? keysets
|
||
: [{ kic: '', kid: '', kicKey: '', kidKey: '', cntr: '' }];
|
||
_cardsKeysetCount = list.length;
|
||
box.innerHTML = list.map((ks, i) => cardsKeysetRowHtml(ks, i)).join('');
|
||
}
|
||
|
||
function cardsKeysetAdd() {
|
||
cardsKeysetRowsRender(cardsKeysetsFromForm().concat([
|
||
{ kic: '', kid: '', kicKey: '', kidKey: '', cntr: '' }]));
|
||
}
|
||
|
||
function cardsKeysetRemove(i) {
|
||
const rows = cardsKeysetsFromForm();
|
||
rows.splice(i, 1);
|
||
cardsKeysetRowsRender(rows);
|
||
}
|
||
|
||
function cardsKeysetKvnUpdate(i) {
|
||
const el = document.getElementById('cards-ks-' + i + '-kvn');
|
||
if (!el) return;
|
||
const kic = (document.getElementById('cards-ks-' + i + '-kic') || {}).value;
|
||
const kid = (document.getElementById('cards-ks-' + i + '-kid') || {}).value;
|
||
const a = spKeysetKvnOf(kic), b = spKeysetKvnOf(kid);
|
||
const bad = (a !== null && b !== null && a !== b) || a === 0 || b === 0;
|
||
el.textContent = a === null ? '?' : a;
|
||
el.classList.toggle('text-red-500', !!bad);
|
||
el.title = bad ? t('KIc and KID must carry the same keyset number (TS 102 225 A.2)')
|
||
: t('Keyset number (b8..b5 of KIc/KID)');
|
||
}
|
||
|
||
// The keyset rows as preset entries (empty rows are dropped).
|
||
function cardsKeysetsFromForm() {
|
||
const out = [];
|
||
for (let i = 0; i < _cardsKeysetCount; i++) {
|
||
const get = id => {
|
||
const el = document.getElementById('cards-ks-' + i + '-' + id);
|
||
return el ? String(el.value).trim() : '';
|
||
};
|
||
const ks = { kic: get('kic').toUpperCase(), kid: get('kid').toUpperCase(),
|
||
kicKey: get('kic-key').toUpperCase(), kidKey: get('kid-key').toUpperCase(),
|
||
cntr: get('cntr').toUpperCase() };
|
||
if (!ks.kic && !ks.kid && !ks.kicKey && !ks.kidKey) continue;
|
||
out.push(ks);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ===== Keyset selectors (SCP80 / RAM) =====
|
||
// The selector follows the KIc/KID bytes: a keyset of the selected preset that
|
||
// matches their keyset number is selected; a number the preset does not define
|
||
// shows as "not defined" (and the packet actions refuse it).
|
||
function spKeysetSync() {
|
||
const kic = (document.getElementById('sp-kic-hex') || {}).value;
|
||
const kid = (document.getElementById('sp-kid-hex') || {}).value;
|
||
// A keyset only exists inside a card preset: with no preset selected the
|
||
// selector is disabled and cleared, so a stale "not defined in the preset"
|
||
// entry can never linger.
|
||
const sync = (selId, idxFn) => {
|
||
const sel = document.getElementById(selId);
|
||
if (!sel) return;
|
||
const idx = idxFn();
|
||
const preset = idx >= 0 ? cards[idx] : null;
|
||
if (!preset) {
|
||
sel.disabled = true;
|
||
sel.innerHTML = '<option value="">' + esc(t('\u2014 select a card preset \u2014')) + '</option>';
|
||
return;
|
||
}
|
||
sel.disabled = false;
|
||
sel.innerHTML = spKeysetOptionsHtml(preset, kic, kid);
|
||
};
|
||
sync('sp-keyset-sel', spPresetIdx);
|
||
sync('ram-keyset-sel', ramPresetIdx);
|
||
sync('tar-keyset-sel', tarPresetIdx);
|
||
// The start buttons follow the same state: they wait for a preset and a
|
||
// keyset defined in it.
|
||
pysimApplyAvailability();
|
||
}
|
||
|
||
function spKeysetApply(ks) {
|
||
if (!ks) return;
|
||
const set = (id, value) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.value = value;
|
||
};
|
||
const kic = parseInt(ks.kic, 16), kid = parseInt(ks.kid, 16);
|
||
set('sp-kic-idx', ((kic >> 4) & 0x0F).toString(16).toUpperCase());
|
||
set('sp-kic-alg', (kic & 0x0F).toString(16).padStart(2, '0').toUpperCase());
|
||
set('sp-kid-idx', ((kid >> 4) & 0x0F).toString(16).toUpperCase());
|
||
set('sp-kid-alg', (kid & 0x0F).toString(16).padStart(2, '0').toUpperCase());
|
||
updateSpKic();
|
||
updateSpKid();
|
||
set('sp-kic-key', ks.kicKey || '');
|
||
set('sp-kid-key', ks.kidKey || '');
|
||
set('sp-cntr', ks.cntr || '');
|
||
spInvalidate();
|
||
updateSp();
|
||
}
|
||
|
||
function spKeysetChanged() {
|
||
const sel = document.getElementById('sp-keyset-sel');
|
||
if (!sel) return;
|
||
const kvn = parseInt(sel.value, 10);
|
||
const idx = spPresetIdx();
|
||
const ks = (idx >= 0 && !isNaN(kvn)) ? spKeysetFor(cards[idx], kvn) : null;
|
||
if (ks) spKeysetApply(ks);
|
||
spKeysetSync();
|
||
}
|
||
|
||
function ramKeysetChanged() {
|
||
const sel = document.getElementById('ram-keyset-sel');
|
||
if (!sel) return;
|
||
const kvn = parseInt(sel.value, 10);
|
||
const idx = ramPresetIdx();
|
||
const ks = (idx >= 0 && !isNaN(kvn)) ? spKeysetFor(cards[idx], kvn) : null;
|
||
if (ks) spKeysetApply(ks);
|
||
spKeysetSync();
|
||
}
|
||
|
||
// A warning for the SCP80/RAM views when the equipped card and the selected
|
||
// preset disagree (pure). Autoselection keeps them in step, so this covers
|
||
// what it cannot fix: a card with no preset at all, or a manually selected
|
||
// preset that belongs to another card. Returns '' when all is well (or when
|
||
// there is no card session / no readable ICCID to compare against).
|
||
function spPresetWarning(iccid, presets, selectedIdx) {
|
||
const list = presets || [];
|
||
const norm = cardsNormIccid(iccid);
|
||
if (!norm) return '';
|
||
const matched = list.findIndex(c => cardsNormIccid(c.iccid) === norm);
|
||
if (matched < 0) {
|
||
if (!list.length) {
|
||
return t('No card presets defined — the SCP80/RAM views have no keys '
|
||
+ 'for the equipped card. Add one in the Cards tab.');
|
||
}
|
||
return t('The equipped card') + ' ' + iccid + ' ' +
|
||
t('does not match any card preset — the selected preset may belong to '
|
||
+ 'another card. Add it in the Cards tab (From card).');
|
||
}
|
||
const sel = Number(selectedIdx);
|
||
if (Number.isInteger(sel) && sel >= 0 && sel < list.length && sel !== matched) {
|
||
return t('The selected preset is not the equipped card\'s') + ' (' +
|
||
(list[matched].name || iccid) + ') — ' +
|
||
t('check that its keys and counters belong to this card.');
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// Render the banner in both SCP80 views (each has its own preset selection).
|
||
function spPresetWarningRender() {
|
||
const iccid = _pysimCardIccid;
|
||
const show = (id, text) => {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
el.textContent = text || '';
|
||
el.classList.toggle('hidden', !text);
|
||
};
|
||
show('sp-preset-warning', spPresetWarning(iccid, cards, spPresetIdx()));
|
||
show('ram-preset-warning', spPresetWarning(iccid, cards, ramPresetIdx()));
|
||
show('tar-preset-warning', spPresetWarning(iccid, cards, tarPresetIdx()));
|
||
}
|
||
|
||
// The RAM view's preset index.
|
||
function ramPresetIdx() {
|
||
const sel = document.getElementById('ram-card-sel');
|
||
const idx = sel ? parseInt(sel.value, 10) : NaN;
|
||
return (!isNaN(idx) && cards[idx]) ? idx : -1;
|
||
}
|
||
|
||
// '' when the SCP80 form may build a packet, else the reason. With a preset
|
||
// selected the keyset number must be one of its keysets; without one only the
|
||
// byte rules apply (hand-entered keys stay possible, a workbench flow).
|
||
function spKeysetGuard() {
|
||
const idx = spPresetIdx();
|
||
const kic = (document.getElementById('sp-kic-hex') || {}).value;
|
||
const kid = (document.getElementById('sp-kid-hex') || {}).value;
|
||
return spKeysetCheck(idx >= 0 ? cards[idx] : null, kic, kid);
|
||
}
|
||
|
||
function ramKeysetGuard() {
|
||
const idx = ramPresetIdx();
|
||
const kic = (document.getElementById('sp-kic-hex') || {}).value;
|
||
const kid = (document.getElementById('sp-kid-hex') || {}).value;
|
||
return spKeysetCheck(idx >= 0 ? cards[idx] : null, kic, kid);
|
||
}
|
||
|
||
// Whether a view's start button may run: its preset must be selected and the
|
||
// form's keyset number one of that preset's keysets (the click-time guards keep
|
||
// the byte-level checks). '00'/'00' (no security) with a preset selected stays
|
||
// allowed - it is a deliberate form choice.
|
||
function spKeysetStartReady(view) {
|
||
const idx = view === 'ram' ? ramPresetIdx() : (view === 'tar' ? tarPresetIdx() : spPresetIdx());
|
||
if (idx < 0) return false;
|
||
const kic = (document.getElementById('sp-kic-hex') || {}).value;
|
||
const kid = (document.getElementById('sp-kid-hex') || {}).value;
|
||
return spKeysetCheck(cards[idx], kic, kid) === '';
|
||
}
|
||
|
||
function cardsFormValues() {
|
||
const val = id => {
|
||
const el = document.getElementById('cards-' + id);
|
||
return el ? el.value.trim() : '';
|
||
};
|
||
return {
|
||
name: val('name'),
|
||
iccid: val('iccid'),
|
||
adm: val('adm').replace(/\s/g, '').toUpperCase(),
|
||
spi1: val('spi1'),
|
||
spi2: val('spi2'),
|
||
tar: val('tar'),
|
||
uiccTar: val('uicc-tar'),
|
||
usimTar: val('usim-tar'),
|
||
pskIdentity: val('psk-id'),
|
||
pskKey: val('psk-key').replace(/\s/g, '').toUpperCase(),
|
||
keysets: cardsKeysetsFromForm(),
|
||
};
|
||
}
|
||
|
||
function cardsSetFormMode() {
|
||
const btn = document.getElementById('cards-add-btn');
|
||
if (btn) btn.textContent = _cardsEditIdx === null ? t('Add') : t('Save');
|
||
const cancel = document.getElementById('cards-cancel-btn');
|
||
if (cancel) cancel.classList.toggle('hidden', _cardsEditIdx === null);
|
||
}
|
||
|
||
function cardsClearForm() {
|
||
['name','iccid','adm','spi1','spi2','psk-id','psk-key'].forEach(id => {
|
||
const el = document.getElementById('cards-' + id);
|
||
if (el) el.value = '';
|
||
});
|
||
// TARs keep their per-target spec defaults instead of being emptied
|
||
[['tar','tar'],['uicc-tar','uiccTar'],['usim-tar','usimTar']].forEach(([id, key]) => {
|
||
const el = document.getElementById('cards-' + id);
|
||
if (el) el.value = cardsTarValue(key, '');
|
||
});
|
||
cardsKeysetRowsRender([]);
|
||
}
|
||
|
||
async function cardsAdd() {
|
||
const v = cardsFormValues();
|
||
// empty TAR fields fall back to the per-target spec defaults
|
||
v.tar = cardsTarValue('tar', v.tar);
|
||
v.uiccTar = cardsTarValue('uiccTar', v.uiccTar);
|
||
v.usimTar = cardsTarValue('usimTar', v.usimTar);
|
||
if (!v.name) { alert(t('Name is required')); return; }
|
||
if (!v.keysets.length) {
|
||
alert(t('Define at least one keyset (KIc/KID and their keys)'));
|
||
return;
|
||
}
|
||
for (const ks of v.keysets) {
|
||
if (!ks.kic || !ks.kid || !ks.kicKey || !ks.kidKey) {
|
||
alert(t('Each keyset needs KIc, KID and both keys'));
|
||
return;
|
||
}
|
||
const ksErr = spKeysetCheck(null, ks.kic, ks.kid);
|
||
if (ksErr) { alert(ksErr); return; }
|
||
if (!spKeysetKvnOf(ks.kic) || !spKeysetKvnOf(ks.kid)) {
|
||
alert(t('Keyset 00 means "no security" and is not a preset keyset'));
|
||
return;
|
||
}
|
||
}
|
||
const kvns = v.keysets.map(ks => spKeysetKvnOf(ks.kic));
|
||
if (new Set(kvns).size !== kvns.length) {
|
||
alert(t('Duplicate keyset number in the keysets'));
|
||
return;
|
||
}
|
||
if ((v.pskKey && !v.pskIdentity) || (!v.pskKey && v.pskIdentity)) {
|
||
alert(t('PSK identity and PSK key must be set together'));
|
||
return;
|
||
}
|
||
if (v.pskKey && !/^[0-9A-F]{32}$/.test(v.pskKey)) {
|
||
alert(t('PSK key must be 32 hex characters'));
|
||
return;
|
||
}
|
||
const dupIdx = cardsFindDuplicateIccid(v.iccid, _cardsEditIdx);
|
||
if (dupIdx >= 0) {
|
||
alert(t('Card with this ICCID already exists') + ': ' + (cards[dupIdx].name || ('Card ' + dupIdx)));
|
||
return;
|
||
}
|
||
// The server store is the source of truth (v3.8.0): create or update
|
||
// through the API, then reload - it also enforces the duplicate-ICCID rule.
|
||
const editing = (_cardsEditIdx !== null && cards[_cardsEditIdx]) ? cards[_cardsEditIdx] : null;
|
||
try {
|
||
if (editing) await pysimFetchOk('/api/presets/update', { id: editing.id, fields: v });
|
||
else await pysimFetchOk('/api/presets', v);
|
||
} catch (e) {
|
||
alert(t('Save error') + ': ' + e.message);
|
||
return;
|
||
}
|
||
await cardsFetch();
|
||
cardsCancelEdit();
|
||
}
|
||
|
||
function cardsEdit(i) {
|
||
const c = cards[i];
|
||
if (!c) return;
|
||
_cardsEditIdx = i;
|
||
const set = (id, value) => {
|
||
const el = document.getElementById('cards-' + id);
|
||
if (el) el.value = value == null ? '' : value;
|
||
};
|
||
set('name', c.name); set('iccid', c.iccid); set('adm', c.adm);
|
||
set('spi1', c.spi1); set('spi2', c.spi2);
|
||
set('tar', cardsTarValue('tar', c.tar));
|
||
set('uicc-tar', cardsTarValue('uiccTar', c.uiccTar));
|
||
set('usim-tar', cardsTarValue('usimTar', c.usimTar));
|
||
set('psk-id', c.pskIdentity); set('psk-key', c.pskKey);
|
||
cardsKeysetRowsRender(spKeysetList(c));
|
||
cardsSetFormMode();
|
||
document.getElementById('cards-name').focus();
|
||
}
|
||
|
||
function cardsCancelEdit() {
|
||
_cardsEditIdx = null;
|
||
cardsSetFormMode();
|
||
cardsClearForm();
|
||
}
|
||
|
||
function ramCardIdxAfterRemove(idx, removedIdx) {
|
||
if (idx === null || idx === undefined || idx < 0) return null;
|
||
if (idx === removedIdx) return null;
|
||
return idx > removedIdx ? idx - 1 : idx;
|
||
}
|
||
|
||
async function cardsRemove(i) {
|
||
const c = cards[i];
|
||
if (!c) return;
|
||
try {
|
||
await pysimFetchOk('/api/presets/delete', { id: c.id });
|
||
} catch (e) {
|
||
alert(t('Delete error') + ': ' + e.message);
|
||
return;
|
||
}
|
||
_ramCardIdx = ramCardIdxAfterRemove(_ramCardIdx, i);
|
||
if (_cardsEditIdx === i) _cardsEditIdx = null;
|
||
else if (_cardsEditIdx !== null && _cardsEditIdx > i) _cardsEditIdx--;
|
||
await cardsFetch();
|
||
}
|
||
|
||
function cardsRender() {
|
||
const tbody = document.getElementById('cards-tbody');
|
||
if (!tbody) return;
|
||
if (!cards.length) {
|
||
tbody.innerHTML = '<tr><td colspan="8" class="py-2 text-gray-400">' + t('No cards defined.') + '</td></tr>';
|
||
return;
|
||
}
|
||
// The preset of the equipped card (normalised ICCID match): its row is
|
||
// highlighted so the list shows which preset belongs to the card in the
|
||
// reader (no highlight while the ICCID is unknown).
|
||
const equippedIdx = cardsFindByIccid(_pysimCardIccid);
|
||
let html = '';
|
||
for (let i = 0; i < cards.length; i++) {
|
||
const c = cards[i];
|
||
const equipped = i === equippedIdx;
|
||
let ota = '<span class="text-gray-400">—</span>';
|
||
if (cardsScp81Complete(c)) {
|
||
ota = '<span class="text-emerald-600 dark:text-emerald-400" title="' + esc(c.pskIdentity) + '">✓</span>';
|
||
} else if (c.pskKey || c.pskIdentity) {
|
||
ota = '<span class="text-amber-500" title="' + esc(t('PSK identity and PSK key must be set together')) + '">⚠</span>';
|
||
}
|
||
html += '<tr class="border-b border-gray-100 dark:border-slate-700' +
|
||
(equipped ? ' bg-emerald-50 dark:bg-emerald-900/30' : '') + '">';
|
||
html += '<td class="py-1 px-2 font-medium">' +
|
||
(equipped ? '<span class="mr-1 text-emerald-600 dark:text-emerald-400" title="' +
|
||
esc(t("The equipped card's preset")) + '">●</span>' : '') +
|
||
esc(c.name) + '</td>';
|
||
html += '<td class="py-1 px-2 font-mono text-xs">' + esc(c.iccid) + '</td>';
|
||
const ksText = spKeysetList(c).map(ks => {
|
||
const kvn = spKeysetKvnOf(ks.kic);
|
||
return (kvn === null ? '?' : kvn) + ': ' + esc(ks.kic || '??') + '/' + esc(ks.kid || '??') +
|
||
' \u2014 ' + esc(ks.cntr || '');
|
||
}).join(' \u00b7 ') || '\u2014';
|
||
html += '<td class="py-1 px-2 font-mono text-xs">' + ksText + '</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" title="ISD / UICC RFM / ADF RFM">' + esc(cardsTarValue('tar', c.tar)) + ' ' + esc(cardsTarValue('uiccTar', c.uiccTar)) + ' ' + esc(cardsTarValue('usimTar', c.usimTar)) + '</td>';
|
||
html += '<td class="py-1 px-2 text-center">' + ota + '</td>';
|
||
html += '<td class="py-1 px-2 whitespace-nowrap"><button onclick="cardsEdit(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700">' + t('Edit') + '</button> '
|
||
+ '<button onclick="cardsRemove(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + t('Remove') + '</button></td>';
|
||
html += '</tr>';
|
||
}
|
||
tbody.innerHTML = html;
|
||
}
|
||
|
||
function cardsRebuildSelect() {
|
||
const sel = document.getElementById('sp-card-sel');
|
||
if (!sel) return;
|
||
// Keep the current selection: cardsSave() runs after every send (counter
|
||
// sync) and an innerHTML rebuild otherwise resets the dropdown to its
|
||
// first option, forcing a manual re-select each time (fixed 2.1.18).
|
||
const prev = sel.value;
|
||
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;
|
||
if (prev !== '' && cards[parseInt(prev, 10)]) sel.value = prev;
|
||
}
|
||
|
||
// ===== ICCID-based preset auto-selection =====
|
||
// After every equip /api/status carries the card's EF.ICCID (2FE2); when a
|
||
// preset holds the same number it is selected in both SCP80 views (Secured
|
||
// packet + RAM). Users may store the ICCID as digits or as the raw EF hex,
|
||
// so both forms are normalized to plain digits.
|
||
function cardsNormIccid(v) {
|
||
let s = String(v == null ? '' : v).trim().toUpperCase().replace(/[\s-]/g, '');
|
||
if (!s) return '';
|
||
if (/[A-F]/.test(s)) s = decIccid(s); // raw EF content (nibble-swapped)
|
||
return String(s || '').replace(/\D/g, '').replace(/^0+/, '');
|
||
}
|
||
|
||
function cardsFindByIccid(iccid) {
|
||
const norm = cardsNormIccid(iccid);
|
||
if (!norm) return -1;
|
||
return cards.findIndex(c => cardsNormIccid(c.iccid) === norm);
|
||
}
|
||
|
||
// Completeness predicates used by the header indicators: SCP80 needs all
|
||
// SPI/keys/counter fields, SCP81 the PSK pair; the ADM key is optional.
|
||
function cardsScp80Complete(c) {
|
||
return !!(c && String(c.kic || '').trim() && String(c.kid || '').trim()
|
||
&& String(c.spi1 || '').trim() && String(c.spi2 || '').trim()
|
||
&& String(c.cntr || '').trim() && String(c.kicKey || '').trim() && String(c.kidKey || '').trim());
|
||
}
|
||
|
||
function cardsScp81Complete(c) {
|
||
return !!(c && String(c.pskIdentity || '').trim() && String(c.pskKey || '').trim());
|
||
}
|
||
|
||
function cardsAdmPresent(c) {
|
||
return !!(c && String(c.adm || '').trim());
|
||
}
|
||
|
||
// The preset whose ICCID matches the equipped card (null when none/unreadable).
|
||
function cardsMatchedPreset(iccid) {
|
||
const value = iccid === undefined ? _pysimCardIccid : iccid;
|
||
const idx = value ? cardsFindByIccid(value) : -1;
|
||
return idx >= 0 ? cards[idx] : null;
|
||
}
|
||
|
||
function cardsFindDuplicateIccid(iccid, skipIdx) {
|
||
// Duplicate detection ignores the edited row and compares normalized
|
||
// values (digits, spaced digits or raw EF hex all mean the same card).
|
||
const norm = cardsNormIccid(iccid);
|
||
if (!norm) return -1;
|
||
return cards.findIndex((c, i) => i !== skipIdx && cardsNormIccid(c.iccid) === norm);
|
||
}
|
||
|
||
function cardsIccidFromCard() {
|
||
// Fill the form's ICCID from the equipped card (status.iccid). The button
|
||
// is only enabled while a readable ICCID is known; alert just in case the
|
||
// state changed between the last poll and the click.
|
||
const iccid = _pysimCardIccid;
|
||
if (!iccid) { alert(t('Card equipped but its ICCID is not readable')); return; }
|
||
document.getElementById('cards-iccid').value = iccid;
|
||
}
|
||
|
||
let _cardsAutoIccid = null;
|
||
|
||
function cardsAutoSelectByIccid(iccid) {
|
||
// Act only when the equip reports a *new* ICCID: a manual preset choice
|
||
// for the same card must not be overridden by later status updates.
|
||
const norm = cardsNormIccid(iccid);
|
||
if (!norm) { _cardsAutoIccid = null; return -1; }
|
||
if (norm === _cardsAutoIccid) return -1;
|
||
_cardsAutoIccid = norm;
|
||
const idx = cardsFindByIccid(norm);
|
||
if (idx < 0) return -1;
|
||
const sp = document.getElementById('sp-card-sel');
|
||
if (sp) sp.value = String(idx);
|
||
const ram = document.getElementById('ram-card-sel');
|
||
if (ram) ram.value = String(idx);
|
||
const tar = document.getElementById('tar-card-sel');
|
||
if (tar) tar.value = String(idx);
|
||
_ramCardIdx = idx;
|
||
cardsApply(String(idx));
|
||
return idx;
|
||
}
|
||
|
||
// Fill the SP form from the preset (no packet generation): the form is a
|
||
// working copy, the preset stays the source of truth. `keepTar` leaves the
|
||
// packet TAR alone: the plain SCP80 send re-reads only the preset-owned
|
||
// counter/keys/SPI, a manually entered target TAR (push/link triggers,
|
||
// applet OTA) must survive.
|
||
function cardsApplyFields(idx, keepTar) {
|
||
const c = cards[parseInt(idx)];
|
||
if (!c) return false;
|
||
document.getElementById('sp-spi1').value = c.spi1;
|
||
// Sync the SPI2 selects with the preset byte (low 5 bits = PoR/security,
|
||
// bit 0x20 = PoR via SMS-SUBMIT): updateSp() recomputes sp-spi2-hex from
|
||
// them, so without this the preset value was clobbered on every refresh.
|
||
const spi2Val = parseInt(c.spi2, 16) || 0;
|
||
const baseHex = (spi2Val & 0x1F).toString(16).padStart(2, '0').toUpperCase();
|
||
const baseSel = document.getElementById('sp-spi2');
|
||
if (baseSel) {
|
||
if (baseSel.options && typeof Option === 'function' &&
|
||
!Array.prototype.some.call(baseSel.options, o => o.value === baseHex)) {
|
||
baseSel.appendChild(new Option(baseHex + ' — preset', baseHex));
|
||
}
|
||
baseSel.value = baseHex;
|
||
}
|
||
const smSel = document.getElementById('sp-spi2-sm');
|
||
if (smSel) smSel.value = String((spi2Val >> 5) & 1);
|
||
document.getElementById('sp-spi2-hex').value = c.spi2;
|
||
// The keyset: keep the keyset number the form carries when this preset
|
||
// defines it, otherwise start from the preset's first keyset.
|
||
const kicNow = (document.getElementById('sp-kic-hex') || {}).value;
|
||
const kidNow = (document.getElementById('sp-kid-hex') || {}).value;
|
||
const kvnNow = spKeysetKvnOf(kicNow) || spKeysetKvnOf(kidNow) || 0;
|
||
const ks = (kvnNow && spKeysetFor(c, kvnNow)) || spKeysetList(c)[0] || null;
|
||
if (ks) {
|
||
const kicByte = parseInt(ks.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(ks.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-cntr').value = ks.cntr || '';
|
||
document.getElementById('sp-kic-key').value = ks.kicKey || '';
|
||
document.getElementById('sp-kid-key').value = ks.kidKey || '';
|
||
}
|
||
if (!keepTar) document.getElementById('sp-tar').value = cardsTarValue(_spTarKey, c[_spTarKey]);
|
||
spInvalidate();
|
||
updateSp();
|
||
spKeysetSync();
|
||
spPresetWarningRender();
|
||
return true;
|
||
}
|
||
|
||
function cardsApply(idx, keepTar) {
|
||
if (!cardsApplyFields(idx, keepTar)) {
|
||
// "— Select card —": there is no keyset to choose, and the preset
|
||
// warning must not outlive the selection it described.
|
||
spKeysetSync();
|
||
spPresetWarningRender();
|
||
return;
|
||
}
|
||
_genSpBuild();
|
||
spShowSizeInfo();
|
||
}
|
||
|
||
// SCP80/RAM operations re-read the selected preset before starting: the
|
||
// Cards tab (or a counter write-back) may have changed it after the form was
|
||
// filled, and a stale counter is rejected by the card (cntr_low) and would
|
||
// then be written back over the preset. Returns the preset counter ('' when
|
||
// no preset is selected).
|
||
function spRefreshFromPreset(selId, keepTar) {
|
||
const sel = document.getElementById(selId || 'sp-card-sel');
|
||
const idx = sel ? parseInt(sel.value, 10) : NaN;
|
||
if (isNaN(idx) || !cards[idx]) return '';
|
||
if (selId === 'ram-card-sel') _spTarKey = 'tar';
|
||
cardsApply(idx, keepTar);
|
||
return cards[idx].cntr;
|
||
}
|
||
|
||
// A send counts as accepted when there is no PoR to check (the SPI requests
|
||
// none) or the card reports success: por_ok, or actual_response_sms_submit
|
||
// (0x0B - the real response follows as an SMS-SUBMIT; the packet was still
|
||
// consumed and the counter must advance). Anything else (cntr_low, PoR
|
||
// error) leaves the counter untouched.
|
||
function spPorAccepted(por) {
|
||
return !por || por.response_status === 'por_ok' ||
|
||
por.response_status === 'actual_response_sms_submit';
|
||
}
|
||
|
||
// The card's low-counter verdict of a send response (v3.6.58). Every further
|
||
// packet with a counter <= the card's is rejected again, so the operation must
|
||
// stop and the preset counter must be raised - a retry cannot recover it.
|
||
// Returns true for a cntr_low verdict, false otherwise. No value is reported:
|
||
// the PoR's CNTR is a copy of the command's counter (TS 102 225 5.2, table 3)
|
||
// and a rejection leaves it zeroed, so the card's counter is not observable -
|
||
// the server's counter probe or a manual raise are the ways forward.
|
||
function spCntrLow(res) {
|
||
if (!res) return false;
|
||
const por = res.por || null;
|
||
const status = (por && por.response_status) || '';
|
||
return !!(res.cntr_low || status === 'cntr_low');
|
||
}
|
||
|
||
// Remote-command status words that count as success (mirrors the server's
|
||
// `_ram_remote_sw_ok`): 9000 normal; 61xx more data available; 62xx/63xx
|
||
// warnings; CAFE GlobalPlatform "more data available" (GET STATUS pages).
|
||
// True for the two expanded command formats (TS 102 226 5.2.1). The expanded
|
||
// format does not use GET RESPONSE (5.2.1.1): a command may never be chained
|
||
// there - the reference TCA loader sends the same P2=00 listing queries
|
||
// wrapped and unchained (Le='00' returns the listing directly), while the
|
||
// live card serves them compact with the chain. The listing builders follow
|
||
// the run's command format for exactly that reason.
|
||
function ramFormatIsExpanded(fmt) {
|
||
return fmt === 'expanded' || fmt === 'expanded-ae';
|
||
}
|
||
|
||
// The command-format ladder of one expanded-registry form (pure): the run's
|
||
// format first (null = no override), then the two expanded codings and
|
||
// compact as the last resort - cards differ in which one they serve (the live
|
||
// ISD refuses the compact P2=02 query and answers it wrapped). A format that
|
||
// already produced data (`working`) is tried first and never repeated.
|
||
function ramFmtLadder(runFmt, working) {
|
||
const ladder = [null, 'expanded', 'expanded-ae', 'compact'].filter((v, i, a) =>
|
||
a.indexOf(v) === i && (v === null || v !== runFmt));
|
||
if (!working) return ladder;
|
||
return [working].concat(ladder.filter(f => f !== null && f !== working));
|
||
}
|
||
|
||
function ramRemoteSwOk(sw) {
|
||
const s = (sw || '').toUpperCase();
|
||
return !!s && (s === '9000' || s === 'CAFE' ||
|
||
['61', '62', '63'].indexOf(s.slice(0, 2)) >= 0);
|
||
}
|
||
|
||
// GET STATUS APDU for the compact listing format: P2.b2=0 (GP Card Spec
|
||
// v2.3.1 11.4.2.2) with the mandatory '4F00' search criterion (all
|
||
// occurrences) and a chained GET RESPONSE (00C0000000) in the same secured
|
||
// payload. The expanded TLV format (P2.b2=1, P2=02/03) must NOT carry a GET
|
||
// RESPONSE - it returns the E3 templates directly - so this builder is only
|
||
// for the compact path.
|
||
function ramGetStatusApdu(p1, p2, fmt) {
|
||
// The chained GET RESPONSE exists in the compact command format only: the
|
||
// expanded format must not carry one (TS 102 226 5.2.1.1), and the
|
||
// reference TCA loader sends the same queries wrapped and unchained
|
||
// (`AA0A2208 80F24000024F0000`; Le='00' returns the listing directly).
|
||
// `fmt` is the run's command format; omitted = compact (the default the
|
||
// live card needs).
|
||
const query = '80F2' + p1 + p2 + '024F0000';
|
||
return ramFormatIsExpanded(fmt) ? query : query + 'C0000000';
|
||
}
|
||
|
||
// P2=02 expanded (E3) registry listing (GP Card Spec 11.4.2.3/11.4.3.1).
|
||
// Used as the fallback when a card rejects the deprecated compact format and
|
||
// for the details pass (on by default: it carries the package versions, the
|
||
// SD/load-file associations and the applet menu entries).
|
||
//
|
||
// Cards differ in what they accept, so the query is tried in four forms:
|
||
// 'tags' - the reference trace tag list (`4F9F70C5C4CCCEEA`), the richest
|
||
// 'noea' - the same list without the TS 102 226 'EA' SCP Registry Data
|
||
// 'lean' - only the objects valid for the kind: the load file version and
|
||
// modules for ELFs (the app-only C5/C4 make strict cards answer
|
||
// 6A88), pySim's field list (AID, life cycle, privileges, SD AID)
|
||
// for the ISD/apps
|
||
// 'notags' - no tag list (GPPro's GET STATUS data field `4F00` only): the
|
||
// card builds the default E3 (AID + life cycle + privileges or
|
||
// modules; cards commonly include the load file version anyway)
|
||
// The ELF forms add '84' (Executable Module AIDs, GP Table 11-37): with a tag
|
||
// list present the card returns only the listed tags, so without it the
|
||
// expanded fallback would list packages without their modules (no module rows
|
||
// / Install buttons on cards that reject the compact form).
|
||
const RAM_EXPANDED_FORMS = ['tags', 'noea', 'lean', 'notags'];
|
||
|
||
// The tag list of one form for a listing ('' = the no-tag-list form).
|
||
function ramExpandedTags(p1, form) {
|
||
const elf = p1 === '10' || p1 === '20';
|
||
if (form === 'notags') return '';
|
||
if (form === 'lean') return elf ? '4F9F70CE84' : '4F9F70C5CC';
|
||
const base = form === 'noea' ? '4F9F70C5C4CCCE' : '4F9F70C5C4CCCEEA';
|
||
return base + (elf ? '84' : '');
|
||
}
|
||
|
||
// `next` = the next-occurrence page (P2.b1=1, GP 11.4.3.2) after a '6310';
|
||
// `aidHex` = an AID search qualifier (`4F <len> <AID>`, GP 11.4.2.3
|
||
// Table 11-35) that selects a single registry entity - used to fetch one
|
||
// applet's entry when the listing cannot be paged (v3.7.0).
|
||
function ramExpandedQueryApdu(p1, form, next, aidHex) {
|
||
const tags = ramExpandedTags(p1, form || 'tags');
|
||
const p2 = next ? '03' : '02';
|
||
const aid = (aidHex || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
const hex = n => n.toString(16).toUpperCase().padStart(2, '0');
|
||
const filter = aid ? '4F' + hex(aid.length / 2) + aid : '4F00';
|
||
const lc = filter.length / 2 + (tags ? tags.length / 2 + 2 : 0);
|
||
return '80F2' + p1 + p2 + hex(lc) + filter +
|
||
(tags ? '5C' + hex(tags.length / 2) + tags : '') + '00';
|
||
}
|
||
|
||
// A '61xx' remote answer means the card holds the response data. The live
|
||
// card (2026-09-30) rejects a standalone GET RESPONSE with 6700 - the GET
|
||
// RESPONSE must ride in the same compact command string as the query, exactly
|
||
// like the compact path does (`80F2..C0000000`). Only the compact command
|
||
// string can chain commands; the expanded AA/22 format has no retry.
|
||
function ramChainGetResponse(apdu) {
|
||
return apdu + 'C0000000';
|
||
}
|
||
|
||
// The ISD (80) and memory responses fit the ENVELOPE; the Apps (40) and ELF
|
||
// (20/10) listings can exceed it, and the card then delivers the actual
|
||
// response as an SMS-SUBMIT - those queries must request the PoR in submit
|
||
// mode (SPI2=0x21). With SPI2=0x01 the card answers the envelope PoR with
|
||
// actual_response_sms_submit and the data never arrives (live 2026-09-28:
|
||
// "Partial - Apps: actual_response_sms_submit").
|
||
function ramListingSpi2(p1) {
|
||
return (p1 === '40' || p1 === '20' || p1 === '10') ? '21' : '01';
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// Transient status line used by the file import handlers (Cards, custom files).
|
||
function ioStatus(id, text) {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
el.textContent = text;
|
||
el.classList.remove('hidden');
|
||
clearTimeout(el._hideTimer);
|
||
el._hideTimer = setTimeout(() => el.classList.add('hidden'), 3000);
|
||
}
|
||
|
||
function cardsExportFile() {
|
||
downloadJson('simple-cards.json', cards);
|
||
}
|
||
|
||
async function cardsImport(text) {
|
||
let imported;
|
||
try {
|
||
imported = JSON.parse(text);
|
||
if (!Array.isArray(imported)) throw new Error('Expected an array');
|
||
} catch (e) {
|
||
alert('Import error: ' + e.message);
|
||
return;
|
||
}
|
||
try {
|
||
// The server validates and normalises the entries (the old localStorage
|
||
// export shape included) and reports what it skipped.
|
||
const res = await pysimFetchOk('/api/presets/import', { presets: imported });
|
||
await cardsFetch();
|
||
ioStatus('cards-io', 'Imported ' + res.added + ' cards'
|
||
+ (res.skipped ? ' (' + res.skipped + ' skipped)' : '') + '.');
|
||
} 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);
|
||
}
|
||
|
||
// ===== 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);
|
||
if (name === 'files') pysimFsFitTree();
|
||
}
|
||
|
||
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 getParentPath(node) {
|
||
const segs = [];
|
||
let n = node && node.parent;
|
||
while (n) {
|
||
if (n.name === 'MF') segs.unshift('MF');
|
||
else if (n.name && n.name.startsWith('ADF.')) segs.unshift(n.name);
|
||
else segs.unshift(n.fid || n.name);
|
||
n = n.parent;
|
||
}
|
||
return segs;
|
||
}
|
||
|
||
// Canonical path of a tree node from MF or an ADF root, matching the custom
|
||
// file paths ("MF/7F20/6F46", "ADF.USIM/6F07").
|
||
function pysimFsNodePath(node) {
|
||
const segs = [];
|
||
let n = node;
|
||
while (n) {
|
||
if (n.name === 'MF') { segs.unshift('MF'); break; }
|
||
if (n.name && n.name.startsWith('ADF.')) { segs.unshift(n.name); break; }
|
||
segs.unshift(((n.fid || n.name) || '').toUpperCase());
|
||
n = n.parent;
|
||
}
|
||
return segs.join('/');
|
||
}
|
||
|
||
function pysimCustomInject(node) {
|
||
if (!node) return;
|
||
const basePath = pysimFsNodePath(node);
|
||
for (const c of pysimCustomFiles) {
|
||
if (pysimCustomParent(c) !== basePath) continue;
|
||
const fid = pysimCustomFid(c);
|
||
const existing = (node.children || []).find(ch => (ch.fid || '').toUpperCase() === fid);
|
||
if (existing) {
|
||
if (existing.modelName === undefined) existing.modelName = existing.name;
|
||
existing.name = c.name;
|
||
existing.kind = c.kind;
|
||
existing.custom = true;
|
||
existing.customPath = c.path;
|
||
} else {
|
||
node.children = node.children || [];
|
||
node.children.push({
|
||
name: c.name,
|
||
fid: fid,
|
||
isDir: c.kind === 'df',
|
||
children: null,
|
||
expanded: false,
|
||
exists: null,
|
||
custom: true,
|
||
customPath: c.path,
|
||
parent: node,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Re-apply the custom-file list to the already-loaded tree: drop injected
|
||
// nodes whose entry is gone, restore renamed model nodes, inject anew.
|
||
function pysimCustomRefreshTree() {
|
||
if (!pysimFsTreeRoot) return;
|
||
const prune = node => {
|
||
if (!node.children) return;
|
||
node.children = node.children.filter(ch => {
|
||
if (!ch.custom) return true;
|
||
if (pysimCustomFiles.some(c => c.path === ch.customPath)) return true;
|
||
if (ch.modelName !== undefined) {
|
||
ch.name = ch.modelName;
|
||
delete ch.modelName;
|
||
delete ch.custom;
|
||
delete ch.customPath;
|
||
delete ch.kind;
|
||
return true;
|
||
}
|
||
return false;
|
||
});
|
||
};
|
||
const walk = node => {
|
||
prune(node);
|
||
pysimCustomInject(node);
|
||
for (const ch of (node.children || [])) {
|
||
if (ch.isDir) walk(ch);
|
||
}
|
||
};
|
||
walk(pysimFsTreeRoot);
|
||
pysimFsRenderTree();
|
||
}
|
||
|
||
let pysimFsSort = localStorage.getItem('simple_fs_sort') === 'name' ? 'name' : 'fid';
|
||
|
||
function pysimFsSortChildren(children, mode) {
|
||
return (children || []).slice().sort((a, b) => {
|
||
if (!!a.isDir !== !!b.isDir) return a.isDir ? -1 : 1;
|
||
const af = (a.fid || '').toUpperCase();
|
||
const bf = (b.fid || '').toUpperCase();
|
||
const an = (a.name || '').toUpperCase();
|
||
const bn = (b.name || '').toUpperCase();
|
||
let r;
|
||
if (mode === 'name') {
|
||
const ak = an || af;
|
||
const bk = bn || bf;
|
||
r = ak < bk ? -1 : ak > bk ? 1 : 0;
|
||
if (r === 0) r = af < bf ? -1 : af > bf ? 1 : 0;
|
||
} else {
|
||
r = af < bf ? -1 : af > bf ? 1 : 0;
|
||
if (r === 0) r = an < bn ? -1 : an > bn ? 1 : 0;
|
||
}
|
||
return r;
|
||
});
|
||
}
|
||
|
||
function pysimFsSetSort(mode) {
|
||
pysimFsSort = mode === 'name' ? 'name' : 'fid';
|
||
localStorage.setItem('simple_fs_sort', pysimFsSort);
|
||
document.querySelectorAll('.pysim-fs-sort-pill').forEach(p => {
|
||
const active = p.dataset.fsSort === pysimFsSort;
|
||
p.classList.toggle('bg-blue-600', active);
|
||
p.classList.toggle('text-white', active);
|
||
p.classList.toggle('bg-gray-200', !active);
|
||
p.classList.toggle('dark:bg-slate-700', !active);
|
||
p.classList.toggle('text-gray-700', !active);
|
||
p.classList.toggle('dark:text-slate-300', !active);
|
||
});
|
||
pysimFsRenderTree();
|
||
}
|
||
|
||
async function pysimFsRefresh() {
|
||
const detailEl = document.getElementById('pysim-fs-detail');
|
||
detailEl.classList.add('hidden');
|
||
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 {
|
||
const body = { name: node.name, fid: node.fid, parent_sel: getParentSel(node), parent_path: getParentPath(node) };
|
||
if (node.custom) body.allow_probe = true;
|
||
const data = await pysimFetch('/api/tree', body);
|
||
if (!data || data.exists === false || data.success === false || data.error) {
|
||
throw new Error((data && data.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;
|
||
pysimFsRenderTree();
|
||
}
|
||
}
|
||
|
||
let _pysimFsProbe = null;
|
||
|
||
function pysimFsSelectBody(node) {
|
||
const body = { name: node.name, fid: node.fid, parent_sel: getParentSel(node), parent_path: getParentPath(node) };
|
||
if (node.custom) body.allow_probe = true;
|
||
return body;
|
||
}
|
||
|
||
function pysimFsProbeUi(text, stopLabel) {
|
||
const status = document.getElementById('pysim-fs-probe-status');
|
||
if (status) {
|
||
const wasVisible = !status.classList.contains('hidden');
|
||
status.textContent = text;
|
||
status.classList.toggle('hidden', !text);
|
||
if (wasVisible !== !text) pysimFsFitTree();
|
||
}
|
||
const btn = document.getElementById('pysim-fs-probe-btn');
|
||
if (btn) {
|
||
btn.textContent = stopLabel ? t('Stop') : t('Probe all files');
|
||
btn.setAttribute('data-l10n', stopLabel ? 'Stop' : 'Probe all files');
|
||
}
|
||
}
|
||
|
||
async function pysimFsProbeAll() {
|
||
if (_pysimFsProbe) { _pysimFsProbe.stop = true; return; }
|
||
if (!pysimFsTreeRoot) return;
|
||
const probe = { stop: false, dirs: 0, done: 0, total: 0, present: 0, absent: 0 };
|
||
_pysimFsProbe = probe;
|
||
const started = Date.now();
|
||
let error = null;
|
||
const count = exists => { if (exists) probe.present++; else probe.absent++; };
|
||
try {
|
||
if (!pysimFsTreeRoot.children) await pysimFsLoadChildren(pysimFsTreeRoot);
|
||
const discover = async node => {
|
||
for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
|
||
if (probe.stop) return;
|
||
if (!child.isDir) continue;
|
||
await pysimFsLoadChildren(child);
|
||
const exists = child.exists === true;
|
||
probe.dirs++;
|
||
count(exists);
|
||
pysimFsRenderTree();
|
||
pysimFsProbeUi(t('Probing:') + ' ' + t('discovering') + ' (' + probe.dirs + ')', true);
|
||
if (exists) await discover(child);
|
||
}
|
||
};
|
||
await discover(pysimFsTreeRoot);
|
||
const files = [];
|
||
const collect = node => {
|
||
for (const child of (node.children || [])) {
|
||
if (child.isDir) { if (child.exists === true) collect(child); }
|
||
else files.push(child);
|
||
}
|
||
};
|
||
collect(pysimFsTreeRoot);
|
||
probe.total = files.length + probe.dirs;
|
||
probe.done = probe.dirs;
|
||
for (const file of files) {
|
||
if (probe.stop) break;
|
||
const data = await pysimFetch('/api/select', pysimFsSelectBody(file));
|
||
const exists = !!(data && data.exists !== false && !data.error);
|
||
file.exists = exists;
|
||
count(exists);
|
||
probe.done++;
|
||
pysimFsProbeUi(t('Probing:') + ' ' + probe.done + '/' + probe.total, true);
|
||
if (probe.done % 10 === 0 || probe.done === probe.total) pysimFsRenderTree();
|
||
}
|
||
} catch (e) {
|
||
error = (e && e.message) ? e.message : String(e);
|
||
}
|
||
pysimFsRenderTree();
|
||
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
||
let summary = t('Probed:') + ' ' + probe.done + '/' + probe.total + ' ' + t('files') + ' — '
|
||
+ probe.present + ' ' + t('present') + ', ' + probe.absent + ' ' + t('absent') + ' (' + secs + 's)';
|
||
if (error) summary = t('Error') + ': ' + error + ' — ' + summary;
|
||
else if (probe.stop) summary = t('Stopped') + ' — ' + summary;
|
||
_pysimFsProbe = null;
|
||
pysimFsProbeUi(summary, false);
|
||
}
|
||
|
||
function pysimFsFitTree() {
|
||
// Cap the tree to the space actually left below its top edge, minus a
|
||
// small gap, so the page itself never grows a scrollbar (the inline
|
||
// 65vh style is only the no-JS fallback).
|
||
const tree = document.getElementById('pysim-fs-tree');
|
||
const box = tree && tree.parentElement;
|
||
if (!box || box.offsetParent === null) return;
|
||
const top = box.getBoundingClientRect().top + window.scrollY;
|
||
const avail = Math.round(window.innerHeight - top - 12);
|
||
box.style.maxHeight = Math.max(200, avail) + 'px';
|
||
}
|
||
|
||
function pysimFsRenderTree() {
|
||
const el = document.getElementById('pysim-fs-tree');
|
||
if (!pysimFsTreeRoot) { el.innerHTML = '(loading)'; return; }
|
||
el.innerHTML = pysimFsRenderNode(pysimFsTreeRoot, 0);
|
||
pysimFsFitTree();
|
||
}
|
||
|
||
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) {
|
||
if (!node.children.length) {
|
||
html += '<div class="text-gray-400 dark:text-slate-500">' + pad + ' ' + esc(t('(empty)')) + '</div>';
|
||
} else {
|
||
for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
|
||
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;
|
||
}
|
||
|
||
// Decoder availability for the selected file, resolved exactly like the
|
||
// decoded view does (name from the tree node, FID fallback) — the content is
|
||
// not needed, so the Read decoded pill can be gated before any read.
|
||
function pysimFsHasDecoder() {
|
||
if (!pysimFsSelected) return false;
|
||
const node = pysimFsFindNode(pysimFsSelected, pysimFsTreeRoot);
|
||
const fid = (node && node.fid) || (/^[0-9a-fA-F]{4}$/.test(pysimFsSelected) ? pysimFsSelected : null);
|
||
return !!efFindDecoder(node && node.name, fid);
|
||
}
|
||
|
||
// Find a node by its canonical path ("MF/7F20/6F46", "ADF.USIM/6F07"), so
|
||
// custom-file parents can be resolved against the loaded tree (standard DFs
|
||
// included, at any depth). Returns null when the path is not loaded yet.
|
||
function pysimFsFindNodeByPath(path) {
|
||
const want = pysimCustomNormPath(path);
|
||
if (!want || !pysimFsTreeRoot) return null;
|
||
const walk = node => {
|
||
if (pysimCustomNormPath(pysimFsNodePath(node)) === want) return node;
|
||
for (const c of (node.children || [])) {
|
||
const hit = walk(c);
|
||
if (hit) return hit;
|
||
}
|
||
return null;
|
||
};
|
||
return walk(pysimFsTreeRoot);
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
let pysimFsSelected = null;
|
||
|
||
function pysimFsInfoHtml(sel, name) {
|
||
if (!sel) return '';
|
||
const num = v => (v === null || v === undefined) ? null : String(v);
|
||
const attrs = [];
|
||
if (sel.fid) attrs.push('FID: ' + esc(String(sel.fid).toUpperCase()));
|
||
if (sel.file_type) attrs.push(esc(t('File type')) + ': ' + esc(sel.file_type));
|
||
if (num(sel.file_size) !== null) attrs.push(esc(t('Size')) + ': ' + esc(num(sel.file_size)));
|
||
if (num(sel.record_len) !== null) attrs.push(esc(t('Record length')) + ': ' + esc(num(sel.record_len)));
|
||
if (num(sel.num_of_rec) !== null) attrs.push(esc(t('Record count')) + ': ' + esc(num(sel.num_of_rec)));
|
||
const metaCls = 'text-xs font-mono text-gray-500 dark:text-slate-400';
|
||
if (!sel.fci_hex) {
|
||
return attrs.length ? '<div class="' + metaCls + '">' + attrs.join(' · ') + '</div>' : '';
|
||
}
|
||
// One bordered block per selected file: the metadata line and the decoded
|
||
// FCI together, with the symbolic name embedded in the border.
|
||
return '<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-2">' +
|
||
(name ? '<legend class="px-1 text-xs font-mono font-medium text-gray-500 dark:text-slate-400">' + esc(name) + '</legend>' : '') +
|
||
(attrs.length ? '<div class="' + metaCls + ' mb-0.5">' + attrs.join(' · ') + '</div>' : '') +
|
||
'<div class="text-xs font-mono leading-relaxed text-gray-600 dark:text-slate-400 break-all">' +
|
||
profilerFciPreviewItems(sel.fci_hex, { hideFileSize: num(sel.file_size) !== null }) +
|
||
'</div></fieldset>';
|
||
}
|
||
|
||
async function pysimFsClickFile(name) {
|
||
pysimFsResetEdit();
|
||
pysimFsSetMode('raw');
|
||
const node = pysimFsFindNode(name, pysimFsTreeRoot);
|
||
const sel = await pysimFetch('/api/select', pysimFsSelectBody(node || { name: name, fid: null }));
|
||
const exists = !!(sel && sel.exists !== false && !sel.error);
|
||
if (node) {
|
||
node.exists = exists;
|
||
if (!exists) node.children = [];
|
||
pysimFsRenderTree();
|
||
}
|
||
pysimFsSelected = name;
|
||
document.getElementById('pysim-fs-info').innerHTML = exists ? pysimFsInfoHtml(sel, name) : '';
|
||
document.getElementById('pysim-fs-content').innerHTML = '';
|
||
pysimFsSyncContentVisibility();
|
||
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');
|
||
}
|
||
pysimApplyAvailability();
|
||
pysimRefresh();
|
||
}
|
||
|
||
// The content pane only appears once a read or edit has something to show;
|
||
// an empty container would just be an empty box.
|
||
function pysimFsSyncContentVisibility() {
|
||
const out = document.getElementById('pysim-fs-content');
|
||
if (out) out.classList.toggle('hidden', !out.innerHTML.trim());
|
||
}
|
||
|
||
async function pysimFsRead() {
|
||
if (!pysimFsSelected) return false;
|
||
const rawName = pysimFsSelected;
|
||
const out = document.getElementById('pysim-fs-content');
|
||
const statusEl = document.getElementById('pysim-fs-status');
|
||
out.innerHTML = '';
|
||
statusEl.textContent = t('Loading...');
|
||
try {
|
||
const node = pysimFsFindNode(rawName, pysimFsTreeRoot);
|
||
const body = pysimFsSelectBody(node || { name: rawName, fid: null });
|
||
body.mode = 'raw'; // decoding is client-side (works offline in snapshots too)
|
||
const data = await pysimFetch('/api/read', body);
|
||
if (!data.success) {
|
||
pysimFsShowError(statusEl, data.sw, data.error);
|
||
return false;
|
||
}
|
||
statusEl.textContent = 'SW: ' + data.sw + ' OK';
|
||
if (pysimFsDecodedMode) {
|
||
out.innerHTML = pysimFsDecodedHtml(node || { name: rawName, fid: null }, data);
|
||
} 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>';
|
||
}
|
||
return true;
|
||
} catch (e) {
|
||
statusEl.textContent = 'Error: ' + e.message;
|
||
return false;
|
||
} finally {
|
||
pysimFsSyncContentVisibility();
|
||
}
|
||
}
|
||
|
||
// Decoded view of a read result: label/value fields from the client-side EF
|
||
// decoders, with the server-side pySim JSON behind a disclosure.
|
||
function pysimFsDecodedHtml(node, data) {
|
||
const name = (node && node.name) || null;
|
||
const fid = (node && node.fid) || (/^[0-9a-fA-F]{4}$/.test(pysimFsSelected || '') ? pysimFsSelected : null);
|
||
const content = data.records ? { kind: 'record', records: data.records } : { kind: 'transparent', data: data.data };
|
||
const dec = efDecodeFile(node && node.name, fid, content);
|
||
let html = '';
|
||
if (!dec || !dec.fn) {
|
||
html += '<div class="text-xs text-yellow-600 dark:text-yellow-500 mb-1">' + esc(t('No decoder for this file')) + ' — ' + esc(t('showing raw data')) + '</div>';
|
||
if (data.records) {
|
||
for (const r of data.records) {
|
||
html += '<div class="flex items-center gap-1 mb-1"><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" value="' + esc(r.data) + '" readonly></div>';
|
||
}
|
||
} else {
|
||
html += '<textarea rows="8" readonly class="w-full font-mono text-xs border-0 bg-transparent resize-none">' + esc(data.data || '') + '</textarea>';
|
||
}
|
||
return html;
|
||
}
|
||
if (dec.records) {
|
||
for (const r of dec.records) {
|
||
html += '<div class="mb-2">' +
|
||
'<div class="text-xs text-gray-500 dark:text-slate-400">' + esc(t('Record')) + ' ' + r.num + (r.empty ? ' — ' + esc(t('empty')) : '') + '</div>' +
|
||
efRenderFieldsHtml(efFlatten(r.data)) + '</div>';
|
||
}
|
||
} else {
|
||
if (dec.empty) html += '<div class="text-xs text-gray-500 dark:text-slate-400 mb-1">' + esc(t('empty')) + '</div>';
|
||
html += efRenderFieldsHtml(efFlatten(dec.data));
|
||
}
|
||
html += '<details class="mt-2" ontoggle="if (this.open) pysimFsLoadPySimJson()"><summary class="text-xs text-gray-400 dark:text-slate-500 cursor-pointer">' + esc(t('pySim JSON (server)')) + '</summary>' +
|
||
'<div id="pysim-fs-pysim-json" class="text-xs font-mono mt-1 text-gray-600 dark:text-slate-400 break-all"></div></details>';
|
||
return html;
|
||
}
|
||
|
||
async function pysimFsLoadPySimJson() {
|
||
const el = document.getElementById('pysim-fs-pysim-json');
|
||
if (!el) return;
|
||
el.textContent = t('Loading...');
|
||
try {
|
||
const rawName = pysimFsSelected;
|
||
const node = pysimFsFindNode(rawName, pysimFsTreeRoot);
|
||
const body = pysimFsSelectBody(node || { name: rawName, fid: null });
|
||
body.mode = 'decoded';
|
||
const data = await pysimFetch('/api/read', body);
|
||
if (data && data.decoded) el.textContent = JSON.stringify(data.decoded, null, 1);
|
||
else if (data && data.data) el.textContent = data.data;
|
||
else el.textContent = (data && data.error) || t('No response yet');
|
||
} catch (e) {
|
||
el.textContent = 'Error: ' + e.message;
|
||
}
|
||
}
|
||
|
||
let pysimFsEditMode = false;
|
||
let pysimFsEditData = null;
|
||
|
||
// Edit raw: transparent files become an editable textarea, record files
|
||
// per-record inputs with selection checkboxes. The decoded view is a
|
||
// read-only field table and a freshly selected file has no content yet, so
|
||
// make sure the editable raw view is loaded first (the pills are read
|
||
// actions; Edit switches back to raw and reads on demand).
|
||
async function pysimFsEdit() {
|
||
const out = document.getElementById('pysim-fs-content');
|
||
if (pysimFsDecodedMode || !out.innerHTML.trim()) {
|
||
pysimFsSetMode('raw', true);
|
||
if (!await pysimFsRead()) return;
|
||
}
|
||
pysimFsEditMode = true;
|
||
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');
|
||
pysimFsPillsEnabled(false);
|
||
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;
|
||
pysimFsSyncContentVisibility();
|
||
}
|
||
|
||
function pysimFsCancel() {
|
||
pysimFsEditMode = false;
|
||
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');
|
||
pysimFsPillsEnabled(true);
|
||
const out = document.getElementById('pysim-fs-content');
|
||
if (pysimFsEditData) out.innerHTML = pysimFsEditData;
|
||
pysimFsEditData = null;
|
||
pysimFsSyncContentVisibility();
|
||
}
|
||
|
||
async function pysimFsSave() {
|
||
const rawName = pysimFsSelected;
|
||
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 = pysimFsSelectBody(node || { name: rawName, fid: null });
|
||
body.data = textareas[0].value.replace(/[^0-9a-fA-F]/g, '');
|
||
const data = await pysimFetch('/api/write', body);
|
||
if (data.success) {
|
||
statusEl.textContent = 'SW: ' + data.sw + ' OK';
|
||
pysimFsCancel();
|
||
pysimFsRead();
|
||
} else {
|
||
pysimFsShowError(statusEl, data.sw, data.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 = pysimFsSelectBody(node || { name: rawName, fid: null });
|
||
body.data = inp.value.replace(/[^0-9a-fA-F]/g, '');
|
||
body.record_nr = parseInt(num);
|
||
const data = await pysimFetch('/api/write', body);
|
||
if (!data.success) {
|
||
pysimFsShowError(statusEl, 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;
|
||
}
|
||
}
|
||
|
||
// The Read raw / Read decoded pills are the read actions: they switch the
|
||
// view mode and (re-)read the selected file. `skipRead` updates only the
|
||
// pills — used by pysimFsEdit() right before its own read. While editing
|
||
// they are inert: re-rendering would discard unsaved changes.
|
||
function pysimFsSetMode(mode, skipRead) {
|
||
if (pysimFsEditMode) return;
|
||
if (mode === 'dec' && !pysimFsHasDecoder()) return;
|
||
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);
|
||
});
|
||
if (!skipRead && pysimFsSelected) pysimFsRead();
|
||
}
|
||
|
||
// Visual cue for the inert pills while editing (pysimFsSetMode guards the
|
||
// click; this only dims them).
|
||
function pysimFsPillsEnabled(enabled) {
|
||
document.querySelectorAll('.pysim-mode-pill').forEach(el => {
|
||
el.classList.toggle('opacity-50', !enabled);
|
||
el.classList.toggle('pointer-events-none', !enabled);
|
||
});
|
||
}
|
||
|
||
function pysimFsResetEdit() {
|
||
pysimFsEditMode = false;
|
||
pysimFsEditData = null;
|
||
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');
|
||
pysimFsPillsEnabled(true);
|
||
}
|
||
|
||
// ===== proactive UICC events =====
|
||
// Event list codings (TS 102 223 v18.3.0 8.25). The values the CAT spec only
|
||
// points at 3GPP for carry their TS 31.111 7.5 event name; "Reserved for
|
||
// 3GPP" is a spec cross-reference, not a usage restriction, so it never
|
||
// appears in the user-facing names.
|
||
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 (single access technology)',
|
||
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: 'Access technology change (multiple access technologies)',
|
||
0x15: 'CSG cell selection',
|
||
0x16: 'Contactless state request',
|
||
0x17: 'IMS registration',
|
||
0x18: 'Incoming IMS data',
|
||
0x19: 'Profile container', 0x1A: 'Void', 0x1B: 'Secured profile container',
|
||
0x1C: 'Poll interval negotiation',
|
||
0x1D: 'Data connection status change',
|
||
0x1E: 'CAG cell selection',
|
||
0x1F: 'Slices status change',
|
||
0x20: 'Reserved (future usage)',
|
||
0x21: 'Reserved (future usage)',
|
||
0x22: 'Reserved (future usage)',
|
||
};
|
||
|
||
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'},
|
||
];
|
||
|
||
// Access Technology coding (TS 102 223 V18.3.0 8.61, tags '3F'/'BF'): the
|
||
// single/multiple technology objects and the PLI qualifier share this list.
|
||
// Date-Time and Time zone object (TS 102 223 8.39): seven semi-octet BCD
|
||
// bytes in the TS 23.040 9.2.3.11 order (units in the high nibble) - YY MM
|
||
// DD hh mm ss plus the time zone in quarters of an hour, its sign in bit 3
|
||
// of the first semi-octet (1 = west of GMT).
|
||
function eventDateTimeTlv(d) {
|
||
const semi = v => ((v % 10) << 4) | Math.floor(v / 10);
|
||
const hx = b => b.toString(16).padStart(2, '0');
|
||
const offMin = -d.getTimezoneOffset(); // minutes east of UTC
|
||
const tz = semi(Math.round(Math.abs(offMin) / 15)) | (offMin < 0 ? 0x08 : 0);
|
||
return '2607' + hx(semi(d.getFullYear() % 100)) + hx(semi(d.getMonth() + 1)) +
|
||
hx(semi(d.getDate())) + hx(semi(d.getHours())) + hx(semi(d.getMinutes())) +
|
||
hx(semi(d.getSeconds())) + hx(tz);
|
||
}
|
||
|
||
// EFADN number coding (TS 31.102 / TS 24.008 10.5.4.7): digit pairs in
|
||
// swapped semi-octets, an odd number of digits padded with 'F'.
|
||
function encBcdDigits(digits) {
|
||
const d = String(digits || '').replace(/\D/g, '');
|
||
let out = '';
|
||
for (let i = 0; i < d.length; i += 2) {
|
||
out += ((i + 1 < d.length ? d[i + 1] : 'F') + d[i]).toUpperCase();
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Address object (TS 102 223 8.1): TON/NPI byte + the number in the EFADN
|
||
// coding. NPI = ISDN/telephony (1), TON per the form (0 = unknown).
|
||
function eventAddressTlv(number, ton) {
|
||
const bcd = encBcdDigits(number);
|
||
if (!bcd) return '';
|
||
const tonNpi = (0x80 | ((parseInt(ton, 10) & 0x07) << 4) | 0x01);
|
||
const value = tonNpi.toString(16).padStart(2, '0') + bcd;
|
||
return '86' + (value.length / 2).toString(16).padStart(2, '0') + value;
|
||
}
|
||
|
||
// Other address value (TS 102 223 8.58): a dotted IPv4 or hex address
|
||
// bytes; '' when the text is not an even-length hex string.
|
||
function eventIpBytes(text, type) {
|
||
const s = String(text || '').trim();
|
||
if (type === '21' && /^\d{1,3}(\.\d{1,3}){3}$/.test(s)) {
|
||
return s.split('.').map(n => (parseInt(n, 10) & 0xFF).toString(16).padStart(2, '0')).join('').toUpperCase();
|
||
}
|
||
const h = s.replace(/[^0-9a-fA-F]/g, '');
|
||
return h.length % 2 ? '' : h.toUpperCase();
|
||
}
|
||
|
||
const ACCESSTECH = [
|
||
{v:0,l:'GSM'},{v:1,l:'TIA/EIA-553-A'},{v:2,l:'TIA/EIA-136-270'},{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 EVENT_FORMS = {
|
||
0x00: { title: 'MT call', src: '83',
|
||
fields: [
|
||
{ id: 'ti', label: 'Transaction identifier', type: 'text', pattern: '[0-9A-Fa-f]{2}', placeholder: 'hex byte, e.g. 00' },
|
||
{ id: 'number', label: 'Calling party number (optional)', type: 'text', pattern: '\\d{1,20}', placeholder: 'digits, e.g. 79001234567' },
|
||
{ id: 'ton', label: 'Type of number', type: 'select',
|
||
opts: [{v:0,l:'Unknown'},{v:1,l:'International'},{v:2,l:'National'},{v:3,l:'Network specific'}] },
|
||
{ id: 'sub', label: 'Subaddress (optional, hex)', type: 'text', pattern: '[0-9A-Fa-f]{2,40}', placeholder: 'e.g. 1234' },
|
||
{ id: 'ims_uri', label: 'IMS URI (optional)', type: 'text', placeholder: 'e.g. sip:user@example.com' },
|
||
{ id: 'media', label: 'Media Type (optional)', type: 'select',
|
||
opts: [{v:'',l:'\u2014 not present \u2014'},{v:0,l:'00 \u2014 voice'},{v:1,l:'01 \u2014 video'}] },
|
||
],
|
||
build(v) {
|
||
let d = '1C01' + (v.ti || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2);
|
||
d += eventAddressTlv(v.number, v.ton);
|
||
if (v.sub) {
|
||
const h = v.sub.replace(/[^0-9a-fA-F]/g, '');
|
||
d += '88' + (h.length / 2).toString(16).padStart(2, '0') + h;
|
||
}
|
||
if (v.ims_uri) {
|
||
const b = new TextEncoder().encode(v.ims_uri).slice(0, 100);
|
||
d += 'B1' + b.length.toString(16).padStart(2, '0') + bytesToHex(Array.from(b));
|
||
}
|
||
if (v.media !== '' && v.media !== undefined)
|
||
d += 'FE01' + ((parseInt(v.media) || 0) & 0xFF).toString(16).padStart(2, '0');
|
||
return d.toUpperCase();
|
||
}
|
||
},
|
||
0x01: { title: 'Call connected', srcField: 'src',
|
||
fields: [
|
||
{ id: 'src', label: 'Source', type: 'select',
|
||
opts: [{v:'82',l:'terminal \u2014 near end (MT call)'},{v:'83',l:'network \u2014 far end (MO call)'}] },
|
||
{ id: 'ti', label: 'Transaction identifier', type: 'text', pattern: '[0-9A-Fa-f]{2}', placeholder: 'hex byte, e.g. 00' },
|
||
{ id: 'media', label: 'Media Type (optional)', type: 'select',
|
||
opts: [{v:'',l:'\u2014 not present \u2014'},{v:0,l:'00 \u2014 voice'},{v:1,l:'01 \u2014 video'}] },
|
||
],
|
||
build(v) {
|
||
let d = '1C01' + (v.ti || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2);
|
||
if (v.media !== '' && v.media !== undefined)
|
||
d += 'FE01' + ((parseInt(v.media) || 0) & 0xFF).toString(16).padStart(2, '0');
|
||
return d.toUpperCase();
|
||
}
|
||
},
|
||
0x02: { title: 'Call disconnected', srcField: 'src',
|
||
fields: [
|
||
{ id: 'src', label: 'Source', type: 'select',
|
||
opts: [{v:'82',l:'terminal \u2014 near end (terminal-initiated)'},{v:'83',l:'network \u2014 far end (network-initiated)'}] },
|
||
{ id: 'ti', label: 'Transaction identifier', type: 'text', pattern: '[0-9A-Fa-f]{2}', placeholder: 'hex byte, e.g. 00' },
|
||
{ id: 'cause_mode', label: 'Cause', type: 'select', onchange: 'updateCallCauseFields()',
|
||
opts: [{v:'',l:'\u2014 not present \u2014'},{v:'rlt',l:'radio link timeout (zero length)'},{v:'custom',l:'custom (hex)'}] },
|
||
{ id: 'cause', label: 'Cause bytes (hex)', type: 'text', pattern: '[0-9A-Fa-f]{2,60}', placeholder: 'e.g. 10', g: 'custom' },
|
||
{ id: 'media', label: 'Media Type (optional)', type: 'select',
|
||
opts: [{v:'',l:'\u2014 not present \u2014'},{v:0,l:'00 \u2014 voice'},{v:1,l:'01 \u2014 video'}] },
|
||
{ id: 'ims_proto', label: 'IMS disconnection cause (optional)', type: 'select',
|
||
opts: [{v:'',l:'\u2014 not present \u2014'},{v:1,l:'01 \u2014 SIP'},{v:2,l:'02 \u2014 Q.850'}] },
|
||
{ id: 'ims_cause', label: 'Cause value', type: 'number', min: '0', max: '65535', placeholder: '0\u201365535, e.g. 603' },
|
||
],
|
||
build(v) {
|
||
let d = '1C01' + (v.ti || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2);
|
||
const cause = (v.cause || '').replace(/[^0-9a-fA-F]/g, '');
|
||
if (v.cause_mode === 'rlt') d += '9A00';
|
||
else if (v.cause_mode === 'custom' && cause)
|
||
d += '9A' + (cause.length / 2).toString(16).padStart(2, '0') + cause;
|
||
if (v.media !== '' && v.media !== undefined)
|
||
d += 'FE01' + ((parseInt(v.media) || 0) & 0xFF).toString(16).padStart(2, '0');
|
||
if (v.ims_proto !== '' && v.ims_proto !== undefined && v.ims_cause !== '' && v.ims_cause !== undefined)
|
||
d += 'D503' + ((parseInt(v.ims_proto) || 0) & 0xFF).toString(16).padStart(2, '0') +
|
||
((parseInt(v.ims_cause) || 0) & 0xFFFF).toString(16).padStart(4, '0');
|
||
return d.toUpperCase();
|
||
}
|
||
},
|
||
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'}] },
|
||
{ id: 'mcc', label: 'MCC (Location Information)', type: 'text', pattern: '\\d{3}', placeholder: 'sent for Normal service' },
|
||
{ id: 'mnc', label: 'MNC', type: 'text', pattern: '\\d{2,3}', placeholder: 'e.g. 01' },
|
||
{ id: 'lac', label: 'LAC', type: 'text', pattern: '[0-9A-Fa-f]{4}', placeholder: 'hex, e.g. 00FF' },
|
||
{ id: 'cell', label: 'Cell ID', type: 'text', pattern: '[0-9A-Fa-f]{4}', placeholder: 'hex, e.g. 0001' },
|
||
],
|
||
build(v) {
|
||
let d = '9B01' + (v.status|0).toString(16).padStart(2,'0');
|
||
// 8.19: the location information is only included for Normal service
|
||
if ((parseInt(v.status) || 0) === 0 && v.mcc)
|
||
d += '9307' + encPlmn(v.mcc, v.mnc || '01') +
|
||
(v.lac || '0000').toUpperCase() + (v.cell || '0001').toUpperCase();
|
||
return d.toUpperCase();
|
||
}
|
||
},
|
||
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',
|
||
fields: [
|
||
{ id: 'cause', label: 'Browser termination cause', type: 'select',
|
||
opts: [{v:0,l:'00 \u2014 user termination'},{v:1,l:'01 \u2014 error termination'}] },
|
||
],
|
||
build(v) { return 'B401' + ((parseInt(v.cause) || 0) & 0xFF).toString(16).padStart(2, '0'); }
|
||
},
|
||
0x09: { title: 'Data available',
|
||
fields: [
|
||
{ id: 'channel', label: 'Channel', type: 'select',
|
||
opts: [{v:0,l:'0 \u2014 no channel available'},
|
||
{v:1,l:'Channel 1'},{v:2,l:'Channel 2'},{v:3,l:'Channel 3'},
|
||
{v:4,l:'Channel 4'},{v:5,l:'Channel 5'},{v:6,l:'Channel 6'},{v:7,l:'Channel 7'}] },
|
||
{ id: 'state', label: 'Link state', type: 'select',
|
||
opts: [{v:0x00,l:'Link not established / TCP closed'},
|
||
{v:0x40,l:'TCP in LISTEN state (UICC server mode)'},
|
||
{v:0x80,l:'Link established / TCP established'}] },
|
||
{ id: 'info', label: 'Channel info', type: 'select',
|
||
opts: [{v:0x00,l:'No further info can be given'},
|
||
{v:0x05,l:'Link dropped (network failure or user cancellation)'}] },
|
||
{ id: 'len', label: 'Channel data length', type: 'text', pattern: '[0-9A-Fa-f]{2}', placeholder: 'hex byte, FF = more than 255' },
|
||
],
|
||
build(v) {
|
||
const st = (parseInt(v.state) || 0) | (parseInt(v.channel) || 0);
|
||
let d = 'B802' + st.toString(16).padStart(2, '0') + ((parseInt(v.info) || 0) & 0xFF).toString(16).padStart(2, '0');
|
||
d += 'B701' + (v.len || 'FF').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2);
|
||
return d.toUpperCase();
|
||
}
|
||
},
|
||
0x0A: { title: 'Channel status',
|
||
fields: [
|
||
{ id: 'channel', label: 'Channel', type: 'select',
|
||
opts: [{v:0,l:'0 \u2014 no channel available'},
|
||
{v:1,l:'Channel 1'},{v:2,l:'Channel 2'},{v:3,l:'Channel 3'},
|
||
{v:4,l:'Channel 4'},{v:5,l:'Channel 5'},{v:6,l:'Channel 6'},{v:7,l:'Channel 7'}] },
|
||
{ id: 'state', label: 'Link state', type: 'select',
|
||
opts: [{v:0x00,l:'Link not established / TCP closed'},
|
||
{v:0x40,l:'TCP in LISTEN state (UICC server mode)'},
|
||
{v:0x80,l:'Link established / TCP established'}] },
|
||
{ id: 'info', label: 'Channel info', type: 'select',
|
||
opts: [{v:0x00,l:'No further info can be given'},
|
||
{v:0x05,l:'Link dropped (network failure or user cancellation)'}] },
|
||
{ id: 'bearer', label: 'Bearer Description (optional)', type: 'select',
|
||
opts: [{v:'',l:'\u2014 not present \u2014'},
|
||
{v:0x03,l:'03 \u2014 default bearer for the requested transport layer'},
|
||
{v:0x04,l:'04 \u2014 local link technology independent'},
|
||
{v:0x05,l:'05 \u2014 Bluetooth'},{v:0x06,l:'06 \u2014 IrDA'},
|
||
{v:0x07,l:'07 \u2014 RS232'},{v:0x10,l:'10 \u2014 USB'}] },
|
||
{ id: 'bearer_params', label: 'Bearer parameters (hex, optional)', type: 'text', pattern: '[0-9A-Fa-f]{2,40}', placeholder: 'bearer specific' },
|
||
{ id: 'addr_type', label: 'Other address \u2014 local address (optional)', type: 'select',
|
||
opts: [{v:'',l:'\u2014 not present \u2014'},{v:'21',l:'21 \u2014 IPv4 address'},
|
||
{v:'57',l:'57 \u2014 IPv6 address'},{v:'00',l:'empty (request a dynamic address)'}] },
|
||
{ id: 'addr', label: 'Address', type: 'text', placeholder: 'dotted IPv4 (192.168.1.2) or hex' },
|
||
],
|
||
build(v) {
|
||
const st = (parseInt(v.state) || 0) | (parseInt(v.channel) || 0);
|
||
let d = 'B802' + st.toString(16).padStart(2,'0') + ((parseInt(v.info) || 0) & 0xFF).toString(16).padStart(2,'0');
|
||
if (v.bearer !== '' && v.bearer !== undefined) {
|
||
const params = (v.bearer_params || '').replace(/[^0-9a-fA-F]/g, '');
|
||
d += 'B5' + (1 + params.length / 2).toString(16).padStart(2, '0') +
|
||
((parseInt(v.bearer) || 0) & 0xFF).toString(16).padStart(2, '0') + params;
|
||
}
|
||
if (v.addr_type !== '' && v.addr_type !== undefined) {
|
||
if (v.addr_type === '00') d += 'BE00';
|
||
else {
|
||
const h = eventIpBytes(v.addr, v.addr_type);
|
||
if (h) d += 'BE' + (1 + h.length / 2).toString(16).padStart(2, '0') + v.addr_type + h;
|
||
}
|
||
}
|
||
return d.toUpperCase();
|
||
}
|
||
},
|
||
0x0B: { title: 'Access Technology Change',
|
||
fields: [
|
||
{ id: 'tech', label: 'Access Technology', type: 'select',
|
||
opts: ACCESSTECH }
|
||
],
|
||
build(v) { return 'BF01' + (v.tech|0).toString(16).padStart(2,'0'); }
|
||
},
|
||
0x0C: { title: 'Display parameters changed',
|
||
fields: [
|
||
{ id: 'rows', label: 'Characters down (screen height)', type: 'number', min: '0', max: '31', placeholder: '0\u201331' },
|
||
{ id: 'cols', label: 'Characters across (screen width)', type: 'number', min: '0', max: '31', placeholder: '0\u201331' },
|
||
{ id: 'sizing', label: 'Screen sizing parameters supported', type: 'select',
|
||
opts: [{v:0,l:'No'},{v:1,l:'Yes'}] },
|
||
{ id: 'fonts', label: 'Variable size fonts supported', type: 'select',
|
||
opts: [{v:0,l:'No'},{v:1,l:'Yes'}] },
|
||
{ id: 'effects', label: 'Screen effects (hex byte)', type: 'text', pattern: '[0-9A-Fa-f]{2}', placeholder: 'b1 resizable, b2 wrapping, b3 scrolling, b6 menu width' },
|
||
],
|
||
build(v) {
|
||
const b1 = ((parseInt(v.rows) || 0) & 0x1F) | (parseInt(v.sizing) ? 0x80 : 0);
|
||
const b2 = ((parseInt(v.cols) || 0) & 0x1F) | (parseInt(v.fonts) ? 0x40 : 0);
|
||
const b3 = parseInt((v.effects || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2), 16) || 0;
|
||
return ('C003' + b1.toString(16).padStart(2, '0') + b2.toString(16).padStart(2, '0') +
|
||
b3.toString(16).padStart(2, '0')).toUpperCase();
|
||
}
|
||
},
|
||
0x0D: null,
|
||
0x0E: { title: 'Network search mode change',
|
||
fields: [
|
||
{ id: 'mode', label: 'Network search mode', type: 'select',
|
||
opts: [{v:0,l:'00 \u2014 manual'},{v:1,l:'01 \u2014 automatic'}] },
|
||
],
|
||
build(v) { return 'E501' + ((parseInt(v.mode) || 0) & 0xFF).toString(16).padStart(2, '0'); }
|
||
},
|
||
0x0F: { title: 'Browsing status',
|
||
fields: [
|
||
{ id: 'status', label: 'Browsing status (hex bytes)', type: 'text', pattern: '[0-9A-Fa-f]{2,40}', placeholder: 'browser error code, e.g. 00' },
|
||
],
|
||
build(v) {
|
||
const h = (v.status || '00').replace(/[^0-9a-fA-F]/g, '');
|
||
return ('E4' + (h.length / 2).toString(16).padStart(2, '0') + h).toUpperCase();
|
||
}
|
||
},
|
||
0x10: { title: 'Frames information change',
|
||
fields: [
|
||
{ id: 'frame', label: 'Default frame ID', type: 'text', pattern: '[0-9A-Fa-f]{2}', placeholder: '01\u20130F, 00 = no frames' },
|
||
{ id: 'list', label: 'Frame information list (hex, optional)', type: 'text', pattern: '[0-9A-Fa-f]{2,80}', placeholder: '2 bytes per frame: down / across' },
|
||
],
|
||
build(v) {
|
||
const id = (v.frame || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2);
|
||
const h = (v.list || '').replace(/[^0-9a-fA-F]/g, '');
|
||
const value = id + h;
|
||
return ('E7' + (value.length / 2).toString(16).padStart(2, '0') + value).toUpperCase();
|
||
}
|
||
},
|
||
0x11: { title: 'I-WLAN Access Status',
|
||
fields: [
|
||
{ id: 'status', label: 'Access status', type: 'select',
|
||
opts: [{v:0,l:'00 \u2014 no current (I-)WLAN coverage'},
|
||
{v:1,l:'01 \u2014 coverage available, no current connection'},
|
||
{v:2,l:'02 \u2014 coverage available, connection on-going'}] },
|
||
],
|
||
build(v) { return 'CB01' + ((parseInt(v.status) || 0) & 0xFF).toString(16).padStart(2, '0'); }
|
||
},
|
||
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\u2013255', g: 'eps' },
|
||
{ id: 'ext_info_type', label: 'Extended information (optional)', type: 'select',
|
||
opts: [{v:'',l:'\u2014 not present \u2014'},{v:1,l:'01 \u2014 CAG ID'},{v:2,l:'02 \u2014 NID'},{v:3,l:'03 \u2014 RedCap'}] },
|
||
{ id: 'ext_info', label: 'Extended identification (hex)', type: 'text', pattern: '[0-9A-Fa-f]{2,20}', placeholder: 'CAG ID: 4 bytes' },
|
||
],
|
||
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');
|
||
}
|
||
}
|
||
if (v.ext_info_type !== '' && v.ext_info_type !== undefined) {
|
||
const h = (v.ext_info || '').replace(/[^0-9a-fA-F]/g, '');
|
||
d += 'F2' + (1 + h.length / 2).toString(16).padStart(2, '0') +
|
||
((parseInt(v.ext_info_type) || 0) & 0xFF).toString(16).padStart(2, '0') + h;
|
||
}
|
||
return d.toUpperCase();
|
||
}
|
||
},
|
||
0x13: null,
|
||
0x14: { title: 'Access technology change (multiple)',
|
||
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 selection', src: '83',
|
||
fields: [
|
||
{ id: 'status', label: 'CSG cell selection status', type: 'select',
|
||
opts: [{v:0,l:'00 \u2014 not camped on an Allowed / Operator CSG cell'},
|
||
{v:1,l:'01 \u2014 camped on an Allowed / Operator CSG cell'}] },
|
||
{ id: 'mech', label: 'Selection mechanism', type: 'select',
|
||
opts: [{v:'',l:'\u2014 none \u2014'},{v:'manual',l:'manual CSG selection'},
|
||
{v:'other',l:'another CSG selection type'}] },
|
||
{ id: 'tech', label: 'Access Technology (optional)', type: 'select',
|
||
opts: [{v:'',l:'\u2014 not present \u2014'}].concat(ACCESSTECH) },
|
||
{ id: 'csg', label: 'CSG ID (optional, hex)', type: 'text', pattern: '[0-9A-Fa-f]{2,16}', placeholder: 'e.g. 00000001' },
|
||
{ id: 'hnb', label: 'HNB name (optional)', type: 'text', placeholder: 'e.g. MyCSG' },
|
||
{ id: 'mcc', label: 'MCC (PLMN ID, optional)', type: 'text', pattern: '\\d{3}', placeholder: 'e.g. 250' },
|
||
{ id: 'mnc', label: 'MNC', type: 'text', pattern: '\\d{2,3}', placeholder: 'e.g. 01' },
|
||
],
|
||
build(v) {
|
||
let d = '';
|
||
if (v.tech !== '' && v.tech !== undefined)
|
||
d += 'BF01' + ((parseInt(v.tech) || 0) & 0xFF).toString(16).padStart(2, '0');
|
||
const mech = v.mech === 'manual' ? 0x40 : (v.mech === 'other' ? 0x80 : 0x00);
|
||
d += 'D502' + ((parseInt(v.status) || 0) & 0xFF).toString(16).padStart(2, '0') +
|
||
(0x01 | mech).toString(16).padStart(2, '0');
|
||
if (v.csg) {
|
||
const h = v.csg.replace(/[^0-9a-fA-F]/g, '');
|
||
d += 'D6' + (h.length / 2).toString(16).padStart(2, '0') + h;
|
||
}
|
||
if (v.hnb) {
|
||
const b = new TextEncoder().encode(v.hnb).slice(0, 60);
|
||
d += 'D7' + b.length.toString(16).padStart(2, '0') + bytesToHex(Array.from(b));
|
||
}
|
||
if (v.mcc) d += '8903' + encPlmn(v.mcc, v.mnc || '01');
|
||
return d.toUpperCase();
|
||
}
|
||
},
|
||
0x16: null,
|
||
0x17: { title: 'IMS registration', src: '83',
|
||
fields: [
|
||
{ id: 'impu', label: 'IMPU URI(s) (optional)', type: 'text', placeholder: 'one or more, comma-separated' },
|
||
{ id: 'code', label: 'IMS status code (optional)', type: 'text', pattern: '\\d{3}', placeholder: 'e.g. 200' },
|
||
],
|
||
build(v) {
|
||
let d = '';
|
||
const uris = (v.impu || '').split(/[\s,;]+/).filter(Boolean);
|
||
if (uris.length) {
|
||
let inner = '';
|
||
uris.forEach(u => {
|
||
const b = new TextEncoder().encode(u).slice(0, 100);
|
||
inner += '80' + b.length.toString(16).padStart(2, '0') + bytesToHex(Array.from(b));
|
||
});
|
||
d += 'F7' + (inner.length / 2).toString(16).padStart(2, '0') + inner;
|
||
}
|
||
if (v.code) {
|
||
const b = new TextEncoder().encode(v.code);
|
||
d += 'F8' + b.length.toString(16).padStart(2, '0') + bytesToHex(Array.from(b));
|
||
}
|
||
return d.toUpperCase();
|
||
}
|
||
},
|
||
0x18: { title: 'Incoming IMS data', src: '83',
|
||
fields: [
|
||
{ id: 'iari', label: 'IARI (optional)', type: 'text', placeholder: 'e.g. urn:urn-7:3gpp-service.ims.icsi.mmtel' },
|
||
],
|
||
build(v) {
|
||
if (!v.iari) return '';
|
||
const b = new TextEncoder().encode(v.iari).slice(0, 100);
|
||
return ('F7' + b.length.toString(16).padStart(2, '0') + bytesToHex(Array.from(b))).toUpperCase();
|
||
}
|
||
},
|
||
0x1C: { title: 'Poll interval negotiation',
|
||
fields: [
|
||
{ id: 'unit', label: 'Time unit', type: 'select',
|
||
opts: [{v:1,l:'Seconds'},{v:0,l:'Minutes'},{v:2,l:'Tenths of seconds'}] },
|
||
{ id: 'interval', label: 'Time interval', type: 'number', min: '1', max: '255', placeholder: '1–255 (default 30 s)' },
|
||
],
|
||
build(v) {
|
||
const unit = parseInt(v.unit, 10) || 0;
|
||
let iv = parseInt(v.interval, 10);
|
||
if (!(iv >= 1 && iv <= 255)) iv = 30;
|
||
return ('0402' + unit.toString(16).padStart(2, '0') + iv.toString(16).padStart(2, '0')).toUpperCase();
|
||
}
|
||
},
|
||
0x1D: { title: 'Data connection status change',
|
||
fields: [
|
||
{ id: 'status', label: 'Data connection status', type: 'select',
|
||
opts: [{v:0,l:'00 \u2014 Data connection successful'},
|
||
{v:1,l:'01 \u2014 Data connection rejected'},
|
||
{v:2,l:'02 \u2014 Data connection dropped / deactivated'}] },
|
||
{ id: 'type', label: 'Data connection type', type: 'select',
|
||
opts: [{v:0,l:'00 \u2014 PDP connection'},{v:1,l:'01 \u2014 PDN connection'},
|
||
{v:2,l:'02 \u2014 PDU connection'}] },
|
||
{ id: 'cause', label: '(E/5G)SM cause (optional)', type: 'number', min: '0', max: '255', placeholder: '0\u2013255, e.g. 26' },
|
||
{ id: 'ti', label: 'Transaction identifier', type: 'text', pattern: '[0-9A-Fa-f]{2}', placeholder: 'hex byte, e.g. 00' },
|
||
{ id: 'datetime', label: 'Date-Time and Time zone (optional)', type: 'select',
|
||
opts: [{v:'',l:'\u2014 not present \u2014'},{v:'now',l:'current date-time (host clock)'}] },
|
||
{ id: 'mcc', label: 'MCC (Location Information, optional)', 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', type: 'text', pattern: '[0-9A-Fa-f]{4}', placeholder: 'hex, e.g. 00FF' },
|
||
{ id: 'cell', label: 'Cell ID', type: 'text', pattern: '[0-9A-Fa-f]{4}', placeholder: 'hex, e.g. 0001' },
|
||
{ id: 'tech', label: 'Access Technology (optional)', type: 'select',
|
||
opts: [{v:'',l:'\u2014 not present \u2014'}].concat(ACCESSTECH) },
|
||
{ id: 'loc_status', label: 'Location status', type: 'select',
|
||
opts: [{v:0,l:'Normal service'},{v:1,l:'Limited service'},{v:2,l:'No service'}] },
|
||
{ id: 'apn', label: 'Network Access Name / APN (optional)', type: 'text', placeholder: 'e.g. internet' },
|
||
{ id: 'pdp_type', label: 'PDP/PDN/PDU type (optional)', type: 'select',
|
||
opts: [{v:'',l:'\u2014 not present \u2014'},
|
||
{v:0,l:'00 \u2014 IPv4'},{v:1,l:'01 \u2014 IPv6'},{v:3,l:'03 \u2014 IPv4v6'},
|
||
{v:4,l:'04 \u2014 PPP (PDP/PDN) / Unstructured (PDU)'},
|
||
{v:5,l:'05 \u2014 non-IP (PDP/PDN) / Ethernet (PDU)'}] },
|
||
],
|
||
build(v) {
|
||
// TS 131 111 7.5.25.2 object order; the conditional objects (cause,
|
||
// location information, access technology, APN, PDP/PDN/PDU type) are
|
||
// omitted when left empty.
|
||
let d = '9D01' + ((parseInt(v.status) || 0) & 0xFF).toString(16).padStart(2, '0');
|
||
d += 'AA01' + ((parseInt(v.type) || 0) & 0xFF).toString(16).padStart(2, '0');
|
||
if (v.cause !== '' && v.cause !== undefined)
|
||
d += 'AE01' + ((parseInt(v.cause) || 0) & 0xFF).toString(16).padStart(2, '0');
|
||
d += '1C01' + (v.ti || '00').replace(/[^0-9a-fA-F]/g, '').padStart(2, '0').slice(0, 2);
|
||
if (v.datetime === 'now') d += eventDateTimeTlv(new Date());
|
||
if (v.mcc)
|
||
d += '1307' + encPlmn(v.mcc, v.mnc || '01') +
|
||
(v.lac || '0000').toUpperCase() + (v.cell || '0001').toUpperCase();
|
||
if (v.tech !== '' && v.tech !== undefined)
|
||
d += 'BF01' + ((parseInt(v.tech) || 0) & 0xFF).toString(16).padStart(2, '0');
|
||
d += '9B01' + ((parseInt(v.loc_status) || 0) & 0xFF).toString(16).padStart(2, '0');
|
||
if (v.apn) {
|
||
const b = new TextEncoder().encode(v.apn).slice(0, 100);
|
||
d += 'C7' + b.length.toString(16).padStart(2, '0') + bytesToHex(Array.from(b));
|
||
}
|
||
if (v.pdp_type !== '' && v.pdp_type !== undefined)
|
||
d += '0B01' + ((parseInt(v.pdp_type) || 0) & 0xFF).toString(16).padStart(2, '0');
|
||
return d.toUpperCase();
|
||
}
|
||
},
|
||
0x1E: { title: 'CAG cell selection', src: '83',
|
||
fields: [
|
||
{ id: 'status', label: 'CAG cell selection status', type: 'select',
|
||
opts: [{v:0,l:'00 \u2014 not camped on a CAG cell'},
|
||
{v:1,l:'01 \u2014 camped on a CAG cell'}] },
|
||
{ id: 'mech', label: 'Selection mechanism', type: 'select',
|
||
opts: [{v:'',l:'\u2014 none \u2014'},{v:'manual',l:'manual CAG selection'},
|
||
{v:'other',l:'another CAG selection type'}] },
|
||
{ id: 'tech', label: 'Access Technology (optional)', type: 'select',
|
||
opts: [{v:'',l:'\u2014 not present \u2014'}].concat(ACCESSTECH) },
|
||
{ id: 'list', label: 'CAG information list (optional, hex)', type: 'text', pattern: '[0-9A-Fa-f]{2,80}', placeholder: 'CAG identity list' },
|
||
{ id: 'hrnn', label: 'CAG HRNN list (optional)', type: 'text', placeholder: 'one or more names, comma-separated' },
|
||
],
|
||
build(v) {
|
||
let d = '';
|
||
if (v.tech !== '' && v.tech !== undefined)
|
||
d += 'BF01' + ((parseInt(v.tech) || 0) & 0xFF).toString(16).padStart(2, '0');
|
||
const mech = v.mech === 'manual' ? 0x40 : (v.mech === 'other' ? 0x80 : 0x00);
|
||
d += 'D502' + ((parseInt(v.status) || 0) & 0xFF).toString(16).padStart(2, '0') +
|
||
(0x01 | mech).toString(16).padStart(2, '0');
|
||
if (v.list) {
|
||
const h = v.list.replace(/[^0-9a-fA-F]/g, '');
|
||
d += 'D6' + (h.length / 2).toString(16).padStart(2, '0') + h;
|
||
}
|
||
if (v.hrnn) {
|
||
let inner = '';
|
||
String(v.hrnn).split(/[\s,;]+/).filter(Boolean).forEach(n => {
|
||
const b = new TextEncoder().encode(n).slice(0, 60);
|
||
inner += '80' + b.length.toString(16).padStart(2, '0') + bytesToHex(Array.from(b));
|
||
});
|
||
if (inner) d += 'D7' + (inner.length / 2).toString(16).padStart(2, '0') + inner;
|
||
}
|
||
return d.toUpperCase();
|
||
}
|
||
},
|
||
0x1F: { title: 'Slices status change', src: '83',
|
||
fields: [
|
||
{ id: 'status', label: 'Slices status', type: 'select',
|
||
opts: [{v:0x01,l:'01 \u2014 allowed'},{v:0x02,l:'02 \u2014 rejected'},
|
||
{v:0x04,l:'04 \u2014 served'},{v:0x81,l:'81 \u2014 partially allowed'},
|
||
{v:0x82,l:'82 \u2014 partially rejected'}] },
|
||
{ id: 'tech', label: 'Access Technology (optional)', type: 'select',
|
||
opts: [{v:'',l:'\u2014 not present \u2014'}].concat(ACCESSTECH) },
|
||
{ id: 'allowed_map', label: 'Allowed slices with S-NSSAI mapping (optional, hex)', type: 'text', pattern: '[0-9A-Fa-f]{2,80}', placeholder: 'TS 24.501 S-NSSAI-with-mapping elements' },
|
||
{ id: 'served', label: 'Served S-NSSAIs (optional, hex)', type: 'text', pattern: '[0-9A-Fa-f]{8,80}', placeholder: '4 bytes per S-NSSAI (SST + SD)' },
|
||
{ id: 'allowed', label: 'Allowed slices (optional, hex)', type: 'text', pattern: '[0-9A-Fa-f]{8,80}', placeholder: '4 bytes per S-NSSAI (SST + SD)' },
|
||
{ id: 'rejected_map', label: 'Rejected slices with S-NSSAI mapping (optional, hex)', type: 'text', pattern: '[0-9A-Fa-f]{2,80}', placeholder: 'TS 24.501 elements + rejection cause' },
|
||
{ id: 'rejected', label: 'Rejected slices (optional, hex)', type: 'text', pattern: '[0-9A-Fa-f]{2,80}', placeholder: 'TS 24.501 rejected S-NSSAI elements' },
|
||
],
|
||
build(v) {
|
||
let d = '';
|
||
if (v.tech !== '' && v.tech !== undefined)
|
||
d += 'BF01' + ((parseInt(v.tech) || 0) & 0xFF).toString(16).padStart(2, '0');
|
||
d += 'D501' + ((parseInt(v.status) || 1) & 0xFF).toString(16).padStart(2, '0');
|
||
const list = (val, tag) => {
|
||
const h = (val || '').replace(/[^0-9a-fA-F]/g, '');
|
||
if (h) d += tag + (h.length / 2).toString(16).padStart(2, '0') + h;
|
||
};
|
||
list(v.allowed_map, 'F7');
|
||
const h = (v.served || '').replace(/[^0-9a-fA-F]/g, '');
|
||
if (h) {
|
||
const n = Math.floor(h.length / 8);
|
||
d += 'D6' + (1 + n * 4).toString(16).padStart(2, '0') +
|
||
n.toString(16).padStart(2, '0') + h.slice(0, n * 8);
|
||
}
|
||
list(v.allowed, 'F8');
|
||
list(v.rejected_map, 'D7');
|
||
list(v.rejected, 'B1');
|
||
return d.toUpperCase();
|
||
}
|
||
},
|
||
};
|
||
|
||
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:'Reserved for GSM (Timing Advance)'},
|
||
{code:'06',name:'Access Technology (single)'},{code:'07',name:'ESN of the terminal'},
|
||
{code:'08',name:'IMEISV'},
|
||
{code:'09',name:'Search Mode'},{code:'0A',name:'Battery charge state'},
|
||
{code:'0B',name:'MEID of the terminal'},
|
||
{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'},
|
||
{code:'1A',name:'Supported Radio Access Technologies'},
|
||
];
|
||
|
||
// TERMINAL PROFILE bit allocation: bytes 1..39 per ETSI TS 102 223
|
||
// V18.3.0 5.2, 3GPP-defined bits named per ETSI TS 131 111 V18.12.0 5.2
|
||
// (no "reserved by 3GPP" placeholders). Index 0 = the first spec table row
|
||
// = byte 1 b1 (the LSB); bit n is byte floor(n/8)+1, mask 1 << (n%8).
|
||
// Bytes beyond the table fall back to generic labels in the UI.
|
||
const TP_BITS = [
|
||
'Profile download', 'SMS-PP data download', 'Cell Broadcast data download', 'Menu selection',
|
||
'SMS-PP data download', 'Timer expiration', 'USSD string data object support in Call Control by USIM', 'Call Control by NAA',
|
||
'Command result', 'Call Control by NAA', 'Call Control by NAA', 'MO short message control by USIM',
|
||
'Call Control by NAA', 'UCS2 Entry supported', 'UCS2 Display supported', 'Display Text',
|
||
'Proactive UICC: DISPLAY TEXT', 'Proactive UICC: GET INKEY', 'Proactive UICC: GET INPUT', 'Proactive UICC: MORE TIME',
|
||
'Proactive UICC: PLAY TONE', 'Proactive UICC: POLL INTERVAL', 'Proactive UICC: POLLING OFF', 'Proactive UICC: REFRESH',
|
||
'Proactive UICC: SELECT ITEM', 'Proactive UICC: SEND SHORT MESSAGE with 3GPP-SMS-TPDU', 'Proactive UICC: SEND SS', 'Proactive UICC: SEND USSD',
|
||
'Proactive UICC: SET UP CALL', 'Proactive UICC: SET UP MENU', 'Proactive UICC: PROVIDE LOCAL INFORMATION (MCC, MNC, LAC, Cell ID & IMEI)', 'Proactive UICC: PROVIDE LOCAL INFORMATION (NMR)',
|
||
'Proactive UICC: SET UP EVENT LIST', 'Event: MT call', 'Event: Call connected', 'Event: Call disconnected',
|
||
'Event: Location status', 'Event: User activity', 'Event: Idle screen available', 'Event: Card reader status',
|
||
'Event: Language selection', 'Event: Browser Termination', 'Event: Data available', 'Event: Channel status',
|
||
'Event: Access Technology Change', 'Event: Display parameters changed', 'Event: Local Connection', 'Event: Network Search Mode Change',
|
||
'Proactive UICC: POWER ON CARD', 'Proactive UICC: POWER OFF CARD', 'Proactive UICC: PERFORM CARD APDU', 'Proactive UICC: GET READER STATUS (Card reader status)',
|
||
'Proactive UICC: GET READER STATUS (Card reader identifier)', 'RFU', 'RFU', 'RFU',
|
||
'Proactive UICC: TIMER MANAGEMENT (start, stop)', 'Proactive UICC: TIMER MANAGEMENT (get current value)', 'Proactive UICC: PROVIDE LOCAL INFORMATION (date, time and time zone)', 'GET INKEY',
|
||
'SET UP IDLE MODE TEXT', 'RUN AT COMMAND', 'SETUP CALL', 'Call Control by NAA',
|
||
'DISPLAY TEXT', 'SEND DTMF command', 'Proactive UICC: PROVIDE LOCAL INFORMATION (NMR)', 'Proactive UICC: PROVIDE LOCAL INFORMATION (language)',
|
||
'Proactive UICC: PROVIDE LOCAL INFORMATION (Timing Advance)', 'Proactive UICC: LANGUAGE NOTIFICATION', 'Proactive UICC: LAUNCH BROWSER', 'Proactive UICC: PROVIDE LOCAL INFORMATION (Access Technology)',
|
||
'Soft keys support for SELECT ITEM', 'Soft keys support for SET UP MENU ITEM', 'RFU', 'RFU',
|
||
'RFU', 'RFU', 'RFU', 'RFU',
|
||
'Maximum number of soft keys (value b1..b8; FF reserved)', 'Maximum number of soft keys (value b1..b8; FF reserved)', 'Maximum number of soft keys (value b1..b8; FF reserved)', 'Maximum number of soft keys (value b1..b8; FF reserved)',
|
||
'Maximum number of soft keys (value b1..b8; FF reserved)', 'Maximum number of soft keys (value b1..b8; FF reserved)', 'Maximum number of soft keys (value b1..b8; FF reserved)', 'Maximum number of soft keys (value b1..b8; FF reserved)',
|
||
'Proactive UICC: OPEN CHANNEL', 'Proactive UICC: CLOSE CHANNEL', 'Proactive UICC: RECEIVE DATA', 'Proactive UICC: SEND DATA',
|
||
'Proactive UICC: GET CHANNEL STATUS', 'Proactive UICC: SERVICE SEARCH', 'Proactive UICC: GET SERVICE INFORMATION', 'Proactive UICC: DECLARE SERVICE',
|
||
'BIP supported Bearer: CSD', 'BIP supported Bearer: GPRS', 'BIP supported Bearer: Bluetooth', 'BIP supported Bearer: IrDA',
|
||
'BIP supported Bearer: RS232', 'Number of BIP channels supported (value b6..b8)', 'Number of BIP channels supported (value b6..b8)', 'Number of BIP channels supported (value b6..b8)',
|
||
'Screen height in characters (value b1..b5)', 'Screen height in characters (value b1..b5)', 'Screen height in characters (value b1..b5)', 'Screen height in characters (value b1..b5)',
|
||
'Screen height in characters (value b1..b5)', 'No display capability (class ND)', 'No keypad available (class NK)', 'Screen Sizing Parameters supported',
|
||
'Screen width in characters (value b1..b7)', 'Screen width in characters (value b1..b7)', 'Screen width in characters (value b1..b7)', 'Screen width in characters (value b1..b7)',
|
||
'Screen width in characters (value b1..b7)', 'Screen width in characters (value b1..b7)', 'Screen width in characters (value b1..b7)', 'Variable size fonts',
|
||
'Display can be resized', 'Text Wrapping supported', 'Text Scrolling supported', 'Text Attributes supported',
|
||
'RFU', 'Width reduction when in a menu (value b6..b8)', 'Width reduction when in a menu (value b6..b8)', 'Width reduction when in a menu (value b6..b8)',
|
||
'BIP: TCP, UICC in client mode, remote connection', 'BIP: UDP, UICC in client mode, remote connection', 'BIP: TCP, UICC in server mode', 'BIP: TCP, UICC in client mode, local connection',
|
||
'BIP: UDP, UICC in client mode, local connection', 'BIP: direct communication channel', 'E-UTRAN', 'HSDPA',
|
||
'Proactive UICC: DISPLAY TEXT (variable timeout)', 'Proactive UICC: GET INKEY (help while waiting for immediate response or variable timeout)', 'USB (BIP supported bearer)', 'Proactive UICC: GET INKEY (variable timeout)',
|
||
'Proactive UICC: PROVIDE LOCAL INFORMATION (ESN)', 'CALL CONTROL on GPRS', 'Proactive UICC: PROVIDE LOCAL INFORMATION (IMEISV)', 'Proactive UICC: PROVIDE LOCAL INFORMATION (Search mode change)',
|
||
'TIA/EIA-136-270 protocol version (value b1..b4)', 'TIA/EIA-136-270 protocol version (value b1..b4)', 'TIA/EIA-136-270 protocol version (value b1..b4)', 'TIA/EIA-136-270 protocol version (value b1..b4)',
|
||
'RFU', 'RFU', 'RFU', 'RFU',
|
||
'Reserved for CCAT', 'Reserved for CCAT', 'Reserved for CCAT', 'Reserved for CCAT',
|
||
'Reserved for CCAT', 'Reserved for CCAT', 'Reserved for CCAT', 'Reserved for CCAT',
|
||
'WML', 'XHTML', 'HTML', 'CHTML',
|
||
'RFU', 'RFU', 'RFU', 'RFU',
|
||
'Support of UTRAN PS with extended parameters', 'Proactive UICC: PROVIDE LOCAL INFORMATION (battery state)', 'Proactive UICC: PLAY TONE (melody/themed tones)', 'Multi-media calls in SET UP CALL',
|
||
'Toolkit-initiated GBA', 'Proactive UICC: RETRIEVE MULTIMEDIA MESSAGE', 'Proactive UICC: SUBMIT MULTIMEDIA MESSAGE', 'Proactive UICC: DISPLAY MULTIMEDIA MESSAGE',
|
||
'Proactive UICC: SET FRAMES', 'Proactive UICC: GET FRAMES STATUS', 'MMS notification download', 'Alpha identifier in REFRESH',
|
||
'Geographical Location Reporting', 'Proactive UICC: PROVIDE LOCAL INFORMATION (MEID)', 'Proactive UICC: PROVIDE LOCAL INFORMATION (NMR (UTRAN/E-UTRAN/Satellite E-UTRAN/NG-RAN/Satellite NG-RAN))', 'USSD Data download and application mode',
|
||
'Maximum number of frames supported (value b1..b4)', 'Maximum number of frames supported (value b1..b4)', 'Maximum number of frames supported (value b1..b4)', 'Maximum number of frames supported (value b1..b4)',
|
||
'RFU', 'RFU', 'RFU', 'RFU',
|
||
'Event: Browsing status', 'Event: MMS transfer status', 'Event: Frame information changed', 'Event: I-WLAN Access status',
|
||
'Event: Network Rejection for GERAN/UTRAN', 'Event: HCI connectivity', 'Event: Network Rejection for E-UTRAN/Satellite E-UTRAN', 'Multiple access technologies supported (Event Access Technology Change / PLI)',
|
||
'Event: CSG Cell Selection', 'Event: Contactless state request', 'RFU', 'RFU',
|
||
'RFU', 'RFU', 'RFU', 'RFU',
|
||
'RFU', 'RFU', 'RFU', 'RFU',
|
||
'RFU', 'RFU', 'RFU', 'RFU',
|
||
'Text alignment left', 'Text alignment centre', 'Text alignment right', 'Font size normal',
|
||
'Font size large', 'Font size small', 'RFU', 'RFU',
|
||
'Style normal', 'Style bold', 'Style italic', 'Style underlined',
|
||
'Style strikethrough', 'Style text foreground colour', 'Style text background colour', 'RFU',
|
||
'I-WLAN bearer support', 'Proactive UICC: PROVIDE LOCAL INFORMATION (WSID of the current I-WLAN connection)', 'TERMINAL APPLICATIONS', '"Steering of Roaming" REFRESH support',
|
||
'Proactive UICC: ACTIVATE', 'Proactive UICC: GEOGRAPHICAL LOCATION REQUEST', 'Proactive UICC: PROVIDE LOCAL INFORMATION (Broadcast Network Information)', '"Steering of Roaming for I-WLAN" REFRESH support',
|
||
'Proactive UICC: Contactless State Changed', 'Support of CSG cell discovery', 'Confirmation parameters for OPEN CHANNEL in Terminal Server mode', 'Communication Control for IMS',
|
||
'CAT over the modem interface', 'Support for Incoming IMS Data event', 'Support for IMS Registration event', 'Proactive UICC: Profile/Envelope/Command Container, ENCAPSULATED SESSION CONTROL',
|
||
'IMS support', 'Support of PROVIDE LOCAL INFORMATION, H(e)NB IP address', 'Support of PROVIDE LOCAL INFORMATION, H(e)NB surrounding macrocells', 'Launch parameters for OPEN CHANNEL in Terminal Server mode',
|
||
'Direct communication channel for OPEN CHANNEL in Terminal Server mode', 'Security for Profile/Envelope/Command Container and ENCAPSULATED SESSION CONTROL', 'CAT service list for eCAT client', 'Refresh enforcement policy',
|
||
'DNS server address request for OPEN CHANNEL (packet data bearer)', 'Network Access Name reuse indication for CLOSE CHANNEL', 'Event: Poll Interval Negotiation', 'ProSe usage information reporting',
|
||
'Proactive UICC: PROVIDE LOCAL INFORMATION (Supported Radio Access Technologies)', 'Event: WLAN Access status', 'WLAN bearer support', 'Proactive UICC: PROVIDE LOCAL INFORMATION (WLAN identifier of the current WLAN connection)',
|
||
'URI support for SEND SHORT MESSAGE', 'IMS URI supported for SET UP CALL', 'Media Type "Voice" supported for SET UP CALL and Call Control by USIM', 'Media Type "Video" supported for SET UP CALL and Call Control by USIM',
|
||
'Proactive UICC: PROVIDE LOCAL INFORMATION (E-UTRAN Timing Advance Information)', 'REFRESH with "eUICC Profile State Change" mode', 'Extended Rejection Cause Code in Event: Network Rejection for E-UTRAN', 'Deprecated, bit = 0',
|
||
'Proactive UICC: GET INPUT (Variable Time out)', 'Data Connection Status Change Event support – PDP Connection', 'Data Connection Status Change Event support – PDN Connection', 'REFRESH with "Application Update" mode',
|
||
'Proactive UICC: LSI COMMAND with "Proactive session request"', 'Proactive UICC: LSI COMMAND with "UICC Platform Reset"', 'RFU', 'RFU',
|
||
'Data Connection Status Change Event support – PDU Connection', 'Event: Network Rejection for NG-RAN', 'Non-IP Data Delivery support', 'Support of PROVIDE LOCAL INFORMATION, Slice(s) information',
|
||
'REFRESH "Steering of Roaming" SOR-CMCI parameter support', 'Event: Network Rejection for Satellite NG-RAN', 'Support of CAG feature', 'Event: Slices Status Change',
|
||
'Support of PROVIDE LOCAL INFORMATION, Rejected Slice(s) Information', 'Support of Extended information for PLI (Location Information), Event: Location Status, Event: Network Rejection', 'Support of chaining of PLI/Envelope commands', '5G ProSe usage information reporting',
|
||
'Reserved for future use', 'Reserved for future use', 'Reserved for future use', 'Reserved for future use',
|
||
'Reserved for future use', 'Reserved for future use', 'Reserved for future use', 'Reserved for future use',
|
||
'Reserved for future use', 'Reserved for future use', 'Reserved for future use', 'Reserved for future use',
|
||
'Proactive UICC: PROVIDE LOCAL INFORMATION (NG-RAN/Satellite NG-RAN Timing Advance Information)', 'Reserved for future use', 'Reserved for future use', 'Reserved for future use',
|
||
'Reserved for future use', 'Reserved for future use', 'Reserved for future use', 'Reserved for future use',
|
||
];
|
||
|
||
// Value fields: spec table rows that carry a number instead of a flag.
|
||
// Bit ranges are 1-based within the byte, b_from = LSB of the value
|
||
// (TS 102 223 5.2: e.g. byte 13 b6..b8 = number of BIP channels,
|
||
// byte 14 b1..b5 = characters down). The Configure dialog renders these
|
||
// as number inputs instead of per-bit checkboxes. `hint` is shown in the UI.
|
||
const TP_VALUE_FIELDS = [
|
||
{ byte: 11, from: 1, to: 8, label: 'Maximum number of soft keys', hint: "'FF' is reserved for future use" },
|
||
{ byte: 13, from: 6, to: 8, label: 'Number of BIP channels supported' },
|
||
{ byte: 14, from: 1, to: 5, label: 'Screen height (characters down)' },
|
||
{ byte: 15, from: 1, to: 7, label: 'Screen width (characters across)' },
|
||
{ byte: 16, from: 6, to: 8, label: 'Width reduction when in a menu' },
|
||
{ byte: 19, from: 1, to: 4, label: 'TIA/EIA-136-270 protocol version' },
|
||
{ byte: 24, from: 1, to: 4, label: 'Maximum number of frames supported' },
|
||
];
|
||
|
||
function tpValueFieldFor(byteNr) {
|
||
return TP_VALUE_FIELDS.find(f => f.byte === byteNr) || null;
|
||
}
|
||
|
||
function tpValueWidth(f) { return f.to - f.from + 1; }
|
||
|
||
function tpGetValue(hex, f) {
|
||
const byteIdx = f.byte - 1;
|
||
if (byteIdx * 2 + 2 > hex.length) return null;
|
||
const b = parseInt(hex.substr(byteIdx * 2, 2), 16);
|
||
return (b >> (f.from - 1)) & ((1 << tpValueWidth(f)) - 1);
|
||
}
|
||
|
||
function tpSetValue(hex, f, value) {
|
||
const bytes = hex.match(/../g) || [];
|
||
const byteIdx = f.byte - 1;
|
||
while (bytes.length <= byteIdx) bytes.push('00');
|
||
let b = parseInt(bytes[byteIdx], 16);
|
||
const width = tpValueWidth(f);
|
||
const mask = ((1 << width) - 1) << (f.from - 1);
|
||
let v = parseInt(value, 10);
|
||
if (!isFinite(v)) v = 0;
|
||
v = Math.max(0, Math.min((1 << width) - 1, v));
|
||
b = (b & ~mask) | ((v << (f.from - 1)) & mask);
|
||
bytes[byteIdx] = b.toString(16).padStart(2, '0').toUpperCase();
|
||
return bytes.join('');
|
||
}
|
||
|
||
// TERMINAL PROFILE presets (device/model -> profile hex). Add new entries
|
||
// here; the Configure dialog lists them by name.
|
||
const TP_PRESETS = [
|
||
{ name: 'Xiaomi Mi A1 (BIP-capable handset, project default)', profile: 'FFFFFFFF7F9F00DFFF03021FE2000000C3FB000704117800710100000038428003' },
|
||
{ name: 'Quectel GSM module (AT+STKPD manual example)', profile: 'FFFFFFFF7F1F007FFF00001F230811060700' },
|
||
{ name: 'Samsung S21+ 5G', profile: 'FFFFFFFF7F9D00DFFF00001FE2000000C3EB00000000400050000000000800000000' },
|
||
{ name: 'Samsung A55 5G', profile: 'FFFFFFFF7F9D00DFFF00001FE2000000C3EB000000004000500000000008000003303002000001' },
|
||
{ name: 'Xiaomi Redmi Note 10 LTE', profile: 'FFFFFFFF7F9F00FFFF03021FE2000000C3EB00000411580071000000003808800700' },
|
||
{ name: 'Sony Xperia Z5c LTE', profile: 'FFFFFFFF7F9F00DFFF03071FE2080C00C3EB00000001480051000000000800' },
|
||
{ name: 'Huawei E5573c / M150 (LTE)', profile: 'FFFFFFEF1F9C009B8500001F22000000C3CB000000010000900000000008' },
|
||
{ name: 'Huawei E173 3G modem', profile: '2965D7E11F90001FD50000000000000000E00000' },
|
||
{ name: 'Nokia 7210 2G', profile: 'FFFFFFFF7F3F00DFFF00001FE28A0D0203290000020040' },
|
||
];
|
||
|
||
function tpNorm(hex) { return (hex || '').toUpperCase().replace(/[^0-9A-F]/g, ''); }
|
||
|
||
function tpValid(hex) { return hex.length > 0 && hex.length % 2 === 0 && hex.length <= 510; }
|
||
|
||
// Bit numbering follows TS 102 223 5.2 exactly: within a byte the spec tables
|
||
// list b1 (the least significant bit) first, so TP_BITS[n] is bit b((n % 8)+1)
|
||
// of byte (n >> 3)+1. tpGetBit/tpSetBit therefore use the LSB-first mask
|
||
// (cross-checked against Wireshark's packet-gsm_sim.c: "Profile download"=0x01).
|
||
function tpGetBit(hex, n) {
|
||
const byteIdx = n >> 3;
|
||
if (byteIdx * 2 + 2 > hex.length) return null;
|
||
const b = parseInt(hex.substr(byteIdx * 2, 2), 16);
|
||
return !!(b & (1 << (n & 7)));
|
||
}
|
||
|
||
function tpSetBit(hex, n, on) {
|
||
const bytes = hex.match(/../g) || [];
|
||
const byteIdx = n >> 3;
|
||
while (bytes.length <= byteIdx) bytes.push('00');
|
||
let b = parseInt(bytes[byteIdx], 16);
|
||
const mask = 1 << (n & 7);
|
||
b = on ? (b | mask) : (b & ~mask);
|
||
bytes[byteIdx] = b.toString(16).padStart(2, '0').toUpperCase();
|
||
return bytes.join('');
|
||
}
|
||
|
||
function tpBitLabel(n) {
|
||
if (n < TP_BITS.length) return TP_BITS[n];
|
||
return 'RFU (byte ' + ((n >> 3) + 1) + ' b' + ((n & 7) + 1) + ')';
|
||
}
|
||
|
||
// Fixed byte-block layout: [firstByte, lastByte, columns] with 1-based
|
||
// inclusive ranges (0 = "to the end of the profile"). Each byte block is a
|
||
// vertical bit list; the groups place the blocks in 1..4 columns.
|
||
const TP_LAYOUT = [
|
||
[1, 12, 2],
|
||
[13, 16, 4],
|
||
[17, 18, 2],
|
||
[19, 21, 3],
|
||
[22, 25, 2],
|
||
[26, 28, 3],
|
||
[29, 30, 2],
|
||
[31, 0, 1],
|
||
];
|
||
|
||
function tpLayoutGroups(totalBytes) {
|
||
const groups = [];
|
||
for (const [from, to, cols] of TP_LAYOUT) {
|
||
const end = to === 0 ? totalBytes : Math.min(to, totalBytes);
|
||
if (from > end) continue;
|
||
groups.push({from: from, to: end, cols: cols});
|
||
}
|
||
return groups;
|
||
}
|
||
|
||
function tpRenderForm() {
|
||
const el = document.getElementById('tp-form');
|
||
if (!el) return;
|
||
const hex = tpNorm(document.getElementById('tp-hex').value);
|
||
if (!tpValid(hex)) { el.innerHTML = ''; return; }
|
||
const bytes = hex.length / 2;
|
||
let html = '';
|
||
for (const g of tpLayoutGroups(bytes)) {
|
||
html += '<div style="display:grid;grid-template-columns:repeat(' + g.cols +
|
||
',minmax(0,1fr));gap:0.5rem;align-items:start;margin-bottom:0.5rem">';
|
||
for (let bi = g.from - 1; bi < g.to; bi++) {
|
||
html += '<div class="border border-gray-200 dark:border-slate-700 rounded p-1.5">';
|
||
html += '<div class="text-gray-500 dark:text-slate-400 mb-0.5">' + esc(t('Byte')) + ' ' + (bi + 1) + '</div>';
|
||
for (let k = 0; k < 8; k++) {
|
||
const n = bi * 8 + k;
|
||
const bitNr = k + 1;
|
||
const vf = tpValueFieldFor(bi + 1);
|
||
if (vf && bitNr >= vf.from && bitNr <= vf.to) {
|
||
// One number input for the whole value field, placed at
|
||
// the field's first (least significant) bit.
|
||
if (bitNr === vf.from) {
|
||
const val = tpGetValue(hex, vf);
|
||
const max = (1 << tpValueWidth(vf)) - 1;
|
||
const range = 'b' + vf.from + (vf.to > vf.from ? '..b' + vf.to : '');
|
||
const title = range + ' ' + vf.label + (vf.hint ? ' — ' + vf.hint : '');
|
||
html += '<label class="flex items-start gap-1 mb-0.5">' +
|
||
'<span class="shrink-0" title="' + escHtml(title) + '">' + esc(range) + '</span>' +
|
||
'<input type="number" min="0" max="' + max + '" value="' + (val === null ? '' : val) + '" ' +
|
||
'class="w-16 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-0.5 dark:bg-slate-800" ' +
|
||
'onchange="tpValueInput(' + vf.byte + ', ' + vf.from + ', ' + vf.to + ', this)"> ' +
|
||
'<span title="' + escHtml(vf.label) + '">' + esc(vf.label) +
|
||
(vf.hint ? ' <span class="text-gray-400 dark:text-slate-500">(' + esc(vf.hint) + ')</span>' : '') +
|
||
'</span></label>';
|
||
}
|
||
continue;
|
||
}
|
||
const mask = 'b' + bitNr; // b1 first, matching the spec tables
|
||
const label = tpBitLabel(n);
|
||
html += '<label class="flex items-start gap-1 mb-0.5">' +
|
||
'<input type="checkbox" class="mt-0.5 shrink-0" ' +
|
||
(tpGetBit(hex, n) ? 'checked ' : '') +
|
||
'onchange="tpToggle(' + n + ', this.checked)"> ' +
|
||
'<span title="' + escHtml(mask + ' ' + label) + '">' + esc(mask + ' ' + label) + '</span></label>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
function tpToggle(n, on) {
|
||
const hex = tpNorm(document.getElementById('tp-hex').value);
|
||
if (!tpValid(hex)) return;
|
||
document.getElementById('tp-hex').value = tpSetBit(hex, n, on);
|
||
tpSyncPreset();
|
||
}
|
||
|
||
function tpValueInput(byteNr, from, to, el) {
|
||
const hex = tpNorm(document.getElementById('tp-hex').value);
|
||
if (!tpValid(hex)) return;
|
||
const f = { byte: byteNr, from: from, to: to };
|
||
const next = tpSetValue(hex, f, el.value);
|
||
document.getElementById('tp-hex').value = next;
|
||
el.value = tpGetValue(next, f); // show the clamped value
|
||
tpSyncPreset();
|
||
}
|
||
|
||
function tpHexInput() {
|
||
const err = document.getElementById('tp-error');
|
||
const hex = tpNorm(document.getElementById('tp-hex').value);
|
||
if (!tpValid(hex)) {
|
||
err.textContent = t('Profile must be non-empty hex with an even number of digits');
|
||
err.classList.remove('hidden');
|
||
document.getElementById('tp-form').innerHTML = '';
|
||
return;
|
||
}
|
||
err.classList.add('hidden');
|
||
tpRenderForm();
|
||
tpSyncPreset();
|
||
}
|
||
|
||
function tpSyncPreset() {
|
||
const sel = document.getElementById('tp-preset');
|
||
if (!sel) return;
|
||
const hex = tpNorm(document.getElementById('tp-hex').value);
|
||
const idx = TP_PRESETS.findIndex(p => p.profile === hex);
|
||
sel.value = idx >= 0 ? String(idx) : 'custom';
|
||
}
|
||
|
||
function tpPresetChanged() {
|
||
const sel = document.getElementById('tp-preset');
|
||
if (!sel || sel.value === 'custom') return;
|
||
const p = TP_PRESETS[parseInt(sel.value, 10)];
|
||
if (!p) return;
|
||
document.getElementById('tp-hex').value = p.profile;
|
||
tpHexInput();
|
||
}
|
||
|
||
function tpStatusMsg(text, cls) {
|
||
const el = document.getElementById('tp-status');
|
||
if (!el) return;
|
||
el.textContent = text || '';
|
||
el.className = 'text-xs mt-1 ' + (cls || 'text-gray-400 dark:text-slate-500');
|
||
}
|
||
|
||
async function tpRefresh() {
|
||
try {
|
||
return await pysimFetch('/api/terminal-profile');
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function tpOpen() {
|
||
const d = await tpRefresh();
|
||
const sel = document.getElementById('tp-preset');
|
||
let html = '<option value="custom">' + esc(t('Custom')) + '</option>';
|
||
TP_PRESETS.forEach((p, i) => {
|
||
html += '<option value="' + i + '">' + esc(p.name) + '</option>';
|
||
});
|
||
sel.innerHTML = html;
|
||
document.getElementById('tp-hex').value = (d && d.profile) || '';
|
||
document.getElementById('tp-error').classList.add('hidden');
|
||
document.getElementById('tp-modal').classList.remove('hidden');
|
||
tpHexInput();
|
||
}
|
||
|
||
function tpClose() { document.getElementById('tp-modal').classList.add('hidden'); }
|
||
|
||
async function tpApply() {
|
||
const hex = tpNorm(document.getElementById('tp-hex').value);
|
||
if (!tpValid(hex)) { tpHexInput(); return; }
|
||
try {
|
||
const resp = await pysimFetch('/api/terminal-profile', { profile: hex });
|
||
if (!resp.ok) { tpStatusMsg(resp.error || t('Error'), 'text-red-500'); return; }
|
||
tpClose();
|
||
tpStatusMsg(t('Applied') + ' (' + resp.profile.length / 2 + ' ' + t('bytes') + ')' +
|
||
(resp.menu ? ' · ' + t('menu updated') : ''), 'text-emerald-600 dark:text-emerald-400');
|
||
tpRefresh();
|
||
} catch (e) {
|
||
tpStatusMsg(String(e.message || e), 'text-red-500');
|
||
}
|
||
}
|
||
|
||
async function tpSend() {
|
||
try {
|
||
const resp = await pysimFetch('/api/terminal-profile', {});
|
||
if (!resp.ok) { tpStatusMsg(resp.error || t('Error'), 'text-red-500'); return; }
|
||
tpStatusMsg(t('Sent') + (resp.menu ? ' · ' + t('menu updated') : ''),
|
||
'text-emerald-600 dark:text-emerald-400');
|
||
tpRefresh();
|
||
stkCheckMenu();
|
||
pysimEventsRender();
|
||
pysimProactiveLogRender();
|
||
} catch (e) {
|
||
tpStatusMsg(String(e.message || e), 'text-red-500');
|
||
}
|
||
}
|
||
|
||
|
||
|
||
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();
|
||
if (eventType === 0x02) updateCallCauseFields();
|
||
}
|
||
modal.classList.remove('hidden');
|
||
}
|
||
|
||
// The call-disconnected form's Cause bytes are shown only for a custom cause
|
||
// (the radio-link-timeout form is the zero-length object).
|
||
function updateCallCauseFields() {
|
||
const sel = document.getElementById('ev-f-cause_mode');
|
||
if (!sel) return;
|
||
const g = sel.value;
|
||
document.querySelectorAll('#event-send-body .event-field').forEach(div => {
|
||
const groups = div.getAttribute('data-groups') || '';
|
||
div.style.display = (groups === '' || groups.indexOf(g) >= 0) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
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;
|
||
// The device-identities source (TS 102 223 8.7): a fixed per-event value
|
||
// or a form field (the call events differ near/far end).
|
||
let eventSrc = (cfg && cfg.src) || 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);
|
||
if (cfg.srcField && values[cfg.srcField]) eventSrc = values[cfg.srcField];
|
||
} 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, event_src: eventSrc });
|
||
if (resp.sw === '9000') {
|
||
result.textContent = (eventData ? t('Event data sent.') : t('Event sent.')) + ' SW: ' + resp.sw;
|
||
if (resp.negotiation) {
|
||
let neg = t('Poll interval negotiation:') + ' ' + t(resp.negotiation.result_name);
|
||
if (resp.negotiation.seconds) neg += ' (' + resp.negotiation.seconds + ' s)';
|
||
result.textContent += ' · ' + neg;
|
||
}
|
||
result.classList.remove('hidden');
|
||
} else {
|
||
result.textContent = 'SW: ' + resp.sw;
|
||
result.classList.remove('hidden');
|
||
result.classList.add('text-amber-600');
|
||
}
|
||
if (resp.net_state) netStateRender(resp.net_state);
|
||
else netStateFetch();
|
||
} 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, resp.card_disabled, resp.warning);
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function pysimUpdatePollUI(enabled, interval, cardDisabled, warning) {
|
||
const btn = document.getElementById('pli-pause-btn');
|
||
const stat = document.getElementById('pli-poll-stat');
|
||
if (!btn) return;
|
||
// The button shows whether STATUS is actually being polled: while the card
|
||
// has disabled proactive polling (POLLING OFF, TS 102 223 6.4.14) it shows
|
||
// OFF even though the operator's switch is still on - polling resumes
|
||
// automatically on the next POLL INTERVAL.
|
||
const effective = !!enabled && !cardDisabled;
|
||
if (effective) {
|
||
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.className = 'text-xs ' + (cardDisabled ? 'text-amber-600 dark:text-amber-400' : 'text-gray-400');
|
||
if (cardDisabled) stat.textContent = warning || t('card disabled polling (POLLING OFF)');
|
||
else if (warning) stat.textContent = warning;
|
||
else stat.textContent = effective ? '(' + interval + 's)' : '';
|
||
}
|
||
}
|
||
|
||
async function pysimPollStatusInit() {
|
||
try {
|
||
const status = await pysimFetch('/api/poll-status');
|
||
pysimUpdatePollUI(status.enabled, status.interval, status.card_disabled);
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
async function pysimResetCardData(refreshStatus) {
|
||
stkMenuClose();
|
||
stkMenuStack = [];
|
||
pysimFsRefresh();
|
||
stkCheckMenu();
|
||
pysimEventsRender();
|
||
pysimProactiveLogRender();
|
||
pysimPliRender();
|
||
if (refreshStatus) await pysimRefresh();
|
||
if (isViewVisible('tab-phone')) netStateFetch();
|
||
}
|
||
|
||
function pysimCardStateUpdate(status) {
|
||
if (!status || typeof status.connected !== 'boolean') return;
|
||
ramOpProgress(status.ram_progress);
|
||
_pysimServerAvailable = true;
|
||
_pysimLastStatus = status;
|
||
_pysimCardEquipped = !!status.connected;
|
||
_pysimEquipping = !!status.equipping;
|
||
const prevIccid = _pysimCardIccid;
|
||
_pysimCardIccid = status.connected ? (status.iccid || null) : null;
|
||
if (prevIccid !== _pysimCardIccid) cardsRender(); // equipped-preset highlight
|
||
pysimUpdateAdmIndicator(status);
|
||
pysimUpdateIccidIndicator(_pysimCardIccid);
|
||
pysimUpdatePresetIndicators(_pysimCardIccid);
|
||
spPresetWarningRender();
|
||
pysimApplyAvailability();
|
||
if (pysimProactiveSeqChanged(status.proactive_seq)
|
||
&& isViewVisible('tab-phone') && isViewVisible('phone-sub-phone')) {
|
||
pysimProactiveLogRender();
|
||
}
|
||
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 (sessionChanged) pysimAdmResetAttempts();
|
||
if (status.connected) {
|
||
pysimSetConnected(true);
|
||
pysimResetCardData(true);
|
||
cardsAutoSelectByIccid(status.iccid);
|
||
return;
|
||
}
|
||
// A card being initialized (or waiting for auto-equip) shows the animated
|
||
// icon, matching the header indicator - not the static no-card one.
|
||
const initializing = status.equipping || (status.card_present && status.auto_equip);
|
||
pysimSetConnected(initializing ? 'spin' : false);
|
||
_cardsAutoIccid = null;
|
||
const statusEl = document.getElementById('pysim-status');
|
||
if (statusEl && statusEl.textContent.trim() !== '') {
|
||
if (initializing) {
|
||
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 [stk, ps, ts] = await Promise.all([
|
||
pysimFetch('/api/stk-status'),
|
||
pysimFetch('/api/poll-status'),
|
||
pysimFetch('/api/test/status').catch(() => null),
|
||
]);
|
||
pysimUpdatePollUI(ps.enabled, ps.interval, ps.card_disabled, ps.warning);
|
||
const stkChanged = pysimStkStatusChanged(stk);
|
||
if (stkChanged && isViewVisible('tab-phone')) stkCheckMenu();
|
||
// A command the server answered by itself must not leave a stale
|
||
// dialog behind (the panel is not refreshed when the tab is hidden)
|
||
if (stkChanged && !stk.pending) stkMenuPanelReset();
|
||
// A command the card sent on its own (not the reply to our click)
|
||
// opens the panel with its prompt (v3.6.38)
|
||
if (stkChanged && stk.pending && stk.pending_data && isViewVisible('tab-phone')
|
||
&& document.getElementById('stk-menu-panel').classList.contains('hidden')) {
|
||
stkMenuShowPending(stk.pending_data);
|
||
}
|
||
if (ts) {
|
||
const wasRunning = testRunActive();
|
||
_testRunState = ts;
|
||
if (wasRunning !== !!ts.running) pysimApplyAvailability();
|
||
if (isViewVisible('phone-sub-test')) testRenderRun(ts);
|
||
// idempotent: saves the counter of a run finished elsewhere
|
||
if (!ts.running) testWriteBackCounter();
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
}, 5000);
|
||
}
|
||
|
||
let _pysimStkSig = null;
|
||
|
||
function pysimStkStatusChanged(stk) {
|
||
if (!stk) return false;
|
||
const sig = [!!stk.active, !!stk.pending, stk.pending_type || ''].join('|');
|
||
if (sig === _pysimStkSig) return false;
|
||
_pysimStkSig = sig;
|
||
return true;
|
||
}
|
||
|
||
// ===== proactive command log =====
|
||
let _pysimProactiveSeq = null;
|
||
|
||
function pysimProactiveSeqChanged(seq) {
|
||
if (seq === undefined || seq === null) return false;
|
||
if (_pysimProactiveSeq === seq) return false;
|
||
_pysimProactiveSeq = seq;
|
||
return true;
|
||
}
|
||
// TS 102 223 9.4 command types (values equal the proactive command template
|
||
// tags); the log falls back to 'Cmd 0xNN' for anything not listed.
|
||
const CMD_NAMES = {
|
||
'01': 'REFRESH', '02': 'MORE TIME', '03': 'POLL INTERVAL', '04': 'POLLING OFF',
|
||
'05': 'SET UP EVENT LIST', '10': 'SET UP CALL', '11': 'SEND SS', '12': 'SEND USSD',
|
||
'13': 'SEND SHORT MESSAGE', '14': 'SEND DTMF', '15': 'LAUNCH BROWSER',
|
||
'16': 'GEOGRAPHICAL LOCATION REQUEST',
|
||
'20': 'PLAY TONE', '21': 'DISPLAY TEXT', '22': 'GET INKEY', '23': 'GET INPUT',
|
||
'24': 'SELECT ITEM', '25': 'SET UP MENU', '26': 'PROVIDE LOCAL INFORMATION',
|
||
'27': 'TIMER MANAGEMENT', '28': 'SET UP IDLE MODE TEXT',
|
||
'30': 'PERFORM CARD APDU', '31': 'POWER ON CARD', '32': 'POWER OFF CARD',
|
||
'33': 'GET READER STATUS', '34': 'RUN AT COMMAND', '35': 'LANGUAGE NOTIFICATION',
|
||
'40': 'OPEN CHANNEL', '41': 'CLOSE CHANNEL', '42': 'RECEIVE DATA', '43': 'SEND DATA',
|
||
'44': 'GET CHANNEL STATUS', '45': 'SERVICE SEARCH', '46': 'GET SERVICE INFORMATION',
|
||
'47': 'DECLARE SERVICE', '50': 'SET FRAMES', '51': 'GET FRAMES STATUS',
|
||
'60': 'RETRIEVE MULTIMEDIA MESSAGE', '61': 'SUBMIT MULTIMEDIA MESSAGE',
|
||
'62': 'DISPLAY MULTIMEDIA MESSAGE', '70': 'ACTIVATE',
|
||
'71': 'CONTACTLESS STATE CHANGED', '72': 'COMMAND CONTAINER',
|
||
'73': 'ENCAPSULATED SESSION CONTROL', '79': 'LSI COMMAND',
|
||
'81': 'End of the proactive UICC session',
|
||
};
|
||
|
||
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 (GSM)',
|
||
0x06:'AccTech', 0x07:'ESN', 0x08:'IMEISV', 0x09:'Search', 0x0A:'Batt',
|
||
0x0B:'MEID', 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', 0x1A:'SupRAT'},
|
||
'27': {0x00:'Start', 0x01:'Deactivate', 0x02:'Get'},
|
||
};
|
||
|
||
function cmdQualifierShort(typeHex, q) {
|
||
// OPEN CHANNEL qualifier is a bit field (TS 102 223 8.6):
|
||
// b1 immediate link establishment, b2 automatic reconnection,
|
||
// b3 background mode (b1 ignored), b4 DNS server address(es) requested.
|
||
if (typeHex === '40') {
|
||
const p = [];
|
||
if (q & 0x04) p.push('Background');
|
||
else if (q & 0x01) p.push('Immediate');
|
||
else p.push('OnDemand');
|
||
if (q & 0x02) p.push('AutoReconn');
|
||
if (q & 0x08) p.push('DNS');
|
||
return p.join('+');
|
||
}
|
||
const qs = CMD_QUALIFIER_SHORT[typeHex];
|
||
return qs ? (qs[q] || '') : '';
|
||
}
|
||
|
||
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');
|
||
const scrollTop = el.scrollTop;
|
||
if (!el.firstChild) 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]
|
||
|| (e.type_name && e.type_name !== 'UNKNOWN' ? e.type_name : 'Cmd 0x' + e.type_hex.toUpperCase());
|
||
let qual = '';
|
||
if (e.qualifier) {
|
||
const qv = cmdQualifierShort(e.type_hex, 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;
|
||
el.scrollTop = scrollTop;
|
||
} catch (err) {
|
||
el.innerHTML = '<span class="text-red-500">Error: ' + esc(err.message) + '</span>';
|
||
}
|
||
}
|
||
|
||
// ===== SCP81: HTTP OTA listener (PSK TLS / redirect / passthru / capture) =====
|
||
let _scp81Timer = null;
|
||
let _scp81LastStatus = null;
|
||
|
||
// ===== SCP81 listener options (HTTP framing / script framing / link events) =====
|
||
// The API takes these at listener start; they are kept in localStorage so a
|
||
// test setup survives reloads. The block is collapsed by default and shows a
|
||
// 'custom' marker when anything differs from the reference defaults. TLS
|
||
// itself has no settings: the listener accepts everything it can and reports
|
||
// the negotiated version/cipher.
|
||
const SCP81_OPT_DEFAULTS = {
|
||
chunked: true, chunkSize: 0, connHeader: 'none', compact: false,
|
||
nextUriOn: true, nextUri: '/api/scp81?req=%d', linkEvents: true,
|
||
scriptTemplate: 'indefinite', crTag: false,
|
||
targetedAppOn: false, targetedApp: ''
|
||
};
|
||
|
||
function scp81OptionsState() {
|
||
const val = id => { const el = document.getElementById(id); return el ? el.value : ''; };
|
||
const on = id => { const el = document.getElementById(id); return !!(el && el.checked); };
|
||
return {
|
||
chunked: on('opt-chunked'),
|
||
chunkSize: Math.max(0, parseInt(val('opt-chunk-size'), 10) || 0),
|
||
connHeader: val('opt-conn-header') || 'none',
|
||
compact: on('opt-compact'),
|
||
nextUriOn: on('opt-next-uri'),
|
||
nextUri: val('opt-next-uri-value').trim(),
|
||
linkEvents: on('opt-link-events'),
|
||
scriptTemplate: val('opt-script-template') === 'definite' ? 'definite' : 'indefinite',
|
||
crTag: on('opt-cr-tag'),
|
||
targetedAppOn: on('opt-targeted-app-on'),
|
||
targetedApp: val('opt-targeted-app').trim()
|
||
};
|
||
}
|
||
|
||
function scp81OptionsFromForm() {
|
||
const s = scp81OptionsState();
|
||
return {
|
||
chunked: s.chunked,
|
||
chunk_size: s.chunkSize,
|
||
conn_header: s.connHeader,
|
||
compact_headers: s.compact,
|
||
next_uri: s.nextUriOn ? (s.nextUri || SCP81_OPT_DEFAULTS.nextUri) : '',
|
||
link_events: s.linkEvents,
|
||
script_template: s.scriptTemplate,
|
||
cr_tag: s.crTag,
|
||
targeted_app: s.targetedAppOn ? s.targetedApp : ''
|
||
};
|
||
}
|
||
|
||
function scp81OptionsSyncDisabled() {
|
||
const on = id => { const el = document.getElementById(id); return !!(el && el.checked); };
|
||
const sync = (id, enabled) => {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
el.disabled = !enabled;
|
||
el.classList.toggle('opacity-40', !enabled);
|
||
};
|
||
sync('opt-next-uri-value', on('opt-next-uri'));
|
||
sync('opt-targeted-app', on('opt-targeted-app-on'));
|
||
}
|
||
|
||
function scp81OptionsBadge() {
|
||
const el = document.getElementById('scp81-opts-badge');
|
||
if (!el) return;
|
||
const s = scp81OptionsState();
|
||
const d = SCP81_OPT_DEFAULTS;
|
||
const same = s.chunked === d.chunked && s.chunkSize === d.chunkSize &&
|
||
s.connHeader === d.connHeader && s.compact === d.compact &&
|
||
s.nextUriOn === d.nextUriOn && (s.nextUri || d.nextUri) === d.nextUri &&
|
||
s.linkEvents === d.linkEvents && s.scriptTemplate === d.scriptTemplate &&
|
||
s.crTag === d.crTag && s.targetedAppOn === d.targetedAppOn &&
|
||
s.targetedApp === d.targetedApp;
|
||
el.textContent = same ? '' : t('custom');
|
||
}
|
||
|
||
function scp81OptionsLoad() {
|
||
let saved = {};
|
||
try { saved = JSON.parse(localStorage.getItem('simple_scp81_opts') || '{}') || {}; } catch (e) { saved = {}; }
|
||
const opt = Object.assign({}, SCP81_OPT_DEFAULTS, saved);
|
||
if (saved.nextUriOn === undefined) opt.nextUriOn = (opt.nextUri || '') !== '';
|
||
const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
|
||
const chk = (id, v) => { const el = document.getElementById(id); if (el) el.checked = !!v; };
|
||
chk('opt-chunked', opt.chunked);
|
||
set('opt-chunk-size', opt.chunkSize);
|
||
set('opt-conn-header', opt.connHeader === 'keep-alive' ? 'keep-alive' : 'none');
|
||
chk('opt-compact', opt.compact);
|
||
chk('opt-next-uri', opt.nextUriOn);
|
||
set('opt-next-uri-value', opt.nextUri || SCP81_OPT_DEFAULTS.nextUri);
|
||
chk('opt-link-events', opt.linkEvents);
|
||
set('opt-script-template', opt.scriptTemplate === 'definite' ? 'definite' : 'indefinite');
|
||
chk('opt-cr-tag', opt.crTag);
|
||
chk('opt-targeted-app-on', opt.targetedAppOn);
|
||
set('opt-targeted-app', opt.targetedApp);
|
||
scp81OptionsSyncDisabled();
|
||
scp81OptionsBadge();
|
||
}
|
||
|
||
function scp81OptionsPersist() {
|
||
localStorage.setItem('simple_scp81_opts', JSON.stringify(scp81OptionsState()));
|
||
scp81OptionsSyncDisabled();
|
||
scp81OptionsBadge();
|
||
}
|
||
|
||
function scp81OptionsReset() {
|
||
localStorage.removeItem('simple_scp81_opts');
|
||
scp81OptionsLoad();
|
||
}
|
||
|
||
function scp81NextUriToggle() {
|
||
scp81OptionsSyncDisabled();
|
||
scp81OptionsPersist();
|
||
}
|
||
|
||
function scp81TargetedAppToggle() {
|
||
scp81OptionsSyncDisabled();
|
||
scp81OptionsPersist();
|
||
}
|
||
|
||
function scp81ModeChanged() {
|
||
const mode = document.getElementById('scp81-mode').value;
|
||
const row = document.getElementById('scp81-script-row');
|
||
if (row) row.style.display = (mode === 'tls') ? '' : 'none';
|
||
const pskNote = document.getElementById('scp81-psk-note');
|
||
if (pskNote) pskNote.classList.toggle('hidden', mode !== 'tls');
|
||
const redirectNote = document.getElementById('scp81-redirect-note');
|
||
if (redirectNote) redirectNote.classList.toggle('hidden', mode !== 'redirect');
|
||
const passthruNote = document.getElementById('scp81-passthru-note');
|
||
if (passthruNote) passthruNote.classList.toggle('hidden', mode !== 'passthru');
|
||
// HTTP and script framing are properties of our TLS listener
|
||
const httpOpts = document.getElementById('scp81-opts-http');
|
||
if (httpOpts) httpOpts.classList.toggle('hidden', mode !== 'tls');
|
||
const scriptOpts = document.getElementById('scp81-opts-script');
|
||
if (scriptOpts) scriptOpts.classList.toggle('hidden', mode !== 'tls');
|
||
// passthru dials the destination from the card's OPEN CHANNEL: the
|
||
// configured target fields do not apply.
|
||
for (const id of ['scp81-host', 'scp81-port']) {
|
||
const el = document.getElementById(id);
|
||
if (!el) continue;
|
||
el.disabled = (mode === 'passthru');
|
||
el.classList.toggle('opacity-40', mode === 'passthru');
|
||
}
|
||
}
|
||
|
||
function scp81SwitchSubtab(name) {
|
||
document.querySelectorAll('.scp81-subtab').forEach(btn => {
|
||
const active = btn.dataset.scp81Sub === 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);
|
||
});
|
||
document.getElementById('scp81-sub-listener').classList.toggle('hidden', name !== 'listener');
|
||
document.getElementById('scp81-sub-scripts').classList.toggle('hidden', name !== 'scripts');
|
||
if (name === 'scripts') { scriptsRender(); scriptsRebuildSelect(); }
|
||
else { scp81ModeChanged(); scp81StatusRefresh(); }
|
||
setHelpAnchor(name === 'scripts' ? 'scp81-scripts' : 'scp81-listener');
|
||
}
|
||
|
||
function scp81Msg(text, cls) {
|
||
const el = document.getElementById('scp81-msg');
|
||
el.textContent = text;
|
||
el.className = 'text-xs mt-2 ' + (cls || 'text-gray-500 dark:text-slate-400');
|
||
}
|
||
|
||
function scp81LogLine(e) {
|
||
const parts = [String(e.seq), e.kind];
|
||
if (e.index) parts.push('#' + e.index);
|
||
if (e.page) parts.push('page ' + e.page);
|
||
if (e.apdu) parts.push(e.apdu);
|
||
if (e.sw) parts.push('SW ' + e.sw);
|
||
if (e.kind === 'script-status' && e.status) parts.push('status=' + e.status);
|
||
if (e.applets !== undefined) parts.push('applets=' + e.applets);
|
||
if (e.free_nv !== undefined) parts.push('free NV=' + e.free_nv);
|
||
if (e.free_volatile !== undefined) parts.push('free vol=' + e.free_volatile);
|
||
if (e.channel) parts.push('ch' + e.channel);
|
||
if (e.requested) parts.push(e.requested);
|
||
if (e.peer) parts.push(e.peer);
|
||
if (e.target) parts.push('-> ' + e.target);
|
||
if (e.bytes) parts.push(e.bytes + 'B');
|
||
if (e.identity) parts.push('id=' + e.identity);
|
||
if (e.agent) parts.push('from=' + e.agent);
|
||
if (e.method) parts.push(e.method);
|
||
if (e.uri) parts.push(e.uri);
|
||
if (e.status && e.kind !== 'script-status') parts.push('HTTP ' + e.status);
|
||
if (e.cipher) parts.push(e.cipher);
|
||
if (e.reason) parts.push('(' + e.reason + ')');
|
||
if (e.error) parts.push(e.error);
|
||
if (e.hex) parts.push(e.hex.slice(0, 96));
|
||
return parts.join(' ');
|
||
}
|
||
|
||
function scp81LogEntryHtml(e) {
|
||
let html = esc(scp81LogLine(e));
|
||
if (e.kind === 'tls-handshake' && e.identity) {
|
||
if (e.psk_match) {
|
||
const name = (typeof cardsPskName === 'function') ? cardsPskName(e.identity) : '';
|
||
html += ' <span class="text-emerald-600 dark:text-emerald-400">[' +
|
||
esc(t('matched')) + (name ? ': ' + esc(name) : '') + ']</span>';
|
||
} else {
|
||
html += ' <span class="text-red-500">[' + esc(t('unknown identity')) + ']</span>';
|
||
}
|
||
} else if (e.kind === 'tls-psk-unknown') {
|
||
html += ' <span class="text-red-500">[' + esc(t('unknown identity')) + ']</span>';
|
||
}
|
||
return html;
|
||
}
|
||
|
||
function scp81DecodeGetStatus(hex) {
|
||
// GET STATUS TLV listing: a stream of E3 entries { 4F aid, 9F70 lifecycle,
|
||
// C5 privileges, 84 module AID, CC associated SD AID }; only complete
|
||
// entries are decoded (pages can end mid-entry).
|
||
const bytes = [];
|
||
const clean = (hex || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
for (let i = 0; i + 1 < clean.length; i += 2) bytes.push(parseInt(clean.substr(i, 2), 16));
|
||
const out = [];
|
||
let i = 0;
|
||
while (i + 2 <= bytes.length) {
|
||
if (bytes[i] !== 0xE3) { i += 1; continue; }
|
||
let ln = bytes[i + 1], off = i + 2;
|
||
if (ln === 0x81 && i + 3 <= bytes.length) { ln = bytes[i + 2]; off = i + 3; }
|
||
if (off + ln > bytes.length) break;
|
||
const content = bytes.slice(off, off + ln);
|
||
const entry = { aid: null, lifecycle: null, privileges: null, modules: [], elf: null, sd: null };
|
||
const toHex = v => v.map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase();
|
||
let k = 0;
|
||
while (k + 2 <= content.length) {
|
||
let tag = content[k], hdr = 2, tlen = content[k + 1];
|
||
if (tag === 0x9F && k + 3 <= content.length) {
|
||
tag = 0x9F70;
|
||
tlen = content[k + 2];
|
||
hdr = 3;
|
||
}
|
||
if (k + hdr + tlen > content.length) break;
|
||
const val = content.slice(k + hdr, k + hdr + tlen);
|
||
if (tag === 0x4F) entry.aid = toHex(val);
|
||
else if (tag === 0x9F70) entry.lifecycle = val.length ? val[val.length - 1].toString(16).padStart(2, '0').toUpperCase() : null;
|
||
else if (tag === 0xC5) entry.privileges = toHex(val);
|
||
else if (tag === 0x84) entry.modules.push(toHex(val));
|
||
else if (tag === 0xC4) entry.elf = toHex(val);
|
||
else if (tag === 0xCC) entry.sd = toHex(val);
|
||
k += hdr + tlen;
|
||
}
|
||
if (entry.aid) out.push(entry);
|
||
i = off + ln;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function scp81CmdLabel(apdu) {
|
||
const a = (apdu || '').toUpperCase();
|
||
if (a.startsWith('80CAFF21')) return 'GET DATA FF21 (extended card resources)';
|
||
if (a.startsWith('80CA0085')) return 'GET DATA 0085 (HTTP administration parameters)';
|
||
if (a.startsWith('80F280')) return 'GET STATUS P1=80 (Issuer Security Domain)';
|
||
if (a.startsWith('80F240')) return 'GET STATUS P1=40 (applications and security domains)';
|
||
if (a.startsWith('80F220')) return 'GET STATUS P1=20 (executable load files)';
|
||
if (a.startsWith('80F210')) return 'GET STATUS P1=10 (executable load files and modules)';
|
||
if (a.startsWith('80E6')) return 'INSTALL';
|
||
if (a.startsWith('80E8')) return 'LOAD';
|
||
if (a.startsWith('80CA')) return 'GET DATA ' + a.slice(4, 8);
|
||
return '';
|
||
}
|
||
|
||
function scp81Ascii(hex) {
|
||
let out = '';
|
||
for (let i = 0; i + 1 < hex.length; i += 2) {
|
||
const c = parseInt(hex.substr(i, 2), 16);
|
||
out += (c >= 0x20 && c < 0x7f) ? String.fromCharCode(c) : '.';
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function scp81Bcd(hex) {
|
||
// Semi-octet BCD time (h:m:s as in GP retry policy / TS 123 040 TP-SCT)
|
||
let out = '';
|
||
for (let i = 0; i < hex.length; i++) out += (i && i % 2 === 0 ? ':' : '') + hex[i];
|
||
return out;
|
||
}
|
||
|
||
function scp81DecodeAdminParams(hex) {
|
||
// GET DATA 0085 answer (TS 102 226 / GP admin session parameters):
|
||
// '85' wrapping 84 connection / 85 security / 86 retry / 89 HTTP TLVs.
|
||
const clean = (hex || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
||
const bytes = [];
|
||
for (let i = 0; i + 1 < clean.length; i += 2) bytes.push(parseInt(clean.substr(i, 2), 16));
|
||
const toHex = v => v.map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase();
|
||
const out = [];
|
||
const walk = (arr, inConnection) => {
|
||
let i = 0;
|
||
while (i + 2 <= arr.length) {
|
||
const tag = arr[i], ln = arr[i + 1];
|
||
if (i + 2 + ln > arr.length) break;
|
||
const val = arr.slice(i + 2, i + 2 + ln);
|
||
const vhex = toHex(val);
|
||
if (tag === 0x84) {
|
||
out.push('connection:');
|
||
} else if (inConnection) {
|
||
if (tag === 0xC7 && val.length >= 2) {
|
||
out.push(' apn=' + scp81Ascii(toHex(val.slice(1))));
|
||
} else if (tag === 0xBE && val.length >= 5 && val[0] === 0x21) {
|
||
out.push(' dest=' + val.slice(1, 5).join('.'));
|
||
} else {
|
||
out.push(' ' + tag.toString(16).toUpperCase().padStart(2, '0') + '=' + vhex);
|
||
}
|
||
} else if (tag === 0x85 && val.length >= 20) {
|
||
// Security parameters: an unframed [14] tag, the PSK identity
|
||
// (20 bytes, ASCII) and the [02] KVN/KID pair - exactly like
|
||
// the trigger's 85 TLV (see the reference '85 18' sample).
|
||
let raw = val;
|
||
if (raw[0] === 0x14 || raw[0] === 0x15) raw = raw.slice(1);
|
||
const ident = scp81Ascii(toHex(raw.slice(0, 20)));
|
||
const rest = raw.slice(20);
|
||
const keyset = (rest[0] === 0x02 && rest.length >= 3)
|
||
? toHex(rest.slice(1, 3)) : null;
|
||
out.push('PSK id=' + ident + ' KVN/KID=' +
|
||
(keyset ? keyset.slice(0, 2) + '/' + keyset.slice(2) : '?'));
|
||
} else if (tag === 0x86) {
|
||
const cntr = val.length >= 2 ? val[0] * 256 + val[1] : 0;
|
||
let timer = null, j = 2;
|
||
while (j + 2 <= val.length) {
|
||
if (val[j] === 0x25 && val[j + 1] === 3 && j + 5 <= val.length) {
|
||
timer = scp81Bcd(toHex(val.slice(j + 2, j + 5)));
|
||
}
|
||
j += 2 + val[j + 1];
|
||
}
|
||
out.push('retry counter=' + cntr + (timer ? ' timer=' + timer : ''));
|
||
} else if (tag === 0x89) {
|
||
let j = 0;
|
||
while (j + 2 <= val.length) {
|
||
const t2 = val[j], l2 = val[j + 1];
|
||
if (j + 2 + l2 > val.length) break;
|
||
const v2 = toHex(val.slice(j + 2, j + 2 + l2));
|
||
if (t2 === 0x8A) out.push('host=' + scp81Ascii(v2));
|
||
else if (t2 === 0x8B) out.push('agent=' + scp81Ascii(v2));
|
||
else if (t2 === 0x8C) out.push('uri=' + scp81Ascii(v2));
|
||
else out.push('http tlv ' + t2.toString(16).toUpperCase() + '=' + v2);
|
||
j += 2 + l2;
|
||
}
|
||
} else if (tag === 0xC7 && val.length >= 2) {
|
||
out.push('apn=' + scp81Ascii(toHex(val.slice(1))));
|
||
} else if (tag === 0xBE && val.length >= 5 && val[0] === 0x21) {
|
||
out.push('dest=' + val.slice(1, 5).join('.'));
|
||
} else {
|
||
out.push('tlv ' + tag.toString(16).toUpperCase().padStart(2, '0') + '=' + vhex);
|
||
}
|
||
if (tag === 0x84) walk(val, true);
|
||
i += 2 + ln;
|
||
}
|
||
};
|
||
if (bytes[0] === 0x85 && bytes.length > 2) walk(bytes.slice(2, 2 + bytes[1]), false);
|
||
else walk(bytes, false);
|
||
return out;
|
||
}
|
||
|
||
function scp81GroupResults(script) {
|
||
// Group R-APDU results by their originating command (first three bytes):
|
||
// auto-continued SW CAFE pages end up under the same command.
|
||
const groups = [];
|
||
const byKey = {};
|
||
(script.results || []).forEach(r => {
|
||
const apdu = (r.apdu || '').toUpperCase();
|
||
const key = apdu.slice(0, 6) || 'unknown';
|
||
if (!byKey[key]) { byKey[key] = { key: key, apdu: apdu, results: [] }; groups.push(byKey[key]); }
|
||
byKey[key].results.push(r);
|
||
});
|
||
return groups;
|
||
}
|
||
|
||
function scp81ResultLines(group) {
|
||
const first = group.apdu || '';
|
||
const lines = [];
|
||
if (first.startsWith('80CAFF21')) {
|
||
group.results.forEach(r => {
|
||
const hex = r.rapdu || '';
|
||
if (hex.startsWith('FF21')) {
|
||
const b = [];
|
||
for (let i = 0; i + 1 < hex.length; i += 2) b.push(parseInt(hex.substr(i, 2), 16));
|
||
const vals = {};
|
||
let k = 3; // skip FF 21 <len>
|
||
while (k + 2 <= b.length) {
|
||
const tag = b[k], ln = b[k + 1], val = b.slice(k + 2, k + 2 + ln);
|
||
if (val.length < ln) break;
|
||
vals[tag] = val.reduce((acc, x) => acc * 256 + x, 0);
|
||
k += 2 + ln;
|
||
}
|
||
lines.push('applets=' + (vals[0x81] || 0) + ' free NV=' + (vals[0x82] || 0) + ' B free vol=' + (vals[0x83] || 0) + ' B');
|
||
} else {
|
||
lines.push(hex.slice(0, 64) + (r.sw && r.sw !== '9000' ? ' SW ' + r.sw : ''));
|
||
}
|
||
});
|
||
} else if (first.startsWith('80CA0085')) {
|
||
const lines2 = [];
|
||
scp81DecodeAdminParams((group.results[0] || {}).rapdu || '').forEach(l => lines2.push(l));
|
||
return lines2.length ? lines2 : ['(no parameters decoded)'];
|
||
} else if (first.startsWith('80F2')) {
|
||
const entries = [];
|
||
const seen = {};
|
||
group.results.forEach(r => entries.push.apply(entries, scp81DecodeGetStatus(r.rapdu || '')));
|
||
const unique = entries.filter(e => {
|
||
if (seen[e.aid]) return false;
|
||
seen[e.aid] = true;
|
||
return true;
|
||
});
|
||
unique.forEach(e => {
|
||
const priv = (typeof decodePrivileges === 'function' && e.privileges) ? decodePrivileges(e.privileges) : (e.privileges || '');
|
||
lines.push(e.aid + jcAidSuffix(e.aid) + (e.lifecycle ? ' life=' + e.lifecycle : '') + (priv ? ' [' + priv + ']' : '') +
|
||
(e.elf ? ' elf=' + e.elf + jcAidSuffix(e.elf) : '') +
|
||
(e.modules.length ? ' module=' + e.modules.map(m => m + jcAidSuffix(m)).join(',') : '') +
|
||
(e.sd ? ' sd=' + e.sd + jcAidSuffix(e.sd) : ''));
|
||
});
|
||
const bad = group.results.filter(r => r.sw && r.sw !== '9000' && r.sw !== 'CAFE');
|
||
bad.forEach(r => lines.push('SW ' + r.sw));
|
||
} else {
|
||
group.results.forEach(r => lines.push((r.rapdu || '') + (r.sw ? ' SW ' + r.sw : '')));
|
||
}
|
||
return lines;
|
||
}
|
||
|
||
// Human-readable progress line for the configured script. `done`/`total`
|
||
// count only the script's own APDUs; listing continuation pages are separate,
|
||
// so a fully executed script never reads as 'pending' while a page is in
|
||
// flight.
|
||
function scp81ScriptStateText(script) {
|
||
const total = script.total || (script.script || []).length;
|
||
if (!script.kind || script.kind === 'none' || !total) return '';
|
||
const executed = (script.done || []).length;
|
||
let s = script.kind + ': ' + executed + '/' + total + ' ' + t('executed');
|
||
const pending = script.pending;
|
||
if (pending && !pending.page) {
|
||
s += ' · ' + t('waiting for card');
|
||
} else if ((pending && pending.page) || script.pages_queued) {
|
||
s += ' · ' + t('listing pages') + (script.pages ? ' (' + script.pages + ')' : '') + '…';
|
||
} else if (script.complete) {
|
||
s += ' · ' + t('completed');
|
||
}
|
||
return s;
|
||
}
|
||
|
||
async function scp81ResultsRefresh() {
|
||
const el = document.getElementById('scp81-results');
|
||
if (!el) return;
|
||
try {
|
||
const script = await pysimFetch('/api/scp81/script');
|
||
const state = document.getElementById('scp81-results-state');
|
||
if (state) state.textContent = scp81ScriptStateText(script);
|
||
const groups = scp81GroupResults(script);
|
||
if (!groups.length) {
|
||
el.innerHTML = '<span class="text-gray-400">' + t('No entries yet.') + '</span>';
|
||
return;
|
||
}
|
||
el.innerHTML = groups.map(g => {
|
||
const lines = scp81ResultLines(g);
|
||
const label = scp81CmdLabel(g.apdu);
|
||
const head = g.apdu + (label ? ' ' + label : '') +
|
||
(g.results.length > 1 ? ' (' + g.results.length + ' ' + t('pages') + ')' : '');
|
||
return '<div class="py-1 border-b border-gray-100 dark:border-slate-700/50">' +
|
||
'<div class="text-gray-500 dark:text-slate-500">' + esc(head) + '</div>' +
|
||
lines.map(l => '<div class="break-all">' + esc(l) + '</div>').join('') + '</div>';
|
||
}).join('');
|
||
} catch (err) {
|
||
el.innerHTML = '<span class="text-red-500">Error: ' + esc(String(err.message || err)) + '</span>';
|
||
}
|
||
}
|
||
|
||
async function scp81StatusRefresh() {
|
||
const el = document.getElementById('scp81-state');
|
||
try {
|
||
const st = await pysimFetch('/api/scp81/status');
|
||
_scp81LastStatus = st;
|
||
const l = st.listener;
|
||
const bip = st.bip || {};
|
||
if (!l) {
|
||
el.textContent = t('not running');
|
||
return st;
|
||
}
|
||
let s = l.mode + (l.host && l.port ? ' ' + l.host + ':' + l.port : '');
|
||
if (st.owner === 'bip') s = t('BIP session:') + ' ' + s;
|
||
if (l.mode === 'tls') {
|
||
const ids = l.psk_identities || [];
|
||
s += ' | PSK: ' + ids.length;
|
||
if (l.identity_seen) {
|
||
const name = cardsPskName(l.identity_seen);
|
||
s += ' | id: ' + l.identity_seen + ' [' +
|
||
(l.identity_matched
|
||
? t('matched') + (name ? ': ' + name : '')
|
||
: t('unknown identity')) + ']';
|
||
}
|
||
if (l.version_seen) {
|
||
s += ' | TLS ' + l.version_seen + (l.cipher_seen ? ' ' + l.cipher_seen : '');
|
||
}
|
||
s += ' | ' + (l.chunked
|
||
? ('chunked' + (l.chunk_size ? ' ' + l.chunk_size : ''))
|
||
: 'content-length');
|
||
}
|
||
const ch = (bip.channels || []).map(c => 'ch' + c.id + (c.target ? ' → ' + c.target : '') + ' in:' + c.bytes_in + ' out:' + c.bytes_out).join(', ');
|
||
if (ch) s += ' | ' + ch;
|
||
el.textContent = s;
|
||
return st;
|
||
} catch (err) {
|
||
el.textContent = String(err.message || err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function scp81LogRefresh() {
|
||
const el = document.getElementById('scp81-log');
|
||
try {
|
||
const log = await pysimFetch('/api/scp81/log');
|
||
const entries = (log.entries || []).slice(-200);
|
||
if (!entries.length) {
|
||
el.innerHTML = '<span class="text-gray-400">' + t('No entries yet.') + '</span>';
|
||
return;
|
||
}
|
||
el.innerHTML = entries.map(e =>
|
||
'<div class="py-0.5 border-b border-gray-100 dark:border-slate-700/50 break-all">' + scp81LogEntryHtml(e) + '</div>'
|
||
).join('');
|
||
} catch (err) {
|
||
el.innerHTML = '<span class="text-red-500">Error: ' + esc(String(err.message || err)) + '</span>';
|
||
}
|
||
}
|
||
|
||
async function scp81Start() {
|
||
const mode = document.getElementById('scp81-mode').value;
|
||
const hostVal = document.getElementById('scp81-host').value.trim();
|
||
const portVal = document.getElementById('scp81-port').value.trim();
|
||
if (mode === 'redirect' && (!hostVal || !portVal)) {
|
||
scp81Msg(t('Redirect requires the target host and port'), 'text-red-500');
|
||
return;
|
||
}
|
||
const body = { action: 'start', mode: mode };
|
||
if (mode !== 'passthru') {
|
||
body.host = hostVal || '127.0.0.1';
|
||
body.port = parseInt(portVal || '8443', 10);
|
||
}
|
||
const opts = scp81OptionsFromForm();
|
||
body.link_events = opts.link_events;
|
||
if (mode === 'tls') {
|
||
body.chunked = opts.chunked;
|
||
body.chunk_size = opts.chunk_size;
|
||
body.conn_header = opts.conn_header;
|
||
body.compact_headers = opts.compact_headers;
|
||
body.next_uri = opts.next_uri;
|
||
body.script_template = opts.script_template;
|
||
body.cr_tag = opts.cr_tag;
|
||
body.targeted_app = opts.targeted_app || null;
|
||
}
|
||
scp81OptionsPersist();
|
||
if (mode === 'tls') {
|
||
const map = cardsPskMap();
|
||
if (!map.length) {
|
||
scp81Msg(t('No card preset has a PSK identity and key') +
|
||
' (' + t('Cards') + ')', 'text-red-500');
|
||
return;
|
||
}
|
||
body.psk_map = map;
|
||
const sel = document.getElementById('scp81-script');
|
||
const chosen = scripts.find(s => s.id === sel.value);
|
||
if (chosen) {
|
||
body.script = chosen.apdus;
|
||
body.script_kind = chosen.name;
|
||
} else {
|
||
body.script = 'none';
|
||
}
|
||
}
|
||
try {
|
||
const resp = await pysimFetch('/api/scp81/bip', body);
|
||
if (resp.ok) {
|
||
const notice = bipReplacedNotice(resp.replaced);
|
||
scp81Msg(t('Listening.') + (notice ? ' ' + notice : ''),
|
||
'text-emerald-600 dark:text-emerald-400');
|
||
} else scp81Msg(resp.error || t('Error'), 'text-red-500');
|
||
scp81StatusRefresh();
|
||
scp81LogRefresh();
|
||
scp81ResultsRefresh();
|
||
} catch (err) {
|
||
scp81Msg(String(err.message || err), 'text-red-500');
|
||
}
|
||
}
|
||
|
||
// Push preset PSK edits into a running listener (no restart needed). The
|
||
// table's signature is compared first: cardsSave() also runs on SCP80 counter
|
||
// syncs, which must not re-post an unchanged table. Without a TLS listener
|
||
// the server just answers 'not running'.
|
||
let _scp81PskSig = null;
|
||
|
||
function scp81PushPskMap() {
|
||
const map = cardsPskMap();
|
||
const sig = JSON.stringify(map);
|
||
if (sig === _scp81PskSig) return;
|
||
_scp81PskSig = sig;
|
||
if (!map.length) return;
|
||
pysimFetch('/api/scp81/psk-map', {psk_map: map})
|
||
.then(() => scp81StatusRefresh())
|
||
.catch(() => {});
|
||
}
|
||
|
||
async function scp81RestartScript() {
|
||
const sel = document.getElementById('scp81-script');
|
||
const chosen = scripts.find(s => s.id === sel.value);
|
||
if (!chosen) {
|
||
scp81Msg(t('Select a script'), 'text-red-500');
|
||
return;
|
||
}
|
||
try {
|
||
const resp = await pysimFetch('/api/scp81/queue',
|
||
{apdus: chosen.apdus, kind: chosen.name, force: true});
|
||
if (resp.ok) scp81Msg(t('Script restarted') + ': ' + chosen.name,
|
||
'text-emerald-600 dark:text-emerald-400');
|
||
else scp81Msg(resp.error || resp.reason || t('Error'), 'text-red-500');
|
||
scp81ResultsRefresh();
|
||
} catch (err) {
|
||
scp81Msg(String(err.message || err), 'text-red-500');
|
||
}
|
||
}
|
||
|
||
async function scp81Stop() {
|
||
try {
|
||
await pysimFetch('/api/scp81/bip', { action: 'stop' });
|
||
scp81Msg(t('Stopped.'), 'text-gray-500 dark:text-slate-400');
|
||
scp81StatusRefresh();
|
||
scp81LogRefresh();
|
||
scp81ResultsRefresh();
|
||
} catch (err) {
|
||
scp81Msg(String(err.message || err), 'text-red-500');
|
||
}
|
||
}
|
||
|
||
async function scp81LogClear() {
|
||
try {
|
||
await pysimFetch('/api/scp81/log-clear', {});
|
||
scp81LogRefresh();
|
||
scp81ResultsRefresh();
|
||
} catch (err) {
|
||
scp81Msg(String(err.message || err), 'text-red-500');
|
||
}
|
||
}
|
||
|
||
// ===== SCP81 scripts (named APDU lists) =====
|
||
// A script is only a list of C-APDUs: the server is agnostic to what they do
|
||
// and just serves them to the card, tracking which ones were executed.
|
||
const SCP81_EXPLORE_APDUS = ['80CAFF2100', '80F28002024F0000', '80CA008500',
|
||
'80F24002024F0000', '80F22002024F0000', '80F21002024F0000'];
|
||
let scripts = [];
|
||
let _scriptsEditId = null;
|
||
let _scriptsNewKind = 'empty';
|
||
|
||
function scriptsLoad() {
|
||
try {
|
||
const d = localStorage.getItem('simple_scripts');
|
||
scripts = d ? JSON.parse(d) : [];
|
||
} catch (e) { scripts = []; }
|
||
scriptsRebuildSelect();
|
||
}
|
||
|
||
function scriptsPersist() {
|
||
localStorage.setItem('simple_scripts', JSON.stringify(scripts));
|
||
scriptsRebuildSelect();
|
||
scriptsRender();
|
||
}
|
||
|
||
function scriptsRebuildSelect() {
|
||
const sel = document.getElementById('scp81-script');
|
||
if (!sel) return;
|
||
const prev = sel.value;
|
||
let html = '<option value="none">' + t('None') + '</option>';
|
||
for (const s of scripts) {
|
||
html += '<option value="' + esc(s.id) + '">' + esc(s.name) +
|
||
' (' + (s.apdus || []).length + ')</option>';
|
||
}
|
||
sel.innerHTML = html;
|
||
if (prev && (prev === 'none' || scripts.some(s => s.id === prev))) sel.value = prev;
|
||
}
|
||
|
||
function scriptsTemplateChanged() {
|
||
const tpl = document.getElementById('scripts-template').value;
|
||
document.getElementById('scripts-install-params').classList.toggle('hidden', tpl !== 'install');
|
||
document.getElementById('scripts-delete-params').classList.toggle('hidden', tpl !== 'delete');
|
||
}
|
||
|
||
function scriptsNew() {
|
||
const tpl = document.getElementById('scripts-template').value;
|
||
scriptsMsg('');
|
||
if (tpl === 'empty') { scriptsEdit(null, 'empty', '', []); return; }
|
||
if (tpl === 'explore') { scriptsEdit(null, 'explore', 'Explore ISD', SCP81_EXPLORE_APDUS.slice()); return; }
|
||
// Install/delete: fill the parameter form above, then press Generate.
|
||
scriptsTemplateChanged();
|
||
}
|
||
|
||
function scriptsEdit(id, kind, name, apdus) {
|
||
const s = id ? scripts.find(x => x.id === id) : null;
|
||
_scriptsEditId = s ? s.id : null;
|
||
_scriptsNewKind = kind || (s && s.kind) || 'empty';
|
||
document.getElementById('scripts-name').value = s ? s.name : (name || '');
|
||
document.getElementById('scripts-apdus').value = ((s ? s.apdus : apdus) || []).join('\n');
|
||
document.getElementById('scripts-editor').classList.remove('hidden');
|
||
document.getElementById('scripts-name').focus();
|
||
}
|
||
|
||
function scriptsCancel() {
|
||
_scriptsEditId = null;
|
||
document.getElementById('scripts-editor').classList.add('hidden');
|
||
}
|
||
|
||
function scriptsMsg(text, cls) {
|
||
const el = document.getElementById('scripts-msg');
|
||
if (!el) return;
|
||
if (!text) { el.classList.add('hidden'); return; }
|
||
el.textContent = text;
|
||
el.className = 'text-xs mt-2 ' + (cls || 'text-gray-500 dark:text-slate-400');
|
||
}
|
||
|
||
function scriptsParseApdus(text) {
|
||
const out = [];
|
||
for (const line of (text || '').split(/\r?\n/)) {
|
||
const clean = line.replace(/[#;].*$/, '').replace(/\s+/g, '').toUpperCase();
|
||
if (!clean) continue;
|
||
if (!/^[0-9A-F]+$/.test(clean) || clean.length % 2 || clean.length < 8) {
|
||
return {error: line.trim()};
|
||
}
|
||
out.push(clean);
|
||
}
|
||
return {apdus: out};
|
||
}
|
||
|
||
function scp81ScriptId() {
|
||
return 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||
}
|
||
|
||
function scriptsSave() {
|
||
const name = document.getElementById('scripts-name').value.trim();
|
||
if (!name) { scriptsMsg(t('Name is required'), 'text-red-500'); return; }
|
||
const parsed = scriptsParseApdus(document.getElementById('scripts-apdus').value);
|
||
if (parsed.error !== undefined) {
|
||
scriptsMsg(t('Invalid APDU') + ': ' + parsed.error, 'text-red-500');
|
||
return;
|
||
}
|
||
if (!parsed.apdus.length) { scriptsMsg(t('Add at least one APDU'), 'text-red-500'); return; }
|
||
if (_scriptsEditId) {
|
||
const s = scripts.find(x => x.id === _scriptsEditId);
|
||
if (s) { s.name = name; s.apdus = parsed.apdus; }
|
||
} else {
|
||
scripts.push({id: scp81ScriptId(), name: name, kind: _scriptsNewKind,
|
||
created: new Date().toISOString(), apdus: parsed.apdus});
|
||
}
|
||
scriptsPersist();
|
||
scriptsCancel();
|
||
scriptsMsg(t('Saved.'), 'text-emerald-600 dark:text-emerald-400');
|
||
}
|
||
|
||
function scriptsDelete(id) {
|
||
const s = scripts.find(x => x.id === id);
|
||
if (!s) return;
|
||
if (!confirm(t('Delete script') + ' "' + s.name + '"?')) return;
|
||
scripts = scripts.filter(x => x.id !== id);
|
||
if (_scriptsEditId === id) scriptsCancel();
|
||
scriptsPersist();
|
||
}
|
||
|
||
// Script template kind -> display label. The stored kind stays
|
||
// 'explore'/'delete'/'install'/'empty' (existing localStorage scripts).
|
||
function scriptKindLabel(kind) {
|
||
const k = String(kind || '').toLowerCase();
|
||
if (k === 'explore') return t('Explore ISD');
|
||
if (k === 'delete') return t('Delete AID');
|
||
if (k === 'install') return t('Install from .cap');
|
||
return t('Empty');
|
||
}
|
||
|
||
function scriptsRender() {
|
||
const tbody = document.getElementById('scripts-tbody');
|
||
if (!tbody) return;
|
||
if (!scripts.length) {
|
||
tbody.innerHTML = '<tr><td colspan="5" class="py-2 text-gray-400">' + t('No scripts defined.') + '</td></tr>';
|
||
return;
|
||
}
|
||
let html = '';
|
||
for (const s of scripts) {
|
||
html += '<tr class="border-b border-gray-100 dark:border-slate-700">';
|
||
html += '<td class="py-1 px-2 font-medium">' + esc(s.name) + '</td>';
|
||
html += '<td class="py-1 px-2 text-xs text-gray-500 dark:text-slate-400">' + esc(scriptKindLabel(s.kind)) + '</td>';
|
||
html += '<td class="py-1 px-2 font-mono text-xs">' + ((s.apdus || []).length) + '</td>';
|
||
html += '<td class="py-1 px-2 text-xs">' + esc((s.created || '').slice(0, 10)) + '</td>';
|
||
html += '<td class="py-1 px-2 whitespace-nowrap">' +
|
||
'<button onclick="scriptsEdit(\'' + esc(s.id) + '\')" class="px-2 py-0.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700">' + t('Edit') + '</button> ' +
|
||
'<button onclick="scriptsDelete(\'' + esc(s.id) + '\')" class="px-2 py-0.5 text-xs rounded bg-red-600 text-white hover:bg-red-700">' + t('Remove') + '</button></td>';
|
||
html += '</tr>';
|
||
}
|
||
tbody.innerHTML = html;
|
||
}
|
||
|
||
async function scriptsGenerateInstall() {
|
||
const fileInput = document.getElementById('scripts-cap-file');
|
||
const file = fileInput.files[0];
|
||
if (!file) { scriptsMsg(t('Select a .cap file'), 'text-red-500'); return; }
|
||
if (!capGateOk('scripts')) { scriptsMsg(t('Analyze the CAP file first'), 'text-red-500'); return; }
|
||
if (file.size > 48 * 1024) { scriptsMsg(t('CAP file exceeds 48 kB limit'), 'text-red-500'); return; }
|
||
scriptsMsg(t('Reading CAP file...'), 'text-gray-500');
|
||
try {
|
||
const capHex = await ramReadFileHex(file);
|
||
const resp = await pysimFetch('/api/scp81/gen-install', {
|
||
cap_hex: capHex,
|
||
sd_aid: (document.getElementById('scripts-cap-sd').value || '').replace(/[^0-9a-fA-F]/g, ''),
|
||
install_params: (document.getElementById('scripts-cap-params').value || '').replace(/[^0-9a-fA-F]/g, ''),
|
||
stk_params: (document.getElementById('scripts-cap-stk').value || '').replace(/[^0-9a-fA-F]/g, ''),
|
||
make_selectable: document.getElementById('scripts-cap-makesel').checked,
|
||
});
|
||
if (!resp.ok) { scriptsMsg(resp.error || t('Error'), 'text-red-500'); return; }
|
||
scriptsMsg('');
|
||
scriptsEdit(null, 'install', 'Install ' + (resp.module_aid || ''),
|
||
resp.apdus || []);
|
||
scriptsMsg(t('INSTALL / LOAD / INSTALL') + ': ' + (resp.apdus || []).length + ' ' + t('APDUs'),
|
||
'text-emerald-600 dark:text-emerald-400');
|
||
} catch (err) {
|
||
scriptsMsg(String(err.message || err), 'text-red-500');
|
||
}
|
||
}
|
||
|
||
function scp81DeleteApdus(aids, p2) {
|
||
// GP DELETE (Card Spec v2.3.1 Table 11-20/23): one APDU per AID, P2.b8
|
||
// selects "object only" ('00') or "object and related objects" ('80');
|
||
// the data field carries the mandatory '4F' AID TLV and Le is '00'
|
||
// (the same builder the RAM Explore delete uses).
|
||
return (aids || []).map(aid => ramDeleteApdu(aid, (p2 || '00') === '80'))
|
||
.filter(a => a);
|
||
}
|
||
|
||
function scriptsGenerateDelete() {
|
||
const p2 = document.getElementById('scripts-delete-p2').value || '00';
|
||
const aids = (document.getElementById('scripts-delete-aids').value || '')
|
||
.split(/\r?\n/)
|
||
.map(s => s.replace(/\s+/g, '').toUpperCase())
|
||
.filter(Boolean);
|
||
if (!aids.length) { scriptsMsg(t('Add at least one AID'), 'text-red-500'); return; }
|
||
for (const aid of aids) {
|
||
if (!/^[0-9A-F]+$/.test(aid) || aid.length % 2) {
|
||
scriptsMsg(t('Invalid APDU') + ': ' + aid, 'text-red-500');
|
||
return;
|
||
}
|
||
}
|
||
const apdus = scp81DeleteApdus(aids, p2);
|
||
scriptsMsg('');
|
||
scriptsEdit(null, 'delete', 'Delete ' + aids.length + ' AID' + (aids.length > 1 ? 's' : ''),
|
||
apdus);
|
||
}
|
||
|
||
function scp81Enter() {
|
||
scp81ModeChanged();
|
||
scriptsLoad();
|
||
scriptsRender();
|
||
scp81StatusRefresh();
|
||
scp81LogRefresh();
|
||
setHelpAnchor(document.getElementById('scp81-sub-scripts').classList.contains('hidden')
|
||
? 'scp81-listener' : 'scp81-scripts');
|
||
if (_scp81Timer) clearInterval(_scp81Timer);
|
||
_scp81Timer = setInterval(() => {
|
||
if (document.getElementById('tab-scp81').classList.contains('hidden')) return;
|
||
scp81StatusRefresh();
|
||
scp81LogRefresh();
|
||
scp81ResultsRefresh();
|
||
}, 2000);
|
||
}
|
||
|
||
// ===== Generic BIP terminal (Simulator -> BIP) =====
|
||
let _bipTimer = null;
|
||
|
||
function bipModeChanged() {
|
||
const mode = document.getElementById('bip-mode').value;
|
||
const notes = { sink: 'bip-sink-note', passthru: 'bip-passthru-note',
|
||
redirect: 'bip-redirect-note' };
|
||
for (const key in notes) {
|
||
const el = document.getElementById(notes[key]);
|
||
if (el) el.classList.toggle('hidden', key !== mode);
|
||
}
|
||
// Host/Port apply to the fixed target and to the sink bind address;
|
||
// passthru dials the destination from the card's OPEN CHANNEL.
|
||
for (const id of ['bip-host', 'bip-port']) {
|
||
const el = document.getElementById(id);
|
||
if (!el) continue;
|
||
el.disabled = (mode === 'passthru');
|
||
el.classList.toggle('opacity-40', mode === 'passthru');
|
||
}
|
||
const port = document.getElementById('bip-port');
|
||
if (port) port.placeholder = (mode === 'sink') ? t('ephemeral') : '8443';
|
||
}
|
||
|
||
// Build the /api/bip/control start body; {error} for invalid input.
|
||
function bipStartBody(mode, host, port) {
|
||
const body = { action: 'start', mode: mode };
|
||
if (mode === 'passthru') return { body: body };
|
||
const h = String(host || '').trim();
|
||
const p = String(port || '').trim();
|
||
if (mode === 'redirect') {
|
||
const n = parseInt(p, 10);
|
||
if (!h) return { error: 'Fixed target requires the host' };
|
||
if (!Number.isInteger(n) || n < 1 || n > 65535) return { error: 'Port must be 1-65535' };
|
||
body.host = h;
|
||
body.port = n;
|
||
} else {
|
||
if (h) body.host = h;
|
||
if (p) {
|
||
const n = parseInt(p, 10);
|
||
if (!Number.isInteger(n) || n < 0 || n > 65535) return { error: 'Port must be 0-65535' };
|
||
body.port = n;
|
||
}
|
||
}
|
||
return { body: body };
|
||
}
|
||
|
||
function bipReplacedNotice(replaced) {
|
||
if (!replaced || !replaced.owner) return '';
|
||
return (replaced.owner === 'scp81' ? t('Stopped the SCP81 listener')
|
||
: t('Stopped the BIP session')) + ' (' + replaced.mode + ')';
|
||
}
|
||
|
||
function bipChannelLine(c) {
|
||
const parts = ['ch' + c.id, (c.requested || '?') + ' -> ' + (c.target || '?')];
|
||
parts.push('in:' + (c.bytes_in || 0) + ' out:' + (c.bytes_out || 0) +
|
||
' buffer:' + (c.pending || 0));
|
||
parts.push(c.peer_closed ? t('peer closed') : t('open'));
|
||
return parts.join(' ');
|
||
}
|
||
|
||
function bipStatusLine(st) {
|
||
const bip = (st && st.bip) || {};
|
||
const l = (st && st.listener) || null;
|
||
const parts = [];
|
||
if (l) {
|
||
let line = l.mode + (l.host !== undefined && l.port !== undefined
|
||
? ' ' + l.host + ':' + l.port : '');
|
||
if (l.connections !== undefined) line += ' | ' + l.connections + ' ' + t('connections');
|
||
parts.push(line);
|
||
}
|
||
if (st && st.owner) parts.push(t('owner') + ': ' + st.owner);
|
||
const ch = (bip.channels || []).map(bipChannelLine).join(' | ');
|
||
if (ch) parts.push(ch);
|
||
return parts.join(' \u00b7 ');
|
||
}
|
||
|
||
function bipMsg(text, cls) {
|
||
const el = document.getElementById('bip-msg');
|
||
el.textContent = text;
|
||
el.classList.remove('hidden');
|
||
el.className = 'text-xs mt-2 ' + (cls || 'text-gray-500 dark:text-slate-400');
|
||
}
|
||
|
||
async function bipStatusRefresh() {
|
||
const el = document.getElementById('bip-state');
|
||
const chEl = document.getElementById('bip-channels');
|
||
try {
|
||
const st = await pysimFetch('/api/bip/status');
|
||
el.textContent = bipStatusLine(st);
|
||
const chans = (st.bip && st.bip.channels) || [];
|
||
chEl.innerHTML = chans.length
|
||
? chans.map(c => '<div class="py-0.5 border-b border-gray-100 dark:border-slate-700/50 break-all">' + esc(bipChannelLine(c)) + '</div>').join('')
|
||
: '<span class="text-gray-400">' + t('No channels.') + '</span>';
|
||
return st;
|
||
} catch (err) {
|
||
el.textContent = String(err.message || err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function bipLogRefresh() {
|
||
const el = document.getElementById('bip-log');
|
||
try {
|
||
const log = await pysimFetch('/api/bip/log');
|
||
const entries = (log.entries || []).slice(-200);
|
||
if (!entries.length) {
|
||
el.innerHTML = '<span class="text-gray-400">' + t('No entries yet.') + '</span>';
|
||
return;
|
||
}
|
||
el.innerHTML = entries.map(e =>
|
||
'<div class="py-0.5 border-b border-gray-100 dark:border-slate-700/50 break-all">' + scp81LogEntryHtml(e) + '</div>'
|
||
).join('');
|
||
} catch (err) {
|
||
el.innerHTML = '<span class="text-red-500">Error: ' + esc(String(err.message || err)) + '</span>';
|
||
}
|
||
}
|
||
|
||
async function bipLogClear() {
|
||
try {
|
||
await pysimFetch('/api/bip/log-clear', {});
|
||
} catch (err) { /* keep the log as it is */ }
|
||
bipLogRefresh();
|
||
}
|
||
|
||
async function bipStart() {
|
||
const mode = document.getElementById('bip-mode').value;
|
||
const built = bipStartBody(mode,
|
||
document.getElementById('bip-host').value,
|
||
document.getElementById('bip-port').value);
|
||
if (built.error) { bipMsg(t(built.error), 'text-red-500'); return; }
|
||
try {
|
||
const resp = await pysimFetch('/api/bip/control', built.body);
|
||
if (!resp.ok) { bipMsg(t('Start failed:') + ' ' + (resp.error || ''), 'text-red-500'); return; }
|
||
const notice = bipReplacedNotice(resp.replaced);
|
||
bipMsg(t('BIP session started') + (notice ? ' \u00b7 ' + notice : ''),
|
||
'text-emerald-600 dark:text-emerald-400');
|
||
bipStatusRefresh();
|
||
bipLogRefresh();
|
||
} catch (err) {
|
||
bipMsg(String(err.message || err), 'text-red-500');
|
||
}
|
||
}
|
||
|
||
async function bipStop() {
|
||
try {
|
||
const resp = await pysimFetch('/api/bip/control', { action: 'stop' });
|
||
const notice = bipReplacedNotice(resp.replaced);
|
||
bipMsg(t('BIP session stopped') + (notice ? ' \u00b7 ' + notice : ''),
|
||
'text-gray-500 dark:text-slate-400');
|
||
bipStatusRefresh();
|
||
bipLogRefresh();
|
||
} catch (err) {
|
||
bipMsg(String(err.message || err), 'text-red-500');
|
||
}
|
||
}
|
||
|
||
function bipEnter() {
|
||
bipModeChanged();
|
||
bipStatusRefresh();
|
||
bipLogRefresh();
|
||
setHelpAnchor('bip');
|
||
if (_bipTimer) clearInterval(_bipTimer);
|
||
_bipTimer = setInterval(() => {
|
||
if (document.getElementById('phone-sub-bip').classList.contains('hidden')) return;
|
||
bipStatusRefresh();
|
||
bipLogRefresh();
|
||
}, 2000);
|
||
}
|
||
|
||
// ===== 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 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;
|
||
// TP-SCTS coding (TS 123 040 9.2.3.11): swapped semi-octet BCD
|
||
// digits, time zone in quarters of an hour with the sign in 0x08,
|
||
// 'FF' unknown - the coding of eventDateTimeTlv / _encode_scts.
|
||
const b = [];
|
||
for (let i = 0; i < 14; i += 2) b.push(parseInt(v[i] + v[i + 1], 16));
|
||
const semi = x => (x & 0x0F) * 10 + (x >> 4);
|
||
const two = x => String(x).padStart(2, '0');
|
||
const yr = semi(b[0]) < 70 ? 2000 + semi(b[0]) : 1900 + semi(b[0]);
|
||
let tz = 'unknown';
|
||
if (b[6] !== 0xFF) {
|
||
const q = (b[6] & 0x07) * 10 + (b[6] >> 4);
|
||
tz = (b[6] & 0x08 ? '-' : '+') + two(q >> 2) + ':' + two((q % 4) * 15);
|
||
}
|
||
return {
|
||
dt: yr + '-' + two(semi(b[1])) + '-' + two(semi(b[2])),
|
||
tm: two(semi(b[3])) + ':' + two(semi(b[4])) + ':' + two(semi(b[5])),
|
||
tz: tz,
|
||
};
|
||
},
|
||
encode(v) {
|
||
const dt = (v.dt||'2000-01-01').split('-'), tm = (v.tm||'00:00:00').split(':');
|
||
const semi = x => (((x % 10) << 4) | Math.floor(x / 10)) & 0xFF;
|
||
const hx = x => x.toString(16).padStart(2, '0');
|
||
let tzQ = 0;
|
||
const tzM = v.tz ? v.tz.match(/^([-+])(\d{2}):?(\d{2})$/) : null;
|
||
if (tzM) {
|
||
// quarters of an hour, swapped digits + the 0x08 sign bit
|
||
const q = Math.min(62, (parseInt(tzM[2]) || 0) * 4 +
|
||
Math.floor((parseInt(tzM[3]) || 0) / 15));
|
||
tzQ = semi(q) | (tzM[1] === '-' ? 0x08 : 0);
|
||
}
|
||
return '2607'
|
||
+ hx(semi((parseInt(dt[0], 10) || 0) % 100))
|
||
+ hx(semi(parseInt(dt[1], 10) || 1))
|
||
+ hx(semi(parseInt(dt[2], 10) || 1))
|
||
+ hx(semi(parseInt(tm[0], 10) || 0))
|
||
+ hx(semi(parseInt(tm[1], 10) || 0))
|
||
+ hx(semi(parseInt(tm[2], 10) || 0))
|
||
+ hx(tzQ);
|
||
},
|
||
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 =====
|
||
// A custom file has a canonical full path rooted at MF or an ADF
|
||
// ("MF/A153/4954", "ADF.USIM/6F07"). The parent may be the root, a custom DF
|
||
// or any DF known from the file-manager tree (MF/7F20, ...) at any depth; a
|
||
// parent not seen yet is accepted and flagged until the tree resolves it.
|
||
// Entries are stored as {path, name, kind} with kind derived from the alias
|
||
// prefix (EF./DF.).
|
||
let pysimCustomFiles = [];
|
||
let _pysimCustomDropped = 0;
|
||
const CUSTOM_ROOTS = ['MF', 'ADF.USIM', 'ADF.ISIM'];
|
||
|
||
function pysimCustomNormPath(path) {
|
||
let p = (path || '').trim().toUpperCase().replace(/\s+/g, '');
|
||
if (!p) return '';
|
||
const parts = p.split('/').filter(Boolean);
|
||
if (parts[0] === '3F00') parts[0] = 'MF';
|
||
return parts.join('/');
|
||
}
|
||
|
||
function pysimCustomKindForName(name) {
|
||
const n = (name || '').toUpperCase();
|
||
if (n.startsWith('DF.')) return 'df';
|
||
if (n.startsWith('EF.')) return 'ef';
|
||
return null;
|
||
}
|
||
|
||
function pysimCustomFid(entry) { return entry.path.split('/').pop(); }
|
||
function pysimCustomParent(entry) { return entry.path.split('/').slice(0, -1).join('/'); }
|
||
function pysimCustomRoot(entry) { return entry.path.split('/')[0]; }
|
||
|
||
// Parent DF suggestions for the current root: the root, custom DFs and every
|
||
// DF node known from the loaded file-manager tree (standard pySim files),
|
||
// at any depth.
|
||
function pysimCustomKnownDfPaths(root) {
|
||
const out = [];
|
||
const seen = new Set();
|
||
const add = p => { if (p && !seen.has(p)) { seen.add(p); out.push(p); } };
|
||
add(root);
|
||
for (const c of pysimCustomFiles) {
|
||
if (c.kind === 'df' && pysimCustomRoot(c) === root) add(c.path);
|
||
}
|
||
const walk = node => {
|
||
for (const ch of (node.children || [])) {
|
||
if (!ch.isDir) continue;
|
||
const p = pysimFsNodePath(ch);
|
||
if (p && p.split('/')[0] === root) add(p);
|
||
walk(ch);
|
||
}
|
||
};
|
||
if (pysimFsTreeRoot) walk(pysimFsTreeRoot);
|
||
return out.sort();
|
||
}
|
||
|
||
// Where a parent path comes from: root, a custom DF, a DF of the loaded tree,
|
||
// a known non-DF (error), or unknown (allowed, flagged as a warning).
|
||
function pysimCustomParentStatus(parentPath) {
|
||
const p = pysimCustomNormPath(parentPath);
|
||
if (!p || p.split('/').length <= 1) return {status: 'root', source: null};
|
||
const c = pysimCustomFiles.find(e => e.path === p);
|
||
if (c) return {status: c.kind === 'df' ? 'df' : 'not-df', source: 'custom'};
|
||
const node = (typeof pysimFsFindNodeByPath === 'function') ? pysimFsFindNodeByPath(p) : null;
|
||
if (node) return {status: node.isDir ? 'df' : 'not-df', source: 'tree'};
|
||
return {status: 'unknown', source: null};
|
||
}
|
||
|
||
function pysimCustomValidate(root, parentPath, fid, name, entries, editIndex) {
|
||
parentPath = pysimCustomNormPath(parentPath) || root;
|
||
// a bare FID chain typed without the root is completed from the selector
|
||
const first = parentPath.split('/')[0];
|
||
if (!CUSTOM_ROOTS.includes(first) && /^[0-9A-F]{4}$/.test(first)) {
|
||
parentPath = pysimCustomNormPath(root + '/' + parentPath);
|
||
}
|
||
if (!CUSTOM_ROOTS.includes(parentPath.split('/')[0])) {
|
||
return {error: t('Root must be MF, ADF.USIM or ADF.ISIM')};
|
||
}
|
||
const segs = parentPath.split('/');
|
||
if (segs.length > 1 && !segs.slice(1).every(s => /^[0-9A-F]{4}$/.test(s))) {
|
||
return {error: t('Parent must be the root or a chain of 4-hex FIDs (e.g. MF/7F20)')};
|
||
}
|
||
fid = (fid || '').trim().toUpperCase();
|
||
if (!/^[0-9A-F]{4}$/.test(fid)) return {error: t('FID must be 4 hex characters')};
|
||
const kind = pysimCustomKindForName(name);
|
||
if (!kind) return {error: t('Alias must start with "EF." or "DF."')};
|
||
// A parent known here to be an EF is an error; a parent that is a standard
|
||
// DF (or not seen yet) is fine - the tree resolves it later.
|
||
const parent = entries.find((e, i) => i !== editIndex && e.path === parentPath);
|
||
if (parent && parent.kind !== 'df') {
|
||
return {error: t('Parent is not a DF:') + ' ' + parentPath};
|
||
}
|
||
const path = parentPath + '/' + fid;
|
||
if (entries.some((e, i) => i !== editIndex && e.path === path)) {
|
||
return {error: t('File already defined:') + ' ' + path};
|
||
}
|
||
return {error: null, path: path, kind: kind};
|
||
}
|
||
|
||
function pysimCustomRewriteDescendants(entries, oldPath, newPath) {
|
||
for (const e of entries) {
|
||
if (e.path.startsWith(oldPath + '/')) {
|
||
e.path = newPath + e.path.slice(oldPath.length);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Normalize a list of raw entries (storage or import): legacy "3F00/..." and
|
||
// relative "a153/4954" forms are resolved against the custom DFs; entries
|
||
// that cannot be resolved to a valid canonical path are dropped.
|
||
function pysimCustomNormalizeEntries(raw, existing) {
|
||
const pending = raw.filter(e => e && e.path && e.name)
|
||
.map((e, i) => ({idx: i, path: pysimCustomNormPath(e.path),
|
||
name: String(e.name).toUpperCase()}));
|
||
const base = (existing || []).slice();
|
||
const accepted = base.slice();
|
||
const resolved = [];
|
||
let dropped = 0;
|
||
let progress = true;
|
||
while (pending.length && progress) {
|
||
progress = false;
|
||
for (let i = 0; i < pending.length; i++) {
|
||
const e = pending[i];
|
||
let path = e.path;
|
||
const parts = path.split('/');
|
||
if (!CUSTOM_ROOTS.includes(parts[0])) {
|
||
// relative: resolve the leading FID against a known custom DF
|
||
const df = accepted.find(x => x.kind === 'df' && pysimCustomFid(x) === parts[0]);
|
||
if (!df) continue;
|
||
path = df.path + '/' + parts.slice(1).join('/');
|
||
}
|
||
const root = path.split('/')[0];
|
||
const res = pysimCustomValidate(root, pysimCustomParent({path: path}),
|
||
pysimCustomFid({path: path}), e.name, accepted, null);
|
||
if (res.error) {
|
||
pending.splice(i, 1);
|
||
dropped++;
|
||
i--;
|
||
progress = true;
|
||
continue;
|
||
}
|
||
accepted.push({path: res.path, name: e.name, kind: res.kind});
|
||
resolved.push({idx: e.idx, entry: accepted[accepted.length - 1]});
|
||
pending.splice(i, 1);
|
||
i--;
|
||
progress = true;
|
||
}
|
||
}
|
||
dropped += pending.length;
|
||
resolved.sort((a, b) => a.idx - b.idx);
|
||
return {files: base.concat(resolved.map(r => r.entry)), dropped: dropped};
|
||
}
|
||
|
||
function pysimCustomLoad() {
|
||
let raw = [];
|
||
try {
|
||
const d = localStorage.getItem('pysim_custom_files');
|
||
raw = d ? JSON.parse(d) : [];
|
||
} catch (e) { raw = []; }
|
||
if (!Array.isArray(raw)) raw = [];
|
||
const res = pysimCustomNormalizeEntries(raw, []);
|
||
pysimCustomFiles = res.files;
|
||
_pysimCustomDropped = res.dropped;
|
||
if (res.dropped) {
|
||
console.warn('custom files: dropped ' + res.dropped + ' invalid legacy entr' +
|
||
(res.dropped === 1 ? 'y' : 'ies'));
|
||
}
|
||
pysimCustomSave();
|
||
}
|
||
|
||
function pysimCustomSave() {
|
||
localStorage.setItem('pysim_custom_files', JSON.stringify(pysimCustomFiles));
|
||
}
|
||
|
||
let pysimCustomEditIndex = null;
|
||
|
||
function pysimCustomRenderRoots() {
|
||
const sel = document.getElementById('pysim-cf-root');
|
||
if (!sel) return;
|
||
const prev = sel.value;
|
||
sel.innerHTML = CUSTOM_ROOTS.map(r => '<option value="' + esc(r) + '">' + esc(r) + '</option>').join('');
|
||
if (CUSTOM_ROOTS.includes(prev)) sel.value = prev;
|
||
}
|
||
|
||
function pysimCustomRenderParents() {
|
||
const list = document.getElementById('pysim-cf-parent-list');
|
||
if (!list) return;
|
||
const root = document.getElementById('pysim-cf-root').value || CUSTOM_ROOTS[0];
|
||
let html = '';
|
||
for (const p of pysimCustomKnownDfPaths(root)) {
|
||
const c = pysimCustomFiles.find(e => e.path === p && e.kind === 'df');
|
||
html += '<option value="' + esc(p) + '">' + esc(c ? p + ' → ' + c.name : p) + '</option>';
|
||
}
|
||
list.innerHTML = html;
|
||
}
|
||
|
||
function pysimCustomRootChanged() {
|
||
const sel = document.getElementById('pysim-cf-root');
|
||
const inp = document.getElementById('pysim-cf-parent');
|
||
const first = pysimCustomNormPath(inp && inp.value).split('/')[0];
|
||
if (CUSTOM_ROOTS.includes(first) && first !== sel.value) inp.value = sel.value;
|
||
pysimCustomRenderParents();
|
||
}
|
||
|
||
// Keep the root selector (and its suggestions) in step with a typed parent path.
|
||
function pysimCustomParentInput() {
|
||
const sel = document.getElementById('pysim-cf-root');
|
||
const inp = document.getElementById('pysim-cf-parent');
|
||
const first = pysimCustomNormPath(inp && inp.value).split('/')[0];
|
||
if (CUSTOM_ROOTS.includes(first) && sel.value !== first) {
|
||
sel.value = first;
|
||
pysimCustomRenderParents();
|
||
}
|
||
}
|
||
|
||
function pysimCustomSubmit() {
|
||
const root = document.getElementById('pysim-cf-root').value;
|
||
const parentPath = document.getElementById('pysim-cf-parent').value || root;
|
||
const fid = document.getElementById('pysim-cf-fid').value;
|
||
const nameEl = document.getElementById('pysim-cf-name');
|
||
const name = nameEl.value.trim();
|
||
const res = pysimCustomValidate(root, parentPath, fid, name, pysimCustomFiles, pysimCustomEditIndex);
|
||
if (res.error) { alert(res.error); return; }
|
||
const parent = pysimCustomParent({path: res.path});
|
||
if (pysimCustomParentStatus(parent).status === 'not-df') {
|
||
alert(t('Parent is not a DF:') + ' ' + parent);
|
||
return;
|
||
}
|
||
if (pysimCustomEditIndex !== null) {
|
||
const oldPath = pysimCustomFiles[pysimCustomEditIndex].path;
|
||
if (oldPath !== res.path) pysimCustomRewriteDescendants(pysimCustomFiles, oldPath, res.path);
|
||
pysimCustomFiles[pysimCustomEditIndex] = {path: res.path, name: name, kind: res.kind};
|
||
} else {
|
||
pysimCustomFiles.push({path: res.path, name: name, kind: res.kind});
|
||
}
|
||
pysimCustomSave();
|
||
pysimCustomRender();
|
||
pysimCustomEditCancel();
|
||
pysimCustomRefreshTree();
|
||
}
|
||
|
||
function pysimCustomEdit(i) {
|
||
const c = pysimCustomFiles[i];
|
||
if (!c) return;
|
||
pysimCustomEditIndex = i;
|
||
pysimCustomRenderRoots();
|
||
document.getElementById('pysim-cf-root').value = pysimCustomRoot(c);
|
||
pysimCustomRenderParents();
|
||
document.getElementById('pysim-cf-parent').value = pysimCustomParent(c);
|
||
document.getElementById('pysim-cf-fid').value = pysimCustomFid(c);
|
||
document.getElementById('pysim-cf-name').value = c.name;
|
||
const btn = document.getElementById('pysim-cf-add-btn');
|
||
btn.textContent = t('Save');
|
||
btn.setAttribute('data-l10n', 'Save');
|
||
document.getElementById('pysim-cf-cancel-btn').classList.remove('hidden');
|
||
document.getElementById('pysim-cf-fid').focus();
|
||
}
|
||
|
||
function pysimCustomEditCancel() {
|
||
pysimCustomEditIndex = null;
|
||
document.getElementById('pysim-cf-fid').value = '';
|
||
document.getElementById('pysim-cf-name').value = '';
|
||
pysimCustomRenderParents();
|
||
const btn = document.getElementById('pysim-cf-add-btn');
|
||
btn.textContent = t('Add');
|
||
btn.setAttribute('data-l10n', 'Add');
|
||
document.getElementById('pysim-cf-cancel-btn').classList.add('hidden');
|
||
}
|
||
|
||
function pysimCustomRemove(i) {
|
||
const c = pysimCustomFiles[i];
|
||
if (!c) return;
|
||
const children = pysimCustomFiles.filter(e => e.path.startsWith(c.path + '/'));
|
||
if (children.length) {
|
||
if (!confirm(t('Delete') + ' ' + c.path + ' ' + t('and') + ' ' + children.length + ' ' +
|
||
t('child file(s)?'))) return;
|
||
}
|
||
pysimCustomFiles = pysimCustomFiles.filter((e, j) =>
|
||
j !== i && !e.path.startsWith(c.path + '/'));
|
||
if (pysimCustomEditIndex !== null) {
|
||
if (pysimCustomEditIndex === i) pysimCustomEditCancel();
|
||
else if (pysimCustomEditIndex > i) pysimCustomEditIndex--;
|
||
}
|
||
pysimCustomSave();
|
||
pysimCustomRender();
|
||
pysimCustomRefreshTree();
|
||
}
|
||
|
||
function pysimCustomRender() {
|
||
const el = document.getElementById('pysim-cf-list');
|
||
if (!el) return;
|
||
let html = '';
|
||
if (_pysimCustomDropped) {
|
||
html += '<div class="text-amber-600 dark:text-amber-400 py-1">' + esc(t('Dropped invalid legacy entries:')) +
|
||
' ' + _pysimCustomDropped + '</div>';
|
||
}
|
||
if (!pysimCustomFiles.length) {
|
||
html += '<span class="text-gray-400">' + esc(t('No custom files defined.')) + '</span>';
|
||
el.innerHTML = html;
|
||
return;
|
||
}
|
||
for (let i = 0; i < pysimCustomFiles.length; i++) {
|
||
const c = pysimCustomFiles[i];
|
||
const warn = pysimCustomParentStatus(pysimCustomParent(c)).status === 'unknown'
|
||
? '<span class="text-amber-600 dark:text-amber-400" title="' + esc(t('Parent DF not seen in the tree yet')) + '">⚠</span> '
|
||
: '';
|
||
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">' + warn + esc(c.path) + '</span> <span class="text-blue-600 dark:text-blue-400">→</span> <b>' + esc(c.name) + '</b>';
|
||
html += '<span class="text-gray-400">(' + esc(c.kind.toUpperCase()) + ')</span>';
|
||
html += '<span class="ml-auto flex gap-2">';
|
||
html += '<button onclick="pysimCustomEdit(' + 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="pysimCustomRemove(' + 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>';
|
||
html += '</div>';
|
||
}
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
function pysimCustomExportFile() {
|
||
downloadJson('simple-custom-files.json', pysimCustomFiles);
|
||
}
|
||
|
||
function pysimCustomImport(text) {
|
||
try {
|
||
const imported = JSON.parse(text);
|
||
if (!Array.isArray(imported)) throw new Error('Expected an array');
|
||
const res = pysimCustomNormalizeEntries(imported, pysimCustomFiles);
|
||
const added = res.files.length - pysimCustomFiles.length;
|
||
pysimCustomFiles = res.files;
|
||
pysimCustomSave();
|
||
pysimCustomRender();
|
||
pysimCustomRefreshTree();
|
||
ioStatus('pysim-cf-io', 'Imported ' + added + ' file(s)' + (res.dropped ? ', dropped ' + res.dropped : '') + '.');
|
||
} 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);
|
||
}
|
||
|
||
// ===== Profiler =====
|
||
let profiles = [];
|
||
let profilerView = 'list';
|
||
let profilerEditId = null;
|
||
let profilerDraft = null;
|
||
let profilerResults = null;
|
||
let profilerResultsLabels = null;
|
||
let profilerResultsHeader = 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('simple_profiles');
|
||
profiles = d ? JSON.parse(d) : [];
|
||
} catch (e) { profiles = []; }
|
||
}
|
||
|
||
function profilerSave() {
|
||
localStorage.setItem('simple_profiles', JSON.stringify(profiles));
|
||
}
|
||
|
||
function snapshotsLoad() {
|
||
try {
|
||
const d = localStorage.getItem('simple_snapshots');
|
||
snapshots = d ? JSON.parse(d) : [];
|
||
} catch (e) { snapshots = []; }
|
||
}
|
||
|
||
function snapshotsSave() {
|
||
try {
|
||
localStorage.setItem('simple_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);
|
||
}
|
||
|
||
// Name for a cloned profile: "Copy of <name>", suffixed "(2)", "(3)" ... when
|
||
// that name is already taken.
|
||
function profilerCloneName(name, existingNames) {
|
||
const base = t('Copy of') + ' ' + (name || '');
|
||
let candidate = base;
|
||
for (let n = 2; (existingNames || []).includes(candidate); n++) {
|
||
candidate = base + ' (' + n + ')';
|
||
}
|
||
return candidate;
|
||
}
|
||
|
||
function profilerClone(i) {
|
||
const p = profiles[i];
|
||
if (!p) return;
|
||
const copy = JSON.parse(JSON.stringify(p));
|
||
copy.id = profilerNewId();
|
||
copy.name = profilerCloneName(p.name, profiles.map(x => x.name));
|
||
copy.created = new Date().toISOString();
|
||
profiles.splice(i + 1, 0, copy);
|
||
profilerSave();
|
||
profilerRenderList();
|
||
profilerEdit(i + 1);
|
||
}
|
||
|
||
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 flex-wrap 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-iccid" 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')) + '<span class="profiler-iccid-label"></span></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="profilerClone(' + i + ')" class="px-2 py-0.5 text-xs rounded bg-gray-600 text-white hover:bg-gray-700">' + esc(t('Clone')) + '</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' : (target === 'profile-snapshot' ? 'Profile from 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() {
|
||
profilerScanOpenForm('profile', '');
|
||
}
|
||
|
||
function profilerScanOpenForm(target, presetName) {
|
||
profilerScanSetTarget(target);
|
||
const nameEl = document.getElementById('profiler-scan-name');
|
||
nameEl.value = presetName || '';
|
||
nameEl.readOnly = false;
|
||
nameEl.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');
|
||
nameEl.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 === 'profile-snapshot' && _scanSnapshot) {
|
||
const rules = await profilerScanSnapshot(_scanSnapshot, ignoreFids, ignoreNames, fciMode, maskFids, onProgress);
|
||
const profile = { id: profilerNewId(), name: name, created: new Date().toISOString(), rules: rules };
|
||
profiles.push(profile);
|
||
profilerSave();
|
||
profilerScanCancel();
|
||
profilerEdit(profiles.length - 1);
|
||
} else 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 {
|
||
_scanSnapshot = null;
|
||
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 (legacy), parentPath (full path of this dir),
|
||
// pathPrefix (rule-path segs for its children) }
|
||
async function walkDir(dir) {
|
||
let body = { name: dir.name, fid: dir.fid };
|
||
const parentPath = (dir.parentPath || []).slice(0, -1);
|
||
if (parentPath.length) body.parent_path = parentPath;
|
||
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);
|
||
const childSeg = c.aid ? c.aid.toUpperCase() : (c.fid ? c.fid : c.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);
|
||
const childDirPath = dir.parentPath.concat(childSeg);
|
||
await walkDir({ name: c.name, fid: c.fid, parentSel: parentSel, parentPath: childDirPath, 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, parentPath: ['MF'], 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;
|
||
}
|
||
|
||
let _scanSnapshot = null;
|
||
|
||
function profilerBuildFileRuleFromSnapshot(f, ignoreFids, ignoreNames, fciMode, maskFids) {
|
||
const isRecord = profilerContentKindForFileType(f.fileType) === 'record';
|
||
const rule = {
|
||
type: 'file',
|
||
path: f.path,
|
||
name: f.name || null,
|
||
fileType: f.fileType || null,
|
||
fileSize: isRecord ? null : ((f.fileSize === null || f.fileSize === undefined) ? null : f.fileSize),
|
||
recordLen: (f.recordLen === null || f.recordLen === undefined) ? null : f.recordLen,
|
||
numRecords: (f.numRecords === null || f.numRecords === undefined) ? null : f.numRecords,
|
||
fciMode: fciMode || 'type_size',
|
||
fciHex: f.fciHex || null,
|
||
content: null,
|
||
};
|
||
const seg = (f.path || '').toUpperCase().split('/').pop();
|
||
const fid = /^[0-9A-F]{4}$/.test(seg) ? seg : '';
|
||
const name = (f.name || '').toUpperCase();
|
||
if (fid && ignoreFids && ignoreFids.has(fid)) return rule;
|
||
if (name && ignoreNames && ignoreNames.has(name)) return rule;
|
||
if (!f.content) return rule;
|
||
if (f.content.kind === 'record') {
|
||
rule.content = {
|
||
mode: 'exact', kind: 'record',
|
||
records: (f.content.records || []).map(r => ({ num: r.num, data: r.data })),
|
||
};
|
||
} else if (typeof f.content.data === 'string') {
|
||
const useMask = maskFids ? maskFids.has(fid) : !!PROFILER_MASK_PREFIX4_FIDS[fid];
|
||
rule.content = useMask
|
||
? { mode: 'mask', kind: 'transparent', expected: profilerMaskPrefix4(f.content.data) }
|
||
: { mode: 'exact', kind: 'transparent', expected: f.content.data };
|
||
}
|
||
return rule;
|
||
}
|
||
|
||
async function profilerScanSnapshot(snapshot, ignoreFids, ignoreNames, fciMode, maskFids, onProgress) {
|
||
const files = (snapshot && snapshot.files) ? snapshot.files : [];
|
||
const total = files.length;
|
||
if (onProgress) onProgress(0, total, '');
|
||
const rules = [];
|
||
for (let i = 0; i < files.length; i++) {
|
||
if (onProgress) onProgress(i + 1, total, files[i].path);
|
||
rules.push(profilerBuildFileRuleFromSnapshot(files[i], ignoreFids, ignoreNames, fciMode, maskFids));
|
||
if (i % 20 === 19) await new Promise(r => setTimeout(r, 0));
|
||
}
|
||
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>';
|
||
}
|
||
const dec = f.content ? efDecodeFile(f.name, efFidFromPath(f.path), f.content) : null;
|
||
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>';
|
||
const dr = dec && dec.records ? dec.records.find(x => x.num === rec.num) : null;
|
||
const sum = dr && !dr.empty ? efDataSummary(dr.data) : '';
|
||
if (sum) html += '<div class="text-xs text-gray-500 dark:text-slate-400 ml-10">' + esc(sum) + '</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">';
|
||
if (dec && dec.data && !dec.empty) {
|
||
html += '<div class="mt-1"><div class="text-xs text-gray-500 dark:text-slate-400 mb-0.5">' + esc(t('Decoded')) + '</div>' + efRenderFieldsHtml(efFlatten(dec.data)) + '</div>';
|
||
}
|
||
}
|
||
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 profilerCardIccid() {
|
||
try {
|
||
const rd = await pysimFetch('/api/read', { path: 'MF/2FE2', mode: 'raw' });
|
||
if (rd && rd.success && rd.data) return decIccid(rd.data) || null;
|
||
} catch (e) { /* EF.ICCID not readable */ }
|
||
return null;
|
||
}
|
||
|
||
async function profilerCheck(i) {
|
||
const p = profiles[i];
|
||
const iccid = await profilerCardIccid();
|
||
await profilerRunProfile(p, null, { kind: 'profile', from: p.name, to: iccid }, null,
|
||
{ expected: p.name, actual: iccid, prefix: true });
|
||
}
|
||
|
||
async function profilerRunProfile(p, source, header, extraResults, labels) {
|
||
profilerSetView('results');
|
||
profilerResultsHeader = header || null;
|
||
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 profilerSnapshotPickListHtml(actionHtml) {
|
||
let html = '';
|
||
for (let si = 0; si < snapshots.length; si++) {
|
||
const s = snapshots[si];
|
||
html += '<button onclick="' + actionHtml(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>';
|
||
}
|
||
return html;
|
||
}
|
||
|
||
function profilerCheckSnapshotPick(i) {
|
||
if (!snapshots.length) {
|
||
alert(t('No snapshots defined.'));
|
||
return;
|
||
}
|
||
document.getElementById('snapshot-pick-list').innerHTML = profilerSnapshotPickListHtml(si => 'profilerCheckSnapshot(' + i + ',' + si + ')');
|
||
document.getElementById('snapshot-pick-modal').classList.remove('hidden');
|
||
}
|
||
|
||
function profilerFromSnapshot() {
|
||
if (!snapshots.length) {
|
||
alert(t('No snapshots defined.'));
|
||
return;
|
||
}
|
||
document.getElementById('snapshot-pick-list').innerHTML = profilerSnapshotPickListHtml(si => 'profilerScanFromSnapshot(' + si + ')');
|
||
document.getElementById('snapshot-pick-modal').classList.remove('hidden');
|
||
}
|
||
|
||
function profilerScanFromSnapshot(si) {
|
||
document.getElementById('snapshot-pick-modal').classList.add('hidden');
|
||
_scanSnapshot = snapshots[si];
|
||
profilerScanOpenForm('profile-snapshot', snapshots[si].name || '');
|
||
}
|
||
|
||
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), { kind: 'profile', from: p.name, to: s.name }, null,
|
||
{ expected: p.name, actual: s.name, prefix: true });
|
||
}
|
||
|
||
// Snapshot comparison is always exact: unlike a profile (which may carry
|
||
// mask rules), every captured file is compared byte-for-byte.
|
||
function profilerRulesFromSnapshot(master) {
|
||
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 = { 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);
|
||
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;
|
||
document.getElementById('snapshot-compare-modal').classList.add('hidden');
|
||
const pseudo = { name: master.name + ' → ' + check.name, rules: profilerRulesFromSnapshot(master) };
|
||
await profilerRunProfile(pseudo, profilerSnapshotSource(check), { kind: 'snapshot', from: master.name, to: check.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 profilerResultsHeaderText(header) {
|
||
if (!header || !header.from) return '';
|
||
const base = header.kind === 'snapshot' ? t('Snapshot comparison results:') : t('Profile verification results for:');
|
||
return base + ' ' + header.from + (header.to ? ' \u2192 ' + header.to : '');
|
||
}
|
||
|
||
function profilerRenderResultsView() {
|
||
if (!profilerResults) return;
|
||
document.getElementById('profiler-results-title').textContent = profilerResultsHeaderText(profilerResultsHeader);
|
||
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;
|
||
res.recordsTotal = exp.length;
|
||
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 = pysimCustomNormPath(path);
|
||
if (!norm) return null;
|
||
for (const c of pysimCustomFiles) {
|
||
if (pysimCustomNormPath(c.path) === 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(', ');
|
||
}
|
||
|
||
// Matched record numbers with a count: [8], total 8 -> "1 of 8 (#8)"; ranges
|
||
// are prefixed too ([1,2,3,5] -> "#1–#3, #5") so the numbers cannot be read
|
||
// as a count ("matching records: 8" used to mean record number 8).
|
||
function profilerMatchedRecordsText(matched, total) {
|
||
const n = (matched || []).length;
|
||
if (!n) return '';
|
||
const nums = profilerNumRanges(matched)
|
||
.split(', ').map(p => '#' + p.replace(/-/g, '\u2013#')).join(', ');
|
||
const tot = (total === undefined || total === null) ? n : total;
|
||
return n + ' ' + t('of') + ' ' + tot + ' (' + nums + ')';
|
||
}
|
||
|
||
// 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);
|
||
// TS 102 221 Table 11.7b: the state rows all keep b8-b5 clear; b8 set is
|
||
// proprietary, any other unlisted value is RFU.
|
||
if (b & 0x80) return 'proprietary (' + v + ')';
|
||
if (b & 0x70) return 'RFU (' + v + ')';
|
||
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 'RFU (' + 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;
|
||
}
|
||
|
||
// TS 102 221 11.1.1.4.10 / 9.5.2: PS template DO 'C6' = PS_DO ('90') + optional
|
||
// usage qualifier ('95') + key reference ('83', repeatable). The PS_DO is a
|
||
// bitmap over the key references in order, MSB first; a set bit = PIN enabled.
|
||
function fcpPsTemplate(v) {
|
||
const parsed = fcpParseTlvs(v);
|
||
let psDo = null, uq = null;
|
||
const pins = [];
|
||
for (const t of parsed.tlvs) {
|
||
if (t.tag === '90') psDo = t.value;
|
||
else if (t.tag === '95') uq = t.value.length ? parseInt(t.value, 16) : 0;
|
||
else if (t.tag === '83' && t.value.length) {
|
||
const idx = pins.length;
|
||
let enabled = false;
|
||
if (psDo && psDo.length) {
|
||
const byteIdx = idx >> 3, bit = 7 - (idx & 7);
|
||
if (byteIdx * 2 + 2 <= psDo.length) enabled = ((parseInt(psDo.substr(byteIdx * 2, 2), 16) >> bit) & 1) === 1;
|
||
}
|
||
let s = pinKeyRefName(parseInt(t.value, 16)) + ': ' + (enabled ? 'enabled' : 'disabled');
|
||
if (uq === null) s += ', always verified';
|
||
else if (uq === 0) s += ', verification not used';
|
||
else if (uq & 0x08) s += ', verify';
|
||
else s += ', usage 0x' + uq.toString(16).toUpperCase().padStart(2, '0');
|
||
pins.push(s);
|
||
uq = null;
|
||
}
|
||
}
|
||
return pins.length ? pins.join('; ') : (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: fcpPsTemplate(value) };
|
||
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 profilerLabelText(labels, which) {
|
||
if (!labels || !labels[which]) return t(which);
|
||
return labels.prefix ? t(which) + ' (' + labels[which] + ')' : labels[which];
|
||
}
|
||
|
||
function fcpDiffHtml(expectedHex, actualHex, labels) {
|
||
const expLabel = profilerLabelText(labels, 'expected');
|
||
const actLabel = profilerLabelText(labels, '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(expLabel) + ': ' + esc(e.error) + '</div>';
|
||
if (aBad) html += '<div class="text-xs text-red-600 mt-0.5 pl-2">' + esc(actLabel) + ': ' + esc(a.error) + '</div>';
|
||
return html;
|
||
}
|
||
|
||
// Decoded FCI rows. The short file identifier belongs to the file
|
||
// identifier, so it is merged into that row. `opts.hideFileSize` drops the
|
||
// File size DO where the caller already shows the size (file manager header).
|
||
function profilerFciPreviewItems(hex, opts) {
|
||
const d = fcpDecode(hex);
|
||
if (!d.items.length && !d.error) return '';
|
||
opts = opts || {};
|
||
const rows = [];
|
||
for (const it of d.items) {
|
||
if (it.decoded === null) continue;
|
||
if (opts.hideFileSize && it.key === '80') continue;
|
||
rows.push({ key: it.key, name: it.name, text: it.decoded || it.value });
|
||
}
|
||
const fi = rows.find(r => r.key === '83');
|
||
const sfi = rows.find(r => r.key === '88');
|
||
if (fi && sfi) {
|
||
fi.text += ' · ' + sfi.name + ': ' + sfi.text;
|
||
rows.splice(rows.indexOf(sfi), 1);
|
||
}
|
||
let html = '';
|
||
for (const r of rows) {
|
||
html += '<div>' + esc(r.name) + ': <span class="text-gray-800 dark:text-slate-200">' + esc(r.text) + '</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 = profilerLabelText(labels, 'expected');
|
||
const actLabel = profilerLabelText(labels, 'actual');
|
||
const labelCls = 'text-xs text-gray-500 dark:text-slate-400 text-right shrink-0 ' + (labels ? ((labels.prefix ? 'w-48' : '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)) {
|
||
const isContent = c.label === 'content' || /^content\.rec\d+$/.test(c.label);
|
||
const dd = isContent && typeof efContentDiff === 'function'
|
||
? efContentDiff(r.name, efFidFromPath(r.path), String(c.expected), String(c.actual)) : null;
|
||
const inputCls = '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';
|
||
html += '<div class="mt-1">';
|
||
html += '<div class="text-xs text-red-600 font-mono pl-2">' + esc(c.label) + '</div>';
|
||
// Raw bytes first: the ground truth, always shown.
|
||
html += '<div class="text-xs text-gray-500 dark:text-slate-400 pl-2 mt-0.5">' + esc(t('Raw comparison')) + '</div>';
|
||
html += '<div class="flex items-center gap-2 mt-0.5 pl-2">' +
|
||
'<span class="' + labelCls + '">' + esc(expLabel) + '</span>' +
|
||
'<input readonly title="' + escHtml(expLabel) + '" value="' + escHtml(String(c.expected)) + '" class="' + inputCls + '"></div>';
|
||
html += '<div class="flex items-center gap-2 mt-0.5 pl-2">' +
|
||
'<span class="' + labelCls + '">' + esc(actLabel) + '</span>' +
|
||
'<input readonly title="' + escHtml(actLabel) + '" value="' + escHtml(String(c.actual)) + '" class="' + inputCls + '"></div>';
|
||
// Per-field decoded diff, when the decoder can see one; the
|
||
// expected/actual column headers carry the comparison labels.
|
||
if (dd && dd.rows.length) {
|
||
html += '<div class="text-xs text-gray-500 dark:text-slate-400 pl-2 mt-0.5">' + esc(t('Decoded comparison')) + '</div>';
|
||
html += '<div class="flex items-center gap-2 mt-0.5 pl-2">' +
|
||
'<span class="' + labelCls + '"></span>' +
|
||
'<span class="flex-1 min-w-0 text-xs text-gray-500 dark:text-slate-400">' + esc(expLabel) + '</span>' +
|
||
'<span class="flex-1 min-w-0 text-xs text-gray-500 dark:text-slate-400">' + esc(actLabel) + '</span></div>';
|
||
for (const [label, ev, av] of dd.rows) {
|
||
html += '<div class="flex items-center gap-2 mt-0.5 pl-2">' +
|
||
'<span class="' + labelCls + '">' + esc(label) + '</span>' +
|
||
'<input readonly title="' + escHtml(expLabel) + '" value="' + escHtml(ev) + '" class="' + inputCls + '">' +
|
||
'<input readonly title="' + escHtml(actLabel) + '" value="' + escHtml(av) + '" class="' + inputCls + '">' +
|
||
'</div>';
|
||
}
|
||
} else if (dd && dd.sameDecoded) {
|
||
html += '<div class="text-xs text-gray-500 dark:text-slate-400 pl-2 mt-0.5">' + esc(t('Decoded values match; raw bytes differ')) + '</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(profilerMatchedRecordsText(r.recordsMatched, r.recordsTotal)) + '</div>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
return html;
|
||
}
|
||
|
||
profilerLoad();
|
||
snapshotsLoad();
|
||
scp81OptionsLoad();
|
||
|
||
// Init sub-tab pills
|
||
document.querySelectorAll('.pysim-subtab').forEach(btn => {
|
||
btn.addEventListener('click', () => pysimSwitchSubtab(btn.dataset.pysimSub));
|
||
});
|
||
window.addEventListener('resize', pysimFsFitTree);
|
||
|
||
const EF_UST_SERVICES = {1: "Local Phone Book", 2: "Fixed Dialling Numbers (FDN)", 3: "Extension 2", 4: "Service Dialling Numbers (SDN)", 5: "Extension3", 6: "Barred Dialling Numbers (BDN)", 7: "Extension4", 8: "Outgoing Call Information (OCI and OCT)", 9: "Incoming Call Information (ICI and ICT)", 10: "Short Message Storage (SMS)", 11: "Short Message Status Reports (SMSR)", 12: "Short Message Service Parameters (SMSP)", 13: "Advice of Charge (AoC)", 14: "Capability Configuration Parameters 2 (CCP2)", 15: "Cell Broadcast Message Identifier", 16: "Cell Broadcast Message Identifier Ranges", 17: "Group Identifier Level 1", 18: "Group Identifier Level 2", 19: "Service Provider Name", 20: "User controlled PLMN selector with Access Technology", 21: "MSISDN", 22: "Image (IMG)", 23: "Support of Localised Service Areas (SoLSA)", 24: "Enhanced Multi-Level Precedence and Pre-emption Service", 25: "Automatic Answer for eMLPP", 26: "RFU", 27: "GSM Access", 28: "Data download via SMS-PP", 29: "Data download via SMS-CB", 30: "Call Control by USIM", 31: "MO-SMS Control by USIM", 32: "RUN AT COMMAND command", 33: "shall be set to 1", 34: "Enabled Services Table", 35: "APN Control List (ACL)", 36: "Depersonalisation Control Keys", 37: "Co-operative Network List", 38: "GSM security context", 39: "CPBCCH Information", 40: "Investigation Scan", 41: "MexE", 42: "Operator controlled PLMN selector with Access Technology", 43: "HPLMN selector with Access Technology", 44: "Extension 5", 45: "PLMN Network Name", 46: "Operator PLMN List", 47: "Mailbox Dialling Numbers", 48: "Message Waiting Indication Status", 49: "Call Forwarding Indication Status", 50: "Reserved and shall be ignored", 51: "Service Provider Display Information", 52: "Multimedia Messaging Service (MMS)", 53: "Extension 8", 54: "Call control on GPRS by USIM", 55: "MMS User Connectivity Parameters", 56: "Network's indication of alerting in the MS (NIA)", 57: "VGCS Group Identifier List (EFVGCS and EFVGCSS)", 58: "VBS Group Identifier List (EFVBS and EFVBSS)", 59: "Pseudonym", 60: "User Controlled PLMN selector for I-WLAN access", 61: "Operator Controlled PLMN selector for I-WLAN access", 62: "User controlled WSID list", 63: "Operator controlled WSID list", 64: "VGCS security", 65: "VBS security", 66: "WLAN Reauthentication Identity", 67: "Multimedia Messages Storage", 68: "Generic Bootstrapping Architecture (GBA)", 69: "MBMS security", 70: "Data download via USSD and USSD application mode", 71: "Equivalent HPLMN", 72: "Additional TERMINAL PROFILE after UICC activation", 73: "Equivalent HPLMN Presentation Indication", 74: "Last RPLMN Selection Indication", 75: "OMA BCAST Smart Card Profile", 76: "GBA-based Local Key Establishment Mechanism", 77: "Terminal Applications", 78: "Service Provider Name Icon", 79: "PLMN Network Name Icon", 80: "Connectivity Parameters for USIM IP connections", 81: "Home I-WLAN Specific Identifier List", 82: "I-WLAN Equivalent HPLMN Presentation Indication", 83: "I-WLAN HPLMN Priority Indication", 84: "I-WLAN Last Registered PLMN", 85: "EPS Mobility Management Information", 86: "Allowed CSG Lists and corresponding indications", 87: "Call control on EPS PDN connection by USIM", 88: "HPLMN Direct Access", 89: "eCall Data", 90: "Operator CSG Lists and corresponding indications", 91: "Support for SM-over-IP", 92: "Support of CSG Display Control", 93: "Communication Control for IMS by USIM", 94: "Extended Terminal Applications", 95: "Support of UICC access to IMS", 96: "Non-Access Stratum configuration by USIM", 97: "PWS configuration by USIM", 98: "RFU", 99: "URI support by UICC", 100: "Extended EARFCN support", 101: "ProSe", 102: "USAT Application Pairing", 103: "Media Type support", 104: "IMS call disconnection cause", 105: "URI support for MO SHORT MESSAGE CONTROL", 106: "ePDG configuration Information support", 107: "ePDG configuration Information configured", 108: "ACDC support", 109: "MCPTT", 110: "ePDG configuration Information for Emergency Service support", 111: "ePDG configuration Information for Emergency Service configured", 112: "eCall Data over IMS", 113: "URI support for SMS-PP DOWNLOAD as defined in 3GPP TS 31.111 [12]", 114: "From Preferred", 115: "IMS configuration data", 116: "TV configuration", 117: "3GPP PS Data Off", 118: "3GPP PS Data Off Service List", 119: "V2X", 120: "XCAP Configuration Data", 121: "EARFCN list for MTC/NB-IOT UEs", 122: "5GS Mobility Management Information", 123: "5G Security Parameters", 124: "Subscription identifier privacy support", 125: "SUCI calculation by the USIM", 126: "UAC Access Identities support", 127: "Expect control plane-based Steering of Roaming information during initial registration in VPLMN", 128: "Call control on PDU Session by USIM", 129: "5GS Operator PLMN List", 130: "Support for SUPI of type NSI or GLI or GCI", 131: "3GPP PS Data Off separate Home and Roaming lists", 132: "Support for URSP by USIM", 133: "5G Security Parameters extended", 134: "MuD and MiD configuration data", 135: "Support for Trusted non-3GPP access networks by USIM", 136: "Support for multiple records of NAS security context storage for multiple registration", 137: "Pre-configured CAG information list", 138: "SOR-CMCI storage in USIM", 139: "5G ProSe", 140: "Storage of disaster roaming information in USIM", 141: "Pre-configured eDRX parameters", 142: "5G NSWO support", 143: "PWS configuration for SNPN in USIM", 144: "Multiplier Coefficient for Higher Priority PLMN search via NG-RAN satellite access", 145: "K_AUSF derivation configuration", 146: "Network Identifier for SNPN (NID)"};
|
||
const EF_SST_SERVICES = {1: "CHV1 disable function", 2: "Abbreviated Dialling Numbers (ADN)", 3: "Fixed Dialling Numbers (FDN)", 4: "Short Message Storage (SMS)", 5: "Advice of Charge (AoC)", 6: "Capability Configuration Parameters (CCP)", 7: "PLMN selector", 8: "RFU", 9: "MSISDN", 10: "Extension1", 11: "Extension2", 12: "SMS Parameters", 13: "Last Number Dialled (LND)", 14: "Cell Broadcast Message Identifier", 15: "Group Identifier Level 1", 16: "Group Identifier Level 2", 17: "Service Provider Name", 18: "Service Dialling Numbers (SDN)", 19: "Extension3", 20: "RFU", 21: "VGCS Group Identifier List (EFVGCS and EFVGCSS)", 22: "VBS Group Identifier List (EFVBS and EFVBSS)", 23: "enhanced Multi-Level Precedence and Pre-emption Service", 24: "Automatic Answer for eMLPP", 25: "Data download via SMS-CB", 26: "Data download via SMS-PP", 27: "Menu selection", 28: "Call control", 29: "Proactive SIM", 30: "Cell Broadcast Message Identifier Ranges", 31: "Barred Dialling Numbers (BDN)", 32: "Extension4", 33: "De-personalization Control Keys", 34: "Co-operative Network List", 35: "Short Message Status Reports", 36: "Network's indication of alerting in the MS", 37: "Mobile Originated Short Message control by SIM", 38: "GPRS", 39: "Image (IMG)", 40: "SoLSA (Support of Local Service Area)", 41: "USSD string data object supported in Call Control", 42: "RUN AT COMMAND command", 43: "User controlled PLMN Selector with Access Technology", 44: "Operator controlled PLMN Selector with Access Technology", 45: "HPLMN Selector with Access Technology", 46: "CPBCCH Information", 47: "Investigation Scan", 48: "Extended Capability Configuration Parameters", 49: "MExE", 50: "Reserved and shall be ignored", 51: "PLMN Network Name", 52: "Operator PLMN List", 53: "Mailbox Dialling Numbers", 54: "Message Waiting Indication Status", 55: "Call Forwarding Indication Status", 56: "Service Provider Display Information", 57: "Multimedia Messaging Service (MMS)", 58: "Extension 8", 59: "MMS User Connectivity Parameters"};
|
||
const EF_IST_SERVICES = {1: "P-CSCF address", 2: "Generic Bootstrapping Architecture (GBA)", 3: "HTTP Digest", 4: "GBA-based Local Key Establishment Mechanism", 5: "Support of P-CSCF discovery for IMS Local Break Out", 6: "Short Message Storage (SMS)", 7: "Short Message Status Reports (SMSR)", 8: "Support for SM-over-IP including data download via SMS-PP as defined in TS 31.111 [31]", 9: "Communication Control for IMS by ISIM", 10: "Support of UICC access to IMS", 11: "URI support by UICC", 12: "Media Type support", 13: "IMS call disconnection cause", 14: "URI support for MO SHORT MESSAGE CONTROL", 15: "MCPTT", 16: "URI support for SMS-PP DOWNLOAD as defined in 3GPP TS 31.111 [31]", 17: "From Preferred", 18: "IMS configuration data", 19: "XCAP Configuration Data", 20: "WebRTC URI", 21: "MuD and MiD configuration data", 22: "IMS Data Channel indication"};
|
||
const EF_PHASE_NAMES = {0: "phase 1", 2: "phase 2", 3: "phase 2 and higher"};
|
||
|
||
// ===== EF content decoders (client-side, offline) =====
|
||
// Decode the contents of common EFs for the file manager, the snapshot view,
|
||
// profile-check results and snapshot comparison. Ported/adapted from
|
||
// simtrace-analyser, which sourced the formats from the pinned specs
|
||
// (TS 51.011, TS 31.102, TS 31.103, TS 102 221 Annex A, TS 24.008) and pySim;
|
||
// pySim's server-side read_*_decoded stays available as a cross-check.
|
||
// Decoders are pure functions over byte arrays returning plain objects;
|
||
// efFlatten() turns a decoded object into [label, value] rows for display and
|
||
// efDiffData() into per-field diff rows for the report.
|
||
|
||
const EF_FIELD_LABELS = {
|
||
imsi: 'IMSI', iccid: 'ICCID', languages: 'Languages', name: 'Name', number: 'Number',
|
||
text: 'Text', title: 'Title', icon: 'Icon', mcc: 'MCC', mnc: 'MNC', lac: 'LAC',
|
||
tac: 'TAC', rac: 'RAC', tmsi: 'TMSI', p_tmsi: 'P-TMSI', guti: 'GUTI', ksi: 'KSI',
|
||
kc: 'Kc', acm_max: 'ACM max', apns: 'APNs', access_classes: 'Access classes',
|
||
op_mode: 'Operation mode', test_mode: 'Test mode', additional_info: 'Additional info',
|
||
mnc_length: 'MNC length', services: 'Services', plmns: 'PLMNs', applications: 'Applications',
|
||
aid: 'AID', label: 'Label', eap: 'EAP', message_ids: 'Message IDs', ranges: 'Ranges',
|
||
esc: 'Emergency category', param_indicators: 'Parameter indicators', tp_da: 'TP-DA',
|
||
sca: 'SCA', tp_pid: 'TP-PID', tp_dcs: 'TP-DCS', tp_vp: 'TP-VP', alpha: 'Alpha',
|
||
direction: 'Direction', status: 'Status', tpdu: 'TPDU', display_condition: 'Display condition',
|
||
rfu: 'RFU', ccp: 'CCP', ext: 'Extension', tag: 'Tag', hex: 'Hex', lac_range: 'LAC range',
|
||
pnn_record: 'PNN record', pnn_record_id: 'PNN record', mcc_mnc: 'MCC/MNC', full: 'Full name',
|
||
short: 'Short name', eap_types: 'EAP types', dfs: 'DFs', uplink_nas_count: 'Uplink NAS count',
|
||
downlink_nas_count: 'Downlink NAS count', nas_algorithms: 'NAS algorithms', k_asme: 'K-ASME',
|
||
ksi_asme: 'KSI-ASME', tmsi_time: 'TMSI time', lac_range_low: 'LAC range low',
|
||
lac_range_high: 'LAC range high', show_in_hplmn: 'Show in HPLMN', hide_in_oplmn: 'Hide in OPLMN',
|
||
location_update_status: 'Location update status', eps_update_status: 'EPS update status',
|
||
update_status: 'Update status', access_tech: 'Access technology', p_tmsi_signature: 'P-TMSI signature',
|
||
};
|
||
|
||
const EF_GSM7_EXT = { 0x0A: '\n', 0x14: '^', 0x28: '{', 0x29: '}', 0x2F: '\\', 0x3C: '[', 0x3D: '~', 0x3E: ']', 0x40: '|', 0x65: '\u20ac' };
|
||
|
||
function efBytes(hex) {
|
||
const n = (hex || '').length >> 1;
|
||
const out = new Uint8Array(n);
|
||
for (let i = 0; i < n; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||
return out;
|
||
}
|
||
|
||
function efHex(b) {
|
||
let s = '';
|
||
for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, '0').toUpperCase();
|
||
return s;
|
||
}
|
||
|
||
function efSwapNibbles(hex) {
|
||
let s = '';
|
||
for (let i = 0; i + 1 < hex.length; i += 2) s += hex[i + 1] + hex[i];
|
||
return s;
|
||
}
|
||
|
||
function efAllFf(b) {
|
||
if (!b.length) return true;
|
||
for (let i = 0; i < b.length; i++) if (b[i] !== 0xFF) return false;
|
||
return true;
|
||
}
|
||
|
||
function efRstripFf(b) {
|
||
let end = b.length;
|
||
while (end > 0 && b[end - 1] === 0xFF) end--;
|
||
return b.subarray(0, end);
|
||
}
|
||
|
||
function efTlv(data) {
|
||
const out = [];
|
||
let i = 0;
|
||
while (i + 2 <= data.length) {
|
||
const tag = data[i]; i += 1;
|
||
let len = data[i]; i += 1;
|
||
if (len & 0x80) {
|
||
const n = len & 0x7F;
|
||
if (n === 0 || i + n > data.length) break;
|
||
len = 0;
|
||
for (let k = 0; k < n; k++) len = (len << 8) | data[i++];
|
||
}
|
||
if (i + len > data.length) break;
|
||
out.push({ tag: tag, value: data.slice(i, i + len) });
|
||
i += len;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function efUcs2Be(b) {
|
||
let end = b.length - (b.length % 2);
|
||
let s = '';
|
||
for (let i = 0; i + 1 < end; i += 2) {
|
||
const u = (b[i] << 8) | b[i + 1];
|
||
if (u === 0) break;
|
||
s += String.fromCharCode(u);
|
||
}
|
||
return s;
|
||
}
|
||
|
||
function efGsm7Octets(b) {
|
||
let s = '';
|
||
for (let i = 0; i < b.length; i++) {
|
||
const v = b[i];
|
||
if (v === 0xFF) continue;
|
||
s += v < GSM7_ALPHABET.length ? GSM7_ALPHABET[v] : '?';
|
||
}
|
||
return s;
|
||
}
|
||
|
||
// TS 102 221 Annex A / TS 24.008 text string (GsmOrUcs2 variants).
|
||
function efAnnexA(b) {
|
||
if (!b.length || efAllFf(b)) return '';
|
||
const first = b[0];
|
||
if (first === 0x80) return efUcs2Be(b.subarray(1));
|
||
if (first === 0x90) {
|
||
let d = b.subarray(1);
|
||
for (let i = 0; i + 1 < d.length; i += 2) {
|
||
if (d[i] === 0 && d[i + 1] === 0) { d = d.subarray(0, i); break; }
|
||
}
|
||
return efUcs2Be(d);
|
||
}
|
||
if (first === 0x81 && b.length >= 3) {
|
||
const num = b[1], base = b[2] << 7;
|
||
let s = '';
|
||
for (let i = 0; i < num && 3 + i < b.length; i++) {
|
||
const ch = b[3 + i];
|
||
s += (ch & 0x80) ? String.fromCharCode((ch & 0x7F) + base)
|
||
: (ch < GSM7_ALPHABET.length ? GSM7_ALPHABET[ch] : '?');
|
||
}
|
||
return s;
|
||
}
|
||
if (first === 0x82 && b.length >= 4) {
|
||
const num = b[1], base = (b[2] << 8) | b[3];
|
||
let s = '';
|
||
for (let i = 0; i < num && 4 + i < b.length; i++) {
|
||
const ch = b[4 + i];
|
||
s += (ch & 0x80) ? String.fromCharCode((ch & 0x7F) + base)
|
||
: (ch < GSM7_ALPHABET.length ? GSM7_ALPHABET[ch] : '?');
|
||
}
|
||
return s;
|
||
}
|
||
if (first >= 0x83 && first <= 0x87) {
|
||
const d = efRstripFf(b.subarray(1));
|
||
return d.length ? gsm7Decode(d) : '';
|
||
}
|
||
if (first & 0x80) {
|
||
const u = b.slice();
|
||
u[0] &= 0x7F;
|
||
let end = u.length;
|
||
while (end >= 2 && u[end - 1] === 0xFF && u[end - 2] === 0xFF) end -= 2;
|
||
return efUcs2Be(u.subarray(0, end));
|
||
}
|
||
const d = efRstripFf(b);
|
||
let allLow = d.length > 0;
|
||
for (let i = 0; i < d.length; i++) if (d[i] >= 0x80) { allLow = false; break; }
|
||
return allLow ? efGsm7Octets(d) : gsm7Decode(d);
|
||
}
|
||
|
||
// [TON/NPI byte + BCD digits] -> dialling string.
|
||
function efBcdAddress(data) {
|
||
if (!data.length) return '';
|
||
const ton = (data[0] >> 4) & 0x07;
|
||
const digits = [];
|
||
for (let i = 1; i < data.length; i++) {
|
||
const lo = data[i] & 0x0F, hi = (data[i] >> 4) & 0x0F;
|
||
if (lo <= 9) digits.push(String(lo)); else if (lo === 0x0F) break;
|
||
if (hi <= 9) digits.push(String(hi)); else if (hi === 0x0F) break;
|
||
}
|
||
const num = digits.join('');
|
||
return ton === 1 ? '+' + num : num;
|
||
}
|
||
|
||
// [length octet (counts the following octets) + TON/NPI + BCD digits],
|
||
// as used by the SMSP TP-DA/SCA fields and TPDU addresses (TS 24.008).
|
||
function efBcdAddressLen(data) {
|
||
if (data.length < 2) return '';
|
||
const n = data[0];
|
||
const ton = (data[1] >> 4) & 0x07;
|
||
const bcd = data.subarray(2, Math.min(data.length, 1 + n));
|
||
const digits = efSwapNibbles(efHex(bcd)).replace(/[fF]+$/, '');
|
||
return ton === 1 ? '+' + digits : digits;
|
||
}
|
||
|
||
// 3-byte PLMN (TS 24.008) -> {mcc, mnc}
|
||
function efPlmn(b) {
|
||
if (b.length < 3) return null;
|
||
const h = efHex(b.subarray(0, 3));
|
||
const mcc = (h[1] + h[0] + h[3]).replace(/F+$/, '') || '0';
|
||
const mnc = (h[5] + h[4] + h[2]).replace(/F+$/, '') || '0';
|
||
return { mcc: mcc, mnc: mnc };
|
||
}
|
||
|
||
function efPlmnAct(u16) {
|
||
const techs = new Set();
|
||
if (u16 & 0x8000) techs.add('UTRAN');
|
||
if (u16 & 0x0800) techs.add('NG-RAN');
|
||
if (u16 & 0x0040) techs.add('GSM COMPACT');
|
||
if (u16 & 0x0020) techs.add('cdma2000 HRPD');
|
||
if (u16 & 0x0010) techs.add('cdma2000 1xRTT');
|
||
const e = u16 & 0x7000;
|
||
if (e === 0x4000 || e === 0x7000) { techs.add('E-UTRAN WB-S1'); techs.add('E-UTRAN NB-S1'); }
|
||
else if (e === 0x5000) techs.add('E-UTRAN NB-S1');
|
||
else if (e === 0x6000) techs.add('E-UTRAN WB-S1');
|
||
const g = u16 & 0x008C;
|
||
if (g === 0x0080 || g === 0x008C) { techs.add('GSM'); techs.add('EC-GSM-IoT'); }
|
||
else if (g === 0x0084) techs.add('GSM');
|
||
else if (g === 0x0086) techs.add('EC-GSM-IoT');
|
||
return [...techs].sort();
|
||
}
|
||
|
||
// TS 23.038 Network Name (DCS byte + text) from EF_PNN.
|
||
// TS 24.008 10.5.3.5a Network Name (the EF.PNN 43/45 TLV value): the first
|
||
// byte is the coding octet — bit 8 ext, bits 5-7 coding scheme (000 GSM
|
||
// 7-bit, 001 UCS2), bit 4 Add CI, bits 1-3 the number of spare bits in the
|
||
// last octet (the value is the count, 1-7; 000 = no information).
|
||
function efPnnText(value) {
|
||
if (value.length < 2) return '';
|
||
const dcs = value[0];
|
||
const coding = (dcs >> 4) & 0x07;
|
||
const spare = dcs & 0x07;
|
||
let data = efRstripFf(value.subarray(1));
|
||
if (coding === 1) {
|
||
for (let i = 0; i + 1 < data.length; i += 2) {
|
||
if (data[i] === 0 && data[i + 1] === 0) { data = data.subarray(0, i); break; }
|
||
}
|
||
return efUcs2Be(data);
|
||
}
|
||
if (coding === 0) {
|
||
let allLow = data.length > 0;
|
||
for (let i = 0; i < data.length; i++) if (data[i] >= 0x80) { allLow = false; break; }
|
||
if (allLow) return efGsm7Octets(data);
|
||
const nbits = data.length * 8;
|
||
const septets = spare > 0 ? Math.floor((nbits - spare) / 7)
|
||
: Math.floor(nbits / 7);
|
||
return gsm7Decode(data, septets);
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// ---- decoders ----
|
||
|
||
function efDecIccid(b) {
|
||
if (efAllFf(b)) return { empty: true };
|
||
const digits = efSwapNibbles(efHex(b)).replace(/[fF]+$/, '');
|
||
return { iccid: digits || null };
|
||
}
|
||
|
||
function efDecImsi(b) {
|
||
if (efAllFf(b)) return { empty: true };
|
||
if (b.length <= 2) return efDecServiceTable(b, EF_IST_SERVICES); // 6F07 is also EF.IST
|
||
const digits = efSwapNibbles(efHex(b.subarray(1))).replace(/[fF]+$/, '');
|
||
// First nibble is the mobile-identity type + parity indicator (TS 24.008).
|
||
return { imsi: digits.slice(1) || null };
|
||
}
|
||
|
||
function efDecLi(b) {
|
||
const codes = [];
|
||
for (let i = 0; i + 1 < b.length; i += 2) {
|
||
if (b[i] === 0xFF && b[i + 1] === 0xFF) continue;
|
||
try {
|
||
const c = String.fromCharCode(b[i]) + String.fromCharCode(b[i + 1]);
|
||
if (/^[A-Za-z]{2}$/.test(c)) codes.push(c);
|
||
} catch (e) {}
|
||
}
|
||
return { languages: codes };
|
||
}
|
||
|
||
function efDecServiceTable(b, table) {
|
||
const services = [];
|
||
for (let i = 0; i < b.length; i++) {
|
||
for (let bit = 0; bit < 8; bit++) {
|
||
if (b[i] & (1 << bit)) {
|
||
const n = i * 8 + bit + 1;
|
||
services.push({ n: n, name: table[n] || 'RFU' });
|
||
}
|
||
}
|
||
}
|
||
return { services: services };
|
||
}
|
||
|
||
function efDecPlmnList(b) {
|
||
const plmns = [];
|
||
for (let i = 0; i + 2 < b.length; i += 3) {
|
||
const chunk = b.subarray(i, i + 3);
|
||
if (efAllFf(chunk)) break;
|
||
const p = efPlmn(chunk);
|
||
if (p) plmns.push(p);
|
||
}
|
||
return { plmns: plmns };
|
||
}
|
||
|
||
// EF.FPLMN: 3-byte PLMN entries; an FFFFFF empty slot may appear in any
|
||
// position and must not terminate the list (TS 31.102 4.2.16).
|
||
function efDecFplmn(b) {
|
||
const plmns = [];
|
||
for (let i = 0; i + 2 < b.length; i += 3) {
|
||
const chunk = b.subarray(i, i + 3);
|
||
if (efAllFf(chunk)) continue;
|
||
const p = efPlmn(chunk);
|
||
if (p) plmns.push(p);
|
||
}
|
||
return { plmns: plmns };
|
||
}
|
||
|
||
function efDecPlmnWact(b) {
|
||
const plmns = [];
|
||
for (let i = 0; i + 4 < b.length; i += 5) {
|
||
const chunk = b.subarray(i, i + 5);
|
||
if (efAllFf(chunk.subarray(0, 3))) break;
|
||
const p = efPlmn(chunk.subarray(0, 3));
|
||
if (!p) continue;
|
||
const act = (chunk[3] << 8) | chunk[4];
|
||
const techs = efPlmnAct(act);
|
||
p.access_tech = techs.length ? techs.join(', ') : '0x' + act.toString(16).toUpperCase().padStart(4, '0');
|
||
plmns.push(p);
|
||
}
|
||
return { plmns: plmns };
|
||
}
|
||
|
||
function efDecSpn(b) {
|
||
if (b.length < 2) return { raw: efHex(b) };
|
||
const cond = b[0];
|
||
const out = { show_in_hplmn: !!(cond & 0x01), hide_in_oplmn: !!(cond & 0x02) };
|
||
const text = efAnnexA(efRstripFf(b.subarray(1)));
|
||
if (text) out.name = text;
|
||
return out;
|
||
}
|
||
|
||
function efDecLoci(b) {
|
||
if (b.length < 11) return { raw: efHex(b) };
|
||
const tmsi = b.subarray(0, 4), lai = b.subarray(4, 9);
|
||
const p = efPlmn(lai.subarray(0, 3)) || {};
|
||
return {
|
||
tmsi: efAllFf(tmsi) ? null : efHex(tmsi),
|
||
mcc: p.mcc, mnc: p.mnc,
|
||
lac: '0x' + efHex(lai.subarray(3, 5)),
|
||
tmsi_time: '0x' + efHex(b.subarray(9, 10)),
|
||
location_update_status: '0x' + efHex(b.subarray(10, 11)),
|
||
};
|
||
}
|
||
|
||
function efDecPsLoci(b) {
|
||
if (efAllFf(b)) return { empty: true };
|
||
if (b.length < 14) return { raw: efHex(b) };
|
||
const rai = b.subarray(7, 13);
|
||
const p = efPlmn(rai.subarray(0, 3)) || {};
|
||
return {
|
||
p_tmsi: efAllFf(b.subarray(0, 4)) ? null : efHex(b.subarray(0, 4)),
|
||
p_tmsi_signature: efHex(b.subarray(4, 7)),
|
||
mcc: p.mcc, mnc: p.mnc,
|
||
lac: '0x' + efHex(rai.subarray(3, 5)),
|
||
rac: '0x' + efHex(rai.subarray(5, 6)),
|
||
update_status: '0x' + efHex(b.subarray(13, 14)),
|
||
};
|
||
}
|
||
|
||
function efDecEpsLoci(b) {
|
||
if (efAllFf(b)) return { empty: true };
|
||
if (b.length < 18) return { raw: efHex(b) };
|
||
const tai = b.subarray(12, 17);
|
||
const p = efPlmn(tai.subarray(0, 3)) || {};
|
||
return {
|
||
guti: efAllFf(b.subarray(0, 12)) ? null : efHex(b.subarray(0, 12)),
|
||
mcc: p.mcc, mnc: p.mnc,
|
||
tac: '0x' + efHex(tai.subarray(3, 5)),
|
||
eps_update_status: '0x' + efHex(b.subarray(17, 18)),
|
||
};
|
||
}
|
||
|
||
function efDecEpsNsc(b) {
|
||
if (!b.length || efAllFf(b)) return { empty: true };
|
||
let inner = b;
|
||
for (const t of efTlv(b)) if (t.tag === 0xA0) { inner = t.value; break; }
|
||
const out = {};
|
||
for (const t of efTlv(inner)) {
|
||
if (t.tag === 0x80) out.ksi_asme = efHex(t.value);
|
||
else if (t.tag === 0x81) out.k_asme = efHex(t.value);
|
||
else if (t.tag === 0x82) out.uplink_nas_count = parseInt(efHex(t.value) || '0', 16);
|
||
else if (t.tag === 0x83) out.downlink_nas_count = parseInt(efHex(t.value) || '0', 16);
|
||
else if (t.tag === 0x84) out.nas_algorithms = efHex(t.value);
|
||
}
|
||
return Object.keys(out).length ? out : { raw: efHex(b) };
|
||
}
|
||
|
||
function efDecKc(b) {
|
||
if (efAllFf(b)) return { empty: true };
|
||
const out = {};
|
||
if (b.length >= 8) out.kc = efHex(b.subarray(0, 8));
|
||
if (b.length >= 9) out.ksi = (b[8] >> 4) & 0x0F;
|
||
return Object.keys(out).length ? out : { raw: efHex(b) };
|
||
}
|
||
|
||
function efDecAcc(b) {
|
||
if (b.length < 2) return { raw: efHex(b) };
|
||
const val = (b[0] << 8) | b[1];
|
||
const classes = [];
|
||
for (let i = 0; i < 16; i++) if (val & (0x8000 >> i)) classes.push(i);
|
||
return { access_classes: classes };
|
||
}
|
||
|
||
function efDecPhase(b) {
|
||
const v = b.length ? b[0] : 0;
|
||
return { phase: EF_PHASE_NAMES[v] || '0x' + v.toString(16).toUpperCase().padStart(2, '0') };
|
||
}
|
||
|
||
function efDecCbmi(b) {
|
||
const ids = [];
|
||
for (let i = 0; i + 1 < b.length; i += 2) {
|
||
const v = (b[i] << 8) | b[i + 1];
|
||
if (v !== 0xFFFF) ids.push(v);
|
||
}
|
||
return { message_ids: ids };
|
||
}
|
||
|
||
function efDecCbmir(b) {
|
||
const ranges = [];
|
||
for (let i = 0; i + 3 < b.length; i += 4) {
|
||
const rec = b.subarray(i, i + 4);
|
||
if (efAllFf(rec)) continue;
|
||
ranges.push([efHex(rec.subarray(0, 2)), efHex(rec.subarray(2, 4))]);
|
||
}
|
||
return ranges.length ? { ranges: ranges } : { raw: efHex(b) };
|
||
}
|
||
|
||
function efDecEcc(b) {
|
||
if (efAllFf(b)) return { empty: true };
|
||
const recs = [];
|
||
for (let i = 0; i + 2 < b.length; i += 3) {
|
||
const rec = b.subarray(i, i + 3);
|
||
if (efAllFf(rec)) continue;
|
||
const digits = efSwapNibbles(efHex(rec.subarray(0, 2))).replace(/[fF]+$/, '');
|
||
recs.push({ number: digits || null, esc: '0x' + efHex(rec.subarray(2, 3)) });
|
||
}
|
||
if (!recs.length) return { raw: efHex(b) };
|
||
return recs.length === 1 ? recs[0] : { numbers: recs };
|
||
}
|
||
|
||
function efDecOpl(b) {
|
||
if (efAllFf(b)) return { empty: true };
|
||
const recs = [];
|
||
for (let i = 0; i + 7 < b.length; i += 8) {
|
||
const rec = b.subarray(i, i + 8);
|
||
if (efAllFf(rec)) continue;
|
||
const p = efPlmn(rec.subarray(0, 3)) || {};
|
||
recs.push({
|
||
mcc: p.mcc, mnc: p.mnc,
|
||
lac_range: [efHex(rec.subarray(3, 5)), efHex(rec.subarray(5, 7))],
|
||
pnn_record: rec[7],
|
||
});
|
||
}
|
||
if (!recs.length) return { raw: efHex(b) };
|
||
return recs.length === 1 ? recs[0] : { records: recs };
|
||
}
|
||
|
||
function efDecAd(b) {
|
||
if (efAllFf(b)) return { empty: true };
|
||
const op = b[0] & 0x03;
|
||
const out = {
|
||
op_mode: { 0: 'normal operation', 1: 'specific features' }[op] || '0x' + op.toString(16).padStart(2, '0'),
|
||
test_mode: !!(b[0] & 0x04),
|
||
};
|
||
if (b.length >= 3) out.additional_info = efHex(b.subarray(1, 3));
|
||
if (b.length >= 4 && b[3] !== 0xFF) out.mnc_length = b[3];
|
||
return out;
|
||
}
|
||
|
||
function efDecAcl(b) {
|
||
if (efAllFf(b)) return { empty: true };
|
||
const apns = [];
|
||
for (const t of efTlv(b.subarray(1))) {
|
||
if (t.tag === 0xDD) apns.push(t.value.length ? String.fromCharCode.apply(null, t.value) : '(network provided)');
|
||
}
|
||
return apns.length ? { apns: apns } : { raw: efHex(b) };
|
||
}
|
||
|
||
function efDecSmsp(b) {
|
||
if (efAllFf(b)) return { empty: true };
|
||
if (b.length < 28) return { raw: efHex(b) };
|
||
const y = b.length - 28;
|
||
const out = {};
|
||
const alpha = efAnnexA(efRstripFf(b.subarray(0, y)));
|
||
if (alpha) out.alpha = alpha;
|
||
const pi = b[y];
|
||
const flags = [];
|
||
out.param_indicators = '0x' + pi.toString(16).toUpperCase().padStart(2, '0');
|
||
if (!(pi & 0x01)) out.tp_da = efBcdAddressLen(b.subarray(y + 1, y + 13));
|
||
if (!(pi & 0x10)) out.sca = efBcdAddressLen(b.subarray(y + 13, y + 25));
|
||
if (!(pi & 0x02)) out.tp_pid = '0x' + b[y + 25].toString(16).toUpperCase().padStart(2, '0');
|
||
if (!(pi & 0x04)) out.tp_dcs = '0x' + b[y + 26].toString(16).toUpperCase().padStart(2, '0');
|
||
if (!(pi & 0x08)) out.tp_vp = '0x' + b[y + 27].toString(16).toUpperCase().padStart(2, '0');
|
||
return out;
|
||
}
|
||
|
||
function efDecSpdi(b) {
|
||
if (efAllFf(b)) return { empty: true };
|
||
const plmns = [];
|
||
const collect = value => {
|
||
for (let i = 0; i + 2 < value.length; i += 3) {
|
||
const p = efPlmn(value.subarray(i, i + 3));
|
||
if (p && !plmns.some(x => x.mcc === p.mcc && x.mnc === p.mnc)) plmns.push(p);
|
||
}
|
||
};
|
||
for (const t of efTlv(b)) {
|
||
if (t.tag === 0xA3) {
|
||
let got = false;
|
||
for (const t2 of efTlv(t.value)) if (t2.tag === 0x80) { collect(t2.value); got = true; }
|
||
if (!got) collect(t.value);
|
||
} else if (t.tag === 0x80) collect(t.value);
|
||
}
|
||
return plmns.length ? { plmns: plmns } : { raw: efHex(b) };
|
||
}
|
||
|
||
function efDecNai(b) {
|
||
if (!b.length || efAllFf(b)) return { empty: true };
|
||
const data = efRstripFf(b);
|
||
if (!data.length || ((data[0] === 0x80 || data[0] === 0x81) && data.length <= 2)) return { empty: true };
|
||
const texts = [];
|
||
for (const t of efTlv(data)) {
|
||
if ((t.tag === 0x80 || t.tag === 0x81) && t.value.length) {
|
||
try { texts.push(new TextDecoder('utf-8').decode(t.value)); } catch (e) {}
|
||
}
|
||
}
|
||
return texts.length ? { text: texts.length === 1 ? texts[0] : texts.join(', ') } : { raw: efHex(b) };
|
||
}
|
||
|
||
function efDecDir(b) {
|
||
const apps = [];
|
||
for (const t of efTlv(b)) {
|
||
if (t.tag !== 0x61) continue;
|
||
const app = {};
|
||
for (const t2 of efTlv(t.value)) {
|
||
if (t2.tag === 0x4F) app.aid = efHex(t2.value);
|
||
else if (t2.tag === 0x50) { const l = efAnnexA(t2.value); if (l) app.label = l; }
|
||
else if (t2.tag === 0x73) {
|
||
for (const t3 of efTlv(t2.value)) {
|
||
if (t3.tag !== 0xA0) continue;
|
||
const eap = {};
|
||
for (const t4 of efTlv(t3.value)) {
|
||
if (t4.tag === 0x80) eap.eap_types = Array.prototype.map.call(t4.value, x => ({ 18: 'EAP-SIM', 23: 'EAP-AKA', 50: 'EAP-AKA\'' }[x] || x));
|
||
else if (t4.tag === 0x81) { eap.dfs = []; for (let i = 0; i + 1 < t4.value.length; i += 2) eap.dfs.push(efHex(t4.value.subarray(i, i + 2))); }
|
||
else if (t4.tag === 0x82) eap.label = String.fromCharCode.apply(null, t4.value);
|
||
}
|
||
app.eap = eap;
|
||
}
|
||
}
|
||
}
|
||
if (Object.keys(app).length) apps.push(app);
|
||
}
|
||
return apps.length ? { applications: apps } : { raw: efHex(b) };
|
||
}
|
||
|
||
// TS 102 221 9.4.1 Table 9.3: PIN key reference names (ordinal + local PINs).
|
||
function pinKeyRefName(v) {
|
||
const names = {
|
||
0x01: 'PIN app1', 0x02: 'PIN app2', 0x03: 'PIN app3', 0x04: 'PIN app4',
|
||
0x05: 'PIN app5', 0x06: 'PIN app6', 0x07: 'PIN app7', 0x08: 'PIN app8',
|
||
0x0A: 'ADM1', 0x0B: 'ADM2', 0x0C: 'ADM3', 0x0D: 'ADM4', 0x0E: 'ADM5',
|
||
0x11: 'universal PIN',
|
||
0x81: 'PIN2 app1', 0x82: 'PIN2 app2', 0x83: 'PIN2 app3', 0x84: 'PIN2 app4',
|
||
0x85: 'PIN2 app5', 0x86: 'PIN2 app6', 0x87: 'PIN2 app7', 0x88: 'PIN2 app8',
|
||
0x8A: 'ADM6', 0x8B: 'ADM7', 0x8C: 'ADM8', 0x8D: 'ADM9', 0x8E: 'ADM10'
|
||
};
|
||
if (names[v]) return names[v];
|
||
return 'key ref 0x' + v.toString(16).toUpperCase().padStart(2, '0');
|
||
}
|
||
|
||
// ISO 7816-4 Tables 16/17: AM byte bit meanings. A record does not store
|
||
// whether it protects an EF or a DF, so both mappings are shown.
|
||
function efArrAmLabel(am) {
|
||
const bits = [
|
||
[0x40, 'DELETE (self)'],
|
||
[0x20, 'TERMINATE EF / TERMINATE DF'],
|
||
[0x10, 'ACTIVATE'],
|
||
[0x08, 'DEACTIVATE'],
|
||
[0x04, 'WRITE/APPEND (EF) / CREATE FILE DF (DF)'],
|
||
[0x02, 'UPDATE/ERASE (EF) / CREATE FILE EF (DF)'],
|
||
[0x01, 'READ/SEARCH (EF) / DELETE FILE child (DF)'],
|
||
];
|
||
if (am & 0x80) {
|
||
const low = bits.filter(b => (b[0] & 0x07) && (am & b[0])).map(b => b[1]);
|
||
return 'bits 7-4 proprietary' + (low.length ? ' + ' + low.join(' + ') : '');
|
||
}
|
||
const ops = bits.filter(b => am & b[0]).map(b => b[1]);
|
||
return ops.length ? ops.join(' + ') : 'no operations';
|
||
}
|
||
|
||
// One SC_DO (ISO 7816-4 Table 23; TS 102 221 Annex E.3).
|
||
function efArrSc(tlv) {
|
||
const tag = tlv.tag, v = tlv.value;
|
||
if (tag === 0x90) return 'always';
|
||
if (tag === 0x97) return 'never';
|
||
if (tag === 0x9E) return 'security condition 0x' + efHex(v);
|
||
if (tag === 0xA4) {
|
||
// An AT control reference template carries one key reference and one
|
||
// usage qualifier (ISO 7816-4 6.3.1); if a malformed CRT repeats the
|
||
// '83' tag, the last one wins here.
|
||
let ref = null, uq = null;
|
||
for (const t of efTlv(v)) {
|
||
if (t.tag === 0x83 && t.value.length) ref = t.value[0];
|
||
else if (t.tag === 0x95 && t.value.length) uq = t.value[0];
|
||
}
|
||
let s = ref === null ? 'PIN' : pinKeyRefName(ref);
|
||
if (uq === null) s += ' (verify)';
|
||
else if (uq === 0x00) s += ' (verification not used)';
|
||
else if (uq & 0x08) s += ' (verify)';
|
||
else s += ' (usage 0x' + uq.toString(16).toUpperCase().padStart(2, '0') + ')';
|
||
return s;
|
||
}
|
||
if (tag === 0xB4 || tag === 0xB6 || tag === 0xB8) return 'SM CRT 0x' + tag.toString(16).toUpperCase();
|
||
if (tag === 0xA0) return '(' + efTlv(v).map(x => efArrSc(x)).join(' OR ') + ')';
|
||
if (tag === 0xAF) return '(' + efTlv(v).map(x => efArrSc(x)).join(' AND ') + ')';
|
||
if (tag === 0xA7) {
|
||
const inner = efTlv(v)[0];
|
||
return 'NOT ' + (inner ? efArrSc(inner) : '?');
|
||
}
|
||
return '0x' + tag.toString(16).toUpperCase().padStart(2, '0') + ' ' + efHex(v);
|
||
}
|
||
|
||
// One AM_DO (ISO 7816-4 Table 21; INCREASE/RESIZE per TS 102 221 11.1.8.1 and
|
||
// TS 102 222 6.10). Returns null for SC_DOs.
|
||
function efArrAmDo(tlv) {
|
||
const tag = tlv.tag, v = tlv.value;
|
||
if (tag === 0x80 && v.length) return 'AM 0x' + efHex(v) + ' (' + efArrAmLabel(v[0]) + ')';
|
||
if (tag === 0x84) {
|
||
const ins = v.length ? v[v.length - 1] : 0;
|
||
if (ins === 0x32) return 'INCREASE (AM_DO 0x84, INS 0x32)';
|
||
if (ins === 0xD4) return 'RESIZE FILE (AM_DO 0x84, INS 0xD4)';
|
||
return 'AM_DO 0x84 (INS 0x' + ins.toString(16).toUpperCase().padStart(2, '0') + ')';
|
||
}
|
||
if (tag >= 0x81 && tag <= 0x8F) return 'command description 0x' + tag.toString(16).toUpperCase() + ' (' + efHex(v) + ')';
|
||
if (tag === 0x9C) return 'proprietary state machine (' + efHex(v) + ')';
|
||
return null;
|
||
}
|
||
|
||
// EF_ARR records: AM_DO + SC_DO sequences in the expanded format
|
||
// (ISO 7816-4 5.4.3.2; TS 102 221 9.2.7/13.4). SC_DOs after one AM_DO
|
||
// without an OR template all have to be fulfilled (AND).
|
||
function efDecArr(b) {
|
||
const rules = [];
|
||
for (const t of efTlv(b)) {
|
||
const am = efArrAmDo(t);
|
||
if (am !== null) { rules.push({ am: am, scs: [] }); continue; }
|
||
const sc = efArrSc(t);
|
||
if (!rules.length) rules.push({ am: '(no AM_DO)', scs: [] });
|
||
rules[rules.length - 1].scs.push(sc);
|
||
}
|
||
if (!rules.length) return { raw: efHex(b) };
|
||
return { rules: rules.map(r => r.am + (r.scs.length ? ': ' + r.scs.join(' AND ') : '')) };
|
||
}
|
||
|
||
function efDecPnn(b) {
|
||
if (efAllFf(b)) return { empty: true };
|
||
const out = {};
|
||
for (const t of efTlv(b)) {
|
||
if (t.tag === 0x43) out.full = efPnnText(t.value);
|
||
else if (t.tag === 0x45) out.short = efPnnText(t.value);
|
||
}
|
||
return Object.keys(out).length ? out : { raw: efHex(b) };
|
||
}
|
||
|
||
// ADN-format record: alpha + len(1) + TON/NPI(1) + BCD(10) + CCP(1) + EXT1(1).
|
||
function efDecAdn(b) {
|
||
if (efAllFf(b)) return { empty: true };
|
||
if (b.length < 14) return { raw: efHex(b) };
|
||
const alpha = efRstripFf(b.subarray(0, b.length - 14));
|
||
const name = efAnnexA(alpha);
|
||
const number = efBcdAddress(b.subarray(b.length - 13, b.length - 2));
|
||
const out = { name: name, number: number };
|
||
if (b[b.length - 2] !== 0xFF) out.ccp = b[b.length - 2];
|
||
if (b[b.length - 1] !== 0xFF) out.ext1 = b[b.length - 1];
|
||
return out;
|
||
}
|
||
|
||
// TS 51.011 §10.5.10 EF_EXT1: record type(1) + extension data(11) + identifier(1).
|
||
// Additional data (type bit 2): count byte + BCD remainder; called party
|
||
// subaddress (type bit 1): TS 24.008 subaddress data.
|
||
function efDecExt1(b) {
|
||
if (efAllFf(b)) return { empty: true };
|
||
if (b.length < 13) return { raw: efHex(b) };
|
||
const rt = b[0];
|
||
const out = {};
|
||
if (rt & 0x02) {
|
||
const n = b[1];
|
||
if (n > 0) {
|
||
const digits = efSwapNibbles(efHex(b.subarray(2, 2 + Math.min(n, 10)))).replace(/[fF]+$/, '');
|
||
out.number = digits || null;
|
||
}
|
||
}
|
||
if (rt & 0x01) out.subaddress = efHex(b.subarray(1, 12));
|
||
if (b[12] !== 0xFF) out.next_record = b[12];
|
||
return Object.keys(out).length ? out : { raw: efHex(b) };
|
||
}
|
||
|
||
function efDecSms(b) {
|
||
if (efAllFf(b)) return { empty: true };
|
||
const s = b[0];
|
||
let direction = null, status;
|
||
if ((s & 0x01) === 0x00) { direction = null; status = 'free space'; }
|
||
else if ((s & 0x07) === 0x01) { direction = 'MT'; status = 'message read'; }
|
||
else if ((s & 0x07) === 0x03) { direction = 'MT'; status = 'message to be read'; }
|
||
else if ((s & 0x07) === 0x07) { direction = 'MO'; status = 'message to be sent'; }
|
||
else if ((s & 0x1F) === 0x05) { direction = 'MO'; status = 'sent (status not requested)'; }
|
||
else if ((s & 0x1F) === 0x0D) { direction = 'MO'; status = 'sent (status requested, not received)'; }
|
||
else if ((s & 0x1F) === 0x15) { direction = 'MO'; status = 'sent (status received, not stored)'; }
|
||
else if ((s & 0x1F) === 0x1D) { direction = 'MO'; status = 'sent (status received, stored)'; }
|
||
else { direction = null; status = 'RFU'; }
|
||
const out = { status: status };
|
||
if (direction) out.direction = direction;
|
||
const rem = b.subarray(1);
|
||
if (rem.length) {
|
||
const scLen = rem[0];
|
||
const tpdu = efRstripFf(rem.subarray(1 + scLen));
|
||
if (tpdu.length) out.tpdu = efHex(tpdu);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// TS 51.011 §10.3.34 EF_SUME: Simple TLVs for the SET UP MENU title.
|
||
function efDecSume(b) {
|
||
if (efAllFf(b)) return { empty: true };
|
||
const out = {};
|
||
for (const t of efTlv(b)) {
|
||
if (t.tag === 0x05) { const s = efAnnexA(t.value); if (s) out.title = s; }
|
||
else if (t.tag === 0x1E) out.icon = efHex(t.value);
|
||
}
|
||
return Object.keys(out).length ? out : { raw: efHex(b) };
|
||
}
|
||
|
||
// ---- registry ----
|
||
|
||
const EF_DECODERS = [
|
||
{ name: 'EF.ICCID', fid: '2fe2', fn: efDecIccid },
|
||
{ name: 'EF.IMSI', fid: '6f07', fn: efDecImsi },
|
||
{ name: 'EF.IST', fid: '6f07', fn: b => efDecServiceTable(b, EF_IST_SERVICES) },
|
||
{ name: 'EF.UST', fid: '6f38', fn: b => efDecServiceTable(b, EF_UST_SERVICES) },
|
||
{ name: 'EF.SST', fid: '6f38', fn: b => efDecServiceTable(b, EF_SST_SERVICES) },
|
||
{ name: 'EF.LI', fid: '6f05', fn: efDecLi },
|
||
{ name: 'EF.LP', fid: '6f05', fn: efDecLi },
|
||
{ name: 'EF.PL', fid: '2f05', fn: efDecLi },
|
||
{ name: 'EF.SPN', fid: '6f46', fn: efDecSpn },
|
||
{ name: 'EF.PLMNsel', fid: '6f30', fn: efDecPlmnList },
|
||
{ name: 'EF.PLMNwAcT', fid: '6f60', fn: efDecPlmnWact },
|
||
{ name: 'EF.OPLMNwAcT', fid: '6f61', fn: efDecPlmnWact },
|
||
{ name: 'EF.HPLMNwAcT', fid: '6f62', fn: efDecPlmnWact },
|
||
{ name: 'EF.EHPLMN', fid: '6fd9', fn: efDecPlmnList },
|
||
{ name: 'EF.OPL', fid: '6fc6', fn: efDecOpl },
|
||
{ name: 'EF.SPDI', fid: '6fcd', fn: efDecSpdi },
|
||
{ name: 'EF.PNN', fid: '6fc5', fn: efDecPnn },
|
||
{ name: 'EF.LOCI', fid: '6f7e', fn: efDecLoci },
|
||
{ name: 'EF.PSLOCI', fid: '6f73', fn: efDecPsLoci },
|
||
{ name: 'EF.EPSLOCI', fid: '6fe3', fn: efDecEpsLoci },
|
||
{ name: 'EF.EPSNSC', fid: '6fe4', fn: efDecEpsNsc },
|
||
{ name: 'EF.FPLMN', fid: '6f7b', fn: efDecFplmn },
|
||
{ name: 'EF.LOCIGPRS', fid: '6f53', fn: efDecPsLoci },
|
||
{ name: 'EF.Kc', fid: '6f20', fn: efDecKc },
|
||
{ name: 'EF.KcGPRS', fid: '6f52', fn: efDecKc },
|
||
{ name: 'EF.ACC', fid: '6f78', fn: efDecAcc },
|
||
{ name: 'EF.PHASE', fid: '6fae', fn: efDecPhase },
|
||
{ name: 'EF.CBMI', fid: '6f45', fn: efDecCbmi },
|
||
{ name: 'EF.CBMID', fid: '6f48', fn: efDecCbmi },
|
||
{ name: 'EF.CBMIR', fid: '6f50', fn: efDecCbmir },
|
||
{ name: 'EF.ECC', fid: '6fb7', fn: efDecEcc },
|
||
{ name: 'EF.AD', fid: '6fad', fn: efDecAd },
|
||
{ name: 'EF.ACL', fid: '6f57', fn: efDecAcl },
|
||
{ name: 'EF.SMSP', fid: '6f42', fn: efDecSmsp },
|
||
{ name: 'EF.SMS', fid: '6f3c', fn: efDecSms },
|
||
{ name: 'EF.ARR', fid: '2f06', fn: efDecArr },
|
||
{ name: 'EF.ARR', fid: '6f06', fn: efDecArr },
|
||
{ name: 'EF.DIR', fid: '2f00', fn: efDecDir },
|
||
{ name: 'EF.IMPI', fid: '6f02', fn: efDecNai },
|
||
{ name: 'EF.DOMAIN', fid: '6f03', fn: efDecNai },
|
||
{ name: 'EF.IMPU', fid: '6f04', fn: efDecNai },
|
||
{ name: 'EF.ADN', fid: '6f3a', fn: efDecAdn },
|
||
{ name: 'EF.FDN', fid: '6f3b', fn: efDecAdn },
|
||
{ name: 'EF.SDN', fid: '6f49', fn: efDecAdn },
|
||
{ name: 'EF.MSISDN', fid: '6f40', fn: efDecAdn },
|
||
{ name: 'EF.LND', fid: '6f44', fn: efDecAdn },
|
||
{ name: 'EF.BDN', fid: '6f4d', fn: efDecAdn },
|
||
{ name: 'EF.EXT1', fid: '6f4a', fn: efDecExt1 },
|
||
{ name: 'EF.EXT2', fid: '6f4b', fn: efDecExt1 },
|
||
{ name: 'EF.EXT3', fid: '6f4c', fn: efDecExt1 },
|
||
{ name: 'EF.EXT4', fid: '6f4e', fn: efDecExt1 },
|
||
{ name: 'EF.EXT5', fid: '6f55', fn: efDecExt1 },
|
||
{ name: 'EF.SUME', fid: '6f54', fn: efDecSume },
|
||
];
|
||
|
||
function efFindDecoder(name, fid) {
|
||
let n = (name || '').toUpperCase();
|
||
if (n.includes('/')) n = n.split('/').pop();
|
||
for (const d of EF_DECODERS) if (d.name.toUpperCase() === n) return d;
|
||
const f = (fid || '').toLowerCase();
|
||
if (f) for (const d of EF_DECODERS) if (d.fid === f) return d;
|
||
return null;
|
||
}
|
||
|
||
function efFidFromPath(path) {
|
||
const seg = (path || '').split('/').pop();
|
||
return /^[0-9a-fA-F]{4}$/.test(seg) ? seg.toUpperCase() : null;
|
||
}
|
||
|
||
// ---- content decoding, flattening and diffs ----
|
||
|
||
function efDecodeBytes(fn, bytes) {
|
||
try { return fn(bytes); } catch (e) { return { raw: efHex(bytes) }; }
|
||
}
|
||
|
||
// content: {kind:'transparent', data:'HEX'} | {kind:'record', records:[{num,data}]}
|
||
function efDecodeFile(name, fid, content) {
|
||
const dec = efFindDecoder(name, fid);
|
||
if (!dec) return null;
|
||
const out = { ef: dec.name, fn: dec.fn, data: null, records: null, empty: false };
|
||
if (!content) return out;
|
||
if (content.kind === 'record') {
|
||
out.records = (content.records || []).map(r => {
|
||
const d = efDecodeBytes(dec.fn, efBytes(r.data || ''));
|
||
return { num: r.num, data: d, empty: !!(d && d.empty) };
|
||
});
|
||
} else {
|
||
out.data = efDecodeBytes(dec.fn, efBytes(content.data || ''));
|
||
out.empty = !!(out.data && out.data.empty);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function efDecodeHex(name, fid, hex) {
|
||
const dec = efFindDecoder(name, fid);
|
||
if (!dec || !dec.fn) return null;
|
||
return efDecodeBytes(dec.fn, efBytes(hex || ''));
|
||
}
|
||
|
||
function efIsRawOnly(data) {
|
||
if (!data || typeof data !== 'object') return true;
|
||
const keys = Object.keys(data);
|
||
return keys.length === 0 || (keys.length === 1 && keys[0] === 'raw');
|
||
}
|
||
|
||
function efFieldLabel(k) {
|
||
if (EF_FIELD_LABELS[k]) return EF_FIELD_LABELS[k];
|
||
return k.replace(/_/g, ' ').replace(/^./, c => c.toUpperCase());
|
||
}
|
||
|
||
function efPrimitive(v) {
|
||
return typeof v === 'boolean' ? (v ? 'yes' : 'no') : String(v);
|
||
}
|
||
|
||
// Flatten a decoded object into [label, value] rows (display and diff).
|
||
function efFlatten(data) {
|
||
const rows = [];
|
||
if (data === null || data === undefined) return rows;
|
||
if (typeof data !== 'object') { rows.push(['', efPrimitive(data)]); return rows; }
|
||
// `empty: true` marks an all-FF (unused) record - a marker, not a field.
|
||
// The decoded views show their own "empty" note; the comparison shows
|
||
// 'Empty (all FF)' for the empty side of a diff.
|
||
if (data.empty === true) return rows;
|
||
const arrayRows = (label, arr) => {
|
||
if (!arr.length) return;
|
||
if (typeof arr[0] !== 'object' || arr[0] === null) {
|
||
rows.push([label, arr.map(efPrimitive).join(', ')]);
|
||
return;
|
||
}
|
||
arr.forEach((it, i) => {
|
||
if (Array.isArray(it)) rows.push([label + ' ' + (i + 1), it.map(efPrimitive).join('\u2013')]);
|
||
else if (it && it.n !== undefined && it.name !== undefined) rows.push(['Service ' + it.n, String(it.name)]);
|
||
else if (it && it.mcc !== undefined) rows.push(['PLMN ' + (i + 1), it.mcc + '-' + it.mnc + (it.access_tech ? ' (' + it.access_tech + ')' : '')]);
|
||
else if (it && it.aid !== undefined) rows.push(['Application ' + (i + 1), (it.label ? it.label + ' \u2014 ' : '') + it.aid + (it.eap ? ' [' + (it.eap.eap_types || []).join(', ') + ']' : '')]);
|
||
else rows.push([label + ' ' + (i + 1), JSON.stringify(it)]);
|
||
});
|
||
};
|
||
for (const k of Object.keys(data)) {
|
||
const v = data[k];
|
||
if (v === null || v === undefined) continue;
|
||
if (Array.isArray(v)) arrayRows(efFieldLabel(k), v);
|
||
else if (typeof v === 'object') {
|
||
for (const [k2, v2] of efFlatten(v)) rows.push([efFieldLabel(k) + (k2 ? ' / ' + k2 : ''), v2]);
|
||
} else {
|
||
rows.push([efFieldLabel(k), efPrimitive(v)]);
|
||
}
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
// Per-field diff of two decoded objects: only differing fields.
|
||
function efDiffData(expData, actData) {
|
||
const e = {}, a = {};
|
||
for (const [k, v] of efFlatten(expData)) e[k] = v;
|
||
for (const [k, v] of efFlatten(actData)) a[k] = v;
|
||
const labels = [...new Set([...Object.keys(e), ...Object.keys(a)])];
|
||
// A side that decodes to an all-FF record has no fields at all: say so in
|
||
// its cells instead of leaving them blank. A field merely absent from a
|
||
// non-empty record still shows as blank.
|
||
const expEmpty = expData && expData.empty === true ? t('Empty (all FF)') : '';
|
||
const actEmpty = actData && actData.empty === true ? t('Empty (all FF)') : '';
|
||
const rows = [];
|
||
for (const l of labels) {
|
||
const ev = e[l], av = a[l];
|
||
if (ev === av) continue;
|
||
rows.push([l,
|
||
ev === undefined ? expEmpty : ev,
|
||
av === undefined ? actEmpty : av]);
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function efDataSummary(data) {
|
||
if (!data || typeof data !== 'object') return '';
|
||
if (data.imsi) return 'IMSI ' + data.imsi;
|
||
if (data.iccid) return 'ICCID ' + data.iccid;
|
||
if (data.title) return data.title;
|
||
if (data.name !== undefined && data.number !== undefined) return (data.name + ' ' + data.number).trim();
|
||
if (data.number) return data.number;
|
||
if (data.text) return data.text;
|
||
if (data.languages && data.languages.length) return data.languages.join(' ');
|
||
if (data.services) return data.services.length + ' services';
|
||
if (data.plmns) return data.plmns.map(p => p.mcc + '-' + p.mnc).join(', ');
|
||
if (data.applications) return data.applications.map(x => x.label || x.aid || '').join('; ');
|
||
if (data.status) return data.status + (data.direction ? ' (' + data.direction + ')' : '');
|
||
if (data.full) return data.full;
|
||
if (data.mcc !== undefined && data.mnc !== undefined) return data.mcc + '-' + data.mnc;
|
||
if (data.kc) return 'Kc ' + data.kc;
|
||
return '';
|
||
}
|
||
|
||
// Decoded field rows: the label column is sized to its content (grid
|
||
// max-content) instead of a fixed width, so values start right after the
|
||
// widest label and short labels do not push the data far to the right.
|
||
function efRenderFieldsHtml(rows) {
|
||
if (!rows.length) return '<span class="text-gray-400">' + esc(t('No decodable fields')) + '</span>';
|
||
let html = '<div class="text-xs" style="display:grid;grid-template-columns:max-content 1fr;column-gap:0.5rem;row-gap:0.125rem">';
|
||
for (const [k, v] of rows) {
|
||
html += '<span class="text-right text-gray-500 dark:text-slate-400 break-all">' + esc(k) + '</span>' +
|
||
'<span class="font-mono break-all">' + esc(v) + '</span>';
|
||
}
|
||
return html + '</div>';
|
||
}
|
||
|
||
// Mismatching decoded fields for a content check; null when the file has no
|
||
// decoder or either side fails to decode.
|
||
function efContentDiff(name, fid, expectedHex, actualHex) {
|
||
const exp = efDecodeHex(name, fid, expectedHex);
|
||
const act = efDecodeHex(name, fid, actualHex);
|
||
if (exp === null || act === null || efIsRawOnly(exp) || efIsRawOnly(act)) return null;
|
||
const rows = efDiffData(exp, act);
|
||
return { rows: rows, sameDecoded: rows.length === 0 };
|
||
}
|
||
|
||
// ===== Network simulation (Phone tab) =====
|
||
// Sends the scenario parameters to POST /api/net-sim and renders the step
|
||
// log. The scenarios are implemented in pysim_simple_server/netsim.py
|
||
// (trace study: projects/UICC_NAA.md section 13).
|
||
|
||
let _netSimOpResults = [];
|
||
let _netSimSearchTimer = null;
|
||
|
||
function netSimRandomHex(nbytes) {
|
||
let s = '';
|
||
for (let i = 0; i < nbytes; i++) s += Math.floor(Math.random() * 256).toString(16).padStart(2, '0').toUpperCase();
|
||
return s;
|
||
}
|
||
|
||
function netSimDice(id, nbytes) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.value = netSimRandomHex(nbytes);
|
||
}
|
||
|
||
function netSimVal(id) {
|
||
const el = document.getElementById(id);
|
||
return el ? el.value.trim() : '';
|
||
}
|
||
|
||
function netSimChk(id) {
|
||
const el = document.getElementById(id);
|
||
return el ? el.checked : false;
|
||
}
|
||
|
||
function netSimParams() {
|
||
const p = {
|
||
send_event: netSimChk('netsim-send-event'),
|
||
dummy_locations: netSimChk('netsim-dummy-locations'),
|
||
invalidate_epsnsc: netSimChk('netsim-invalidate-epsnsc'),
|
||
keep_kasme: netSimChk('netsim-keep-kasme'),
|
||
write_kc: netSimChk('netsim-write-kc'),
|
||
sms_location: netSimChk('netsim-sms-location'),
|
||
cb_clear: netSimChk('netsim-cb-clear'),
|
||
write_fplmn: netSimChk('netsim-write-fplmn'),
|
||
};
|
||
const map = {
|
||
mcc: 'netsim-mcc', mnc: 'netsim-mnc', lac: 'netsim-lac', cell_id: 'netsim-cell',
|
||
tac: 'netsim-tac', rac: 'netsim-rac', tmsi: 'netsim-tmsi', guti: 'netsim-guti',
|
||
ksi: 'netsim-ksi', kasme: 'netsim-kasme', kc: 'netsim-kc', ul: 'netsim-ul',
|
||
dl: 'netsim-dl', algo: 'netsim-algo', rand: 'netsim-rand', autn: 'netsim-autn',
|
||
churn_count: 'netsim-churn-count', churn_delay_ms: 'netsim-churn-delay',
|
||
};
|
||
for (const [key, id] of Object.entries(map)) {
|
||
const v = netSimVal(id);
|
||
if (v !== '') p[key] = v;
|
||
}
|
||
return p;
|
||
}
|
||
|
||
// ===== Network state monitor =====
|
||
// Cached server-side view of the network-related EFs (read at equip, patched
|
||
// from net-sim writes, refreshed on demand) plus the simulated service state
|
||
// and the derived location/roaming view. No card polling: the panel fetches
|
||
// the cached state when the Phone tab opens and after net-sim/event actions.
|
||
const NET_STATE_FILES = [
|
||
['imsi', 'EF.IMSI'], ['ehplmn', 'EF.EHPLMN'], ['spdi', 'EF.SPDI'],
|
||
['hplmnwact', 'EF.HPLMNwAcT'], ['loci', 'EF.LOCI'], ['psloci', 'EF.PSLOCI'],
|
||
['epsloci', 'EF.EPSLOCI'], ['epsnsc', 'EF.EPSNSC'], ['cbmi', 'EF.CBMI'],
|
||
['cbmir', 'EF.CBMIR'], ['smsstatus', 'EF.SMSS'], ['fplmn', 'EF.FPLMN'],
|
||
];
|
||
const NET_STATE_STATUS = {
|
||
loci: { 0: 'updated', 1: 'not updated', 2: 'PLMN not allowed', 3: 'LA not allowed' },
|
||
psloci: { 0: 'updated', 1: 'not updated', 2: 'PLMN not allowed' },
|
||
epsloci: { 0: 'updated', 1: 'not updated', 2: 'roaming not allowed' },
|
||
};
|
||
const NET_STATE_AREA = {
|
||
loci: ['LAI', 'location_update_status'],
|
||
psloci: ['RAI', 'update_status'],
|
||
epsloci: ['TAI', 'eps_update_status'],
|
||
};
|
||
const NET_STATE_ROAMING = { home: 'Home', equivalent: 'Home equivalent', guest: 'Guest (roaming)' };
|
||
const NET_STATE_SERVICE = {
|
||
normal: ['text-emerald-600 dark:text-emerald-400', '● ', 'Normal service'],
|
||
limited: ['text-amber-600 dark:text-amber-500', '⚠ ', 'Limited service'],
|
||
none: ['text-red-500', '⛔ ', 'No service'],
|
||
};
|
||
const NET_STATE_SOURCE = { init: 'init', write: 'write', read: 'read', refresh: 'refresh' };
|
||
|
||
function netStateFetch() {
|
||
return pysimFetch('/api/net-state').then(res => {
|
||
netStateRender(res && res.available ? res.state : null);
|
||
}).catch(() => {});
|
||
}
|
||
|
||
function netStateRefresh() {
|
||
return pysimFetch('/api/net-state-refresh', {}).then(res => {
|
||
netStateRender(res ? res.state : null);
|
||
}).catch(() => {});
|
||
}
|
||
|
||
function netStateStatusWord(key, raw) {
|
||
const v = parseInt(String(raw || '').replace(/^0x/i, ''), 16);
|
||
const word = (NET_STATE_STATUS[key] || {})[v];
|
||
return word ? t(word) : (raw || '');
|
||
}
|
||
|
||
function netStateFlattenTitle(decoded) {
|
||
const data = decoded && decoded.data ? decoded.data : {};
|
||
return efFlatten(data).map(r => (r[0] ? r[0] + ': ' : '') + r[1]).join(' · ');
|
||
}
|
||
|
||
// Compact PLMN list for the monitor rows: `max` keeps the row short (the
|
||
// full decoded list stays in the row's title tooltip).
|
||
function netStatePlmnList(d, withTech, max) {
|
||
const list = (d.plmns || []).map(p => p.mcc + '-' + p.mnc +
|
||
(withTech && p.access_tech ? ' (' + p.access_tech + ')' : ''));
|
||
if (max && list.length > max) return list.slice(0, max).join(', ') + ' … +' + (list.length - max);
|
||
return list.join(', ');
|
||
}
|
||
|
||
function netStateSummary(key, entry) {
|
||
if (!entry || !entry.present) {
|
||
return { text: '—', title: t('not present'), cls: 'text-gray-400' };
|
||
}
|
||
const name = (NET_STATE_FILES.find(f => f[0] === key) || [])[1];
|
||
const content = entry.kind === 'record'
|
||
? { kind: 'record', records: entry.records || [] }
|
||
: { kind: 'transparent', data: entry.data || '' };
|
||
const dec = name ? efDecodeFile(name, null, content) : null;
|
||
if (!dec) {
|
||
const raw = entry.kind === 'record' ? '' : (entry.data || '');
|
||
return raw ? { text: raw, title: raw, cls: '' }
|
||
: { text: '—', title: t('empty'), cls: 'text-gray-400' };
|
||
}
|
||
const d = dec.data;
|
||
if (!d || d.empty) return { text: '—', title: t('empty'), cls: 'text-gray-400' };
|
||
let text = '';
|
||
if (key === 'imsi') {
|
||
text = d.imsi || '';
|
||
} else if (key === 'ehplmn' || key === 'spdi' || key === 'fplmn') {
|
||
text = netStatePlmnList(d, false, 3);
|
||
} else if (key === 'hplmnwact') {
|
||
// Compact row: the first network only, plus a count of the hidden
|
||
// entries (the tooltip carries the full decoded list with tech).
|
||
text = netStatePlmnList(d, false, 1);
|
||
} else if (NET_STATE_AREA[key]) {
|
||
const [area, statusKey] = NET_STATE_AREA[key];
|
||
const plmn = (d.mcc && d.mnc) ? d.mcc + '-' + d.mnc : null;
|
||
const lac = d.lac ? String(d.lac).replace(/^0x/i, '') : '';
|
||
const status = d[statusKey] !== undefined ? netStateStatusWord(key, d[statusKey]) : '';
|
||
text = (plmn ? area + ' ' + plmn + (lac ? '/' + lac : '') : area + ' —') +
|
||
(status ? ' · ' + status : '');
|
||
} else if (key === 'epsnsc') {
|
||
const parts = [];
|
||
if (d.ksi_asme !== undefined) parts.push('KSI ' + d.ksi_asme);
|
||
if (d.uplink_nas_count !== undefined || d.downlink_nas_count !== undefined) {
|
||
parts.push('UL/DL ' + (d.uplink_nas_count || 0) + '/' + (d.downlink_nas_count || 0));
|
||
}
|
||
if (d.nas_algorithms) parts.push('algo ' + d.nas_algorithms);
|
||
text = parts.join(' · ');
|
||
} else if (key === 'cbmi') {
|
||
text = (d.message_ids || []).map(v => v.toString(16).toUpperCase().padStart(4, '0')).join(', ');
|
||
} else if (key === 'cbmir') {
|
||
text = (d.ranges || []).map(r => r[0] + '–' + r[1]).join(', ');
|
||
} else if (key === 'smsstatus') {
|
||
text = entry.data || '';
|
||
}
|
||
return { text: text || '—', title: netStateFlattenTitle(dec) || (entry.data || ''), cls: '' };
|
||
}
|
||
|
||
function netStateRender(state) {
|
||
const badge = document.getElementById('netstate-badge');
|
||
if (!badge) return;
|
||
const svc = (state && state.service) || {};
|
||
const known = NET_STATE_SERVICE[svc.state];
|
||
badge.className = 'text-sm font-semibold ' +
|
||
(known ? known[0] : 'text-gray-400 dark:text-slate-500');
|
||
badge.textContent = known ? known[1] + t(known[2]) : '○ ' + t('Undefined');
|
||
const loc = state && state.network ? state.network.location : null;
|
||
const locEl = document.getElementById('netstate-location');
|
||
if (loc) {
|
||
const statuses = [];
|
||
if (loc.roaming) statuses.push(esc(t(NET_STATE_ROAMING[loc.roaming] || loc.roaming)));
|
||
if (loc.rejected) statuses.push('<span class="text-red-500">⛔ ' + esc(t('PLMN not allowed')) + '</span>');
|
||
let h = '<span class="font-mono">' + esc(loc.mcc + '-' + loc.mnc) + '</span>';
|
||
if (loc.country) h += ' · ' + esc(loc.country);
|
||
if (loc.operator) h += ' · ' + esc(loc.operator);
|
||
if (statuses.length) h += '<span class="block">' + statuses.join(' · ') + '</span>';
|
||
locEl.innerHTML = h;
|
||
} else {
|
||
locEl.innerHTML = '<span class="text-gray-400">—</span>';
|
||
}
|
||
document.getElementById('netstate-read').textContent =
|
||
state && state.read_at ? new Date(state.read_at * 1000).toLocaleTimeString() : '';
|
||
const files = (state && state.files) || {};
|
||
let html = '';
|
||
for (const [key, name] of NET_STATE_FILES) {
|
||
const entry = files[key];
|
||
const s = netStateSummary(key, entry);
|
||
const src = entry && entry.present
|
||
? (NET_STATE_SOURCE[entry.source] || entry.source || '') : '';
|
||
html += '<div class="flex items-baseline gap-1" title="' + esc(s.title || '') + '">' +
|
||
'<span class="w-24 shrink-0 text-gray-500 dark:text-slate-400">' + esc(name) + '</span>' +
|
||
'<span class="flex-1 min-w-0 truncate ' + (s.cls || '') + '">' + esc(s.text) + '</span>' +
|
||
'<span class="text-gray-300 dark:text-slate-600">' + esc(src) + '</span>' +
|
||
'</div>';
|
||
}
|
||
document.getElementById('netstate-body').innerHTML = html ||
|
||
'<span class="text-gray-400">' + esc(t('No data')) + '</span>';
|
||
}
|
||
|
||
async function netSimRun(scenario) {
|
||
const statusEl = document.getElementById('netsim-status');
|
||
statusEl.textContent = t('Running') + '...';
|
||
statusEl.className = 'text-xs text-gray-500 dark:text-slate-400 mb-1';
|
||
try {
|
||
const body = netSimParams();
|
||
body.scenario = scenario;
|
||
const res = await pysimFetch('/api/net-sim', body);
|
||
netSimRenderLog(res);
|
||
if (res.success) {
|
||
statusEl.textContent = t('Done') + ' — ' + (res.steps || []).length + ' ' + t('steps');
|
||
statusEl.className = 'text-xs text-emerald-600 dark:text-emerald-400 mb-1';
|
||
} else {
|
||
statusEl.textContent = t('Failed') + ': ' + (res.error || '');
|
||
statusEl.className = 'text-xs text-red-500 mb-1';
|
||
}
|
||
pysimEventsRender();
|
||
if (res.net_state) netStateRender(res.net_state);
|
||
else netStateFetch();
|
||
} catch (e) {
|
||
statusEl.textContent = t('Error') + ': ' + e.message;
|
||
statusEl.className = 'text-xs text-red-500 mb-1';
|
||
}
|
||
}
|
||
|
||
function netSimStepText(s) {
|
||
const sw = (s.sw !== undefined && s.sw !== null) ? ' → SW ' + s.sw : '';
|
||
if (s.action === 'skip') return t('skip') + ' ' + (s.file || '') + (s.note ? ' — ' + s.note : '');
|
||
if (s.action === 'event') return 'ENVELOPE ' + (s.file || '') + ' ' + (s.data || '') + sw;
|
||
if (s.action === 'authenticate') {
|
||
const parsed = s.parsed ? s.parsed.type : '';
|
||
return 'AUTHENTICATE ' + parsed + sw + (s.response ? ' ' + s.response : '');
|
||
}
|
||
return s.action + ' ' + (s.file || '') + (s.path ? ' (' + s.path + ')' : '') + (s.data ? ' ' + s.data : '') + sw;
|
||
}
|
||
|
||
function netSimRenderLog(res) {
|
||
const el = document.getElementById('netsim-log');
|
||
if (!el) return;
|
||
let html = '';
|
||
for (const s of res.steps || []) {
|
||
const cls = s.ok === false ? 'text-red-500' : (s.action === 'skip' ? 'text-yellow-600' : 'text-gray-600 dark:text-slate-400');
|
||
html += '<div class="' + cls + ' break-all">' + esc(netSimStepText(s)) + '</div>';
|
||
}
|
||
el.innerHTML = html || '<span class="text-gray-400">' + esc(t('No steps')) + '</span>';
|
||
}
|
||
|
||
function netSimSearchInput() {
|
||
clearTimeout(_netSimSearchTimer);
|
||
_netSimSearchTimer = setTimeout(netSimSearch, 250);
|
||
}
|
||
|
||
async function netSimSearch() {
|
||
const q = netSimVal('netsim-op-search');
|
||
const el = document.getElementById('netsim-op-results');
|
||
if (!el) return;
|
||
if (q.length < 2) { el.innerHTML = ''; return; }
|
||
try {
|
||
const res = await pysimFetch('/api/mcc-mnc?q=' + encodeURIComponent(q));
|
||
if (!res.available) {
|
||
el.innerHTML = '<span class="text-yellow-600">' + esc(t('Operator list not loaded (server --mcc-mnc-list)')) + '</span>';
|
||
return;
|
||
}
|
||
_netSimOpResults = res.results || [];
|
||
el.innerHTML = _netSimOpResults.map((r, i) =>
|
||
'<div class="cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-700 rounded px-1" onclick="netSimPickOperator(' + i + ')">' +
|
||
esc((r.countryName || '') + ' — ' + (r.brand || r.operator || '') + ' (' + r.mcc + '/' + r.mnc + ')') + '</div>').join('');
|
||
} catch (e) {
|
||
el.textContent = e.message;
|
||
}
|
||
}
|
||
|
||
function netSimPickOperator(i) {
|
||
const r = _netSimOpResults[i];
|
||
if (!r) return;
|
||
document.getElementById('netsim-mcc').value = r.mcc;
|
||
document.getElementById('netsim-mnc').value = r.mnc;
|
||
const el = document.getElementById('netsim-op-results');
|
||
if (el) el.innerHTML = '';
|
||
}
|
||
|
||
async function netSimRandomOperator() {
|
||
const statusEl = document.getElementById('netsim-status');
|
||
try {
|
||
const exclude = (netSimVal('netsim-mcc') || '') + (netSimVal('netsim-mnc') || '');
|
||
const res = await pysimFetch('/api/mcc-mnc?random=1' + (exclude ? '&exclude=' + encodeURIComponent(exclude) : ''));
|
||
if (!res.available) {
|
||
statusEl.textContent = t('Operator list not loaded (server --mcc-mnc-list)');
|
||
statusEl.className = 'text-xs text-yellow-600 mb-1';
|
||
return;
|
||
}
|
||
if (res.result) {
|
||
document.getElementById('netsim-mcc').value = res.result.mcc;
|
||
document.getElementById('netsim-mnc').value = res.result.mnc;
|
||
statusEl.textContent = (res.result.countryName || '') + ' — ' + (res.result.brand || res.result.operator || '') + ' (' + res.result.mcc + '/' + res.result.mnc + ')';
|
||
statusEl.className = 'text-xs text-gray-500 dark:text-slate-400 mb-1';
|
||
}
|
||
} catch (e) {
|
||
statusEl.textContent = t('Error') + ': ' + e.message;
|
||
statusEl.className = 'text-xs text-red-500 mb-1';
|
||
}
|
||
}
|
||
|
||
// The card's home network: EF.HPLMNwAcT first record, else the IMSI (the
|
||
// server resolves it; see netstate.home_plmn).
|
||
async function netSimHomeOperator() {
|
||
const statusEl = document.getElementById('netsim-status');
|
||
try {
|
||
const res = await pysimFetch('/api/net-state');
|
||
const home = res && res.available && res.state && res.state.network ? res.state.network.home : null;
|
||
if (!home) {
|
||
statusEl.textContent = t('Home PLMN not available (no HPLMNwAcT/IMSI)');
|
||
statusEl.className = 'text-xs text-yellow-600 mb-1';
|
||
return;
|
||
}
|
||
document.getElementById('netsim-mcc').value = home.mcc;
|
||
document.getElementById('netsim-mnc').value = home.mnc;
|
||
statusEl.textContent = t('Home network') + ': ' + home.mcc + '/' + home.mnc +
|
||
' (' + t(home.source === 'imsi' ? 'from IMSI' : 'from EF.HPLMNwAcT') + ')';
|
||
statusEl.className = 'text-xs text-gray-500 dark:text-slate-400 mb-1';
|
||
} catch (e) {
|
||
statusEl.textContent = t('Error') + ': ' + e.message;
|
||
statusEl.className = 'text-xs text-red-500 mb-1';
|
||
}
|
||
}
|
||
|
||
// ===== 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',
|
||
'detected from the CAP': 'определено по CAP',
|
||
'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)',
|
||
'File system access (full)': 'Доступ к файловой системе (полный)',
|
||
'ADF.USIM access (full)': 'Доступ к ADF.USIM (полный)',
|
||
'ADF AID (hex)': 'AID ADF (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 —': '— выберите пресет —',
|
||
'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',
|
||
'Parser': 'Разбор',
|
||
'R-APDU parser': 'Разбор R-APDU',
|
||
'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 to file': 'Экспорт в файл',
|
||
'Import from file': 'Импорт из файла',
|
||
'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 исполняемых модулей / классов апплетов:',
|
||
'Applications:': 'Приложения:',
|
||
'Free NV:': 'Свободно NV:',
|
||
'Free Volatile:': 'Свободно volatile:',
|
||
'AID:': 'AID:',
|
||
'Lifecycle:': 'Жизненный цикл:',
|
||
'Privileges:': 'Привилегии:',
|
||
'SD AID:': 'AID SD:',
|
||
'Implicit sel:': 'Неявный выбор:',
|
||
'Version:': 'Версия:',
|
||
'(none)': '(нет)',
|
||
'Partial': 'Частично',
|
||
'OK': 'OK',
|
||
'ISD': 'ISD',
|
||
'Apps': 'Приложения',
|
||
'ELFs': 'ELF',
|
||
'ELF Modules': 'Модули ELF',
|
||
'Memory': 'Память',
|
||
'no data': 'нет данных',
|
||
'(no data)': '(нет данных)',
|
||
'response via SMS not captured': 'ответ по SMS не перехвачен',
|
||
'no PoR': 'нет PoR',
|
||
'not in the list': 'нет в списке',
|
||
'free NV': 'свободно NV',
|
||
'Failed': 'Ошибка',
|
||
'cascade': 'каскадно',
|
||
'Select a card preset with keys first (RAM subtab → Card preset)': 'Сначала выберите пресет карты с ключами (RAM → Пресет карты)',
|
||
'Select a .cap file': 'Выберите файл .cap',
|
||
'RAM install (CAP file)': 'RAM-установка (файл CAP)',
|
||
'Extra install parameters (hex, optional)': 'Дополнительные параметры установки (hex, опционально)',
|
||
'Install parameters (hex, optional)': 'Параметры установки (hex, опционально)',
|
||
'STK parameters (hex, optional)': 'STK-параметры (hex, опционально)',
|
||
'Make selectable': 'Сделать выбираемым',
|
||
'Queue CAP install': 'Поставить установку CAP в очередь',
|
||
'Queued': 'В очереди',
|
||
'Expanded format': 'Развёрнутый формат',
|
||
'To expanded': 'В развёрнутый',
|
||
'→ Expanded Script': '→ Расширенный скрипт',
|
||
'Queue in SCP81': 'В очередь SCP81',
|
||
'No commands': 'Нет команд',
|
||
'APDUs': 'APDU',
|
||
'INSTALL / LOAD / INSTALL': 'INSTALL / LOAD / INSTALL',
|
||
'send a push to run it': 'отправьте push для запуска',
|
||
'Script results (R-APDUs)': 'Результаты скрипта (R-APDU)',
|
||
'CAP file exceeds 48 kB limit': 'Файл CAP превышает лимит 48 кБ',
|
||
'Reading CAP file...': 'Чтение файла CAP...',
|
||
'Sending to server for install...': 'Отправка на сервер для установки...',
|
||
'Analyzing CAP file...': 'Анализ CAP-файла...',
|
||
'Analysis failed:': 'Ошибка анализа:',
|
||
'Retry': 'Повторить',
|
||
'Analyze the CAP file first': 'Сначала проанализируйте CAP-файл',
|
||
'CAP details': 'Детали CAP',
|
||
'CAP requirements (estimate)': 'Требования CAP (оценка)',
|
||
'NVRAM requirement ≈': 'Требование NVRAM ≈',
|
||
'Requires:': 'Требуются:',
|
||
'Compiled against:': 'Собрано под:',
|
||
'CAP format': 'формат CAP',
|
||
'Package:': 'Пакет:',
|
||
'Applets:': 'Апплеты:',
|
||
'Components:': 'Компоненты:',
|
||
'applet package': 'пакет-апплет',
|
||
'exports an API': 'экспортирует API',
|
||
'uses int': 'использует int',
|
||
'refs': 'ссылок',
|
||
'code image': 'образ кода',
|
||
'data': 'данные',
|
||
'Code image (load file):': 'Образ кода (load-файл):',
|
||
'bytecode (Method.cap):': 'байткод (Method.cap):',
|
||
'other components:': 'прочие компоненты:',
|
||
'Persistent data (NVRAM):': 'Постоянные данные (NVRAM):',
|
||
'NVRAM = code image + persistent data (GP Card Spec v2.3.1 Table 11-48, C6+C8 style); card memory management, rounding and the registry entry are not included, and the static field image appears in both parts.': 'NVRAM = образ кода + постоянные данные (GP Card Spec v2.3.1 Table 11-48, стиль C6+C8); управление памятью карты, округление и запись в реестре не учтены, а образ статических полей входит в обе части.',
|
||
'Code (Method.cap):': 'Код (Method.cap):',
|
||
'Persistent (NVRAM):': 'Энергонезависимая память (NVRAM):',
|
||
'RAM (volatile):': 'ОЗУ (volatile):',
|
||
'Details': 'Подробнее',
|
||
'Static image:': 'Образ статических полей:',
|
||
'Static array init:': 'Инициализация статических массивов:',
|
||
'Install-time objects:': 'Объекты при установке:',
|
||
'Object headers:': 'Заголовки объектов:',
|
||
'Reference storage:': 'Хранение ссылок:',
|
||
'Transient arrays:': 'Transient-массивы:',
|
||
'Runtime transient:': 'Transient во время работы:',
|
||
'Peak method frame:': 'Пиковый кадр метода:',
|
||
'Suggested install quotas (informational):': 'Рекомендуемые квоты установки (справочно):',
|
||
'Estimate only — memory values are not used for installation': 'Только оценка — значения памяти не используются при установке',
|
||
'Package': 'Пакет',
|
||
'Version': 'Версия',
|
||
'Type': 'Тип',
|
||
'Applets': 'Апплеты',
|
||
'Platform': 'Платформа',
|
||
'int support': 'поддержка int',
|
||
'Requires': 'Требуются',
|
||
'Family': 'Семейство',
|
||
'AID': 'AID',
|
||
'Refs': 'Ссылки',
|
||
'NVRAM requirement': 'Требование NVRAM',
|
||
'code image (load file)': 'образ кода (load-файл)',
|
||
'persistent data': 'постоянные данные',
|
||
'RAM (volatile)': 'ОЗУ (volatile)',
|
||
'other components': 'прочие компоненты',
|
||
'static image': 'статический образ',
|
||
'static array init': 'инициализация массивов',
|
||
'install-time objects': 'объекты установки',
|
||
'object headers': 'заголовки объектов',
|
||
'reference storage': 'хранение ссылок',
|
||
'transient arrays': 'transient-массивы',
|
||
'runtime transient': 'transient во время работы',
|
||
'peak method frame': 'пиковый кадр',
|
||
'Suggested quotas (informational)': 'Рекомендуемые квоты (справочно)',
|
||
'Components': 'Компоненты',
|
||
'Component': 'Компонент',
|
||
'Share': 'Доля',
|
||
'Load file (total)': 'Load-файл (итого)',
|
||
'Notes': 'Примечания',
|
||
'Step': 'Шаг',
|
||
'Install OK': 'Установка OK',
|
||
'Install FAILED at step:': 'Установка не удалась на шаге:',
|
||
'Hint:': 'Подсказка:',
|
||
'Set all 0.0': 'Все 0.0',
|
||
'CAP compatibility test': 'Проверка совместимости CAP',
|
||
'Import versions to test': 'Версии импортов для проверки',
|
||
"Runs the load only as far as the block that completes the CAP's Import component: the card checks the import list there, so its answer says whether the card can satisfy it. Nothing is installed and nothing is committed - the link gate is the real install's business. Lines are AID=version (rewrites an import) or +AID=version (appends a synthetic import, to query the card for a package the CAP does not use).": 'Выполняет загрузку только до блока, завершающего компонент Import CAP: именно там карта проверяет список импортов, поэтому её ответ показывает, может ли она их удовлетворить. Ничего не устанавливается и не фиксируется — проверка связывания остаётся за настоящей установкой. Строки: AID=version (переписать импорт) или +AID=version (добавить синтетический импорт, чтобы запросить у карты пакет, который CAP не использует).',
|
||
'Sets every AID=version line to 0.0; +AID=version lines keep their version.': 'Заменяет все строки AID=version на 0.0; строки +AID=version сохраняют свою версию.',
|
||
"The card checks the CAP's import list while it loads, so a rejection at the Import component usually means it cannot satisfy that list.": 'Карта проверяет список импортов CAP при загрузке, поэтому отказ на компоненте Import обычно означает, что она не может удовлетворить этот список.',
|
||
"Press 'Set all 0.0' first: if the test now passes, the list was the cause; then restore one line at a time to find the package and version the card lacks. A +AID=version line asks the card about a package the CAP does not use.": 'Сначала нажмите «Все 0.0»: если проверка проходит, причина была в списке; затем возвращайте по одной строке, чтобы найти пакет и версию, которых не хватает карте. Строка +AID=version спрашивает карту о пакете, который CAP не использует.',
|
||
'LOAD block size (1..240, default 240)': 'Размер блока LOAD (1..240, по умолчанию 240)',
|
||
'Import list accepted': 'Список импортов принят',
|
||
'Import list rejected': 'Список импортов отклонён',
|
||
'tested through LOAD': 'проверено до LOAD',
|
||
'Invalid line:': 'Некорректная строка:',
|
||
'No import versions given': 'Версии импортов не заданы',
|
||
'probe': 'проба',
|
||
'imports': 'импортов',
|
||
'mixed': 'разные',
|
||
'appended': 'добавлено',
|
||
'unmatched': 'без совпадений',
|
||
'Load request rejected': 'Запрос загрузки отклонён',
|
||
'The card did not answer the format probe': 'Карта не ответила на пробу формата',
|
||
'Sending to server...': 'Отправка на сервер...',
|
||
'Failed in:': 'Ошибка в:',
|
||
'load-file bytes': 'байты load-файла',
|
||
'covers': 'охватывает',
|
||
'Import list under test': 'Список импортов под проверкой',
|
||
'not in the CAP': 'нет в CAP',
|
||
"not in the CAP's import list": 'нет в списке импортов CAP',
|
||
"The card verifies the load file's import list when the Import component is complete (JC VM spec 4.5.2).": 'Карта проверяет список импортов load-файла, когда компонент Import завершён (JC VM spec 4.5.2).',
|
||
'The import list already passed \u2014 the Import component completed at': 'Список импортов уже пройден \u2014 компонент Import завершён на',
|
||
'Every import was tested at 0.0 \u2014 a package the card does not have (or another load-file check, e.g. memory) is the cause.': 'Все импорты проверены с версией 0.0 \u2014 причина в пакете, которого нет на карте (или в другой проверке load-файла, например памяти).',
|
||
'The card checks the whole list at that point \u2014 lower the versions (Set all 0.0) to test whether the list is the cause, then restore one line at a time to find the culprit.': 'Карта проверяет весь список в этой точке \u2014 понизьте версии («Все 0.0»), чтобы проверить, в списке ли причина, затем возвращайте по одной строке, чтобы найти виновника.',
|
||
'The card checks the whole list at that point \u2014 the CAP compatibility test can confirm it: Set all 0.0 first, then restore one line at a time to find the package and version the card lacks.': 'Карта проверяет весь список в этой точке \u2014 это можно подтвердить проверкой совместимости CAP: сначала «Все 0.0», затем возвращайте по одной строке, чтобы найти пакет и версию, которых не хватает карте.',
|
||
'completes': 'завершает',
|
||
'inside': 'внутри',
|
||
'the failed step completes': 'неудавшийся шаг завершает',
|
||
'a LOAD rejected here means the card refused the CAP: every import version under Requires must be present on the card': 'отклонение LOAD здесь означает, что карта отклонила CAP: все версии импортов из «Требуются» должны присутствовать на карте',
|
||
'. If the CAP needs only versions this card has, retry with a smaller LOAD block (e.g. 64 B)': '. Если CAP требует только доступные этой карте версии, повторите с меньшим размером блока LOAD (например, 64 Б)',
|
||
'Profile from snapshot': 'Профиль из снимка',
|
||
'Profile verification results for:': 'Результаты проверки профиля:',
|
||
'Snapshot comparison results:': 'Результаты сравнения снимков:',
|
||
'RAM operations perform atomic GlobalPlatform commands over SCP80. Card keys are taken from the saved preset.': 'RAM-операции выполняют атомарные команды GlobalPlatform через SCP80. Ключи карты берутся из сохранённого пресета.',
|
||
'No cards defined.': 'Карты не заданы.',
|
||
"The equipped card's preset": 'Пресет подключённой карты',
|
||
'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-simple-server running locally.': 'Для доступа к локальному картридеру необходим запущенный pysim-simple-server.',
|
||
'To connect, install': 'Для подключения установите',
|
||
'and start it.': 'и запустите его.',
|
||
'Refer to pysim-simple-server README files for details.': 'Подробности — в README файлах pysim-simple-server.',
|
||
'Click': 'Нажмите кнопку',
|
||
'when pysim-simple-server is ready': 'когда pysim-simple-server будет готов',
|
||
'Equip card': 'Подключить карту',
|
||
'Check status': 'Проверить статус',
|
||
'Reset card': 'Сброс карты',
|
||
'Disconnect from server': 'Отключиться от сервера',
|
||
'File manager': 'Файловый менеджер',
|
||
'Custom files': 'Пользовательские файлы',
|
||
'pySim command line': 'Командная строка pySim',
|
||
'Raw APDU': 'Отправка APDU',
|
||
'Read raw': 'Читать сырые данные',
|
||
'Read decoded': 'Декодировать',
|
||
'Edit raw': 'Редактировать сырые данные',
|
||
'Refresh': 'Обновить',
|
||
'Last refresh': 'Последнее обновление',
|
||
'Save': 'Сохранить',
|
||
'Decoded': 'Декодированные данные',
|
||
'Edit': 'Редактировать',
|
||
'Clone': 'Клонировать',
|
||
'Copy of': 'Копия',
|
||
'Execute': 'Выполнить',
|
||
'Send': 'Отправить',
|
||
'Custom files are added in file manager tree and included in card scan.': 'Пользовательские файлы добавляются в менеджер файлов и учитываются при чтении карт.',
|
||
'Cancel': 'Отмена',
|
||
'Timeout': 'Таймаут',
|
||
'OK': 'OK',
|
||
'Exit': 'Выход',
|
||
'← Back': '← Назад',
|
||
'Connecting...': 'Подключение...',
|
||
'Checking card...': 'Проверка карты...',
|
||
'Connected': 'Подключено',
|
||
'Connection failed:': 'Ошибка подключения:',
|
||
'pysim-blocked-hint': 'Браузер, вероятно, заблокировал доступ к локальному серверу карт. Разрешите доступ к локальной сети для этого сайта (Chrome/Edge/Vivaldi: Настройки сайта → Доступ к локальной сети → Разрешить). Также проверьте, что pysim-simple-server запущен по адресу {url}.',
|
||
'pysim-unreachable-hint': 'Не удалось подключиться к серверу карт. Проверьте, что pysim-simple-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': 'Отправить на карту',
|
||
'Simulator': 'Симулятор',
|
||
'HTTP OTA listener': 'HTTP OTA слушатель',
|
||
'Mode': 'Режим',
|
||
'Capture (dump)': 'Захват (dump)',
|
||
'PSK TLS server': 'PSK TLS сервер',
|
||
'Script': 'Сценарий',
|
||
'Memory + ELF info': 'Память + ELF',
|
||
'Host': 'Хост',
|
||
'Port': 'Порт',
|
||
'PSK key (hex)': 'PSK ключ (hex)',
|
||
'HTTP OTA log': 'Журнал HTTP OTA',
|
||
'The key is only sent to the local server and is never stored or logged.': 'Ключ передаётся только локальному серверу, не сохраняется и не записывается в журнал.',
|
||
'Listening.': 'Слушает.',
|
||
'Stopped.': 'Остановлено.',
|
||
'BIP': 'BIP',
|
||
'BIP terminal': 'BIP-терминал',
|
||
'Local sink (log only)': 'Локальный приёмник (только журнал)',
|
||
'Card\'s destination (OPEN CHANNEL)': 'Адрес карты (OPEN CHANNEL)',
|
||
'Fixed target': 'Заданный адрес',
|
||
'Channels': 'Каналы',
|
||
'BIP log': 'Журнал BIP',
|
||
'Local sink: accepts the card\'s BIP channels on this Host and Port, logs everything received and never sends anything back. Leave the port empty for an ephemeral port (the bound one appears in the status). Use it for cards that open a TCP connection just to upload data.': 'Локальный приёмник: принимает BIP-каналы карты на этом Host и Port, записывает всё принятое в журнал и ничего не отправляет обратно. Оставьте порт пустым для эфемерного порта (занятый порт появится в статусе). Подходит для карт, которые открывают TCP-соединение только чтобы выгрузить данные.',
|
||
'Card\'s destination: each BIP channel is connected to the destination the card requests in OPEN CHANNEL (Other address + transport port, TCP client only); Host and Port are not used and the server\'s network is used (lab only).': 'Адрес карты: каждый BIP-канал подключается к адресу, который карта запросила в OPEN CHANNEL (Other address + transport port, только TCP-клиент); Host и Port не используются, используется сеть сервера (только для лаборатории).',
|
||
'Fixed target: every BIP channel is connected to this Host:Port; the address the card requests is only logged.': 'Заданный адрес: каждый BIP-канал подключается к этому Host:Port; запрошенный картой адрес только записывается в журнал.',
|
||
'One BIP session at a time: starting here replaces a running SCP81 listener, and starting the SCP81 listener replaces this session.': 'Одновременно работает только одна BIP-сессия: запуск здесь останавливает слушатель SCP81, а запуск слушателя SCP81 останавливает эту сессию.',
|
||
'ephemeral': 'эфемерный',
|
||
'Fixed target requires the host': 'Для заданного адреса нужен хост',
|
||
'Port must be 1-65535': 'Порт должен быть в диапазоне 1-65535',
|
||
'Port must be 0-65535': 'Порт должен быть в диапазоне 0-65535',
|
||
'peer closed': 'пир закрыт',
|
||
'open': 'открыт',
|
||
'connections': 'соединений',
|
||
'owner': 'владелец',
|
||
'No channels.': 'Каналов нет.',
|
||
'Start failed:': 'Запуск не удался:',
|
||
'BIP session started': 'BIP-сессия запущена',
|
||
'BIP session stopped': 'BIP-сессия остановлена',
|
||
'Stopped the SCP81 listener': 'Остановлен слушатель SCP81',
|
||
'Stopped the BIP session': 'Остановлена BIP-сессия',
|
||
'BIP session:': 'BIP-сессия:',
|
||
'not running': 'не запущен',
|
||
'configured': 'настроен',
|
||
'required': 'обязательно',
|
||
'No entries yet.': 'Записей пока нет.',
|
||
'Sort:': 'Сортировка:',
|
||
'Name': 'Имя',
|
||
'Probe all files': 'Проверить все файлы',
|
||
'Stop': 'Остановить',
|
||
'Probing:': 'Проверка:',
|
||
'discovering': 'поиск файлов',
|
||
'files': 'файлов',
|
||
'present': 'есть',
|
||
'absent': 'нет',
|
||
'Probed:': 'Проверено:',
|
||
'Stopped': 'Остановлено',
|
||
'(empty)': '(пусто)',
|
||
'Error': 'Ошибка',
|
||
'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:': 'Извлечённые проактивные команды:',
|
||
'Network simulation': 'Симуляция сети',
|
||
'Replays the write patterns a real phone performs on network-condition changes (EPS attach, service loss, roaming denial, 2G fallback, SMS delivery, cell-broadcast reconfiguration, AUTHENTICATE). Only UPDATE BINARY/RECORD, ENVELOPE and AUTHENTICATE are sent; FPLMN is appended only by the permanent "PLMN not allowed" rejection (TS 31.102 4.2.16, never duplicated), an attach to a listed PLMN clears its entry first (successful manual selection, TS 23.122), and the 5GS location files are never touched.': 'Воспроизводит шаблоны записи реального телефона при смене сетевых условий (подключение EPS, потеря сервиса, запрет роуминга, откат на 2G, приём SMS, перенастройка cell broadcast, AUTHENTICATE). Отправляются только UPDATE BINARY/RECORD, ENVELOPE и AUTHENTICATE; FPLMN дописывается только сценарием постоянного отказа (TS 31.102 4.2.16, без дубликатов), а подключение к сети из списка сначала очищает её запись (успешный ручной выбор, TS 23.122); 5GS location-файлы не затрагиваются.',
|
||
'Cold boot': 'Холодная загрузка',
|
||
'EPS attach': 'Подключение EPS',
|
||
'2G attach': 'Подключение 2G',
|
||
'Service lost': 'Потеря сервиса',
|
||
'Limited service': 'Ограниченный сервис',
|
||
'Roaming denied': 'Роуминг запрещён',
|
||
'Churn': 'Серия переподключений',
|
||
'SMS received': 'Принято SMS',
|
||
'CB reconfig': 'Перенастройка CB',
|
||
'AUTHENTICATE': 'AUTHENTICATE',
|
||
'Parameters': 'Параметры',
|
||
'Operator search (country / brand / MCC-MNC)': 'Поиск оператора (страна / бренд / MCC-MNC)',
|
||
'Sets the PLMN written to LOCI/PSLOCI/EPSLOCI. Used by EPS attach, 2G attach and SMS received (real LAI/RAI/TAI; SMS only when its location rewrite is enabled), and by Cold boot, Service lost, Limited service and Roaming denied (the dummy LOCI/PSLOCI keep this PLMN, the EPSLOCI dummy wipes the TAI PLMN). Churn, CB reconfig and AUTHENTICATE don\'t use it.': 'Задаёт PLMN, записываемый в LOCI/PSLOCI/EPSLOCI. Используется в сценариях «Подключение EPS», «Подключение 2G» и «Принято SMS» (реальные LAI/RAI/TAI; в SMS — только при включённой перезаписи location), а также «Холодная загрузка», «Потеря сервиса», «Ограниченный сервис» и «Роуминг запрещён» (фиктивные LOCI/PSLOCI сохраняют этот PLMN, фиктивный EPSLOCI стирает TAI PLMN). «Серия переподключений», «Перенастройка CB» и AUTHENTICATE его не используют.',
|
||
'MCC': 'MCC',
|
||
'MNC': 'MNC',
|
||
'LAC': 'LAC',
|
||
'Cell ID': 'Идентификатор соты',
|
||
'TAC': 'TAC',
|
||
'RAC': 'RAC',
|
||
'Random roaming operator': 'Случайный роуминг-оператор',
|
||
'Advanced identity (empty = random)': 'Дополнительные идентификаторы (пусто = случайно)',
|
||
'Algorithm': 'Алгоритм',
|
||
'Send Location status event (if subscribed)': 'Отправлять событие Location status (если подписано)',
|
||
'Dummy location files': 'Фиктивные location-файлы',
|
||
'Invalidate EPSNSC': 'Инвалидировать EPSNSC',
|
||
'Keep KASME (KSI 07 only)': 'Сохранять KASME (только KSI 07)',
|
||
'Write/invalidate Kc': 'Записывать/инвалидировать Kc',
|
||
'SMS: rewrite location too': 'SMS: перезаписывать и location',
|
||
'CB: clear lists instead of writing IDs': 'CB: очищать списки вместо записи ID',
|
||
'Churn count': 'Число циклов',
|
||
'Delay ms': 'Задержка, мс',
|
||
'Running': 'Выполняется',
|
||
'Done': 'Готово',
|
||
'Failed': 'Ошибка',
|
||
'steps': 'шагов',
|
||
'No steps': 'Нет шагов',
|
||
'skip': 'пропуск',
|
||
'Operator list not loaded (server --mcc-mnc-list)': 'Список операторов не загружен (--mcc-mnc-list на сервере)',
|
||
'Home network': 'Домашняя сеть',
|
||
'from EF.HPLMNwAcT': 'из EF.HPLMNwAcT',
|
||
'from IMSI': 'из IMSI',
|
||
'Home PLMN not available (no HPLMNwAcT/IMSI)': 'Домашняя сеть недоступна (нет HPLMNwAcT/IMSI)',
|
||
'eSIM LPA': 'eSIM LPA',
|
||
'The equipped card is not an eUICC': 'Подключённая карта — не eUICC',
|
||
'Chip': 'Чип',
|
||
'Profiles': 'Профили',
|
||
'Notifications': 'Уведомления',
|
||
'Not available': 'Недоступно',
|
||
'No profiles': 'Нет профилей',
|
||
'No notifications': 'Нет уведомлений',
|
||
'EID': 'EID',
|
||
'SVN': 'SVN',
|
||
'Profile version': 'Версия профиля',
|
||
'Firmware version': 'Версия прошивки',
|
||
'Card resource': 'Ресурсы карты',
|
||
'Installed applications': 'Установленных приложений',
|
||
'Free non-volatile memory': 'Свободная энергонезависимая память',
|
||
'Free volatile memory': 'Свободная энергозависимая память',
|
||
'UICC capability': 'Возможности UICC',
|
||
'TS 102 241 version': 'Версия TS 102 241',
|
||
'GlobalPlatform version': 'Версия GlobalPlatform',
|
||
'RSP capability': 'Возможности RSP',
|
||
'Category': 'Категория',
|
||
'Forbidden PPRs': 'Запрещённые PPR',
|
||
'PP version': 'Версия PP',
|
||
'SS accreditation number': 'Номер аккредитации SS',
|
||
'Certification data': 'Данные сертификации',
|
||
'Platform label': 'Метка платформы',
|
||
'Discovery base URL': 'Базовый URL Discovery',
|
||
'TRE properties': 'Свойства TRE',
|
||
'TRE product reference': 'Ссылка на продукт TRE',
|
||
'Additional profile package versions': 'Доп. версии пакетов профилей',
|
||
'Raw TLVs': 'Сырые TLV',
|
||
'CI PKI (verification)': 'CI PKI (проверка)',
|
||
'CI PKI (signing)': 'CI PKI (подпись)',
|
||
'Subject key identifier': 'Идентификатор ключа субъекта',
|
||
'Rules authorisation table': 'Таблица авторизации правил (RAT)',
|
||
'Rule': 'Правило',
|
||
'PPR IDs': 'Идентификаторы PPR',
|
||
'Allowed operators': 'Разрешённые операторы',
|
||
'PPR flags': 'Флаги PPR',
|
||
'PLMN': 'PLMN',
|
||
'Addresses': 'Адреса',
|
||
'Default SM-DP+ address': 'Адрес SM-DP+ по умолчанию',
|
||
'Root DS address': 'Адрес корневого DS',
|
||
'IoT specific info': 'IoT-специфичная информация',
|
||
'Enabled': 'Включён',
|
||
'Disabled': 'Отключён',
|
||
'Enable': 'Включить',
|
||
'Disable': 'Отключить',
|
||
'ISD-P AID': 'ISD-P AID',
|
||
'Provider': 'Оператор',
|
||
'Profile name': 'Имя профиля',
|
||
'Class': 'Класс',
|
||
'Owner': 'Владелец',
|
||
'Icon': 'Значок',
|
||
'EID': 'EID',
|
||
'SVN': 'SVN',
|
||
'Profile version': 'Версия профиля',
|
||
'Firmware version': 'Версия прошивки',
|
||
'Card resource': 'Ресурс карты',
|
||
'UICC capability': 'Возможности UICC',
|
||
'TS 102 241 version': 'Версия TS 102 241',
|
||
'GlobalPlatform version': 'Версия GlobalPlatform',
|
||
'RSP capability': 'Возможности RSP',
|
||
'Category': 'Категория',
|
||
'PP version': 'Версия PP',
|
||
'SS accreditation number': 'Номер аккредитации SS',
|
||
'Default SM-DP+ address': 'Адрес SM-DP+ по умолчанию',
|
||
'Root DS address': 'Адрес корневого DS',
|
||
'CI PKI (verification)': 'CI PKI (проверка)',
|
||
'CI PKI (signing)': 'CI PKI (подпись)',
|
||
'IoT specific info': 'IoT-информация',
|
||
'install': 'установка',
|
||
'enable': 'включение',
|
||
'disable': 'отключение',
|
||
'delete': 'удаление',
|
||
'profile not found': 'профиль не найден',
|
||
'profile is not disabled': 'профиль не отключён',
|
||
'profile is not enabled': 'профиль не включён',
|
||
'disallowed by policy': 'запрещено политикой',
|
||
'wrong profile re-enabling': 'неверное повторное включение профиля',
|
||
'card is busy with a CAT session': 'карта занята CAT-сессией',
|
||
'undefined error': 'неопределённая ошибка',
|
||
'the card will be re-initialized. Continue?': 'карта будет переинициализирована. Продолжить?',
|
||
'Switching profile — card re-initializing...': 'Переключение профиля — карта переинициализируется...',
|
||
'Profile switched': 'Профиль переключён',
|
||
'card re-initialized': 'карта переинициализирована',
|
||
'REFRESH received': 'получен REFRESH',
|
||
'not confirmed': 'не подтверждено',
|
||
'Profile switch failed': 'Не удалось переключить профиль',
|
||
'No commands logged yet.': 'Команды пока не регистрировались.',
|
||
'Send event notification to the card?': 'Отправить уведомление о событии на карту?',
|
||
'This event type is not yet supported.': 'Этот тип события пока не поддерживается.',
|
||
'Event sent.': 'Событие отправлено.',
|
||
'Event data sent.': 'Данные события отправлены.',
|
||
'Sending...': 'Отправка...',
|
||
'Yes': 'Да',
|
||
'No': 'Нет',
|
||
'Help': 'Справка',
|
||
'Menu': 'Меню',
|
||
'one character': 'один символ',
|
||
'characters': 'символов',
|
||
'up to': 'до',
|
||
'At least': 'Не менее',
|
||
'At most': 'Не более',
|
||
'digits 0-9 * # +': 'цифры 0-9 * # +',
|
||
'hidden entry': 'скрытый ввод',
|
||
'Enter exactly one character': 'Введите ровно один символ',
|
||
'Digits only (0-9, *, #, +)': 'Только цифры (0-9, *, #, +)',
|
||
'Invalid': 'Некорректное значение',
|
||
'Service Status': 'Статус сервиса',
|
||
'Normal service': 'Обычный сервис',
|
||
'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': 'Картридер вставлен',
|
||
'Channel': 'Канал',
|
||
'Link state': 'Состояние линии',
|
||
'Channel info': 'Информация канала',
|
||
'Link not established / TCP closed': 'Линия не установлена / TCP закрыт',
|
||
'TCP in LISTEN state (UICC server mode)': 'TCP в состоянии LISTEN (режим сервера UICC)',
|
||
'Link established / TCP established': 'Линия установлена / TCP установлен',
|
||
'No further info can be given': 'Дополнительная информация отсутствует',
|
||
'Link dropped (network failure or user cancellation)': 'Линия разорвана (сбой сети или отмена пользователем)',
|
||
'PROVIDE LOCAL INFORMATION response data': 'Данные для PROVIDE LOCAL INFORMATION',
|
||
'The date/time qualifier is answered from the host clock while its entry is empty.': 'Квалификатор «дата и время» при пустой записи отвечается с часов хоста.',
|
||
'Send STATUS': 'Отправить STATUS',
|
||
'help': 'справка',
|
||
'STATUS and Polling': 'STATUS и опрос',
|
||
'Test script': 'Тест-скрипт',
|
||
'New test script': 'Новый тест-скрипт',
|
||
'STK menu browsing': 'Обзор меню STK',
|
||
'Applet via SCP80': 'Апплет через SCP80',
|
||
'Script must be an object': 'Скрипт должен быть объектом',
|
||
'Script steps must be a list': 'Шаги скрипта должны быть списком',
|
||
'Script has no steps': 'В скрипте нет шагов',
|
||
'step must be an object': 'шаг должен быть объектом',
|
||
'unknown action kind': 'неизвестный тип действия',
|
||
'APDU is empty': 'APDU пуст',
|
||
'SCP80 needs an APDU or a secured packet': 'SCP80 требует APDU или готовый защищённый пакет',
|
||
'item id must be 1..255': 'item id должен быть 1..255',
|
||
'file path is empty': 'путь к файлу пуст',
|
||
'command is required': 'команда обязательна',
|
||
'step must be an action or an expectation': 'шаг должен быть действием или ожиданием',
|
||
'No steps yet - add an action or an expectation.': 'Шагов пока нет — добавьте действие или ожидание.',
|
||
'No content checks.': 'Нет проверок содержимого.',
|
||
'Delete this test script?': 'Удалить этот тест-скрипт?',
|
||
'Import failed:': 'Импорт не удался:',
|
||
'Imported:': 'Импортировано:',
|
||
'Import': 'Импорт',
|
||
'Action': 'Действие',
|
||
'Expectation': 'Ожидание',
|
||
'Action kind': 'Тип действия',
|
||
'Template': 'Шаблон',
|
||
'Run report': 'Отчёт о запуске',
|
||
'Clear report': 'Очистить отчёт',
|
||
'Add action': 'Добавить действие',
|
||
'Add expectation': 'Добавить ожидание',
|
||
'Event type': 'Тип события',
|
||
'Event data (hex, optional)': 'Данные события (hex, опционально)',
|
||
'Item id (1-255)': 'Item id (1–255)',
|
||
'File path': 'Путь к файлу',
|
||
'Auto (by file type)': 'Авто (по типу файла)',
|
||
'Transparent (binary)': 'Прозрачный (binary)',
|
||
'Record number (record mode)': 'Номер записи (режим record)',
|
||
'APDU (hex)': 'APDU (hex)',
|
||
'Source': 'Источник',
|
||
'C-APDU (built with the preset)': 'C-APDU (собирается с пресетом)',
|
||
'Pre-built secured packet': 'Готовый защищённый пакет',
|
||
'Secured packet (hex)': 'Защищённый пакет (hex)',
|
||
'TAR override (hex, optional)': 'Переопределение TAR (hex, опционально)',
|
||
'SPI1 override (hex, optional)': 'Переопределение SPI1 (hex, опционально)',
|
||
'SPI2 override (hex, optional)': 'Переопределение SPI2 (hex, опционально)',
|
||
'Attempts (1 = single STATUS)': 'Попытки (1 = один STATUS)',
|
||
'Interval (ms, when polling)': 'Интервал (мс, при опросе)',
|
||
'attempts must be at least 1': 'число попыток должно быть ≥ 1',
|
||
'Expected SW': 'Ожидаемое SW',
|
||
'default: 9000 (91?? when polling)': 'по умолчанию: 9000 (91?? при опросе)',
|
||
'Expected data (hex, optional)': 'Ожидаемые данные (hex, опционально)',
|
||
'PoR check': 'Проверка PoR',
|
||
'PoR ok': 'PoR: успешно',
|
||
'no PoR': 'PoR: нет',
|
||
'PoR error': 'Ошибка PoR',
|
||
'PoR unknown': 'PoR: неизвестно',
|
||
'remote SW': 'SW команды',
|
||
'transport SW': 'транспортный SW',
|
||
'Any': 'Любой',
|
||
'No PoR expected': 'PoR не ожидается',
|
||
'On mismatch': 'При несовпадении',
|
||
'Error (stop the script)': 'Ошибка (остановить скрипт)',
|
||
'Warning (continue)': 'Предупреждение (продолжить)',
|
||
'Message / label (optional)': 'Сообщение / метка (опционально)',
|
||
'Qualifier (optional)': 'Квалификатор (опционально)',
|
||
'Content checks': 'Проверки содержимого',
|
||
'Add check': 'Добавить проверку',
|
||
'TERMINAL RESPONSE result': 'Результат TERMINAL RESPONSE',
|
||
'Item identifier (SELECT ITEM / SET UP MENU)': 'Идентификатор пункта (SELECT ITEM / SET UP MENU)',
|
||
'Text (GET INKEY / GET INPUT)': 'Текст (GET INKEY / GET INPUT)',
|
||
'Mask ("?" = wildcard)': 'Маска («?» — подстановка)',
|
||
'Contains': 'Содержит',
|
||
'Item': 'Пункт',
|
||
'Raw': 'Raw',
|
||
'Test script running:': 'Выполняется тест-скрипт:',
|
||
'Test script running': 'Выполняется тест-скрипт',
|
||
'Preset': 'Пресет',
|
||
'Counter': 'Счётчик',
|
||
'No run yet.': 'Запусков пока нет.',
|
||
'running': 'выполняется',
|
||
'Received': 'Получено',
|
||
'No card preset matches the equipped card - SCP80 steps need one': 'Ни один пресет карты не соответствует подключённой карте — для шагов SCP80 он нужен',
|
||
'The matched card preset has incomplete SCP80 data (KIc/KID, SPI1/SPI2, counter, keys)': 'У подобранного пресета неполные данные SCP80 (KIc/KID, SPI1/SPI2, счётчик, ключи)',
|
||
'SCP80 counter saved to the preset:': 'Счётчик SCP80 сохранён в пресет:',
|
||
'STK menu': 'Меню STK',
|
||
'No menu set by the card': 'Меню не задано картой',
|
||
'Polling': 'Опрос',
|
||
'card disabled polling (POLLING OFF)': 'карта отключила опрос (POLLING OFF)',
|
||
'Time unit': 'Единица времени',
|
||
'Time interval': 'Интервал времени',
|
||
'Poll interval negotiation:': 'Согласование интервала опроса:',
|
||
'accepted': 'принято',
|
||
'rejected': 'отклонено',
|
||
'modified': 'изменено',
|
||
'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': 'Не прочитано',
|
||
'Decoded': 'Декодировано',
|
||
'Record': 'Запись',
|
||
'empty': 'пусто',
|
||
'No decoder for this file': 'Нет декодера для этого файла',
|
||
'showing raw data': 'показаны сырые данные',
|
||
'No decodable fields': 'Нет декодируемых полей',
|
||
'pySim JSON (server)': 'pySim JSON (сервер)',
|
||
'Raw comparison': 'Сравнение сырых данных',
|
||
'Decoded comparison': 'Декодированное сравнение',
|
||
'Decoded values match; raw bytes differ': 'Декодированные значения совпадают; отличаются сырые байты',
|
||
'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': 'совпадающие записи',
|
||
'of': 'из',
|
||
'None': 'Нет',
|
||
'Exact': 'Точное',
|
||
'Mask': 'Маска',
|
||
'Checking': 'Проверка',
|
||
'passed': 'пройдено',
|
||
'failed': 'не пройдено',
|
||
'errors': 'ошибок',
|
||
'Only mismatches': 'Только расхождения',
|
||
'No mismatches': 'Расхождений нет',
|
||
'expected': 'ожидалось',
|
||
'actual': 'фактически',
|
||
'HTTP OTA': 'HTTP OTA',
|
||
'HTTP OTA Trigger (Push SMS)': 'HTTP OTA Триггер (Push SMS)',
|
||
'Push commands': 'Пуш/триггер',
|
||
'Administrative session trigger (Push SMS)': 'Триггер административной сессии (Push SMS)',
|
||
'Administrative session store data (SD admin params)': 'Запись данных административной сессии (параметры SD)',
|
||
'Not the administration session — a C-APDU for an app that supports BIP/CAT_TP': 'C-APDU для приложения с поддержкой BIP/CAT_TP (не admin session!)',
|
||
'Request': 'Запрос',
|
||
'BIP channel opening (OPEN CHANNEL)': 'Открытие BIP-канала (OPEN CHANNEL)',
|
||
'Channel / link trigger': 'Триггер канала/линка',
|
||
'Mode': 'Режим',
|
||
'Fields': 'Поля',
|
||
'Hex': 'Hex',
|
||
'Bearer description (hex, default 350103)': 'Описание bearer (hex, по умолчанию 350103)',
|
||
'Buffer size (hex, 2 bytes)': 'Размер буфера (hex, 2 байта)',
|
||
'Transport protocol': 'Транспортный протокол',
|
||
'UDP, UICC client, remote': 'UDP, клиент UICC, удалённое соединение',
|
||
'TCP, UICC client, remote': 'TCP, клиент UICC, удалённое соединение',
|
||
'Alpha (transparent — no user confirmation)': 'Alpha (прозрачный режим — без подтверждения пользователя)',
|
||
'Alpha identifier (hex, optional)': 'Alpha-идентификатор (hex, необязательно)',
|
||
'Also request the CAT_TP link establishment (02) in the same message': 'Также запросить установление соединения CAT_TP (02) в том же сообщении',
|
||
'Channel / link establishment (TS 102 226 §9)': 'Установление канала/линка (TS 102 226 §9)',
|
||
'Also send the identification packet (04) in the same message': 'Также отправить пакет идентификации (04) в том же сообщении',
|
||
'The identification packet follows the TCP request in the same message (ICCID when empty).': 'Пакет идентификации отправляется следом за TCP-запросом в том же сообщении (при пустом значении — ICCID).',
|
||
'CAT_TP link establishment': 'Установление соединения CAT_TP',
|
||
'TCP connection': 'TCP-соединение',
|
||
'OPEN CHANNEL COMPREHENSION-TLVs (hex)': 'COMPREHENSION-TLV OPEN CHANNEL (hex)',
|
||
'Destination port (hex)': 'Порт назначения (hex)',
|
||
'Max SDU (hex, optional)': 'Макс. SDU (hex, необязательно)',
|
||
'Identification data (hex, optional)': 'Данные идентификации (hex, необязательно)',
|
||
'Identification data (hex, optional; ICCID when empty)': 'Данные идентификации (hex, необязательно; при пустом — ICCID)',
|
||
'Extra TLVs (hex, optional)': 'Дополнительные TLV (hex, необязательно)',
|
||
'Bearer description (hex, empty = 350103)': 'Описание bearer (hex, пусто = 350103)',
|
||
'Destination address value (hex: 21 IPv4 / 57 IPv6 / F0 FQDN)': 'Значение адреса назначения (hex: 21 IPv4 / 57 IPv6 / F0 FQDN)',
|
||
'NAA / APN (text)': 'NAA / APN (текст)',
|
||
'C-APDU (TS 102 226 §9)': 'C-APDU (TS 102 226 §9)',
|
||
'HTTP OTA Store (SD admin params)': 'HTTP OTA Store (параметры SD)',
|
||
'SD params tag': 'Tag параметров SD',
|
||
"Connection parameters ('84')": "Параметры соединения ('84')",
|
||
'+ TLV': '+ TLV',
|
||
'+ Device Identities': '+ Device Identities',
|
||
'+ Alpha (empty)': '+ Alpha (пустой)',
|
||
'+ Command details': '+ Command details',
|
||
'+ Bearer (default)': '+ Bearer (по умолчанию)',
|
||
"Security parameters ('85')": "Параметры безопасности ('85')",
|
||
'PSK Identity (text)': 'PSK Identity (текст)',
|
||
"Retry policy ('86')": "Политика повтора ('86')",
|
||
'Retry counter (2 bytes, hex)': 'Счётчик повторов (2 байта, hex)',
|
||
'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')",
|
||
'Listener': 'Слушатель',
|
||
'Scripts': 'Скрипты',
|
||
'Empty': 'Пустой',
|
||
'Explore ISD': 'Обзор ISD',
|
||
'Delete AID': 'Удалить AID',
|
||
'New': 'Новый',
|
||
'New script from': 'Новый скрипт из',
|
||
'Generate': 'Сгенерировать',
|
||
'Kind': 'Тип',
|
||
'Created': 'Создан',
|
||
'matched': 'совпал',
|
||
'unknown identity': 'неизвестный идентификатор',
|
||
'executed': 'выполнено',
|
||
'pending': 'ожидает',
|
||
'Name is required': 'Укажите имя',
|
||
'From card': 'С карты',
|
||
'Priority (0 = RFU)': 'Приоритет (0 = RFU)',
|
||
'Applet-specific parameters (C9, hex, optional)': 'Параметры апплета (C9, hex, опционально)',
|
||
'INSTALL [for install] (loaded package)': 'INSTALL [for install] (загруженный пакет)',
|
||
'INSTALL [for make selectable]': 'INSTALL [for make selectable]',
|
||
'Load File AID / Package AID (hex)': 'Load File AID / AID пакета (hex)',
|
||
'Executable Module AID / Applet Class AID (hex)': 'AID модуля / класса апплета (hex)',
|
||
'Instance AID (hex, empty = module AID)': 'AID экземпляра (hex, пусто = AID модуля)',
|
||
'Install': 'Установить',
|
||
'Instance AID required': 'Требуется AID экземпляра',
|
||
'Load File AID and Module AID required': 'Требуются Load File AID и Module AID',
|
||
'Sending to card...': 'Отправка на карту...',
|
||
'C7 / C8 memory quotas (bytes, optional)': 'Квоты памяти C7 / C8 (байты, опционально)',
|
||
'STK parameters not generated: check the TAR (multiple of 3 bytes), menu IDs (<= 7F) and the ADF AID (5-16 bytes)': 'STK-параметры не сформированы: проверьте TAR (кратность 3 байтам), ID меню (<= 7F) и ADF AID (5-16 байт)',
|
||
'SCP80 (GSM 03.48, ETSI TS 102 225)': 'SCP80 (GSM 03.48, ETSI TS 102 225)',
|
||
'SCP81 (HTTP OTA)': 'SCP81 (HTTP OTA)',
|
||
'Start': 'Запустить',
|
||
'Clear': 'Очистить',
|
||
'Loading...': 'Загрузка...',
|
||
'Close': 'Закрыть',
|
||
'Installing package': 'Установка пакета',
|
||
'Do not close or reload this window while the operation is running.': 'Не закрывайте и не перезагружайте окно, пока операция выполняется.',
|
||
'Working...': 'Выполняется...',
|
||
'APDU hex': 'APDU (hex)',
|
||
'CAP file (max 48 kB)': 'CAP-файл (не более 48 кБ)',
|
||
'Install from .cap': 'Установить из .cap',
|
||
'RAM': 'RAM',
|
||
'SCP81': 'SCP81',
|
||
'Secured packet': 'Secured packet',
|
||
'Card reader server is not connected': 'Сервер картридера не подключён',
|
||
'KIc and KID are required': 'Укажите KIc и KID',
|
||
'KIc key and KID key are required': 'Укажите ключ KIc и ключ KID',
|
||
'RAM command format:': 'Формат команд RAM:',
|
||
'Fetch expanded registry details (load-file / SD associations)': 'Запрашивать расширенные данные реестра (связи load-файла и SD)',
|
||
'Runs one extra expanded query per listing (P2=02; on by default). The compact listing carries AID, life cycle and privileges only - it has no version; the expanded pass adds the Executable Load File version, the Load File AID, the Security Domain AID, the module lists and the applet STK menu entries (SCP Registry Data). Cards differ in what they accept, so three query forms are tried (tag list with EA, tag list without EA, no tag list - the GPPro form); the explorer reports which form worked and where the run fell back to the compact listing (package versions are then unavailable). It costs a few extra secured packets per Explore; untick to skip it.': 'Запускает дополнительный расширенный запрос для каждого списка (P2=02; включено по умолчанию). Компактный список содержит только AID, состояние и привилегии — версии в нём нет; расширенный проход добавляет версию исполняемого load-файла, AID load-файла, AID Security Domain, списки модулей и пункты меню STK апплета (SCP Registry Data). Карты различаются в том, что принимают, поэтому пробуются три формы запроса (список тегов с EA, список тегов без EA, без списка тегов — форма GPPro); обзор сообщает, какая форма сработала и где выполнен откат к компактному списку (версии пакетов тогда недоступны). Это стоит несколько дополнительных защищённых пакетов на каждый обзор; снимите флажок, чтобы пропустить.',
|
||
'Expanded registry:': 'Расширенный реестр:',
|
||
'tag list + EA': 'список тегов + EA',
|
||
'tag list': 'список тегов',
|
||
'no tag list': 'без списка тегов',
|
||
'lean tag list': 'сокращённый список тегов',
|
||
'refused': 'отказ',
|
||
'compact listing used': 'использован компактный список',
|
||
'no expanded data': 'нет расширенных данных',
|
||
'Expanded registry refused by the card': 'Карта отклонила расширенный реестр',
|
||
'compact listing only (package versions unavailable)': 'только компактный список (версии пакетов недоступны)',
|
||
'Package versions come from the expanded registry query \u2014 this card did not return them.': 'Версии пакетов приходят из расширенного запроса реестра \u2014 эта карта их не вернула.',
|
||
'Package versions are only in the tag-list forms of the expanded registry query \u2014 this card accepted only the no-tag-list form.': 'Версии пакетов есть только в формах расширенного запроса со списком тегов \u2014 эта карта приняла только форму без списка тегов.',
|
||
'from the app instance': 'от экземпляра апплета',
|
||
'request error': 'ошибка запроса',
|
||
'Low counter': 'Низкий счётчик',
|
||
'the card rejected the packet': 'карта отклонила пакет',
|
||
'Sync counter': 'Синхронизировать счётчик',
|
||
'Syncing counter...': 'Синхронизация счётчика...',
|
||
'Counter synced': 'Счётчик синхронизирован',
|
||
'Counter sync failed': 'Не удалось синхронизировать счётчик',
|
||
'Retry the operation.': 'Повторите операцию.',
|
||
'No card preset selected': 'Пресет карты не выбран',
|
||
'Format': 'Формат',
|
||
'Auto (detect)': 'Авто (определение)',
|
||
'Compact': 'Компактный',
|
||
'Expanded (AA, definite length)': 'Expanded (AA, определённая длина)',
|
||
'Expanded (AE 80, indefinite length)': 'Expanded (AE 80, неопределённая длина)',
|
||
'Go to preset': 'К пресету',
|
||
'first page only': 'только первая страница',
|
||
'the operation was stopped': 'операция остановлена',
|
||
'STK menu entry:': 'Пункт меню STK:',
|
||
'position': 'позиция',
|
||
'Other registry data:': 'Прочие данные реестра:',
|
||
'NV used:': 'Занято NV:',
|
||
'NV freed:': 'Освобождено NV:',
|
||
'free': 'свободно',
|
||
'compact = the TS 102 226 5.2.1 command string (one C-APDU per secured packet, e.g. GET STATUS + GET RESPONSE); expanded = the AA/22 Command Scripting template (5.2.1.0). Detected per operation with a read-only GET STATUS probe; a card that rejects the deprecated compact P2.b2=0 form answers the expanded one.': 'compact = командная строка по TS 102 226 5.2.1 (один C-APDU на защищённый пакет, например GET STATUS + GET RESPONSE); expanded = шаблон Command Scripting AA/22 (5.2.1.0). Определяется для каждой операции пробным чтением GET STATUS; карта, отклоняющая устаревшую компактную форму P2.b2=0, отвечает в расширенной.',
|
||
'Card resources (GET DATA FF21)': 'Ресурсы карты (GET DATA FF21)',
|
||
'Extended Card Resources (GP tag FF21, ETSI TS 102 226 8.2.1.7.2): installed applications, free non-volatile and free volatile memory': 'Расширенные ресурсы карты (тег GP FF21, ETSI TS 102 226 8.2.1.7.2): число установленных приложений, свободная энергонезависимая и свободная энергозависимая память',
|
||
'No secured packet to send. Generate a secure packet first.': 'Нет защищённого пакета для отправки. Сначала соберите пакет.',
|
||
'No secured packet to verify. Generate a secure packet first.': 'Нет защищённого пакета для проверки. Сначала соберите пакет.',
|
||
'Select': 'Выборка',
|
||
'event notification to the card?': 'уведомление о событии на карту?',
|
||
'SCP81 HTTP OTA: the listener picks the key by the PSK identity the card sends in the TLS handshake. Cards without a PSK are ignored by the listener.': 'SCP81 HTTP OTA: слушатель выбирает ключ по PSK identity, которую карта присылает в TLS-рукопожатии. Карты без PSK слушателем игнорируются.',
|
||
'Card with this ICCID already exists': 'Карта с таким ICCID уже есть в списке',
|
||
'Card equipped but its ICCID is not readable': 'Карта подключена, но её ICCID не читается',
|
||
'OTA keysets — each with its own number and counter': 'OTA keysets — у каждого свой номер и счётчик',
|
||
'Add keyset': 'Добавить keyset',
|
||
'Remove keyset': 'Удалить keyset',
|
||
'Keyset': 'Keyset',
|
||
'Keyset number (b8..b5 of KIc/KID)': 'Номер keyset (b8..b5 KIc/KID)',
|
||
'Keysets (number: KIc/KID — counter)': 'Keysets (номер: KIc/KID — счётчик)',
|
||
'must be one hex byte': 'должен быть одним hex-байтом',
|
||
'KIc and KID must carry the same keyset number (TS 102 225 A.2)': 'KIc и KID должны содержать один и тот же номер keyset (TS 102 225 A.2)',
|
||
'is not defined in the preset': 'не определён в пресете',
|
||
'not defined in the preset': 'не определён в пресете',
|
||
'— no keyset —': '— нет keyset —',
|
||
'— select a card preset —': '— выберите пресет карты —',
|
||
'Select a card preset and its keyset': 'Выберите пресет карты и его keyset',
|
||
'counter': 'счётчик',
|
||
'Define at least one keyset (KIc/KID and their keys)': 'Определите хотя бы один keyset (KIc/KID и его ключи)',
|
||
'Each keyset needs KIc, KID and both keys': 'Каждому набору нужны KIc, KID и оба ключа',
|
||
'Keyset 00 means "no security" and is not a preset keyset': 'keyset 00 означает «без защиты» и не является keyset пресета',
|
||
'Duplicate keyset number in the keysets': 'Повторяющийся номер keyset',
|
||
'Select a card preset - its keysets define the keyset numbers': 'Выберите пресет карты — его keysets задают номера keyset',
|
||
'SCP80 counters saved to the preset.': 'Счётчики SCP80 сохранены в пресете.',
|
||
'counter not tracked (SPI1 has no counter check)': 'счётчик не отслеживается (в SPI1 нет проверки счётчика)',
|
||
'TAR probe': 'TAR-сканер',
|
||
'Sends one secured packet per selected TAR (TS 101 220 Annex D, Table D.1) carrying a harmless command, and classifies the card\'s answer - which OTA applications it has registered. Every packet the card accepts consumes a counter, which is saved to the preset.': 'Отправляет по одному защищённому пакету на каждый выбранный TAR (TS 101 220 Annex D, Table D.1) с безобидной командой и классифицирует ответ карты — какие OTA-приложения на ней зарегистрированы. Каждый принятый картой пакет расходует счётчик, который сохраняется в пресете.',
|
||
'TARs to probe — TS 101 220 V18.3.0 Annex D, Table D.1': 'TAR для пробы — TS 101 220 V18.3.0 Annex D, Table D.1',
|
||
'Select all': 'Выбрать все',
|
||
'Select none': 'Снять все',
|
||
'Add TAR': 'Добавить TAR',
|
||
'Remove TAR': 'Удалить TAR',
|
||
'A TAR is three hex bytes, e.g. B00001': 'TAR — это три hex-байта, например B00001',
|
||
'Select at least one TAR': 'Выберите хотя бы один TAR',
|
||
'Probe TARs (registered OTA applications)': 'Проба TAR (зарегистрированные OTA-приложения)',
|
||
'Probe APDU (hex)': 'APDU пробы (hex)',
|
||
'TARs to probe (hex, one per line; empty = all standard TARs of TS 101 220 Annex D)': 'TAR для пробы (hex, по одному в строке; пусто = все стандартные TAR из TS 101 220 Annex D)',
|
||
'One secured packet per TAR: each packet the card accepts consumes a counter, which is saved to the preset.': 'Один защищённый пакет на каждый TAR: каждый принятый картой пакет расходует счётчик, который сохраняется в пресете.',
|
||
'Specify the probe APDU': 'Укажите APDU пробы',
|
||
'Registered applications:': 'Зарегистрированные приложения:',
|
||
'Application': 'Приложение',
|
||
'ENVELOPE': 'ENVELOPE',
|
||
'App SW': 'SW приложения',
|
||
'Data': 'Данные',
|
||
'registered': 'зарегистрировано',
|
||
'accepted, no answer': 'принято, нет ответа',
|
||
'accepted, no PoR': 'принято, нет PoR',
|
||
'No card presets defined — the SCP80/RAM views have no keys for the equipped card. Add one in the Cards tab.': 'Предустановки карт не заданы — в представлениях SCP80/RAM нет ключей для подключённой карты. Добавьте её на вкладке «Карты».',
|
||
'The equipped card': 'Подключённая карта',
|
||
'does not match any card preset — the selected preset may belong to another card. Add it in the Cards tab (From card).': 'не совпадает ни с одним пресетом — выбранный пресет может принадлежать другой карте. Добавьте его на вкладке «Карты» (кнопка «С карты»).',
|
||
"The selected preset is not the equipped card's": 'Выбранный пресет не принадлежит подключённой карте',
|
||
'check that its keys and counters belong to this card.': 'проверьте, что её ключи и счётчики относятся к этой карте.',
|
||
'Keyset number (optional)': 'Номер keyset (опционально)',
|
||
"empty = the preset's first keyset": 'пусто = первый keyset пресета',
|
||
'Keysets (number: KIc/KID — counter)': 'Keysets (номер: KIc/KID — счётчик)',
|
||
'KIc and KID must carry the same keyset number (their high nibble) in the range 01-0F — TS 102 225 A.2; each keyset has its own counter on the card (Annex A.1).': 'KIc и KID должны содержать один и тот же номер keyset (старший ниббл) в диапазоне 01-0F — TS 102 225 A.2; у каждого keyset свой счётчик на карте (Annex A.1).',
|
||
'Card presets are stored on the server. JSON export/import buttons are below the card list; a preset carries several keysets, each with its own number and counter.': 'Пресеты карт хранятся на сервере. Кнопки экспорта/импорта JSON — под списком карт; пресет содержит несколько keysets, у каждого свой номер и счётчик.',
|
||
'Save error': 'Ошибка сохранения',
|
||
'Delete error': 'Ошибка удаления',
|
||
'Counter not saved:': 'Счётчик не сохранён:',
|
||
'Saved.': 'Сохранено.',
|
||
'Invalid APDU': 'Некорректный APDU',
|
||
'Add at least one APDU': 'Добавьте хотя бы один APDU',
|
||
'Add at least one AID': 'Добавьте хотя бы один AID',
|
||
'Delete script': 'Удалить скрипт',
|
||
'No scripts defined.': 'Скрипты не заданы.',
|
||
'Select a script': 'Выберите скрипт',
|
||
'Script restarted': 'Скрипт перезапущен',
|
||
'Restart script': 'Перезапустить скрипт',
|
||
'waiting for card': 'ожидание ответа карты',
|
||
'listing pages': 'страницы списка',
|
||
'completed': 'завершено',
|
||
'pages': 'стр.',
|
||
'PSK identity and PSK key must be set together': 'PSK identity и PSK-ключ нужно задавать вместе',
|
||
'PSK key must be 32 hex characters': 'PSK-ключ должен содержать 32 hex-символа',
|
||
'No card preset has a PSK identity and key': 'Ни у одной карты не заданы PSK identity и ключ',
|
||
'Scripts are stored locally in your browser and sent to the server when a listener starts.': 'Скрипты хранятся локально в браузере и передаются серверу при запуске слушателя.',
|
||
'PSK keys come from the card presets (Cards tab): the listener picks the key by the identity the card sends in the TLS handshake.': 'PSK-ключи берутся из пресетов карт (вкладка «Карты»): слушатель выбирает ключ по идентификатору, который карта присылает в TLS-рукопожатии.',
|
||
'The .cap is only used to generate the APDUs: the script stores the APDU list, not the file.': '.cap используется только для генерации APDU: в скрипте хранится список APDU, а не файл.',
|
||
'APDUs (one hex C-APDU per line)': 'APDU (по одному hex C-APDU в строке)',
|
||
'AIDs to delete (one per line)': 'AID для удаления (по одному в строке)',
|
||
'Delete parameters (P2)': 'Параметры удаления (P2)',
|
||
'Object only': 'Только объект',
|
||
'Object and related objects': 'Объект и связанные объекты',
|
||
'SD AID (empty = ISD)': 'AID SD (пусто = ISD)',
|
||
'CAP file': 'CAP-файл',
|
||
'LOAD block size (bytes, empty = 240)': 'Размер блока LOAD (байт, пусто = 240)',
|
||
'default': 'по умолчанию',
|
||
'concatenated': 'конкатенация',
|
||
'too large for the card concatenation buffer': 'слишком велик для буфера конкатенации карты',
|
||
'Splits the load file into LOAD APDUs (1-240 bytes of payload). A secured packet larger than one SMS is sent as a concatenated SMS-PP download (up to 5 SMs).': 'Разбивает load-файл на LOAD APDU (полезная нагрузка 1–240 байт). Защищённый пакет больше одного SMS отправляется как конкатенированная SMS-PP загрузка (до 5 SMS).',
|
||
'LOAD block size must be 1..240': 'Размер блока LOAD должен быть в диапазоне 1..240',
|
||
'LOAD blocks': 'Блоки LOAD',
|
||
'Redirect to external server': 'Перенаправление на внешний сервер',
|
||
'Pass-through (card destination)': 'Проброс (адрес карты)',
|
||
'Redirect: every BIP channel the card opens is connected to this Host:Port (the external HTTP OTA platform); TLS is terminated there, and the address the card requests is only logged.': 'Перенаправление: каждый открываемый картой BIP-канал подключается к этому Host:Port (внешняя платформа HTTP OTA); TLS завершается там, а запрошенный картой адрес только журналируется.',
|
||
'Pass-through: the terminal connects each BIP channel to the destination the card requests in OPEN CHANNEL (Other address + transport port, TCP client only); Host and Port above are not used, TLS is terminated by that platform, and the server\'s network is used (lab only).': 'Проброс: терминал подключает каждый BIP-канал к адресу, который карта запрашивает в OPEN CHANNEL (Other address + порт Transport level, только TCP-клиент); поля Host и Port выше не используются, TLS завершается на той платформе, и используется сеть сервера (только для лаборатории).',
|
||
'Redirect requires the target host and port': 'Для перенаправления нужны host и port внешнего сервера',
|
||
'Options (applied at Start)': 'Настройки (применяются при запуске)',
|
||
'Reset to defaults': 'Сбросить к умолчаниям',
|
||
'HTTP framing': 'HTTP-фрейминг',
|
||
'Chunked body (Transfer-Encoding: chunked)': 'Chunked body (Transfer-Encoding: chunked)',
|
||
'Chunk size (bytes, 0 = one TLS record)': 'Размер чанка (байт, 0 = одна TLS-запись)',
|
||
'Connection header': 'Заголовок Connection',
|
||
'omit (implicit keep-alive)': 'не отправлять (неявный keep-alive)',
|
||
"Compact headers (omit the optional space after ':')": 'Компактные заголовки (без необязательного пробела после «:»)',
|
||
'Send X-Admin-Next-URI (required to continue the session)': 'Отправлять X-Admin-Next-URI (нужен для продолжения сессии)',
|
||
'Unchecked = one-shot: per GP Am. B §4.4.2 the card executes the command, returns no response string and closes the session.': 'Выкл - команда без ответа: по GP Am. B §4.4.2 карта выполнит команду, не вернёт строку ответа и закроет сессию.',
|
||
'Send X-Admin-Targeted-Application': 'Отправлять X-Admin-Targeted-Application',
|
||
'custom': 'изменены',
|
||
'Script framing': 'Фрейминг скрипта',
|
||
'Indefinite (AE 80 … 00 00)': 'Неопределённая длина (AE 80 … 00 00)',
|
||
'Definite (AA)': 'Определённая длина (AA)',
|
||
'Comprehension-required tags': 'Теги comprehension-required',
|
||
'Show link events (Channel status ENVELOPEs, TS 102 223 7.5.11)': 'Показывать события канала (ENVELOPE Channel status, TS 102 223 7.5.11)',
|
||
'ADM verified': 'ADM подтверждён',
|
||
'ADM not verified': 'ADM не подтверждён',
|
||
'ADM key in the card preset — verified': 'Ключ ADM в пресете — подтверждён',
|
||
'ADM key in the card preset — not verified': 'Ключ ADM в пресете — не подтверждён',
|
||
'ADM key in the card preset — click to verify': 'Ключ ADM в пресете — нажмите для проверки',
|
||
'Verify ADM': 'Проверить ADM',
|
||
'Verifying ADM...': 'Проверка ADM...',
|
||
'ADM verification failed': 'Проверка ADM не удалась',
|
||
'ADM verification already failed': 'Проверка ADM уже завершилась неудачей',
|
||
'attempt(s) left': 'попыток осталось',
|
||
'A wrong key can block the ADM permanently. Try again?': 'Неверный ключ может навсегда заблокировать ADM. Попробовать снова?',
|
||
'This is the last attempt before the ADM is blocked. Try again?': 'Это последняя попытка — неверный ключ заблокирует ADM. Попробовать снова?',
|
||
'ADM is blocked': 'ADM заблокирован',
|
||
'ADM is blocked — unblock the card to try again': 'ADM заблокирован — разблокируйте карту, чтобы повторить',
|
||
'Verified — no ADM key in the card preset': 'Подтверждён — ключа ADM в пресете нет',
|
||
'No ADM key in the card preset — not verified': 'Ключа ADM в пресете нет — не подтверждён',
|
||
'SCP80 preset complete': 'Предустановка SCP80 заполнена',
|
||
'SCP81 preset complete': 'Предустановка SCP81 заполнена',
|
||
'Network state': 'Сетевое состояние',
|
||
'Undefined': 'Не определено',
|
||
'PLMN not allowed': 'PLMN не разрешён',
|
||
'roaming not allowed': 'роуминг не разрешён',
|
||
'LA not allowed': 'LA не разрешён',
|
||
'updated': 'обновлён',
|
||
'not updated': 'не обновлён',
|
||
'not present': 'отсутствует',
|
||
'empty': 'пусто',
|
||
'Empty (all FF)': 'Пусто (только FF)',
|
||
'No data': 'Нет данных',
|
||
'Home': 'Домашняя сеть',
|
||
'Home equivalent': 'Эквивалентная домашней',
|
||
'Guest (roaming)': 'Гостевая (роуминг)',
|
||
'Rejection: write FPLMN': 'Отказ: записать FPLMN',
|
||
'TERMINAL PROFILE': 'TERMINAL PROFILE',
|
||
'Preset (device model)': 'Пресет (модель устройства)',
|
||
'Profile (hex)': 'Профиль (hex)',
|
||
'Apply': 'Применить',
|
||
'Configure': 'Настроить',
|
||
'Custom': 'Другой',
|
||
'not configured': 'не задан',
|
||
'bytes': 'байт',
|
||
'Byte': 'Байт',
|
||
'Applied': 'Применено',
|
||
'Sent': 'Отправлено',
|
||
'menu updated': 'меню обновлено',
|
||
'Profile must be non-empty hex with an even number of digits': 'Профиль должен быть непустым hex с чётным числом цифр',
|
||
'Bits are decoded per TS 102 223 5.2; the hex field is authoritative and bytes/bits outside the table are preserved as-is.': 'Биты декодируются по TS 102 223 5.2; поле hex — основное, байты и биты вне таблицы сохраняются как есть.',
|
||
'Root (MF / ADF)': 'Корень (MF / ADF)',
|
||
'Parent DF': 'Родительский DF',
|
||
'FID (4 hex)': 'FID (4 hex)',
|
||
'Alias (EF. / DF.)': 'Псевдоним (EF. / DF.)',
|
||
'The parent may be any DF path — a standard DF from the tree or a custom DF, any depth; parents not yet seen in the tree are marked ⚠.': 'Родителем может быть любой путь DF — стандартный DF из дерева или пользовательский DF, любой вложенности; DF, ещё не встречавшиеся в дереве, помечаются ⚠.',
|
||
'Root must be MF, ADF.USIM or ADF.ISIM': 'Корень должен быть MF, ADF.USIM или ADF.ISIM',
|
||
'FID must be 4 hex characters': 'FID должен состоять из 4 hex-символов',
|
||
'Alias must start with "EF." or "DF."': 'Псевдоним должен начинаться с «EF.» или «DF.»',
|
||
'Parent must be the root or a chain of 4-hex FIDs (e.g. MF/7F20)': 'Родитель — корень или цепочка 4-hex FID (например, MF/7F20)',
|
||
'Parent is not a DF:': 'Родитель не является DF:',
|
||
'Parent DF not seen in the tree yet': 'Родительский DF ещё не встречался в дереве',
|
||
'File already defined:': 'Файл уже задан:',
|
||
'and': 'и',
|
||
'child file(s)?': 'дочерних файлов?',
|
||
'Dropped invalid legacy entries:': 'Отброшено некорректных старых записей:',
|
||
'No custom files defined.': 'Пользовательские файлы не заданы.',
|
||
};
|
||
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);
|
||
});
|
||
// static tooltips (the data-l10n-title attribute; the html test checks
|
||
// those keys against LANG_RU together with the data-l10n ones)
|
||
document.querySelectorAll('[data-l10n-title]').forEach(el => {
|
||
el.title = t(el.getAttribute('data-l10n-title'));
|
||
});
|
||
}
|
||
|
||
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();
|
||
pysimUpdateCardActionLabels();
|
||
pysimUpdateStateIndicator();
|
||
if (isViewVisible('tab-cards')) { cardsRender(); cardsSetFormMode(); }
|
||
if (isViewVisible('scp81-sub-scripts')) { scriptsRender(); scriptsRebuildSelect(); }
|
||
if (_ramExplorerData && isViewVisible('ram-explorer')) ramRenderExplorer();
|
||
// the CAP analysis bodies are built with t(); the legends are static
|
||
capRenderAnalysis('ram');
|
||
capRenderAnalysis('scripts');
|
||
if (isViewVisible('tab-phone')) {
|
||
if (isViewVisible('phone-sub-bip')) { bipStatusRefresh(); bipLogRefresh(); }
|
||
if (isViewVisible('phone-sub-phone')) {
|
||
pysimEventsRender();
|
||
pysimProactiveLogRender();
|
||
}
|
||
if (isViewVisible('phone-sub-tr')) pysimPliRender();
|
||
if (isViewVisible('phone-sub-esim')) {
|
||
esimRenderChip();
|
||
esimRenderProfiles();
|
||
esimRenderNotifications();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Init language
|
||
const userLang = (navigator.language || '').split('-')[0];
|
||
currentLang = localStorage.getItem('lang') || (userLang === 'ru' ? 'ru' : 'en');
|
||
document.getElementById('lang-btn').textContent = currentLang.toUpperCase();
|
||
updateHelpLink();
|
||
|
||
// Init custom files (after the language init: validation/rendering uses t())
|
||
pysimCustomLoad();
|
||
pysimCustomRenderRoots();
|
||
pysimCustomRenderParents();
|
||
|
||
cardsLoad();
|
||
pysimStartAvailabilityProbe();
|
||
pysimFsSetSort(pysimFsSort);
|
||
|
||
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();
|
||
uiccStdFilesLoad();
|
||
updateSpKic();
|
||
updateSpKid();
|
||
chainInit('chain-sim');
|
||
chainInit('chain-usim');
|
||
chainInit('chain-ram');
|
||
ramResetGrants();
|
||
updateInstallParamsHex();
|
||
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> |