diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c7d39d6..b1d6608 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,6 +54,11 @@ jobs: - name: Get dependencies run: flutter pub get + - name: Generate and check localizations + run: | + flutter gen-l10n + dart run tools/check_localizations.dart + - name: Analyze if: runner.os == 'Linux' run: flutter analyze --no-fatal-infos diff --git a/.gitignore b/.gitignore index f748180..3d63512 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,8 @@ coverage/ *.freezed.dart *.mocks.dart lib/generated_plugin_registrant.dart +lib/l10n/app_localizations.dart +lib/l10n/app_localizations_*.dart # OS Thumbs.db diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md new file mode 100644 index 0000000..af3f9cb --- /dev/null +++ b/CHANGELOG.en.md @@ -0,0 +1,70 @@ +# Changelog + +This file records significant changes to PM3 GUI. Version numbers follow [Semantic Versioning](https://semver.org/). + +## [Unreleased] + +### Fixed + +- Refresh retained command-card save confirmations after language changes, + preserving status clearing and error replacement. Replace stale translation + documentation links and describe the implemented HID reader-brute-force labels. + +- Refresh retained connection errors when the language changes by storing + process error kinds and arguments, while keeping the process contract + Flutter-free and preserving terminal history without retrying the connection. + +- Give HID Prox reader brute-force its own tab, heading and action labels, + without changing genuine key-recovery translations or PM3 command syntax. + +- Render retained write-back status and per-block outcomes in the current + language, including active, finished, cancelled and connection-lost sequences, + without clearing results or rewriting raw terminal output. + +- Report language-directory lookup failures as storage errors so startup + reaches Settings and queued saves can recover when the directory is available. + +- Give the DESFire Applications tab its own resource while preserving the + editor's Apply action translation. + +- Preserve the selected language when validating imported command-card drafts + and card data, without changing failed-import atomicity. + +- Keep command-catalog synchronization runnable with standalone `dart run` by + adapting Flutter localizations outside the process layer. + +- Keep feature-page subterminals displaying output until the page changes, a command is run again, or the page is disposed; output is no longer cut off after 5/8/10 seconds. The main terminal retains its existing display and history behavior. +- Fix commands being sent repeatedly from a single click on a feature page, and loading animations obscuring live output during execution. +- Use sequential client `rem` markers to determine when commands and the Windows handshake have completed, avoiding premature returns caused by silence, table borders and command echoes; reject concurrent sends, and no longer return partially successful results after an explicit timeout. +- Prevent invalid UTF-8 bytes from the Windows client from disconnecting the entire session. + +### Added + +- Added English and Simplified Chinese ARB localizations, localized framework + delegates, immediate language switching with a persistent system-default + option, locale-aware command help and dump analysis, translation checks, + bilingual UI coverage and an English README. The current catalogs contain + 701 messages per language; native OS dialogs and raw client output are not + translated by the GUI. + +- Synchronize 1033 terminal command entries from the current client's 96 help groups, and provide the `tools/sync_command_catalog.dart` update tool; preserve existing Chinese descriptions. + +## [0.0.3] - 2026-09-04 + +### Added + +- Add persistent desktop client settings, connection Session logs and reliable shutdown. +- Add the official Windows `pm3.bat` interactive entry point and support for connecting to physical PM3EASY512K devices. + +### Changed + +- Consolidate Windows/Linux builds and tag-based releases into one workflow; `v*` tags automatically generate Release artifacts for both platforms. +- Improve the download, contributing, security and GPL-3.0 licensing documentation. + +### Fixed + +- Fix: Correctly detect found MIFARE keys in `lib/parsers/output_parser.dart` (was incorrectly checking a numeric flag). +- Fix: Improve `sendCommandAndWait` in `lib/backend/desktop_cli/pm3_process.dart` to wait for table terminators (ensures `hf mf autopwn` full output including final key table is captured). + +[Unreleased]: https://github.com/Babel36acl/Promark3-GUI/compare/v0.0.3...HEAD +[0.0.3]: https://github.com/Babel36acl/Promark3-GUI/compare/v0.0.3-alpha...v0.0.3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fa58b0..79c65c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,33 @@ ### Fixed +- Refresh retained command-card save confirmations after language changes, + preserving status clearing and error replacement. Replace stale translation + documentation links and describe the implemented HID reader-brute-force labels. + +- Refresh retained connection errors when the language changes by storing + process error kinds and arguments, while keeping the process contract + Flutter-free and preserving terminal history without retrying the connection. + +- Give HID Prox reader brute-force its own tab, heading and action labels, + without changing genuine key-recovery translations or PM3 command syntax. + +- Render retained write-back status and per-block outcomes in the current + language, including active, finished, cancelled and connection-lost sequences, + without clearing results or rewriting raw terminal output. + +- Report language-directory lookup failures as storage errors so startup + reaches Settings and queued saves can recover when the directory is available. + +- Give the DESFire Applications tab its own resource while preserving the + editor's Apply action translation. + +- Preserve the selected language when validating imported command-card drafts + and card data, without changing failed-import atomicity. + +- Keep command-catalog synchronization runnable with standalone `dart run` by + adapting Flutter localizations outside the process layer. + - 子终端持续显示输出,直到切换页面、重新执行命令或销毁页面;不再在 5/8/10 秒后截断,总终端保持原有显示与历史行为。 - 修复功能页面一次点击重复发送命令,以及执行中加载动画遮住实时输出的问题。 - 使用客户端 `rem` 顺序标记判断命令与 Windows 握手完成,避免静默、表格边框和命令回显导致提前返回;拒绝并发发送,显式超时不再返回部分成功结果。 @@ -13,6 +40,13 @@ ### Added +- Added English and Simplified Chinese ARB localizations, localized framework + delegates, immediate language switching with a persistent system-default + option, locale-aware command help and dump analysis, translation checks, + bilingual UI coverage and an English README. The current catalogs contain + 701 messages per language; native OS dialogs and raw client output are not + translated by the GUI. + - 从当前客户端 96 个帮助分组同步 1033 个终端命令条目,并提供 `tools/sync_command_catalog.dart` 更新工具;已有中文说明保留。 ## [0.0.3] - 2026-09-04 @@ -32,6 +66,5 @@ - Fix: Correctly detect found MIFARE keys in `lib/parsers/output_parser.dart` (was incorrectly checking a numeric flag). - Fix: Improve `sendCommandAndWait` in `lib/backend/desktop_cli/pm3_process.dart` to wait for table terminators (ensures `hf mf autopwn` full output including final key table is captured). -[Unreleased]: https://github.com/AKCX2002/Promark3-GUI/compare/v0.0.3...HEAD -[0.0.3]: https://github.com/AKCX2002/Promark3-GUI/compare/v0.0.3-alpha...v0.0.3 - +[Unreleased]: https://github.com/Babel36acl/Promark3-GUI/compare/v0.0.3...HEAD +[0.0.3]: https://github.com/Babel36acl/Promark3-GUI/compare/v0.0.3-alpha...v0.0.3 diff --git a/CONTRIBUTING.en.md b/CONTRIBUTING.en.md new file mode 100644 index 0000000..9e480d4 --- /dev/null +++ b/CONTRIBUTING.en.md @@ -0,0 +1,66 @@ +# Contributing Guide + +Thank you for contributing improvements to PM3 GUI. The project uses the GPL-3.0 license; by submitting a contribution, you confirm that you have the right to submit the content and agree to release it under that license. + +## Before You Start + +- Bug reports should include the operating system, Flutter version, Proxmark3 client source, reproduction steps and sanitized logs. +- For new features or significant behavior changes, consider opening an Issue first to describe the use case and expected interaction. +- For security issues, follow [SECURITY.en.md](./SECURITY.en.md). Do not publicly disclose vulnerability details, credentials, card UIDs or private device information. + +## Local Development + +```bash +flutter pub get +flutter run -d windows +``` + +For Linux development, change the device target to `linux`. When integrating with the official Proxmark3 distribution on Windows, select `pm3.bat` in the distribution's root directory. + +Before submitting, run at least the checks relevant to your changes: + +```powershell +flutter gen-l10n +dart run tools\check_localizations.dart +flutter analyze --no-pub --no-fatal-infos +flutter test --no-pub +``` + +Run `dart format` only on the Dart files changed in your contribution; do not change unrelated code to correct historical formatting. CI checks localization resources and builds the desktop application on both Windows/Linux; the full tests and static analysis run in the Linux job. + +For changes to desktop packaging or platform code, also run `flutter build` for the corresponding platform. For changes involving real hardware behavior, state the device model, the scope of read-only/write operations and the actual validation results; do not describe simulated tests as successful real-device validation. + +## Localization + +English and Simplified Chinese resources are maintained in `lib/l10n/*.arb`. +Follow [the translation workflow](docs.en/localization.md), update both catalogs, +and run `flutter gen-l10n` and `dart run tools/check_localizations.dart` before +the existing analysis/tests. Never edit generated localization Dart files or +translate PM3 command syntax, protocol identifiers or raw hardware output. +Use distinct keys for different meanings even if their Chinese spelling is the +same (for example, DESFire Applications versus the editor Apply action). +New temporary model instances must inherit the active localizations before +validating user data. + +Keep standalone Dart tools Flutter-free: `Pm3Process` accepts +`Pm3ProcessMessages`, while `DesktopCliBackend` injects the Flutter adapter. +Changes to its Chinese defaults must continue to match the ARB catalog. +Directory lookup/storage failures must remain visible in Settings and must not +prevent startup or poison later language saves. + +Relevant regression coverage lives in `test/l10n/`, `test/ui/`, +`test/models/command_card_test.dart`, `test/services/locale_settings_store_test.dart` +and `test/tools/sync_command_catalog_test.dart`. Include the exact commands and +limitations of any validation in the PR; do not claim Linux or real-device +coverage based only on Windows widget/unit tests. + +Update the corresponding HTML snapshot when changing `README.en.md` or +`docs.en/checklist_new_features.md`; regeneration instructions and document status +are in [docs.en/README.md](docs.en/README.md). + +## Pull Request + +- Keep changes focused, and explain the problem, solution and validation results. +- Preserve existing compatible behavior; explain the need for any new dependencies or platform differences. +- Update the affected README, CHANGELOG or architecture documentation. +- Do not commit build directories, Proxmark3 distributions, firmware, credentials or unsanitized Session logs. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e396c5b..d62c686 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,14 +19,45 @@ Linux 开发可将设备目标改为 `linux`。Windows 与官方 Proxmark3 发 提交前至少运行与改动相关的检查: -```bash -dart format --output=none --set-exit-if-changed lib test +```powershell +flutter gen-l10n +dart run tools\check_localizations.dart flutter analyze --no-pub --no-fatal-infos flutter test --no-pub ``` +对本次修改的 Dart 文件单独运行 `dart format`;不要为修正历史格式而修改无关代码。CI 在 Windows/Linux 都检查本地化资源并构建桌面程序,完整测试和静态分析在 Linux job 执行。 + 涉及桌面打包或平台代码时,再执行对应平台的 `flutter build`。涉及真实硬件行为时,请说明设备型号、只读/写入范围和实际验证结果;不要把模拟测试写成实机通过。 +## Localization + +English and Simplified Chinese resources are maintained in `lib/l10n/*.arb`. +Follow [the translation workflow](docs/localization.md), update both catalogs, +and run `flutter gen-l10n` and `dart run tools/check_localizations.dart` before +the existing analysis/tests. Never edit generated localization Dart files or +translate PM3 command syntax, protocol identifiers or raw hardware output. +Use distinct keys for different meanings even if their Chinese spelling is the +same (for example, DESFire Applications versus the editor Apply action). +New temporary model instances must inherit the active localizations before +validating user data. + +Keep standalone Dart tools Flutter-free: `Pm3Process` accepts +`Pm3ProcessMessages`, while `DesktopCliBackend` injects the Flutter adapter. +Changes to its Chinese defaults must continue to match the ARB catalog. +Directory lookup/storage failures must remain visible in Settings and must not +prevent startup or poison later language saves. + +Relevant regression coverage lives in `test/l10n/`, `test/ui/`, +`test/models/command_card_test.dart`, `test/services/locale_settings_store_test.dart` +and `test/tools/sync_command_catalog_test.dart`. Include the exact commands and +limitations of any validation in the PR; do not claim Linux or real-device +coverage based only on Windows widget/unit tests. + +Update the corresponding HTML snapshot when changing `README.md` or +`docs/checklist_new_features.md`; regeneration instructions and document status +are in [docs/README.md](docs/README.md). + ## Pull Request - 保持改动聚焦,并说明问题、解决方式和验证结果。 diff --git a/PROJECT_ARCHIVE.en.md b/PROJECT_ARCHIVE.en.md new file mode 100644 index 0000000..1fb3ab8 --- /dev/null +++ b/PROJECT_ARCHIVE.en.md @@ -0,0 +1,107 @@ +# PM3 GUI Project Archive — Development Notes +> Created: 2026-04-08 | This file records the project's development context to help future maintainers take over. + +> **Historical snapshot, not a current feature list.** The directories, platforms, +> completion status and environment notes below are preserved from 2026-04-08; +> for current development, refer to the [README](README.en.md), +> [current architecture](docs.en/refactoring_plan.md) and [localization guide](docs.en/localization.md). + +## Current Addendum After PR #27 (2026-10-22) + +- The existing navigation contains 30 pages, and the interface supports Simplified Chinese and English rather than + being hard-coded for Chinese only; ARB resources and generation are in `lib/l10n/`, with 701 messages per language. +- The language can be switched immediately and is saved independently in `language.json`; import validation, + command help and Dump analysis use the selected language. Directory lookup/save failures appear in Settings, + and later saves can be retried. +- `Pm3Process` is now in `lib/backend/desktop_cli/`, supporting standalone command-help + synchronization through a pure Dart message contract; Flutter localization adaptation stays at the backend boundary. +- The officially supported desktop scope is Windows/Linux x64. Android, themes and unfinished items below are + historical records only; do not infer the current support scope or make renewed claims of completed real-device validation from them. + +--- + +## Project Status Summary (Historical Record) + +- ✅ Flutter project created, with all 19 Dart source files (3198 lines) +- ✅ Entire interface localized into Chinese +- ✅ Linux build validation passed +- ✅ Android USB OTG configuration completed +- ✅ Git repository (2 commits) +- ⚠️ Not actually run in an environment with a display (the remote headless environment lacks GPU drivers) + +## Architecture + +**CLI Wrapper** — The GUI does not implement the PM3 hardware protocol directly; it starts and interacts with the PM3 command line through `dart:io Process`. + +``` +User action → AppState → PM3Process.sendCommand(stdin) + ↓ + PM3 CLI stdout → OutputParser (regex) + ↓ + Structured data → notifyListeners → UI +``` + +**Advantage**: PM3 firmware/CLI updates do not affect GUI compatibility. + +## Code Navigation + +| Feature | Entry File | Description | +|------|----------|------| +| Application startup | `lib/main.dart` | Provider injection | +| Global state | `lib/state/app_state.dart` | Connection/terminal/card/theme | +| PM3 communication | `lib/services/pm3_process.dart` | Process pipes | +| Command templates | `lib/services/pm3_commands.dart` | All PM3 commands | +| Output parsing | `lib/parsers/output_parser.dart` | UID/ATQA/SAK/block/key regex | +| Dump parsing | `lib/parsers/dump_parser.dart` | Automatic .eml/.bin/.json detection | +| Data models | `lib/models/mifare_card.dart` | CardType + MifareCard | +| Access control | `lib/models/access_bits.dart` | Access-bit decoding | +| Home navigation | `lib/ui/home_page.dart` | Bottom navigation with 6 pages | +| Theme | `lib/ui/theme.dart` | Material 3 dark/light | + +## Feature Pages + +1. **Connection** — PM3 path + serial port selection + connection status +2. **Terminal** — Full-featured terminal + 7 shortcut buttons +3. **Dump Viewer** — Open/view/export MIFARE dump files +4. **High Frequency (Mifare)** — Detection/attacks/read and write/magic cards (4 tabs) +5. **Low Frequency** — General/EM4x05/T55xx (3 tabs) +6. **Settings** — Theme/path/about/maintenance + +## To Do + +- Signal waveform visualization (fl_chart added but not used) +- Dump file editing +- iClass / Legic / NTAG / DESFire pages +- Command autocomplete + persistent history +- Internationalization framework (currently hard-coded Chinese, no arb files) +- Unit tests +- Actual Windows/Android testing + +## Environment Requirements + +```bash +# Flutter +export PATH="/opt/flutter/bin:$PATH" +flutter pub get + +# Linux build dependencies +pacman -S gtk3 # Arch + +# Build +flutter build linux # Output build/linux/x64/release/bundle/ +flutter build apk # Android + +# Run +flutter run -d linux +``` + +## Notes + +- The global gitignore (`~/.gitignore_global`) may exclude `lib/`; `!/lib/` at the end of the project's `.gitignore` overrides this. +- Android USB OTG configuration is in `android/app/src/main/AndroidManifest.xml` and `xml/usb_device_filter.xml`. +- PM3 VID:PID = `9ac4:4b8f` + +## References + +- [wh201906/Proxmark3GUI](https://github.com/wh201906/Proxmark3GUI) — Qt C++ reference implementation +- [RfidResearchGroup/proxmark3](https://github.com/RfidResearchGroup/proxmark3) — PM3 firmware/CLI diff --git a/PROJECT_ARCHIVE.md b/PROJECT_ARCHIVE.md index 3c9c398..398533f 100644 --- a/PROJECT_ARCHIVE.md +++ b/PROJECT_ARCHIVE.md @@ -1,9 +1,24 @@ # PM3 GUI 项目存档 — 开发笔记 > 创建时间: 2026-04-08 | 此文件记录项目开发上下文,便于后续接手 +> **历史快照,不是当前功能清单。** 下文保留 2026-04-08 的目录、平台、 +> 完成状态与环境记录;当前开发请以 [README](README.md)、 +> [架构现状](docs/refactoring_plan.md) 和 [本地化指南](docs/localization.md) 为准。 + +## PR #27 后的当前补充(2026-10-22) + +- 现有导航包含 30 个页面,界面支持简体中文和英语,不再硬编码为仅中文;ARB + 与生成流程位于 `lib/l10n/`,当前每种语言 701 条消息。 +- 语言可立即切换并独立保存于 `language.json`;导入验证、命令帮助和 Dump + 分析按所选语言工作。目录查找/保存失败会在设置页提示,后续保存可重试。 +- `Pm3Process` 当前位于 `lib/backend/desktop_cli/`,通过纯 Dart 消息契约支持独立 + 命令帮助同步;Flutter 本地化适配保留在后端边界。 +- 正式桌面范围是 Windows/Linux x64。下文 Android、主题和待完成条目只代表历史记录; + 不能据此推断当前支持范围或重新声称已经完成实机验证。 + --- -## 项目状态总结 +## 项目状态总结(历史记录) - ✅ Flutter 项目创建完成,所有 19 个 Dart 源文件(3198 行) - ✅ 界面全中文本地化 diff --git a/README.en.html b/README.en.html new file mode 100644 index 0000000..d645ae0 --- /dev/null +++ b/README.en.html @@ -0,0 +1,175 @@ + +

PM3 GUI

+

简体中文 (Simplified Chinese) | English | English quick start

+
+ Build + Release + Platforms + License +
+

PM3 GUI is a GPL-3.0 open-source desktop graphical client for Proxmark3, providing common RFID/NFC operations, dump file management and a command terminal.

+
+

⚠️ Alpha notice: The current version is still evolving; pages and behavior may change.

+
+

Perform operations only on devices, cards and systems that you own or are explicitly authorized to use, and comply with the laws applicable in your location.

+

Interface Language

+

In Settings → Language, select System default, 简体中文 (Simplified Chinese) or English. Changes take effect immediately, without restarting or disconnecting the device; unsupported system languages use English. The language preference is saved independently and does not affect existing PM3 configuration, drafts or device data. Command help descriptions also switch languages; raw client output, command syntax and export formats remain unchanged.

+

Only Simplified Chinese and English resources are provided; other Chinese system regions/writing variants use the existing Simplified Chinese resources. Flutter framework text, such as menus and dialogs, follows the application language, while native file pickers continue to use the operating system language.

+

If saving the language fails, the current session continues using the selected language and Settings displays an error; after fixing the directory or permission problem, select a language again to retry saving. A damaged or unsupported language setting does not prevent the interface from starting.

+

For translation maintenance, adding languages, current behavior, PR #27 regression coverage and validation limitations, see Localization. For the status and historical context of all documents, see the documentation index; README.en.html is the HTML snapshot of this file.

+

Platforms and Scope

+ +

The CI workflow includes Windows and Linux x64 desktop paths. Current limited real-device evidence comes from Windows x64: using a user-provided distribution corresponding to RRG commit da509461 and a PM3EASY512K (identified by the client as PM3 GENERIC / AT91SAM7S512 / 512K), client connection, read-only hw version and disconnection have been completed through the root-directory pm3.bat. Additional validation on 2026-09-05 covered the Mifare page's Detect Card action through the real client and device: one click sends one command, and the page displays ATQA/SAK; the client reported MIFARE Classic 1K as a possible type. A command silent for 6 seconds and search output lasting more than 10 seconds have also been verified. These results must not be extrapolated to claim validation of Linux, other devices, card writing, erasing or full firmware interaction.

+

Downloads and Releases

+ +

Core Capabilities

+ +
Flutter UI / Feature
+        │
+        ▼
+  Pm3Controller → Pm3Backend → DesktopCliBackend
+                                      │
+                                      ▼
+                                  Pm3Process
+                                      │
+                                      ▼
+                         official proxmark3 client
+
+

Pm3Process is the pure Dart process adapter used by DesktopCliBackend and reused by the standalone Dart help-synchronization tool; Widgets and Features do not use dart:io Process directly. The GUI injects localized messages through the backend; standalone tools do not load Flutter's dart:ui and retain Chinese diagnostic defaults.

+

Pipe mode uses the RRG client's rem command to generate sequential completion markers (verified with client da509461), so brief pauses or table separators do not cause premature completion. By default, it continues waiting; while a long command is executing, the session can be disconnected from the Connection page. Changing pages only stops subterminal listening and does not terminate the command. Calls with an explicit timeout disconnect the session on timeout to avoid mixing an unfinished command with subsequent output.

+

Terminal autocomplete selects docs/pm3_commands_help.zh.yaml or docs/pm3_commands_help.yaml according to the interface language, then merges the docs/pm3_commands_client.json client snapshot. Entries added from the snapshot retain the client's original descriptions; no real-time machine translation is performed. After updating the client, run the following read-only help synchronization from the project root; on Windows, use the distribution's root entry point, and for a native client, add the port argument after the path:

+
dart run tools\sync_command_catalog.dart 'C:\path\to\pm3.bat'
+
+

The Linux example is dart run tools/sync_command_catalog.dart /usr/bin/proxmark3 /dev/ttyACM0. First run flutter pub get to prepare the package configuration, then run it with the dart bundled with Flutter; this is not a flutter run entry point. The tool requires an installed client and the corresponding device connection, and does not read the GUI's saved language preference.

+

Successful synchronization updates docs/pm3_commands_client.json; then rebuild the GUI. Calling the tool without arguments displays usage and returns 2; connection/collection failures return 1; success returns 0. The snapshot reflects commands visible to the client and connected device at collection time; it does not mean every feature has a dedicated page or has been validated with a real card.

+

Configuration and Privacy

+

The Connection page selects and persists the serial/device port; Settings manages the client entry-point path, startup arguments and optional working directory. Startup arguments are edited one argument per line, preserving spaces, quotes and backslashes exactly as entered. On Windows, prefer pm3.bat in the root directory of the official distribution; do not bypass the distribution's initialization process by selecting the internal client directly. PM3 GUI starts this script through cmd.exe /d /c call and preserves interactive standard input/output; the script initializes the environment, launches the internal client and detects the device automatically, so the GUI's saved port and extra startup arguments are not passed to .bat/.cmd. Native proxmark3.exe and Linux proxmark3 still start with the selected port.

+

Devices such as PM3EASY512K that are already supported by the official RfidResearchGroup/proxmark3 client use the same entry point; the GUI does not need separate model-specific drivers or an allowlist. These settings use atomic replacement with a temporary file in the same directory, and the interface displays saving or failure status; application shutdown waits for the last settings write. The settings files are located at:

+
<application support directory>/pm3_settings.json
+<application support directory>/language.json
+
+

These files save client configuration and language preference separately and do not overwrite one another; language in language.json is "en", "zh" or null (system default). The application support directory is determined by path_provider's getApplicationSupportDirectory() for the current operating system and user account. It is not the project directory; the actual absolute path depends on the system configuration. Language-directory lookup/read/write exceptions appear as settings errors; saving does not silently fall back to the current working directory.

+

Each successful connection creates a local Session under the same application support directory:

+
<application support directory>/sessions/YYYY-MM-DD_HHmmss_SSS/
+├─ session.json       # Port, client path, start/end times
+├─ terminal.log       # Client output
+└─ commands.jsonl     # Sent commands (one JSON entry per line)
+
+

These files are written only to the local machine and are not uploaded by PM3 GUI. Terminal output and command arguments may contain UIDs, device information or other sensitive data; storage error details may also contain personal directory paths. Protect the application support directory, and review and sanitize logs or error reports yourself before sharing them.

+

Installation and Running

+

Requirements

+ +

Run

+
git clone https://github.com/Babel36acl/Promark3-GUI.git
+cd Promark3-GUI
+flutter pub get
+flutter run -d windows
+
+

On Linux x64, use flutter run -d linux. First install the corresponding desktop toolchain according to the official Flutter documentation.

+

If upstream main or the selected Release does not yet include PR #27, check out the PR branch in the repository above to try it, then run flutter pub get:

+
git fetch https://github.com/Babel36acl/Promark3-GUI.git pull/27/head:pr-27
+git switch pr-27
+
+

Build

+
flutter build windows
+flutter build linux
+
+

File Formats

+

The dump viewer's Command Data tab can directly edit command-capture results without first generating a dump file. MIFARE chk/fchk/nested/staticnested/autopwn results from commands sent by feature pages and the main terminal are collected using the actual sector numbers; hardnested requires the command to include --tblk and --ta/--tb explicitly before its recovered results can be associated. Block tables from rdbl/rdsc/cgetblk/cgetsc/egetblk/eview/view are collected using the actual block numbers. Main terminal output remains unchanged.

+

Before changing cards, use New Card / Capacity to clear the draft and select the capacity; detecting a different UID also clears the old data. Missing blocks and keys remain unknown, and trailer blocks read through normal authentication do not treat hidden keys as zero keys. Keys → Trailer Blocks and Trailer Blocks → Keys explicitly synchronize the two. Draft JSON can save and reopen incomplete data; only complete data can be exported as BIN/EML, only complete sector keys can be exported as key BIN, and known keys can be exported separately as a dictionary. Opening key BIN/key text without a dump opens this tab and does not create a fictitious all-zero dump. When importing drafts or card data, block and key validation uses the currently selected language; a failed import does not change the existing draft, capacity, UID or keys.

+

File collection scans the configured working directory, the Windows distribution's client directory, save/dump paths in the client's .proxmark3/preferences.json, and the GUI's default pm3_files categorization directory. Scan exceptions appear beside the file-collection heading, while scanning continues in other accessible directories.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypeExtensionReadExport
EML dump.eml✅✅
Binary dump.bin, .dump✅✅
JSON dump.json✅✅
Key dictionary.dic✅✅
Key text.keys.txt✅✅
Command-data draft.json (dedicated draft format)✅ (Command Data tab)✅
+

Repository Structure

+
lib/core/pm3/       # Pm3Backend contract, commands, events, results and Session models
+lib/backend/         # DesktopCliBackend, Pm3Process and Mock Backend
+lib/services/        # Settings, Session, file and command services
+lib/state/           # Provider state management
+lib/ui/              # Desktop pages and components
+lib/l10n/            # ARB resources, language preference state and Flutter localization adaptation
+docs/                # Command mappings, specifications and architecture roadmap
+.github/workflows/   # Windows/Linux builds and tag-based releases
+
+

Development and Validation

+

Before submitting, you can run:

+
flutter gen-l10n
+dart run tools\check_localizations.dart
+flutter analyze --no-pub --no-fatal-infos
+flutter test --no-pub
+flutter build windows --debug --no-pub
+
+

Run dart format only on the Dart files changed in this contribution; do not reformat unrelated files merely to tidy historical formatting. The commands above cover localization generation/completeness, static analysis, Dart/Flutter tests and a Windows debug build; they do not cover the Linux runner build or replace real PM3 hardware acceptance testing. The CI Linux x64 build path is defined in .github/workflows/build.yml.

+

Contributing and Security

+

Issues and Pull Requests are welcome. Before starting, read CONTRIBUTING.en.md; report security vulnerabilities privately as described in SECURITY.en.md, and do not disclose sensitive details in public Issues.

+

Future Roadmap

+

The next phase will establish a Command Registry and Parser Registry to unify command metadata, parameters and output parsing. Dedicated GUI pages will continue to prioritize frequently used workflows; infrequent or new commands will remain available through Terminal.

+

License

+

This project uses the GPL-3.0 license; see LICENSE.

+

The Proxmark3 name, client and firmware belong to their respective upstream projects and contributors; this repository does not distribute or relicense those upstream components.

diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..4fc23b0 --- /dev/null +++ b/README.en.md @@ -0,0 +1,193 @@ +# PM3 GUI + +[简体中文 (Simplified Chinese)](README.md) | **English** | [English quick start](README.quickstart.en.md) + +
+ Build + Release + Platforms + License +
+ +PM3 GUI is a GPL-3.0 open-source desktop graphical client for Proxmark3, providing common RFID/NFC operations, dump file management and a command terminal. + +> ⚠️ **Alpha notice**: The current version is still evolving; pages and behavior may change. + +Perform operations only on devices, cards and systems that you own or are explicitly authorized to use, and comply with the laws applicable in your location. + +## Interface Language + +In **Settings → Language**, select **System default**, **简体中文 (Simplified Chinese)** or **English**. Changes take effect immediately, without restarting or disconnecting the device; unsupported system languages use English. The language preference is saved independently and does not affect existing PM3 configuration, drafts or device data. Command help descriptions also switch languages; raw client output, command syntax and export formats remain unchanged. + +Only Simplified Chinese and English resources are provided; other Chinese system regions/writing variants use the existing Simplified Chinese resources. Flutter framework text, such as menus and dialogs, follows the application language, while native file pickers continue to use the operating system language. + +If saving the language fails, the current session continues using the selected language and Settings displays an error; after fixing the directory or permission problem, select a language again to retry saving. A damaged or unsupported language setting does not prevent the interface from starting. + +For translation maintenance, adding languages, current behavior, PR #27 regression coverage and validation limitations, see [Localization](docs.en/localization.md). For the status and historical context of all documents, see the [documentation index](docs.en/README.md); [README.en.html](README.en.html) is the HTML snapshot of this file. + +## Platforms and Scope + +- Windows x64 is the primary platform for 1.0. +- Linux x64 is an officially supported platform for 1.0. +- Android, iOS, Web and macOS are outside the scope of 1.0. +- This project does not bundle Proxmark3 firmware or the client; device communication uses the official Proxmark3 client installed by the user. On Windows, prefer `pm3.bat` in the official distribution's root directory; on Linux, use `proxmark3`. + +The CI workflow includes Windows and Linux x64 desktop paths. Current limited real-device evidence comes from Windows x64: using a user-provided distribution corresponding to RRG commit `da509461` and a PM3EASY512K (identified by the client as `PM3 GENERIC / AT91SAM7S512 / 512K`), client connection, read-only `hw version` and disconnection have been completed through the root-directory `pm3.bat`. Additional validation on 2026-09-05 covered the Mifare page's **Detect Card** action through the real client and device: one click sends one command, and the page displays ATQA/SAK; the client reported MIFARE Classic 1K as a possible type. A command silent for 6 seconds and search output lasting more than 10 seconds have also been verified. These results must not be extrapolated to claim validation of Linux, other devices, card writing, erasing or full firmware interaction. + +## Downloads and Releases + +- Published versions and Windows/Linux x64 archives are available from [GitHub Releases](https://github.com/Babel36acl/Promark3-GUI/releases). Localization changes belong to [PR #27](https://github.com/Babel36acl/Promark3-GUI/pull/27) and the Unreleased development content below; historical release archives are not guaranteed to include these changes. +- CI for `main` branch commits and Pull Requests generates and checks localization resources and builds releases on both platforms; static analysis and the full tests run in the Linux job. The first run for an external PR may require approval from an upstream maintainer. +- After a tag such as `v0.0.4` is pushed, the same workflow packages both platforms and creates a GitHub Release with automatically generated release notes. +- Releases contain only PM3 GUI; obtain the Proxmark3 client and firmware from [RfidResearchGroup/proxmark3](https://github.com/RfidResearchGroup/proxmark3). + +## Core Capabilities + +- Drive the official Proxmark3 client through the `Pm3Backend` contract and `DesktopCliBackend`. +- The terminal supports full command passthrough, history and output viewing. +- Dedicated GUI pages prioritize frequently used workflows; infrequent, diagnostic and new commands remain available in Terminal. +- Feature-page subterminals continue receiving output until the page changes; running another command starts a new capture. Received content remains visible during execution, and the main terminal keeps its output history independently. +- View, analyze and export EML, BIN/DUMP, JSON and key files. +- Connections, command output and process exits have diagnosable local lifecycle management. + +```text +Flutter UI / Feature + │ + ▼ + Pm3Controller → Pm3Backend → DesktopCliBackend + │ + ▼ + Pm3Process + │ + ▼ + official proxmark3 client +``` + +`Pm3Process` is the pure Dart process adapter used by `DesktopCliBackend` and reused by the standalone Dart help-synchronization tool; Widgets and Features do not use `dart:io Process` directly. The GUI injects localized messages through the backend; standalone tools do not load Flutter's `dart:ui` and retain Chinese diagnostic defaults. + +Pipe mode uses the RRG client's `rem` command to generate sequential completion markers (verified with client `da509461`), so brief pauses or table separators do not cause premature completion. By default, it continues waiting; while a long command is executing, the session can be disconnected from the Connection page. Changing pages only stops subterminal listening and does not terminate the command. Calls with an explicit timeout disconnect the session on timeout to avoid mixing an unfinished command with subsequent output. + +Terminal autocomplete selects `docs/pm3_commands_help.zh.yaml` or `docs/pm3_commands_help.yaml` according to the interface language, then merges the `docs/pm3_commands_client.json` client snapshot. Entries added from the snapshot retain the client's original descriptions; no real-time machine translation is performed. After updating the client, run the following read-only help synchronization from the project root; on Windows, use the distribution's root entry point, and for a native client, add the port argument after the path: + +```powershell +dart run tools\sync_command_catalog.dart 'C:\path\to\pm3.bat' +``` + +The Linux example is `dart run tools/sync_command_catalog.dart /usr/bin/proxmark3 /dev/ttyACM0`. First run `flutter pub get` to prepare the package configuration, then run it with the `dart` bundled with Flutter; this is not a `flutter run` entry point. The tool requires an installed client and the corresponding device connection, and does not read the GUI's saved language preference. + +Successful synchronization updates `docs/pm3_commands_client.json`; then rebuild the GUI. Calling the tool without arguments displays usage and returns 2; connection/collection failures return 1; success returns 0. The snapshot reflects commands visible to the client and connected device at collection time; it does not mean every feature has a dedicated page or has been validated with a real card. + +## Configuration and Privacy + +The Connection page selects and persists the serial/device port; Settings manages the client entry-point path, startup arguments and optional working directory. Startup arguments are edited **one argument per line**, preserving spaces, quotes and backslashes exactly as entered. On Windows, prefer `pm3.bat` in the **root directory** of the official distribution; do not bypass the distribution's initialization process by selecting the internal client directly. PM3 GUI starts this script through `cmd.exe /d /c call` and preserves interactive standard input/output; the script initializes the environment, launches the internal client and detects the device automatically, so the GUI's saved port and extra startup arguments are not passed to `.bat`/`.cmd`. Native `proxmark3.exe` and Linux `proxmark3` still start with the selected port. + +Devices such as PM3EASY512K that are already supported by the official [RfidResearchGroup/proxmark3](https://github.com/RfidResearchGroup/proxmark3) client use the same entry point; the GUI does not need separate model-specific drivers or an allowlist. These settings use atomic replacement with a temporary file in the same directory, and the interface displays saving or failure status; application shutdown waits for the last settings write. The settings files are located at: + +```text +/pm3_settings.json +/language.json +``` + +These files save client configuration and language preference separately and do not overwrite one another; `language` in `language.json` is `"en"`, `"zh"` or `null` (system default). The application support directory is determined by `path_provider`'s `getApplicationSupportDirectory()` for the current operating system and user account. It is not the project directory; the actual absolute path depends on the system configuration. Language-directory lookup/read/write exceptions appear as settings errors; saving does not silently fall back to the current working directory. + +Each successful connection creates a local Session under the same application support directory: + +```text +/sessions/YYYY-MM-DD_HHmmss_SSS/ +├─ session.json # Port, client path, start/end times +├─ terminal.log # Client output +└─ commands.jsonl # Sent commands (one JSON entry per line) +``` + +These files are written only to the local machine and are not uploaded by PM3 GUI. Terminal output and command arguments may contain UIDs, device information or other sensitive data; storage error details may also contain personal directory paths. Protect the application support directory, and review and sanitize logs or error reports yourself before sharing them. + +## Installation and Running + +### Requirements + +- Flutter stable (3.47.7 used for this validation) +- Dart SDK bundled with Flutter (3.13.5 used for this validation) +- Official Proxmark3 client: `pm3.bat` in the Windows distribution's root directory, or Linux `proxmark3` + +### Run + +```bash +git clone https://github.com/Babel36acl/Promark3-GUI.git +cd Promark3-GUI +flutter pub get +flutter run -d windows +``` + +On Linux x64, use `flutter run -d linux`. First install the corresponding desktop toolchain according to the official Flutter documentation. + +If upstream `main` or the selected Release does not yet include PR #27, check out the PR branch in the repository above to try it, then run `flutter pub get`: + +```powershell +git fetch https://github.com/Babel36acl/Promark3-GUI.git pull/27/head:pr-27 +git switch pr-27 +``` + +### Build + +```bash +flutter build windows +flutter build linux +``` + +## File Formats + +The dump viewer's **Command Data** tab can directly edit command-capture results without first generating a dump file. MIFARE `chk/fchk/nested/staticnested/autopwn` results from commands sent by feature pages and the main terminal are collected using the actual sector numbers; `hardnested` requires the command to include `--tblk` and `--ta/--tb` explicitly before its recovered results can be associated. Block tables from `rdbl/rdsc/cgetblk/cgetsc/egetblk/eview/view` are collected using the actual block numbers. Main terminal output remains unchanged. + +Before changing cards, use **New Card / Capacity** to clear the draft and select the capacity; detecting a different UID also clears the old data. Missing blocks and keys remain unknown, and trailer blocks read through normal authentication do not treat hidden keys as zero keys. **Keys → Trailer Blocks** and **Trailer Blocks → Keys** explicitly synchronize the two. Draft JSON can save and reopen incomplete data; only complete data can be exported as BIN/EML, only complete sector keys can be exported as key BIN, and known keys can be exported separately as a dictionary. Opening key BIN/key text without a dump opens this tab and does not create a fictitious all-zero dump. When importing drafts or card data, block and key validation uses the currently selected language; a failed import does not change the existing draft, capacity, UID or keys. + +File collection scans the configured working directory, the Windows distribution's `client` directory, save/dump paths in the client's `.proxmark3/preferences.json`, and the GUI's default `pm3_files` categorization directory. Scan exceptions appear beside the file-collection heading, while scanning continues in other accessible directories. + +| Type | Extension | Read | Export | +|---|---|---:|---:| +| EML dump | `.eml` | ✅ | ✅ | +| Binary dump | `.bin`, `.dump` | ✅ | ✅ | +| JSON dump | `.json` | ✅ | ✅ | +| Key dictionary | `.dic` | ✅ | ✅ | +| Key text | `.keys.txt` | ✅ | ✅ | +| Command-data draft | `.json` (dedicated draft format) | ✅ (Command Data tab) | ✅ | + +## Repository Structure + +```text +lib/core/pm3/ # Pm3Backend contract, commands, events, results and Session models +lib/backend/ # DesktopCliBackend, Pm3Process and Mock Backend +lib/services/ # Settings, Session, file and command services +lib/state/ # Provider state management +lib/ui/ # Desktop pages and components +lib/l10n/ # ARB resources, language preference state and Flutter localization adaptation +docs/ # Command mappings, specifications and architecture roadmap +.github/workflows/ # Windows/Linux builds and tag-based releases +``` + +## Development and Validation + +Before submitting, you can run: + +```powershell +flutter gen-l10n +dart run tools\check_localizations.dart +flutter analyze --no-pub --no-fatal-infos +flutter test --no-pub +flutter build windows --debug --no-pub +``` + +Run `dart format` only on the Dart files changed in this contribution; do not reformat unrelated files merely to tidy historical formatting. The commands above cover localization generation/completeness, static analysis, Dart/Flutter tests and a Windows debug build; they do not cover the Linux runner build or replace real PM3 hardware acceptance testing. The CI Linux x64 build path is defined in `.github/workflows/build.yml`. + +## Contributing and Security + +Issues and Pull Requests are welcome. Before starting, read [CONTRIBUTING.en.md](./CONTRIBUTING.en.md); report security vulnerabilities privately as described in [SECURITY.en.md](./SECURITY.en.md), and do not disclose sensitive details in public Issues. + +## Future Roadmap + +The next phase will establish a **Command Registry** and **Parser Registry** to unify command metadata, parameters and output parsing. Dedicated GUI pages will continue to prioritize frequently used workflows; infrequent or new commands will remain available through Terminal. + +## License + +This project uses the **GPL-3.0** license; see [LICENSE](./LICENSE). + +The Proxmark3 name, client and firmware belong to their respective upstream projects and contributors; this repository does not distribute or relicense those upstream components. diff --git a/README.html b/README.html index 573bcc3..fdb4e2d 100644 --- a/README.html +++ b/README.html @@ -1,811 +1,175 @@ - -

PM3 GUI

-

- Flutter - Dart - 平台 - License -

-

- Proxmark3 跨平台图形界面 -

-
-

✨ 功能概览

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
模块说明
🔌 连接 / 仪表盘自动检测串口、连接状态总览、环境信息、PM3 文件自动收集与归类
💻 终端完整的 PM3 交互式终端,支持 ANSI 彩色输出、命令历史和快捷命令按钮
📁 Dump 查看 / 编辑扇区视图 · 密钥编辑 · 深度分析 · CUID 回写/清空 · 智能文件合并
🔀 Dump 对比两份转储并排对比,字节级差异高亮,差异统计面板
🔐 Mifare 高频检测 / Autopwn / 转储 / 恢复 / Nested / Hardnested / Darkside / 魔术卡
📡 低频 (LF)EM410x 读取与克隆、T55xx 检测 / 转储 / 块读写、天线调谐
⚙️ 设置PM3 路径配置、莫兰迪色系主题切换、硬件版本和调谐快捷操作
-

🏗 架构设计

-
┌──────────────────────────────────────────────────────────┐
-│                    Flutter GUI (侧边栏导航)               │
-│  ┌─────────────┐  ┌──────────┐  ┌──────────────────────┐ │
-│  │ Provider     │  │ 莫兰迪   │  │   Dump 解析器        │ │
-│  │ 全局状态     │  │ M3 主题  │  │ .eml .bin .json .dic │ │
-│  └──────┬──────┘  └──────────┘  └──────────────────────┘ │
-│         │                                                │
-│  ┌──────▼──────────────────────────────────────────────┐ │
-│  │               Pm3Process (dart:io)                  │ │
-│  │   stdin/stdout 管道 ⟷ pm3 命令行  ⟷ OutputParser   │ │
-│  └─────────────────────────────────────────────────────┘ │
-│         │                                                │
-│  ┌──────▼──────────────────────────────────────────────┐ │
-│  │         FileCollector — 文件自动收集 & 归类          │ │
-│  │   扫描 PM3 工作目录 → 按 UID 分组 → 移动到归类目录  │ │
-│  └─────────────────────────────────────────────────────┘ │
-└──────────────────────────────────────────────────────────┘
-         │  启动子进程
-         ▼
-┌────────────────────┐
-│  proxmark3 命令行  │  ← Iceman 分支 (RRG)
-│  (原始程序不变)  │
-└────────────────────┘
-
-
-

CLI Wrapper 模式 — 界面通过管道启动原版 pm3 / proxmark3 程序。 -上游命令更新自动继承,零维护成本。

-
-
-

📖 各页面功能详解

-

1. 🔌 连接 / 仪表盘(connection_page)

-

进入应用后的首页,采用左右分栏布局:

-

左栏 — 连接控制

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
功能说明
连接状态头圆形指示灯 + 端口/版本一行显示
PM3 程序路径输入框 + 文件有效性实时验证(✅/❌)
串口选择下拉菜单 + 刷新按钮;自动扫描 /dev/ttyACM* / ttyUSB*(Linux)或 COM1-20(Windows)
连接/断开带加载动画的主按钮
设备信息连接后显示端口、版本、命令历史数、终端缓冲行数;快捷按钮「硬件版本」「天线调谐」
环境信息平台 / CPU 架构 / Dart 版本 / PM3 路径有效性
错误信息连接失败时显示莫兰迪色错误卡片
-

右栏 — PM3 文件收集

- - - - - - - - - - - - - - - - - - - - - - - - - -
功能说明
自动扫描自动扫描 PM3 工作目录(pm3 所在目录 + $HOME + 当前目录),检测 dump/key 文件
按卡片分组按 UID 自动分组展示,显示各组的转储数/密钥数/文件总数
文件详情展开卡片组可看到每个文件的名称、大小、修改时间、格式
归类整理点击「归类整理」将散落的文件移动到结构化目录中
-

📂 归类整理存放位置:

-

默认目标目录为 <当前工作目录>/pm3_files/,可在对话框中自行修改。整理后的目录结构:

-
pm3_files/
-├── hf-mf/
-│   ├── A991A280/
-│   │   ├── hf-mf-A991A280-dump.bin
-│   │   ├── hf-mf-A991A280-dump-001.bin
-│   │   ├── hf-mf-A991A280-key.bin
-│   │   └── hf-mf-A991A280-dump.json
-│   └── 3BA66BB9/
-│       ├── hf-mf-3BA66BB9-dump.bin
-│       └── hf-mf-3BA66BB9-key.bin
-└── lf-em/
-    └── 12345678/
-        └── lf-em-12345678-dump.bin
-
+ +

PM3 GUI

+

简体中文 (Simplified Chinese) | English | English quick start

+
+ Build + Release + Platforms + License +
+

PM3 GUI 是面向 Proxmark3 的 GPL-3.0 开源桌面图形客户端,提供常用 RFID/NFC 操作、转储文件管理和命令终端。

-

规则:按 <频段>-<卡类型>/<UID>/ 二级目录结构存放。已存在于目标位置的同名文件不会被覆盖。整理完成后自动重新扫描。

+

⚠️ Alpha 说明:当前版本仍在迭代,页面和行为可能调整。

-

自动触发时机:

+

请仅对自有或已明确授权的设备、卡片和系统执行操作,并遵守所在地适用法律。

+

界面语言

+

在“设置 → 语言”中选择“跟随系统”、“简体中文”或“English”。更改立即生效,无需重启或断开设备;不受支持的系统语言使用英语。语言设置独立保存,不影响现有 PM3 配置、草稿和设备数据。命令帮助说明也会切换语言;原始客户端输出、命令语法和导出格式保持不变。

+

仅提供简体中文和英语资源;系统的其他中文地区/书写变体使用现有简体中文资源。Flutter 的菜单、对话框等框架文案随应用语言变化,原生文件选择器仍使用操作系统语言。

+

语言保存失败时,当前会话仍使用所选语言,并在设置页显示错误;修复目录或权限问题后,再次选择语言可重试保存。损坏或不受支持的语言设置不会阻止界面启动。

+

翻译维护、新增语言、当前行为、PR #27 的回归覆盖与验证限制请参阅 Localization。全部文档的现状与历史说明见 文档索引;README.html 是本文件的 HTML 快照。

+

平台与范围

    -
  • 应用启动时首次扫描
  • -
  • 连接 PM3 成功时
  • -
  • PM3 输出中检测到 saved 关键字时(延迟 1 秒后自动刷新)
  • +
  • Windows x64 是 1.0 主平台。
  • +
  • Linux x64 是 1.0 正式支持平台。
  • +
  • Android、iOS、Web 和 macOS 不属于 1.0 范围。
  • +
  • 本项目不打包 Proxmark3 固件或客户端;设备通信使用用户安装的官方 Proxmark3 client。Windows 优先使用官方发行包根目录的 pm3.bat,Linux 使用 proxmark3。
-
-

2. 💻 终端(terminal_page)

-

全功能 PM3 交互式终端,直接透传 stdin/stdout。

- - - - - - - - - - - - - - - - - - - - - - - - - -
功能说明
命令输入底部输入框,回车发送
输出显示带滚动的终端输出区域,自动 ANSI 色彩剥离
命令历史方向键 ↑ 回溯上一条命令
快捷命令按钮组:高频搜索 · 低频搜索 · 14A 信息 · 硬件版本 · 天线调谐 · 低频读取 · 自动破解
-
-

3. 📁 Dump 查看 / 编辑(dump_viewer_page)

-

核心离线功能,无需连接硬件即可使用。包含 4 个选项卡(无论是否已加载文件均可见):

-

选项卡 ①:扇区视图

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
功能说明
扇区列表左侧扇区编号列表(Sec 0 ~ Sec N),点击切换
块数据 Hex每个块 16 字节 32 位十六进制显示
高亮规则制造商块(Block 0)→ 特殊标记;尾块(Trailer)→ 密钥分段高亮
密钥分段Key A(前 6 字节)· 访问控制位(3+1 字节)· Key B(后 6 字节)
访问控制解码中文说明各块的读/写/递增/递减权限
-

选项卡 ②:密钥 / 编辑

- - - - - - - - - - - - - - - - - - - - - - - - - -
功能说明
密钥表格按扇区列出 Key A / Key B,带 TextEditingController 可直接编辑
块数据编辑选择扇区后逐块编辑 hex 数据
复制密钥支持仅复制 Key A / Key B / 全部密钥到剪贴板
应用编辑将编辑器中的修改写回内存中的 MifareCard 模型
-

选项卡 ③:深度分析

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
功能说明
制造商块解码UID / SAK / ATQA / 制造商识别
默认密钥检测对照 20+ 已知密钥(NXP / MAD / NDEF / VIGIK / HID 等)标记
密钥模式分析统计各扇区密钥使用模式,识别全 FF / 全 0 / 自定义
MAD 解析解析 MAD(Mifare Application Directory)扇区,显示 AID 描述
值块检测识别符合 Mifare 值块格式的数据块,显示解码值
数据 ASCII非零数据块的可打印 ASCII 预览
-

选项卡 ④:回写 / 清空(CUID)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
功能说明
整卡回写将内存中的 dump 数据逐块写入 CUID 兼容卡
扇区回写选择单个扇区写入
整卡清空将所有数据块清零(0x00),带确认对话框
认证密钥选择Key A / Key B 切换写入认证密钥
跳过 Block 0可选跳过制造商块(Block 0)
写入尾块可选是否写入 Trailer 块(密钥+访问控制位)
进度显示逐块写入进度条,支持取消操作
-

🔑 智能文件合并策略

-

打开文件时的行为因文件类型不同而异:

- - - - - - - - - - - - - - - - - - - - - -
打开的文件类型行为
Key 文件(.dic / 文件名含 -key / .bin 大小匹配密钥布局)仅合并密钥到当前卡片的 sectorKeys,块数据完全不动
Dump 文件(当前已有密钥时)弹出对话框:保留当前密钥 或 用转储文件覆盖
Dump 文件(首次加载)正常加载全部数据(块 + 密钥)
-
-

这解决了常见工作流问题:先用 hf mf autopwn 获取密钥文件,再加载 dump 查看数据但不想丢失已破解的密钥。

-
-

Key 文件检测规则(三重判定):

-
    -
  1. 扩展名为 .dic → 字典文件
  2. -
  3. 文件名包含 -key → PM3 标准密钥文件名
  4. -
  5. .bin 文件大小为 60 / 192 / 384 / 480 字节 → 对应 MINI / 1K / 2K / 4K 密钥布局
  6. -
-

PM3 二进制密钥文件格式:

-
[KeyA_sec0][KeyA_sec1]...[KeyA_secN][KeyB_sec0][KeyB_sec1]...[KeyB_secN]
- ← sectorCount × 6 字节 →           ← sectorCount × 6 字节 →
+

CI 工作流包含 Windows 和 Linux x64 桌面路径。当前有限实机证据来自 Windows x64:使用用户提供、对应 RRG commit da509461 的发行包和 PM3EASY512K(客户端识别为 PM3 GENERIC / AT91SAM7S512 / 512K),已通过根目录 pm3.bat 完成客户端连接、只读 hw version 和断开。2026-09-05 补充通过 Mifare 页面“检测卡片”到真实客户端与设备的验证:一次点击发送一次命令,页面显示 ATQA/SAK;客户端报告可能类型为 MIFARE Classic 1K。另已验证 6 秒静默命令和超过 10 秒的搜索输出。该结果不能外推为 Linux、其他设备、写卡、擦除或完整固件交互已经验证。

+

下载与发布

+
    +
  • 已发布版本及 Windows/Linux x64 压缩包见 GitHub Releases。本地化变更属于 PR #27 和下文的 Unreleased 开发内容;历史版本压缩包不保证包含这些变更。
  • +
  • main 分支提交和 Pull Request 的 CI 在两个平台生成并检查本地化资源、构建 release;静态分析和完整测试在 Linux job 执行。外部 PR 的首次运行可能需要上游维护者批准。
  • +
  • 推送形如 v0.0.4 的标签后,同一工作流会打包两个平台并创建带自动发行说明的 GitHub Release。
  • +
  • Release 仅包含 PM3 GUI;Proxmark3 client 和固件请从 RfidResearchGroup/proxmark3 获取。
  • +
+

核心能力

+
    +
  • 通过 Pm3Backend 契约和 DesktopCliBackend 驱动官方 Proxmark3 client。
  • +
  • 终端支持完整命令透传、历史记录和输出查看。
  • +
  • 专用 GUI 优先覆盖高频工作流;低频、诊断及新增命令保留在 Terminal。
  • +
  • 功能页子终端持续接收输出,切换页面即停止;再次执行命令时开始新的捕获。执行中仍显示已收到的内容,总终端独立保留输出历史。
  • +
  • 支持 EML、BIN/DUMP、JSON 和密钥文件的查看、分析与导出。
  • +
  • 连接、命令输出和进程退出具备可诊断的本地生命周期管理。
  • +
+
Flutter UI / Feature
+        │
+        ▼
+  Pm3Controller → Pm3Backend → DesktopCliBackend
+                                      │
+                                      ▼
+                                  Pm3Process
+                                      │
+                                      ▼
+                         official proxmark3 client
 
-
-

⚠️ 注意:PM3 密钥文件采用顺序布局(先全部 Key A,再全部 Key B),不是交错布局。

-
-

导出格式

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
格式扩展名说明
EML.eml文本模拟器格式(每行一个块,32 hex chars)
二进制转储.bin原始 1:1 二进制转储
JSON.jsonPM3 Jansson 格式(含 UID/ATQA/SAK 元数据)
二进制密钥.key.binPM3 标准密钥二进制格式
密钥字典.dic一行一个密钥,带注释头
密钥文本.keys.txt按扇区列出 Key A / Key B
-
-

4. 🔀 Dump 对比(dump_compare_page)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
功能说明
双栏加载分别选择文件 A 和文件 B
并排显示按块对齐显示两份转储数据
字节级高亮不同的字节标红,相同的保持原色
差异统计显示不同块数 / 不同字节数 / 总块数
仅显示差异过滤开关,可只展示有差异的块
-
-

5. 🔐 Mifare 高频操作(mifare_page)

-

需连接 PM3 硬件。包含 4 个选项卡:

- - - - - - - - - - - - - - - - - - - - - - - - - -
选项卡功能
快捷操作检测卡片 · 卡片信息 · Autopwn 自动破解 · 转储 · 恢复 · 嗅探
密钥攻击检查默认密钥 · Nested 攻击 · Static Nested · Hardnested · Darkside 攻击
读写块单块读取 · 数据写入(选择扇区/块/密钥类型/密钥值/数据)
魔术卡Block 0 读取 · 整卡擦除(带确认) · 模拟器加载 · 支持 Gen1A / Gen2(CUID) / Gen3
-

支持卡类型切换:MINI / 1K / 2K / 4K

-
-

6. 📡 低频操作(lf_page)

-

需连接 PM3 硬件。包含 3 个选项卡:

- - - - - - - - - - - - - - - - - - - - - -
选项卡功能
通用低频搜索 · 读取 · 嗅探 · 天线调谐
EM4x05读取 EM410x ID · 克隆到 T55xx(含 hex 格式验证)
T55xx检测 · 信息 · 转储 · 块 0-7 逐块读写
-
-

7. ⚙️ 设置(settings_page)

- - - - - - - - - - - - - - - - - - - - - - - - - -
功能说明
主题切换深色 莫兰迪 / 浅色 莫兰迪
PM3 路径独立的路径配置入口
关于信息平台 / 架构 / 支持格式列表
维护操作清除终端 · 查询硬件版本 · 天线调谐
-
-

📂 项目结构

-
pm3gui/
-├── lib/
-│   ├── main.dart                  # 入口,Provider 注入,主题切换
-│   ├── models/
-│   │   ├── mifare_card.dart       # MifareCard / CardType / SectorKey 数据模型
-│   │   ├── access_bits.dart       # 访问控制位 编码/解码
-│   │   └── dump_analysis.dart     # 深度分析引擎(MAD/值块/默认密钥/模式)
-│   ├── parsers/
-│   │   ├── dump_parser.dart       # 统一解析入口(自动识别格式)
-│   │   ├── eml_parser.dart        # .eml 文本格式
-│   │   ├── bin_parser.dart        # .bin / .dump 二进制格式 + 密钥文件检测
-│   │   ├── json_dump_parser.dart  # PM3 Jansson JSON 格式
-│   │   ├── key_parser.dart        # 密钥文件解析 / 导出(.bin / .dic / .txt)
-│   │   └── output_parser.dart     # PM3 stdout 正则解析器(UID/密钥/块等)
-│   ├── services/
-│   │   ├── pm3_process.dart       # 进程管理(连接/发送/流读取)
-│   │   ├── pm3_commands.dart      # 命令模板(HF/LF/HW/魔术卡/CUID)
-│   │   ├── file_collector.dart    # PM3 文件自动收集 / 归类服务
-│   │   ├── dump_converter.dart    # 转储格式转换
-│   │   └── file_dialog_service.dart # 跨平台文件选择对话框
-│   ├── state/
-│   │   └── app_state.dart         # 全局状态(连接/终端/卡片/文件收集/写入进度)
-│   └── ui/
-│       ├── theme.dart             # 莫兰迪色系 Material 3 深色/浅色主题
-│       ├── home_page.dart         # 侧边栏导航(7 页 + IndexedStack 状态保持)
-│       └── pages/
-│           ├── connection_page.dart   # 连接 / 仪表盘(双栏布局)
-│           ├── terminal_page.dart     # 交互终端
-│           ├── dump_viewer_page.dart  # Dump 查看 / 编辑 / 分析 / 回写
-│           ├── dump_compare_page.dart # Dump 对比(双栏差异高亮)
-│           ├── mifare_page.dart       # Mifare 操作页
-│           ├── lf_page.dart           # 低频操作页
-│           └── settings_page.dart     # 设置页
-├── docs/
-│   └── pm3_commands.yaml          # PM3 命令覆盖率映射(43 项 GUI 封装)
-├── android/                       # 已配置 USB OTG 权限
-├── linux/
-├── windows/
-├── test/
-└── pubspec.yaml
+

Pm3Process 是 DesktopCliBackend 使用的纯 Dart 进程适配器,也供独立 Dart 帮助同步工具复用;Widget 和 Feature 不直接使用 dart:io Process。GUI 通过后端注入本地化消息,独立工具不加载 Flutter 的 dart:ui,并保留中文诊断默认值。

+

管道模式使用 RRG 客户端的 rem 命令生成顺序完成标记(已验证客户端 da509461),不会因短暂停顿或表格分隔线提前结束。默认持续等待,长命令执行期间可从连接页断开会话;切页只停止子终端监听,不终止命令。显式指定超时的调用在超时后断开会话,避免未完成命令与后续输出混合。

+

终端补全按界面语言选择 docs/pm3_commands_help.zh.yaml 或 docs/pm3_commands_help.yaml,再合并 docs/pm3_commands_client.json 客户端快照。快照补充的条目保留客户端原始说明,不进行实时机器翻译。更新客户端后,可在项目根目录执行以下只读帮助同步;Windows 使用发行包根目录入口,原生客户端在路径后增加端口参数:

+
dart run tools\sync_command_catalog.dart 'C:\path\to\pm3.bat'
 
-

🎨 莫兰迪色系

-

界面采用低饱和度莫兰迪色彩体系,长时间使用不易视觉疲劳:

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
色彩色值用途
莫兰迪蓝#7E9AAB主色调、选中状态、链接
莫兰迪绿#8FA9A0成功、连接状态、HF 标识
莫兰迪玫瑰#BFA2A2断开、删除、警告操作
莫兰迪暖灰#A89F91次要信息
莫兰迪薰衣草#9B96B4密钥文件、装饰色
柔和红#C47D7D错误信息
柔和绿#8EAD8E成功状态
柔和黄#C9B07F警告提示
-

🚀 快速上手

-

前置条件

+

Linux 示例为 dart run tools/sync_command_catalog.dart /usr/bin/proxmark3 /dev/ttyACM0。先运行 flutter pub get 准备包配置,再使用 Flutter 附带的 dart 执行;这不是 flutter run 入口。工具需要已安装的客户端及相应设备连接,不读取 GUI 保存的语言设置。

+

同步成功后更新 docs/pm3_commands_client.json,然后重新构建 GUI。无参数调用显示用法并返回 2;连接/采集失败返回 1;成功返回 0。快照反映采集时客户端与已连接设备可见的命令,不代表每项功能已有专用页面或完成实卡验证。

+

配置与隐私

+

连接页负责选择并持久化串口/设备端口;设置页管理客户端入口路径、启动参数和可选工作目录。启动参数按“一行一个参数”编辑,空格、引号和反斜杠均按原字符串保留。Windows 请优先选择官方发行包根目录的 pm3.bat,不要绕过发行包初始化流程直接选择内部客户端。PM3 GUI 会通过 cmd.exe /d /c call 启动该脚本并保留标准输入输出交互;脚本自行完成环境初始化、启动内部客户端并自动检测设备,因此 GUI 保存的端口和额外启动参数不会传给 .bat/.cmd。原生 proxmark3.exe 和 Linux proxmark3 仍按所选端口启动。

+

PM3EASY512K 等已由官方 RfidResearchGroup/proxmark3 客户端支持的设备沿用同一入口,不需要 GUI 维护独立型号驱动或白名单。相关设置使用同目录临时文件原子替换,并在界面显示保存中或失败状态;应用关闭会等待最后一次设置写入。设置文件位于:

+
<应用支持目录>/pm3_settings.json
+<应用支持目录>/language.json
+
+

两者分别保存客户端配置和语言偏好,互不覆盖;language.json 中的 language 为 "en"、"zh" 或 null(跟随系统)。应用支持目录由 path_provider 的 getApplicationSupportDirectory() 按当前操作系统和用户账户决定。它不是项目目录,实际绝对路径请以系统配置为准。语言目录查找/读写异常会显示为设置错误;不会回退到当前工作目录静默保存。

+

每次成功连接会在同一应用支持目录下创建本地 Session:

+
<应用支持目录>/sessions/YYYY-MM-DD_HHmmss_SSS/
+├─ session.json       # 端口、客户端路径、开始/结束时间
+├─ terminal.log       # 客户端输出
+└─ commands.jsonl     # 发出的命令(每行一条 JSON)
+
+

这些文件仅写入本机,不会由 PM3 GUI 上传。终端输出和命令参数可能包含 UID、设备信息或其他敏感数据;存储错误详情也可能包含个人目录路径。请保护应用支持目录,并在共享日志或错误报告前自行检查、去敏。

+

安装与运行

+

环境要求

    -
  • Flutter 3.24+
  • -
  • Proxmark3 命令行客户端 (pm3) — Iceman 分支
  • +
  • Flutter stable(本次验证使用 3.47.7)
  • +
  • 随 Flutter 提供的 Dart SDK(本次验证使用 3.13.5)
  • +
  • 官方 Proxmark3 client:Windows 发行包根目录 pm3.bat,或 Linux proxmark3
-

编译与运行

-
# 克隆仓库
-git clone https://github.com/AKCX2002/pm3gui.git
-cd pm3gui
-
-# 安装依赖
+

运行

+
git clone https://github.com/Babel36acl/Promark3-GUI.git
+cd Promark3-GUI
 flutter pub get
-
-# Linux 桌面运行
-flutter run -d linux
-
-# 编译发布版
-flutter build linux          # → build/linux/x64/release/bundle/
-flutter build apk            # → build/app/outputs/flutter-apk/
-flutter build windows        # → build/windows/x64/runner/Release/
+flutter run -d windows
 
-

Android(USB OTG)

-

应用已预配置 USB 主机权限和 PM3 设备过滤器。 -通过 USB OTG 线缆连接 Proxmark3,应用会自动检测设备。

-
-

⚠️ Android 运行需要为 ARM 架构交叉编译 PM3 原生程序,这属于单独的编译步骤。

-
-

📋 支持的卡片协议

-

命令覆盖率

-

PM3 GUI 封装了 43 个一级命令模板(Pm3Commands 类),覆盖以下协议族:

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
协议GUI 命令数原始 CLI 命令数覆盖内容
HF 14443-A29搜索、信息
HF Mifare Classic2557全链路:检测→攻击→读写→模拟器→Gen1A/Gen2(CUID)/Gen3
LF 通用46搜索、读取、嗅探、调谐
LF EM4x215EM410x 读取/克隆
LF T55xx512检测/信息/转储/块读写
HW 硬件314版本/状态/调谐
-
-

完整命令映射参见 docs/pm3_commands.yaml。 -未覆盖的命令仍可通过「终端」页面直接输入执行。

-
-

支持的 Dump/Key 格式

+

Linux x64 可使用 flutter run -d linux。请先按 Flutter 官方文档安装对应桌面工具链。

+

若上游 main 或所选 Release 尚未包含 PR #27,可在上述仓库中取出 PR 分支试用,再执行 flutter pub get:

+
git fetch https://github.com/Babel36acl/Promark3-GUI.git pull/27/head:pr-27
+git switch pr-27
+
+

构建

+
flutter build windows
+flutter build linux
+
+

文件格式

+

转储查看器的“命令数据”页可直接编辑命令采集结果,无需先生成 dump 文件。功能页和总终端发送的 MIFARE chk/fchk/nested/staticnested/autopwn 结果按实际扇区号收集;hardnested 需要命令明确包含 --tblk 和 --ta/--tb,才会归属其恢复结果。rdbl/rdsc/cgetblk/cgetsc/egetblk/eview/view 的块表按实际块号收集。总终端输出保持原样。

+

换卡前通过“新卡 / 容量”清空草稿并选择容量;识别到不同 UID 时也会清空旧数据。缺失块和密钥保留为未知,普通认证读取的尾块不会把隐藏密钥当成零密钥。“密钥 → 尾块”和“尾块 → 密钥”显式同步两者。草稿 JSON 可保存、重新打开未完成数据;完整数据才能导出 BIN/EML,完整扇区密钥才能导出密钥 BIN,已知密钥可单独导出字典。没有转储时打开密钥 BIN/密钥文本会进入此页,不会生成虚假的全零转储。导入草稿或卡片数据时,块与密钥的校验使用当前所选语言;导入失败不改变原有草稿、容量、UID 和密钥。

+

文件收集扫描配置的工作目录、Windows 发行包 client 目录、客户端 .proxmark3/preferences.json 中的保存/转储路径,以及 GUI 默认 pm3_files 归类目录。扫描异常在文件收集标题旁显示,其他可访问目录仍会继续扫描。

- + - - - + + - + - - - + + - - - - + + + - + - - - - - - - - - - + + - - - + + - - - + + - -
格式类型 扩展名读取导出说明读取导出
EMLEML 转储 .eml✅✅文本模拟器格式✅✅
二进制转储.bin .dump✅✅原始 1:1 二进制.bin、.dump✅✅
JSONJSON 转储 .json✅✅PM3 Jansson 格式
二进制密钥.bin(按大小检测)✅✅PM3 标准密钥格式✅✅
密钥字典 .dic✅✅一行一个密钥✅✅
密钥文本 .keys.txt—✅按扇区列出✅✅
-

🔧 配置项

- - - - - - - - - - - - - - - - - - - - - - - - - - - + + + +
设置默认值说明
PM3 路径./pm3proxmark3 客户端程序路径
主题深色(莫兰迪)可在设置页或侧边栏切换
文件收集目录PM3 目录 + $HOME + CWD自动扫描的搜索范围
归类整理目录<CWD>/pm3_files/「归类整理」的默认目标(可自定义)命令数据草稿.json(专用草稿格式)✅(命令数据页)✅
-

📝 开源协议

-

本项目采用 GPL-3.0 协议开源,与 Proxmark3 项目保持一致。

-

🙏 致谢

- +

仓库结构

+
lib/core/pm3/       # Pm3Backend 契约、命令、事件、结果和 Session 模型
+lib/backend/         # DesktopCliBackend、Pm3Process 和 Mock Backend
+lib/services/        # 设置、Session、文件和命令服务
+lib/state/           # Provider 状态管理
+lib/ui/              # 桌面页面和组件
+lib/l10n/            # ARB 资源、语言偏好状态与 Flutter 本地化适配
+docs/                # 命令映射、规格和架构路线
+.github/workflows/   # Windows/Linux 构建与标签发布
+
+

开发与验证

+

提交前可运行:

+
flutter gen-l10n
+dart run tools\check_localizations.dart
+flutter analyze --no-pub --no-fatal-infos
+flutter test --no-pub
+flutter build windows --debug --no-pub
+
+

对本次修改的 Dart 文件单独运行 dart format,不要为了整理历史格式而重排无关文件。上述命令覆盖本地化生成/完整性、静态分析、Dart/Flutter 测试和 Windows debug 构建;不覆盖 Linux runner 构建,也不替代真实 PM3 硬件验收。CI 的 Linux x64 构建路径以 .github/workflows/build.yml 为准。

+

参与贡献与安全

+

欢迎提交 Issue 和 Pull Request。开始前请阅读 CONTRIBUTING.md;安全漏洞请按 SECURITY.md 私下报告,不要在公开 Issue 中披露敏感细节。

+

后续路线

+

下一阶段将建立 Command Registry 和 Parser Registry,统一命令元数据、参数及输出解析。专用 GUI 继续优先覆盖高频工作流,低频或新增命令继续通过 Terminal 使用。

+

许可证

+

本项目使用 GPL-3.0 许可证,详见 LICENSE。

+

Proxmark3 名称及其客户端、固件归对应上游项目及贡献者所有;本仓库不分发或重新授权这些上游组件。

diff --git a/README.md b/README.md index ec2f119..92cd45c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # PM3 GUI +**简体中文 (Simplified Chinese)** | [English](README.en.md) | [English quick start](README.quickstart.en.md) +
- Build - Release + Build + Release Platforms License
@@ -13,6 +15,16 @@ PM3 GUI 是面向 Proxmark3 的 GPL-3.0 开源桌面图形客户端,提供常 请仅对自有或已明确授权的设备、卡片和系统执行操作,并遵守所在地适用法律。 +## 界面语言 + +在“设置 → 语言”中选择“跟随系统”、“简体中文”或“English”。更改立即生效,无需重启或断开设备;不受支持的系统语言使用英语。语言设置独立保存,不影响现有 PM3 配置、草稿和设备数据。命令帮助说明也会切换语言;原始客户端输出、命令语法和导出格式保持不变。 + +仅提供简体中文和英语资源;系统的其他中文地区/书写变体使用现有简体中文资源。Flutter 的菜单、对话框等框架文案随应用语言变化,原生文件选择器仍使用操作系统语言。 + +语言保存失败时,当前会话仍使用所选语言,并在设置页显示错误;修复目录或权限问题后,再次选择语言可重试保存。损坏或不受支持的语言设置不会阻止界面启动。 + +翻译维护、新增语言、当前行为、PR #27 的回归覆盖与验证限制请参阅 [Localization](docs/localization.md)。全部文档的现状与历史说明见 [文档索引](docs/README.md);[README.html](README.html) 是本文件的 HTML 快照。 + ## 平台与范围 - Windows x64 是 1.0 主平台。 @@ -24,8 +36,8 @@ CI 工作流包含 Windows 和 Linux x64 桌面路径。当前有限实机证据 ## 下载与发布 -- 已发布版本及 Windows/Linux x64 压缩包见 [GitHub Releases](https://github.com/AKCX2002/Promark3-GUI/releases)。 -- `main` 分支提交和 Pull Request 会执行静态分析、测试及双平台 release 构建。 +- 已发布版本及 Windows/Linux x64 压缩包见 [GitHub Releases](https://github.com/Babel36acl/Promark3-GUI/releases)。本地化变更属于 [PR #27](https://github.com/Babel36acl/Promark3-GUI/pull/27) 和下文的 Unreleased 开发内容;历史版本压缩包不保证包含这些变更。 +- `main` 分支提交和 Pull Request 的 CI 在两个平台生成并检查本地化资源、构建 release;静态分析和完整测试在 Linux job 执行。外部 PR 的首次运行可能需要上游维护者批准。 - 推送形如 `v0.0.4` 的标签后,同一工作流会打包两个平台并创建带自动发行说明的 GitHub Release。 - Release 仅包含 PM3 GUI;Proxmark3 client 和固件请从 [RfidResearchGroup/proxmark3](https://github.com/RfidResearchGroup/proxmark3) 获取。 @@ -51,17 +63,19 @@ Flutter UI / Feature official proxmark3 client ``` -`Pm3Process` 是 `DesktopCliBackend` 的内部适配器,Widget 和 Feature 不直接使用 `dart:io Process`。 +`Pm3Process` 是 `DesktopCliBackend` 使用的纯 Dart 进程适配器,也供独立 Dart 帮助同步工具复用;Widget 和 Feature 不直接使用 `dart:io Process`。GUI 通过后端注入本地化消息,独立工具不加载 Flutter 的 `dart:ui`,并保留中文诊断默认值。 管道模式使用 RRG 客户端的 `rem` 命令生成顺序完成标记(已验证客户端 `da509461`),不会因短暂停顿或表格分隔线提前结束。默认持续等待,长命令执行期间可从连接页断开会话;切页只停止子终端监听,不终止命令。显式指定超时的调用在超时后断开会话,避免未完成命令与后续输出混合。 -终端补全合并现有中文帮助与 `docs/pm3_commands_client.json` 客户端快照。更新客户端后,可在项目根目录执行以下只读帮助同步;Windows 使用发行包根目录入口,原生客户端在路径后增加端口参数: +终端补全按界面语言选择 `docs/pm3_commands_help.zh.yaml` 或 `docs/pm3_commands_help.yaml`,再合并 `docs/pm3_commands_client.json` 客户端快照。快照补充的条目保留客户端原始说明,不进行实时机器翻译。更新客户端后,可在项目根目录执行以下只读帮助同步;Windows 使用发行包根目录入口,原生客户端在路径后增加端口参数: ```powershell -dart run tools/sync_command_catalog.dart 'C:/path/to/pm3.bat' +dart run tools\sync_command_catalog.dart 'C:\path\to\pm3.bat' ``` -同步完成后重新构建 GUI。快照反映采集时客户端与已连接设备可见的命令,不代表每项功能已有专用页面或完成实卡验证。 +Linux 示例为 `dart run tools/sync_command_catalog.dart /usr/bin/proxmark3 /dev/ttyACM0`。先运行 `flutter pub get` 准备包配置,再使用 Flutter 附带的 `dart` 执行;这不是 `flutter run` 入口。工具需要已安装的客户端及相应设备连接,不读取 GUI 保存的语言设置。 + +同步成功后更新 `docs/pm3_commands_client.json`,然后重新构建 GUI。无参数调用显示用法并返回 2;连接/采集失败返回 1;成功返回 0。快照反映采集时客户端与已连接设备可见的命令,不代表每项功能已有专用页面或完成实卡验证。 ## 配置与隐私 @@ -71,9 +85,10 @@ PM3EASY512K 等已由官方 [RfidResearchGroup/proxmark3](https://github.com/Rfi ```text <应用支持目录>/pm3_settings.json +<应用支持目录>/language.json ``` -应用支持目录由 `path_provider` 的 `getApplicationSupportDirectory()` 按当前操作系统和用户账户决定。它不是项目目录,实际绝对路径请以系统配置为准。 +两者分别保存客户端配置和语言偏好,互不覆盖;`language.json` 中的 `language` 为 `"en"`、`"zh"` 或 `null`(跟随系统)。应用支持目录由 `path_provider` 的 `getApplicationSupportDirectory()` 按当前操作系统和用户账户决定。它不是项目目录,实际绝对路径请以系统配置为准。语言目录查找/读写异常会显示为设置错误;不会回退到当前工作目录静默保存。 每次成功连接会在同一应用支持目录下创建本地 Session: @@ -84,20 +99,20 @@ PM3EASY512K 等已由官方 [RfidResearchGroup/proxmark3](https://github.com/Rfi └─ commands.jsonl # 发出的命令(每行一条 JSON) ``` -这些文件仅写入本机,不会由 PM3 GUI 上传。终端输出和命令参数可能包含 UID、设备信息或其他敏感数据,请保护应用支持目录,并在共享日志前自行检查内容。 +这些文件仅写入本机,不会由 PM3 GUI 上传。终端输出和命令参数可能包含 UID、设备信息或其他敏感数据;存储错误详情也可能包含个人目录路径。请保护应用支持目录,并在共享日志或错误报告前自行检查、去敏。 ## 安装与运行 ### 环境要求 -- Flutter 3.27+ -- Dart 3.6+ +- Flutter stable(本次验证使用 3.47.7) +- 随 Flutter 提供的 Dart SDK(本次验证使用 3.13.5) - 官方 Proxmark3 client:Windows 发行包根目录 `pm3.bat`,或 Linux `proxmark3` ### 运行 ```bash -git clone https://github.com/AKCX2002/Promark3-GUI.git +git clone https://github.com/Babel36acl/Promark3-GUI.git cd Promark3-GUI flutter pub get flutter run -d windows @@ -105,6 +120,13 @@ flutter run -d windows Linux x64 可使用 `flutter run -d linux`。请先按 Flutter 官方文档安装对应桌面工具链。 +若上游 `main` 或所选 Release 尚未包含 PR #27,可在上述仓库中取出 PR 分支试用,再执行 `flutter pub get`: + +```powershell +git fetch https://github.com/Babel36acl/Promark3-GUI.git pull/27/head:pr-27 +git switch pr-27 +``` + ### 构建 ```bash @@ -116,7 +138,7 @@ flutter build linux 转储查看器的“命令数据”页可直接编辑命令采集结果,无需先生成 dump 文件。功能页和总终端发送的 MIFARE `chk/fchk/nested/staticnested/autopwn` 结果按实际扇区号收集;`hardnested` 需要命令明确包含 `--tblk` 和 `--ta/--tb`,才会归属其恢复结果。`rdbl/rdsc/cgetblk/cgetsc/egetblk/eview/view` 的块表按实际块号收集。总终端输出保持原样。 -换卡前通过“新卡 / 容量”清空草稿并选择容量;识别到不同 UID 时也会清空旧数据。缺失块和密钥保留为未知,普通认证读取的尾块不会把隐藏密钥当成零密钥。“密钥 → 尾块”和“尾块 → 密钥”显式同步两者。草稿 JSON 可保存、重新打开未完成数据;完整数据才能导出 BIN/EML,完整扇区密钥才能导出密钥 BIN,已知密钥可单独导出字典。没有转储时打开密钥 BIN/密钥文本会进入此页,不会生成虚假的全零转储。 +换卡前通过“新卡 / 容量”清空草稿并选择容量;识别到不同 UID 时也会清空旧数据。缺失块和密钥保留为未知,普通认证读取的尾块不会把隐藏密钥当成零密钥。“密钥 → 尾块”和“尾块 → 密钥”显式同步两者。草稿 JSON 可保存、重新打开未完成数据;完整数据才能导出 BIN/EML,完整扇区密钥才能导出密钥 BIN,已知密钥可单独导出字典。没有转储时打开密钥 BIN/密钥文本会进入此页,不会生成虚假的全零转储。导入草稿或卡片数据时,块与密钥的校验使用当前所选语言;导入失败不改变原有草稿、容量、UID 和密钥。 文件收集扫描配置的工作目录、Windows 发行包 `client` 目录、客户端 `.proxmark3/preferences.json` 中的保存/转储路径,以及 GUI 默认 `pm3_files` 归类目录。扫描异常在文件收集标题旁显示,其他可访问目录仍会继续扫描。 @@ -137,6 +159,7 @@ lib/backend/ # DesktopCliBackend、Pm3Process 和 Mock Backend lib/services/ # 设置、Session、文件和命令服务 lib/state/ # Provider 状态管理 lib/ui/ # 桌面页面和组件 +lib/l10n/ # ARB 资源、语言偏好状态与 Flutter 本地化适配 docs/ # 命令映射、规格和架构路线 .github/workflows/ # Windows/Linux 构建与标签发布 ``` @@ -145,14 +168,15 @@ docs/ # 命令映射、规格和架构路线 提交前可运行: -```bash -dart format --output=none --set-exit-if-changed lib test +```powershell +flutter gen-l10n +dart run tools\check_localizations.dart flutter analyze --no-pub --no-fatal-infos flutter test --no-pub flutter build windows --debug --no-pub ``` -上述命令覆盖 Windows 上的格式、静态分析、Dart/Flutter 测试和 Windows debug 构建;不覆盖 Linux runner 构建,也不替代真实 PM3 硬件验收。CI 的 Linux x64 构建路径以 `.github/workflows/build.yml` 为准。 +对本次修改的 Dart 文件单独运行 `dart format`,不要为了整理历史格式而重排无关文件。上述命令覆盖本地化生成/完整性、静态分析、Dart/Flutter 测试和 Windows debug 构建;不覆盖 Linux runner 构建,也不替代真实 PM3 硬件验收。CI 的 Linux x64 构建路径以 `.github/workflows/build.yml` 为准。 ## 参与贡献与安全 diff --git a/README.quickstart.en.md b/README.quickstart.en.md new file mode 100644 index 0000000..464dc33 --- /dev/null +++ b/README.quickstart.en.md @@ -0,0 +1,138 @@ +# PM3 GUI + +[简体中文 (Simplified Chinese)](README.md) | [English](README.en.md) | **English quick start** + +A GPL-3.0 Flutter desktop interface for the official Proxmark3 client. +The interface is available in Simplified Chinese and English. +Windows x64 and Linux x64 are supported desktop targets; the Proxmark3 +client and firmware are installed separately. + +Only operate on devices, cards and systems you own or are explicitly +authorized to use. + +## Languages + +Choose **Settings → Language**: + +- **System default** follows the operating system's preferred languages. + Unsupported languages fall back to English. +- **简体中文** selects Simplified Chinese. +- **English** selects English. + +Changes apply immediately without restarting or disconnecting the device. +The selection is saved in `language.json` in the operating system's application +support directory, separately from the existing `pm3_settings.json`. +Read/write failures are shown in Settings. +The selected language still applies to the current session if saving fails; +after correcting directory availability or permissions, choose a language again +to retry. Corrupt or unsupported preferences are reported without preventing +the UI from starting. Other Chinese regional/script variants use the shipped +Simplified Chinese bundle; a separate Traditional Chinese bundle is not provided. + +The application uses Flutter's standard ARB / `gen-l10n` pipeline, typed +localization accessors, ICU placeholders/plurals and localized Material, +Cupertino and widget delegates. Both languages cover navigation, operation +pages, warnings, editing, dump analysis and application-generated messages. +Terminal command help switches languages as well. +Bundled descriptions follow the selected language, while entries supplied only +by the client snapshot retain their original descriptions. No live translation +service is used. + +Proxmark3 commands, protocol names, hexadecimal data, file-format identifiers, +exported machine-readable content and raw client output are intentionally +unchanged. Native operating system file dialogs follow the operating system's +language; their appearance is not controlled by Flutter. + +Draft/card imports validate blocks and keys in the selected language. A failed +import leaves the existing draft unchanged. The DESFire **Applications** tab and +the editor's **Apply** action deliberately use separate resources. +See the [localization guide](docs/localization.md) for current behavior, +regression coverage and validation limitations. + +## Development + +Use the stable Flutter SDK with its bundled Dart SDK and the Windows or Linux +desktop toolchain. This change was developed with Flutter 3.47.7 / Dart 3.13.5; +`flutter pub get` resolves the framework-pinned dependencies for your SDK. + +```powershell +git clone https://github.com/Babel36acl/Promark3-GUI.git +Set-Location .\Promark3-GUI +flutter pub get +flutter run -d windows +``` + +If upstream `main` or a historical release does not yet include PR #27, fetch +the PR branch before `flutter pub get`: + +```powershell +git fetch https://github.com/Babel36acl/Promark3-GUI.git pull/27/head:pr-27 +git switch pr-27 +``` + +For Linux, run `flutter run -d linux`. If Flutter is not in the current process's +PATH, invoke your installed SDK's `bin\flutter.bat` directly on Windows. + +On Windows, select `pm3.bat` in the **root of the official Proxmark3 distribution** +in the connection page or Settings. The script initializes its own environment +and detects devices. Saved GUI port/argument values are not passed to that +script. Native clients, including Linux `proxmark3`, still use the selected +port and arguments. Each launch argument is entered on a separate line; +spaces, quotes and backslashes are preserved. + +```powershell +flutter gen-l10n +dart run tools\check_localizations.dart +flutter analyze --no-pub --no-fatal-infos +flutter test --no-pub +flutter build windows --release --no-pub +``` + +`flutter pub get` and builds also generate localizations automatically. +Generated localization Dart files are ignored by Git; edit the ARB files, +not generated accessors. Run `dart format` on the Dart files you change rather +than reformatting unrelated historical code. + +CI generates and checks translations on Windows and Linux, and builds both +desktop targets. Analysis and the full test suite run in the Linux job. +First-time external contributions may require a maintainer to approve CI. + +## Refreshing command help + +After `flutter pub get`, use the SDK's standalone Dart VM from the project root: + +```powershell +dart run tools\sync_command_catalog.dart 'C:\path\to\pm3.bat' +``` + +On Linux, use +`dart run tools/sync_command_catalog.dart /usr/bin/proxmark3 /dev/ttyACM0`. +This requires an installed client and its device connection. It updates +`docs/pm3_commands_client.json` on success; rebuild the GUI to include the new +asset. The snapshot reflects the commands available to that client/device, +not a guarantee of GUI coverage or hardware validation. + +The tool imports a Flutter-free process layer and does not require a Flutter +engine or read the GUI language preference. Its built-in diagnostic defaults +remain Chinese; raw client output is unchanged. Exit codes are 0 for success, +1 for a connection/collection failure and 2 for invalid arguments. + +For translator guidance and adding another language, see +[Localization](docs/localization.md). The original [Chinese README](README.md) +describes file formats, session logging and device integration in more detail. +The [documentation index](docs/README.md) distinguishes current guidance from +historical specifications. [README.html](README.html) is generated from the +Chinese README. + +## Privacy and license + +The application does not upload device data or session logs. Local logs and +dump files can contain card identifiers, keys and other sensitive information; +storage-error diagnostics can also include local paths. Review and redact +these details before sharing logs or reports. + +See [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md) and +[LICENSE](LICENSE). +The Proxmark3 client and firmware belong to +[RfidResearchGroup/proxmark3](https://github.com/RfidResearchGroup/proxmark3) +and their respective contributors; they are not redistributed by this project. diff --git a/SECURITY.en.md b/SECURITY.en.md new file mode 100644 index 0000000..42976dc --- /dev/null +++ b/SECURITY.en.md @@ -0,0 +1,20 @@ +# Security Policy + +## Supported Scope + +Security fixes target the latest GitHub Release and the `main` branch. Fixes may not be backported separately to older versions; first confirm that the issue is still reproducible in the current code. + +## Reporting a Vulnerability + +If the repository's **Security** page offers **Report a vulnerability**, use that private channel first. If that option is unavailable, open an Issue without vulnerability details and ask the maintainers for a private contact method. + +Reports should include: + +- Affected version, commit and operating system; +- Reproduction steps, impact and a minimal proof; +- Known mitigations; +- Logs with credentials, card UIDs, device identifiers and personal paths removed. + +Do not post directly exploitable details or sensitive data in public Issues, discussions or Pull Requests. After receiving a report, maintainers will first confirm the impact and scope of handling, then coordinate a fix and disclosure timeline. + +This repository handles issues in PM3 GUI's own code, build process and packaging. For issues in the Proxmark3 client or firmware, also refer to the upstream security channels at [RfidResearchGroup/proxmark3](https://github.com/RfidResearchGroup/proxmark3). diff --git a/docs.en/README.md b/docs.en/README.md new file mode 100644 index 0000000..8529fe3 --- /dev/null +++ b/docs.en/README.md @@ -0,0 +1,96 @@ +# English Documentation Index + +[简体中文 (Simplified Chinese)](../README.md) | [English](../README.en.md) | [English quick start](../README.quickstart.en.md) + +This is the parallel English documentation tree. It mirrors all 14 Markdown +documents and the HTML checklist in the original `docs/` tree, retaining the +same relative names and subdirectories. The original Chinese content is kept +separate from these English counterparts; README headers link between the guides. + +This index distinguishes current guidance from preserved design/history +documents. PR #27 adds GUI internationalization; it does not certify additional +protocol coverage, platforms or hardware operations. + +## Current guidance + +| Document | Purpose | +|---|---| +| [Complete English README](../README.en.md) / [HTML snapshot](../README.en.html) | Full translation of the Chinese README: user setup, languages, client integration, formats, privacy and build steps | +| [English quick-start](../README.quickstart.en.md) | English setup, language selection and standalone command-help synchronization | +| [Localization](localization.md) | ARB workflow, adding languages, preference/error behavior and regression coverage | +| [Current implementation and roadmap](refactoring_plan.md) | Current backend/tool boundaries, supported desktop targets and future direction | +| [Contributing](../CONTRIBUTING.en.md) | Contributor workflow and localization requirements | +| [Changelog](../CHANGELOG.en.md) | Unreleased PR changes and released history | +| [Security policy](../SECURITY.en.md) | Private reporting and sanitizing diagnostics before sharing | + +The language resources remain `lib/l10n/app_en.arb` and `app_zh.arb` in the +repository. The completeness checker reports the current count (701 per +language for PR #27 as documented on 2026-10-22). Generated localization Dart +files are ignored by Git and regenerated by Flutter. + +The command catalogs are runtime/maintenance data, not narrative documentation, +and remain canonical in the original `docs/` directory: + +- [`pm3_commands.csv`](../docs/pm3_commands.csv) +- [`pm3_commands.yaml`](../docs/pm3_commands.yaml) +- [`pm3_commands_help.yaml`](../docs/pm3_commands_help.yaml), the English help + catalog +- [`pm3_commands_help.zh.yaml`](../docs/pm3_commands_help.zh.yaml), the + Simplified Chinese help catalog +- [`pm3_commands_client.json`](../docs/pm3_commands_client.json) + +These files are not duplicated or translated in this tree. Their contents are +not claims of tested feature coverage. Preserve command syntax and +machine-readable fields. The client snapshot can be refreshed by +`tools/sync_command_catalog.dart`; see the READMEs. + +## Historical design references + +These files retain the original design sketches, code examples and planning +checkboxes; they are not current implementation checklists. Their prose, +diagrams and illustrative UI messages have been translated into English. +That does not make historical examples current production implementations or +permit new hard-coded UI strings. Follow the current localization guide when +implementing changes. + +- [Project archive](../PROJECT_ARCHIVE.en.md) +- [Feature design](spec_new_features.md) +- [Coverage planning](complete_coverage_plan.md) +- [Original task breakdown](tasks_new_features.md) +- [Original development checklist](checklist_new_features.md) / + [HTML snapshot](checklist_new_features.html) +- [ISO 14443-B design](hf_14b_page_spec.md) +- [ISO 15693 design](hf_15_page_spec.md) +- [iCLASS design](hf_iclass_page_spec.md) +- [DESFire design](hf_mfdes_page_spec.md) +- [Ultralight/NTAG design](hf_mfu_page_spec.md) +- [HID Prox design](lf_hid_page_spec.md) +- [2026-09-04 desktop foundation plan](superpowers/plans/2026-09-04-desktop-foundation.md) + +## HTML snapshots + +The original `README.html` and `docs/checklist_new_features.html` are snapshots +of their same-named source Markdown documents. Their English counterparts are +`README.en.html` and `docs.en/checklist_new_features.html`. Do not maintain +an independent feature list in HTML. + +Regenerate only the English snapshots from the repository root with +PowerShell 7: + +```powershell +Set-Content -LiteralPath README.en.html -Encoding utf8 -Value ('' + "`n" + (ConvertFrom-Markdown -Path README.en.md).Html.TrimEnd()) +Set-Content -LiteralPath docs.en\checklist_new_features.html -Encoding utf8 -Value ('' + "`n" + (ConvertFrom-Markdown -Path docs.en\checklist_new_features.md).Html.TrimEnd()) +``` + +The renderer is built into PowerShell; no project or system package install is +required. Review the generated links, code blocks and tables with the Markdown +change. The commands above do not modify the original Chinese files. + +## Original-language counterparts + +The [original documentation index](../docs/README.md), +[Chinese README](../README.md) and [Chinese README HTML](../README.html) remain +available at their existing paths. The complete English README is +`README.en.md`, with its `README.en.html` snapshot. The shorter English guide is +`README.quickstart.en.md`. README headers provide links to the Chinese guide, +the full English guide and the English quick-start. diff --git a/docs.en/checklist_new_features.html b/docs.en/checklist_new_features.html new file mode 100644 index 0000000..96a762a --- /dev/null +++ b/docs.en/checklist_new_features.html @@ -0,0 +1,360 @@ + +

PM3GUI New Feature Page Development Checklist

+
+

The Phase checklists and their original checked states below are retained as design records from before PR #27. They do not represent current implementation progress, +SDK requirements, or supported platforms. For current development entry points, see the documentation index and +Localization; do not directly copy the hardcoded Chinese text in the old examples.

+
+

Localization Acceptance for Newly Added Pages (PR #27)

+
    +
  • English and Chinese ARB resources are complete; sentences use named placeholders, and different meanings such as nouns/actions use different keys.
  • +
  • Labels, tooltips, validation, confirmation/danger messages, empty states, and errors all use the current interface language.
  • +
  • Temporary validation models inherit the current language; failed imports do not alter the original data.
  • +
  • Raw PM3 commands, hexadecimal data, protocol identifiers, and persisted fields are not translated.
  • +
  • Standalone Dart tools do not introduce Flutter runtime dependencies, and the actual dart run entry points have regression coverage.
  • +
  • Failures to locate/save the language directory are visible on the settings page; initialization can still complete, and subsequent saves can be retried.
  • +
  • English and Chinese pages and dialogs have been specifically checked in default/smaller desktop windows and with text scaling.
  • +
  • After changes, run flutter gen-l10n, resource integrity checks, static analysis, and relevant tests.
  • +
  • Synchronize Markdown/HTML documentation, and clearly distinguish code tests, Linux builds, and real-device validation.
  • +
+

Pre-development Checks

+

Environment Preparation

+
    +
  • Flutter SDK 3.24+ is installed
  • +
  • Project dependencies are updated (flutter pub get)
  • +
  • Existing code has no compilation errors (flutter analyze)
  • +
  • The application starts and runs normally
  • +
+

Code Review

+ +
+

Phase 1: Basic Preparation

+

Task 1.1: Update the Navigation Structure

+

File: lib/ui/home_page.dart

+

Import Statements

+
    +
  • Add the hf_mfu_page.dart import
  • +
  • Add the hf_iclass_page.dart import
  • +
  • Add the hf_15_page.dart import
  • +
  • Add the hf_mfdes_page.dart import
  • +
  • Add the lf_hid_page.dart import
  • +
+

_pages List Update

+
    +
  • Add HfMfuPage() after MifarePage()
  • +
  • Add HfIclassPage()
  • +
  • Add Hf15Page()
  • +
  • Add LfHidPage() after LfPage()
  • +
+ +
    +
  • Add the NTAG navigation item (Icons.memory)
  • +
  • Add the iCLASS navigation item (Icons.badge)
  • +
  • Add the ISO15693 navigation item (Icons.contactless)
  • +
  • Add the HID navigation item (Icons.door_front_door_outlined)
  • +
+

Verification

+
    +
  • flutter analyze reports no errors
  • +
  • The application compiles normally
  • +
  • The sidebar displays all new navigation items
  • +
+
+

Phase 2: Mifare Ultralight/NTAG Page

+

File: lib/ui/pages/hf_mfu_page.dart

+

Basic Structure

+
    +
  • The file header contains a library; declaration
  • +
  • All necessary imports
  • +
  • HfMfuPage StatefulWidget class
  • +
  • _HfMfuPageState state class
  • +
  • SingleTickerProviderStateMixin mixin
  • +
  • TabController initialization and disposal
  • +
+

Tab Structure (5 Tabs)

+
    +
  • TabBar contains 5 Tabs
  • +
  • TabBarView contains 5 corresponding pages
  • +
  • Tabs: Info, Read/Write, NDEF, Emulator, Tools
  • +
+

"Info" Tab

+
    +
  • [Get Info] button -> HfMfuCmd.info()
  • +
  • [Dump Card] button -> HfMfuCmd.dump()
  • +
  • [Wipe Card] button -> HfMfuCmd.wipe() (with a confirmation dialog)
  • +
  • Result display area
  • +
+

"Read/Write" Tab

+
    +
  • Block number input (0-255)
  • +
  • [Read Block] button -> HfMfuCmd.rdbl()
  • +
  • Data input (16-character hex validation)
  • +
  • [Write Block] button -> HfMfuCmd.wrbl()
  • +
  • Password input (8-character hex)
  • +
  • [Authenticate] button -> HfMfuCmd.cauth()
  • +
+

"NDEF" Tab

+
    +
  • [Read NDEF] button -> HfMfuCmd.ndefRead()
  • +
  • NDEF result display area
  • +
+

"Emulator" Tab

+
    +
  • File selection functionality
  • +
  • [Load into Emulator] -> HfMfuCmd.eload()
  • +
  • [Save Emulator] -> HfMfuCmd.esave()
  • +
  • [View Emulator] -> HfMfuCmd.eview()
  • +
  • [Simulate Card] -> HfMfuCmd.sim()
  • +
+

"Tools" Tab

+
    +
  • [Generate Keys] -> HfMfuCmd.keygen()
  • +
  • UID input (optional)
  • +
  • [Generate Password] -> HfMfuCmd.pwdgen()
  • +
  • [Set UID] -> HfMfuCmd.setuid()
  • +
+

Common Functionality

+
    +
  • The _execute() method checks the connection state
  • +
  • Show a SnackBar message when disconnected
  • +
  • Use a monospace font to display hex data
  • +
  • Dangerous operations have confirmation dialogs
  • +
+
+

Phase 3: iCLASS/Picopass Page

+

File: lib/ui/pages/hf_iclass_page.dart

+

Basic Structure

+
    +
  • Correct imports
  • +
  • StatefulWidget structure
  • +
  • TabController (4 tabs)
  • +
+

Tab Structure (4 Tabs)

+
    +
  • Tabs: Info, Read/Write, Cracking, Emulator
  • +
+

"Info" Tab

+
    +
  • [Get Info] -> HfIclassCmd.info()
  • +
  • [Read Card] -> HfIclassCmd.reader()
  • +
  • [Dump Card] -> HfIclassCmd.dump()
  • +
+

"Read/Write" Tab

+
    +
  • Block number selector (0-31)
  • +
  • Key input (16-character hex, optional)
  • +
  • [Read Block] -> HfIclassCmd.rdbl()
  • +
  • Data input (16-character hex)
  • +
  • [Write Block] -> HfIclassCmd.wrbl()
  • +
+

"Cracking" Tab

+
    +
  • [Check Keys] -> HfIclassCmd.chk()
  • +
  • [Loclass Attack] -> HfIclassCmd.loclass()
  • +
  • [Sniff Communication] -> HfIclassCmd.sniff()
  • +
+

"Emulator" Tab

+
    +
  • [Load File] -> HfIclassCmd.eload()
  • +
  • [Save File] -> HfIclassCmd.esave()
  • +
  • [View] -> HfIclassCmd.eview()
  • +
  • [Simulate Card] -> HfIclassCmd.sim()
  • +
+
+

Phase 4: ISO15693 Page

+

File: lib/ui/pages/hf_15_page.dart

+

Basic Structure

+
    +
  • Correct imports
  • +
  • StatefulWidget structure
  • +
  • TabController (3 tabs)
  • +
+

Tab Structure (3 Tabs)

+
    +
  • Tabs: Info, Read/Write, Tools
  • +
+

"Info" Tab

+
    +
  • [Reader] -> Hf15Cmd.reader()
  • +
  • [Tag Info] -> Hf15Cmd.info()
  • +
  • [Dump Card] -> Hf15Cmd.dump()
  • +
  • [Wipe Card] -> Hf15Cmd.wipe()
  • +
+

"Read/Write" Tab

+
    +
  • Block number input
  • +
  • [Read Block] -> Hf15Cmd.rdbl()
  • +
  • Data input
  • +
  • [Write Block] -> Hf15Cmd.wrbl()
  • +
+

"Tools" Tab

+
    +
  • [Find AFI] -> Hf15Cmd.findafi()
  • +
  • UID input
  • +
  • [Set UID] -> Hf15Cmd.csetuid()
  • +
  • [Simulate Card] -> Hf15Cmd.sim()
  • +
  • [Sniff] -> Hf15Cmd.sniff()
  • +
+
+

Phase 5: Mifare DESFire Page

+

File: lib/ui/pages/hf_mfdes_page.dart

+

Basic Structure

+
    +
  • Correct imports
  • +
  • StatefulWidget structure
  • +
  • TabController (4 tabs)
  • +
+

Tab Structure (4 Tabs)

+
    +
  • Tabs: Info, Applications, Files, Management
  • +
+

"Info" Tab

+
    +
  • [Detect] -> HfMfdesCmd.detect()
  • +
  • [Info] -> HfMfdesCmd.info()
  • +
  • [Get UID] -> HfMfdesCmd.getuid()
  • +
  • [Free Memory] -> HfMfdesCmd.freemem()
  • +
  • [Check Keys] -> HfMfdesCmd.chk()
  • +
+

"Applications" Tab

+
    +
  • [List AIDs] -> HfMfdesCmd.getaids()
  • +
  • [List Applications] -> HfMfdesCmd.lsapp()
  • +
  • AID input (6-character hex)
  • +
  • [Select Application] -> HfMfdesCmd.selectapp()
  • +
+

"Files" Tab

+
    +
  • [List Files] -> HfMfdesCmd.getfileids()
  • +
  • FID input
  • +
  • [Read File] -> HfMfdesCmd.read()
  • +
  • Data input
  • +
  • [Write File] -> HfMfdesCmd.write()
  • +
  • [Dump Application] -> HfMfdesCmd.dump()
  • +
+

"Management" Tab

+
    +
  • Key number input
  • +
  • Key input
  • +
  • Algorithm selection (AES/DES)
  • +
  • [Authenticate] -> HfMfdesCmd.auth()
  • +
  • [Format PICC] button (with confirmation) -> HfMfdesCmd.formatpicc()
  • +
+
+

Phase 6: HID Prox Page

+

File: lib/ui/pages/lf_hid_page.dart

+

Basic Structure

+
    +
  • Correct imports
  • +
  • StatefulWidget structure
  • +
  • TabController (3 tabs)
  • +
+

Tab Structure (3 Tabs)

+
    +
  • Tabs: Read, Clone/Simulate, Cracking
  • +
+

"Read" Tab

+
    +
  • [Read HID Card] -> LfHidCmd.reader()
  • +
  • [Demodulate Signal] -> LfHidCmd.demod()
  • +
  • Result display area (parse FC/CN)
  • +
+

"Clone/Simulate" Tab

+
    +
  • Card number data input (Wiegand format)
  • +
  • [Clone to T55xx] -> LfHidCmd.clone()
  • +
  • [Simulate Card] -> LfHidCmd.sim()
  • +
+

"Cracking" Tab

+
    +
  • FC input (optional)
  • +
  • [Brute Force] -> LfHidCmd.brute()
  • +
+
+

Phase 7: Testing and Optimization

+

Task 7.1: Unit Tests

+
    +
  • Test tab switching on each page
  • +
  • Test that all buttons trigger the correct commands
  • +
  • Test input validation (hex format, length)
  • +
  • Test messages when no device is connected
  • +
+

Task 7.2: Integration Tests

+
    +
  • Test that sidebar navigation switches pages correctly
  • +
  • Test that theme switching works normally
  • +
  • Test that there are no conflicts with existing pages
  • +
  • Test application startup and runtime performance
  • +
+

Task 7.3: Code Optimization

+
    +
  • Extract shared components (ActionCard, ResultDisplay, etc.)
  • +
  • Optimize duplicate code
  • +
  • Add necessary comments
  • +
  • Run flutter analyze to check for issues
  • +
+
+

Color Scheme Change Checklist

+

Completed Changes

+

1. Theme File Update (lib/ui/theme.dart)

+
    +
  • Switch to a single-mode theme
  • +
  • Use a dark blue dashboard color scheme
  • +
  • Remove the lightTheme() method
  • +
  • Rename darkTheme() to theme()
  • +
  • Update all color references
  • +
  • Fix the deprecated withOpacity method
  • +
+

2. Application State Update (lib/state/app_state.dart)

+
    +
  • Remove the isDarkMode state
  • +
  • Remove the toggleTheme() method
  • +
+

3. Main Application Update (lib/main.dart)

+
    +
  • Update the theme reference to AppTheme.theme()
  • +
  • Remove theme mode switching logic
  • +
+

4. Home Page Update (lib/ui/home_page.dart)

+
    +
  • Remove the theme toggle button
  • +
  • Clean up related code
  • +
+

5. Settings Page Update (lib/ui/settings_page.dart)

+
    +
  • Remove the theme settings option
  • +
+

6. Connection Page Update (lib/ui/pages/connection_page.dart)

+
    +
  • Import the new theme file
  • +
  • Replace all Morandi color references
  • +
  • Use the new blue theme colors
  • +
+

7. Compilation Verification

+
    +
  • flutter analyze reports no errors
  • +
  • The application compiles normally
  • +
+

Color Scheme

+
    +
  • Dark blue background: #0F172A
  • +
  • Card color: #1E293B
  • +
  • Highlight blue: #38BDF8
  • +
  • Secondary gray text: #94A3B8
  • +
  • Divider: #334155
  • +
  • Error color: #F87171
  • +
+

Verification Results

+
    +
  • The application starts normally
  • +
  • All pages display correctly
  • +
  • Consistent color styling
  • +
  • No compilation errors
  • +
+
+

Summary

+

The color scheme changes are complete, and the application now uses a single-theme mode with a dark blue dashboard style. All related files have been updated, and compilation verification has passed. Development of the new feature pages can begin next.

diff --git a/docs.en/checklist_new_features.md b/docs.en/checklist_new_features.md new file mode 100644 index 0000000..57fc162 --- /dev/null +++ b/docs.en/checklist_new_features.md @@ -0,0 +1,386 @@ +# PM3GUI New Feature Page Development Checklist + +> The Phase checklists and their original checked states below are retained as design records from before PR #27. They do not represent current implementation progress, +> SDK requirements, or supported platforms. For current development entry points, see the [documentation index](README.md) and +> [Localization](localization.md); do not directly copy the hardcoded Chinese text in the old examples. + +## Localization Acceptance for Newly Added Pages (PR #27) + +- [ ] English and Chinese ARB resources are complete; sentences use named placeholders, and different meanings such as nouns/actions use different keys. +- [ ] Labels, tooltips, validation, confirmation/danger messages, empty states, and errors all use the current interface language. +- [ ] Temporary validation models inherit the current language; failed imports do not alter the original data. +- [ ] Raw PM3 commands, hexadecimal data, protocol identifiers, and persisted fields are not translated. +- [ ] Standalone Dart tools do not introduce Flutter runtime dependencies, and the actual `dart run` entry points have regression coverage. +- [ ] Failures to locate/save the language directory are visible on the settings page; initialization can still complete, and subsequent saves can be retried. +- [ ] English and Chinese pages and dialogs have been specifically checked in default/smaller desktop windows and with text scaling. +- [ ] After changes, run `flutter gen-l10n`, resource integrity checks, static analysis, and relevant tests. +- [ ] Synchronize Markdown/HTML documentation, and clearly distinguish code tests, Linux builds, and real-device validation. + +## Pre-development Checks + +### Environment Preparation + +- [x] Flutter SDK 3.24+ is installed +- [x] Project dependencies are updated (`flutter pub get`) +- [x] Existing code has no compilation errors (`flutter analyze`) +- [x] The application starts and runs normally + +### Code Review + +- [x] Read [pm3_commands.dart](../lib/services/pm3_commands.dart) to understand available commands +- [x] Read [mifare_page.dart](../lib/ui/pages/mifare_page.dart) to understand code patterns +- [x] Read [home_page.dart](../lib/ui/home_page.dart) to understand the navigation structure + +--- + +## Phase 1: Basic Preparation + +### Task 1.1: Update the Navigation Structure + +**File**: `lib/ui/home_page.dart` + +#### Import Statements + +- [ ] Add the `hf_mfu_page.dart` import +- [ ] Add the `hf_iclass_page.dart` import +- [ ] Add the `hf_15_page.dart` import +- [ ] Add the `hf_mfdes_page.dart` import +- [ ] Add the `lf_hid_page.dart` import + +#### _pages List Update + +- [ ] Add `HfMfuPage()` after `MifarePage()` +- [ ] Add `HfIclassPage()` +- [ ] Add `Hf15Page()` +- [ ] Add `LfHidPage()` after `LfPage()` + +#### _navItems List Update + +- [ ] Add the NTAG navigation item (Icons.memory) +- [ ] Add the iCLASS navigation item (Icons.badge) +- [ ] Add the ISO15693 navigation item (Icons.contactless) +- [ ] Add the HID navigation item (Icons.door_front_door_outlined) + +#### Verification + +- [ ] `flutter analyze` reports no errors +- [ ] The application compiles normally +- [ ] The sidebar displays all new navigation items + +--- + +## Phase 2: Mifare Ultralight/NTAG Page + +### File: `lib/ui/pages/hf_mfu_page.dart` + +#### Basic Structure + +- [ ] The file header contains a `library;` declaration +- [ ] All necessary imports +- [ ] `HfMfuPage` StatefulWidget class +- [ ] `_HfMfuPageState` state class +- [ ] `SingleTickerProviderStateMixin` mixin +- [ ] TabController initialization and disposal + +#### Tab Structure (5 Tabs) + +- [ ] TabBar contains 5 Tabs +- [ ] TabBarView contains 5 corresponding pages +- [ ] Tabs: Info, Read/Write, NDEF, Emulator, Tools + +#### "Info" Tab + +- [ ] [Get Info] button -> `HfMfuCmd.info()` +- [ ] [Dump Card] button -> `HfMfuCmd.dump()` +- [ ] [Wipe Card] button -> `HfMfuCmd.wipe()` (with a confirmation dialog) +- [ ] Result display area + +#### "Read/Write" Tab + +- [ ] Block number input (0-255) +- [ ] [Read Block] button -> `HfMfuCmd.rdbl()` +- [ ] Data input (16-character hex validation) +- [ ] [Write Block] button -> `HfMfuCmd.wrbl()` +- [ ] Password input (8-character hex) +- [ ] [Authenticate] button -> `HfMfuCmd.cauth()` + +#### "NDEF" Tab + +- [ ] [Read NDEF] button -> `HfMfuCmd.ndefRead()` +- [ ] NDEF result display area + +#### "Emulator" Tab + +- [ ] File selection functionality +- [ ] [Load into Emulator] -> `HfMfuCmd.eload()` +- [ ] [Save Emulator] -> `HfMfuCmd.esave()` +- [ ] [View Emulator] -> `HfMfuCmd.eview()` +- [ ] [Simulate Card] -> `HfMfuCmd.sim()` + +#### "Tools" Tab + +- [ ] [Generate Keys] -> `HfMfuCmd.keygen()` +- [ ] UID input (optional) +- [ ] [Generate Password] -> `HfMfuCmd.pwdgen()` +- [ ] [Set UID] -> `HfMfuCmd.setuid()` + +#### Common Functionality + +- [ ] The `_execute()` method checks the connection state +- [ ] Show a SnackBar message when disconnected +- [ ] Use a monospace font to display hex data +- [ ] Dangerous operations have confirmation dialogs + +--- + +## Phase 3: iCLASS/Picopass Page + +### File: `lib/ui/pages/hf_iclass_page.dart` + +#### Basic Structure + +- [ ] Correct imports +- [ ] StatefulWidget structure +- [ ] TabController (4 tabs) + +#### Tab Structure (4 Tabs) + +- [ ] Tabs: Info, Read/Write, Cracking, Emulator + +#### "Info" Tab + +- [ ] [Get Info] -> `HfIclassCmd.info()` +- [ ] [Read Card] -> `HfIclassCmd.reader()` +- [ ] [Dump Card] -> `HfIclassCmd.dump()` + +#### "Read/Write" Tab + +- [ ] Block number selector (0-31) +- [ ] Key input (16-character hex, optional) +- [ ] [Read Block] -> `HfIclassCmd.rdbl()` +- [ ] Data input (16-character hex) +- [ ] [Write Block] -> `HfIclassCmd.wrbl()` + +#### "Cracking" Tab + +- [ ] [Check Keys] -> `HfIclassCmd.chk()` +- [ ] [Loclass Attack] -> `HfIclassCmd.loclass()` +- [ ] [Sniff Communication] -> `HfIclassCmd.sniff()` + +#### "Emulator" Tab + +- [ ] [Load File] -> `HfIclassCmd.eload()` +- [ ] [Save File] -> `HfIclassCmd.esave()` +- [ ] [View] -> `HfIclassCmd.eview()` +- [ ] [Simulate Card] -> `HfIclassCmd.sim()` + +--- + +## Phase 4: ISO15693 Page + +### File: `lib/ui/pages/hf_15_page.dart` + +#### Basic Structure + +- [ ] Correct imports +- [ ] StatefulWidget structure +- [ ] TabController (3 tabs) + +#### Tab Structure (3 Tabs) + +- [ ] Tabs: Info, Read/Write, Tools + +#### "Info" Tab + +- [ ] [Reader] -> `Hf15Cmd.reader()` +- [ ] [Tag Info] -> `Hf15Cmd.info()` +- [ ] [Dump Card] -> `Hf15Cmd.dump()` +- [ ] [Wipe Card] -> `Hf15Cmd.wipe()` + +#### "Read/Write" Tab + +- [ ] Block number input +- [ ] [Read Block] -> `Hf15Cmd.rdbl()` +- [ ] Data input +- [ ] [Write Block] -> `Hf15Cmd.wrbl()` + +#### "Tools" Tab + +- [ ] [Find AFI] -> `Hf15Cmd.findafi()` +- [ ] UID input +- [ ] [Set UID] -> `Hf15Cmd.csetuid()` +- [ ] [Simulate Card] -> `Hf15Cmd.sim()` +- [ ] [Sniff] -> `Hf15Cmd.sniff()` + +--- + +## Phase 5: Mifare DESFire Page + +### File: `lib/ui/pages/hf_mfdes_page.dart` + +#### Basic Structure + +- [ ] Correct imports +- [ ] StatefulWidget structure +- [ ] TabController (4 tabs) + +#### Tab Structure (4 Tabs) + +- [ ] Tabs: Info, Applications, Files, Management + +#### "Info" Tab + +- [ ] [Detect] -> `HfMfdesCmd.detect()` +- [ ] [Info] -> `HfMfdesCmd.info()` +- [ ] [Get UID] -> `HfMfdesCmd.getuid()` +- [ ] [Free Memory] -> `HfMfdesCmd.freemem()` +- [ ] [Check Keys] -> `HfMfdesCmd.chk()` + +#### "Applications" Tab + +- [ ] [List AIDs] -> `HfMfdesCmd.getaids()` +- [ ] [List Applications] -> `HfMfdesCmd.lsapp()` +- [ ] AID input (6-character hex) +- [ ] [Select Application] -> `HfMfdesCmd.selectapp()` + +#### "Files" Tab + +- [ ] [List Files] -> `HfMfdesCmd.getfileids()` +- [ ] FID input +- [ ] [Read File] -> `HfMfdesCmd.read()` +- [ ] Data input +- [ ] [Write File] -> `HfMfdesCmd.write()` +- [ ] [Dump Application] -> `HfMfdesCmd.dump()` + +#### "Management" Tab + +- [ ] Key number input +- [ ] Key input +- [ ] Algorithm selection (AES/DES) +- [ ] [Authenticate] -> `HfMfdesCmd.auth()` +- [ ] [Format PICC] button (with confirmation) -> `HfMfdesCmd.formatpicc()` + +--- + +## Phase 6: HID Prox Page + +### File: `lib/ui/pages/lf_hid_page.dart` + +#### Basic Structure + +- [ ] Correct imports +- [ ] StatefulWidget structure +- [ ] TabController (3 tabs) + +#### Tab Structure (3 Tabs) + +- [ ] Tabs: Read, Clone/Simulate, Cracking + +#### "Read" Tab + +- [ ] [Read HID Card] -> `LfHidCmd.reader()` +- [ ] [Demodulate Signal] -> `LfHidCmd.demod()` +- [ ] Result display area (parse FC/CN) + +#### "Clone/Simulate" Tab + +- [ ] Card number data input (Wiegand format) +- [ ] [Clone to T55xx] -> `LfHidCmd.clone()` +- [ ] [Simulate Card] -> `LfHidCmd.sim()` + +#### "Cracking" Tab + +- [ ] FC input (optional) +- [ ] [Brute Force] -> `LfHidCmd.brute()` + +--- + +## Phase 7: Testing and Optimization + +### Task 7.1: Unit Tests + +- [ ] Test tab switching on each page +- [ ] Test that all buttons trigger the correct commands +- [ ] Test input validation (hex format, length) +- [ ] Test messages when no device is connected + +### Task 7.2: Integration Tests + +- [ ] Test that sidebar navigation switches pages correctly +- [ ] Test that theme switching works normally +- [ ] Test that there are no conflicts with existing pages +- [ ] Test application startup and runtime performance + +### Task 7.3: Code Optimization + +- [ ] Extract shared components (ActionCard, ResultDisplay, etc.) +- [ ] Optimize duplicate code +- [ ] Add necessary comments +- [ ] Run `flutter analyze` to check for issues + +--- + +## Color Scheme Change Checklist + +### Completed Changes + +#### 1. Theme File Update (`lib/ui/theme.dart`) + +- [x] Switch to a single-mode theme +- [x] Use a dark blue dashboard color scheme +- [x] Remove the lightTheme() method +- [x] Rename darkTheme() to theme() +- [x] Update all color references +- [x] Fix the deprecated `withOpacity` method + +#### 2. Application State Update (`lib/state/app_state.dart`) + +- [x] Remove the `isDarkMode` state +- [x] Remove the `toggleTheme()` method + +#### 3. Main Application Update (`lib/main.dart`) + +- [x] Update the theme reference to `AppTheme.theme()` +- [x] Remove theme mode switching logic + +#### 4. Home Page Update (`lib/ui/home_page.dart`) + +- [x] Remove the theme toggle button +- [x] Clean up related code + +#### 5. Settings Page Update (`lib/ui/settings_page.dart`) + +- [x] Remove the theme settings option + +#### 6. Connection Page Update (`lib/ui/pages/connection_page.dart`) + +- [x] Import the new theme file +- [x] Replace all Morandi color references +- [x] Use the new blue theme colors + +#### 7. Compilation Verification + +- [x] `flutter analyze` reports no errors +- [x] The application compiles normally + +### Color Scheme + +- **Dark blue background**: `#0F172A` +- **Card color**: `#1E293B` +- **Highlight blue**: `#38BDF8` +- **Secondary gray text**: `#94A3B8` +- **Divider**: `#334155` +- **Error color**: `#F87171` + +### Verification Results + +- [x] The application starts normally +- [x] All pages display correctly +- [x] Consistent color styling +- [x] No compilation errors + +--- + +## Summary + +The color scheme changes are complete, and the application now uses a single-theme mode with a dark blue dashboard style. All related files have been updated, and compilation verification has passed. Development of the new feature pages can begin next. diff --git a/docs.en/complete_coverage_plan.md b/docs.en/complete_coverage_plan.md new file mode 100644 index 0000000..9c257b4 --- /dev/null +++ b/docs.en/complete_coverage_plan.md @@ -0,0 +1,407 @@ +# PM3GUI Complete Coverage Improvement Plan + +> This file retains the coverage plan from before PR #27; the protocol counts, pages to add, and phase statuses below +> are records of the original plan, not a current completion checklist. PR #27 adds the English/Chinese interface and localization workflow; +> it does not justify claims of added or validated protocol capabilities. For the current scope, see the [README](../README.en.md), +> [implementation status](refactoring_plan.md), and [localization guide](localization.md). + +## 1. Coverage Analysis at the Time of the Original Plan + +### 1.1 Already Covered Protocols (7) +- HF Mifare Classic +- HF 14443-A +- General LF +- LF EM4x +- LF T55xx +- HW Hardware + +### 1.2 Newly Documented Protocols (5) +- HF Mifare Ultralight/NTAG +- HF iCLASS/Picopass +- HF ISO15693 +- HF Mifare DESFire +- LF HID Prox + +### 1.3 Protocols to Add (15) + +#### High Frequency (HF) - 6 +| Protocol | Command Count | Priority | Common Scenarios | +|------|--------|--------|----------| +| 14443-B | 9 | P1 | Standard Type B cards | +| FeliCa | 8 | P2 | Japanese transit cards | +| Legic | 7 | P2 | Access control systems | +| SEOS | 3 | P3 | High-security access control | +| EMV | 15 | P1 | Bank cards/payments | +| FIDO | 3 | P3 | Security authentication | + +#### Low Frequency (LF) - 8 +| Protocol | Command Count | Priority | Common Scenarios | +|------|--------|--------|----------| +| AWID | 3 | P2 | Access cards | +| Indala | 3 | P2 | Access cards | +| Hitag | 7 | P1 | Car keys | +| IO | 3 | P2 | Access cards | +| Pyramid | 2 | P3 | Access cards | +| Keri | 2 | P3 | Access cards | +| FDXB | 2 | P3 | Animal tags | +| EM4x05/4x50/4x70 | 12 | P2 | Specialized applications | + +#### Other - 4 +| Protocol | Command Count | Priority | Description | +|------|--------|--------|------| +| Data | 30+ | P2 | Data analysis | +| Trace | 4 | P2 | Trace analysis | +| NFC | 5 | P2 | NFC decoding | +| Script | 2 | P3 | Script execution | + +--- + +## 2. Complete Page List (27 Protocols) + +### High-frequency Pages (13) +1. **hf_mfu_page.dart** - Mifare Ultralight/NTAG ✅ +2. **hf_mfdes_page.dart** - Mifare DESFire ✅ +3. **hf_iclass_page.dart** - iCLASS/Picopass ✅ +4. **hf_15_page.dart** - ISO15693 ✅ +5. **hf_14b_page.dart** - 14443-B 📋 +6. **hf_felica_page.dart** - FeliCa 📋 +7. **hf_legic_page.dart** - Legic 📋 +8. **hf_seos_page.dart** - SEOS 📋 +9. **hf_emv_page.dart** - EMV 📋 +10. **hf_fido_page.dart** - FIDO 📋 +11. **hf_mf_page.dart** - Mifare Classic (existing) +12. **hf_14a_page.dart** - 14443-A (existing) +13. **hf_sniff_page.dart** - General sniffing 📋 + +### Low-frequency Pages (9) +1. **lf_hid_page.dart** - HID Prox ✅ +2. **lf_awid_page.dart** - AWID 📋 +3. **lf_indala_page.dart** - Indala 📋 +4. **lf_hitag_page.dart** - Hitag 📋 +5. **lf_io_page.dart** - IO Prox 📋 +6. **lf_pyramid_page.dart** - Pyramid 📋 +7. **lf_keri_page.dart** - Keri 📋 +8. **lf_fdxb_page.dart** - FDXB 📋 +9. **lf_em4x_page.dart** - EM4x family (existing) + +### Tool Pages (5) +1. **data_page.dart** - Data analysis 📋 +2. **trace_page.dart** - Trace analysis 📋 +3. **nfc_page.dart** - NFC decoding 📋 +4. **script_page.dart** - Script execution 📋 +5. **hw_page.dart** - Hardware information (existing) + +--- + +## 3. Unified Page Template + +All new pages follow a unified code structure: + +```dart +/// [Protocol] operations page +library; + +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:pm3gui/state/app_state.dart'; +import 'package:pm3gui/services/pm3_commands.dart'; +import 'package:pm3gui/ui/theme.dart'; + +class [Protocol]Page extends StatefulWidget { + const [Protocol]Page({super.key}); + + @override + State<[Protocol]Page> createState() => _[Protocol]PageState(); +} + +class _[Protocol]PageState extends State<[Protocol]Page> + with SingleTickerProviderStateMixin { + late TabController _tabController; + + // State variables + String _result = ''; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: N, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + void _execute(String cmd) { + final appState = context.read(); + if (!appState.isConnected) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('PM3 is not connected')), + ); + return; + } + + setState(() { + _isLoading = true; + _result = ''; + }); + + appState.sendCommand(cmd).then((_) { + setState(() { + _isLoading = false; + _result = appState.terminalOutput.last; + }); + }); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 2'), + // ... + ], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildTab1(), + _buildTab2(), + // ... + ], + ), + ), + ], + ); + } + + Widget _buildResultDisplay() { + return Expanded( + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + child: Text( + _result.isEmpty ? 'Run a command to view results' : _result, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ), + ), + ), + ); + } +} +``` + +--- + +## 4. Navigation Structure Update + +### 4.1 Sidebar Grouping + +Group the sidebar by protocol type as follows: + +``` +📁 High Frequency (HF) + ├── Mifare Classic + ├── Mifare Ultralight + ├── Mifare DESFire + ├── iCLASS + ├── ISO15693 + ├── 14443-B + ├── FeliCa + ├── Legic + ├── SEOS + ├── EMV + └── FIDO + +📁 Low Frequency (LF) + ├── EM4x + ├── T55xx + ├── HID Prox + ├── AWID + ├── Indala + ├── Hitag + ├── IO Prox + ├── Pyramid + ├── Keri + └── FDXB + +📁 Tools + ├── Data Analysis + ├── Trace Analysis + ├── NFC Decoding + └── Script Execution +``` + +### 4.2 home_page.dart Update + +```dart +// Import all new pages +import 'package:pm3gui/ui/pages/hf_mfu_page.dart'; +import 'package:pm3gui/ui/pages/hf_mfdes_page.dart'; +import 'package:pm3gui/ui/pages/hf_iclass_page.dart'; +import 'package:pm3gui/ui/pages/hf_15_page.dart'; +import 'package:pm3gui/ui/pages/hf_14b_page.dart'; +import 'package:pm3gui/ui/pages/hf_felica_page.dart'; +import 'package:pm3gui/ui/pages/hf_legic_page.dart'; +import 'package:pm3gui/ui/pages/hf_seos_page.dart'; +import 'package:pm3gui/ui/pages/hf_emv_page.dart'; +import 'package:pm3gui/ui/pages/hf_fido_page.dart'; +import 'package:pm3gui/ui/pages/lf_hid_page.dart'; +import 'package:pm3gui/ui/pages/lf_awid_page.dart'; +import 'package:pm3gui/ui/pages/lf_indala_page.dart'; +import 'package:pm3gui/ui/pages/lf_hitag_page.dart'; +import 'package:pm3gui/ui/pages/lf_io_page.dart'; +import 'package:pm3gui/ui/pages/lf_pyramid_page.dart'; +import 'package:pm3gui/ui/pages/lf_keri_page.dart'; +import 'package:pm3gui/ui/pages/lf_fdxb_page.dart'; +import 'package:pm3gui/ui/pages/data_page.dart'; +import 'package:pm3gui/ui/pages/trace_page.dart'; +import 'package:pm3gui/ui/pages/nfc_page.dart'; +import 'package:pm3gui/ui/pages/script_page.dart'; + +// _pages list +final _pages = const [ + ConnectionPage(), + TerminalPage(), + DumpViewerPage(), + DumpComparePage(), + // HF pages + MifarePage(), + HfMfuPage(), + HfMfdesPage(), + HfIclassPage(), + Hf15Page(), + Hf14bPage(), + HfFelicaPage(), + HfLegicPage(), + HfSeosPage(), + HfEmvPage(), + HfFidoPage(), + // LF pages + LfPage(), + LfHidPage(), + LfAwidPage(), + LfIndalaPage(), + LfHitagPage(), + LfIoPage(), + LfPyramidPage(), + LfKeriPage(), + LfFdxbPage(), + // Tool pages + DataPage(), + TracePage(), + NfcPage(), + ScriptPage(), + SettingsPage(), +]; +``` + +--- + +## 5. Projected Coverage Improvement + +### 5.1 Coverage After Adding All Pages + +| Metric | Current | After Addition | Improvement | +|:-----|:----:|:------:|:----:| +| **Total protocol families** | 27 | 27 | - | +| **Covered protocol families** | 7 | **27** | **+20** | +| **Protocol family coverage** | **26%** | **100%** | **+74%** | +| **GUI-wrapped commands** | 43 | **~200** | **+157** | +| **Accessible through the terminal** | 100% | 100% | - | + +### 5.2 Coverage by Protocol + +After adding all pages, all 27 protocol families will be covered: + +- ✅ **High Frequency (HF)** - All 13 protocols covered +- ✅ **Low Frequency (LF)** - All 9 protocols covered +- ✅ **Tools** - All 5 categories covered + +--- + +## 6. Implementation Recommendations + +### 6.1 Phased Implementation + +Implementation in three batches by priority is recommended: + +**First Batch (P0-P1)** - Core functionality +- HF: Ultralight, DESFire, iCLASS, ISO15693, 14443-B, EMV +- LF: HID Prox, Hitag +- Tools: Data, Trace + +**Second Batch (P2)** - Common functionality +- HF: FeliCa, Legic +- LF: AWID, Indala, IO, EM4x05/4x50/4x70 +- Tools: NFC + +**Third Batch (P3)** - Specialized functionality +- HF: SEOS, FIDO +- LF: Pyramid, Keri, FDXB +- Tools: Script + +### 6.2 Code Reuse + +Extract the following shared components: + +```dart +// lib/ui/components/ +├── action_button.dart // General-purpose action button +├── hex_input_field.dart // Hexadecimal input field +├── result_display.dart // Result display area +├── file_selector.dart // File selector +├── tab_page_scaffold.dart // Tab page scaffold +└── command_executor.dart // Command executor +``` + +### 6.3 Testing Strategy + +1. **Unit Tests** - Test each page independently +2. **Integration Tests** - Navigation between pages and state sharing +3. **Command Tests** - Verify that all commands are sent correctly +4. **UI Tests** - Verify interface display and interaction + +--- + +## 7. Summary + +By adding 20 new pages, PM3GUI's coverage will increase from **26%** to **100%**, providing complete GUI support for all 27 protocol families. + +### Key Benefits +- **100% protocol coverage** - GUI support for all common card types +- **Consistent user experience** - All pages follow the same design pattern +- **Zero maintenance cost** - Command compatibility is automatically inherited from the PM3 client +- **Extensible architecture** - Easy to add support for new protocols + +### Document Checklist +- ✅ `hf_mfu_page_spec.md` - Mifare Ultralight/NTAG +- ✅ `hf_mfdes_page_spec.md` - Mifare DESFire +- ✅ `hf_iclass_page_spec.md` - iCLASS/Picopass +- ✅ `hf_15_page_spec.md` - ISO15693 +- ✅ `lf_hid_page_spec.md` - HID Prox +- ✅ `hf_14b_page_spec.md` - 14443-B +- 📋 Documentation for the other 15 protocols is to be generated + +### Next Actions +1. Generate detailed documentation for the remaining 15 protocols based on this document +2. Extract shared components and establish a component library +3. Implement pages in batches by priority +4. Establish a complete test suite +5. Update user documentation and tutorials diff --git a/docs.en/hf_14b_page_spec.md b/docs.en/hf_14b_page_spec.md new file mode 100644 index 0000000..997ea68 --- /dev/null +++ b/docs.en/hf_14b_page_spec.md @@ -0,0 +1,353 @@ +# HF 14443-B Page Documentation + +> Original design reference (before PR #27). The diagrams, examples, and feature goals +> below, translated from Chinese, do not establish the current implementation or real-card +> validation status; new or modified interfaces must use the [Chinese/English ARB and validation contract](localization.md). +> For current usage and support scope, see the [README](../README.en.md). + +## 1. Page Overview + +### 1.1 Feature Goals +Implement a complete interface for operating ISO14443-B cards, including reading information, reading and writing data, and card simulation. + +### 1.2 Technical Implementation +- **File**: `lib\ui\pages\hf_14b_page.dart` +- **Class name**: `Hf14bPage` +- **Extends**: `StatefulWidget` +- **Core command class**: `Hf14bCmd` + +--- + +## 2. Page Structure + +### 2.1 Overall Layout +``` +┌─────────────────────────────────────────────────────────────┐ +│ TabBar: [Info] [Read/Write] [Simulation] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Tab 1 - Info: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ [Reader] [Tag Info] [APDU] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ Tab 2 - Read/Write: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ APDU Command: [________________] [Send] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ Tab 3 - Simulation: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ [Simulate Card] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 Component Structure + +```dart +class Hf14bPage extends StatefulWidget { + const Hf14bPage({super.key}); + @override + State createState() => _Hf14bPageState(); +} + +class _Hf14bPageState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + // State variables + String _apdu = ''; + String _result = ''; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + // Command execution method + void _execute(String cmd) { + final appState = context.read(); + if (!appState.isConnected) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('PM3 not connected')), + ); + return; + } + + setState(() { + _isLoading = true; + _result = ''; + }); + + appState.sendCommand(cmd).then((_) { + setState(() { + _isLoading = false; + _result = appState.terminalOutput.last; + }); + }); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Info'), + Tab(text: 'Read/Write'), + Tab(text: 'Simulation'), + ], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildInfoTab(), + _buildReadWriteTab(), + _buildSimTab(), + ], + ), + ), + ], + ); + } + + // Builder methods for each tab + Widget _buildInfoTab() { ... } + Widget _buildReadWriteTab() { ... } + Widget _buildSimTab() { ... } +} +``` + +--- + +## 3. Feature Implementation + +### 3.1 Core Command Mapping + +| Button | Command | Description | +|------|------|------| +| Reader | `hf 14b reader` | Read a 14443-B card | +| Tag Info | `hf 14b info` | Read tag information | +| APDU | `hf 14b apdu` | Send an APDU command | +| Send | `hf 14b apdu -s ` | Send the specified APDU | +| Simulate Card | `hf 14b sim` | Simulate a 14443-B card | + +### 3.2 Info Tab + +```dart +Widget _buildInfoTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ElevatedButton.icon( + onPressed: () => _execute('hf 14b reader'), + icon: const Icon(Icons.read_more), + label: const Text('Reader'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf 14b info'), + icon: const Icon(Icons.info_outline), + label: const Text('Tag Info'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf 14b apdu'), + icon: const Icon(Icons.send), + label: const Text('APDU'), + ), + ], + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.3 Read/Write Tab + +```dart +Widget _buildReadWriteTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + children: [ + const Text('APDU Command: ', style: TextStyle(fontSize: 14)), + Expanded( + child: TextFormField( + controller: TextEditingController(text: _apdu), + onChanged: (value) => _apdu = value, + decoration: const InputDecoration( + hintText: 'Hexadecimal APDU command', + helperText: 'Example: 00A4040000', + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9A-Fa-f]')), + ], + ), + ), + ElevatedButton( + onPressed: () => _execute('hf 14b apdu -s $_apdu'), + child: const Text('Send'), + ), + ], + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.4 Simulation Tab + +```dart +Widget _buildSimTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + ElevatedButton.icon( + onPressed: () => _execute('hf 14b sim'), + icon: const Icon(Icons.phone_android), + label: const Text('Simulate Card'), + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.5 Result Display Component + +```dart +Widget _buildResultDisplay() { + return Expanded( + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + child: Text( + _result.isEmpty ? 'Run a command to view the results' : _result, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ), + ), + ), + ); +} +``` + +--- + +## 4. UI/UX Design + +### 4.1 Color Usage +- **Primary color**: `AppTheme.accentBlue` (#38BDF8) +- **Background color**: `AppTheme.darkBg` (#0F172A) +- **Card color**: `AppTheme.darkSurface` (#1E293B) +- **Text color**: `AppTheme.auxiliaryGrey` (#94A3B8) +- **Error color**: `#F87171` + +### 4.2 Interaction Design +- **Button styles**: Use `ElevatedButton` and `OutlinedButton` +- **Input validation**: Validate hex format in real time +- **Loading state**: Display a `CircularProgressIndicator` while executing a command +- **Result display**: Use a monospace font for command output + +### 4.3 Responsive Design +- Adapt to different screen sizes +- Automatically adjust the layout on small screens +- Maintain clickable target areas for action buttons + +--- + +## 5. Dependencies + +| Dependency | Purpose | Version | +|------|------|------| +| `flutter` | Core framework | 3.24+ | +| `provider` | State management | ^6.0.0 | +| `pm3gui` | Internal library | - | + +--- + +## 6. Test Plan + +### 6.1 Functional Tests +- [ ] All buttons send the correct commands +- [ ] Input validation works correctly +- [ ] Results are displayed correctly + +### 6.2 Integration Tests +- [ ] The page loads correctly +- [ ] Tab switching works correctly +- [ ] No conflicts with other pages +- [ ] Theme styles are applied correctly + +### 6.3 Boundary Tests +- [ ] APDU length boundary values +- [ ] Prompt when no device is connected + +--- + +## 7. Code Optimization + +### 7.1 Performance Optimization +- Use `const` constructors to reduce rebuilds +- Optimize state updates to avoid unnecessary repaints +- Use `SingleTickerProviderStateMixin` to improve animation performance + +### 7.2 Code Quality +- Add detailed comments +- Extract repeated code into separate methods +- Follow Dart code style guidelines +- Ensure comprehensive error handling + +--- + +## 8. Compatibility + +### 8.1 Device Compatibility +- Support all devices that run Flutter +- Adapt to phones, tablets, and desktop platforms + +### 8.2 Proxmark3 Compatibility +- Support Iceman firmware +- Support all ISO14443-B cards +- Command compatibility is automatically inherited from the PM3 client + +--- + +## 9. Summary + +The HF 14443-B page provides a complete set of card operations, including reading information, APDU communication, and card simulation. The page uses a tabbed layout with a clear structure and intuitive controls. Integrating the `Hf14bCmd` command class provides full compatibility with the PM3 client while offering a user-friendly interface. + +The page implementation follows Flutter best practices and uses modern Material 3 design to provide a good user experience. diff --git a/docs.en/hf_15_page_spec.md b/docs.en/hf_15_page_spec.md new file mode 100644 index 0000000..2af2093 --- /dev/null +++ b/docs.en/hf_15_page_spec.md @@ -0,0 +1,468 @@ +# ISO15693 Page Documentation + +> Original design reference (before PR #27). The diagrams, examples, and feature goals +> below, translated from Chinese, do not establish the current implementation or real-card +> validation status; new or modified interfaces must use the [Chinese/English ARB and validation contract](localization.md). +> For current usage and support scope, see the [README](../README.en.md). + +## 1. Page Overview + +### 1.1 Feature Goals +Implement a complete interface for operating ISO15693 cards, including reading information, reading and writing data, AFI operations, and card simulation. + +### 1.2 Technical Implementation +- **File**: `lib\ui\pages\hf_15_page.dart` +- **Class name**: `Hf15Page` +- **Extends**: `StatefulWidget` +- **Core command class**: `Hf15Cmd` + +--- + +## 2. Page Structure + +### 2.1 Overall Layout +``` +┌─────────────────────────────────────────────────────────────┐ +│ TabBar: [Info] [Read/Write] [Tools] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Tab 1 - Info: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ [Reader] [Tag Info] [Dump Card] [Wipe Card] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ Tab 2 - Read/Write: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Block: [0-255 ▼] [Read Block] │ │ +│ │ Data: [________________] [Write Block] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ Tab 3 - Tools: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ [Find AFI] [Set UID] [Simulate Card] [Sniff] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 Component Structure + +```dart +class Hf15Page extends StatefulWidget { + const Hf15Page({super.key}); + @override + State createState() => _Hf15PageState(); +} + +class _Hf15PageState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + // State variables + int _blockNumber = 0; + String _blockData = ''; + String _uid = ''; + String _result = ''; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + // Command execution method + void _execute(String cmd) { + final appState = context.read(); + if (!appState.isConnected) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('PM3 not connected')), + ); + return; + } + + setState(() { + _isLoading = true; + _result = ''; + }); + + appState.sendCommand(cmd).then((_) { + setState(() { + _isLoading = false; + _result = appState.terminalOutput.last; + }); + }); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Info'), + Tab(text: 'Read/Write'), + Tab(text: 'Tools'), + ], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildInfoTab(), + _buildReadWriteTab(), + _buildToolsTab(), + ], + ), + ), + ], + ); + } + + // Builder methods for each tab + Widget _buildInfoTab() { ... } + Widget _buildReadWriteTab() { ... } + Widget _buildToolsTab() { ... } +} +``` + +--- + +## 3. Feature Implementation + +### 3.1 Core Command Mapping + +| Button | Command | Description | +|------|------|------| +| Reader | `hf 15 reader` | Reader mode; automatically read cards | +| Tag Info | `hf 15 info` | Read basic tag information | +| Dump Card | `hf 15 dump -f dump.15` | Dump all card data | +| Wipe Card | `hf 15 wipe` | Wipe the entire card | +| Read Block | `hf 15 rdbl -b ` | Read data from the specified block | +| Write Block | `hf 15 wrbl -b -d ` | Write data to the specified block | +| Find AFI | `hf 15 findafi` | Find the Application Family Identifier | +| Set UID | `hf 15 csetuid -u ` | Set the card UID | +| Simulate Card | `hf 15 sim` | Start card simulation | +| Sniff | `hf 15 sniff` | Sniff card communication | + +### 3.2 Info Tab + +```dart +Widget _buildInfoTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ElevatedButton.icon( + onPressed: () => _execute('hf 15 reader'), + icon: const Icon(Icons.read_more), + label: const Text('Reader'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf 15 info'), + icon: const Icon(Icons.info_outline), + label: const Text('Tag Info'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf 15 dump -f dump.15'), + icon: const Icon(Icons.download), + label: const Text('Dump Card'), + ), + ElevatedButton.icon( + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Confirm Wipe'), + content: const Text('Are you sure you want to wipe the entire card? This operation cannot be undone!'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + _execute('hf 15 wipe'); + }, + child: const Text('Confirm'), + ), + ], + ), + ); + }, + icon: const Icon(Icons.delete_forever), + label: const Text('Wipe Card'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFF87171), + ), + ), + ], + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.3 Read/Write Tab + +```dart +Widget _buildReadWriteTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + children: [ + const Text('Block: ', style: TextStyle(fontSize: 14)), + Expanded( + child: DropdownButton( + value: _blockNumber, + onChanged: (value) => setState(() => _blockNumber = value!), + items: List.generate(256, (i) => DropdownMenuItem( + value: i, + child: Text(i.toString()), + )), + ), + ), + ElevatedButton( + onPressed: () => _execute('hf 15 rdbl -b $_blockNumber'), + child: const Text('Read Block'), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + const Text('Data: ', style: TextStyle(fontSize: 14)), + Expanded( + child: TextFormField( + controller: TextEditingController(text: _blockData), + onChanged: (value) => _blockData = value, + decoration: const InputDecoration( + hintText: '16-digit hexadecimal data', + helperText: 'Example: 0011223344556677', + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9A-Fa-f]')), + LengthLimitingTextInputFormatter(16), + ], + ), + ), + ElevatedButton( + onPressed: () => _execute('hf 15 wrbl -b $_blockNumber -d $_blockData'), + child: const Text('Write Block'), + ), + ], + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.4 Tools Tab + +```dart +Widget _buildToolsTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ElevatedButton.icon( + onPressed: () => _execute('hf 15 findafi'), + icon: const Icon(Icons.search), + label: const Text('Find AFI'), + ), + ElevatedButton.icon( + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Set UID'), + content: TextField( + controller: TextEditingController(text: _uid), + onChanged: (value) => _uid = value, + decoration: const InputDecoration( + hintText: '14-digit hexadecimal UID', + helperText: 'Example: 0123456789ABC', + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9A-Fa-f]')), + LengthLimitingTextInputFormatter(14), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + _execute('hf 15 csetuid -u $_uid'); + }, + child: const Text('Confirm'), + ), + ], + ), + ); + }, + icon: const Icon(Icons.edit), + label: const Text('Set UID'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf 15 sim'), + icon: const Icon(Icons.phone_android), + label: const Text('Simulate Card'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf 15 sniff'), + icon: const Icon(Icons.radio), + label: const Text('Sniff'), + ), + ], + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.5 Result Display Component + +```dart +Widget _buildResultDisplay() { + return Expanded( + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + child: Text( + _result.isEmpty ? 'Run a command to view the results' : _result, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ), + ), + ), + ); +} +``` + +--- + +## 4. UI/UX Design + +### 4.1 Color Usage +- **Primary color**: `AppTheme.accentBlue` (#38BDF8) +- **Background color**: `AppTheme.darkBg` (#0F172A) +- **Card color**: `AppTheme.darkSurface` (#1E293B) +- **Text color**: `AppTheme.auxiliaryGrey` (#94A3B8) +- **Error color**: `#F87171` + +### 4.2 Interaction Design +- **Button styles**: Use `ElevatedButton` and `OutlinedButton` +- **Input validation**: Validate hex format and length in real time +- **Loading state**: Display a `CircularProgressIndicator` while executing a command +- **Result display**: Use a monospace font for command output +- **Dangerous operations**: Dangerous operations such as wiping require a confirmation dialog + +### 4.3 Responsive Design +- Adapt to different screen sizes +- Automatically adjust the layout on small screens +- Maintain clickable target areas for action buttons + +--- + +## 5. Dependencies + +| Dependency | Purpose | Version | +|------|------|------| +| `flutter` | Core framework | 3.24+ | +| `provider` | State management | ^6.0.0 | +| `pm3gui` | Internal library | - | + +--- + +## 6. Test Plan + +### 6.1 Functional Tests +- [ ] All buttons send the correct commands +- [ ] Input validation works correctly +- [ ] Results are displayed correctly +- [ ] Dangerous-operation confirmation dialogs work correctly + +### 6.2 Integration Tests +- [ ] The page loads correctly +- [ ] Tab switching works correctly +- [ ] No conflicts with other pages +- [ ] Theme styles are applied correctly + +### 6.3 Boundary Tests +- [ ] Block number boundary values (0, 255) +- [ ] Data length boundary values +- [ ] UID length boundary values +- [ ] Prompt when no device is connected + +--- + +## 7. Code Optimization + +### 7.1 Performance Optimization +- Use `const` constructors to reduce rebuilds +- Optimize state updates to avoid unnecessary repaints +- Use `SingleTickerProviderStateMixin` to improve animation performance + +### 7.2 Code Quality +- Add detailed comments +- Extract repeated code into separate methods +- Follow Dart code style guidelines +- Ensure comprehensive error handling + +--- + +## 8. Compatibility + +### 8.1 Device Compatibility +- Support all devices that run Flutter +- Adapt to phones, tablets, and desktop platforms + +### 8.2 Proxmark3 Compatibility +- Support Iceman firmware +- Support all ISO15693 cards +- Command compatibility is automatically inherited from the PM3 client + +--- + +## 9. Summary + +The ISO15693 page provides a complete set of card operations, including reading information, reading and writing data, AFI operations, and card simulation. The page uses a tabbed layout with a clear structure and intuitive controls. Integrating the `Hf15Cmd` command class provides full compatibility with the PM3 client while offering a user-friendly interface. + +The page implementation follows Flutter best practices and uses modern Material 3 design to provide a good user experience. diff --git a/docs.en/hf_iclass_page_spec.md b/docs.en/hf_iclass_page_spec.md new file mode 100644 index 0000000..b5f0524 --- /dev/null +++ b/docs.en/hf_iclass_page_spec.md @@ -0,0 +1,492 @@ +# iCLASS/Picopass Page Documentation + +> Original design reference (before PR #27). The diagrams, examples, and feature goals +> below, translated from Chinese, do not establish the current implementation or real-card +> validation status; new or modified interfaces must use the [Chinese/English ARB and validation contract](localization.md). +> For current usage and support scope, see the [README](../README.en.md). + +## 1. Page Overview + +### 1.1 Feature Goals +Implement a complete interface for operating iCLASS and Picopass cards, including reading information, reading and writing data, key cracking, and card simulation. + +### 1.2 Technical Implementation +- **File**: `lib\ui\pages\hf_iclass_page.dart` +- **Class name**: `HfIclassPage` +- **Extends**: `StatefulWidget` +- **Core command class**: `HfIclassCmd` + +--- + +## 2. Page Structure + +### 2.1 Overall Layout +``` +┌─────────────────────────────────────────────────────────────┐ +│ TabBar: [Info] [Read/Write] [Cracking] [Emulator] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Tab 1 - Info: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ [Get Info] [Read Card] [Dump Card] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ Tab 2 - Read/Write: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Block: [0-31 ▼] Key: [________________] [Read] │ │ +│ │ Data: [________________] [Write] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ Tab 3 - Cracking: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ [Check Keys] [Loclass Attack] [Sniff Traffic] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ Tab 4 - Emulator: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ [Load File] [Save File] [View] [Simulate] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 Component Structure + +```dart +class HfIclassPage extends StatefulWidget { + const HfIclassPage({super.key}); + @override + State createState() => _HfIclassPageState(); +} + +class _HfIclassPageState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + // State variables + int _blockNumber = 0; + String _key = ''; + String _blockData = ''; + String _result = ''; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 4, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + // Command execution method + void _execute(String cmd) { + final appState = context.read(); + if (!appState.isConnected) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('PM3 not connected')), + ); + return; + } + + setState(() { + _isLoading = true; + _result = ''; + }); + + appState.sendCommand(cmd).then((_) { + setState(() { + _isLoading = false; + _result = appState.terminalOutput.last; + }); + }); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Info'), + Tab(text: 'Read/Write'), + Tab(text: 'Cracking'), + Tab(text: 'Emulator'), + ], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildInfoTab(), + _buildReadWriteTab(), + _buildCrackTab(), + _buildEmulatorTab(), + ], + ), + ), + ], + ); + } + + // Builder methods for each tab + Widget _buildInfoTab() { ... } + Widget _buildReadWriteTab() { ... } + Widget _buildCrackTab() { ... } + Widget _buildEmulatorTab() { ... } +} +``` + +--- + +## 3. Feature Implementation + +### 3.1 Core Command Mapping + +| Button | Command | Description | +|------|------|------| +| Get Info | `hf iclass info` | Read basic card information | +| Read Card | `hf iclass reader` | Read card data | +| Dump Card | `hf iclass dump -f dump.iclass` | Dump all card data | +| Read Block | `hf iclass rdbl -b [-k ]` | Read data from the specified block | +| Write Block | `hf iclass wrbl -b -d [-k ]` | Write data to the specified block | +| Check Keys | `hf iclass chk` | Check default keys | +| Loclass Attack | `hf iclass loclass` | Perform offline cracking | +| Sniff Traffic | `hf iclass sniff` | Sniff card communication | +| Load File | `hf iclass eload -f ` | Load a dump file into the emulator | +| Save File | `hf iclass esave -f ` | Save emulator data to a file | +| View | `hf iclass eview` | View the current emulator state | +| Simulate | `hf iclass sim` | Start card simulation | + +### 3.2 Info Tab + +```dart +Widget _buildInfoTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ElevatedButton.icon( + onPressed: () => _execute('hf iclass info'), + icon: const Icon(Icons.info_outline), + label: const Text('Get Info'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf iclass reader'), + icon: const Icon(Icons.read_more), + label: const Text('Read Card'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf iclass dump -f dump.iclass'), + icon: const Icon(Icons.download), + label: const Text('Dump Card'), + ), + ], + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.3 Read/Write Tab + +```dart +Widget _buildReadWriteTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + children: [ + const Text('Block: ', style: TextStyle(fontSize: 14)), + Expanded( + child: DropdownButton( + value: _blockNumber, + onChanged: (value) => setState(() => _blockNumber = value!), + items: List.generate(32, (i) => DropdownMenuItem( + value: i, + child: Text(i.toString()), + )), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: TextFormField( + controller: TextEditingController(text: _key), + onChanged: (value) => _key = value, + decoration: const InputDecoration( + hintText: '16-digit hexadecimal key', + helperText: 'Example: 0011223344556677', + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9A-Fa-f]')), + LengthLimitingTextInputFormatter(16), + ], + ), + ), + ElevatedButton( + onPressed: () { + final cmd = _key.isNotEmpty + ? 'hf iclass rdbl -b $_blockNumber -k $_key' + : 'hf iclass rdbl -b $_blockNumber'; + _execute(cmd); + }, + child: const Text('Read'), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + const Text('Data: ', style: TextStyle(fontSize: 14)), + Expanded( + child: TextFormField( + controller: TextEditingController(text: _blockData), + onChanged: (value) => _blockData = value, + decoration: const InputDecoration( + hintText: '16-digit hexadecimal data', + helperText: 'Example: 0011223344556677', + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9A-Fa-f]')), + LengthLimitingTextInputFormatter(16), + ], + ), + ), + ElevatedButton( + onPressed: () { + final cmd = _key.isNotEmpty + ? 'hf iclass wrbl -b $_blockNumber -d $_blockData -k $_key' + : 'hf iclass wrbl -b $_blockNumber -d $_blockData'; + _execute(cmd); + }, + child: const Text('Write'), + ), + ], + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.4 Cracking Tab + +```dart +Widget _buildCrackTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ElevatedButton.icon( + onPressed: () => _execute('hf iclass chk'), + icon: const Icon(Icons.key), + label: const Text('Check Keys'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf iclass loclass'), + icon: const Icon(Icons.lock_open), + label: const Text('Loclass Attack'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf iclass sniff'), + icon: const Icon(Icons.radio), + label: const Text('Sniff Traffic'), + ), + ], + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.5 Emulator Tab + +```dart +Widget _buildEmulatorTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ElevatedButton.icon( + onPressed: () async { + final result = await FilePicker.platform.pickFiles( + type: FileType.any, + allowedExtensions: ['iclass', 'bin', 'dump'], + ); + if (result != null) { + _execute('hf iclass eload -f ${result.files.single.path}'); + } + }, + icon: const Icon(Icons.file_open), + label: const Text('Load File'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf iclass esave -f emulator.iclass'), + icon: const Icon(Icons.save), + label: const Text('Save File'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf iclass eview'), + icon: const Icon(Icons.visibility), + label: const Text('View'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf iclass sim'), + icon: const Icon(Icons.phone_android), + label: const Text('Simulate'), + ), + ], + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.6 Result Display Component + +```dart +Widget _buildResultDisplay() { + return Expanded( + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + child: Text( + _result.isEmpty ? 'Run a command to view the results' : _result, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ), + ), + ), + ); +} +``` + +--- + +## 4. UI/UX Design + +### 4.1 Color Usage +- **Primary color**: `AppTheme.accentBlue` (#38BDF8) +- **Background color**: `AppTheme.darkBg` (#0F172A) +- **Card color**: `AppTheme.darkSurface` (#1E293B) +- **Text color**: `AppTheme.auxiliaryGrey` (#94A3B8) +- **Error color**: `#F87171` + +### 4.2 Interaction Design +- **Button styles**: Use `ElevatedButton` and `OutlinedButton` +- **Input validation**: Validate hex format and length in real time +- **Loading state**: Display a `CircularProgressIndicator` while executing a command +- **Result display**: Use a monospace font for command output +- **File selection**: Use the `file_picker` plugin to select files +- **Key input**: Support optional key input + +### 4.3 Responsive Design +- Adapt to different screen sizes +- Automatically adjust the layout on small screens +- Maintain clickable target areas for action buttons + +--- + +## 5. Dependencies + +| Dependency | Purpose | Version | +|------|------|------| +| `flutter` | Core framework | 3.24+ | +| `provider` | State management | ^6.0.0 | +| `file_picker` | File selection | ^5.0.0 | +| `pm3gui` | Internal library | - | + +--- + +## 6. Test Plan + +### 6.1 Functional Tests +- [ ] All buttons send the correct commands +- [ ] Input validation works correctly +- [ ] Results are displayed correctly +- [ ] File selection works correctly +- [ ] Optional key input works correctly + +### 6.2 Integration Tests +- [ ] The page loads correctly +- [ ] Tab switching works correctly +- [ ] No conflicts with other pages +- [ ] Theme styles are applied correctly + +### 6.3 Boundary Tests +- [ ] Block number boundary values (0, 31) +- [ ] Data length boundary values +- [ ] Key length boundary values +- [ ] Prompt when no device is connected +- [ ] Operations without a key + +--- + +## 7. Code Optimization + +### 7.1 Performance Optimization +- Use `const` constructors to reduce rebuilds +- Optimize state updates to avoid unnecessary repaints +- Use `SingleTickerProviderStateMixin` to improve animation performance + +### 7.2 Code Quality +- Add detailed comments +- Extract repeated code into separate methods +- Follow Dart code style guidelines +- Ensure comprehensive error handling + +--- + +## 8. Compatibility + +### 8.1 Device Compatibility +- Support all devices that run Flutter +- Adapt to phones, tablets, and desktop platforms + +### 8.2 Proxmark3 Compatibility +- Support Iceman firmware +- Support all iCLASS and Picopass cards +- Command compatibility is automatically inherited from the PM3 client + +--- + +## 9. Summary + +The iCLASS/Picopass page provides a complete set of card operations, including reading information, reading and writing data, key cracking, and card simulation. The page uses a tabbed layout with a clear structure and intuitive controls. Integrating the `HfIclassCmd` command class provides full compatibility with the PM3 client while offering a user-friendly interface. + +The page implementation follows Flutter best practices and uses modern Material 3 design to provide a good user experience. diff --git a/docs.en/hf_mfdes_page_spec.md b/docs.en/hf_mfdes_page_spec.md new file mode 100644 index 0000000..e44aa49 --- /dev/null +++ b/docs.en/hf_mfdes_page_spec.md @@ -0,0 +1,580 @@ +# Mifare DESFire Page Documentation + +> Historical design reference (pre-PR #27). The diagrams, examples, and feature goals +> below do not establish the current implementation or physical-card verification +> status; new or modified interfaces must follow the [Chinese/English ARB and validation contract](localization.md). +> See the [README](../README.en.md) for current usage and supported scope. +> +> The current applications tab uses `desfireApplicationsTab`, rendered in English as **Applications**; +> the editor's **Apply** action retains `messageApply`. These must not be merged +> just because their Chinese labels are the same. + +## 1. Page Overview + +### 1.1 Feature Goals +Implement a complete interface for operating Mifare DESFire cards, including reading information, managing applications, working with files, and authentication. + +### 1.2 Technical Implementation +- **File**: [`lib/ui/pages/hf_mfdes_page.dart`](../lib/ui/pages/hf_mfdes_page.dart) +- **Class name**: `HfMfdesPage` +- **Extends**: `StatefulWidget` +- **Core command class**: `HfMfdesCmd` + +--- + +## 2. Page Structure + +### 2.1 Overall Layout +``` +┌─────────────────────────────────────────────────────────────┐ +│ TabBar: [Info] [Applications] [Files] [Management] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Tab 1 - Info: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ [Detect] [Info] [Get UID] [Free Memory] [Check Keys] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ Tab 2 - Applications: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ [List AIDs] [List Applications] │ │ +│ │ AID: [________] [Select Application] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ Tab 3 - Files: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ [List Files] [Dump Application] │ │ +│ │ FID: [____] [Read] [Write] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ Tab 4 - Management: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Key Number: [0-15 ▼] Algorithm: [AES/DES ▼] │ │ +│ │ Key: [________________] [Authenticate] │ │ +│ │ [Format PICC] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 Component Structure + +```dart +class HfMfdesPage extends StatefulWidget { + const HfMfdesPage({super.key}); + @override + State createState() => _HfMfdesPageState(); +} + +class _HfMfdesPageState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + // State variables + String _aid = ''; + int _fid = 0; + String _key = ''; + int _keyNumber = 0; + String _algorithm = 'AES'; + String _result = ''; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 4, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + // Command execution method + void _execute(String cmd) { + final appState = context.read(); + if (!appState.isConnected) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('PM3 not connected')), + ); + return; + } + + setState(() { + _isLoading = true; + _result = ''; + }); + + appState.sendCommand(cmd).then((_) { + setState(() { + _isLoading = false; + _result = appState.terminalOutput.last; + }); + }); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Info'), + Tab(text: 'Applications'), + Tab(text: 'Files'), + Tab(text: 'Management'), + ], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildInfoTab(), + _buildAppTab(), + _buildFileTab(), + _buildManageTab(), + ], + ), + ), + ], + ); + } + + // Builder methods for each tab + Widget _buildInfoTab() { ... } + Widget _buildAppTab() { ... } + Widget _buildFileTab() { ... } + Widget _buildManageTab() { ... } +} +``` + +--- + +## 3. Feature Implementation + +### 3.1 Core Command Mapping + +| Button | Command | Description | +|------|------|------| +| Detect | `hf mfdes detect` | Detect a DESFire card | +| Info | `hf mfdes info` | Read basic card information | +| Get UID | `hf mfdes getuid` | Get the card UID | +| Free Memory | `hf mfdes freemem` | View free memory | +| Check Keys | `hf mfdes chk` | Check default keys | +| List AIDs | `hf mfdes getaids` | List all application AIDs | +| List Applications | `hf mfdes lsapp` | List current applications | +| Select Application | `hf mfdes selectapp -a ` | Select the specified application | +| List Files | `hf mfdes getfileids` | List files in the current application | +| Dump Application | `hf mfdes dump` | Dump data from the current application | +| Read File | `hf mfdes read -f ` | Read the specified file | +| Write File | `hf mfdes write -f -d ` | Write data to the specified file | +| Authenticate | `hf mfdes auth -k -a ` | Authenticate using the specified algorithm | +| Format PICC | `hf mfdes formatpicc` | Format the entire card | + +### 3.2 Info Tab + +```dart +Widget _buildInfoTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ElevatedButton.icon( + onPressed: () => _execute('hf mfdes detect'), + icon: const Icon(Icons.search), + label: const Text('Detect'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf mfdes info'), + icon: const Icon(Icons.info_outline), + label: const Text('Info'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf mfdes getuid'), + icon: const Icon(Icons.credit_card), + label: const Text('Get UID'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf mfdes freemem'), + icon: const Icon(Icons.storage), + label: const Text('Free Memory'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf mfdes chk'), + icon: const Icon(Icons.key), + label: const Text('Check Keys'), + ), + ], + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.3 Applications Tab + +```dart +Widget _buildAppTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ElevatedButton.icon( + onPressed: () => _execute('hf mfdes getaids'), + icon: const Icon(Icons.list), + label: const Text('List AIDs'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf mfdes lsapp'), + icon: const Icon(Icons.apps), + label: const Text('List Applications'), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + const Text('AID: ', style: TextStyle(fontSize: 14)), + Expanded( + child: TextFormField( + controller: TextEditingController(text: _aid), + onChanged: (value) => _aid = value, + decoration: const InputDecoration( + hintText: '6-digit hexadecimal AID', + helperText: 'Example: 000000', + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9A-Fa-f]')), + LengthLimitingTextInputFormatter(6), + ], + ), + ), + ElevatedButton( + onPressed: () => _execute('hf mfdes selectapp -a $_aid'), + child: const Text('Select Application'), + ), + ], + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.4 Files Tab + +```dart +Widget _buildFileTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ElevatedButton.icon( + onPressed: () => _execute('hf mfdes getfileids'), + icon: const Icon(Icons.file_copy), + label: const Text('List Files'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf mfdes dump'), + icon: const Icon(Icons.download), + label: const Text('Dump Application'), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + const Text('FID: ', style: TextStyle(fontSize: 14)), + Expanded( + child: DropdownButton( + value: _fid, + onChanged: (value) => setState(() => _fid = value!), + items: List.generate(16, (i) => DropdownMenuItem( + value: i, + child: Text(i.toString()), + )), + ), + ), + ElevatedButton( + onPressed: () => _execute('hf mfdes read -f $_fid'), + child: const Text('Read'), + ), + ElevatedButton( + onPressed: () { + showDialog( + context: context, + builder: (context) { + String data = ''; + return AlertDialog( + title: const Text('Write File'), + content: TextField( + onChanged: (value) => data = value, + decoration: const InputDecoration( + hintText: 'Hexadecimal data', + helperText: 'Example: 00112233', + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9A-Fa-f]')), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + _execute('hf mfdes write -f $_fid -d $data'); + }, + child: const Text('Confirm'), + ), + ], + ); + }, + ); + }, + child: const Text('Write'), + ), + ], + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.5 Management Tab + +```dart +Widget _buildManageTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + children: [ + const Text('Key Number: ', style: TextStyle(fontSize: 14)), + Expanded( + child: DropdownButton( + value: _keyNumber, + onChanged: (value) => setState(() => _keyNumber = value!), + items: List.generate(16, (i) => DropdownMenuItem( + value: i, + child: Text(i.toString()), + )), + ), + ), + const SizedBox(width: 12), + const Text('Algorithm: ', style: TextStyle(fontSize: 14)), + Expanded( + child: DropdownButton( + value: _algorithm, + onChanged: (value) => setState(() => _algorithm = value!), + items: const [ + DropdownMenuItem(value: 'AES', child: Text('AES')), + DropdownMenuItem(value: 'DES', child: Text('DES')), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + const Text('Key: ', style: TextStyle(fontSize: 14)), + Expanded( + child: TextFormField( + controller: TextEditingController(text: _key), + onChanged: (value) => _key = value, + decoration: const InputDecoration( + hintText: '32-digit hexadecimal key (AES)', + helperText: 'Example: 00112233445566778899AABBCCDDEEFF', + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9A-Fa-f]')), + ], + ), + ), + ElevatedButton( + onPressed: () => _execute('hf mfdes auth -k $_key -a $_algorithm'), + child: const Text('Authenticate'), + ), + ], + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Confirm Format'), + content: const Text('Are you sure you want to format the entire card? This operation cannot be undone!'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + _execute('hf mfdes formatpicc'); + }, + child: const Text('Confirm'), + ), + ], + ), + ); + }, + icon: const Icon(Icons.format_shapes), + label: const Text('Format PICC'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFF87171), + ), + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.6 Result Display Component + +```dart +Widget _buildResultDisplay() { + return Expanded( + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + child: Text( + _result.isEmpty ? 'Run a command to view results' : _result, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ), + ), + ), + ); +} +``` + +--- + +## 4. UI/UX Design + +### 4.1 Color Usage +- **Primary color**: `AppTheme.accentBlue` (#38BDF8) +- **Background color**: `AppTheme.darkBg` (#0F172A) +- **Card color**: `AppTheme.darkSurface` (#1E293B) +- **Text color**: `AppTheme.auxiliaryGrey` (#94A3B8) +- **Error color**: `#F87171` + +### 4.2 Interaction Design +- **Button styles**: Use `ElevatedButton` and `OutlinedButton` +- **Input validation**: Validate hexadecimal format and length in real time +- **Loading state**: Display a `CircularProgressIndicator` while a command is running +- **Result display**: Display command output in a monospaced font +- **Dangerous operations**: Require a confirmation dialog for dangerous operations such as formatting +- **Algorithm selection**: Support switching between AES and DES algorithms + +### 4.3 Responsive Design +- Adapt to different screen sizes +- Automatically adjust the layout on small screens +- Preserve the tappable areas of action buttons + +--- + +## 5. Dependencies + +| Dependency | Purpose | Version | +|------|------|------| +| `flutter` | Core framework | 3.24+ | +| `provider` | State management | ^6.0.0 | +| `pm3gui` | Internal library | - | + +--- + +## 6. Test Plan + +### 6.1 Functional Tests +- [ ] All buttons send the correct commands +- [ ] Input validation works correctly +- [ ] Results display correctly +- [ ] Confirmation dialogs for dangerous operations work correctly +- [ ] Algorithm selection works correctly + +### 6.2 Integration Tests +- [ ] The page loads correctly +- [ ] Tab switching works correctly +- [ ] There are no conflicts with other pages +- [ ] Theme styles are applied correctly + +### 6.3 Boundary Tests +- [ ] AID length boundary values +- [ ] Key length boundary values +- [ ] FID boundary values (0-15) +- [ ] Prompt when no device is connected + +--- + +## 7. Code Optimization + +### 7.1 Performance Optimization +- Use `const` constructors to reduce rebuilds +- Optimize state updates to avoid unnecessary repaints +- Use `SingleTickerProviderStateMixin` to improve animation performance + +### 7.2 Code Quality +- Add detailed comments +- Extract duplicated code into separate methods +- Follow Dart code style guidelines +- Ensure comprehensive error handling + +--- + +## 8. Compatibility + +### 8.1 Device Compatibility +- Support all devices that run Flutter +- Adapt to phones, tablets, and desktop platforms + +### 8.2 Proxmark3 Compatibility +- Support Iceman firmware +- Support all Mifare DESFire cards +- Command compatibility is automatically inherited from the PM3 client + +--- + +## 9. Summary + +The Mifare DESFire page provides a complete set of card operations, including reading information, managing applications, working with files, and authentication. The page uses a tabbed layout with a clear structure and intuitive controls. Integration with the `HfMfdesCmd` command class provides full compatibility with the PM3 client while offering a user-friendly interface. + +The page implementation follows Flutter best practices and uses a modern Material 3 design to provide a good user experience. diff --git a/docs.en/hf_mfu_page_spec.md b/docs.en/hf_mfu_page_spec.md new file mode 100644 index 0000000..a85050a --- /dev/null +++ b/docs.en/hf_mfu_page_spec.md @@ -0,0 +1,579 @@ +# Mifare Ultralight/NTAG Page Documentation + +> Historical design reference (pre-PR #27). The diagrams, examples, and feature goals +> below do not establish the current implementation or physical-card verification +> status; new or modified interfaces must follow the [Chinese/English ARB and validation contract](localization.md). +> See the [README](../README.en.md) for current usage and supported scope. + +## 1. Page Overview + +### 1.1 Feature Goals +Implement a complete interface for operating Mifare Ultralight and NTAG series cards, including reading information, reading and writing data, NDEF operations, and card simulation. + +### 1.2 Technical Implementation +- **File**: [`lib/ui/pages/hf_mfu_page.dart`](../lib/ui/pages/hf_mfu_page.dart) +- **Class name**: `HfMfuPage` +- **Extends**: `StatefulWidget` +- **Core command class**: `HfMfuCmd` + +--- + +## 2. Page Structure + +### 2.1 Overall Layout +``` +┌─────────────────────────────────────────────────────────────┐ +│ TabBar: [Info] [Read/Write] [NDEF] [Emulator] [Tools] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Tab 1 - Info: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ [Get Info] [Dump Card] [Wipe Card] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ Tab 2 - Read/Write: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Block Number: [0-255 ▼] [Read Block] │ │ +│ │ Data: [________________] [Write Block] │ │ +│ │ Password: [________________] [Authenticate] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ Tab 3 - NDEF: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ [Read NDEF] Result display area... │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ Tab 4 - Emulator: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ [Load File] [Save to File] [View] [Simulate] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ Tab 5 - Tools: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ [Generate Key] [Generate Password] [Set UID] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 Component Structure + +```dart +class HfMfuPage extends StatefulWidget { + const HfMfuPage({super.key}); + @override + State createState() => _HfMfuPageState(); +} + +class _HfMfuPageState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + // State variables + int _blockNumber = 0; + String _blockData = ''; + String _password = ''; + String _uid = ''; + String _result = ''; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 5, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + // Command execution method + void _execute(String cmd) { + final appState = context.read(); + if (!appState.isConnected) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('PM3 not connected')), + ); + return; + } + + setState(() { + _isLoading = true; + _result = ''; + }); + + appState.sendCommand(cmd).then((_) { + setState(() { + _isLoading = false; + _result = appState.terminalOutput.last; + }); + }); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Info'), + Tab(text: 'Read/Write'), + Tab(text: 'NDEF'), + Tab(text: 'Emulator'), + Tab(text: 'Tools'), + ], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildInfoTab(), + _buildReadWriteTab(), + _buildNdefTab(), + _buildEmulatorTab(), + _buildToolsTab(), + ], + ), + ), + ], + ); + } + + // Builder methods for each tab + Widget _buildInfoTab() { ... } + Widget _buildReadWriteTab() { ... } + Widget _buildNdefTab() { ... } + Widget _buildEmulatorTab() { ... } + Widget _buildToolsTab() { ... } +} +``` + +--- + +## 3. Feature Implementation + +### 3.1 Core Command Mapping + +| Button | Command | Description | +|------|------|------| +| Get Info | `hf mfu info` | Read basic tag information | +| Dump Card | `hf mfu dump -f dump.mfu` | Dump all card data | +| Wipe Card | `hf mfu wipe` | Wipe the entire card | +| Read Block | `hf mfu rdbl -b ` | Read data from the specified block | +| Write Block | `hf mfu wrbl -b -d ` | Write data to the specified block | +| Authenticate | `hf mfu cauth -p ` | Authenticate using a password | +| Read NDEF | `hf mfu ndefread` | Read NDEF data | +| Load into Emulator | `hf mfu eload -f ` | Load a dump file into the emulator | +| Save Emulator | `hf mfu esave -f ` | Save emulator data to a file | +| View Emulator | `hf mfu eview` | View the current emulator state | +| Simulate Card | `hf mfu sim` | Start card simulation | +| Generate Key | `hf mfu keygen` | Generate default keys | +| Generate Password | `hf mfu pwdgen` | Generate default passwords | +| Set UID | `hf mfu setuid -u ` | Set the card UID | + +### 3.2 Info Tab + +```dart +Widget _buildInfoTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ElevatedButton.icon( + onPressed: () => _execute('hf mfu info'), + icon: const Icon(Icons.info_outline), + label: const Text('Get Info'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf mfu dump -f dump.mfu'), + icon: const Icon(Icons.download), + label: const Text('Dump Card'), + ), + ElevatedButton.icon( + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Confirm Wipe'), + content: const Text('Are you sure you want to wipe the entire card? This operation cannot be undone!'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + _execute('hf mfu wipe'); + }, + child: const Text('Confirm'), + ), + ], + ), + ); + }, + icon: const Icon(Icons.delete_forever), + label: const Text('Wipe Card'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFF87171), + ), + ), + ], + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.3 Read/Write Tab + +```dart +Widget _buildReadWriteTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + children: [ + const Text('Block Number: ', style: TextStyle(fontSize: 14)), + Expanded( + child: DropdownButton( + value: _blockNumber, + onChanged: (value) => setState(() => _blockNumber = value!), + items: List.generate(256, (i) => DropdownMenuItem( + value: i, + child: Text(i.toString()), + )), + ), + ), + ElevatedButton( + onPressed: () => _execute('hf mfu rdbl -b $_blockNumber'), + child: const Text('Read Block'), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + const Text('Data: ', style: TextStyle(fontSize: 14)), + Expanded( + child: TextFormField( + controller: TextEditingController(text: _blockData), + onChanged: (value) => _blockData = value, + decoration: const InputDecoration( + hintText: '16-digit hexadecimal data', + helperText: 'Example: 0011223344556677', + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9A-Fa-f]')), + LengthLimitingTextInputFormatter(16), + ], + ), + ), + ElevatedButton( + onPressed: () => _execute('hf mfu wrbl -b $_blockNumber -d $_blockData'), + child: const Text('Write Block'), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + const Text('Password: ', style: TextStyle(fontSize: 14)), + Expanded( + child: TextFormField( + controller: TextEditingController(text: _password), + onChanged: (value) => _password = value, + decoration: const InputDecoration( + hintText: '8-digit hexadecimal password', + helperText: 'Example: 00112233', + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9A-Fa-f]')), + LengthLimitingTextInputFormatter(8), + ], + ), + ), + ElevatedButton( + onPressed: () => _execute('hf mfu cauth -p $_password'), + child: const Text('Authenticate'), + ), + ], + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.4 NDEF Tab + +```dart +Widget _buildNdefTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + ElevatedButton.icon( + onPressed: () => _execute('hf mfu ndefread'), + icon: const Icon(Icons.nfc), + label: const Text('Read NDEF'), + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.5 Emulator Tab + +```dart +Widget _buildEmulatorTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ElevatedButton.icon( + onPressed: () async { + final result = await FilePicker.platform.pickFiles( + type: FileType.any, + allowedExtensions: ['mfu', 'bin', 'dump'], + ); + if (result != null) { + _execute('hf mfu eload -f ${result.files.single.path}'); + } + }, + icon: const Icon(Icons.file_open), + label: const Text('Load File'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf mfu esave -f emulator.mfu'), + icon: const Icon(Icons.save), + label: const Text('Save to File'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf mfu eview'), + icon: const Icon(Icons.visibility), + label: const Text('View'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf mfu sim'), + icon: const Icon(Icons.phone_android), + label: const Text('Simulate'), + ), + ], + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.6 Tools Tab + +```dart +Widget _buildToolsTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ElevatedButton.icon( + onPressed: () => _execute('hf mfu keygen'), + icon: const Icon(Icons.key), + label: const Text('Generate Key'), + ), + ElevatedButton.icon( + onPressed: () => _execute('hf mfu pwdgen'), + icon: const Icon(Icons.lock), + label: const Text('Generate Password'), + ), + ElevatedButton.icon( + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Set UID'), + content: TextField( + controller: TextEditingController(text: _uid), + onChanged: (value) => _uid = value, + decoration: const InputDecoration( + hintText: '14-digit hexadecimal UID', + helperText: 'Example: 0123456789ABC', + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9A-Fa-f]')), + LengthLimitingTextInputFormatter(14), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + _execute('hf mfu setuid -u $_uid'); + }, + child: const Text('Confirm'), + ), + ], + ), + ); + }, + icon: const Icon(Icons.edit), + label: const Text('Set UID'), + ), + ], + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.7 Result Display Component + +```dart +Widget _buildResultDisplay() { + return Expanded( + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + child: Text( + _result.isEmpty ? 'Run a command to view results' : _result, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ), + ), + ), + ); +} +``` + +--- + +## 4. UI/UX Design + +### 4.1 Color Usage +- **Primary color**: `AppTheme.accentBlue` (#38BDF8) +- **Background color**: `AppTheme.darkBg` (#0F172A) +- **Card color**: `AppTheme.darkSurface` (#1E293B) +- **Text color**: `AppTheme.auxiliaryGrey` (#94A3B8) +- **Error color**: `#F87171` + +### 4.2 Interaction Design +- **Button styles**: Use `ElevatedButton` and `OutlinedButton` +- **Input validation**: Validate hexadecimal format and length in real time +- **Loading state**: Display a `CircularProgressIndicator` while a command is running +- **Result display**: Display command output in a monospaced font +- **Dangerous operations**: Require a confirmation dialog for dangerous operations such as wiping +- **File selection**: Use the `file_picker` plugin to select files + +### 4.3 Responsive Design +- Adapt to different screen sizes +- Automatically adjust the layout on small screens +- Preserve the tappable areas of action buttons + +--- + +## 5. Dependencies + +| Dependency | Purpose | Version | +|------|------|------| +| `flutter` | Core framework | 3.24+ | +| `provider` | State management | ^6.0.0 | +| `file_picker` | File selection | ^5.0.0 | +| `pm3gui` | Internal library | - | + +--- + +## 6. Test Plan + +### 6.1 Functional Tests +- [ ] All buttons send the correct commands +- [ ] Input validation works correctly +- [ ] Results display correctly +- [ ] File selection works correctly +- [ ] Confirmation dialogs for dangerous operations work correctly + +### 6.2 Integration Tests +- [ ] The page loads correctly +- [ ] Tab switching works correctly +- [ ] There are no conflicts with other pages +- [ ] Theme styles are applied correctly + +### 6.3 Boundary Tests +- [ ] Block number boundary values (0, 255) +- [ ] Data length boundary values +- [ ] Password length boundary values +- [ ] UID length boundary values +- [ ] Prompt when no device is connected + +--- + +## 7. Code Optimization + +### 7.1 Performance Optimization +- Use `const` constructors to reduce rebuilds +- Optimize state updates to avoid unnecessary repaints +- Use `SingleTickerProviderStateMixin` to improve animation performance + +### 7.2 Code Quality +- Add detailed comments +- Extract duplicated code into separate methods +- Follow Dart code style guidelines +- Ensure comprehensive error handling + +--- + +## 8. Compatibility + +### 8.1 Device Compatibility +- Support all devices that run Flutter +- Adapt to phones, tablets, and desktop platforms + +### 8.2 Proxmark3 Compatibility +- Support Iceman firmware +- Support all Mifare Ultralight and NTAG cards +- Command compatibility is automatically inherited from the PM3 client + +--- + +## 9. Summary + +The Mifare Ultralight/NTAG page provides a complete set of card operations, including reading information, reading and writing data, NDEF operations, and card simulation. The page uses a tabbed layout with a clear structure and intuitive controls. Integration with the `HfMfuCmd` command class provides full compatibility with the PM3 client while offering a user-friendly interface. + +The page implementation follows Flutter best practices and uses a modern Material 3 design to provide a good user experience. diff --git a/docs.en/lf_hid_page_spec.md b/docs.en/lf_hid_page_spec.md new file mode 100644 index 0000000..8d45987 --- /dev/null +++ b/docs.en/lf_hid_page_spec.md @@ -0,0 +1,397 @@ +# HID Prox Page Documentation + +> Historical design reference (pre-PR #27). The diagrams, examples, and feature goals +> below do not establish the current implementation or physical-card verification +> status; new or modified interfaces must follow the [Chinese/English ARB and validation contract](localization.md). +> See the [README](../README.en.md) for current usage and supported scope. +> +> `lf hid brute` enumerates facility codes/card numbers against a reader; it does not perform cryptographic key recovery. +> The current interface already uses the dedicated `hidReaderBruteForceTab`, `hidReaderBruteForceTitle`, and `hidStartBruteForce` +> resources, covered by [Chinese/English regression tests](../test/ui/lf_hid_page_test.dart); +> see the [localization guide](localization.md) for behavior and validation scope. + +## 1. Page Overview + +### 1.1 Feature Goals +Implement a complete interface for operating HID Prox low-frequency cards, including reading information, cloning cards, simulating cards, and brute forcing. + +### 1.2 Technical Implementation +- **File**: [`lib/ui/pages/lf_hid_page.dart`](../lib/ui/pages/lf_hid_page.dart) +- **Class name**: `LfHidPage` +- **Extends**: `StatefulWidget` +- **Core command class**: `LfHidCmd` + +--- + +## 2. Page Structure + +### 2.1 Overall Layout +``` +┌─────────────────────────────────────────────────────────────┐ +│ TabBar: [Read] [Clone/Simulate] [Cracking] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Tab 1 - Read: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ [Read HID Card] [Demodulate Signal] │ │ +│ │ Result display area... │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ Tab 2 - Clone/Simulate: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Card Number Data: [________________] │ │ +│ │ [Clone to T55xx] [Simulate Card] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ Tab 3 - Cracking: │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ FC: [____] (optional) │ │ +│ │ [Brute Force] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 Component Structure + +```dart +class LfHidPage extends StatefulWidget { + const LfHidPage({super.key}); + @override + State createState() => _LfHidPageState(); +} + +class _LfHidPageState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + // State variables + String _cardData = ''; + String _fc = ''; + String _result = ''; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + // Command execution method + void _execute(String cmd) { + final appState = context.read(); + if (!appState.isConnected) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('PM3 not connected')), + ); + return; + } + + setState(() { + _isLoading = true; + _result = ''; + }); + + appState.sendCommand(cmd).then((_) { + setState(() { + _isLoading = false; + _result = appState.terminalOutput.last; + }); + }); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Read'), + Tab(text: 'Clone/Simulate'), + Tab(text: 'Cracking'), + ], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildReadTab(), + _buildCloneSimTab(), + _buildCrackTab(), + ], + ), + ), + ], + ); + } + + // Builder methods for each tab + Widget _buildReadTab() { ... } + Widget _buildCloneSimTab() { ... } + Widget _buildCrackTab() { ... } +} +``` + +--- + +## 3. Feature Implementation + +### 3.1 Core Command Mapping + +| Button | Command | Description | +|------|------|------| +| Read HID Card | `lf hid reader` | Read an HID Prox card | +| Demodulate Signal | `lf hid demod` | Demodulate an HID signal | +| Clone to T55xx | `lf hid clone -w ` | Clone to a T55xx card | +| Simulate Card | `lf hid sim -w ` | Simulate an HID card | +| Brute Force | `lf hid brute [-f ]` | Brute force HID cards | + +### 3.2 Read Tab + +```dart +Widget _buildReadTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ElevatedButton.icon( + onPressed: () => _execute('lf hid reader'), + icon: const Icon(Icons.nfc), + label: const Text('Read HID Card'), + ), + ElevatedButton.icon( + onPressed: () => _execute('lf hid demod'), + icon: const Icon(Icons.radio), + label: const Text('Demodulate Signal'), + ), + ], + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.3 Clone/Simulate Tab + +```dart +Widget _buildCloneSimTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + children: [ + const Text('Card Number Data: ', style: TextStyle(fontSize: 14)), + Expanded( + child: TextFormField( + controller: TextEditingController(text: _cardData), + onChanged: (value) => _cardData = value, + decoration: const InputDecoration( + hintText: 'Wiegand-format data', + helperText: 'Example: 26bit-format data', + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9]')), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ElevatedButton.icon( + onPressed: () => _execute('lf hid clone -w $_cardData'), + icon: const Icon(Icons.copy), + label: const Text('Clone to T55xx'), + ), + ElevatedButton.icon( + onPressed: () => _execute('lf hid sim -w $_cardData'), + icon: const Icon(Icons.phone_android), + label: const Text('Simulate Card'), + ), + ], + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.4 Cracking Tab + +```dart +Widget _buildCrackTab() { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + children: [ + const Text('FC: ', style: TextStyle(fontSize: 14)), + Expanded( + child: TextFormField( + controller: TextEditingController(text: _fc), + onChanged: (value) => _fc = value, + decoration: const InputDecoration( + hintText: ' facility code (optional)', + helperText: 'Example: 123', + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9]')), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () { + final cmd = _fc.isNotEmpty + ? 'lf hid brute -f $_fc' + : 'lf hid brute'; + _execute(cmd); + }, + icon: const Icon(Icons.lock_open), + label: const Text('Brute Force'), + ), + const SizedBox(height: 16), + _buildResultDisplay(), + ], + ), + ); +} +``` + +### 3.5 Result Display Component + +```dart +Widget _buildResultDisplay() { + return Expanded( + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + child: Text( + _result.isEmpty ? 'Run a command to view results' : _result, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ), + ), + ), + ); +} +``` + +--- + +## 4. UI/UX Design + +### 4.1 Color Usage +- **Primary color**: `AppTheme.accentBlue` (#38BDF8) +- **Background color**: `AppTheme.darkBg` (#0F172A) +- **Card color**: `AppTheme.darkSurface` (#1E293B) +- **Text color**: `AppTheme.auxiliaryGrey` (#94A3B8) +- **Error color**: `#F87171` + +### 4.2 Interaction Design +- **Button styles**: Use `ElevatedButton` and `OutlinedButton` +- **Input validation**: Validate numeric format in real time +- **Loading state**: Display a `CircularProgressIndicator` while a command is running +- **Result display**: Display command output in a monospaced font +- **FC input**: Support optional facility code input + +### 4.3 Responsive Design +- Adapt to different screen sizes +- Automatically adjust the layout on small screens +- Preserve the tappable areas of action buttons + +--- + +## 5. Dependencies + +| Dependency | Purpose | Version | +|------|------|------| +| `flutter` | Core framework | 3.24+ | +| `provider` | State management | ^6.0.0 | +| `pm3gui` | Internal library | - | + +--- + +## 6. Test Plan + +### 6.1 Functional Tests +- [ ] All buttons send the correct commands +- [ ] Input validation works correctly +- [ ] Results display correctly +- [ ] Optional FC input works correctly + +### 6.2 Integration Tests +- [ ] The page loads correctly +- [ ] Tab switching works correctly +- [ ] There are no conflicts with other pages +- [ ] Theme styles are applied correctly + +### 6.3 Boundary Tests +- [ ] Card number data length boundary values +- [ ] FC input boundary values +- [ ] Prompt when no device is connected + +--- + +## 7. Code Optimization + +### 7.1 Performance Optimization +- Use `const` constructors to reduce rebuilds +- Optimize state updates to avoid unnecessary repaints +- Use `SingleTickerProviderStateMixin` to improve animation performance + +### 7.2 Code Quality +- Add detailed comments +- Extract duplicated code into separate methods +- Follow Dart code style guidelines +- Ensure comprehensive error handling + +--- + +## 8. Compatibility + +### 8.1 Device Compatibility +- Support all devices that run Flutter +- Adapt to phones, tablets, and desktop platforms + +### 8.2 Proxmark3 Compatibility +- Support Iceman firmware +- Support all HID Prox cards +- Command compatibility is automatically inherited from the PM3 client + +--- + +## 9. Summary + +The HID Prox page provides a complete set of card operations, including reading information, cloning cards, simulating cards, and brute forcing. The page uses a tabbed layout with a clear structure and intuitive controls. Integration with the `LfHidCmd` command class provides full compatibility with the PM3 client while offering a user-friendly interface. + +The page implementation follows Flutter best practices and uses a modern Material 3 design to provide a good user experience. diff --git a/docs.en/localization.md b/docs.en/localization.md new file mode 100644 index 0000000..93b2c42 --- /dev/null +++ b/docs.en/localization.md @@ -0,0 +1,191 @@ +# Localization + +## PR #27 scope + +PR #27 adds English alongside Simplified Chinese without changing PM3 commands, +protocol identifiers, dump formats or raw client output. There are currently +701 messages in each catalog; `tools/check_localizations.dart` reports the +authoritative count. This is development/Unreleased content, not evidence that +an older release archive contains these features. + +Choose **Settings → Language** to follow the system, select **简体中文** +(Simplified Chinese), or select **English**. Unsupported system languages fall +back to English; Chinese regional/script variants use the shipped Simplified +Chinese bundle. Framework menus/dialogs use the app locale, while native file +dialogs keep the OS language. Previously captured terminal output is not +rewritten when the language changes. + +## Design + +This project follows Flutter's official +[internationalization guide](https://docs.flutter.dev/ui/internationalization): + +- `flutter_localizations` supplies framework translations and text direction. +- `lib/l10n/app_en.arb` is the template; `app_zh.arb` preserves Simplified Chinese. +- `l10n.yaml` configures `flutter gen-l10n`; generated Dart files live beside + the ARB files and are not committed. +- `MaterialApp` uses generated delegates, supported locales and + `onGenerateTitle`. English is the fallback for unsupported system languages. +- UI code reads `context.l10n`. Stateful pages remain mounted when language + changes; navigation, text controllers, draft data and connections are retained. +- Domain objects receive localization explicitly through `updateLocalizations` + or optional `localizations` arguments. There is no mutable global locale or + run-time dictionary that replaces text in hardware output. The immutable + Chinese default preserves existing non-UI caller behavior. +- Dump-analysis caches include the locale; terminal suggestions are reloaded + on locale changes with stale asynchronous results discarded. +- Write-back progress stores a locale-independent status, current block and + per-block outcomes. The viewer formats these with `context.l10n` on display, + so active, finished, cancelled and connection-lost sequences follow language + changes without clearing results or restarting commands. Raw terminal output + remains unchanged. +- `Pm3Process` depends only on Dart SDK libraries and the pure-Dart + `Pm3ProcessMessages` contract. Its Chinese defaults preserve standalone-tool + behavior. `DesktopCliBackend` injects `LocalizedPm3ProcessMessages` to adapt + generated `AppLocalizations` without pulling Flutter into the process layer. + Retained connection and lifecycle errors store message kinds and diagnostic + arguments; `lastError` formats them through the current adapter. Changing + language refreshes the Connection error card without retrying the connection, + emitting new output or rewriting historical terminal/session logs. + Keep these defaults in sync with the Chinese ARB catalog; the message-contract + tests verify every process message. +- Staging `CommandCard` instances inherit the active localizations before + validating imported blocks or keys. Failed draft/card imports are atomic. +- Editor save confirmations retain the saved path and format `messageSaved` + with the current locale when displayed, without saving the file again. + Successful draft loads clear the status; save/import failures replace it; + cancelled file dialogs leave the existing status unchanged. +- The DESFire Applications tab uses `desfireApplicationsTab`; the editor's Apply + verb remains `messageApply`. Shared source-language spelling is not sufficient + reason to merge resources. +- HID Prox reader brute-force uses the dedicated `hidReaderBruteForceTab`, + `hidReaderBruteForceTitle` and `hidStartBruteForce` resources. These describe + facility-code/card-number enumeration against a reader, not cryptographic key + recovery. Shared key-recovery wording remains available for genuine + key-recovery workflows. + +## Editing messages + +Give messages stable, semantic camelCase keys. Localize complete sentences: +do not concatenate translated word fragments, use display text as a storage +key, or decide application behavior by comparing translated strings. +Keep different meanings in separate resources even when one language spells +them identically, such as the DESFire "Applications" noun and editor "Apply" verb. + +Each template entry requires `@messageKey` translator metadata. Interpolations +use named ARB placeholders with explicit Dart types. Counts with grammatical +variants use ICU plurals, for example: + +```json +{ + "messageCommands": "{value, plural, =0{No commands} =1{1 command} other{{value} commands}}", + "@messageCommands": { + "description": "Number of commands in terminal history.", + "placeholders": { + "value": {"type": "int"} + } + } +} +``` + +Keep placeholder names identical across languages, but allow translators to +reorder them. Hex values, commands, protocol names, serialized field names and +exported data must remain unchanged. Raw client output and OS diagnostic +details are not machine-translated. + +After editing, run these commands from the repository root: + +```powershell +flutter gen-l10n +dart run tools\check_localizations.dart +flutter analyze --no-pub --no-fatal-infos +flutter test --no-pub +``` + +The resource check rejects missing/extra/empty messages, missing template +metadata and untranslated Chinese in the English catalog. `gen-l10n` validates +ICU syntax and typed placeholders. Tests cover both languages on all 30 pages, +live switching, data retention, persistence, failure reporting, locale fallback, +framework strings, command-help language and locale-aware analysis. +The current page/tab smoke tests use a 1500 × 1100 viewport; the dedicated +DESFire/HID label tests use 1200 × 900. Loaded-dump write-progress tests use +1600 × 1100 to accommodate the fixed-width test font in the existing toolbar. +These are not a substitute for exercising +default/smaller desktop windows, text scaling, dialogs and real hardware. + +The focused regression files are: + +| Feature | Coverage | +|---|---| +| Preference loading, serialized saves and retries | `test/l10n/app_locale_controller_test.dart` | +| Directory exception conversion and diagnostics | `test/services/locale_settings_store_test.dart` | +| Startup Settings error, switching and page/tab rendering | `test/l10n/localization_test.dart` | +| Standalone message defaults, Flutter adapter and retained connection errors | `test/l10n/pm3_process_messages_test.dart` | +| Connection error card after Settings language changes, without retry/output changes | `test/l10n/localization_test.dart` | +| Retained lifecycle errors with replacement framework-free messages | `test/backend/pm3_process_test.dart` | +| English import errors and atomicity | `test/models/command_card_test.dart` | +| Retained editor save status, cancellation, successful-load clearing and error replacement | `test/ui/command_card_editor_test.dart` | +| Editor save confirmation after Settings language changes, without another save | `test/l10n/localization_test.dart` | +| Applications versus Apply | `test/ui/hf_mfdes_page_test.dart` | +| HID reader brute-force labels, language switching and unchanged command syntax | `test/ui/lf_hid_page_test.dart` | +| Locale-independent write states, mixed block results, cancellation and connection loss | `test/state/write_progress_test.dart` | +| Retained write-back progress/results after Settings language changes | `test/l10n/localization_test.dart` | +| Actual standalone Dart usage/failure paths | `test/tools/sync_command_catalog_test.dart` | + +The existing Windows batch-handshake fixture has an intermittent failure that +was also reproduced against unchanged upstream code. Report it separately from +new regressions; do not weaken its assertions or claim a fully clean run when +it fails. Linux builds and real-device operations require their own evidence. + +Use wrapping or flexible layouts for captions that expand in translation. +Do not shorten safety warnings to make them fit. + +## Adding a language + +1. Copy the template to `lib/l10n/app_.arb`, set `@@locale`, and + translate every message while preserving placeholders. +2. Add the language's native name to the selector in + `lib/ui/pages/settings_page.dart`. Native language names are deliberately + constant so users can recover from choosing an unfamiliar language. +3. Run the commands above and exercise the new locale, including dialogs, + errors, larger text and long captions. +4. If translated command help is available, add its asset and update + `Pm3CommandCatalog` selection. Otherwise document that command help remains + in English. Do not translate command syntax. + +Generated `supportedLocales` and the preference controller recognize languages +from the ARB files, so a separate hard-coded locale list is not required. + +## Preference storage + +`LocaleSettingsStore` writes `language.json` in the application support +directory using an exclusive temporary file, a flushed write and same-directory +replacement. `null` means system default. Writes are serialized and flushed +during application exit. Missing files are normal; corrupt, unsupported or +unwritable preferences produce an explicit Settings error. +Path-provider directory-lookup exceptions are converted to storage errors while +preserving their diagnostics and stack traces. Initialization can therefore +continue to the UI, and failed writes do not prevent later saves from retrying. +The current-session selection remains active after a save failure, but is not +guaranteed to survive restart until a later save succeeds. Resolve the +directory/permission problem and choose a language again to retry; there is no +automatic background retry or silent fallback to the working directory. + +The stored JSON is `{"language":"en"}`, `{"language":"zh"}` or +`{"language":null}`. Do not translate these codes or the `language` field name. + +## Standalone command-catalog synchronization + +After `flutter pub get`, `dart run tools\sync_command_catalog.dart` runs on the +standalone Dart VM. With no arguments it prints usage and exits with code 2; +with a client it connects, collects read-only help and writes the snapshot on +success (exit 0). Connection/collection errors return 1. + +For Windows, pass the distribution-root `pm3.bat`; for a native client, also +pass its port. This tool does not read `language.json`: it uses the pure-Dart +Chinese diagnostic defaults. It does not load generated Flutter localizations +or translate client output. GUI command help separately selects the bundled +English/Chinese YAML and augments it with the client snapshot. + +Language settings are separate from Proxmark3 launch settings and do not change +session records, device data, dump formats or client configuration. diff --git a/docs.en/refactoring_plan.md b/docs.en/refactoring_plan.md new file mode 100644 index 0000000..f8348d3 --- /dev/null +++ b/docs.en/refactoring_plan.md @@ -0,0 +1,178 @@ +# PM3 GUI: Current Architecture and Roadmap + +This document describes the current implementation baseline and the roadmap. +It is not a commitment to provide a separate page for every Proxmark3 protocol. +Feature priorities are guided by maintainability, common workflows and +compatibility with the official client. + +## Platform and version boundaries + +- Windows x64 is the primary platform for 1.0. +- Linux x64 is an officially supported platform for 1.0. +- Android, iOS, Web and macOS are outside the supported scope of 1.0. +- PM3 GUI does not bundle firmware or client binaries. Windows uses `pm3.bat` + from the root of the official distribution; Linux uses the user's installed + `proxmark3`. +- The existing, limited hardware records come from Windows x64, using the + distribution corresponding to RRG commit `da509461` and a PM3EASY512K + (`PM3 GENERIC / AT91SAM7S512 / 512K`). They cover BAT connection, prompt + recognition, read-only `hw version`, disconnection and the Mifare page's + read-only detection on 2026-09-05. These are not new hardware validations + performed for PR #27 and cannot be extrapolated to Linux, other devices, + card writing/erasure or complete firmware interaction. + +## Current architecture + +```text +Flutter UI / Feature + | + v + Pm3Controller + | + v + Pm3Backend + | + v +DesktopCliBackend + | + v +Pm3Process (internal process adapter) + | + v +Official Proxmark3 client +``` + +`Pm3Backend` is the contract between the desktop implementation and the +upper-level state/UI. `DesktopCliBackend` maps the official client process to +connection state, events and command results. The backend and the standalone +Dart help-synchronization tool reuse `Pm3Process`; `dart:io Process` details +do not leak into widgets or features. + +### PR #27: UI languages and the tooling boundary + +The UI uses Flutter ARB / `gen-l10n` resources and offers Simplified Chinese, +English and system-default selections. Unsupported system languages fall back +to English. `AppLocaleController` manages the current selection, persistence +state and errors; `MaterialApp` uses generated delegates and supported locales. +Changing the language does not recreate connections, page drafts or text +controllers. + +`AppLocalizations` stays on the Flutter side. `DesktopCliBackend` adapts it to +the pure-Dart `Pm3ProcessMessages`, so the process layer and +`tools/sync_command_catalog.dart` do not depend on `dart:ui`. Standalone tools +retain their Chinese diagnostic defaults. Commands, hexadecimal data and raw +client output are not translated at runtime. + +Language preferences are stored separately in `language.json` in the +application support directory. The `language` value is `en`, `zh` or `null` +(system default). Writes are queued and replace the target using a temporary +file in the same directory; application exit waits for the queue to finish. +Directory-lookup and read/write errors appear in Settings without preventing +initialization. A new selection can retry saving after the directory issue is +fixed; there is no fallback to the working directory. + +When importing a draft or card, the temporary `CommandCard` used for validation +inherits the current language, and a failed import does not overwrite existing +data. Dump-analysis caches include the locale. Terminal command help reloads +when the language changes and discards stale asynchronous results. See +[Localization](localization.md) for the maintenance contract for new messages +and languages. + +### Client settings + +The Connection page selects and persists the serial/device port. Settings +manages these client fields: + +- Client executable path. +- Client startup arguments, entered one argument per line in Settings without + shell tokenization or escaping. +- Optional working directory. + +Settings are stored as JSON in `pm3_settings.json` in the application support +directory. Saving first writes a temporary file in the same directory and then +atomically replaces the target. Application shutdown waits for initialization +and the final queued write; the state layer consumes and displays save errors. +The actual root directory comes from `path_provider`'s +`getApplicationSupportDirectory()`, so it varies with the operating system and +user account. Do not hard-code it to the project directory or any fixed +absolute path. + +### Local sessions + +Each successful connection creates a local session directory: + +```text +/sessions/YYYY-MM-DD_HHmmss_SSS/ +|-- session.json # Port, client path, start/end times +|-- terminal.log # Client output, appended line by line +`-- commands.jsonl # Issued commands, one JSON record per line +``` + +Writes use a serial queue. On disconnection or application shutdown, the +backend first completes process exit, then closes the session and waits for +trailing writes. This preserves a useful local diagnostic record. Sessions are +not uploaded. Terminal output may contain sensitive information such as UIDs, +device details or command arguments; users should protect the application +support directory according to their local security requirements. + +### Process lifecycle + +Connection handles early client exit and startup failure. Disconnection first +requests a graceful client exit, waits for a bounded interval and only then +forces termination. It also cleans up stdout/stderr subscriptions and process +references. On Windows, forced termination targets only the exact PID already +held by the application and terminates that PID's process tree. On POSIX, TERM +is sent first, escalating to KILL after a bounded wait. Application shutdown +waits for settings writes, the session queue, application subscriptions and +backend resources to be released. + +## Feature coverage strategy + +Dedicated GUI pages cover only frequently used, stable workflows that benefit +from structured interaction. Infrequently used protocols, diagnostic commands +and new official-client commands remain available through the Terminal page. +This preserves full command access without maintaining duplicate pages and +incomplete parameter wrappers for every protocol. + +Current navigation groups general functions, high-frequency (HF), +low-frequency (LF) and tools. Existing pages and command mappings are defined +by `lib/ui/pages/`, `lib/services/pm3_commands.dart`, +`docs/pm3_commands*.yaml` and `docs/pm3_commands_client.json` in the repository. +The number of protocol pages is not the coverage metric for 1.0. + +## Next phase + +The next phase begins with two reusable registries: + +1. **Command Registry:** unify command metadata, parameter definitions, + capability flags and UI availability. +2. **Parser Registry:** unify output parsers, structured results and + version-compatibility strategies. + +Once the registries are stable, add a small number of dedicated GUI pages +based on actual usage frequency. Infrequent or not-yet-modeled commands +continue to use Terminal. Each extension should preserve passthrough access to +the official client and add the corresponding tests and documentation. + +## Validation boundaries + +Dart formatting checks, Flutter static analysis, the complete Flutter test +suite and a Windows debug build on a Windows x64 workstation provide +code-level validation of the desktop foundation. The repository's CI workflow +also declares a Linux x64 build path, but local results do not replace an +actual build on a Linux runner. + +PR #27's CI generates and checks localization resources on Windows and Linux. +Static analysis and the complete test suite run in the Linux job. See +[Localization](localization.md) for the specific language, storage-recovery, +import and UI regression tests and their limitations. References to earlier +hardware records do not mean that all hardware acceptance checks were +repeated for these localization changes. + +The existing hardware records cover only the Windows distribution +corresponding to RRG commit `da509461` and one PM3EASY512K +(`PM3 GENERIC / AT91SAM7S512 / 512K`): the root-level `pm3.bat` can connect, +produce a real prompt, execute read-only `hw version` and disconnect. The +2026-09-05 record also includes the Mifare page's Detect card operation +displaying ATQA/SAK. There is still no acceptance evidence here for Linux, +other devices, card writing/erasure or complete firmware interaction. diff --git a/docs.en/spec_new_features.md b/docs.en/spec_new_features.md new file mode 100644 index 0000000..3b0565d --- /dev/null +++ b/docs.en/spec_new_features.md @@ -0,0 +1,494 @@ +# PM3GUI New Feature Page Development Plan + +> This is an original feature-design reference from before PR #27, not a +> statement of current feature or platform completion. The original Chinese +> layouts and code examples are translated here as design illustrations. +> Current implementations must use the English/Chinese resources, message +> injection and validation behavior specified in [Localization](localization.md); +> do not reintroduce hard-coded Chinese UI text. +> See the [documentation index](README.md) for current usage and support scope. + +## 1. Project Overview + +### 1.1 Goal +Add new feature pages to PM3GUI to expand coverage of Proxmark3 commands, +prioritizing common high-frequency (HF) and low-frequency (LF) card functions +that do not yet have GUI support. + +### 1.2 Design Principles +- **Consistency**: Follow the existing pages' design style and code patterns. +- **Incremental delivery**: Implement the most frequently used functions first, + then expand them gradually. +- **Extensibility**: Use an architecture that makes it easy to add support for + more protocols later. + +--- + +## 2. Planned New Feature Pages + +### 2.1 Page Priorities + +| Priority | Page Name | Protocol | Rationale | +|:--------:|:---------:|:--------:|:----------| +| P0 | **Mifare Ultralight/NTAG** | hf mfu | One of the most common HF card families, with widespread use | +| P0 | **iCLASS/Picopass** | hf iclass | Common in access-control systems, with strong demand | +| P1 | **ISO15693** | hf 15 | Common in industrial and library tags | +| P1 | **Mifare DESFire** | hf mfdes | Advanced secure cards | +| P1 | **HID Prox** | lf hid | The most common LF access-control cards | +| P2 | **FeliCa** | hf felica | Common in Japan | +| P2 | **Hitag** | lf hitag | Applications such as vehicle keys | + +--- + +## 3. Detailed Design Specifications + +### 3.1 Mifare Ultralight/NTAG Page (hf_mfu_page.dart) + +#### 3.1.1 Functional Scope +Based on the 17 commands defined in `HfMfuCmd`: +- `info()` - Tag information +- `dump()` - Dump card data +- `rdbl()` - Read a block +- `wrbl()` - Write a block +- `restore()` - Restore a card +- `view()` - View a dump file +- `wipe()` - Erase a card +- `ndefRead()` - Read NDEF data +- `keygen()` - Generate keys +- `pwdgen()` - Generate a password +- `cauth()` - Authenticate +- `cchk()` - Check passwords +- `sim()` - Simulate a card +- `eload()` - Load into the emulator +- `esave()` - Save emulator data +- `eview()` - View the emulator +- `setuid()` - Set the UID + +#### 3.1.2 Page Layout +```text ++-------------------------------------------------------------+ +| TabBar: [Information] [Read/Write] [NDEF] [Emulator] [Tools] | ++-------------------------------------------------------------+ +| | +| Tab 1 - Information: | +| +-------------------------------------------------------+ | +| | [Get information] [Dump card] [Erase card] | | +| +-------------------------------------------------------+ | +| | +| Tab 2 - Read/Write: | +| +-------------------------------------------------------+ | +| | Block: [0-255 v] [Read block] | | +| | Data: [________________] [Write block] | | +| | Password: [________________] [Authenticate] | | +| +-------------------------------------------------------+ | +| | +| Tab 3 - NDEF: | +| +-------------------------------------------------------+ | +| | [Read NDEF] Result-display area... | | +| +-------------------------------------------------------+ | +| | +| Tab 4 - Emulator: | +| +-------------------------------------------------------+ | +| | [Load file] [Save to file] [View] [Simulate] | | +| +-------------------------------------------------------+ | +| | +| Tab 5 - Tools: | +| +-------------------------------------------------------+ | +| | [Generate key] [Generate password] [Set UID] | | +| +-------------------------------------------------------+ | +| | ++-------------------------------------------------------------+ +``` + +#### 3.1.3 Key Implementation Details +- Block-number range: 0-255, the maximum supported by NTAG216 +- Data format: eight hexadecimal bytes, or 16 characters +- Password format: four hexadecimal bytes, or eight characters +- UID format: seven hexadecimal bytes, or 14 characters + +--- + +### 3.2 iCLASS/Picopass Page (hf_iclass_page.dart) + +#### 3.2.1 Functional Scope +Use `HfIclassCmd`, prioritizing core functions: +- `info()` - Tag information +- `reader()` - Reader mode +- `dump()` - Dump a card +- `rdbl()` - Read a block +- `wrbl()` - Write a block +- `restore()` - Restore a card +- `view()` - View a dump +- `sniff()` - Sniff +- `chk()` - Check keys +- `loclass()` - Offline key recovery +- `sim()` - Simulate +- `eload()/esave()/eview()` - Emulator operations + +#### 3.2.2 Page Layout +```text ++-------------------------------------------------------------+ +| TabBar: [Information] [Read/Write] [Key Recovery] [Emulator] | ++-------------------------------------------------------------+ +| | +| Tab 1 - Information: | +| +-------------------------------------------------------+ | +| | [Get information] [Read card] [Dump card] | | +| +-------------------------------------------------------+ | +| | +| Tab 2 - Read/Write: | +| +-------------------------------------------------------+ | +| | Block: [0-31 v] Key: [________________] [Read] | | +| | Data: [________________] [Write] | | +| +-------------------------------------------------------+ | +| | +| Tab 3 - Key Recovery: | +| +-------------------------------------------------------+ | +| | [Check keys] [Loclass attack] [Sniff communication] | | +| +-------------------------------------------------------+ | +| | +| Tab 4 - Emulator: | +| +-------------------------------------------------------+ | +| | [Load] [Save] [View] [Simulate] | | +| +-------------------------------------------------------+ | +| | ++-------------------------------------------------------------+ +``` + +#### 3.2.3 Key Implementation Details +- Block-number range: 0-31, for iCLASS 2K +- Key format: eight hexadecimal bytes, or 16 characters +- Data format: eight hexadecimal bytes, or 16 characters + +--- + +### 3.3 ISO15693 Page (hf_15_page.dart) + +#### 3.3.1 Functional Scope +Use `Hf15Cmd` to implement core functions: +- `reader()` - Reader +- `info()` - Tag information +- `dump()` - Dump +- `restore()` - Restore +- `rdbl()` - Read a block +- `wrbl()` - Write a block +- `view()` - View a dump +- `wipe()` - Erase +- `sniff()` - Sniff +- `sim()` - Simulate +- `findafi()` - Find the AFI +- `csetuid()` - Set the UID + +#### 3.3.2 Page Layout +Use a structure similar to the Mifare page, with: +- Information tab: reader, information and dump +- Read/Write tab: block read/write operations +- Tools tab: AFI search, UID setting and simulation + +--- + +### 3.4 Mifare DESFire Page (hf_mfdes_page.dart) + +#### 3.4.1 Functional Scope +Use `HfMfdesCmd` to implement basic functions: +- `info()` - Tag information +- `detect()` - Detect +- `getuid()` - Get the UID +- `freemem()` - Free memory +- `chk()` - Check keys +- `auth()` - Authenticate +- `formatpicc()` - Format +- `getaids()` - Get the AID list +- `lsapp()` - List applications +- `selectapp()` - Select an application +- `dump()` - Dump +- `read()` - Read a file +- `write()` - Write a file + +#### 3.4.2 Page Layout +```text ++-------------------------------------------------------------+ +| TabBar: [Information] [Applications] [Files] [Management] | ++-------------------------------------------------------------+ +| | +| Tab 1 - Information: | +| +-------------------------------------------------------+ | +| | [Detect] [Information] [UID] [Free memory] [Check keys] | | +| +-------------------------------------------------------+ | +| | +| Tab 2 - Applications: | +| +-------------------------------------------------------+ | +| | [List AIDs] [List applications] | | +| | AID: [________] [Select application] | | +| +-------------------------------------------------------+ | +| | +| Tab 3 - Files: | +| +-------------------------------------------------------+ | +| | [List files] [Dump application] | | +| | FID: [____] [Read] [Write] | | +| +-------------------------------------------------------+ | +| | +| Tab 4 - Management: | +| +-------------------------------------------------------+ | +| | [Authenticate] [Format PICC] | | +| +-------------------------------------------------------+ | +| | ++-------------------------------------------------------------+ +``` + +--- + +### 3.5 HID Prox Page (lf_hid_page.dart) + +#### 3.5.1 Functional Scope +Based on `LfHidCmd`: +- `reader()` - Read +- `demod()` - Demodulate +- `clone()` - Clone +- `sim()` - Simulate +- `brute()` - Brute-force + +#### 3.5.2 Page Layout +```text ++-------------------------------------------------------------+ +| TabBar: [Read] [Clone/Simulate] [Brute-force] | ++-------------------------------------------------------------+ +| | +| Tab 1 - Read: | +| +-------------------------------------------------------+ | +| | [Read HID card] [Demodulate signal] | | +| | Result-display area... | | +| +-------------------------------------------------------+ | +| | +| Tab 2 - Clone/Simulate: | +| +-------------------------------------------------------+ | +| | Card data: [________________] | | +| | [Clone to T55xx] [Simulate card] | | +| +-------------------------------------------------------+ | +| | +| Tab 3 - Brute-force: | +| +-------------------------------------------------------+ | +| | FC: [____] (optional) | | +| | [Brute-force] | | +| +-------------------------------------------------------+ | +| | ++-------------------------------------------------------------+ +``` + +--- + +## 4. Technical Implementation Standards + +### 4.1 File Structure +```text +lib/ +|-- ui/ +| |-- pages/ +| | |-- hf_mfu_page.dart # NEW: Ultralight/NTAG +| | |-- hf_iclass_page.dart # NEW: iCLASS/Picopass +| | |-- hf_15_page.dart # NEW: ISO15693 +| | |-- hf_mfdes_page.dart # NEW: DESFire +| | |-- lf_hid_page.dart # NEW: HID Prox +| | `-- ... (existing pages) +| `-- home_page.dart # MODIFY: Add navigation entries +|-- services/ +| `-- pm3_commands.dart # EXISTING: Already contains the required commands +`-- state/ + `-- app_state.dart # EXISTING: No changes needed +``` + +### 4.2 Code Pattern + +Each new page follows this pattern: + +```dart +/// Operations page for the named feature. +library; + +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:pm3gui/state/app_state.dart'; +import 'package:pm3gui/services/pm3_commands.dart'; + +class XxxPage extends StatefulWidget { + const XxxPage({super.key}); + + @override + State createState() => _XxxPageState(); +} + +class _XxxPageState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + // ... State variables + + @override + void initState() { + super.initState(); + _tabController = TabController(length: N, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + void _execute(String cmd) { + final appState = context.read(); + if (!appState.isConnected) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('PM3 not connected')), + ); + return; + } + appState.sendCommand(cmd); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 2'), + // ... + ], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildTab1(), + _buildTab2(), + // ... + ], + ), + ), + ], + ); + } + // ... _buildTabX() methods +} +``` + +### 4.3 Navigation Integration + +In `home_page.dart`: + +```dart +// 1. Import the new pages. +import 'package:pm3gui/ui/pages/hf_mfu_page.dart'; +import 'package:pm3gui/ui/pages/hf_iclass_page.dart'; +// ... + +// 2. Add them to the _pages list. +final _pages = const [ + ConnectionPage(), + TerminalPage(), + DumpViewerPage(), + DumpComparePage(), + MifarePage(), + HfMfuPage(), // NEW + HfIclassPage(), // NEW + LfPage(), + LfHidPage(), // NEW + SettingsPage(), +]; + +// 3. Add them to the _navItems list. +static const _navItems = [ + _NavItem(Icons.usb, Icons.usb, 'Connection', 'Device connection'), + _NavItem(Icons.terminal, Icons.terminal, 'Terminal', 'Interactive terminal'), + _NavItem(Icons.file_open, Icons.file_open, 'Dump', 'View/edit dumps'), + _NavItem(Icons.compare_arrows, Icons.compare_arrows, 'Compare', 'Dump comparison'), + _NavItem(Icons.nfc, Icons.nfc, 'Mifare', 'Mifare Classic'), + _NavItem(Icons.memory, Icons.memory, 'NTAG', 'Ultralight/NTAG'), // NEW + _NavItem(Icons.badge, Icons.badge, 'iCLASS', 'iCLASS/Picopass'), // NEW + _NavItem(Icons.radio, Icons.radio, 'Low Frequency', 'LF operations'), + _NavItem(Icons.door_front_door_outlined, Icons.door_front_door_outlined, + 'HID', 'HID Prox'), // NEW + _NavItem(Icons.settings, Icons.settings, 'Settings', 'Application settings'), +]; +``` + +--- + +## 5. UI/UX Design Standards + +### 5.1 Colors +Continue the Morandi palette: +- Primary: `#7E9AAB` (Morandi blue) +- Success/HF: `#8FA9A0` (Morandi green) +- Warning/LF: `#BFA2A2` (Morandi rose) +- Secondary information: `#A89F91` (Morandi warm gray) + +### 5.2 Components +- **Action cards**: Use `Card` + `ListTile`, with an icon and subtitle. +- **Inputs**: Use `TextFormField`, with a monospace font for hexadecimal data. +- **Buttons**: Use `ElevatedButton` for primary actions and `OutlinedButton` + for secondary actions. +- **Segmented buttons**: Use `SegmentedButton` to switch options. + +### 5.3 Interaction Feedback +- Display a loading indicator while a command is running. +- Display results in a scrollable card. +- Support copying results to the clipboard. +- Require a confirmation dialog for dangerous operations such as erasing. + +--- + +## 6. Test Plan + +### 6.1 Functional Tests +- [ ] Tabs switch normally on every page. +- [ ] All buttons send the correct commands. +- [ ] Input validation covers hexadecimal format and length. +- [ ] Results can be displayed and copied. +- [ ] A prompt is shown when no device is connected. + +### 6.2 Integration Tests +- [ ] New pages integrate with sidebar navigation. +- [ ] Theme switching works normally. +- [ ] New pages coexist with existing pages without conflicts. + +--- + +## 7. Appendix + +### 7.1 Reference Resources +- [pm3_commands.yaml](../docs/pm3_commands.yaml) - Command-mapping reference +- [pm3_commands.dart](../lib/services/pm3_commands.dart) - Command wrapper classes +- [mifare_page.dart](../lib/ui/pages/mifare_page.dart) - Reference implementation + +### 7.2 Command Quick Reference + +```bash +# Mifare Ultralight +hf mfu info +hf mfu dump -f +hf mfu rdbl -b +hf mfu wrbl -b -d +hf mfu ndefread + +# iCLASS +hf iclass info +hf iclass dump -f +hf iclass rdbl -b [-k ] +hf iclass wrbl -b -d [-k ] + +# ISO15693 +hf 15 reader +hf 15 dump -f +hf 15 rdbl -b +hf 15 wrbl -b -d + +# HID Prox +lf hid reader +lf hid clone -w +lf hid sim -w +``` diff --git a/docs.en/superpowers/plans/2026-09-04-desktop-foundation.md b/docs.en/superpowers/plans/2026-09-04-desktop-foundation.md new file mode 100644 index 0000000..458f3aa --- /dev/null +++ b/docs.en/superpowers/plans/2026-09-04-desktop-foundation.md @@ -0,0 +1,391 @@ +# PM3 Desktop Foundation Implementation Plan + +> **Historical implementation plan (2026-09-04).** The steps and execution +> notes below record the development process at that time. They are not new +> instructions for the current agent to execute. See +> [Localization](../../localization.md) for PR #27's language-state, +> standalone-Dart tooling boundary and storage-recovery contracts. Follow +> [CONTRIBUTING](../../../CONTRIBUTING.en.md) for the current development +> workflow. + +> **For agent workers:** Required sub-skill: use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task by task. Steps use +> checkbox (`- [ ]`) syntax to track progress. + +**Goal:** Add Windows/Linux client-configuration persistence, local session +logs and reliable process exit on top of the completed `Pm3Backend` boundary, +giving Phase 2 a runnable and testable desktop foundation. + +**Architecture:** Configuration and sessions are local desktop capabilities. +Small services inject them into `ConnectionState`/`AppState` without exposing +process details to features or widgets. `Pm3Process` remains an internal adapter +for `DesktopCliBackend`, but explicitly owns stdout/stderr subscriptions, the +exit handshake and the dispose lifecycle. All new file writes are restricted +to the application support directory. They neither access the network nor +record key-vault credentials. + +**Technology stack:** Flutter Desktop, Dart `dart:io`/`dart:convert`, Provider, +`path_provider`, `file_selector`, `flutter_test`. + +--- + +## File structure + +- Create `lib/core/pm3/pm3_client_settings.dart`: serializable desktop client + settings. +- Create `lib/services/pm3_settings_store.dart`: load, validate and save the + JSON settings file. +- Modify `lib/state/connection_state.dart`: load/save the client path, working + directory, arguments and serial port. +- Modify `lib/state/app_state.dart`: expose initialization state and settings + operations. +- Modify `lib/ui/pages/settings_page.dart` and + `lib/ui/pages/connection_page.dart`: provide real file selection and feedback + on settings state. +- Create `lib/core/pm3/pm3_session.dart`: session metadata and command-record + models. +- Create `lib/services/pm3_session_recorder.dart`: write `session.json`, + `terminal.log` and `commands.jsonl`. +- Modify `lib/core/pm3/pm3_controller.dart` and `lib/state/app_state.dart`: connect + commands and backend events to the session. +- Modify `lib/backend/desktop_cli/pm3_process.dart`: reliably handle early exit, + graceful exit, forced termination and disposal. +- Modify `lib/backend/desktop_cli/desktop_cli_backend.dart`: await underlying + shutdown and ensure the order in which subscriptions are released. +- Create `test/services/pm3_settings_store_test.dart`, + `test/services/pm3_session_recorder_test.dart` and + `test/backend/pm3_process_test.dart`. +- Modify `README.md` and `docs/refactoring_plan.md`: synchronize the + Windows/Linux-only scope and Phase 2 status. + +### Task 1: Client-settings persistence and path selection + +**Files:** +- Create: `lib/core/pm3/pm3_client_settings.dart` +- Create: `lib/services/pm3_settings_store.dart` +- Modify: `lib/state/connection_state.dart` +- Modify: `lib/state/app_state.dart` +- Modify: `lib/ui/pages/settings_page.dart` +- Modify: `lib/ui/pages/connection_page.dart` +- Test: `test/services/pm3_settings_store_test.dart` +- Test: `test/state/connection_state_test.dart` + +- [x] **Step 1: Write failing settings-store tests** + +```dart +test('round-trips desktop client settings', () async { + final directory = await Directory.systemTemp.createTemp('pm3-settings-'); + addTearDown(() => directory.delete(recursive: true)); + final store = Pm3SettingsStore(directoryProvider: () async => directory); + const expected = Pm3ClientSettings( + executable: r'C:\tools\proxmark3.exe', + port: 'COM7', + arguments: ['--flush'], + workingDirectory: r'C:\tools', + ); + await store.save(expected); + expect(await store.load(), expected); +}); + +test('invalid JSON falls back without throwing', () async { + // After invalid JSON is written, load() returns null instead of failing startup. +}); +``` + +- [x] **Step 2: Run the red tests** + +Run: `flutter test --no-pub test/services/pm3_settings_store_test.dart` + +Expected: FAIL because `Pm3ClientSettings` and `Pm3SettingsStore` do not exist. + +- [x] **Step 3: Implement the minimal model and storage** + +```dart +final class Pm3ClientSettings { + const Pm3ClientSettings({ + required this.executable, + this.port = '', + this.arguments = const [], + this.workingDirectory, + }); + + final String executable; + final String port; + final List arguments; + final String? workingDirectory; + + Map toJson() => { + 'executable': executable, + 'port': port, + 'arguments': arguments, + 'workingDirectory': workingDirectory, + }; +} +``` + +`Pm3SettingsStore` uses an injected directory provider and saves JSON to +`pm3_settings.json`. `load()` returns `null` for a missing file, invalid JSON or +invalid fields. + +- [x] **Step 4: Wire settings into the state layer and UI** + +`ConnectionState.initialize()` restores settings at startup. `setPm3Path`, +`setPort` and the new argument/working-directory setters save snapshots. +Settings and Connection use `FileDialogService.pickSingleFilePath()` to select +`proxmark3.exe`/`proxmark3`; users are no longer limited to typing paths manually. + +- [x] **Step 5: Run the green tests and related regressions** + +Run: + +```text +flutter test --no-pub test/services/pm3_settings_store_test.dart test/state/connection_state_test.dart +flutter test --no-pub +``` + +Expected: all tests pass. + +- [x] **Step 6: Commit** + +```text +feat(settings): persist PM3 client settings +``` + +### Task 2: Local sessions and unified logging + +**Files:** +- Create: `lib/core/pm3/pm3_session.dart` +- Create: `lib/services/pm3_session_recorder.dart` +- Modify: `lib/core/pm3/pm3_controller.dart` +- Modify: `lib/state/app_state.dart` +- Test: `test/services/pm3_session_recorder_test.dart` +- Test: `test/backend/pm3_controller_test.dart` + +- [x] **Step 1: Write failing session tests** + +```dart +test('records metadata, terminal output and command history', () async { + final directory = await Directory.systemTemp.createTemp('pm3-session-'); + addTearDown(() => directory.delete(recursive: true)); + final recorder = Pm3SessionRecorder(rootDirectoryProvider: () async => directory); + await recorder.start(devicePort: 'COM7', executable: 'proxmark3.exe'); + await recorder.recordCommand('hw version'); + await recorder.recordOutput('[+] client: v4'); + await recorder.close(); + expect(await File('${recorder.sessionPath}/session.json').exists(), isTrue); + expect(await File('${recorder.sessionPath}/terminal.log').readAsString(), contains('client: v4')); + expect(await File('${recorder.sessionPath}/commands.jsonl').readAsString(), contains('hw version')); +}); +``` + +- [x] **Step 2: Run the red tests** + +Run: `flutter test --no-pub test/services/pm3_session_recorder_test.dart` + +Expected: FAIL because the session types and recorder do not exist. + +- [x] **Step 3: Implement a session recorder with serialized writes** + +Each successful connection creates `sessions/YYYY-MM-DD_HHmmss_SSS/` and +immediately writes `session.json`. All output is appended to `terminal.log`. +All commands are appended to `commands.jsonl` as single-line JSON records. +An internal Future chain serializes writes. `close()` waits for trailing +writes and stores `endedAt` in the metadata. + +- [x] **Step 4: Wire the controller and application state** + +`Pm3Controller.execute()` emits a command observation before calling the +backend. `AppState` starts a session on connected, records output events and +closes the session on disconnected/dispose. Recording failures update only +local diagnostic state; they do not block PM3 commands. + +- [x] **Step 5: Run the green tests and related regressions** + +Run: + +```text +flutter test --no-pub test/services/pm3_session_recorder_test.dart test/backend/pm3_controller_test.dart +flutter test --no-pub +``` + +Expected: all tests pass and demonstrate that file contents are complete after +close. + +- [x] **Step 6: Commit** + +```text +feat(logging): add local PM3 session records +``` + +### Task 3: Reliable PM3 process lifecycle + +**Files:** +- Modify: `lib/backend/desktop_cli/pm3_process.dart` +- Modify: `lib/backend/desktop_cli/desktop_cli_backend.dart` +- Test: `test/backend/pm3_process_test.dart` + +- [x] **Step 1: Write failing process-lifecycle tests** + +Tests create a Dart child-process fixture at runtime. One prints `pm3 -->` on +startup and exits when it receives `quit`; another exits before printing the +prompt. The historical assertion below checks the legacy Chinese diagnostic +word `退出` ("exit"), which is preserved verbatim rather than changing the +behavior of the example. + +```dart +test('disconnect waits for a graceful child exit', () async { + final process = Pm3Process(connectCooldown: Duration.zero); + expect(await process.connect(dartExecutable, 'COM7', arguments: fixtureArgs), isTrue); + await process.disconnect(); + expect(process.state, Pm3ProcessState.disconnected); +}); + +test('connect returns false when child exits before prompt', () async { + final process = Pm3Process(connectCooldown: Duration.zero); + expect(await process.connect(dartExecutable, 'COM7', arguments: earlyExitArgs), isFalse); + expect(process.lastError, contains('退出')); +}); +``` + +- [x] **Step 2: Run the red tests** + +Run: `flutter test --no-pub test/backend/pm3_process_test.dart` + +Expected: FAIL. The existing constructor does not allow cooldown injection, +and an early-exit connection waits for the 15-second timeout. + +- [x] **Step 3: Implement the minimal lifecycle fix** + +Retain stdout/stderr subscriptions and complete the connection Future on early +exit. `disconnect()` first writes `quit`, waits for a bounded interval and +calls `kill()` only on timeout. Publish disconnected after clearing +subscriptions and process references. `dispose()` marks the instance as +released and ensures later asynchronous callbacks do not write to closed +controllers. + +- [x] **Step 4: Run the green tests and related regressions** + +Run: + +```text +flutter test --no-pub test/backend/pm3_process_test.dart +flutter test --no-pub +``` + +Expected: all tests pass and no test processes remain. + +- [x] **Step 5: Commit** + +```text +fix(backend): improve the PM3 process-exit lifecycle +``` + +### Task 4: Documentation, static checks and desktop-build validation + +**Files:** +- Modify: `README.md` +- Modify: `docs/refactoring_plan.md` + +- [x] **Step 1: Synchronize the design baseline** + +State that Windows x64 is the primary platform and Linux x64 is officially +supported; Android/macOS/Web are outside 1.0. Record external-client settings, +local session files, the backend lifecycle and the next Command/Parser work. + +- [x] **Step 2: Formatting and static checks** + +Run: + +```text +dart format --output=none --set-exit-if-changed lib test +flutter analyze --no-pub +``` + +Expected: changed files are formatted correctly, and analysis introduces no +warnings/errors. Report the repository's four existing `avoid_print` infos in +`tools/verify_extract.dart` separately. + +- [x] **Step 3: Complete test suite and Windows build** + +Run: + +```text +flutter test --no-pub +flutter build windows --debug --no-pub +``` + +Expected: zero failing tests and Windows debug build exit code 0. A Linux +build requires a Linux host; the current Windows environment can perform only +static review of the code paths and CI configuration. + +- [x] **Step 4: Commit** + +```text +docs(architecture): update desktop-foundation implementation status +``` + +### Task 5: RRG Windows BAT client compatibility and hardware recheck + +**Files:** +- Modify: `lib/backend/desktop_cli/pm3_process.dart` +- Modify: `lib/backend/desktop_cli/desktop_cli_backend.dart` +- Modify: `lib/state/connection_state.dart` +- Modify: `lib/ui/pages/connection_page.dart` +- Modify: `lib/ui/pages/settings_page.dart` +- Add: `test/backend/desktop_cli_backend_test.dart` +- Modify: `test/backend/pm3_process_test.dart` +- Modify: `test/state/connection_state_test.dart` +- Modify: `README.md` +- Modify: `docs/refactoring_plan.md` + +- [x] **Step 1: Write failing tests for the BAT entry point** + +When `pm3.bat` is selected on Windows, the process layer must launch it through +`cmd.exe /d /c call` and keep stdin/stdout interactive. Intermediate +"establishing USB communication" output must not be mistaken for a connected +prompt. + +- [x] **Step 2: Run the red tests** + +Run: `flutter test --no-pub test/backend/pm3_process_test.dart` + +Expected: FAIL. The existing implementation passes `.bat` directly to +`Process.start` and accepts `Communicating with PM3 over USB-CDC` too early. + +The actual red tests also cover the RRG BAT launcher not flushing its +waiting-for-input prompt through a stdout pipe: connection times out without +active input. A standalone `[usb]` transport tag must not count as evidence +of success either. + +- [x] **Step 3: Implement the minimal BAT adaptation** + +Only Windows `.bat`/`.cmd` entry points launch through the system command +interpreter. Native `proxmark3.exe` and Linux `proxmark3` paths retain their +existing behavior. After a Windows BAT client first reports USB-CDC +communication, send one read-only `hw version` handshake to advance pipe +output. Recognize real `pm3 -->` prompts across chunks and without a final +newline. Intermediate communication lines or standalone transport tags do not +mean a successful connection. Settings and default-path guidance should prefer +the distribution-root `pm3.bat` for Windows users. + +- [x] **Step 4: Hardware and regression validation** + +On Windows x64, the user-provided distribution corresponding to RRG commit +`da509461` and a PM3EASY512K (`PM3 GENERIC / AT91SAM7S512 / 512K`) were used to +verify the root `pm3.bat` connection chain, real prompt recognition, read-only +`hw version` and disconnection. This evidence does not cover Linux, other +devices, complete firmware interaction or actual card operations. The full +Flutter test suite, changed-scope static checks and a Windows debug build +passed. + +- [x] **Step 5: Commit** + +The recorded commit descriptions below are translated into English; their +commit hashes are unchanged. + +```text +2cc4242 feat(backend): support the official Windows PM3 script +36452ec fix(backend): recognize PM3 prompts without a newline +471dfa6 fix(backend): wake the Windows BAT interactive prompt +``` diff --git a/docs.en/tasks_new_features.md b/docs.en/tasks_new_features.md new file mode 100644 index 0000000..4847411 --- /dev/null +++ b/docs.en/tasks_new_features.md @@ -0,0 +1,392 @@ +# PM3GUI New Feature Page Development Task Breakdown + +> The tasks, estimates and checkbox states below are preserved as planning +> records from before PR #27, not as the current backlog or feature list. +> New development should follow the [current implementation and roadmap](refactoring_plan.md), +> [localization guide](localization.md) and [contributing guide](../CONTRIBUTING.en.md). +> The original examples' Chinese UI text has been translated here for readers; +> production UI text must use ARB resources. Command and data formats remain unchanged. + +## Task Overview + +Break down the five new pages in the planning document into specific, +actionable tasks. + +--- + +## Phase 1: Basic Preparation (P0) + +### Task 1.1: Update the Navigation Structure +**File**: `lib/ui/home_page.dart` +**Dependencies**: None +**Estimate**: 30 minutes + +**Subtasks**: +- [ ] 1.1.1 Add imports for the new pages, initially using placeholder classes. +- [ ] 1.1.2 Update the `_pages` list with placeholders for the five new pages. +- [ ] 1.1.3 Update the `_navItems` list with five new navigation entries. +- [ ] 1.1.4 Verify that the application compiles and runs normally. + +**Code-change preview**: +```dart +// Import the new pages. +import 'package:pm3gui/ui/pages/hf_mfu_page.dart'; +import 'package:pm3gui/ui/pages/hf_iclass_page.dart'; +import 'package:pm3gui/ui/pages/hf_15_page.dart'; +import 'package:pm3gui/ui/pages/hf_mfdes_page.dart'; +import 'package:pm3gui/ui/pages/lf_hid_page.dart'; + +// The _pages list. +final _pages = const [ + ConnectionPage(), + TerminalPage(), + DumpViewerPage(), + DumpComparePage(), + MifarePage(), + HfMfuPage(), // NEW + HfIclassPage(), // NEW + Hf15Page(), // NEW + LfPage(), + LfHidPage(), // NEW + SettingsPage(), +]; + +// The _navItems list. +static const _navItems = [ + _NavItem(Icons.usb, Icons.usb, 'Connection', 'Device connection'), + _NavItem(Icons.terminal, Icons.terminal, 'Terminal', 'Interactive terminal'), + _NavItem(Icons.file_open, Icons.file_open, 'Dump', 'View/edit dumps'), + _NavItem(Icons.compare_arrows, Icons.compare_arrows, 'Compare', 'Dump comparison'), + _NavItem(Icons.nfc, Icons.nfc, 'Mifare', 'Mifare Classic'), + _NavItem(Icons.memory, Icons.memory, 'NTAG', 'Ultralight/NTAG'), + _NavItem(Icons.badge, Icons.badge, 'iCLASS', 'iCLASS/Picopass'), + _NavItem(Icons.contactless, Icons.contactless, 'ISO15693', 'ISO15693'), + _NavItem(Icons.radio, Icons.radio, 'Low Frequency', 'LF operations'), + _NavItem(Icons.door_front_door_outlined, Icons.door_front_door_outlined, + 'HID', 'HID Prox'), + _NavItem(Icons.settings, Icons.settings, 'Settings', 'Application settings'), +]; +``` + +--- + +## Phase 2: Mifare Ultralight/NTAG Page (P0) + +### Task 2.1: Create the Page Framework +**File**: `lib/ui/pages/hf_mfu_page.dart` +**Dependencies**: None +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 2.1.1 Create the basic file structure: imports and class definitions. +- [ ] 2.1.2 Implement the TabController with five tabs. +- [ ] 2.1.3 Implement the basic `_execute()` method. +- [ ] 2.1.4 Create five empty `_buildXxx()` methods. + +### Task 2.2: Implement the "Information" Tab +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 2.2.1 Add the [Get information] button -> `HfMfuCmd.info()`. +- [ ] 2.2.2 Add the [Dump card] button -> `HfMfuCmd.dump()`. +- [ ] 2.2.3 Add the [Erase card] button, with a confirmation dialog -> `HfMfuCmd.wipe()`. +- [ ] 2.2.4 Add the result-display area. + +### Task 2.3: Implement the "Read/Write" Tab +**Estimate**: 1.5 hours + +**Subtasks**: +- [ ] 2.3.1 Add a block-number input with range validation for 0-255. +- [ ] 2.3.2 Add the [Read block] button -> `HfMfuCmd.rdbl()`. +- [ ] 2.3.3 Add a data input with validation for 16 hexadecimal characters. +- [ ] 2.3.4 Add the [Write block] button -> `HfMfuCmd.wrbl()`. +- [ ] 2.3.5 Add a password input for eight hexadecimal characters. +- [ ] 2.3.6 Add the [Authenticate] button -> `HfMfuCmd.cauth()`. + +### Task 2.4: Implement the "NDEF" Tab +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 2.4.1 Add the [Read NDEF] button -> `HfMfuCmd.ndefRead()`. +- [ ] 2.4.2 Add the NDEF result-display area with formatted output. + +### Task 2.5: Implement the "Emulator" Tab +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 2.5.1 Add file selection. +- [ ] 2.5.2 Add the [Load into emulator] button -> `HfMfuCmd.eload()`. +- [ ] 2.5.3 Add the [Save emulator] button -> `HfMfuCmd.esave()`. +- [ ] 2.5.4 Add the [View emulator] button -> `HfMfuCmd.eview()`. +- [ ] 2.5.5 Add the [Simulate card] button -> `HfMfuCmd.sim()`. + +### Task 2.6: Implement the "Tools" Tab +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 2.6.1 Add the [Generate key] button -> `HfMfuCmd.keygen()`. +- [ ] 2.6.2 Add an optional UID input. +- [ ] 2.6.3 Add the [Generate password] button -> `HfMfuCmd.pwdgen()`. +- [ ] 2.6.4 Add the [Set UID] button -> `HfMfuCmd.setuid()`. + +--- + +## Phase 3: iCLASS/Picopass Page (P0) + +### Task 3.1: Create the Page Framework +**File**: `lib/ui/pages/hf_iclass_page.dart` +**Dependencies**: None +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 3.1.1 Create the basic file structure. +- [ ] 3.1.2 Implement the TabController with four tabs. +- [ ] 3.1.3 Implement the basic `_execute()` method. + +### Task 3.2: Implement the "Information" Tab +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 3.2.1 Add the [Get information] button -> `HfIclassCmd.info()`. +- [ ] 3.2.2 Add the [Read card] button -> `HfIclassCmd.reader()`. +- [ ] 3.2.3 Add the [Dump card] button -> `HfIclassCmd.dump()`. + +### Task 3.3: Implement the "Read/Write" Tab +**Estimate**: 1.5 hours + +**Subtasks**: +- [ ] 3.3.1 Add a block-number selector for 0-31. +- [ ] 3.3.2 Add an optional key input for 16 hexadecimal characters. +- [ ] 3.3.3 Add the [Read block] button -> `HfIclassCmd.rdbl()`. +- [ ] 3.3.4 Add a data input for 16 hexadecimal characters. +- [ ] 3.3.5 Add the [Write block] button -> `HfIclassCmd.wrbl()`. + +### Task 3.4: Implement the "Key Recovery" Tab +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 3.4.1 Add the [Check keys] button -> `HfIclassCmd.chk()`. +- [ ] 3.4.2 Add the [Loclass attack] button -> `HfIclassCmd.loclass()`. +- [ ] 3.4.3 Add the [Sniff communication] button -> `HfIclassCmd.sniff()`. + +### Task 3.5: Implement the "Emulator" Tab +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 3.5.1 Add the [Load file] button -> `HfIclassCmd.eload()`. +- [ ] 3.5.2 Add the [Save file] button -> `HfIclassCmd.esave()`. +- [ ] 3.5.3 Add the [View] button -> `HfIclassCmd.eview()`. +- [ ] 3.5.4 Add the [Simulate card] button -> `HfIclassCmd.sim()`. + +--- + +## Phase 4: ISO15693 Page (P1) + +### Task 4.1: Create the Page Framework +**File**: `lib/ui/pages/hf_15_page.dart` +**Dependencies**: None +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 4.1.1 Create the basic file structure. +- [ ] 4.1.2 Implement the TabController with three tabs. +- [ ] 4.1.3 Implement the basic `_execute()` method. + +### Task 4.2: Implement the "Information" Tab +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 4.2.1 Add the [Reader] button -> `Hf15Cmd.reader()`. +- [ ] 4.2.2 Add the [Tag information] button -> `Hf15Cmd.info()`. +- [ ] 4.2.3 Add the [Dump card] button -> `Hf15Cmd.dump()`. +- [ ] 4.2.4 Add the [Erase card] button -> `Hf15Cmd.wipe()`. + +### Task 4.3: Implement the "Read/Write" Tab +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 4.3.1 Add a block-number input. +- [ ] 4.3.2 Add the [Read block] button -> `Hf15Cmd.rdbl()`. +- [ ] 4.3.3 Add a data input. +- [ ] 4.3.4 Add the [Write block] button -> `Hf15Cmd.wrbl()`. + +### Task 4.4: Implement the "Tools" Tab +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 4.4.1 Add the [Find AFI] button -> `Hf15Cmd.findafi()`. +- [ ] 4.4.2 Add a UID input. +- [ ] 4.4.3 Add the [Set UID] button -> `Hf15Cmd.csetuid()`. +- [ ] 4.4.4 Add the [Simulate card] button -> `Hf15Cmd.sim()`. +- [ ] 4.4.5 Add the [Sniff] button -> `Hf15Cmd.sniff()`. + +--- + +## Phase 5: Mifare DESFire Page (P1) + +### Task 5.1: Create the Page Framework +**File**: `lib/ui/pages/hf_mfdes_page.dart` +**Dependencies**: None +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 5.1.1 Create the basic file structure. +- [ ] 5.1.2 Implement the TabController with four tabs. +- [ ] 5.1.3 Implement the basic `_execute()` method. + +### Task 5.2: Implement the "Information" Tab +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 5.2.1 Add the [Detect] button -> `HfMfdesCmd.detect()`. +- [ ] 5.2.2 Add the [Information] button -> `HfMfdesCmd.info()`. +- [ ] 5.2.3 Add the [Get UID] button -> `HfMfdesCmd.getuid()`. +- [ ] 5.2.4 Add the [Free memory] button -> `HfMfdesCmd.freemem()`. +- [ ] 5.2.5 Add the [Check keys] button -> `HfMfdesCmd.chk()`. + +### Task 5.3: Implement the "Applications" Tab +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 5.3.1 Add the [List AIDs] button -> `HfMfdesCmd.getaids()`. +- [ ] 5.3.2 Add the [List applications] button -> `HfMfdesCmd.lsapp()`. +- [ ] 5.3.3 Add an AID input for six hexadecimal characters. +- [ ] 5.3.4 Add the [Select application] button -> `HfMfdesCmd.selectapp()`. + +### Task 5.4: Implement the "Files" Tab +**Estimate**: 1.5 hours + +**Subtasks**: +- [ ] 5.4.1 Add the [List files] button -> `HfMfdesCmd.getfileids()`. +- [ ] 5.4.2 Add a FID input. +- [ ] 5.4.3 Add the [Read file] button -> `HfMfdesCmd.read()`. +- [ ] 5.4.4 Add a data input. +- [ ] 5.4.5 Add the [Write file] button -> `HfMfdesCmd.write()`. +- [ ] 5.4.6 Add the [Dump application] button -> `HfMfdesCmd.dump()`. + +### Task 5.5: Implement the "Management" Tab +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 5.5.1 Add a key-number input. +- [ ] 5.5.2 Add a key input. +- [ ] 5.5.3 Add algorithm selection for AES/DES. +- [ ] 5.5.4 Add the [Authenticate] button -> `HfMfdesCmd.auth()`. +- [ ] 5.5.5 Add the [Format PICC] button, with confirmation -> `HfMfdesCmd.formatpicc()`. + +--- + +## Phase 6: HID Prox Page (P1) + +### Task 6.1: Create the Page Framework +**File**: `lib/ui/pages/lf_hid_page.dart` +**Dependencies**: None +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 6.1.1 Create the basic file structure. +- [ ] 6.1.2 Implement the TabController with three tabs. +- [ ] 6.1.3 Implement the basic `_execute()` method. + +### Task 6.2: Implement the "Read" Tab +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 6.2.1 Add the [Read HID card] button -> `LfHidCmd.reader()`. +- [ ] 6.2.2 Add the [Demodulate signal] button -> `LfHidCmd.demod()`. +- [ ] 6.2.3 Add the result-display area, parsing FC/CN. + +### Task 6.3: Implement the "Clone/Simulate" Tab +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 6.3.1 Add a card-data input in Wiegand format. +- [ ] 6.3.2 Add the [Clone to T55xx] button -> `LfHidCmd.clone()`. +- [ ] 6.3.3 Add the [Simulate card] button -> `LfHidCmd.sim()`. + +### Task 6.4: Implement the "Brute-force" Tab +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 6.4.1 Add an optional FC input. +- [ ] 6.4.2 Add the [Brute-force] button -> `LfHidCmd.brute()`. + +--- + +## Phase 7: Testing and Optimization + +### Task 7.1: Unit Tests +**Estimate**: 2 hours + +**Subtasks**: +- [ ] 7.1.1 Test tab switching on each page. +- [ ] 7.1.2 Test that every button triggers the correct command. +- [ ] 7.1.3 Test input validation for hexadecimal format and length. +- [ ] 7.1.4 Test the prompts shown when no device is connected. + +### Task 7.2: Integration Tests +**Estimate**: 1 hour + +**Subtasks**: +- [ ] 7.2.1 Test that sidebar navigation switches pages correctly. +- [ ] 7.2.2 Test that theme switching works normally. +- [ ] 7.2.3 Test for conflicts with existing pages. +- [ ] 7.2.4 Test application startup and runtime performance. + +### Task 7.3: Code Optimization +**Estimate**: 2 hours + +**Subtasks**: +- [ ] 7.3.1 Extract shared components, such as ActionCard and ResultDisplay. +- [ ] 7.3.2 Optimize duplicate code. +- [ ] 7.3.3 Add necessary comments. +- [ ] 7.3.4 Run `flutter analyze` to check for issues. + +--- + +## Time-Estimate Summary + +| Phase | Number of Tasks | Estimated Time | Actual Time | +|:-----:|:---------------:|:--------------:|:-----------:| +| Phase 1: Basic preparation | 1 | 0.5h | - | +| Phase 2: MFU page | 6 | 6.5h | - | +| Phase 3: iCLASS page | 5 | 5.5h | - | +| Phase 4: ISO15693 page | 4 | 4h | - | +| Phase 5: DESFire page | 5 | 5.5h | - | +| Phase 6: HID page | 4 | 4h | - | +| Phase 7: Testing and optimization | 3 | 5h | - | +| **Total** | **28** | **31h** | - | + +--- + +## Dependency Diagram + +```text +Phase 1: Basic preparation + | + |---> Phase 2: MFU page -----------+ + | | + |---> Phase 3: iCLASS page --------+ + | | + |---> Phase 4: ISO15693 page ------+---> Phase 7: Testing and optimization + | | + |---> Phase 5: DESFire page -------+ + | | + `---> Phase 6: HID page -----------+ +``` + +**Notes**: +- Phases 2-6 can be developed in parallel. +- Phase 7 must take place after all pages are complete. +- Within each phase, perform tasks in sequence where possible. + +--- + +## Risks and Mitigations + +| Risk | Likelihood | Impact | Mitigation | +|:-----|:----------:|:------:|:-----------| +| Command-parameter mismatch | Medium | High | Consult pm3_commands.yaml and test against the real CLI. | +| Too many pages crowd the navigation | Medium | Medium | Consider collapsible menus or categories. | +| State management becomes more complex | Low | Medium | Keep page state independent; do not add global state. | +| Compilation problems | Low | High | Run flutter analyze promptly after each change. | diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..ff4c87d --- /dev/null +++ b/docs/README.md @@ -0,0 +1,66 @@ +# Documentation index + +[简体中文 (Simplified Chinese)](../README.md) | [English](../README.en.md) | [English quick start](../README.quickstart.en.md) + +This index distinguishes current guidance from preserved design/history +documents. PR #27 adds GUI internationalization; it does not certify additional +protocol coverage, platforms or hardware operations. + +## Current guidance + +| Document | Purpose | +|---|---| +| [Chinese README](../README.md) / [HTML snapshot](../README.html) | User setup, languages, client integration, formats, privacy and build steps | +| [English README](../README.en.md) / [HTML snapshot](../README.en.html) | Full English user guide, formats, client integration and build steps | +| [English quick-start](../README.quickstart.en.md) | English setup, language selection and standalone command-help synchronization | +| [Localization](localization.md) | ARB workflow, adding languages, preference/error behavior and regression coverage | +| [Current implementation and roadmap](refactoring_plan.md) | Current backend/tool boundaries, supported desktop targets and future direction | +| [Contributing](../CONTRIBUTING.md) | Contributor workflow and localization requirements | +| [Changelog](../CHANGELOG.md) | Unreleased PR changes and released history | +| [Security policy](../SECURITY.md) | Private reporting and sanitizing diagnostics before sharing | + +The language resources are `lib/l10n/app_en.arb` and `app_zh.arb`. +The completeness checker reports the current count (701 per language for +PR #27 as documented on 2026-10-22). Generated localization Dart files are +ignored by Git and regenerated by Flutter. + +`pm3_commands.yaml`, `pm3_commands_help.yaml`, `pm3_commands_help.zh.yaml` and +`pm3_commands_client.json` are command/asset data, not claims of tested feature +coverage. Preserve command syntax and machine-readable fields. The snapshot +can be refreshed by `tools/sync_command_catalog.dart`; see the READMEs. + +## Historical design references + +These files retain original design sketches, code examples and planning +checkboxes; they are not current implementation checklists. Chinese text in +their diagrams/examples is illustrative, not permission to hard-code new UI +strings. Follow the current localization guide when implementing changes. + +- [Project archive](../PROJECT_ARCHIVE.md) +- [Feature design](spec_new_features.md) +- [Coverage planning](complete_coverage_plan.md) +- [Original task breakdown](tasks_new_features.md) +- [Original development checklist](checklist_new_features.md) / + [HTML snapshot](checklist_new_features.html) +- [ISO 14443-B design](hf_14b_page_spec.md) +- [ISO 15693 design](hf_15_page_spec.md) +- [iCLASS design](hf_iclass_page_spec.md) +- [DESFire design](hf_mfdes_page_spec.md) +- [Ultralight/NTAG design](hf_mfu_page_spec.md) +- [HID Prox design](lf_hid_page_spec.md) +- [2026-09-04 desktop foundation plan](superpowers/plans/2026-09-04-desktop-foundation.md) + +## HTML snapshots + +`README.html` and `docs/checklist_new_features.html` are rendered from their +same-named Markdown sources. Do not maintain an independent feature list in +HTML. Regenerate from the repository root with PowerShell 7: + +```powershell +Set-Content -LiteralPath README.html -Encoding utf8 -Value ('' + "`n" + (ConvertFrom-Markdown -Path README.md).Html.TrimEnd()) +Set-Content -LiteralPath docs\checklist_new_features.html -Encoding utf8 -Value ('' + "`n" + (ConvertFrom-Markdown -Path docs\checklist_new_features.md).Html.TrimEnd()) +``` + +The renderer is built into PowerShell; no project or system package install is +required. Review the generated links, code blocks and tables with the Markdown +change. diff --git a/docs/checklist_new_features.html b/docs/checklist_new_features.html index 69e322e..c478d3e 100644 --- a/docs/checklist_new_features.html +++ b/docs/checklist_new_features.html @@ -1,328 +1,345 @@ - -

PM3GUI 新功能页面开发检查清单

-

开发前检查

-

环境准备

-
    -
  • Flutter SDK 3.24+ 已安装
  • -
  • 项目依赖已更新 (flutter pub get)
  • -
  • 现有代码无编译错误 (flutter analyze)
  • -
  • 应用能正常启动运行
  • -
-

代码审查

- -
-

Phase 1: 基础准备

-

任务 1.1: 更新导航结构

+ +

PM3GUI 新功能页面开发检查清单

+
+

下文 Phase 清单与原有勾选状态保留为 PR #27 之前的设计记录,不代表当前实现进度、 +SDK 要求或受支持平台。当前开发入口见 文档索引 和 +Localization;不要直接复制旧示例中的硬编码中文。

+
+

当前新增页面的本地化验收(PR #27)

+
    +
  • 中英文 ARB 资源齐全,句子使用具名占位符,名词/动作等不同含义使用不同键。
  • +
  • 标签、工具提示、校验、确认/危险提示、空状态和错误均使用当前界面语言。
  • +
  • 临时校验模型继承当前语言;失败导入不改变原数据。
  • +
  • 原始 PM3 命令、十六进制数据、协议标识和持久化字段不被翻译。
  • +
  • 独立 Dart 工具不引入 Flutter 运行时依赖,实际 dart run 入口有回归覆盖。
  • +
  • 语言目录查找/保存失败可在设置页看到,初始化仍可完成,后续保存可重试。
  • +
  • 中英文的页面、对话框、默认/较小桌面窗口与文本放大均经过针对性检查。
  • +
  • 修改后运行 flutter gen-l10n、资源完整性检查、静态分析和相关测试。
  • +
  • 同步 Markdown/HTML 文档,并明确区分代码测试、Linux 构建和真实设备验证。
  • +
+

开发前检查

+

环境准备

+
    +
  • Flutter SDK 3.24+ 已安装
  • +
  • 项目依赖已更新 (flutter pub get)
  • +
  • 现有代码无编译错误 (flutter analyze)
  • +
  • 应用能正常启动运行
  • +
+

代码审查

+ +
+

Phase 1: 基础准备

+

任务 1.1: 更新导航结构

文件: lib/ui/home_page.dart

-

导入语句

+

导入语句

    -
  • 添加 hf_mfu_page.dart 导入
  • -
  • 添加 hf_iclass_page.dart 导入
  • -
  • 添加 hf_15_page.dart 导入
  • -
  • 添加 hf_mfdes_page.dart 导入
  • -
  • 添加 lf_hid_page.dart 导入
  • +
  • 添加 hf_mfu_page.dart 导入
  • +
  • 添加 hf_iclass_page.dart 导入
  • +
  • 添加 hf_15_page.dart 导入
  • +
  • 添加 hf_mfdes_page.dart 导入
  • +
  • 添加 lf_hid_page.dart 导入
-

_pages 列表更新

+

_pages 列表更新

    -
  • 在 MifarePage() 后添加 HfMfuPage()
  • -
  • 添加 HfIclassPage()
  • -
  • 添加 Hf15Page()
  • -
  • 在 LfPage() 后添加 LfHidPage()
  • +
  • 在 MifarePage() 后添加 HfMfuPage()
  • +
  • 添加 HfIclassPage()
  • +
  • 添加 Hf15Page()
  • +
  • 在 LfPage() 后添加 LfHidPage()
-

_navItems 列表更新

+
    -
  • 添加 NTAG 导航项 (Icons.memory)
  • -
  • 添加 iCLASS 导航项 (Icons.badge)
  • -
  • 添加 ISO15693 导航项 (Icons.contactless)
  • -
  • 添加 HID 导航项 (Icons.door_front_door_outlined)
  • +
  • 添加 NTAG 导航项 (Icons.memory)
  • +
  • 添加 iCLASS 导航项 (Icons.badge)
  • +
  • 添加 ISO15693 导航项 (Icons.contactless)
  • +
  • 添加 HID 导航项 (Icons.door_front_door_outlined)
-

验证

+

验证

    -
  • flutter analyze 无错误
  • -
  • 应用能正常编译
  • -
  • 侧边栏显示所有新导航项
  • +
  • flutter analyze 无错误
  • +
  • 应用能正常编译
  • +
  • 侧边栏显示所有新导航项
-
-

Phase 2: Mifare Ultralight/NTAG 页面

-

文件: lib/ui/pages/hf_mfu_page.dart

-

基础结构

+
+

Phase 2: Mifare Ultralight/NTAG 页面

+

文件: lib/ui/pages/hf_mfu_page.dart

+

基础结构

    -
  • 文件头部包含 library; 声明
  • -
  • 所有必要的 imports
  • -
  • HfMfuPage StatefulWidget 类
  • -
  • _HfMfuPageState 状态类
  • -
  • SingleTickerProviderStateMixin 混入
  • -
  • TabController 初始化和释放
  • +
  • 文件头部包含 library; 声明
  • +
  • 所有必要的 imports
  • +
  • HfMfuPage StatefulWidget 类
  • +
  • _HfMfuPageState 状态类
  • +
  • SingleTickerProviderStateMixin 混入
  • +
  • TabController 初始化和释放
-

Tab 结构 (5个标签)

+

Tab 结构 (5个标签)

    -
  • TabBar 包含5个 Tab
  • -
  • TabBarView 包含5个对应页面
  • -
  • 标签: 信息、读写、NDEF、模拟器、工具
  • +
  • TabBar 包含5个 Tab
  • +
  • TabBarView 包含5个对应页面
  • +
  • 标签: 信息、读写、NDEF、模拟器、工具
-

"信息"标签

+

"信息"标签

    -
  • [获取信息] 按钮 -> HfMfuCmd.info()
  • -
  • [转储卡片] 按钮 -> HfMfuCmd.dump()
  • -
  • [擦除卡片] 按钮 -> HfMfuCmd.wipe() (带确认对话框)
  • -
  • 结果显示区域
  • +
  • [获取信息] 按钮 -> HfMfuCmd.info()
  • +
  • [转储卡片] 按钮 -> HfMfuCmd.dump()
  • +
  • [擦除卡片] 按钮 -> HfMfuCmd.wipe() (带确认对话框)
  • +
  • 结果显示区域
-

"读写"标签

+

"读写"标签

    -
  • 块号输入框 (0-255)
  • -
  • [读取块] 按钮 -> HfMfuCmd.rdbl()
  • -
  • 数据输入框 (16字符hex验证)
  • -
  • [写入块] 按钮 -> HfMfuCmd.wrbl()
  • -
  • 密码输入框 (8字符hex)
  • -
  • [认证] 按钮 -> HfMfuCmd.cauth()
  • +
  • 块号输入框 (0-255)
  • +
  • [读取块] 按钮 -> HfMfuCmd.rdbl()
  • +
  • 数据输入框 (16字符hex验证)
  • +
  • [写入块] 按钮 -> HfMfuCmd.wrbl()
  • +
  • 密码输入框 (8字符hex)
  • +
  • [认证] 按钮 -> HfMfuCmd.cauth()
-

"NDEF"标签

+

"NDEF"标签

    -
  • [读取NDEF] 按钮 -> HfMfuCmd.ndefRead()
  • -
  • NDEF结果显示区域
  • +
  • [读取NDEF] 按钮 -> HfMfuCmd.ndefRead()
  • +
  • NDEF结果显示区域
-

"模拟器"标签

+

"模拟器"标签

    -
  • 文件选择功能
  • -
  • [加载到模拟器] -> HfMfuCmd.eload()
  • -
  • [保存模拟器] -> HfMfuCmd.esave()
  • -
  • [查看模拟器] -> HfMfuCmd.eview()
  • -
  • [模拟卡片] -> HfMfuCmd.sim()
  • +
  • 文件选择功能
  • +
  • [加载到模拟器] -> HfMfuCmd.eload()
  • +
  • [保存模拟器] -> HfMfuCmd.esave()
  • +
  • [查看模拟器] -> HfMfuCmd.eview()
  • +
  • [模拟卡片] -> HfMfuCmd.sim()
-

"工具"标签

+

"工具"标签

    -
  • [生成密钥] -> HfMfuCmd.keygen()
  • -
  • UID输入框 (可选)
  • -
  • [生成密码] -> HfMfuCmd.pwdgen()
  • -
  • [设置UID] -> HfMfuCmd.setuid()
  • +
  • [生成密钥] -> HfMfuCmd.keygen()
  • +
  • UID输入框 (可选)
  • +
  • [生成密码] -> HfMfuCmd.pwdgen()
  • +
  • [设置UID] -> HfMfuCmd.setuid()
-

通用功能

+

通用功能

    -
  • _execute() 方法检查连接状态
  • -
  • 未连接时显示 SnackBar 提示
  • -
  • 使用等宽字体显示hex数据
  • -
  • 危险操作有确认对话框
  • +
  • _execute() 方法检查连接状态
  • +
  • 未连接时显示 SnackBar 提示
  • +
  • 使用等宽字体显示hex数据
  • +
  • 危险操作有确认对话框
-
-

Phase 3: iCLASS/Picopass 页面

-

文件: lib/ui/pages/hf_iclass_page.dart

-

基础结构

+
+

Phase 3: iCLASS/Picopass 页面

+

文件: lib/ui/pages/hf_iclass_page.dart

+

基础结构

    -
  • 正确的 imports
  • -
  • StatefulWidget 结构
  • -
  • TabController (4个标签)
  • +
  • 正确的 imports
  • +
  • StatefulWidget 结构
  • +
  • TabController (4个标签)
-

Tab 结构 (4个标签)

+

Tab 结构 (4个标签)

    -
  • 标签: 信息、读写、破解、模拟器
  • +
  • 标签: 信息、读写、破解、模拟器
-

"信息"标签

+

"信息"标签

    -
  • [获取信息] -> HfIclassCmd.info()
  • -
  • [读取卡片] -> HfIclassCmd.reader()
  • -
  • [转储卡片] -> HfIclassCmd.dump()
  • +
  • [获取信息] -> HfIclassCmd.info()
  • +
  • [读取卡片] -> HfIclassCmd.reader()
  • +
  • [转储卡片] -> HfIclassCmd.dump()
-

"读写"标签

+

"读写"标签

    -
  • 块号选择器 (0-31)
  • -
  • 密钥输入框 (16字符hex, 可选)
  • -
  • [读取块] -> HfIclassCmd.rdbl()
  • -
  • 数据输入框 (16字符hex)
  • -
  • [写入块] -> HfIclassCmd.wrbl()
  • +
  • 块号选择器 (0-31)
  • +
  • 密钥输入框 (16字符hex, 可选)
  • +
  • [读取块] -> HfIclassCmd.rdbl()
  • +
  • 数据输入框 (16字符hex)
  • +
  • [写入块] -> HfIclassCmd.wrbl()
-

"破解"标签

+

"破解"标签

    -
  • [检查密钥] -> HfIclassCmd.chk()
  • -
  • [Loclass攻击] -> HfIclassCmd.loclass()
  • -
  • [嗅探通信] -> HfIclassCmd.sniff()
  • +
  • [检查密钥] -> HfIclassCmd.chk()
  • +
  • [Loclass攻击] -> HfIclassCmd.loclass()
  • +
  • [嗅探通信] -> HfIclassCmd.sniff()
-

"模拟器"标签

+

"模拟器"标签

    -
  • [加载文件] -> HfIclassCmd.eload()
  • -
  • [保存文件] -> HfIclassCmd.esave()
  • -
  • [查看] -> HfIclassCmd.eview()
  • -
  • [模拟卡片] -> HfIclassCmd.sim()
  • +
  • [加载文件] -> HfIclassCmd.eload()
  • +
  • [保存文件] -> HfIclassCmd.esave()
  • +
  • [查看] -> HfIclassCmd.eview()
  • +
  • [模拟卡片] -> HfIclassCmd.sim()
-
-

Phase 4: ISO15693 页面

-

文件: lib/ui/pages/hf_15_page.dart

-

基础结构

+
+

Phase 4: ISO15693 页面

+

文件: lib/ui/pages/hf_15_page.dart

+

基础结构

    -
  • 正确的 imports
  • -
  • StatefulWidget 结构
  • -
  • TabController (3个标签)
  • +
  • 正确的 imports
  • +
  • StatefulWidget 结构
  • +
  • TabController (3个标签)
-

Tab 结构 (3个标签)

+

Tab 结构 (3个标签)

    -
  • 标签: 信息、读写、工具
  • +
  • 标签: 信息、读写、工具
-

"信息"标签

+

"信息"标签

    -
  • [读取器] -> Hf15Cmd.reader()
  • -
  • [标签信息] -> Hf15Cmd.info()
  • -
  • [转储卡片] -> Hf15Cmd.dump()
  • -
  • [擦除卡片] -> Hf15Cmd.wipe()
  • +
  • [读取器] -> Hf15Cmd.reader()
  • +
  • [标签信息] -> Hf15Cmd.info()
  • +
  • [转储卡片] -> Hf15Cmd.dump()
  • +
  • [擦除卡片] -> Hf15Cmd.wipe()
-

"读写"标签

+

"读写"标签

    -
  • 块号输入框
  • -
  • [读取块] -> Hf15Cmd.rdbl()
  • -
  • 数据输入框
  • -
  • [写入块] -> Hf15Cmd.wrbl()
  • +
  • 块号输入框
  • +
  • [读取块] -> Hf15Cmd.rdbl()
  • +
  • 数据输入框
  • +
  • [写入块] -> Hf15Cmd.wrbl()
-

"工具"标签

+

"工具"标签

    -
  • [查找AFI] -> Hf15Cmd.findafi()
  • -
  • UID输入框
  • -
  • [设置UID] -> Hf15Cmd.csetuid()
  • -
  • [模拟卡片] -> Hf15Cmd.sim()
  • -
  • [嗅探] -> Hf15Cmd.sniff()
  • +
  • [查找AFI] -> Hf15Cmd.findafi()
  • +
  • UID输入框
  • +
  • [设置UID] -> Hf15Cmd.csetuid()
  • +
  • [模拟卡片] -> Hf15Cmd.sim()
  • +
  • [嗅探] -> Hf15Cmd.sniff()
-
-

Phase 5: Mifare DESFire 页面

-

文件: lib/ui/pages/hf_mfdes_page.dart

-

基础结构

+
+

Phase 5: Mifare DESFire 页面

+

文件: lib/ui/pages/hf_mfdes_page.dart

+

基础结构

    -
  • 正确的 imports
  • -
  • StatefulWidget 结构
  • -
  • TabController (4个标签)
  • +
  • 正确的 imports
  • +
  • StatefulWidget 结构
  • +
  • TabController (4个标签)
-

Tab 结构 (4个标签)

+

Tab 结构 (4个标签)

    -
  • 标签: 信息、应用、文件、管理
  • +
  • 标签: 信息、应用、文件、管理
-

"信息"标签

+

"信息"标签

    -
  • [检测] -> HfMfdesCmd.detect()
  • -
  • [信息] -> HfMfdesCmd.info()
  • -
  • [获取UID] -> HfMfdesCmd.getuid()
  • -
  • [空闲内存] -> HfMfdesCmd.freemem()
  • -
  • [检查密钥] -> HfMfdesCmd.chk()
  • +
  • [检测] -> HfMfdesCmd.detect()
  • +
  • [信息] -> HfMfdesCmd.info()
  • +
  • [获取UID] -> HfMfdesCmd.getuid()
  • +
  • [空闲内存] -> HfMfdesCmd.freemem()
  • +
  • [检查密钥] -> HfMfdesCmd.chk()
-

"应用"标签

+

"应用"标签

    -
  • [列出AID] -> HfMfdesCmd.getaids()
  • -
  • [列出应用] -> HfMfdesCmd.lsapp()
  • -
  • AID输入框 (6字符hex)
  • -
  • [选择应用] -> HfMfdesCmd.selectapp()
  • +
  • [列出AID] -> HfMfdesCmd.getaids()
  • +
  • [列出应用] -> HfMfdesCmd.lsapp()
  • +
  • AID输入框 (6字符hex)
  • +
  • [选择应用] -> HfMfdesCmd.selectapp()
-

"文件"标签

+

"文件"标签

    -
  • [列出文件] -> HfMfdesCmd.getfileids()
  • -
  • FID输入框
  • -
  • [读取文件] -> HfMfdesCmd.read()
  • -
  • 数据输入框
  • -
  • [写入文件] -> HfMfdesCmd.write()
  • -
  • [转储应用] -> HfMfdesCmd.dump()
  • +
  • [列出文件] -> HfMfdesCmd.getfileids()
  • +
  • FID输入框
  • +
  • [读取文件] -> HfMfdesCmd.read()
  • +
  • 数据输入框
  • +
  • [写入文件] -> HfMfdesCmd.write()
  • +
  • [转储应用] -> HfMfdesCmd.dump()
-

"管理"标签

+

"管理"标签

    -
  • 密钥号输入框
  • -
  • 密钥输入框
  • -
  • 算法选择 (AES/DES)
  • -
  • [认证] -> HfMfdesCmd.auth()
  • -
  • [格式化PICC] 按钮(带确认)-> HfMfdesCmd.formatpicc()
  • +
  • 密钥号输入框
  • +
  • 密钥输入框
  • +
  • 算法选择 (AES/DES)
  • +
  • [认证] -> HfMfdesCmd.auth()
  • +
  • [格式化PICC] 按钮(带确认)-> HfMfdesCmd.formatpicc()
-
-

Phase 6: HID Prox 页面

-

文件: lib/ui/pages/lf_hid_page.dart

-

基础结构

+
+

Phase 6: HID Prox 页面

+

文件: lib/ui/pages/lf_hid_page.dart

+

基础结构

    -
  • 正确的 imports
  • -
  • StatefulWidget 结构
  • -
  • TabController (3个标签)
  • +
  • 正确的 imports
  • +
  • StatefulWidget 结构
  • +
  • TabController (3个标签)
-

Tab 结构 (3个标签)

+

Tab 结构 (3个标签)

    -
  • 标签: 读取、克隆/模拟、破解
  • +
  • 标签: 读取、克隆/模拟、破解
-

"读取"标签

+

"读取"标签

    -
  • [读取HID卡] -> LfHidCmd.reader()
  • -
  • [解调信号] -> LfHidCmd.demod()
  • -
  • 结果显示区域(解析FC/CN)
  • +
  • [读取HID卡] -> LfHidCmd.reader()
  • +
  • [解调信号] -> LfHidCmd.demod()
  • +
  • 结果显示区域(解析FC/CN)
-

"克隆/模拟"标签

+

"克隆/模拟"标签

    -
  • 卡号数据输入框(Wiegand格式)
  • -
  • [克隆到T55xx] -> LfHidCmd.clone()
  • -
  • [模拟卡片] -> LfHidCmd.sim()
  • +
  • 卡号数据输入框(Wiegand格式)
  • +
  • [克隆到T55xx] -> LfHidCmd.clone()
  • +
  • [模拟卡片] -> LfHidCmd.sim()
-

"破解"标签

+

"破解"标签

    -
  • FC输入框(可选)
  • -
  • [暴力破解] -> LfHidCmd.brute()
  • +
  • FC输入框(可选)
  • +
  • [暴力破解] -> LfHidCmd.brute()
-
-

Phase 7: 测试与优化

-

任务 7.1: 单元测试

+
+

Phase 7: 测试与优化

+

任务 7.1: 单元测试

    -
  • 测试每个页面的Tab切换
  • -
  • 测试所有按钮触发正确的命令
  • -
  • 测试输入验证(hex格式、长度)
  • -
  • 测试未连接设备时的提示
  • +
  • 测试每个页面的Tab切换
  • +
  • 测试所有按钮触发正确的命令
  • +
  • 测试输入验证(hex格式、长度)
  • +
  • 测试未连接设备时的提示
-

任务 7.2: 集成测试

+

任务 7.2: 集成测试

    -
  • 测试侧边栏导航正确切换页面
  • -
  • 测试主题切换正常
  • -
  • 测试与现有页面无冲突
  • -
  • 测试应用启动和运行性能
  • +
  • 测试侧边栏导航正确切换页面
  • +
  • 测试主题切换正常
  • +
  • 测试与现有页面无冲突
  • +
  • 测试应用启动和运行性能
-

任务 7.3: 代码优化

+

任务 7.3: 代码优化

    -
  • 提取公共组件(ActionCard, ResultDisplay等)
  • -
  • 优化重复代码
  • -
  • 添加必要的注释
  • -
  • 运行 flutter analyze 检查问题
  • +
  • 提取公共组件(ActionCard, ResultDisplay等)
  • +
  • 优化重复代码
  • +
  • 添加必要的注释
  • +
  • 运行 flutter analyze 检查问题
-
-

配色更改检查清单

-

已完成的更改

-

1. 主题文件更新 (lib/ui/theme.dart)

+
+

配色更改检查清单

+

已完成的更改

+

1. 主题文件更新 (lib/ui/theme.dart)

    -
  • 切换到单模式主题
  • -
  • 使用深蓝色仪表盘配色方案
  • -
  • 移除 lightTheme() 方法
  • -
  • 重命名 darkTheme() 为 theme()
  • -
  • 更新所有颜色引用
  • -
  • 修复 deprecated withOpacity 方法
  • +
  • 切换到单模式主题
  • +
  • 使用深蓝色仪表盘配色方案
  • +
  • 移除 lightTheme() 方法
  • +
  • 重命名 darkTheme() 为 theme()
  • +
  • 更新所有颜色引用
  • +
  • 修复 deprecated withOpacity 方法
-

2. 应用状态更新 (lib/state/app_state.dart)

+

2. 应用状态更新 (lib/state/app_state.dart)

    -
  • 移除 isDarkMode 状态
  • -
  • 移除 toggleTheme() 方法
  • +
  • 移除 isDarkMode 状态
  • +
  • 移除 toggleTheme() 方法
-

3. 主应用更新 (lib/main.dart)

+

3. 主应用更新 (lib/main.dart)

    -
  • 更新主题引用为 AppTheme.theme()
  • -
  • 移除主题模式切换逻辑
  • +
  • 更新主题引用为 AppTheme.theme()
  • +
  • 移除主题模式切换逻辑
-

4. 主页面更新 (lib/ui/home_page.dart)

+

4. 主页面更新 (lib/ui/home_page.dart)

    -
  • 移除主题切换按钮
  • -
  • 清理相关代码
  • +
  • 移除主题切换按钮
  • +
  • 清理相关代码
-

5. 设置页面更新 (lib/ui/settings_page.dart)

+

5. 设置页面更新 (lib/ui/settings_page.dart)

    -
  • 移除主题设置选项
  • +
  • 移除主题设置选项
-

6. 连接页面更新 (lib/ui/pages/connection_page.dart)

+

6. 连接页面更新 (lib/ui/pages/connection_page.dart)

    -
  • 导入新的主题文件
  • -
  • 替换所有莫兰迪颜色引用
  • -
  • 使用新的蓝色主题色彩
  • +
  • 导入新的主题文件
  • +
  • 替换所有莫兰迪颜色引用
  • +
  • 使用新的蓝色主题色彩
-

7. 编译验证

+

7. 编译验证

    -
  • flutter analyze 无错误
  • -
  • 应用能正常编译
  • +
  • flutter analyze 无错误
  • +
  • 应用能正常编译
-

配色方案

+

配色方案

  • 深蓝背景: #0F172A
  • 卡片色: #1E293B
  • @@ -331,13 +348,13 @@

    配色方案

  • 分隔线: #334155
  • 错误色: #F87171
-

验证结果

+

验证结果

    -
  • 应用启动正常
  • -
  • 所有页面显示正确
  • -
  • 颜色风格统一
  • -
  • 无编译错误
  • +
  • 应用启动正常
  • +
  • 所有页面显示正确
  • +
  • 颜色风格统一
  • +
  • 无编译错误
-
-

总结

+
+

总结

配色更改已完成,应用现在使用深蓝色仪表盘风格的单主题模式。所有相关文件已更新,编译验证通过。接下来可以开始实施新功能页面的开发。

diff --git a/docs/checklist_new_features.md b/docs/checklist_new_features.md index 737017c..089d2e4 100644 --- a/docs/checklist_new_features.md +++ b/docs/checklist_new_features.md @@ -1,5 +1,21 @@ # PM3GUI 新功能页面开发检查清单 +> 下文 Phase 清单与原有勾选状态保留为 PR #27 之前的设计记录,不代表当前实现进度、 +> SDK 要求或受支持平台。当前开发入口见 [文档索引](README.md) 和 +> [Localization](localization.md);不要直接复制旧示例中的硬编码中文。 + +## 当前新增页面的本地化验收(PR #27) + +- [ ] 中英文 ARB 资源齐全,句子使用具名占位符,名词/动作等不同含义使用不同键。 +- [ ] 标签、工具提示、校验、确认/危险提示、空状态和错误均使用当前界面语言。 +- [ ] 临时校验模型继承当前语言;失败导入不改变原数据。 +- [ ] 原始 PM3 命令、十六进制数据、协议标识和持久化字段不被翻译。 +- [ ] 独立 Dart 工具不引入 Flutter 运行时依赖,实际 `dart run` 入口有回归覆盖。 +- [ ] 语言目录查找/保存失败可在设置页看到,初始化仍可完成,后续保存可重试。 +- [ ] 中英文的页面、对话框、默认/较小桌面窗口与文本放大均经过针对性检查。 +- [ ] 修改后运行 `flutter gen-l10n`、资源完整性检查、静态分析和相关测试。 +- [ ] 同步 Markdown/HTML 文档,并明确区分代码测试、Linux 构建和真实设备验证。 + ## 开发前检查 ### 环境准备 diff --git a/docs/complete_coverage_plan.md b/docs/complete_coverage_plan.md index c9c0076..4da1172 100644 --- a/docs/complete_coverage_plan.md +++ b/docs/complete_coverage_plan.md @@ -1,6 +1,11 @@ # PM3GUI 完整覆盖率提升计划 -## 1. 当前覆盖率分析 +> 此文件保留 PR #27 之前的覆盖率规划;下文协议计数、待添加页面与阶段状态 +> 是原规划记录,不是当前完成清单。PR #27 新增的是中英文界面与本地化流程, +> 不据此声称增加或验证协议能力。当前范围见 [README](../README.md)、 +> [实现现状](refactoring_plan.md) 和 [本地化指南](localization.md)。 + +## 1. 原规划时的覆盖率分析 ### 1.1 已覆盖协议(7个) - HF Mifare Classic diff --git a/docs/hf_14b_page_spec.md b/docs/hf_14b_page_spec.md index aa51e9c..f717e45 100644 --- a/docs/hf_14b_page_spec.md +++ b/docs/hf_14b_page_spec.md @@ -1,5 +1,9 @@ # HF 14443-B 页面文档 +> 原始设计参考(PR #27 之前)。下文中文图示、示例和功能目标不等于当前实现或实卡 +> 验证情况;新增/修改界面须使用 [中英文 ARB 与校验契约](localization.md)。 +> 当前使用与支持范围见 [README](../README.md)。 + ## 1. 页面概述 ### 1.1 功能目标 diff --git a/docs/hf_15_page_spec.md b/docs/hf_15_page_spec.md index 3e6f965..3fe3d25 100644 --- a/docs/hf_15_page_spec.md +++ b/docs/hf_15_page_spec.md @@ -1,5 +1,9 @@ # ISO15693 页面文档 +> 原始设计参考(PR #27 之前)。下文中文图示、示例和功能目标不等于当前实现或实卡 +> 验证情况;新增/修改界面须使用 [中英文 ARB 与校验契约](localization.md)。 +> 当前使用与支持范围见 [README](../README.md)。 + ## 1. 页面概述 ### 1.1 功能目标 diff --git a/docs/hf_iclass_page_spec.md b/docs/hf_iclass_page_spec.md index ea836f7..09f7d62 100644 --- a/docs/hf_iclass_page_spec.md +++ b/docs/hf_iclass_page_spec.md @@ -1,5 +1,9 @@ # iCLASS/Picopass 页面文档 +> 原始设计参考(PR #27 之前)。下文中文图示、示例和功能目标不等于当前实现或实卡 +> 验证情况;新增/修改界面须使用 [中英文 ARB 与校验契约](localization.md)。 +> 当前使用与支持范围见 [README](../README.md)。 + ## 1. 页面概述 ### 1.1 功能目标 diff --git a/docs/hf_mfdes_page_spec.md b/docs/hf_mfdes_page_spec.md index 9f3d829..2fede53 100644 --- a/docs/hf_mfdes_page_spec.md +++ b/docs/hf_mfdes_page_spec.md @@ -1,5 +1,12 @@ # Mifare DESFire 页面文档 +> 原始设计参考(PR #27 之前)。下文中文图示、示例和功能目标不等于当前实现或实卡 +> 验证情况;新增/修改界面须使用 [中英文 ARB 与校验契约](localization.md)。 +> 当前使用与支持范围见 [README](../README.md)。 +> +> 当前“应用”选项卡使用 `desfireApplicationsTab`,英文为 **Applications**; +> 编辑器的 **Apply** 动作保留 `messageApply`。两者不能因中文相同而合并。 + ## 1. 页面概述 ### 1.1 功能目标 diff --git a/docs/hf_mfu_page_spec.md b/docs/hf_mfu_page_spec.md index 49c4f48..6c1258a 100644 --- a/docs/hf_mfu_page_spec.md +++ b/docs/hf_mfu_page_spec.md @@ -1,5 +1,9 @@ # Mifare Ultralight/NTAG 页面文档 +> 原始设计参考(PR #27 之前)。下文中文图示、示例和功能目标不等于当前实现或实卡 +> 验证情况;新增/修改界面须使用 [中英文 ARB 与校验契约](localization.md)。 +> 当前使用与支持范围见 [README](../README.md)。 + ## 1. 页面概述 ### 1.1 功能目标 diff --git a/docs/lf_hid_page_spec.md b/docs/lf_hid_page_spec.md index bad0b4d..e9c768c 100644 --- a/docs/lf_hid_page_spec.md +++ b/docs/lf_hid_page_spec.md @@ -1,5 +1,14 @@ # HID Prox 页面文档 +> 原始设计参考(PR #27 之前)。下文中文图示、示例和功能目标不等于当前实现或实卡 +> 验证情况;新增/修改界面须使用 [中英文 ARB 与校验契约](localization.md)。 +> 当前使用与支持范围见 [README](../README.md)。 +> +> `lf hid brute` 针对读卡器枚举设施码/卡号,不执行密码学密钥恢复。当前界面已使用 +> `hidReaderBruteForceTab`、`hidReaderBruteForceTitle` 和 `hidStartBruteForce` +> 专用资源,并由[中英文回归测试](../test/ui/lf_hid_page_test.dart)覆盖; +> 行为与验证范围见[本地化指南](localization.md)。 + ## 1. 页面概述 ### 1.1 功能目标 diff --git a/docs/localization.md b/docs/localization.md new file mode 100644 index 0000000..71e1647 --- /dev/null +++ b/docs/localization.md @@ -0,0 +1,191 @@ +# Localization + +## PR #27 scope + +PR #27 adds English alongside Simplified Chinese without changing PM3 commands, +protocol identifiers, dump formats or raw client output. There are currently +701 messages in each catalog; `tools/check_localizations.dart` reports the +authoritative count. This is development/Unreleased content, not evidence that +an older release archive contains these features. + +Choose **Settings → Language** to follow the system, select **简体中文**, or +select **English**. Unsupported system languages fall back to English; Chinese +regional/script variants use the shipped Simplified Chinese bundle. +Framework menus/dialogs use the app locale, while native file dialogs keep the +OS language. Previously captured terminal output is not rewritten when the +language changes. + +## Design + +This project follows Flutter's official +[internationalization guide](https://docs.flutter.dev/ui/internationalization): + +- `flutter_localizations` supplies framework translations and text direction. +- `lib/l10n/app_en.arb` is the template; `app_zh.arb` preserves Simplified Chinese. +- `l10n.yaml` configures `flutter gen-l10n`; generated Dart files live beside + the ARB files and are not committed. +- `MaterialApp` uses generated delegates, supported locales and + `onGenerateTitle`. English is the fallback for unsupported system languages. +- UI code reads `context.l10n`. Stateful pages remain mounted when language + changes; navigation, text controllers, draft data and connections are retained. +- Domain objects receive localization explicitly through `updateLocalizations` + or optional `localizations` arguments. There is no mutable global locale or + run-time dictionary that replaces text in hardware output. The immutable + Chinese default preserves existing non-UI caller behavior. +- Dump-analysis caches include the locale; terminal suggestions are reloaded + on locale changes with stale asynchronous results discarded. +- Write-back progress stores a locale-independent status, current block and + per-block outcomes. The viewer formats these with `context.l10n` on display, + so active, finished, cancelled and connection-lost sequences follow language + changes without clearing results or restarting commands. Raw terminal output + remains unchanged. +- `Pm3Process` depends only on Dart SDK libraries and the pure-Dart + `Pm3ProcessMessages` contract. Its Chinese defaults preserve standalone-tool + behavior. `DesktopCliBackend` injects `LocalizedPm3ProcessMessages` to adapt + generated `AppLocalizations` without pulling Flutter into the process layer. + Retained connection and lifecycle errors store message kinds and diagnostic + arguments; `lastError` formats them through the current adapter. Changing + language refreshes the Connection error card without retrying the connection, + emitting new output or rewriting historical terminal/session logs. + Keep these defaults in sync with the Chinese ARB catalog; the message-contract + tests verify every process message. +- Staging `CommandCard` instances inherit the active localizations before + validating imported blocks or keys. Failed draft/card imports are atomic. +- Editor save confirmations retain the saved path and format `messageSaved` + with the current locale when displayed, without saving the file again. + Successful draft loads clear the status; save/import failures replace it; + cancelled file dialogs leave the existing status unchanged. +- The DESFire Applications tab uses `desfireApplicationsTab`; the editor's Apply + verb remains `messageApply`. Shared source-language spelling is not sufficient + reason to merge resources. +- HID Prox reader brute-force uses the dedicated `hidReaderBruteForceTab`, + `hidReaderBruteForceTitle` and `hidStartBruteForce` resources. These describe + facility-code/card-number enumeration against a reader, not cryptographic key + recovery. Shared key-recovery wording remains available for genuine + key-recovery workflows. + +## Editing messages + +Give messages stable, semantic camelCase keys. Localize complete sentences: +do not concatenate translated word fragments, use display text as a storage +key, or decide application behavior by comparing translated strings. +Keep different meanings in separate resources even when one language spells +them identically, such as the DESFire "Applications" noun and editor "Apply" verb. + +Each template entry requires `@messageKey` translator metadata. Interpolations +use named ARB placeholders with explicit Dart types. Counts with grammatical +variants use ICU plurals, for example: + +```json +{ + "messageCommands": "{value, plural, =0{No commands} =1{1 command} other{{value} commands}}", + "@messageCommands": { + "description": "Number of commands in terminal history.", + "placeholders": { + "value": {"type": "int"} + } + } +} +``` + +Keep placeholder names identical across languages, but allow translators to +reorder them. Hex values, commands, protocol names, serialized field names and +exported data must remain unchanged. Raw client output and OS diagnostic +details are not machine-translated. + +After editing: + +```powershell +flutter gen-l10n +dart run tools\check_localizations.dart +flutter analyze --no-pub --no-fatal-infos +flutter test --no-pub +``` + +The resource check rejects missing/extra/empty messages, missing template +metadata and untranslated Chinese in the English catalog. `gen-l10n` validates +ICU syntax and typed placeholders. Tests cover both languages on all 30 pages, +live switching, data retention, persistence, failure reporting, locale fallback, +framework strings, command-help language and locale-aware analysis. +The current page/tab smoke tests use a 1500 × 1100 viewport; the dedicated +DESFire/HID label tests use 1200 × 900. Loaded-dump write-progress tests use +1600 × 1100 to accommodate the fixed-width test font in the existing toolbar. +These are not a substitute for exercising +default/smaller desktop windows, text scaling, dialogs and real hardware. + +The focused regression files are: + +| Feature | Coverage | +|---|---| +| Preference loading, serialized saves and retries | `test/l10n/app_locale_controller_test.dart` | +| Directory exception conversion and diagnostics | `test/services/locale_settings_store_test.dart` | +| Startup Settings error, switching and page/tab rendering | `test/l10n/localization_test.dart` | +| Standalone message defaults, Flutter adapter and retained connection errors | `test/l10n/pm3_process_messages_test.dart` | +| Connection error card after Settings language changes, without retry/output changes | `test/l10n/localization_test.dart` | +| Retained lifecycle errors with replacement framework-free messages | `test/backend/pm3_process_test.dart` | +| English import errors and atomicity | `test/models/command_card_test.dart` | +| Retained editor save status, cancellation, successful-load clearing and error replacement | `test/ui/command_card_editor_test.dart` | +| Editor save confirmation after Settings language changes, without another save | `test/l10n/localization_test.dart` | +| Applications versus Apply | `test/ui/hf_mfdes_page_test.dart` | +| HID reader brute-force labels, language switching and unchanged command syntax | `test/ui/lf_hid_page_test.dart` | +| Locale-independent write states, mixed block results, cancellation and connection loss | `test/state/write_progress_test.dart` | +| Retained write-back progress/results after Settings language changes | `test/l10n/localization_test.dart` | +| Actual standalone Dart usage/failure paths | `test/tools/sync_command_catalog_test.dart` | + +The existing Windows batch-handshake fixture has an intermittent failure that +was also reproduced against unchanged upstream code. Report it separately from +new regressions; do not weaken its assertions or claim a fully clean run when +it fails. Linux builds and real-device operations require their own evidence. + +Use wrapping or flexible layouts for captions that expand in translation. +Do not shorten safety warnings to make them fit. + +## Adding a language + +1. Copy the template to `lib/l10n/app_.arb`, set `@@locale`, and + translate every message while preserving placeholders. +2. Add the language's native name to the selector in + `lib/ui/pages/settings_page.dart`. Native language names are deliberately + constant so users can recover from choosing an unfamiliar language. +3. Run the commands above and exercise the new locale, including dialogs, + errors, larger text and long captions. +4. If translated command help is available, add its asset and update + `Pm3CommandCatalog` selection. Otherwise document that command help remains + in English. Do not translate command syntax. + +Generated `supportedLocales` and the preference controller recognize languages +from the ARB files, so a separate hard-coded locale list is not required. + +## Preference storage + +`LocaleSettingsStore` writes `language.json` in the application support +directory using an exclusive temporary file, a flushed write and same-directory +replacement. `null` means system default. Writes are serialized and flushed +during application exit. Missing files are normal; corrupt, unsupported or +unwritable preferences produce an explicit Settings error. +Path-provider directory-lookup exceptions are converted to storage errors while +preserving their diagnostics and stack traces. Initialization can therefore +continue to the UI, and failed writes do not prevent later saves from retrying. +The current-session selection remains active after a save failure, but is not +guaranteed to survive restart until a later save succeeds. Resolve the +directory/permission problem and choose a language again to retry; there is no +automatic background retry or silent fallback to the working directory. + +The stored JSON is `{"language":"en"}`, `{"language":"zh"}` or +`{"language":null}`. Do not translate these codes or the `language` field name. + +## Standalone command-catalog synchronization + +After `flutter pub get`, `dart run tools\sync_command_catalog.dart` runs on the +standalone Dart VM. With no arguments it prints usage and exits with code 2; +with a client it connects, collects read-only help and writes the snapshot on +success (exit 0). Connection/collection errors return 1. + +For Windows, pass the distribution-root `pm3.bat`; for a native client, also +pass its port. This tool does not read `language.json`: it uses the pure-Dart +Chinese diagnostic defaults. It does not load generated Flutter localizations +or translate client output. GUI command help separately selects the bundled +English/Chinese YAML and augments it with the client snapshot. + +Language settings are separate from Proxmark3 launch settings and do not change +session records, device data, dump formats or client configuration. diff --git a/docs/refactoring_plan.md b/docs/refactoring_plan.md index 5b85085..2389dde 100644 --- a/docs/refactoring_plan.md +++ b/docs/refactoring_plan.md @@ -8,7 +8,7 @@ - Linux x64 是 1.0 的正式支持平台。 - Android、iOS、Web 和 macOS 不属于 1.0 的支持范围。 - PM3 GUI 不打包固件或客户端二进制文件。Windows 使用官方发行包根目录的 `pm3.bat`,Linux 使用用户安装的 `proxmark3`。 -- 当前有限实机证据来自 Windows x64:使用用户提供、对应 RRG commit `da509461` 的发行包和 PM3EASY512K(客户端识别为 `PM3 GENERIC / AT91SAM7S512 / 512K`),已完成 BAT 链路连接、真实提示符识别、只读 `hw version` 和断开。该结果不能外推到 Linux、其他设备、完整固件交互或真实卡片操作。 +- 既有有限实机记录来自 Windows x64:使用对应 RRG commit `da509461` 的发行包和 PM3EASY512K(`PM3 GENERIC / AT91SAM7S512 / 512K`),完成 BAT 连接、提示符识别、只读 `hw version`、断开及 2026-09-05 的 Mifare 页面只读检测。它不是 PR #27 新增的实机验证,不能外推到 Linux、其他设备、写卡/擦除或完整固件交互。 ## 当前架构 @@ -31,7 +31,17 @@ Pm3Process(内部进程适配器) 官方 Proxmark3 client ``` -`Pm3Backend` 是桌面实现与上层状态/UI 之间的契约,`DesktopCliBackend` 负责把官方客户端进程映射为连接、事件和命令结果。`Pm3Process` 只在桌面 CLI 后端内部使用;`dart:io Process` 不向 Widget 或 Feature 泄漏。 +`Pm3Backend` 是桌面实现与上层状态/UI 之间的契约,`DesktopCliBackend` 负责把官方客户端进程映射为连接、事件和命令结果。`Pm3Process` 由后端与独立 Dart 帮助同步工具复用;`dart:io Process` 不向 Widget 或 Feature 泄漏。 + +### PR #27:界面语言与工具边界 + +界面使用 Flutter 的 ARB / `gen-l10n` 资源,提供简体中文、英语与跟随系统选项;不支持的系统语言回退为英语。`AppLocaleController` 管理当前选择、保存状态和错误,`MaterialApp` 使用生成的 delegates 与 supported locales。切换语言不重新创建连接、页面草稿或文本控制器。 + +`AppLocalizations` 只进入 Flutter 侧。`DesktopCliBackend` 将其适配为纯 Dart 的 `Pm3ProcessMessages`,进程层和 `tools/sync_command_catalog.dart` 不依赖 `dart:ui`;独立工具保留中文诊断默认值。运行时不翻译命令、十六进制数据或原始客户端输出。 + +语言偏好独立保存在应用支持目录的 `language.json` 中,`language` 为 `en`、`zh` 或 `null`(跟随系统)。写入排队、使用同目录临时文件替换,退出等待队列完成。目录查找/读写错误显示于设置页,初始化仍可完成;修复目录问题后的新选择可以重试保存,不会回退到工作目录。 + +导入草稿或卡片时,验证用的临时 `CommandCard` 继承当前语言,失败时不覆盖原数据。Dump 分析缓存包含语言标识;终端命令帮助随语言重新加载并丢弃过期异步结果。新增文案和语言的维护契约见 [Localization](localization.md)。 ### 客户端设置 @@ -64,7 +74,7 @@ Pm3Process(内部进程适配器) GUI 专用页面只覆盖高频、稳定且能从结构化交互中获益的工作流。低频协议、诊断命令和官方客户端新增命令继续通过 Terminal 页面执行,以保留完整命令能力,避免为每个协议维护重复页面和不完整的参数封装。 -当前导航按通用功能、高频(HF)、低频(LF)和工具分组;已有页面和命令映射以 `lib/ui/pages/`、`lib/services/pm3_commands.dart` 及 `docs/pm3_commands*.{yaml,csv}` 为准。协议页面数量不是 1.0 的覆盖率指标。 +当前导航按通用功能、高频(HF)、低频(LF)和工具分组;已有页面和命令映射以 `lib/ui/pages/`、`lib/services/pm3_commands.dart`、`docs/pm3_commands*.yaml` 和 `docs/pm3_commands_client.json` 为准。协议页面数量不是 1.0 的覆盖率指标。 ## 下一阶段 @@ -79,4 +89,6 @@ GUI 专用页面只覆盖高频、稳定且能从结构化交互中获益的工 在 Windows x64 工作站上执行 Dart 格式检查、Flutter 静态分析、完整 Flutter 测试和 Windows debug 构建,是桌面基础层的代码级验证。仓库的 CI 工作流还声明了 Linux x64 构建路径,但本机运行结果不能替代 Linux runner 的实际构建。 -硬件验证目前仅覆盖用户提供、对应 RRG commit `da509461` 的 Windows 发行包与一台 PM3EASY512K(`PM3 GENERIC / AT91SAM7S512 / 512K`):根目录 `pm3.bat` 链路能够连接、输出真实提示符、执行只读 `hw version` 并断开。Linux、其他设备、完整固件交互和真实卡片操作仍未验证。 +PR #27 的 CI 在 Windows/Linux 都生成并检查本地化资源,静态分析与完整测试在 Linux job 执行。语言、存储恢复、导入和界面回归测试的具体文件与限制见 [Localization](localization.md);对先前实机记录的引用不表示本次本地化改动已重新执行全部硬件验收。 + +既有硬件记录仅覆盖对应 RRG commit `da509461` 的 Windows 发行包与一台 PM3EASY512K(`PM3 GENERIC / AT91SAM7S512 / 512K`):根目录 `pm3.bat` 能够连接、输出真实提示符、执行只读 `hw version` 并断开;2026-09-05 记录还包含 Mifare 页面“检测卡片”显示 ATQA/SAK。Linux、其他设备、写卡/擦除与完整固件交互仍没有这里可引用的验收证据。 diff --git a/docs/spec_new_features.md b/docs/spec_new_features.md index fc23759..d31c7d4 100644 --- a/docs/spec_new_features.md +++ b/docs/spec_new_features.md @@ -1,5 +1,10 @@ # PM3GUI 新功能页面开发规划文档 +> 这是 PR #27 之前的原始功能设计参考,不是当前功能或平台完成情况。 +> 中文布局/代码示例保留作设计说明;当前实现须使用 [Localization](localization.md) +> 规定的中英文资源、消息注入和校验行为,不能重新引入硬编码中文。 +> 最新使用与支持范围见 [文档索引](README.md)。 + ## 1. 项目概述 ### 1.1 目标 diff --git a/docs/superpowers/plans/2026-09-04-desktop-foundation.md b/docs/superpowers/plans/2026-09-04-desktop-foundation.md index 77ea152..318b65d 100644 --- a/docs/superpowers/plans/2026-09-04-desktop-foundation.md +++ b/docs/superpowers/plans/2026-09-04-desktop-foundation.md @@ -1,5 +1,10 @@ # PM3 Desktop Foundation 实现计划 +> **历史实现计划(2026-09-04)。** 下文步骤与执行说明记录当时的开发过程, +> 不是当前代理应直接执行的新指令。PR #27 的语言状态、独立 Dart 工具边界和 +> 存储恢复契约见 [Localization](../../localization.md);当前开发流程以 +> [CONTRIBUTING](../../../CONTRIBUTING.md) 为准。 + > **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 **目标:** 在已完成的 `Pm3Backend` 边界上补齐 Windows/Linux 客户端配置持久化、本地 Session 日志和可靠进程退出,使 Phase 2 形成可运行、可测试的桌面基础层。 diff --git a/docs/tasks_new_features.md b/docs/tasks_new_features.md index 0fb2042..45af3a8 100644 --- a/docs/tasks_new_features.md +++ b/docs/tasks_new_features.md @@ -1,5 +1,10 @@ # PM3GUI 新功能页面开发任务分解 +> 下文任务、工时和复选状态保留为 PR #27 之前的规划记录,不是当前待办或功能清单。 +> 新开发以 [当前实现与路线](refactoring_plan.md)、 +> [本地化指南](localization.md) 和 [贡献指南](../CONTRIBUTING.md) 为准; +> 页面示例中的中文需替换为 ARB 资源,命令/数据格式保持原样。 + ## 任务总览 将规划文档中的5个新页面分解为具体可执行的任务。 diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..213fd96 --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,7 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +output-class: AppLocalizations +required-resource-attributes: true +untranslated-messages-file: build/l10n_untranslated.json +format: true diff --git a/lib/backend/desktop_cli/desktop_cli_backend.dart b/lib/backend/desktop_cli/desktop_cli_backend.dart index d53a972..9781570 100644 --- a/lib/backend/desktop_cli/desktop_cli_backend.dart +++ b/lib/backend/desktop_cli/desktop_cli_backend.dart @@ -8,9 +8,11 @@ import 'package:pm3gui/core/pm3/pm3_event.dart'; import 'package:pm3gui/core/pm3/pm3_result.dart'; import 'pm3_process.dart'; +import 'package:pm3gui/l10n/localized_messages.dart'; +import 'package:pm3gui/l10n/localized_pm3_process_messages.dart'; /// Desktop backend backed by the official Proxmark3 CLI process. -final class DesktopCliBackend implements Pm3Backend { +final class DesktopCliBackend with LocalizedMessages implements Pm3Backend { DesktopCliBackend({Pm3Process? process}) : _process = process ?? Pm3Process() { _outputSubscription = _process.outputStream.listen((line) { @@ -29,6 +31,12 @@ final class DesktopCliBackend implements Pm3Backend { Pm3ConnectionConfig? _config; Future? _shutdownFuture; + @override + void updateLocalizations(AppLocalizations value) { + super.updateLocalizations(value); + _process.l10n = LocalizedPm3ProcessMessages(value); + } + /// Selects a sensible external-client default without leaking platform /// checks into application state or features. static String detectExecutable() { diff --git a/lib/backend/desktop_cli/pm3_process.dart b/lib/backend/desktop_cli/pm3_process.dart index 1cc9fa9..4024550 100644 --- a/lib/backend/desktop_cli/pm3_process.dart +++ b/lib/backend/desktop_cli/pm3_process.dart @@ -10,6 +10,7 @@ library; import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'pm3_process_messages.dart'; /// PM3 客户端进程的连接状态 enum Pm3ProcessState { @@ -18,6 +19,102 @@ enum Pm3ProcessState { connected // 已连接 } +enum _Pm3FailureKind { + disposed, + cooldown, + programNotFound, + fatalStdout, + handshakeWrite, + stdoutStream, + fatalStderr, + stderrStream, + exitedBeforePrompt, + exitStatus, + connectionTimeout, + start, + connection, + waitingForStart, + sendQuit, + readExitStatus, + streamClosed, + waitingForExit, + terminateTree, + forceTerminate, + exitTimeout, + terminateReturnedFalse, + terminate, + serialPortInUse, + serialDeviceNotFound, + serialPermissionDenied, + serialConnection, +} + +final class _Pm3Failure { + const _Pm3Failure(this.kind, [this.argument = '', this.secondArgument = '']); + + final _Pm3FailureKind kind; + final String argument; + final String secondArgument; + + bool matches(_Pm3Failure other) => + (kind, argument, secondArgument) == + (other.kind, other.argument, other.secondArgument); + + String format(Pm3ProcessMessages messages) => switch (kind) { + _Pm3FailureKind.disposed => + messages.messageThePm3ProcessManagerHasBeenDisposed, + _Pm3FailureKind.cooldown => + messages.messagePleaseWaitSecondsBeforeRetrying(argument), + _Pm3FailureKind.programNotFound => + messages.messagePm3ProgramNotFoundOnWindowsSelect(argument), + _Pm3FailureKind.fatalStdout => messages.messageFatalErrorOnPm3Stdout, + _Pm3FailureKind.handshakeWrite => + messages.messageFailedToSendTheReadOnlyPm3(argument), + _Pm3FailureKind.stdoutStream => + messages.messagePm3StdoutStreamError(argument), + _Pm3FailureKind.fatalStderr => messages.messageFatalErrorOnPm3Stderr, + _Pm3FailureKind.stderrStream => + messages.messagePm3StderrStreamError(argument), + _Pm3FailureKind.exitedBeforePrompt => + messages.messagePm3ProcessExitedBeforeTheConnectionPrompt(argument), + _Pm3FailureKind.exitStatus => + messages.messagePm3ProcessExitStatusError(argument), + _Pm3FailureKind.connectionTimeout => + messages.messageConnectionTimedOutSCheckThatThe(argument), + _Pm3FailureKind.start => messages.messageCouldNotStartPm3PathCheckThe( + argument, secondArgument), + _Pm3FailureKind.connection => + messages.messageConnectionFailed(argument), + _Pm3FailureKind.waitingForStart => + messages.messageFailedWhileWaitingForPm3ToStart(argument), + _Pm3FailureKind.sendQuit => messages.messageFailedToSendQuit(argument), + _Pm3FailureKind.readExitStatus => messages + .messageCouldNotReadTheExitStatusAfter(argument, secondArgument), + _Pm3FailureKind.streamClosed => + messages.messagePm3StreamClosed(argument), + _Pm3FailureKind.waitingForExit => + messages.messageFailedWhileWaitingForPm3ToExit(argument), + _Pm3FailureKind.terminateTree => + messages.messageFailedToTerminatePm3TermProcessTree, + _Pm3FailureKind.forceTerminate => + messages.messageFailedToForceTerminatePm3KillProcess, + _Pm3FailureKind.exitTimeout => + messages.messageTimedOutWaitingForPm3ToExit, + _Pm3FailureKind.terminateReturnedFalse => + messages.messageFailedToTerminatePm3ReturnedFalse(argument), + _Pm3FailureKind.terminate => + messages.messageFailedToTerminatePm3(argument), + _Pm3FailureKind.serialPortInUse => + messages.messageTheSerialPortIsInUseBy, + _Pm3FailureKind.serialDeviceNotFound => + messages.messageSerialDeviceNotFoundCheckThePm3, + _Pm3FailureKind.serialPermissionDenied => + messages.messagePermissionDeniedForTheSerialPortTry, + _Pm3FailureKind.serialConnection => + messages.messageSerialConnectionError(argument), + }; +} + /// 交互式 PM3 子进程所需的最小句柄,供生命周期测试注入可控实现。 abstract interface class Pm3ProcessHandle { Stream> get stdout; @@ -67,7 +164,7 @@ final class _IoPm3ProcessHandle implements Pm3ProcessHandle { throw ProcessException( 'taskkill', arguments, - '整树终止失败 (exit=${result.exitCode})${detail.isEmpty ? '' : ': $detail'}', + 'Failed to terminate process tree (exit=${result.exitCode})${detail.isEmpty ? '' : ': $detail'}', result.exitCode, ); } @@ -93,6 +190,8 @@ Future _startPm3Process( /// 包装 pm3 命令行进程以进行通信 class Pm3Process { + Pm3ProcessMessages l10n; + Pm3ProcessHandle? _process; // PM3 进程实例 StreamSubscription? _stdoutSubscription; StreamSubscription? _stderrSubscription; @@ -108,7 +207,7 @@ class Pm3Process { int _processGeneration = 0; Pm3ProcessState _state = Pm3ProcessState.disconnected; // 当前连接状态 String _version = ''; // PM3 版本信息 - String _lastError = ''; // 最后一次错误信息 + final List<_Pm3Failure> _lastErrors = []; DateTime? _lastConnectAttempt; // 最后一次连接尝试时间 /// 来自 pm3 stdout/stderr 的行流 @@ -128,13 +227,15 @@ class Pm3Process { Stream get stateStream => _stateController.stream; Pm3ProcessState get state => _state; String get version => _version; - String get lastError => _lastError; + String get lastError => + _lastErrors.map((error) => error.format(l10n)).join(';'); bool get isConnected => _state == Pm3ProcessState.connected; /// 连接尝试之间的最小间隔(防止重试风暴) static const _defaultConnectCooldown = Duration(seconds: 3); Pm3Process({ + this.l10n = const Pm3ProcessMessages(), Duration connectCooldown = _defaultConnectCooldown, Duration gracefulExitTimeout = const Duration(milliseconds: 500), Duration connectTimeout = const Duration(seconds: 15), @@ -196,7 +297,7 @@ class Pm3Process { String? workingDirectory, }) { if (_disposed || _shuttingDown) { - _lastError = 'PM3 进程管理器已释放'; + _setLastError(const _Pm3Failure(_Pm3FailureKind.disposed)); return Future.value(false); } if (_connectingFuture != null) return _connectingFuture!; @@ -227,13 +328,14 @@ class Pm3Process { final elapsed = DateTime.now().difference(_lastConnectAttempt!); if (elapsed < _connectCooldown) { final wait = _connectCooldown - elapsed; - _lastError = '请等待 ${wait.inSeconds + 1} 秒后再试'; - _emitOutput('[请等待冷却: ${wait.inSeconds + 1}s]'); + _setLastError(_Pm3Failure( + _Pm3FailureKind.cooldown, (wait.inSeconds + 1).toString())); + _emitOutput(l10n.messageCoolingDownS((wait.inSeconds + 1).toString())); return false; } } _lastConnectAttempt = DateTime.now(); - _lastError = ''; + _lastErrors.clear(); if (_state != Pm3ProcessState.disconnected) { await disconnect(); @@ -246,19 +348,17 @@ class Pm3Process { // 解析实际可执行文件路径 final resolved = resolvePm3Path(pm3Path); if (resolved == null) { - _lastError = '找不到 PM3 程序: $pm3Path\n' - 'Windows 请优先选择官方发行包根目录的 pm3.bat;' - 'Linux 请选择 proxmark3'; - _emitOutput('[错误] $_lastError'); + _setLastError(_Pm3Failure(_Pm3FailureKind.programNotFound, pm3Path)); + _emitOutput(l10n.messageError(lastError)); _setState(Pm3ProcessState.disconnected); return false; } final (execPath, resolvedWorkDir) = resolved; final workDir = workingDirectory ?? resolvedWorkDir; - _emitOutput('[使用 PM3: $execPath]'); + _emitOutput(l10n.messageUsingPm3((execPath).toString())); if (workDir != null) { - _emitOutput('[工作目录: $workDir]'); + _emitOutput(l10n.messageWorkingDirectory((workDir).toString())); } // 官方 Windows 发行包以根目录 pm3.bat 作为入口。脚本会自行完成 @@ -292,13 +392,17 @@ class Pm3Process { _connectionCompleter = completer; var connected = false; var batchHandshakeSent = false; - final readyMarker = 'PM3GUI_READY_${connectionGeneration}_${DateTime.now().microsecondsSinceEpoch}'; + final readyMarker = + 'PM3GUI_READY_${connectionGeneration}_${DateTime.now().microsecondsSinceEpoch}'; var stdoutPending = ''; void handleStdoutRecord(String line) { if (!identical(_process, process) || _disposed) return; - if (batchHandshakeSent && line.contains('pm3 --> rem $readyMarker')) return; - if (batchHandshakeSent && line.trimRight().endsWith('remark: $readyMarker')) { + if (batchHandshakeSent && line.contains('pm3 --> rem $readyMarker')) { + return; + } + if (batchHandshakeSent && + line.trimRight().endsWith('remark: $readyMarker')) { connected = true; _setState(Pm3ProcessState.connected); if (!completer.isCompleted) completer.complete(true); @@ -320,7 +424,8 @@ class Pm3Process { // 检测致命错误和连接提示 if (_detectFatalError(line)) { - _failConnecting(completer, 'PM3 stdout 出现致命错误'); + _failConnecting( + completer, const _Pm3Failure(_Pm3FailureKind.fatalStdout)); unawaited(_closeCurrentProcess( process, connectionGeneration, @@ -351,9 +456,11 @@ class Pm3Process { await process.writeLine('rem $readyMarker'); } catch (error) { if (!identical(_process, process) || _disposed) return; - _lastError = 'PM3 BAT 只读握手写入失败: $error'; - _emitOutput('[错误] $_lastError'); - _failConnecting(completer, _lastError); + final failure = + _Pm3Failure(_Pm3FailureKind.handshakeWrite, error.toString()); + _setLastError(failure); + _emitOutput(l10n.messageError(lastError)); + _failConnecting(completer, failure); await _closeCurrentProcess( process, connectionGeneration, @@ -391,10 +498,13 @@ class Pm3Process { // 监听 stdout // 部分 Windows 客户端帮助文本含非 UTF-8 字节;保留后续输出与会话。 - _stdoutSubscription = process.stdout.transform(const Utf8Decoder(allowMalformed: true)).listen( + _stdoutSubscription = process.stdout + .transform(const Utf8Decoder(allowMalformed: true)) + .listen( handleStdoutChunk, onError: (Object error, StackTrace stackTrace) { - _failConnecting(completer, 'PM3 stdout 流错误: $error'); + _failConnecting(completer, + _Pm3Failure(_Pm3FailureKind.stdoutStream, error.toString())); unawaited(_closeCurrentProcess( process, connectionGeneration, @@ -422,7 +532,8 @@ class Pm3Process { .listen((line) { if (!identical(_process, process) || _disposed) return; if (_detectFatalError(line)) { - _failConnecting(completer, 'PM3 stderr 出现致命错误'); + _failConnecting( + completer, const _Pm3Failure(_Pm3FailureKind.fatalStderr)); unawaited(_closeCurrentProcess( process, connectionGeneration, @@ -433,7 +544,8 @@ class Pm3Process { if (_commandCompleter != null) _responseBuffer.writeln('[ERR] $line'); _emitOutput('[ERR] $line'); }, onError: (Object error, StackTrace stackTrace) { - _failConnecting(completer, 'PM3 stderr 流错误: $error'); + _failConnecting(completer, + _Pm3Failure(_Pm3FailureKind.stderrStream, error.toString())); unawaited(_closeCurrentProcess( process, connectionGeneration, @@ -452,15 +564,17 @@ class Pm3Process { unawaited(process.exitCode.then( (code) async { if (!identical(_process, process) || _disposed) return; - _emitOutput('[PM3 进程退出, code=$code]'); + _emitOutput(l10n.messagePm3ProcessExitedCode((code).toString())); if (!connected) { - _lastError = 'PM3 进程在连接提示前退出 (code=$code)'; + _setLastError(_Pm3Failure( + _Pm3FailureKind.exitedBeforePrompt, code.toString())); if (!completer.isCompleted) completer.complete(false); } await _closeCurrentProcess(process, connectionGeneration); }, onError: (Object error, StackTrace stackTrace) async { - _failConnecting(completer, 'PM3 进程退出状态错误: $error'); + _failConnecting(completer, + _Pm3Failure(_Pm3FailureKind.exitStatus, error.toString())); await _closeCurrentProcess( process, connectionGeneration, @@ -474,8 +588,9 @@ class Pm3Process { return await completer.future.timeout(_connectTimeout); } on TimeoutException { if (!connected && identical(_process, process)) { - _lastError = '连接超时 (${_connectTimeout.inSeconds}s),请检查设备是否已连接'; - _emitOutput('[连接超时]'); + _setLastError(_Pm3Failure(_Pm3FailureKind.connectionTimeout, + _connectTimeout.inSeconds.toString())); + _emitOutput(l10n.messageConnectionTimedOut); await _closeCurrentProcess( process, connectionGeneration, @@ -486,18 +601,16 @@ class Pm3Process { } } on ProcessException catch (e) { _startingFuture = null; - _lastError = '无法启动 PM3 程序: ${e.message}\n' - '路径: $pm3Path\n' - '请检查路径是否正确,程序是否已编译'; - _emitOutput('[错误] $_lastError'); + _setLastError(_Pm3Failure(_Pm3FailureKind.start, e.message, pm3Path)); + _emitOutput(l10n.messageError(lastError)); if (connectionGeneration == _processGeneration) { _setState(Pm3ProcessState.disconnected); } return false; } catch (e) { _startingFuture = null; - _lastError = '连接失败: $e'; - _emitOutput('[错误] $_lastError'); + _setLastError(_Pm3Failure(_Pm3FailureKind.connection, e.toString())); + _emitOutput(l10n.messageError(lastError)); if (connectionGeneration == _processGeneration) { _setState(Pm3ProcessState.disconnected); } @@ -508,7 +621,7 @@ class Pm3Process { /// 向交互式会话发送命令 Future sendCommand(String command) async { if (_process == null || _state != Pm3ProcessState.connected) { - _emitOutput('[未连接]'); + _emitOutput(l10n.messageNotConnected); return; } _emitOutput('[pm3] $command'); @@ -517,24 +630,23 @@ class Pm3Process { /// 等待客户端执行完成标记;静默、表格边框和命令回显都不是结束信号。 /// 默认持续等待。显式超时会断开会话,防止后续命令混入未完成的输出。 - Future sendCommandAndWait(String command, - {Duration? timeout}) async { + Future sendCommandAndWait(String command, {Duration? timeout}) async { if (_process == null || _state != Pm3ProcessState.connected) { - return '[未连接]'; + return l10n.messageNotConnected; } if (_commandCompleter != null) { - throw StateError('PM3 命令仍在执行,请等待完成或断开连接'); + throw StateError(l10n.pm3Busy); } final process = _process!; final completer = Completer(); _commandCompleter = completer; - _commandMarker = 'PM3GUI_DONE_${DateTime.now().microsecondsSinceEpoch}_${++_commandSequence}'; + _commandMarker = + 'PM3GUI_DONE_${DateTime.now().microsecondsSinceEpoch}_${++_commandSequence}'; _responseBuffer.clear(); // 在写 stdin 前注册错误处理,断开或写入失败也不会产生未处理 Future。 - final result = timeout == null - ? completer.future - : completer.future.timeout(timeout); + final result = + timeout == null ? completer.future : completer.future.timeout(timeout); unawaited(result.then((_) {}, onError: (Object _) {})); try { _emitOutput('[pm3] $command'); @@ -542,7 +654,7 @@ class Pm3Process { await process.writeLine('rem $_commandMarker'); return await result; } on TimeoutException { - _emitOutput('[错误] 命令超时,会话已停止'); + _emitOutput(l10n.messageErrorCommandTimedOutTheSessionWas); await disconnect(); rethrow; } catch (_) { @@ -558,7 +670,8 @@ class Pm3Process { void _failCommand() { final completer = _commandCompleter; if (completer != null && !completer.isCompleted) { - completer.completeError(StateError('PM3 会话在命令完成前断开')); + completer.completeError( + StateError(l10n.messageThePm3SessionDisconnectedBeforeTheCommand)); } } @@ -603,7 +716,8 @@ class Pm3Process { try { await _terminateUnownedOnce(await starting); } catch (error) { - _recordLifecycleError('等待 PM3 启动失败: $error'); + _recordLifecycleError( + _Pm3Failure(_Pm3FailureKind.waitingForStart, error.toString())); } } } finally { @@ -647,7 +761,8 @@ class Pm3Process { final startedProcess = await starting; await _terminateUnownedOnce(startedProcess); } catch (error) { - _recordLifecycleError('等待 PM3 启动失败: $error'); + _recordLifecycleError( + _Pm3Failure(_Pm3FailureKind.waitingForStart, error.toString())); } } } finally { @@ -676,7 +791,8 @@ class Pm3Process { try { await process.writeLine('quit'); } catch (error) { - _recordLifecycleError('发送 quit 失败: $error'); + _recordLifecycleError( + _Pm3Failure(_Pm3FailureKind.sendQuit, error.toString())); } final exited = await _waitForExit(process, _gracefulExitTimeout); if (!exited) forceKill = true; @@ -725,18 +841,22 @@ class Pm3Process { const Duration(milliseconds: 100), ); if (!identical(_process, process) || _disposed) return; - _emitOutput('[PM3 进程退出, code=$code]'); - _lastError = 'PM3 进程在连接提示前退出 (code=$code)'; - _failConnecting(completer, _lastError); + _emitOutput(l10n.messagePm3ProcessExitedCode((code).toString())); + final failure = + _Pm3Failure(_Pm3FailureKind.exitedBeforePrompt, code.toString()); + _setLastError(failure); + _failConnecting(completer, failure); await _closeCurrentProcess(process, generation); return; } on TimeoutException { // 输出管道孤立关闭,以下路径强制收口该进程。 } catch (error) { - _recordLifecycleError('PM3 $streamName 流关闭后读取退出状态失败: $error'); + _recordLifecycleError(_Pm3Failure( + _Pm3FailureKind.readExitStatus, streamName, error.toString())); } if (!identical(_process, process) || _disposed) return; - _failConnecting(completer, 'PM3 $streamName 流已关闭'); + _failConnecting( + completer, _Pm3Failure(_Pm3FailureKind.streamClosed, streamName)); await _closeCurrentProcess(process, generation, forceKill: true); } @@ -747,7 +867,8 @@ class Pm3Process { } on TimeoutException { return false; } catch (error) { - _recordLifecycleError('等待 PM3 退出失败: $error'); + _recordLifecycleError( + _Pm3Failure(_Pm3FailureKind.waitingForExit, error.toString())); return false; } } @@ -755,18 +876,19 @@ class Pm3Process { Future _killAndWait(Pm3ProcessHandle process) async { final terminated = await _tryTerminate(process, force: false); if (!terminated) { - _recordLifecycleError('终止 PM3 进程失败: TERM/整树终止未成功'); + _recordLifecycleError(const _Pm3Failure(_Pm3FailureKind.terminateTree)); } var exited = await _waitForExit(process, _killExitTimeout); if (!exited) { final forceTerminated = await _tryTerminate(process, force: true); if (!forceTerminated) { - _recordLifecycleError('强制终止 PM3 进程失败: KILL/整树终止未成功'); + _recordLifecycleError( + const _Pm3Failure(_Pm3FailureKind.forceTerminate)); } exited = await _waitForExit(process, _killExitTimeout); } if (!exited) { - _recordLifecycleError('终止 PM3 后等待退出超时'); + _recordLifecycleError(const _Pm3Failure(_Pm3FailureKind.exitTimeout)); } } @@ -778,12 +900,14 @@ class Pm3Process { final terminated = await process.terminate(force: force); if (!terminated) { _recordLifecycleError( - '终止 PM3 进程失败: ${force ? 'KILL' : 'TERM'} 返回 false', + _Pm3Failure( + _Pm3FailureKind.terminateReturnedFalse, force ? 'KILL' : 'TERM'), ); } return terminated; } catch (error) { - _recordLifecycleError('终止 PM3 进程失败: $error'); + _recordLifecycleError( + _Pm3Failure(_Pm3FailureKind.terminate, error.toString())); return false; } } @@ -806,19 +930,23 @@ class Pm3Process { } } - void _failConnecting(Completer completer, String fallbackError) { + void _setLastError(_Pm3Failure error) { + _lastErrors + ..clear() + ..add(error); + } + + void _failConnecting(Completer completer, _Pm3Failure fallbackError) { if (_disposed) return; - if (_lastError.isEmpty) _lastError = fallbackError; + if (_lastErrors.isEmpty) _lastErrors.add(fallbackError); if (!completer.isCompleted) completer.complete(false); } - void _recordLifecycleError(String message) { - if (_lastError.isEmpty) { - _lastError = message; - } else if (!_lastError.contains(message)) { - _lastError = '$_lastError;$message'; + void _recordLifecycleError(_Pm3Failure error) { + if (!_lastErrors.any((existing) => existing.matches(error))) { + _lastErrors.add(error); } - _emitOutput('[错误] $message'); + _emitOutput(l10n.messageError(error.format(l10n))); } /// 检测致命错误,这意味着我们应该停止尝试 @@ -827,23 +955,20 @@ class Pm3Process { if (lower.contains('claimed by another process') || lower.contains('is claimed by')) { - _lastError = '串口 已被其他进程占用\n' - '请关闭其他使用该端口的程序(如另一个 pm3 终端)'; + _setLastError(const _Pm3Failure(_Pm3FailureKind.serialPortInUse)); return true; } if (lower.contains('no such file or directory') && lower.contains('serial')) { - _lastError = '串口设备不存在,请检查 PM3 是否已通过 USB 连接'; + _setLastError(const _Pm3Failure(_Pm3FailureKind.serialDeviceNotFound)); return true; } if (lower.contains('permission denied')) { - _lastError = '串口权限不足\n' - '请尝试: sudo chmod 666 /dev/ttyACM0\n' - '或将用户加入 dialout 组'; + _setLastError(const _Pm3Failure(_Pm3FailureKind.serialPermissionDenied)); return true; } if (lower.contains('error') && lower.contains('serial port')) { - _lastError = '串口连接错误: $line'; + _setLastError(_Pm3Failure(_Pm3FailureKind.serialConnection, line)); return true; } return false; diff --git a/lib/backend/desktop_cli/pm3_process_messages.dart b/lib/backend/desktop_cli/pm3_process_messages.dart new file mode 100644 index 0000000..75137ca --- /dev/null +++ b/lib/backend/desktop_cli/pm3_process_messages.dart @@ -0,0 +1,61 @@ +/// Framework-independent message contract with legacy Chinese defaults. +/// +/// Flutter callers supply an adapter; standalone Dart tools need no framework. +class Pm3ProcessMessages { + const Pm3ProcessMessages(); + + String messageConnectionFailed(String value) => '连接失败: $value'; + String get messageConnectionTimedOut => '[连接超时]'; + String messageConnectionTimedOutSCheckThatThe(String value) => + '连接超时 (${value}s),请检查设备是否已连接'; + String messageCoolingDownS(String value) => '[请等待冷却: ${value}s]'; + String messageCouldNotReadTheExitStatusAfter(String value, String value2) => + 'PM3 $value 流关闭后读取退出状态失败: $value2'; + String messageCouldNotStartPm3PathCheckThe(String value, String value2) => + '无法启动 PM3 程序: $value\n路径: $value2\n请检查路径是否正确,程序是否已编译'; + String messageError(String value) => '[错误] $value'; + String get messageErrorCommandTimedOutTheSessionWas => '[错误] 命令超时,会话已停止'; + String get messageFailedToForceTerminatePm3KillProcess => + '强制终止 PM3 进程失败: KILL/整树终止未成功'; + String messageFailedToSendQuit(String value) => '发送 quit 失败: $value'; + String messageFailedToSendTheReadOnlyPm3(String value) => + 'PM3 BAT 只读握手写入失败: $value'; + String messageFailedToTerminatePm3(String value) => '终止 PM3 进程失败: $value'; + String messageFailedToTerminatePm3ReturnedFalse(String value) => + '终止 PM3 进程失败: $value 返回 false'; + String get messageFailedToTerminatePm3TermProcessTree => + '终止 PM3 进程失败: TERM/整树终止未成功'; + String messageFailedWhileWaitingForPm3ToExit(String value) => + '等待 PM3 退出失败: $value'; + String messageFailedWhileWaitingForPm3ToStart(String value) => + '等待 PM3 启动失败: $value'; + String get messageFatalErrorOnPm3Stderr => 'PM3 stderr 出现致命错误'; + String get messageFatalErrorOnPm3Stdout => 'PM3 stdout 出现致命错误'; + String get messageNotConnected => '[未连接]'; + String get messagePermissionDeniedForTheSerialPortTry => + '串口权限不足\n请尝试: sudo chmod 666 /dev/ttyACM0\n或将用户加入 dialout 组'; + String messagePleaseWaitSecondsBeforeRetrying(String value) => + '请等待 $value 秒后再试'; + String messagePm3ProcessExitStatusError(String value) => + 'PM3 进程退出状态错误: $value'; + String messagePm3ProcessExitedBeforeTheConnectionPrompt(String value) => + 'PM3 进程在连接提示前退出 (code=$value)'; + String messagePm3ProcessExitedCode(String value) => '[PM3 进程退出, code=$value]'; + String messagePm3ProgramNotFoundOnWindowsSelect(String value) => + '找不到 PM3 程序: $value\nWindows 请优先选择官方发行包根目录的 pm3.bat;Linux 请选择 proxmark3'; + String messagePm3StderrStreamError(String value) => 'PM3 stderr 流错误: $value'; + String messagePm3StdoutStreamError(String value) => 'PM3 stdout 流错误: $value'; + String messagePm3StreamClosed(String value) => 'PM3 $value 流已关闭'; + String messageSerialConnectionError(String value) => '串口连接错误: $value'; + String get messageSerialDeviceNotFoundCheckThePm3 => + '串口设备不存在,请检查 PM3 是否已通过 USB 连接'; + String get messageThePm3ProcessManagerHasBeenDisposed => 'PM3 进程管理器已释放'; + String get messageThePm3SessionDisconnectedBeforeTheCommand => + 'PM3 会话在命令完成前断开'; + String get messageTheSerialPortIsInUseBy => + '串口 已被其他进程占用\n请关闭其他使用该端口的程序(如另一个 pm3 终端)'; + String get messageTimedOutWaitingForPm3ToExit => '终止 PM3 后等待退出超时'; + String messageUsingPm3(String value) => '[使用 PM3: $value]'; + String messageWorkingDirectory(String value) => '[工作目录: $value]'; + String get pm3Busy => 'PM3 命令仍在执行,请等待完成或断开连接'; +} diff --git a/lib/core/pm3/pm3_controller.dart b/lib/core/pm3/pm3_controller.dart index 900a367..481a1f2 100644 --- a/lib/core/pm3/pm3_controller.dart +++ b/lib/core/pm3/pm3_controller.dart @@ -5,12 +5,22 @@ import 'pm3_command.dart'; import 'pm3_connection.dart'; import 'pm3_event.dart'; import 'pm3_result.dart'; +import 'package:pm3gui/l10n/localized_messages.dart'; /// The only PM3 entry point exposed to application and feature layers. -final class Pm3Controller { +final class Pm3Controller with LocalizedMessages { Pm3Controller(this._backend); final Pm3Backend _backend; + + @override + void updateLocalizations(AppLocalizations value) { + super.updateLocalizations(value); + if (_backend case final LocalizedMessages backend) { + backend.updateLocalizations(value); + } + } + final StreamController _commands = StreamController.broadcast(sync: true); Future? _shutdownFuture; @@ -40,7 +50,7 @@ final class Pm3Controller { Future disconnect() => _backend.disconnect(); Future execute(Pm3Command command, {Duration? timeout}) async { - if (_isExecuting) throw StateError('PM3 命令仍在执行,请等待完成或断开连接'); + if (_isExecuting) throw StateError(l10n.pm3Busy); _isExecuting = true; try { _commands.add(command); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 0000000..9152a03 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,3350 @@ +{ + "@@locale": "en", + "appTitle": "PM3 GUI", + "@appTitle": { + "description": "Application title" + }, + "navConnection": "Connection", + "@navConnection": { + "description": "Connection navigation entry" + }, + "navTerminal": "Terminal", + "@navTerminal": { + "description": "Terminal navigation entry" + }, + "navDumpViewer": "Dump viewer", + "@navDumpViewer": { + "description": "Dump viewer navigation entry" + }, + "navDumpCompare": "Compare dumps", + "@navDumpCompare": { + "description": "Dump comparison navigation entry" + }, + "navHfSniff": "HF sniff / tune", + "@navHfSniff": { + "description": "High-frequency sniffing and tuning navigation" + }, + "navLfGeneral": "LF general / EM / T55", + "@navLfGeneral": { + "description": "Low-frequency general operations navigation" + }, + "navData": "Data processing", + "@navData": { + "description": "Data processing navigation entry" + }, + "navScripts": "Scripts", + "@navScripts": { + "description": "Scripts navigation entry" + }, + "navSettings": "Settings", + "@navSettings": { + "description": "Settings page title and navigation entry" + }, + "navCollapse": "Collapse menu", + "@navCollapse": { + "description": "Collapse navigation sidebar" + }, + "navHighFrequency": "High frequency (HF)", + "@navHighFrequency": { + "description": "High-frequency navigation group" + }, + "navLowFrequency": "Low frequency (LF)", + "@navLowFrequency": { + "description": "Low-frequency navigation group" + }, + "navTools": "Tools", + "@navTools": { + "description": "Tools navigation group" + }, + "connected": "Connected", + "@connected": { + "description": "Device connection status" + }, + "disconnected": "Disconnected", + "@disconnected": { + "description": "Device disconnected status" + }, + "pm3NotConnected": "PM3 is not connected", + "@pm3NotConnected": { + "description": "Command rejected because no device is connected" + }, + "pm3Busy": "A PM3 command is still running. Wait for it to finish or disconnect.", + "@pm3Busy": { + "description": "Command rejected because another command is running" + }, + "hexDigits": "{count} hex digits", + "@hexDigits": { + "description": "Hex input length hint", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "maxCharacters": "Maximum {count} characters", + "@maxCharacters": { + "description": "Input character limit", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "executingCommand": "Execute: {command}", + "@executingCommand": { + "description": "Executed command heading", + "placeholders": { + "command": { + "type": "String" + } + } + }, + "executeToViewResults": "Run a command to view results", + "@executeToViewResults": { + "description": "Empty command result panel" + }, + "copiedToClipboard": "Copied to clipboard", + "@copiedToClipboard": { + "description": "Copy confirmation" + }, + "copy": "Copy", + "@copy": { + "description": "Copy result action" + }, + "clear": "Clear", + "@clear": { + "description": "Clear result action" + }, + "language": "Language", + "@language": { + "description": "Display language setting" + }, + "languageSystem": "System default", + "@languageSystem": { + "description": "Follow the operating system language" + }, + "languageHelp": "Changes apply immediately. Unsupported system languages use English.", + "@languageHelp": { + "description": "Language preference help" + }, + "languagePreferenceError": "Could not load or save the language preference: {error}", + "@languagePreferenceError": { + "description": "Visible language preference persistence error", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "settingsPm3Configuration": "PM3 configuration", + "@settingsPm3Configuration": { + "description": "Client configuration section" + }, + "settingsPm3Path": "PM3 program path", + "@settingsPm3Path": { + "description": "Client executable path field" + }, + "settingsWindowsPathHint": "Official distribution root\\pm3.bat", + "@settingsWindowsPathHint": { + "description": "Windows client path example" + }, + "settingsWindowsPathHelp": "On Windows, select pm3.bat in the official distribution root.", + "@settingsWindowsPathHelp": { + "description": "Windows launcher guidance" + }, + "settingsPickPm3": "Select PM3 program", + "@settingsPickPm3": { + "description": "Browse for client executable" + }, + "settingsArguments": "PM3 launch arguments", + "@settingsArguments": { + "description": "Client argument field" + }, + "settingsWindowsArgumentsHelp": "pm3.bat initializes its environment and detects devices itself; these arguments are not passed to the script. One argument per line; spaces, quotes and backslashes are preserved.", + "@settingsWindowsArgumentsHelp": { + "description": "Windows launcher argument behavior" + }, + "settingsArgumentsHelp": "One argument per line; spaces, quotes and backslashes are preserved.", + "@settingsArgumentsHelp": { + "description": "Native executable argument behavior" + }, + "settingsWorkingDirectory": "PM3 working directory (optional)", + "@settingsWorkingDirectory": { + "description": "Optional client working directory field" + }, + "settingsSaving": "Saving settings…", + "@settingsSaving": { + "description": "Client settings save progress" + }, + "settingsAbout": "About PM3 GUI", + "@settingsAbout": { + "description": "About section title" + }, + "settingsDescription": "A Flutter-based Proxmark3 graphical interface", + "@settingsDescription": { + "description": "Application description" + }, + "settingsPlatform": "Platform", + "@settingsPlatform": { + "description": "Operating system field" + }, + "settingsArchitecture": "Architecture", + "@settingsArchitecture": { + "description": "Software architecture field" + }, + "settingsArchitectureDescription": "CLI wrapper (compatible with future updates)", + "@settingsArchitectureDescription": { + "description": "Software architecture description" + }, + "settingsSupportedFormats": "Supported formats", + "@settingsSupportedFormats": { + "description": "Supported file formats label" + }, + "settingsMaintenance": "Maintenance", + "@settingsMaintenance": { + "description": "Maintenance section title" + }, + "terminalClear": "Clear terminal", + "@terminalClear": { + "description": "Clear terminal output action" + }, + "hardwareVersion": "Hardware version", + "@hardwareVersion": { + "description": "Read hardware version action" + }, + "antennaTune": "Antenna tuning", + "@antennaTune": { + "description": "Antenna tuning action" + }, + "terminalHfSearch": "HF search", + "@terminalHfSearch": { + "description": "High-frequency search quick command" + }, + "terminalLfSearch": "LF search", + "@terminalLfSearch": { + "description": "Low-frequency search quick command" + }, + "terminal14aInfo": "14A information", + "@terminal14aInfo": { + "description": "ISO 14443-A information quick command" + }, + "terminalLfRead": "LF read", + "@terminalLfRead": { + "description": "Low-frequency read quick command" + }, + "terminalAutopwn": "Automatic key recovery", + "@terminalAutopwn": { + "description": "MIFARE automatic key recovery quick command" + }, + "terminalHint": "Hint: {hint}", + "@terminalHint": { + "description": "Command hint caption", + "placeholders": { + "hint": { + "type": "String" + } + } + }, + "terminalInputHint": "Enter a command… (Tab / Shift+Tab cycles completions)", + "@terminalInputHint": { + "description": "Terminal input placeholder" + }, + "terminalCatalog": "Command library", + "@terminalCatalog": { + "description": "Open the command library" + }, + "terminalSend": "Send", + "@terminalSend": { + "description": "Send terminal command" + }, + "terminalSearchHint": "Search commands (e.g. hf mf, autopwn, info)", + "@terminalSearchHint": { + "description": "Command library search placeholder" + }, + "terminalCatalogError": "Could not load the command library: {error}", + "@terminalCatalogError": { + "description": "Command catalog loading failure", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "messageFailedToTerminateTheProcessTreeExit": "Failed to terminate the process tree (exit={value}){value2}", + "@messageFailedToTerminateTheProcessTreeExit": { + "description": "pm3_process: Failed to terminate the process tree (exit={value}){value2}", + "placeholders": { + "value": { + "type": "String" + }, + "value2": { + "type": "String" + } + } + }, + "messageThePm3ProcessManagerHasBeenDisposed": "The PM3 process manager has been disposed", + "@messageThePm3ProcessManagerHasBeenDisposed": { + "description": "pm3_process: The PM3 process manager has been disposed" + }, + "messagePleaseWaitSecondsBeforeRetrying": "Please wait {value} seconds before retrying", + "@messagePleaseWaitSecondsBeforeRetrying": { + "description": "pm3_process: Please wait {value} seconds before retrying", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageCoolingDownS": "[Cooling down: {value}s]", + "@messageCoolingDownS": { + "description": "pm3_process: [Cooling down: {value}s]", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messagePm3ProgramNotFoundOnWindowsSelect": "PM3 program not found: {value}\nOn Windows, select pm3.bat in the official distribution root; on Linux, select proxmark3.", + "@messagePm3ProgramNotFoundOnWindowsSelect": { + "description": "pm3_process: PM3 program not found: {value}\nOn Windows, select pm3.bat in the official distribution root; on Linux, select proxmark3.", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageError": "[Error] {value}", + "@messageError": { + "description": "pm3_process: [Error] {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageUsingPm3": "[Using PM3: {value}]", + "@messageUsingPm3": { + "description": "pm3_process: [Using PM3: {value}]", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageWorkingDirectory": "[Working directory: {value}]", + "@messageWorkingDirectory": { + "description": "pm3_process: [Working directory: {value}]", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageFatalErrorOnPm3Stdout": "Fatal error on PM3 stdout", + "@messageFatalErrorOnPm3Stdout": { + "description": "pm3_process: Fatal error on PM3 stdout" + }, + "messageFailedToSendTheReadOnlyPm3": "Failed to send the read-only PM3 BAT handshake: {value}", + "@messageFailedToSendTheReadOnlyPm3": { + "description": "pm3_process: Failed to send the read-only PM3 BAT handshake: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messagePm3StdoutStreamError": "PM3 stdout stream error: {value}", + "@messagePm3StdoutStreamError": { + "description": "pm3_process: PM3 stdout stream error: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageFatalErrorOnPm3Stderr": "Fatal error on PM3 stderr", + "@messageFatalErrorOnPm3Stderr": { + "description": "pm3_process: Fatal error on PM3 stderr" + }, + "messagePm3StderrStreamError": "PM3 stderr stream error: {value}", + "@messagePm3StderrStreamError": { + "description": "pm3_process: PM3 stderr stream error: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messagePm3ProcessExitedCode": "[PM3 process exited, code={value}]", + "@messagePm3ProcessExitedCode": { + "description": "pm3_process: [PM3 process exited, code={value}]", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messagePm3ProcessExitedBeforeTheConnectionPrompt": "PM3 process exited before the connection prompt (code={value})", + "@messagePm3ProcessExitedBeforeTheConnectionPrompt": { + "description": "pm3_process: PM3 process exited before the connection prompt (code={value})", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messagePm3ProcessExitStatusError": "PM3 process exit status error: {value}", + "@messagePm3ProcessExitStatusError": { + "description": "pm3_process: PM3 process exit status error: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageConnectionTimedOutSCheckThatThe": "Connection timed out ({value}s). Check that the device is connected.", + "@messageConnectionTimedOutSCheckThatThe": { + "description": "pm3_process: Connection timed out ({value}s). Check that the device is connected.", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageConnectionTimedOut": "[Connection timed out]", + "@messageConnectionTimedOut": { + "description": "pm3_process: [Connection timed out]" + }, + "messageCouldNotStartPm3PathCheckThe": "Could not start PM3: {value}\nPath: {value2}\nCheck the program path and that the client has been built.", + "@messageCouldNotStartPm3PathCheckThe": { + "description": "pm3_process: Could not start PM3: {value}\nPath: {value2}\nCheck the program path and that the client has been built.", + "placeholders": { + "value": { + "type": "String" + }, + "value2": { + "type": "String" + } + } + }, + "messageConnectionFailed": "Connection failed: {value}", + "@messageConnectionFailed": { + "description": "pm3_process: Connection failed: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageNotConnected": "[Not connected]", + "@messageNotConnected": { + "description": "pm3_process: [Not connected]" + }, + "messageErrorCommandTimedOutTheSessionWas": "[Error] Command timed out; the session was stopped", + "@messageErrorCommandTimedOutTheSessionWas": { + "description": "pm3_process: [Error] Command timed out; the session was stopped" + }, + "messageThePm3SessionDisconnectedBeforeTheCommand": "The PM3 session disconnected before the command completed", + "@messageThePm3SessionDisconnectedBeforeTheCommand": { + "description": "pm3_process: The PM3 session disconnected before the command completed" + }, + "messageFailedWhileWaitingForPm3ToStart": "Failed while waiting for PM3 to start: {value}", + "@messageFailedWhileWaitingForPm3ToStart": { + "description": "pm3_process: Failed while waiting for PM3 to start: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageFailedToSendQuit": "Failed to send quit: {value}", + "@messageFailedToSendQuit": { + "description": "pm3_process: Failed to send quit: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageCouldNotReadTheExitStatusAfter": "Could not read the exit status after PM3 {value} closed: {value2}", + "@messageCouldNotReadTheExitStatusAfter": { + "description": "pm3_process: Could not read the exit status after PM3 {value} closed: {value2}", + "placeholders": { + "value": { + "type": "String" + }, + "value2": { + "type": "String" + } + } + }, + "messagePm3StreamClosed": "PM3 {value} stream closed", + "@messagePm3StreamClosed": { + "description": "pm3_process: PM3 {value} stream closed", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageFailedWhileWaitingForPm3ToExit": "Failed while waiting for PM3 to exit: {value}", + "@messageFailedWhileWaitingForPm3ToExit": { + "description": "pm3_process: Failed while waiting for PM3 to exit: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageFailedToTerminatePm3TermProcessTree": "Failed to terminate PM3: TERM / process-tree termination failed", + "@messageFailedToTerminatePm3TermProcessTree": { + "description": "pm3_process: Failed to terminate PM3: TERM / process-tree termination failed" + }, + "messageFailedToForceTerminatePm3KillProcess": "Failed to force-terminate PM3: KILL / process-tree termination failed", + "@messageFailedToForceTerminatePm3KillProcess": { + "description": "pm3_process: Failed to force-terminate PM3: KILL / process-tree termination failed" + }, + "messageTimedOutWaitingForPm3ToExit": "Timed out waiting for PM3 to exit after termination", + "@messageTimedOutWaitingForPm3ToExit": { + "description": "pm3_process: Timed out waiting for PM3 to exit after termination" + }, + "messageFailedToTerminatePm3ReturnedFalse": "Failed to terminate PM3: {value} returned false", + "@messageFailedToTerminatePm3ReturnedFalse": { + "description": "pm3_process: Failed to terminate PM3: {value} returned false", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageFailedToTerminatePm3": "Failed to terminate PM3: {value}", + "@messageFailedToTerminatePm3": { + "description": "pm3_process: Failed to terminate PM3: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageTheSerialPortIsInUseBy": "The serial port is in use by another process.\nClose other programs using this port, such as another PM3 terminal.", + "@messageTheSerialPortIsInUseBy": { + "description": "pm3_process: The serial port is in use by another process.\nClose other programs using this port, such as another PM3 terminal." + }, + "messageSerialDeviceNotFoundCheckThePm3": "Serial device not found. Check the PM3 USB connection.", + "@messageSerialDeviceNotFoundCheckThePm3": { + "description": "pm3_process: Serial device not found. Check the PM3 USB connection." + }, + "messagePermissionDeniedForTheSerialPortTry": "Permission denied for the serial port.\nTry: sudo chmod 666 /dev/ttyACM0\nOr add your user to the dialout group.", + "@messagePermissionDeniedForTheSerialPortTry": { + "description": "pm3_process: Permission denied for the serial port.\nTry: sudo chmod 666 /dev/ttyACM0\nOr add your user to the dialout group." + }, + "messageSerialConnectionError": "Serial connection error: {value}", + "@messageSerialConnectionError": { + "description": "pm3_process: Serial connection error: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageEnterHexDigitsUseForUnknownDigits": "Enter {value} hex digits; use ? for unknown digits or clear the field for unread data.", + "@messageEnterHexDigitsUseForUnknownDigits": { + "description": "command_card: Enter {value} hex digits; use ? for unknown digits or clear the field for unread data.", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageBlockNumberExceedsTheCardCapacity": "Block number exceeds the card capacity", + "@messageBlockNumberExceedsTheCardCapacity": { + "description": "command_card: Block number exceeds the card capacity" + }, + "messageSectorNumberExceedsTheCardCapacity": "Sector number exceeds the card capacity", + "@messageSectorNumberExceedsTheCardCapacity": { + "description": "command_card: Sector number exceeds the card capacity" + }, + "messageThisIsNotACommandDataDraft": "This is not a command data draft", + "@messageThisIsNotACommandDataDraft": { + "description": "command_card: This is not a command data draft" + }, + "messageUnsupportedCardCapacity": "Unsupported card capacity", + "@messageUnsupportedCardCapacity": { + "description": "command_card: Unsupported card capacity" + }, + "messageInvalidUidFormat": "Invalid UID format", + "@messageInvalidUidFormat": { + "description": "command_card: Invalid UID format" + }, + "messageBlockIsIncompleteSaveADraftOr": "Block {value} is incomplete. Save a draft or complete the data first.", + "@messageBlockIsIncompleteSaveADraftOr": { + "description": "command_card: Block {value} is incomplete. Save a draft or complete the data first.", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageSectorHasIncompleteKeysExportADictionary": "Sector {value} has incomplete keys. Export a dictionary or save a draft first.", + "@messageSectorHasIncompleteKeysExportADictionary": { + "description": "command_card: Sector {value} has incomplete keys. Export a dictionary or save a draft first.", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageThereAreNoCompleteKeysToExport": "There are no complete keys to export", + "@messageThereAreNoCompleteKeysToExport": { + "description": "command_card: There are no complete keys to export" + }, + "messageEmptyUnallocated": "Empty / unallocated", + "@messageEmptyUnallocated": { + "description": "dump_analysis: Empty / unallocated" + }, + "messageDefectManagement": "Defect management", + "@messageDefectManagement": { + "description": "dump_analysis: Defect management" + }, + "messageReserved": "Reserved", + "@messageReserved": { + "description": "dump_analysis: Reserved" + }, + "messageAdditionalDirectoryEntries": "Additional directory entries", + "@messageAdditionalDirectoryEntries": { + "description": "dump_analysis: Additional directory entries" + }, + "messageCardholderInformation": "Cardholder information", + "@messageCardholderInformation": { + "description": "dump_analysis: Cardholder information" + }, + "messageUnallocated": "Unallocated", + "@messageUnallocated": { + "description": "dump_analysis: Unallocated" + }, + "messageAirlineServices": "Airline services", + "@messageAirlineServices": { + "description": "dump_analysis: Airline services" + }, + "messageUrbanTransport": "Urban transport", + "@messageUrbanTransport": { + "description": "dump_analysis: Urban transport" + }, + "messageUrbanTransport2": "Urban transport (2)", + "@messageUrbanTransport2": { + "description": "dump_analysis: Urban transport (2)" + }, + "messageRailTransport": "Rail transport", + "@messageRailTransport": { + "description": "dump_analysis: Rail transport" + }, + "messageTransportGeneral": "Transport (general)", + "@messageTransportGeneral": { + "description": "dump_analysis: Transport (general)" + }, + "messageSecurityServices": "Security services", + "@messageSecurityServices": { + "description": "dump_analysis: Security services" + }, + "messageUrbanTransport3": "Urban transport (3)", + "@messageUrbanTransport3": { + "description": "dump_analysis: Urban transport (3)" + }, + "messageEventTicketing": "Event ticketing", + "@messageEventTicketing": { + "description": "dump_analysis: Event ticketing" + }, + "messageLoyaltySchemes": "Loyalty schemes", + "@messageLoyaltySchemes": { + "description": "dump_analysis: Loyalty schemes" + }, + "messageParking": "Parking", + "@messageParking": { + "description": "dump_analysis: Parking" + }, + "messageAccessControl": "Access control", + "@messageAccessControl": { + "description": "dump_analysis: Access control" + }, + "messageElectronicPurseMicropayments": "Electronic purse / micropayments", + "@messageElectronicPurseMicropayments": { + "description": "dump_analysis: Electronic purse / micropayments" + }, + "messageElectronicTicketingGeneral": "Electronic ticketing (general)", + "@messageElectronicTicketingGeneral": { + "description": "dump_analysis: Electronic ticketing (general)" + }, + "messageMobileOffice": "Mobile office", + "@messageMobileOffice": { + "description": "dump_analysis: Mobile office" + }, + "messageIdentification": "Identification", + "@messageIdentification": { + "description": "dump_analysis: Identification" + }, + "messageMobilePayments": "Mobile payments", + "@messageMobilePayments": { + "description": "dump_analysis: Mobile payments" + }, + "messageFelicaIntegration": "FeliCa integration", + "@messageFelicaIntegration": { + "description": "dump_analysis: FeliCa integration" + }, + "messageFuelServices": "Fuel services", + "@messageFuelServices": { + "description": "dump_analysis: Fuel services" + }, + "messageInformationProcessing": "Information processing", + "@messageInformationProcessing": { + "description": "dump_analysis: Information processing" + }, + "messageMifareClassic1kCompatible": "MIFARE Classic 1K (compatible)", + "@messageMifareClassic1kCompatible": { + "description": "dump_analysis: MIFARE Classic 1K (compatible)" + }, + "messageMifareClassic1kCompatible2": "MIFARE Classic 1K (compatible 2)", + "@messageMifareClassic1kCompatible2": { + "description": "dump_analysis: MIFARE Classic 1K (compatible 2)" + }, + "messageUnknownSak": "Unknown (SAK={value})", + "@messageUnknownSak": { + "description": "dump_analysis: Unknown (SAK={value})", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageUnknown0x": "Unknown (0x{value})", + "@messageUnknown0x": { + "description": "dump_analysis: Unknown (0x{value})", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageApplication0x": "Application 0x{value}", + "@messageApplication0x": { + "description": "dump_analysis: Application 0x{value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageNxpFactoryDefault": "NXP factory default", + "@messageNxpFactoryDefault": { + "description": "dump_analysis: NXP factory default" + }, + "messageAllZeros": "All zeros", + "@messageAllZeros": { + "description": "dump_analysis: All zeros" + }, + "messageInvalidKeyTextFormat": "Invalid key text format", + "@messageInvalidKeyTextFormat": { + "description": "key_parser: Invalid key text format" + }, + "messageDuplicateSector": "Duplicate sector {value}", + "@messageDuplicateSector": { + "description": "key_parser: Duplicate sector {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageKeyTextIsMissingSectors": "Key text is missing sectors", + "@messageKeyTextIsMissingSectors": { + "description": "key_parser: Key text is missing sectors" + }, + "messageBinaryDumpBin": "Binary dump (.bin)", + "@messageBinaryDumpBin": { + "description": "dump_converter: Binary dump (.bin)" + }, + "messageTextDumpEml": "Text dump (.eml)", + "@messageTextDumpEml": { + "description": "dump_converter: Text dump (.eml)" + }, + "messageBinaryKeysKeyBin": "Binary keys (.key.bin)", + "@messageBinaryKeysKeyBin": { + "description": "dump_converter: Binary keys (.key.bin)" + }, + "messageKeyDictionaryDic": "Key dictionary (.dic)", + "@messageKeyDictionaryDic": { + "description": "dump_converter: Key dictionary (.dic)" + }, + "messageKeyListKeysTxt": "Key list (.keys.txt)", + "@messageKeyListKeysTxt": { + "description": "dump_converter: Key list (.keys.txt)" + }, + "messageFileNotFound": "File not found: {value}", + "@messageFileNotFound": { + "description": "dump_converter: File not found: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageAKeyFileCannotBeConvertedTo": "A key file cannot be converted to a full dump (data blocks are missing)", + "@messageAKeyFileCannotBeConvertedTo": { + "description": "dump_converter: A key file cannot be converted to a full dump (data blocks are missing)" + }, + "messageAKeyFileCannotBeConvertedTo2": "A key file cannot be converted to EML (data blocks are missing)", + "@messageAKeyFileCannotBeConvertedTo2": { + "description": "dump_converter: A key file cannot be converted to EML (data blocks are missing)" + }, + "messageAKeyFileCannotBeConvertedTo3": "A key file cannot be converted to JSON (data blocks are missing)", + "@messageAKeyFileCannotBeConvertedTo3": { + "description": "dump_converter: A key file cannot be converted to JSON (data blocks are missing)" + }, + "messageNoKeyData": "No key data", + "@messageNoKeyData": { + "description": "dump_converter: No key data" + }, + "messageConversionFailed": "Conversion failed: {value}", + "@messageConversionFailed": { + "description": "dump_converter: Conversion failed: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageUniqueKeys": "Unique keys: {value}", + "@messageUniqueKeys": { + "description": "dump_converter: Unique keys: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageUnsupportedKeyExportFormat": "Unsupported key export format: {value}", + "@messageUnsupportedKeyExportFormat": { + "description": "dump_converter: Unsupported key export format: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageSectorsUniqueKeyAUniqueKeyB": "Sectors: {value}; unique Key A: {value2}; unique Key B: {value3}; total unique keys: {value4}", + "@messageSectorsUniqueKeyAUniqueKeyB": { + "description": "dump_converter: Sectors: {value}; unique Key A: {value2}; unique Key B: {value3}; total unique keys: {value4}", + "placeholders": { + "value": { + "type": "String" + }, + "value2": { + "type": "String" + }, + "value3": { + "type": "String" + }, + "value4": { + "type": "String" + } + } + }, + "messageConnectionLostOperationAborted": "Connection lost; operation aborted", + "@messageConnectionLostOperationAborted": { + "description": "app_state: Connection lost; operation aborted" + }, + "messageWritingBlock": "Writing block {value}…", + "@messageWritingBlock": { + "description": "app_state: Writing block {value}…", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageAuthenticationOrWritingFailed": "Authentication or writing failed", + "@messageAuthenticationOrWritingFailed": { + "description": "app_state: Authentication or writing failed" + }, + "messageWriteSuccessful": "Write successful", + "@messageWriteSuccessful": { + "description": "app_state: Write successful" + }, + "messageFinishedSucceededFailed": "Finished: {value} succeeded, {value2} failed", + "@messageFinishedSucceededFailed": { + "description": "app_state: Finished: {value} succeeded, {value2} failed", + "placeholders": { + "value": { + "type": "String" + }, + "value2": { + "type": "String" + } + } + }, + "messageCancelled": "Cancelled", + "@messageCancelled": { + "description": "app_state: Cancelled" + }, + "messageCouldNotSaveSettings": "Could not save settings: {value}", + "@messageCouldNotSaveSettings": { + "description": "connection_state: Could not save settings: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageFileScanFailed": "File scan failed: {value}", + "@messageFileScanFailed": { + "description": "file_state: File scan failed: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageInstalled": "Installed", + "@messageInstalled": { + "description": "hardware_state: Installed" + }, + "messageOnWindowsUseTheOfficialPm3Bat": "On Windows, use the official pm3.bat launcher.", + "@messageOnWindowsUseTheOfficialPm3Bat": { + "description": "connection_page: On Windows, use the official pm3.bat launcher." + }, + "messageSerialPort": "Serial port", + "@messageSerialPort": { + "description": "connection_page: Serial port" + }, + "messageRefreshPorts": "Refresh ports", + "@messageRefreshPorts": { + "description": "connection_page: Refresh ports" + }, + "messageNoSerialPortFoundConnectYourPm3": "No serial port found. Connect your PM3 device.", + "@messageNoSerialPortFoundConnectYourPm3": { + "description": "connection_page: No serial port found. Connect your PM3 device." + }, + "messageTheOfficialBatLauncherDetectsDevicesAutomatically": "The official BAT launcher detects devices automatically; this port is only saved in settings.", + "@messageTheOfficialBatLauncherDetectsDevicesAutomatically": { + "description": "connection_page: The official BAT launcher detects devices automatically; this port is only saved in settings." + }, + "messageConnecting": "Connecting…", + "@messageConnecting": { + "description": "connection_page: Connecting…" + }, + "messageDisconnect": "Disconnect", + "@messageDisconnect": { + "description": "connection_page: Disconnect" + }, + "messageConnectPm3": "Connect PM3", + "@messageConnectPm3": { + "description": "connection_page: Connect PM3" + }, + "messageDeviceInformation": "Device information", + "@messageDeviceInformation": { + "description": "connection_page: Device information" + }, + "messageRefreshHardwareInformation": "Refresh hardware information", + "@messageRefreshHardwareInformation": { + "description": "connection_page: Refresh hardware information" + }, + "messagePort": "Port", + "@messagePort": { + "description": "connection_page: Port" + }, + "messageVersion": "Version", + "@messageVersion": { + "description": "connection_page: Version" + }, + "messageStatus": "Status", + "@messageStatus": { + "description": "connection_page: Status" + }, + "messageDeviceModel": "Device model", + "@messageDeviceModel": { + "description": "connection_page: Device model" + }, + "messageFirmware": "Firmware", + "@messageFirmware": { + "description": "connection_page: Firmware" + }, + "messageSmartcardModule": "Smartcard module", + "@messageSmartcardModule": { + "description": "connection_page: Smartcard module" + }, + "messageDeviceId": "Device ID", + "@messageDeviceId": { + "description": "connection_page: Device ID" + }, + "messageRetrievingHardwareInformation": "Retrieving hardware information…", + "@messageRetrievingHardwareInformation": { + "description": "connection_page: Retrieving hardware information…" + }, + "messageCommandHistory": "Command history", + "@messageCommandHistory": { + "description": "connection_page: Command history" + }, + "messageCommands": "{value, plural, =0{No commands} =1{1 command} other{{value} commands}}", + "@messageCommands": { + "description": "Number of commands in terminal history; follows locale plural rules.", + "placeholders": { + "value": { + "type": "int" + } + } + }, + "messageTerminalBuffer": "Terminal buffer", + "@messageTerminalBuffer": { + "description": "connection_page: Terminal buffer" + }, + "messageLines": "Lines: {value}", + "@messageLines": { + "description": "connection_page: Lines: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageHardwareStatus": "Hardware status", + "@messageHardwareStatus": { + "description": "connection_page: Hardware status" + }, + "messageDebugLevel": "Debug level", + "@messageDebugLevel": { + "description": "connection_page: Debug level" + }, + "messageEnvironment": "Environment", + "@messageEnvironment": { + "description": "connection_page: Environment" + }, + "messagePm3Path": "PM3 path", + "@messagePm3Path": { + "description": "connection_page: PM3 path" + }, + "messageValid": "✅ Valid", + "@messageValid": { + "description": "connection_page: ✅ Valid" + }, + "messageInvalid": "❌ Invalid", + "@messageInvalid": { + "description": "connection_page: ❌ Invalid" + }, + "messagePm3FileCollection": "PM3 file collection", + "@messagePm3FileCollection": { + "description": "connection_page: PM3 file collection" + }, + "messageFilesCards": "Files: {value}; cards: {value2}", + "@messageFilesCards": { + "description": "connection_page: Files: {value}; cards: {value2}", + "placeholders": { + "value": { + "type": "String" + }, + "value2": { + "type": "String" + } + } + }, + "messageScan": "Scan", + "@messageScan": { + "description": "connection_page: Scan" + }, + "messageOrganizeFiles": "Organize files", + "@messageOrganizeFiles": { + "description": "connection_page: Organize files" + }, + "messageNoPm3ExportFilesFound": "No PM3 export files found", + "@messageNoPm3ExportFilesFound": { + "description": "connection_page: No PM3 export files found" + }, + "messageFilesAreCollectedAutomaticallyAfterConnectingAnd": "Files are collected automatically after connecting and running dump / autopwn.", + "@messageFilesAreCollectedAutomaticallyAfterConnectingAnd": { + "description": "connection_page: Files are collected automatically after connecting and running dump / autopwn." + }, + "messagePm3Connected": "PM3 connected", + "@messagePm3Connected": { + "description": "connection_page: PM3 connected" + }, + "messagePm3NotConnected": "PM3 not connected", + "@messagePm3NotConnected": { + "description": "connection_page: PM3 not connected" + }, + "messageSelectASerialPortAndConnectThe": "Select a serial port and connect the device", + "@messageSelectASerialPortAndConnectThe": { + "description": "connection_page: Select a serial port and connect the device" + }, + "messageConnectionFailed2": "Connection failed", + "@messageConnectionFailed2": { + "description": "connection_page: Connection failed" + }, + "messageDumpsKeyFilesFiles": "Dumps: {value} • Key files: {value2} • Files: {value3}", + "@messageDumpsKeyFilesFiles": { + "description": "connection_page: Dumps: {value} • Key files: {value2} • Files: {value3}", + "placeholders": { + "value": { + "type": "String" + }, + "value2": { + "type": "String" + }, + "value3": { + "type": "String" + } + } + }, + "messageOpenInDumpViewer": "Open in dump viewer", + "@messageOpenInDumpViewer": { + "description": "connection_page: Open in dump viewer" + }, + "messageOpenedInDumpViewer": "Opened in dump viewer: {value}", + "@messageOpenedInDumpViewer": { + "description": "connection_page: Opened in dump viewer: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageOrganizePm3Files": "Organize PM3 files", + "@messageOrganizePm3Files": { + "description": "connection_page: Organize PM3 files" + }, + "messageOrganizeCollectedPm3ExportFilesIntoSubdirectories": "Organize collected PM3 export files into subdirectories by card UID.\n\nDirectory structure:\n 📁 hf-mf/A991A280/\n ├── hf-mf-A991A280-dump.bin\n ├── hf-mf-A991A280-key.bin\n └── …\n", + "@messageOrganizeCollectedPm3ExportFilesIntoSubdirectories": { + "description": "connection_page: Organize collected PM3 export files into subdirectories by card UID.\n\nDirectory structure:\n 📁 hf-mf/A991A280/\n ├── hf-mf-A991A280-dump.bin\n ├── hf-mf-A991A280-key.bin\n └── …\n" + }, + "messageDestinationDirectory": "Destination directory", + "@messageDestinationDirectory": { + "description": "connection_page: Destination directory" + }, + "messageFilesToOrganize": "Files to organize: {value}", + "@messageFilesToOrganize": { + "description": "connection_page: Files to organize: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageCancel": "Cancel", + "@messageCancel": { + "description": "connection_page: Cancel" + }, + "messageFilesOrganized": "Files organized: {value}", + "@messageFilesOrganized": { + "description": "connection_page: Files organized: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageStartOrganizing": "Start organizing", + "@messageStartOrganizing": { + "description": "connection_page: Start organizing" + }, + "messageFlashUsage": "Flash usage", + "@messageFlashUsage": { + "description": "connection_page: Flash usage" + }, + "messageBasic": "Basic", + "@messageBasic": { + "description": "data_page: Basic" + }, + "messageAnalysis": "Analysis", + "@messageAnalysis": { + "description": "data_page: Analysis" + }, + "messageCompare": "Compare", + "@messageCompare": { + "description": "data_page: Compare" + }, + "messagePlot": "Plot", + "@messagePlot": { + "description": "data_page: Plot" + }, + "messageDisplayTheDataGraph": "Display the data graph", + "@messageDisplayTheDataGraph": { + "description": "data_page: Display the data graph" + }, + "messageClear": "Clear", + "@messageClear": { + "description": "data_page: Clear" + }, + "messageClearTheGraphBuffer": "Clear the graph buffer", + "@messageClearTheGraphBuffer": { + "description": "data_page: Clear the graph buffer" + }, + "messageDetectClock": "Detect clock", + "@messageDetectClock": { + "description": "data_page: Detect clock" + }, + "messageAutomaticallyDetectTheClockRate": "Automatically detect the clock rate", + "@messageAutomaticallyDetectTheClockRate": { + "description": "data_page: Automatically detect the clock rate" + }, + "messageSampling": "Sampling", + "@messageSampling": { + "description": "data_page: Sampling" + }, + "messageSampleCount": "Sample count", + "@messageSampleCount": { + "description": "data_page: Sample count" + }, + "messageReadSamples": "Read samples", + "@messageReadSamples": { + "description": "data_page: Read samples" + }, + "messageFileOperations": "File operations", + "@messageFileOperations": { + "description": "data_page: File operations" + }, + "messageFilePath": "File path", + "@messageFilePath": { + "description": "data_page: File path" + }, + "messageSave": "Save", + "@messageSave": { + "description": "data_page: Save" + }, + "messageLoad": "Load", + "@messageLoad": { + "description": "data_page: Load" + }, + "messageAsn1Decoding": "ASN.1 decoding", + "@messageAsn1Decoding": { + "description": "data_page: ASN.1 decoding" + }, + "messageHexData": "Hex data", + "@messageHexData": { + "description": "data_page: Hex data" + }, + "messageDecode": "Decode", + "@messageDecode": { + "description": "data_page: Decode" + }, + "messageFileComparison": "File comparison", + "@messageFileComparison": { + "description": "data_page: File comparison" + }, + "messageFileA": "File A", + "@messageFileA": { + "description": "data_page: File A" + }, + "messageFileB": "File B", + "@messageFileB": { + "description": "data_page: File B" + }, + "messageOpenFileA": "Open file A", + "@messageOpenFileA": { + "description": "dump_compare_page: Open file A" + }, + "messageOpenFileB": "Open file B", + "@messageOpenFileB": { + "description": "dump_compare_page: Open file B" + }, + "messageDifferencesOnly": "Differences only", + "@messageDifferencesOnly": { + "description": "dump_compare_page: Differences only" + }, + "messageFileAError": "File A error: {value}", + "@messageFileAError": { + "description": "dump_compare_page: File A error: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageFileBError": "File B error: {value}", + "@messageFileBError": { + "description": "dump_compare_page: File B error: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageOpenTwoDumpFilesToCompareThem": "Open two dump files to compare them", + "@messageOpenTwoDumpFilesToCompareThem": { + "description": "dump_compare_page: Open two dump files to compare them" + }, + "messageSupportsEmlBinJsonDump": "Supports .eml / .bin / .json / .dump", + "@messageSupportsEmlBinJsonDump": { + "description": "dump_compare_page: Supports .eml / .bin / .json / .dump" + }, + "messageTotalBlocks": "Total blocks", + "@messageTotalBlocks": { + "description": "dump_compare_page: Total blocks" + }, + "messageDataDifferences": "Data differences", + "@messageDataDifferences": { + "description": "dump_compare_page: Data differences" + }, + "messageBlocks": "Blocks: {value}", + "@messageBlocks": { + "description": "dump_compare_page: Blocks: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageKeyADifferences": "Key A differences", + "@messageKeyADifferences": { + "description": "dump_compare_page: Key A differences" + }, + "messageKeyBDifferences": "Key B differences", + "@messageKeyBDifferences": { + "description": "dump_compare_page: Key B differences" + }, + "messageNone": "(none)", + "@messageNone": { + "description": "dump_compare_page: (none)" + }, + "messageManufacturer": "Manufacturer", + "@messageManufacturer": { + "description": "dump_compare_page: Manufacturer" + }, + "messageTrailer": "Trailer", + "@messageTrailer": { + "description": "dump_compare_page: Trailer" + }, + "messageCouldNotOpenFile": "Could not open file: {value}", + "@messageCouldNotOpenFile": { + "description": "dump_viewer_page: Could not open file: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageDictionaryFileUniqueKeys": "Dictionary file: {value} unique keys", + "@messageDictionaryFileUniqueKeys": { + "description": "dump_viewer_page: Dictionary file: {value} unique keys", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageDictionaryLoadedKeys": "Dictionary loaded; keys: {value}", + "@messageDictionaryLoadedKeys": { + "description": "dump_viewer_page: Dictionary loaded; keys: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageMergedKeysForSectorsBlockDataUnchanged": "Merged keys for {value} sectors (block data unchanged)", + "@messageMergedKeysForSectorsBlockDataUnchanged": { + "description": "dump_viewer_page: Merged keys for {value} sectors (block data unchanged)", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageKeyHandling": "Key handling", + "@messageKeyHandling": { + "description": "dump_viewer_page: Key handling" + }, + "messageKeyDataIsAlreadyLoadedHowShould": "Key data is already loaded. How should keys be handled when opening another dump?", + "@messageKeyDataIsAlreadyLoadedHowShould": { + "description": "dump_viewer_page: Key data is already loaded. How should keys be handled when opening another dump?" + }, + "messageDumpKeyFieldsAreOftenFfWhile": "Dump key fields are often FF, while actual keys may come from a separate key file.", + "@messageDumpKeyFieldsAreOftenFfWhile": { + "description": "dump_viewer_page: Dump key fields are often FF, while actual keys may come from a separate key file." + }, + "messageKeepCurrentKeys": "Keep current keys", + "@messageKeepCurrentKeys": { + "description": "dump_viewer_page: Keep current keys" + }, + "messageReplaceWithDumpKeys": "Replace with dump keys", + "@messageReplaceWithDumpKeys": { + "description": "dump_viewer_page: Replace with dump keys" + }, + "messageDefaultKeyFileSet": "Default key file set: {value}", + "@messageDefaultKeyFileSet": { + "description": "dump_viewer_page: Default key file set: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageDefaultDumpFileSet": "Default dump file set: {value}", + "@messageDefaultDumpFileSet": { + "description": "dump_viewer_page: Default dump file set: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageSectorView": "Sector view", + "@messageSectorView": { + "description": "dump_viewer_page: Sector view" + }, + "messageKeysEdit": "Keys / edit", + "@messageKeysEdit": { + "description": "dump_viewer_page: Keys / edit" + }, + "messageDetailedAnalysis": "Detailed analysis", + "@messageDetailedAnalysis": { + "description": "dump_viewer_page: Detailed analysis" + }, + "messageWriteClear": "Write / clear", + "@messageWriteClear": { + "description": "dump_viewer_page: Write / clear" + }, + "messageCommandData": "Command data", + "@messageCommandData": { + "description": "dump_viewer_page: Command data" + }, + "messageOpenADumpFileToViewSector": "Open a dump file to view sector data", + "@messageOpenADumpFileToViewSector": { + "description": "dump_viewer_page: Open a dump file to view sector data" + }, + "messageOpenADumpOrKeyFileTo": "Open a dump or key file to edit it", + "@messageOpenADumpOrKeyFileTo": { + "description": "dump_viewer_page: Open a dump or key file to edit it" + }, + "messageOpenADumpFileToViewDetailed": "Open a dump file to view detailed analysis", + "@messageOpenADumpFileToViewDetailed": { + "description": "dump_viewer_page: Open a dump file to view detailed analysis" + }, + "messageSupportsEmlBinJsonDumpDic": "Supports .eml / .bin / .json / .dump / .dic", + "@messageSupportsEmlBinJsonDumpDic": { + "description": "dump_viewer_page: Supports .eml / .bin / .json / .dump / .dic" + }, + "messageOpenFile": "Open file", + "@messageOpenFile": { + "description": "dump_viewer_page: Open file" + }, + "messageSetAsDefaultKeys": "Set as default keys", + "@messageSetAsDefaultKeys": { + "description": "dump_viewer_page: Set as default keys" + }, + "messageSetAsDefaultDump": "Set as default dump", + "@messageSetAsDefaultDump": { + "description": "dump_viewer_page: Set as default dump" + }, + "messageExportAs": "Export as", + "@messageExportAs": { + "description": "dump_viewer_page: Export as" + }, + "messageEmlTextDump": ".eml (text dump)", + "@messageEmlTextDump": { + "description": "dump_viewer_page: .eml (text dump)" + }, + "messageBinBinaryDump": ".bin (binary dump)", + "@messageBinBinaryDump": { + "description": "dump_viewer_page: .bin (binary dump)" + }, + "messageJsonPm3Format": ".json (PM3 format)", + "@messageJsonPm3Format": { + "description": "dump_viewer_page: .json (PM3 format)" + }, + "messageKeyBinBinaryKeys": ".key.bin (binary keys)", + "@messageKeyBinBinaryKeys": { + "description": "dump_viewer_page: .key.bin (binary keys)" + }, + "messageDicKeyDictionary": ".dic (key dictionary)", + "@messageDicKeyDictionary": { + "description": "dump_viewer_page: .dic (key dictionary)" + }, + "messageKeysTxtKeyList": ".keys.txt (key list)", + "@messageKeysTxtKeyList": { + "description": "dump_viewer_page: .keys.txt (key list)" + }, + "messageExportFailed": "Export failed: {value}", + "@messageExportFailed": { + "description": "dump_viewer_page: Export failed: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageExportedAndOrganized": "Exported and organized: {value}", + "@messageExportedAndOrganized": { + "description": "dump_viewer_page: Exported and organized: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageExported": "Exported: {value}", + "@messageExported": { + "description": "dump_viewer_page: Exported: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageNoCardData": "No card data", + "@messageNoCardData": { + "description": "dump_viewer_page: No card data" + }, + "messageSectorBlocks": "Sector {value} (blocks {value2}–{value3})", + "@messageSectorBlocks": { + "description": "dump_viewer_page: Sector {value} (blocks {value2}–{value3})", + "placeholders": { + "value": { + "type": "String" + }, + "value2": { + "type": "String" + }, + "value3": { + "type": "String" + } + } + }, + "messageAccessConditions": "Access conditions", + "@messageAccessConditions": { + "description": "dump_viewer_page: Access conditions" + }, + "messageManufacturerBlock": "Manufacturer block", + "@messageManufacturerBlock": { + "description": "dump_viewer_page: Manufacturer block" + }, + "messageBlock": "Block", + "@messageBlock": { + "description": "dump_viewer_page: Block" + }, + "messageDescription": "Description", + "@messageDescription": { + "description": "dump_viewer_page: Description" + }, + "messageData": "Data {value}", + "@messageData": { + "description": "dump_viewer_page: Data {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageKeyAWriteKeyAAccessBits": "Key A: write Key A / access bits / Key B", + "@messageKeyAWriteKeyAAccessBits": { + "description": "dump_viewer_page: Key A: write Key A / access bits / Key B" + }, + "messageKeyAWriteKeyAAccessBits2": "Key A: write Key A / access bits; Key B: read / write", + "@messageKeyAWriteKeyAAccessBits2": { + "description": "dump_viewer_page: Key A: write Key A / access bits; Key B: read / write" + }, + "messageReadAccessBitsOnly": "Read access bits only", + "@messageReadAccessBitsOnly": { + "description": "dump_viewer_page: Read access bits only" + }, + "messageKeyAWriteAccessBitsKeyB": "Key A: write access bits; Key B: read / write", + "@messageKeyAWriteAccessBitsKeyB": { + "description": "dump_viewer_page: Key A: write access bits; Key B: read / write" + }, + "messageKeyBWriteAll": "Key B: write all", + "@messageKeyBWriteAll": { + "description": "dump_viewer_page: Key B: write all" + }, + "messageLocked": "Locked", + "@messageLocked": { + "description": "dump_viewer_page: Locked" + }, + "messageUnknown": "Unknown", + "@messageUnknown": { + "description": "dump_viewer_page: Unknown" + }, + "messageReadWriteWithKeyAOrB": "Read / write with Key A or B", + "@messageReadWriteWithKeyAOrB": { + "description": "dump_viewer_page: Read / write with Key A or B" + }, + "messageReadWithABNoWriting": "Read with A / B; no writing", + "@messageReadWithABNoWriting": { + "description": "dump_viewer_page: Read with A / B; no writing" + }, + "messageReadWriteWithKeyBOnly": "Read / write with Key B only", + "@messageReadWriteWithKeyBOnly": { + "description": "dump_viewer_page: Read / write with Key B only" + }, + "messageReadABWriteBDecrementA": "Read: A / B; write: B; decrement: A / B (value block)", + "@messageReadABWriteBDecrementA": { + "description": "dump_viewer_page: Read: A / B; write: B; decrement: A / B (value block)" + }, + "messageReadWithKeyBOnly": "Read with Key B only", + "@messageReadWithKeyBOnly": { + "description": "dump_viewer_page: Read with Key B only" + }, + "messageReadABWriteIncrementBDecrement": "Read: A / B; write / increment: B; decrement: A / B (value block)", + "@messageReadABWriteIncrementBDecrement": { + "description": "dump_viewer_page: Read: A / B; write / increment: B; decrement: A / B (value block)" + }, + "messageNoData": "No data", + "@messageNoData": { + "description": "dump_viewer_page: No data" + }, + "messageOverview": "📋 Overview", + "@messageOverview": { + "description": "dump_viewer_page: 📋 Overview" + }, + "messageCardType": "Card type", + "@messageCardType": { + "description": "dump_viewer_page: Card type" + }, + "messageEmptyDataBlocks": "Empty data blocks", + "@messageEmptyDataBlocks": { + "description": "dump_viewer_page: Empty data blocks" + }, + "messageDataUsage": "Data usage", + "@messageDataUsage": { + "description": "dump_viewer_page: Data usage" + }, + "messageValueBlocks": "Value blocks", + "@messageValueBlocks": { + "description": "dump_viewer_page: Value blocks" + }, + "messageManufacturerBlockBlock0": "🏭 Manufacturer block (block 0)", + "@messageManufacturerBlockBlock0": { + "description": "dump_viewer_page: 🏭 Manufacturer block (block 0)" + }, + "messageChipType": "Chip type", + "@messageChipType": { + "description": "dump_viewer_page: Chip type" + }, + "messageRawData": "Raw data", + "@messageRawData": { + "description": "dump_viewer_page: Raw data" + }, + "messageKeyAnalysis": "🔑 Key analysis", + "@messageKeyAnalysis": { + "description": "dump_viewer_page: 🔑 Key analysis" + }, + "messageTotalSectors": "Total sectors", + "@messageTotalSectors": { + "description": "dump_viewer_page: Total sectors" + }, + "messageKnownKeyA": "Known Key A", + "@messageKnownKeyA": { + "description": "dump_viewer_page: Known Key A" + }, + "messageKnownKeyB": "Known Key B", + "@messageKnownKeyB": { + "description": "dump_viewer_page: Known Key B" + }, + "messageAllKeysIdentical": "All keys identical", + "@messageAllKeysIdentical": { + "description": "dump_viewer_page: All keys identical" + }, + "messageYes": "Yes", + "@messageYes": { + "description": "dump_viewer_page: Yes" + }, + "messageNo": "No", + "@messageNo": { + "description": "dump_viewer_page: No" + }, + "messageContainsBlankKeys": "Contains blank keys", + "@messageContainsBlankKeys": { + "description": "dump_viewer_page: Contains blank keys" + }, + "messageYes2": "Yes ⚠️", + "@messageYes2": { + "description": "dump_viewer_page: Yes ⚠️" + }, + "messageKeyAGroups": "Key A groups:", + "@messageKeyAGroups": { + "description": "dump_viewer_page: Key A groups:" + }, + "messageSectors": "{value} → sectors {value2}", + "@messageSectors": { + "description": "dump_viewer_page: {value} → sectors {value2}", + "placeholders": { + "value": { + "type": "String" + }, + "value2": { + "type": "String" + } + } + }, + "messageKeyBGroups": "Key B groups:", + "@messageKeyBGroups": { + "description": "dump_viewer_page: Key B groups:" + }, + "messageDefaultKeyMatches": "⚠️ Default key matches:", + "@messageDefaultKeyMatches": { + "description": "dump_viewer_page: ⚠️ Default key matches:" + }, + "messageSectorKey": "Sector {value} Key {value2}: {value3} ({value4})", + "@messageSectorKey": { + "description": "dump_viewer_page: Sector {value} Key {value2}: {value3} ({value4})", + "placeholders": { + "value": { + "type": "String" + }, + "value2": { + "type": "String" + }, + "value3": { + "type": "String" + }, + "value4": { + "type": "String" + } + } + }, + "messageMadApplicationDirectoryV": "📂 MAD application directory (v{value})", + "@messageMadApplicationDirectoryV": { + "description": "dump_viewer_page: 📂 MAD application directory (v{value})", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageSector": "Sector {value}", + "@messageSector": { + "description": "dump_viewer_page: Sector {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageValueBlocks2": "💰 Value blocks", + "@messageValueBlocks2": { + "description": "dump_viewer_page: 💰 Value blocks" + }, + "messageBlock2": "Block {value}", + "@messageBlock2": { + "description": "dump_viewer_page: Block {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageValueAddress": "Value: {value} Address: {value2}", + "@messageValueAddress": { + "description": "dump_viewer_page: Value: {value} Address: {value2}", + "placeholders": { + "value": { + "type": "String" + }, + "value2": { + "type": "String" + } + } + }, + "messageSectorOverview": "📊 Sector overview", + "@messageSectorOverview": { + "description": "dump_viewer_page: 📊 Sector overview" + }, + "messageValidAccessBits": "✅ Valid access bits", + "@messageValidAccessBits": { + "description": "dump_viewer_page: ✅ Valid access bits" + }, + "messageInvalidAccessBits": "❌ Invalid access bits", + "@messageInvalidAccessBits": { + "description": "dump_viewer_page: ❌ Invalid access bits" + }, + "messageNoAccessBits": "- No access bits", + "@messageNoAccessBits": { + "description": "dump_viewer_page: - No access bits" + }, + "messageDataBlockAsciiView": "📝 Data block ASCII view", + "@messageDataBlockAsciiView": { + "description": "dump_viewer_page: 📝 Data block ASCII view" + }, + "messageExportAllFormats": "📦 Export all formats", + "@messageExportAllFormats": { + "description": "dump_viewer_page: 📦 Export all formats" + }, + "messageExportTheCurrentDumpInAllSupported": "Export the current dump in all supported formats:", + "@messageExportTheCurrentDumpInAllSupported": { + "description": "dump_viewer_page: Export the current dump in all supported formats:" + }, + "messageExportEml": "Export .eml", + "@messageExportEml": { + "description": "dump_viewer_page: Export .eml" + }, + "messageExportBin": "Export .bin", + "@messageExportBin": { + "description": "dump_viewer_page: Export .bin" + }, + "messageExportJson": "Export .json", + "@messageExportJson": { + "description": "dump_viewer_page: Export .json" + }, + "messageExportKeyBin": "Export .key.bin", + "@messageExportKeyBin": { + "description": "dump_viewer_page: Export .key.bin" + }, + "messageExportDic": "Export .dic", + "@messageExportDic": { + "description": "dump_viewer_page: Export .dic" + }, + "messageExportKeysTxt": "Export .keys.txt", + "@messageExportKeysTxt": { + "description": "dump_viewer_page: Export .keys.txt" + }, + "messageKeyExtraction": "🔑 Key extraction", + "@messageKeyExtraction": { + "description": "dump_viewer_page: 🔑 Key extraction" + }, + "messageAllKeysExtractedFromTheCurrentDump": "All keys extracted from the current dump:", + "@messageAllKeysExtractedFromTheCurrentDump": { + "description": "dump_viewer_page: All keys extracted from the current dump:" + }, + "messageCopyAllKeys": "Copy all keys", + "@messageCopyAllKeys": { + "description": "dump_viewer_page: Copy all keys" + }, + "messageKeyAOnly": "Key A only", + "@messageKeyAOnly": { + "description": "dump_viewer_page: Key A only" + }, + "messageKeyBOnly": "Key B only", + "@messageKeyBOnly": { + "description": "dump_viewer_page: Key B only" + }, + "messageFileFormatConversion": "🔄 File format conversion", + "@messageFileFormatConversion": { + "description": "dump_viewer_page: 🔄 File format conversion" + }, + "messageSelectADumpOrKeyFileTo": "Select a dump or key file to convert to another format:", + "@messageSelectADumpOrKeyFileTo": { + "description": "dump_viewer_page: Select a dump or key file to convert to another format:" + }, + "messageSelectSourceFile": "Select source file", + "@messageSelectSourceFile": { + "description": "dump_viewer_page: Select source file" + }, + "messageTargetFormat": "Target format:", + "@messageTargetFormat": { + "description": "dump_viewer_page: Target format:" + }, + "messageConverting": "Converting…", + "@messageConverting": { + "description": "dump_viewer_page: Converting…" + }, + "messageStartConversion": "Start conversion", + "@messageStartConversion": { + "description": "dump_viewer_page: Start conversion" + }, + "messageExternalKeyFile": "📋 External key file", + "@messageExternalKeyFile": { + "description": "dump_viewer_page: 📋 External key file" + }, + "messageLoadedExternalKeys": "Loaded external keys:", + "@messageLoadedExternalKeys": { + "description": "dump_viewer_page: Loaded external keys:" + }, + "messageFormatReference": "ℹ️ Format reference", + "@messageFormatReference": { + "description": "dump_viewer_page: ℹ️ Format reference" + }, + "messageRawBinaryDump16BytesPerBlock": "Raw binary dump (16 bytes per block); the default PM3 dump format", + "@messageRawBinaryDump16BytesPerBlock": { + "description": "dump_viewer_page: Raw binary dump (16 bytes per block); the default PM3 dump format" + }, + "messageTextDump32HexDigitsPerLine": "Text dump (32 hex digits per line); used for emulator loading", + "@messageTextDump32HexDigitsPerLine": { + "description": "dump_viewer_page: Text dump (32 hex digits per line); used for emulator loading" + }, + "messagePm3JsonFormatIncludingCardMetadataAnd": "PM3 JSON format, including card metadata and keys", + "@messagePm3JsonFormatIncludingCardMetadataAnd": { + "description": "dump_viewer_page: PM3 JSON format, including card metadata and keys" + }, + "messageBinaryKeyFile6ByteKeyA": "Binary key file: (6-byte Key A + 6-byte Key B) × sector count", + "@messageBinaryKeyFile6ByteKeyA": { + "description": "dump_viewer_page: Binary key file: (6-byte Key A + 6-byte Key B) × sector count" + }, + "messageKeyDictionaryOneKeyPerLineUsed": "Key dictionary (one key per line); used for brute-force recovery", + "@messageKeyDictionaryOneKeyPerLineUsed": { + "description": "dump_viewer_page: Key dictionary (one key per line); used for brute-force recovery" + }, + "messageReadableKeyTableSectorNumberKeyA": "Readable key table (sector number + Key A + Key B)", + "@messageReadableKeyTableSectorNumberKeyA": { + "description": "dump_viewer_page: Readable key table (sector number + Key A + Key B)" + }, + "messageSector2": "Sector", + "@messageSector2": { + "description": "dump_viewer_page: Sector" + }, + "messageSectorAB": "Sector {value} A:{value2} B:{value3}", + "@messageSectorAB": { + "description": "dump_viewer_page: Sector {value} A:{value2} B:{value3}", + "placeholders": { + "value": { + "type": "String" + }, + "value2": { + "type": "String" + }, + "value3": { + "type": "String" + } + } + }, + "messageSaveConvertedFile": "Save converted file", + "@messageSaveConvertedFile": { + "description": "dump_viewer_page: Save converted file" + }, + "messageConversionSuccessfulOutput": "✅ Conversion successful!\nOutput: {value}\n{value2}", + "@messageConversionSuccessfulOutput": { + "description": "dump_viewer_page: ✅ Conversion successful!\nOutput: {value}\n{value2}", + "placeholders": { + "value": { + "type": "String" + }, + "value2": { + "type": "String" + } + } + }, + "messageConversionError": "❌ Conversion error: {value}", + "@messageConversionError": { + "description": "dump_viewer_page: ❌ Conversion error: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageEnterHexDigits": "Enter {value} hex digits", + "@messageEnterHexDigits": { + "description": "dump_viewer_page: Enter {value} hex digits", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageChangesNotApplied": "Changes not applied: {value}", + "@messageChangesNotApplied": { + "description": "dump_viewer_page: Changes not applied: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageKeyEditor": "🔑 Key editor", + "@messageKeyEditor": { + "description": "dump_viewer_page: 🔑 Key editor" + }, + "messageApplyChanges": "Apply changes", + "@messageApplyChanges": { + "description": "dump_viewer_page: Apply changes" + }, + "messageAllDefaults": "All defaults", + "@messageAllDefaults": { + "description": "dump_viewer_page: All defaults" + }, + "messageBlockDataEditor": "📝 Block data editor", + "@messageBlockDataEditor": { + "description": "dump_viewer_page: 📝 Block data editor" + }, + "messageData2": "Data", + "@messageData2": { + "description": "dump_viewer_page: Data" + }, + "messageOpenADumpFileFirst": "Open a dump file first", + "@messageOpenADumpFileFirst": { + "description": "dump_viewer_page: Open a dump file first" + }, + "messageCuidCardWritingAndClearing": "CUID card writing and clearing", + "@messageCuidCardWritingAndClearing": { + "description": "dump_viewer_page: CUID card writing and clearing" + }, + "messageCuidGen2CardsHaveNoBackdoorCommands": "CUID (Gen2) cards have no backdoor commands; each block must be authenticated with a known key before writing.\n• Clear card: authenticate with the current card keys and zero data blocks.\n• Write card: authenticate with the target card keys and write the dump block by block.\n• Write sector: write only the selected sector.\n• Gen1A backdoor restore: restore the entire card using hf mf restore (Gen1A cards only).\n\n⚠️ Writing CUID block 0 automatically adds --force.", + "@messageCuidGen2CardsHaveNoBackdoorCommands": { + "description": "dump_viewer_page: CUID (Gen2) cards have no backdoor commands; each block must be authenticated with a known key before writing.\n• Clear card: authenticate with the current card keys and zero data blocks.\n• Write card: authenticate with the target card keys and write the dump block by block.\n• Write sector: write only the selected sector.\n• Gen1A backdoor restore: restore the entire card using hf mf restore (Gen1A cards only).\n\n⚠️ Writing CUID block 0 automatically adds --force." + }, + "messageConnectYourPm3DeviceFirst": "Connect your PM3 device first", + "@messageConnectYourPm3DeviceFirst": { + "description": "dump_viewer_page: Connect your PM3 device first" + }, + "messageWriteOptions": "⚙️ Write options", + "@messageWriteOptions": { + "description": "dump_viewer_page: ⚙️ Write options" + }, + "messageAuthenticationKeyType": "Authentication key type:", + "@messageAuthenticationKeyType": { + "description": "dump_viewer_page: Authentication key type:" + }, + "messageSkipBlock0ManufacturerBlock": "Skip block 0 (manufacturer block)", + "@messageSkipBlock0ManufacturerBlock": { + "description": "dump_viewer_page: Skip block 0 (manufacturer block)" + }, + "messageBlock0CannotBeWrittenUnlessThis": "Block 0 cannot be written unless this is a magic card", + "@messageBlock0CannotBeWrittenUnlessThis": { + "description": "dump_viewer_page: Block 0 cannot be written unless this is a magic card" + }, + "messageWriteTrailersKeysAccessBits": "Write trailers (keys + access bits)", + "@messageWriteTrailersKeysAccessBits": { + "description": "dump_viewer_page: Write trailers (keys + access bits)" + }, + "messageWriteTheDumpTrailerDataToThe": "Write the dump trailer data to the target card", + "@messageWriteTheDumpTrailerDataToThe": { + "description": "dump_viewer_page: Write the dump trailer data to the target card" + }, + "messageDumpKeysForAuthentication": "🔑 Dump keys (for authentication)", + "@messageDumpKeysForAuthentication": { + "description": "dump_viewer_page: 🔑 Dump keys (for authentication)" + }, + "messageSmartWrite": "🚀 Smart write", + "@messageSmartWrite": { + "description": "dump_viewer_page: 🚀 Smart write" + }, + "messageSelectTheBestWritePathForThe": "Select the best write path for the target card type (UID / CUID / FUID / Gen1A / Gen3 / Gen4).", + "@messageSelectTheBestWritePathForThe": { + "description": "dump_viewer_page: Select the best write path for the target card type (UID / CUID / FUID / Gen1A / Gen3 / Gen4)." + }, + "messageTargetCardTypeMode": "Target card type / mode", + "@messageTargetCardTypeMode": { + "description": "dump_viewer_page: Target card type / mode" + }, + "messageAutoDefault": "AUTO (default)", + "@messageAutoDefault": { + "description": "dump_viewer_page: AUTO (default)" + }, + "messageStandardUidCard": "Standard UID card", + "@messageStandardUidCard": { + "description": "dump_viewer_page: Standard UID card" + }, + "messageFuidUidCanBeChangedOnce": "FUID (UID can be changed once)", + "@messageFuidUidCanBeChangedOnce": { + "description": "dump_viewer_page: FUID (UID can be changed once)" + }, + "messageGen1aBackdoorCard": "Gen1A backdoor card", + "@messageGen1aBackdoorCard": { + "description": "dump_viewer_page: Gen1A backdoor card" + }, + "messageGen3MagicCard": "Gen3 magic card", + "@messageGen3MagicCard": { + "description": "dump_viewer_page: Gen3 magic card" + }, + "messageGen4PasswordOptional": "Gen4 password (optional)", + "@messageGen4PasswordOptional": { + "description": "dump_viewer_page: Gen4 password (optional)" + }, + "message8HexDigitsEG00000000": "8 hex digits, e.g. 00000000", + "@message8HexDigitsEG00000000": { + "description": "dump_viewer_page: 8 hex digits, e.g. 00000000" + }, + "messageSmartWrite2": "⚡ Smart write", + "@messageSmartWrite2": { + "description": "dump_viewer_page: ⚡ Smart write" + }, + "messageAuthenticateWithTheDumpKeysAndZero": "Authenticate with the dump keys and zero all data blocks.\nReset trailers to default keys and access bits.\nFor CUID / Gen2 cards (no backdoor).", + "@messageAuthenticateWithTheDumpKeysAndZero": { + "description": "dump_viewer_page: Authenticate with the dump keys and zero all data blocks.\nReset trailers to default keys and access bits.\nFor CUID / Gen2 cards (no backdoor)." + }, + "messageClearEntireCuidCard": "Clear entire CUID card", + "@messageClearEntireCuidCard": { + "description": "dump_viewer_page: Clear entire CUID card" + }, + "messageAuthenticateWithTheTargetCardKeysAnd": "Authenticate with the target card keys and write the dump block by block.\nFor CUID / Gen2 cards (no backdoor).\nKnown target card keys are required.", + "@messageAuthenticateWithTheTargetCardKeysAnd": { + "description": "dump_viewer_page: Authenticate with the target card keys and write the dump block by block.\nFor CUID / Gen2 cards (no backdoor).\nKnown target card keys are required." + }, + "messageWriteEntireCuidCard": "Write entire CUID card", + "@messageWriteEntireCuidCard": { + "description": "dump_viewer_page: Write entire CUID card" + }, + "messageWriteOnlySelectedSectorAuthenticateWithThe": "Write only selected sector {value}.\nAuthenticate with the dump keys and write each block in this sector.\nUse for partial updates after editing one sector.", + "@messageWriteOnlySelectedSectorAuthenticateWithThe": { + "description": "dump_viewer_page: Write only selected sector {value}.\nAuthenticate with the dump keys and write each block in this sector.\nUse for partial updates after editing one sector.", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageWriteSector": "Write sector {value}", + "@messageWriteSector": { + "description": "dump_viewer_page: Write sector {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageRestoreTheEntireCardUsingThePm3": "Restore the entire card using the PM3 hf mf restore command.\nFor Gen1A backdoor cards only; no keys required.\nWrites all blocks directly using the 0x40 / 0x43 backdoor commands.", + "@messageRestoreTheEntireCardUsingThePm3": { + "description": "dump_viewer_page: Restore the entire card using the PM3 hf mf restore command.\nFor Gen1A backdoor cards only; no keys required.\nWrites all blocks directly using the 0x40 / 0x43 backdoor commands." + }, + "messageGen1aBackdoorRestore": "Gen1A backdoor restore", + "@messageGen1aBackdoorRestore": { + "description": "dump_viewer_page: Gen1A backdoor restore" + }, + "messageWriteProgress": "📊 Write progress", + "@messageWriteProgress": { + "description": "dump_viewer_page: 📊 Write progress" + }, + "messageFormatConversionAndKeyExtraction": "📦 Format conversion and key extraction", + "@messageFormatConversionAndKeyExtraction": { + "description": "dump_viewer_page: 📦 Format conversion and key extraction" + }, + "messageEditedDataIsIncomplete": "Edited data is incomplete", + "@messageEditedDataIsIncomplete": { + "description": "dump_viewer_page: Edited data is incomplete" + }, + "messageNoValidUidFoundCannotWriteUid": "No valid UID found; cannot write UID", + "@messageNoValidUidFoundCannotWriteUid": { + "description": "dump_viewer_page: No valid UID found; cannot write UID" + }, + "messageClearCuidBlockByBlock": "Clear CUID block by block", + "@messageClearCuidBlockByBlock": { + "description": "dump_viewer_page: Clear CUID block by block" + }, + "messageAuthenticateTheTargetCardWithTheDump": "Authenticate the target card with the dump keys,\nthen zero each data block.\n\nTrailers will be reset to defaults:\nKey A = FFFFFFFFFFFF\nAccess bits = FF078069\nKey B = FFFFFFFFFFFF\n\n⚠️ This operation cannot be undone!", + "@messageAuthenticateTheTargetCardWithTheDump": { + "description": "dump_viewer_page: Authenticate the target card with the dump keys,\nthen zero each data block.\n\nTrailers will be reset to defaults:\nKey A = FFFFFFFFFFFF\nAccess bits = FF078069\nKey B = FFFFFFFFFFFF\n\n⚠️ This operation cannot be undone!" + }, + "messageBlocksToClear": "Blocks to clear: {value} ({value2})", + "@messageBlocksToClear": { + "description": "dump_viewer_page: Blocks to clear: {value} ({value2})", + "placeholders": { + "value": { + "type": "String" + }, + "value2": { + "type": "String" + } + } + }, + "messageSkipBlock0": "skip block 0", + "@messageSkipBlock0": { + "description": "dump_viewer_page: skip block 0" + }, + "messageIncludeBlock0": "include block 0", + "@messageIncludeBlock0": { + "description": "dump_viewer_page: include block 0" + }, + "messageStartClearing": "Start clearing", + "@messageStartClearing": { + "description": "dump_viewer_page: Start clearing" + }, + "messageWriteCuidBlockByBlock": "Write CUID block by block", + "@messageWriteCuidBlockByBlock": { + "description": "dump_viewer_page: Write CUID block by block" + }, + "messageAuthenticateTheTargetCardWithTheDump2": "Authenticate the target card with the dump keys,\nthen write the dump block by block.\n\n✅ Data blocks: write original dump data.\n✅ Trailers: write complete dump trailer data\n (Key A + access bits + Key B).\n\n💡 Use when a CUID / standard card has known keys\n and its data needs to be replaced.", + "@messageAuthenticateTheTargetCardWithTheDump2": { + "description": "dump_viewer_page: Authenticate the target card with the dump keys,\nthen write the dump block by block.\n\n✅ Data blocks: write original dump data.\n✅ Trailers: write complete dump trailer data\n (Key A + access bits + Key B).\n\n💡 Use when a CUID / standard card has known keys\n and its data needs to be replaced." + }, + "messageBlocksToWrite": "Blocks to write: {value} ({value2}, {value3})", + "@messageBlocksToWrite": { + "description": "dump_viewer_page: Blocks to write: {value} ({value2}, {value3})", + "placeholders": { + "value": { + "type": "String" + }, + "value2": { + "type": "String" + }, + "value3": { + "type": "String" + } + } + }, + "messageIncludeTrailers": "include trailers", + "@messageIncludeTrailers": { + "description": "dump_viewer_page: include trailers" + }, + "messageSkipTrailers": "skip trailers", + "@messageSkipTrailers": { + "description": "dump_viewer_page: skip trailers" + }, + "messageStartWriting": "Start writing", + "@messageStartWriting": { + "description": "dump_viewer_page: Start writing" + }, + "messageAuthenticateWithTheDumpKeysThenWrite": "Authenticate with the dump keys,\nthen write all blocks in sector {value} to the target card.\n\n{value2} Authentication key: Key {value3}", + "@messageAuthenticateWithTheDumpKeysThenWrite": { + "description": "dump_viewer_page: Authenticate with the dump keys,\nthen write all blocks in sector {value} to the target card.\n\n{value2} Authentication key: Key {value3}", + "placeholders": { + "value": { + "type": "String" + }, + "value2": { + "type": "String" + }, + "value3": { + "type": "String" + } + } + }, + "messageIncludeTrailers2": "Include trailers", + "@messageIncludeTrailers2": { + "description": "dump_viewer_page: Include trailers" + }, + "messageExcludeTrailers": "Exclude trailers", + "@messageExcludeTrailers": { + "description": "dump_viewer_page: Exclude trailers" + }, + "messageInformation": "Information", + "@messageInformation": { + "description": "hf_14b_page: Information" + }, + "messageReadWrite": "Read / write", + "@messageReadWrite": { + "description": "hf_14b_page: Read / write" + }, + "messageReader": "Reader", + "@messageReader": { + "description": "hf_14b_page: Reader" + }, + "messageReadA14443BCard": "Read a 14443-B card", + "@messageReadA14443BCard": { + "description": "hf_14b_page: Read a 14443-B card" + }, + "messageTagInformation": "Tag information", + "@messageTagInformation": { + "description": "hf_14b_page: Tag information" + }, + "messageReadTagDetails": "Read tag details", + "@messageReadTagDetails": { + "description": "hf_14b_page: Read tag details" + }, + "messageSniff": "Sniff", + "@messageSniff": { + "description": "hf_14b_page: Sniff" + }, + "messageCapture14443BCommunication": "Capture 14443-B communication", + "@messageCapture14443BCommunication": { + "description": "hf_14b_page: Capture 14443-B communication" + }, + "messageDump": "Dump", + "@messageDump": { + "description": "hf_14b_page: Dump" + }, + "messageReadAllData": "Read all data", + "@messageReadAllData": { + "description": "hf_14b_page: Read all data" + }, + "messageReadNdefData": "Read NDEF data", + "@messageReadNdefData": { + "description": "hf_14b_page: Read NDEF data" + }, + "messageBlockOperations": "Block operations", + "@messageBlockOperations": { + "description": "hf_14b_page: Block operations" + }, + "messageBlockNumber": "Block number", + "@messageBlockNumber": { + "description": "hf_14b_page: Block number" + }, + "messageReadBlock": "Read block", + "@messageReadBlock": { + "description": "hf_14b_page: Read block" + }, + "messageWriteDataHex": "Write data (hex)", + "@messageWriteDataHex": { + "description": "hf_14b_page: Write data (hex)" + }, + "messageWriteBlock": "Write block", + "@messageWriteBlock": { + "description": "hf_14b_page: Write block" + }, + "messageRawCommand": "Raw command", + "@messageRawCommand": { + "description": "hf_14b_page: Raw command" + }, + "messageRawHexData": "Raw hex data", + "@messageRawHexData": { + "description": "hf_14b_page: Raw hex data" + }, + "messageSimulateCard": "Simulate card", + "@messageSimulateCard": { + "description": "hf_14b_page: Simulate card" + }, + "messageSimulateA14443BTag": "Simulate a 14443-B tag", + "@messageSimulateA14443BTag": { + "description": "hf_14b_page: Simulate a 14443-B tag" + }, + "messageAutomaticallyReadIso15693": "Automatically read ISO 15693", + "@messageAutomaticallyReadIso15693": { + "description": "hf_15_page: Automatically read ISO 15693" + }, + "messageReadBasicTagInformation": "Read basic tag information", + "@messageReadBasicTagInformation": { + "description": "hf_15_page: Read basic tag information" + }, + "messageDumpCard": "Dump card", + "@messageDumpCard": { + "description": "hf_15_page: Dump card" + }, + "messageEraseCard": "Erase card", + "@messageEraseCard": { + "description": "hf_15_page: Erase card" + }, + "messageClearCardData": "Clear card data", + "@messageClearCardData": { + "description": "hf_15_page: Clear card data" + }, + "messageConfirmErase": "Confirm erase", + "@messageConfirmErase": { + "description": "hf_15_page: Confirm erase" + }, + "messageThisOperationCannotBeUndone": "This operation cannot be undone!", + "@messageThisOperationCannotBeUndone": { + "description": "hf_15_page: This operation cannot be undone!" + }, + "messageWriteData4ByteHex": "Write data (4-byte hex)", + "@messageWriteData4ByteHex": { + "description": "hf_15_page: Write data (4-byte hex)" + }, + "messageFindAfi": "Find AFI", + "@messageFindAfi": { + "description": "hf_15_page: Find AFI" + }, + "messageScanApplicationFamilyIdentifiers": "Scan application family identifiers", + "@messageScanApplicationFamilyIdentifiers": { + "description": "hf_15_page: Scan application family identifiers" + }, + "messageSimulateAnIso15693Tag": "Simulate an ISO 15693 tag", + "@messageSimulateAnIso15693Tag": { + "description": "hf_15_page: Simulate an ISO 15693 tag" + }, + "messageSniffCommunication": "Sniff communication", + "@messageSniffCommunication": { + "description": "hf_15_page: Sniff communication" + }, + "messageCaptureReaderCommunication": "Capture reader communication", + "@messageCaptureReaderCommunication": { + "description": "hf_15_page: Capture reader communication" + }, + "messageSetUid": "Set UID", + "@messageSetUid": { + "description": "hf_15_page: Set UID" + }, + "messageUid8ByteHex": "UID (8-byte hex)", + "@messageUid8ByteHex": { + "description": "hf_15_page: UID (8-byte hex)" + }, + "messageConfirm": "Confirm", + "@messageConfirm": { + "description": "hf_15_page: Confirm" + }, + "messageSearch": "Search", + "@messageSearch": { + "description": "hf_emv_page: Search" + }, + "messageSearchForEmvCards": "Search for EMV cards", + "@messageSearchForEmvCards": { + "description": "hf_emv_page: Search for EMV cards" + }, + "messageSelectPpseApplication": "Select PPSE application", + "@messageSelectPpseApplication": { + "description": "hf_emv_page: Select PPSE application" + }, + "messageExecuteTransaction": "Execute transaction", + "@messageExecuteTransaction": { + "description": "hf_emv_page: Execute transaction" + }, + "messageSimulateTheEmvTransactionFlow": "Simulate the EMV transaction flow", + "@messageSimulateTheEmvTransactionFlow": { + "description": "hf_emv_page: Simulate the EMV transaction flow" + }, + "messageTest": "Test", + "@messageTest": { + "description": "hf_emv_page: Test" + }, + "messageEmvSelfTest": "EMV self-test", + "@messageEmvSelfTest": { + "description": "hf_emv_page: EMV self-test" + }, + "messageReadAFelicaCard": "Read a FeliCa card", + "@messageReadAFelicaCard": { + "description": "hf_felica_page: Read a FeliCa card" + }, + "messageGetFelicaDetails": "Get FeliCa details", + "@messageGetFelicaDetails": { + "description": "hf_felica_page: Get FeliCa details" + }, + "messageDumpCardData": "Dump card data", + "@messageDumpCardData": { + "description": "hf_felica_page: Dump card data" + }, + "messageLiteDump": "Lite dump", + "@messageLiteDump": { + "description": "hf_felica_page: Lite dump" + }, + "messageDumpFelicaLite": "Dump FeliCa Lite", + "@messageDumpFelicaLite": { + "description": "hf_felica_page: Dump FeliCa Lite" + }, + "messageCaptureFelicaCommunication": "Capture FeliCa communication", + "@messageCaptureFelicaCommunication": { + "description": "hf_felica_page: Capture FeliCa communication" + }, + "messageScServiceCode": "SC (service code)", + "@messageScServiceCode": { + "description": "hf_felica_page: SC (service code)" + }, + "messageBlBlockNumber": "BL (block number)", + "@messageBlBlockNumber": { + "description": "hf_felica_page: BL (block number)" + }, + "messageReadFidoInformation": "Read FIDO information", + "@messageReadFidoInformation": { + "description": "hf_fido_page: Read FIDO information" + }, + "messageRegister": "Register", + "@messageRegister": { + "description": "hf_fido_page: Register" + }, + "messageFido2Registration": "FIDO2 registration", + "@messageFido2Registration": { + "description": "hf_fido_page: FIDO2 registration" + }, + "messageAuthenticate": "Authenticate", + "@messageAuthenticate": { + "description": "hf_fido_page: Authenticate" + }, + "messageFido2Authentication": "FIDO2 authentication", + "@messageFido2Authentication": { + "description": "hf_fido_page: FIDO2 authentication" + }, + "messageList": "List", + "@messageList": { + "description": "hf_fido_page: List" + }, + "messageListFidoCommunication": "List FIDO communication", + "@messageListFidoCommunication": { + "description": "hf_fido_page: List FIDO communication" + }, + "messageKeyRecovery": "Key recovery", + "@messageKeyRecovery": { + "description": "hf_iclass_page: Key recovery" + }, + "messageEmulator": "Emulator", + "@messageEmulator": { + "description": "hf_iclass_page: Emulator" + }, + "messageGetInformation": "Get information", + "@messageGetInformation": { + "description": "hf_iclass_page: Get information" + }, + "messageReadAnIclassTag": "Read an iCLASS tag", + "@messageReadAnIclassTag": { + "description": "hf_iclass_page: Read an iCLASS tag" + }, + "messageReadCard": "Read card", + "@messageReadCard": { + "description": "hf_iclass_page: Read card" + }, + "messageReaderMode": "Reader mode", + "@messageReaderMode": { + "description": "hf_iclass_page: Reader mode" + }, + "messageDumpAllBlocksToAFile": "Dump all blocks to a file", + "@messageDumpAllBlocksToAFile": { + "description": "hf_iclass_page: Dump all blocks to a file" + }, + "messageKey8ByteHex": "Key (8-byte hex)", + "@messageKey8ByteHex": { + "description": "hf_iclass_page: Key (8-byte hex)" + }, + "messageWriteData8ByteHex": "Write data (8-byte hex)", + "@messageWriteData8ByteHex": { + "description": "hf_iclass_page: Write data (8-byte hex)" + }, + "messageCheckKeys": "Check keys", + "@messageCheckKeys": { + "description": "hf_iclass_page: Check keys" + }, + "messageCheckCommonDefaultKeys": "Check common default keys", + "@messageCheckCommonDefaultKeys": { + "description": "hf_iclass_page: Check common default keys" + }, + "messageLoclassAttack": "Loclass attack", + "@messageLoclassAttack": { + "description": "hf_iclass_page: Loclass attack" + }, + "messageRecoverIclassKeysOffline": "Recover iCLASS keys offline", + "@messageRecoverIclassKeysOffline": { + "description": "hf_iclass_page: Recover iCLASS keys offline" + }, + "messageCaptureCardReaderCommunication": "Capture card / reader communication", + "@messageCaptureCardReaderCommunication": { + "description": "hf_iclass_page: Capture card / reader communication" + }, + "messageEmulatorOperations": "Emulator operations", + "@messageEmulatorOperations": { + "description": "hf_iclass_page: Emulator operations" + }, + "messageFilePathOptional": "File path (optional)", + "@messageFilePathOptional": { + "description": "hf_iclass_page: File path (optional)" + }, + "messageLeaveBlankToUseTheDefault": "Leave blank to use the default", + "@messageLeaveBlankToUseTheDefault": { + "description": "hf_iclass_page: Leave blank to use the default" + }, + "messageLoadIntoEmulator": "Load into emulator", + "@messageLoadIntoEmulator": { + "description": "hf_iclass_page: Load into emulator" + }, + "messageView": "View", + "@messageView": { + "description": "hf_iclass_page: View" + }, + "messageSimulate": "Simulate", + "@messageSimulate": { + "description": "hf_iclass_page: Simulate" + }, + "messageReadALegicTag": "Read a Legic tag", + "@messageReadALegicTag": { + "description": "hf_legic_page: Read a Legic tag" + }, + "messageDumpAllData": "Dump all data", + "@messageDumpAllData": { + "description": "hf_legic_page: Dump all data" + }, + "messageErase": "Erase", + "@messageErase": { + "description": "hf_legic_page: Erase" + }, + "messageSimulateALegicTag": "Simulate a Legic tag", + "@messageSimulateALegicTag": { + "description": "hf_legic_page: Simulate a Legic tag" + }, + "messageReadWriteOperations": "Read / write operations", + "@messageReadWriteOperations": { + "description": "hf_legic_page: Read / write operations" + }, + "messageOffset": "Offset", + "@messageOffset": { + "description": "hf_legic_page: Offset" + }, + "messageLength": "Length", + "@messageLength": { + "description": "hf_legic_page: Length" + }, + "messageRead": "Read", + "@messageRead": { + "description": "hf_legic_page: Read" + }, + "messageWrite": "Write", + "@messageWrite": { + "description": "hf_legic_page: Write" + }, + "messageApply": "Apply", + "@messageApply": { + "description": "Command card editor action that applies edits (verb)." + }, + "desfireApplicationsTab": "Applications", + "@desfireApplicationsTab": { + "description": "DESFire tab for listing and selecting applications (noun), not the Apply action." + }, + "messageFiles": "Files", + "@messageFiles": { + "description": "hf_mfdes_page: Files" + }, + "messageManagement": "Management", + "@messageManagement": { + "description": "hf_mfdes_page: Management" + }, + "messageDetect": "Detect", + "@messageDetect": { + "description": "hf_mfdes_page: Detect" + }, + "messageDetectADesfireCard": "Detect a DESFire card", + "@messageDetectADesfireCard": { + "description": "hf_mfdes_page: Detect a DESFire card" + }, + "messageReadBasicCardInformation": "Read basic card information", + "@messageReadBasicCardInformation": { + "description": "hf_mfdes_page: Read basic card information" + }, + "messageGetUid": "Get UID", + "@messageGetUid": { + "description": "hf_mfdes_page: Get UID" + }, + "messageGetCardUid": "Get card UID", + "@messageGetCardUid": { + "description": "hf_mfdes_page: Get card UID" + }, + "messageFreeMemory": "Free memory", + "@messageFreeMemory": { + "description": "hf_mfdes_page: Free memory" + }, + "messageViewAvailableMemory": "View available memory", + "@messageViewAvailableMemory": { + "description": "hf_mfdes_page: View available memory" + }, + "messageCheckDefaultKeys": "Check default keys", + "@messageCheckDefaultKeys": { + "description": "hf_mfdes_page: Check default keys" + }, + "messageViewApplicationDirectory": "View application directory", + "@messageViewApplicationDirectory": { + "description": "hf_mfdes_page: View application directory" + }, + "messageListAids": "List AIDs", + "@messageListAids": { + "description": "hf_mfdes_page: List AIDs" + }, + "messageListApplications": "List applications", + "@messageListApplications": { + "description": "hf_mfdes_page: List applications" + }, + "messageSelectApplication": "Select application", + "@messageSelectApplication": { + "description": "hf_mfdes_page: Select application" + }, + "messageAid3ByteHex": "AID (3-byte hex)", + "@messageAid3ByteHex": { + "description": "hf_mfdes_page: AID (3-byte hex)" + }, + "messageSelect": "Select", + "@messageSelect": { + "description": "hf_mfdes_page: Select" + }, + "messageListFiles": "List files", + "@messageListFiles": { + "description": "hf_mfdes_page: List files" + }, + "messageFileDetails": "File details", + "@messageFileDetails": { + "description": "hf_mfdes_page: File details" + }, + "messageDumpApplication": "Dump application", + "@messageDumpApplication": { + "description": "hf_mfdes_page: Dump application" + }, + "messageFileReadWrite": "File read / write", + "@messageFileReadWrite": { + "description": "hf_mfdes_page: File read / write" + }, + "messageKeyNumber": "Key number", + "@messageKeyNumber": { + "description": "hf_mfdes_page: Key number" + }, + "messageAlgorithm": "Algorithm", + "@messageAlgorithm": { + "description": "hf_mfdes_page: Algorithm" + }, + "messageKeyHex": "Key (hex)", + "@messageKeyHex": { + "description": "hf_mfdes_page: Key (hex)" + }, + "messageKeySettings": "Key settings", + "@messageKeySettings": { + "description": "hf_mfdes_page: Key settings" + }, + "messageGetKeySettings": "Get key settings", + "@messageGetKeySettings": { + "description": "hf_mfdes_page: Get key settings" + }, + "messageFormatPicc": "Format PICC", + "@messageFormatPicc": { + "description": "hf_mfdes_page: Format PICC" + }, + "messageDeleteAllApplicationsAndData": "Delete all applications and data", + "@messageDeleteAllApplicationsAndData": { + "description": "hf_mfdes_page: Delete all applications and data" + }, + "messageConfirmFormat": "Confirm format", + "@messageConfirmFormat": { + "description": "hf_mfdes_page: Confirm format" + }, + "messageThisWillDeleteAllApplicationsAndData": "This will delete all applications and data. This operation cannot be undone!", + "@messageThisWillDeleteAllApplicationsAndData": { + "description": "hf_mfdes_page: This will delete all applications and data. This operation cannot be undone!" + }, + "messageReadAnMfuNtagTag": "Read an MFU / NTAG tag", + "@messageReadAnMfuNtagTag": { + "description": "hf_mfu_page: Read an MFU / NTAG tag" + }, + "messageReadAllDataToAFile": "Read all data to a file", + "@messageReadAllDataToAFile": { + "description": "hf_mfu_page: Read all data to a file" + }, + "messagePasswordAuthentication": "Password authentication", + "@messagePasswordAuthentication": { + "description": "hf_mfu_page: Password authentication" + }, + "messagePassword4ByteHex": "Password (4-byte hex)", + "@messagePassword4ByteHex": { + "description": "hf_mfu_page: Password (4-byte hex)" + }, + "messageReadNdef": "Read NDEF", + "@messageReadNdef": { + "description": "hf_mfu_page: Read NDEF" + }, + "messageReadNfcDataExchangeFormat": "Read NFC Data Exchange Format", + "@messageReadNfcDataExchangeFormat": { + "description": "hf_mfu_page: Read NFC Data Exchange Format" + }, + "messageSaveEmulator": "Save emulator", + "@messageSaveEmulator": { + "description": "hf_mfu_page: Save emulator" + }, + "messageGenerateKeys": "Generate keys", + "@messageGenerateKeys": { + "description": "hf_mfu_page: Generate keys" + }, + "messageKeygenDeriveKeysFromUid": "keygen — derive keys from UID", + "@messageKeygenDeriveKeysFromUid": { + "description": "hf_mfu_page: keygen — derive keys from UID" + }, + "messageDerivePassword": "Derive password", + "@messageDerivePassword": { + "description": "hf_mfu_page: Derive password" + }, + "messagePwdgenDeriveTheNtagPassword": "pwdgen — derive the NTAG password", + "@messagePwdgenDeriveTheNtagPassword": { + "description": "hf_mfu_page: pwdgen — derive the NTAG password" + }, + "messageKeyCheck": "Key check", + "@messageKeyCheck": { + "description": "hf_mfu_page: Key check" + }, + "messageCchkCheckCommonKeys": "cchk — check common keys", + "@messageCchkCheckCommonKeys": { + "description": "hf_mfu_page: cchk — check common keys" + }, + "messageUid7ByteHex": "UID (7-byte hex)", + "@messageUid7ByteHex": { + "description": "hf_mfu_page: UID (7-byte hex)" + }, + "messageReadSeosInformation": "Read SEOS information", + "@messageReadSeosInformation": { + "description": "hf_seos_page: Read SEOS information" + }, + "messageReadPacsData": "Read PACS data", + "@messageReadPacsData": { + "description": "hf_seos_page: Read PACS data" + }, + "messageSimulateASeosCard": "Simulate a SEOS card", + "@messageSimulateASeosCard": { + "description": "hf_seos_page: Simulate a SEOS card" + }, + "messageHfSearch": "HF search", + "@messageHfSearch": { + "description": "hf_sniff_page: HF search" + }, + "messageSearchForHighFrequencyTags": "Search for high-frequency tags", + "@messageSearchForHighFrequencyTags": { + "description": "hf_sniff_page: Search for high-frequency tags" + }, + "messageHfSniff": "HF sniff", + "@messageHfSniff": { + "description": "hf_sniff_page: HF sniff" + }, + "messageCaptureHighFrequencyCommunication": "Capture high-frequency communication", + "@messageCaptureHighFrequencyCommunication": { + "description": "hf_sniff_page: Capture high-frequency communication" + }, + "messageHfTuning": "HF tuning", + "@messageHfTuning": { + "description": "hf_sniff_page: HF tuning" + }, + "messageTuneTheHfAntenna": "Tune the HF antenna", + "@messageTuneTheHfAntenna": { + "description": "hf_sniff_page: Tune the HF antenna" + }, + "messageProtocolList": "Protocol list", + "@messageProtocolList": { + "description": "hf_sniff_page: Protocol list" + }, + "messageProtocol": "Protocol", + "@messageProtocol": { + "description": "hf_sniff_page: Protocol" + }, + "messageDecodeList": "Decode list", + "@messageDecodeList": { + "description": "hf_sniff_page: Decode list" + }, + "messageReadAnAwidCard": "Read an AWID card", + "@messageReadAnAwidCard": { + "description": "lf_awid_page: Read an AWID card" + }, + "messageCloneSimulate": "Clone / simulate", + "@messageCloneSimulate": { + "description": "lf_awid_page: Clone / simulate" + }, + "messageFcFacilityCode": "FC (facility code)", + "@messageFcFacilityCode": { + "description": "lf_awid_page: FC (facility code)" + }, + "messageCnCardNumber": "CN (card number)", + "@messageCnCardNumber": { + "description": "lf_awid_page: CN (card number)" + }, + "messageClone": "Clone", + "@messageClone": { + "description": "lf_awid_page: Clone" + }, + "messageReadAnFdxBTag": "Read an FDX-B tag", + "@messageReadAnFdxBTag": { + "description": "lf_fdxb_page: Read an FDX-B tag" + }, + "messageRawData2": "Raw data", + "@messageRawData2": { + "description": "lf_fdxb_page: Raw data" + }, + "messageCloneToT55xx": "Clone to T55xx", + "@messageCloneToT55xx": { + "description": "lf_fdxb_page: Clone to T55xx" + }, + "messageCloneSimulate2": "Clone / simulate", + "@messageCloneSimulate2": { + "description": "lf_hid_page: Clone / simulate" + }, + "messageReadHidCard": "Read HID card", + "@messageReadHidCard": { + "description": "lf_hid_page: Read HID card" + }, + "messageReadAHidProxCard": "Read a HID Prox card", + "@messageReadAHidProxCard": { + "description": "lf_hid_page: Read a HID Prox card" + }, + "messageDemodulateSignal": "Demodulate signal", + "@messageDemodulateSignal": { + "description": "lf_hid_page: Demodulate signal" + }, + "messageDemodulateTheHidLfSignal": "Demodulate the HID LF signal", + "@messageDemodulateTheHidLfSignal": { + "description": "lf_hid_page: Demodulate the HID LF signal" + }, + "messageWiegandData": "Wiegand data", + "@messageWiegandData": { + "description": "lf_hid_page: Wiegand data" + }, + "messageForExample2006ec0c86": "For example: 2006ec0c86", + "@messageForExample2006ec0c86": { + "description": "lf_hid_page: For example: 2006ec0c86" + }, + "hidReaderBruteForceTab": "Reader brute-force", + "@hidReaderBruteForceTab": { + "description": "HID Prox tab for trying facility-code/card-number combinations against a reader, not recovering cryptographic keys." + }, + "hidReaderBruteForceTitle": "Brute-force facility codes / card numbers", + "@hidReaderBruteForceTitle": { + "description": "HID Prox reader brute-force form heading. This enumerates credential combinations, not cryptographic keys." + }, + "messageFcOptional": "FC (optional)", + "@messageFcOptional": { + "description": "lf_hid_page: FC (optional)" + }, + "messageLeaveBlankToTryAllValues": "Leave blank to try all values", + "@messageLeaveBlankToTryAllValues": { + "description": "lf_hid_page: Leave blank to try all values" + }, + "hidStartBruteForce": "Start brute-force", + "@hidStartBruteForce": { + "description": "Start the HID Prox reader brute-force operation (lf hid brute), not key recovery." + }, + "messageReadAHitagCard": "Read a Hitag card", + "@messageReadAHitagCard": { + "description": "lf_hitag_page: Read a Hitag card" + }, + "messageGetTagDetails": "Get tag details", + "@messageGetTagDetails": { + "description": "lf_hitag_page: Get tag details" + }, + "messageCaptureCommunicationData": "Capture communication data", + "@messageCaptureCommunicationData": { + "description": "lf_hitag_page: Capture communication data" + }, + "messageCheckCommonKeys": "Check common keys", + "@messageCheckCommonKeys": { + "description": "lf_hitag_page: Check common keys" + }, + "messageKeyRecoveryAttack": "Key recovery attack", + "@messageKeyRecoveryAttack": { + "description": "lf_hitag_page: Key recovery attack" + }, + "messageSimulateAHitagTag": "Simulate a Hitag tag", + "@messageSimulateAHitagTag": { + "description": "lf_hitag_page: Simulate a Hitag tag" + }, + "messageReadAnIndalaCard": "Read an Indala card", + "@messageReadAnIndalaCard": { + "description": "lf_indala_page: Read an Indala card" + }, + "messageReadAnIoproxCard": "Read an ioProx card", + "@messageReadAnIoproxCard": { + "description": "lf_io_page: Read an ioProx card" + }, + "messageReadAKeriCard": "Read a Keri card", + "@messageReadAKeriCard": { + "description": "lf_keri_page: Read a Keri card" + }, + "messageGeneral": "General", + "@messageGeneral": { + "description": "lf_page: General" + }, + "messageAutomaticallyDetectLowFrequencyTags": "Automatically detect low-frequency tags", + "@messageAutomaticallyDetectLowFrequencyTags": { + "description": "lf_page: Automatically detect low-frequency tags" + }, + "messageReadTheRawLowFrequencySignal": "Read the raw low-frequency signal", + "@messageReadTheRawLowFrequencySignal": { + "description": "lf_page: Read the raw low-frequency signal" + }, + "messageLfSniff": "LF sniff", + "@messageLfSniff": { + "description": "lf_page: LF sniff" + }, + "messageCaptureLowFrequencyCommunication": "Capture low-frequency communication", + "@messageCaptureLowFrequencyCommunication": { + "description": "lf_page: Capture low-frequency communication" + }, + "messageCheckAntennaTuning": "Check antenna tuning", + "@messageCheckAntennaTuning": { + "description": "lf_page: Check antenna tuning" + }, + "messageReadEm410x": "Read EM410x", + "@messageReadEm410x": { + "description": "lf_page: Read EM410x" + }, + "messageReadTheEm4100TagId": "Read the EM4100 tag ID", + "@messageReadTheEm4100TagId": { + "description": "lf_page: Read the EM4100 tag ID" + }, + "messageCloneEm410x": "Clone EM410x", + "@messageCloneEm410x": { + "description": "lf_page: Clone EM410x" + }, + "messageTagId10HexDigits": "Tag ID (10 hex digits)", + "@messageTagId10HexDigits": { + "description": "lf_page: Tag ID (10 hex digits)" + }, + "messageForExample0102030405": "For example: 0102030405", + "@messageForExample0102030405": { + "description": "lf_page: For example: 0102030405" + }, + "messageIdMustContain10HexDigits": "ID must contain 10 hex digits", + "@messageIdMustContain10HexDigits": { + "description": "lf_page: ID must contain 10 hex digits" + }, + "messageDetectT55xxChipConfiguration": "Detect T55xx chip configuration", + "@messageDetectT55xxChipConfiguration": { + "description": "lf_page: Detect T55xx chip configuration" + }, + "messageShowT55xxConfiguration": "Show T55xx configuration", + "@messageShowT55xxConfiguration": { + "description": "lf_page: Show T55xx configuration" + }, + "messageDumpAllT55xxBlocks": "Dump all T55xx blocks", + "@messageDumpAllT55xxBlocks": { + "description": "lf_page: Dump all T55xx blocks" + }, + "messageReadAPyramidCard": "Read a Pyramid card", + "@messageReadAPyramidCard": { + "description": "lf_pyramid_page: Read a Pyramid card" + }, + "messageCardType2": "Card type:", + "@messageCardType2": { + "description": "mifare_page: Card type:" + }, + "messageQuickActions": "Quick actions", + "@messageQuickActions": { + "description": "mifare_page: Quick actions" + }, + "messageKeyRecoveryAttacks": "Key recovery attacks", + "@messageKeyRecoveryAttacks": { + "description": "mifare_page: Key recovery attacks" + }, + "messageReadWriteBlocks": "Read / write blocks", + "@messageReadWriteBlocks": { + "description": "mifare_page: Read / write blocks" + }, + "messageMagicCards": "Magic cards", + "@messageMagicCards": { + "description": "mifare_page: Magic cards" + }, + "messageDumpRestoreParametersOptional": "Dump / restore parameters (optional)", + "@messageDumpRestoreParametersOptional": { + "description": "mifare_page: Dump / restore parameters (optional)" + }, + "messageKeyFileKeysOptional": "Key file --keys (optional)", + "@messageKeyFileKeysOptional": { + "description": "mifare_page: Key file --keys (optional)" + }, + "messageLeaveBlankToUseThePm3Default": "Leave blank to use the PM3 default", + "@messageLeaveBlankToUseThePm3Default": { + "description": "mifare_page: Leave blank to use the PM3 default" + }, + "messageDumpFileFileOptional": "Dump file --file (optional)", + "@messageDumpFileFileOptional": { + "description": "mifare_page: Dump file --file (optional)" + }, + "messageDumpOutputFileRestoreInputFile": "dump: output file / restore: input file", + "@messageDumpOutputFileRestoreInputFile": { + "description": "mifare_page: dump: output file / restore: input file" + }, + "messageSelectKeyFile": "Select key file", + "@messageSelectKeyFile": { + "description": "mifare_page: Select key file" + }, + "messageSelectDumpFile": "Select dump file", + "@messageSelectDumpFile": { + "description": "mifare_page: Select dump file" + }, + "messageClearCustomSettings": "Clear custom settings", + "@messageClearCustomSettings": { + "description": "mifare_page: Clear custom settings" + }, + "messageDefaultSourcesKeyDump": "Default sources: key={value}; dump={value2}", + "@messageDefaultSourcesKeyDump": { + "description": "mifare_page: Default sources: key={value}; dump={value2}", + "placeholders": { + "value": { + "type": "String" + }, + "value2": { + "type": "String" + } + } + }, + "messageDetectCard": "Detect card", + "@messageDetectCard": { + "description": "mifare_page: Detect card" + }, + "messageScanHf14aTags": "Scan HF 14A tags", + "@messageScanHf14aTags": { + "description": "mifare_page: Scan HF 14A tags" + }, + "messageCardInformation": "Card information", + "@messageCardInformation": { + "description": "mifare_page: Card information" + }, + "messageGetDetailedCardInformation": "Get detailed card information", + "@messageGetDetailedCardInformation": { + "description": "mifare_page: Get detailed card information" + }, + "messageAutomaticKeyRecovery": "Automatic key recovery", + "@messageAutomaticKeyRecovery": { + "description": "mifare_page: Automatic key recovery" + }, + "messageReadAllSectorsToAFile": "Read all sectors to a file", + "@messageReadAllSectorsToAFile": { + "description": "mifare_page: Read all sectors to a file" + }, + "messageRestoreCard": "Restore card", + "@messageRestoreCard": { + "description": "mifare_page: Restore card" + }, + "messageWriteTheDumpBackToTheCard": "Write the dump back to the card", + "@messageWriteTheDumpBackToTheCard": { + "description": "mifare_page: Write the dump back to the card" + }, + "messageSniff2": "Sniff", + "@messageSniff2": { + "description": "mifare_page: Sniff" + }, + "messageCaptureCardCommunication": "Capture card communication", + "@messageCaptureCardCommunication": { + "description": "mifare_page: Capture card communication" + }, + "messageMoreCardRecoveryDiagnosticsSeeCommandReference": "More card recovery / diagnostics (see command reference)", + "@messageMoreCardRecoveryDiagnosticsSeeCommandReference": { + "description": "mifare_page: More card recovery / diagnostics (see command reference)" + }, + "messageCollectCuid": "Collect CUID", + "@messageCollectCuid": { + "description": "mifare_page: Collect CUID" + }, + "messagePm3ScriptNameScriptRunName": "PM3 script name (script run )", + "@messagePm3ScriptNameScriptRunName": { + "description": "mifare_page: PM3 script name (script run )" + }, + "messageForExampleMifareMfAutopwn": "For example: mifare/mf_autopwn", + "@messageForExampleMifareMfAutopwn": { + "description": "mifare_page: For example: mifare/mf_autopwn" + }, + "messageListScripts": "List scripts", + "@messageListScripts": { + "description": "mifare_page: List scripts" + }, + "messageRunScript": "Run script", + "@messageRunScript": { + "description": "mifare_page: Run script" + }, + "messageKnownKey": "Known key", + "@messageKnownKey": { + "description": "mifare_page: Known key" + }, + "messageTryDefaultKeysOnAllSectors": "Try default keys on all sectors", + "@messageTryDefaultKeysOnAllSectors": { + "description": "mifare_page: Try default keys on all sectors" + }, + "messageNestedAttack": "Nested attack", + "@messageNestedAttack": { + "description": "mifare_page: Nested attack" + }, + "messageUseAKnownKeyToFindOther": "Use a known key to find other keys", + "@messageUseAKnownKeyToFindOther": { + "description": "mifare_page: Use a known key to find other keys" + }, + "messageForCardsWithStaticNonces": "For cards with static nonces", + "@messageForCardsWithStaticNonces": { + "description": "mifare_page: For cards with static nonces" + }, + "messageAdvancedKeyRecovery": "Advanced key recovery", + "@messageAdvancedKeyRecovery": { + "description": "mifare_page: Advanced key recovery" + }, + "messageNoKnownKeyRequired": "No known key required", + "@messageNoKnownKeyRequired": { + "description": "mifare_page: No known key required" + }, + "messageReadDataFromOneBlock": "Read data from one block", + "@messageReadDataFromOneBlock": { + "description": "mifare_page: Read data from one block" + }, + "messageWriteData": "Write data", + "@messageWriteData": { + "description": "mifare_page: Write data" + }, + "messageData32HexDigits": "Data (32 hex digits)", + "@messageData32HexDigits": { + "description": "mifare_page: Data (32 hex digits)" + }, + "messageForExample00000000000000000000000000000000": "For example: 00000000000000000000000000000000", + "@messageForExample00000000000000000000000000000000": { + "description": "mifare_page: For example: 00000000000000000000000000000000" + }, + "messageMagicCardOperations": "Magic card operations", + "@messageMagicCardOperations": { + "description": "mifare_page: Magic card operations" + }, + "messageTheseCommandsOnlyApplyToGen1aGen2": "These commands only apply to Gen1A / Gen2 (CUID) magic cards.\nWriting block 0 may damage a non-magic card!", + "@messageTheseCommandsOnlyApplyToGen1aGen2": { + "description": "mifare_page: These commands only apply to Gen1A / Gen2 (CUID) magic cards.\nWriting block 0 may damage a non-magic card!" + }, + "messageReadBlock0": "Read block 0", + "@messageReadBlock0": { + "description": "mifare_page: Read block 0" + }, + "messageReadManufacturerBlock": "Read manufacturer block", + "@messageReadManufacturerBlock": { + "description": "mifare_page: Read manufacturer block" + }, + "messageRestoreFactoryDefaults": "Restore factory defaults", + "@messageRestoreFactoryDefaults": { + "description": "mifare_page: Restore factory defaults" + }, + "messageEraseCard2": "Erase card?", + "@messageEraseCard2": { + "description": "mifare_page: Erase card?" + }, + "messageThisWillResetAllBlocksAreYou": "This will reset all blocks. Are you sure?", + "@messageThisWillResetAllBlocksAreYou": { + "description": "mifare_page: This will reset all blocks. Are you sure?" + }, + "messageLoadEmulator": "Load emulator", + "@messageLoadEmulator": { + "description": "mifare_page: Load emulator" + }, + "messageLoadADumpIntoTheEmulator": "Load a dump into the emulator", + "@messageLoadADumpIntoTheEmulator": { + "description": "mifare_page: Load a dump into the emulator" + }, + "messageNdefDecoding": "NDEF decoding", + "@messageNdefDecoding": { + "description": "nfc_page: NDEF decoding" + }, + "messageListAvailableScripts": "List available scripts", + "@messageListAvailableScripts": { + "description": "script_page: List available scripts" + }, + "messageScriptName": "Script name", + "@messageScriptName": { + "description": "script_page: Script name" + }, + "messageForExampleHfMfAutopwn": "For example: hf_mf_autopwn", + "@messageForExampleHfMfAutopwn": { + "description": "script_page: For example: hf_mf_autopwn" + }, + "messageRun": "Run", + "@messageRun": { + "description": "script_page: Run" + }, + "messageHint": "Hint: {value}", + "@messageHint": { + "description": "terminal_page: Hint: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageClearTerminal": "Clear terminal", + "@messageClearTerminal": { + "description": "terminal_page: Clear terminal" + }, + "messageListDecode": "List / decode", + "@messageListDecode": { + "description": "trace_page: List / decode" + }, + "messageProtocolType": "Protocol type", + "@messageProtocolType": { + "description": "trace_page: Protocol type" + }, + "messageDecodeList2": "Decode list", + "@messageDecodeList2": { + "description": "trace_page: Decode list" + }, + "messageExtract": "Extract", + "@messageExtract": { + "description": "trace_page: Extract" + }, + "messageExtractTraceData": "Extract trace data", + "@messageExtractTraceData": { + "description": "trace_page: Extract trace data" + }, + "messageSpacesAreAllowedMeansUnknownClearThe": "Spaces are allowed; ? means unknown. Clear the field to remove the entry.", + "@messageSpacesAreAllowedMeansUnknownClearThe": { + "description": "command_card_editor: Spaces are allowed; ? means unknown. Clear the field to remove the entry." + }, + "messageSaved": "Saved: {value}", + "@messageSaved": { + "description": "command_card_editor: Saved: {value}", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageCommandResultsAreCollectedHereAutomaticallyClick": "Command results are collected here automatically. Click a key or block to edit it. Select “New card” before changing cards; ? means unknown.", + "@messageCommandResultsAreCollectedHereAutomaticallyClick": { + "description": "command_card_editor: Command results are collected here automatically. Click a key or block to edit it. Select “New card” before changing cards; ? means unknown." + }, + "messageUidBlocksKeys": "{value} · UID {value2} · Blocks: {value3} · Keys: {value4}", + "@messageUidBlocksKeys": { + "description": "command_card_editor: {value} · UID {value2} · Blocks: {value3} · Keys: {value4}", + "placeholders": { + "value": { + "type": "String" + }, + "value2": { + "type": "String" + }, + "value3": { + "type": "String" + }, + "value4": { + "type": "String" + } + } + }, + "messageNotIdentified": "Not identified", + "@messageNotIdentified": { + "description": "command_card_editor: Not identified" + }, + "messageNewCardSelectCapacity": "New card / select capacity", + "@messageNewCardSelectCapacity": { + "description": "command_card_editor: New card / select capacity" + }, + "messageNewCardClearTheCurrentDraft": "New {value} card (clear the current draft)", + "@messageNewCardClearTheCurrentDraft": { + "description": "command_card_editor: New {value} card (clear the current draft)", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "messageNewCardCapacity": "New card / capacity", + "@messageNewCardCapacity": { + "description": "command_card_editor: New card / capacity" + }, + "messageOpenDraft": "Open draft", + "@messageOpenDraft": { + "description": "command_card_editor: Open draft" + }, + "messageSaveDraft": "Save draft", + "@messageSaveDraft": { + "description": "command_card_editor: Save draft" + }, + "messageExportKeyDictionary": "Export key dictionary", + "@messageExportKeyDictionary": { + "description": "command_card_editor: Export key dictionary" + }, + "messageExportKeyBin2": "Export key BIN", + "@messageExportKeyBin2": { + "description": "command_card_editor: Export key BIN" + }, + "messageReplaceDraftWithTheOpenFile": "Replace draft with the open file", + "@messageReplaceDraftWithTheOpenFile": { + "description": "command_card_editor: Replace draft with the open file" + }, + "messageExportEml2": "Export EML", + "@messageExportEml2": { + "description": "command_card_editor: Export EML" + }, + "messageExportBin2": "Export BIN", + "@messageExportBin2": { + "description": "command_card_editor: Export BIN" + }, + "messageDirectCommand": "Direct command", + "@messageDirectCommand": { + "description": "command_card_editor: Direct command" + }, + "messageForExampleHfMfChk1k": "For example: hf mf chk --1k", + "@messageForExampleHfMfChk1k": { + "description": "command_card_editor: For example: hf mf chk --1k" + }, + "messageRunning": "Running", + "@messageRunning": { + "description": "command_card_editor: Running" + }, + "messageExecute": "Execute", + "@messageExecute": { + "description": "command_card_editor: Execute" + }, + "messageKeysTrailers": "Keys → trailers", + "@messageKeysTrailers": { + "description": "command_card_editor: Keys → trailers" + }, + "messageTrailersKeys": "Trailers → keys", + "@messageTrailersKeys": { + "description": "command_card_editor: Trailers → keys" + }, + "messageNotRead": "Not read", + "@messageNotRead": { + "description": "command_card_editor: Not read" + }, + "messageSectorKey2": "Sector {value} · Key {value2}", + "@messageSectorKey2": { + "description": "command_card_editor: Sector {value} · Key {value2}", + "placeholders": { + "value": { + "type": "String" + }, + "value2": { + "type": "String" + } + } + }, + "messageBlock3": "Block {value}{value2}", + "@messageBlock3": { + "description": "command_card_editor: Block {value}{value2}", + "placeholders": { + "value": { + "type": "String" + }, + "value2": { + "type": "String" + } + } + }, + "messageTrailer2": " · Trailer", + "@messageTrailer2": { + "description": "command_card_editor: · Trailer" + } +} diff --git a/lib/l10n/app_locale_controller.dart b/lib/l10n/app_locale_controller.dart new file mode 100644 index 0000000..be7ba32 --- /dev/null +++ b/lib/l10n/app_locale_controller.dart @@ -0,0 +1,90 @@ +import 'dart:io'; + +import 'package:flutter/widgets.dart'; +import 'package:pm3gui/services/locale_settings_store.dart'; +import 'app_localizations.dart'; + +class AppLocaleController extends ChangeNotifier { + AppLocaleController({LocaleSettingsRepository? repository}) + : _repository = repository ?? LocaleSettingsStore(); + + final LocaleSettingsRepository _repository; + Locale? _locale; + Object? _error; + bool _changed = false; + bool _disposed = false; + int _pendingSaves = 0; + Future? _initialization; + Future _queue = Future.value(); + + Locale? get locale => _locale; + Object? get error => _error; + bool get isSaving => _pendingSaves > 0; + + Future initialize() => _initialization ??= _restore(); + + Future _restore() async { + try { + final languageCode = await _repository.load(); + if (languageCode != null && + !AppLocalizations.supportedLocales + .any((locale) => locale.languageCode == languageCode)) { + throw FormatException('Unsupported saved language: $languageCode'); + } + if (!_changed) { + _locale = languageCode == null ? null : Locale(languageCode); + } + } on FileSystemException catch (error) { + _error = error; + } on FormatException catch (error) { + _error = error; + } + _notify(); + } + + Future setLocale(Locale? locale) async { + if (locale != null && !AppLocalizations.supportedLocales.contains(locale)) { + throw ArgumentError.value(locale, 'locale', 'Unsupported app locale'); + } + _changed = true; + _locale = locale; + _error = null; + _pendingSaves++; + _notify(); + final previous = _queue; + final saving = () async { + await previous; + await initialize(); + try { + await _repository.save(locale?.languageCode); + _error = null; + } on FileSystemException catch (error) { + _error = error; + } finally { + _pendingSaves--; + _notify(); + } + }(); + _queue = saving; + await saving; + } + + Future flush() async { + await initialize(); + while (true) { + final pending = _queue; + await pending; + if (identical(pending, _queue)) return; + } + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb new file mode 100644 index 0000000..d400cc4 --- /dev/null +++ b/lib/l10n/app_zh.arb @@ -0,0 +1,704 @@ +{ + "@@locale": "zh", + "appTitle": "PM3 图形界面", + "navConnection": "连接", + "navTerminal": "终端", + "navDumpViewer": "Dump 查看", + "navDumpCompare": "Dump 对比", + "navHfSniff": "HF 嗅探/调谐", + "navLfGeneral": "LF 通用/EM/T55", + "navData": "数据处理", + "navScripts": "脚本", + "navSettings": "设置", + "navCollapse": "收起菜单", + "navHighFrequency": "高频 HF", + "navLowFrequency": "低频 LF", + "navTools": "工具", + "connected": "已连接", + "disconnected": "未连接", + "pm3NotConnected": "未连接 PM3", + "pm3Busy": "PM3 命令仍在执行,请等待完成或断开连接", + "hexDigits": "{count} 位 hex", + "maxCharacters": "最大 {count} 字符", + "executingCommand": "执行: {command}", + "executeToViewResults": "执行命令查看结果", + "copiedToClipboard": "已复制到剪贴板", + "copy": "复制", + "clear": "清空", + "language": "语言", + "languageSystem": "跟随系统", + "languageHelp": "更改立即生效。系统语言不受支持时使用英语。", + "languagePreferenceError": "无法读取或保存语言设置:{error}", + "settingsPm3Configuration": "PM3 配置", + "settingsPm3Path": "PM3 程序路径", + "settingsWindowsPathHint": "官方发行包根目录\\pm3.bat", + "settingsWindowsPathHelp": "Windows 推荐选择发行包根目录的 pm3.bat", + "settingsPickPm3": "选择 PM3 程序", + "settingsArguments": "PM3 启动参数", + "settingsWindowsArgumentsHelp": "选择 pm3.bat 时由脚本自行初始化并自动检测设备,此处参数不会传给脚本。每行一个参数;空格、引号和反斜杠按原样保留", + "settingsArgumentsHelp": "每行一个参数;空格、引号和反斜杠按原样保留", + "settingsWorkingDirectory": "PM3 工作目录(可选)", + "settingsSaving": "正在保存设置…", + "settingsAbout": "关于 PM3 GUI", + "settingsDescription": "基于 Flutter 的 Proxmark3 图形界面", + "settingsPlatform": "平台", + "settingsArchitecture": "架构", + "settingsArchitectureDescription": "CLI Wrapper (兼容未来更新)", + "settingsSupportedFormats": "支持格式", + "settingsMaintenance": "维护", + "terminalClear": "清除终端", + "hardwareVersion": "硬件版本", + "antennaTune": "天线调谐", + "terminalHfSearch": "高频搜索", + "terminalLfSearch": "低频搜索", + "terminal14aInfo": "14A 信息", + "terminalLfRead": "低频读取", + "terminalAutopwn": "自动破解", + "terminalHint": "提示:{hint}", + "terminalInputHint": "输入命令...(Tab/Shift+Tab 循环补全)", + "terminalCatalog": "命令库", + "terminalSend": "发送", + "terminalSearchHint": "搜索命令(如 hf mf, autopwn, info)", + "terminalCatalogError": "无法加载命令库:{error}", + "messageFailedToTerminateTheProcessTreeExit": "整树终止失败 (exit={value}){value2}", + "messageThePm3ProcessManagerHasBeenDisposed": "PM3 进程管理器已释放", + "messagePleaseWaitSecondsBeforeRetrying": "请等待 {value} 秒后再试", + "messageCoolingDownS": "[请等待冷却: {value}s]", + "messagePm3ProgramNotFoundOnWindowsSelect": "找不到 PM3 程序: {value}\nWindows 请优先选择官方发行包根目录的 pm3.bat;Linux 请选择 proxmark3", + "messageError": "[错误] {value}", + "messageUsingPm3": "[使用 PM3: {value}]", + "messageWorkingDirectory": "[工作目录: {value}]", + "messageFatalErrorOnPm3Stdout": "PM3 stdout 出现致命错误", + "messageFailedToSendTheReadOnlyPm3": "PM3 BAT 只读握手写入失败: {value}", + "messagePm3StdoutStreamError": "PM3 stdout 流错误: {value}", + "messageFatalErrorOnPm3Stderr": "PM3 stderr 出现致命错误", + "messagePm3StderrStreamError": "PM3 stderr 流错误: {value}", + "messagePm3ProcessExitedCode": "[PM3 进程退出, code={value}]", + "messagePm3ProcessExitedBeforeTheConnectionPrompt": "PM3 进程在连接提示前退出 (code={value})", + "messagePm3ProcessExitStatusError": "PM3 进程退出状态错误: {value}", + "messageConnectionTimedOutSCheckThatThe": "连接超时 ({value}s),请检查设备是否已连接", + "messageConnectionTimedOut": "[连接超时]", + "messageCouldNotStartPm3PathCheckThe": "无法启动 PM3 程序: {value}\n路径: {value2}\n请检查路径是否正确,程序是否已编译", + "messageConnectionFailed": "连接失败: {value}", + "messageNotConnected": "[未连接]", + "messageErrorCommandTimedOutTheSessionWas": "[错误] 命令超时,会话已停止", + "messageThePm3SessionDisconnectedBeforeTheCommand": "PM3 会话在命令完成前断开", + "messageFailedWhileWaitingForPm3ToStart": "等待 PM3 启动失败: {value}", + "messageFailedToSendQuit": "发送 quit 失败: {value}", + "messageCouldNotReadTheExitStatusAfter": "PM3 {value} 流关闭后读取退出状态失败: {value2}", + "messagePm3StreamClosed": "PM3 {value} 流已关闭", + "messageFailedWhileWaitingForPm3ToExit": "等待 PM3 退出失败: {value}", + "messageFailedToTerminatePm3TermProcessTree": "终止 PM3 进程失败: TERM/整树终止未成功", + "messageFailedToForceTerminatePm3KillProcess": "强制终止 PM3 进程失败: KILL/整树终止未成功", + "messageTimedOutWaitingForPm3ToExit": "终止 PM3 后等待退出超时", + "messageFailedToTerminatePm3ReturnedFalse": "终止 PM3 进程失败: {value} 返回 false", + "messageFailedToTerminatePm3": "终止 PM3 进程失败: {value}", + "messageTheSerialPortIsInUseBy": "串口 已被其他进程占用\n请关闭其他使用该端口的程序(如另一个 pm3 终端)", + "messageSerialDeviceNotFoundCheckThePm3": "串口设备不存在,请检查 PM3 是否已通过 USB 连接", + "messagePermissionDeniedForTheSerialPortTry": "串口权限不足\n请尝试: sudo chmod 666 /dev/ttyACM0\n或将用户加入 dialout 组", + "messageSerialConnectionError": "串口连接错误: {value}", + "messageEnterHexDigitsUseForUnknownDigits": "需要 {value} 位十六进制字符;未知位用 ?,清空表示未读取", + "messageBlockNumberExceedsTheCardCapacity": "块号超出卡容量", + "messageSectorNumberExceedsTheCardCapacity": "扇区号超出卡容量", + "messageThisIsNotACommandDataDraft": "不是命令数据草稿", + "messageUnsupportedCardCapacity": "不支持的卡容量", + "messageInvalidUidFormat": "UID 格式不正确", + "messageBlockIsIncompleteSaveADraftOr": "块 {value} 尚未完整,先保存草稿或补齐数据", + "messageSectorHasIncompleteKeysExportADictionary": "扇区 {value} 密钥不完整,可先导出字典或保存草稿", + "messageThereAreNoCompleteKeysToExport": "没有完整密钥可导出", + "messageEmptyUnallocated": "空/未分配", + "messageDefectManagement": "缺陷管理", + "messageReserved": "备用", + "messageAdditionalDirectoryEntries": "额外的目录条目", + "messageCardholderInformation": "卡持有者信息", + "messageUnallocated": "未分配", + "messageAirlineServices": "航空服务", + "messageUrbanTransport": "城市交通", + "messageUrbanTransport2": "城市交通 (2)", + "messageRailTransport": "铁路交通", + "messageTransportGeneral": "运输 (通用)", + "messageSecurityServices": "安防方案", + "messageUrbanTransport3": "城市交通 (3)", + "messageEventTicketing": "事件票务", + "messageLoyaltySchemes": "忠诚方案", + "messageParking": "停车场", + "messageAccessControl": "门禁控制", + "messageElectronicPurseMicropayments": "电子钱包 / 小额支付", + "messageElectronicTicketingGeneral": "电子票务 (通用)", + "messageMobileOffice": "移动办公", + "messageIdentification": "身份识别", + "messageMobilePayments": "手机支付", + "messageFelicaIntegration": "FeliCa集成", + "messageFuelServices": "加油", + "messageInformationProcessing": "信息处理", + "messageMifareClassic1kCompatible": "MIFARE Classic 1K (兼容)", + "messageMifareClassic1kCompatible2": "MIFARE Classic 1K (兼容2)", + "messageUnknownSak": "未知 (SAK={value})", + "messageUnknown0x": "未知 (0x{value})", + "messageApplication0x": "应用 0x{value}", + "messageNxpFactoryDefault": "NXP 出厂默认", + "messageAllZeros": "全零", + "messageInvalidKeyTextFormat": "密钥文本格式错误", + "messageDuplicateSector": "扇区 {value} 重复", + "messageKeyTextIsMissingSectors": "密钥文本缺少扇区", + "messageBinaryDumpBin": "二进制转储 (.bin)", + "messageTextDumpEml": "文本转储 (.eml)", + "messageBinaryKeysKeyBin": "二进制密钥 (.key.bin)", + "messageKeyDictionaryDic": "密钥字典 (.dic)", + "messageKeyListKeysTxt": "密钥列表 (.keys.txt)", + "messageFileNotFound": "文件不存在: {value}", + "messageAKeyFileCannotBeConvertedTo": "密钥文件无法转换为完整dump(缺少数据块)", + "messageAKeyFileCannotBeConvertedTo2": "密钥文件无法转换为EML(缺少数据块)", + "messageAKeyFileCannotBeConvertedTo3": "密钥文件无法转换为JSON(缺少数据块)", + "messageNoKeyData": "无密钥数据", + "messageConversionFailed": "转换失败: {value}", + "messageUniqueKeys": "{value} 个唯一密钥", + "messageUnsupportedKeyExportFormat": "不支持的密钥导出格式: {value}", + "messageSectorsUniqueKeyAUniqueKeyB": "{value} 扇区, {value2} 种 KeyA, {value3} 种 KeyB, 共 {value4} 种唯一密钥", + "messageConnectionLostOperationAborted": "连接断开,操作中止", + "messageWritingBlock": "正在写入 块 {value} ...", + "messageAuthenticationOrWritingFailed": "认证失败或写入失败", + "messageWriteSuccessful": "写入成功", + "messageFinishedSucceededFailed": "完成: {value} 成功, {value2} 失败", + "messageCancelled": "已取消", + "messageCouldNotSaveSettings": "设置保存失败: {value}", + "messageFileScanFailed": "文件扫描失败: {value}", + "messageInstalled": "已安装", + "messageOnWindowsUseTheOfficialPm3Bat": "Windows 推荐使用官方 pm3.bat", + "messageSerialPort": "串口选择", + "messageRefreshPorts": "刷新端口", + "messageNoSerialPortFoundConnectYourPm3": "未找到串口,请连接 PM3 设备", + "messageTheOfficialBatLauncherDetectsDevicesAutomatically": "官方 BAT 会自动检测设备;此端口值仅保留于设置中", + "messageConnecting": "连接中...", + "messageDisconnect": "断开连接", + "messageConnectPm3": "连接 PM3", + "messageDeviceInformation": "设备信息", + "messageRefreshHardwareInformation": "刷新硬件信息", + "messagePort": "端口", + "messageVersion": "版本", + "messageStatus": "状态", + "messageDeviceModel": "设备型号", + "messageFirmware": "固件", + "messageSmartcardModule": "智能卡模块", + "messageDeviceId": "设备 ID", + "messageRetrievingHardwareInformation": "正在获取硬件信息...", + "messageCommandHistory": "命令历史", + "messageCommands": "{value} 条", + "messageTerminalBuffer": "终端缓冲", + "messageLines": "{value} 行", + "messageHardwareStatus": "硬件状态", + "messageDebugLevel": "调试级别", + "messageEnvironment": "环境信息", + "messagePm3Path": "PM3 路径", + "messageValid": "✅ 有效", + "messageInvalid": "❌ 无效", + "messagePm3FileCollection": "PM3 文件收集", + "messageFilesCards": "{value} 个文件, {value2} 张卡", + "messageScan": "扫描", + "messageOrganizeFiles": "归类整理", + "messageNoPm3ExportFilesFound": "未发现 PM3 导出文件", + "messageFilesAreCollectedAutomaticallyAfterConnectingAnd": "连接设备并执行 dump/autopwn 后自动收集", + "messagePm3Connected": "PM3 已连接", + "messagePm3NotConnected": "PM3 未连接", + "messageSelectASerialPortAndConnectThe": "请选择串口并连接设备", + "messageConnectionFailed2": "连接失败", + "messageDumpsKeyFilesFiles": "{value} 份转储 • {value2} 份密钥 • {value3} 个文件", + "messageOpenInDumpViewer": "在 Dump 查看器中打开", + "messageOpenedInDumpViewer": "已切换到 Dump 查看器: {value}", + "messageOrganizePm3Files": "归类整理 PM3 文件", + "messageOrganizeCollectedPm3ExportFilesIntoSubdirectories": "将收集到的 PM3 导出文件按卡片 UID 分类整理到子目录中。\n\n目录结构:\n 📁 hf-mf/A991A280/\n ├── hf-mf-A991A280-dump.bin\n ├── hf-mf-A991A280-key.bin\n └── ...\n", + "messageDestinationDirectory": "目标目录", + "messageFilesToOrganize": "共 {value} 个文件将被整理", + "messageCancel": "取消", + "messageFilesOrganized": "已整理 {value} 个文件", + "messageStartOrganizing": "开始整理", + "messageFlashUsage": "Flash 用量", + "messageBasic": "基础", + "messageAnalysis": "分析", + "messageCompare": "比较", + "messagePlot": "绘图", + "messageDisplayTheDataGraph": "显示数据图表", + "messageClear": "清除", + "messageClearTheGraphBuffer": "清除绘图缓冲区", + "messageDetectClock": "检测时钟", + "messageAutomaticallyDetectTheClockRate": "自动检测时钟速率", + "messageSampling": "采样", + "messageSampleCount": "采样数", + "messageReadSamples": "读取采样", + "messageFileOperations": "文件操作", + "messageFilePath": "文件路径", + "messageSave": "保存", + "messageLoad": "加载", + "messageAsn1Decoding": "ASN.1 解码", + "messageHexData": "Hex 数据", + "messageDecode": "解码", + "messageFileComparison": "文件比较", + "messageFileA": "文件 A", + "messageFileB": "文件 B", + "messageOpenFileA": "打开文件 A", + "messageOpenFileB": "打开文件 B", + "messageDifferencesOnly": "仅差异", + "messageFileAError": "文件 A 错误: {value}", + "messageFileBError": "文件 B 错误: {value}", + "messageOpenTwoDumpFilesToCompareThem": "分别打开两个转储文件进行对比", + "messageSupportsEmlBinJsonDump": "支持 .eml / .bin / .json / .dump", + "messageTotalBlocks": "总块数", + "messageDataDifferences": "数据差异", + "messageBlocks": "{value} 块", + "messageKeyADifferences": "Key A 差异", + "messageKeyBDifferences": "Key B 差异", + "messageNone": "(无)", + "messageManufacturer": "制造商", + "messageTrailer": "尾块", + "messageCouldNotOpenFile": "打开失败: {value}", + "messageDictionaryFileUniqueKeys": "字典文件: {value} 个唯一密钥", + "messageDictionaryLoadedKeys": "加载字典: {value} 个密钥", + "messageMergedKeysForSectorsBlockDataUnchanged": "已合并 {value} 个扇区的密钥(块数据保持不变)", + "messageKeyHandling": "密钥处理", + "messageKeyDataIsAlreadyLoadedHowShould": "当前已有密钥数据。加载新的转储文件时,如何处理密钥?", + "messageDumpKeyFieldsAreOftenFfWhile": "转储文件的密钥区通常为 FF,而实际密钥可能来自单独的 key 文件", + "messageKeepCurrentKeys": "保留当前密钥", + "messageReplaceWithDumpKeys": "用转储覆盖", + "messageDefaultKeyFileSet": "已设为默认密钥文件: {value}", + "messageDefaultDumpFileSet": "已设为默认转储文件: {value}", + "messageSectorView": "扇区视图", + "messageKeysEdit": "密钥/编辑", + "messageDetailedAnalysis": "深度分析", + "messageWriteClear": "回写/清空", + "messageCommandData": "命令数据", + "messageOpenADumpFileToViewSector": "打开转储文件查看扇区数据", + "messageOpenADumpOrKeyFileTo": "打开转储文件或密钥文件以编辑", + "messageOpenADumpFileToViewDetailed": "打开转储文件后可查看深度分析", + "messageSupportsEmlBinJsonDumpDic": "支持 .eml / .bin / .json / .dump / .dic", + "messageOpenFile": "打开文件", + "messageSetAsDefaultKeys": "设为默认密钥", + "messageSetAsDefaultDump": "设为默认转储", + "messageExportAs": "导出为", + "messageEmlTextDump": ".eml (文本转储)", + "messageBinBinaryDump": ".bin (二进制转储)", + "messageJsonPm3Format": ".json (PM3 格式)", + "messageKeyBinBinaryKeys": ".key.bin (二进制密钥)", + "messageDicKeyDictionary": ".dic (密钥字典)", + "messageKeysTxtKeyList": ".keys.txt (密钥列表)", + "messageExportFailed": "导出失败: {value}", + "messageExportedAndOrganized": "已自动归类导出: {value}", + "messageExported": "已导出: {value}", + "messageNoCardData": "无卡片数据", + "messageSectorBlocks": "扩区 {value} (块 {value2}–{value3})", + "messageAccessConditions": "访问控制条件", + "messageManufacturerBlock": "制造商块", + "messageBlock": "块", + "messageDescription": "说明", + "messageData": "数据 {value}", + "messageKeyAWriteKeyAAccessBits": "KeyA: 写 KeyA|访控|KeyB", + "messageKeyAWriteKeyAAccessBits2": "KeyA: 写 KeyA|访控; KeyB: 读写", + "messageReadAccessBitsOnly": "仅可读访控", + "messageKeyAWriteAccessBitsKeyB": "KeyA: 写访控; KeyB: 读写", + "messageKeyBWriteAll": "KeyB: 写全部", + "messageLocked": "已锁定", + "messageUnknown": "未知", + "messageReadWriteWithKeyAOrB": "读写 Key A 或 B", + "messageReadWithABNoWriting": "可读 A/B,不可写", + "messageReadWriteWithKeyBOnly": "读写仅 Key B", + "messageReadABWriteBDecrementA": "读: A/B,写: B,减: A/B (值块)", + "messageReadWithKeyBOnly": "仅 Key B 可读", + "messageReadABWriteIncrementBDecrement": "读: A/B,写/加: B,减: A/B (值块)", + "messageNoData": "无数据", + "messageOverview": "📋 概览", + "messageCardType": "卡片类型", + "messageEmptyDataBlocks": "空白数据块", + "messageDataUsage": "数据使用率", + "messageValueBlocks": "值块数", + "messageManufacturerBlockBlock0": "🏭 制造商块 (Block 0)", + "messageChipType": "芯片类型", + "messageRawData": "原始数据", + "messageKeyAnalysis": "🔑 密钥分析", + "messageTotalSectors": "扇区总数", + "messageKnownKeyA": "已知 Key A", + "messageKnownKeyB": "已知 Key B", + "messageAllKeysIdentical": "所有密钥相同", + "messageYes": "是", + "messageNo": "否", + "messageContainsBlankKeys": "存在空白密钥", + "messageYes2": "是 ⚠️", + "messageKeyAGroups": "Key A 分组:", + "messageSectors": "{value} → 扇区 {value2}", + "messageKeyBGroups": "Key B 分组:", + "messageDefaultKeyMatches": "⚠️ 默认密钥匹配:", + "messageSectorKey": "扇区 {value} Key{value2}: {value3} ({value4})", + "messageMadApplicationDirectoryV": "📂 MAD 应用目录 (v{value})", + "messageSector": "扇区 {value}", + "messageValueBlocks2": "💰 值块", + "messageBlock2": "块 {value}", + "messageValueAddress": "值: {value} 地址: {value2}", + "messageSectorOverview": "📊 扇区概览", + "messageValidAccessBits": "✅ 访控有效", + "messageInvalidAccessBits": "❌ 访控无效", + "messageNoAccessBits": "- 无访控", + "messageDataBlockAsciiView": "📝 数据块 ASCII 视图", + "messageExportAllFormats": "📦 一键导出全部格式", + "messageExportTheCurrentDumpInAllSupported": "从当前打开的转储文件导出为所有支持的格式:", + "messageExportEml": "导出 .eml", + "messageExportBin": "导出 .bin", + "messageExportJson": "导出 .json", + "messageExportKeyBin": "导出 .key.bin", + "messageExportDic": "导出 .dic", + "messageExportKeysTxt": "导出 .keys.txt", + "messageKeyExtraction": "🔑 密钥提取", + "messageAllKeysExtractedFromTheCurrentDump": "从当前转储中提取的所有密钥:", + "messageCopyAllKeys": "复制全部密钥", + "messageKeyAOnly": "仅 Key A", + "messageKeyBOnly": "仅 Key B", + "messageFileFormatConversion": "🔄 文件格式转换", + "messageSelectADumpOrKeyFileTo": "选择任意dump/密钥文件, 转换为其他格式:", + "messageSelectSourceFile": "选择源文件", + "messageTargetFormat": "目标格式:", + "messageConverting": "转换中...", + "messageStartConversion": "开始转换", + "messageExternalKeyFile": "📋 外部密钥文件", + "messageLoadedExternalKeys": "已加载的外部密钥:", + "messageFormatReference": "ℹ️ 格式说明", + "messageRawBinaryDump16BytesPerBlock": "原始二进制转储 (16字节/块), PM3默认dump格式", + "messageTextDump32HexDigitsPerLine": "文本转储 (每行32位十六进制), 用于模拟器加载", + "messagePm3JsonFormatIncludingCardMetadataAnd": "PM3 JSON格式, 含卡片元数据和密钥", + "messageBinaryKeyFile6ByteKeyA": "二进制密钥文件 (6字节KeyA + 6字节KeyB) × 扇区数", + "messageKeyDictionaryOneKeyPerLineUsed": "密钥字典 (每行一个密钥), 用于暴力破解", + "messageReadableKeyTableSectorNumberKeyA": "可读密钥表 (扇区号 + KeyA + KeyB)", + "messageSector2": "扇区", + "messageSectorAB": "扇区 {value} A:{value2} B:{value3}", + "messageSaveConvertedFile": "保存转换文件", + "messageConversionSuccessfulOutput": "✅ 转换成功!\n输出: {value}\n{value2}", + "messageConversionError": "❌ 转换异常: {value}", + "messageEnterHexDigits": "需要 {value} 位十六进制字符", + "messageChangesNotApplied": "修改未应用:{value}", + "messageKeyEditor": "🔑 密钥编辑", + "messageApplyChanges": "应用修改", + "messageAllDefaults": "全部默认", + "messageBlockDataEditor": "📝 块数据编辑", + "messageData2": "数据", + "messageOpenADumpFileFirst": "请先打开转储文件", + "messageCuidCardWritingAndClearing": "CUID 卡回写 & 清空", + "messageCuidGen2CardsHaveNoBackdoorCommands": "CUID(Gen2)卡没有后门命令,必须使用已知密钥认证后逐块写入。\n• 整卡清空:使用卡片当前密钥认证,将数据块写为全 0\n• 整卡回写:使用目标卡密钥认证,将 Dump 数据逐块写入\n• 回写扇区:仅写入当前选中的单个扇区\n• Gen1A 后门恢复:使用 hf mf restore 后门命令整卡恢复(仅 Gen1A 卡)\n\n⚠️ CUID 卡写入块 0 会自动加 --force 参数", + "messageConnectYourPm3DeviceFirst": "请先连接 PM3 设备", + "messageWriteOptions": "⚙️ 写入选项", + "messageAuthenticationKeyType": "认证密钥类型:", + "messageSkipBlock0ManufacturerBlock": "跳过块 0 (制造商块)", + "messageBlock0CannotBeWrittenUnlessThis": "除非是魔术卡,否则块 0 无法写入", + "messageWriteTrailersKeysAccessBits": "写入尾块 (密钥+访问控制)", + "messageWriteTheDumpTrailerDataToThe": "将 Dump 中的尾块数据写入目标卡", + "messageDumpKeysForAuthentication": "🔑 Dump 密钥 (用于认证)", + "messageSmartWrite": "🚀 一键智能回写", + "messageSelectTheBestWritePathForThe": "按目标卡类型选择最佳回写路径(可用于 UID/CUID/FUID/Gen1A/Gen3/Gen4)。", + "messageTargetCardTypeMode": "目标卡类型/模式", + "messageAutoDefault": "AUTO (默认)", + "messageStandardUidCard": "UID 普通卡", + "messageFuidUidCanBeChangedOnce": "FUID(一次性 UID 可改)", + "messageGen1aBackdoorCard": "Gen1A 后门卡", + "messageGen3MagicCard": "Gen3 魔术卡", + "messageGen4PasswordOptional": "Gen4 密码(可选)", + "message8HexDigitsEG00000000": "8 hex,例如 00000000", + "messageSmartWrite2": "⚡ 一键智能回写", + "messageAuthenticateWithTheDumpKeysAndZero": "使用 Dump 中的密钥认证,将所有数据块写为全 0\n尾块恢复为默认密钥和访问控制位\n适用于 CUID / Gen2 卡(无后门)", + "messageClearEntireCuidCard": "CUID 整卡清空", + "messageAuthenticateWithTheTargetCardKeysAnd": "使用目标卡密钥认证,将 Dump 数据逐块写入\n适用于 CUID / Gen2 卡(无后门)\n需要已知目标卡的密钥才能写入", + "messageWriteEntireCuidCard": "CUID 整卡回写", + "messageWriteOnlySelectedSectorAuthenticateWithThe": "仅回写当前选中的扇区 {value}\n使用 Dump 密钥认证,逐块写入该扇区\n适合修改单个扇区后的局部更新", + "messageWriteSector": "回写扇区 {value}", + "messageRestoreTheEntireCardUsingThePm3": "使用 PM3 的 hf mf restore 命令整卡恢复\n仅适用于 Gen1A 后门卡,无需密钥\n通过 0x40/0x43 后门指令直接写入所有块", + "messageGen1aBackdoorRestore": "Gen1A 后门恢复", + "messageWriteProgress": "📊 写入进度", + "messageFormatConversionAndKeyExtraction": "📦 格式转换 & 密钥提取", + "messageEditedDataIsIncomplete": "编辑数据不完整", + "messageNoValidUidFoundCannotWriteUid": "未找到有效 UID,无法执行 UID 写入", + "messageClearCuidBlockByBlock": "CUID 逐块清空", + "messageAuthenticateTheTargetCardWithTheDump": "将使用 Dump 中的密钥对目标卡进行认证,\n然后将每个数据块写为全 0。\n\n尾块将被重置为默认值:\nKey A = FFFFFFFFFFFF\n访问控制 = FF078069\nKey B = FFFFFFFFFFFF\n\n⚠️ 此操作不可逆!", + "messageBlocksToClear": "将清空 {value} 块 ({value2})", + "messageSkipBlock0": "跳过块0", + "messageIncludeBlock0": "含块0", + "messageStartClearing": "开始清空", + "messageWriteCuidBlockByBlock": "CUID 逐块回写", + "messageAuthenticateTheTargetCardWithTheDump2": "将使用 Dump 中的密钥对目标卡进行认证,\n然后将 Dump 数据逐块写入目标卡。\n\n✅ 数据块: 写入 Dump 中的原始数据\n✅ 尾块: 写入 Dump 中的完整尾块数据\n (包含 Key A + 访问控制 + Key B)\n\n💡 使用场景: CUID/普通卡的密钥已知,\n 需要覆盖写入新数据。", + "messageBlocksToWrite": "将写入 {value} 块 ({value2}, {value3})", + "messageIncludeTrailers": "含尾块", + "messageSkipTrailers": "跳过尾块", + "messageStartWriting": "开始回写", + "messageAuthenticateWithTheDumpKeysThenWrite": "将使用 Dump 中的密钥认证,\n回写扇区 {value} 的所有块到目标卡。\n\n{value2} 认证密钥: Key {value3}", + "messageIncludeTrailers2": "包含尾块", + "messageExcludeTrailers": "不含尾块", + "messageInformation": "信息", + "messageReadWrite": "读写", + "messageReader": "读取器", + "messageReadA14443BCard": "读取 14443-B 卡片", + "messageTagInformation": "标签信息", + "messageReadTagDetails": "读取标签详情", + "messageSniff": "嗅探", + "messageCapture14443BCommunication": "捕获 14443-B 通信", + "messageDump": "转储", + "messageReadAllData": "读取全部数据", + "messageReadNdefData": "读取 NDEF 数据", + "messageBlockOperations": "块操作", + "messageBlockNumber": "块号", + "messageReadBlock": "读取块", + "messageWriteDataHex": "写入数据 (hex)", + "messageWriteBlock": "写入块", + "messageRawCommand": "裸命令", + "messageRawHexData": "原始 hex 数据", + "messageSimulateCard": "模拟卡片", + "messageSimulateA14443BTag": "模拟 14443-B 标签", + "messageAutomaticallyReadIso15693": "自动读取 ISO15693", + "messageReadBasicTagInformation": "读取标签基本信息", + "messageDumpCard": "转储卡片", + "messageEraseCard": "擦除卡片", + "messageClearCardData": "清空卡片数据", + "messageConfirmErase": "确认擦除", + "messageThisOperationCannotBeUndone": "此操作不可恢复!", + "messageWriteData4ByteHex": "写入数据 (4 字节 hex)", + "messageFindAfi": "查找 AFI", + "messageScanApplicationFamilyIdentifiers": "扫描应用族标识符", + "messageSimulateAnIso15693Tag": "模拟 ISO15693 标签", + "messageSniffCommunication": "嗅探通信", + "messageCaptureReaderCommunication": "捕获读卡器通信", + "messageSetUid": "设置 UID", + "messageUid8ByteHex": "UID (8 字节 hex)", + "messageConfirm": "确认", + "messageSearch": "搜索", + "messageSearchForEmvCards": "搜索 EMV 卡片", + "messageSelectPpseApplication": "选择 PPSE 应用", + "messageExecuteTransaction": "执行交易", + "messageSimulateTheEmvTransactionFlow": "模拟 EMV 交易流程", + "messageTest": "测试", + "messageEmvSelfTest": "EMV 自检", + "messageReadAFelicaCard": "读取 FeliCa 卡片", + "messageGetFelicaDetails": "获取 FeliCa 详情", + "messageDumpCardData": "转储卡片数据", + "messageLiteDump": "Lite 转储", + "messageDumpFelicaLite": "转储 FeliCa Lite", + "messageCaptureFelicaCommunication": "捕获 FeliCa 通信", + "messageScServiceCode": "SC (服务代码)", + "messageBlBlockNumber": "BL (块号)", + "messageReadFidoInformation": "读取 FIDO 信息", + "messageRegister": "注册", + "messageFido2Registration": "FIDO2 注册", + "messageAuthenticate": "认证", + "messageFido2Authentication": "FIDO2 认证", + "messageList": "列表", + "messageListFidoCommunication": "列出 FIDO 通信", + "messageKeyRecovery": "破解", + "messageEmulator": "模拟器", + "messageGetInformation": "获取信息", + "messageReadAnIclassTag": "读取 iCLASS 标签", + "messageReadCard": "读取卡片", + "messageReaderMode": "reader 模式", + "messageDumpAllBlocksToAFile": "转储全部块到文件", + "messageKey8ByteHex": "密钥 (8 字节 hex)", + "messageWriteData8ByteHex": "写入数据 (8 字节 hex)", + "messageCheckKeys": "检查密钥", + "messageCheckCommonDefaultKeys": "检查常用默认密钥", + "messageLoclassAttack": "Loclass 攻击", + "messageRecoverIclassKeysOffline": "离线破解 iCLASS 密钥", + "messageCaptureCardReaderCommunication": "捕获卡片与读卡器通信", + "messageEmulatorOperations": "模拟器操作", + "messageFilePathOptional": "文件路径(可选)", + "messageLeaveBlankToUseTheDefault": "留空使用默认", + "messageLoadIntoEmulator": "加载到模拟器", + "messageView": "查看", + "messageSimulate": "模拟", + "messageReadALegicTag": "读取 Legic 标签", + "messageDumpAllData": "转储全部数据", + "messageErase": "擦除", + "messageSimulateALegicTag": "模拟 Legic 标签", + "messageReadWriteOperations": "读写操作", + "messageOffset": "偏移", + "messageLength": "长度", + "messageRead": "读取", + "messageWrite": "写入", + "messageApply": "应用", + "desfireApplicationsTab": "应用", + "messageFiles": "文件", + "messageManagement": "管理", + "messageDetect": "检测", + "messageDetectADesfireCard": "检测 DESFire 卡片", + "messageReadBasicCardInformation": "读取卡片基本信息", + "messageGetUid": "获取 UID", + "messageGetCardUid": "获取卡片 UID", + "messageFreeMemory": "空闲内存", + "messageViewAvailableMemory": "查看可用内存", + "messageCheckDefaultKeys": "检查默认密钥", + "messageViewApplicationDirectory": "查看应用目录", + "messageListAids": "列出 AID", + "messageListApplications": "列出应用", + "messageSelectApplication": "选择应用", + "messageAid3ByteHex": "AID (3 字节 hex)", + "messageSelect": "选择", + "messageListFiles": "列出文件", + "messageFileDetails": "文件详情", + "messageDumpApplication": "转储应用", + "messageFileReadWrite": "文件读写", + "messageKeyNumber": "密钥号", + "messageAlgorithm": "算法", + "messageKeyHex": "密钥 (hex)", + "messageKeySettings": "密钥设置", + "messageGetKeySettings": "获取密钥设置信息", + "messageFormatPicc": "格式化 PICC", + "messageDeleteAllApplicationsAndData": "删除所有应用和数据", + "messageConfirmFormat": "确认格式化", + "messageThisWillDeleteAllApplicationsAndData": "此操作将删除所有应用和数据,不可恢复!", + "messageReadAnMfuNtagTag": "读取 MFU/NTAG 标签", + "messageReadAllDataToAFile": "读取全部数据到文件", + "messagePasswordAuthentication": "密码认证", + "messagePassword4ByteHex": "密码 (4 字节 hex)", + "messageReadNdef": "读取 NDEF", + "messageReadNfcDataExchangeFormat": "读取 NFC 数据交换格式", + "messageSaveEmulator": "保存模拟器", + "messageGenerateKeys": "生成密钥", + "messageKeygenDeriveKeysFromUid": "keygen — 根据 UID 推导密钥", + "messageDerivePassword": "密码推导", + "messagePwdgenDeriveTheNtagPassword": "pwdgen — 推导 NTAG 密码", + "messageKeyCheck": "密钥检查", + "messageCchkCheckCommonKeys": "cchk — 检查常用密钥", + "messageUid7ByteHex": "UID (7 字节 hex)", + "messageReadSeosInformation": "读取 SEOS 信息", + "messageReadPacsData": "读取 PACS 数据", + "messageSimulateASeosCard": "模拟 SEOS 卡片", + "messageHfSearch": "HF 搜索", + "messageSearchForHighFrequencyTags": "搜索高频标签", + "messageHfSniff": "HF 嗅探", + "messageCaptureHighFrequencyCommunication": "捕获高频通信", + "messageHfTuning": "HF 调谐", + "messageTuneTheHfAntenna": "调谐高频天线", + "messageProtocolList": "协议列表", + "messageProtocol": "协议", + "messageDecodeList": "列表解码", + "messageReadAnAwidCard": "读取 AWID 卡片", + "messageCloneSimulate": "克隆 / 模拟", + "messageFcFacilityCode": "FC (设施码)", + "messageCnCardNumber": "CN (卡号)", + "messageClone": "克隆", + "messageReadAnFdxBTag": "读取 FDX-B 标签", + "messageRawData2": "RAW 数据", + "messageCloneToT55xx": "克隆到 T55xx", + "messageCloneSimulate2": "克隆/模拟", + "messageReadHidCard": "读取 HID 卡", + "messageReadAHidProxCard": "读取 HID Prox 卡片", + "messageDemodulateSignal": "解调信号", + "messageDemodulateTheHidLfSignal": "解调 HID 低频信号", + "messageWiegandData": "Wiegand 数据", + "messageForExample2006ec0c86": "例如: 2006ec0c86", + "hidReaderBruteForceTab": "读卡器暴力枚举", + "hidReaderBruteForceTitle": "枚举设施码 / 卡号", + "messageFcOptional": "FC(可选)", + "messageLeaveBlankToTryAllValues": "留空则遍历所有", + "hidStartBruteForce": "开始暴力枚举", + "messageReadAHitagCard": "读取 Hitag 卡片", + "messageGetTagDetails": "获取标签详情", + "messageCaptureCommunicationData": "捕获通信数据", + "messageCheckCommonKeys": "检查常用密钥", + "messageKeyRecoveryAttack": "密钥恢复攻击", + "messageSimulateAHitagTag": "模拟 Hitag 标签", + "messageReadAnIndalaCard": "读取 Indala 卡片", + "messageReadAnIoproxCard": "读取 ioProx 卡片", + "messageReadAKeriCard": "读取 Keri 卡片", + "messageGeneral": "通用", + "messageAutomaticallyDetectLowFrequencyTags": "自动检测低频标签", + "messageReadTheRawLowFrequencySignal": "读取低频原始信号", + "messageLfSniff": "低频喗探", + "messageCaptureLowFrequencyCommunication": "捕获低频通信", + "messageCheckAntennaTuning": "检查天线调谐状态", + "messageReadEm410x": "读取 EM410x", + "messageReadTheEm4100TagId": "读取 EM4100 标签 ID", + "messageCloneEm410x": "克隆 EM410x", + "messageTagId10HexDigits": "标签 ID (10位 hex)", + "messageForExample0102030405": "例如 0102030405", + "messageIdMustContain10HexDigits": "ID 必须是 10 位 hex 字符", + "messageDetectT55xxChipConfiguration": "检测 T55xx 芯片配置", + "messageShowT55xxConfiguration": "显示 T55xx 配置信息", + "messageDumpAllT55xxBlocks": "转储所有 T55xx 块", + "messageReadAPyramidCard": "读取 Pyramid 卡片", + "messageCardType2": "卡片类型:", + "messageQuickActions": "快捷操作", + "messageKeyRecoveryAttacks": "密钥攻击", + "messageReadWriteBlocks": "读写块", + "messageMagicCards": "魔术卡", + "messageDumpRestoreParametersOptional": "Dump/Restore 参数(可选)", + "messageKeyFileKeysOptional": "密钥文件 --keys (可选)", + "messageLeaveBlankToUseThePm3Default": "留空则使用 PM3 默认", + "messageDumpFileFileOptional": "转储文件 --file (可选)", + "messageDumpOutputFileRestoreInputFile": "dump:输出文件 / restore:输入文件", + "messageSelectKeyFile": "选择密钥文件", + "messageSelectDumpFile": "选择转储文件", + "messageClearCustomSettings": "清空自定义", + "messageDefaultSourcesKeyDump": "默认来源:key={value}; dump={value2}", + "messageDetectCard": "检测卡片", + "messageScanHf14aTags": "扫描 HF 14A 标签", + "messageCardInformation": "卡片信息", + "messageGetDetailedCardInformation": "获取详细卡片信息", + "messageAutomaticKeyRecovery": "自动密钥恢复", + "messageReadAllSectorsToAFile": "读取所有扇区到文件", + "messageRestoreCard": "恢复卡片", + "messageWriteTheDumpBackToTheCard": "将转储写回卡片", + "messageSniff2": "喗探", + "messageCaptureCardCommunication": "捕获卡片通信", + "messageMoreCardRecoveryDiagnosticsSeeCommandReference": "更多卡型破解/诊断(参考命令文档)", + "messageCollectCuid": "采集 CUID", + "messagePm3ScriptNameScriptRunName": "PM3 脚本名 (script run )", + "messageForExampleMifareMfAutopwn": "例如: mifare/mf_autopwn", + "messageListScripts": "列出脚本", + "messageRunScript": "运行脚本", + "messageKnownKey": "已知密钥", + "messageTryDefaultKeysOnAllSectors": "用默认密钥尝试所有扇区", + "messageNestedAttack": "Nested 攻击", + "messageUseAKnownKeyToFindOther": "用已知密钥查找其他密钥", + "messageForCardsWithStaticNonces": "适用于静态 nonce 卡片", + "messageAdvancedKeyRecovery": "高级密钥恢复", + "messageNoKnownKeyRequired": "无需已知密钥", + "messageReadDataFromOneBlock": "读取单个块数据", + "messageWriteData": "写入数据", + "messageData32HexDigits": "数据 (32位 hex)", + "messageForExample00000000000000000000000000000000": "例如 00000000000000000000000000000000", + "messageMagicCardOperations": "魔术卡操作", + "messageTheseCommandsOnlyApplyToGen1aGen2": "这些命令仅适用于 Gen1A/Gen2 (CUID) 魔术卡。\n写入块 0 可能会损坏非魔术卡!", + "messageReadBlock0": "读取块 0", + "messageReadManufacturerBlock": "读取制造商块", + "messageRestoreFactoryDefaults": "恢复出厂状态", + "messageEraseCard2": "擦除卡片?", + "messageThisWillResetAllBlocksAreYou": "这将重置所有块。确定吗?", + "messageLoadEmulator": "加载模拟器", + "messageLoadADumpIntoTheEmulator": "将转储加载到模拟器", + "messageNdefDecoding": "NDEF 解码", + "messageListAvailableScripts": "列出可用脚本", + "messageScriptName": "脚本名称", + "messageForExampleHfMfAutopwn": "例如: hf_mf_autopwn", + "messageRun": "运行", + "messageHint": "提示:{value}", + "messageClearTerminal": "清屏", + "messageListDecode": "列表/解码", + "messageProtocolType": "协议类型", + "messageDecodeList2": "解码列表", + "messageExtract": "提取", + "messageExtractTraceData": "提取 Trace 数据", + "messageSpacesAreAllowedMeansUnknownClearThe": "允许空格;? 表示未知。清空移除该项。", + "messageSaved": "已保存:{value}", + "messageCommandResultsAreCollectedHereAutomaticallyClick": "命令结果自动采集到此处,点击密钥或块数据即可编辑。换卡前请选择“新卡”;? 表示未知。", + "messageUidBlocksKeys": "{value} · UID {value2} · {value3} 块 · {value4} 项密钥", + "messageNotIdentified": "未识别", + "messageNewCardSelectCapacity": "新卡 / 选择容量", + "messageNewCardClearTheCurrentDraft": "新卡 {value}(清空当前草稿)", + "messageNewCardCapacity": "新卡 / 容量", + "messageOpenDraft": "打开草稿", + "messageSaveDraft": "保存草稿", + "messageExportKeyDictionary": "导出密钥字典", + "messageExportKeyBin2": "导出密钥 BIN", + "messageReplaceDraftWithTheOpenFile": "以已打开文件替换草稿", + "messageExportEml2": "导出 EML", + "messageExportBin2": "导出 BIN", + "messageDirectCommand": "直接命令", + "messageForExampleHfMfChk1k": "例如 hf mf chk --1k", + "messageRunning": "执行中", + "messageExecute": "执行", + "messageKeysTrailers": "密钥 → 尾块", + "messageTrailersKeys": "尾块 → 密钥", + "messageNotRead": "未读取", + "messageSectorKey2": "扇区 {value} · Key {value2}", + "messageBlock3": "块 {value}{value2}", + "messageTrailer2": " · 尾块" +} diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart new file mode 100644 index 0000000..98cb646 --- /dev/null +++ b/lib/l10n/l10n.dart @@ -0,0 +1,8 @@ +import 'package:flutter/widgets.dart'; +import 'app_localizations.dart'; + +export 'app_localizations.dart'; + +extension L10nContext on BuildContext { + AppLocalizations get l10n => AppLocalizations.of(this)!; +} diff --git a/lib/l10n/localized_messages.dart b/lib/l10n/localized_messages.dart new file mode 100644 index 0000000..ed97f90 --- /dev/null +++ b/lib/l10n/localized_messages.dart @@ -0,0 +1,15 @@ +import 'package:flutter/widgets.dart'; +import 'app_localizations.dart'; + +export 'app_localizations.dart'; + +final AppLocalizations defaultLocalizations = + lookupAppLocalizations(const Locale('zh')); + +mixin LocalizedMessages { + AppLocalizations l10n = defaultLocalizations; + + void updateLocalizations(AppLocalizations value) { + l10n = value; + } +} diff --git a/lib/l10n/localized_pm3_process_messages.dart b/lib/l10n/localized_pm3_process_messages.dart new file mode 100644 index 0000000..3b1bceb --- /dev/null +++ b/lib/l10n/localized_pm3_process_messages.dart @@ -0,0 +1,116 @@ +import 'package:pm3gui/backend/desktop_cli/pm3_process_messages.dart'; +import 'app_localizations.dart'; + +final class LocalizedPm3ProcessMessages implements Pm3ProcessMessages { + const LocalizedPm3ProcessMessages(this._localizations); + + final AppLocalizations _localizations; + + @override + String messageConnectionFailed(String value) => + _localizations.messageConnectionFailed(value); + @override + String get messageConnectionTimedOut => + _localizations.messageConnectionTimedOut; + @override + String messageConnectionTimedOutSCheckThatThe(String value) => + _localizations.messageConnectionTimedOutSCheckThatThe(value); + @override + String messageCoolingDownS(String value) => + _localizations.messageCoolingDownS(value); + @override + String messageCouldNotReadTheExitStatusAfter(String value, String value2) => + _localizations.messageCouldNotReadTheExitStatusAfter(value, value2); + @override + String messageCouldNotStartPm3PathCheckThe(String value, String value2) => + _localizations.messageCouldNotStartPm3PathCheckThe(value, value2); + @override + String messageError(String value) => _localizations.messageError(value); + @override + String get messageErrorCommandTimedOutTheSessionWas => + _localizations.messageErrorCommandTimedOutTheSessionWas; + @override + String get messageFailedToForceTerminatePm3KillProcess => + _localizations.messageFailedToForceTerminatePm3KillProcess; + @override + String messageFailedToSendQuit(String value) => + _localizations.messageFailedToSendQuit(value); + @override + String messageFailedToSendTheReadOnlyPm3(String value) => + _localizations.messageFailedToSendTheReadOnlyPm3(value); + @override + String messageFailedToTerminatePm3(String value) => + _localizations.messageFailedToTerminatePm3(value); + @override + String messageFailedToTerminatePm3ReturnedFalse(String value) => + _localizations.messageFailedToTerminatePm3ReturnedFalse(value); + @override + String get messageFailedToTerminatePm3TermProcessTree => + _localizations.messageFailedToTerminatePm3TermProcessTree; + @override + String messageFailedWhileWaitingForPm3ToExit(String value) => + _localizations.messageFailedWhileWaitingForPm3ToExit(value); + @override + String messageFailedWhileWaitingForPm3ToStart(String value) => + _localizations.messageFailedWhileWaitingForPm3ToStart(value); + @override + String get messageFatalErrorOnPm3Stderr => + _localizations.messageFatalErrorOnPm3Stderr; + @override + String get messageFatalErrorOnPm3Stdout => + _localizations.messageFatalErrorOnPm3Stdout; + @override + String get messageNotConnected => _localizations.messageNotConnected; + @override + String get messagePermissionDeniedForTheSerialPortTry => + _localizations.messagePermissionDeniedForTheSerialPortTry; + @override + String messagePleaseWaitSecondsBeforeRetrying(String value) => + _localizations.messagePleaseWaitSecondsBeforeRetrying(value); + @override + String messagePm3ProcessExitStatusError(String value) => + _localizations.messagePm3ProcessExitStatusError(value); + @override + String messagePm3ProcessExitedBeforeTheConnectionPrompt(String value) => + _localizations.messagePm3ProcessExitedBeforeTheConnectionPrompt(value); + @override + String messagePm3ProcessExitedCode(String value) => + _localizations.messagePm3ProcessExitedCode(value); + @override + String messagePm3ProgramNotFoundOnWindowsSelect(String value) => + _localizations.messagePm3ProgramNotFoundOnWindowsSelect(value); + @override + String messagePm3StderrStreamError(String value) => + _localizations.messagePm3StderrStreamError(value); + @override + String messagePm3StdoutStreamError(String value) => + _localizations.messagePm3StdoutStreamError(value); + @override + String messagePm3StreamClosed(String value) => + _localizations.messagePm3StreamClosed(value); + @override + String messageSerialConnectionError(String value) => + _localizations.messageSerialConnectionError(value); + @override + String get messageSerialDeviceNotFoundCheckThePm3 => + _localizations.messageSerialDeviceNotFoundCheckThePm3; + @override + String get messageThePm3ProcessManagerHasBeenDisposed => + _localizations.messageThePm3ProcessManagerHasBeenDisposed; + @override + String get messageThePm3SessionDisconnectedBeforeTheCommand => + _localizations.messageThePm3SessionDisconnectedBeforeTheCommand; + @override + String get messageTheSerialPortIsInUseBy => + _localizations.messageTheSerialPortIsInUseBy; + @override + String get messageTimedOutWaitingForPm3ToExit => + _localizations.messageTimedOutWaitingForPm3ToExit; + @override + String messageUsingPm3(String value) => _localizations.messageUsingPm3(value); + @override + String messageWorkingDirectory(String value) => + _localizations.messageWorkingDirectory(value); + @override + String get pm3Busy => _localizations.pm3Busy; +} diff --git a/lib/main.dart b/lib/main.dart index a31dee0..5f747eb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,29 +11,35 @@ library; import 'dart:ui' show AppExitResponse; +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/ui/theme.dart'; import 'package:pm3gui/ui/home_page.dart'; +import 'package:pm3gui/l10n/l10n.dart'; +import 'package:pm3gui/l10n/app_locale_controller.dart'; /// 应用入口函数 /// /// 初始化应用并提供全局状态管理 -void main() { +Future main() async { WidgetsFlutterBinding.ensureInitialized(); + final localeController = AppLocaleController(); + await localeController.initialize(); final appState = AppState(); AppLifecycleListener( onExitRequested: () async { await appState.shutdown(); + await localeController.flush(); return AppExitResponse.exit; }, ); runApp( ChangeNotifierProvider( create: (_) => appState, - child: const PM3GuiApp(), + child: PM3GuiApp(localeController: localeController), ), ); } @@ -41,17 +47,51 @@ void main() { /// 应用根组件 /// /// 负责应用的主题管理和页面导航 -class PM3GuiApp extends StatelessWidget { - const PM3GuiApp({super.key}); +class PM3GuiApp extends StatefulWidget { + const PM3GuiApp({super.key, this.localeController}); + + final AppLocaleController? localeController; + + @override + State createState() => _PM3GuiAppState(); +} + +class _PM3GuiAppState extends State { + late final AppLocaleController _localeController; + + @override + void initState() { + super.initState(); + _localeController = widget.localeController ?? AppLocaleController(); + unawaited(_localeController.initialize()); + } + + @override + void dispose() { + if (widget.localeController == null) _localeController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - return MaterialApp( - title: 'PM3 图形界面', - debugShowCheckedModeBanner: false, - theme: AppTheme.theme(), - // 应用主页面 - home: const HomePage(), + return ChangeNotifierProvider.value( + value: _localeController, + child: AnimatedBuilder( + animation: _localeController, + builder: (context, _) => MaterialApp( + onGenerateTitle: (context) => context.l10n.appTitle, + locale: _localeController.locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + debugShowCheckedModeBanner: false, + theme: AppTheme.theme(), + builder: (context, child) { + context.read().updateLocalizations(context.l10n); + return child!; + }, + home: const HomePage(), + ), + ), ); } } diff --git a/lib/models/command_card.dart b/lib/models/command_card.dart index 058f7f1..5720cf0 100644 --- a/lib/models/command_card.dart +++ b/lib/models/command_card.dart @@ -4,10 +4,11 @@ import 'dart:typed_data'; import 'mifare_card.dart'; import '../parsers/output_parser.dart'; import '../parsers/key_parser.dart'; +import 'package:pm3gui/l10n/localized_messages.dart'; /// 命令采集的未完成卡片。缺项保持未知,不能当作全零块或默认密钥。 /// 文件转储仍由 MifareCard 表示;只有完整草稿才能导出为转储。 -class CommandCard { +class CommandCard with LocalizedMessages { CardType type = card1K; String uid = ''; final Map blocks = {}; @@ -144,14 +145,15 @@ class CommandCard { if (normalized.isNotEmpty && (normalized.length != length || !RegExp(r'^[0-9A-F?]+$').hasMatch(normalized))) { - throw FormatException('需要 $length 位十六进制字符;未知位用 ?,清空表示未读取'); + throw FormatException( + l10n.messageEnterHexDigitsUseForUnknownDigits((length).toString())); } return normalized; } void editBlock(int block, String value) { if (block < 0 || block >= type.blockCount) { - throw const FormatException('块号超出卡容量'); + throw FormatException(l10n.messageBlockNumberExceedsTheCardCapacity); } final data = normalize(value, 32); if (data.isEmpty) { @@ -163,7 +165,7 @@ class CommandCard { void editKey(int sector, bool a, String value) { if (sector < 0 || sector >= type.sectorCount) { - throw const FormatException('扇区号超出卡容量'); + throw FormatException(l10n.messageSectorNumberExceedsTheCardCapacity); } final key = normalize(value, 12); final target = a ? keysA : keysB; @@ -207,16 +209,20 @@ class CommandCard { void importDraft(String text) { final json = jsonDecode(text) as Map; if (json['format'] != 'pm3gui-command-card-v1') { - throw const FormatException('不是命令数据草稿'); + throw FormatException(l10n.messageThisIsNotACommandDataDraft); } final cardType = CardType.fromBlockCount(json['blocksCount'] as int); - if (cardType == null) throw const FormatException('不支持的卡容量'); - final parsed = CommandCard()..clear(cardType: cardType); + if (cardType == null) { + throw FormatException(l10n.messageUnsupportedCardCapacity); + } + final parsed = CommandCard() + ..updateLocalizations(l10n) + ..clear(cardType: cardType); parsed.uid = json['uid'] as String; if (parsed.uid.isNotEmpty && !RegExp(r'^(?:[0-9A-F]{8}|[0-9A-F]{14}|[0-9A-F]{20})$') .hasMatch(parsed.uid)) { - throw const FormatException('UID 格式不正确'); + throw FormatException(l10n.messageInvalidUidFormat); } for (final item in (json['blocks'] as Map).entries) { parsed.editBlock(int.parse(item.key), item.value as String); @@ -237,7 +243,8 @@ class CommandCard { String exportEml() { for (var b = 0; b < type.blockCount; b++) { if (!RegExp(r'^[0-9A-F]{32}$').hasMatch(blocks[b] ?? '')) { - throw FormatException('块 $b 尚未完整,先保存草稿或补齐数据'); + throw FormatException( + l10n.messageBlockIsIncompleteSaveADraftOr((b).toString())); } } return '${List.generate(type.blockCount, (b) => blocks[b]).join('\n')}\n'; @@ -250,7 +257,9 @@ class CommandCard { } void importCard(MifareCard card) { - final parsed = CommandCard()..clear(cardType: card.cardType); + final parsed = CommandCard() + ..updateLocalizations(l10n) + ..clear(cardType: card.cardType); for (var b = 0; b < card.blocks.length; b++) { parsed.editBlock(b, card.blocks[b]); } @@ -270,7 +279,8 @@ class CommandCard { for (var s = 0; s < type.sectorCount; s++) { if (!RegExp(r'^[0-9A-F]{12}$').hasMatch(keysA[s] ?? '') || !RegExp(r'^[0-9A-F]{12}$').hasMatch(keysB[s] ?? '')) { - throw FormatException('扇区 $s 密钥不完整,可先导出字典或保存草稿'); + throw FormatException(l10n + .messageSectorHasIncompleteKeysExportADictionary((s).toString())); } keys.add(SectorKey(keyA: keysA[s]!, keyB: keysB[s]!)); } @@ -280,7 +290,9 @@ class CommandCard { String exportDictionary() { final keys = {...keysA.values, ...keysB.values} .where((k) => RegExp(r'^[0-9A-F]{12}$').hasMatch(k)); - if (keys.isEmpty) throw const FormatException('没有完整密钥可导出'); + if (keys.isEmpty) { + throw FormatException(l10n.messageThereAreNoCompleteKeysToExport); + } return '${keys.join('\n')}\n'; } } diff --git a/lib/models/dump_analysis.dart b/lib/models/dump_analysis.dart index b808614..a33299d 100644 --- a/lib/models/dump_analysis.dart +++ b/lib/models/dump_analysis.dart @@ -7,6 +7,7 @@ library; import 'dart:typed_data'; import 'mifare_card.dart'; import 'access_bits.dart'; +import 'package:pm3gui/l10n/localized_messages.dart'; // ========================================================================== // 常量 @@ -36,56 +37,61 @@ const List knownDefaultKeys = [ ]; /// MAD AID 描述 (功能代码 → 说明) -const Map madAidDescriptions = { - 0x0000: '空/未分配', - 0x0001: '缺陷管理', - 0x0002: '备用', - 0x0003: '额外的目录条目', - 0x0004: '卡持有者信息', - 0x0005: '未分配', - 0x0008: '航空服务', - 0x0010: '城市交通', - 0x0011: '城市交通 (2)', - 0x0012: '铁路交通', - 0x0014: '运输 (通用)', - 0x0015: '安防方案', - 0x0018: '城市交通 (3)', - 0x0020: '事件票务', - 0x0028: '忠诚方案', - 0x0030: '停车场', - 0x0038: '门禁控制', - 0x0040: '电子钱包 / 小额支付', - 0x0047: '电子票务 (通用)', - 0x0048: '移动办公', - 0x0049: '身份识别', - 0x004A: '手机支付', - 0x004B: 'FeliCa集成', - 0x0060: '加油', - 0x0068: '信息处理', - 0x0070: 'NFC Forum NDEF', -}; +Map get madAidDescriptions => + madAidDescriptionsFor(defaultLocalizations); + +Map madAidDescriptionsFor(AppLocalizations l10n) => { + 0x0000: l10n.messageEmptyUnallocated, + 0x0001: l10n.messageDefectManagement, + 0x0002: l10n.messageReserved, + 0x0003: l10n.messageAdditionalDirectoryEntries, + 0x0004: l10n.messageCardholderInformation, + 0x0005: l10n.messageUnallocated, + 0x0008: l10n.messageAirlineServices, + 0x0010: l10n.messageUrbanTransport, + 0x0011: l10n.messageUrbanTransport2, + 0x0012: l10n.messageRailTransport, + 0x0014: l10n.messageTransportGeneral, + 0x0015: l10n.messageSecurityServices, + 0x0018: l10n.messageUrbanTransport3, + 0x0020: l10n.messageEventTicketing, + 0x0028: l10n.messageLoyaltySchemes, + 0x0030: l10n.messageParking, + 0x0038: l10n.messageAccessControl, + 0x0040: l10n.messageElectronicPurseMicropayments, + 0x0047: l10n.messageElectronicTicketingGeneral, + 0x0048: l10n.messageMobileOffice, + 0x0049: l10n.messageIdentification, + 0x004A: l10n.messageMobilePayments, + 0x004B: l10n.messageFelicaIntegration, + 0x0060: l10n.messageFuelServices, + 0x0068: l10n.messageInformationProcessing, + 0x0070: 'NFC Forum NDEF', + }; /// NXP 芯片类型 (根据SAK/ATQA) -const Map nxpChipTypes = { - '04_0044': 'MIFARE Ultralight', - '44_0044': 'MIFARE Ultralight C', - '04_0344': 'MIFARE Ultralight EV1 (640bit)', - '44_0344': 'MIFARE Ultralight EV1 (1312bit)', - '08_0004': 'MIFARE Classic 1K', - '18_0002': 'MIFARE Classic 4K', - '09_0004': 'MIFARE Mini', - '10_0004': 'MIFARE Plus 2K SL2', - '11_0004': 'MIFARE Plus 4K SL2', - '20_0344': 'MIFARE DESFire', - '20_0444': 'MIFARE DESFire EV1', - '60_0004': 'MIFARE Classic 1K (Infineon)', - '00_0004': 'MIFARE Classic 1K (兼容)', - '28_0004': 'MIFARE Classic 1K (Emulated)', - '38_0002': 'MIFARE Classic 4K (SmartMX)', - '88_0004': 'MIFARE Classic 1K (Infineon SLE 66R35)', - '08_0044': 'MIFARE Classic 1K (兼容2)', - '02_0004': 'MIFARE Classic 1K (NXP)', -}; +Map get nxpChipTypes => nxpChipTypesFor(defaultLocalizations); + +Map nxpChipTypesFor(AppLocalizations l10n) => { + '04_0044': 'MIFARE Ultralight', + '44_0044': 'MIFARE Ultralight C', + '04_0344': 'MIFARE Ultralight EV1 (640bit)', + '44_0344': 'MIFARE Ultralight EV1 (1312bit)', + '08_0004': 'MIFARE Classic 1K', + '18_0002': 'MIFARE Classic 4K', + '09_0004': 'MIFARE Mini', + '10_0004': 'MIFARE Plus 2K SL2', + '11_0004': 'MIFARE Plus 4K SL2', + '20_0344': 'MIFARE DESFire', + '20_0444': 'MIFARE DESFire EV1', + '60_0004': 'MIFARE Classic 1K (Infineon)', + '00_0004': l10n.messageMifareClassic1kCompatible, + '28_0004': 'MIFARE Classic 1K (Emulated)', + '38_0002': 'MIFARE Classic 4K (SmartMX)', + '88_0004': 'MIFARE Classic 1K (Infineon SLE 66R35)', + '08_0044': l10n.messageMifareClassic1kCompatible2, + '02_0004': 'MIFARE Classic 1K (NXP)', + }; // ========================================================================== // 分析结果类 @@ -270,10 +276,12 @@ class DumpAnalysis { // ========================================================================== class DumpAnalyzer { /// 执行完整 dump 分析 - static DumpAnalysis analyze(MifareCard card) { - final mfr = _decodeManufacturerBlock(card); - final mad = _parseMad(card); - final keys = _analyzeKeys(card); + static DumpAnalysis analyze(MifareCard card, + {AppLocalizations? localizations}) { + final l10n = localizations ?? defaultLocalizations; + final mfr = _decodeManufacturerBlock(card, l10n); + final mad = _parseMad(card, l10n); + final keys = _analyzeKeys(card, l10n); final sectors = []; final values = []; int emptyCount = 0; @@ -349,7 +357,8 @@ class DumpAnalyzer { } // --- 制造商块解码 --- - static ManufacturerBlockInfo? _decodeManufacturerBlock(MifareCard card) { + static ManufacturerBlockInfo? _decodeManufacturerBlock( + MifareCard card, AppLocalizations l10n) { if (card.blocks.isEmpty || card.blocks[0].length < 32) return null; final hex = card.blocks[0].toUpperCase(); final bytes = _hexToBytes(hex); @@ -383,10 +392,11 @@ class DumpAnalyzer { // 芯片类型 final chipLookup = '${sak}_${_reverseEndian16(atqa)}'; - final chipType = nxpChipTypes[chipLookup] ?? _guessChipType(sak); + final chipType = + nxpChipTypesFor(l10n)[chipLookup] ?? _guessChipType(sak, l10n); // 制造商 - final mfr = _getManufacturer(bytes[0]); + final mfr = _getManufacturer(bytes[0], l10n); return ManufacturerBlockInfo( uid: uid, @@ -405,7 +415,7 @@ class DumpAnalyzer { return '${hex4.substring(2, 4)}${hex4.substring(0, 2)}'; } - static String _guessChipType(String sak) { + static String _guessChipType(String sak, AppLocalizations l10n) { switch (sak.toUpperCase()) { case '08': return 'MIFARE Classic 1K'; @@ -428,11 +438,11 @@ class DumpAnalyzer { case '01': return 'TNP3xxx (NXP)'; default: - return '未知 (SAK=$sak)'; + return l10n.messageUnknownSak((sak).toString()); } } - static String _getManufacturer(int byte0) { + static String _getManufacturer(int byte0, AppLocalizations l10n) { switch (byte0) { case 0x01: return 'Motorola'; @@ -481,12 +491,13 @@ class DumpAnalyzer { case 0x97: return 'Qualcomm'; default: - return '未知 (0x${byte0.toRadixString(16).padLeft(2, '0')})'; + return l10n.messageUnknown0x( + (byte0.toRadixString(16).padLeft(2, '0')).toString()); } } // --- MAD 解析 --- - static MadInfo? _parseMad(MifareCard card) { + static MadInfo? _parseMad(MifareCard card, AppLocalizations l10n) { // MAD 位于扇区0的 block 1-2 (MADv1) 或 + 扇区16 block 0-2 (MADv2) if (card.blocks.length < 4) return null; final b1 = card.blocks[1]; @@ -523,8 +534,9 @@ class DumpAnalyzer { final aid = (hi << 8) | lo; if (aid != 0) hasNonZero = true; - final desc = madAidDescriptions[aid] ?? - '应用 0x${aid.toRadixString(16).padLeft(4, '0')}'; + final desc = madAidDescriptionsFor(l10n)[aid] ?? + l10n.messageApplication0x( + (aid.toRadixString(16).padLeft(4, '0')).toString()); entries.add(MadEntry(sector: i + 1, aid: aid, description: desc)); } @@ -549,7 +561,7 @@ class DumpAnalyzer { } // --- 密钥分析 --- - static KeyAnalysis _analyzeKeys(MifareCard card) { + static KeyAnalysis _analyzeKeys(MifareCard card, AppLocalizations l10n) { final keyAGroups = >{}; final keyBGroups = >{}; final defaults = []; @@ -564,7 +576,7 @@ class DumpAnalyzer { if (ka.isNotEmpty && ka != '000000000000') { foundA++; keyAGroups.putIfAbsent(ka, () => []).add(s); - final defName = _findDefaultKeyName(ka); + final defName = _findDefaultKeyName(ka, l10n); if (defName != null) { defaults.add(DefaultKeyMatch( keyHex: ka, keyType: 'A', sector: s, keyName: defName)); @@ -576,7 +588,7 @@ class DumpAnalyzer { if (kb.isNotEmpty && kb != '000000000000') { foundB++; keyBGroups.putIfAbsent(kb, () => []).add(s); - final defName = _findDefaultKeyName(kb); + final defName = _findDefaultKeyName(kb, l10n); if (defName != null) { defaults.add(DefaultKeyMatch( keyHex: kb, keyType: 'B', sector: s, keyName: defName)); @@ -600,11 +612,11 @@ class DumpAnalyzer { ); } - static String? _findDefaultKeyName(String hex) { + static String? _findDefaultKeyName(String hex, AppLocalizations l10n) { final upper = hex.toUpperCase(); - const names = { - 'FFFFFFFFFFFF': 'NXP 出厂默认', - '000000000000': '全零', + final names = { + 'FFFFFFFFFFFF': l10n.messageNxpFactoryDefault, + '000000000000': l10n.messageAllZeros, 'A0A1A2A3A4A5': 'MAD KeyA', 'B0B1B2B3B4B5': 'MAD KeyB', 'D3F7D3F7D3F7': 'NDEF', diff --git a/lib/parsers/dump_parser.dart b/lib/parsers/dump_parser.dart index 1ea2795..27bc955 100644 --- a/lib/parsers/dump_parser.dart +++ b/lib/parsers/dump_parser.dart @@ -12,6 +12,7 @@ import 'package:pm3gui/models/dump_analysis.dart'; import 'package:pm3gui/parsers/eml_parser.dart'; import 'package:pm3gui/parsers/bin_parser.dart'; import 'package:pm3gui/parsers/json_dump_parser.dart'; +import 'package:pm3gui/l10n/localized_messages.dart'; /// Result of parsing a dump file. class DumpResult { @@ -19,13 +20,20 @@ class DumpResult { final String format; // 'eml', 'bin', 'json' final String? error; DumpAnalysis? _analysis; + String? _analysisLocale; DumpResult({required this.card, required this.format, this.error}); bool get isSuccess => error == null; /// 获取或生成深度分析结果 (懒加载) - DumpAnalysis get analysis { - _analysis ??= DumpAnalyzer.analyze(card); + DumpAnalysis get analysis => analysisFor(defaultLocalizations); + + DumpAnalysis analysisFor(AppLocalizations localizations) { + if (_analysisLocale != localizations.localeName) { + _analysis = null; + _analysisLocale = localizations.localeName; + } + _analysis ??= DumpAnalyzer.analyze(card, localizations: localizations); return _analysis!; } } diff --git a/lib/parsers/key_parser.dart b/lib/parsers/key_parser.dart index 35e4d74..05fdf2e 100644 --- a/lib/parsers/key_parser.dart +++ b/lib/parsers/key_parser.dart @@ -14,6 +14,7 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:pm3gui/models/mifare_card.dart'; +import 'package:pm3gui/l10n/localized_messages.dart'; // --------------------------------------------------------------------------- // Binary key file (.bin) — sectorCount × 12 bytes @@ -130,23 +131,26 @@ String exportKeysAsText(List keys) { } /// Read the indexed text format emitted by exportKeysAsText, without inventing missing sectors. -List parseKeyText(String text) { +List parseKeyText(String text, {AppLocalizations? localizations}) { + final l10n = localizations ?? defaultLocalizations; final rows = {}; for (final raw in text.split('\n')) { final line = raw.trim(); if (line.isEmpty || line.startsWith('#')) continue; final match = RegExp(r'^(\d+)\s+([0-9a-fA-F]{12})\s+([0-9a-fA-F]{12})$') .firstMatch(line); - if (match == null) throw const FormatException('密钥文本格式错误'); + if (match == null) throw FormatException(l10n.messageInvalidKeyTextFormat); final sector = int.parse(match.group(1)!); - if (rows.containsKey(sector)) throw FormatException('扇区 $sector 重复'); + if (rows.containsKey(sector)) { + throw FormatException(l10n.messageDuplicateSector((sector).toString())); + } rows[sector] = SectorKey( keyA: match.group(2)!.toUpperCase(), keyB: match.group(3)!.toUpperCase()); } if (![5, 16, 32, 40].contains(rows.length) || List.generate(rows.length, (i) => i).any((i) => !rows.containsKey(i))) { - throw const FormatException('密钥文本缺少扇区'); + throw FormatException(l10n.messageKeyTextIsMissingSectors); } return List.generate(rows.length, (i) => rows[i]!); } diff --git a/lib/services/dump_converter.dart b/lib/services/dump_converter.dart index aaa81fe..9768813 100644 --- a/lib/services/dump_converter.dart +++ b/lib/services/dump_converter.dart @@ -16,21 +16,31 @@ import 'package:pm3gui/parsers/bin_parser.dart'; import 'package:pm3gui/parsers/eml_parser.dart'; import 'package:pm3gui/parsers/json_dump_parser.dart'; import 'package:pm3gui/parsers/key_parser.dart'; +import 'package:pm3gui/l10n/localized_messages.dart'; /// Supported file formats for conversion. enum DumpFormat { - bin('bin', '二进制转储 (.bin)', true), - eml('eml', '文本转储 (.eml)', true), - json('json', 'PM3 JSON (.json)', true), - keyBin('key.bin', '二进制密钥 (.key.bin)', false), - dic('dic', '密钥字典 (.dic)', false), - keyTxt('keys.txt', '密钥列表 (.keys.txt)', false); + bin('bin', true), + eml('eml', true), + json('json', true), + keyBin('key.bin', false), + dic('dic', false), + keyTxt('keys.txt', false); final String ext; - final String label; + String get label => localizedLabel(defaultLocalizations); final bool isFullDump; // true = contains block data, false = keys only - const DumpFormat(this.ext, this.label, this.isFullDump); + const DumpFormat(this.ext, this.isFullDump); + + String localizedLabel(AppLocalizations l10n) => switch (this) { + bin => l10n.messageBinaryDumpBin, + eml => l10n.messageTextDumpEml, + json => 'PM3 JSON (.json)', + keyBin => l10n.messageBinaryKeysKeyBin, + dic => l10n.messageKeyDictionaryDic, + keyTxt => l10n.messageKeyListKeysTxt, + }; } /// Result of a conversion operation. @@ -61,12 +71,16 @@ Future convertDumpFile({ required String inputPath, required String outputPath, required DumpFormat targetFormat, + AppLocalizations? localizations, }) async { + final l10n = localizations ?? defaultLocalizations; try { // ---- Parse input ---- final inputFile = File(inputPath); if (!await inputFile.exists()) { - return ConvertResult(success: false, error: '文件不存在: $inputPath'); + return ConvertResult( + success: false, + error: l10n.messageFileNotFound((inputPath).toString())); } final ext = inputPath.split('.').last.toLowerCase(); @@ -108,47 +122,50 @@ Future convertDumpFile({ switch (targetFormat) { case DumpFormat.bin: if (card == null) { - return ConvertResult(success: false, error: '密钥文件无法转换为完整dump(缺少数据块)'); + return ConvertResult( + success: false, error: l10n.messageAKeyFileCannotBeConvertedTo); } await outFile.writeAsBytes(exportToBin(card)); case DumpFormat.eml: if (card == null) { - return ConvertResult(success: false, error: '密钥文件无法转换为EML(缺少数据块)'); + return ConvertResult( + success: false, error: l10n.messageAKeyFileCannotBeConvertedTo2); } await outFile.writeAsString(exportToEml(card)); case DumpFormat.json: if (card == null) { - return ConvertResult(success: false, error: '密钥文件无法转换为JSON(缺少数据块)'); + return ConvertResult( + success: false, error: l10n.messageAKeyFileCannotBeConvertedTo3); } await outFile.writeAsString(exportToJson(card)); case DumpFormat.keyBin: final keys = card?.sectorKeys ?? keysOnly; if (keys == null || keys.isEmpty) { - return ConvertResult(success: false, error: '无密钥数据'); + return ConvertResult(success: false, error: l10n.messageNoKeyData); } await outFile.writeAsBytes(exportKeysToBin(keys)); case DumpFormat.dic: final keys = card?.sectorKeys ?? keysOnly; if (keys == null || keys.isEmpty) { - return ConvertResult(success: false, error: '无密钥数据'); + return ConvertResult(success: false, error: l10n.messageNoKeyData); } await outFile.writeAsString(exportToDic(keys)); case DumpFormat.keyTxt: final keys = card?.sectorKeys ?? keysOnly; if (keys == null || keys.isEmpty) { - return ConvertResult(success: false, error: '无密钥数据'); + return ConvertResult(success: false, error: l10n.messageNoKeyData); } await outFile.writeAsString(exportKeysAsText(keys)); } // Build key summary final keys = card?.sectorKeys ?? keysOnly; - final summary = keys != null ? _buildKeySummary(keys) : null; + final summary = keys != null ? _buildKeySummary(keys, l10n) : null; return ConvertResult( success: true, @@ -156,16 +173,21 @@ Future convertDumpFile({ keySummary: summary, ); } catch (e) { - return ConvertResult(success: false, error: '转换失败: $e'); + return ConvertResult( + success: false, error: l10n.messageConversionFailed((e).toString())); } } /// Extract keys from any supported dump file. /// /// Returns (sectorKeys, keySummaryText) or throws on error. -Future<(List, String)> extractKeysFromFile(String path) async { +Future<(List, String)> extractKeysFromFile(String path, + {AppLocalizations? localizations}) async { + final l10n = localizations ?? defaultLocalizations; final file = File(path); - if (!await file.exists()) throw Exception('文件不存在: $path'); + if (!await file.exists()) { + throw Exception(l10n.messageFileNotFound((path).toString())); + } final ext = path.split('.').last.toLowerCase(); @@ -175,7 +197,7 @@ Future<(List, String)> extractKeysFromFile(String path) async { final data = Uint8List.fromList(bytes); if (_isKeyBinSize(data.length)) { final keys = parseKeyBinBytes(data); - return (keys, _buildKeySummary(keys)); + return (keys, _buildKeySummary(keys, l10n)); } } @@ -184,19 +206,20 @@ Future<(List, String)> extractKeysFromFile(String path) async { final text = await file.readAsString(); final dictKeys = parseDicString(text); final keys = _dictKeysToSectorKeys(dictKeys); - return (keys, '${dictKeys.length} 个唯一密钥'); + return (keys, l10n.messageUniqueKeys((dictKeys.length).toString())); } // Full dump — parse and extract final result = await parseDumpFile(path); if (!result.isSuccess) throw Exception(result.error); final keys = result.card.sectorKeys; - return (keys, _buildKeySummary(keys)); + return (keys, _buildKeySummary(keys, l10n)); } /// Write keys to a file in the requested format. -Future saveKeysToFile( - List keys, String path, DumpFormat fmt) async { +Future saveKeysToFile(List keys, String path, DumpFormat fmt, + {AppLocalizations? localizations}) async { + final l10n = localizations ?? defaultLocalizations; switch (fmt) { case DumpFormat.keyBin: await File(path).writeAsBytes(exportKeysToBin(keys)); @@ -205,7 +228,8 @@ Future saveKeysToFile( case DumpFormat.keyTxt: await File(path).writeAsString(exportKeysAsText(keys)); default: - throw ArgumentError('不支持的密钥导出格式: ${fmt.label}'); + throw ArgumentError( + l10n.messageUnsupportedKeyExportFormat(fmt.localizedLabel(l10n))); } } @@ -249,12 +273,13 @@ List _dictKeysToSectorKeys(List dictKeys) { return List.generate(16, (_) => SectorKey(keyA: kA, keyB: kB)); } -String _buildKeySummary(List keys) { +String _buildKeySummary(List keys, AppLocalizations l10n) { final uniqueA = keys.map((k) => k.keyA.toUpperCase()).toSet(); final uniqueB = keys.map((k) => k.keyB.toUpperCase()).toSet(); final allKeys = {...uniqueA, ...uniqueB}; - return '${keys.length} 扇区, ' - '${uniqueA.length} 种 KeyA, ' - '${uniqueB.length} 种 KeyB, ' - '共 ${allKeys.length} 种唯一密钥'; + return l10n.messageSectorsUniqueKeyAUniqueKeyB( + (keys.length).toString(), + (uniqueA.length).toString(), + (uniqueB.length).toString(), + (allKeys.length).toString()); } diff --git a/lib/services/locale_settings_store.dart b/lib/services/locale_settings_store.dart new file mode 100644 index 0000000..9e92122 --- /dev/null +++ b/lib/services/locale_settings_store.dart @@ -0,0 +1,73 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart' + show MissingPluginException, PlatformException; +import 'package:path_provider/path_provider.dart'; +import 'pm3_settings_store.dart'; + +abstract interface class LocaleSettingsRepository { + Future load(); + Future save(String? languageCode); +} + +final class LocaleSettingsStore implements LocaleSettingsRepository { + LocaleSettingsStore({SettingsDirectoryProvider? directoryProvider}) + : _directoryProvider = + directoryProvider ?? getApplicationSupportDirectory; + + final SettingsDirectoryProvider _directoryProvider; + static var _sequence = 0; + + Future _file() async { + final Directory directory; + try { + directory = await _directoryProvider(); + } on MissingPlatformDirectoryException catch (error, stack) { + _throwDirectoryLookupError(error, stack); + } on PlatformException catch (error, stack) { + _throwDirectoryLookupError(error, stack); + } on MissingPluginException catch (error, stack) { + _throwDirectoryLookupError(error, stack); + } + return File('${directory.path}${Platform.pathSeparator}language.json'); + } + + static Never _throwDirectoryLookupError(Object error, StackTrace stack) => + Error.throwWithStackTrace( + FileSystemException( + 'Unable to resolve the application support directory: $error', + ), + stack, + ); + + @override + Future load() async { + final file = await _file(); + if (!await file.exists()) return null; + final json = jsonDecode(await file.readAsString()); + if (json is! Map || + !json.containsKey('language') || + (json['language'] != null && json['language'] is! String)) { + throw const FormatException('Invalid language preference file'); + } + return json['language'] as String?; + } + + @override + Future save(String? languageCode) async { + final file = await _file(); + await file.parent.create(recursive: true); + final temporary = File('${file.path}.$pid.${_sequence++}.tmp'); + try { + await temporary.create(exclusive: true); + await temporary.writeAsString( + jsonEncode({'language': languageCode}), + flush: true, + ); + await temporary.rename(file.path); + } finally { + if (await temporary.exists()) await temporary.delete(); + } + } +} diff --git a/lib/services/pm3_command_catalog.dart b/lib/services/pm3_command_catalog.dart index e99ae60..b494b06 100644 --- a/lib/services/pm3_command_catalog.dart +++ b/lib/services/pm3_command_catalog.dart @@ -71,13 +71,17 @@ class Pm3CommandCatalog { static const _zhAsset = 'docs/pm3_commands_help.zh.yaml'; static const _enAsset = 'docs/pm3_commands_help.yaml'; - static Future> load({bool preferZh = true}) async { - final text = await _loadYamlText(preferZh: preferZh); + static Future> load( + {bool preferZh = true, AssetBundle? bundle}) async { + final assets = bundle ?? rootBundle; + final text = await _loadYamlText(preferZh: preferZh, bundle: assets); final entries = _parseEntries(text); - final snapshot = jsonDecode(await rootBundle.loadString( - 'docs/pm3_commands_client.json')) as Map; + final snapshot = + jsonDecode(await assets.loadString('docs/pm3_commands_client.json')) + as Map; final known = entries.map((entry) => entry.command).toSet(); - for (final item in (snapshot['commands'] as List).cast>()) { + for (final item + in (snapshot['commands'] as List).cast>()) { final command = item['command'] as String; if (!known.add(command)) continue; entries.add(Pm3CommandEntry( @@ -90,12 +94,9 @@ class Pm3CommandCatalog { return entries; } - static Future _loadYamlText({required bool preferZh}) async { - try { - return await rootBundle.loadString(preferZh ? _zhAsset : _enAsset); - } catch (_) { - return rootBundle.loadString(preferZh ? _enAsset : _zhAsset); - } + static Future _loadYamlText( + {required bool preferZh, required AssetBundle bundle}) async { + return bundle.loadString(preferZh ? _zhAsset : _enAsset); } /// Lightweight line parser for our generated YAML structure. diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index cd3667f..7da83fe 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -9,6 +9,7 @@ import 'package:pm3gui/state/connection_state.dart'; import 'package:pm3gui/state/terminal_state.dart'; import 'package:pm3gui/state/file_state.dart'; import 'package:pm3gui/state/hardware_state.dart'; +import 'package:pm3gui/l10n/localized_messages.dart'; enum AppPage { connection, @@ -57,13 +58,15 @@ class NavigationIntent { }); } +enum WriteStatus { idle, writing, finished, cancelled, connectionLost } + class WriteProgress { final int total; int completed; int succeeded; int failed; - bool cancelled; - String currentStatus; + WriteStatus status; + int currentBlock; final List results; WriteProgress({ @@ -71,27 +74,42 @@ class WriteProgress { this.completed = 0, this.succeeded = 0, this.failed = 0, - this.cancelled = false, - this.currentStatus = '', + this.status = WriteStatus.idle, + this.currentBlock = 0, List? results, }) : results = results ?? []; double get progress => total > 0 ? completed / total : 0; + bool get cancelled => + status == WriteStatus.cancelled || status == WriteStatus.connectionLost; bool get isRunning => !cancelled && completed < total; + + String localizedStatus(AppLocalizations l10n) => switch (status) { + WriteStatus.idle => '', + WriteStatus.writing => + l10n.messageWritingBlock(currentBlock.toString()), + WriteStatus.finished => l10n.messageFinishedSucceededFailed( + succeeded.toString(), failed.toString()), + WriteStatus.cancelled => l10n.messageCancelled, + WriteStatus.connectionLost => + l10n.messageConnectionLostOperationAborted, + }; } class WriteBlockResult { final int block; final bool success; - final String message; const WriteBlockResult({ required this.block, required this.success, - required this.message, }); + + String localizedMessage(AppLocalizations l10n) => success + ? l10n.messageWriteSuccessful + : l10n.messageAuthenticationOrWritingFailed; } -class AppState extends ChangeNotifier { +class AppState extends ChangeNotifier with LocalizedMessages { late final ConnectionState connectionState; late final TerminalState terminalState; late final FileState fileState; @@ -116,6 +134,15 @@ class AppState extends ChangeNotifier { MifareCard currentCard = MifareCard(); final commandCard = CommandCard(); + @override + void updateLocalizations(AppLocalizations value) { + super.updateLocalizations(value); + connectionState.updateLocalizations(value); + fileState.updateLocalizations(value); + hardwareState.updateLocalizations(value); + commandCard.updateLocalizations(value); + } + WriteProgress? writeProgress; String? preferredMfKeyFile; @@ -288,7 +315,7 @@ class AppState extends ChangeNotifier { try { await connectionState.sendCommand(cmd); } catch (error) { - terminalState.addOutput('[错误] $error'); + terminalState.addOutput(l10n.messageError((error).toString())); } } @@ -303,13 +330,14 @@ class AppState extends ChangeNotifier { for (final (block, cmd) in commands) { if (progress.cancelled) break; if (!isConnected) { - progress.currentStatus = '连接断开,操作中止'; - progress.cancelled = true; + progress.status = WriteStatus.connectionLost; notifyListeners(); break; } - progress.currentStatus = '正在写入 块 $block ...'; + progress + ..status = WriteStatus.writing + ..currentBlock = block; notifyListeners(); await sendCommand(cmd); @@ -327,14 +355,12 @@ class AppState extends ChangeNotifier { progress.results.add(WriteBlockResult( block: block, success: false, - message: '认证失败或写入失败', )); } else { progress.succeeded++; progress.results.add(WriteBlockResult( block: block, success: true, - message: '写入成功', )); } progress.completed++; @@ -342,8 +368,7 @@ class AppState extends ChangeNotifier { } if (!progress.cancelled) { - progress.currentStatus = - '完成: ${progress.succeeded} 成功, ${progress.failed} 失败'; + progress.status = WriteStatus.finished; } notifyListeners(); return progress; @@ -351,8 +376,7 @@ class AppState extends ChangeNotifier { void cancelWriteSequence() { if (writeProgress != null && writeProgress!.isRunning) { - writeProgress!.cancelled = true; - writeProgress!.currentStatus = '已取消'; + writeProgress!.status = WriteStatus.cancelled; notifyListeners(); } } diff --git a/lib/state/connection_state.dart b/lib/state/connection_state.dart index 13d8053..7fe7eb8 100644 --- a/lib/state/connection_state.dart +++ b/lib/state/connection_state.dart @@ -9,8 +9,9 @@ import 'package:pm3gui/core/pm3/pm3_client_settings.dart'; import 'package:pm3gui/core/pm3/pm3_connection.dart'; import 'package:pm3gui/core/pm3/pm3_controller.dart'; import 'package:pm3gui/services/pm3_settings_store.dart'; +import 'package:pm3gui/l10n/localized_messages.dart'; -class ConnectionState extends ChangeNotifier { +class ConnectionState extends ChangeNotifier with LocalizedMessages { ConnectionState({ Pm3Controller? controller, Pm3SettingsRepository? settingsStore, @@ -20,6 +21,12 @@ class ConnectionState extends ChangeNotifier { } final Pm3Controller controller; + @override + void updateLocalizations(AppLocalizations value) { + super.updateLocalizations(value); + controller.updateLocalizations(value); + } + final Pm3SettingsRepository _settingsStore; Future? _initialization; Future _settingsQueue = Future.value(); @@ -29,7 +36,7 @@ class ConnectionState extends ChangeNotifier { bool _argumentsDirty = false; bool _workingDirectoryDirty = false; int _pendingSettingsSaves = 0; - String? _settingsError; + Object? _settingsError; String pm3Path = ''; String portName = ''; @@ -42,7 +49,9 @@ class ConnectionState extends ChangeNotifier { bool get isConnected => controller.isConnected; String get lastError => controller.lastError; bool get isSavingSettings => _pendingSettingsSaves > 0; - String? get settingsError => _settingsError; + String? get settingsError => _settingsError == null + ? null + : l10n.messageCouldNotSaveSettings(_settingsError.toString()); bool get usesWindowsBatchClient { final lowerPath = pm3Path.toLowerCase(); return Platform.isWindows && @@ -130,7 +139,7 @@ class ConnectionState extends ChangeNotifier { _settingsError = null; }, onError: (Object error, StackTrace _) { - _settingsError = '设置保存失败: $error'; + _settingsError = error; }, ).whenComplete(() { _pendingSettingsSaves--; diff --git a/lib/state/file_state.dart b/lib/state/file_state.dart index 0896cc2..478ca12 100644 --- a/lib/state/file_state.dart +++ b/lib/state/file_state.dart @@ -5,8 +5,9 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:pm3gui/services/file_collector.dart'; +import 'package:pm3gui/l10n/localized_messages.dart'; -class FileState extends ChangeNotifier { +class FileState extends ChangeNotifier with LocalizedMessages { List collectedFiles = []; List cardGroups = []; bool isScanning = false; @@ -42,7 +43,7 @@ class FileState extends ChangeNotifier { final errors = result['errors'] as List; scanError = errors.isEmpty ? null : errors.join('\n'); } catch (e) { - scanError = '文件扫描失败: $e'; + scanError = l10n.messageFileScanFailed((e).toString()); } isScanning = false; diff --git a/lib/state/hardware_state.dart b/lib/state/hardware_state.dart index c33fe2d..0dc2c50 100644 --- a/lib/state/hardware_state.dart +++ b/lib/state/hardware_state.dart @@ -2,14 +2,16 @@ library; import 'package:flutter/foundation.dart'; +import 'package:pm3gui/l10n/localized_messages.dart'; -class HardwareState extends ChangeNotifier { +class HardwareState extends ChangeNotifier with LocalizedMessages { String hwModel = ''; String hwFirmware = ''; String hwBootrom = ''; String hwMcu = ''; String hwFlashSize = ''; - String hwSmartcard = ''; + bool _hasSmartcard = false; + String get hwSmartcard => _hasSmartcard ? l10n.messageInstalled : ''; String hwFpga = ''; String hwUniqueId = ''; int hwFlashFree = 0; @@ -63,7 +65,7 @@ class HardwareState extends ChangeNotifier { hwBootrom = ''; hwMcu = ''; hwFlashSize = ''; - hwSmartcard = ''; + _hasSmartcard = false; hwFpga = ''; hwUniqueId = ''; hwFlashFree = 0; @@ -119,7 +121,7 @@ class HardwareState extends ChangeNotifier { // 检查智能卡模块 if (output.toLowerCase().contains('smartcard module (sim)')) { - hwSmartcard = '已安装'; + _hasSmartcard = true; } // 解析 Flash 内存使用情况 diff --git a/lib/ui/components/connection_guard.dart b/lib/ui/components/connection_guard.dart index 39c7d0a..e83c3c6 100644 --- a/lib/ui/components/connection_guard.dart +++ b/lib/ui/components/connection_guard.dart @@ -6,19 +6,20 @@ library; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; +import 'package:pm3gui/l10n/l10n.dart'; /// 只检查连接与忙状态;调用方先订阅输出,再发送一次命令。 bool ensurePm3Ready(BuildContext context) { final appState = context.read(); if (!appState.isConnected) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('未连接 PM3')), + SnackBar(content: Text(context.l10n.pm3NotConnected)), ); return false; } if (appState.connectionState.controller.isExecuting) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('PM3 命令仍在执行,请等待完成或断开连接')), + SnackBar(content: Text(context.l10n.pm3Busy)), ); return false; } diff --git a/lib/ui/components/hex_input_field.dart b/lib/ui/components/hex_input_field.dart index 9ca5982..c117ee1 100644 --- a/lib/ui/components/hex_input_field.dart +++ b/lib/ui/components/hex_input_field.dart @@ -6,6 +6,7 @@ library; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class HexInputField extends StatelessWidget { final String label; @@ -34,9 +35,12 @@ class HexInputField extends StatelessWidget { labelText: label, hintText: hint.isNotEmpty ? hint - : (byteLength != null ? '${byteLength! * 2} 位 hex' : 'hex'), + : (byteLength != null + ? context.l10n.hexDigits(byteLength! * 2) + : 'hex'), prefixIcon: prefixIcon != null ? Icon(prefixIcon, size: 18) : null, - counterText: maxChars != null ? '最大 $maxChars 字符' : null, + counterText: + maxChars != null ? context.l10n.maxCharacters(maxChars) : null, ), inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'[0-9a-fA-F]')), diff --git a/lib/ui/components/result_display.dart b/lib/ui/components/result_display.dart index 11ed99f..7b39d16 100644 --- a/lib/ui/components/result_display.dart +++ b/lib/ui/components/result_display.dart @@ -7,6 +7,7 @@ library; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class ResultDisplay extends StatelessWidget { final String command; @@ -50,7 +51,7 @@ class ResultDisplay extends StatelessWidget { const SizedBox(width: 8), Expanded( child: Text( - '执行: $command', + context.l10n.executingCommand(command), style: TextStyle( fontFamily: 'monospace', fontSize: 12, @@ -77,20 +78,20 @@ class ResultDisplay extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.all(12), child: SelectionArea( - child: SingleChildScrollView( - child: Text( - result.isEmpty ? '执行命令查看结果' : result, - style: TextStyle( - fontFamily: 'monospace', - fontSize: 12, - height: 1.5, - color: result.isEmpty - ? Colors.grey[600] - : theme.colorScheme.onSurface, - ), - ), - ), + child: SingleChildScrollView( + child: Text( + result.isEmpty ? context.l10n.executeToViewResults : result, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + height: 1.5, + color: result.isEmpty + ? Colors.grey[600] + : theme.colorScheme.onSurface, ), + ), + ), + ), ), ), @@ -106,17 +107,18 @@ class ResultDisplay extends StatelessWidget { () { Clipboard.setData(ClipboardData(text: result)); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('已复制到剪贴板')), + SnackBar( + content: Text(context.l10n.copiedToClipboard)), ); }, icon: const Icon(Icons.copy, size: 16), - label: const Text('复制'), + label: Text(context.l10n.copy), ), if (onClear != null) TextButton.icon( onPressed: onClear, icon: const Icon(Icons.clear, size: 16), - label: const Text('清空'), + label: Text(context.l10n.clear), ), ], ), diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index 453d880..aa98e7d 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:pm3gui/core/pm3/pm3_connection.dart'; import 'package:pm3gui/state/app_state.dart'; +import 'package:pm3gui/l10n/l10n.dart'; import 'package:pm3gui/ui/pages/connection_page.dart'; import 'package:pm3gui/ui/pages/terminal_page.dart'; import 'package:pm3gui/ui/pages/dump_viewer_page.dart'; @@ -86,47 +87,49 @@ class _HomePageState extends State { SettingsPage(), // 29 settings ]; - static const _generalItems = [ - _NavItem(Icons.usb, '连接', AppPage.connection), - _NavItem(Icons.terminal, '终端', AppPage.terminal), - _NavItem(Icons.file_open, 'Dump 查看', AppPage.dumpViewer), - _NavItem(Icons.compare_arrows, 'Dump 对比', AppPage.dumpCompare), - ]; + List<_NavItem> get _generalItems => [ + _NavItem(Icons.usb, context.l10n.navConnection, AppPage.connection), + _NavItem(Icons.terminal, context.l10n.navTerminal, AppPage.terminal), + _NavItem( + Icons.file_open, context.l10n.navDumpViewer, AppPage.dumpViewer), + _NavItem(Icons.compare_arrows, context.l10n.navDumpCompare, + AppPage.dumpCompare), + ]; - static const _hfItems = [ - _NavItem(Icons.nfc, 'Mifare Classic', AppPage.mifare), - _NavItem(Icons.nfc, 'Ultralight/NTAG', AppPage.mifareUltralight), - _NavItem(Icons.nfc, 'DESFire', AppPage.desfire), - _NavItem(Icons.nfc, 'iCLASS', AppPage.iclass), - _NavItem(Icons.nfc, 'ISO 15693', AppPage.iso15693), - _NavItem(Icons.nfc, 'ISO 14443-B', AppPage.iso14443b), - _NavItem(Icons.nfc, 'FeliCa', AppPage.felica), - _NavItem(Icons.nfc, 'Legic', AppPage.legic), - _NavItem(Icons.payment, 'EMV', AppPage.emv), - _NavItem(Icons.badge, 'SEOS', AppPage.seos), - _NavItem(Icons.fingerprint, 'FIDO', AppPage.fido), - _NavItem(Icons.hearing, 'HF 嗅探/调谐', AppPage.hfSniff), - ]; + List<_NavItem> get _hfItems => [ + _NavItem(Icons.nfc, 'Mifare Classic', AppPage.mifare), + _NavItem(Icons.nfc, 'Ultralight/NTAG', AppPage.mifareUltralight), + _NavItem(Icons.nfc, 'DESFire', AppPage.desfire), + _NavItem(Icons.nfc, 'iCLASS', AppPage.iclass), + _NavItem(Icons.nfc, 'ISO 15693', AppPage.iso15693), + _NavItem(Icons.nfc, 'ISO 14443-B', AppPage.iso14443b), + _NavItem(Icons.nfc, 'FeliCa', AppPage.felica), + _NavItem(Icons.nfc, 'Legic', AppPage.legic), + _NavItem(Icons.payment, 'EMV', AppPage.emv), + _NavItem(Icons.badge, 'SEOS', AppPage.seos), + _NavItem(Icons.fingerprint, 'FIDO', AppPage.fido), + _NavItem(Icons.hearing, context.l10n.navHfSniff, AppPage.hfSniff), + ]; - static const _lfItems = [ - _NavItem(Icons.radio, 'LF 通用/EM/T55', AppPage.lf), - _NavItem(Icons.credit_card, 'HID Prox', AppPage.lfHid), - _NavItem(Icons.security, 'Hitag', AppPage.lfHitag), - _NavItem(Icons.credit_card, 'AWID', AppPage.lfAwid), - _NavItem(Icons.credit_card, 'Indala', AppPage.lfIndala), - _NavItem(Icons.credit_card, 'ioProx', AppPage.lfIo), - _NavItem(Icons.credit_card, 'Pyramid', AppPage.lfPyramid), - _NavItem(Icons.credit_card, 'Keri', AppPage.lfKeri), - _NavItem(Icons.pets, 'FDX-B', AppPage.lfFdxb), - ]; + List<_NavItem> get _lfItems => [ + _NavItem(Icons.radio, context.l10n.navLfGeneral, AppPage.lf), + _NavItem(Icons.credit_card, 'HID Prox', AppPage.lfHid), + _NavItem(Icons.security, 'Hitag', AppPage.lfHitag), + _NavItem(Icons.credit_card, 'AWID', AppPage.lfAwid), + _NavItem(Icons.credit_card, 'Indala', AppPage.lfIndala), + _NavItem(Icons.credit_card, 'ioProx', AppPage.lfIo), + _NavItem(Icons.credit_card, 'Pyramid', AppPage.lfPyramid), + _NavItem(Icons.credit_card, 'Keri', AppPage.lfKeri), + _NavItem(Icons.pets, 'FDX-B', AppPage.lfFdxb), + ]; - static const _toolItems = [ - _NavItem(Icons.show_chart, '数据处理', AppPage.data), - _NavItem(Icons.timeline, 'Trace', AppPage.trace), - _NavItem(Icons.nfc, 'NFC', AppPage.nfc), - _NavItem(Icons.code, '脚本', AppPage.script), - _NavItem(Icons.settings, '设置', AppPage.settings), - ]; + List<_NavItem> get _toolItems => [ + _NavItem(Icons.show_chart, context.l10n.navData, AppPage.data), + _NavItem(Icons.timeline, 'Trace', AppPage.trace), + _NavItem(Icons.nfc, 'NFC', AppPage.nfc), + _NavItem(Icons.code, context.l10n.navScripts, AppPage.script), + _NavItem(Icons.settings, context.l10n.navSettings, AppPage.settings), + ]; @override Widget build(BuildContext context) { @@ -211,7 +214,9 @@ class _HomePageState extends State { if (_sidebarExpanded) ...[ const SizedBox(width: 8), Text( - isConnected ? '已连接' : '未连接', + isConnected + ? context.l10n.connected + : context.l10n.disconnected, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -251,8 +256,11 @@ class _HomePageState extends State { ), if (_sidebarExpanded) ...[ const SizedBox(width: 12), - const Text('收起菜单', - style: TextStyle(fontSize: 13)), + Flexible( + child: Text(context.l10n.navCollapse, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13)), + ), ], ], ), @@ -284,9 +292,12 @@ class _HomePageState extends State { children: [ ..._generalItems .map((n) => _navTile(n, appState, theme, currentPageIndex)), - _groupTile('📡 高频 HF', _hfItems, appState, theme, currentPageIndex), - _groupTile('📻 低频 LF', _lfItems, appState, theme, currentPageIndex), - _groupTile('🛠 工具', _toolItems, appState, theme, currentPageIndex), + _groupTile(context.l10n.navHighFrequency, _hfItems, appState, theme, + currentPageIndex), + _groupTile(context.l10n.navLowFrequency, _lfItems, appState, theme, + currentPageIndex), + _groupTile(context.l10n.navTools, _toolItems, appState, theme, + currentPageIndex), ], ); } diff --git a/lib/ui/pages/connection_page.dart b/lib/ui/pages/connection_page.dart index cb429e6..c1bd7da 100644 --- a/lib/ui/pages/connection_page.dart +++ b/lib/ui/pages/connection_page.dart @@ -10,6 +10,7 @@ import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/file_collector.dart'; import 'package:pm3gui/services/file_dialog_service.dart'; import 'package:pm3gui/ui/theme.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class ConnectionPage extends StatefulWidget { const ConnectionPage({super.key}); @@ -139,17 +140,18 @@ class _ConnectionPageState extends State { // ── PM3 路径 ── _buildSection( icon: Icons.folder_open, - title: 'PM3 程序路径', + title: context.l10n.settingsPm3Path, child: TextFormField( controller: _pm3PathController, style: const TextStyle(fontFamily: 'monospace', fontSize: 13), decoration: InputDecoration( hintText: Platform.isWindows - ? r'官方发行包根目录\pm3.bat' + ? context.l10n.settingsWindowsPathHint : '/usr/bin/proxmark3', - helperText: - Platform.isWindows ? 'Windows 推荐使用官方 pm3.bat' : null, + helperText: Platform.isWindows + ? context.l10n.messageOnWindowsUseTheOfficialPm3Bat + : null, prefixIcon: const Icon(Icons.terminal, size: 18), suffixIcon: File(pm3Path).existsSync() ? Row( @@ -177,7 +179,7 @@ class _ConnectionPageState extends State { // ── 串口选择 ── _buildSection( icon: Icons.usb, - title: '串口选择', + title: context.l10n.messageSerialPort, trailing: IconButton( icon: _scanning ? const SizedBox( @@ -186,7 +188,7 @@ class _ConnectionPageState extends State { child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.refresh, size: 18), onPressed: _scanning ? null : _scanPorts, - tooltip: '刷新端口', + tooltip: context.l10n.messageRefreshPorts, visualDensity: VisualDensity.compact, ), child: availablePorts.isEmpty @@ -201,8 +203,10 @@ class _ConnectionPageState extends State { Icon(Icons.info_outline, size: 16, color: const Color(0xFFF87171)), const SizedBox(width: 8), - const Expanded( - child: Text('未找到串口,请连接 PM3 设备', + Expanded( + child: Text( + context.l10n + .messageNoSerialPortFoundConnectYourPm3, style: TextStyle(fontSize: 13)), ), ]), @@ -225,7 +229,8 @@ class _ConnectionPageState extends State { helperText: Platform.isWindows && (pm3Path.toLowerCase().endsWith('.bat') || pm3Path.toLowerCase().endsWith('.cmd')) - ? '官方 BAT 会自动检测设备;此端口值仅保留于设置中' + ? context.l10n + .messageTheOfficialBatLauncherDetectsDevicesAutomatically : null, ), ), @@ -256,10 +261,10 @@ class _ConnectionPageState extends State { size: 18), label: Text( isConnecting - ? '连接中...' + ? context.l10n.messageConnecting : isConnected - ? '断开连接' - : '连接 PM3', + ? context.l10n.messageDisconnect + : context.l10n.messageConnectPm3, style: const TextStyle(fontSize: 14), ), style: ElevatedButton.styleFrom( @@ -278,33 +283,38 @@ class _ConnectionPageState extends State { const SizedBox(height: 12), _buildSection( icon: Icons.developer_board, - title: '设备信息', + title: context.l10n.messageDeviceInformation, trailing: IconButton( icon: const Icon(Icons.refresh, size: 16), - tooltip: '刷新硬件信息', + tooltip: context.l10n.messageRefreshHardwareInformation, onPressed: () => appState.refreshHwInfo(), visualDensity: VisualDensity.compact, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _infoRow('端口', portName), - _infoRow('版本', pm3Version), - _infoRow('状态', '已连接', valueColor: AppTheme.accentBlue), + _infoRow(context.l10n.messagePort, portName), + _infoRow(context.l10n.messageVersion, pm3Version), + _infoRow( + context.l10n.messageStatus, context.l10n.connected, + valueColor: AppTheme.accentBlue), if (hwInfoParsed) ...[ const Divider(height: 16), - if (hwModel.isNotEmpty) _infoRow('设备型号', hwModel), + if (hwModel.isNotEmpty) + _infoRow(context.l10n.messageDeviceModel, hwModel), if (hwMcu.isNotEmpty) _infoRow('MCU', hwMcu), if (hwFlashSize.isNotEmpty) _infoRow('Flash', hwFlashSize), - if (hwFirmware.isNotEmpty) _infoRow('固件', hwFirmware), + if (hwFirmware.isNotEmpty) + _infoRow(context.l10n.messageFirmware, hwFirmware), if (hwBootrom.isNotEmpty) _infoRow('Bootrom', hwBootrom), if (hwFpga.isNotEmpty) _infoRow('FPGA', hwFpga), if (hwSmartcard.isNotEmpty) - _infoRow('智能卡模块', hwSmartcard), + _infoRow(context.l10n.messageSmartcardModule, + hwSmartcard), if (hwUniqueId.isNotEmpty) - _infoRow('设备 ID', hwUniqueId), + _infoRow(context.l10n.messageDeviceId, hwUniqueId), if (hwFlashTotal > 0) _buildFlashUsage(appState), ] else ...[ const Divider(height: 16), @@ -315,14 +325,20 @@ class _ConnectionPageState extends State { child: CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(width: 8), - Text('正在获取硬件信息...', + Text( + context + .l10n.messageRetrievingHardwareInformation, style: TextStyle( fontSize: 12, color: Colors.grey[500])), ]), ], const Divider(height: 16), - _infoRow('命令历史', '$commandHistoryLength 条'), - _infoRow('终端缓冲', '$terminalOutputLength 行'), + _infoRow(context.l10n.messageCommandHistory, + context.l10n.messageCommands(commandHistoryLength)), + _infoRow( + context.l10n.messageTerminalBuffer, + context.l10n.messageLines( + (terminalOutputLength).toString())), const SizedBox(height: 8), Row(children: [ Expanded( @@ -330,7 +346,7 @@ class _ConnectionPageState extends State { onPressed: () => appState.sendCommand('hw version'), icon: const Icon(Icons.info, size: 14), - label: const Text('硬件版本', + label: Text(context.l10n.hardwareVersion, style: TextStyle(fontSize: 12)), ), ), @@ -339,7 +355,7 @@ class _ConnectionPageState extends State { child: OutlinedButton.icon( onPressed: () => appState.sendCommand('hw tune'), icon: const Icon(Icons.wifi_tethering, size: 14), - label: const Text('天线调谐', + label: Text(context.l10n.antennaTune, style: TextStyle(fontSize: 12)), ), ), @@ -351,7 +367,7 @@ class _ConnectionPageState extends State { onPressed: () => appState.sendCommand('hw status'), icon: const Icon(Icons.monitor_heart, size: 14), - label: const Text('硬件状态', + label: Text(context.l10n.messageHardwareStatus, style: TextStyle(fontSize: 12)), ), ), @@ -361,7 +377,7 @@ class _ConnectionPageState extends State { onPressed: () => appState.sendCommand('hw dbg -4'), icon: const Icon(Icons.bug_report, size: 14), - label: const Text('调试级别', + label: Text(context.l10n.messageDebugLevel, style: TextStyle(fontSize: 12)), ), ), @@ -375,15 +391,19 @@ class _ConnectionPageState extends State { const SizedBox(height: 16), _buildSection( icon: Icons.computer, - title: '环境信息', + title: context.l10n.messageEnvironment, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _infoRow('平台', Platform.operatingSystem), - _infoRow('架构', _getArch()), + _infoRow(context.l10n.settingsPlatform, + Platform.operatingSystem), + _infoRow(context.l10n.settingsArchitecture, _getArch()), _infoRow('Dart', Platform.version.split(' ').first), - _infoRow('PM3 路径', - File(pm3Path).existsSync() ? '✅ 有效' : '❌ 无效'), + _infoRow( + context.l10n.messagePm3Path, + File(pm3Path).existsSync() + ? context.l10n.messageValid + : context.l10n.messageInvalid), ], ), ), @@ -401,46 +421,54 @@ class _ConnectionPageState extends State { Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row(children: [ - Icon(Icons.folder_special, - size: 20, color: AppTheme.accentBlue), - const SizedBox(width: 8), - const Text('PM3 文件收集', - style: - TextStyle(fontWeight: FontWeight.bold, fontSize: 15)), - if (appState.fileState.scanError != null) - Tooltip( - message: appState.fileState.scanError!, - child: const Icon(Icons.error_outline, - color: Colors.orange)), - const SizedBox(width: 8), - Text( - '$collectedFilesCount 个文件, ' - '$cardGroupsCount 张卡', - style: TextStyle(fontSize: 12, color: Colors.grey[500]), - ), - const Spacer(), - if (isFileScanning) - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2)), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: - isFileScanning ? null : () => appState.scanForFiles(), - icon: const Icon(Icons.refresh, size: 16), - label: const Text('扫描', style: TextStyle(fontSize: 12)), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: !hasCollectedFiles - ? null - : () => _showOrganizeDialog(appState), - icon: const Icon(Icons.create_new_folder, size: 16), - label: const Text('归类整理', style: TextStyle(fontSize: 12)), - ), - ]), + child: Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Icon(Icons.folder_special, + size: 20, color: AppTheme.accentBlue), + const SizedBox(width: 8), + Text(context.l10n.messagePm3FileCollection, + style: TextStyle( + fontWeight: FontWeight.bold, fontSize: 15)), + if (appState.fileState.scanError != null) + Tooltip( + message: appState.fileState.scanError!, + child: const Icon(Icons.error_outline, + color: Colors.orange)), + const SizedBox(width: 8), + Text( + context.l10n.messageFilesCards( + (collectedFilesCount).toString(), + (cardGroupsCount).toString()), + style: TextStyle(fontSize: 12, color: Colors.grey[500]), + ), + const SizedBox(width: 8), + if (isFileScanning) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2)), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: isFileScanning + ? null + : () => appState.scanForFiles(), + icon: const Icon(Icons.refresh, size: 16), + label: Text(context.l10n.messageScan, + style: TextStyle(fontSize: 12)), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: !hasCollectedFiles + ? null + : () => _showOrganizeDialog(appState), + icon: const Icon(Icons.create_new_folder, size: 16), + label: Text(context.l10n.messageOrganizeFiles, + style: TextStyle(fontSize: 12)), + ), + ]), ), const Divider(height: 1), @@ -454,11 +482,13 @@ class _ConnectionPageState extends State { size: 48, color: Colors.grey.withValues(alpha: 0.3)), const SizedBox(height: 12), - Text('未发现 PM3 导出文件', + Text(context.l10n.messageNoPm3ExportFilesFound, style: TextStyle( color: Colors.grey[500], fontSize: 14)), const SizedBox(height: 4), - Text('连接设备并执行 dump/autopwn 后自动收集', + Text( + context.l10n + .messageFilesAreCollectedAutomaticallyAfterConnectingAnd, style: TextStyle( color: Colors.grey[600], fontSize: 12)), ]), @@ -479,7 +509,7 @@ class _ConnectionPageState extends State { Widget _pickPm3PathButton(AppState appState) => IconButton( icon: const Icon(Icons.folder_open, size: 18), - tooltip: '选择 PM3 程序', + tooltip: context.l10n.settingsPickPm3, onPressed: () async { final path = await FileDialogService.pickSingleFilePath(); if (path != null) await appState.setPm3Path(path); @@ -534,7 +564,9 @@ class _ConnectionPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - isConnected ? 'PM3 已连接' : 'PM3 未连接', + isConnected + ? context.l10n.messagePm3Connected + : context.l10n.messagePm3NotConnected, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), ), @@ -542,7 +574,7 @@ class _ConnectionPageState extends State { Text( isConnected ? '${appState.portName} • ${appState.pm3Version}' - : '请选择串口并连接设备', + : context.l10n.messageSelectASerialPortAndConnectThe, style: TextStyle(fontSize: 12, color: Colors.grey[500]), ), ], @@ -598,7 +630,7 @@ class _ConnectionPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('连接失败', + Text(context.l10n.messageConnectionFailed2, style: TextStyle( fontWeight: FontWeight.bold, color: const Color(0xFFF87171), @@ -641,8 +673,8 @@ class _ConnectionPageState extends State { title: Text(group.label, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), subtitle: Text( - '${group.dumpCount} 份转储 • ${group.keyCount} 份密钥 • ' - '${group.files.length} 个文件', + context.l10n.messageDumpsKeyFilesFiles((group.dumpCount).toString(), + (group.keyCount).toString(), (group.files.length).toString()), style: TextStyle(fontSize: 11, color: Colors.grey[500]), ), children: [ @@ -669,16 +701,19 @@ class _ConnectionPageState extends State { '${_formatTime(f.modified)} • ${f.format}', style: TextStyle(fontSize: 10, color: Colors.grey[500]), ), - trailing: Row( - mainAxisSize: MainAxisSize.min, + trailing: Wrap( + runSpacing: 8, children: [ IconButton( icon: const Icon(Icons.folder_open, size: 16), - tooltip: '在 Dump 查看器中打开', + tooltip: context.l10n.messageOpenInDumpViewer, onPressed: () { context.read().requestOpenDumpInViewer(f.path); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('已切换到 Dump 查看器: ${f.fileName}')), + SnackBar( + content: Text(context.l10n + .messageOpenedInDumpViewer( + (f.fileName).toString()))), ); }, visualDensity: VisualDensity.compact, @@ -701,31 +736,28 @@ class _ConnectionPageState extends State { showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('归类整理 PM3 文件'), + title: Text(context.l10n.messageOrganizePm3Files), content: SizedBox( width: 400, child: Column(mainAxisSize: MainAxisSize.min, children: [ - const Text( - '将收集到的 PM3 导出文件按卡片 UID 分类整理到子目录中。\n\n' - '目录结构:\n' - ' 📁 hf-mf/A991A280/\n' - ' ├── hf-mf-A991A280-dump.bin\n' - ' ├── hf-mf-A991A280-key.bin\n' - ' └── ...\n', + Text( + context.l10n + .messageOrganizeCollectedPm3ExportFilesIntoSubdirectories, style: TextStyle(fontSize: 13), ), const SizedBox(height: 12), TextField( controller: controller, - decoration: const InputDecoration( - labelText: '目标目录', + decoration: InputDecoration( + labelText: context.l10n.messageDestinationDirectory, prefixIcon: Icon(Icons.folder, size: 18), ), style: const TextStyle(fontFamily: 'monospace', fontSize: 13), ), const SizedBox(height: 8), Text( - '共 ${appState.collectedFiles.length} 个文件将被整理', + context.l10n.messageFilesToOrganize( + (appState.collectedFiles.length).toString()), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), ), ]), @@ -733,7 +765,7 @@ class _ConnectionPageState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('取消'), + child: Text(context.l10n.messageCancel), ), ElevatedButton( onPressed: () async { @@ -742,11 +774,13 @@ class _ConnectionPageState extends State { await appState.organizeCollectedFiles(controller.text); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('已整理 $count 个文件')), + SnackBar( + content: Text(context.l10n + .messageFilesOrganized((count).toString()))), ); } }, - child: const Text('开始整理'), + child: Text(context.l10n.messageStartOrganizing), ), ], ), @@ -790,7 +824,7 @@ class _ConnectionPageState extends State { Row(children: [ SizedBox( width: 80, - child: Text('Flash 用量', + child: Text(context.l10n.messageFlashUsage, style: TextStyle(color: Colors.grey[500], fontSize: 12)), ), Expanded( diff --git a/lib/ui/pages/data_page.dart b/lib/ui/pages/data_page.dart index 34f5f38..1388f5f 100644 --- a/lib/ui/pages/data_page.dart +++ b/lib/ui/pages/data_page.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class DataPage extends StatefulWidget { const DataPage({super.key}); @@ -64,10 +65,10 @@ class _DataPageState extends State @override Widget build(BuildContext context) { return Column(children: [ - TabBar(controller: _tabController, tabs: const [ - Tab(text: '基础'), - Tab(text: '分析'), - Tab(text: '比较'), + TabBar(controller: _tabController, tabs: [ + Tab(text: context.l10n.messageBasic), + Tab(text: context.l10n.messageAnalysis), + Tab(text: context.l10n.messageCompare), ]), Expanded( child: TabBarView(controller: _tabController, children: [ @@ -84,18 +85,18 @@ class _DataPageState extends State crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '绘图', - subtitle: '显示数据图表', + title: context.l10n.messagePlot, + subtitle: context.l10n.messageDisplayTheDataGraph, icon: Icons.show_chart, onTap: () => _execute(DataCmd.plot())), ActionCard( - title: '清除', - subtitle: '清除绘图缓冲区', + title: context.l10n.messageClear, + subtitle: context.l10n.messageClearTheGraphBuffer, icon: Icons.clear_all, onTap: () => _execute(DataCmd.clear())), ActionCard( - title: '检测时钟', - subtitle: '自动检测时钟速率', + title: context.l10n.messageDetectClock, + subtitle: context.l10n.messageAutomaticallyDetectTheClockRate, icon: Icons.timer, onTap: () => _execute(DataCmd.detectclock())), Card( @@ -104,13 +105,14 @@ class _DataPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('采样', + Text(context.l10n.messageSampling, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 13)), const SizedBox(height: 8), TextFormField( - decoration: const InputDecoration( - labelText: '采样数', isDense: true), + decoration: InputDecoration( + labelText: context.l10n.messageSampleCount, + isDense: true), initialValue: _sampleCount, onChanged: (v) => _sampleCount = v, ), @@ -119,7 +121,7 @@ class _DataPageState extends State onPressed: () => _execute(DataCmd.samples( count: int.tryParse(_sampleCount) ?? 20000)), icon: const Icon(Icons.memory, size: 18), - label: const Text('读取采样')), + label: Text(context.l10n.messageReadSamples)), ], ))), ], @@ -145,12 +147,13 @@ class _DataPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('文件操作', + Text(context.l10n.messageFileOperations, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), TextFormField( - decoration: const InputDecoration( - labelText: '文件路径', isDense: true), + decoration: InputDecoration( + labelText: context.l10n.messageFilePath, + isDense: true), onChanged: (v) => _file = v, ), const SizedBox(height: 8), @@ -160,13 +163,13 @@ class _DataPageState extends State ? () => _execute(DataCmd.save(_file)) : null, icon: const Icon(Icons.save, size: 18), - label: const Text('保存')), + label: Text(context.l10n.messageSave)), OutlinedButton.icon( onPressed: _file.isNotEmpty ? () => _execute(DataCmd.load(_file)) : null, icon: const Icon(Icons.upload, size: 18), - label: const Text('加载')), + label: Text(context.l10n.messageLoad)), ]), ], ))), @@ -177,18 +180,19 @@ class _DataPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('ASN.1 解码', + Text(context.l10n.messageAsn1Decoding, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), HexInputField( - label: 'Hex 数据', onChanged: (v) => _hexData = v), + label: context.l10n.messageHexData, + onChanged: (v) => _hexData = v), const SizedBox(height: 8), ElevatedButton.icon( onPressed: _hexData.isNotEmpty ? () => _execute(DataCmd.asn1(_hexData)) : null, icon: const Icon(Icons.code, size: 18), - label: const Text('解码')), + label: Text(context.l10n.messageDecode)), ], ))), ]), @@ -212,18 +216,18 @@ class _DataPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('文件比较', + Text(context.l10n.messageFileComparison, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), TextFormField( - decoration: - const InputDecoration(labelText: '文件 A', isDense: true), + decoration: InputDecoration( + labelText: context.l10n.messageFileA, isDense: true), onChanged: (v) => _fileA = v, ), const SizedBox(height: 8), TextFormField( - decoration: - const InputDecoration(labelText: '文件 B', isDense: true), + decoration: InputDecoration( + labelText: context.l10n.messageFileB, isDense: true), onChanged: (v) => _fileB = v, ), const SizedBox(height: 12), @@ -232,7 +236,7 @@ class _DataPageState extends State ? () => _execute(DataCmd.diff(_fileA, _fileB)) : null, icon: const Icon(Icons.compare, size: 18), - label: const Text('比较')), + label: Text(context.l10n.messageCompare)), ], ))), main: ResultDisplay( diff --git a/lib/ui/pages/dump_compare_page.dart b/lib/ui/pages/dump_compare_page.dart index 55faad7..e72931b 100644 --- a/lib/ui/pages/dump_compare_page.dart +++ b/lib/ui/pages/dump_compare_page.dart @@ -8,6 +8,7 @@ import 'package:file_selector/file_selector.dart' as fs; import 'package:pm3gui/models/mifare_card.dart'; import 'package:pm3gui/parsers/dump_parser.dart'; import 'package:pm3gui/services/file_dialog_service.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class DumpComparePage extends StatefulWidget { const DumpComparePage({super.key}); @@ -70,7 +71,7 @@ class _DumpComparePageState extends State { ElevatedButton.icon( onPressed: () => _loadCard(true), icon: const Icon(Icons.file_open, size: 18), - label: const Text('打开文件 A'), + label: Text(context.l10n.messageOpenFileA), ), if (_pathA != null) ...[ const SizedBox(width: 8), @@ -89,7 +90,7 @@ class _DumpComparePageState extends State { ElevatedButton.icon( onPressed: () => _loadCard(false), icon: const Icon(Icons.file_open, size: 18), - label: const Text('打开文件 B'), + label: Text(context.l10n.messageOpenFileB), ), if (_pathB != null) ...[ const SizedBox(width: 8), @@ -104,30 +105,33 @@ class _DumpComparePageState extends State { const Spacer(), const SizedBox(width: 16), FilterChip( - label: const Text('仅差异', style: TextStyle(fontSize: 12)), + label: Text(context.l10n.messageDifferencesOnly, + style: TextStyle(fontSize: 12)), selected: _onlyDiffs, onSelected: (v) => setState(() => _onlyDiffs = v), ), ]), ), // 错误信息 - if (_errorA != null) _errorBar('文件 A 错误: $_errorA'), - if (_errorB != null) _errorBar('文件 B 错误: $_errorB'), + if (_errorA != null) + _errorBar(context.l10n.messageFileAError((_errorA).toString())), + if (_errorB != null) + _errorBar(context.l10n.messageFileBError((_errorB).toString())), const Divider(height: 1), // 对比内容 if (_cardA != null && _cardB != null) Expanded(child: _buildCompareView()) else - const Expanded( + Expanded( child: Center( child: Column(mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.compare_arrows, size: 64, color: Colors.grey), SizedBox(height: 16), - Text('分别打开两个转储文件进行对比', + Text(context.l10n.messageOpenTwoDumpFilesToCompareThem, style: TextStyle(color: Colors.grey, fontSize: 16)), SizedBox(height: 8), - Text('支持 .eml / .bin / .json / .dump', + Text(context.l10n.messageSupportsEmlBinJsonDump, style: TextStyle(color: Colors.grey)), ]), ), @@ -181,15 +185,16 @@ class _DumpComparePageState extends State { Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: Row(children: [ - _statChip('总块数', '$maxBlocks'), + _statChip(context.l10n.messageTotalBlocks, '$maxBlocks'), const SizedBox(width: 8), - _statChip('数据差异', '$diffCount 块', + _statChip(context.l10n.messageDataDifferences, + context.l10n.messageBlocks((diffCount).toString()), color: diffCount > 0 ? Colors.orange : Colors.green), const SizedBox(width: 8), - _statChip('Key A 差异', '$diffKeyA', + _statChip(context.l10n.messageKeyADifferences, '$diffKeyA', color: diffKeyA > 0 ? Colors.orange : Colors.green), const SizedBox(width: 8), - _statChip('Key B 差异', '$diffKeyB', + _statChip(context.l10n.messageKeyBDifferences, '$diffKeyB', color: diffKeyB > 0 ? Colors.orange : Colors.green), const Spacer(), Chip( @@ -208,10 +213,12 @@ class _DumpComparePageState extends State { padding: const EdgeInsets.all(8), itemCount: maxBlocks, itemBuilder: (context, block) { - final a = - block < _cardA!.blocks.length ? _cardA!.blocks[block] : '(无)'; - final b = - block < _cardB!.blocks.length ? _cardB!.blocks[block] : '(无)'; + final a = block < _cardA!.blocks.length + ? _cardA!.blocks[block] + : context.l10n.messageNone; + final b = block < _cardB!.blocks.length + ? _cardB!.blocks[block] + : context.l10n.messageNone; final isDiff = a.toUpperCase() != b.toUpperCase(); if (_onlyDiffs && !isDiff) return const SizedBox.shrink(); @@ -249,11 +256,11 @@ class _DumpComparePageState extends State { fontWeight: FontWeight.bold, fontSize: 12)), if (isBlock0) - const Text('制造商', + Text(context.l10n.messageManufacturer, style: TextStyle(fontSize: 9, color: Colors.blue)), if (isTrailerA) - const Text('尾块', + Text(context.l10n.messageTrailer, style: TextStyle( fontSize: 9, color: Colors.orange)), ], diff --git a/lib/ui/pages/dump_viewer_page.dart b/lib/ui/pages/dump_viewer_page.dart index cc2bf2a..86a101c 100644 --- a/lib/ui/pages/dump_viewer_page.dart +++ b/lib/ui/pages/dump_viewer_page.dart @@ -21,6 +21,7 @@ import 'package:pm3gui/services/file_dialog_service.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/ui/widgets/command_card_editor.dart'; +import 'package:pm3gui/l10n/l10n.dart'; enum _KeyOverride { preserve, overwrite } @@ -118,7 +119,9 @@ class _DumpViewerPageState extends State await _loadDumpFile(path); } } catch (e) { - setState(() => _error = 'Error: $e'); + if (!mounted) return; + setState( + () => _error = context.l10n.messageCouldNotOpenFile(e.toString())); } } @@ -131,10 +134,13 @@ class _DumpViewerPageState extends State await _loadDumpFile(path); } } catch (e) { - setState(() => _error = 'Error: $e'); if (!mounted) return; + setState( + () => _error = context.l10n.messageCouldNotOpenFile(e.toString())); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('打开失败: $e')), + SnackBar( + content: + Text(context.l10n.messageCouldNotOpenFile((e).toString()))), ); } } @@ -199,19 +205,24 @@ class _DumpViewerPageState extends State // Show info only setState(() { _extractedKeys = null; - _keySummary = '字典文件: ${keyList.length} 个唯一密钥'; + _keySummary = context.l10n + .messageDictionaryFileUniqueKeys((keyList.length).toString()); _error = null; }); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('加载字典: ${keyList.length} 个密钥')), + SnackBar( + content: Text(context.l10n + .messageDictionaryLoadedKeys((keyList.length).toString()))), ); return; } // Binary key file if (ext == 'txt') { - newKeys = parseKeyText(await File(path).readAsString()); + final l10n = context.l10n; + newKeys = + parseKeyText(await File(path).readAsString(), localizations: l10n); } else { final bytes = await File(path).readAsBytes(); newKeys = parseKeyBinBytes(Uint8List.fromList(bytes)); @@ -227,7 +238,9 @@ class _DumpViewerPageState extends State if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('已合并 ${newKeys.length} 个扇区的密钥(块数据保持不变)'), + content: Text(context.l10n + .messageMergedKeysForSectorsBlockDataUnchanged( + (newKeys.length).toString())), backgroundColor: Colors.green.withValues(alpha: 0.8), ), ); @@ -322,16 +335,16 @@ class _DumpViewerPageState extends State return showDialog<_KeyOverride>( context: context, builder: (ctx) => AlertDialog( - title: const Row(children: [ + title: Row(children: [ Icon(Icons.vpn_key, size: 20), SizedBox(width: 8), - Text('密钥处理'), + Text(context.l10n.messageKeyHandling), ]), - content: const SizedBox( + content: SizedBox( width: 360, child: Column(mainAxisSize: MainAxisSize.min, children: [ Text( - '当前已有密钥数据。加载新的转储文件时,如何处理密钥?', + context.l10n.messageKeyDataIsAlreadyLoadedHowShould, style: TextStyle(fontSize: 14), ), SizedBox(height: 16), @@ -340,7 +353,7 @@ class _DumpViewerPageState extends State SizedBox(width: 8), Expanded( child: Text( - '转储文件的密钥区通常为 FF,而实际密钥可能来自单独的 key 文件', + context.l10n.messageDumpKeyFieldsAreOftenFfWhile, style: TextStyle(fontSize: 12, color: Colors.grey), ), ), @@ -350,17 +363,17 @@ class _DumpViewerPageState extends State actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('取消'), + child: Text(context.l10n.messageCancel), ), OutlinedButton.icon( onPressed: () => Navigator.pop(ctx, _KeyOverride.preserve), icon: const Icon(Icons.shield, size: 16), - label: const Text('保留当前密钥'), + label: Text(context.l10n.messageKeepCurrentKeys), ), ElevatedButton.icon( onPressed: () => Navigator.pop(ctx, _KeyOverride.overwrite), icon: const Icon(Icons.sync, size: 16), - label: const Text('用转储覆盖'), + label: Text(context.l10n.messageReplaceWithDumpKeys), ), ], ), @@ -376,7 +389,9 @@ class _DumpViewerPageState extends State appState.setPreferredMfKeyFile(tmp.path); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('已设为默认密钥文件: ${tmp.path}')), + SnackBar( + content: Text( + context.l10n.messageDefaultKeyFileSet((tmp.path).toString()))), ); } } @@ -390,7 +405,9 @@ class _DumpViewerPageState extends State appState.setPreferredMfDumpFile(tmp.path); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('已设为默认转储文件: ${tmp.path}')), + SnackBar( + content: Text( + context.l10n.messageDefaultDumpFileSet((tmp.path).toString()))), ); } } @@ -430,12 +447,22 @@ class _DumpViewerPageState extends State TabBar( controller: _tabController, labelColor: Theme.of(context).colorScheme.primary, - tabs: const [ - Tab(text: '扇区视图', icon: Icon(Icons.grid_view, size: 18)), - Tab(text: '密钥/编辑', icon: Icon(Icons.edit, size: 18)), - Tab(text: '深度分析', icon: Icon(Icons.analytics, size: 18)), - Tab(text: '回写/清空', icon: Icon(Icons.upload, size: 18)), - Tab(text: '命令数据', icon: Icon(Icons.terminal, size: 18)), + tabs: [ + Tab( + text: context.l10n.messageSectorView, + icon: Icon(Icons.grid_view, size: 18)), + Tab( + text: context.l10n.messageKeysEdit, + icon: Icon(Icons.edit, size: 18)), + Tab( + text: context.l10n.messageDetailedAnalysis, + icon: Icon(Icons.analytics, size: 18)), + Tab( + text: context.l10n.messageWriteClear, + icon: Icon(Icons.upload, size: 18)), + Tab( + text: context.l10n.messageCommandData, + icon: Icon(Icons.terminal, size: 18)), ], ), Expanded( @@ -448,13 +475,18 @@ class _DumpViewerPageState extends State const VerticalDivider(width: 1), Expanded(child: _buildSectorView()), ]) - : _buildEmptyHint('打开转储文件查看扇区数据', Icons.grid_view), + : _buildEmptyHint( + context.l10n.messageOpenADumpFileToViewSector, + Icons.grid_view), _card != null ? _buildKeyEditorView() - : _buildEmptyHint('打开转储文件或密钥文件以编辑', Icons.edit), + : _buildEmptyHint( + context.l10n.messageOpenADumpOrKeyFileTo, Icons.edit), _card != null ? _buildAnalysisView() - : _buildEmptyHint('打开转储文件后可查看深度分析', Icons.analytics), + : _buildEmptyHint( + context.l10n.messageOpenADumpFileToViewDetailed, + Icons.analytics), _buildWriteBackView(), CommandCardEditor(openedCard: _card), ], @@ -471,19 +503,19 @@ class _DumpViewerPageState extends State const SizedBox(height: 16), Text(message, style: TextStyle(color: Colors.grey[500], fontSize: 15)), const SizedBox(height: 8), - Text('支持 .eml / .bin / .json / .dump / .dic', + Text(context.l10n.messageSupportsEmlBinJsonDumpDic, style: TextStyle(color: Colors.grey[600], fontSize: 12)), const SizedBox(height: 16), OutlinedButton.icon( onPressed: _openFile, icon: const Icon(Icons.file_open, size: 16), - label: const Text('打开文件'), + label: Text(context.l10n.messageOpenFile), ), const SizedBox(width: 8), OutlinedButton.icon( onPressed: () => _tabController.animateTo(4), icon: const Icon(Icons.terminal), - label: const Text('命令数据'), + label: Text(context.l10n.messageCommandData), ), ]), ); @@ -496,38 +528,46 @@ class _DumpViewerPageState extends State ElevatedButton.icon( onPressed: _openFile, icon: const Icon(Icons.file_open, size: 18), - label: const Text('打开文件'), + label: Text(context.l10n.messageOpenFile), ), const SizedBox(width: 8), if (_card != null) ...[ OutlinedButton.icon( onPressed: _setAsPreferredKeyFile, icon: const Icon(Icons.vpn_key, size: 16), - label: const Text('设为默认密钥'), + label: Text(context.l10n.messageSetAsDefaultKeys), ), const SizedBox(width: 8), OutlinedButton.icon( onPressed: _setAsPreferredDumpFile, icon: const Icon(Icons.save, size: 16), - label: const Text('设为默认转储'), + label: Text(context.l10n.messageSetAsDefaultDump), ), const SizedBox(width: 8), PopupMenuButton( onSelected: _exportAs, - child: const Chip( + child: Chip( avatar: Icon(Icons.save_alt, size: 18), - label: Text('导出为'), + label: Text(context.l10n.messageExportAs), ), itemBuilder: (context) => [ - const PopupMenuItem(value: 'eml', child: Text('.eml (文本转储)')), - const PopupMenuItem(value: 'bin', child: Text('.bin (二进制转储)')), - const PopupMenuItem(value: 'json', child: Text('.json (PM3 格式)')), + PopupMenuItem( + value: 'eml', child: Text(context.l10n.messageEmlTextDump)), + PopupMenuItem( + value: 'bin', child: Text(context.l10n.messageBinBinaryDump)), + PopupMenuItem( + value: 'json', + child: Text(context.l10n.messageJsonPm3Format)), const PopupMenuDivider(), - const PopupMenuItem( - value: 'key.bin', child: Text('.key.bin (二进制密钥)')), - const PopupMenuItem(value: 'dic', child: Text('.dic (密钥字典)')), - const PopupMenuItem( - value: 'keys.txt', child: Text('.keys.txt (密钥列表)')), + PopupMenuItem( + value: 'key.bin', + child: Text(context.l10n.messageKeyBinBinaryKeys)), + PopupMenuItem( + value: 'dic', + child: Text(context.l10n.messageDicKeyDictionary)), + PopupMenuItem( + value: 'keys.txt', + child: Text(context.l10n.messageKeysTxtKeyList)), ], ), const Spacer(), @@ -570,8 +610,8 @@ class _DumpViewerPageState extends State _afterExportSuccess(savePath, format, appState, autoSaved: false); } catch (e) { if (mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('导出失败: $e'))); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(context.l10n.messageExportFailed((e).toString())))); } } } @@ -642,7 +682,10 @@ class _DumpViewerPageState extends State ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - autoSaved ? '已自动归类导出: $savePath' : '已导出: $savePath', + autoSaved + ? context.l10n + .messageExportedAndOrganized((savePath).toString()) + : context.l10n.messageExported((savePath).toString()), ), ), ); @@ -670,7 +713,9 @@ class _DumpViewerPageState extends State } Widget _buildSectorView() { - if (_card == null) return const Center(child: Text('无卡片数据')); + if (_card == null) { + return Center(child: Text(context.l10n.messageNoCardData)); + } final ct = _card!.cardType; final sector = _selectedSector; final firstBlock = ct.sectorFirstBlock[sector]; @@ -688,7 +733,9 @@ class _DumpViewerPageState extends State return SingleChildScrollView( padding: const EdgeInsets.all(12), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('扩区 $sector (块 $firstBlock\u2013$trailerBlockIdx)', + Text( + context.l10n.messageSectorBlocks((sector).toString(), + (firstBlock).toString(), (trailerBlockIdx).toString()), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 4), Row(children: [ @@ -703,7 +750,7 @@ class _DumpViewerPageState extends State if (accessInfo != null) ...[ const SizedBox(height: 16), const Divider(), - const Text('访问控制条件', + Text(context.l10n.messageAccessConditions, style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), const SizedBox(height: 8), _buildAccessTable(accessInfo), @@ -719,10 +766,10 @@ class _DumpViewerPageState extends State Color bgColor = Colors.transparent; String label = 'Data'; if (block == 0 && _selectedSector == 0) { - label = '制造商块'; + label = context.l10n.messageManufacturerBlock; bgColor = Colors.blue.withValues(alpha: 0.1); } else if (isTrailer) { - label = '尾块'; + label = context.l10n.messageTrailer; bgColor = Colors.orange.withValues(alpha: 0.1); } @@ -808,12 +855,12 @@ class _DumpViewerPageState extends State 2: FlexColumnWidth() }, children: [ - const TableRow( + TableRow( decoration: BoxDecoration(color: Color(0xFF2A2A3C)), children: [ Padding( padding: EdgeInsets.all(6), - child: Text('块', + child: Text(context.l10n.messageBlock, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 12))), Padding( @@ -823,7 +870,7 @@ class _DumpViewerPageState extends State fontWeight: FontWeight.bold, fontSize: 12))), Padding( padding: EdgeInsets.all(6), - child: Text('说明', + child: Text(context.l10n.messageDescription, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 12))), ]), @@ -831,7 +878,10 @@ class _DumpViewerPageState extends State TableRow(children: [ Padding( padding: const EdgeInsets.all(6), - child: Text(i == allBits.length - 1 ? '尾块' : '数据 $i', + child: Text( + i == allBits.length - 1 + ? context.l10n.messageTrailer + : context.l10n.messageData((i).toString()), style: const TextStyle(fontSize: 12))), Padding( padding: const EdgeInsets.all(6), @@ -850,29 +900,29 @@ class _DumpViewerPageState extends State String _accessDesc(int c, {bool isTrailer = false}) { if (isTrailer) { - const d = { - 0: 'KeyA: 写 KeyA|访控|KeyB', - 1: 'KeyA: 写 KeyA|访控; KeyB: 读写', - 2: '仅可读访控', - 3: 'KeyA: 写访控; KeyB: 读写', - 4: 'KeyA: 写 KeyA|访控; KeyB: 读写', - 5: 'KeyB: 写全部', - 6: '已锁定', - 7: '已锁定' + final d = { + 0: context.l10n.messageKeyAWriteKeyAAccessBits, + 1: context.l10n.messageKeyAWriteKeyAAccessBits2, + 2: context.l10n.messageReadAccessBitsOnly, + 3: context.l10n.messageKeyAWriteAccessBitsKeyB, + 4: context.l10n.messageKeyAWriteKeyAAccessBits2, + 5: context.l10n.messageKeyBWriteAll, + 6: context.l10n.messageLocked, + 7: context.l10n.messageLocked }; - return d[c] ?? '未知'; + return d[c] ?? context.l10n.messageUnknown; } - const d = { - 0: '读写 Key A 或 B', - 1: '可读 A/B,不可写', - 2: '可读 A/B,不可写', - 3: '读写仅 Key B', - 4: '读: A/B,写: B,减: A/B (值块)', - 5: '仅 Key B 可读', - 6: '读: A/B,写/加: B,减: A/B (值块)', - 7: '已锁定' + final d = { + 0: context.l10n.messageReadWriteWithKeyAOrB, + 1: context.l10n.messageReadWithABNoWriting, + 2: context.l10n.messageReadWithABNoWriting, + 3: context.l10n.messageReadWriteWithKeyBOnly, + 4: context.l10n.messageReadABWriteBDecrementA, + 5: context.l10n.messageReadWithKeyBOnly, + 6: context.l10n.messageReadABWriteIncrementBDecrement, + 7: context.l10n.messageLocked }; - return d[c] ?? '未知'; + return d[c] ?? context.l10n.messageUnknown; } Widget _keyChip(String label, String key) { @@ -907,73 +957,90 @@ class _DumpViewerPageState extends State // ====================================================================== Widget _buildAnalysisView() { if (_dumpResult == null || _card == null) { - return const Center(child: Text('无数据')); + return Center(child: Text(context.l10n.messageNoData)); } - final a = _dumpResult!.analysis; + final a = _dumpResult!.analysisFor(context.l10n); return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ // --- 概览卡片 - _analysisCard('📋 概览', [ - _kvRow('卡片类型', _card!.cardType.label), - _kvRow('UID', _card!.uid.isNotEmpty ? _card!.uid : '未知'), - _kvRow('总块数', '${a.totalBlocks}'), - _kvRow('空白数据块', '${a.emptyBlockCount}'), - _kvRow('数据使用率', '${a.usagePercent.toStringAsFixed(1)}%'), - _kvRow('值块数', '${a.valueBlocks.length}'), + _analysisCard(context.l10n.messageOverview, [ + _kvRow(context.l10n.messageCardType, _card!.cardType.label), + _kvRow('UID', + _card!.uid.isNotEmpty ? _card!.uid : context.l10n.messageUnknown), + _kvRow(context.l10n.messageTotalBlocks, '${a.totalBlocks}'), + _kvRow(context.l10n.messageEmptyDataBlocks, '${a.emptyBlockCount}'), + _kvRow(context.l10n.messageDataUsage, + '${a.usagePercent.toStringAsFixed(1)}%'), + _kvRow(context.l10n.messageValueBlocks, '${a.valueBlocks.length}'), ]), const SizedBox(height: 12), // --- 制造商信息 if (a.manufacturerInfo != null) - _analysisCard('🏭 制造商块 (Block 0)', [ + _analysisCard(context.l10n.messageManufacturerBlockBlock0, [ _kvRow('UID', a.manufacturerInfo!.uid), _kvRow('BCC', '${a.manufacturerInfo!.bcc} ${a.manufacturerInfo!.bccValid ? "✅" : "❌"}'), _kvRow('SAK', a.manufacturerInfo!.sak), _kvRow('ATQA', a.manufacturerInfo!.atqa), - _kvRow('芯片类型', a.manufacturerInfo!.chipType), - _kvRow('制造商', a.manufacturerInfo!.manufacturer), - _kvRow('原始数据', _fmtHex(a.manufacturerInfo!.rawHex)), + _kvRow(context.l10n.messageChipType, a.manufacturerInfo!.chipType), + _kvRow(context.l10n.messageManufacturer, + a.manufacturerInfo!.manufacturer), + _kvRow(context.l10n.messageRawData, + _fmtHex(a.manufacturerInfo!.rawHex)), ]), if (a.manufacturerInfo != null) const SizedBox(height: 12), // --- 密钥分析 - _analysisCard('🔑 密钥分析', [ - _kvRow('扇区总数', '${a.keyAnalysis.totalSectors}'), - _kvRow('已知 Key A', + _analysisCard(context.l10n.messageKeyAnalysis, [ + _kvRow(context.l10n.messageTotalSectors, + '${a.keyAnalysis.totalSectors}'), + _kvRow(context.l10n.messageKnownKeyA, '${a.keyAnalysis.foundKeyA} / ${a.keyAnalysis.totalSectors}'), - _kvRow('已知 Key B', + _kvRow(context.l10n.messageKnownKeyB, '${a.keyAnalysis.foundKeyB} / ${a.keyAnalysis.totalSectors}'), - _kvRow('所有密钥相同', a.keyAnalysis.allKeysIdentical ? '是' : '否'), - _kvRow('存在空白密钥', a.keyAnalysis.hasBlankKeys ? '是 ⚠️' : '否'), + _kvRow( + context.l10n.messageAllKeysIdentical, + a.keyAnalysis.allKeysIdentical + ? context.l10n.messageYes + : context.l10n.messageNo), + _kvRow( + context.l10n.messageContainsBlankKeys, + a.keyAnalysis.hasBlankKeys + ? context.l10n.messageYes2 + : context.l10n.messageNo), if (a.keyAnalysis.keyAGroups.isNotEmpty) ...[ const Divider(), - const Text('Key A 分组:', + Text(context.l10n.messageKeyAGroups, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), for (final e in a.keyAnalysis.keyAGroups.entries) Padding( padding: const EdgeInsets.only(left: 16, top: 2), - child: Text('${e.key} → 扇区 ${e.value.join(", ")}', + child: Text( + context.l10n.messageSectors( + (e.key).toString(), (e.value.join(", ")).toString()), style: const TextStyle(fontFamily: 'monospace', fontSize: 12)), ), ], if (a.keyAnalysis.keyBGroups.isNotEmpty) ...[ const Divider(), - const Text('Key B 分组:', + Text(context.l10n.messageKeyBGroups, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), for (final e in a.keyAnalysis.keyBGroups.entries) Padding( padding: const EdgeInsets.only(left: 16, top: 2), - child: Text('${e.key} → 扇区 ${e.value.join(", ")}', + child: Text( + context.l10n.messageSectors( + (e.key).toString(), (e.value.join(", ")).toString()), style: const TextStyle(fontFamily: 'monospace', fontSize: 12)), ), ], if (a.keyAnalysis.defaultMatches.isNotEmpty) ...[ const Divider(), - const Text('⚠️ 默认密钥匹配:', + Text(context.l10n.messageDefaultKeyMatches, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 13, @@ -982,7 +1049,11 @@ class _DumpViewerPageState extends State Padding( padding: const EdgeInsets.only(left: 16, top: 2), child: Text( - '扇区 ${m.sector} Key${m.keyType}: ${m.keyHex} (${m.keyName})', + context.l10n.messageSectorKey( + (m.sector).toString(), + (m.keyType).toString(), + (m.keyHex).toString(), + (m.keyName).toString()), style: const TextStyle( fontFamily: 'monospace', fontSize: 12, @@ -994,49 +1065,59 @@ class _DumpViewerPageState extends State // --- MAD if (a.madInfo != null) - _analysisCard('📂 MAD 应用目录 (v${a.madInfo!.version})', [ - _kvRow('CRC', a.madInfo!.crc), - _kvRow('Info', a.madInfo!.infoBytes), - const Divider(), - for (final e in a.madInfo!.entries) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Row(children: [ - SizedBox( - width: 60, - child: Text('扇区 ${e.sector}', - style: const TextStyle(fontSize: 12))), - SizedBox( - width: 80, - child: Text( - '0x${e.aid.toRadixString(16).padLeft(4, '0')}', - style: const TextStyle( - fontFamily: 'monospace', fontSize: 12))), - Expanded( - child: Text(e.description, - style: const TextStyle(fontSize: 12))), - ]), - ), - ]), + _analysisCard( + context.l10n.messageMadApplicationDirectoryV( + (a.madInfo!.version).toString()), + [ + _kvRow('CRC', a.madInfo!.crc), + _kvRow('Info', a.madInfo!.infoBytes), + const Divider(), + for (final e in a.madInfo!.entries) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Row(children: [ + SizedBox( + width: 60, + child: Text( + context.l10n.messageSector((e.sector).toString()), + style: const TextStyle(fontSize: 12))), + SizedBox( + width: 80, + child: Text( + '0x${e.aid.toRadixString(16).padLeft(4, '0')}', + style: const TextStyle( + fontFamily: 'monospace', fontSize: 12))), + Expanded( + child: Text(e.description, + style: const TextStyle(fontSize: 12))), + ]), + ), + ]), if (a.madInfo != null) const SizedBox(height: 12), // --- 值块 if (a.valueBlocks.isNotEmpty) - _analysisCard('💰 值块', [ + _analysisCard(context.l10n.messageValueBlocks2, [ for (final v in a.valueBlocks) Padding( padding: const EdgeInsets.only(top: 4), child: Row(children: [ SizedBox( width: 80, - child: Text('块 ${v.blockNumber}', + child: Text( + context.l10n + .messageBlock2((v.blockNumber).toString()), style: const TextStyle(fontSize: 12))), SizedBox( width: 100, - child: Text('扇区 ${v.sectorNumber}', + child: Text( + context.l10n + .messageSector((v.sectorNumber).toString()), style: const TextStyle(fontSize: 12))), Expanded( - child: Text('值: ${v.value} 地址: ${v.address}', + child: Text( + context.l10n.messageValueAddress( + (v.value).toString(), (v.address).toString()), style: const TextStyle( fontFamily: 'monospace', fontSize: 12, @@ -1047,7 +1128,7 @@ class _DumpViewerPageState extends State if (a.valueBlocks.isNotEmpty) const SizedBox(height: 12), // --- 扇区概览表 - _analysisCard('📊 扇区概览', [ + _analysisCard(context.l10n.messageSectorOverview, [ for (final s in a.sectors) Padding( padding: const EdgeInsets.only(top: 2), @@ -1076,10 +1157,13 @@ class _DumpViewerPageState extends State ? Colors.orangeAccent : Colors.cyanAccent))), if (s.accessInfo != null) - Text(s.accessInfo!.isValid ? '✅ 访控有效' : '❌ 访控无效', + Text( + s.accessInfo!.isValid + ? context.l10n.messageValidAccessBits + : context.l10n.messageInvalidAccessBits, style: const TextStyle(fontSize: 11)) else - const Text('- 无访控', + Text(context.l10n.messageNoAccessBits, style: TextStyle(fontSize: 11, color: Colors.grey)), ]), ), @@ -1087,7 +1171,7 @@ class _DumpViewerPageState extends State const SizedBox(height: 12), // --- 数据块 ASCII - _analysisCard('📝 数据块 ASCII 视图', [ + _analysisCard(context.l10n.messageDataBlockAsciiView, [ for (final s in a.sectors) for (final b in s.blocks.where((x) => !x.isTrailer && !x.isEmpty)) Padding( @@ -1147,57 +1231,64 @@ class _DumpViewerPageState extends State // 格式转换 & 密钥提取视图 // ====================================================================== Widget _buildConverterView() { - if (_card == null) return const Center(child: Text('无数据')); + if (_card == null) return Center(child: Text(context.l10n.messageNoData)); return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ // ---- 一键导出全部格式 ---- - _analysisCard('📦 一键导出全部格式', [ - const Text('从当前打开的转储文件导出为所有支持的格式:', style: TextStyle(fontSize: 13)), + _analysisCard(context.l10n.messageExportAllFormats, [ + Text(context.l10n.messageExportTheCurrentDumpInAllSupported, + style: TextStyle(fontSize: 13)), const SizedBox(height: 12), Wrap(spacing: 8, runSpacing: 8, children: [ - _exportButton('导出 .eml', Icons.text_snippet, 'eml'), - _exportButton('导出 .bin', Icons.memory, 'bin'), - _exportButton('导出 .json', Icons.data_object, 'json'), - _exportButton('导出 .key.bin', Icons.key, 'key.bin'), - _exportButton('导出 .dic', Icons.list_alt, 'dic'), - _exportButton('导出 .keys.txt', Icons.text_fields, 'keys.txt'), + _exportButton( + context.l10n.messageExportEml, Icons.text_snippet, 'eml'), + _exportButton(context.l10n.messageExportBin, Icons.memory, 'bin'), + _exportButton( + context.l10n.messageExportJson, Icons.data_object, 'json'), + _exportButton( + context.l10n.messageExportKeyBin, Icons.key, 'key.bin'), + _exportButton(context.l10n.messageExportDic, Icons.list_alt, 'dic'), + _exportButton(context.l10n.messageExportKeysTxt, Icons.text_fields, + 'keys.txt'), ]), ]), const SizedBox(height: 16), // ---- 密钥提取 ---- - _analysisCard('🔑 密钥提取', [ - const Text('从当前转储中提取的所有密钥:', style: TextStyle(fontSize: 13)), + _analysisCard(context.l10n.messageKeyExtraction, [ + Text(context.l10n.messageAllKeysExtractedFromTheCurrentDump, + style: TextStyle(fontSize: 13)), const SizedBox(height: 8), _buildKeyTable(), const SizedBox(height: 12), - Row(children: [ + Wrap(runSpacing: 8, children: [ OutlinedButton.icon( onPressed: () => _copyKeysToClipboard(), icon: const Icon(Icons.copy, size: 16), - label: const Text('复制全部密钥'), + label: Text(context.l10n.messageCopyAllKeys), ), const SizedBox(width: 8), OutlinedButton.icon( onPressed: () => _copyKeysToClipboard(keyAOnly: true), icon: const Icon(Icons.copy, size: 16), - label: const Text('仅 Key A'), + label: Text(context.l10n.messageKeyAOnly), ), const SizedBox(width: 8), OutlinedButton.icon( onPressed: () => _copyKeysToClipboard(keyBOnly: true), icon: const Icon(Icons.copy, size: 16), - label: const Text('仅 Key B'), + label: Text(context.l10n.messageKeyBOnly), ), ]), ]), const SizedBox(height: 16), // ---- 独立格式转换 ---- - _analysisCard('🔄 文件格式转换', [ - const Text('选择任意dump/密钥文件, 转换为其他格式:', style: TextStyle(fontSize: 13)), + _analysisCard(context.l10n.messageFileFormatConversion, [ + Text(context.l10n.messageSelectADumpOrKeyFileTo, + style: TextStyle(fontSize: 13)), const SizedBox(height: 12), // Source file @@ -1205,7 +1296,7 @@ class _DumpViewerPageState extends State ElevatedButton.icon( onPressed: _pickConvertSource, icon: const Icon(Icons.file_open, size: 16), - label: const Text('选择源文件'), + label: Text(context.l10n.messageSelectSourceFile), ), const SizedBox(width: 8), if (_convertInput != null) @@ -1221,12 +1312,14 @@ class _DumpViewerPageState extends State // Target format if (_convertInput != null) ...[ - const Text('目标格式:', style: TextStyle(fontSize: 13)), + Text(context.l10n.messageTargetFormat, + style: TextStyle(fontSize: 13)), const SizedBox(height: 4), Wrap(spacing: 6, runSpacing: 6, children: [ for (final fmt in _getAvailableTargets()) ChoiceChip( - label: Text(fmt.label, style: const TextStyle(fontSize: 12)), + label: Text(fmt.localizedLabel(context.l10n), + style: const TextStyle(fontSize: 12)), selected: _convertTarget == fmt, onSelected: (sel) { if (sel) setState(() => _convertTarget = fmt); @@ -1243,7 +1336,9 @@ class _DumpViewerPageState extends State height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.swap_horiz, size: 16), - label: Text(_converting ? '转换中...' : '开始转换'), + label: Text(_converting + ? context.l10n.messageConverting + : context.l10n.messageStartConversion), ), ], @@ -1268,8 +1363,9 @@ class _DumpViewerPageState extends State // ---- 密钥比对 ----- if (_extractedKeys != null) - _analysisCard('📋 外部密钥文件', [ - const Text('已加载的外部密钥:', style: TextStyle(fontSize: 13)), + _analysisCard(context.l10n.messageExternalKeyFile, [ + Text(context.l10n.messageLoadedExternalKeys, + style: TextStyle(fontSize: 13)), const SizedBox(height: 8), _buildExternalKeyTable(), const SizedBox(height: 8), @@ -1280,13 +1376,15 @@ class _DumpViewerPageState extends State // ---- 格式说明 ---- const SizedBox(height: 16), - _analysisCard('ℹ️ 格式说明', [ - _fmtDesc('.bin', '原始二进制转储 (16字节/块), PM3默认dump格式'), - _fmtDesc('.eml', '文本转储 (每行32位十六进制), 用于模拟器加载'), - _fmtDesc('.json', 'PM3 JSON格式, 含卡片元数据和密钥'), - _fmtDesc('.key.bin', '二进制密钥文件 (6字节KeyA + 6字节KeyB) × 扇区数'), - _fmtDesc('.dic', '密钥字典 (每行一个密钥), 用于暴力破解'), - _fmtDesc('.keys.txt', '可读密钥表 (扇区号 + KeyA + KeyB)'), + _analysisCard(context.l10n.messageFormatReference, [ + _fmtDesc('.bin', context.l10n.messageRawBinaryDump16BytesPerBlock), + _fmtDesc('.eml', context.l10n.messageTextDump32HexDigitsPerLine), + _fmtDesc('.json', + context.l10n.messagePm3JsonFormatIncludingCardMetadataAnd), + _fmtDesc('.key.bin', context.l10n.messageBinaryKeyFile6ByteKeyA), + _fmtDesc('.dic', context.l10n.messageKeyDictionaryOneKeyPerLineUsed), + _fmtDesc('.keys.txt', + context.l10n.messageReadableKeyTableSectorNumberKeyA), ]), ]), ); @@ -1328,12 +1426,12 @@ class _DumpViewerPageState extends State 2: FlexColumnWidth(), }, children: [ - const TableRow( + TableRow( decoration: BoxDecoration(color: Color(0xFF2A2A3C)), children: [ Padding( padding: EdgeInsets.all(6), - child: Text('扇区', + child: Text(context.l10n.messageSector2, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 12))), Padding( @@ -1388,12 +1486,12 @@ class _DumpViewerPageState extends State 2: FlexColumnWidth(), }, children: [ - const TableRow( + TableRow( decoration: BoxDecoration(color: Color(0xFF2A2A3C)), children: [ Padding( padding: EdgeInsets.all(6), - child: Text('扇区', + child: Text(context.l10n.messageSector2, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 12))), Padding( @@ -1451,14 +1549,16 @@ class _DumpViewerPageState extends State } else if (keyBOnly) { buf.writeln(k.keyB.toUpperCase()); } else { - buf.writeln( - '扇区 ${i.toString().padLeft(2)} A:${k.keyA.toUpperCase()} B:${k.keyB.toUpperCase()}'); + buf.writeln(context.l10n.messageSectorAB( + (i.toString().padLeft(2)).toString(), + (k.keyA.toUpperCase()).toString(), + (k.keyB.toUpperCase()).toString())); } } Clipboard.setData(ClipboardData(text: buf.toString())); if (mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar(content: Text('已复制到剪贴板'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.copiedToClipboard))); } } @@ -1470,6 +1570,7 @@ class _DumpViewerPageState extends State } Future _pickConvertSource() async { + final l10n = context.l10n; final path = await FileDialogService.pickSingleFilePath( desktopTypeGroups: const [ fs.XTypeGroup( @@ -1478,7 +1579,7 @@ class _DumpViewerPageState extends State ), ], ); - if (path == null) return; + if (path == null || !mounted) return; setState(() { _convertInput = path; _convertInputFormat = path.split('.').last.toLowerCase(); @@ -1488,7 +1589,9 @@ class _DumpViewerPageState extends State // Also try to extract keys for display try { - final (keys, summary) = await extractKeysFromFile(path); + final (keys, summary) = + await extractKeysFromFile(path, localizations: l10n); + if (!mounted) return; setState(() { _extractedKeys = keys; _keySummary = summary; @@ -1500,6 +1603,7 @@ class _DumpViewerPageState extends State Future _doConvert() async { if (_convertInput == null || _convertTarget == null) return; + final l10n = context.l10n; setState(() { _converting = true; _convertStatus = null; @@ -1512,26 +1616,29 @@ class _DumpViewerPageState extends State final defaultName = '$baseName.${_convertTarget!.ext}'; final savePath = await FileDialogService.pickSaveFilePath( - dialogTitle: '保存转换文件', + dialogTitle: l10n.messageSaveConvertedFile, suggestedName: defaultName, ); + if (!mounted) return; if (savePath == null) { setState(() => _converting = false); return; } final result = await convertDumpFile( + localizations: l10n, inputPath: _convertInput!, outputPath: savePath, targetFormat: _convertTarget!, ); + if (!mounted) return; setState(() { _converting = false; if (result.success) { - _convertStatus = '✅ 转换成功!\n' - '输出: ${result.outputPath}\n' - '${result.keySummary ?? ''}'; + _convertStatus = context.l10n.messageConversionSuccessfulOutput( + (result.outputPath).toString(), + (result.keySummary ?? '').toString()); } else { _convertStatus = '❌ ${result.error}'; } @@ -1539,7 +1646,7 @@ class _DumpViewerPageState extends State } catch (e) { setState(() { _converting = false; - _convertStatus = '❌ 转换异常: $e'; + _convertStatus = context.l10n.messageConversionError((e).toString()); }); } } @@ -1586,7 +1693,8 @@ class _DumpViewerPageState extends State String normalize(String value, int length) { final text = value.replaceAll(RegExp(r'\s'), '').toUpperCase(); if (text.length != length || !RegExp(r'^[0-9A-F]+$').hasMatch(text)) { - throw FormatException('需要 $length 位十六进制字符'); + throw FormatException( + context.l10n.messageEnterHexDigits((length).toString())); } return text; } @@ -1602,8 +1710,9 @@ class _DumpViewerPageState extends State )); } } catch (e) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('修改未应用:$e'))); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: + Text(context.l10n.messageChangesNotApplied((e).toString())))); return false; } _card!.blocks = blocks; @@ -1619,7 +1728,7 @@ class _DumpViewerPageState extends State // 密钥编辑视图 // ====================================================================== Widget _buildKeyEditorView() { - if (_card == null) return const Center(child: Text('无数据')); + if (_card == null) return Center(child: Text(context.l10n.messageNoData)); return Row( children: [ @@ -1633,14 +1742,14 @@ class _DumpViewerPageState extends State children: [ // 操作栏 Row(children: [ - const Text('🔑 密钥编辑', + Text(context.l10n.messageKeyEditor, style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const Spacer(), OutlinedButton.icon( onPressed: _applyEdits, icon: const Icon(Icons.check, size: 16), - label: const Text('应用修改'), + label: Text(context.l10n.messageApplyChanges), ), const SizedBox(width: 8), OutlinedButton.icon( @@ -1652,7 +1761,7 @@ class _DumpViewerPageState extends State } }, icon: const Icon(Icons.restore, size: 16), - label: const Text('全部默认'), + label: Text(context.l10n.messageAllDefaults), ), ]), const SizedBox(height: 12), @@ -1667,12 +1776,12 @@ class _DumpViewerPageState extends State 2: FlexColumnWidth(), }, children: [ - const TableRow( + TableRow( decoration: BoxDecoration(color: Color(0xFF2A2A3C)), children: [ Padding( padding: EdgeInsets.all(8), - child: Text('扇区', + child: Text(context.l10n.messageSector2, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 12))), @@ -1761,11 +1870,11 @@ class _DumpViewerPageState extends State Padding( padding: const EdgeInsets.all(12), child: Row(children: [ - const Text('📝 块数据编辑', + Text(context.l10n.messageBlockDataEditor, style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const Spacer(), - Text('扇区 $_selectedSector', + Text(context.l10n.messageSector((_selectedSector).toString()), style: TextStyle(fontSize: 13, color: Colors.grey[400])), ]), ), @@ -1821,13 +1930,13 @@ class _DumpViewerPageState extends State final isTrailer = block == trailerIdx; final isBlock0 = block == 0 && sector == 0; - String label = '数据'; + String label = context.l10n.messageData2; Color labelColor = Colors.grey; if (isBlock0) { - label = '制造商'; + label = context.l10n.messageManufacturer; labelColor = Colors.blue; } else if (isTrailer) { - label = '尾块'; + label = context.l10n.messageTrailer; labelColor = Colors.orange; } @@ -1898,7 +2007,9 @@ class _DumpViewerPageState extends State // 回写/清空 (CUID) 视图 // ====================================================================== Widget _buildWriteBackView() { - if (_card == null) return const Center(child: Text('请先打开转储文件')); + if (_card == null) { + return Center(child: Text(context.l10n.messageOpenADumpFileFirst)); + } final appState = context.watch(); final isConnected = appState.isConnected; final progress = appState.writeProgress; @@ -1911,23 +2022,18 @@ class _DumpViewerPageState extends State child: Padding( padding: const EdgeInsets.all(12), child: Column(children: [ - const Row(children: [ + Row(children: [ Icon(Icons.info_outline, color: Colors.blue), SizedBox(width: 8), Expanded( - child: Text('CUID 卡回写 & 清空', + child: Text(context.l10n.messageCuidCardWritingAndClearing, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), ), ]), const SizedBox(height: 8), - const Text( - 'CUID(Gen2)卡没有后门命令,必须使用已知密钥认证后逐块写入。\n' - '• 整卡清空:使用卡片当前密钥认证,将数据块写为全 0\n' - '• 整卡回写:使用目标卡密钥认证,将 Dump 数据逐块写入\n' - '• 回写扇区:仅写入当前选中的单个扇区\n' - '• Gen1A 后门恢复:使用 hf mf restore 后门命令整卡恢复(仅 Gen1A 卡)\n\n' - '⚠️ CUID 卡写入块 0 会自动加 --force 参数', + Text( + context.l10n.messageCuidGen2CardsHaveNoBackdoorCommands, style: TextStyle(fontSize: 13), ), ]), @@ -1943,10 +2049,10 @@ class _DumpViewerPageState extends State color: Colors.red.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), - child: const Row(children: [ + child: Row(children: [ Icon(Icons.warning, color: Colors.redAccent), SizedBox(width: 8), - Text('请先连接 PM3 设备', + Text(context.l10n.messageConnectYourPm3DeviceFirst, style: TextStyle(color: Colors.redAccent, fontSize: 14)), ]), ), @@ -1959,12 +2065,12 @@ class _DumpViewerPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('⚙️ 写入选项', + Text(context.l10n.messageWriteOptions, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), const SizedBox(height: 8), Row(children: [ - const Text('认证密钥类型:'), + Text(context.l10n.messageAuthenticationKeyType), const SizedBox(width: 8), SegmentedButton( segments: const [ @@ -1978,9 +2084,10 @@ class _DumpViewerPageState extends State ]), const SizedBox(height: 8), CheckboxListTile( - title: const Text('跳过块 0 (制造商块)', + title: Text(context.l10n.messageSkipBlock0ManufacturerBlock, style: TextStyle(fontSize: 13)), - subtitle: const Text('除非是魔术卡,否则块 0 无法写入', + subtitle: Text( + context.l10n.messageBlock0CannotBeWrittenUnlessThis, style: TextStyle(fontSize: 11)), value: _skipBlock0, onChanged: (v) => setState(() => _skipBlock0 = v ?? true), @@ -1988,9 +2095,10 @@ class _DumpViewerPageState extends State contentPadding: EdgeInsets.zero, ), CheckboxListTile( - title: const Text('写入尾块 (密钥+访问控制)', + title: Text(context.l10n.messageWriteTrailersKeysAccessBits, style: TextStyle(fontSize: 13)), - subtitle: const Text('将 Dump 中的尾块数据写入目标卡', + subtitle: Text( + context.l10n.messageWriteTheDumpTrailerDataToThe, style: TextStyle(fontSize: 11)), value: _writeTrailers, onChanged: (v) => setState(() => _writeTrailers = v ?? true), @@ -2010,7 +2118,7 @@ class _DumpViewerPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('🔑 Dump 密钥 (用于认证)', + Text(context.l10n.messageDumpKeysForAuthentication, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), const SizedBox(height: 8), @@ -2033,49 +2141,50 @@ class _DumpViewerPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('🚀 一键智能回写', + Text(context.l10n.messageSmartWrite, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), const SizedBox(height: 8), - const Text( - '按目标卡类型选择最佳回写路径(可用于 UID/CUID/FUID/Gen1A/Gen3/Gen4)。', + Text( + context.l10n.messageSelectTheBestWritePathForThe, style: TextStyle(fontSize: 12), ), const SizedBox(height: 8), DropdownButtonFormField<_QuickWriteMode>( initialValue: _quickWriteMode, style: const TextStyle(fontSize: 13), - decoration: const InputDecoration( - labelText: '目标卡类型/模式', + decoration: InputDecoration( + labelText: context.l10n.messageTargetCardTypeMode, border: OutlineInputBorder(), isDense: true, contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10), ), - items: const [ + items: [ DropdownMenuItem( value: _QuickWriteMode.auto, - child: - Text('AUTO (默认)', style: TextStyle(fontSize: 13))), + child: Text(context.l10n.messageAutoDefault, + style: TextStyle(fontSize: 13))), DropdownMenuItem( value: _QuickWriteMode.uid, - child: Text('UID 普通卡', style: TextStyle(fontSize: 13))), + child: Text(context.l10n.messageStandardUidCard, + style: TextStyle(fontSize: 13))), DropdownMenuItem( value: _QuickWriteMode.cuid, child: Text('CUID / Gen2', style: TextStyle(fontSize: 13))), DropdownMenuItem( value: _QuickWriteMode.fuid, - child: Text('FUID(一次性 UID 可改)', + child: Text(context.l10n.messageFuidUidCanBeChangedOnce, style: TextStyle(fontSize: 13))), DropdownMenuItem( value: _QuickWriteMode.gen1a, - child: - Text('Gen1A 后门卡', style: TextStyle(fontSize: 13))), + child: Text(context.l10n.messageGen1aBackdoorCard, + style: TextStyle(fontSize: 13))), DropdownMenuItem( value: _QuickWriteMode.gen3, - child: - Text('Gen3 魔术卡', style: TextStyle(fontSize: 13))), + child: Text(context.l10n.messageGen3MagicCard, + style: TextStyle(fontSize: 13))), DropdownMenuItem( value: _QuickWriteMode.gen4, child: @@ -2089,9 +2198,9 @@ class _DumpViewerPageState extends State const SizedBox(height: 8), TextFormField( initialValue: _gen4Pwd, - decoration: const InputDecoration( - labelText: 'Gen4 密码(可选)', - hintText: '8 hex,例如 00000000', + decoration: InputDecoration( + labelText: context.l10n.messageGen4PasswordOptional, + hintText: context.l10n.message8HexDigitsEG00000000, border: OutlineInputBorder(), isDense: true, contentPadding: @@ -2110,8 +2219,8 @@ class _DumpViewerPageState extends State ? () => _executeQuickWriteBack(appState) : null, icon: const Icon(Icons.flash_on, size: 18), - label: - const Text('⚡ 一键智能回写', style: TextStyle(fontSize: 13)), + label: Text(context.l10n.messageSmartWrite2, + style: TextStyle(fontSize: 13)), style: ElevatedButton.styleFrom( backgroundColor: Colors.deepPurple, foregroundColor: Colors.white, @@ -2129,14 +2238,12 @@ class _DumpViewerPageState extends State // CUID 逐块清空 Expanded( child: Tooltip( - message: '使用 Dump 中的密钥认证,将所有数据块写为全 0\n' - '尾块恢复为默认密钥和访问控制位\n' - '适用于 CUID / Gen2 卡(无后门)', + message: context.l10n.messageAuthenticateWithTheDumpKeysAndZero, child: ElevatedButton.icon( onPressed: isConnected ? () => _showCuidClearDialog(appState) : null, icon: const Icon(Icons.delete_sweep), - label: const Text('CUID 整卡清空'), + label: Text(context.l10n.messageClearEntireCuidCard), style: ElevatedButton.styleFrom( backgroundColor: Colors.orange, foregroundColor: Colors.white, @@ -2149,14 +2256,12 @@ class _DumpViewerPageState extends State // CUID 逐块回写 Expanded( child: Tooltip( - message: '使用目标卡密钥认证,将 Dump 数据逐块写入\n' - '适用于 CUID / Gen2 卡(无后门)\n' - '需要已知目标卡的密钥才能写入', + message: context.l10n.messageAuthenticateWithTheTargetCardKeysAnd, child: ElevatedButton.icon( onPressed: isConnected ? () => _showCuidWriteDialog(appState) : null, icon: const Icon(Icons.upload), - label: const Text('CUID 整卡回写'), + label: Text(context.l10n.messageWriteEntireCuidCard), style: ElevatedButton.styleFrom( backgroundColor: Colors.blue, foregroundColor: Colors.white, @@ -2172,14 +2277,15 @@ class _DumpViewerPageState extends State Row(children: [ Expanded( child: Tooltip( - message: '仅回写当前选中的扇区 $_selectedSector\n' - '使用 Dump 密钥认证,逐块写入该扇区\n' - '适合修改单个扇区后的局部更新', + message: context.l10n + .messageWriteOnlySelectedSectorAuthenticateWithThe( + (_selectedSector).toString()), child: OutlinedButton.icon( onPressed: isConnected ? () => _showSectorWriteDialog(appState) : null, icon: const Icon(Icons.edit_note), - label: Text('回写扇区 $_selectedSector'), + label: Text(context.l10n + .messageWriteSector((_selectedSector).toString())), ), ), ), @@ -2187,9 +2293,7 @@ class _DumpViewerPageState extends State // hf mf restore (Gen1A 后门回写) Expanded( child: Tooltip( - message: '使用 PM3 的 hf mf restore 命令整卡恢复\n' - '仅适用于 Gen1A 后门卡,无需密钥\n' - '通过 0x40/0x43 后门指令直接写入所有块', + message: context.l10n.messageRestoreTheEntireCardUsingThePm3, child: OutlinedButton.icon( onPressed: isConnected ? () { @@ -2199,7 +2303,7 @@ class _DumpViewerPageState extends State } : null, icon: const Icon(Icons.restore), - label: const Text('Gen1A 后门恢复'), + label: Text(context.l10n.messageGen1aBackdoorRestore), ), ), ), @@ -2215,7 +2319,7 @@ class _DumpViewerPageState extends State crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ - const Text('📊 写入进度', + Text(context.l10n.messageWriteProgress, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 14)), const Spacer(), @@ -2223,7 +2327,7 @@ class _DumpViewerPageState extends State TextButton.icon( onPressed: () => appState.cancelWriteSequence(), icon: const Icon(Icons.cancel, size: 16), - label: const Text('取消'), + label: Text(context.l10n.messageCancel), style: TextButton.styleFrom( foregroundColor: Colors.redAccent), ), @@ -2240,7 +2344,7 @@ class _DumpViewerPageState extends State style: const TextStyle(fontFamily: 'monospace', fontSize: 13), ), - Text(progress.currentStatus, + Text(progress.localizedStatus(context.l10n), style: TextStyle(fontSize: 12, color: Colors.grey[400])), const SizedBox(height: 8), // 逐块结果列表 @@ -2261,11 +2365,13 @@ class _DumpViewerPageState extends State : Colors.redAccent, ), const SizedBox(width: 6), - Text('块 ${r.block.toString().padLeft(3)}', + Text( + context.l10n.messageBlock2( + (r.block.toString().padLeft(3)).toString()), style: const TextStyle( fontFamily: 'monospace', fontSize: 12)), const SizedBox(width: 8), - Text(r.message, + Text(r.localizedMessage(context.l10n), style: TextStyle( fontSize: 11, color: r.success @@ -2285,7 +2391,7 @@ class _DumpViewerPageState extends State const SizedBox(height: 16), // ---- 格式转换 (折叠到这里) ---- ExpansionTile( - title: const Text('📦 格式转换 & 密钥提取', + title: Text(context.l10n.messageFormatConversionAndKeyExtraction, style: TextStyle(fontWeight: FontWeight.bold)), children: [ Padding( @@ -2299,7 +2405,9 @@ class _DumpViewerPageState extends State } Future _prepareTempDumpBin(AppState appState) async { - if (!_applyEdits()) throw const FormatException('编辑数据不完整'); + if (!_applyEdits()) { + throw FormatException(context.l10n.messageEditedDataIsIncomplete); + } final file = File( '${Directory.systemTemp.path}/pm3gui-quick-write-${DateTime.now().millisecondsSinceEpoch}.bin', ); @@ -2333,8 +2441,9 @@ class _DumpViewerPageState extends State _card!.uid.toUpperCase().replaceAll(RegExp(r'[^0-9A-F]'), ''); if (uid.isEmpty) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('未找到有效 UID,无法执行 UID 写入'))); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: + Text(context.l10n.messageNoValidUidFoundCannotWriteUid))); } return; } @@ -2440,24 +2549,22 @@ class _DumpViewerPageState extends State showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('CUID 逐块清空'), + title: Text(context.l10n.messageClearCuidBlockByBlock), content: SizedBox( width: 400, child: Column(mainAxisSize: MainAxisSize.min, children: [ - const Text( - '将使用 Dump 中的密钥对目标卡进行认证,\n' - '然后将每个数据块写为全 0。\n\n' - '尾块将被重置为默认值:\n' - 'Key A = FFFFFFFFFFFF\n' - '访问控制 = FF078069\n' - 'Key B = FFFFFFFFFFFF\n\n' - '⚠️ 此操作不可逆!', + Text( + context.l10n.messageAuthenticateTheTargetCardWithTheDump, style: TextStyle(fontSize: 13), ), const SizedBox(height: 12), Text( - '将清空 ${_card!.cardType.blockCount} 块 ' - '(${_skipBlock0 ? "跳过块0" : "含块0"})', + context.l10n.messageBlocksToClear( + (_card!.cardType.blockCount).toString(), + (_skipBlock0 + ? context.l10n.messageSkipBlock0 + : context.l10n.messageIncludeBlock0) + .toString()), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), ), ]), @@ -2465,7 +2572,7 @@ class _DumpViewerPageState extends State actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('取消'), + child: Text(context.l10n.messageCancel), ), ElevatedButton( onPressed: () { @@ -2473,7 +2580,8 @@ class _DumpViewerPageState extends State _executeCuidClear(appState); }, style: ElevatedButton.styleFrom(backgroundColor: Colors.orange), - child: const Text('开始清空', style: TextStyle(color: Colors.white)), + child: Text(context.l10n.messageStartClearing, + style: TextStyle(color: Colors.white)), ), ], ), @@ -2487,25 +2595,26 @@ class _DumpViewerPageState extends State showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('CUID 逐块回写'), + title: Text(context.l10n.messageWriteCuidBlockByBlock), content: SizedBox( width: 400, child: Column(mainAxisSize: MainAxisSize.min, children: [ - const Text( - '将使用 Dump 中的密钥对目标卡进行认证,\n' - '然后将 Dump 数据逐块写入目标卡。\n\n' - '✅ 数据块: 写入 Dump 中的原始数据\n' - '✅ 尾块: 写入 Dump 中的完整尾块数据\n' - ' (包含 Key A + 访问控制 + Key B)\n\n' - '💡 使用场景: CUID/普通卡的密钥已知,\n' - ' 需要覆盖写入新数据。', + Text( + context.l10n.messageAuthenticateTheTargetCardWithTheDump2, style: TextStyle(fontSize: 13), ), const SizedBox(height: 12), Text( - '将写入 ${_card!.cardType.blockCount} 块 ' - '(${_skipBlock0 ? "跳过块0" : "含块0"}, ' - '${_writeTrailers ? "含尾块" : "跳过尾块"})', + context.l10n.messageBlocksToWrite( + (_card!.cardType.blockCount).toString(), + (_skipBlock0 + ? context.l10n.messageSkipBlock0 + : context.l10n.messageIncludeBlock0) + .toString(), + (_writeTrailers + ? context.l10n.messageIncludeTrailers + : context.l10n.messageSkipTrailers) + .toString()), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), ), ]), @@ -2513,14 +2622,14 @@ class _DumpViewerPageState extends State actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('取消'), + child: Text(context.l10n.messageCancel), ), ElevatedButton( onPressed: () { Navigator.pop(ctx); _executeCuidWrite(appState); }, - child: const Text('开始回写'), + child: Text(context.l10n.messageStartWriting), ), ], ), @@ -2534,24 +2643,28 @@ class _DumpViewerPageState extends State showDialog( context: context, builder: (ctx) => AlertDialog( - title: Text('回写扇区 $_selectedSector'), + title: + Text(context.l10n.messageWriteSector((_selectedSector).toString())), content: Text( - '将使用 Dump 中的密钥认证,\n' - '回写扇区 $_selectedSector 的所有块到目标卡。\n\n' - '${_writeTrailers ? "包含尾块" : "不含尾块"} ' - '认证密钥: Key $_writeKeyType', + context.l10n.messageAuthenticateWithTheDumpKeysThenWrite( + (_selectedSector).toString(), + (_writeTrailers + ? context.l10n.messageIncludeTrailers2 + : context.l10n.messageExcludeTrailers) + .toString(), + (_writeKeyType).toString()), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('取消'), + child: Text(context.l10n.messageCancel), ), ElevatedButton( onPressed: () { Navigator.pop(ctx); _executeSectorWrite(appState, _selectedSector); }, - child: const Text('开始回写'), + child: Text(context.l10n.messageStartWriting), ), ], ), diff --git a/lib/ui/pages/hf_14b_page.dart b/lib/ui/pages/hf_14b_page.dart index 14ca377..3b739d0 100644 --- a/lib/ui/pages/hf_14b_page.dart +++ b/lib/ui/pages/hf_14b_page.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class Hf14bPage extends StatefulWidget { const Hf14bPage({super.key}); @@ -64,10 +65,10 @@ class _Hf14bPageState extends State @override Widget build(BuildContext context) { return Column(children: [ - TabBar(controller: _tabController, tabs: const [ - Tab(text: '信息'), - Tab(text: '读写'), - Tab(text: '工具'), + TabBar(controller: _tabController, tabs: [ + Tab(text: context.l10n.messageInformation), + Tab(text: context.l10n.messageReadWrite), + Tab(text: context.l10n.navTools), ]), Expanded( child: TabBarView(controller: _tabController, children: [ @@ -84,28 +85,28 @@ class _Hf14bPageState extends State crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '读取器', - subtitle: '读取 14443-B 卡片', + title: context.l10n.messageReader, + subtitle: context.l10n.messageReadA14443BCard, icon: Icons.nfc, onTap: () => _execute(Hf14bCmd.reader())), ActionCard( - title: '标签信息', - subtitle: '读取标签详情', + title: context.l10n.messageTagInformation, + subtitle: context.l10n.messageReadTagDetails, icon: Icons.info_outline, onTap: () => _execute(Hf14bCmd.info())), ActionCard( - title: '嗅探', - subtitle: '捕获 14443-B 通信', + title: context.l10n.messageSniff, + subtitle: context.l10n.messageCapture14443BCommunication, icon: Icons.hearing, onTap: () => _execute(Hf14bCmd.sniff())), ActionCard( - title: '转储', - subtitle: '读取全部数据', + title: context.l10n.messageDump, + subtitle: context.l10n.messageReadAllData, icon: Icons.download, onTap: () => _execute(Hf14bCmd.dump())), ActionCard( title: 'NDEF', - subtitle: '读取 NDEF 数据', + subtitle: context.l10n.messageReadNdefData, icon: Icons.article, onTap: () => _execute(Hf14bCmd.ndefRead())), ], @@ -131,11 +132,12 @@ class _Hf14bPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('块操作', + Text(context.l10n.messageBlockOperations, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), TextFormField( - decoration: const InputDecoration(labelText: '块号'), + decoration: InputDecoration( + labelText: context.l10n.messageBlockNumber), keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (v) => _blockNumber = int.tryParse(v) ?? 0, @@ -147,11 +149,11 @@ class _Hf14bPageState extends State onPressed: () => _execute(Hf14bCmd.rdbl(_blockNumber)), icon: const Icon(Icons.visibility, size: 18), - label: const Text('读取块')), + label: Text(context.l10n.messageReadBlock)), ), const SizedBox(height: 12), HexInputField( - label: '写入数据 (hex)', + label: context.l10n.messageWriteDataHex, byteLength: 4, onChanged: (v) => _blockData = v), const SizedBox(height: 8), @@ -161,7 +163,7 @@ class _Hf14bPageState extends State Hf14bCmd.wrbl(_blockNumber, _blockData)) : null, icon: const Icon(Icons.edit, size: 18), - label: const Text('写入块')), + label: Text(context.l10n.messageWriteBlock)), ], ))), const SizedBox(height: 12), @@ -171,18 +173,19 @@ class _Hf14bPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('裸命令', + Text(context.l10n.messageRawCommand, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), HexInputField( - label: '原始 hex 数据', onChanged: (v) => _rawHex = v), + label: context.l10n.messageRawHexData, + onChanged: (v) => _rawHex = v), const SizedBox(height: 8), ElevatedButton.icon( onPressed: _rawHex.isNotEmpty ? () => _execute(Hf14bCmd.raw(_rawHex)) : null, icon: const Icon(Icons.send, size: 18), - label: const Text('发送')), + label: Text(context.l10n.terminalSend)), ], ))), ]), @@ -202,8 +205,8 @@ class _Hf14bPageState extends State sideWidth: 280, side: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '模拟卡片', - subtitle: '模拟 14443-B 标签', + title: context.l10n.messageSimulateCard, + subtitle: context.l10n.messageSimulateA14443BTag, icon: Icons.play_arrow, onTap: () => _execute(Hf14bCmd.sim())), ]), diff --git a/lib/ui/pages/hf_15_page.dart b/lib/ui/pages/hf_15_page.dart index b8b4436..f0f5aeb 100644 --- a/lib/ui/pages/hf_15_page.dart +++ b/lib/ui/pages/hf_15_page.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class Hf15Page extends StatefulWidget { const Hf15Page({super.key}); @@ -64,10 +65,10 @@ class _Hf15PageState extends State @override Widget build(BuildContext context) { return Column(children: [ - TabBar(controller: _tabController, tabs: const [ - Tab(text: '信息'), - Tab(text: '读写'), - Tab(text: '工具'), + TabBar(controller: _tabController, tabs: [ + Tab(text: context.l10n.messageInformation), + Tab(text: context.l10n.messageReadWrite), + Tab(text: context.l10n.navTools), ]), Expanded( child: TabBarView(controller: _tabController, children: [ @@ -84,26 +85,28 @@ class _Hf15PageState extends State crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '读取器', - subtitle: '自动读取 ISO15693', + title: context.l10n.messageReader, + subtitle: context.l10n.messageAutomaticallyReadIso15693, icon: Icons.nfc, onTap: () => _execute(Hf15Cmd.reader())), ActionCard( - title: '标签信息', - subtitle: '读取标签基本信息', + title: context.l10n.messageTagInformation, + subtitle: context.l10n.messageReadBasicTagInformation, icon: Icons.info_outline, onTap: () => _execute(Hf15Cmd.info())), ActionCard( - title: '转储卡片', - subtitle: '读取全部数据', + title: context.l10n.messageDumpCard, + subtitle: context.l10n.messageReadAllData, icon: Icons.download, onTap: () => _execute(Hf15Cmd.dump())), ActionCard( - title: '擦除卡片', - subtitle: '清空卡片数据', + title: context.l10n.messageEraseCard, + subtitle: context.l10n.messageClearCardData, icon: Icons.delete_forever, - onTap: () => - _confirmThenExecute('确认擦除', '此操作不可恢复!', Hf15Cmd.wipe())), + onTap: () => _confirmThenExecute( + context.l10n.messageConfirmErase, + context.l10n.messageThisOperationCannotBeUndone, + Hf15Cmd.wipe())), ], ), main: ResultDisplay( @@ -127,11 +130,12 @@ class _Hf15PageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('块操作', + Text(context.l10n.messageBlockOperations, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), TextFormField( - decoration: const InputDecoration(labelText: '块号'), + decoration: InputDecoration( + labelText: context.l10n.messageBlockNumber), keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (v) => _blockNumber = int.tryParse(v) ?? 0, @@ -142,11 +146,11 @@ class _Hf15PageState extends State child: ElevatedButton.icon( onPressed: () => _execute(Hf15Cmd.rdbl(_blockNumber)), icon: const Icon(Icons.visibility, size: 18), - label: const Text('读取块')), + label: Text(context.l10n.messageReadBlock)), ), const SizedBox(height: 12), HexInputField( - label: '写入数据 (4 字节 hex)', + label: context.l10n.messageWriteData4ByteHex, byteLength: 4, onChanged: (v) => _blockData = v), const SizedBox(height: 8), @@ -156,7 +160,7 @@ class _Hf15PageState extends State _execute(Hf15Cmd.wrbl(_blockNumber, _blockData)) : null, icon: const Icon(Icons.edit, size: 18), - label: const Text('写入块')), + label: Text(context.l10n.messageWriteBlock)), ], ))), ]), @@ -176,18 +180,18 @@ class _Hf15PageState extends State sideWidth: 320, side: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '查找 AFI', - subtitle: '扫描应用族标识符', + title: context.l10n.messageFindAfi, + subtitle: context.l10n.messageScanApplicationFamilyIdentifiers, icon: Icons.search, onTap: () => _execute(Hf15Cmd.findafi())), ActionCard( - title: '模拟卡片', - subtitle: '模拟 ISO15693 标签', + title: context.l10n.messageSimulateCard, + subtitle: context.l10n.messageSimulateAnIso15693Tag, icon: Icons.play_arrow, onTap: () => _execute(Hf15Cmd.sim())), ActionCard( - title: '嗅探通信', - subtitle: '捕获读卡器通信', + title: context.l10n.messageSniffCommunication, + subtitle: context.l10n.messageCaptureReaderCommunication, icon: Icons.hearing, onTap: () => _execute(Hf15Cmd.sniff())), const Divider(), @@ -197,11 +201,11 @@ class _Hf15PageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('设置 UID', + Text(context.l10n.messageSetUid, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), HexInputField( - label: 'UID (8 字节 hex)', + label: context.l10n.messageUid8ByteHex, byteLength: 8, onChanged: (v) => _uid = v, prefixIcon: Icons.credit_card), @@ -211,7 +215,7 @@ class _Hf15PageState extends State ? () => _execute(Hf15Cmd.csetuid(_uid)) : null, icon: const Icon(Icons.edit, size: 18), - label: const Text('设置 UID')), + label: Text(context.l10n.messageSetUid)), ], ))), ]), @@ -235,13 +239,13 @@ class _Hf15PageState extends State actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('取消')), + child: Text(context.l10n.messageCancel)), TextButton( onPressed: () { Navigator.pop(context); _execute(cmd); }, - child: const Text('确认')), + child: Text(context.l10n.messageConfirm)), ], )); } diff --git a/lib/ui/pages/hf_emv_page.dart b/lib/ui/pages/hf_emv_page.dart index 334c14f..055dcc8 100644 --- a/lib/ui/pages/hf_emv_page.dart +++ b/lib/ui/pages/hf_emv_page.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class HfEmvPage extends StatefulWidget { const HfEmvPage({super.key}); @@ -53,23 +54,23 @@ class _HfEmvPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '搜索', - subtitle: '搜索 EMV 卡片', + title: context.l10n.messageSearch, + subtitle: context.l10n.messageSearchForEmvCards, icon: Icons.search, onTap: () => _execute(HfEmvCmd.search())), ActionCard( title: 'PPSE', - subtitle: '选择 PPSE 应用', + subtitle: context.l10n.messageSelectPpseApplication, icon: Icons.payment, onTap: () => _execute(HfEmvCmd.ppse())), ActionCard( - title: '执行交易', - subtitle: '模拟 EMV 交易流程', + title: context.l10n.messageExecuteTransaction, + subtitle: context.l10n.messageSimulateTheEmvTransactionFlow, icon: Icons.receipt_long, onTap: () => _execute(HfEmvCmd.exec())), ActionCard( - title: '测试', - subtitle: 'EMV 自检', + title: context.l10n.messageTest, + subtitle: context.l10n.messageEmvSelfTest, icon: Icons.check_circle, onTap: () => _execute(HfEmvCmd.test())), ], diff --git a/lib/ui/pages/hf_felica_page.dart b/lib/ui/pages/hf_felica_page.dart index 412a3b9..93421d6 100644 --- a/lib/ui/pages/hf_felica_page.dart +++ b/lib/ui/pages/hf_felica_page.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class HfFelicaPage extends StatefulWidget { const HfFelicaPage({super.key}); @@ -63,9 +64,9 @@ class _HfFelicaPageState extends State @override Widget build(BuildContext context) { return Column(children: [ - TabBar(controller: _tabController, tabs: const [ - Tab(text: '信息'), - Tab(text: '读写'), + TabBar(controller: _tabController, tabs: [ + Tab(text: context.l10n.messageInformation), + Tab(text: context.l10n.messageReadWrite), ]), Expanded( child: TabBarView(controller: _tabController, children: [ @@ -81,28 +82,28 @@ class _HfFelicaPageState extends State crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '读取器', - subtitle: '读取 FeliCa 卡片', + title: context.l10n.messageReader, + subtitle: context.l10n.messageReadAFelicaCard, icon: Icons.nfc, onTap: () => _execute(HfFelicaCmd.reader())), ActionCard( - title: '标签信息', - subtitle: '获取 FeliCa 详情', + title: context.l10n.messageTagInformation, + subtitle: context.l10n.messageGetFelicaDetails, icon: Icons.info_outline, onTap: () => _execute(HfFelicaCmd.info())), ActionCard( - title: '转储', - subtitle: '转储卡片数据', + title: context.l10n.messageDump, + subtitle: context.l10n.messageDumpCardData, icon: Icons.download, onTap: () => _execute(HfFelicaCmd.dump())), ActionCard( - title: 'Lite 转储', - subtitle: '转储 FeliCa Lite', + title: context.l10n.messageLiteDump, + subtitle: context.l10n.messageDumpFelicaLite, icon: Icons.download_for_offline, onTap: () => _execute(HfFelicaCmd.litedump())), ActionCard( - title: '嗅探', - subtitle: '捕获 FeliCa 通信', + title: context.l10n.messageSniff, + subtitle: context.l10n.messageCaptureFelicaCommunication, icon: Icons.hearing, onTap: () => _execute(HfFelicaCmd.sniff())), ], @@ -128,17 +129,19 @@ class _HfFelicaPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('块操作', + Text(context.l10n.messageBlockOperations, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), Row(children: [ Expanded( child: HexInputField( - label: 'SC (服务代码)', onChanged: (v) => _sc = v)), + label: context.l10n.messageScServiceCode, + onChanged: (v) => _sc = v)), const SizedBox(width: 8), Expanded( child: HexInputField( - label: 'BL (块号)', onChanged: (v) => _bl = v)), + label: context.l10n.messageBlBlockNumber, + onChanged: (v) => _bl = v)), ]), const SizedBox(height: 8), ElevatedButton.icon( @@ -146,10 +149,11 @@ class _HfFelicaPageState extends State ? () => _execute(HfFelicaCmd.rdbl(_sc, _bl)) : null, icon: const Icon(Icons.visibility, size: 18), - label: const Text('读取块')), + label: Text(context.l10n.messageReadBlock)), const SizedBox(height: 12), HexInputField( - label: '写入数据 (hex)', onChanged: (v) => _blockData = v), + label: context.l10n.messageWriteDataHex, + onChanged: (v) => _blockData = v), const SizedBox(height: 8), ElevatedButton.icon( onPressed: _blockData.isNotEmpty && @@ -159,7 +163,7 @@ class _HfFelicaPageState extends State _execute(HfFelicaCmd.wrbl(_sc, _bl, _blockData)) : null, icon: const Icon(Icons.edit, size: 18), - label: const Text('写入块')), + label: Text(context.l10n.messageWriteBlock)), ], ))), const SizedBox(height: 12), @@ -169,18 +173,19 @@ class _HfFelicaPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('裸命令', + Text(context.l10n.messageRawCommand, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), HexInputField( - label: '原始 hex 数据', onChanged: (v) => _rawHex = v), + label: context.l10n.messageRawHexData, + onChanged: (v) => _rawHex = v), const SizedBox(height: 8), ElevatedButton.icon( onPressed: _rawHex.isNotEmpty ? () => _execute(HfFelicaCmd.raw(_rawHex)) : null, icon: const Icon(Icons.send, size: 18), - label: const Text('发送')), + label: Text(context.l10n.terminalSend)), ], ))), ]), diff --git a/lib/ui/pages/hf_fido_page.dart b/lib/ui/pages/hf_fido_page.dart index db0accb..c8b32b8 100644 --- a/lib/ui/pages/hf_fido_page.dart +++ b/lib/ui/pages/hf_fido_page.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class HfFidoPage extends StatefulWidget { const HfFidoPage({super.key}); @@ -53,23 +54,23 @@ class _HfFidoPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '信息', - subtitle: '读取 FIDO 信息', + title: context.l10n.messageInformation, + subtitle: context.l10n.messageReadFidoInformation, icon: Icons.fingerprint, onTap: () => _execute(HfFidoCmd.info())), ActionCard( - title: '注册', - subtitle: 'FIDO2 注册', + title: context.l10n.messageRegister, + subtitle: context.l10n.messageFido2Registration, icon: Icons.app_registration, onTap: () => _execute(HfFidoCmd.reg())), ActionCard( - title: '认证', - subtitle: 'FIDO2 认证', + title: context.l10n.messageAuthenticate, + subtitle: context.l10n.messageFido2Authentication, icon: Icons.verified_user, onTap: () => _execute(HfFidoCmd.auth())), ActionCard( - title: '列表', - subtitle: '列出 FIDO 通信', + title: context.l10n.messageList, + subtitle: context.l10n.messageListFidoCommunication, icon: Icons.list_alt, onTap: () => _execute('hf fido list')), ], diff --git a/lib/ui/pages/hf_iclass_page.dart b/lib/ui/pages/hf_iclass_page.dart index e8f6f07..13b94b5 100644 --- a/lib/ui/pages/hf_iclass_page.dart +++ b/lib/ui/pages/hf_iclass_page.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class HfIclassPage extends StatefulWidget { const HfIclassPage({super.key}); @@ -66,11 +67,11 @@ class _HfIclassPageState extends State Widget build(BuildContext context) { return Column( children: [ - TabBar(controller: _tabController, tabs: const [ - Tab(text: '信息'), - Tab(text: '读写'), - Tab(text: '破解'), - Tab(text: '模拟器'), + TabBar(controller: _tabController, tabs: [ + Tab(text: context.l10n.messageInformation), + Tab(text: context.l10n.messageReadWrite), + Tab(text: context.l10n.messageKeyRecovery), + Tab(text: context.l10n.messageEmulator), ]), Expanded( child: TabBarView(controller: _tabController, children: [ @@ -90,18 +91,18 @@ class _HfIclassPageState extends State crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '获取信息', - subtitle: '读取 iCLASS 标签', + title: context.l10n.messageGetInformation, + subtitle: context.l10n.messageReadAnIclassTag, icon: Icons.info_outline, onTap: () => _execute(HfIclassCmd.info())), ActionCard( - title: '读取卡片', - subtitle: 'reader 模式', + title: context.l10n.messageReadCard, + subtitle: context.l10n.messageReaderMode, icon: Icons.nfc, onTap: () => _execute(HfIclassCmd.reader())), ActionCard( - title: '转储卡片', - subtitle: '转储全部块到文件', + title: context.l10n.messageDumpCard, + subtitle: context.l10n.messageDumpAllBlocksToAFile, icon: Icons.download, onTap: () => _execute(HfIclassCmd.dump(key: _key.isEmpty ? null : _key))), @@ -129,17 +130,18 @@ class _HfIclassPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('块操作', + Text(context.l10n.messageBlockOperations, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), HexInputField( - label: '密钥 (8 字节 hex)', + label: context.l10n.messageKey8ByteHex, byteLength: 8, onChanged: (v) => _key = v, prefixIcon: Icons.vpn_key), const SizedBox(height: 8), TextFormField( - decoration: const InputDecoration(labelText: '块号'), + decoration: InputDecoration( + labelText: context.l10n.messageBlockNumber), keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (v) => _blockNumber = int.tryParse(v) ?? 0, @@ -151,12 +153,12 @@ class _HfIclassPageState extends State onPressed: () => _execute(HfIclassCmd.rdbl(_blockNumber, key: _key.isEmpty ? null : _key)), icon: const Icon(Icons.visibility, size: 18), - label: const Text('读取块'), + label: Text(context.l10n.messageReadBlock), ), ), const SizedBox(height: 12), HexInputField( - label: '写入数据 (8 字节 hex)', + label: context.l10n.messageWriteData8ByteHex, byteLength: 8, onChanged: (v) => _blockData = v), const SizedBox(height: 8), @@ -167,7 +169,7 @@ class _HfIclassPageState extends State key: _key.isEmpty ? null : _key)) : null, icon: const Icon(Icons.edit, size: 18), - label: const Text('写入块'), + label: Text(context.l10n.messageWriteBlock), ), ], ))), @@ -189,18 +191,18 @@ class _HfIclassPageState extends State sideWidth: 300, side: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '检查密钥', - subtitle: '检查常用默认密钥', + title: context.l10n.messageCheckKeys, + subtitle: context.l10n.messageCheckCommonDefaultKeys, icon: Icons.security, onTap: () => _execute(HfIclassCmd.chk())), ActionCard( - title: 'Loclass 攻击', - subtitle: '离线破解 iCLASS 密钥', + title: context.l10n.messageLoclassAttack, + subtitle: context.l10n.messageRecoverIclassKeysOffline, icon: Icons.bolt, onTap: () => _execute(HfIclassCmd.loclass())), ActionCard( - title: '嗅探通信', - subtitle: '捕获卡片与读卡器通信', + title: context.l10n.messageSniffCommunication, + subtitle: context.l10n.messageCaptureCardReaderCommunication, icon: Icons.hearing, onTap: () => _execute(HfIclassCmd.sniff())), ]), @@ -226,13 +228,14 @@ class _HfIclassPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('模拟器操作', + Text(context.l10n.messageEmulatorOperations, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), TextFormField( - decoration: const InputDecoration( - labelText: '文件路径(可选)', - hintText: '留空使用默认', + decoration: InputDecoration( + labelText: context.l10n.messageFilePathOptional, + hintText: + context.l10n.messageLeaveBlankToUseTheDefault, prefixIcon: Icon(Icons.file_open, size: 18)), style: const TextStyle( fontFamily: 'monospace', fontSize: 12), @@ -244,15 +247,15 @@ class _HfIclassPageState extends State onPressed: () => _execute(HfIclassCmd.eload( _emuFile.isEmpty ? 'dump' : _emuFile)), icon: const Icon(Icons.upload, size: 18), - label: const Text('加载到模拟器')), + label: Text(context.l10n.messageLoadIntoEmulator)), OutlinedButton.icon( onPressed: () => _execute(HfIclassCmd.eview()), icon: const Icon(Icons.visibility, size: 18), - label: const Text('查看')), + label: Text(context.l10n.messageView)), ElevatedButton.icon( onPressed: () => _execute(HfIclassCmd.sim()), icon: const Icon(Icons.play_arrow, size: 18), - label: const Text('模拟')), + label: Text(context.l10n.messageSimulate)), ]), ], ))), diff --git a/lib/ui/pages/hf_legic_page.dart b/lib/ui/pages/hf_legic_page.dart index e929891..f2af4d9 100644 --- a/lib/ui/pages/hf_legic_page.dart +++ b/lib/ui/pages/hf_legic_page.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class HfLegicPage extends StatefulWidget { const HfLegicPage({super.key}); @@ -63,9 +64,9 @@ class _HfLegicPageState extends State @override Widget build(BuildContext context) { return Column(children: [ - TabBar(controller: _tabController, tabs: const [ - Tab(text: '信息'), - Tab(text: '读写'), + TabBar(controller: _tabController, tabs: [ + Tab(text: context.l10n.messageInformation), + Tab(text: context.l10n.messageReadWrite), ]), Expanded( child: TabBarView(controller: _tabController, children: [ @@ -81,24 +82,26 @@ class _HfLegicPageState extends State crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '标签信息', - subtitle: '读取 Legic 标签', + title: context.l10n.messageTagInformation, + subtitle: context.l10n.messageReadALegicTag, icon: Icons.info_outline, onTap: () => _execute(HfLegicCmd.info())), ActionCard( - title: '转储', - subtitle: '转储全部数据', + title: context.l10n.messageDump, + subtitle: context.l10n.messageDumpAllData, icon: Icons.download, onTap: () => _execute(HfLegicCmd.dump())), ActionCard( - title: '擦除', - subtitle: '清空卡片数据', + title: context.l10n.messageErase, + subtitle: context.l10n.messageClearCardData, icon: Icons.delete_forever, - onTap: () => - _confirmThenExecute('确认擦除', '此操作不可恢复!', HfLegicCmd.wipe())), + onTap: () => _confirmThenExecute( + context.l10n.messageConfirmErase, + context.l10n.messageThisOperationCannotBeUndone, + HfLegicCmd.wipe())), ActionCard( - title: '模拟', - subtitle: '模拟 Legic 标签', + title: context.l10n.messageSimulate, + subtitle: context.l10n.messageSimulateALegicTag, icon: Icons.play_arrow, onTap: () => _execute(HfLegicCmd.sim())), ], @@ -124,13 +127,14 @@ class _HfLegicPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('读写操作', + Text(context.l10n.messageReadWriteOperations, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), Row(children: [ Expanded( child: TextFormField( - decoration: const InputDecoration(labelText: '偏移'), + decoration: InputDecoration( + labelText: context.l10n.messageOffset), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly @@ -140,7 +144,8 @@ class _HfLegicPageState extends State const SizedBox(width: 8), Expanded( child: TextFormField( - decoration: const InputDecoration(labelText: '长度'), + decoration: InputDecoration( + labelText: context.l10n.messageLength), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly @@ -156,11 +161,12 @@ class _HfLegicPageState extends State onPressed: () => _execute(HfLegicCmd.rdbl(_offset, _length)), icon: const Icon(Icons.visibility, size: 18), - label: const Text('读取')), + label: Text(context.l10n.messageRead)), ), const SizedBox(height: 12), HexInputField( - label: '写入数据 (hex)', onChanged: (v) => _writeData = v), + label: context.l10n.messageWriteDataHex, + onChanged: (v) => _writeData = v), const SizedBox(height: 8), ElevatedButton.icon( onPressed: _writeData.isNotEmpty @@ -168,7 +174,7 @@ class _HfLegicPageState extends State _execute(HfLegicCmd.wrbl(_offset, _writeData)) : null, icon: const Icon(Icons.edit, size: 18), - label: const Text('写入')), + label: Text(context.l10n.messageWrite)), ], ))), ]), @@ -192,13 +198,13 @@ class _HfLegicPageState extends State actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('取消')), + child: Text(context.l10n.messageCancel)), TextButton( onPressed: () { Navigator.pop(context); _execute(cmd); }, - child: const Text('确认')), + child: Text(context.l10n.messageConfirm)), ], )); } diff --git a/lib/ui/pages/hf_mfdes_page.dart b/lib/ui/pages/hf_mfdes_page.dart index 7502961..c84fb8f 100644 --- a/lib/ui/pages/hf_mfdes_page.dart +++ b/lib/ui/pages/hf_mfdes_page.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class HfMfdesPage extends StatefulWidget { const HfMfdesPage({super.key}); @@ -67,11 +68,11 @@ class _HfMfdesPageState extends State @override Widget build(BuildContext context) { return Column(children: [ - TabBar(controller: _tabController, tabs: const [ - Tab(text: '信息'), - Tab(text: '应用'), - Tab(text: '文件'), - Tab(text: '管理'), + TabBar(controller: _tabController, tabs: [ + Tab(text: context.l10n.messageInformation), + Tab(text: context.l10n.desfireApplicationsTab), + Tab(text: context.l10n.messageFiles), + Tab(text: context.l10n.messageManagement), ]), Expanded( child: TabBarView(controller: _tabController, children: [ @@ -89,33 +90,33 @@ class _HfMfdesPageState extends State crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '检测', - subtitle: '检测 DESFire 卡片', + title: context.l10n.messageDetect, + subtitle: context.l10n.messageDetectADesfireCard, icon: Icons.search, onTap: () => _execute(HfMfdesCmd.detect())), ActionCard( - title: '信息', - subtitle: '读取卡片基本信息', + title: context.l10n.messageInformation, + subtitle: context.l10n.messageReadBasicCardInformation, icon: Icons.info_outline, onTap: () => _execute(HfMfdesCmd.info())), ActionCard( - title: '获取 UID', - subtitle: '获取卡片 UID', + title: context.l10n.messageGetUid, + subtitle: context.l10n.messageGetCardUid, icon: Icons.credit_card, onTap: () => _execute(HfMfdesCmd.getuid())), ActionCard( - title: '空闲内存', - subtitle: '查看可用内存', + title: context.l10n.messageFreeMemory, + subtitle: context.l10n.messageViewAvailableMemory, icon: Icons.storage, onTap: () => _execute(HfMfdesCmd.freemem())), ActionCard( - title: '检查密钥', - subtitle: '检查默认密钥', + title: context.l10n.messageCheckKeys, + subtitle: context.l10n.messageCheckDefaultKeys, icon: Icons.security, onTap: () => _execute(HfMfdesCmd.chk())), ActionCard( title: 'MAD', - subtitle: '查看应用目录', + subtitle: context.l10n.messageViewApplicationDirectory, icon: Icons.list_alt, onTap: () => _execute(HfMfdesCmd.mad())), ], @@ -139,11 +140,11 @@ class _HfMfdesPageState extends State ElevatedButton.icon( onPressed: () => _execute(HfMfdesCmd.getaids()), icon: const Icon(Icons.list, size: 18), - label: const Text('列出 AID')), + label: Text(context.l10n.messageListAids)), ElevatedButton.icon( onPressed: () => _execute(HfMfdesCmd.lsapp()), icon: const Icon(Icons.apps, size: 18), - label: const Text('列出应用')), + label: Text(context.l10n.messageListApplications)), ]), const SizedBox(height: 16), Card( @@ -152,11 +153,11 @@ class _HfMfdesPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('选择应用', + Text(context.l10n.messageSelectApplication, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), HexInputField( - label: 'AID (3 字节 hex)', + label: context.l10n.messageAid3ByteHex, byteLength: 3, onChanged: (v) => _aid = v, prefixIcon: Icons.apps), @@ -166,7 +167,7 @@ class _HfMfdesPageState extends State ? () => _execute(HfMfdesCmd.selectapp(_aid)) : null, icon: const Icon(Icons.check_circle, size: 18), - label: const Text('选择')), + label: Text(context.l10n.messageSelect)), ], ))), ]), @@ -189,16 +190,16 @@ class _HfMfdesPageState extends State ElevatedButton.icon( onPressed: () => _execute(HfMfdesCmd.getfileids()), icon: const Icon(Icons.folder_open, size: 18), - label: const Text('列出文件')), + label: Text(context.l10n.messageListFiles)), ElevatedButton.icon( onPressed: () => _execute(HfMfdesCmd.lsfiles()), icon: const Icon(Icons.description, size: 18), - label: const Text('文件详情')), + label: Text(context.l10n.messageFileDetails)), ElevatedButton.icon( onPressed: () => _execute(HfMfdesCmd.dump(aid: _aid.isEmpty ? null : _aid)), icon: const Icon(Icons.download, size: 18), - label: const Text('转储应用')), + label: Text(context.l10n.messageDumpApplication)), ]), const SizedBox(height: 16), Card( @@ -207,7 +208,7 @@ class _HfMfdesPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('文件读写', + Text(context.l10n.messageFileReadWrite, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), HexInputField( @@ -216,18 +217,19 @@ class _HfMfdesPageState extends State onChanged: (v) => _fid = v, prefixIcon: Icons.tag), const SizedBox(height: 8), - Row(children: [ + Wrap(runSpacing: 8, children: [ ElevatedButton.icon( onPressed: _fid.isNotEmpty ? () => _execute(HfMfdesCmd.read( aid: _aid.isEmpty ? null : _aid, fid: _fid)) : null, icon: const Icon(Icons.visibility, size: 18), - label: const Text('读取')), + label: Text(context.l10n.messageRead)), ]), const SizedBox(height: 8), HexInputField( - label: '写入数据 (hex)', onChanged: (v) => _writeData = v), + label: context.l10n.messageWriteDataHex, + onChanged: (v) => _writeData = v), const SizedBox(height: 8), ElevatedButton.icon( onPressed: _writeData.isNotEmpty && _fid.isNotEmpty @@ -235,7 +237,7 @@ class _HfMfdesPageState extends State aid: _aid.isEmpty ? null : _aid, fid: _fid)) : null, icon: const Icon(Icons.edit, size: 18), - label: const Text('写入')), + label: Text(context.l10n.messageWrite)), ], ))), ]), @@ -260,13 +262,14 @@ class _HfMfdesPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('认证', + Text(context.l10n.messageAuthenticate, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), Row(children: [ Expanded( child: TextFormField( - decoration: const InputDecoration(labelText: '密钥号'), + decoration: InputDecoration( + labelText: context.l10n.messageKeyNumber), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly @@ -277,8 +280,8 @@ class _HfMfdesPageState extends State Expanded( child: DropdownButtonFormField( initialValue: _algorithm, - decoration: const InputDecoration( - labelText: '算法', + decoration: InputDecoration( + labelText: context.l10n.messageAlgorithm, isDense: true, ), items: const [ @@ -294,7 +297,7 @@ class _HfMfdesPageState extends State ]), const SizedBox(height: 8), HexInputField( - label: '密钥 (hex)', + label: context.l10n.messageKeyHex, byteLength: 16, onChanged: (v) => _key = v, prefixIcon: Icons.vpn_key), @@ -306,21 +309,23 @@ class _HfMfdesPageState extends State algo: _algorithm)) : null, icon: const Icon(Icons.lock_open, size: 18), - label: const Text('认证')), + label: Text(context.l10n.messageAuthenticate)), ], ))), const SizedBox(height: 12), ActionCard( - title: '密钥设置', - subtitle: '获取密钥设置信息', + title: context.l10n.messageKeySettings, + subtitle: context.l10n.messageGetKeySettings, icon: Icons.settings, onTap: () => _execute(HfMfdesCmd.getkeysettings())), ActionCard( - title: '格式化 PICC', - subtitle: '删除所有应用和数据', + title: context.l10n.messageFormatPicc, + subtitle: context.l10n.messageDeleteAllApplicationsAndData, icon: Icons.delete_forever, onTap: () => _confirmThenExecute( - '确认格式化', '此操作将删除所有应用和数据,不可恢复!', HfMfdesCmd.formatpicc())), + context.l10n.messageConfirmFormat, + context.l10n.messageThisWillDeleteAllApplicationsAndData, + HfMfdesCmd.formatpicc())), ]), main: ResultDisplay( command: _lastCmd, @@ -342,13 +347,13 @@ class _HfMfdesPageState extends State actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('取消')), + child: Text(context.l10n.messageCancel)), TextButton( onPressed: () { Navigator.pop(context); _execute(cmd); }, - child: const Text('确认')), + child: Text(context.l10n.messageConfirm)), ], )); } diff --git a/lib/ui/pages/hf_mfu_page.dart b/lib/ui/pages/hf_mfu_page.dart index 1c6e6d8..9811e85 100644 --- a/lib/ui/pages/hf_mfu_page.dart +++ b/lib/ui/pages/hf_mfu_page.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class HfMfuPage extends StatefulWidget { const HfMfuPage({super.key}); @@ -72,12 +73,12 @@ class _HfMfuPageState extends State children: [ TabBar( controller: _tabController, - tabs: const [ - Tab(text: '信息'), - Tab(text: '读写'), + tabs: [ + Tab(text: context.l10n.messageInformation), + Tab(text: context.l10n.messageReadWrite), Tab(text: 'NDEF'), - Tab(text: '模拟器'), - Tab(text: '工具'), + Tab(text: context.l10n.messageEmulator), + Tab(text: context.l10n.navTools), ], ), Expanded( @@ -104,21 +105,23 @@ class _HfMfuPageState extends State crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '获取信息', - subtitle: '读取 MFU/NTAG 标签', + title: context.l10n.messageGetInformation, + subtitle: context.l10n.messageReadAnMfuNtagTag, icon: Icons.info_outline, onTap: () => _execute(HfMfuCmd.info())), ActionCard( - title: '转储卡片', - subtitle: '读取全部数据到文件', + title: context.l10n.messageDumpCard, + subtitle: context.l10n.messageReadAllDataToAFile, icon: Icons.download, onTap: () => _execute(HfMfuCmd.dump())), ActionCard( - title: '擦除卡片', - subtitle: '清空卡片数据', + title: context.l10n.messageEraseCard, + subtitle: context.l10n.messageClearCardData, icon: Icons.delete_forever, - onTap: () => - _confirmThenExecute('确认擦除', '此操作不可恢复!', HfMfuCmd.wipe())), + onTap: () => _confirmThenExecute( + context.l10n.messageConfirmErase, + context.l10n.messageThisOperationCannotBeUndone, + HfMfuCmd.wipe())), ], ), main: ResultDisplay( @@ -146,11 +149,12 @@ class _HfMfuPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('块操作', + Text(context.l10n.messageBlockOperations, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), TextFormField( - decoration: const InputDecoration(labelText: '块号'), + decoration: InputDecoration( + labelText: context.l10n.messageBlockNumber), keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (v) => _blockNumber = int.tryParse(v) ?? 0, @@ -161,12 +165,12 @@ class _HfMfuPageState extends State child: ElevatedButton.icon( onPressed: () => _execute(HfMfuCmd.rdbl(_blockNumber)), icon: const Icon(Icons.visibility, size: 18), - label: const Text('读取块'), + label: Text(context.l10n.messageReadBlock), ), ), const SizedBox(height: 12), HexInputField( - label: '写入数据 (hex)', + label: context.l10n.messageWriteDataHex, byteLength: 4, onChanged: (v) => _blockData = v, ), @@ -177,7 +181,7 @@ class _HfMfuPageState extends State _execute(HfMfuCmd.wrbl(_blockNumber, _blockData)) : null, icon: const Icon(Icons.edit, size: 18), - label: const Text('写入块'), + label: Text(context.l10n.messageWriteBlock), ), ], ), @@ -190,11 +194,11 @@ class _HfMfuPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('密码认证', + Text(context.l10n.messagePasswordAuthentication, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), HexInputField( - label: '密码 (4 字节 hex)', + label: context.l10n.messagePassword4ByteHex, byteLength: 4, onChanged: (v) => _password = v, prefixIcon: Icons.vpn_key, @@ -205,7 +209,7 @@ class _HfMfuPageState extends State ? () => _execute(HfMfuCmd.cauth(_password)) : null, icon: const Icon(Icons.lock_open, size: 18), - label: const Text('认证'), + label: Text(context.l10n.messageAuthenticate), ), ], ), @@ -233,8 +237,8 @@ class _HfMfuPageState extends State crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '读取 NDEF', - subtitle: '读取 NFC 数据交换格式', + title: context.l10n.messageReadNdef, + subtitle: context.l10n.messageReadNfcDataExchangeFormat, icon: Icons.article, onTap: () => _execute(HfMfuCmd.ndefRead())), ], @@ -264,13 +268,13 @@ class _HfMfuPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('模拟器操作', + Text(context.l10n.messageEmulatorOperations, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), TextFormField( - decoration: const InputDecoration( - labelText: '文件路径(可选)', - hintText: '留空使用默认', + decoration: InputDecoration( + labelText: context.l10n.messageFilePathOptional, + hintText: context.l10n.messageLeaveBlankToUseTheDefault, prefixIcon: Icon(Icons.file_open, size: 18), ), style: @@ -286,23 +290,23 @@ class _HfMfuPageState extends State onPressed: () => _execute(HfMfuCmd.eload( _emuFile.isEmpty ? 'dump' : _emuFile)), icon: const Icon(Icons.upload, size: 18), - label: const Text('加载到模拟器'), + label: Text(context.l10n.messageLoadIntoEmulator), ), OutlinedButton.icon( onPressed: () => _execute(HfMfuCmd.esave( file: _emuFile.isEmpty ? null : _emuFile)), icon: const Icon(Icons.save, size: 18), - label: const Text('保存模拟器'), + label: Text(context.l10n.messageSaveEmulator), ), OutlinedButton.icon( onPressed: () => _execute(HfMfuCmd.eview()), icon: const Icon(Icons.visibility, size: 18), - label: const Text('查看'), + label: Text(context.l10n.messageView), ), ElevatedButton.icon( onPressed: () => _execute(HfMfuCmd.sim()), icon: const Icon(Icons.play_arrow, size: 18), - label: const Text('模拟'), + label: Text(context.l10n.messageSimulate), ), ], ), @@ -332,18 +336,18 @@ class _HfMfuPageState extends State crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '生成密钥', - subtitle: 'keygen — 根据 UID 推导密钥', + title: context.l10n.messageGenerateKeys, + subtitle: context.l10n.messageKeygenDeriveKeysFromUid, icon: Icons.vpn_key, onTap: () => _execute(HfMfuCmd.keygen())), ActionCard( - title: '密码推导', - subtitle: 'pwdgen — 推导 NTAG 密码', + title: context.l10n.messageDerivePassword, + subtitle: context.l10n.messagePwdgenDeriveTheNtagPassword, icon: Icons.password, onTap: () => _execute(HfMfuCmd.pwdgen())), ActionCard( - title: '密钥检查', - subtitle: 'cchk — 检查常用密钥', + title: context.l10n.messageKeyCheck, + subtitle: context.l10n.messageCchkCheckCommonKeys, icon: Icons.security, onTap: () => _execute(HfMfuCmd.cchk())), const Divider(), @@ -353,11 +357,11 @@ class _HfMfuPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('设置 UID', + Text(context.l10n.messageSetUid, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), HexInputField( - label: 'UID (7 字节 hex)', + label: context.l10n.messageUid7ByteHex, byteLength: 7, onChanged: (v) => _uid = v, prefixIcon: Icons.credit_card, @@ -368,7 +372,7 @@ class _HfMfuPageState extends State ? () => _execute(HfMfuCmd.setuid(_uid)) : null, icon: const Icon(Icons.edit, size: 18), - label: const Text('设置 UID'), + label: Text(context.l10n.messageSetUid), ), ], ), @@ -397,13 +401,14 @@ class _HfMfuPageState extends State content: Text(msg), actions: [ TextButton( - onPressed: () => Navigator.pop(context), child: const Text('取消')), + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.messageCancel)), TextButton( onPressed: () { Navigator.pop(context); _execute(cmd); }, - child: const Text('确认')), + child: Text(context.l10n.messageConfirm)), ], ), ); diff --git a/lib/ui/pages/hf_seos_page.dart b/lib/ui/pages/hf_seos_page.dart index 0686a06..acc1271 100644 --- a/lib/ui/pages/hf_seos_page.dart +++ b/lib/ui/pages/hf_seos_page.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class HfSeosPage extends StatefulWidget { const HfSeosPage({super.key}); @@ -53,18 +54,18 @@ class _HfSeosPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '信息', - subtitle: '读取 SEOS 信息', + title: context.l10n.messageInformation, + subtitle: context.l10n.messageReadSeosInformation, icon: Icons.info_outline, onTap: () => _execute(HfSeosCmd.info())), ActionCard( title: 'PACS', - subtitle: '读取 PACS 数据', + subtitle: context.l10n.messageReadPacsData, icon: Icons.badge, onTap: () => _execute(HfSeosCmd.pacs())), ActionCard( - title: '模拟', - subtitle: '模拟 SEOS 卡片', + title: context.l10n.messageSimulate, + subtitle: context.l10n.messageSimulateASeosCard, icon: Icons.play_arrow, onTap: () => _execute(HfSeosCmd.sim())), ], diff --git a/lib/ui/pages/hf_sniff_page.dart b/lib/ui/pages/hf_sniff_page.dart index 9a61db2..f6006d9 100644 --- a/lib/ui/pages/hf_sniff_page.dart +++ b/lib/ui/pages/hf_sniff_page.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class HfSniffPage extends StatefulWidget { const HfSniffPage({super.key}); @@ -64,18 +65,18 @@ class _HfSniffPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: 'HF 搜索', - subtitle: '搜索高频标签', + title: context.l10n.messageHfSearch, + subtitle: context.l10n.messageSearchForHighFrequencyTags, icon: Icons.search, onTap: () => _execute(HfCmd.search())), ActionCard( - title: 'HF 嗅探', - subtitle: '捕获高频通信', + title: context.l10n.messageHfSniff, + subtitle: context.l10n.messageCaptureHighFrequencyCommunication, icon: Icons.hearing, onTap: () => _execute(HfCmd.sniff())), ActionCard( - title: 'HF 调谐', - subtitle: '调谐高频天线', + title: context.l10n.messageHfTuning, + subtitle: context.l10n.messageTuneTheHfAntenna, icon: Icons.tune, onTap: () => _execute(HfCmd.tune())), const SizedBox(height: 8), @@ -85,14 +86,15 @@ class _HfSniffPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('协议列表', + Text(context.l10n.messageProtocolList, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 13)), const SizedBox(height: 8), DropdownButtonFormField( initialValue: _listProtocol, - decoration: const InputDecoration( - labelText: '协议', isDense: true), + decoration: InputDecoration( + labelText: context.l10n.messageProtocol, + isDense: true), items: _protocols .map((p) => DropdownMenuItem(value: p, child: Text(p))) @@ -104,7 +106,7 @@ class _HfSniffPageState extends State { ElevatedButton.icon( onPressed: () => _execute(HfCmd.list(_listProtocol)), icon: const Icon(Icons.list, size: 18), - label: const Text('列表解码')), + label: Text(context.l10n.messageDecodeList)), ], ))), ], diff --git a/lib/ui/pages/lf_awid_page.dart b/lib/ui/pages/lf_awid_page.dart index c429aa1..fdbbe28 100644 --- a/lib/ui/pages/lf_awid_page.dart +++ b/lib/ui/pages/lf_awid_page.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class LfAwidPage extends StatefulWidget { const LfAwidPage({super.key}); @@ -57,8 +58,8 @@ class _LfAwidPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '读取', - subtitle: '读取 AWID 卡片', + title: context.l10n.messageRead, + subtitle: context.l10n.messageReadAnAwidCard, icon: Icons.nfc, onTap: () => _execute(LfAwidCmd.reader())), Card( @@ -67,13 +68,14 @@ class _LfAwidPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('克隆 / 模拟', + Text(context.l10n.messageCloneSimulate, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 13)), const SizedBox(height: 8), TextFormField( - decoration: const InputDecoration( - labelText: 'FC (设施码)', isDense: true), + decoration: InputDecoration( + labelText: context.l10n.messageFcFacilityCode, + isDense: true), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly @@ -82,8 +84,9 @@ class _LfAwidPageState extends State { ), const SizedBox(height: 8), TextFormField( - decoration: const InputDecoration( - labelText: 'CN (卡号)', isDense: true), + decoration: InputDecoration( + labelText: context.l10n.messageCnCardNumber, + isDense: true), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly @@ -97,13 +100,13 @@ class _LfAwidPageState extends State { ? () => _execute(LfAwidCmd.clone(_fc, _cn)) : null, icon: const Icon(Icons.copy, size: 18), - label: const Text('克隆')), + label: Text(context.l10n.messageClone)), OutlinedButton.icon( onPressed: _fc.isNotEmpty && _cn.isNotEmpty ? () => _execute(LfAwidCmd.sim(_fc, _cn)) : null, icon: const Icon(Icons.play_arrow, size: 18), - label: const Text('模拟')), + label: Text(context.l10n.messageSimulate)), ]), ], ))), diff --git a/lib/ui/pages/lf_fdxb_page.dart b/lib/ui/pages/lf_fdxb_page.dart index ecb71c5..d5ba13b 100644 --- a/lib/ui/pages/lf_fdxb_page.dart +++ b/lib/ui/pages/lf_fdxb_page.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class LfFdxbPage extends StatefulWidget { const LfFdxbPage({super.key}); @@ -54,8 +55,8 @@ class _LfFdxbPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '读取', - subtitle: '读取 FDX-B 标签', + title: context.l10n.messageRead, + subtitle: context.l10n.messageReadAnFdxBTag, icon: Icons.pets, onTap: () => _execute(LfFdxbCmd.reader())), const SizedBox(height: 8), @@ -65,12 +66,12 @@ class _LfFdxbPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('克隆', + Text(context.l10n.messageClone, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 13)), const SizedBox(height: 8), HexInputField( - label: 'RAW 数据', + label: context.l10n.messageRawData2, onChanged: (v) => setState(() => _rawData = v)), const SizedBox(height: 8), ElevatedButton.icon( @@ -78,7 +79,7 @@ class _LfFdxbPageState extends State { ? () => _execute(LfFdxbCmd.clone(_rawData)) : null, icon: const Icon(Icons.copy, size: 18), - label: const Text('克隆到 T55xx')), + label: Text(context.l10n.messageCloneToT55xx)), ], ))), ], diff --git a/lib/ui/pages/lf_hid_page.dart b/lib/ui/pages/lf_hid_page.dart index 3370463..9a8c9d3 100644 --- a/lib/ui/pages/lf_hid_page.dart +++ b/lib/ui/pages/lf_hid_page.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class LfHidPage extends StatefulWidget { const LfHidPage({super.key}); @@ -63,16 +64,16 @@ class _LfHidPageState extends State @override Widget build(BuildContext context) { return Column(children: [ - TabBar(controller: _tabController, tabs: const [ - Tab(text: '读取'), - Tab(text: '克隆/模拟'), - Tab(text: '破解'), + TabBar(controller: _tabController, tabs: [ + Tab(text: context.l10n.messageRead), + Tab(text: context.l10n.messageCloneSimulate2), + Tab(text: context.l10n.hidReaderBruteForceTab), ]), Expanded( child: TabBarView(controller: _tabController, children: [ _buildReadTab(), _buildCloneSimTab(), - _buildCrackTab(), + _buildBruteForceTab(), ])), ]); } @@ -83,13 +84,13 @@ class _LfHidPageState extends State crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '读取 HID 卡', - subtitle: '读取 HID Prox 卡片', + title: context.l10n.messageReadHidCard, + subtitle: context.l10n.messageReadAHidProxCard, icon: Icons.nfc, onTap: () => _execute(LfHidCmd.reader())), ActionCard( - title: '解调信号', - subtitle: '解调 HID 低频信号', + title: context.l10n.messageDemodulateSignal, + subtitle: context.l10n.messageDemodulateTheHidLfSignal, icon: Icons.radio, onTap: () => _execute(LfHidCmd.demod())), ], @@ -115,13 +116,13 @@ class _LfHidPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('克隆 / 模拟', + Text(context.l10n.messageCloneSimulate, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), TextFormField( - decoration: const InputDecoration( - labelText: 'Wiegand 数据', - hintText: '例如: 2006ec0c86', + decoration: InputDecoration( + labelText: context.l10n.messageWiegandData, + hintText: context.l10n.messageForExample2006ec0c86, prefixIcon: Icon(Icons.credit_card, size: 18), ), inputFormatters: [ @@ -139,13 +140,13 @@ class _LfHidPageState extends State ? () => _execute(LfHidCmd.clone(_cardData)) : null, icon: const Icon(Icons.copy, size: 18), - label: const Text('克隆到 T55xx')), + label: Text(context.l10n.messageCloneToT55xx)), OutlinedButton.icon( onPressed: _cardData.isNotEmpty ? () => _execute(LfHidCmd.sim(_cardData)) : null, icon: const Icon(Icons.play_arrow, size: 18), - label: const Text('模拟')), + label: Text(context.l10n.messageSimulate)), ]), ], ))), @@ -161,7 +162,7 @@ class _LfHidPageState extends State ); } - Widget _buildCrackTab() { + Widget _buildBruteForceTab() { return SplitPageLayout( sideWidth: 320, side: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -171,13 +172,13 @@ class _LfHidPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('暴力破解', + Text(context.l10n.hidReaderBruteForceTitle, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), TextFormField( - decoration: const InputDecoration( - labelText: 'FC(可选)', - hintText: '留空则遍历所有', + decoration: InputDecoration( + labelText: context.l10n.messageFcOptional, + hintText: context.l10n.messageLeaveBlankToTryAllValues, prefixIcon: Icon(Icons.numbers, size: 18), ), keyboardType: TextInputType.number, @@ -189,7 +190,7 @@ class _LfHidPageState extends State onPressed: () => _execute( LfHidCmd.brute(fc: _fc.isEmpty ? null : _fc)), icon: const Icon(Icons.bolt, size: 18), - label: const Text('开始破解')), + label: Text(context.l10n.hidStartBruteForce)), ], ))), ]), diff --git a/lib/ui/pages/lf_hitag_page.dart b/lib/ui/pages/lf_hitag_page.dart index 53ab0df..11b72cd 100644 --- a/lib/ui/pages/lf_hitag_page.dart +++ b/lib/ui/pages/lf_hitag_page.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class LfHitagPage extends StatefulWidget { const LfHitagPage({super.key}); @@ -53,38 +54,38 @@ class _LfHitagPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '读取', - subtitle: '读取 Hitag 卡片', + title: context.l10n.messageRead, + subtitle: context.l10n.messageReadAHitagCard, icon: Icons.nfc, onTap: () => _execute(LfHitagCmd.reader())), ActionCard( - title: '信息', - subtitle: '获取标签详情', + title: context.l10n.messageInformation, + subtitle: context.l10n.messageGetTagDetails, icon: Icons.info_outline, onTap: () => _execute(LfHitagCmd.info())), ActionCard( - title: '转储', - subtitle: '转储全部数据', + title: context.l10n.messageDump, + subtitle: context.l10n.messageDumpAllData, icon: Icons.download, onTap: () => _execute(LfHitagCmd.dump())), ActionCard( - title: '嗅探', - subtitle: '捕获通信数据', + title: context.l10n.messageSniff, + subtitle: context.l10n.messageCaptureCommunicationData, icon: Icons.hearing, onTap: () => _execute(LfHitagCmd.sniff())), ActionCard( - title: '检查密钥', - subtitle: '检查常用密钥', + title: context.l10n.messageCheckKeys, + subtitle: context.l10n.messageCheckCommonKeys, icon: Icons.security, onTap: () => _execute(LfHitagCmd.chk())), ActionCard( - title: '破解', - subtitle: '密钥恢复攻击', + title: context.l10n.messageKeyRecovery, + subtitle: context.l10n.messageKeyRecoveryAttack, icon: Icons.bolt, onTap: () => _execute(LfHitagCmd.crack())), ActionCard( - title: '模拟', - subtitle: '模拟 Hitag 标签', + title: context.l10n.messageSimulate, + subtitle: context.l10n.messageSimulateAHitagTag, icon: Icons.play_arrow, onTap: () => _execute(LfHitagCmd.sim())), ], diff --git a/lib/ui/pages/lf_indala_page.dart b/lib/ui/pages/lf_indala_page.dart index 7fa7d86..7ae1140 100644 --- a/lib/ui/pages/lf_indala_page.dart +++ b/lib/ui/pages/lf_indala_page.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class LfIndalaPage extends StatefulWidget { const LfIndalaPage({super.key}); @@ -54,8 +55,8 @@ class _LfIndalaPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '读取', - subtitle: '读取 Indala 卡片', + title: context.l10n.messageRead, + subtitle: context.l10n.messageReadAnIndalaCard, icon: Icons.nfc, onTap: () => _execute(LfIndalaCmd.reader())), const SizedBox(height: 8), @@ -65,12 +66,12 @@ class _LfIndalaPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('克隆 / 模拟', + Text(context.l10n.messageCloneSimulate, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 13)), const SizedBox(height: 8), HexInputField( - label: 'RAW 数据', + label: context.l10n.messageRawData2, onChanged: (v) => setState(() => _rawData = v)), const SizedBox(height: 8), Wrap(spacing: 8, runSpacing: 8, children: [ @@ -79,13 +80,13 @@ class _LfIndalaPageState extends State { ? () => _execute(LfIndalaCmd.clone(_rawData)) : null, icon: const Icon(Icons.copy, size: 18), - label: const Text('克隆')), + label: Text(context.l10n.messageClone)), OutlinedButton.icon( onPressed: _rawData.isNotEmpty ? () => _execute(LfIndalaCmd.sim(_rawData)) : null, icon: const Icon(Icons.play_arrow, size: 18), - label: const Text('模拟')), + label: Text(context.l10n.messageSimulate)), ]), ], ))), diff --git a/lib/ui/pages/lf_io_page.dart b/lib/ui/pages/lf_io_page.dart index aa44191..c3eb475 100644 --- a/lib/ui/pages/lf_io_page.dart +++ b/lib/ui/pages/lf_io_page.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class LfIoPage extends StatefulWidget { const LfIoPage({super.key}); @@ -54,8 +55,8 @@ class _LfIoPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '读取', - subtitle: '读取 ioProx 卡片', + title: context.l10n.messageRead, + subtitle: context.l10n.messageReadAnIoproxCard, icon: Icons.nfc, onTap: () => _execute(LfIoCmd.reader())), const SizedBox(height: 8), @@ -65,12 +66,12 @@ class _LfIoPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('克隆 / 模拟', + Text(context.l10n.messageCloneSimulate, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 13)), const SizedBox(height: 8), HexInputField( - label: 'RAW 数据', + label: context.l10n.messageRawData2, onChanged: (v) => setState(() => _rawData = v)), const SizedBox(height: 8), Wrap(spacing: 8, runSpacing: 8, children: [ @@ -79,13 +80,13 @@ class _LfIoPageState extends State { ? () => _execute(LfIoCmd.clone(_rawData)) : null, icon: const Icon(Icons.copy, size: 18), - label: const Text('克隆')), + label: Text(context.l10n.messageClone)), OutlinedButton.icon( onPressed: _rawData.isNotEmpty ? () => _execute(LfIoCmd.sim(_rawData)) : null, icon: const Icon(Icons.play_arrow, size: 18), - label: const Text('模拟')), + label: Text(context.l10n.messageSimulate)), ]), ], ))), diff --git a/lib/ui/pages/lf_keri_page.dart b/lib/ui/pages/lf_keri_page.dart index bcd1228..9285c74 100644 --- a/lib/ui/pages/lf_keri_page.dart +++ b/lib/ui/pages/lf_keri_page.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class LfKeriPage extends StatefulWidget { const LfKeriPage({super.key}); @@ -54,8 +55,8 @@ class _LfKeriPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '读取', - subtitle: '读取 Keri 卡片', + title: context.l10n.messageRead, + subtitle: context.l10n.messageReadAKeriCard, icon: Icons.nfc, onTap: () => _execute(LfKeriCmd.reader())), const SizedBox(height: 8), @@ -65,12 +66,12 @@ class _LfKeriPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('克隆', + Text(context.l10n.messageClone, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 13)), const SizedBox(height: 8), HexInputField( - label: 'RAW 数据', + label: context.l10n.messageRawData2, onChanged: (v) => setState(() => _rawData = v)), const SizedBox(height: 8), ElevatedButton.icon( @@ -78,7 +79,7 @@ class _LfKeriPageState extends State { ? () => _execute(LfKeriCmd.clone(_rawData)) : null, icon: const Icon(Icons.copy, size: 18), - label: const Text('克隆到 T55xx')), + label: Text(context.l10n.messageCloneToT55xx)), ], ))), ], diff --git a/lib/ui/pages/lf_page.dart b/lib/ui/pages/lf_page.dart index 9d4cb77..56cbc02 100644 --- a/lib/ui/pages/lf_page.dart +++ b/lib/ui/pages/lf_page.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class LfPage extends StatefulWidget { const LfPage({super.key}); @@ -66,8 +67,8 @@ class _LfPageState extends State with SingleTickerProviderStateMixin { children: [ TabBar( controller: _tabController, - tabs: const [ - Tab(text: '通用'), + tabs: [ + Tab(text: context.l10n.messageGeneral), Tab(text: 'EM4x05'), Tab(text: 'T55xx'), ], @@ -92,16 +93,26 @@ class _LfPageState extends State with SingleTickerProviderStateMixin { side: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _actionCard('低频搜索', '自动检测低频标签', Icons.search, () { + _actionCard( + context.l10n.terminalLfSearch, + context.l10n.messageAutomaticallyDetectLowFrequencyTags, + Icons.search, () { _execute(Pm3Commands.lfSearch()); }), - _actionCard('低频读取', '读取低频原始信号', Icons.sensors, () { + _actionCard( + context.l10n.terminalLfRead, + context.l10n.messageReadTheRawLowFrequencySignal, + Icons.sensors, () { _execute(Pm3Commands.lfRead()); }), - _actionCard('低频喗探', '捕获低频通信', Icons.hearing, () { + _actionCard( + context.l10n.messageLfSniff, + context.l10n.messageCaptureLowFrequencyCommunication, + Icons.hearing, () { _execute(Pm3Commands.lfSniff()); }), - _actionCard('天线调谐', '检查天线调谐状态', Icons.tune, () { + _actionCard(context.l10n.antennaTune, + context.l10n.messageCheckAntennaTuning, Icons.tune, () { _execute(Pm3Commands.lfTune()); }), ], @@ -123,7 +134,8 @@ class _LfPageState extends State with SingleTickerProviderStateMixin { side: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _actionCard('读取 EM410x', '读取 EM4100 标签 ID', Icons.nfc, () { + _actionCard(context.l10n.messageReadEm410x, + context.l10n.messageReadTheEm4100TagId, Icons.nfc, () { _execute(Pm3Commands.lfEm410xRead()); }), const SizedBox(height: 12), @@ -133,13 +145,13 @@ class _LfPageState extends State with SingleTickerProviderStateMixin { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('克隆 EM410x', + Text(context.l10n.messageCloneEm410x, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), TextFormField( - decoration: const InputDecoration( - labelText: '标签 ID (10位 hex)', - hintText: '例如 0102030405', + decoration: InputDecoration( + labelText: context.l10n.messageTagId10HexDigits, + hintText: context.l10n.messageForExample0102030405, ), style: const TextStyle(fontFamily: 'monospace'), onChanged: (v) => _em410xId = v, @@ -151,12 +163,14 @@ class _LfPageState extends State with SingleTickerProviderStateMixin { _execute(Pm3Commands.lfEm410xClone(_em410xId)); } else { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('ID 必须是 10 位 hex 字符')), + SnackBar( + content: Text(context + .l10n.messageIdMustContain10HexDigits)), ); } }, icon: const Icon(Icons.copy), - label: const Text('克隆'), + label: Text(context.l10n.messageClone), ), ], ), @@ -181,13 +195,20 @@ class _LfPageState extends State with SingleTickerProviderStateMixin { side: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _actionCard('检测', '检测 T55xx 芯片配置', Icons.memory, () { + _actionCard( + context.l10n.messageDetect, + context.l10n.messageDetectT55xxChipConfiguration, + Icons.memory, () { _execute(Pm3Commands.lfT55xxDetect()); }), - _actionCard('信息', '显示 T55xx 配置信息', Icons.info_outline, () { + _actionCard( + context.l10n.messageInformation, + context.l10n.messageShowT55xxConfiguration, + Icons.info_outline, () { _execute(Pm3Commands.lfT55xxInfo()); }), - _actionCard('转储', '转储所有 T55xx 块', Icons.download, () { + _actionCard(context.l10n.messageDump, + context.l10n.messageDumpAllT55xxBlocks, Icons.download, () { _execute(Pm3Commands.lfT55xxDump()); }), const Divider(), @@ -197,22 +218,23 @@ class _LfPageState extends State with SingleTickerProviderStateMixin { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('块操作', + Text(context.l10n.messageBlockOperations, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), for (var block = 0; block < 8; block++) ListTile( dense: true, - title: Text('块 $block', + title: Text( + context.l10n.messageBlock2((block).toString()), style: const TextStyle(fontFamily: 'monospace')), - trailing: Row( - mainAxisSize: MainAxisSize.min, + trailing: Wrap( + runSpacing: 8, children: [ IconButton( icon: const Icon(Icons.visibility, size: 20), onPressed: () => _execute(Pm3Commands.lfT55xxReadBlock(block)), - tooltip: '读取', + tooltip: context.l10n.messageRead, ), ], ), diff --git a/lib/ui/pages/lf_pyramid_page.dart b/lib/ui/pages/lf_pyramid_page.dart index 8a3f9c7..bb5f8fa 100644 --- a/lib/ui/pages/lf_pyramid_page.dart +++ b/lib/ui/pages/lf_pyramid_page.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class LfPyramidPage extends StatefulWidget { const LfPyramidPage({super.key}); @@ -56,8 +57,8 @@ class _LfPyramidPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '读取', - subtitle: '读取 Pyramid 卡片', + title: context.l10n.messageRead, + subtitle: context.l10n.messageReadAPyramidCard, icon: Icons.nfc, onTap: () => _execute(LfPyramidCmd.reader())), const SizedBox(height: 8), @@ -67,13 +68,14 @@ class _LfPyramidPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('克隆', + Text(context.l10n.messageClone, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 13)), const SizedBox(height: 8), TextFormField( - decoration: const InputDecoration( - labelText: 'FC (设施码)', isDense: true), + decoration: InputDecoration( + labelText: context.l10n.messageFcFacilityCode, + isDense: true), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly @@ -82,8 +84,9 @@ class _LfPyramidPageState extends State { ), const SizedBox(height: 8), TextFormField( - decoration: const InputDecoration( - labelText: 'CN (卡号)', isDense: true), + decoration: InputDecoration( + labelText: context.l10n.messageCnCardNumber, + isDense: true), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly @@ -96,7 +99,7 @@ class _LfPyramidPageState extends State { ? () => _execute(LfPyramidCmd.clone(_fc, _cn)) : null, icon: const Icon(Icons.copy, size: 18), - label: const Text('克隆到 T55xx')), + label: Text(context.l10n.messageCloneToT55xx)), ], ))), ], diff --git a/lib/ui/pages/mifare_page.dart b/lib/ui/pages/mifare_page.dart index ee1b028..4111291 100644 --- a/lib/ui/pages/mifare_page.dart +++ b/lib/ui/pages/mifare_page.dart @@ -9,6 +9,7 @@ import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/services/file_dialog_service.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class MifarePage extends StatefulWidget { const MifarePage({super.key}); @@ -86,7 +87,7 @@ class _MifarePageState extends State padding: const EdgeInsets.all(8), child: Row( children: [ - const Text('卡片类型:', + Text(context.l10n.messageCardType2, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(width: 8), SegmentedButton( @@ -105,11 +106,11 @@ class _MifarePageState extends State ), TabBar( controller: _tabController, - tabs: const [ - Tab(text: '快捷操作'), - Tab(text: '密钥攻击'), - Tab(text: '读写块'), - Tab(text: '魔术卡'), + tabs: [ + Tab(text: context.l10n.messageQuickActions), + Tab(text: context.l10n.messageKeyRecoveryAttacks), + Tab(text: context.l10n.messageReadWriteBlocks), + Tab(text: context.l10n.messageMagicCards), ], ), Expanded( @@ -150,14 +151,15 @@ class _MifarePageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Dump/Restore 参数(可选)', + Text(context.l10n.messageDumpRestoreParametersOptional, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), TextFormField( initialValue: _quickKeyFile, decoration: InputDecoration( - labelText: '密钥文件 --keys (可选)', - hintText: appState.preferredMfKeyFile ?? '留空则使用 PM3 默认', + labelText: context.l10n.messageKeyFileKeysOptional, + hintText: appState.preferredMfKeyFile ?? + context.l10n.messageLeaveBlankToUseThePm3Default, prefixIcon: const Icon(Icons.vpn_key, size: 18), ), onChanged: (v) => _quickKeyFile = v, @@ -168,9 +170,9 @@ class _MifarePageState extends State TextFormField( initialValue: _quickDumpFile, decoration: InputDecoration( - labelText: '转储文件 --file (可选)', + labelText: context.l10n.messageDumpFileFileOptional, hintText: appState.preferredMfDumpFile ?? - 'dump:输出文件 / restore:输入文件', + context.l10n.messageDumpOutputFileRestoreInputFile, prefixIcon: const Icon(Icons.file_open, size: 18), ), onChanged: (v) => _quickDumpFile = v, @@ -197,7 +199,7 @@ class _MifarePageState extends State } }, icon: const Icon(Icons.vpn_key, size: 16), - label: const Text('选择密钥文件'), + label: Text(context.l10n.messageSelectKeyFile), ), OutlinedButton.icon( onPressed: () async { @@ -214,7 +216,7 @@ class _MifarePageState extends State } }, icon: const Icon(Icons.file_open, size: 16), - label: const Text('选择转储文件'), + label: Text(context.l10n.messageSelectDumpFile), ), TextButton.icon( onPressed: () => setState(() { @@ -222,7 +224,7 @@ class _MifarePageState extends State _quickDumpFile = ''; }), icon: const Icon(Icons.cleaning_services, size: 16), - label: const Text('清空自定义'), + label: Text(context.l10n.messageClearCustomSettings), ), ], ), @@ -230,8 +232,9 @@ class _MifarePageState extends State appState.preferredMfDumpFile != null) ...[ const SizedBox(height: 6), Text( - '默认来源:key=${appState.preferredMfKeyFile ?? '-'}; ' - 'dump=${appState.preferredMfDumpFile ?? '-'}', + context.l10n.messageDefaultSourcesKeyDump( + (appState.preferredMfKeyFile ?? '-').toString(), + (appState.preferredMfDumpFile ?? '-').toString()), style: TextStyle(fontSize: 11, color: Colors.grey[500]), ), ], @@ -240,30 +243,42 @@ class _MifarePageState extends State ), ), const SizedBox(height: 10), - _actionCard('检测卡片', '扫描 HF 14A 标签', Icons.search, () { + _actionCard(context.l10n.messageDetectCard, + context.l10n.messageScanHf14aTags, Icons.search, () { _execute(Pm3Commands.hf14aSearch()); }), - _actionCard('卡片信息', '获取详细卡片信息', Icons.info_outline, () { + _actionCard( + context.l10n.messageCardInformation, + context.l10n.messageGetDetailedCardInformation, + Icons.info_outline, () { _execute(Pm3Commands.hfMfInfo()); }), - _actionCard('自动破解', '自动密钥恢复', Icons.bolt, () { + _actionCard(context.l10n.terminalAutopwn, + context.l10n.messageAutomaticKeyRecovery, Icons.bolt, () { _execute(Pm3Commands.hfMfAutopwn(flag)); }), - _actionCard('转储卡片', '读取所有扇区到文件', Icons.download, () { + _actionCard(context.l10n.messageDumpCard, + context.l10n.messageReadAllSectorsToAFile, Icons.download, () { _execute(Pm3Commands.hfMfDump( flag, keyFile: effectiveKey.isEmpty ? null : effectiveKey, dumpFile: effectiveDump.isEmpty ? null : effectiveDump, )); }), - _actionCard('恢复卡片', '将转储写回卡片', Icons.upload, () { + _actionCard( + context.l10n.messageRestoreCard, + context.l10n.messageWriteTheDumpBackToTheCard, + Icons.upload, () { _execute(Pm3Commands.hfMfRestore( flag, keyFile: effectiveKey.isEmpty ? null : effectiveKey, dumpFile: effectiveDump.isEmpty ? null : effectiveDump, )); }), - _actionCard('喗探', '捕获卡片通信', Icons.hearing, () { + _actionCard( + context.l10n.messageSniff2, + context.l10n.messageCaptureCardCommunication, + Icons.hearing, () { _execute(Pm3Commands.hf14aSniff()); }), const SizedBox(height: 10), @@ -273,7 +288,9 @@ class _MifarePageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Text('更多卡型破解/诊断(参考命令文档)', + Text( + context.l10n + .messageMoreCardRecoveryDiagnosticsSeeCommandReference, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), Wrap( @@ -298,16 +315,17 @@ class _MifarePageState extends State OutlinedButton.icon( onPressed: () => _execute(Hf14aCmd.cuids(count: 20)), icon: const Icon(Icons.qr_code, size: 16), - label: const Text('采集 CUID'), + label: Text(context.l10n.messageCollectCuid), ), ], ), const SizedBox(height: 10), TextFormField( initialValue: _scriptName, - decoration: const InputDecoration( - labelText: 'PM3 脚本名 (script run )', - hintText: '例如: mifare/mf_autopwn', + decoration: InputDecoration( + labelText: + context.l10n.messagePm3ScriptNameScriptRunName, + hintText: context.l10n.messageForExampleMifareMfAutopwn, border: OutlineInputBorder(), isDense: true, ), @@ -316,12 +334,13 @@ class _MifarePageState extends State fontFamily: 'monospace', fontSize: 12), ), const SizedBox(height: 8), - Row( + Wrap( + runSpacing: 8, children: [ OutlinedButton.icon( onPressed: () => _execute(ScriptCmd.list()), icon: const Icon(Icons.list, size: 16), - label: const Text('列出脚本'), + label: Text(context.l10n.messageListScripts), ), const SizedBox(width: 8), ElevatedButton.icon( @@ -329,7 +348,7 @@ class _MifarePageState extends State ? null : () => _execute(ScriptCmd.run(_scriptName)), icon: const Icon(Icons.play_arrow, size: 16), - label: const Text('运行脚本'), + label: Text(context.l10n.messageRunScript), ), ], ), @@ -361,15 +380,15 @@ class _MifarePageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('已知密钥', + Text(context.l10n.messageKnownKey, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), Row(children: [ Expanded( child: TextFormField( initialValue: _keyA, - decoration: - const InputDecoration(labelText: '密钥 (hex)'), + decoration: InputDecoration( + labelText: context.l10n.messageKeyHex), onChanged: (v) => _keyA = v, style: const TextStyle(fontFamily: 'monospace'), ), @@ -390,7 +409,8 @@ class _MifarePageState extends State Expanded( child: TextFormField( initialValue: '0', - decoration: const InputDecoration(labelText: '块号'), + decoration: InputDecoration( + labelText: context.l10n.messageBlockNumber), onChanged: (v) => _selectedBlock = int.tryParse(v) ?? 0, keyboardType: TextInputType.number, @@ -402,22 +422,31 @@ class _MifarePageState extends State ), ), const SizedBox(height: 12), - _actionCard('检查密钥', '用默认密钥尝试所有扇区', Icons.vpn_key, () { + _actionCard( + context.l10n.messageCheckKeys, + context.l10n.messageTryDefaultKeysOnAllSectors, + Icons.vpn_key, () { _execute(Pm3Commands.hfMfCheck(flag)); }), - _actionCard('Nested 攻击', '用已知密钥查找其他密钥', Icons.account_tree, () { + _actionCard( + context.l10n.messageNestedAttack, + context.l10n.messageUseAKnownKeyToFindOther, + Icons.account_tree, () { _execute(Pm3Commands.hfMfNested( flag, _selectedBlock, _selectedKeyType, _keyA)); }), - _actionCard('Static Nested', '适用于静态 nonce 卡片', Icons.repeat, () { + _actionCard('Static Nested', + context.l10n.messageForCardsWithStaticNonces, Icons.repeat, () { _execute(Pm3Commands.hfMfStaticNested( flag, _selectedBlock, _selectedKeyType, _keyA)); }), - _actionCard('Hardnested', '高级密钥恢复', Icons.security, () { + _actionCard('Hardnested', context.l10n.messageAdvancedKeyRecovery, + Icons.security, () { _execute(Pm3Commands.hfMfHardnested( _selectedBlock, _selectedKeyType, _keyA, 0, 'A')); }), - _actionCard('Darkside', '无需已知密钥', Icons.dark_mode, () { + _actionCard('Darkside', context.l10n.messageNoKnownKeyRequired, + Icons.dark_mode, () { _execute(Pm3Commands.hfMfDarkside()); }), ], @@ -442,14 +471,15 @@ class _MifarePageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('块操作', + Text(context.l10n.messageBlockOperations, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), Row(children: [ Expanded( child: TextFormField( initialValue: '0', - decoration: const InputDecoration(labelText: '块号'), + decoration: InputDecoration( + labelText: context.l10n.messageBlockNumber), onChanged: (v) => _selectedBlock = int.tryParse(v) ?? 0, keyboardType: TextInputType.number, )), @@ -467,7 +497,8 @@ class _MifarePageState extends State const SizedBox(height: 8), TextFormField( initialValue: _keyA, - decoration: const InputDecoration(labelText: '密钥 (hex)'), + decoration: InputDecoration( + labelText: context.l10n.messageKeyHex), onChanged: (v) => _keyA = v, style: const TextStyle(fontFamily: 'monospace'), ), @@ -483,8 +514,8 @@ class _MifarePageState extends State child: ListTile( leading: Icon(Icons.visibility, color: Theme.of(context).colorScheme.primary), - title: const Text('读取块'), - subtitle: Text('读取单个块数据', + title: Text(context.l10n.messageReadBlock), + subtitle: Text(context.l10n.messageReadDataFromOneBlock, style: TextStyle(fontSize: 12, color: Colors.grey[400])), trailing: _isExecuting ? const SizedBox( @@ -507,13 +538,14 @@ class _MifarePageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('写入数据', + Text(context.l10n.messageWriteData, style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), TextFormField( - decoration: const InputDecoration( - labelText: '数据 (32位 hex)', - hintText: '例如 00000000000000000000000000000000', + decoration: InputDecoration( + labelText: context.l10n.messageData32HexDigits, + hintText: context.l10n + .messageForExample00000000000000000000000000000000, ), style: const TextStyle(fontFamily: 'monospace'), onChanged: (v) => _writeData = v, @@ -532,7 +564,7 @@ class _MifarePageState extends State } : null, icon: const Icon(Icons.edit, size: 18), - label: const Text('写入块'), + label: Text(context.l10n.messageWriteBlock), ), ), ], @@ -555,7 +587,7 @@ class _MifarePageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Card( + Card( child: Padding( padding: EdgeInsets.all(12), child: Column( @@ -563,14 +595,13 @@ class _MifarePageState extends State Icon(Icons.warning_amber, color: Colors.orange, size: 36), SizedBox(height: 8), Text( - '魔术卡操作', + context.l10n.messageMagicCardOperations, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), ), SizedBox(height: 4), Text( - '这些命令仅适用于 Gen1A/Gen2 (CUID) 魔术卡。\n' - '写入块 0 可能会损坏非魔术卡!', + context.l10n.messageTheseCommandsOnlyApplyToGen1aGen2, textAlign: TextAlign.center, style: TextStyle(color: Colors.orange), ), @@ -579,32 +610,40 @@ class _MifarePageState extends State ), ), const SizedBox(height: 12), - _actionCard('读取块 0', '读取制造商块', Icons.visibility, () { + _actionCard( + context.l10n.messageReadBlock0, + context.l10n.messageReadManufacturerBlock, + Icons.visibility, () { _execute(Pm3Commands.hfMfMagicGetBlock(0)); }), - _actionCard('擦除卡片', '恢复出厂状态', Icons.delete_forever, () { + _actionCard( + context.l10n.messageEraseCard, + context.l10n.messageRestoreFactoryDefaults, + Icons.delete_forever, () { showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('擦除卡片?'), - content: const Text('这将重置所有块。确定吗?'), + title: Text(context.l10n.messageEraseCard2), + content: + Text(context.l10n.messageThisWillResetAllBlocksAreYou), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('取消')), + child: Text(context.l10n.messageCancel)), TextButton( onPressed: () { Navigator.pop(ctx); _execute(Pm3Commands.hfMfMagicWipe()); }, - child: - const Text('擦除', style: TextStyle(color: Colors.red)), + child: Text(context.l10n.messageErase, + style: TextStyle(color: Colors.red)), ), ], ), ); }), - _actionCard('加载模拟器', '将转储加载到模拟器', Icons.memory, () { + _actionCard(context.l10n.messageLoadEmulator, + context.l10n.messageLoadADumpIntoTheEmulator, Icons.memory, () { _execute(Pm3Commands.hfMfEmulatorClear()); }), ], diff --git a/lib/ui/pages/nfc_page.dart b/lib/ui/pages/nfc_page.dart index 0649865..cb84851 100644 --- a/lib/ui/pages/nfc_page.dart +++ b/lib/ui/pages/nfc_page.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class NfcPage extends StatefulWidget { const NfcPage({super.key}); @@ -80,19 +81,20 @@ class _NfcPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('NDEF 解码', + Text(context.l10n.messageNdefDecoding, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 13)), const SizedBox(height: 8), HexInputField( - label: 'Hex 数据', onChanged: (v) => _hexData = v), + label: context.l10n.messageHexData, + onChanged: (v) => _hexData = v), const SizedBox(height: 8), ElevatedButton.icon( onPressed: _hexData.isNotEmpty ? () => _execute(NfcCmd.decode(_hexData)) : null, icon: const Icon(Icons.code, size: 18), - label: const Text('解码')), + label: Text(context.l10n.messageDecode)), ], ))), ], diff --git a/lib/ui/pages/script_page.dart b/lib/ui/pages/script_page.dart index 2cbc866..404cec6 100644 --- a/lib/ui/pages/script_page.dart +++ b/lib/ui/pages/script_page.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class ScriptPage extends StatefulWidget { const ScriptPage({super.key}); @@ -55,8 +56,8 @@ class _ScriptPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActionCard( - title: '列表', - subtitle: '列出可用脚本', + title: context.l10n.messageList, + subtitle: context.l10n.messageListAvailableScripts, icon: Icons.list, onTap: () => _execute(ScriptCmd.list())), const SizedBox(height: 8), @@ -66,14 +67,14 @@ class _ScriptPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('运行脚本', + Text(context.l10n.messageRunScript, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 13)), const SizedBox(height: 8), TextFormField( - decoration: const InputDecoration( - labelText: '脚本名称', - hintText: '例如: hf_mf_autopwn', + decoration: InputDecoration( + labelText: context.l10n.messageScriptName, + hintText: context.l10n.messageForExampleHfMfAutopwn, isDense: true, ), onChanged: (v) => _scriptName = v, @@ -84,7 +85,7 @@ class _ScriptPageState extends State { ? () => _execute(ScriptCmd.run(_scriptName)) : null, icon: const Icon(Icons.play_arrow, size: 18), - label: const Text('运行')), + label: Text(context.l10n.messageRun)), ], ))), ], diff --git a/lib/ui/pages/settings_page.dart b/lib/ui/pages/settings_page.dart index c065bec..3caef40 100644 --- a/lib/ui/pages/settings_page.dart +++ b/lib/ui/pages/settings_page.dart @@ -7,6 +7,8 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/core/pm3/pm3_argument_codec.dart'; import 'package:pm3gui/services/file_dialog_service.dart'; import 'package:pm3gui/state/app_state.dart'; +import 'package:pm3gui/l10n/l10n.dart'; +import 'package:pm3gui/l10n/app_locale_controller.dart'; class SettingsPage extends StatefulWidget { const SettingsPage({super.key}); @@ -41,6 +43,8 @@ class _SettingsPageState extends State { @override Widget build(BuildContext context) { final appState = context.watch(); + final language = context.watch(); + final l10n = context.l10n; _syncController(_pm3PathController, appState.pm3Path); if (!_argumentsFocusNode.hasFocus) { _syncController( @@ -58,11 +62,58 @@ class _SettingsPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - '设置', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + Text( + l10n.navSettings, + style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), ), const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + leading: const Icon(Icons.language), + title: Text(l10n.language), + contentPadding: EdgeInsets.zero, + ), + DropdownButtonFormField( + key: ValueKey( + 'language-${language.locale?.languageCode ?? 'system'}'), + initialValue: language.locale?.languageCode ?? 'system', + decoration: InputDecoration(labelText: l10n.language), + items: [ + DropdownMenuItem( + value: 'system', child: Text(l10n.languageSystem)), + const DropdownMenuItem(value: 'zh', child: Text('简体中文')), + const DropdownMenuItem( + value: 'en', child: Text('English')), + ], + onChanged: (value) { + if (value == null) return; + language + .setLocale(value == 'system' ? null : Locale(value)); + }, + ), + const SizedBox(height: 8), + Text(l10n.languageHelp), + if (language.isSaving) ...[ + const SizedBox(height: 8), + const LinearProgressIndicator(), + ], + if (language.error case final error?) ...[ + const SizedBox(height: 8), + Text(l10n.languagePreferenceError(error.toString()), + key: const ValueKey('language-error'), + style: TextStyle( + color: Theme.of(context).colorScheme.error)), + ], + ], + ), + ), + ), + const SizedBox(height: 12), // PM3 Configuration Card( @@ -71,24 +122,24 @@ class _SettingsPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const ListTile( - leading: Icon(Icons.terminal), - title: Text('PM3 配置'), + ListTile( + leading: const Icon(Icons.terminal), + title: Text(l10n.settingsPm3Configuration), contentPadding: EdgeInsets.zero, ), TextFormField( controller: _pm3PathController, decoration: InputDecoration( - labelText: 'PM3 程序路径', + labelText: l10n.settingsPm3Path, hintText: Platform.isWindows - ? r'官方发行包根目录\pm3.bat' + ? l10n.settingsWindowsPathHint : '/usr/bin/proxmark3', helperText: Platform.isWindows - ? 'Windows 推荐选择发行包根目录的 pm3.bat' + ? l10n.settingsWindowsPathHelp : null, suffixIcon: IconButton( icon: const Icon(Icons.folder_open), - tooltip: '选择 PM3 程序', + tooltip: l10n.settingsPickPm3, onPressed: () async { final path = await FileDialogService.pickSingleFilePath(); @@ -103,12 +154,11 @@ class _SettingsPageState extends State { controller: _argumentsController, focusNode: _argumentsFocusNode, decoration: InputDecoration( - labelText: 'PM3 启动参数', + labelText: l10n.settingsArguments, hintText: '--flush\n--example=value with spaces', helperText: Platform.isWindows - ? '选择 pm3.bat 时由脚本自行初始化并自动检测设备,' - '此处参数不会传给脚本。每行一个参数;空格、引号和反斜杠按原样保留' - : '每行一个参数;空格、引号和反斜杠按原样保留', + ? l10n.settingsWindowsArgumentsHelp + : l10n.settingsArgumentsHelp, ), minLines: 2, maxLines: 6, @@ -119,22 +169,22 @@ class _SettingsPageState extends State { const SizedBox(height: 12), TextFormField( controller: _workingDirectoryController, - decoration: const InputDecoration( - labelText: 'PM3 工作目录(可选)', + decoration: InputDecoration( + labelText: l10n.settingsWorkingDirectory, ), onChanged: appState.setPm3WorkingDirectory, ), if (appState.isSavingSettings) ...[ const SizedBox(height: 12), - const Row( - key: ValueKey('settings-saving'), + Row( + key: const ValueKey('settings-saving'), children: [ - SizedBox.square( + const SizedBox.square( dimension: 16, child: CircularProgressIndicator(strokeWidth: 2), ), - SizedBox(width: 8), - Text('正在保存设置…'), + const SizedBox(width: 8), + Text(l10n.settingsSaving), ], ), ], @@ -157,26 +207,26 @@ class _SettingsPageState extends State { Card( child: Column( children: [ - const ListTile( - leading: Icon(Icons.info_outline), - title: Text('关于 PM3 GUI'), - subtitle: Text('基于 Flutter 的 Proxmark3 图形界面'), + ListTile( + leading: const Icon(Icons.info_outline), + title: Text(l10n.settingsAbout), + subtitle: Text(l10n.settingsDescription), ), const Divider(), ListTile( leading: const Icon(Icons.code), - title: const Text('平台'), + title: Text(l10n.settingsPlatform), subtitle: Text(Platform.operatingSystem), ), - const ListTile( - leading: Icon(Icons.architecture), - title: Text('架构'), - subtitle: Text('CLI Wrapper (兼容未来更新)'), + ListTile( + leading: const Icon(Icons.architecture), + title: Text(l10n.settingsArchitecture), + subtitle: Text(l10n.settingsArchitectureDescription), ), - const ListTile( - leading: Icon(Icons.layers), - title: Text('支持格式'), - subtitle: Text('.eml, .bin/.dump, .json (PM3 Jansson)'), + ListTile( + leading: const Icon(Icons.layers), + title: Text(l10n.settingsSupportedFormats), + subtitle: const Text('.eml, .bin/.dump, .json (PM3 Jansson)'), ), ], ), @@ -184,9 +234,9 @@ class _SettingsPageState extends State { const SizedBox(height: 24), // Quick actions - const Text( - '维护', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + Text( + l10n.settingsMaintenance, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Wrap( @@ -196,19 +246,19 @@ class _SettingsPageState extends State { OutlinedButton.icon( onPressed: () => appState.clearTerminal(), icon: const Icon(Icons.delete_sweep), - label: const Text('清除终端'), + label: Text(l10n.terminalClear), ), if (appState.isConnected) OutlinedButton.icon( onPressed: () => appState.sendCommand('hw version'), icon: const Icon(Icons.info), - label: const Text('硬件版本'), + label: Text(l10n.hardwareVersion), ), if (appState.isConnected) OutlinedButton.icon( onPressed: () => appState.sendCommand('hw tune'), icon: const Icon(Icons.tune), - label: const Text('天线调谐'), + label: Text(l10n.antennaTune), ), ], ), diff --git a/lib/ui/pages/terminal_page.dart b/lib/ui/pages/terminal_page.dart index 8690d99..94d9e2b 100644 --- a/lib/ui/pages/terminal_page.dart +++ b/lib/ui/pages/terminal_page.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_command_catalog.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class _AutocompleteIntent extends Intent { const _AutocompleteIntent(); @@ -38,6 +39,9 @@ class _TerminalPageState extends State { bool _ignoreNextInputChange = false; int _lastTerminalLineCount = 0; + String? _catalogLanguage; + Object? _catalogError; + int _catalogRequest = 0; bool get _isCyclingAutocomplete => _cycleCandidates.isNotEmpty; @@ -76,7 +80,20 @@ class _TerminalPageState extends State { void initState() { super.initState(); _inputController.addListener(_onInputChanged); - _loadCatalog(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final language = Localizations.localeOf(context).languageCode; + if (_catalogLanguage != language) { + _catalogLanguage = language; + _catalog = const []; + _suggestionIndex.clear(); + _suggestions = const []; + _resetAutocompleteCycle(); + _loadCatalog(language); + } } @override @@ -105,14 +122,27 @@ class _TerminalPageState extends State { _cycleIndex = -1; } - Future _loadCatalog() async { - final loaded = await Pm3CommandCatalog.load(preferZh: true); - if (!mounted) return; - setState(() { - _catalog = loaded; - _rebuildSuggestionIndex(); - _updateSuggestions(); - }); + Future _loadCatalog(String language) async { + final request = ++_catalogRequest; + try { + final loaded = await Pm3CommandCatalog.load( + preferZh: language == 'zh', bundle: DefaultAssetBundle.of(context)); + if (!mounted || request != _catalogRequest) return; + setState(() { + _catalogError = null; + _catalog = loaded; + _rebuildSuggestionIndex(); + _updateSuggestions(); + }); + } on FlutterError catch (error) { + if (mounted && request == _catalogRequest) { + setState(() => _catalogError = error); + } + } on FormatException catch (error) { + if (mounted && request == _catalogRequest) { + setState(() => _catalogError = error); + } + } } void _rebuildSuggestionIndex() { @@ -283,6 +313,9 @@ class _TerminalPageState extends State { return Column( children: [ + if (_catalogError case final error?) + Text(context.l10n.terminalCatalogError(error.toString()), + style: TextStyle(color: Theme.of(context).colorScheme.error)), // Quick command bar Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), @@ -290,13 +323,13 @@ class _TerminalPageState extends State { scrollDirection: Axis.horizontal, child: Row( children: [ - _quickBtn('高频搜索', 'hf search'), - _quickBtn('低频搜索', 'lf search'), - _quickBtn('14A 信息', 'hf 14a info'), - _quickBtn('硬件版本', 'hw version'), - _quickBtn('天线调谐', 'hw tune'), - _quickBtn('低频读取', 'lf read'), - _quickBtn('自动破解', 'hf mf autopwn'), + _quickBtn(context.l10n.terminalHfSearch, 'hf search'), + _quickBtn(context.l10n.terminalLfSearch, 'lf search'), + _quickBtn(context.l10n.terminal14aInfo, 'hf 14a info'), + _quickBtn(context.l10n.hardwareVersion, 'hw version'), + _quickBtn(context.l10n.antennaTune, 'hw tune'), + _quickBtn(context.l10n.terminalLfRead, 'lf read'), + _quickBtn(context.l10n.terminalAutopwn, 'hf mf autopwn'), ], ), ), @@ -365,7 +398,8 @@ class _TerminalPageState extends State { color: const Color(0xFF17172A), padding: const EdgeInsets.fromLTRB(10, 0, 10, 6), child: Text( - '提示:${_selectedOrFirstSuggestion!.localizedHint}', + context.l10n.messageHint( + (_selectedOrFirstSuggestion!.localizedHint).toString()), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( @@ -434,9 +468,9 @@ class _TerminalPageState extends State { fontFamily: 'monospace', fontSize: 14, ), - decoration: const InputDecoration( + decoration: InputDecoration( border: InputBorder.none, - hintText: '输入命令...(Tab/Shift+Tab 循环补全)', + hintText: context.l10n.terminalInputHint, isDense: true, contentPadding: EdgeInsets.zero, ), @@ -450,19 +484,19 @@ class _TerminalPageState extends State { IconButton( icon: const Icon(Icons.menu_book, size: 20), onPressed: _showQuickInputPanel, - tooltip: '命令库', + tooltip: context.l10n.terminalCatalog, ), IconButton( icon: const Icon(Icons.send, size: 20), onPressed: _submitCommand, - tooltip: '发送', + tooltip: context.l10n.terminalSend, ), IconButton( icon: const Icon(Icons.delete_sweep, size: 20), onPressed: () { context.read().clearTerminal(); }, - tooltip: '清屏', + tooltip: context.l10n.messageClearTerminal, ), ], ), @@ -539,9 +573,9 @@ class _TerminalPageState extends State { TextField( controller: searchCtrl, autofocus: true, - decoration: const InputDecoration( + decoration: InputDecoration( prefixIcon: Icon(Icons.search), - hintText: '搜索命令(如 hf mf, autopwn, info)', + hintText: context.l10n.terminalSearchHint, ), onChanged: updateFilter, ), diff --git a/lib/ui/pages/trace_page.dart b/lib/ui/pages/trace_page.dart index 1577553..0c64ee5 100644 --- a/lib/ui/pages/trace_page.dart +++ b/lib/ui/pages/trace_page.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/services/pm3_commands.dart'; import 'package:pm3gui/ui/components/components.dart'; +import 'package:pm3gui/l10n/l10n.dart'; class TracePage extends StatefulWidget { const TracePage({super.key}); @@ -75,14 +76,15 @@ class _TracePageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('列表/解码', + Text(context.l10n.messageListDecode, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 13)), const SizedBox(height: 8), DropdownButtonFormField( initialValue: _protocol, - decoration: const InputDecoration( - labelText: '协议类型', isDense: true), + decoration: InputDecoration( + labelText: context.l10n.messageProtocolType, + isDense: true), items: _protocols .map((p) => DropdownMenuItem(value: p, child: Text(p))) @@ -95,12 +97,12 @@ class _TracePageState extends State { onPressed: () => _execute(TraceCmd.list(type: _protocol)), icon: const Icon(Icons.list_alt, size: 18), - label: const Text('解码列表')), + label: Text(context.l10n.messageDecodeList2)), ], ))), ActionCard( - title: '提取', - subtitle: '提取 Trace 数据', + title: context.l10n.messageExtract, + subtitle: context.l10n.messageExtractTraceData, icon: Icons.filter_alt, onTap: () => _execute(TraceCmd.extract())), const SizedBox(height: 8), @@ -110,13 +112,14 @@ class _TracePageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('文件操作', + Text(context.l10n.messageFileOperations, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 13)), const SizedBox(height: 8), TextFormField( - decoration: const InputDecoration( - labelText: '文件路径', isDense: true), + decoration: InputDecoration( + labelText: context.l10n.messageFilePath, + isDense: true), onChanged: (v) => _file = v, ), const SizedBox(height: 8), @@ -126,13 +129,13 @@ class _TracePageState extends State { ? () => _execute(TraceCmd.save(_file)) : null, icon: const Icon(Icons.save, size: 18), - label: const Text('保存')), + label: Text(context.l10n.messageSave)), OutlinedButton.icon( onPressed: _file.isNotEmpty ? () => _execute(TraceCmd.load(_file)) : null, icon: const Icon(Icons.upload, size: 18), - label: const Text('加载')), + label: Text(context.l10n.messageLoad)), ]), ], ))), diff --git a/lib/ui/widgets/command_card_editor.dart b/lib/ui/widgets/command_card_editor.dart index 9946ed9..f6fb79f 100644 --- a/lib/ui/widgets/command_card_editor.dart +++ b/lib/ui/widgets/command_card_editor.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/models/mifare_card.dart'; import 'package:pm3gui/services/file_dialog_service.dart'; import 'package:pm3gui/state/app_state.dart'; +import 'package:pm3gui/l10n/l10n.dart'; /// 对命令采集草稿直接编辑;完整文件的分析/回写沿用转储查看器。 class CommandCardEditor extends StatefulWidget { @@ -17,8 +18,17 @@ class _CommandCardEditorState extends State { final _command = TextEditingController(); int _sector = 0; String? _error; + String? _savedPath; bool _running = false; + void _showError(Object error) { + if (!mounted) return; + setState(() { + _error = error.toString(); + _savedPath = null; + }); + } + @override void dispose() { _command.dispose(); @@ -40,12 +50,14 @@ class _CommandCardEditorState extends State { controller: controller, autofocus: true, decoration: InputDecoration( - errorText: error, helperText: '允许空格;? 表示未知。清空移除该项。'), + errorText: error, + helperText: context.l10n + .messageSpacesAreAllowedMeansUnknownClearThe), )), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('取消')), + child: Text(context.l10n.messageCancel)), FilledButton( onPressed: () { try { @@ -55,7 +67,7 @@ class _CommandCardEditorState extends State { update(() => error = '$e'); } }, - child: const Text('应用')), + child: Text(context.l10n.messageApply)), ], ), )); @@ -97,9 +109,14 @@ class _CommandCardEditorState extends State { } if (format == 'bin' || format == 'eml') app.setPreferredMfDumpFile(path); await app.scanForFiles(); - if (mounted) setState(() => _error = '已保存:$path'); + if (mounted) { + setState(() { + _error = null; + _savedPath = path; + }); + } } catch (e) { - if (mounted) setState(() => _error = '$e'); + _showError(e); } } @@ -113,10 +130,11 @@ class _CommandCardEditorState extends State { setState(() { _sector = 0; _error = null; + _savedPath = null; }); } } catch (e) { - if (mounted) setState(() => _error = '$e'); + _showError(e); } } @@ -126,18 +144,28 @@ class _CommandCardEditorState extends State { final card = app.commandCard; if (_sector >= card.type.sectorCount) _sector = 0; final first = card.type.sectorFirstBlock[_sector]; + final savedPath = _savedPath; + final status = _error ?? + (savedPath == null ? null : context.l10n.messageSaved(savedPath)); return ListView(padding: const EdgeInsets.all(12), children: [ - const Text('命令结果自动采集到此处,点击密钥或块数据即可编辑。换卡前请选择“新卡”;? 表示未知。'), + Text( + context.l10n.messageCommandResultsAreCollectedHereAutomaticallyClick), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ - Text( - '${card.type.label} · UID ${card.uid.isEmpty ? '未识别' : card.uid} · ${card.blocks.length} 块 · ${card.keysA.length + card.keysB.length} 项密钥'), + Text(context.l10n.messageUidBlocksKeys( + (card.type.label).toString(), + (card.uid.isEmpty + ? context.l10n.messageNotIdentified + : card.uid) + .toString(), + (card.blocks.length).toString(), + (card.keysA.length + card.keysB.length).toString())), PopupMenuButton( - tooltip: '新卡 / 选择容量', + tooltip: context.l10n.messageNewCardSelectCapacity, onSelected: (type) => setState(() { card.clear(cardType: type); _sector = 0; @@ -145,18 +173,24 @@ class _CommandCardEditorState extends State { itemBuilder: (_) => [ for (final type in [cardMini, card1K, card2K, card4K]) PopupMenuItem( - value: type, child: Text('新卡 ${type.label}(清空当前草稿)')) + value: type, + child: Text(context.l10n + .messageNewCardClearTheCurrentDraft( + (type.label).toString()))) ], - child: const Chip(label: Text('新卡 / 容量')), + child: Chip(label: Text(context.l10n.messageNewCardCapacity)), ), - OutlinedButton(onPressed: _load, child: const Text('打开草稿')), OutlinedButton( - onPressed: () => _save('json'), child: const Text('保存草稿')), + onPressed: _load, child: Text(context.l10n.messageOpenDraft)), + OutlinedButton( + onPressed: () => _save('json'), + child: Text(context.l10n.messageSaveDraft)), OutlinedButton( - onPressed: () => _save('dic'), child: const Text('导出密钥字典')), + onPressed: () => _save('dic'), + child: Text(context.l10n.messageExportKeyDictionary)), OutlinedButton( onPressed: () => _save('key.bin'), - child: const Text('导出密钥 BIN')), + child: Text(context.l10n.messageExportKeyBin2)), if (widget.openedCard != null) OutlinedButton( onPressed: () { @@ -164,23 +198,25 @@ class _CommandCardEditorState extends State { card.importCard(widget.openedCard!); setState(() => _sector = 0); } catch (e) { - setState(() => _error = '$e'); + _showError(e); } }, - child: const Text('以已打开文件替换草稿')), + child: Text(context.l10n.messageReplaceDraftWithTheOpenFile)), OutlinedButton( - onPressed: () => _save('eml'), child: const Text('导出 EML')), + onPressed: () => _save('eml'), + child: Text(context.l10n.messageExportEml2)), OutlinedButton( - onPressed: () => _save('bin'), child: const Text('导出 BIN')), + onPressed: () => _save('bin'), + child: Text(context.l10n.messageExportBin2)), ]), const SizedBox(height: 8), Row(children: [ Expanded( child: TextField( controller: _command, - decoration: const InputDecoration( - labelText: '直接命令', - hintText: '例如 hf mf chk --1k', + decoration: InputDecoration( + labelText: context.l10n.messageDirectCommand, + hintText: context.l10n.messageForExampleHfMfChk1k, border: OutlineInputBorder()))), const SizedBox(width: 8), FilledButton( @@ -195,47 +231,58 @@ class _CommandCardEditorState extends State { if (mounted) setState(() => _running = false); } }, - child: Text(_running ? '执行中' : '执行')), + child: Text(_running + ? context.l10n.messageRunning + : context.l10n.messageExecute)), ]), - if (_error != null) + if (status != null) Padding( - padding: const EdgeInsets.all(8), child: SelectableText(_error!)), + padding: const EdgeInsets.all(8), child: SelectableText(status)), const SizedBox(height: 8), Wrap(spacing: 8, children: [ DropdownButton( value: _sector, items: [ for (var s = 0; s < card.type.sectorCount; s++) - DropdownMenuItem(value: s, child: Text('扇区 $s')) + DropdownMenuItem( + value: s, + child: Text(context.l10n.messageSector((s).toString()))) ], onChanged: (s) => setState(() => _sector = s!)), TextButton( onPressed: () => setState(card.keysToTrailers), - child: const Text('密钥 → 尾块')), + child: Text(context.l10n.messageKeysTrailers)), TextButton( onPressed: () => setState(card.trailersToKeys), - child: const Text('尾块 → 密钥')), + child: Text(context.l10n.messageTrailersKeys)), ]), for (final a in [true, false]) ListTile( title: Text('Key ${a ? 'A' : 'B'}'), - subtitle: Text((a ? card.keysA : card.keysB)[_sector] ?? '未读取', + subtitle: Text( + (a ? card.keysA : card.keysB)[_sector] ?? + context.l10n.messageNotRead, style: const TextStyle(fontFamily: 'monospace')), trailing: const Icon(Icons.edit), onTap: () => _edit( - '扇区 $_sector · Key ${a ? 'A' : 'B'}', + context.l10n.messageSectorKey2( + (_sector).toString(), (a ? 'A' : 'B').toString()), (a ? card.keysA : card.keysB)[_sector] ?? '', (v) => card.editKey(_sector, a, v)), ), for (var b = first; b < first + card.type.blocksPerSector[_sector]; b++) ListTile( - title: Text( - '块 $b${b == card.type.trailerBlock(_sector) ? ' · 尾块' : ''}'), - subtitle: Text(card.blocks[b] ?? '未读取', + title: Text(context.l10n.messageBlock3( + (b).toString(), + (b == card.type.trailerBlock(_sector) + ? context.l10n.messageTrailer2 + : '') + .toString())), + subtitle: Text(card.blocks[b] ?? context.l10n.messageNotRead, style: const TextStyle(fontFamily: 'monospace')), trailing: const Icon(Icons.edit), - onTap: () => - _edit('块 $b', card.blocks[b] ?? '', (v) => card.editBlock(b, v)), + onTap: () => _edit(context.l10n.messageBlock2((b).toString()), + card.blocks[b] ?? '', (v) => card.editBlock(b, v)), ), ]); } diff --git a/pubspec.lock b/pubspec.lock index ce2026b..b8984b6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -222,6 +222,11 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -280,6 +285,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" jni: dependency: transitive description: @@ -340,10 +353,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -356,10 +369,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.19.0" mime: dependency: transitive description: @@ -561,10 +574,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.12" typed_data: dependency: transitive description: @@ -617,10 +630,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.3" vm_service: dependency: transitive description: @@ -670,5 +683,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.3 <4.0.0" + dart: ">=3.11.0-0 <4.0.0" flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 5f50b78..305b58c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: pm3gui -description: "Proxmark3 跨平台图形界面 — 读写分析 RFID/NFC 卡片,点击即用" +description: "Multilingual Proxmark3 desktop GUI for RFID/NFC operations and dump analysis." publish_to: 'none' version: 0.0.3 @@ -9,6 +9,9 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter + intl: any cupertino_icons: ^1.0.9 # State management provider: ^6.1.2 @@ -36,6 +39,7 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: + generate: true # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in diff --git a/test/backend/pm3_process_test.dart b/test/backend/pm3_process_test.dart index 91eb9d5..8fff6eb 100644 --- a/test/backend/pm3_process_test.dart +++ b/test/backend/pm3_process_test.dart @@ -4,6 +4,8 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:pm3gui/backend/desktop_cli/pm3_process.dart'; +import 'package:pm3gui/backend/desktop_cli/pm3_process_messages.dart'; +import '../support/dart_executable.dart'; void main() { late Directory fixtureDirectory; @@ -608,6 +610,44 @@ pause >nul expect(process.lastError, contains('终止')); }); + test('retained lifecycle errors use replacement framework-free messages', + () async { + final child = _FakeProcess(killResults: [false, true]); + const originalMessages = _LifecycleMessages('original'); + const replacementMessages = _LifecycleMessages('replacement'); + final process = Pm3Process( + l10n: originalMessages, + connectCooldown: Duration.zero, + gracefulExitTimeout: const Duration(milliseconds: 20), + killExitTimeout: const Duration(milliseconds: 20), + processStarter: (_, __, {workingDirectory}) async => child, + ); + addTearDown(process.shutdown); + final output = []; + final subscription = process.outputStream.listen(output.add); + addTearDown(subscription.cancel); + final connecting = process.connect(_dartExecutable, 'COM7'); + await Future.delayed(Duration.zero); + child.stdoutController.add(utf8.encode('pm3 -->\n')); + expect(await connecting.timeout(const Duration(seconds: 2)), isTrue); + + await process.disconnect().timeout(const Duration(seconds: 2)); + final originalOutput = output.toList(); + for (final messages in [ + originalMessages, + replacementMessages, + originalMessages, + ]) { + process.l10n = messages; + expect(process.lastError, + '${messages.messageFailedToTerminatePm3ReturnedFalse('TERM')};' + '${messages.messageFailedToTerminatePm3TermProcessTree}'); + expect(output, originalOutput); + expect(child.terminationForces, [false, true]); + expect(process.state, Pm3ProcessState.disconnected); + } + }); + test('exitCode error closes a connected process without a zone error', () async { final child = _FakeProcess(); @@ -788,6 +828,20 @@ final class _FakeProcess implements Pm3ProcessHandle { } } +class _LifecycleMessages extends Pm3ProcessMessages { + const _LifecycleMessages(this.prefix); + + final String prefix; + + @override + String messageFailedToTerminatePm3ReturnedFalse(String value) => + '$prefix: terminate $value returned false'; + + @override + String get messageFailedToTerminatePm3TermProcessTree => + '$prefix: TERM process-tree termination failed'; +} + const _fixtureSource = r''' import 'dart:convert'; import 'dart:io'; @@ -840,27 +894,4 @@ Future _waitUntil( return await condition() == expected; } -String get _dartExecutable { - final executable = File(Platform.resolvedExecutable); - if (executable.uri.pathSegments.last.toLowerCase().startsWith('dart')) { - return executable.path; - } - var directory = executable.parent; - while (directory.parent.path != directory.path) { - final candidate = File( - '${directory.path}${Platform.pathSeparator}dart-sdk' - '${Platform.pathSeparator}bin${Platform.pathSeparator}dart.exe', - ); - if (candidate.existsSync()) return candidate.path; - directory = directory.parent; - } - final lookup = Process.runSync( - Platform.isWindows ? 'where' : 'which', - [Platform.isWindows ? 'dart.bat' : 'dart'], - ); - final path = (lookup.stdout as String).trim().split(RegExp(r'[\r\n]+')).first; - if (lookup.exitCode != 0 || path.isEmpty) { - throw StateError('找不到当前 Dart 可执行文件'); - } - return path; -} +String get _dartExecutable => dartExecutable; diff --git a/test/l10n/app_locale_controller_test.dart b/test/l10n/app_locale_controller_test.dart new file mode 100644 index 0000000..5b9f189 --- /dev/null +++ b/test/l10n/app_locale_controller_test.dart @@ -0,0 +1,159 @@ +import 'dart:io'; +import 'dart:convert'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:pm3gui/l10n/app_locale_controller.dart'; +import 'package:pm3gui/services/locale_settings_store.dart'; +import '../support/localized_test_app.dart'; + +void main() { + test('default follows the system and explicit selection survives restart', + () async { + final repository = MemoryLocaleSettings(); + final controller = AppLocaleController(repository: repository); + await controller.initialize(); + expect(controller.locale, isNull); + await controller.setLocale(const Locale('en')); + expect(repository.language, 'en'); + final restored = AppLocaleController(repository: repository); + await restored.initialize(); + expect(restored.locale, const Locale('en')); + await restored.setLocale(null); + expect(repository.language, isNull); + controller.dispose(); + restored.dispose(); + }); + + test('unsupported selection is rejected without changing the preference', + () async { + final controller = + AppLocaleController(repository: MemoryLocaleSettings('zh')); + await controller.initialize(); + await expectLater( + controller.setLocale(const Locale('fr')), throwsArgumentError); + expect(controller.locale, const Locale('zh')); + controller.dispose(); + }); + + test('read and write failures are visible rather than reported as saved', + () async { + final controller = AppLocaleController(repository: _FailingRepository()); + await controller.initialize(); + expect(controller.error, isA()); + await controller.setLocale(const Locale('en')); + expect(controller.error, isA()); + expect(controller.isSaving, isFalse); + controller.dispose(); + }); + + test('rapid selections are serialized and flush persists the last selection', + () async { + final directory = await Directory.systemTemp.createTemp('pm3-locale-'); + try { + final repository = + LocaleSettingsStore(directoryProvider: () async => directory); + final controller = AppLocaleController(repository: repository); + await controller.initialize(); + final saves = [ + controller.setLocale(const Locale('en')), + controller.setLocale(const Locale('zh')), + controller.setLocale(null), + controller.setLocale(const Locale('en')), + ]; + await controller.flush(); + await Future.wait(saves); + expect(await repository.load(), 'en'); + expect( + directory + .listSync() + .whereType() + .map((f) => f.uri.pathSegments.last), + ['language.json']); + expect( + jsonDecode(await File( + '${directory.path}${Platform.pathSeparator}language.json') + .readAsString()), + {'language': 'en'}); + controller.dispose(); + } finally { + await directory.delete(recursive: true); + } + }); + + test('invalid saved language is surfaced and can be replaced', () async { + final controller = + AppLocaleController(repository: MemoryLocaleSettings('unsupported')); + await controller.initialize(); + expect(controller.error, isA()); + expect(controller.locale, isNull); + await controller.setLocale(const Locale('zh')); + expect(controller.error, isNull); + controller.dispose(); + }); + + test('directory lookup failure is exposed without blocking initialization', + () async { + final failure = + MissingPlatformDirectoryException('support directory unavailable'); + final controller = AppLocaleController( + repository: LocaleSettingsStore( + directoryProvider: () async => throw failure, + ), + ); + addTearDown(controller.dispose); + + await controller.initialize(); + await controller.flush(); + + expect(controller.locale, isNull); + expect(controller.error, isA()); + expect(controller.error.toString(), contains(failure.toString())); + expect(controller.isSaving, isFalse); + }); + + test('directory lookup save failures do not poison later queued saves', + () async { + final directory = + await Directory.systemTemp.createTemp('pm3-locale-directory-'); + addTearDown(() => directory.delete(recursive: true)); + var unavailable = false; + final store = LocaleSettingsStore(directoryProvider: () async { + if (unavailable) { + throw MissingPlatformDirectoryException( + 'support directory unavailable'); + } + return directory; + }); + final controller = AppLocaleController(repository: store); + addTearDown(controller.dispose); + await controller.initialize(); + unavailable = true; + + await Future.wait([ + controller.setLocale(const Locale('en')), + controller.setLocale(const Locale('zh')), + ]); + await controller.flush(); + + expect(controller.locale, const Locale('zh')); + expect(controller.error, isA()); + expect(controller.isSaving, isFalse); + + unavailable = false; + await controller.setLocale(const Locale('en')); + await controller.flush(); + + expect(controller.error, isNull); + expect(controller.isSaving, isFalse); + expect(await store.load(), 'en'); + }); +} + +class _FailingRepository implements LocaleSettingsRepository { + @override + Future load() async => throw const FormatException('invalid JSON'); + @override + Future save(String? languageCode) async => + throw const FileSystemException('permission denied'); +} diff --git a/test/l10n/localization_test.dart b/test/l10n/localization_test.dart new file mode 100644 index 0000000..b584d4c --- /dev/null +++ b/test/l10n/localization_test.dart @@ -0,0 +1,531 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:provider/provider.dart'; +import 'package:pm3gui/backend/desktop_cli/desktop_cli_backend.dart'; +import 'package:pm3gui/backend/desktop_cli/pm3_process.dart'; +import 'package:pm3gui/backend/mock/mock_pm3_backend.dart'; +import 'package:pm3gui/core/pm3/pm3_client_settings.dart'; +import 'package:pm3gui/core/pm3/pm3_controller.dart'; +import 'package:pm3gui/l10n/app_locale_controller.dart'; +import 'package:pm3gui/l10n/l10n.dart'; +import 'package:pm3gui/main.dart'; +import 'package:pm3gui/models/dump_analysis.dart'; +import 'package:pm3gui/parsers/dump_parser.dart'; +import 'package:pm3gui/services/dump_converter.dart'; +import 'package:pm3gui/services/pm3_command_catalog.dart'; +import 'package:pm3gui/services/pm3_settings_store.dart'; +import 'package:pm3gui/services/locale_settings_store.dart'; +import 'package:pm3gui/state/app_state.dart'; +import 'package:pm3gui/state/connection_state.dart' as connection; +import '../support/localized_test_app.dart'; +import '../support/file_dialogs.dart'; + +void main() { + for (final language in ['en', 'zh']) { + testWidgets('all pages render in $language without layout errors', + (tester) async { + tester.view.physicalSize = const Size(1500, 1100); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final app = _appState(); + final locale = + AppLocaleController(repository: MemoryLocaleSettings(language)); + final initialized = Future.wait([app.initialize(), locale.initialize()]); + await tester.pump(); + await initialized; + await tester.pumpWidget(ChangeNotifierProvider.value( + value: app, + child: DefaultAssetBundle( + bundle: _CatalogBundle(), + child: PM3GuiApp(localeController: locale), + ), + )); + await _render(tester); + for (final page in AppPage.values) { + app.navigateTo(page); + await _render(tester); + expect(tester.takeException(), isNull, + reason: '$language / ${page.name}'); + final tabBars = find.byType(TabBar); + if (tabBars.evaluate().isNotEmpty) { + final tabs = + find.descendant(of: tabBars.first, matching: find.byType(Tab)); + final count = tabs.evaluate().length; + for (var index = 0; index < count; index++) { + await tester.tap(tabs.at(index)); + await _render(tester); + expect(tester.takeException(), isNull, + reason: '$language / ${page.name} / tab $index'); + } + } + } + await tester.pumpWidget(const SizedBox()); + app.dispose(); + await tester.pump(); + locale.dispose(); + }); + } + + testWidgets( + 'language picker switches immediately, preserves data and saves selection', + (tester) async { + tester.view.physicalSize = const Size(1500, 1100); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final repository = MemoryLocaleSettings('zh'); + final locale = AppLocaleController(repository: repository); + final app = _appState(); + final initialized = Future.wait([app.initialize(), locale.initialize()]); + await tester.pump(); + await initialized; + app.commandCard.uid = '01020304'; + app.navigateTo(AppPage.settings); + await tester.pumpWidget(ChangeNotifierProvider.value( + value: app, + child: DefaultAssetBundle( + bundle: _CatalogBundle(), + child: PM3GuiApp(localeController: locale), + ), + )); + await _render(tester); + expect(find.text('语言'), findsWidgets); + await tester.tap(find.byKey(const ValueKey('language-zh'))); + await _render(tester); + await tester.tap(find.text('English').last); + await _render(tester); + expect(find.text('Language'), findsWidgets); + expect(find.text('Settings'), findsWidgets); + expect(find.text('语言'), findsNothing); + expect(repository.language, 'en'); + expect(app.commandCard.uid, '01020304'); + expect( + () => app.commandCard.editBlock(-1, ''), + throwsA(isA().having( + (e) => e.message, 'localized message', contains('Block number')))); + await tester.tap(find.byKey(const ValueKey('language-en'))); + await _render(tester); + await tester.tap(find.text('简体中文').last); + await _render(tester); + expect(find.text('语言'), findsWidgets); + expect(repository.language, 'zh'); + await tester.pumpWidget(const SizedBox()); + app.dispose(); + await tester.pump(); + locale.dispose(); + }); + + testWidgets( + 'directory lookup failure still allows startup and a Settings error', + (tester) async { + tester.view.physicalSize = const Size(1500, 1100); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final app = _appState(); + final locale = AppLocaleController( + repository: LocaleSettingsStore( + directoryProvider: () async => throw MissingPlatformDirectoryException( + 'support directory unavailable'), + ), + ); + try { + final initialized = Future.wait([app.initialize(), locale.initialize()]); + final initialization = expectLater(initialized, completes); + await tester.pump(); + await initialization; + app.navigateTo(AppPage.settings); + await tester.pumpWidget(ChangeNotifierProvider.value( + value: app, + child: DefaultAssetBundle( + bundle: _CatalogBundle(), + child: PM3GuiApp(localeController: locale), + ), + )); + await _render(tester); + + expect(find.byKey(const ValueKey('language-error')), findsOneWidget); + expect( + find.textContaining('support directory unavailable'), findsOneWidget); + expect(tester.takeException(), isNull); + } finally { + await tester.pumpWidget(const SizedBox()); + app.dispose(); + await tester.pump(); + locale.dispose(); + } + }); + + testWidgets( + 'connection failure follows Settings language without retrying or rewriting terminal output', + (tester) async { + tester.view.physicalSize = const Size(1500, 1100); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var starts = 0; + final process = Pm3Process( + processStarter: (_, __, {workingDirectory}) async { + starts++; + throw StateError('A missing client must not be started'); + }, + ); + final backend = DesktopCliBackend(process: process); + final app = _OfflineAppState( + connectionState: connection.ConnectionState( + controller: Pm3Controller(backend), + settingsStore: _Settings(), + ), + ); + final locale = AppLocaleController(repository: MemoryLocaleSettings('zh')); + final initialized = Future.wait([app.initialize(), locale.initialize()]); + await tester.pump(); + await initialized; + final path = '${Directory.systemTemp.path}${Platform.pathSeparator}' + 'pm3gui-missing-${DateTime.now().microsecondsSinceEpoch}.exe'; + expect(File(path).existsSync(), isFalse); + app.connectionState + ..pm3Path = path + ..portName = 'test'; + final states = []; + final subscription = process.stateStream.listen(states.add); + addTearDown(subscription.cancel); + try { + await tester.pumpWidget(ChangeNotifierProvider.value( + value: app, + child: DefaultAssetBundle( + bundle: _CatalogBundle(), + child: PM3GuiApp(localeController: locale), + ), + )); + await _render(tester); + final connecting = app.connect(); + await tester.pump(); + expect(await connecting, isFalse); + await _render(tester); + final chinese = lookupAppLocalizations(const Locale('zh')); + expect(find.text(chinese.messagePm3ProgramNotFoundOnWindowsSelect(path)), + findsOneWidget); + final terminal = app.terminalOutput.toList(); + final history = app.commandHistory.toList(); + expect(terminal, [ + chinese.messageError( + chinese.messagePm3ProgramNotFoundOnWindowsSelect(path)), + ]); + + for (final language in ['en', 'zh']) { + final l10n = lookupAppLocalizations(Locale(language)); + final other = + lookupAppLocalizations(Locale(language == 'en' ? 'zh' : 'en')); + app.navigateTo(AppPage.settings); + await _render(tester); + await tester.tap( + find.byKey(ValueKey('language-${locale.locale!.languageCode}'))); + await _render(tester); + await tester.tap(find.text(language == 'en' ? 'English' : '简体中文').last); + await _render(tester); + app.navigateTo(AppPage.connection); + await _render(tester); + + final expected = l10n.messagePm3ProgramNotFoundOnWindowsSelect(path); + expect(find.text(expected), findsOneWidget); + expect(find.text(other.messagePm3ProgramNotFoundOnWindowsSelect(path)), + findsNothing); + expect(app.lastError, expected); + expect(app.isConnected, isFalse); + expect(app.pm3Path, path); + expect(app.terminalOutput, orderedEquals(terminal)); + expect(app.commandHistory, orderedEquals(history)); + expect( + states, [Pm3ProcessState.connecting, Pm3ProcessState.disconnected]); + expect(starts, 0); + expect(tester.takeException(), isNull); + } + } finally { + await tester.pumpWidget(const SizedBox()); + app.dispose(); + await tester.pump(); + locale.dispose(); + } + }); + + for (final status in [ + WriteStatus.writing, + WriteStatus.finished, + WriteStatus.cancelled, + WriteStatus.connectionLost, + ]) { + testWidgets( + '${status.name} write progress and results follow Settings language changes', + (tester) async { + tester.view.physicalSize = const Size(1600, 1100); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final dump = (await tester.runAsync(() async { + final directory = + await Directory.systemTemp.createTemp('pm3-write-locale-'); + addTearDown(() => directory.delete(recursive: true)); + return File('${directory.path}${Platform.pathSeparator}sample.bin') + ..writeAsBytesSync(Uint8List(1024)); + }))!; + final app = _appState(); + final locale = + AppLocaleController(repository: MemoryLocaleSettings('zh')); + final initialized = Future.wait([app.initialize(), locale.initialize()]); + await tester.pump(); + await initialized; + final progress = WriteProgress( + total: status == WriteStatus.finished ? 2 : 4, + completed: 2, + succeeded: 1, + failed: 1, + status: status, + currentBlock: 9, + results: const [ + WriteBlockResult(block: 4, success: true), + WriteBlockResult(block: 7, success: false), + ], + ); + final results = progress.results.toList(); + app.writeProgress = progress; + app.terminalState.addOutput('Auth error - 原始输出'); + app.requestNavigationIntent( + AppPage.dumpViewer, + action: 'open_file', + params: {'path': dump.path}, + ); + try { + await tester.pumpWidget(ChangeNotifierProvider.value( + value: app, + child: DefaultAssetBundle( + bundle: _CatalogBundle(), + child: PM3GuiApp(localeController: locale), + ), + )); + for (var attempt = 0; + attempt < 100 && app.preferredMfDumpFile != dump.path; + attempt++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10))); + await tester.pump(); + } + expect(app.preferredMfDumpFile, dump.path); + await _render(tester); + final zh = lookupAppLocalizations(const Locale('zh')); + await tester.tap(find.text(zh.messageWriteClear)); + await tester.pumpAndSettle(); + + for (final language in ['zh', 'en', 'zh']) { + final l10n = lookupAppLocalizations(Locale(language)); + final other = + lookupAppLocalizations(Locale(language == 'en' ? 'zh' : 'en')); + if (locale.locale?.languageCode != language) { + app.navigateTo(AppPage.settings); + await _render(tester); + final currentLanguage = locale.locale!.languageCode; + await tester.tap(find.byKey(ValueKey('language-$currentLanguage'))); + await _render(tester); + await tester + .tap(find.text(language == 'en' ? 'English' : '简体中文').last); + await _render(tester); + app.navigateTo(AppPage.dumpViewer); + await _render(tester); + } + await tester.ensureVisible(find.text(l10n.messageWriteProgress)); + await _render(tester); + expect(find.text(progress.localizedStatus(l10n)), findsOneWidget); + expect(find.text(l10n.messageWriteSuccessful), findsOneWidget); + expect(find.text(l10n.messageAuthenticationOrWritingFailed), + findsOneWidget); + expect(find.text(progress.localizedStatus(other)), findsNothing); + expect(find.text(other.messageWriteSuccessful), findsNothing); + expect(find.text(other.messageAuthenticationOrWritingFailed), + findsNothing); + expect(app.writeProgress, same(progress)); + expect(progress.status, status); + expect(progress.completed, 2); + expect(progress.succeeded, 1); + expect(progress.failed, 1); + expect(progress.results, orderedEquals(results)); + expect(progress.results.map((r) => r.block), [4, 7]); + expect(app.preferredMfDumpFile, dump.path); + expect(app.terminalOutput, contains('Auth error - 原始输出')); + } + } finally { + await tester.pumpWidget(const SizedBox()); + app.dispose(); + await tester.pump(); + locale.dispose(); + } + }); + } + + testWidgets( + 'editor save status follows Settings language without saving again', + (tester) async { + tester.view.physicalSize = const Size(1500, 1100); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final directory = Directory.systemTemp.createTempSync('editor-locale-'); + addTearDown(() => directory.deleteSync(recursive: true)); + final path = '${directory.path}${Platform.pathSeparator}command-card.json'; + final dialogs = TestFileDialogs()..savePath = path; + final app = _appState(); + final locale = AppLocaleController(repository: MemoryLocaleSettings('zh')); + final initialized = Future.wait([app.initialize(), locale.initialize()]); + await tester.pump(); + await initialized; + app.commandCard.uid = '01020304'; + app.navigateTo(AppPage.dumpViewer); + final zh = lookupAppLocalizations(const Locale('zh')); + try { + await tester.pumpWidget(ChangeNotifierProvider.value( + value: app, + child: DefaultAssetBundle( + bundle: _CatalogBundle(), + child: PM3GuiApp(localeController: locale), + ), + )); + await _render(tester); + await tester.tap(find.descendant( + of: find.byType(TabBar), matching: find.text(zh.messageCommandData))); + await tester.pumpAndSettle(); + await tester.tap(find.text(zh.messageSaveDraft)); + await pumpFileOperation( + tester, () => find.text(zh.messageSaved(path)).evaluate().isNotEmpty); + final saved = File(path).readAsStringSync(); + final modified = File(path).lastModifiedSync(); + final draft = app.commandCard; + for (final language in ['en', 'zh']) { + app.navigateTo(AppPage.settings); + await _render(tester); + await tester.tap( + find.byKey(ValueKey('language-${locale.locale!.languageCode}'))); + await _render(tester); + await tester.tap(find.text(language == 'en' ? 'English' : '简体中文').last); + await _render(tester); + app.navigateTo(AppPage.dumpViewer); + await _render(tester); + final l10n = lookupAppLocalizations(Locale(language)); + final other = + lookupAppLocalizations(Locale(language == 'en' ? 'zh' : 'en')); + expect(find.text(l10n.messageSaved(path)), findsOneWidget); + expect(find.text(other.messageSaved(path)), findsNothing); + expect(app.commandCard, same(draft)); + expect(app.commandCard.uid, '01020304'); + expect(File(path).readAsStringSync(), saved); + expect(File(path).lastModifiedSync(), modified); + expect(dialogs.calls.map((call) => call.method), ['getSavePath']); + expect(tester.takeException(), isNull); + } + } finally { + await tester.pumpWidget(const SizedBox()); + app.dispose(); + await tester.pump(); + locale.dispose(); + } + }); + + for (final systemLocale in [ + const Locale('fr', 'FR'), + const Locale('zh', 'CN'), + const Locale('en', 'GB') + ]) { + testWidgets('system locale $systemLocale resolves to a supported language', + (tester) async { + tester.binding.platformDispatcher.localesTestValue = [systemLocale]; + addTearDown(tester.binding.platformDispatcher.clearLocalesTestValue); + await tester.pumpWidget(MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (context) => Text(context.l10n.navSettings)), + )); + await tester.pumpAndSettle(); + expect(find.text(systemLocale.languageCode == 'zh' ? '设置' : 'Settings'), + findsOneWidget); + final context = tester.element(find.byType(Text).last); + final material = MaterialLocalizations.of(context); + expect(material.cancelButtonLabel, + systemLocale.languageCode == 'zh' ? '取消' : 'Cancel'); + }); + } + + testWidgets( + 'command descriptions follow the language without changing command syntax', + (tester) async { + final zh = + await Pm3CommandCatalog.load(preferZh: true, bundle: _CatalogBundle()); + final en = + await Pm3CommandCatalog.load(preferZh: false, bundle: _CatalogBundle()); + final zhCommands = zh.map((e) => e.command).toSet(); + final enCommands = en.map((e) => e.command).toSet(); + expect(enCommands, zhCommands); + final chinese = zh.firstWhere((e) => e.command == 'hf 14a info'); + final english = en.firstWhere((e) => e.command == 'hf 14a info'); + expect(RegExp(r'[\u3400-\u9fff]').hasMatch(chinese.localizedHint), isTrue); + expect(RegExp(r'[\u3400-\u9fff]').hasMatch(english.localizedHint), isFalse); + }); + + test('analysis caches are locale-aware and format labels are localized', () { + final en = lookupAppLocalizations(const Locale('en')); + final zh = lookupAppLocalizations(const Locale('zh')); + expect(en.messageCommands(0), 'No commands'); + expect(en.messageCommands(1), '1 command'); + expect(en.messageCommands(2), '2 commands'); + expect(zh.messageCommands(2), '2 条'); + expect(madAidDescriptionsFor(en)[0x0038], 'Access control'); + expect(madAidDescriptionsFor(zh)[0x0038], '门禁控制'); + expect(DumpFormat.bin.localizedLabel(en), 'Binary dump (.bin)'); + expect(DumpFormat.bin.localizedLabel(zh), '二进制转储 (.bin)'); + final result = parseDumpBytes( + // A valid blank 1K dump, not a hardware acquisition. + Uint8List(1024), + ); + final english = result.analysisFor(en); + expect(identical(english, result.analysisFor(en)), isTrue); + expect(identical(english, result.analysisFor(zh)), isFalse); + }); +} + +Future _render(WidgetTester tester) async { + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(); +} + +AppState _appState() => _OfflineAppState( + connectionState: connection.ConnectionState( + controller: Pm3Controller(MockPm3Backend()), + settingsStore: _Settings(), + ), + ); + +class _OfflineAppState extends AppState { + _OfflineAppState({required super.connectionState}); + + @override + Future scanForFiles() async {} +} + +class _CatalogBundle extends CachingAssetBundle { + @override + Future loadString(String key, {bool cache = true}) => + SynchronousFuture(File(key).readAsStringSync()); + + @override + Future load(String key) async => + ByteData.sublistView(File(key).readAsBytesSync()); +} + +class _Settings implements Pm3SettingsRepository { + @override + Future load() async => null; + @override + Future save(Pm3ClientSettings settings) async {} +} diff --git a/test/l10n/pm3_process_messages_test.dart b/test/l10n/pm3_process_messages_test.dart new file mode 100644 index 0000000..0ac9e7d --- /dev/null +++ b/test/l10n/pm3_process_messages_test.dart @@ -0,0 +1,187 @@ +import 'dart:io'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pm3gui/backend/desktop_cli/desktop_cli_backend.dart'; +import 'package:pm3gui/backend/desktop_cli/pm3_process.dart'; +import 'package:pm3gui/backend/desktop_cli/pm3_process_messages.dart'; +import 'package:pm3gui/core/pm3/pm3_connection.dart'; +import 'package:pm3gui/l10n/app_localizations.dart'; +import 'package:pm3gui/l10n/localized_pm3_process_messages.dart'; + +void main() { + test('standalone Chinese defaults match every generated process message', () { + final chinese = lookupAppLocalizations(const Locale('zh')); + expect( + _snapshot(const Pm3ProcessMessages()), + _snapshot(LocalizedPm3ProcessMessages(chinese)), + ); + }); + + test('backend adapts live language changes without changing the process', + () async { + final process = Pm3Process(); + final backend = DesktopCliBackend(process: process); + addTearDown(backend.shutdown); + final english = lookupAppLocalizations(const Locale('en')); + final chinese = lookupAppLocalizations(const Locale('zh')); + + backend.updateLocalizations(english); + expect(process.l10n, isA()); + expect(process.l10n.pm3Busy, english.pm3Busy); + expect(process.l10n.messageConnectionFailed('detail'), + english.messageConnectionFailed('detail')); + expect(process.l10n.messageCouldNotStartPm3PathCheckThe('error', 'path'), + english.messageCouldNotStartPm3PathCheckThe('error', 'path')); + expect(process.l10n.messagePm3ProgramNotFoundOnWindowsSelect('path'), + english.messagePm3ProgramNotFoundOnWindowsSelect('path')); + + backend.updateLocalizations(chinese); + expect(_snapshot(process.l10n), _snapshot(const Pm3ProcessMessages())); + }); + + test('process accepts a framework-independent message implementation', + () async { + final process = Pm3Process(l10n: const _CustomMessages()); + await process.shutdown(); + + expect(await process.connect('unused-client', 'unused-port'), isFalse); + expect(process.lastError, 'custom disposed message'); + process.l10n = const _CustomMessages('replacement disposed message'); + expect(process.lastError, 'replacement disposed message'); + }); + + test('missing-client error follows the locale without reconnecting or output', + () async { + var starts = 0; + final process = Pm3Process( + processStarter: (_, __, {workingDirectory}) async { + starts++; + throw StateError('A missing client must not be started'); + }, + ); + final backend = DesktopCliBackend(process: process); + addTearDown(backend.shutdown); + final output = []; + final states = []; + final outputSubscription = process.outputStream.listen(output.add); + final stateSubscription = process.stateStream.listen(states.add); + addTearDown(outputSubscription.cancel); + addTearDown(stateSubscription.cancel); + final chinese = lookupAppLocalizations(const Locale('zh')); + final english = lookupAppLocalizations(const Locale('en')); + final path = '${Directory.systemTemp.path}${Platform.pathSeparator}' + 'pm3gui-missing-${DateTime.now().microsecondsSinceEpoch}.exe'; + expect(File(path).existsSync(), isFalse); + backend.updateLocalizations(chinese); + + await backend.connect(Pm3ConnectionConfig(executable: path, port: 'test')); + + final originalOutput = [ + chinese + .messageError(chinese.messagePm3ProgramNotFoundOnWindowsSelect(path)), + ]; + expect(output, originalOutput); + expect(states, [Pm3ProcessState.connecting, Pm3ProcessState.disconnected]); + for (final l10n in [chinese, english, chinese]) { + backend.updateLocalizations(l10n); + final expected = l10n.messagePm3ProgramNotFoundOnWindowsSelect(path); + expect(process.lastError, expected); + expect(backend.lastError, expected); + expect(backend.state, Pm3ConnectionState.disconnected); + expect(output, originalOutput); + expect( + states, [Pm3ProcessState.connecting, Pm3ProcessState.disconnected]); + expect(starts, 0); + } + }); + + for (final failure in [ + ProcessException('client', const [], 'raw OS diagnostic 37'), + StateError('raw startup diagnostic 42'), + ]) { + test('retained ${failure.runtimeType} arguments follow the locale', + () async { + var starts = 0; + final process = Pm3Process( + processStarter: (_, __, {workingDirectory}) async { + starts++; + throw failure; + }, + ); + final backend = DesktopCliBackend(process: process); + addTearDown(backend.shutdown); + final output = []; + final subscription = process.outputStream.listen(output.add); + addTearDown(subscription.cancel); + final chinese = lookupAppLocalizations(const Locale('zh')); + final english = lookupAppLocalizations(const Locale('en')); + final path = Platform.resolvedExecutable; + backend.updateLocalizations(chinese); + + await backend + .connect(Pm3ConnectionConfig(executable: path, port: 'test')); + + final originalOutput = output.toList(); + for (final l10n in [chinese, english, chinese]) { + backend.updateLocalizations(l10n); + final expected = failure is ProcessException + ? l10n.messageCouldNotStartPm3PathCheckThe(failure.message, path) + : l10n.messageConnectionFailed(failure.toString()); + expect(backend.lastError, expected); + expect(output, originalOutput); + expect(starts, 1); + expect(backend.state, Pm3ConnectionState.disconnected); + } + }); + } +} + +List _snapshot(Pm3ProcessMessages messages) => [ + messages.messageConnectionFailed('detail'), + messages.messageConnectionTimedOut, + messages.messageConnectionTimedOutSCheckThatThe('3'), + messages.messageCoolingDownS('3'), + messages.messageCouldNotReadTheExitStatusAfter('stdout', 'detail'), + messages.messageCouldNotStartPm3PathCheckThe('detail', 'path'), + messages.messageError('detail'), + messages.messageErrorCommandTimedOutTheSessionWas, + messages.messageFailedToForceTerminatePm3KillProcess, + messages.messageFailedToSendQuit('detail'), + messages.messageFailedToSendTheReadOnlyPm3('detail'), + messages.messageFailedToTerminatePm3('detail'), + messages.messageFailedToTerminatePm3ReturnedFalse('TERM'), + messages.messageFailedToTerminatePm3TermProcessTree, + messages.messageFailedWhileWaitingForPm3ToExit('detail'), + messages.messageFailedWhileWaitingForPm3ToStart('detail'), + messages.messageFatalErrorOnPm3Stderr, + messages.messageFatalErrorOnPm3Stdout, + messages.messageNotConnected, + messages.messagePermissionDeniedForTheSerialPortTry, + messages.messagePleaseWaitSecondsBeforeRetrying('3'), + messages.messagePm3ProcessExitStatusError('detail'), + messages.messagePm3ProcessExitedBeforeTheConnectionPrompt('9'), + messages.messagePm3ProcessExitedCode('9'), + messages.messagePm3ProgramNotFoundOnWindowsSelect('path'), + messages.messagePm3StderrStreamError('detail'), + messages.messagePm3StdoutStreamError('detail'), + messages.messagePm3StreamClosed('stdout'), + messages.messageSerialConnectionError('detail'), + messages.messageSerialDeviceNotFoundCheckThePm3, + messages.messageThePm3ProcessManagerHasBeenDisposed, + messages.messageThePm3SessionDisconnectedBeforeTheCommand, + messages.messageTheSerialPortIsInUseBy, + messages.messageTimedOutWaitingForPm3ToExit, + messages.messageUsingPm3('path'), + messages.messageWorkingDirectory('directory'), + messages.pm3Busy, + ]; + +class _CustomMessages extends Pm3ProcessMessages { + const _CustomMessages([this.disposedMessage = 'custom disposed message']); + + final String disposedMessage; + + @override + String get messageThePm3ProcessManagerHasBeenDisposed => disposedMessage; +} diff --git a/test/models/command_card_test.dart b/test/models/command_card_test.dart index d879a47..6e1d11c 100644 --- a/test/models/command_card_test.dart +++ b/test/models/command_card_test.dart @@ -1,4 +1,8 @@ +import 'dart:convert'; + +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:pm3gui/l10n/app_localizations.dart'; import 'package:pm3gui/models/command_card.dart'; import 'package:pm3gui/models/mifare_card.dart'; import 'package:pm3gui/parsers/key_parser.dart'; @@ -64,6 +68,58 @@ void main() { expect(restored.exportDictionary(), 'ABCDEF012345\n'); }); + for (final field in ['blocks', 'keysA', 'keysB']) { + test('English draft import localizes invalid $field and remains atomic', + () { + final english = lookupAppLocalizations(const Locale('en')); + final card = _populatedCard(english); + final saved = card.exportDraft(); + final draft = jsonEncode({ + 'format': 'pm3gui-command-card-v1', + 'blocksCount': card1K.blockCount, + 'uid': '55667788', + 'blocks': {'0': field == 'blocks' ? 'ZZ' : 'CD' * 16}, + 'keysA': {'0': field == 'keysA' ? 'ZZ' : 'C0C1C2C3C4C5'}, + 'keysB': {'0': field == 'keysB' ? 'ZZ' : 'D0D1D2D3D4D5'}, + }); + final message = english.messageEnterHexDigitsUseForUnknownDigits( + field == 'blocks' ? '32' : '12'); + + expect( + () => card.importDraft(draft), + throwsA(isA() + .having((error) => error.message, 'English message', message)), + ); + expect(card.exportDraft(), saved); + expect(card.l10n, same(english)); + }); + + test('English card import localizes invalid $field and remains atomic', () { + final english = lookupAppLocalizations(const Locale('en')); + final card = _populatedCard(english); + final saved = card.exportDraft(); + final imported = MifareCard(uid: '55667788'); + switch (field) { + case 'blocks': + imported.blocks[0] = 'ZZ'; + case 'keysA': + imported.sectorKeys[0].keyA = 'ZZ'; + case 'keysB': + imported.sectorKeys[0].keyB = 'ZZ'; + } + final message = english.messageEnterHexDigitsUseForUnknownDigits( + field == 'blocks' ? '32' : '12'); + + expect( + () => card.importCard(imported), + throwsA(isA() + .having((error) => error.message, 'English message', message)), + ); + expect(card.exportDraft(), saved); + expect(card.l10n, same(english)); + }); + } + test('complete export has correct capacity and RRG key ordering', () { final card = CommandCard()..clear(cardType: cardMini); for (var b = 0; b < 20; b++) { @@ -93,3 +149,11 @@ void main() { expect(card.keysA, isEmpty); }); } + +CommandCard _populatedCard(AppLocalizations localizations) => CommandCard() + ..updateLocalizations(localizations) + ..clear(cardType: cardMini) + ..uid = '11223344' + ..editBlock(0, 'AB' * 16) + ..editKey(0, true, 'A0A1A2A3A4A5') + ..editKey(0, false, 'B0B1B2B3B4B5'); diff --git a/test/services/locale_settings_store_test.dart b/test/services/locale_settings_store_test.dart new file mode 100644 index 0000000..51ab2c9 --- /dev/null +++ b/test/services/locale_settings_store_test.dart @@ -0,0 +1,95 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:pm3gui/services/locale_settings_store.dart'; + +void main() { + final failures = [ + MissingPlatformDirectoryException( + 'support directory unavailable', + details: 'native lookup detail', + ), + PlatformException( + code: 'directory_unavailable', + message: 'support directory unavailable', + details: 'native lookup detail', + ), + MissingPluginException('path provider unavailable'), + ]; + + for (final failure in failures) { + test('load normalizes ${failure.runtimeType} directory lookup failures', + () async { + final store = _failingStore(failure); + + await expectLater(store.load(), _storageFailure(failure)); + }); + + test('save normalizes ${failure.runtimeType} directory lookup failures', + () async { + final store = _failingStore(failure); + + await expectLater(store.save('en'), _storageFailure(failure)); + }); + } + + test('directory lookup conversion preserves the original stack', () async { + final failure = MissingPlatformDirectoryException('lookup failed'); + final originalStack = + StackTrace.fromString('path_provider directory lookup'); + final store = LocaleSettingsStore( + directoryProvider: () => Future.error(failure, originalStack), + ); + + try { + await store.load(); + fail('Expected a storage failure'); + } on FileSystemException catch (error, stack) { + expect(error.message, contains(failure.toString())); + expect(stack.toString(), originalStack.toString()); + } + }); + + for (final save in [false, true]) { + final operation = save ? 'save' : 'load'; + test('$operation preserves an existing directory FileSystemException', + () async { + final failure = FileSystemException( + 'access denied', + 'application-support', + const OSError('access denied', 5), + ); + final store = _failingStore(failure); + + await expectLater( + save ? store.save('en') : store.load(), + throwsA(same(failure)), + ); + }); + + test('$operation does not mask unexpected directory provider errors', + () async { + final failure = StateError('unexpected provider bug'); + final store = _failingStore(failure); + + await expectLater( + save ? store.save('en') : store.load(), + throwsA(same(failure)), + ); + }); + } +} + +LocaleSettingsStore _failingStore(Object failure) => LocaleSettingsStore( + directoryProvider: () async => throw failure, + ); + +Matcher _storageFailure(Exception cause) => throwsA( + isA().having( + (error) => error.message, + 'directory lookup diagnostic', + contains(cause.toString()), + ), + ); diff --git a/test/state/write_progress_test.dart b/test/state/write_progress_test.dart new file mode 100644 index 0000000..d3ffd5a --- /dev/null +++ b/test/state/write_progress_test.dart @@ -0,0 +1,268 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart' show Locale; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pm3gui/core/pm3/pm3_backend.dart'; +import 'package:pm3gui/core/pm3/pm3_client_settings.dart'; +import 'package:pm3gui/core/pm3/pm3_command.dart'; +import 'package:pm3gui/core/pm3/pm3_connection.dart'; +import 'package:pm3gui/core/pm3/pm3_controller.dart'; +import 'package:pm3gui/core/pm3/pm3_event.dart'; +import 'package:pm3gui/core/pm3/pm3_result.dart'; +import 'package:pm3gui/l10n/l10n.dart'; +import 'package:pm3gui/services/pm3_settings_store.dart'; +import 'package:pm3gui/state/app_state.dart'; +import 'package:pm3gui/state/connection_state.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final en = lookupAppLocalizations(const Locale('en')); + final zh = lookupAppLocalizations(const Locale('zh')); + + test('write statuses and block outcomes format in the requested locale', () { + final progress = WriteProgress(total: 3); + expect(progress.localizedStatus(en), isEmpty); + expect(progress.localizedStatus(zh), isEmpty); + expect(progress.progress, 0); + expect(progress.isRunning, isTrue); + expect(progress.cancelled, isFalse); + + progress + ..status = WriteStatus.writing + ..currentBlock = 17; + expect(progress.localizedStatus(en), en.messageWritingBlock('17')); + expect(progress.localizedStatus(zh), zh.messageWritingBlock('17')); + expect(progress.isRunning, isTrue); + + progress + ..completed = 3 + ..succeeded = 2 + ..failed = 1 + ..status = WriteStatus.finished; + expect(progress.localizedStatus(en), + en.messageFinishedSucceededFailed('2', '1')); + expect(progress.localizedStatus(zh), + zh.messageFinishedSucceededFailed('2', '1')); + expect(progress.progress, 1); + expect(progress.isRunning, isFalse); + expect(progress.cancelled, isFalse); + + for (final status in [WriteStatus.cancelled, WriteStatus.connectionLost]) { + progress + ..completed = 1 + ..status = status; + expect(progress.cancelled, isTrue); + expect(progress.isRunning, isFalse); + for (final l10n in [en, zh]) { + expect( + progress.localizedStatus(l10n), + status == WriteStatus.cancelled + ? l10n.messageCancelled + : l10n.messageConnectionLostOperationAborted); + } + } + + const success = WriteBlockResult(block: 4, success: true); + const failure = WriteBlockResult(block: 5, success: false); + for (final l10n in [zh, en, zh]) { + expect(success.localizedMessage(l10n), l10n.messageWriteSuccessful); + expect(failure.localizedMessage(l10n), + l10n.messageAuthenticationOrWritingFailed); + } + }); + + test('completed sequences retain mixed results across locale changes', + () async { + final backend = _WriteBackend(); + final app = _appState(backend)..updateLocalizations(zh); + addTearDown(app.shutdown); + final progress = await app.sendCommandSequence( + [(4, 'write-ok'), (5, 'write-auth-fail'), (6, 'write-fail')], + delayBetween: Duration.zero, + ); + final results = progress.results.toList(); + final rawOutput = app.terminalOutput.toList(); + + expect(progress.status, WriteStatus.finished); + expect(progress.completed, 3); + expect(progress.succeeded, 1); + expect(progress.failed, 2); + expect(progress.results.map((r) => (r.block, r.success)), + [(4, true), (5, false), (6, false)]); + expect(backend.commands, ['write-ok', 'write-auth-fail', 'write-fail']); + expect(progress.localizedStatus(zh), + zh.messageFinishedSucceededFailed('1', '2')); + + for (final l10n in [en, zh]) { + app.updateLocalizations(l10n); + expect(app.writeProgress, same(progress)); + expect(progress.results, orderedEquals(results)); + expect(progress.localizedStatus(l10n), + l10n.messageFinishedSucceededFailed('1', '2')); + expect(progress.results.first.localizedMessage(l10n), + l10n.messageWriteSuccessful); + expect(progress.results.last.localizedMessage(l10n), + l10n.messageAuthenticationOrWritingFailed); + expect(app.terminalOutput, rawOutput); + } + app.cancelWriteSequence(); + expect(progress.status, WriteStatus.finished); + }); + + test('locale switching and cancellation retain the in-flight block result', + () async { + final gate = Completer(); + final backend = _WriteBackend(commandGate: gate); + final app = _appState(backend)..updateLocalizations(zh); + addTearDown(app.shutdown); + final writing = app.sendCommandSequence( + [(17, 'write-ok'), (18, 'write-ok')], + delayBetween: Duration.zero, + ); + final progress = app.writeProgress!; + expect(progress.status, WriteStatus.writing); + expect(progress.currentBlock, 17); + expect(progress.localizedStatus(zh), zh.messageWritingBlock('17')); + + app.updateLocalizations(en); + expect(app.writeProgress, same(progress)); + expect(progress.localizedStatus(en), en.messageWritingBlock('17')); + expect(progress.isRunning, isTrue); + app.cancelWriteSequence(); + expect(progress.localizedStatus(en), en.messageCancelled); + expect(progress.isRunning, isFalse); + gate.complete(); + expect(await writing, same(progress)); + + expect(progress.status, WriteStatus.cancelled); + expect(progress.completed, 1); + expect(progress.succeeded, 1); + expect(progress.failed, 0); + expect(progress.results.single.block, 17); + expect(backend.commands, ['write-ok']); + app.updateLocalizations(zh); + expect(progress.localizedStatus(zh), zh.messageCancelled); + expect(progress.results.single.localizedMessage(zh), + zh.messageWriteSuccessful); + app.updateLocalizations(en); + expect(progress.localizedStatus(en), en.messageCancelled); + expect(progress.results.single.localizedMessage(en), + en.messageWriteSuccessful); + }); + + test('connection loss retains previous block results in both languages', + () async { + final backend = _WriteBackend(); + final app = _appState(backend)..updateLocalizations(zh); + addTearDown(app.shutdown); + app.addListener(() { + if (app.writeProgress?.completed == 1 && backend.connected) { + unawaited(backend.disconnect()); + } + }); + final progress = await app.sendCommandSequence( + [(4, 'write-ok'), (5, 'write-ok')], + delayBetween: Duration.zero, + ); + + expect(progress.status, WriteStatus.connectionLost); + expect(progress.cancelled, isTrue); + expect(progress.completed, 1); + expect(progress.succeeded, 1); + expect(progress.failed, 0); + expect(backend.commands, ['write-ok']); + final result = progress.results.single; + for (final l10n in [en, zh]) { + app.updateLocalizations(l10n); + expect(progress.localizedStatus(l10n), + l10n.messageConnectionLostOperationAborted); + expect(progress.results.single, same(result)); + expect(result.block, 4); + expect(result.localizedMessage(l10n), l10n.messageWriteSuccessful); + } + app.cancelWriteSequence(); + expect(progress.status, WriteStatus.connectionLost); + }); + + test('empty sequences finish without any block results or commands', + () async { + final backend = _WriteBackend(); + final app = _appState(backend); + addTearDown(app.shutdown); + final progress = await app.sendCommandSequence([]); + expect(progress.status, WriteStatus.finished); + expect(progress.isRunning, isFalse); + expect(progress.progress, 0); + expect(progress.results, isEmpty); + expect(backend.commands, isEmpty); + }); +} + +AppState _appState(_WriteBackend backend) => AppState( + connectionState: ConnectionState( + controller: Pm3Controller(backend), + settingsStore: _Settings(), + ), + ); + +class _Settings implements Pm3SettingsRepository { + @override + Future load() async => null; + @override + Future save(Pm3ClientSettings settings) async {} +} + +class _WriteBackend implements Pm3Backend { + _WriteBackend({this.commandGate}); + + final Completer? commandGate; + final _events = StreamController.broadcast(sync: true); + final commands = []; + bool connected = true; + + @override + Stream get events => _events.stream; + @override + Pm3ConnectionState get state => connected + ? Pm3ConnectionState.connected + : Pm3ConnectionState.disconnected; + @override + String get version => 'test'; + @override + String get lastError => ''; + @override + Future connect(Pm3ConnectionConfig config) async { + connected = true; + } + + @override + Future disconnect() async { + connected = false; + } + + @override + Future cancel() async {} + @override + Future shutdown() => _events.close(); + + @override + Future execute(Pm3Command command, {Duration? timeout}) async { + commands.add(command.executable); + final startedAt = DateTime.now(); + final output = switch (command.executable) { + 'write-auth-fail' => 'Auth error', + 'write-fail' => '( fail )', + _ => '[+] writing\n[+] written\n[+] done', + }; + for (final line in output.split('\n')) { + _events.add(Pm3OutputEvent(DateTime.now(), line)); + } + await commandGate?.future; + return Pm3Result( + command: command, + output: output, + startedAt: startedAt, + finishedAt: DateTime.now(), + ); + } +} diff --git a/test/support/dart_executable.dart b/test/support/dart_executable.dart new file mode 100644 index 0000000..7ab6290 --- /dev/null +++ b/test/support/dart_executable.dart @@ -0,0 +1,19 @@ +import 'dart:io'; + +String get dartExecutable { + final executable = File(Platform.resolvedExecutable); + final name = executable.uri.pathSegments.last.toLowerCase(); + if (name == 'dart' || name == 'dart.exe') return executable.path; + + final separator = Platform.pathSeparator; + final dartName = Platform.isWindows ? 'dart.exe' : 'dart'; + var directory = executable.parent; + while (directory.parent.path != directory.path) { + final candidate = File( + '${directory.path}${separator}dart-sdk${separator}bin$separator$dartName', + ); + if (candidate.existsSync()) return candidate.path; + directory = directory.parent; + } + throw StateError('Unable to locate the Dart SDK beside $executable'); +} diff --git a/test/support/file_dialogs.dart b/test/support/file_dialogs.dart new file mode 100644 index 0000000..87f2cd8 --- /dev/null +++ b/test/support/file_dialogs.dart @@ -0,0 +1,38 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class TestFileDialogs { + TestFileDialogs() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(_channel, (call) async { + calls.add(call); + final selectedOpenPath = openPath; + return switch (call.method) { + 'getSavePath' => savePath, + 'openFile' => selectedOpenPath == null ? null : [selectedOpenPath], + _ => throw PlatformException( + code: 'unexpected-file-dialog', + message: 'Unexpected file dialog method: ${call.method}', + ), + }; + }); + addTearDown(() => messenger.setMockMethodCallHandler(_channel, null)); + } + + static const _channel = MethodChannel('plugins.flutter.io/file_selector'); + final calls = []; + String? savePath; + String? openPath; +} + +Future pumpFileOperation( + WidgetTester tester, bool Function() completed) async { + for (var attempt = 0; attempt < 100 && !completed(); attempt++) { + await tester + .runAsync(() => Future.delayed(const Duration(milliseconds: 10))); + await tester.pump(); + } + expect(completed(), isTrue, + reason: 'The file operation did not reach the expected UI state.'); +} diff --git a/test/support/localized_test_app.dart b/test/support/localized_test_app.dart new file mode 100644 index 0000000..bd194fe --- /dev/null +++ b/test/support/localized_test_app.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:pm3gui/l10n/app_locale_controller.dart'; +import 'package:pm3gui/l10n/l10n.dart'; +import 'package:pm3gui/services/locale_settings_store.dart'; + +class MemoryLocaleSettings implements LocaleSettingsRepository { + MemoryLocaleSettings([this.language]); + String? language; + + @override + Future load() async => language; + @override + Future save(String? languageCode) async => language = languageCode; +} + +class LocalizedTestApp extends StatelessWidget { + const LocalizedTestApp({ + super.key, + required this.home, + this.locale = const Locale('zh'), + }); + + final Widget home; + final Locale locale; + + @override + Widget build(BuildContext context) => ChangeNotifierProvider( + create: (_) => AppLocaleController(repository: MemoryLocaleSettings()), + child: MaterialApp( + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: home, + ), + ); +} diff --git a/test/tools/sync_command_catalog_test.dart b/test/tools/sync_command_catalog_test.dart new file mode 100644 index 0000000..9182767 --- /dev/null +++ b/test/tools/sync_command_catalog_test.dart @@ -0,0 +1,55 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import '../support/dart_executable.dart'; + +void main() { + final projectDirectory = Directory.current.absolute; + final tool = File( + '${projectDirectory.path}${Platform.pathSeparator}tools' + '${Platform.pathSeparator}sync_command_catalog.dart', + ); + + Future runTool(List arguments) => Process.run( + dartExecutable, + ['run', tool.path, ...arguments], + workingDirectory: projectDirectory.path, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); + + test('standalone Dart reaches the catalog tool usage handler', () async { + final result = await runTool([]); + + expect(result.exitCode, 2, reason: result.stderr.toString()); + expect(result.stderr, + contains('Usage: dart run tools/sync_command_catalog.dart')); + expect(result.stderr, isNot(contains("Dart library 'dart:ui'"))); + }); + + test('standalone Dart constructs the process and reports a missing client', + () async { + final directory = await Directory.systemTemp.createTemp('pm3-standalone-'); + try { + final missingClient = File( + '${directory.path}${Platform.pathSeparator}missing-proxmark3', + ); + final snapshot = File( + '${projectDirectory.path}${Platform.pathSeparator}docs' + '${Platform.pathSeparator}pm3_commands_client.json', + ); + final before = await snapshot.readAsBytes(); + + final result = await runTool([missingClient.path, 'test-port']); + + expect(result.exitCode, 1, reason: result.stderr.toString()); + expect(result.stderr, contains('Help group "connection":')); + expect(result.stderr, contains(missingClient.path)); + expect(result.stderr, isNot(contains("Dart library 'dart:ui'"))); + expect(await snapshot.readAsBytes(), before); + } finally { + await directory.delete(recursive: true); + } + }); +} diff --git a/test/ui/command_card_editor_test.dart b/test/ui/command_card_editor_test.dart index 6e8f2f8..3c94fca 100644 --- a/test/ui/command_card_editor_test.dart +++ b/test/ui/command_card_editor_test.dart @@ -5,11 +5,15 @@ import 'package:provider/provider.dart'; import 'package:pm3gui/backend/mock/mock_pm3_backend.dart'; import 'package:pm3gui/core/pm3/pm3_client_settings.dart'; import 'package:pm3gui/core/pm3/pm3_controller.dart'; +import 'package:pm3gui/l10n/l10n.dart'; +import 'package:pm3gui/models/mifare_card.dart'; import 'package:pm3gui/services/pm3_settings_store.dart'; import 'package:pm3gui/services/pm3_session_recorder.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/state/connection_state.dart' as connection; import 'package:pm3gui/ui/widgets/command_card_editor.dart'; +import '../support/localized_test_app.dart'; +import '../support/file_dialogs.dart'; void main() { testWidgets( @@ -45,7 +49,8 @@ void main() { })); Widget editor() => ChangeNotifierProvider.value( value: app, - child: const MaterialApp(home: Scaffold(body: CommandCardEditor()))); + child: + const LocalizedTestApp(home: Scaffold(body: CommandCardEditor()))); await tester.pumpWidget(editor()); await tester.enterText(find.byType(TextField), 'hf mf chk --1k'); await tester.tap(find.text('执行')); @@ -74,6 +79,176 @@ void main() { expect(find.textContaining('块 0 尚未完整'), findsOneWidget); await tester.pumpWidget(const SizedBox()); }); + + testWidgets( + 'cancelled dialogs retain save status and opening a draft clears it across locales', + (tester) async { + tester.view.physicalSize = const Size(1400, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final directory = Directory.systemTemp.createTempSync('editor-status-'); + addTearDown(() => directory.deleteSync(recursive: true)); + final path = '${directory.path}${Platform.pathSeparator}command-card.json'; + final dialogs = TestFileDialogs()..savePath = path; + final app = _statusApp(); + final zh = lookupAppLocalizations(const Locale('zh')); + try { + await _pumpEditor(tester, app, const Locale('zh')); + await tester.tap(find.text(zh.messageSaveDraft)); + await pumpFileOperation( + tester, () => find.text(zh.messageSaved(path)).evaluate().isNotEmpty); + + dialogs.savePath = null; + await tester.tap(find.text(zh.messageSaveDraft)); + await tester.pumpAndSettle(); + expect(find.text(zh.messageSaved(path)), findsOneWidget); + await tester.tap(find.text(zh.messageOpenDraft)); + await tester.pumpAndSettle(); + expect(find.text(zh.messageSaved(path)), findsOneWidget); + + await _pumpEditor(tester, app, const Locale('en')); + final en = lookupAppLocalizations(const Locale('en')); + expect(find.text(en.messageSaved(path)), findsOneWidget); + expect(find.text(zh.messageSaved(path)), findsNothing); + dialogs.openPath = path; + await tester.tap(find.text(en.messageOpenDraft)); + await pumpFileOperation( + tester, () => find.byType(SelectableText).evaluate().isEmpty); + for (final language in ['zh', 'en']) { + await _pumpEditor(tester, app, Locale(language)); + expect(find.byType(SelectableText), findsNothing); + expect(app.commandCard.uid, '01020304'); + } + expect(dialogs.calls.map((call) => call.method), + ['getSavePath', 'getSavePath', 'openFile', 'openFile']); + } finally { + await tester.pumpWidget(const SizedBox()); + app.dispose(); + await tester.pump(); + } + }); + + for (final failure in [ + 'save validation', + 'save write', + 'draft import', + 'card import', + ]) { + testWidgets( + '$failure replaces a save status without reviving it on locale changes', + (tester) async { + tester.view.physicalSize = const Size(1400, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final directory = Directory.systemTemp.createTempSync('editor-status-'); + addTearDown(() => directory.deleteSync(recursive: true)); + final path = + '${directory.path}${Platform.pathSeparator}command-card.json'; + final dialogs = TestFileDialogs()..savePath = path; + final app = _statusApp(); + final invalidCard = MifareCard(blocks: ['BAD']); + final zh = lookupAppLocalizations(const Locale('zh')); + try { + await _pumpEditor(tester, app, const Locale('zh'), + openedCard: invalidCard); + await tester.tap(find.text(zh.messageSaveDraft)); + await pumpFileOperation(tester, + () => find.text(zh.messageSaved(path)).evaluate().isNotEmpty); + final saved = File(path).readAsStringSync(); + switch (failure) { + case 'save validation': + await tester.tap(find.text(zh.messageExportBin2)); + case 'save write': + dialogs.savePath = directory.path; + await tester.tap(find.text(zh.messageSaveDraft)); + case 'draft import': + final invalidPath = + '${directory.path}${Platform.pathSeparator}invalid.json'; + File(invalidPath).writeAsStringSync('{"format":"invalid"}'); + dialogs.openPath = invalidPath; + await tester.tap(find.text(zh.messageOpenDraft)); + case 'card import': + await tester.tap(find.text(zh.messageReplaceDraftWithTheOpenFile)); + } + final errorFinder = find.byWidgetPredicate((widget) => + widget is SelectableText && widget.data != zh.messageSaved(path)); + await pumpFileOperation( + tester, () => errorFinder.evaluate().isNotEmpty); + final error = tester.widget(errorFinder).data!; + switch (failure) { + case 'save validation': + expect(error, + 'FormatException: ${zh.messageBlockIsIncompleteSaveADraftOr('0')}'); + case 'save write': + expect(error, matches(r'^(?:FileSystem|PathAccess)Exception:')); + expect(error, contains(directory.path)); + case 'draft import': + expect(error, + 'FormatException: ${zh.messageThisIsNotACommandDataDraft}'); + case 'card import': + expect(error, + 'FormatException: ${zh.messageEnterHexDigitsUseForUnknownDigits('32')}'); + } + + for (final language in ['en', 'zh']) { + final l10n = lookupAppLocalizations(Locale(language)); + await _pumpEditor(tester, app, Locale(language), + openedCard: invalidCard); + expect(find.text(error), findsOneWidget); + expect(find.text(l10n.messageSaved(path)), findsNothing); + expect(app.commandCard.exportDraft(), saved); + } + final recoveryPath = + '${directory.path}${Platform.pathSeparator}recovered.json'; + dialogs.savePath = recoveryPath; + await tester.tap(find.text(zh.messageSaveDraft)); + await pumpFileOperation( + tester, + () => + find.text(zh.messageSaved(recoveryPath)).evaluate().isNotEmpty); + expect(find.text(error), findsNothing); + await _pumpEditor(tester, app, const Locale('en'), + openedCard: invalidCard); + final en = lookupAppLocalizations(const Locale('en')); + expect(find.text(en.messageSaved(recoveryPath)), findsOneWidget); + expect(find.text(en.messageSaved(path)), findsNothing); + expect(find.text(error), findsNothing); + } finally { + await tester.pumpWidget(const SizedBox()); + app.dispose(); + await tester.pump(); + } + }); + } +} + +AppState _statusApp() => _OfflineAppState( + connectionState: connection.ConnectionState( + controller: Pm3Controller(MockPm3Backend()), + settingsStore: _Settings(), + ), + )..commandCard.uid = '01020304'; + +Future _pumpEditor(WidgetTester tester, AppState app, Locale locale, + {MifareCard? openedCard}) async { + app.updateLocalizations(lookupAppLocalizations(locale)); + await tester.pumpWidget(ChangeNotifierProvider.value( + value: app, + child: LocalizedTestApp( + locale: locale, + home: Scaffold(body: CommandCardEditor(openedCard: openedCard)), + ), + )); + await tester.pumpAndSettle(); +} + +class _OfflineAppState extends AppState { + _OfflineAppState({required super.connectionState}); + + @override + Future scanForFiles() async {} } class _Settings implements Pm3SettingsRepository { diff --git a/test/ui/hf_mfdes_page_test.dart b/test/ui/hf_mfdes_page_test.dart new file mode 100644 index 0000000..3d659d1 --- /dev/null +++ b/test/ui/hf_mfdes_page_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pm3gui/l10n/l10n.dart'; +import 'package:pm3gui/ui/pages/hf_mfdes_page.dart'; +import '../support/localized_test_app.dart'; + +void main() { + for (final language in ['en', 'zh']) { + testWidgets('DESFire uses an applications noun in $language', + (tester) async { + tester.view.physicalSize = const Size(1200, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final localizations = lookupAppLocalizations(Locale(language)); + final applications = language == 'en' ? 'Applications' : '应用'; + final apply = language == 'en' ? 'Apply' : '应用'; + await tester.pumpWidget(LocalizedTestApp( + locale: Locale(language), + home: const Scaffold(body: HfMfdesPage()), + )); + await tester.pumpAndSettle(); + + final tab = find.descendant( + of: find.byType(TabBar), + matching: find.text(applications), + ); + expect(tab, findsOneWidget); + await tester.tap(tab); + await tester.pumpAndSettle(); + expect(find.text(localizations.messageListApplications), findsOneWidget); + expect(localizations.messageApply, apply); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/test/ui/lf_hid_page_test.dart b/test/ui/lf_hid_page_test.dart new file mode 100644 index 0000000..a81fbeb --- /dev/null +++ b/test/ui/lf_hid_page_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pm3gui/l10n/l10n.dart'; +import 'package:pm3gui/services/pm3_commands.dart'; +import 'package:pm3gui/ui/pages/lf_hid_page.dart'; +import '../support/localized_test_app.dart'; + +void main() { + for (final language in ['en', 'zh']) { + testWidgets('HID reader brute-force labels are distinct in $language', + (tester) async { + tester.view.physicalSize = const Size(1200, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(LocalizedTestApp( + locale: Locale(language), + home: const Scaffold(body: LfHidPage()), + )); + await tester.pumpAndSettle(); + + final tabLabel = language == 'en' ? 'Reader brute-force' : '读卡器暴力枚举'; + final title = language == 'en' + ? 'Brute-force facility codes / card numbers' + : '枚举设施码 / 卡号'; + final start = language == 'en' ? 'Start brute-force' : '开始暴力枚举'; + await tester.tap(find.descendant( + of: find.byType(TabBar), + matching: find.text(tabLabel), + )); + await tester.pumpAndSettle(); + expect(find.text(title), findsOneWidget); + expect(find.text(start), findsOneWidget); + expect(find.text('Key recovery'), findsNothing); + expect(find.text('Brute-force recovery'), findsNothing); + expect(find.text('Start recovery'), findsNothing); + expect(lookupAppLocalizations(Locale(language)).messageKeyRecovery, + language == 'en' ? 'Key recovery' : '破解'); + await tester.enterText(find.byType(TextFormField), '123'); + + final otherLanguage = language == 'en' ? 'zh' : 'en'; + final otherL10n = lookupAppLocalizations(Locale(otherLanguage)); + await tester.pumpWidget(LocalizedTestApp( + locale: Locale(otherLanguage), + home: const Scaffold(body: LfHidPage()), + )); + await tester.pumpAndSettle(); + expect(find.text(otherL10n.hidReaderBruteForceTitle), findsOneWidget); + expect(find.text(otherL10n.hidStartBruteForce), findsOneWidget); + expect(find.text('123'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + + test('HID brute-force command syntax is unchanged', () { + expect(LfHidCmd.brute(), 'lf hid brute'); + expect(LfHidCmd.brute(fc: '123'), 'lf hid brute --fc 123'); + }); +} diff --git a/test/ui/page_output_test.dart b/test/ui/page_output_test.dart index 825c064..cb09ee8 100644 --- a/test/ui/page_output_test.dart +++ b/test/ui/page_output_test.dart @@ -15,15 +15,20 @@ import 'package:pm3gui/services/pm3_settings_store.dart'; import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/state/connection_state.dart' as connection; import 'package:pm3gui/ui/pages/hf_mfu_page.dart'; +import '../support/localized_test_app.dart'; void main() { - testWidgets('one click streams immediately beyond five seconds until page switch', (tester) async { + testWidgets( + 'one click streams immediately beyond five seconds until page switch', + (tester) async { late _StreamingBackend backend; late AppState state; await tester.runAsync(() async { backend = _StreamingBackend(); - state = AppState(connectionState: connection.ConnectionState( - controller: Pm3Controller(backend), settingsStore: _Settings(), + state = AppState( + connectionState: connection.ConnectionState( + controller: Pm3Controller(backend), + settingsStore: _Settings(), )); await state.initialize(); }); @@ -31,7 +36,7 @@ void main() { addTearDown(state.dispose); await tester.pumpWidget(ChangeNotifierProvider.value( value: state, - child: const MaterialApp(home: Scaffold(body: HfMfuPage())), + child: const LocalizedTestApp(home: Scaffold(body: HfMfuPage())), )); await tester.tap(find.text('获取信息')); await tester.pump(); @@ -70,12 +75,18 @@ void main() { await tester.runAsync(state.shutdown); }); - testWidgets('catalog includes installed client commands and retains localized entries', (tester) async { + testWidgets( + 'catalog includes installed client commands and retains localized entries', + (tester) async { final entries = (await tester.runAsync(() => Pm3CommandCatalog.load()))!; final commands = entries.map((e) => e.command).toSet(); - expect(commands, containsAll(['hf mf sen', 'hf mf gdmgetcfg', 'hf mf madread', 'hf mf wrbl'])); + expect( + commands, + containsAll( + ['hf mf sen', 'hf mf gdmgetcfg', 'hf mf madread', 'hf mf wrbl'])); expect(entries.where((e) => e.command == 'hf 14a info'), hasLength(1)); - expect(entries.firstWhere((e) => e.command == 'hf 14a info').description, contains('用法')); + expect(entries.firstWhere((e) => e.command == 'hf 14a info').description, + contains('用法')); }); } @@ -113,7 +124,10 @@ class _StreamingBackend implements Pm3Backend { final started = DateTime.now(); emit('FIRST_ROW'); await done.future; - return Pm3Result(command: command, output: 'FIRST_ROW\nLATE_ROW', - startedAt: started, finishedAt: DateTime.now()); + return Pm3Result( + command: command, + output: 'FIRST_ROW\nLATE_ROW', + startedAt: started, + finishedAt: DateTime.now()); } } diff --git a/test/ui/settings_page_test.dart b/test/ui/settings_page_test.dart index 1fd1ad1..80874a2 100644 --- a/test/ui/settings_page_test.dart +++ b/test/ui/settings_page_test.dart @@ -8,6 +8,7 @@ import 'package:pm3gui/state/app_state.dart'; import 'package:pm3gui/state/connection_state.dart' as pm3; import 'package:pm3gui/ui/pages/settings_page.dart'; import 'package:provider/provider.dart'; +import '../support/localized_test_app.dart'; void main() { testWidgets('settings page shows a consumed save error', (tester) async { @@ -25,7 +26,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider.value( value: state, - child: const MaterialApp(home: Scaffold(body: SettingsPage())), + child: const LocalizedTestApp(home: Scaffold(body: SettingsPage())), ), ); diff --git a/tools/check_localizations.dart b/tools/check_localizations.dart new file mode 100644 index 0000000..a352203 --- /dev/null +++ b/tools/check_localizations.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; +import 'dart:io'; + +/// Run from the repository root; no packages or Flutter engine are required. +void main() { + final directory = Directory('lib${Platform.pathSeparator}l10n'); + final template = + _read(File('${directory.path}${Platform.pathSeparator}app_en.arb')); + final keys = template.keys.where((key) => !key.startsWith('@')).toSet(); + final failures = []; + for (final file in directory + .listSync() + .whereType() + .where((file) => file.path.endsWith('.arb'))) { + final messages = _read(file); + final actual = messages.keys.where((key) => !key.startsWith('@')).toSet(); + final missing = keys.difference(actual); + final extra = actual.difference(keys); + if (missing.isNotEmpty) { + failures.add('${file.path}: missing ${missing.join(', ')}'); + } + if (extra.isNotEmpty) { + failures.add('${file.path}: extra ${extra.join(', ')}'); + } + for (final key in actual) { + final value = messages[key]; + if (value is! String || value.trim().isEmpty) { + failures.add('${file.path}: $key must be a nonempty string'); + } + } + } + for (final key in keys) { + if (!template.containsKey('@$key')) { + failures.add('app_en.arb: $key needs translator metadata'); + } + if (RegExp(r'[\u3400-\u9fff]').hasMatch(template[key] as String)) { + failures.add('app_en.arb: $key contains untranslated Chinese'); + } + } + if (failures.isNotEmpty) { + stderr.writeln(failures.join('\n')); + exitCode = 1; + } else { + stdout.writeln( + 'Localization resources complete: ${keys.length} messages per language.'); + } +} + +Map _read(File file) => + jsonDecode(file.readAsStringSync()) as Map;