Compare commits

...

13 Commits

Author SHA1 Message Date
catarrh fb6f80ef1d ui: translate the RAM operations hint
The data-l10n key for 'RAM operations perform atomic GlobalPlatform
commands over SCP80. Card keys are taken from the saved preset.' was
missing from LANG_RU, so the hint stayed English. SW cache v127 -> v128.
2026-09-12 23:25:42 +03:00
catarrh 2494e04984 ui: clear stale RAM status on op change, remember the card preset
- ramOpChanged() now clears the previous execution status (result line,
  steps, explorer, progress) only when the Operation value actually
  changes, so switching Explore -> Install Package no longer shows the
  old 'Partial - ELF Modules: no data' until Execute; re-entering the
  RAM subtab still preserves the last result
- ramClearResults() also hides the progress caption
- the Card preset dropdown no longer resets to the placeholder after
  every executed operation (ramSaveCntr -> ramRender rebuilt it) or on
  subtab re-entry: ramRender keeps the current/remembered index,
  ramApplyCard and ramExecute remember the selection for the session,
  and cardsRemove keeps _ramCardIdx aligned via ramCardIdxAfterRemove
- tests: op-change clearing, selection preservation across rebuilds,
  pick/execute remembering, index bookkeeping; SW cache v126 -> v127.
2026-09-12 23:20:26 +03:00
catarrh b49e6728b4 ui: translate SCP80/RAM explorer labels, buttons and result strings
The RAM explorer HTML is generated after load, so its data-l10n sections
and Delete/Delete All buttons stayed English until a language switch, and
the memory/AID/lifecycle labels were hardcoded with no key at all.

- ramRenderExploreHtml() now builds every label and button with t()
  (reusing the existing Delete/Delete All/heading/AID keys and wiring the
  three previously-unused Application/Instance, Load File and Module AID
  keys); new RU keys cover Applications:, Free NV:, Free Volatile:, AID:,
  Lifecycle:, Privileges:, SD AID:, Implicit sel:, Version: and (none)
- the explorer dataset is cached in _ramExplorerData and re-rendered by
  refreshDynamicI18n() on language switch (cleared on op change/results
  reset)
- RAM flow strings localized too: Partial/OK summaries, error labels
  (Memory/ISD/Apps/ELFs/ELF Modules/no data), GET DATA progress, delete
  confirm/labels, install alerts/progress/steps and Error:
- tests: ram.test.js asserts every explorer label/button goes through t()
  and the (none) placeholder; SW cache v125 -> v126.
2026-09-12 23:07:28 +03:00
catarrh dfb9551eb7 release: v2.1.1
Version bumped in server.py, pyproject.toml, the PWA header and the
docs/api.md example; SW cache v124 -> v125.
2026-09-12 22:25:07 +03:00
catarrh 8459040856 ui: auto-refresh the proactive log and STK menu state
The proactive command list only re-rendered on tab/pill switches, so new
fetched commands (background STATUS polling, menu traffic) stayed
invisible while the Phone view was open.

- /api/status now exposes proactive_seq (_PROACTIVE_ENTRY_ID, monotonic
  and not reset with the log), so the existing 2s status poll detects
  changes with no extra request; pysimCardStateUpdate() re-renders the
  log when the sequence changed and the Phone/Phone view is visible
- the 5s backend timer no longer fetches the log (it was discarded) and
  now uses the already-fetched stk-status: when active/pending changes
  while the Phone tab is visible, stkCheckMenu() refreshes the menu
  button without a tab switch
- pysimProactiveLogRender() keeps its scroll position and only shows
  Loading... on a first/empty paint
- tests: proactive seq + STK signature helpers and poll-driven render
  behaviour in card_state.test.js; SW cache v123 -> v124.
2026-09-12 22:22:55 +03:00
catarrh a5cccee17c release: v2.1.0
Version bumped in server.py, pyproject.toml, the PWA header and the
docs/api.md example; SW cache v122 -> v123.
2026-09-12 22:16:46 +03:00
catarrh 2a559371ae ui: keep the selected file in state, drop the File: title line
The File: EF.DIR [LF] line doubled as the selection store: pysimFsClickFile
wrote a formatted display string there and pysimFsRead/Save parsed it back
(stripping '✗ ' and ' [TYPE]'). The pane now keeps the name in
pysimFsSelected, the redundant title is gone (FID/type/size/FCI remain in
the info block), and the dead pysimFsSelect() plus the unused 'File:' RU
entry are removed. Read/Save still re-select on the server first: pySim's
current selection is a shared cursor that tree expansion, scans and
commands move, so it cannot identify the file shown in the pane.

SW cache v121 -> v122; structural test updated.
2026-09-12 22:12:25 +03:00
catarrh e0e86e31af ui: drop the Decoded FCI caption in the file manager info block
The decoded FCI items now follow the FID/type/size line directly; the
snapshot detail view keeps its caption. Test updated to assert the
caption is absent; SW cache v120 -> v121.
2026-09-12 21:59:41 +03:00
catarrh 2ec7a2d6c0 ui: show FID, layout and decoded FCI in the file manager detail pane
- selecting an EF now renders an info block between the File: title and
  the contents: FID, file type, size, record length/count (nulls skipped)
  followed by the decoded FCI via the existing profilerFciPreviewItems()
  decoder (no raw hex), matching the snapshot detail view
- pysimFsInfoHtml() is pure and reused by pysimFsClickFile(); the [LF]
  shorthand stays in the title; custom files get the same metadata from
  the temporary select probe
- tests: pysimFsInfoHtml in profiler.test.js (full response, missing
  fci_hex, skipped nulls) and a structural check for #pysim-fs-info;
  help EN/RU and READMEs updated; SW cache v119 -> v120.
2026-09-12 21:54:06 +03:00
catarrh 26f0063696 ui: reword the custom files hint
- EN: Custom files are added in file manager tree and included in card scan.
- RU: Пользовательские файлы добавляются в менеджер файлов и учитываются
  при чтении карт.
- SW cache v118 -> v119.
2026-09-12 21:47:23 +03:00
catarrh ddc6cb8f9d fix: parent-scoped file selection; never mutate the pySim model
pySim's lchan.select() resolves names against global selectables (self +
parent chain + MF children + applications) and falls back to probe_file(),
which blindly SELECTs an unknown FID and permanently injects a dynamically
named DF.XXXX/EF.XXXX into the running filesystem model. Probing a whole tree
or scanning a snapshot with custom files therefore polluted the model, made
tree branches show children of the wrong object, and could persist phantom
files into snapshots.

- server: new _select_with_parent()/_select_path() walk the requested parent
  path (new parent_path field, parent_sel kept as legacy fallback) strictly
  through the model and call lchan.select_file() only; model-unknown 4-hex
  segments are probed only with allow_probe and the temporary child pySim
  adds is detached again via the cleanup callable that the four handlers
  (/api/tree|select|read|write) now run in a finally block
- frontend: getParentPath() builds the segment chain (MF, ADF names, FIDs)
  and all tree/select/read/write bodies plus the snapshot/profile walker send
  parent_path; allow_probe is set only for custom files; the blind retries
  in the file manager were dropped
- tests: tests/test_select_scope.py (duplicate-FID resolution, no APDU for
  unknown non-custom files, probe+detach, model unchanged); fs_load/fs_probe
  assertions for parent_path and allow_probe; docs/api.md and AGENTS.md
  document the contract; SW cache v117 -> v118.
2026-09-12 21:45:57 +03:00
catarrh 85a66af7a5 ui: hide, don't clear, children of non-selectable DFs
- pysimFsLoadChildren failure now only marks exists=false and re-renders;
  loaded children stay in the node and are hidden by the renderer (red
  cross, no toggle, no (empty)), so a failed DF is re-attempted on the
  next probe/refresh instead of being short-circuited
- dropped the parent_sel-less /api/tree retry: error payloads now carry
  exists:false, so it doubled requests for every absent DF and re-selected
  the same FID without its parent (pySim probe_file fallback)
- probe walks strictly on exists===true and never collects files under a
  non-existing DF; no longer clears children on a failed EF select
- tests updated: single request per failed load, children untouched,
  200 exists:false marks absent without retry, render hides stale
  children, probe never fetches/selects them; SW cache v116 -> v117.
2026-09-12 21:23:31 +03:00
catarrh 92271d6726 ui: probe-all-files walk and honest file tree coloring
- pysimFsLoadChildren now treats {success:false,error} (and any error
  payload) as a failed listing: absent DFs/ADFs render as a red cross
  without an expand arrow instead of silently opening empty; the
  /api/tree 500 body also carries exists:false for shape consistency
  with /api/select
- an expanded directory with zero children shows a gray (empty)
  placeholder
- new Probe all files button (data-needs=card) in the tree header:
  walks the whole model tree from MF, verifies every DF/ADF via
  /api/tree and every file (incl. custom entries) via /api/select,
  skips subtrees of absent dirs, shows N/total progress, toggles to
  Stop, and reports present/absent counts with elapsed time; results
  colour the current tree only
- tests: fs_load (error/retry/empty), fs_render (red cross, (empty)),
  fs_probe (flags, absent-dir skip, custom files, stop, summary);
  html structural check; help EN/RU, READMEs, AGENTS updated;
  SW cache v115 -> v116.
2026-09-12 21:14:09 +03:00
17 changed files with 1262 additions and 175 deletions
+3
View File
@@ -490,6 +490,9 @@ Browse the UICC filesystem in a tree view. Files are shown with names, FIDs, and
- **Read** — reads the selected file (auto-detects transparent vs record files) - **Read** — reads the selected file (auto-detects transparent vs record files)
- **Edit** — switch to edit mode, modify hex data, click **Save** to write back - **Edit** — switch to edit mode, modify hex data, click **Save** to write back
- **Raw / Decoded** — toggle between hex dump and pysim-decoded JSON view - **Raw / Decoded** — toggle between hex dump and pysim-decoded JSON view
- Selecting a file shows its FID, file type, size / record layout and the decoded FCI above the contents
- Missing files are shown in red (✗); a present but empty DF shows `(empty)`
- **Probe all files** — walks the whole tree (incl. custom files), marks every entry present/absent with *N / total* progress, stoppable, and ends with a summary; browsing itself stays lazy
### Command Hints ### Command Hints
+3
View File
@@ -464,6 +464,9 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
- **Read** — чтение файла (автоопределение transparent/record) - **Read** — чтение файла (автоопределение transparent/record)
- **Edit** — режим редактирования, измените hex-данные и нажмите **Save** для записи - **Edit** — режим редактирования, измените hex-данные и нажмите **Save** для записи
- **Raw / Decoded** — переключение между hex-дампом и декодированным JSON - **Raw / Decoded** — переключение между hex-дампом и декодированным JSON
- При выборе файла над содержимым показываются FID, тип файла, размер / структура записей и декодированный FCI
- Отсутствующие файлы показаны красным (✗); существующий пустой DF — `(пусто)`
- **Проверить все файлы** — обход всего дерева (включая пользовательские) с пометкой «есть/нет», прогрессом *N / всего*, возможностью остановки и сводкой в конце; сам просмотр остаётся ленивым
### Подсказки команд ### Подсказки команд
+18 -5
View File
@@ -55,7 +55,7 @@ Returns server version for compatibility checking.
**Example response:** **Example response:**
```json ```json
{"version": "2.0.0"} {"version": "2.1.1"}
``` ```
### `GET /api/status` ### `GET /api/status`
@@ -272,7 +272,7 @@ Returns the current STK session state.
Read file content. Auto-detects transparent vs record files. Read file content. Auto-detects transparent vs record files.
```json ```json
{"name": "EF.ICCID", "fid": "2FE2", "parent_sel": "3F00", "mode": "raw"} {"name": "EF.ICCID", "fid": "2FE2", "parent_path": ["MF"], "mode": "raw"}
``` ```
Returns transparent data: Returns transparent data:
@@ -298,12 +298,12 @@ it for snapshot timing statistics. Other commands are not reported.
Write raw hex data to a file. Write raw hex data to a file.
```json ```json
{"name": "EF.ICCID", "fid": "2FE2", "data": "A0A1A2...", "parent_sel": "3F00"} {"name": "EF.ICCID", "fid": "2FE2", "data": "A0A1A2...", "parent_path": ["MF"]}
``` ```
For record files: For record files:
```json ```json
{"name": "EF.ADN", "fid": "6F3A", "data": "A0A1...", "record_nr": 1, "parent_sel": "7F10"} {"name": "EF.ADN", "fid": "6F3A", "data": "A0A1...", "record_nr": 1, "parent_path": ["MF", "7F10"]}
``` ```
Returns: Returns:
@@ -316,9 +316,19 @@ Returns:
Select a file by name or FID, with optional parent selection. Select a file by name or FID, with optional parent selection.
```json ```json
{"name": "EF.ICCID", "fid": "2FE2", "parent_sel": "3F00"} {"name": "EF.ICCID", "fid": "2FE2", "parent_path": ["MF"]}
``` ```
`parent_path` lists the path segments from MF to the parent (ADF names or
FIDs); the legacy single-segment `parent_sel` is still accepted but is only
unambiguous for ADFs. Resolution is strictly parent-scoped: model-known files
are selected through the requested parent only (pySim `select_file()`), never
via pySim's global selectables or its `probe_file()` model injection, so a
same-FID file under another parent is never picked and the filesystem model
is not modified. `allow_probe: true` (PWA custom files) additionally allows a
model-unknown 4-hex FID to be selected directly; any temporary model object
created for it is detached again before the response is sent.
Returns: Returns:
```json ```json
{"name": "EF.ICCID", "fid": "2FE2", "file_type": "transparent", {"name": "EF.ICCID", "fid": "2FE2", "file_type": "transparent",
@@ -340,6 +350,9 @@ Get directory listing with typed children.
{"name": "MF", "fid": "3F00"} {"name": "MF", "fid": "3F00"}
``` ```
Use `parent_path` (or the legacy `parent_sel`) to list a subdirectory, e.g.
`{"name": "DF.GSM-ACCESS", "fid": "5F3B", "parent_path": ["MF", "ADF.USIM"]}`.
Returns: Returns:
```json ```json
{"exists": true, "name": "MF", "fid": "3F00", "file_type": "df", "children": [{"name": "EF.ICCID", "fid": "2fe2", "isDir": false}]} {"exists": true, "name": "MF", "fid": "3F00", "file_type": "df", "children": [{"name": "EF.ICCID", "fid": "2fe2", "isDir": false}]}
+2 -1
View File
@@ -317,11 +317,12 @@
<p class="mb-3">Подключение к локальному <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> для работы с картой: введите URL сервера (по умолчанию <code class="font-mono text-sm">http://127.0.0.1:8080</code>) и нажмите <strong>Подключиться</strong>. Область статуса показывает состояние ридера/карты, а <strong>Подключить карту</strong> (пере)инициализирует карту после вставки. Подвкладки: <strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong> и <strong>Отправка APDU</strong>. <strong>&laquo;Профайлер&raquo;</strong> и <strong>&laquo;Симулятор телефона&raquo;</strong> — отдельные вкладки верхнего уровня.</p> <p class="mb-3">Подключение к локальному <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> для работы с картой: введите URL сервера (по умолчанию <code class="font-mono text-sm">http://127.0.0.1:8080</code>) и нажмите <strong>Подключиться</strong>. Область статуса показывает состояние ридера/карты, а <strong>Подключить карту</strong> (пере)инициализирует карту после вставки. Подвкладки: <strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong> и <strong>Отправка APDU</strong>. <strong>&laquo;Профайлер&raquo;</strong> и <strong>&laquo;Симулятор телефона&raquo;</strong> — отдельные вкладки верхнего уровня.</p>
<h3 id="file-manager" class="text-lg font-medium mb-2">4.1 Файловый менеджер</h3> <h3 id="file-manager" class="text-lg font-medium mb-2">4.1 Файловый менеджер</h3>
<p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по <strong>FID</strong> или символьному <strong>имени</strong> (пиллы над деревом; выбор сохраняется в <code class="font-mono text-sm">localStorage</code>).</p> <p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по <strong>FID</strong> или символьному <strong>имени</strong> (пиллы над деревом; выбор сохраняется в <code class="font-mono text-sm">localStorage</code>). При выборе файла над содержимым также показываются FID, тип файла, размер / структура записей и декодированный FCI.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Прочитать</strong> — чтение файла (автоопределение transparent/record)</li> <li><strong>Прочитать</strong> — чтение файла (автоопределение transparent/record)</li>
<li><strong>Редактировать</strong> — изменение hex-данных, <strong>Сохранить</strong> для записи (или <strong>Отмена</strong>)</li> <li><strong>Редактировать</strong> — изменение hex-данных, <strong>Сохранить</strong> для записи (или <strong>Отмена</strong>)</li>
<li><strong>Данные как на карте / Декодированные данные</strong> — переключение между hex-дампом и декодированным JSON</li> <li><strong>Данные как на карте / Декодированные данные</strong> — переключение между hex-дампом и декодированным JSON</li>
<li><strong>Проверить все файлы</strong> — обход всего дерева (включая пользовательские файлы) с пометкой каждого элемента: есть (обычный вид) или нет (красный ✗, без стрелки разворачивания); существующие пустые DF показывают <code class="font-mono text-sm">(пусто)</code>. Отображается прогресс <em>N / всего</em>, обход можно остановить; в конце — сводка «есть/нет». Файлы проверяются только при разворачивании или проверке — просмотр остаётся ленивым.</li>
</ul> </ul>
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.2 Командная строка pySim</h3> <h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.2 Командная строка pySim</h3>
+2 -1
View File
@@ -317,11 +317,12 @@
<p class="mb-3">Connects to a local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> for live card operations: enter the server URL (default <code class="font-mono text-sm">http://127.0.0.1:8080</code>) and press <strong>Connect</strong>. The status area shows the reader/card state, and <strong>Equip card</strong> (re)initializes the card after insertion. Sub-tabs: <strong>File manager</strong>, <strong>pySim command line</strong>, and <strong>Raw APDU</strong>. The <strong>Profiler</strong> and <strong>Phone simulator</strong> are separate top-level tabs.</p> <p class="mb-3">Connects to a local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> for live card operations: enter the server URL (default <code class="font-mono text-sm">http://127.0.0.1:8080</code>) and press <strong>Connect</strong>. The status area shows the reader/card state, and <strong>Equip card</strong> (re)initializes the card after insertion. Sub-tabs: <strong>File manager</strong>, <strong>pySim command line</strong>, and <strong>Raw APDU</strong>. The <strong>Profiler</strong> and <strong>Phone simulator</strong> are separate top-level tabs.</p>
<h3 id="file-manager" class="text-lg font-medium mb-2">4.1 File manager</h3> <h3 id="file-manager" class="text-lg font-medium mb-2">4.1 File manager</h3>
<p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by <strong>FID</strong> or symbolic <strong>Name</strong> (pills above the tree; the choice is remembered in <code class="font-mono text-sm">localStorage</code>).</p> <p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by <strong>FID</strong> or symbolic <strong>Name</strong> (pills above the tree; the choice is remembered in <code class="font-mono text-sm">localStorage</code>). Selecting a file also shows its FID, file type, size / record layout and the decoded FCI above the content pane.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Read</strong> — reads the selected file (auto-detects transparent vs record files)</li> <li><strong>Read</strong> — reads the selected file (auto-detects transparent vs record files)</li>
<li><strong>Edit</strong> — modify hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>)</li> <li><strong>Edit</strong> — modify hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>)</li>
<li><strong>Raw / Decoded</strong> — toggle between hex dump and pySim-decoded JSON</li> <li><strong>Raw / Decoded</strong> — toggle between hex dump and pySim-decoded JSON</li>
<li><strong>Probe all files</strong> — walks the whole tree (including custom files) and marks every entry present (normal) or absent (red ✗, no expand arrow); empty-but-present DFs show <code class="font-mono text-sm">(empty)</code>. Shows progress <em>N / total</em>, can be stopped, and finishes with a present/absent summary. Files are only verified when expanded or probed — browsing stays lazy.</li>
</ul> </ul>
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.2 pySim command line</h3> <h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.2 pySim command line</h3>
+309 -128
View File
@@ -18,7 +18,7 @@
<div class="max-w-7xl mx-auto px-6 py-2"> <div class="max-w-7xl mx-auto px-6 py-2">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v2.0.0</span></h1> <h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v2.1.1</span></h1>
<div class="flex items-center gap-4"> <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" 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> <span id="state-indicator-dot" class="text-xs text-gray-400" title="Connecting..."></span>
@@ -765,7 +765,7 @@
</div> </div>
<div id="profiler-list-custom" class="hidden"> <div id="profiler-list-custom" class="hidden">
<div class="mb-2 text-xs font-mono text-gray-600 dark:text-slate-400" data-l10n="Add known file paths and aliases to the file tree.">Add known file paths and aliases to the file tree.</div> <div class="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 gap-2 items-center mb-2"> <div class="flex gap-2 items-center mb-2">
<input id="pysim-cf-path" class="font-mono flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" placeholder="3F00/6F46" onkeydown="if(event.key==='Enter')pysimCustomSubmit()"> <input id="pysim-cf-path" class="font-mono flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" placeholder="3F00/6F46" onkeydown="if(event.key==='Enter')pysimCustomSubmit()">
<input id="pysim-cf-name" class="font-mono w-48 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" placeholder="EF.SPN" onkeydown="if(event.key==='Enter')pysimCustomSubmit()"> <input id="pysim-cf-name" class="font-mono w-48 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 dark:bg-slate-800" placeholder="EF.SPN" onkeydown="if(event.key==='Enter')pysimCustomSubmit()">
@@ -860,11 +860,13 @@
<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-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> <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> </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 id="pysim-fs-tree"></div> <div id="pysim-fs-tree"></div>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<div id="pysim-fs-detail" class="hidden"> <div id="pysim-fs-detail" class="hidden">
<div class="text-xs font-mono mb-1"><span data-l10n="File:">File:</span> <b id="pysim-fs-filename"></b></div> <div id="pysim-fs-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"></div> <div id="pysim-fs-content" class="w-full font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1.5 bg-gray-100 dark:bg-slate-800 min-h-20 overflow-y-auto"></div>
<div class="flex gap-2 mt-1 items-center"> <div class="flex gap-2 mt-1 items-center">
<button data-needs="card" onclick="pysimFsRead()" id="pysim-fs-read-btn" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Read">Read</button> <button data-needs="card" onclick="pysimFsRead()" id="pysim-fs-read-btn" class="px-2 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Read">Read</button>
@@ -4719,7 +4721,7 @@ async function pysimVerifySp() {
resultEl.classList.add('text-red-600'); resultEl.classList.add('text-red-600');
} }
} catch (e) { } catch (e) {
resultEl.textContent = 'Error: ' + e.message; resultEl.textContent = t('Error') + ': ' + e.message;
resultEl.classList.remove('text-green-600'); resultEl.classList.remove('text-green-600');
resultEl.classList.add('text-red-600'); resultEl.classList.add('text-red-600');
} }
@@ -4804,10 +4806,16 @@ async function pysimSendOta() {
// reuse /api/send-ota. Install Package sends the .cap hex to /api/ram-install // 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]. // which orchestrates INSTALL[for load] -> LOAD x N -> INSTALL[for install].
let _ramCardIdx = null;
let _ramOpLast = null;
function ramRender() { function ramRender() {
// populate the card preset selector from the in-memory cards[] array // 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'); const sel = document.getElementById('ram-card-sel');
if (!sel) return; 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>'; sel.innerHTML = '<option value="" data-l10n="— Select card —">— Select card —</option>';
cards.forEach((c, i) => { cards.forEach((c, i) => {
const opt = document.createElement('option'); const opt = document.createElement('option');
@@ -4815,22 +4823,23 @@ function ramRender() {
opt.textContent = c.name || ('Card ' + i); opt.textContent = c.name || ('Card ' + i);
sel.appendChild(opt); sel.appendChild(opt);
}); });
if (keep !== null && keep !== undefined && cards[keep]) sel.value = String(keep);
ramOpChanged(); ramOpChanged();
} }
function ramApplyCard(idx) { function ramApplyCard(idx) {
// copy the selected card preset into the SP form fields so that // copy the selected card preset into the SP form fields so that
// getRamSpParams() picks up the right SPI/KIc/KID/TAR/CNTR/keys // getRamSpParams() picks up the right SPI/KIc/KID/TAR/CNTR/keys
const i = parseInt(idx, 10);
if (!isNaN(i) && cards[i]) _ramCardIdx = i;
cardsApply(idx); cardsApply(idx);
} }
function ramOpChanged() { function ramOpChanged() {
const op = document.getElementById('ram-op').value; const op = document.getElementById('ram-op').value;
if (_ramOpLast !== null && op !== _ramOpLast) ramClearResults();
_ramOpLast = op;
document.getElementById('ram-install-params').classList.toggle('hidden', op !== 'install-cap'); document.getElementById('ram-install-params').classList.toggle('hidden', op !== 'install-cap');
if (op !== 'explore') {
document.getElementById('ram-explorer').classList.add('hidden');
document.getElementById('ram-explorer').innerHTML = '';
}
} }
function ramShowProgress(text) { function ramShowProgress(text) {
@@ -4844,6 +4853,8 @@ function ramHideProgress() {
} }
function ramClearResults() { function ramClearResults() {
_ramExplorerData = null;
ramHideProgress();
document.getElementById('ram-result').classList.add('hidden'); document.getElementById('ram-result').classList.add('hidden');
document.getElementById('ram-explorer').classList.add('hidden'); document.getElementById('ram-explorer').classList.add('hidden');
document.getElementById('ram-explorer').innerHTML = ''; document.getElementById('ram-explorer').innerHTML = '';
@@ -5058,7 +5069,7 @@ function ramFmtLifecycle(hex) {
// Decode GP Card Spec privileges TLV (tag C5) into human-readable strings // Decode GP Card Spec privileges TLV (tag C5) into human-readable strings
function ramFmtPrivileges(hex) { function ramFmtPrivileges(hex) {
const p = hex || ''; const p = hex || '';
if (!p) return '(none)'; if (!p) return t('(none)');
const bytes = p.match(/.{2}/g) || []; const bytes = p.match(/.{2}/g) || [];
const privs = []; const privs = [];
const b1 = parseInt(bytes[0] || '00', 16); const b1 = parseInt(bytes[0] || '00', 16);
@@ -5082,7 +5093,7 @@ function ramFmtPrivileges(hex) {
if (b3 & 0x04) privs.push('Delegated Perso'); if (b3 & 0x04) privs.push('Delegated Perso');
if (b3 & 0x08) privs.push('Trusted Path'); if (b3 & 0x08) privs.push('Trusted Path');
if (b3 & 0x10) privs.push('Authorized Mgmt'); if (b3 & 0x10) privs.push('Authorized Mgmt');
return privs.length ? privs.join(', ') : '(none)'; return privs.length ? privs.join(', ') : t('(none)');
} }
// Merge ELF-module entries (P1=10, carry module AIDs) into ELF entries (P1=20). // Merge ELF-module entries (P1=10, carry module AIDs) into ELF entries (P1=20).
@@ -5106,62 +5117,62 @@ function ramRenderExploreHtml(mem, isd, apps, elfs) {
let html = ''; let html = '';
if (mem && (mem.appCount != null || mem.freeNV != null || mem.freeV != null)) { 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="mb-4 p-3 bg-gray-50 dark:bg-slate-800 rounded">';
html += '<div class="font-semibold text-sm mb-1" data-l10n="Memory (GET DATA FF21)">Memory (GET DATA FF21)</div>'; html += '<div class="font-semibold text-sm mb-1">' + esc(t('Memory (GET DATA FF21)')) + '</div>';
html += '<div>Applications: ' + (mem.appCount != null ? mem.appCount : '?') + '</div>'; html += '<div>' + esc(t('Applications:')) + ' ' + (mem.appCount != null ? mem.appCount : '?') + '</div>';
html += '<div>Free NV: ' + (mem.freeNV != null ? mem.freeNV + ' B' : '?') + '</div>'; html += '<div>' + esc(t('Free NV:')) + ' ' + (mem.freeNV != null ? mem.freeNV + ' B' : '?') + '</div>';
html += '<div>Free Volatile: ' + (mem.freeV != null ? mem.freeV + ' B' : '?') + '</div>'; html += '<div>' + esc(t('Free Volatile:')) + ' ' + (mem.freeV != null ? mem.freeV + ' B' : '?') + '</div>';
html += '</div>'; html += '</div>';
} }
if (isd && isd.length) { if (isd && isd.length) {
html += '<div class="mb-4">'; html += '<div class="mb-4">';
html += '<div class="font-semibold text-sm mb-1" data-l10n="ISD (Issuer Security Domain)">ISD (Issuer Security Domain)</div>'; html += '<div class="font-semibold text-sm mb-1">' + esc(t('ISD (Issuer Security Domain)')) + '</div>';
isd.forEach(o => { isd.forEach(o => {
html += '<div class="mb-1 pl-2 border-l-2 border-blue-400">'; html += '<div class="mb-1 pl-2 border-l-2 border-blue-400">';
html += '<div>AID: ' + (o.aid || '?') + '</div>'; html += '<div>' + esc(t('AID:')) + ' ' + (o.aid || '?') + '</div>';
html += '<div>Lifecycle: ' + ramFmtLifecycle(o.lifecycle || '') + '</div>'; html += '<div>' + esc(t('Lifecycle:')) + ' ' + ramFmtLifecycle(o.lifecycle || '') + '</div>';
if (o.privileges) html += '<div>Privileges: ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')</div>'; if (o.privileges) html += '<div>' + esc(t('Privileges:')) + ' ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')</div>';
if (o.sdAid) html += '<div>SD AID: ' + o.sdAid + '</div>'; if (o.sdAid) html += '<div>' + esc(t('SD AID:')) + ' ' + o.sdAid + '</div>';
html += '</div>'; html += '</div>';
}); });
html += '</div>'; html += '</div>';
} }
if (apps && apps.length) { if (apps && apps.length) {
html += '<div class="mb-4">'; html += '<div class="mb-4">';
html += '<div class="font-semibold text-sm mb-1" data-l10n="Applications / Applet Instances">Applications / Applet Instances</div>'; html += '<div class="font-semibold text-sm mb-1">' + esc(t('Applications / Applet Instances')) + '</div>';
apps.forEach(o => { apps.forEach(o => {
html += '<div class="mb-1 pl-2 border-l-2 border-green-400">'; html += '<div class="mb-1 pl-2 border-l-2 border-green-400">';
html += '<div>Application / Instance AID: ' + (o.aid || '?'); html += '<div>' + esc(t('Application / Instance AID:')) + ' ' + (o.aid || '?');
if (o.aid) { if (o.aid) {
html += ' <button onclick="ramDeleteFromExplorer(\'' + o.aid + '\', false)" data-needs="card" class="ml-2 px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Delete">Delete</button>'; html += ' <button onclick="ramDeleteFromExplorer(\'' + o.aid + '\', false)" data-needs="card" class="ml-2 px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 disabled:opacity-40 disabled:cursor-not-allowed">' + esc(t('Delete')) + '</button>';
} }
html += '</div>'; html += '</div>';
html += '<div>Lifecycle: ' + ramFmtLifecycle(o.lifecycle || '') + '</div>'; html += '<div>' + esc(t('Lifecycle:')) + ' ' + ramFmtLifecycle(o.lifecycle || '') + '</div>';
if (o.privileges) html += '<div>Privileges: ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')</div>'; if (o.privileges) html += '<div>' + esc(t('Privileges:')) + ' ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')</div>';
if (o.implicitSel) html += '<div>Implicit sel: ' + o.implicitSel + '</div>'; if (o.implicitSel) html += '<div>' + esc(t('Implicit sel:')) + ' ' + o.implicitSel + '</div>';
if (o.elfAid) html += '<div>Load File AID / Package AID: ' + o.elfAid + '</div>'; if (o.elfAid) html += '<div>' + esc(t('Load File AID / Package AID:')) + ' ' + o.elfAid + '</div>';
if (o.sdAid) html += '<div>SD AID: ' + o.sdAid + '</div>'; if (o.sdAid) html += '<div>' + esc(t('SD AID:')) + ' ' + o.sdAid + '</div>';
html += '</div>'; html += '</div>';
}); });
html += '</div>'; html += '</div>';
} }
if (elfs && elfs.length) { if (elfs && elfs.length) {
html += '<div class="mb-4">'; html += '<div class="mb-4">';
html += '<div class="font-semibold text-sm mb-1" data-l10n="Executable Load Files (ELFs) / Packages">Executable Load Files (ELFs) / Packages</div>'; html += '<div class="font-semibold text-sm mb-1">' + esc(t('Executable Load Files (ELFs) / Packages')) + '</div>';
elfs.forEach(o => { elfs.forEach(o => {
html += '<div class="mb-1 pl-2 border-l-2 border-purple-400">'; html += '<div class="mb-1 pl-2 border-l-2 border-purple-400">';
html += '<div>Load File AID / Package AID: ' + (o.aid || '?'); html += '<div>' + esc(t('Load File AID / Package AID:')) + ' ' + (o.aid || '?');
if (o.aid) { if (o.aid) {
html += ' <button onclick="ramDeleteFromExplorer(\'' + o.aid + '\', false)" data-needs="card" class="ml-2 px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Delete">Delete</button>'; html += ' <button onclick="ramDeleteFromExplorer(\'' + o.aid + '\', false)" data-needs="card" class="ml-2 px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 disabled:opacity-40 disabled:cursor-not-allowed">' + esc(t('Delete')) + '</button>';
html += ' <button onclick="ramDeleteFromExplorer(\'' + o.aid + '\', true)" data-needs="card" class="ml-1 px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Delete All">Delete All</button>'; html += ' <button onclick="ramDeleteFromExplorer(\'' + o.aid + '\', true)" data-needs="card" class="ml-1 px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 disabled:opacity-40 disabled:cursor-not-allowed">' + esc(t('Delete All')) + '</button>';
} }
html += '</div>'; html += '</div>';
html += '<div>Lifecycle: ' + ramFmtLifecycle(o.lifecycle || '') + '</div>'; html += '<div>' + esc(t('Lifecycle:')) + ' ' + ramFmtLifecycle(o.lifecycle || '') + '</div>';
if (o.version) html += '<div>Version: ' + o.version + '</div>'; if (o.version) html += '<div>' + esc(t('Version:')) + ' ' + o.version + '</div>';
if (o.moduleAids && o.moduleAids.length) { if (o.moduleAids && o.moduleAids.length) {
html += '<div>Executable Module AIDs / Applet Class AIDs:</div>'; html += '<div>' + esc(t('Executable Module AIDs / Applet Class AIDs:')) + '</div>';
o.moduleAids.forEach(m => { html += '<div class="pl-4">- ' + m + '</div>'; }); o.moduleAids.forEach(m => { html += '<div class="pl-4">- ' + m + '</div>'; });
} }
if (o.sdAid) html += '<div>SD AID: ' + o.sdAid + '</div>'; if (o.sdAid) html += '<div>' + esc(t('SD AID:')) + ' ' + o.sdAid + '</div>';
html += '</div>'; html += '</div>';
}); });
html += '</div>'; html += '</div>';
@@ -5169,10 +5180,25 @@ function ramRenderExploreHtml(mem, isd, apps, elfs) {
return html; 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);
explorerEl.innerHTML = html || esc(t('(no data)'));
explorerEl.classList.remove('hidden');
pysimApplyAvailability();
}
// ===== Operation handlers ===== // ===== Operation handlers =====
async function ramExplore(sp) { async function ramExplore(sp) {
const resultEl = document.getElementById('ram-result'); const resultEl = document.getElementById('ram-result');
const explorerEl = document.getElementById('ram-explorer');
const stepsEl = document.getElementById('ram-steps'); const stepsEl = document.getElementById('ram-steps');
let cntr = sp.cntr; let cntr = sp.cntr;
const errors = []; const errors = [];
@@ -5202,7 +5228,7 @@ async function ramExplore(sp) {
const res = await ramSendOta(apdu, Object.assign({}, sp, { cntr, spi2 })); const res = await ramSendOta(apdu, Object.assign({}, sp, { cntr, spi2 }));
cntr = ramIncrementCntr(cntr); cntr = ramIncrementCntr(cntr);
if (!res.success || !res.por || res.por.response_status !== 'por_ok') { if (!res.success || !res.por || res.por.response_status !== 'por_ok') {
const errorMsg = res.por ? res.por.response_status : (res.error || 'no data'); const errorMsg = res.por ? res.por.response_status : (res.error || t('no data'));
errors.push(label + ': ' + errorMsg); errors.push(label + ': ' + errorMsg);
tlvFailed = true; tlvFailed = true;
break; break;
@@ -5227,7 +5253,7 @@ async function ramExplore(sp) {
if (sw === '6F00') { tlvFailed = true; break; } if (sw === '6F00') { tlvFailed = true; break; }
if (!data) { if (!data) {
if (sw !== '9000') { if (sw !== '9000') {
errors.push(label + ': (no data) — SW ' + sw); errors.push(label + ': ' + t('(no data)') + ' — SW ' + sw);
tlvFailed = true; tlvFailed = true;
} }
break; break;
@@ -5242,62 +5268,60 @@ async function ramExplore(sp) {
} }
} }
ramShowProgress('GET DATA FF21 (memory)...'); ramShowProgress(t('Memory (GET DATA FF21)') + '...');
try { try {
const memRes = await ramSendOta('80CAFF2100', Object.assign({}, sp, { spi2: '01' })); const memRes = await ramSendOta('80CAFF2100', Object.assign({}, sp, { spi2: '01' }));
cntr = ramIncrementCntr(cntr); cntr = ramIncrementCntr(cntr);
if (memRes.success && memRes.por && memRes.por.response_status === 'por_ok') { if (memRes.success && memRes.por && memRes.por.response_status === 'por_ok') {
const data = memRes.por.decoded ? memRes.por.decoded.last_response_data : ''; const data = memRes.por.decoded ? memRes.por.decoded.last_response_data : '';
if (!data) { if (!data) {
errors.push('Memory: (no data)'); errors.push(t('Memory') + ': ' + t('(no data)'));
} else { } else {
const m = ramParseGetMemory(data); const m = ramParseGetMemory(data);
if (m) Object.assign(mem, m); if (m) Object.assign(mem, m);
} }
} else { } else {
const errorMsg = memRes.por ? memRes.por.response_status : (memRes.error || 'no data'); const errorMsg = memRes.por ? memRes.por.response_status : (memRes.error || 'no data');
errors.push('Memory: ' + errorMsg); errors.push(t('Memory') + ': ' + errorMsg);
} }
} catch (e) { } catch (e) {
errors.push('Memory: ' + e.message); errors.push(t('Memory') + ': ' + e.message);
} }
// 2-5. GET STATUS for ISD / Apps / ELFs / ELF modules // 2-5. GET STATUS for ISD / Apps / ELFs / ELF modules
// P1 per GP Card Spec v2.3.1 table 11-33: // P1 per GP Card Spec v2.3.1 table 11-33:
// 80=ISD, 40=Applications, 20=Executable Load Files (ELFs), 10=ELF+modules // 80=ISD, 40=Applications, 20=Executable Load Files (ELFs), 10=ELF+modules
await paginate('80', isd, ramParseAppStatus, 'ISD'); await paginate('80', isd, ramParseAppStatus, t('ISD'));
await paginate('40', apps, ramParseAppStatus, 'Apps'); await paginate('40', apps, ramParseAppStatus, t('Apps'));
await paginate('20', elfs, ramParseElfStatus, 'ELFs'); await paginate('20', elfs, ramParseElfStatus, t('ELFs'));
await paginate('10', modules, ramParseElfStatus, 'ELF Modules'); await paginate('10', modules, ramParseElfStatus, t('ELF Modules'));
ramMergeElfData(elfs, modules); ramMergeElfData(elfs, modules);
ramSaveCntr(cntr); ramSaveCntr(cntr);
ramHideProgress(); ramHideProgress();
if (errors.length) { if (errors.length) {
resultEl.textContent = 'Partial — ' + errors.join('; '); resultEl.textContent = t('Partial') + ' — ' + errors.join('; ');
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600'); resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
stepsEl.classList.remove('hidden'); stepsEl.classList.remove('hidden');
stepsEl.textContent = errors.join('\n'); stepsEl.textContent = errors.join('\n');
} else { } else {
resultEl.textContent = 'OK — ' + isd.length + ' ISD, ' + apps.length + ' apps, ' + elfs.length + ' ELFs' + (mem.freeNV ? ', ' + mem.freeNV + ' free NV' : ''); resultEl.textContent = t('OK') + ' — ' + isd.length + ' ' + t('ISD') + ', ' + apps.length + ' ' + t('Apps') + ', ' + elfs.length + ' ' + t('ELFs') + (mem.freeNV ? ', ' + mem.freeNV + ' ' + t('free NV') : '');
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600'); resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
} }
const html = ramRenderExploreHtml(mem, isd, apps, elfs); _ramExplorerData = { mem: mem, isd: isd, apps: apps, elfs: elfs };
explorerEl.innerHTML = html || '(no data)'; ramRenderExplorer();
explorerEl.classList.remove('hidden');
pysimApplyAvailability();
} }
async function ramDeleteFromExplorer(aid, withCascade) { async function ramDeleteFromExplorer(aid, withCascade) {
const sp = getRamSpParams(); const sp = getRamSpParams();
if (!sp.kicKey || !sp.kidKey) { if (!sp.kicKey || !sp.kidKey) {
alert('Select a card preset with keys first (RAM subtab → Card preset)'); alert(t('Select a card preset with keys first (RAM subtab → Card preset)'));
return; return;
} }
const label = withCascade ? 'DELETE (cascade)' : 'DELETE'; const label = withCascade ? t('Delete') + ' (' + t('cascade') + ')' : t('Delete');
if (!confirm(label + ' — AID: ' + aid + '?')) return; if (!confirm(label + ' — ' + t('AID:') + ' ' + aid + '?')) return;
const p2 = withCascade ? '80' : '00'; const p2 = withCascade ? '80' : '00';
const aidLen = (aid.length / 2).toString(16).padStart(2, '0'); const aidLen = (aid.length / 2).toString(16).padStart(2, '0');
const apdu = '80E400' + p2 + _ber_len(2 + aid.length / 2) + '4F' + aidLen + aid; const apdu = '80E400' + p2 + _ber_len(2 + aid.length / 2) + '4F' + aidLen + aid;
@@ -5307,7 +5331,7 @@ async function ramDeleteFromExplorer(aid, withCascade) {
const resultEl = document.getElementById('ram-result'); const resultEl = document.getElementById('ram-result');
const stepsEl = document.getElementById('ram-steps'); const stepsEl = document.getElementById('ram-steps');
if (!res.success || !res.por || res.por.response_status !== 'por_ok') { if (!res.success || !res.por || res.por.response_status !== 'por_ok') {
resultEl.textContent = 'Failed: ' + (res.por ? res.por.response_status : res.error || res.sw); resultEl.textContent = t('Failed') + ': ' + (res.por ? res.por.response_status : res.error || res.sw);
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600'); resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
return; return;
} }
@@ -5315,7 +5339,7 @@ async function ramDeleteFromExplorer(aid, withCascade) {
const sw = res.por.decoded ? res.por.decoded.last_status_word : ''; const sw = res.por.decoded ? res.por.decoded.last_status_word : '';
stepsEl.classList.remove('hidden'); stepsEl.classList.remove('hidden');
stepsEl.textContent = label + ' ' + aid + ' -> ' + sw; stepsEl.textContent = label + ' ' + aid + ' -> ' + sw;
resultEl.textContent = 'OK'; resultEl.textContent = t('OK');
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600'); resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
await ramExplore(sp); await ramExplore(sp);
} }
@@ -5323,12 +5347,12 @@ async function ramDeleteFromExplorer(aid, withCascade) {
async function ramInstallCap(sp) { async function ramInstallCap(sp) {
const fileInput = document.getElementById('ram-cap-file'); const fileInput = document.getElementById('ram-cap-file');
const file = fileInput.files[0]; const file = fileInput.files[0];
if (!file) { alert('Select a .cap file'); return; } if (!file) { alert(t('Select a .cap file')); return; }
if (file.size > 48 * 1024) { alert('CAP file exceeds 48 kB limit'); return; } if (file.size > 48 * 1024) { alert(t('CAP file exceeds 48 kB limit')); return; }
ramShowProgress('Reading CAP file...'); ramShowProgress(t('Reading CAP file...'));
const capHex = await ramReadFileHex(file); const capHex = await ramReadFileHex(file);
ramShowProgress('Sending to server for install...'); ramShowProgress(t('Sending to server for install...'));
const body = { const body = {
cap_hex: capHex, cap_hex: capHex,
@@ -5351,26 +5375,28 @@ async function ramInstallCap(sp) {
let txt = ''; let txt = '';
(data.steps || []).forEach((s, idx) => { (data.steps || []).forEach((s, idx) => {
const mark = s.por_status === 'por_ok' ? '✅' : '❌'; const mark = s.por_status === 'por_ok' ? '✅' : '❌';
txt += mark + ' Step ' + (idx + 1) + ': ' + s.name + ' — ' + s.por_status + ' (SW ' + s.sw + ')\n'; txt += mark + ' ' + t('Step') + ' ' + (idx + 1) + ': ' + s.name + ' — ' + s.por_status + ' (SW ' + s.sw + ')\n';
}); });
stepsEl.textContent = txt; stepsEl.textContent = txt;
if (data.success) { if (data.success) {
ramSaveCntr(data.final_cntr); ramSaveCntr(data.final_cntr);
resultEl.textContent = 'Install OK — load_file_aid=' + data.load_file_aid + ' module_aid=' + data.module_aid; resultEl.textContent = t('Install OK') + ' — load_file_aid=' + data.load_file_aid + ' module_aid=' + data.module_aid;
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600'); resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
} else { } else {
resultEl.textContent = 'Install FAILED at step: ' + data.failed_step + (data.error ? ' (' + data.error + ')' : ''); resultEl.textContent = t('Install FAILED at step:') + ' ' + data.failed_step + (data.error ? ' (' + data.error + ')' : '');
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600'); resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
} }
} }
async function ramExecute() { async function ramExecute() {
ramClearResults(); ramClearResults();
const cardIdx = parseInt(document.getElementById('ram-card-sel').value, 10);
if (!isNaN(cardIdx) && cards[cardIdx]) _ramCardIdx = cardIdx;
const op = document.getElementById('ram-op').value; const op = document.getElementById('ram-op').value;
const sp = getRamSpParams(); const sp = getRamSpParams();
if (!sp.kicKey || !sp.kidKey) { if (!sp.kicKey || !sp.kidKey) {
alert('Select a card preset with keys first (RAM subtab → Card preset)'); alert(t('Select a card preset with keys first (RAM subtab → Card preset)'));
return; return;
} }
try { try {
@@ -5378,7 +5404,7 @@ async function ramExecute() {
else if (op === 'install-cap') await ramInstallCap(sp); else if (op === 'install-cap') await ramInstallCap(sp);
} catch (e) { } catch (e) {
const resultEl = document.getElementById('ram-result'); const resultEl = document.getElementById('ram-result');
resultEl.textContent = 'Error: ' + e.message; resultEl.textContent = t('Error') + ': ' + e.message;
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600'); resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
ramHideProgress(); ramHideProgress();
} }
@@ -5572,7 +5598,14 @@ function cardsAdd() {
}); });
} }
function ramCardIdxAfterRemove(idx, removedIdx) {
if (idx === null || idx === undefined || idx < 0) return null;
if (idx === removedIdx) return null;
return idx > removedIdx ? idx - 1 : idx;
}
function cardsRemove(i) { function cardsRemove(i) {
_ramCardIdx = ramCardIdxAfterRemove(_ramCardIdx, i);
cards.splice(i, 1); cards.splice(i, 1);
cardsSave(); cardsSave();
cardsRender(); cardsRender();
@@ -5844,6 +5877,18 @@ function getParentSel(node) {
return p.name; 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;
}
function pysimCustomInject(node) { function pysimCustomInject(node) {
if (!node || !node.fid) return; if (!node || !node.fid) return;
const pfid = node.fid.toLowerCase(); const pfid = node.fid.toLowerCase();
@@ -5938,16 +5983,11 @@ async function pysimFsRefresh() {
async function pysimFsLoadChildren(node) { async function pysimFsLoadChildren(node) {
if (node.children) return; if (node.children) return;
try { try {
let body = { name: node.name, fid: node.fid }; const body = { name: node.name, fid: node.fid, parent_sel: getParentSel(node), parent_path: getParentPath(node) };
const parentSel = getParentSel(node); if (node.custom) body.allow_probe = true;
if (parentSel) body.parent_sel = parentSel; const data = await pysimFetch('/api/tree', body);
let data = await pysimFetch('/api/tree', body); if (!data || data.exists === false || data.success === false || data.error) {
if (!data || data.exists === false) { throw new Error((data && data.error) || 'File not found');
body = { name: node.name, fid: node.fid };
data = await pysimFetch('/api/tree', body);
}
if (!data || data.exists === false) {
throw new Error('File not found');
} }
node.children = (data.children || []).map(c => ({ node.children = (data.children || []).map(c => ({
name: c.name, name: c.name,
@@ -5963,11 +6003,88 @@ async function pysimFsLoadChildren(node) {
pysimCustomInject(node); pysimCustomInject(node);
} catch (e) { } catch (e) {
node.exists = false; node.exists = false;
node.children = [];
pysimFsRenderTree(); 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) {
status.textContent = text;
status.classList.toggle('hidden', !text);
}
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 pysimFsRenderTree() { function pysimFsRenderTree() {
const el = document.getElementById('pysim-fs-tree'); const el = document.getElementById('pysim-fs-tree');
if (!pysimFsTreeRoot) { el.innerHTML = '(loading)'; return; } if (!pysimFsTreeRoot) { el.innerHTML = '(loading)'; return; }
@@ -6004,8 +6121,12 @@ function pysimFsRenderNode(node, depth) {
} }
html += '</div>'; html += '</div>';
if (isDir && node.children && node.expanded) { if (isDir && node.children && node.expanded) {
for (const child of pysimFsSortChildren(node.children, pysimFsSort)) { if (!node.children.length) {
html += pysimFsRenderNode(child, depth + 1); html += '<div class="text-gray-400 dark:text-slate-500">' + pad + '&nbsp;&nbsp;&nbsp;&nbsp;' + esc(t('(empty)')) + '</div>';
} else {
for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
html += pysimFsRenderNode(child, depth + 1);
}
} }
} }
return html; return html;
@@ -6034,28 +6155,38 @@ async function pysimFsToggleDir(name) {
if (node.expanded) pysimRefresh(); if (node.expanded) pysimRefresh();
} }
let pysimFsSelected = null;
function pysimFsInfoHtml(sel) {
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)));
let html = '';
if (attrs.length) html += '<div class="text-xs font-mono text-gray-500 dark:text-slate-400">' + attrs.join(' · ') + '</div>';
if (sel.fci_hex) {
html += '<div class="text-xs font-mono leading-relaxed text-gray-600 dark:text-slate-400 break-all mt-0.5">' + profilerFciPreviewItems(sel.fci_hex) + '</div>';
}
return html;
}
async function pysimFsClickFile(name) { async function pysimFsClickFile(name) {
pysimFsResetEdit(); pysimFsResetEdit();
pysimFsSetMode('raw'); pysimFsSetMode('raw');
const node = pysimFsFindNode(name, pysimFsTreeRoot); const node = pysimFsFindNode(name, pysimFsTreeRoot);
let body = { name: name, fid: node ? node.fid : null }; const sel = await pysimFetch('/api/select', pysimFsSelectBody(node || { name: name, fid: null }));
const parentSel = getParentSel(node); const exists = !!(sel && sel.exists !== false && !sel.error);
if (parentSel) body.parent_sel = parentSel;
let sel = await pysimFetch('/api/select', body);
if (!sel || sel.exists === false) {
body = { name: name, fid: node ? node.fid : null };
sel = await pysimFetch('/api/select', body);
}
const exists = sel && sel.exists !== false;
if (node) { if (node) {
node.exists = exists; node.exists = exists;
if (!exists) node.children = []; if (!exists) node.children = [];
pysimFsRenderTree(); pysimFsRenderTree();
} }
const ft = exists ? sel.file_type : null; pysimFsSelected = name;
const label = ({ transparent: 'TR', linear_fixed: 'LF', cyclic: 'CY', ber_tlv: 'BT', df: 'DF' })[ft] || ''; document.getElementById('pysim-fs-info').innerHTML = exists ? pysimFsInfoHtml(sel) : '';
const prefix = exists ? '' : '✗ ';
document.getElementById('pysim-fs-filename').textContent = prefix + name + (label ? ' [' + label + ']' : '');
document.getElementById('pysim-fs-content').innerHTML = ''; document.getElementById('pysim-fs-content').innerHTML = '';
document.getElementById('pysim-fs-status').textContent = exists ? '' : '✗ File not found on card'; document.getElementById('pysim-fs-status').textContent = exists ? '' : '✗ File not found on card';
if (exists) { if (exists) {
@@ -6067,21 +6198,16 @@ async function pysimFsClickFile(name) {
} }
async function pysimFsRead() { async function pysimFsRead() {
const rawName = document.getElementById('pysim-fs-filename').textContent.replace(/ \[.*?\]$/, '').replace(/^✗ /, ''); const rawName = pysimFsSelected;
const out = document.getElementById('pysim-fs-content'); const out = document.getElementById('pysim-fs-content');
const statusEl = document.getElementById('pysim-fs-status'); const statusEl = document.getElementById('pysim-fs-status');
out.innerHTML = ''; out.innerHTML = '';
statusEl.textContent = 'Loading...'; statusEl.textContent = 'Loading...';
try { try {
const node = pysimFsFindNode(rawName, pysimFsTreeRoot); const node = pysimFsFindNode(rawName, pysimFsTreeRoot);
let body = { name: rawName, fid: node ? node.fid : null, mode: pysimFsDecodedMode ? 'decoded' : 'raw' }; const body = pysimFsSelectBody(node || { name: rawName, fid: null });
const parentSel = getParentSel(node); body.mode = pysimFsDecodedMode ? 'decoded' : 'raw';
if (parentSel) body.parent_sel = parentSel; const data = await pysimFetch('/api/read', body);
let data = await pysimFetch('/api/read', body);
if (!data.success) {
body = { name: rawName, fid: node ? node.fid : null, mode: pysimFsDecodedMode ? 'decoded' : 'raw' };
data = await pysimFetch('/api/read', body);
}
if (!data.success) { if (!data.success) {
statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error'); statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error');
return; return;
@@ -6107,18 +6233,6 @@ async function pysimFsRead() {
} }
} }
async function pysimFsSelect() {
const name = document.getElementById('pysim-fs-filename').textContent;
const node = pysimFsFindNode(name, pysimFsTreeRoot);
if (node && node.fid) {
await pysimFetch('/api/command', { cmd: 'select ' + node.fid });
} else {
await pysimFetch('/api/command', { cmd: 'select ' + name });
}
pysimFsRefresh();
pysimRefresh();
}
let pysimFsEditMode = false; let pysimFsEditMode = false;
let pysimFsEditData = null; let pysimFsEditData = null;
@@ -6152,7 +6266,7 @@ function pysimFsCancel() {
} }
async function pysimFsSave() { async function pysimFsSave() {
const rawName = document.getElementById('pysim-fs-filename').textContent.replace(/ \[.*?\]$/, '').replace(/^✗ /, ''); const rawName = pysimFsSelected;
const statusEl = document.getElementById('pysim-fs-status'); const statusEl = document.getElementById('pysim-fs-status');
const out = document.getElementById('pysim-fs-content'); const out = document.getElementById('pysim-fs-content');
const node = pysimFsFindNode(rawName, pysimFsTreeRoot); const node = pysimFsFindNode(rawName, pysimFsTreeRoot);
@@ -6161,9 +6275,8 @@ async function pysimFsSave() {
const textareas = out.querySelectorAll('textarea'); const textareas = out.querySelectorAll('textarea');
if (textareas.length === 1) { if (textareas.length === 1) {
// Transparent file // Transparent file
const body = { name: rawName, fid: node ? node.fid : null, data: textareas[0].value.replace(/[^0-9a-fA-F]/g, '') }; const body = pysimFsSelectBody(node || { name: rawName, fid: null });
const parentSel = getParentSel(node); body.data = textareas[0].value.replace(/[^0-9a-fA-F]/g, '');
if (parentSel) body.parent_sel = parentSel;
const data = await pysimFetch('/api/write', body); const data = await pysimFetch('/api/write', body);
if (data.success) { if (data.success) {
statusEl.textContent = 'SW: ' + data.sw + ' OK'; statusEl.textContent = 'SW: ' + data.sw + ' OK';
@@ -6183,9 +6296,9 @@ async function pysimFsSave() {
wroteAny = true; wroteAny = true;
const num = row?.querySelector('span')?.textContent; const num = row?.querySelector('span')?.textContent;
if (num) { if (num) {
const body = { name: rawName, fid: node ? node.fid : null, data: inp.value.replace(/[^0-9a-fA-F]/g, ''), record_nr: parseInt(num) }; const body = pysimFsSelectBody(node || { name: rawName, fid: null });
const parentSel = getParentSel(node); body.data = inp.value.replace(/[^0-9a-fA-F]/g, '');
if (parentSel) body.parent_sel = parentSel; body.record_nr = parseInt(num);
const data = await pysimFetch('/api/write', body); const data = await pysimFetch('/api/write', body);
if (!data.success) { if (!data.success) {
statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error') + ' (record ' + num + ')'; statusEl.textContent = 'SW: ' + (data.sw || '?') + ' — ' + (data.error || 'Error') + ' (record ' + num + ')';
@@ -6631,6 +6744,10 @@ function pysimCardStateUpdate(status) {
_pysimCardEquipped = !!status.connected; _pysimCardEquipped = !!status.connected;
_pysimEquipping = !!status.equipping; _pysimEquipping = !!status.equipping;
pysimApplyAvailability(); 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('|'); const key = [status.connected, !!status.card_present, !!status.equipping, !!status.auto_equip, status.card_session].join('|');
if (key === _pysimCardStateKey) return; if (key === _pysimCardStateKey) return;
_pysimCardStateKey = key; _pysimCardStateKey = key;
@@ -6670,17 +6787,35 @@ function pysimStartBackendPoll() {
}, 2000); }, 2000);
_pysimPollTimer = setInterval(async () => { _pysimPollTimer = setInterval(async () => {
try { try {
const [log, stk, ps] = await Promise.all([ const [stk, ps] = await Promise.all([
pysimFetch('/api/proactive-log'),
pysimFetch('/api/stk-status'), pysimFetch('/api/stk-status'),
pysimFetch('/api/poll-status'), pysimFetch('/api/poll-status'),
]); ]);
pysimUpdatePollUI(ps.enabled, ps.interval); pysimUpdatePollUI(ps.enabled, ps.interval);
if (pysimStkStatusChanged(stk) && isViewVisible('tab-phone')) stkCheckMenu();
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
}, 5000); }, 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 ===== // ===== 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;
}
const CMD_NAMES = { const CMD_NAMES = {
'03': 'POLL INTERVAL', '05': 'SET UP EVENT LIST', '03': 'POLL INTERVAL', '05': 'SET UP EVENT LIST',
'13': 'SEND SHORT MESSAGE', '20': 'PLAY TONE', '13': 'SEND SHORT MESSAGE', '20': 'PLAY TONE',
@@ -6736,7 +6871,8 @@ function pysimLogFields(decoded) {
async function pysimProactiveLogRender() { async function pysimProactiveLogRender() {
const el = document.getElementById('pysim-proactive-log'); const el = document.getElementById('pysim-proactive-log');
el.innerHTML = '<span class="text-gray-400">' + t('Loading...') + '</span>'; const scrollTop = el.scrollTop;
if (!el.firstChild) el.innerHTML = '<span class="text-gray-400">' + t('Loading...') + '</span>';
try { try {
const log = await pysimFetch('/api/proactive-log'); const log = await pysimFetch('/api/proactive-log');
if (!log || !log.length) { if (!log || !log.length) {
@@ -6783,6 +6919,7 @@ async function pysimProactiveLogRender() {
html += '</div></div>'; html += '</div></div>';
}); });
el.innerHTML = html; el.innerHTML = html;
el.scrollTop = scrollTop;
} catch (err) { } catch (err) {
el.innerHTML = '<span class="text-red-500">Error: ' + esc(err.message) + '</span>'; el.innerHTML = '<span class="text-red-500">Error: ' + esc(err.message) + '</span>';
} }
@@ -7671,6 +7808,7 @@ async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, ma
// dir: { name, fid, parentSel (to select this dir), pathPrefix (rule-path segs for its children) } // dir: { name, fid, parentSel (to select this dir), pathPrefix (rule-path segs for its children) }
async function walkDir(dir) { async function walkDir(dir) {
let body = { name: dir.name, fid: dir.fid }; let body = { name: dir.name, fid: dir.fid };
if (dir.parentPath && dir.parentPath.length) body.parent_path = dir.parentPath;
if (dir.parentSel) body.parent_sel = dir.parentSel; if (dir.parentSel) body.parent_sel = dir.parentSel;
let data; let data;
try { try {
@@ -7686,7 +7824,8 @@ async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, ma
// ADF roots use the AID, not the generic ADF fid // ADF roots use the AID, not the generic ADF fid
const childPrefix = c.aid ? [c.aid.toUpperCase()] const childPrefix = c.aid ? [c.aid.toUpperCase()]
: dir.pathPrefix.concat(c.fid ? c.fid.toUpperCase() : c.name); : dir.pathPrefix.concat(c.fid ? c.fid.toUpperCase() : c.name);
await walkDir({ name: c.name, fid: c.fid, parentSel: parentSel, pathPrefix: childPrefix }); const childParentPath = (dir.parentPath || []).concat(c.aid ? c.aid.toUpperCase() : (c.fid ? c.fid : c.name));
await walkDir({ name: c.name, fid: c.fid, parentSel: parentSel, parentPath: childParentPath, pathPrefix: childPrefix });
} else { } else {
const childPath = dir.pathPrefix.concat(c.fid ? c.fid.toUpperCase() : c.name).join('/'); const childPath = dir.pathPrefix.concat(c.fid ? c.fid.toUpperCase() : c.name).join('/');
if (seen.has(childPath)) continue; if (seen.has(childPath)) continue;
@@ -7697,7 +7836,7 @@ async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, ma
} }
// MF-rooted walk // MF-rooted walk
await walkDir({ name: 'MF', fid: null, parentSel: null, pathPrefix: ['MF'] }); await walkDir({ name: 'MF', fid: null, parentSel: null, parentPath: ['MF'], pathPrefix: ['MF'] });
// Sweep custom files not already covered (map leading MF fid 3F00 -> MF) // Sweep custom files not already covered (map leading MF fid 3F00 -> MF)
for (const cf of pysimCustomFiles) { for (const cf of pysimCustomFiles) {
@@ -9091,6 +9230,37 @@ const LANG_RU = {
'Application / Instance AID:': 'AID приложения / экземпляра:', 'Application / Instance AID:': 'AID приложения / экземпляра:',
'Load File AID / Package AID:': 'AID Load File / пакета:', 'Load File AID / Package AID:': 'AID Load File / пакета:',
'Executable Module AIDs / Applet Class AIDs:': 'AID исполняемых модулей / классов апплетов:', '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)': '(нет данных)',
'free NV': 'свободно NV',
'Failed': 'Ошибка',
'cascade': 'каскадно',
'Select a card preset with keys first (RAM subtab → Card preset)': 'Сначала выберите пресет карты с ключами (RAM → Пресет карты)',
'Select a .cap file': 'Выберите файл .cap',
'CAP file exceeds 48 kB limit': 'Файл CAP превышает лимит 48 кБ',
'Reading CAP file...': 'Чтение файла CAP...',
'Sending to server for install...': 'Отправка на сервер для установки...',
'Step': 'Шаг',
'Install OK': 'Установка OK',
'Install FAILED at step:': 'Установка не удалась на шаге:',
'RAM operations perform atomic GlobalPlatform commands over SCP80. Card keys are taken from the saved preset.': 'RAM-операции выполняют атомарные команды GlobalPlatform через SCP80. Ключи карты берутся из сохранённого пресета.',
'No cards defined.': 'Карты не заданы.', 'No cards defined.': 'Карты не заданы.',
'Card presets': 'Выбор карты', 'Card presets': 'Выбор карты',
'Card preset': 'Пресет карты', 'Card preset': 'Пресет карты',
@@ -9121,8 +9291,7 @@ const LANG_RU = {
'Edit': 'Редактировать', 'Edit': 'Редактировать',
'Execute': 'Выполнить', 'Execute': 'Выполнить',
'Send': 'Отправить', 'Send': 'Отправить',
'Add known file paths and aliases to the file tree.': 'Добавьте пути и псевдонимы файлов в дерево файлов.', 'Custom files are added in file manager tree and included in card scan.': 'Пользовательские файлы добавляются в менеджер файлов и учитываются при чтении карт.',
'File:': 'Файл:',
'Cancel': 'Отмена', 'Cancel': 'Отмена',
'Timeout': 'Таймаут', 'Timeout': 'Таймаут',
'OK': 'OK', 'OK': 'OK',
@@ -9147,6 +9316,17 @@ const LANG_RU = {
'Phone simulator': 'Симулятор телефона', 'Phone simulator': 'Симулятор телефона',
'Sort:': 'Сортировка:', 'Sort:': 'Сортировка:',
'Name': 'Имя', 'Name': 'Имя',
'Probe all files': 'Проверить все файлы',
'Stop': 'Остановить',
'Probing:': 'Проверка:',
'discovering': 'поиск файлов',
'files': 'файлов',
'present': 'есть',
'absent': 'нет',
'Probed:': 'Проверено:',
'Stopped': 'Остановлено',
'(empty)': '(пусто)',
'Error': 'Ошибка',
'No server connection': 'Нет соединения с сервером', 'No server connection': 'Нет соединения с сервером',
'Server connected, no card equipped': 'Сервер подключён, карта не подключена', 'Server connected, no card equipped': 'Сервер подключён, карта не подключена',
'Card equipped': 'Карта подключена', 'Card equipped': 'Карта подключена',
@@ -9349,6 +9529,7 @@ function refreshDynamicI18n() {
if (isViewVisible('profiler-scan-modal')) profilerScanRefreshOptions(); if (isViewVisible('profiler-scan-modal')) profilerScanRefreshOptions();
pysimUpdateStateIndicator(); pysimUpdateStateIndicator();
if (isViewVisible('scp80-sub-cards')) cardsRender(); if (isViewVisible('scp80-sub-cards')) cardsRender();
if (_ramExplorerData && isViewVisible('ram-explorer')) ramRenderExplorer();
if (isViewVisible('tab-phone')) { if (isViewVisible('tab-phone')) {
if (isViewVisible('phone-sub-phone')) { if (isViewVisible('phone-sub-phone')) {
pysimEventsRender(); pysimEventsRender();
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v115'; const CACHE = 'otaman-v128';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+43 -2
View File
@@ -22,23 +22,29 @@ function extractFunc(src, name) {
} }
let code = 'var _pysimCardStateKey = null;\nvar _pysimCardSession = null;\n' let code = 'var _pysimCardStateKey = null;\nvar _pysimCardSession = null;\n'
+ 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\n'; + 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\n'
+ 'var _pysimProactiveSeq = null;\nvar _pysimStkSig = null;\n';
code += extractFunc(html, 'pysimCardStateUpdate') + '\n'; code += extractFunc(html, 'pysimCardStateUpdate') + '\n';
code += extractFunc(html, 'pysimAvailabilityState') + '\n'; code += extractFunc(html, 'pysimAvailabilityState') + '\n';
code += extractFunc(html, 'pysimControlDisabled') + '\n'; code += extractFunc(html, 'pysimControlDisabled') + '\n';
code += extractFunc(html, 'pysimProactiveSeqChanged') + '\n';
code += extractFunc(html, 'pysimStkStatusChanged') + '\n';
code += '\nglobalThis.esc = s => s;\n'; code += '\nglobalThis.esc = s => s;\n';
code += 'globalThis.t = s => s;\n'; code += 'globalThis.t = s => s;\n';
eval(code); eval(code);
function setup() { function setup() {
const el = { textContent: 'status line', innerHTML: '' }; const el = { textContent: 'status line', innerHTML: '' };
const calls = { connected: [], resets: [], refreshStatus: [] }; const calls = { connected: [], resets: [], refreshStatus: [], proactive: 0 };
_pysimCardStateKey = null; _pysimCardStateKey = null;
_pysimCardSession = null; _pysimCardSession = null;
_pysimProactiveSeq = null;
globalThis.document = { getElementById: () => el, querySelectorAll: () => [] }; globalThis.document = { getElementById: () => el, querySelectorAll: () => [] };
globalThis.pysimSetConnected = v => calls.connected.push(v); globalThis.pysimSetConnected = v => calls.connected.push(v);
globalThis.pysimResetCardData = refresh => calls.resets.push(refresh); globalThis.pysimResetCardData = refresh => calls.resets.push(refresh);
globalThis.pysimApplyAvailability = () => {}; globalThis.pysimApplyAvailability = () => {};
globalThis.isViewVisible = () => true;
globalThis.pysimProactiveLogRender = () => { calls.proactive++; };
return { el, calls }; return { el, calls };
} }
@@ -127,3 +133,38 @@ test('no card with auto-equip enabled still shows the no-card message', () => {
assert.ok(el.innerHTML.includes('No card detected'), el.innerHTML); assert.ok(el.innerHTML.includes('No card detected'), el.innerHTML);
assert.ok(!el.innerHTML.includes('initializing'), el.innerHTML); assert.ok(!el.innerHTML.includes('initializing'), el.innerHTML);
}); });
test('proactive log refreshes when the status sequence changes', () => {
const { calls } = setup();
pysimCardStateUpdate(status({ proactive_seq: 7 }));
pysimCardStateUpdate(status({ proactive_seq: 7 }));
assert.strictEqual(calls.proactive, 1);
pysimCardStateUpdate(status({ proactive_seq: 8 }));
assert.strictEqual(calls.proactive, 2);
});
test('proactive log is not refreshed while the phone view is hidden', () => {
const { calls } = setup();
globalThis.isViewVisible = () => false;
pysimCardStateUpdate(status({ proactive_seq: 3 }));
assert.strictEqual(calls.proactive, 0);
});
test('pysimProactiveSeqChanged tracks the last sequence', () => {
_pysimProactiveSeq = null;
assert.ok(pysimProactiveSeqChanged(4));
assert.ok(!pysimProactiveSeqChanged(4));
assert.ok(pysimProactiveSeqChanged(5));
assert.ok(!pysimProactiveSeqChanged(undefined));
assert.ok(!pysimProactiveSeqChanged(null));
});
test('pysimStkStatusChanged detects menu state transitions', () => {
_pysimStkSig = null;
assert.ok(pysimStkStatusChanged({ active: false, pending: false }));
assert.ok(!pysimStkStatusChanged({ active: false, pending: false }));
assert.ok(pysimStkStatusChanged({ active: true, pending: true, pending_type: 'select_item' }));
assert.ok(!pysimStkStatusChanged({ active: true, pending: true, pending_type: 'select_item' }));
assert.ok(pysimStkStatusChanged({ active: true, pending: false }));
assert.ok(!pysimStkStatusChanged(null));
});
+103
View File
@@ -0,0 +1,103 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = '';
code += extractFunc(html, 'getParentSel') + '\n';
code += extractFunc(html, 'getParentPath') + '\n';
code += extractFunc(html, 'pysimFsLoadChildren') + '\n';
code += 'globalThis.pysimCustomInject = () => {};\n';
eval(code);
let calls = [];
let responses = [];
let renders = 0;
function setup() {
calls = [];
responses = [];
renders = 0;
globalThis.pysimFetch = async (p, body) => {
calls.push({ path: p, body: JSON.parse(JSON.stringify(body)) });
const r = responses.shift();
if (r instanceof Error) throw r;
return JSON.parse(JSON.stringify(r));
};
globalThis.pysimFsRenderTree = () => { renders++; };
const node = { name: 'DF.USIM', fid: '7fff', isDir: true, children: null, exists: null, parent: { name: 'MF', fid: '3f00' } };
return node;
}
test('tree error payload marks the directory as absent', async () => {
const node = setup();
responses = [
{ success: false, error: 'SW ... 6a82', exists: false },
{ success: false, error: 'SW ... 6a82', exists: false },
];
await pysimFsLoadChildren(node);
assert.strictEqual(node.exists, false);
assert.strictEqual(node.children, null);
assert.strictEqual(renders, 1);
assert.strictEqual(calls.length, 1);
assert.strictEqual(calls[0].body.parent_sel, 'MF');
assert.deepStrictEqual(calls[0].body.parent_path, ['MF']);
});
test('error payload without exists is not treated as an empty listing', async () => {
const node = setup();
responses = [
{ success: false, error: 'boom' },
{ success: false, error: 'boom' },
];
await pysimFsLoadChildren(node);
assert.strictEqual(node.exists, false);
assert.strictEqual(node.children, null);
assert.strictEqual(renders, 1);
assert.strictEqual(calls.length, 1);
});
test('a 200 exists:false response marks the directory absent without retrying', async () => {
const node = setup();
responses = [{ exists: false }];
await pysimFsLoadChildren(node);
assert.strictEqual(node.exists, false);
assert.strictEqual(node.children, null);
assert.strictEqual(calls.length, 1);
});
test('a node with loaded children is not fetched again', async () => {
const node = setup();
node.exists = true;
node.children = [{ name: 'EF.UPLMNWLAN', fid: '4f42', isDir: false, exists: true }];
await pysimFsLoadChildren(node);
assert.strictEqual(node.exists, true);
assert.strictEqual(node.children.length, 1);
assert.strictEqual(calls.length, 0);
});
test('empty successful listing keeps the directory present', async () => {
const node = setup();
responses = [{ exists: true, children: [] }];
await pysimFsLoadChildren(node);
assert.strictEqual(node.exists, true);
assert.deepStrictEqual(node.children, []);
});
+156
View File
@@ -0,0 +1,156 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = 'var pysimFsTreeRoot = null;\nvar _pysimFsProbe = null;\nvar pysimFsSort = "fid";\n';
for (const fn of ['getParentSel', 'getParentPath', 'pysimFsSortChildren', 'pysimFsLoadChildren', 'pysimFsSelectBody', 'pysimFsProbeUi', 'pysimFsProbeAll']) {
code += extractFunc(html, fn) + '\n';
}
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\nglobalThis.pysimCustomInject = () => {};\n';
eval(code);
function fakeEl() {
const classes = new Set();
return {
textContent: '',
attrs: {},
classList: {
add: (...cs) => cs.forEach(c => classes.add(c)),
remove: (...cs) => cs.forEach(c => classes.delete(c)),
contains: c => classes.has(c),
toggle: (c, on) => { if (on === undefined ? !classes.has(c) : on) classes.add(c); else classes.delete(c); },
},
setAttribute(k, v) { this.attrs[k] = v; },
};
}
let els = {};
let calls = [];
function setup(routes) {
els = { 'pysim-fs-probe-status': fakeEl(), 'pysim-fs-probe-btn': fakeEl() };
calls = [];
globalThis.document = { getElementById: id => els[id] || null };
globalThis.pysimFetch = async (p, body) => {
calls.push({ path: p, body: body || {} });
for (const r of routes) {
if (r.path !== p) continue;
if (r.name && (!body || body.name !== r.name)) continue;
return typeof r.reply === 'function' ? r.reply(body, calls) : JSON.parse(JSON.stringify(r.reply));
}
throw new Error('unexpected fetch ' + p + ' ' + JSON.stringify(body));
};
globalThis.pysimFsRenderTree = () => {};
}
function root(children) {
pysimFsTreeRoot = { name: 'MF', fid: '3f00', isDir: true, expanded: true, exists: true, children: null, parent: null };
pysimFsTreeRoot.children = children.map(c => Object.assign({ children: null, expanded: false, exists: true, parent: pysimFsTreeRoot }, c));
return pysimFsTreeRoot;
}
const df = (name, fid) => ({ name, fid, isDir: true });
const ef = (name, fid) => ({ name, fid, isDir: false });
const selectNames = () => calls.filter(c => c.path === '/api/select').map(c => c.body.name);
test('probes dirs and files, including custom entries, and reports counts', async () => {
root([df('DF.A', '5f01'), ef('EF.ROOT', '2f01'), Object.assign(ef('EF.CUSTOM', '6fcc'), { custom: true })]);
let sawStop = null;
setup([
{ path: '/api/tree', name: 'DF.A', reply: { exists: true, children: [{ name: 'EF.1', fid: '6f01', isDir: false }] } },
{ path: '/api/select', name: 'EF.1', reply: () => { sawStop = els['pysim-fs-probe-btn'].textContent; return { exists: true }; } },
{ path: '/api/select', name: 'EF.ROOT', reply: { error: 'SW 6a82', exists: false } },
{ path: '/api/select', name: 'EF.CUSTOM', reply: { exists: true } },
]);
await pysimFsProbeAll();
assert.strictEqual(sawStop, 'Stop');
assert.deepStrictEqual(selectNames(), ['EF.1', 'EF.ROOT', 'EF.CUSTOM']);
assert.strictEqual(pysimFsTreeRoot.children[0].children[0].exists, true);
assert.strictEqual(pysimFsTreeRoot.children[1].exists, false);
assert.strictEqual(pysimFsTreeRoot.children[2].exists, true);
const status = els['pysim-fs-probe-status'].textContent;
assert.match(status, /4\/4 files/);
assert.match(status, /3 present/);
assert.match(status, /1 absent/);
assert.strictEqual(els['pysim-fs-probe-btn'].textContent, 'Probe all files');
assert.strictEqual(els['pysim-fs-probe-btn'].attrs['data-l10n'], 'Probe all files');
});
test('an absent directory is marked and its subtree is never fetched', async () => {
root([df('DF.B', '5f02'), ef('EF.ROOT', '2f01')]);
setup([
{ path: '/api/tree', name: 'DF.B', reply: { success: false, error: 'SW 6a82', exists: false } },
{ path: '/api/select', name: 'EF.ROOT', reply: { error: 'SW 6a82', exists: false } },
]);
await pysimFsProbeAll();
assert.strictEqual(pysimFsTreeRoot.children[0].exists, false);
assert.deepStrictEqual(selectNames(), ['EF.ROOT']);
assert.strictEqual(calls.filter(c => c.path === '/api/tree' && c.body.name === 'DF.B').length, 1);
const status = els['pysim-fs-probe-status'].textContent;
assert.match(status, /2\/2 files/);
assert.match(status, /0 present/);
assert.match(status, /2 absent/);
});
test('stop halts the walk and still reports a summary', async () => {
root([ef('EF.X', '6f01'), ef('EF.Y', '6f02')]);
setup([
{ path: '/api/select', name: 'EF.X', reply: () => { _pysimFsProbe.stop = true; return { exists: true }; } },
]);
await pysimFsProbeAll();
assert.deepStrictEqual(selectNames(), ['EF.X']);
const status = els['pysim-fs-probe-status'].textContent;
assert.ok(status.startsWith('Stopped —'), status);
assert.strictEqual(els['pysim-fs-probe-btn'].textContent, 'Probe all files');
});
test('select bodies carry the parent path and set allow_probe only for custom files', async () => {
root([df('DF.A', '5f01'), Object.assign(ef('EF.CUSTOM', '6fcc'), { custom: true })]);
const body = pysimFsSelectBody(pysimFsTreeRoot.children[0]);
assert.deepStrictEqual(body.parent_path, ['MF']);
assert.strictEqual(body.parent_sel, 'MF');
assert.ok(!body.allow_probe);
const custom = pysimFsSelectBody(pysimFsTreeRoot.children[1]);
assert.deepStrictEqual(custom.parent_path, ['MF']);
assert.strictEqual(custom.allow_probe, true);
const nested = { name: 'EF.1', fid: '6f01', isDir: false, parent: pysimFsTreeRoot.children[0] };
assert.deepStrictEqual(pysimFsSelectBody(nested).parent_path, ['MF', '5f01']);
});
test('children of an absent directory are neither fetched nor selected', async () => {
const stale = Object.assign(df('DF.C', '5f03'), {
exists: false,
children: [{ name: 'EF.STALE', fid: '6f0e', isDir: false, exists: true, children: null }],
});
root([stale, ef('EF.ROOT', '2f01')]);
setup([
{ path: '/api/select', name: 'EF.ROOT', reply: { exists: true } },
]);
await pysimFsProbeAll();
assert.deepStrictEqual(selectNames(), ['EF.ROOT']);
assert.strictEqual(stale.exists, false);
assert.strictEqual(calls.filter(c => c.path === '/api/tree').length, 0);
const status = els['pysim-fs-probe-status'].textContent;
assert.match(status, /2\/2 files/);
assert.match(status, /1 present/);
assert.match(status, /1 absent/);
});
+67
View File
@@ -0,0 +1,67 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = 'var pysimFsSort = "fid";\n';
code += extractFunc(html, 'pysimFsSortChildren') + '\n';
code += extractFunc(html, 'pysimFsRenderNode') + '\n';
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
eval(code);
const ef = (name, fid) => ({ name, fid, isDir: false, exists: true, children: null, expanded: false });
const df = (name, fid, extra) => Object.assign({ name, fid, isDir: true, exists: true, children: null, expanded: false }, extra || {});
test('absent directory renders a cross and no expand toggle', () => {
const node = df('DF.WLAN', '5f40', { exists: false, children: [] });
const out = pysimFsRenderNode(node, 0);
assert.ok(out.includes('✗'), out);
assert.ok(!out.includes('pysimFsToggleDir'), out);
assert.ok(!out.includes('▶'), out);
});
test('expanded empty directory shows the (empty) placeholder', () => {
const node = df('DF.EMPTY', '5f00', { children: [], expanded: true });
const out = pysimFsRenderNode(node, 0);
assert.ok(out.includes('(empty)'), out);
});
test('expanded directory with children renders no placeholder', () => {
const node = df('DF.GSM', '7f20', { children: [ef('EF.IMSI', '6f07')], expanded: true });
const out = pysimFsRenderNode(node, 0);
assert.ok(out.includes('EF.IMSI'), out);
assert.ok(!out.includes('(empty)'), out);
});
test('collapsed directory hides its children', () => {
const node = df('DF.GSM', '7f20', { children: [ef('EF.IMSI', '6f07')], expanded: false });
const out = pysimFsRenderNode(node, 0);
assert.ok(!out.includes('EF.IMSI'), out);
});
test('non-existing directory hides previously loaded children', () => {
const node = df('DF.WLAN', '5f40', { exists: false, expanded: true, children: [ef('EF.UPLMNWLAN', '4f42')] });
const out = pysimFsRenderNode(node, 0);
assert.ok(out.includes('✗'), out);
assert.ok(!out.includes('EF.UPLMNWLAN'), out);
assert.ok(!out.includes('(empty)'), out);
assert.ok(!out.includes('pysimFsToggleDir'), out);
});
+20
View File
@@ -61,3 +61,23 @@ test('custom files form has add/save and cancel controls', () => {
assert.ok(html.includes("event.key==='Enter')pysimCustomSubmit()")); assert.ok(html.includes("event.key==='Enter')pysimCustomSubmit()"));
assert.ok(!html.includes('pysimCustomAdd')); assert.ok(!html.includes('pysimCustomAdd'));
}); });
test('file manager has a probe-all-files button and status line', () => {
assert.match(html, /id="pysim-fs-probe-btn"[^>]*data-needs="card"/);
assert.match(html, /id="pysim-fs-probe-btn"[^>]*data-l10n="Probe all files"/);
assert.ok(html.includes('onclick="pysimFsProbeAll()"'));
assert.ok(html.includes('id="pysim-fs-probe-status"'));
});
test('file manager shows FCI info and keeps the selection in state, not the DOM', () => {
const detail = html.indexOf('id="pysim-fs-detail"');
const info = html.indexOf('id="pysim-fs-info"');
const content = html.indexOf('id="pysim-fs-content"');
assert.ok(detail !== -1 && info > detail && info < content, 'pysim-fs-info must sit above the content');
assert.ok(html.includes('function pysimFsInfoHtml'));
assert.ok(html.includes("pysimFsInfoHtml(sel)"));
assert.ok(!html.includes('pysim-fs-filename'));
assert.ok(html.includes('let pysimFsSelected = null;'));
assert.ok(html.includes('pysimFsSelected = name;'));
assert.ok(!html.includes('pysimFsSelect()'));
});
+28 -1
View File
@@ -21,7 +21,7 @@ function extractFunc(src, name, asyncFn) {
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1); return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
} }
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerMaskFidForFile', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary', 'profilerSnapshotCountLabel']; const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerMaskFidForFile', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary', 'profilerSnapshotCountLabel', 'pysimFsInfoHtml'];
let code = ''; let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n'; for (const f of FNS) code += extractFunc(html, f) + '\n';
code += extractFunc(html, 'profilerBuildFileRule', true) + '\n'; code += extractFunc(html, 'profilerBuildFileRule', true) + '\n';
@@ -698,6 +698,33 @@ test('fcpDiffHtml highlights differing FCI parameters', () => {
delete global.t; delete global.t;
}); });
test('pysimFsInfoHtml shows FID, type, size and the decoded FCI', () => {
global.t = s => s;
const hex = '621A82054221000F0583026F4F8A01058B036F06098002004B8801B0';
const out = pysimFsInfoHtml({ fid: '6f4f', file_type: 'linear_fixed', file_size: 75, record_len: 15, num_of_rec: 5, fci_hex: hex });
assert.ok(out.includes('FID: 6F4F'), out);
assert.ok(out.includes('File type: linear_fixed'), out);
assert.ok(out.includes('Size: 75'), out);
assert.ok(out.includes('Record length: 15'), out);
assert.ok(out.includes('Record count: 5'), out);
assert.ok(!out.includes('Decoded FCI'), out);
assert.ok(out.includes('File descriptor'), out);
assert.ok(out.includes('75 bytes'), out);
delete global.t;
});
test('pysimFsInfoHtml omits the FCI block without fci_hex and skips null fields', () => {
global.t = s => s;
const out = pysimFsInfoHtml({ fid: '6f07', file_type: 'transparent', file_size: null, record_len: null, num_of_rec: undefined, fci_hex: null });
assert.ok(out.includes('FID: 6F07'), out);
assert.ok(!out.includes('Size:'), out);
assert.ok(!out.includes('Record length:'), out);
assert.ok(!out.includes('Record count:'), out);
assert.ok(!out.includes('Decoded FCI'), out);
assert.strictEqual(pysimFsInfoHtml(null), '');
delete global.t;
});
test('profilerFciPreviewItems renders decoded items and degrades gracefully', () => { test('profilerFciPreviewItems renders decoded items and degrades gracefully', () => {
global.t = s => s; global.t = s => s;
assert.ok(profilerFciPreviewItems(FCP_TRANSPARENT).includes('File size: ')); assert.ok(profilerFciPreviewItems(FCP_TRANSPARENT).includes('File size: '));
+161 -2
View File
@@ -6,7 +6,7 @@ const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8'); const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) { function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{'); const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src); const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found'); if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1; let i = m.index + m[0].length - 1;
@@ -22,13 +22,18 @@ function extractFunc(src, name) {
} }
// Extract chain builder functions and dependencies // Extract chain builder functions and dependencies
const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'chainInit', 'chainRamBuildRowHex']; const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml',
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute'];
let code = ''; let code = '';
for (const f of FNS) { for (const f of FNS) {
code += extractFunc(html, f) + '\n'; code += extractFunc(html, f) + '\n';
} }
const m = html.match(/const _chains = \{\};/); const m = html.match(/const _chains = \{\};/);
if (m) code += m[0].replace(/^const /, 'var ') + '\n'; if (m) code += m[0].replace(/^const /, 'var ') + '\n';
const lc = html.match(/const RAM_LIFECYCLE = \{[\s\S]*?\n\};/);
if (lc) code += lc[0].replace(/^const /, 'var ') + '\n';
eval(code);
code += 'var _ramCardIdx = null;\nvar _ramOpLast = null;\nvar _ramExplorerData = null;\n';
eval(code); eval(code);
const els = {}; const els = {};
@@ -161,3 +166,157 @@ test('STORE DATA ram-enc P1 values 00/40/80/C0/E0', () => {
assert.ok(apdu.startsWith('80E2' + p1 + '00'), enc + ' -> P1 ' + p1); assert.ok(apdu.startsWith('80E2' + p1 + '00'), enc + ' -> P1 ' + p1);
} }
}); });
test('ramRenderExploreHtml localizes every label and button', () => {
const seen = [];
global.t = s => { seen.push(s); return 'XX' + s; };
const out = ramRenderExploreHtml(
{ appCount: 5, freeNV: 100, freeV: 50 },
[{ aid: 'A000000151000000', lifecycle: '07', privileges: '', sdAid: 'A000000151000000' }],
[{ aid: 'A1130001180001', lifecycle: '07', privileges: '80', implicitSel: '00', elfAid: 'ELF1' }],
[{ aid: 'ELF1', lifecycle: '01', version: '1.0', moduleAids: ['M1'], sdAid: null }]
);
delete global.t;
assert.ok(out.includes('XXDelete'), out);
assert.ok(out.includes('XXDelete All'), out);
assert.ok(out.includes('XXApplications:'), out);
assert.ok(out.includes('XXFree NV:'), out);
assert.ok(out.includes('XXFree Volatile:'), out);
assert.ok(out.includes('XXAID:'), out);
assert.ok(out.includes('XXLifecycle:'), out);
assert.ok(out.includes('XXPrivileges:'), out);
assert.ok(out.includes('XXSD AID:'), out);
assert.ok(out.includes('XXImplicit sel:'), out);
assert.ok(out.includes('XXVersion:'), out);
assert.ok(seen.includes('Application / Instance AID:'));
assert.ok(seen.includes('Load File AID / Package AID:'));
assert.ok(seen.includes('Executable Module AIDs / Applet Class AIDs:'));
assert.ok(!out.includes('data-l10n'), out);
});
test('ramFmtPrivileges uses the translated (none) placeholder', () => {
global.t = s => 'XX' + s;
assert.strictEqual(ramFmtPrivileges(''), 'XX(none)');
assert.strictEqual(ramFmtPrivileges('00'), 'XX(none)');
delete global.t;
});
function fakeClassList() {
const set = new Set();
return {
add: (...cs) => cs.forEach(c => set.add(c)),
remove: (...cs) => cs.forEach(c => set.delete(c)),
contains: c => set.has(c),
toggle: (c, on) => { if (on === undefined ? !set.has(c) : on) set.add(c); else set.delete(c); },
};
}
function fakeEl(id) {
return {
id,
value: '',
innerHTML: '',
textContent: '',
classList: fakeClassList(),
options: [],
appendChild(opt) { this.options.push(opt); },
};
}
function fakeRamDocument(ids) {
const els = {};
for (const id of ids) els[id] = fakeEl(id);
const sel = els['ram-card-sel'];
if (sel) {
Object.defineProperty(sel, 'innerHTML', {
get() { return this._html || ''; },
set(v) { this._html = v; this.value = ''; },
});
}
globalThis.document = {
getElementById: id => els[id] || null,
createElement: () => fakeEl('option'),
};
return els;
}
test('ramOpChanged clears the executed status only on a real op change', () => {
const els = fakeRamDocument(['ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']);
_ramOpLast = null;
els['ram-op'].value = 'explore';
ramOpChanged();
assert.ok(!els['ram-result'].classList.contains('hidden'));
els['ram-result'].classList.remove('hidden');
els['ram-steps'].classList.remove('hidden');
ramOpChanged();
assert.ok(!els['ram-result'].classList.contains('hidden'), 'same op must keep the result');
els['ram-op'].value = 'install-cap';
ramOpChanged();
assert.ok(els['ram-result'].classList.contains('hidden'));
assert.ok(els['ram-steps'].classList.contains('hidden'));
assert.ok(els['ram-explorer'].classList.contains('hidden'));
assert.ok(els['ram-progress'].classList.contains('hidden'));
assert.ok(!els['ram-install-params'].classList.contains('hidden'));
});
test('ramRender keeps the selected card preset across rebuilds', () => {
const els = fakeRamDocument(['ram-card-sel', 'ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']);
globalThis.cards = [{ name: 'A' }, { name: 'B' }, { name: 'C' }];
_ramCardIdx = null;
_ramOpLast = 'explore';
els['ram-op'].value = 'explore';
ramRender();
assert.strictEqual(els['ram-card-sel'].value, '');
els['ram-card-sel'].value = '1';
ramRender();
assert.strictEqual(els['ram-card-sel'].value, '1');
els['ram-card-sel'].value = '';
_ramCardIdx = 2;
ramRender();
assert.strictEqual(els['ram-card-sel'].value, '2');
globalThis.cards = [{ name: 'A' }];
_ramCardIdx = 2;
ramRender();
assert.strictEqual(els['ram-card-sel'].value, '');
delete globalThis.cards;
});
test('ramApplyCard remembers a valid picked preset', () => {
globalThis.cards = [{ name: 'A' }, { name: 'B' }];
let applied = null;
globalThis.cardsApply = i => { applied = i; };
_ramCardIdx = null;
ramApplyCard('1');
assert.strictEqual(_ramCardIdx, 1);
assert.strictEqual(applied, '1');
ramApplyCard('');
assert.strictEqual(_ramCardIdx, 1, 'invalid pick must not forget the preset');
delete globalThis.cards;
delete globalThis.cardsApply;
});
test('ramExecute commits the dropdown selection before running', async () => {
const els = fakeRamDocument(['ram-card-sel', 'ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']);
globalThis.cards = [{ name: 'A' }];
globalThis.getRamSpParams = () => ({ kicKey: '11', kidKey: '22' });
let explored = false;
globalThis.ramExplore = async () => { explored = true; };
globalThis.alert = () => {};
_ramCardIdx = null;
els['ram-card-sel'].value = '0';
els['ram-op'].value = 'explore';
await ramExecute();
assert.strictEqual(_ramCardIdx, 0);
assert.ok(explored);
delete globalThis.cards;
delete globalThis.getRamSpParams;
delete globalThis.ramExplore;
delete globalThis.alert;
});
test('ramCardIdxAfterRemove keeps the remembered index aligned', () => {
assert.strictEqual(ramCardIdxAfterRemove(2, 0), 1);
assert.strictEqual(ramCardIdxAfterRemove(0, 0), null);
assert.strictEqual(ramCardIdxAfterRemove(0, 2), 0);
assert.strictEqual(ramCardIdxAfterRemove(null, 1), null);
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-otaman-server" name = "pysim-otaman-server"
version = "2.0.0" version = "2.1.1"
description = "HTTP REST server wrapping pysim for the OTAMan PWA" description = "HTTP REST server wrapping pysim for the OTAMan PWA"
requires-python = ">=3.8" requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+162 -33
View File
@@ -19,7 +19,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE from osmocom.tlv import BER_TLV_IE
VERSION = '2.0.0' VERSION = '2.1.1'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -214,39 +214,143 @@ def _get_file_type(lchan, cur_file):
return None return None
def _select_with_parent(lchan, name, parent_sel, app): def _fid4(sel):
if parent_sel: """True if sel is a 4-digit hex FID."""
lchan.select(parent_sel, app) return bool(re.fullmatch(r'[0-9a-fA-F]{4}', str(sel or '')))
fcp = lchan.select(name, app)
return fcp
def _file_by_sel(parent, sel):
"""Resolve sel (FID or symbolic name, case-insensitive) among parent's direct children."""
if parent is None or not sel:
return None
s = str(sel).strip().lower()
for f in (getattr(parent, 'children', None) or {}).values():
if f.fid and f.fid.lower() == s:
return f
if f.name and f.name.lower() == s:
return f
return None
def _app_by_sel(rs, sel):
"""Resolve an ADF by AID or application name (case-insensitive)."""
if rs is None or not sel:
return None
s = str(sel).strip().lower()
for aid, adf in (rs.mf.applications or {}).items():
if aid.lower() == s or (adf.name and adf.name.lower() == s):
return adf
return None
def _find_in_tree(root, sel):
"""All model files matching sel (fid or name) below root; unique-match helper."""
s = str(sel or '').strip().lower()
found = []
seen = set()
stack = [root]
while stack:
cur = stack.pop()
if id(cur) in seen:
continue
seen.add(id(cur))
candidates = list((getattr(cur, 'children', None) or {}).values())
candidates += list((getattr(cur, 'applications', None) or {}).values())
for f in candidates:
if (f.fid and f.fid.lower() == s) or (f.name and f.name.lower() == s):
found.append(f)
stack.append(f)
return found
def _select_with_parent(lchan, name, parent_sel, app, parent_path=None, allow_probe=False):
"""Select name strictly within the requested parent.
Every model-known FID/name is resolved through the parent's children and
selected with lchan.select_file(); pySim's global selectables and its
probe_file() fallback are never used for model files, so a same-FID file
under a different parent can no longer be picked and the model is not
mutated. Model-unknown 4-hex segments (custom files) are probed only when
allow_probe is set and are detached again via the returned cleanup.
Returns (selected_file, cleanup): cleanup is None unless a probe happened;
handlers must call it in a finally block after using the selection.
"""
rs = app.rs
prev = lchan.selected_file
probes = []
parent = rs.mf
lchan.select_file(parent, app)
segs = [s for s in (parent_path or ([parent_sel] if parent_sel else [])) if s]
for seg in segs:
if str(seg).upper() in ('MF', '3F00'):
continue
f = _app_by_sel(rs, seg) or _file_by_sel(parent, seg)
if f is None and not parent_path:
matches = _find_in_tree(rs.mf, seg)
if len(matches) > 1:
raise RuntimeError('Ambiguous parent selector: %s' % seg)
if matches:
f = matches[0]
if f is None:
if allow_probe and _fid4(seg):
fid = str(seg).lower()
probes.append((parent, fid))
lchan.probe_file(fid, app)
parent = lchan.selected_file
continue
raise RuntimeError('File not found: %s' % seg)
lchan.select_file(f, app)
parent = f
target = None
if str(name).upper() in ('MF', '3F00'):
target = rs.mf
if target is None:
target = _file_by_sel(parent, name) or _app_by_sel(rs, name)
if target is None and not parent_path and not parent_sel:
matches = _find_in_tree(rs.mf, name)
if len(matches) > 1:
raise RuntimeError('Ambiguous file selector: %s' % name)
if matches:
target = matches[0]
if target is not None:
lchan.select_file(target, app)
elif allow_probe and _fid4(name):
fid = str(name).lower()
probes.append((parent, fid))
lchan.probe_file(fid, app)
else:
raise RuntimeError('File not found: %s' % name)
cleanup = None
if probes:
def cleanup():
for p, fid in reversed(probes):
try:
(getattr(p, 'children', None) or {}).pop(fid, None)
except Exception:
pass
try:
lchan.select_file(prev, app)
except Exception:
try:
lchan.select_file(rs.mf, app)
except Exception:
pass
return lchan.selected_file, cleanup
def _select_path(lchan, path, app): def _select_path(lchan, path, app):
"""Select a file described by a full path. """Select a file described by a full path.
Path is '/' separated; the first element is either 'MF' (or the MF fid Path is '/' separated; the first element is 'MF' (or '3F00'), an ADF AID,
'3F00') or an ADF AID (hex). Remaining elements are FIDs or file names. or an ADF name; remaining elements are FIDs or file names. Resolution is
pySim's lchan.select() cannot select an ADF by its raw AID (selectables are strictly parent-scoped (see _select_with_parent); unknown 4-hex segments
keyed by name/fid only), so ADF roots are resolved through rs.mf.applications. are custom files and are probed without touching the model tree.
""" """
parts = [p for p in (path or '').split('/') if p] parts = [p for p in (path or '').split('/') if p]
if not parts: if not parts:
raise RuntimeError('Empty path') raise RuntimeError('Empty path')
rs = app.rs return _select_with_parent(lchan, parts[-1], None, app, parent_path=parts[:-1], allow_probe=True)
first = parts[0]
if first.upper() in ('MF', '3F00'):
lchan.select('MF', app)
else:
aid = first.lower()
adf = rs.mf.applications.get(aid)
if not adf:
adf = next((v for k, v in rs.mf.applications.items() if k.lower() == aid), None)
if not adf:
raise RuntimeError('ADF not found: %s' % first)
lchan.select_file(adf, app)
for seg in parts[1:]:
lchan.select(seg, app)
return lchan.selected_file
def _parse_tree_output(output): def _parse_tree_output(output):
@@ -1717,6 +1821,7 @@ class PysimHandler(BaseHTTPRequestHandler):
'connected': connected, 'connected': connected,
'card_present': bool(getattr(self.server, 'card_present', False)), 'card_present': bool(getattr(self.server, 'card_present', False)),
'card_session': int(getattr(self.server, 'card_session', 0)), 'card_session': int(getattr(self.server, 'card_session', 0)),
'proactive_seq': _PROACTIVE_ENTRY_ID,
'equipping': bool(getattr(self.server, 'equipping', False)), 'equipping': bool(getattr(self.server, 'equipping', False)),
'auto_equip': bool(_AUTO_EQUIP), 'auto_equip': bool(_AUTO_EQUIP),
'card': card.name if card else None, 'card': card.name if card else None,
@@ -1960,22 +2065,25 @@ class PysimHandler(BaseHTTPRequestHandler):
fid = body.get('fid') fid = body.get('fid')
name = fid if fid else body.get('name', '') name = fid if fid else body.get('name', '')
parent_sel = body.get('parent_sel') parent_sel = body.get('parent_sel')
parent_path = body.get('parent_path')
allow_probe = bool(body.get('allow_probe'))
rs = app.rs rs = app.rs
if not rs: if not rs:
self._send_json({'error': _err('no_card_state', lang)}, 503) self._send_json({'error': _err('no_card_state', lang)}, 503)
self._log_resp({'error': _err('no_card_state', lang)}) self._log_resp({'error': _err('no_card_state', lang)})
return return
lchan = rs.lchan[0] lchan = rs.lchan[0]
cleanup = None
try: try:
_collect_apdu_times() _collect_apdu_times()
try: try:
if path: if path:
_select_path(lchan, path, app) cur, cleanup = _select_path(lchan, path, app)
else: else:
_select_with_parent(lchan, name, parent_sel, app) cur, cleanup = _select_with_parent(lchan, name, parent_sel, app, parent_path, allow_probe)
finally: finally:
apdu_times = _end_apdu_time_collection() apdu_times = _end_apdu_time_collection()
cur = lchan.selected_file cur = cur or lchan.selected_file
data = { data = {
'name': cur.name if cur else None, 'name': cur.name if cur else None,
'fid': cur.fid.upper() if cur and cur.fid else None, 'fid': cur.fid.upper() if cur and cur.fid else None,
@@ -1993,6 +2101,9 @@ class PysimHandler(BaseHTTPRequestHandler):
err = {'error': str(e), 'exists': False} err = {'error': str(e), 'exists': False}
self._send_json(err, 404) self._send_json(err, 404)
self._log_resp(err) self._log_resp(err)
finally:
if cleanup:
cleanup()
elif self.path == '/api/read': elif self.path == '/api/read':
app = self.server.app app = self.server.app
if not app: if not app:
@@ -2005,6 +2116,8 @@ class PysimHandler(BaseHTTPRequestHandler):
fid = body.get('fid') fid = body.get('fid')
name = fid if fid else body.get('name', '') name = fid if fid else body.get('name', '')
parent_sel = body.get('parent_sel') parent_sel = body.get('parent_sel')
parent_path = body.get('parent_path')
allow_probe = bool(body.get('allow_probe'))
mode = body.get('mode', 'raw') mode = body.get('mode', 'raw')
rs = app.rs rs = app.rs
if not rs: if not rs:
@@ -2012,14 +2125,15 @@ class PysimHandler(BaseHTTPRequestHandler):
self._log_resp({'error': _err('no_card_state', lang)}) self._log_resp({'error': _err('no_card_state', lang)})
return return
lchan = rs.lchan[0] lchan = rs.lchan[0]
cleanup = None
try: try:
_collect_apdu_times() _collect_apdu_times()
try: try:
sel = fid if fid else name sel = fid if fid else name
if path: if path:
_select_path(lchan, path, app) _, cleanup = _select_path(lchan, path, app)
else: else:
_select_with_parent(lchan, sel, parent_sel, app) _, cleanup = _select_with_parent(lchan, sel, parent_sel, app, parent_path, allow_probe)
ft = _get_file_type(lchan, lchan.selected_file) ft = _get_file_type(lchan, lchan.selected_file)
is_record = ft in ('linear_fixed', 'cyclic') is_record = ft in ('linear_fixed', 'cyclic')
if mode == 'decoded': if mode == 'decoded':
@@ -2073,6 +2187,9 @@ class PysimHandler(BaseHTTPRequestHandler):
err = {'success': False, 'error': str(e)} err = {'success': False, 'error': str(e)}
self._send_json(err, 500) self._send_json(err, 500)
self._log_resp(err) self._log_resp(err)
finally:
if cleanup:
cleanup()
elif self.path == '/api/write': elif self.path == '/api/write':
app = self.server.app app = self.server.app
if not app: if not app:
@@ -2086,15 +2203,18 @@ class PysimHandler(BaseHTTPRequestHandler):
fid = body.get('fid') fid = body.get('fid')
record_nr = body.get('record_nr') record_nr = body.get('record_nr')
parent_sel = body.get('parent_sel') parent_sel = body.get('parent_sel')
parent_path = body.get('parent_path')
allow_probe = bool(body.get('allow_probe'))
rs = app.rs rs = app.rs
if not rs: if not rs:
self._send_json({'error': _err('no_card_state', lang)}, 503) self._send_json({'error': _err('no_card_state', lang)}, 503)
self._log_resp({'error': _err('no_card_state', lang)}) self._log_resp({'error': _err('no_card_state', lang)})
return return
lchan = rs.lchan[0] lchan = rs.lchan[0]
cleanup = None
try: try:
sel = fid if fid else name sel = fid if fid else name
_select_with_parent(lchan, sel, parent_sel, app) _, cleanup = _select_with_parent(lchan, sel, parent_sel, app, parent_path, allow_probe)
ft = _get_file_type(lchan, lchan.selected_file) ft = _get_file_type(lchan, lchan.selected_file)
is_record = ft in ('linear_fixed', 'cyclic') is_record = ft in ('linear_fixed', 'cyclic')
if record_nr: if record_nr:
@@ -2131,6 +2251,9 @@ class PysimHandler(BaseHTTPRequestHandler):
err = {'success': False, 'error': str(e)} err = {'success': False, 'error': str(e)}
self._send_json(err, 500) self._send_json(err, 500)
self._log_resp(err) self._log_resp(err)
finally:
if cleanup:
cleanup()
elif self.path == '/api/tree': elif self.path == '/api/tree':
app = self.server.app app = self.server.app
if not app: if not app:
@@ -2143,15 +2266,18 @@ class PysimHandler(BaseHTTPRequestHandler):
name = fid if fid else body.get('name', '') name = fid if fid else body.get('name', '')
fid = body.get('fid') fid = body.get('fid')
parent_sel = body.get('parent_sel') parent_sel = body.get('parent_sel')
parent_path = body.get('parent_path')
allow_probe = bool(body.get('allow_probe'))
rs = app.rs rs = app.rs
if not rs: if not rs:
self._send_json({'error': _err('no_card_state', lang)}, 503) self._send_json({'error': _err('no_card_state', lang)}, 503)
self._log_resp({'error': _err('no_card_state', lang)}) self._log_resp({'error': _err('no_card_state', lang)})
return return
lchan = rs.lchan[0] lchan = rs.lchan[0]
cleanup = None
try: try:
sel = fid if fid else name sel = fid if fid else name
_select_with_parent(lchan, sel, parent_sel, app) _, cleanup = _select_with_parent(lchan, sel, parent_sel, app, parent_path, allow_probe)
cur = lchan.selected_file cur = lchan.selected_file
out = StringIO() out = StringIO()
old_stdout = app.stdout old_stdout = app.stdout
@@ -2184,9 +2310,12 @@ class PysimHandler(BaseHTTPRequestHandler):
sys.stderr.write('Handler error: %s\n' % e) sys.stderr.write('Handler error: %s\n' % e)
if 'Card' in str(e) or 'Transaction' in str(e) or 'Transmit' in str(e): if 'Card' in str(e) or 'Transaction' in str(e) or 'Transmit' in str(e):
_handle_card_disconnect() _handle_card_disconnect()
err = {'success': False, 'error': str(e)} err = {'success': False, 'error': str(e), 'exists': False}
self._send_json(err, 500) self._send_json(err, 500)
self._log_resp(err) self._log_resp(err)
finally:
if cleanup:
cleanup()
elif self.path == '/api/menu-select': elif self.path == '/api/menu-select':
scc = self.server.scc scc = self.server.scc
if not scc: if not scc:
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Unit tests for the parent-scoped select helpers in pysim_otaman_server.server.
The helpers must resolve every model-known file strictly within the requested
parent (no pySim global selectables, no probe_file model injection) and must
detach any model-unknown file that had to be probed for a custom file.
"""
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
PROJECTS = Path(__file__).resolve().parents[2]
PY_SIM = PROJECTS / 'pysim'
if str(PY_SIM) not in sys.path:
sys.path.insert(0, str(PY_SIM))
from pysim_otaman_server.server import (
_app_by_sel,
_fid4,
_file_by_sel,
_find_in_tree,
_select_path,
_select_with_parent,
)
class FakeFile:
def __init__(self, fid=None, name=None, parent=None, aid=None):
self.fid = fid
self.name = name
self.parent = parent
self.aid = aid
self.sfid = None
self.children = {}
def add_files(self, files):
for f in files:
f.parent = self
self.children[f.fid] = f
class FakeMF(FakeFile):
def __init__(self):
super().__init__(fid='3f00', name='MF')
self.applications = {}
class FakeLchan:
"""No .select() on purpose: any global-resolution call would fail loudly."""
def __init__(self, mf):
self.selected_file = mf
self.selects = []
self.probes = []
def select_file(self, f, app=None):
self.selected_file = f
self.selects.append(f)
def probe_file(self, fid, app=None):
self.probes.append(fid)
f = FakeFile(fid=fid, name='EF.' + fid.upper(), parent=self.selected_file)
self.selected_file.add_files([f])
self.selected_file = f
def build_model():
mf = FakeMF()
gsm = FakeFile('7f20', 'DF.GSM', mf)
mf.children['7f20'] = gsm
spn = FakeFile('6f46', 'EF.SPN', gsm)
gsm.children['6f46'] = spn
telecom = FakeFile('7f10', 'DF.TELECOM', mf)
mf.children['7f10'] = telecom
tel_ph = FakeFile('5f3a', 'DF.PHONEBOOK', telecom)
telecom.children['5f3a'] = tel_ph
usim = FakeFile(None, 'ADF.USIM', mf, aid='A0000000871002')
mf.applications['a0000000871002'] = usim
imsi = FakeFile('6f07', 'EF.IMSI', usim)
usim.children['6f07'] = imsi
usim_ph = FakeFile('5f3a', 'DF.PHONEBOOK', usim)
usim.children['5f3a'] = usim_ph
return mf, usim, usim_ph, telecom, tel_ph, gsm, spn
def setup():
mf, usim, usim_ph, telecom, tel_ph, gsm, spn = build_model()
app = SimpleNamespace(rs=SimpleNamespace(mf=mf))
lchan = FakeLchan(mf)
return app, lchan, mf, usim, usim_ph, gsm, spn
class FidHelpersTest(unittest.TestCase):
def test_fid4(self):
self.assertTrue(_fid4('6F07'))
self.assertFalse(_fid4('EF.IMSI'))
self.assertFalse(_fid4('6F0'))
def test_file_by_sel_matches_fid_and_name(self):
_, _, _, _, _, gsm, spn = setup()
self.assertIs(_file_by_sel(gsm, '6f46'), spn)
self.assertIs(_file_by_sel(gsm, 'EF.SPN'), spn)
self.assertIsNone(_file_by_sel(gsm, '6f07'))
def test_find_in_tree_reports_duplicates(self):
app, _, mf, _, _, _, _ = setup()
self.assertEqual(len(_find_in_tree(mf, '5f3a')), 2)
self.assertEqual(len(_find_in_tree(mf, 'EF.IMSI')), 1)
class ParentScopedSelectTest(unittest.TestCase):
def test_duplicate_fid_is_resolved_under_the_walked_parent(self):
app, lchan, mf, usim, usim_ph, _, _ = setup()
target, cleanup = _select_with_parent(lchan, '5f3a', None, app, parent_path=['MF', 'A0000000871002'])
self.assertIs(target, usim_ph)
self.assertIsNone(cleanup)
self.assertEqual(lchan.selects, [mf, usim, usim_ph])
self.assertEqual(lchan.probes, [])
def test_path_with_fids_selects_exactly(self):
app, lchan, mf, _, _, gsm, spn = setup()
target, cleanup = _select_path(lchan, 'MF/7F20/6F46', app)
self.assertIs(target, spn)
self.assertIsNone(cleanup)
self.assertEqual(lchan.selects, [mf, gsm, spn])
def test_path_with_aid_root_selects_application(self):
app, lchan, _, usim, _, _, _ = setup()
target, _ = _select_path(lchan, 'A0000000871002/6F07', app)
self.assertEqual(target.fid, '6f07')
self.assertEqual(lchan.selected_file.parent, usim)
def test_ambiguous_legacy_parent_selector_is_rejected(self):
app, lchan, _, _, _, _, _ = setup()
with self.assertRaisesRegex(RuntimeError, 'Ambiguous'):
_select_with_parent(lchan, '6f07', '5f3a', app)
def test_unknown_name_is_not_probed(self):
app, lchan, _, _, _, gsm, _ = setup()
with self.assertRaisesRegex(RuntimeError, 'File not found'):
_select_with_parent(lchan, 'NOSUCH', None, app, parent_path=['MF', '7F20'])
self.assertEqual(lchan.probes, [])
def test_unknown_fid_without_allow_probe_does_not_touch_the_card(self):
app, lchan, _, _, _, gsm, _ = setup()
with self.assertRaisesRegex(RuntimeError, 'File not found'):
_select_with_parent(lchan, '6f99', None, app, parent_path=['MF', '7F20'])
self.assertEqual(lchan.probes, [])
def test_allow_probe_detaches_the_temporary_file_and_restores_selection(self):
app, lchan, mf, _, _, gsm, _ = setup()
before = set(gsm.children)
target, cleanup = _select_with_parent(lchan, '6f99', None, app, parent_path=['MF', '7F20'], allow_probe=True)
self.assertEqual(lchan.probes, ['6f99'])
self.assertEqual(target.fid, '6f99')
self.assertIsNotNone(cleanup)
self.assertIn('6f99', gsm.children)
cleanup()
self.assertEqual(set(gsm.children), before)
self.assertIs(lchan.selected_file, mf)
def test_custom_path_segments_are_probed_and_detached(self):
app, lchan, mf, _, _, gsm, _ = setup()
before = set(gsm.children)
target, cleanup = _select_path(lchan, 'MF/7F20/A0B1/6F01', app)
self.assertEqual(lchan.probes, ['a0b1', '6f01'])
self.assertEqual(target.fid, '6f01')
cleanup()
self.assertEqual(set(gsm.children), before)
self.assertIs(lchan.selected_file, mf)
def test_model_known_selection_never_mutates_the_tree(self):
app, lchan, _, _, _, gsm, spn = setup()
before = {id(k): k for k in gsm.children}
_select_with_parent(lchan, '6f46', None, app, parent_path=['MF', '7F20'])
self.assertEqual({id(k): k for k in gsm.children}, before)
self.assertEqual(lchan.probes, [])
if __name__ == '__main__':
unittest.main()