Compare commits

...

10 Commits

Author SHA1 Message Date
catarrh c9ae494f62 fix: snapshot/profile scans skipped DF subtrees (parent_path off-by-one)
profilerScanCard's walkDir passed each child dir's own segment in
parent_path (dir.parentPath.concat(childSeg) as the child's parent path),
so /api/tree for every DF child walked the target as its own parent and
failed: DF.GSM, DF.TELECOM and DF.GSM-ACCESS subtrees were silently
missing from New snapshot, Profile from card and Profile from snapshot
(ADF children survived only because application names/AIDs resolve
globally).

- walkDir now stores each dir's full path and sends
  parent_path = fullPath.slice(0, -1) (omitted for MF); the child's full
  path is fullPath.concat(aid/fid/name)
- verified against the running server: the walker now enumerates 189 EFs
  and captures 92, matching the file-manager probe exactly (was 111/48)
- test: fake MF -> DF.GSM tree asserts parent_path [undefined, ['MF']]
  and that both subtrees' rules are produced; SW cache v132 -> v133.
2026-09-13 21:39:15 +03:00
catarrh 0370fa58ad release: v2.1.2
Version bumped in server.py, pyproject.toml, the PWA header and the
docs/api.md example; SW cache v131 -> v132.
2026-09-13 11:35:02 +03:00
catarrh 2213e25385 ui: generate a profile from a card snapshot
- new 'Profile from snapshot' button in the Profiles toolbar: reuses the
  snapshot picker (profilerSnapshotPickListHtml refactor) and then opens
  the same scan form as Profile from card (profilerScanOpenForm), with
  the profile name prefilled from the snapshot
- new profiler-build target 'profile-snapshot': the form's ignore list,
  mask options and FCP/FCI mode apply to profilerScanSnapshot(), which
  walks the snapshot's captured files instead of the card; uncaptured
  contents produce rules without a content check, record files map to
  exact record content, transparent files honor the masks
- profilerScanStart() pushes the generated profile and opens the editor
  like the live scan; _scanSnapshot is cleared in the finally block
- RU entry 'Профиль из снимка'; tests for the snapshot rule builder
  (exact/mask/ignore/uncaptured/record), the snapshot scan progress, the
  picker wiring and the form title, plus a structural button check;
  help EN/RU and READMEs updated; SW cache v130 -> v131.
2026-09-13 11:32:18 +03:00
catarrh bf8abbb0fd ui: descriptive check-results headers with the check type first
- new profilerResultsHeaderText() builds the header from a structured
  {kind, from, to}: profile checks read 'Profile verification results
  for: <profile> -> <card ICCID | snapshot name>', snapshot comparison
  reads 'Snapshot comparison results: <master> -> <checked>' (the arrow
  matches the one already used in the compare header)
- profilerRunProfile() stores the header and profilerRenderResultsView()
  writes it, so the header follows a language switch and the ICCID stays
  current; RU keys added ('Результаты проверки профиля:',
  'Результаты сравнения снимков:')
- tests: header builder variants + render wiring; flow tests now expect
  header objects; help EN/RU and READMEs updated; SW cache v129 -> v130.
2026-09-13 11:23:59 +03:00
catarrh a9a4d03b09 ui: label check reports with the profile, card ICCID / snapshot name
- new profilerLabelText() resolves expected/actual labels at render time;
  profilerRenderReport and fcpDiffHtml (including the decode-failure
  notes) use it, so a language switch keeps the labels localized
- Check card: reads EF.ICCID via /api/read (MF/2FE2) and labels the
  report 'expected (<profile>)' / 'actual (<card ICCID>)'; the results
  title becomes '<profile> — <ICCID>' (falls back to the plain title and
  'actual' when EF.ICCID is unreadable)
- Check card snapshot: labels 'expected (<profile>)' / 'actual
  (<snapshot name>)
- snapshot-vs-snapshot compare keeps verbatim master/check names;
  the mismatch label column widens to w-48 for prefixed labels
- tests: profilerLabelText, prefixed report/diff labels, profilerCardIccid
  decode/fallback, both check flows pass the labels; help EN/RU, READMEs
  updated; SW cache v128 -> v129.
2026-09-13 11:14:19 +03:00
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
13 changed files with 735 additions and 123 deletions
+2 -2
View File
@@ -504,7 +504,7 @@ Type a command name in the **pySim command line** input. Usage hints appear as a
Verifies that a card matches a named **profile** — an ordered set of rules describing the expected file system and, optionally, file contents. Profiles are stored in `localStorage`. Verifies that a card matches a named **profile** — an ordered set of rules describing the expected file system and, optionally, file contents. Profiles are stored in `localStorage`.
- **New profile** creates an empty ruleset; **Profile from card** scans the equipped card and generates one rule per existing file; **Import profile** loads a ruleset from JSON (the name is stored inside the file). - **New profile** creates an empty ruleset; **Profile from card** scans the equipped card and generates one rule per existing file; **Profile from snapshot** generates the same ruleset from a saved snapshot (same ignore/mask/FCP-FCI options, no card reader, name prefilled from the snapshot); **Import profile** loads a ruleset from JSON (the name is stored inside the file).
- Each profile row has **Check card ▶** (run against the equipped card), **Check card snapshot** (run offline against a saved snapshot), **Edit**, **Export**, and **Delete**. - Each profile row has **Check card ▶** (run against the equipped card), **Check card snapshot** (run offline against a saved snapshot), **Edit**, **Export**, and **Delete**.
A filesystem rule is defined by: A filesystem rule is defined by:
@@ -514,7 +514,7 @@ A filesystem rule is defined by:
- **File attributes** — file type, size, record length and record count, taken from the FCP template (any may be left unset). - **File attributes** — file type, size, record length and record count, taken from the FCP template (any may be left unset).
- **Check contents** (optional) — **Exact** hex equality, or **Mask** where `?` is a per-nibble wildcard (a mask with no `?` is a prefix match, e.g. `0891` for the IMSI MCC/MNC). Record files store a per-record list. - **Check contents** (optional) — **Exact** hex equality, or **Mask** where `?` is a per-nibble wildcard (a mask with no `?` is a prefix match, e.g. `0891` for the IMSI MCC/MNC). Record files store a per-record list.
The check report marks each verified aspect (e.g. *filetype ✓, size ✗, contents ✓*), lists mismatches as read-only monospace expected/actual fields aligned in one column, and shows a decoded per-parameter FCI comparison for FCI mismatches. Corrupt FCI data shows whatever decoded before the faulty part plus an explicit decode-failure note; record mismatches list the *matching records*. **Only mismatches** in the results header hides all passing files and keeps failures and errors only. The check report marks each verified aspect (e.g. *filetype ✓, size ✗, contents ✓*), lists mismatches as read-only monospace expected/actual fields aligned in one column, and shows a decoded per-parameter FCI comparison for FCI mismatches. Corrupt FCI data shows whatever decoded before the faulty part plus an explicit decode-failure note; record mismatches list the *matching records*. In the report the mismatch fields and FCI comparison columns are labelled `expected (profile name)` and `actual (card ICCID)` for a live check, or `actual (snapshot name)` for a snapshot check; the results header reads `Profile verification results for: <profile> → <card ICCID>` (or `… → <snapshot name>`; snapshot comparison: `Snapshot comparison results: <master> → <checked>`). **Only mismatches** in the results header hides all passing files and keeps failures and errors only.
#### “Profile from card” scan options #### “Profile from card” scan options
+2 -2
View File
@@ -478,7 +478,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
Проверка соответствия карты именованному **профилю** — упорядоченному набору правил, описывающих ожидаемую файловую систему и (опционально) содержимое файлов. Профили хранятся в `localStorage`. Проверка соответствия карты именованному **профилю** — упорядоченному набору правил, описывающих ожидаемую файловую систему и (опционально) содержимое файлов. Профили хранятся в `localStorage`.
- **Новый профиль** создаёт пустой набор правил; **Профиль с карты** сканирует подключённую карту и создаёт по правилу на каждый существующий файл; **Импорт профиля** загружает набор из JSON (имя хранится внутри файла). - **Новый профиль** создаёт пустой набор правил; **Профиль с карты** сканирует подключённую карту и создаёт по правилу на каждый существующий файл; **Профиль из снимка** создаёт тот же набор правил из сохранённого снимка (те же опции игнорирования/масок/FCP-FCI, без картридера, имя подставляется из снимка); **Импорт профиля** загружает набор из JSON (имя хранится внутри файла).
- В каждой строке профиля: **Проверить карту ▶** (на подключённой карте), **Проверить снимок карты** (offline по сохранённому снимку), **Редактировать**, **Экспорт** и **Удалить**. - В каждой строке профиля: **Проверить карту ▶** (на подключённой карте), **Проверить снимок карты** (offline по сохранённому снимку), **Редактировать**, **Экспорт** и **Удалить**.
Правило файловой системы задаётся: Правило файловой системы задаётся:
@@ -488,7 +488,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
- **Атрибуты файла** — тип, размер, длина и число записей из шаблона FCP (любое можно не задавать). - **Атрибуты файла** — тип, размер, длина и число записей из шаблона FCP (любое можно не задавать).
- **Проверка содержимого** (опционально) — **Exact** (точное равенство hex) или **Mask**, где `?` — пониббловый джокер (маска без `?` — префиксное совпадение, напр. `0891` для MCC/MNC IMSI). Для record-файлов хранится список записей. - **Проверка содержимого** (опционально) — **Exact** (точное равенство hex) или **Mask**, где `?` — пониббловый джокер (маска без `?` — префиксное совпадение, напр. `0891` для MCC/MNC IMSI). Для record-файлов хранится список записей.
Отчёт проверки помечает каждый аспект (напр. *тип файла ✓, размер ✗, содержимое ✓*), показывает расхождения как поля только для чтения (ожидаемое/фактическое в одной колонке) и декодированное сравнение параметров FCI для расхождений FCI. Повреждённые FCI показывают всё, что удалось декодировать, плюс явное сообщение об ошибке; для записей указываются *совпадающие записи*. Опция **«Только расхождения»** скрывает все совпавшие файлы, оставляя несовпадения и ошибки. Отчёт проверки помечает каждый аспект (напр. *тип файла ✓, размер ✗, содержимое ✓*), показывает расхождения как поля только для чтения (ожидаемое/фактическое в одной колонке) и декодированное сравнение параметров FCI для расхождений FCI. Повреждённые FCI показывают всё, что удалось декодировать, плюс явное сообщение об ошибке; для записей указываются *совпадающие записи*. В отчёте поля расхождений и колонки сравнения FCI подписаны `ожидалось (имя профиля)` и `фактически (ICCID карты)` для проверки карты либо `фактически (имя снимка)` для проверки снимка; в заголовке отчёта — `Результаты проверки профиля: <профиль> → <ICCID карты>` (или `… → <имя снимка>`; для сравнения снимков — `Результаты сравнения снимков: <эталон> → <проверяемый>`). Опция **«Только расхождения»** скрывает все совпавшие файлы, оставляя несовпадения и ошибки.
#### Опции сканирования «Профиль с карты» #### Опции сканирования «Профиль с карты»
+1 -1
View File
@@ -55,7 +55,7 @@ Returns server version for compatibility checking.
**Example response:** **Example response:**
```json ```json
{"version": "2.1.0"} {"version": "2.1.2"}
``` ```
### `GET /api/status` ### `GET /api/status`
+5 -4
View File
@@ -376,6 +376,7 @@
<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> — создаёт пустой набор правил, запросив имя.</li> <li><strong>Новый профиль</strong> — создаёт пустой набор правил, запросив имя.</li>
<li><strong>Профиль с карты</strong> — сканирует подключённую карту и создаёт по одному правилу на каждый существующий файл (см. ниже), затем открывает редактор.</li> <li><strong>Профиль с карты</strong> — сканирует подключённую карту и создаёт по одному правилу на каждый существующий файл (см. ниже), затем открывает редактор.</li>
<li><strong>Профиль из снимка</strong> — выбирает сохранённый снимок карты и создаёт по правилу на каждый захваченный файл с теми же опциями сканирования (см. ниже), без картридера; имя профиля подставляется из имени снимка.</li>
<li><strong>Импорт профиля</strong> — загружает набор правил из JSON-файла (имя хранится внутри JSON).</li> <li><strong>Импорт профиля</strong> — загружает набор правил из JSON-файла (имя хранится внутри JSON).</li>
<li>В каждой строке профиля показаны имя и время создания, а также действия <strong>Проверить карту ▶</strong>, <strong>Проверить снимок карты</strong>, <strong>Редактировать</strong>, <strong>Экспорт</strong> (скачать JSON) и <strong>Удалить</strong>.</li> <li>В каждой строке профиля показаны имя и время создания, а также действия <strong>Проверить карту ▶</strong>, <strong>Проверить снимок карты</strong>, <strong>Редактировать</strong>, <strong>Экспорт</strong> (скачать JSON) и <strong>Удалить</strong>.</li>
</ul> </ul>
@@ -387,9 +388,9 @@
<li><strong>Атрибуты файла</strong> — тип файла, размер, длина записи и число записей из FCP-шаблона (любой можно оставить незаданным).</li> <li><strong>Атрибуты файла</strong> — тип файла, размер, длина записи и число записей из FCP-шаблона (любой можно оставить незаданным).</li>
<li><strong>Проверить содержимое</strong> (опционально) — <strong>Точное</strong> (точное совпадение hex) или <strong>Маска</strong>, где <code class="font-mono text-sm">?</code> — шаблон на один полубайт (маска без <code class="font-mono text-sm">?</code> — совпадение префикса, например <code class="font-mono text-sm">0891</code> для MCC/MNC из IMSI). Для record-файлов хранится список по записям.</li> <li><strong>Проверить содержимое</strong> (опционально) — <strong>Точное</strong> (точное совпадение hex) или <strong>Маска</strong>, где <code class="font-mono text-sm">?</code> — шаблон на один полубайт (маска без <code class="font-mono text-sm">?</code> — совпадение префикса, например <code class="font-mono text-sm">0891</code> для MCC/MNC из IMSI). Для record-файлов хранится список по записям.</li>
</ul> </ul>
<p class="text-sm mb-3"><strong>Проверить карту</strong> выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, <em>тип файла и размер, содержимое</em> или <em>полный FCI</em>); если часть проверок прошла, а часть нет — каждый аспект помечается (<em>тип файла ✓, размер ✗, содержимое ✓</em>), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения; для расхождений FCI дополнительно показывается декодированное сравнение по параметрам (размер файла, дескриптор/структура, жизненный цикл, FID, SFI, проприетарные параметры…). Декодированный просмотр FCI также отображается рядом с полем FCI hex при редактировании правила. Если данные FCI повреждены, показывается всё, что удалось декодировать до места ошибки, вместе с явным сообщением об ошибке декодирования. Для record-файлов при расхождении содержимого добавляется пометка <em>совпадающие записи: 1-5, 7-10</em> со списком записей, которые совпали. Опция <strong>«Только расхождения»</strong> в заголовке отчёта скрывает все совпавшие файлы и оставляет только несовпадения и ошибки.</p> <p class="text-sm mb-3"><strong>Проверить карту</strong> выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, <em>тип файла и размер, содержимое</em> или <em>полный FCI</em>); если часть проверок прошла, а часть нет — каждый аспект помечается (<em>тип файла ✓, размер ✗, содержимое ✓</em>), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения; для расхождений FCI дополнительно показывается декодированное сравнение по параметрам (размер файла, дескриптор/структура, жизненный цикл, FID, SFI, проприетарные параметры…). Декодированный просмотр FCI также отображается рядом с полем FCI hex при редактировании правила. Если данные FCI повреждены, показывается всё, что удалось декодировать до места ошибки, вместе с явным сообщением об ошибке декодирования. Для record-файлов при расхождении содержимого добавляется пометка <em>совпадающие записи: 1-5, 7-10</em> со списком записей, которые совпали. В отчёте поля расхождений и колонки сравнения FCI подписаны <em>ожидалось (имя профиля)</em> и <em>фактически (ICCID карты)</em>, а в заголовке отчёта выводится <em>Результаты проверки профиля: &lt;профиль&gt; &rarr; &lt;ICCID карты&gt;</em>. Опция <strong>«Только расхождения»</strong> в заголовке отчёта скрывает все совпавшие файлы и оставляет только несовпадения и ошибки.</p>
<h4 class="font-medium mb-1">Опции сканирования &laquo;Профиль с карты&raquo;</h4> <h4 class="font-medium mb-1">Опции сканирования &laquo;Профиль с карты&raquo;</h4>
<p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает селектор <strong>&laquo;Проверка FCP/FCI&raquo;</strong> (те же три режима, по умолчанию <strong>Тип файла + размер (FCP)</strong>), применяемый ко всем создаваемым правилам, а также список <strong>&laquo;Игнорировать содержимое файлов&raquo;</strong> (все отмечены по умолчанию, кроме <code class="font-mono text-sm">EF.ARR</code>; флажок в заголовке отмечает или снимает весь список) часто перезаписываемых файлов, содержимое которых пропускается: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Ещё две отмеченные по умолчанию опции <strong>&laquo;Сравнивать первые 4 байта для&raquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> и <code class="font-mono text-sm">EF.ICCID</code> захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает <em>N / всего файлов</em> с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>&laquo;Пользовательские файлы&raquo;</strong> включаются с той же проверкой существования.</p> <p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает селектор <strong>&laquo;Проверка FCP/FCI&raquo;</strong> (те же три режима, по умолчанию <strong>Тип файла + размер (FCP)</strong>), применяемый ко всем создаваемым правилам, а также список <strong>&laquo;Игнорировать содержимое файлов&raquo;</strong> (все отмечены по умолчанию, кроме <code class="font-mono text-sm">EF.ARR</code>; флажок в заголовке отмечает или снимает весь список) часто перезаписываемых файлов, содержимое которых пропускается: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Ещё две отмеченные по умолчанию опции <strong>&laquo;Сравнивать первые 4 байта для&raquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> и <code class="font-mono text-sm">EF.ICCID</code> захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает <em>N / всего файлов</em> с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>&laquo;Пользовательские файлы&raquo;</strong> включаются с той же проверкой существования. Тот же диалог и опции использует <strong>&laquo;Профиль из снимка&raquo;</strong>: вместо карты обходятся захваченные файлы выбранного снимка; для файлов, содержимое которых не было захвачено, правило создаётся без проверки содержимого (при последующей проверке профиля они помечаются как непроверяемые).</p>
<h4 id="card-snapshots" class="font-medium mb-1">Снимки карт</h4> <h4 id="card-snapshots" class="font-medium mb-1">Снимки карт</h4>
<p class="text-sm mb-2">Представление списка имеет две вкладки &mdash; <strong>&laquo;Профили&raquo;</strong> и <strong>&laquo;Снимки карт&raquo;</strong>. Снимок карты — неизменяемая фиксация файловой системы карты: для каждого существующего файла сохраняются путь, символьное имя, тип, размер (или длина/число записей), сырой FCI из ответа SELECT и содержимое, если файл читается (без списка игнорирования и без масок). ICCID декодируется из EF.ICCID и показывается рядом с именем снимка. При сканировании измеряется время каждой команды карты (SELECT, READ BINARY, READ RECORD) от отправки до ответа; снимок хранит min/сред/max по каждому типу команд и общее время сканирования, а в представлении эти значения показываются в сводке под заголовком, время select/read — для каждого файла и время чтения — для каждой записи. Время носит информационный характер и не используется при проверках и сравнении.</p> <p class="text-sm mb-2">Представление списка имеет две вкладки &mdash; <strong>&laquo;Профили&raquo;</strong> и <strong>&laquo;Снимки карт&raquo;</strong>. Снимок карты — неизменяемая фиксация файловой системы карты: для каждого существующего файла сохраняются путь, символьное имя, тип, размер (или длина/число записей), сырой FCI из ответа SELECT и содержимое, если файл читается (без списка игнорирования и без масок). ICCID декодируется из EF.ICCID и показывается рядом с именем снимка. При сканировании измеряется время каждой команды карты (SELECT, READ BINARY, READ RECORD) от отправки до ответа; снимок хранит min/сред/max по каждому типу команд и общее время сканирования, а в представлении эти значения показываются в сводке под заголовком, время select/read — для каждого файла и время чтения — для каждой записи. Время носит информационный характер и не используется при проверках и сравнении.</p>
@@ -397,8 +398,8 @@
<li><strong>Новый снимок</strong> &mdash; запрашивает имя и сканирует карту, затем возвращает к списку.</li> <li><strong>Новый снимок</strong> &mdash; запрашивает имя и сканирует карту, затем возвращает к списку.</li>
<li><strong>Импорт снимка</strong> &mdash; загружает снимок из JSON-файла.</li> <li><strong>Импорт снимка</strong> &mdash; загружает снимок из JSON-файла.</li>
<li>В каждой строке снимка — <strong>Открыть</strong>, <strong>Экспорт</strong> и <strong>Удалить</strong>. <strong>Открыть</strong> показывает все захваченные данные только для чтения (сырой FCI с декодированным FCI, содержимое); редактируется только имя снимка.</li> <li>В каждой строке снимка — <strong>Открыть</strong>, <strong>Экспорт</strong> и <strong>Удалить</strong>. <strong>Открыть</strong> показывает все захваченные данные только для чтения (сырой FCI с декодированным FCI, содержимое); редактируется только имя снимка.</li>
<li><strong>Проверить снимок карты</strong> в строке профиля выполняет правила профиля на выбранном из списка снимке, без картридера. Отчёт такой же, как при проверке карты; файлы, содержимое которых не было захвачено при сканировании, помечаются как непроверяемые ошибки.</li> <li><strong>Проверить снимок карты</strong> в строке профиля выполняет правила профиля на выбранном из списка снимке, без картридера. Отчёт такой же, как при проверке карты: фактическая сторона подписана именем снимка (<em>фактически (имя снимка)</em>), а в заголовке — <em>Результаты проверки профиля: &lt;профиль&gt; &rarr; &lt;имя снимка&gt;</em>; файлы, содержимое которых не было захвачено при сканировании, помечаются как непроверяемые ошибки.</li>
<li><strong>Сравнить снимки</strong> сравнивает два снимка без картридера так же, как проверка профиля: выберите <em>эталонный</em> снимок и <em>снимок для проверки</em>, при необходимости включите маску первых 4 байт EF.IMSI/EF.ICCID (включена по умолчанию) и получите такой же отчёт; в этом отчёте поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual. Файлы, которые есть только в проверяемом снимке, помечаются как лишние. «К списку» возвращает на вкладку «Снимки карт».</li> <li><strong>Сравнить снимки</strong> сравнивает два снимка без картридера так же, как проверка профиля: выберите <em>эталонный</em> снимок и <em>снимок для проверки</em>, при необходимости включите маску первых 4 байт EF.IMSI/EF.ICCID (включена по умолчанию) и получите такой же отчёт; в этом отчёте заголовок — <em>Результаты сравнения снимков: &lt;эталон&gt; &rarr; &lt;проверяемый&gt;</em>, а поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual. Файлы, которые есть только в проверяемом снимке, помечаются как лишние. «К списку» возвращает на вкладку «Снимки карт».</li>
</ul> </ul>
</section> </section>
+5 -4
View File
@@ -376,6 +376,7 @@
<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>New profile</strong> — creates an empty ruleset after prompting for a name.</li> <li><strong>New profile</strong> — creates an empty ruleset after prompting for a name.</li>
<li><strong>Profile from card</strong> — scans the equipped card and generates one rule per existing file (see below), then opens the editor.</li> <li><strong>Profile from card</strong> — scans the equipped card and generates one rule per existing file (see below), then opens the editor.</li>
<li><strong>Profile from snapshot</strong> — picks a saved card snapshot and generates one rule per captured file using the same scan options (see below), without a card reader; the profile name is prefilled with the snapshot name.</li>
<li><strong>Import profile</strong> — loads a ruleset from a JSON file (the name is stored inside the JSON).</li> <li><strong>Import profile</strong> — loads a ruleset from a JSON file (the name is stored inside the JSON).</li>
<li>Each profile row shows its name and creation time, with <strong>Check card ▶</strong>, <strong>Check card snapshot</strong>, <strong>Edit</strong>, <strong>Export</strong> (download JSON), and <strong>Delete</strong> actions.</li> <li>Each profile row shows its name and creation time, with <strong>Check card ▶</strong>, <strong>Check card snapshot</strong>, <strong>Edit</strong>, <strong>Export</strong> (download JSON), and <strong>Delete</strong> actions.</li>
</ul> </ul>
@@ -387,9 +388,9 @@
<li><strong>File attributes</strong> — file type, size, record length and record count, taken from the FCP template (any may be left unset).</li> <li><strong>File attributes</strong> — file type, size, record length and record count, taken from the FCP template (any may be left unset).</li>
<li><strong>Check contents</strong> (optional) — <strong>Exact</strong> hex equality, or <strong>Mask</strong> where <code class="font-mono text-sm">?</code> is a per-nibble wildcard (a mask with no <code class="font-mono text-sm">?</code> is a prefix match, e.g. <code class="font-mono text-sm">0891</code> for the IMSI MCC/MNC). Record files store a per-record list.</li> <li><strong>Check contents</strong> (optional) — <strong>Exact</strong> hex equality, or <strong>Mask</strong> where <code class="font-mono text-sm">?</code> is a per-nibble wildcard (a mask with no <code class="font-mono text-sm">?</code> is a prefix match, e.g. <code class="font-mono text-sm">0891</code> for the IMSI MCC/MNC). Record files store a per-record list.</li>
</ul> </ul>
<p class="text-sm mb-3"><strong>Check card</strong> runs every rule against the equipped card and shows a live progress line plus a pass/fail report. Each row states exactly what was verified next to the file path (e.g. <em>filetype and size, contents</em> or <em>exact FCI</em>); when some checks pass and others fail, each aspect is marked (<em>filetype ✓, size ✗, contents ✓</em>) with the mismatches detailed below. Mismatched raw data (FCI, contents, record data) is shown as read-only monospace fields — expected above actual, aligned in the same column — for easy comparison; FCI mismatches additionally show a decoded per-parameter comparison (file size, file descriptor/structure, life cycle, FID, SFI, proprietary parameters…). A decoded FCI preview is also shown beside the FCI hex field while editing a rule. If the FCI data is corrupt, whatever was decoded before the faulty part is shown together with an explicit decode-failure note. For record files with a contents mismatch, a <em>matching records: 1-5, 7-10</em> note lists the records that did match. An <strong>Only mismatches</strong> option in the results header hides all passing files and keeps only failures and errors.</p> <p class="text-sm mb-3"><strong>Check card</strong> runs every rule against the equipped card and shows a live progress line plus a pass/fail report. Each row states exactly what was verified next to the file path (e.g. <em>filetype and size, contents</em> or <em>exact FCI</em>); when some checks pass and others fail, each aspect is marked (<em>filetype ✓, size ✗, contents ✓</em>) with the mismatches detailed below. Mismatched raw data (FCI, contents, record data) is shown as read-only monospace fields — expected above actual, aligned in the same column — for easy comparison; FCI mismatches additionally show a decoded per-parameter comparison (file size, file descriptor/structure, life cycle, FID, SFI, proprietary parameters…). A decoded FCI preview is also shown beside the FCI hex field while editing a rule. If the FCI data is corrupt, whatever was decoded before the faulty part is shown together with an explicit decode-failure note. For record files with a contents mismatch, a <em>matching records: 1-5, 7-10</em> note lists the records that did match. In the report the mismatch fields and FCI comparison columns are labelled <em>expected (profile name)</em> and <em>actual (card ICCID)</em>, and the results header reads <em>Profile verification results for: &lt;profile&gt; &rarr; &lt;card ICCID&gt;</em>. An <strong>Only mismatches</strong> option in the results header hides all passing files and keeps only failures and errors.</p>
<h4 class="font-medium mb-1">&ldquo;Profile from card&rdquo; scan options</h4> <h4 class="font-medium mb-1">&ldquo;Profile from card&rdquo; scan options</h4>
<p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>&ldquo;FCP/FCI check&rdquo;</strong> selector (the same three modes above, default <strong>Filetype + size</strong>) applied to every generated rule, plus an <strong>&ldquo;Ignore contents of files&rdquo;</strong> checklist (all checked by default except <code class="font-mono text-sm">EF.ARR</code>; the header checkbox checks or unchecks the whole list) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Two further checked-by-default options <strong>&ldquo;Match first 4 bytes for&rdquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> and <code class="font-mono text-sm">EF.ICCID</code> capture those files&rsquo; contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows <em>N / total files</em> with the current file path while scanning; during the scan the options are hidden and the buttons are locked. Rules are created only for files that actually exist on the card (a FCP template is returned); missing files are skipped. Custom files from the <strong>Custom files</strong> sub-tab are included under the same existence check.</p> <p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>&ldquo;FCP/FCI check&rdquo;</strong> selector (the same three modes above, default <strong>Filetype + size</strong>) applied to every generated rule, plus an <strong>&ldquo;Ignore contents of files&rdquo;</strong> checklist (all checked by default except <code class="font-mono text-sm">EF.ARR</code>; the header checkbox checks or unchecks the whole list) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Two further checked-by-default options <strong>&ldquo;Match first 4 bytes for&rdquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> and <code class="font-mono text-sm">EF.ICCID</code> capture those files&rsquo; contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows <em>N / total files</em> with the current file path while scanning; during the scan the options are hidden and the buttons are locked. Rules are created only for files that actually exist on the card (a FCP template is returned); missing files are skipped. Custom files from the <strong>Custom files</strong> sub-tab are included under the same existence check. The same dialog and options are used by <strong>Profile from snapshot</strong>, which walks the selected snapshot&rsquo;s captured files instead of the card; rules for files whose contents were not captured during the scan get no content check (they are reported as unverifiable when the profile is later checked).</p>
<h4 id="card-snapshots" class="font-medium mb-1">Card snapshots</h4> <h4 id="card-snapshots" class="font-medium mb-1">Card snapshots</h4>
<p class="text-sm mb-2">The list view has two tabs &mdash; <strong>Profiles</strong> and <strong>Card snapshots</strong>. A card snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name. The scan also measures each card command (SELECT, READ BINARY, READ RECORD) from command to response; the snapshot stores min/avg/max per command type and the total scan time, and the view shows these in the summary under the title plus the select/read times per file and the read time per record. Timings are informational only and are not used by checks or comparisons.</p> <p class="text-sm mb-2">The list view has two tabs &mdash; <strong>Profiles</strong> and <strong>Card snapshots</strong>. A card snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name. The scan also measures each card command (SELECT, READ BINARY, READ RECORD) from command to response; the snapshot stores min/avg/max per command type and the total scan time, and the view shows these in the summary under the title plus the select/read times per file and the read time per record. Timings are informational only and are not used by checks or comparisons.</p>
@@ -397,8 +398,8 @@
<li><strong>New snapshot</strong> &mdash; asks for a name and scans the card, then returns to the list.</li> <li><strong>New snapshot</strong> &mdash; asks for a name and scans the card, then returns to the list.</li>
<li><strong>Import snapshot</strong> &mdash; loads a snapshot from a JSON file.</li> <li><strong>Import snapshot</strong> &mdash; loads a snapshot from a JSON file.</li>
<li>Each snapshot row has <strong>Open</strong>, <strong>Export</strong>, and <strong>Delete</strong>. <strong>Open</strong> shows all captured data read-only (raw FCI with the decoded FCI, contents); only the snapshot name is editable.</li> <li>Each snapshot row has <strong>Open</strong>, <strong>Export</strong>, and <strong>Delete</strong>. <strong>Open</strong> shows all captured data read-only (raw FCI with the decoded FCI, contents); only the snapshot name is editable.</li>
<li><strong>Check card snapshot</strong> on a profile row runs the profile rules against a snapshot you pick from the list, without a card reader. The report is the same as a live check; files whose contents were not captured during the scan are reported as unverifiable errors.</li> <li><strong>Check card snapshot</strong> on a profile row runs the profile rules against a snapshot you pick from the list, without a card reader. The report is the same as a live check: the actual side is labelled with the snapshot name (<em>actual (snapshot name)</em>) and the header reads <em>Profile verification results for: &lt;profile&gt; &rarr; &lt;snapshot name&gt;</em>; files whose contents were not captured during the scan are reported as unverifiable errors.</li>
<li><strong>Compare snapshots</strong> compares two snapshots offline, exactly like a profile check: pick the <em>master</em> snapshot and the <em>snapshot to check</em>, optionally masking the first 4 bytes of EF.IMSI/EF.ICCID (on by default), and get the same pass/fail report; in that report the mismatch fields and the FCI comparison columns are labeled with the master and checked snapshot names instead of expected/actual. Files present only in the checked snapshot are reported as extra files. Back to list returns to the Card snapshots tab.</li> <li><strong>Compare snapshots</strong> compares two snapshots offline, exactly like a profile check: pick the <em>master</em> snapshot and the <em>snapshot to check</em>, optionally masking the first 4 bytes of EF.IMSI/EF.ICCID (on by default), and get the same pass/fail report; in that report the header reads <em>Snapshot comparison results: &lt;master&gt; &rarr; &lt;checked&gt;</em> and the mismatch fields and the FCI comparison columns are labeled with the master and checked snapshot names instead of expected/actual. Files present only in the checked snapshot are reported as extra files. Back to list returns to the Card snapshots tab.</li>
</ul> </ul>
</section> </section>
+300 -101
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.1.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.2</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>
@@ -749,6 +749,7 @@
<div class="flex flex-wrap gap-2 mb-3"> <div class="flex flex-wrap gap-2 mb-3">
<button onclick="profilerNew()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="New profile">New profile</button> <button onclick="profilerNew()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="New profile">New profile</button>
<button data-needs="card" onclick="profilerFromCard()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Profile from card">Profile from card</button> <button data-needs="card" onclick="profilerFromCard()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Profile from card">Profile from card</button>
<button onclick="profilerFromSnapshot()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Profile from snapshot">Profile from snapshot</button>
<button onclick="document.getElementById('profiler-import-file').click()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Import profile">Import profile</button> <button onclick="document.getElementById('profiler-import-file').click()" class="px-2.5 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Import profile">Import profile</button>
<input type="file" id="profiler-import-file" accept=".json,application/json" class="hidden" onchange="profilerImportFile(this)"> <input type="file" id="profiler-import-file" accept=".json,application/json" class="hidden" onchange="profilerImportFile(this)">
</div> </div>
@@ -4721,7 +4722,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');
} }
@@ -4806,10 +4807,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');
@@ -4817,22 +4824,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) {
@@ -4846,6 +4854,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 = '';
@@ -5060,7 +5070,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);
@@ -5084,7 +5094,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).
@@ -5108,62 +5118,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>';
@@ -5171,10 +5181,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 = [];
@@ -5204,7 +5229,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;
@@ -5229,7 +5254,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;
@@ -5244,62 +5269,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;
@@ -5309,7 +5332,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;
} }
@@ -5317,7 +5340,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);
} }
@@ -5325,12 +5348,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,
@@ -5353,26 +5376,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 {
@@ -5380,7 +5405,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();
} }
@@ -5574,7 +5599,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();
@@ -6713,6 +6745,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;
@@ -6752,17 +6788,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',
@@ -6818,7 +6872,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) {
@@ -6865,6 +6920,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>';
} }
@@ -7349,6 +7405,7 @@ let profilerEditId = null;
let profilerDraft = null; let profilerDraft = null;
let profilerResults = null; let profilerResults = null;
let profilerResultsLabels = null; let profilerResultsLabels = null;
let profilerResultsHeader = null;
let profilerMismatchOnly = false; let profilerMismatchOnly = false;
let snapshots = []; let snapshots = [];
let snapshotViewId = null; let snapshotViewId = null;
@@ -7593,7 +7650,7 @@ function profilerScanSetTarget(target) {
_scanTarget = target; _scanTarget = target;
const title = document.getElementById('profiler-scan-title'); const title = document.getElementById('profiler-scan-title');
const label = document.getElementById('profiler-scan-name-label'); const label = document.getElementById('profiler-scan-name-label');
const titleKey = target === 'snapshot' ? 'New snapshot' : 'Profile from card'; const titleKey = target === 'snapshot' ? 'New snapshot' : (target === 'profile-snapshot' ? 'Profile from snapshot' : 'Profile from card');
const labelKey = target === 'snapshot' ? 'Snapshot name' : 'Profile name'; const labelKey = target === 'snapshot' ? 'Snapshot name' : 'Profile name';
title.setAttribute('data-l10n', titleKey); title.setAttribute('data-l10n', titleKey);
title.textContent = t(titleKey); title.textContent = t(titleKey);
@@ -7613,10 +7670,15 @@ function profilerScanRefreshOptions() {
} }
function profilerFromCard() { function profilerFromCard() {
profilerScanSetTarget('profile'); profilerScanOpenForm('profile', '');
document.getElementById('profiler-scan-name').value = ''; }
document.getElementById('profiler-scan-name').readOnly = false;
document.getElementById('profiler-scan-name').classList.remove('opacity-50'); function profilerScanOpenForm(target, presetName) {
profilerScanSetTarget(target);
const nameEl = document.getElementById('profiler-scan-name');
nameEl.value = presetName || '';
nameEl.readOnly = false;
nameEl.classList.remove('opacity-50');
const ignore = document.getElementById('profiler-scan-ignore'); const ignore = document.getElementById('profiler-scan-ignore');
let html = ''; let html = '';
for (const f of PROFILER_IGNORE_FILES) { for (const f of PROFILER_IGNORE_FILES) {
@@ -7641,7 +7703,7 @@ function profilerFromCard() {
document.getElementById('profiler-scan-btn').disabled = false; document.getElementById('profiler-scan-btn').disabled = false;
document.getElementById('profiler-scan-btn').textContent = t('Scan'); document.getElementById('profiler-scan-btn').textContent = t('Scan');
document.getElementById('profiler-scan-modal').classList.remove('hidden'); document.getElementById('profiler-scan-modal').classList.remove('hidden');
document.getElementById('profiler-scan-name').focus(); nameEl.focus();
} }
function profilerScanCancel() { function profilerScanCancel() {
@@ -7707,7 +7769,14 @@ async function profilerScanStart() {
const onProgress = (done, total, path) => { const onProgress = (done, total, path) => {
progEl.textContent = done + ' / ' + total + ' ' + t('files') + (path ? ' — ' + path : ''); progEl.textContent = done + ' / ' + total + ' ' + t('files') + (path ? ' — ' + path : '');
}; };
if (_scanTarget === 'snapshot') { if (_scanTarget === 'profile-snapshot' && _scanSnapshot) {
const rules = await profilerScanSnapshot(_scanSnapshot, ignoreFids, ignoreNames, fciMode, maskFids, onProgress);
const profile = { id: profilerNewId(), name: name, created: new Date().toISOString(), rules: rules };
profiles.push(profile);
profilerSave();
profilerScanCancel();
profilerEdit(profiles.length - 1);
} else if (_scanTarget === 'snapshot') {
const timing = profilerTimingAccumulator(); const timing = profilerTimingAccumulator();
const scanStart = performance.now(); const scanStart = performance.now();
const files = await profilerScanCard(new Set(), new Set(), 'exact', onProgress, new Set(), 'snapshot', timing); const files = await profilerScanCard(new Set(), new Set(), 'exact', onProgress, new Set(), 'snapshot', timing);
@@ -7731,6 +7800,7 @@ async function profilerScanStart() {
errEl.textContent = e.message; errEl.textContent = e.message;
errEl.classList.remove('hidden'); errEl.classList.remove('hidden');
} finally { } finally {
_scanSnapshot = null;
profilerScanSetTarget(_scanTarget); profilerScanSetTarget(_scanTarget);
cancelBtn.disabled = false; cancelBtn.disabled = false;
nameEl.readOnly = false; nameEl.readOnly = false;
@@ -7750,10 +7820,12 @@ async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, ma
const files = []; const files = [];
const seen = new Set(); const seen = new Set();
// dir: { name, fid, parentSel (to select this dir), pathPrefix (rule-path segs for its children) } // dir: { name, fid, parentSel (legacy), parentPath (full path of 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; const parentPath = (dir.parentPath || []).slice(0, -1);
if (parentPath.length) body.parent_path = parentPath;
if (dir.parentSel) body.parent_sel = dir.parentSel; if (dir.parentSel) body.parent_sel = dir.parentSel;
let data; let data;
try { try {
@@ -7765,12 +7837,13 @@ async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, ma
const parentSel = (dir.name === 'MF') ? 'MF' const parentSel = (dir.name === 'MF') ? 'MF'
: (dir.name && dir.name.startsWith('ADF.')) ? dir.name : (dir.name && dir.name.startsWith('ADF.')) ? dir.name
: (dir.fid ? dir.fid : dir.name); : (dir.fid ? dir.fid : dir.name);
const childSeg = c.aid ? c.aid.toUpperCase() : (c.fid ? c.fid : c.name);
if (c.isDir) { if (c.isDir) {
// 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);
const childParentPath = (dir.parentPath || []).concat(c.aid ? c.aid.toUpperCase() : (c.fid ? c.fid : c.name)); const childDirPath = dir.parentPath.concat(childSeg);
await walkDir({ name: c.name, fid: c.fid, parentSel: parentSel, parentPath: childParentPath, pathPrefix: childPrefix }); await walkDir({ name: c.name, fid: c.fid, parentSel: parentSel, parentPath: childDirPath, 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;
@@ -7809,6 +7882,55 @@ async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, ma
return rules; return rules;
} }
let _scanSnapshot = null;
function profilerBuildFileRuleFromSnapshot(f, ignoreFids, ignoreNames, fciMode, maskFids) {
const isRecord = profilerContentKindForFileType(f.fileType) === 'record';
const rule = {
type: 'file',
path: f.path,
name: f.name || null,
fileType: f.fileType || null,
fileSize: isRecord ? null : ((f.fileSize === null || f.fileSize === undefined) ? null : f.fileSize),
recordLen: (f.recordLen === null || f.recordLen === undefined) ? null : f.recordLen,
numRecords: (f.numRecords === null || f.numRecords === undefined) ? null : f.numRecords,
fciMode: fciMode || 'type_size',
fciHex: f.fciHex || null,
content: null,
};
const seg = (f.path || '').toUpperCase().split('/').pop();
const fid = /^[0-9A-F]{4}$/.test(seg) ? seg : '';
const name = (f.name || '').toUpperCase();
if (fid && ignoreFids && ignoreFids.has(fid)) return rule;
if (name && ignoreNames && ignoreNames.has(name)) return rule;
if (!f.content) return rule;
if (f.content.kind === 'record') {
rule.content = {
mode: 'exact', kind: 'record',
records: (f.content.records || []).map(r => ({ num: r.num, data: r.data })),
};
} else if (typeof f.content.data === 'string') {
const useMask = maskFids ? maskFids.has(fid) : !!PROFILER_MASK_PREFIX4_FIDS[fid];
rule.content = useMask
? { mode: 'mask', kind: 'transparent', expected: profilerMaskPrefix4(f.content.data) }
: { mode: 'exact', kind: 'transparent', expected: f.content.data };
}
return rule;
}
async function profilerScanSnapshot(snapshot, ignoreFids, ignoreNames, fciMode, maskFids, onProgress) {
const files = (snapshot && snapshot.files) ? snapshot.files : [];
const total = files.length;
if (onProgress) onProgress(0, total, '');
const rules = [];
for (let i = 0; i < files.length; i++) {
if (onProgress) onProgress(i + 1, total, files[i].path);
rules.push(profilerBuildFileRuleFromSnapshot(files[i], ignoreFids, ignoreNames, fciMode, maskFids));
if (i % 20 === 19) await new Promise(r => setTimeout(r, 0));
}
return rules;
}
async function profilerBuildFileRule(path, c, ignoreFids, ignoreNames, fciMode, maskFids) { async function profilerBuildFileRule(path, c, ignoreFids, ignoreNames, fciMode, maskFids) {
let sel; let sel;
try { try {
@@ -8374,14 +8496,24 @@ function profilerSaveProfile() {
profilerSetView('list'); profilerSetView('list');
} }
async function profilerCheck(i) { async function profilerCardIccid() {
const p = profiles[i]; try {
await profilerRunProfile(p, null, p.name); const rd = await pysimFetch('/api/read', { path: 'MF/2FE2', mode: 'raw' });
if (rd && rd.success && rd.data) return decIccid(rd.data) || null;
} catch (e) { /* EF.ICCID not readable */ }
return null;
} }
async function profilerRunProfile(p, source, title, extraResults, labels) { async function profilerCheck(i) {
const p = profiles[i];
const iccid = await profilerCardIccid();
await profilerRunProfile(p, null, { kind: 'profile', from: p.name, to: iccid }, null,
{ expected: p.name, actual: iccid, prefix: true });
}
async function profilerRunProfile(p, source, header, extraResults, labels) {
profilerSetView('results'); profilerSetView('results');
document.getElementById('profiler-results-title').textContent = title; profilerResultsHeader = header || null;
const prog = document.getElementById('profiler-progress'); const prog = document.getElementById('profiler-progress');
document.getElementById('profiler-summary').innerHTML = ''; document.getElementById('profiler-summary').innerHTML = '';
document.getElementById('profiler-report').innerHTML = ''; document.getElementById('profiler-report').innerHTML = '';
@@ -8398,27 +8530,47 @@ async function profilerRunProfile(p, source, title, extraResults, labels) {
profilerRenderResultsView(); profilerRenderResultsView();
} }
function profilerSnapshotPickListHtml(actionHtml) {
let html = '';
for (let si = 0; si < snapshots.length; si++) {
const s = snapshots[si];
html += '<button onclick="' + actionHtml(si) + '" class="w-full text-left px-3 py-2 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-100 dark:hover:bg-slate-700">' +
'<span class="font-semibold text-gray-800 dark:text-slate-200">' + esc(s.name) + '</span>' +
'<span class="block text-gray-500 dark:text-slate-400">' + esc(s.iccid || '') + ' · ' + esc(new Date(s.created).toLocaleString()) + ' · ' + (s.files ? s.files.length : 0) + ' ' + t('files') + '</span></button>';
}
return html;
}
function profilerCheckSnapshotPick(i) { function profilerCheckSnapshotPick(i) {
if (!snapshots.length) { if (!snapshots.length) {
alert(t('No snapshots defined.')); alert(t('No snapshots defined.'));
return; return;
} }
let html = ''; document.getElementById('snapshot-pick-list').innerHTML = profilerSnapshotPickListHtml(si => 'profilerCheckSnapshot(' + i + ',' + si + ')');
for (let si = 0; si < snapshots.length; si++) {
const s = snapshots[si];
html += '<button onclick="profilerCheckSnapshot(' + i + ',' + si + ')" class="w-full text-left px-3 py-2 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-100 dark:hover:bg-slate-700">' +
'<span class="font-semibold text-gray-800 dark:text-slate-200">' + esc(s.name) + '</span>' +
'<span class="block text-gray-500 dark:text-slate-400">' + esc(s.iccid || '') + ' · ' + esc(new Date(s.created).toLocaleString()) + ' · ' + (s.files ? s.files.length : 0) + ' ' + t('files') + '</span></button>';
}
document.getElementById('snapshot-pick-list').innerHTML = html;
document.getElementById('snapshot-pick-modal').classList.remove('hidden'); document.getElementById('snapshot-pick-modal').classList.remove('hidden');
} }
function profilerFromSnapshot() {
if (!snapshots.length) {
alert(t('No snapshots defined.'));
return;
}
document.getElementById('snapshot-pick-list').innerHTML = profilerSnapshotPickListHtml(si => 'profilerScanFromSnapshot(' + si + ')');
document.getElementById('snapshot-pick-modal').classList.remove('hidden');
}
function profilerScanFromSnapshot(si) {
document.getElementById('snapshot-pick-modal').classList.add('hidden');
_scanSnapshot = snapshots[si];
profilerScanOpenForm('profile-snapshot', snapshots[si].name || '');
}
async function profilerCheckSnapshot(i, si) { async function profilerCheckSnapshot(i, si) {
document.getElementById('snapshot-pick-modal').classList.add('hidden'); document.getElementById('snapshot-pick-modal').classList.add('hidden');
const p = profiles[i]; const p = profiles[i];
const s = snapshots[si]; const s = snapshots[si];
await profilerRunProfile(p, profilerSnapshotSource(s), p.name + ' — ' + s.name); await profilerRunProfile(p, profilerSnapshotSource(s), { kind: 'profile', from: p.name, to: s.name }, null,
{ expected: p.name, actual: s.name, prefix: true });
} }
function profilerMaskFidForFile(f, maskFids) { function profilerMaskFidForFile(f, maskFids) {
@@ -8505,7 +8657,7 @@ async function snapshotCompareRun() {
}); });
document.getElementById('snapshot-compare-modal').classList.add('hidden'); document.getElementById('snapshot-compare-modal').classList.add('hidden');
const pseudo = { name: master.name + ' → ' + check.name, rules: profilerRulesFromSnapshot(master, maskFids) }; const pseudo = { name: master.name + ' → ' + check.name, rules: profilerRulesFromSnapshot(master, maskFids) };
await profilerRunProfile(pseudo, profilerSnapshotSource(check), pseudo.name, profilerExtraFileResults(master, check), await profilerRunProfile(pseudo, profilerSnapshotSource(check), { kind: 'snapshot', from: master.name, to: check.name }, profilerExtraFileResults(master, check),
{ expected: master.name, actual: check.name }); { expected: master.name, actual: check.name });
} }
@@ -8518,8 +8670,15 @@ function profilerVisibleResults(results, mismatchOnly) {
return mismatchOnly ? results.filter(r => r.status !== 'pass') : results; return mismatchOnly ? results.filter(r => r.status !== 'pass') : results;
} }
function profilerResultsHeaderText(header) {
if (!header || !header.from) return '';
const base = header.kind === 'snapshot' ? t('Snapshot comparison results:') : t('Profile verification results for:');
return base + ' ' + header.from + (header.to ? ' \u2192 ' + header.to : '');
}
function profilerRenderResultsView() { function profilerRenderResultsView() {
if (!profilerResults) return; if (!profilerResults) return;
document.getElementById('profiler-results-title').textContent = profilerResultsHeaderText(profilerResultsHeader);
let passed = 0, failed = 0, errors = 0; let passed = 0, failed = 0, errors = 0;
for (const res of profilerResults) { for (const res of profilerResults) {
if (res.status === 'pass') passed++; if (res.status === 'pass') passed++;
@@ -8952,9 +9111,14 @@ function fcpDecode(hex) {
return { ok: !error && items.length > 0, template, items, error }; return { ok: !error && items.length > 0, template, items, error };
} }
function profilerLabelText(labels, which) {
if (!labels || !labels[which]) return t(which);
return labels.prefix ? t(which) + ' (' + labels[which] + ')' : labels[which];
}
function fcpDiffHtml(expectedHex, actualHex, labels) { function fcpDiffHtml(expectedHex, actualHex, labels) {
const expLabel = (labels && labels.expected) ? labels.expected : t('expected'); const expLabel = profilerLabelText(labels, 'expected');
const actLabel = (labels && labels.actual) ? labels.actual : t('actual'); const actLabel = profilerLabelText(labels, 'actual');
const e = fcpDecode(expectedHex); const e = fcpDecode(expectedHex);
const a = fcpDecode(actualHex); const a = fcpDecode(actualHex);
const eBad = !!e.error, aBad = !!a.error; const eBad = !!e.error, aBad = !!a.error;
@@ -8985,8 +9149,8 @@ function fcpDiffHtml(expectedHex, actualHex, labels) {
} }
html += '</tbody></table>'; html += '</tbody></table>';
} }
if (eBad) html += '<div class="text-xs text-red-600 mt-0.5 pl-2">' + esc(t('expected')) + ': ' + esc(e.error) + '</div>'; if (eBad) html += '<div class="text-xs text-red-600 mt-0.5 pl-2">' + esc(expLabel) + ': ' + esc(e.error) + '</div>';
if (aBad) html += '<div class="text-xs text-red-600 mt-0.5 pl-2">' + esc(t('actual')) + ': ' + esc(a.error) + '</div>'; if (aBad) html += '<div class="text-xs text-red-600 mt-0.5 pl-2">' + esc(actLabel) + ': ' + esc(a.error) + '</div>';
return html; return html;
} }
@@ -9015,9 +9179,9 @@ function profilerFciInput(i, value) {
} }
function profilerRenderReport(results, labels) { function profilerRenderReport(results, labels) {
const expLabel = (labels && labels.expected) ? labels.expected : t('expected'); const expLabel = profilerLabelText(labels, 'expected');
const actLabel = (labels && labels.actual) ? labels.actual : t('actual'); const actLabel = profilerLabelText(labels, 'actual');
const labelCls = 'text-xs text-gray-500 dark:text-slate-400 text-right shrink-0 ' + (labels ? 'w-40 break-all' : 'w-24'); const labelCls = 'text-xs text-gray-500 dark:text-slate-400 text-right shrink-0 ' + (labels ? ((labels.prefix ? 'w-48' : 'w-40') + ' break-all') : 'w-24');
let html = ''; let html = '';
for (const r of results) { for (const r of results) {
const color = r.status === 'pass' ? 'text-emerald-600' : (r.status === 'fail' ? 'text-red-600' : 'text-yellow-600'); const color = r.status === 'pass' ? 'text-emerald-600' : (r.status === 'fail' ? 'text-red-600' : 'text-yellow-600');
@@ -9175,6 +9339,40 @@ 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:': 'Установка не удалась на шаге:',
'Profile from snapshot': 'Профиль из снимка',
'Profile verification results for:': 'Результаты проверки профиля:',
'Snapshot comparison results:': 'Результаты сравнения снимков:',
'RAM operations perform atomic GlobalPlatform commands over SCP80. Card keys are taken from the saved preset.': 'RAM-операции выполняют атомарные команды GlobalPlatform через SCP80. Ключи карты берутся из сохранённого пресета.',
'No cards defined.': 'Карты не заданы.', 'No cards defined.': 'Карты не заданы.',
'Card presets': 'Выбор карты', 'Card presets': 'Выбор карты',
'Card preset': 'Пресет карты', 'Card preset': 'Пресет карты',
@@ -9443,6 +9641,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-v123'; const CACHE = 'otaman-v133';
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));
});
+7
View File
@@ -81,3 +81,10 @@ test('file manager shows FCI info and keeps the selection in state, not the DOM'
assert.ok(html.includes('pysimFsSelected = name;')); assert.ok(html.includes('pysimFsSelected = name;'));
assert.ok(!html.includes('pysimFsSelect()')); assert.ok(!html.includes('pysimFsSelect()'));
}); });
test('profile list has a Profile from snapshot button', () => {
assert.match(html, /data-l10n="Profile from snapshot">Profile from snapshot</);
assert.ok(html.includes('onclick="profilerFromSnapshot()"'));
assert.ok(html.includes('function profilerScanFromSnapshot(si)'));
assert.ok(html.includes('function profilerBuildFileRuleFromSnapshot('));
});
+205 -2
View File
@@ -21,14 +21,18 @@ 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', 'pysimFsInfoHtml']; 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', 'profilerLabelText', 'profilerResultsHeaderText', 'profilerRenderResultsView', 'profilerBuildFileRuleFromSnapshot', 'profilerSnapshotPickListHtml', 'profilerScanSetTarget'];
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';
code += extractFunc(html, 'profilerRunRule', true) + '\n'; code += extractFunc(html, 'profilerRunRule', true) + '\n';
code += extractFunc(html, 'profilerScanCard', true) + '\n'; code += extractFunc(html, 'profilerScanCard', true) + '\n';
code += extractFunc(html, 'profilerBuildSnapshotFile', true) + '\n'; code += extractFunc(html, 'profilerBuildSnapshotFile', true) + '\n';
code += "var _scanTarget = 'profile';\n"; code += extractFunc(html, 'profilerCardIccid', true) + '\n';
code += extractFunc(html, 'profilerCheck', true) + '\n';
code += extractFunc(html, 'profilerCheckSnapshot', true) + '\n';
code += extractFunc(html, 'profilerScanSnapshot', true) + '\n';
code += "var _scanTarget = 'profile';\nvar profilerResults = null;\nvar profilerResultsHeader = null;\nvar profilerMismatchOnly = false;\n";
code += html.match(/const PROFILER_MASK_PREFIX4_FIDS = \{[\s\S]*?\n\};/)[0] + '\n'; code += html.match(/const PROFILER_MASK_PREFIX4_FIDS = \{[\s\S]*?\n\};/)[0] + '\n';
eval(code); eval(code);
@@ -943,6 +947,32 @@ test('profilerScanCard snapshot mode builds snapshot entries with ICCID', async
delete global.pysimCustomFiles; delete global.pysimCustomFiles;
}); });
test('profilerScanCard walks nested dirs with their parent path, not their own segment', async () => {
global.pysimCustomFiles = [];
const treeCalls = [];
global.pysimFetch = async (path, body) => {
if (path === '/api/tree') {
treeCalls.push(body);
if (body.name === 'MF') return { exists: true, name: 'MF', children: [
{ name: 'EF.DIR', fid: '2f00', isDir: false },
{ name: 'DF.GSM', fid: '7f20', isDir: true },
] };
if (body.name === 'DF.GSM') return { exists: true, name: 'DF.GSM', children: [
{ name: 'EF.ADN', fid: '6f3a', isDir: false },
] };
throw new Error('unexpected tree ' + body.name);
}
if (path === '/api/select') return { name: 'X', fid: '0000', file_type: 'transparent', file_size: 1, record_len: null, num_of_rec: null, exists: true };
if (path === '/api/read') return { success: true, data: 'AA' };
throw new Error('unexpected ' + path);
};
const files = await profilerScanCard(new Set(), new Set(), 'type', undefined, new Set(), 'snapshot');
// MF root has no parent; DF.GSM must be looked up under MF, not under itself
assert.deepStrictEqual(treeCalls.map(b => b.parent_path), [undefined, ['MF']]);
assert.deepStrictEqual(files.map(f => f.path).sort(), ['MF/2F00', 'MF/7F20/6F3A']);
delete global.pysimCustomFiles;
});
test('profilerListSwitch toggles the profiles/snapshots tabs', () => { test('profilerListSwitch toggles the profiles/snapshots tabs', () => {
const mkBtn = tab => ({ const mkBtn = tab => ({
dataset: { listTab: tab }, dataset: { listTab: tab },
@@ -1303,3 +1333,176 @@ test('fcpDiffHtml headers use custom labels', () => {
assert.ok(diff.includes('>Candidate<'), diff); assert.ok(diff.includes('>Candidate<'), diff);
delete global.t; delete global.t;
}); });
test('profilerLabelText builds default, verbatim and prefixed labels', () => {
global.t = s => 't:' + s;
assert.strictEqual(profilerLabelText(null, 'expected'), 't:expected');
assert.strictEqual(profilerLabelText({ expected: 'A', actual: 'B' }, 'expected'), 'A');
assert.strictEqual(profilerLabelText({ expected: 'A', actual: 'B' }, 'actual'), 'B');
assert.strictEqual(profilerLabelText({ expected: 'A', actual: 'B', prefix: true }, 'actual'), 't:actual (B)');
assert.strictEqual(profilerLabelText({ expected: '', prefix: true }, 'expected'), 't:expected');
delete global.t;
});
test('profilerRenderReport prefixes names in expected/actual labels', () => {
global.t = s => s;
global.pysimCustomFiles = [];
const html = profilerRenderReport([{
path: 'MF/6F3A', status: 'fail',
checks: [{ label: 'fileSize', expected: 4, actual: 9, ok: false }],
}], { expected: 'My profile', actual: 'Snap X', prefix: true });
assert.ok(html.includes('expected (My profile)'), html);
assert.ok(html.includes('actual (Snap X)'), html);
delete global.t;
delete global.pysimCustomFiles;
});
test('fcpDiffHtml uses prefixed labels in headers and decode notes', () => {
global.t = s => s;
const diff = fcpDiffHtml(FCP_TRANSPARENT, '62128002000A8202412183026F078A0105880110', { expected: 'Prof', actual: 'Snap', prefix: true });
assert.ok(diff.includes('expected (Prof)'), diff);
assert.ok(diff.includes('actual (Snap)'), diff);
const bad = fcpDiffHtml('not hex', FCP_TRANSPARENT, { expected: 'Prof', actual: 'Snap', prefix: true });
assert.ok(bad.includes('expected (Prof)'), bad);
delete global.t;
});
test('profilerCardIccid reads and decodes MF/2FE2, null otherwise', async () => {
let body = null;
global.pysimFetch = async (path, b) => { body = [path, b]; return { success: true, data: '98103254769810325476' }; };
assert.strictEqual(await profilerCardIccid(), '89012345678901234567');
assert.deepStrictEqual(body, ['/api/read', { path: 'MF/2FE2', mode: 'raw' }]);
global.pysimFetch = async () => ({ success: false });
assert.strictEqual(await profilerCardIccid(), null);
global.pysimFetch = async () => { throw new Error('offline'); };
assert.strictEqual(await profilerCardIccid(), null);
delete global.pysimFetch;
});
test('profilerCheck titles with the card ICCID and passes prefixed labels', async () => {
global.t = s => s;
global.profiles = [{ name: 'MyProfile', rules: [] }];
global.pysimFetch = async () => ({ success: true, data: '98103254769810325476' });
let captured = null;
global.profilerRunProfile = async (...args) => { captured = args; };
await profilerCheck(0);
assert.deepStrictEqual(captured[2], { kind: 'profile', from: 'MyProfile', to: '89012345678901234567' });
assert.deepStrictEqual(captured[4], { expected: 'MyProfile', actual: '89012345678901234567', prefix: true });
delete global.profiles; delete global.pysimFetch; delete global.profilerRunProfile; delete global.t;
});
test('profilerCheckSnapshot passes the snapshot name as the actual label', async () => {
global.t = s => s;
global.profiles = [{ name: 'Prof', rules: [] }];
global.snapshots = [{ name: 'SnapX', files: [] }];
global.document = { getElementById: () => ({ classList: { add() {} } }) };
let captured = null;
global.profilerRunProfile = async (...args) => { captured = args; };
await profilerCheckSnapshot(0, 0);
assert.deepStrictEqual(captured[2], { kind: 'profile', from: 'Prof', to: 'SnapX' });
assert.deepStrictEqual(captured[4], { expected: 'Prof', actual: 'SnapX', prefix: true });
delete global.profiles; delete global.snapshots; delete global.profilerRunProfile; delete global.t;
});
test('profilerResultsHeaderText builds profile and snapshot headers', () => {
global.t = s => 't:' + s;
assert.strictEqual(profilerResultsHeaderText({ kind: 'profile', from: 'Prof', to: '8901' }), 't:Profile verification results for: Prof \u2192 8901');
assert.strictEqual(profilerResultsHeaderText({ kind: 'snapshot', from: 'Snap1', to: 'Snap2' }), 't:Snapshot comparison results: Snap1 \u2192 Snap2');
assert.strictEqual(profilerResultsHeaderText({ kind: 'profile', from: 'Prof', to: null }), 't:Profile verification results for: Prof');
assert.strictEqual(profilerResultsHeaderText(null), '');
delete global.t;
});
test('profilerRenderResultsView writes the built header into the title', () => {
global.t = s => s;
profilerResults = [];
profilerResultsHeader = { kind: 'profile', from: 'Prof', to: 'Snap' };
const els = {};
for (const id of ['profiler-summary', 'profiler-report', 'profiler-results-title']) els[id] = { innerHTML: '', textContent: '' };
global.document = { getElementById: id => els[id] || null };
profilerRenderResultsView();
assert.strictEqual(els['profiler-results-title'].textContent, 'Profile verification results for: Prof \u2192 Snap');
profilerResultsHeader = { kind: 'snapshot', from: 'A', to: 'B' };
profilerRenderResultsView();
assert.strictEqual(els['profiler-results-title'].textContent, 'Snapshot comparison results: A \u2192 B');
profilerResults = null;
profilerResultsHeader = null;
delete global.t;
});
test('profilerBuildFileRuleFromSnapshot builds a rule with exact contents', () => {
const f = { path: 'MF/7F10/6F3A', name: 'EF.ADN', fileType: 'transparent', fileSize: 4, recordLen: null, numRecords: null, fciHex: '620B', content: { kind: 'transparent', data: 'AABBCCDD' } };
const rule = profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(), 'type_size', new Set());
assert.strictEqual(rule.path, 'MF/7F10/6F3A');
assert.strictEqual(rule.name, 'EF.ADN');
assert.strictEqual(rule.fileType, 'transparent');
assert.strictEqual(rule.fileSize, 4);
assert.strictEqual(rule.fciMode, 'type_size');
assert.strictEqual(rule.fciHex, '620B');
assert.deepStrictEqual(rule.content, { mode: 'exact', kind: 'transparent', expected: 'AABBCCDD' });
});
test('profilerBuildFileRuleFromSnapshot masks the first 4 bytes on request', () => {
const f = { path: 'MF/6F07', name: 'EF.IMSI', fileType: 'transparent', fileSize: 9, content: { kind: 'transparent', data: '0891101234567890' } };
assert.deepStrictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(), 'type', new Set(['6F07'])).content,
{ mode: 'mask', kind: 'transparent', expected: '08911012????????' });
assert.strictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(), 'type', new Set()).content.mode, 'exact');
});
test('profilerBuildFileRuleFromSnapshot honors the ignore list by FID and name', () => {
const f = { path: 'MF/7F20/6F52', name: 'EF.KcGPRS', fileType: 'transparent', fileSize: 9, content: { kind: 'transparent', data: 'AABB' } };
assert.strictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(['6F52']), new Set(), 'type_size', new Set()).content, null);
assert.strictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(['EF.KCGPRS']), 'type_size', new Set()).content, null);
});
test('profilerBuildFileRuleFromSnapshot keeps uncaptured and record contents', () => {
const noContent = { path: 'MF/6F07', name: 'EF.IMSI', fileType: 'transparent', fileSize: 9, content: null };
assert.strictEqual(profilerBuildFileRuleFromSnapshot(noContent, new Set(), new Set(), 'type_size', new Set()).content, null);
const records = { path: 'MF/7F10/6F3A', name: 'EF.ADN', fileType: 'linear_fixed', fileSize: 60, recordLen: 30, numRecords: 2, content: { kind: 'record', records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }] } };
const rule = profilerBuildFileRuleFromSnapshot(records, new Set(), new Set(), 'exact', new Set());
assert.strictEqual(rule.fileSize, null);
assert.strictEqual(rule.recordLen, 30);
assert.strictEqual(rule.numRecords, 2);
assert.deepStrictEqual(rule.content, { mode: 'exact', kind: 'record', records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }] });
});
test('profilerScanSnapshot walks snapshot files with progress', async () => {
const snapshot = { files: [
{ path: 'MF/6F07', name: 'EF.IMSI', fileType: 'transparent', content: null },
{ path: 'MF/2FE2', name: 'EF.ICCID', fileType: 'transparent', content: { kind: 'transparent', data: '9807' } },
] };
const progress = [];
const rules = await profilerScanSnapshot(snapshot, new Set(), new Set(), 'type_size', new Set(), (d, tt, p) => progress.push([d, tt, p]));
assert.strictEqual(rules.length, 2);
assert.deepStrictEqual(rules.map(r => r.path), ['MF/6F07', 'MF/2FE2']);
assert.deepStrictEqual(progress, [[0, 2, ''], [1, 2, 'MF/6F07'], [2, 2, 'MF/2FE2']]);
});
test('profilerSnapshotPickListHtml wires the chosen action per snapshot', () => {
global.t = s => s;
global.snapshots = [
{ name: 'SnapA', iccid: '8901', created: '2026-01-01T00:00:00Z', files: [{}, {}] },
{ name: 'SnapB', iccid: '', created: '2026-01-02T00:00:00Z', files: [] },
];
const html = profilerSnapshotPickListHtml(si => 'profilerScanFromSnapshot(' + si + ')');
assert.ok(html.includes('onclick="profilerScanFromSnapshot(0)"'), html);
assert.ok(html.includes('onclick="profilerScanFromSnapshot(1)"'), html);
assert.ok(html.includes('SnapA'), html);
assert.ok(html.includes('2 files'), html);
delete global.snapshots;
delete global.t;
});
test('profilerScanSetTarget labels the snapshot-sourced profile form', () => {
global.t = s => s;
const els = {};
for (const id of ['profiler-scan-title', 'profiler-scan-name-label', 'profiler-scan-options']) {
els[id] = { textContent: '', attrs: {}, classList: { set: new Set(), toggle(c, on) { if (on) this.set.add(c); else this.set.delete(c); }, contains(c) { return this.set.has(c); } }, setAttribute(k, v) { this.attrs[k] = v; } };
}
global.document = { getElementById: id => els[id] || null };
profilerScanSetTarget('profile-snapshot');
assert.strictEqual(els['profiler-scan-title'].textContent, 'Profile from snapshot');
assert.strictEqual(els['profiler-scan-name-label'].textContent, 'Profile name');
assert.ok(!els['profiler-scan-options'].classList.contains('hidden'));
delete global.t;
});
+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.1.0" version = "2.1.2"
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.
+2 -1
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.1.0' VERSION = '2.1.2'
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
@@ -1821,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,