Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -9,6 +9,57 @@ every change carries a descriptive commit message.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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
|
## [3.39.0] — 2026-10-26
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -2,22 +2,27 @@
|
|||||||
|
|
||||||
> Formerly known as OTAMan — renamed to SIMple on 2026-09-19.
|
> 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://atroshin.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 a workbench for UICC/SIM card specialists: an offline HTML/JS frontend (PWA — a Progressive Web App) plus a local card server ([`pysim-simple-server`](pysim_simple_server/), a pySim/PC-SC bridge that also serves the frontend). It covers the full lab cycle:
|
||||||
|
|
||||||
- **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);
|
- **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;
|
- **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;
|
- **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.
|
- **Card profiler** — filesystem capture (profiles and immutable snapshots with timings), exact snapshot comparison and FCP/FCI decoding.
|
||||||
|
|
||||||
**Demo:** [simple.atroshin.ru](https://simple.atroshin.ru) — the PWA alone, for experimenting. Install the server (below) for card-reader functions.
|
**Demo:** [simple.atroshin.ru](https://simple.atroshin.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
|
## Quick start
|
||||||
|
|
||||||
**PWA only (client-side tools):** open `frontend/index.html` in any browser, or serve `frontend/` with any static server. No Python required.
|
**Install the server** — two ways to get it:
|
||||||
|
|
||||||
**Full (PWA + card server)** — two ways to get it:
|
|
||||||
|
|
||||||
**1. Download the release package (recommended).** From the
|
**1. Download the release package (recommended).** From the
|
||||||
[Releases](https://github.com/anttro/simple/releases) page take the latest package
|
[Releases](https://github.com/anttro/simple/releases) page take the latest package
|
||||||
@@ -25,7 +30,7 @@ SIMple is a workbench for UICC/SIM card specialists: an offline HTML/JS PWA plus
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
./setup.sh # or setup.bat on Windows — creates .venv, installs pysim + server
|
./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.
|
The package is built from the tagged source — no git history, no development files.
|
||||||
@@ -47,7 +52,7 @@ cd simple # `git pull` still updates it
|
|||||||
./start.sh # or start.bat
|
./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
|
## Build
|
||||||
|
|
||||||
@@ -557,7 +562,7 @@ probe is refused when SPI1 has no counter check or the named preset does not def
|
|||||||
|
|
||||||
## Cards
|
## 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 |
|
| Field | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -588,7 +593,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.
|
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
|
### File Browser
|
||||||
|
|
||||||
@@ -743,21 +748,14 @@ The interface is in English with Russian support. The language is detected from
|
|||||||
|
|
||||||
## Server (pysim-simple-server)
|
## 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/*`.
|
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/*`.
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
### Scripts
|
### Scripts
|
||||||
|
|
||||||
| Script | What it does |
|
| 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. |
|
| `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`. |
|
| `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 **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).
|
||||||
@@ -773,8 +771,8 @@ pysim-simple-server --http-port 8080
|
|||||||
```
|
```
|
||||||
|
|
||||||
On Windows use `setup.bat` instead: it installs pysim's dependencies from
|
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
|
`requirements-pysim.txt` and pysim itself with `--no-deps`, so pysim's optional
|
||||||
its MSVC build chain) is not pulled in.
|
dependencies are not pulled in.
|
||||||
|
|
||||||
### CLI options
|
### CLI options
|
||||||
|
|
||||||
@@ -782,7 +780,7 @@ its MSVC build chain) is not pulled in.
|
|||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `--http-host` | Bind address (default: `127.0.0.1`) |
|
| `--http-host` | Bind address (default: `127.0.0.1`) |
|
||||||
| `--http-port` | TCP port (default: `8080`) |
|
| `--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 |
|
| `-p` / `--pcsc-device` | PC/SC reader slot number |
|
||||||
| `-d` / `--device` | Serial device path |
|
| `-d` / `--device` | Serial device path |
|
||||||
| `--no-card-init` | Skip card init to preserve the CAT session (no file manager) |
|
| `--no-card-init` | Skip card init to preserve the CAT session (no file manager) |
|
||||||
@@ -824,13 +822,13 @@ See [docs/api.md](docs/api.md) for the full endpoint reference.
|
|||||||
|
|
||||||
## Version compatibility
|
## Version compatibility
|
||||||
|
|
||||||
| PWA (SIMple) | Server | Status |
|
| frontend (SIMple) | Server | Status |
|
||||||
|-------------|--------|--------|
|
|-------------|--------|--------|
|
||||||
| any | same major | ✅ Compatible |
|
| any | same major | ✅ Compatible |
|
||||||
| any | older major | ❌ Outdated — update server |
|
| 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).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
@@ -843,5 +841,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.
|
`setup.sh` / `setup.bat` install it from its upstream git repository.
|
||||||
|
|
||||||
Bundled third-party components (the MCC/MNC operator list and the AES/DES
|
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).
|
[THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES).
|
||||||
+25
-27
@@ -2,22 +2,27 @@
|
|||||||
|
|
||||||
> Прежнее название — OTAMan; переименован в SIMple 2026-09-19.
|
> Прежнее название — OTAMan; переименован в SIMple 2026-09-19.
|
||||||
|
|
||||||
📢 Новости и обновления проекта: [Telegram-канал](https://t.me/youeyeseesee)
|
📢 Новости и обновления проекта: [сайт](https://atroshin.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);
|
- **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;
|
- **OTA-лаборатория** — защищённые пакеты SCP80 (TS 102 225) и установка Java Card RAM, а также терминальная сторона HTTP OTA (SCP81): эмуляция BIP-каналов, PSK-TLS-сервер администрирования и выполнение скриптов APDU;
|
||||||
- **Симулятор** — браузер меню SIM Toolkit, обработка proactive-команд и событий, редактирование TERMINAL PROFILE, тест-скрипты карты и журнал C-AT-диалога;
|
- **Симулятор** — браузер меню SIM Toolkit, обработка proactive-команд и событий, редактирование TERMINAL PROFILE, тест-скрипты карты и журнал C-AT-диалога;
|
||||||
- **Профайлер карты** — захват файловой системы (профили и неизменяемые снимки с таймингами), точное сравнение снимков и декодер FCP/FCI.
|
- **Профайлер карты** — захват файловой системы (профили и неизменяемые снимки с таймингами), точное сравнение снимков и декодер FCP/FCI.
|
||||||
|
|
||||||
**Демо:** [simple.atroshin.ru](https://simple.atroshin.ru) — только PWA, для экспериментов. Для функций картридера установите сервер (ниже).
|
**Демо:** [simple.atroshin.ru](https://simple.atroshin.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. Скачать релизный пакет (рекомендуется).** На странице
|
**1. Скачать релизный пакет (рекомендуется).** На странице
|
||||||
[Releases](https://github.com/anttro/simple/releases) возьмите последний пакет
|
[Releases](https://github.com/anttro/simple/releases) возьмите последний пакет
|
||||||
@@ -25,7 +30,7 @@ SIMple — инструментарий для специалистов по UIC
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
./setup.sh # или setup.bat в Windows — создаёт .venv, ставит pysim + сервер
|
./setup.sh # или setup.bat в Windows — создаёт .venv, ставит pysim + сервер
|
||||||
./start.sh # или start.bat — запускает сервер (он же раздаёт PWA)
|
./start.sh # или start.bat — запускает сервер (он же раздаёт фронтенд)
|
||||||
```
|
```
|
||||||
|
|
||||||
Пакет собран из помеченного тегом исходного кода — без истории git и без файлов для
|
Пакет собран из помеченного тегом исходного кода — без истории git и без файлов для
|
||||||
@@ -47,7 +52,7 @@ cd simple # `git pull` всё равно
|
|||||||
./start.sh # или start.bat
|
./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** в интерфейсе.
|
||||||
|
|
||||||
## Сборка
|
## Сборка
|
||||||
|
|
||||||
@@ -532,7 +537,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,7 +568,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
|||||||
|
|
||||||
Подключение к встроенному [`pysim-simple-server`](pysim_simple_server/) для работы с картой.
|
Подключение к встроенному [`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.
|
||||||
|
|
||||||
### Файловый менеджер
|
### Файловый менеджер
|
||||||
|
|
||||||
@@ -715,33 +720,26 @@ 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)
|
## Сервер (pysim-simple-server)
|
||||||
|
|
||||||
Встроенный Python-сервер оборачивает [pySim](https://osmocom.org/projects/pysim/wiki) и раздаёт как PWA (из `frontend/`), так и JSON API по `/api/*`.
|
Встроенный Python-сервер оборачивает [pySim](https://osmocom.org/projects/pysim/wiki) и раздаёт как фронтенд (из `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 сознательно не ставится.
|
|
||||||
|
|
||||||
### Скрипты
|
### Скрипты
|
||||||
|
|
||||||
| Скрипт | Назначение |
|
| Скрипт | Назначение |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `setup.sh` / `setup.bat` | Создаёт `.venv/` (или исправляет неполный), ставит pysim и сервер. Запускать один раз после клонирования или распаковки релизного пакета. |
|
| `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`. |
|
| `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-ридер не найден, сервер сообщает об этом и завершается — подключите ридер и перезапустите (ридер без карты — по-прежнему нормальный старт без карты).
|
||||||
@@ -757,8 +755,8 @@ pysim-simple-server --http-port 8080
|
|||||||
```
|
```
|
||||||
|
|
||||||
В Windows используйте `setup.bat`: он ставит зависимости pysim из
|
В Windows используйте `setup.bat`: он ставит зависимости pysim из
|
||||||
`requirements-pysim.txt`, а сам pysim — с `--no-deps`, поэтому SMPP-мост (и его сборка
|
`requirements-pysim.txt`, а сам pysim — с `--no-deps`, поэтому необязательные
|
||||||
через MSVC) не подтягивается.
|
зависимости pysim не подтягиваются.
|
||||||
|
|
||||||
### Параметры CLI
|
### Параметры CLI
|
||||||
|
|
||||||
@@ -766,7 +764,7 @@ pysim-simple-server --http-port 8080
|
|||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `--http-host` | Адрес привязки (по умолчанию `127.0.0.1`) |
|
| `--http-host` | Адрес привязки (по умолчанию `127.0.0.1`) |
|
||||||
| `--http-port` | Порт (по умолчанию `8080`) |
|
| `--http-port` | Порт (по умолчанию `8080`) |
|
||||||
| `--web-dir` | Каталог со статикой PWA (по умолчанию `<repo>/frontend`) |
|
| `--web-dir` | Каталог со статикой фронтенда (по умолчанию `<repo>/frontend`) |
|
||||||
| `-p` / `--pcsc-device` | Номер слота PC/SC |
|
| `-p` / `--pcsc-device` | Номер слота PC/SC |
|
||||||
| `-d` / `--device` | Путь к serial-устройству |
|
| `-d` / `--device` | Путь к serial-устройству |
|
||||||
| `--no-card-init` | Пропустить инициализацию карты (сохранить CAT-сессию) |
|
| `--no-card-init` | Пропустить инициализацию карты (сохранить CAT-сессию) |
|
||||||
@@ -816,5 +814,5 @@ pysim-simple-server --http-port 8080
|
|||||||
Сам pySim не входит ни в этот репозиторий, ни в релизные артефакты: `setup.sh` /
|
Сам pySim не входит ни в этот репозиторий, ни в релизные артефакты: `setup.sh` /
|
||||||
`setup.bat` устанавливают его из его upstream git-репозитория.
|
`setup.bat` устанавливают его из его upstream git-репозитория.
|
||||||
|
|
||||||
Встроенные сторонние компоненты (список операторов MCC/MNC и AES/DES-бандлы в
|
Встроенные сторонние компоненты (список операторов MCC/MNC и AES/DES-бандлы во
|
||||||
PWA) сохраняют свои лицензии MIT — см. [THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES).
|
фронтенде) сохраняют свои лицензии MIT — см. [THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES).
|
||||||
+441
-10
@@ -1104,9 +1104,9 @@
|
|||||||
<div id="pysim-body" class="hidden">
|
<div id="pysim-body" class="hidden">
|
||||||
<div class="flex flex-wrap justify-center gap-1 mb-2">
|
<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-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="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" 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>
|
||||||
|
|
||||||
<div id="pysim-sub-files">
|
<div id="pysim-sub-files">
|
||||||
@@ -1117,6 +1117,17 @@
|
|||||||
<span data-l10n="Sort:">Sort:</span>
|
<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-blue-600 text-white" data-fs-sort="fid" onclick="pysimFsSetSort('fid')">FID</span>
|
||||||
<span class="pysim-fs-sort-pill px-2 py-0.5 rounded cursor-pointer bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300" data-fs-sort="name" onclick="pysimFsSetSort('name')" data-l10n="Name">Name</span>
|
<span class="pysim-fs-sort-pill px-2 py-0.5 rounded cursor-pointer bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300" data-fs-sort="name" onclick="pysimFsSetSort('name')" data-l10n="Name">Name</span>
|
||||||
|
<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>
|
</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>
|
<button id="pysim-fs-probe-btn" data-needs="card" onclick="pysimFsProbeAll()" class="w-full mb-1 px-2 py-0.5 rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Probe all files">Probe all files</button>
|
||||||
<div id="pysim-fs-probe-status" class="hidden mb-1 text-gray-500 dark:text-slate-400"></div>
|
<div id="pysim-fs-probe-status" class="hidden mb-1 text-gray-500 dark:text-slate-400"></div>
|
||||||
@@ -1979,7 +1990,7 @@
|
|||||||
// ===== Version =====
|
// ===== Version =====
|
||||||
// Single source of truth for the PWA version: shown in the header and used
|
// Single source of truth for the PWA version: shown in the header and used
|
||||||
// by the server version check in pysimConnect().
|
// by the server version check in pysimConnect().
|
||||||
const SIMPLE_VERSION = '3.39.0';
|
const SIMPLE_VERSION = '3.40.3';
|
||||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||||
|
|
||||||
// ===== view history (the browser Back/Forward buttons) =====
|
// ===== view history (the browser Back/Forward buttons) =====
|
||||||
@@ -4410,6 +4421,11 @@ function uiccStdFilesLoad() {
|
|||||||
Object.keys(_chains).forEach(id => {
|
Object.keys(_chains).forEach(id => {
|
||||||
if (document.getElementById(id + '-rows')) chainRender(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;
|
return _uiccStdFiles;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -14299,6 +14315,11 @@ async function cardsFetch() {
|
|||||||
cardsRebuildSelect();
|
cardsRebuildSelect();
|
||||||
cardsRender();
|
cardsRender();
|
||||||
tarRender();
|
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();
|
scp81PushPskMap();
|
||||||
spPresetWarningRender();
|
spPresetWarningRender();
|
||||||
return true;
|
return true;
|
||||||
@@ -15193,12 +15214,16 @@ let _cardsAutoIccid = null;
|
|||||||
function cardsAutoSelectByIccid(iccid) {
|
function cardsAutoSelectByIccid(iccid) {
|
||||||
// Act only when the equip reports a *new* ICCID: a manual preset choice
|
// 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.
|
// 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);
|
const norm = cardsNormIccid(iccid);
|
||||||
if (!norm) { _cardsAutoIccid = null; return -1; }
|
if (!norm) { _cardsAutoIccid = null; return -1; }
|
||||||
if (norm === _cardsAutoIccid) return -1;
|
if (norm === _cardsAutoIccid) return -1;
|
||||||
_cardsAutoIccid = norm;
|
|
||||||
const idx = cardsFindByIccid(norm);
|
const idx = cardsFindByIccid(norm);
|
||||||
if (idx < 0) return -1;
|
if (idx < 0) return -1;
|
||||||
|
_cardsAutoIccid = norm;
|
||||||
const sp = document.getElementById('sp-card-sel');
|
const sp = document.getElementById('sp-card-sel');
|
||||||
if (sp) sp.value = String(idx);
|
if (sp) sp.value = String(idx);
|
||||||
const ram = document.getElementById('ram-card-sel');
|
const ram = document.getElementById('ram-card-sel');
|
||||||
@@ -16073,6 +16098,346 @@ function pysimFsSetSort(mode) {
|
|||||||
pysimFsRenderTree();
|
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() {
|
async function pysimFsRefresh() {
|
||||||
const detailEl = document.getElementById('pysim-fs-detail');
|
const detailEl = document.getElementById('pysim-fs-detail');
|
||||||
detailEl.classList.add('hidden');
|
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>';
|
html += '<div class="text-gray-400 dark:text-slate-500">' + pad + ' ' + esc(t('(empty)')) + '</div>';
|
||||||
} else {
|
} else {
|
||||||
for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
|
for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
|
||||||
|
if (!pysimFsFilterShow(child)) continue;
|
||||||
html += pysimFsRenderNode(child, depth + 1);
|
html += pysimFsRenderNode(child, depth + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16337,6 +16703,42 @@ async function pysimFsToggleDir(path) {
|
|||||||
if (node.expanded) pysimRefresh();
|
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;
|
let pysimFsSelected = null;
|
||||||
|
|
||||||
function pysimFsInfoHtml(sel, name, opts) {
|
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
|
// 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 = {
|
const EF_ACC_CLASSES = {
|
||||||
0: 'Class 0', 1: 'Class 1', 2: 'Class 2', 3: 'Class 3', 4: 'Class 4', 5: 'Class 5',
|
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',
|
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',
|
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_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 }];
|
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 }];
|
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
|
// 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.
|
// (deleted 00 / off 10 / on 11) instead of the 1-bit checkbox grid.
|
||||||
const EF_SST_FIELDS = [{
|
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), '
|
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).',
|
+ '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) + '"' +
|
'<input type="checkbox" data-bit="' + n + '" data-bit-name="' + esc(name) + '"' +
|
||||||
(selected.has(String(n)) ? ' checked' : '') + ' onchange="pysimFsDecRowCheck(this)">' +
|
(selected.has(String(n)) ? ' checked' : '') + ' onchange="pysimFsDecRowCheck(this)">' +
|
||||||
'<span class="font-mono text-gray-500 dark:text-slate-400">' + n + '</span>' +
|
'<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>';
|
}).join('') + '</div></div>';
|
||||||
} else if (f.kind === 'bits2') {
|
} else if (f.kind === 'bits2') {
|
||||||
// the tri-state grid (EF.SST): one select per service, the decoder's
|
// 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">' +
|
'<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' : '') + '>' +
|
states3.map(o => '<option value="' + o[0] + '"' + (o[0] === cur ? ' selected' : '') + '>' +
|
||||||
esc(o[1]) + '</option>').join('') +
|
esc(o[1]) + '</option>').join('') +
|
||||||
'</select></label>';
|
'</select>' + pysimFsFilterBtnHtml(f.table, n) + '</label>';
|
||||||
}).join('') + '</div></div>';
|
}).join('') + '</div></div>';
|
||||||
} else if (f.kind === 'select') {
|
} else if (f.kind === 'select') {
|
||||||
const cur = String(value === undefined || value === null ? '' : value);
|
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 — редактирование не поддерживается',
|
'The number continues in EF.EXT5 - editing it is not supported': 'Номер продолжается в EF.EXT5 — редактирование не поддерживается',
|
||||||
'Services': 'Сервисы',
|
'Services': 'Сервисы',
|
||||||
'Filter services': 'Фильтр сервисов',
|
'Filter services': 'Фильтр сервисов',
|
||||||
|
'To EF filter': 'В фильтр EF',
|
||||||
'Off': 'Выключен',
|
'Off': 'Выключен',
|
||||||
'On': 'Включён',
|
'On': 'Включён',
|
||||||
'The service state must be deleted, off or on': 'Состояние сервиса должно быть: удалён, выключен или включён',
|
'The service state must be deleted, off or on': 'Состояние сервиса должно быть: удалён, выключен или включён',
|
||||||
@@ -27713,6 +28119,17 @@ const LANG_RU = {
|
|||||||
'No entries yet.': 'Записей пока нет.',
|
'No entries yet.': 'Записей пока нет.',
|
||||||
'Sort:': 'Сортировка:',
|
'Sort:': 'Сортировка:',
|
||||||
'Name': 'Имя',
|
'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': 'Проверить все файлы',
|
'Probe all files': 'Проверить все файлы',
|
||||||
'Feature': 'Функция',
|
'Feature': 'Функция',
|
||||||
'SST': 'SST',
|
'SST': 'SST',
|
||||||
@@ -28745,6 +29162,7 @@ function isViewVisible(id) {
|
|||||||
// Dynamic views build their labels with t() at render time; re-render the
|
// 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.
|
// visible ones after a language switch so they don't stay in the old language.
|
||||||
function refreshDynamicI18n() {
|
function refreshDynamicI18n() {
|
||||||
|
pysimFsFilterRender();
|
||||||
if (isViewVisible('tab-profiler')) {
|
if (isViewVisible('tab-profiler')) {
|
||||||
if (profilerView === 'list') { profilerRenderList(); profilerRenderSnapshotList(); if (profilerListTab === 'custom') pysimCustomRender(); }
|
if (profilerView === 'list') { profilerRenderList(); profilerRenderSnapshotList(); if (profilerListTab === 'custom') pysimCustomRender(); }
|
||||||
else if (profilerView === 'editor') profilerRenderEditor();
|
else if (profilerView === 'editor') profilerRenderEditor();
|
||||||
@@ -28820,6 +29238,19 @@ document.getElementById('theme-btn').textContent = document.documentElement.clas
|
|||||||
updateConvFields();
|
updateConvFields();
|
||||||
updateSp();
|
updateSp();
|
||||||
uiccStdFilesLoad();
|
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();
|
updateSpKic();
|
||||||
updateSpKid();
|
updateSpKid();
|
||||||
chainInit('chain-sim');
|
chainInit('chain-sim');
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simple-v484';
|
const CACHE = 'simple-v488';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -146,6 +146,30 @@ test('cardsAutoSelectByIccid reacts to a card swap and to unreadable ICCIDs', ()
|
|||||||
assert.strictEqual(cardsAutoSelectByIccid('1234567890123456789'), -1);
|
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', () => {
|
test('cardsFindDuplicateIccid catches duplicates across stored formats', () => {
|
||||||
globalThis.cards = [
|
globalThis.cards = [
|
||||||
{ name: 'A', iccid: '1111111111111111111' },
|
{ 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>' : '')"));
|
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', () => {
|
test('cardsRender highlights the equipped card preset', () => {
|
||||||
globalThis.t = s => s;
|
globalThis.t = s => s;
|
||||||
const els = { 'cards-tbody': { innerHTML: '' } };
|
const els = { 'cards-tbody': { innerHTML: '' } };
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const FNS = [
|
|||||||
'efEncPlmnList', 'efListRows', 'efPlmnRowOrig', 'efEncAdn', 'efEncAdnExt', 'efPnnNameBytes', 'efPnnHexBytes', 'efPnnOrigTlv', 'efEncPnn',
|
'efEncPlmnList', 'efListRows', 'efPlmnRowOrig', 'efEncAdn', 'efEncAdnExt', 'efPnnNameBytes', 'efPnnHexBytes', 'efPnnOrigTlv', 'efEncPnn',
|
||||||
'efFindEncoder', 'efHasEncoder', 'efEncError',
|
'efFindEncoder', 'efHasEncoder', 'efEncError',
|
||||||
'efDecModelValues', 'efDecModelFor', 'efDecEditModel', 'efActTechList', 'efDecFieldHtml',
|
'efDecModelValues', 'efDecModelFor', 'efDecEditModel', 'efActTechList', 'efDecFieldHtml',
|
||||||
|
'servicesFeatureByNumber', 'pysimFsFilterBtnHtml',
|
||||||
'efDecRowHtml', 'efDecGridHtml', 'efDecItemRowHtml', 'efDecItemsHtml',
|
'efDecRowHtml', 'efDecGridHtml', 'efDecItemRowHtml', 'efDecItemsHtml',
|
||||||
'efDecRecordsHtml', 'efDecEditHtml',
|
'efDecRecordsHtml', 'efDecEditHtml',
|
||||||
'efDecCollectValues', 'efDecCollectRows', 'efDecCollectItems',
|
'efDecCollectValues', 'efDecCollectRows', 'efDecCollectItems',
|
||||||
@@ -60,10 +61,11 @@ const FNS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
let code = 'var pysimFsSelected = null;\nvar pysimFsTreeRoot = null;\nvar pysimFsDecModel = null;\n'
|
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 f of FNS) code += extractFunc(html, f) + '\n';
|
||||||
for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP', 'EF_ACT_TECHS', 'EF_SPN_FIELDS',
|
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);
|
code += extractConst(html, c);
|
||||||
}
|
}
|
||||||
eval(code);
|
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', () => {
|
test('the bits filter hides the non-matching rows', () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
{ textContent: '1 Local Phone Book', style: {} },
|
{ 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';
|
let code = 'var pysimFsSort = "fid";\n';
|
||||||
|
code += 'var pysimFsFilter = new Set();\n';
|
||||||
code += extractFunc(html, 'pysimFsSortChildren') + '\n';
|
code += extractFunc(html, 'pysimFsSortChildren') + '\n';
|
||||||
code += extractFunc(html, 'pysimFsNodePath') + '\n';
|
code += extractFunc(html, 'pysimFsNodePath') + '\n';
|
||||||
|
code += extractFunc(html, 'pysimFsFilterMatch') + '\n';
|
||||||
|
code += extractFunc(html, 'pysimFsFilterShow') + '\n';
|
||||||
code += extractFunc(html, 'pysimFsRenderNode') + '\n';
|
code += extractFunc(html, 'pysimFsRenderNode') + '\n';
|
||||||
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
|
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
|
||||||
eval(code);
|
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]
|
[project]
|
||||||
name = "pysim-simple-server"
|
name = "pysim-simple-server"
|
||||||
version = "3.39.0"
|
version = "3.40.3"
|
||||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||||
license = { text = "GPL-2.0-or-later" }
|
license = { text = "GPL-2.0-or-later" }
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
|
|||||||
from osmocom.utils import rpad
|
from osmocom.utils import rpad
|
||||||
|
|
||||||
|
|
||||||
VERSION = '3.39.0'
|
VERSION = '3.40.3'
|
||||||
|
|
||||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user