Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4b64788b0 | ||
|
|
9db3fd4262 | ||
|
|
8da0f29025 | ||
|
|
5181cdf1c4 | ||
|
|
a32e9b4939 | ||
|
|
26ffe6de14 | ||
|
|
2115bdb6dd | ||
|
|
71f1c493db | ||
|
|
ae572d67a7 | ||
|
|
0a686fd3e5 | ||
|
|
33f0ee3449 | ||
|
|
215d0a0ba0 | ||
|
|
8f63368008 | ||
|
|
29d762a662 | ||
|
|
eeea7ca509 | ||
|
|
ad4289eabe | ||
|
|
04859051d3 | ||
|
|
2154912d2b | ||
|
|
6750f72cd5 | ||
|
|
465a43eef2 | ||
|
|
fdbff77188 | ||
|
|
1f8fa54c49 | ||
|
|
98926833f7 | ||
|
|
4fd2efe77c | ||
|
|
4a4232de5c | ||
|
|
cc7c23a9c7 | ||
|
|
8fdf1ddff9 | ||
|
|
e2d5d720f7 | ||
|
|
627198e57b | ||
|
|
77fbed80ce | ||
|
|
d1067e3e72 | ||
|
|
01e50d841f | ||
|
|
3878156646 |
No files matched your search
@@ -0,0 +1,37 @@
|
||||
name: deploy
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Show toolchain
|
||||
run: node -v && npm -v
|
||||
|
||||
- name: Configure SSH
|
||||
env:
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
MX_HOST_KEY: ${{ secrets.MX_HOST_KEY }}
|
||||
run: |
|
||||
install -m 700 -d ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
|
||||
printf '%s\n' "$MX_HOST_KEY" > ~/.ssh/known_hosts
|
||||
chmod 600 ~/.ssh/id_ed25519 ~/.ssh/known_hosts
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build:prod
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REL="$(date -u +%Y%m%dT%H%M%SZ)-$(printf %s "$GITHUB_SHA" | cut -c1-7)"
|
||||
rsync -a --exclude=node_modules --exclude=.git \
|
||||
--link-dest=/var/www/simple/current \
|
||||
frontend/ deploy@10.8.0.1:/var/www/simple/releases/"$REL"/
|
||||
ssh deploy@10.8.0.1 "deploy-site simple $REL"
|
||||
@@ -9,6 +9,70 @@ every change carries a descriptive commit message.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [3.40.4] — 2026-11-04
|
||||
|
||||
### Added
|
||||
|
||||
- **Windows guard in the helper scripts.** `setup.sh`, `start.sh` and
|
||||
`update.sh` now detect a Windows-hosted bash (WSL via `/proc/version`, and
|
||||
Git Bash / MSYS2 / MinGW / Cygwin via `uname -s`) and exit with a clear
|
||||
error pointing at the matching `.bat` script, instead of failing deep in the
|
||||
venv/PC-SC logic. Under WSL the Linux guest cannot reach the Windows PC/SC
|
||||
readers, and a mingw/msys shell uses the wrong (`bin/`) venv layout; users
|
||||
who deliberately want the WSL/Cygwin flow can run the underlying commands
|
||||
directly, without the helper scripts.
|
||||
|
||||
## [3.40.3] — 2026-10-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Equipped card not always auto-selected in SCP80/RAM.** The ICCID-based
|
||||
preset auto-selection latched its "already handled" guard even when no
|
||||
matching preset was found, so a card equipped before `/api/presets` had
|
||||
loaded (the connect-time race) never got auto-selected — navigating to
|
||||
SCP80/RAM showed no preset until the card was re-inserted. The guard is
|
||||
now latched only after a successful selection, and `cardsFetch` re-runs
|
||||
the auto-selection once the store arrives.
|
||||
|
||||
## [3.40.2] — 2026-10-26
|
||||
|
||||
### Added
|
||||
|
||||
- **File filter: reset (✕) button.** A red ✕ to the right of the filter
|
||||
selector appears whenever the filter is not "All" and resets it to "All"
|
||||
in one click.
|
||||
|
||||
## [3.40.1] — 2026-10-26
|
||||
|
||||
### Changed
|
||||
|
||||
- **File filter: drop the "SMS-related" / "CB-related" categories.** Those
|
||||
files are service-declared and already reachable through their per-service
|
||||
filter options, which carry the per-spec service names ("Short Message
|
||||
Storage (SMS)", "SMS Parameters (SMSP)", "Short Message Status Reports
|
||||
(SMSR)", "Cell Broadcast Message Identifier (CBMI)", "Cell Broadcast
|
||||
Message Identifier Ranges (CBMIR)").
|
||||
|
||||
## [3.40.0] — 2026-10-26
|
||||
|
||||
### Added
|
||||
|
||||
- **File-tree filter** (the file manager gains a **Filter** dropdown, next to
|
||||
Sort). A collapsed checkbox multiselect with: **All** (default), **Custom
|
||||
files**, **Identifiers** (ICCID/IMSI/MSISDN/IMPI/IMPU/SUPI_NAI/GID1/GID2),
|
||||
**PLMN selection** / **Provider names** / **WLAN/I-WLAN** / **SMS-related** /
|
||||
**CB-related** (cell broadcast), and one option per file-backed service of
|
||||
the cross-reference. When a filter is active, directories that are not on
|
||||
the path of the selected files are hidden; the button turns blue to stand
|
||||
out.
|
||||
- **Expand all / Collapse all** buttons beside the sort pills; **Expand all**
|
||||
walks the tree loading children on demand and skips directories the probe
|
||||
found absent, and selecting any filter other than "All" auto-expands the
|
||||
tree so the matched files are visible.
|
||||
- **"To EF filter"** cross-link: each file-backed service row in the
|
||||
SST/UST/IST/EST decoded editors carries a compact glyph that narrows the
|
||||
file-tree filter to exactly that service's files.
|
||||
|
||||
## [3.39.0] — 2026-10-26
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
# SIMple — a UICC workbench: APDU · OTA · STK · profiling
|
||||
# SIMple — all-in-one
|
||||
|
||||
English | [Русский](README_RUS.md)
|
||||
|
||||
> Formerly known as OTAMan — renamed to SIMple on 2026-09-19.
|
||||
|
||||
📢 Project news and updates: [Telegram channel](https://t.me/youeyeseesee)
|
||||
📢 Project news and updates: [website](https://sim-tools.ru) · [Telegram channel](https://t.me/youeyeseesee)
|
||||
|
||||
SIMple is a workbench for UICC/SIM card specialists: an offline HTML/JS PWA plus a local card server ([`pysim-simple-server`](pysim_simple_server/), a pySim/PC-SC bridge that also serves the PWA). It covers the full lab cycle:
|
||||
SIMple is an all-in-one toolkit for UICC/SIM card specialists: an offline HTML/JS frontend (PWA — a Progressive Web App) and a local card server ([`pysim-simple-server`](pysim_simple_server/), a pySim/PC-SC wrapper). It packs a wide set of card tools:
|
||||
|
||||
- **APDU workbench** — SIM/USIM RFM and Expanded Script builders (TS 102 226), GlobalPlatform RAM commands, a C-APDU/R-APDU parser and a response decoder (ISO 7816 / TS 102 221 / GP);
|
||||
- **OTA lab** — SCP80 secured packets (TS 102 225) and Java Card RAM installation, plus the terminal side of HTTP OTA (SCP81): BIP channel emulation, a PSK TLS administration server and APDU script execution;
|
||||
- **Phone/CAT simulator** — SIM Toolkit menu browser, proactive command / event handling, TERMINAL PROFILE editing and the C-AT dialogue log;
|
||||
- **Card profiler** — filesystem capture (profiles and immutable snapshots with timings), exact snapshot comparison and FCP/FCI decoding.
|
||||
- **File manager** — browse and edit files, including in decoded form, with FCP/FCI and access-rule decoding;
|
||||
- **APDU builder and parser** — SIM/USIM RFM, RAM in the compact format, Expanded Format Scripts (TS 102 226), a C-APDU/R-APDU parser and a response decoder (ISO 7816 / TS 102 221 / GP);
|
||||
- **OTA lab** — TS 102 225 / GSM 03.48 secured packets (SCP80) for sending through a card reader, and HTTP OTA (SCP81): BIP channel emulation and a PSK-TLS server for working through a card reader;
|
||||
- **Simulator** — an STK menu browser, proactive command and event handling, TERMINAL PROFILE editing, and test scripts for checking any scenario;
|
||||
- **Profiler** — filesystem snapshot capture, comparing snapshots to each other and to the rule sets a profile must satisfy.
|
||||
|
||||
**Demo:** [simple.atroshin.ru](https://simple.atroshin.ru) — the PWA alone, for experimenting. Install the server (below) for card-reader functions.
|
||||
**Demo:** [simsimple.ru](https://simsimple.ru) — the frontend alone, for experimenting. Install the server (below) for card-reader functions.
|
||||
|
||||

|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Python 3.9+** with `pip`, and **Git** — the app runs on 3.9+, but the **SCP81 TLS-PSK listener needs Python 3.13+** (`ssl`'s PSK callbacks).
|
||||
- **Python venv support** — `setup.sh` creates `.venv`; if that fails with `ensurepip is not available` (Debian/Ubuntu ship the venv module separately), install it (`sudo apt install python3-venv`) and run `./setup.sh` again. An incomplete `.venv` left by the failed run is removed automatically.
|
||||
- **Smart card reader** (PC/SC or serial/FTDI) — PC/SC is preferable (`pcsc-lite` + `ccid` on Linux). `pyscard` has no Linux wheels, so it builds from source: install the PC/SC header package too (`libpcsclite-dev` on Debian/Ubuntu, `pcsc-lite-devel` on Fedora; on Arch `pcsclite` includes them).
|
||||
- **Windows** — use **Python 3.13 (strongly recommended)**: `pyscard` ships precompiled wheels for it, so nothing needs compiling; on **3.14** the `pyscard` and some pysim dependency wheels are missing and build from source (needs MSVC C++ Build Tools).
|
||||
|
||||
## Quick start
|
||||
|
||||
**PWA only (client-side tools):** open `frontend/index.html` in any browser, or serve `frontend/` with any static server. No Python required.
|
||||
|
||||
**Full (PWA + card server)** — two ways to get it:
|
||||
**Install the server** — two ways to get it:
|
||||
|
||||
**1. Download the release package (recommended).** From the
|
||||
[Releases](https://github.com/anttro/simple/releases) page take the latest package
|
||||
@@ -25,18 +35,14 @@ SIMple is a workbench for UICC/SIM card specialists: an offline HTML/JS PWA plus
|
||||
|
||||
```sh
|
||||
./setup.sh # or setup.bat on Windows — creates .venv, installs pysim + server
|
||||
./start.sh # or start.bat — starts the server (it serves the PWA too)
|
||||
./start.sh # or start.bat — starts the server (it serves the frontend too)
|
||||
```
|
||||
|
||||
The package is built from the tagged source — no git history, no development files.
|
||||
Git is still required: `setup` installs **pysim** from its upstream repository
|
||||
(nothing from pysim or its dependencies is bundled).
|
||||
Git is still required: `setup` installs **pysim** from its upstream repository.
|
||||
|
||||
**Updates are fast:** `./update.sh` (or `update.bat`) downloads the latest release
|
||||
package, replaces the application files in place and re-installs the app into the
|
||||
existing `.venv` — pysim and its prerequisites are kept, so nothing is rebuilt.
|
||||
`./update.sh --check` reports the current and latest versions, `--version X.Y.Z`
|
||||
picks a specific release and `--zip file.zip` updates from a local package.
|
||||
**Updates:** `./update.sh` (or `update.bat`) updates a packaged install in place
|
||||
— pysim and its prerequisites are kept, so nothing is rebuilt.
|
||||
|
||||
**2. Clone the repository (for development, or changes not released yet).**
|
||||
|
||||
@@ -47,7 +53,7 @@ cd simple # `git pull` still updates it
|
||||
./start.sh # or start.bat
|
||||
```
|
||||
|
||||
Then open http://127.0.0.1:8080 — the UI and API share one origin, so no CORS or browser-permission setup is needed. The server starts fine with an empty reader (no card is a normal state): insert a card and it is initialized automatically (auto-equip), or press **Equip card** in the UI.
|
||||
Then open http://127.0.0.1:8080 — the server starts fine with an empty reader (no card is a normal state): insert a card and it is initialized automatically (auto-equip), or press **Equip card** in the UI.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -557,7 +563,7 @@ probe is refused when SPI1 has no counter check or the named preset does not def
|
||||
|
||||
## Cards
|
||||
|
||||
Stores saved card configurations (presets) in a **server-side store** — `~/.pysim-simple-server/card_presets.json` (`--card-presets` overrides it). A preset holds the cryptographic keys, SPI settings, TARs and the replay counters for SCP80 operations, the optional **ADM** key, plus the **PSK identity / PSK key** pair used by the SCP81 HTTP OTA listener. A preset carries **several keysets** — a keyset's number is the `b8..b5` nibble of its KIc/KID (TS 102 225 §5.1.2/A.2): KIc and KID of a keyset must carry the same number (`01`–`0F`; A.2 rejects a mismatch), `00` (no security) is not a preset keyset, and each keyset has its own counter on the card (Annex A.1) — the server owns those counters, persists every consumed increment and appends it to an audit trail (`card_presets.json.audit.jsonl`). The list is loaded when the PWA connects; JSON export/import (which also accepts the old flat presets) is the migration path from the browser-local store. Cards is a **top-level tab**. The form groups the fields into two bordered blocks — **SCP80 (GSM 03.48, ETSI TS 102 225)** and **SCP81 (HTTP OTA)** — with the optional **ADM** field in the top row; the keysets are edited as rows (one per number, **Add keyset** / **Remove keyset**). When the card is equipped its EF.ICCID is read and the preset with the same ICCID is selected automatically in all three SCP80 views; when the equipped card matches **no** preset — or the selected preset belongs to another card — all three SCP80 views show an amber warning above the preset selector. The header shows gray **SCP80** / **SCP81** markers and a key glyph on the **ADM** badge when the preset matching the equipped card's ICCID has those settings filled in.
|
||||
Stores saved card configurations (presets) in a **server-side store** — `~/.pysim-simple-server/card_presets.json` (`--card-presets` overrides it). A preset holds the cryptographic keys, SPI settings, TARs and the replay counters for SCP80 operations, the optional **ADM** key, plus the **PSK identity / PSK key** pair used by the SCP81 HTTP OTA listener. A preset carries **several keysets** — a keyset's number is the `b8..b5` nibble of its KIc/KID (TS 102 225 §5.1.2/A.2): KIc and KID of a keyset must carry the same number (`01`–`0F`; A.2 rejects a mismatch), `00` (no security) is not a preset keyset, and each keyset has its own counter on the card (Annex A.1) — the server owns those counters, persists every consumed increment and appends it to an audit trail (`card_presets.json.audit.jsonl`). The list is loaded when the frontend connects; JSON export/import (which also accepts the old flat presets) is the migration path from the browser-local store. Cards is a **top-level tab**. The form groups the fields into two bordered blocks — **SCP80 (GSM 03.48, ETSI TS 102 225)** and **SCP81 (HTTP OTA)** — with the optional **ADM** field in the top row; the keysets are edited as rows (one per number, **Add keyset** / **Remove keyset**). When the card is equipped its EF.ICCID is read and the preset with the same ICCID is selected automatically in all three SCP80 views; when the equipped card matches **no** preset — or the selected preset belongs to another card — all three SCP80 views show an amber warning above the preset selector. The header shows gray **SCP80** / **SCP81** markers and a key glyph on the **ADM** badge when the preset matching the equipped card's ICCID has those settings filled in.
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
@@ -588,7 +594,7 @@ The **SCP81** column shows whether the preset supplies a usable PSK pair: **✓*
|
||||
|
||||
Connects to the bundled [`pysim-simple-server`](pysim_simple_server/) for live card operations.
|
||||
|
||||
> **Browser restriction:** when the PWA is served from a public HTTPS host, reaching the local server (`http://127.0.0.1:8080`) requires two things: the server must send `Access-Control-Allow-Private-Network: true` (pysim-simple-server ≥ 1.6.1 does this automatically), and the browser must be allowed to access the local network — in Chrome/Edge/Vivaldi: Site settings → Local network access → allow the site (or accept the permission prompt). Without the browser permission, the request to `127.0.0.1` is blocked before any preflight is sent.
|
||||
> **Browser restriction:** when the frontend is served from a public HTTPS host, reaching the local server (`http://127.0.0.1:8080`) requires two things: the server must send `Access-Control-Allow-Private-Network: true` (pysim-simple-server ≥ 1.6.1 does this automatically), and the browser must be allowed to access the local network — in Chrome/Edge/Vivaldi: Site settings → Local network access → allow the site (or accept the permission prompt). Without the browser permission, the request to `127.0.0.1` is blocked before any preflight is sent.
|
||||
|
||||
### File Browser
|
||||
|
||||
@@ -618,7 +624,7 @@ A filesystem rule is defined by:
|
||||
|
||||
- **Path** — `MF`-rooted (e.g. `MF/7F10/6F3A`) or ADF AID-rooted (e.g. `A0000000871002/6F07`).
|
||||
- **FCP/FCI check** — **Filetype only (FCP)**, **Filetype + size (FCP)** (adds file size, or record length/count for record files), or **Exact FCI** (byte-for-byte comparison of the raw SELECT FCP template `'62'`, catching FID/AID, life-cycle status, security-attribute, and proprietary-parameter changes).
|
||||
- **Access rules** (optional) — verifies the file's access conditions as decoded rules: the FCI's `8B` reference is resolved against the card's (or snapshot's) EF.ARR copies and the decoded lines are compared as a set — record-number independent, so cards with a different EF.ARR layout still verify (missing/extra lines are listed; a lost reference fails; an unresolvable one is unverifiable). Captured automatically when a profile is created from a card or a snapshot; the editor shows each rule as structured rows (operation bits / RESIZE FILE / INCREASE plus security-condition selects, with a canonical preview line; `+ rule` adds one, raw template forms stay read-only) and the block is collapsible with None hiding it. With **Exact FCI**, *Ignore the EF.ARR reference in the exact FCI* compares the FCI without the `8B` value — a different record number no longer fails while the rules check owns its meaning.
|
||||
- **Access rules** (optional) — verifies the file's access conditions as decoded rules: the FCI's `8B` reference is resolved against the card's (or snapshot's) EF.ARR copies and the decoded lines are compared as a set — record-number independent, so cards with a different EF.ARR layout still verify (missing/extra lines are listed; a lost reference fails; an unresolvable one is unverifiable). Captured automatically when a profile is created from a card or a snapshot; the editor shows each rule as structured rows (operation bits / RESIZE FILE / INCREASE plus security-condition selects, with a canonical preview line; `+ rule` adds one, raw template forms stay read-only) and the block is collapsible with None hiding it. With **Exact FCI**, *Ignore the EF.ARR reference in the exact FCI* compares the FCI without the `8B` value — a different record number does not fail while the rules check owns its meaning.
|
||||
- **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.
|
||||
|
||||
@@ -650,7 +656,7 @@ Files not in pysim's model can be added manually from the **Custom files** tab i
|
||||
2. Enter the 4-hex **FID** and an alias (`EF.…`/`DF.…`; the prefix decides whether the entry is an EF or a DF)
|
||||
3. Click **Add** — the file appears in the File manager tree; use **Edit** on a row to reload it into the form (the button becomes **Save**, **Cancel** aborts) or **Delete** to remove it (deleting a DF also deletes its child entries after a confirmation)
|
||||
|
||||
The canonical path removes the old ambiguity where the same file could be described both relatively and absolutely. Custom files persist in `localStorage` across sessions and are included in card scans under the same existence check. Export/import as JSON for sharing; legacy relative paths (e.g. `a153/4954`) are resolved on load, unresolvable ones are dropped and reported in the list.
|
||||
The path is always canonical (MF- or ADF-rooted) — the same file is never described both relatively and absolutely. Custom files persist in `localStorage` across sessions and are included in card scans under the same existence check. Export/import as JSON for sharing; legacy relative paths (e.g. `a153/4954`) are resolved on load, unresolvable ones are dropped and reported in the list.
|
||||
|
||||
## Simulator
|
||||
|
||||
@@ -743,24 +749,17 @@ The interface is in English with Russian support. The language is detected from
|
||||
|
||||
## Server (pysim-simple-server)
|
||||
|
||||
The bundled Python server wraps [pySim](https://osmocom.org/projects/pysim/wiki) and serves both the SIMple PWA (from `frontend/`) and a JSON API under `/api/*`.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Python 3.9+** with `pip`, and **Git** — the app runs on 3.9+, but the **SCP81 TLS-PSK listener needs Python 3.13+** (`ssl`'s PSK callbacks).
|
||||
- **Python venv support** — `setup.sh` creates `.venv`; if that fails with `ensurepip is not available` (Debian/Ubuntu ship the venv module separately), install it (`sudo apt install python3-venv`) and run `./setup.sh` again. An incomplete `.venv` left by the failed run is removed automatically.
|
||||
- **Smart card reader** (PC/SC or serial/FTDI) — PC/SC is preferable (`pcsc-lite` + `ccid` on Linux). `pyscard` has no Linux wheels, so it builds from source: install the PC/SC header package too (`libpcsclite-dev` on Debian/Ubuntu, `pcsc-lite-devel` on Fedora; on Arch `pcsclite` includes them).
|
||||
- **Windows** — use **Python 3.13 (strongly recommended)**: `pyscard` has precompiled wheels up to 3.13 and the SCP81 TLS-PSK listener needs 3.13+. On 3.14 `pyscard` and some pysim dependency wheels are not available and build from source (needs MSVC C++ Build Tools). 3.9–3.12 run the app but not the SCP81 PSK listener. The SMPP bridge (`smpp.twisted3`) is intentionally skipped on Windows.
|
||||
The bundled Python server wraps [pySim](https://osmocom.org/projects/pysim/wiki) and serves both the SIMple frontend (from `frontend/`) and a JSON API under `/api/*`.
|
||||
|
||||
### Scripts
|
||||
|
||||
| Script | What it does |
|
||||
|--------|-------------|
|
||||
| `setup.sh` / `setup.bat` | Creates `.venv/` (or repairs an incomplete one), installs pysim and the server. Run once after cloning or after unzipping the release package. |
|
||||
| `start.sh` / `start.bat` | Starts the server from the venv (serves the PWA + API on `:8080`). |
|
||||
| `start.sh` / `start.bat` | Starts the server from the venv (serves the frontend + API on `:8080`). |
|
||||
| `update.sh` / `update.bat` | Updates a packaged install in place: fetches the latest release package, replaces the app files and re-installs the app into the existing `.venv` (pysim is kept). `--check`, `--version X.Y.Z`, `--zip FILE`, `--no-pip`, `--force`. |
|
||||
|
||||
`start.sh` checks PC/SC access before starting: if the user may not use `pcscd` (or the service is not running) it prints the cause with the group/polkit guidance and exits — the server **no longer falls back to a serial reader silently**. With access, the server auto-detects the first PC/SC reader; `start.bat` always passes `-p 0` (PC/SC is built into Windows). An explicit serial/FTDI reader is still possible with `-d <device>` (the pre-flight check is skipped). Extra arguments are passed through to the server, e.g. `./start.sh --gsmtap` or `start.bat --gsmtap 10.0.0.5:4729`. If no PC/SC reader is found the server reports it and exits — connect the reader and restart (a reader present but no card is still a normal cardless start).
|
||||
`start.sh` checks PC/SC access before starting: if the user may not use `pcscd` (or the service is not running) it prints the cause with the group/polkit guidance and exits — the server **does not fall back to a serial reader silently**. With access, the server auto-detects the first PC/SC reader; `start.bat` always passes `-p 0` (PC/SC is built into Windows). An explicit serial/FTDI reader is still possible with `-d <device>` (the pre-flight check is skipped). Extra arguments are passed through to the server, e.g. `./start.sh --gsmtap` or `start.bat --gsmtap 10.0.0.5:4729`. If no PC/SC reader is found the server reports it and exits — connect the reader and restart (a reader present but no card is still a normal cardless start).
|
||||
|
||||
### Manual installation (Linux/macOS)
|
||||
|
||||
@@ -773,8 +772,8 @@ pysim-simple-server --http-port 8080
|
||||
```
|
||||
|
||||
On Windows use `setup.bat` instead: it installs pysim's dependencies from
|
||||
`requirements-pysim.txt` and pysim itself with `--no-deps`, so the SMPP bridge (and
|
||||
its MSVC build chain) is not pulled in.
|
||||
`requirements-pysim.txt` and pysim itself with `--no-deps`, so pysim's optional
|
||||
dependencies are not pulled in.
|
||||
|
||||
### CLI options
|
||||
|
||||
@@ -782,7 +781,7 @@ its MSVC build chain) is not pulled in.
|
||||
|--------|-------------|
|
||||
| `--http-host` | Bind address (default: `127.0.0.1`) |
|
||||
| `--http-port` | TCP port (default: `8080`) |
|
||||
| `--web-dir` | Directory with PWA static files (default: `<repo>/frontend`) |
|
||||
| `--web-dir` | Directory with frontend static files (default: `<repo>/frontend`) |
|
||||
| `-p` / `--pcsc-device` | PC/SC reader slot number |
|
||||
| `-d` / `--device` | Serial device path |
|
||||
| `--no-card-init` | Skip card init to preserve the CAT session (no file manager) |
|
||||
@@ -813,10 +812,10 @@ its MSVC build chain) is not pulled in.
|
||||
```
|
||||
|
||||
then `sudo systemctl restart polkit` (or log out/in). Start the service if needed (`sudo systemctl enable --now pcscd`); `start.sh` and the server report this and exit instead of falling back to a serial reader, and `pcsc_scan` shows the same error until the rule is active. If your `pcscd` was built without PolicyKit and the socket is group-owned, adding the user to that group works instead.
|
||||
- **"device file /dev/ttyUSB0 does not exist"** — only with an explicit `-d <device>`: the device is not connected (or the path is wrong). Without `-d` the server no longer uses the serial transport.
|
||||
- **"device file /dev/ttyUSB0 does not exist"** — only with an explicit `-d <device>`: the device is not connected (or the path is wrong). Without `-d` the server uses the PC/SC transport only.
|
||||
- **"no PC/SC reader found"** — connect a reader (`pcsc_scan` lists them) and restart; the server exits instead of starting without a reader.
|
||||
- **"Service not available" (0x8010001D) / reader gone after a `pcscd` restart** — the server recovers by itself: a watchdog revives pyscard's presence monitor and the next equip (automatic after re-insertion, or the **Equip** button) recreates the PC/SC transport (releasing the old link first). No server restart needed.
|
||||
- **"Card was removed" errors / auto-equip fails after a card swap** — a card insertion marks the PC/SC link for a rebuild (so the **first** attempt already uses a fresh link — on Windows the old one cannot connect after a swap), the auto-equip retries up to 3 times, and **every failed attempt rebuilds the transport** before the next one. A card removed while the server initializes at startup leaves it cardless with one line (no traceback), and the next insertion equips normally. The swallowed init error (with its traceback) is now printed as `AUTO-EQUIP: init output:`, and a card that is present but produced no card is a failure, not a silent "card gone". The watchdog re-arms with a doubling delay (5 s up to 60 s) while a card is present; a removed card clears the card state completely, so handlers answer "no card" instead of transmitting over the dead card.
|
||||
- **"Card was removed" errors / auto-equip fails after a card swap** — a card insertion marks the PC/SC link for a rebuild (so the **first** attempt already uses a fresh link — on Windows the old one cannot connect after a swap), the auto-equip retries up to 3 times, and **every failed attempt rebuilds the transport** before the next one. A card removed while the server initializes at startup leaves it cardless with one line (no traceback), and the next insertion equips normally. A failed init is printed as `AUTO-EQUIP: init output:` (with its traceback), and a card that is present but produced no card is a failure, not a silent "card gone". The watchdog re-arms with a doubling delay (5 s up to 60 s) while a card is present; a removed card clears the card state completely, so handlers answer "no card" instead of transmitting over the dead card.
|
||||
|
||||
### API reference
|
||||
|
||||
@@ -824,13 +823,27 @@ See [docs/api.md](docs/api.md) for the full endpoint reference.
|
||||
|
||||
## Version compatibility
|
||||
|
||||
| PWA (SIMple) | Server | Status |
|
||||
| frontend (SIMple) | Server | Status |
|
||||
|-------------|--------|--------|
|
||||
| any | same major | ✅ Compatible |
|
||||
| any | older major | ❌ Outdated — update server |
|
||||
| any | newer major | ⚠️ Server newer — update PWA |
|
||||
| any | newer major | ⚠️ Server newer — update frontend |
|
||||
|
||||
The PWA checks the server version on connect via `GET /api/version` and compares the major version (e.g. a 3.x PWA with a 3.x server; a 2.x server is flagged as outdated).
|
||||
The frontend checks the server version on connect via `GET /api/version` and compares the major version (e.g. a 3.x frontend with a 3.x server; a 2.x server is flagged as outdated).
|
||||
|
||||
## Related tools
|
||||
|
||||
SIMple, SIMtrace Analyser and sigrok_iso7816_stream are three standalone
|
||||
tools for working with SIM/UICC cards, published together on the same
|
||||
[website](https://sim-tools.ru) and [news channel](https://t.me/youeyeseesee).
|
||||
Each one is fully independent — install only the tools you need:
|
||||
|
||||
- **[SIMple](https://github.com/anttro/simple)** — a UICC workbench: APDU
|
||||
construction, SCP80/SCP81 OTA, SIM Toolkit simulation and card profiling.
|
||||
- **[SIMtrace Analyser](https://github.com/anttro/simtrace-analyser)** — an
|
||||
APDU trace analyser for GSMTAP-SIM captures.
|
||||
- **[sigrok_iso7816_stream](https://github.com/anttro/sigrok_iso7816_stream)** —
|
||||
a passive ISO 7816 sniffer built on a cheap FX2 logic analyser.
|
||||
|
||||
## License
|
||||
|
||||
@@ -843,5 +856,5 @@ pySim itself is not included in this repository or in the release artifacts:
|
||||
`setup.sh` / `setup.bat` install it from its upstream git repository.
|
||||
|
||||
Bundled third-party components (the MCC/MNC operator list and the AES/DES
|
||||
bundles in the PWA) keep their own MIT licenses — see
|
||||
bundles in the frontend) keep their own MIT licenses — see
|
||||
[THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES).
|
||||
+63
-49
@@ -1,23 +1,33 @@
|
||||
# SIMple — мастерская для UICC/SIM: APDU · OTA · STK · профилирование
|
||||
# SIMple — всё в одном
|
||||
|
||||
[English](README.md) | Русский
|
||||
|
||||
> Прежнее название — OTAMan; переименован в SIMple 2026-09-19.
|
||||
|
||||
📢 Новости и обновления проекта: [Telegram-канал](https://t.me/youeyeseesee)
|
||||
📢 Новости и обновления проекта: [сайт](https://sim-tools.ru) · [Telegram-канал](https://t.me/youeyeseesee)
|
||||
|
||||
SIMple — инструментарий для специалистов по UICC/SIM-картам: автономный HTML/JS PWA и локальный сервер карт ([`pysim-simple-server`](pysim_simple_server/) — мост к pySim/PC-SC, он же раздаёт PWA). Закрывает весь цикл лабораторной работы:
|
||||
SIMple — инструментарий для специалистов по UICC/SIM-картам: автономный HTML/JS-фронтенд (PWA — Progressive Web App) и локальный сервер карт ([`pysim-simple-server`](pysim_simple_server/) — обёртка pySim/PC-SC). Содержит множество инструментов для работы с картой:
|
||||
|
||||
- **APDU-верстак** — конструкторы SIM/USIM RFM и Expanded Script (TS 102 226), команды GlobalPlatform RAM, парсер C-APDU/R-APDU и декодер ответов (ISO 7816 / TS 102 221 / GP);
|
||||
- **OTA-лаборатория** — защищённые пакеты SCP80 (TS 102 225) и установка Java Card RAM, а также терминальная сторона HTTP OTA (SCP81): эмуляция BIP-каналов, PSK-TLS-сервер администрирования и выполнение скриптов APDU;
|
||||
- **Симулятор** — браузер меню SIM Toolkit, обработка proactive-команд и событий, редактирование TERMINAL PROFILE, тест-скрипты карты и журнал C-AT-диалога;
|
||||
- **Профайлер карты** — захват файловой системы (профили и неизменяемые снимки с таймингами), точное сравнение снимков и декодер FCP/FCI.
|
||||
- **Файловый менеджер** — просмотр и редактирование файлов, в том числе в декодированном виде, декодирование FCP/FCI и правил доступа;
|
||||
- **APDU-конструктор и деконструктор** — SIM/USIM RFM, RAM в компактном формате, Expanded Format Scripts (TS 102 226), парсер C-APDU/R-APDU и декодер ответов (ISO 7816 / TS 102 221 / GP);
|
||||
- **OTA-лаборатория** — Secured packets TS 102 225, GSM 03.48 (SCP80) для отправки пакетов через картридер, HTTP OTA (SCP81): эмуляция BIP-каналов, PSK-TLS-сервер для работы через картридер;
|
||||
- **Симулятор** — браузер STK-меню, обработка proactive-команд и событий, редактирование TERMINAL PROFILE, тест-скрипты для проверки любых сценариев;
|
||||
- **Профайлер** — захват снимков файловой системы, сравнение снимков друг с другом и с наборами правил, которым должен соответствовать профиль.
|
||||
|
||||
**Демо:** [simple.atroshin.ru](https://simple.atroshin.ru) — только PWA, для экспериментов. Для функций картридера установите сервер (ниже).
|
||||
**Демо:** [simsimple.ru](https://simsimple.ru) — только фронтенд, для экспериментов. Для функций картридера установите сервер (ниже).
|
||||
|
||||

|
||||
|
||||
## Требования
|
||||
|
||||
- **Python 3.9+** с `pip`, и **Git** — приложение работает на 3.9+, но **SCP81 TLS-PSK-слушателю нужен Python 3.13+** (PSK-callback'и `ssl`).
|
||||
- **Поддержка venv в Python** — `setup.sh` создаёт `.venv`; если создание завершается с `ensurepip is not available` (в Debian/Ubuntu модуль venv поставляется отдельно), установите его (`sudo apt install python3-venv`) и запустите `./setup.sh` снова. Неполный `.venv`, оставшийся от неудачного запуска, удаляется автоматически.
|
||||
- **Смарт-картридер** (PC/SC или serial/FTDI) — предпочтителен PC/SC (`pcsc-lite` + `ccid` на Linux). У `pyscard` нет Linux-wheel, поэтому он собирается из исходников: поставьте и пакет с заголовками PC/SC (`libpcsclite-dev` в Debian/Ubuntu, `pcsc-lite-devel` в Fedora; в Arch они входят в `pcsclite`).
|
||||
- **Windows** — используйте **Python 3.13 (настоятельно рекомендуется)**: для него `pyscard` поставляет готовые wheel, так что ничего компилировать не нужно; на **3.14** wheel для `pyscard` и части зависимостей pysim отсутствуют, и они собираются из исходников (нужны MSVC C++ Build Tools).
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
**Только PWA (клиентские функции):** откройте `frontend/index.html` в любом браузере или раздайте `frontend/` любым статическим сервером. Python не нужен.
|
||||
|
||||
**Полная установка (PWA + сервер карт)** — два способа:
|
||||
**Установка сервера** — два способа:
|
||||
|
||||
**1. Скачать релизный пакет (рекомендуется).** На странице
|
||||
[Releases](https://github.com/anttro/simple/releases) возьмите последний пакет
|
||||
@@ -25,18 +35,14 @@ SIMple — инструментарий для специалистов по UIC
|
||||
|
||||
```sh
|
||||
./setup.sh # или setup.bat в Windows — создаёт .venv, ставит pysim + сервер
|
||||
./start.sh # или start.bat — запускает сервер (он же раздаёт PWA)
|
||||
./start.sh # или start.bat — запускает сервер (он же раздаёт фронтенд)
|
||||
```
|
||||
|
||||
Пакет собран из помеченного тегом исходного кода — без истории git и без файлов для
|
||||
разработки. Git всё равно нужен: `setup` ставит **pysim** из его upstream-репозитория
|
||||
(ни pysim, ни его зависимости в пакет не входят).
|
||||
разработки. Git всё равно нужен: `setup` ставит **pysim** из его upstream-репозитория.
|
||||
|
||||
**Обновление быстрое:** `./update.sh` (или `update.bat`) скачивает последний релизный
|
||||
пакет, заменяет файлы приложения на месте и переустанавливает приложение в существующий
|
||||
`.venv` — pysim и его зависимости остаются, ничего не пересобирается.
|
||||
`./update.sh --check` показывает текущую и последнюю версии, `--version X.Y.Z` —
|
||||
конкретный релиз, `--zip file.zip` — обновление из локального пакета.
|
||||
**Обновление:** `./update.sh` (или `update.bat`) обновляет пакетную установку на месте
|
||||
— pysim и его зависимости остаются, ничего не пересобирается.
|
||||
|
||||
**2. Клонировать репозиторий (для разработки или ещё не выпущенных изменений).**
|
||||
|
||||
@@ -47,7 +53,7 @@ cd simple # `git pull` всё равно
|
||||
./start.sh # или start.bat
|
||||
```
|
||||
|
||||
Затем откройте http://127.0.0.1:8080 — интерфейс и API на одном origin, поэтому CORS и разрешения браузера не нужны. Сервер запускается и с пустым картридером (отсутствие карты — нормальное состояние): вставьте карту, и она инициализируется автоматически (auto-equip), либо нажмите **Equip card** в интерфейсе.
|
||||
Затем откройте http://127.0.0.1:8080 — сервер запускается и с пустым картридером (отсутствие карты — нормальное состояние): вставьте карту, и она инициализируется автоматически (auto-equip), либо нажмите **Equip card** в интерфейсе.
|
||||
|
||||
## Сборка
|
||||
|
||||
@@ -61,7 +67,7 @@ npm run build
|
||||
|
||||
## Интерфейс
|
||||
|
||||
Семь вкладок верхнего уровня: **Remote APDU**, **SCP80**, **SCP81**, **Cards («Карты»)**, **Profiler**, **Card reader** и **Simulator**. Вкладки Remote APDU и SCP80 используют пиллы-подвкладки; вкладка **SCP81** — пиллы **Listener** и **Scripts**; во вкладке Card reader три подвкладки: **File manager**, **pySim command line** и **Raw APDU**; во вкладке Profiler — **Profiles**, **Card snapshots** и **Custom files**.
|
||||
Семь вкладок верхнего уровня: **Remote APDU**, **SCP80**, **SCP81**, **Cards («Карты»)**, **Profiler**, **Card reader** и **Simulator**. Вкладки Remote APDU и SCP80 используют кнопки-подвкладки; вкладка **SCP81** — кнопки **Listener** и **Scripts**; во вкладке Card reader три подвкладки: **File manager**, **pySim command line** и **Raw APDU**; во вкладке Profiler — **Profiles**, **Card snapshots** и **Custom files**.
|
||||
|
||||
---
|
||||
|
||||
@@ -402,7 +408,7 @@ CLA = `80` (GlobalPlatform v2.3.1). Удалённое управление со
|
||||
|
||||
## Вкладка SCP80
|
||||
|
||||
Вкладка **SCP80** группирует SCP80-виды, переключаемые тремя пиллами: **Secured Packet**, **RAM** и **TAR-сканер**. Сборка защищённых пакетов по ETSI TS 102 225. При подключении карты сервер читает её EF.ICCID (2FE2); если в пресете указан тот же номер (цифрами или сырым hex из EF), он автоматически выбирается во всех трёх видах SCP80.
|
||||
Вкладка **SCP80** группирует SCP80-виды, переключаемые тремя кнопками: **Secured Packet**, **RAM** и **TAR-сканер**. Сборка защищённых пакетов по ETSI TS 102 225. При подключении карты сервер читает её EF.ICCID (2FE2); если в пресете указан тот же номер (цифрами или сырым hex из EF), он автоматически выбирается во всех трёх видах SCP80.
|
||||
|
||||
### Secured Packet
|
||||
|
||||
@@ -518,7 +524,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
||||
|
||||
### TAR-сканер
|
||||
|
||||
Третья пилюля вкладки SCP80 показывает, **какие OTA-приложения зарегистрированы на карте**: отправляется по одному
|
||||
Третья кнопка вкладки SCP80 показывает, **какие OTA-приложения зарегистрированы на карте**: отправляется по одному
|
||||
защищённому пакету на каждый выбранный TAR — стандартные значения из TS 101 220 V18.3.0 Annex D, Table D.1
|
||||
(предзаполнены, все отмечены) плюс свои TAR, добавляемые кнопкой **Добавить TAR** и хранимые в `localStorage` — с
|
||||
безобидной C-APDU (по умолчанию `SELECT MF`), пресетом и keyset этого представления и SPI `16/01` (проверка
|
||||
@@ -532,7 +538,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
||||
|
||||
## Карты
|
||||
|
||||
Хранит сохранённые конфигурации карт (пресеты) в **серверном хранилище** — `~/.pysim-simple-server/card_presets.json` (переопределяется `--card-presets`). Пресет содержит криптографические ключи, настройки SPI, TAR-ы и счётчики повторов для SCP80-операций, необязательный ключ **ADM**, а также пару **PSK identity / PSK key** для слушателя SCP81 HTTP OTA. Пресет содержит **несколько keysets** — номер keyset задаётся нибблом `b8..b5` его KIc/KID (TS 102 225 §5.1.2/A.2): KIc и KID одного keyset должны нести один и тот же номер (`01`–`0F`; A.2 отвергает несовпадение), `00` («без защиты») не является keyset пресета, и у каждого keyset на карте свой счётчик (Annex A.1) — счётчиками владеет сервер: он сохраняет каждый израсходованный инкремент и пишет его в журнал аудита (`card_presets.json.audit.jsonl`). Список загружается при подключении PWA; экспорт/импорт JSON (принимает и старые плоские пресеты) — путь миграции из браузерного хранилища. «Карты» — **верхнеуровневая вкладка**. Поля формы сгруппированы в два блока с рамкой — **SCP80 (GSM 03.48, ETSI TS 102 225)** и **SCP81 (HTTP OTA)**; необязательное поле **ADM** находится в верхней строке, а keysets редактируются строками (по одной на номер, **Добавить keyset** / **Удалить keyset**). При подключении карты читается её EF.ICCID, и пресет с тем же ICCID автоматически выбирается во всех трёх видах SCP80; если подключённая карта не совпадает ни с одним пресетом — или выбранный пресет принадлежит другой карте — все три вида SCP80 показывают жёлтое предупреждение над выбором пресета. В шапке отображаются серые маркеры **SCP80** / **SCP81** и значок ключа на бейдже **ADM**, когда в пресете с ICCID подключённой карты эти поля заполнены.
|
||||
Хранит сохранённые конфигурации карт (пресеты) в **серверном хранилище** — `~/.pysim-simple-server/card_presets.json` (переопределяется `--card-presets`). Пресет содержит криптографические ключи, настройки SPI, TAR-ы и счётчики повторов для SCP80-операций, необязательный ключ **ADM**, а также пару **PSK identity / PSK key** для слушателя SCP81 HTTP OTA. Пресет содержит **несколько keysets** — номер keyset задаётся нибблом `b8..b5` его KIc/KID (TS 102 225 §5.1.2/A.2): KIc и KID одного keyset должны нести один и тот же номер (`01`–`0F`; A.2 отвергает несовпадение), `00` («без защиты») не является keyset пресета, и у каждого keyset на карте свой счётчик (Annex A.1) — счётчиками владеет сервер: он сохраняет каждый израсходованный инкремент и пишет его в журнал аудита (`card_presets.json.audit.jsonl`). Список загружается при подключении фронтенда; экспорт/импорт JSON (принимает и старые плоские пресеты) — путь миграции из браузерного хранилища. «Карты» — **верхнеуровневая вкладка**. Поля формы сгруппированы в два блока с рамкой — **SCP80 (GSM 03.48, ETSI TS 102 225)** и **SCP81 (HTTP OTA)**; необязательное поле **ADM** находится в верхней строке, а keysets редактируются строками (по одной на номер, **Добавить keyset** / **Удалить keyset**). При подключении карты читается её EF.ICCID, и пресет с тем же ICCID автоматически выбирается во всех трёх видах SCP80; если подключённая карта не совпадает ни с одним пресетом — или выбранный пресет принадлежит другой карте — все три вида SCP80 показывают жёлтое предупреждение над выбором пресета. В шапке отображаются серые маркеры **SCP80** / **SCP81** и значок ключа на бейдже **ADM**, когда в пресете с ICCID подключённой карты эти поля заполнены.
|
||||
|
||||
| Поле | Описание |
|
||||
|---|---|
|
||||
@@ -563,14 +569,14 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
||||
|
||||
Подключение к встроенному [`pysim-simple-server`](pysim_simple_server/) для работы с картой.
|
||||
|
||||
> **Ограничение браузера:** если PWA раздаётся с публичного HTTPS-хоста, для доступа к локальному серверу (`http://127.0.0.1:8080`) нужны два условия: сервер должен отправлять `Access-Control-Allow-Private-Network: true` (pysim-simple-server ≥ 1.6.1 делает это автоматически), и браузеру должно быть разрешено обращаться к локальной сети — в Chrome/Edge/Vivaldi: Настройки сайта → Доступ к локальной сети → разрешить сайт (или подтвердить запрос). Без разрешения браузера запрос к `127.0.0.1` блокируется ещё до отправки preflight.
|
||||
> **Ограничение браузера:** если фронтенд раздаётся с публичного HTTPS-хоста, для доступа к локальному серверу (`http://127.0.0.1:8080`) нужны два условия: сервер должен отправлять `Access-Control-Allow-Private-Network: true` (pysim-simple-server ≥ 1.6.1 делает это автоматически), и браузеру должно быть разрешено обращаться к локальной сети — в Chrome/Edge/Vivaldi: Настройки сайта → Доступ к локальной сети → разрешить сайт (или подтвердить запрос). Без разрешения браузера запрос к `127.0.0.1` блокируется ещё до отправки preflight.
|
||||
|
||||
### Файловый менеджер
|
||||
|
||||
Дерево файлов UICC. Отображаются имена, FID и AID (для ADF). При выборе файла открывается панель деталей: FID, тип файла, размер / структура записей и декодированный FCI (в блоке с рамкой и именем файла) над панелью содержимого. Когда FCI ссылается на свои правила доступа через запись EF.ARR (атрибут безопасности `8B`), правила указанной записи показываются отступом под этой строкой — они берутся из кэшированных копий EF.ARR карты (MF, DF.TELECOM/DF.GSM и ADF.USIM, по цепочке DF файла; снимок разрешает их из собственной захваченной копии).
|
||||
|
||||
- Элементы сгруппированы (DF выше EF) и отсортированы по **FID** или символьному **имени** (пиллы закреплены над деревом вместе с **«Проверить все файлы»**, выбор сохраняется в `localStorage`)
|
||||
- **Read raw** / **Read decoded** — чтение выбранного файла как hex-дампа или как таблицы декодированных полей (клиентские декодеры: IMSI, ICCID, SPN, списки PLMN, LOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); подсвеченная пилла — текущий вид, клик по любой из них перечитывает файл. таблица декодируется в браузере из тех же сырых байт (без серверного декодирования); **Read decoded** отключается, когда клиентские декодеры не покрывают файл (определяется по имени/FID до чтения)
|
||||
- Элементы сгруппированы (DF выше EF) и отсортированы по **FID** или символьному **имени** (кнопки закреплены над деревом вместе с **«Проверить все файлы»**, выбор сохраняется в `localStorage`)
|
||||
- **Read raw** / **Read decoded** — чтение выбранного файла как hex-дампа или как таблицы декодированных полей (клиентские декодеры: IMSI, ICCID, SPN, списки PLMN, LOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); подсвеченная кнопка — текущий вид, клик по любой из них перечитывает файл. таблица декодируется в браузере из тех же сырых байт (без серверного декодирования); **Read decoded** отключается, когда клиентские декодеры не покрывают файл (определяется по имени/FID до чтения)
|
||||
- **Edit raw** — редактирование сырых hex-данных и **Save** для записи (или **Cancel**); для transparent-файлов — textarea, для record-файлов — по полю на запись с флажками выбора. **Edit raw** сначала переключается на hex и при необходимости читает файл
|
||||
- **Edit decoded** — редактирование в собственной декодированной форме файла (клиентские кодировщики; запись обычным путём): **EF.SPN**, **EF.PNN**, списки PLMN **PLMNsel / FPLMN / EHPLMN** и **PLMNwAcT / OPLMNwAcT / HPLMNwAcT**, файлы номеров **ADN / FDN / SDN / MSISDN / LND / BDN**, таблицы сервисов **UST / IST / EST** (**SST** только для чтения до появления редактора allocated/activated), **ACC**, **PHASE**, **HPPLMN**, **AD**, **ICCID / IMSI** (с подтверждением) списки cell broadcast **CBMI / CBMID / CBMIR** и **EF.SMSP** (полная форма SMS-параметров: alpha, TP-DA/TS-SCA с их правилами адресации и TON, TP-PID, TP-DCS и относительный TP-VP; у каждого — флажок «отсутствует/по умолчанию» и живые подсказки по DCS и сроку действия). Для transparent-файла — одна форма (строки PLMN и элементы PNN с добавлением/удалением); для record-файла — форма на запись с флажком, который ставится автоматически при изменении поля, поэтому записываются только изменённые записи. **Save** проверяет все выбранные записи до записи; указатели расширений (EF.EXT1–EF.EXT5) и байт метода сравнения BDN сохраняются; редактирование номера, продолжение которого лежит в файле расширения, отклоняется. Обзор форматов и план охвата — в `docs/decoded-editing.md`
|
||||
- **Проверить все файлы** — обход всего дерева (включая пользовательские) с пометкой «есть/нет», прогрессом *N / всего*, возможностью остановки и сводкой в конце; сам просмотр остаётся ленивым. Отсутствующие файлы показаны красным (✗); существующий пустой DF — `(пусто)`
|
||||
@@ -593,7 +599,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
||||
|
||||
- **Путь** — от `MF` (напр. `MF/7F10/6F3A`) или от AID ADF (напр. `A0000000871002/6F07`).
|
||||
- **Проверка FCP/FCI** — **Только тип файла (FCP)**, **Тип файла + размер (FCP)** (добавляет размер файла или длину/число записей) либо **Полный FCI** (побайтовое сравнение сырого шаблона FCP `'62'` из ответа SELECT — ловит изменения FID/AID, life-cycle, security attributes и проприетарных параметров).
|
||||
- **Правила доступа** (необязательно) — проверяет условия доступа файла как декодированные правила: ссылка `8B` в FCI разрешается по копиям EF.ARR карты (или снимка), а декодированные строки сравниваются как множество — независимо от номера записи, поэтому карты с другой раскладкой EF.ARR всё равно проверяются (отсутствующие/лишние строки перечисляются; потерянная ссылка — ошибка; неразрешимая — непроверяемо). Заполняется автоматически при создании профиля с карты или из снимка; редактор показывает каждое правило структурированной строкой (биты операции / RESIZE FILE / INCREASE плюс селекторы условий безопасности, с предпросмотром канонической строки; `+ правило` добавляет, шаблонные формы остаются только для чтения), а блок сворачивается, «Нет» скрывает его. При **Полном FCI** флажок *«Игнорировать ссылку на EF.ARR в точном FCI»* сравнивает FCI без значения `8B` — другой номер записи больше не приводит к несовпадению, а смысл ссылки проверяют правила.
|
||||
- **Правила доступа** (необязательно) — проверяет условия доступа файла как декодированные правила: ссылка `8B` в FCI разрешается по копиям EF.ARR карты (или снимка), а декодированные строки сравниваются как множество — независимо от номера записи, поэтому карты с другой раскладкой EF.ARR всё равно проверяются (отсутствующие/лишние строки перечисляются; потерянная ссылка — ошибка; неразрешимая — непроверяемо). Заполняется автоматически при создании профиля с карты или из снимка; редактор показывает каждое правило структурированной строкой (биты операции / RESIZE FILE / INCREASE плюс селекторы условий безопасности, с предпросмотром канонической строки; `+ правило` добавляет, шаблонные формы остаются только для чтения), а блок сворачивается, «Нет» скрывает его. При **Полном FCI** флажок *«Игнорировать ссылку на EF.ARR в точном FCI»* сравнивает FCI без значения `8B` — другой номер записи не приводит к несовпадению, а смысл ссылки проверяют правила.
|
||||
- **Атрибуты файла** — тип, размер, длина и число записей из шаблона FCP (любое можно не задавать).
|
||||
- **Проверка содержимого** (опционально) — **Exact** (точное равенство hex) или **Mask**, где `?` — пониббловый джокер (маска без `?` — префиксное совпадение, напр. `0891` для MCC/MNC IMSI). Для record-файлов хранится список записей.
|
||||
|
||||
@@ -625,7 +631,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
||||
2. Введите 4-hex **FID** и псевдоним (`EF.…`/`DF.…`; префикс определяет, EF это или DF)
|
||||
3. Нажмите **Add** — файл появится в дереве файлового менеджера; кнопка **Edit** загружает запись в форму (кнопка становится **Save**, **Cancel** отменяет), **Delete** удаляет запись (удаление DF удаляет и дочерние записи после подтверждения)
|
||||
|
||||
Канонический путь убирает прежнюю неоднозначность, когда один и тот же файл можно было описать и относительно, и абсолютно. Пользовательские файлы сохраняются в `localStorage` между сессиями и включаются в сканирование карты с той же проверкой существования. Экспорт/импорт в JSON для обмена; устаревшие относительные пути (напр. `a153/4954`) разрешаются при загрузке, неразрешимые отбрасываются и показываются в списке.
|
||||
Путь всегда канонический (от MF или ADF) — один и тот же файл никогда не описывается и относительно, и абсолютно. Пользовательские файлы сохраняются в `localStorage` между сессиями и включаются в сканирование карты с той же проверкой существования. Экспорт/импорт в JSON для обмена; устаревшие относительные пути (напр. `a153/4954`) разрешаются при загрузке, неразрешимые отбрасываются и показываются в списке.
|
||||
|
||||
## Симулятор
|
||||
|
||||
@@ -715,36 +721,29 @@ SIMple — Progressive Web App. Можно установить для offline-
|
||||
|
||||
## Совместимость версий
|
||||
|
||||
| PWA (SIMple) | Сервер | Статус |
|
||||
| фронтенд (SIMple) | Сервер | Статус |
|
||||
|---|---|---|
|
||||
| любая | та же мажорная | ✅ Совместимы |
|
||||
| любая | старее мажорная | ❌ Сервер устарел — обновите сервер |
|
||||
| любая | новее мажорная | ⚠️ Сервер новее — обновите PWA |
|
||||
| любая | новее мажорная | ⚠️ Сервер новее — обновите фронтенд |
|
||||
|
||||
PWA проверяет версию сервера при подключении через `GET /api/version` и сравнивает мажорную версию (например, PWA 3.x с сервером 3.x; сервер 2.x помечается как устаревший).
|
||||
фронтенд проверяет версию сервера при подключении через `GET /api/version` и сравнивает мажорную версию (например, фронтенд 3.x с сервером 3.x; сервер 2.x помечается как устаревший).
|
||||
|
||||
---
|
||||
|
||||
## Сервер (pysim-simple-server)
|
||||
|
||||
Встроенный Python-сервер оборачивает [pySim](https://osmocom.org/projects/pysim/wiki) и раздаёт как PWA (из `frontend/`), так и JSON API по `/api/*`.
|
||||
|
||||
### Требования
|
||||
|
||||
- **Python 3.9+** с `pip`, и **Git** — приложение работает на 3.9+, но **SCP81 TLS-PSK-слушателю нужен Python 3.13+** (PSK-callback'и `ssl`).
|
||||
- **Поддержка venv в Python** — `setup.sh` создаёт `.venv`; если создание завершается с `ensurepip is not available` (в Debian/Ubuntu модуль venv поставляется отдельно), установите его (`sudo apt install python3-venv`) и запустите `./setup.sh` снова. Неполный `.venv`, оставшийся от неудачного запуска, удаляется автоматически.
|
||||
- **Смарт-картридер** (PC/SC или serial/FTDI) — предпочтителен PC/SC (`pcsc-lite` + `ccid` на Linux). У `pyscard` нет Linux-wheel, поэтому он собирается из исходников: поставьте и пакет с заголовками PC/SC (`libpcsclite-dev` в Debian/Ubuntu, `pcsc-lite-devel` в Fedora; в Arch они входят в `pcsclite`).
|
||||
- **Windows** — используйте **Python 3.13 (настоятельно рекомендуется)**: у `pyscard` есть готовые wheel до 3.13, и SCP81 TLS-PSK-слушателю нужен 3.13+. На 3.14 wheel для `pyscard` и части зависимостей pysim отсутствуют, они собираются из исходников (нужны MSVC C++ Build Tools). 3.9–3.12 запускают приложение, но не SCP81 PSK-слушатель. SMPP-мост (`smpp.twisted3`) в Windows сознательно не ставится.
|
||||
Встроенный Python-сервер оборачивает [pySim](https://osmocom.org/projects/pysim/wiki) и раздаёт как фронтенд (из `frontend/`), так и JSON API по `/api/*`.
|
||||
|
||||
### Скрипты
|
||||
|
||||
| Скрипт | Назначение |
|
||||
|--------|-------------|
|
||||
| `setup.sh` / `setup.bat` | Создаёт `.venv/` (или исправляет неполный), ставит pysim и сервер. Запускать один раз после клонирования или распаковки релизного пакета. |
|
||||
| `start.sh` / `start.bat` | Запускает сервер из venv (раздаёт PWA + API на `:8080`). |
|
||||
| `start.sh` / `start.bat` | Запускает сервер из venv (раздаёт фронтенд + API на `:8080`). |
|
||||
| `update.sh` / `update.bat` | Обновляет пакетную установку на месте: скачивает последний релизный пакет, заменяет файлы приложения и переустанавливает приложение в существующий `.venv` (pysim сохраняется). `--check`, `--version X.Y.Z`, `--zip FILE`, `--no-pip`, `--force`. |
|
||||
|
||||
`start.sh` перед запуском проверяет доступ к PC/SC: если пользователь не может использовать `pcscd` (или служба не запущена), он печатает причину и рекомендации (группа/polkit) и завершается — сервер **больше не откатывается на последовательный ридер молча**. При наличии доступа сервер сам находит первый PC/SC-ридер; `start.bat` всегда передаёт `-p 0` (в Windows PC/SC встроен). Явный serial/FTDI-ридер по-прежнему возможен через `-d <устройство>` (проверка пропускается). Дополнительные аргументы передаются серверу, например `./start.sh --gsmtap` или `start.bat --gsmtap 10.0.0.5:4729`. Если PC/SC-ридер не найден, сервер сообщает об этом и завершается — подключите ридер и перезапустите (ридер без карты — по-прежнему нормальный старт без карты).
|
||||
`start.sh` перед запуском проверяет доступ к PC/SC: если пользователь не может использовать `pcscd` (или служба не запущена), он печатает причину и рекомендации (группа/polkit) и завершается — сервер **не откатывается на последовательный ридер молча**. При наличии доступа сервер сам находит первый PC/SC-ридер; `start.bat` всегда передаёт `-p 0` (в Windows PC/SC встроен). Явный serial/FTDI-ридер по-прежнему возможен через `-d <устройство>` (проверка пропускается). Дополнительные аргументы передаются серверу, например `./start.sh --gsmtap` или `start.bat --gsmtap 10.0.0.5:4729`. Если PC/SC-ридер не найден, сервер сообщает об этом и завершается — подключите ридер и перезапустите (ридер без карты — по-прежнему нормальный старт без карты).
|
||||
|
||||
### Ручная установка (Linux/macOS)
|
||||
|
||||
@@ -757,8 +756,8 @@ pysim-simple-server --http-port 8080
|
||||
```
|
||||
|
||||
В Windows используйте `setup.bat`: он ставит зависимости pysim из
|
||||
`requirements-pysim.txt`, а сам pysim — с `--no-deps`, поэтому SMPP-мост (и его сборка
|
||||
через MSVC) не подтягивается.
|
||||
`requirements-pysim.txt`, а сам pysim — с `--no-deps`, поэтому необязательные
|
||||
зависимости pysim не подтягиваются.
|
||||
|
||||
### Параметры CLI
|
||||
|
||||
@@ -766,7 +765,7 @@ pysim-simple-server --http-port 8080
|
||||
|----------|-------------|
|
||||
| `--http-host` | Адрес привязки (по умолчанию `127.0.0.1`) |
|
||||
| `--http-port` | Порт (по умолчанию `8080`) |
|
||||
| `--web-dir` | Каталог со статикой PWA (по умолчанию `<repo>/frontend`) |
|
||||
| `--web-dir` | Каталог со статикой фронтенда (по умолчанию `<repo>/frontend`) |
|
||||
| `-p` / `--pcsc-device` | Номер слота PC/SC |
|
||||
| `-d` / `--device` | Путь к serial-устройству |
|
||||
| `--no-card-init` | Пропустить инициализацию карты (сохранить CAT-сессию) |
|
||||
@@ -797,15 +796,30 @@ pysim-simple-server --http-port 8080
|
||||
```
|
||||
|
||||
затем `sudo systemctl restart polkit` (или выйдите и войдите). При необходимости запустите службу (`sudo systemctl enable --now pcscd`); `start.sh` и сервер сообщают об этом и завершаются вместо отката на serial-ридер, а `pcsc_scan` показывает ту же ошибку, пока правило не начнёт действовать. Если `pcscd` собран без PolicyKit и сокет принадлежит отдельной группе, вместо этого можно добавить пользователя в неё.
|
||||
- **"device file /dev/ttyUSB0 does not exist"** — только при явном `-d <устройство>`: устройство не подключено (или путь неверен). Без `-d` сервер больше не использует последовательный транспорт.
|
||||
- **"device file /dev/ttyUSB0 does not exist"** — только при явном `-d <устройство>`: устройство не подключено (или путь неверен). Без `-d` сервер использует только транспорт PC/SC.
|
||||
- **"no PC/SC reader found"** — подключите ридер (`pcsc_scan` покажет список) и перезапустите; сервер завершается, а не стартует без ридера.
|
||||
- **"Service not available" (0x8010001D) / ридер пропал после перезапуска `pcscd`** — сервер восстанавливается сам: сторож перезапускает монитор присутствия pyscard, а следующий equip (автоматически после повторной вставки карты или кнопкой **Equip**) пересоздаёт транспорт PC/SC (сначала освобождая старый линк). Перезапуск сервера не нужен.
|
||||
- **Ошибки "Card was removed" / equip не срабатывает после смены карты** — вставка карты помечает соединение PC/SC для пересоздания (поэтому **первая** попытка уже использует свежее соединение — в Windows старое после смены карты подключиться не может), авто-equip повторяется до 3 раз, и **каждая неудачная попытка пересоздаёт транспорт** перед следующей. Карта, снятая во время инициализации при старте сервера, оставляет его без карты с одной строкой (без трассировки), а следующая вставка инициализируется обычным образом. Проглоченная ошибка инициализации (с трассировкой) теперь печатается как `AUTO-EQUIP: init output:`, а «карта есть, но карта не создана» — это ошибка, а не тихое «карта пропала». Сторож повторяет с удвоением задержки (5 с … 60 с), пока карта в ридере; снятие карты полностью очищает состояние, поэтому обработчики отвечают «нет карты», а не шлют APDU мёртвой карте.
|
||||
- **Ошибки "Card was removed" / equip не срабатывает после смены карты** — вставка карты помечает соединение PC/SC для пересоздания (поэтому **первая** попытка уже использует свежее соединение — в Windows старое после смены карты подключиться не может), авто-equip повторяется до 3 раз, и **каждая неудачная попытка пересоздаёт транспорт** перед следующей. Карта, снятая во время инициализации при старте сервера, оставляет его без карты с одной строкой (без трассировки), а следующая вставка инициализируется обычным образом. Неудачная инициализация печатается как `AUTO-EQUIP: init output:` (с трассировкой), а карта, которая есть в ридере, но не инициализировалась, — это ошибка, а не тихое «карта пропала». Сторож повторяет с удвоением задержки (5 с … 60 с), пока карта в ридере; снятие карты полностью очищает состояние, поэтому обработчики отвечают «нет карты», а не шлют APDU мёртвой карте.
|
||||
|
||||
### Справочник API
|
||||
|
||||
Полный справочник endpoints: [docs/api.md](docs/api.md).
|
||||
|
||||
## Связанные инструменты
|
||||
|
||||
SIMple, SIMtrace Analyser и sigrok_iso7816_stream — три автономных
|
||||
инструмента для работы с SIM/UICC-картами, представленные на одном
|
||||
[сайте](https://sim-tools.ru) и в одном [Telegram-канале](https://t.me/youeyeseesee).
|
||||
Каждый полностью независим — устанавливайте только нужные вам:
|
||||
|
||||
- **[SIMple](https://github.com/anttro/simple)** — мастерская для UICC/SIM:
|
||||
конструкторы APDU, OTA SCP80/SCP81, симуляция SIM Toolkit и профилирование
|
||||
карт.
|
||||
- **[SIMtrace Analyser](https://github.com/anttro/simtrace-analyser)** —
|
||||
анализатор APDU-трассировок GSMTAP-SIM.
|
||||
- **[sigrok_iso7816_stream](https://github.com/anttro/sigrok_iso7816_stream)** —
|
||||
пассивный ISO 7816-сниффер на дешёвом логическом анализаторе FX2.
|
||||
|
||||
## Лицензия
|
||||
|
||||
**GPL-2.0-or-later** — см. [LICENSE](LICENSE). Это та же лицензия, что у
|
||||
@@ -816,5 +830,5 @@ pysim-simple-server --http-port 8080
|
||||
Сам pySim не входит ни в этот репозиторий, ни в релизные артефакты: `setup.sh` /
|
||||
`setup.bat` устанавливают его из его upstream git-репозитория.
|
||||
|
||||
Встроенные сторонние компоненты (список операторов MCC/MNC и AES/DES-бандлы в
|
||||
PWA) сохраняют свои лицензии MIT — см. [THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES).
|
||||
Встроенные сторонние компоненты (список операторов MCC/MNC и AES/DES-бандлы во
|
||||
фронтенде) сохраняют свои лицензии MIT — см. [THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES).
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-labelledby="diagram-title diagram-desc" font-family="'Segoe UI', 'Helvetica Neue', Arial, 'DejaVu Sans', sans-serif">
|
||||
<title id="diagram-title">SIMple: card access path</title>
|
||||
<desc id="diagram-desc">Browser (SIMple PWA) talks to pysim-simple-server over HTTP on :8080; the server wraps pySim and talks to the card reader over PC/SC, which talks to the UICC/SIM card.</desc>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0 0 L10 5 L0 10 Z" fill="#64748b"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect x="1" y="1" width="958" height="538" rx="18" fill="#ffffff" stroke="#e2e8f0" stroke-width="1.5"/>
|
||||
<text x="480" y="30" text-anchor="middle" font-size="14" fill="#475569">card access path</text>
|
||||
|
||||
<rect x="250" y="48" width="460" height="76" rx="12" fill="#eff6ff" stroke="#93c5fd" stroke-width="1.5"/>
|
||||
<text x="480" y="92" text-anchor="middle" font-size="20" font-weight="600" fill="#0f172a">Browser (SIMple PWA)</text>
|
||||
<line x1="480" y1="124" x2="480" y2="172" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<text x="492" y="152" font-size="14" fill="#64748b" font-family="'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace">HTTP :8080</text>
|
||||
|
||||
<rect x="250" y="176" width="460" height="80" rx="12" fill="#f5f3ff" stroke="#c4b5fd" stroke-width="1.5"/>
|
||||
<text x="480" y="210" text-anchor="middle" font-size="19" font-weight="600" fill="#0f172a" font-family="'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace">pysim-simple-server</text>
|
||||
<text x="480" y="236" text-anchor="middle" font-size="14" fill="#475569">serves the PWA + JSON API · wraps pySim</text>
|
||||
<line x1="480" y1="256" x2="480" y2="304" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<text x="492" y="284" font-size="14" fill="#64748b" font-family="'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace">PC/SC</text>
|
||||
|
||||
<rect x="250" y="308" width="460" height="76" rx="12" fill="#ecfdf5" stroke="#6ee7b7" stroke-width="1.5"/>
|
||||
<text x="480" y="352" text-anchor="middle" font-size="20" font-weight="600" fill="#0f172a">Card reader</text>
|
||||
<line x1="480" y1="384" x2="480" y2="432" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="250" y="436" width="460" height="76" rx="12" fill="#fffbeb" stroke="#fcd34d" stroke-width="1.5"/>
|
||||
<text x="480" y="480" text-anchor="middle" font-size="20" font-weight="600" fill="#0f172a">UICC/SIM card</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-labelledby="diagram-title diagram-desc" font-family="'Segoe UI', 'Helvetica Neue', Arial, 'DejaVu Sans', sans-serif">
|
||||
<title id="diagram-title">SIMple: путь доступа к карте</title>
|
||||
<desc id="diagram-desc">Браузер (PWA SIMple) обращается к pysim-simple-server по HTTP на :8080; сервер оборачивает pySim и по PC/SC общается с картридером, а тот — с картой UICC/SIM.</desc>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0 0 L10 5 L0 10 Z" fill="#64748b"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect x="1" y="1" width="958" height="538" rx="18" fill="#ffffff" stroke="#e2e8f0" stroke-width="1.5"/>
|
||||
<text x="480" y="30" text-anchor="middle" font-size="14" fill="#475569">путь доступа к карте</text>
|
||||
|
||||
<rect x="250" y="48" width="460" height="76" rx="12" fill="#eff6ff" stroke="#93c5fd" stroke-width="1.5"/>
|
||||
<text x="480" y="92" text-anchor="middle" font-size="20" font-weight="600" fill="#0f172a">Браузер (PWA SIMple)</text>
|
||||
<line x1="480" y1="124" x2="480" y2="172" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<text x="492" y="152" font-size="14" fill="#64748b" font-family="'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace">HTTP :8080</text>
|
||||
|
||||
<rect x="250" y="176" width="460" height="80" rx="12" fill="#f5f3ff" stroke="#c4b5fd" stroke-width="1.5"/>
|
||||
<text x="480" y="210" text-anchor="middle" font-size="19" font-weight="600" fill="#0f172a" font-family="'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace">pysim-simple-server</text>
|
||||
<text x="480" y="236" text-anchor="middle" font-size="14" fill="#475569">раздаёт PWA + JSON API · оборачивает pySim</text>
|
||||
<line x1="480" y1="256" x2="480" y2="304" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<text x="492" y="284" font-size="14" fill="#64748b" font-family="'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace">PC/SC</text>
|
||||
|
||||
<rect x="250" y="308" width="460" height="76" rx="12" fill="#ecfdf5" stroke="#6ee7b7" stroke-width="1.5"/>
|
||||
<text x="480" y="352" text-anchor="middle" font-size="20" font-weight="600" fill="#0f172a">Картридер</text>
|
||||
<line x1="480" y1="384" x2="480" y2="432" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="250" y="436" width="460" height="76" rx="12" fill="#fffbeb" stroke="#fcd34d" stroke-width="1.5"/>
|
||||
<text x="480" y="480" text-anchor="middle" font-size="20" font-weight="600" fill="#0f172a">Карта UICC/SIM</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -46,7 +46,7 @@
|
||||
<li>Выбор языка и темы хранится в <code class="font-mono text-sm">localStorage</code> и сохраняется между перезагрузками.</li>
|
||||
<li>Вкладки верхнего уровня: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>RAM/GP</strong>, <strong>Expanded Script</strong>, <strong>«Пуш/триггер»</strong>, <strong>«Разбор»</strong> (<strong>«Разбор C-APDU»</strong>, <strong>«Разбор R-APDU»</strong>)), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>RAM</strong>, <strong>TAR-сканер</strong>), <strong>SCP81</strong> (<strong>«Слушатель»</strong>, <strong>«Скрипты»</strong>), <strong>«Карты»</strong>, <strong>«Профайлер»</strong> (вкладки <strong>«Профили»</strong>, <strong>«Снимки карт»</strong>, <strong>«Пользовательские файлы»</strong>), <strong>«Картридер»</strong> (<strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong>, <strong>Отправка APDU</strong>) и <strong>«Симулятор»</strong>.</li>
|
||||
<li>Ссылка <strong>справка</strong> открывает эту документацию на разделе, соответствующем текущему представлению (например, вкладка «Профайлер» открывает §7).</li>
|
||||
<li>Кнопки браузера <strong>«Назад»</strong> / <strong>«Вперёд»</strong> проходят по собственному стеку представлений приложения — каждое переключение вкладки, пиллы и списка/редактора записывается (только на время сессии, URL не меняется); встроенные кнопки <em>«Отмена»</em>/<em>«Назад»</em> заменяют текущую запись, а не добавляют новую.</li>
|
||||
<li>Кнопки браузера <strong>«Назад»</strong> / <strong>«Вперёд»</strong> проходят по собственному стеку представлений приложения — каждое переключение вкладки, кнопки и списка/редактора записывается (только на время сессии, URL не меняется); встроенные кнопки <em>«Отмена»</em>/<em>«Назад»</em> заменяют текущую запись, а не добавляют новую.</li>
|
||||
</ul>
|
||||
|
||||
|
||||
@@ -347,7 +347,7 @@
|
||||
</section>
|
||||
<section class="mb-10">
|
||||
<h3 id="tar-probe" class="text-lg font-medium mb-2">3.3 TAR-сканер</h3>
|
||||
<p class="mb-2">Третья пилюля вкладки SCP80 показывает, <strong>какие OTA-приложения зарегистрированы на карте</strong>: отправляется <strong>по одному защищённому пакету на каждый выбранный TAR</strong> с безобидной C-APDU (по умолчанию <code class="font-mono text-sm">SELECT MF</code>). v3.26.0: строки — это список TAR выбранного пресета (никаких произвольных TAR), каждая строка использует свой <strong>MSL</strong> и свой <strong>keyset по умолчанию</strong> (разрешается из хранилища пресета — селектор keyset у сканера убран), а TAR, чей MSL требует защиты и у которого нет keyset по умолчанию, отклоняется до отправки. Разрешённый SPI1 должен иметь проверку счётчика (b5b4 ≠ 00) и запрашивать PoR. Кнопка <strong>Execute</strong> недоступна, пока не выбран пресет карты.</p>
|
||||
<p class="mb-2">Третья кнопка вкладки SCP80 показывает, <strong>какие OTA-приложения зарегистрированы на карте</strong>: отправляется <strong>по одному защищённому пакету на каждый выбранный TAR</strong> с безобидной C-APDU (по умолчанию <code class="font-mono text-sm">SELECT MF</code>). v3.26.0: строки — это список TAR выбранного пресета (никаких произвольных TAR), каждая строка использует свой <strong>MSL</strong> и свой <strong>keyset по умолчанию</strong> (разрешается из хранилища пресета — селектор keyset у сканера убран), а TAR, чей MSL требует защиты и у которого нет keyset по умолчанию, отклоняется до отправки. Разрешённый SPI1 должен иметь проверку счётчика (b5b4 ≠ 00) и запрашивать PoR. Кнопка <strong>Execute</strong> недоступна, пока не выбран пресет карты.</p>
|
||||
<p class="mb-2">Список показывает записи TAR выбранного пресета (все отмечены; кнопки <strong>Выбрать все</strong> / <strong>Снять все</strong> переключают быстро); подсказка строки называет её MSL и keyset по умолчанию.</p>
|
||||
<p class="mb-2">Каждый TAR классифицируется по ответу карты: <strong>зарегистрировано</strong> (ENVELOPE 9000 + PoR со SW и данными приложения), <strong>принято, нет ответа</strong> (PoR без R-APDU), <strong>принято, нет PoR</strong> (пакет молча отброшен) и <strong>отклонено</strong> (ENVELOPE не 9000, обычно <code class="font-mono text-sm">6200</code> для TAR без зарегистрированного приложения). Таблица результата появляется в окне операции; строка под кнопкой Execute показывает <code class="font-mono text-sm">зарегистрировано N / M</code>.</p>
|
||||
<p class="mb-2">Каждый принятый картой пакет <strong>расходует счётчик</strong>, который сохраняется в keyset пресета — проба отклоняется, если в разрешённом SPI1 нет проверки счётчика, если в пресете нет keyset по умолчанию для строки (при ненулевом MSL он обязателен) или запись называет несуществующий номер keyset. На живой карте: <code class="font-mono text-sm">000000</code>, <code class="font-mono text-sm">B00000</code> и <code class="font-mono text-sm">B00001</code> зарегистрированы (приложения компактного формата), расширенный формат, USAT, мультиплексирование и CASD отклонены с <code class="font-mono text-sm">6200</code>.</p>
|
||||
@@ -405,7 +405,7 @@
|
||||
<p class="mb-3">Подключение к локальному <a href="https://github.com/anttro/simple" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-simple-server</a> для работы с картой: введите URL сервера (по умолчанию <code class="font-mono text-sm">http://127.0.0.1:8080</code>) и нажмите <strong>Подключиться</strong>. Область статуса показывает состояние ридера/карты, а <strong>Подключить карту</strong> (пере)инициализирует карту после вставки. Подвкладки: <strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong> и <strong>Отправка APDU</strong>. <strong>«Профайлер»</strong> и <strong>«Симулятор»</strong> — отдельные вкладки верхнего уровня.</p>
|
||||
|
||||
<h3 id="file-manager" class="text-lg font-medium mb-2">6.1 Файловый менеджер</h3>
|
||||
<p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по <strong>FID</strong> или символьному <strong>имени</strong> (пиллы и кнопка <strong>«Проверить все файлы»</strong> закреплены над прокручиваемым деревом; выбор сохраняется в <code class="font-mono text-sm">localStorage</code>). При выборе файла над содержимым также показываются FID, тип файла, размер / структура записей и декодированный FCI — в рамке с символьным именем файла. Если FCI ссылается на свои правила доступа через запись <code class="font-mono text-sm">EF.ARR</code> (атрибут безопасности <code class="font-mono text-sm">8B</code>), правила указанной записи показываются с отступом под этой строкой: они берутся из копий <code class="font-mono text-sm">EF.ARR</code> карты — копии MF (<code class="font-mono text-sm">2F06</code>), копий DF.TELECOM/DF.GSM (<code class="font-mono text-sm">6F06</code>) и копии ADF.USIM (<code class="font-mono text-sm">6F06</code>): читаются один раз за сессию при equip, обновляются при сбросе/equip и после записи <code class="font-mono text-sm">EF.ARR</code>; снимок разрешает те же ссылки из собственной захваченной копии. Побеждает копия собственного DF файла, с подъёмом до MF; ссылка, для которой в этой цепочке копии нет, остаётся неразрешённой, если только кандидат не единственный.</p>
|
||||
<p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по <strong>FID</strong> или символьному <strong>имени</strong> (кнопки вместе с <strong>«Проверить все файлы»</strong> закреплены над прокручиваемым деревом; выбор сохраняется в <code class="font-mono text-sm">localStorage</code>). При выборе файла над содержимым также показываются FID, тип файла, размер / структура записей и декодированный FCI — в рамке с символьным именем файла. Если FCI ссылается на свои правила доступа через запись <code class="font-mono text-sm">EF.ARR</code> (атрибут безопасности <code class="font-mono text-sm">8B</code>), правила указанной записи показываются с отступом под этой строкой: они берутся из копий <code class="font-mono text-sm">EF.ARR</code> карты — копии MF (<code class="font-mono text-sm">2F06</code>), копий DF.TELECOM/DF.GSM (<code class="font-mono text-sm">6F06</code>) и копии ADF.USIM (<code class="font-mono text-sm">6F06</code>): читаются один раз за сессию при equip, обновляются при сбросе/equip и после записи <code class="font-mono text-sm">EF.ARR</code>; снимок разрешает те же ссылки из собственной захваченной копии. Побеждает копия собственного DF файла, с подъёмом до MF; ссылка, для которой в этой цепочке копии нет, остаётся неразрешённой, если только кандидат не единственный.</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Читать сырые данные / Декодировать</strong> — чтение выбранного файла в виде hex-дампа или таблицы декодированных полей (клиентские декодеры EF: IMSI, ICCID, SPN, списки PLMN, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); таблица декодируется в браузере из тех же сырых байт (без серверного декодирования). Подсвеченная кнопка — текущий вид; нажатие любой из них (пере)читывает файл. «Декодировать» недоступна, если для файла нет клиентского декодера (проверка по имени/FID до чтения).</li>
|
||||
<li><strong>Редактировать сырые данные</strong> — изменение hex-данных, <strong>Сохранить</strong> для записи (или <strong>Отмена</strong>); transparent-файлы редактируются в textarea, record-файлы — по одному полю на запись с флажками выбора. «Редактировать сырые данные» сначала переключает на hex-вид и при необходимости читает файл.</li>
|
||||
|
||||
+441
-10
@@ -1104,9 +1104,9 @@
|
||||
<div id="pysim-body" class="hidden">
|
||||
<div class="flex flex-wrap justify-center gap-1 mb-2">
|
||||
<button class="pysim-subtab px-3 py-1 text-sm rounded-full bg-blue-600 text-white" data-pysim-sub="files" data-l10n="File manager">File manager</button>
|
||||
<button class="pysim-subtab px-3 py-1 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600 disabled:opacity-40 disabled:cursor-not-allowed" data-pysim-sub="services" data-needs="card" data-l10n="Services">Services</button>
|
||||
<button class="pysim-subtab px-3 py-1 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-pysim-sub="cmd" data-l10n="pySim command line">pySim command line</button>
|
||||
<button class="pysim-subtab px-3 py-1 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-pysim-sub="apdu" data-l10n="Raw APDU">Raw APDU</button>
|
||||
<button class="pysim-subtab px-3 py-1 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600 disabled:opacity-40 disabled:cursor-not-allowed" data-pysim-sub="services" data-needs="card" data-l10n="Services">Services</button>
|
||||
</div>
|
||||
|
||||
<div id="pysim-sub-files">
|
||||
@@ -1117,6 +1117,17 @@
|
||||
<span data-l10n="Sort:">Sort:</span>
|
||||
<span class="pysim-fs-sort-pill px-2 py-0.5 rounded cursor-pointer bg-blue-600 text-white" data-fs-sort="fid" onclick="pysimFsSetSort('fid')">FID</span>
|
||||
<span class="pysim-fs-sort-pill px-2 py-0.5 rounded cursor-pointer bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300" data-fs-sort="name" onclick="pysimFsSetSort('name')" data-l10n="Name">Name</span>
|
||||
<span class="mx-1 text-gray-400 dark:text-slate-600">·</span>
|
||||
<button type="button" onclick="pysimFsExpandAll()" class="px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Expand all">Expand all</button>
|
||||
<button type="button" onclick="pysimFsCollapseAll()" class="px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Collapse all">Collapse all</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 mb-1 text-gray-500 dark:text-slate-400">
|
||||
<span data-l10n="Filter:">Filter:</span>
|
||||
<div class="relative">
|
||||
<button type="button" id="pysim-fs-filter-btn" aria-haspopup="true" aria-expanded="false" onclick="pysimFsFilterToggle()" class="px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 max-w-xs truncate">All</button>
|
||||
<div id="pysim-fs-filter-menu" role="group" aria-label="Filter" class="hidden absolute left-0 mt-1 z-50 w-64 max-h-64 overflow-y-auto rounded border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-800 p-2 shadow-lg"></div>
|
||||
</div>
|
||||
<button type="button" id="pysim-fs-filter-clear" onclick="pysimFsFilterClear()" class="hidden px-1 text-xs text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300" data-l10n-title="Reset filter" aria-label="Reset filter">✕</button>
|
||||
</div>
|
||||
<button id="pysim-fs-probe-btn" data-needs="card" onclick="pysimFsProbeAll()" class="w-full mb-1 px-2 py-0.5 rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Probe all files">Probe all files</button>
|
||||
<div id="pysim-fs-probe-status" class="hidden mb-1 text-gray-500 dark:text-slate-400"></div>
|
||||
@@ -1979,7 +1990,7 @@
|
||||
// ===== Version =====
|
||||
// Single source of truth for the PWA version: shown in the header and used
|
||||
// by the server version check in pysimConnect().
|
||||
const SIMPLE_VERSION = '3.39.0';
|
||||
const SIMPLE_VERSION = '3.40.4';
|
||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||
|
||||
// ===== view history (the browser Back/Forward buttons) =====
|
||||
@@ -4410,6 +4421,11 @@ function uiccStdFilesLoad() {
|
||||
Object.keys(_chains).forEach(id => {
|
||||
if (document.getElementById(id + '-rows')) chainRender(id);
|
||||
});
|
||||
// The tree filter's directory rule needs the files' paths: a
|
||||
// tree drawn while the list was still loading kept every
|
||||
// directory, so redraw it now under an active filter.
|
||||
if (pysimFsTreeRoot && pysimFsFilter.size && !pysimFsFilter.has(FS_FILTER_ALL) &&
|
||||
document.getElementById('pysim-fs-tree')) pysimFsRenderTree();
|
||||
return _uiccStdFiles;
|
||||
});
|
||||
}
|
||||
@@ -14299,6 +14315,11 @@ async function cardsFetch() {
|
||||
cardsRebuildSelect();
|
||||
cardsRender();
|
||||
tarRender();
|
||||
// A card may already be equipped when the presets arrive (the server
|
||||
// starts with a card, or the fetch simply raced the first status poll):
|
||||
// run the auto-selection now that the store is loaded. It is a no-op
|
||||
// once the card's preset is already selected (the latched ICCID guard).
|
||||
cardsAutoSelectByIccid(_pysimCardIccid);
|
||||
scp81PushPskMap();
|
||||
spPresetWarningRender();
|
||||
return true;
|
||||
@@ -15193,12 +15214,16 @@ let _cardsAutoIccid = null;
|
||||
function cardsAutoSelectByIccid(iccid) {
|
||||
// Act only when the equip reports a *new* ICCID: a manual preset choice
|
||||
// for the same card must not be overridden by later status updates.
|
||||
// The guard is latched only once the card's preset is actually found and
|
||||
// selected - a miss (the preset store not loaded yet, or no preset for
|
||||
// this card) is retried when the store arrives (cardsFetch) so a card
|
||||
// equipped before the presets loaded still gets auto-selected.
|
||||
const norm = cardsNormIccid(iccid);
|
||||
if (!norm) { _cardsAutoIccid = null; return -1; }
|
||||
if (norm === _cardsAutoIccid) return -1;
|
||||
_cardsAutoIccid = norm;
|
||||
const idx = cardsFindByIccid(norm);
|
||||
if (idx < 0) return -1;
|
||||
_cardsAutoIccid = norm;
|
||||
const sp = document.getElementById('sp-card-sel');
|
||||
if (sp) sp.value = String(idx);
|
||||
const ram = document.getElementById('ram-card-sel');
|
||||
@@ -16073,6 +16098,346 @@ function pysimFsSetSort(mode) {
|
||||
pysimFsRenderTree();
|
||||
}
|
||||
|
||||
// The tree filter: a multiselect of file criteria, applied when the tree
|
||||
// renders. The criteria name standard files by their symbolic name - the
|
||||
// same value in the tree nodes and in frontend/uicc_files.json - so a file
|
||||
// can belong to several criteria (EF.SPN is a provider name and the file of
|
||||
// the "Service Provider Name" service). `all` is the exclusive default.
|
||||
// The categories are shown under three headings (`group`): the subscriber
|
||||
// data, the network-selection data, and services (one option per file-backed
|
||||
// feature of the service cross-reference).
|
||||
const FS_FILTER_ALL = 'all';
|
||||
// "Custom files" is not a name-based criterion: it matches the injected
|
||||
// custom-file nodes by their `custom` flag (user-chosen names).
|
||||
const FS_FILTER_CUSTOM = 'custom';
|
||||
const FS_FILTER_GROUPS = [
|
||||
{ id: 'identifiers', label: 'Identifiers', group: 'Identifiers', files: [
|
||||
'EF.ICCID', 'EF.IMSI', 'EF.MSISDN', 'EF.IMPI', 'EF.IMPU', 'EF.SUPI_NAI', 'EF.GID1', 'EF.GID2'] },
|
||||
{ id: 'plmn-selection', label: 'PLMN selection', group: 'PLMN', files: [
|
||||
'EF.PLMNsel', 'EF.PLMNwAcT', 'EF.OPLMNwAcT', 'EF.HPLMNwAcT', 'EF.EHPLMN', 'EF.FPLMN',
|
||||
'EF.HPPLMN', 'EF.LRPLMNSI', 'EF.EHPLMNPI', 'EF.MCHPPLMN'] },
|
||||
{ id: 'provider-names', label: 'Provider names', group: 'Identifiers', files: [
|
||||
'EF.SPN', 'EF.PNN', 'EF.OPL', 'EF.OPL5G', 'EF.SPDI', 'EF.SPNI', 'EF.PNNI', 'EF.CNL'] },
|
||||
{ id: 'wlan', label: 'WLAN / I-WLAN', group: 'PLMN', files: [
|
||||
'EF.UPLMNWLAN', 'EF.OPLMNWLAN', 'EF.WRI', 'EF.HWSIDL', 'EF.WEHPLMNPI', 'EF.WHPI',
|
||||
'EF.WLRPLMN', 'EF.HPLMNDAI'] },
|
||||
];
|
||||
const FS_FILTER_GROUP_ORDER = ['Identifiers', 'PLMN', 'Services'];
|
||||
|
||||
// One option per file-backed feature of the service cross-reference: a
|
||||
// capability/RFU row has no files, so there is nothing to filter to. The
|
||||
// map is declared later in this file, so it is read when this is called,
|
||||
// never while the filter block itself is evaluated.
|
||||
function fsFilterServiceOptions() {
|
||||
return Object.keys(SERVICES_FS_MAP)
|
||||
.filter(name => (SERVICES_FS_MAP[name] || []).length)
|
||||
.map(name => ({ id: 'svc:' + name, label: name, files: SERVICES_FS_MAP[name] }));
|
||||
}
|
||||
|
||||
// The inverted index: uppercased file name -> Set(criterion ids). It is
|
||||
// static (the file lists and the service map do not change), so it is built
|
||||
// once on first use.
|
||||
let _fsFilterIndex = null;
|
||||
|
||||
function fsFilterIndexBuild(groups, services) {
|
||||
const idx = new Map();
|
||||
for (const o of (groups || []).concat(services || [])) {
|
||||
for (const f of (o.files || [])) {
|
||||
const key = String(f).toUpperCase();
|
||||
if (!idx.has(key)) idx.set(key, new Set());
|
||||
idx.get(key).add(o.id);
|
||||
}
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
function fsFilterIndex() {
|
||||
if (!_fsFilterIndex) _fsFilterIndex = fsFilterIndexBuild(FS_FILTER_GROUPS, fsFilterServiceOptions());
|
||||
return _fsFilterIndex;
|
||||
}
|
||||
|
||||
// --- the directory rule: the canonical paths of the selected files ---
|
||||
// A directory stays in a filtered tree only when it lies on the path of a
|
||||
// file the filter selects, so the ancestors come from the standard file list
|
||||
// (frontend/uicc_files.json, the same source the SELECT picker and the
|
||||
// services editor use). A name can live in several trees (EF.SPN is in
|
||||
// DF.GSM and in ADF.USIM), so the index keeps every path of a name.
|
||||
// The list is fetched once at startup (`_uiccStdFiles`, filled by
|
||||
// uiccStdFilesLoad); until it arrives the ancestors are unknown and the
|
||||
// directories are kept - never hide what cannot be judged.
|
||||
let _fsFilterPathIndex = null;
|
||||
let _fsFilterPathIndexSrc = null;
|
||||
let _fsFilterPaths = null; // { key, paths } of the current selection
|
||||
let _fsFilterPathsKey = null;
|
||||
|
||||
function fsFilterPathIndexBuild(files) {
|
||||
const idx = new Map();
|
||||
for (const e of (files || [])) {
|
||||
if (!e || !e.name || !e.path) continue;
|
||||
const name = String(e.name).toUpperCase();
|
||||
const p = String(e.path).toUpperCase();
|
||||
if (!idx.has(name)) idx.set(name, []);
|
||||
if (idx.get(name).indexOf(p) < 0) idx.get(name).push(p);
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
function fsFilterPathIndex() {
|
||||
if (_fsFilterPathIndex && _fsFilterPathIndexSrc === _uiccStdFiles) return _fsFilterPathIndex;
|
||||
_fsFilterPathIndexSrc = _uiccStdFiles;
|
||||
_fsFilterPathIndex = fsFilterPathIndexBuild(_uiccStdFiles);
|
||||
return _fsFilterPathIndex;
|
||||
}
|
||||
|
||||
// The custom files' canonical paths the selection picks. Custom files sit
|
||||
// outside the standard list (user-chosen names and DFs), so their own paths
|
||||
// are the only ancestry the filter has for them - without this the "Custom
|
||||
// files" filter would hide the DFs holding them.
|
||||
function fsFilterCustomPaths() {
|
||||
const list = (typeof pysimCustomFiles !== 'undefined' && pysimCustomFiles) || [];
|
||||
const out = [];
|
||||
for (const c of list) {
|
||||
const p = String((c && c.path) || '').toUpperCase();
|
||||
if (!p) continue;
|
||||
if (!pysimFsFilterMatch({ name: c.name, custom: true })) continue;
|
||||
out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The selection's signature: the entries plus the custom files (that set
|
||||
// changes when the user adds or removes one). The cache is keyed by it, so a
|
||||
// direct `pysimFsFilter` assignment recomputes as well as the setters'
|
||||
// explicit invalidation does.
|
||||
function fsFilterPathsKeyOf() {
|
||||
return Array.from(pysimFsFilter).sort().join('|') + '#' + fsFilterCustomPaths().sort().join('|');
|
||||
}
|
||||
|
||||
function pysimFsFilterPathsInvalidate() {
|
||||
_fsFilterPaths = null;
|
||||
_fsFilterPathsKey = null;
|
||||
}
|
||||
|
||||
// The uppercased canonical paths of every file the current selection picks,
|
||||
// or null while the list is unavailable (not fetched yet, or an empty/failed
|
||||
// load): the caller keeps the directories then instead of hiding them all.
|
||||
function pysimFsFilterMatchingPaths() {
|
||||
if (!Array.isArray(_uiccStdFiles) || !_uiccStdFiles.length) return null;
|
||||
const key = fsFilterPathsKeyOf();
|
||||
if (_fsFilterPaths && _fsFilterPathsKey === key) return _fsFilterPaths;
|
||||
const out = new Set();
|
||||
const idx = fsFilterIndex();
|
||||
for (const [name, ids] of idx) {
|
||||
let hit = false;
|
||||
for (const id of pysimFsFilter) if (ids.has(id)) { hit = true; break; }
|
||||
if (!hit) continue;
|
||||
for (const p of (fsFilterPathIndex().get(name) || [])) out.add(p);
|
||||
}
|
||||
for (const p of fsFilterCustomPaths()) out.add(p);
|
||||
_fsFilterPaths = out;
|
||||
_fsFilterPathsKey = key;
|
||||
return out;
|
||||
}
|
||||
|
||||
let pysimFsFilter = new Set([FS_FILTER_ALL]);
|
||||
|
||||
// The persisted selection, normalised: `all` is exclusive, and a missing or
|
||||
// unusable entry falls back to `all` (show everything).
|
||||
function pysimFsFilterRestore() {
|
||||
let saved = [];
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem('simple_fs_filter') || '[]');
|
||||
if (Array.isArray(raw)) saved = raw.filter(v => typeof v === 'string' && v);
|
||||
} catch (e) { /* ignore a broken entry */ }
|
||||
pysimFsFilter = new Set(saved);
|
||||
if (!pysimFsFilter.size || pysimFsFilter.has(FS_FILTER_ALL)) pysimFsFilter = new Set([FS_FILTER_ALL]);
|
||||
pysimFsFilterPathsInvalidate();
|
||||
}
|
||||
pysimFsFilterRestore();
|
||||
|
||||
// The label of one filter entry: the custom flag, a category's own name or
|
||||
// the feature name of a per-service entry.
|
||||
function pysimFsFilterLabel(id) {
|
||||
if (id === FS_FILTER_CUSTOM) return t('Custom files');
|
||||
const category = FS_FILTER_GROUPS.find(g => g.id === id);
|
||||
if (category) return t(category.label);
|
||||
if (id.indexOf('svc:') === 0) return id.slice(4);
|
||||
return id;
|
||||
}
|
||||
|
||||
// The row's caption: the default or a single choice by name, otherwise the
|
||||
// number of selected entries.
|
||||
function pysimFsFilterSummary() {
|
||||
if (!pysimFsFilter.size || pysimFsFilter.has(FS_FILTER_ALL)) return t('All');
|
||||
if (pysimFsFilter.size === 1) return pysimFsFilterLabel(Array.from(pysimFsFilter)[0]);
|
||||
return t('Selected: {n}').replace('{n}', pysimFsFilter.size);
|
||||
}
|
||||
|
||||
// The collapsed filter: a single row that expands into a checkbox list -
|
||||
// "All"/"Custom files", then a heading per group with its categories (the
|
||||
// Services heading carries one checkbox per file-backed feature).
|
||||
function pysimFsFilterRender() {
|
||||
const menu = document.getElementById('pysim-fs-filter-menu');
|
||||
const btn = document.getElementById('pysim-fs-filter-btn');
|
||||
if (!menu) return;
|
||||
const active = pysimFsFilter.size > 0 && !pysimFsFilter.has(FS_FILTER_ALL);
|
||||
const services = fsFilterServiceOptions();
|
||||
const item = (id, label) =>
|
||||
'<label class="flex items-center gap-2 px-2 py-0.5 rounded cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-700 text-xs text-gray-700 dark:text-slate-200">' +
|
||||
'<input type="checkbox" data-filter="' + esc(id) + '"' +
|
||||
(pysimFsFilter.has(id) ? ' checked' : '') +
|
||||
' onchange="pysimFsFilterSetEntry(this.dataset.filter, this.checked)">' +
|
||||
'<span>' + esc(label) + '</span></label>';
|
||||
const heading = group =>
|
||||
'<div class="px-2 mt-1 uppercase text-gray-500 dark:text-slate-400">' + esc(t(group)) + '</div>';
|
||||
if (!FS_FILTER_GROUPS.length && !services.length) {
|
||||
menu.innerHTML = '<div class="px-2 text-gray-500 dark:text-slate-400">' + esc(t('(no filters yet)')) + '</div>';
|
||||
} else {
|
||||
let out = item(FS_FILTER_ALL, t('All')) + item(FS_FILTER_CUSTOM, t('Custom files'));
|
||||
for (const group of FS_FILTER_GROUP_ORDER) {
|
||||
const categories = FS_FILTER_GROUPS.filter(g => g.group === group);
|
||||
const extra = group === 'Services' ? services : [];
|
||||
if (!categories.length && !extra.length) continue;
|
||||
out += heading(group);
|
||||
out += categories.map(g => item(g.id, t(g.label))).join('');
|
||||
out += extra.map(o => item(o.id, o.label)).join('');
|
||||
}
|
||||
menu.innerHTML = out;
|
||||
}
|
||||
if (btn) {
|
||||
btn.textContent = pysimFsFilterSummary();
|
||||
// a non-"All" selection stands out (blue, like the active sort pill)
|
||||
btn.classList.toggle('bg-blue-600', active);
|
||||
btn.classList.toggle('text-white', active);
|
||||
btn.classList.toggle('text-gray-700', !active);
|
||||
btn.classList.toggle('dark:text-slate-300', !active);
|
||||
btn.classList.toggle('hover:bg-gray-200', !active);
|
||||
btn.classList.toggle('dark:hover:bg-slate-700', !active);
|
||||
}
|
||||
const clearBtn = document.getElementById('pysim-fs-filter-clear');
|
||||
if (clearBtn) {
|
||||
clearBtn.classList.toggle('hidden', !active);
|
||||
clearBtn.setAttribute('aria-label', t('Reset filter'));
|
||||
}
|
||||
}
|
||||
|
||||
// Expand or collapse the menu (`force` overrides the toggle; the outside
|
||||
// click and Escape handlers pass `false`).
|
||||
function pysimFsFilterToggle(force) {
|
||||
const menu = document.getElementById('pysim-fs-filter-menu');
|
||||
const btn = document.getElementById('pysim-fs-filter-btn');
|
||||
if (!menu) return;
|
||||
const open = force === undefined ? menu.classList.contains('hidden') : !!force;
|
||||
if (open) pysimFsFilterRender();
|
||||
menu.classList.toggle('hidden', !open);
|
||||
if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
}
|
||||
|
||||
// One checkbox: `all` is exclusive, and an emptied selection means "all".
|
||||
function pysimFsFilterSetEntry(entry, on) {
|
||||
const next = new Set(pysimFsFilter);
|
||||
if (entry === FS_FILTER_ALL) {
|
||||
next.clear();
|
||||
next.add(FS_FILTER_ALL);
|
||||
} else if (on) {
|
||||
next.delete(FS_FILTER_ALL);
|
||||
next.add(entry);
|
||||
} else {
|
||||
next.delete(entry);
|
||||
if (!next.size) next.add(FS_FILTER_ALL);
|
||||
}
|
||||
pysimFsFilter = next;
|
||||
pysimFsFilterPathsInvalidate();
|
||||
localStorage.setItem('simple_fs_filter', JSON.stringify(Array.from(pysimFsFilter)));
|
||||
pysimFsFilterRender();
|
||||
pysimFsRenderTree();
|
||||
pysimFsAutoExpandForFilter();
|
||||
}
|
||||
|
||||
// The ✕ next to the filter selector: back to "All".
|
||||
function pysimFsFilterClear() {
|
||||
pysimFsFilterSetEntry(FS_FILTER_ALL, true);
|
||||
}
|
||||
|
||||
// The cross-reference inverted to service number -> feature name, per file
|
||||
// table. Both maps are declared later in this file, so they are read on the
|
||||
// first call, never while the filter block itself is evaluated.
|
||||
let _servicesFeatureByNumber = null;
|
||||
|
||||
function servicesFeatureByNumber() {
|
||||
if (!_servicesFeatureByNumber) {
|
||||
const out = { sst: {}, ust: {}, est: {}, ist: {} };
|
||||
for (const f of SERVICES_CROSS_REF) {
|
||||
for (const table of Object.keys(out)) {
|
||||
const n = f[table];
|
||||
if (n === null || n === undefined) continue;
|
||||
out[table][n] = f.f;
|
||||
}
|
||||
}
|
||||
_servicesFeatureByNumber = out;
|
||||
}
|
||||
return _servicesFeatureByNumber;
|
||||
}
|
||||
|
||||
// The service row's cross-link to the file tree: a capability-only service
|
||||
// names no file, so it carries no link.
|
||||
function pysimFsFilterBtnHtml(table, n) {
|
||||
if (!table) return '';
|
||||
const feature = (servicesFeatureByNumber()[table] || {})[n];
|
||||
if (!feature || !(SERVICES_FS_MAP[feature] || []).length) return '';
|
||||
const label = t('To EF filter');
|
||||
return '<button type="button" class="ml-1 px-1 text-xs text-blue-600 dark:text-blue-400 hover:underline" onclick="pysimFsSetFilterToService(\'' +
|
||||
esc(table) + '\', ' + Number(n) + ')" title="' + esc(label) + '" aria-label="' + esc(label) + '">▾</button>';
|
||||
}
|
||||
|
||||
// Select exactly that service's files in the file tree, persisting the choice
|
||||
// like any other filter entry.
|
||||
function pysimFsSetFilterToService(table, n) {
|
||||
const feature = (servicesFeatureByNumber()[table] || {})[n];
|
||||
if (!feature || !(SERVICES_FS_MAP[feature] || []).length) return;
|
||||
pysimFsFilter = new Set(['svc:' + feature]);
|
||||
pysimFsFilterPathsInvalidate();
|
||||
localStorage.setItem('simple_fs_filter', JSON.stringify(Array.from(pysimFsFilter)));
|
||||
pysimFsFilterRender();
|
||||
pysimFsRenderTree();
|
||||
pysimFsAutoExpandForFilter();
|
||||
}
|
||||
|
||||
// Whether a file node passes the active filter. An empty selection and
|
||||
// `all` both show everything; otherwise the node's name must be listed under
|
||||
// one of the selected criteria.
|
||||
function pysimFsFilterMatch(node) {
|
||||
if (!pysimFsFilter.size || pysimFsFilter.has(FS_FILTER_ALL)) return true;
|
||||
// "Custom files" matches by the node's custom flag, not its name (custom
|
||||
// files carry user-chosen names outside the standard list).
|
||||
if (pysimFsFilter.has(FS_FILTER_CUSTOM) && node && node.custom) return true;
|
||||
const name = String((node && node.name) || '').toUpperCase();
|
||||
const ids = name ? fsFilterIndex().get(name) : null;
|
||||
if (!ids) return false;
|
||||
for (const id of pysimFsFilter) if (ids.has(id)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Whether a node stays in the filtered tree. An empty selection and `all`
|
||||
// show everything; otherwise a file shows when it matches, and a directory
|
||||
// only when it matches itself (a service may name a DF - the GPRS feature is
|
||||
// DF.GSM) or lies on the path of a matching file (its canonical path is a
|
||||
// prefix of that file's registry path). The MF root always stays, and while
|
||||
// the standard list is unavailable the ancestors cannot be computed, so every
|
||||
// directory is kept.
|
||||
function pysimFsFilterShow(node) {
|
||||
if (!pysimFsFilter.size || pysimFsFilter.has(FS_FILTER_ALL)) return true;
|
||||
if (!node) return false;
|
||||
if (!node.isDir) return pysimFsFilterMatch(node);
|
||||
if (pysimFsFilterMatch(node)) return true;
|
||||
const path = String(pysimFsNodePath(node) || '').toUpperCase();
|
||||
if (!path || path === 'MF') return true;
|
||||
const paths = pysimFsFilterMatchingPaths();
|
||||
if (!paths) return true;
|
||||
for (const p of paths) if (p === path || p.indexOf(path + '/') === 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function pysimFsRefresh() {
|
||||
const detailEl = document.getElementById('pysim-fs-detail');
|
||||
detailEl.classList.add('hidden');
|
||||
@@ -16271,6 +16636,7 @@ function pysimFsRenderNode(node, depth) {
|
||||
html += '<div class="text-gray-400 dark:text-slate-500">' + pad + ' ' + esc(t('(empty)')) + '</div>';
|
||||
} else {
|
||||
for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
|
||||
if (!pysimFsFilterShow(child)) continue;
|
||||
html += pysimFsRenderNode(child, depth + 1);
|
||||
}
|
||||
}
|
||||
@@ -16337,6 +16703,42 @@ async function pysimFsToggleDir(path) {
|
||||
if (node.expanded) pysimRefresh();
|
||||
}
|
||||
|
||||
// Expand every directory that exists (skipping ones the probe already found
|
||||
// absent), loading children on demand. Also the auto-action after a filter
|
||||
// narrows to a specific category, so the matched files are visible.
|
||||
async function pysimFsExpandAll() {
|
||||
const stack = [pysimFsTreeRoot];
|
||||
while (stack.length) {
|
||||
const node = stack.pop();
|
||||
if (!node || !node.isDir) continue;
|
||||
if (node.exists === false) continue;
|
||||
if (!node.children) {
|
||||
await pysimFsLoadChildren(node);
|
||||
if (node.exists === false) continue;
|
||||
}
|
||||
node.expanded = true;
|
||||
for (const c of (node.children || [])) if (c.isDir) stack.push(c);
|
||||
}
|
||||
pysimFsRenderTree();
|
||||
}
|
||||
|
||||
function pysimFsCollapseAll() {
|
||||
const stack = [pysimFsTreeRoot];
|
||||
while (stack.length) {
|
||||
const node = stack.pop();
|
||||
if (!node || !node.isDir) continue;
|
||||
node.expanded = false;
|
||||
for (const c of (node.children || [])) if (c.isDir) stack.push(c);
|
||||
}
|
||||
pysimFsRenderTree();
|
||||
}
|
||||
|
||||
// A filter that narrows to a specific category expands the tree so the
|
||||
// matched files are shown; "All"/empty leaves the tree as-is.
|
||||
function pysimFsAutoExpandForFilter() {
|
||||
if (pysimFsFilter.size > 0 && !pysimFsFilter.has(FS_FILTER_ALL)) pysimFsExpandAll();
|
||||
}
|
||||
|
||||
let pysimFsSelected = null;
|
||||
|
||||
function pysimFsInfoHtml(sel, name, opts) {
|
||||
@@ -26164,20 +26566,22 @@ const EF_PNN_ITEMS = {
|
||||
};
|
||||
|
||||
// Service-table and scalar editors (wave 2). The bits kind renders the whole
|
||||
// table as a filterable checkbox grid; its value is the decoder's list.
|
||||
// table as a filterable checkbox grid; its value is the decoder's list. The
|
||||
// service-table fields carry `table` (sst/ust/est/ist), which adds the row's
|
||||
// "To EF filter" cross-link to the file tree.
|
||||
const EF_ACC_CLASSES = {
|
||||
0: 'Class 0', 1: 'Class 1', 2: 'Class 2', 3: 'Class 3', 4: 'Class 4', 5: 'Class 5',
|
||||
6: 'Class 6', 7: 'Class 7', 8: 'Class 8', 9: 'Class 9', 11: 'Class 11',
|
||||
12: 'Class 12', 13: 'Class 13', 14: 'Class 14', 15: 'Class 15',
|
||||
};
|
||||
|
||||
const EF_UST_FIELDS = [{ key: 'services', label: 'Services', kind: 'bits', items: EF_UST_SERVICES }];
|
||||
const EF_IST_FIELDS = [{ key: 'services', label: 'Services', kind: 'bits', items: EF_IST_SERVICES }];
|
||||
const EF_EST_FIELDS = [{ key: 'services', label: 'Services', kind: 'bits', items: EF_EST_SERVICES }];
|
||||
const EF_UST_FIELDS = [{ key: 'services', label: 'Services', kind: 'bits', items: EF_UST_SERVICES, table: 'ust' }];
|
||||
const EF_IST_FIELDS = [{ key: 'services', label: 'Services', kind: 'bits', items: EF_IST_SERVICES, table: 'ist' }];
|
||||
const EF_EST_FIELDS = [{ key: 'services', label: 'Services', kind: 'bits', items: EF_EST_SERVICES, table: 'est' }];
|
||||
// EF.SST is the SIM table with two bits per service: the tri-state grid
|
||||
// (deleted 00 / off 10 / on 11) instead of the 1-bit checkbox grid.
|
||||
const EF_SST_FIELDS = [{
|
||||
key: 'services', label: 'Services', kind: 'bits2', items: EF_SST_SERVICES,
|
||||
key: 'services', label: 'Services', kind: 'bits2', items: EF_SST_SERVICES, table: 'sst',
|
||||
hint: 'Two bits per service (TS 51.011 10.3.7): deleted = not allocated (00), '
|
||||
+ 'off = allocated but not activated (10), on = allocated and activated (11).',
|
||||
}];
|
||||
@@ -26563,7 +26967,8 @@ function efDecFieldHtml(f, value) {
|
||||
'<input type="checkbox" data-bit="' + n + '" data-bit-name="' + esc(name) + '"' +
|
||||
(selected.has(String(n)) ? ' checked' : '') + ' onchange="pysimFsDecRowCheck(this)">' +
|
||||
'<span class="font-mono text-gray-500 dark:text-slate-400">' + n + '</span>' +
|
||||
'<span class="break-all">' + esc(t(name)) + '</span></label>';
|
||||
'<span class="break-all">' + esc(t(name)) + '</span>' +
|
||||
pysimFsFilterBtnHtml(f.table, n) + '</label>';
|
||||
}).join('') + '</div></div>';
|
||||
} else if (f.kind === 'bits2') {
|
||||
// the tri-state grid (EF.SST): one select per service, the decoder's
|
||||
@@ -26588,7 +26993,7 @@ function efDecFieldHtml(f, value) {
|
||||
'<select data-bit2="' + n + '" data-bit2-name="' + esc(name) + '" onchange="pysimFsDecRowCheck(this)" class="border border-gray-300 dark:border-slate-600 rounded px-1 bg-white dark:bg-slate-800 text-gray-800 dark:text-slate-200">' +
|
||||
states3.map(o => '<option value="' + o[0] + '"' + (o[0] === cur ? ' selected' : '') + '>' +
|
||||
esc(o[1]) + '</option>').join('') +
|
||||
'</select></label>';
|
||||
'</select>' + pysimFsFilterBtnHtml(f.table, n) + '</label>';
|
||||
}).join('') + '</div></div>';
|
||||
} else if (f.kind === 'select') {
|
||||
const cur = String(value === undefined || value === null ? '' : value);
|
||||
@@ -27541,6 +27946,7 @@ const LANG_RU = {
|
||||
'The number continues in EF.EXT5 - editing it is not supported': 'Номер продолжается в EF.EXT5 — редактирование не поддерживается',
|
||||
'Services': 'Сервисы',
|
||||
'Filter services': 'Фильтр сервисов',
|
||||
'To EF filter': 'В фильтр EF',
|
||||
'Off': 'Выключен',
|
||||
'On': 'Включён',
|
||||
'The service state must be deleted, off or on': 'Состояние сервиса должно быть: удалён, выключен или включён',
|
||||
@@ -27713,6 +28119,17 @@ const LANG_RU = {
|
||||
'No entries yet.': 'Записей пока нет.',
|
||||
'Sort:': 'Сортировка:',
|
||||
'Name': 'Имя',
|
||||
'Filter:': 'Фильтр:',
|
||||
'Reset filter': 'Сбросить фильтр',
|
||||
'(no filters yet)': '(фильтров пока нет)',
|
||||
'Identifiers': 'Идентификаторы',
|
||||
'PLMN selection': 'Выбор PLMN',
|
||||
'Provider names': 'Имена операторов',
|
||||
'WLAN / I-WLAN': 'WLAN / I-WLAN',
|
||||
'Custom files': 'Пользовательские файлы',
|
||||
'Selected: {n}': 'Выбрано: {n}',
|
||||
'Expand all': 'Развернуть все',
|
||||
'Collapse all': 'Свернуть все',
|
||||
'Probe all files': 'Проверить все файлы',
|
||||
'Feature': 'Функция',
|
||||
'SST': 'SST',
|
||||
@@ -28745,6 +29162,7 @@ function isViewVisible(id) {
|
||||
// Dynamic views build their labels with t() at render time; re-render the
|
||||
// visible ones after a language switch so they don't stay in the old language.
|
||||
function refreshDynamicI18n() {
|
||||
pysimFsFilterRender();
|
||||
if (isViewVisible('tab-profiler')) {
|
||||
if (profilerView === 'list') { profilerRenderList(); profilerRenderSnapshotList(); if (profilerListTab === 'custom') pysimCustomRender(); }
|
||||
else if (profilerView === 'editor') profilerRenderEditor();
|
||||
@@ -28820,6 +29238,19 @@ document.getElementById('theme-btn').textContent = document.documentElement.clas
|
||||
updateConvFields();
|
||||
updateSp();
|
||||
uiccStdFilesLoad();
|
||||
pysimFsFilterRender();
|
||||
// the file-tree filter's dropdown closes on an outside click or Escape (the
|
||||
// button toggles it, so a click on the button itself is left to its handler)
|
||||
document.addEventListener('click', e => {
|
||||
const target = e.target;
|
||||
if (!target || !target.closest) return;
|
||||
if (!target.closest('#pysim-fs-filter-btn') && !target.closest('#pysim-fs-filter-menu')) {
|
||||
pysimFsFilterToggle(false);
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') pysimFsFilterToggle(false);
|
||||
});
|
||||
updateSpKic();
|
||||
updateSpKid();
|
||||
chainInit('chain-sim');
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v484';
|
||||
const CACHE = 'simple-v490';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -146,6 +146,30 @@ test('cardsAutoSelectByIccid reacts to a card swap and to unreadable ICCIDs', ()
|
||||
assert.strictEqual(cardsAutoSelectByIccid('1234567890123456789'), -1);
|
||||
});
|
||||
|
||||
test('a miss is not latched, so the store arriving later still auto-selects', () => {
|
||||
// The card equips before /api/presets fills cards[] (the connect-time
|
||||
// race): the auto-selection must not latch the ICCID on a miss, or the
|
||||
// later cardsFetch would be a no-op and the preset would stay unselected
|
||||
// until the card is re-inserted.
|
||||
const els = fakeDoc();
|
||||
const applied = [];
|
||||
globalThis.cardsApply = idx => applied.push(idx);
|
||||
globalThis.cards = []; // presets not loaded yet
|
||||
globalThis._ramCardIdx = null;
|
||||
globalThis._cardsAutoIccid = null;
|
||||
assert.strictEqual(cardsAutoSelectByIccid(DIGITS), -1);
|
||||
assert.strictEqual(globalThis._cardsAutoIccid, null,
|
||||
'a missed auto-selection must not latch the ICCID');
|
||||
// the presets arrive: the retry (from cardsFetch) finds and selects the card
|
||||
globalThis.cards = [{ name: 'A', iccid: RAW_HEX }];
|
||||
assert.strictEqual(cardsAutoSelectByIccid(DIGITS), 0);
|
||||
assert.strictEqual(els['sp-card-sel'].value, '0');
|
||||
assert.strictEqual(els['ram-card-sel'].value, '0');
|
||||
assert.strictEqual(els['tar-card-sel'].value, '0');
|
||||
assert.deepStrictEqual(applied, ['0']);
|
||||
assert.strictEqual(globalThis._ramCardIdx, 0);
|
||||
});
|
||||
|
||||
test('cardsFindDuplicateIccid catches duplicates across stored formats', () => {
|
||||
globalThis.cards = [
|
||||
{ name: 'A', iccid: '1111111111111111111' },
|
||||
@@ -201,6 +225,13 @@ test('the card-state update wires the ICCID into the preset selection', () => {
|
||||
assert.ok(html.includes("(data.iccid ? ' | ICCID: <b>' + esc(data.iccid) + '</b>' : '')"));
|
||||
});
|
||||
|
||||
test('cardsFetch retries the auto-selection once the presets load', () => {
|
||||
// The equip may be reported before /api/presets resolves, so the fetch
|
||||
// must re-run the auto-selection (a no-op once the card is selected).
|
||||
assert.ok(html.includes('cardsAutoSelectByIccid(_pysimCardIccid);'),
|
||||
'cardsFetch must re-run the auto-selection after loading the store');
|
||||
});
|
||||
|
||||
test('cardsRender highlights the equipped card preset', () => {
|
||||
globalThis.t = s => s;
|
||||
const els = { 'cards-tbody': { innerHTML: '' } };
|
||||
|
||||
@@ -48,6 +48,7 @@ const FNS = [
|
||||
'efEncPlmnList', 'efListRows', 'efPlmnRowOrig', 'efEncAdn', 'efEncAdnExt', 'efPnnNameBytes', 'efPnnHexBytes', 'efPnnOrigTlv', 'efEncPnn',
|
||||
'efFindEncoder', 'efHasEncoder', 'efEncError',
|
||||
'efDecModelValues', 'efDecModelFor', 'efDecEditModel', 'efActTechList', 'efDecFieldHtml',
|
||||
'servicesFeatureByNumber', 'pysimFsFilterBtnHtml',
|
||||
'efDecRowHtml', 'efDecGridHtml', 'efDecItemRowHtml', 'efDecItemsHtml',
|
||||
'efDecRecordsHtml', 'efDecEditHtml',
|
||||
'efDecCollectValues', 'efDecCollectRows', 'efDecCollectItems',
|
||||
@@ -60,10 +61,11 @@ const FNS = [
|
||||
];
|
||||
|
||||
let code = 'var pysimFsSelected = null;\nvar pysimFsTreeRoot = null;\nvar pysimFsDecModel = null;\n'
|
||||
+ 'var pysimFsDecEditMode = false;\nvar pysimFsEditMode = false;\n';
|
||||
+ 'var pysimFsDecEditMode = false;\nvar pysimFsEditMode = false;\nvar _servicesFeatureByNumber = null;\n';
|
||||
for (const f of FNS) code += extractFunc(html, f) + '\n';
|
||||
for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP', 'EF_ACT_TECHS', 'EF_SPN_FIELDS',
|
||||
'EF_ADN_FIELDS', 'EF_PLMN_LIST', 'EF_PNN_ITEMS', 'EF_UST_SERVICES', 'EF_SST_SERVICES', 'EF_IST_SERVICES', 'EF_EST_SERVICES', 'EF_PHASE_NAMES', 'EF_ACC_CLASSES', 'EF_UST_FIELDS', 'EF_IST_FIELDS', 'EF_EST_FIELDS', 'EF_SST_FIELDS', 'EF_ACC_FIELDS', 'EF_PHASE_FIELDS', 'EF_HPPLMN_FIELDS', 'EF_AD_OP_MODES', 'EF_AD_FIELDS', 'EF_PLMN_ONLY_LIST', 'EF_ICCID_FIELDS', 'EF_IMSI_FIELDS', 'EF_CBMI_LIST', 'EF_CBMIR_LIST', 'EF_READONLY_NAMES', 'EF_SMSP_PARAMS', 'EF_SMSP_CUSTOM', 'EF_ENCODERS', 'EF_ADF_RIDS']) {
|
||||
'EF_ADN_FIELDS', 'EF_PLMN_LIST', 'EF_PNN_ITEMS', 'EF_UST_SERVICES', 'EF_SST_SERVICES', 'EF_IST_SERVICES', 'EF_EST_SERVICES', 'EF_PHASE_NAMES', 'EF_ACC_CLASSES', 'EF_UST_FIELDS', 'EF_IST_FIELDS', 'EF_EST_FIELDS', 'EF_SST_FIELDS', 'EF_ACC_FIELDS', 'EF_PHASE_FIELDS', 'EF_HPPLMN_FIELDS', 'EF_AD_OP_MODES', 'EF_AD_FIELDS', 'EF_PLMN_ONLY_LIST', 'EF_ICCID_FIELDS', 'EF_IMSI_FIELDS', 'EF_CBMI_LIST', 'EF_CBMIR_LIST', 'EF_READONLY_NAMES', 'EF_SMSP_PARAMS', 'EF_SMSP_CUSTOM', 'EF_ENCODERS', 'EF_ADF_RIDS',
|
||||
'SERVICES_CROSS_REF', 'SERVICES_FS_MAP']) {
|
||||
code += extractConst(html, c);
|
||||
}
|
||||
eval(code);
|
||||
@@ -739,6 +741,20 @@ test('the bits field renders a filtered checkbox grid and collects {n,name}', ()
|
||||
] });
|
||||
});
|
||||
|
||||
test('the service grids link file-backed rows to the file tree filter', () => {
|
||||
const ust = efDecEditHtml(efDecEditModel('EF.UST', '6f38', { kind: 'transparent', data: UST_ONE }), UST_ONE);
|
||||
// service 2 = FDN (files), service 99 = URI support by UICC (capability)
|
||||
assert.match(ust, /pysimFsSetFilterToService\('ust', 2\)/);
|
||||
assert.ok(!/pysimFsSetFilterToService\('ust', 99\)/.test(ust));
|
||||
const sst = efDecEditHtml(efDecEditModel('EF.SST', '6f38', { kind: 'transparent', data: '4C' }, '7F20'), '4C');
|
||||
// service 4 = SMS (files), service 1 = CHV1 disable function (capability)
|
||||
assert.match(sst, /pysimFsSetFilterToService\('sst', 4\)/);
|
||||
assert.ok(!/pysimFsSetFilterToService\('sst', 1\)/.test(sst));
|
||||
// EF.ACC's bits grid is not a service table - no cross-link at all
|
||||
const acc = efDecEditHtml(efDecEditModel('EF.ACC', '6f78', { kind: 'transparent', data: ACC_ONE }), ACC_ONE);
|
||||
assert.ok(!/pysimFsSetFilterToService/.test(acc));
|
||||
});
|
||||
|
||||
test('the bits filter hides the non-matching rows', () => {
|
||||
const rows = [
|
||||
{ textContent: '1 Local Phone Book', style: {} },
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
function extractConst(src, name) {
|
||||
const m = new RegExp('const ' + name + ' = ').exec(src);
|
||||
if (!m) throw new Error('const ' + name + ' not found');
|
||||
const begin = m.index + m[0].length;
|
||||
const opener = src[begin];
|
||||
const closer = opener === '[' ? ']' : (opener === '{' ? '}' : null);
|
||||
if (!closer) throw new Error('unsupported const ' + name);
|
||||
let depth = 0, i = begin;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === opener) depth++;
|
||||
else if (src[i] === closer) {
|
||||
depth--;
|
||||
if (depth === 0) { i++; break; }
|
||||
}
|
||||
}
|
||||
return 'var ' + name + ' = ' + src.slice(begin, i) + ';\n';
|
||||
}
|
||||
|
||||
function extractStrConst(src, name) {
|
||||
const m = new RegExp("const " + name + " = '([^']*)';").exec(src);
|
||||
if (!m) throw new Error('const ' + name + ' not found');
|
||||
return 'var ' + name + " = '" + m[1] + "';\n";
|
||||
}
|
||||
|
||||
// module state the extracted functions close over
|
||||
let pysimFsFilter = new Set(['all']);
|
||||
let _fsFilterIndex = null;
|
||||
// The shipped standard list stands in for the fetched one (uicc_files.json is
|
||||
// what uiccStdFilesLoad caches; the filter reads its entries synchronously).
|
||||
const stdAsset = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'uicc_files.json'), 'utf8'));
|
||||
let _uiccStdFiles = stdAsset.files;
|
||||
let _fsFilterPathIndex = null;
|
||||
let _fsFilterPathIndexSrc = null;
|
||||
let _fsFilterPaths = null;
|
||||
let _fsFilterPathsKey = null;
|
||||
let pysimCustomFiles = [];
|
||||
const _store = {};
|
||||
let _treeRenders = 0;
|
||||
let pysimFsTreeRoot = null; // expand-all walks this; null = nothing to expand
|
||||
let pysimFsLoadChildren = async node => { if (!node.children) node.children = []; };
|
||||
|
||||
globalThis.localStorage = {
|
||||
getItem: k => (Object.prototype.hasOwnProperty.call(_store, k) ? _store[k] : null),
|
||||
setItem: (k, v) => { _store[k] = String(v); },
|
||||
removeItem: k => { delete _store[k]; },
|
||||
};
|
||||
globalThis.esc = s => String(s);
|
||||
globalThis.t = k => k;
|
||||
globalThis.pysimFsRenderTree = () => { _treeRenders++; };
|
||||
|
||||
// The filter's two DOM elements: the collapsed row's button and the hidden
|
||||
// menu it expands into. The menu only needs a settable innerHTML (the
|
||||
// render never reads the DOM back) and a classList for the open/closed state.
|
||||
function fakeBtn() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
textContent: '',
|
||||
attrs: {},
|
||||
classes,
|
||||
classList: {
|
||||
contains: c => classes.has(c),
|
||||
toggle(c, force) {
|
||||
const on = force === undefined ? !classes.has(c) : !!force;
|
||||
if (on) classes.add(c); else classes.delete(c);
|
||||
return on;
|
||||
},
|
||||
},
|
||||
setAttribute(k, v) { this.attrs[k] = String(v); },
|
||||
getAttribute(k) { return this.attrs[k]; },
|
||||
};
|
||||
}
|
||||
|
||||
function fakeMenu() {
|
||||
const classes = new Set(['hidden']);
|
||||
return {
|
||||
_html: '',
|
||||
classes,
|
||||
classList: {
|
||||
contains: c => classes.has(c),
|
||||
toggle(c, force) {
|
||||
const on = force === undefined ? !classes.has(c) : !!force;
|
||||
if (on) classes.add(c); else classes.delete(c);
|
||||
return on;
|
||||
},
|
||||
},
|
||||
get innerHTML() { return this._html; },
|
||||
set innerHTML(v) { this._html = v; },
|
||||
};
|
||||
}
|
||||
|
||||
const dom = { menu: null, btn: null, clear: null };
|
||||
globalThis.document = {
|
||||
getElementById: id => (id === 'pysim-fs-filter-menu' ? dom.menu
|
||||
: (id === 'pysim-fs-filter-btn' ? dom.btn
|
||||
: (id === 'pysim-fs-filter-clear' ? dom.clear : null))),
|
||||
};
|
||||
|
||||
let code = '';
|
||||
for (const f of ['fsFilterServiceOptions', 'fsFilterIndexBuild', 'fsFilterIndex',
|
||||
'fsFilterPathIndexBuild', 'fsFilterPathIndex', 'fsFilterCustomPaths',
|
||||
'fsFilterPathsKeyOf', 'pysimFsFilterPathsInvalidate', 'pysimFsFilterMatchingPaths',
|
||||
'pysimFsFilterMatch', 'pysimFsFilterShow', 'pysimFsFilterLabel',
|
||||
'pysimFsFilterSummary', 'pysimFsFilterRender', 'pysimFsFilterToggle',
|
||||
'pysimFsFilterSetEntry', 'pysimFsFilterClear', 'pysimFsFilterRestore', 'pysimFsNodePath',
|
||||
'pysimFsExpandAll', 'pysimFsCollapseAll', 'pysimFsAutoExpandForFilter']) {
|
||||
code += extractFunc(html, f) + '\n';
|
||||
}
|
||||
for (const c of ['FS_FILTER_GROUPS', 'FS_FILTER_GROUP_ORDER', 'SERVICES_FS_MAP']) code += extractConst(html, c);
|
||||
code += extractStrConst(html, 'FS_FILTER_ALL');
|
||||
code += extractStrConst(html, 'FS_FILTER_CUSTOM');
|
||||
eval(code);
|
||||
|
||||
function withFilter(ids, fn) {
|
||||
const saved = pysimFsFilter;
|
||||
pysimFsFilter = new Set(ids);
|
||||
try { return fn(); } finally { pysimFsFilter = saved; }
|
||||
}
|
||||
|
||||
// The checkbox rows the render wrote: [{id, checked}] in document order.
|
||||
function renderedBoxes() {
|
||||
const out = [];
|
||||
for (const m of (dom.menu ? dom.menu.innerHTML : '').matchAll(
|
||||
/<input type="checkbox" data-filter="([^"]*)"( checked)?/g)) {
|
||||
out.push({ id: m[1], checked: !!m[2] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const box = id => renderedBoxes().find(b => b.id === id);
|
||||
|
||||
const fileBackedFeatures = () => Object.keys(SERVICES_FS_MAP).filter(n => SERVICES_FS_MAP[n].length);
|
||||
|
||||
test('the file tree has a collapsed filter dropdown next to the sort pills', () => {
|
||||
assert.match(html, /id="pysim-fs-filter-btn"[^>]*aria-haspopup="true"[^>]*aria-expanded="false"[^>]*onclick="pysimFsFilterToggle\(\)"/);
|
||||
assert.match(html, /id="pysim-fs-filter-menu"[^>]*role="group"[^>]*class="hidden absolute/);
|
||||
assert.match(html, /data-l10n="Filter:"/);
|
||||
// the multiselect (and its onchange handler) is gone
|
||||
assert.ok(!/id="pysim-fs-filter"/.test(html));
|
||||
assert.ok(!/pysimFsSetFilter\(/.test(html));
|
||||
});
|
||||
|
||||
test('the ✕ next to the selector resets a non-All filter to All', () => {
|
||||
// markup: a hidden red ✕ that clears the filter
|
||||
assert.match(html, /id="pysim-fs-filter-clear"[^>]*onclick="pysimFsFilterClear\(\)"/);
|
||||
assert.match(html, /id="pysim-fs-filter-clear"[^>]*data-l10n-title="Reset filter"/);
|
||||
// the clear handler resets to All
|
||||
const fn = extractFunc(html, 'pysimFsFilterClear');
|
||||
assert.ok(fn.indexOf('pysimFsFilterSetEntry(FS_FILTER_ALL, true)') > 0, fn);
|
||||
// the render shows it only when a non-All filter is active
|
||||
dom.menu = fakeMenu();
|
||||
dom.btn = fakeBtn();
|
||||
dom.clear = fakeBtn();
|
||||
withFilter(['plmn-selection'], () => {
|
||||
pysimFsFilterRender();
|
||||
assert.strictEqual(dom.clear.classes.has('hidden'), false);
|
||||
assert.strictEqual(dom.clear.attrs['aria-label'], 'Reset filter');
|
||||
});
|
||||
withFilter(['all'], () => {
|
||||
pysimFsFilterRender();
|
||||
assert.strictEqual(dom.clear.classes.has('hidden'), true);
|
||||
});
|
||||
// the Russian entry exists
|
||||
const dict = html.match(/const LANG_RU = \{([\s\S]*?)\n\};/)[1];
|
||||
assert.ok(dict.includes("'Reset filter':"), 'missing Russian entry');
|
||||
});
|
||||
|
||||
test('an outside click or Escape closes the menu', () => {
|
||||
const click = html.match(/document\.addEventListener\('click', e => \{([\s\S]{0,600}?)\n\}\);/);
|
||||
assert.ok(click, 'no document click listener');
|
||||
assert.match(click[1], /#pysim-fs-filter-btn/);
|
||||
assert.match(click[1], /#pysim-fs-filter-menu/);
|
||||
assert.match(click[1], /pysimFsFilterToggle\(false\)/);
|
||||
const key = html.match(/document\.addEventListener\('keydown', e => \{([\s\S]{0,300}?)\n\}\);/);
|
||||
assert.ok(key, 'no document keydown listener');
|
||||
assert.match(key[1], /Escape/);
|
||||
assert.match(key[1], /pysimFsFilterToggle\(false\)/);
|
||||
});
|
||||
|
||||
test('the four category groups name their files', () => {
|
||||
assert.deepStrictEqual(FS_FILTER_GROUPS.map(g => g.id),
|
||||
['identifiers', 'plmn-selection', 'provider-names', 'wlan']);
|
||||
const files = id => new Set(FS_FILTER_GROUPS.find(g => g.id === id).files);
|
||||
assert.deepStrictEqual([...files('identifiers')],
|
||||
['EF.ICCID', 'EF.IMSI', 'EF.MSISDN', 'EF.IMPI', 'EF.IMPU', 'EF.SUPI_NAI', 'EF.GID1', 'EF.GID2']);
|
||||
assert.deepStrictEqual([...files('plmn-selection')],
|
||||
['EF.PLMNsel', 'EF.PLMNwAcT', 'EF.OPLMNwAcT', 'EF.HPLMNwAcT', 'EF.EHPLMN', 'EF.FPLMN',
|
||||
'EF.HPPLMN', 'EF.LRPLMNSI', 'EF.EHPLMNPI', 'EF.MCHPPLMN']);
|
||||
assert.deepStrictEqual([...files('provider-names')],
|
||||
['EF.SPN', 'EF.PNN', 'EF.OPL', 'EF.OPL5G', 'EF.SPDI', 'EF.SPNI', 'EF.PNNI', 'EF.CNL']);
|
||||
assert.deepStrictEqual([...files('wlan')],
|
||||
['EF.UPLMNWLAN', 'EF.OPLMNWLAN', 'EF.WRI', 'EF.HWSIDL', 'EF.WEHPLMNPI', 'EF.WHPI',
|
||||
'EF.WLRPLMN', 'EF.HPLMNDAI']);
|
||||
assert.strictEqual(FS_FILTER_ALL, 'all');
|
||||
assert.strictEqual(FS_FILTER_CUSTOM, 'custom');
|
||||
});
|
||||
|
||||
test('the per-service options come from the file-backed features only', () => {
|
||||
const services = fsFilterServiceOptions();
|
||||
const features = fileBackedFeatures();
|
||||
assert.ok(features.length > 50, 'expected the cross-reference to be available');
|
||||
assert.strictEqual(services.length, features.length);
|
||||
assert.ok(services.every(o => o.id === 'svc:' + o.label));
|
||||
const spn = services.find(o => o.id === 'svc:Service Provider Name (SPN)');
|
||||
assert.deepStrictEqual(spn.files, ['EF.SPN']);
|
||||
// capability/RFU rows carry no files, so they are not offered
|
||||
assert.ok(!services.some(o => o.id === 'svc:Proactive SIM' || o.id === 'svc:CHV1 disable function'));
|
||||
});
|
||||
|
||||
test('the inverted index keys the uppercased names and merges memberships', () => {
|
||||
const idx = fsFilterIndexBuild(FS_FILTER_GROUPS, fsFilterServiceOptions());
|
||||
const ids = name => Array.from(idx.get(name.toUpperCase()) || []);
|
||||
assert.ok(ids('EF.ICCID').includes('identifiers'));
|
||||
assert.ok(ids('EF.PLMNwAcT').includes('plmn-selection'));
|
||||
assert.ok(ids('EF.SPN').includes('provider-names'));
|
||||
assert.ok(ids('EF.SPN').includes('svc:Service Provider Name (SPN)'));
|
||||
// SMS/CB files are covered by their per-service options, not a category
|
||||
assert.ok(ids('EF.SMS').includes('svc:Short Message Storage (SMS)'));
|
||||
assert.ok(ids('EF.CBMI').includes('svc:Cell Broadcast Message Identifier (CBMI)'));
|
||||
// EF.PSISMSC is neither a category nor a file-backed service, so nothing
|
||||
// maps it (pySim models no such file, and no service lists it either)
|
||||
assert.ok(!idx.has('EF.PSISMSC'));
|
||||
assert.ok(!fileBackedFeatures().some(n => (SERVICES_FS_MAP[n] || []).includes('EF.PSISMSC')));
|
||||
});
|
||||
|
||||
test('a file matches the selected categories, case-insensitively', () => {
|
||||
withFilter(['identifiers'], () => {
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ICCID', fid: '2FE2' }), true);
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'ef.iccid', fid: '2FE2' }), true);
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.SPN', fid: '6F46' }), false);
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'DF.GSM', fid: '7F20', isDir: true }), false);
|
||||
});
|
||||
withFilter(['svc:Service Provider Name (SPN)'], () => {
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.SPN' }), true);
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.PNN' }), false);
|
||||
});
|
||||
withFilter(['identifiers', 'svc:Short Message Status Reports (SMSR)'], () => {
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.IMSI' }), true);
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.SMSR' }), true);
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.SPN' }), false);
|
||||
});
|
||||
withFilter(['svc:Short Message Storage (SMS)'], () => {
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ECC' }), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('an empty filter and "all" show everything', () => {
|
||||
withFilter([], () => {
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ADN' }), true);
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'DF.GSM', isDir: true }), true);
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: undefined }), true);
|
||||
});
|
||||
withFilter(['all'], () => {
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ADN' }), true);
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ZZZ' }), true);
|
||||
});
|
||||
// a stale multi-selection that still names all also shows everything
|
||||
withFilter(['all', 'identifiers'], () => {
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ECC' }), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('a capability-only service selection matches nothing', () => {
|
||||
withFilter(['svc:Proactive SIM'], () => {
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ADN' }), false);
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.UST' }), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('"custom files" matches only the injected custom nodes', () => {
|
||||
withFilter(['custom'], () => {
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ADN', custom: false }), false);
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'MyCustomEF', custom: true }), true);
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ICCID', custom: true }), true);
|
||||
});
|
||||
withFilter(['custom', 'identifiers'], () => {
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ICCID', custom: false }), true);
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'MyCustomEF', custom: true }), true);
|
||||
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.SPN', custom: false }), false);
|
||||
});
|
||||
});
|
||||
|
||||
// A small tree with real parent links (pysimFsNodePath walks them): MF with
|
||||
// DF.TELECOM (EF.MSISDN), DF.GSM (EF.SPN, EF.ECC) and ADF.USIM (DF.WLAN).
|
||||
function fsTree() {
|
||||
const root = { name: 'MF', fid: '3F00', isDir: true, children: [], parent: null, expanded: true };
|
||||
const add = (parent, name, fid, isDir) => {
|
||||
const n = { name, fid, isDir: !!isDir, children: isDir ? [] : null, parent, expanded: true };
|
||||
parent.children.push(n);
|
||||
return n;
|
||||
};
|
||||
const telecom = add(root, 'DF.TELECOM', '7F10', true);
|
||||
const msisdn = add(telecom, 'EF.MSISDN', '6F40', false);
|
||||
const gsm = add(root, 'DF.GSM', '7F20', true);
|
||||
const spn = add(gsm, 'EF.SPN', '6F46', false);
|
||||
const ecc = add(gsm, 'EF.ECC', '6FB7', false);
|
||||
const usim = add(root, 'ADF.USIM', '7FFF', true);
|
||||
const wlan = add(usim, 'DF.WLAN', '5F40', true);
|
||||
return { root, telecom, msisdn, gsm, spn, ecc, usim, wlan };
|
||||
}
|
||||
|
||||
test('a directory shows only on the path of a file the filter selects', () => {
|
||||
const t = fsTree();
|
||||
withFilter(['provider-names'], () => {
|
||||
// the root always stays; DF.GSM holds EF.SPN (MF/7F20/6F46)
|
||||
assert.strictEqual(pysimFsFilterShow(t.root), true);
|
||||
assert.strictEqual(pysimFsFilterShow(t.gsm), true);
|
||||
// ... and ADF.USIM holds its own EF.SPN copy
|
||||
assert.strictEqual(pysimFsFilterShow(t.usim), true);
|
||||
// neither DF.TELECOM nor DF.WLAN holds a provider-name file
|
||||
assert.strictEqual(pysimFsFilterShow(t.telecom), false);
|
||||
assert.strictEqual(pysimFsFilterShow(t.wlan), false);
|
||||
// files keep their name rule
|
||||
assert.strictEqual(pysimFsFilterShow(t.spn), true);
|
||||
assert.strictEqual(pysimFsFilterShow(t.ecc), false);
|
||||
});
|
||||
withFilter(['identifiers'], () => {
|
||||
// EF.MSISDN is in DF.TELECOM, EF.IMSI/EF.GID1/2 in DF.GSM
|
||||
assert.strictEqual(pysimFsFilterShow(t.telecom), true);
|
||||
assert.strictEqual(pysimFsFilterShow(t.msisdn), true);
|
||||
assert.strictEqual(pysimFsFilterShow(t.gsm), true);
|
||||
assert.strictEqual(pysimFsFilterShow(t.usim), true); // EF.IMSI/EF.MSISDN copies
|
||||
assert.strictEqual(pysimFsFilterShow(t.wlan), false);
|
||||
});
|
||||
withFilter(['svc:Cell Broadcast Message Identifier (CBMI)'], () => {
|
||||
assert.strictEqual(pysimFsFilterShow(t.gsm), true); // EF.CBMI
|
||||
assert.strictEqual(pysimFsFilterShow(t.telecom), false);
|
||||
assert.strictEqual(pysimFsFilterShow(t.wlan), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('a directory named by the selection itself shows', () => {
|
||||
const t = fsTree();
|
||||
// the GPRS feature names DF.GSM (its SST bit covers the DF, not a file)
|
||||
withFilter(['svc:GPRS'], () => {
|
||||
assert.strictEqual(pysimFsFilterMatch(t.gsm), true);
|
||||
assert.strictEqual(pysimFsFilterShow(t.gsm), true);
|
||||
assert.strictEqual(pysimFsFilterShow(t.telecom), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('"all" and an empty selection show every node', () => {
|
||||
const t = fsTree();
|
||||
for (const ids of [['all'], []]) {
|
||||
withFilter(ids, () => {
|
||||
assert.strictEqual(pysimFsFilterShow(t.root), true);
|
||||
assert.strictEqual(pysimFsFilterShow(t.telecom), true);
|
||||
assert.strictEqual(pysimFsFilterShow(t.wlan), true);
|
||||
assert.strictEqual(pysimFsFilterShow(t.ecc), true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('directories are kept until the standard list is available', () => {
|
||||
const t = fsTree();
|
||||
const saved = _uiccStdFiles;
|
||||
_uiccStdFiles = null;
|
||||
try {
|
||||
withFilter(['provider-names'], () => {
|
||||
assert.strictEqual(pysimFsFilterMatchingPaths(), null);
|
||||
// the ancestors cannot be judged, so nothing vanishes
|
||||
assert.strictEqual(pysimFsFilterShow(t.telecom), true);
|
||||
assert.strictEqual(pysimFsFilterShow(t.wlan), true);
|
||||
// files still obey the name rule
|
||||
assert.strictEqual(pysimFsFilterShow(t.ecc), false);
|
||||
});
|
||||
} finally {
|
||||
_uiccStdFiles = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test('the standard list gives a name every tree it lives in', () => {
|
||||
const idx = fsFilterPathIndexBuild(_uiccStdFiles);
|
||||
assert.deepStrictEqual(idx.get('EF.SPN').slice().sort(),
|
||||
['ADF.USIM-NON-IMSI/6F46', 'ADF.USIM/6F46', 'MF/7F20/6F46']);
|
||||
assert.deepStrictEqual(idx.get('EF.ICCID'), ['MF/2FE2']);
|
||||
// the index is cached for the loaded list
|
||||
assert.strictEqual(fsFilterPathIndex(), fsFilterPathIndex());
|
||||
});
|
||||
|
||||
test('the selection resolves to the paths of its files', () => {
|
||||
withFilter(['svc:Cell Broadcast Message Identifier (CBMI)'], () => {
|
||||
const paths = pysimFsFilterMatchingPaths();
|
||||
assert.ok(paths instanceof Set, 'expected a Set');
|
||||
assert.deepStrictEqual(Array.from(paths).sort(), [
|
||||
'ADF.USIM-NON-IMSI/6F45', 'ADF.USIM/6F45', 'MF/7F20/6F45',
|
||||
]);
|
||||
// cached for the same selection
|
||||
assert.strictEqual(pysimFsFilterMatchingPaths(), paths);
|
||||
});
|
||||
withFilter(['custom'], () => {
|
||||
assert.deepStrictEqual(Array.from(pysimFsFilterMatchingPaths()), []);
|
||||
});
|
||||
});
|
||||
|
||||
test('"custom files" keeps the directories that hold them', () => {
|
||||
const t = fsTree();
|
||||
const custom = { name: 'EF.MYFILE', fid: '6F99', isDir: false, children: null, parent: t.gsm, custom: true };
|
||||
t.gsm.children.push(custom);
|
||||
pysimCustomFiles = [{ path: 'MF/7F20/6F99', name: 'EF.MYFILE', kind: 'ef' }];
|
||||
try {
|
||||
withFilter(['custom'], () => {
|
||||
// custom files are outside the standard list: their own paths are
|
||||
// the ancestry, so the holding DF stays
|
||||
assert.strictEqual(pysimFsFilterShow(custom), true);
|
||||
assert.strictEqual(pysimFsFilterShow(t.gsm), true);
|
||||
assert.strictEqual(pysimFsFilterShow(t.root), true);
|
||||
assert.strictEqual(pysimFsFilterShow(t.telecom), false);
|
||||
});
|
||||
} finally {
|
||||
pysimCustomFiles = [];
|
||||
}
|
||||
});
|
||||
|
||||
test('the menu lists all, custom, the three headings and the services', () => {
|
||||
pysimFsFilter = new Set(['all']);
|
||||
dom.menu = fakeMenu();
|
||||
dom.btn = fakeBtn();
|
||||
pysimFsFilterRender();
|
||||
const out = dom.menu.innerHTML;
|
||||
assert.match(out, /^<label[^>]*><input type="checkbox" data-filter="all" checked/);
|
||||
assert.match(out, /data-filter="custom"/);
|
||||
assert.match(out, /<div class="[^"]*">Identifiers<\/div>/);
|
||||
assert.match(out, /<div class="[^"]*">PLMN<\/div>/);
|
||||
assert.match(out, /<div class="[^"]*">Services<\/div>/);
|
||||
// the headings come in the declared order
|
||||
assert.ok(out.indexOf('>Identifiers</div>') < out.indexOf('>PLMN</div>'));
|
||||
assert.ok(out.indexOf('>PLMN</div>') < out.indexOf('>Services</div>'));
|
||||
// the categories sit under their headings
|
||||
assert.ok(out.indexOf('data-filter="provider-names"') < out.indexOf('>PLMN</div>'));
|
||||
assert.ok(out.indexOf('data-filter="plmn-selection"') > out.indexOf('>PLMN</div>'));
|
||||
// the per-service options sit under the Services heading
|
||||
assert.ok(out.indexOf('data-filter="svc:') > out.indexOf('>Services</div>'));
|
||||
const boxes = renderedBoxes();
|
||||
assert.strictEqual(boxes.length, 2 + FS_FILTER_GROUPS.length + fsFilterServiceOptions().length);
|
||||
assert.strictEqual(boxes[0].id, 'all');
|
||||
assert.strictEqual(boxes[0].checked, true);
|
||||
assert.strictEqual(box('custom').checked, false);
|
||||
assert.ok(boxes.some(b => b.id === 'svc:Service Provider Name (SPN)'));
|
||||
assert.ok(!boxes.some(b => b.id === 'svc:Proactive SIM'));
|
||||
// the row shows the summary, and the checkbox calls the setter directly
|
||||
assert.strictEqual(dom.btn.textContent, 'All');
|
||||
assert.match(out, /onchange="pysimFsFilterSetEntry\(this\.dataset\.filter, this\.checked\)"/);
|
||||
});
|
||||
|
||||
test('the button turns blue when a filter is active and back for all', () => {
|
||||
dom.menu = fakeMenu();
|
||||
dom.btn = fakeBtn();
|
||||
pysimFsFilter = new Set(['identifiers']);
|
||||
pysimFsFilterRender();
|
||||
assert.strictEqual(dom.btn.classes.has('bg-blue-600'), true);
|
||||
assert.strictEqual(dom.btn.classes.has('text-white'), true);
|
||||
assert.strictEqual(dom.btn.classes.has('text-gray-700'), false);
|
||||
pysimFsFilter = new Set(['all']);
|
||||
pysimFsFilterRender();
|
||||
assert.strictEqual(dom.btn.classes.has('bg-blue-600'), false);
|
||||
assert.strictEqual(dom.btn.classes.has('text-white'), false);
|
||||
assert.strictEqual(dom.btn.classes.has('text-gray-700'), true);
|
||||
});
|
||||
|
||||
test('expand all and collapse all walk the directory tree, skipping absent DFs', async () => {
|
||||
const telecom = { name: 'DF.TELECOM', fid: '7F10', isDir: true, exists: true, expanded: false,
|
||||
parent: null, children: [
|
||||
{ name: 'DF.PHONEBOOK', fid: '5F3A', isDir: true, exists: true, expanded: false, parent: null, children: null },
|
||||
] };
|
||||
const gsm = { name: 'DF.GSM', fid: '7F20', isDir: true, exists: false, expanded: false, parent: null, children: null };
|
||||
pysimFsTreeRoot = { name: 'MF', fid: '3F00', isDir: true, exists: true, expanded: true, parent: null,
|
||||
children: [gsm, telecom] };
|
||||
let loads = 0;
|
||||
pysimFsLoadChildren = async node => { if (!node.children) { node.children = []; } loads++; };
|
||||
try {
|
||||
_treeRenders = 0;
|
||||
await pysimFsExpandAll();
|
||||
assert.strictEqual(pysimFsTreeRoot.expanded, true);
|
||||
assert.strictEqual(gsm.expanded, false, 'an absent DF is not expanded');
|
||||
assert.strictEqual(telecom.expanded, true);
|
||||
assert.strictEqual(telecom.children[0].expanded, true);
|
||||
assert.ok(loads > 0, 'lazy children are loaded on demand');
|
||||
assert.strictEqual(_treeRenders, 1);
|
||||
|
||||
pysimFsCollapseAll();
|
||||
assert.strictEqual(pysimFsTreeRoot.expanded, false);
|
||||
assert.strictEqual(telecom.expanded, false);
|
||||
assert.strictEqual(telecom.children[0].expanded, false);
|
||||
} finally {
|
||||
pysimFsTreeRoot = null;
|
||||
}
|
||||
});
|
||||
|
||||
test('auto-expand fires only for a non-"all" filter', () => {
|
||||
// expand-all (with a null tree) still re-renders once; pin the branch
|
||||
pysimFsFilter = new Set(['all']);
|
||||
_treeRenders = 0;
|
||||
pysimFsAutoExpandForFilter();
|
||||
assert.strictEqual(_treeRenders, 0, '"all" does not expand');
|
||||
pysimFsFilter = new Set(['identifiers']);
|
||||
pysimFsAutoExpandForFilter();
|
||||
assert.strictEqual(_treeRenders, 1, 'a category expands the tree');
|
||||
});
|
||||
|
||||
test('the menu marks the saved selection', () => {
|
||||
pysimFsFilter = new Set(['svc:Short Message Storage (SMS)', 'identifiers']);
|
||||
dom.menu = fakeMenu();
|
||||
dom.btn = fakeBtn();
|
||||
pysimFsFilterRender();
|
||||
assert.deepStrictEqual(renderedBoxes().filter(b => b.checked).map(b => b.id),
|
||||
['identifiers', 'svc:Short Message Storage (SMS)']);
|
||||
assert.strictEqual(box('all').checked, false);
|
||||
assert.strictEqual(dom.btn.textContent, 'Selected: 2');
|
||||
});
|
||||
|
||||
test('the button summary names the single entry or counts the selection', () => {
|
||||
const summary = ids => withFilter(ids, () => pysimFsFilterSummary());
|
||||
assert.strictEqual(summary(['all']), 'All');
|
||||
// an empty selection also means everything
|
||||
assert.strictEqual(summary([]), 'All');
|
||||
assert.strictEqual(summary(['all', 'plmn-selection']), 'All');
|
||||
assert.strictEqual(summary(['custom']), 'Custom files');
|
||||
assert.strictEqual(summary(['plmn-selection']), 'PLMN selection');
|
||||
assert.strictEqual(summary(['svc:Service Provider Name (SPN)']), 'Service Provider Name (SPN)');
|
||||
assert.strictEqual(summary(['plmn-selection', 'wlan']), 'Selected: 2');
|
||||
// an unknown id falls back to itself
|
||||
assert.strictEqual(summary(['providers']), 'providers');
|
||||
});
|
||||
|
||||
test('the menu toggles open and closed', () => {
|
||||
dom.menu = fakeMenu();
|
||||
dom.btn = fakeBtn();
|
||||
assert.strictEqual(dom.menu.classList.contains('hidden'), true);
|
||||
pysimFsFilterToggle();
|
||||
assert.strictEqual(dom.menu.classList.contains('hidden'), false);
|
||||
assert.strictEqual(dom.btn.getAttribute('aria-expanded'), 'true');
|
||||
pysimFsFilterToggle();
|
||||
assert.strictEqual(dom.menu.classList.contains('hidden'), true);
|
||||
assert.strictEqual(dom.btn.getAttribute('aria-expanded'), 'false');
|
||||
// the forced forms (the outside click / Escape handlers pass false)
|
||||
pysimFsFilterToggle(true);
|
||||
assert.strictEqual(dom.menu.classList.contains('hidden'), false);
|
||||
pysimFsFilterToggle(false);
|
||||
assert.strictEqual(dom.menu.classList.contains('hidden'), true);
|
||||
});
|
||||
|
||||
test('the checkbox rows keep "all" exclusive and persist the selection', () => {
|
||||
dom.menu = fakeMenu();
|
||||
dom.btn = fakeBtn();
|
||||
delete _store.simple_fs_filter;
|
||||
_treeRenders = 0;
|
||||
pysimFsFilter = new Set(['all']);
|
||||
pysimFsFilterSetEntry('all', true);
|
||||
assert.deepStrictEqual([...pysimFsFilter], ['all']);
|
||||
assert.strictEqual(_store.simple_fs_filter, '["all"]');
|
||||
assert.strictEqual(_treeRenders, 1);
|
||||
// choosing a category drops "all"
|
||||
pysimFsFilterSetEntry('provider-names', true);
|
||||
assert.deepStrictEqual([...pysimFsFilter], ['provider-names']);
|
||||
assert.strictEqual(_store.simple_fs_filter, '["provider-names"]');
|
||||
assert.strictEqual(_treeRenders, 3); // render + the auto-expand's render
|
||||
assert.strictEqual(box('provider-names').checked, true);
|
||||
assert.strictEqual(box('all').checked, false);
|
||||
assert.strictEqual(dom.btn.textContent, 'Provider names');
|
||||
// several categories stay selected
|
||||
pysimFsFilterSetEntry('identifiers', true);
|
||||
assert.deepStrictEqual([...pysimFsFilter], ['provider-names', 'identifiers']);
|
||||
// unchecking one keeps the other
|
||||
pysimFsFilterSetEntry('provider-names', false);
|
||||
assert.deepStrictEqual([...pysimFsFilter], ['identifiers']);
|
||||
// unchecking the last one restores the default
|
||||
pysimFsFilterSetEntry('identifiers', false);
|
||||
assert.deepStrictEqual([...pysimFsFilter], ['all']);
|
||||
assert.strictEqual(_store.simple_fs_filter, '["all"]');
|
||||
assert.strictEqual(box('all').checked, true);
|
||||
// "all" is exclusive even when another entry is selected
|
||||
pysimFsFilter = new Set(['provider-names']);
|
||||
pysimFsFilterSetEntry('all', true);
|
||||
assert.deepStrictEqual([...pysimFsFilter], ['all']);
|
||||
});
|
||||
|
||||
test('the saved selection is restored, defaulting to "all"', () => {
|
||||
delete _store.simple_fs_filter;
|
||||
pysimFsFilterRestore();
|
||||
assert.deepStrictEqual([...pysimFsFilter], ['all']);
|
||||
_store.simple_fs_filter = JSON.stringify(['providers', 'svc:Service Provider Name (SPN)']);
|
||||
pysimFsFilterRestore();
|
||||
assert.deepStrictEqual([...pysimFsFilter], ['providers', 'svc:Service Provider Name (SPN)']);
|
||||
// `all` is exclusive; an empty or broken save falls back to it
|
||||
_store.simple_fs_filter = JSON.stringify(['all', 'plmn-selection']);
|
||||
pysimFsFilterRestore();
|
||||
assert.deepStrictEqual([...pysimFsFilter], ['all']);
|
||||
_store.simple_fs_filter = '[]';
|
||||
pysimFsFilterRestore();
|
||||
assert.deepStrictEqual([...pysimFsFilter], ['all']);
|
||||
_store.simple_fs_filter = '{not json';
|
||||
pysimFsFilterRestore();
|
||||
assert.deepStrictEqual([...pysimFsFilter], ['all']);
|
||||
});
|
||||
|
||||
test('the render consults the filter when walking children', () => {
|
||||
assert.match(html, /if \(!pysimFsFilterShow\(child\)\) continue;/);
|
||||
assert.ok(!/pysimFsFilterMatch\(child\)/.test(html));
|
||||
});
|
||||
|
||||
test('the directory rule uses the registry paths, not the loaded children', () => {
|
||||
const fn = extractFunc(html, 'pysimFsFilterShow');
|
||||
assert.ok(!fn.includes('node.children'), fn);
|
||||
assert.ok(fn.indexOf('pysimFsFilterMatchingPaths()') > 0, fn);
|
||||
assert.ok(fn.indexOf("path === 'MF'") > 0, fn);
|
||||
// the standard list is read synchronously from the cached array
|
||||
assert.match(html, /let _uiccStdFiles = null;/);
|
||||
assert.ok(extractFunc(html, 'pysimFsFilterMatchingPaths').indexOf('_uiccStdFiles') > 0);
|
||||
});
|
||||
|
||||
test('every filter change drops the path cache before the tree redraws', () => {
|
||||
for (const name of ['pysimFsFilterSetEntry', 'pysimFsSetFilterToService', 'pysimFsFilterRestore']) {
|
||||
const fn = extractFunc(html, name);
|
||||
const at = fn.indexOf('pysimFsFilterPathsInvalidate()');
|
||||
assert.ok(at > 0, name + ' does not invalidate the path cache');
|
||||
const render = fn.indexOf('pysimFsRenderTree()');
|
||||
if (render > 0) assert.ok(at < render, name + ': the cache is dropped before the redraw');
|
||||
}
|
||||
// the list arriving redraws a tree that was filtered while it loaded
|
||||
const load = extractFunc(html, 'uiccStdFilesLoad');
|
||||
assert.ok(load.indexOf('pysimFsRenderTree()') > 0, load);
|
||||
});
|
||||
|
||||
test('the filter state is persisted under simple_fs_filter', () => {
|
||||
const fn = extractFunc(html, 'pysimFsFilterSetEntry');
|
||||
assert.ok(fn.indexOf("localStorage.setItem('simple_fs_filter'") > 0, fn);
|
||||
assert.ok(fn.indexOf('pysimFsRenderTree()') > 0, fn);
|
||||
assert.ok(fn.indexOf('pysimFsFilterRender()') > 0, fn);
|
||||
});
|
||||
|
||||
test('the dropdown is re-rendered on language switch', () => {
|
||||
const fn = extractFunc(html, 'refreshDynamicI18n');
|
||||
assert.ok(fn.indexOf('pysimFsFilterRender()') > 0, fn);
|
||||
});
|
||||
|
||||
test('the filter labels have Russian entries', () => {
|
||||
const dict = html.match(/const LANG_RU = \{([\s\S]*?)\n\};/)[1];
|
||||
for (const key of ['All', 'Custom files', 'Identifiers', 'PLMN', 'Services', 'PLMN selection',
|
||||
'Provider names', 'WLAN / I-WLAN', 'Selected: {n}']) {
|
||||
assert.ok(dict.includes("'" + key + "':"), 'missing Russian entry: ' + key);
|
||||
}
|
||||
});
|
||||
@@ -22,8 +22,11 @@ function extractFunc(src, name) {
|
||||
}
|
||||
|
||||
let code = 'var pysimFsSort = "fid";\n';
|
||||
code += 'var pysimFsFilter = new Set();\n';
|
||||
code += extractFunc(html, 'pysimFsSortChildren') + '\n';
|
||||
code += extractFunc(html, 'pysimFsNodePath') + '\n';
|
||||
code += extractFunc(html, 'pysimFsFilterMatch') + '\n';
|
||||
code += extractFunc(html, 'pysimFsFilterShow') + '\n';
|
||||
code += extractFunc(html, 'pysimFsRenderNode') + '\n';
|
||||
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
|
||||
eval(code);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// The service-table rows' cross-link to the file-tree filter: a table row
|
||||
// that is backed by files gets a "To EF filter" button that narrows the tree
|
||||
// to exactly that service's files.
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
function extractConst(src, name) {
|
||||
const m = new RegExp('const ' + name + ' = ').exec(src);
|
||||
if (!m) throw new Error('const ' + name + ' not found');
|
||||
const begin = m.index + m[0].length;
|
||||
const opener = src[begin];
|
||||
const closer = opener === '[' ? ']' : (opener === '{' ? '}' : null);
|
||||
if (!closer) throw new Error('unsupported const ' + name);
|
||||
let depth = 0, i = begin;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === opener) depth++;
|
||||
else if (src[i] === closer) {
|
||||
depth--;
|
||||
if (depth === 0) { i++; break; }
|
||||
}
|
||||
}
|
||||
return 'var ' + name + ' = ' + src.slice(begin, i) + ';\n';
|
||||
}
|
||||
|
||||
// module state the extracted functions close over
|
||||
let pysimFsFilter = new Set(['all']);
|
||||
let _servicesFeatureByNumber = null;
|
||||
// the tree filter's matching-path cache (pysimFsSetFilterToService drops it)
|
||||
let _fsFilterPaths = null;
|
||||
let _fsFilterPathsKey = null;
|
||||
const _store = {};
|
||||
let _renders = 0;
|
||||
let _treeRenders = 0;
|
||||
let pysimFsTreeRoot = null; // expand-all walks this; null = nothing to expand
|
||||
|
||||
globalThis.localStorage = {
|
||||
getItem: k => (Object.prototype.hasOwnProperty.call(_store, k) ? _store[k] : null),
|
||||
setItem: (k, v) => { _store[k] = String(v); },
|
||||
removeItem: k => { delete _store[k]; },
|
||||
};
|
||||
globalThis.esc = s => String(s);
|
||||
globalThis.t = k => k;
|
||||
globalThis.pysimFsFilterRender = () => { _renders++; };
|
||||
globalThis.pysimFsRenderTree = () => { _treeRenders++; };
|
||||
|
||||
let code = '';
|
||||
for (const f of ['servicesFeatureByNumber', 'pysimFsFilterBtnHtml', 'pysimFsSetFilterToService',
|
||||
'pysimFsFilterPathsInvalidate', 'pysimFsExpandAll', 'pysimFsCollapseAll',
|
||||
'pysimFsAutoExpandForFilter']) {
|
||||
code += extractFunc(html, f) + '\n';
|
||||
}
|
||||
for (const c of ['SERVICES_CROSS_REF', 'SERVICES_FS_MAP']) code += extractConst(html, c);
|
||||
code += "var FS_FILTER_ALL = 'all';\n";
|
||||
eval(code);
|
||||
|
||||
test('servicesFeatureByNumber inverts the cross-reference per file table', () => {
|
||||
const map = servicesFeatureByNumber();
|
||||
assert.strictEqual(map.ust[2], 'Fixed Dialling Numbers (FDN)');
|
||||
assert.strictEqual(map.sst[2], 'Local Phone Book (ADN)');
|
||||
assert.strictEqual(map.est[1], 'Fixed Dialling Numbers (FDN)');
|
||||
assert.strictEqual(map.ist[1], 'P-CSCF address');
|
||||
// the same number names a different feature in another table
|
||||
assert.strictEqual(map.ust[1], 'Local Phone Book (ADN)');
|
||||
assert.strictEqual(map.sst[1], 'CHV1 disable function');
|
||||
// a capability-only number maps to a feature too
|
||||
assert.strictEqual(map.ust[99], 'URI support by UICC');
|
||||
// the map is built once
|
||||
assert.strictEqual(servicesFeatureByNumber(), map);
|
||||
});
|
||||
|
||||
test('pysimFsFilterBtnHtml links a file-backed service row only', () => {
|
||||
const out = pysimFsFilterBtnHtml('ust', 2);
|
||||
assert.match(out, /^<button type="button" class="ml-1 px-1 text-xs text-blue-600 dark:text-blue-400 hover:underline"/);
|
||||
assert.ok(out.indexOf("pysimFsSetFilterToService('ust', 2)") > 0, out);
|
||||
assert.ok(out.indexOf('To EF filter') > 0, out);
|
||||
// OCI is file-backed
|
||||
assert.notStrictEqual(pysimFsFilterBtnHtml('ust', 8), '');
|
||||
// a capability-only service, a missing table and an unmodelled number
|
||||
assert.strictEqual(pysimFsFilterBtnHtml('ust', 99), '');
|
||||
assert.strictEqual(pysimFsFilterBtnHtml(undefined, 1), '');
|
||||
assert.strictEqual(pysimFsFilterBtnHtml('sst', 99), '');
|
||||
});
|
||||
|
||||
test('pysimFsSetFilterToService narrows the tree to that service', () => {
|
||||
delete _store.simple_fs_filter;
|
||||
_renders = 0;
|
||||
_treeRenders = 0;
|
||||
pysimFsFilter = new Set(['all']);
|
||||
pysimFsSetFilterToService('ust', 2);
|
||||
assert.deepStrictEqual([...pysimFsFilter], ['svc:Fixed Dialling Numbers (FDN)']);
|
||||
assert.strictEqual(_store.simple_fs_filter, JSON.stringify(['svc:Fixed Dialling Numbers (FDN)']));
|
||||
assert.strictEqual(_renders, 1);
|
||||
assert.strictEqual(_treeRenders, 2); // render + the auto-expand's render
|
||||
// a capability-only service changes nothing
|
||||
pysimFsSetFilterToService('ust', 99);
|
||||
assert.deepStrictEqual([...pysimFsFilter], ['svc:Fixed Dialling Numbers (FDN)']);
|
||||
assert.strictEqual(_renders, 1);
|
||||
assert.strictEqual(_treeRenders, 2);
|
||||
});
|
||||
|
||||
test('the four service-table fields carry their table, EF.ACC carries none', () => {
|
||||
assert.match(html, /const EF_UST_FIELDS = \[\{[^\n]*table: 'ust'/);
|
||||
assert.match(html, /const EF_IST_FIELDS = \[\{[^\n]*table: 'ist'/);
|
||||
assert.match(html, /const EF_EST_FIELDS = \[\{[^\n]*table: 'est'/);
|
||||
assert.match(html, /const EF_SST_FIELDS = \[\{[\s\S]{0,200}?table: 'sst'/);
|
||||
const acc = /const EF_ACC_FIELDS = [^\n]*/.exec(html);
|
||||
assert.ok(acc, 'EF_ACC_FIELDS not found');
|
||||
assert.ok(acc[0].indexOf('table') < 0, acc[0]);
|
||||
});
|
||||
|
||||
test('both service-grid renderers append the cross-link after their control', () => {
|
||||
const fn = extractFunc(html, 'efDecFieldHtml');
|
||||
assert.strictEqual((fn.match(/pysimFsFilterBtnHtml\(f\.table, n\)/g) || []).length, 2, fn);
|
||||
});
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pysim-simple-server"
|
||||
version = "3.39.0"
|
||||
version = "3.40.4"
|
||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||
license = { text = "GPL-2.0-or-later" }
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
|
||||
from osmocom.utils import rpad
|
||||
|
||||
|
||||
VERSION = '3.39.0'
|
||||
VERSION = '3.40.4'
|
||||
|
||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||
|
||||
|
||||
@@ -4,6 +4,19 @@ set -e
|
||||
# pysim-simple-server setup script
|
||||
# Creates a venv and installs pysim and its dependencies.
|
||||
|
||||
# Windows guard: setup.sh is the native Linux/macOS helper. Under WSL the
|
||||
# Linux guest cannot reach the Windows PC/SC readers, and a Git Bash / MSYS2 /
|
||||
# MinGW / Cygwin shell has the wrong (bin/) venv layout - use setup.bat, which
|
||||
# drives the Windows Python and the built-in PC/SC stack. A deliberate
|
||||
# WSL/Cygwin flow can run the underlying pip/venv commands without this script.
|
||||
if grep -qi microsoft /proc/version 2>/dev/null || \
|
||||
uname -s 2>/dev/null | grep -qiE 'MINGW|MSYS|CYGWIN'; then
|
||||
echo "Error: setup.sh is the Linux/macOS helper and cannot run on Windows"
|
||||
echo "(WSL / MSYS2 / MinGW / Cygwin detected)."
|
||||
echo "Use setup.bat from a Windows command prompt (cmd.exe / PowerShell) instead."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
VENV_DIR="$SCRIPT_DIR/.venv"
|
||||
|
||||
|
||||
@@ -7,6 +7,19 @@ set -e
|
||||
# Every run is mirrored to simple_lastrun.log (overwritten on each start) so the
|
||||
# latest server output can be read without copy-pasting the console.
|
||||
|
||||
# Windows guard: start.sh is the native Linux/macOS helper. Under WSL the
|
||||
# Linux guest cannot reach the Windows PC/SC readers, and a Git Bash / MSYS2 /
|
||||
# MinGW / Cygwin shell has the wrong (bin/) venv layout - use start.bat, which
|
||||
# drives the Windows Python and the built-in PC/SC stack. A deliberate
|
||||
# WSL/Cygwin flow can run the server without this script.
|
||||
if grep -qi microsoft /proc/version 2>/dev/null || \
|
||||
uname -s 2>/dev/null | grep -qiE 'MINGW|MSYS|CYGWIN'; then
|
||||
echo "Error: start.sh is the Linux/macOS helper and cannot run on Windows"
|
||||
echo "(WSL / MSYS2 / MinGW / Cygwin detected)."
|
||||
echo "Use start.bat from a Windows command prompt (cmd.exe / PowerShell) instead."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
VENV_DIR="$SCRIPT_DIR/.venv"
|
||||
|
||||
|
||||
@@ -165,6 +165,18 @@ class SourcePins(unittest.TestCase):
|
||||
self.assertIn('%HTTP_HOST%:%HTTP_PORT%', src)
|
||||
self.assertNotIn('http://127.0.0.1:8080', src)
|
||||
|
||||
def test_the_start_script_guards_windows_and_points_to_start_bat(self):
|
||||
src = (Path(m.__file__).parent.parent / 'start.sh').read_text()
|
||||
self.assertIn('microsoft /proc/version', src)
|
||||
self.assertIn('MINGW|MSYS|CYGWIN', src)
|
||||
self.assertIn('Use start.bat from a Windows command prompt', src)
|
||||
|
||||
def test_the_update_script_guards_windows_and_points_to_update_bat(self):
|
||||
src = (Path(m.__file__).parent.parent / 'update.sh').read_text()
|
||||
self.assertIn('microsoft /proc/version', src)
|
||||
self.assertIn('MINGW|MSYS|CYGWIN', src)
|
||||
self.assertIn('Use update.bat from a Windows command prompt', src)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -39,6 +39,11 @@ class SetupShTests(unittest.TestCase):
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(res.returncode, 0, res.stderr)
|
||||
|
||||
def test_windows_guard_points_to_setup_bat(self):
|
||||
self.assertIn('microsoft /proc/version', self.src)
|
||||
self.assertIn('MINGW|MSYS|CYGWIN', self.src)
|
||||
self.assertIn('Use setup.bat from a Windows command prompt', self.src)
|
||||
|
||||
|
||||
class SetupBatTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
|
||||
@@ -2,6 +2,19 @@
|
||||
# Update this packaged SIMple install in place (see tools/update.py --help).
|
||||
# The .venv is reused - pysim and its prerequisites are not re-installed.
|
||||
set -e
|
||||
|
||||
# Windows guard: update.sh is the native Linux/macOS helper. Under WSL the
|
||||
# Linux guest cannot reach the Windows PC/SC readers, and a Git Bash / MSYS2 /
|
||||
# MinGW / Cygwin shell has the wrong (bin/) venv layout - use update.bat. A
|
||||
# deliberate WSL/Cygwin flow can run tools/update.py without this script.
|
||||
if grep -qi microsoft /proc/version 2>/dev/null || \
|
||||
uname -s 2>/dev/null | grep -qiE 'MINGW|MSYS|CYGWIN'; then
|
||||
echo "Error: update.sh is the Linux/macOS helper and cannot run on Windows"
|
||||
echo "(WSL / MSYS2 / MinGW / Cygwin detected)."
|
||||
echo "Use update.bat from a Windows command prompt (cmd.exe / PowerShell) instead."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
if [ -x "$SCRIPT_DIR/.venv/bin/python" ]; then
|
||||
PY="$SCRIPT_DIR/.venv/bin/python"
|
||||
|
||||
Reference in new issue
Block a user